福州大学 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 |
项目展示
| 主界面 | 游戏界面 | 成功界面 | 失败界面 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
项目介绍
游戏规则
“一箭又一箭”是一类点击式箭头解谜游戏:棋盘上摆着若干带方向的箭头,玩家按顺序点击箭头,让它们全部飞出棋盘。
- 点中一支箭头后,程序检查它前进方向上的路径;
- 前方到棋盘边界之间没有其他箭头 → 它沿该方向飞出棋盘并消失;
- 前方有箭头阻挡 → 它飞不出去,并给出碰撞反馈(抖动、火花、挡路箭头高亮);
- 点击被阻挡的箭头会消耗一次失误机会(每关 3 次);
- 清空本关全部箭头 → 通关,可进入下一关(并记录本关用时);
- 失误次数耗尽 → 本关失败,可重试本关。
界面设计
界面为暗色界面,简洁至上。
| 画面 | 内容 |
|---|---|
| 开始界面 | 标题 / 副标题 / 方向箭头装饰 / 主按钮“开始游戏”/ 页脚“设置”“关于” |
| 关于界面 | 版本号与制作信息 |
| 设置界面 | 「背景音乐」「音效」两个滑动开关 |
| 游戏画面 | 整宽信息栏 + 棋盘;第 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 / MISS,Session再据此扣失误或推进关卡; - 辅助线
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.VERSION 与 pyproject.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_flashestest_session.py::test_blocked_click_costs_one_mistake |
| T03 | 点击位于边缘且朝向棋盘外的箭头 | 箭头正常消失,不发生越界错误 | 循环条件「不出界」保证路径畅通,CLICKED → CLEARED,无 IndexError |
✅ | test_board.py::test_path_pointing_out_of_board_is_cleartest_fly_out_always_exits_the_board(四方向参数化) |
| T04 | 消除本关全部箭头 | 显示通关并进入下一关 | 清空后等待飞出动画与停顿,状态转 LEVEL_CLEARED 并弹出通关卡片;点击主按钮进入下一关(末关回到第 1 关) |
✅ | test_session.py::test_clearing_all_arrows_shows_the_result_and_advancestest_clearing_the_last_level_starts_a_new_round |
| T05 | 失误次数耗尽 | 显示失败并允许重新开始 | 第 3 次撞墙当帧状态转 FAILED,弹出失败卡片;点「重试本关」后箭头布局与失误次数恢复 |
✅ | test_session.py::test_running_out_of_mistakes_fails_the_leveltest_retry_after_failure_restores_the_level |
| T06 | 游戏进行中重新开始 | 箭头布局和失误次数恢复 | 重新加载本关:箭头全部复位、失误回到 3、计时归零(最佳成绩保留) | ✅ | test_session.py::test_restart_restores_layout_and_mistakestest_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 带来的帮助
- 将想法快速落地。功能增加,UI 优化,这些脑内非常美好的想法
- 大范围重构能一次做到底。
viewport那次要改ui/board/session/game四个文件,涉及约 15 个几何函数与几十处长度常量换算。AI 给出了清晰的三条约定(布局常量按设计尺寸写绝对值 / 几何函数返回屏幕坐标 / 绘制时用s()换算),并让「窗口 = 设计尺寸」时视口退化为恒等变换。这是我在人工重构里很难保证的。 - 文档与上下文交接。不少开发者(包括我)都不喜欢写文档,随着开发的推进,缺乏文档沉淀会使得项目更难维护。AI 每轮对话都会积累一份文档,使得后续维护者可以比较快速地了解当前项目状态。
出现的问题
- 过度实现与多余产出。Agent 非常喜欢在 docstring, README 和用户界面写行为解释。虽然留档是好事,但问题是这些文字不合时宜地出现了:用户和开发者不想看权衡评价;那应该写到专门的文档里。另外一个就是滥用测试,大到核心功能,小到文档描述,Agent 都固执地测试一切,然而像文本描述这样的东西就完全没有测试的必要。
- 沉默的假设。当需求比较模糊或者未经过预先调查,AI 会挑一个解释往下做。若不及时发现,就会沿着错误方向走很远。后来我在先让它复述方案与前提,再确认一遍。
我的收获
与 AI 协作的核心能力是“验收”:会跑测试、会看图、会读 diff、会追问前提。AI 写代码很快,但定义“什么叫做对了”这件事仍然只能由人来做。





浙公网安备 33010602011771号