上一篇讲了用 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,但实际工程里非常容易踩。空中再跳一次发生在两种典型场景:
- 二段跳逻辑写出 BUG,本来想做"只能跳第二次",结果允许无限连跳
- 地面检测的 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 .cs → request_recompile → wait_for_compilation |
| 新建 JumpRegressionTester 组件 | Write .cs → request_recompile |
| 挂到 Player 上、保存场景 | execute_code 一段 attach + SaveScene |
| 跑 BASELINE | enter_play_mode → execute_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 自己的工具集。哪个想先看?欢迎评论投票。