软件工程第二次作业

使用 Python 和 AIGC 完成“箭路突围”小游戏

一、作业信息

项目 内容
这个作业属于哪个课程 软件工程课程
这个作业要求在哪里 本次作业要求
这个作业的目标 使用 Python 和 AIGC 完成“一箭又一箭”小游戏
学号 102401608
GitHub 仓库 arrow_by_arrow

二、项目展示

1. 项目名称

Arrow Escape(箭路突围)

2. 游戏展示

开始界面

游戏开始界面

游戏进行过程

下面的视频展示游戏进行过程,包括点击箭头、箭头移动和游戏操作界面。

游戏进行过程

关卡通关界面

游戏过关过程

关卡失败界面

游戏失败过程

以上只展示开始界面、游戏进行过程、游戏过关过程和游戏失败过程,其他界面不单独展示。动图已上传到 GitHub 仓库,并通过公开地址引用,博客园不需要读取本地电脑文件即可显示。

动画素材来源说明

本文和游戏中使用的动画小人表情包来自 TG 贴纸包 muzimi66,作者署名为 @muzimi2,属于同人贴纸素材。相关素材仅用于本课程作业的学习、开发和展示,素材版权归原作者及相关权利人所有。


三、项目介绍

这是一个基于方向判断和消除顺序的解谜小游戏。棋盘中会随机分布不同方向的箭头,方向包括上、下、左、右。玩家需要观察每支箭头的移动方向,判断它前方是否有其他箭头阻挡,并按照合适的顺序点击箭头,使所有箭头依次离开棋盘。

如果点击的箭头前方没有其他箭头,它会沿自身方向飞出棋盘;如果前方存在箭头,则本次操作失败,箭头不会消失,同时减少一次剩余机会。当失误次数耗尽后,本关失败。

游戏共设计五个关卡:

  • 第一关:用于熟悉游戏规则;
  • 第二关:增加箭头数量和棋盘规模;
  • 第三关:箭头之间出现更多交叉阻挡;
  • 第四关:棋盘进一步扩大,箭头密度提高;
  • 第五关:作为最终关卡,箭头数量较多,难度最高。

每关都有独立计时器,通关后会根据关卡难度、完成时间和失误次数计算分数。


四、主要功能

1. 基本游戏功能

  • 支持上、下、左、右四种方向的箭头;
  • 点击没有被阻挡的箭头后,箭头会飞出棋盘;
  • 点击被阻挡的箭头时,显示碰撞位置和路径反馈;
  • 每关最多允许两次失误;
  • 箭头全部消除后进入通关界面;
  • 失误次数耗尽后进入失败界面;
  • 支持重新开始当前关卡。

2. 关卡功能

  • 共设计五个关卡;
  • 后续关卡逐渐增加棋盘大小和箭头数量;
  • 箭头位置采用随机打乱方式生成;
  • 关卡生成时保证存在可通关顺序;
  • 尽量避免同一直线上连续出现多个同向箭头;
  • 不同关卡具有不同的阻挡关系和难度。

3. 计时、评分与存档

游戏开始后会启动独立计时器。计时器使用单调时钟记录游戏时间,不会因为箭头飞行动画、碰撞反馈或其他界面动画而暂停。

每关通关后,根据关卡难度、箭头数量、完成时间、失误次数和是否超过建议用时计算评分,评分结果显示在通关结算界面中。

主界面包含“开始游戏”“继续游戏”和“选择关卡”。游戏过程中可以手动保存进度,关闭窗口时也会自动保存。保存内容包括当前关卡、已经消除的箭头、剩余机会和操作步数。

4. AI 助手

游戏右侧加入了可点击的 AI 角色,初始状态会显示:

需要一键求解的话就点我哦

点击 AI 角色后,AI 会自动寻找当前可以直接移出的箭头,并按照步骤逐个执行。成功完成当前关卡后,会显示:

成功了,不愧是我~

AI 求解过程中,玩家不能同时操作棋盘,避免自动操作和手动操作互相冲突。

5. 音效、动画和界面

