福州大学 202601 软件工程第二次个人作业

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

项目展示

主界面 游戏界面 成功界面 失败界面
LiteSnap-1790086920972 LiteSnap-1790087048445 LiteSnap-1790087068195 LiteSnap-1790087080149

项目介绍

游戏规则

“一箭又一箭”是一类点击式箭头解谜游戏:棋盘上摆着若干带方向的箭头,玩家按顺序点击箭头,让它们全部飞出棋盘

  1. 点中一支箭头后,程序检查它前进方向上的路径;
  2. 前方到棋盘边界之间没有其他箭头 → 它沿该方向飞出棋盘并消失;
  3. 前方有箭头阻挡 → 它飞不出去,并给出碰撞反馈(抖动、火花、挡路箭头高亮);
  4. 点击被阻挡的箭头会消耗一次失误机会(每关 3 次);
  5. 清空本关全部箭头 → 通关,可进入下一关(并记录本关用时);
  6. 失误次数耗尽 → 本关失败,可重试本关。

界面设计

界面为暗色界面,简洁至上。

画面 内容
开始界面 标题 / 副标题 / 方向箭头装饰 / 主按钮“开始游戏”/ 页脚“设置”“关于”
关于界面 版本号与制作信息
设置界面 「背景音乐」「音效」两个滑动开关
游戏画面 整宽信息栏 + 棋盘;第 1 关额外挂一条教程提示条;右下角「辅助线」开关
结算浮层 遮罩 + 卡片:徽章 / 标题 / 一行正文 / 本关用时 / 两个按钮

图标由程序绘制,不依赖外部图标。

主要功能与特色

基础功能

  • 可正常显示与操作的界面;
  • 上 / 下 / 左 / 右四种方向的箭头;
  • 鼠标点击选择箭头,命中判定与绘制共用同一份几何;
  • 正确判断前方是否有阻挡;无阻挡则飞出并消失,有阻挡则给出明显反馈;
  • 失误次数显示与扣减、失败结算与重试;
  • 10 个可通关关卡、通关结算与关卡流转;
  • 进行中「重新开始」按钮 / R 键,恢复本关初始布局与失误次数。

额外特色

  • 交互式教程(第 1 关):提示条 + 箭头呼吸高亮,引导玩家学习游戏玩法,熟悉者也可直接跳过;
  • 辅助线:把路径检测的结果可视化,可随时用右下角开关切换;
  • 计时与最佳成绩
  • 自由缩放窗口
  • 抗锯齿贴图:圆角面板、箭头圆片、图标都在 4 倍画布上绘制后再缩小,边缘带过渡色;
  • 音乐与音效:使用无版权素材。背景音乐循环播放,按钮 / 飞出 / 撞墙 / 通关 / 失败各一声音效。可在设置界面分别开关。

实现思路

箭头与方向的表示

方向用一个枚举表示,枚举值同时就是关卡字符,因此“有哪些方向”与“关卡里能写哪些字符”是同一份声明,不会各说各话:

class Direction(Enum):
    UP = "^"
    RIGHT = ">"
    DOWN = "v"
    LEFT = "<"

    @property
    def delta(self) -> tuple[int, int]:
        """网格中的行列增量 (d_row, d_col)。"""
        return _DELTAS[self]          # UP=(-1,0) RIGHT=(0,1) DOWN=(1,0) LEFT=(0,-1)

    @property
    def angle(self) -> int:
        """绘制时相对“向上”箭头的顺时针旋转角度。"""
        return _ANGLES[self]          # 0 / 90 / 180 / 270

    @property
    def vector(self) -> tuple[float, float]:
        """屏幕像素坐标下的单位向量 (dx, dy),供动画位移使用。"""
        delta_row, delta_col = self.delta
        return (float(delta_col), float(delta_row))

同一件事有「网格视角」(delta) 与「屏幕视角」(vector) 两种表示,看起来重复,实则必要:delta 用行列增量做路径检测(纯整数、与像素无关),vector 用于计算飞出动画的像素位移(屏幕 y 轴向下,因此「向下」= 行号增加 = dy 为正)。两种表示各自只服务于一个用途,不会出现「用屏幕向量去做网格判定」这种耦合。

单个箭头是一份不可变数据:

@dataclass(frozen=True, slots=True)
class Arrow:
    row: int
    col: int
    direction: Direction

棋盘内部就是一张二维网格,空格是 None

self._cells: list[list[Arrow | None]] = _parse_level(level)

这样做的好处是「路径检测」变成了极简单的事情:沿方向逐格前进,第一个非 None 的格子就是挡路者。

