《一箭又一箭》开发博客
《一箭又一箭》开发博客 —— 用 Python + Pygame + AIGC 做一个小游戏
本文中的图片均托管在本项目的 GitHub 仓库
screenshots/目录下,可直接访问。
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/ |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/homework/16718 |
| 这个作业的目标 | 使用 Python 和 AIGC 完成“一箭又一箭”小游戏 |
| 学号 | 102401213 |
| GitHub 仓库 | https://github.com/emiliaistureangle/- |
一、项目展示
1. 开始界面

标题 + 一句玩法提示,中间是装饰用的“靶心 + 四支箭头”,下面是「开始游戏 / 退出游戏」,底部列出四条规则与快捷键。
2. 游戏过程(第 1 关“热身”)

顶部信息栏显示关卡进度、剩余箭头数、失误机会(心形)和用时;中间是虚线网格上的箭头阵列;底部是「重新开始 / 返回主菜单」。
3. 碰撞反馈

点错时:被点的箭头抖动并变红,被它挡住的箭头闪金色,同时屏幕轻微震动、弹出“撞击!-1”飘字、左上角心形减少一颗。
4. 通关与失败
| 通关 | 失败 |
|---|---|
![]() |
![]() |
通关面板显示用时与剩余机会,并有礼花粒子;最后一关会显示“全部通关!”。失败面板会提示解决思路(先找正前方没有阻挡的那一支)。
5. 完整试玩演示(GIF)