游戏加入了箭头飞出、箭头碰撞、关卡通关和关卡失败音效,并加入通关、失败以及 AI 角色的 GIF 动画。AI 角色和结算页面中的动画小人表情包均按照上方的素材来源说明进行署名。

界面使用中文和暖色马卡龙风格,按钮采用圆角设计,支持窗口最大化和 F11 全屏。全屏时界面保持等比例缩放并居中,退出全屏后恢复普通窗口布局。


五、实现思路

1. 箭头数据结构

每个箭头保存所在行、所在列、方向和是否仍然存在等信息:

Arrow(
    row,        # 所在行
    col,        # 所在列
    direction,  # 箭头方向
    active      # 是否仍然存在
)

项目实际使用 dataclass 表示箭头:

from dataclasses import dataclass


@dataclass
class Arrow:
    row: int
    col: int
    direction: str
    active: bool = True


def make_arrows(values):
    return [Arrow(row, col, direction) for row, col, direction in values]

active 表示箭头是否仍然存在。箭头飞出后只更新这个状态并删除对应的画布元素,不需要重新生成整张棋盘,这也方便保存游戏进度。

方向使用以下字符表示:

U  # 上
D  # 下
L  # 左
R  # 右

在程序中,字符会进一步映射为行列坐标变化量和中文名称:

DIRECTIONS = {
    "U": (-1, 0, "上"),
    "D": (1, 0, "下"),
    "L": (0, -1, "左"),
    "R": (0, 1, "右"),
}

例如,"R": (0, 1, "右") 表示行号不变、列号加一。路径扫描和画布动画都使用这个统一映射,避免出现逻辑方向和界面方向不一致的问题。

2. 箭头路径判断

程序根据箭头方向逐格扫描路径。例如,向上的箭头检查所在列上方的格子,向右的箭头检查所在行右侧的格子。

从当前箭头的下一格开始,沿着箭头方向逐格检查。
如果遇到仍然存在的箭头,则当前箭头被阻挡。
如果直到棋盘边界都没有遇到箭头,则当前箭头可以飞出棋盘。

该方法可以处理水平箭头、垂直箭头、棋盘边缘箭头以及箭头消除后产生的新路径。

实际的阻挡检测函数如下:

def find_blocker_in_state(arrows, rows, cols, index):
    current = arrows[index]
    dr, dc, _ = DIRECTIONS[current.direction]
    row, col = current.row + dr, current.col + dc

    while 0 <= row < rows and 0 <= col < cols:
        for other_index, other in enumerate(arrows):
            if (
                other_index != index
                and other.active
                and other.row == row
                and other.col == col
            ):
                return other_index
        row, col = row + dr, col + dc

    return None

返回其他箭头的下标表示发生阻挡,返回 None 表示路径直到棋盘边界都没有遇到活动箭头。判断逻辑只依赖箭头坐标和方向,因此不会和某一关的固定布局绑定。

3. 点击箭头后的处理

没有阻挡时:

  1. 播放箭头飞出动画和音效;
  2. 将箭头设置为不可用;
  3. 更新剩余箭头数量;
  4. 判断是否已经通关。

存在阻挡时:

  1. 保留当前箭头;
  2. 显示箭头前方的阻挡路径和碰撞位置;
  3. 播放碰撞音效;
  4. 减少一次剩余机会;
  5. 判断是否已经失败。

画布点击事件会先处理状态保护,再统一调用阻挡判断:

def handle_click(self, event):
    if self.animating or self.ai_solving:
        return

    clicked = self.find_arrow_at(event.x, event.y)
    if clicked is None:
        self.update_status("请点击棋盘上的箭头。")
        return

    self.moves += 1
    blocker = self.find_blocker(clicked)
    if blocker is None:
        self.fly_out(clicked)
    else:
        self.block_arrow(clicked, blocker)

动画播放期间不重复处理点击,AI 自动求解期间也不接受玩家操作,避免同一时刻有多个操作修改棋盘状态。

箭头飞出时使用 after() 分帧移动,并在动画结束后才修改 active:

def fly_out(self, index):
    self.animating = True
    play_sound("fly")
    arrow = self.arrows[index]
    dr, dc, _ = DIRECTIONS[arrow.direction]
    distance = max(
        LEVELS[self.level_index]["rows"],
        LEVELS[self.level_index]["cols"],
    ) * self.cell_size
    frames = 28

    def step(frame):
        if frame <= frames:
            eased = 1 - (1 - frame / frames) ** 3
            previous = 1 - (1 - max(0, frame - 1) / frames) ** 3
            delta = eased - previous
            for item in self.arrow_items[index]:
                self.canvas.move(item, dc * distance * delta,
                                 dr * distance * delta)
            self.root.after(16, lambda: step(frame + 1))
        else:
            for item in self.arrow_items[index]:
                self.canvas.delete(item)
            arrow.active = False
            self.animating = False
            self.after_arrow_removed()

    step(0)

这里的 16ms 约等于每秒 60 帧,eased 用于制造先慢后快再慢的缓动效果。

4. 关卡生成

第一关使用固定布局,方便玩家熟悉规则。后续关卡采用随机生成方式,但不是简单地完全随机,而是先构造一个可以被清除的顺序,再将箭头顺序打乱,形成看起来随机但实际可通关的关卡。

关卡生成时还会检查:

  • 是否存在可通关顺序;
  • 阻挡关系是否足够丰富;
  • 箭头数量是否达到当前关卡要求;
  • 是否连续出现多个同向箭头;
  • 棋盘是否过于规则或整齐。

随机布局的核心代码如下:

def build_random_level(rows, cols, count, seed, minimum_blocked):
    best = None
    for attempt in range(160):
        arrows = _random_solvable_layout(
            rows, cols, count, seed + attempt * 7919
        )
        blocked = sum(
            any(
                other_index != index
                and _arrow_reaches_cell(arrow, other.row, other.col, rows, cols)
                for other_index, other in enumerate(arrows)
            )
            for index, arrow in enumerate(arrows)
        )
        if blocked >= minimum_blocked:
            max_run, repeated_pairs = line_direction_stats(arrows)
            rank = (max(0, max_run - 2), repeated_pairs, -blocked)
            if best is None or rank < best[0]:
                best = (rank, arrows)
            if max_run <= 2:
                return arrows
    if best is not None:
        return best[1]
    raise RuntimeError("随机关卡未达到目标难度")

minimum_blocked 控制关卡至少具有多少阻挡关系,line_direction_stats() 用来限制同一直线上连续同向箭头的数量。程序会尝试多个随机种子,在可通关性、阻挡数量和视觉随机性之间选择布局。

5. AI 求解

AI 求解过程如下:

1. 扫描当前所有仍然存在的箭头;
2. 找出前方没有阻挡的箭头;
3. 高亮当前准备执行的箭头;
4. 执行箭头飞出;
5. 等待动画结束;
6. 重新扫描棋盘;
7. 重复上述过程,直到棋盘清空。

AI 不是直接修改最终结果,而是模拟玩家逐步点击箭头,因此可以展示完整的求解过程。

AI 每次只寻找一个当前可移出的箭头,执行完成后重新扫描棋盘:

def ai_solve_step(self):
    self.ai_after_id = None
    if not self.ai_solving or self.current_view != "game" or self.animating:
        return

    index = next(
        (
            i for i, arrow in enumerate(self.arrows)
            if arrow.active and self.find_blocker(i) is None
        ),
        None,
    )
    if index is None:
        self.ai_solving = False
        self.ai_status.set("当前布局没有可行步骤")
        self.set_ai_state("idle")
        return

    self.ai_status.set(f"第 {self.ai_step + 1} 步:找到可移出的箭头")
    self.root.after(
        260,
        lambda i=index, sid=self.session_id: self.ai_execute_step(i, sid),
    )

session_id 用来识别旧的延迟回调。玩家返回主界面或重新开始关卡后,旧回调即使稍后触发,也不会继续操作新棋盘。

6. 计时、评分与存档

计时器使用 time.monotonic() 计算真实经过的时间,与箭头动画的回调相互独立:

def current_elapsed_seconds(self):
    if self.timer_started_at is None:
        return int(self.elapsed_seconds)
    return max(0, int(time.monotonic() - self.timer_started_at))


# 进入关卡时
self.timer_started_at = time.monotonic()

# 从存档恢复时
self.timer_started_at = time.monotonic() - self.elapsed_seconds

保存进度时,程序把当前关卡、活动箭头、剩余机会、步数、误点次数和用时写入 JSON:

data = {
    "level_index": self.level_index,
    "active": [arrow.active for arrow in self.arrows],
    "mistakes_left": self.mistakes_left,
    "moves": self.moves,
    "blocked_attempts": self.blocked_attempts,
    "elapsed_seconds": self.current_elapsed_seconds(),
    "level_scores": {
        str(key): value for key, value in self.level_scores.items()
    },
}

with open(self.save_path, "w", encoding="utf-8") as handle:
    json.dump(data, handle, ensure_ascii=False, indent=2)

评分同时考虑关卡难度、箭头数量、用时和误点次数:

def calculate_level_score(level_index, elapsed_seconds, blocked_attempts):
    level = LEVELS[level_index]
    density_bonus = min(180, len(level["arrows"]) * 3)
    difficulty_bonus = level["difficulty"] * 45
    speed_bonus = min(300, max(0, level["par_time"] - elapsed_seconds) * 2)
    overtime_penalty = max(0, elapsed_seconds - level["par_time"]) * 3
    mistake_penalty = blocked_attempts * (35 + level["difficulty"] * 8)
    score = 420 + density_bonus + difficulty_bonus + speed_bonus
    score -= overtime_penalty + mistake_penalty
    return max(0, min(1000, round(score)))

7. GIF 动画播放

GIF 使用 Pillow 逐帧读取,再由 Tkinter 的 after() 按每帧持续时间循环播放:

def load_ai_frames(self, state):
    path = os.path.join(ASSET_DIR, AI_IMAGE_NAMES[state])
    with Image.open(path) as source:
        frames = []
        delays = []
        for index in range(getattr(source, "n_frames", 1)):
            source.seek(index)
            frames.append(source.convert("RGBA").copy())
            delays.append(max(40, int(source.info.get("duration", 100) or 100)))
        self.ai_frame_delays = delays
        return frames


def advance_ai_frame(self):
    frame_index = self.ai_frame_index % len(self.ai_frames)
    self.ai_image_label.configure(image=self.ai_frames[frame_index])
    self.ai_frame_index += 1
    delay = self.ai_frame_delays[frame_index]
    self.ai_frame_job = self.root.after(delay, self.advance_ai_frame)

切换 AI 状态前会先取消上一状态的定时回调,离开游戏界面或关闭窗口时也会清理回调,避免多个动画任务同时修改同一个控件。


六、AIGC 使用过程

本项目使用 ChatGPT/Codex 作为开发辅助工具,但最终代码由我进行整理、运行、测试和修改。AI 主要用于分析问题、提供实现思路、协助编写代码和定位错误,项目功能不是直接复制生成结果。

第一次 AI 协作:设计游戏核心逻辑

我的需求

请帮我设计一个箭头消除类小游戏。箭头只能沿自身方向移动,如果前方存在其他箭头则不能移动,并且要支持碰撞判断和关卡通关。

AI 提供的思路

AI 建议使用行列坐标保存箭头位置,并根据方向向量逐格扫描箭头路径。通过判断路径上是否存在其他活动箭头,确定当前箭头是否可以移动。

我的修改和使用

我根据这个思路设计了 Arrow 数据结构和阻挡检测函数,并补充了棋盘边界判断、箭头状态更新、失误次数处理、通关失败判断和飞出动画。

第二次 AI 协作:优化随机生成关卡

我的需求

现在的关卡太规则,想让箭头随机乱序,同时必须保证关卡可以正常通关,并且不要连续出现太多同向箭头。

AI 提供的思路

AI 建议先构造一个可以被清除的箭头顺序,再将箭头位置和顺序打乱,并统计每一行和每一列的方向连续情况。

