软件工程第二次作业
| 项目 | 一箭又一箭 |
|---|---|
| 这个作业属于哪个课程 | 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.项目展示
开始界面

游戏界面

通关界面

失败界面

2.箭头消除 · 方向突围 — 项目介绍
一、游戏规则
棋盘上分布着上、下、左、右四个方向的箭头。玩家点击箭头后,程序判断该箭头前进方向上是否有其他箭头阻挡:
- 无阻挡:箭头沿方向飞出棋盘并消失;
- 有阻挡:箭头不能消除,撞到阻挡物后原路弹回,并消耗一次失误。
失误次数达到 3 次时本关失败;清空当前关卡全部箭头即通关,可进入下一关。
二、界面设计
游戏共包含 6 个界面:
- 开始界面:显示标题与三个按钮——开始游戏、选择关卡、退出游戏;
- 关卡选择界面:两列按钮列出全部关卡,可点击进入任意一关;
- 游戏界面:顶部显示当前关卡、剩余箭头数和剩余失误次数;中部为 5×5 棋盘;底部为反馈文字,以及"重新开始 (R)"和"返回首页"按钮;
- 关卡完成界面:显示"关卡完成!",提供"下一关"和"返回首页"按钮;
- 全部通关界面:显示"你已通过全部关卡!",提供"返回首页"按钮;
- 失败界面:显示"关卡失败",提供"重玩本关"和"返回首页"按钮。
箭头按方向用不同颜色区分(↑黄、↓绿、←橙、→蓝),飞出和被阻挡时都有对应的动画反馈。
三、主要功能
- 方向系统:支持上、下、左、右四种箭头;
- 路径检测:沿箭头方向逐格检查是否有其他箭头阻挡;
- 飞出动画:箭头平滑飞出窗口,约 0.3~0.5 秒完成;
- 碰撞反馈:箭头飞到阻挡物边缘后弹回,格子变红,底部显示提示文字;
- 失误管理:每关最多 3 次失误,界面顶部实时显示剩余失误;
- 关卡系统:共 6 关,可从开始界面选择任意关卡,通关后自动弹出下一关提示;
- 重开机制:支持按钮和快捷键 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、列号不变。这样任何需要"沿某方向走一格"的地方,只要把 dr、dc 加到坐标上即可,不用写四个 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. 逐句解释
dr, dc从DIRS里取出当前方向的偏移量;nr, nc = r + dr, c + dc从箭头的下一格开始检查(自己这一格不算阻挡);while inside(nr, nc)只关心棋盘范围内的格子;- 每次循环:
- 如果该格不是空的,说明有箭头,立即返回
True; - 否则继续往同一方向走一格;
- 如果该格不是空的,说明有箭头,立即返回
- 循环结束时说明已经走出棋盘边界,返回
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'。 |
排查发现 controller 和 view 文件夹名前面多了一个空格(长度分别是 11 和 5),在 PyCharm 里用 Shift+F6 重命名去掉空格后恢复正常。 |
| 开始/选关/通关/失败界面 | DeepSeek | 生成 6 个界面(开始、选关、游戏、关卡完成、全部通关、失败)的状态机和渲染代码。 | 界面逻辑跑通,但弹窗标题前出现方块,例如"💥 关卡失败"里的 emoji 渲染失败。 | 把 renderer.py 和 game.py 里所有 emoji(💥 🎉 🏆 ✈️ 🚫)全部删掉,改成纯中文提示,方块消失。 |
| 飞出与碰撞动画 | DeepSeek | 生成 FlyingArrow 和 BounceArrow 两个动画类,以及 EffectManager 统一管理。 |
飞出动画能播,但被阻挡时箭头只原地飞一小段就弹回,没有真正飞到阻挡物边缘。 | 把 BounceArrow 的 forward_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×700、CELL_SIZE 改为 68,并同步修改 renderer.py 和 effects.py 里的 BOARD_Y 为 110,保证动画坐标对齐。 |
| 游戏界面按钮 | 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 全是方块,这些都要自己实际玩一遍才能发现,发现后再回到对应的文件里改。
浙公网安备 33010602011771号