第二次作业

项目 内容
这个作业属于哪个课程 202601软件工程
这个作业要求在哪里 2026秋软件工程个人作业(第二次)
这个作业的目标 使用 Python 和 AIGC 完成“一箭又一箭”小游戏
学号 102401104
GitHub 仓库 https://github.com/astronomy-yy/arrow-after-arrow

目录


一、项目展示

1.1 演示动图

① 界面浏览:开始页 → 规则介绍 → 入门 / 基础 / 字母三套玩法的选关页 → 字母关盘面 → 对局菜单 → 设置面板(动态图,约 15 秒)。

界面浏览演示

② 通关演示:从开始页点「入门玩法」进第 1 关,真实点击打完一局到通关结算。中间故意点了一支被挡住的箭,可以看到它冲一下再弹回、红心少一颗、并在盘上留下暗红标记(动态图,约 11 秒)。

通关演示

1.2 三个界面

开始界面 游戏界面(第 1 关)
开始界面 游戏界面
通关结算 失败界面
通关 失败

1.3 游戏过程的一些截帧

一箭正沿自身折线滑出(尾迹逐格点亮) 被挡住的一帧:弹出「被挡住了!」、红心少一颗
飞出 被挡

上面右边这张就是碰撞反馈:玩家点了一支被挡住的箭之后,那支箭会先沿箭头方向冲出去一段再弹回来(弹回期间画的是它自己的颜色,好让人看清「是哪一支没飞出去」),动画结束后它会一直保持暗红标记(206, 62, 66),比调色板里最接近的那档红还要深 56 个色阶),顶栏红心同时少一颗,并在棋盘上方弹出「被挡住了!」。

越往后的关卡越密(第 12 关,55 支箭) 放大后拖到一侧看局部
第12关 缩放拖动

1.4 其它界面

规则介绍页 设置面板
规则页 设置
入门玩法选关(3 关) 字母玩法选关(26 个字母)
入门选关 字母选关

二、项目介绍

2.1 游戏规则

棋盘被裁成一个造型(矩形 / 圆形 / 菱形 / 心形 / 沙漏……),里面密密麻麻塞满彩色的箭头。玩家点一支箭头:

  • 箭头方向上没有别的箭头挡着 → 它沿自己的轨迹滑出棋盘、飞出屏幕,然后消失;
  • 箭头前方有别的箭头挡着 → 它不能消失,会朝箭头方向冲一下再弹回、扣掉一颗红心,并在盘上留下暗红标记,提醒玩家「这条路目前走不通」;
  • 点空白处不罚。

清空全部箭头即通关;红心耗尽或倒计时归零则失败。本作业只要求「单格箭头」,本项目在完成基础规则之后把它扩展成了能拐弯的折线箭(身体可以拐好几个弯,箭头在末端)—— 判定规则不变,但盘面观感和拆解层次丰富得多。

2.2 界面设计

游戏一共 9 个界面状态(开始页 / 规则页 / 三套玩法的选关页 / 对局中 / 单关通关 / 全部通关 / 失败),作业要求的三类界面都在:

  • 开始界面:一块带箭形暗纹的浅底(日间)/ 深蓝底(夜间),中间是拼装出来的卡通贴纸标题「一箭又一箭」,下面一只长着笑脸的卡通箭吉祥物,再下面五个入口:规则介绍、入门玩法、基础玩法、字母玩法、随机关卡。
  • 游戏界面
    • 当前关卡:顶栏正中「关卡 N」;
    • 游戏棋盘:整页中间那块圆角面板;
    • 剩余箭头数量:顶栏倒计时左边「→ 剩余 N」,跟着棋盘实时变;
    • 剩余失误次数:顶栏三颗红心,扣掉一颗就有一颗变暗;
    • 重新开始按钮:顶栏倒计时右边的环形箭头,点一下盘面 / 红心 / 倒计时一起回到开局。
    • 另有手柄(跳关)、(菜单)、靶心(返回)三个按钮和底栏的缩放滑杆、提示、辅助线开关。
  • 通关 / 失败界面:半透明遮罩 + 居中卡片,通关显示星级与「线段已全部清空,继续下一关」,失败显示「红心用完了」并给「重新开始 / 返回首页」。

2.3 主要功能

作业「游戏基本要求」的 11 条逐条对照:

作业要求 实现情况
可正常显示和操作的图形化界面 ✅ pygame-ce,逻辑画布 640×1140,窗口可自由拖拽缩放,画面等比适配
棋盘含上下左右四种方向的箭头 Direction 四方向枚举,盘面上四种方向都会出现
能通过鼠标点击选择箭头 ✅ 左键按下记账、抬起了结(位移 > 8 逻辑像素改判为拖动棋盘)
能正确判断前进方向上是否存在其他箭头 ✅ 射线检测,见 3.3
前方无阻挡时箭头飞出棋盘并消失 ✅ 沿自身折线滑出的动画 + 残影,飞出后从棋盘移除
前方有阻挡时箭头不消失并给出明显碰撞反馈 ✅ 朝箭头方向冲一下再弹回 + 抖动 + 暗红常驻标记 + 「被挡住了!」提示 + 音效
设置失误次数并在界面中显示剩余次数 ✅ 每关 3 颗红心,顶栏显示
至少 3 个可以正常通关的关卡 ✅ 一共 41 关:入门 3 关 + 基础 12 关 + 字母 26 关
清空全部箭头后显示通关结果并进入下一关 ✅ 单关结算卡片 + 「下一关」;整套玩法最后一关是「全部通关」
失误次数耗尽时显示失败结果 ✅ 失败卡片 + 「重新开始 / 返回首页」
提供重新开始,使当前关卡恢复到初始状态 ✅ 顶栏按钮 / 菜单项 / 失败页三处共用同一套复位逻辑

2.4 特色

  1. 折线箭:一支箭是一条能拐弯的折线,点一下整条线沿自身轨迹「流」出去,身体不会横扫旁边的格子 —— 这也是本项目最核心的一条规则。
  2. 关卡是算出来的,不是摆出来的:41 关全部由「逆向构造法」生成,可通关性由构造过程保证(见 3.6),每一关还会被两道独立校验再验一遍。
  3. AI 求解 / 提示 / 撤销:把「谁挡谁」建成有向图做拓扑排序,能给出下一步提示(H),也能自动替你通关(A)。
  4. 字母玩法:内置 A–Z 的 5×7 点阵字模,放大成棋盘遮罩再往里铺箭头 —— 每一关整盘就是一个大写字母。
  5. 倒计时星级 + 金币:按失误 / 提示 / 撤销次数结算 1~3 星,金币可以买提示或跳关,进度存成 JSON 存档。

三、实现思路

3.1 箭头和方向怎么表示

方向就是一个四值枚举,枚举值同时充当关卡数据里的字符,省掉一层映射:

class Direction(Enum):
    UP = "U"
    DOWN = "D"
    LEFT = "L"
    RIGHT = "R"

DIRECTION_DELTA = {
    Direction.UP: (-1, 0),      # (dr, dc)
    Direction.DOWN: (1, 0),
    Direction.LEFT: (0, -1),
    Direction.RIGHT: (0, 1),
}

一支箭的表示(关卡数据里就是这个形状):

{
    "cells": [[3, 5], [3, 6], [4, 6], [4, 7]],  # 尾端 -> 箭头端,逐格上下左右相连
    "dir": "R",        # 箭头朝向 U / D / L / R
    "color": 4,        # 调色板索引
}

约定两条:最后一个格子是箭头端dir 说的是箭头从这一格往哪边飞;其余格子只是身体。为什么要把「身体」和「箭头端」分开,是下面路径检测的关键。

3.2 关卡怎么表示

{
    "id": 9, "name": "第9关 大盘如月", "shape": "round",
    "mistakes": 3,            # 红心数量
    "rows": 21, "cols": 21,   # 棋盘尺寸
    "time_limit": 460,        # 倒计时秒数
    "seed": 9909072,          # 生成时用的随机种子
    "arrows": [ ... ],        # 若干支箭
    "solution": [37, 36, ..., 0],   # 一条已验证的通关顺序(箭的 id 序列)
}

shape 只是造型名,交给 shapes.py 展开成一个遮罩格子集合;箭头只会铺在遮罩里,底部的辅助线点阵也只画在遮罩格上,盘面因此呈现圆、心、沙漏这些造型。字母玩法多两个字段 shape="letter" + letter="M",遮罩由点阵字模栅格化而来,取遮罩统一走 shapes.level_cells(level) 一个口子。

棋盘内部另有一张占据网格 _occ[rows][cols],每格记着「这一格属于哪一支箭」,空格是 None。有了它,任意一格属于谁都是 O(1),射线检测就不用去遍历箭头列表。

3.3 路径检测(重点)

先想清楚一件事:折线箭滑出时,身体只经过它自己原来的格子(整条线沿着自己的轨迹往前「流」),所以身体不可能碰到别的箭头;唯一可能撞上的,只有箭头端朝出去的那条射线

于是判定简化成一句话:从箭头端的下一格开始,沿方向一格一格走到出界,途中只要遇到别的箭头就说明被挡住。

