2026软件工程第二次作业

项目 内容
这个作业属于哪个课程 H202601软件工程与软件工程实践
这个作业要求在哪里 软件工程课程第二次个人作业
这个作业的目标 使用 Python 独立开发一个“一箭又一箭”风格的小游戏
学号 102401301
GitHub 仓库 GitHub仓库链接

项目展示

这是一段游戏演示:从开始界面进入第 1 关,故意点一个被挡住的箭头(失误次数从 3 减到 2),然后依次消除箭头,最后结算通关。

demo

下面是各个界面的截图。

开始界面:

01_start

游戏界面。顶部信息栏显示关卡、剩余箭头、剩余失误、用时、本关最佳星级,右侧是撤销/提示/重新开始/音效四个按钮:

02_playing

点击被挡住的箭头时,箭头会晃动并变红,失误次数减 1:

03_blocked

点「提示」后,一个当前能飞出的箭头会被呼吸圈高亮:

04_hint

关卡通过,按本关消耗的失误次数评星(这一关零失误,3 星):

05_clear

失误耗尽时本关失败,可以重开:

06_failed

关卡选择界面,未通关的关卡显示空心星,已通关的显示星级和最佳用时:

07_select

随机关卡,每次随机生成,保证有解:

08_random

项目介绍

规则

棋盘是一个网格,格子里放箭头,箭头朝上、下、左、右四个方向之一。点一个箭头,程序检查它前进方向到棋盘边界之间有没有别的箭头:

  • 前方没有箭头,它沿所指方向飞出棋盘,然后消失;
  • 前方有箭头,它不能飞出,会晃动变红提示碰撞,同时失误次数减 1。

清空当前关卡的全部箭头就过关,进入下一关;失误次数减到 0 则本关失败,可以重开。

判定只看同一行或同一列,不涉及斜向。

界面

分四类界面:开始界面、游戏界面、结果界面,另外加了一个关卡选择界面。

游戏界面顶部有一条信息栏,显示当前关卡、剩余箭头数量、剩余失误次数、用时、本关最佳星级。右侧排四个按钮:撤销、提示、重新开始、音效。

棋盘用深色底板加网格线,箭头用四个颜色区分方向(上黄、下橙、左绿、右蓝)。箭头飞出时沿方向平移并淡出,被挡时来回晃动并闪红。

主要功能

基础功能之外,我参考原版《一箭又一箭》做了几个附加功能:

撤销。回退上一步,包括恢复被消除的箭头和退还失误次数。每关有次数上限,按钮或 Z 键触发。

提示。高亮一个当前能消除的箭头,用呼吸圈表示。每关有次数上限,按钮或 H 键触发。

计时与星级评价。记录本关用时。按消耗的失误次数评 1 到 3 星,零失误 3 星,失误 1 次 2 星,其余 1 星。星级显示在信息栏和结算界面上。

关卡选择与解锁。独立的选关界面,显示每关的星级和最佳用时。通关后解锁下一关,没解锁的关卡是灰色锁定状态。

随机关卡。随机生成关卡,用的是反向构造,生成出来的关卡一定有解。

进度存档。解锁进度、每关最佳星级和用时、音效开关都存在 save/progress.json,下次启动自动读回。文件损坏或读不出来时会退回默认值,不会导致游戏打不开。

音效。飞出、碰撞、通关、失败、提示这几个音效是用正弦波在代码里合成的,没有用外部音频文件。没有声卡的环境会自动静音。

内置关卡一共 5 关,棋盘从 3×3 到 6×6。

特色

关卡的可解性由程序保证,不靠人工试。预设关卡用生成器产出后又用求解器校验,随机关卡在生成阶段就保证有解。这一点我下面会展开讲,因为中间踩过坑。

实现思路

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

箭头是一个数据类,用行列坐标加方向表示:

@dataclass
class Arrow:
    row: int
    col: int
    direction: str      # "up" / "down" / "left" / "right"
    state: str = "idle" # idle / flying / blocked
    anim_t: float = 0.0

方向到坐标位移的映射集中放在一处,避免散落在各个函数里:

DIRS = {
    "up": (-1, 0),
    "down": (1, 0),
    "left": (0, -1),
    "right": (0, 1),
}

行号向下增加,列号向右增加,所以向上的行增量是 -1。

关卡是纯数据,跟逻辑分开,方便调整和测试:

{
    "name": "第 1 关 · 入门",
    "rows": 3, "cols": 3,
    "mistakes": 3, "undos": 3, "hints": 3,
    "arrows": [(2, 0, "up"), (1, 0, "right"), ...],
}

盘面状态用字典维护,键是 (row, col),值是箭头。这样按坐标取箭头是 O(1),重新开始或撤销时重建也快。

路径检测

这是整个游戏的核心,也是最重要最复杂的地方。

做法是从箭头所在格出发,沿方向一格一格走,在棋盘范围内遇到箭头就判定被挡:

def is_blocked(self, arrow):
    dr, dc = DIRS[arrow.direction]
    row, col = arrow.row + dr, arrow.col + dc
    while self.in_bounds(row, col):
        if self.solid_at(row, col):
            return True
        row += dr
        col += dc
    return False

边界判断放在 while 的条件里,先判断再取格子。这样写的原因是:如果反过来先取出下一格再判断有没有越界,向上和向左走的时候会先访问到 grid[-1] 这种负下标,Python 不会报错但会绕到棋盘另一端,形成很难发现的 bug。把它放进循环条件就从源头绕开了这个问题。这一点对应作业测试项 T03(边缘且朝外的箭头不应越界报错)。

另外,箭头飞出时有一段动画。动画播放期间这个箭头在逻辑上算已经消除,不应该继续挡住别的箭头,所以在判断里用 solid_at 过滤掉 flying 状态的箭头,而不是直接判断格子里有没有箭头。否则会出现「箭头明明飞出去了但还是挡着后面」的情况。

关卡怎么保证有解

第一版关卡是我自己摆的。摆完用程序一验证,发现有几关根本通不了——箭头互相指着,形成闭环,谁都飞不出去。人工摆关卡很容易摆出这种死局,而且肉眼不容易看出来。

后来改用反向构造。思路是把拆除顺序倒过来放置:

放第一个箭头时棋盘是空的,随便放。放第 k 个箭头时,棋盘上只有「会在它之后才被拆掉」的那些箭头,所以这个箭头此刻的路径上必须没有它们。只要满足这个条件放进去,那么按放置顺序的逆序点击,每个箭头出手时前方都是空的,一定能全部清掉。

这样构造出来的布局天生有解,不需要事后去试。

# 放入 (row, col) 处箭头时,检查该方向到边界之间是否为空
r, c = row + dr, col + dc
clear = True
while 0 <= r < rows and 0 <= c < cols:
    if grid[r][c] is not None:
        clear = False
        break
    r += dr
    c += dc
if clear:
    grid[row][col] = direction

生成之后仍然会用求解器再校验一遍,作为双保险。

撤销怎么实现

每次操作前先给棋盘拍个快照,记下所有箭头的坐标方向和剩余失误数:

def snapshot(self):
    return {
        "arrows": [(a.row, a.col, a.direction)
                   for a in self.arrows if a.state != "flying"],
        "mistakes_left": self.mistakes_left,
    }

撤销时用 restore() 把棋盘清空重建。快照只存坐标和方向这类基本数据,不存箭头对象,避免撤销后旧对象还被别处引用。

音效怎么生成

直接用正弦波算采样点,衰减做包络,塞进 pygame.mixer.Sound

for i in range(count):
    t = i / SAMPLE_RATE
    envelope = 1 - i / count
    sample = math.sin(2 * math.pi * freq * t) * volume * envelope
    buf.append(int(max(-1.0, min(1.0, sample)) * 32767))

这样不用引入任何音频素材文件。

AIGC 使用过程

开发过程中我一直在用 AIGC 编程助手(ZCode,接的是 DeepSeek 模型)辅助。下面是几次比较有代表性的协作,以供查阅:

