上一篇介绍了 Funplay Unity MCP 中 execute_code 工具的设计动因——用一个支持任意 C# 片段在 Editor 内存中编译执行的入口,承接所有"专用工具组合不出来"的需求。本文承接,挑 8 个真实场景把"30 行 C# 干完一件 Unity Editor 苦活"这件事坐实。

每个场景都按 IFunplayCommand 模板写,跑在 Editor 主线程,自动接 Undo,结构化返回。脚本可以直接通过 execute_code 工具调用,也能贴进 Editor 菜单脚本里独立运行。

调用约定

所有示例共享同一前置 using:

using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using UnityEditor;
using Funplay.Editor.Tools.Scripting;

每段代码都是一个 class CommandScript : IFunplayCommand,只贴 Execute(ExecutionContext ctx) 方法体。完整脚本请把方法体填进类模板。

场景 1:批量给场景里的 GameObject 加前缀

需求:场景里散落了上百个测试用 GameObject,临测试结束前需要统一改名带 [OBSOLETE] 前缀,方便后续清理。

public void Execute(ExecutionContext ctx)
{
    const string prefix = "[OBSOLETE] ";
    var targets = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None)
        .Where(g => !g.name.StartsWith(prefix))
        .ToList();

    foreach (var go in targets)
    {
        ctx.RegisterObjectModification(go);
        go.name = prefix + go.name;
    }

    EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
    ctx.Log("Renamed {0} GameObjects with prefix", targets.Count);
    ctx.ReturnValue = new { renamed = targets.Count };
}

RegisterObjectModification 让用户能 Ctrl+Z 一键全部撤销。Filter 里跳过已加前缀的对象,幂等运行。

场景 2:找出场景里挂了缺失脚本的 GameObject

需求:项目重构后某些脚本被删,场景里残留 missing script 引用,会导致 prefab 序列化警告但不易定位。

public void Execute(ExecutionContext ctx)
{
    var bad = new List<object>();
    foreach (var go in Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None))
    {
        var components = go.GetComponents<Component>();
        for (int i = 0; i < components.Length; i++)
        {
            if (components[i] == null)
            {
                bad.Add(new
                {
                    instanceId = go.GetInstanceID(),
                    path = AnimationUtility.CalculateTransformPath(go.transform, null),
                    slot = i
                });
            }
        }
    }
    ctx.Log("Found {0} GameObjects with missing scripts", bad.Count);
    ctx.ReturnValue = new { count = bad.Count, items = bad };
}

返回的每条记录带 instanceId,AI 客户端拿到后可以接着用 find_method=by_id 打开对象进一步处理,不必重新按名字匹配。

场景 3:导出场景层级到 JSON

需求:把场景里某棵子树的层级、组件类型清单导出,给文档系统或外部工具消费。

public void Execute(ExecutionContext ctx)
{
    var root = Selection.activeGameObject;
    if (root == null)
    {
        ctx.LogError("请先在 Hierarchy 中选中一个根对象");
        return;
    }

    object Walk(Transform t) => new
    {
        name = t.name,
        instanceId = t.gameObject.GetInstanceID(),
        components = t.gameObject.GetComponents<Component>()
            .Where(c => c != null).Select(c => c.GetType().Name).ToArray(),
        children = Enumerable.Range(0, t.childCount).Select(i => Walk(t.GetChild(i))).ToArray()
    };

    var tree = Walk(root.transform);
    var json = JsonUtility.ToJson(tree, true);
    var path = $"Assets/Generated/{root.name}.hierarchy.json";
    Directory.CreateDirectory("Assets/Generated");
    File.WriteAllText(path, JsonUtility.ToJson(tree, true));
    AssetDatabase.ImportAsset(path);

    ctx.Log("Exported hierarchy to {0}", path);
    ctx.ReturnValue = new { path, rootName = root.name };
}

注:JsonUtility 不支持匿名类,实际项目中建议替换为 Newtonsoft.Json.JsonConvert.SerializeObject(Funplay 包内已引用)。

场景 4:统计当前场景所有 MeshFilter 的三角形总数

