软件工程第二次作业

2026 秋软件工程个人作业(第二次)——用 AIGC 完成「一箭又一箭」小游戏

项目 内容
这个作业属于哪个课程 <课程链接:https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering>
这个作业要求在哪里 <作业链接:https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16717>
这个作业的目标 使用 Python 和 AIGC 完成「一箭又一箭」小游戏
学号 102401227
GitHub 仓库 https://github.com/echo14670/arrow-arrow-game

一、项目展示

演示 GIF:

一箭又一箭 2026-09-16 20-43-18 (1)

各界面截图:

01-start

01b-level-select

02-gameplay

03-blocked

05-level-clear

06-failed

07-all-clear

01c-custom-setup

01d-custom-level

二、项目介绍

游戏规则:棋盘上散布着朝向上、下、左、右四个方向的箭头。点击一个箭头时,
程序检查它前进方向上、到棋盘边界之间还有没有别的箭头:

  • 前方没有任何箭头 → 该箭头飞出棋盘并消失;
  • 前方有别的箭头 → 箭头飞不出去,播放抖动加变红的碰撞反馈,并消耗一次失误机会;
  • 清空本关全部箭头即过关,进入下一关;失误次数耗尽则本关失败,可以重新开始。

界面设计:一共三套界面,都是深色背景、圆角卡片风格。

  • 开始界面:游戏标题、四种箭头的示意图、玩法说明、「开始游戏」与「选择关卡」两个按钮;
  • 选择关卡界面:列出全部 6 关的名称、尺寸、箭头数与失误上限,点任意一行就能直接开始;
    最下面一行是「自定义关卡」入口,可以自己定地图大小、箭头数量与难度,生成的关卡以绿色高亮
    追加在列表的最后一行,可以反复重玩;
  • 游戏界面:上方 HUD 显示关卡名、第几关 / 共几关、剩余箭头数、剩余失误次数、本关用时;
    中间是棋盘;下方是「重新开始」「撤销」「返回主菜单」三个按钮;
  • 结果界面:过关面板显示本关用时、点击次数、失误次数、撤销次数,并提供「下一关」;
    失败面板提示失误次数已经用尽,可以重试本关或返回主菜单;
    全部通关面板显示总用时、总点击次数与总失误次数。

主要功能与特色

功能 说明
六个关卡 从 3×3 到 6×6,共 54 个箭头,难度递增
飞出动画 箭头沿自己的方向滑出棋盘并淡出
碰撞反馈 箭头左右抖动 + 变红 + 浮出「被挡住了!」提示
失误机制 每关独立的失误次数上限,剩余 1 次时数字变红提醒
实时计时 显示本关用时,全部通关时统计总用时
撤销 可以撤销上一步成功消除,不消耗失误
重新开始 随时把本关恢复到初始状态(布局与失误次数都还原)
返回主菜单 每一关(含自定义关卡)都能一键中断本局回到主菜单,进度清零
键盘操作 R 重新开始、U 撤销、Enter 开始 / 下一关、Esc 退出
选择关卡 主菜单可进入选关界面,点击任意一关直接开始,也支持数字键 1–9
自定义关卡 自己定地图大小(2×2–6×6)、箭头数量(1–行×列)与难度(低/中/高),生成器保证一定有解
鼠标悬停 悬停的格子高亮,箭头变亮
打包 exe 提供一键打包脚本,生成免安装的单文件 exe

三、实现思路

3.1 箭头、方向与关卡怎么表示

方向用一个枚举表示,每个方向的取值就是它在网格里的移动量(行向下为正、列向右为正):

class Direction(Enum):
    UP = (-1, 0)
    DOWN = (1, 0)
    LEFT = (0, -1)
    RIGHT = (0, 1)

棋盘用二维列表保存,每格是 DirectionNone。关卡数据用等长字符串描述,
. 是空格,^ v < > 是四种箭头,这样在代码里一眼就能看出布局:

Level(
    name="第 2 关 · 错身而过",
    grid=(
        ".v..",
        ">>vv",
        "....",
        "...<",
    ),
    mistakes=4,
)

3.2 路径检测

这是整个游戏的核心:箭头能不能飞出,完全取决于它到边界之间有没有别的箭头。
实现方式是从箭头所在格子出发,沿着它的方向一步一步走,出界就说明畅通:

