软件工程第二次作业

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

1.项目展示

开始界面

image

游戏界面

动画

通关界面

动画3

失败界面

动画2

2.箭头消除 · 方向突围 — 项目介绍

一、游戏规则

棋盘上分布着上、下、左、右四个方向的箭头。玩家点击箭头后,程序判断该箭头前进方向上是否有其他箭头阻挡:

  • 无阻挡:箭头沿方向飞出棋盘并消失;
  • 有阻挡:箭头不能消除,撞到阻挡物后原路弹回,并消耗一次失误。

失误次数达到 3 次时本关失败;清空当前关卡全部箭头即通关,可进入下一关。

二、界面设计

游戏共包含 6 个界面:

  • 开始界面:显示标题与三个按钮——开始游戏、选择关卡、退出游戏;
  • 关卡选择界面:两列按钮列出全部关卡,可点击进入任意一关;
  • 游戏界面:顶部显示当前关卡、剩余箭头数和剩余失误次数;中部为 5×5 棋盘;底部为反馈文字,以及"重新开始 (R)"和"返回首页"按钮;
  • 关卡完成界面:显示"关卡完成!",提供"下一关"和"返回首页"按钮;
  • 全部通关界面:显示"你已通过全部关卡!",提供"返回首页"按钮;
  • 失败界面:显示"关卡失败",提供"重玩本关"和"返回首页"按钮。

箭头按方向用不同颜色区分(↑黄、↓绿、←橙、→蓝),飞出和被阻挡时都有对应的动画反馈。

三、主要功能

  1. 方向系统:支持上、下、左、右四种箭头;
  2. 路径检测:沿箭头方向逐格检查是否有其他箭头阻挡;
  3. 飞出动画:箭头平滑飞出窗口,约 0.3~0.5 秒完成;
  4. 碰撞反馈:箭头飞到阻挡物边缘后弹回,格子变红,底部显示提示文字;
  5. 失误管理:每关最多 3 次失误,界面顶部实时显示剩余失误;
  6. 关卡系统:共 6 关,可从开始界面选择任意关卡,通关后自动弹出下一关提示;
  7. 重开机制:支持按钮和快捷键 R 重玩当前关。

四、特色

  • 保证有解:内置贪心求解器,随机生成后先验证存在消除顺序,才作为正式关卡;
  • 保证有阻挡:每关至少存在 2 个被阻挡的箭头,避免开局一飞而空;
  • 拟真碰撞:箭头按真实距离飞到阻挡物边缘再弹回,而不是简单的原地晃动;
  • 界面闭环:开始 → 选关 → 游戏 → 通关/失败 → 返回,全部通过按钮流转,无需退出程序;
  • MVC 分层:棋盘数据、游戏状态、渲染动画相互独立,便于维护和扩展。

3.实现思路

一、箭头、方向和关卡的表示

1. 方向的表示

四种方向(上、下、左、右)统一用字典配置,每个方向包含行偏移、列偏移和显示符号:

DIRS = {
    'up':    {'dr': -1, 'dc': 0,  'symbol': '↑'},
    'down':  {'dr': 1,  'dc': 0,  'symbol': '↓'},
    'left':  {'dr': 0,  'dc': -1, 'symbol': '←'},
    'right': {'dr': 0,  'dc': 1,  'symbol': '→'},
}

其中:

  • dr:行偏移,例如 'up' 的行偏移是 -1,表示往上走一行;
  • dc:列偏移,例如 'left' 的列偏移是 -1,表示往左走一列;
  • symbol:绘制时显示的字符,用于在棋盘上画出箭头的方向。

例如 'up' 表示行号减 1、列号不变。这样任何需要"沿某方向走一格"的地方,只要把 drdc 加到坐标上即可,不用写四个 if 分支。以后若要扩展斜向箭头,只需在 DIRS 里补一行,其它代码不用动。

2. 箭头的表示

棋盘用二维列表存储,每个格子要么是 None(空格),要么是一个字典:

{
    'dir': 'up',   # 方向,取值是 DIRS 的键
}

用字典而不是直接存字符串,是为了后续扩展方便。比如以后想给箭头加颜色、锁定状态、剩余步数等字段,只需在字典里加键值,不影响已有代码。

