让 AI 自动跑 PlayMode 回归测试:从 BUG 注入到自动判 FAIL 的完整闭环

上一篇讲了用 funplay-unity-mcp 让 AI 跑通"调跳跃手感"的闭环------参数 → PlayMode → 截图 → 改参数。那是开发新功能时的工作流,要解决的是"找最优"。

但游戏开发还有另一类需求:已经做出来的东西,怎么保证后续改动不退化?

跳跃手感调到合适了,过一周加了个新功能、改了个组件、合了别人的 PR------你回头跑一次会发现:怎么连按 Space 能无限跳了?或者:高度怎么从 3.68 变成 4.5 了?这种"以前没有、现在出现"的退化,就是回归测试要抓的。

这一篇还是用 funplay-unity-mcp,但工作流换了:不是"试不同参数找最优",而是"用同一组输入,检查输出是否落在预期范围内"。AI 自己写测试组件、自己注入 BUG 验证、自己跑断言、自己出 PASS/FAIL 报告。完整代码和数据都来自真实跑过的实验。

要测的功能

承接上一篇的设置:一个 Player Cube + JumpController,按 Space 跳一次,标准重力上升、加倍重力下降("snappy"手感)。

要保护的不变量很简单:连按两次 Space 时,第二次必须在空中被忽略。也就是:玩家只能从地面起跳,空中不能再跳一次。

这个 invariant 看起来很 trivial,但实际工程里非常容易踩。空中再跳一次发生在两种典型场景:

  1. 二段跳逻辑写出 BUG,本来想做"只能跳第二次",结果允许无限连跳
  2. 地面检测的 raycast 距离改大了(比如想适应斜坡),结果空中也算 grounded

这一篇模拟第 2 种,因为它隐蔽------参数从 0.55 改成 5.0,单跳还正常,连按才能复现。

把测试逻辑抽出来

第一步是让 JumpController 可以被测试。原来的 Update 里耦合了 Input 读取 + 地面检测 + 跳跃执行三件事:

csharp 复制代码
void Update()
{
    _grounded = Physics.Raycast(transform.position, Vector3.down, 0.55f);
    if (Input.GetKeyDown(jumpKey) && _grounded)
    {
        _rb.velocity = new Vector3(_rb.velocity.x, 0f, _rb.velocity.z);
        _rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
    }
}

测试时不能依赖键盘 Input------MCP 工具调用之间的时序抖动会让测试结果不可重复。把地面检测和跳跃执行抽成可外部调用的方法:

csharp 复制代码
public float groundCheckDistance = 0.55f;  // 后面要把它改成 5.0 注入 BUG

public bool IsGrounded()
    => Physics.Raycast(transform.position, Vector3.down, groundCheckDistance);

public void TryJump()
{
    if (IsGrounded()) DoJump();
}

void DoJump()
{
    _rb.velocity = new Vector3(_rb.velocity.x, 0f, _rb.velocity.z);
    _rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}

void Update()
{
    _grounded = IsGrounded();
    if (Input.GetKeyDown(jumpKey) && _grounded) DoJump();
}

这样测试可以直接调 jc.TryJump(),走的就是和真实玩家按 Space 完全一样的判定路径------IsGrounded() 那个 raycast 是关键,BUG 就藏在它里面。

测试组件

测试本身写成一个挂在 Player 上的 MonoBehaviour。它的工作就是按一个固定时序触发两次 jump 尝试,采样 Y 轨迹,跑完后给出 PASS/FAIL:

csharp 复制代码
public class JumpRegressionTester : MonoBehaviour
{
    public JumpController jc;
    public JumpSampler sampler;
    public float firstJumpAt = 0.05f;
    public float secondJumpAt = 0.30f;
    public float testDuration = 2.0f;
    public float singleJumpThreshold = 4.0f;  // 单跳峰值 3.68 + 余量

    public string verdict = "not run";
    public float peakY;
    bool _running, _firstFired, _secondFired;
    float _startTime;