can_fly_arrow(arrow):
    (dr, dc) = DIRECTION_DELTA[arrow.direction]
    (r, c)   = arrow.head + (dr, dc)          # 从箭头端「下一格」开始,不检查自己那一格
    while in_bounds(r, c):                    # 走到棋盘外就自然结束 —— 出界不算撞墙
        if _occ[r][c] is not None and _occ[r][c] is not arrow:
            return False                      # 被挡住
        (r, c) += (dr, dc)
    return True                               # 一路到边界都没东西,可以飞出去

实际代码就长这样(game/board.py):

def can_fly_arrow(self, arrow):
    dr, dc = DIRECTION_DELTA[arrow.direction]
    r, c = arrow.head
    r += dr
    c += dc
    while self.in_bounds(r, c):
        other = self._occ[r][c]
        if other is not None and other is not arrow:
            return False
        r += dr
        c += dc
    return True

对照作业里的两个例子:

→  ·  ·  ↑  ·    ← 第一支朝右,右边第 3 格有 ↑,射线非空 → 不能飞
↑  ·  ·  ·  →    ← 最后一支朝右,右边已经没有格子 → 出界即通过 → 可以飞

几个值得说的点:

  1. 必须是「整条射线」而不是「相邻一格」。如果只看下一格,那么「隔两格有箭」就会误判成可飞,飞出去时穿过别人才发现不对。所以循环要一直扫到出界。
  2. 边界不写特判while in_bounds(...) 这个循环条件已经把「出界」处理掉了:贴在边上朝盘外的箭头,进循环前就已经越界,直接返回 True。这样四条边、四个方向都用同一段代码,不会有 row - 1 = -1 之类的越界下标(Python 的负下标还会悄悄折回最后一行,是最难查的一类 bug)。这一条后来专门写成了回归测试:四方向各放一支贴边朝外的箭,断言全部能飞且不抛异常。
  3. 只需要 O(行+列) 的时间:射线最多穿过一行/一列,_occ 又是 O(1) 查询,所以判定代价可以忽略 —— 每帧把全盘每支箭都算一遍都够用(求助器、AI 求解、提示都靠它)。
  4. 点错标记要留下来:判定失败时把箭的 id 记进 wrong_ids,绘制时改涂暗红。它不只是装饰——玩到后面盘面很密,玩家需要一个「这条我试过了」的记号。

3.4 点一下之后发生什么

点击统一走一个分发函数,三条分支:

def _click_cell(self, cell):
    arrow = self.board.arrow_at(*cell)
    if arrow is None:                       # 点到空白:不罚
        return
    if self.board.can_fly_arrow(arrow):
        self._launch(arrow)                 # 飞出去
    else:
        self._block(arrow)                  # 被挡住
  • _launch():压一份撤销快照 → 把「尾端格子 + 箭头延长线」算成一条像素路径,交给动画对象 → 从棋盘移除这支箭 → 剩余数 -1 → 播放飞出音效。
  • _block():同样压快照 → 红心 -1 → 打上暗红标记 → 生成「沿射线冲一下再弹回」的弹回反馈 → 弹一条「被挡住了!」提示 → 播放碰撞音效。

弹回动画不是随便抖两下:它的路径是沿箭头那条射线先冲出去一段再退回来,所以玩家一眼就能看出「是哪一支把它挡住的」。

3.5 失误、成败与重新开始

  • 每关开局 3 颗红心(mistakes: 3),点错一次扣一颗;红心变 0 → 失败。
  • 倒计时归零 → 也判失败(这一条是扩展功能)。
  • 主循环里每帧只在动画播完之后判一次成败,避免「最后一支还在飞就弹出结算」:
if not self.flying and not self.blocked:
    if self.board.remaining == 0:
        self._finish_level()          # 通关:算星级、给金币、写存档
    elif self.board.mistakes <= 0:
        self.state = GameState.GAME_OVER
    elif self.time_left <= 0:
        self.state = GameState.GAME_OVER

重新开始的做法是「不修补、直接重建」:Board.reset() 从关卡数据里的 _initial_arrows 深拷贝一份重新摆盘,红心、剩余数、点错标记、撤销栈全部复位。顶栏按钮、菜单项、失败页的「重新开始」三处共用同一个回调,所以三处在任何状态下表现都一致。

3.6 关卡怎么保证「一定能通关」

手摆关卡几乎不可能保证有解,而且改一格就可能摆成死局,所以 41 关全部由算法生成 —— 用的是逆向构造法

  1. 从空棋盘开始,一支一支把箭「放」回去,放箭顺序就是通关顺序的倒序
  2. 放第 k 支箭时只要求一件事:它自己的箭头射线上不能有已经放好的箭
  3. 这一条恰好等价于「按放置顺序倒着点,每一步的射线都是空的」——第 k 支箭飞的时候,留在盘上的正好是比它早放的那 k-1 支,而它在放下那一刻就确认躲开了这些箭,所以关卡必然可通关
  4. 落点用「代价优先 + 小窗口随机」挑,兼顾铺得满与每局不一样。