def is_free(self, row, col) -> bool:
    direction = self.at(row, col)
    if direction is None:
        return False
    d_row, d_col = direction.delta
    cur_row, cur_col = row + d_row, col + d_col
    while self.inside(cur_row, cur_col):     # 越界即视为畅通
        if self._cells[cur_row][cur_col] is not None:
            return False                      # 遇到别的箭头,被挡住
        cur_row += d_row
        cur_col += d_col
    return True

这里有三个容易踩的坑:

  1. 必须显式判断边界。如果写成 self._cells[row - 1][col],当箭头在第 0 行且朝上时,
    Python 的负索引会绕到棋盘最后一行,把棋盘底部的箭头误判成阻挡。我在
    tests/test_board.py 里专门写了回归用例 test_negative_index_wraparound_regression
  2. 只查同一行或同一列。斜向的箭头不算阻挡,所以只需要沿一个方向走。
  3. 只关心「到边界之间」。箭头后面的箭头不影响它,因此不需要双向检查。

因为每次只关心一个方向,最坏情况下的复杂度是 O(棋盘边长),一帧里对全部箭头做判断也毫无压力。

3.3 游戏流程与状态机

游戏规则全部放在 game/session.pySession 里,界面完全不参与判定,
这样同一套逻辑既能跑在窗口里,也能写自动化测试。状态机有五个阶段:

START(开始界面) → PLAYING(游戏中) → LEVEL_CLEAR(过关) → PLAYING(下一关)……
                                      ↘ FAILED(失败)→ PLAYING(重试本关)
                                      ↘ ALL_CLEAR(全部通关)

点击一格只有三种结果:REMOVED(飞出并消失)、BLOCKED(被挡住,扣一次失误)、
IGNORED(空格、棋盘外,或者当前阶段不接受输入)。

3.4 关卡怎么保证一定能通关

随机撒箭头的关卡很容易出现「互相挡住谁也走不了」的死局。这里用的是逆向摆放

如果一关的消除顺序是 r1, r2, …, rn,那么消除 r_i 时棋盘上还剩 r_i … rn。
所以只要按 rn, r_{n-1}, …, r1 的顺序摆放,并且摆放每个箭头时它相对「已经摆好的箭头」
前方是通的,构造出来的关卡就一定存在 r1 → … → rn 这条通关路径。

生成器(tools/generate_levels.py)每关随机生成几千个候选,再用评分挑出「开局被挡的箭头多、
需要做选择」的布局(打分细节见 3.5);第 1 关是手写的教学关。另外还有一个贪心求解器 game/solver.py

def solve(board):
    """拿走箭头只会让别的箭头更自由,因此不需要回溯,逐步贪心即可。"""

它既能用来校验关卡可解(tests/test_levels.py 断言「解序列长度 == 箭头数」),
也能用在截图脚本里自动通关。

3.5 从「一定可解」到「有意思」:候选评分 best_layout

逆向摆放只保证了「有解」,并不保证「好玩」:按这套规则随机摆出来的候选,绝大多数都是开局几乎
每个箭头都畅通,点哪个都一样,玩起来没有思考量。所以生成器在「保证可解」之外又加了一层筛选,
把生成拆成三步——批量造候选、给候选打分、取分数最高的那个(tools/generate_levels.py)。

打分需要的三个统计量来自 game/solver.pydescribeblocked_at_start 是开局就被挡住的
箭头数,forced_steps 是只有唯一选择(不能不点它)的步数,choice_steps 是有多个可选箭头的步数。
评分函数本身只有两行:

def score(stats: dict[str, int], arrows: int) -> float:
    """布局评分:可解、挡住的箭头够多、需要思考的步数适中。"""
    if not stats["solvable"]:
        return -1.0                                   # 不可解的候选直接给最低分
    blocked_ratio = stats["blocked_at_start"] / max(arrows, 1)
    return blocked_ratio * 2.0 + stats["choice_steps"] * 0.05 + stats["forced_steps"] * 0.1

best_layout 就是围绕这个评分做的「随机采样 + 取最优」循环:

def best_layout(rows, cols, arrows, tries, rng):
    best, best_score = None, -1.0
    for _ in range(tries):                            # 默认采样 3000 次
        board = random_layout(rows, cols, arrows, rng)
        if board is None or not is_solvable(board):
            continue                                  # 摆不满或不可解的候选直接丢弃
        stats = describe(board)
        current = score(stats, arrows)
        if current > best_score:
            best, best_score = (board, stats), current
    if best is None:
        raise SystemExit("生成失败:请放宽箭头数量或增加尝试次数")
    return best

