Files
mcp-bridge/main.js

2189 lines
67 KiB
JavaScript
Raw Normal View History

"use strict";
const { IpcManager } = require("./dist/IpcManager");
const http = require("http");
const pathModule = require("path");
const fs = require("fs");
const crypto = require("crypto");
let logBuffer = []; // 存储所有日志
let mcpServer = null;
let isSceneBusy = false;
let serverConfig = {
port: 3456,
active: false,
};
/**
* 封装日志函数同时发送给面板保存到内存并在编辑器控制台打印
* @param {'info' | 'success' | 'warn' | 'error'} type 日志类型
* @param {string} message 日志内容
*/
function addLog(type, message) {
const logEntry = {
time: new Date().toLocaleTimeString(),
type: type,
content: message,
};
logBuffer.push(logEntry);
Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:on-log", logEntry);
// 【修改】确保所有日志都输出到编辑器控制台,以便用户查看
if (type === "error") {
Editor.error(`[MCP] ${message}`);
} else if (type === "warn") {
Editor.warn(`[MCP] ${message}`);
} else {
}
}
/**
* 获取完整的日志内容文本格式
* @returns {string} 拼接后的日志字符串
*/
function getLogContent() {
return logBuffer.map(entry => `[${entry.time}] [${entry.type}] ${entry.content}`).join('\n');
}
/**
* 生成新场景的 JSON 模板数据
* @returns {string} 场景数据的 JSON 字符串
*/
const getNewSceneTemplate = () => {
// 尝试获取 UUID 生成函数
let newId = "";
if (Editor.Utils && Editor.Utils.uuid) {
newId = Editor.Utils.uuid();
} else if (Editor.Utils && Editor.Utils.UuidUtils && Editor.Utils.UuidUtils.uuid) {
newId = Editor.Utils.UuidUtils.uuid();
} else {
// 兜底方案:如果找不到编辑器 API生成一个随机字符串
newId = Math.random().toString(36).substring(2, 15);
}
const sceneData = [
{
__type__: "cc.SceneAsset",
_name: "",
_objFlags: 0,
_native: "",
scene: { __id__: 1 },
},
{
__id__: 1,
__type__: "cc.Scene",
_name: "",
_objFlags: 0,
_parent: null,
_children: [],
_active: true,
_level: 0,
_components: [],
autoReleaseAssets: false,
_id: newId,
},
];
return JSON.stringify(sceneData);
};
/**
* 获取所有支持的 MCP 工具列表定义
* @returns {Array<Object>} 工具定义数组
*/
const getToolsList = () => {
const globalPrecautions = "【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab必须通过 UUID 进行赋值。";
return [
{
name: "get_selected_node",
description: `${globalPrecautions} 获取当前编辑器中选中的节点 ID。建议获取后立即调用 get_scene_hierarchy 确认该节点是否仍存在于当前场景中。`,
inputSchema: { type: "object", properties: {} },
},
{
name: "set_node_name",
description: `${globalPrecautions} 修改指定节点的名称`,
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "节点的 UUID" },
newName: { type: "string", description: "新的节点名称" },
},
required: ["id", "newName"],
},
},
{
name: "save_scene",
description: `${globalPrecautions} 保存当前场景的修改`,
inputSchema: { type: "object", properties: {} },
},
{
name: "get_scene_hierarchy",
description: `${globalPrecautions} 获取当前场景的完整节点树结构(包括 UUID、名称和层级关系`,
inputSchema: { type: "object", properties: {} },
},
{
name: "update_node_transform",
description: `${globalPrecautions} 修改节点的坐标、缩放或颜色。执行前必须调用 get_scene_hierarchy 确保 node ID 有效。`,
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "节点 UUID" },
x: { type: "number" },
y: { type: "number" },
scaleX: { type: "number" },
scaleY: { type: "number" },
color: { type: "string", description: "HEX 颜色代码如 #FF0000" },
},
required: ["id"],
},
},
{
name: "create_scene",
description: `${globalPrecautions} 在 assets 目录下创建一个新的场景文件。创建并通过 open_scene 打开后,请务必初始化基础节点(如 Canvas 和 Camera`,
inputSchema: {
type: "object",
properties: {
sceneName: { type: "string", description: "场景名称" },
},
required: ["sceneName"],
},
},
{
name: "create_prefab",
description: `${globalPrecautions} 将场景中的某个节点保存为预制体资源`,
inputSchema: {
type: "object",
properties: {
nodeId: { type: "string", description: "节点 UUID" },
prefabName: { type: "string", description: "预制体名称" },
},
required: ["nodeId", "prefabName"],
},
},
{
name: "open_scene",
description: `${globalPrecautions} 打开场景文件。注意这是一个异步且耗时的操作打开后请等待几秒。重要如果是新创建或空的场景请务必先创建并初始化基础节点Canvas/Camera`,
inputSchema: {
type: "object",
properties: {
url: {
type: "string",
description: "场景资源路径,如 db://assets/NewScene.fire",
},
},
required: ["url"],
},
},
{
name: "create_node",
description: `${globalPrecautions} 在当前场景中创建一个新节点。重要提示1. 如果指定 parentId必须先通过 get_scene_hierarchy 确保该父节点真实存在且未被删除。2. 类型说明:'sprite' (100x100 尺寸 + 默认贴图), 'button' (150x50 尺寸 + 深色底图 + Button组件), 'label' (120x40 尺寸 + Label组件), 'empty' (纯空节点)。`,
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "节点名称" },
parentId: {
type: "string",
description: "父节点 UUID (可选,不传则挂在场景根部)",
},
type: {
type: "string",
enum: ["empty", "sprite", "label", "button"],
description: "节点预设类型",
},
},
required: ["name"],
},
},
{
name: "manage_components",
description: `${globalPrecautions} 管理节点组件。重要提示1. 操作前必须调用 get_scene_hierarchy 确认 nodeId 对应的节点仍然存在。2. 添加前先用 'get' 检查是否已存在。3. 添加 cc.Sprite 后必须设置 spriteFrame 属性否则节点不显示。4. 创建按钮时,请确保目标节点有足够的 width 和 height 作为点击区域。5. 赋值或更新属性前必须确保目标属性在组件上真实存在严禁盲目操作不存在的属性。6. 对于资源类属性(如 cc.Prefab, sp.SkeletonData传递资源的 UUID。插件会自动进行异步加载并正确序列化避免 Inspector 出现 Type Error。`,
inputSchema: {
type: "object",
properties: {
nodeId: { type: "string", description: "节点 UUID" },
action: { type: "string", enum: ["add", "remove", "update", "get"], description: "操作类型 (add: 添加组件, remove: 移除组件, update: 更新组件属性, get: 获取组件列表)" },
componentType: { type: "string", description: "组件类型,如 cc.Sprite (add/update 操作需要)" },
componentId: { type: "string", description: "组件 ID (remove/update 操作可选)" },
properties: { type: "object", description: "组件属性 (add/update 操作使用). 支持智能解析: 如果属性类型是组件但提供了节点UUID会自动查找对应组件。" },
},
required: ["nodeId", "action"],
},
},
{
name: "manage_script",
description: `${globalPrecautions} 管理脚本文件。注意:创建或修改脚本后,编辑器需要时间进行编译(通常几秒钟)。新脚本在编译完成前无法作为组件添加到节点。建议在 create 后调用 refresh_editor或等待一段时间后再使用 manage_components。`,
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "read", "write"], description: "操作类型" },
path: { type: "string", description: "脚本路径,如 db://assets/scripts/NewScript.js" },
content: { type: "string", description: "脚本内容 (用于 create 和 write 操作)" },
name: { type: "string", description: "脚本名称 (用于 create 操作)" },
},
required: ["action", "path"],
},
},
{
name: "batch_execute",
description: `${globalPrecautions} 批处理执行多个操作`,
inputSchema: {
type: "object",
properties: {
operations: {
type: "array",
items: {
type: "object",
properties: {
tool: { type: "string", description: "工具名称" },
params: { type: "object", description: "工具参数" },
},
required: ["tool", "params"],
},
description: "操作列表",
},
},
required: ["operations"],
},
},
{
name: "manage_asset",
description: `${globalPrecautions} 管理资源`,
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "move", "get_info"], description: "操作类型" },
path: { type: "string", description: "资源路径,如 db://assets/textures" },
targetPath: { type: "string", description: "目标路径 (用于 move 操作)" },
content: { type: "string", description: "资源内容 (用于 create 操作)" },
},
required: ["action", "path"],
},
},
{
name: "scene_management",
description: `${globalPrecautions} 场景管理`,
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["create", "delete", "duplicate", "get_info"],
description: "操作类型",
},
path: { type: "string", description: "场景路径,如 db://assets/scenes/NewScene.fire" },
targetPath: { type: "string", description: "目标路径 (用于 duplicate 操作)" },
name: { type: "string", description: "场景名称 (用于 create 操作)" },
},
required: ["action", "path"],
},
},
{
name: "prefab_management",
description: `${globalPrecautions} 预制体管理`,
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["create", "update", "instantiate", "get_info"],
description: "操作类型",
},
path: { type: "string", description: "预制体路径,如 db://assets/prefabs/NewPrefab.prefab" },
nodeId: { type: "string", description: "节点 ID (用于 create 操作)" },
parentId: { type: "string", description: "父节点 ID (用于 instantiate 操作)" },
},
required: ["action", "path"],
},
},
{
name: "manage_editor",
description: `${globalPrecautions} 管理编辑器`,
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["get_selection", "set_selection", "refresh_editor"],
description: "操作类型",
},
target: {
type: "string",
enum: ["node", "asset"],
description: "目标类型 (用于 set_selection 操作)",
},
properties: { type: "object", description: "操作属性" },
},
required: ["action"],
},
},
{
name: "find_gameobjects",
description: `${globalPrecautions} 查找游戏对象`,
inputSchema: {
type: "object",
properties: {
conditions: { type: "object", description: "查找条件" },
recursive: { type: "boolean", default: true, description: "是否递归查找" },
},
required: ["conditions"],
},
},
{
name: "manage_material",
description: `${globalPrecautions} 管理材质。支持创建、获取信息以及更新 Shader、Defines 和 Uniforms 参数。`,
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "get_info", "update"], description: "操作类型" },
path: { type: "string", description: "材质路径,如 db://assets/materials/NewMaterial.mat" },
properties: {
type: "object",
description: "材质属性 (add/update 操作使用)",
properties: {
shaderUuid: { type: "string", description: "关联的 Shader (Effect) UUID" },
defines: { type: "object", description: "预编译宏定义" },
uniforms: { type: "object", description: "Uniform 参数列表" }
}
},
},
required: ["action", "path"],
},
},
{
name: "manage_texture",
description: `${globalPrecautions} 管理纹理`,
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "get_info"], description: "操作类型" },
path: { type: "string", description: "纹理路径,如 db://assets/textures/NewTexture.png" },
properties: { type: "object", description: "纹理属性" },
},
required: ["action", "path"],
},
},
{
name: "manage_shader",
description: `${globalPrecautions} 管理着色器 (Effect)。支持创建、读取、更新、删除和获取信息。`,
inputSchema: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "delete", "read", "write", "get_info"], description: "操作类型" },
path: { type: "string", description: "着色器路径,如 db://assets/effects/NewEffect.effect" },
content: { type: "string", description: "着色器内容 (create/write)" },
},
required: ["action", "path"],
},
},
{
name: "execute_menu_item",
description: `${globalPrecautions} 执行菜单项`,
inputSchema: {
type: "object",
properties: {
menuPath: { type: "string", description: "菜单项路径" },
},
required: ["menuPath"],
},
},
{
name: "apply_text_edits",
description: `${globalPrecautions} 应用文本编辑`,
inputSchema: {
type: "object",
properties: {
filePath: { type: "string", description: "文件路径" },
edits: { type: "array", items: { type: "object" }, description: "编辑操作列表" },
},
required: ["filePath", "edits"],
},
},
{
name: "read_console",
description: `${globalPrecautions} 读取控制台`,
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "输出限制" },
type: { type: "string", enum: ["log", "error", "warn"], description: "输出类型" },
},
},
},
{
name: "validate_script",
description: `${globalPrecautions} 验证脚本`,
inputSchema: {
type: "object",
properties: {
filePath: { type: "string", description: "脚本路径" },
},
required: ["filePath"],
},
},
{
name: "find_in_file",
description: `${globalPrecautions} 在项目中全局搜索文本内容`,
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词" },
extensions: {
type: "array",
items: { type: "string" },
description: "文件后缀列表 (例如 ['.js', '.ts'])",
default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"]
},
includeSubpackages: { type: "boolean", default: true, description: "是否搜索子包 (暂时默认搜索 assets 目录)" }
},
required: ["query"]
}
},
{
name: "manage_undo",
description: `${globalPrecautions} 管理编辑器的撤销和重做历史`,
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["undo", "redo", "begin_group", "end_group", "cancel_group"],
description: "操作类型"
},
description: { type: "string", description: "撤销组的描述 (用于 begin_group)" }
},
required: ["action"]
}
},
{
name: "manage_vfx",
description: `${globalPrecautions} 管理全场景特效 (粒子系统)。重要提示:在创建或更新前,必须通过 get_scene_hierarchy 或 manage_components 确认父节点或目标节点的有效性。严禁对不存在的对象进行操作。`,
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["create", "update", "get_info"],
description: "操作类型"
},
nodeId: { type: "string", description: "节点 UUID (用于 update/get_info)" },
properties: {
type: "object",
description: "粒子系统属性 (用于 create/update)",
properties: {
duration: { type: "number", description: "发射时长" },
emissionRate: { type: "number", description: "发射速率" },
life: { type: "number", description: "生命周期" },
lifeVar: { type: "number", description: "生命周期变化" },
startColor: { type: "string", description: "起始颜色 (Hex)" },
endColor: { type: "string", description: "结束颜色 (Hex)" },
startSize: { type: "number", description: "起始大小" },
endSize: { type: "number", description: "结束大小" },
speed: { type: "number", description: "速度" },
angle: { type: "number", description: "角度" },
gravity: { type: "object", properties: { x: { type: "number" }, y: { type: "number" } } },
file: { type: "string", description: "粒子文件路径 (plist) 或 texture 路径" }
}
},
name: { type: "string", description: "节点名称 (用于 create)" },
parentId: { type: "string", description: "父节点 ID (用于 create)" }
},
required: ["action"]
}
},
{
name: "get_sha",
description: `${globalPrecautions} 获取指定文件的 SHA-256 哈希值`,
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "文件路径,如 db://assets/scripts/Test.ts" }
},
required: ["path"]
}
},
{
name: "manage_animation",
description: `${globalPrecautions} 管理节点的动画组件。重要提示:在执行 play/pause 等操作前,必须先确认节点及其 Animation 组件存在。严禁操作空引用。`,
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["get_list", "get_info", "play", "stop", "pause", "resume"],
description: "操作类型"
},
nodeId: { type: "string", description: "节点 UUID" },
clipName: { type: "string", description: "动画剪辑名称 (用于 play)" }
},
required: ["action", "nodeId"]
}
}
];
};
module.exports = {
"scene-script": "scene-script.js",
/**
* 插件加载时的回调
*/
load() {
addLog("info", "MCP Bridge Plugin Loaded");
// 读取配置
let profile = this.getProfile();
serverConfig.port = profile.get("last-port") || 3456;
let autoStart = profile.get("auto-start");
if (autoStart) {
addLog("info", "Auto-start is enabled. Initializing server...");
// 延迟一点启动,确保编辑器环境完全就绪
setTimeout(() => {
this.startServer(serverConfig.port);
}, 1000);
}
},
/**
* 获取插件配置文件的辅助函数
* @returns {Object} Editor.Profile 实例
*/
getProfile() {
// 'local' 表示存储在项目本地local/mcp-bridge.json
return Editor.Profile.load("profile://local/mcp-bridge.json", "mcp-bridge");
},
/**
* 插件卸载时的回调
*/
unload() {
this.stopServer();
},
/**
* 启动 HTTP 服务器
* @param {number} port 监听端口
*/
startServer(port) {
if (mcpServer) this.stopServer();
try {
mcpServer = http.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const url = req.url;
if (url === "/list-tools") {
const tools = getToolsList();
addLog("info", `AI Client requested tool list`);
// 明确返回成功结构
res.writeHead(200);
return res.end(JSON.stringify({ tools: tools }));
res.writeHead(200);
return res.end(JSON.stringify({ tools: tools }));
}
if (url === "/list-resources") {
const resources = this.getResourcesList();
addLog("info", `AI Client requested resource list`);
res.writeHead(200);
return res.end(JSON.stringify({ resources: resources }));
}
if (url === "/read-resource") {
try {
const { uri } = JSON.parse(body || "{}");
addLog("mcp", `READ -> [${uri}]`);
this.handleReadResource(uri, (err, content) => {
if (err) {
addLog("error", `读取失败: ${err}`);
res.writeHead(500);
return res.end(JSON.stringify({ error: err }));
}
addLog("success", `读取成功: ${uri}`);
res.writeHead(200);
// 返回 MCP Resource 格式: { contents: [{ uri, mimeType, text }] }
res.end(JSON.stringify({
contents: [{
uri: uri,
mimeType: "application/json",
text: typeof content === 'string' ? content : JSON.stringify(content)
}]
}));
});
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: e.message }));
}
return;
}
if (url === "/call-tool") {
try {
const { name, arguments: args } = JSON.parse(body || "{}");
addLog("mcp", `REQ -> [${name}]`);
this.handleMcpCall(name, args, (err, result) => {
const response = {
content: [
{
type: "text",
text: err
? `Error: ${err}`
: typeof result === "object"
? JSON.stringify(result, null, 2)
: result,
},
],
};
if (err) {
addLog("error", `RES <- [${name}] 失败: ${err}`);
} else {
// 成功时尝试捕获简单的结果预览(如果是字符串或简短对象)
let preview = "";
if (typeof result === 'string') {
preview = result.length > 100 ? result.substring(0, 100) + "..." : result;
} else if (typeof result === 'object') {
try {
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}`);
}
res.writeHead(200);
res.end(JSON.stringify(response));
});
} catch (e) {
if (e instanceof SyntaxError) {
addLog("error", `JSON Parse Error: ${e.message}`);
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
} else {
addLog("error", `Internal Server Error: ${e.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: e.message }));
}
}
return;
}
// --- 兜底处理 (404) ---
res.writeHead(404);
res.end(JSON.stringify({ error: "Not Found", url: url }));
});
});
mcpServer.on("error", (e) => {
addLog("error", `Server Error: ${e.message}`);
});
mcpServer.listen(port, () => {
serverConfig.active = true;
addLog("success", `MCP Server running at http://127.0.0.1:${port}`);
Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig);
});
// 启动成功后顺便存一下端口
this.getProfile().set("last-port", port);
this.getProfile().save();
} catch (e) {
addLog("error", `Failed to start server: ${e.message}`);
}
},
stopServer() {
if (mcpServer) {
mcpServer.close();
mcpServer = null;
serverConfig.active = false;
addLog("warn", "MCP Server stopped");
Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig);
}
},
getResourcesList() {
return [
{
uri: "cocos://hierarchy",
name: "Scene Hierarchy",
description: "当前场景层级的 JSON 快照",
mimeType: "application/json"
},
{
uri: "cocos://selection",
name: "Current Selection",
description: "当前选中节点的 UUID 列表",
mimeType: "application/json"
},
{
uri: "cocos://logs/latest",
name: "Editor Logs",
description: "最新的编辑器日志 (内存缓存)",
mimeType: "text/plain"
}
];
},
handleReadResource(uri, callback) {
let parsed;
try {
parsed = new URL(uri);
} catch (e) {
return callback(`Invalid URI: ${uri}`);
}
if (parsed.protocol !== "cocos:") {
return callback(`Unsupported protocol: ${parsed.protocol}`);
}
const type = parsed.hostname; // hierarchy, selection, logs
switch (type) {
case "hierarchy":
// 注意: query-hierarchy 是异步的
Editor.Ipc.sendToPanel("scene", "scene:query-hierarchy", (err, sceneId, hierarchy) => {
if (err) return callback(err);
callback(null, JSON.stringify(hierarchy, null, 2));
});
break;
case "selection":
const selection = Editor.Selection.curSelection("node");
callback(null, JSON.stringify(selection));
break;
case "logs":
callback(null, getLogContent());
break;
default:
callback(`Resource not found: ${uri}`);
break;
}
},
/**
* 处理来自 HTTP MCP 调用请求
* @param {string} name 工具名称
* @param {Object} args 工具参数
* @param {Function} callback 完成回调 (err, result)
*/
handleMcpCall(name, args, callback) {
if (isSceneBusy && (name === "save_scene" || name === "create_node")) {
return callback("编辑器正忙(正在处理场景),请稍候。");
}
switch (name) {
case "get_selected_node":
const ids = Editor.Selection.curSelection("node");
callback(null, ids);
break;
case "set_node_name":
// 使用 scene:set-property 以支持撤销
Editor.Ipc.sendToPanel("scene", "scene:set-property", {
id: args.id,
path: "name",
type: "String",
value: args.newName,
isSubProp: false
});
callback(null, `节点名称已更新为 ${args.newName}`);
break;
case "save_scene":
isSceneBusy = true;
addLog("info", "准备保存场景... 等待 UI 同步。");
Editor.Ipc.sendToPanel("scene", "scene:stash-and-save");
isSceneBusy = false;
addLog("info", "安全保存已完成。");
callback(null, "场景保存成功。");
break;
case "get_scene_hierarchy":
Editor.Scene.callSceneScript("mcp-bridge", "get-hierarchy", callback);
break;
case "update_node_transform":
// 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统
Editor.Scene.callSceneScript("mcp-bridge", "update-node-transform", args, (err, result) => {
if (err) {
addLog("error", `Transform update failed: ${err}`);
callback(err);
} else {
callback(null, "变换信息已更新");
}
});
break;
case "create_scene":
const sceneUrl = `db://assets/${args.sceneName}.fire`;
if (Editor.assetdb.exists(sceneUrl)) {
return callback("场景已存在");
}
Editor.assetdb.create(sceneUrl, getNewSceneTemplate(), (err) => {
callback(err, err ? null : `标准场景已创建于 ${sceneUrl}`);
});
break;
case "create_prefab":
const prefabUrl = `db://assets/${args.prefabName}.prefab`;
Editor.Ipc.sendToMain("scene:create-prefab", args.nodeId, prefabUrl);
callback(null, `命令已发送:正在创建预制体 '${args.prefabName}'`);
break;
case "open_scene":
isSceneBusy = true; // 锁定
const openUuid = Editor.assetdb.urlToUuid(args.url);
if (openUuid) {
Editor.Ipc.sendToMain("scene:open-by-uuid", openUuid);
setTimeout(() => {
isSceneBusy = false;
callback(null, `成功:正在打开场景 ${args.url}`);
}, 2000);
} else {
isSceneBusy = false;
callback(`找不到路径为 ${args.url} 的资源`);
}
break;
case "create_node":
if (args.type === "sprite" || args.type === "button") {
const splashUuid = Editor.assetdb.urlToUuid("db://internal/image/default_sprite_splash.png/default_sprite_splash");
args.defaultSpriteUuid = splashUuid;
}
Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, callback);
break;
case "manage_components":
Editor.Scene.callSceneScript("mcp-bridge", "manage-components", args, callback);
break;
case "manage_script":
this.manageScript(args, callback);
break;
case "batch_execute":
this.batchExecute(args, callback);
break;
case "manage_asset":
this.manageAsset(args, callback);
break;
case "scene_management":
this.sceneManagement(args, callback);
break;
case "prefab_management":
this.prefabManagement(args, callback);
break;
case "manage_editor":
this.manageEditor(args, callback);
break;
case "get_sha":
this.getSha(args, callback);
break;
case "manage_animation":
this.manageAnimation(args, callback);
break;
case "find_gameobjects":
Editor.Scene.callSceneScript("mcp-bridge", "find-gameobjects", args, callback);
break;
case "manage_material":
this.manageMaterial(args, callback);
break;
case "manage_texture":
this.manageTexture(args, callback);
break;
case "manage_shader":
this.manageShader(args, callback);
break;
case "execute_menu_item":
this.executeMenuItem(args, callback);
break;
case "apply_text_edits":
this.applyTextEdits(args, callback);
break;
case "read_console":
this.readConsole(args, callback);
break;
case "validate_script":
this.validateScript(args, callback);
break;
case "find_in_file":
this.findInFile(args, callback);
break;
case "manage_undo":
this.manageUndo(args, callback);
break;
case "manage_vfx":
// 【修复】在主进程预先解析 URL 为 UUID因为渲染进程(scene-script)无法访问 Editor.assetdb
if (args.properties && args.properties.file) {
if (typeof args.properties.file === 'string' && args.properties.file.startsWith("db://")) {
const uuid = Editor.assetdb.urlToUuid(args.properties.file);
if (uuid) {
args.properties.file = uuid; // 替换为 UUID
} else {
console.warn(`Failed to resolve path to UUID: ${args.properties.file}`);
}
}
}
// 预先获取默认贴图 UUID (尝试多个可能的路径)
const defaultPaths = [
"db://internal/image/default_sprite_splash",
"db://internal/image/default_sprite_splash.png",
"db://internal/image/default_particle",
"db://internal/image/default_particle.png"
];
for (const path of defaultPaths) {
const uuid = Editor.assetdb.urlToUuid(path);
if (uuid) {
args.defaultSpriteUuid = uuid;
addLog("info", `[mcp-bridge] Resolved Default Sprite UUID: ${uuid} from ${path}`);
break;
}
}
if (!args.defaultSpriteUuid) {
addLog("warn", "[mcp-bridge] Failed to resolve any default sprite UUID.");
}
Editor.Scene.callSceneScript("mcp-bridge", "manage-vfx", args, callback);
break;
default:
callback(`Unknown tool: ${name}`);
break;
}
},
/**
* 管理项目中的脚本文件 (TS/JS)
* @param {Object} args 参数
* @param {Function} callback 完成回调
*/
manageScript(args, callback) {
const { action, path: scriptPath, content } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(scriptPath)) {
return callback(`Script already exists at ${scriptPath}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(scriptPath);
const dirPath = path.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
Editor.assetdb.create(
scriptPath,
content ||
`const { ccclass, property } = cc._decorator;
@ccclass
export default class NewScript extends cc.Component {
@property(cc.Label)
label: cc.Label = null;
@property
text: string = 'hello';
// LIFE-CYCLE CALLBACKS:
onLoad () {}
start () {}
update (dt) {}
}`,
(err) => {
if (err) {
callback(err);
} else {
// 【关键修复】创建脚本后,必须刷新 AssetDB 并等待完成,
// 否则后续立即挂载脚本的操作(manage_components)会因找不到脚本 UUID 而失败。
Editor.assetdb.refresh(scriptPath, (refreshErr) => {
if (refreshErr) {
addLog("warn", `Refresh failed after script creation: ${refreshErr}`);
}
callback(null, `Script created at ${scriptPath}`);
});
}
},
);
break;
case "delete":
if (!Editor.assetdb.exists(scriptPath)) {
return callback(`Script not found at ${scriptPath}`);
}
Editor.assetdb.delete([scriptPath], (err) => {
callback(err, err ? null : `Script deleted at ${scriptPath}`);
});
break;
case "read":
// 使用 fs 读取,绕过 assetdb.loadAny
const readFsPath = Editor.assetdb.urlToFspath(scriptPath);
if (!readFsPath || !fs.existsSync(readFsPath)) {
return callback(`Script not found at ${scriptPath}`);
}
try {
const content = fs.readFileSync(readFsPath, "utf-8");
callback(null, content);
} catch (e) {
callback(`Failed to read script: ${e.message}`);
}
break;
case "write":
// 使用 fs 写入 + refresh确保覆盖成功
const writeFsPath = Editor.assetdb.urlToFspath(scriptPath);
if (!writeFsPath) {
return callback(`Invalid path: ${scriptPath}`);
}
try {
fs.writeFileSync(writeFsPath, content, "utf-8");
Editor.assetdb.refresh(scriptPath, (err) => {
if (err) addLog("warn", `Refresh failed after write: ${err}`);
callback(null, `Script updated at ${scriptPath}`);
});
} catch (e) {
callback(`Failed to write script: ${e.message}`);
}
break;
default:
callback(`未知的脚本操作类型: ${action}`);
break;
}
},
/**
* 批量执行多个 MCP 工具操作
* @param {Object} args 参数 (operations 数组)
* @param {Function} callback 完成回调
*/
batchExecute(args, callback) {
const { operations } = args;
const results = [];
let completed = 0;
if (!operations || operations.length === 0) {
return callback("未提供任何操作指令");
}
operations.forEach((operation, index) => {
this.handleMcpCall(operation.tool, operation.params, (err, result) => {
results[index] = { tool: operation.tool, error: err, result: result };
completed++;
if (completed === operations.length) {
callback(null, results);
}
});
});
},
/**
* 通用的资源管理函数 (创建删除移动等)
* @param {Object} args 参数
* @param {Function} callback 完成回调
*/
manageAsset(args, callback) {
const { action, path, targetPath, content } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Asset already exists at ${path}`);
}
// 确保父目录存在
const fs = require("fs");
const pathModule = require("path");
const absolutePath = Editor.assetdb.urlToFspath(path);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
Editor.assetdb.create(path, content || "", (err) => {
callback(err, err ? null : `Asset created at ${path}`);
});
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Asset not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Asset deleted at ${path}`);
});
break;
case "move":
if (!Editor.assetdb.exists(path)) {
return callback(`Asset not found at ${path}`);
}
if (Editor.assetdb.exists(targetPath)) {
return callback(`Target asset already exists at ${targetPath}`);
}
Editor.assetdb.move(path, targetPath, (err) => {
callback(err, err ? null : `Asset moved from ${path} to ${targetPath}`);
});
break;
case "get_info":
try {
if (!Editor.assetdb.exists(path)) {
return callback(`Asset not found: ${path}`);
}
const uuid = Editor.assetdb.urlToUuid(path);
const info = Editor.assetdb.assetInfoByUuid(uuid);
if (info) {
callback(null, info);
} else {
// 备选方案:如果 API 未返回信息但资源确实存在
callback(null, { url: path, uuid: uuid, exists: true });
}
} catch (e) {
callback(`Error getting asset info: ${e.message}`);
}
break;
default:
callback(`未知的资源管理操作: ${action}`);
break;
}
},
/**
* 场景相关的资源管理 (创建克隆场景等)
* @param {Object} args 参数
* @param {Function} callback 完成回调
*/
sceneManagement(args, callback) {
const { action, path, targetPath, name } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Scene already exists at ${path}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(path);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
Editor.assetdb.create(path, getNewSceneTemplate(), (err) => {
callback(err, err ? null : `Scene created at ${path}`);
});
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Scene not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Scene deleted at ${path}`);
});
break;
case "duplicate":
if (!Editor.assetdb.exists(path)) {
return callback(`Scene not found at ${path}`);
}
if (!targetPath) {
return callback(`Target path is required for duplicate operation`);
}
if (Editor.assetdb.exists(targetPath)) {
return callback(`Target scene already exists at ${targetPath}`);
}
// 【修复】Cocos 2.4.x 主进程中 Editor.assetdb 没有 loadAny
// 直接使用 fs 读取物理文件
try {
const sourceFsPath = Editor.assetdb.urlToFspath(path);
if (!sourceFsPath || !fs.existsSync(sourceFsPath)) {
return callback(`Failed to locate source scene file: ${path}`);
}
const content = fs.readFileSync(sourceFsPath, "utf-8");
// 确保目标目录存在
const targetAbsolutePath = Editor.assetdb.urlToFspath(targetPath);
const targetDirPath = pathModule.dirname(targetAbsolutePath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
// 创建复制的场景
Editor.assetdb.create(targetPath, content, (err) => {
if (err) return callback(err);
// 【增加】关键刷新,确保数据库能查到新文件
Editor.assetdb.refresh(targetPath, (refreshErr) => {
callback(refreshErr, refreshErr ? null : `Scene duplicated from ${path} to ${targetPath}`);
});
});
} catch (e) {
callback(`Duplicate failed: ${e.message}`);
}
break;
case "get_info":
if (Editor.assetdb.exists(path)) {
const uuid = Editor.assetdb.urlToUuid(path);
const info = Editor.assetdb.assetInfoByUuid(uuid);
callback(null, info || { url: path, uuid: uuid, exists: true });
} else {
callback(`Scene not found: ${path}`);
}
break;
default:
callback(`Unknown scene action: ${action}`);
break;
}
},
// 预制体管理
prefabManagement(args, callback) {
const { action, path: prefabPath, nodeId, parentId } = args;
switch (action) {
case "create":
if (!nodeId) {
return callback(`Node ID is required for create operation`);
}
if (Editor.assetdb.exists(prefabPath)) {
return callback(`Prefab already exists at ${prefabPath}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(prefabPath);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
// 解析目标目录和文件名
// db://assets/folder/PrefabName.prefab -> db://assets/folder, PrefabName
const targetDir = prefabPath.substring(0, prefabPath.lastIndexOf('/'));
const fileName = prefabPath.substring(prefabPath.lastIndexOf('/') + 1);
const prefabName = fileName.replace('.prefab', '');
// 1. 重命名节点以匹配预制体名称
Editor.Ipc.sendToPanel("scene", "scene:set-property", {
id: nodeId,
path: "name",
type: "String",
value: prefabName,
isSubProp: false
});
// 2. 发送创建命令 (参数: [uuids], dirPath)
// 注意: scene:create-prefab 第三个参数必须是 db:// 目录路径
setTimeout(() => {
Editor.Ipc.sendToPanel("scene", "scene:create-prefab", [nodeId], targetDir);
}, 100); // 稍微延迟以确保重命名生效
callback(null, `Command sent: Creating prefab from node ${nodeId} at ${targetDir} as ${prefabName}`);
break;
case "update":
if (!nodeId) {
return callback(`Node ID is required for update operation`);
}
if (!Editor.assetdb.exists(prefabPath)) {
return callback(`Prefab not found at ${prefabPath}`);
}
// 更新预制体
Editor.Ipc.sendToPanel("scene", "scene:update-prefab", nodeId, prefabPath);
callback(null, `Command sent: Updating prefab ${prefabPath} from node ${nodeId}`);
break;
case "instantiate":
if (!Editor.assetdb.exists(prefabPath)) {
return callback(`Prefab not found at ${prefabPath}`);
}
// 实例化预制体
const prefabUuid = Editor.assetdb.urlToUuid(prefabPath);
Editor.Scene.callSceneScript(
"mcp-bridge",
"instantiate-prefab",
{
prefabUuid: prefabUuid,
parentId: parentId,
},
callback,
);
break;
case "get_info":
if (Editor.assetdb.exists(prefabPath)) {
const uuid = Editor.assetdb.urlToUuid(prefabPath);
const info = Editor.assetdb.assetInfoByUuid(uuid);
// 确保返回对象包含 exists: true以满足测试验证
const result = info || { url: prefabPath, uuid: uuid };
result.exists = true;
callback(null, result);
} else {
callback(`Prefab not found: ${prefabPath}`);
}
break;
default:
callback(`未知的预制体管理操作: ${action}`);
}
},
/**
* 管理编辑器状态 (选中对象刷新等)
* @param {Object} args 参数
* @param {Function} callback 完成回调
*/
manageEditor(args, callback) {
const { action, target, properties } = args;
switch (action) {
case "get_selection":
// 获取当前选中的资源或节点
const nodeSelection = Editor.Selection.curSelection("node");
const assetSelection = Editor.Selection.curSelection("asset");
callback(null, {
nodes: nodeSelection,
assets: assetSelection,
});
break;
case "set_selection":
// 设置选中状态
if (target === "node") {
const ids = properties.ids || properties.nodes;
if (ids) Editor.Selection.select("node", ids);
} else if (target === "asset") {
const ids = properties.ids || properties.assets;
if (ids) Editor.Selection.select("asset", ids);
}
callback(null, "Selection updated");
break;
case "refresh_editor":
// 刷新编辑器
2026-02-02 10:14:17 +08:00
const refreshPath = (properties && properties.path) ? properties.path : 'db://assets/scripts';
Editor.assetdb.refresh(refreshPath, (err) => {
if (err) {
addLog("error", `Refresh failed: ${err}`);
callback(err);
} else {
callback(null, `Editor refreshed: ${refreshPath}`);
}
});
break;
default:
callback("未知的编辑器管理操作");
break;
}
},
// 管理着色器 (Effect)
manageShader(args, callback) {
const { action, path: effectPath, content } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(effectPath)) {
return callback(`Effect already exists at ${effectPath}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(effectPath);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const defaultEffect = `CCEffect %{
techniques:
- passes:
- vert: vs
frag: fs
blendState:
targets:
- blend: true
rasterizerState:
cullMode: none
properties:
texture: { value: white }
mainColor: { value: [1, 1, 1, 1], editor: { type: color } }
}%
CCProgram vs %{
precision highp float;
#include <cc-global>
attribute vec3 a_position;
attribute vec2 a_uv0;
varying vec2 v_uv0;
void main () {
gl_Position = cc_matViewProj * vec4(a_position, 1.0);
v_uv0 = a_uv0;
}
}%
CCProgram fs %{
precision highp float;
uniform sampler2D texture;
uniform Constant {
vec4 mainColor;
};
varying vec2 v_uv0;
void main () {
gl_FragColor = mainColor * texture2D(texture, v_uv0);
}
}%`;
Editor.assetdb.create(effectPath, content || defaultEffect, (err) => {
if (err) return callback(err);
Editor.assetdb.refresh(effectPath, (refreshErr) => {
callback(refreshErr, refreshErr ? null : `Effect created at ${effectPath}`);
});
});
break;
case "read":
if (!Editor.assetdb.exists(effectPath)) {
return callback(`Effect not found: ${effectPath}`);
}
const fspath = Editor.assetdb.urlToFspath(effectPath);
try {
const data = fs.readFileSync(fspath, "utf-8");
callback(null, data);
} catch (e) {
callback(`Failed to read effect: ${e.message}`);
}
break;
case "write":
if (!Editor.assetdb.exists(effectPath)) {
return callback(`Effect not found: ${effectPath}`);
}
const writeFsPath = Editor.assetdb.urlToFspath(effectPath);
try {
fs.writeFileSync(writeFsPath, content, "utf-8");
Editor.assetdb.refresh(effectPath, (err) => {
callback(err, err ? null : `Effect updated at ${effectPath}`);
});
} catch (e) {
callback(`Failed to write effect: ${e.message}`);
}
break;
case "delete":
if (!Editor.assetdb.exists(effectPath)) {
return callback(`Effect not found: ${effectPath}`);
}
Editor.assetdb.delete([effectPath], (err) => {
callback(err, err ? null : `Effect deleted: ${effectPath}`);
});
break;
case "get_info":
if (Editor.assetdb.exists(effectPath)) {
const uuid = Editor.assetdb.urlToUuid(effectPath);
const info = Editor.assetdb.assetInfoByUuid(uuid);
callback(null, info || { url: effectPath, uuid: uuid, exists: true });
} else {
callback(`Effect not found: ${effectPath}`);
}
break;
default:
callback(`Unknown shader action: ${action}`);
break;
}
},
// 管理材质
manageMaterial(args, callback) {
const { action, path: matPath, properties = {} } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(matPath)) {
return callback(`Material already exists at ${matPath}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(matPath);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
// 构造 Cocos 2.4.x 材质内容
const materialData = {
__type__: "cc.Material",
_name: "",
_objFlags: 0,
_native: "",
_effectAsset: properties.shaderUuid ? { __uuid__: properties.shaderUuid } : null,
_techniqueIndex: 0,
_techniqueData: {
"0": {
defines: properties.defines || {},
props: properties.uniforms || {}
}
}
};
Editor.assetdb.create(matPath, JSON.stringify(materialData, null, 2), (err) => {
if (err) return callback(err);
Editor.assetdb.refresh(matPath, (refreshErr) => {
callback(refreshErr, refreshErr ? null : `Material created at ${matPath}`);
});
});
break;
case "update":
if (!Editor.assetdb.exists(matPath)) {
return callback(`Material not found at ${matPath}`);
}
const fspath = Editor.assetdb.urlToFspath(matPath);
try {
const content = fs.readFileSync(fspath, "utf-8");
const matData = JSON.parse(content);
// 确保结构存在
if (!matData._techniqueData) matData._techniqueData = {};
if (!matData._techniqueData["0"]) matData._techniqueData["0"] = {};
const tech = matData._techniqueData["0"];
// 更新 Shader
if (properties.shaderUuid) {
matData._effectAsset = { __uuid__: properties.shaderUuid };
}
// 更新 Defines
if (properties.defines) {
tech.defines = Object.assign(tech.defines || {}, properties.defines);
}
// 更新 Props/Uniforms
if (properties.uniforms) {
tech.props = Object.assign(tech.props || {}, properties.uniforms);
}
fs.writeFileSync(fspath, JSON.stringify(matData, null, 2), "utf-8");
Editor.assetdb.refresh(matPath, (err) => {
callback(err, err ? null : `Material updated at ${matPath}`);
});
} catch (e) {
callback(`Failed to update material: ${e.message}`);
}
break;
case "delete":
if (!Editor.assetdb.exists(matPath)) {
return callback(`Material not found at ${matPath}`);
}
Editor.assetdb.delete([matPath], (err) => {
callback(err, err ? null : `Material deleted at ${matPath}`);
});
break;
case "get_info":
if (Editor.assetdb.exists(matPath)) {
const uuid = Editor.assetdb.urlToUuid(matPath);
const info = Editor.assetdb.assetInfoByUuid(uuid);
callback(null, info || { url: matPath, uuid: uuid, exists: true });
} else {
callback(`Material not found: ${matPath}`);
}
break;
default:
callback(`Unknown material action: ${action}`);
break;
}
},
// 管理纹理
manageTexture(args, callback) {
const { action, path, properties } = args;
switch (action) {
case "create":
if (Editor.assetdb.exists(path)) {
return callback(`Texture already exists at ${path}`);
}
// 确保父目录存在
const absolutePath = Editor.assetdb.urlToFspath(path);
const dirPath = pathModule.dirname(absolutePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
// 【修复】Cocos 2.4.x 无法直接用 Editor.assetdb.create 创建带后缀的纹理(会校验内容)
// 我们需要先物理写入一个 1x1 的透明图片,再刷新数据库
const base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
const buffer = Buffer.from(base64Data, 'base64');
try {
fs.writeFileSync(absolutePath, buffer);
Editor.assetdb.refresh(path, (err) => {
if (err) return callback(err);
callback(null, `Texture created and refreshed at ${path}`);
});
} catch (e) {
callback(`Failed to write texture file: ${e.message}`);
}
break;
case "delete":
if (!Editor.assetdb.exists(path)) {
return callback(`Texture not found at ${path}`);
}
Editor.assetdb.delete([path], (err) => {
callback(err, err ? null : `Texture deleted at ${path}`);
});
break;
case "get_info":
if (Editor.assetdb.exists(path)) {
const uuid = Editor.assetdb.urlToUuid(path);
const info = Editor.assetdb.assetInfoByUuid(uuid);
callback(null, info || { url: path, uuid: uuid, exists: true });
} else {
callback(`Texture not found: ${path}`);
}
break;
default:
callback(`Unknown texture action: ${action}`);
break;
}
},
/**
* 对文件应用一系列精确的文本编辑操作
* @param {Object} args 参数
* @param {Function} callback 完成回调
*/
applyTextEdits(args, callback) {
const { filePath, edits } = args;
// 1. 获取文件系统路径
const fspath = Editor.assetdb.urlToFspath(filePath);
if (!fspath) {
return callback(`File not found or invalid URL: ${filePath}`);
}
const fs = require("fs");
if (!fs.existsSync(fspath)) {
return callback(`File does not exist: ${fspath}`);
}
try {
// 2. 读取
let updatedContent = fs.readFileSync(fspath, "utf-8");
// 3. 应用编辑
// 必须按倒序应用编辑,否则后续编辑的位置会偏移 (假设edits未排序这里简单处理实际上LSP通常建议客户端倒序应用或计算偏移)
// 这里假设edits已经按照位置排序或者用户负责如果需要严谨应先按 start/position 倒序排序
// 简单排序保险:
const sortedEdits = [...edits].sort((a, b) => {
const posA = a.position !== undefined ? a.position : a.start;
const posB = b.position !== undefined ? b.position : b.start;
return posB - posA; // 从大到小
});
sortedEdits.forEach((edit) => {
switch (edit.type) {
case "insert":
updatedContent =
updatedContent.slice(0, edit.position) +
edit.text +
updatedContent.slice(edit.position);
break;
case "delete":
updatedContent = updatedContent.slice(0, edit.start) + updatedContent.slice(edit.end);
break;
case "replace":
updatedContent =
updatedContent.slice(0, edit.start) + edit.text + updatedContent.slice(edit.end);
break;
}
});
// 4. 写入
fs.writeFileSync(fspath, updatedContent, "utf-8");
// 5. 通知编辑器资源变化 (重要)
Editor.assetdb.refresh(filePath, (err) => {
if (err) addLog("warn", `Refresh failed for ${filePath}: ${err}`);
callback(null, `Text edits applied to ${filePath}`);
});
} catch (err) {
callback(`Action failed: ${err.message}`);
}
},
// 读取控制台
readConsole(args, callback) {
const { limit, type } = args;
let filteredOutput = logBuffer;
if (type) {
filteredOutput = filteredOutput.filter((item) => item.type === type);
}
if (limit) {
filteredOutput = filteredOutput.slice(-limit);
}
callback(null, filteredOutput);
},
2026-02-02 10:14:17 +08:00
executeMenuItem(args, callback) {
const { menuPath } = args;
if (!menuPath) {
return callback("Menu path is required");
}
addLog("info", `Executing Menu Item: ${menuPath}`);
// 菜单项映射表 (Cocos Creator 2.4.x IPC)
// 参考: IPC_MESSAGES.md
const menuMap = {
'File/New Scene': 'scene:new-scene',
'File/Save Scene': 'scene:stash-and-save',
'File/Save': 'scene:stash-and-save', // 别名
'Edit/Undo': 'scene:undo',
'Edit/Redo': 'scene:redo',
'Edit/Delete': 'scene:delete-nodes',
'Delete': 'scene:delete-nodes',
'delete': 'scene:delete-nodes',
'Node/Create Empty Node': 'scene:create-node-by-classid', // 简化的映射,通常需要参数
'Project/Build': 'app:build-project',
};
// 特殊处理 delete-node:UUID 格式
if (menuPath.startsWith("delete-node:")) {
const uuid = menuPath.split(":")[1];
if (uuid) {
Editor.Scene.callSceneScript('mcp-bridge', 'delete-node', { uuid }, (err, result) => {
if (err) callback(err);
else callback(null, result || `Node ${uuid} deleted via scene script`);
});
return;
}
}
if (menuMap[menuPath]) {
const ipcMsg = menuMap[menuPath];
try {
// 获取当前选中的节点进行删除(如果该消息是删除操作)
if (ipcMsg === 'scene:delete-nodes') {
const selection = Editor.Selection.curSelection("node");
if (selection.length > 0) {
Editor.Ipc.sendToMain(ipcMsg, selection);
callback(null, `Menu action triggered: ${menuPath} -> ${ipcMsg} with ${selection.length} nodes`);
} else {
callback("No nodes selected for deletion");
}
} else {
Editor.Ipc.sendToMain(ipcMsg);
callback(null, `Menu action triggered: ${menuPath} -> ${ipcMsg}`);
}
} catch (err) {
callback(`Failed to execute IPC ${ipcMsg}: ${err.message}`);
}
2026-02-02 10:14:17 +08:00
} else {
// 对于未在映射表中的菜单,尝试通用的 menu:click (虽然不一定有效)
// 或者直接返回不支持的警告
addLog("warn", `Menu item '${menuPath}' not found in supported map. Trying legacy fallback.`);
// 尝试通用调用
try {
// 注意Cocos Creator 2.x 的 menu:click 通常需要 Electron 菜单 ID而不只是路径
// 这里做个尽力而为的尝试
Editor.Ipc.sendToMain('menu:click', menuPath);
callback(null, `Generic menu action sent: ${menuPath} (Success guaranteed only for supported items)`);
} catch (e) {
callback(`Failed to execute menu item: ${menuPath}`);
}
2026-02-02 10:14:17 +08:00
}
},
// 验证脚本
validateScript(args, callback) {
const { filePath } = args;
// 1. 获取文件系统路径
const fspath = Editor.assetdb.urlToFspath(filePath);
if (!fspath) {
return callback(`File not found or invalid URL: ${filePath}`);
}
// 2. 检查文件是否存在
if (!fs.existsSync(fspath)) {
return callback(`File does not exist: ${fspath}`);
}
// 3. 读取内容并验证
try {
const content = fs.readFileSync(fspath, "utf-8");
// 检查空文件
if (!content || content.trim().length === 0) {
return callback(null, { valid: false, message: "Script is empty" });
}
// 对于 JavaScript 脚本,使用 Function 构造器进行语法验证
if (filePath.endsWith(".js")) {
const wrapper = `(function() { ${content} })`;
try {
new Function(wrapper);
callback(null, { valid: true, message: "JavaScript syntax is valid" });
} catch (syntaxErr) {
return callback(null, { valid: false, message: syntaxErr.message });
}
}
// 对于 TypeScript由于没有内置 TS 编译器,我们进行基础的"防呆"检查
// 并明确告知用户无法进行完整编译验证
else if (filePath.endsWith(".ts")) {
// 简单的正则表达式检查:是否有非法字符或明显错误结构 (示例)
// 这里暂时只做简单的括号匹配检查或直接通过,但给出一个 Warning
// 检查是否有 class 定义 (简单的启发式检查)
if (!content.includes('class ') && !content.includes('interface ') && !content.includes('enum ') && !content.includes('export ')) {
return callback(null, { valid: true, message: "Warning: TypeScript file seems to lack standard definitions (class/interface), but basic syntax check is skipped due to missing compiler." });
}
callback(null, { valid: true, message: "TypeScript basic check passed. (Full compilation validation requires editor build)" });
} else {
callback(null, { valid: true, message: "Unknown script type, validation skipped." });
}
} catch (err) {
callback(null, { valid: false, message: `Read Error: ${err.message}` });
}
},
// 暴露给 MCP 或面板的 API 封装
messages: {
"scan-ipc-messages"(event) {
try {
const msgs = IpcManager.getIpcMessages();
if (event.reply) event.reply(null, msgs);
} catch (e) {
if (event.reply) event.reply(e.message);
}
},
"test-ipc-message"(event, args) {
const { name, params } = args;
IpcManager.testIpcMessage(name, params).then((result) => {
if (event.reply) event.reply(null, result);
});
},
"open-test-panel"() {
Editor.Panel.open("mcp-bridge");
},
"toggle-server"(event, port) {
if (serverConfig.active) this.stopServer();
else this.startServer(port);
},
"clear-logs"() {
logBuffer = [];
addLog("info", "Logs cleared");
},
// 修改场景中的节点(需要通过 scene-script
"set-node-property"(event, args) {
addLog("mcp", `Creating node: ${args.name} (${args.type})`);
// 确保第一个参数 'mcp-bridge' 和 package.json 的 name 一致
Editor.Scene.callSceneScript("mcp-bridge", "set-property", args, (err, result) => {
if (err) {
Editor.error("Scene Script Error:", err);
}
if (event && event.reply) {
event.reply(err, result);
}
});
},
"create-node"(event, args) {
addLog("mcp", `Creating node: ${args.name} (${args.type})`);
Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, (err, result) => {
if (err) addLog("error", `CreateNode Failed: ${err}`);
else addLog("success", `Node Created: ${result}`);
event.reply(err, result);
});
},
"get-server-state"(event) {
let profile = this.getProfile();
event.reply(null, {
config: serverConfig,
logs: logBuffer,
autoStart: profile.get("auto-start"), // 返回自动启动状态
});
},
"set-auto-start"(event, value) {
this.getProfile().set("auto-start", value);
this.getProfile().save();
addLog("info", `Auto-start set to: ${value}`);
},
"inspect-apis"() {
addLog("info", "[API Inspector] Starting DEEP inspection...");
// 获取函数参数的辅助函数
const getArgs = (func) => {
try {
const str = func.toString();
const match = str.match(/function\s.*?\(([^)]*)\)/) || str.match(/.*?\(([^)]*)\)/);
if (match) {
return match[1].split(",").map(arg => arg.trim()).filter(a => a).join(", ");
}
return `${func.length} args`;
} catch (e) {
return "?";
}
};
// 检查对象的辅助函数
const inspectObj = (name, obj) => {
if (!obj) return { name, exists: false };
const props = {};
const proto = Object.getPrototypeOf(obj);
// 组合自身属性和原型属性
const allKeys = new Set([...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertyNames(proto || {})]);
allKeys.forEach(key => {
if (key.startsWith("_")) return; // 跳过私有属性
try {
const val = obj[key];
if (typeof val === 'function') {
props[key] = `func(${getArgs(val)})`;
} else {
props[key] = typeof val;
}
} catch (e) { }
});
return { name, exists: true, props };
};
// 1. 检查标准对象
const standardObjects = {
"Editor.assetdb": Editor.assetdb,
"Editor.Selection": Editor.Selection,
"Editor.Ipc": Editor.Ipc,
"Editor.Panel": Editor.Panel,
"Editor.Scene": Editor.Scene,
"Editor.Utils": Editor.Utils,
"Editor.remote": Editor.remote
};
const report = {};
Object.keys(standardObjects).forEach(key => {
report[key] = inspectObj(key, standardObjects[key]);
});
// 2. 检查特定论坛提到的 API
const forumChecklist = [
"Editor.assetdb.queryInfoByUuid",
"Editor.assetdb.assetInfoByUuid",
"Editor.assetdb.move",
"Editor.assetdb.createOrSave",
"Editor.assetdb.delete",
"Editor.assetdb.urlToUuid",
"Editor.assetdb.uuidToUrl",
"Editor.assetdb.fspathToUrl",
"Editor.assetdb.urlToFspath",
"Editor.remote.assetdb.uuidToUrl",
"Editor.Selection.select",
"Editor.Selection.clear",
"Editor.Selection.curSelection",
"Editor.Selection.curGlobalActivate"
];
const checklistResults = {};
forumChecklist.forEach(path => {
const parts = path.split(".");
let curr = global; // 在主进程中Editor 是全局的
let exists = true;
for (const part of parts) {
if (curr && curr[part]) {
curr = curr[part];
} else {
exists = false;
break;
}
}
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] Forum Checklist:\n${JSON.stringify(checklistResults, null, 2)}`);
// 3. 检查内置包 IPC 消息
const ipcReport = {};
const builtinPackages = ["scene", "builder", "assets"]; // 核心内置包
const fs = require("fs");
builtinPackages.forEach(pkgName => {
try {
const pkgPath = Editor.url(`packages://${pkgName}/package.json`);
if (pkgPath && fs.existsSync(pkgPath)) {
const pkgData = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
if (pkgData.messages) {
ipcReport[pkgName] = Object.keys(pkgData.messages);
} else {
ipcReport[pkgName] = "No messages defined";
}
} else {
ipcReport[pkgName] = "Package path not found";
}
} catch (e) {
ipcReport[pkgName] = `Error: ${e.message}`;
}
});
addLog("info", `[API Inspector] Built-in IPC Messages:\n${JSON.stringify(ipcReport, null, 2)}`);
},
},
// 全局文件搜索
findInFile(args, callback) {
const { query, extensions, includeSubpackages } = args;
const assetsPath = Editor.assetdb.urlToFspath("db://assets");
const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"];
const results = [];
const MAX_RESULTS = 500; // 限制返回结果数量,防止溢出
try {
// 递归遍历函数
const walk = (dir) => {
if (results.length >= MAX_RESULTS) return;
const list = fs.readdirSync(dir);
list.forEach((file) => {
if (results.length >= MAX_RESULTS) return;
// 忽略隐藏文件和 node_modules
if (file.startsWith('.') || file === 'node_modules' || file === 'bin' || file === 'local') return;
const filePath = pathModule.join(dir, file);
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
walk(filePath);
} else {
// 检查后缀
const ext = pathModule.extname(file).toLowerCase();
if (validExtensions.includes(ext)) {
try {
const content = fs.readFileSync(filePath, 'utf8');
// 简单的行匹配
const lines = content.split('\n');
lines.forEach((line, index) => {
if (results.length >= MAX_RESULTS) return;
if (line.includes(query)) {
// 转换为项目相对路径 (db://assets/...)
const relativePath = pathModule.relative(assetsPath, filePath);
// 统一使用 forward slash
const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join('/');
results.push({
filePath: dbPath,
line: index + 1,
content: line.trim()
});
}
});
} catch (e) {
// 读取文件出错,跳过
}
}
}
});
};
walk(assetsPath);
callback(null, results);
} catch (err) {
callback(`Find in file failed: ${err.message}`);
}
},
// 管理撤销/重做
manageUndo(args, callback) {
const { action, description } = args;
try {
switch (action) {
case "undo":
Editor.Ipc.sendToPanel("scene", "scene:undo");
callback(null, "Undo command executed");
break;
case "redo":
Editor.Ipc.sendToPanel("scene", "scene:redo");
callback(null, "Redo command executed");
break;
case "begin_group":
// scene:undo-record [id]
// 这里的 id 好像是可选的,或者用于区分不同的事务
Editor.Ipc.sendToPanel("scene", "scene:undo-record", description || "MCP Action");
callback(null, `Undo group started: ${description || "MCP Action"}`);
break;
case "end_group":
Editor.Ipc.sendToPanel("scene", "scene:undo-commit");
callback(null, "Undo group committed");
break;
case "cancel_group":
Editor.Ipc.sendToPanel("scene", "scene:undo-cancel");
callback(null, "Undo group cancelled");
break;
default:
callback(`Unknown undo action: ${action}`);
}
} catch (err) {
callback(`Undo operation failed: ${err.message}`);
}
},
// 获取文件 SHA-256
getSha(args, callback) {
const { path: url } = args;
const fspath = Editor.assetdb.urlToFspath(url);
if (!fspath || !fs.existsSync(fspath)) {
return callback(`File not found: ${url}`);
}
try {
const fileBuffer = fs.readFileSync(fspath);
const hashSum = crypto.createHash('sha256');
hashSum.update(fileBuffer);
const sha = hashSum.digest('hex');
callback(null, { path: url, sha: sha });
} catch (err) {
callback(`Failed to calculate SHA: ${err.message}`);
}
},
// 管理动画
manageAnimation(args, callback) {
// 转发给场景脚本处理
Editor.Scene.callSceneScript("mcp-bridge", "manage-animation", args, callback);
},
};