2026软件工程第二次作业
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601软件工程与软件工程实践 |
| 这个作业要求在哪里 | 软件工程课程第二次个人作业 |
| 这个作业的目标 | 使用 Python 独立开发一个“一箭又一箭”风格的小游戏 |
| 学号 | 102401301 |
| GitHub 仓库 | GitHub仓库链接 |
项目展示
这是一段游戏演示:从开始界面进入第 1 关,故意点一个被挡住的箭头(失误次数从 3 减到 2),然后依次消除箭头,最后结算通关。

下面是各个界面的截图。
开始界面:

游戏界面。顶部信息栏显示关卡、剩余箭头、剩余失误、用时、本关最佳星级,右侧是撤销/提示/重新开始/音效四个按钮:

点击被挡住的箭头时,箭头会晃动并变红,失误次数减 1:

点「提示」后,一个当前能飞出的箭头会被呼吸圈高亮:

关卡通过,按本关消耗的失误次数评星(这一关零失误,3 星):

失误耗尽时本关失败,可以重开:

关卡选择界面,未通关的关卡显示空心星,已通关的显示星级和最佳用时:

随机关卡,每次随机生成,保证有解:

项目介绍
规则
棋盘是一个网格,格子里放箭头,箭头朝上、下、左、右四个方向之一。点一个箭头,程序检查它前进方向到棋盘边界之间有没有别的箭头:
- 前方没有箭头,它沿所指方向飞出棋盘,然后消失;
- 前方有箭头,它不能飞出,会晃动变红提示碰撞,同时失误次数减 1。
清空当前关卡的全部箭头就过关,进入下一关;失误次数减到 0 则本关失败,可以重开。
判定只看同一行或同一列,不涉及斜向。
界面
分四类界面:开始界面、游戏界面、结果界面,另外加了一个关卡选择界面。
游戏界面顶部有一条信息栏,显示当前关卡、剩余箭头数量、剩余失误次数、用时、本关最佳星级。右侧排四个按钮:撤销、提示、重新开始、音效。
棋盘用深色底板加网格线,箭头用四个颜色区分方向(上黄、下橙、左绿、右蓝)。箭头飞出时沿方向平移并淡出,被挡时来回晃动并闪红。
主要功能
基础功能之外,我参考原版《一箭又一箭》做了几个附加功能:
撤销。回退上一步,包括恢复被消除的箭头和退还失误次数。每关有次数上限,按钮或 Z 键触发。
提示。高亮一个当前能消除的箭头,用呼吸圈表示。每关有次数上限,按钮或 H 键触发。
计时与星级评价。记录本关用时。按消耗的失误次数评 1 到 3 星,零失误 3 星,失误 1 次 2 星,其余 1 星。星级显示在信息栏和结算界面上。
关卡选择与解锁。独立的选关界面,显示每关的星级和最佳用时。通关后解锁下一关,没解锁的关卡是灰色锁定状态。
随机关卡。随机生成关卡,用的是反向构造,生成出来的关卡一定有解。
进度存档。解锁进度、每关最佳星级和用时、音效开关都存在 save/progress.json,下次启动自动读回。文件损坏或读不出来时会退回默认值,不会导致游戏打不开。
音效。飞出、碰撞、通关、失败、提示这几个音效是用正弦波在代码里合成的,没有用外部音频文件。没有声卡的环境会自动静音。
内置关卡一共 5 关,棋盘从 3×3 到 6×6。
特色
关卡的可解性由程序保证,不靠人工试。预设关卡用生成器产出后又用求解器校验,随机关卡在生成阶段就保证有解。这一点我下面会展开讲,因为中间踩过坑。
实现思路
箭头、方向、关卡怎么表示
箭头是一个数据类,用行列坐标加方向表示:
@dataclass
class Arrow:
row: int
col: int
direction: str # "up" / "down" / "left" / "right"
state: str = "idle" # idle / flying / blocked
anim_t: float = 0.0
方向到坐标位移的映射集中放在一处,避免散落在各个函数里:
DIRS = {
"up": (-1, 0),
"down": (1, 0),
"left": (0, -1),
"right": (0, 1),
}
行号向下增加,列号向右增加,所以向上的行增量是 -1。
关卡是纯数据,跟逻辑分开,方便调整和测试:
{
"name": "第 1 关 · 入门",
"rows": 3, "cols": 3,
"mistakes": 3, "undos": 3, "hints": 3,
"arrows": [(2, 0, "up"), (1, 0, "right"), ...],
}
盘面状态用字典维护,键是 (row, col),值是箭头。这样按坐标取箭头是 O(1),重新开始或撤销时重建也快。
路径检测
这是整个游戏的核心,也是最重要最复杂的地方。
做法是从箭头所在格出发,沿方向一格一格走,在棋盘范围内遇到箭头就判定被挡:
def is_blocked(self, arrow):
dr, dc = DIRS[arrow.direction]
row, col = arrow.row + dr, arrow.col + dc
while self.in_bounds(row, col):
if self.solid_at(row, col):
return True
row += dr
col += dc
return False
边界判断放在 while 的条件里,先判断再取格子。这样写的原因是:如果反过来先取出下一格再判断有没有越界,向上和向左走的时候会先访问到 grid[-1] 这种负下标,Python 不会报错但会绕到棋盘另一端,形成很难发现的 bug。把它放进循环条件就从源头绕开了这个问题。这一点对应作业测试项 T03(边缘且朝外的箭头不应越界报错)。
另外,箭头飞出时有一段动画。动画播放期间这个箭头在逻辑上算已经消除,不应该继续挡住别的箭头,所以在判断里用 solid_at 过滤掉 flying 状态的箭头,而不是直接判断格子里有没有箭头。否则会出现「箭头明明飞出去了但还是挡着后面」的情况。
关卡怎么保证有解
第一版关卡是我自己摆的。摆完用程序一验证,发现有几关根本通不了——箭头互相指着,形成闭环,谁都飞不出去。人工摆关卡很容易摆出这种死局,而且肉眼不容易看出来。
后来改用反向构造。思路是把拆除顺序倒过来放置:
放第一个箭头时棋盘是空的,随便放。放第 k 个箭头时,棋盘上只有「会在它之后才被拆掉」的那些箭头,所以这个箭头此刻的路径上必须没有它们。只要满足这个条件放进去,那么按放置顺序的逆序点击,每个箭头出手时前方都是空的,一定能全部清掉。
这样构造出来的布局天生有解,不需要事后去试。
# 放入 (row, col) 处箭头时,检查该方向到边界之间是否为空
r, c = row + dr, col + dc
clear = True
while 0 <= r < rows and 0 <= c < cols:
if grid[r][c] is not None:
clear = False
break
r += dr
c += dc
if clear:
grid[row][col] = direction
生成之后仍然会用求解器再校验一遍,作为双保险。
撤销怎么实现
每次操作前先给棋盘拍个快照,记下所有箭头的坐标方向和剩余失误数:
def snapshot(self):
return {
"arrows": [(a.row, a.col, a.direction)
for a in self.arrows if a.state != "flying"],
"mistakes_left": self.mistakes_left,
}
撤销时用 restore() 把棋盘清空重建。快照只存坐标和方向这类基本数据,不存箭头对象,避免撤销后旧对象还被别处引用。
音效怎么生成
直接用正弦波算采样点,衰减做包络,塞进 pygame.mixer.Sound:
for i in range(count):
t = i / SAMPLE_RATE
envelope = 1 - i / count
sample = math.sin(2 * math.pi * freq * t) * volume * envelope
buf.append(int(max(-1.0, min(1.0, sample)) * 32767))
这样不用引入任何音频素材文件。
AIGC 使用过程
开发过程中我一直在用 AIGC 编程助手(ZCode,接的是 DeepSeek 模型)辅助。下面是几次比较有代表性的协作,以供查阅:
| 子任务 | 借助何种 AIGC 技术 | AI 实现或提供了什么 | 效果如何 | 人工修改 |
|---|---|---|---|---|
| 路径检测 | ZCode(DeepSeek) | 生成四个方向的检测代码 | 提示了向上/向左的越界隐患 | 把边界判断改到 while 条件里,并补了四方向的边界测试 |
| 关卡设计 | ZCode(DeepSeek) | 提出反向构造法,生成可解布局 | 生成了必定可解的关卡,但第一版初始没有阻挡 | 增加筛选条件,重新挑选布局 |
| 字体加载 | ZCode(DeepSeek) | 定位到 pygame 字体枚举崩溃的原因 | 给出直接按字体文件路径加载的方案 | 加了异常兜底,保证中文能显示 |
| 随机关卡校验 | ZCode(DeepSeek) | 编写生成器与求解器 | 求解器自身有个循环上限的 bug,被测试抓到 | 改成固定循环次数 |
| 界面可读性 | ZCode(DeepSeek) | 排查文字与箭头重叠 | 给出结果卡片方案 | 卡片加高并重排元素间距 |
下面挑几个展开说。
1. 路径检测里的越界问题
我让 AI 生成四方向的路径检测。它第一版给的是「先取下一格、再判断是否越界」的写法。我追问了向上和向左的情况,它指出这样会在边界处访问到负下标——Python 里 grid[-1] 不报错,会绕到棋盘另一端,判定结果就错了,而且不会抛异常,很难靠运行发现。
改成把 in_bounds 放进 while 条件后正常。这个改动对应作业的 T03,我在 tests/test_board.py 里给四个方向都贴边的场景补了测试(test_edge_arrow_pointing_outward_is_free)。
这里我想说的是,AI 第一版给的代码是能跑的,正常下棋大部分时候也不会出问题,只在边缘才暴露。如果不追问、不专门测边界,这个 bug 会一直留着。
2. 关卡设计:从人工摆放改到反向构造
我先让 AI 生成几个关卡数组。它生成了,但我试玩时发现有些布局根本通不了。我把它生成的结果拿回来验证,确认是无解闭环。
然后 AI 提出反向构造法:不直接摆最终布局,而是按「拆除顺序的逆序」一个一个放,放每个箭头时保证它的路径上只有会在它之后被拆掉的箭头。这样按放的反序点击就一定有解。它还写了配套的生成器和求解器。
效果是生成出来的关卡确实都有解了。但我又发现新问题:第一版生成出来的布局,开局时一个被挡的箭头都没有,玩家点哪个都能飞,阻挡机制完全体现不出来。于是加了筛选条件——要求初始被阻挡的箭头数够多、方向分布也要多样,重新挑了一批布局写进 levels.py。
再后来我让AI把「每关都必须可解」写成了自动化测试(test_every_level_is_solvable)。这个测试后来还真派上了用场,下面会提到。
3. 字体加载的意外崩溃
游戏第一次启动就崩了,报 TypeError: expected str, bytes or os.PathLike object, not int,位置在 pygame 的 sysfont.initsysfonts_win32。
我把报错贴给 AI,它定位到是 pygame 枚举系统字体时读到了注册表里的异常值。给的方案是绕开字体枚举,直接按字体文件路径加载,优先用 C:\Windows\Fonts\msyh.ttc(微软雅黑)。
改完之后中文正常显示了。我又让它顺手加了兜底:如果所有字体文件路径都找不到,就退回系统默认字体,可能不支持中文但不至于崩。
4. 生成器校验器自己的 bug
这个比较有意思。我写完随机关卡的功能后,测试 test_generated_levels_are_solvable 报某个随机种子的关卡不可解。
我第一反应是生成器有问题。但让 AI 一起看之后发现,问题出在校验函数 solvable() 自己身上:它的循环上限用了 len(remaining),而这个值会随着箭头被消除而变小,导致大棋盘在还没判完的时候就提前退出了循环,被误判成无解。
改成固定循环「箭头总数」次、每轮只消一个箭头,改完 30 个随机种子全部通过。
这个 bug 是测试抓出来的,不是我看出来的。如果没有这条测试,交付出去的就是「随机功能偶尔生成无解关卡」。
5. 界面可读性
加入星级和用时之后,结果界面出问题了:卡片里的「完美通关 · 用时 00:02」这行字压到了「下一关」按钮上。
我截图给 AI 看,它建议把结果面板做成不透明的圆角卡片,卡片里的元素都相对卡片定位。改成这样之后,卡片加高到 364,标题、星级、说明、两个按钮按内容高度重新分配间距,重叠就没了。
顺便说一句,最早的结果界面用的是半透明遮罩加文字,文字会跟背后的棋盘箭头叠在一起,也是那时候一起改掉的。
测试结果
测试用 pytest,命令是 python -m pytest -v。因为这些测试要投递鼠标事件、还要渲染界面,所以做了无头支持(SDL 的 dummy 驱动,离屏 Surface),不依赖显示器和声卡。
作业要求的测试项
| 编号 | 测试内容 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|
| T01 | 点击前方无阻挡的箭头 | 箭头飞出棋盘并消失 | 箭头进入 flying 状态,动画结束后从棋盘移除,剩余箭头数 -1 | 通过 |
| T02 | 点击前方有阻挡的箭头 | 箭头不消失,失误次数减 1 | 箭头仍在棋盘上,进入 blocked 状态,失误次数 -1 | 通过 |
| T03 | 点击位于边缘且朝向棋盘外的箭头 | 箭头正常消失,不发生越界错误 | 正常飞出消除,无越界异常 | 通过 |
| T04 | 消除本关全部箭头 | 显示通关并进入下一关 | 显示「本关通过」,点主按钮进入下一关;最后一关显示「全部通关」 | 通过 |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 显示「本关失败」,重开后失误和布局复原 | 通过 |
| T06 | 游戏进行中重新开始 | 箭头布局和失误次数恢复 | 剩余箭头数和失误次数都恢复到初始值 | 通过 |
T01 到 T06 这几条不是直接调函数,而是构造真实的 MOUSEBUTTONDOWN 事件、按格子中心的像素坐标投给 App,这样连「屏幕坐标转格子坐标」的换算也一起测到了。
路径检测与棋盘逻辑
| 用例 | 验证点 | 结果 |
|---|---|---|
| 四个方向分别被阻挡 | 上/下/左/右都能正确判定 | 通过 |
| 前方无箭头 | 判定为可飞出 | 通过 |
| 不同行或列 | 不互相阻挡 | 通过 |
| 中间隔着空格 | 仍判定为被阻挡 | 通过 |
| 四方向贴边 | 紧贴边界的箭头可飞出,不越界 | 通过 |
| 点击空格或棋盘外 | 忽略,不改变状态 | 通过 |
| 失误下限 | 失误次数不会降到 0 以下 | 通过 |
| 飞出中的箭头 | 不计入剩余数量,也不阻挡其他箭头 | 通过 |
关卡可解性
作业里写了「关卡实际上无法通关」要扣分,所以这条单独测:
| 用例 | 验证点 | 结果 |
|---|---|---|
| 关卡数量 | 至少 3 关(实际 5 关) | 通过 |
| 每关可解 | 贪心求解器能把每关清空 | 通过 |
| 每关初始有阻挡 | 保证玩法能体现阻挡机制 | 通过 |
| 关卡数据合法 | 无越界、无重叠、方向合法 | 通过 |
| 四个方向齐全 | 5 关合起来用到了全部四个方向 | 通过 |
关卡试玩
用 python tools/playtest.py 对每关做两件事:一是只用正确操作通关,验证能不能零失误过;二是随机乱点若干次,统计通关率,用来衡量难度。
| 关卡 | 尺寸 | 箭头数 | 初始被挡 | 失误预算 | 可零失误通关 | 随机点击通关率 |
|---|---|---|---|---|---|---|
| 第 1 关 · 入门 | 3×3 | 5 | 3 | 3 | 是 | 33% |
| 第 2 关 · 交错 | 4×4 | 7 | 5 | 4 | 是 | 25% |
| 第 3 关 · 层层设卡 | 5×5 | 10 | 6 | 5 | 是 | 16% |
| 第 4 关 · 回环 | 5×5 | 10 | 6 | 5 | 是 | 21% |
| 第 5 关 · 迷宫 | 6×6 | 12 | 7 | 6 | 是 | 16% |
五关都能零失误通关,随机乱点的通关率整体往下走,难度是递增的。
附加功能
| 功能 | 验证点 | 结果 |
|---|---|---|
| 撤销 | 恢复被消除的箭头和失误次数 | 通过 |
| 撤销 | 次数用完后无法继续撤销 | 通过 |
| 提示 | 提示的箭头一定是可消除的 | 通过 |
| 提示 | 次数用尽后失效,高亮会超时消失 | 通过 |
| 星级 | 0 失误 3 星、1 失误 2 星、其余 1 星 | 通过 |
| 计时 | 用时随帧累加 | 通过 |
| 存档 | 写入后能完整读回 | 通过 |
| 存档 | 同星级保留更快的时间 | 通过 |
| 存档 | 文件损坏时安全回退,不崩溃 | 通过 |
| 随机关卡 | 30 个随机种子生成的关卡全部可解 | 通过 |
| 随机关卡 | 求解器能识别出无解布局 | 通过 |
| 选关解锁 | 未解锁关卡无法开始,已解锁可开始 | 通过 |
| 通关结算 | 通关后记录星级并解锁下一关 | 通过 |
| 音效 | 无声卡环境下播放不报错 | 通过 |
汇总
一共 56 项测试,全部通过。
$ python -m pytest -q
........................................................ [100%]
56 passed in 0.65s
除了无头测试,我也用真实的显示窗口跑过:窗口正常创建,帧率稳定在 60 FPS,中文字体、棋盘、箭头显示正常,鼠标点击、撤销/提示/重新开始/音效按钮、选关界面、星级结算都能用。
另外我把仓库重新克隆到一个干净目录,按 README 的步骤建环境、装依赖、跑测试、启动游戏,走了一遍确认别人拿到这个仓库能跑起来。
PSP 表格
| 任务 | 预估耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 1.5 | 1.5 | 0 |
| Python 与图形库学习 | 2 | 1 | -1 |
| 游戏界面实现 | 3 | 3.5 | +0.5 |
| 路径与碰撞逻辑实现 | 2.5 | 2 | -0.5 |
| 关卡设计 | 2 | 3 | +1 |
| AIGC 辅助开发 | 2 | 2.5 | +0.5 |
| 测试与修改 | 2 | 3 | +1 |
| README 与博客撰写 | 2 | 2 | 0 |
| 合计 | 17 | 18.5 | +1.5 |
差异比较大的两项是关卡设计和测试修改,都在下面说。
心得体会
AI 帮了什么
省时间的地方主要是样板代码和查错。界面绘制、状态机、动画这些我不太想手写的部分,AI 给得很快,改改就能用。查错也快,比如字体崩溃那个 TypeError,错误信息指向 pygame 内部,我一开始不知道从哪下手,AI 直接说是字体枚举读到了注册表异常值。
另外有些思路是我自己想不到的。反向构造法就是 AI 提的。我原来的想法是随机摆布局然后用求解器筛掉无解的,效率低还容易筛不出来。反向构造从根上保证有解,这个思路我没想到。
它还帮我做了几件我本来要花不少时间的事:写测试用例、解释报错、给界面问题出方案。
以下是我与Zcode的交互界面:

