软件工程第二次个人作业

用 Python + AIGC 完成「一箭又一箭」小游戏

项目 内容
这个作业属于哪个课程 2026 秋软件工程与软件工程实践
这个作业要求在哪里 2026 秋软件工程个人作业(第二次)
这个作业的目标 使用 Python 和 AIGC 完成"一箭又一箭"小游戏
学号 102402113
GitHub 仓库 https://github.com/liuliu223377/one-arrow-after-another

一、项目展示

下面这段gif是完整的演示:开始界面 → 点「提示」看光圈 → 通关第 1 关拿三星 →进入第 2 关
→ 故意点错被挡 → 点「撤销」把失误还回来 → 点「自动求解」交给 AI 走完 →
最后进选关界面看解锁进度。

游戏演示

基础界面:

开始界面 游戏界面
开始界面 游戏界面
箭头飞出棋盘 被挡住时的碰撞反馈
飞出 被挡住
第 5 关(6×6 大棋盘) 通关结算(含星级与用时)
第 5 关 通关
失败结算 全部通关
失败 全部通关

附加功能:

提示功能(光圈套在能飞出的箭头上) AI 自动求解(逐步播放)
提示 自动求解
关卡选择与进度保存
选关

上面所有截图和 GIF 都不是手动录屏的,是写了两个脚本自动生成的,不用每次优化自己录频:
用 SDL 的 dummy 显示驱动在内存里逐帧渲染,再存成 PNG / 合成 GIF。
两个脚本分别是 tools/screenshot.pytools/demo_gif.py(GIF)。


二、项目介绍

2.1 游戏规则

「一箭又一箭」是一类点击式箭头解谜游戏。棋盘是一个网格,每个格子上最多有一支箭头,
方向分上、下、左、右四种。玩家点击箭头,程序检查它前进方向到棋盘边界之间还有没有别的箭头:

  • 没有阻挡 → 箭头飞出去,从棋盘上消失;
  • 有阻挡 → 箭头飞不出去,会朝挡路的方向"顶"一下再弹回来,挡它的那支箭头闪红,
    同时失误次数减 1

清空一关的全部箭头即可过关;失误次数用完则本关失败,可以随时重来。

判断规则只涉及同行或同列的直线,不需要处理转弯:

→  ·  ·  ↑  ·        第一个箭头朝右,右边还有箭头 → 飞不出去
↑  ·  ·  ·  →        最后一个箭头朝右,右边没有东西 → 正常飞出

2.2 界面设计

界面分三块,从上到下:

  1. 顶部信息栏:关卡名、关卡提示语,以及三张数据卡片——剩余箭头数(4/4 这种
    带总数的写法)、剩余失误数、本关用时。失误数快用完时数字会从绿色变黄、再变红,
    给玩家一个心理准备。
  2. 中间棋盘:格子 + 箭头。鼠标悬停的格子会高亮;用提示功能时,
    目标格子上会套一圈会"呼吸"的蓝色光圈。出题方和玩家之间的"对话"全在这里发生。
  3. 底部操作区:一排四个按钮——提示 / 撤销 / 自动求解 / 重新开始
    以及一行状态提示文字("嗖 —— 箭头飞出去了" / "前方有箭头挡路!")。
    按钮不可用时(比如没得撤销、提示用完了)会压暗,一眼能看出点不了。

配色上全部用了深色背景 + 高对比度的白色箭头,只有三个地方出现彩色:
悬停是黄色、挡路是红色、通关是绿色,让玩家的注意力自然地落在需要关注的地方。

2.3 主要功能

基础要求:

  • 完整的界面:开始界面(含玩法说明)、选关界面游戏界面
    通关/失败/全部通关结算界面
  • 四种方向的箭头,路径阻挡判断与边界处理;
  • 箭头飞出的位移动画(越飞越淡)+ 被挡住时的晃动动画 + 挡路箭头闪红;
  • 失误次数统计与显示,用完即失败;
  • 5 个关卡,通关后自动进入下一关,最后一关结束显示"全部通关";
  • 重新开始:本关布局和失误次数一并恢复;
  • 键盘快捷键:R 重开、H 提示、U/Z 撤销、S 自动求解、M 选关、
    回车/空格 继续、Esc 退出。