难度是另一个旋钮:挑落点时偏好「箭头前方空走廊长」还是「短」。走廊越长,越容易被后面放的箭压住,阻挡就越多。实测这个偏好度从 0 调到 1,开局能直接点掉的比例从 99% 一路降到 22%,而被挡的比例从 0.9% 升到 78%,填充率反而从 0.924 升到 0.965 —— 「铺得满」和「有阻挡」并不矛盾,反而互相成全。12 关的难度因此严格单调:第 1 关六成多能直接点(给新手留容错),第 12 关只剩一成(55 支箭里开局只有 6 支能点)。

这里踩过本作业最大的一次坑:早期版本为了「撑开走廊让盘面好看」,多加了一条约束「箭的身体也不许压在已放好箭的射线上」。它和第一条合起来等价于「任意两支箭的射线和身体都不相交」,也就是每支箭的出口走廊永远是空的、点什么都对,关卡退化成「无脑乱点必通关」。这一条详见 4.3 的第 ③ 次协作。


四、AIGC 使用过程

4.1 工具与人机分工

本次开发使用了两款 AIGC 工具:

  • 豆包:前期用它拆需求、定阶段大纲、生成项目骨架与第一版状态机 / 数据结构;
  • DeepSeek:中后期的主力,用来做关卡生成器调参、求解器重写、动画与界面细节、修 bug 和补测试。

代码基本由 AIGC 产出,我的工作集中在三个环节:

我的角色 具体做什么
① 提需求 讲清楚要什么样的玩法、界面和手感;把「和参考游戏差太远」这种感受说成具体要改哪里;补上 AI 想不到的细节 —— 比如「点错要在盘上留个记号」「被挡要让玩家看清是哪一支」
② 试玩找问题 起真窗口,从开始页一路点到通关,逐关试玩,盯运行时的各种异常现象:线飞出去抖不抖、点错有没有反馈、按键有没有反应、文字有没有压到卡片外、按钮点不点得动、关卡是不是「随便点都能过」…… 这些自动化测试跑不出来,只能靠人玩
③ 判断与验收 决定 AI 给的方案留不留;确认改完问题真的没了;把「这个不能算过」的结论反馈回去,让 AI 继续改

一句话概括这套用法:我把「感觉不对」描述清楚、复现出来,AI 负责定位和修,我负责确认真的修好了。

用法上最大的体会是:先把「感觉不对」翻译成能算的数,再让 AI 动手。比如「盘面太稀疏」→ 量填充率;「线飞出去不平滑」→ 打印逐帧采样点;「入门关卡太有规律」→ 定义五条「不规律」指标。量化之后再让 AI 改,改完我马上就能验证有没有变好,而不是凭观感来回猜。

4.2 我试玩时抓到的运行时问题

自动化测试只能证明「代码按断言跑通了」,证明不了「游戏看得清、点得动、好玩」。下面这些全是我起真窗口试玩时看到的现象

我试玩时看到的现象 我的反馈 根因(AI 定位) 结果
盘面稀稀拉拉,和参考游戏完全不像 「太稀疏了,要铺满」 生成器的生长策略没调参,填充率只有 0.53~0.86 调参后 0.88~1.00
键盘按键按了没反应(鼠标点得动) 「快捷键失效」 事件分发没接上 统一走 _dispatch_event
点被挡的箭只闪一下,看不清是哪一支被挡 「要让玩家看出是谁挡的」 反馈只做了红闪 改成沿射线冲一下再弹回 + 暗红常驻标记 + 提示条
开局随便点哪一支都能飞,一局下来没遇到一次阻挡 「玩法没了」 生成器多了一条把阻挡抹掉的约束(见 4.4②) 删约束 + 重建 12 关难度曲线
用提示时显示「无解」,可我明明点得通 「有解的关被判无解」 求解器 DFS 指数爆炸,把有解判成无解 改成拓扑排序精确求解
箭飞出去在转弯处抖 「转弯处在抖」 采样只取整格,拐角被连成斜弦切掉 取真正的折线段,长度守恒
切窗口再切回来,线会瞬移一格 「切窗口后位置跳了」 单帧步进没夹上限 步进夹 50 ms
规则页文字压在正文上 / 菜单最后一项顶到底边 「这几处排版看不下去」 整页自动收紧策略的问题 逐张看图修掉
「重新开始」按钮点了没反应 「按钮是不是坏了」 图标用 draw.arc 在小半径下一个像素都画不出来(按钮在、能点、只是看不见) 改成逐点 draw.lines 描弧
新截图上印着旧进度 「截图污染了真实存档」 Save(path=SAVE_PATH) 默认参数在定义时就绑死 截图脚本改用临时存档