这里的取舍是:

  1. blocked_ratio 是主项(权重 2.0)。开局被挡的箭头越多,玩家越得先想办法把「挡路的那几个」
    清掉,关卡才有解谜感;被挡数接近 0 的布局就是「一眼看穿」的废稿。它先除以箭头总数做了归一化,
    不同规模的候选之间才能比较。
  2. forced_steps / choice_steps 是次要项(权重 0.1 / 0.05),用来在同样「挡得多」的候选
    之间做微调。顺带一提,因为这两个数之和恒等于箭头总数,这两项其实等价于
    「一个常数 + 0.05 × 必经步数」,真正在起作用的是「挡得多」和「有若干步是被逼着走的」。
  3. 这两个统计量只是「手感」的近似。它们是在固定的贪心顺序(按行优先取第一个可点的箭头)下
    数出来的,同一个布局换个顺序数字就会变。所以评分只负责把明显平庸、看起来都差不多的候选排掉,
    最终第 2–6 关的布局还是我把候选打印出来逐个看过之后挑的,第 1 关则是手写的教学关。

实际跑的时候还有两点量级上的经验:

  • 采样次数要够。候选是随机的,--tries 默认 3000,就是为了让「挡得多」的样本出现并且留下来;
    --count 一次输出多个候选时内部用 seen 去重,避免重复推荐同一个布局。
  • 箭头密度不能太高。逆向摆放时每个格子会随机试四个方向,四个方向都被前面的箭头挡住就跳过
    这一格,箭头数接近格子总数时很容易摆不满而返回 None。实测 5×5 摆 12 个箭头时 200 次全部
    成功,摆 20 个成功 198 次,摆到 24 个就只剩 21 次了,所以 arrowsrows * cols 的比例
    要留足余量。

需要强调的是,评分只是在候选池里挑更好玩的那个,池子本身是否干净并不靠它:逆向摆放保证「可解」
不依赖运气,我用它连跑 300 次 5×5 / 12 箭头,不可解的样本数是 0。

3.6 动画与界面

  • 箭头用多边形按方向绘制(以「朝右」为基准画一次,再按 0°/90°/180°/270° 旋转),
    并按(方向, 尺寸, 颜色)缓存成小图片,避免每帧重画。
  • 飞出动画:箭头沿自己的方向平移并逐渐透明;碰撞动画:箭头沿垂直于运动方向抖动并闪红,
    再浮出一行提示文字。
  • 动画期间会锁定鼠标点击,防止玩家连点导致一次点击被重复结算。
  • 中文字体按「微软雅黑 → 黑体 → 宋体」的顺序查找字体文件,找不到再退回系统字体名,
    最后退回 pygame 默认字体。

3.7 自定义关卡:把生成器交到玩家手里

选关界面六关下面是「自定义关卡」。它的三个参数和取值范围是这样定的:

参数 范围 为什么是这个范围
行数 / 列数 2 × 2 到 6 × 6 上限沿用界面里按 6×6 设计的棋盘(6 列 467 像素宽、6 行刚好排在 HUD 与按钮之间),再大就会破坏UI界面;下限取 2 是避免退化成「一条线」
箭头数量 1 到 行 × 列 上限就是格子总数,因为生成器能保证这个范围内的任意数量都有解;变更大小时自动夹紧
难度 低 / 中 / 高 用 3.5 节的 best_layout 评分衡量:高难度取分数最高的候选,低难度取最低分,中难度取中位数

「任意数量都有解」靠的是两层构造。第一层还是 3.4 节的逆向摆放:随机挑格子与方向,只要求新箭头
相对已经摆好的箭头前方通畅,摆出来的关卡天生带一条通关路径。但箭头数量接近格子总数时随机摆放
经常摆不满,于是补了第二层洋葱式摆放

def _ring(row, col, rows, cols):
    """格子到最近一条边的距离(第几层洋葱皮)"""
    return min(row, col, rows - 1 - row, cols - 1 - col)

按这个层号排序,消除顺序先外圈后内圈,摆放时再倒过来从内层往外层放。一个箭头指向最近的边时,
它前方只可能是更外圈的格子,而那些箭头会更早被消除——所以不管要摆几个箭头,这个顺序都成立。
我用它把 2×2 到 6×6 的每种大小、每个箭头数量都跑了一遍(325 组「大小 × 箭头数」,再乘三档
难度共 975 组),箭头数量全部正确、求解器复核全部通过。

难度分档则是把 3.5 节的采样逻辑收进 game/generator.py:造 24 个候选、逐个用 solve() 复核、
score() 排序,再按难度取不同位置:

pool.sort(key=lambda item: item[0])
if difficulty is Difficulty.HIGH:
    return pool[-1][1]              # 高难度:分数最高的候选
if difficulty is Difficulty.LOW:
    return pool[0][1]               # 低难度:分数最低的候选
return pool[len(pool) // 2][1]      # 中难度:中位数

失误次数不再手写,而是按箭头数量与难度算出来:低 0.5 / 中 0.35 / 高 0.25 倍,再夹到 2 到 8 之间,
也就是难度越高允许的失误越少。生成的关卡只占一个槽位(列表第 7 行,用绿色高亮区分),
再次生成就替换掉它——这样既不用改动 LEVELS 里的 6 个内置关卡,next_level() 走到最后一关时
也会自然进入「全部通关」。

可以看到,这一节基本是把 3.4、3.5 两节的生成器从「我用来造关卡的工具」变成「玩家手里的功能」,
真正新增的只有洋葱式摆放这一个构造法,以及参数范围与夹紧规则。

四、AIGC 使用过程

本次使用的 AIGC 工具是 Codex,它参与了从需求分析到打包的全过程。
下面是其中几次有代表性的协作(更完整的记录见仓库里的 docs/aigc-log.md):

子任务 借助何种 AIGC AI 实现或提供了什么 效果 如何人工修改
环境勘测与技术选型 Codex 检查本机 Python 版本与可用图形库,比较 Pygame / Tkinter 的取舍,验证 pygame 有可用的 wheel 确定 Python 3.13.12 + Pygame 2.6.1 安装卡住后改用清华镜像;确认第 3 方依赖只有 pygame
关卡生成 Codex 提出「逆向摆放」构造法 + 贪心求解器,保证每关一定有解 6 个关卡全部可解,没有再出现死局 第 1 关改成手写教学关,其余关卡按统计指标人工挑选
路径检测 Codex 给出四种方向的检测代码,并指出「向上检测用负索引会绕到棋盘底部」这个坑 核心逻辑一次写对 保留显式边界判断写法,补回归用例
自动化测试 Codex 生成 58 条 unittest 用例 首次运行 3 个失败、5 个报错 定位出三处都是测试自己写错(行宽不一致、边界摆位、点击顺序),修正后全部通过
界面截图 Codex 用 SDL dummy 驱动离屏渲染,写脚本自动出图 自动生成界面截图(目前共 10 张),并暴露出两处视觉缺陷 修正浮字压住箭头、结果面板文字被按钮盖住的问题
打包 exe Codex 给出 PyInstaller 参数并封装成一键脚本 生成约 27 MB 的 ArrowArrowGame.exe,双击可运行 可执行文件改用 ASCII 名,dist/ 等产物加入 .gitignore
自定义关卡(附加功能) Codex 新增关卡生成器:随机摆放 + 洋葱式摆放兜底,保证「1–行×列 任意数量都有解」,再按 best_layout 评分按难度挑候选;另加 CustomSetup 参数表单与自定义面板 玩家可以自己定 2×2–6×6 的大小、箭头数量与难度,生成的关卡以绿色高亮追加在选关列表最后一行 修正三处问题:高密度摆不满、棋盘缩小时箭头数量没跟着夹紧、拿一个本身就无解的布局当难度对照;面板加高、入口按钮改为按关卡数量动态定位

举一个最典型的例子:关卡的可解性。我一开始的想法是「随机撒箭头,然后人工试玩,走不通就换一个」,
但很快就发现随机布局产生死局的概率很高。Codex 提出了逆向摆放的思路——按消除顺序倒着放箭头
这样构造出来的关卡天生就带着一条通关路径,再配合一个不需要回溯的贪心求解器,
就能在测试里断言「每一关的通关序列长度等于箭头数」。这个做法把「靠人试」变成了「靠算法保证」,
tests/test_levels.py 里的 8 条用例全部围绕这一点。

另一个例子是 AI 自己犯的错:Codex 一口气写了 51 条测试用例,第一次运行就有 3 个失败、5 个报错。
逐条查下来发现三处都是测试代码自己写错:用来做测试的关卡两行宽度不一致、
「四个方向都要能被挡住」的用例把箭头放在了棋盘边界上(边界上的箭头前方本来就是棋盘外)、
T06 的用例先点掉了唯一能解救受阻箭头的那个箭头。修正之后 51 条全绿。之后每加一个功能我都按同样的流程补用例:加完选关功能是 70 条,
加完自定义关卡是 133 条,全部通过。

五、测试结果

用标准库 unittest 写了 138 条自动化测试:

python -m unittest discover -s tests -t . -v
# Ran 138 tests in 4.725s
# OK

作业要求的六个测试用例都做了自动化覆盖:

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 箭头数量 3 → 2,该格变空,失误次数不变 通过
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 箭头数量不变、仍在原格,失误 5 → 4 通过
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 左上角 ^、左下角 > 都正常飞出,无异常 通过
T04 消除本关全部箭头 显示通关并进入下一关 剩余箭头 0,进入过关面板,点下一关切到第 2 关 通过
T05 失误次数耗尽 显示失败并允许重新开始 失误 1 → 0,显示失败面板,重试恢复初始状态 通过
T06 游戏进行中重新开始 箭头布局和失误次数恢复 棋盘与初始布局完全一致,失误恢复上限,用时归零 通过

除此之外还覆盖了:点击空格与棋盘外坐标被忽略、撤销不消耗失误、通关后不能撤销、
计时只在游戏中累加、动画期间点击不重复结算,以及八个界面阶段(开始 / 选关 / 自定义关卡设置 /
游戏 / 过关 / 失败 / 全部通关)都能正常渲染。

测试过程中发现并修复的问题(详细记录见 docs/test-record.md):

# 问题 原因与修复
1 测试用例报错 测试用关卡两行宽度不一致,改成等长后正常
2 用例失败 四方向阻挡用例把箭头放在了边界上,改为在棋盘中心程序化摆放
3 用例失败 T06 先点掉了关键箭头,调整点击顺序后通过
4 界面缺陷 「被挡住了!」浮字压在箭头上,起点移到格子上方
5 界面缺陷 结果面板第 4 行文字被按钮盖住,重排面板与按钮位置
6 统计错误 重复通关导致显示「通关 7 / 6 关」,改为不重复统计
7 环境问题 pip 直连 PyPI 卡住,改用清华镜像安装成功
8 进程崩溃 加完选关功能后测试进程以 0xC0000005 崩溃:字体与箭头贴图被缓存,pygame.quit() 后底层资源已释放,重新初始化再绘制就访问违例;加 clear_cache() 并在 Game 初始化时调用
9 生成器覆盖不到高密度 箭头数量接近格子总数时随机摆放反复摆不满,一个候选都拿不到;补「洋葱式摆放」兜底,让 1..行×列 的任意数量都能构造出可解关卡
10 参数联动缺失 棋盘从 6×6 改小到 3×3 后箭头数量仍可能是 36;在表单的赋值逻辑里补上「先改大小、再夹箭头」
11 测试样例本身无解 本想用 (">>", "<.") 当「被挡得更多」的对照,实际三个箭头互相挡死、评分 -1;换成 (">v", "<."),分数 0.87 对 0.15

六、PSP 表格

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

七、心得体会

AI 帮了什么忙

  1. 把思路直接给出来。逆向摆放保证关卡可解、贪心求解器不需要回溯、动画期间锁输入,
    这些做法我自己大概率要试错很久才能想明白,AI 一次就讲清楚了。
  2. 减少重复劳动。编写133 条测试用例、10 张截图、打包脚本,这些步骤现在几乎不占时间。
  3. 帮助检查自己没想到的边界。比如「向上检测时负索引会绕到棋盘底部」这一类问题,
    它主动提醒了我,避免了一个很难发现的 bug。

出现的问题

  1. AI 写的测试也会错。51 条用例第一次跑就有 3 失败 5 报错,而且错误全在测试自己身上:
    棋盘行宽不一致、把箭头摆在棋盘边界上导致「阻挡」根本不存在、点击顺序破坏了测试前提。
    这提醒我:测试跑出红色不代表程序有问题,先怀疑测试本身。
  2. 界面细节必须看截图。浮字压住箭头、面板文字被按钮盖住,这两处代码层面完全看不出问题,
    只有把图渲染出来才发现。

收获

这次作业让我体会到,AIGC 更像一个「手很快、但需要人审稿的搭档」:它能把结构、算法和重复代码
快速铺开,但边界条件、统计口径、视觉细节这些需要判断力的地方,仍然得自己掌控。
另外一点感悟是:规则和界面分离这个设计非常合理——
Session 不依赖窗口,所以 44 条流程测试跑完只要 0.02 秒,
如果规则写死在界面里,这些测试根本没法做。

posted @ 2026-09-16 21:16  asdadsdf  阅读(18)  评论(0)    收藏  举报