软件工程第二次个人作业——一箭又一箭
用 AIGC 做一个「一箭又一箭」小游戏
作业信息
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2026秋软件工程与软件工程实践 |
| 这个作业要求在哪里 | 软件工程课程第二次个人作业 |
| 这个作业的目标 | 使用 Python 和 AIGC 完成「一箭又一箭」小游戏 |
| 学号 | 102402142 |
| GitHub 仓库 | https://github.com/Yeee-tj/yet-arrow-away |
一、项目展示
玩法演示
点箭头让它沿自己指的方向飞出去,路上没东西挡着就飞出棋盘、被消除。

上面这段能看到一个关键设计:棋盘上有个发光的表盘,点它是没用的——只有箭头飞出去的时候路线正好从它上面穿过,才能拿到那 5 秒。所以要先清掉挡路的箭头,再把合适的箭头留到正确的时机放飞。
点到被挡住的箭头时,箭头会晃动变红,并扣掉一颗爱心:

清空棋盘后弹出结算表情图:

界面


二、项目介绍
游戏规则
棋盘上散布着朝上、下、左、右四个方向的箭头。点中一个箭头,程序沿着它指的方向一路检查到棋盘边界:
- 路上一个箭头都没有 → 箭头飞出棋盘,被消除;
- 路上碰到别的箭头 → 不能消除,扣掉一次爱心,箭头晃动变红给出反馈。
清空当前关卡的全部箭头即过关,进入下一关;爱心用完或者倒计时归零则失败,可以重新开始。
三个关卡
| 关卡 | 棋盘 | 箭头数 | 爱心 | 时间限制 | 时间道具 | 开局可飞 |
|---|---|---|---|---|---|---|
| 第 1 关 · 牛刀小试 | 6 × 6 | 9 | 2 | 10 秒 | 1 个(+5 秒) | 4 个 |
| 第 2 关 · 渐入佳境 | 7 × 7 | 14 | 2 | 15 秒 | 1 个(+5 秒) | 4 个 |
| 第 3 关 · 步步为营 | 8 × 8 | 20 | 2 | 20 秒 | 1 个(+5 秒) | 5 个 |

界面设计
信息全部集中在顶部一条栏里,分三块:
- 左边:关卡名 + 关卡进度、剩余箭头数和步数、用爱心表示的剩余机会(亮红实心是还剩的,暗色空心是已经失去的);
- 右边:倒计时进度条,颜色随时间从绿变黄再到红,最后 10 秒会闪。
底部是「重新开始」和「提示」两个按钮,加一行操作提示。
三个我觉得值得一提的设计
1. 时间给得很紧,逼你先看再点。 第 1 关 9 个箭头只给 10 秒,平均一个箭头一秒多。这不够"点一下想一下",只能先把棋盘扫一遍、在脑子里排个序再动手。道具就是这根救命稻草——+5 秒在一个 10 秒的关卡里等于多了一半时间。
2. 爱心只给 2 颗。 这个数字调过:给 3 颗以上容错太足,可以乱点碰运气;只给 1 颗又太苛刻,一次手滑就要重来。2 颗刚好是"允许你犯点小错,但不能一直瞎试"。
3. 提示功能不是随便找一个箭头高亮的。 按 H 会调用求解器,逐个试算,只推荐「点了之后局面仍然可解」的走法,不会把你带进死路。

