软件工程第二次作业—— 一箭又一箭
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2026 秋软件工程与实践(福州大学·计算机与大数据学院) |
| 这个作业要求在哪里 | 软件工程第二次个人作业 |
| 这个作业的目标 | 使用 Python 和 AIGC 完成一款「一箭一箭」小游戏 |
| 学号 | 102402152 |
| GitHub 仓库 | https://github.com/always123-11/yijian |
1. 项目展示
《一箭一箭 · Arrow Away》 —— 一款用 Python + Pygame 从零实现的箭头消除小游戏。
棋盘上散布着若干个箭头,每个箭头只朝一个方向。点一下箭头,它就会朝自己指的方向
飞出去;但如果前方还有别的箭头挡路,它就飞不出去。你要用有限的机会,按正确的顺序
把整个棋盘清空。
1.1 开始界面

1.2 游戏界面
关卡名、剩余箭头数、剩余次数、进度条、操作提示都在界面上实时显示:

1.3 悬停提示与路径预览
鼠标移到箭头上时,会高亮该箭头,并用小圆点标出它检查的路径:
| 可以飞出(绿色) | 被挡住(红色,并提示是哪个方向挡住的) |
|---|---|
![]() |
|
![]() |
1.4 被挡住时的反馈
箭头不消失,而是原地抖动变红,同时弹出「被挡住了!」的飘字提示:

1.5 通关与失败界面
| 通关 | 失败 |
|---|---|
![]() |
![]() |
1.6 全部通关