二、项目介绍
2.1 游戏规则
棋盘上散布着指向 上 / 下 / 左 / 右 的箭头,点击一支箭头后:
- 射线畅通(它正前方到边界之间没有任何其它箭头)→ 该箭头沿所指方向飞出棋盘并消失;
- 射线被挡住 → 发生撞击,损失一次失误机会;
- 清空全部箭头 = 过关;失误机会耗尽 = 失败。
判断的关键只有一句话:“正前方至边界这条射线上,有没有别的箭头”。注意反方向的箭头不算阻挡,这构成了“必须先解锁挡路者”的依赖链,也就是这个游戏的策略深度来源。
2.2 界面设计
窗口固定 600 × 800(竖屏),从上到下分三段:
| 区域 | 高度 | 内容 |
|---|---|---|
| 顶部信息栏 | 150 px | 关卡号与关卡名、剩余箭头数、失误机会(心形)、本关用时 |
| 中间棋盘 | 自适应 | 居中排布的虚线网格 + 箭头,格子边长按行列数自动缩放(限制在 40–78 px) |
| 底部控制栏 | 132 px | 操作提示 + 「重新开始 / 返回主菜单」按钮 |
棋盘底盘先在关卡开始时算好位置与格子边长(compute_board()),这样 3×3 和 6×6 的关卡都能自动居中、大小一致。
2.3 主要功能与特色
- 四个方向的路径检测,含边界、反方向、跨空格纵向扫描等边界情况处理;
- 明显的碰撞反馈:抖动 + 变红 + 被撞者闪金 + 震屏 + 红屏一闪 + 飘字,做到“一眼看出是谁挡了谁”;
- 失误机会机制:心形实时显示,已用掉的画成暗色;
- 5 个难度递增的关卡,全部经求解器校验必定可通关;
- 不依赖任何外部素材:箭头、心形、虚线网格全部由
pygame.draw现画,中文调用系统字体 —— 交作业时把1.py拷到任何装了 pygame 的机器上即可运行; - 自带两个命令行验收入口:
--solve校验关卡可解性、--test无窗口自测核心规则。
三、实现思路
3.1 箭头、方向与关卡怎么表示
方向用整数编码,与关卡数据中的数字一一对应,空格用 0:
UP, DOWN, LEFT, RIGHT = 1, 2, 3, 4
DIR_VEC = {UP: (0, -1), DOWN: (0, 1), LEFT: (-1, 0), RIGHT: (1, 0)} # (dx, dy)
关卡就是一张数字矩阵,再配上本关的失误机会次数:
{
"name": "热身",
"mistakes": 3,
"grid": [[4, 2, 0],
[1, 0, 0],
[0, 0, 3]],
}
Level 在构造时把矩阵转成 {(行, 列): Arrow} 的字典,方便按坐标 O(1) 取到某格的箭头;同时补零成矩形,避免关卡数据写得不整齐导致下标越界。
之所以把方向向量抽成 DIR_VEC,是为了让后面的路径检测四个方向共用一段代码,而不是写四段近似重复的循环。
3.2 路径检测(本项目的核心)
我最开始的想法是“按上/下/左/右分别设置起点、终点和步长,写四个循环”。在 AIGC 的帮助下把它收敛成了一段沿方向向量扫描到边界的 while 循环:
def blocker_of(self, arrow):
"""返回挡路的第一支箭头;射线畅通则返回 None。"""
dx, dy = DIR_VEC[arrow.direction]
r, c = arrow.row + dy, arrow.col + dx # 从“正前方一格”开始
while 0 <= r < self.rows and 0 <= c < self.cols:
other = self.arrows.get((r, c))
if other is not None and not other.removed:
return other # 撞上第一支箭头
r += dy
c += dx
return None # 一路扫到边界都空 → 畅通
这段代码有三个点值得说明:
- 起点是“正前方一格”(
arrow.row + dy),所以箭头自己不会被算成阻挡者;反方向的箭头在循环方向上也永远扫不到,天然满足“反方向不算阻挡”。 while的条件同时完成了“到边界就停”,因此不会出现数组越界 —— 这也是我后来在自测里专门加了一条1×1棋盘的用例来确认的原因。- 返回的是“挡路的那一支箭头”而不是
True/False。返回值从布尔改成对象,是一个很小的改动,但它让碰撞时可以直接给被撞的那支箭头加一个闪金效果,反馈一下子清楚了:玩家能看到“是被它挡住的”。
is_clear()、free_arrows()(当前所有可安全点击的箭头)都建立在这个函数之上:
def is_clear(self, arrow):
return self.blocker_of(arrow) is None
整个流程可以概括为:
3.3 关卡怎么保证“必定可通关”
这是我在做这个作业时最大的一个收获:这套规则允许出现“互锁死局”。比如
→ _ ←
两支箭头互相阻挡,点谁都会撞,这一关永远清不掉。所以关卡不能随手填数字。
于是我写了求解器 solve_level():反复找出“前方无阻挡”的箭头并移除,直到卡住为止;如果最后还有箭头剩下,就说明存在死锁。
lv = Level(data)
while True:
free = lv.free_arrows()
if not free:
break
free[0].removed = True
order.append(...)
return order if lv.remaining() == 0 else None
为什么贪心就一定对? 因为“消除一支箭头”只会让其它箭头的射线走廊变得更通畅,不会让原本畅通的变堵 —— 这个过程是单调的。所以“能拆就拆”永远不亏:如果存在通关顺序,它的第一步必然也是贪心会选的那一类;一旦贪心卡住,就说明真的没有活路。这是个完备的判据,因此我把它固化进 1.py,用 python 1.py --solve 随时复核,也顺便拿到了每一关的通关顺序。
3.4 把“逻辑状态”和“表现状态”分开
这是我自己发现并坚持改掉的一个设计缺陷。一开始箭头只有 removed 一个状态,飞行动画要 0.x 秒才播完。结果就出现了诡异的时序 bug:上一支箭头还在飞的路上(视觉上还在棋盘上),别的箭头就因为“它还在”而被判成被挡住。
最后的做法是拆成两个字段:
arrow.removed = True # 逻辑:点击瞬间立刻生效,参与后续所有路径判定
arrow.state = "FLYING" # 表现:慢慢播飞出、淡出动画
点击判定永远立刻生效,动画只负责“好看”。state 一共有 IDLE / FLYING / COLLIDING / GONE 四种取值,绘制时按它决定颜色、位移和透明度。
3.5 动画、碰撞反馈与“与帧率无关”
所有动画都按“秒”累加,而不是按帧累加:
raw = self.clock.tick(FPS) / 1000.0
dt = min(raw, 0.05) # 单帧限幅:卡顿时也不让箭头瞬移
arrow.fly_dist += FLY_SPEED * dt # 13 格 / 秒
- 飞出:沿方向向量累加位移(
FLY_SPEED = 13格/秒),飞过棋盘后开始淡出,完全飞出置为GONE; - 撞击:
COLLIDE_TIME = 0.40s内做sin/cos衰减抖动并变红,同时SHAKE_TIME = 0.30s震屏、红屏一闪; - 淡出:用
BLEND_RGBA_MULT对精灵做 alpha 乘法(直接set_alpha对带透明通道的 Surface 是无效的); - 胜负结算延迟:清空后等最后一支箭头飞完再弹面板;失误耗尽后延迟
0.75s再弹失败面板,让撞击动画播完。
dt = min(raw, 0.05) 是写第一版时就预防性加上去的:万一某一帧卡了 0.5 秒,箭头最多只走 0.05 秒的距离,不会一下子穿过棋盘。
四、AIGC 使用过程
使用的工具:VS Code 内的 GitHub Copilot(对话式编程助手)。
工作方式是“我给出规则与技术文档 → AI 写实现 → 我验证、纠错、做设计取舍”。下面记录 3 次代表性的协作过程。
4.1 关卡设计:AI 反过来纠正了我的前提
- 我提出的要求:按上面这套规则帮我把关卡填好,并保证每一关都能通关。
- AI 完成的事:它先指出我的前提是错的 —— 这条规则本身允许“互锁死局”(
[4, 0, 3]即→ _ ←),所以随手填数字是不行的。接着给出“随机生成网格 + 贪心求解校验 + 用依赖链深度衡量难度”的生成筛选脚本,并论证了贪心求解的完备性。 - 实际效果:
python 1.py --solve输出 5 个关卡全部[OK] 可通关,0 个死锁。 - 我做的修改:第 1 关不走生成脚本,改成我自己设计的 3×3 热身关(开局有 2 支可点、依赖链只有 3 层);从候选里挑掉了几关“开局只有唯一一步可走、过于劝退”的;并要求把
solve_level()固化进1.py,方便随时复核。
这一段的价值不在于“AI 帮我写了关卡”,而在于它发现了我规则定义里的一个漏洞,这个漏洞如果留到最后才发现,整个关卡表都要重做。
4.2 路径检测:AI 一次写对,但我改了状态设计
- 我提出的要求:按技术文档中“按四个方向分别设置起点、终点、步长”的描述实现
blocker_of(),返回“正前方到边界之间是否有箭头”。 - AI 完成的事:没有照抄四段循环,而是收敛成一段沿方向向量扫描的
while,并且把挡路的那支箭头返回出来,而不是只返回真假。 - 实际效果:一次通过;
--test中 6 项路径检测全部通过,另外用[[4, 0, 3]]专门确认了互锁死局能被判为无解。 - 我做的修改:返回值从布尔改成箭头对象是我主动要求的(为了碰撞时能闪金);更重要的是,我把前面 §3.4 说的
removed(逻辑)与state(表现)拆开 —— 这是 AI 没有主动做的,是我在检查动画流程时发现的设计缺陷。
4.3 中文字体崩溃排查:靠 AI 定位堆栈
-
现象:程序一画文字就崩溃:
TypeError: expected str, bytes or os.PathLike object, not int -
AI 完成的事:根据堆栈定位到 pygame 2.6.1 在扫描 Windows 字体注册表时的缺陷(
sysfont.py的initsysfonts_win32遇到非字符串项),并给出修复方案:不再使用SysFont/match_font,改为直接按字体文件路径加载,同时保留三级回退。 -
实际效果:修复后中文标题、按钮、提示全部正常显示,自测与截图均无异常。
-
我做的修改:把候选字体表补成“雅黑 → 黑体 → 等线 → 宋体 → Noto → 文泉驿 → 苹方”,并让粗体优先命中
msyhbd.ttc,避免合成加粗在中文笔画上发虚。
def fit_font(size, bold=False):
"""① 字体文件 → ② 系统字体库 → ③ pygame 内置默认字体。"""
regular, bold_file = resolve_font_files()
path = bold_file if (bold and bold_file) else regular
if path:
return pygame.font.Font(path, size) # 最稳:按文件路径加载
try:
return pygame.font.SysFont(",".join(CJK_FONT_NAMES), size, bold=bold)
except Exception:
return pygame.font.Font(None, size) # 至少不会崩
4.4 小结:我做的 vs. AI 做的
| 环节 | 我(人) | AIGC |
|---|---|---|
| 需求与规则定义 | 确定玩法、失误机制、界面布局 | — |
| 关卡数据 | 裁定难度曲线,选定最终 5 关 | 生成候选 + 判死锁 + 算难度 |
| 算法正确性 | 复核“贪心完备性”的论证 | 提出论证并实现 |
| 代码编写 | 给出算法描述与接口约定 | 生成绝大部分实现代码 |
| 故障发现与修复 | 确认修复方向、验收结果 | 跑出故障、分析堆栈、给出修复 |
| 验收 | 核对测试结论、实际运行确认 | 编写并运行自测脚本(23 项断言) |
五、测试结果
5.1 关卡可解性校验(python 1.py --solve)
| 关卡 | 规模 | 箭头数 | 失误机会 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|---|---|
| 第 1 关 热身 | 3 × 3 | 4 | 3 | 可通关,无死锁 | 可通关(4 步) | ✅ |
| 第 2 关 连锁 | 4 × 4 | 8 | 3 | 可通关,无死锁 | 可通关(8 步) | ✅ |
| 第 3 关 交错 | 5 × 5 | 12 | 3 | 可通关,无死锁 | 可通关(12 步) | ✅ |
| 第 4 关 迷宫 | 5 × 6 | 15 | 4 | 可通关,无死锁 | 可通关(15 步) | ✅ |
| 第 5 关 终章 | 6 × 6 | 18 | 4 | 可通关,无死锁 | 可通关(18 步) | ✅ |
结论:5 个关卡,0 个不可解。
5.2 核心规则自测(python 1.py --test,无窗口)
| # | 测试项目 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|---|
| 1 | 路径检测:被同行的箭头挡住 | 返回挡路箭头 (0,2) |
返回 (0,2) |
✅ |
| 2 | 路径检测:被挡时 is_clear |
为 False |
False |
✅ |
| 3 | 路径检测:到边界全程为空 | 判定畅通 | 畅通 | ✅ |
| 4 | 路径检测:反方向有箭头 | 不算阻挡 | 不算阻挡 | ✅ |
| 5 | 路径检测:纵向扫描(跨越空格) | 找到第 3 行的箭头 | 找到 | ✅ |
| 6 | 边界情况:1×1 棋盘 | 流畅,不越界 | 畅通 | ✅ |
| 7 | 互锁死局:→ _ ← |
双方都被判定为被挡 | 双方都被挡 | ✅ |
| 8 | 互锁死局:求解器判定 | 返回“无解” | None(无解) |
✅ |
| 9–13 | 第 1–5 关可通关 | 均可解 | 全部可解 | ✅ |
| 14 | 点击畅通箭头 | 返回 True 且箭头被消除 |
符合 | ✅ |
| 15 | 清空全部箭头后 | 状态切换到 WIN |
WIN |
✅ |
| 16 | WIN 时剩余箭头 |
为 0 | 0 | ✅ |
| 17 | 第 2 关存在被挡箭头(测试前置) | 存在 | 存在 | ✅ |
| 18 | 连续撞击 3 次 | 失误机会归零 | 归零 | ✅ |
| 19 | 撞击后错误箭头状态 | 进入 COLLIDING |
COLLIDING |
✅ |
| 20 | 动画播完后 | 状态切换到 LOSE |
LOSE |
✅ |
| 21 | COLLIDING 动画结束 |
回到 IDLE |
IDLE |
✅ |
| 22 | 重新开始 | 失误次数与箭头数恢复 | 均恢复 | ✅ |
| 23 | 最后 1 次机会撞掉 | 触发失败倒计时 | 触发 | ✅ |
自测结果:23 / 23 项通过。
5.3 界面自检(离屏渲染截图 + 像素取样)
| 测试项目 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|
| 被点击的箭头颜色 | 变红 | 取样为 (255, 91, 91) |
✅ |
| 被撞的箭头颜色 | 闪金 | 取样为 (255, 188, 91) |
✅ |
| 飞行结束后箭头状态与透明度 | state = GONE、alpha = 0 |
符合 | ✅ |
| 底部提示文字不被按钮压住 | 文字与按钮不重叠 | 首次发现重叠(文字中心 y=710 与按钮顶 y=708),重排底部栏后通过 | ✅(修复后) |
| 顶部“剩余箭头”数字与标签不挤压 | 右对齐且不重叠 | 首次发现挤成一团,改为先测文字宽度再整体右对齐后通过 | ✅(修复后) |
5.4 运行验收
| 测试项目 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|
python 1.py 按 README 可启动 |
正常出现窗口并进入菜单 | 600 × 800 窗口正常启动 | ✅ |
python 1.py --help |
打印完整说明 | 正常打印 | ✅ |
六、PSP 表格
| 任务 | 预估耗时(小时) | 实际耗时(小时) | 差异(小时) |
|---|---|---|---|
| 需求分析与游戏设计 | 2.0 | 2.5 | +0.5 |
| Python 与图形库学习 | 3.0 | 2.0 | −1.0 |
| 游戏界面实现 | 3.0 | 3.5 | +0.5 |
| 路径与碰撞逻辑实现 | 2.0 | 1.5 | −0.5 |
| 关卡设计 | 3.0 | 4.0 | +1.0 |
| AIGC 辅助开发 | 2.0 | 1.5 | −0.5 |
| 测试与修改 | 3.0 | 4.5 | +1.5 |
| README 与博客撰写 | 2.0 | 2.5 | +0.5 |
| 合计 | 20.0 | 22.0 | +2.0 |
几点说明:
- “关卡设计”和“测试与修改”超支最多。前者是因为“随手填的关卡可能有死锁”这个问题发现得比预期晚,需要重做关卡数据;后者是因为修掉字体崩溃等故障之后必须重跑一遍全部验收。
- “路径与碰撞逻辑实现”比预期快:路径检测被收敛成一段扫描循环后一次写对,省下了原本预估的调试时间;但这部分省下来的时间被测试吃掉了。
- “Python 与图形库学习”低于预期:pygame 的事件循环与 Surface 绘制比预想的直观,但字体那一个坑单独花掉了不少时间(记在上面“测试与修改”里)。
七、心得体会
7.1 AI 带来的帮助
- 把“规则描述”快速变成可运行的骨架。我描述玩法、界面分区、动画参数,AI 直接给出可运行的实现,我可以在十几分钟内看到界面原型,把精力集中在“对不对、好不好玩”上,而不是抠 API 用法。
- 发现了我自己没想到的漏洞。最有价值的一次是 AI 指出“规则允许互锁死局”,否则我的关卡表极可能做废。
- 排查故障的效率。字体崩溃那次,从堆栈定位到
sysfont.py的注册表扫描缺陷、再到“按文件路径加载 + 三级回退”的方案,整个来回很短。 - 自动化验收。无窗口自测脚本(23 项断言)和离屏截图 + 像素取样,帮我抓到了两个自己没注意到的布局问题。
7.2 出现的问题
- AI 给出的“看起来对”的代码不一定真的对。动画与逻辑耦合的时序 bug(§3.4)是典型:代码能跑、画面正常,但会偶发误判。这类问题不靠测试很难发现,只能靠自己盯着流程再想一遍。
- 环境相关的坑 AI 无法预知。pygame 在本机扫描字体注册表崩溃,属于“同一份代码在别人机器上正常、在我这里崩”,只能靠堆栈和实验逐步逼近。
- AI 不会替我做设计取舍。
removed/state的拆分、界面布局的重排、难度曲线的裁定,这些必须我自己判断;AI 默认会给出“能跑”的版本,而不是“经得起追问”的版本。 - 要防止“假验收”。测试通过了不等于没问题 —— 例如测试全部通过时,底部文字其实正被按钮压住。像素取样这种更“笨”的验证方式反而抓到了它。
7.3 我的收获
- “逻辑与表现分离”这个思想是这次最大的收获。点击立即改逻辑、动画慢慢播,把“判定”和“好看”彻底解耦,一次就消掉了整类时序 bug。
- 学会了用单调性论证算法的正确性。贪心为什么对、什么时候会卡住,我现在的答案是“消除只会让射线更通畅,是单调过程,所以能拆就拆永不亏”,这个思路比记住某个算法更有用。
- 测试是给自己写的。
--solve和--test这两个入口在最后阶段反复救我:每次改完代码跑一遍,几秒钟就能确认“关卡还是全部可解、状态机还是对的”。 - 对 AI 的产出要负责的人是我。AI 生成的代码进入项目之后,出问题算我的;所以我的原则是:AI 写、我必须能解释、并且必须有测试。这也是我在博客里坚持把关键代码逐段解释清楚的原因。
附:素材与代码来源说明
- 本项目的代码由本人编写,并与 AIGC 工具(VS Code 内的 GitHub Copilot)协作完成,协作过程记录见随仓库提交的
AIGC使用记录.md。 - 程序中未使用任何第三方图片、音效或字体文件:棋盘、箭头、心形、虚线均由
pygame.draw现场绘制;中文显示调用操作系统自带字体(优先 Windows 的微软雅黑msyh.ttc),不随程序分发字体。 - 未使用原商业游戏《一箭又一箭》的代码、美术素材、音效或关卡,5 个关卡全部为自行设计并用内置求解器校验。
- 仓库中不包含密码、Cookie、API Key、Token 等任何敏感信息。


浙公网安备 33010602011771号