三、实现思路
3.1 箭头和方向怎么表示
方向用四个字符常量 U D L R,每个方向对应一组「行增量、列增量」:
DELTA = {
"U": (-1, 0), # 行索引向下增长,所以「上」是 -1
"D": (1, 0),
"L": (0, -1),
"R": (0, 1),
}
这里有个容易搞反的地方:屏幕坐标系里 y 轴是向下的,所以第 0 行在最上面,"向上"是行号减一。我一开始把 UP 写成 (1, 0),结果所有箭头方向都反了。
棋盘用字典存箭头:键是格子坐标 (行, 列),值是方向。空格子不入表,所以"这个位置有没有箭头"就是判断键在不在字典里,查询很快,也天然表达了"棋盘上大部分是空地"这件事。
3.2 路径检测(核心)
这是整个程序最关键的一段。做法是从箭头的下一个格子开始,沿它指的方向一格一格往外走:
def check_path(self, r, c):
direction = self.arrows[(r, c)]
dr, dc = DELTA[direction]
nr, nc = r + dr, c + dc # 从下一格开始看
while self.in_bounds(nr, nc): # 只要还在棋盘内就继续走
if self.has_arrow(nr, nc): # 碰到箭头 -> 被挡住
return False, (nr, nc)
nr += dr
nc += dc
return True, None # 走出棋盘了 -> 可以飞出
这段代码最关键的地方是「越界」和「被挡住」必须分开处理。
朝棋盘外的箭头,行或列的索引会算到 -1、或者超出棋盘尺寸。如果写成"先访问这个格子、再判断合不合法",就很容易在某一行抛 IndexError。而这里把 in_bounds() 放在 while 条件上,索引一旦越界循环就自然结束,直接走到最后的 return True, None——从结构上避免了越界访问,不需要任何特判。