子任务 借助何种 AIGC 技术 AI 实现或提供了什么 效果如何 人工修改
路径检测 ZCode(DeepSeek) 生成四个方向的检测代码 提示了向上/向左的越界隐患 把边界判断改到 while 条件里,并补了四方向的边界测试
关卡设计 ZCode(DeepSeek) 提出反向构造法,生成可解布局 生成了必定可解的关卡,但第一版初始没有阻挡 增加筛选条件,重新挑选布局
字体加载 ZCode(DeepSeek) 定位到 pygame 字体枚举崩溃的原因 给出直接按字体文件路径加载的方案 加了异常兜底,保证中文能显示
随机关卡校验 ZCode(DeepSeek) 编写生成器与求解器 求解器自身有个循环上限的 bug,被测试抓到 改成固定循环次数
界面可读性 ZCode(DeepSeek) 排查文字与箭头重叠 给出结果卡片方案 卡片加高并重排元素间距

下面挑几个展开说。

1. 路径检测里的越界问题

我让 AI 生成四方向的路径检测。它第一版给的是「先取下一格、再判断是否越界」的写法。我追问了向上和向左的情况,它指出这样会在边界处访问到负下标——Python 里 grid[-1] 不报错,会绕到棋盘另一端,判定结果就错了,而且不会抛异常,很难靠运行发现。

改成把 in_bounds 放进 while 条件后正常。这个改动对应作业的 T03,我在 tests/test_board.py 里给四个方向都贴边的场景补了测试(test_edge_arrow_pointing_outward_is_free)。

这里我想说的是,AI 第一版给的代码是能跑的,正常下棋大部分时候也不会出问题,只在边缘才暴露。如果不追问、不专门测边界,这个 bug 会一直留着。

2. 关卡设计:从人工摆放改到反向构造

我先让 AI 生成几个关卡数组。它生成了,但我试玩时发现有些布局根本通不了。我把它生成的结果拿回来验证,确认是无解闭环。

然后 AI 提出反向构造法:不直接摆最终布局,而是按「拆除顺序的逆序」一个一个放,放每个箭头时保证它的路径上只有会在它之后被拆掉的箭头。这样按放的反序点击就一定有解。它还写了配套的生成器和求解器。

效果是生成出来的关卡确实都有解了。但我又发现新问题:第一版生成出来的布局,开局时一个被挡的箭头都没有,玩家点哪个都能飞,阻挡机制完全体现不出来。于是加了筛选条件——要求初始被阻挡的箭头数够多、方向分布也要多样,重新挑了一批布局写进 levels.py

再后来我让AI把「每关都必须可解」写成了自动化测试(test_every_level_is_solvable)。这个测试后来还真派上了用场,下面会提到。

3. 字体加载的意外崩溃

游戏第一次启动就崩了,报 TypeError: expected str, bytes or os.PathLike object, not int,位置在 pygame 的 sysfont.initsysfonts_win32

我把报错贴给 AI,它定位到是 pygame 枚举系统字体时读到了注册表里的异常值。给的方案是绕开字体枚举,直接按字体文件路径加载,优先用 C:\Windows\Fonts\msyh.ttc(微软雅黑)。

改完之后中文正常显示了。我又让它顺手加了兜底:如果所有字体文件路径都找不到,就退回系统默认字体,可能不支持中文但不至于崩。

4. 生成器校验器自己的 bug

这个比较有意思。我写完随机关卡的功能后,测试 test_generated_levels_are_solvable 报某个随机种子的关卡不可解。

我第一反应是生成器有问题。但让 AI 一起看之后发现,问题出在校验函数 solvable() 自己身上:它的循环上限用了 len(remaining),而这个值会随着箭头被消除而变小,导致大棋盘在还没判完的时候就提前退出了循环,被误判成无解。

改成固定循环「箭头总数」次、每轮只消一个箭头,改完 30 个随机种子全部通过。

这个 bug 是测试抓出来的,不是我看出来的。如果没有这条测试,交付出去的就是「随机功能偶尔生成无解关卡」。

5. 界面可读性

加入星级和用时之后,结果界面出问题了:卡片里的「完美通关 · 用时 00:02」这行字压到了「下一关」按钮上。

