asdllx

导航

软件工程第二次作业

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

一、项目展示

「一箭又一箭」是一款点击式箭头解谜小游戏。6×6 的棋盘上摆着若干方向不同的箭头,玩家点击一个箭头:如果它前方这一行或这一列上没有别的箭头,它就沿自己的方向飞出棋盘并被消除;如果被挡住了,箭头不会消失,同时扣掉一次失误机会。把棋盘清空就通关,失误次数用完就失败。

目前一共五关,通过当前关卡才会解锁下一关。

demo

二、项目介绍

游戏规则

  1. 开始界面有四个入口:开始游戏(全新开始,会先弹确认框清空进度)、继续游戏(读存档接着上次的关卡)、选择关卡、随机关卡;
  2. 用鼠标点击棋盘里的箭头,点到空格子没有反应;
  3. 前方没有阻挡的箭头会沿方向飞出棋盘,飞行带尾迹;
  4. 前方有阻挡的箭头不会消失,会变红晃动,出现扩散圆环、粒子、音效和“前方有阻挡”提示,并消耗一次失误;
  5. 每关三次失误机会,用完本关失败,可以重新开始本关;
  6. 清空本关全部箭头即通关,通过后解锁下一关,通关界面按剩余失误和用时评 1~3 星;
  7. 键盘快捷键:H 提示、U 撤销、S 自动求解、R 重新开始、F12 截图、ESC 返回上一级。

界面设计

界面是深色渐变背景加圆角卡片:顶部状态卡片显示关卡进度、用时、提示次数和剩余失误方块;棋盘用深浅交替的圆角格子;按钮是渐变色块,鼠标悬停会变亮;通关按星级评价,失败画一个叉;选关界面把五关排成卡片,没解锁的关卡画成暗色带一把锁。

主要功能与特色

  • 基础玩法:四方向箭头、路径阻挡判断、飞出与碰撞反馈、失误次数、三个以上可通关关卡(实际做了五关)、通关/失败界面、重新开始;
  • 扩展功能:选关与逐关解锁、计时与星级评价、进度存档(progress.json)、提示(H)、撤销(U)、随机关卡、自动求解(S);
  • 特色:不依赖任何外部素材——箭头、星星、锁、图标都用多边形绘制,音效由代码现场合成;

运行方法

python -m pip install -r requirements.txt
python main.py

依赖只有 pygame-ce 一个包,Python 3.12 及以上都可以(本项目在 3.14.7 上测试)。

三、实现思路

下面把程序拆成几块,每一块先说清楚要解决什么问题,再给关键代码和解释。完整代码在仓库的 main.py 里。

3.1 箭头、方向与关卡怎么表示

棋盘是 6 行 6 列,箭头存在一个字典里:键是 (行, 列) 坐标,值是方向字母 U/D/L/R。选字典是因为判断阻挡时要反复问“这个格子上有没有箭头”,字典查找接近 O(1);另外整关复制只要 dict(arrows),撤销和重开都直接用得上。

arrows = {(0, 3): "U", (1, 0): "L", (2, 0): "D", ...}

def direction_vector(direction):
    """把方向字母换成行列变化量。"""
    if direction == "U": return -1, 0      # 向上=行号减 1
    if direction == "D": return 1, 0
    if direction == "L": return 0, -1
    if direction == "R": return 0, 1
    return 0, 0

所有跟方向有关的计算都过 direction_vector 这一层,画箭头、算路径、做动画复用同一份映射,不会出现方向定义不一致的问题。关卡就是一组这样的字典:

LEVELS = [
    {(0, 3): "U", (1, 0): "L", ...},     # 第 1 关,12 个箭头
    ...
]

游戏界面用四个状态区分:START(开始)、SELECT(选关)、PLAYING(游戏中)、RESULT(通关或失败)。主循环每帧先处理输入,再按当前状态分发绘制:

if game_state == START:      draw_start_screen()
elif game_state == SELECT:   draw_select_screen()
elif game_state == PLAYING:  draw_playing_screen()
elif game_state == RESULT:   draw_result_screen()

