emiliaccc

《一箭又一箭》开发博客

《一箭又一箭》开发博客 —— 用 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 主要功能与特色

  1. 四个方向的路径检测,含边界、反方向、跨空格纵向扫描等边界情况处理;
  2. 明显的碰撞反馈:抖动 + 变红 + 被撞者闪金 + 震屏 + 红屏一闪 + 飘字,做到“一眼看出是谁挡了谁”;
  3. 失误机会机制:心形实时显示,已用掉的画成暗色;
  4. 5 个难度递增的关卡,全部经求解器校验必定可通关
  5. 不依赖任何外部素材:箭头、心形、虚线网格全部由 pygame.draw 现画,中文调用系统字体 —— 交作业时把 1.py 拷到任何装了 pygame 的机器上即可运行;
  6. 自带两个命令行验收入口--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                                # 一路扫到边界都空 → 畅通

这段代码有三个点值得说明:

  1. 起点是“正前方一格”arrow.row + dy),所以箭头自己不会被算成阻挡者;反方向的箭头在循环方向上也永远扫不到,天然满足“反方向不算阻挡”。
  2. while 的条件同时完成了“到边界就停”,因此不会出现数组越界 —— 这也是我后来在自测里专门加了一条 1×1 棋盘的用例来确认的原因。
  3. 返回的是“挡路的那一支箭头”而不是 True/False。返回值从布尔改成对象,是一个很小的改动,但它让碰撞时可以直接给被撞的那支箭头加一个闪金效果,反馈一下子清楚了:玩家能看到“是被它挡住的”。

is_clear()free_arrows()(当前所有可安全点击的箭头)都建立在这个函数之上:

def is_clear(self, arrow):
    return self.blocker_of(arrow) is None

整个流程可以概括为:

flowchart TD A[鼠标点击某格] --> B{blocker_of 沿射线扫描} B -->|返回 None:畅通| C[标记 removed,播放飞出动画] B -->|返回某支箭头:被挡| D[抖动变红 + 震屏 + 被撞者闪金] D --> E[失误机会 -1] C --> F{剩余箭头数 = 0 ?} F -->|是| G[过关:礼花 + 结算面板] E --> H{失误机会 = 0 ?} H -->|是| I[失败:结算面板]

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.pyinitsysfonts_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 = GONEalpha = 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 我的收获

  1. “逻辑与表现分离”这个思想是这次最大的收获。点击立即改逻辑、动画慢慢播,把“判定”和“好看”彻底解耦,一次就消掉了整类时序 bug。
  2. 学会了用单调性论证算法的正确性。贪心为什么对、什么时候会卡住,我现在的答案是“消除只会让射线更通畅,是单调过程,所以能拆就拆永不亏”,这个思路比记住某个算法更有用。
  3. 测试是给自己写的--solve--test 这两个入口在最后阶段反复救我:每次改完代码跑一遍,几秒钟就能确认“关卡还是全部可解、状态机还是对的”。
  4. 对 AI 的产出要负责的人是我。AI 生成的代码进入项目之后,出问题算我的;所以我的原则是:AI 写、我必须能解释、并且必须有测试。这也是我在博客里坚持把关键代码逐段解释清楚的原因。

附:素材与代码来源说明

  • 本项目的代码由本人编写,并与 AIGC 工具(VS Code 内的 GitHub Copilot)协作完成,协作过程记录见随仓库提交的 AIGC使用记录.md
  • 程序中未使用任何第三方图片、音效或字体文件:棋盘、箭头、心形、虚线均由 pygame.draw 现场绘制;中文显示调用操作系统自带字体(优先 Windows 的微软雅黑 msyh.ttc),不随程序分发字体。
  • 未使用原商业游戏《一箭又一箭》的代码、美术素材、音效或关卡,5 个关卡全部为自行设计并用内置求解器校验。
  • 仓库中不包含密码、Cookie、API Key、Token 等任何敏感信息。

posted on 2026-09-20 23:28  ヰ世界情緒  阅读(5)  评论(0)    收藏  举报

导航