修复: 防止MCP指令并发导致编辑器卡死

- 新增 CommandQueue 指令队列,所有 /call-tool 请求串行化执行
- 新增 callSceneScriptWithTimeout 超时保护(15秒),防止IPC永久挂起
- batchExecute 从并行 forEach 改为串行链式执行
- 9处 callSceneScript 替换为超时版本
- 清理 /list-tools 中重复的 res.end 死代码
- 更新注意事项文档,记录并发安全与防卡死机制(第9章)
This commit is contained in:
火焰库拉
2026-02-12 22:55:08 +08:00
parent 09817ac79d
commit 517866e50a
2 changed files with 406 additions and 207 deletions

513
main.js
View File

@@ -14,6 +14,83 @@ let serverConfig = {
active: false, active: false,
}; };
/**
* 指令队列 - 确保所有 MCP 工具调用串行执行
* 防止 AssetDB.refresh 等异步重操作被并发请求打断,导致编辑器卡死
* @see mcp_freeze_analysis.md
*/
let commandQueue = [];
let isProcessingCommand = false;
/**
* 将一个异步操作加入队列,保证串行执行
* @param {Function} fn 接受 done 回调的函数fn(done) 中操作完成后必须调用 done()
* @returns {Promise} 操作完成后 resolve
*/
function enqueueCommand(fn) {
return new Promise((resolve) => {
commandQueue.push({ fn, resolve });
processNextCommand();
});
}
/**
* 从队列中取出下一个指令并执行
*/
function processNextCommand() {
if (isProcessingCommand || commandQueue.length === 0) return;
isProcessingCommand = true;
const { fn, resolve } = commandQueue.shift();
try {
fn(() => {
isProcessingCommand = false;
resolve();
processNextCommand();
});
} catch (e) {
// 防止队列因未捕获异常永久阻塞
addLog("error", `[CommandQueue] 指令执行异常: ${e.message}`);
isProcessingCommand = false;
resolve();
processNextCommand();
}
}
/**
* 带超时保护的 callSceneScript 包装
* 防止 Scene 面板阻塞时 callback 永不返回,导致 HTTP 连接堆积
* @param {string} pluginName 插件名
* @param {string} method 方法名
* @param {*} args 参数(可以是对象或 null
* @param {Function} callback 回调 (err, result)
* @param {number} timeout 超时毫秒数,默认 15000
*/
function callSceneScriptWithTimeout(pluginName, method, args, callback, timeout = 15000) {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
addLog("error", `[超时] callSceneScript "${method}" 超过 ${timeout}ms 未响应`);
callback(`操作超时: ${method} (${timeout}ms)`);
}
}, timeout);
// callSceneScript 支持 3 参数(无 args和 4 参数两种调用形式
const wrappedCallback = (err, result) => {
if (!settled) {
settled = true;
clearTimeout(timer);
callback(err, result);
}
};
if (args === null || args === undefined) {
Editor.Scene.callSceneScript(pluginName, method, wrappedCallback);
} else {
Editor.Scene.callSceneScript(pluginName, method, args, wrappedCallback);
}
}
/** /**
* 封装日志函数,同时发送给面板、保存到内存并在编辑器控制台打印 * 封装日志函数,同时发送给面板、保存到内存并在编辑器控制台打印
* @param {'info' | 'success' | 'warn' | 'error'} type 日志类型 * @param {'info' | 'success' | 'warn' | 'error'} type 日志类型
@@ -42,7 +119,7 @@ function addLog(type, message) {
* @returns {string} 拼接后的日志字符串 * @returns {string} 拼接后的日志字符串
*/ */
function getLogContent() { function getLogContent() {
return logBuffer.map(entry => `[${entry.time}] [${entry.type}] ${entry.content}`).join('\n'); return logBuffer.map((entry) => `[${entry.time}] [${entry.type}] ${entry.content}`).join("\n");
} }
/** /**
@@ -91,7 +168,8 @@ const getNewSceneTemplate = () => {
* @returns {Array<Object>} 工具定义数组 * @returns {Array<Object>} 工具定义数组
*/ */
const getToolsList = () => { const getToolsList = () => {
const globalPrecautions = "【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab必须通过 UUID 进行赋值。"; const globalPrecautions =
"【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab必须通过 UUID 进行赋值。";
return [ return [
{ {
name: "get_selected_node", name: "get_selected_node",
@@ -202,10 +280,19 @@ const getToolsList = () => {
type: "object", type: "object",
properties: { properties: {
nodeId: { type: "string", description: "节点 UUID" }, nodeId: { type: "string", description: "节点 UUID" },
action: { type: "string", enum: ["add", "remove", "update", "get"], description: "操作类型 (add: 添加组件, remove: 移除组件, update: 更新组件属性, get: 获取组件列表)" }, action: {
type: "string",
enum: ["add", "remove", "update", "get"],
description:
"操作类型 (add: 添加组件, remove: 移除组件, update: 更新组件属性, get: 获取组件列表)",
},
componentType: { type: "string", description: "组件类型,如 cc.Sprite (add/update 操作需要)" }, componentType: { type: "string", description: "组件类型,如 cc.Sprite (add/update 操作需要)" },
componentId: { type: "string", description: "组件 ID (remove/update 操作可选)" }, componentId: { type: "string", description: "组件 ID (remove/update 操作可选)" },
properties: { type: "object", description: "组件属性 (add/update 操作使用). 支持智能解析: 如果属性类型是组件但提供了节点UUID会自动查找对应组件。" }, properties: {
type: "object",
description:
"组件属性 (add/update 操作使用). 支持智能解析: 如果属性类型是组件但提供了节点UUID会自动查找对应组件。",
},
}, },
required: ["nodeId", "action"], required: ["nodeId", "action"],
}, },
@@ -335,7 +422,11 @@ const getToolsList = () => {
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
action: { type: "string", enum: ["create", "delete", "get_info", "update"], description: "操作类型" }, action: {
type: "string",
enum: ["create", "delete", "get_info", "update"],
description: "操作类型",
},
path: { type: "string", description: "材质路径,如 db://assets/materials/NewMaterial.mat" }, path: { type: "string", description: "材质路径,如 db://assets/materials/NewMaterial.mat" },
properties: { properties: {
type: "object", type: "object",
@@ -343,8 +434,8 @@ const getToolsList = () => {
properties: { properties: {
shaderUuid: { type: "string", description: "关联的 Shader (Effect) UUID" }, shaderUuid: { type: "string", description: "关联的 Shader (Effect) UUID" },
defines: { type: "object", description: "预编译宏定义" }, defines: { type: "object", description: "预编译宏定义" },
uniforms: { type: "object", description: "Uniform 参数列表" } uniforms: { type: "object", description: "Uniform 参数列表" },
} },
}, },
}, },
required: ["action", "path"], required: ["action", "path"],
@@ -356,7 +447,11 @@ const getToolsList = () => {
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
action: { type: "string", enum: ["create", "delete", "get_info", "update"], description: "操作类型" }, action: {
type: "string",
enum: ["create", "delete", "get_info", "update"],
description: "操作类型",
},
path: { type: "string", description: "纹理路径,如 db://assets/textures/NewTexture.png" }, path: { type: "string", description: "纹理路径,如 db://assets/textures/NewTexture.png" },
properties: { type: "object", description: "纹理属性" }, properties: { type: "object", description: "纹理属性" },
}, },
@@ -369,7 +464,11 @@ const getToolsList = () => {
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
action: { type: "string", enum: ["create", "delete", "read", "write", "get_info"], description: "操作类型" }, action: {
type: "string",
enum: ["create", "delete", "read", "write", "get_info"],
description: "操作类型",
},
path: { type: "string", description: "着色器路径,如 db://assets/effects/NewEffect.effect" }, path: { type: "string", description: "着色器路径,如 db://assets/effects/NewEffect.effect" },
content: { type: "string", description: "着色器内容 (create/write)" }, content: { type: "string", description: "着色器内容 (create/write)" },
}, },
@@ -382,7 +481,10 @@ const getToolsList = () => {
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
menuPath: { type: "string", description: "菜单项路径 (支持 'Project/Build' 或 'delete-node:UUID')" }, menuPath: {
type: "string",
description: "菜单项路径 (支持 'Project/Build' 或 'delete-node:UUID')",
},
}, },
required: ["menuPath"], required: ["menuPath"],
}, },
@@ -398,7 +500,11 @@ const getToolsList = () => {
items: { items: {
type: "object", type: "object",
properties: { properties: {
type: { type: "string", enum: ["insert", "delete", "replace"], description: "操作类型" }, type: {
type: "string",
enum: ["insert", "delete", "replace"],
description: "操作类型",
},
start: { type: "number", description: "起始偏移量 (字符索引)" }, start: { type: "number", description: "起始偏移量 (字符索引)" },
end: { type: "number", description: "结束偏移量 (delete/replace 用)" }, end: { type: "number", description: "结束偏移量 (delete/replace 用)" },
position: { type: "number", description: "插入位置 (insert 用)" }, position: { type: "number", description: "插入位置 (insert 用)" },
@@ -419,7 +525,11 @@ const getToolsList = () => {
type: "object", type: "object",
properties: { properties: {
limit: { type: "number", description: "输出限制" }, limit: { type: "number", description: "输出限制" },
type: { type: "string", enum: ["info", "warn", "error", "success", "mcp"], description: "输出类型 (info, warn, error, success, mcp)" }, type: {
type: "string",
enum: ["info", "warn", "error", "success", "mcp"],
description: "输出类型 (info, warn, error, success, mcp)",
},
}, },
}, },
}, },
@@ -441,23 +551,32 @@ const getToolsList = () => {
type: "object", type: "object",
properties: { properties: {
query: { type: "string", description: "搜索关键词或正则表达式模式" }, query: { type: "string", description: "搜索关键词或正则表达式模式" },
useRegex: { type: "boolean", description: "是否将 query 视为正则表达式 (仅在 matchType 为 'content', 'file_name' 或 'dir_name' 时生效)" }, useRegex: {
path: { type: "string", description: "搜索起点路径,例如 'db://assets/scripts'。默认为 'db://assets'" }, type: "boolean",
description:
"是否将 query 视为正则表达式 (仅在 matchType 为 'content', 'file_name' 或 'dir_name' 时生效)",
},
path: {
type: "string",
description: "搜索起点路径,例如 'db://assets/scripts'。默认为 'db://assets'",
},
matchType: { matchType: {
type: "string", type: "string",
enum: ["content", "file_name", "dir_name"], enum: ["content", "file_name", "dir_name"],
description: "匹配模式:'content' (内容关键词/正则), 'file_name' (搜索文件名), 'dir_name' (搜索文件夹名)" description:
"匹配模式:'content' (内容关键词/正则), 'file_name' (搜索文件名), 'dir_name' (搜索文件夹名)",
}, },
extensions: { extensions: {
type: "array", type: "array",
items: { type: "string" }, items: { type: "string" },
description: "限定文件后缀 (如 ['.js', '.ts'])。仅在 matchType 为 'content' 或 'file_name' 时有效。", description:
default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"] "限定文件后缀 (如 ['.js', '.ts'])。仅在 matchType 为 'content' 或 'file_name' 时有效。",
default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"],
}, },
includeSubpackages: { type: "boolean", default: true, description: "是否递归搜索子目录" } includeSubpackages: { type: "boolean", default: true, description: "是否递归搜索子目录" },
}, },
required: ["query"] required: ["query"],
} },
}, },
{ {
name: "manage_undo", name: "manage_undo",
@@ -468,12 +587,12 @@ const getToolsList = () => {
action: { action: {
type: "string", type: "string",
enum: ["undo", "redo", "begin_group", "end_group", "cancel_group"], enum: ["undo", "redo", "begin_group", "end_group", "cancel_group"],
description: "操作类型" description: "操作类型",
}, },
description: { type: "string", description: "撤销组的描述 (用于 begin_group)" } description: { type: "string", description: "撤销组的描述 (用于 begin_group)" },
}, },
required: ["action"] required: ["action"],
} },
}, },
{ {
name: "manage_vfx", name: "manage_vfx",
@@ -484,7 +603,7 @@ const getToolsList = () => {
action: { action: {
type: "string", type: "string",
enum: ["create", "update", "get_info"], enum: ["create", "update", "get_info"],
description: "操作类型" description: "操作类型",
}, },
nodeId: { type: "string", description: "节点 UUID (用于 update/get_info)" }, nodeId: { type: "string", description: "节点 UUID (用于 update/get_info)" },
properties: { properties: {
@@ -502,14 +621,14 @@ const getToolsList = () => {
speed: { type: "number", description: "速度" }, speed: { type: "number", description: "速度" },
angle: { type: "number", description: "角度" }, angle: { type: "number", description: "角度" },
gravity: { type: "object", properties: { x: { type: "number" }, y: { type: "number" } } }, gravity: { type: "object", properties: { x: { type: "number" }, y: { type: "number" } } },
file: { type: "string", description: "粒子文件路径 (plist) 或 texture 路径" } file: { type: "string", description: "粒子文件路径 (plist) 或 texture 路径" },
} },
}, },
name: { type: "string", description: "节点名称 (用于 create)" }, name: { type: "string", description: "节点名称 (用于 create)" },
parentId: { type: "string", description: "父节点 ID (用于 create)" } parentId: { type: "string", description: "父节点 ID (用于 create)" },
}, },
required: ["action"] required: ["action"],
} },
}, },
{ {
name: "get_sha", name: "get_sha",
@@ -517,10 +636,10 @@ const getToolsList = () => {
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
path: { type: "string", description: "文件路径,如 db://assets/scripts/Test.ts" } path: { type: "string", description: "文件路径,如 db://assets/scripts/Test.ts" },
}, },
required: ["path"] required: ["path"],
} },
}, },
{ {
name: "manage_animation", name: "manage_animation",
@@ -531,19 +650,17 @@ const getToolsList = () => {
action: { action: {
type: "string", type: "string",
enum: ["get_list", "get_info", "play", "stop", "pause", "resume"], enum: ["get_list", "get_info", "play", "stop", "pause", "resume"],
description: "操作类型" description: "操作类型",
}, },
nodeId: { type: "string", description: "节点 UUID" }, nodeId: { type: "string", description: "节点 UUID" },
clipName: { type: "string", description: "动画剪辑名称 (用于 play)" } clipName: { type: "string", description: "动画剪辑名称 (用于 play)" },
}, },
required: ["action", "nodeId"] required: ["action", "nodeId"],
} },
} },
]; ];
}; };
module.exports = { module.exports = {
"scene-script": "scene-script.js", "scene-script": "scene-script.js",
/** /**
@@ -603,8 +720,6 @@ module.exports = {
// 明确返回成功结构 // 明确返回成功结构
res.writeHead(200); res.writeHead(200);
return res.end(JSON.stringify({ tools: tools })); return res.end(JSON.stringify({ tools: tools }));
res.writeHead(200);
return res.end(JSON.stringify({ tools: tools }));
} }
if (url === "/list-resources") { if (url === "/list-resources") {
const resources = this.getResourcesList(); const resources = this.getResourcesList();
@@ -625,13 +740,17 @@ module.exports = {
addLog("success", `读取成功: ${uri}`); addLog("success", `读取成功: ${uri}`);
res.writeHead(200); res.writeHead(200);
// 返回 MCP Resource 格式: { contents: [{ uri, mimeType, text }] } // 返回 MCP Resource 格式: { contents: [{ uri, mimeType, text }] }
res.end(JSON.stringify({ res.end(
contents: [{ JSON.stringify({
uri: uri, contents: [
mimeType: "application/json", {
text: typeof content === 'string' ? content : JSON.stringify(content) uri: uri,
}] mimeType: "application/json",
})); text: typeof content === "string" ? content : JSON.stringify(content),
},
],
}),
);
}); });
} catch (e) { } catch (e) {
res.writeHead(500); res.writeHead(500);
@@ -642,40 +761,46 @@ module.exports = {
if (url === "/call-tool") { if (url === "/call-tool") {
try { try {
const { name, arguments: args } = JSON.parse(body || "{}"); const { name, arguments: args } = JSON.parse(body || "{}");
addLog("mcp", `REQ -> [${name}]`); addLog("mcp", `REQ -> [${name}] (队列长度: ${commandQueue.length})`);
this.handleMcpCall(name, args, (err, result) => { // 【关键修复】所有 MCP 指令通过队列串行化执行,
const response = { // 防止 AssetDB.refresh 等异步操作被并发请求打断导致编辑器卡死
content: [ enqueueCommand((done) => {
{ this.handleMcpCall(name, args, (err, result) => {
type: "text", const response = {
text: err content: [
? `Error: ${err}` {
: typeof result === "object" type: "text",
? JSON.stringify(result, null, 2) text: err
: result, ? `Error: ${err}`
}, : typeof result === "object"
], ? JSON.stringify(result, null, 2)
}; : result,
if (err) { },
addLog("error", `RES <- [${name}] 失败: ${err}`); ],
} else { };
// 成功时尝试捕获简单的结果预览(如果是字符串或简短对象) if (err) {
let preview = ""; addLog("error", `RES <- [${name}] 失败: ${err}`);
if (typeof result === 'string') { } else {
preview = result.length > 100 ? result.substring(0, 100) + "..." : result; // 成功时尝试捕获简单的结果预览(如果是字符串或简短对象)
} else if (typeof result === 'object') { let preview = "";
try { if (typeof result === "string") {
const jsonStr = JSON.stringify(result); preview = result.length > 100 ? result.substring(0, 100) + "..." : result;
preview = jsonStr.length > 100 ? jsonStr.substring(0, 100) + "..." : jsonStr; } else if (typeof result === "object") {
} catch (e) { try {
preview = "Object (Circular/Unserializable)"; const jsonStr = JSON.stringify(result);
preview =
jsonStr.length > 100 ? jsonStr.substring(0, 100) + "..." : jsonStr;
} catch (e) {
preview = "Object (Circular/Unserializable)";
}
} }
addLog("success", `RES <- [${name}] 成功 : ${preview}`);
} }
addLog("success", `RES <- [${name}] 成功 : ${preview}`); res.writeHead(200);
} res.end(JSON.stringify(response));
res.writeHead(200); done(); // 当前指令完成,释放队列给下一个指令
res.end(JSON.stringify(response)); });
}); });
} catch (e) { } catch (e) {
if (e instanceof SyntaxError) { if (e instanceof SyntaxError) {
@@ -729,20 +854,20 @@ module.exports = {
uri: "cocos://hierarchy", uri: "cocos://hierarchy",
name: "Scene Hierarchy", name: "Scene Hierarchy",
description: "当前场景层级的 JSON 快照", description: "当前场景层级的 JSON 快照",
mimeType: "application/json" mimeType: "application/json",
}, },
{ {
uri: "cocos://selection", uri: "cocos://selection",
name: "Current Selection", name: "Current Selection",
description: "当前选中节点的 UUID 列表", description: "当前选中节点的 UUID 列表",
mimeType: "application/json" mimeType: "application/json",
}, },
{ {
uri: "cocos://logs/latest", uri: "cocos://logs/latest",
name: "Editor Logs", name: "Editor Logs",
description: "最新的编辑器日志 (内存缓存)", description: "最新的编辑器日志 (内存缓存)",
mimeType: "text/plain" mimeType: "text/plain",
} },
]; ];
}, },
@@ -807,7 +932,7 @@ module.exports = {
path: "name", path: "name",
type: "String", type: "String",
value: args.newName, value: args.newName,
isSubProp: false isSubProp: false,
}); });
callback(null, `节点名称已更新为 ${args.newName}`); callback(null, `节点名称已更新为 ${args.newName}`);
break; break;
@@ -822,12 +947,12 @@ module.exports = {
break; break;
case "get_scene_hierarchy": case "get_scene_hierarchy":
Editor.Scene.callSceneScript("mcp-bridge", "get-hierarchy", callback); callSceneScriptWithTimeout("mcp-bridge", "get-hierarchy", null, callback);
break; break;
case "update_node_transform": case "update_node_transform":
// 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统 // 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统
Editor.Scene.callSceneScript("mcp-bridge", "update-node-transform", args, (err, result) => { callSceneScriptWithTimeout("mcp-bridge", "update-node-transform", args, (err, result) => {
if (err) { if (err) {
addLog("error", `Transform update failed: ${err}`); addLog("error", `Transform update failed: ${err}`);
callback(err); callback(err);
@@ -870,14 +995,16 @@ module.exports = {
case "create_node": case "create_node":
if (args.type === "sprite" || args.type === "button") { if (args.type === "sprite" || args.type === "button") {
const splashUuid = Editor.assetdb.urlToUuid("db://internal/image/default_sprite_splash.png/default_sprite_splash"); const splashUuid = Editor.assetdb.urlToUuid(
"db://internal/image/default_sprite_splash.png/default_sprite_splash",
);
args.defaultSpriteUuid = splashUuid; args.defaultSpriteUuid = splashUuid;
} }
Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, callback); callSceneScriptWithTimeout("mcp-bridge", "create-node", args, callback);
break; break;
case "manage_components": case "manage_components":
Editor.Scene.callSceneScript("mcp-bridge", "manage-components", args, callback); callSceneScriptWithTimeout("mcp-bridge", "manage-components", args, callback);
break; break;
case "manage_script": case "manage_script":
@@ -911,7 +1038,7 @@ module.exports = {
break; break;
case "find_gameobjects": case "find_gameobjects":
Editor.Scene.callSceneScript("mcp-bridge", "find-gameobjects", args, callback); callSceneScriptWithTimeout("mcp-bridge", "find-gameobjects", args, callback);
break; break;
case "manage_material": case "manage_material":
@@ -953,7 +1080,7 @@ module.exports = {
case "manage_vfx": case "manage_vfx":
// 【修复】在主进程预先解析 URL 为 UUID因为渲染进程(scene-script)无法访问 Editor.assetdb // 【修复】在主进程预先解析 URL 为 UUID因为渲染进程(scene-script)无法访问 Editor.assetdb
if (args.properties && args.properties.file) { if (args.properties && args.properties.file) {
if (typeof args.properties.file === 'string' && args.properties.file.startsWith("db://")) { if (typeof args.properties.file === "string" && args.properties.file.startsWith("db://")) {
const uuid = Editor.assetdb.urlToUuid(args.properties.file); const uuid = Editor.assetdb.urlToUuid(args.properties.file);
if (uuid) { if (uuid) {
args.properties.file = uuid; // 替换为 UUID args.properties.file = uuid; // 替换为 UUID
@@ -967,7 +1094,7 @@ module.exports = {
"db://internal/image/default_sprite_splash", "db://internal/image/default_sprite_splash",
"db://internal/image/default_sprite_splash.png", "db://internal/image/default_sprite_splash.png",
"db://internal/image/default_particle", "db://internal/image/default_particle",
"db://internal/image/default_particle.png" "db://internal/image/default_particle.png",
]; ];
for (const path of defaultPaths) { for (const path of defaultPaths) {
@@ -983,7 +1110,7 @@ module.exports = {
addLog("warn", "[mcp-bridge] Failed to resolve any default sprite UUID."); addLog("warn", "[mcp-bridge] Failed to resolve any default sprite UUID.");
} }
Editor.Scene.callSceneScript("mcp-bridge", "manage-vfx", args, callback); callSceneScriptWithTimeout("mcp-bridge", "manage-vfx", args, callback);
break; break;
default: default:
@@ -1014,7 +1141,7 @@ module.exports = {
Editor.assetdb.create( Editor.assetdb.create(
scriptPath, scriptPath,
content || content ||
`const { ccclass, property } = cc._decorator; `const { ccclass, property } = cc._decorator;
@ccclass @ccclass
export default class NewScript extends cc.Component { export default class NewScript extends cc.Component {
@@ -1097,29 +1224,33 @@ export default class NewScript extends cc.Component {
}, },
/** /**
* 批量执行多个 MCP 工具操作 * 批量执行多个 MCP 工具操作(串行链式执行)
* 【重要修复】原并行 forEach 会导致多个 AssetDB 操作同时执行引发编辑器卡死,
* 改为串行执行确保每个操作完成后再执行下一个
* @param {Object} args 参数 (operations 数组) * @param {Object} args 参数 (operations 数组)
* @param {Function} callback 完成回调 * @param {Function} callback 完成回调
*/ */
batchExecute(args, callback) { batchExecute(args, callback) {
const { operations } = args; const { operations } = args;
const results = []; const results = [];
let completed = 0;
if (!operations || operations.length === 0) { if (!operations || operations.length === 0) {
return callback("未提供任何操作指令"); return callback("未提供任何操作指令");
} }
operations.forEach((operation, index) => { let index = 0;
const next = () => {
if (index >= operations.length) {
return callback(null, results);
}
const operation = operations[index];
this.handleMcpCall(operation.tool, operation.params, (err, result) => { this.handleMcpCall(operation.tool, operation.params, (err, result) => {
results[index] = { tool: operation.tool, error: err, result: result }; results[index] = { tool: operation.tool, error: err, result: result };
completed++; index++;
next();
if (completed === operations.length) {
callback(null, results);
}
}); });
}); };
next();
}, },
/** /**
@@ -1169,7 +1300,6 @@ export default class NewScript extends cc.Component {
}); });
break; break;
case "get_info": case "get_info":
try { try {
if (!Editor.assetdb.exists(path)) { if (!Editor.assetdb.exists(path)) {
@@ -1302,9 +1432,9 @@ export default class NewScript extends cc.Component {
// 解析目标目录和文件名 // 解析目标目录和文件名
// db://assets/folder/PrefabName.prefab -> db://assets/folder, PrefabName // db://assets/folder/PrefabName.prefab -> db://assets/folder, PrefabName
const targetDir = prefabPath.substring(0, prefabPath.lastIndexOf('/')); const targetDir = prefabPath.substring(0, prefabPath.lastIndexOf("/"));
const fileName = prefabPath.substring(prefabPath.lastIndexOf('/') + 1); const fileName = prefabPath.substring(prefabPath.lastIndexOf("/") + 1);
const prefabName = fileName.replace('.prefab', ''); const prefabName = fileName.replace(".prefab", "");
// 1. 重命名节点以匹配预制体名称 // 1. 重命名节点以匹配预制体名称
Editor.Ipc.sendToPanel("scene", "scene:set-property", { Editor.Ipc.sendToPanel("scene", "scene:set-property", {
@@ -1312,7 +1442,7 @@ export default class NewScript extends cc.Component {
path: "name", path: "name",
type: "String", type: "String",
value: prefabName, value: prefabName,
isSubProp: false isSubProp: false,
}); });
// 2. 发送创建命令 (参数: [uuids], dirPath) // 2. 发送创建命令 (参数: [uuids], dirPath)
@@ -1342,7 +1472,7 @@ export default class NewScript extends cc.Component {
} }
// 实例化预制体 // 实例化预制体
const prefabUuid = Editor.assetdb.urlToUuid(prefabPath); const prefabUuid = Editor.assetdb.urlToUuid(prefabPath);
Editor.Scene.callSceneScript( callSceneScriptWithTimeout(
"mcp-bridge", "mcp-bridge",
"instantiate-prefab", "instantiate-prefab",
{ {
@@ -1402,7 +1532,7 @@ export default class NewScript extends cc.Component {
break; break;
case "refresh_editor": case "refresh_editor":
// 刷新编辑器 // 刷新编辑器
const refreshPath = (properties && properties.path) ? properties.path : 'db://assets/scripts'; const refreshPath = properties && properties.path ? properties.path : "db://assets/scripts";
Editor.assetdb.refresh(refreshPath, (err) => { Editor.assetdb.refresh(refreshPath, (err) => {
if (err) { if (err) {
addLog("error", `刷新失败: ${err}`); addLog("error", `刷新失败: ${err}`);
@@ -1559,11 +1689,11 @@ CCProgram fs %{
_effectAsset: properties.shaderUuid ? { __uuid__: properties.shaderUuid } : null, _effectAsset: properties.shaderUuid ? { __uuid__: properties.shaderUuid } : null,
_techniqueIndex: 0, _techniqueIndex: 0,
_techniqueData: { _techniqueData: {
"0": { 0: {
defines: properties.defines || {}, defines: properties.defines || {},
props: properties.uniforms || {} props: properties.uniforms || {},
} },
} },
}; };
Editor.assetdb.create(matPath, JSON.stringify(materialData, null, 2), (err) => { Editor.assetdb.create(matPath, JSON.stringify(materialData, null, 2), (err) => {
@@ -1654,11 +1784,12 @@ CCProgram fs %{
} }
// 1. 准备文件内容 (优先使用 properties.content否则使用默认 1x1) // 1. 准备文件内容 (优先使用 properties.content否则使用默认 1x1)
let base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; let base64Data =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
if (properties && properties.content) { if (properties && properties.content) {
base64Data = properties.content; base64Data = properties.content;
} }
const buffer = Buffer.from(base64Data, 'base64'); const buffer = Buffer.from(base64Data, "base64");
try { try {
// 2. 写入物理文件 // 2. 写入物理文件
@@ -1687,7 +1818,7 @@ CCProgram fs %{
// 注意Cocos 2.4 纹理 Meta 中 subMetas 下通常有一个与纹理同名的 key (或者主要的一个 key) // 注意Cocos 2.4 纹理 Meta 中 subMetas 下通常有一个与纹理同名的 key (或者主要的一个 key)
if (properties.border) { if (properties.border) {
// 确保类型是 sprite // 确保类型是 sprite
meta.type = 'sprite'; meta.type = "sprite";
// 找到 SpriteFrame 的 subMeta // 找到 SpriteFrame 的 subMeta
const subKeys = Object.keys(meta.subMetas); const subKeys = Object.keys(meta.subMetas);
@@ -1772,8 +1903,8 @@ CCProgram fs %{
// 更新 9-slice border // 更新 9-slice border
if (properties.border) { if (properties.border) {
// 确保类型是 sprite // 确保类型是 sprite
if (meta.type !== 'sprite') { if (meta.type !== "sprite") {
meta.type = 'sprite'; meta.type = "sprite";
changed = true; changed = true;
} }
@@ -1790,11 +1921,13 @@ CCProgram fs %{
// 方式 1: standard array style // 方式 1: standard array style
if (subMeta.border !== undefined) { if (subMeta.border !== undefined) {
const oldBorder = subMeta.border; const oldBorder = subMeta.border;
if (!oldBorder || if (
!oldBorder ||
oldBorder[0] !== newBorder[0] || oldBorder[0] !== newBorder[0] ||
oldBorder[1] !== newBorder[1] || oldBorder[1] !== newBorder[1] ||
oldBorder[2] !== newBorder[2] || oldBorder[2] !== newBorder[2] ||
oldBorder[3] !== newBorder[3]) { oldBorder[3] !== newBorder[3]
) {
subMeta.border = newBorder; subMeta.border = newBorder;
changed = true; changed = true;
} }
@@ -1802,11 +1935,12 @@ CCProgram fs %{
// 方式 2: individual fields style (common in 2.3.x) // 方式 2: individual fields style (common in 2.3.x)
else if (subMeta.borderTop !== undefined) { else if (subMeta.borderTop !== undefined) {
// top, bottom, left, right // top, bottom, left, right
if (subMeta.borderTop !== newBorder[0] || if (
subMeta.borderTop !== newBorder[0] ||
subMeta.borderBottom !== newBorder[1] || subMeta.borderBottom !== newBorder[1] ||
subMeta.borderLeft !== newBorder[2] || subMeta.borderLeft !== newBorder[2] ||
subMeta.borderRight !== newBorder[3]) { subMeta.borderRight !== newBorder[3]
) {
subMeta.borderTop = newBorder[0]; subMeta.borderTop = newBorder[0];
subMeta.borderBottom = newBorder[1]; subMeta.borderBottom = newBorder[1];
subMeta.borderLeft = newBorder[2]; subMeta.borderLeft = newBorder[2];
@@ -1843,7 +1977,6 @@ CCProgram fs %{
} }
}, },
/** /**
* 对文件应用一系列精确的文本编辑操作 * 对文件应用一系列精确的文本编辑操作
* @param {Object} args 参数 * @param {Object} args 参数
@@ -1881,9 +2014,7 @@ CCProgram fs %{
switch (edit.type) { switch (edit.type) {
case "insert": case "insert":
updatedContent = updatedContent =
updatedContent.slice(0, edit.position) + updatedContent.slice(0, edit.position) + edit.text + updatedContent.slice(edit.position);
edit.text +
updatedContent.slice(edit.position);
break; break;
case "delete": case "delete":
updatedContent = updatedContent.slice(0, edit.start) + updatedContent.slice(edit.end); updatedContent = updatedContent.slice(0, edit.start) + updatedContent.slice(edit.end);
@@ -1903,7 +2034,6 @@ CCProgram fs %{
if (err) addLog("warn", `刷新失败 ${filePath}: ${err}`); if (err) addLog("warn", `刷新失败 ${filePath}: ${err}`);
callback(null, `文本编辑已应用: ${filePath}`); callback(null, `文本编辑已应用: ${filePath}`);
}); });
} catch (err) { } catch (err) {
callback(`操作失败: ${err.message}`); callback(`操作失败: ${err.message}`);
} }
@@ -1937,21 +2067,21 @@ CCProgram fs %{
// 菜单项映射表 (Cocos Creator 2.4.x IPC) // 菜单项映射表 (Cocos Creator 2.4.x IPC)
// 参考: IPC_MESSAGES.md // 参考: IPC_MESSAGES.md
const menuMap = { const menuMap = {
'File/New Scene': 'scene:new-scene', "File/New Scene": "scene:new-scene",
'File/Save Scene': 'scene:stash-and-save', "File/Save Scene": "scene:stash-and-save",
'File/Save': 'scene:stash-and-save', // 别名 "File/Save": "scene:stash-and-save", // 别名
'Edit/Undo': 'scene:undo', "Edit/Undo": "scene:undo",
'Edit/Redo': 'scene:redo', "Edit/Redo": "scene:redo",
'Edit/Delete': 'scene:delete-nodes', "Edit/Delete": "scene:delete-nodes",
'Delete': 'scene:delete-nodes', Delete: "scene:delete-nodes",
'delete': 'scene:delete-nodes', delete: "scene:delete-nodes",
}; };
// 特殊处理 delete-node:UUID 格式 // 特殊处理 delete-node:UUID 格式
if (menuPath.startsWith("delete-node:")) { if (menuPath.startsWith("delete-node:")) {
const uuid = menuPath.split(":")[1]; const uuid = menuPath.split(":")[1];
if (uuid) { if (uuid) {
Editor.Scene.callSceneScript('mcp-bridge', 'delete-node', { uuid }, (err, result) => { callSceneScriptWithTimeout("mcp-bridge", "delete-node", { uuid }, (err, result) => {
if (err) callback(err); if (err) callback(err);
else callback(null, result || `Node ${uuid} deleted via scene script`); else callback(null, result || `Node ${uuid} deleted via scene script`);
}); });
@@ -1963,7 +2093,7 @@ CCProgram fs %{
const ipcMsg = menuMap[menuPath]; const ipcMsg = menuMap[menuPath];
try { try {
// 获取当前选中的节点进行删除(如果该消息是删除操作) // 获取当前选中的节点进行删除(如果该消息是删除操作)
if (ipcMsg === 'scene:delete-nodes') { if (ipcMsg === "scene:delete-nodes") {
const selection = Editor.Selection.curSelection("node"); const selection = Editor.Selection.curSelection("node");
if (selection.length > 0) { if (selection.length > 0) {
Editor.Ipc.sendToMain(ipcMsg, selection); Editor.Ipc.sendToMain(ipcMsg, selection);
@@ -1987,7 +2117,7 @@ CCProgram fs %{
try { try {
// 注意Cocos Creator 2.x 的 menu:click 通常需要 Electron 菜单 ID而不只是路径 // 注意Cocos Creator 2.x 的 menu:click 通常需要 Electron 菜单 ID而不只是路径
// 这里做个尽力而为的尝试 // 这里做个尽力而为的尝试
Editor.Ipc.sendToMain('menu:click', menuPath); Editor.Ipc.sendToMain("menu:click", menuPath);
callback(null, `通用菜单动作已发送: ${menuPath} (仅支持项保证成功)`); callback(null, `通用菜单动作已发送: ${menuPath} (仅支持项保证成功)`);
} catch (e) { } catch (e) {
callback(`执行菜单项失败: ${menuPath}`); callback(`执行菜单项失败: ${menuPath}`);
@@ -2036,11 +2166,23 @@ CCProgram fs %{
// 这里暂时只做简单的括号匹配检查或直接通过,但给出一个 Warning // 这里暂时只做简单的括号匹配检查或直接通过,但给出一个 Warning
// 检查是否有 class 定义 (简单的启发式检查) // 检查是否有 class 定义 (简单的启发式检查)
if (!content.includes('class ') && !content.includes('interface ') && !content.includes('enum ') && !content.includes('export ')) { if (
return callback(null, { valid: true, message: "警告: TypeScript 文件似乎缺少标准定义 (class/interface/export),但由于缺少编译器,已跳过基础语法检查。" }); !content.includes("class ") &&
!content.includes("interface ") &&
!content.includes("enum ") &&
!content.includes("export ")
) {
return callback(null, {
valid: true,
message:
"警告: TypeScript 文件似乎缺少标准定义 (class/interface/export),但由于缺少编译器,已跳过基础语法检查。",
});
} }
callback(null, { valid: true, message: "TypeScript 基础检查通过。(完整编译验证需要通过编辑器构建流程)" }); callback(null, {
valid: true,
message: "TypeScript 基础检查通过。(完整编译验证需要通过编辑器构建流程)",
});
} else { } else {
callback(null, { valid: true, message: "未知的脚本类型,跳过验证。" }); callback(null, { valid: true, message: "未知的脚本类型,跳过验证。" });
} }
@@ -2122,7 +2264,11 @@ CCProgram fs %{
const str = func.toString(); const str = func.toString();
const match = str.match(/function\s.*?\(([^)]*)\)/) || str.match(/.*?\(([^)]*)\)/); const match = str.match(/function\s.*?\(([^)]*)\)/) || str.match(/.*?\(([^)]*)\)/);
if (match) { if (match) {
return match[1].split(",").map(arg => arg.trim()).filter(a => a).join(", "); return match[1]
.split(",")
.map((arg) => arg.trim())
.filter((a) => a)
.join(", ");
} }
return `${func.length} args`; return `${func.length} args`;
} catch (e) { } catch (e) {
@@ -2137,18 +2283,21 @@ CCProgram fs %{
const proto = Object.getPrototypeOf(obj); const proto = Object.getPrototypeOf(obj);
// 组合自身属性和原型属性 // 组合自身属性和原型属性
const allKeys = new Set([...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertyNames(proto || {})]); const allKeys = new Set([
...Object.getOwnPropertyNames(obj),
...Object.getOwnPropertyNames(proto || {}),
]);
allKeys.forEach(key => { allKeys.forEach((key) => {
if (key.startsWith("_")) return; // 跳过私有属性 if (key.startsWith("_")) return; // 跳过私有属性
try { try {
const val = obj[key]; const val = obj[key];
if (typeof val === 'function') { if (typeof val === "function") {
props[key] = `func(${getArgs(val)})`; props[key] = `func(${getArgs(val)})`;
} else { } else {
props[key] = typeof val; props[key] = typeof val;
} }
} catch (e) { } } catch (e) {}
}); });
return { name, exists: true, props }; return { name, exists: true, props };
}; };
@@ -2161,11 +2310,11 @@ CCProgram fs %{
"Editor.Panel": Editor.Panel, "Editor.Panel": Editor.Panel,
"Editor.Scene": Editor.Scene, "Editor.Scene": Editor.Scene,
"Editor.Utils": Editor.Utils, "Editor.Utils": Editor.Utils,
"Editor.remote": Editor.remote "Editor.remote": Editor.remote,
}; };
const report = {}; const report = {};
Object.keys(standardObjects).forEach(key => { Object.keys(standardObjects).forEach((key) => {
report[key] = inspectObj(key, standardObjects[key]); report[key] = inspectObj(key, standardObjects[key]);
}); });
@@ -2184,11 +2333,11 @@ CCProgram fs %{
"Editor.Selection.select", "Editor.Selection.select",
"Editor.Selection.clear", "Editor.Selection.clear",
"Editor.Selection.curSelection", "Editor.Selection.curSelection",
"Editor.Selection.curGlobalActivate" "Editor.Selection.curGlobalActivate",
]; ];
const checklistResults = {}; const checklistResults = {};
forumChecklist.forEach(path => { forumChecklist.forEach((path) => {
const parts = path.split("."); const parts = path.split(".");
let curr = global; // 在主进程中Editor 是全局的 let curr = global; // 在主进程中Editor 是全局的
let exists = true; let exists = true;
@@ -2200,7 +2349,11 @@ CCProgram fs %{
break; break;
} }
} }
checklistResults[path] = exists ? (typeof curr === 'function' ? `Available(${getArgs(curr)})` : "Available") : "Missing"; checklistResults[path] = exists
? typeof curr === "function"
? `Available(${getArgs(curr)})`
: "Available"
: "Missing";
}); });
addLog("info", `[API Inspector] Standard Objects:\n${JSON.stringify(report, null, 2)}`); addLog("info", `[API Inspector] Standard Objects:\n${JSON.stringify(report, null, 2)}`);
@@ -2211,7 +2364,7 @@ CCProgram fs %{
const builtinPackages = ["scene", "builder", "assets"]; // 核心内置包 const builtinPackages = ["scene", "builder", "assets"]; // 核心内置包
const fs = require("fs"); const fs = require("fs");
builtinPackages.forEach(pkgName => { builtinPackages.forEach((pkgName) => {
try { try {
const pkgPath = Editor.url(`packages://${pkgName}/package.json`); const pkgPath = Editor.url(`packages://${pkgName}/package.json`);
if (pkgPath && fs.existsSync(pkgPath)) { if (pkgPath && fs.existsSync(pkgPath)) {
@@ -2233,8 +2386,6 @@ CCProgram fs %{
}, },
}, },
// 全局文件搜索 // 全局文件搜索
// 项目搜索 (升级版 find_in_file) // 项目搜索 (升级版 find_in_file)
searchProject(args, callback) { searchProject(args, callback) {
@@ -2276,7 +2427,15 @@ CCProgram fs %{
if (results.length >= MAX_RESULTS) return; if (results.length >= MAX_RESULTS) return;
// 忽略隐藏文件和常用忽略目录 // 忽略隐藏文件和常用忽略目录
if (file.startsWith('.') || file === 'node_modules' || file === 'bin' || file === 'local' || file === 'library' || file === 'temp') return; if (
file.startsWith(".") ||
file === "node_modules" ||
file === "bin" ||
file === "local" ||
file === "library" ||
file === "temp"
)
return;
const filePath = pathModule.join(dir, file); const filePath = pathModule.join(dir, file);
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
@@ -2285,12 +2444,15 @@ CCProgram fs %{
// 目录名搜索 // 目录名搜索
if (mode === "dir_name") { if (mode === "dir_name") {
if (checkMatch(file)) { if (checkMatch(file)) {
const relativePath = pathModule.relative(Editor.assetdb.urlToFspath("db://assets"), filePath); const relativePath = pathModule.relative(
const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join('/'); Editor.assetdb.urlToFspath("db://assets"),
filePath,
);
const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/");
results.push({ results.push({
filePath: dbPath, filePath: dbPath,
type: "directory", type: "directory",
name: file name: file,
}); });
} }
} }
@@ -2314,12 +2476,15 @@ CCProgram fs %{
// 简化逻辑:对文件名搜索,也检查后缀(如果用户未传则用默认列表) // 简化逻辑:对文件名搜索,也检查后缀(如果用户未传则用默认列表)
if (validExtensions.includes(ext)) { if (validExtensions.includes(ext)) {
if (checkMatch(file)) { if (checkMatch(file)) {
const relativePath = pathModule.relative(Editor.assetdb.urlToFspath("db://assets"), filePath); const relativePath = pathModule.relative(
const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join('/'); Editor.assetdb.urlToFspath("db://assets"),
filePath,
);
const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/");
results.push({ results.push({
filePath: dbPath, filePath: dbPath,
type: "file", type: "file",
name: file name: file,
}); });
} }
} }
@@ -2330,17 +2495,21 @@ CCProgram fs %{
else if (mode === "content") { else if (mode === "content") {
if (validExtensions.includes(ext)) { if (validExtensions.includes(ext)) {
try { try {
const content = fs.readFileSync(filePath, 'utf8'); const content = fs.readFileSync(filePath, "utf8");
const lines = content.split('\n'); const lines = content.split("\n");
lines.forEach((line, index) => { lines.forEach((line, index) => {
if (results.length >= MAX_RESULTS) return; if (results.length >= MAX_RESULTS) return;
if (checkMatch(line)) { if (checkMatch(line)) {
const relativePath = pathModule.relative(Editor.assetdb.urlToFspath("db://assets"), filePath); const relativePath = pathModule.relative(
const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join('/'); Editor.assetdb.urlToFspath("db://assets"),
filePath,
);
const dbPath =
"db://assets/" + relativePath.split(pathModule.sep).join("/");
results.push({ results.push({
filePath: dbPath, filePath: dbPath,
line: index + 1, line: index + 1,
content: line.trim() content: line.trim(),
}); });
} }
}); });
@@ -2412,9 +2581,9 @@ CCProgram fs %{
try { try {
const fileBuffer = fs.readFileSync(fspath); const fileBuffer = fs.readFileSync(fspath);
const hashSum = crypto.createHash('sha256'); const hashSum = crypto.createHash("sha256");
hashSum.update(fileBuffer); hashSum.update(fileBuffer);
const sha = hashSum.digest('hex'); const sha = hashSum.digest("hex");
callback(null, { path: url, sha: sha }); callback(null, { path: url, sha: sha });
} catch (err) { } catch (err) {
callback(`计算 SHA 失败: ${err.message}`); callback(`计算 SHA 失败: ${err.message}`);
@@ -2424,6 +2593,6 @@ CCProgram fs %{
// 管理动画 // 管理动画
manageAnimation(args, callback) { manageAnimation(args, callback) {
// 转发给场景脚本处理 // 转发给场景脚本处理
Editor.Scene.callSceneScript("mcp-bridge", "manage-animation", args, callback); callSceneScriptWithTimeout("mcp-bridge", "manage-animation", args, callback);
}, },
}; };

View File

@@ -3,22 +3,25 @@
## 1. 资源与预制体管理 ## 1. 资源与预制体管理
### 1.1 禁止手动干预 `.meta` 文件 ### 1.1 禁止手动干预 `.meta` 文件
* **问题**:手动使用 `manage_asset` 创建或修改以 `.meta` 结尾的文件会导致编辑器报错(如 `Invalid assetpath, must not use .meta as suffix`)。
* **原因**Cocos Creator 资源数据库会自动识别新资源并生成对应的 `.meta` 文件。外部强行介入会破坏资源索引一致性 - **问题**:手动使用 `manage_asset` 创建或修改以 `.meta` 结尾的文件会导致编辑器报错(如 `Invalid assetpath, must not use .meta as suffix`
* **最佳实践**:始终使用高级资源工具(如 `prefab_management`)来处理资源,让编辑器自行管理 `.meta` 文件 - **原因**Cocos Creator 资源数据库会自动识别新资源并生成对应的 `.meta` 文件。外部强行介入会破坏资源索引一致性
- **最佳实践**:始终使用高级资源工具(如 `prefab_management`)来处理资源,让编辑器自行管理 `.meta` 文件。
### 1.2 预制体的正确创建流程 ### 1.2 预制体的正确创建流程
* **推荐工具**:使用 `prefab_management``create` 操作。
* **核心逻辑**该工具会同步处理节点的序列化、db 路径映射以及资源刷新Refresh确保预制体及其配套的 `.meta` 文件原子化生成 - **推荐工具**:使用 `prefab_management``create` 操作
- **核心逻辑**该工具会同步处理节点的序列化、db 路径映射以及资源刷新Refresh确保预制体及其配套的 `.meta` 文件原子化生成。
--- ---
## 2. 脚本属性Inspector关联 ## 2. 脚本属性Inspector关联
### 2.1 路径与 UUID 的区别 ### 2.1 路径与 UUID 的区别
* **问题**:在 Inspector 面板中,脚本属性(如 `cc.Prefab``cc.Node`)若通过路径关联,经常会出现 "Type Error"。
* **原因**:编辑器 Inspector 期望获得的是引擎内部的对象引用。虽然 `db://` 路径可以指向文件,但在属性赋值层面,编辑器无法自动将字符串路径转换为对应的类实例 - **问题**:在 Inspector 面板中,脚本属性(如 `cc.Prefab``cc.Node`)若通过路径关联,经常会出现 "Type Error"
* **最佳实践** - **原因**:编辑器 Inspector 期望获得的是引擎内部的对象引用。虽然 `db://` 路径可以指向文件,但在属性赋值层面,编辑器无法自动将字符串路径转换为对应的类实例。
- **最佳实践**
1. 通过 `manage_asset -> get_info` 获取资源的 **UUID** 1. 通过 `manage_asset -> get_info` 获取资源的 **UUID**
2. 在调用 `manage_components -> update` 时,**优先使用 UUID** 进行赋值。 2. 在调用 `manage_components -> update` 时,**优先使用 UUID** 进行赋值。
3. UUID 是底层唯一标识,能确保编辑器精准识别资源类型并正确完成引用链接。 3. UUID 是底层唯一标识,能确保编辑器精准识别资源类型并正确完成引用链接。
@@ -28,69 +31,96 @@
## 3. 场景同步与编辑器状态 ## 3. 场景同步与编辑器状态
### 3.1 刷新与编译 ### 3.1 刷新与编译
* **注意事项**:创建新脚本后,必须调用 `manage_editor -> refresh_editor` 或等待几秒钟以触发编译。
* **风险**:在脚本编译完成前尝试将其作为组件添加到节点,会导致 `添加组件失败: Cannot read property 'constructor' of null` 或找不到脚本组件的问题 - **注意事项**:创建新脚本后,必须调用 `manage_editor -> refresh_editor` 或等待几秒钟以触发编译
- **风险**:在脚本编译完成前尝试将其作为组件添加到节点,会导致 `添加组件失败: Cannot read property 'constructor' of null` 或找不到脚本组件的问题。
### 3.2 节点删除 (IPC 协议) ### 3.2 节点删除 (IPC 协议)
* **正确协议**:应使用 `Editor.Ipc.sendToMain('scene:delete-nodes', uuid_or_array)`
* **注意**:不要误用 `scene:delete-selected`,因为它在某些版本的编辑器底层不接受参数或行为不一致 - **正确协议**:应使用 `Editor.Ipc.sendToMain('scene:delete-nodes', uuid_or_array)`
* **技巧**:在 `mcp-bridge` 中,调用 `execute_menu_item("delete-node:NODE_UUID")` 会走场景脚本的直连删除,而 `execute_menu_item("Delete")` 则会走主进程的 `scene:delete-nodes` 并自动带上选中的节点 - **注意**:不要误用 `scene:delete-selected`,因为它在某些版本的编辑器底层不接受参数或行为不一致
- **技巧**:在 `mcp-bridge` 中,调用 `execute_menu_item("delete-node:NODE_UUID")` 会走场景脚本的直连删除,而 `execute_menu_item("Delete")` 则会走主进程的 `scene:delete-nodes` 并自动带上选中的节点。
--- ---
## 4. MCP Bridge 源码补丁说明 ## 4. MCP Bridge 源码补丁说明
### 4.1 属性解析增强 (Asset 序列化修复) ### 4.1 属性解析增强 (Asset 序列化修复)
* **改进点**:在 `scene-script.js``applyProperties` 中通过 `cc.AssetLibrary.loadAsset` 解决了资源属性在 Inspector 报错 "Type Error" 的问题。
* **原理** - **改进点**:在 `scene-script.js``applyProperties` 中通过 `cc.AssetLibrary.loadAsset` 解决了资源属性在 Inspector 报错 "Type Error" 的问题。
* **问题根源**:在场景进程中直接将 UUID 字符串赋给资源属性(如 `comp.prefab = "uuid"`),会导致 `.fire` 文件将其保存为纯字符串而非对象格式。编辑器 Inspector 期望的是资源引用结构 `{ "__uuid__": "..." }`,类型不匹配产生 Type Error。 - **原理**
* **修复逻辑** - **问题根源**:在场景进程中直接将 UUID 字符串赋给资源属性(如 `comp.prefab = "uuid"`),会导致 `.fire` 文件将其保存为纯字符串而非对象格式。编辑器 Inspector 期望的是资源引用结构 `{ "__uuid__": "..." }`,类型不匹配产生 Type Error。
- **修复逻辑**
1. **真实加载**:使用 `cc.AssetLibrary.loadAsset(uuid, callback)` 在场景进程中异步加载真实的资源实例。 1. **真实加载**:使用 `cc.AssetLibrary.loadAsset(uuid, callback)` 在场景进程中异步加载真实的资源实例。
2. **实例赋值**:在回调中将加载到的 `asset` 对象赋予组件(`component[key] = asset`),这确保了场景保存时能生成正确的序列化对象。 2. **实例赋值**:在回调中将加载到的 `asset` 对象赋予组件(`component[key] = asset`),这确保了场景保存时能生成正确的序列化对象。
3. **UI 同步**:同步发送 IPC `scene:set-property`,使用 `{ uuid: value }` 格式通知编辑器面板更新 Inspector UI。 3. **UI 同步**:同步发送 IPC `scene:set-property`,使用 `{ uuid: value }` 格式通知编辑器面板更新 Inspector UI。
* **Node/Component**: 对于节点 or 组件引用,通过 `findNode` 查找实例并直接赋值实例对象,而非 UUID 字符串。 - **Node/Component**: 对于节点 or 组件引用,通过 `findNode` 查找实例并直接赋值实例对象,而非 UUID 字符串。
--- ---
## 5. AI 操作安全守则 (Subject Validation Rule) ## 5. AI 操作安全守则 (Subject Validation Rule)
### 5.1 确定性优先 ### 5.1 确定性优先
* **核心法则**:任何对节点、组件、属性的操作,都必须建立在 **“主体已确认存在”** 的基础上。
* **具体流程** - **核心法则**:任何对节点、组件、属性的操作,都必须建立在 **“主体已确认存在”** 的基础上。
- **具体流程**
1. **节点校验**:在操作前必须调用 `get_scene_hierarchy` 确认 `nodeId` 1. **节点校验**:在操作前必须调用 `get_scene_hierarchy` 确认 `nodeId`
2. **组件校验**:在 `update``remove` 前必须调用 `manage_components(action: 'get')` 确认目标组件存在。 2. **组件校验**:在 `update``remove` 前必须调用 `manage_components(action: 'get')` 确认目标组件存在。
3. **属性校验**:严禁猜测属性名。在 `update` 前,应通过读取脚本定义或 `get` 返回的现有属性列表来确定准确的属性名称。 3. **属性校验**:严禁猜测属性名。在 `update` 前,应通过读取脚本定义或 `get` 返回的现有属性列表来确定准确的属性名称。
* **禁止行为**:禁止基于假设进行盲目赋值或删除。如果发现对象不存在,应立即报错或尝试重建,而非继续尝试修改。 - **禁止行为**:禁止基于假设进行盲目赋值或删除。如果发现对象不存在,应立即报错或尝试重建,而非继续尝试修改。
--- ---
## 6. 常见资源关键字 ## 6. 常见资源关键字
* **资产识别启发式**:当通过 `manage_components` 赋值时,如果属性名包含以下关键字,插件会尝试将其作为 UUID 资源处理:
`prefab`, `sprite`, `texture`, `material`, `skeleton`, `spine`, `atlas`, `font`, `audio`, `data` - **资产识别启发式**:当通过 `manage_components` 赋值时,如果属性名包含以下关键字,插件会尝试将其作为 UUID 资源处理:
* **建议**:如果资源未正确加载,请检查属性名是否包含以上关键字,或手动确认该 UUID 不属于任何节点。 `prefab`, `sprite`, `texture`, `material`, `skeleton`, `spine`, `atlas`, `font`, `audio`, `data`
- **建议**:如果资源未正确加载,请检查属性名是否包含以上关键字,或手动确认该 UUID 不属于任何节点。
--- ---
## 7. 搜索工具 (search_project) 使用建议 ## 7. 搜索工具 (search_project) 使用建议
* **性能建议**:尽量指定 `path` 参数缩小搜索范围(如 `db://assets/scripts`),避免全项目大面积搜索,尤其是在包含大量旧资源的 Library 目录(虽然插件已过滤)。
* **正则表达式**:在使用 `useRegex` 时,确保正则模式的语法正确。如果正则匹配失败,工具会返回详细的错误提示 - **性能建议**:尽量指定 `path` 参数缩小搜索范围(如 `db://assets/scripts`),避免全项目大面积搜索,尤其是在包含大量旧资源的 Library 目录(虽然插件已过滤)
* **模式选择** - **正则表达式**:在使用 `useRegex` 时,确保正则模式的语法正确。如果正则匹配失败,工具会返回详细的错误提示。
* 查找具体逻辑代码:使用 `matchType: "content"` - **模式选择**
* 定位资源文件:使用 `matchType: "file_name"` 并配合 `extensions` 过滤 - 查找具体逻辑代码:使用 `matchType: "content"`
* 重构目录结构前:使用 `matchType: "dir_name"` 检查目录名冲突 - 定位资源文件:使用 `matchType: "file_name"` 并配合 `extensions` 过滤
- 重构目录结构前:使用 `matchType: "dir_name"` 检查目录名冲突。
--- ---
## 8. Undo/Redo (撤销/重做) 使用指南 ## 8. Undo/Redo (撤销/重做) 使用指南
### 8.1 事务分组 ### 8.1 事务分组
* **背景**:连续执行多次修改(如同时移动并缩放)时,通常希望一次“撤销”能回滚所有更改。
* **最佳实践** - **背景**:连续执行多次修改(如同时移动并缩放)时,通常希望一次“撤销”能回滚所有更改。
- **最佳实践**
1. 调用 `manage_undo(action: 'begin_group', description: '操作描述')` 1. 调用 `manage_undo(action: 'begin_group', description: '操作描述')`
2. 执行多次修改(如调用 `update_node_transform`)。 2. 执行多次修改(如调用 `update_node_transform`)。
3. 调用 `manage_undo(action: 'end_group')` 3. 调用 `manage_undo(action: 'end_group')`
* **注意**`undo-record` 需要在 `begin_group` 时明确关联节点 ID否则可能导致撤销栈无法精准匹配对象。 - **注意**`undo-record` 需要在 `begin_group` 时明确关联节点 ID否则可能导致撤销栈无法精准匹配对象。
### 8.2 属性修改方式 ### 8.2 属性修改方式
* **核心规则**:在 `scene-script.js` 中严禁直接使用 `node.x = 100`
* **正确做法**:必须通过 `Editor.Ipc.sendToPanel('scene', 'scene:set-property', ...)`。只有这样,修改才会被 Cocos Creator 的 UndoManager 捕获,从而支持撤销。 - **核心规则**:在 `scene-script.js` 中严禁直接使用 `node.x = 100`
- **正确做法**:必须通过 `Editor.Ipc.sendToPanel('scene', 'scene:set-property', ...)`。只有这样,修改才会被 Cocos Creator 的 UndoManager 捕获,从而支持撤销。
---
## 9. 并发安全与防卡死机制
### 9.1 指令队列 (CommandQueue)
- **背景**AI 客户端可能在短时间内连续发送多个 MCP 请求(如 `delete-node``refresh_editor``search_project`),如果并发执行,`AssetDB.refresh()` 等异步重操作会与后续请求产生 I/O 和 IPC 通道冲突,导致编辑器主线程阻塞。
- **解决方案**:在 `/call-tool` HTTP 入口处引入 `enqueueCommand` 机制,将所有 MCP 工具调用**串行化**执行。前一个指令的回调完成后,才会出队并处理下一个指令。
- **注意事项**
1. 队列在 `processNextCommand``catch` 块中有防死锁保护,即使某个指令抛出异常也不会永久阻塞后续指令。
2. `batchExecute` 内部也已从并行 `forEach` 改为串行链式执行。
3. 队列长度会在日志中显示(`REQ -> [toolName] (队列长度: N)`),可用于排查积压问题。
### 9.2 IPC 超时保护 (callSceneScriptWithTimeout)
- **背景**`Editor.Scene.callSceneScript` 的回调依赖 Scene 面板响应 IPC 消息。如果主线程阻塞Scene 面板无法处理消息,导致 callback 永远不返回HTTP 连接堆积。
- **解决方案**:所有 `callSceneScript` 调用均通过 `callSceneScriptWithTimeout` 包装,默认 15 秒超时。超时后自动返回错误,释放 HTTP 连接和队列位置。
- **日志标识**:超时会记录 `[超时] callSceneScript "方法名" 超过 15000ms 未响应`