这样四个界面互不干扰,后来加“选关界面”时只多了一个分支和一个绘制函数。

3.2 路径检测:一个箭头能不能飞出去

这是整个游戏最核心的判断。做法很朴素:从箭头所在格子出发,沿它的方向一格一格往前看,途中碰到别的箭头就说明被挡住;一直走到棋盘外面都没碰到,就说明能飞出去。

def is_blocked_on(board, position):
    row, col = position
    row_change, col_change = direction_vector(board[position])

    next_row = row + row_change        # 往方向上走一格
    next_col = col + col_change

    while 0 <= next_row < ROWS and 0 <= next_col < COLS:
        if (next_row, next_col) in board:   # 这一格有箭头 → 被挡住
            return True
        next_row += row_change              # 再往前一格,继续检查
        next_col += col_change

    return False                       # 一直走到出界都没拦到 → 能飞出去

拿两个箭头把循环走一遍

例一:(2,2) 朝右的箭头,会被挡住。

  1. row, col = 2, 2,方向是 R,direction_vector("R") 返回 (0, 1),意思是“行不变、列 +1”;
  2. 第一次循环:next_row, next_col = (2, 3),在棋盘内,查 (2,3) in board → 没有,继续;
  3. 第二次循环:(2, 4),也在棋盘内,查 (2,4) → 有箭头,于是直接 return True,判定被挡住;

例二:(4,5) 朝右的箭头,能飞出去。

  1. 方向 R 同样是 (0, 1),(4,5) 已经是最右一列;
  2. 第一次循环:算出 next_col = 6,此时 0 <= 6 < 6 不成立,while 条件直接为假,循环一次都不执行;
  3. 走到最后一行 return False,判定能飞出去。

这就是这段代码最巧妙的地方:边界情况不用单独写分支——贴着边朝外指的箭头,第一次算坐标就越界了,循环体压根不执行,自然就是“没被挡住”。我一开始还专门加了个 if 出界 then 可以飞 的判断,反而容易和中间的情况写重、写漏。

为什么要传一个 board 参数?

函数的第一行不是直接读全局的 arrows,而是接收一个棋盘字典。这是故意的:游戏里判断“我现在能不能点这个箭头”用的是当前棋盘,但关卡校验脚本和自动求解需要问的是“假设某几个箭头已经被点掉了,剩下的还怎么走”。参数化之后,同一份逻辑三处复用:

  • 玩家点击时:is_blocked(position) 决定是“扣一次失误”还是“飞出去”;
  • 关卡校验:判断一关里有没有解不开的死结;
  • 自动求解和提示:在推演出来的棋盘上继续判断下一步能点谁。

三处共用一份逻辑,所以只要你手动点得出的顺序是合法的,脚本算出来的顺序、自动演示点的顺序就一定也是合法的,不会出现“脚本说能过、游戏里却点不动”的不一致。

代价与边界测试

单次判断最多扫过 6 格(棋盘一列/一行的长度),一次点击的开销完全可以忽略,所以不需要任何加速技巧。写完以后我专门把三种“边界情况”都点了一遍:贴着上边朝上的 (0,3)、贴着左边朝左的 (1,0)、贴着右边朝右的 (4,5),确认它们都能直接飞出;另外像 (3,3) 向右这种“本身不贴边、但前方整行都空”的情况也能正确判为可飞出。这条也写进了测试表。

3.3 一次点击背后的完整流程

点击处理分两层:外层 handle_mouse_click 决定“点在哪个界面、点到什么”,内层 handle_arrow_click 决定“点到哪个箭头、该怎么处理”。

def handle_arrow_click(mouse_position):
    if flying_arrow is not None or auto_running():
        return                       # 动画中、自动演示时不接受手动点击
    for position in list(arrows):
        if cell_rect(*position).collidepoint(mouse_position):
            push_history()           # ① 先记快照
            if is_blocked(position):
                hit_blocked_arrow(position)   # ② 扣失误 + 碰撞反馈
            else:
                activate_arrow(position)      # ③ 飞出去
            return