说明:以上截图直接引用 GitHub 仓库
docs/images/中的图片,无需另外上传。
2. 项目介绍
2.1 游戏规则
- 箭头只能朝它自己指的方向飞出,不能改变方向。
- 箭头朝向的整条路径直到棋盘边缘都不能有其他箭头挡住。
- 每次点击(成功或失败)都会消耗一次机会。
- 点击空格不消耗机会(避免误操作惩罚过重)。
- 清空所有箭头 → 通关;机会用完仍有箭头 → 失败;
剩下的箭头互相挡住、谁都飞不出去 → 也会判定为失败(「无路可走了」)。
2.2 操作方式
| 操作 | 说明 |
|---|---|
| 鼠标左键点击箭头 | 让箭头朝自己的方向飞出 |
| 鼠标悬停 | 提示该箭头能否飞出,并预览检查路径 |
R |
重新开始本关 |
ESC |
返回主菜单 |
Q |
退出游戏 |
Enter / 空格 |
在菜单与结果界面确认 |
2.3 技术选型
| 项目 | 选择 | 理由 |
|---|---|---|
| 语言 | Python 3.14 | 作业要求;开发效率高 |
| 图形库 | Pygame(pygame-ce 2.5.8) |
作业推荐;绘图 API 足够做箭头、动画和按钮 |
| 测试 | unittest(标准库) |
不引入额外依赖,python -m unittest 即可运行 |
| 关卡校验 | 自研精确求解器 | 保证「每个关卡都能通关」这一硬性要求 |
2.4 项目结构
yijian/
├── main.py # 程序入口
├── core/ # 核心逻辑(不依赖 pygame,可独立测试)
│ ├── board.py # 棋盘、规则判定、点击处理
│ ├── solver.py # 关卡求解与可通关性校验
│ └── levels.py # 8 个关卡定义
├── ui/ # 界面层(pygame)
│ ├── theme.py # 配色、字号、布局
│ ├── render.py # 绘制工具(字体/箭头/按钮)
│ ├── animations.py # 动画(飞出/抖动/飘字/粒子)
│ └── game.py # 场景管理与主循环
├── tests/ # 25 项自动化测试
├── tools/ # 关卡体检、测试记录、自动截图
└── docs/ # AIGC 记录、测试记录、截图
最重要的一个设计决定:把规则逻辑(core/)和界面表现(ui/)彻底分开。
core/ 完全不引用 pygame,因此可以被单元测试直接覆盖,也能在无显示器环境下运行。
好处是「先清路再飞」这类规则 bug 可以在毫秒级的单元测试里被发现,
而不必开着窗口一遍遍手点。
2.5 核心功能实现
(1)路径判定 —— 游戏的核心
def path_to_edge(self, arrow: Arrow) -> list[tuple[int, int]]:
"""从该箭头出发、沿其朝向直到棋盘边缘所经过的格子序列。"""
dr, dc = DIRECTIONS[arrow.direction]
cells = []
r, c = arrow.row + dr, arrow.col + dc
while self.in_bounds(r, c):
cells.append((r, c))
r += dr
c += dc
return cells
def find_blocker(self, arrow: Arrow) -> Arrow | None:
"""找到挡在该箭头路径上的第一个箭头;没有则返回 None。"""
for (r, c) in self.path_to_edge(arrow):
if self._grid[r][c] != EMPTY:
return Arrow(r, c, self._grid[r][c])
return None
只有整条路径到边缘都没有别的箭头,该箭头才能飞出。
(2)点击处理
Board.click(row, col) 是唯一的对外交互入口,界面层只负责把鼠标坐标换算成
格子坐标。它返回一个 MoveResult,里面带有本次点击的类型、涉及的箭头、
检查过的路径和点击之后的局面状态,界面层据此播放不同的动画。
(3)状态建模(踩过坑的地方)
一开始我把「是否还能点击」和「局面状态」混在同一个字段里,结果界面层为了
避免重复触发改写了状态字段,导致失败界面永远不出现。后来把两者拆开:
self.status = "playing" # 只描述局面: playing / solved / failed / stuck
self.accepts_input = True # 才决定是否还能继续点击
规则层写、界面层只读 —— 这样职责就清楚了。
2.6 关卡设计:如何保证"每个关卡都能通关"
这是本次作业里最需要认真处理的一点。关卡用字符画描述:
"""
.^.^..
......
..>...
..<...
......
.v..v.
"""
. 是空格子,^v<> 是箭头。设计完一关后,用 core/solver.py 里的
精确求解器(记忆化 DFS 穷举所有消除顺序)算出最少步数,再把次数上限设为
最少步数 + 容错次数。
最终 8 个关卡全部通过校验:
| 关卡 | 棋盘 | 箭头数 | 最少步数 | 次数上限 | 容错 |
|---|---|---|---|---|---|
| 第 1 关 · 初识 | 6×6 | 6 | 6 | 10 | 4 |
| 第 2 关 · 依次解锁 | 6×6 | 6 | 6 | 10 | 4 |
| 第 3 关 · 十字 | 6×6 | 4 | 4 | 8 | 4 |
| 第 4 关 · 交错 | 6×6 | 4 | 4 | 9 | 5 |
| 第 5 关 · 层层解锁 | 8×8 | 9 | 9 | 15 | 6 |
| 第 6 关 · 上下夹击 | 8×8 | 8 | 8 | 14 | 6 |
| 第 7 关 · 合围 | 9×8 | 8 | 8 | 15 | 7 |
| 第 8 关 · 终局 | 10×9 | 12 | 12 | 19 | 7 |
这里踩了一个大坑:最初设计的 8 个关卡里有 6 个根本无法通关 ——
因为我把 > 和 < 放在同一行面对面、^ 和 v 放在同一列面对面。
这类箭头会永远互相挡住。原因可以从规则直接证明:
把箭头 A 移走,只会让别的箭头路径变短,绝不会变长。
因此 A 一旦挡住 B,在 A 消失前 B 永远出不去;
若 A、B 互相挡住,两者就都永远出不去。相向而行是硬死锁。
想清楚这一点之后,我重新设计了每一关,并把求解器接进关卡校验流程,
任何一关不可解都会直接报错。这也是本项目里最有价值的一次分析。
3. AIGC 使用过程
本项目使用 DeepSeek Harness 中的编码助手辅助开发。下面记录 3 次有代表性的
AIGC 协作过程(完整版见仓库 docs/AIGC_LOG.md)。
3.1 案例一:关卡文本缩进导致棋盘整体错位
| 项目 | 内容 |
|---|---|
| 子任务 | 用三引号字符串书写关卡并解析成棋盘 |
| 提示词 | 「关卡用多行字符串表示,. 是空格子,^v<> 是箭头。写一个 grid_from_text 把字符串解析成每行一个字符串的列表。」 |
| AI 提供的内容 | [line for line in text.strip("\n").splitlines() if line.strip() != ""] |
| 实际效果 | 关卡全部错位。关卡写在函数里,每行都带 8 个空格的公共缩进,strip("\n") 去不掉,箭头被整体平移 |
| 人工修改 | 改用 textwrap.dedent() 去掉公共缩进,并把空格统一成 . |
| 验证 | 新增测试 test_leading_indent_is_stripped / test_space_means_empty,并重新校验 8 个关卡 |
收获:AI 给的"看起来对"的一行代码,忽略了 Python 三引号字符串的缩进语义。
这个问题不会报错,只会让所有关卡悄悄变形 —— 必须靠测试才能发现。
3.2 案例二:设计 8 个"保证可通关"的关卡
| 项目 | 内容 |
|---|---|
| 子任务 | 设计难度递增且一定能通关的关卡 |
| 提示词 | 「帮我设计 8 个箭头消除关卡,难度递增,并且每一关都必须可以通关。怎么保证?」 |
| AI 提供的内容 | 一批关卡字符画 + 一个精确求解器(记忆化 DFS,返回最少步数) |
| 实际效果 | 第一版 8 关里 6 关不可解(存在相向而行的死锁对) |
| 人工修改 | 由我分析出"相向即死锁"的规则推论,逐关重排方向;把求解器接入关卡校验,不可解直接失败 |
| 验证 | test_every_level_completable 用求解器给出的顺序真实走一遍每一关 |
收获:AI 能很快生成"看起来合理"的布局,但它不理解规则背后的可解性约束。
是「求解器 + 我给出的死锁分析」才把"保证可通关"真正落实。
3.3 案例三:点击处理破坏了失败状态
| 项目 | 内容 |
|---|---|
| 子任务 | 点击后切换界面场景(通关 / 失败) |
| AI 提供的内容 | click_cell 末尾多了一行 self.board.status = "playing" if result.status == "playing" else "" |
| 实际效果 | 失败界面永远不出现,而且状态语义被规则层与界面层混用 |
| 人工修改 | 拆出独立的 Board.accepts_input 标志:status 只描述局面,accepts_input 才决定能否点击 |
| 验证 | 新增 test_failed_scene_renders,并用截图工具生成真实的失败界面确认 |
收获:AI 为"修复"一个想象中的重复触发问题,顺手覆盖了状态字段,
属于典型的过度防御式修改。教训是把"局面状态"和"输入许可"分开建模。
3.4 AIGC 生成代码的验证
为保证"程序能够运行、无严重错误、我能理解自己提交的代码",做了以下验证:
| 验证手段 | 结果 |
|---|---|
规则单元测试 T01–T11(tests/test_core.py) |
17 项通过 |
全关卡真实通关测试(tests/test_playthrough.py) |
8 关全部打通 |
| 界面冒烟测试(headless) | 五个界面均可渲染 |
关卡可解性自动校验(tools/check_levels.py) |
8 关全部 OK |
| 界面截图人工检查(9 张) | 无乱码、无重叠 |
| 手动试玩 | 操作、动画、提示均正常 |
4. 测试结果
完整输出见仓库 docs/TEST_RECORD.md(由 python tools/run_tests.py 真实执行生成)。
4.1 作业要求的 6 个测试点
| 编号 | 测试内容 | 预期结果 | 实测 |
|---|---|---|---|
| T01 | 点击前方无阻挡的箭头 | 箭头飞出棋盘并消失 | 通过 |
| T02 | 点击前方有阻挡的箭头 | 箭头不消失,失败次数 +1 | 通过 |
| T03 | 点击位于边缘且朝向棋盘外的箭头 | 正常飞出 | 通过 |
| T04 | 清除本关全部箭头 | 提示通关并进入下一关 | 通过 |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 通过 |
| T06 | 游戏进行中重新开始 | 棋盘布局和失误次数恢复 | 通过 |
4.2 自动化测试总览
Ran 25 tests in 7.9s
OK
其中包含:
- 规则单元测试 17 项:覆盖 T01–T06,并额外覆盖点击空格、死局检测、
求解器最优性、恰好用完次数的边界、关卡文本解析的健壮性。 - 集成测试 8 项:用求解器给出的解法真实走通全部 8 个关卡,
并对菜单 / 游戏中 / 通关 / 失败 / 全通关五个界面做渲染冒烟测试。
4.3 关卡可通关性校验
关卡 棋盘 箭头 最少步数 次数上限 容错 可通关
第 1 关 · 初识 6x6 6 6 10 4 是
第 2 关 · 依次解锁 6x6 6 6 10 4 是
第 3 关 · 十字 6x6 4 4 8 4 是
第 4 关 · 交错 6x6 4 4 9 5 是
第 5 关 · 层层解锁 8x8 9 9 15 6 是
第 6 关 · 上下夹击 8x8 8 8 14 6 是
第 7 关 · 合围 9x8 8 8 15 7 是
第 8 关 · 终局 10x9 12 12 19 7 是
结果: 全部 8 关均可通关 ✓
4.4 手工测试
自动化测试之外,还通过人工试玩 + 逐张查看截图确认了:中文显示无乱码、
界面无重叠、飞出动画与被挡抖动正常、悬停路径提示正确、五个界面切换正常、
快捷键 R/ESC/Q 正常。
5. 关键代码说明
5.1 为什么把逻辑和界面分开
如果把规则直接写在 pygame 的事件循环里,那么"某个箭头能不能飞出去"这件事
就只能靠手点来验证,改一行代码要重开五次窗口。分开之后:
# ui/game.py —— 界面层只做两件事: 换算坐标、把状态画出来
def click_cell(self, row, col):
result = self.board.click(row, col) # 规则层返回结果
if result.kind in (OK, SOLVED):
self._play_fly(row, col, ...) # 成功 -> 飞行动画
elif result.kind == BLOCKED:
self._play_blocked(row, col, ...) # 被挡 -> 抖动 + 飘字
规则层不需要知道 pygame 的存在,所以 tests/test_core.py 里可以直接
构造一个棋盘、调用 click()、断言结果 —— 整个测试套件跑完只要 8 秒。
5.2 求解器:怎么算"最少几步能通关"
因为只有"成功飞出"才会改变棋盘,局面完全由剩余箭头集合决定。
所以可以用记忆化 DFS 穷举所有消除顺序:
def dfs(b):
if b.arrows_left == 0:
return 0, ()
key = frozenset(a.pos for a in b.arrows())
if key in memo: ...
best = None
for a in sorted(b.flyable(), key=unlock_score): # 启发式排序加速
b2 = b.clone(); b2.click(a.row, a.col)
sub = dfs(b2)
...
搜索时优先尝试"飞出后能解锁更多箭头"的走法,可以更快找到最优解。
关卡规模小(最多 12 个箭头),完全够用。
5.3 箭头是怎么画出来的
没有用任何图片素材,全部由 pygame 的绘图 API 实时绘制:先构造一个"朝上"的
七边形(三角箭头 + 矩形箭杆),再按方向旋转 0/90/180/270 度。
_DIR_ANGLE = {"^": 0, ">": 90, "v": 180, "<": 270}
这样做的好处是任意分辨率都不会糊,也不需要考虑素材版权问题。
飞出、抖动、飘字、粒子等动画则是在每一帧根据动画进度重新计算位置、
缩放和透明度。
6. 心得与体会
6.1 关于 AIGC
- AI 很擅长"写出来",但不擅长"保证对"。它给的代码看起来都很合理,
但案例一那种"不报错却全错"的问题,只有测试能抓住。 - AI 需要被喂进正确的约束。案例二里,如果我只说"设计几个关卡",
拿到的一定是不可解的布局;是我先想清楚"相向即死锁",AI 才能照着这个
约束去改。领域理解还是得人来做。 - 过度防御式修改是 AI 的常见毛病。案例三那行"顺手清空状态"的代码,
表面上是防重复触发,实际上毁掉了失败判定。
6.2 关于软件工程
- 把核心逻辑和界面分开是这个项目里最值的一个决定。它让 8 秒跑完的
25 项测试成为可能,也让"关卡是否可通关"这种问题能被自动校验,
而不是靠人一遍遍试。 - 可验证性 > 直觉。设计关卡时我的直觉给出的 8 个布局有 6 个是错的;
写一个求解器虽然多花了时间,但它一次性地解决了"保证可通关"这个问题,
并且以后改关卡也不会退化。 - 测试记录的自动化。测试结果是脚本跑出来直接写进文档的,
不是手抄的 —— 这样文档不会和代码脱节。
6.3 遇到的问题与解决
| 问题 | 原因 | 解决 |
|---|---|---|
| 关卡全部错位 | 三引号字符串的缩进未去除 | 改用 textwrap.dedent() + 补测试 |
| 6 个关卡不可解 | 同一行/列存在相向的箭头对 | 分析出"相向即死锁",重排方向 + 求解器校验 |
| 失败界面不出现 | 界面层改写了 board.status |
拆出 accepts_input,状态只由规则层写 |
| 测试之间互相干扰 | 有测试调用 pygame.quit(),使字体缓存失效 |
每个测试重建显示面 + reset_fonts() |
| 新 Python 装不上 pygame | 官方 pygame 没有 3.14 的预编译包 | 改用 pygame-ce(用法完全一致) |
| 剩余次数图标溢出面板 | 图标从左往右排,超出面板宽度 | 改为从右往左排列 |
7. PSP 表格
说明:下表的实际耗时是对整个开发过程的整体估算(依据各模块的实际工作量
与调试过程中遇到的问题量回顾得出),并非逐分钟精确计时,用于反映各阶段的
时间分配与偏差情况。
| 任务 | 预计耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 1.0 | 1.2 | +0.2 |
| Python 与图形库学习 | 1.5 | 1.0 | -0.5 |
| 游戏界面实现 | 2.5 | 3.0 | +0.5 |
| 路径与碰撞逻辑实现 | 1.5 | 2.0 | +0.5 |
| 关卡设计 | 1.5 | 3.5 | +2.0 |
| AIGC 辅助开发 | 1.0 | 1.5 | +0.5 |
| 测试与修改 | 2.0 | 3.0 | +1.0 |
| README 与博客撰写 | 1.5 | 1.8 | +0.3 |
| 合计 | 12.5 | 17.0 | +4.5 |
差异分析:
- 关卡设计超支最多(+2.0h)。原以为"摆几个箭头"很快,实际上为了满足
"每关都能通关",反复经历「设计 → 求解器报错 → 分析死锁 → 重新设计」,
改了三轮才全部通过。这部分时间花得值 —— 它直接对应作业的硬性要求。 - 测试与修改超支(+1.0h)。主要花在排查"失败界面不出现"和
"测试之间互相干扰(pygame 退出后字体失效)"这两个问题上。 - 图形库学习比预期省时间(-0.5h)。Pygame 的绘图 API 比想象中简单,
而且画箭头只需要旋转一个多边形,不需要任何素材。 - 总体超支 36%,主要原因是低估了"可验证性"的工作量 ——
写求解器、写测试、做关卡校验合计约 4 小时,这部分在最初计划里几乎没算。
8. 扩展功能说明
在完成全部基础要求之外,额外实现了以下功能(对应作业「扩展功能(附加分)」):
| 扩展功能 | 说明 |
|---|---|
| 得分 / 剩余次数统计 | 每关记录用了多少次机会,通关界面展示;全部通关后汇总总次数 |
| 提示功能 | 鼠标悬停时高亮箭头、绿色/红色区分能否飞出,并用圆点预览检查路径 |
| 更难关卡 | 共 8 关(要求 ≥5),棋盘从 6×6 递增到 10×9,箭头数从 4 增加到 12 |
| 动画与视觉反馈 | 飞出(滑出 + 淡出 + 缩放)、被挡(抖动 + 变红)、飘字提示、通关粒子爆开 |
| 关卡可解性自动校验工具 | tools/check_levels.py 自动体检所有关卡,保证不会出现无法通关的布局 |
| 自动化测试 | 25 项测试,覆盖规则、通关流程、界面冒烟 |
| 无显示器截图工具 | tools/screenshot.py 用 SDL dummy 驱动自动生成界面截 |





浙公网安备 33010602011771号