上图左边是前方畅通的情况(绿色虚线一路查到边界),右边是被挡住的情况(红色虚线在碰到箭头时停住)。两种情况的判断共用同一段循环,区别只是循环是"走完了"还是"提前 return"。
3.3 飞行路线:时间道具怎么拿到
除了"能不能飞出去",还需要知道一个箭头会经过哪些格子——这是时间道具的判定依据。它和路径检测共用同一套走法,但不检查有没有被挡住,而是一路把经过的格子收进列表:
def path_cells(self, r, c):
direction = self.arrows[(r, c)]
dr, dc = DELTA[direction]
nr, nc = r + dr, c + dc
cells = []
while self.in_bounds(nr, nc): # 走到边界就停
cells.append((nr, nc)) # 不管路上有没有东西,先记下来
nr += dr
nc += dc
return cells
这里有个顺序陷阱:必须在箭头从棋盘移除之前调用它。 path_cells 的第一步是确认这个格子有箭头,一旦箭头被 remove 掉,方法直接返回空列表,这趟飞行的路线就彻底查不到了——结果就是道具永远吃不到。所以游戏里的顺序被固定为:
got = self._collect_events_on_path(r, c) # 先算路线(此时箭头还在)
self.board.remove(r, c) # 再移除
self.flying.append({...}) # 最后才是视觉动画
这个约束很容易被后来的改动破坏(看起来两个操作换个位置没差别),所以我专门写了一条测试——「箭头移除后飞行路线变空」——把它锁住,改坏了会立刻报错。
3.4 关卡怎么设计,怎么保证一定可解
关卡用字符网格写,直观好改:
"grid": [
". D . D L .",
"R . U . . .",
...
]
. 是空格子,U/D/L/R 是四个方向的箭头。
这三个关卡不是手工摆的,而是由 level_gen.py 随机生成、再按条件筛选,生成时需要同时满足四条:
- 四个方向都要出现,避免玩法单调;
- 同一行或同一列上,相邻位置连续同方向的箭头不超过 2 个;
- 开局能直接飞走的箭头不能太多;
- 必须能被
solver.py验证「确实可以通关」。
第 2 条和第 4 条都是踩坑之后加的,下面在 AIGC 使用过程里详细说。
还有一条铁律:同一行或同一列不能出现两个箭头「面对面」互相指着对方。比如一行写成 R . . . L,左边的想往右飞被右边挡住,右边的想往左飞被左边挡住,两个箭头谁都动不了,整关直接无解。这个错误肉眼看不出来,只能靠求解器查。
3.5 求解器怎么工作
求解器用的是深度优先搜索 + 记忆化:每一轮先找出当前所有能飞出去的箭头,任选一个让它飞出,在新局面上重复;直到清空(成功),或者一个能飞的都没有(死局,退回换一种点法)。同样的局面会被反复遇到,所以用一个字典把算过的局面记下来,避免重复搜索。
它有两个用途:验证关卡可解,以及给游戏提供提示功能。
3.6 代码怎么分层
项目分成两层,这个划分对后面帮助很大:
代码/
├── arrows.py # 纯逻辑:棋盘结构 + 路径检测 + 飞行路线
├── levels.py # 3 个关卡的数据
├── solver.py # 求解器:验证可解 + 提供提示
├── level_gen.py # 关卡生成器
├── game.py # 界面层:绘制、交互、动画、流程
└── test_core.py / test_game.py # 自动测试
arrows.py、levels.py、solver.py 完全不依赖 pygame。好处有两个:一是路径判断、关卡可解性都能写成自动化测试,不用手点鼠标;二是界面层不重复实现任何游戏规则,所有判定都调用逻辑层,不会出现"两处规则不一致"的问题。
3.7 顺带一提:爱心是算出来的
爱心不是用图片素材,也不是用字体里的 ♥ 字符(这个字符在有些字体里渲染出来是方块),而是用经典的心形参数方程算出来的:
x = 16·sin³t
y = 13·cos t − 5·cos 2t − 2·cos 3t − cos 4t
采样 60 个点连成多边形,坐标归一化到统一范围,所以换任何尺寸都不用改数字。整个游戏的图形元素(箭头、棋盘、按钮、爱心、道具图标)全部是 pygame 的绘图 API 程序化绘制的,没有依赖任何外部图片素材。
四、AIGC 使用过程
我用的是 CodeBuddy(Agent 模式),整个开发过程都是和它协作完成的。下面是七次比较有代表性的记录。
| # | 任务 | AI 提供了什么 | 实际效果 | 我做了什么 |
|---|---|---|---|---|
| 1 | 核心路径检测 | check_path 用 in_bounds() 作循环条件,避免越界特判 |
四个方向判断全部正确,边界不报错 | 逐行理解「越界」和「被挡住」的区别,补了中文注释 |
| 2 | 关卡设计(AI 出错) | 生成了第 2、3 关的布局 | ❌ 第 2 关无解:出现 R . . . L 面对面互相挡死 |
用求解器定位后改成 D . . . L;从此立规:每关必须过求解器 |
| 3 | 界面太"呆"(设计失误) | 为求"有序"用了整排同方向箭头当主链 | ❌ 玩起来从一头点到另一头,纯机械劳动 | 我试玩后反馈"显得很呆",AI 加了"连续同方向 ≤ 2"的约束并写成测试 |
| 4 | 爱心 / 倒计时 / 结算图 | 心形参数方程、时间条渐变、easeOutBack 弹入动画 | 直观、不需要图片素材 | 调整了颜色和版面;反馈"爱心给太多",改成每关 2 颗 |
| 5 | 道具机制重做 | 新增 path_cells 算飞行路线,道具从"点击获取"改成"飞过获取" |
道具从白送变成了需要规划路线 | 要求"每关只放一个 +5 秒",并核对出"先收集再移除"的顺序约束,补测试锁死 |
| 6 | 打包 exe 体积优化 | PyInstaller 打包;排查出 176 MB 是 numpy 拖来的 Intel MKL,排除后压到 14 MB | 微信可发送,对方双击即玩 | 要求用打好的 exe 跑自检,确认表情图和中文都正常 |
| 7 | Git 推送排障 | 定位代理配错(环境变量指向 2375)和引用写入异常 |
成功推送到 GitHub | 确认自己**的真实端口 |
三次详细说明
第 2 次:AI 生成的关卡居然是死局。
我当时的要求是"第 2、3 关弄得稍微难一点"。AI 很快就给出了两关的布局,看起来也挺像样。但我在写测试的时候顺手跑了一遍求解器,结果第 2 关直接报"无解"。
排查之后发现是第 2 行写成了 R . . . L——左边朝右、右边朝左,同一行面对面。左边的箭头想往右飞被右边的挡住,右边的想往左飞被左边的挡住,两个箭头互相锁死,谁都动不了。而且这个错误光看字符网格是看不出来的,我盯着看了半天都没发现问题。
改成 D . . . L 之后就通了。这件事给我留下的规矩是:每个关卡生成完,必须跑一遍求解器验证,不能凭肉眼觉得"看起来没问题"就收工。
第 3 次:做出来的关卡"看起来很整齐,玩起来很呆"。
第一版的关卡里,我为了让通关顺序明确,让 AI 摆了一整排同方向的箭头当"主链"。顺序确实唯一了,但也意味着玩家只要从最右边一路点过去就行,完全不需要思考和判断。
我自己试玩之后觉得不对劲,反馈说"几个同方向箭头聚集在一起可以连续点掉,显得很呆"。AI 分析后承认这是设计失误——为了追求"顺序唯一"牺牲了"每一步都要重新判断"。修正办法是把"顺序唯一"换成"方向交错":限制同一行/列上相邻连续同方向的箭头不超过 2 个。现在的关卡方向都是混着来的,每点一步都要重新看哪个箭头还有出路。
为了防止以后改关卡又改回去,这条约束被写成了自动测试。
第 6 次:打包出来的 exe 有 176 MB。
我想要一个能直接发给同学玩的版本,不用对方装 Python。AI 用 PyInstaller 打包很顺利,但打出来一看——176 MB,微信都发不出去(限制 100 MB)。
排查发现是 Anaconda 环境里的 numpy 连带了 Intel 的数学库 MKL,光那些 DLL 就有几十 MB,被整个拖进了包里。而这个游戏的图形全是 pygame 画的,一个 numpy 都不需要。加了一批 --exclude-module 之后,体积从 176 MB 掉到 14.3 MB。
这件事让我第一次意识到:"程序能跑"和"程序能发给别人跑"之间还有很长的路——资源路径、中文字体、崩溃日志、打包体积,每一项都是独立的问题。
五、测试结果
手工测试(作业要求的 T01–T06)
| 编号 | 测试内容 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|
| T01 | 点击前方无阻挡的箭头 | 箭头飞出棋盘并消失 | 箭头沿方向飞出并消除,有飞出动画 | ✅ |
| T02 | 点击前方有阻挡的箭头 | 箭头不消失,失误次数减 1 | 箭头晃动变红,爱心熄灭一颗,顶部提示被哪个格子挡住 | ✅ |
| T03 | 点击位于边缘且朝向棋盘外的箭头 | 箭头正常消失,不发生越界错误 | 正常飞出,无异常报错 | ✅ |
| T04 | 清除本关全部箭头 | 显示通关并进入下一关 | 弹出通关表情图和步数统计,点「下一关」进入下一关 | ✅ |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 弹出失败表情图,点「再来一次」重开本关 | ✅ |
| T06 | 游戏进行中重新开始 | 箭头布局和失误次数恢复 | 棋盘、爱心、倒计时、道具全部恢复初始状态 | ✅ |
自动化测试
手工测试之外,我把上面这些也写成了自动测试,总共 70 个用例:
test_core.py:42 个用例,覆盖 T01–T03 以及关卡质量检查;test_game.py:28 个用例,覆盖 T04–T06 以及界面流程。
其中 T01–T03 的覆盖方式是把四个方向各测一遍,而不是只测一个方向就认为对了。比如 T03 专门测了「四个角的箭头朝四个方向」,因为角落是最容易出现索引越界的地方。
界面部分也能自动测,靠的是一个技巧:给 SDL 设置虚拟显示驱动后,pygame 会在"虚拟屏幕"上绘制,不需要真的弹窗口,于是渲染代码也能被自动跑到,任何绘制时的报错都会当场暴露。
os.environ["SDL_VIDEODRIVER"] = "dummy" # 必须在 import pygame 之前设置
测试中真的抓到的问题
自动化测试不是摆设,它抓到了几个我自己看不出来的问题:
- 第 2 关原本无法通关——就是前面说的面对面死锁,靠求解器发现;
- 游戏启动后直接进第 1 关,开始界面不显示——初始化时误把状态设成了"游戏中",靠流程测试发现;
- 事件道具可以重复点击无限刷奖励——发奖励时没有先判断是否已触发,补了防护和测试。
另外还有几个是看截图看出来的:顶部信息栏文字重叠、底部提示被窗口边缘切掉、棋盘没有真正居中(算棋盘宽度时把"格子间距"也按格子数量算了,实际应该少一个)。
六、PSP 表格
| 任务 | 预估耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 1.5 | 1.0 | -0.5 |
| Python 与图形库学习 | 1.0 | 0.5 | -0.5 |
| 游戏界面实现 | 3.0 | 3.5 | +0.5 |
| 路径与碰撞逻辑实现 | 2.0 | 2.0 | 0 |
| 关卡设计 | 1.5 | 3.0 | +1.5 |
| AIGC 辅助开发 | —— | —— | —— |
| 测试与修改 | 1.5 | 2.5 | +1.0 |
| README 与博客撰写 | 2.0 | 2.5 | +0.5 |
| 合计 | 12.5 | 15.0 | +2.5 |
几点说明:
- 「AIGC 辅助开发」这一项没有单独计时,因为它和别的阶段是完全重叠的——写路径检测、设计关卡、改 bug、写测试,每一步都是和 AI 一起做的,硬把它拆出来单独计时没有意义。
- 「关卡设计」超时最多(+1.5 小时),因为改了两轮:先是 AI 生成的关卡无解要重做,后来又是"太呆"要重新设计约束。
- 「Python 与图形库学习」比预估少,因为 pygame 的基本用法 AI 讲得很清楚,我没花太多时间翻文档,主要时间花在理解坐标约定(比如 y 轴向下)这些容易搞反的细节上。
七、心得体会
AI 确实大幅降低了"从想法到能跑起来"的门槛,但它给的东西必须验证。
这次最大的感受是:AI 出稿很快,但它不一定对,而且错得很难用肉眼发现。第 2 关那个面对面死锁就是典型例子——布局看起来很整齐,AI 也说得头头是道,但整关其实是死的。如果不是我顺手跑了求解器,交上去就是一个"无法通关"的关卡,而评分表里专门有一条扣分项就是这个。
所以后来我养成了一个习惯:AI 产出的东西,尽量找一个能自动验证的办法。关卡就写求解器,路径判断就写单元测试,界面就导出截图自己看一遍。这次一共写了 70 个测试用例,抓出来的问题里有一半是我自己看不出来的。
"AI 一次写对"是错觉,真实的协作是来回改。
刚开始我会期待"描述清楚需求,AI 就给出完美结果"。但实际过程更像是我说一个方向、它出个初稿、我试玩/测试、发现问题、再反馈。
"同方向箭头聚成一排显得很呆"这个反馈就是我自己玩出来的,AI 一开始没意识到这个问题——因为它按"让顺序唯一"这个目标去做,逻辑上没错,但玩起来的手感只有人能判断。这类问题用户不反馈,AI 永远发现不了。
有些坑只有真做到那一步才会遇到。
比如打包 exe:代码写完、测试通过、GitHub 也传了,看起来这个项目就结束了。但真的打包发给同学,才发现有资源路径(打包后 __file__ 指向临时目录)、中文字体(对方电脑不一定有微软雅黑)、体积(176 MB 微信发不出去)这一连串问题。
这些问题在开发阶段完全暴露不出来,因为在自己机器上跑源码永远是对的。这段经历让我对"软件交付"这件事有了具体的认识——能跑只是第一步。
浙公网安备 33010602011771号