push_history() 把 箭头布局 + 失误次数 + 提示次数 压进栈,这就是撤销功能的基础——所以点错扣掉的那次失误也能撤销。两种结果分开写:

def activate_arrow(position):
    global flying_arrow, flying_start_time
    flying_arrow = (position, arrows[position])
    flying_start_time = pygame.time.get_ticks()
    play_sound(fly_sound)
    del arrows[position]             # 立刻从棋盘删除,剩下的只是演出

点中的瞬间就删除箭头,因为逻辑上它已经飞出去了:动画期间每帧用 (当前时间 - flying_start_time) / FLY_DURATION 算出进度,把箭头往它的方向平移;动画结束后检查棋盘是否为空,为空就通关。被挡住的走 hit_blocked_arrow():扣一次失误、记录碰撞位置、生成粒子、播放音效,失误扣完切到失败界面。

3.4 关卡可解性:为什么用拓扑排序而不是穷举

这个游戏最容易出的 bug 是做出通不了的关卡。规则里有个隐藏后果:同一行或同一列上有两个箭头互相指着对方时,两边都会被对方挡住,点哪边都只扣失误、棋盘不变,这一关就永久卡死。

一开始我用穷举判断:把当前棋盘当成一个状态,每步从“没被挡住的箭头”里挑一个点掉,广度优先搜索看能不能到空棋盘。方法正确,但状态数最多能到 2 的 n 次方,箭头一多就慢。

后来发现等价而更快的判据:把“谁挡住谁”看成一个有向图,有环就一定无解,没有环就一定可解。

def is_solvable(board):
    remaining, pending = dict(board), set(board)
    while pending:
        free = [p for p in pending if not is_blocked_on(remaining, p)]
        if not free:                 # 一个都点不掉 → 有环
            return False
        for p in free:               # 当前能点的全部点掉,加快收敛
            pending.discard(p)
            del remaining[p]
    return True

理由:有环时环上的箭头互相挡着,环外的操作影响不到它们,永远点不掉;无环时必然存在一个“没人挡它”的箭头(有向无环图必有入度为 0 的点),点掉它之后剩下的图仍然无环,于是能一路清空。复杂度从指数级降到多项式级,所以“随机关卡”可以放心地随机生成、判断、不合格就重来。

四、扩展功能

基础玩法跑通之后又补了六项功能,尽量每一项都做成完整可用的,而不是空壳。

扩展功能 说明
更多关卡与关卡选择 五关(12/16/20/22/24 个箭头)+ 选关界面,每张卡片显示箭头数量、星级和最佳用时
逐关解锁 通过第 N 关才解锁第 N+1 关,没解锁的卡片画成暗色带锁,点它没有反应;解锁进度也存在存档里
计时与星级评价 状态栏实时显示用时,通关按“是否零失误”“是否用时达标”评 1~3 星
保存游戏进度 星级、最佳用时、上次关卡和解锁进度写入 progress.json;开始界面区分“开始游戏(新游戏,带确认框)”和“继续游戏(读存档)”
提示与撤销 H 高亮一个可以点击的箭头;U 退回上一步(连点错的失误也能还回来)
随机关卡与自动求解 随机生成一定可通关的关卡;S 按解法自动演示通关(演示成绩不计入星级)
打包成可执行文件 pyinstaller -F -w --name 一箭又一箭 main.py,因为项目没有外部素材,一条命令即可

五、AIGC 使用过程

5.1 使用情况汇总