箭头主题色不存进 Arrow,而是由 palette.theme_color(row, col) 从格子位置推导((row * 3 + col) % 6 从六色调色板取色),因此一关之内颜色恒定、上下左右相邻的箭头必然不同色,重新开始本关也不会换色。

关卡的表示

关卡是纯字符串元组. 表示空格,^ v < > 表示箭头:

Level = tuple[str, ...]

TUTORIAL_LEVEL: Level = (
    ".^v.",
    "..<.",
    "...<",
    ">...",
)

优点:肉眼可读、能直接写进测试做断言、可以整关对照(关卡是数据,不是代码)。

第 1 关(教程关)手写,因为教程的三步引导既需要「前方畅通的箭头」也需要「被挡住的箭头」,而 ui.tutorial_panel_rect() 的位置又依赖「第 1 关是 4×4」这一布局假设。其余关卡不再手画,改为写一行规格

@dataclass(frozen=True, slots=True)
class LevelSpec:
    rows: int          # 棋盘行数
    cols: int          # 棋盘列数
    arrows: int        # 箭头数量
    min_blocked: int = 0   # 开局至少有多少支箭头被挡住(保证有解谜成分)
    seed: int = 0          # 随机种子:同规格 ⇒ 同一张关卡图
LEVELS = (TUTORIAL_LEVEL,) + tuple(
    generate_solvable_level(spec) for spec in LEVEL_SPECS
)

加一关 = 加一行规格,既不用手画网格,也不会画出“同一行两支箭头面对面互相瞄准”的死局。

路径检测

这是整个玩法的核心。因为只有四个轴向方向,不需要通用寻路算法:从箭头所在格子出发,沿 direction.delta 一格一格前进,直到走出棋盘(畅通)或遇到另一个箭头(被挡)。

def blocking_arrow(self, arrow: Arrow) -> Arrow | None:
    """返回第一个阻挡 arrow 的箭头,前方畅通时返回 None。"""
    delta_row, delta_col = arrow.direction.delta
    row = arrow.row + delta_row
    col = arrow.col + delta_col
    while 0 <= row < self.rows and 0 <= col < self.cols:
        occupant = self._cells[row][col]
        if occupant is not None:
            return occupant
        row += delta_row
        col += delta_col
    return None

它同时是三个功能的唯一判据:

  • is_path_clear(arrow)blocking_arrow(arrow) is None,即「这一箭点得动吗」;
  • 点击处理 handle_click() 按结果返回 ClickResult.CLEARED / BLOCKED / MISSSession 再据此扣失误或推进关卡;
  • 辅助线 guide_line(arrow) 用同一个 blocking_arrow() 决定虚线画到哪儿(畅通 → 棋盘边缘;被挡 → 挡路箭头圆片的外沿),因此画出来的结论永远和点下去的结果一致

单次检测的代价是“到边界或到阻挡者”的格数,最多 rows + cols - 1 步;14×14 的终极关卡上也就是 27 次比较,完全不必优化。

关卡生成与可通关性验证

前面说过,手工画关卡的一大麻烦是“画出来发现是死局”。所以生成侧用逆向构造:把格子打乱后依次摆放箭头,摆每一支时都要求它的前进方向上还没有箭头。

为什么这样一定能通关?把摆放顺序倒过来清即可:清第 k 支时,比它晚摆的箭头都已经清掉,比它早摆的按构造不在它的前进方向上,因此它的前方必然畅通。生成算法本身就是可通关性的构造性证明

验证侧是完全独立的第二道关——贪心求解

反复挑一支“当前前方畅通”的箭头清掉;
全部清完 → 可通关;中途卡住 → 死局。

它能当判定器用的理由是单调性:清掉箭头只会减少阻挡,任何一支现在能清的箭头以后也一定能清,所以「随便挑哪一支」都不会错过解。verify_level() 还会检查尺寸、箭头数、开局被挡数是否符合规格,generate_solvable_level() 最多重试 200 轮仍不合格就抛错,而不是交出坏关卡。测试里再把 10 关逐一过一遍这道门禁——内置关卡的可通关性是每次跑测试都会被重新证明一遍的事实

AIGC 使用

本项目全程使用 VSCode Copilot Agent + DeepSeek V4.1 Flash

子任务 AI 实现或提供了什么 效果如何 人工修改
Nuitka 打包 跑通本地打包流程 生成打包产物,可以运行
字体优化 字体引入和优化渲染 字体效果基本满意 提供字体文件
辅助线功能 箭头前进方向以虚线标注 辅助线默认开启,破坏游戏体验 要求作为可选功能提供

测试结果

总览

