软件工程第二次作业
2026 秋软件工程个人作业(第二次)——用 AIGC 完成「一箭又一箭」小游戏
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | <课程链接:https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering> |
| 这个作业要求在哪里 | <作业链接:https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16717> |
| 这个作业的目标 | 使用 Python 和 AIGC 完成「一箭又一箭」小游戏 |
| 学号 | 102401227 |
| GitHub 仓库 | https://github.com/echo14670/arrow-arrow-game |
一、项目展示
演示 GIF:

各界面截图:









二、项目介绍
游戏规则:棋盘上散布着朝向上、下、左、右四个方向的箭头。点击一个箭头时,
程序检查它前进方向上、到棋盘边界之间还有没有别的箭头:
- 前方没有任何箭头 → 该箭头飞出棋盘并消失;
- 前方有别的箭头 → 箭头飞不出去,播放抖动加变红的碰撞反馈,并消耗一次失误机会;
- 清空本关全部箭头即过关,进入下一关;失误次数耗尽则本关失败,可以重新开始。
界面设计:一共三套界面,都是深色背景、圆角卡片风格。
- 开始界面:游戏标题、四种箭头的示意图、玩法说明、「开始游戏」与「选择关卡」两个按钮;
- 选择关卡界面:列出全部 6 关的名称、尺寸、箭头数与失误上限,点任意一行就能直接开始;
最下面一行是「自定义关卡」入口,可以自己定地图大小、箭头数量与难度,生成的关卡以绿色高亮
追加在列表的最后一行,可以反复重玩; - 游戏界面:上方 HUD 显示关卡名、第几关 / 共几关、剩余箭头数、剩余失误次数、本关用时;
中间是棋盘;下方是「重新开始」「撤销」「返回主菜单」三个按钮; - 结果界面:过关面板显示本关用时、点击次数、失误次数、撤销次数,并提供「下一关」;
失败面板提示失误次数已经用尽,可以重试本关或返回主菜单;
全部通关面板显示总用时、总点击次数与总失误次数。
主要功能与特色:
| 功能 | 说明 |
|---|---|
| 六个关卡 | 从 3×3 到 6×6,共 54 个箭头,难度递增 |
| 飞出动画 | 箭头沿自己的方向滑出棋盘并淡出 |
| 碰撞反馈 | 箭头左右抖动 + 变红 + 浮出「被挡住了!」提示 |
| 失误机制 | 每关独立的失误次数上限,剩余 1 次时数字变红提醒 |
| 实时计时 | 显示本关用时,全部通关时统计总用时 |
| 撤销 | 可以撤销上一步成功消除,不消耗失误 |
| 重新开始 | 随时把本关恢复到初始状态(布局与失误次数都还原) |
| 返回主菜单 | 每一关(含自定义关卡)都能一键中断本局回到主菜单,进度清零 |
| 键盘操作 | R 重新开始、U 撤销、Enter 开始 / 下一关、Esc 退出 |
| 选择关卡 | 主菜单可进入选关界面,点击任意一关直接开始,也支持数字键 1–9 |
| 自定义关卡 | 自己定地图大小(2×2–6×6)、箭头数量(1–行×列)与难度(低/中/高),生成器保证一定有解 |
| 鼠标悬停 | 悬停的格子高亮,箭头变亮 |
| 打包 exe | 提供一键打包脚本,生成免安装的单文件 exe |
三、实现思路
3.1 箭头、方向与关卡怎么表示
方向用一个枚举表示,每个方向的取值就是它在网格里的移动量(行向下为正、列向右为正):
class Direction(Enum):
UP = (-1, 0)
DOWN = (1, 0)
LEFT = (0, -1)
RIGHT = (0, 1)
棋盘用二维列表保存,每格是 Direction 或 None。关卡数据用等长字符串描述,
. 是空格,^ v < > 是四种箭头,这样在代码里一眼就能看出布局:
Level(
name="第 2 关 · 错身而过",
grid=(
".v..",
">>vv",
"....",
"...<",
),
mistakes=4,
)
3.2 路径检测
这是整个游戏的核心:箭头能不能飞出,完全取决于它到边界之间有没有别的箭头。
实现方式是从箭头所在格子出发,沿着它的方向一步一步走,出界就说明畅通:
def is_free(self, row, col) -> bool:
direction = self.at(row, col)
if direction is None:
return False
d_row, d_col = direction.delta
cur_row, cur_col = row + d_row, col + d_col
while self.inside(cur_row, cur_col): # 越界即视为畅通
if self._cells[cur_row][cur_col] is not None:
return False # 遇到别的箭头,被挡住
cur_row += d_row
cur_col += d_col
return True
这里有三个容易踩的坑:
- 必须显式判断边界。如果写成
self._cells[row - 1][col],当箭头在第 0 行且朝上时,
Python 的负索引会绕到棋盘最后一行,把棋盘底部的箭头误判成阻挡。我在
tests/test_board.py里专门写了回归用例test_negative_index_wraparound_regression。 - 只查同一行或同一列。斜向的箭头不算阻挡,所以只需要沿一个方向走。
- 只关心「到边界之间」。箭头后面的箭头不影响它,因此不需要双向检查。
因为每次只关心一个方向,最坏情况下的复杂度是 O(棋盘边长),一帧里对全部箭头做判断也毫无压力。
3.3 游戏流程与状态机
游戏规则全部放在 game/session.py 的 Session 里,界面完全不参与判定,
这样同一套逻辑既能跑在窗口里,也能写自动化测试。状态机有五个阶段:
START(开始界面) → PLAYING(游戏中) → LEVEL_CLEAR(过关) → PLAYING(下一关)……
↘ FAILED(失败)→ PLAYING(重试本关)
↘ ALL_CLEAR(全部通关)
点击一格只有三种结果:REMOVED(飞出并消失)、BLOCKED(被挡住,扣一次失误)、
IGNORED(空格、棋盘外,或者当前阶段不接受输入)。
3.4 关卡怎么保证一定能通关
随机撒箭头的关卡很容易出现「互相挡住谁也走不了」的死局。这里用的是逆向摆放:
如果一关的消除顺序是 r1, r2, …, rn,那么消除 r_i 时棋盘上还剩 r_i … rn。
所以只要按 rn, r_{n-1}, …, r1 的顺序摆放,并且摆放每个箭头时它相对「已经摆好的箭头」
前方是通的,构造出来的关卡就一定存在 r1 → … → rn 这条通关路径。
生成器(tools/generate_levels.py)每关随机生成几千个候选,再用评分挑出「开局被挡的箭头多、
需要做选择」的布局(打分细节见 3.5);第 1 关是手写的教学关。另外还有一个贪心求解器 game/solver.py:
def solve(board):
"""拿走箭头只会让别的箭头更自由,因此不需要回溯,逐步贪心即可。"""
它既能用来校验关卡可解(tests/test_levels.py 断言「解序列长度 == 箭头数」),
也能用在截图脚本里自动通关。
3.5 从「一定可解」到「有意思」:候选评分 best_layout
逆向摆放只保证了「有解」,并不保证「好玩」:按这套规则随机摆出来的候选,绝大多数都是开局几乎
每个箭头都畅通,点哪个都一样,玩起来没有思考量。所以生成器在「保证可解」之外又加了一层筛选,
把生成拆成三步——批量造候选、给候选打分、取分数最高的那个(tools/generate_levels.py)。
打分需要的三个统计量来自 game/solver.py 的 describe:blocked_at_start 是开局就被挡住的
箭头数,forced_steps 是只有唯一选择(不能不点它)的步数,choice_steps 是有多个可选箭头的步数。
评分函数本身只有两行:
def score(stats: dict[str, int], arrows: int) -> float:
"""布局评分:可解、挡住的箭头够多、需要思考的步数适中。"""
if not stats["solvable"]:
return -1.0 # 不可解的候选直接给最低分
blocked_ratio = stats["blocked_at_start"] / max(arrows, 1)
return blocked_ratio * 2.0 + stats["choice_steps"] * 0.05 + stats["forced_steps"] * 0.1
best_layout 就是围绕这个评分做的「随机采样 + 取最优」循环:
def best_layout(rows, cols, arrows, tries, rng):
best, best_score = None, -1.0
for _ in range(tries): # 默认采样 3000 次
board = random_layout(rows, cols, arrows, rng)
if board is None or not is_solvable(board):
continue # 摆不满或不可解的候选直接丢弃
stats = describe(board)
current = score(stats, arrows)
if current > best_score:
best, best_score = (board, stats), current
if best is None:
raise SystemExit("生成失败:请放宽箭头数量或增加尝试次数")
return best
这里的取舍是:
blocked_ratio是主项(权重 2.0)。开局被挡的箭头越多,玩家越得先想办法把「挡路的那几个」
清掉,关卡才有解谜感;被挡数接近 0 的布局就是「一眼看穿」的废稿。它先除以箭头总数做了归一化,
不同规模的候选之间才能比较。forced_steps/choice_steps是次要项(权重 0.1 / 0.05),用来在同样「挡得多」的候选
之间做微调。顺带一提,因为这两个数之和恒等于箭头总数,这两项其实等价于
「一个常数 + 0.05 × 必经步数」,真正在起作用的是「挡得多」和「有若干步是被逼着走的」。- 这两个统计量只是「手感」的近似。它们是在固定的贪心顺序(按行优先取第一个可点的箭头)下
数出来的,同一个布局换个顺序数字就会变。所以评分只负责把明显平庸、看起来都差不多的候选排掉,
最终第 2–6 关的布局还是我把候选打印出来逐个看过之后挑的,第 1 关则是手写的教学关。
实际跑的时候还有两点量级上的经验:
- 采样次数要够。候选是随机的,
--tries默认 3000,就是为了让「挡得多」的样本出现并且留下来;
--count一次输出多个候选时内部用seen去重,避免重复推荐同一个布局。 - 箭头密度不能太高。逆向摆放时每个格子会随机试四个方向,四个方向都被前面的箭头挡住就跳过
这一格,箭头数接近格子总数时很容易摆不满而返回None。实测 5×5 摆 12 个箭头时 200 次全部
成功,摆 20 个成功 198 次,摆到 24 个就只剩 21 次了,所以arrows和rows * cols的比例
要留足余量。
需要强调的是,评分只是在候选池里挑更好玩的那个,池子本身是否干净并不靠它:逆向摆放保证「可解」
不依赖运气,我用它连跑 300 次 5×5 / 12 箭头,不可解的样本数是 0。
3.6 动画与界面
- 箭头用多边形按方向绘制(以「朝右」为基准画一次,再按 0°/90°/180°/270° 旋转),
并按(方向, 尺寸, 颜色)缓存成小图片,避免每帧重画。 - 飞出动画:箭头沿自己的方向平移并逐渐透明;碰撞动画:箭头沿垂直于运动方向抖动并闪红,
再浮出一行提示文字。 - 动画期间会锁定鼠标点击,防止玩家连点导致一次点击被重复结算。
- 中文字体按「微软雅黑 → 黑体 → 宋体」的顺序查找字体文件,找不到再退回系统字体名,
最后退回 pygame 默认字体。
3.7 自定义关卡:把生成器交到玩家手里
选关界面六关下面是「自定义关卡」。它的三个参数和取值范围是这样定的:
| 参数 | 范围 | 为什么是这个范围 |
|---|---|---|
| 行数 / 列数 | 2 × 2 到 6 × 6 | 上限沿用界面里按 6×6 设计的棋盘(6 列 467 像素宽、6 行刚好排在 HUD 与按钮之间),再大就会破坏UI界面;下限取 2 是避免退化成「一条线」 |
| 箭头数量 | 1 到 行 × 列 | 上限就是格子总数,因为生成器能保证这个范围内的任意数量都有解;变更大小时自动夹紧 |
| 难度 | 低 / 中 / 高 | 用 3.5 节的 best_layout 评分衡量:高难度取分数最高的候选,低难度取最低分,中难度取中位数 |
「任意数量都有解」靠的是两层构造。第一层还是 3.4 节的逆向摆放:随机挑格子与方向,只要求新箭头
相对已经摆好的箭头前方通畅,摆出来的关卡天生带一条通关路径。但箭头数量接近格子总数时随机摆放
经常摆不满,于是补了第二层洋葱式摆放:
def _ring(row, col, rows, cols):
"""格子到最近一条边的距离(第几层洋葱皮)"""
return min(row, col, rows - 1 - row, cols - 1 - col)
按这个层号排序,消除顺序先外圈后内圈,摆放时再倒过来从内层往外层放。一个箭头指向最近的边时,
它前方只可能是更外圈的格子,而那些箭头会更早被消除——所以不管要摆几个箭头,这个顺序都成立。
我用它把 2×2 到 6×6 的每种大小、每个箭头数量都跑了一遍(325 组「大小 × 箭头数」,再乘三档
难度共 975 组),箭头数量全部正确、求解器复核全部通过。
难度分档则是把 3.5 节的采样逻辑收进 game/generator.py:造 24 个候选、逐个用 solve() 复核、
按 score() 排序,再按难度取不同位置:
pool.sort(key=lambda item: item[0])
if difficulty is Difficulty.HIGH:
return pool[-1][1] # 高难度:分数最高的候选
if difficulty is Difficulty.LOW:
return pool[0][1] # 低难度:分数最低的候选
return pool[len(pool) // 2][1] # 中难度:中位数
失误次数不再手写,而是按箭头数量与难度算出来:低 0.5 / 中 0.35 / 高 0.25 倍,再夹到 2 到 8 之间,
也就是难度越高允许的失误越少。生成的关卡只占一个槽位(列表第 7 行,用绿色高亮区分),
再次生成就替换掉它——这样既不用改动 LEVELS 里的 6 个内置关卡,next_level() 走到最后一关时
也会自然进入「全部通关」。
可以看到,这一节基本是把 3.4、3.5 两节的生成器从「我用来造关卡的工具」变成「玩家手里的功能」,
真正新增的只有洋葱式摆放这一个构造法,以及参数范围与夹紧规则。
四、AIGC 使用过程
本次使用的 AIGC 工具是 Codex,它参与了从需求分析到打包的全过程。
下面是其中几次有代表性的协作(更完整的记录见仓库里的 docs/aigc-log.md):
| 子任务 | 借助何种 AIGC | AI 实现或提供了什么 | 效果 | 如何人工修改 |
|---|---|---|---|---|
| 环境勘测与技术选型 | Codex | 检查本机 Python 版本与可用图形库,比较 Pygame / Tkinter 的取舍,验证 pygame 有可用的 wheel | 确定 Python 3.13.12 + Pygame 2.6.1 | 安装卡住后改用清华镜像;确认第 3 方依赖只有 pygame |
| 关卡生成 | Codex | 提出「逆向摆放」构造法 + 贪心求解器,保证每关一定有解 | 6 个关卡全部可解,没有再出现死局 | 第 1 关改成手写教学关,其余关卡按统计指标人工挑选 |
| 路径检测 | Codex | 给出四种方向的检测代码,并指出「向上检测用负索引会绕到棋盘底部」这个坑 | 核心逻辑一次写对 | 保留显式边界判断写法,补回归用例 |
| 自动化测试 | Codex | 生成 58 条 unittest 用例 | 首次运行 3 个失败、5 个报错 | 定位出三处都是测试自己写错(行宽不一致、边界摆位、点击顺序),修正后全部通过 |
| 界面截图 | Codex | 用 SDL dummy 驱动离屏渲染,写脚本自动出图 | 自动生成界面截图(目前共 10 张),并暴露出两处视觉缺陷 | 修正浮字压住箭头、结果面板文字被按钮盖住的问题 |
| 打包 exe | Codex | 给出 PyInstaller 参数并封装成一键脚本 | 生成约 27 MB 的 ArrowArrowGame.exe,双击可运行 |
可执行文件改用 ASCII 名,dist/ 等产物加入 .gitignore |
| 自定义关卡(附加功能) | Codex | 新增关卡生成器:随机摆放 + 洋葱式摆放兜底,保证「1–行×列 任意数量都有解」,再按 best_layout 评分按难度挑候选;另加 CustomSetup 参数表单与自定义面板 |
玩家可以自己定 2×2–6×6 的大小、箭头数量与难度,生成的关卡以绿色高亮追加在选关列表最后一行 | 修正三处问题:高密度摆不满、棋盘缩小时箭头数量没跟着夹紧、拿一个本身就无解的布局当难度对照;面板加高、入口按钮改为按关卡数量动态定位 |
举一个最典型的例子:关卡的可解性。我一开始的想法是「随机撒箭头,然后人工试玩,走不通就换一个」,
但很快就发现随机布局产生死局的概率很高。Codex 提出了逆向摆放的思路——按消除顺序倒着放箭头,
这样构造出来的关卡天生就带着一条通关路径,再配合一个不需要回溯的贪心求解器,
就能在测试里断言「每一关的通关序列长度等于箭头数」。这个做法把「靠人试」变成了「靠算法保证」,
tests/test_levels.py 里的 8 条用例全部围绕这一点。
另一个例子是 AI 自己犯的错:Codex 一口气写了 51 条测试用例,第一次运行就有 3 个失败、5 个报错。
逐条查下来发现三处都是测试代码自己写错:用来做测试的关卡两行宽度不一致、
「四个方向都要能被挡住」的用例把箭头放在了棋盘边界上(边界上的箭头前方本来就是棋盘外)、
T06 的用例先点掉了唯一能解救受阻箭头的那个箭头。修正之后 51 条全绿。之后每加一个功能我都按同样的流程补用例:加完选关功能是 70 条,
加完自定义关卡是 133 条,全部通过。
五、测试结果
用标准库 unittest 写了 138 条自动化测试:
python -m unittest discover -s tests -t . -v
# Ran 138 tests in 4.725s
# OK
作业要求的六个测试用例都做了自动化覆盖:
| 编号 | 测试内容 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|
| T01 | 点击前方无阻挡的箭头 | 箭头飞出棋盘并消失 | 箭头数量 3 → 2,该格变空,失误次数不变 | 通过 |
| T02 | 点击前方有阻挡的箭头 | 箭头不消失,失误次数减 1 | 箭头数量不变、仍在原格,失误 5 → 4 | 通过 |
| T03 | 点击位于边缘且朝向棋盘外的箭头 | 箭头正常消失,不发生越界错误 | 左上角 ^、左下角 > 都正常飞出,无异常 |
通过 |
| T04 | 消除本关全部箭头 | 显示通关并进入下一关 | 剩余箭头 0,进入过关面板,点下一关切到第 2 关 | 通过 |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 失误 1 → 0,显示失败面板,重试恢复初始状态 | 通过 |
| T06 | 游戏进行中重新开始 | 箭头布局和失误次数恢复 | 棋盘与初始布局完全一致,失误恢复上限,用时归零 | 通过 |
除此之外还覆盖了:点击空格与棋盘外坐标被忽略、撤销不消耗失误、通关后不能撤销、
计时只在游戏中累加、动画期间点击不重复结算,以及八个界面阶段(开始 / 选关 / 自定义关卡设置 /
游戏 / 过关 / 失败 / 全部通关)都能正常渲染。
测试过程中发现并修复的问题(详细记录见 docs/test-record.md):
| # | 问题 | 原因与修复 |
|---|---|---|
| 1 | 测试用例报错 | 测试用关卡两行宽度不一致,改成等长后正常 |
| 2 | 用例失败 | 四方向阻挡用例把箭头放在了边界上,改为在棋盘中心程序化摆放 |
| 3 | 用例失败 | T06 先点掉了关键箭头,调整点击顺序后通过 |
| 4 | 界面缺陷 | 「被挡住了!」浮字压在箭头上,起点移到格子上方 |
| 5 | 界面缺陷 | 结果面板第 4 行文字被按钮盖住,重排面板与按钮位置 |
| 6 | 统计错误 | 重复通关导致显示「通关 7 / 6 关」,改为不重复统计 |
| 7 | 环境问题 | pip 直连 PyPI 卡住,改用清华镜像安装成功 |
| 8 | 进程崩溃 | 加完选关功能后测试进程以 0xC0000005 崩溃:字体与箭头贴图被缓存,pygame.quit() 后底层资源已释放,重新初始化再绘制就访问违例;加 clear_cache() 并在 Game 初始化时调用 |
| 9 | 生成器覆盖不到高密度 | 箭头数量接近格子总数时随机摆放反复摆不满,一个候选都拿不到;补「洋葱式摆放」兜底,让 1..行×列 的任意数量都能构造出可解关卡 |
| 10 | 参数联动缺失 | 棋盘从 6×6 改小到 3×3 后箭头数量仍可能是 36;在表单的赋值逻辑里补上「先改大小、再夹箭头」 |
| 11 | 测试样例本身无解 | 本想用 (">>", "<.") 当「被挡得更多」的对照,实际三个箭头互相挡死、评分 -1;换成 (">v", "<."),分数 0.87 对 0.15 |
六、PSP 表格
| 任务 | 预估耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 1.0 | 0.5 | 0.5 |
| Python 与图形库学习 | 0.5 | 0.5 | 0.0 |
| 游戏界面实现 | 2.0 | 0.5 | 1.5 |
| 路径与碰撞逻辑实现 | 1.5 | 0.5 | 1.0 |
| 关卡设计 | 1.0 | 0.5 | 0.5 |
| AIGC 辅助开发 | 1.0 | 0.5 | 0.5 |
| 测试与修改 | 1.5 | 0.5 | 1.0 |
| README 与博客撰写 | 1.5 | 0.5 | 1.0 |
| 合计 | 10.0 | 4.0 | 6.0 |
七、心得体会
AI 帮了什么忙
- 把思路直接给出来。逆向摆放保证关卡可解、贪心求解器不需要回溯、动画期间锁输入,
这些做法我自己大概率要试错很久才能想明白,AI 一次就讲清楚了。 - 减少重复劳动。编写133 条测试用例、10 张截图、打包脚本,这些步骤现在几乎不占时间。
- 帮助检查自己没想到的边界。比如「向上检测时负索引会绕到棋盘底部」这一类问题,
它主动提醒了我,避免了一个很难发现的 bug。
出现的问题
- AI 写的测试也会错。51 条用例第一次跑就有 3 失败 5 报错,而且错误全在测试自己身上:
棋盘行宽不一致、把箭头摆在棋盘边界上导致「阻挡」根本不存在、点击顺序破坏了测试前提。
这提醒我:测试跑出红色不代表程序有问题,先怀疑测试本身。 - 界面细节必须看截图。浮字压住箭头、面板文字被按钮盖住,这两处代码层面完全看不出问题,
只有把图渲染出来才发现。
收获
这次作业让我体会到,AIGC 更像一个「手很快、但需要人审稿的搭档」:它能把结构、算法和重复代码
快速铺开,但边界条件、统计口径、视觉细节这些需要判断力的地方,仍然得自己掌控。
另外一点感悟是:规则和界面分离这个设计非常合理——
Session 不依赖窗口,所以 44 条流程测试跑完只要 0.02 秒,
如果规则写死在界面里,这些测试根本没法做。
浙公网安备 33010602011771号