子任务 借助何种 AIGC 技术 AI 实现或提供了什么 效果如何 人工修改
环境配置 DeepSeek 定位到 Python 3.14 没有 pygame 预编译包、编译要下载 SDL2;建议改用 pygame-ce 换成 pygame-ce 后一次装好 修改 requirements.txt,并把排查过程写进文档
程序结构 DeepSeek 建议按状态拆分界面,每种界面一个绘制函数 代码可读性明显变好,加界面只需多一个分支 自己补上失误次数、重开、关卡切换等规则
路径检测 DeepSeek 给出沿行列方向逐格检查的算法 判断正确,边界无需单独分支 改成接收 board 参数以便三处复用,并专门测试贴边箭头
碰撞反馈 DeepSeek 给出变红、晃动、粒子、扩散圆环和正弦波合成音效的方案 反馈明显,打击感强 调整参数,音效包异常处理;后来按频谱把方波换成三角波
关卡验证 DeepSeek 指出同线互相阻挡会永久卡死,并给出穷举检查脚本 发现两关无解 调整箭头方向,脚本留在仓库,后来换成更快的拓扑判断
界面设计 DeepSeek 建议按窗口分辨率直接绘制,用渐变、圆角、阴影和矢量图形做质感 界面从“像表单程序”变成有游戏感的样子 推翻低分辨率放大的方案重做,自己补状态栏、星级、悬停效果
扩展功能 DeepSeek 建议关卡用“随机生成 + 可解性判断”筛选,撤销用快照栈、存档用 JSON、自动求解复用解法生成 五关难度递增,提示/撤销/存档/解锁/自动求解都能用 自己设计“新游戏 / 继续游戏 / 逐关解锁”的流程,并写自动检查防止文字被遮挡等问题

5.2 三次代表性过程记录

这三次都是项目里最核心、影响最大的问题:路径检测决定玩法判断对不对,关卡可解性决定关卡能不能真的通关,程序结构决定后面能不能顺利把功能加下去。

(1)路径检测:整个玩法的判断核心

  • 我提出的要求:把“箭头前方有没有别的箭头”这段判断写成代码,四个方向都要能判断,贴边的箭头也不能出错,而且希望它能被后面的关卡校验复用;
  • AI 完成的工作:给出“从箭头所在格子出发,沿它的方向一格一格往前看,走到棋盘边界为止”的思路和初版代码,用 row_change / col_change 表示四个方向;
  • 实际效果:四个方向都能判断,但初版把“贴边朝外”当成特殊情况单独处理,我用贴边箭头测试时发现这种写法容易和中间情况写重、写漏,代码也不够干净;
  • 我做的人工修改:① 把边界判断并进 while 条件 0 <= next_row < ROWS and 0 <= next_col < COLS——贴边朝外的箭头第一次算坐标就出界,循环一次都不执行,自然判为“能飞出”,不需要特例;② 把函数改成接收 board 参数,让同一份逻辑既能判断当前棋盘,也能在“假设某些箭头已经点掉”的棋盘上推演,于是点击判断、关卡校验脚本、自动求解三处共用同一份代码,不可能出现“脚本说能过、游戏里点不动”的不一致。

(2)关卡验证:AI 帮我发现两关根本通不了

  • 我提出的要求:试玩第二关时怎么点都过不去,怀疑是自己没找到正确顺序,请帮我分析;
  • AI 完成的工作:指出同一行或同一列上两个互相指着的箭头会永久互相阻挡,并给出“穷举所有点击顺序”的检查脚本;
  • 实际效果:脚本跑出来确认第 2、3 关确实无解,调整箭头方向后三关都能通关;
  • 我做的人工修改:改了四个箭头的方向,把校验脚本留在仓库里(现在的 tools/check_levels.py),后续加关卡时每改一次就跑一遍;后来还把判断换成更快的拓扑排序,同时用在校验脚本和随机关卡上。

(3)程序结构:用四个状态撑起整个界面

  • 我提出的要求:一开始所有绘制代码都堆在主循环里,越写越乱,我希望有个结构能撑住后面继续加功能(当时已经打算加选关、存档、提示这些);
  • AI 完成的工作:建议按“开始 / 选关 / 游戏中 / 结果”四个状态拆分,每种界面一个绘制函数,主循环只负责“读输入 → 按状态分发”;
  • 实际效果:代码可读性明显变好,后来加选关界面、确认弹框、结果界面的星级评价时,都只需要多写一个分支和一个绘制函数,没有牵动别的界面;
  • 我做的人工修改:① 自己补上失误次数、关卡切换、重新开始、逐关解锁这些规则;② 把按钮矩形统一提到文件顶部,让“绘制”和“点击检测”共用同一份数据,直接避免了“按钮挪了位置以后点不中”的问题——后面加四个扩展按钮时一次就摆对了;③ 另外写了一套自动检查(现在 69 项),专门盯“文字被按钮遮挡”“文字互相重叠”“箭头超出格子”这类界面问题。

