软件工程课程第二次个人作业
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 课程链接 |
| 这个作业要求在哪里 | 作业链接 |
| 这个作业的目标 | 使用 Python 和 AIGC 完成“一箭又一箭”小游戏,学习图形交互、路径判断、状态管理与软件测试 |
| 学号 | 112401341 |
| GitHub 仓库 | jhw66/SE_Pygame |
一、项目展示
本项目是一款基于 Python 和 Pygame 的桌面箭头解谜游戏。玩家需要观察箭头的方向与阻挡关系,按照合适的顺序点击箭头,让它们全部飞出棋盘。
当前版本包含五个关卡,支持单格、直线与弯曲箭头,并提供随机换题、管理员选关、棋盘缩放和拖动等功能。
1. 基本玩法与过关
2. 进入管理者模式与失败测试
3. 调正关卡配置
二、项目介绍
1. 游戏规则
玩家使用鼠标左键点击箭头头部所在的格子,程序根据头部朝向判断前方是否存在阻挡。
- 路径允许通过时,箭头头部向前运动,身体沿原路径跟随,整支箭头离场后被消除。
- 路径受阻时,箭头运动到首次障碍处,变红并扣除一点生命值,随后沿原路返回。
- 点击身体格、空白格或棋盘外区域不触发消除,也不扣生命值。
- 当前关所有箭头被清空后通关;生命值归零时失败。
- “重新开始”恢复当前题目,“换一题”使用当前关配置生成新布局。
基础作业要求单格箭头。本项目在此基础上支持多格和弯曲路径,其他箭头的身体格也会形成阻挡;将关卡配置中的最小、最大长度都设为 1,就可以使用基础单格玩法。
2. 界面设计
界面采用深色背景、细网格和彩色箭头。每支箭头的身体和尖端使用相同颜色,碰撞时临时变红,便于区分正常状态与错误反馈。生命值使用红色爱心表示,损失生命后对应爱心变灰。
棋盘保持居中,按钮和状态信息优先放在左右留白区域,窗口空间不足时改用上下工具条。玩家可以调整窗口大小,也可以使用滚轮缩放、鼠标拖动和方向键平移。格子显示过小时,只允许概览,需要放大后才能点击,减少误操作。
3. 主要功能与特色
| 功能 | 实现效果 |
|---|---|
| 五关渐进挑战 | 普通模式按通关进度逐关解锁,支持再次挑战已通关关卡 |
| 可解随机出题 | 生成过程中检查可解性,避免仅靠随机放置产生无法完成的布局 |
| 多格与弯曲箭头 | 通过统一路径结构表示不同形状,身体沿路径跟随头部移动 |
| 飞出和碰撞动画 | 成功时整支离场;失败时前进、变红、停顿、返回 |
| 配置化关卡 | 通过 JSON 修改行列数、箭头数量、生命值和形状参数 |
| 管理员自由选关 | 从本地 JSON 读取密码,解锁权限与实际通关记录分开保存 |
| 随机种子 | 支持 --seed,在相同配置、环境与生成顺序下复现题目序列 |
| 大棋盘浏览 | 支持缩放、拖动、方向键平移及全景恢复 |
| 后台生成 | 生成关卡时保持界面响应,取消旧任务后不会安装过期结果 |
仓库模板提供的五关配置如下;本机修改过配置时,以本地 config/campaign.json 为准。
| 关卡 | 名称 | 棋盘 | 箭头支数 | 初始生命值 |
|---|---|---|---|---|
| 第一关 | 初识方向 | 5×5 | 20 | 3 |
| 第二关 | 解除阻挡 | 10×10 | 70 | 3 |
| 第三关 | 步步推进 | 15×15 | 160 | 3 |
| 第四关 | 密阵挑战 | 20×20 | 280 | 3 |
| 第五关 | 箭阵终章 | 25×25 | 450 | 3 |
4. 开发环境与运行
本次整理与自动化检查使用 Windows 11、Python 3.12.10 和 Pygame 2.6.1。在项目根目录执行:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe main.py
需要固定随机起点时,使用:
.\.venv\Scripts\python.exe main.py --seed 18
首次启动自动从示例文件创建本地配置。管理员密码在 config/admin.json 中设置,再从首页的“管理员入口”输入验证。当前没有默认管理员密码,也没有跨次运行的存档功能。
三、实现思路
1. 按职责拆分代码
| 文件 | 主要职责 |
|---|---|
main.py |
窗口、页面切换、事件处理、后台生成任务 |
game_logic.py |
箭头与棋盘数据、阻挡判断、运动预判、求解 |
game_state.py |
当前题目、生命值、动画阶段、失败、通关和重开 |
levels.py |
关卡参数验证和可解布局生成 |
board_view.py |
绘图、坐标转换、缩放和平移 |
campaign.py |
五关配置、解锁进度、通关记录及管理员验证 |
这样的划分使规则判断不依赖画面,可以单独检查边界和碰撞;视图的缩放只改变显示位置,不改变逻辑棋盘。
对应源码节选自 main.py:入口导入功能模块,主循环依次处理输入、更新状态、绘制画面。
from board_view import BoardView, BACKGROUND, ARROW_COLOR, ERROR_COLOR
from game_state import GameSession, FAILED, WON
from levels import generate_level
from campaign import Campaign, LEVEL_COUNT, read_admin_password
while app.running:
dt = clock.tick(60) / 1000
app.handle_events(pygame.event.get())
if not app.running:
break
app.update(dt)
app.draw()
pygame.display.flip()
dt 是上一帧到当前帧经过的秒数,传给游戏状态对象后用于推进动画。
2. 箭头与方向如何表示
箭头使用不可变的数据对象保存编号、身体路径和方向。下面是结构节选,省略了初始化时的数据校验:
@dataclass(frozen=True)
class Arrow:
id: int
cells: tuple[Cell, ...]
direction: str
@property
def head(self):
return self.cells[-1]
cells 中的坐标按照“尾部到头部”的顺序保存。例如:
cells = ((2, 1), (2, 2), (1, 2))
direction = "U"
它表示一支从 (2, 1) 延伸到 (2, 2),再转向 (1, 2) 的弯曲箭头,最后一个坐标是头部,朝向上方。单格箭头只需要一个坐标。路径中的相邻格必须上下左右相连,不能重复占用同一格。
方向统一转换为行列增量:
DIRECTIONS = {
"U": (-1, 0),
"D": (1, 0),
"L": (0, -1),
"R": (0, 1),
}
行号向下增加,列号向右增加,因此向上对应行号减 1。使用方向增量后,四种方向可以共用一段扫描逻辑。
3. 棋盘与关卡如何表示
棋盘同时保存两种数据:arrows 字典按编号保存完整箭头对象;二维列表 occupancy 记录每格被哪支箭头占用,空格为 None。
这样,绘制时可以遍历完整路径;检测阻挡时,则可以直接查询某格的占用情况,不必每次遍历全部箭头。消除一支箭头时,需要同时移除对象并清空它占用的所有格子。
下面节选 game_logic.py 的棋盘字段和删除方法,省略其他成员方法。
@dataclass
class BoardState:
rows: int
cols: int
arrows: dict[int, Arrow] = field(default_factory=dict)
occupancy: list[list[int | None]] = field(init=False, repr=False, compare=False)
def remove(self, arrow_id):
arrow = self.arrows.pop(arrow_id)
for r, c in arrow.cells:
self.occupancy[r][c] = None
return arrow
关卡参数保存在 config/campaign.json 的 levels 数组中,读取后转换为 LevelConfig。参数包括行数、列数、箭头数量、最小长度、最大长度和转弯偏好,名称与生命值由关卡对象额外保存。
下面分别节选 levels.py 的生成参数字段和 campaign.py 的关卡字段,省略参数校验方法。
@dataclass(frozen=True)
class LevelConfig:
rows: int
cols: int
arrow_count: int
min_length: int = 1
max_length: int = 1
turn_probability: float = 0.0
@dataclass(frozen=True)
class CampaignLevel:
name: str
config: LevelConfig
lives: int # lives: int 这样的类型标注本身不会检查输入是否合法,真正的检查由后面的函数完成
程序会检查以下基本容量条件:
箭头支数 × 最小长度 ≤ 行数 × 列数
它只能说明空间容量足够,不能单独证明几何布局可行,因此生成时还需要进一步验证。
对应检查位于 levels.py 的 LevelConfig.__post_init__() 中:
if self.arrow_count * self.min_length > self.rows * self.cols:
raise ValueError("空间不足:箭头支数 × 最小长度不能超过棋盘总格数")
4. 核心:路径检测方法
检测从头部前方的第一格开始,沿箭头朝向逐格扫描,直到遇到障碍或到达棋盘外。只检查紧邻的一格是不够的,中间隔着空格的其他箭头同样可能阻挡。
当前项目的核心函数如下,节选自 game_logic.py,省略了部分注释:
def plan_movement(board, arrow_id):
arrow = board.arrows[arrow_id]
dr, dc = DIRECTIONS[arrow.direction]
r, c = arrow.head
own_indices = {cell: i for i, cell in enumerate(arrow.cells)}
step = 0
while True:
step += 1
cell = r + dr * step, c + dc * step
if not board.inside(cell):
return MovementPlan(
arrow_id, "exit",
step + len(arrow.cells) - 1 + TAIL_EXTENT
)
occupant = board.occupancy[cell[0]][cell[1]]
blocked = occupant is not None and (
occupant != arrow_id or own_indices[cell] >= step
)
if blocked:
return MovementPlan(
arrow_id, "collision",
step - 0.5 - NOSE_EXTENT, cell
)
这段代码有四个关键点:
- 从前方第一格开始。
step先增加再检查,避免把头部自己判定为障碍。 - 先检查边界。
board.inside()通过后才访问占用表,避免越界或负下标取到另一侧数据。 - 区分其他箭头与自身身体。 其他箭头占用的格子直接阻挡。对于弯曲箭头前方的自身身体,需要判断头部到达时该格是否已经腾空;路径索引小于移动步数的尾部格已经离开,否则仍会阻挡。
- 返回运动计划。 结果包含成功或碰撞、运动距离以及障碍位置,动画直接使用这份结果,避免判断和画面各自计算而不一致。
成功时,头部先出界还不算整支消失,因此离场距离还要加上身体长度与尾部余量。碰撞时则减去半格和尖端长度,让尖端停在障碍格边缘附近。
5. 箭头如何沿路径移动
箭头运动用 progress 表示已经前进的路程,单位是“格”,可以是小数。初始路程为 0,前进时逐渐增加,返回时逐渐减小。单格、直线和弯曲箭头都通过这个路程值计算当前形状。
头部沿朝向前进,身体沿既有折线路径逐段跟随。 路径末端沿头部方向继续延伸,尾部不断经过原来的转角,弯曲箭头便会逐渐从棋盘中抽出。例如 ((2, 1), (2, 2), (1, 2)) 朝上的箭头,前进半格后头部中心位于 (0.5, 2),尾部中心沿第一段前进到 (2, 1.5);实际绘制还会增加尖端和尾端的长度余量。
(1)根据路程计算路径位置。 game_logic.py 的 path_point() 在相邻格中心之间进行线性插值;超出原路径头端后,沿箭头朝向继续计算。
def path_point(arrow, distance):
cells = arrow.cells
if distance < 0:
if len(cells) == 1:
dr, dc = DIRECTIONS[arrow.direction]
else:
dr, dc = cells[1][0] - cells[0][0], cells[1][1] - cells[0][1]
return cells[0][0] + distance * dr, cells[0][1] + distance * dc
if distance >= len(cells) - 1:
dr, dc = DIRECTIONS[arrow.direction]
extra = distance - (len(cells) - 1)
return arrow.head[0] + extra * dr, arrow.head[1] + extra * dc
i = int(distance)
fraction = distance - i
a, b = cells[i:i + 2]
return a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1])
例如路程为 0.5,插值比例也是 0.5,得到前两个格中心之间的中点。使用小数路程可以让箭头平滑移动,而不只是在整数格之间跳动。这里计算的是逻辑坐标,与窗口像素和缩放比例无关。
(2)计算当前身体与头部的位置。 moving_shape() 根据 progress 计算身体的起止位置,保留身体范围内的转角,再调用 path_point() 得到绘制点。
def moving_shape(arrow, progress=0.0):
start = progress - TAIL_EXTENT
end = len(arrow.cells) - 1 + progress + 0.12
distances = [start] + [i for i in range(len(arrow.cells)) if start < i < end] + [end]
return [path_point(arrow, s) for s in distances], path_point(arrow, len(arrow.cells) - 1 + progress)
前进时,这个固定长度的区间沿路径向前滑动;碰撞返回时,减小同一个 progress,就可以沿相同路径退回原位,无需另建返回轨迹。
(3)转换为屏幕坐标并绘制。 board_view.py 中的 to_screen() 将行列坐标转换为像素位置,+0.5 用于对齐格子中心。
def to_screen(self, cell):
r, c = cell
return self.x + (c + 0.5) * self.cell_size, self.y + (r + 0.5) * self.cell_size
draw_arrow() 根据当前路程取得路径,用折线绘制身体。下面节选其开头,后续还会在头部位置绘制三角形尖端。
path, head = moving_shape(arrow, progress)
points = [self.to_screen(point) for point in path]
width = max(1, round(self.cell_size * 0.08))
pygame.draw.lines(surface, color, False, points, width) # 将多个点连成折线且不闭合
缩放改变的是逻辑坐标到屏幕坐标的换算比例,移动进度仍以格为单位,因此放大棋盘不会改变箭头的运动规则和动作时长。
6. 动画和状态如何配合
当前题目使用 PLAYING、FAILED、WON 三种主要状态。一次动作内部又分为前进、碰撞停顿和返回三个阶段。
点击头部 → 生成运动计划 → 前进
├─ 可以离场 → 整支离场后删除 → 检查通关
└─ 存在阻挡 → 扣血、变红 → 停顿 → 返回原位
动画根据每帧经过的时间更新位移。成功移动时,直到整支离场才修改棋盘数量;发生碰撞时,只在首次接触障碍并进入停顿阶段扣一次生命值,避免一段动画反复扣血。
对应源码是 game_state.py 的 GameSession.update(),下面省略注释,保留状态转换逻辑。needed 表示当前阶段还需多久,elapsed 表示本帧实际消耗多久;本帧有剩余时间时继续推进下一阶段。
def update(self, dt):
if dt < 0:
raise ValueError("时间增量不能为负数")
while self.motion is not None and dt > 0:
motion = self.motion
if motion.phase == "forward":
needed = (motion.plan.distance - motion.progress) / motion.speed
elapsed = min(dt, needed)
motion.progress = min(motion.plan.distance, motion.progress + elapsed * motion.speed)
dt -= elapsed
if elapsed + 1e-10 < needed:
break
motion.progress = motion.plan.distance
if motion.plan.outcome == "exit":
self.board.remove(motion.plan.arrow_id)
self.motion = None
if not self.board.arrows:
self.state = WON
else:
motion.phase = "pause"
self.mistakes_remaining -= 1
elif motion.phase == "pause":
elapsed = min(dt, motion.pause_remaining)
motion.pause_remaining -= elapsed
dt -= elapsed
if motion.pause_remaining > 1e-10:
break
motion.phase = "return"
else:
needed = motion.progress / motion.speed
elapsed = min(dt, needed)
motion.progress = max(0.0, motion.progress - elapsed * motion.speed)
dt -= elapsed
if elapsed + 1e-10 < needed:
break
self.motion = None
if self.mistakes_remaining == 0:
self.state = FAILED
生命值归零后界面立即显示失败弹窗,背景仍可继续完成返回动画。点击重开时恢复初始棋盘副本,清除旧动画;视图缩放、平移和颜色由其他对象管理,因此可以保留。
重开逻辑位于 game_state.py 的 GameSession.restart(),只恢复游戏数据,不重置视图:
def restart(self):
self.board = self.initial.copy()
self.state = PLAYING if self.board.arrows else WON
self.mistakes_remaining = self.max_lives
self.motion = None
7. 如何保证随机关卡可解
生成器首先构造可解的单格布局,再从箭尾尝试延长身体。每次延长后,用求解器检查整个棋盘;如果扩展破坏可解性,就恢复原箭头。
求解器在棋盘副本上反复寻找当前能够退出的箭头并移除。清空棋盘表示存在完整解;仍有箭头却无法继续移除,表示布局死锁。由于消除箭头只会减少阻挡,这种逐步移除的方法适合当前规则。
对应源码为 game_logic.py 的 solve_board()。working 是棋盘副本,验解不会删除玩家实际棋盘中的箭头。
def solve_board(board, checkpoint=None):
working = board.copy()
solution = []
while working.arrows:
progressed = False
for arrow_id in list(working.arrows):
if checkpoint:
checkpoint()
if can_exit(working, arrow_id):
working.remove(arrow_id)
solution.append(arrow_id)
progressed = True
if not progressed:
return None
return solution
levels.py 的 _grow() 临时延长箭头后,会调用同一求解器。下面节选检查和恢复逻辑,extended 是候选新箭头,arrow 是扩展前的箭头。
board.remove(arrow_id)
board.add(extended)
accepted = False
try:
accepted = solve_board(board, checkpoint) is not None
finally:
if not accepted:
board.remove(arrow_id)
board.add(arrow)
生成过程会先满足最小长度,再尝试增长到更长的路径。最大长度是上限,不保证每支都达到。levels.py 在生成和求解循环中调用 checkpoint() 检查取消信号与时间预算,避免计算无限持续。
下面节选 generate_level() 内部的检查函数,cancel_event 和 deadline 由外层函数提供:
def checkpoint():
if cancel_event is not None and cancel_event.is_set():
raise GenerationCancelled("已取消生成")
if time.monotonic() >= deadline:
raise GenerationError("生成超时,请减少支数、最小长度或扩大棋盘")
四、AIGC 使用过程
| AIGC 工具 | 子任务 | 提出的要求 | AI 提供的帮助 | 效果与人工参与 |
|---|---|---|---|---|
| ChatGPT | 学习 Pygame 基础 | 解释游戏循环、鼠标事件和画面更新的关系 | 讲解概念,提供小型代码示例 | 学 |
| ChatGPT | 理解坐标与路径判断 | 解释像素坐标与网格坐标转换,以及四方向阻挡检测 | 拆解计算过程,分析边界与阻挡案例 | 学 |
| ChatGPT | 理解箭头移动 | 解释弯曲箭头如何前进、返回,以及动画如何计时 | 说明插值、移动进度和状态切换 | 学 |
| Codex | 功能构建 | 逐步完善小游戏和多关挑战功能 | 辅助编写和整合界面、关卡、配置及交互代码 | 项目形成完整游戏流程;人工确定需求、关卡参数、调整内部实现逻辑 |
| Codex | 问题排查 | 检查显示、重开与游戏流程中的问题 | 定位相关代码并修复,配合自动化检查 | 已有记录包含修复与验证;人工反馈问题、明确预期 |
五、测试
测试结果表
| 编号 | 测试内容与操作 | 预期结果 | 本次实际结果 | 是否通过 |
|---|---|---|---|---|
| T01 | 正常消除:点击前方无阻挡的箭头头部 | 箭头沿路径移动,整支离场后消失,剩余数量减 1,生命值不变 | 使用三格弯曲箭头触发点击;前进期间保留箭头,完整离场后数量由 2 减为 1,生命值保持 3 | 通过 |
| T02 | 碰撞反馈:点击前方被其他箭头挡住的头部 | 箭头前进、变红、返回原位;数量不变,生命值减 1,一颗红心变灰 | 检查到碰撞阶段及红色箭头像素,生命值由 3 变为 2,生命值区域出现灰色爱心;动作结束后布局恢复,箭头仍为 3 支 | 通过 |
| T03 | 边界处理:点击位于边缘、朝向棋盘外的箭头 | 正常飞出,没有报错、卡住或错误扣血 | 分别验证上、下、左、右四个边界朝外箭头;均完成消除,无异常,生命值保持 3 | 通过 |
| T04 | 通关与切关:清空当前关卡,再点击“下一关” | 最后一支完全离场后显示通关;下一关解锁,进入后显示对应配置 | 最后一支离场后进入 WON,记录第一关完成并解锁第二关;点击下一关后实际生成 10×10、70 支箭头、3 点生命值的第二关,绘制调用正常 |
通过 |
| T05 | 失败处理:等每次碰撞结束,再点击受阻箭头,直到生命值归零 | 自动出现失败弹窗,提供重新开始和返回主页 | 三次碰撞后生命值降至 0,失败弹窗条件成立并完成绘制,含两个操作按钮;进一步验证 Enter 可重开、再次失败后 Esc 可回首页 | 通过 |
| T06 | 游戏中重开:先消除一些箭头并损失生命,再点击“重新开始” | 恢复当前题初始布局、箭头数量和生命值;颜色、观察位置保持 | 先从 3 支减为 2 支、生命值从 3 降为 2,并缩放平移;点击重开后恢复初始布局、3 支箭头和 3 点生命值,配色及缩放平移参数不变,动画清空 | 通过 |
六、PSP 表格
以下按学习、构建、复查三个阶段组织,单位为小时,差异为实际减预计。
| 阶段 | 主要工作 | 预计耗时 | 实际耗时 | 差异 |
|---|---|---|---|---|
| 学习阶段 | 学习 Pygame 主循环、绘图、事件、动画...基础内容 | 8 | 13 | +5 |
| 构建阶段 | 完成箭头与棋盘、路径判断、动画、生命值、五关流程、配置与管理员功能 | 16 | 18 | +2 |
| 复查阶段 | 执行测试、定位修复、核对运行说明,整理 README、博客与演示素材 | 1 | 1 | +0 |
—— END OF POST ——
Thanks for reading.




浙公网安备 33010602011771号