软件工程第二次个人作业
用 Python + AIGC 完成「一箭又一箭」小游戏
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2026 秋软件工程与软件工程实践 |
| 这个作业要求在哪里 | 2026 秋软件工程个人作业(第二次) |
| 这个作业的目标 | 使用 Python 和 AIGC 完成"一箭又一箭"小游戏 |
| 学号 | 102402113 |
| GitHub 仓库 | https://github.com/liuliu223377/one-arrow-after-another |
一、项目展示
下面这段gif是完整的演示:开始界面 → 点「提示」看光圈 → 通关第 1 关拿三星 →进入第 2 关
→ 故意点错被挡 → 点「撤销」把失误还回来 → 点「自动求解」交给 AI 走完 →
最后进选关界面看解锁进度。

基础界面:
| 开始界面 | 游戏界面 |
|---|---|
![]() |
![]() |
| 箭头飞出棋盘 | 被挡住时的碰撞反馈 |
|---|---|
![]() |
![]() |
| 第 5 关(6×6 大棋盘) | 通关结算(含星级与用时) |
|---|---|
![]() |
![]() |
| 失败结算 | 全部通关 |
|---|---|
![]() |
![]() |
附加功能:
| 提示功能(光圈套在能飞出的箭头上) | AI 自动求解(逐步播放) |
|---|---|
![]() |
![]() |
| 关卡选择与进度保存 |
|---|
![]() |
上面所有截图和 GIF 都不是手动录屏的,是写了两个脚本自动生成的,不用每次优化自己录频:
用 SDL 的 dummy 显示驱动在内存里逐帧渲染,再存成 PNG / 合成 GIF。
两个脚本分别是tools/screenshot.py和tools/demo_gif.py(GIF)。
二、项目介绍
2.1 游戏规则
「一箭又一箭」是一类点击式箭头解谜游戏。棋盘是一个网格,每个格子上最多有一支箭头,
方向分上、下、左、右四种。玩家点击箭头,程序检查它前进方向到棋盘边界之间还有没有别的箭头:
- 没有阻挡 → 箭头飞出去,从棋盘上消失;
- 有阻挡 → 箭头飞不出去,会朝挡路的方向"顶"一下再弹回来,挡它的那支箭头闪红,
同时失误次数减 1。
清空一关的全部箭头即可过关;失误次数用完则本关失败,可以随时重来。
判断规则只涉及同行或同列的直线,不需要处理转弯:
→ · · ↑ · 第一个箭头朝右,右边还有箭头 → 飞不出去
↑ · · · → 最后一个箭头朝右,右边没有东西 → 正常飞出
2.2 界面设计
界面分三块,从上到下:
- 顶部信息栏:关卡名、关卡提示语,以及三张数据卡片——剩余箭头数(
4/4这种
带总数的写法)、剩余失误数、本关用时。失误数快用完时数字会从绿色变黄、再变红,
给玩家一个心理准备。 - 中间棋盘:格子 + 箭头。鼠标悬停的格子会高亮;用提示功能时,
目标格子上会套一圈会"呼吸"的蓝色光圈。出题方和玩家之间的"对话"全在这里发生。 - 底部操作区:一排四个按钮——提示 / 撤销 / 自动求解 / 重新开始,
以及一行状态提示文字("嗖 —— 箭头飞出去了" / "前方有箭头挡路!")。
按钮不可用时(比如没得撤销、提示用完了)会压暗,一眼能看出点不了。
配色上全部用了深色背景 + 高对比度的白色箭头,只有三个地方出现彩色:
悬停是黄色、挡路是红色、通关是绿色,让玩家的注意力自然地落在需要关注的地方。
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












浙公网安备 33010602011771号