软件工程第二次个人作业——一箭又一箭

用 AIGC 做一个「一箭又一箭」小游戏

作业信息

项目 内容
这个作业属于哪个课程 2026秋软件工程与软件工程实践
这个作业要求在哪里 软件工程课程第二次个人作业
这个作业的目标 使用 Python 和 AIGC 完成「一箭又一箭」小游戏
学号 102402142
GitHub 仓库 https://github.com/Yeee-tj/yet-arrow-away

一、项目展示

玩法演示

点箭头让它沿自己指的方向飞出去,路上没东西挡着就飞出棋盘、被消除。

fig01_游戏过程_完整玩法

上面这段能看到一个关键设计:棋盘上有个发光的表盘,点它是没用的——只有箭头飞出去的时候路线正好从它上面穿过,才能拿到那 5 秒。所以要先清掉挡路的箭头,再把合适的箭头留到正确的时机放飞。

点到被挡住的箭头时,箭头会晃动变红,并扣掉一颗爱心:

fig02_游戏过程_碰撞反馈

清空棋盘后弹出结算表情图:

fig03_游戏过程_通关结算

界面

fig04_开始界面

fig05_失败界面


二、项目介绍

游戏规则

棋盘上散布着朝上、下、左、右四个方向的箭头。点中一个箭头,程序沿着它指的方向一路检查到棋盘边界:

  • 路上一个箭头都没有 → 箭头飞出棋盘,被消除;
  • 路上碰到别的箭头 → 不能消除,扣掉一次爱心,箭头晃动变红给出反馈。

清空当前关卡的全部箭头即过关,进入下一关;爱心用完或者倒计时归零则失败,可以重新开始。

三个关卡

关卡 棋盘 箭头数 爱心 时间限制 时间道具 开局可飞
第 1 关 · 牛刀小试 6 × 6 9 2 10 秒 1 个(+5 秒) 4 个
第 2 关 · 渐入佳境 7 × 7 14 2 15 秒 1 个(+5 秒) 4 个
第 3 关 · 步步为营 8 × 8 20 2 20 秒 1 个(+5 秒) 5 个

fig06_第三关棋盘

界面设计

信息全部集中在顶部一条栏里,分三块:

  • 左边:关卡名 + 关卡进度、剩余箭头数和步数、用爱心表示的剩余机会(亮红实心是还剩的,暗色空心是已经失去的);
  • 右边:倒计时进度条,颜色随时间从绿变黄再到红,最后 10 秒会闪。

底部是「重新开始」和「提示」两个按钮,加一行操作提示。

三个我觉得值得一提的设计

1. 时间给得很紧,逼你先看再点。 第 1 关 9 个箭头只给 10 秒,平均一个箭头一秒多。这不够"点一下想一下",只能先把棋盘扫一遍、在脑子里排个序再动手。道具就是这根救命稻草——+5 秒在一个 10 秒的关卡里等于多了一半时间。

2. 爱心只给 2 颗。 这个数字调过:给 3 颗以上容错太足,可以乱点碰运气;只给 1 颗又太苛刻,一次手滑就要重来。2 颗刚好是"允许你犯点小错,但不能一直瞎试"。

3. 提示功能不是随便找一个箭头高亮的。 按 H 会调用求解器,逐个试算,只推荐「点了之后局面仍然可解」的走法,不会把你带进死路。

fig07_提示功能


三、实现思路

3.1 箭头和方向怎么表示

方向用四个字符常量 U D L R,每个方向对应一组「行增量、列增量」:

DELTA = {
    "U": (-1, 0),   # 行索引向下增长,所以「上」是 -1
    "D": (1, 0),
    "L": (0, -1),
    "R": (0, 1),
}

这里有个容易搞反的地方:屏幕坐标系里 y 轴是向下的,所以第 0 行在最上面,"向上"是行号减一。我一开始把 UP 写成 (1, 0),结果所有箭头方向都反了。

棋盘用字典存箭头:键是格子坐标 (行, 列),值是方向。空格子不入表,所以"这个位置有没有箭头"就是判断键在不在字典里,查询很快,也天然表达了"棋盘上大部分是空地"这件事。

3.2 路径检测(核心)

这是整个程序最关键的一段。做法是从箭头的下一个格子开始,沿它指的方向一格一格往外走:

def check_path(self, r, c):
    direction = self.arrows[(r, c)]
    dr, dc = DELTA[direction]
    nr, nc = r + dr, c + dc          # 从下一格开始看

    while self.in_bounds(nr, nc):    # 只要还在棋盘内就继续走
        if self.has_arrow(nr, nc):   # 碰到箭头 -> 被挡住
            return False, (nr, nc)
        nr += dr
        nc += dc

    return True, None                # 走出棋盘了 -> 可以飞出

