软件工程第二次作业
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16717 |
| 这个作业的目标 | 使用 Python 和 AIGC 完成“一箭又一箭”小游戏 |
| 学号 | 102401507 |
| GitHub 仓库 | https://github.com/Ki-K4/arrow_game |
一、项目展示
「一箭又一箭」是一款点击式箭头解谜小游戏。6×6 的棋盘上摆着若干方向不同的箭头,玩家点击一个箭头:如果它前方这一行或这一列上没有别的箭头,它就沿自己的方向飞出棋盘并被消除;如果被挡住了,箭头不会消失,同时扣掉一次失误机会。把棋盘清空就通关,失误次数用完就失败。
目前一共五关,通过当前关卡才会解锁下一关。

二、项目介绍
游戏规则
- 开始界面有四个入口:开始游戏(全新开始,会先弹确认框清空进度)、继续游戏(读存档接着上次的关卡)、选择关卡、随机关卡;
- 用鼠标点击棋盘里的箭头,点到空格子没有反应;
- 前方没有阻挡的箭头会沿方向飞出棋盘,飞行带尾迹;
- 前方有阻挡的箭头不会消失,会变红晃动,出现扩散圆环、粒子、音效和“前方有阻挡”提示,并消耗一次失误;
- 每关三次失误机会,用完本关失败,可以重新开始本关;
- 清空本关全部箭头即通关,通过后解锁下一关,通关界面按剩余失误和用时评 1~3 星;
- 键盘快捷键:
H提示、U撤销、S自动求解、R重新开始、F12截图、ESC返回上一级。
界面设计
界面是深色渐变背景加圆角卡片:顶部状态卡片显示关卡进度、用时、提示次数和剩余失误方块;棋盘用深浅交替的圆角格子;按钮是渐变色块,鼠标悬停会变亮;通关按星级评价,失败画一个叉;选关界面把五关排成卡片,没解锁的关卡画成暗色带一把锁。
主要功能与特色
- 基础玩法:四方向箭头、路径阻挡判断、飞出与碰撞反馈、失误次数、三个以上可通关关卡(实际做了五关)、通关/失败界面、重新开始;
- 扩展功能:选关与逐关解锁、计时与星级评价、进度存档(
progress.json)、提示(H)、撤销(U)、随机关卡、自动求解(S); - 特色:不依赖任何外部素材——箭头、星星、锁、图标都用多边形绘制,音效由代码现场合成;
运行方法
python -m pip install -r requirements.txt
python main.py
依赖只有 pygame-ce 一个包,Python 3.12 及以上都可以(本项目在 3.14.7 上测试)。
三、实现思路
下面把程序拆成几块,每一块先说清楚要解决什么问题,再给关键代码和解释。完整代码在仓库的 main.py 里。
3.1 箭头、方向与关卡怎么表示
棋盘是 6 行 6 列,箭头存在一个字典里:键是 (行, 列) 坐标,值是方向字母 U/D/L/R。选字典是因为判断阻挡时要反复问“这个格子上有没有箭头”,字典查找接近 O(1);另外整关复制只要 dict(arrows),撤销和重开都直接用得上。
arrows = {(0, 3): "U", (1, 0): "L", (2, 0): "D", ...}
def direction_vector(direction):
"""把方向字母换成行列变化量。"""
if direction == "U": return -1, 0 # 向上=行号减 1
if direction == "D": return 1, 0
if direction == "L": return 0, -1
if direction == "R": return 0, 1
return 0, 0
所有跟方向有关的计算都过 direction_vector 这一层,画箭头、算路径、做动画复用同一份映射,不会出现方向定义不一致的问题。关卡就是一组这样的字典:
LEVELS = [
{(0, 3): "U", (1, 0): "L", ...}, # 第 1 关,12 个箭头
...
]
游戏界面用四个状态区分:START(开始)、SELECT(选关)、PLAYING(游戏中)、RESULT(通关或失败)。主循环每帧先处理输入,再按当前状态分发绘制:
if game_state == START: draw_start_screen()
elif game_state == SELECT: draw_select_screen()
elif game_state == PLAYING: draw_playing_screen()
elif game_state == RESULT: draw_result_screen()
这样四个界面互不干扰,后来加“选关界面”时只多了一个分支和一个绘制函数。
3.2 路径检测:一个箭头能不能飞出去
这是整个游戏最核心的判断。做法很朴素:从箭头所在格子出发,沿它的方向一格一格往前看,途中碰到别的箭头就说明被挡住;一直走到棋盘外面都没碰到,就说明能飞出去。
def is_blocked_on(board, position):
row, col = position
row_change, col_change = direction_vector(board[position])
next_row = row + row_change # 往方向上走一格
next_col = col + col_change
while 0 <= next_row < ROWS and 0 <= next_col < COLS:
if (next_row, next_col) in board: # 这一格有箭头 → 被挡住
return True
next_row += row_change # 再往前一格,继续检查
next_col += col_change
return False # 一直走到出界都没拦到 → 能飞出去
拿两个箭头把循环走一遍
例一:(2,2) 朝右的箭头,会被挡住。
row, col = 2, 2,方向是R,direction_vector("R")返回(0, 1),意思是“行不变、列 +1”;- 第一次循环:
next_row, next_col = (2, 3),在棋盘内,查(2,3) in board→ 没有,继续; - 第二次循环:
(2, 4),也在棋盘内,查(2,4)→ 有箭头,于是直接return True,判定被挡住;
例二:(4,5) 朝右的箭头,能飞出去。
- 方向
R同样是(0, 1),(4,5)已经是最右一列; - 第一次循环:算出
next_col = 6,此时0 <= 6 < 6不成立,while条件直接为假,循环一次都不执行; - 走到最后一行
return False,判定能飞出去。
这就是这段代码最巧妙的地方:边界情况不用单独写分支——贴着边朝外指的箭头,第一次算坐标就越界了,循环体压根不执行,自然就是“没被挡住”。我一开始还专门加了个 if 出界 then 可以飞 的判断,反而容易和中间的情况写重、写漏。
为什么要传一个 board 参数?
函数的第一行不是直接读全局的 arrows,而是接收一个棋盘字典。这是故意的:游戏里判断“我现在能不能点这个箭头”用的是当前棋盘,但关卡校验脚本和自动求解需要问的是“假设某几个箭头已经被点掉了,剩下的还怎么走”。参数化之后,同一份逻辑三处复用:
- 玩家点击时:
is_blocked(position)决定是“扣一次失误”还是“飞出去”; - 关卡校验:判断一关里有没有解不开的死结;
- 自动求解和提示:在推演出来的棋盘上继续判断下一步能点谁。
三处共用一份逻辑,所以只要你手动点得出的顺序是合法的,脚本算出来的顺序、自动演示点的顺序就一定也是合法的,不会出现“脚本说能过、游戏里却点不动”的不一致。
代价与边界测试
单次判断最多扫过 6 格(棋盘一列/一行的长度),一次点击的开销完全可以忽略,所以不需要任何加速技巧。写完以后我专门把三种“边界情况”都点了一遍:贴着上边朝上的 (0,3)、贴着左边朝左的 (1,0)、贴着右边朝右的 (4,5),确认它们都能直接飞出;另外像 (3,3) 向右这种“本身不贴边、但前方整行都空”的情况也能正确判为可飞出。这条也写进了测试表。
3.3 一次点击背后的完整流程
点击处理分两层:外层 handle_mouse_click 决定“点在哪个界面、点到什么”,内层 handle_arrow_click 决定“点到哪个箭头、该怎么处理”。
def handle_arrow_click(mouse_position):
if flying_arrow is not None or auto_running():
return # 动画中、自动演示时不接受手动点击
for position in list(arrows):
if cell_rect(*position).collidepoint(mouse_position):
push_history() # ① 先记快照
if is_blocked(position):
hit_blocked_arrow(position) # ② 扣失误 + 碰撞反馈
else:
activate_arrow(position) # ③ 飞出去
return
push_history() 把 箭头布局 + 失误次数 + 提示次数 压进栈,这就是撤销功能的基础——所以点错扣掉的那次失误也能撤销。两种结果分开写:
def activate_arrow(position):
global flying_arrow, flying_start_time
flying_arrow = (position, arrows[position])
flying_start_time = pygame.time.get_ticks()
play_sound(fly_sound)
del arrows[position] # 立刻从棋盘删除,剩下的只是演出
点中的瞬间就删除箭头,因为逻辑上它已经飞出去了:动画期间每帧用 (当前时间 - flying_start_time) / FLY_DURATION 算出进度,把箭头往它的方向平移;动画结束后检查棋盘是否为空,为空就通关。被挡住的走 hit_blocked_arrow():扣一次失误、记录碰撞位置、生成粒子、播放音效,失误扣完切到失败界面。
3.4 关卡可解性:为什么用拓扑排序而不是穷举
这个游戏最容易出的 bug 是做出通不了的关卡。规则里有个隐藏后果:同一行或同一列上有两个箭头互相指着对方时,两边都会被对方挡住,点哪边都只扣失误、棋盘不变,这一关就永久卡死。
一开始我用穷举判断:把当前棋盘当成一个状态,每步从“没被挡住的箭头”里挑一个点掉,广度优先搜索看能不能到空棋盘。方法正确,但状态数最多能到 2 的 n 次方,箭头一多就慢。
后来发现等价而更快的判据:把“谁挡住谁”看成一个有向图,有环就一定无解,没有环就一定可解。
def is_solvable(board):
remaining, pending = dict(board), set(board)
while pending:
free = [p for p in pending if not is_blocked_on(remaining, p)]
if not free: # 一个都点不掉 → 有环
return False
for p in free: # 当前能点的全部点掉,加快收敛
pending.discard(p)
del remaining[p]
return True
理由:有环时环上的箭头互相挡着,环外的操作影响不到它们,永远点不掉;无环时必然存在一个“没人挡它”的箭头(有向无环图必有入度为 0 的点),点掉它之后剩下的图仍然无环,于是能一路清空。复杂度从指数级降到多项式级,所以“随机关卡”可以放心地随机生成、判断、不合格就重来。
四、扩展功能
基础玩法跑通之后又补了六项功能,尽量每一项都做成完整可用的,而不是空壳。
| 扩展功能 | 说明 |
|---|---|
| 更多关卡与关卡选择 | 五关(12/16/20/22/24 个箭头)+ 选关界面,每张卡片显示箭头数量、星级和最佳用时 |
| 逐关解锁 | 通过第 N 关才解锁第 N+1 关,没解锁的卡片画成暗色带锁,点它没有反应;解锁进度也存在存档里 |
| 计时与星级评价 | 状态栏实时显示用时,通关按“是否零失误”“是否用时达标”评 1~3 星 |
| 保存游戏进度 | 星级、最佳用时、上次关卡和解锁进度写入 progress.json;开始界面区分“开始游戏(新游戏,带确认框)”和“继续游戏(读存档)” |
| 提示与撤销 | H 高亮一个可以点击的箭头;U 退回上一步(连点错的失误也能还回来) |
| 随机关卡与自动求解 | 随机生成一定可通关的关卡;S 按解法自动演示通关(演示成绩不计入星级) |
| 打包成可执行文件 | pyinstaller -F -w --name 一箭又一箭 main.py,因为项目没有外部素材,一条命令即可 |
五、AIGC 使用过程
5.1 使用情况汇总
| 子任务 | 借助何种 AIGC 技术 | AI 实现或提供了什么 | 效果如何 | 人工修改 |
|---|---|---|---|---|
| 环境配置 | DeepSeek | 定位到 Python 3.14 没有 pygame 预编译包、编译要下载 SDL2;建议改用 pygame-ce | 换成 pygame-ce 后一次装好 | 修改 requirements.txt,并把排查过程写进文档 |
| 程序结构 | DeepSeek | 建议按状态拆分界面,每种界面一个绘制函数 | 代码可读性明显变好,加界面只需多一个分支 | 自己补上失误次数、重开、关卡切换等规则 |
| 路径检测 | DeepSeek | 给出沿行列方向逐格检查的算法 | 判断正确,边界无需单独分支 | 改成接收 board 参数以便三处复用,并专门测试贴边箭头 |
| 碰撞反馈 | DeepSeek | 给出变红、晃动、粒子、扩散圆环和正弦波合成音效的方案 | 反馈明显,打击感强 | 调整参数,音效包异常处理;后来按频谱把方波换成三角波 |
| 关卡验证 | DeepSeek | 指出同线互相阻挡会永久卡死,并给出穷举检查脚本 | 发现两关无解 | 调整箭头方向,脚本留在仓库,后来换成更快的拓扑判断 |
| 界面设计 | DeepSeek | 建议按窗口分辨率直接绘制,用渐变、圆角、阴影和矢量图形做质感 | 界面从“像表单程序”变成有游戏感的样子 | 推翻低分辨率放大的方案重做,自己补状态栏、星级、悬停效果 |
| 扩展功能 | DeepSeek | 建议关卡用“随机生成 + 可解性判断”筛选,撤销用快照栈、存档用 JSON、自动求解复用解法生成 | 五关难度递增,提示/撤销/存档/解锁/自动求解都能用 | 自己设计“新游戏 / 继续游戏 / 逐关解锁”的流程,并写自动检查防止文字被遮挡等问题 |
5.2 三次代表性过程记录
这三次都是项目里最核心、影响最大的问题:路径检测决定玩法判断对不对,关卡可解性决定关卡能不能真的通关,程序结构决定后面能不能顺利把功能加下去。
(1)路径检测:整个玩法的判断核心
- 我提出的要求:把“箭头前方有没有别的箭头”这段判断写成代码,四个方向都要能判断,贴边的箭头也不能出错,而且希望它能被后面的关卡校验复用;
- AI 完成的工作:给出“从箭头所在格子出发,沿它的方向一格一格往前看,走到棋盘边界为止”的思路和初版代码,用
row_change / col_change表示四个方向; - 实际效果:四个方向都能判断,但初版把“贴边朝外”当成特殊情况单独处理,我用贴边箭头测试时发现这种写法容易和中间情况写重、写漏,代码也不够干净;
- 我做的人工修改:① 把边界判断并进
while条件0 <= next_row < ROWS and 0 <= next_col < COLS——贴边朝外的箭头第一次算坐标就出界,循环一次都不执行,自然判为“能飞出”,不需要特例;② 把函数改成接收board参数,让同一份逻辑既能判断当前棋盘,也能在“假设某些箭头已经点掉”的棋盘上推演,于是点击判断、关卡校验脚本、自动求解三处共用同一份代码,不可能出现“脚本说能过、游戏里点不动”的不一致。
(2)关卡验证:AI 帮我发现两关根本通不了
- 我提出的要求:试玩第二关时怎么点都过不去,怀疑是自己没找到正确顺序,请帮我分析;
- AI 完成的工作:指出同一行或同一列上两个互相指着的箭头会永久互相阻挡,并给出“穷举所有点击顺序”的检查脚本;
- 实际效果:脚本跑出来确认第 2、3 关确实无解,调整箭头方向后三关都能通关;
- 我做的人工修改:改了四个箭头的方向,把校验脚本留在仓库里(现在的
tools/check_levels.py),后续加关卡时每改一次就跑一遍;后来还把判断换成更快的拓扑排序,同时用在校验脚本和随机关卡上。
(3)程序结构:用四个状态撑起整个界面
- 我提出的要求:一开始所有绘制代码都堆在主循环里,越写越乱,我希望有个结构能撑住后面继续加功能(当时已经打算加选关、存档、提示这些);
- AI 完成的工作:建议按“开始 / 选关 / 游戏中 / 结果”四个状态拆分,每种界面一个绘制函数,主循环只负责“读输入 → 按状态分发”;
- 实际效果:代码可读性明显变好,后来加选关界面、确认弹框、结果界面的星级评价时,都只需要多写一个分支和一个绘制函数,没有牵动别的界面;
- 我做的人工修改:① 自己补上失误次数、关卡切换、重新开始、逐关解锁这些规则;② 把按钮矩形统一提到文件顶部,让“绘制”和“点击检测”共用同一份数据,直接避免了“按钮挪了位置以后点不中”的问题——后面加四个扩展按钮时一次就摆对了;③ 另外写了一套自动检查(现在 69 项),专门盯“文字被按钮遮挡”“文字互相重叠”“箭头超出格子”这类界面问题。
六、测试结果
测试在 Windows + Python 3.14.7 下完成,逐条记录如下。
| 编号 | 测试内容 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|
| T01 | 点击前方无阻挡的箭头 | 箭头飞出棋盘并消失 | 与预期一致,飞行过程带尾迹动画 | 通过 |
| T02 | 点击前方有阻挡的箭头 | 箭头不消失,失误次数减 1 | 与预期一致,同时出现变红晃动、扩散圆环、粒子和音效 | 通过 |
| T03 | 点击位于边缘且朝向棋盘外的箭头 | 箭头正常消失,不发生越界错误 | 与预期一致(is_blocked_on 的范围判断生效) |
通过 |
| T04 | 消除本关全部箭头 | 显示通关并进入下一关 | 显示通关界面,提示“已解锁第 N 关”,可进入下一关 | 通过 |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 显示失败界面,用时停在失败那一刻,可重新开始本关 | 通过 |
| T06 | 游戏进行中重新开始 | 箭头布局和失误次数恢复 | 与预期一致,计时也重新开始 | 通过 |
| T07 | 点击“重新开始”以外的空白区域 | 不产生任何误操作 | 与预期一致 | 通过 |
| T08 | 完成全部关卡 | 显示完成全部关卡的提示 | 显示“恭喜你完成全部关卡!”并可重新开始游戏 | 通过 |
| T09 | 用脚本检查五关可解性 | 五关都存在合理的通关顺序 | 输出 共 5 关,无解 0 关 |
通过 |
| T10 | 按 H 提示、按 U 撤销 |
高亮一个可点箭头;撤销恢复上一步 | 与预期一致,点错扣掉的失误也能撤销 | 通过 |
| T11 | 存档与逐关解锁 | 关掉重开进度仍在;锁住的关卡点不动 | 与预期一致,旧存档也能按通关记录正确迁移 | 通过 |
| T12 | 按 S 自动求解 |
自动按解法演示通关 | 与预期一致,演示成绩不记入星级和最佳用时 | 通过 |
每个关卡我都实际试玩过;此外还写了一套自动化检查脚本,把关键逻辑和界面布局跑一遍,目前一共 69 项全部通过,覆盖:完整流程(开始 → 逐关通关 → 失败 → 重开)、界面渲染、文字与按钮不被遮挡、箭头不超出格子、音效是旋律、计时在结算时停止、关卡解锁与存档读写等。
七、PSP 表格
| 任务 | 预估耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 1.0 | 1.0 | +0.0 |
| Python 与图形库学习 | 2.0 | 2.5 | +0.5 |
| 游戏界面实现 | 1.5 | 3.0 | +1.5 |
| 路径与碰撞逻辑实现 | 2.0 | 2.5 | +0.5 |
| 关卡设计 | 1.0 | 2.5 | +1.5 |
| AIGC 辅助开发 | 1.5 | 2.5 | +1.0 |
| 测试与修改 | 1.5 | 1.5 | +0.0 |
| README 与博客撰写 | 1.0 | 1.5 | +0.5 |
| 合计 | 11.5 | 17.0 | +5.5 |
界面实现和关卡设计超时最多:界面改了三版(平涂 → 低分辨率复古 → 深色渐变卡片),关卡则是因为先后解决了“两关无解”“箭头数量太少”“没有难度梯度”等问题,每改一次都要重新验证。
八、心得体会
这次作业让我体会到,AIGC 能明显加快开发速度,但它给的东西必须自己验证。
装环境那次,AI 帮我从一堆报错里定位到真正的原因;写路径检测、做碰撞反馈和音效时,它给的方案直接可用,省下了大量查文档的时间。界面返工和关卡验证则提醒我:AI 说得有道理不等于效果好,好不好看、能不能通关,必须自己看结果再判断。
关卡那次是最典型的一课。AI 生成的关卡数据看起来没问题,我甚至以为“应该能过,是我没找到顺序”,直到把校验脚本跑出来才确认两关都是死局。从那以后我给自己定了个规矩:凡是“看起来应该对”的东西都要想办法验证一次——这也是后来我把穷举脚本、拓扑排序判断和 69 项自动检查一路加下来的原因。
另外一个感受是,在利用AIGC进行开发时,真正花时间的不是写代码,而是想清楚规则、设计关卡、测试各种边界。以后再配合 AIGC 开发,我会更重视理解代码和验证结果,而不是把生成的内容直接当成答案。
浙公网安备 33010602011771号