这张表里没有一条会让程序崩溃或测试变红 —— 能跑、不报错,但就是不对。找到它们靠的就是「起窗口自己玩一遍」。这也是本次作业里我花时间最多的地方。

4.3 AIGC 使用过程记录表

子任务 借助何种 AIGC 技术 AI 实现或提供了什么 效果如何 人工修改(我的介入:提需求 / 试玩检查 / 验收)
环境搭建 豆包 目录结构、.gitignorerequirements.txt、README 初稿、环境冒烟脚本 pip install pygame 在 Python 3.14 上源码编译失败 我装依赖时编译报错,把报错贴回去,改用 pygame-ce 2.5.8;并要求在 README 里写明原因
设计草稿与数据结构 豆包 状态机表、方向枚举、棋盘类、路径检测伪代码、最初三关 三关都能通关 我逐关试玩确认能通关;提出「单格箭头和参考游戏完全不像」,要求改成能拐弯的折线箭
路径检测 can_fly 豆包 四方向检测函数 + 17 个 pytest 用例 一次跑通 我不放心边界,要求专门验「贴边朝外」的四个方向;试玩时发现折线身体旁边的箭被误判成阻挡,要求补专项用例
界面框架与控件 豆包 按钮 / 图标按钮、开始界面、状态切换骨架 界面能跑,但快捷键没反应 我试玩时发现按键没反应(鼠标却正常),反馈后 AI 定位到事件分发没接上
箭头造型与动画 豆包 箭头多边形、飞出加速淡出、被挡红闪 + 晃动 有毛刺,被挡反馈不够明显 我看截图觉得拐角有缺口;点错时只闪一下,看不清是哪一支被挡,要求「要让玩家看出是谁挡的」
关卡生成器 豆包 → DeepSeek 逆向构造法生成必然可通关的关卡 初版填充率只有 0.53~0.86,盘面明显稀疏 我拿参考录屏对比,要求把盘面铺满;试玩时发现「随便点都能过、一局没遇到阻挡」,反馈后删掉多余约束并重建难度曲线(见 4.4②)
求解器 DeepSeek 最初的 DFS + 记忆化求解 在真实难度的盘面上指数爆炸,把有解的关卡判成无解 我用提示功能时发现「明明点得通却提示无解」,反馈后改成「谁挡谁」建图 + Kahn 拓扑排序
飞出动画 DeepSeek 沿自身折线取段、残影、4 倍超采样抗锯齿 转弯处仍在抖 我试玩反馈「转弯处在抖」,并把现象说清是「转弯的地方」;AI 给的整幅超采样方案我实测太贵,否掉了;切窗口后会瞬移一格也是我发现的
界面美化 DeepSeek paint.py:渐变、柔光、投影、卡片、文字阴影,全部预渲染缓存 界面有了层次,但三处排版出事故 我逐张离屏截图看,挑出「文字压到卡片外」「下半屏大片空白」「菜单最后一项顶在底边」
关卡「太有规律」 DeepSeek 把「不规律」量化成五条指标 + 逆向构造散铺器 + 扫参 条带数从 12/7/6 变成 0/0/0,铺满率没掉 我试玩时觉得「一眼看出规律、像图纸」,要求量化并改掉;参数是 AI 扫的,但取哪一段(alpha 0.30~0.45)由我拍板
顶栏控件与规则页排版 DeepSeek 「剩余箭数」读数 + 「重新开始」按钮;规则页改「题头带」布局 + 整页自动收紧 规则页 9 处文字重叠变 0;顶栏三件套互不重叠 我发现「重新开始点了没反应」(其实是图标一个像素都没画出来);规则页 9 处重叠也是我逐页看出来的
测试与文档 DeepSeek tests/ 11 个文件共 374 个用例;README / 设计文档 / 测试记录 / 本 AIGC 记录 无窗口跑通,全程绿灯 我发现截图脚本把真实存档进度印到了新截图上,要求改用临时存档;过期截图与调试脚本也是我要求清掉的

4.4 三次有代表性的协作详述

下面三次是我印象最深的,也最能说明 4.1 里的那套分工:我提需求 → 我试玩找问题 → AI 定位和改 → 我验收。