我的修改和使用

我在此基础上实现了随机生成关卡的逻辑,并检查所有箭头是否存在可通关顺序、后续关卡是否增加箭头数量、阻挡关系是否足够丰富,以及是否出现连续同向箭头。之后又编写测试检查每个关卡是否可以完成。

第三次 AI 协作:优化界面和全屏布局

我的需求

游戏界面存在按钮拥挤、文字被遮挡、全屏后内容缩小且不居中等问题,希望改成暖色马卡龙风格,并保证全屏时等比例放大。

AI 提供的思路

AI 建议将界面分为主界面、关卡选择界面、游戏界面和结算界面,并根据窗口大小计算统一缩放比例。

我的修改和使用

我根据实际运行效果多次调整了主界面按钮间距、棋盘位置、右侧 AI 区域、顶部信息栏、圆角按钮尺寸、全屏居中方式以及普通窗口和全屏窗口的布局区别。

第四次 AI 协作:加入 AI 角色和动图

我的需求

希望游戏右侧有一个可以点击的 AI 角色,点击后自动求解,求解成功后播放成功动画,再进入通关界面。

AI 提供的思路

AI 建议使用 Tkinter 的 after() 方法逐步执行 AI 操作,并使用 Pillow 读取 GIF 的每一帧,循环播放不同状态的动画。

我的修改和使用

我加入了等待、求解和求解成功三种 AI 状态,并处理了点击 AI 触发求解、求解期间暂停普通操作、动画与结算页面分开播放、窗口关闭时取消延迟任务以及防止重复点击等问题。


七、测试过程与结果

测试包括手工测试和自动化测试。

1. 手工测试

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 箭头正常飞出并消失 通过
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减少 1 箭头保留,剩余机会减少 通过
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 箭头正常消失 通过
T04 消除本关全部箭头 显示通关界面并进入下一关 正常显示通关界面 通过
T05 失误次数耗尽 显示失败界面并允许重新开始 正常显示失败界面 通过
T06 游戏进行中重新开始 箭头布局和失误次数恢复 当前关卡重新加载 通过

2. 其他测试内容

另外还测试了计时器独立运行、保存进度、关闭游戏自动保存、继续游戏、选择关卡、AI 逐步求解、通关和失败 GIF、音效、全屏切换、后续关卡难度以及四种方向判断。

3. 自动化测试

项目中加入了两个测试文件:

test_game_logic.py
test_ui_stability.py

运行命令:

python -m unittest -v

当前版本共运行 20 项测试,结果如下:

Ran 20 tests

OK

测试覆盖了箭头方向判断、阻挡检测、边界处理、关卡可通关性、关卡难度递增、随机关卡生成、评分计算、AI 资源文件、全屏布局、普通窗口布局和界面稳定性。

自动化测试的调用方式如下:

class ArrowGameLogicTests(unittest.TestCase):
    def test_all_levels_have_a_clearable_order(self):
        for level in LEVELS:
            arrows = [
                Arrow(arrow.row, arrow.col, arrow.direction)
                for arrow in level["arrows"]
            ]
            order = find_clear_order(
                arrows, level["rows"], level["cols"]
            )
            self.assertIsNotNone(order, level["name"])
            self.assertEqual(len(order), len(arrows))

这项测试会对每个关卡寻找一条完整清除顺序。如果找不到可移出的箭头,说明关卡存在死锁,测试会直接失败。相比只手动试玩一两个关卡,这种测试可以在调整关卡生成器后快速发现可玩性问题。


八、PSP 表格

以下是本次项目开发过程中的时间记录。由于部分时间是边学习边完成的,实际时间会受到调试和环境配置影响。

任务 预计耗时(小时) 实际耗时(小时) 差异(小时)
需求分析与游戏设计 3 4 +1
Python 与 Tkinter 学习 4 5 +1
游戏基本界面实现 4 5 +1
箭头路径与碰撞逻辑实现 5 6 +1
关卡设计与随机生成 4 6 +2
动画、音效和结算界面 3 5 +2
AI 助手功能实现 3 4 +1
全屏布局和界面优化 4 6 +2
测试与问题修复 4 6 +2
README 与博客撰写 2 3 +1
合计 36 50 +14