附加功能(详见第四节):

  • 计时与星级评价:每关走表,通关时按失误次数评 1~3 星;
  • 提示功能:每关 3 次,高亮一支当前能飞出去的箭头;
  • 撤销上一步:回退最近一次点击,失误次数也一并还回来;
  • AI 自动求解:一键算出本关解法,并逐步播放飞出动画;
  • 关卡选择与进度保存:选关界面显示解锁状态与历史最好成绩,进度存 JSON 文件。

2.4 特色

① 零资源文件。 整个项目不需要任何图片、字体、音频文件——
箭头是代码算出来的多边形,按钮和面板是 pygame.draw 画的,
中文直接调用系统已有字体。所以把文件夹拷到任何一台装好 pygame 的电脑上都能直接跑,
不会出现"缺素材跑不起来"的问题。

② 规则和画面彻底分离。 board.py(棋盘与路径检测)和 gamestate.py(失误、关卡进度)
里面没有一行 import pygame,是纯粹的 Python 逻辑;界面层只负责把状态画出来。
好处是游戏规则可以脱离图形界面直接做单元测试——本项目的 24 个自动化测试就是这么跑起来的。

③ 关卡用"所见即所得"的字符网格描述,并且有可解性校验。
(详见 3.3 和 3.4)


三、实现思路

3.1 棋盘、方向怎么表示

棋盘坐标用 (row, col) 元组表示,row 向下增大、col 向右增大,左上角是 (0, 0)
棋盘本身用一个字典存:键是格子坐标,值是箭头方向,字典里没有的格子就是空格。

self.arrows = {(0, 1): RIGHT, (1, 0): UP, ...}

这样"某个格子有没有箭头"就是一次 in 判断,"还剩几支箭头"就是 len()
不需要额外维护一个二维数组再到处判空。

方向用四个字符串常量 UP / DOWN / LEFT / RIGHT 表示,再配一张方向增量表
把"方向"翻译成"行列各走几格":

DIRECTION_DELTA = {
    UP:    (-1, 0),   # 行减一 = 往上
    DOWN:  ( 1, 0),
    LEFT:  ( 0, -1),  # 列减一 = 往左
    RIGHT: ( 0,  1),
}

这张表是整个项目的地基。有了它,"往某个方向走一格"就统一成了 (r + dr, c + dc)
四个方向共用同一套代码,不用写四遍 if direction == UP: ... elif ...

3.2 路径检测(核心)

这是整个游戏最核心的一段逻辑。思路很朴素:从箭头所在位置出发,沿着它的方向一格一格往外走——

  • 如果走到的格子在棋盘内有箭头,说明被挡住了,返回那支箭头的坐标;
  • 如果走到棋盘外,说明这一路上都没有东西,箭头可以飞出去。
def blocker_of(self, pos):
    """返回 pos 前进方向上第一个挡路的箭头坐标;畅通时返回 None。"""
    direction = self.arrows[pos]
    dr, dc = DIRECTION_DELTA[direction]
    r, c = pos[0] + dr, pos[1] + dc
    while self.is_inside((r, c)):      # 还在棋盘里就继续走
        if (r, c) in self.arrows:      # 撞到箭头了
            return (r, c)
        r += dr
        c += dc
    return None                        # 一路走到棋盘外,说明畅通

is_inside() 就是 0 <= r < rows and 0 <= c < cols
边界处理直接交给循环条件,不需要在循环里写特殊的判断,
所以"朝棋盘外飞"这种情况天然不会越界(T03 测试专门验证了这一点,包括 1×1 的棋盘)。

这里有一个我自己做的设计决定:函数返回的不是 True/False,而是挡路那支箭头的坐标。

一开始我写的是 path_is_clear(pos) -> bool,够用,但界面层拿到一个布尔值之后
只能知道"点错了",没法告诉玩家是谁挡的。改成返回坐标之后,
界面就能把挡路的那支箭头标成红色——玩家一眼就知道该先清谁,
不用自己再沿着方向数一遍。所以现在 blocker_of() 是主函数,
path_is_clear() 变成了它的一层薄封装:

def path_is_clear(self, pos):
    return self.blocker_of(pos) is None

3.3 关卡怎么表示

关卡写在 levels.py 里,用的是一种"所见即所得"的字符网格:

{
    "name": "第 2 关 · 连锁",
    "hint": "先清掉敞开的那几个,别的箭头才有路可走",
    "mistakes": 5,
    "grid": """
        →  →  .  ↓
        .  ↑  .  ↓
        .  ↑  .  .
        .  ←  ←  .
    """,
}

