2026 秋软件工程个人作业(第二次)
软件工程个人作业2:一箭又一箭小游戏开发
一、作业基础信息
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 2026-01 软件工程与软件工程实践班级博客 |
| 这个作业要求在哪里 | 2026秋软件工程个人作业(第二次) |
| 这个作业的目标 | 使用 Python 和 AIGC 完成“一箭又一箭”小游戏 |
| 学号 | 102401139 |
| GitHub 仓库 | game1-arrow |
二、项目展示
本次开发的“一箭又一箭”小游戏拥有完整的开始界面、游戏主界面、通关界面、失败界面,支持多关卡切换、失误判定、碰撞反馈、重新开始功能。
演示GIF/视频:

开始界面:

选关界面:

游戏对局界面:

旋转棋盘界面:

通关界面:

失败界面:

三、项目介绍
1. 游戏规则
棋盘上分布着朝上(^)、下(v)、左(<)、右(>)四个方向的箭头。玩家点击一个箭头后,程序检测其前进方向上的路径:
-
若箭头与棋盘边界之间没有其他箭头,箭头飞出棋盘并被消除;
-
若路径上存在其他箭头,箭头不能被消除,并通过晃动、变红和 “前方有阻挡!” 文字给出碰撞反馈,同时消耗 1 次失误机会;
-
每关有 2~3 次失误机会(前 6 关 3 次、后 4 关 2 次),并且每关限时(60~160 秒);清空本关全部箭头即通关并进入下一关;失误次数耗尽或超时则本关失败,可重新开始。
路径判定采用网格单格箭头:只需判断同一行(左右箭头)或同一列(上下箭头)、箭头与边界之间是否还有其他箭头。
2. 界面与功能特色
-
完整三级界面:开始页 → 游戏页 → 通关/失败结果页;
-
实时显示:当前关卡、剩余箭头数、剩余失误次数;
-
箭头飞出动画、碰撞晃动反馈,交互直观;
-
内置10个可通关合法关卡,布局合理、无死锁;
-
支持随时重新开始当前关卡。
-
后三个关卡新增旋转棋盘功能,增强视觉迷惑性。
四、实现思路与核心原理
4.1 箭头和方向怎么表示
方向用四个字符常量 ^ v < >,每个方向对应一组「行增量、列增量」:
DIRECTIONS = {
'^': (-1, 0), # 上
'v': (1, 0), # 下
'<': (0, -1), # 左
'>': (0, 1), # 右
}
这里有个容易搞反的地方:屏幕坐标系里 y 轴是向下的,所以第 0 行在最上面,"向上" 是行号减一。我一开始把 ^ 写成 (1, 0),结果所有上下箭头的路径检测都反了 —— 逻辑测试居然还能过(因为测试用例也跟着错了),直到实际试玩时才发现 "明明朝上的箭头点了说被挡"。
棋盘用二维列表存储:grid[r][c] 存放方向字符或 None(空格)。选二维列表而不是字典,是因为棋盘是稠密矩形网格,按坐标直接索引 O (1),而且后面做棋盘旋转时行列互换非常方便。
4.2 路径检测(核心)
这是整个程序最关键的一段。做法是从箭头的下一个格子开始,沿它指的方向一格一格往外走:
def can_fly(self, r, c):
ch = self.arrow(r, c)
if ch is None:
return False
dr, dc = DIRECTIONS[ch]
nr, nc = r + dr, c + dc # 从下一格开始看,不检测自身
while self.in_bounds(nr, nc): # 只要还在棋盘内就继续走
if self.grid[nr][nc] is not None:
return False # 碰到箭头 -> 被挡住
nr += dr
nc += dc
return True # 走出棋盘了 -> 可以飞出
几个关键点:
- 从下一格开始,不检测箭头自身所在的格子
- 只沿方向向量走(同行或同列),不会跨行误判
- 遇到边界自动停止,
in_bounds兜底,不会数组越界 - 返回纯布尔值,调用方根据结果决定是消除还是扣失误,逻辑和界面完全解耦
时间复杂度 O (棋盘边长),最大 9×9 的棋盘完全不是性能瓶颈。
4.3 游戏状态管理与扩展机制
通过 Session 类统一管理一局游戏的所有状态,界面层只和 Session 打交道:
class Session:
def __init__(self, level, max_mistakes=3, time_limit=0, rotate_every=0):
self.board = Board(level)
self.mistakes_left = max_mistakes
self.time_left = time_limit # 0 表示不限时
self.rotate_every = rotate_every # 0 表示不旋转
self.status = 'playing' # playing / won / lost
self.undo_stack = []
状态机只有三种:playing(进行中)、won(通关)、lost(失败)。UI 层根据状态渲染不同界面、响应不同操作,切换逻辑清晰。
4.4 棋盘旋转机制(附加玩法)
为什么要加旋转? 基础玩法玩到后面关卡,箭头多了之后策略性会下降 —— 因为边缘的箭头永远可以先飞,玩家只需要 "从外往里消"。加入棋盘旋转后,每消除几个箭头整个棋盘就转 90°,原来在边缘的箭头可能转到中间,迫使玩家重新规划顺序,增加了策略深度和紧张感。
旋转的核心是两个映射:位置映射和方向映射。
位置映射:顺时针旋转 90° 后,原来在 (r, c) 的箭头会移动到 (c, rows-1-r)。可以这样理解:先把行号变成列号(转置),再把新行号翻转(顺时针旋转后原来的第一行变成最后一列)。
方向映射:箭头本身也要跟着转,朝上的变成朝右,朝右的变成朝下,以此类推:^→>→v→<→^。
ROTATE\_CW = {'^': '>', '>': 'v', 'v': '<', '<': '^'}
def rotate\_cw(self):
  """顺时针旋转90度:位置和方向同步旋转。"""
  new\_rows, new\_cols = self.cols, self.rows
  new\_grid = \[\[None] \* new\_cols for \_ in range(new\_rows)]
  for r in range(self.rows):
  for c in range(self.cols):
  ch = self.grid\[r]\[c]
  if ch is not None:
  nr, nc = c, self.rows - 1 - r # 位置映射
  new\_grid\[nr]\[nc] = ROTATE\_CW\[ch] # 方向同步旋转
  self.grid = new\_grid
  self.rows, self.cols = new\_rows, new\_cols