uv run pytest -q                 →  486 passed in 1.73s
uv run ruff check .              →  All checks passed!
uv run ruff format --check .     →  64 files already formatted
uv run ty check                  →  All checks passed!
测试文件 用例数 覆盖内容
test_game.py 82 事件分发、画面切换、缩放、音频接线、设置页、教程接线
test_ui.py 82 菜单页排版与产品规则、信息栏宽度账、结算浮层、缩放下的布局
test_generator.py 58 生成属性(8 种子 × 5 尺寸)、规格校验、求解器、与 Board 规则对齐
test_palette.py 52 主题色分配、状态混色、相邻不同色
test_board.py 49 路径检测、命中、动画、像素级配色、辅助线、格子留白、抗锯齿
test_resources.py 38 字体 / 音频素材定位与「声明 == 实物」、打包选项约束
test_icons.py 31 每个图标按 list(Icon) 参数化绘制
test_session.py 30 失误、失败、通关、流转、重开、计时、缩放保留进度
test_tutorial.py 28 教程状态机、演示不扣失误、提示条几何与文字宽度
test_sprites.py 15 超采样贴图、缓存、大画布降档
test_audio.py 11 载入、开关、无声卡降级、幂等播放
test_viewport.py 9 恒等 / 放大 / 居中留白 / 下限 / 量化 / 无裂缝
test_config.py 1 config.VERSIONpyproject.toml 一致

核心测试内容

编号 测试内容 预期结果 实际结果 是否通过 对应自动化测试
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 点击后该箭头立即从网格移除、ClickResult.CLEARED、飞出动画正常播放并在 0.32s 后结束 test_board.py::test_click_clears_arrow_when_path_is_clear
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 箭头留在原位、ClickResult.BLOCKED、碰撞提示启动、失误 3 → 2 test_board.py::test_blocked_arrow_stays_on_board_and_flashes
test_session.py::test_blocked_click_costs_one_mistake
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 循环条件「不出界」保证路径畅通,CLICKED → CLEARED,无 IndexError test_board.py::test_path_pointing_out_of_board_is_clear
test_fly_out_always_exits_the_board(四方向参数化)
T04 消除本关全部箭头 显示通关并进入下一关 清空后等待飞出动画与停顿,状态转 LEVEL_CLEARED 并弹出通关卡片;点击主按钮进入下一关(末关回到第 1 关) test_session.py::test_clearing_all_arrows_shows_the_result_and_advances
test_clearing_the_last_level_starts_a_new_round
T05 失误次数耗尽 显示失败并允许重新开始 第 3 次撞墙当帧状态转 FAILED,弹出失败卡片;点「重试本关」后箭头布局与失误次数恢复 test_session.py::test_running_out_of_mistakes_fails_the_level
test_retry_after_failure_restores_the_level
T06 游戏进行中重新开始 箭头布局和失误次数恢复 重新加载本关:箭头全部复位、失误回到 3、计时归零(最佳成绩保留) test_session.py::test_restart_restores_layout_and_mistakes
test_restart_resets_the_timer_but_keeps_the_best_time

PSP 表格

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

心得体会

AI 带来的帮助

  1. 将想法快速落地。功能增加,UI 优化,这些脑内非常美好的想法
  2. 大范围重构能一次做到底viewport 那次要改 ui / board / session / game 四个文件,涉及约 15 个几何函数与几十处长度常量换算。AI 给出了清晰的三条约定(布局常量按设计尺寸写绝对值 / 几何函数返回屏幕坐标 / 绘制时用 s() 换算),并让「窗口 = 设计尺寸」时视口退化为恒等变换。这是我在人工重构里很难保证的。
  3. 文档与上下文交接。不少开发者(包括我)都不喜欢写文档,随着开发的推进,缺乏文档沉淀会使得项目更难维护。AI 每轮对话都会积累一份文档,使得后续维护者可以比较快速地了解当前项目状态。

出现的问题

  1. 过度实现与多余产出。Agent 非常喜欢在 docstring, README 和用户界面写行为解释。虽然留档是好事,但问题是这些文字不合时宜地出现了:用户和开发者不想看权衡评价;那应该写到专门的文档里。另外一个就是滥用测试,大到核心功能,小到文档描述,Agent 都固执地测试一切,然而像文本描述这样的东西就完全没有测试的必要。
  2. 沉默的假设。当需求比较模糊或者未经过预先调查,AI 会挑一个解释往下做。若不及时发现,就会沿着错误方向走很远。后来我在先让它复述方案与前提,再确认一遍。

我的收获

与 AI 协作的核心能力是“验收”:会跑测试、会看图、会读 diff、会追问前提。AI 写代码很快,但定义“什么叫做对了”这件事仍然只能由人来做

posted @ 2026-09-22 23:35  Watermelonabc  阅读(8)  评论(0)    收藏  举报