这段代码最关键的地方是「越界」和「被挡住」必须分开处理。

朝棋盘外的箭头,行或列的索引会算到 -1、或者超出棋盘尺寸。如果写成"先访问这个格子、再判断合不合法",就很容易在某一行抛 IndexError。而这里把 in_bounds() 放在 while 条件上,索引一旦越界循环就自然结束,直接走到最后的 return True, None——从结构上避免了越界访问,不需要任何特判

fig08_路径检测示意

上图左边是前方畅通的情况(绿色虚线一路查到边界),右边是被挡住的情况(红色虚线在碰到箭头时停住)。两种情况的判断共用同一段循环,区别只是循环是"走完了"还是"提前 return"。

3.3 飞行路线:时间道具怎么拿到

除了"能不能飞出去",还需要知道一个箭头会经过哪些格子——这是时间道具的判定依据。它和路径检测共用同一套走法,但不检查有没有被挡住,而是一路把经过的格子收进列表:

def path_cells(self, r, c):
    direction = self.arrows[(r, c)]
    dr, dc = DELTA[direction]
    nr, nc = r + dr, c + dc
    cells = []
    while self.in_bounds(nr, nc):    # 走到边界就停
        cells.append((nr, nc))       # 不管路上有没有东西,先记下来
        nr += dr
        nc += dc
    return cells

这里有个顺序陷阱:必须在箭头从棋盘移除之前调用它。 path_cells 的第一步是确认这个格子有箭头,一旦箭头被 remove 掉,方法直接返回空列表,这趟飞行的路线就彻底查不到了——结果就是道具永远吃不到。所以游戏里的顺序被固定为:

got = self._collect_events_on_path(r, c)   # 先算路线(此时箭头还在)
self.board.remove(r, c)                   # 再移除
self.flying.append({...})                 # 最后才是视觉动画

这个约束很容易被后来的改动破坏(看起来两个操作换个位置没差别),所以我专门写了一条测试——「箭头移除后飞行路线变空」——把它锁住,改坏了会立刻报错。

3.4 关卡怎么设计,怎么保证一定可解

关卡用字符网格写,直观好改:

"grid": [
    ". D . D L .",
    "R . U . . .",
    ...
]

. 是空格子,U/D/L/R 是四个方向的箭头。

这三个关卡不是手工摆的,而是由 level_gen.py 随机生成、再按条件筛选,生成时需要同时满足四条:

  1. 四个方向都要出现,避免玩法单调;
  2. 同一行或同一列上,相邻位置连续同方向的箭头不超过 2 个
  3. 开局能直接飞走的箭头不能太多;
  4. 必须能被 solver.py 验证「确实可以通关」。

第 2 条和第 4 条都是踩坑之后加的,下面在 AIGC 使用过程里详细说。

还有一条铁律:同一行或同一列不能出现两个箭头「面对面」互相指着对方。比如一行写成 R . . . L,左边的想往右飞被右边挡住,右边的想往左飞被左边挡住,两个箭头谁都动不了,整关直接无解。这个错误肉眼看不出来,只能靠求解器查。

3.5 求解器怎么工作

求解器用的是深度优先搜索 + 记忆化:每一轮先找出当前所有能飞出去的箭头,任选一个让它飞出,在新局面上重复;直到清空(成功),或者一个能飞的都没有(死局,退回换一种点法)。同样的局面会被反复遇到,所以用一个字典把算过的局面记下来,避免重复搜索。

它有两个用途:验证关卡可解,以及给游戏提供提示功能

3.6 代码怎么分层

项目分成两层,这个划分对后面帮助很大:

代码/
├── arrows.py      # 纯逻辑:棋盘结构 + 路径检测 + 飞行路线
├── levels.py      # 3 个关卡的数据
├── solver.py      # 求解器:验证可解 + 提供提示
├── level_gen.py   # 关卡生成器
├── game.py        # 界面层:绘制、交互、动画、流程
└── test_core.py / test_game.py   # 自动测试

arrows.pylevels.pysolver.py 完全不依赖 pygame。好处有两个:一是路径判断、关卡可解性都能写成自动化测试,不用手点鼠标;二是界面层不重复实现任何游戏规则,所有判定都调用逻辑层,不会出现"两处规则不一致"的问题。

3.7 顺带一提:爱心是算出来的

爱心不是用图片素材,也不是用字体里的 字符(这个字符在有些字体里渲染出来是方块),而是用经典的心形参数方程算出来的:

x = 16·sin³t
y = 13·cos t − 5·cos 2t − 2·cos 3t − cos 4t

采样 60 个点连成多边形,坐标归一化到统一范围,所以换任何尺寸都不用改数字。整个游戏的图形元素(箭头、棋盘、按钮、爱心、道具图标)全部是 pygame 的绘图 API 程序化绘制的,没有依赖任何外部图片素材。


四、AIGC 使用过程

我用的是 CodeBuddy(Agent 模式),整个开发过程都是和它协作完成的。下面是七次比较有代表性的记录。

# 任务 AI 提供了什么 实际效果 我做了什么
1 核心路径检测 check_pathin_bounds() 作循环条件,避免越界特判 四个方向判断全部正确,边界不报错 逐行理解「越界」和「被挡住」的区别,补了中文注释
2 关卡设计(AI 出错) 生成了第 2、3 关的布局 ❌ 第 2 关无解:出现 R . . . L 面对面互相挡死 用求解器定位后改成 D . . . L;从此立规:每关必须过求解器
3 界面太"呆"(设计失误) 为求"有序"用了整排同方向箭头当主链 ❌ 玩起来从一头点到另一头,纯机械劳动 我试玩后反馈"显得很呆",AI 加了"连续同方向 ≤ 2"的约束并写成测试
4 爱心 / 倒计时 / 结算图 心形参数方程、时间条渐变、easeOutBack 弹入动画 直观、不需要图片素材 调整了颜色和版面;反馈"爱心给太多",改成每关 2 颗
5 道具机制重做 新增 path_cells 算飞行路线,道具从"点击获取"改成"飞过获取" 道具从白送变成了需要规划路线 要求"每关只放一个 +5 秒",并核对出"先收集再移除"的顺序约束,补测试锁死
6 打包 exe 体积优化 PyInstaller 打包;排查出 176 MB 是 numpy 拖来的 Intel MKL,排除后压到 14 MB 微信可发送,对方双击即玩 要求用打好的 exe 跑自检,确认表情图和中文都正常
7 Git 推送排障 定位代理配错(环境变量指向 2375)和引用写入异常 成功推送到 GitHub 确认自己**的真实端口

三次详细说明

第 2 次:AI 生成的关卡居然是死局。

我当时的要求是"第 2、3 关弄得稍微难一点"。AI 很快就给出了两关的布局,看起来也挺像样。但我在写测试的时候顺手跑了一遍求解器,结果第 2 关直接报"无解"。

排查之后发现是第 2 行写成了 R . . . L——左边朝右、右边朝左,同一行面对面。左边的箭头想往右飞被右边的挡住,右边的想往左飞被左边的挡住,两个箭头互相锁死,谁都动不了。而且这个错误光看字符网格是看不出来的,我盯着看了半天都没发现问题。

改成 D . . . L 之后就通了。这件事给我留下的规矩是:每个关卡生成完,必须跑一遍求解器验证,不能凭肉眼觉得"看起来没问题"就收工。

第 3 次:做出来的关卡"看起来很整齐,玩起来很呆"。

第一版的关卡里,我为了让通关顺序明确,让 AI 摆了一整排同方向的箭头当"主链"。顺序确实唯一了,但也意味着玩家只要从最右边一路点过去就行,完全不需要思考和判断

我自己试玩之后觉得不对劲,反馈说"几个同方向箭头聚集在一起可以连续点掉,显得很呆"。AI 分析后承认这是设计失误——为了追求"顺序唯一"牺牲了"每一步都要重新判断"。修正办法是把"顺序唯一"换成"方向交错":限制同一行/列上相邻连续同方向的箭头不超过 2 个。现在的关卡方向都是混着来的,每点一步都要重新看哪个箭头还有出路。

为了防止以后改关卡又改回去,这条约束被写成了自动测试。

第 6 次:打包出来的 exe 有 176 MB。

我想要一个能直接发给同学玩的版本,不用对方装 Python。AI 用 PyInstaller 打包很顺利,但打出来一看——176 MB,微信都发不出去(限制 100 MB)。

排查发现是 Anaconda 环境里的 numpy 连带了 Intel 的数学库 MKL,光那些 DLL 就有几十 MB,被整个拖进了包里。而这个游戏的图形全是 pygame 画的,一个 numpy 都不需要。加了一批 --exclude-module 之后,体积从 176 MB 掉到 14.3 MB

这件事让我第一次意识到:"程序能跑"和"程序能发给别人跑"之间还有很长的路——资源路径、中文字体、崩溃日志、打包体积,每一项都是独立的问题。


五、测试结果