① 路径检测:功能一次成型,但边界是我要求单独验一遍的

  • 我提的需求:给棋盘实现「判断某支箭能不能飞出去」的函数。四个方向都要支持;射线遇到别的箭就算被挡;一路到边界都没有东西就算能飞出;不许出现数组越界
  • AI 提供的内容:四方向的 DIRECTION_DELTA 增量表、can_fly_arrow() 的循环实现,并配了 17 个 pytest 用例(四方向无阻挡、四方向被挡、贴边出界、折线身体不参与碰撞)。
  • 我的检查:17 个用例第一次跑就全过。但有一处我不放心:Python 的负下标不会报错,会悄悄折回数组最后一行/列,边界判断只要写得稍偏,箭头朝上贴边时就会「穿越」到底边 —— 而且照样能跑、照样不抛异常。
  • 我的判断与要求:要求把「贴在边上、朝向盘外」这一情况单独拎出来验一遍 —— 四个方向各放一支贴边朝外的箭,逐一确认能正常飞出、且不抛异常;另外试玩时发现一条折线箭的身体旁边有别的箭时,不该被判定为阻挡,要求补上「折线身体不横扫」的专项用例。
  • 结果:这两条后来都变成了回归测试,一直守着核心规则。功能本身是一次成型的,但能跑不等于对 —— 边界是我要求补验的。

② 关卡生成器:密度达标了,但玩法是我试玩时发现没了的

  • 我提的需求:我做出来的关卡和参考录屏差得太远 —— 参考盘面几乎铺满,我的只有一半覆盖率,看起来不像同一个游戏。要求把盘面铺满,同时每一关都必须能通关。
  • AI 提供的内容:把「单支箭的生长策略」参数化(最长格数、继续直行的概率、提前收尾的概率、随机窗口),然后扫参数对比填充率;为了让箭铺得更满,在放置时加了一条「身体也不许压在别人射线上」的约束。
  • 我的检查:填充率确实上去了(0.88~1.00),盘面看着饱满了。但我一上手试玩就发现不对劲:开局随便点哪一支都能飞出去,一局下来根本没遇到阻挡,玩法消失了。
  • 我的判断与要求:先让 AI 写诊断脚本,量 12 关的「开局能直接飞的箭比例」,量到的是 全部 100% 可飞、被挡比例 0%,确认不是我的错觉。然后把两条约束放在一起看:第一条「自己的射线上不能有已放好的箭」是可通关性的保证,第二条「身体不能压在别人射线上」看起来在预留走廊,两条合起来等价于「任意两支箭的射线与身体都不相交」——阻挡在数学上不可能出现,第二条对可通关性毫无贡献。要求删掉它;删掉之后,后放的箭(先点)可以名正言顺地站在先放的箭(后点)的射线上,阻挡自然出现。再把「前方空走廊长度」做成难度旋钮,12 关重新生成,开局可飞比例从 67% 逐关降到 11%。
  • 还有一件是我盯测试盯出来的:测试里原来有一条断言「开局每支箭都能飞出去」—— 它把这个 bug 当成了预期行为锁住,测试全绿反而让我更晚发现问题。我要求把它改成反向断言「必须有能点的,也必须有点不动的」,并新增一个专门的测试文件,把「有阻挡」和「难度递减」锁进回归。

③ 飞出动画:我把「看着有点抖」描述到了具体位置

  • 我提的需求:箭飞出去的时候不够平滑,特别是转弯的地方,看着像在抖。要求定位原因并修掉。
  • AI 提供的内容:写脚本把飞出动画每一帧的采样点打印出来,一眼就看出问题 —— 飞出是「各节沿自己那条折线往前流」,但采样只取每格一个点,落在两个采样点之间的拐角会被这两点连成一条斜弦切掉,直角被削成斜角;而相位是连续增长的,所以这个斜角每前进一格就「削平→复原」一次(飞行速度 26 格/秒,就是每秒削 26 次)—— 表现正好是「转弯处在抖」。量化结果:40 像素格距下被切掉的线长最多 23 像素,并且在 0~23 之间来回振荡(等于线的长度在「呼吸」)。修法是取折线真正的一段(把区间内的整数节点补回来),折线长度因此恒等于「节数-1 × 格距」,与相位无关。
  • 我的检查:改完转弯处不再抖动,飞行中线的长度与厚度都恒定了。我在试玩里还多试了一步 —— 把窗口切出去再切回来,发现线会「瞬移」一格,反馈后加了单帧步进 50 ms 的上限。
  • 我的判断与要求:AI 提了「整幅画面超采样」这个方案,我实测约 2.5 ms/帧,太贵,否掉了,只给两处小贴图(圆点、箭头)做超采样;pygame 的 draw.circle / draw.polygon 都不带抗锯齿、动起来会「爬行」,这个也是我看出来的。最有用的一条是:要求把「长度守恒」写成回归测试 —— 这比对着截图肉眼比对靠谱得多。