需求:性能审查阶段快速判断场景的几何复杂度,定位高 poly 区域。

public void Execute(ExecutionContext ctx)
{
    var heavy = new List<object>();
    long total = 0;

    foreach (var mf in Object.FindObjectsByType<MeshFilter>(FindObjectsSortMode.None))
    {
        var mesh = mf.sharedMesh;
        if (mesh == null) continue;
        long tri = mesh.triangles.Length / 3;
        total += tri;
        if (tri > 5000)
        {
            heavy.Add(new
            {
                instanceId = mf.gameObject.GetInstanceID(),
                name = mf.gameObject.name,
                triangles = tri,
                meshName = mesh.name
            });
        }
    }

    heavy = heavy.OrderByDescending(h => (long)h.GetType().GetProperty("triangles").GetValue(h)).Take(10).ToList();
    ctx.Log("Total triangles in scene: {0}", total);
    ctx.ReturnValue = new { totalTriangles = total, top10 = heavy };
}

返回里 top10 直接是按三角形数降序的对象列表,AI 客户端可以基于此提出"建议 LOD"或"建议合批"的下一步动作。

场景 5:批量把所有 Material 的 _Color 改成同一个颜色

需求:项目主题切换,临时把所有非 PBR Material 的主色调统一改成调试色,便于视觉验收。

public void Execute(ExecutionContext ctx)
{
    var color = new Color(0.2f, 0.6f, 0.9f, 1f);
    var guids = AssetDatabase.FindAssets("t:Material", new[] { "Assets/Materials" });
    int updated = 0;

    foreach (var guid in guids)
    {
        var path = AssetDatabase.GUIDToAssetPath(guid);
        var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
        if (mat == null || !mat.HasProperty("_Color")) continue;
        ctx.RegisterObjectModification(mat);
        mat.SetColor("_Color", color);
        updated++;
    }

    AssetDatabase.SaveAssets();
    ctx.Log("Updated _Color on {0} materials", updated);
    ctx.ReturnValue = new { updated, color = ColorUtility.ToHtmlStringRGBA(color) };
}

AssetDatabase.FindAssets 配合路径过滤精准定位资产范围,避免误改第三方包内的 Material。

场景 6:找出未被任何场景引用的 prefab

需求:项目长期演进会留下大量"孤儿 prefab"——已经没有任何场景或其他 prefab 在用了。批量找出来便于评估清理。

public void Execute(ExecutionContext ctx)
{
    var allPrefabs = AssetDatabase.FindAssets("t:Prefab")
        .Select(AssetDatabase.GUIDToAssetPath)
        .ToHashSet();

    var referenced = new HashSet<string>();
    foreach (var scenePath in AssetDatabase.FindAssets("t:Scene").Select(AssetDatabase.GUIDToAssetPath))
    {
        foreach (var dep in AssetDatabase.GetDependencies(scenePath, true))
            if (allPrefabs.Contains(dep)) referenced.Add(dep);
    }
    foreach (var prefabPath in allPrefabs.ToList())
    {
        foreach (var dep in AssetDatabase.GetDependencies(prefabPath, true))
            if (allPrefabs.Contains(dep) && dep != prefabPath) referenced.Add(dep);
    }

    var orphans = allPrefabs.Except(referenced).OrderBy(p => p).ToList();
    ctx.Log("Found {0} orphan prefabs out of {1} total", orphans.Count, allPrefabs.Count);
    ctx.ReturnValue = new { total = allPrefabs.Count, orphans };
}

AssetDatabase.GetDependencies(path, true) 递归收集依赖,把 prefab 嵌套引用也算进去。在 10K+ 资产的项目里 1-2 秒可以扫完。

场景 7:从 CSV 批量生成 ScriptableObject 配置

需求:策划维护一份 Assets/Data/items.csv,每行一条 ItemConfig 数据,需要把它生成为运行时可加载的 ScriptableObject 资产。

