软工第二次作业

项目 内容
这个作业属于哪个课程 https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering
这个作业要求在哪里 https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16717
这个作业的目标 使用 Python 和 AIGC 完成“一箭又一箭”小游戏
学号 102401527
GitHub 仓库 https://github.com/wu873/-

一、项目展示

游戏共三个界面,全部截图由项目自带的 tools/make_screenshots.py 自动生成(该脚本用 SDL 的无窗口模式批量出图,不需要手动一张张截)。

1. 开始界面
image

标题、玩法说明卡片、"开始游戏"和"退出游戏"两个按钮,背景有几个缓慢飘动的箭头作装饰。

2. 游戏界面(进行中)
image

这是第 2 关进行到一半的样子:左侧是棋盘,右侧面板显示当前关卡、剩余箭头(9/12)、剩余失误(3 颗爱心)、用时,以及"重新开始""返回主菜单"两个按钮。截图里鼠标正悬停在 (4,1) 的箭头上,能看到白色高亮框。

3. 碰撞反馈

image

点击被挡住的箭头时的瞬间:箭头变白、底下格子闪红、箭头沿行进方向来回抖动,并飘出"被挡住了!"的提示,同时剩余失误从 3 变成 2(一颗爱心变灰)。

4. 通关界面
image

5. 失败界面

image

结算界面会把刚才的棋盘压在底下显示,并给出用时和失误次数。通关且还有下一关时,主按钮是"下一关";失败时主按钮是"重新开始本关"。


二、项目介绍

游戏规则

棋盘上有若干带方向的箭头(上、下、左、右四种)。玩家点击箭头后,程序检查它前进方向上的路径:

  • 如果箭头与棋盘边界之间没有其它箭头阻挡,该箭头飞出棋盘并被消除;
  • 如果路径上存在其它箭头,该箭头不能消除,会抖动、变色并飘字提示,同时消耗一次失误机会;
  • 清除本关全部箭头即通关,进入下一关;
  • 失误次数(3 次)耗尽则本关失败,可以重新开始。

界面设计

整个窗口 960×700,分成三块:

  • 顶部信息栏:左侧是游戏名,右侧显示"第 2 关 / 共 3 关";
  • 左侧棋盘区:520×520 的深色面板,网格在面板里居中显示(所以 6×6、7×7、8×8 的关卡都能用同一套布局),网格交点画了点阵,和参考的小游戏风格一致;
  • 右侧信息面板:关卡、剩余箭头、剩余失误、用时四项信息,加两个按钮。

四个方向各用一种固定的颜色(上=蓝、下=橙、左=紫、右=绿),玩家一眼就能看出箭头的朝向,不用去分辨细小的三角尖。

主要功能与特色

功能 说明
三个界面 开始 / 游戏 / 结算,通过 App.switch_to() 切换
完整操作 鼠标点击飞出或碰撞,悬停高亮,R 键重开,ESC 返回主菜单
三种动画 飞出(带拖尾残影)、碰撞抖动、飘字提示
失误可视化 爱心显示,用掉一颗变灰
用时统计 通关结算时显示本关用时
3 个关卡 6 / 12 / 15 个箭头,难度递进,全部经程序验证可通关
开发工具 关卡求解器、自动化测试、批量截图脚本

界面没有使用任何第三方美术素材——箭头、爱心、按钮全部用 Pygame 的图形接口画出来,字体调用系统自带的微软雅黑。所以仓库里不需要附带图片、音频等资源文件。


三、实现思路

整个程序拆成 6 个模块,各管一件事:

文件 职责
main.py 程序入口:窗口、主循环、界面切换
scenes.py 三个界面的布局与交互
game_logic.py 游戏规则:路径检测、飞出、碰撞、失误、胜负
ui.py 界面零件:中文字体、按钮、箭头图形、爱心
config.py 尺寸、布局、配色等参数集中配置
levels.py 关卡数据

这样拆的好处是:规则逻辑完全不碰"怎么画",改配色只要动 config.py,加关卡只要动 levels.py。