↑ ↓ ← → 表示四个方向的箭头,. 表示空格。这样在代码里排出来的形状,
和实际游戏画面是一致的,改关卡的时候不用在脑子里做坐标换算。
解析函数还兼容 ASCII 写法(^ v < >)和不带空格的连写,方便快速录入。

3.4 怎么保证关卡一定能通关

这是我在这个项目里踩得最深的一个坑。

关卡的难点在于:箭头是互相阻挡的,如果布局没设计好,很容易出现
"一圈箭头首尾相接、谁也飞不出去"的死局——游戏直接卡死,玩家怎么点都过不了。

所以光靠肉眼看是不够的,需要程序来验证。这里用到一个性质:

拿走一支箭头只会给别的箭头让路,永远不会让局面变得更糟。

也就是说,"当前能飞出去的箭头"这个集合只会越来越大,不会缩小。
所以从一个局面出发,无脑地"能飞就飞",如果都清不完,那这关就真的无解
反过来,如果这样能清完,那这关一定可通。不需要回溯搜索。

def find_solution(board):
    """能清完就返回一条可行的点击顺序,无解返回 None。"""
    order = []
    while not board.is_cleared():
        movable = board.movable_positions()
        if not movable:
            return None          # 卡住了,说明无解
        pos = movable[0]
        board.click(pos)
        order.append(pos)
    return order

配一个命令行脚本 tools/check_levels.py,每次改完关卡跑一遍,
它会把每一关的棋盘大小、箭头数量、一条可行的通关顺序都打印出来:

[通过] 第 4 关 · 螺旋
       棋盘 5x5,箭头 16 个,最少 16 步
       一条可行顺序:5行5列 -> 4行5列 -> 3行5列 -> 2行5列 -> 1行5列 -> ...

所有关卡必须先过这个校验,才会被收进 levels.py 这个工具同时也是单元测试的一部分。

3.5 动画

飞出的箭头和晃动效果都放在 effects.py 里,关键的一点是:
所有速度都按"每秒多少"来定义,每帧乘上 dt 再累加。

dt = clock.tick(FPS) / 1000.0     # 换算成秒
self.x += vx * dt                 # 而不是 self.x += vx

这样在高刷屏和普通屏上,动画的快慢是一样的。

晃动效果用的是一条衰减的正弦曲线——开头朝着挡路的方向"顶"出去,
然后来回摆几次、幅度越来越小,最后停回原位,
模拟出"撞了一下被弹回来"的手感:

amplitude = SHAKE_AMPLITUDE * (1 - progress) ** 1.5 * math.sin(progress * math.pi * SHAKE_CYCLES * 2)

四、附加功能的实现

基础要求做完之后,又挑了几个扩展功能来做。挑的标准是:
能看出效果(截图里一眼能分辨)、并且能干净地接在现有架构上
音效和打包 exe 没有做:音效会破坏"零资源文件"这个设计,
打包成 exe 依赖具体的 Python 环境,换个机器不一定跑得起来。

4.1 计时与星级评价

每关从进入时开始走表,只在"正在玩"的阶段累加;通关、失败之后表就停了。
信息栏从两张卡片扩成三张(剩余箭头 / 剩余失误 / 用时),
用时按 mm:ss 显示。

星级按失误次数评,规则很简单:

def compute_stars(mistakes_used, allowance):
    if mistakes_used <= 0:            # 一次没失误
        return 3
    if mistakes_used * 2 <= allowance:  # 用掉不到一半额度
        return 2
    return 1

另外还记了一个跨关累计的 total_elapsed,只在"通关进入下一关"时累加,
中途重开的那些尝试不算进去——最后"全部通关"的结算显示的是
真正从头走到尾用掉的时间。

4.2 提示功能

每关 3 次,点一下就在一支"现在能飞出去"的箭头上套一圈会呼吸的蓝色光圈。

这个功能能用两行代码写完,靠的还是 3.4 里那条单调性:

def use_hint(self):
    movable = self.board.movable_positions()
    if not movable:
        return None
    self.hints_left -= 1
    return movable[0]

为什么随便挑一支都行? 因为拿走箭头只会给别人让路,不会把可解的关卡变难,
所以"当前能飞的箭头"里每一支都是安全的,随便挑一支提示给玩家,
都不会把他带进死路。如果关卡不是单调的,提示就得先做一次搜索才能保证不害人。