六、测试结果

测试在 Windows + Python 3.14.7 下完成,逐条记录如下。

编号 测试内容 预期结果 实际结果 是否通过
T01 点击前方无阻挡的箭头 箭头飞出棋盘并消失 与预期一致,飞行过程带尾迹动画 通过
T02 点击前方有阻挡的箭头 箭头不消失,失误次数减 1 与预期一致,同时出现变红晃动、扩散圆环、粒子和音效 通过
T03 点击位于边缘且朝向棋盘外的箭头 箭头正常消失,不发生越界错误 与预期一致(is_blocked_on 的范围判断生效) 通过
T04 消除本关全部箭头 显示通关并进入下一关 显示通关界面,提示“已解锁第 N 关”,可进入下一关 通过
T05 失误次数耗尽 显示失败并允许重新开始 显示失败界面,用时停在失败那一刻,可重新开始本关 通过
T06 游戏进行中重新开始 箭头布局和失误次数恢复 与预期一致,计时也重新开始 通过
T07 点击“重新开始”以外的空白区域 不产生任何误操作 与预期一致 通过
T08 完成全部关卡 显示完成全部关卡的提示 显示“恭喜你完成全部关卡!”并可重新开始游戏 通过
T09 用脚本检查五关可解性 五关都存在合理的通关顺序 输出 共 5 关,无解 0 关 通过
T10 按 H 提示、按 U 撤销 高亮一个可点箭头;撤销恢复上一步 与预期一致,点错扣掉的失误也能撤销 通过
T11 存档与逐关解锁 关掉重开进度仍在;锁住的关卡点不动 与预期一致,旧存档也能按通关记录正确迁移 通过
T12 按 S 自动求解 自动按解法演示通关 与预期一致,演示成绩不记入星级和最佳用时 通过

每个关卡我都实际试玩过;此外还写了一套自动化检查脚本,把关键逻辑和界面布局跑一遍,目前一共 69 项全部通过,覆盖:完整流程(开始 → 逐关通关 → 失败 → 重开)、界面渲染、文字与按钮不被遮挡、箭头不超出格子、音效是旋律、计时在结算时停止、关卡解锁与存档读写等。

七、PSP 表格

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

界面实现和关卡设计超时最多:界面改了三版(平涂 → 低分辨率复古 → 深色渐变卡片),关卡则是因为先后解决了“两关无解”“箭头数量太少”“没有难度梯度”等问题,每改一次都要重新验证。

八、心得体会

这次作业让我体会到,AIGC 能明显加快开发速度,但它给的东西必须自己验证。

装环境那次,AI 帮我从一堆报错里定位到真正的原因;写路径检测、做碰撞反馈和音效时,它给的方案直接可用,省下了大量查文档的时间。界面返工和关卡验证则提醒我:AI 说得有道理不等于效果好,好不好看、能不能通关,必须自己看结果再判断。

关卡那次是最典型的一课。AI 生成的关卡数据看起来没问题,我甚至以为“应该能过,是我没找到顺序”,直到把校验脚本跑出来才确认两关都是死局。从那以后我给自己定了个规矩:凡是“看起来应该对”的东西都要想办法验证一次——这也是后来我把穷举脚本、拓扑排序判断和 69 项自动检查一路加下来的原因。

另外一个感受是,在利用AIGC进行开发时,真正花时间的不是写代码,而是想清楚规则、设计关卡、测试各种边界。以后再配合 AIGC 开发,我会更重视理解代码和验证结果,而不是把生成的内容直接当成答案。

posted on 2026-09-20 00:44  asdllx  阅读(18)  评论(0)    收藏  举报