软件工程第二次作业

项目内容 内容
这个作业属于哪个课程 H202601 软件工程与软件工程实践
这个作业要求在哪里 2026 秋软件工程个人作业(第二次)
这个作业的目标 使用 Python 和 AIGC 完成“一箭又一箭”小游戏()
学号 102401509
GitHub 仓库 zhewanyiershinicheng/arrow_game

第一部分:使用AIGC进行游戏开发

借助的AIGC AI 完成的任务 效果如何 人工修改
Mimo Desktop 整体设计 实现了基本的功能 初版不做修改
Mimo Desktop 二次设计 修改bug 存在部分逻辑问题,手动修改
Mimo Desktop 界面设计 更加美观 去除界面中不必要的元素,优化文本

首次对话

  1. 将任务的所有要求都发给AI,让其自行生成,不做其他任何操作
    image
    虽然AI此处说有安装python,但是实际我的电脑上并未安装,其使用的是其他软件文件中自带的python,实际运行测试的时候是用后来全局安装的,依赖需重新装(这里使用的是Pygame)。

  2. 实际运行效果
    image
    经过游玩测试,核心玩法已经实现,但是有下述问题:

  • 失误次数总数会随着关卡的增加而减少。
  • 失误次数剩余为1时,若失误就会直接结束游戏,并且没有触发错误时的箭头动效。
  • 当前的正确答案,即当前可以移动的箭头边框带有绿色标识,相当于直接告诉答案。
  • 存在不明意义关卡试玩验证(即答案),说明关卡并不是随机生成的。
    image
  • 排版存在错位,文字图像存在相互覆盖的问题

二次修改

  1. 将之前的问题反馈给AI修复
    image
  2. 有部分问题依旧存在,故再次修改
    image
  3. 成功修复后
    image
    同关不同图:
    image
    image
    最后计数的问题手动修改,就不浪费token了。

界面加强

这里使用harness中自带的前端设计skill,看看效果:
image
有部分提示词相关的文本也直接写进去了,这里最后由人工统一修改。

到目前为止的版本: 点此跳转

扩展功能

作业中还有如下的要求:

  1. 更多关卡或关卡选择;
  2. 得分、计时或星级评价;
  3. 提示功能;
  4. 撤销上一步;
  5. 保存游戏进度;
  6. 随机生成可通关的关卡;
  7. AI 自动求解当前关卡;
  8. 更流畅的动画和音效;
  9. 将程序打包为可执行文件。

这里我们就不全部都实现了,这种游戏不用提示也能玩明白(应该吧)
目前关卡随机,动画效果基本实现了,撤销上一步的意义也不大,所以就着重在1,2,9
但是为了方便起见,我们先将这个基础版本的打包,使用pyinstaller:

pyinstaller -w -F arrow_game.py
//游戏文件只有arrow_game.py,其他文件都只是AI coding时用于测试的脚本和图片,你可以在v1.0中下载到它们。

默认仅支持windows
接下来我们增加音效和得分、计时和星级评价功能
这个过程有和agent对话博弈多轮,这里不做展出。
界面展示和测试结果详见文章结尾链接视频。

最终版本: 点此跳转

第二部分:核心代码

这里我们不关注动画是如何实现的,而在意游戏本身的内容。

Q1: 怎么实现点击

1.1 事件入口:on_mouse_down

主循环收到 MOUSEBUTTONDOWN 后调用:

# arrow_game.py (主循环)
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
    on_mouse_down(game, event.pos)
def on_mouse_down(game: Game, pos: Tuple[int, int]) -> None:
    # ① 先判按钮(开始 / 重开 / 菜单等)
    for btn in game.buttons:
        if btn.rect.collidepoint(pos):
            btn.press = 1.0
            play_sound("click")
            request_action(game, btn.action)
            return
    # ② 仅在游玩态、非结算中、非转场中,才处理棋盘点击
    if game.state == State.PLAYING and not game.pending_fail and not game.pending_win and game.trans_dir == 0:
        for a in game.arrows:
            if not a.alive:
                continue
            if cell_rect(game, a.row, a.col).collidepoint(pos):
                click_arrow(game, a.row, a.col)
                break

要点:

  • 先按钮后棋盘,避免点按钮误触箭头
  • 只处理 alive 的箭头
  • pending_win / pending_fail / 转场中忽略点击,防止连点打乱结算

1.2 坐标映射:cell_rect

鼠标是像素坐标 (x, y),箭头逻辑坐标是 (row, col):