光圈的呼吸相位由 dt 累加得到,不读系统时钟——这样录 GIF 的时候动画也是对的。

4.3 撤销上一步

维护一个本关的点击历史栈,每步记下 {格子, 类型, 方向}
方向必须在 board.click() 之前取,因为箭头飞走之后就查不到方向了,
而撤销要靠它把箭头原样放回去。

做的时候特意让撤销把失误次数也还回来——不然它只是个装饰:
玩家点错一下照样白赔一次失误额度,撤销就没意义了。现在点错了可以撤,
失误次数退回原值,这才是这个功能真正有用的地方。

有一个地方是故意没做的:失败之后不能撤销
can_undo 要求当前处于 PHASE_PLAYING,一旦失误用光进入失败状态,
就不能靠连点撤销把自己救回来。这是为了不让"失误次数"这个核心机制失效——
失败界面给的是"重玩本关"。

4.4 AI 自动求解

3.4 里那个用来校验关卡可解性的 find_solution(),在这里直接变成了游戏功能:
点一下「自动求解」,程序算出本关的解法,然后按 0.55 秒一步的节拍替你点出来,
每一步都走正常的点击路径,所以飞出动画、失误判定全都一致。

两个细节:

  • 在棋盘副本上试算。 solution() 复制当前局面再解,绝不会动到玩家正在玩的那一盘:

    def solution(self):
        probe = Board.from_level(self.level)
        probe.arrows = dict(self.board.arrows)   # 从当前局面接着往下解
        return find_solution(probe)
    
  • 从当前局面解,而不是从头解。 玩家已经自己飞掉几支了,剩下的解法要接着这个局面算。

把手动点击和自动求解合并成了一个 perform_move(),两条路走同一段代码,
省得以后改动画的时候漏掉一边。

4.5 关卡选择与进度保存

新增了一个选关界面:每关一张卡片,显示解锁状态、拿到的星级、历史最好用时。
"通关一关才解锁下一关",没解锁的卡片整张压暗。

进度存在项目根目录的 savegame.json 里。有几个地方是刻意处理的:

  • 原子写入。 先写 savegame.json.tmp,成功了再 os.replace() 替换掉原文件。
    直接覆盖的话,万一写到一半断电,原存档就毁了。

    with open(tmp, "w", encoding="utf-8") as fp:
        json.dump(payload, fp, ensure_ascii=False, indent=2)
    os.replace(tmp, self.path)
    
  • 坏存档不能影响玩游戏。 文件不存在、内容不是 JSON、字段类型不对,
    一律当成"没有存档"。最坏情况是进度丢了,但游戏照常能打开。
    载入时逐个字段校验,坏掉的条目直接丢掉,不连累其他关卡。

  • 成绩只会变好不会变差。 重玩一关成绩差了,不会把之前的纪录覆盖掉,
    每个字段各取"更好的那个"。

  • savegame.json 加进了 .gitignore 它是玩家数据,每台机器都不一样,
    不应该进版本库——不然下次 git pull 会跟本地进度打架。


五、AIGC 使用过程

本项目使用 Claude Code 辅助开发。AI 参与的部分包括需求拆解、模块划分、
路径检测算法、动画参数调优、关卡布局设计、单元测试用例编写。

但 AI 给的东西并不是拿来就能用的,下面记录几次真实发生返工的协作过程。

记录 1:棋盘数据结构与路径检测

项目 内容
子任务 设计棋盘的数据结构,实现四个方向的路径检测
借助何种 AIGC 技术 Claude Code
AI 实现或提供了什么 {(row, col): direction} 字典存棋盘,配一张方向增量表 DIRECTION_DELTA;路径检测写成沿方向逐格推进的循环,返回"前方是否畅通"的布尔值
效果如何 检测逻辑一次就跑通了,四个方向共用一套代码,边界处理也正确
人工修改 把返回布尔值改成返回挡路箭头的坐标

为什么要改: 拿到 True/False 之后,界面层只知道"点错了",没法告诉玩家是谁挡的
玩家点了一支飞不出去的箭头,只能自己再沿着方向一格一格数过去。
改成返回坐标之后,界面可以把挡路的那支箭头标红,玩家一眼就知道该先清谁。