出了问题的地方
AI 第一版给的代码不一定对。 路径检测那个越界问题就是典型。代码能跑,常规情况也没事,只在边缘出问题,而且 Python 还不报错。如果我不追问、不专门测边界,这个 bug 就留在那了。这让我意识到,AI 给的东西不能因为「跑起来了」就当成对的。
我自己的关卡也是错的。 第 5 关是我自己摆的,一验证发现无解。人工摆关卡很容易摆出闭环死局,肉眼看不出来。这件事让我明白为什么要写自动化校验——不是形式,是真的能挡住错误。
测试本身也会错。 校验器那个循环上限的 bug 是测试抓出来的;但反过来说,我一开始也写错过测试用例(有个测试断言「贴着右边界的箭头会被左边箭头挡住」,其实它右边已经是棋盘外,本来就该飞出去)。写测试的时候脑子也得清楚。
AI 不知道界面好不好看。 结果界面文字压到按钮上,AI 是在我截图给它看之后才发现的。界面这种东西得自己看、自己判断,没法全靠 AI。
AI 说的和实际跑出来的可能不一样。 每次改完我都会实际跑一遍,或者截图确认。尤其是「功能实现了」和「功能好使」是两回事,中间差着一个验证。
收获
这次做下来,最大的收获不是学会了 pygame,而是对怎么用 AI 写代码有了点感觉。
我现在的做法是:让 AI 写,但关键的地方要自己看懂、自己验证。路径检测怎么判边界、关卡为什么有解、测试到底测了什么,这些我得自己能讲清楚。作业里说助教可能针对关键代码提问,我觉得这个要求挺合理的——代码可以 AI 写,但责任在我自己。
还有一个具体的收获是把正确性写成测试。人工试玩能发现的问题有限,尤其是边界情况和随机情况。T03 那种边缘越界,人工玩的时候不太会特意去点四个角试试;随机关卡无解,随机生成几十次也不一定碰得上。写成测试之后,每次改动都能自动跑一遍,心里有底。
差异方面,关卡设计超了 1 小时,主要花在第一版布局无解、改反向构造、又发现初始没阻挡这几轮返工上;测试与修改超了 1 小时,因为补充功能和修 bug 之后测试要跟着加。Python 与图形库学习省了 1 小时,因为 pygame 以前打数模比赛的时候用过一些。
最后附上仓库地址:https://github.com/daxuemangongdao/myhomework!
(为了方便大家查看,在文章开头也附上了仓库地址ヘ( ̄ω ̄ヘ))

感谢大家读到这里,祝生活愉快 (๑•ᴗ•๑)♡

浙公网安备 33010602011771号