手工测试(作业要求的 T01–T06)

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 箭头沿方向飞出并消除,有飞出动画
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 箭头晃动变红,爱心熄灭一颗,顶部提示被哪个格子挡住
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 正常飞出,无异常报错
T04 清除本关全部箭头 显示通关并进入下一关 弹出通关表情图和步数统计,点「下一关」进入下一关
T05 失误次数耗尽 显示失败并允许重新开始 弹出失败表情图,点「再来一次」重开本关
T06 游戏进行中重新开始 箭头布局和失误次数恢复 棋盘、爱心、倒计时、道具全部恢复初始状态

自动化测试

手工测试之外,我把上面这些也写成了自动测试,总共 70 个用例

  • test_core.py42 个用例,覆盖 T01–T03 以及关卡质量检查;
  • test_game.py28 个用例,覆盖 T04–T06 以及界面流程。

其中 T01–T03 的覆盖方式是把四个方向各测一遍,而不是只测一个方向就认为对了。比如 T03 专门测了「四个角的箭头朝四个方向」,因为角落是最容易出现索引越界的地方。

界面部分也能自动测,靠的是一个技巧:给 SDL 设置虚拟显示驱动后,pygame 会在"虚拟屏幕"上绘制,不需要真的弹窗口,于是渲染代码也能被自动跑到,任何绘制时的报错都会当场暴露。

os.environ["SDL_VIDEODRIVER"] = "dummy"   # 必须在 import pygame 之前设置

测试中真的抓到的问题

自动化测试不是摆设,它抓到了几个我自己看不出来的问题:

  1. 第 2 关原本无法通关——就是前面说的面对面死锁,靠求解器发现;
  2. 游戏启动后直接进第 1 关,开始界面不显示——初始化时误把状态设成了"游戏中",靠流程测试发现;
  3. 事件道具可以重复点击无限刷奖励——发奖励时没有先判断是否已触发,补了防护和测试。

另外还有几个是看截图看出来的:顶部信息栏文字重叠、底部提示被窗口边缘切掉、棋盘没有真正居中(算棋盘宽度时把"格子间距"也按格子数量算了,实际应该少一个)。


六、PSP 表格

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

几点说明:

  • 「AIGC 辅助开发」这一项没有单独计时,因为它和别的阶段是完全重叠的——写路径检测、设计关卡、改 bug、写测试,每一步都是和 AI 一起做的,硬把它拆出来单独计时没有意义。
  • 「关卡设计」超时最多(+1.5 小时),因为改了两轮:先是 AI 生成的关卡无解要重做,后来又是"太呆"要重新设计约束。
  • 「Python 与图形库学习」比预估少,因为 pygame 的基本用法 AI 讲得很清楚,我没花太多时间翻文档,主要时间花在理解坐标约定(比如 y 轴向下)这些容易搞反的细节上。

七、心得体会

AI 确实大幅降低了"从想法到能跑起来"的门槛,但它给的东西必须验证。

这次最大的感受是:AI 出稿很快,但它不一定对,而且错得很难用肉眼发现。第 2 关那个面对面死锁就是典型例子——布局看起来很整齐,AI 也说得头头是道,但整关其实是死的。如果不是我顺手跑了求解器,交上去就是一个"无法通关"的关卡,而评分表里专门有一条扣分项就是这个。
所以后来我养成了一个习惯:AI 产出的东西,尽量找一个能自动验证的办法。关卡就写求解器,路径判断就写单元测试,界面就导出截图自己看一遍。这次一共写了 70 个测试用例,抓出来的问题里有一半是我自己看不出来的。

"AI 一次写对"是错觉,真实的协作是来回改。

刚开始我会期待"描述清楚需求,AI 就给出完美结果"。但实际过程更像是我说一个方向、它出个初稿、我试玩/测试、发现问题、再反馈。
"同方向箭头聚成一排显得很呆"这个反馈就是我自己玩出来的,AI 一开始没意识到这个问题——因为它按"让顺序唯一"这个目标去做,逻辑上没错,但玩起来的手感只有人能判断。这类问题用户不反馈,AI 永远发现不了。

有些坑只有真做到那一步才会遇到。

比如打包 exe:代码写完、测试通过、GitHub 也传了,看起来这个项目就结束了。但真的打包发给同学,才发现有资源路径(打包后 __file__ 指向临时目录)、中文字体(对方电脑不一定有微软雅黑)、体积(176 MB 微信发不出去)这一连串问题。
这些问题在开发阶段完全暴露不出来,因为在自己机器上跑源码永远是对的。这段经历让我对"软件交付"这件事有了具体的认识——能跑只是第一步。

posted @ 2026-09-17 20:21  Adca  阅读(15)  评论(0)    收藏  举报