3. 棋盘的整体结构

Board 类持有 self.grid,是一个 ROWS × COLS 的二维列表:

class Board:
    def __init__(self):
        self.grid = [[None] * COLS for _ in range(ROWS)]

    def get(self, r, c):
        if 0 <= r < ROWS and 0 <= c < COLS:
            return self.grid[r][c]
        return None

    def remove(self, r, c):
        self.grid[r][c] = None

    def is_empty(self, r, c):
        return self.grid[r][c] is None

    def count_arrows(self):
        n = 0
        for r in range(ROWS):
            for c in range(COLS):
                if self.grid[r][c] is not None:
                    n += 1
        return n

所有游戏逻辑都只通过这几个方法与棋盘交互,不直接操作二维列表,便于以后替换底层数据结构。

4. 关卡的表示

每个关卡是一段 5 行的字符串数组,. 表示空格,U/D/L/R 分别表示上、下、左、右:

LEVELS = [
    [
        "..U..",
        "..R.L",
        ".D...",
        ".....",
        "..R..",
    ],
    # 更多关卡……
]

LEVEL_CHAR_TO_DIR = {
    'U': 'up',
    'D': 'down',
    'L': 'left',
    'R': 'right',
}

加载关卡时把字符翻译成方向字典填入棋盘:

def load_level(self, index):
    self.clear()
    pattern = LEVELS[index]
    for r in range(ROWS):
        for c in range(COLS):
            ch = pattern[r][c]
            if ch in LEVEL_CHAR_TO_DIR:
                self.grid[r][c] = {'dir': LEVEL_CHAR_TO_DIR[ch]}

用字符画表示关卡有三个好处:

  • 手写关卡时一眼能看出布局;
  • 增加关卡只要往 LEVELS 里加一段字符串;
  • 不需要额外的 JSON 或数据库文件。

二、路径检测方法(核心)

1. 检测思路

判断一个箭头能否飞出,本质是问:

从这个格子出发,沿它朝向的方向一格一格往前走,在碰到棋盘边界之前,会不会先碰到另一个箭头?

  • 会 → 被阻挡,不能飞出;
  • 不会 → 可以飞出。

2. 关键函数

def inside(r, c):
    return 0 <= r < ROWS and 0 <= c < COLS


def is_blocked(board, r, c, direction):
    dr = DIRS[direction]['dr']
    dc = DIRS[direction]['dc']
    nr, nc = r + dr, c + dc          # 从下一格开始
    while inside(nr, nc):            # 只要还在棋盘内
        if not board.is_empty(nr, nc):
            return True              # 碰到箭头 → 被阻挡
        nr += dr                     # 继续沿方向走
        nc += dc
    return False                     # 走到边界外都没碰到 → 可飞出

3. 逐句解释

  1. dr, dcDIRS 里取出当前方向的偏移量;
  2. nr, nc = r + dr, c + dc 从箭头的下一格开始检查(自己这一格不算阻挡);
  3. while inside(nr, nc) 只关心棋盘范围内的格子;
  4. 每次循环:
    • 如果该格不是空的,说明有箭头,立即返回 True
    • 否则继续往同一方向走一格;
  5. 循环结束时说明已经走出棋盘边界,返回 False

4. 为什么用循环而不是递归

while 循环比递归更直观,也不会因为棋盘尺寸增大而栈溢出。每次循环只做一次坐标加法和一次边界判断,时间复杂度是 O(最长边长),5×5 棋盘最多走 5 步。

5. 同一思路在其它地方的复用

求解器 is_solvable:判断随机生成的棋盘是否存在消除顺序。做法是反复扫描所有箭头,把当前可以飞出的标记为可移除,移除后再扫一遍,直到没有可移除的为止。最后如果棋盘全空,说明有解。它内部的"是否被阻挡"判断用的是同一套方向偏移和逐格检查。

