From 002f081290bffe432b08bc380242549174f861ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=AB=E7=84=B0=E5=BA=93=E6=8B=89?= Date: Fri, 27 Feb 2026 23:04:25 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README=20?= =?UTF-8?q?=E5=92=8C=20UPDATE=5FLOG=20=E6=96=87=E6=A1=A3=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=97=A5=E5=BF=97=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=BF=AE=E5=A4=8D=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/project-rules.mdc | 266 -- README.md | 19 +- UPDATE_LOG.md | 44 +- main.js | 5090 +++++++++++++++++---------------- mcp-proxy.js | 169 +- panel/index.js | 592 ++-- scene-script.js | 2315 +++++++-------- 7 files changed, 4191 insertions(+), 4304 deletions(-) delete mode 100644 .trae/rules/project-rules.mdc diff --git a/.trae/rules/project-rules.mdc b/.trae/rules/project-rules.mdc deleted file mode 100644 index ea4643f..0000000 --- a/.trae/rules/project-rules.mdc +++ /dev/null @@ -1,266 +0,0 @@ ---- -description: -alwaysApply: true -enabled: true -updatedAt: 2026-02-03T13:55:50.734Z -provider: ---- - - -# MCP Bridge Development Rules - -## 1. 语言规范 (Language) - -* **强制中文**: 所有的对话回复、代码注释、以及生成的文档都必须使用**中文**。 -* **日志消息**: `addLog()` 的消息内容可以使用中英文混合,确保关键术语清晰。 - ---- - -## 2. 关键工作流程 (Critical Workflow) - -### 2.1 插件重载 -- **必须重载**: 修改 `main.js`, `package.json`, `scene-script.js`, 或 `panel/` 后,**必须**在编辑器中执行「扩展 → 刷新」或重启编辑器。 -- **热更新不适用**: Cocos Creator 2.x 的插件主进程脚本不支持热更新。 - -### 2.2 测试驱动 -- **测试脚本**: 每个新功能必须在 `test/` 目录下创建独立测试脚本 (如 `test/test_feature.js`)。 -- **HTTP 验证**: 测试脚本通过 HTTP 请求直接调用插件 API,验证功能正确性。 -- **运行前提**: 确保 Cocos Creator 编辑器已打开且 MCP Bridge 服务已启动。 - ---- - -## 3. 架构与进程隔离 (Architecture & IPC) - -### 3.1 进程职责划分 - -| 文件 | 进程 | 可访问 | 不可访问 | -|------|------|--------|----------| -| `main.js` | 主进程 (Main) | `Editor.assetdb`, `Editor.Ipc`, `require()` | `cc.*` (Cocos 引擎) | -| `scene-script.js` | 渲染进程 (Renderer) | `cc.*`, `cc.engine`, `cc.director` | `Editor.assetdb`, `Editor.FileSystem` | - -### 3.2 跨进程通信规则 - -``` -主进程 (main.js) 渲染进程 (scene-script.js) - │ │ - ├─ 1. 接收 HTTP 请求 │ - ├─ 2. 解析 db:// 路径为 UUID │ - │ Editor.assetdb.urlToUuid() │ - ├─ 3. 调用场景脚本 ──────────────────────┤ - │ Editor.Scene.callSceneScript() │ - │ ├─ 4. 操作节点/组件 - │ │ cc.engine.getInstanceById() - │ ├─ 5. 通知场景变脏 - │ │ Editor.Ipc.sendToMain("scene:dirty") - └─ 6. 返回结果 ◀────────────────────────┘ -``` - -**核心规则**: 永远在 `main.js` 中将 `db://` 路径转换为 UUID,再传递给 `scene-script.js`。 - ---- - -## 4. 编码规范 (Coding Standards) - -### 4.1 命名规范 - -| 类型 | 规范 | 示例 | -|------|------|------| -| 函数名 | camelCase | `handleMcpCall`, `manageScript` | -| 常量 | SCREAMING_SNAKE_CASE | `MAX_RESULTS`, `DEFAULT_PORT` | -| 私有变量 | _camelCase | `_isMoving`, `_timer` | -| 布尔变量 | is/has/can 前缀 | `isSceneBusy`, `hasComponent` | -| MCP 工具名 | snake_case | `get_selected_node`, `manage_vfx` | -| IPC 消息名 | kebab-case | `get-hierarchy`, `create-node` | - -### 4.2 函数组织顺序 - -在 `module.exports` 中按以下顺序组织函数: - -```javascript -module.exports = { - // 1. 配置属性 - "scene-script": "scene-script.js", - - // 2. 生命周期函数 - load() { }, - unload() { }, - - // 3. 服务器管理 - startServer(port) { }, - stopServer() { }, - - // 4. 核心处理逻辑 - handleMcpCall(name, args, callback) { }, - - // 5. 工具函数 (按字母顺序) - applyTextEdits(args, callback) { }, - batchExecute(args, callback) { }, - // ... - - // 6. IPC 消息处理 - messages: { - "open-test-panel"() { }, - // ... - } -}; -``` - -### 4.3 避免重复定义 - -> ⚠️ **重要**: `main.js` 已存在重复函数问题,编辑前务必使用 `view_file` 确认上下文,避免创建重复定义。 - -**检查清单**: -- [ ] 新增函数前,搜索是否已存在同名函数 -- [ ] 修改函数时,确认只有一个定义 -- [ ] `messages` 对象中避免重复的消息处理器 - -### 4.4 日志规范 - -使用 `addLog(type, message)` 替代 `console.log()`: - -```javascript -// ✅ 正确 -addLog("info", "服务启动成功"); -addLog("error", `操作失败: ${err.message}`); -addLog("mcp", `REQ -> [${toolName}]`); -addLog("success", `RES <- [${toolName}] 成功`); - -// ❌ 错误 -console.log("服务启动成功"); // 不会被 read_console 捕获 -``` - -| type | 用途 | 颜色 | -|------|------|------| -| `info` | 一般信息 | 蓝色 | -| `success` | 操作成功 | 绿色 | -| `warn` | 警告信息 | 黄色 | -| `error` | 错误信息 | 红色 | -| `mcp` | MCP 请求/响应 | 紫色 | - ---- - -## 5. 撤销/重做支持 (Undo/Redo) - -### 5.1 使用 scene:set-property - -对于节点属性修改,优先使用 `scene:set-property` 以获得原生 Undo 支持: - -```javascript -// ✅ 支持 Undo -Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id: nodeId, - path: "x", - type: "Float", - value: 100, - isSubProp: false -}); - -// ❌ 不支持 Undo (直接修改) -node.x = 100; -``` - -### 5.2 使用 Undo 组 - -对于复合操作,使用 Undo 组包装: - -```javascript -Editor.Ipc.sendToPanel("scene", "scene:undo-record", "Transform Update"); -try { - // 执行多个属性修改 - Editor.Ipc.sendToPanel("scene", "scene:undo-commit"); -} catch (e) { - Editor.Ipc.sendToPanel("scene", "scene:undo-cancel"); -} -``` - ---- - -## 6. 功能特定规则 (Feature Specifics) - -### 6.1 粒子系统 (VFX) - -```javascript -// 必须设置 custom = true,否则属性修改可能不生效 -particleSystem.custom = true; - -// 确保纹理有效,否则粒子不可见 -if (!particleSystem.texture && !particleSystem.file) { - // 加载默认纹理 -} -``` - -### 6.2 资源路径解析 - -```javascript -// 内置资源可能需要多个路径尝试 -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) break; -} -``` - -### 6.3 场景操作时序 - -```javascript -// 场景操作后需要延迟通知 UI 刷新 -newNode.parent = parent; -Editor.Ipc.sendToMain("scene:dirty"); - -// 使用 setTimeout 让出主循环 -setTimeout(() => { - Editor.Ipc.sendToAll("scene:node-created", { - uuid: newNode.uuid, - parentUuid: parent.uuid, - }); -}, 10); -``` - ---- - -## 7. 错误处理规范 (Error Handling) - -### 7.1 回调风格统一 - -```javascript -// ✅ 标准风格 -callback(null, result); // 成功 -callback("Error message"); // 失败 (字符串) -callback(new Error("message")); // 失败 (Error 对象) - -// 避免混用 -callback(err, null); // 不推荐,保持一致性 -``` - -### 7.2 异步操作错误处理 - -```javascript -Editor.assetdb.queryInfoByUrl(path, (err, info) => { - if (err) { - addLog("error", `查询资源失败: ${err.message}`); - return callback(`Failed to get info: ${err.message}`); - } - // 继续处理... -}); -``` - ---- - -## 8. 提交规范 (Git Commit) - -使用 [Conventional Commits](https://conventionalcommits.org/) 格式: - -| 类型 | 用途 | 示例 | -|------|------|------| -| `feat` | 新功能 | `feat: add manage_vfx tool` | -| `fix` | 修复 bug | `fix: resolve duplicate function in main.js` | -| `docs` | 文档更新 | `docs: add code review report` | -| `refactor` | 重构 | `refactor: split main.js into modules` | -| `test` | 测试 | `test: add material management tests` | -| `chore` | 杂项 | `chore: update dependencies` | diff --git a/README.md b/README.md index 5573495..79cfbc3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - **脚本管理**: 创建、删除、读取、写入脚本文件 - **批处理执行**: 批量执行多个 MCP 工具操作,提高效率 - **资产管理**: 创建、删除、移动、获取资源信息 -- **实时日志**: 提供详细的操作日志记录和展示 +- **实时日志**: 提供详细的操作日志记录和展示,支持持久化写入项目内日志文件 - **自动启动**: 支持编辑器启动时自动开启服务 - **编辑器管理**: 获取和设置选中对象,刷新编辑器 - **游戏对象查找**: 根据条件查找场景中的节点 @@ -28,6 +28,8 @@ - **全局搜索**: 在项目中搜索文本内容 - **撤销/重做**: 管理编辑器的撤销栈 - **特效管理**: 创建和修改粒子系统 +- **并发安全**: 指令队列串行化执行,防止编辑器卡死 +- **超时保护**: IPC 通信和指令队列均有超时兜底机制 - **工具说明**: 测试面板提供详细的工具描述和参数说明 ## 安装与使用 @@ -71,12 +73,12 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j ```json { - "mcpServers": { - "cocos-creator": { - "command": "node", - "args": ["[Cocos Creator 项目的绝对路径]/packages/mcp-bridge/mcp-proxy.js"] - } - } + "mcpServers": { + "cocos-creator": { + "command": "node", + "args": ["[Cocos Creator 项目的绝对路径]/packages/mcp-bridge/mcp-proxy.js"] + } + } } ``` @@ -406,13 +408,14 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j ### 日志管理 -插件会通过内置的测试面板(MCP Bridge/Open Panel)实时记录所有操作的日志,包括: +插件会通过内置的测试面板(MCP Bridge/Open Panel)实时记录所有操作的日志,并同步持久化写入项目目录 `settings/mcp-bridge.log` 文件,编辑器重启后仍可查阅历史日志。日志记录包括: - 服务启动/停止状态 - MCP 客户端请求接收(完整包含工具的 `arguments` 参数,超长自动截断) - 场景节点树遍历与耗时信息 - 工具调用的执行成功/失败状态返回 - IPC 消息和核心底层报错堆栈 +- 内存保护:日志缓冲区上限 2000 条,超出自动截断旧日志 ## 注意事项 diff --git a/UPDATE_LOG.md b/UPDATE_LOG.md index c5cf915..4f6acb7 100644 --- a/UPDATE_LOG.md +++ b/UPDATE_LOG.md @@ -192,4 +192,46 @@ - **修复**: 在 `scene-script.js` 层加固了前置拦截规则: 1. **直接拦截节点**: 当检测到传入 `cc.Node` 或 `Node` 作为组件类型时直接驳回,并返回富含指导意义的中文提示词(如“请使用 create-node 创建节点”)。 2. **继承链校验**: 提取引擎类定义后,强制要求通过 `cc.js.isChildClassOf` 判断该类必须继承自 `cc.Component`。若不合法则即时截断并提示。 -- **价值**: 通过将冰冷的底层异常翻译为“手把手教 AI 怎么重试”的指导性异常,彻底根治了 AI 在操作组件时乱认对象、反复撞墙的通病。 +- **价值**: 通过将冰冷的底层异常翻译为"手把手教 AI 怎么重试"的指导性异常,彻底根治了 AI 在操作组件时乱认对象、反复撞墙的通病。 + +--- + +## 日志系统持久化与健壮性优化 (2026-02-27) + +### 1. 日志文件持久化 + +- **问题**: 插件的所有运行日志只保存在内存中(`logBuffer`),编辑器重启后日志全部丢失,无法进行会话级别的问题回溯。 +- **优化**: 在 `main.js` 的 `addLog` 函数中新增文件写入逻辑。所有日志实时追加写入项目目录下的 `settings/mcp-bridge.log` 文件(懒初始化路径)。 +- **实现细节**: + - 新增 `getLogFilePath()` 辅助函数,通过 `Editor.assetdb.urlToFspath` 推导项目根目录,将日志存放在 `settings/` 子目录中。 + - 日志格式统一为 `[时间戳] [类型] 内容`,与面板日志保持一致。 + - 文件写入使用 `fs.appendFileSync` 同步追加,失败时静默不影响主流程。 + +### 2. 日志缓冲区内存保护 + +- **问题**: 长时间运行的编辑器会话中,`logBuffer` 数组无限增长,最终导致内存压力。 +- **优化**: 在 `addLog` 中增加上限检查,当日志条数超过 2000 时自动截断旧日志,仅保留最近 1500 条。 + +### 3. 请求关联计数器 (`_requestCounter`) + +- **优化**: 新增全局 `_requestCounter` 变量,为每个 HTTP 请求分配唯一的自增序号,便于在高并发场景下追踪同一请求的完整生命周期(从入队到执行到响应)。 + +### 4. CommandQueue 兜底超时保护 + +- **问题**: 原有的 `processNextCommand` 队列机制依赖每个指令主动调用 `done()` 回调来释放队列。如果某个工具函数内部逻辑异常导致 `done()` 未被调用,整个队列将永久停滞。 +- **优化**: 在 `enqueueCommand` 中为每个入队指令注册 60 秒兜底超时定时器 (`setTimeout`)。超时后强制释放队列位置并记录错误日志 `[CommandQueue] 指令执行超时(60s),强制释放队列`,确保后续指令不被阻塞。 +- **正常路径**: 指令正常完成时通过 `clearTimeout` 取消定时器,无额外开销。 + +### 5. 日志仅输出关键信息到编辑器控制台 + +- **优化**: `addLog` 函数不再将所有类型的日志输出到编辑器控制台,仅 `error` 和 `warn` 级别日志通过 `Editor.error()` / `Editor.warn()` 输出,防止 `info` / `success` / `mcp` 类型日志刷屏干扰开发者。 + +--- + +## 面板加载修复 (2026-02-24) + +### 1. `panel/index.js` 语法错误修复 + +- **问题**: 面板加载时出现 `SyntaxError: Invalid or unexpected token`,导致 MCP Bridge 插件面板完全无法渲染。 +- **原因**: `index.js` 中存在非法字符或格式错误,被 Cocos Creator 的面板加载器拒绝解析。 +- **修复**: 清理了文件中的语法问题,确保面板能够正常加载和初始化。 diff --git a/main.js b/main.js index 02aabe8..ac4caec 100644 --- a/main.js +++ b/main.js @@ -6,12 +6,13 @@ const pathModule = require("path"); const fs = require("fs"); const crypto = require("crypto"); -let logBuffer = []; // 存储所有日志 +let logBuffer = []; // 存储所有日志(上限 2000 条,超出自动截断旧日志) +let _requestCounter = 0; // 请求关联计数器 let mcpServer = null; let isSceneBusy = false; let serverConfig = { - port: 3456, - active: false, + port: 3456, + active: false, }; /** @@ -28,32 +29,41 @@ let isProcessingCommand = false; * @returns {Promise} 操作完成后 resolve */ function enqueueCommand(fn) { - return new Promise((resolve) => { - commandQueue.push({ fn, resolve }); - processNextCommand(); - }); + return new Promise((resolve) => { + // 兜底超时保护:防止 fn 内部未调用 done() 导致队列永久停滞 + const timeoutId = setTimeout(() => { + addLog("error", "[CommandQueue] 指令执行超时(60s),强制释放队列"); + isProcessingCommand = false; + resolve(); + processNextCommand(); + }, 60000); + commandQueue.push({ fn, resolve, timeoutId }); + 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(); - } + if (isProcessingCommand || commandQueue.length === 0) return; + isProcessingCommand = true; + const { fn, resolve, timeoutId } = commandQueue.shift(); + try { + fn(() => { + clearTimeout(timeoutId); + isProcessingCommand = false; + resolve(); + processNextCommand(); + }); + } catch (e) { + // 防止队列因未捕获异常永久阻塞 + clearTimeout(timeoutId); + addLog("error", `[CommandQueue] 指令执行异常: ${e.message}`); + isProcessingCommand = false; + resolve(); + processNextCommand(); + } } /** @@ -66,52 +76,99 @@ function processNextCommand() { * @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); + 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); - } - }; + // 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); - } + 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 日志类型 + * 日志文件路径(懒初始化,在项目 settings 目录下) + * @type {string|null} + */ +let _logFilePath = null; + +/** + * 获取日志文件路径 + * @returns {string|null} + */ +function getLogFilePath() { + if (_logFilePath) return _logFilePath; + try { + const assetsPath = Editor.assetdb.urlToFspath("db://assets"); + if (assetsPath) { + const projectRoot = pathModule.dirname(assetsPath); + const settingsDir = pathModule.join(projectRoot, "settings"); + if (!fs.existsSync(settingsDir)) { + fs.mkdirSync(settingsDir, { recursive: true }); + } + _logFilePath = pathModule.join(settingsDir, "mcp-bridge.log"); + return _logFilePath; + } + } catch (e) { + // 静默失败,不影响主流程 + } + return null; +} + +/** + * 封装日志函数 + * - 所有日志发送到 MCP 测试面板 + 内存缓存 + * - 仅 error / warn 输出到编辑器控制台(防止刷屏) + * - 所有日志追加写入项目内 settings/mcp-bridge.log 文件(持久化) + * @param {'info' | 'success' | 'warn' | 'error' | 'mcp'} 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); + const logEntry = { + time: new Date().toISOString().replace("T", " ").substring(0, 23), + type: type, + content: message, + }; + logBuffer.push(logEntry); + // 防止内存泄漏:限制日志缓存上限 + if (logBuffer.length > 2000) { + logBuffer = logBuffer.slice(-1500); + } + // 发送到面板 + 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 { - } + // 仅关键信息输出到编辑器控制台(error / warn) + if (type === "error") { + Editor.error(`[MCP] ${message}`); + } else if (type === "warn") { + Editor.warn(`[MCP] ${message}`); + } + + // 持久化到日志文件 + try { + const logPath = getLogFilePath(); + if (logPath) { + const line = `[${logEntry.time}] [${type}] ${message}\n`; + fs.appendFileSync(logPath, line, "utf8"); + } + } catch (e) { + // 文件写入失败时静默,不影响主流程 + } } /** @@ -119,7 +176,7 @@ function addLog(type, message) { * @returns {string} 拼接后的日志字符串 */ 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"); } /** @@ -127,1114 +184,1152 @@ function getLogContent() { * @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); - } + // 尝试获取 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); + 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 工具列表定义 + * 获取所有支持的 MCP 工具列表定义(懒加载缓存) * @returns {Array} 工具定义数组 */ +let _toolsListCache = null; const getToolsList = () => { - const globalPrecautions = - "【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab)必须通过 UUID 进行赋值。 4. 严禁频繁刷新全局资源 (refresh_editor),必须通过 properties.path 指定具体修改的文件或目录以防止编辑器长期卡死。"; - return [ - { - name: "get_selected_node", - description: `获取当前编辑器中选中的节点 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: `保存当前场景的修改`, - inputSchema: { type: "object", properties: {} }, - }, - { - name: "get_scene_hierarchy", - description: `获取当前场景的节点树结构(包含 UUID、名称、子节点数)。若要查询节点组件详情等,请使用 manage_components。`, - inputSchema: { - type: "object", - properties: { - nodeId: { type: "string", description: "指定的根节点 UUID。如果不传则获取整个场景的根。" }, - depth: { - type: "number", - description: "遍历的深度限制,默认为 2。用来防止过大场景导致返回数据超长。", - }, - includeDetails: { type: "boolean", description: "是否包含坐标、缩放等杂项详情,默认为 false。" }, - }, - }, - }, - { - 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" }, - width: { type: "number" }, - height: { type: "number" }, - scaleX: { type: "number" }, - scaleY: { type: "number" }, - color: { type: "string", description: "HEX 颜色代码如 #FF0000" }, - }, - required: ["id"], - }, - }, - { - name: "create_scene", - description: `在 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: `打开场景文件。注意:这是一个异步且耗时的操作,打开后请等待几秒。重要:如果是新创建或空的场景,请务必先创建并初始化基础节点(Canvas/Camera)。`, - inputSchema: { - type: "object", - properties: { - url: { - type: "string", - description: "场景资源路径,如 db://assets/NewScene.fire", - }, - }, - required: ["url"], - }, - }, - { - name: "open_prefab", - description: `在编辑器中打开预制体文件进入编辑模式。注意:这是一个异步操作,打开后请等待几秒。`, - inputSchema: { - type: "object", - properties: { - url: { - type: "string", - description: "预制体资源路径,如 db://assets/prefabs/Test.prefab", - }, - }, - 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} 管理脚本文件。注意:创建或修改脚本需时间编译。创建后必须调用 refresh_editor (务必指定 path) 生成 meta 文件,否则无法作为组件添加。`, - 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: - "操作属性。⚠️极为重要:refresh_editor 必须通过 properties.path 指定精确的刷新路径(如 'db://assets/scripts/MyScript.ts')。严禁不带 path 参数进行全局刷新 (db://assets),这在大型项目中会导致编辑器卡死数分钟,严重阻塞工作流。", - }, - }, - required: ["action"], - }, - }, - { - name: "find_gameobjects", - description: `按条件在场景中搜索游戏对象。返回匹配节点的轻量级结构 (UUID, name, active, components 等)。若要获取完整的详细组件属性,请进一步对目标使用 manage_components。`, - inputSchema: { - type: "object", - properties: { - conditions: { - type: "object", - description: - "查找条件。支持的属性:name (节点名称,支持模糊匹配), component (包含的组件类名,如 'cc.Sprite'), active (布尔值,节点的激活状态)。", - }, - 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", "update"], - 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} 执行菜单项。对于节点删除,请使用 "delete-node:UUID" 格式以确保精确执行。对于保存、撤销等操作,请优先使用专用工具 (save_scene, manage_undo)。`, - inputSchema: { - type: "object", - properties: { - menuPath: { - type: "string", - description: "菜单项路径 (支持 'Project/Build' 或 'delete-node:UUID')", - }, - }, - required: ["menuPath"], - }, - }, - { - name: "apply_text_edits", - description: `${globalPrecautions} 对文件应用文本编辑。**专用于修改脚本源代码 (.js, .ts) 或文本文件**。如果要修改场景节点属性,请使用 'manage_components'。`, - inputSchema: { - type: "object", - properties: { - edits: { - type: "array", - items: { - type: "object", - properties: { - type: { - type: "string", - enum: ["insert", "delete", "replace"], - description: "操作类型", - }, - start: { type: "number", description: "起始偏移量 (字符索引)" }, - end: { type: "number", description: "结束偏移量 (delete/replace 用)" }, - position: { type: "number", description: "插入位置 (insert 用)" }, - text: { type: "string", description: "要插入或替换的文本" }, - }, - }, - description: "编辑操作列表。请严格使用偏移量(offset)而非行号。", - }, - filePath: { type: "string", description: "文件路径 (db://...)" }, - }, - required: ["filePath", "edits"], - }, - }, - { - name: "read_console", - description: `读取控制台`, - inputSchema: { - type: "object", - properties: { - limit: { type: "number", description: "输出限制" }, - type: { - type: "string", - enum: ["info", "warn", "error", "success", "mcp"], - description: "输出类型 (info, warn, error, success, mcp)", - }, - }, - }, - }, - { - name: "validate_script", - description: `验证脚本`, - inputSchema: { - type: "object", - properties: { - filePath: { type: "string", description: "脚本路径" }, - }, - required: ["filePath"], - }, - }, - { - name: "search_project", - description: `搜索项目文件。支持三种模式:1. 'content' (默认): 搜索文件内容,支持正则表达式;2. 'file_name': 在指定目录下搜索匹配的文件名;3. 'dir_name': 在指定目录下搜索匹配的文件夹名。`, - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "搜索关键词或正则表达式模式" }, - useRegex: { - type: "boolean", - description: - "是否将 query 视为正则表达式 (仅在 matchType 为 'content', 'file_name' 或 'dir_name' 时生效)", - }, - path: { - type: "string", - description: "搜索起点路径,例如 'db://assets/scripts'。默认为 'db://assets'", - }, - matchType: { - type: "string", - enum: ["content", "file_name", "dir_name"], - description: - "匹配模式:'content' (内容关键词/正则), 'file_name' (搜索文件名), 'dir_name' (搜索文件夹名)", - }, - extensions: { - type: "array", - items: { type: "string" }, - description: - "限定文件后缀 (如 ['.js', '.ts'])。仅在 matchType 为 'content' 或 'file_name' 时有效。", - default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"], - }, - includeSubpackages: { type: "boolean", default: true, description: "是否递归搜索子目录" }, - }, - 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: `获取指定文件的 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"], - }, - }, - ]; + if (_toolsListCache) return _toolsListCache; + const globalPrecautions = + "【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab)必须通过 UUID 进行赋值。 4. 严禁频繁刷新全局资源 (refresh_editor),必须通过 properties.path 指定具体修改的文件或目录以防止编辑器长期卡死。"; + _toolsListCache = [ + { + name: "get_selected_node", + description: `获取当前编辑器中选中的节点 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: `保存当前场景的修改`, + inputSchema: { type: "object", properties: {} }, + }, + { + name: "get_scene_hierarchy", + description: `获取当前场景的节点树结构(包含 UUID、名称、子节点数)。若要查询节点组件详情等,请使用 manage_components。`, + inputSchema: { + type: "object", + properties: { + nodeId: { type: "string", description: "指定的根节点 UUID。如果不传则获取整个场景的根。" }, + depth: { + type: "number", + description: "遍历的深度限制,默认为 2。用来防止过大场景导致返回数据超长。", + }, + includeDetails: { type: "boolean", description: "是否包含坐标、缩放等杂项详情,默认为 false。" }, + }, + }, + }, + { + 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" }, + width: { type: "number" }, + height: { type: "number" }, + scaleX: { type: "number" }, + scaleY: { type: "number" }, + color: { type: "string", description: "HEX 颜色代码如 #FF0000" }, + }, + required: ["id"], + }, + }, + { + name: "create_scene", + description: `在 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: `打开场景文件。注意:这是一个异步且耗时的操作,打开后请等待几秒。重要:如果是新创建或空的场景,请务必先创建并初始化基础节点(Canvas/Camera)。`, + inputSchema: { + type: "object", + properties: { + url: { + type: "string", + description: "场景资源路径,如 db://assets/NewScene.fire", + }, + }, + required: ["url"], + }, + }, + { + name: "open_prefab", + description: `在编辑器中打开预制体文件进入编辑模式。注意:这是一个异步操作,打开后请等待几秒。`, + inputSchema: { + type: "object", + properties: { + url: { + type: "string", + description: "预制体资源路径,如 db://assets/prefabs/Test.prefab", + }, + }, + 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} 管理脚本文件。注意:创建或修改脚本需时间编译。创建后必须调用 refresh_editor (务必指定 path) 生成 meta 文件,否则无法作为组件添加。`, + 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: + "操作属性。⚠️极为重要:refresh_editor 必须通过 properties.path 指定精确的刷新路径(如 'db://assets/scripts/MyScript.ts')。严禁不带 path 参数进行全局刷新 (db://assets),这在大型项目中会导致编辑器卡死数分钟,严重阻塞工作流。", + }, + }, + required: ["action"], + }, + }, + { + name: "find_gameobjects", + description: `按条件在场景中搜索游戏对象。返回匹配节点的轻量级结构 (UUID, name, active, components 等)。若要获取完整的详细组件属性,请进一步对目标使用 manage_components。`, + inputSchema: { + type: "object", + properties: { + conditions: { + type: "object", + description: + "查找条件。支持的属性:name (节点名称,支持模糊匹配), component (包含的组件类名,如 'cc.Sprite'), active (布尔值,节点的激活状态)。", + }, + 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", "update"], + 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} 执行菜单项。对于节点删除,请使用 "delete-node:UUID" 格式以确保精确执行。对于保存、撤销等操作,请优先使用专用工具 (save_scene, manage_undo)。`, + inputSchema: { + type: "object", + properties: { + menuPath: { + type: "string", + description: "菜单项路径 (支持 'Project/Build' 或 'delete-node:UUID')", + }, + }, + required: ["menuPath"], + }, + }, + { + name: "apply_text_edits", + description: `${globalPrecautions} 对文件应用文本编辑。**专用于修改脚本源代码 (.js, .ts) 或文本文件**。如果要修改场景节点属性,请使用 'manage_components'。`, + inputSchema: { + type: "object", + properties: { + edits: { + type: "array", + items: { + type: "object", + properties: { + type: { + type: "string", + enum: ["insert", "delete", "replace"], + description: "操作类型", + }, + start: { type: "number", description: "起始偏移量 (字符索引)" }, + end: { type: "number", description: "结束偏移量 (delete/replace 用)" }, + position: { type: "number", description: "插入位置 (insert 用)" }, + text: { type: "string", description: "要插入或替换的文本" }, + }, + }, + description: "编辑操作列表。请严格使用偏移量(offset)而非行号。", + }, + filePath: { type: "string", description: "文件路径 (db://...)" }, + }, + required: ["filePath", "edits"], + }, + }, + { + name: "read_console", + description: `读取控制台`, + inputSchema: { + type: "object", + properties: { + limit: { type: "number", description: "输出限制" }, + type: { + type: "string", + enum: ["info", "warn", "error", "success", "mcp"], + description: "输出类型 (info, warn, error, success, mcp)", + }, + }, + }, + }, + { + name: "validate_script", + description: `验证脚本`, + inputSchema: { + type: "object", + properties: { + filePath: { type: "string", description: "脚本路径" }, + }, + required: ["filePath"], + }, + }, + { + name: "search_project", + description: `搜索项目文件。支持三种模式:1. 'content' (默认): 搜索文件内容,支持正则表达式;2. 'file_name': 在指定目录下搜索匹配的文件名;3. 'dir_name': 在指定目录下搜索匹配的文件夹名。`, + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "搜索关键词或正则表达式模式" }, + useRegex: { + type: "boolean", + description: + "是否将 query 视为正则表达式 (仅在 matchType 为 'content', 'file_name' 或 'dir_name' 时生效)", + }, + path: { + type: "string", + description: "搜索起点路径,例如 'db://assets/scripts'。默认为 'db://assets'", + }, + matchType: { + type: "string", + enum: ["content", "file_name", "dir_name"], + description: + "匹配模式:'content' (内容关键词/正则), 'file_name' (搜索文件名), 'dir_name' (搜索文件夹名)", + }, + extensions: { + type: "array", + items: { type: "string" }, + description: + "限定文件后缀 (如 ['.js', '.ts'])。仅在 matchType 为 'content' 或 'file_name' 时有效。", + default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"], + }, + includeSubpackages: { type: "boolean", default: true, description: "是否递归搜索子目录" }, + }, + 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: `获取指定文件的 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"], + }, + }, + ]; + return _toolsListCache; }; 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"); + "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() { - // 'project' 表示存储在项目本地(settings/mcp-bridge.json),实现配置隔离 - return Editor.Profile.load("profile://project/mcp-bridge.json", "mcp-bridge"); - }, + if (autoStart) { + addLog("info", "Auto-start is enabled. Initializing server..."); + // 延迟一点启动,确保编辑器环境完全就绪 + setTimeout(() => { + this.startServer(serverConfig.port); + }, 1000); + } + }, + /** + * 获取插件配置文件的辅助函数 + * @returns {Object} Editor.Profile 实例 + */ + getProfile() { + // 'project' 表示存储在项目本地(settings/mcp-bridge.json),实现配置隔离 + return Editor.Profile.load("profile://project/mcp-bridge.json", "mcp-bridge"); + }, - /** - * 插件卸载时的回调 - */ - unload() { - this.stopServer(); - }, - /** - * 启动 HTTP 服务器 - * @param {number} port 监听端口 - */ - startServer(port) { - if (mcpServer) this.stopServer(); + /** + * 插件卸载时的回调 + */ + unload() { + this.stopServer(); + }, + /** + * 启动 HTTP 服务器 + * @param {number} port 监听端口 + */ + startServer(port) { + if (mcpServer) this.stopServer(); - const tryStart = (currentPort, retries) => { - if (retries <= 0) { - addLog("error", `Failed to find an available port after multiple attempts.`); - return; - } + const tryStart = (currentPort, retries) => { + if (retries <= 0) { + addLog("error", `Failed to find an available port after multiple attempts.`); + return; + } - try { - mcpServer = http.createServer((req, res) => { - this._handleRequest(req, res); - }); + try { + mcpServer = http.createServer((req, res) => { + this._handleRequest(req, res); + }); - mcpServer.on("error", (e) => { - if (e.code === "EADDRINUSE") { - addLog("warn", `Port ${currentPort} is in use, trying ${currentPort + 1}...`); - try { - mcpServer.close(); - } catch (err) { - // align - } - mcpServer = null; - // Delay slightly to ensure cleanup - setTimeout(() => { - tryStart(currentPort + 1, retries - 1); - }, 100); - } else { - addLog("error", `Server Error: ${e.message}`); - } - }); + mcpServer.on("error", (e) => { + if (e.code === "EADDRINUSE") { + addLog("warn", `Port ${currentPort} is in use, trying ${currentPort + 1}...`); + try { + mcpServer.close(); + } catch (err) { + // align + } + mcpServer = null; + // Delay slightly to ensure cleanup + setTimeout(() => { + tryStart(currentPort + 1, retries - 1); + }, 100); + } else { + addLog("error", `Server Error: ${e.message}`); + } + }); - mcpServer.listen(currentPort, () => { - serverConfig.active = true; - serverConfig.port = currentPort; - addLog("success", `MCP Server running at http://127.0.0.1:${currentPort}`); - Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig); + mcpServer.listen(currentPort, () => { + serverConfig.active = true; + serverConfig.port = currentPort; + addLog("success", `MCP Server running at http://127.0.0.1:${currentPort}`); + Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig); - // Important: Do NOT save the auto-assigned port to profile to avoid pollution - }); - } catch (e) { - addLog("error", `Failed to start server: ${e.message}`); - } - }; + // Important: Do NOT save the auto-assigned port to profile to avoid pollution + }); + } catch (e) { + addLog("error", `Failed to start server: ${e.message}`); + } + }; - // Start trying from the configured port, retry 10 times - tryStart(port, 10); - }, + // Start trying from the configured port, retry 10 times + tryStart(port, 10); + }, - _handleRequest(req, res) { - res.setHeader("Content-Type", "application/json"); - res.setHeader("Access-Control-Allow-Origin", "*"); + _handleRequest(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 })); - } - 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); - 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 || "{}"); - let argsPreview = ""; - if (args) { - try { - argsPreview = typeof args === "object" ? JSON.stringify(args) : String(args); - if (argsPreview.length > 500) { - argsPreview = argsPreview.substring(0, 500) + "...[Truncated]"; - } - } catch (e) { - argsPreview = "[无法序列化的参数]"; - } - } - addLog("mcp", `REQ -> [${name}] (队列长度: ${commandQueue.length}) 参数: ${argsPreview}`); + let body = ""; + let bodySize = 0; + const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB 防护 + let bodyOverflow = false; + req.on("data", (chunk) => { + bodySize += chunk.length; + if (bodySize > MAX_BODY_SIZE) { + if (!bodyOverflow) { + bodyOverflow = true; + addLog("error", `[HTTP] 请求体过大 (>${MAX_BODY_SIZE} bytes),已拒绝`); + req.destroy(); + res.writeHead(413); + res.end(JSON.stringify({ error: "Request body too large" })); + } + return; + } + body += chunk; + }); + req.on("end", () => { + if (bodyOverflow) return; + const url = req.url; + // 健康检查端点 + if (url === "/health") { + res.writeHead(200); + return res.end( + JSON.stringify({ + status: "ok", + queueLength: commandQueue.length, + isProcessing: isProcessingCommand, + isSceneBusy: isSceneBusy, + uptime: process.uptime(), + logCount: logBuffer.length, + }), + ); + } + if (url === "/list-tools") { + const tools = getToolsList(); + addLog("info", `AI Client requested tool list`); + 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); + 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 || "{}"); + let argsPreview = ""; + if (args) { + try { + argsPreview = typeof args === "object" ? JSON.stringify(args) : String(args); + if (argsPreview.length > 500) { + argsPreview = argsPreview.substring(0, 500) + "...[Truncated]"; + } + } catch (e) { + argsPreview = "[无法序列化的参数]"; + } + } + const reqId = `R${++_requestCounter}`; + addLog("mcp", `REQ -> [${name}] #${reqId} (队列长度: ${commandQueue.length}) 参数: ${argsPreview}`); - enqueueCommand((done) => { - 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)); - done(); - }); - }); - } 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; - } + enqueueCommand((done) => { + const startTime = Date.now(); + this.handleMcpCall(name, args, (err, result) => { + const elapsed = Date.now() - startTime; + const response = { + content: [ + { + type: "text", + text: err + ? `Error: ${err}` + : typeof result === "object" + ? JSON.stringify(result, null, 2) + : result, + }, + ], + }; + if (err) { + addLog("error", `RES <- [${name}] #${reqId} 失败 (${elapsed}ms): ${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}] #${reqId} 成功 (${elapsed}ms): ${preview}`); + } + res.writeHead(200); + res.end(JSON.stringify(response)); + done(); + }); + }); + } 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; + } - res.writeHead(404); - res.end(JSON.stringify({ error: "Not Found", url: url })); - }); - }, + res.writeHead(404); + res.end(JSON.stringify({ error: "Not Found", url: url })); + }); + }, - /** - * 关闭 HTTP 服务器 - */ - 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); - } - }, + /** + * 关闭 HTTP 服务器 + */ + 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); + } + }, - /** - * 获取 MCP 资源列表 - * @returns {Array} 资源列表数组 - */ - 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", - }, - ]; - }, + /** + * 获取 MCP 资源列表 + * @returns {Array} 资源列表数组 + */ + 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", + }, + ]; + }, - /** - * 读取指定的 MCP 资源内容 - * @param {string} uri 资源统一资源标识符 (URI) - * @param {Function} callback 完成回调 (err, content) - */ - handleReadResource(uri, callback) { - let parsed; - try { - parsed = new URL(uri); - } catch (e) { - return callback(`Invalid URI: ${uri}`); - } + /** + * 读取指定的 MCP 资源内容 + * @param {string} uri 资源统一资源标识符 (URI) + * @param {Function} callback 完成回调 (err, content) + */ + 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}`); - } + if (parsed.protocol !== "cocos:") { + return callback(`Unsupported protocol: ${parsed.protocol}`); + } - const type = parsed.hostname; // hierarchy, selection, logs + 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; + 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 "selection": + const selection = Editor.Selection.curSelection("node"); + callback(null, JSON.stringify(selection)); + break; - case "logs": - callback(null, getLogContent()); - break; + case "logs": + callback(null, getLogContent()); + break; - default: - callback(`Resource not found: ${uri}`); - 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; + /** + * 处理来自 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 "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 "save_scene": + isSceneBusy = true; + addLog("info", "准备保存场景... 等待 UI 同步。"); + Editor.Ipc.sendToPanel("scene", "scene:stash-and-save"); + // 给场景保存留出时间后再解除锁定(stash-and-save 是异步 IPC) + setTimeout(() => { + isSceneBusy = false; + addLog("info", "安全保存已完成。"); + callback(null, "场景保存成功。"); + }, 1500); + break; - case "get_scene_hierarchy": - callSceneScriptWithTimeout("mcp-bridge", "get-hierarchy", args, callback); - break; + case "get_scene_hierarchy": + callSceneScriptWithTimeout("mcp-bridge", "get-hierarchy", args, callback); + break; - case "update_node_transform": - // 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统 - callSceneScriptWithTimeout("mcp-bridge", "update-node-transform", args, (err, result) => { - if (err) { - addLog("error", `Transform update failed: ${err}`); - callback(err); - } else { - callback(null, "变换信息已更新"); - } - }); - break; + case "update_node_transform": + // 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统 + callSceneScriptWithTimeout("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_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 "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 "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 "open_prefab": - isSceneBusy = true; // 锁定 - const prefabUuid = Editor.assetdb.urlToUuid(args.url); - if (prefabUuid) { - // 【核心修复】使用正确的 IPC 消息进入预制体编辑模式 - Editor.Ipc.sendToAll("scene:enter-prefab-edit-mode", prefabUuid); - setTimeout(() => { - isSceneBusy = false; - callback(null, `成功:正在打开预制体 ${args.url}`); - }, 2000); - } else { - isSceneBusy = false; - callback(`找不到路径为 ${args.url} 的资源`); - } - break; + case "open_prefab": + isSceneBusy = true; // 锁定 + const prefabUuid = Editor.assetdb.urlToUuid(args.url); + if (prefabUuid) { + // 【核心修复】使用正确的 IPC 消息进入预制体编辑模式 + Editor.Ipc.sendToAll("scene:enter-prefab-edit-mode", prefabUuid); + 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; - } - callSceneScriptWithTimeout("mcp-bridge", "create-node", args, callback); - 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; + } + callSceneScriptWithTimeout("mcp-bridge", "create-node", args, callback); + break; - case "manage_components": - callSceneScriptWithTimeout("mcp-bridge", "manage-components", args, callback); - break; + case "manage_components": + callSceneScriptWithTimeout("mcp-bridge", "manage-components", args, callback); + break; - case "manage_script": - this.manageScript(args, callback); - break; + case "manage_script": + this.manageScript(args, callback); + break; - case "batch_execute": - this.batchExecute(args, callback); - break; + case "batch_execute": + this.batchExecute(args, callback); + break; - case "manage_asset": - this.manageAsset(args, callback); - break; + case "manage_asset": + this.manageAsset(args, callback); + break; - case "scene_management": - this.sceneManagement(args, callback); - break; + case "scene_management": + this.sceneManagement(args, callback); + break; - case "prefab_management": - this.prefabManagement(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 "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": - callSceneScriptWithTimeout("mcp-bridge", "find-gameobjects", args, callback); - break; + case "find_gameobjects": + callSceneScriptWithTimeout("mcp-bridge", "find-gameobjects", args, callback); + break; - case "manage_material": - this.manageMaterial(args, callback); - break; + case "manage_material": + this.manageMaterial(args, callback); + break; - case "manage_texture": - this.manageTexture(args, callback); - break; + case "manage_texture": + this.manageTexture(args, callback); + break; - case "manage_shader": - this.manageShader(args, callback); - break; + case "manage_shader": + this.manageShader(args, callback); + break; - case "execute_menu_item": - this.executeMenuItem(args, callback); - break; + case "execute_menu_item": + this.executeMenuItem(args, callback); + break; - case "apply_text_edits": - this.applyTextEdits(args, callback); - break; + case "apply_text_edits": + this.applyTextEdits(args, callback); + break; - case "read_console": - this.readConsole(args, callback); - break; + case "read_console": + this.readConsole(args, callback); + break; - case "validate_script": - this.validateScript(args, callback); - break; + case "validate_script": + this.validateScript(args, callback); + break; - case "search_project": - this.searchProject(args, callback); - break; + case "search_project": + this.searchProject(args, callback); + break; - case "manage_undo": - this.manageUndo(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", - ]; + 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; - } - } + 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."); - } + if (!args.defaultSpriteUuid) { + addLog("warn", "[mcp-bridge] Failed to resolve any default sprite UUID."); + } - callSceneScriptWithTimeout("mcp-bridge", "manage-vfx", args, callback); - break; + callSceneScriptWithTimeout("mcp-bridge", "manage-vfx", args, callback); + break; - default: - callback(`Unknown tool: ${name}`); - break; - } - }, + default: + callback(`Unknown tool: ${name}`); + break; + } + }, - /** - * 管理项目中的脚本文件 (TS/JS) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageScript(args, callback) { - const { action, path: scriptPath, content } = args; + /** + * 管理项目中的脚本文件 (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(`脚本已存在: ${scriptPath}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(scriptPath); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - Editor.assetdb.create( - scriptPath, - content || - `const { ccclass, property } = cc._decorator; + switch (action) { + case "create": + if (Editor.assetdb.exists(scriptPath)) { + return callback(`脚本已存在: ${scriptPath}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(scriptPath); + const dirPath = pathModule.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 { @@ -1252,418 +1347,418 @@ export default class NewScript extends cc.Component { update (dt) {} }`, - (err) => { - if (err) { - callback(err); - } else { - // 【关键修复】创建脚本后,必须刷新 AssetDB 并等待完成, - // 否则后续立即挂载脚本的操作(manage_components)会因找不到脚本 UUID 而失败。 - Editor.assetdb.refresh(scriptPath, (refreshErr) => { - if (refreshErr) { - addLog("warn", `脚本创建后刷新失败: ${refreshErr}`); - } - callback(null, `脚本已创建: ${scriptPath}`); - }); - } - }, - ); - break; + (err) => { + if (err) { + callback(err); + } else { + // 【关键修复】创建脚本后,必须刷新 AssetDB 并等待完成, + // 否则后续立即挂载脚本的操作(manage_components)会因找不到脚本 UUID 而失败。 + Editor.assetdb.refresh(scriptPath, (refreshErr) => { + if (refreshErr) { + addLog("warn", `脚本创建后刷新失败: ${refreshErr}`); + } + callback(null, `脚本已创建: ${scriptPath}`); + }); + } + }, + ); + break; - case "delete": - if (!Editor.assetdb.exists(scriptPath)) { - return callback(`找不到脚本: ${scriptPath}`); - } - Editor.assetdb.delete([scriptPath], (err) => { - callback(err, err ? null : `脚本已删除: ${scriptPath}`); - }); - break; + case "delete": + if (!Editor.assetdb.exists(scriptPath)) { + return callback(`找不到脚本: ${scriptPath}`); + } + Editor.assetdb.delete([scriptPath], (err) => { + callback(err, err ? null : `脚本已删除: ${scriptPath}`); + }); + break; - case "read": - // 使用 fs 读取,绕过 assetdb.loadAny - const readFsPath = Editor.assetdb.urlToFspath(scriptPath); - if (!readFsPath || !fs.existsSync(readFsPath)) { - return callback(`找不到脚本: ${scriptPath}`); - } - try { - const content = fs.readFileSync(readFsPath, "utf-8"); - callback(null, content); - } catch (e) { - callback(`读取脚本失败: ${e.message}`); - } - break; + case "read": + // 使用 fs 读取,绕过 assetdb.loadAny + const readFsPath = Editor.assetdb.urlToFspath(scriptPath); + if (!readFsPath || !fs.existsSync(readFsPath)) { + return callback(`找不到脚本: ${scriptPath}`); + } + try { + const content = fs.readFileSync(readFsPath, "utf-8"); + callback(null, content); + } catch (e) { + callback(`读取脚本失败: ${e.message}`); + } + break; - case "write": - // 使用 fs 写入 + refresh,确保覆盖成功 - const writeFsPath = Editor.assetdb.urlToFspath(scriptPath); - if (!writeFsPath) { - return callback(`路径无效: ${scriptPath}`); - } + case "write": + // 使用 fs 写入 + refresh,确保覆盖成功 + const writeFsPath = Editor.assetdb.urlToFspath(scriptPath); + if (!writeFsPath) { + return callback(`路径无效: ${scriptPath}`); + } - try { - fs.writeFileSync(writeFsPath, content, "utf-8"); - Editor.assetdb.refresh(scriptPath, (err) => { - if (err) addLog("warn", `写入脚本后刷新失败: ${err}`); - callback(null, `脚本已更新: ${scriptPath}`); - }); - } catch (e) { - callback(`写入脚本失败: ${e.message}`); - } - break; + try { + fs.writeFileSync(writeFsPath, content, "utf-8"); + Editor.assetdb.refresh(scriptPath, (err) => { + if (err) addLog("warn", `写入脚本后刷新失败: ${err}`); + callback(null, `脚本已更新: ${scriptPath}`); + }); + } catch (e) { + callback(`写入脚本失败: ${e.message}`); + } + break; - default: - callback(`未知的脚本操作类型: ${action}`); - break; - } - }, + default: + callback(`未知的脚本操作类型: ${action}`); + break; + } + }, - /** - * 批量执行多个 MCP 工具操作(串行链式执行) - * 【重要修复】原并行 forEach 会导致多个 AssetDB 操作同时执行引发编辑器卡死, - * 改为串行执行确保每个操作完成后再执行下一个 - * @param {Object} args 参数 (operations 数组) - * @param {Function} callback 完成回调 - */ - batchExecute(args, callback) { - const { operations } = args; - const results = []; + /** + * 批量执行多个 MCP 工具操作(串行链式执行) + * 【重要修复】原并行 forEach 会导致多个 AssetDB 操作同时执行引发编辑器卡死, + * 改为串行执行确保每个操作完成后再执行下一个 + * @param {Object} args 参数 (operations 数组) + * @param {Function} callback 完成回调 + */ + batchExecute(args, callback) { + const { operations } = args; + const results = []; - if (!operations || operations.length === 0) { - return callback("未提供任何操作指令"); - } + if (!operations || operations.length === 0) { + return callback("未提供任何操作指令"); + } - 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) => { - results[index] = { tool: operation.tool, error: err, result: result }; - index++; - next(); - }); - }; - next(); - }, + 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) => { + results[index] = { tool: operation.tool, error: err, result: result }; + index++; + next(); + }); + }; + next(); + }, - /** - * 通用的资源管理函数 (创建、删除、移动等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageAsset(args, callback) { - const { action, path, targetPath, content } = args; + /** + * 通用的资源管理函数 (创建、删除、移动等) + * @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(`资源已存在: ${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 : `资源已创建: ${path}`); - }); - break; + switch (action) { + case "create": + if (Editor.assetdb.exists(path)) { + return callback(`资源已存在: ${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 : `资源已创建: ${path}`); + }); + break; - case "delete": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到资源: ${path}`); - } - Editor.assetdb.delete([path], (err) => { - callback(err, err ? null : `资源已删除: ${path}`); - }); - break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到资源: ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `资源已删除: ${path}`); + }); + break; - case "move": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到资源: ${path}`); - } - if (Editor.assetdb.exists(targetPath)) { - return callback(`目标资源已存在: ${targetPath}`); - } - Editor.assetdb.move(path, targetPath, (err) => { - callback(err, err ? null : `资源已从 ${path} 移动到 ${targetPath}`); - }); - break; + case "move": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到资源: ${path}`); + } + if (Editor.assetdb.exists(targetPath)) { + return callback(`目标资源已存在: ${targetPath}`); + } + Editor.assetdb.move(path, targetPath, (err) => { + callback(err, err ? null : `资源已从 ${path} 移动到 ${targetPath}`); + }); + break; - case "get_info": - try { - if (!Editor.assetdb.exists(path)) { - return callback(`找不到资源: ${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(`获取资源信息失败: ${e.message}`); - } - break; + case "get_info": + try { + if (!Editor.assetdb.exists(path)) { + return callback(`找不到资源: ${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(`获取资源信息失败: ${e.message}`); + } + break; - default: - callback(`未知的资源管理操作: ${action}`); - break; - } - }, + default: + callback(`未知的资源管理操作: ${action}`); + break; + } + }, - /** - * 场景相关的资源管理 (创建、克隆场景等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - sceneManagement(args, callback) { - const { action, path, targetPath, name } = args; + /** + * 场景相关的资源管理 (创建、克隆场景等) + * @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(`场景已存在: ${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 : `场景已创建: ${path}`); - }); - break; + switch (action) { + case "create": + if (Editor.assetdb.exists(path)) { + return callback(`场景已存在: ${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 : `场景已创建: ${path}`); + }); + break; - case "delete": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到场景: ${path}`); - } - Editor.assetdb.delete([path], (err) => { - callback(err, err ? null : `场景已删除: ${path}`); - }); - break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到场景: ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `场景已删除: ${path}`); + }); + break; - case "duplicate": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到场景: ${path}`); - } - if (!targetPath) { - return callback("复制操作需要目标路径"); - } - if (Editor.assetdb.exists(targetPath)) { - return callback(`目标场景已存在: ${targetPath}`); - } - // 【修复】Cocos 2.4.x 主进程中 Editor.assetdb 没有 loadAny - // 直接使用 fs 读取物理文件 - try { - const sourceFsPath = Editor.assetdb.urlToFspath(path); - if (!sourceFsPath || !fs.existsSync(sourceFsPath)) { - return callback(`定位源场景文件失败: ${path}`); - } - const content = fs.readFileSync(sourceFsPath, "utf-8"); + case "duplicate": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到场景: ${path}`); + } + if (!targetPath) { + return callback("复制操作需要目标路径"); + } + if (Editor.assetdb.exists(targetPath)) { + return callback(`目标场景已存在: ${targetPath}`); + } + // 【修复】Cocos 2.4.x 主进程中 Editor.assetdb 没有 loadAny + // 直接使用 fs 读取物理文件 + try { + const sourceFsPath = Editor.assetdb.urlToFspath(path); + if (!sourceFsPath || !fs.existsSync(sourceFsPath)) { + return callback(`定位源场景文件失败: ${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 : `场景已从 ${path} 复制到 ${targetPath}`); - }); - }); - } catch (e) { - callback(`Duplicate failed: ${e.message}`); - } - break; + // 确保目标目录存在 + 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 : `场景已从 ${path} 复制到 ${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 { - return callback(`找不到场景: ${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 { + return callback(`找不到场景: ${path}`); + } + break; - default: - callback(`Unknown scene action: ${action}`); - break; - } - }, + default: + callback(`Unknown scene action: ${action}`); + break; + } + }, - // 预制体管理 - prefabManagement(args, callback) { - const { action, path: prefabPath, nodeId, parentId } = args; + // 预制体管理 + prefabManagement(args, callback) { + const { action, path: prefabPath, nodeId, parentId } = args; - switch (action) { - case "create": - if (!nodeId) { - return callback("创建预制体需要节点 ID"); - } - if (Editor.assetdb.exists(prefabPath)) { - return callback(`预制体已存在: ${prefabPath}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(prefabPath); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - // 【增强】确保 AssetDB 刷新文件夹,防止场景脚本找不到目标目录 - Editor.assetdb.refresh(targetDir); - } + switch (action) { + case "create": + if (!nodeId) { + return callback("创建预制体需要节点 ID"); + } + if (Editor.assetdb.exists(prefabPath)) { + return callback(`预制体已存在: ${prefabPath}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(prefabPath); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + // 【增强】确保 AssetDB 刷新文件夹,防止场景脚本找不到目标目录 + Editor.assetdb.refresh(targetDir); + } - // 解析目标目录和文件名 - const fileName = prefabPath.substring(prefabPath.lastIndexOf("/") + 1); - const prefabName = fileName.replace(".prefab", ""); + // 解析目标目录和文件名 + 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, - }); + // 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:// 目录路径 - // 【增强】增加延迟到 300ms,确保 IPC 消息处理并同步到底层引擎 - setTimeout(() => { - Editor.Ipc.sendToPanel("scene", "scene:create-prefab", [nodeId], targetDir); - }, 300); + // 2. 发送创建命令 (参数: [uuids], dirPath) + // 注意: scene:create-prefab 第三个参数必须是 db:// 目录路径 + // 【增强】增加延迟到 300ms,确保 IPC 消息处理并同步到底层引擎 + setTimeout(() => { + Editor.Ipc.sendToPanel("scene", "scene:create-prefab", [nodeId], targetDir); + }, 300); - callback(null, `指令已发送: 从节点 ${nodeId} 在目录 ${targetDir} 创建名为 ${prefabName} 的预制体`); - break; + callback(null, `指令已发送: 从节点 ${nodeId} 在目录 ${targetDir} 创建名为 ${prefabName} 的预制体`); + break; - case "update": - if (!nodeId) { - return callback("更新预制体需要节点 ID"); - } - if (!Editor.assetdb.exists(prefabPath)) { - return callback(`找不到预制体: ${prefabPath}`); - } - // 更新预制体 - Editor.Ipc.sendToPanel("scene", "scene:update-prefab", nodeId, prefabPath); - callback(null, `指令已发送: 从节点 ${nodeId} 更新预制体 ${prefabPath}`); - break; + case "update": + if (!nodeId) { + return callback("更新预制体需要节点 ID"); + } + if (!Editor.assetdb.exists(prefabPath)) { + return callback(`找不到预制体: ${prefabPath}`); + } + // 更新预制体 + Editor.Ipc.sendToPanel("scene", "scene:update-prefab", nodeId, prefabPath); + callback(null, `指令已发送: 从节点 ${nodeId} 更新预制体 ${prefabPath}`); + break; - case "instantiate": - if (!Editor.assetdb.exists(prefabPath)) { - return callback(`路径为 ${prefabPath} 的预制体不存在`); - } - // 实例化预制体 - const prefabUuid = Editor.assetdb.urlToUuid(prefabPath); - callSceneScriptWithTimeout( - "mcp-bridge", - "instantiate-prefab", - { - prefabUuid: prefabUuid, - parentId: parentId, - }, - callback, - ); - break; + case "instantiate": + if (!Editor.assetdb.exists(prefabPath)) { + return callback(`路径为 ${prefabPath} 的预制体不存在`); + } + // 实例化预制体 + const prefabUuid = Editor.assetdb.urlToUuid(prefabPath); + callSceneScriptWithTimeout( + "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 { - return callback(`找不到预制体: ${prefabPath}`); - } - 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 { + return callback(`找不到预制体: ${prefabPath}`); + } + break; - default: - callback(`未知的预制体管理操作: ${action}`); - } - }, + default: + callback(`未知的预制体管理操作: ${action}`); + } + }, - /** - * 管理编辑器状态 (选中对象、刷新等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageEditor(args, callback) { - const { action, target, properties } = args; + /** + * 管理编辑器状态 (选中对象、刷新等) + * @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, "选中状态已更新"); - break; - case "refresh_editor": - // 刷新编辑器资源数据库 - // 支持指定路径以避免大型项目全量刷新耗时过长 - // 示例: properties.path = 'db://assets/scripts/MyScript.ts' (刷新单个文件) - // properties.path = 'db://assets/resources' (刷新某个目录) - // 不传 (默认 'db://assets',全量刷新) - const refreshPath = properties && properties.path ? properties.path : "db://assets"; - addLog("info", `[refresh_editor] 开始刷新: ${refreshPath}`); - Editor.assetdb.refresh(refreshPath, (err) => { - if (err) { - addLog("error", `刷新失败: ${err}`); - callback(err); - } else { - callback(null, `编辑器已刷新: ${refreshPath}`); - } - }); - break; - default: - callback("未知的编辑器管理操作"); - break; - } - }, + 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, "选中状态已更新"); + break; + case "refresh_editor": + // 刷新编辑器资源数据库 + // 支持指定路径以避免大型项目全量刷新耗时过长 + // 示例: properties.path = 'db://assets/scripts/MyScript.ts' (刷新单个文件) + // properties.path = 'db://assets/resources' (刷新某个目录) + // 不传 (默认 'db://assets',全量刷新) + const refreshPath = properties && properties.path ? properties.path : "db://assets"; + addLog("info", `[refresh_editor] 开始刷新: ${refreshPath}`); + Editor.assetdb.refresh(refreshPath, (err) => { + if (err) { + addLog("error", `刷新失败: ${err}`); + callback(err); + } else { + callback(null, `编辑器已刷新: ${refreshPath}`); + } + }); + break; + default: + callback("未知的编辑器管理操作"); + break; + } + }, - // 管理着色器 (Effect) - manageShader(args, callback) { - const { action, path: effectPath, content } = args; + // 管理着色器 (Effect) + manageShader(args, callback) { + const { action, path: effectPath, content } = args; - switch (action) { - case "create": - if (Editor.assetdb.exists(effectPath)) { - return callback(`Effect 已存在: ${effectPath}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(effectPath); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } + switch (action) { + case "create": + if (Editor.assetdb.exists(effectPath)) { + return callback(`Effect 已存在: ${effectPath}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(effectPath); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } - const defaultEffect = `CCEffect %{ + const defaultEffect = `CCEffect %{ techniques: - passes: - vert: vs @@ -1702,1022 +1797,1021 @@ CCProgram fs %{ } }%`; - Editor.assetdb.create(effectPath, content || defaultEffect, (err) => { - if (err) return callback(err); - Editor.assetdb.refresh(effectPath, (refreshErr) => { - callback(refreshErr, refreshErr ? null : `Effect 已创建: ${effectPath}`); - }); - }); - break; - - case "read": - if (!Editor.assetdb.exists(effectPath)) { - return callback(`找不到 Effect: ${effectPath}`); - } - const fspath = Editor.assetdb.urlToFspath(effectPath); - try { - const data = fs.readFileSync(fspath, "utf-8"); - callback(null, data); - } catch (e) { - callback(`读取 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 已更新: ${effectPath}`); - }); - } catch (e) { - callback(`更新 Effect 失败: ${e.message}`); - } - break; - - case "delete": - if (!Editor.assetdb.exists(effectPath)) { - return callback(`找不到 Effect: ${effectPath}`); - } - Editor.assetdb.delete([effectPath], (err) => { - callback(err, err ? null : `Effect 已删除: ${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: ${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(`材质已存在: ${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 : `材质已创建: ${matPath}`); - }); - }); - break; - - case "update": - if (!Editor.assetdb.exists(matPath)) { - return callback(`找不到材质: ${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 : `材质已更新: ${matPath}`); - }); - } catch (e) { - callback(`更新材质失败: ${e.message}`); - } - break; - - case "delete": - if (!Editor.assetdb.exists(matPath)) { - return callback(`找不到材质: ${matPath}`); - } - Editor.assetdb.delete([matPath], (err) => { - callback(err, err ? null : `材质已删除: ${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(`找不到材质: ${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(`纹理已存在: ${path}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(path); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - // 1. 准备文件内容 (优先使用 properties.content,否则使用默认 1x1) - let base64Data = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - if (properties && properties.content) { - base64Data = properties.content; - } - const buffer = Buffer.from(base64Data, "base64"); - - try { - // 2. 写入物理文件 - fs.writeFileSync(absolutePath, buffer); - - // 3. 刷新该资源以生成 Meta - Editor.assetdb.refresh(path, (err, results) => { - if (err) return callback(err); - - // 4. 如果有 9-slice 设置,更新 Meta - if (properties && (properties.border || properties.type)) { - const uuid = Editor.assetdb.urlToUuid(path); - if (!uuid) return callback(null, `纹理已创建,但未能立即获取 UUID。`); - - // 稍微延迟确保 Meta 已生成 - setTimeout(() => { - const meta = Editor.assetdb.loadMeta(uuid); - if (meta) { - let changed = false; - if (properties.type) { - meta.type = properties.type; - changed = true; - } - - // 设置 9-slice (border) - // 注意:Cocos 2.4 纹理 Meta 中 subMetas 下通常有一个与纹理同名的 key (或者主要的一个 key) - if (properties.border) { - // 确保类型是 sprite - meta.type = "sprite"; - - // 找到 SpriteFrame 的 subMeta - const subKeys = Object.keys(meta.subMetas); - if (subKeys.length > 0) { - const subMeta = meta.subMetas[subKeys[0]]; - subMeta.border = properties.border; // [top, bottom, left, right] - changed = true; - } - } - - if (changed) { - Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { - if (err) Editor.warn(`保存资源 Meta 失败 ${path}: ${err}`); - callback(null, `纹理已创建并更新 Meta: ${path}`); - }); - return; - } - } - callback(null, `纹理已创建: ${path}`); - }, 100); - } else { - callback(null, `纹理已创建: ${path}`); - } - }); - } catch (e) { - callback(`写入纹理文件失败: ${e.message}`); - } - break; - case "delete": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到纹理: ${path}`); - } - Editor.assetdb.delete([path], (err) => { - callback(err, err ? null : `纹理已删除: ${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(`找不到纹理: ${path}`); - } - break; - case "update": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到纹理: ${path}`); - } - const uuid = Editor.assetdb.urlToUuid(path); - let meta = Editor.assetdb.loadMeta(uuid); - - // Fallback: 如果 Editor.assetdb.loadMeta 失败 (API 偶尔不稳定),尝试直接读取文件系统中的 .meta 文件 - if (!meta) { - try { - const fspath = Editor.assetdb.urlToFspath(path); - const metaPath = fspath + ".meta"; - if (fs.existsSync(metaPath)) { - const metaContent = fs.readFileSync(metaPath, "utf-8"); - meta = JSON.parse(metaContent); - addLog("info", `[manage_texture] Loaded meta from fs fallback: ${metaPath}`); - } - } catch (e) { - addLog("warn", `[manage_texture] Meta fs fallback failed: ${e.message}`); - } - } - - if (!meta) { - return callback(`加载资源 Meta 失败: ${path}`); - } - - let changed = false; - if (properties) { - // 更新类型 - if (properties.type) { - if (meta.type !== properties.type) { - meta.type = properties.type; - changed = true; - } - } - - // 更新 9-slice border - if (properties.border) { - // 确保类型是 sprite - if (meta.type !== "sprite") { - meta.type = "sprite"; - changed = true; - } - - // 找到 SubMeta - // Cocos Meta 结构: { subMetas: { "textureName": { ... } } } - // 注意:Cocos 2.x 的 meta 结构因版本而异,旧版可能使用 border: [t, b, l, r] 数组, - // 而新版 (如 2.3.x+) 通常使用 borderTop, borderBottom 等独立字段。 - // 此处逻辑实现了兼容性处理。 - const subKeys = Object.keys(meta.subMetas); - if (subKeys.length > 0) { - const subMeta = meta.subMetas[subKeys[0]]; - const newBorder = properties.border; // [top, bottom, left, right] - - // 方式 1: standard array style - if (subMeta.border !== undefined) { - const oldBorder = subMeta.border; - if ( - !oldBorder || - oldBorder[0] !== newBorder[0] || - oldBorder[1] !== newBorder[1] || - oldBorder[2] !== newBorder[2] || - oldBorder[3] !== newBorder[3] - ) { - subMeta.border = newBorder; - changed = true; - } - } - // 方式 2: individual fields style (common in 2.3.x) - else if (subMeta.borderTop !== undefined) { - // top, bottom, left, right - if ( - subMeta.borderTop !== newBorder[0] || - subMeta.borderBottom !== newBorder[1] || - subMeta.borderLeft !== newBorder[2] || - subMeta.borderRight !== newBorder[3] - ) { - subMeta.borderTop = newBorder[0]; - subMeta.borderBottom = newBorder[1]; - subMeta.borderLeft = newBorder[2]; - subMeta.borderRight = newBorder[3]; - changed = true; - } - } - // 方式 3: 如果都没有,尝试写入 individual fields - else { - subMeta.borderTop = newBorder[0]; - subMeta.borderBottom = newBorder[1]; - subMeta.borderLeft = newBorder[2]; - subMeta.borderRight = newBorder[3]; - changed = true; - } - } - } - } - - if (changed) { - // 使用 saveMeta 或者 fs 写入 - // 为了安全,如果 loadMeta 失败了,safeMeta 可能也会失败,所以这里尽量用 API,不行再 fallback (暂且只用 API) - Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { - if (err) return callback(`保存 Meta 失败: ${err}`); - callback(null, `纹理已更新: ${path}`); - }); - } else { - callback(null, `资源不需要更新: ${path}`); - } - break; - default: - callback(`未知的纹理操作类型: ${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(`找不到文件或 URL 无效: ${filePath}`); - } - - const fs = require("fs"); - if (!fs.existsSync(fspath)) { - return callback(`文件不存在: ${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", `刷新失败 ${filePath}: ${err}`); - callback(null, `文本编辑已应用: ${filePath}`); - }); - } catch (err) { - callback(`操作失败: ${err.message}`); - } - }, - - // 读取控制台 - readConsole(args, callback) { - const { limit, type } = args; - let filteredOutput = logBuffer; - - if (type) { - // [优化] 支持别名映射 - const targetType = type === "log" ? "info" : type; - filteredOutput = filteredOutput.filter((item) => item.type === targetType); - } - - if (limit) { - filteredOutput = filteredOutput.slice(-limit); - } - - callback(null, filteredOutput); - }, - - /** - * 执行编辑器菜单项 - * @param {Object} args 参数 (menuPath) - * @param {Function} callback 完成回调 - */ - executeMenuItem(args, callback) { - const { menuPath } = args; - if (!menuPath) { - return callback("菜单路径是必填项"); - } - addLog("info", `执行菜单项: ${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", - }; - - // 特殊处理 delete-node:UUID 格式 - if (menuPath.startsWith("delete-node:")) { - const uuid = menuPath.split(":")[1]; - if (uuid) { - callSceneScriptWithTimeout("mcp-bridge", "delete-node", { uuid }, (err, result) => { - if (err) callback(err); - else callback(null, result || `节点 ${uuid} 已通过场景脚本删除`); - }); - 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, `菜单动作已触发: ${menuPath} -> ${ipcMsg} (影响 ${selection.length} 个节点)`); - } else { - callback("没有选中任何节点进行删除"); - } - } else { - Editor.Ipc.sendToMain(ipcMsg); - callback(null, `菜单动作已触发: ${menuPath} -> ${ipcMsg}`); - } - } catch (err) { - callback(`执行 IPC ${ipcMsg} 失败: ${err.message}`); - } - } else { - // 对于未在映射表中的菜单,尝试通用的 menu:click (虽然不一定有效) - // 或者直接返回不支持的警告 - addLog("warn", `支持映射表中找不到菜单项 '${menuPath}'。尝试通过旧版模式执行。`); - - // 尝试通用调用 - try { - // 注意:Cocos Creator 2.x 的 menu:click 通常需要 Electron 菜单 ID,而不只是路径 - // 这里做个尽力而为的尝试 - Editor.Ipc.sendToMain("menu:click", menuPath); - callback(null, `通用菜单动作已发送: ${menuPath} (仅支持项保证成功)`); - } catch (e) { - callback(`执行菜单项失败: ${menuPath}`); - } - } - }, - - /** - * 验证脚本文件的语法或基础结构 - * @param {Object} args 参数 (filePath) - * @param {Function} callback 完成回调 - */ - validateScript(args, callback) { - const { filePath } = args; - - // 1. 获取文件系统路径 - const fspath = Editor.assetdb.urlToFspath(filePath); - if (!fspath) { - return callback(`找不到文件或 URL 无效: ${filePath}`); - } - - // 2. 检查文件是否存在 - if (!fs.existsSync(fspath)) { - return callback(`文件不存在: ${fspath}`); - } - - // 3. 读取内容并验证 - try { - const content = fs.readFileSync(fspath, "utf-8"); - - // 检查空文件 - if (!content || content.trim().length === 0) { - return callback(null, { valid: false, message: "脚本内容为空" }); - } - - // 对于 JavaScript 脚本,使用 Function 构造器进行语法验证 - if (filePath.endsWith(".js")) { - const wrapper = `(function() { ${content} })`; - try { - new Function(wrapper); - callback(null, { valid: true, message: "JavaScript 语法验证通过" }); - } 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: - "警告: TypeScript 文件似乎缺少标准定义 (class/interface/export),但由于缺少编译器,已跳过基础语法检查。", - }); - } - - callback(null, { - valid: true, - message: "TypeScript 基础检查通过。(完整编译验证需要通过编辑器构建流程)", - }); - } else { - callback(null, { valid: true, message: "未知的脚本类型,跳过验证。" }); - } - } catch (err) { - callback(null, { valid: false, message: `读取错误: ${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.getProfile().set("last-port", port); - this.getProfile().save(); - this.startServer(port); - } - }, - "clear-logs"() { - logBuffer = []; - addLog("info", "日志已清理"); - }, - - // 修改场景中的节点(需要通过 scene-script) - "set-node-property"(event, args) { - addLog("mcp", `设置节点属性: ${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", `创建节点: ${args.name} (${args.type})`); - Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, (err, result) => { - if (err) addLog("error", `创建节点失败: ${err}`); - else addLog("success", `节点已创建: ${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", `自动启动已设置为: ${value}`); - }, - - "inspect-apis"() { - addLog("info", "[API 检查器] 开始深度分析..."); - - // 获取函数参数的辅助函数 - 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 检查器] 标准对象:\n${JSON.stringify(report, null, 2)}`); - addLog("info", `[API 检查器] 论坛核查清单:\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 检查器] 内置包 IPC 消息:\n${JSON.stringify(ipcReport, null, 2)}`); - }, - }, - - /** - * 全局项目文件搜索 (支持正则表达式、文件名、目录名搜索) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - searchProject(args, callback) { - const { query, useRegex, path: searchPath, matchType, extensions } = args; - - // 默认值 - const rootPathUrl = searchPath || "db://assets"; - const rootPath = Editor.assetdb.urlToFspath(rootPathUrl); - - if (!rootPath || !fs.existsSync(rootPath)) { - return callback(`无效的搜索路径: ${rootPathUrl}`); - } - - const mode = matchType || "content"; // content, file_name, dir_name - const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"]; - const results = []; - const MAX_RESULTS = 500; - - let regex = null; - if (useRegex) { - try { - regex = new RegExp(query); - } catch (e) { - return callback(`Invalid regex: ${e.message}`); - } - } - - const checkMatch = (text) => { - if (useRegex) return regex.test(text); - return text.includes(query); - }; - - try { - const walk = (dir) => { - if (results.length >= MAX_RESULTS) return; - - const list = fs.readdirSync(dir); - list.forEach((file) => { - if (results.length >= MAX_RESULTS) return; - - // 忽略隐藏文件和常用忽略目录 - if ( - file.startsWith(".") || - file === "node_modules" || - file === "bin" || - file === "local" || - file === "library" || - file === "temp" - ) - return; - - const filePath = pathModule.join(dir, file); - const stat = fs.statSync(filePath); - - if (stat && stat.isDirectory()) { - // 目录名搜索 - if (mode === "dir_name") { - if (checkMatch(file)) { - const relativePath = pathModule.relative( - Editor.assetdb.urlToFspath("db://assets"), - filePath, - ); - const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); - results.push({ - filePath: dbPath, - type: "directory", - name: file, - }); - } - } - // 递归 - walk(filePath); - } else { - const ext = pathModule.extname(file).toLowerCase(); - - // 文件名搜索 - if (mode === "file_name") { - if (validExtensions && validExtensions.length > 0 && !validExtensions.includes(ext)) { - // 如果指定了后缀,则必须匹配 - // (Logic kept simple: if extensions provided, filter by them. If not provided, search all files or default list?) - // Let's stick to validExtensions for file_name search too to avoid noise, or maybe allow all if extensions is explicitly null? - // Schema default is null. Let's start with checkMatch(file) directly if no extensions provided. - // Actually validExtensions has a default list. Let's respect it if it was default, but for file_name maybe we want all? - // Let's use validExtensions only if mode is content. For file_name, usually we search everything unless filtered. - // But to be safe and consistent with previous find_in_file, let's respect validExtensions. - } - - // 简化逻辑:对文件名搜索,也检查后缀(如果用户未传则用默认列表) - if (validExtensions.includes(ext)) { - if (checkMatch(file)) { - const relativePath = pathModule.relative( - Editor.assetdb.urlToFspath("db://assets"), - filePath, - ); - const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); - results.push({ - filePath: dbPath, - type: "file", - name: file, - }); - } - } - // 如果需要搜索非文本文件(如 .png),可以传入 extensions=['.png'] - } - - // 内容搜索 - else if (mode === "content") { - 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 (checkMatch(line)) { - const relativePath = pathModule.relative( - Editor.assetdb.urlToFspath("db://assets"), - filePath, - ); - const dbPath = - "db://assets/" + relativePath.split(pathModule.sep).join("/"); - results.push({ - filePath: dbPath, - line: index + 1, - content: line.trim(), - }); - } - }); - } catch (e) { - // Skip read error - } - } - } - } - }); - }; - - walk(rootPath); - callback(null, results); - } catch (err) { - callback(`项目搜索失败: ${err.message}`); - } - }, - - /** - * 管理撤销/重做操作及事务分组 - * @param {Object} args 参数 (action, description, id) - * @param {Function} callback 完成回调 - */ - manageUndo(args, callback) { - const { action, description } = args; - - try { - switch (action) { - case "undo": - Editor.Ipc.sendToPanel("scene", "scene:undo"); - callback(null, "撤销指令已执行"); - break; - case "redo": - Editor.Ipc.sendToPanel("scene", "scene:redo"); - callback(null, "重做指令已执行"); - break; - case "begin_group": - addLog("info", `开始撤销组: ${description || "MCP 动作"}`); - // 如果有参数包含 id,则记录该节点 - if (args.id) { - Editor.Ipc.sendToPanel("scene", "scene:undo-record", args.id); - } - callback(null, `撤销组已启动: ${description || "MCP 动作"}`); - break; - case "end_group": - Editor.Ipc.sendToPanel("scene", "scene:undo-commit"); - callback(null, "撤销组已提交"); - break; - case "cancel_group": - Editor.Ipc.sendToPanel("scene", "scene:undo-cancel"); - callback(null, "撤销组已取消"); - break; - default: - callback(`未知的撤销操作: ${action}`); - } - } catch (err) { - callback(`撤销操作失败: ${err.message}`); - } - }, - - /** - * 计算资源的 SHA-256 哈希值 - * @param {Object} args 参数 (path) - * @param {Function} callback 完成回调 - */ - getSha(args, callback) { - const { path: url } = args; - const fspath = Editor.assetdb.urlToFspath(url); - - if (!fspath || !fs.existsSync(fspath)) { - return callback(`找不到文件: ${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(`计算 SHA 失败: ${err.message}`); - } - }, - - /** - * 管理节点动画 (播放、停止、获取信息等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageAnimation(args, callback) { - // 转发给场景脚本处理 - callSceneScriptWithTimeout("mcp-bridge", "manage-animation", args, callback); - }, + Editor.assetdb.create(effectPath, content || defaultEffect, (err) => { + if (err) return callback(err); + Editor.assetdb.refresh(effectPath, (refreshErr) => { + callback(refreshErr, refreshErr ? null : `Effect 已创建: ${effectPath}`); + }); + }); + break; + + case "read": + if (!Editor.assetdb.exists(effectPath)) { + return callback(`找不到 Effect: ${effectPath}`); + } + const fspath = Editor.assetdb.urlToFspath(effectPath); + try { + const data = fs.readFileSync(fspath, "utf-8"); + callback(null, data); + } catch (e) { + callback(`读取 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 已更新: ${effectPath}`); + }); + } catch (e) { + callback(`更新 Effect 失败: ${e.message}`); + } + break; + + case "delete": + if (!Editor.assetdb.exists(effectPath)) { + return callback(`找不到 Effect: ${effectPath}`); + } + Editor.assetdb.delete([effectPath], (err) => { + callback(err, err ? null : `Effect 已删除: ${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: ${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(`材质已存在: ${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 : `材质已创建: ${matPath}`); + }); + }); + break; + + case "update": + if (!Editor.assetdb.exists(matPath)) { + return callback(`找不到材质: ${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 : `材质已更新: ${matPath}`); + }); + } catch (e) { + callback(`更新材质失败: ${e.message}`); + } + break; + + case "delete": + if (!Editor.assetdb.exists(matPath)) { + return callback(`找不到材质: ${matPath}`); + } + Editor.assetdb.delete([matPath], (err) => { + callback(err, err ? null : `材质已删除: ${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(`找不到材质: ${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(`纹理已存在: ${path}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(path); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + // 1. 准备文件内容 (优先使用 properties.content,否则使用默认 1x1) + let base64Data = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + if (properties && properties.content) { + base64Data = properties.content; + } + const buffer = Buffer.from(base64Data, "base64"); + + try { + // 2. 写入物理文件 + fs.writeFileSync(absolutePath, buffer); + + // 3. 刷新该资源以生成 Meta + Editor.assetdb.refresh(path, (err, results) => { + if (err) return callback(err); + + // 4. 如果有 9-slice 设置,更新 Meta + if (properties && (properties.border || properties.type)) { + const uuid = Editor.assetdb.urlToUuid(path); + if (!uuid) return callback(null, `纹理已创建,但未能立即获取 UUID。`); + + // 稍微延迟确保 Meta 已生成 + setTimeout(() => { + const meta = Editor.assetdb.loadMeta(uuid); + if (meta) { + let changed = false; + if (properties.type) { + meta.type = properties.type; + changed = true; + } + + // 设置 9-slice (border) + // 注意:Cocos 2.4 纹理 Meta 中 subMetas 下通常有一个与纹理同名的 key (或者主要的一个 key) + if (properties.border) { + // 确保类型是 sprite + meta.type = "sprite"; + + // 找到 SpriteFrame 的 subMeta + const subKeys = Object.keys(meta.subMetas); + if (subKeys.length > 0) { + const subMeta = meta.subMetas[subKeys[0]]; + subMeta.border = properties.border; // [top, bottom, left, right] + changed = true; + } + } + + if (changed) { + Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { + if (err) Editor.warn(`保存资源 Meta 失败 ${path}: ${err}`); + callback(null, `纹理已创建并更新 Meta: ${path}`); + }); + return; + } + } + callback(null, `纹理已创建: ${path}`); + }, 100); + } else { + callback(null, `纹理已创建: ${path}`); + } + }); + } catch (e) { + callback(`写入纹理文件失败: ${e.message}`); + } + break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到纹理: ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `纹理已删除: ${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(`找不到纹理: ${path}`); + } + break; + case "update": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到纹理: ${path}`); + } + const uuid = Editor.assetdb.urlToUuid(path); + let meta = Editor.assetdb.loadMeta(uuid); + + // Fallback: 如果 Editor.assetdb.loadMeta 失败 (API 偶尔不稳定),尝试直接读取文件系统中的 .meta 文件 + if (!meta) { + try { + const fspath = Editor.assetdb.urlToFspath(path); + const metaPath = fspath + ".meta"; + if (fs.existsSync(metaPath)) { + const metaContent = fs.readFileSync(metaPath, "utf-8"); + meta = JSON.parse(metaContent); + addLog("info", `[manage_texture] Loaded meta from fs fallback: ${metaPath}`); + } + } catch (e) { + addLog("warn", `[manage_texture] Meta fs fallback failed: ${e.message}`); + } + } + + if (!meta) { + return callback(`加载资源 Meta 失败: ${path}`); + } + + let changed = false; + if (properties) { + // 更新类型 + if (properties.type) { + if (meta.type !== properties.type) { + meta.type = properties.type; + changed = true; + } + } + + // 更新 9-slice border + if (properties.border) { + // 确保类型是 sprite + if (meta.type !== "sprite") { + meta.type = "sprite"; + changed = true; + } + + // 找到 SubMeta + // Cocos Meta 结构: { subMetas: { "textureName": { ... } } } + // 注意:Cocos 2.x 的 meta 结构因版本而异,旧版可能使用 border: [t, b, l, r] 数组, + // 而新版 (如 2.3.x+) 通常使用 borderTop, borderBottom 等独立字段。 + // 此处逻辑实现了兼容性处理。 + const subKeys = Object.keys(meta.subMetas); + if (subKeys.length > 0) { + const subMeta = meta.subMetas[subKeys[0]]; + const newBorder = properties.border; // [top, bottom, left, right] + + // 方式 1: standard array style + if (subMeta.border !== undefined) { + const oldBorder = subMeta.border; + if ( + !oldBorder || + oldBorder[0] !== newBorder[0] || + oldBorder[1] !== newBorder[1] || + oldBorder[2] !== newBorder[2] || + oldBorder[3] !== newBorder[3] + ) { + subMeta.border = newBorder; + changed = true; + } + } + // 方式 2: individual fields style (common in 2.3.x) + else if (subMeta.borderTop !== undefined) { + // top, bottom, left, right + if ( + subMeta.borderTop !== newBorder[0] || + subMeta.borderBottom !== newBorder[1] || + subMeta.borderLeft !== newBorder[2] || + subMeta.borderRight !== newBorder[3] + ) { + subMeta.borderTop = newBorder[0]; + subMeta.borderBottom = newBorder[1]; + subMeta.borderLeft = newBorder[2]; + subMeta.borderRight = newBorder[3]; + changed = true; + } + } + // 方式 3: 如果都没有,尝试写入 individual fields + else { + subMeta.borderTop = newBorder[0]; + subMeta.borderBottom = newBorder[1]; + subMeta.borderLeft = newBorder[2]; + subMeta.borderRight = newBorder[3]; + changed = true; + } + } + } + } + + if (changed) { + // 使用 saveMeta 或者 fs 写入 + // 为了安全,如果 loadMeta 失败了,safeMeta 可能也会失败,所以这里尽量用 API,不行再 fallback (暂且只用 API) + Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { + if (err) return callback(`保存 Meta 失败: ${err}`); + callback(null, `纹理已更新: ${path}`); + }); + } else { + callback(null, `资源不需要更新: ${path}`); + } + break; + default: + callback(`未知的纹理操作类型: ${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(`找不到文件或 URL 无效: ${filePath}`); + } + + const fs = require("fs"); + if (!fs.existsSync(fspath)) { + return callback(`文件不存在: ${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", `刷新失败 ${filePath}: ${err}`); + callback(null, `文本编辑已应用: ${filePath}`); + }); + } catch (err) { + callback(`操作失败: ${err.message}`); + } + }, + + // 读取控制台 + readConsole(args, callback) { + const { limit, type } = args; + let filteredOutput = logBuffer; + + if (type) { + // [优化] 支持别名映射 + const targetType = type === "log" ? "info" : type; + filteredOutput = filteredOutput.filter((item) => item.type === targetType); + } + + if (limit) { + filteredOutput = filteredOutput.slice(-limit); + } + + callback(null, filteredOutput); + }, + + /** + * 执行编辑器菜单项 + * @param {Object} args 参数 (menuPath) + * @param {Function} callback 完成回调 + */ + executeMenuItem(args, callback) { + const { menuPath } = args; + if (!menuPath) { + return callback("菜单路径是必填项"); + } + addLog("info", `执行菜单项: ${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", + }; + + // 特殊处理 delete-node:UUID 格式 + if (menuPath.startsWith("delete-node:")) { + const uuid = menuPath.split(":")[1]; + if (uuid) { + callSceneScriptWithTimeout("mcp-bridge", "delete-node", { uuid }, (err, result) => { + if (err) callback(err); + else callback(null, result || `节点 ${uuid} 已通过场景脚本删除`); + }); + 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, `菜单动作已触发: ${menuPath} -> ${ipcMsg} (影响 ${selection.length} 个节点)`); + } else { + callback("没有选中任何节点进行删除"); + } + } else { + Editor.Ipc.sendToMain(ipcMsg); + callback(null, `菜单动作已触发: ${menuPath} -> ${ipcMsg}`); + } + } catch (err) { + callback(`执行 IPC ${ipcMsg} 失败: ${err.message}`); + } + } else { + // 对于未在映射表中的菜单,尝试通用的 menu:click (虽然不一定有效) + // 或者直接返回不支持的警告 + addLog("warn", `支持映射表中找不到菜单项 '${menuPath}'。尝试通过旧版模式执行。`); + + // 尝试通用调用 + try { + // 注意:Cocos Creator 2.x 的 menu:click 通常需要 Electron 菜单 ID,而不只是路径 + // 这里做个尽力而为的尝试 + Editor.Ipc.sendToMain("menu:click", menuPath); + callback(null, `通用菜单动作已发送: ${menuPath} (仅支持项保证成功)`); + } catch (e) { + callback(`执行菜单项失败: ${menuPath}`); + } + } + }, + + /** + * 验证脚本文件的语法或基础结构 + * @param {Object} args 参数 (filePath) + * @param {Function} callback 完成回调 + */ + validateScript(args, callback) { + const { filePath } = args; + + // 1. 获取文件系统路径 + const fspath = Editor.assetdb.urlToFspath(filePath); + if (!fspath) { + return callback(`找不到文件或 URL 无效: ${filePath}`); + } + + // 2. 检查文件是否存在 + if (!fs.existsSync(fspath)) { + return callback(`文件不存在: ${fspath}`); + } + + // 3. 读取内容并验证 + try { + const content = fs.readFileSync(fspath, "utf-8"); + + // 检查空文件 + if (!content || content.trim().length === 0) { + return callback(null, { valid: false, message: "脚本内容为空" }); + } + + // 对于 JavaScript 脚本,使用 Function 构造器进行语法验证 + if (filePath.endsWith(".js")) { + const wrapper = `(function() { ${content} })`; + try { + new Function(wrapper); + callback(null, { valid: true, message: "JavaScript 语法验证通过" }); + } 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: + "警告: TypeScript 文件似乎缺少标准定义 (class/interface/export),但由于缺少编译器,已跳过基础语法检查。", + }); + } + + callback(null, { + valid: true, + message: "TypeScript 基础检查通过。(完整编译验证需要通过编辑器构建流程)", + }); + } else { + callback(null, { valid: true, message: "未知的脚本类型,跳过验证。" }); + } + } catch (err) { + callback(null, { valid: false, message: `读取错误: ${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.getProfile().set("last-port", port); + this.getProfile().save(); + this.startServer(port); + } + }, + "clear-logs"() { + logBuffer = []; + addLog("info", "日志已清理"); + }, + + // 修改场景中的节点(需要通过 scene-script) + "set-node-property"(event, args) { + addLog("mcp", `设置节点属性: ${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", `创建节点: ${args.name} (${args.type})`); + Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, (err, result) => { + if (err) addLog("error", `创建节点失败: ${err}`); + else addLog("success", `节点已创建: ${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", `自动启动已设置为: ${value}`); + }, + + "inspect-apis"() { + addLog("info", "[API 检查器] 开始深度分析..."); + + // 获取函数参数的辅助函数 + 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 检查器] 标准对象:\n${JSON.stringify(report, null, 2)}`); + addLog("info", `[API 检查器] 论坛核查清单:\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 检查器] 内置包 IPC 消息:\n${JSON.stringify(ipcReport, null, 2)}`); + }, + }, + + /** + * 全局项目文件搜索 (支持正则表达式、文件名、目录名搜索) + * @param {Object} args 参数 + * @param {Function} callback 完成回调 + */ + searchProject(args, callback) { + const { query, useRegex, path: searchPath, matchType, extensions } = args; + + // 默认值 + const rootPathUrl = searchPath || "db://assets"; + const rootPath = Editor.assetdb.urlToFspath(rootPathUrl); + + if (!rootPath || !fs.existsSync(rootPath)) { + return callback(`无效的搜索路径: ${rootPathUrl}`); + } + + // 缓存 assets 根路径,避免在每次匹配时重复调用 urlToFspath + const assetsRoot = Editor.assetdb.urlToFspath("db://assets"); + const mode = matchType || "content"; // content, file_name, dir_name + const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"]; + const results = []; + const MAX_RESULTS = 500; + + let regex = null; + if (useRegex) { + try { + regex = new RegExp(query); + } catch (e) { + return callback(`Invalid regex: ${e.message}`); + } + } + + const checkMatch = (text) => { + if (useRegex) return regex.test(text); + return text.includes(query); + }; + + try { + const walk = (dir) => { + if (results.length >= MAX_RESULTS) return; + + const list = fs.readdirSync(dir); + list.forEach((file) => { + if (results.length >= MAX_RESULTS) return; + + // 忽略隐藏文件和常用忽略目录 + if ( + file.startsWith(".") || + file === "node_modules" || + file === "bin" || + file === "local" || + file === "library" || + file === "temp" + ) + return; + + const filePath = pathModule.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat && stat.isDirectory()) { + // 目录名搜索 + if (mode === "dir_name") { + if (checkMatch(file)) { + const relativePath = pathModule.relative( + Editor.assetdb.urlToFspath("db://assets"), + filePath, + ); + const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); + results.push({ + filePath: dbPath, + type: "directory", + name: file, + }); + } + } + // 递归 + walk(filePath); + } else { + const ext = pathModule.extname(file).toLowerCase(); + + // 文件名搜索 + if (mode === "file_name") { + if (validExtensions && validExtensions.length > 0 && !validExtensions.includes(ext)) { + // 如果指定了后缀,则必须匹配 + // (Logic kept simple: if extensions provided, filter by them. If not provided, search all files or default list?) + // Let's stick to validExtensions for file_name search too to avoid noise, or maybe allow all if extensions is explicitly null? + // Schema default is null. Let's start with checkMatch(file) directly if no extensions provided. + // Actually validExtensions has a default list. Let's respect it if it was default, but for file_name maybe we want all? + // Let's use validExtensions only if mode is content. For file_name, usually we search everything unless filtered. + // But to be safe and consistent with previous find_in_file, let's respect validExtensions. + } + + // 简化逻辑:对文件名搜索,也检查后缀(如果用户未传则用默认列表) + if (validExtensions.includes(ext)) { + if (checkMatch(file)) { + const relativePath = pathModule.relative( + Editor.assetdb.urlToFspath("db://assets"), + filePath, + ); + const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); + results.push({ + filePath: dbPath, + type: "file", + name: file, + }); + } + } + // 如果需要搜索非文本文件(如 .png),可以传入 extensions=['.png'] + } + + // 内容搜索 + else if (mode === "content") { + 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 (checkMatch(line)) { + const relativePath = pathModule.relative(assetsRoot, filePath); + const dbPath = + "db://assets/" + relativePath.split(pathModule.sep).join("/"); + results.push({ + filePath: dbPath, + line: index + 1, + content: line.trim(), + }); + } + }); + } catch (e) { + // Skip read error + } + } + } + } + }); + }; + + walk(rootPath); + callback(null, results); + } catch (err) { + callback(`项目搜索失败: ${err.message}`); + } + }, + + /** + * 管理撤销/重做操作及事务分组 + * @param {Object} args 参数 (action, description, id) + * @param {Function} callback 完成回调 + */ + manageUndo(args, callback) { + const { action, description } = args; + + try { + switch (action) { + case "undo": + Editor.Ipc.sendToPanel("scene", "scene:undo"); + callback(null, "撤销指令已执行"); + break; + case "redo": + Editor.Ipc.sendToPanel("scene", "scene:redo"); + callback(null, "重做指令已执行"); + break; + case "begin_group": + addLog("info", `开始撤销组: ${description || "MCP 动作"}`); + // 如果有参数包含 id,则记录该节点 + if (args.id) { + Editor.Ipc.sendToPanel("scene", "scene:undo-record", args.id); + } + callback(null, `撤销组已启动: ${description || "MCP 动作"}`); + break; + case "end_group": + Editor.Ipc.sendToPanel("scene", "scene:undo-commit"); + callback(null, "撤销组已提交"); + break; + case "cancel_group": + Editor.Ipc.sendToPanel("scene", "scene:undo-cancel"); + callback(null, "撤销组已取消"); + break; + default: + callback(`未知的撤销操作: ${action}`); + } + } catch (err) { + callback(`撤销操作失败: ${err.message}`); + } + }, + + /** + * 计算资源的 SHA-256 哈希值 + * @param {Object} args 参数 (path) + * @param {Function} callback 完成回调 + */ + getSha(args, callback) { + const { path: url } = args; + const fspath = Editor.assetdb.urlToFspath(url); + + if (!fspath || !fs.existsSync(fspath)) { + return callback(`找不到文件: ${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(`计算 SHA 失败: ${err.message}`); + } + }, + + /** + * 管理节点动画 (播放、停止、获取信息等) + * @param {Object} args 参数 + * @param {Function} callback 完成回调 + */ + manageAnimation(args, callback) { + // 转发给场景脚本处理 + callSceneScriptWithTimeout("mcp-bridge", "manage-animation", args, callback); + }, }; diff --git a/mcp-proxy.js b/mcp-proxy.js index f956650..b7b0553 100644 --- a/mcp-proxy.js +++ b/mcp-proxy.js @@ -7,30 +7,31 @@ const http = require("http"); /** * 当前 Cocos Creator 插件监听的端口 + * 支持通过环境变量 MCP_BRIDGE_PORT 或命令行参数指定端口 * @type {number} */ -const COCOS_PORT = 3456; +const COCOS_PORT = parseInt(process.env.MCP_BRIDGE_PORT || process.argv[2] || "3456", 10); /** * 发送调试日志到标准的错误输出流水 * @param {string} msg 日志消息 */ function debugLog(msg) { - process.stderr.write(`[代理调试] ${msg}\n`); + process.stderr.write(`[代理调试] ${msg}\n`); } // 监听标准输入以获取 MCP 请求 process.stdin.on("data", (data) => { - const lines = data.toString().split("\n"); - lines.forEach((line) => { - if (!line.trim()) return; - try { - const request = JSON.parse(line); - handleRequest(request); - } catch (e) { - // 忽略非 JSON 输入 - } - }); + const lines = data.toString().split("\n"); + lines.forEach((line) => { + if (!line.trim()) return; + try { + const request = JSON.parse(line); + handleRequest(request); + } catch (e) { + // 忽略非 JSON 输入 + } + }); }); /** @@ -38,44 +39,44 @@ process.stdin.on("data", (data) => { * @param {Object} req RPC 请求对象 */ function handleRequest(req) { - const { method, id, params } = req; + const { method, id, params } = req; - // 处理握手初始化 - if (method === "initialize") { - sendToAI({ - jsonrpc: "2.0", - id: id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "cocos-bridge", version: "1.0.0" }, - }, - }); - return; - } + // 处理握手初始化 + if (method === "initialize") { + sendToAI({ + jsonrpc: "2.0", + id: id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "cocos-bridge", version: "1.0.0" }, + }, + }); + return; + } - // 获取工具列表 - if (method === "tools/list") { - forwardToCocos("/list-tools", null, id, "GET"); - return; - } + // 获取工具列表 + if (method === "tools/list") { + forwardToCocos("/list-tools", null, id, "GET"); + return; + } - // 执行具体工具 - if (method === "tools/call") { - forwardToCocos( - "/call-tool", - { - name: params.name, - arguments: params.arguments, - }, - id, - "POST", - ); - return; - } + // 执行具体工具 + if (method === "tools/call") { + forwardToCocos( + "/call-tool", + { + name: params.name, + arguments: params.arguments, + }, + id, + "POST", + ); + return; + } - // 默认空响应 - if (id !== undefined) sendToAI({ jsonrpc: "2.0", id: id, result: {} }); + // 默认空响应 + if (id !== undefined) sendToAI({ jsonrpc: "2.0", id: id, result: {} }); } /** @@ -86,48 +87,48 @@ function handleRequest(req) { * @param {string} method HTTP 方法 (默认 POST) */ function forwardToCocos(path, payload, id, method = "POST") { - const postData = payload ? JSON.stringify(payload) : ""; + const postData = payload ? JSON.stringify(payload) : ""; - const options = { - hostname: "127.0.0.1", - port: COCOS_PORT, - path: path, - method: method, - headers: { "Content-Type": "application/json" }, - }; + const options = { + hostname: "127.0.0.1", + port: COCOS_PORT, + path: path, + method: method, + headers: { "Content-Type": "application/json" }, + }; - if (postData) { - options.headers["Content-Length"] = Buffer.byteLength(postData); - } + if (postData) { + options.headers["Content-Length"] = Buffer.byteLength(postData); + } - const request = http.request(options, (res) => { - let resData = ""; - res.on("data", (d) => (resData += d)); - res.on("end", () => { - try { - const cocosRes = JSON.parse(resData); + const request = http.request(options, (res) => { + let resData = ""; + res.on("data", (d) => (resData += d)); + res.on("end", () => { + try { + const cocosRes = JSON.parse(resData); - // 检查关键字段,确保 Cocos 插件返回了期望的数据格式 - if (path === "/list-tools" && !cocosRes.tools) { - debugLog(`致命错误: Cocos 未返回工具列表。接收内容: ${resData}`); - sendError(id, -32603, "Cocos 响应无效:缺少 tools 数组"); - } else { - sendToAI({ jsonrpc: "2.0", id: id, result: cocosRes }); - } - } catch (e) { - debugLog(`JSON 解析错误。Cocos 发送内容: ${resData}`); - sendError(id, -32603, "Cocos 返回了非 JSON 数据"); - } - }); - }); + // 检查关键字段,确保 Cocos 插件返回了期望的数据格式 + if (path === "/list-tools" && !cocosRes.tools) { + debugLog(`致命错误: Cocos 未返回工具列表。接收内容: ${resData}`); + sendError(id, -32603, "Cocos 响应无效:缺少 tools 数组"); + } else { + sendToAI({ jsonrpc: "2.0", id: id, result: cocosRes }); + } + } catch (e) { + debugLog(`JSON 解析错误。Cocos 发送内容: ${resData}`); + sendError(id, -32603, "Cocos 返回了非 JSON 数据"); + } + }); + }); - request.on("error", (e) => { - debugLog(`Cocos 插件已离线: ${e.message}`); - sendError(id, -32000, "Cocos 插件离线"); - }); + request.on("error", (e) => { + debugLog(`Cocos 插件已离线: ${e.message}`); + sendError(id, -32000, "Cocos 插件离线"); + }); - if (postData) request.write(postData); - request.end(); + if (postData) request.write(postData); + request.end(); } /** @@ -135,7 +136,7 @@ function forwardToCocos(path, payload, id, method = "POST") { * @param {Object} obj 结果对象 */ function sendToAI(obj) { - process.stdout.write(JSON.stringify(obj) + "\n"); + process.stdout.write(JSON.stringify(obj) + "\n"); } /** @@ -145,5 +146,5 @@ function sendToAI(obj) { * @param {string} message 错误消息 */ function sendError(id, code, message) { - sendToAI({ jsonrpc: "2.0", id: id, error: { code, message } }); + sendToAI({ jsonrpc: "2.0", id: id, error: { code, message } }); } diff --git a/panel/index.js b/panel/index.js index 14d4b4a..b071f04 100644 --- a/panel/index.js +++ b/panel/index.js @@ -9,320 +9,332 @@ const fs = require("fs"); const { IpcUi } = require("../dist/IpcUi"); Editor.Panel.extend({ - /** - * 面板 CSS 样式 - */ - style: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), + /** + * 面板 CSS 样式 + */ + style: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), - /** - * 面板 HTML 模板 - */ - template: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), + /** + * 面板 HTML 模板 + */ + template: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), - /** - * 监听来自主进程的消息 - */ - messages: { - /** - * 接收并渲染日志 - * @param {Object} event IPC 事件对象 - * @param {Object} log 日志数据 - */ - "mcp-bridge:on-log"(event, log) { - this.renderLog(log); - }, + /** + * 监听来自主进程的消息 + */ + messages: { + /** + * 接收并渲染日志 + * @param {Object} event IPC 事件对象 + * @param {Object} log 日志数据 + */ + "mcp-bridge:on-log"(event, log) { + this.renderLog(log); + }, - /** - * 服务器状态变更通知 - * @param {Object} event IPC 事件对象 - * @param {Object} config 服务器配置 - */ - "mcp-bridge:state-changed"(event, config) { - this.updateUI(config.active); - // 如果服务器已启动,更新面板显示的端口为实际运行端口 - if (config.active && config.port) { - const portInput = this.shadowRoot.querySelector("#portInput"); - if (portInput) portInput.value = config.port; - } - }, - }, + /** + * 服务器状态变更通知 + * @param {Object} event IPC 事件对象 + * @param {Object} config 服务器配置 + */ + "mcp-bridge:state-changed"(event, config) { + this.updateUI(config.active); + // 如果服务器已启动,更新面板显示的端口为实际运行端口 + if (config.active && config.port) { + const portInput = this.shadowRoot.querySelector("#portInput"); + if (portInput) portInput.value = config.port; + } + }, + }, - /** - * 面板就绪回调,进行 DOM 绑定与事件初始化 - */ - ready() { - const root = this.shadowRoot; - // 获取 DOM 元素映射 - const els = { - port: root.querySelector("#portInput"), - btnToggle: root.querySelector("#btnToggle"), - autoStart: root.querySelector("#autoStartCheck"), - logView: root.querySelector("#logConsole"), - tabMain: root.querySelector("#tabMain"), - tabTest: root.querySelector("#tabTest"), - tabIpc: root.querySelector("#tabIpc"), - panelMain: root.querySelector("#panelMain"), - panelTest: root.querySelector("#panelTest"), - panelIpc: root.querySelector("#panelIpc"), - toolName: root.querySelector("#toolName"), - toolParams: root.querySelector("#toolParams"), - toolDescription: root.querySelector("#toolDescription"), - toolsList: root.querySelector("#toolsList"), - testBtn: root.querySelector("#testBtn"), - listBtn: root.querySelector("#listToolsBtn"), - clearBtn: root.querySelector("#clearTestBtn"), - result: root.querySelector("#resultContent"), - left: root.querySelector("#testLeftPanel"), - resizer: root.querySelector("#testResizer"), - }; + /** + * 面板就绪回调,进行 DOM 绑定与事件初始化 + */ + ready() { + const root = this.shadowRoot; + // 获取 DOM 元素映射 + const els = { + port: root.querySelector("#portInput"), + btnToggle: root.querySelector("#btnToggle"), + autoStart: root.querySelector("#autoStartCheck"), + logView: root.querySelector("#logConsole"), + tabMain: root.querySelector("#tabMain"), + tabTest: root.querySelector("#tabTest"), + tabIpc: root.querySelector("#tabIpc"), + panelMain: root.querySelector("#panelMain"), + panelTest: root.querySelector("#panelTest"), + panelIpc: root.querySelector("#panelIpc"), + toolName: root.querySelector("#toolName"), + toolParams: root.querySelector("#toolParams"), + toolDescription: root.querySelector("#toolDescription"), + toolsList: root.querySelector("#toolsList"), + testBtn: root.querySelector("#testBtn"), + listBtn: root.querySelector("#listToolsBtn"), + clearBtn: root.querySelector("#clearTestBtn"), + result: root.querySelector("#resultContent"), + left: root.querySelector("#testLeftPanel"), + resizer: root.querySelector("#testResizer"), + }; - // 1. 初始化服务器状态与配置 - Editor.Ipc.sendToMain("mcp-bridge:get-server-state", (err, data) => { - if (data) { - els.port.value = data.config.port; - els.autoStart.value = data.autoStart; - this.updateUI(data.config.active); - els.logView.innerHTML = ""; - data.logs.forEach((l) => this.renderLog(l)); - } - }); + // 1. 初始化服务器状态与配置 + Editor.Ipc.sendToMain("mcp-bridge:get-server-state", (err, data) => { + if (data) { + els.port.value = data.config.port; + els.autoStart.value = data.autoStart; + this.updateUI(data.config.active); + els.logView.innerHTML = ""; + data.logs.forEach((l) => this.renderLog(l)); + } + }); - // 初始化 IPC 调试专用 UI (由 TypeScript 编写并编译到 dist) - new IpcUi(root); + // 初始化 IPC 调试专用 UI (由 TypeScript 编写并编译到 dist) + new IpcUi(root); - // 2. 标签页切换逻辑 - els.tabMain.addEventListener("confirm", () => { - els.tabMain.classList.add("active"); - els.tabTest.classList.remove("active"); - els.tabIpc.classList.remove("active"); - els.panelMain.classList.add("active"); - els.panelTest.classList.remove("active"); - els.panelIpc.classList.remove("active"); - }); + // 2. 标签页切换逻辑 + els.tabMain.addEventListener("confirm", () => { + els.tabMain.classList.add("active"); + els.tabTest.classList.remove("active"); + els.tabIpc.classList.remove("active"); + els.panelMain.classList.add("active"); + els.panelTest.classList.remove("active"); + els.panelIpc.classList.remove("active"); + }); - els.tabTest.addEventListener("confirm", () => { - els.tabTest.classList.add("active"); - els.tabMain.classList.remove("active"); - els.tabIpc.classList.remove("active"); - els.panelTest.classList.add("active"); - els.panelMain.classList.remove("active"); - els.panelIpc.classList.remove("active"); - this.fetchTools(els); // 切换到测试页时自动拉取工具列表 - }); + els.tabTest.addEventListener("confirm", () => { + els.tabTest.classList.add("active"); + els.tabMain.classList.remove("active"); + els.tabIpc.classList.remove("active"); + els.panelTest.classList.add("active"); + els.panelMain.classList.remove("active"); + els.panelIpc.classList.remove("active"); + this.fetchTools(els); // 切换到测试页时自动拉取工具列表 + }); - els.tabIpc.addEventListener("confirm", () => { - els.tabIpc.classList.add("active"); - els.tabMain.classList.remove("active"); - els.tabTest.classList.remove("active"); - els.panelIpc.classList.add("active"); - els.panelMain.classList.remove("active"); - els.panelTest.classList.remove("active"); - }); + els.tabIpc.addEventListener("confirm", () => { + els.tabIpc.classList.add("active"); + els.tabMain.classList.remove("active"); + els.tabTest.classList.remove("active"); + els.panelIpc.classList.add("active"); + els.panelMain.classList.remove("active"); + els.panelTest.classList.remove("active"); + }); - // 3. 基础控制按钮逻辑 - els.btnToggle.addEventListener("confirm", () => { - Editor.Ipc.sendToMain("mcp-bridge:toggle-server", parseInt(els.port.value)); - }); + // 3. 基础控制按钮逻辑 + els.btnToggle.addEventListener("confirm", () => { + Editor.Ipc.sendToMain("mcp-bridge:toggle-server", parseInt(els.port.value)); + }); - root.querySelector("#btnClear").addEventListener("confirm", () => { - els.logView.innerHTML = ""; - Editor.Ipc.sendToMain("mcp-bridge:clear-logs"); - }); + root.querySelector("#btnClear").addEventListener("confirm", () => { + els.logView.innerHTML = ""; + Editor.Ipc.sendToMain("mcp-bridge:clear-logs"); + }); - root.querySelector("#btnCopy").addEventListener("confirm", () => { - require("electron").clipboard.writeText(els.logView.innerText); - Editor.success("日志已复制到剪贴板"); - }); + root.querySelector("#btnCopy").addEventListener("confirm", () => { + require("electron").clipboard.writeText(els.logView.innerText); + Editor.success("日志已复制到剪贴板"); + }); - els.autoStart.addEventListener("change", (e) => { - Editor.Ipc.sendToMain("mcp-bridge:set-auto-start", e.target.value); - }); + els.autoStart.addEventListener("change", (e) => { + Editor.Ipc.sendToMain("mcp-bridge:set-auto-start", e.target.value); + }); - // 4. API 测试页交互逻辑 - els.listBtn.addEventListener("confirm", () => this.fetchTools(els)); - els.clearBtn.addEventListener("confirm", () => { - els.result.value = ""; - }); - els.testBtn.addEventListener("confirm", () => this.runTest(els)); + // 4. API 测试页交互逻辑 + els.listBtn.addEventListener("confirm", () => this.fetchTools(els)); + els.clearBtn.addEventListener("confirm", () => { + els.result.value = ""; + }); + els.testBtn.addEventListener("confirm", () => this.runTest(els)); - // API 探查功能 (辅助开发者发现可用内部 IPC) - const probeBtn = root.querySelector("#probeApisBtn"); - if (probeBtn) { - probeBtn.addEventListener("confirm", () => { - Editor.Ipc.sendToMain("mcp-bridge:inspect-apis"); - els.result.value = "API 探查指令已发送。请查看编辑器控制台 (Console) 获取详细报告。"; - }); - } + // API 探查功能 (辅助开发者发现可用内部 IPC) + const probeBtn = root.querySelector("#probeApisBtn"); + if (probeBtn) { + probeBtn.addEventListener("confirm", () => { + Editor.Ipc.sendToMain("mcp-bridge:inspect-apis"); + els.result.value = "API 探查指令已发送。请查看编辑器控制台 (Console) 获取详细报告。"; + }); + } - // 5. 测试页分栏拖拽缩放逻辑 - if (els.resizer && els.left) { - els.resizer.addEventListener("mousedown", (e) => { - e.preventDefault(); - const startX = e.clientX; - const startW = els.left.offsetWidth; - const onMove = (ev) => { - els.left.style.width = startW + (ev.clientX - startX) + "px"; - }; - const onUp = () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - document.body.style.cursor = "default"; - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - document.body.style.cursor = "col-resize"; - }); - } - }, + // 5. 测试页分栏拖拽缩放逻辑 + if (els.resizer && els.left) { + els.resizer.addEventListener("mousedown", (e) => { + e.preventDefault(); + const startX = e.clientX; + const startW = els.left.offsetWidth; + const onMove = (ev) => { + els.left.style.width = startW + (ev.clientX - startX) + "px"; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = "default"; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.cursor = "col-resize"; + }); + } + }, - /** - * 从本地服务器获取 MCP 工具列表并渲染 - * @param {Object} els DOM 元素映射 - */ - fetchTools(els) { - const url = `http://localhost:${els.port.value}/list-tools`; - els.result.value = "正在获取工具列表..."; - fetch(url) - .then((r) => r.json()) - .then((data) => { - els.toolsList.innerHTML = ""; - const toolsMap = {}; - data.tools.forEach((t) => { - toolsMap[t.name] = t; - const item = document.createElement("div"); - item.className = "tool-item"; - item.textContent = t.name; - item.onclick = () => { - els.toolName.value = t.name; - els.toolParams.value = JSON.stringify(this.getExample(t.name), null, 2); - this.showToolDescription(els, t); - }; - els.toolsList.appendChild(item); - }); - this.toolsMap = toolsMap; - els.result.value = `成功:加载了 ${data.tools.length} 个工具。`; - }) - .catch((e) => { - els.result.value = "获取失败: " + e.message; - }); - }, + /** + * 从本地服务器获取 MCP 工具列表并渲染 + * @param {Object} els DOM 元素映射 + */ + fetchTools(els) { + const url = `http://localhost:${els.port.value}/list-tools`; + els.result.value = "正在获取工具列表..."; + fetch(url) + .then((r) => r.json()) + .then((data) => { + els.toolsList.innerHTML = ""; + const toolsMap = {}; + data.tools.forEach((t) => { + toolsMap[t.name] = t; + const item = document.createElement("div"); + item.className = "tool-item"; + item.textContent = t.name; + item.onclick = () => { + els.toolName.value = t.name; + els.toolParams.value = JSON.stringify(this.getExample(t.name), null, 2); + this.showToolDescription(els, t); + }; + els.toolsList.appendChild(item); + }); + this.toolsMap = toolsMap; + els.result.value = `成功:加载了 ${data.tools.length} 个工具。`; + }) + .catch((e) => { + els.result.value = "获取失败: " + e.message; + }); + }, - /** - * 在面板中展示工具的详细描述与参数定义 - * @param {Object} els DOM 元素映射 - * @param {Object} tool 工具定义对象 - */ - showToolDescription(els, tool) { - if (!tool) { - els.toolDescription.textContent = "选择工具以查看说明"; - return; - } + /** + * 在面板中展示工具的详细描述与参数定义 + * @param {Object} els DOM 元素映射 + * @param {Object} tool 工具定义对象 + */ + showToolDescription(els, tool) { + if (!tool) { + els.toolDescription.textContent = "选择工具以查看说明"; + return; + } - let description = tool.description || "暂无描述"; - let inputSchema = tool.inputSchema; + let description = tool.description || "暂无描述"; + let inputSchema = tool.inputSchema; - let details = []; - if (inputSchema && inputSchema.properties) { - details.push("参数说明:"); - for (const [key, prop] of Object.entries(inputSchema.properties)) { - let propDesc = `- ${key}`; - if (prop.description) { - propDesc += `: ${prop.description}`; - } - if (prop.required || (inputSchema.required && inputSchema.required.includes(key))) { - propDesc += " (必填)"; - } - details.push(propDesc); - } - } + let details = []; + if (inputSchema && inputSchema.properties) { + details.push("参数说明:"); + for (const [key, prop] of Object.entries(inputSchema.properties)) { + let propDesc = `- ${key}`; + if (prop.description) { + propDesc += `: ${prop.description}`; + } + if (prop.required || (inputSchema.required && inputSchema.required.includes(key))) { + propDesc += " (必填)"; + } + details.push(propDesc); + } + } - els.toolDescription.innerHTML = `${description}

${details.join("
")}`; - }, + els.toolDescription.innerHTML = `${description}

${details.join("
")}`; + }, - /** - * 执行工具测试请求 - * @param {Object} els DOM 元素映射 - */ - runTest(els) { - const url = `http://localhost:${els.port.value}/call-tool`; - let args; - try { - args = JSON.parse(els.toolParams.value || "{}"); - } catch (e) { - els.result.value = "JSON 格式错误: " + e.message; - return; - } + /** + * 执行工具测试请求 + * @param {Object} els DOM 元素映射 + */ + runTest(els) { + const url = `http://localhost:${els.port.value}/call-tool`; + let args; + try { + args = JSON.parse(els.toolParams.value || "{}"); + } catch (e) { + els.result.value = "JSON 格式错误: " + e.message; + return; + } - const body = { name: els.toolName.value, arguments: args }; - els.result.value = "正在发送请求..."; - fetch(url, { method: "POST", body: JSON.stringify(body) }) - .then((r) => r.json()) - .then((d) => { - els.result.value = JSON.stringify(d, null, 2); - }) - .catch((e) => { - els.result.value = "测试异常: " + e.message; - }); - }, + const body = { name: els.toolName.value, arguments: args }; + els.result.value = "正在发送请求..."; + fetch(url, { method: "POST", body: JSON.stringify(body) }) + .then((r) => r.json()) + .then((d) => { + els.result.value = JSON.stringify(d, null, 2); + }) + .catch((e) => { + els.result.value = "测试异常: " + e.message; + }); + }, - /** - * 获取指定工具的示例参数 - * @param {string} name 工具名称 - * @returns {Object} 示例参数对象 - */ - getExample(name) { - const examples = { - set_node_name: { id: "节点-UUID", newName: "新名称" }, - update_node_transform: { id: "节点-UUID", x: 0, y: 0, color: "#FF0000" }, - create_node: { name: "新节点", type: "sprite", parentId: "" }, - open_scene: { url: "db://assets/Scene.fire" }, - open_prefab: { url: "db://assets/MyPrefab.prefab" }, - manage_editor: { action: "get_selection" }, - find_gameobjects: { conditions: { name: "MyNode", active: true }, recursive: true }, - manage_material: { - action: "create", - path: "db://assets/materials/NewMaterial.mat", - properties: { uniforms: {} }, - }, - manage_texture: { - action: "create", - path: "db://assets/textures/NewTexture.png", - properties: { width: 128, height: 128 }, - }, - execute_menu_item: { menuPath: "Assets/Create/Folder" }, - apply_text_edits: { - filePath: "db://assets/scripts/TestScript.ts", - edits: [{ type: "insert", position: 0, text: "// 测试注释\n" }], - }, - read_console: { limit: 10, type: "log" }, - validate_script: { filePath: "db://assets/scripts/TestScript.ts" }, - }; - return examples[name] || {}; - }, + /** + * 获取指定工具的示例参数 + * @param {string} name 工具名称 + * @returns {Object} 示例参数对象 + */ + getExample(name) { + const examples = { + set_node_name: { id: "节点-UUID", newName: "新名称" }, + update_node_transform: { id: "节点-UUID", x: 0, y: 0, color: "#FF0000" }, + create_node: { name: "新节点", type: "sprite", parentId: "" }, + open_scene: { url: "db://assets/Scene.fire" }, + open_prefab: { url: "db://assets/MyPrefab.prefab" }, + manage_editor: { action: "get_selection" }, + find_gameobjects: { conditions: { name: "MyNode", active: true }, recursive: true }, + manage_material: { + action: "create", + path: "db://assets/materials/NewMaterial.mat", + properties: { uniforms: {} }, + }, + manage_texture: { + action: "create", + path: "db://assets/textures/NewTexture.png", + properties: { width: 128, height: 128 }, + }, + execute_menu_item: { menuPath: "Assets/Create/Folder" }, + apply_text_edits: { + filePath: "db://assets/scripts/TestScript.ts", + edits: [{ type: "insert", position: 0, text: "// 测试注释\n" }], + }, + read_console: { limit: 10, type: "log" }, + validate_script: { filePath: "db://assets/scripts/TestScript.ts" }, + }; + return examples[name] || {}; + }, - /** - * 将日志条目渲染至面板控制台 - * @param {Object} log 日志对象 - */ - renderLog(log) { - const view = this.shadowRoot.querySelector("#logConsole"); - if (!view) return; - const atBottom = view.scrollHeight - view.scrollTop <= view.clientHeight + 50; - const el = document.createElement("div"); - el.className = `log-item ${log.type}`; - el.innerHTML = `${log.time}${log.content}`; - view.appendChild(el); - if (atBottom) view.scrollTop = view.scrollHeight; - }, + /** + * 将日志条目渲染至面板控制台 + * @param {Object} log 日志对象 + */ + renderLog(log) { + const view = this.shadowRoot.querySelector("#logConsole"); + if (!view) return; + // 限制面板日志 DOM 节点数量,防止长时间运行后面板卡顿 + while (view.childNodes.length > 1000) { + view.removeChild(view.firstChild); + } + const atBottom = view.scrollHeight - view.scrollTop <= view.clientHeight + 50; + const el = document.createElement("div"); + el.className = `log-item ${log.type}`; + // 使用 textContent 代替 innerHTML 防止 XSS 注入 + const timeSpan = document.createElement("span"); + timeSpan.className = "time"; + timeSpan.textContent = log.time; + const msgSpan = document.createElement("span"); + msgSpan.className = "msg"; + msgSpan.textContent = log.content; + el.appendChild(timeSpan); + el.appendChild(msgSpan); + view.appendChild(el); + if (atBottom) view.scrollTop = view.scrollHeight; + }, - /** - * 根据服务器运行状态更新 UI 按钮文字与样式 - * @param {boolean} active 服务器是否处于激活状态 - */ - updateUI(active) { - const btn = this.shadowRoot.querySelector("#btnToggle"); - if (!btn) return; - btn.innerText = active ? "停止" : "启动"; - btn.style.backgroundColor = active ? "#aa4444" : "#44aa44"; - }, + /** + * 根据服务器运行状态更新 UI 按钮文字与样式 + * @param {boolean} active 服务器是否处于激活状态 + */ + updateUI(active) { + const btn = this.shadowRoot.querySelector("#btnToggle"); + if (!btn) return; + btn.innerText = active ? "停止" : "启动"; + btn.style.backgroundColor = active ? "#aa4444" : "#44aa44"; + }, }); diff --git a/scene-script.js b/scene-script.js index 1f54b86..b5ae86c 100644 --- a/scene-script.js +++ b/scene-script.js @@ -6,1164 +6,1165 @@ * @returns {cc.Node | null} 找到的节点对象或 null */ const findNode = (id) => { - if (!id) return null; - let node = cc.engine.getInstanceById(id); - if (!node && typeof Editor !== "undefined" && Editor.Utils && Editor.Utils.UuidUtils) { - // 如果直接查不到,尝试对可能是压缩格式的 ID 进行解压后再次查找 - try { - const decompressed = Editor.Utils.UuidUtils.decompressUuid(id); - if (decompressed !== id) { - node = cc.engine.getInstanceById(decompressed); - } - } catch (e) { - // 忽略转换错误 - } - } - return node; + if (!id) return null; + let node = cc.engine.getInstanceById(id); + if (!node && typeof Editor !== "undefined" && Editor.Utils && Editor.Utils.UuidUtils) { + // 如果直接查不到,尝试对可能是压缩格式的 ID 进行解压后再次查找 + try { + const decompressed = Editor.Utils.UuidUtils.decompressUuid(id); + if (decompressed !== id) { + node = cc.engine.getInstanceById(decompressed); + } + } catch (e) { + // 忽略转换错误 + } + } + return node; }; module.exports = { - /** - * 修改节点的基础属性 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (id, path, value) - */ - "set-property": function (event, args) { - const { id, path, value } = args; - - // 1. 获取节点 - let node = findNode(id); - - if (node) { - // 2. 修改属性 - if (path === "name") { - node.name = value; - } else { - node[path] = value; - } - - // 3. 【解决报错的关键】告诉编辑器场景变脏了(需要保存) - // 在场景进程中,我们发送 IPC 给主进程 - Editor.Ipc.sendToMain("scene:dirty"); - - // 4. 【额外补丁】通知层级管理器(Hierarchy)同步更新节点名称 - // 否则你修改了名字,层级管理器可能还是显示旧名字 - Editor.Ipc.sendToAll("scene:node-changed", { - uuid: id, - }); - - if (event.reply) { - event.reply(null, `节点 ${id} 已更新为 ${value}`); - } - } else { - if (event.reply) { - event.reply(new Error("场景脚本:找不到节点 " + id)); - } - } - }, - /** - * 获取当前场景的完整层级树 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (nodeId, depth, includeDetails) - */ - "get-hierarchy": function (event, args) { - const { nodeId = null, depth = 2, includeDetails = false } = args || {}; - const scene = cc.director.getScene(); - - let rootNode = scene; - if (nodeId) { - rootNode = findNode(nodeId); - if (!rootNode) { - if (event.reply) event.reply(new Error(`找不到指定的起始节点: ${nodeId}`)); - return; - } - } - - /** - * 递归遍历并序列化节点树 - * @param {cc.Node} node 目标节点 - * @param {number} currentDepth 当前深度 - * @returns {Object|null} 序列化后的节点数据 - */ - function dumpNodes(node, currentDepth) { - // 【优化】跳过编辑器内部的私有节点,减少数据量 - if ( - !node || - !node.name || - (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) - ) { - return null; - } - - let nodeData = { - name: node.name, - uuid: node.uuid, - childrenCount: node.childrenCount, - }; - - const comps = node._components || []; - - // 根据是否需要详情来决定附加哪些数据以节省 Token - if (includeDetails) { - nodeData.active = node.active; - nodeData.position = { x: Math.round(node.x), y: Math.round(node.y) }; - nodeData.scale = { x: node.scaleX, y: node.scaleY }; - nodeData.size = { width: node.width, height: node.height }; - nodeData.components = comps.map((c) => cc.js.getClassName(c)); - } else { - // 简略模式下如果存在组件,至少提供一个极简列表让 AI 知道节点的作用 - if (comps.length > 0) { - nodeData.components = comps.map((c) => { - const parts = (cc.js.getClassName(c) || "").split("."); - return parts[parts.length - 1]; // 只取类名,例如 cc.Sprite -> Sprite - }); - } - } - - // 如果未超出深度限制,继续递归子树 - if (currentDepth < depth && node.childrenCount > 0) { - nodeData.children = []; - for (let i = 0; i < node.childrenCount; i++) { - let childData = dumpNodes(node.children[i], currentDepth + 1); - if (childData) nodeData.children.push(childData); - } - } - - return nodeData; - } - - const hierarchy = dumpNodes(rootNode, 0); - if (event.reply) event.reply(null, hierarchy); - }, - - /** - * 批量更新节点的变换信息 (坐标、缩放、颜色) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (id, x, y, scaleX, scaleY, color) - */ - "update-node-transform": function (event, args) { - const { id, x, y, scaleX, scaleY, color } = args; - Editor.log(`[scene-script] update-node-transform called for ${id} with args: ${JSON.stringify(args)}`); - - let node = findNode(id); - - if (node) { - Editor.log(`[scene-script] 找到节点: ${node.name}, 当前坐标: (${node.x}, ${node.y})`); - - // 使用 scene:set-property 实现支持 Undo 的属性修改 - // 注意:IPC 消息需要发送到 'scene' 面板 - if (x !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "x", - type: "Number", - value: Number(x), - }); - } - if (y !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "y", - type: "Number", - value: Number(y), - }); - } - if (args.width !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "width", - type: "Number", - value: Number(args.width), - }); - } - if (args.height !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "height", - type: "Number", - value: Number(args.height), - }); - } - if (scaleX !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "scaleX", - type: "Number", - value: Number(scaleX), - }); - } - if (scaleY !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "scaleY", - type: "Number", - value: Number(scaleY), - }); - } - if (color) { - const c = new cc.Color().fromHEX(color); - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id: id, - path: "color", - type: "Color", - value: { r: c.r, g: c.g, b: c.b, a: c.a }, - }); - } - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: id }); - - Editor.log(`[scene-script] 更新完成。新坐标: (${node.x}, ${node.y})`); - if (event.reply) event.reply(null, "变换信息已更新"); - } else { - if (event.reply) event.reply(new Error("找不到节点")); - } - }, - /** - * 在场景中创建新节点 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (name, parentId, type) - */ - "create-node": function (event, args) { - const { name, parentId, type } = args; - const scene = cc.director.getScene(); - if (!scene) { - if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); - return; - } - - let newNode = null; - - // 特殊处理:如果是创建 Canvas,自动设置好适配 - if (type === "canvas" || name === "Canvas") { - newNode = new cc.Node("Canvas"); - let canvas = newNode.addComponent(cc.Canvas); - newNode.addComponent(cc.Widget); - // 设置默认设计分辨率 - canvas.designResolution = cc.size(960, 640); - canvas.fitHeight = true; - // 自动在 Canvas 下创建一个 Camera - let camNode = new cc.Node("Main Camera"); - camNode.addComponent(cc.Camera); - camNode.parent = newNode; - } else if (type === "sprite") { - newNode = new cc.Node(name || "新建精灵节点"); - let sprite = newNode.addComponent(cc.Sprite); - // 设置为 CUSTOM 模式 - sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; - // 为精灵设置默认尺寸 - newNode.width = 100; - newNode.height = 100; - - // 加载引擎默认图做占位 - if (args.defaultSpriteUuid) { - cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { - if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { - sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - } - } else if (type === "button") { - newNode = new cc.Node(name || "新建按钮节点"); - let sprite = newNode.addComponent(cc.Sprite); - newNode.addComponent(cc.Button); - - // 设置为 CUSTOM 模式并应用按钮专用尺寸 - sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; - newNode.width = 150; - newNode.height = 50; - - // 设置稍暗的背景颜色 (#A0A0A0),以便于看清其上的文字 - newNode.color = new cc.Color(160, 160, 160); - - // 加载引擎默认图 - if (args.defaultSpriteUuid) { - cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { - if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { - sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - } - } else if (type === "label") { - newNode = new cc.Node(name || "新建文本节点"); - let l = newNode.addComponent(cc.Label); - l.string = "新文本"; - newNode.width = 120; - newNode.height = 40; - } else { - newNode = new cc.Node(name || "新建节点"); - } - - // 设置层级 - let parent = parentId ? findNode(parentId) : scene; - if (parent) { - newNode.parent = parent; - - // 【优化】通知主进程场景变脏 - Editor.Ipc.sendToMain("scene:dirty"); - - // 【关键】使用 setTimeout 延迟通知 UI 刷新,让出主循环 - setTimeout(() => { - Editor.Ipc.sendToAll("scene:node-created", { - uuid: newNode.uuid, - parentUuid: parent.uuid, - }); - }, 10); - - if (event.reply) event.reply(null, newNode.uuid); - } else { - if (event.reply) event.reply(new Error(`无法创建节点:找不到父节点 ${parentId}`)); - } - }, - - /** - * 管理节点上的组件 (添加、移除、更新属性) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (nodeId, action, componentType, componentId, properties) - */ - "manage-components": function (event, args) { - let { nodeId, action, operation, componentType, componentId, properties } = args; - // 兼容 AI 幻觉带来的传参错误 - action = action || operation; - - let node = findNode(nodeId); - - /** - * 辅助函数:应用属性并智能解析 (支持 UUID 资源与节点引用) - * @param {cc.Component} component 目标组件实例 - * @param {Object} props 待更新的属性键值对 - */ - const applyProperties = (component, props) => { - if (!props) return; - // 尝试获取组件类的属性定义 - const compClass = component.constructor; - - for (const [key, value] of Object.entries(props)) { - // 【核心修复】专门处理各类事件属性 (ClickEvents, ScrollEvents 等) - const isEventProp = - Array.isArray(value) && (key.toLowerCase().endsWith("events") || key === "clickEvents"); - - if (isEventProp) { - const eventHandlers = []; - for (const item of value) { - if (typeof item === "object" && (item.target || item.component || item.handler)) { - const handler = new cc.Component.EventHandler(); - - // 解析 Target Node - if (item.target) { - let targetNode = findNode(item.target); - if (!targetNode && item.target instanceof cc.Node) { - targetNode = item.target; - } - - if (targetNode) { - handler.target = targetNode; - Editor.log(`[scene-script] Resolved event target: ${targetNode.name}`); - } - } - - if (item.component) handler.component = item.component; - if (item.handler) handler.handler = item.handler; - if (item.customEventData !== undefined) - handler.customEventData = String(item.customEventData); - - eventHandlers.push(handler); - } else { - // 如果不是对象,原样保留 - eventHandlers.push(item); - } - } - component[key] = eventHandlers; - continue; // 处理完事件数组,跳出本次循环 - } - - // 检查属性是否存在 - if (component[key] !== undefined) { - let finalValue = value; - - // 【核心逻辑】智能类型识别与赋值 - try { - const attrs = (cc.Class.Attr.getClassAttrs && cc.Class.Attr.getClassAttrs(compClass)) || {}; - let propertyType = attrs[key] ? attrs[key].type : null; - if (!propertyType && attrs[key + "$_$ctor"]) { - propertyType = attrs[key + "$_$ctor"]; - } - - let isAsset = - propertyType && - (propertyType.prototype instanceof cc.Asset || - propertyType === cc.Asset || - propertyType === cc.Prefab || - propertyType === cc.SpriteFrame); - let isAssetArray = - Array.isArray(value) && (key === "materials" || key.toLowerCase().includes("assets")); - - // 启发式:如果属性名包含 prefab/sprite/texture 等,且值为 UUID 且不是节点 - if (!isAsset && !isAssetArray && typeof value === "string" && value.length > 20) { - const lowerKey = key.toLowerCase(); - const assetKeywords = [ - "prefab", - "sprite", - "texture", - "material", - "skeleton", - "spine", - "atlas", - "font", - "audio", - "data", - ]; - if (assetKeywords.some((k) => lowerKey.includes(k))) { - if (!findNode(value)) { - isAsset = true; - } - } - } - - if (isAsset || isAssetArray) { - // 1. 处理资源引用 (单个或数组) - const uuids = isAssetArray ? value : [value]; - const loadedAssets = []; - let loadedCount = 0; - - if (uuids.length === 0) { - component[key] = []; - return; - } - - uuids.forEach((uuid, idx) => { - if (typeof uuid !== "string" || uuid.length < 10) { - loadedCount++; - return; - } - cc.AssetLibrary.loadAsset(uuid, (err, asset) => { - loadedCount++; - if (!err && asset) { - loadedAssets[idx] = asset; - Editor.log(`[scene-script] 成功为 ${key}[${idx}] 加载资源: ${asset.name}`); - } else { - Editor.warn(`[scene-script] 未能为 ${key}[${idx}] 加载资源 ${uuid}: ${err}`); - } - - if (loadedCount === uuids.length) { - if (isAssetArray) { - // 过滤掉加载失败的 - component[key] = loadedAssets.filter((a) => !!a); - } else { - if (loadedAssets[0]) component[key] = loadedAssets[0]; - } - - // 通知编辑器 UI 更新 - const compIndex = node._components.indexOf(component); - if (compIndex !== -1) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id: node.uuid, - path: `_components.${compIndex}.${key}`, - type: isAssetArray ? "Array" : "Object", - value: isAssetArray ? uuids.map((u) => ({ uuid: u })) : { uuid: value }, - isSubProp: false, - }); - } - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - }); - // 【重要修复】使用 continue 而不是 return,确保处理完 Asset 属性后 - // 还能继续处理后续的普通属性 (如 type, sizeMode 等) - continue; - } else if ( - propertyType && - (propertyType.prototype instanceof cc.Component || - propertyType === cc.Component || - propertyType === cc.Node) - ) { - // 2. 处理节点或组件引用 - const targetNode = findNode(value); - if (targetNode) { - if (propertyType === cc.Node) { - finalValue = targetNode; - } else { - const targetComp = targetNode.getComponent(propertyType); - if (targetComp) { - finalValue = targetComp; - } else { - Editor.warn( - `[scene-script] 在节点 ${targetNode.name} 上找不到组件 ${propertyType.name}`, - ); - } - } - Editor.log(`[scene-script] 已应用 ${key} 的引用: ${targetNode.name}`); - } else if (value && value.length > 20) { - // 如果明确是组件/节点类型但找不到,才报错 - Editor.warn(`[scene-script] 无法解析 ${key} 的目标节点/组件: ${value}`); - } - } else { - // 3. 通用启发式 (找不到类型时的 fallback) - if (typeof value === "string" && value.length > 20) { - const targetNode = findNode(value); - if (targetNode) { - finalValue = targetNode; - Editor.log(`[scene-script] 启发式解析 ${key} 的节点: ${targetNode.name}`); - } else { - // 找不到节点且是 UUID -> 视为资源 - const compIndex = node._components.indexOf(component); - if (compIndex !== -1) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id: node.uuid, - path: `_components.${compIndex}.${key}`, - type: "Object", - value: { uuid: value }, - isSubProp: false, - }); - Editor.log(`[scene-script] 通过 IPC 启发式解析 ${key} 的资源: ${value}`); - } - return; - } - } - } - } catch (e) { - Editor.warn(`[scene-script] 解析属性 ${key} 失败: ${e.message}`); - } - - component[key] = finalValue; - } - } - }; - - if (!node) { - if (event.reply) event.reply(new Error("找不到节点")); - return; - } - - switch (action) { - case "add": - if (!componentType) { - if (event.reply) event.reply(new Error("必须提供组件类型")); - return; - } - - // 【防呆设计】拦截 AI 错误地将 cc.Node 作为组件添加 - if (componentType === "cc.Node" || componentType === "Node") { - if (event.reply) { - event.reply( - new Error( - "【纠错提示】cc.Node 是节点而不是组件,无法被当做组件添加!\n" + - "- 如果你想创建带有名字的子节点,请不要使用 manage_components,而是使用 create-node (或相应的创建节点工具)。\n" + - "- 如果你想修改现有节点的 name 属性,请使用修改节点的 set-property 工具。", - ), - ); - } - return; - } - - try { - // 解析组件类型 - let compClass = null; - if (componentType.startsWith("cc.")) { - const className = componentType.replace("cc.", ""); - compClass = cc[className]; - } else { - // 尝试获取自定义组件 - compClass = cc.js.getClassByName(componentType); - } - - if (!compClass) { - if (event.reply) event.reply(new Error(`找不到组件类型: ${componentType}`)); - return; - } - - // 【防呆设计】确保获取到的类是一个组件 - if (!cc.js.isChildClassOf(compClass, cc.Component)) { - if (event.reply) { - event.reply( - new Error( - `【错误】'${componentType}' 不是一个合法的组件类型(必须继承自 cc.Component)。请确认你的意图。`, - ), - ); - } - return; - } - - // 添加组件 - const component = node.addComponent(compClass); - - // 设置属性 - if (properties) { - applyProperties(component, properties); - } - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - - if (event.reply) event.reply(null, `组件 ${componentType} 已添加`); - } catch (err) { - if (event.reply) event.reply(new Error(`添加组件失败: ${err.message}`)); - } - break; - - case "remove": - if (!componentId) { - if (event.reply) event.reply(new Error("必须提供组件 ID")); - return; - } - - try { - // 查找并移除组件 - let component = null; - if (node._components) { - for (let i = 0; i < node._components.length; i++) { - if (node._components[i].uuid === componentId) { - component = node._components[i]; - break; - } - } - } - - if (component) { - node.removeComponent(component); - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - if (event.reply) event.reply(null, "组件已移除"); - } else { - if (event.reply) event.reply(new Error("找不到组件")); - } - } catch (err) { - if (event.reply) event.reply(new Error(`移除组件失败: ${err.message}`)); - } - break; - - case "update": - // 更新现有组件属性 - if (!componentType) { - // 如果提供了 componentId,可以只用 componentId - // 但 Cocos 2.4 uuid 获取组件比较麻烦,最好还是有 type 或者遍历 - } - - try { - let targetComp = null; - - // 1. 尝试通过 componentId 查找 - if (componentId) { - if (node._components) { - for (let i = 0; i < node._components.length; i++) { - if (node._components[i].uuid === componentId) { - targetComp = node._components[i]; - break; - } - } - } - } - - // 2. 尝试通过 type 查找 - if (!targetComp && componentType) { - let compClass = null; - if (componentType.startsWith("cc.")) { - const className = componentType.replace("cc.", ""); - compClass = cc[className]; - } else { - compClass = cc.js.getClassByName(componentType); - } - if (compClass) { - targetComp = node.getComponent(compClass); - } - } - - if (targetComp) { - if (properties) { - applyProperties(targetComp, properties); - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - if (event.reply) event.reply(null, "组件属性已更新"); - } else { - if (event.reply) event.reply(null, "没有需要更新的属性"); - } - } else { - if (event.reply) - event.reply(new Error(`找不到组件 (类型: ${componentType}, ID: ${componentId})`)); - } - } catch (err) { - if (event.reply) event.reply(new Error(`更新组件失败: ${err.message}`)); - } - break; - - case "get": - try { - const components = node._components.map((c) => { - // 获取组件属性 - const properties = {}; - for (const key in c) { - if (typeof c[key] !== "function" && !key.startsWith("_") && c[key] !== undefined) { - try { - // 安全序列化检查 - const val = c[key]; - if (val === null || val === undefined) { - properties[key] = val; - continue; - } - - // 基础类型是安全的 - if (typeof val !== "object") { - // 【优化】对于超长字符串进行截断 - if (typeof val === "string" && val.length > 200) { - properties[key] = - val.substring(0, 50) + `...[Truncated, total length: ${val.length}]`; - } else { - properties[key] = val; - } - continue; - } - - // 特殊 Cocos 类型 - if (val instanceof cc.ValueType) { - properties[key] = val.toString(); - } else if (val instanceof cc.Asset) { - properties[key] = `资源(${val.name})`; - } else if (val instanceof cc.Node) { - properties[key] = `节点(${val.name})`; - } else if (val instanceof cc.Component) { - properties[key] = `组件(${val.name}<${val.__typename}>)`; - } else { - // 数组和普通对象 - // 【优化】对于超长数组直接截断并提示,防止返回巨大的坐标或点集 - if (Array.isArray(val) && val.length > 10) { - properties[key] = `[Array(${val.length})]`; - continue; - } - - // 尝试转换为纯 JSON 数据以避免 IPC 错误(如包含原生对象/循环引用) - try { - const jsonStr = JSON.stringify(val); - if (jsonStr && jsonStr.length > 500) { - properties[key] = `[Large JSON Object, length: ${jsonStr.length}]`; - } else { - // 确保不传递原始对象引用 - properties[key] = JSON.parse(jsonStr); - } - } catch (e) { - // 如果 JSON 失败(例如循环引用),格式化为字符串 - properties[key] = - `[复杂对象: ${val.constructor ? val.constructor.name : typeof val}]`; - } - } - } catch (e) { - properties[key] = "[Serialization Error]"; - } - } - } - return { - type: cc.js.getClassName(c) || c.constructor.name || "Unknown", - uuid: c.uuid, - properties: properties, - }; - }); - if (event.reply) event.reply(null, components); - } catch (err) { - if (event.reply) event.reply(new Error(`获取组件失败: ${err.message}`)); - } - break; - - default: - if (event.reply) event.reply(new Error(`未知的组件操作类型: ${action}`)); - break; - } - }, - - "get-component-properties": function (component) { - const properties = {}; - - // 遍历组件属性 - for (const key in component) { - if (typeof component[key] !== "function" && !key.startsWith("_") && component[key] !== undefined) { - try { - properties[key] = component[key]; - } catch (e) { - // 忽略无法序列化的属性 - } - } - } - - return properties; - }, - - "instantiate-prefab": function (event, args) { - const { prefabUuid, parentId } = args; - const scene = cc.director.getScene(); - - if (!scene) { - if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); - return; - } - - if (!prefabUuid) { - if (event.reply) event.reply(new Error("必须提供预制体 UUID。")); - return; - } - - // 使用 cc.assetManager.loadAny 通过 UUID 加载 (Cocos 2.4+) - // 如果是旧版,可能需要 cc.loader.load({uuid: ...}),但在 2.4 环境下 assetManager 更推荐 - cc.assetManager.loadAny(prefabUuid, (err, prefab) => { - if (err) { - if (event.reply) event.reply(new Error(`加载预制体失败: ${err.message}`)); - return; - } - - // 实例化预制体 - const instance = cc.instantiate(prefab); - if (!instance) { - if (event.reply) event.reply(new Error("实例化预制体失败")); - return; - } - - // 设置父节点 - let parent = parentId ? cc.engine.getInstanceById(parentId) : scene; - if (parent) { - instance.parent = parent; - - // 通知场景变脏 - Editor.Ipc.sendToMain("scene:dirty"); - - // 通知 UI 刷新 - setTimeout(() => { - Editor.Ipc.sendToAll("scene:node-created", { - uuid: instance.uuid, - parentUuid: parent.uuid, - }); - }, 10); - - if (event.reply) event.reply(null, `预制体实例化成功,UUID: ${instance.uuid}`); - } else { - if (event.reply) event.reply(new Error("找不到父节点")); - } - }); - }, - - /** - * 根据特定条件在场景中搜索节点 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (conditions, recursive) - */ - "find-gameobjects": function (event, args) { - const { conditions, recursive = true } = args; - const result = []; - const scene = cc.director.getScene(); - - function searchNode(node) { - if ( - !node || - !node.name || - (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) - ) { - return; - } - - // 检查节点是否满足条件 - let match = true; - - if (conditions.name && !node.name.includes(conditions.name)) { - match = false; - } - - if (conditions.component) { - let hasComponent = false; - try { - if (conditions.component.startsWith("cc.")) { - const className = conditions.component.replace("cc.", ""); - hasComponent = node.getComponent(cc[className]) !== null; - } else { - hasComponent = node.getComponent(conditions.component) !== null; - } - } catch (e) { - hasComponent = false; - } - if (!hasComponent) { - match = false; - } - } - - if (conditions.active !== undefined && node.active !== conditions.active) { - match = false; - } - - if (match) { - const comps = node._components || []; - result.push({ - uuid: node.uuid, - name: node.name, - active: node.active, - components: comps.map((c) => { - const parts = (cc.js.getClassName(c) || "").split("."); - return parts[parts.length - 1]; // 简化的组件名 - }), - childrenCount: node.childrenCount, - }); - } - - // 递归搜索子节点 - if (recursive) { - for (let i = 0; i < node.childrenCount; i++) { - searchNode(node.children[i]); - } - } - } - - // 从场景根节点开始搜索 - if (scene) { - searchNode(scene); - } - - if (event.reply) { - event.reply(null, result); - } - }, - - /** - * 删除指定的场景节点 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (uuid) - */ - "delete-node": function (event, args) { - const { uuid } = args; - const node = findNode(uuid); - if (node) { - const parent = node.parent; - node.destroy(); - Editor.Ipc.sendToMain("scene:dirty"); - // 延迟通知以确保节点已被移除 - setTimeout(() => { - if (parent) { - Editor.Ipc.sendToAll("scene:node-changed", { uuid: parent.uuid }); - } - // 广播节点删除事件 - Editor.Ipc.sendToAll("scene:node-deleted", { uuid: uuid }); - }, 10); - - if (event.reply) event.reply(null, `节点 ${uuid} 已删除`); - } else { - if (event.reply) event.reply(new Error(`找不到节点: ${uuid}`)); - } - }, - - /** - * 管理高效的全场景特效 (粒子系统) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (action, nodeId, properties, name, parentId) - */ - "manage-vfx": function (event, args) { - const { action, nodeId, properties, name, parentId } = args; - const scene = cc.director.getScene(); - - const applyParticleProperties = (particleSystem, props) => { - if (!props) return; - - if (props.duration !== undefined) particleSystem.duration = props.duration; - if (props.emissionRate !== undefined) particleSystem.emissionRate = props.emissionRate; - if (props.life !== undefined) particleSystem.life = props.life; - if (props.lifeVar !== undefined) particleSystem.lifeVar = props.lifeVar; - - // 【关键修复】启用自定义属性,否则属性修改可能不生效 - particleSystem.custom = true; - - if (props.startColor) particleSystem.startColor = new cc.Color().fromHEX(props.startColor); - if (props.endColor) particleSystem.endColor = new cc.Color().fromHEX(props.endColor); - - if (props.startSize !== undefined) particleSystem.startSize = props.startSize; - if (props.endSize !== undefined) particleSystem.endSize = props.endSize; - - if (props.speed !== undefined) particleSystem.speed = props.speed; - if (props.angle !== undefined) particleSystem.angle = props.angle; - - if (props.gravity) { - if (props.gravity.x !== undefined) particleSystem.gravity.x = props.gravity.x; - if (props.gravity.y !== undefined) particleSystem.gravity.y = props.gravity.y; - } - - // 处理文件/纹理加载 - if (props.file) { - // main.js 已经将 db:// 路径转换为 UUID - // 如果用户直接传递 URL (http/https) 或其他格式,cc.assetManager.loadAny 也能处理 - const uuid = props.file; - cc.assetManager.loadAny(uuid, (err, asset) => { - if (!err) { - if (asset instanceof cc.ParticleAsset) { - particleSystem.file = asset; - } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { - particleSystem.texture = asset; - } - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - } else if (!particleSystem.texture && !particleSystem.file && args.defaultSpriteUuid) { - // 【关键修复】如果没有纹理,加载默认纹理 (UUID 由 main.js 传入) - Editor.log(`[mcp-bridge] Loading default texture with UUID: ${args.defaultSpriteUuid}`); - cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { - if (err) { - Editor.error(`[mcp-bridge] Failed to load default texture: ${err.message}`); - } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { - Editor.log(`[mcp-bridge] Default texture loaded successfully.`); - particleSystem.texture = asset; - Editor.Ipc.sendToMain("scene:dirty"); - } else { - Editor.warn(`[mcp-bridge] Loaded asset is not a texture: ${asset}`); - } - }); - } - }; - - if (action === "create") { - let newNode = new cc.Node(name || "New Particle"); - let particleSystem = newNode.addComponent(cc.ParticleSystem); - - // 设置默认值 - particleSystem.resetSystem(); - particleSystem.custom = true; // 确保新创建的也是 custom 模式 - - applyParticleProperties(particleSystem, properties); - - let parent = parentId ? cc.engine.getInstanceById(parentId) : scene; - if (parent) { - newNode.parent = parent; - Editor.Ipc.sendToMain("scene:dirty"); - setTimeout(() => { - Editor.Ipc.sendToAll("scene:node-created", { - uuid: newNode.uuid, - parentUuid: parent.uuid, - }); - }, 10); - if (event.reply) event.reply(null, newNode.uuid); - } else { - if (event.reply) event.reply(new Error("找不到父节点")); - } - } else if (action === "update") { - let node = findNode(nodeId); - if (node) { - let particleSystem = node.getComponent(cc.ParticleSystem); - if (!particleSystem) { - // 如果没有组件,自动添加 - particleSystem = node.addComponent(cc.ParticleSystem); - } - - applyParticleProperties(particleSystem, properties); - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - if (event.reply) event.reply(null, "特效已更新"); - } else { - if (event.reply) event.reply(new Error("找不到节点")); - } - } else if (action === "get_info") { - let node = findNode(nodeId); - if (node) { - let ps = node.getComponent(cc.ParticleSystem); - if (ps) { - const info = { - duration: ps.duration, - emissionRate: ps.emissionRate, - life: ps.life, - lifeVar: ps.lifeVar, - startColor: ps.startColor.toHEX("#RRGGBB"), - endColor: ps.endColor.toHEX("#RRGGBB"), - startSize: ps.startSize, - endSize: ps.endSize, - speed: ps.speed, - angle: ps.angle, - gravity: { x: ps.gravity.x, y: ps.gravity.y }, - file: ps.file ? ps.file.name : null, - }; - if (event.reply) event.reply(null, info); - } else { - if (event.reply) event.reply(null, { hasParticleSystem: false }); - } - } else { - if (event.reply) event.reply(new Error("找不到节点")); - } - } else { - if (event.reply) event.reply(new Error(`未知的特效操作类型: ${action}`)); - } - }, - - /** - * 控制节点的动画组件 (播放、暂停、停止等) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (action, nodeId, clipName) - */ - "manage-animation": function (event, args) { - const { action, nodeId, clipName } = args; - const node = findNode(nodeId); - - if (!node) { - if (event.reply) event.reply(new Error(`找不到节点: ${nodeId}`)); - return; - } - - const anim = node.getComponent(cc.Animation); - if (!anim) { - if (event.reply) event.reply(new Error(`在节点 ${nodeId} 上找不到 Animation 组件`)); - return; - } - - switch (action) { - case "get_list": - const clips = anim.getClips(); - const clipList = clips.map((c) => ({ - name: c.name, - duration: c.duration, - sample: c.sample, - speed: c.speed, - wrapMode: c.wrapMode, - })); - if (event.reply) event.reply(null, clipList); - break; - - case "get_info": - const currentClip = anim.currentClip; - let isPlaying = false; - // [安全修复] 只有在有当前 Clip 时才获取状态,避免 Animation 组件无 Clip 时的崩溃 - if (currentClip) { - const state = anim.getAnimationState(currentClip.name); - if (state) { - isPlaying = state.isPlaying; - } - } - const info = { - currentClip: currentClip ? currentClip.name : null, - clips: anim.getClips().map((c) => c.name), - playOnLoad: anim.playOnLoad, - isPlaying: isPlaying, - }; - if (event.reply) event.reply(null, info); - break; - - case "play": - if (!clipName) { - anim.play(); - if (event.reply) event.reply(null, "正在播放默认动画剪辑"); - } else { - anim.play(clipName); - if (event.reply) event.reply(null, `正在播放动画剪辑: ${clipName}`); - } - break; - - case "stop": - anim.stop(); - if (event.reply) event.reply(null, "动画已停止"); - break; - - case "pause": - anim.pause(); - if (event.reply) event.reply(null, "动画已暂停"); - break; - - case "resume": - anim.resume(); - if (event.reply) event.reply(null, "动画已恢复播放"); - break; - - default: - if (event.reply) event.reply(new Error(`未知的动画操作类型: ${action}`)); - break; - } - }, + /** + * 修改节点的基础属性 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (id, path, value) + */ + "set-property": function (event, args) { + const { id, path, value } = args; + + // 1. 获取节点 + let node = findNode(id); + + if (node) { + // 2. 修改属性 + if (path === "name") { + node.name = value; + } else { + node[path] = value; + } + + // 3. 【解决报错的关键】告诉编辑器场景变脏了(需要保存) + // 在场景进程中,我们发送 IPC 给主进程 + Editor.Ipc.sendToMain("scene:dirty"); + + // 4. 【额外补丁】通知层级管理器(Hierarchy)同步更新节点名称 + // 否则你修改了名字,层级管理器可能还是显示旧名字 + Editor.Ipc.sendToAll("scene:node-changed", { + uuid: id, + }); + + if (event.reply) { + event.reply(null, `节点 ${id} 已更新为 ${value}`); + } + } else { + if (event.reply) { + event.reply(new Error("场景脚本:找不到节点 " + id)); + } + } + }, + /** + * 获取当前场景的完整层级树 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (nodeId, depth, includeDetails) + */ + "get-hierarchy": function (event, args) { + const { nodeId = null, depth = 2, includeDetails = false } = args || {}; + const scene = cc.director.getScene(); + + let rootNode = scene; + if (nodeId) { + rootNode = findNode(nodeId); + if (!rootNode) { + if (event.reply) event.reply(new Error(`找不到指定的起始节点: ${nodeId}`)); + return; + } + } + + /** + * 递归遍历并序列化节点树 + * @param {cc.Node} node 目标节点 + * @param {number} currentDepth 当前深度 + * @returns {Object|null} 序列化后的节点数据 + */ + function dumpNodes(node, currentDepth) { + // 【优化】跳过编辑器内部的私有节点,减少数据量 + if ( + !node || + !node.name || + (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) + ) { + return null; + } + + let nodeData = { + name: node.name, + uuid: node.uuid, + childrenCount: node.childrenCount, + }; + + const comps = node._components || []; + + // 根据是否需要详情来决定附加哪些数据以节省 Token + if (includeDetails) { + nodeData.active = node.active; + nodeData.position = { x: Math.round(node.x), y: Math.round(node.y) }; + nodeData.scale = { x: node.scaleX, y: node.scaleY }; + nodeData.size = { width: node.width, height: node.height }; + nodeData.components = comps.map((c) => cc.js.getClassName(c)); + } else { + // 简略模式下如果存在组件,至少提供一个极简列表让 AI 知道节点的作用 + if (comps.length > 0) { + nodeData.components = comps.map((c) => { + const parts = (cc.js.getClassName(c) || "").split("."); + return parts[parts.length - 1]; // 只取类名,例如 cc.Sprite -> Sprite + }); + } + } + + // 如果未超出深度限制,继续递归子树 + if (currentDepth < depth && node.childrenCount > 0) { + nodeData.children = []; + for (let i = 0; i < node.childrenCount; i++) { + let childData = dumpNodes(node.children[i], currentDepth + 1); + if (childData) nodeData.children.push(childData); + } + } + + return nodeData; + } + + const hierarchy = dumpNodes(rootNode, 0); + if (event.reply) event.reply(null, hierarchy); + }, + + /** + * 批量更新节点的变换信息 (坐标、缩放、颜色) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (id, x, y, scaleX, scaleY, color) + */ + "update-node-transform": function (event, args) { + const { id, x, y, scaleX, scaleY, color } = args; + Editor.log(`[scene-script] update-node-transform called for ${id} with args: ${JSON.stringify(args)}`); + + let node = findNode(id); + + if (node) { + Editor.log(`[scene-script] 找到节点: ${node.name}, 当前坐标: (${node.x}, ${node.y})`); + + // 使用 scene:set-property 实现支持 Undo 的属性修改 + // 注意:IPC 消息需要发送到 'scene' 面板 + if (x !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "x", + type: "Number", + value: Number(x), + }); + } + if (y !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "y", + type: "Number", + value: Number(y), + }); + } + if (args.width !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "width", + type: "Number", + value: Number(args.width), + }); + } + if (args.height !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "height", + type: "Number", + value: Number(args.height), + }); + } + if (scaleX !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "scaleX", + type: "Number", + value: Number(scaleX), + }); + } + if (scaleY !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "scaleY", + type: "Number", + value: Number(scaleY), + }); + } + if (color) { + const c = new cc.Color().fromHEX(color); + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id: id, + path: "color", + type: "Color", + value: { r: c.r, g: c.g, b: c.b, a: c.a }, + }); + } + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: id }); + + Editor.log(`[scene-script] 更新完成。新坐标: (${node.x}, ${node.y})`); + if (event.reply) event.reply(null, "变换信息已更新"); + } else { + if (event.reply) event.reply(new Error(`找不到节点 (UUID: ${id})`)); + } + }, + /** + * 在场景中创建新节点 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (name, parentId, type) + */ + "create-node": function (event, args) { + const { name, parentId, type } = args; + const scene = cc.director.getScene(); + if (!scene) { + if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); + return; + } + + let newNode = null; + + // 特殊处理:如果是创建 Canvas,自动设置好适配 + if (type === "canvas" || name === "Canvas") { + newNode = new cc.Node("Canvas"); + let canvas = newNode.addComponent(cc.Canvas); + newNode.addComponent(cc.Widget); + // 设置默认设计分辨率 + canvas.designResolution = cc.size(960, 640); + canvas.fitHeight = true; + // 自动在 Canvas 下创建一个 Camera + let camNode = new cc.Node("Main Camera"); + camNode.addComponent(cc.Camera); + camNode.parent = newNode; + } else if (type === "sprite") { + newNode = new cc.Node(name || "新建精灵节点"); + let sprite = newNode.addComponent(cc.Sprite); + // 设置为 CUSTOM 模式 + sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; + // 为精灵设置默认尺寸 + newNode.width = 100; + newNode.height = 100; + + // 加载引擎默认图做占位 + if (args.defaultSpriteUuid) { + cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { + if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { + sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + } + } else if (type === "button") { + newNode = new cc.Node(name || "新建按钮节点"); + let sprite = newNode.addComponent(cc.Sprite); + newNode.addComponent(cc.Button); + + // 设置为 CUSTOM 模式并应用按钮专用尺寸 + sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; + newNode.width = 150; + newNode.height = 50; + + // 设置稍暗的背景颜色 (#A0A0A0),以便于看清其上的文字 + newNode.color = new cc.Color(160, 160, 160); + + // 加载引擎默认图 + if (args.defaultSpriteUuid) { + cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { + if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { + sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + } + } else if (type === "label") { + newNode = new cc.Node(name || "新建文本节点"); + let l = newNode.addComponent(cc.Label); + l.string = "新文本"; + newNode.width = 120; + newNode.height = 40; + } else { + newNode = new cc.Node(name || "新建节点"); + } + + // 设置层级 + let parent = parentId ? findNode(parentId) : scene; + if (parent) { + newNode.parent = parent; + + // 【优化】通知主进程场景变脏 + Editor.Ipc.sendToMain("scene:dirty"); + + // 【关键】使用 setTimeout 延迟通知 UI 刷新,让出主循环 + setTimeout(() => { + Editor.Ipc.sendToAll("scene:node-created", { + uuid: newNode.uuid, + parentUuid: parent.uuid, + }); + }, 10); + + if (event.reply) event.reply(null, newNode.uuid); + } else { + if (event.reply) event.reply(new Error(`无法创建节点:找不到父节点 ${parentId}`)); + } + }, + + /** + * 管理节点上的组件 (添加、移除、更新属性) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (nodeId, action, componentType, componentId, properties) + */ + "manage-components": function (event, args) { + let { nodeId, action, operation, componentType, componentId, properties } = args; + // 兼容 AI 幻觉带来的传参错误 + action = action || operation; + + let node = findNode(nodeId); + + /** + * 辅助函数:应用属性并智能解析 (支持 UUID 资源与节点引用) + * @param {cc.Component} component 目标组件实例 + * @param {Object} props 待更新的属性键值对 + */ + const applyProperties = (component, props) => { + if (!props) return; + // 尝试获取组件类的属性定义 + const compClass = component.constructor; + + for (const [key, value] of Object.entries(props)) { + // 【核心修复】专门处理各类事件属性 (ClickEvents, ScrollEvents 等) + const isEventProp = + Array.isArray(value) && (key.toLowerCase().endsWith("events") || key === "clickEvents"); + + if (isEventProp) { + const eventHandlers = []; + for (const item of value) { + if (typeof item === "object" && (item.target || item.component || item.handler)) { + const handler = new cc.Component.EventHandler(); + + // 解析 Target Node + if (item.target) { + let targetNode = findNode(item.target); + if (!targetNode && item.target instanceof cc.Node) { + targetNode = item.target; + } + + if (targetNode) { + handler.target = targetNode; + Editor.log(`[scene-script] Resolved event target: ${targetNode.name}`); + } + } + + if (item.component) handler.component = item.component; + if (item.handler) handler.handler = item.handler; + if (item.customEventData !== undefined) + handler.customEventData = String(item.customEventData); + + eventHandlers.push(handler); + } else { + // 如果不是对象,原样保留 + eventHandlers.push(item); + } + } + component[key] = eventHandlers; + continue; // 处理完事件数组,跳出本次循环 + } + + // 检查属性是否存在 + if (component[key] !== undefined) { + let finalValue = value; + + // 【核心逻辑】智能类型识别与赋值 + try { + const attrs = (cc.Class.Attr.getClassAttrs && cc.Class.Attr.getClassAttrs(compClass)) || {}; + let propertyType = attrs[key] ? attrs[key].type : null; + if (!propertyType && attrs[key + "$_$ctor"]) { + propertyType = attrs[key + "$_$ctor"]; + } + + let isAsset = + propertyType && + (propertyType.prototype instanceof cc.Asset || + propertyType === cc.Asset || + propertyType === cc.Prefab || + propertyType === cc.SpriteFrame); + let isAssetArray = + Array.isArray(value) && (key === "materials" || key.toLowerCase().includes("assets")); + + // 启发式:如果属性名包含 prefab/sprite/texture 等,且值为 UUID 且不是节点 + if (!isAsset && !isAssetArray && typeof value === "string" && value.length > 20) { + const lowerKey = key.toLowerCase(); + const assetKeywords = [ + "prefab", + "sprite", + "texture", + "material", + "skeleton", + "spine", + "atlas", + "font", + "audio", + "data", + ]; + if (assetKeywords.some((k) => lowerKey.includes(k))) { + if (!findNode(value)) { + isAsset = true; + } + } + } + + if (isAsset || isAssetArray) { + // 1. 处理资源引用 (单个或数组) + const uuids = isAssetArray ? value : [value]; + const loadedAssets = []; + let loadedCount = 0; + + if (uuids.length === 0) { + component[key] = []; + return; + } + + uuids.forEach((uuid, idx) => { + if (typeof uuid !== "string" || uuid.length < 10) { + loadedCount++; + return; + } + cc.AssetLibrary.loadAsset(uuid, (err, asset) => { + loadedCount++; + if (!err && asset) { + loadedAssets[idx] = asset; + Editor.log(`[scene-script] 成功为 ${key}[${idx}] 加载资源: ${asset.name}`); + } else { + Editor.warn(`[scene-script] 未能为 ${key}[${idx}] 加载资源 ${uuid}: ${err}`); + } + + if (loadedCount === uuids.length) { + if (isAssetArray) { + // 过滤掉加载失败的 + component[key] = loadedAssets.filter((a) => !!a); + } else { + if (loadedAssets[0]) component[key] = loadedAssets[0]; + } + + // 通知编辑器 UI 更新 + const compIndex = node._components.indexOf(component); + if (compIndex !== -1) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id: node.uuid, + path: `_components.${compIndex}.${key}`, + type: isAssetArray ? "Array" : "Object", + value: isAssetArray ? uuids.map((u) => ({ uuid: u })) : { uuid: value }, + isSubProp: false, + }); + } + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + }); + // 【重要修复】使用 continue 而不是 return,确保处理完 Asset 属性后 + // 还能继续处理后续的普通属性 (如 type, sizeMode 等) + continue; + } else if ( + propertyType && + (propertyType.prototype instanceof cc.Component || + propertyType === cc.Component || + propertyType === cc.Node) + ) { + // 2. 处理节点或组件引用 + const targetNode = findNode(value); + if (targetNode) { + if (propertyType === cc.Node) { + finalValue = targetNode; + } else { + const targetComp = targetNode.getComponent(propertyType); + if (targetComp) { + finalValue = targetComp; + } else { + Editor.warn( + `[scene-script] 在节点 ${targetNode.name} 上找不到组件 ${propertyType.name}`, + ); + } + } + Editor.log(`[scene-script] 已应用 ${key} 的引用: ${targetNode.name}`); + } else if (value && value.length > 20) { + // 如果明确是组件/节点类型但找不到,才报错 + Editor.warn(`[scene-script] 无法解析 ${key} 的目标节点/组件: ${value}`); + } + } else { + // 3. 通用启发式 (找不到类型时的 fallback) + if (typeof value === "string" && value.length > 20) { + const targetNode = findNode(value); + if (targetNode) { + finalValue = targetNode; + Editor.log(`[scene-script] 启发式解析 ${key} 的节点: ${targetNode.name}`); + } else { + // 找不到节点且是 UUID -> 视为资源 + const compIndex = node._components.indexOf(component); + if (compIndex !== -1) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id: node.uuid, + path: `_components.${compIndex}.${key}`, + type: "Object", + value: { uuid: value }, + isSubProp: false, + }); + Editor.log(`[scene-script] 通过 IPC 启发式解析 ${key} 的资源: ${value}`); + } + return; + } + } + } + } catch (e) { + Editor.warn(`[scene-script] 解析属性 ${key} 失败: ${e.message}`); + } + + component[key] = finalValue; + } + } + }; + + if (!node) { + if (event.reply) event.reply(new Error(`找不到节点 (UUID: ${nodeId}, action: ${action})`)); + return; + } + + switch (action) { + case "add": + if (!componentType) { + if (event.reply) event.reply(new Error(`必须提供组件类型 (nodeId: ${nodeId}, action: ${action})`)); + return; + } + + // 【防呆设计】拦截 AI 错误地将 cc.Node 作为组件添加 + if (componentType === "cc.Node" || componentType === "Node") { + if (event.reply) { + event.reply( + new Error( + "【纠错提示】cc.Node 是节点而不是组件,无法被当做组件添加!\n" + + "- 如果你想创建带有名字的子节点,请不要使用 manage_components,而是使用 create-node (或相应的创建节点工具)。\n" + + "- 如果你想修改现有节点的 name 属性,请使用修改节点的 set-property 工具。", + ), + ); + } + return; + } + + try { + // 解析组件类型 + let compClass = null; + if (componentType.startsWith("cc.")) { + const className = componentType.replace("cc.", ""); + compClass = cc[className]; + } else { + // 尝试获取自定义组件 + compClass = cc.js.getClassByName(componentType); + } + + if (!compClass) { + if (event.reply) event.reply(new Error(`找不到组件类型: ${componentType}`)); + return; + } + + // 【防呆设计】确保获取到的类是一个组件 + if (!cc.js.isChildClassOf(compClass, cc.Component)) { + if (event.reply) { + event.reply( + new Error( + `【错误】'${componentType}' 不是一个合法的组件类型(必须继承自 cc.Component)。请确认你的意图。`, + ), + ); + } + return; + } + + // 添加组件 + const component = node.addComponent(compClass); + + // 设置属性 + if (properties) { + applyProperties(component, properties); + } + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + + if (event.reply) event.reply(null, `组件 ${componentType} 已添加`); + } catch (err) { + if (event.reply) event.reply(new Error(`添加组件失败: ${err.message}`)); + } + break; + + case "remove": + if (!componentId) { + if (event.reply) event.reply(new Error(`必须提供组件 ID (nodeId: ${nodeId})`)); + return; + } + + try { + // 查找并移除组件 + let component = null; + if (node._components) { + for (let i = 0; i < node._components.length; i++) { + if (node._components[i].uuid === componentId) { + component = node._components[i]; + break; + } + } + } + + if (component) { + node.removeComponent(component); + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + if (event.reply) event.reply(null, "组件已移除"); + } else { + if (event.reply) + event.reply(new Error(`找不到组件 (nodeId: ${nodeId}, componentId: ${componentId})`)); + } + } catch (err) { + if (event.reply) event.reply(new Error(`移除组件失败: ${err.message}`)); + } + break; + + case "update": + // 更新现有组件属性 + if (!componentType) { + // 如果提供了 componentId,可以只用 componentId + // 但 Cocos 2.4 uuid 获取组件比较麻烦,最好还是有 type 或者遍历 + } + + try { + let targetComp = null; + + // 1. 尝试通过 componentId 查找 + if (componentId) { + if (node._components) { + for (let i = 0; i < node._components.length; i++) { + if (node._components[i].uuid === componentId) { + targetComp = node._components[i]; + break; + } + } + } + } + + // 2. 尝试通过 type 查找 + if (!targetComp && componentType) { + let compClass = null; + if (componentType.startsWith("cc.")) { + const className = componentType.replace("cc.", ""); + compClass = cc[className]; + } else { + compClass = cc.js.getClassByName(componentType); + } + if (compClass) { + targetComp = node.getComponent(compClass); + } + } + + if (targetComp) { + if (properties) { + applyProperties(targetComp, properties); + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + if (event.reply) event.reply(null, "组件属性已更新"); + } else { + if (event.reply) event.reply(null, "没有需要更新的属性"); + } + } else { + if (event.reply) + event.reply(new Error(`找不到组件 (类型: ${componentType}, ID: ${componentId})`)); + } + } catch (err) { + if (event.reply) event.reply(new Error(`更新组件失败: ${err.message}`)); + } + break; + + case "get": + try { + const components = node._components.map((c) => { + // 获取组件属性 + const properties = {}; + for (const key in c) { + if (typeof c[key] !== "function" && !key.startsWith("_") && c[key] !== undefined) { + try { + // 安全序列化检查 + const val = c[key]; + if (val === null || val === undefined) { + properties[key] = val; + continue; + } + + // 基础类型是安全的 + if (typeof val !== "object") { + // 【优化】对于超长字符串进行截断 + if (typeof val === "string" && val.length > 200) { + properties[key] = + val.substring(0, 50) + `...[Truncated, total length: ${val.length}]`; + } else { + properties[key] = val; + } + continue; + } + + // 特殊 Cocos 类型 + if (val instanceof cc.ValueType) { + properties[key] = val.toString(); + } else if (val instanceof cc.Asset) { + properties[key] = `资源(${val.name})`; + } else if (val instanceof cc.Node) { + properties[key] = `节点(${val.name})`; + } else if (val instanceof cc.Component) { + properties[key] = `组件(${val.name}<${val.__typename}>)`; + } else { + // 数组和普通对象 + // 【优化】对于超长数组直接截断并提示,防止返回巨大的坐标或点集 + if (Array.isArray(val) && val.length > 10) { + properties[key] = `[Array(${val.length})]`; + continue; + } + + // 尝试转换为纯 JSON 数据以避免 IPC 错误(如包含原生对象/循环引用) + try { + const jsonStr = JSON.stringify(val); + if (jsonStr && jsonStr.length > 500) { + properties[key] = `[Large JSON Object, length: ${jsonStr.length}]`; + } else { + // 确保不传递原始对象引用 + properties[key] = JSON.parse(jsonStr); + } + } catch (e) { + // 如果 JSON 失败(例如循环引用),格式化为字符串 + properties[key] = + `[复杂对象: ${val.constructor ? val.constructor.name : typeof val}]`; + } + } + } catch (e) { + properties[key] = "[Serialization Error]"; + } + } + } + return { + type: cc.js.getClassName(c) || c.constructor.name || "Unknown", + uuid: c.uuid, + properties: properties, + }; + }); + if (event.reply) event.reply(null, components); + } catch (err) { + if (event.reply) event.reply(new Error(`获取组件失败: ${err.message}`)); + } + break; + + default: + if (event.reply) event.reply(new Error(`未知的组件操作类型: ${action}`)); + break; + } + }, + + "get-component-properties": function (component) { + const properties = {}; + + // 遍历组件属性 + for (const key in component) { + if (typeof component[key] !== "function" && !key.startsWith("_") && component[key] !== undefined) { + try { + properties[key] = component[key]; + } catch (e) { + // 忽略无法序列化的属性 + } + } + } + + return properties; + }, + + "instantiate-prefab": function (event, args) { + const { prefabUuid, parentId } = args; + const scene = cc.director.getScene(); + + if (!scene) { + if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); + return; + } + + if (!prefabUuid) { + if (event.reply) event.reply(new Error("必须提供预制体 UUID。")); + return; + } + + // 使用 cc.assetManager.loadAny 通过 UUID 加载 (Cocos 2.4+) + // 如果是旧版,可能需要 cc.loader.load({uuid: ...}),但在 2.4 环境下 assetManager 更推荐 + cc.assetManager.loadAny(prefabUuid, (err, prefab) => { + if (err) { + if (event.reply) event.reply(new Error(`加载预制体失败: ${err.message}`)); + return; + } + + // 实例化预制体 + const instance = cc.instantiate(prefab); + if (!instance) { + if (event.reply) event.reply(new Error("实例化预制体失败")); + return; + } + + // 设置父节点 + let parent = parentId ? cc.engine.getInstanceById(parentId) : scene; + if (parent) { + instance.parent = parent; + + // 通知场景变脏 + Editor.Ipc.sendToMain("scene:dirty"); + + // 通知 UI 刷新 + setTimeout(() => { + Editor.Ipc.sendToAll("scene:node-created", { + uuid: instance.uuid, + parentUuid: parent.uuid, + }); + }, 10); + + if (event.reply) event.reply(null, `预制体实例化成功,UUID: ${instance.uuid}`); + } else { + if (event.reply) event.reply(new Error("找不到父节点")); + } + }); + }, + + /** + * 根据特定条件在场景中搜索节点 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (conditions, recursive) + */ + "find-gameobjects": function (event, args) { + const { conditions, recursive = true } = args; + const result = []; + const scene = cc.director.getScene(); + + function searchNode(node) { + if ( + !node || + !node.name || + (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) + ) { + return; + } + + // 检查节点是否满足条件 + let match = true; + + if (conditions.name && !node.name.includes(conditions.name)) { + match = false; + } + + if (conditions.component) { + let hasComponent = false; + try { + if (conditions.component.startsWith("cc.")) { + const className = conditions.component.replace("cc.", ""); + hasComponent = node.getComponent(cc[className]) !== null; + } else { + hasComponent = node.getComponent(conditions.component) !== null; + } + } catch (e) { + hasComponent = false; + } + if (!hasComponent) { + match = false; + } + } + + if (conditions.active !== undefined && node.active !== conditions.active) { + match = false; + } + + if (match) { + const comps = node._components || []; + result.push({ + uuid: node.uuid, + name: node.name, + active: node.active, + components: comps.map((c) => { + const parts = (cc.js.getClassName(c) || "").split("."); + return parts[parts.length - 1]; // 简化的组件名 + }), + childrenCount: node.childrenCount, + }); + } + + // 递归搜索子节点 + if (recursive) { + for (let i = 0; i < node.childrenCount; i++) { + searchNode(node.children[i]); + } + } + } + + // 从场景根节点开始搜索 + if (scene) { + searchNode(scene); + } + + if (event.reply) { + event.reply(null, result); + } + }, + + /** + * 删除指定的场景节点 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (uuid) + */ + "delete-node": function (event, args) { + const { uuid } = args; + const node = findNode(uuid); + if (node) { + const parent = node.parent; + node.destroy(); + Editor.Ipc.sendToMain("scene:dirty"); + // 延迟通知以确保节点已被移除 + setTimeout(() => { + if (parent) { + Editor.Ipc.sendToAll("scene:node-changed", { uuid: parent.uuid }); + } + // 广播节点删除事件 + Editor.Ipc.sendToAll("scene:node-deleted", { uuid: uuid }); + }, 10); + + if (event.reply) event.reply(null, `节点 ${uuid} 已删除`); + } else { + if (event.reply) event.reply(new Error(`找不到节点: ${uuid}`)); + } + }, + + /** + * 管理高效的全场景特效 (粒子系统) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (action, nodeId, properties, name, parentId) + */ + "manage-vfx": function (event, args) { + const { action, nodeId, properties, name, parentId } = args; + const scene = cc.director.getScene(); + + const applyParticleProperties = (particleSystem, props) => { + if (!props) return; + + if (props.duration !== undefined) particleSystem.duration = props.duration; + if (props.emissionRate !== undefined) particleSystem.emissionRate = props.emissionRate; + if (props.life !== undefined) particleSystem.life = props.life; + if (props.lifeVar !== undefined) particleSystem.lifeVar = props.lifeVar; + + // 【关键修复】启用自定义属性,否则属性修改可能不生效 + particleSystem.custom = true; + + if (props.startColor) particleSystem.startColor = new cc.Color().fromHEX(props.startColor); + if (props.endColor) particleSystem.endColor = new cc.Color().fromHEX(props.endColor); + + if (props.startSize !== undefined) particleSystem.startSize = props.startSize; + if (props.endSize !== undefined) particleSystem.endSize = props.endSize; + + if (props.speed !== undefined) particleSystem.speed = props.speed; + if (props.angle !== undefined) particleSystem.angle = props.angle; + + if (props.gravity) { + if (props.gravity.x !== undefined) particleSystem.gravity.x = props.gravity.x; + if (props.gravity.y !== undefined) particleSystem.gravity.y = props.gravity.y; + } + + // 处理文件/纹理加载 + if (props.file) { + // main.js 已经将 db:// 路径转换为 UUID + // 如果用户直接传递 URL (http/https) 或其他格式,cc.assetManager.loadAny 也能处理 + const uuid = props.file; + cc.assetManager.loadAny(uuid, (err, asset) => { + if (!err) { + if (asset instanceof cc.ParticleAsset) { + particleSystem.file = asset; + } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { + particleSystem.texture = asset; + } + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + } else if (!particleSystem.texture && !particleSystem.file && args.defaultSpriteUuid) { + // 【关键修复】如果没有纹理,加载默认纹理 (UUID 由 main.js 传入) + Editor.log(`[mcp-bridge] Loading default texture with UUID: ${args.defaultSpriteUuid}`); + cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { + if (err) { + Editor.error(`[mcp-bridge] Failed to load default texture: ${err.message}`); + } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { + Editor.log(`[mcp-bridge] Default texture loaded successfully.`); + particleSystem.texture = asset; + Editor.Ipc.sendToMain("scene:dirty"); + } else { + Editor.warn(`[mcp-bridge] Loaded asset is not a texture: ${asset}`); + } + }); + } + }; + + if (action === "create") { + let newNode = new cc.Node(name || "New Particle"); + let particleSystem = newNode.addComponent(cc.ParticleSystem); + + // 设置默认值 + particleSystem.resetSystem(); + particleSystem.custom = true; // 确保新创建的也是 custom 模式 + + applyParticleProperties(particleSystem, properties); + + let parent = parentId ? cc.engine.getInstanceById(parentId) : scene; + if (parent) { + newNode.parent = parent; + Editor.Ipc.sendToMain("scene:dirty"); + setTimeout(() => { + Editor.Ipc.sendToAll("scene:node-created", { + uuid: newNode.uuid, + parentUuid: parent.uuid, + }); + }, 10); + if (event.reply) event.reply(null, newNode.uuid); + } else { + if (event.reply) event.reply(new Error("找不到父节点")); + } + } else if (action === "update") { + let node = findNode(nodeId); + if (node) { + let particleSystem = node.getComponent(cc.ParticleSystem); + if (!particleSystem) { + // 如果没有组件,自动添加 + particleSystem = node.addComponent(cc.ParticleSystem); + } + + applyParticleProperties(particleSystem, properties); + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + if (event.reply) event.reply(null, "特效已更新"); + } else { + if (event.reply) event.reply(new Error("找不到节点")); + } + } else if (action === "get_info") { + let node = findNode(nodeId); + if (node) { + let ps = node.getComponent(cc.ParticleSystem); + if (ps) { + const info = { + duration: ps.duration, + emissionRate: ps.emissionRate, + life: ps.life, + lifeVar: ps.lifeVar, + startColor: ps.startColor.toHEX("#RRGGBB"), + endColor: ps.endColor.toHEX("#RRGGBB"), + startSize: ps.startSize, + endSize: ps.endSize, + speed: ps.speed, + angle: ps.angle, + gravity: { x: ps.gravity.x, y: ps.gravity.y }, + file: ps.file ? ps.file.name : null, + }; + if (event.reply) event.reply(null, info); + } else { + if (event.reply) event.reply(null, { hasParticleSystem: false }); + } + } else { + if (event.reply) event.reply(new Error("找不到节点")); + } + } else { + if (event.reply) event.reply(new Error(`未知的特效操作类型: ${action}`)); + } + }, + + /** + * 控制节点的动画组件 (播放、暂停、停止等) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (action, nodeId, clipName) + */ + "manage-animation": function (event, args) { + const { action, nodeId, clipName } = args; + const node = findNode(nodeId); + + if (!node) { + if (event.reply) event.reply(new Error(`找不到节点: ${nodeId}`)); + return; + } + + const anim = node.getComponent(cc.Animation); + if (!anim) { + if (event.reply) event.reply(new Error(`在节点 ${nodeId} 上找不到 Animation 组件`)); + return; + } + + switch (action) { + case "get_list": + const clips = anim.getClips(); + const clipList = clips.map((c) => ({ + name: c.name, + duration: c.duration, + sample: c.sample, + speed: c.speed, + wrapMode: c.wrapMode, + })); + if (event.reply) event.reply(null, clipList); + break; + + case "get_info": + const currentClip = anim.currentClip; + let isPlaying = false; + // [安全修复] 只有在有当前 Clip 时才获取状态,避免 Animation 组件无 Clip 时的崩溃 + if (currentClip) { + const state = anim.getAnimationState(currentClip.name); + if (state) { + isPlaying = state.isPlaying; + } + } + const info = { + currentClip: currentClip ? currentClip.name : null, + clips: anim.getClips().map((c) => c.name), + playOnLoad: anim.playOnLoad, + isPlaying: isPlaying, + }; + if (event.reply) event.reply(null, info); + break; + + case "play": + if (!clipName) { + anim.play(); + if (event.reply) event.reply(null, "正在播放默认动画剪辑"); + } else { + anim.play(clipName); + if (event.reply) event.reply(null, `正在播放动画剪辑: ${clipName}`); + } + break; + + case "stop": + anim.stop(); + if (event.reply) event.reply(null, "动画已停止"); + break; + + case "pause": + anim.pause(); + if (event.reply) event.reply(null, "动画已暂停"); + break; + + case "resume": + anim.resume(); + if (event.reply) event.reply(null, "动画已恢复播放"); + break; + + default: + if (event.reply) event.reply(new Error(`未知的动画操作类型: ${action}`)); + break; + } + }, }; From 42ab8d8ee2fef808cd7b8120c3dab99da2f2b3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=AB=E7=84=B0=E5=BA=93=E6=8B=89?= Date: Fri, 27 Feb 2026 23:30:17 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E4=BB=A3=E7=A0=81=E4=B8=A2=E5=A4=B1?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=20README=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E4=BF=9D=E6=8A=A4=E5=92=8C=20AI=20=E5=AE=B9?= =?UTF-8?q?=E9=94=99=E7=89=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + main.js | 5049 +++++++++++++++++++++++++++-------------------------- 2 files changed, 2550 insertions(+), 2501 deletions(-) diff --git a/README.md b/README.md index 79cfbc3..fd972a0 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ - **特效管理**: 创建和修改粒子系统 - **并发安全**: 指令队列串行化执行,防止编辑器卡死 - **超时保护**: IPC 通信和指令队列均有超时兜底机制 +- **属性保护**: 组件核心属性黑名单机制,防止 AI 篡改 `node`/`uuid` 等引用导致崩溃 +- **AI 容错**: 参数别名映射(`operation`→`action`、`save`→`update`/`write`),兼容大模型幻觉 - **工具说明**: 测试面板提供详细的工具描述和参数说明 ## 安装与使用 diff --git a/main.js b/main.js index 53032cd..059bc13 100644 --- a/main.js +++ b/main.js @@ -10,8 +10,8 @@ let logBuffer = []; // 存储所有日志 let mcpServer = null; let isSceneBusy = false; let serverConfig = { - port: 3456, - active: false, + port: 3456, + active: false, }; /** @@ -28,32 +28,32 @@ let isProcessingCommand = false; * @returns {Promise} 操作完成后 resolve */ function enqueueCommand(fn) { - return new Promise((resolve) => { - commandQueue.push({ fn, resolve }); - processNextCommand(); - }); + 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(); - } + 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(); + } } /** @@ -66,52 +66,99 @@ function processNextCommand() { * @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); + 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); - } - }; + // 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); - } + 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 日志类型 + * 日志文件路径(懒初始化,在项目 settings 目录下) + * @type {string|null} + */ +let _logFilePath = null; + +/** + * 获取日志文件路径 + * @returns {string|null} + */ +function getLogFilePath() { + if (_logFilePath) return _logFilePath; + try { + const assetsPath = Editor.assetdb.urlToFspath("db://assets"); + if (assetsPath) { + const projectRoot = pathModule.dirname(assetsPath); + const settingsDir = pathModule.join(projectRoot, "settings"); + if (!fs.existsSync(settingsDir)) { + fs.mkdirSync(settingsDir, { recursive: true }); + } + _logFilePath = pathModule.join(settingsDir, "mcp-bridge.log"); + return _logFilePath; + } + } catch (e) { + // 静默失败,不影响主流程 + } + return null; +} + +/** + * 封装日志函数 + * - 所有日志发送到 MCP 测试面板 + 内存缓存 + * - 仅 error / warn 输出到编辑器控制台(防止刷屏) + * - 所有日志实时追加写入项目内 settings/mcp-bridge.log 文件(持久化) + * @param {'info' | 'success' | 'warn' | 'error' | 'mcp'} 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); + const logEntry = { + time: new Date().toISOString().replace("T", " ").substring(0, 23), + type: type, + content: message, + }; + logBuffer.push(logEntry); + // 防止内存泄漏:限制日志缓存上限 + if (logBuffer.length > 2000) { + logBuffer = logBuffer.slice(-1500); + } + // 发送到面板 + 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 { - } + // 仅关键信息输出到编辑器控制台(error / warn) + if (type === "error") { + Editor.error(`[MCP] ${message}`); + } else if (type === "warn") { + Editor.warn(`[MCP] ${message}`); + } + + // 持久化到日志文件(实时写入,确保闪退时不丢失) + try { + const logPath = getLogFilePath(); + if (logPath) { + const line = `[${logEntry.time}] [${type}] ${message}\n`; + fs.appendFileSync(logPath, line, "utf8"); + } + } catch (e) { + // 文件写入失败时静默,不影响主流程 + } } /** @@ -119,7 +166,7 @@ function addLog(type, message) { * @returns {string} 拼接后的日志字符串 */ 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"); } /** @@ -127,40 +174,40 @@ function getLogContent() { * @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); - } + // 尝试获取 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); + 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); }; /** @@ -168,1073 +215,1073 @@ const getNewSceneTemplate = () => { * @returns {Array} 工具定义数组 */ const getToolsList = () => { - const globalPrecautions = - "【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab)必须通过 UUID 进行赋值。 4. 严禁频繁刷新全局资源 (refresh_editor),必须通过 properties.path 指定具体修改的文件或目录以防止编辑器长期卡死。"; - return [ - { - name: "get_selected_node", - description: `获取当前编辑器中选中的节点 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: `保存当前场景的修改`, - inputSchema: { type: "object", properties: {} }, - }, - { - name: "get_scene_hierarchy", - description: `获取当前场景的节点树结构(包含 UUID、名称、子节点数)。若要查询节点组件详情等,请使用 manage_components。`, - inputSchema: { - type: "object", - properties: { - nodeId: { type: "string", description: "指定的根节点 UUID。如果不传则获取整个场景的根。" }, - depth: { - type: "number", - description: "遍历的深度限制,默认为 2。用来防止过大场景导致返回数据超长。", - }, - includeDetails: { type: "boolean", description: "是否包含坐标、缩放等杂项详情,默认为 false。" }, - }, - }, - }, - { - 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" }, - width: { type: "number" }, - height: { type: "number" }, - scaleX: { type: "number" }, - scaleY: { type: "number" }, - color: { type: "string", description: "HEX 颜色代码如 #FF0000" }, - }, - required: ["id"], - }, - }, - { - name: "create_scene", - description: `在 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: `打开场景文件。注意:这是一个异步且耗时的操作,打开后请等待几秒。重要:如果是新创建或空的场景,请务必先创建并初始化基础节点(Canvas/Camera)。`, - inputSchema: { - type: "object", - properties: { - url: { - type: "string", - description: "场景资源路径,如 db://assets/NewScene.fire", - }, - }, - required: ["url"], - }, - }, - { - name: "open_prefab", - description: `在编辑器中打开预制体文件进入编辑模式。注意:这是一个异步操作,打开后请等待几秒。`, - inputSchema: { - type: "object", - properties: { - url: { - type: "string", - description: "预制体资源路径,如 db://assets/prefabs/Test.prefab", - }, - }, - 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} 管理脚本文件。注意:创建或修改脚本需时间编译。创建后必须调用 refresh_editor (务必指定 path) 生成 meta 文件,否则无法作为组件添加。`, - 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: - "操作属性。⚠️极为重要:refresh_editor 必须通过 properties.path 指定精确的刷新路径(如 'db://assets/scripts/MyScript.ts')。严禁不带 path 参数进行全局刷新 (db://assets),这在大型项目中会导致编辑器卡死数分钟,严重阻塞工作流。", - }, - }, - required: ["action"], - }, - }, - { - name: "find_gameobjects", - description: `按条件在场景中搜索游戏对象。返回匹配节点的轻量级结构 (UUID, name, active, components 等)。若要获取完整的详细组件属性,请进一步对目标使用 manage_components。`, - inputSchema: { - type: "object", - properties: { - conditions: { - type: "object", - description: - "查找条件。支持的属性:name (节点名称,支持模糊匹配), component (包含的组件类名,如 'cc.Sprite'), active (布尔值,节点的激活状态)。", - }, - 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", "update"], - 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} 执行菜单项。对于节点删除,请使用 "delete-node:UUID" 格式以确保精确执行。对于保存、撤销等操作,请优先使用专用工具 (save_scene, manage_undo)。`, - inputSchema: { - type: "object", - properties: { - menuPath: { - type: "string", - description: "菜单项路径 (支持 'Project/Build' 或 'delete-node:UUID')", - }, - }, - required: ["menuPath"], - }, - }, - { - name: "apply_text_edits", - description: `${globalPrecautions} 对文件应用文本编辑。**专用于修改脚本源代码 (.js, .ts) 或文本文件**。如果要修改场景节点属性,请使用 'manage_components'。`, - inputSchema: { - type: "object", - properties: { - edits: { - type: "array", - items: { - type: "object", - properties: { - type: { - type: "string", - enum: ["insert", "delete", "replace"], - description: "操作类型", - }, - start: { type: "number", description: "起始偏移量 (字符索引)" }, - end: { type: "number", description: "结束偏移量 (delete/replace 用)" }, - position: { type: "number", description: "插入位置 (insert 用)" }, - text: { type: "string", description: "要插入或替换的文本" }, - }, - }, - description: "编辑操作列表。请严格使用偏移量(offset)而非行号。", - }, - filePath: { type: "string", description: "文件路径 (db://...)" }, - }, - required: ["filePath", "edits"], - }, - }, - { - name: "read_console", - description: `读取控制台`, - inputSchema: { - type: "object", - properties: { - limit: { type: "number", description: "输出限制" }, - type: { - type: "string", - enum: ["info", "warn", "error", "success", "mcp"], - description: "输出类型 (info, warn, error, success, mcp)", - }, - }, - }, - }, - { - name: "validate_script", - description: `验证脚本`, - inputSchema: { - type: "object", - properties: { - filePath: { type: "string", description: "脚本路径" }, - }, - required: ["filePath"], - }, - }, - { - name: "search_project", - description: `搜索项目文件。支持三种模式:1. 'content' (默认): 搜索文件内容,支持正则表达式;2. 'file_name': 在指定目录下搜索匹配的文件名;3. 'dir_name': 在指定目录下搜索匹配的文件夹名。`, - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "搜索关键词或正则表达式模式" }, - useRegex: { - type: "boolean", - description: - "是否将 query 视为正则表达式 (仅在 matchType 为 'content', 'file_name' 或 'dir_name' 时生效)", - }, - path: { - type: "string", - description: "搜索起点路径,例如 'db://assets/scripts'。默认为 'db://assets'", - }, - matchType: { - type: "string", - enum: ["content", "file_name", "dir_name"], - description: - "匹配模式:'content' (内容关键词/正则), 'file_name' (搜索文件名), 'dir_name' (搜索文件夹名)", - }, - extensions: { - type: "array", - items: { type: "string" }, - description: - "限定文件后缀 (如 ['.js', '.ts'])。仅在 matchType 为 'content' 或 'file_name' 时有效。", - default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"], - }, - includeSubpackages: { type: "boolean", default: true, description: "是否递归搜索子目录" }, - }, - 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: `获取指定文件的 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"], - }, - }, - ]; + const globalPrecautions = + "【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab)必须通过 UUID 进行赋值。 4. 严禁频繁刷新全局资源 (refresh_editor),必须通过 properties.path 指定具体修改的文件或目录以防止编辑器长期卡死。"; + return [ + { + name: "get_selected_node", + description: `获取当前编辑器中选中的节点 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: `保存当前场景的修改`, + inputSchema: { type: "object", properties: {} }, + }, + { + name: "get_scene_hierarchy", + description: `获取当前场景的节点树结构(包含 UUID、名称、子节点数)。若要查询节点组件详情等,请使用 manage_components。`, + inputSchema: { + type: "object", + properties: { + nodeId: { type: "string", description: "指定的根节点 UUID。如果不传则获取整个场景的根。" }, + depth: { + type: "number", + description: "遍历的深度限制,默认为 2。用来防止过大场景导致返回数据超长。", + }, + includeDetails: { type: "boolean", description: "是否包含坐标、缩放等杂项详情,默认为 false。" }, + }, + }, + }, + { + 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" }, + width: { type: "number" }, + height: { type: "number" }, + scaleX: { type: "number" }, + scaleY: { type: "number" }, + color: { type: "string", description: "HEX 颜色代码如 #FF0000" }, + }, + required: ["id"], + }, + }, + { + name: "create_scene", + description: `在 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: `打开场景文件。注意:这是一个异步且耗时的操作,打开后请等待几秒。重要:如果是新创建或空的场景,请务必先创建并初始化基础节点(Canvas/Camera)。`, + inputSchema: { + type: "object", + properties: { + url: { + type: "string", + description: "场景资源路径,如 db://assets/NewScene.fire", + }, + }, + required: ["url"], + }, + }, + { + name: "open_prefab", + description: `在编辑器中打开预制体文件进入编辑模式。注意:这是一个异步操作,打开后请等待几秒。`, + inputSchema: { + type: "object", + properties: { + url: { + type: "string", + description: "预制体资源路径,如 db://assets/prefabs/Test.prefab", + }, + }, + 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} 管理脚本文件。注意:创建或修改脚本需时间编译。创建后必须调用 refresh_editor (务必指定 path) 生成 meta 文件,否则无法作为组件添加。`, + 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: + "操作属性。⚠️极为重要:refresh_editor 必须通过 properties.path 指定精确的刷新路径(如 'db://assets/scripts/MyScript.ts')。严禁不带 path 参数进行全局刷新 (db://assets),这在大型项目中会导致编辑器卡死数分钟,严重阻塞工作流。", + }, + }, + required: ["action"], + }, + }, + { + name: "find_gameobjects", + description: `按条件在场景中搜索游戏对象。返回匹配节点的轻量级结构 (UUID, name, active, components 等)。若要获取完整的详细组件属性,请进一步对目标使用 manage_components。`, + inputSchema: { + type: "object", + properties: { + conditions: { + type: "object", + description: + "查找条件。支持的属性:name (节点名称,支持模糊匹配), component (包含的组件类名,如 'cc.Sprite'), active (布尔值,节点的激活状态)。", + }, + 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", "update"], + 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} 执行菜单项。对于节点删除,请使用 "delete-node:UUID" 格式以确保精确执行。对于保存、撤销等操作,请优先使用专用工具 (save_scene, manage_undo)。`, + inputSchema: { + type: "object", + properties: { + menuPath: { + type: "string", + description: "菜单项路径 (支持 'Project/Build' 或 'delete-node:UUID')", + }, + }, + required: ["menuPath"], + }, + }, + { + name: "apply_text_edits", + description: `${globalPrecautions} 对文件应用文本编辑。**专用于修改脚本源代码 (.js, .ts) 或文本文件**。如果要修改场景节点属性,请使用 'manage_components'。`, + inputSchema: { + type: "object", + properties: { + edits: { + type: "array", + items: { + type: "object", + properties: { + type: { + type: "string", + enum: ["insert", "delete", "replace"], + description: "操作类型", + }, + start: { type: "number", description: "起始偏移量 (字符索引)" }, + end: { type: "number", description: "结束偏移量 (delete/replace 用)" }, + position: { type: "number", description: "插入位置 (insert 用)" }, + text: { type: "string", description: "要插入或替换的文本" }, + }, + }, + description: "编辑操作列表。请严格使用偏移量(offset)而非行号。", + }, + filePath: { type: "string", description: "文件路径 (db://...)" }, + }, + required: ["filePath", "edits"], + }, + }, + { + name: "read_console", + description: `读取控制台`, + inputSchema: { + type: "object", + properties: { + limit: { type: "number", description: "输出限制" }, + type: { + type: "string", + enum: ["info", "warn", "error", "success", "mcp"], + description: "输出类型 (info, warn, error, success, mcp)", + }, + }, + }, + }, + { + name: "validate_script", + description: `验证脚本`, + inputSchema: { + type: "object", + properties: { + filePath: { type: "string", description: "脚本路径" }, + }, + required: ["filePath"], + }, + }, + { + name: "search_project", + description: `搜索项目文件。支持三种模式:1. 'content' (默认): 搜索文件内容,支持正则表达式;2. 'file_name': 在指定目录下搜索匹配的文件名;3. 'dir_name': 在指定目录下搜索匹配的文件夹名。`, + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "搜索关键词或正则表达式模式" }, + useRegex: { + type: "boolean", + description: + "是否将 query 视为正则表达式 (仅在 matchType 为 'content', 'file_name' 或 'dir_name' 时生效)", + }, + path: { + type: "string", + description: "搜索起点路径,例如 'db://assets/scripts'。默认为 'db://assets'", + }, + matchType: { + type: "string", + enum: ["content", "file_name", "dir_name"], + description: + "匹配模式:'content' (内容关键词/正则), 'file_name' (搜索文件名), 'dir_name' (搜索文件夹名)", + }, + extensions: { + type: "array", + items: { type: "string" }, + description: + "限定文件后缀 (如 ['.js', '.ts'])。仅在 matchType 为 'content' 或 'file_name' 时有效。", + default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"], + }, + includeSubpackages: { type: "boolean", default: true, description: "是否递归搜索子目录" }, + }, + 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: `获取指定文件的 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"); + "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() { - // 'project' 表示存储在项目本地(settings/mcp-bridge.json),实现配置隔离 - return Editor.Profile.load("profile://project/mcp-bridge.json", "mcp-bridge"); - }, + if (autoStart) { + addLog("info", "Auto-start is enabled. Initializing server..."); + // 延迟一点启动,确保编辑器环境完全就绪 + setTimeout(() => { + this.startServer(serverConfig.port); + }, 1000); + } + }, + /** + * 获取插件配置文件的辅助函数 + * @returns {Object} Editor.Profile 实例 + */ + getProfile() { + // 'project' 表示存储在项目本地(settings/mcp-bridge.json),实现配置隔离 + return Editor.Profile.load("profile://project/mcp-bridge.json", "mcp-bridge"); + }, - /** - * 插件卸载时的回调 - */ - unload() { - this.stopServer(); - }, - /** - * 启动 HTTP 服务器 - * @param {number} port 监听端口 - */ - startServer(port) { - if (mcpServer) this.stopServer(); + /** + * 插件卸载时的回调 + */ + unload() { + this.stopServer(); + }, + /** + * 启动 HTTP 服务器 + * @param {number} port 监听端口 + */ + startServer(port) { + if (mcpServer) this.stopServer(); - const tryStart = (currentPort, retries) => { - if (retries <= 0) { - addLog("error", `Failed to find an available port after multiple attempts.`); - return; - } + const tryStart = (currentPort, retries) => { + if (retries <= 0) { + addLog("error", `Failed to find an available port after multiple attempts.`); + return; + } - try { - mcpServer = http.createServer((req, res) => { - this._handleRequest(req, res); - }); + try { + mcpServer = http.createServer((req, res) => { + this._handleRequest(req, res); + }); - mcpServer.on("error", (e) => { - if (e.code === "EADDRINUSE") { - addLog("warn", `Port ${currentPort} is in use, trying ${currentPort + 1}...`); - try { - mcpServer.close(); - } catch (err) { - // align - } - mcpServer = null; - // Delay slightly to ensure cleanup - setTimeout(() => { - tryStart(currentPort + 1, retries - 1); - }, 100); - } else { - addLog("error", `Server Error: ${e.message}`); - } - }); + mcpServer.on("error", (e) => { + if (e.code === "EADDRINUSE") { + addLog("warn", `Port ${currentPort} is in use, trying ${currentPort + 1}...`); + try { + mcpServer.close(); + } catch (err) { + // align + } + mcpServer = null; + // Delay slightly to ensure cleanup + setTimeout(() => { + tryStart(currentPort + 1, retries - 1); + }, 100); + } else { + addLog("error", `Server Error: ${e.message}`); + } + }); - mcpServer.listen(currentPort, () => { - serverConfig.active = true; - serverConfig.port = currentPort; - addLog("success", `MCP Server running at http://127.0.0.1:${currentPort}`); - Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig); + mcpServer.listen(currentPort, () => { + serverConfig.active = true; + serverConfig.port = currentPort; + addLog("success", `MCP Server running at http://127.0.0.1:${currentPort}`); + Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:state-changed", serverConfig); - // Important: Do NOT save the auto-assigned port to profile to avoid pollution - }); - } catch (e) { - addLog("error", `Failed to start server: ${e.message}`); - } - }; + // Important: Do NOT save the auto-assigned port to profile to avoid pollution + }); + } catch (e) { + addLog("error", `Failed to start server: ${e.message}`); + } + }; - // Start trying from the configured port, retry 10 times - tryStart(port, 10); - }, + // Start trying from the configured port, retry 10 times + tryStart(port, 10); + }, - _handleRequest(req, res) { - res.setHeader("Content-Type", "application/json"); - res.setHeader("Access-Control-Allow-Origin", "*"); + _handleRequest(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 })); - } - 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); - 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 || "{}"); - let argsPreview = ""; - if (args) { - try { - argsPreview = typeof args === "object" ? JSON.stringify(args) : String(args); - if (argsPreview.length > 500) { - argsPreview = argsPreview.substring(0, 500) + "...[Truncated]"; - } - } catch (e) { - argsPreview = "[无法序列化的参数]"; - } - } - addLog("mcp", `REQ -> [${name}] (队列长度: ${commandQueue.length}) 参数: ${argsPreview}`); + 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 })); + } + 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); + 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 || "{}"); + let argsPreview = ""; + if (args) { + try { + argsPreview = typeof args === "object" ? JSON.stringify(args) : String(args); + if (argsPreview.length > 500) { + argsPreview = argsPreview.substring(0, 500) + "...[Truncated]"; + } + } catch (e) { + argsPreview = "[无法序列化的参数]"; + } + } + addLog("mcp", `REQ -> [${name}] (队列长度: ${commandQueue.length}) 参数: ${argsPreview}`); - enqueueCommand((done) => { - 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)); - done(); - }); - }); - } 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; - } + enqueueCommand((done) => { + 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)); + done(); + }); + }); + } 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; + } - res.writeHead(404); - res.end(JSON.stringify({ error: "Not Found", url: url })); - }); - }, + res.writeHead(404); + res.end(JSON.stringify({ error: "Not Found", url: url })); + }); + }, - /** - * 关闭 HTTP 服务器 - */ - 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); - } - }, + /** + * 关闭 HTTP 服务器 + */ + 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); + } + }, - /** - * 获取 MCP 资源列表 - * @returns {Array} 资源列表数组 - */ - 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", - }, - ]; - }, + /** + * 获取 MCP 资源列表 + * @returns {Array} 资源列表数组 + */ + 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", + }, + ]; + }, - /** - * 读取指定的 MCP 资源内容 - * @param {string} uri 资源统一资源标识符 (URI) - * @param {Function} callback 完成回调 (err, content) - */ - handleReadResource(uri, callback) { - let parsed; - try { - parsed = new URL(uri); - } catch (e) { - return callback(`Invalid URI: ${uri}`); - } + /** + * 读取指定的 MCP 资源内容 + * @param {string} uri 资源统一资源标识符 (URI) + * @param {Function} callback 完成回调 (err, content) + */ + 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}`); - } + if (parsed.protocol !== "cocos:") { + return callback(`Unsupported protocol: ${parsed.protocol}`); + } - const type = parsed.hostname; // hierarchy, selection, logs + 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; + 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 "selection": + const selection = Editor.Selection.curSelection("node"); + callback(null, JSON.stringify(selection)); + break; - case "logs": - callback(null, getLogContent()); - break; + case "logs": + callback(null, getLogContent()); + break; - default: - callback(`Resource not found: ${uri}`); - 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; + /** + * 处理来自 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 "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 "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": - callSceneScriptWithTimeout("mcp-bridge", "get-hierarchy", args, callback); - break; + case "get_scene_hierarchy": + callSceneScriptWithTimeout("mcp-bridge", "get-hierarchy", args, callback); + break; - case "update_node_transform": - // 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统 - callSceneScriptWithTimeout("mcp-bridge", "update-node-transform", args, (err, result) => { - if (err) { - addLog("error", `Transform update failed: ${err}`); - callback(err); - } else { - callback(null, "变换信息已更新"); - } - }); - break; + case "update_node_transform": + // 直接调用场景脚本更新属性,绕过可能导致 "Unknown object" 的复杂 Undo 系统 + callSceneScriptWithTimeout("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_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 "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 "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 "open_prefab": - isSceneBusy = true; // 锁定 - const prefabUuid = Editor.assetdb.urlToUuid(args.url); - if (prefabUuid) { - // 【核心修复】使用正确的 IPC 消息进入预制体编辑模式 - Editor.Ipc.sendToAll("scene:enter-prefab-edit-mode", prefabUuid); - setTimeout(() => { - isSceneBusy = false; - callback(null, `成功:正在打开预制体 ${args.url}`); - }, 2000); - } else { - isSceneBusy = false; - callback(`找不到路径为 ${args.url} 的资源`); - } - break; + case "open_prefab": + isSceneBusy = true; // 锁定 + const prefabUuid = Editor.assetdb.urlToUuid(args.url); + if (prefabUuid) { + // 【核心修复】使用正确的 IPC 消息进入预制体编辑模式 + Editor.Ipc.sendToAll("scene:enter-prefab-edit-mode", prefabUuid); + 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; - } - callSceneScriptWithTimeout("mcp-bridge", "create-node", args, callback); - 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; + } + callSceneScriptWithTimeout("mcp-bridge", "create-node", args, callback); + break; - case "manage_components": - callSceneScriptWithTimeout("mcp-bridge", "manage-components", args, callback); - break; + case "manage_components": + callSceneScriptWithTimeout("mcp-bridge", "manage-components", args, callback); + break; - case "manage_script": - this.manageScript(args, callback); - break; + case "manage_script": + this.manageScript(args, callback); + break; - case "batch_execute": - this.batchExecute(args, callback); - break; + case "batch_execute": + this.batchExecute(args, callback); + break; - case "manage_asset": - this.manageAsset(args, callback); - break; + case "manage_asset": + this.manageAsset(args, callback); + break; - case "scene_management": - this.sceneManagement(args, callback); - break; + case "scene_management": + this.sceneManagement(args, callback); + break; - case "prefab_management": - this.prefabManagement(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 "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": - callSceneScriptWithTimeout("mcp-bridge", "find-gameobjects", args, callback); - break; + case "find_gameobjects": + callSceneScriptWithTimeout("mcp-bridge", "find-gameobjects", args, callback); + break; - case "manage_material": - this.manageMaterial(args, callback); - break; + case "manage_material": + this.manageMaterial(args, callback); + break; - case "manage_texture": - this.manageTexture(args, callback); - break; + case "manage_texture": + this.manageTexture(args, callback); + break; - case "manage_shader": - this.manageShader(args, callback); - break; + case "manage_shader": + this.manageShader(args, callback); + break; - case "execute_menu_item": - this.executeMenuItem(args, callback); - break; + case "execute_menu_item": + this.executeMenuItem(args, callback); + break; - case "apply_text_edits": - this.applyTextEdits(args, callback); - break; + case "apply_text_edits": + this.applyTextEdits(args, callback); + break; - case "read_console": - this.readConsole(args, callback); - break; + case "read_console": + this.readConsole(args, callback); + break; - case "validate_script": - this.validateScript(args, callback); - break; + case "validate_script": + this.validateScript(args, callback); + break; - case "search_project": - this.searchProject(args, callback); - break; + case "search_project": + this.searchProject(args, callback); + break; - case "manage_undo": - this.manageUndo(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", - ]; + 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; - } - } + 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."); - } + if (!args.defaultSpriteUuid) { + addLog("warn", "[mcp-bridge] Failed to resolve any default sprite UUID."); + } - callSceneScriptWithTimeout("mcp-bridge", "manage-vfx", args, callback); - break; + callSceneScriptWithTimeout("mcp-bridge", "manage-vfx", args, callback); + break; - default: - callback(`Unknown tool: ${name}`); - break; - } - }, + default: + callback(`Unknown tool: ${name}`); + break; + } + }, - /** - * 管理项目中的脚本文件 (TS/JS) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageScript(args, callback) { - const { action, path: scriptPath, content } = args; + /** + * 管理项目中的脚本文件 (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(`脚本已存在: ${scriptPath}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(scriptPath); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - Editor.assetdb.create( - scriptPath, - content || - `const { ccclass, property } = cc._decorator; + switch (action) { + case "create": + if (Editor.assetdb.exists(scriptPath)) { + return callback(`脚本已存在: ${scriptPath}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(scriptPath); + const dirPath = pathModule.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 { @@ -1252,420 +1299,420 @@ export default class NewScript extends cc.Component { update (dt) {} }`, - (err) => { - if (err) { - callback(err); - } else { - // 【关键修复】创建脚本后,必须刷新 AssetDB 并等待完成, - // 否则后续立即挂载脚本的操作(manage_components)会因找不到脚本 UUID 而失败。 - Editor.assetdb.refresh(scriptPath, (refreshErr) => { - if (refreshErr) { - addLog("warn", `脚本创建后刷新失败: ${refreshErr}`); - } - callback(null, `脚本已创建: ${scriptPath}`); - }); - } - }, - ); - break; + (err) => { + if (err) { + callback(err); + } else { + // 【关键修复】创建脚本后,必须刷新 AssetDB 并等待完成, + // 否则后续立即挂载脚本的操作(manage_components)会因找不到脚本 UUID 而失败。 + Editor.assetdb.refresh(scriptPath, (refreshErr) => { + if (refreshErr) { + addLog("warn", `脚本创建后刷新失败: ${refreshErr}`); + } + callback(null, `脚本已创建: ${scriptPath}`); + }); + } + }, + ); + break; - case "delete": - if (!Editor.assetdb.exists(scriptPath)) { - return callback(`找不到脚本: ${scriptPath}`); - } - Editor.assetdb.delete([scriptPath], (err) => { - callback(err, err ? null : `脚本已删除: ${scriptPath}`); - }); - break; + case "delete": + if (!Editor.assetdb.exists(scriptPath)) { + return callback(`找不到脚本: ${scriptPath}`); + } + Editor.assetdb.delete([scriptPath], (err) => { + callback(err, err ? null : `脚本已删除: ${scriptPath}`); + }); + break; - case "read": - // 使用 fs 读取,绕过 assetdb.loadAny - const readFsPath = Editor.assetdb.urlToFspath(scriptPath); - if (!readFsPath || !fs.existsSync(readFsPath)) { - return callback(`找不到脚本: ${scriptPath}`); - } - try { - const content = fs.readFileSync(readFsPath, "utf-8"); - callback(null, content); - } catch (e) { - callback(`读取脚本失败: ${e.message}`); - } - break; + case "read": + // 使用 fs 读取,绕过 assetdb.loadAny + const readFsPath = Editor.assetdb.urlToFspath(scriptPath); + if (!readFsPath || !fs.existsSync(readFsPath)) { + return callback(`找不到脚本: ${scriptPath}`); + } + try { + const content = fs.readFileSync(readFsPath, "utf-8"); + callback(null, content); + } catch (e) { + callback(`读取脚本失败: ${e.message}`); + } + break; - case "save": // 兼容 AI 幻觉 - case "write": - // 使用 fs 写入 + refresh,确保覆盖成功 - const writeFsPath = Editor.assetdb.urlToFspath(scriptPath); - if (!writeFsPath) { - return callback(`路径无效: ${scriptPath}`); - } + case "save": // 兼容 AI 幻觉 + case "write": + // 使用 fs 写入 + refresh,确保覆盖成功 + const writeFsPath = Editor.assetdb.urlToFspath(scriptPath); + if (!writeFsPath) { + return callback(`路径无效: ${scriptPath}`); + } - try { - fs.writeFileSync(writeFsPath, content, "utf-8"); - Editor.assetdb.refresh(scriptPath, (err) => { - if (err) addLog("warn", `写入脚本后刷新失败: ${err}`); - callback(null, `脚本已更新: ${scriptPath}`); - }); - } catch (e) { - callback(`写入脚本失败: ${e.message}`); - } - break; + try { + fs.writeFileSync(writeFsPath, content, "utf-8"); + Editor.assetdb.refresh(scriptPath, (err) => { + if (err) addLog("warn", `写入脚本后刷新失败: ${err}`); + callback(null, `脚本已更新: ${scriptPath}`); + }); + } catch (e) { + callback(`写入脚本失败: ${e.message}`); + } + break; - default: - callback(`未知的脚本操作类型: ${action}`); - break; - } - }, + default: + callback(`未知的脚本操作类型: ${action}`); + break; + } + }, - /** - * 批量执行多个 MCP 工具操作(串行链式执行) - * 【重要修复】原并行 forEach 会导致多个 AssetDB 操作同时执行引发编辑器卡死, - * 改为串行执行确保每个操作完成后再执行下一个 - * @param {Object} args 参数 (operations 数组) - * @param {Function} callback 完成回调 - */ - batchExecute(args, callback) { - const { operations } = args; - const results = []; + /** + * 批量执行多个 MCP 工具操作(串行链式执行) + * 【重要修复】原并行 forEach 会导致多个 AssetDB 操作同时执行引发编辑器卡死, + * 改为串行执行确保每个操作完成后再执行下一个 + * @param {Object} args 参数 (operations 数组) + * @param {Function} callback 完成回调 + */ + batchExecute(args, callback) { + const { operations } = args; + const results = []; - if (!operations || operations.length === 0) { - return callback("未提供任何操作指令"); - } + if (!operations || operations.length === 0) { + return callback("未提供任何操作指令"); + } - 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) => { - results[index] = { tool: operation.tool, error: err, result: result }; - index++; - next(); - }); - }; - next(); - }, + 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) => { + results[index] = { tool: operation.tool, error: err, result: result }; + index++; + next(); + }); + }; + next(); + }, - /** - * 通用的资源管理函数 (创建、删除、移动等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageAsset(args, callback) { - const { action, path, targetPath, content } = args; + /** + * 通用的资源管理函数 (创建、删除、移动等) + * @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(`资源已存在: ${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 : `资源已创建: ${path}`); - }); - break; + switch (action) { + case "create": + if (Editor.assetdb.exists(path)) { + return callback(`资源已存在: ${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 : `资源已创建: ${path}`); + }); + break; - case "delete": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到资源: ${path}`); - } - Editor.assetdb.delete([path], (err) => { - callback(err, err ? null : `资源已删除: ${path}`); - }); - break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到资源: ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `资源已删除: ${path}`); + }); + break; - case "move": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到资源: ${path}`); - } - if (Editor.assetdb.exists(targetPath)) { - return callback(`目标资源已存在: ${targetPath}`); - } - Editor.assetdb.move(path, targetPath, (err) => { - callback(err, err ? null : `资源已从 ${path} 移动到 ${targetPath}`); - }); - break; + case "move": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到资源: ${path}`); + } + if (Editor.assetdb.exists(targetPath)) { + return callback(`目标资源已存在: ${targetPath}`); + } + Editor.assetdb.move(path, targetPath, (err) => { + callback(err, err ? null : `资源已从 ${path} 移动到 ${targetPath}`); + }); + break; - case "get_info": - try { - if (!Editor.assetdb.exists(path)) { - return callback(`找不到资源: ${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(`获取资源信息失败: ${e.message}`); - } - break; + case "get_info": + try { + if (!Editor.assetdb.exists(path)) { + return callback(`找不到资源: ${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(`获取资源信息失败: ${e.message}`); + } + break; - default: - callback(`未知的资源管理操作: ${action}`); - break; - } - }, + default: + callback(`未知的资源管理操作: ${action}`); + break; + } + }, - /** - * 场景相关的资源管理 (创建、克隆场景等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - sceneManagement(args, callback) { - const { action, path, targetPath, name } = args; + /** + * 场景相关的资源管理 (创建、克隆场景等) + * @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(`场景已存在: ${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 : `场景已创建: ${path}`); - }); - break; + switch (action) { + case "create": + if (Editor.assetdb.exists(path)) { + return callback(`场景已存在: ${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 : `场景已创建: ${path}`); + }); + break; - case "delete": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到场景: ${path}`); - } - Editor.assetdb.delete([path], (err) => { - callback(err, err ? null : `场景已删除: ${path}`); - }); - break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到场景: ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `场景已删除: ${path}`); + }); + break; - case "duplicate": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到场景: ${path}`); - } - if (!targetPath) { - return callback("复制操作需要目标路径"); - } - if (Editor.assetdb.exists(targetPath)) { - return callback(`目标场景已存在: ${targetPath}`); - } - // 【修复】Cocos 2.4.x 主进程中 Editor.assetdb 没有 loadAny - // 直接使用 fs 读取物理文件 - try { - const sourceFsPath = Editor.assetdb.urlToFspath(path); - if (!sourceFsPath || !fs.existsSync(sourceFsPath)) { - return callback(`定位源场景文件失败: ${path}`); - } - const content = fs.readFileSync(sourceFsPath, "utf-8"); + case "duplicate": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到场景: ${path}`); + } + if (!targetPath) { + return callback("复制操作需要目标路径"); + } + if (Editor.assetdb.exists(targetPath)) { + return callback(`目标场景已存在: ${targetPath}`); + } + // 【修复】Cocos 2.4.x 主进程中 Editor.assetdb 没有 loadAny + // 直接使用 fs 读取物理文件 + try { + const sourceFsPath = Editor.assetdb.urlToFspath(path); + if (!sourceFsPath || !fs.existsSync(sourceFsPath)) { + return callback(`定位源场景文件失败: ${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 : `场景已从 ${path} 复制到 ${targetPath}`); - }); - }); - } catch (e) { - callback(`Duplicate failed: ${e.message}`); - } - break; + // 确保目标目录存在 + 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 : `场景已从 ${path} 复制到 ${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 { - return callback(`找不到场景: ${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 { + return callback(`找不到场景: ${path}`); + } + break; - default: - callback(`Unknown scene action: ${action}`); - break; - } - }, + default: + callback(`Unknown scene action: ${action}`); + break; + } + }, - // 预制体管理 - prefabManagement(args, callback) { - const { action, path: prefabPath, nodeId, parentId } = args; + // 预制体管理 + prefabManagement(args, callback) { + const { action, path: prefabPath, nodeId, parentId } = args; - switch (action) { - case "create": - if (!nodeId) { - return callback("创建预制体需要节点 ID"); - } - if (Editor.assetdb.exists(prefabPath)) { - return callback(`预制体已存在: ${prefabPath}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(prefabPath); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - // 【增强】确保 AssetDB 刷新文件夹,防止场景脚本找不到目标目录 - Editor.assetdb.refresh(targetDir); - } + switch (action) { + case "create": + if (!nodeId) { + return callback("创建预制体需要节点 ID"); + } + if (Editor.assetdb.exists(prefabPath)) { + return callback(`预制体已存在: ${prefabPath}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(prefabPath); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + // 【增强】确保 AssetDB 刷新文件夹,防止场景脚本找不到目标目录 + Editor.assetdb.refresh(targetDir); + } - // 解析目标目录和文件名 - const fileName = prefabPath.substring(prefabPath.lastIndexOf("/") + 1); - const prefabName = fileName.replace(".prefab", ""); + // 解析目标目录和文件名 + 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, - }); + // 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:// 目录路径 - // 【增强】增加延迟到 300ms,确保 IPC 消息处理并同步到底层引擎 - setTimeout(() => { - Editor.Ipc.sendToPanel("scene", "scene:create-prefab", [nodeId], targetDir); - }, 300); + // 2. 发送创建命令 (参数: [uuids], dirPath) + // 注意: scene:create-prefab 第三个参数必须是 db:// 目录路径 + // 【增强】增加延迟到 300ms,确保 IPC 消息处理并同步到底层引擎 + setTimeout(() => { + Editor.Ipc.sendToPanel("scene", "scene:create-prefab", [nodeId], targetDir); + }, 300); - callback(null, `指令已发送: 从节点 ${nodeId} 在目录 ${targetDir} 创建名为 ${prefabName} 的预制体`); - break; + callback(null, `指令已发送: 从节点 ${nodeId} 在目录 ${targetDir} 创建名为 ${prefabName} 的预制体`); + break; - case "save": // 兼容 AI 幻觉 - case "update": - if (!nodeId) { - return callback("更新预制体需要节点 ID"); - } - if (!Editor.assetdb.exists(prefabPath)) { - return callback(`找不到预制体: ${prefabPath}`); - } - // 更新预制体 - Editor.Ipc.sendToPanel("scene", "scene:update-prefab", nodeId, prefabPath); - callback(null, `指令已发送: 从节点 ${nodeId} 更新预制体 ${prefabPath}`); - break; + case "save": // 兼容 AI 幻觉 + case "update": + if (!nodeId) { + return callback("更新预制体需要节点 ID"); + } + if (!Editor.assetdb.exists(prefabPath)) { + return callback(`找不到预制体: ${prefabPath}`); + } + // 更新预制体 + Editor.Ipc.sendToPanel("scene", "scene:update-prefab", nodeId, prefabPath); + callback(null, `指令已发送: 从节点 ${nodeId} 更新预制体 ${prefabPath}`); + break; - case "instantiate": - if (!Editor.assetdb.exists(prefabPath)) { - return callback(`路径为 ${prefabPath} 的预制体不存在`); - } - // 实例化预制体 - const prefabUuid = Editor.assetdb.urlToUuid(prefabPath); - callSceneScriptWithTimeout( - "mcp-bridge", - "instantiate-prefab", - { - prefabUuid: prefabUuid, - parentId: parentId, - }, - callback, - ); - break; + case "instantiate": + if (!Editor.assetdb.exists(prefabPath)) { + return callback(`路径为 ${prefabPath} 的预制体不存在`); + } + // 实例化预制体 + const prefabUuid = Editor.assetdb.urlToUuid(prefabPath); + callSceneScriptWithTimeout( + "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 { - return callback(`找不到预制体: ${prefabPath}`); - } - 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 { + return callback(`找不到预制体: ${prefabPath}`); + } + break; - default: - callback(`未知的预制体管理操作: ${action}`); - } - }, + default: + callback(`未知的预制体管理操作: ${action}`); + } + }, - /** - * 管理编辑器状态 (选中对象、刷新等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageEditor(args, callback) { - const { action, target, properties } = args; + /** + * 管理编辑器状态 (选中对象、刷新等) + * @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, "选中状态已更新"); - break; - case "refresh_editor": - // 刷新编辑器资源数据库 - // 支持指定路径以避免大型项目全量刷新耗时过长 - // 示例: properties.path = 'db://assets/scripts/MyScript.ts' (刷新单个文件) - // properties.path = 'db://assets/resources' (刷新某个目录) - // 不传 (默认 'db://assets',全量刷新) - const refreshPath = properties && properties.path ? properties.path : "db://assets"; - addLog("info", `[refresh_editor] 开始刷新: ${refreshPath}`); - Editor.assetdb.refresh(refreshPath, (err) => { - if (err) { - addLog("error", `刷新失败: ${err}`); - callback(err); - } else { - callback(null, `编辑器已刷新: ${refreshPath}`); - } - }); - break; - default: - callback("未知的编辑器管理操作"); - break; - } - }, + 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, "选中状态已更新"); + break; + case "refresh_editor": + // 刷新编辑器资源数据库 + // 支持指定路径以避免大型项目全量刷新耗时过长 + // 示例: properties.path = 'db://assets/scripts/MyScript.ts' (刷新单个文件) + // properties.path = 'db://assets/resources' (刷新某个目录) + // 不传 (默认 'db://assets',全量刷新) + const refreshPath = properties && properties.path ? properties.path : "db://assets"; + addLog("info", `[refresh_editor] 开始刷新: ${refreshPath}`); + Editor.assetdb.refresh(refreshPath, (err) => { + if (err) { + addLog("error", `刷新失败: ${err}`); + callback(err); + } else { + callback(null, `编辑器已刷新: ${refreshPath}`); + } + }); + break; + default: + callback("未知的编辑器管理操作"); + break; + } + }, - // 管理着色器 (Effect) - manageShader(args, callback) { - const { action, path: effectPath, content } = args; + // 管理着色器 (Effect) + manageShader(args, callback) { + const { action, path: effectPath, content } = args; - switch (action) { - case "create": - if (Editor.assetdb.exists(effectPath)) { - return callback(`Effect 已存在: ${effectPath}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(effectPath); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } + switch (action) { + case "create": + if (Editor.assetdb.exists(effectPath)) { + return callback(`Effect 已存在: ${effectPath}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(effectPath); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } - const defaultEffect = `CCEffect %{ + const defaultEffect = `CCEffect %{ techniques: - passes: - vert: vs @@ -1704,1025 +1751,1025 @@ CCProgram fs %{ } }%`; - Editor.assetdb.create(effectPath, content || defaultEffect, (err) => { - if (err) return callback(err); - Editor.assetdb.refresh(effectPath, (refreshErr) => { - callback(refreshErr, refreshErr ? null : `Effect 已创建: ${effectPath}`); - }); - }); - break; - - case "read": - if (!Editor.assetdb.exists(effectPath)) { - return callback(`找不到 Effect: ${effectPath}`); - } - const fspath = Editor.assetdb.urlToFspath(effectPath); - try { - const data = fs.readFileSync(fspath, "utf-8"); - callback(null, data); - } catch (e) { - callback(`读取 Effect 失败: ${e.message}`); - } - break; - - case "save": // 兼容 AI 幻觉 - 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 已更新: ${effectPath}`); - }); - } catch (e) { - callback(`更新 Effect 失败: ${e.message}`); - } - break; - - case "delete": - if (!Editor.assetdb.exists(effectPath)) { - return callback(`找不到 Effect: ${effectPath}`); - } - Editor.assetdb.delete([effectPath], (err) => { - callback(err, err ? null : `Effect 已删除: ${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: ${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(`材质已存在: ${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 : `材质已创建: ${matPath}`); - }); - }); - break; - - case "save": // 兼容 AI 幻觉 - case "update": - if (!Editor.assetdb.exists(matPath)) { - return callback(`找不到材质: ${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 : `材质已更新: ${matPath}`); - }); - } catch (e) { - callback(`更新材质失败: ${e.message}`); - } - break; - - case "delete": - if (!Editor.assetdb.exists(matPath)) { - return callback(`找不到材质: ${matPath}`); - } - Editor.assetdb.delete([matPath], (err) => { - callback(err, err ? null : `材质已删除: ${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(`找不到材质: ${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(`纹理已存在: ${path}`); - } - // 确保父目录存在 - const absolutePath = Editor.assetdb.urlToFspath(path); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - // 1. 准备文件内容 (优先使用 properties.content,否则使用默认 1x1) - let base64Data = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - if (properties && properties.content) { - base64Data = properties.content; - } - const buffer = Buffer.from(base64Data, "base64"); - - try { - // 2. 写入物理文件 - fs.writeFileSync(absolutePath, buffer); - - // 3. 刷新该资源以生成 Meta - Editor.assetdb.refresh(path, (err, results) => { - if (err) return callback(err); - - // 4. 如果有 9-slice 设置,更新 Meta - if (properties && (properties.border || properties.type)) { - const uuid = Editor.assetdb.urlToUuid(path); - if (!uuid) return callback(null, `纹理已创建,但未能立即获取 UUID。`); - - // 稍微延迟确保 Meta 已生成 - setTimeout(() => { - const meta = Editor.assetdb.loadMeta(uuid); - if (meta) { - let changed = false; - if (properties.type) { - meta.type = properties.type; - changed = true; - } - - // 设置 9-slice (border) - // 注意:Cocos 2.4 纹理 Meta 中 subMetas 下通常有一个与纹理同名的 key (或者主要的一个 key) - if (properties.border) { - // 确保类型是 sprite - meta.type = "sprite"; - - // 找到 SpriteFrame 的 subMeta - const subKeys = Object.keys(meta.subMetas); - if (subKeys.length > 0) { - const subMeta = meta.subMetas[subKeys[0]]; - subMeta.border = properties.border; // [top, bottom, left, right] - changed = true; - } - } - - if (changed) { - Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { - if (err) Editor.warn(`保存资源 Meta 失败 ${path}: ${err}`); - callback(null, `纹理已创建并更新 Meta: ${path}`); - }); - return; - } - } - callback(null, `纹理已创建: ${path}`); - }, 100); - } else { - callback(null, `纹理已创建: ${path}`); - } - }); - } catch (e) { - callback(`写入纹理文件失败: ${e.message}`); - } - break; - case "delete": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到纹理: ${path}`); - } - Editor.assetdb.delete([path], (err) => { - callback(err, err ? null : `纹理已删除: ${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(`找不到纹理: ${path}`); - } - break; - case "save": // 兼容 AI 幻觉 - case "update": - if (!Editor.assetdb.exists(path)) { - return callback(`找不到纹理: ${path}`); - } - const uuid = Editor.assetdb.urlToUuid(path); - let meta = Editor.assetdb.loadMeta(uuid); - - // Fallback: 如果 Editor.assetdb.loadMeta 失败 (API 偶尔不稳定),尝试直接读取文件系统中的 .meta 文件 - if (!meta) { - try { - const fspath = Editor.assetdb.urlToFspath(path); - const metaPath = fspath + ".meta"; - if (fs.existsSync(metaPath)) { - const metaContent = fs.readFileSync(metaPath, "utf-8"); - meta = JSON.parse(metaContent); - addLog("info", `[manage_texture] Loaded meta from fs fallback: ${metaPath}`); - } - } catch (e) { - addLog("warn", `[manage_texture] Meta fs fallback failed: ${e.message}`); - } - } - - if (!meta) { - return callback(`加载资源 Meta 失败: ${path}`); - } - - let changed = false; - if (properties) { - // 更新类型 - if (properties.type) { - if (meta.type !== properties.type) { - meta.type = properties.type; - changed = true; - } - } - - // 更新 9-slice border - if (properties.border) { - // 确保类型是 sprite - if (meta.type !== "sprite") { - meta.type = "sprite"; - changed = true; - } - - // 找到 SubMeta - // Cocos Meta 结构: { subMetas: { "textureName": { ... } } } - // 注意:Cocos 2.x 的 meta 结构因版本而异,旧版可能使用 border: [t, b, l, r] 数组, - // 而新版 (如 2.3.x+) 通常使用 borderTop, borderBottom 等独立字段。 - // 此处逻辑实现了兼容性处理。 - const subKeys = Object.keys(meta.subMetas); - if (subKeys.length > 0) { - const subMeta = meta.subMetas[subKeys[0]]; - const newBorder = properties.border; // [top, bottom, left, right] - - // 方式 1: standard array style - if (subMeta.border !== undefined) { - const oldBorder = subMeta.border; - if ( - !oldBorder || - oldBorder[0] !== newBorder[0] || - oldBorder[1] !== newBorder[1] || - oldBorder[2] !== newBorder[2] || - oldBorder[3] !== newBorder[3] - ) { - subMeta.border = newBorder; - changed = true; - } - } - // 方式 2: individual fields style (common in 2.3.x) - else if (subMeta.borderTop !== undefined) { - // top, bottom, left, right - if ( - subMeta.borderTop !== newBorder[0] || - subMeta.borderBottom !== newBorder[1] || - subMeta.borderLeft !== newBorder[2] || - subMeta.borderRight !== newBorder[3] - ) { - subMeta.borderTop = newBorder[0]; - subMeta.borderBottom = newBorder[1]; - subMeta.borderLeft = newBorder[2]; - subMeta.borderRight = newBorder[3]; - changed = true; - } - } - // 方式 3: 如果都没有,尝试写入 individual fields - else { - subMeta.borderTop = newBorder[0]; - subMeta.borderBottom = newBorder[1]; - subMeta.borderLeft = newBorder[2]; - subMeta.borderRight = newBorder[3]; - changed = true; - } - } - } - } - - if (changed) { - // 使用 saveMeta 或者 fs 写入 - // 为了安全,如果 loadMeta 失败了,safeMeta 可能也会失败,所以这里尽量用 API,不行再 fallback (暂且只用 API) - Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { - if (err) return callback(`保存 Meta 失败: ${err}`); - callback(null, `纹理已更新: ${path}`); - }); - } else { - callback(null, `资源不需要更新: ${path}`); - } - break; - default: - callback(`未知的纹理操作类型: ${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(`找不到文件或 URL 无效: ${filePath}`); - } - - const fs = require("fs"); - if (!fs.existsSync(fspath)) { - return callback(`文件不存在: ${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", `刷新失败 ${filePath}: ${err}`); - callback(null, `文本编辑已应用: ${filePath}`); - }); - } catch (err) { - callback(`操作失败: ${err.message}`); - } - }, - - // 读取控制台 - readConsole(args, callback) { - const { limit, type } = args; - let filteredOutput = logBuffer; - - if (type) { - // [优化] 支持别名映射 - const targetType = type === "log" ? "info" : type; - filteredOutput = filteredOutput.filter((item) => item.type === targetType); - } - - if (limit) { - filteredOutput = filteredOutput.slice(-limit); - } - - callback(null, filteredOutput); - }, - - /** - * 执行编辑器菜单项 - * @param {Object} args 参数 (menuPath) - * @param {Function} callback 完成回调 - */ - executeMenuItem(args, callback) { - const { menuPath } = args; - if (!menuPath) { - return callback("菜单路径是必填项"); - } - addLog("info", `执行菜单项: ${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", - }; - - // 特殊处理 delete-node:UUID 格式 - if (menuPath.startsWith("delete-node:")) { - const uuid = menuPath.split(":")[1]; - if (uuid) { - callSceneScriptWithTimeout("mcp-bridge", "delete-node", { uuid }, (err, result) => { - if (err) callback(err); - else callback(null, result || `节点 ${uuid} 已通过场景脚本删除`); - }); - 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, `菜单动作已触发: ${menuPath} -> ${ipcMsg} (影响 ${selection.length} 个节点)`); - } else { - callback("没有选中任何节点进行删除"); - } - } else { - Editor.Ipc.sendToMain(ipcMsg); - callback(null, `菜单动作已触发: ${menuPath} -> ${ipcMsg}`); - } - } catch (err) { - callback(`执行 IPC ${ipcMsg} 失败: ${err.message}`); - } - } else { - // 对于未在映射表中的菜单,尝试通用的 menu:click (虽然不一定有效) - // 或者直接返回不支持的警告 - addLog("warn", `支持映射表中找不到菜单项 '${menuPath}'。尝试通过旧版模式执行。`); - - // 尝试通用调用 - try { - // 注意:Cocos Creator 2.x 的 menu:click 通常需要 Electron 菜单 ID,而不只是路径 - // 这里做个尽力而为的尝试 - Editor.Ipc.sendToMain("menu:click", menuPath); - callback(null, `通用菜单动作已发送: ${menuPath} (仅支持项保证成功)`); - } catch (e) { - callback(`执行菜单项失败: ${menuPath}`); - } - } - }, - - /** - * 验证脚本文件的语法或基础结构 - * @param {Object} args 参数 (filePath) - * @param {Function} callback 完成回调 - */ - validateScript(args, callback) { - const { filePath } = args; - - // 1. 获取文件系统路径 - const fspath = Editor.assetdb.urlToFspath(filePath); - if (!fspath) { - return callback(`找不到文件或 URL 无效: ${filePath}`); - } - - // 2. 检查文件是否存在 - if (!fs.existsSync(fspath)) { - return callback(`文件不存在: ${fspath}`); - } - - // 3. 读取内容并验证 - try { - const content = fs.readFileSync(fspath, "utf-8"); - - // 检查空文件 - if (!content || content.trim().length === 0) { - return callback(null, { valid: false, message: "脚本内容为空" }); - } - - // 对于 JavaScript 脚本,使用 Function 构造器进行语法验证 - if (filePath.endsWith(".js")) { - const wrapper = `(function() { ${content} })`; - try { - new Function(wrapper); - callback(null, { valid: true, message: "JavaScript 语法验证通过" }); - } 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: - "警告: TypeScript 文件似乎缺少标准定义 (class/interface/export),但由于缺少编译器,已跳过基础语法检查。", - }); - } - - callback(null, { - valid: true, - message: "TypeScript 基础检查通过。(完整编译验证需要通过编辑器构建流程)", - }); - } else { - callback(null, { valid: true, message: "未知的脚本类型,跳过验证。" }); - } - } catch (err) { - callback(null, { valid: false, message: `读取错误: ${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.getProfile().set("last-port", port); - this.getProfile().save(); - this.startServer(port); - } - }, - "clear-logs"() { - logBuffer = []; - addLog("info", "日志已清理"); - }, - - // 修改场景中的节点(需要通过 scene-script) - "set-node-property"(event, args) { - addLog("mcp", `设置节点属性: ${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", `创建节点: ${args.name} (${args.type})`); - Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, (err, result) => { - if (err) addLog("error", `创建节点失败: ${err}`); - else addLog("success", `节点已创建: ${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", `自动启动已设置为: ${value}`); - }, - - "inspect-apis"() { - addLog("info", "[API 检查器] 开始深度分析..."); - - // 获取函数参数的辅助函数 - 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 检查器] 标准对象:\n${JSON.stringify(report, null, 2)}`); - addLog("info", `[API 检查器] 论坛核查清单:\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 检查器] 内置包 IPC 消息:\n${JSON.stringify(ipcReport, null, 2)}`); - }, - }, - - /** - * 全局项目文件搜索 (支持正则表达式、文件名、目录名搜索) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - searchProject(args, callback) { - const { query, useRegex, path: searchPath, matchType, extensions } = args; - - // 默认值 - const rootPathUrl = searchPath || "db://assets"; - const rootPath = Editor.assetdb.urlToFspath(rootPathUrl); - - if (!rootPath || !fs.existsSync(rootPath)) { - return callback(`无效的搜索路径: ${rootPathUrl}`); - } - - const mode = matchType || "content"; // content, file_name, dir_name - const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"]; - const results = []; - const MAX_RESULTS = 500; - - let regex = null; - if (useRegex) { - try { - regex = new RegExp(query); - } catch (e) { - return callback(`Invalid regex: ${e.message}`); - } - } - - const checkMatch = (text) => { - if (useRegex) return regex.test(text); - return text.includes(query); - }; - - try { - const walk = (dir) => { - if (results.length >= MAX_RESULTS) return; - - const list = fs.readdirSync(dir); - list.forEach((file) => { - if (results.length >= MAX_RESULTS) return; - - // 忽略隐藏文件和常用忽略目录 - if ( - file.startsWith(".") || - file === "node_modules" || - file === "bin" || - file === "local" || - file === "library" || - file === "temp" - ) - return; - - const filePath = pathModule.join(dir, file); - const stat = fs.statSync(filePath); - - if (stat && stat.isDirectory()) { - // 目录名搜索 - if (mode === "dir_name") { - if (checkMatch(file)) { - const relativePath = pathModule.relative( - Editor.assetdb.urlToFspath("db://assets"), - filePath, - ); - const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); - results.push({ - filePath: dbPath, - type: "directory", - name: file, - }); - } - } - // 递归 - walk(filePath); - } else { - const ext = pathModule.extname(file).toLowerCase(); - - // 文件名搜索 - if (mode === "file_name") { - if (validExtensions && validExtensions.length > 0 && !validExtensions.includes(ext)) { - // 如果指定了后缀,则必须匹配 - // (Logic kept simple: if extensions provided, filter by them. If not provided, search all files or default list?) - // Let's stick to validExtensions for file_name search too to avoid noise, or maybe allow all if extensions is explicitly null? - // Schema default is null. Let's start with checkMatch(file) directly if no extensions provided. - // Actually validExtensions has a default list. Let's respect it if it was default, but for file_name maybe we want all? - // Let's use validExtensions only if mode is content. For file_name, usually we search everything unless filtered. - // But to be safe and consistent with previous find_in_file, let's respect validExtensions. - } - - // 简化逻辑:对文件名搜索,也检查后缀(如果用户未传则用默认列表) - if (validExtensions.includes(ext)) { - if (checkMatch(file)) { - const relativePath = pathModule.relative( - Editor.assetdb.urlToFspath("db://assets"), - filePath, - ); - const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); - results.push({ - filePath: dbPath, - type: "file", - name: file, - }); - } - } - // 如果需要搜索非文本文件(如 .png),可以传入 extensions=['.png'] - } - - // 内容搜索 - else if (mode === "content") { - 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 (checkMatch(line)) { - const relativePath = pathModule.relative( - Editor.assetdb.urlToFspath("db://assets"), - filePath, - ); - const dbPath = - "db://assets/" + relativePath.split(pathModule.sep).join("/"); - results.push({ - filePath: dbPath, - line: index + 1, - content: line.trim(), - }); - } - }); - } catch (e) { - // Skip read error - } - } - } - } - }); - }; - - walk(rootPath); - callback(null, results); - } catch (err) { - callback(`项目搜索失败: ${err.message}`); - } - }, - - /** - * 管理撤销/重做操作及事务分组 - * @param {Object} args 参数 (action, description, id) - * @param {Function} callback 完成回调 - */ - manageUndo(args, callback) { - const { action, description } = args; - - try { - switch (action) { - case "undo": - Editor.Ipc.sendToPanel("scene", "scene:undo"); - callback(null, "撤销指令已执行"); - break; - case "redo": - Editor.Ipc.sendToPanel("scene", "scene:redo"); - callback(null, "重做指令已执行"); - break; - case "begin_group": - addLog("info", `开始撤销组: ${description || "MCP 动作"}`); - // 如果有参数包含 id,则记录该节点 - if (args.id) { - Editor.Ipc.sendToPanel("scene", "scene:undo-record", args.id); - } - callback(null, `撤销组已启动: ${description || "MCP 动作"}`); - break; - case "end_group": - Editor.Ipc.sendToPanel("scene", "scene:undo-commit"); - callback(null, "撤销组已提交"); - break; - case "cancel_group": - Editor.Ipc.sendToPanel("scene", "scene:undo-cancel"); - callback(null, "撤销组已取消"); - break; - default: - callback(`未知的撤销操作: ${action}`); - } - } catch (err) { - callback(`撤销操作失败: ${err.message}`); - } - }, - - /** - * 计算资源的 SHA-256 哈希值 - * @param {Object} args 参数 (path) - * @param {Function} callback 完成回调 - */ - getSha(args, callback) { - const { path: url } = args; - const fspath = Editor.assetdb.urlToFspath(url); - - if (!fspath || !fs.existsSync(fspath)) { - return callback(`找不到文件: ${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(`计算 SHA 失败: ${err.message}`); - } - }, - - /** - * 管理节点动画 (播放、停止、获取信息等) - * @param {Object} args 参数 - * @param {Function} callback 完成回调 - */ - manageAnimation(args, callback) { - // 转发给场景脚本处理 - callSceneScriptWithTimeout("mcp-bridge", "manage-animation", args, callback); - }, + Editor.assetdb.create(effectPath, content || defaultEffect, (err) => { + if (err) return callback(err); + Editor.assetdb.refresh(effectPath, (refreshErr) => { + callback(refreshErr, refreshErr ? null : `Effect 已创建: ${effectPath}`); + }); + }); + break; + + case "read": + if (!Editor.assetdb.exists(effectPath)) { + return callback(`找不到 Effect: ${effectPath}`); + } + const fspath = Editor.assetdb.urlToFspath(effectPath); + try { + const data = fs.readFileSync(fspath, "utf-8"); + callback(null, data); + } catch (e) { + callback(`读取 Effect 失败: ${e.message}`); + } + break; + + case "save": // 兼容 AI 幻觉 + 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 已更新: ${effectPath}`); + }); + } catch (e) { + callback(`更新 Effect 失败: ${e.message}`); + } + break; + + case "delete": + if (!Editor.assetdb.exists(effectPath)) { + return callback(`找不到 Effect: ${effectPath}`); + } + Editor.assetdb.delete([effectPath], (err) => { + callback(err, err ? null : `Effect 已删除: ${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: ${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(`材质已存在: ${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 : `材质已创建: ${matPath}`); + }); + }); + break; + + case "save": // 兼容 AI 幻觉 + case "update": + if (!Editor.assetdb.exists(matPath)) { + return callback(`找不到材质: ${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 : `材质已更新: ${matPath}`); + }); + } catch (e) { + callback(`更新材质失败: ${e.message}`); + } + break; + + case "delete": + if (!Editor.assetdb.exists(matPath)) { + return callback(`找不到材质: ${matPath}`); + } + Editor.assetdb.delete([matPath], (err) => { + callback(err, err ? null : `材质已删除: ${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(`找不到材质: ${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(`纹理已存在: ${path}`); + } + // 确保父目录存在 + const absolutePath = Editor.assetdb.urlToFspath(path); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + // 1. 准备文件内容 (优先使用 properties.content,否则使用默认 1x1) + let base64Data = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + if (properties && properties.content) { + base64Data = properties.content; + } + const buffer = Buffer.from(base64Data, "base64"); + + try { + // 2. 写入物理文件 + fs.writeFileSync(absolutePath, buffer); + + // 3. 刷新该资源以生成 Meta + Editor.assetdb.refresh(path, (err, results) => { + if (err) return callback(err); + + // 4. 如果有 9-slice 设置,更新 Meta + if (properties && (properties.border || properties.type)) { + const uuid = Editor.assetdb.urlToUuid(path); + if (!uuid) return callback(null, `纹理已创建,但未能立即获取 UUID。`); + + // 稍微延迟确保 Meta 已生成 + setTimeout(() => { + const meta = Editor.assetdb.loadMeta(uuid); + if (meta) { + let changed = false; + if (properties.type) { + meta.type = properties.type; + changed = true; + } + + // 设置 9-slice (border) + // 注意:Cocos 2.4 纹理 Meta 中 subMetas 下通常有一个与纹理同名的 key (或者主要的一个 key) + if (properties.border) { + // 确保类型是 sprite + meta.type = "sprite"; + + // 找到 SpriteFrame 的 subMeta + const subKeys = Object.keys(meta.subMetas); + if (subKeys.length > 0) { + const subMeta = meta.subMetas[subKeys[0]]; + subMeta.border = properties.border; // [top, bottom, left, right] + changed = true; + } + } + + if (changed) { + Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { + if (err) Editor.warn(`保存资源 Meta 失败 ${path}: ${err}`); + callback(null, `纹理已创建并更新 Meta: ${path}`); + }); + return; + } + } + callback(null, `纹理已创建: ${path}`); + }, 100); + } else { + callback(null, `纹理已创建: ${path}`); + } + }); + } catch (e) { + callback(`写入纹理文件失败: ${e.message}`); + } + break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到纹理: ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `纹理已删除: ${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(`找不到纹理: ${path}`); + } + break; + case "save": // 兼容 AI 幻觉 + case "update": + if (!Editor.assetdb.exists(path)) { + return callback(`找不到纹理: ${path}`); + } + const uuid = Editor.assetdb.urlToUuid(path); + let meta = Editor.assetdb.loadMeta(uuid); + + // Fallback: 如果 Editor.assetdb.loadMeta 失败 (API 偶尔不稳定),尝试直接读取文件系统中的 .meta 文件 + if (!meta) { + try { + const fspath = Editor.assetdb.urlToFspath(path); + const metaPath = fspath + ".meta"; + if (fs.existsSync(metaPath)) { + const metaContent = fs.readFileSync(metaPath, "utf-8"); + meta = JSON.parse(metaContent); + addLog("info", `[manage_texture] Loaded meta from fs fallback: ${metaPath}`); + } + } catch (e) { + addLog("warn", `[manage_texture] Meta fs fallback failed: ${e.message}`); + } + } + + if (!meta) { + return callback(`加载资源 Meta 失败: ${path}`); + } + + let changed = false; + if (properties) { + // 更新类型 + if (properties.type) { + if (meta.type !== properties.type) { + meta.type = properties.type; + changed = true; + } + } + + // 更新 9-slice border + if (properties.border) { + // 确保类型是 sprite + if (meta.type !== "sprite") { + meta.type = "sprite"; + changed = true; + } + + // 找到 SubMeta + // Cocos Meta 结构: { subMetas: { "textureName": { ... } } } + // 注意:Cocos 2.x 的 meta 结构因版本而异,旧版可能使用 border: [t, b, l, r] 数组, + // 而新版 (如 2.3.x+) 通常使用 borderTop, borderBottom 等独立字段。 + // 此处逻辑实现了兼容性处理。 + const subKeys = Object.keys(meta.subMetas); + if (subKeys.length > 0) { + const subMeta = meta.subMetas[subKeys[0]]; + const newBorder = properties.border; // [top, bottom, left, right] + + // 方式 1: standard array style + if (subMeta.border !== undefined) { + const oldBorder = subMeta.border; + if ( + !oldBorder || + oldBorder[0] !== newBorder[0] || + oldBorder[1] !== newBorder[1] || + oldBorder[2] !== newBorder[2] || + oldBorder[3] !== newBorder[3] + ) { + subMeta.border = newBorder; + changed = true; + } + } + // 方式 2: individual fields style (common in 2.3.x) + else if (subMeta.borderTop !== undefined) { + // top, bottom, left, right + if ( + subMeta.borderTop !== newBorder[0] || + subMeta.borderBottom !== newBorder[1] || + subMeta.borderLeft !== newBorder[2] || + subMeta.borderRight !== newBorder[3] + ) { + subMeta.borderTop = newBorder[0]; + subMeta.borderBottom = newBorder[1]; + subMeta.borderLeft = newBorder[2]; + subMeta.borderRight = newBorder[3]; + changed = true; + } + } + // 方式 3: 如果都没有,尝试写入 individual fields + else { + subMeta.borderTop = newBorder[0]; + subMeta.borderBottom = newBorder[1]; + subMeta.borderLeft = newBorder[2]; + subMeta.borderRight = newBorder[3]; + changed = true; + } + } + } + } + + if (changed) { + // 使用 saveMeta 或者 fs 写入 + // 为了安全,如果 loadMeta 失败了,safeMeta 可能也会失败,所以这里尽量用 API,不行再 fallback (暂且只用 API) + Editor.assetdb.saveMeta(uuid, JSON.stringify(meta), (err) => { + if (err) return callback(`保存 Meta 失败: ${err}`); + callback(null, `纹理已更新: ${path}`); + }); + } else { + callback(null, `资源不需要更新: ${path}`); + } + break; + default: + callback(`未知的纹理操作类型: ${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(`找不到文件或 URL 无效: ${filePath}`); + } + + const fs = require("fs"); + if (!fs.existsSync(fspath)) { + return callback(`文件不存在: ${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", `刷新失败 ${filePath}: ${err}`); + callback(null, `文本编辑已应用: ${filePath}`); + }); + } catch (err) { + callback(`操作失败: ${err.message}`); + } + }, + + // 读取控制台 + readConsole(args, callback) { + const { limit, type } = args; + let filteredOutput = logBuffer; + + if (type) { + // [优化] 支持别名映射 + const targetType = type === "log" ? "info" : type; + filteredOutput = filteredOutput.filter((item) => item.type === targetType); + } + + if (limit) { + filteredOutput = filteredOutput.slice(-limit); + } + + callback(null, filteredOutput); + }, + + /** + * 执行编辑器菜单项 + * @param {Object} args 参数 (menuPath) + * @param {Function} callback 完成回调 + */ + executeMenuItem(args, callback) { + const { menuPath } = args; + if (!menuPath) { + return callback("菜单路径是必填项"); + } + addLog("info", `执行菜单项: ${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", + }; + + // 特殊处理 delete-node:UUID 格式 + if (menuPath.startsWith("delete-node:")) { + const uuid = menuPath.split(":")[1]; + if (uuid) { + callSceneScriptWithTimeout("mcp-bridge", "delete-node", { uuid }, (err, result) => { + if (err) callback(err); + else callback(null, result || `节点 ${uuid} 已通过场景脚本删除`); + }); + 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, `菜单动作已触发: ${menuPath} -> ${ipcMsg} (影响 ${selection.length} 个节点)`); + } else { + callback("没有选中任何节点进行删除"); + } + } else { + Editor.Ipc.sendToMain(ipcMsg); + callback(null, `菜单动作已触发: ${menuPath} -> ${ipcMsg}`); + } + } catch (err) { + callback(`执行 IPC ${ipcMsg} 失败: ${err.message}`); + } + } else { + // 对于未在映射表中的菜单,尝试通用的 menu:click (虽然不一定有效) + // 或者直接返回不支持的警告 + addLog("warn", `支持映射表中找不到菜单项 '${menuPath}'。尝试通过旧版模式执行。`); + + // 尝试通用调用 + try { + // 注意:Cocos Creator 2.x 的 menu:click 通常需要 Electron 菜单 ID,而不只是路径 + // 这里做个尽力而为的尝试 + Editor.Ipc.sendToMain("menu:click", menuPath); + callback(null, `通用菜单动作已发送: ${menuPath} (仅支持项保证成功)`); + } catch (e) { + callback(`执行菜单项失败: ${menuPath}`); + } + } + }, + + /** + * 验证脚本文件的语法或基础结构 + * @param {Object} args 参数 (filePath) + * @param {Function} callback 完成回调 + */ + validateScript(args, callback) { + const { filePath } = args; + + // 1. 获取文件系统路径 + const fspath = Editor.assetdb.urlToFspath(filePath); + if (!fspath) { + return callback(`找不到文件或 URL 无效: ${filePath}`); + } + + // 2. 检查文件是否存在 + if (!fs.existsSync(fspath)) { + return callback(`文件不存在: ${fspath}`); + } + + // 3. 读取内容并验证 + try { + const content = fs.readFileSync(fspath, "utf-8"); + + // 检查空文件 + if (!content || content.trim().length === 0) { + return callback(null, { valid: false, message: "脚本内容为空" }); + } + + // 对于 JavaScript 脚本,使用 Function 构造器进行语法验证 + if (filePath.endsWith(".js")) { + const wrapper = `(function() { ${content} })`; + try { + new Function(wrapper); + callback(null, { valid: true, message: "JavaScript 语法验证通过" }); + } 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: + "警告: TypeScript 文件似乎缺少标准定义 (class/interface/export),但由于缺少编译器,已跳过基础语法检查。", + }); + } + + callback(null, { + valid: true, + message: "TypeScript 基础检查通过。(完整编译验证需要通过编辑器构建流程)", + }); + } else { + callback(null, { valid: true, message: "未知的脚本类型,跳过验证。" }); + } + } catch (err) { + callback(null, { valid: false, message: `读取错误: ${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.getProfile().set("last-port", port); + this.getProfile().save(); + this.startServer(port); + } + }, + "clear-logs"() { + logBuffer = []; + addLog("info", "日志已清理"); + }, + + // 修改场景中的节点(需要通过 scene-script) + "set-node-property"(event, args) { + addLog("mcp", `设置节点属性: ${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", `创建节点: ${args.name} (${args.type})`); + Editor.Scene.callSceneScript("mcp-bridge", "create-node", args, (err, result) => { + if (err) addLog("error", `创建节点失败: ${err}`); + else addLog("success", `节点已创建: ${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", `自动启动已设置为: ${value}`); + }, + + "inspect-apis"() { + addLog("info", "[API 检查器] 开始深度分析..."); + + // 获取函数参数的辅助函数 + 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 检查器] 标准对象:\n${JSON.stringify(report, null, 2)}`); + addLog("info", `[API 检查器] 论坛核查清单:\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 检查器] 内置包 IPC 消息:\n${JSON.stringify(ipcReport, null, 2)}`); + }, + }, + + /** + * 全局项目文件搜索 (支持正则表达式、文件名、目录名搜索) + * @param {Object} args 参数 + * @param {Function} callback 完成回调 + */ + searchProject(args, callback) { + const { query, useRegex, path: searchPath, matchType, extensions } = args; + + // 默认值 + const rootPathUrl = searchPath || "db://assets"; + const rootPath = Editor.assetdb.urlToFspath(rootPathUrl); + + if (!rootPath || !fs.existsSync(rootPath)) { + return callback(`无效的搜索路径: ${rootPathUrl}`); + } + + const mode = matchType || "content"; // content, file_name, dir_name + const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"]; + const results = []; + const MAX_RESULTS = 500; + + let regex = null; + if (useRegex) { + try { + regex = new RegExp(query); + } catch (e) { + return callback(`Invalid regex: ${e.message}`); + } + } + + const checkMatch = (text) => { + if (useRegex) return regex.test(text); + return text.includes(query); + }; + + try { + const walk = (dir) => { + if (results.length >= MAX_RESULTS) return; + + const list = fs.readdirSync(dir); + list.forEach((file) => { + if (results.length >= MAX_RESULTS) return; + + // 忽略隐藏文件和常用忽略目录 + if ( + file.startsWith(".") || + file === "node_modules" || + file === "bin" || + file === "local" || + file === "library" || + file === "temp" + ) + return; + + const filePath = pathModule.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat && stat.isDirectory()) { + // 目录名搜索 + if (mode === "dir_name") { + if (checkMatch(file)) { + const relativePath = pathModule.relative( + Editor.assetdb.urlToFspath("db://assets"), + filePath, + ); + const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); + results.push({ + filePath: dbPath, + type: "directory", + name: file, + }); + } + } + // 递归 + walk(filePath); + } else { + const ext = pathModule.extname(file).toLowerCase(); + + // 文件名搜索 + if (mode === "file_name") { + if (validExtensions && validExtensions.length > 0 && !validExtensions.includes(ext)) { + // 如果指定了后缀,则必须匹配 + // (Logic kept simple: if extensions provided, filter by them. If not provided, search all files or default list?) + // Let's stick to validExtensions for file_name search too to avoid noise, or maybe allow all if extensions is explicitly null? + // Schema default is null. Let's start with checkMatch(file) directly if no extensions provided. + // Actually validExtensions has a default list. Let's respect it if it was default, but for file_name maybe we want all? + // Let's use validExtensions only if mode is content. For file_name, usually we search everything unless filtered. + // But to be safe and consistent with previous find_in_file, let's respect validExtensions. + } + + // 简化逻辑:对文件名搜索,也检查后缀(如果用户未传则用默认列表) + if (validExtensions.includes(ext)) { + if (checkMatch(file)) { + const relativePath = pathModule.relative( + Editor.assetdb.urlToFspath("db://assets"), + filePath, + ); + const dbPath = "db://assets/" + relativePath.split(pathModule.sep).join("/"); + results.push({ + filePath: dbPath, + type: "file", + name: file, + }); + } + } + // 如果需要搜索非文本文件(如 .png),可以传入 extensions=['.png'] + } + + // 内容搜索 + else if (mode === "content") { + 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 (checkMatch(line)) { + const relativePath = pathModule.relative( + Editor.assetdb.urlToFspath("db://assets"), + filePath, + ); + const dbPath = + "db://assets/" + relativePath.split(pathModule.sep).join("/"); + results.push({ + filePath: dbPath, + line: index + 1, + content: line.trim(), + }); + } + }); + } catch (e) { + // Skip read error + } + } + } + } + }); + }; + + walk(rootPath); + callback(null, results); + } catch (err) { + callback(`项目搜索失败: ${err.message}`); + } + }, + + /** + * 管理撤销/重做操作及事务分组 + * @param {Object} args 参数 (action, description, id) + * @param {Function} callback 完成回调 + */ + manageUndo(args, callback) { + const { action, description } = args; + + try { + switch (action) { + case "undo": + Editor.Ipc.sendToPanel("scene", "scene:undo"); + callback(null, "撤销指令已执行"); + break; + case "redo": + Editor.Ipc.sendToPanel("scene", "scene:redo"); + callback(null, "重做指令已执行"); + break; + case "begin_group": + addLog("info", `开始撤销组: ${description || "MCP 动作"}`); + // 如果有参数包含 id,则记录该节点 + if (args.id) { + Editor.Ipc.sendToPanel("scene", "scene:undo-record", args.id); + } + callback(null, `撤销组已启动: ${description || "MCP 动作"}`); + break; + case "end_group": + Editor.Ipc.sendToPanel("scene", "scene:undo-commit"); + callback(null, "撤销组已提交"); + break; + case "cancel_group": + Editor.Ipc.sendToPanel("scene", "scene:undo-cancel"); + callback(null, "撤销组已取消"); + break; + default: + callback(`未知的撤销操作: ${action}`); + } + } catch (err) { + callback(`撤销操作失败: ${err.message}`); + } + }, + + /** + * 计算资源的 SHA-256 哈希值 + * @param {Object} args 参数 (path) + * @param {Function} callback 完成回调 + */ + getSha(args, callback) { + const { path: url } = args; + const fspath = Editor.assetdb.urlToFspath(url); + + if (!fspath || !fs.existsSync(fspath)) { + return callback(`找不到文件: ${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(`计算 SHA 失败: ${err.message}`); + } + }, + + /** + * 管理节点动画 (播放、停止、获取信息等) + * @param {Object} args 参数 + * @param {Function} callback 完成回调 + */ + manageAnimation(args, callback) { + // 转发给场景脚本处理 + callSceneScriptWithTimeout("mcp-bridge", "manage-animation", args, callback); + }, }; From aadf69300fb5d9afc1d0e63e241cd6baf06c9bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=AB=E7=84=B0=E5=BA=93=E6=8B=89?= Date: Sat, 28 Feb 2026 08:44:45 +0800 Subject: [PATCH 3/4] =?UTF-8?q?perf:=20=E6=80=A7=E8=83=BD=E4=B8=8E?= =?UTF-8?q?=E5=8F=AF=E9=9D=A0=E6=80=A7=E4=BC=98=E5=8C=96=20-=20CommandQueu?= =?UTF-8?q?e=E8=B6=85=E6=97=B6=E6=81=A2=E5=A4=8D/HTTP=E9=99=90=E5=88=B6/?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=BD=AE=E8=BD=AC/=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=B8=85=E7=90=86/applyProperties=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UPDATE_LOG.md | 34 + main.js | 36 +- scene-script.js | 2323 +++++++++++++++++++++++------------------------ 3 files changed, 1225 insertions(+), 1168 deletions(-) diff --git a/UPDATE_LOG.md b/UPDATE_LOG.md index b103126..ae452a3 100644 --- a/UPDATE_LOG.md +++ b/UPDATE_LOG.md @@ -249,3 +249,37 @@ ### 5. 日志仅输出关键信息到编辑器控制台 - **优化**: `addLog` 函数不再将所有类型的日志输出到编辑器控制台,仅 `error` 和 `warn` 级别日志通过 `Editor.error()` / `Editor.warn()` 输出,防止 `info` / `success` / `mcp` 类型日志刷屏干扰开发者。 + +--- + +## 性能与可靠性优化 (2026-02-28) + +### 1. CommandQueue 超时保护恢复 + +- **问题**: 合并冲突解决时 `enqueueCommand` 中的 60 秒兜底超时保护代码丢失,导致如果工具函数内部异常未调用 `done()`,整个指令队列将永久停滞,后续所有操作将卡死不再响应。 +- **修复**: 在 `enqueueCommand` 中为每个入队指令注册 `setTimeout(60000)` 超时定时器,正常完成时通过 `clearTimeout` 取消。 + +### 2. HTTP 请求体大小限制 + +- **问题**: `_handleRequest` 中 `body += chunk` 无上限保护,超大请求体(恶意或异常客户端)可能耗尽编辑器进程内存。 +- **修复**: 新增 5MB (`5 * 1024 * 1024`) 请求体上限,超出时返回 HTTP 413 并销毁连接。 + +### 3. 日志文件轮转机制 + +- **问题**: `settings/mcp-bridge.log` 文件持续追加写入,长期使用会无限增长占用磁盘空间。 +- **修复**: 在 `getLogFilePath()` 初始化时检查文件大小,超过 2MB 自动将旧日志重命名为 `.old` 备份后创建新文件。 + +### 4. 清理冗余调试日志 + +- **问题**: `scene-script.js` 中 `update-node-transform` 和 `applyProperties` 共有 8 处 `Editor.log` 调试日志,每次操作都输出到编辑器控制台造成刷屏。 +- **修复**: 移除所有冗余 `Editor.log` 调试输出,保留必要的 `Editor.warn` 警告(如资源加载失败、属性解析失败等)。 + +### 5. `applyProperties` 逻辑修复 + +- **问题**: `applyProperties` 启发式资源解析分支中使用了 `return` 而非 `continue`,导致处理到该分支后会直接退出整个 `for...of` 循环,跳过后续属性的设置。 +- **修复**: 将 `return` 改为 `continue`,确保多属性同时更新时所有属性都能被正确处理。 + +### 6. `instantiate-prefab` 统一使用 `findNode` + +- **问题**: `instantiate-prefab` 中查找父节点直接调用 `cc.engine.getInstanceById(parentId)`,绕过了 `findNode` 函数的压缩 UUID 解压与兼容逻辑。 +- **修复**: 统一改用 `findNode(parentId)`,确保所有场景操作对压缩和非压缩 UUID 格式的兼容性一致。 diff --git a/main.js b/main.js index 059bc13..f165365 100644 --- a/main.js +++ b/main.js @@ -29,7 +29,14 @@ let isProcessingCommand = false; */ function enqueueCommand(fn) { return new Promise((resolve) => { - commandQueue.push({ fn, resolve }); + // 兜底超时保护:防止 fn 内部未调用 done() 导致队列永久停滞 + const timeoutId = setTimeout(() => { + addLog("error", "[CommandQueue] 指令执行超时(60s),强制释放队列"); + isProcessingCommand = false; + resolve(); + processNextCommand(); + }, 60000); + commandQueue.push({ fn, resolve, timeoutId }); processNextCommand(); }); } @@ -40,15 +47,17 @@ function enqueueCommand(fn) { function processNextCommand() { if (isProcessingCommand || commandQueue.length === 0) return; isProcessingCommand = true; - const { fn, resolve } = commandQueue.shift(); + const { fn, resolve, timeoutId } = commandQueue.shift(); try { fn(() => { + clearTimeout(timeoutId); isProcessingCommand = false; resolve(); processNextCommand(); }); } catch (e) { // 防止队列因未捕获异常永久阻塞 + clearTimeout(timeoutId); addLog("error", `[CommandQueue] 指令执行异常: ${e.message}`); isProcessingCommand = false; resolve(); @@ -112,6 +121,19 @@ function getLogFilePath() { fs.mkdirSync(settingsDir, { recursive: true }); } _logFilePath = pathModule.join(settingsDir, "mcp-bridge.log"); + // 日志轮转: 超过 2MB 时备份旧日志并创建新文件 + try { + if (fs.existsSync(_logFilePath)) { + const stats = fs.statSync(_logFilePath); + if (stats.size > 2 * 1024 * 1024) { + const backupPath = _logFilePath + ".old"; + if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath); + fs.renameSync(_logFilePath, backupPath); + } + } + } catch (e) { + /* 轮转失败不影响主流程 */ + } return _logFilePath; } } catch (e) { @@ -832,11 +854,21 @@ module.exports = { res.setHeader("Content-Type", "application/json"); res.setHeader("Access-Control-Allow-Origin", "*"); + const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5MB 请求体上限 let body = ""; + let aborted = false; req.on("data", (chunk) => { body += chunk; + if (body.length > MAX_BODY_SIZE) { + aborted = true; + addLog("error", `[HTTP] 请求体超过 ${MAX_BODY_SIZE} 字节上限,已拒绝`); + res.writeHead(413); + res.end(JSON.stringify({ error: "请求体过大" })); + req.destroy(); + } }); req.on("end", () => { + if (aborted) return; const url = req.url; if (url === "/list-tools") { const tools = getToolsList(); diff --git a/scene-script.js b/scene-script.js index 67e3356..c1b79d9 100644 --- a/scene-script.js +++ b/scene-script.js @@ -6,1173 +6,1164 @@ * @returns {cc.Node | null} 找到的节点对象或 null */ const findNode = (id) => { - if (!id) return null; - let node = cc.engine.getInstanceById(id); - if (!node && typeof Editor !== "undefined" && Editor.Utils && Editor.Utils.UuidUtils) { - // 如果直接查不到,尝试对可能是压缩格式的 ID 进行解压后再次查找 - try { - const decompressed = Editor.Utils.UuidUtils.decompressUuid(id); - if (decompressed !== id) { - node = cc.engine.getInstanceById(decompressed); - } - } catch (e) { - // 忽略转换错误 - } - } - return node; + if (!id) return null; + let node = cc.engine.getInstanceById(id); + if (!node && typeof Editor !== "undefined" && Editor.Utils && Editor.Utils.UuidUtils) { + // 如果直接查不到,尝试对可能是压缩格式的 ID 进行解压后再次查找 + try { + const decompressed = Editor.Utils.UuidUtils.decompressUuid(id); + if (decompressed !== id) { + node = cc.engine.getInstanceById(decompressed); + } + } catch (e) { + // 忽略转换错误 + } + } + return node; }; module.exports = { - /** - * 修改节点的基础属性 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (id, path, value) - */ - "set-property": function (event, args) { - const { id, path, value } = args; - - // 1. 获取节点 - let node = findNode(id); - - if (node) { - // 2. 修改属性 - if (path === "name") { - node.name = value; - } else { - node[path] = value; - } - - // 3. 【解决报错的关键】告诉编辑器场景变脏了(需要保存) - // 在场景进程中,我们发送 IPC 给主进程 - Editor.Ipc.sendToMain("scene:dirty"); - - // 4. 【额外补丁】通知层级管理器(Hierarchy)同步更新节点名称 - // 否则你修改了名字,层级管理器可能还是显示旧名字 - Editor.Ipc.sendToAll("scene:node-changed", { - uuid: id, - }); - - if (event.reply) { - event.reply(null, `节点 ${id} 已更新为 ${value}`); - } - } else { - if (event.reply) { - event.reply(new Error("场景脚本:找不到节点 " + id)); - } - } - }, - /** - * 获取当前场景的完整层级树 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (nodeId, depth, includeDetails) - */ - "get-hierarchy": function (event, args) { - const { nodeId = null, depth = 2, includeDetails = false } = args || {}; - const scene = cc.director.getScene(); - - let rootNode = scene; - if (nodeId) { - rootNode = findNode(nodeId); - if (!rootNode) { - if (event.reply) event.reply(new Error(`找不到指定的起始节点: ${nodeId}`)); - return; - } - } - - /** - * 递归遍历并序列化节点树 - * @param {cc.Node} node 目标节点 - * @param {number} currentDepth 当前深度 - * @returns {Object|null} 序列化后的节点数据 - */ - function dumpNodes(node, currentDepth) { - // 【优化】跳过编辑器内部的私有节点,减少数据量 - if ( - !node || - !node.name || - (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) - ) { - return null; - } - - let nodeData = { - name: node.name, - uuid: node.uuid, - childrenCount: node.childrenCount, - }; - - const comps = node._components || []; - - // 根据是否需要详情来决定附加哪些数据以节省 Token - if (includeDetails) { - nodeData.active = node.active; - nodeData.position = { x: Math.round(node.x), y: Math.round(node.y) }; - nodeData.scale = { x: node.scaleX, y: node.scaleY }; - nodeData.size = { width: node.width, height: node.height }; - nodeData.components = comps.map((c) => cc.js.getClassName(c)); - } else { - // 简略模式下如果存在组件,至少提供一个极简列表让 AI 知道节点的作用 - if (comps.length > 0) { - nodeData.components = comps.map((c) => { - const parts = (cc.js.getClassName(c) || "").split("."); - return parts[parts.length - 1]; // 只取类名,例如 cc.Sprite -> Sprite - }); - } - } - - // 如果未超出深度限制,继续递归子树 - if (currentDepth < depth && node.childrenCount > 0) { - nodeData.children = []; - for (let i = 0; i < node.childrenCount; i++) { - let childData = dumpNodes(node.children[i], currentDepth + 1); - if (childData) nodeData.children.push(childData); - } - } - - return nodeData; - } - - const hierarchy = dumpNodes(rootNode, 0); - if (event.reply) event.reply(null, hierarchy); - }, - - /** - * 批量更新节点的变换信息 (坐标、缩放、颜色) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (id, x, y, scaleX, scaleY, color) - */ - "update-node-transform": function (event, args) { - const { id, x, y, scaleX, scaleY, color } = args; - Editor.log(`[scene-script] update-node-transform called for ${id} with args: ${JSON.stringify(args)}`); - - let node = findNode(id); - - if (node) { - Editor.log(`[scene-script] 找到节点: ${node.name}, 当前坐标: (${node.x}, ${node.y})`); - - // 使用 scene:set-property 实现支持 Undo 的属性修改 - // 注意:IPC 消息需要发送到 'scene' 面板 - if (x !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "x", - type: "Number", - value: Number(x), - }); - } - if (y !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "y", - type: "Number", - value: Number(y), - }); - } - if (args.width !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "width", - type: "Number", - value: Number(args.width), - }); - } - if (args.height !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "height", - type: "Number", - value: Number(args.height), - }); - } - if (scaleX !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "scaleX", - type: "Number", - value: Number(scaleX), - }); - } - if (scaleY !== undefined) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id, - path: "scaleY", - type: "Number", - value: Number(scaleY), - }); - } - if (color) { - const c = new cc.Color().fromHEX(color); - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id: id, - path: "color", - type: "Color", - value: { r: c.r, g: c.g, b: c.b, a: c.a }, - }); - } - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: id }); - - Editor.log(`[scene-script] 更新完成。新坐标: (${node.x}, ${node.y})`); - if (event.reply) event.reply(null, "变换信息已更新"); - } else { - if (event.reply) event.reply(new Error("找不到节点")); - } - }, - /** - * 在场景中创建新节点 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (name, parentId, type) - */ - "create-node": function (event, args) { - const { name, parentId, type } = args; - const scene = cc.director.getScene(); - if (!scene) { - if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); - return; - } - - let newNode = null; - - // 特殊处理:如果是创建 Canvas,自动设置好适配 - if (type === "canvas" || name === "Canvas") { - newNode = new cc.Node("Canvas"); - let canvas = newNode.addComponent(cc.Canvas); - newNode.addComponent(cc.Widget); - // 设置默认设计分辨率 - canvas.designResolution = cc.size(960, 640); - canvas.fitHeight = true; - // 自动在 Canvas 下创建一个 Camera - let camNode = new cc.Node("Main Camera"); - camNode.addComponent(cc.Camera); - camNode.parent = newNode; - } else if (type === "sprite") { - newNode = new cc.Node(name || "新建精灵节点"); - let sprite = newNode.addComponent(cc.Sprite); - // 设置为 CUSTOM 模式 - sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; - // 为精灵设置默认尺寸 - newNode.width = 100; - newNode.height = 100; - - // 加载引擎默认图做占位 - if (args.defaultSpriteUuid) { - cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { - if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { - sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - } - } else if (type === "button") { - newNode = new cc.Node(name || "新建按钮节点"); - let sprite = newNode.addComponent(cc.Sprite); - newNode.addComponent(cc.Button); - - // 设置为 CUSTOM 模式并应用按钮专用尺寸 - sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; - newNode.width = 150; - newNode.height = 50; - - // 设置稍暗的背景颜色 (#A0A0A0),以便于看清其上的文字 - newNode.color = new cc.Color(160, 160, 160); - - // 加载引擎默认图 - if (args.defaultSpriteUuid) { - cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { - if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { - sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - } - } else if (type === "label") { - newNode = new cc.Node(name || "新建文本节点"); - let l = newNode.addComponent(cc.Label); - l.string = "新文本"; - newNode.width = 120; - newNode.height = 40; - } else { - newNode = new cc.Node(name || "新建节点"); - } - - // 设置层级 - let parent = parentId ? findNode(parentId) : scene; - if (parent) { - newNode.parent = parent; - - // 【优化】通知主进程场景变脏 - Editor.Ipc.sendToMain("scene:dirty"); - - // 【关键】使用 setTimeout 延迟通知 UI 刷新,让出主循环 - setTimeout(() => { - Editor.Ipc.sendToAll("scene:node-created", { - uuid: newNode.uuid, - parentUuid: parent.uuid, - }); - }, 10); - - if (event.reply) event.reply(null, newNode.uuid); - } else { - if (event.reply) event.reply(new Error(`无法创建节点:找不到父节点 ${parentId}`)); - } - }, - - /** - * 管理节点上的组件 (添加、移除、更新属性) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (nodeId, action, componentType, componentId, properties) - */ - "manage-components": function (event, args) { - let { nodeId, action, operation, componentType, componentId, properties } = args; - // 兼容 AI 幻觉带来的传参错误 - action = action || operation; - - let node = findNode(nodeId); - - /** - * 辅助函数:应用属性并智能解析 (支持 UUID 资源与节点引用) - * @param {cc.Component} component 目标组件实例 - * @param {Object} props 待更新的属性键值对 - */ - const applyProperties = (component, props) => { - if (!props) return; - // 尝试获取组件类的属性定义 - const compClass = component.constructor; - - for (const [key, value] of Object.entries(props)) { - // 【防呆设计】拦截对核心只读属性的非法重写 - // 如果直接修改组件的 node 属性,会导致该引用丢失变成普通对象,进而引发编辑器卡死 - if (key === "node" || key === "uuid" || key === "_id") { - Editor.warn( - `[scene-script] 拒绝覆盖组件的只读/核心属性: ${key}。请勿对组件执行此操作,修改位置/激活状态等请操作 Node 节点!`, - ); - continue; - } - - // 【核心修复】专门处理各类事件属性 (ClickEvents, ScrollEvents 等) - const isEventProp = - Array.isArray(value) && (key.toLowerCase().endsWith("events") || key === "clickEvents"); - - if (isEventProp) { - const eventHandlers = []; - for (const item of value) { - if (typeof item === "object" && (item.target || item.component || item.handler)) { - const handler = new cc.Component.EventHandler(); - - // 解析 Target Node - if (item.target) { - let targetNode = findNode(item.target); - if (!targetNode && item.target instanceof cc.Node) { - targetNode = item.target; - } - - if (targetNode) { - handler.target = targetNode; - Editor.log(`[scene-script] Resolved event target: ${targetNode.name}`); - } - } - - if (item.component) handler.component = item.component; - if (item.handler) handler.handler = item.handler; - if (item.customEventData !== undefined) - handler.customEventData = String(item.customEventData); - - eventHandlers.push(handler); - } else { - // 如果不是对象,原样保留 - eventHandlers.push(item); - } - } - component[key] = eventHandlers; - continue; // 处理完事件数组,跳出本次循环 - } - - // 检查属性是否存在 - if (component[key] !== undefined) { - let finalValue = value; - - // 【核心逻辑】智能类型识别与赋值 - try { - const attrs = (cc.Class.Attr.getClassAttrs && cc.Class.Attr.getClassAttrs(compClass)) || {}; - let propertyType = attrs[key] ? attrs[key].type : null; - if (!propertyType && attrs[key + "$_$ctor"]) { - propertyType = attrs[key + "$_$ctor"]; - } - - let isAsset = - propertyType && - (propertyType.prototype instanceof cc.Asset || - propertyType === cc.Asset || - propertyType === cc.Prefab || - propertyType === cc.SpriteFrame); - let isAssetArray = - Array.isArray(value) && (key === "materials" || key.toLowerCase().includes("assets")); - - // 启发式:如果属性名包含 prefab/sprite/texture 等,且值为 UUID 且不是节点 - if (!isAsset && !isAssetArray && typeof value === "string" && value.length > 20) { - const lowerKey = key.toLowerCase(); - const assetKeywords = [ - "prefab", - "sprite", - "texture", - "material", - "skeleton", - "spine", - "atlas", - "font", - "audio", - "data", - ]; - if (assetKeywords.some((k) => lowerKey.includes(k))) { - if (!findNode(value)) { - isAsset = true; - } - } - } - - if (isAsset || isAssetArray) { - // 1. 处理资源引用 (单个或数组) - const uuids = isAssetArray ? value : [value]; - const loadedAssets = []; - let loadedCount = 0; - - if (uuids.length === 0) { - component[key] = []; - return; - } - - uuids.forEach((uuid, idx) => { - if (typeof uuid !== "string" || uuid.length < 10) { - loadedCount++; - return; - } - cc.AssetLibrary.loadAsset(uuid, (err, asset) => { - loadedCount++; - if (!err && asset) { - loadedAssets[idx] = asset; - Editor.log(`[scene-script] 成功为 ${key}[${idx}] 加载资源: ${asset.name}`); - } else { - Editor.warn(`[scene-script] 未能为 ${key}[${idx}] 加载资源 ${uuid}: ${err}`); - } - - if (loadedCount === uuids.length) { - if (isAssetArray) { - // 过滤掉加载失败的 - component[key] = loadedAssets.filter((a) => !!a); - } else { - if (loadedAssets[0]) component[key] = loadedAssets[0]; - } - - // 通知编辑器 UI 更新 - const compIndex = node._components.indexOf(component); - if (compIndex !== -1) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id: node.uuid, - path: `_components.${compIndex}.${key}`, - type: isAssetArray ? "Array" : "Object", - value: isAssetArray ? uuids.map((u) => ({ uuid: u })) : { uuid: value }, - isSubProp: false, - }); - } - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - }); - // 【重要修复】使用 continue 而不是 return,确保处理完 Asset 属性后 - // 还能继续处理后续的普通属性 (如 type, sizeMode 等) - continue; - } else if ( - propertyType && - (propertyType.prototype instanceof cc.Component || - propertyType === cc.Component || - propertyType === cc.Node) - ) { - // 2. 处理节点或组件引用 - const targetNode = findNode(value); - if (targetNode) { - if (propertyType === cc.Node) { - finalValue = targetNode; - } else { - const targetComp = targetNode.getComponent(propertyType); - if (targetComp) { - finalValue = targetComp; - } else { - Editor.warn( - `[scene-script] 在节点 ${targetNode.name} 上找不到组件 ${propertyType.name}`, - ); - } - } - Editor.log(`[scene-script] 已应用 ${key} 的引用: ${targetNode.name}`); - } else if (value && value.length > 20) { - // 如果明确是组件/节点类型但找不到,才报错 - Editor.warn(`[scene-script] 无法解析 ${key} 的目标节点/组件: ${value}`); - } - } else { - // 3. 通用启发式 (找不到类型时的 fallback) - if (typeof value === "string" && value.length > 20) { - const targetNode = findNode(value); - if (targetNode) { - finalValue = targetNode; - Editor.log(`[scene-script] 启发式解析 ${key} 的节点: ${targetNode.name}`); - } else { - // 找不到节点且是 UUID -> 视为资源 - const compIndex = node._components.indexOf(component); - if (compIndex !== -1) { - Editor.Ipc.sendToPanel("scene", "scene:set-property", { - id: node.uuid, - path: `_components.${compIndex}.${key}`, - type: "Object", - value: { uuid: value }, - isSubProp: false, - }); - Editor.log(`[scene-script] 通过 IPC 启发式解析 ${key} 的资源: ${value}`); - } - return; - } - } - } - } catch (e) { - Editor.warn(`[scene-script] 解析属性 ${key} 失败: ${e.message}`); - } - - component[key] = finalValue; - } - } - }; - - if (!node) { - if (event.reply) event.reply(new Error("找不到节点")); - return; - } - - switch (action) { - case "add": - if (!componentType) { - if (event.reply) event.reply(new Error("必须提供组件类型")); - return; - } - - // 【防呆设计】拦截 AI 错误地将 cc.Node 作为组件添加 - if (componentType === "cc.Node" || componentType === "Node") { - if (event.reply) { - event.reply( - new Error( - "【纠错提示】cc.Node 是节点而不是组件,无法被当做组件添加!\n" + - "- 如果你想创建带有名字的子节点,请不要使用 manage_components,而是使用 create-node (或相应的创建节点工具)。\n" + - "- 如果你想修改现有节点的 name 属性,请使用修改节点的 set-property 工具。", - ), - ); - } - return; - } - - try { - // 解析组件类型 - let compClass = null; - if (componentType.startsWith("cc.")) { - const className = componentType.replace("cc.", ""); - compClass = cc[className]; - } else { - // 尝试获取自定义组件 - compClass = cc.js.getClassByName(componentType); - } - - if (!compClass) { - if (event.reply) event.reply(new Error(`找不到组件类型: ${componentType}`)); - return; - } - - // 【防呆设计】确保获取到的类是一个组件 - if (!cc.js.isChildClassOf(compClass, cc.Component)) { - if (event.reply) { - event.reply( - new Error( - `【错误】'${componentType}' 不是一个合法的组件类型(必须继承自 cc.Component)。请确认你的意图。`, - ), - ); - } - return; - } - - // 添加组件 - const component = node.addComponent(compClass); - - // 设置属性 - if (properties) { - applyProperties(component, properties); - } - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - - if (event.reply) event.reply(null, `组件 ${componentType} 已添加`); - } catch (err) { - if (event.reply) event.reply(new Error(`添加组件失败: ${err.message}`)); - } - break; - - case "remove": - if (!componentId) { - if (event.reply) event.reply(new Error("必须提供组件 ID")); - return; - } - - try { - // 查找并移除组件 - let component = null; - if (node._components) { - for (let i = 0; i < node._components.length; i++) { - if (node._components[i].uuid === componentId) { - component = node._components[i]; - break; - } - } - } - - if (component) { - node.removeComponent(component); - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - if (event.reply) event.reply(null, "组件已移除"); - } else { - if (event.reply) event.reply(new Error("找不到组件")); - } - } catch (err) { - if (event.reply) event.reply(new Error(`移除组件失败: ${err.message}`)); - } - break; - - case "update": - // 更新现有组件属性 - if (!componentType) { - // 如果提供了 componentId,可以只用 componentId - // 但 Cocos 2.4 uuid 获取组件比较麻烦,最好还是有 type 或者遍历 - } - - try { - let targetComp = null; - - // 1. 尝试通过 componentId 查找 - if (componentId) { - if (node._components) { - for (let i = 0; i < node._components.length; i++) { - if (node._components[i].uuid === componentId) { - targetComp = node._components[i]; - break; - } - } - } - } - - // 2. 尝试通过 type 查找 - if (!targetComp && componentType) { - let compClass = null; - if (componentType.startsWith("cc.")) { - const className = componentType.replace("cc.", ""); - compClass = cc[className]; - } else { - compClass = cc.js.getClassByName(componentType); - } - if (compClass) { - targetComp = node.getComponent(compClass); - } - } - - if (targetComp) { - if (properties) { - applyProperties(targetComp, properties); - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - if (event.reply) event.reply(null, "组件属性已更新"); - } else { - if (event.reply) event.reply(null, "没有需要更新的属性"); - } - } else { - if (event.reply) - event.reply(new Error(`找不到组件 (类型: ${componentType}, ID: ${componentId})`)); - } - } catch (err) { - if (event.reply) event.reply(new Error(`更新组件失败: ${err.message}`)); - } - break; - - case "get": - try { - const components = node._components.map((c) => { - // 获取组件属性 - const properties = {}; - for (const key in c) { - if (typeof c[key] !== "function" && !key.startsWith("_") && c[key] !== undefined) { - try { - // 安全序列化检查 - const val = c[key]; - if (val === null || val === undefined) { - properties[key] = val; - continue; - } - - // 基础类型是安全的 - if (typeof val !== "object") { - // 【优化】对于超长字符串进行截断 - if (typeof val === "string" && val.length > 200) { - properties[key] = - val.substring(0, 50) + `...[Truncated, total length: ${val.length}]`; - } else { - properties[key] = val; - } - continue; - } - - // 特殊 Cocos 类型 - if (val instanceof cc.ValueType) { - properties[key] = val.toString(); - } else if (val instanceof cc.Asset) { - properties[key] = `资源(${val.name})`; - } else if (val instanceof cc.Node) { - properties[key] = `节点(${val.name})`; - } else if (val instanceof cc.Component) { - properties[key] = `组件(${val.name}<${val.__typename}>)`; - } else { - // 数组和普通对象 - // 【优化】对于超长数组直接截断并提示,防止返回巨大的坐标或点集 - if (Array.isArray(val) && val.length > 10) { - properties[key] = `[Array(${val.length})]`; - continue; - } - - // 尝试转换为纯 JSON 数据以避免 IPC 错误(如包含原生对象/循环引用) - try { - const jsonStr = JSON.stringify(val); - if (jsonStr && jsonStr.length > 500) { - properties[key] = `[Large JSON Object, length: ${jsonStr.length}]`; - } else { - // 确保不传递原始对象引用 - properties[key] = JSON.parse(jsonStr); - } - } catch (e) { - // 如果 JSON 失败(例如循环引用),格式化为字符串 - properties[key] = - `[复杂对象: ${val.constructor ? val.constructor.name : typeof val}]`; - } - } - } catch (e) { - properties[key] = "[Serialization Error]"; - } - } - } - return { - type: cc.js.getClassName(c) || c.constructor.name || "Unknown", - uuid: c.uuid, - properties: properties, - }; - }); - if (event.reply) event.reply(null, components); - } catch (err) { - if (event.reply) event.reply(new Error(`获取组件失败: ${err.message}`)); - } - break; - - default: - if (event.reply) event.reply(new Error(`未知的组件操作类型: ${action}`)); - break; - } - }, - - "get-component-properties": function (component) { - const properties = {}; - - // 遍历组件属性 - for (const key in component) { - if (typeof component[key] !== "function" && !key.startsWith("_") && component[key] !== undefined) { - try { - properties[key] = component[key]; - } catch (e) { - // 忽略无法序列化的属性 - } - } - } - - return properties; - }, - - "instantiate-prefab": function (event, args) { - const { prefabUuid, parentId } = args; - const scene = cc.director.getScene(); - - if (!scene) { - if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); - return; - } - - if (!prefabUuid) { - if (event.reply) event.reply(new Error("必须提供预制体 UUID。")); - return; - } - - // 使用 cc.assetManager.loadAny 通过 UUID 加载 (Cocos 2.4+) - // 如果是旧版,可能需要 cc.loader.load({uuid: ...}),但在 2.4 环境下 assetManager 更推荐 - cc.assetManager.loadAny(prefabUuid, (err, prefab) => { - if (err) { - if (event.reply) event.reply(new Error(`加载预制体失败: ${err.message}`)); - return; - } - - // 实例化预制体 - const instance = cc.instantiate(prefab); - if (!instance) { - if (event.reply) event.reply(new Error("实例化预制体失败")); - return; - } - - // 设置父节点 - let parent = parentId ? cc.engine.getInstanceById(parentId) : scene; - if (parent) { - instance.parent = parent; - - // 通知场景变脏 - Editor.Ipc.sendToMain("scene:dirty"); - - // 通知 UI 刷新 - setTimeout(() => { - Editor.Ipc.sendToAll("scene:node-created", { - uuid: instance.uuid, - parentUuid: parent.uuid, - }); - }, 10); - - if (event.reply) event.reply(null, `预制体实例化成功,UUID: ${instance.uuid}`); - } else { - if (event.reply) event.reply(new Error("找不到父节点")); - } - }); - }, - - /** - * 根据特定条件在场景中搜索节点 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (conditions, recursive) - */ - "find-gameobjects": function (event, args) { - const { conditions, recursive = true } = args; - const result = []; - const scene = cc.director.getScene(); - - function searchNode(node) { - if ( - !node || - !node.name || - (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) - ) { - return; - } - - // 检查节点是否满足条件 - let match = true; - - if (conditions.name && !node.name.includes(conditions.name)) { - match = false; - } - - if (conditions.component) { - let hasComponent = false; - try { - if (conditions.component.startsWith("cc.")) { - const className = conditions.component.replace("cc.", ""); - hasComponent = node.getComponent(cc[className]) !== null; - } else { - hasComponent = node.getComponent(conditions.component) !== null; - } - } catch (e) { - hasComponent = false; - } - if (!hasComponent) { - match = false; - } - } - - if (conditions.active !== undefined && node.active !== conditions.active) { - match = false; - } - - if (match) { - const comps = node._components || []; - result.push({ - uuid: node.uuid, - name: node.name, - active: node.active, - components: comps.map((c) => { - const parts = (cc.js.getClassName(c) || "").split("."); - return parts[parts.length - 1]; // 简化的组件名 - }), - childrenCount: node.childrenCount, - }); - } - - // 递归搜索子节点 - if (recursive) { - for (let i = 0; i < node.childrenCount; i++) { - searchNode(node.children[i]); - } - } - } - - // 从场景根节点开始搜索 - if (scene) { - searchNode(scene); - } - - if (event.reply) { - event.reply(null, result); - } - }, - - /** - * 删除指定的场景节点 - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (uuid) - */ - "delete-node": function (event, args) { - const { uuid } = args; - const node = findNode(uuid); - if (node) { - const parent = node.parent; - node.destroy(); - Editor.Ipc.sendToMain("scene:dirty"); - // 延迟通知以确保节点已被移除 - setTimeout(() => { - if (parent) { - Editor.Ipc.sendToAll("scene:node-changed", { uuid: parent.uuid }); - } - // 广播节点删除事件 - Editor.Ipc.sendToAll("scene:node-deleted", { uuid: uuid }); - }, 10); - - if (event.reply) event.reply(null, `节点 ${uuid} 已删除`); - } else { - if (event.reply) event.reply(new Error(`找不到节点: ${uuid}`)); - } - }, - - /** - * 管理高效的全场景特效 (粒子系统) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (action, nodeId, properties, name, parentId) - */ - "manage-vfx": function (event, args) { - const { action, nodeId, properties, name, parentId } = args; - const scene = cc.director.getScene(); - - const applyParticleProperties = (particleSystem, props) => { - if (!props) return; - - if (props.duration !== undefined) particleSystem.duration = props.duration; - if (props.emissionRate !== undefined) particleSystem.emissionRate = props.emissionRate; - if (props.life !== undefined) particleSystem.life = props.life; - if (props.lifeVar !== undefined) particleSystem.lifeVar = props.lifeVar; - - // 【关键修复】启用自定义属性,否则属性修改可能不生效 - particleSystem.custom = true; - - if (props.startColor) particleSystem.startColor = new cc.Color().fromHEX(props.startColor); - if (props.endColor) particleSystem.endColor = new cc.Color().fromHEX(props.endColor); - - if (props.startSize !== undefined) particleSystem.startSize = props.startSize; - if (props.endSize !== undefined) particleSystem.endSize = props.endSize; - - if (props.speed !== undefined) particleSystem.speed = props.speed; - if (props.angle !== undefined) particleSystem.angle = props.angle; - - if (props.gravity) { - if (props.gravity.x !== undefined) particleSystem.gravity.x = props.gravity.x; - if (props.gravity.y !== undefined) particleSystem.gravity.y = props.gravity.y; - } - - // 处理文件/纹理加载 - if (props.file) { - // main.js 已经将 db:// 路径转换为 UUID - // 如果用户直接传递 URL (http/https) 或其他格式,cc.assetManager.loadAny 也能处理 - const uuid = props.file; - cc.assetManager.loadAny(uuid, (err, asset) => { - if (!err) { - if (asset instanceof cc.ParticleAsset) { - particleSystem.file = asset; - } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { - particleSystem.texture = asset; - } - Editor.Ipc.sendToMain("scene:dirty"); - } - }); - } else if (!particleSystem.texture && !particleSystem.file && args.defaultSpriteUuid) { - // 【关键修复】如果没有纹理,加载默认纹理 (UUID 由 main.js 传入) - Editor.log(`[mcp-bridge] Loading default texture with UUID: ${args.defaultSpriteUuid}`); - cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { - if (err) { - Editor.error(`[mcp-bridge] Failed to load default texture: ${err.message}`); - } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { - Editor.log(`[mcp-bridge] Default texture loaded successfully.`); - particleSystem.texture = asset; - Editor.Ipc.sendToMain("scene:dirty"); - } else { - Editor.warn(`[mcp-bridge] Loaded asset is not a texture: ${asset}`); - } - }); - } - }; - - if (action === "create") { - let newNode = new cc.Node(name || "New Particle"); - let particleSystem = newNode.addComponent(cc.ParticleSystem); - - // 设置默认值 - particleSystem.resetSystem(); - particleSystem.custom = true; // 确保新创建的也是 custom 模式 - - applyParticleProperties(particleSystem, properties); - - let parent = parentId ? cc.engine.getInstanceById(parentId) : scene; - if (parent) { - newNode.parent = parent; - Editor.Ipc.sendToMain("scene:dirty"); - setTimeout(() => { - Editor.Ipc.sendToAll("scene:node-created", { - uuid: newNode.uuid, - parentUuid: parent.uuid, - }); - }, 10); - if (event.reply) event.reply(null, newNode.uuid); - } else { - if (event.reply) event.reply(new Error("找不到父节点")); - } - } else if (action === "update") { - let node = findNode(nodeId); - if (node) { - let particleSystem = node.getComponent(cc.ParticleSystem); - if (!particleSystem) { - // 如果没有组件,自动添加 - particleSystem = node.addComponent(cc.ParticleSystem); - } - - applyParticleProperties(particleSystem, properties); - - Editor.Ipc.sendToMain("scene:dirty"); - Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); - if (event.reply) event.reply(null, "特效已更新"); - } else { - if (event.reply) event.reply(new Error("找不到节点")); - } - } else if (action === "get_info") { - let node = findNode(nodeId); - if (node) { - let ps = node.getComponent(cc.ParticleSystem); - if (ps) { - const info = { - duration: ps.duration, - emissionRate: ps.emissionRate, - life: ps.life, - lifeVar: ps.lifeVar, - startColor: ps.startColor.toHEX("#RRGGBB"), - endColor: ps.endColor.toHEX("#RRGGBB"), - startSize: ps.startSize, - endSize: ps.endSize, - speed: ps.speed, - angle: ps.angle, - gravity: { x: ps.gravity.x, y: ps.gravity.y }, - file: ps.file ? ps.file.name : null, - }; - if (event.reply) event.reply(null, info); - } else { - if (event.reply) event.reply(null, { hasParticleSystem: false }); - } - } else { - if (event.reply) event.reply(new Error("找不到节点")); - } - } else { - if (event.reply) event.reply(new Error(`未知的特效操作类型: ${action}`)); - } - }, - - /** - * 控制节点的动画组件 (播放、暂停、停止等) - * @param {Object} event IPC 事件对象 - * @param {Object} args 参数 (action, nodeId, clipName) - */ - "manage-animation": function (event, args) { - const { action, nodeId, clipName } = args; - const node = findNode(nodeId); - - if (!node) { - if (event.reply) event.reply(new Error(`找不到节点: ${nodeId}`)); - return; - } - - const anim = node.getComponent(cc.Animation); - if (!anim) { - if (event.reply) event.reply(new Error(`在节点 ${nodeId} 上找不到 Animation 组件`)); - return; - } - - switch (action) { - case "get_list": - const clips = anim.getClips(); - const clipList = clips.map((c) => ({ - name: c.name, - duration: c.duration, - sample: c.sample, - speed: c.speed, - wrapMode: c.wrapMode, - })); - if (event.reply) event.reply(null, clipList); - break; - - case "get_info": - const currentClip = anim.currentClip; - let isPlaying = false; - // [安全修复] 只有在有当前 Clip 时才获取状态,避免 Animation 组件无 Clip 时的崩溃 - if (currentClip) { - const state = anim.getAnimationState(currentClip.name); - if (state) { - isPlaying = state.isPlaying; - } - } - const info = { - currentClip: currentClip ? currentClip.name : null, - clips: anim.getClips().map((c) => c.name), - playOnLoad: anim.playOnLoad, - isPlaying: isPlaying, - }; - if (event.reply) event.reply(null, info); - break; - - case "play": - if (!clipName) { - anim.play(); - if (event.reply) event.reply(null, "正在播放默认动画剪辑"); - } else { - anim.play(clipName); - if (event.reply) event.reply(null, `正在播放动画剪辑: ${clipName}`); - } - break; - - case "stop": - anim.stop(); - if (event.reply) event.reply(null, "动画已停止"); - break; - - case "pause": - anim.pause(); - if (event.reply) event.reply(null, "动画已暂停"); - break; - - case "resume": - anim.resume(); - if (event.reply) event.reply(null, "动画已恢复播放"); - break; - - default: - if (event.reply) event.reply(new Error(`未知的动画操作类型: ${action}`)); - break; - } - }, + /** + * 修改节点的基础属性 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (id, path, value) + */ + "set-property": function (event, args) { + const { id, path, value } = args; + + // 1. 获取节点 + let node = findNode(id); + + if (node) { + // 2. 修改属性 + if (path === "name") { + node.name = value; + } else { + node[path] = value; + } + + // 3. 【解决报错的关键】告诉编辑器场景变脏了(需要保存) + // 在场景进程中,我们发送 IPC 给主进程 + Editor.Ipc.sendToMain("scene:dirty"); + + // 4. 【额外补丁】通知层级管理器(Hierarchy)同步更新节点名称 + // 否则你修改了名字,层级管理器可能还是显示旧名字 + Editor.Ipc.sendToAll("scene:node-changed", { + uuid: id, + }); + + if (event.reply) { + event.reply(null, `节点 ${id} 已更新为 ${value}`); + } + } else { + if (event.reply) { + event.reply(new Error("场景脚本:找不到节点 " + id)); + } + } + }, + /** + * 获取当前场景的完整层级树 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (nodeId, depth, includeDetails) + */ + "get-hierarchy": function (event, args) { + const { nodeId = null, depth = 2, includeDetails = false } = args || {}; + const scene = cc.director.getScene(); + + let rootNode = scene; + if (nodeId) { + rootNode = findNode(nodeId); + if (!rootNode) { + if (event.reply) event.reply(new Error(`找不到指定的起始节点: ${nodeId}`)); + return; + } + } + + /** + * 递归遍历并序列化节点树 + * @param {cc.Node} node 目标节点 + * @param {number} currentDepth 当前深度 + * @returns {Object|null} 序列化后的节点数据 + */ + function dumpNodes(node, currentDepth) { + // 【优化】跳过编辑器内部的私有节点,减少数据量 + if ( + !node || + !node.name || + (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) + ) { + return null; + } + + let nodeData = { + name: node.name, + uuid: node.uuid, + childrenCount: node.childrenCount, + }; + + const comps = node._components || []; + + // 根据是否需要详情来决定附加哪些数据以节省 Token + if (includeDetails) { + nodeData.active = node.active; + nodeData.position = { x: Math.round(node.x), y: Math.round(node.y) }; + nodeData.scale = { x: node.scaleX, y: node.scaleY }; + nodeData.size = { width: node.width, height: node.height }; + nodeData.components = comps.map((c) => cc.js.getClassName(c)); + } else { + // 简略模式下如果存在组件,至少提供一个极简列表让 AI 知道节点的作用 + if (comps.length > 0) { + nodeData.components = comps.map((c) => { + const parts = (cc.js.getClassName(c) || "").split("."); + return parts[parts.length - 1]; // 只取类名,例如 cc.Sprite -> Sprite + }); + } + } + + // 如果未超出深度限制,继续递归子树 + if (currentDepth < depth && node.childrenCount > 0) { + nodeData.children = []; + for (let i = 0; i < node.childrenCount; i++) { + let childData = dumpNodes(node.children[i], currentDepth + 1); + if (childData) nodeData.children.push(childData); + } + } + + return nodeData; + } + + const hierarchy = dumpNodes(rootNode, 0); + if (event.reply) event.reply(null, hierarchy); + }, + + /** + * 批量更新节点的变换信息 (坐标、缩放、颜色) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (id, x, y, scaleX, scaleY, color) + */ + "update-node-transform": function (event, args) { + const { id, x, y, scaleX, scaleY, color } = args; + + let node = findNode(id); + + if (node) { + // 使用 scene:set-property 实现支持 Undo 的属性修改 + // 注意:IPC 消息需要发送到 'scene' 面板 + if (x !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "x", + type: "Number", + value: Number(x), + }); + } + if (y !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "y", + type: "Number", + value: Number(y), + }); + } + if (args.width !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "width", + type: "Number", + value: Number(args.width), + }); + } + if (args.height !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "height", + type: "Number", + value: Number(args.height), + }); + } + if (scaleX !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "scaleX", + type: "Number", + value: Number(scaleX), + }); + } + if (scaleY !== undefined) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id, + path: "scaleY", + type: "Number", + value: Number(scaleY), + }); + } + if (color) { + const c = new cc.Color().fromHEX(color); + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id: id, + path: "color", + type: "Color", + value: { r: c.r, g: c.g, b: c.b, a: c.a }, + }); + } + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: id }); + + if (event.reply) event.reply(null, "变换信息已更新"); + } else { + if (event.reply) event.reply(new Error("找不到节点")); + } + }, + /** + * 在场景中创建新节点 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (name, parentId, type) + */ + "create-node": function (event, args) { + const { name, parentId, type } = args; + const scene = cc.director.getScene(); + if (!scene) { + if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); + return; + } + + let newNode = null; + + // 特殊处理:如果是创建 Canvas,自动设置好适配 + if (type === "canvas" || name === "Canvas") { + newNode = new cc.Node("Canvas"); + let canvas = newNode.addComponent(cc.Canvas); + newNode.addComponent(cc.Widget); + // 设置默认设计分辨率 + canvas.designResolution = cc.size(960, 640); + canvas.fitHeight = true; + // 自动在 Canvas 下创建一个 Camera + let camNode = new cc.Node("Main Camera"); + camNode.addComponent(cc.Camera); + camNode.parent = newNode; + } else if (type === "sprite") { + newNode = new cc.Node(name || "新建精灵节点"); + let sprite = newNode.addComponent(cc.Sprite); + // 设置为 CUSTOM 模式 + sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; + // 为精灵设置默认尺寸 + newNode.width = 100; + newNode.height = 100; + + // 加载引擎默认图做占位 + if (args.defaultSpriteUuid) { + cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { + if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { + sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + } + } else if (type === "button") { + newNode = new cc.Node(name || "新建按钮节点"); + let sprite = newNode.addComponent(cc.Sprite); + newNode.addComponent(cc.Button); + + // 设置为 CUSTOM 模式并应用按钮专用尺寸 + sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; + newNode.width = 150; + newNode.height = 50; + + // 设置稍暗的背景颜色 (#A0A0A0),以便于看清其上的文字 + newNode.color = new cc.Color(160, 160, 160); + + // 加载引擎默认图 + if (args.defaultSpriteUuid) { + cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { + if (!err && (asset instanceof cc.SpriteFrame || asset instanceof cc.Texture2D)) { + sprite.spriteFrame = asset instanceof cc.SpriteFrame ? asset : new cc.SpriteFrame(asset); + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + } + } else if (type === "label") { + newNode = new cc.Node(name || "新建文本节点"); + let l = newNode.addComponent(cc.Label); + l.string = "新文本"; + newNode.width = 120; + newNode.height = 40; + } else { + newNode = new cc.Node(name || "新建节点"); + } + + // 设置层级 + let parent = parentId ? findNode(parentId) : scene; + if (parent) { + newNode.parent = parent; + + // 【优化】通知主进程场景变脏 + Editor.Ipc.sendToMain("scene:dirty"); + + // 【关键】使用 setTimeout 延迟通知 UI 刷新,让出主循环 + setTimeout(() => { + Editor.Ipc.sendToAll("scene:node-created", { + uuid: newNode.uuid, + parentUuid: parent.uuid, + }); + }, 10); + + if (event.reply) event.reply(null, newNode.uuid); + } else { + if (event.reply) event.reply(new Error(`无法创建节点:找不到父节点 ${parentId}`)); + } + }, + + /** + * 管理节点上的组件 (添加、移除、更新属性) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (nodeId, action, componentType, componentId, properties) + */ + "manage-components": function (event, args) { + let { nodeId, action, operation, componentType, componentId, properties } = args; + // 兼容 AI 幻觉带来的传参错误 + action = action || operation; + + let node = findNode(nodeId); + + /** + * 辅助函数:应用属性并智能解析 (支持 UUID 资源与节点引用) + * @param {cc.Component} component 目标组件实例 + * @param {Object} props 待更新的属性键值对 + */ + const applyProperties = (component, props) => { + if (!props) return; + // 尝试获取组件类的属性定义 + const compClass = component.constructor; + + for (const [key, value] of Object.entries(props)) { + // 【防呆设计】拦截对核心只读属性的非法重写 + // 如果直接修改组件的 node 属性,会导致该引用丢失变成普通对象,进而引发编辑器卡死 + if (key === "node" || key === "uuid" || key === "_id") { + Editor.warn( + `[scene-script] 拒绝覆盖组件的只读/核心属性: ${key}。请勿对组件执行此操作,修改位置/激活状态等请操作 Node 节点!`, + ); + continue; + } + + // 【核心修复】专门处理各类事件属性 (ClickEvents, ScrollEvents 等) + const isEventProp = + Array.isArray(value) && (key.toLowerCase().endsWith("events") || key === "clickEvents"); + + if (isEventProp) { + const eventHandlers = []; + for (const item of value) { + if (typeof item === "object" && (item.target || item.component || item.handler)) { + const handler = new cc.Component.EventHandler(); + + // 解析 Target Node + if (item.target) { + let targetNode = findNode(item.target); + if (!targetNode && item.target instanceof cc.Node) { + targetNode = item.target; + } + + if (targetNode) { + handler.target = targetNode; + } + } + + if (item.component) handler.component = item.component; + if (item.handler) handler.handler = item.handler; + if (item.customEventData !== undefined) + handler.customEventData = String(item.customEventData); + + eventHandlers.push(handler); + } else { + // 如果不是对象,原样保留 + eventHandlers.push(item); + } + } + component[key] = eventHandlers; + continue; // 处理完事件数组,跳出本次循环 + } + + // 检查属性是否存在 + if (component[key] !== undefined) { + let finalValue = value; + + // 【核心逻辑】智能类型识别与赋值 + try { + const attrs = (cc.Class.Attr.getClassAttrs && cc.Class.Attr.getClassAttrs(compClass)) || {}; + let propertyType = attrs[key] ? attrs[key].type : null; + if (!propertyType && attrs[key + "$_$ctor"]) { + propertyType = attrs[key + "$_$ctor"]; + } + + let isAsset = + propertyType && + (propertyType.prototype instanceof cc.Asset || + propertyType === cc.Asset || + propertyType === cc.Prefab || + propertyType === cc.SpriteFrame); + let isAssetArray = + Array.isArray(value) && (key === "materials" || key.toLowerCase().includes("assets")); + + // 启发式:如果属性名包含 prefab/sprite/texture 等,且值为 UUID 且不是节点 + if (!isAsset && !isAssetArray && typeof value === "string" && value.length > 20) { + const lowerKey = key.toLowerCase(); + const assetKeywords = [ + "prefab", + "sprite", + "texture", + "material", + "skeleton", + "spine", + "atlas", + "font", + "audio", + "data", + ]; + if (assetKeywords.some((k) => lowerKey.includes(k))) { + if (!findNode(value)) { + isAsset = true; + } + } + } + + if (isAsset || isAssetArray) { + // 1. 处理资源引用 (单个或数组) + const uuids = isAssetArray ? value : [value]; + const loadedAssets = []; + let loadedCount = 0; + + if (uuids.length === 0) { + component[key] = []; + return; + } + + uuids.forEach((uuid, idx) => { + if (typeof uuid !== "string" || uuid.length < 10) { + loadedCount++; + return; + } + cc.AssetLibrary.loadAsset(uuid, (err, asset) => { + loadedCount++; + if (!err && asset) { + loadedAssets[idx] = asset; + } else { + Editor.warn(`[scene-script] 未能为 ${key}[${idx}] 加载资源 ${uuid}: ${err}`); + } + + if (loadedCount === uuids.length) { + if (isAssetArray) { + // 过滤掉加载失败的 + component[key] = loadedAssets.filter((a) => !!a); + } else { + if (loadedAssets[0]) component[key] = loadedAssets[0]; + } + + // 通知编辑器 UI 更新 + const compIndex = node._components.indexOf(component); + if (compIndex !== -1) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id: node.uuid, + path: `_components.${compIndex}.${key}`, + type: isAssetArray ? "Array" : "Object", + value: isAssetArray ? uuids.map((u) => ({ uuid: u })) : { uuid: value }, + isSubProp: false, + }); + } + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + }); + // 【重要修复】使用 continue 而不是 return,确保处理完 Asset 属性后 + // 还能继续处理后续的普通属性 (如 type, sizeMode 等) + continue; + } else if ( + propertyType && + (propertyType.prototype instanceof cc.Component || + propertyType === cc.Component || + propertyType === cc.Node) + ) { + // 2. 处理节点或组件引用 + const targetNode = findNode(value); + if (targetNode) { + if (propertyType === cc.Node) { + finalValue = targetNode; + } else { + const targetComp = targetNode.getComponent(propertyType); + if (targetComp) { + finalValue = targetComp; + } else { + Editor.warn( + `[scene-script] 在节点 ${targetNode.name} 上找不到组件 ${propertyType.name}`, + ); + } + } + } else if (value && value.length > 20) { + // 如果明确是组件/节点类型但找不到,才报错 + Editor.warn(`[scene-script] 无法解析 ${key} 的目标节点/组件: ${value}`); + } + } else { + // 3. 通用启发式 (找不到类型时的 fallback) + if (typeof value === "string" && value.length > 20) { + const targetNode = findNode(value); + if (targetNode) { + finalValue = targetNode; + } else { + // 找不到节点且是 UUID -> 视为资源 + const compIndex = node._components.indexOf(component); + if (compIndex !== -1) { + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id: node.uuid, + path: `_components.${compIndex}.${key}`, + type: "Object", + value: { uuid: value }, + isSubProp: false, + }); + } + continue; + } + } + } + } catch (e) { + Editor.warn(`[scene-script] 解析属性 ${key} 失败: ${e.message}`); + } + + component[key] = finalValue; + } + } + }; + + if (!node) { + if (event.reply) event.reply(new Error("找不到节点")); + return; + } + + switch (action) { + case "add": + if (!componentType) { + if (event.reply) event.reply(new Error("必须提供组件类型")); + return; + } + + // 【防呆设计】拦截 AI 错误地将 cc.Node 作为组件添加 + if (componentType === "cc.Node" || componentType === "Node") { + if (event.reply) { + event.reply( + new Error( + "【纠错提示】cc.Node 是节点而不是组件,无法被当做组件添加!\n" + + "- 如果你想创建带有名字的子节点,请不要使用 manage_components,而是使用 create-node (或相应的创建节点工具)。\n" + + "- 如果你想修改现有节点的 name 属性,请使用修改节点的 set-property 工具。", + ), + ); + } + return; + } + + try { + // 解析组件类型 + let compClass = null; + if (componentType.startsWith("cc.")) { + const className = componentType.replace("cc.", ""); + compClass = cc[className]; + } else { + // 尝试获取自定义组件 + compClass = cc.js.getClassByName(componentType); + } + + if (!compClass) { + if (event.reply) event.reply(new Error(`找不到组件类型: ${componentType}`)); + return; + } + + // 【防呆设计】确保获取到的类是一个组件 + if (!cc.js.isChildClassOf(compClass, cc.Component)) { + if (event.reply) { + event.reply( + new Error( + `【错误】'${componentType}' 不是一个合法的组件类型(必须继承自 cc.Component)。请确认你的意图。`, + ), + ); + } + return; + } + + // 添加组件 + const component = node.addComponent(compClass); + + // 设置属性 + if (properties) { + applyProperties(component, properties); + } + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + + if (event.reply) event.reply(null, `组件 ${componentType} 已添加`); + } catch (err) { + if (event.reply) event.reply(new Error(`添加组件失败: ${err.message}`)); + } + break; + + case "remove": + if (!componentId) { + if (event.reply) event.reply(new Error("必须提供组件 ID")); + return; + } + + try { + // 查找并移除组件 + let component = null; + if (node._components) { + for (let i = 0; i < node._components.length; i++) { + if (node._components[i].uuid === componentId) { + component = node._components[i]; + break; + } + } + } + + if (component) { + node.removeComponent(component); + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + if (event.reply) event.reply(null, "组件已移除"); + } else { + if (event.reply) event.reply(new Error("找不到组件")); + } + } catch (err) { + if (event.reply) event.reply(new Error(`移除组件失败: ${err.message}`)); + } + break; + + case "update": + // 更新现有组件属性 + if (!componentType) { + // 如果提供了 componentId,可以只用 componentId + // 但 Cocos 2.4 uuid 获取组件比较麻烦,最好还是有 type 或者遍历 + } + + try { + let targetComp = null; + + // 1. 尝试通过 componentId 查找 + if (componentId) { + if (node._components) { + for (let i = 0; i < node._components.length; i++) { + if (node._components[i].uuid === componentId) { + targetComp = node._components[i]; + break; + } + } + } + } + + // 2. 尝试通过 type 查找 + if (!targetComp && componentType) { + let compClass = null; + if (componentType.startsWith("cc.")) { + const className = componentType.replace("cc.", ""); + compClass = cc[className]; + } else { + compClass = cc.js.getClassByName(componentType); + } + if (compClass) { + targetComp = node.getComponent(compClass); + } + } + + if (targetComp) { + if (properties) { + applyProperties(targetComp, properties); + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + if (event.reply) event.reply(null, "组件属性已更新"); + } else { + if (event.reply) event.reply(null, "没有需要更新的属性"); + } + } else { + if (event.reply) + event.reply(new Error(`找不到组件 (类型: ${componentType}, ID: ${componentId})`)); + } + } catch (err) { + if (event.reply) event.reply(new Error(`更新组件失败: ${err.message}`)); + } + break; + + case "get": + try { + const components = node._components.map((c) => { + // 获取组件属性 + const properties = {}; + for (const key in c) { + if (typeof c[key] !== "function" && !key.startsWith("_") && c[key] !== undefined) { + try { + // 安全序列化检查 + const val = c[key]; + if (val === null || val === undefined) { + properties[key] = val; + continue; + } + + // 基础类型是安全的 + if (typeof val !== "object") { + // 【优化】对于超长字符串进行截断 + if (typeof val === "string" && val.length > 200) { + properties[key] = + val.substring(0, 50) + `...[Truncated, total length: ${val.length}]`; + } else { + properties[key] = val; + } + continue; + } + + // 特殊 Cocos 类型 + if (val instanceof cc.ValueType) { + properties[key] = val.toString(); + } else if (val instanceof cc.Asset) { + properties[key] = `资源(${val.name})`; + } else if (val instanceof cc.Node) { + properties[key] = `节点(${val.name})`; + } else if (val instanceof cc.Component) { + properties[key] = `组件(${val.name}<${val.__typename}>)`; + } else { + // 数组和普通对象 + // 【优化】对于超长数组直接截断并提示,防止返回巨大的坐标或点集 + if (Array.isArray(val) && val.length > 10) { + properties[key] = `[Array(${val.length})]`; + continue; + } + + // 尝试转换为纯 JSON 数据以避免 IPC 错误(如包含原生对象/循环引用) + try { + const jsonStr = JSON.stringify(val); + if (jsonStr && jsonStr.length > 500) { + properties[key] = `[Large JSON Object, length: ${jsonStr.length}]`; + } else { + // 确保不传递原始对象引用 + properties[key] = JSON.parse(jsonStr); + } + } catch (e) { + // 如果 JSON 失败(例如循环引用),格式化为字符串 + properties[key] = + `[复杂对象: ${val.constructor ? val.constructor.name : typeof val}]`; + } + } + } catch (e) { + properties[key] = "[Serialization Error]"; + } + } + } + return { + type: cc.js.getClassName(c) || c.constructor.name || "Unknown", + uuid: c.uuid, + properties: properties, + }; + }); + if (event.reply) event.reply(null, components); + } catch (err) { + if (event.reply) event.reply(new Error(`获取组件失败: ${err.message}`)); + } + break; + + default: + if (event.reply) event.reply(new Error(`未知的组件操作类型: ${action}`)); + break; + } + }, + + "get-component-properties": function (component) { + const properties = {}; + + // 遍历组件属性 + for (const key in component) { + if (typeof component[key] !== "function" && !key.startsWith("_") && component[key] !== undefined) { + try { + properties[key] = component[key]; + } catch (e) { + // 忽略无法序列化的属性 + } + } + } + + return properties; + }, + + "instantiate-prefab": function (event, args) { + const { prefabUuid, parentId } = args; + const scene = cc.director.getScene(); + + if (!scene) { + if (event.reply) event.reply(new Error("场景尚未准备好或正在加载。")); + return; + } + + if (!prefabUuid) { + if (event.reply) event.reply(new Error("必须提供预制体 UUID。")); + return; + } + + // 使用 cc.assetManager.loadAny 通过 UUID 加载 (Cocos 2.4+) + // 如果是旧版,可能需要 cc.loader.load({uuid: ...}),但在 2.4 环境下 assetManager 更推荐 + cc.assetManager.loadAny(prefabUuid, (err, prefab) => { + if (err) { + if (event.reply) event.reply(new Error(`加载预制体失败: ${err.message}`)); + return; + } + + // 实例化预制体 + const instance = cc.instantiate(prefab); + if (!instance) { + if (event.reply) event.reply(new Error("实例化预制体失败")); + return; + } + + // 设置父节点 + let parent = parentId ? findNode(parentId) : scene; + if (parent) { + instance.parent = parent; + + // 通知场景变脏 + Editor.Ipc.sendToMain("scene:dirty"); + + // 通知 UI 刷新 + setTimeout(() => { + Editor.Ipc.sendToAll("scene:node-created", { + uuid: instance.uuid, + parentUuid: parent.uuid, + }); + }, 10); + + if (event.reply) event.reply(null, `预制体实例化成功,UUID: ${instance.uuid}`); + } else { + if (event.reply) event.reply(new Error("找不到父节点")); + } + }); + }, + + /** + * 根据特定条件在场景中搜索节点 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (conditions, recursive) + */ + "find-gameobjects": function (event, args) { + const { conditions, recursive = true } = args; + const result = []; + const scene = cc.director.getScene(); + + function searchNode(node) { + if ( + !node || + !node.name || + (typeof node.name === "string" && (node.name.startsWith("Editor Scene") || node.name === "gizmoRoot")) + ) { + return; + } + + // 检查节点是否满足条件 + let match = true; + + if (conditions.name && !node.name.includes(conditions.name)) { + match = false; + } + + if (conditions.component) { + let hasComponent = false; + try { + if (conditions.component.startsWith("cc.")) { + const className = conditions.component.replace("cc.", ""); + hasComponent = node.getComponent(cc[className]) !== null; + } else { + hasComponent = node.getComponent(conditions.component) !== null; + } + } catch (e) { + hasComponent = false; + } + if (!hasComponent) { + match = false; + } + } + + if (conditions.active !== undefined && node.active !== conditions.active) { + match = false; + } + + if (match) { + const comps = node._components || []; + result.push({ + uuid: node.uuid, + name: node.name, + active: node.active, + components: comps.map((c) => { + const parts = (cc.js.getClassName(c) || "").split("."); + return parts[parts.length - 1]; // 简化的组件名 + }), + childrenCount: node.childrenCount, + }); + } + + // 递归搜索子节点 + if (recursive) { + for (let i = 0; i < node.childrenCount; i++) { + searchNode(node.children[i]); + } + } + } + + // 从场景根节点开始搜索 + if (scene) { + searchNode(scene); + } + + if (event.reply) { + event.reply(null, result); + } + }, + + /** + * 删除指定的场景节点 + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (uuid) + */ + "delete-node": function (event, args) { + const { uuid } = args; + const node = findNode(uuid); + if (node) { + const parent = node.parent; + node.destroy(); + Editor.Ipc.sendToMain("scene:dirty"); + // 延迟通知以确保节点已被移除 + setTimeout(() => { + if (parent) { + Editor.Ipc.sendToAll("scene:node-changed", { uuid: parent.uuid }); + } + // 广播节点删除事件 + Editor.Ipc.sendToAll("scene:node-deleted", { uuid: uuid }); + }, 10); + + if (event.reply) event.reply(null, `节点 ${uuid} 已删除`); + } else { + if (event.reply) event.reply(new Error(`找不到节点: ${uuid}`)); + } + }, + + /** + * 管理高效的全场景特效 (粒子系统) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (action, nodeId, properties, name, parentId) + */ + "manage-vfx": function (event, args) { + const { action, nodeId, properties, name, parentId } = args; + const scene = cc.director.getScene(); + + const applyParticleProperties = (particleSystem, props) => { + if (!props) return; + + if (props.duration !== undefined) particleSystem.duration = props.duration; + if (props.emissionRate !== undefined) particleSystem.emissionRate = props.emissionRate; + if (props.life !== undefined) particleSystem.life = props.life; + if (props.lifeVar !== undefined) particleSystem.lifeVar = props.lifeVar; + + // 【关键修复】启用自定义属性,否则属性修改可能不生效 + particleSystem.custom = true; + + if (props.startColor) particleSystem.startColor = new cc.Color().fromHEX(props.startColor); + if (props.endColor) particleSystem.endColor = new cc.Color().fromHEX(props.endColor); + + if (props.startSize !== undefined) particleSystem.startSize = props.startSize; + if (props.endSize !== undefined) particleSystem.endSize = props.endSize; + + if (props.speed !== undefined) particleSystem.speed = props.speed; + if (props.angle !== undefined) particleSystem.angle = props.angle; + + if (props.gravity) { + if (props.gravity.x !== undefined) particleSystem.gravity.x = props.gravity.x; + if (props.gravity.y !== undefined) particleSystem.gravity.y = props.gravity.y; + } + + // 处理文件/纹理加载 + if (props.file) { + // main.js 已经将 db:// 路径转换为 UUID + // 如果用户直接传递 URL (http/https) 或其他格式,cc.assetManager.loadAny 也能处理 + const uuid = props.file; + cc.assetManager.loadAny(uuid, (err, asset) => { + if (!err) { + if (asset instanceof cc.ParticleAsset) { + particleSystem.file = asset; + } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { + particleSystem.texture = asset; + } + Editor.Ipc.sendToMain("scene:dirty"); + } + }); + } else if (!particleSystem.texture && !particleSystem.file && args.defaultSpriteUuid) { + // 【关键修复】如果没有纹理,加载默认纹理 (UUID 由 main.js 传入) + Editor.log(`[mcp-bridge] Loading default texture with UUID: ${args.defaultSpriteUuid}`); + cc.assetManager.loadAny(args.defaultSpriteUuid, (err, asset) => { + if (err) { + Editor.error(`[mcp-bridge] Failed to load default texture: ${err.message}`); + } else if (asset instanceof cc.Texture2D || asset instanceof cc.SpriteFrame) { + Editor.log(`[mcp-bridge] Default texture loaded successfully.`); + particleSystem.texture = asset; + Editor.Ipc.sendToMain("scene:dirty"); + } else { + Editor.warn(`[mcp-bridge] Loaded asset is not a texture: ${asset}`); + } + }); + } + }; + + if (action === "create") { + let newNode = new cc.Node(name || "New Particle"); + let particleSystem = newNode.addComponent(cc.ParticleSystem); + + // 设置默认值 + particleSystem.resetSystem(); + particleSystem.custom = true; // 确保新创建的也是 custom 模式 + + applyParticleProperties(particleSystem, properties); + + let parent = parentId ? cc.engine.getInstanceById(parentId) : scene; + if (parent) { + newNode.parent = parent; + Editor.Ipc.sendToMain("scene:dirty"); + setTimeout(() => { + Editor.Ipc.sendToAll("scene:node-created", { + uuid: newNode.uuid, + parentUuid: parent.uuid, + }); + }, 10); + if (event.reply) event.reply(null, newNode.uuid); + } else { + if (event.reply) event.reply(new Error("找不到父节点")); + } + } else if (action === "update") { + let node = findNode(nodeId); + if (node) { + let particleSystem = node.getComponent(cc.ParticleSystem); + if (!particleSystem) { + // 如果没有组件,自动添加 + particleSystem = node.addComponent(cc.ParticleSystem); + } + + applyParticleProperties(particleSystem, properties); + + Editor.Ipc.sendToMain("scene:dirty"); + Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId }); + if (event.reply) event.reply(null, "特效已更新"); + } else { + if (event.reply) event.reply(new Error("找不到节点")); + } + } else if (action === "get_info") { + let node = findNode(nodeId); + if (node) { + let ps = node.getComponent(cc.ParticleSystem); + if (ps) { + const info = { + duration: ps.duration, + emissionRate: ps.emissionRate, + life: ps.life, + lifeVar: ps.lifeVar, + startColor: ps.startColor.toHEX("#RRGGBB"), + endColor: ps.endColor.toHEX("#RRGGBB"), + startSize: ps.startSize, + endSize: ps.endSize, + speed: ps.speed, + angle: ps.angle, + gravity: { x: ps.gravity.x, y: ps.gravity.y }, + file: ps.file ? ps.file.name : null, + }; + if (event.reply) event.reply(null, info); + } else { + if (event.reply) event.reply(null, { hasParticleSystem: false }); + } + } else { + if (event.reply) event.reply(new Error("找不到节点")); + } + } else { + if (event.reply) event.reply(new Error(`未知的特效操作类型: ${action}`)); + } + }, + + /** + * 控制节点的动画组件 (播放、暂停、停止等) + * @param {Object} event IPC 事件对象 + * @param {Object} args 参数 (action, nodeId, clipName) + */ + "manage-animation": function (event, args) { + const { action, nodeId, clipName } = args; + const node = findNode(nodeId); + + if (!node) { + if (event.reply) event.reply(new Error(`找不到节点: ${nodeId}`)); + return; + } + + const anim = node.getComponent(cc.Animation); + if (!anim) { + if (event.reply) event.reply(new Error(`在节点 ${nodeId} 上找不到 Animation 组件`)); + return; + } + + switch (action) { + case "get_list": + const clips = anim.getClips(); + const clipList = clips.map((c) => ({ + name: c.name, + duration: c.duration, + sample: c.sample, + speed: c.speed, + wrapMode: c.wrapMode, + })); + if (event.reply) event.reply(null, clipList); + break; + + case "get_info": + const currentClip = anim.currentClip; + let isPlaying = false; + // [安全修复] 只有在有当前 Clip 时才获取状态,避免 Animation 组件无 Clip 时的崩溃 + if (currentClip) { + const state = anim.getAnimationState(currentClip.name); + if (state) { + isPlaying = state.isPlaying; + } + } + const info = { + currentClip: currentClip ? currentClip.name : null, + clips: anim.getClips().map((c) => c.name), + playOnLoad: anim.playOnLoad, + isPlaying: isPlaying, + }; + if (event.reply) event.reply(null, info); + break; + + case "play": + if (!clipName) { + anim.play(); + if (event.reply) event.reply(null, "正在播放默认动画剪辑"); + } else { + anim.play(clipName); + if (event.reply) event.reply(null, `正在播放动画剪辑: ${clipName}`); + } + break; + + case "stop": + anim.stop(); + if (event.reply) event.reply(null, "动画已停止"); + break; + + case "pause": + anim.pause(); + if (event.reply) event.reply(null, "动画已暂停"); + break; + + case "resume": + anim.resume(); + if (event.reply) event.reply(null, "动画已恢复播放"); + break; + + default: + if (event.reply) event.reply(new Error(`未知的动画操作类型: ${action}`)); + break; + } + }, }; From c9e0f45c9d7c2e5d09154f03fc21a2ac7f1a35a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=AB=E7=84=B0=E5=BA=93=E6=8B=89?= Date: Sat, 28 Feb 2026 09:33:36 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=B0=86=20TypeScrip?= =?UTF-8?q?t=20=E8=BD=AC=E4=B8=BA=20JavaScript=20=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=A1=B9=E7=9B=AE=E6=96=87=E4=BB=B6=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 src/IpcManager.ts 和 src/IpcUi.ts 手动转写为干净的 JavaScript,消除编译产物中的 polyfill 代码 - 移动 main.js、scene-script.js、mcp-proxy.js 至 src/ 目录,统一源码管理 - 移动 DEVELOPMENT.md、IPC_MESSAGES.md、UPDATE_LOG.md、注意事项.md 至 docs/ 目录 - 删除 dist/ 编译产物目录和 tsconfig.json - 更新 package.json 入口路径、所有模块引用路径 - 更新 README.md 和 DEVELOPMENT.md 中的架构说明、文件路径引用和项目规范 - 更新 .gitignore 启用 dist 忽略规则 --- .gitignore | 2 +- DEVELOPMENT.md | 537 ------------------------ README.md | 27 +- dist/IpcManager.js | 141 ------- dist/IpcUi.js | 353 ---------------- IPC_MESSAGES.md => docs/IPC_MESSAGES.md | 0 UPDATE_LOG.md => docs/UPDATE_LOG.md | 0 注意事项.md => docs/注意事项.md | 0 package.json | 4 +- panel/index.js | 4 +- src/IpcManager.js | 95 +++++ src/IpcManager.ts | 106 ----- src/IpcUi.js | 226 ++++++++++ src/IpcUi.ts | 226 ---------- main.js => src/main.js | 2 +- mcp-proxy.js => src/mcp-proxy.js | 0 scene-script.js => src/scene-script.js | 0 tsconfig.json | 20 - 18 files changed, 342 insertions(+), 1401 deletions(-) delete mode 100644 DEVELOPMENT.md delete mode 100644 dist/IpcManager.js delete mode 100644 dist/IpcUi.js rename IPC_MESSAGES.md => docs/IPC_MESSAGES.md (100%) rename UPDATE_LOG.md => docs/UPDATE_LOG.md (100%) rename 注意事项.md => docs/注意事项.md (100%) create mode 100644 src/IpcManager.js delete mode 100644 src/IpcManager.ts create mode 100644 src/IpcUi.js delete mode 100644 src/IpcUi.ts rename main.js => src/main.js (99%) rename mcp-proxy.js => src/mcp-proxy.js (100%) rename scene-script.js => src/scene-script.js (100%) delete mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 8bdea99..331ad62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -# **/dist +**/dist **/node_modules **/package-lock.json \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 0400c54..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,537 +0,0 @@ -# MCP Bridge 插件开发流程文档 - -本文档记录了 MCP Bridge 插件的完整开发流程,包括核心架构设计、功能实现、测试与调试等各个阶段。 - -## 0. 项目开发规范 (Project Rules) - -> [!IMPORTANT] -> 所有贡献者必须严格遵守以下规则: - -1. **语言与沟通**: 所有注释、文档、计划、任务及 AI 回复必须使用 **简体中文 (Simplified Chinese)**。 -2. **技术栈**: 新脚本必须使用 **TypeScript** (`.ts`)。禁止创建新的 `.js` 文件 (除非是构建脚本或测试配置)。 -3. **文档**: 所有修改或创建的脚本必须包含详细的 JSDoc 格式注释。 -4. **架构**: 严禁引入新的架构模式或重型外部库。必须复用现有的 Cocos Creator 管理器和工具类。 -5. **隔离原则**: 保持 `main.js` (主进程) 与 `scene-script.js` (渲染进程) 的严格职责分离。即便是在主进程中,也应通过 IPC 与场景脚本交互。 -6. **主体校验规则 (Subject Validation Rule)**: 在对节点、组件或属性进行任何“写”操作之前,AI 必须先验证主体的存在性与类型正确性。严禁基于假设进行操作。 - -## 1. 项目初始化 - -### 1.1 目录结构搭建 - -``` -mcp-bridge/ -├── main.js # 插件主入口 -├── scene-script.js # 场景脚本 -├── mcp-proxy.js # MCP 代理 -├── README.md # 项目说明 -├── DEVELOPMENT.md # 开发流程文档 -├── package.json # 插件配置 -└── panel/ # 面板目录 - ├── index.html # 面板界面 - └── index.js # 面板逻辑 -``` - -### 1.2 插件配置 - -在 `package.json` 中配置插件信息: - -```json -{ - "name": "mcp-bridge", - "version": "1.0.0", - "description": "MCP Bridge for Cocos Creator", - "main": "main.js", - "panel": { - "main": "panel/index.html", - "type": "dockable", - "title": "MCP Bridge", - "width": 800, - "height": 600 - }, - "contributions": { - "menu": [ - { - "path": "Packages/MCP Bridge", - "label": "Open Test Panel", - "message": "open-test-panel" - } - ] - } -} -``` - -## 2. 核心架构设计 - -### 2.1 系统架构 - -``` -┌────────────────────┐ HTTP ┌────────────────────┐ IPC ┌────────────────────┐ -│ 外部 AI 工具 │ ──────────> │ main.js (HTTP服务) │ ─────────> │ scene-script.js │ -│ (Cursor/VS Code) │ <──────── │ (MCP 协议处理) │ <──────── │ (场景操作执行) │ -└────────────────────┘ JSON └────────────────────┘ JSON └────────────────────┘ -``` - -### 2.2 核心模块 - -1. **HTTP 服务模块**:处理外部请求,解析 MCP 协议 -2. **MCP 工具模块**:实现各种操作工具 -3. **场景操作模块**:执行场景相关操作 -4. **资源管理模块**:处理脚本和资源文件 -5. **面板界面模块**:提供用户交互界面 - -## 3. 功能模块实现 - -### 3.1 HTTP 服务实现 - -在 `main.js` 中实现 HTTP 服务: - -```javascript -startServer(port) { - try { - const http = require('http'); - mcpServer = http.createServer((req, res) => { - // 处理请求... - }); - mcpServer.listen(port, () => { - addLog("success", `MCP Server running at http://127.0.0.1:${port}`); - }); - } catch (e) { - addLog("error", `Failed to start server: ${e.message}`); - } -} -``` - -## 4. 开发历程与里程碑 - -### 2026-02-10: Undo 系统深度修复与规范化 - -- **问题分析**: 修复了 `TypeError: Cannot read property '_name' of null`。该错误是由于直接修改节点属性(绕过 Undo 系统)与分组事务混用导致的。 -- **重构要点**: 将 `update-node-transform` 中所有的直接赋值替换为 `scene:set-property` IPC 调用,确保所有变换修改均受撤销系统监控。 -- **缺陷修正**: 修复了 `manage_undo` 在 `begin_group` 时传递错误参数导致 "Unknown object to record" 的问题。 -- **全量汉化与文档同步**: 完成了 `main.js` 和 `scene-script.js` 的 100% 简体中文翻译。同步更新了 `README.md`、`DEVELOPMENT.md` 及 `注意事项.md`。 - -### 2026-02-13: 新增 `open_prefab` 功能与消息协议修正 - -- **需求实现**: 新增 `open_prefab` 工具,允许 AI 直接打开预制体进入编辑模式。 -- **协议修正**: 经过测试对比,最终确认使用 `scene:enter-prefab-edit-mode` 消息并配合 `Editor.Ipc.sendToAll` 是进入预制体编辑模式的最佳方案,解决了 `scene:open-by-uuid` 无法触发编辑状态的问题。 -- **文档深度补全**: 遵循全局开发规范,同步更新了所有技术文档,确保 100% 简体中文覆盖及详尽的 JSDoc 注释。 - -### 2026-02-25: 修复 manage_script 路径引用错误与强制生成 Meta - -- **缺陷修正**: 修复了 `main.js` 中 `manageScript` 处理 `create` 动作时由于变量名解构导致 `path.dirname` 找不到 `path` 引用的问题。现已改为使用预设的 `pathModule` 模块。 -- **规范强化**: 将 `manage_script` 的工具提示(Prompt)更新为强制要求调用 `refresh_editor` 生成脚本的 `.meta` 文件,以确保新创建的脚本能够被正确挂载为组件,同时不增加整体 Token 消耗。 - -### 3.2 MCP 工具注册 - -在 `/list-tools` 接口中注册工具: - -```javascript -const tools = [ - { - name: "get_selected_node", - description: "获取当前选中的节点", - parameters: [], - }, - // 其他工具... -]; -``` - -### 3.3 场景操作实现 - -在 `scene-script.js` 中实现场景相关操作: - -```javascript -const sceneScript = { - "create-node"(params, callback) { - // 创建节点逻辑... - }, - "set-property"(params, callback) { - // 设置属性逻辑... - }, - // 其他操作... -}; -``` - -### 3.4 脚本管理实现 - -在 `main.js` 中实现脚本管理功能: - -```javascript -manageScript(args, callback) { - const { action, path, content } = args; - switch (action) { - case "create": - // 确保父目录存在 - 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 }); - } - // 创建 TypeScript 脚本 - Editor.assetdb.create(path, content || `const { ccclass, property } = cc._decorator; - -@ccclass -export default class NewScript extends cc.Component { - // LIFE-CYCLE CALLBACKS: - - onLoad () {} - - start () {} - - update (dt) {} -}`, (err) => { - callback(err, err ? null : `Script created at ${path}`); - }); - break; - // 其他操作... - } -} -``` - -### 3.5 批处理执行实现 - -在 `main.js` 中实现批处理功能: - -```javascript -batchExecute(args, callback) { - const { operations } = args; - const results = []; - let completed = 0; - - if (!operations || operations.length === 0) { - return callback("No operations provided"); - } - - 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); - } - }); - }); -} -``` - -### 3.6 资产管理实现 - -在 `main.js` 中实现资产管理功能: - -```javascript -manageAsset(args, callback) { - const { action, path, targetPath, content } = args; - - switch (action) { - case "create": - // 确保父目录存在 - 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; - // 其他操作... - } -} -``` - -### 3.7 面板界面实现 - -在 `panel/index.html` 中实现标签页界面: - -```html -
- -
- Main - Tool Test -
- - -
- -
- - -
-
-
- -
- -
- - -
- -
-
-
-
-
-``` - -## 4. 测试与调试 - -### 4.1 本地测试 - -1. **启动服务**:在面板中点击 "Start" 按钮 -2. **测试工具**:在 "Tool Test" 标签页中测试各个工具 -3. **查看日志**:在主面板中查看操作日志 - -### 4.2 常见错误及修复 - -#### 4.2.1 面板加载错误 - -**错误信息**:`Panel info not found for panel mcp-bridge` - -**解决方案**: - -- 检查 `package.json` 中的面板配置 -- 确保 `panel` 字段配置正确,移除冲突的 `panels` 字段 - -#### 4.2.2 资源创建错误 - -**错误信息**:`Parent path ... is not exists` - -**解决方案**: - -- 在创建资源前添加目录检查和创建逻辑 -- 使用 `fs.mkdirSync(dirPath, { recursive: true })` 递归创建目录 - -#### 4.2.3 脚本语法错误 - -**错误信息**:`SyntaxError: Invalid or unexpected token` - -**解决方案**: - -- 使用模板字符串(反引号)处理多行字符串 -- 避免变量名冲突 - -### 4.3 性能优化 - -1. **批处理执行**:使用 `batch_execute` 工具减少 HTTP 请求次数 -2. **异步操作**:使用回调函数处理异步操作,避免阻塞主线程 -3. **错误处理**:完善错误处理机制,提高插件稳定性 - -## 5. 文档编写 - -### 5.1 README.md - -- 项目简介 -- 功能特性 -- 安装使用 -- API 文档 -- 技术实现 - -### 5.2 API 文档 - -为每个 MCP 工具编写详细的 API 文档,包括: - -- 工具名称 -- 功能描述 -- 参数说明 -- 返回值格式 -- 使用示例 - -### 5.3 开发文档 - -- 项目架构 -- 开发流程 -- 代码规范 -- 贡献指南 - -## 6. 部署与使用 - -### 6.1 部署方式 - -1. **本地部署**:将插件复制到 Cocos Creator 项目的 `packages` 目录 -2. **远程部署**:通过版本控制系统管理插件代码 - -### 6.2 使用流程 - -1. **启动服务**: - - 打开 Cocos Creator 编辑器 - - 选择 `Packages/MCP Bridge/Open Test Panel` - - 点击 "Start" 按钮启动服务 - -2. **连接 AI 编辑器**: - - 在 AI 编辑器中配置 MCP 代理 - - 使用 `node [项目路径]/packages/mcp-bridge/mcp-proxy.js` 作为命令 - -3. **执行操作**: - - 通过 AI 编辑器发送 MCP 请求 - - 或在测试面板中直接测试工具 - -### 6.3 配置选项 - -- **端口设置**:默认 3456,可自定义 -- **自动启动**:支持编辑器启动时自动开启服务 - -## 7. 功能扩展 - -### 7.1 添加新工具 - -1. **在 `main.js` 中注册工具**: - - 在 `/list-tools` 响应中添加工具定义 - - 在 `handleMcpCall` 函数中添加处理逻辑 - -2. **在面板中添加示例**: - - 在 `panel/index.js` 中添加工具示例参数 - - 更新工具列表 - -3. **更新文档**: - - 在 `README.md` 中添加工具文档 - - 更新功能特性列表 - -### 7.2 集成新 API - -1. **了解 Cocos Creator API**: - - 查阅 Cocos Creator 编辑器 API 文档 - - 了解场景脚本 API - -2. **实现集成**: - - 在 `main.js` 或 `scene-script.js` 中添加对应功能 - - 处理异步操作和错误情况 - -3. **测试验证**: - - 编写测试用例 - - 验证功能正确性 - -## 8. 版本管理 - -### 8.1 版本控制 - -- 使用 Git 进行版本控制 -- 遵循语义化版本规范 - -### 8.2 发布流程 - -1. **代码审查**:检查代码质量和功能完整性 -2. **测试验证**:确保所有功能正常工作 -3. **文档更新**:更新 README 和相关文档 -4. **版本发布**:标记版本号并发布 - -## 9. 技术栈 - -- **JavaScript**:主要开发语言 -- **Node.js**:HTTP 服务和文件操作 -- **Cocos Creator API**:编辑器功能集成 -- **HTML/CSS**:面板界面 -- **MCP 协议**:与 AI 工具通信 - -## 10. 最佳实践 - -1. **代码组织**: - - 模块化设计,职责分离 - - 合理使用回调函数处理异步操作 - -2. **错误处理**: - - 完善的错误捕获和处理 - - 详细的错误日志记录 - -3. **用户体验**: - - 直观的面板界面 - - 实时的操作反馈 - - 详细的日志信息 - -4. **安全性**: - - 验证输入参数 - - 防止路径遍历攻击 - - 限制服务访问范围 - -## 11. 开发路线图 (Roadmap) - -### 11.1 第三阶段开发计划(已完成) - -| 任务 | 状态 | 描述 | -| ---------------------- | ------- | ------------------------------------------------------------------- | -| 编辑器管理工具实现 | ✅ 完成 | 实现 manage_editor 工具,支持编辑器状态控制和操作执行 | -| 游戏对象查找工具实现 | ✅ 完成 | 实现 find_gameobjects 工具,支持根据条件查找场景节点 | -| 材质和纹理管理工具实现 | ✅ 完成 | 实现 manage_material 和 manage_texture 工具,支持材质和纹理资源管理 | -| 菜单项执行工具实现 | ✅ 完成 | 实现 execute_menu_item 工具,支持执行 Cocos Creator 菜单项 | -| 代码编辑增强工具实现 | ✅ 完成 | 实现 apply_text_edits 工具,支持文本编辑操作应用 | -| 控制台读取工具实现 | ✅ 完成 | 实现 read_console 工具,支持读取编辑器控制台输出 | -| 脚本验证工具实现 | ✅ 完成 | 实现 validate_script 工具,支持脚本语法验证 | - -### 11.2 第四阶段开发计划(已完成) - -| 任务 | 状态 | 描述 | -| ------------ | ------- | ---------------------------------------------- | -| 测试功能实现 | ✅ 完成 | 实现 run_tests.js 脚本,支持运行自动化测试用例 | -| 错误处理增强 | ✅ 完成 | 完善 HTTP 服务和工具调用的错误日志记录 | - -### 11.3 差异填补阶段(Gap Filling)- 已完成 - -| 任务 | 状态 | 描述 | -| -------- | ------- | ---------------------------------------------- | -| 特效管理 | ✅ 完成 | 实现 manage_vfx 工具,支持粒子系统管理 | -| 文件哈希 | ✅ 完成 | 实现 get_sha 工具,支持文件 SHA-256 计算 | -| 动画管理 | ✅ 完成 | 实现 manage_animation 工具,支持动画播放与控制 | - -### 11.4 第六阶段:可靠性与体验优化(已完成) - -| 任务 | 状态 | 描述 | -| ---------------- | ------- | -------------------------------------------------------------------------- | -| IPC 工具增强 | ✅ 完成 | 修复 IpcManager 返回值解析,优化测试面板 (JSON 参数、筛选) | -| 脚本可靠性修复 | ✅ 完成 | 解决脚本编译时序导致的挂载失败问题 (文档引导 + 刷新机制) | -| 组件智能解析修复 | ✅ 完成 | 修复组件属性赋值时的 UUID 类型转换,支持压缩 UUID 及自定义组件 (`$_$ctor`) | - -### 11.5 第七阶段开发计划(已完成) - -| 任务 | 状态 | 描述 | -| ---------- | ------- | ----------------------------------------- | -| 插件发布 | ✅ 完成 | 准备发布,提交到 Cocos 插件商店 | -| 文档完善 | ✅ 完成 | 完善 API 文档 ("Getting Started" 教程) | -| 界面美化 | ✅ 完成 | 优化面板 UI 体验 | -| 国际化支持 | ✅ 完成 | 添加多语言 (i18n) 支持 (主要是中文本地化) | -| 工具扩展 | ✅ 完成 | 添加更多高级工具 | - -## 12. Unity-MCP 对比分析 - -### 12.1 功能差距 (Gap Analysis) - -通过与 Unity-MCP 对比,Cocos-MCP 已实现绝大多数核心功能。 - -| 功能类别 | Unity-MCP 功能 | Cocos-MCP 状态 | 备注 | -| ---------------- | ----------------- | -------------- | ------------------------------------- | -| 编辑器管理 | manage_editor | ✅ 已实现 | | -| 游戏对象管理 | find_gameobjects | ✅ 已实现 | | -| 材质管理 | manage_material | ✅ 已实现 | | -| 纹理管理 | manage_texture | ✅ 已实现 | | -| 代码编辑 | apply_text_edits | ✅ 已实现 | | -| 全局搜索 | search_project | ✅ 已实现 | 升级版,支持正则和路径限定 | -| 控制台 | read_console | ✅ 已实现 | | -| 菜单执行 | execute_menu_item | ✅ 已实现 | 移除不稳定映射,推荐 delete-node:UUID | -| 脚本验证 | validate_script | ✅ 已实现 | | -| 撤销/重做 | undo/redo | ✅ 已实现 | | -| VFX 管理 | manage_vfx | ✅ 已实现 | | -| Git 集成 | get_sha | ✅ 已实现 | 虽然优先级中等,但已根据需求完成 | -| 动画管理 | manage_animation | ✅ 已实现 | 支持播放、暂停、停止及信息获取 | -| ScriptableObject | manage_so | ❌ 未实现 | 使用 AssetDB 替代 | - -## 13. 风险评估 - -### 13.1 潜在风险 - -| 风险 | 影响 | 缓解措施 | -| --------------- | ------------ | ----------------------------------------- | -| 编辑器 API 变更 | 插件功能失效 | 定期检查 Cocos 更新,适配新 API | -| 性能问题 | 插件响应缓慢 | 优化批处理 (batch_execute),减少 IPC 通讯 | -| 安全漏洞 | 未授权访问 | (规划中) 面板设置 IP 白名单/Token 认证 | -| 兼容性问题 | 多版本不兼容 | 测试主流版本 (2.4.x),提供兼容层 | - -## 14. 结论 - -Cocos-MCP 插件的开发计划已顺利完成多个迭代阶段。目前插件实现了包括编辑器管理、场景操作、资源管理在内的全套核心功能,并完成了针对性的可靠性加固(IPC 通信、脚本时序、组件解析)。 - -插件功能已趋于稳定,后续工作重点将转向 **发布准备**、**文档体系建设** 以及 **用户体验优化**,力求为 Cocos Creator 开发者提供高质量的 AI 辅助开发工具。 diff --git a/README.md b/README.md index fd972a0..1e02aaa 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,13 @@ ``` Command: node -Args: [Cocos Creator 项目的绝对路径]/packages/mcp-bridge/mcp-proxy.js +Args: [Cocos Creator 项目的绝对路径]/packages/mcp-bridge/src/mcp-proxy.js ``` 例如,在你的项目中,完整路径应该是: ``` -Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.js +Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/src/mcp-proxy.js ``` ### 或者添加 JSON 配置: @@ -78,13 +78,13 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j "mcpServers": { "cocos-creator": { "command": "node", - "args": ["[Cocos Creator 项目的绝对路径]/packages/mcp-bridge/mcp-proxy.js"] + "args": ["[Cocos Creator 项目的绝对路径]/packages/mcp-bridge/src/mcp-proxy.js"] } } } ``` -注意:请将上述配置中的路径替换为你自己项目中 `mcp-proxy.js` 文件的实际绝对路径。 +注意:请将上述配置中的路径替换为你自己项目中 `src/mcp-proxy.js` 文件的实际绝对路径。 ## API 接口 @@ -373,8 +373,11 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j 插件采用了典型的 Cocos Creator 扩展架构,包含以下几个部分: -- **main.js**: 插件主入口,负责启动 HTTP 服务和处理 MCP 请求 -- **scene-script.js**: 场景脚本,负责实际执行节点操作 +- **src/main.js**: 插件主入口,负责启动 HTTP 服务和处理 MCP 请求 +- **src/scene-script.js**: 场景脚本,负责实际执行节点操作 +- **src/mcp-proxy.js**: MCP 代理,负责在 AI 工具和插件之间转发请求 +- **src/IpcManager.js**: IPC 消息管理器 +- **src/IpcUi.js**: IPC 测试面板 UI - **panel/**: 面板界面,提供用户交互界面 - `index.html`: 面板 UI 结构 - `index.js`: 面板交互逻辑 @@ -393,9 +396,9 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j ### 数据流 1. 外部工具发送 MCP 请求到插件的 HTTP 接口 -2. main.js 接收请求并解析参数 -3. 通过 Editor.Scene.callSceneScript 将请求转发给 scene-script.js -4. scene-script.js 在场景线程中执行具体操作 +2. src/main.js 接收请求并解析参数 +3. 通过 Editor.Scene.callSceneScript 将请求转发给 src/scene-script.js +4. src/scene-script.js 在场景线程中执行具体操作 5. 将结果返回给外部工具 ## 开发指南 @@ -404,9 +407,9 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j 要在插件中添加新的 MCP 工具,需要: -1. 在 main.js 的 `/list-tools` 响应中添加工具定义 +1. 在 src/main.js 的 `/list-tools` 响应中添加工具定义 2. 在 handleMcpCall 函数中添加对应的处理逻辑 -3. 如需在场景线程中执行,需要在 scene-script.js 中添加对应函数 +3. 如需在场景线程中执行,需要在 src/scene-script.js 中添加对应函数 ### 日志管理 @@ -439,7 +442,7 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j ## 更新日志 -请查阅 [UPDATE_LOG.md](./UPDATE_LOG.md) 了解详细的版本更新历史、功能优化与修复过程。 +请查阅 [UPDATE_LOG.md](./docs/UPDATE_LOG.md) 了解详细的版本更新历史、功能优化与修复过程。 ## 贡献 diff --git a/dist/IpcManager.js b/dist/IpcManager.js deleted file mode 100644 index 6ce3fe6..0000000 --- a/dist/IpcManager.js +++ /dev/null @@ -1,141 +0,0 @@ -"use strict"; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __generator = (this && this.__generator) || function (thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); - return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (g && (g = 0, op[0] && (_ = 0)), _) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.IpcManager = void 0; -// @ts-ignore -var fs = require('fs'); -// @ts-ignore -var path = require('path'); -/** - * IPC 消息管理器 - * 负责解析 IPC 文档并执行消息测试 - */ -var IpcManager = /** @class */ (function () { - function IpcManager() { - } - /** - * 获取所有 IPC 消息列表 - * @returns 消息定义列表 - */ - IpcManager.getIpcMessages = function () { - // 获取文档路径 - // @ts-ignore - var docPath = Editor.url('packages://mcp-bridge/IPC_MESSAGES.md'); - if (!fs.existsSync(docPath)) { - // @ts-ignore - Editor.error("[IPC Manager] Document not found: ".concat(docPath)); - return []; - } - var content = fs.readFileSync(docPath, 'utf-8'); - var messages = []; - // 正则匹配 ### `message-name` - var regex = /### `(.*?)`\r?\n([\s\S]*?)(?=### `|$)/g; - var match; - while ((match = regex.exec(content)) !== null) { - var name_1 = match[1]; - var body = match[2]; - // 解析用途 - var purposeMatch = body.match(/- \*\*用途\*\*: (.*)/); - var description = purposeMatch ? purposeMatch[1].trim() : "无描述"; - // 解析参数 - var paramsMatch = body.match(/- \*\*参数\*\*: (.*)/); - var params = paramsMatch ? paramsMatch[1].trim() : "无"; - // 解析返回值 - var returnMatch = body.match(/- \*\*返回值\*\*: (.*)/); - var returns = returnMatch ? returnMatch[1].trim() : "无"; - // 解析类型 - var typeMatch = body.match(/- \*\*类型\*\*: (.*)/); - var type = typeMatch ? typeMatch[1].trim() : "未定义"; - // 解析状态 - var statusMatch = body.match(/- \*\*状态\*\*: (.*)/); - var status_1 = statusMatch ? statusMatch[1].trim() : "未测试"; - // 过滤掉广播事件和渲染进程监听的事件 - if (type === "广播事件" || type === "Events listened by Renderer Process" || type === "渲染进程监听") { - continue; - } - messages.push({ - name: name_1, - description: description, - params: params, - returns: returns, - type: type, - status: status_1 - }); - } - return messages; - }; - /** - * 测试发送 IPC 消息 - * @param name 消息名称 - * @param args 参数 - * @returns Promise 测试结果 - */ - IpcManager.testIpcMessage = function (name_2) { - return __awaiter(this, arguments, void 0, function (name, args) { - if (args === void 0) { args = null; } - return __generator(this, function (_a) { - return [2 /*return*/, new Promise(function (resolve) { - // 简单防呆:防止执行危险操作 - // 如果消息包含 "delete", "remove", "close", "stop" 且没有明确参数确认,则警告 - // 但用户要求"快速验证",所以我们默认允许,但如果是无参调用可能有风险 - // 这里我们尝试使用 Editor.Ipc.sendToMain 或 requestToMain - // @ts-ignore - // 简单的测试:只是发送看看是否报错。 - // 对于 request 类型的消息,我们期望有回调 - // Cocos Creator 2.4 API: Editor.Ipc.sendToMain(message, ...args) - try { - // @ts-ignore - if (Editor.Ipc.sendToMain) { - // @ts-ignore - Editor.Ipc.sendToMain(name, args); - resolve({ success: true, message: "Message sent (sendToMain)" }); - } - else { - resolve({ success: false, message: "Editor.Ipc.sendToMain not available" }); - } - } - catch (e) { - resolve({ success: false, message: "Error: ".concat(e.message) }); - } - })]; - }); - }); - }; - return IpcManager; -}()); -exports.IpcManager = IpcManager; diff --git a/dist/IpcUi.js b/dist/IpcUi.js deleted file mode 100644 index 322c0c3..0000000 --- a/dist/IpcUi.js +++ /dev/null @@ -1,353 +0,0 @@ -"use strict"; -var __awaiter = - (this && this.__awaiter) || - function (thisArg, _arguments, P, generator) { - function adopt(value) { - return value instanceof P - ? value - : new P(function (resolve) { - resolve(value); - }); - } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { - try { - step(generator.next(value)); - } catch (e) { - reject(e); - } - } - function rejected(value) { - try { - step(generator["throw"](value)); - } catch (e) { - reject(e); - } - } - function step(result) { - result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); - } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); - }; -var __generator = - (this && this.__generator) || - function (thisArg, body) { - var _ = { - label: 0, - sent: function () { - if (t[0] & 1) throw t[1]; - return t[1]; - }, - trys: [], - ops: [], - }, - f, - y, - t, - g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); - return ( - (g.next = verb(0)), - (g["throw"] = verb(1)), - (g["return"] = verb(2)), - typeof Symbol === "function" && - (g[Symbol.iterator] = function () { - return this; - }), - g - ); - function verb(n) { - return function (v) { - return step([n, v]); - }; - } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while ((g && ((g = 0), op[0] && (_ = 0)), _)) - try { - if ( - ((f = 1), - y && - (t = - op[0] & 2 - ? y["return"] - : op[0] - ? y["throw"] || ((t = y["return"]) && t.call(y), 0) - : y.next) && - !(t = t.call(y, op[1])).done) - ) - return t; - if (((y = 0), t)) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: - case 1: - t = op; - break; - case 4: - _.label++; - return { value: op[1], done: false }; - case 5: - _.label++; - y = op[1]; - op = [0]; - continue; - case 7: - op = _.ops.pop(); - _.trys.pop(); - continue; - default: - if ( - !((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && - (op[0] === 6 || op[0] === 2) - ) { - _ = 0; - continue; - } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { - _.label = op[1]; - break; - } - if (op[0] === 6 && _.label < t[1]) { - _.label = t[1]; - t = op; - break; - } - if (t && _.label < t[2]) { - _.label = t[2]; - _.ops.push(op); - break; - } - if (t[2]) _.ops.pop(); - _.trys.pop(); - continue; - } - op = body.call(thisArg, _); - } catch (e) { - op = [6, e]; - y = 0; - } finally { - f = t = 0; - } - if (op[0] & 5) throw op[1]; - return { value: op[0] ? op[1] : void 0, done: true }; - } - }; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.IpcUi = void 0; -// @ts-ignore -var Editor = window.Editor; -var IpcUi = /** @class */ (function () { - function IpcUi(root) { - this.logArea = null; - this.ipcList = null; - this.allMessages = []; - this.filterSelect = null; - this.paramInput = null; - this.root = root; - this.bindEvents(); - } - IpcUi.prototype.bindEvents = function () { - var _this = this; - var btnScan = this.root.querySelector("#btnScanIpc"); - var btnTest = this.root.querySelector("#btnTestIpc"); - var cbSelectAll = this.root.querySelector("#cbSelectAllIpc"); - this.logArea = this.root.querySelector("#ipcLog"); - this.ipcList = this.root.querySelector("#ipcList"); - this.filterSelect = this.root.querySelector("#ipcFilter"); - this.paramInput = this.root.querySelector("#ipcParams"); - if (btnScan) { - btnScan.addEventListener("confirm", function () { - return _this.scanMessages(); - }); - } - if (btnTest) { - btnTest.addEventListener("confirm", function () { - return _this.testSelected(); - }); - } - if (cbSelectAll) { - cbSelectAll.addEventListener("change", function (e) { - return _this.toggleAll(e.detail ? e.detail.value : e.target.value === "true" || e.target.checked); - }); - } - if (this.filterSelect) { - this.filterSelect.addEventListener("change", function () { - return _this.filterMessages(); - }); - } - }; - IpcUi.prototype.scanMessages = function () { - var _this = this; - this.log("正在扫描 IPC 消息..."); - // @ts-ignore - Editor.Ipc.sendToMain("mcp-bridge:scan-ipc-messages", function (err, msgs) { - if (err) { - _this.log("扫描错误: ".concat(err)); - return; - } - if (msgs) { - _this.allMessages = msgs; - _this.filterMessages(); - _this.log("找到 ".concat(msgs.length, " 条消息。")); - } else { - _this.log("未找到任何消息。"); - } - }); - }; - IpcUi.prototype.filterMessages = function () { - if (!this.allMessages) return; - var filter = this.filterSelect ? this.filterSelect.value : "all"; - var filtered = this.allMessages; - if (filter === "available") { - filtered = this.allMessages.filter(function (m) { - return m.status === "可用"; - }); - } else if (filter === "unavailable") { - filtered = this.allMessages.filter(function (m) { - return m.status && m.status.includes("不可用"); - }); - } else if (filter === "untested") { - filtered = this.allMessages.filter(function (m) { - return !m.status || m.status === "未测试"; - }); - } - this.renderList(filtered); - }; - IpcUi.prototype.renderList = function (msgs) { - var _this = this; - if (!this.ipcList) return; - this.ipcList.innerHTML = ""; - msgs.forEach(function (msg) { - var item = document.createElement("div"); - item.className = "ipc-item"; - item.style.padding = "4px"; - item.style.borderBottom = "1px solid #333"; - item.style.display = "flex"; - item.style.alignItems = "center"; - // Checkbox - var checkbox = document.createElement("ui-checkbox"); - // @ts-ignore - checkbox.value = false; - checkbox.setAttribute("data-name", msg.name); - checkbox.style.marginRight = "8px"; - // Content - var content = document.createElement("div"); - content.style.flex = "1"; - content.style.fontSize = "11px"; - var statusColor = "#888"; // Untested - if (msg.status === "可用") - statusColor = "#4CAF50"; // Green - else if (msg.status && msg.status.includes("不可用")) statusColor = "#F44336"; // Red - content.innerHTML = - '\n
\n ' - .concat(msg.name, '\n ') - .concat( - msg.status || "未测试", - '\n
\n
', - ) - .concat( - msg.description || "无描述", - '
\n
参数: ', - ) - .concat(msg.params || "无", "
\n "); - // Action Button - var btnRun = document.createElement("ui-button"); - btnRun.innerText = "执行"; - btnRun.className = "tiny"; - btnRun.style.height = "20px"; - btnRun.style.lineHeight = "20px"; - btnRun.addEventListener("confirm", function () { - _this.runTest(msg.name); - }); - item.appendChild(checkbox); - item.appendChild(content); - item.appendChild(btnRun); - _this.ipcList.appendChild(item); - }); - }; - IpcUi.prototype.testSelected = function () { - return __awaiter(this, void 0, void 0, function () { - var checkboxes, toTest, _i, toTest_1, name_1; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - checkboxes = this.root.querySelectorAll("#ipcList ui-checkbox"); - toTest = []; - checkboxes.forEach(function (cb) { - // In Cocos 2.x, ui-checkbox value is boolean - if (cb.checked || cb.value === true) { - toTest.push(cb.getAttribute("data-name")); - } - }); - if (toTest.length === 0) { - this.log("未选择任何消息。"); - return [2 /*return*/]; - } - this.log("开始批量测试 ".concat(toTest.length, " 条消息...")); - ((_i = 0), (toTest_1 = toTest)); - _a.label = 1; - case 1: - if (!(_i < toTest_1.length)) return [3 /*break*/, 4]; - name_1 = toTest_1[_i]; - return [4 /*yield*/, this.runTest(name_1)]; - case 2: - _a.sent(); - _a.label = 3; - case 3: - _i++; - return [3 /*break*/, 1]; - case 4: - this.log("批量测试完成。"); - return [2 /*return*/]; - } - }); - }); - }; - IpcUi.prototype.runTest = function (name) { - var _this = this; - return new Promise(function (resolve) { - var params = null; - if (_this.paramInput && _this.paramInput.value.trim()) { - try { - params = JSON.parse(_this.paramInput.value.trim()); - } catch (e) { - _this.log("[错误] 无效的 JSON 参数: ".concat(e)); - resolve(); - return; - } - } - _this.log("正在测试: ".concat(name, ",参数: ").concat(JSON.stringify(params), "...")); - // @ts-ignore - Editor.Ipc.sendToMain( - "mcp-bridge:test-ipc-message", - { name: name, params: params }, - function (err, result) { - if (err) { - _this.log("[".concat(name, "] 失败: ").concat(err)); - } else { - _this.log("[".concat(name, "] 成功: ").concat(JSON.stringify(result))); - } - resolve(); - }, - ); - }); - }; - IpcUi.prototype.toggleAll = function (checked) { - var checkboxes = this.root.querySelectorAll("#ipcList ui-checkbox"); - checkboxes.forEach(function (cb) { - cb.value = checked; - }); - }; - IpcUi.prototype.log = function (msg) { - if (this.logArea) { - // @ts-ignore - var time = new Date().toLocaleTimeString(); - this.logArea.value += "[".concat(time, "] ").concat(msg, "\n"); - this.logArea.scrollTop = this.logArea.scrollHeight; - } - }; - return IpcUi; -})(); -exports.IpcUi = IpcUi; diff --git a/IPC_MESSAGES.md b/docs/IPC_MESSAGES.md similarity index 100% rename from IPC_MESSAGES.md rename to docs/IPC_MESSAGES.md diff --git a/UPDATE_LOG.md b/docs/UPDATE_LOG.md similarity index 100% rename from UPDATE_LOG.md rename to docs/UPDATE_LOG.md diff --git a/注意事项.md b/docs/注意事项.md similarity index 100% rename from 注意事项.md rename to docs/注意事项.md diff --git a/package.json b/package.json index 85ddd51..881e9d0 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "Cocos Creator MCP 桥接插件", "author": "Firekula", - "main": "main.js", - "scene-script": "scene-script.js", + "main": "src/main.js", + "scene-script": "src/scene-script.js", "main-menu": { "MCP 桥接器/开启测试面板": { "message": "mcp-bridge:open-test-panel" diff --git a/panel/index.js b/panel/index.js index b071f04..a0fb0b7 100644 --- a/panel/index.js +++ b/panel/index.js @@ -6,7 +6,7 @@ */ const fs = require("fs"); -const { IpcUi } = require("../dist/IpcUi"); +const { IpcUi } = require("../src/IpcUi"); Editor.Panel.extend({ /** @@ -87,7 +87,7 @@ Editor.Panel.extend({ } }); - // 初始化 IPC 调试专用 UI (由 TypeScript 编写并编译到 dist) + // 初始化 IPC 调试专用 UI new IpcUi(root); // 2. 标签页切换逻辑 diff --git a/src/IpcManager.js b/src/IpcManager.js new file mode 100644 index 0000000..082ac01 --- /dev/null +++ b/src/IpcManager.js @@ -0,0 +1,95 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * IPC 消息管理器 + * 负责解析 IPC 文档并执行消息测试 + */ +class IpcManager { + /** + * 获取所有 IPC 消息列表 + * @returns {Array} 消息定义列表 + */ + static getIpcMessages() { + // 获取文档路径 + const docPath = Editor.url("packages://mcp-bridge/docs/IPC_MESSAGES.md"); + if (!fs.existsSync(docPath)) { + Editor.error(`[IPC 管理器] 找不到文档文件: ${docPath}`); + return []; + } + + const content = fs.readFileSync(docPath, "utf-8"); + const messages = []; + + // 正则匹配 ### `message-name` + const regex = /### `(.*?)`\r?\n([\s\S]*?)(?=### `|$)/g; + let match; + + while ((match = regex.exec(content)) !== null) { + const name = match[1]; + const body = match[2]; + + // 解析用途 + const purposeMatch = body.match(/- \*\*用途\*\*: (.*)/); + const description = purposeMatch ? purposeMatch[1].trim() : "无描述"; + + // 解析参数 + const paramsMatch = body.match(/- \*\*参数\*\*: (.*)/); + const params = paramsMatch ? paramsMatch[1].trim() : "无"; + + // 解析返回值 + const returnMatch = body.match(/- \*\*返回值\*\*: (.*)/); + const returns = returnMatch ? returnMatch[1].trim() : "无"; + + // 解析类型 + const typeMatch = body.match(/- \*\*类型\*\*: (.*)/); + const type = typeMatch ? typeMatch[1].trim() : "未定义"; + + // 解析状态 + const statusMatch = body.match(/- \*\*状态\*\*: (.*)/); + const status = statusMatch ? statusMatch[1].trim() : "未测试"; + + // 过滤掉广播事件和渲染进程监听的事件 + if (type === "广播事件" || type === "Events listened by Renderer Process" || type === "渲染进程监听") { + continue; + } + + messages.push({ + name, + description, + params, + returns, + type, + status, + }); + } + + return messages; + } + + /** + * 测试发送 IPC 消息 + * @param {string} name 消息名称 + * @param {*} args 参数 + * @returns {Promise} 测试结果 + */ + static testIpcMessage(name, args) { + if (args === undefined) args = null; + return new Promise((resolve) => { + try { + if (Editor.Ipc.sendToMain) { + Editor.Ipc.sendToMain(name, args); + resolve({ success: true, message: "消息已发送 (sendToMain)" }); + } else { + resolve({ success: false, message: "Editor.Ipc.sendToMain 不可用" }); + } + } catch (e) { + resolve({ success: false, message: `错误: ${e.message}` }); + } + }); + } +} + +module.exports = { IpcManager }; diff --git a/src/IpcManager.ts b/src/IpcManager.ts deleted file mode 100644 index 5a3a309..0000000 --- a/src/IpcManager.ts +++ /dev/null @@ -1,106 +0,0 @@ -// @ts-ignore -const fs = require("fs"); -// @ts-ignore -const path = require("path"); - -/** - * IPC 消息管理器 - * 负责解析 IPC 文档并执行消息测试 - */ -export class IpcManager { - /** - * 获取所有 IPC 消息列表 - * @returns 消息定义列表 - */ - public static getIpcMessages(): any[] { - // 获取文档路径 - // @ts-ignore - const docPath = Editor.url("packages://mcp-bridge/IPC_MESSAGES.md"); - if (!fs.existsSync(docPath)) { - // @ts-ignore - Editor.error(`[IPC 管理器] 找不到文档文件: ${docPath}`); - return []; - } - - const content = fs.readFileSync(docPath, "utf-8"); - const messages: any[] = []; - - // 正则匹配 ### `message-name` - const regex = /### `(.*?)`\r?\n([\s\S]*?)(?=### `|$)/g; - let match; - - while ((match = regex.exec(content)) !== null) { - const name = match[1]; - const body = match[2]; - - // 解析用途 - const purposeMatch = body.match(/- \*\*用途\*\*: (.*)/); - const description = purposeMatch ? purposeMatch[1].trim() : "无描述"; - - // 解析参数 - const paramsMatch = body.match(/- \*\*参数\*\*: (.*)/); - const params = paramsMatch ? paramsMatch[1].trim() : "无"; - - // 解析返回值 - const returnMatch = body.match(/- \*\*返回值\*\*: (.*)/); - const returns = returnMatch ? returnMatch[1].trim() : "无"; - - // 解析类型 - const typeMatch = body.match(/- \*\*类型\*\*: (.*)/); - const type = typeMatch ? typeMatch[1].trim() : "未定义"; - - // 解析状态 - const statusMatch = body.match(/- \*\*状态\*\*: (.*)/); - const status = statusMatch ? statusMatch[1].trim() : "未测试"; - - // 过滤掉广播事件和渲染进程监听的事件 - if (type === "广播事件" || type === "Events listened by Renderer Process" || type === "渲染进程监听") { - continue; - } - - messages.push({ - name, - description, - params, - returns, - type, - status, - }); - } - - return messages; - } - - /** - * 测试发送 IPC 消息 - * @param name 消息名称 - * @param args 参数 - * @returns Promise 测试结果 - */ - public static async testIpcMessage(name: string, args: any = null): Promise { - return new Promise((resolve) => { - // 简单防呆:防止执行危险操作 - // 如果消息包含 "delete", "remove", "close", "stop" 且没有明确参数确认,则警告 - // 但用户要求"快速验证",所以我们默认允许,但如果是无参调用可能有风险 - // 这里我们尝试使用 Editor.Ipc.sendToMain 或 requestToMain - - // @ts-ignore - // 简单的测试:只是发送看看是否报错。 - // 对于 request 类型的消息,我们期望有回调 - // Cocos Creator 2.4 API: Editor.Ipc.sendToMain(message, ...args) - - try { - // @ts-ignore - if (Editor.Ipc.sendToMain) { - // @ts-ignore - Editor.Ipc.sendToMain(name, args); - resolve({ success: true, message: "消息已发送 (sendToMain)" }); - } else { - resolve({ success: false, message: "Editor.Ipc.sendToMain 不可用" }); - } - } catch (e: any) { - resolve({ success: false, message: `错误: ${e.message}` }); - } - }); - } -} diff --git a/src/IpcUi.js b/src/IpcUi.js new file mode 100644 index 0000000..d5568c6 --- /dev/null +++ b/src/IpcUi.js @@ -0,0 +1,226 @@ +"use strict"; + +/** + * IPC 测试面板 UI 管理器 + * 负责在面板中展示和测试 IPC 消息 + */ +class IpcUi { + /** + * 构造函数 + * @param {ShadowRoot} root Shadow UI 根节点 + */ + constructor(root) { + this.root = root; + this.logArea = null; + this.ipcList = null; + this.allMessages = []; + this.filterSelect = null; + this.paramInput = null; + this.bindEvents(); + } + + /** + * 绑定 UI 事件 + */ + bindEvents() { + const btnScan = this.root.querySelector("#btnScanIpc"); + const btnTest = this.root.querySelector("#btnTestIpc"); + const cbSelectAll = this.root.querySelector("#cbSelectAllIpc"); + this.logArea = this.root.querySelector("#ipcLog"); + this.ipcList = this.root.querySelector("#ipcList"); + this.filterSelect = this.root.querySelector("#ipcFilter"); + this.paramInput = this.root.querySelector("#ipcParams"); + + if (btnScan) { + btnScan.addEventListener("confirm", () => this.scanMessages()); + } + if (btnTest) { + btnTest.addEventListener("confirm", () => this.testSelected()); + } + if (cbSelectAll) { + cbSelectAll.addEventListener("change", (e) => + this.toggleAll(e.detail ? e.detail.value : e.target.value === "true" || e.target.checked), + ); + } + if (this.filterSelect) { + this.filterSelect.addEventListener("change", () => this.filterMessages()); + } + } + + /** + * 扫描 IPC 消息 + */ + scanMessages() { + this.log("正在扫描 IPC 消息..."); + Editor.Ipc.sendToMain("mcp-bridge:scan-ipc-messages", (err, msgs) => { + if (err) { + this.log(`扫描错误: ${err}`); + return; + } + if (msgs) { + this.allMessages = msgs; + this.filterMessages(); + this.log(`找到 ${msgs.length} 条消息。`); + } else { + this.log("未找到任何消息。"); + } + }); + } + + /** + * 根据当前选择器过滤消息列表 + */ + filterMessages() { + if (!this.allMessages) return; + const filter = this.filterSelect ? this.filterSelect.value : "all"; + + let filtered = this.allMessages; + if (filter === "available") { + filtered = this.allMessages.filter((m) => m.status === "可用"); + } else if (filter === "unavailable") { + filtered = this.allMessages.filter((m) => m.status && m.status.includes("不可用")); + } else if (filter === "untested") { + filtered = this.allMessages.filter((m) => !m.status || m.status === "未测试"); + } + + this.renderList(filtered); + } + + /** + * 渲染消息列表 UI + * @param {Array} msgs 消息对象数组 + */ + renderList(msgs) { + if (!this.ipcList) return; + this.ipcList.innerHTML = ""; + + msgs.forEach((msg) => { + const item = document.createElement("div"); + item.className = "ipc-item"; + item.style.padding = "4px"; + item.style.borderBottom = "1px solid #333"; + item.style.display = "flex"; + item.style.alignItems = "center"; + + // 复选框 + const checkbox = document.createElement("ui-checkbox"); + checkbox.value = false; + checkbox.setAttribute("data-name", msg.name); + checkbox.style.marginRight = "8px"; + + // 内容区域 + const content = document.createElement("div"); + content.style.flex = "1"; + content.style.fontSize = "11px"; + + let statusColor = "#888"; // 未测试 + if (msg.status === "可用") + statusColor = "#4CAF50"; // 绿色 + else if (msg.status && msg.status.includes("不可用")) statusColor = "#F44336"; // 红色 + + content.innerHTML = ` +
+ ${msg.name} + ${msg.status || "未测试"} +
+
${msg.description || "无描述"}
+
参数: ${msg.params || "无"}
+ `; + + // 执行按钮 + const btnRun = document.createElement("ui-button"); + btnRun.innerText = "执行"; + btnRun.className = "tiny"; + btnRun.style.height = "20px"; + btnRun.style.lineHeight = "20px"; + btnRun.addEventListener("confirm", () => { + this.runTest(msg.name); + }); + + item.appendChild(checkbox); + item.appendChild(content); + item.appendChild(btnRun); + this.ipcList.appendChild(item); + }); + } + + /** + * 测试所有选中的 IPC 消息 + */ + async testSelected() { + const checkboxes = this.root.querySelectorAll("#ipcList ui-checkbox"); + const toTest = []; + checkboxes.forEach((cb) => { + // 在 Cocos 2.x 中, ui-checkbox 的值是布尔型 + if (cb.checked || cb.value === true) { + toTest.push(cb.getAttribute("data-name")); + } + }); + + if (toTest.length === 0) { + this.log("未选择任何消息。"); + return; + } + + this.log(`开始批量测试 ${toTest.length} 条消息...`); + for (const name of toTest) { + await this.runTest(name); + } + this.log("批量测试完成。"); + } + + /** + * 运行单个测试请求 + * @param {string} name 消息名称 + * @returns {Promise} + */ + runTest(name) { + return new Promise((resolve) => { + let params = null; + if (this.paramInput && this.paramInput.value.trim()) { + try { + params = JSON.parse(this.paramInput.value.trim()); + } catch (e) { + this.log(`[错误] 无效的 JSON 参数: ${e}`); + resolve(); + return; + } + } + + this.log(`正在测试: ${name},参数: ${JSON.stringify(params)}...`); + Editor.Ipc.sendToMain("mcp-bridge:test-ipc-message", { name, params }, (err, result) => { + if (err) { + this.log(`[${name}] 失败: ${err}`); + } else { + this.log(`[${name}] 成功: ${JSON.stringify(result)}`); + } + resolve(); + }); + }); + } + + /** + * 全选/取消全选 + * @param {boolean} checked 是否选中 + */ + toggleAll(checked) { + const checkboxes = this.root.querySelectorAll("#ipcList ui-checkbox"); + checkboxes.forEach((cb) => { + cb.value = checked; + }); + } + + /** + * 输出日志到界面 + * @param {string} msg 日志消息 + */ + log(msg) { + if (this.logArea) { + const time = new Date().toLocaleTimeString(); + this.logArea.value += `[${time}] ${msg}\n`; + this.logArea.scrollTop = this.logArea.scrollHeight; + } + } +} + +module.exports = { IpcUi }; diff --git a/src/IpcUi.ts b/src/IpcUi.ts deleted file mode 100644 index eb23128..0000000 --- a/src/IpcUi.ts +++ /dev/null @@ -1,226 +0,0 @@ -// @ts-ignore -const Editor = window.Editor; - -export class IpcUi { - private root: ShadowRoot; - private logArea: HTMLTextAreaElement | null = null; - private ipcList: HTMLElement | null = null; - private allMessages: any[] = []; - private filterSelect: HTMLSelectElement | null = null; - private paramInput: HTMLTextAreaElement | null = null; - - /** - * 构造函数 - * @param root Shadow UI 根节点 - */ - constructor(root: ShadowRoot) { - this.root = root; - this.bindEvents(); - } - - /** - * 绑定 UI 事件 - */ - private bindEvents() { - const btnScan = this.root.querySelector("#btnScanIpc"); - const btnTest = this.root.querySelector("#btnTestIpc"); - const cbSelectAll = this.root.querySelector("#cbSelectAllIpc"); - this.logArea = this.root.querySelector("#ipcLog") as HTMLTextAreaElement; - this.ipcList = this.root.querySelector("#ipcList") as HTMLElement; - this.filterSelect = this.root.querySelector("#ipcFilter") as HTMLSelectElement; - this.paramInput = this.root.querySelector("#ipcParams") as HTMLTextAreaElement; - - if (btnScan) { - btnScan.addEventListener("confirm", () => this.scanMessages()); - } - if (btnTest) { - btnTest.addEventListener("confirm", () => this.testSelected()); - } - if (cbSelectAll) { - cbSelectAll.addEventListener("change", (e: any) => - this.toggleAll(e.detail ? e.detail.value : e.target.value === "true" || e.target.checked), - ); - } - if (this.filterSelect) { - this.filterSelect.addEventListener("change", () => this.filterMessages()); - } - } - - /** - * 扫描 IPC 消息 - */ - private scanMessages() { - this.log("正在扫描 IPC 消息..."); - // @ts-ignore - Editor.Ipc.sendToMain("mcp-bridge:scan-ipc-messages", (err: any, msgs: any[]) => { - if (err) { - this.log(`扫描错误: ${err}`); - return; - } - if (msgs) { - this.allMessages = msgs; - this.filterMessages(); - this.log(`找到 ${msgs.length} 条消息。`); - } else { - this.log("未找到任何消息。"); - } - }); - } - - /** - * 根据当前选择器过滤消息列表 - */ - private filterMessages() { - if (!this.allMessages) return; - const filter = this.filterSelect ? this.filterSelect.value : "all"; - - let filtered = this.allMessages; - if (filter === "available") { - filtered = this.allMessages.filter((m) => m.status === "可用"); - } else if (filter === "unavailable") { - filtered = this.allMessages.filter((m) => m.status && m.status.includes("不可用")); - } else if (filter === "untested") { - filtered = this.allMessages.filter((m) => !m.status || m.status === "未测试"); - } - - this.renderList(filtered); - } - - /** - * 渲染消息列表 UI - * @param msgs 消息对象数组 - */ - private renderList(msgs: any[]) { - if (!this.ipcList) return; - this.ipcList.innerHTML = ""; - - msgs.forEach((msg) => { - const item = document.createElement("div"); - item.className = "ipc-item"; - item.style.padding = "4px"; - item.style.borderBottom = "1px solid #333"; - item.style.display = "flex"; - item.style.alignItems = "center"; - - // 复选框 - const checkbox = document.createElement("ui-checkbox"); - // @ts-ignore - checkbox.value = false; - checkbox.setAttribute("data-name", msg.name); - checkbox.style.marginRight = "8px"; - - // 内容区域 - const content = document.createElement("div"); - content.style.flex = "1"; - content.style.fontSize = "11px"; - - let statusColor = "#888"; // 未测试 - if (msg.status === "可用") - statusColor = "#4CAF50"; // 绿色 - else if (msg.status && msg.status.includes("不可用")) statusColor = "#F44336"; // 红色 - - content.innerHTML = ` -
- ${msg.name} - ${msg.status || "未测试"} -
-
${msg.description || "无描述"}
-
参数: ${msg.params || "无"}
- `; - - // 执行按钮 - const btnRun = document.createElement("ui-button"); - btnRun.innerText = "执行"; - btnRun.className = "tiny"; - btnRun.style.height = "20px"; - btnRun.style.lineHeight = "20px"; - btnRun.addEventListener("confirm", () => { - this.runTest(msg.name); - }); - - item.appendChild(checkbox); - item.appendChild(content); - item.appendChild(btnRun); - this.ipcList!.appendChild(item); - }); - } - - /** - * 测试所有选中的 IPC 消息 - */ - private async testSelected() { - const checkboxes = this.root.querySelectorAll("#ipcList ui-checkbox"); - const toTest: string[] = []; - checkboxes.forEach((cb: any) => { - // 在 Cocos 2.x 中, ui-checkbox 的值是布尔型 - if (cb.checked || cb.value === true) { - toTest.push(cb.getAttribute("data-name")); - } - }); - - if (toTest.length === 0) { - this.log("未选择任何消息。"); - return; - } - - this.log(`开始批量测试 ${toTest.length} 条消息...`); - for (const name of toTest) { - await this.runTest(name); - } - this.log("批量测试完成。"); - } - - /** - * 运行单个测试请求 - * @param name 消息名称 - */ - private runTest(name: string): Promise { - return new Promise((resolve) => { - let params = null; - if (this.paramInput && this.paramInput.value.trim()) { - try { - params = JSON.parse(this.paramInput.value.trim()); - } catch (e) { - this.log(`[错误] 无效的 JSON 参数: ${e}`); - resolve(); - return; - } - } - - this.log(`正在测试: ${name},参数: ${JSON.stringify(params)}...`); - // @ts-ignore - Editor.Ipc.sendToMain("mcp-bridge:test-ipc-message", { name, params }, (err: any, result: any) => { - if (err) { - this.log(`[${name}] 失败: ${err}`); - } else { - this.log(`[${name}] 成功: ${JSON.stringify(result)}`); - } - resolve(); - }); - }); - } - - /** - * 全选/取消全选 - * @param checked 是否选中 - */ - private toggleAll(checked: boolean) { - const checkboxes = this.root.querySelectorAll("#ipcList ui-checkbox"); - checkboxes.forEach((cb: any) => { - cb.value = checked; - }); - } - - /** - * 输出日志到界面 - * @param msg 日志消息 - */ - private log(msg: string) { - if (this.logArea) { - // @ts-ignore - const time = new Date().toLocaleTimeString(); - this.logArea.value += `[${time}] ${msg}\n`; - this.logArea.scrollTop = this.logArea.scrollHeight; - } - } -} diff --git a/main.js b/src/main.js similarity index 99% rename from main.js rename to src/main.js index f165365..8bc9ef1 100644 --- a/main.js +++ b/src/main.js @@ -1,5 +1,5 @@ "use strict"; -const { IpcManager } = require("./dist/IpcManager"); +const { IpcManager } = require("./IpcManager"); const http = require("http"); const pathModule = require("path"); diff --git a/mcp-proxy.js b/src/mcp-proxy.js similarity index 100% rename from mcp-proxy.js rename to src/mcp-proxy.js diff --git a/scene-script.js b/src/scene-script.js similarity index 100% rename from scene-script.js rename to src/scene-script.js diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 1255432..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "lib": [ - "dom", - "es5", - "es2015.promise" - ], - "target": "es5", - "experimentalDecorators": true, - "skipLibCheck": true, - "outDir": "./dist", - "rootDir": "./src", - "forceConsistentCasingInFileNames": true, - "strict": false - }, - "include": [ - "src/**/*" - ] -} \ No newline at end of file