五、测试结果

5.1 测试方法

分两层:

  • 自动化测试:pytest,在 SDL dummy 驱动下无窗口运行,共 374 个用例(11 个测试文件),覆盖路径检测、关卡生成、阻挡与难度曲线、求解器与存档、窗口尺寸、缩放平移、动效、线段渲染、界面排版、字母玩法,以及端到端「合成鼠标事件真的点完一整关」。
  • 手工测试:按 README 的安装说明起真窗口试玩(入门 3 关与基础玩法多关逐关点通),并逐个界面核对排版与交互。

下面这张表是照作业「测试要求」里的 T01~T06 六项,在无窗口环境里重新跑一遍得到的实际结果(脚本用合成鼠标事件走完整链路:点击 → 判定 → 动画 → 状态切换)。

5.2 T01 ~ T06 测试记录

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 第 1 关共 21 支箭、开局可飞 13 支;点掉 1 支后剩余 20 支,红心仍 3 颗,状态仍为「对局中」,该箭已从棋盘移除
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 第 1 关有 8 支被挡;点其中一支后剩余仍是 21 支(没消失),红心 3 → 2,该箭进入暗红标记集合,状态仍为「对局中」,并弹出「被挡住了!」
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 构造 5×5 棋盘,上下左右四条边各放一支朝盘外的箭:(0,1)↑(4,3)↓(2,0)←(2,4)→ 全部正常飞出;四支飞完后剩余 0 并进入通关状态,全程未抛异常
T04 消除本关全部箭头 显示通关并进入下一关 按通关顺序点完 21 支后状态变为「单关通关」,剩余 0,结算 3 星、金币 10 → 12;点「下一关」后进入第 2 关(29 支箭),红心与倒计时复位
T05 失误次数耗尽 显示失败并允许重新开始 用 3×3 棋盘上两支箭互相对着、谁也飞不出去的场面来测(红心设 2 颗):连点两次,红心 2→1 时仍在对局中、红心归 0 时状态变为「失败」;点「重新开始」回到对局中,红心恢复 2 颗、箭恢复 2 支
T06 游戏进行中重新开始 箭头布局和失误次数恢复 先点掉 4 支、再故意点错 1 次(此时剩余 17 支、红心 2 颗、1 支暗红标记、倒计时 298.8 s);点重新开始后剩余 21 支(等于开局)、红心 3 颗(等于上限)、点错标记清空、倒计时复位到 300 s、状态为对局中

结论:T01 ~ T06 六项全部通过。

5.3 自动化测试的覆盖情况

