软工第二次作业
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16717 |
| 这个作业的目标 | 使用 Python 和 AIGC 完成“一箭又一箭”小游戏 |
| 学号 | 102401527 |
| GitHub 仓库 | https://github.com/wu873/- |
一、项目展示
游戏共三个界面,全部截图由项目自带的 tools/make_screenshots.py 自动生成(该脚本用 SDL 的无窗口模式批量出图,不需要手动一张张截)。
1. 开始界面

标题、玩法说明卡片、"开始游戏"和"退出游戏"两个按钮,背景有几个缓慢飘动的箭头作装饰。
2. 游戏界面(进行中)

这是第 2 关进行到一半的样子:左侧是棋盘,右侧面板显示当前关卡、剩余箭头(9/12)、剩余失误(3 颗爱心)、用时,以及"重新开始""返回主菜单"两个按钮。截图里鼠标正悬停在 (4,1) 的箭头上,能看到白色高亮框。
3. 碰撞反馈

点击被挡住的箭头时的瞬间:箭头变白、底下格子闪红、箭头沿行进方向来回抖动,并飘出"被挡住了!"的提示,同时剩余失误从 3 变成 2(一颗爱心变灰)。
4. 通关界面

5. 失败界面

结算界面会把刚才的棋盘压在底下显示,并给出用时和失误次数。通关且还有下一关时,主按钮是"下一关";失败时主按钮是"重新开始本关"。
二、项目介绍
游戏规则
棋盘上有若干带方向的箭头(上、下、左、右四种)。玩家点击箭头后,程序检查它前进方向上的路径:
- 如果箭头与棋盘边界之间没有其它箭头阻挡,该箭头飞出棋盘并被消除;
- 如果路径上存在其它箭头,该箭头不能消除,会抖动、变色并飘字提示,同时消耗一次失误机会;
- 清除本关全部箭头即通关,进入下一关;
- 失误次数(3 次)耗尽则本关失败,可以重新开始。
界面设计
整个窗口 960×700,分成三块:
- 顶部信息栏:左侧是游戏名,右侧显示"第 2 关 / 共 3 关";
- 左侧棋盘区:520×520 的深色面板,网格在面板里居中显示(所以 6×6、7×7、8×8 的关卡都能用同一套布局),网格交点画了点阵,和参考的小游戏风格一致;
- 右侧信息面板:关卡、剩余箭头、剩余失误、用时四项信息,加两个按钮。
四个方向各用一种固定的颜色(上=蓝、下=橙、左=紫、右=绿),玩家一眼就能看出箭头的朝向,不用去分辨细小的三角尖。
主要功能与特色
| 功能 | 说明 |
|---|---|
| 三个界面 | 开始 / 游戏 / 结算,通过 App.switch_to() 切换 |
| 完整操作 | 鼠标点击飞出或碰撞,悬停高亮,R 键重开,ESC 返回主菜单 |
| 三种动画 | 飞出(带拖尾残影)、碰撞抖动、飘字提示 |
| 失误可视化 | 爱心显示,用掉一颗变灰 |
| 用时统计 | 通关结算时显示本关用时 |
| 3 个关卡 | 6 / 12 / 15 个箭头,难度递进,全部经程序验证可通关 |
| 开发工具 | 关卡求解器、自动化测试、批量截图脚本 |
界面没有使用任何第三方美术素材——箭头、爱心、按钮全部用 Pygame 的图形接口画出来,字体调用系统自带的微软雅黑。所以仓库里不需要附带图片、音频等资源文件。
三、实现思路
整个程序拆成 6 个模块,各管一件事:
| 文件 | 职责 |
|---|---|
main.py |
程序入口:窗口、主循环、界面切换 |
scenes.py |
三个界面的布局与交互 |
game_logic.py |
游戏规则:路径检测、飞出、碰撞、失误、胜负 |
ui.py |
界面零件:中文字体、按钮、箭头图形、爱心 |
config.py |
尺寸、布局、配色等参数集中配置 |
levels.py |
关卡数据 |
这样拆的好处是:规则逻辑完全不碰"怎么画",改配色只要动 config.py,加关卡只要动 levels.py。
3.1 箭头和方向怎么表示
方向不用数字枚举,直接用字符串 "up" / "down" / "left" / "right",配上一次就能记住的单位向量表:
DIRECTION_VECTORS = {
"up": (-1, 0), "down": (1, 0),
"left": (0, -1), "right": (0, 1),
}
行号往下增大,所以"向上"是行号减 1。有了这张表,"往某个方向走一格"就统一写成 row + d_row, col + d_col,四个方向不用写四份代码。
棋盘本身用二维列表 grid[row][col],格子里放 Arrow 对象或 None。Arrow 只存三个字段:行、列、方向。
3.2 关卡怎么表示
关卡没有用坐标数组,而是用"字符网格",一行一个字符串:
{
"name": "第 1 关",
"hearts": 3,
"grid": [
"D....L",
"......",
"....UR",
"......",
"...U..",
"..L...",
],
},
U/D/L/R 分别代表四个方向的箭头,. 是空格子。这么做的好处是,源码里直接就能"看到"关卡长什么样,调整关卡不用算坐标;读取时由 parse_level() 一次性转成 (行, 列, 方向) 列表。
3.3 路径检测(核心)
这是整个游戏最关键的一段。因为基础版是单格箭头,判定条件是"同方向到边界之间没有其它箭头",所以只需要从箭头所在格出发,沿着它的方向一格一格往外走:
def path_is_clear(self, arrow):
d_row, d_col = DIRECTION_VECTORS[arrow.direction]
row, col = arrow.row + d_row, arrow.col + d_col
while 0 <= row < self.rows and 0 <= col < self.cols:
if self.grid[row][col] is not None:
return False
row += d_row
col += d_col
return True
两个要点:
- 循环条件
0 <= row < self.rows and 0 <= col < self.cols同时承担了"边界处理"。箭头走到棋盘外时循环自然结束,返回True,就表示"一路畅通,可以飞出"。这样写就不存在数组越界的可能——四个方向共用同一段代码,不需要像分方向写那样额外判断边界。这也是我在测试 T03 里专门验证的:把箭头放在最边缘并朝棋盘外,点击后可以正常飞出。 - 不需要遍历整行整列,走到第一个非空格子就可以立刻返回
False,比"先取出整行再判断"更直接。
判定结果只有两种,分别对应两条处理路径(Board.click()):
- 能飞出:立刻把该格子置为
None(这样后续其它箭头的路径判定会马上认为这条路通了),再生成一段飞出动画;如果此时棋盘上已经没有箭头,就把状态设为通关。 - 被挡住:生成抖动动画和飘字提示,
mistakes_left减 1;减到 0 就把状态设为失败。
有个细节值得一提:箭头是"先消失、后播动画"。数据上立刻移除,视觉上用一个独立的动画对象继续从原位置飞出去。这样即使玩家连点好几个箭头,规则判定也始终和玩家看到的棋盘一致,不会出现"箭头已经飞走了却还挡着路"的情况。
飞出距离是根据箭头位置现算的——从当前格中心一直算到完全离开棋盘面板,所以不管箭头在哪一格,看起来都是飞出去而不是原地消失。
3.4 坐标换算
鼠标点击的是像素坐标,棋盘用的是行列坐标,中间靠 Board.cell_at() 换算:
col = (pos[0] - ox) // C.CELL_SIZE
row = (pos[1] - oy) // C.CELL_SIZE
(ox, oy) 是网格在棋盘面板里居中后的左上角。换算完再检查一下行列是否越界,越界就返回 None,界面层拿到 None 就知道这一下点在棋盘外了。
3.5 关卡可解性验证(踩坑后加的)
这是本次开发中最重要的一个教训:两个箭头如果互相指着对方,这一关就永远不可能通关。比如下面这种情况,左边的箭头朝右、右边的箭头朝左,中间是空的,谁都想往对方那边飞,结果谁都飞不出去:
→ · ←
我最初设计的三关竟然都有这种死局:第 1 关的 (0,0)↓ 和 (2,0)↑、第 2 关的 (2,5)↓ 和 (4,5)↑、第 3 关的 (2,0)↓ 和 (7,0)↑,全都是隔着空格互相指着对方。前两关是手工推演规则时发现的,第 3 关则是在排查时写了个求解器才查出来——因为关卡数据"看起来"都很合理,光靠肉眼很难发现,所以我写了 tools/check_levels.py:每一步找出当前所有能飞出的箭头,挨个尝试,用深度优先搜索加记忆化找出一个通关顺序;如果搜遍所有可能都搜不到,就说明这一关无解。
def search(remaining):
if not remaining:
return []
if remaining in memo:
return memo[remaining]
memo[remaining] = None # 先占位,避免重复搜索
for cell in free_arrows(remaining, ...):
rest = search(remaining - {cell})
if rest is not None:
memo[remaining] = [cell] + rest
return memo[remaining]
return None
求解器报出"无解"之后,我把死局的两个箭头之一改掉,重新验证,三关才全部通过。现在这个脚本还能打印出每一关的一组通关顺序,试玩的时候可以照着走。
3.6 中文字体
Pygame 的默认字体不含汉字,直接渲染中文会显示成一串方框。所以 ui.py 里做了一层字体查找:先按优先级在系统字体目录里找微软雅黑、黑体等字体文件,找不到再用 pygame.font.match_font() 按字体名匹配,仍找不到才退回默认字体。字体对象按字号缓存,只在第一次用到时创建。
四、AIGC 使用过程
本次开发使用的是 Claude Code(命令行 Coding Agent)【如果实际用的是别的工具/模型,请改成你自己的情况】。下面 4 次记录都来自真实开发过程。
| 子任务 | 借助何种 AIGC 技术 | AI 实现或提供了什么 | 效果如何 | 人工修改 |
|---|---|---|---|---|
| 游戏界面与基础功能 | Claude Code | 按"开始界面 + 游戏界面 + 结算界面"的要求,生成了整套 Pygame 界面代码:窗口与三栏布局、按钮控件、中文字体自动查找、箭头图形(只画一个朝右的箭头,再用旋转得到另外三个方向并加缓存)、爱心失误显示,以及飞出、抖动、飘字三种动画 | 代码可直接运行,界面基本达到预期 | 渲染出截图逐张检查后,发现两处文字遮挡:底部状态提示和右侧的快捷键说明叠在了一起;碰撞飘字正好压在箭头格子上。各自调整了绘制坐标后正常 |
| 关卡设计 | Claude Code | 生成了 3 关的字符网格关卡数据 | 关卡在源码里可读性很好,但三关全部是死局:每关都出现了互相指着对方的一对箭头(如第 1 关 (0,0)↓ 与 (2,0)↑、第 3 关 (2,0)↓ 与 (7,0)↑)。这种箭头谁也飞不出去,关卡不可能通关,但表面上看布局完全正常 | 手工推演规则时先发现前两关的问题并调整;为防遗漏又写了 tools/check_levels.py 用程序验证可解性,脚本随即报出第 3 关"无解"。据此继续调整:第 1 关把 ↑ 从 (2,0) 挪到 (2,4)、第 2 关把 (4,5) 的 ↑ 改成 ←、第 3 关把 (2,0) 由 ↓ 改成 →。改完重新验证,三关均报告可以通关 |
| 自动化测试 | Claude Code | 按作业要求的 T01~T06 编写测试脚本,用构造鼠标事件的方式驱动真实的界面代码(而不是绕开界面直接改数据) | 首次运行 6 项里 5 项通过,T05(失误耗尽后显示失败)未通过 | 排查后确认不是游戏的问题:游戏会在最后一次碰撞动画播完(约 0.85 秒)之后,再等 0.55 秒才切换到结算界面,而测试只推进了 1.2 秒。把测试的等待时间改成 2 秒后通过。另外补了一项 T07,专门测"重新开始"按钮本身 |
| 开发环境 | Claude Code | 安装 Pygame 时报错,AI 判断是当前 Python 3.14 没有官方 Pygame 的预编译包、退化成源码编译又拉不到依赖,建议改用 pygame-ce 并给出国内镜像的安装命令 |
一次安装成功(pygame-ce 2.5.8),import pygame 的用法与官方版完全一致 |
确认不需要改动任何业务代码,并把这个差异写进 README 的安装说明 |
几点感受:
- AI 写"界面长什么样"这类代码又快又稳,但布局对不对,只有真的渲染出来看一眼才知道。前两条记录里的问题都不是语法错误、程序也能正常跑,纯粹是画面上压住了字。
- 关卡数据是这次最值得警惕的地方。AI 生成的关卡"看起来很合理",但规则上的死局从表面上根本看不出来。最后的解决办法是写程序去验证程序——这件事本身很适合交给 AI 做,但"要验证"这个判断得由人来做。
- 第三条记录里,AI 写的测试一开始没通过,而失败的原因在测试自己身上。第一反应容易以为是游戏有 bug,实际排查下来是测试等待时间不够。这种"谁错了"的判断,是需要自己过一遍代码才能下的。
- AI 生成的代码里,有些地方我改成了自己更习惯的写法(比如把方向判定写成统一的向量表 + 一个循环,而不是四个方向各写一段)。
五、测试结果
测试脚本:tools/game_tests.py,运行 python tools/game_tests.py。测试通过构造鼠标事件调用真实界面代码,走的是"鼠标点击 → 界面派发 → 棋盘判定"的完整链路。
| 编号 | 测试内容 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|
| T01 | 点击前方无阻挡的箭头 | 箭头飞出棋盘并消失 | 点击 (0,0) 后箭头数 6 → 5 | 通过 |
| T02 | 点击前方有阻挡的箭头 | 箭头不消失,失误次数减 1 | 箭头数保持 6 不变,失误次数 3 → 2 | 通过 |
| T03 | 点击边缘且朝向棋盘外的箭头 | 箭头正常消失,不发生越界错误 | 最右列朝右的箭头飞出,箭头数 6 → 5;另外单独测了四个方向的 1×1 边界,均正常 | 通过 |
| T04 | 消除本关全部箭头 | 显示通关并进入下一关 | 三关分别用 6 / 12 / 15 步通关,且都自动跳转到结算界面 | 通过 |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 连点 3 次被挡住的箭头后状态为 lost、剩余失误 0,并进入结算界面 | 通过 |
| T06 | 游戏进行中重新开始 | 箭头布局和失误次数恢复 | 重开前(箭头 10,失误 2)→ 重开后(箭头 12,失误 3),用时归零 | 通过 |
| T07 | 点击"重新开始"按钮(附加) | 按钮也能重置本关 | 点按钮后箭头数恢复为 6,失误次数恢复为 3 | 通过 |
关卡可解性验证(python tools/check_levels.py):
第 1 关 (6 x 6,6 个箭头,失误上限 3) 初始能飞出的箭头:5 个 可以通关
第 2 关 (7 x 7,12 个箭头,失误上限 3) 初始能飞出的箭头:5 个 可以通关
第 3 关 (8 x 8,15 个箭头,失误上限 3) 初始能飞出的箭头:7 个 可以通关
全部 3 关都可以通关。
除自动化测试外,我还手工把三关都实际玩通关了一遍,确认每一关都有合理的通关顺序、难度递进正常。
六、PSP 表格
下表的"预估耗时"是参考值,请按你自己的实际情况调整;"实际耗时"必须填你真实投入的时间,差异 = 实际 − 预估。
| 任务 | 预估耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 1.5 | 2 | 0.5 |
| Python 与图形库学习 | 2 | 2 | 0 |
| 游戏界面实现 | 2 | 1.5 | -0.5 |
| 路径与碰撞逻辑实现 | 2.5 | 2 | -0.5 |
| 关卡设计 | 1.5 | 2 | 0.5 |
| AIGC 辅助开发 | 1 | 1 | 0 |
| 测试与修改 | 2 | 3 | 1 |
| README 与博客撰写 | 2 | 3 | 1 |
| 合计 | 14.5 | 16.5 | 2 |
七、心得体会
- AI 生成的代码"能跑"和"正确"是两回事:界面代码一次就跑通了,但截图一看就有文字重叠;关卡数据看起来没毛病,实际有两关是死局。
- "让程序验证程序"是这次最大的收获:与其盯着关卡数据看,不如写个求解器;与其手动试玩每一关,不如让脚本自动通关。
- AI 写的测试也会错:T05 失败时,先怀疑游戏、排查后才发现是测试等待时间不够。判断"到底谁错了"的能力比写代码本身更重要。
- 环境问题(Python 3.14 装不上官方 pygame)这类坑,AI 能快速给出替代方案,但验证方案是否真的可行仍然要靠自己跑一遍。
- 后续想做的改进:【例如:加音效、撤销上一步、随机生成可通关的关卡、打包成 exe】。

浙公网安备 33010602011771号