3.1 箭头和方向怎么表示

方向不用数字枚举,直接用字符串 "up" / "down" / "left" / "right",配上一次就能记住的单位向量表:

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

行号往下增大,所以"向上"是行号减 1。有了这张表,"往某个方向走一格"就统一写成 row + d_row, col + d_col,四个方向不用写四份代码。

棋盘本身用二维列表 grid[row][col],格子里放 Arrow 对象或 None。Arrow 只存三个字段:行、列、方向。

3.2 关卡怎么表示

关卡没有用坐标数组,而是用"字符网格",一行一个字符串:

{
    "name": "第 1 关",
    "hearts": 3,
    "grid": [
        "D....L",
        "......",
        "....UR",
        "......",
        "...U..",
        "..L...",
    ],
},

U/D/L/R 分别代表四个方向的箭头,. 是空格子。这么做的好处是,源码里直接就能"看到"关卡长什么样,调整关卡不用算坐标;读取时由 parse_level() 一次性转成 (行, 列, 方向) 列表。

3.3 路径检测(核心)

这是整个游戏最关键的一段。因为基础版是单格箭头,判定条件是"同方向到边界之间没有其它箭头",所以只需要从箭头所在格出发,沿着它的方向一格一格往外走:

def path_is_clear(self, arrow):
    d_row, d_col = DIRECTION_VECTORS[arrow.direction]
    row, col = arrow.row + d_row, arrow.col + d_col
    while 0 <= row < self.rows and 0 <= col < self.cols:
        if self.grid[row][col] is not None:
            return False
        row += d_row
        col += d_col
    return True

两个要点:

  1. 循环条件 0 <= row < self.rows and 0 <= col < self.cols 同时承担了"边界处理"。箭头走到棋盘外时循环自然结束,返回 True,就表示"一路畅通,可以飞出"。这样写就不存在数组越界的可能——四个方向共用同一段代码,不需要像分方向写那样额外判断边界。这也是我在测试 T03 里专门验证的:把箭头放在最边缘并朝棋盘外,点击后可以正常飞出。
  2. 不需要遍历整行整列,走到第一个非空格子就可以立刻返回 False,比"先取出整行再判断"更直接。

判定结果只有两种,分别对应两条处理路径(Board.click()):

  • 能飞出:立刻把该格子置为 None(这样后续其它箭头的路径判定会马上认为这条路通了),再生成一段飞出动画;如果此时棋盘上已经没有箭头,就把状态设为通关。
  • 被挡住:生成抖动动画和飘字提示,mistakes_left 减 1;减到 0 就把状态设为失败。

有个细节值得一提:箭头是"先消失、后播动画"。数据上立刻移除,视觉上用一个独立的动画对象继续从原位置飞出去。这样即使玩家连点好几个箭头,规则判定也始终和玩家看到的棋盘一致,不会出现"箭头已经飞走了却还挡着路"的情况。

飞出距离是根据箭头位置现算的——从当前格中心一直算到完全离开棋盘面板,所以不管箭头在哪一格,看起来都是飞出去而不是原地消失。

3.4 坐标换算

鼠标点击的是像素坐标,棋盘用的是行列坐标,中间靠 Board.cell_at() 换算:

col = (pos[0] - ox) // C.CELL_SIZE
row = (pos[1] - oy) // C.CELL_SIZE

(ox, oy) 是网格在棋盘面板里居中后的左上角。换算完再检查一下行列是否越界,越界就返回 None,界面层拿到 None 就知道这一下点在棋盘外了。

3.5 关卡可解性验证(踩坑后加的)

这是本次开发中最重要的一个教训:两个箭头如果互相指着对方,这一关就永远不可能通关。比如下面这种情况,左边的箭头朝右、右边的箭头朝左,中间是空的,谁都想往对方那边飞,结果谁都飞不出去:

→  ·  ←