我截图给 AI 看,它建议把结果面板做成不透明的圆角卡片,卡片里的元素都相对卡片定位。改成这样之后,卡片加高到 364,标题、星级、说明、两个按钮按内容高度重新分配间距,重叠就没了。

顺便说一句,最早的结果界面用的是半透明遮罩加文字,文字会跟背后的棋盘箭头叠在一起,也是那时候一起改掉的。

测试结果

测试用 pytest,命令是 python -m pytest -v。因为这些测试要投递鼠标事件、还要渲染界面,所以做了无头支持(SDL 的 dummy 驱动,离屏 Surface),不依赖显示器和声卡。

作业要求的测试项

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 箭头进入 flying 状态,动画结束后从棋盘移除,剩余箭头数 -1 通过
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 箭头仍在棋盘上,进入 blocked 状态,失误次数 -1 通过
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 正常飞出消除,无越界异常 通过
T04 消除本关全部箭头 显示通关并进入下一关 显示「本关通过」,点主按钮进入下一关;最后一关显示「全部通关」 通过
T05 失误次数耗尽 显示失败并允许重新开始 显示「本关失败」,重开后失误和布局复原 通过
T06 游戏进行中重新开始 箭头布局和失误次数恢复 剩余箭头数和失误次数都恢复到初始值 通过

T01 到 T06 这几条不是直接调函数,而是构造真实的 MOUSEBUTTONDOWN 事件、按格子中心的像素坐标投给 App,这样连「屏幕坐标转格子坐标」的换算也一起测到了。

路径检测与棋盘逻辑

用例 验证点 结果
四个方向分别被阻挡 上/下/左/右都能正确判定 通过
前方无箭头 判定为可飞出 通过
不同行或列 不互相阻挡 通过
中间隔着空格 仍判定为被阻挡 通过
四方向贴边 紧贴边界的箭头可飞出,不越界 通过
点击空格或棋盘外 忽略,不改变状态 通过
失误下限 失误次数不会降到 0 以下 通过
飞出中的箭头 不计入剩余数量,也不阻挡其他箭头 通过

关卡可解性

作业里写了「关卡实际上无法通关」要扣分,所以这条单独测:

用例 验证点 结果
关卡数量 至少 3 关(实际 5 关) 通过
每关可解 贪心求解器能把每关清空 通过
每关初始有阻挡 保证玩法能体现阻挡机制 通过
关卡数据合法 无越界、无重叠、方向合法 通过
四个方向齐全 5 关合起来用到了全部四个方向 通过

关卡试玩

python tools/playtest.py 对每关做两件事:一是只用正确操作通关,验证能不能零失误过;二是随机乱点若干次,统计通关率,用来衡量难度。

关卡 尺寸 箭头数 初始被挡 失误预算 可零失误通关 随机点击通关率
第 1 关 · 入门 3×3 5 3 3 33%
第 2 关 · 交错 4×4 7 5 4 25%
第 3 关 · 层层设卡 5×5 10 6 5 16%
第 4 关 · 回环 5×5 10 6 5 21%
第 5 关 · 迷宫 6×6 12 7 6 16%

五关都能零失误通关,随机乱点的通关率整体往下走,难度是递增的。

附加功能

功能 验证点 结果
撤销 恢复被消除的箭头和失误次数 通过
撤销 次数用完后无法继续撤销 通过
提示 提示的箭头一定是可消除的 通过
提示 次数用尽后失效,高亮会超时消失 通过
星级 0 失误 3 星、1 失误 2 星、其余 1 星 通过
计时 用时随帧累加 通过
存档 写入后能完整读回 通过
存档 同星级保留更快的时间 通过
存档 文件损坏时安全回退,不崩溃 通过
随机关卡 30 个随机种子生成的关卡全部可解 通过
随机关卡 求解器能识别出无解布局 通过
选关解锁 未解锁关卡无法开始,已解锁可开始 通过
通关结算 通关后记录星级并解锁下一关 通过
音效 无声卡环境下播放不报错 通过

汇总