def is_solvable(board):
    # 复制一份棋盘,不修改原棋盘
    grid = [[board.grid[r][c] for c in range(COLS)] for r in range(ROWS)]

    def blocked_sim(r, c, direction):
        dr = DIRS[direction]['dr']
        dc = DIRS[direction]['dc']
        nr, nc = r + dr, c + dc
        while 0 <= nr < ROWS and 0 <= nc < COLS:
            if grid[nr][nc] is not None:
                return True
            nr += dr
            nc += dc
        return False

    while True:
        removable = []
        for r in range(ROWS):
            for c in range(COLS):
                cell = grid[r][c]
                if cell is None:
                    continue
                if not blocked_sim(r, c, cell['dir']):
                    removable.append((r, c))

        if not removable:
            # 没有能飞出的箭头了,检查是否已清空
            for r in range(ROWS):
                for c in range(COLS):
                    if grid[r][c] is not None:
                        return False
            return True

        # 移除当前所有可飞出的箭头
        for r, c in removable:
            grid[r][c] = None

碰撞动画 EffectManager.trigger_blocked:也是沿方向逐格找第一个非空格子,把它的坐标作为"撞墙点",再算起点到该点的像素距离,让箭头飞到这个位置后弹回。

def _find_blocker(self, board, r, c, direction):
    dr, dc = DIRS[direction]['dr'], DIRS[direction]['dc']
    nr, nc = r + dr, c + dc
    while 0 <= nr < ROWS and 0 <= nc < COLS:
        if not board.is_empty(nr, nc):
            return nr, nc
        nr += dr
        nc += dc
    return None, None


def trigger_blocked(self, r, c, direction, board):
    self.blocked[(r, c)] = 22
    target_r, target_c = self._find_blocker(board, r, c, direction)
    if target_r is None:
        return
    cx, cy = self._cell_center(r, c)
    bx, by = self._cell_center(target_r, target_c)
    full_dist = ((bx - cx) ** 2 + (by - cy) ** 2) ** 0.5
    buffer = CELL_SIZE - 14
    forward_px = max(6, full_dist - buffer)
    dx, dy = self._unit_offset(direction)
    self.bouncing.append(
        BounceArrow(cx, cy, direction, dx, dy, forward_px, total_frames=40)
    )

6. 一致性保证

路径检测、求解器、碰撞动画三处共用同一份 DIRS 配置和同一套逐格前进的写法,所以:

  • 游戏逻辑认为被阻挡的箭头,动画里一定撞到同一格;
  • 求解器认为可解的棋盘,玩家在游戏里一定真的能通关。

这避免了"逻辑上说能飞、动画上却撞墙"这种不一致。

4. AIGC 使用过程

AIGC 使用过程记录

子任务 借助何种 AIGC 技术 AI 实现或提供了什么 效果如何 人工修改
项目目录结构搭建 DeepSeek 提供 config.py + model/ + controller/ + view/ + main.py 的 MVC 分层结构,并生成各模块初始代码。 结构清晰,但运行时反复报 ModuleNotFoundError: No module named 'controller' 排查发现 controllerview 文件夹名前面多了一个空格(长度分别是 11 和 5),在 PyCharm 里用 Shift+F6 重命名去掉空格后恢复正常。
开始/选关/通关/失败界面 DeepSeek 生成 6 个界面(开始、选关、游戏、关卡完成、全部通关、失败)的状态机和渲染代码。 界面逻辑跑通,但弹窗标题前出现方块,例如"💥 关卡失败"里的 emoji 渲染失败。 renderer.pygame.py 里所有 emoji(💥 🎉 🏆 ✈️ 🚫)全部删掉,改成纯中文提示,方块消失。
飞出与碰撞动画 DeepSeek 生成 FlyingArrowBounceArrow 两个动画类,以及 EffectManager 统一管理。 飞出动画能播,但被阻挡时箭头只原地飞一小段就弹回,没有真正飞到阻挡物边缘。 BounceArrowforward_px 从固定值改成动态计算:先找出阻挡物格子中心,再算起点到它的距离,减去一个格子的缓冲区,让箭头正好飞到阻挡物边缘再返回。
关卡可解性 DeepSeek 最初用 place_random 随机生成箭头位置和方向。 玩到高关卡时经常出现"无解"局面——所有箭头互相朝向对方,玩家怎么点都只能扣失误。 model/rules.py 里加 is_solvable(board) 贪心求解器,place_random 反复随机直到生成可解棋盘;同时加上 has_blocked_arrow 保证每关至少 2 个箭头被阻挡,避免开局一飞而空。
游戏界面尺寸调整 DeepSeek 初始窗口 640×800、格子 84px 录演示 GIF 时窗口太大,录出来文件体积也大。 config.py 里的 WINDOW_WIDTH/HEIGHT 改为 520×700CELL_SIZE 改为 68,并同步修改 renderer.pyeffects.py 里的 BOARD_Y110,保证动画坐标对齐。
游戏界面按钮 DeepSeek 生成游戏界面,只给了"重新开始 (R)"一个按钮。 玩到一半想回开始界面时,只能先退出程序再重开,体验差。 在游戏界面底部增加"返回首页"按钮,和"重新开始 (R)"并排;在 main.py 里新增 game_home_btn,点击后切回开始界面。