记录 2:关卡设计(AI 给出的关卡其实无解)

项目 内容
子任务 设计 5 个由易到难的关卡
借助何种 AIGC 技术 Claude Code
AI 实现或提供了什么 字符网格的关卡表示法,以及若干套箭头布局;其中有一套是"四边箭头首尾相接围成一圈"的对称设计
效果如何 那一关完全无法通关——四边的箭头互相顶住,谁的前方都有别的箭头,点哪一支都是失误,游戏卡死
人工修改 放弃该布局,改成"外圈留一个出口"的螺旋结构;并写了 find_solution() 做可解性校验,要求所有关卡必须先过校验

问题不在于 AI 写错了代码,
而在于布局的对称性看着很美,但带来了死锁,而这一点光看图是看不出来的。
后来把"可解性"变成了一个程序化的检查项,从此再也没出现过无解关卡——
这件事让我意识到,对游戏设计来说,"能验证"比"看起来对"重要得多

记录 3:碰撞动画的帧率问题

项目 内容
子任务 被挡住时的箭头晃动动画
借助何种 AIGC 技术 Claude Code
AI 实现或提供了什么 用衰减正弦曲线实现晃动;但动画的推进是按帧数计算的
效果如何 在高帧率下看起来正常,但我用较大的时间步长逐帧导出检查时,发现采样点正好落在正弦的零点上,连续几帧位移都是 0,看起来像"卡住不动"
人工修改 把所有动画速度改成按秒定义,每帧乘 dt;并调小了晃动周期数

如何发现的: 我写了个脚本把动画逐帧导出成一张横向的长图来检查效果,
结果发现中间有连续几帧箭头纹丝不动。如果只是"跑起来看一眼",这个问题很可能被漏掉。

改成按秒计算之后,动画在任何帧率下的观感都一致了。
这个坑其实和游戏逻辑无关,但它是"看起来没问题"和"确认没问题"的差别。

记录 4:结算面板的出现时机

项目 内容
子任务 通关 / 失败后的结算面板
借助何种 AIGC 技术 Claude Code
AI 实现或提供了什么 半透明遮罩 + 居中面板的结算界面,状态一变就立刻弹出
效果如何 功能正确,但通关最后一支箭飞出的动画被面板盖住了,玩家还没看到"最后一箭飞出去"就被弹窗打断,观感很突兀
人工修改 加了一个 0.55 秒的延迟,等飞出动画播完再弹面板;中间这段时间先什么都不画

记录 5:requirements.txt 的编码问题

项目 内容
子任务 编写运行说明与依赖文件
借助何种 AIGC 技术 Claude Code
AI 实现或提供了什么 requirements.txt,并贴心地加了中文注释说明用途
效果如何 pip install -r requirements.txt 直接报错UnicodeDecodeError: 'gbk' codec can't decode byte 0xa1
人工修改 把注释改成英文,文件保持纯 ASCII;并在 README 里记录了这个坑

原因: pip 读取 requirements.txt 时,如果文件没有 BOM,
会用系统默认编码来解码,而中文 Windows 的默认编码是 GBK,遇到 UTF-8 的中文字节就炸了。
.py 源码不受影响——Python 解析源码固定按 UTF-8。

这个 Bug 是真实运行时才暴露的:AI 生成的代码"逻辑没问题",
但在中文 Windows 这个具体环境里就是跑不起来
。这类问题不实际跑一遍是发现不了的。

记录 6:加第三张信息卡之后,文字差点撞在一起

项目 内容
子任务 附加功能:在信息栏里加一张"用时"卡片
借助何种 AIGC 技术 Claude Code
AI 实现或提供了什么 卡片从 2 张扩到 3 张,并把"剩余箭头"的标签写成 剩余箭头 4/4(把总数塞进标签里)
效果如何 第 1 关看着没问题,但第 5 关有 20 支箭头,标签变成 剩余箭头 20/20,会跟右对齐的数值撞在一起
人工修改 标签退回纯文字 剩余箭头,总数挪到数值位置显示成 4/4

为什么前面几关看不出来: 这是个典型的"小数据量测不出来"的问题。

卡片宽度是 172px,左右各留 16px 内边距。标签用 17px 字号,四个汉字就是 68px;
右边的数值要右对齐,数值部分还得占几十像素。

  • 第 1 关:标签 剩余箭头 4/4 ≈ 112px,数值 4 ≈ 13px,加起来放得下;
  • 第 5 关:标签 剩余箭头 20/20 ≈ 123px,数值 20 ≈ 26px,两边一挤就重叠了

