修复(manage_animation): 修复当动画组件没有剪辑时的崩溃问题;文档: 在 README 中添加 get_sha 和 manage_animation 说明

This commit is contained in:
火焰库拉
2026-02-04 01:57:12 +08:00
parent 62cddf3fa2
commit 532cd08f9b
4 changed files with 425 additions and 12 deletions

View File

@@ -0,0 +1,266 @@
---
description:
alwaysApply: true
enabled: true
updatedAt: 2026-02-03T13:55:50.734Z
provider:
---
# MCP Bridge Development Rules
## 1. 语言规范 (Language)
* **强制中文**: 所有的对话回复、代码注释、以及生成的文档都必须使用**中文**。
* **日志消息**: `addLog()` 的消息内容可以使用中英文混合,确保关键术语清晰。
---
## 2. 关键工作流程 (Critical Workflow)
### 2.1 插件重载
- **必须重载**: 修改 `main.js`, `package.json`, `scene-script.js`, 或 `panel/` 后,**必须**在编辑器中执行「扩展 → 刷新」或重启编辑器。
- **热更新不适用**: Cocos Creator 2.x 的插件主进程脚本不支持热更新。
### 2.2 测试驱动
- **测试脚本**: 每个新功能必须在 `test/` 目录下创建独立测试脚本 (如 `test/test_feature.js`)。
- **HTTP 验证**: 测试脚本通过 HTTP 请求直接调用插件 API验证功能正确性。
- **运行前提**: 确保 Cocos Creator 编辑器已打开且 MCP Bridge 服务已启动。
---
## 3. 架构与进程隔离 (Architecture & IPC)
### 3.1 进程职责划分
| 文件 | 进程 | 可访问 | 不可访问 |
|------|------|--------|----------|
| `main.js` | 主进程 (Main) | `Editor.assetdb`, `Editor.Ipc`, `require()` | `cc.*` (Cocos 引擎) |
| `scene-script.js` | 渲染进程 (Renderer) | `cc.*`, `cc.engine`, `cc.director` | `Editor.assetdb`, `Editor.FileSystem` |
### 3.2 跨进程通信规则
```
主进程 (main.js) 渲染进程 (scene-script.js)
│ │
├─ 1. 接收 HTTP 请求 │
├─ 2. 解析 db:// 路径为 UUID │
│ Editor.assetdb.urlToUuid() │
├─ 3. 调用场景脚本 ──────────────────────┤
│ Editor.Scene.callSceneScript() │
│ ├─ 4. 操作节点/组件
│ │ cc.engine.getInstanceById()
│ ├─ 5. 通知场景变脏
│ │ Editor.Ipc.sendToMain("scene:dirty")
└─ 6. 返回结果 ◀────────────────────────┘
```
**核心规则**: 永远在 `main.js` 中将 `db://` 路径转换为 UUID再传递给 `scene-script.js`。
---
## 4. 编码规范 (Coding Standards)
### 4.1 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 函数名 | camelCase | `handleMcpCall`, `manageScript` |
| 常量 | SCREAMING_SNAKE_CASE | `MAX_RESULTS`, `DEFAULT_PORT` |
| 私有变量 | _camelCase | `_isMoving`, `_timer` |
| 布尔变量 | is/has/can 前缀 | `isSceneBusy`, `hasComponent` |
| MCP 工具名 | snake_case | `get_selected_node`, `manage_vfx` |
| IPC 消息名 | kebab-case | `get-hierarchy`, `create-node` |
### 4.2 函数组织顺序
在 `module.exports` 中按以下顺序组织函数:
```javascript
module.exports = {
// 1. 配置属性
"scene-script": "scene-script.js",
// 2. 生命周期函数
load() { },
unload() { },
// 3. 服务器管理
startServer(port) { },
stopServer() { },
// 4. 核心处理逻辑
handleMcpCall(name, args, callback) { },
// 5. 工具函数 (按字母顺序)
applyTextEdits(args, callback) { },
batchExecute(args, callback) { },
// ...
// 6. IPC 消息处理
messages: {
"open-test-panel"() { },
// ...
}
};
```
### 4.3 避免重复定义
> ⚠️ **重要**: `main.js` 已存在重复函数问题,编辑前务必使用 `view_file` 确认上下文,避免创建重复定义。
**检查清单**:
- [ ] 新增函数前,搜索是否已存在同名函数
- [ ] 修改函数时,确认只有一个定义
- [ ] `messages` 对象中避免重复的消息处理器
### 4.4 日志规范
使用 `addLog(type, message)` 替代 `console.log()`
```javascript
// ✅ 正确
addLog("info", "服务启动成功");
addLog("error", `操作失败: ${err.message}`);
addLog("mcp", `REQ -> [${toolName}]`);
addLog("success", `RES <- [${toolName}] 成功`);
// ❌ 错误
console.log("服务启动成功"); // 不会被 read_console 捕获
```
| type | 用途 | 颜色 |
|------|------|------|
| `info` | 一般信息 | 蓝色 |
| `success` | 操作成功 | 绿色 |
| `warn` | 警告信息 | 黄色 |
| `error` | 错误信息 | 红色 |
| `mcp` | MCP 请求/响应 | 紫色 |
---
## 5. 撤销/重做支持 (Undo/Redo)
### 5.1 使用 scene:set-property
对于节点属性修改,优先使用 `scene:set-property` 以获得原生 Undo 支持:
```javascript
// ✅ 支持 Undo
Editor.Ipc.sendToPanel("scene", "scene:set-property", {
id: nodeId,
path: "x",
type: "Float",
value: 100,
isSubProp: false
});
// ❌ 不支持 Undo (直接修改)
node.x = 100;
```
### 5.2 使用 Undo 组
对于复合操作,使用 Undo 组包装:
```javascript
Editor.Ipc.sendToPanel("scene", "scene:undo-record", "Transform Update");
try {
// 执行多个属性修改
Editor.Ipc.sendToPanel("scene", "scene:undo-commit");
} catch (e) {
Editor.Ipc.sendToPanel("scene", "scene:undo-cancel");
}
```
---
## 6. 功能特定规则 (Feature Specifics)
### 6.1 粒子系统 (VFX)
```javascript
// 必须设置 custom = true否则属性修改可能不生效
particleSystem.custom = true;
// 确保纹理有效,否则粒子不可见
if (!particleSystem.texture && !particleSystem.file) {
// 加载默认纹理
}
```
### 6.2 资源路径解析
```javascript
// 内置资源可能需要多个路径尝试
const defaultPaths = [
"db://internal/image/default_sprite_splash",
"db://internal/image/default_sprite_splash.png",
"db://internal/image/default_particle",
"db://internal/image/default_particle.png"
];
for (const path of defaultPaths) {
const uuid = Editor.assetdb.urlToUuid(path);
if (uuid) break;
}
```
### 6.3 场景操作时序
```javascript
// 场景操作后需要延迟通知 UI 刷新
newNode.parent = parent;
Editor.Ipc.sendToMain("scene:dirty");
// 使用 setTimeout 让出主循环
setTimeout(() => {
Editor.Ipc.sendToAll("scene:node-created", {
uuid: newNode.uuid,
parentUuid: parent.uuid,
});
}, 10);
```
---
## 7. 错误处理规范 (Error Handling)
### 7.1 回调风格统一
```javascript
// ✅ 标准风格
callback(null, result); // 成功
callback("Error message"); // 失败 (字符串)
callback(new Error("message")); // 失败 (Error 对象)
// 避免混用
callback(err, null); // 不推荐,保持一致性
```
### 7.2 异步操作错误处理
```javascript
Editor.assetdb.queryInfoByUrl(path, (err, info) => {
if (err) {
addLog("error", `查询资源失败: ${err.message}`);
return callback(`Failed to get info: ${err.message}`);
}
// 继续处理...
});
```
---
## 8. 提交规范 (Git Commit)
使用 [Conventional Commits](https://conventionalcommits.org/) 格式:
| 类型 | 用途 | 示例 |
|------|------|------|
| `feat` | 新功能 | `feat: add manage_vfx tool` |
| `fix` | 修复 bug | `fix: resolve duplicate function in main.js` |
| `docs` | 文档更新 | `docs: add code review report` |
| `refactor` | 重构 | `refactor: split main.js into modules` |
| `test` | 测试 | `test: add material management tests` |
| `chore` | 杂项 | `chore: update dependencies` |

View File

@@ -39,7 +39,7 @@
### 启动
1. 打开 Cocos Creator 编辑器
2. 在菜单栏选择 `Packages/MCP Bridge/Open Test Panel` 打开测试面板
2. 在菜单栏选择 `MCP Bridge/Open Panel` 打开测试面板
3. 在面板中点击 "Start" 按钮启动服务
4. 服务默认运行在端口 3456 上
@@ -284,6 +284,21 @@ Args: [你的项目所在盘符]:/[项目路径]/packages/mcp-bridge/mcp-proxy.j
- `duration`, `emissionRate`, `life`, `lifeVar`, `startColor`, `endColor`
- `startSize`, `endSize`, `speed`, `angle`, `gravity`, `file` (plist/texture)
### 25. manage_animation
- **描述**: 管理节点的动画组件
- **参数**:
- `action`: 操作类型 (`get_list`, `get_info`, `play`, `stop`, `pause`, `resume`)
- `nodeId`: 节点 UUID
- `clipName`: 动画剪辑名称 (用于 `play` 操作,可选,默认播放 defaultClip)
### 26. get_sha
- **描述**: 获取指定文件的 SHA-256 哈希值
- **参数**:
- `path`: 文件路径,如 `db://assets/scripts/Test.ts`
## 技术实现
### 架构设计

76
main.js
View File

@@ -4,6 +4,7 @@ const { IpcManager } = require("./dist/IpcManager");
const http = require("http");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
let logBuffer = []; // 存储所有日志
let mcpServer = null;
@@ -449,6 +450,34 @@ const getToolsList = () => {
},
required: ["action"]
}
},
{
name: "get_sha",
description: "获取指定文件的 SHA-256 哈希值",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "文件路径,如 db://assets/scripts/Test.ts" }
},
required: ["path"]
}
},
{
name: "manage_animation",
description: "管理节点的动画组件",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["get_list", "get_info", "play", "stop", "pause", "resume"],
description: "操作类型"
},
nodeId: { type: "string", description: "节点 UUID" },
clipName: { type: "string", description: "动画剪辑名称 (用于 play)" }
},
required: ["action", "nodeId"]
}
}
];
};
@@ -707,17 +736,10 @@ module.exports = {
case "save_scene":
isSceneBusy = true;
addLog("info", "Preparing to save scene... Waiting for UI sync.");
// 强制延迟保存,防止死锁
setTimeout(() => {
// 使用 stash-and-save 替代 save-scene这更接近 Ctrl+S 的行为
Editor.Ipc.sendToMain("scene:stash-and-save");
addLog("info", "Executing Safe Save (Stash)...");
setTimeout(() => {
isSceneBusy = false;
addLog("info", "Safe Save completed.");
callback(null, "Scene saved successfully.");
}, 1000);
}, 500);
Editor.Ipc.sendToPanel("scene", "scene:stash-and-save");
isSceneBusy = false;
addLog("info", "Safe Save completed.");
callback(null, "Scene saved successfully.");
break;
case "get_scene_hierarchy":
@@ -798,6 +820,12 @@ module.exports = {
case "manage_editor":
this.manageEditor(args, callback);
break;
case "get_sha":
this.getSha(args, callback);
break;
case "manage_animation":
this.manageAnimation(args, callback);
break;
case "find_gameobjects":
Editor.Scene.callSceneScript("mcp-bridge", "find-gameobjects", args, callback);
@@ -1865,4 +1893,30 @@ export default class NewScript extends cc.Component {
callback(`Undo operation failed: ${err.message}`);
}
},
// 获取文件 SHA-256
getSha(args, callback) {
const { path: url } = args;
const fspath = Editor.assetdb.urlToFspath(url);
if (!fspath || !fs.existsSync(fspath)) {
return callback(`File not found: ${url}`);
}
try {
const fileBuffer = fs.readFileSync(fspath);
const hashSum = crypto.createHash('sha256');
hashSum.update(fileBuffer);
const sha = hashSum.digest('hex');
callback(null, { path: url, sha: sha });
} catch (err) {
callback(`Failed to calculate SHA: ${err.message}`);
}
},
// 管理动画
manageAnimation(args, callback) {
// 转发给场景脚本处理
Editor.Scene.callSceneScript("mcp-bridge", "manage-animation", args, callback);
},
};

