软件工程课程第二次个人作业

项目 内容
这个作业属于哪个课程 课程链接
这个作业要求在哪里 作业链接
这个作业的目标 使用 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
            )

这段代码有四个关键点:

  1. 从前方第一格开始。 step 先增加再检查,避免把头部自己判定为障碍。
  2. 先检查边界。 board.inside() 通过后才访问占用表,避免越界或负下标取到另一侧数据。
  3. 区分其他箭头与自身身体。 其他箭头占用的格子直接阻挡。对于弯曲箭头前方的自身身体,需要判断头部到达时该格是否已经腾空;路径索引小于移动步数的尾部格已经离开,否则仍会阻挡。
  4. 返回运动计划。 结果包含成功或碰撞、运动距离以及障碍位置,动画直接使用这份结果,避免判断和画面各自计算而不一致。

成功时,头部先出界还不算整支消失,因此离场距离还要加上身体长度与尾部余量。碰撞时则减去半格和尖端长度,让尖端停在障碍格边缘附近。

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.

posted @ 2026-09-22 20:10  无名氏66  阅读(13)  评论(0)    收藏  举报