改成"标签只写名字、数值写成 当前/总数"之后,标签固定 68px,
数值最宽也就 50px 左右,不管多少支箭头都放得下,
而且 4/4 这种写法反而比纯数字更能说明进度。

第 5 关(6×6、20 支箭头)是数据量最大的一关,现在所有截图都会覆盖到它。

小结

这 6 次记录有一个共同点:AI 给出的东西大多是"能跑通"的,
但距离"好用、好看、在真实环境里不出问题"还有距离,这段距离需要人来测试和修改。

六、测试结果

6.1 自动化测试

项目里的游戏规则和图形界面是分离的,所以可以直接对规则做单元测试。
运行方式(在one-arrow-after-another项目根目录下):

python -m unittest discover -s tests -v

测试结果:24 个用例全部通过。

对应作业要求的测试项:

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 箭头从棋盘移除,剩余数 -1 通过
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 箭头保留,失误 5 → 4,并返回挡路箭头坐标 通过
T03 点击边缘且朝向棋盘外的箭头 正常消失,不发生越界错误 四条边、四个角、以及 1×1 棋盘均正常飞出,无索引错误 通过
T04 消除本关全部箭头 显示通关并进入下一关 状态切换为通关,点"进入下一关"后关卡号 +1,棋盘重新加载 通过
T05 失误次数耗尽 显示失败并允许重新开始 第 5 次失误后状态切为失败,继续点击不再扣次数,可重新开始 通过
T06 游戏进行中重新开始 箭头布局和失误次数恢复 箭头数从 4 恢复到 5,失误数从 3 恢复到 4,棋盘状态完全复原 通过

除了这 6 项,还额外覆盖了:

附加测试 内容 结果
关卡可解性 5 个关卡都必须存在一条清空全部箭头的顺序 5 关全部可通
失误额度校验 顺着正确解法走一遍,不应该产生任何失误 通过
关卡解析 空格分隔与连写、中文与 ASCII 箭头符号、空网格与非法字符的报错 通过

关于附加功能的测试覆盖,这里如实说明:

上面 24 个用例覆盖的是基础要求(T01~T06)和关卡可解性。
第四节那几个附加功能是在写完基础部分之后加的,
它们的逻辑目前只有手工的冒烟验证,还没有补进
tests/test_game.py
。已经手工验证过的是:

验证项 方法 结果
提示只指向能飞出去的箭头、次数用完不再给 直接调用 use_hint() 检查返回值 符合预期
撤销能还原棋盘和失误次数 走 3 步(2 次成功 + 1 次失误)后连撤 3 次 棋盘与失误数完全复原
撤销不会把失误次数刷到超过额度 构造被改小的额度后撤销 被夹在本关额度内
自动求解算出的顺序真能通关 对 5 个关卡各跑一遍 solution() 全部走通,0 失误
自动求解不改动玩家当前这一盘 调用前后比对 board.arrows 完全一致
存档能存能读、坏存档不崩 写盘后重新载入;再写入一段非法 JSON 读回一致;坏档被当成空存档
重玩成绩变差不会覆盖旧纪录 先记 3 星,再用 1 星覆盖 仍保持 3 星

这几项逻辑都在 gamestate.py / progress.py 里,都不依赖 pygame。

6.2 手工试玩

5 个关卡都已实际点通一遍,通关顺序可以用 python tools/check_levels.py 打印出来对照。

各关数据:

关卡 棋盘 箭头数 失误额度 最少步数 可通
第 1 关 · 入门 3×3 4 5 4
第 2 关 · 连锁 4×4 8 5 8
第 3 关 · 四角 5×5 13 5 13
第 4 关 · 螺旋 5×5 16 4 16
第 5 关 · 大螺旋 6×6 20 4 20

6.3 运行时验证