触发机制:每关配置 rotate_every(第 8 关 = 4,第 9-10 关 = 3),Session 记录 removed_count,每次成功飞出后 +1,达到阈值就调用 rotate_cw(),并设置 just_rotated 标志让 UI 层播放动画。
def click(self, r, c):
  # ... 消除箭头 ...
  self.removed\_count += 1
  if self.rotate\_every > 0 and self.removed\_count >= self.rotate\_every:
  if self.board.count() > 0: # 最后一个箭头直接通关,不旋转
  self.board.rotate\_cw()
  self.just\_rotated = True
  self.removed\_count = 0
  self.undo\_stack = \[] # 坐标全变了,撤销栈失效
动画实现:UI 层在旋转前先 capture_board() 把当前棋盘画到一个独立 Surface 上,旋转后用 BoardRotate 动画把这个旧截图旋转 90°、缩小、淡出,露出下面已经旋转好的新棋盘。0.6 秒完成,视觉上就是 "旧棋盘转走了,新棋盘露出来"。
几个设计要点和踩坑:
-
可解性保持:旋转是对称变换,箭头之间的相对阻挡关系不变,所以原来能通关的关卡旋转后仍然能通关,不需要重新验证关卡数据。
-
撤销栈必须清空:旋转后所有箭头的坐标都变了,之前记录的
(r, c)撤销信息全部失效,不清空会导致撤销到错误位置甚至数组越界。 -
最后一个箭头不旋转:如果消除后棋盘空了,直接通关,不需要再旋转(旋转一个空棋盘没有意义,还会多播一次动画)。
-
旋转动画期间忽略点击:防止玩家在旋转过程中点到坐标还没更新的箭头,导致误判。
五、AIGC 辅助开发过程(3次真实记录)
本作业使用 豆包(Doubao)AI 编程助手 辅助开发。以下是 7 次具有代表性的协作过程:
| 子任务 | 借助何种 AIGC 技术 | AI 实现或提供了什么 | 效果如何 | 人工修改 |
|---|---|---|---|---|
| 路径检测 | 豆包 | 生成四方向路径检测代码与方向向量表 | 初版 '^' 方向向量写错(误配为向下),顶边朝上箭头被误判为阻挡 |
用 T03 边界用例暴露后,修正方向向量为 (-1, 0) |
| 关卡设计 | 豆包 | 生成关卡数组与死锁检测思路 | 第 2、3、4、6 关初版存在同行 / 同列互堵死锁,无法通关 | 用求解器逐一验证,调整箭头方向消除死锁并重新试玩 |
| 碰撞 / 飞出动画 | 豆包 | 补全箭头晃动、变色与淡出动画代码 | 初版把箭头尺寸写成浮点数导致绘制报错,晃动频率偏高 | 修复整数类型转换,调整晃动幅度与动画时长 |
| 自动化测试 | 豆包 | 生成 T01~T06 测试用例与 UI 冒烟测试 | 逻辑测试全部通过 | 补充了互堵关卡检测、边缘用例,修正了 1 处测试自身的棋盘设计错误 |
| 时间限制 | 豆包 | 生成倒计时字段与超时判负逻辑 | 逻辑正确但初版把超时和失误耗尽混为一个失败原因,界面无法区分 | 增加 lose_reason 区分两种失败,UI 按原因显示不同文案并接入 HUD 倒计时 |
| 可解关卡生成器 | 豆包 | 给出“逆向构造 + 求解器验证”生成思路 | 初版随机方向生成的关卡初始可飞箭头多达 10/14,开头太简单 | 加入向心方向偏好与“方向翻转优化”,初始可飞降到 2~4 支 |
| 代码体检 | 豆包 | 复查界面代码,发现 get_font 签名被改坏 |
传 bold=True 的 4 处调用全部抛 TypeError,UI 一运行就崩 |
恢复带候选字体遍历与 bold 参数的实现,UI 冒烟测试恢复通过 |
记录 1:四方向路径检测(边界条件修复)
提出的要求:实现 “箭头沿前进方向到棋盘边界之间没有其他箭头即可飞出” 的检测,四个方向都要支持,且边缘朝外的箭头不能越界。
AI 完成的工作:给出 DIRECTIONS 方向向量表与基于 while 循环的逐格检测代码,结构清晰。
实际效果与问题:初版代码中 '^'(上)的方向向量被写成了 (1, 0)(向下),导致顶边朝上的箭头会被错误地检查下方路径。我在自动化测试里加入了 “顶边朝上的箭头应能飞出” 的边界用例(T03),测试立即失败暴露了该问题。
人工修改:将 '^' 的向量修正为 (-1, 0) 后测试通过。这让我意识到:AI 生成的代码必须用边界用例验证,尤其方向、索引这类容易 “差一点” 的逻辑。对应提交:fix: 修正向上箭头的方向向量,通过 T03 边界用例。
记录 2:关卡设计(不可解布局的发现与调整)
提出的要求:设计若干难度递增、且保证存在通关顺序的关卡。
AI 完成的工作:生成了多组关卡数组,并提示可以用贪心求解器验证可解性。
实际效果与问题:把初版关卡交给求解器测试后,第 2、3、4、6 关均被判定 “不可解”。分析发现是两类互堵死锁:如第 3 关初版同一行 >...< 两箭头互相阻挡;第 4 关同一列 v 在上、^ 在下互相阻挡。这类布局即使先删除其他箭头也无法解除(两箭头互相构成对方的阻挡)。
人工修改:根据求解器输出逐个调整死锁处箭头方向(如将 < 改为空格、将 v 改为空格),每次修改后重新运行求解器测试,直到 6 关全部验证可解,并逐关试玩确认通关顺序合理。这个过程让我真正理解了 “可解性” 的本质,也体会到自动验证比肉眼检查可靠得多。对应提交:fix: 调整第 2/3/4/6 关布局,消除互堵死锁并通过求解器验证。
记录 3:碰撞与飞出动画(参数与类型问题)
提出的要求:箭头成功飞出时要有滑出 + 淡出动画,被阻挡时要有晃动 + 变色 + 文字提示。
AI 完成的工作:生成了 FlyOut(沿方向移动到棋盘外一格并逐渐透明)与 Shake(正弦波左右晃动并变红)两个动画类,以及浮动提示文字类。
实际效果与问题:初版运行时出现 TypeError: 'float' object cannot be interpreted as an integer—— 箭头绘制尺寸写成了 CELL * 0.55 这种浮点数,直接传给 pygame.draw 的 border_radius 报错;另外初版晃动频率偏高、幅度衰减不明显,视觉上过于 “抖”。
人工修改:在绘制函数入口将尺寸转为整数;把晃动频率从 48 调到 42、幅度加上 (1 - k) 衰减因子,让动画先剧烈后平息;同时把飞出动画时长控制在约 0.34s。调整后动画反馈清晰、不喧宾夺主。
记录 4:自动化测试(T01~T06)
提出的要求:按作业要求编写 T01~T06 六项测试,并补充关卡合法性检查。
AI 完成的工作:生成了基于 unittest 的测试文件,覆盖 “无阻挡飞出 / 有阻挡失误 -1 / 边缘不越界 / 清空通关 / 失误耗尽失败 / 进行中重开恢复” 六类场景,以及全部关卡的可解性校验。
实际效果与问题:T01~T05 直接通过;T06 测试自身初版选错了棋盘(选了一个本身互堵、没有任何箭头可飞的棋盘),导致断言失败。
人工修改:重新设计 T06 的棋盘(.<>..>:一个可飞出、一个被阻挡),并额外补充了 “互堵关卡应被判定为不可解” 的反例测试和 UI 冒烟测试(虚拟显示驱动下绘制所有界面、跑通通关与失败流程)。最终 19 项测试全部通过。
记录 5:时间限制(失败原因区分)
提出的要求:给每关增加倒计时,时间耗尽直接判负;重新开始要恢复剩余时间。
AI 完成的工作:在 Session 中增加 time_limit / time_left 字段与 tick(dt) 方法,倒计时归零置为 lost;UI 层每帧调用 tick 并更新 HUD。
实际效果与问题:初版把“失误耗尽”和“时间耗尽”都归为 lost,失败界面无法区分原因,用户看到文案永远是“失误次数已用完”。另外时间放在逻辑层后,界面层最初忘记在 update 中推进,倒计时根本不走。
人工修改:增加 lose_reason('mistakes' / 'time')区分两种失败,失败弹窗按原因显示不同文案;在 update 主循环接入 session.tick(dt),超时先弹出“时间到!”浮动提示再切失败界面;HUD 右上角显示倒计时,最后 30 秒变红。新增 T07 共 7 个时间用例(倒计时递减、超时判负、失败原因、重开恢复、通关停表等),并补了一个 UI 冒烟测试验证“超时后进入失败界面”。
记录 6:可解关卡生成器(难度控制)
提出的要求:把 6 关扩充到 10 关、明显加大难度,同时保证每个新关卡都存在通关顺序。
AI 完成的工作:给出“逆向构造”思路——从空棋盘开始,每放置一个箭头就用贪心求解器验证“放置后仍可解”,不满足则回退,从构造上保证可解性。
实际效果与问题:初版生成器随机四向摆放,生成的关卡虽然可解,但初始可直接飞出的箭头多达 10~14 个(一半以上),开头毫无解谜感;且方向分布偏向同一种,视觉单调。
人工修改:加入两个改进——①向心方向偏好:箭头方向按位置偏向棋盘中心(连续权重),边缘箭头大多朝内被锁住;②方向翻转优化:生成后对每个“初始可飞”箭头尝试换向,只要保持可解且减少初始可飞数就接受,迭代直到初始可飞数压到 2~4 支。最终第 7~10 关为 7×79×9、1420 支箭头、失误上限 2 次、限时 120~160 秒,与前三关形成明显难度阶梯。
记录 7:代码体检(get_font 参数错误)
提出的要求:对现有代码做一次全面检查,找出“程序跑不起来”的问题。
AI 完成的工作:运行 UI 冒烟测试,发现 ui.py 的 get_font 函数被改成了只接受 size 一个参数,而全文件有 4 处调用仍传 bold=True(关卡按钮、HUD 标题、失败弹窗、浮动文字),全部抛 TypeError——游戏一启动就会崩溃;同时发现该版本只尝试 “simhei” 一种字体、找不到时可能退回默认字体丢失中文。
人工修改:恢复 get_font(size, bold=False) 的完整实现(遍历候选中文字体 + 找不到退回默认字体),跑通全部 UI 冒烟测试。这再次印证:AI 修改代码后必须用测试回归,签名变了而调用没跟上是最常见的隐性炸弹。
六、项目测试结果
测试环境:Windows + Python 3.14.7 + pygame-ce 2.5.8,运行命令:
python -m unittest discover -s tests -v
| 编号 | 测试内容 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|
| T01 | 点击前方无阻挡的箭头 | 箭头飞出棋盘并消失 | 箭头消失,剩余数量减少 | ✅ 通过 |
| T02 | 点击前方有阻挡的箭头 | 箭头不消失,失误次数减 1 | 箭头保留,失误 3→2 | ✅ 通过 |
| T03 | 点击位于边缘且朝向棋盘外的箭头 | 箭头正常消失,不发生越界错误 | 上 / 下 / 左 / 右及角上箭头均正常飞出 | ✅ 通过 |
| T04 | 消除本关全部箭头 | 显示通关并进入下一关 | 状态变为 won,自动切换到通关界面,可进入下一关 | ✅ 通过 |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 状态变为 lost,重新开始后棋盘与失误恢复 | ✅ 通过 |
| T06 | 游戏进行中重新开始 | 箭头布局和失误次数恢复 | 布局、失误次数、撤销栈均恢复初始 | ✅ 通过 |
| T07 | 每关限时倒计时 | 超时判负、重开恢复时间、通关停表 | 倒计时递减,超时进入失败界面且原因显示为“时间耗尽” | ✅ 通过 |
| 附加 | 全部 10 关可解性验证 | 每关都存在通关顺序 | 求解器验证全部可解 | ✅ 通过 |
| 附加 | 互堵关卡检测 | 不可解布局能被识别 | 死锁关卡被判定为不可解 | ✅ 通过 |
| 附加 | UI 冒烟测试 | 各界面可绘制、流程正常 | 全部界面绘制、通关 / 失败 / 超时流程无异常 | ✅ 通过 |
测试小结:共 28 项测试全部通过。测试不仅验证了需求,还真实暴露了开发中的多个 Bug(方向向量错误、关卡死锁、动画类型错误、get_font 参数错误、时间不推进),体现了 “先写用例、再验证” 的价值。除自动化测试外,10 个关卡均已用求解器验证可解,并通过 tools/validate_levels.py 复核。
七、PSP 个人软件开发过程量表
| 任务 | 预估耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 1.0 | 0.5 | -0.5 |
| Python 与图形库学习 | 2.0 | 1.5 | -0.5 |
| 游戏界面实现 | 4.0 | 3.0 | -1.0 |
| 路径与碰撞逻辑实现 | 3.0 | 2.0 | -1.0 |
| 关卡设计(含扩充至 10 关与生成器) | 2.0 | 3.5 | +1.5 |
| AIGC 辅助开发(含时间限制与代码体检) | 2.0 | 2.5 | +0.5 |
| 测试与修改(含 T07 与 UI 超时用例) | 2.0 | 3.0 | +1.0 |
| README 与博客撰写 | 3.0 | 3.5 | +0.5 |
| 合计 | 19.0 | 19.5 | +0.5 |
八、心得体会
AI 带来的帮助:AIGC 把 “从零搭框架” 变成了 “在草稿上修改”。路径检测、动画类、测试用例这些成熟模式,AI 能一次性给出可用版本,让我把精力集中在真正需要判断的地方 —— 边界条件、关卡可解性、交互细节。特别是 “先用测试暴露问题、再让 AI 和人工一起修” 的工作方式,开发效率明显提高。
出现的问题:AI 生成代码最大的风险是 “看起来对、实际错”。本次 AI 的路径检测方向向量写错、关卡出现互堵死锁、动画参数类型错误,都是只靠肉眼检查发现不了的,全部要靠边界用例和求解器这类可验证的手段兜底。这提醒我:AI 是 “高效的草稿生成器”,不是 “可靠的代码评审员”,把 AI 代码直接当正确答案是最危险的用法。
我的收获:一是真正理解了本游戏路径检测与 “互堵死锁” 的本质(同行 > 对 <、同列 v 对 ^ 必然无解),并会用贪心求解器证明关卡可解;二是掌握了 “逻辑层与界面层解耦 + 自动化测试” 的工程习惯,使逻辑可以脱离图形界面被反复验证;三是对 AI 协作有了更理性的定位 —— 提问要具体(“请实现四方向路径检测并处理边缘越界”),验证要独立(测试、试玩、求解器),修改要敢下手;四是这次查错让我亲身体会到,AI 修改过的代码必须用测试回归——get_font 签名被改坏这种“一启动就崩”的问题,正是靠 UI 冒烟测试第一时间暴露的;而“逆向构造 + 求解器验证”的关卡生成思路,让我不用再靠肉眼一个个排查死锁,扩关卡从“碰运气”变成了“可复现的工序”。

浙公网安备 33010602011771号