我最初设计的三关竟然都有这种死局:第 1 关的 (0,0)↓ 和 (2,0)↑、第 2 关的 (2,5)↓ 和 (4,5)↑、第 3 关的 (2,0)↓ 和 (7,0)↑,全都是隔着空格互相指着对方。前两关是手工推演规则时发现的,第 3 关则是在排查时写了个求解器才查出来——因为关卡数据"看起来"都很合理,光靠肉眼很难发现,所以我写了 tools/check_levels.py:每一步找出当前所有能飞出的箭头,挨个尝试,用深度优先搜索加记忆化找出一个通关顺序;如果搜遍所有可能都搜不到,就说明这一关无解。

def search(remaining):
    if not remaining:
        return []
    if remaining in memo:
        return memo[remaining]
    memo[remaining] = None                    # 先占位,避免重复搜索
    for cell in free_arrows(remaining, ...):
        rest = search(remaining - {cell})
        if rest is not None:
            memo[remaining] = [cell] + rest
            return memo[remaining]
    return None

求解器报出"无解"之后,我把死局的两个箭头之一改掉,重新验证,三关才全部通过。现在这个脚本还能打印出每一关的一组通关顺序,试玩的时候可以照着走。

3.6 中文字体

Pygame 的默认字体不含汉字,直接渲染中文会显示成一串方框。所以 ui.py 里做了一层字体查找:先按优先级在系统字体目录里找微软雅黑、黑体等字体文件,找不到再用 pygame.font.match_font() 按字体名匹配,仍找不到才退回默认字体。字体对象按字号缓存,只在第一次用到时创建。


四、AIGC 使用过程

本次开发使用的是 Claude Code(命令行 Coding Agent)【如果实际用的是别的工具/模型,请改成你自己的情况】。下面 4 次记录都来自真实开发过程。

子任务 借助何种 AIGC 技术 AI 实现或提供了什么 效果如何 人工修改
游戏界面与基础功能 Claude Code 按"开始界面 + 游戏界面 + 结算界面"的要求,生成了整套 Pygame 界面代码:窗口与三栏布局、按钮控件、中文字体自动查找、箭头图形(只画一个朝右的箭头,再用旋转得到另外三个方向并加缓存)、爱心失误显示,以及飞出、抖动、飘字三种动画 代码可直接运行,界面基本达到预期 渲染出截图逐张检查后,发现两处文字遮挡:底部状态提示和右侧的快捷键说明叠在了一起;碰撞飘字正好压在箭头格子上。各自调整了绘制坐标后正常
关卡设计 Claude Code 生成了 3 关的字符网格关卡数据 关卡在源码里可读性很好,但三关全部是死局:每关都出现了互相指着对方的一对箭头(如第 1 关 (0,0)↓ 与 (2,0)↑、第 3 关 (2,0)↓ 与 (7,0)↑)。这种箭头谁也飞不出去,关卡不可能通关,但表面上看布局完全正常 手工推演规则时先发现前两关的问题并调整;为防遗漏又写了 tools/check_levels.py 用程序验证可解性,脚本随即报出第 3 关"无解"。据此继续调整:第 1 关把 ↑ 从 (2,0) 挪到 (2,4)、第 2 关把 (4,5) 的 ↑ 改成 ←、第 3 关把 (2,0) 由 ↓ 改成 →。改完重新验证,三关均报告可以通关
自动化测试 Claude Code 按作业要求的 T01~T06 编写测试脚本,用构造鼠标事件的方式驱动真实的界面代码(而不是绕开界面直接改数据) 首次运行 6 项里 5 项通过,T05(失误耗尽后显示失败)未通过 排查后确认不是游戏的问题:游戏会在最后一次碰撞动画播完(约 0.85 秒)之后,再等 0.55 秒才切换到结算界面,而测试只推进了 1.2 秒。把测试的等待时间改成 2 秒后通过。另外补了一项 T07,专门测"重新开始"按钮本身
开发环境 Claude Code 安装 Pygame 时报错,AI 判断是当前 Python 3.14 没有官方 Pygame 的预编译包、退化成源码编译又拉不到依赖,建议改用 pygame-ce 并给出国内镜像的安装命令 一次安装成功(pygame-ce 2.5.8),import pygame 的用法与官方版完全一致 确认不需要改动任何业务代码,并把这个差异写进 README 的安装说明