5. 测试结果

测试用例与结果

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 箭头沿朝向方向平滑飞出窗口并消失,剩余箭头数减 1 通过
T02 点击前方有阻挡的箭头 箭头不消失,失误次数加 1 箭头飞到阻挡物边缘后原路弹回,格子短暂变红,剩余失误数减 1 通过
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 边缘箭头点击后飞出,飞出过程中坐标超出棋盘范围也不报错,动画结束后自动回收 通过
T04 消除本关全部箭头 显示通关并进入下一关 最后一个箭头飞完后弹出"关卡完成"界面,显示"下一关"和"返回首页"按钮 通过
T05 失误次数耗尽 显示失败并允许重新开始 第 3 次失误后弹出"关卡失败"界面,显示"重玩本关"和"返回首页"按钮 通过
T06 点击"重新开始 (R)"按钮 箭头布局和失误次数恢复 当前关卡恢复到初始布局,失误清零,关卡数不变 通过
T07 按键盘 R 键 同 T06 效果与点击"重新开始"按钮一致 通过
T08 点击"返回首页"按钮 回到开始界面 从游戏界面切回开始界面,棋盘和状态重置 通过
T09 从关卡选择界面进入指定关卡 能正确加载对应关卡 点击"第 N 关"按钮后进入第 N 关,箭头布局与关卡数据一致 通过
T10 清空全部关卡 显示"你已通过全部关卡"界面 通关最后一关后弹出"你已通过全部关卡!"界面,提供"返回首页"按钮 通过
T11 点击空白格子 无任何反应 点击空格子不触发任何逻辑,失误数不变 通过
T12 动画播放期间连续点击 不出现重复消除或计数错乱 快速点击时动画正常播放,失误计数、剩余箭头数均准确 通过
T13 按 ESC 键 从游戏/选关/结束界面返回开始界面 ESC 键在多个界面都能正确返回开始界面 通过
T14 观察所有关卡的箭头布局 每关都存在可通关顺序 逐一试玩全部关卡,均可在失误不超过 3 次的前提下通关,未发现无解局面 通过
T15 观察被阻挡时的视觉反馈 有明显碰撞反馈 箭头飞到阻挡物边缘后弹回,格子变红,底部提示"被挡住了!失误 X/3" 通过

6. PSP 表格

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

7. 心得体会

这次开发最大的体会是:不要让 AI 一次性生成全部代码,先定框架、再分文件,之后改哪里就改哪个文件,既方便又不容易出错。

一开始我让 AI 直接把整个游戏代码一次性写出来,结果特别乱:所有逻辑挤在一起,出了错根本不知道从哪下手。后来改成先定好目录结构(config.py + model/ + controller/ + view/ + main.py),再让 AI 一个文件一个文件地生成,整个过程就顺了很多。

分文件之后有两个明显好处:

  • 改代码很方便:要调动画只改 view/effects.py,要调界面只改 view/renderer.py,要调规则只改 model/rules.py,不会牵一发动全身。
  • 出错容易定位:程序报错时,看 traceback 指向哪个文件就能直接改那个文件,不用在一大堆代码里翻。

整个过程下来我最大的收获是:AI 给的是起点,不是终点。 它写的代码能跑但不一定对,比如随机生成的关卡经常无解、碰撞动画只是"原地晃一下"、emoji 全是方块,这些都要自己实际玩一遍才能发现,发现后再回到对应的文件里改。


posted @ 2026-09-19 15:50  Oribitix  阅读(23)  评论(0)    收藏  举报