View File

@@ -750,4 +750,82 @@ module.exports = {
if (event.reply) event.reply(new Error(`Unknown VFX action: ${action}`));
}
},
"manage-animation": function (event, args) {
const { action, nodeId, clipName } = args;
const node = cc.engine.getInstanceById(nodeId);
if (!node) {
if (event.reply) event.reply(new Error(`Node not found: ${nodeId}`));
return;
}
const anim = node.getComponent(cc.Animation);
if (!anim) {
if (event.reply) event.reply(new Error(`Animation component not found on node: ${nodeId}`));
return;
}
switch (action) {
case "get_list":
const clips = anim.getClips();
const clipList = clips.map(c => ({
name: c.name,
duration: c.duration,
sample: c.sample,
speed: c.speed,
wrapMode: c.wrapMode
}));
if (event.reply) event.reply(null, clipList);
break;
case "get_info":
const currentClip = anim.currentClip;
let isPlaying = false;
// [安全修复] 只有在有当前 Clip 时才获取状态,避免 Animation 组件无 Clip 时的崩溃
if (currentClip) {
const state = anim.getAnimationState(currentClip.name);
if (state) {
isPlaying = state.isPlaying;
}
}
const info = {
currentClip: currentClip ? currentClip.name : null,
clips: anim.getClips().map(c => c.name),
playOnLoad: anim.playOnLoad,
isPlaying: isPlaying
};
if (event.reply) event.reply(null, info);
break;
case "play":
if (!clipName) {
anim.play();
if (event.reply) event.reply(null, "Playing default clip");
} else {
anim.play(clipName);
if (event.reply) event.reply(null, `Playing clip: ${clipName}`);
}
break;
case "stop":
anim.stop();
if (event.reply) event.reply(null, "Animation stopped");
break;
case "pause":
anim.pause();
if (event.reply) event.reply(null, "Animation paused");
break;
case "resume":
anim.resume();
if (event.reply) event.reply(null, "Animation resumed");
break;
default:
if (event.reply) event.reply(new Error(`Unknown animation action: ${action}`));
break;
}
},
};