测试文件 用例数 覆盖内容
test_path.py 17 四方向射线检测、贴边出界、折线身体不参与碰撞
test_generator.py 22 8 种造型遮罩合法且连通、生成关卡可通关、填充率
test_blocking.py 29 阻挡链 / 环路、难度逐关递减、求解器与棋盘可飞集合一致
test_solver_storage.py 18 拓扑排序求解、提示、存档读写
test_game_flow.py 8 端到端合成点击通关、点错扣心与撤销、点空白不罚、动画期间锁输入、重开
test_motion.py 22 飞出取段的长度守恒、残影、弹回摆动
test_stroke.py 43 线宽、飞镖箭头、外描边、圆点直径
test_paint.py 62 渐变 / 卡片 / 折行排版、规则页文字不重叠、顶栏控件落位
test_pan.py 24 缩放锚点、拖动与点击的消歧、边界夹取
test_window.py 12 窗口尺寸收敛到屏幕内、缩放响应
test_letters.py 117 26 个字母字模与遮罩、字母关可解性、入门玩法、界面流转
合计 374 全部通过(python -m pytest -q

运行方式(项目根目录):

pip install -r requirements.txt
python main.py            # 启动游戏
python -m pytest -q       # 374 个用例,无窗口运行

六、PSP 表格

任务 预估耗时(小时) 实际耗时(小时) 差异(小时)
需求分析与游戏设计 2.0 2.0 0.0
Python 与图形库学习 1.5 1.0 -0.5
游戏界面实现 4.0 4.5 +0.5
路径与碰撞逻辑实现 3.0 3.0 0.0
关卡设计 2.5 3.5 +1.0
AIGC 辅助开发 4.0 6.0 +2.0
测试与修改 3.0 3.0 0.0
README 与博客撰写 2.0 2.0 0.0
合计 22.0 25.0 +3.0

七、心得体会

7.1 AI 帮了什么

最大的帮助不是「写代码」,而是「把模糊的感觉变成能算的数」。 4.3 里的三次协作都是这个套路:我说「不够平滑」「太稀疏」「没有阻挡」,AI 会先写个脚本把现象量化(采样点、填充率、可飞比例),定位到确切的根因,再动手改。这比直接说「帮我优化一下动画」有用得多 —— 后者多半会得到一段看起来更复杂、但没解决真正问题的代码。

第二个帮助是覆盖面。 374 个自动化用例里,很多边界情况(贴边出界、折线身体不横扫、点空白不罚、动画期间锁输入、拖动与点击抢同一个左键)是我自己想不到的。让 AI 按「这个功能可能怎么坏」来补测试,命中率相当高。

第三个帮助是「把不报错的毛病挖出来」。 这个项目里最花时间的几个问题,没有一个会让程序崩溃或测试变红:规则页标题压住正文(数值上根本没超界)、顶栏「重新开始」图标一个像素都没画出来(按钮在、能点、只是看不见)、生成器多一条约束导致关卡没了阻挡。这类问题只能靠「量」和「看图」发现。

7.2 AI 带来的问题

  1. 看起来在优化,实际在删功能。 生成器那条多余的约束就是典型:它被描述成「预留空走廊,让盘面更好看」,目标(填充率)也确实达成了,但代价是把玩法整个抹掉。判断一段 AI 代码能不能留,要看它影响了什么语义,而不是看指标有没有变好。
  2. 测试可能把 bug 锁成预期行为。 那条「开局每支箭都能飞」的断言,正是把这个 bug 当成正确行为保了下来 —— 测试全绿反而让我更晚发现问题。测试本身也要评审:它断言的是「正确的需求」还是「当时的实现」?
  3. 小尺寸下「画不出来」的坑。 「重新开始」图标用的是 pygame.draw.arc,在小半径 + 线宽大于 1 时一个像素都不渲染。代码没错、不抛异常、按钮也能点,只有肉眼看图才发现。凡是「画出来的东西」,都得真的看一眼图。
  4. 单位不统一导致的静默错误。 做艺术字描边时,描边宽度用的是「超采样后的像素」,而内外边距用的是「原始像素」,两边没统一,标称 6 像素的描边实际只有 2 像素。
  5. 默认值在定义时就绑定。 存档类 Save(path=SAVE_PATH) 的默认参数在函数定义时就绑死了,改模块常量不生效 —— 结果截图脚本一直在用真实存档,把旧进度印到了新截图上。

7.3 收获

  • 需求要落到可验证的形式。 「铺满棋盘」「不要太有规律」「飞得要平顺」,这些说法我后来全部翻译成了数字(填充率、条带数、折线长度守恒),于是既能验收,也能写成回归测试守住。
  • AI 是很快的副驾驶,但方向盘得自己握。 它能在几秒内给出一个能跑的版本,但「这段代码要保留哪条约束」「这个指标达标了玩法还在不在」「这个测试断言的是不是真需求」,只能自己判断。
  • 先量后改。 这个项目里每一次「感觉不对 → 打印/统计 → 定位 → 改 → 再量」都比较顺利;反过来凭直觉改的几次,基本都改了两三遍。
  • 把踩过的坑写回文档。 我在仓库里单独维护了一份 AIGC记录.md(27 条汇总 + 14 条详述)和 docs/test-record.md(T01~T23),把每次「不报错的毛病」和它的判据都记下来。写博客时回头看,这份记录本身就是最有价值的产出之一。

八、素材来源与安全说明

  • 玩法参考:微信小游戏《一箭又一箭》。本项目只参考其核心玩法(点击箭头、射线判定、飞出与阻挡),没有使用原游戏的任何代码、美术素材、音效或关卡数据;关卡全部由本项目的生成算法产出。
  • 美术素材零外部素材。所有图形(箭头、图标、吉祥物、艺术字标题、背景暗纹)都是程序用几何图形画出来的矢量图;音效由程序合成(game/audio.py),不依赖任何音频文件。
  • 第三方库:仅使用 pygame-ce(图形库)与 pytest(测试框架),均为开源库,通过 pip 安装,不含任何来源不明的代码。
  • 敏感信息:仓库中没有任何密码、Cookie、API Key 或 Token.gitignore 已排除 .env;本次开发没有使用任何需要密钥的在线服务。
  • 开发过程:仓库共 55 次提交,从项目骨架、路径检测、碰撞与失误机制、关卡生成器,到界面美化、动效、扩展功能和文档,按阶段分次提交,不是一次性上传。本博客和 README 中引用的截图与动图,都由仓库里的 tools/screenshot.py 与离屏录制脚本生成,与提交的代码一一对应。
posted @ 2026-09-21 00:55  astronomyy  阅读(0)  评论(0)    收藏  举报