public void Execute(ExecutionContext ctx)
{
    var csvPath = "Assets/Data/items.csv";
    if (!File.Exists(csvPath))
    {
        ctx.LogError("CSV not found: {0}", csvPath);
        return;
    }

    Directory.CreateDirectory("Assets/Generated/Items");
    var lines = File.ReadAllLines(csvPath).Skip(1); // 跳过表头
    int created = 0;

    foreach (var line in lines)
    {
        var cols = line.Split(',');
        if (cols.Length < 3) continue;
        var so = ScriptableObject.CreateInstance<ItemConfig>();
        so.id = cols[0];
        so.displayName = cols[1];
        so.basePrice = int.Parse(cols[2]);

        var path = $"Assets/Generated/Items/{so.id}.asset";
        AssetDatabase.CreateAsset(so, path);
        ctx.RegisterObjectCreation(so);
        created++;
    }

    AssetDatabase.SaveAssets();
    ctx.Log("Created {0} ItemConfig assets", created);
    ctx.ReturnValue = new { created, outputDir = "Assets/Generated/Items" };
}

ItemConfig 假设是项目里已有的 ScriptableObject 子类。AssetDatabase.CreateAsset 创建后通过 RegisterObjectCreation 注册到 Undo 栈,撤销时连同资产一起删除。

场景 8:一键禁用场景里所有 AudioSource

需求:调试期间烦人的 BGM、SFX 想集中关掉,又不想逐个禁用 AudioSource 组件。

public void Execute(ExecutionContext ctx)
{
    var sources = Object.FindObjectsByType<AudioSource>(FindObjectsSortMode.None);
    foreach (var src in sources)
    {
        ctx.RegisterObjectModification(src);
        src.enabled = false;
    }
    EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
    ctx.Log("Disabled {0} AudioSource components", sources.Length);
    ctx.ReturnValue = new { disabled = sources.Length };
}

简短到极致——但比手动逐个禁用快得多。同样自动接 Undo,调试结束后一次 Ctrl+Z 全部恢复。

模式总结

8 个场景按操作类型可以归到三类典型模式:

execute_code 典型用途

查询/审计

批改

生成

场景 2: missing script 扫描

场景 3: 层级 JSON 导出

场景 4: triangle 统计

场景 6: orphan prefab 检测

场景 1: 批量重命名

场景 5: Material 颜色批改

场景 8: AudioSource 批量禁用

场景 7: CSV → ScriptableObject

模式 特征 关键 API
查询/审计 只读,返回结构化结果 Object.FindObjectsByType + AssetDatabase.FindAssets/GetDependencies
批改 写入,需要 Undo 注册 ctx.RegisterObjectModification + EditorSceneManager.MarkSceneDirty / AssetDatabase.SaveAssets
生成 创建新对象/资产 ctx.RegisterObjectCreation + AssetDatabase.CreateAsset / PrefabUtility.SaveAsPrefabAsset

每一类都有共通的"定型"——查询模式必返回 { count, items[] } 便于 AI 续接;批改模式必接 Undo + 标脏;生成模式必走 AssetDatabase 注册以便引用。

与专用工具组合的对比

如果不用 execute_code,场景 4(统计三角形)需要:列举所有 GameObject → 对每个查 MeshFilter 组件 → 读 sharedMesh → 计算 triangles。即使有专用 list_components 工具,也至少 3-N 次 round-trip,N 等于场景对象数。execute_code 一次调用搞定。

场景 6(orphan prefab)则根本无法用专用工具完成——AssetDatabase.GetDependencies 没有现成的 MCP 工具暴露,唯一出路是 execute_code

写在最后

Editor 自动化的本质从来不是"省下点几下鼠标",而是把人工不擅长的事——大批量遍历、跨资产追溯、严格幂等——交给可重放的代码。execute_code 把这件事的成本压到了"写 30 行 C#"的级别,且无需为每个场景预先定义新工具、无需写 .cs 文件、无需触发 domain reload。

完整工具实现在 FunplayAI/funplay-unity-mcp,开源 MIT 协议。本文所有示例代码都可以直接通过 execute_code MCP 工具调用,或粘进 Editor 菜单脚本独立运行。

如果你也在为 Unity 项目接入 AI 工作流并尝试用一个工具承接更多需求,欢迎仓库提 issue 或讨论。

更多推荐