检查项 方法 结果
程序能否正常启动退出 跑完整主循环 ✅ 正常退出,exit code 0
换关后界面是否残留上一关的提示 切换关卡后截图检查 ❌ 发现残留 → 已修复(清空状态文字)
结算时机 逐帧导出检查 ❌ 面板盖住飞出动画 → 已修复(加 0.55 秒延迟)
仓库是否含敏感信息 全文扫描 token/password/key 等关键字 ✅ 未发现
新增按钮后信息栏/按钮排是否重叠 逐张检查 11 张截图(含 6×6 的第 5 关) ❌ 标签与数值会撞 → 已修复(见记录 6)
录 GIF 的结果是否可复现 对比本机存档为空 / 已有进度两种情况 ❌ 演示内容会跟着存档变 → 已修复(Progress.in_memory()
选关界面是否会带入上一屏的状态文字 从"自动求解中"直接切到选关界面截图 ❌ 发现残留 → 已修复(走统一的 open_select() 入口)
GIF 帧间颜色是否稳定 对比逐帧自适应调色板与全局调色板 ❌ 逐帧调色板会闪 → 改成两遍全局调色板(4.97 MB → 3.5 MB)

6.4 素材来源说明

本项目没有使用任何外部素材:没有图片、音频、字体文件,
箭头和界面全部由代码绘制,中文字体调用系统已有字体。
玩法参考了微信小游戏《一箭又一箭》,但关卡、代码、美术全部为本次原创
没有使用原游戏的任何素材或代码。


七、PSP 表格

任务 预估耗时(小时) 实际耗时(小时) 差异(小时)
需求分析与游戏设计 1 2 1
Python 与图形库学习 2 2 0
游戏界面实现 1 1.5 0.5
路径与碰撞逻辑实现 2 2.5 0.5
关卡设计 1 1 0
AIGC 辅助开发 2 4 2
测试与修改 1 2 1
README 与博客撰写 1 1 0
合计 11 16 5

八、心得体会

这次作业之前,我以为"用 AI 写代码"就是把需求描述清楚、然后把生成的代码复制过来。
实际做下来发现完全不是这么回事。

AI 生成的代码确实大部分是"能跑"的,但它并不知道我的具体环境具体需求
最典型的是记录 5 里那个 requirements.txt:AI 加了中文注释,看起来很贴心,
在中文 Windows 上 pip 就是读不了,直接报错。这类问题不实际跑一遍根本发现不了。

而在 AI 生成代码时,直接把 board.py(纯逻辑层)和 main.py(图形界面层)
分开了,并且强调 board.py 里绝对不能 import pygame。我一开始没看懂,觉得反正都在
一个项目里,为什么不能互相调用?后来去查资料才明白,这是典型的“MVC 模式”解耦思想——
把游戏逻辑和图形界面剥离,逻辑代码就可以独立测试,一旦游戏卡住,我只需要检查逻辑文件里
的判定,根本不用去管 Pygame 的窗口绘制。

更值得说的是记录 2。AI 设计的那一关,从对称性上看很ok,
但实际是一局死棋——一圈箭头互相顶住,玩家怎么点都过不了。
光看代码、甚至光看游戏截图,都看不出这个问题。
后来我把"可解性"写成了一个程序化的校验(find_solution()),
所有关卡必须先过校验才能收进项目里。这件事让我印象很深:
游戏设计里,"能被程序验证"比"看起来对"重要得多。


附:项目结构

.
├── main.py              游戏入口:窗口、主循环、事件分发
├── board.py             棋盘与路径检测(纯逻辑,不依赖 pygame)
├── gamestate.py         失误、关卡进度、计时星级、提示与撤销(纯逻辑,不依赖 pygame)
├── progress.py          解锁状态与历史成绩,存 savegame.json(纯逻辑,不依赖 pygame)
├── levels.py            关卡数据(字符网格,改这里就能加关卡)
├── effects.py           动画:飞出棋盘、碰撞晃动
├── render.py            绘制原语:箭头多边形、文字、按钮、布局换算
├── scenes.py            界面绘制:开始界面 / 选关 / 信息栏 / 棋盘 / 结算浮层
├── config.py            窗口尺寸、配色、字体、动画参数
├── savegame.json        游戏存档(自动生成;已加进 .gitignore,不进版本库)
├── tests/test_game.py   T01~T06 自动化测试
└── tools/
    ├── check_levels.py  关卡可解性校验
    ├── screenshot.py    自动生成 11 张界面截图
    └── demo_gif.py      自动录制演示 GIF

仓库地址: https://github.com/liuliu223377/one-arrow-after-another

posted @ 2026-09-18 09:54  102402113  阅读(5)  评论(0)    收藏  举报