Unity 项目的自动化测试通常停留在 EditMode 单元测试层------验证函数逻辑、组件初始化、数值计算。但游戏的"真实行为"------按钮点击是否触发动画、角色是否在地板上、UI 在不同分辨率是否错位------只有进入 PlayMode 才能看到。
把 Unity Editor 暴露给 AI 客户端的设计如果停留在"修改场景资产",就漏掉了游戏开发中最关键的环节。Funplay Unity MCP 把 PlayMode 也作为一等公民暴露------AI 客户端能进入游戏运行态、模拟键鼠输入、回传 Game View 截图、读取 Console 日志、最后退出回到编辑态。本文记录这一套"视觉闭环"的设计与实现。
1. 为什么 PlayMode 验证是 AI 的刚需
AI 客户端要完成"帮我做一个能跑的原型"这类高阶任务时,必然涉及一个验证循环:
否
是
AI 修改场景
进入 PlayMode
模拟用户操作
截图 + 读日志
结果符合预期?
退出 + 调整代码
完成
没有这个闭环,AI 只能"盲改"------改完代码不知道效果,需要人工切到 Unity 看一眼然后报告。有了闭环,AI 自己就能形成"试 → 看 → 调"的迭代节奏。
2. PlayMode 工具链概览
Funplay Unity MCP 把这条闭环拆为 5 个原子工具:
| 工具 | 作用 | 关键 API |
|---|---|---|
enter_play_mode |
进入运行态 | EditorApplication.EnterPlaymode() |
simulate_mouse_click |
模拟点击 | 自定义 InputEvent dispatch |
simulate_key_press / simulate_key_combo |
模拟键盘 | 同上 |
capture_game_view / capture_scene_view |
截图 | Texture2D.ReadPixels + base64 |
get_console_logs |
读取 Unity Console | LogEntries reflection + 过滤 |
exit_play_mode |
退出运行态 | EditorApplication.ExitPlaymode() |
每个工具都是无状态的------客户端按需组合即可。下文按完整闭环顺序讨论几个关键实现点。
3. 进入 PlayMode 的等待问题
EditorApplication.EnterPlaymode() 是异步的------调用后 Unity 不会立刻进入运行态,需要经历"序列化 EditMode 状态 → 域重载 → 调用所有 [InitializeOnEnterPlayMode] → 进入 PlayMode"的过程。期间任何 MCP 调用都可能因 domain reload 被打断。
enter_play_mode 工具的实现要点:
csharp
[ToolProvider("PlayMode")]
internal static class PlayModeFunctions
{
[SceneEditingTool]
[Description("Enter PlayMode and wait until the player is fully running")]
public static async Task<string> EnterPlayMode(int timeoutSeconds = 10)
{
if (EditorApplication.isPlaying)
return Response.Success("Already in PlayMode");
EditorApplication.EnterPlaymode();
// 等真正进入 isPlaying = true 且 isPlayingOrWillChangePlaymode = false
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
if (EditorApplication.isPlaying && !EditorApplication.isPlayingOrWillChangePlaymode)
return Response.Success("Entered PlayMode");
await Task.Delay(100);
}
return Response.Error("PLAYMODE_TIMEOUT", "Failed to enter PlayMode within timeout");
}
}
返回结构化结果让客户端能基于 success 字段决定后续动作。如果遇到 timeout,AI 通常会选择 get_compilation_errors 检查是不是脚本编译失败阻塞了 PlayMode 进入。
4. 输入模拟的实现要点
Unity 的 Input.GetKey / Input.GetMouseButton 不能从 Editor 脚本直接 fake------这些 API 读取的是底层 OS 输入队列。要在 Editor 自动化里模拟输入,常见两条路:
输入模拟
路径 1: Editor 端 dispatch Event
路径 2: 新 Input System EventSource
只对 EditorGUI / UGUI 有效
不触发 Input.GetKey
需项目用 New Input System
全栈生效
Funplay Unity MCP 走的是混合方案------优先使用 Input System 的 InputState.Change API(项目启用 New Input System 时),降级到 EditorWindow Event dispatch(旧 Input Manager 时只对 UGUI 生效)。
简化版的鼠标点击实现:
csharp
[SceneEditingTool]
[Description("Simulate a mouse click at the given Game View coordinates")]
public static string SimulateMouseClick(float x, float y, int button = 0)
{
if (!EditorApplication.isPlaying)
return Response.Error("NOT_IN_PLAYMODE", "Must be in PlayMode to simulate input");
var gameView = GetGameView();
if (gameView == null)
return Response.Error("NO_GAME_VIEW", "Game View window is not open");
gameView.Focus();
var evtDown = new Event { type = EventType.MouseDown, mousePosition = new Vector2(x, y), button = button };
var evtUp = new Event { type = EventType.MouseUp, mousePosition = new Vector2(x, y), button = button };
gameView.SendEvent(evtDown);
gameView.SendEvent(evtUp);
return Response.Success($"Clicked at ({x}, {y})");
}
实际生产实现里还包含坐标系转换(Game View 与底层 Display 的 DPI 缩放)、多窗口选择(多个 Game View Tab 时选哪个)、以及 Input System 路径的并行实现。
5. 截图:base64 内联 vs 文件路径
截图工具有两个返回选项:
- base64 内联:直接把 PNG 字节 base64 编码塞进工具响应,AI 客户端可作为 image content 解析
- 文件路径 :截图存
Assets/Screenshots/,工具返回 path,客户端按需读
Funplay Unity MCP 默认走 base64 内联------MCP 协议原生支持 image content type,客户端直接渲染:
csharp
[ReadOnlyTool]
[Description("Capture the Game View as a PNG image returned inline")]
public static string CaptureGameView()
{
var gameView = GetGameView();
if (gameView == null)
return Response.Error("NO_GAME_VIEW", "Game View window is not open");
var tex = ScreenCapture.CaptureScreenshotAsTexture();
var png = tex.EncodeToPNG();
var base64 = Convert.ToBase64String(png);
UnityEngine.Object.DestroyImmediate(tex);
return Response.Success("Captured", new
{
format = "png",
width = tex.width,
height = tex.height,
encoding = "base64",
data = base64
});
}
base64 编码会让响应膨胀 ~33%------一张 1920×1080 截图原始 ~500KB,base64 后 ~670KB。MCP 客户端能接受这个大小,但如果项目需要 4K 截图或连续多帧,应换成文件路径方案。Funplay 提供了两个版本以供选择。
6. 用 Console 日志做断言
视觉断言("按钮变红了")需要 AI 自己看截图判断。但很多游戏逻辑用 Debug.Log 输出状态------这部分可以直接读 Console 日志做断言,不需要视觉判断。
csharp
[ReadOnlyTool]
[Description("Get recent Unity Console log entries with optional filter")]
public static string GetConsoleLogs(string filter = null, int limit = 100, string level = null)
{
// LogEntries 是 internal API,通过反射访问
var logEntries = typeof(EditorWindow).Assembly.GetType("UnityEditor.LogEntries");
var startMethod = logEntries.GetMethod("StartGettingEntries");
var getEntryMethod = logEntries.GetMethod("GetEntryInternal");
var endMethod = logEntries.GetMethod("EndGettingEntries");
int totalCount = (int)startMethod.Invoke(null, null);
var entries = new List<object>();
/* ... 按 filter / level 过滤 ... */
endMethod.Invoke(null, null);
return Response.Success($"Got {entries.Count} logs", new { entries });
}
AI 客户端在执行验证时通常组合使用:截图判断视觉、日志判断状态。例如"按下空格 → 角色应该跳跃"这种断言,截图看角色 Y 坐标变化、日志看 Player jumped at time X 这种业务输出,二者交叉验证。
7. 完整闭环示例
把上述工具串成一个验证流程------让 AI 验证"游戏开始时点击 Start 按钮后角色出现在场景里":
Unity Editor Funplay MCP AI 客户端 Unity Editor Funplay MCP AI 客户端 enter_play_mode EnterPlaymode + 等 isPlaying success success capture_game_view ScreenCapture png base64 图片 1(初始 UI 含 Start 按钮) simulate_mouse_click x=960 y=540 Event MouseDown/Up success success capture_game_view ScreenCapture png base64 图片 2(应该看到角色) get_console_logs filter=Player { entries: [Player spawned at ...] } exit_play_mode ExitPlaymode success
整个验证不需要写一行测试代码------AI 通过截图判断 UI 状态、通过日志确认业务事件、通过比较两张截图判断角色是否出现。所有工具都是声明式调用,AI 客户端把它们拼成有意义的验证序列。
8. PlayMode 退出的状态清理
exit_play_mode 看起来只是 EditorApplication.ExitPlaymode() 的薄封装,但实际有几个隐藏陷阱:
- 退出动作也是异步的 ------同样需要等
!isPlaying && !isPlayingOrWillChangePlaymode - PlayMode 期间创建的 GameObject 在退出后会消失 ------这与 Edit 模式下的
GameObject.CreatePrimitive行为完全不同 - PlayMode 期间的脚本字段修改不会持久化到 EditMode ------除非显式调用
EditorUtility.CopySerialized
Funplay 的 exit_play_mode 工具默认会等待退出完全完成才返回 success,并附带提示告诉客户端"PlayMode 期间的修改已丢弃"。这种显式提示能避免 AI 在退出后误以为对象/字段还在。
9. 与 EditMode 工具的协同
PlayMode 工具不是孤立的------它们与 EditMode 的场景编辑工具配合使用才有意义:
根据结果调整
EditMode 阶段
set_component_property 改字段
execute_code 改脚本
request_recompile 触发编译
PlayMode 阶段
enter_play_mode
simulate_mouse_click 测试交互
capture_game_view 抓状态
get_console_logs 读断言
exit_play_mode
AI 客户端在"修改 + 验证"循环里会不断在两组工具之间切换。Funplay 在工具命名、返回结构、错误码上保持一致,让这种切换对 LLM 是无缝的------任何一类工具调用失败都会返回相同形态的 error JSON,AI 不需要为不同工具组学习不同的错误处理逻辑。
10. 写在最后
让 AI 能"看见游戏在跑"是 Unity AI 工具区别于通用 IDE AI 的关键能力。单元测试无法覆盖的视觉/交互/时序问题,PlayMode 视觉闭环都能让 AI 自主验证、自主迭代。
实现层面要解决的核心问题是:异步状态等待(PlayMode 进出)、输入事件 dispatch(两套 Input 系统)、截图编码与传输(base64 体积、Game View 选择)、日志读取(反射 internal API)。每一项都有自己的踩坑空间,但落地后形成的回环是质变的------AI 不再是"写完代码等人测",而是"写完代码自己测、看到问题自己调"。
Funplay Unity MCP 的完整 PlayMode 工具实现在 FunplayAI/funplay-unity-mcp,源码位于 Editor/Tools/Builtins/PlayMode*.cs、InputSimulation*.cs、ScreenCapture*.cs。MIT 协议,欢迎 issue 与讨论。