一共 56 项测试,全部通过。

$ python -m pytest -q
........................................................                 [100%]
56 passed in 0.65s

除了无头测试,我也用真实的显示窗口跑过:窗口正常创建,帧率稳定在 60 FPS,中文字体、棋盘、箭头显示正常,鼠标点击、撤销/提示/重新开始/音效按钮、选关界面、星级结算都能用。

另外我把仓库重新克隆到一个干净目录,按 README 的步骤建环境、装依赖、跑测试、启动游戏,走了一遍确认别人拿到这个仓库能跑起来。

PSP 表格

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

差异比较大的两项是关卡设计和测试修改,都在下面说。

心得体会

AI 帮了什么

省时间的地方主要是样板代码和查错。界面绘制、状态机、动画这些我不太想手写的部分,AI 给得很快,改改就能用。查错也快,比如字体崩溃那个 TypeError,错误信息指向 pygame 内部,我一开始不知道从哪下手,AI 直接说是字体枚举读到了注册表异常值。

另外有些思路是我自己想不到的。反向构造法就是 AI 提的。我原来的想法是随机摆布局然后用求解器筛掉无解的,效率低还容易筛不出来。反向构造从根上保证有解,这个思路我没想到。

它还帮我做了几件我本来要花不少时间的事:写测试用例、解释报错、给界面问题出方案。

以下是我与Zcode的交互界面:
image

出了问题的地方

AI 第一版给的代码不一定对。 路径检测那个越界问题就是典型。代码能跑,常规情况也没事,只在边缘出问题,而且 Python 还不报错。如果我不追问、不专门测边界,这个 bug 就留在那了。这让我意识到,AI 给的东西不能因为「跑起来了」就当成对的。

我自己的关卡也是错的。 第 5 关是我自己摆的,一验证发现无解。人工摆关卡很容易摆出闭环死局,肉眼看不出来。这件事让我明白为什么要写自动化校验——不是形式,是真的能挡住错误。

测试本身也会错。 校验器那个循环上限的 bug 是测试抓出来的;但反过来说,我一开始也写错过测试用例(有个测试断言「贴着右边界的箭头会被左边箭头挡住」,其实它右边已经是棋盘外,本来就该飞出去)。写测试的时候脑子也得清楚。

AI 不知道界面好不好看。 结果界面文字压到按钮上,AI 是在我截图给它看之后才发现的。界面这种东西得自己看、自己判断,没法全靠 AI。

AI 说的和实际跑出来的可能不一样。 每次改完我都会实际跑一遍,或者截图确认。尤其是「功能实现了」和「功能好使」是两回事,中间差着一个验证。

收获

这次做下来,最大的收获不是学会了 pygame,而是对怎么用 AI 写代码有了点感觉

我现在的做法是:让 AI 写,但关键的地方要自己看懂、自己验证。路径检测怎么判边界、关卡为什么有解、测试到底测了什么,这些我得自己能讲清楚。作业里说助教可能针对关键代码提问,我觉得这个要求挺合理的——代码可以 AI 写,但责任在我自己。

还有一个具体的收获是把正确性写成测试。人工试玩能发现的问题有限,尤其是边界情况和随机情况。T03 那种边缘越界,人工玩的时候不太会特意去点四个角试试;随机关卡无解,随机生成几十次也不一定碰得上。写成测试之后,每次改动都能自动跑一遍,心里有底。

差异方面,关卡设计超了 1 小时,主要花在第一版布局无解、改反向构造、又发现初始没阻挡这几轮返工上;测试与修改超了 1 小时,因为补充功能和修 bug 之后测试要跟着加。Python 与图形库学习省了 1 小时,因为 pygame 以前打数模比赛的时候用过一些。

最后附上仓库地址:https://github.com/daxuemangongdao/myhomework!
(为了方便大家查看,在文章开头也附上了仓库地址ヘ( ̄ω ̄ヘ))
image

感谢大家读到这里,祝生活愉快 (๑•ᴗ•๑)♡

posted @ 2026-09-14 16:54  lsqqchy  阅读(23)  评论(0)    收藏  举报