几点感受:

  • AI 写"界面长什么样"这类代码又快又稳,但布局对不对,只有真的渲染出来看一眼才知道。前两条记录里的问题都不是语法错误、程序也能正常跑,纯粹是画面上压住了字。
  • 关卡数据是这次最值得警惕的地方。AI 生成的关卡"看起来很合理",但规则上的死局从表面上根本看不出来。最后的解决办法是写程序去验证程序——这件事本身很适合交给 AI 做,但"要验证"这个判断得由人来做。
  • 第三条记录里,AI 写的测试一开始没通过,而失败的原因在测试自己身上。第一反应容易以为是游戏有 bug,实际排查下来是测试等待时间不够。这种"谁错了"的判断,是需要自己过一遍代码才能下的。
  • AI 生成的代码里,有些地方我改成了自己更习惯的写法(比如把方向判定写成统一的向量表 + 一个循环,而不是四个方向各写一段)。

五、测试结果

测试脚本:tools/game_tests.py,运行 python tools/game_tests.py。测试通过构造鼠标事件调用真实界面代码,走的是"鼠标点击 → 界面派发 → 棋盘判定"的完整链路。

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 点击 (0,0) 后箭头数 6 → 5 通过
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 箭头数保持 6 不变,失误次数 3 → 2 通过
T03 点击边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 最右列朝右的箭头飞出,箭头数 6 → 5;另外单独测了四个方向的 1×1 边界,均正常 通过
T04 消除本关全部箭头 显示通关并进入下一关 三关分别用 6 / 12 / 15 步通关,且都自动跳转到结算界面 通过
T05 失误次数耗尽 显示失败并允许重新开始 连点 3 次被挡住的箭头后状态为 lost、剩余失误 0,并进入结算界面 通过
T06 游戏进行中重新开始 箭头布局和失误次数恢复 重开前(箭头 10,失误 2)→ 重开后(箭头 12,失误 3),用时归零 通过
T07 点击"重新开始"按钮(附加) 按钮也能重置本关 点按钮后箭头数恢复为 6,失误次数恢复为 3 通过

关卡可解性验证(python tools/check_levels.py):

第 1 关 (6 x 6,6 个箭头,失误上限 3)  初始能飞出的箭头:5 个  可以通关
第 2 关 (7 x 7,12 个箭头,失误上限 3) 初始能飞出的箭头:5 个  可以通关
第 3 关 (8 x 8,15 个箭头,失误上限 3) 初始能飞出的箭头:7 个  可以通关
全部 3 关都可以通关。

除自动化测试外,我还手工把三关都实际玩通关了一遍,确认每一关都有合理的通关顺序、难度递进正常。


六、PSP 表格

下表的"预估耗时"是参考值,请按你自己的实际情况调整;"实际耗时"必须填你真实投入的时间,差异 = 实际 − 预估。

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

七、心得体会

  1. AI 生成的代码"能跑"和"正确"是两回事:界面代码一次就跑通了,但截图一看就有文字重叠;关卡数据看起来没毛病,实际有两关是死局。
  2. "让程序验证程序"是这次最大的收获:与其盯着关卡数据看,不如写个求解器;与其手动试玩每一关,不如让脚本自动通关。
  3. AI 写的测试也会错:T05 失败时,先怀疑游戏、排查后才发现是测试等待时间不够。判断"到底谁错了"的能力比写代码本身更重要。
  4. 环境问题(Python 3.14 装不上官方 pygame)这类坑,AI 能快速给出替代方案,但验证方案是否真的可行仍然要靠自己跑一遍。
  5. 后续想做的改进:【例如:加音效、撤销上一步、随机生成可通关的关卡、打包成 exe】。
posted @ 2026-09-19 12:58  林复彬  阅读(14)  评论(0)    收藏  举报