    public void RunTest()
    {
        _running = true;
        _firstFired = _secondFired = false;
        peakY = -999f;
        verdict = "running";
        var rb = GetComponent<Rigidbody>();
        rb.position = new Vector3(0, 0.5f, 0);  // ⚠️ rb.position 才能传送 Rigidbody
        rb.velocity = Vector3.zero;
        transform.position = rb.position;
        sampler.StartRecording();
        _startTime = Time.time;
    }

    void FixedUpdate()
    {
        if (!_running) return;
        var dt = Time.time - _startTime;
        if (!_firstFired && dt >= firstJumpAt) { jc.TryJump(); _firstFired = true; return; }
        if (_firstFired && !_secondFired && dt >= secondJumpAt) { jc.TryJump(); _secondFired = true; }
        if (dt >= testDuration)
        {
            sampler.StopRecording();
            foreach (var y in sampler.ys) if (y > peakY) peakY = y;
            verdict = peakY > singleJumpThreshold ? "FAIL --- peak exceeded single-jump bound" : "PASS";
            _running = false;
        }
    }
}

两个有意思的细节,是踩过坑才加的:

1. 时序逻辑必须在 FixedUpdate,不能在 Update。Unity 失焦时(比如你切到 AI 客户端窗口的时候)Update 会被节流,可能第一次 tick 时 dt 已经超过两次 jump 的时间点 → 两个 if 在同一帧都触发 → 两个 impulse 同帧叠加,cube 直接飞到 13 米。FixedUpdate 是物理线程,固定 50Hz 雷打不动,没这个问题。第一次跑出来 13 米的时候我以为 BUG 提前复现了,仔细看采样数据才发现是测试自己的 bug。

2. 重置 Player 位置必须用 rb.position ,不能只改 transform.position。Rigidbody 控制的物体直接改 transform 不会真正传送,物理引擎下一帧又会把它拉回原位。同样是采样数据反过来诊断出来的------重置后前几帧 Y 一直是上一次测试结束的位置。

三轮运行

跑了三次,每次测试组件都做一样的事:t=0.05s 调一次 TryJump,t=0.30s 再调一次,跑 2 秒。

Run 1 --- BASELINE

参数:groundCheckDistance = 0.55(正常)。

跑完后:

复制代码
verdict = PASS
peakY = 3.682
peakT = 0.867

第一次 TryJump:Player 在 y=0.5,raycast 0.55 米下去打到地面,IsGrounded() = true,跳起来。

第二次 TryJump(t=0.30s):Player 已经升到大约 y=2.2,raycast 0.55 米下去打不到地面,IsGrounded() = false,TryJump 直接 return,没有任何 impulse。

最终峰值 3.682 米,正是单跳应该达到的高度。在阈值 4.0 之下,断言 PASS。

Run 2 --- BUG 注入

唯一改动:jc.groundCheckDistance = 5.0f

复制代码
verdict = FAIL --- peak exceeded single-jump bound
peakY = 5.296

第一次 TryJump 和 baseline 一样,正常跳起。

第二次 TryJump:Player 在 y≈2.2,raycast 5.0 米下去------足以打到地面(地面在 y=0,距离 2.2 米,小于 5.0)→ IsGrounded() = true → 再加一次 impulse。

cube 在已经有上升速度的情况下又被加了一次向上 impulse,峰值飙到 5.30 米。超过断言阈值 4.0,自动判 FAIL。

Run 3 --- FIX 验证

groundCheckDistance 改回 0.55,再跑:

复制代码
verdict = PASS
peakY = 3.682

数字和 baseline 一模一样------这就是回归测试的目的。修完之后行为应该和已知正确的基线完全一致。

三次轨迹放一张图里:

绿色(BASELINE)和蓝色(FIX)完全重合在 3.68 米的弧线上。橙色(BUG)越过了红色虚线阈值,被自动抓到。

这个闭环里 AI 在做什么

整个过程 AI 调用 funplay 工具的链路:

阶段 工具调用
重构 JumpController 暴露 TryJump Write .csrequest_recompilewait_for_compilation
新建 JumpRegressionTester 组件 Write .csrequest_recompile
挂到 Player 上、保存场景 execute_code 一段 attach + SaveScene
跑 BASELINE enter_play_modeexecute_code 触发 RunTest → get_editor_state × N 让物理跑 → execute_code 读 verdict + dump 轨迹 JSON
注入 BUG 再跑 execute_code 改一个字段 + RunTest → 读结果
修复再跑 execute_code 改回字段 + RunTest → 读结果
出报告 dump 三组 JSON 到本地,画对比图

注意没有用 simulate_key_press。这个工具适合"模拟玩家按键序列"的场景(比如"按住空格 0.5 秒跳跃 vs 0.2 秒跳跃"),但对回归测试这种需要严格时序复现的场景,反而是直接调 jc.TryJump() 更稳------同步、可重复、不依赖 Unity Input 系统的事件队列。

更重要的一点是:所有判定都是数字。"peakY > 4.0 → FAIL"是机器能跑的断言,不需要人去看截图判断"这次是不是跳太高了"。这才是把测试自动化的关键------AI 能告诉你"出问题了"是因为 peakY 越线了,不是因为它"觉得"哪里不对。

这种闭环适合什么场景

适合的:

  • 可量化的玩法 invariant :跳跃高度、移动速度、子弹伤害、技能 CD、UI 元素位置 ------ 凡是能写出 assert X 在 [a, b] 的,都能这样测
  • 集成层 BUG 复现:参数耦合、组件交互、物理边界------单测覆盖不到但能在 PlayMode 里跑出来的
  • 重构前后对照:跑一次留 baseline JSON → 重构 → 再跑一次 → 比对,差异超过容忍范围就 FAIL

不太适合:

  • 图形/视觉的回归:粒子效果、shader、相机抖动这些不好量化
  • 依赖玩家主观判断的体验:手感、节奏、难度曲线,AI 跑 1000 次也不知道是不是"好玩"
  • 大规模端到端:跑完整关卡级别的测试,PlayMode 启动开销太大,更适合传统脚本化的 EditMode 测试

完整数据

复制代码
Run         gc      peakY    peakT    verdict
BASELINE    0.55    3.682    0.860    PASS
BUG         5.0     5.296    1.100    FAIL  (peak > 4.0 阈值)
FIX         0.55    3.682    0.860    PASS  (=baseline)

轨迹采样 JSON、JumpController/JumpRegressionTester 源码、matplotlib 渲染脚本都在本地实验目录里,看仓库能复现:

下一篇要写什么还没定,候选两个:(1)Unity / Cocos / Godot 三个引擎接 MCP 的横向体验对比;(2)让 AI 给你写一个 ToolProvider 扩展 funplay 自己的工具集。哪个想先看?欢迎评论投票。

相关推荐
古月开发1 小时前
比价助手:截图自动全网比价与历史价格查询实战
人工智能·信息可视化·自动化
lqqjuly1 小时前
优化理论:梯度方法、约束优化与机器学习优化
人工智能·机器学习
m沐沐1 小时前
【机器学习】Python 实现垃圾邮件分类(随机森林 + 可视化 + 特征重要性)
人工智能·python·随机森林·机器学习·分类·pycharm·回归算法
程序员cxuan1 小时前
这个 6.6 k star 的仓库,我差点删库了。
人工智能·后端·程序员
扫地僧9851 小时前
一个基于 PyTorch 手语翻译模型Xuanmen_Net
人工智能·pytorch·python
搬砖的小码农_Sky1 小时前
Windows环境下OpenClaw本地部署完整指南
人工智能·windows·ai·人机交互·agi
恋猫de小郭1 小时前
能在手机本地跑的图像生成模型 Bonsai Image ,效果还不错
前端·aigc·ai编程
风舞雪凌月1 小时前
【总结】国产AI大模型公司汇总
人工智能
Hali_Botebie1 小时前
【光流】自动驾驶光流任务 DeFlow: Decoder of Scene Flow Network in Autonomous Driving
人工智能·机器学习·自动驾驶