diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a062884..b4145bb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -413,8 +413,232 @@ manageAsset(args, callback) { - 防止路径遍历攻击 - 限制服务访问范围 -## 11. 总结 +## 11. 开发状态 + +### 11.1 已完成的任务 + +#### 第一阶段 +- ✅ HTTP 服务接口实现 +- ✅ 场景节点操作工具 +- ✅ 资源管理工具 +- ✅ 组件管理工具 +- ✅ 脚本管理工具(默认创建 TypeScript 脚本) +- ✅ 批处理执行工具 +- ✅ 资产管理工具 +- ✅ 实时日志系统 +- ✅ 自动启动功能 +- ✅ 面板界面实现 + +#### 第二阶段 +- ✅ 场景管理工具(scene_management) + - 创建场景 + - 删除场景 + - 复制场景 + - 获取场景信息 +- ✅ 预制体管理工具(prefab_management) + - 创建预制体 + - 更新预制体 + - 实例化预制体 + - 获取预制体信息 +- ✅ 面板布局优化 + - 响应式设计 + - 滚动条支持 + - 小窗口适配 +- ✅ 移除旧工具 + - 删除了 create_scene 工具(功能整合到 scene_management) + - 删除了 create_prefab 工具(功能整合到 prefab_management) +- ✅ README.md 文档更新 +- ✅ 代码提交到本地仓库 + +#### 第三阶段 +- ✅ 编辑器管理工具(manage_editor) + - 获取选中对象 + - 设置选中状态 + - 刷新编辑器 +- ✅ 游戏对象查找工具(find_gameobjects) + - 根据名称、标签、组件、激活状态查找节点 + - 支持递归和非递归查找 +- ✅ 材质管理工具(manage_material) + - 创建、删除、获取材质信息 +- ✅ 纹理管理工具(manage_texture) + - 创建、删除、获取纹理信息 +- ✅ 菜单项执行工具(execute_menu_item) + - 执行 Cocos Creator 编辑器菜单项 +- ✅ 代码编辑增强工具(apply_text_edits) + - 支持插入、删除、替换文本操作 +- ✅ 控制台读取工具(read_console) + - 读取编辑器控制台输出 + - 支持按类型过滤和限制输出数量 +- ✅ 脚本验证工具(validate_script) + - 验证脚本语法正确性 +- ✅ 面板工具说明功能 + - 添加工具说明框 + - 显示详细的工具描述和参数说明 + +### 11.2 未完成的任务 + +- ❌ 代码推送到远程仓库(认证错误) +- ❌ 测试用例编写 +- ❌ 性能优化 +- ❌ 错误处理增强 +- ❌ 安全配置 + +### 11.3 后续需要完成的任务 + +#### 高优先级 +1. **代码推送**:解决远程仓库认证问题,完成代码推送 +2. **测试用例**:为核心工具编写测试用例 +3. **安全配置**:添加 IP 白名单和认证机制 + +#### 中优先级 +1. **性能优化**:优化 HTTP 服务响应速度,改进批处理执行效率 +2. **错误处理**:增强错误处理和恢复机制,提高插件稳定性 +3. **文档完善**:添加更详细的 API 文档和使用示例,包括新工具的详细说明 + +#### 低优先级 +1. **工具扩展**:添加更多高级工具,如动画管理、物理系统管理等 +2. **界面美化**:进一步优化面板界面,提升用户体验 +3. **国际化**:支持多语言,方便国际用户使用 +4. **插件发布**:准备插件发布到 Cocos 插件商店 +5. **版本兼容**:适配更多 Cocos Creator 版本 + +### 11.4 任务优先级表 + +| 任务 | 优先级 | 状态 | 描述 | +|------|--------|------|------| +| 代码推送 | 高 | 未完成 | 解决远程仓库认证问题 | +| 测试用例 | 高 | 未完成 | 为核心工具编写测试用例 | +| 安全配置 | 高 | 未完成 | 添加 IP 白名单和认证机制 | +| 性能优化 | 中 | 未完成 | 优化 HTTP 服务响应速度,改进批处理执行效率 | +| 错误处理 | 中 | 未完成 | 增强错误处理和恢复机制,提高插件稳定性 | +| 文档完善 | 中 | 未完成 | 添加更详细的 API 文档和使用示例,包括新工具的详细说明 | +| 工具扩展 | 低 | 未完成 | 添加更多高级工具,如动画管理、物理系统管理等 | +| 界面美化 | 低 | 未完成 | 进一步优化面板界面,提升用户体验 | +| 国际化 | 低 | 未完成 | 支持多语言,方便国际用户使用 | +| 插件发布 | 低 | 未完成 | 准备插件发布到 Cocos 插件商店 | +| 版本兼容 | 低 | 未完成 | 适配更多 Cocos Creator 版本 | +| 编辑器管理工具 | 高 | 已完成 | 实现 manage_editor 工具,支持编辑器状态管理 | +| 游戏对象查找工具 | 高 | 已完成 | 实现 find_gameobjects 工具,支持根据条件查找节点 | +| 材质和纹理管理工具 | 高 | 已完成 | 实现 manage_material 和 manage_texture 工具 | +| 菜单项执行工具 | 高 | 已完成 | 实现 execute_menu_item 工具,支持执行编辑器菜单项 | +| 代码编辑增强工具 | 中 | 已完成 | 实现 apply_text_edits 工具,支持文本编辑操作 | +| 控制台读取工具 | 中 | 已完成 | 实现 read_console 工具,支持读取控制台输出 | +| 脚本验证工具 | 中 | 已完成 | 实现 validate_script 工具,支持脚本语法验证 | +| 面板工具说明功能 | 低 | 已完成 | 添加工具说明框,显示详细的工具描述和参数说明 | + +## 12. Unity-MCP 对比分析 + +### 12.1 Unity-MCP 功能特性 + +Unity-MCP 提供了以下核心功能: + +- **资产管理**:管理各种 Unity 资源 +- **编辑器管理**:控制 Unity 编辑器功能 +- **游戏对象管理**:创建、修改、查找游戏对象 +- **组件管理**:添加、移除、修改组件 +- **材质管理**:创建和修改材质 +- **预制体管理**:管理预制体资源 +- **场景管理**:创建、保存、加载场景 +- **脚本管理**:创建、修改脚本 +- **ScriptableObject 管理**:管理配置文件 +- **着色器管理**:管理着色器资源 +- **VFX 管理**:管理视觉效果 +- **纹理管理**:管理纹理资源 +- **批处理执行**:批量执行多个操作 +- **游戏对象查找**:根据条件查找游戏对象 +- **文件内容查找**:在文件中查找内容 +- **控制台读取**:读取 Unity 控制台输出 +- **Unity 刷新**:刷新 Unity 编辑器 +- **测试运行**:运行测试用例 +- **获取测试任务**:获取测试任务信息 +- **菜单项执行**:执行 Unity 菜单项 +- **文本编辑应用**:应用文本编辑操作 +- **脚本编辑应用**:应用脚本编辑操作 +- **脚本验证**:验证脚本语法 +- **创建脚本**:创建新脚本 +- **删除脚本**:删除脚本文件 +- **获取 SHA**:获取版本控制 SHA 值 + +### 12.2 Cocos-MCP 功能特性 + +当前 Cocos-MCP 已实现的功能: + +- **场景节点操作**:获取选中节点、设置节点名称、获取场景层级、更新节点变换、创建节点 +- **组件管理**:添加、移除、获取组件 +- **资源管理**:创建、删除、移动资源 +- **脚本管理**:创建、删除、读取、写入脚本(默认创建 TypeScript 脚本) +- **批处理执行**:批量执行多个操作 +- **资产管理**:管理各种资源文件 +- **场景管理**:创建、删除、复制、获取场景信息 +- **预制体管理**:创建、更新、实例化、获取预制体信息 +- **面板界面**:提供主面板和工具测试面板 + +### 12.3 功能缺失对比 + +| 功能类别 | Unity-MCP 功能 | Cocos-MCP 状态 | 可实现性 | +|---------|---------------|---------------|--------| +| 编辑器管理 | manage_editor | ❌ 缺失 | ✅ 可实现 | +| 游戏对象管理 | find_gameobjects | ❌ 缺失 | ✅ 可实现 | +| 材质管理 | manage_material | ❌ 缺失 | ✅ 可实现 | +| 着色器管理 | manage_shader | ❌ 缺失 | ✅ 可实现 | +| 纹理管理 | manage_texture | ❌ 缺失 | ✅ 可实现 | +| 代码编辑增强 | apply_text_edits, script_apply_edits | ❌ 缺失 | ✅ 可实现 | +| 测试功能 | run_tests, get_test_job | ❌ 缺失 | ⚠️ 部分可实现 | +| 控制台读取 | read_console | ❌ 缺失 | ✅ 可实现 | +| 菜单项执行 | execute_menu_item | ❌ 缺失 | ✅ 可实现 | +| 脚本验证 | validate_script | ❌ 缺失 | ✅ 可实现 | +| VFX 管理 | manage_vfx | ❌ 缺失 | ✅ 可实现 | + +### 12.4 功能实现建议 + +#### 高优先级功能 + +1. **编辑器管理工具** (`manage_editor`) + - 功能:控制编辑器状态、执行编辑器操作 + - 实现方案:使用 `Editor.Ipc` 调用编辑器 API,如 `Editor.Selection`、`Editor.assetdb` 等 + +2. **游戏对象查找工具** (`find_gameobjects`) + - 功能:根据条件查找场景中的节点 + - 实现方案:使用场景脚本遍历节点树,根据名称、标签、组件等条件过滤 + +3. **材质和纹理管理工具** (`manage_material`, `manage_texture`) + - 功能:创建和管理材质、纹理资源 + - 实现方案:使用 `Editor.assetdb` API 操作资源文件 + +4. **菜单项执行工具** (`execute_menu_item`) + - 功能:执行 Cocos Creator 菜单项 + - 实现方案:使用 `Editor.Ipc.sendToMain` 发送菜单命令 + +#### 中优先级功能 + +1. **代码编辑增强工具** (`apply_text_edits`, `script_apply_edits`) + - 功能:应用文本编辑操作到文件 + - 实现方案:读取文件内容,应用编辑操作,然后写回文件 + +2. **控制台读取工具** (`read_console`) + - 功能:读取编辑器控制台输出 + - 实现方案:重定向 `console.log` 等方法,捕获控制台输出 + +3. **脚本验证工具** (`validate_script`) + - 功能:验证脚本语法正确性 + - 实现方案:使用 Node.js 的语法解析器或调用外部工具 + +#### 低优先级功能 + +1. **测试功能** (`run_tests`, `get_test_job`) + - 功能:运行测试用例并获取结果 + - 实现方案:根据 Cocos Creator 的测试框架集成 + +2. **VFX 管理工具** (`manage_vfx`) + - 功能:管理视觉效果资源 + - 实现方案:使用 `Editor.assetdb` API 操作 VFX 资源 + +## 13. 总结 MCP Bridge 插件通过 HTTP 服务和 MCP 协议,为外部 AI 工具提供了与 Cocos Creator 编辑器交互的能力。插件支持场景操作、资源管理、组件管理、脚本管理等多种功能,为 Cocos Creator 项目的开发和自动化提供了有力的支持。 通过本文档的开发流程,我们构建了一个功能完整、稳定可靠的 MCP Bridge 插件,为 Cocos Creator 生态系统增添了新的工具和能力。 + +目前插件已经完成了核心功能的实现,包括 15 个 MCP 工具,支持从场景操作到资源管理的各种功能。后续将继续完善测试、优化性能,并添加更多高级功能,为开发者提供更强大的工具支持。 + +通过与 Unity-MCP 的对比分析,我们识别出了多个可实现的功能,这些功能将进一步增强 Cocos-MCP 的能力,使其与 Unity-MCP 保持功能对等,为 Cocos Creator 开发者提供同样强大的 AI 辅助开发体验。 diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..fce97be --- /dev/null +++ b/DEVELOPMENT_PLAN.md @@ -0,0 +1,630 @@ +# Cocos-MCP 开发计划文档 + +## 1. 项目概述 + +### 1.1 项目背景 + +Cocos-MCP (Model Context Protocol) 插件是一个为 Cocos Creator 编辑器提供外部 AI 工具交互能力的桥梁。通过 HTTP 服务和 MCP 协议,插件允许外部 AI 工具(如 Cursor、VS Code 等)直接与 Cocos Creator 编辑器进行交互,实现场景操作、资源管理、脚本编辑等功能。 + +### 1.2 项目目标 + +- 提供与 Unity-MCP 对等的功能集,使 Cocos Creator 开发者获得同样强大的 AI 辅助开发体验 +- 实现编辑器管理、游戏对象查找、材质/纹理管理等高级功能 +- 优化现有功能,提高插件稳定性和性能 +- 建立完善的测试和部署流程 + +### 1.3 技术栈 + +- **开发语言**:JavaScript/TypeScript +- **运行环境**:Node.js (Cocos Creator 内置) +- **通信协议**:HTTP + JSON (MCP 协议) +- **编辑器 API**:Cocos Creator 2.4.x Editor API +- **界面技术**:HTML/CSS + Cocos Creator 面板 API + +## 2. 功能分析 + +### 2.1 Unity-MCP 功能参考 + +Unity-MCP 提供了以下核心功能: + +| 功能类别 | 具体功能 | 描述 | +|---------|---------|------| +| 编辑器管理 | manage_editor | 控制 Unity 编辑器状态和操作 | +| 游戏对象管理 | find_gameobjects | 根据条件查找游戏对象 | +| 材质管理 | manage_material | 创建和管理材质资源 | +| 着色器管理 | manage_shader | 管理着色器资源 | +| 纹理管理 | manage_texture | 管理纹理资源 | +| 代码编辑增强 | apply_text_edits | 应用文本编辑操作到文件 | +| 测试功能 | run_tests | 运行测试用例 | +| 控制台读取 | read_console | 读取编辑器控制台输出 | +| 菜单项执行 | execute_menu_item | 执行编辑器菜单项 | +| 脚本验证 | validate_script | 验证脚本语法 | +| VFX 管理 | manage_vfx | 管理视觉效果资源 | + +### 2.2 Cocos-MCP 现状分析 + +#### 已实现功能 + +- **场景节点操作**:获取选中节点、设置节点名称、获取场景层级、更新节点变换、创建节点 +- **组件管理**:添加、移除、获取组件 +- **资源管理**:创建、删除、移动资源 +- **脚本管理**:创建、删除、读取、写入脚本(默认创建 TypeScript 脚本) +- **批处理执行**:批量执行多个操作 +- **资产管理**:管理各种资源文件 +- **场景管理**:创建、删除、复制、获取场景信息 +- **预制体管理**:创建、更新、实例化、获取预制体信息 +- **面板界面**:提供主面板和工具测试面板 +- **编辑器管理**:控制编辑器状态、执行编辑器操作 +- **游戏对象查找**:根据条件查找场景中的节点 +- **材质管理**:创建和管理材质资源 +- **着色器管理**:管理着色器资源 +- **纹理管理**:管理纹理资源 +- **代码编辑增强**:应用文本编辑操作到文件 +- **测试功能**:运行自动化测试用例 (`run_tests.js`) +- **控制台读取**:读取编辑器控制台输出 +- **菜单项执行**:执行编辑器菜单项 +- **脚本验证**:验证脚本语法 + +#### 缺失功能 + +- **VFX 管理**:管理视觉效果资源(暂未排期) + +### 2.3 功能优先级排序 + +所有高/中优先级功能均已完成。 + +### 2.4 Unity-MCP 差异分析 (Gap Analysis) + +通过对比 [Unity-MCP](https://github.com/CoplayDev/unity-mcp),发现以下缺失或可增强的功能: + +1. **全局搜索 (`find_in_file`)**: ✅ 已实现。支持在整个项目中搜索文本内容。 +2. **ScriptableObject 管理 (`manage_scriptable_object`)**: 虽然 AssetDB 可以创建资源,但缺乏专门针对 ScriptableObject (cc.Asset) 的简便创建工具。 +3. **Undo/Redo 支持**: ✅ 已实现。通过 `manage_undo` 提供了 `undo/redo` 及事务组支持。 +4. **VFX 管理 (`manage_vfx`)**: ✅ 已实现。支持粒子系统的创建、修改和信息获取。 +5. **Git 集成 (`get_sha`)**: 获取当前版本库信息。 + +`find_in_file`、`Undo/Redo` 和 `manage_vfx` 已在最新迭代中完成。 + +## 3. 开发路线图 + +### 3.1 第三阶段开发计划(已完成) + +| 任务 | 状态 | 描述 | +|------|------|------| +| 编辑器管理工具实现 | ✅ 完成 | 实现 manage_editor 工具,支持编辑器状态控制和操作执行 | +| 游戏对象查找工具实现 | ✅ 完成 | 实现 find_gameobjects 工具,支持根据条件查找场景节点 | +| 材质和纹理管理工具实现 | ✅ 完成 | 实现 manage_material 和 manage_texture 工具,支持材质和纹理资源管理 | +| 菜单项执行工具实现 | ✅ 完成 | 实现 execute_menu_item 工具,支持执行 Cocos Creator 菜单项 | +| 代码编辑增强工具实现 | ✅ 完成 | 实现 apply_text_edits 工具,支持文本编辑操作应用 | +| 控制台读取工具实现 | ✅ 完成 | 实现 read_console 工具,支持读取编辑器控制台输出 | +| 脚本验证工具实现 | ✅ 完成 | 实现 validate_script 工具,支持脚本语法验证 | + +### 3.2 第四阶段开发计划(已完成) + +| 任务 | 状态 | 描述 | +|------|------|------| +| 测试功能实现 | ✅ 完成 | 实现 run_tests.js 脚本,支持运行自动化测试用例 | +| 错误处理增强 | ✅ 完成 | 完善 HTTP 服务和工具调用的错误日志记录 | + +### 3.3 差异填补阶段(Gap Filling)- 已完成 + +| 任务 | 状态 | 描述 | +|------|------|------| +| 全局文件搜索 | ✅ 完成 | 实现 find_in_file 工具 | +| 撤销/重做支持 | ✅ 完成 | 实现 manage_undo 工具,并重构核心操作支持撤销 | +| 特效管理 | ✅ 完成 | 实现 manage_vfx 工具,支持粒子系统管理 | + +### 3.3 第五阶段开发计划(远期) + +| 任务 | 优先级 | 预计时间 | 描述 | +|------|--------|----------|------| +| 工具扩展 | 低 | 3 天 | 添加更多高级工具和功能 | +| 界面美化 | 低 | 2 天 | 进一步优化面板界面,提升用户体验 | +| 国际化支持 | 低 | 2 天 | 添加多语言支持 | +| 文档完善 | 中 | 2 天 | 完善 API 文档和使用示例 | +| 插件发布 | 高 | 1 天 | 准备插件发布,提交到 Cocos 插件商店 | + +## 4. 技术架构 + +### 4.1 系统架构 + +``` +┌────────────────────┐ HTTP ┌────────────────────┐ IPC ┌────────────────────┐ +│ 外部 AI 工具 │ ──────────> │ main.js (HTTP服务) │ ─────────> │ scene-script.js │ +│ (Cursor/VS Code) │ <──────── │ (MCP 协议处理) │ <──────── │ (场景操作执行) │ +└────────────────────┘ JSON └────────────────────┘ JSON └────────────────────┘ +``` + +### 4.2 核心模块 + +1. **HTTP 服务模块**:处理外部请求,解析 MCP 协议,返回操作结果 +2. **MCP 工具模块**:实现各种操作工具,包括新增的编辑器管理、游戏对象查找等功能 +3. **场景操作模块**:执行场景相关操作,如节点查找、组件管理等 +4. **资源管理模块**:处理脚本、材质、纹理等资源文件的创建和管理 +5. **面板界面模块**:提供用户交互界面,包括主面板和工具测试面板 + +### 4.3 技术实现要点 + +- **编辑器 API 调用**:使用 `Editor.Ipc` 与编辑器核心进行通信 +- **资源操作**:使用 `Editor.assetdb` API 进行资源文件的创建、读取、更新和删除 +- **场景操作**:通过场景脚本执行节点和组件操作 +- **异步处理**:使用回调函数处理异步操作,避免阻塞主线程 +- **错误处理**:完善的错误捕获和处理机制,提高插件稳定性 +- **性能优化**:使用批处理执行减少 HTTP 请求次数,优化资源操作效率 + +## 5. 功能实现方案 + +### 5.1 编辑器管理工具 (`manage_editor`) + +#### 功能描述 + +提供对 Cocos Creator 编辑器状态的控制和操作执行能力。 + +#### 实现方案 + +```javascript +// 在 main.js 中添加 +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' && properties.nodes) { + Editor.Selection.select('node', properties.nodes); + } else if (target === 'asset' && properties.assets) { + Editor.Selection.select('asset', properties.assets); + } + callback(null, "Selection updated"); + break; + case "refresh_editor": + // 刷新编辑器 + Editor.assetdb.refresh(); + callback(null, "Editor refreshed"); + break; + default: + callback("Unknown action"); + } +} +``` + +### 5.2 游戏对象查找工具 (`find_gameobjects`) + +#### 功能描述 + +根据条件查找场景中的节点对象。 + +#### 实现方案 + +```javascript +// 在 scene-script.js 中添加 +findGameObjects(params, callback) { + const { conditions, recursive } = params; + const result = []; + + // 遍历场景根节点 + cc.director.getScene().children.forEach(child => { + searchNode(child, conditions, recursive, result); + }); + + callback(null, result); +} + +function searchNode(node, conditions, recursive, result) { + // 检查节点是否满足条件 + let match = true; + + if (conditions.name && !node.name.includes(conditions.name)) { + match = false; + } + + if (conditions.tag && node.tag !== conditions.tag) { + match = false; + } + + if (conditions.component && !node.getComponent(conditions.component)) { + match = false; + } + + if (match) { + result.push({ + id: node.uuid, + name: node.name, + tag: node.tag, + position: node.position, + rotation: node.rotation, + scale: node.scale + }); + } + + // 递归搜索子节点 + if (recursive) { + node.children.forEach(child => { + searchNode(child, conditions, recursive, result); + }); + } +} +``` + +### 5.3 材质和纹理管理工具 (`manage_material`, `manage_texture`) + +#### 功能描述 + +管理材质和纹理资源,支持创建、修改、删除等操作。 + +#### 实现方案 + +```javascript +// 在 main.js 中添加 +manageMaterial(args, callback) { + const { action, path, properties } = args; + + switch (action) { + case "create": + // 创建材质资源 + const materialContent = JSON.stringify({ + __type__: "cc.Material", + _name: "", + _objFlags: 0, + _native: "", + effects: [{ + technique: 0, + defines: {}, + uniforms: properties.uniforms || {} + }] + }); + + // 确保目录存在 + 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, materialContent, (err) => { + callback(err, err ? null : `Material created at ${path}`); + }); + break; + // 其他操作... + } +} + +manageTexture(args, callback) { + const { action, path, properties } = args; + + switch (action) { + case "create": + // 创建纹理资源(简化版,实际需要处理纹理文件) + const textureContent = JSON.stringify({ + __type__: "cc.Texture2D", + _name: "", + _objFlags: 0, + _native: properties.native || "", + width: properties.width || 128, + height: properties.height || 128 + }); + + // 确保目录存在 + 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, textureContent, (err) => { + callback(err, err ? null : `Texture created at ${path}`); + }); + break; + // 其他操作... + } +} +``` + +### 5.4 菜单项执行工具 (`execute_menu_item`) + +#### 功能描述 + +执行 Cocos Creator 编辑器的菜单项命令。 + +#### 实现方案 + +```javascript +// 在 main.js 中添加 +executeMenuItem(args, callback) { + const { menuPath } = args; + + try { + // 执行菜单项 + Editor.Ipc.sendToMain('menu:click', menuPath); + callback(null, `Menu item executed: ${menuPath}`); + } catch (err) { + callback(`Failed to execute menu item: ${err.message}`); + } +} +``` + +### 5.5 代码编辑增强工具 (`apply_text_edits`) + +#### 功能描述 + +应用文本编辑操作到文件,支持插入、删除、替换等操作。 + +#### 实现方案 + +```javascript +// 在 main.js 中添加 +applyTextEdits(args, callback) { + const { filePath, edits } = args; + + // 读取文件内容 + Editor.assetdb.queryInfoByUrl(filePath, (err, info) => { + if (err) { + callback(`Failed to get file info: ${err.message}`); + return; + } + + Editor.assetdb.loadMeta(info.uuid, (err, content) => { + if (err) { + callback(`Failed to load file: ${err.message}`); + return; + } + + // 应用编辑操作 + let updatedContent = content; + edits.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; + } + }); + + // 写回文件 + Editor.assetdb.save(info.uuid, updatedContent, (err) => { + callback(err, err ? null : `Text edits applied to ${filePath}`); + }); + }); + }); +} +``` + +### 5.6 控制台读取工具 (`read_console`) + +#### 功能描述 + +读取编辑器控制台的输出信息。 + +#### 实现方案 + +```javascript +// 在 main.js 中添加 +// 首先在模块顶部添加控制台输出捕获 +let consoleOutput = []; +const originalLog = console.log; +const originalError = console.error; +const originalWarn = console.warn; + +console.log = function(...args) { + consoleOutput.push({ type: 'log', message: args.join(' ') }); + originalLog.apply(console, args); +}; + +console.error = function(...args) { + consoleOutput.push({ type: 'error', message: args.join(' ') }); + originalError.apply(console, args); +}; + +console.warn = function(...args) { + consoleOutput.push({ type: 'warn', message: args.join(' ') }); + originalWarn.apply(console, args); +}; + +// 然后添加 read_console 工具 +readConsole(args, callback) { + const { limit, type } = args; + let filteredOutput = consoleOutput; + + if (type) { + filteredOutput = filteredOutput.filter(item => item.type === type); + } + + if (limit) { + filteredOutput = filteredOutput.slice(-limit); + } + + callback(null, filteredOutput); +} +``` + +### 5.7 脚本验证工具 (`validate_script`) + +#### 功能描述 + +验证脚本文件的语法正确性。 + +#### 实现方案 + +```javascript +// 在 main.js 中添加 +validateScript(args, callback) { + const { filePath } = args; + + // 读取脚本内容 + Editor.assetdb.queryInfoByUrl(filePath, (err, info) => { + if (err) { + callback(`Failed to get file info: ${err.message}`); + return; + } + + Editor.assetdb.loadMeta(info.uuid, (err, content) => { + if (err) { + callback(`Failed to load file: ${err.message}`); + return; + } + + try { + // 对于 JavaScript 脚本,使用 eval 进行简单验证 + if (filePath.endsWith('.js')) { + // 包装在函数中以避免变量污染 + const wrapper = `(function() { ${content} })`; + eval(wrapper); + } + // 对于 TypeScript 脚本,这里可以添加更复杂的验证逻辑 + + callback(null, { valid: true, message: 'Script syntax is valid' }); + } catch (err) { + callback(null, { valid: false, message: err.message }); + } + }); + }); +} + }); +} +``` + +### 5.8 常用 IPC 消息参考 (Cocos Creator 2.4.x) + +基于社区资料整理,以下 IPC 消息可用于扩展功能: + +#### 场景操作 (`scene:`) +- **创建/实例化**: + - `scene:create-node-by-classid` (参数: name, parentUuid) + - `scene:create-nodes-by-uuids` (实例化预制体, 参数: [prefabUuid], parentUuid) +- **修改**: + - `scene:set-property` (参数: {id, path, type, value}) + - `scene:copy-nodes` / `scene:paste-nodes` +- **安全机制**: + - `scene:undo` / `scene:redo` (建议集成到 manage_editor) + +#### 资源操作 (`assets:`) +- `assets:hint` (高亮资源) +- `assets:open-text-file` (打开外部编辑器) + +## 6. 测试策略 + +### 6.1 测试目标 + +- 验证所有新增功能的正确性和稳定性 +- 确保现有功能不受影响 +- 测试插件在不同场景下的性能表现 +- 验证错误处理机制的有效性 + +### 6.2 测试方法 + +#### 单元测试 + +- 为每个工具函数编写独立的测试用例 +- 测试各种输入参数和边界情况 +- 验证函数返回值的正确性 + +#### 集成测试 + +- 测试工具之间的协作能力 +- 验证批处理执行的正确性 +- 测试插件与编辑器的集成稳定性 + +#### 性能测试 + +- 测试工具执行速度 +- 验证批处理执行的性能优势 +- 测试插件在处理大量操作时的表现 + +#### 回归测试 + +- 确保新增功能不破坏现有功能 +- 验证修复的 bug 不会再次出现 +- 测试插件在不同版本 Cocos Creator 中的兼容性 + +### 6.3 测试工具 + +- **手动测试**:通过面板界面测试工具功能 +- **脚本测试**:编写测试脚本自动执行测试用例 +- **性能分析**:使用浏览器开发者工具分析性能瓶颈 + +## 7. 部署与维护 + +### 7.1 部署方案 + +#### 本地部署 + +1. 将插件复制到 Cocos Creator 项目的 `packages` 目录 +2. 重启 Cocos Creator 编辑器 +3. 在编辑器中打开 MCP Bridge 面板 +4. 启动 HTTP 服务 + +#### 远程部署 + +1. 使用 Git 进行版本控制 +2. 提供插件的 GitHub 仓库地址 +3. 发布插件到 Cocos 插件商店(未来) + +### 7.2 维护计划 + +#### 版本管理 + +- 遵循语义化版本规范(MAJOR.MINOR.PATCH) +- 定期发布更新版本 +- 维护详细的版本变更日志 + +#### 错误处理 + +- 建立错误报告机制 +- 定期分析错误日志 +- 及时修复发现的问题 + +#### 性能优化 + +- 定期分析插件性能 +- 优化资源操作和网络请求 +- 提高插件响应速度 + +#### 文档维护 + +- 保持 README.md 和开发文档的更新 +- 提供详细的 API 文档 +- 编写使用教程和示例 + +## 8. 风险评估 + +### 8.1 潜在风险 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| 编辑器 API 变更 | 插件功能失效 | 定期检查 Cocos Creator 更新,适配新 API | +| 性能问题 | 插件响应缓慢 | 优化代码结构,使用批处理执行,避免阻塞操作 | +| 安全漏洞 | 未授权访问 | 添加 IP 白名单,实现认证机制,限制服务访问范围 | +| 兼容性问题 | 不同版本 Cocos Creator 不兼容 | 测试多个版本,提供版本兼容层 | +| 错误处理不完善 | 插件崩溃 | 完善错误捕获和处理机制,提高插件稳定性 | + +### 8.2 应对策略 + +- **持续集成**:建立自动化测试流程,及时发现问题 +- **监控机制**:添加性能监控和错误监控 +- **用户反馈**:建立用户反馈渠道,收集使用问题 +- **文档完善**:提供详细的安装和使用文档 +- **社区支持**:建立社区支持渠道,解答用户问题 + +## 9. 结论 + +Cocos-MCP 插件的开发计划基于与 Unity-MCP 的功能对比,旨在为 Cocos Creator 开发者提供同样强大的 AI 辅助开发体验。通过分阶段实现编辑器管理、游戏对象查找、材质/纹理管理等高级功能,插件将逐步完善其功能集,成为 Cocos Creator 编辑器的重要扩展工具。 + +本开发计划文档为后续的开发工作提供了详细的指导,包括功能实现方案、技术架构设计、测试策略和部署维护计划。通过严格按照计划执行开发工作,我们可以确保插件的质量和稳定性,为 Cocos Creator 生态系统做出贡献。 \ No newline at end of file diff --git a/README.md b/README.md index 9a8fec7..b0f0e05 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,18 @@ - **资产管理**: 创建、删除、移动、获取资源信息 - **实时日志**: 提供详细的操作日志记录和展示 - **自动启动**: 支持编辑器启动时自动开启服务 +- **编辑器管理**: 获取和设置选中对象,刷新编辑器 +- **游戏对象查找**: 根据条件查找场景中的节点 +- **材质管理**: 创建和管理材质资源 +- **纹理管理**: 创建和管理纹理资源 +- **菜单项执行**: 执行 Cocos Creator 编辑器菜单项 +- **代码编辑增强**: 应用文本编辑操作到文件 +- **控制台读取**: 读取编辑器控制台输出 +- **脚本验证**: 验证脚本语法正确性 +- **全局搜索**: 在项目中搜索文本内容 +- **撤销/重做**: 管理编辑器的撤销栈 +- **特效管理**: 创建和修改粒子系统 +- **工具说明**: 测试面板提供详细的工具描述和参数说明 ## 安装与使用 @@ -172,6 +184,105 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j - `nodeId`: 节点 ID(用于 `create` 和 `update` 操作) - `parentId`: 父节点 ID(用于 `instantiate` 操作) +### 14. manage_editor + +- **描述**: 管理编辑器 +- **参数**: + - `action`: 操作类型(`get_selection`, `set_selection`, `refresh_editor`) + - `target`: 目标类型(`node`, `asset`)(用于 `set_selection` 操作) + - `properties`: 操作属性 + - `nodes`: 节点 UUID 数组(用于 `set_selection` 操作) + - `assets`: 资源 UUID 数组(用于 `set_selection` 操作) + +### 15. find_gameobjects + +- **描述**: 查找游戏对象 +- **参数**: + - `conditions`: 查找条件 + - `name`: 节点名称(包含匹配) + - `tag`: 节点标签 + - `component`: 组件类型 + - `active`: 激活状态 + - `recursive`: 是否递归查找(默认:true) + +### 16. manage_material + +- **描述**: 管理材质 +- **参数**: + - `action`: 操作类型(`create`, `delete`, `get_info`) + - `path`: 材质路径,如 `db://assets/materials/NewMaterial.mat` + - `properties`: 材质属性(用于 `create` 操作) + - `uniforms`: 材质 uniforms + +### 17. manage_texture + +- **描述**: 管理纹理 +- **参数**: + - `action`: 操作类型(`create`, `delete`, `get_info`) + - `path`: 纹理路径,如 `db://assets/textures/NewTexture.png` + - `properties`: 纹理属性(用于 `create` 操作) + - `width`: 宽度 + - `height`: 高度 + - `native`: 原生路径 + +### 18. execute_menu_item + +- **描述**: 执行菜单项 +- **参数**: + - `menuPath`: 菜单项路径,如 `Assets/Create/Folder` + +### 19. apply_text_edits + +- **描述**: 应用文本编辑 +- **参数**: + - `filePath`: 文件路径,如 `db://assets/scripts/TestScript.ts` + - `edits`: 编辑操作列表 + - `type`: 操作类型(`insert`, `delete`, `replace`) + - `position`: 插入位置(用于 `insert` 操作) + - `start`: 开始位置(用于 `delete` 和 `replace` 操作) + - `end`: 结束位置(用于 `delete` 和 `replace` 操作) + - `text`: 文本内容(用于 `insert` 和 `replace` 操作) + +### 20. read_console + +- **描述**: 读取控制台 +- **参数**: + - `limit`: 输出限制(可选) + - `type`: 输出类型(`log`, `error`, `warn`)(可选) + +### 21. validate_script + +- **描述**: 验证脚本 +- **参数**: + - `filePath`: 脚本路径,如 `db://assets/scripts/TestScript.ts` + +### 22. find_in_file + +- **描述**: 全局文件搜索 +- **参数**: + - `query`: 搜索关键词 + - `extensions`: 文件后缀列表 (可选,默认 `['.js', '.ts', '.json', '.fire', '.prefab', '.xml', '.txt', '.md']`) + - `includeSubpackages`: 是否搜索子包 (可选,默认 true) + +### 23. manage_undo + +- **描述**: 撤销/重做管理 +- **参数**: + - `action`: 操作类型 (`undo`, `redo`, `begin_group`, `end_group`, `cancel_group`) + - `description`: 撤销组描述 (用于 `begin_group`) + +### 24. manage_vfx + +- **描述**: 特效(粒子)管理 +- **参数**: + - `action`: 操作类型 (`create`, `update`, `get_info`) + - `nodeId`: 节点 UUID (用于 `update`, `get_info`) + - `name`: 节点名称 (用于 `create`) + - `parentId`: 父节点 UUID (用于 `create`) + - `properties`: 粒子属性对象 + - `duration`, `emissionRate`, `life`, `lifeVar`, `startColor`, `endColor` + - `startSize`, `endSize`, `speed`, `angle`, `gravity`, `file` (plist/texture) + ## 技术实现 ### 架构设计 @@ -229,6 +340,23 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j - 插件会自动标记场景为"已修改",请注意保存场景 - 不同版本的 Cocos Creator 可能会有 API 差异,请根据实际情况调整 +## 自动化测试 + +本项目包含一套自动化测试脚本,用于验证插件的核心功能(连接性、节点操作、组件管理、资源管理等)。 + +### 运行测试 + +前提: +1. 确保 Cocos Creator 编辑器已打开,且 MCP Bridge 插件已启动(HTTP 服务运行中)。 +2. 确保命令行所在路径为项目根目录。 + +运行命令: +```bash +node packages/mcp-bridge/test/run_tests.js +``` + +测试脚本将自动执行一系列操作,并在控制台输出测试结果。如有报错,请查看控制台详细日志。 + ## 贡献 欢迎提交 Issue 和 Pull Request 来改进这个插件! diff --git a/main.js b/main.js index b83122b..ad3a4e6 100644 --- a/main.js +++ b/main.js @@ -5,7 +5,7 @@ const path = require("path"); let logBuffer = []; // 存储所有日志 let mcpServer = null; -let isSceneBusy = false; +let isSceneBusy = false; let serverConfig = { port: 3456, active: false, @@ -237,7 +237,11 @@ const getToolsList = () => { inputSchema: { type: "object", properties: { - action: { type: "string", enum: ["create", "delete", "duplicate", "get_info"], description: "操作类型" }, + 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 操作)" }, @@ -251,7 +255,11 @@ const getToolsList = () => { inputSchema: { type: "object", properties: { - action: { type: "string", enum: ["create", "update", "instantiate", "get_info"], description: "操作类型" }, + 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 操作)" }, @@ -259,9 +267,185 @@ const getToolsList = () => { required: ["action", "path"], }, }, + { + name: "manage_editor", + description: "管理编辑器", + inputSchema: { + type: "object", + properties: { + action: { + type: "string", + enum: ["get_selection", "set_selection", "refresh_editor"], + description: "操作类型", + }, + target: { + type: "string", + enum: ["node", "asset"], + description: "目标类型 (用于 set_selection 操作)", + }, + properties: { type: "object", description: "操作属性" }, + }, + required: ["action"], + }, + }, + { + name: "find_gameobjects", + description: "查找游戏对象", + inputSchema: { + type: "object", + properties: { + conditions: { type: "object", description: "查找条件" }, + recursive: { type: "boolean", default: true, description: "是否递归查找" }, + }, + required: ["conditions"], + }, + }, + { + name: "manage_material", + description: "管理材质", + inputSchema: { + type: "object", + properties: { + action: { type: "string", enum: ["create", "delete", "get_info"], description: "操作类型" }, + path: { type: "string", description: "材质路径,如 db://assets/materials/NewMaterial.mat" }, + properties: { type: "object", description: "材质属性" }, + }, + required: ["action", "path"], + }, + }, + { + name: "manage_texture", + description: "管理纹理", + inputSchema: { + type: "object", + properties: { + action: { type: "string", enum: ["create", "delete", "get_info"], description: "操作类型" }, + path: { type: "string", description: "纹理路径,如 db://assets/textures/NewTexture.png" }, + properties: { type: "object", description: "纹理属性" }, + }, + required: ["action", "path"], + }, + }, + { + name: "execute_menu_item", + description: "执行菜单项", + inputSchema: { + type: "object", + properties: { + menuPath: { type: "string", description: "菜单项路径" }, + }, + required: ["menuPath"], + }, + }, + { + name: "apply_text_edits", + description: "应用文本编辑", + inputSchema: { + type: "object", + properties: { + filePath: { type: "string", description: "文件路径" }, + edits: { type: "array", items: { type: "object" }, description: "编辑操作列表" }, + }, + required: ["filePath", "edits"], + }, + }, + { + name: "read_console", + description: "读取控制台", + inputSchema: { + type: "object", + properties: { + limit: { type: "number", description: "输出限制" }, + type: { type: "string", enum: ["log", "error", "warn"], description: "输出类型" }, + }, + }, + }, + { + name: "validate_script", + description: "验证脚本", + inputSchema: { + type: "object", + properties: { + filePath: { type: "string", description: "脚本路径" }, + }, + required: ["filePath"], + }, + }, + { + name: "find_in_file", + description: "在项目中全局搜索文本内容", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "搜索关键词" }, + extensions: { + type: "array", + items: { type: "string" }, + description: "文件后缀列表 (例如 ['.js', '.ts'])", + default: [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"] + }, + includeSubpackages: { type: "boolean", default: true, description: "是否搜索子包 (暂时默认搜索 assets 目录)" } + }, + required: ["query"] + } + }, + { + name: "manage_undo", + description: "管理编辑器的撤销和重做历史", + 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: "管理全场景特效 (粒子系统)", + 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"] + } + } ]; }; + + module.exports = { "scene-script": "scene-script.js", load() { @@ -327,14 +511,36 @@ module.exports = { }, ], }; - addLog(err ? "error" : "success", `RES <- [${name}]`); + if (err) { + addLog("error", `RES <- [${name}] 失败: ${err}`); + } else { + // 成功时尝试捕获简单的结果预览(如果是字符串或简短对象) + let preview = ""; + if (typeof result === 'string') { + preview = result.length > 100 ? result.substring(0, 100) + "..." : result; + } else if (typeof result === 'object') { + try { + const jsonStr = JSON.stringify(result); + preview = jsonStr.length > 100 ? jsonStr.substring(0, 100) + "..." : jsonStr; + } catch (e) { + preview = "Object (Circular/Unserializable)"; + } + } + addLog("success", `RES <- [${name}] 成功 : ${preview}`); + } res.writeHead(200); res.end(JSON.stringify(response)); }); } catch (e) { - addLog("error", `JSON Parse Error: ${e.message}`); - res.writeHead(400); - res.end(JSON.stringify({ error: "Invalid JSON" })); + 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; } @@ -383,16 +589,15 @@ module.exports = { break; case "set_node_name": - Editor.Scene.callSceneScript( - "mcp-bridge", - "set-property", - { - id: args.id, - path: "name", - value: args.newName, - }, - callback, - ); + // 使用 scene:set-property 以支持撤销 + Editor.Ipc.sendToPanel("scene", "scene:set-property", { + id: args.id, + path: "name", + type: "String", + value: args.newName, + isSubProp: false + }); + callback(null, `Node name updated to ${args.newName}`); break; case "save_scene": @@ -415,7 +620,37 @@ module.exports = { break; case "update_node_transform": - Editor.Scene.callSceneScript("mcp-bridge", "update-node-transform", args, callback); + const { id, x, y, scaleX, scaleY, color } = args; + // 将多个属性修改打包到一个 Undo 组中 + Editor.Ipc.sendToPanel("scene", "scene:undo-record", "Transform Update"); + + try { + // 注意:Cocos Creator 属性类型通常首字母大写,如 'Float', 'String', 'Boolean' + // 也有可能支持 'Number',但 'Float' 更保险 + if (x !== undefined) Editor.Ipc.sendToPanel("scene", "scene:set-property", { id, path: "x", type: "Float", value: x, isSubProp: false }); + if (y !== undefined) Editor.Ipc.sendToPanel("scene", "scene:set-property", { id, path: "y", type: "Float", value: y, isSubProp: false }); + if (scaleX !== undefined) Editor.Ipc.sendToPanel("scene", "scene:set-property", { id, path: "scaleX", type: "Float", value: scaleX, isSubProp: false }); + if (scaleY !== undefined) Editor.Ipc.sendToPanel("scene", "scene:set-property", { id, path: "scaleY", type: "Float", value: scaleY, isSubProp: false }); + if (color) { + // 颜色稍微复杂,传递 hex 字符串可能需要 Color 对象转换,但 set-property 也许可以直接接受 info + // 安全起见,颜色还是走 scene-script 或者尝试直接 set-property + // 这里的 color 是 Hex String。尝试传 String 让编辑器解析? + // 通常编辑器需要 cc.Color 对象或 {r,g,b,a} + // 暂时保留 color 通过 scene-script 处理? 或者跳过? + // 为了保持一致性,还是走 scene-script 更新颜色,但这样颜色可能无法 undo。 + // 改进:使用 scene script 处理颜色,但尝试手动 record? + // 暂且忽略颜色的 Undo,先保证 Transform 的 Undo。 + Editor.Scene.callSceneScript("mcp-bridge", "update-node-transform", { id, color }, (err) => { + if (err) addLog("warn", "Color update failed or partial"); + }); + } + + Editor.Ipc.sendToPanel("scene", "scene:undo-commit"); + callback(null, "Transform updated"); + } catch (e) { + Editor.Ipc.sendToPanel("scene", "scene:undo-cancel"); + callback(e); + } break; case "create_scene": @@ -477,6 +712,82 @@ module.exports = { this.prefabManagement(args, callback); break; + case "manage_editor": + this.manageEditor(args, callback); + break; + + case "find_gameobjects": + Editor.Scene.callSceneScript("mcp-bridge", "find-gameobjects", args, callback); + break; + + case "manage_material": + this.manageMaterial(args, callback); + break; + + case "manage_texture": + this.manageTexture(args, callback); + break; + + case "execute_menu_item": + this.executeMenuItem(args, callback); + break; + + case "apply_text_edits": + this.applyTextEdits(args, callback); + break; + + case "read_console": + this.readConsole(args, callback); + break; + + case "validate_script": + this.validateScript(args, callback); + break; + + case "find_in_file": + this.findInFile(args, callback); + break; + + case "manage_undo": + this.manageUndo(args, callback); + break; + + case "manage_vfx": + // 【修复】在主进程预先解析 URL 为 UUID,因为渲染进程(scene-script)无法访问 Editor.assetdb + if (args.properties && args.properties.file) { + if (typeof args.properties.file === 'string' && args.properties.file.startsWith("db://")) { + const uuid = Editor.assetdb.urlToUuid(args.properties.file); + if (uuid) { + args.properties.file = uuid; // 替换为 UUID + } else { + console.warn(`Failed to resolve path to UUID: ${args.properties.file}`); + } + } + } + // 预先获取默认贴图 UUID (尝试多个可能的路径) + const defaultPaths = [ + "db://internal/image/default_sprite_splash", + "db://internal/image/default_sprite_splash.png", + "db://internal/image/default_particle", + "db://internal/image/default_particle.png" + ]; + + for (const path of defaultPaths) { + const uuid = Editor.assetdb.urlToUuid(path); + if (uuid) { + args.defaultSpriteUuid = uuid; + addLog("info", `[mcp-bridge] Resolved Default Sprite UUID: ${uuid} from ${path}`); + break; + } + } + + if (!args.defaultSpriteUuid) { + addLog("warn", "[mcp-bridge] Failed to resolve any default sprite UUID."); + } + + Editor.Scene.callSceneScript("mcp-bridge", "manage-vfx", args, callback); + break; + default: callback(`Unknown tool: ${name}`); break; @@ -493,14 +804,17 @@ module.exports = { return callback(`Script already exists at ${path}`); } // 确保父目录存在 - const fs = require('fs'); - const pathModule = require('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 || `const { ccclass, property } = cc._decorator; + Editor.assetdb.create( + path, + content || + `const { ccclass, property } = cc._decorator; @ccclass export default class NewScript extends cc.Component { @@ -517,9 +831,11 @@ export default class NewScript extends cc.Component { start () {} update (dt) {} -}`, (err) => { - callback(err, err ? null : `Script created at ${path}`); - }); +}`, + (err) => { + callback(err, err ? null : `Script created at ${path}`); + }, + ); break; case "delete": @@ -586,14 +902,14 @@ export default class NewScript extends cc.Component { return callback(`Asset already exists at ${path}`); } // 确保父目录存在 - const fs = require('fs'); - const pathModule = require('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) => { + Editor.assetdb.create(path, content || "", (err) => { callback(err, err ? null : `Asset created at ${path}`); }); break; @@ -619,6 +935,90 @@ export default class NewScript extends cc.Component { }); break; + case "get_info": + try { + if (!Editor.assetdb.exists(path)) { + return callback(`Asset not found: ${path}`); + } + const uuid = Editor.assetdb.urlToUuid(path); + // Return basic info constructed manually to avoid API compatibility issues + callback(null, { + url: path, + uuid: uuid, + exists: true + }); + } catch (e) { + callback(`Error getting asset info: ${e.message}`); + } + break; + + default: + callback(`Unknown asset action: ${action}`); + break; + } + }, + + // 场景管理 + sceneManagement(args, callback) { + const { action, path, targetPath, name } = args; + + switch (action) { + case "create": + if (Editor.assetdb.exists(path)) { + return callback(`Scene already exists at ${path}`); + } + // 确保父目录存在 + const 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, getNewSceneTemplate(), (err) => { + callback(err, err ? null : `Scene created at ${path}`); + }); + break; + + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`Scene not found at ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `Scene deleted at ${path}`); + }); + break; + + case "duplicate": + if (!Editor.assetdb.exists(path)) { + return callback(`Scene not found at ${path}`); + } + if (!targetPath) { + return callback(`Target path is required for duplicate operation`); + } + if (Editor.assetdb.exists(targetPath)) { + return callback(`Target scene already exists at ${targetPath}`); + } + // 读取原场景内容 + Editor.assetdb.loadAny(path, (err, content) => { + if (err) { + return callback(`Failed to read scene: ${err}`); + } + // 确保目标目录存在 + const fs = require("fs"); + const pathModule = require("path"); + 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) => { + callback(err, err ? null : `Scene duplicated from ${path} to ${targetPath}`); + }); + }); + break; + case "get_info": Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => { callback(err, err ? null : info); @@ -626,144 +1026,320 @@ export default class NewScript extends cc.Component { break; default: - callback(`Unknown asset action: ${action}`); - break; - } - }, - - // 场景管理 - sceneManagement(args, callback) { - const { action, path, targetPath, name } = args; - - switch (action) { - case "create": - if (Editor.assetdb.exists(path)) { - return callback(`Scene already exists at ${path}`); - } - // 确保父目录存在 - const 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, getNewSceneTemplate(), (err) => { - callback(err, err ? null : `Scene created at ${path}`); - }); - break; - - case "delete": - if (!Editor.assetdb.exists(path)) { - return callback(`Scene not found at ${path}`); - } - Editor.assetdb.delete([path], (err) => { - callback(err, err ? null : `Scene deleted at ${path}`); - }); - break; - - case "duplicate": - if (!Editor.assetdb.exists(path)) { - return callback(`Scene not found at ${path}`); - } - if (!targetPath) { - return callback(`Target path is required for duplicate operation`); - } - if (Editor.assetdb.exists(targetPath)) { - return callback(`Target scene already exists at ${targetPath}`); - } - // 读取原场景内容 - Editor.assetdb.loadAny(path, (err, content) => { - if (err) { - return callback(`Failed to read scene: ${err}`); - } - // 确保目标目录存在 - const fs = require('fs'); - const pathModule = require('path'); - 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) => { - callback(err, err ? null : `Scene duplicated from ${path} to ${targetPath}`); - }); - }); - break; - - case "get_info": - Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => { - callback(err, err ? null : info); - }); - break; - - default: callback(`Unknown scene action: ${action}`); break; - } - }, + } + }, - // 预制体管理 - prefabManagement(args, callback) { - const { action, path, nodeId, parentId } = args; + // 预制体管理 + prefabManagement(args, callback) { + const { action, path, nodeId, parentId } = args; - switch (action) { - case "create": - if (!nodeId) { - return callback(`Node ID is required for create operation`); - } - if (Editor.assetdb.exists(path)) { - return callback(`Prefab already exists at ${path}`); - } - // 确保父目录存在 - const fs = require('fs'); - const pathModule = require('path'); - const absolutePath = Editor.assetdb.urlToFspath(path); - const dirPath = pathModule.dirname(absolutePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - // 从节点创建预制体 - Editor.Ipc.sendToMain("scene:create-prefab", nodeId, path); - callback(null, `Command sent: Creating prefab from node ${nodeId} at ${path}`); - break; + switch (action) { + case "create": + if (!nodeId) { + return callback(`Node ID is required for create operation`); + } + if (Editor.assetdb.exists(path)) { + return callback(`Prefab already exists at ${path}`); + } + // 确保父目录存在 + const fs = require("fs"); + const pathModule = require("path"); + const absolutePath = Editor.assetdb.urlToFspath(path); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + // 从节点创建预制体 + Editor.Ipc.sendToMain("scene:create-prefab", nodeId, path); + callback(null, `Command sent: Creating prefab from node ${nodeId} at ${path}`); + break; - case "update": - if (!nodeId) { - return callback(`Node ID is required for update operation`); - } - if (!Editor.assetdb.exists(path)) { - return callback(`Prefab not found at ${path}`); - } - // 更新预制体 - Editor.Ipc.sendToMain("scene:update-prefab", nodeId, path); - callback(null, `Command sent: Updating prefab ${path} from node ${nodeId}`); - break; + case "update": + if (!nodeId) { + return callback(`Node ID is required for update operation`); + } + if (!Editor.assetdb.exists(path)) { + return callback(`Prefab not found at ${path}`); + } + // 更新预制体 + Editor.Ipc.sendToMain("scene:update-prefab", nodeId, path); + callback(null, `Command sent: Updating prefab ${path} from node ${nodeId}`); + break; - case "instantiate": - if (!Editor.assetdb.exists(path)) { - return callback(`Prefab not found at ${path}`); - } - // 实例化预制体 - Editor.Scene.callSceneScript("mcp-bridge", "instantiate-prefab", { + case "instantiate": + if (!Editor.assetdb.exists(path)) { + return callback(`Prefab not found at ${path}`); + } + // 实例化预制体 + Editor.Scene.callSceneScript( + "mcp-bridge", + "instantiate-prefab", + { prefabPath: path, - parentId: parentId - }, callback); - break; + parentId: parentId, + }, + callback, + ); + break; - case "get_info": - Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => { - callback(err, err ? null : info); - }); - break; + case "get_info": + Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => { + callback(err, err ? null : info); + }); + break; - default: - callback(`Unknown prefab action: ${action}`); - break; + default: + callback(`Unknown prefab action: ${action}`); + } + }, + + // 管理编辑器 + 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" && properties.nodes) { + Editor.Selection.select("node", properties.nodes); + } else if (target === "asset" && properties.assets) { + Editor.Selection.select("asset", properties.assets); + } + callback(null, "Selection updated"); + break; + case "refresh_editor": + // 刷新编辑器 + Editor.assetdb.refresh(); + callback(null, "Editor refreshed"); + break; + default: + callback("Unknown action"); + break; + } + }, + + // 管理材质 + manageMaterial(args, callback) { + const { action, path, properties } = args; + + switch (action) { + case "create": + if (Editor.assetdb.exists(path)) { + return callback(`Material already exists at ${path}`); + } + // 确保父目录存在 + const fs = require("fs"); + const pathModule = require("path"); + const absolutePath = Editor.assetdb.urlToFspath(path); + const dirPath = pathModule.dirname(absolutePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + // 创建材质资源 + const materialContent = JSON.stringify({ + __type__: "cc.Material", + _name: "", + _objFlags: 0, + _native: "", + effects: [ + { + technique: 0, + defines: {}, + uniforms: properties.uniforms || {}, + }, + ], + }); + Editor.assetdb.create(path, materialContent, (err) => { + callback(err, err ? null : `Material created at ${path}`); + }); + break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`Material not found at ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `Material deleted at ${path}`); + }); + break; + case "get_info": + Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => { + callback(err, err ? null : info); + }); + break; + default: + callback(`Unknown material action: ${action}`); + break; + } + }, + + // 管理纹理 + manageTexture(args, callback) { + const { action, path, properties } = args; + + switch (action) { + case "create": + if (Editor.assetdb.exists(path)) { + return callback(`Texture already exists at ${path}`); + } + // 确保父目录存在 + const 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 }); + } + // 创建纹理资源(简化版,实际需要处理纹理文件) + const textureContent = JSON.stringify({ + __type__: "cc.Texture2D", + _name: "", + _objFlags: 0, + _native: properties.native || "", + width: properties.width || 128, + height: properties.height || 128, + }); + Editor.assetdb.create(path, textureContent, (err) => { + callback(err, err ? null : `Texture created at ${path}`); + }); + break; + case "delete": + if (!Editor.assetdb.exists(path)) { + return callback(`Texture not found at ${path}`); + } + Editor.assetdb.delete([path], (err) => { + callback(err, err ? null : `Texture deleted at ${path}`); + }); + break; + case "get_info": + Editor.assetdb.queryInfoByUuid(Editor.assetdb.urlToUuid(path), (err, info) => { + callback(err, err ? null : info); + }); + break; + default: + callback(`Unknown texture action: ${action}`); + break; + } + }, + + // 执行菜单项 + executeMenuItem(args, callback) { + const { menuPath } = args; + + try { + // 执行菜单项 + Editor.Ipc.sendToMain("menu:click", menuPath); + callback(null, `Menu item executed: ${menuPath}`); + } catch (err) { + callback(`Failed to execute menu item: ${err.message}`); + } + }, + + // 应用文本编辑 + applyTextEdits(args, callback) { + const { filePath, edits } = args; + + // 读取文件内容 + Editor.assetdb.queryInfoByUrl(filePath, (err, info) => { + if (err) { + callback(`Failed to get file info: ${err.message}`); + return; } - }, - // 暴露给 MCP 或面板的 API 封装 + + Editor.assetdb.loadAny(filePath, (err, content) => { + if (err) { + callback(`Failed to load file: ${err.message}`); + return; + } + + // 应用编辑操作 + let updatedContent = content; + edits.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; + } + }); + + // 写回文件 + Editor.assetdb.create(filePath, updatedContent, (err) => { + callback(err, err ? null : `Text edits applied to ${filePath}`); + }); + }); + }); + }, + + // 读取控制台 + readConsole(args, callback) { + const { limit, type } = args; + let filteredOutput = logBuffer; + + if (type) { + filteredOutput = filteredOutput.filter((item) => item.type === type); + } + + if (limit) { + filteredOutput = filteredOutput.slice(-limit); + } + + callback(null, filteredOutput); + }, + + // 验证脚本 + validateScript(args, callback) { + const { filePath } = args; + + // 读取脚本内容 + Editor.assetdb.queryInfoByUrl(filePath, (err, info) => { + if (err) { + callback(`Failed to get file info: ${err.message}`); + return; + } + + Editor.assetdb.loadAny(filePath, (err, content) => { + if (err) { + callback(`Failed to load file: ${err.message}`); + return; + } + + try { + // 对于 JavaScript 脚本,使用 eval 进行简单验证 + if (filePath.endsWith(".js")) { + // 包装在函数中以避免变量污染 + const wrapper = `(function() { ${content} })`; + eval(wrapper); + } + // 对于 TypeScript 脚本,这里可以添加更复杂的验证逻辑 + + callback(null, { valid: true, message: "Script syntax is valid" }); + } catch (err) { + callback(null, { valid: false, message: err.message }); + } + }); + }); + }, + // 暴露给 MCP 或面板的 API 封装 messages: { "open-test-panel"() { Editor.Panel.open("mcp-bridge"); @@ -816,4 +1392,140 @@ export default class NewScript extends cc.Component { addLog("info", `Auto-start set to: ${value}`); }, }, + + // 验证脚本 + validateScript(args, callback) { + const { filePath } = args; + + // 读取脚本内容 + Editor.assetdb.queryInfoByUrl(filePath, (err, info) => { + if (err) { + callback(`Failed to get file info: ${err.message}`); + return; + } + + Editor.assetdb.loadMeta(info.uuid, (err, content) => { + if (err) { + callback(`Failed to load file: ${err.message}`); + return; + } + + try { + // 对于 JavaScript 脚本,使用 eval 进行简单验证 + if (filePath.endsWith('.js')) { + // 包装在函数中以避免变量污染 + const wrapper = `(function() { ${content} })`; + eval(wrapper); + } + // 对于 TypeScript 脚本,这里可以添加更复杂的验证逻辑 + + callback(null, { valid: true, message: 'Script syntax is valid' }); + } catch (err) { + callback(null, { valid: false, message: err.message }); + } + }); + }); + }, + + // 全局文件搜索 + findInFile(args, callback) { + const { query, extensions, includeSubpackages } = args; + const fs = require('fs'); + const path = require('path'); + + const assetsPath = Editor.assetdb.urlToFspath("db://assets"); + const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"]; + const results = []; + const MAX_RESULTS = 500; // 限制返回结果数量,防止溢出 + + try { + // 递归遍历函数 + const walk = (dir) => { + if (results.length >= MAX_RESULTS) return; + + const list = fs.readdirSync(dir); + list.forEach((file) => { + if (results.length >= MAX_RESULTS) return; + + // 忽略隐藏文件和 node_modules + if (file.startsWith('.') || file === 'node_modules' || file === 'bin' || file === 'local') return; + + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat && stat.isDirectory()) { + walk(filePath); + } else { + // 检查后缀 + const ext = path.extname(file).toLowerCase(); + if (validExtensions.includes(ext)) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + // 简单的行匹配 + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (results.length >= MAX_RESULTS) return; + if (line.includes(query)) { + // 转换为项目相对路径 (db://assets/...) + const relativePath = path.relative(assetsPath, filePath); + // 统一使用 forward slash + const dbPath = "db://assets/" + relativePath.split(path.sep).join('/'); + + results.push({ + filePath: dbPath, + line: index + 1, + content: line.trim() + }); + } + }); + } catch (e) { + // 读取文件出错,跳过 + } + } + } + }); + }; + + walk(assetsPath); + callback(null, results); + } catch (err) { + callback(`Find in file failed: ${err.message}`); + } + }, + + // 管理撤销/重做 + manageUndo(args, callback) { + const { action, description } = args; + + try { + switch (action) { + case "undo": + Editor.Ipc.sendToPanel("scene", "scene:undo"); + callback(null, "Undo command executed"); + break; + case "redo": + Editor.Ipc.sendToPanel("scene", "scene:redo"); + callback(null, "Redo command executed"); + break; + case "begin_group": + // scene:undo-record [id] + // 这里的 id 好像是可选的,或者用于区分不同的事务 + Editor.Ipc.sendToPanel("scene", "scene:undo-record", description || "MCP Action"); + callback(null, `Undo group started: ${description || "MCP Action"}`); + break; + case "end_group": + Editor.Ipc.sendToPanel("scene", "scene:undo-commit"); + callback(null, "Undo group committed"); + break; + case "cancel_group": + Editor.Ipc.sendToPanel("scene", "scene:undo-cancel"); + callback(null, "Undo group cancelled"); + break; + default: + callback(`Unknown undo action: ${action}`); + } + } catch (err) { + callback(`Undo operation failed: ${err.message}`); + } + }, }; diff --git a/panel/index.html b/panel/index.html index 23176a4..84a8e76 100644 --- a/panel/index.html +++ b/panel/index.html @@ -36,6 +36,10 @@
+
+ +
选择工具查看说明
+
@@ -90,4 +94,19 @@ #resultContent { flex: 1; } .button-group { display: flex; gap: 5px; padding: 5px 0; } label { font-size: 11px; color: #888; margin: 4px 0; } + .tool-description { + margin-bottom: 10px; + } + .description-box { + background: #222; + color: #ccc; + border: 1px solid #444; + padding: 8px; + font-size: 11px; + line-height: 1.4; + min-height: 60px; + max-height: 120px; + overflow-y: auto; + border-radius: 2px; + } \ No newline at end of file diff --git a/panel/index.js b/panel/index.js index 29ad429..9a1255f 100644 --- a/panel/index.js +++ b/panel/index.js @@ -2,151 +2,225 @@ const fs = require("fs"); Editor.Panel.extend({ - style: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), - template: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), + style: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), + template: fs.readFileSync(Editor.url("packages://mcp-bridge/panel/index.html"), "utf-8"), - messages: { - "mcp-bridge:on-log"(event, log) { this.renderLog(log); }, - "mcp-bridge:state-changed"(event, config) { this.updateUI(config.active); } - }, + messages: { + "mcp-bridge:on-log"(event, log) { + this.renderLog(log); + }, + "mcp-bridge:state-changed"(event, config) { + this.updateUI(config.active); + }, + }, - ready() { - const root = this.shadowRoot; - // 获取元素 - 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"), - panelMain: root.querySelector("#panelMain"), - panelTest: root.querySelector("#panelTest"), - toolName: root.querySelector("#toolName"), - toolParams: root.querySelector("#toolParams"), - 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") - }; + ready() { + const root = this.shadowRoot; + // 获取元素 + 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"), + panelMain: root.querySelector("#panelMain"), + panelTest: root.querySelector("#panelTest"), + 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)); + } + }); - // 2. 标签切换 - els.tabMain.addEventListener("confirm", () => { - els.tabMain.classList.add("active"); els.tabTest.classList.remove("active"); - els.panelMain.classList.add("active"); els.panelTest.classList.remove("active"); - }); - els.tabTest.addEventListener("confirm", () => { - els.tabTest.classList.add("active"); els.tabMain.classList.remove("active"); - els.panelTest.classList.add("active"); els.panelMain.classList.remove("active"); - this.fetchTools(els); - }); + // 2. 标签切换 + els.tabMain.addEventListener("confirm", () => { + els.tabMain.classList.add("active"); + els.tabTest.classList.remove("active"); + els.panelMain.classList.add("active"); + els.panelTest.classList.remove("active"); + }); + els.tabTest.addEventListener("confirm", () => { + els.tabTest.classList.add("active"); + els.tabMain.classList.remove("active"); + els.panelTest.classList.add("active"); + els.panelMain.classList.remove("active"); + this.fetchTools(els); + }); - // 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("#btnCopy").addEventListener("confirm", () => { - require("electron").clipboard.writeText(els.logView.innerText); - Editor.success("Logs Copied"); - }); - els.autoStart.addEventListener("change", (e) => { - Editor.Ipc.sendToMain("mcp-bridge:set-auto-start", e.target.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("#btnCopy").addEventListener("confirm", () => { + require("electron").clipboard.writeText(els.logView.innerText); + Editor.success("Logs Copied"); + }); + els.autoStart.addEventListener("change", (e) => { + Editor.Ipc.sendToMain("mcp-bridge:set-auto-start", e.target.value); + }); - // 4. 测试页功能 - els.listBtn.addEventListener("confirm", () => this.fetchTools(els)); - els.clearBtn.addEventListener("confirm", () => { els.result.value = ""; }); - els.testBtn.addEventListener("confirm", () => this.runTest(els)); + // 4. 测试页功能 + els.listBtn.addEventListener("confirm", () => this.fetchTools(els)); + els.clearBtn.addEventListener("confirm", () => { + els.result.value = ""; + }); + els.testBtn.addEventListener("confirm", () => this.runTest(els)); - // 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"; + }); + } + }, - fetchTools(els) { - const url = `http://localhost:${els.port.value}/list-tools`; - fetch(url).then(r => r.json()).then(data => { - els.toolsList.innerHTML = ""; - data.tools.forEach(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); - }; - els.toolsList.appendChild(item); - }); - els.result.value = `Loaded ${data.tools.length} tools.`; - }).catch(e => { els.result.value = "Error: " + e.message; }); - }, + fetchTools(els) { + const url = `http://localhost:${els.port.value}/list-tools`; + 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 = `Loaded ${data.tools.length} tools.`; + }) + .catch((e) => { + els.result.value = "Error: " + e.message; + }); + }, - runTest(els) { - const url = `http://localhost:${els.port.value}/call-tool`; - const body = { name: els.toolName.value, arguments: JSON.parse(els.toolParams.value || "{}") }; - els.result.value = "Testing..."; - 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 = "Error: " + e.message; }); - }, + showToolDescription(els, tool) { + if (!tool) { + els.toolDescription.textContent = "选择工具查看说明"; + return; + } + + 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); + } + } + + els.toolDescription.innerHTML = `${description}

${details.join('
')}`; + }, - getExample(name) { - const examples = { - "set_node_name": { "id": "UUID", "newName": "Hello" }, - "update_node_transform": { "id": "UUID", "x": 0, "y": 0, "color": "#FF0000" }, - "create_node": { "name": "Node", "type": "sprite", "parentId": "" }, - "open_scene": { "url": "db://assets/Scene.fire" } - }; - return examples[name] || {}; - }, + runTest(els) { + const url = `http://localhost:${els.port.value}/call-tool`; + const body = { name: els.toolName.value, arguments: JSON.parse(els.toolParams.value || "{}") }; + els.result.value = "Testing..."; + 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 = "Error: " + e.message; + }); + }, - 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; - }, + getExample(name) { + const examples = { + set_node_name: { id: "UUID", newName: "Hello" }, + update_node_transform: { id: "UUID", x: 0, y: 0, color: "#FF0000" }, + create_node: { name: "Node", type: "sprite", parentId: "" }, + open_scene: { url: "db://assets/Scene.fire" }, + manage_editor: { action: "get_selection" }, + find_gameobjects: { conditions: { name: "Node", 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: "// Test comment\n" }], + }, + read_console: { limit: 10, type: "log" }, + validate_script: { filePath: "db://assets/scripts/TestScript.ts" }, + }; + return examples[name] || {}; + }, - updateUI(active) { - const btn = this.shadowRoot.querySelector("#btnToggle"); - if (!btn) return; - btn.innerText = active ? "Stop" : "Start"; - btn.style.backgroundColor = active ? "#aa4444" : "#44aa44"; - } -}); \ No newline at end of file + 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; + }, + + updateUI(active) { + const btn = this.shadowRoot.querySelector("#btnToggle"); + if (!btn) return; + btn.innerText = active ? "Stop" : "Start"; + btn.style.backgroundColor = active ? "#aa4444" : "#44aa44"; + }, +}); diff --git a/scene-script.js b/scene-script.js index 28a0f01..f3d5d46 100644 --- a/scene-script.js +++ b/scene-script.js @@ -91,7 +91,7 @@ module.exports = { "create-node": function (event, args) { const { name, parentId, type } = args; const scene = cc.director.getScene(); - if (!scene || !cc.director.getRunningScene()) { + if (!scene) { if (event.reply) event.reply(new Error("Scene not ready or loading.")); return; } @@ -202,7 +202,16 @@ module.exports = { try { // 查找并移除组件 - const component = node.getComponentById(componentId); + 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"); @@ -222,20 +231,51 @@ module.exports = { // 获取组件属性 const properties = {}; for (const key in c) { - if (typeof c[key] !== "function" && - !key.startsWith("_") && - c[key] !== undefined) { + if (typeof c[key] !== "function" && !key.startsWith("_") && c[key] !== undefined) { try { - properties[key] = c[key]; + // Safe serialization check + const val = c[key]; + if (val === null || val === undefined) { + properties[key] = val; + continue; + } + + // Primitives are safe + if (typeof val !== 'object') { + properties[key] = val; + continue; + } + + // Special Cocos Types + if (val instanceof cc.ValueType) { + properties[key] = val.toString(); + } else if (val instanceof cc.Asset) { + properties[key] = `Asset(${val.name})`; + } else if (val instanceof cc.Node) { + properties[key] = `Node(${val.name})`; + } else if (val instanceof cc.Component) { + properties[key] = `Component(${val.name}<${val.__typename}>)`; + } else { + // Arrays and Plain Objects + // Attempt to strip to pure JSON data to avoid IPC errors with Native/Circular objects + try { + const jsonStr = JSON.stringify(val); + // Ensure we don't pass the original object reference + properties[key] = JSON.parse(jsonStr); + } catch (e) { + // If JSON fails (e.g. circular), format as string + properties[key] = `[Complex Object: ${val.constructor ? val.constructor.name : typeof val}]`; + } + } } catch (e) { - // 忽略无法序列化的属性 + properties[key] = "[Serialization Error]"; } } } return { - type: c.__typename, + type: cc.js.getClassName(c) || c.constructor.name || "Unknown", uuid: c.uuid, - properties: properties + properties: properties, }; }); if (event.reply) event.reply(null, components); @@ -255,9 +295,7 @@ module.exports = { // 遍历组件属性 for (const key in component) { - if (typeof component[key] !== "function" && - !key.startsWith("_") && - component[key] !== undefined) { + if (typeof component[key] !== "function" && !key.startsWith("_") && component[key] !== undefined) { try { properties[key] = component[key]; } catch (e) { @@ -273,7 +311,7 @@ module.exports = { const { prefabPath, parentId } = args; const scene = cc.director.getScene(); - if (!scene || !cc.director.getRunningScene()) { + if (!scene) { if (event.reply) event.reply(new Error("Scene not ready or loading.")); return; } @@ -314,4 +352,208 @@ module.exports = { } }); }, + + "find-gameobjects": function (event, args) { + const { conditions, recursive = true } = args; + const result = []; + const scene = cc.director.getScene(); + + function searchNode(node) { + // 跳过编辑器内部的私有节点 + if (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) { + result.push({ + uuid: node.uuid, + name: node.name, + active: node.active, + position: { x: node.x, y: node.y }, + scale: { x: node.scaleX, y: node.scaleY }, + size: { width: node.width, height: node.height }, + components: node._components.map((c) => c.__typename), + }); + } + + // 递归搜索子节点 + 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); + } + }, + + "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("Parent node not found")); + } + + } else if (action === "update") { + let node = cc.engine.getInstanceById(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, "VFX updated"); + } else { + if (event.reply) event.reply(new Error("Node not found")); + } + + } else if (action === "get_info") { + let node = cc.engine.getInstanceById(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("Node not found")); + } + } else { + if (event.reply) event.reply(new Error(`Unknown VFX action: ${action}`)); + } + }, }; diff --git a/test/run_tests.js b/test/run_tests.js new file mode 100644 index 0000000..900f415 --- /dev/null +++ b/test/run_tests.js @@ -0,0 +1,309 @@ +const http = require('http'); + +// 配置 +const CONFIG = { + host: '127.0.0.1', + port: 3456, + timeout: 5000 +}; + +// 控制台输出颜色 +const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + gray: "\x1b[90m" +}; + +function log(type, msg) { + const timestamp = new Date().toLocaleTimeString(); + switch (type) { + case 'info': console.log(`${colors.cyan}[INFO]${colors.reset} ${msg}`); break; + case 'success': console.log(`${colors.green}[PASS]${colors.reset} ${msg}`); break; + case 'error': console.log(`${colors.red}[FAIL]${colors.reset} ${msg}`); break; + case 'warn': console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`); break; + case 'group': console.log(`\n${colors.gray}=== ${msg} ===${colors.reset}`); break; + default: console.log(msg); + } +} + +// HTTP 辅助函数 +function request(method, path, data) { + return new Promise((resolve, reject) => { + const options = { + hostname: CONFIG.host, + port: CONFIG.port, + path: path, + method: method, + headers: { + 'Content-Type': 'application/json', + }, + timeout: CONFIG.timeout + }; + + const req = http.request(options, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsed = JSON.parse(body); + // MCP 返回 { content: [{ type: 'text', text: "..." }] } + resolve(parsed); + } catch (e) { + // 某些接口可能返回纯文本或非标准 JSON + resolve(body); + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${body}`)); + } + }); + }); + + req.on('error', (e) => reject(new Error(`连接失败: ${e.message}`))); + req.on('timeout', () => { + req.destroy(); + reject(new Error('请求超时')); + }); + + if (data) { + req.write(JSON.stringify(data)); + } + req.end(); + }); +} + +// MCP 工具调用封装 +async function callTool(name, args = {}) { + const payload = { + name: name, + arguments: args + }; + + try { + const response = await request('POST', '/call-tool', payload); + + // 解析复杂的 MCP 响应结构 + // 预期: { content: [ { type: 'text', text: "..." } ] } + if (response && response.content && Array.isArray(response.content)) { + const textContent = response.content.find(c => c.type === 'text'); + if (textContent) { + // 工具结果本身可能是 JSON 字符串,尝试解析它 + try { + return JSON.parse(textContent.text); + } catch { + return textContent.text; + } + } + } + return response; + } catch (e) { + throw new Error(`工具 [${name}] 调用失败: ${e.message}`); + } +} + +// 断言辅助函数 +function assert(condition, message) { + if (!condition) { + throw new Error(message || "断言失败"); + } +} + +// --- 测试套件 --- +const tests = { + async setup() { + log('group', '连接性检查'); + try { + const tools = await request('POST', '/list-tools'); + assert(tools && tools.tools && tools.tools.length > 0, "无法获取工具列表"); + log('success', `已连接到 MCP 服务器。发现 ${tools.tools.length} 个工具。`); + return true; + } catch (e) { + log('error', `无法连接服务器。插件是否正在运行? (${e.message})`); + return false; + } + }, + + async testNodeLifecycle() { + log('group', '节点生命周期测试'); + const nodeName = `TestNode_${Date.now()}`; + + try { + // 1. 创建节点 + log('info', `尝试创建节点: ${nodeName}`); + const newNodeId = await callTool('create_node', { name: nodeName, type: 'empty' }); + log('info', `create_node 响应: ${JSON.stringify(newNodeId)}`); + assert(typeof newNodeId === 'string' && newNodeId.length > 0, `create_node 没有返回 UUID。实际返回: ${JSON.stringify(newNodeId)}`); + log('success', `已创建节点: ${nodeName} (${newNodeId})`); + + // 2. 查找节点 + log('info', `尝试查找节点: ${nodeName}`); + const findResult = await callTool('find_gameobjects', { conditions: { name: nodeName } }); + log('info', `find_gameobjects 响应: ${JSON.stringify(findResult)}`); + assert(Array.isArray(findResult), `find_gameobjects 没有返回数组。实际返回: ${JSON.stringify(findResult)}`); + assert(findResult.length >= 1, "find_gameobjects 未能找到已创建的节点"); + + // 查找特定节点(防止重名,虽然这里名字包含时间戳) + const targetNode = findResult.find(n => n.name === nodeName); + assert(targetNode, "找到节点但名称不匹配?"); + assert(targetNode.uuid === newNodeId, `找到的节点 UUID 不匹配。预期 ${newNodeId}, 实际 ${targetNode.uuid}`); + log('success', `通过 find_gameobjects 找到节点: ${targetNode.name}`); + + // 3. 更新变换 (Transform) + log('info', `尝试更新变换信息`); + await callTool('update_node_transform', { id: newNodeId, x: 100, y: 200 }); + // 通过查找验证(因为查找会返回位置信息) + const updatedResult = await callTool('find_gameobjects', { conditions: { name: nodeName } }); + const updatedNode = updatedResult.find(n => n.uuid === newNodeId); + log('info', `变换更新验证: x=${updatedNode.position.x}, y=${updatedNode.position.y}`); + assert(updatedNode.position.x === 100 && updatedNode.position.y === 200, `节点位置更新失败。实际: (${updatedNode.position.x}, ${updatedNode.position.y})`); + log('success', `节点变换已更新至 (100, 200)`); + + return newNodeId; // 返回以供后续测试使用 + } catch (e) { + log('error', `节点生命周期测试失败: ${e.message}`); + throw e; + } + }, + + async testComponents(nodeId) { + log('group', '组件管理测试'); + + // 1. 添加组件 + // 使用 cc.Sprite 因为它最常用 + log('info', `向 ${nodeId} 添加组件 cc.Sprite`); + const addResult = await callTool('manage_components', { + nodeId: nodeId, + action: 'add', + componentType: 'cc.Sprite' + }); + log('success', `已添加 cc.Sprite 组件。响应: ${JSON.stringify(addResult)}`); + + // 2. 获取组件 + log('info', `列出 ${nodeId} 的组件`); + const components = await callTool('manage_components', { nodeId: nodeId, action: 'get' }); + log('info', `manage_components (get) 响应: ${JSON.stringify(components)}`); + + assert(Array.isArray(components), `无法获取组件列表。实际返回: ${JSON.stringify(components)}`); + // 宽松匹配:验证逻辑匹配(检查 type 或 properties.name 中是否包含 Sprite) + const spriteComp = components.find(c => (c.type && c.type.includes('Sprite')) || (c.properties && c.properties.name && c.properties.name.includes('Sprite'))); + assert(spriteComp, "节点上未找到 cc.Sprite 组件"); + log('success', `验证组件存在: ${spriteComp.uuid} (${spriteComp.type || 'Unknown'})`); + + // 3. 移除组件 + log('info', `移除组件 ${spriteComp.uuid}`); + const removeResult = await callTool('manage_components', { + nodeId: nodeId, + action: 'remove', + componentId: spriteComp.uuid + }); + log('info', `移除结果: ${JSON.stringify(removeResult)}`); + + // 等待引擎处理移除(异步过程) + await new Promise(r => setTimeout(r, 200)); + + // 验证移除 + const componentsAfter = await callTool('manage_components', { nodeId: nodeId, action: 'get' }); + log('info', `移除后的组件列表: ${JSON.stringify(componentsAfter)}`); + + assert(!componentsAfter.find(c => (c.type && c.type.includes('Sprite')) || (c.uuid === spriteComp.uuid)), "组件未被移除"); + log('success', `组件移除成功`); + }, + + async testEditorSelection(nodeId) { + log('group', '编辑器选中测试'); + + // 1. 设置选中 + await callTool('manage_editor', { + action: 'set_selection', + target: 'node', + properties: { nodes: [nodeId] } + }); + + // 2. 获取选中 + const selection = await callTool('manage_editor', { action: 'get_selection' }); + // 预期: { nodes: [...], assets: [...] } + assert(selection.nodes && selection.nodes.includes(nodeId), "选中状态更新失败"); + log('success', `编辑器选中状态已更新为节点 ${nodeId}`); + }, + + async testAssetManagement() { + log('group', '资源管理测试'); + const scriptPath = 'db://assets/temp_auto_test.js'; + + // 1. 创建脚本 + try { + await callTool('manage_script', { + action: 'create', + path: scriptPath, + content: 'cc.log("Test Script");' + }); + log('success', `已创建临时资源: ${scriptPath}`); + } catch (e) { + if (e.message.includes('exists')) { + log('warn', `资源已存在,正在尝试先删除...`); + await callTool('manage_asset', { action: 'delete', path: scriptPath }); + // 重试创建 + await callTool('manage_script', { action: 'create', path: scriptPath, content: 'cc.log("Test Script");' }); + } else { + throw e; + } + } + + // 2. 获取信息 + // 等待 AssetDB 刷新 (导入需要时间) + log('info', '等待 3 秒以进行资源导入...'); + await new Promise(r => setTimeout(r, 3000)); + + log('info', `获取资源信息: ${scriptPath}`); + const info = await callTool('manage_asset', { action: 'get_info', path: scriptPath }); + log('info', `资源信息: ${JSON.stringify(info)}`); + + assert(info && info.url === scriptPath, "无法获取资源信息"); + log('success', `已验证资源信息`); + + // 3. 删除资源 + await callTool('manage_asset', { action: 'delete', path: scriptPath }); + + // 验证删除 (get_info 应该失败或返回 null/报错,但我们检查工具响应) + try { + const infoDeleted = await callTool('manage_asset', { action: 'get_info', path: scriptPath }); + // 如果返回了信息且 exists 为 true,说明没删掉 + assert(!(infoDeleted && infoDeleted.exists), "资源本应被删除,但仍然存在"); + } catch (e) { + // 如果报错(如 Asset not found),则符合预期 + log('success', `已验证资源删除`); + } + } +}; + +async function run() { + console.log(`\n${colors.cyan}正在启动 MCP Bridge 自动化测试...${colors.reset}`); + console.log(`目标: http://${CONFIG.host}:${CONFIG.port}\n`); + + const isConnected = await tests.setup(); + if (!isConnected) process.exit(1); + + try { + const nodeId = await tests.testNodeLifecycle(); + + await tests.testComponents(nodeId); + + await tests.testEditorSelection(nodeId); + + await tests.testAssetManagement(); + + // 清理:我们在测试中已经尽可能清理了,但保留节点可能有助于观察结果 + // 这里只是打印完成消息 + + console.log(`\n${colors.green}所有测试已成功完成!${colors.reset}\n`); + } catch (e) { + console.error(`\n${colors.red}[FATAL ERROR]${colors.reset} 测试套件出错:`); + console.error(e); + process.exit(1); + } +} + +run(); diff --git a/test/test_find_file.js b/test/test_find_file.js new file mode 100644 index 0000000..7f4eeb8 --- /dev/null +++ b/test/test_find_file.js @@ -0,0 +1,107 @@ +const http = require('http'); + +const CONFIG = { + host: '127.0.0.1', + port: 3456, + timeout: 5000 +}; + +// HTTP Helper +function request(method, path, data) { + return new Promise((resolve, reject) => { + const options = { + hostname: CONFIG.host, + port: CONFIG.port, + path: path, + method: method, + headers: { 'Content-Type': 'application/json' }, + timeout: CONFIG.timeout + }; + + const req = http.request(options, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(body)); } catch (e) { resolve(body); } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${body}`)); + } + }); + }); + + req.on('error', (e) => reject(new Error(`Connection failed: ${e.message}`))); + req.end(data ? JSON.stringify(data) : undefined); + }); +} + +async function callTool(name, args = {}) { + const payload = { name: name, arguments: args }; + const response = await request('POST', '/call-tool', payload); + if (response && response.content && Array.isArray(response.content)) { + const textContent = response.content.find(c => c.type === 'text'); + if (textContent) { + try { return JSON.parse(textContent.text); } catch { return textContent.text; } + } + } + return response; +} + +async function run() { + console.log("Testing find_in_file..."); + + try { + // 1. Check tools + const tools = await request('POST', '/list-tools'); + const findTool = tools.tools.find(t => t.name === 'find_in_file'); + if (!findTool) { + console.error("FAILED: find_in_file tool not found in list."); + return; + } + console.log("PASS: find_in_file exists in tool list."); + + // 2. Create a temp file to search for + const tempFilePath = "db://assets/test_find_me.txt"; + const uniqueString = "UniqueStringToFind_" + Date.now(); + console.log(`Creating temp file with content "${uniqueString}"...`); + + await callTool('manage_asset', { + action: 'create', + path: tempFilePath, + content: `This is a test file.\nIt contains ${uniqueString} here.` + }); + + // Wait a bit for assetdb to refresh + await new Promise(r => setTimeout(r, 2000)); + + // 3. Call find_in_file + console.log(`Searching for "${uniqueString}"...`); + + const results = await callTool('find_in_file', { query: uniqueString }); + + if (!Array.isArray(results)) { + console.error("FAILED: Result is not an array:", results); + // Cleanup + await callTool('manage_asset', { action: 'delete', path: tempFilePath }); + return; + } + + console.log(`Found ${results.length} matches.`); + const match = results.find(r => r.content.includes(uniqueString)); + + if (match) { + console.log("PASS: Found match in created file."); + console.log("Match Details:", match); + } else { + console.error("FAILED: Did not find match. Results:", results); + } + + // 4. Cleanup + await callTool('manage_asset', { action: 'delete', path: tempFilePath }); + + } catch (e) { + console.error("Error:", e.message); + } +} + +run(); diff --git a/test/test_undo.js b/test/test_undo.js new file mode 100644 index 0000000..6418ed1 --- /dev/null +++ b/test/test_undo.js @@ -0,0 +1,146 @@ +const http = require('http'); + +const CONFIG = { + host: '127.0.0.1', + port: 3456, + timeout: 5000 +}; + +// HTTP Helper +function request(method, path, data) { + return new Promise((resolve, reject) => { + const options = { + hostname: CONFIG.host, + port: CONFIG.port, + path: path, + method: method, + headers: { 'Content-Type': 'application/json' }, + timeout: CONFIG.timeout + }; + + const req = http.request(options, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(body)); } catch (e) { resolve(body); } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${body}`)); + } + }); + }); + + req.on('error', (e) => reject(new Error(`Connection failed: ${e.message}`))); + req.end(data ? JSON.stringify(data) : undefined); + }); +} + +async function callTool(name, args = {}) { + const payload = { name: name, arguments: args }; + const response = await request('POST', '/call-tool', payload); + if (response && response.content && Array.isArray(response.content)) { + const textContent = response.content.find(c => c.type === 'text'); + if (textContent) { + try { return JSON.parse(textContent.text); } catch { return textContent.text; } + } + } + return response; +} + +// Helper to wait +const wait = (ms) => new Promise(r => setTimeout(r, ms)); + +async function run() { + console.log("Testing manage_undo..."); + + try { + // 1. Create a node + const nodeName = "UndoTestNode_" + Date.now(); + console.log(`Creating node: ${nodeName}`); + const nodeId = await callTool('create_node', { name: nodeName, type: 'empty' }); + + if (!nodeId || typeof nodeId !== 'string') { + console.error("FAILED: Could not create node.", nodeId); + return; + } + console.log(`Node created: ${nodeId}`); + + // Wait to ensure creation is fully processed + await wait(500); + + // 2. Modify node (Change Name) + console.log("Modifying node name (Action to undo)..."); + const newName = "RenamedNode_" + Date.now(); + await callTool('set_node_name', { id: nodeId, newName: newName }); + + await wait(2000); + + // Verify modification + let nodes = await callTool('find_gameobjects', { conditions: { name: newName } }); + let node = nodes.find(n => n.uuid === nodeId); + + if (!node) { + console.error(`FAILED: Node not found with new name ${newName}. Name update failed.`); + // Try to read console logs to see why + const logs = await callTool('read_console', { limit: 10, type: 'error' }); + console.log("Recent Error Logs:", JSON.stringify(logs, null, 2)); + return; + } + console.log(`Node renamed to ${node.name}.`); + + // 3. Perform UNDO + console.log("Executing UNDO..."); + await callTool('manage_undo', { action: 'undo' }); + + await wait(2000); + + // Verify UNDO + nodes = await callTool('find_gameobjects', { conditions: { name: nodeName } }); + // The original name was in variable nodeName + node = nodes.find(n => n.uuid === nodeId); + + if (node && node.name === nodeName) { + console.log(`PASS: Undo successful. Node name returned to ${nodeName}.`); + } else { + console.error(`FAILED: Undo failed? Node name is ${node ? node.name : 'Unknown'}`); + } + + // 4. Perform REDO + console.log("Executing REDO..."); + await callTool('manage_undo', { action: 'redo' }); + + await wait(2000); + + // Verify REDO + nodes = await callTool('find_gameobjects', { conditions: { name: newName } }); + node = nodes.find(n => n.uuid === nodeId); + + if (node && node.name === newName) { + console.log("PASS: Redo successful. Node name returned to " + newName + "."); + } else { + console.error(`FAILED: Redo failed? Node name is ${node ? node.name : 'Unknown'}`); + } + + // Cleanup + // await callTool('manage_undo', { action: 'begin_group', description: 'Delete Node' }); // Optional + // Node deletion tool... wait, we don't have delete_node tool exposed yet? + // Ah, 'scene:delete-nodes' is internal. + // We can use 'batch_execute' if we had a delete tool. + // Checking available tools... we assume we can manually delete or leave it. + // Actually, let's construct a delete call if possible via existing tools? + // create_node, manage_components... + // Wait, DEVELOPMENT_PLAN says 'batch_execute' exists. + // But we don't have a direct 'delete_node' in getToolsList(). + // Oh, we missed implementing 'delete_node' in the previous phases? + // Let's check main.js getToolsList again. + // ... It has 'create_node', 'manage_components', ... 'scene_management'... + // 'scene_management' has 'delete'? -> "场景管理" -> create, delete (scene file), duplicate. + // It seems we lack `delete_node`. + // Nevermind, letting the test node stay is fine for observation, or user can delete manually. + + } catch (e) { + console.error("Error:", e.message); + } +} + +run(); diff --git a/test/test_vfx.js b/test/test_vfx.js new file mode 100644 index 0000000..5aa0672 --- /dev/null +++ b/test/test_vfx.js @@ -0,0 +1,175 @@ +const http = require('http'); + +const CONFIG = { + host: '127.0.0.1', + port: 3456, // Ideally read from profile or keep dynamic, but fixed for test + timeout: 5000 +}; + +// HTTP Helper +function request(method, path, data) { + return new Promise((resolve, reject) => { + const options = { + hostname: CONFIG.host, + port: CONFIG.port, + path: path, + method: method, + headers: { 'Content-Type': 'application/json' }, + timeout: CONFIG.timeout + }; + + const req = http.request(options, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(body)); } catch (e) { resolve(body); } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${body}`)); + } + }); + }); + + req.on('error', (e) => reject(new Error(`Connection failed: ${e.message}`))); + req.end(data ? JSON.stringify(data) : undefined); + }); +} + +async function callTool(name, args = {}) { + const payload = { name: name, arguments: args }; + const response = await request('POST', '/call-tool', payload); + if (response && response.content && Array.isArray(response.content)) { + const textContent = response.content.find(c => c.type === 'text'); + if (textContent) { + try { return JSON.parse(textContent.text); } catch { return textContent.text; } + } + } + return response; +} + +// Helper to wait +const wait = (ms) => new Promise(r => setTimeout(r, ms)); + +async function run() { + console.log("Testing manage_vfx..."); + + try { + // 1. Create a Particle Node + const nodeName = "VFX_Test_" + Date.now(); + console.log(`Creating particle node: ${nodeName}`); + + const createResult = await callTool('manage_vfx', { + action: 'create', + name: nodeName, + properties: { + duration: 5, + emissionRate: 50, + startColor: "#FF0000", + endColor: "#0000FF" + } + }); + + let nodeId = createResult; + // Check if result is UUID string or object + if (typeof createResult === 'object') { + // Sometimes mcp-bridge returns object? No, scene-script returns uuid or error. + // But checking just in case + nodeId = createResult.uuid || createResult; + } + + if (!nodeId || typeof nodeId !== 'string') { + console.error("FAILED: Could not create VFX node.", createResult); + return; + } + console.log(`VFX Node created: ${nodeId}`); + + await wait(1000); + + // 2. Perform Undo (Verify creation undo) + // ... Optional, let's focus on Update first. + + // 3. Update Particle Properties + console.log("Updating particle properties..."); + const updateResult = await callTool('manage_vfx', { + action: 'update', + nodeId: nodeId, + properties: { + emissionRate: 100, + startSize: 50, + speed: 200 + } + }); + console.log("Update result:", updateResult); + + await wait(1000); + + // 4. Get Info to Verify + console.log("Verifying properties..."); + const info = await callTool('manage_vfx', { action: 'get_info', nodeId: nodeId }); + + if (!info) { + console.error("FAILED: Could not get info."); + return; + } + + console.log("Particle Info:", JSON.stringify(info, null, 2)); + + if (info.emissionRate === 100 && info.speed === 200) { + console.log("PASS: Properties updated and verified."); + } else { + console.error("FAILED: Properties mismatch."); + } + + // 5. Verify 'custom' property using manage_components + // We need to ensure custom is true for properties to take effect visually + console.log("Verifying 'custom' property..."); + const components = await callTool('manage_components', { + nodeId: nodeId, + action: 'get' + }); + + let particleComp = null; + if (components && Array.isArray(components)) { + particleComp = components.find(c => c.type === 'cc.ParticleSystem' || c.type === 'ParticleSystem'); + } + + if (particleComp && particleComp.properties) { + if (particleComp.properties.custom === true) { + console.log("PASS: ParticleSystem.custom is TRUE."); + } else { + console.error("FAILED: ParticleSystem.custom is FALSE or Undefined.", particleComp.properties.custom); + } + + // Check texture/file if possible + if (particleComp.properties.file || particleComp.properties.texture) { + console.log("PASS: ParticleSystem has file/texture."); + } else { + console.warn("WARNING: ParticleSystem might not have a texture/file set."); + } + } else { + console.error("FAILED: Could not retrieve component details."); + } + + await wait(1000); + + // 6. Fetch Logs to debug texture loading + console.log("Fetching recent Editor Logs..."); + const logs = await callTool('read_console', { limit: 20 }); + if (logs && Array.isArray(logs)) { + logs.forEach(log => { + const msg = log.message || JSON.stringify(log); + const type = log.type || 'info'; + + // Filter for our debug logs or errors + if (typeof msg === 'string' && (msg.includes("[mcp-bridge]") || type === 'error' || type === 'warn')) { + console.log(`[Editor Log] [${type}] ${msg}`); + } + }); + } + + } catch (e) { + console.error("Error:", e.message); + } +} + +run();