def cell_rect(game: Game, row: int, col: int) -> pygame.Rect:
    ox, oy = game.board_origin
    return pygame.Rect(ox + col * CELL, oy + row * CELL, CELL, CELL)
  • 棋盘原点 (ox, oy) 由 layout_board 计算
  • col * CELL 对应 x,row * CELL 对应 y
  • rect.collidepoint(pos) 判断是否点在该格内

1.3 路径判定:is_blocked

点击后判断「前方到边界之间是否还有其他箭头」:

def is_blocked(arrows: List[Arrow], target: Arrow, rows: int, cols: int) -> bool:
    dr, dc = DIR_DELTA[target.direction]
    r, c = target.row + dr, target.col + dc
    alive = {(a.row, a.col) for a in arrows if a.alive and a is not target}
    while 0 <= r < rows and 0 <= c < cols:
        if (r, c) in alive:
            return True
        r += dr
        c += dc
    return False

方向增量:

DIR_DELTA = {0: (-1, 0), 1: (1, 0), 2: (0, -1), 3: (0, 1)}
# 上 / 下 / 左 / 右

从目标前一格沿方向扫描到边界;命中其他存活箭头 → 阻挡。

1.4 逻辑结算:click_arrow

def click_arrow(game: Game, row: int, col: int) -> None:
    if game.state != State.PLAYING or game.pending_fail or game.pending_win:
        return

    target = None
    for a in game.arrows:
        if a.alive and a.row == row and a.col == col:
            target = a
            break
    if target is None or target.anim == "bump":
        return  # 碰撞动画期间忽略重复点击

    if not is_blocked(game.arrows, target, game.rows, game.cols):
        # —— 无阻挡:飞出并消除 ——
        target.alive = False
        target.anim = "flying"
        target.anim_t = 0.0
        # …飘字 / 粒子 / 音效…
        if not game.alive_arrows():
            game.pending_win = True  # 动画播完后再切通关界面
        return

    # —— 有阻挡:碰撞反馈 + 失误 ——
    target.anim = "bump"
    target.flash = 1.0
    game.shake = SHAKE_AMPLITUDE
    # …

    if game.misses_left <= 0:
        # 次数已是 0 时再错 → 失败
        game.pending_fail = True
        game.run_total_misses += 1
        game.run_had_fail = True
    else:
        # 还有次数:扣 1,继续玩(减到 0 不立刻失败)
        game.misses_left -= 1
        game.run_total_misses += 1

流程示意:

鼠标点击
   │
   ├─ 按钮? ──是──► request_action(菜单/重开等)
   │
   └─ 棋盘格子? ──► click_arrow
                        │
                        ├─ is_blocked == False
                        │     alive=False + flying
                        │     清空? → pending_win
                        │
                        └─ is_blocked == True
                              bump 动画
                              misses>0: misses -= 1
                              misses==0: pending_fail

1.5 为什么用 pending_win / pending_fail

不在点击瞬间切 WIN/FAIL,而是:

  1. 先改状态、播 flying / bump
  2. update_animations 里等动画结束
  3. 再 rebuild_buttons 并切结果界面

这样碰撞动效不会被结果弹层立刻盖住。

Q2:怎么实现随机生成地图

目标:每次进关随机布局,难度随箭头数量上升,且保证有解。

思路:逆序构造 放箭头 + 贪心求解器 校验。

2.1 难度:箭头数量

def arrow_count_for_level(index: int, hard: bool = False) -> int:
    if hard:
        return GRID_N * GRID_N      # 高难:5×5 = 25,铺满
    return 5 + index * 2            # 剧情:5, 7, 9, 11

2.2 求解器:solve_level

反复消除「当前前方无阻挡」的箭头,直到清空或卡住:

def solve_level(grid: List[str]) -> Optional[List[Tuple[int, int, int]]]:
    rows, cols, arrows = parse_level(grid)
    objs = [_A(a.row, a.col, a.direction) for a in arrows]
    order = []
    progress = True
    while any(o.alive for o in objs):
        if not progress:
            return None          # 卡住 → 无解
        progress = False
        for o in objs:
            if not o.alive:
                continue
            if not is_blocked(objs, o, rows, cols):
                o.alive = False
                order.append((o.row, o.col, o.direction))
                progress = True
                break
    return order                 # 一条可行消除顺序

说明:消除只会减少阻挡,不会新增,因此「能消就消」在本规则下是完备的。

2.3 逆序构造:_try_reverse_construct

按消除顺序的逆放箭头:

构造顺序 对应正向消除 约束
先放 最后消 空盘上可任意放
后放 先消 不得被已放(更晚消)的箭头挡住
def _try_reverse_construct(rows, cols, n):
    occupied = set()
    placed = []                    # 放置顺序 = 消除顺序的逆
    all_cells = [(r, c) for r in range(rows) for c in range(cols)]

    for _ in range(n):
        free_cells = [(r, c) for r, c in all_cells if (r, c) not in occupied]
        random.shuffle(free_cells)
        free_cells = free_cells[: min(20, len(free_cells))]
        dirs = [0, 1, 2, 3]
        random.shuffle(dirs)

        best = []
        for r, c in free_cells:
            for d in dirs:
                trial = Arrow(row=r, col=c, direction=d)
                # 关键:不能被「已放置、更晚消除」的箭头挡住
                if is_blocked(placed, trial, rows, cols):
                    continue
                score = 0
                for p in placed:
                    if _path_contains(trial, p, rows, cols):
                        score += 2   # 本箭头会挡住已放的 → 正向更难
                    if _path_contains(p, trial, rows, cols):
                        score += 1
                best.append((score, r, c, d))

        if not best:
            return None            # 当前候选都不合法
        best.sort(key=lambda x: x[0], reverse=True)
        top = best[0][0]
        pool = [item for item in best if item[0] >= max(0, top - 1)][:8]
        _, r, c, d = random.choice(pool)
        placed.append(Arrow(row=r, col=c, direction=d))
        occupied.add((r, c))

    return grid_from_arrows(placed, rows, cols)

_path_contains 判断「blocker 是否在 target 的前进路径上」,用来给能制造阻挡的落点加分,让开局更有解谜感,而不是全图都能点。

2.4 入口:generate_solvable_grid

def generate_solvable_grid(level_index, rows=GRID_N, cols=GRID_N, hard=False):
    target = min(arrow_count_for_level(level_index, hard=hard), rows * cols)
    if hard:
        tries_list = [(target, 30), (target - 1, 12), (20, 10), (16, 8)]
    else:
        tries_list = [(target, 24), (target - 1, 12), (max(4, target - 2), 8)]

    for n, tries in tries_list:
        if n < 4:
            continue
        for _ in range(tries):
            grid = _try_reverse_construct(rows, cols, n)
            if not grid:
                continue
            order = solve_level(grid)
            total = sum(ch in CHAR_DIR for row in grid for ch in row)
            if order and len(order) == total:
                return grid, total   # 构造成功且求解器认可
    fallback = ["R.R..", "..U..", ".....", "L...D", "....."]
    return fallback, sum(...)

流程:

确定目标箭头数 n
      │
      ▼
逆序构造随机图(可重试多次)
      │
      ▼
solve_level 校验
      │
      ├─ 步数 == n  → 返回该图(保证可通关)
      │
      └─ 无解/失败  → 降低 n 或换尝试次数
      │
      ▼
仍失败 → 兜底固定可解图

2.5 关卡载入:load_level

def load_level(game: Game, index: int) -> None:
    hard = bool(getattr(game, "hard_mode", False))
    grid, arrow_n = generate_solvable_grid(index, hard=hard)
    rows, cols, arrows = parse_level(grid)
    game.arrows = arrows
    game.level_grid = grid
    game.arrow_count = arrow_n
    # 重置失误、计时相关字段、入场动画等
    ...

每次进入关卡、点「重新开始」都会重新调用 generate_solvable_grid,因此每局地图都可能不同。

2.6 为何这样能「随机且有解」

手段 作用
逆序放置 正向按放置逆序点击时,每一步前方都没有「还没消掉的箭头」
is_blocked(placed, trial) 保证新放的箭头不被更晚消除的箭头挡住
评分偏爱阻挡 让初始局面更有连锁,而不是全部一次点掉
solve_level 校验 构造异常时丢弃重试,避免互锁死局
箭头数 n 控制难度;高难 n=25 铺满棋盘

第三部分:其它要求

测试表格

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

PSP 表格

任务 预估耗时(小时) 实际耗时(小时) 差异(小时)
需求分析与游戏设计 1 1 0
Python 与图形库学习 1 1 0
游戏界面实现 2 4 2
路径与碰撞逻辑实现 1 0.2 0.8
关卡设计 1 1 0
AIGC 辅助开发 10 15 5
测试与修改 15 12 3
README 与博客撰写 2 2 0
合计 33 36.2 10.8

仓库地址
https://github.com/zhewanyiershinicheng/arrow_game

实机演示
推荐自行下载游玩,保证没有后门,请放心运行
演示视频下载:https://pan.cyld.top/@s/U2FSGbxv

posted @ 2026-09-21 11:43  丛雨绫刀  阅读(19)  评论(0)    收藏  举报