实际耗时比预计时间长,主要原因是第一次使用 Tkinter 制作完整图形界面,窗口缩放和全屏适配需要反复调试,动画音效存在异步调用问题,关卡需要同时满足随机性和可通关性,AI 求解还需要处理自动操作与普通操作的冲突,打包 EXE 后也需要重新测试资源文件。


九、项目仓库和运行方式

项目 GitHub 仓库:

https://github.com/wizneko/arrow_by_arrow

项目目录主要包括:

arrow_escape_game/
├── main.py
├── assets/
│   ├── result_win.gif
│   ├── result_fail.gif
│   ├── ai_idle.gif
│   ├── ai_solving.gif
│   ├── ai_success.gif
│   ├── arrow_fly.wav
│   ├── arrow_collision.wav
│   ├── level_win.wav
│   └── level_fail.wav
├── screenshots/
│   ├── blog_start_screen.png
│   ├── blog_gameplay.gif
│   ├── blog_level_clear.gif
│   ├── blog_level_fail.gif
│   └── gameplay.png
├── dist/
│   └── ArrowEscape.exe
├── test_game_logic.py
├── test_ui_stability.py
├── README.md
└── .gitignore

直接运行

进入项目的 dist 文件夹,双击:

ArrowEscape.exe

即可启动游戏,不需要另外安装 Python。

运行源代码

安装 Python 和 Pillow 后执行:

python -m pip install Pillow
python main.py

十、心得体会

这次作业让我对一个完整软件项目的开发过程有了更具体的认识。刚开始时,我主要关注如何让箭头移动和消除,但真正实现后发现,一个完整的小游戏还需要考虑界面布局、异常情况、关卡设计、计时、存档、音效、动画、测试和打包等很多方面。

开发过程中,最困难的部分是箭头之间的阻挡关系和关卡生成。简单地随机生成箭头,很容易出现无法通关的情况。因此,我需要先考虑可通关顺序,再将箭头打乱,最后通过测试检查生成的关卡是否真的可以完成。这让我认识到,随机并不等于完全没有规则,好的随机关卡需要在随机性和可玩性之间取得平衡。

界面布局也是花费时间比较多的部分。最开始的界面存在按钮拥挤、文字被遮挡、全屏后内容缩小等问题。通过多次调整,我逐渐使用统一缩放比例和居中布局解决这些问题,也更加理解了图形界面开发中尺寸管理的重要性。

AIGC 对本次开发提供了较大帮助。它可以帮助我快速理解 Tkinter、路径判断、动画播放和测试设计,也能在遇到错误时提供排查方向。但是,AI 给出的代码不能直接保证适合当前项目,仍然需要我自己运行、观察和修改。例如,AI 提供的某些界面布局在实际窗口中会出现文字遮挡,关卡生成算法也需要结合实际棋盘效果进行调整。因此,我认为 AIGC 更适合作为开发助手,而不能替代程序测试和开发者自己的判断。

通过这次作业,我掌握了以下内容:

  • 使用 Python 和 Tkinter 制作桌面图形界面;
  • 使用坐标和方向向量实现路径判断;
  • 使用 Pillow 播放 GIF 动画;
  • 使用 after() 实现定时动画和延迟操作;
  • 使用 JSON 保存游戏进度;
  • 使用随机算法生成可通关关卡;
  • 使用 unittest 编写自动化测试;
  • 使用 PyInstaller 打包 Windows 可执行文件;
  • 使用 GitHub 管理和发布项目。

后续如果继续完善这个项目,我希望增加更多关卡、更加复杂的关卡生成算法、排行榜、撤销操作、更多角色动画以及更完善的音效系统。同时,也希望进一步学习游戏开发框架和软件工程中的项目管理、版本控制与自动化测试方法。

posted @ 2026-09-19 11:14  Wizneko  阅读(28)  评论(0)    收藏  举报