diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8f94ad..ba4d55e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,35 @@ When adding a new tool: 6. Add clear input schema descriptions so AI clients can choose the tool correctly. 7. Test the tool with `tools/list` and `tools/call`. +### Asset Creation via Template Files + +When a tool creates a new Cocos asset with a complex serialized format +(scene, prefab, animation clip, material, etc.), **store a template file in +`resources/` and pass its content to `asset-db: create-asset`** rather than +hard-coding JSON strings in source. + +Why: + +- Cocos serialized formats are version-specific and contain many required + sub-objects (e.g. a scene needs `cc.SceneGlobals` → `cc.AmbientInfo`, + `cc.ShadowsInfo`, `cc.SkyboxInfo`, `cc.FogInfo`, `cc.OctreeInfo`, + `cc.SkinInfo`, `cc.LightProbeInfo`, `cc.PostSettingsInfo`). Missing or + misnamed classes cause silent import failures or runtime errors. +- A real template file can be created/verified in the Cocos editor and + updated by simply replacing the file. +- `asset-db: create-asset(url, content)` handles `.meta` generation and + import in one step — no need for `fs.writeFileSync` + `refreshAssets`. + +Pattern: + +1. Create the template file at `resources/template.` (e.g. + `resources/template.scene`). +2. Add `"resources/"` to the `files` array in `package.json` so it ships + in the npm package. +3. In the tool handler, read the template with `fs.readFileSync`, optionally + replace `_name` fields, then call `create-asset` with the content string. +4. Export a pure helper (e.g. `buildSceneContent`) for unit testing. + ## Documentation The documentation is bilingual: diff --git a/docs/FEATURE_ABSORPTION.md b/docs/FEATURE_ABSORPTION.md new file mode 100644 index 0000000..e126b5b --- /dev/null +++ b/docs/FEATURE_ABSORPTION.md @@ -0,0 +1,338 @@ +# Feature Absorption Plan: From cocos-mcp-server to funplay-cocos-mcp + +This document identifies features in `cocos-mcp-server` that are missing from `funplay-cocos-mcp`, evaluates which ones are worth porting as dedicated tools, and provides concrete implementation guidance for each. + +## Design Principles + +Follow the existing funplay tool-design philosophy (`CONTRIBUTING.md`): + +- **`execute_javascript` can already handle it → skip.** No dedicated tool needed. +- **High-frequency, complex parameters, or benefits from structured schema → dedicated tool.** +- **Multiple narrow tools → consolidate into 1-2 tools with an `action` parameter.** +- New tools default to `full` profile; only high-signal tools enter `core`. + +## Priority Tiers + +| Tier | Rationale | Tool count | +|---|---|---:| +| P1 — Fill real gaps | Capabilities funplay completely lacks and cannot trivially do via `execute_javascript` | 5 new tools | +| P2 — Enhance existing | Strengthen tools funplay already has, or add consolidated versions of cocos-mcp-server's fragmented tools | 3 enhancements | +| P3 — Optional | Useful but lower frequency; implement on demand | 6 items | + +--- + +## P1 — Fill Real Gaps + +### 1. `create_scene` — Scene creation + +**Gap**: funplay has `open_scene` and `list_scenes` but cannot create a new scene. + +**cocos-mcp-server source**: `source/tools/scene-tools.ts:205` — constructs a full `cc.Scene` JSON template (~400 lines) and writes it to disk, then imports via asset-db. + +**Implementation**: +- **File**: `lib/tools/scene-management.js` (new) +- **Dependencies**: `sceneBridge` (add `createScene` method to `scene.js`) + `Editor.Message.request('asset-db', 'create-asset', ...)` +- **Schema**: + ```json + { + "sceneName": { "type": "string", "description": "Name of the new scene" }, + "savePath": { "type": "string", "description": "Asset path, e.g. db://assets/scenes/NewScene.scene" } + } + ``` +- **Required**: `["sceneName", "savePath"]` +- **Profile**: `full` +- **Notes**: Do NOT port the 400-line JSON template. Instead, use `Editor.Message.request('scene', 'create-scene', ...)` if available in the target Cocos version, or call `director.runScene(new Scene())` in the scene script then `Editor.Message.request('scene', 'save-scene')`. The hand-written JSON approach in cocos-mcp-server is fragile across Cocos versions. + +### 2. `copy_paste_node` — Node clipboard + +**Gap**: funplay has `create_node`, `delete_node`, `set_node_transform` but no copy/paste/cut. + +**cocos-mcp-server source**: `source/tools/scene-advanced-tools.ts:463` — `Editor.Message.request('scene', 'copy-node', uuids)`, `paste-node`, `cut-node`. + +**Implementation**: +- **File**: `lib/tools/scene-management.js` (same file as above) +- **Dependencies**: `Editor.Message.request('scene', ...)` — these are editor-side IPC, no scene script needed. +- **Schema** (consolidated with `action`): + ```json + { + "action": { "type": "string", "enum": ["copy", "paste", "cut"] }, + "uuids": { "type": "array", "items": { "type": "string" }, "description": "Node UUIDs to copy or cut" }, + "target": { "type": "string", "description": "Target parent node UUID (paste only)" }, + "keepWorldTransform": { "type": "boolean", "default": false } + } + ``` +- **Required**: `["action"]` +- **Profile**: `full` +- **IPC mapping**: + - `copy` → `Editor.Message.request('scene', 'copy-node', uuids)` + - `paste` → `Editor.Message.request('scene', 'paste-node', { target, uuids, keepWorldTransform })` + - `cut` → `Editor.Message.request('scene', 'cut-node', uuids)` + +### 3. `batch_asset_ops` — Batch asset import/delete + +**Gap**: funplay has `delete_asset` (single) but no batch import or batch delete. + +**cocos-mcp-server source**: `source/tools/asset-advanced-tools.ts:61` — `batch_import_assets` (source dir → target dir + filter + recursive + overwrite), `batch_delete_assets` (URL array). + +**Implementation**: +- **File**: `lib/tools/assets-advanced.js` (existing, append) +- **Dependencies**: `Editor.Message.request('asset-db', ...)` +- **Schema** (consolidated with `action`): + ```json + { + "action": { "type": "string", "enum": ["import", "delete"] }, + "sourceDirectory": { "type": "string", "description": "Local filesystem source (import only)" }, + "targetDirectory": { "type": "string", "description": "Asset-db target URL, e.g. db://assets/textures (import only)" }, + "urls": { "type": "array", "items": { "type": "string" }, "description": "Asset URLs to delete (delete only)" }, + "fileFilter": { "type": "array", "items": { "type": "string" }, "description": "File extensions, e.g. [\".png\", \".jpg\"]" }, + "recursive": { "type": "boolean", "default": false }, + "overwrite": { "type": "boolean", "default": false } + } + ``` +- **Required**: `["action"]` +- **Profile**: `full` +- **IPC mapping**: + - `import` → `Editor.Message.request('asset-db', 'import', targetDirectory, sourceDirectory, { ... })` + - `delete` → `Editor.Message.request('asset-db', 'delete-assets', urls)` + +### 4. `query_scene_state` — Scene state queries + +**Gap**: funplay cannot check if the scene has unsaved changes, check scene readiness, or soft-reload. + +**cocos-mcp-server source**: `source/tools/scene-advanced-tools.ts:283-305` — `query_scene_dirty`, `soft_reload_scene`, `query_scene_ready`. + +**Implementation**: +- **File**: `lib/tools/scene-management.js` +- **Dependencies**: `Editor.Message.request('scene', ...)` +- **Schema** (consolidated with `action`): + ```json + { + "action": { "type": "string", "enum": ["is_dirty", "is_ready", "soft_reload"] } + } + ``` +- **Required**: `["action"]` +- **Profile**: `core` — `is_dirty` is a critical signal for AI to decide whether to save before making changes. +- **IPC mapping**: + - `is_dirty` → `Editor.Message.request('scene', 'query-is-dirty')` + - `is_ready` → `Editor.Message.request('scene', 'query-ready')` + - `soft_reload` → `Editor.Message.request('scene', 'soft-reload')` + +### 5. `find_unused_assets` — Find unused assets + +**Gap**: funplay has `inspect_asset_dependencies` and `validate_asset_dependencies` for individual assets, but no project-wide unused-asset scan. + +**cocos-mcp-server source**: `source/tools/asset-advanced-tools.ts:144` — scans a directory, cross-references the dependency graph. + +**Implementation**: +- **File**: `lib/tools/assets-advanced.js` (append) +- **Dependencies**: `Editor.Message.request('asset-db', 'query-assets', ...)` + dependency analysis +- **Schema**: + ```json + { + "directory": { "type": "string", "default": "db://assets" }, + "excludeDirectories": { "type": "array", "items": { "type": "string" }, "default": [] } + } + ``` +- **Required**: `[]` +- **Profile**: `full` +- **Notes**: Implementation requires querying all assets, building a reverse dependency map, then filtering. Medium complexity. + +--- + +## P2 — Enhance Existing Capabilities + +### 6. Enhance `inspect_asset_dependencies` — Add reverse direction + +**Gap**: funplay only supports forward dependencies. cocos-mcp-server supports `dependents` (reverse) and `both`. + +**cocos-mcp-server source**: `source/tools/asset-advanced-tools.ts:124` — `direction` parameter with enum `["dependents", "dependencies", "both"]`. + +**Implementation**: +- **File**: `lib/tools/assets-advanced.js` (modify existing tool) +- **Change**: Add `direction` parameter to the existing `inspect_asset_dependencies` tool schema. +- **IPC**: `Editor.Message.request('asset-db', 'query-dependency', ...)` + reverse traversal for `dependents`. + +### 7. `manage_scene_view` — Scene view / Gizmo control (consolidate 20 → 3) + +**Gap**: funplay has zero scene-view control tools. cocos-mcp-server has 20 separate tools. + +**cocos-mcp-server source**: `source/tools/scene-view-tools.ts` — gizmo tool/pivot/coordinate, 2D/3D mode, grid, icon gizmo, focus camera, align camera/view, get/reset status. + +**Implementation**: +- **File**: `lib/tools/scene-view.js` (new) +- **Dependencies**: `Editor.Message.request('scene', ...)` +- **Consolidate into 3 tools**: + + **`set_scene_view`**: + ```json + { + "action": { "type": "string", "enum": ["gizmo_tool", "gizmo_pivot", "gizmo_coordinate", "view_mode", "grid", "icon_gizmo_3d", "icon_gizmo_size", "reset"] }, + "value": { "description": "Value depends on action: gizmo_tool→\"position\"|\"rotation\"|\"scale\"|\"rect\", gizmo_pivot→\"pivot\"|\"center\", gizmo_coordinate→\"local\"|\"global\", view_mode→boolean(is2D), grid→boolean, icon_gizmo_3d→boolean, icon_gizmo_size→number" } + } + ``` + + **`get_scene_view`**: + ```json + { + "action": { "type": "string", "enum": ["gizmo_tool", "gizmo_pivot", "gizmo_coordinate", "view_mode", "grid", "icon_gizmo_3d", "icon_gizmo_size", "full_status"] } + } + ``` + + **`focus_on_nodes`**: + ```json + { + "uuids": { "type": "array", "items": { "type": "string" }, "description": "Node UUIDs to focus on (null/empty for all)" } + } + ``` + +- **Profile**: `full` +- **IPC mapping** (all `Editor.Message.request('scene', ...)`): + - `change-gizmo-tool`, `change-gizmo-pivot`, `change-gizmo-coordinate` + - `change-view-mode`, `set-grid-visible`, `set-icon-gizmo-3d`, `set-icon-gizmo-size` + - `focus-camera-on-nodes`, `align-camera-with-view`, `align-view-with-node` + - `query-gizmo-tool-name`, `query-gizmo-pivot`, `query-gizmo-coordinate`, etc. + +### 8. `manage_reference_image` — Reference images (consolidate 12 → 2) + +**Gap**: funplay has zero reference-image tools. cocos-mcp-server has 12. + +**cocos-mcp-server source**: `source/tools/reference-image-tools.ts` — add/remove/switch/clear/refresh, set position/scale/opacity/data, query config/current, list. + +**Implementation**: +- **File**: `lib/tools/reference-image.js` (new) +- **Dependencies**: `Editor.Message.request('reference-image', ...)` +- **Consolidate into 2 tools**: + + **`manage_reference_image`**: + ```json + { + "action": { "type": "string", "enum": ["add", "remove", "switch", "clear_all", "refresh", "list", "query_current", "query_config"] }, + "paths": { "type": "array", "items": { "type": "string" }, "description": "Image paths (add/remove)" }, + "path": { "type": "string", "description": "Single image path (switch)" }, + "sceneUUID": { "type": "string", "description": "Optional scene UUID (switch)" } + } + ``` + + **`set_reference_image_property`**: + ```json + { + "action": { "type": "string", "enum": ["position", "scale", "opacity", "data"] }, + "x": { "type": "number" }, "y": { "type": "number" }, + "sx": { "type": "number" }, "sy": { "type": "number" }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "key": { "type": "string", "enum": ["path", "x", "y", "sx", "sy", "opacity"] }, + "value": { "description": "Value for the given key (data action)" } + } + ``` + +- **Profile**: `full` +- **IPC mapping**: `Editor.Message.request('reference-image', 'add-image'|'remove-image'|'switch-image'|'set-image-data'|'query-config'|'query-current-image'|'refresh'|'clear-all', ...)` + +--- + +## P3 — Optional Enhancements + +Implement on demand. All are `full` profile. + +| # | Tool name | Source | Consolidation | Key IPC | +|---|---|---|---|---| +| 9 | `manage_undo` | `scene-advanced-tools.ts:241` | `begin`/`end`/`cancel` → 1 tool + action | `scene: begin-undo-recording` etc. | +| 10 | `manage_asset_crud` | `project-tools.ts:178` | `create`/`copy`/`move`/`save`/`reimport` → 1 tool + action | `asset-db: create-asset` etc. | +| 11 | `manage_preview_server` | `project-tools.ts:156` | `start`/`stop` → 1 tool + action | `preview-server: start`/`stop` | +| 12 | `get_project_settings` | `project-tools.ts:52` | Single tool, category param | `project: query-settings` | +| 13 | `validate_asset_references` | `asset-advanced-tools.ts:110` | Directory-wide scan | `asset-db: query-assets` + analysis | +| 14 | `build_project` | `project-tools.ts:24` | Platform enum param | `builder: build` | + +--- + +## Not Recommended for Absorption + +These cocos-mcp-server features are adequately covered by `execute_javascript` and do not warrant dedicated tools: + +| Feature | Reason to skip | +|---|---| +| Broadcast message listening (`listen_broadcast`, `stop_listening`) | Low frequency; `execute_javascript` with `Editor.Message.broadcast` is sufficient | +| Preferences panel opening (`open_preferences_settings`) | One-liner: `Editor.Panel.open('preferences')` | +| Console log capture (`get_console_logs`, `clear_console`) | funplay's `get_recent_logs` + `clear_logs` already cover this | +| Texture compression (`compress_textures`) | Very low frequency; better as a project build setting | +| Asset manifest export (`export_asset_manifest`) | Low frequency; `execute_javascript` can iterate asset-db | +| Array element manipulation (`move_array_element`, `remove_array_element`) | `execute_javascript` in scene context is more flexible | +| Node property reset (`reset_node_property`) | `execute_javascript` one-liner via `Editor.Message.request('scene', 'reset-property', ...)` | +| Scene snapshot (`scene_snapshot`) | Advanced internal feature; AI rarely needs it directly | +| `validate_json_params` / `safe_string_value` / `format_mcp_request` | Workarounds for cocos-mcp-server's own JSON parsing bugs; funplay's server handles JSON correctly | + +--- + +## Implementation Steps + +### Step 1: Create `lib/tools/scene-management.js` + +New file with `createSceneManagementTools({ createSchema, sceneBridge })` exporting: +- `create_scene` +- `copy_paste_node` +- `query_scene_state` + +### Step 2: Extend `lib/tools/assets-advanced.js` + +Append to existing `createAssetsAdvancedTools`: +- `batch_asset_ops` +- `find_unused_assets` +- Add `direction` parameter to existing `inspect_asset_dependencies` + +### Step 3: Create `lib/tools/scene-view.js` + +New file with `createSceneViewTools({ createSchema })` exporting: +- `set_scene_view` +- `get_scene_view` +- `focus_on_nodes` + +### Step 4: Create `lib/tools/reference-image.js` + +New file with `createReferenceImageTools({ createSchema })` exporting: +- `manage_reference_image` +- `set_reference_image_property` + +### Step 5: Register in `lib/tool-registry.js` + +```javascript +const { createSceneManagementTools } = require('./tools/scene-management'); +const { createSceneViewTools } = require('./tools/scene-view'); +const { createReferenceImageTools } = require('./tools/reference-image'); + +// Inside createToolRegistry, in the tools array: +...createSceneManagementTools({ createSchema, sceneBridge }), +...createSceneViewTools({ createSchema }), +...createReferenceImageTools({ createSchema }), +``` + +### Step 6: Add scene-side methods to `scene.js` + +Add `createScene` method to the scene script's `methods` export for scene creation support. + +### Step 7: Run verification chain + +```bash +npm run check # syntax check all files +npm test # run unit tests +npm run docs:generate # regenerate docs/TOOLS.md +npm run docs:check # verify docs in sync +npm run release:check # release metadata +``` + +### Step 8: Update documentation + +- `README.md` — add new tool categories to the Built-in Tools table +- `README_CN.md` — mirror changes +- `CHANGELOG.md` — add entry under next version + +--- + +## Expected Outcome + +| Metric | Before | After | +|---|---|---| +| `full` profile tools | 101 | ~115 (net +14 after consolidation) | +| `core` profile tools | 37 | 38 (+`query_scene_state`) | +| Blank areas | Scene creation, view control, reference images, clipboard, batch assets | All filled | +| New files | — | `lib/tools/scene-management.js`, `lib/tools/scene-view.js`, `lib/tools/reference-image.js` | +| Modified files | — | `lib/tools/assets-advanced.js`, `lib/tool-registry.js`, `scene.js` | diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..8ddcaa3 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,583 @@ +# Implementation Plan: High-Frequency Editor Operations + +> **Goal**: Fill the 9 highest-impact tool gaps identified in the Cocos Creator business-development workflow analysis. +> +> **Current state**: 101 tools (core=37, full=101), version 0.4.0 +> **Target state**: 110 tools (core=38, full=110) + +## Tool Summary + +| # | Tool | Profile | Side | Phase | New file? | +|---|---|---|---|---|---| +| 1 | `create_scene` | `full` | editor | 1 | `lib/tools/scene-management.js` | +| 2 | `query_scene_state` | `core` | editor | 1 | same | +| 3 | `copy_paste_node` | `full` | editor | 1 | same | +| 4 | `rename_node` | `full` | scene | 2 | scene.js + scene-management.js | +| 5 | `reparent_node` | `full` | scene | 2 | same | +| 6 | `create_prefab` | `full` | editor | 3 | prefabs.js + tool-registry.js | +| 7 | `create_script` | `full` | editor | 4 | `lib/tools/scripts.js` | +| 8 | `batch_asset_ops` | `full` | editor | 5 | assets-advanced.js (append) | +| 9 | `find_unused_assets` | `full` | editor | 5 | same | + +## Files Changed + +| File | Action | Phase | +|---|---|---| +| `lib/tools/scene-management.js` | **New** — factory for tools 1-5 | 1, 2 | +| `scene.js` | **Modify** — add `renameNode`, `reparentNode` methods | 2 | +| `lib/prefabs.js` | **Modify** — add `createPrefab` helper | 3 | +| `lib/tool-registry.js` | **Modify** — import + spread new factories, add `create_prefab` inline | 1-5 | +| `lib/tools/scripts.js` | **New** — factory for tool 7 | 4 | +| `lib/tools/assets-advanced.js` | **Modify** — append tools 8-9 | 5 | +| `docs/TOOLS.md` | **Regenerate** via `npm run docs:generate` | final | +| `README.md` / `README_CN.md` | **Modify** — add new tool categories | final | +| `CHANGELOG.md` | **Modify** — add `## [0.5.0]` entry | final | +| `test/*.test.js` | **New** — unit tests for pure-logic helpers | each phase | + +--- + +## Phase 1: Scene Management Tools + +**File**: `lib/tools/scene-management.js` (new) + +**Dependencies**: `{ createSchema, getRuntimeContext }` — uses `Editor.Message.request` directly (editor-side), no scene bridge needed for tools 1-3. + +**Imports**: Reuse `tryEditorRequests` from `./cocos-project.js`. + +### Tool 1: `create_scene` + +```js +{ + name: 'create_scene', + profile: 'full', + description: '[core] Create a new scene asset in the Cocos project and open it.', + inputSchema: createSchema( + { + sceneName: { type: 'string', description: 'Name of the new scene (without extension).' }, + savePath: { type: 'string', description: 'Asset-db URL, e.g. db://assets/scenes/NewScene.scene.' }, + open: { type: 'boolean', description: 'Whether to open the scene after creation. Default true.' }, + }, + ['sceneName', 'savePath'] + ), + handler: async (args) => { ... } +} +``` + +**Implementation approach**: + +Use `Editor.Message.request('asset-db', 'create-asset', savePath, '')` to create an empty `.scene` file. Cocos Creator's asset-db will generate a valid scene template automatically when the file extension is `.scene`. Then optionally `openAsset(savePath)`. + +Fallback: if `create-asset` doesn't auto-generate scene content, write a minimal scene JSON template to disk and let asset-db import it. + +```js +// Pseudocode +const result = await Editor.Message.request('asset-db', 'create-asset', args.savePath, ''); +// result contains { uuid, url, ... } +if (args.open !== false) { + await openAsset(result.url || args.savePath); +} +return { created: true, sceneName: args.sceneName, url: result.url, uuid: result.uuid }; +``` + +**IPC mapping**: +- `asset-db: create-asset` (primary) +- `asset-db: import-asset` (fallback if create-asset doesn't work for scenes) + +**Error handling**: Throw if `savePath` doesn't end with `.scene`. Throw if asset already exists. + +--- + +### Tool 2: `query_scene_state` + +```js +{ + name: 'query_scene_state', + profile: 'core', + description: '[specialist] Query scene state: dirty (unsaved changes), ready, or soft-reload.', + inputSchema: createSchema( + { + action: { type: 'string', enum: ['is_dirty', 'is_ready', 'soft_reload'], description: 'State query or action.' }, + }, + ['action'] + ), + handler: async (args) => { ... } +} +``` + +**Why `core`**: `is_dirty` is a critical signal for AI to decide whether to save before making changes. This is the only new tool promoted to `core`. + +**IPC mapping**: +- `is_dirty` → `Editor.Message.request('scene', 'query-is-dirty')` → returns boolean +- `is_ready` → `Editor.Message.request('scene', 'query-ready')` → returns boolean +- `soft_reload` → `Editor.Message.request('scene', 'soft-reload')` → reloads scene from disk + +**Implementation**: +```js +const IPC_MAP = { + is_dirty: { channel: 'scene', method: 'query-is-dirty' }, + is_ready: { channel: 'scene', method: 'query-ready' }, + soft_reload: { channel: 'scene', method: 'soft-reload' }, +}; +const candidate = IPC_MAP[args.action]; +const result = await tryEditorRequests([candidate]); +return { action: args.action, result: result.result }; +``` + +--- + +### Tool 3: `copy_paste_node` + +```js +{ + name: 'copy_paste_node', + profile: 'full', + description: '[core] Copy, cut, or paste scene nodes via the Cocos editor clipboard.', + inputSchema: createSchema( + { + action: { type: 'string', enum: ['copy', 'cut', 'paste'], description: 'Clipboard action.' }, + uuids: { type: 'array', items: { type: 'string' }, description: 'Node UUIDs to copy or cut.' }, + target: { type: 'string', description: 'Target parent node UUID for paste.' }, + keepWorldTransform: { type: 'boolean', description: 'Keep world transform on paste. Default false.' }, + }, + ['action'] + ), + handler: async (args) => { ... } +} +``` + +**IPC mapping**: +- `copy` → `Editor.Message.request('scene', 'copy-node', args.uuids)` +- `cut` → `Editor.Message.request('scene', 'cut-node', args.uuids)` +- `paste` → `Editor.Message.request('scene', 'paste-node', { target: args.target, keepWorldTransform: args.keepWorldTransform })` + +**Validation**: +- `copy`/`cut` requires `uuids` non-empty +- `paste` requires `target` + +--- + +## Phase 2: Node Operations + +**Files**: `scene.js` (add methods) + `lib/tools/scene-management.js` (add tool definitions) + +### Tool 4: `rename_node` (scene-side) + +**scene.js method** — add to `exports.methods`: + +```js +async renameNode(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const oldName = node.name; + const oldPath = getNodePath(node); + const newName = String(options.newName || '').trim(); + if (!newName) { + throw new Error('newName is required.'); + } + node.name = newName; + return { + renamed: true, + oldName, + newName, + oldPath, + newPath: getNodePath(node), + uuid: node.uuid, + }; +}, +``` + +**Tool definition** (in `scene-management.js`): + +```js +{ + name: 'rename_node', + profile: 'full', + description: '[core] Rename a scene node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node UUID.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + newName: { type: 'string', description: 'New name for the node.' }, + }, + ['newName'] + ), + handler: async (args) => sceneBridge.call('renameNode', args), +} +``` + +**Dependencies**: `{ createSchema, sceneBridge }` — add `sceneBridge` to the factory's dependency bag. + +--- + +### Tool 5: `reparent_node` (scene-side) + +**scene.js method** — add to `exports.methods`: + +```js +async reparentNode(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const oldPath = getNodePath(node); + const oldParent = node.parent; + + let newParent; + if (options.targetUuid) { + newParent = findNodeByUuid(options.targetUuid); + } else if (options.targetPath) { + newParent = findNodeByPath(options.targetPath); + } else if (options.targetName) { + newParent = findNodeByName(options.targetName); + } + if (!newParent) { + throw new Error('Target parent node was not found.'); + } + + const siblingIndex = Number.isFinite(options.siblingIndex) ? options.siblingIndex : -1; + if (siblingIndex >= 0 && siblingIndex <= newParent.children.length) { + newParent.insertChild(node, siblingIndex); + } else { + node.parent = newParent; + } + + return { + reparented: true, + uuid: node.uuid, + oldPath, + newPath: getNodePath(node), + oldParent: oldParent ? oldParent.name : null, + newParent: newParent.name, + siblingIndex: node.getSiblingIndex(), + }; +}, +``` + +**Tool definition** (in `scene-management.js`): + +```js +{ + name: 'reparent_node', + profile: 'full', + description: '[core] Move a node to a new parent in the scene hierarchy.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path to move.' }, + uuid: { type: 'string', description: 'Node UUID to move.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + targetPath: { type: 'string', description: 'New parent node path.' }, + targetUuid: { type: 'string', description: 'New parent node UUID.' }, + targetName: { type: 'string', description: 'Fallback exact new parent name.' }, + siblingIndex: { type: 'number', description: 'Optional insert position among siblings. Default: append.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('reparentNode', args), +} +``` + +--- + +## Phase 3: Prefab Creation + +**Files**: `lib/prefabs.js` (add helper) + `lib/tool-registry.js` (add inline tool) + +### Tool 6: `create_prefab` + +**lib/prefabs.js** — add `createPrefab` helper: + +```js +async function createPrefab(options = {}) { + const nodeUuid = String(options.nodeUuid || '').trim(); + const savePath = String(options.savePath || '').trim(); + if (!nodeUuid) throw new Error('nodeUuid is required.'); + if (!savePath) throw new Error('savePath is required.'); + + const result = await Editor.Message.request('scene', 'create-prefab', nodeUuid, savePath); + return { + created: true, + nodeUuid, + savePath, + result, + }; +} +``` + +**Tool definition** (inline in `tool-registry.js`, near existing prefab tools ~line 760): + +```js +{ + name: 'create_prefab', + profile: 'full', + description: '[core] Create a prefab asset from a scene node.', + inputSchema: createSchema( + { + nodeUuid: { type: 'string', description: 'UUID of the scene node to save as prefab.' }, + savePath: { type: 'string', description: 'Asset-db URL, e.g. db://assets/prefabs/Enemy.prefab.' }, + }, + ['nodeUuid', 'savePath'] + ), + handler: async (args) => createPrefab(args), +}, +``` + +**IPC mapping**: `Editor.Message.request('scene', 'create-prefab', nodeUuid, savePath)` + +**Notes**: The `create-prefab` scene message is well-documented in Cocos Creator 3.8+. It takes a node UUID and a target `.prefab` path, creates the prefab asset, and links the scene node as a prefab instance. + +--- + +## Phase 4: Script Creation + +**File**: `lib/tools/scripts.js` (new) + +**Dependencies**: `{ createSchema, getRuntimeContext }` — uses `fs` for file writing + `Editor.Message.request('asset-db', 'refresh-asset')` for refresh. + +### Tool 7: `create_script` + +```js +{ + name: 'create_script', + profile: 'full', + description: '[core] Create a new TypeScript component script with a standard Cocos template.', + inputSchema: createSchema( + { + scriptName: { type: 'string', description: 'Class name in PascalCase, e.g. PlayerController.' }, + savePath: { type: 'string', description: 'Asset-db URL, e.g. db://assets/scripts/PlayerController.ts.' }, + template: { type: 'string', enum: ['component', 'plain'], description: 'Script template. Default: component.' }, + properties: { type: 'array', items: { type: 'object' }, description: 'Optional @property declarations.' }, + }, + ['scriptName', 'savePath'] + ), + handler: async (args) => { ... } +} +``` + +**Template generation** (pure logic, unit-testable): + +```js +function generateComponentTemplate(className, properties = []) { + const props = properties.map(p => { + const type = p.type || 'number'; + const name = p.name || 'property'; + return ` @property({ type: ${type} })\n ${name}: ${type} = ${p.default || '0'};`; + }).join('\n\n'); + + return `import { _decorator, Component, ${properties.map(p => p.type).filter(t => t && t !== 'number' && t !== 'string' && t !== 'boolean').join(', ')} } from 'cc'; +const { ccclass, property } = _decorator; + +@ccclass('${className}') +export class ${className} extends Component { +${props || ' // Add properties here'} +}`; +``` + +**Implementation**: +1. Generate template string from `scriptName` + `template` + `properties` +2. Write to project filesystem via `resolveProjectPath` + `fs.writeFileSync` +3. Refresh asset-db: `Editor.Message.request('asset-db', 'refresh-asset', savePath)` +4. Return `{ created: true, scriptName, savePath, className }` + +**Unit tests** (test/scripts.test.js): +- `generateComponentTemplate('PlayerController')` → contains `@ccclass`, `export class PlayerController extends Component` +- `generateComponentTemplate('Enemy', [{name:'speed',type:'number',default:'5'}])` → contains `@property({ type: number })` and `speed: number = 5` +- `generatePlainTemplate('Utils')` → no `@ccclass`, just `export class Utils` + +--- + +## Phase 5: Asset Batch Operations + +**File**: `lib/tools/assets-advanced.js` (append to existing) + +**Dependencies**: `{ createSchema, getRuntimeContext }` — uses `Editor.Message.request('asset-db', ...)` and `listAssets`. + +### Tool 8: `batch_asset_ops` + +```js +{ + name: 'batch_asset_ops', + profile: 'full', + description: '[core] Batch import or delete assets in the Cocos asset database.', + inputSchema: createSchema( + { + action: { type: 'string', enum: ['import', 'delete'], description: 'Batch operation.' }, + sourceDirectory: { type: 'string', description: 'Local filesystem source (import only).' }, + targetDirectory: { type: 'string', description: 'Asset-db target URL, e.g. db://assets/textures (import only).' }, + urls: { type: 'array', items: { type: 'string' }, description: 'Asset URLs to delete (delete only).' }, + fileFilter: { type: 'array', items: { type: 'string' }, description: 'File extensions, e.g. [".png", ".jpg"].' }, + recursive: { type: 'boolean', description: 'Import recursively. Default false.' }, + overwrite: { type: 'boolean', description: 'Overwrite existing assets. Default false.' }, + }, + ['action'] + ), + handler: async (args) => { ... } +} +``` + +**IPC mapping**: +- `import` → `Editor.Message.request('asset-db', 'import', targetDirectory, sourceDirectory)` +- `delete` → `Editor.Message.request('asset-db', 'delete-assets', urls)` + +**Validation**: +- `import` requires `sourceDirectory` + `targetDirectory` +- `delete` requires `urls` non-empty + +### Tool 9: `find_unused_assets` + +```js +{ + name: 'find_unused_assets', + profile: 'full', + description: '[core] Find assets not referenced by any scene, prefab, or script in the project.', + inputSchema: createSchema( + { + directory: { type: 'string', description: 'Asset-db directory to scan. Default: db://assets.' }, + excludeDirectories: { type: 'array', items: { type: 'string' }, description: 'Directories to exclude.' }, + assetTypes: { type: 'array', items: { type: 'string' }, description: 'Filter by asset type, e.g. ["cc.Texture2D", "cc.AudioClip"].' }, + limit: { type: 'number', description: 'Max results. Default 200.' }, + }, + [] + ), + handler: async (args) => { ... } +} +``` + +**Implementation** (pure-logic core, unit-testable): + +```js +async function findUnusedAssets(projectPath, options = {}) { + // 1. Query all assets in the directory + const allAssets = await listAssets({ + pattern: options.directory || 'db://assets/**', + ccType: options.assetTypes, + }); + + // 2. Build a set of all referenced UUIDs by scanning: + // - All .scene files + // - All .prefab files + // - All .ts/.js files (for dynamic asset references) + // - All .anim files + // - All .meta files (for sub-asset references) + const referencedUuids = new Set(); + // ... scan files, collect UUIDs using collectUuidReferences from assets-advanced.js + + // 3. Filter: assets whose UUID is NOT in referencedUuids + const unused = allAssets.filter(asset => !referencedUuids.has(asset.uuid)); + + return { + totalScanned: allAssets.length, + unusedCount: unused.length, + unused: unused.slice(0, options.limit || 200), + }; +} +``` + +**Unit tests** (test/find-unused-assets.test.js): +- Mock `listAssets` + file scanning → verify correct filtering +- Test exclude directories +- Test asset type filtering + +--- + +## Registration + +### `lib/tool-registry.js` changes + +**1. Add imports** (top of file): +```js +const { createSceneManagementTools } = require('./tools/scene-management'); +const { createScriptTools } = require('./tools/scripts'); +const { createPrefab } = require('./prefabs'); // add to existing destructure +``` + +**2. Spread factories** into the `tools` array: +```js +// After existing cocos-project tools (~line 717) +...createSceneManagementTools({ createSchema, sceneBridge, getRuntimeContext }), + +// After existing prefab tools (~line 780) +{ + name: 'create_prefab', + // ... inline definition + handler: async (args) => createPrefab(args), +}, + +// After existing file tools (~line 1315) +...createScriptTools({ createSchema, getRuntimeContext }), + +// After existing assets-advanced tools (~line 1023) +// batch_asset_ops and find_unused_assets are appended inside +// createAssetsAdvancedTools via the existing factory +``` + +**3. Pass `sceneBridge` to `createSceneManagementTools`** — currently only `createSceneEventTools` receives `sceneBridge`. Add it to the scene-management factory call. + +### `lib/tools/assets-advanced.js` changes + +Append `batch_asset_ops` and `find_unused_assets` tool definitions to the `createAssetsAdvancedTools` return array. Add `getRuntimeContext` to the factory's dependency bag (already received). + +### `lib/tools/scene-management.js` factory signature + +```js +function createSceneManagementTools({ createSchema, sceneBridge, getRuntimeContext }) { + return [ + // Tool 1: create_scene (editor-side, uses getRuntimeContext for projectPath) + // Tool 2: query_scene_state (editor-side, uses tryEditorRequests) + // Tool 3: copy_paste_node (editor-side, uses tryEditorRequests) + // Tool 4: rename_node (scene-side, uses sceneBridge.call) + // Tool 5: reparent_node (scene-side, uses sceneBridge.call) + ]; +} +module.exports = { createSceneManagementTools }; +``` + +--- + +## Implementation Order + +Execute phases sequentially. Each phase ends with `npm run check && npm test` before proceeding. + +``` +Phase 1 (tools 1-3) ──▶ npm run check && npm test + │ +Phase 2 (tools 4-5) ──▶ npm run check && npm test + │ +Phase 3 (tool 6) ──▶ npm run check && npm test + │ +Phase 4 (tool 7) ──▶ npm run check && npm test + │ +Phase 5 (tools 8-9) ──▶ npm run check && npm test + │ +Final: docs:generate ──▶ docs:check ──▶ release:check ──▶ pack:dry-run +``` + +## Verification Checklist (per phase) + +- [ ] `npm run check` — syntax check all files +- [ ] `npm test` — unit tests pass +- [ ] `npm run docs:generate` — regenerate TOOLS.md (final phase only) +- [ ] `npm run docs:check` — docs in sync (final phase only) +- [ ] `npm run release:check` — release metadata valid (final phase only) +- [ ] `npm run pack:dry-run` — package builds (final phase only) + +## Version Bump + +- `package.json` version: `0.4.0` → `0.5.0` +- `server.json` version(s): update to match +- `CHANGELOG.md`: add `## [0.5.0] - 2026-06-30` entry +- Git tag: `v0.5.0` + +## Expected Outcome + +| Metric | Before | After | +|---|---|---| +| `full` profile tools | 101 | 110 (+9) | +| `core` profile tools | 37 | 38 (+1: `query_scene_state`) | +| New files | — | `lib/tools/scene-management.js`, `lib/tools/scripts.js` | +| Modified files | — | `scene.js`, `lib/prefabs.js`, `lib/tool-registry.js`, `lib/tools/assets-advanced.js` | +| New scene.js methods | — | `renameNode`, `reparentNode` | +| New unit test files | — | `test/scene-management.test.js`, `test/scripts.test.js`, `test/find-unused-assets.test.js` | +| Gaps filled | Scene creation, node clipboard, node rename/reparent, prefab creation, script creation, batch assets, unused asset scan | All high-frequency gaps covered | diff --git a/docs/INTEGRATION_TEST.md b/docs/INTEGRATION_TEST.md new file mode 100644 index 0000000..9a09dec --- /dev/null +++ b/docs/INTEGRATION_TEST.md @@ -0,0 +1,2001 @@ +# funplay-cocos-mcp 集成测试文档 + +> **目标**:AI Agent 读取本文档后,可全程自动循环执行所有测试,输出结构化测试报告,验证 funplay-cocos-mcp 全部 101 个工具、10 个资源、4 个 prompt 在 Cocos Creator 3.8.8 下的兼容性。 +> +> **测试项目**:一次性测试项目,测完丢弃。 +> +> **调用方式**:curl(协议层 + 独立工具)与 execute_javascript(链式编排)结合。 + +--- + +## 1. 测试环境搭建 + +### 1.1 前置条件 + +| 条件 | 要求 | +|---|---| +| Cocos Creator | 3.8.8 | +| Node.js | >= 18(Cocos 自带即可) | +| 操作系统 | Windows / macOS / Linux 均可 | +| funplay-cocos-mcp | 已 clone 或安装到 Cocos 项目 `extensions/` 目录 | + +### 1.2 创建一次性测试项目 + +1. 打开 Cocos Creator 3.8.8,新建一个 **Empty(2D)** 项目,命名为 `mcp-test-disposable` +2. 项目创建后会自动生成一个默认场景 `assets/scene.scene`,保留它 +3. 在 `assets/` 下创建文件夹 `scripts` 和 `prefabs` + +### 1.3 安装 funplay-cocos-mcp 扩展 + +```bash +# 方式一:符号链接(开发模式) +cd /path/to/mcp-test-disposable/extensions +# Windows: mklink /D funplay-cocos-mcp C:\Users\wangj\data\src\mcp\funplay-cocos-mcp +# macOS/Linux: ln -s /path/to/funplay-cocos-mcp funplay-cocos-mcp + +# 方式二:直接复制 +cp -r /path/to/funplay-cocos-mcp /path/to/mcp-test-disposable/extensions/funplay-cocos-mcp +``` + +回到 Cocos Creator,重新加载扩展(菜单 → 扩展 → 重新载入)。 + +### 1.4 启动 MCP 服务器 + +1. 打开菜单:`Funplay > MCP Server` +2. 在面板中确认 Tool Profile 设置为 `full`(测试全部 101 个工具) +3. 点击启动服务器 +4. 确认面板显示服务器运行在 `http://127.0.0.1:8765/` + +### 1.5 验证连通性 + +```bash +# 健康检查 +curl -s http://127.0.0.1:8765/health + +# 预期返回: +# {"ok":true,"name":"Funplay Cocos MCP - mcp-test-disposable","version":"0.4.0",...} +``` + +如果端口被占用,服务器会自动回退到下一个可用端口。请从 `/health` 响应中确认实际端口,后续所有测试中使用该端口。 + +### 1.6 测试数据初始化 + +在正式测试前,通过 `execute_javascript` 在编辑器上下文中创建测试所需的基础数据: + +```bash +# 设置变量 +MCP_URL="http://127.0.0.1:8765" + +# 初始化测试数据:创建 Canvas + 测试节点 + 测试脚本文件 +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-11-25" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "execute_javascript", + "arguments": { + "context": "scene", + "code": "const { Canvas, UITransform, Label, Button, director, find, Node } = cc; const scene = director.getScene(); let canvas = find(\"Canvas\"); if (!canvas) { canvas = new Node(\"Canvas\"); canvas.addComponent(Canvas); canvas.addComponent(UITransform); scene.addChild(canvas); } let testNode = find(\"Canvas/TestNode\"); if (!testNode) { testNode = new Node(\"TestNode\"); testNode.addComponent(UITransform); testNode.addComponent(Label); testNode.addComponent(Button); canvas.addChild(testNode); } return { canvasUuid: canvas.uuid, testNodeUuid: testNode.uuid, sceneName: scene.name };" + } + } + }' +``` + +```bash +# 创建测试脚本文件 +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-11-25" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "write_file", + "arguments": { + "path": "assets/scripts/TestComponent.ts", + "content": "import { _decorator, Component, Label } from \"cc\";\nconst { ccclass, property } = _decorator;\n\n@ccclass(\"TestComponent\")\nexport class TestComponent extends Component {\n @property({ type: Label })\n label: Label | null = null;\n\n @property\n speed: number = 10;\n\n start() {\n console.log(\"TestComponent started\");\n }\n\n update(dt: number) {\n // test method\n }\n\n public greet(name: string): string {\n return `Hello, ${name}!`;\n }\n}\n" + } + } + }' +``` + +```bash +# 刷新资源数据库 +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-11-25" \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "refresh_assets", + "arguments": {} + } + }' +``` + +```bash +# 保存场景,确保 TestNode 已持久化 +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-11-25" \ + -d '{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "save_current_scene", + "arguments": {} + } + }' +``` + +```bash +# 从 TestNode 创建预制体(funplay-cocos-mcp 无 create_prefab 工具,通过 execute_javascript 调用 Editor API) +# 将 替换为前面初始化返回的 testNodeUuid +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-11-25" \ + -d '{ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "execute_javascript", + "arguments": { + "context": "editor", + "code": "const testNodeUuid = \"\"; const savePath = \"db://assets/prefabs/TestPrefab.prefab\"; const result = await Editor.Message.request(\"scene\", \"create-prefab\", testNodeUuid, savePath); return { result: result, savePath: savePath };" + } + } + }' +``` + +```bash +# 刷新资源数据库,确保新预制体被索引 +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-11-25" \ + -d '{ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "refresh_assets", + "arguments": {"path": "assets/prefabs"} + } + }' +``` + +```bash +# 查询预制体 uuid,供后续测试使用 +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-11-25" \ + -d '{ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "list_prefabs", + "arguments": {} + } + }' +``` + +**AI Agent 注意**:记录以下初始化返回值,后续链式测试需要使用: +- `canvasUuid` — Canvas 节点 UUID +- `testNodeUuid` — TestNode 节点 UUID +- `prefabUuid` — TestPrefab 预制体 UUID(从最后一步 list_prefabs 返回中获取) +- `prefabPath` — `db://assets/prefabs/TestPrefab.prefab` + +--- + +## 2. AI Agent 自动化测试框架 + +### 2.1 通用变量与辅助函数 + +AI Agent 在执行测试前,先设置以下变量: + +```bash +MCP_URL="http://127.0.0.1:8765" +PROTO_VER="2025-11-25" +TEST_ID=0 +PASS_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 +RESULTS_FILE="/tmp/mcp-test-results.json" +``` + +辅助函数(AI Agent 可用 shell 函数或等价逻辑实现): + +```bash +# MCP initialize +mcp_init() { + curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"$PROTO_VER\",\"clientInfo\":{\"name\":\"test-agent\",\"version\":\"1.0\"}}}" +} + +# MCP tools/call +mcp_tool() { + local tool_name="$1" + local args="${2:-{}}" + local id="${3:-1}" + curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"tools/call\",\"params\":{\"name\":\"$tool_name\",\"arguments\":$args}}" +} + +# MCP resources/read +mcp_resource() { + local uri="$1" + curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\",\"params\":{\"uri\":\"$uri\"}}" +} + +# MCP prompts/get +mcp_prompt() { + local name="$1" + curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"prompts/get\",\"params\":{\"name\":\"$name\"}}" +} + +# 从 tools/call 响应中提取 structuredContent.data 的 JSON +extract_data() { + local response="$1" + echo "$response" | python3 -c " +import sys, json +resp = json.load(sys.stdin) +sc = resp.get('result', {}).get('structuredContent', {}) +print(json.dumps(sc.get('data', {}))) +" 2>/dev/null +} + +# 从 tools/call 响应中提取 ok 字段 +extract_ok() { + local response="$1" + echo "$response" | python3 -c " +import sys, json +resp = json.load(sys.stdin) +sc = resp.get('result', {}).get('structuredContent', {}) +print(sc.get('ok', 'missing')) +" 2>/dev/null +} + +# 从 tools/call 响应中提取 isError +extract_is_error() { + local response="$1" + echo "$response" | python3 -c " +import sys, json +resp = json.load(sys.stdin) +print(resp.get('result', {}).get('isError', False)) +" 2>/dev/null +} + +# 记录测试结果 +record_result() { + local test_id="$1" + local tool_name="$2" + local status="$3" # pass / fail / skip + local detail="$4" + echo "{\"id\":\"$test_id\",\"tool\":\"$tool_name\",\"status\":\"$status\",\"detail\":\"$detail\"}" >> "$RESULTS_FILE" + if [ "$status" = "pass" ]; then PASS_COUNT=$((PASS_COUNT+1)); + elif [ "$status" = "fail" ]; then FAIL_COUNT=$((FAIL_COUNT+1)); + else SKIP_COUNT=$((SKIP_COUNT+1)); fi +} +``` + +### 2.2 通用判定规则 + +每个测试用例的判定遵循以下通用规则,叠加各用例的特定规则: + +| 规则编号 | 规则 | 判定方法 | +|---|---|---| +| R1 | HTTP 状态码 200 | `curl` 退出码为 0 且响应包含 `"jsonrpc"` | +| R2 | 响应包含 `result` 字段 | 响应 JSON 有 `result` 键 | +| R3 | `result.isError` 不为 `true` | `extract_is_error` 返回 `False` | +| R4 | `structuredContent.ok` 为 `true` | `extract_ok` 返回 `True` | +| R5 | `structuredContent.data` 存在 | `extract_data` 返回非空 JSON | +| R6 | 无 `error` 顶层字段 | 响应 JSON 无 `error` 键 | + +**例外**:截图工具返回 image content(`data:image/png;base64,...`),不适用 R4/R5,改用"content 中包含 image 类型项"判定。 + +### 2.3 测试报告格式 + +测试结果以 JSON 数组形式写入 `$RESULTS_FILE`: + +```json +[ + {"id":"T001","tool":"initialize","status":"pass","detail":"protocol 2025-11-25 negotiated"}, + {"id":"T101","tool":"get_project_info","status":"pass","detail":"projectPath=/path/to/project, cocosVersion=3.8.8"}, + {"id":"T201","tool":"create_node","status":"fail","detail":"ok=false, error=Scene not loaded"} +] +``` + +最终汇总报告: + +``` +========== funplay-cocos-mcp 集成测试报告 ========== +测试时间: 2026-06-30T12:00:00Z +Cocos Creator 版本: 3.8.8 +MCP 服务器版本: 0.4.0 +工具配置档: full +================================================ +通过: 95 失败: 4 跳过: 2 总计: 101 +================================================ +失败详情: + T201 create_node: ok=false, error=Scene not loaded + T432 simulate_mouse_click: No visible window found + ... +================================================ +兼容性结论: [PASS/PARTIAL/FAIL] +``` + +### 2.4 AI Agent 执行循环流程 + +``` +1. 执行 Phase 0 协议测试 + → 全部通过才继续;失败则中止并报告 + +2. 执行测试数据初始化(1.6 节) + → 记录 canvasUuid, testNodeUuid + +3. 按顺序执行 Phase 1 → Phase 6 + 每个测试用例: + a. 检查前置条件(如需要场景已加载、需要 preview 运行等) + b. 执行 curl 命令或 execute_javascript 链式调用 + c. 解析响应 JSON + d. 执行判定规则(R1-R6 + 特定规则) + e. 记录结果到 $RESULTS_FILE + f. 如果是链式测试,提取输出数据(uuid 等)供后续测试使用 + g. 如果失败,记录详情,继续下一个测试 + +4. 链式测试特殊处理: + - 前一步失败时,后续依赖步骤标记为 skip + - 提取 uuid 等数据时使用 python3 解析 JSON + +5. 全部完成后: + a. 汇总 PASS/FAIL/SKIP 计数 + b. 列出所有失败项详情 + c. 输出兼容性结论 + d. 将 $RESULTS_FILE 内容输出为结构化报告 +``` + +--- + +## 3. Phase 0: MCP 协议合规性测试 + +### T001: initialize 握手 + +- **方法**: curl +- **前置条件**: MCP 服务器已启动 + +```bash +mcp_init +``` + +- **判定**: R1 + R2 + 响应 `result.protocolVersion` 为受支持版本 + `result.capabilities` 包含 `tools`/`resources`/`prompts` + `result.serverInfo.name` 包含 `Funplay` +- **记录**: 协商出的协议版本 + +### T002: tools/list + +- **方法**: curl +- **前置条件**: T001 通过 + +```bash +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +- **判定**: R1 + R2 + `result.tools` 为数组 + 数组长度 > 0 + 每个工具有 `name`/`description`/`inputSchema` +- **记录**: 工具总数(full 模式应为 101) + +### T003: resources/list + +```bash +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d '{"jsonrpc":"2.0","id":1,"method":"resources/list","params":{}}' +``` + +- **判定**: R1 + R2 + `result.resources` 为数组 + 长度 >= 10 + 每项有 `uri`/`name`/`description` + +### T004: resources/templates/list + +```bash +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d '{"jsonrpc":"2.0","id":1,"method":"resources/templates/list","params":{}}' +``` + +- **判定**: R1 + R2 + `result.resourceTemplates` 为数组 + 长度 >= 3 + 每项有 `uriTemplate` + +### T005: prompts/list + +```bash +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d '{"jsonrpc":"2.0","id":1,"method":"prompts/list","params":{}}' +``` + +- **判定**: R1 + R2 + `result.prompts` 为数组 + 长度 >= 4 + 每项有 `name`/`description` + +### T006: Accept header 缺失应拒绝 + +```bash +curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +- **判定**: HTTP 状态码为 406(Accept header 校验失败) + +### T007: 不支持的方法应返回 -32601 + +```bash +curl -s -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: $PROTO_VER" \ + -d '{"jsonrpc":"2.0","id":1,"method":"nonexistent/method","params":{}}' +``` + +- **判定**: R1 + 响应包含 `error.code` 为 -32601 + +### T008: GET /tools 调试端点 + +```bash +curl -s "$MCP_URL/tools" +``` + +- **判定**: R1 + 响应包含 `tools` 数组 + `count` 字段 + `ok: true` + +--- + +## 4. Phase 1: Core 只读工具测试 + +> 以下工具均为 `core` 配置档中的只读/安全工具,可独立执行,无副作用。 + +### T101: get_project_info + +```bash +mcp_tool "get_project_info" '{}' +``` + +- **判定**: R1-R5 + `data.projectPath` 非空 + `data.cocosVersion` 包含 `3.8` +- **记录**: projectPath, cocosVersion + +### T102: get_editor_state + +```bash +mcp_tool "get_editor_state" '{}' +``` + +- **判定**: R1-R5 + `data` 包含项目信息 + +### T103: get_tool_catalog + +```bash +mcp_tool "get_tool_catalog" '{}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 > 0 + 每项有 `name`/`profile`/`category` + +### T104: get_scene_info + +```bash +mcp_tool "get_scene_info" '{}' +``` + +- **判定**: R1-R5 + `data.sceneName` 或 `data.name` 非空 +- **注意**: 需要场景已加载,如失败则后续场景相关测试标记 skip + +### T105: get_hierarchy + +```bash +mcp_tool "get_hierarchy" '{}' +``` + +- **判定**: R1-R5 + `data` 包含节点树结构 + +### T106: list_scenes + +```bash +mcp_tool "list_scenes" '{}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 >= 1 +- **记录**: 第一个场景的 uuid 和 path,供 T107 使用 + +### T107: open_scene + +```bash +# 使用 T106 记录的场景 uuid +mcp_tool "open_scene" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 + 无 `isError` +- **注意**: 替换 `` 为 T106 获取的实际值 + +### T108: list_assets + +```bash +mcp_tool "list_assets" '{"pattern":"db://assets/**/*"}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 > 0 +- **记录**: 第一个资产的 uuid,供 T109/T110 使用 + +### T109: inspect_asset + +```bash +# 使用 T108 记录的 asset uuid +mcp_tool "inspect_asset" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 包含资产信息 + +### T110: inspect_asset_dependencies + +```bash +mcp_tool "inspect_asset_dependencies" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 + `data` 存在(即使无依赖也应返回空数组) + +### T111: validate_asset_dependencies + +```bash +mcp_tool "validate_asset_dependencies" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 + `data` 存在 + +### T112: inspect_prefab + +```bash +# 先列出预制体 +mcp_tool "list_assets" '{"pattern":"db://assets/**/*.prefab"}' +``` + +- **判定**: 如果有预制体,inspect_prefab 返回 R1-R5;如无预制体则 skip +- **注意**: AI Agent 应先检查是否有 .prefab 资产,有则取 uuid 调用 `inspect_prefab` + +### T113: inspect_prefab_instance + +```bash +mcp_tool "inspect_prefab_instance" '{"path":"Canvas/TestNode"}' +``` + +- **判定**: R1-R4(TestNode 非预制体实例,应返回有效响应说明非实例) + +### T114: validate_prefab_references + +```bash +# 如果 T112 有预制体 uuid 则使用,否则 skip +mcp_tool "validate_prefab_references" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 + +### T115: validate_scene + +```bash +mcp_tool "validate_scene" '{}' +``` + +- **判定**: R1-R5 + `data` 包含验证结果 + +### T116: get_selection + +```bash +mcp_tool "get_selection" '{}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T117: set_selection + +```bash +# 使用初始化时记录的 testNodeUuid +mcp_tool "set_selection" "{\"type\":\"node\",\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 + +### T118: get_runtime_state + +```bash +mcp_tool "get_runtime_state" '{}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T119: get_performance_snapshot + +```bash +mcp_tool "get_performance_snapshot" '{}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T120: get_build_status + +```bash +mcp_tool "get_build_status" '{}' +``` + +- **判定**: R1-R4 + `data` 存在 + +### T121: list_editor_windows + +```bash +mcp_tool "list_editor_windows" '{}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 > 0 +- **记录**: 窗口列表,供截图和输入模拟测试使用 + +### T122: get_recent_logs + +```bash +mcp_tool "get_recent_logs" '{}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T123: search_project_logs + +```bash +mcp_tool "search_project_logs" '{"query":"error"}' +``` + +- **判定**: R1-R4 + `data` 存在 + +### T124: clear_logs + +```bash +mcp_tool "clear_logs" '{}' +``` + +- **判定**: R1-R4 + +### T125: run_script_diagnostics + +```bash +mcp_tool "run_script_diagnostics" '{}' +``` + +- **判定**: R1-R5 + `data` 存在(`diagnostics` 数组,即使为空) + +### T126: get_script_diagnostic_context + +```bash +mcp_tool "get_script_diagnostic_context" '{}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T127: check_for_updates + +```bash +mcp_tool "check_for_updates" '{}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T128: list_project_instructions + +```bash +mcp_tool "list_project_instructions" '{}' +``` + +- **判定**: R1-R5 + `data` 为数组 + +### T129: read_project_instruction + +```bash +# 如果 T128 返回有指令文件则读取第一个,否则 skip +mcp_tool "read_project_instruction" "{\"name\":\"\"}" +``` + +- **判定**: R1-R4(有指令文件时)或 skip(无指令文件时) + +### T130: capture_editor_screenshot + +```bash +mcp_tool "capture_editor_screenshot" '{}' +``` + +- **判定**: R1-R3 + `result.content` 包含 `type: "image"` 项 +- **注意**: 需要编辑器窗口可见 + +### T131: capture_scene_screenshot + +```bash +mcp_tool "capture_scene_screenshot" '{}' +``` + +- **判定**: R1-R3 + `result.content` 包含 `type: "image"` 项 + +### T132: capture_preview_screenshot + +```bash +mcp_tool "capture_preview_screenshot" '{}' +``` + +- **判定**: R1-R3 + `result.content` 包含 image 项 +- **注意**: 需要 preview 窗口可见;如无 preview 运行则 skip + +### T133: execute_javascript (scene context) + +```bash +mcp_tool "execute_javascript" '{"context":"scene","code":"return { sceneName: scene.name, childCount: scene.children.length };"}' +``` + +- **判定**: R1-R5 + `data.sceneName` 非空 + +### T134: execute_javascript (editor context) + +```bash +mcp_tool "execute_javascript" '{"context":"editor","code":"return { projectPath: context.projectPath, toolCount: helpers.listTools().length };"}' +``` + +- **判定**: R1-R5 + `data.projectPath` 非空 + `data.toolCount` > 0 + +### T135: execute_scene_script + +```bash +mcp_tool "execute_scene_script" '{"code":"return { sceneName: scene.name };"}' +``` + +- **判定**: R1-R5 + `data.sceneName` 非空 + +### T136: execute_editor_script + +```bash +mcp_tool "execute_editor_script" '{"code":"return { projectPath: context.projectPath };"}' +``` + +- **判定**: R1-R5 + `data.projectPath` 非空 + +### T137: open_asset + +```bash +# 使用 T108 记录的 asset uuid +mcp_tool "open_asset" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 + +### T138: select_asset + +```bash +mcp_tool "select_asset" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 + +--- + +## 5. Phase 2: Core 变更工具链式测试 + +> 以下测试有副作用,按链式顺序执行。前一步的输出作为后一步的输入。 + +### 链 A: 节点创建 → 变换 → 删除 + +#### T201: create_node + +```bash +mcp_tool "create_node" '{"name":"ChainTestNode","parentPath":"Canvas"}' +``` + +- **判定**: R1-R5 + `data.uuid` 非空 +- **记录**: `createdNodeUuid = data.uuid` + +#### T202: set_node_transform(依赖 T201) + +```bash +mcp_tool "set_node_transform" "{\"uuid\":\"\",\"position\":{\"x\":100,\"y\":200,\"z\":0},\"scale\":{\"x\":2,\"y\":2,\"z\":1}}" +``` + +- **判定**: R1-R4 +- **前置**: T201 通过 + +#### T203: delete_node(依赖 T201) + +```bash +mcp_tool "delete_node" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 +- **前置**: T201 通过 +- **清理**: 删除 T201 创建的节点 + +### 链 B: 组件添加 → 设置属性 → 移除 + +#### T204: add_component(依赖 T201 或使用已有的 TestNode) + +```bash +# 使用初始化时的 testNodeUuid +mcp_tool "add_component" "{\"path\":\"Canvas/TestNode\",\"componentType\":\"cc.Sprite\"}" +``` + +- **判定**: R1-R4 + +#### T205: set_component_property(依赖 T204) + +```bash +mcp_tool "set_component_property" "{\"path\":\"Canvas/TestNode\",\"componentType\":\"cc.Sprite\",\"property\":\"color\",\"value\":{\"r\":255,\"g\":0,\"b\":0,\"a\":255}}" +``` + +- **判定**: R1-R4 +- **前置**: T204 通过 + +#### T206: remove_component(依赖 T204) + +```bash +mcp_tool "remove_component" "{\"path\":\"Canvas/TestNode\",\"componentType\":\"cc.Sprite\"}" +``` + +- **判定**: R1-R4 +- **前置**: T204 通过 +- **清理**: 移除 T204 添加的组件 + +### T207: save_current_scene + +```bash +mcp_tool "save_current_scene" '{}' +``` + +- **判定**: R1-R4 +- **注意**: 保存前面所有变更 + +--- + +## 6. Phase 3: Full 额外只读工具测试 + +> 以下工具仅在 `full` 配置档中可用。 + +### T301: find_nodes + +```bash +mcp_tool "find_nodes" '{"name":"TestNode"}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 >= 1 + +### T302: inspect_node + +```bash +mcp_tool "inspect_node" '{"path":"Canvas/TestNode"}' +``` + +- **判定**: R1-R5 + `data.uuid` 非空 + `data.name` 为 `TestNode` + +### T303: list_components + +```bash +mcp_tool "list_components" '{"path":"Canvas/TestNode"}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 >= 1(至少有 UITransform) + +### T304: inspect_component + +```bash +mcp_tool "inspect_component" '{"path":"Canvas/TestNode","componentType":"cc.Label"}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T305: list_cameras + +```bash +mcp_tool "list_cameras" '{}' +``` + +- **判定**: R1-R5 + `data` 为数组 + +### T306: list_animations + +```bash +mcp_tool "list_animations" '{}' +``` + +- **判定**: R1-R5 + `data` 为数组 + +### T307: list_prefabs + +```bash +mcp_tool "list_prefabs" '{}' +``` + +- **判定**: R1-R5 + `data` 为数组 +- **记录**: 如有预制体,记录第一个的 uuid + +### T308: get_editor_selection + +```bash +mcp_tool "get_editor_selection" '{}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T309: exists + +```bash +mcp_tool "exists" '{"path":"assets/scripts/TestComponent.ts"}' +``` + +- **判定**: R1-R5 + `data.exists` 为 `true` + +### T310: read_file + +```bash +mcp_tool "read_file" '{"path":"assets/scripts/TestComponent.ts"}' +``` + +- **判定**: R1-R5 + `data.content` 包含 `TestComponent` + +### T311: get_file_snippet + +```bash +mcp_tool "get_file_snippet" '{"path":"assets/scripts/TestComponent.ts","line":3,"context":3}' +``` + +- **判定**: R1-R5 + `data` 存在 + +### T312: list_directory + +```bash +mcp_tool "list_directory" '{"path":"assets/scripts"}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 >= 1 + +### T313: search_files + +```bash +mcp_tool "search_files" '{"pattern":"*.ts"}' +``` + +- **判定**: R1-R5 + `data` 为数组 + 长度 >= 1 + +### T314: capture_desktop_screenshot + +```bash +mcp_tool "capture_desktop_screenshot" '{}' +``` + +- **判定**: R1-R3 + `result.content` 包含 image 项 + +### T315: capture_game_screenshot + +```bash +mcp_tool "capture_game_screenshot" '{}' +``` + +- **判定**: R1-R3 + `result.content` 包含 image 项 +- **注意**: 需要 Game 面板可见 + +--- + +## 7. Phase 4: Full 额外变更工具测试 + +> 以下工具会修改项目状态,按子阶段链式执行。 + +### 4a. UI 创建工具 + +#### T401: create_canvas + +```bash +mcp_tool "create_canvas" '{"name":"TestCanvas"}' +``` + +- **判定**: R1-R5 + `data.uuid` 非空 +- **记录**: `canvasUuid = data.uuid` + +#### T402: create_label(依赖 T401 或使用已有 Canvas) + +```bash +mcp_tool "create_label" '{"name":"TestLabel","parentPath":"Canvas","text":"Hello MCP"}' +``` + +- **判定**: R1-R5 + `data.uuid` 非空 + +#### T403: create_button(依赖 T401) + +```bash +mcp_tool "create_button" '{"name":"TestButton","parentPath":"Canvas","text":"Click Me"}' +``` + +- **判定**: R1-R5 + `data.uuid` 非空 +- **记录**: `buttonUuid = data.uuid`(供 4g 事件测试使用) + +#### T404: create_sprite(依赖 T401) + +```bash +mcp_tool "create_sprite" '{"name":"TestSprite","parentPath":"Canvas"}' +``` + +- **判定**: R1-R5 + `data.uuid` 非空 + +### 4b. 摄像机工具 + +#### T405: create_camera + +```bash +mcp_tool "create_camera" '{"name":"TestCamera"}' +``` + +- **判定**: R1-R5 + `data.uuid` 非空 +- **记录**: `cameraUuid = data.uuid` + +#### T406: set_camera_properties(依赖 T405) + +```bash +mcp_tool "set_camera_properties" "{\"uuid\":\"\",\"properties\":{\"projection\":1}}" +``` + +- **判定**: R1-R4 +- **前置**: T405 通过 + +### 4c. 动画工具 + +#### T407: add_animation_clip + +```bash +mcp_tool "add_animation_clip" '{"path":"Canvas/TestNode","clipName":"TestClip"}' +``` + +- **判定**: R1-R4 +- **注意**: 如节点无 Animation 组件,工具应自动添加或返回明确错误 + +#### T408: play_animation(依赖 T407) + +```bash +mcp_tool "play_animation" '{"path":"Canvas/TestNode","clipName":"TestClip"}' +``` + +- **判定**: R1-R4 +- **前置**: T407 通过 + +#### T409: stop_animation(依赖 T407) + +```bash +mcp_tool "stop_animation" '{"path":"Canvas/TestNode","clipName":"TestClip"}' +``` + +- **判定**: R1-R4 +- **前置**: T407 通过 + +### 4d. 预制体生命周期测试 + +> **前置条件**: 1.6 测试数据初始化已创建 `TestPrefab.prefab`,AI Agent 已记录 `prefabUuid` 和 `prefabPath`。 +> +> 本阶段测试完整的预制体生命周期:创建验证 → 实例化 → 修改实例 → 应用回预制体 → 验证保存 → 还原 → JSON编辑 → 复制 → 引用验证。 + +#### T410: instantiate_prefab — 实例化预制体到场景 + +```bash +# 使用初始化记录的 prefabUuid +mcp_tool "instantiate_prefab" "{\"prefabUuid\":\"\",\"parentPath\":\"Canvas\"}" +``` + +- **判定**: R1-R5 + `data.uuid` 非空 +- **记录**: `instance1Uuid = data.uuid`,`instance1Path = "Canvas/TestPrefab"`(或返回的实际路径) + +#### T411: create_prefab_instance — 创建链接预制体实例 + +```bash +mcp_tool "create_prefab_instance" "{\"prefabUuid\":\"\",\"parentPath\":\"Canvas\"}" +``` + +- **判定**: R1-R5 + `data.uuid` 非空 +- **记录**: `instance2Uuid = data.uuid`,`instance2Path = "Canvas/TestPrefab"`(或返回的实际路径) +- **注意**: 如与 T410 实例名冲突,AI Agent 应使用不同名称或跳过此步 + +#### T412: inspect_prefab_instance — 检查实例链接状态(依赖 T410) + +```bash +mcp_tool "inspect_prefab_instance" "{\"path\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 存在 + `data.isPrefabInstance` 为 `true` 或包含预制体关联信息 +- **前置**: T410 通过 + +#### T413: set_node_transform — 修改实例节点属性(依赖 T410) + +```bash +# 修改实例节点的位置,作为待应用的变更 +mcp_tool "set_node_transform" "{\"uuid\":\"\",\"position\":{\"x\":200,\"y\":300,\"z\":0},\"scale\":{\"x\":1.5,\"y\":1.5,\"z\":1}}" +``` + +- **判定**: R1-R4 +- **前置**: T410 通过 +- **记录**: 修改的位置值 `modifiedPosition = {x:200, y:300}`,供 T415 验证 + +#### T414: apply_prefab_instance — 将修改应用回预制体(依赖 T413) + +```bash +mcp_tool "apply_prefab_instance" "{\"path\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 存在 +- **前置**: T413 通过 +- **说明**: 此操作将实例上的变更保存回 `.prefab` 源文件,等同于"保存预制体" + +#### T415: inspect_prefab — 验证修改已保存到预制体(依赖 T410, T414) + +```bash +mcp_tool "inspect_prefab" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 存在 + 包含预制体序列化信息 +- **前置**: T414 通过 +- **验证点**: 预制体数据应反映 T413 的修改(位置/缩放变更) + +#### T416: read_file — 读取 .prefab 文件验证持久化(依赖 T414) + +```bash +mcp_tool "read_file" '{"path":"assets/prefabs/TestPrefab.prefab"}' +``` + +- **判定**: R1-R5 + `data.content` 非空 + 包含有效的 JSON 结构 +- **前置**: T414 通过 +- **验证点**: 文件内容应包含 T413 修改的位置值(`"x":200` 或 `"y":300`) +- **说明**: 这是验证预制体确实已保存到磁盘的关键步骤 + +#### T417: revert_prefab_instance — 还原实例到预制体状态(依赖 T410) + +```bash +mcp_tool "revert_prefab_instance" "{\"path\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 存在 +- **前置**: T410 通过 +- **验证点**: 实例节点应恢复为预制体的原始状态(T413 的修改被撤销) +- **说明**: revert 撤销的是实例上的 override,不影响已 apply 的预制体文件 + +#### T418: edit_prefab_json — 直接编辑预制体 JSON(依赖 T410) + +```bash +mcp_tool "edit_prefab_json" '{"prefabPath":"db://assets/prefabs/TestPrefab.prefab","jsonPath":"_name","value":"EditedTestPrefab"}' +``` + +- **判定**: R1-R5 + `data` 存在 +- **前置**: T410 通过 +- **说明**: 直接修改 .prefab 文件的 JSON 字段,绕过场景编辑 + +#### T419: inspect_prefab — 验证 JSON 编辑生效(依赖 T418) + +```bash +mcp_tool "inspect_prefab" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 存在 +- **前置**: T418 通过 +- **验证点**: 预制体名称应反映 T418 的修改(`EditedTestPrefab`) +- **说明**: 也可通过 `read_file` 读取 .prefab 文件验证 `_name` 字段已变更 + +#### T420: duplicate_prefab — 复制预制体(依赖 T410) + +```bash +mcp_tool "duplicate_prefab" "{\"prefabUuid\":\"\",\"newName\":\"TestPrefabCopy\"}" +``` + +- **判定**: R1-R5 + `data` 存在 + `data.uuid` 或 `data.url` 非空 +- **前置**: T410 通过 +- **记录**: `duplicatedPrefabUuid = data.uuid`(如有) +- **验证点**: 新预制体应存在于 `assets/prefabs/` 目录,且 UUID 与原预制体不同 + +#### T421: validate_prefab_references — 验证预制体引用完整性(依赖 T410) + +```bash +mcp_tool "validate_prefab_references" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 存在 + 无断引错误 +- **前置**: T410 通过 +- **说明**: 检查预制体内部的所有 UUID 引用是否有效 + +#### T422: validate_prefab_references — 验证复制的预制体引用(依赖 T420) + +```bash +# 使用 T420 返回的 duplicatedPrefabUuid +mcp_tool "validate_prefab_references" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R5 + `data` 存在 + 无断引错误 +- **前置**: T420 通过 +- **清理**: 测试完成后可删除复制的预制体(`delete_asset`) + +### 4e. 文件工具 + +#### T423: write_file + +```bash +mcp_tool "write_file" '{"path":"assets/scripts/test-output.txt","content":"MCP test output\n"}' +``` + +- **判定**: R1-R4 + +#### T424: replace_in_file + +```bash +mcp_tool "replace_in_file" '{"path":"assets/scripts/test-output.txt","search":"MCP test output","replace":"MCP test output (updated)"}' +``` + +- **判定**: R1-R4 +- **前置**: T423 通过 + +#### T425: refresh_assets + +```bash +mcp_tool "refresh_assets" '{"path":"assets/scripts"}' +``` + +- **判定**: R1-R4 + +### 4f. 资源操作工具 + +#### T426: open_asset(已在 T137 测试,此处跳过) + +#### T427: select_asset(已在 T138 测试,此处跳过) + +#### T428: delete_asset + +```bash +# 删除 T423 创建的测试文件 +mcp_tool "delete_asset" '{"url":"db://assets/scripts/test-output.txt"}' +``` + +- **判定**: R1-R4 +- **清理**: 删除测试文件 + +### 4g. 事件工具 + +#### T429: bind_button_click_event(依赖 T403) + +```bash +mcp_tool "bind_button_click_event" "{\"buttonPath\":\"Canvas/TestButton\",\"targetPath\":\"Canvas/TestNode\",\"componentName\":\"TestComponent\",\"handler\":\"greet\"}" +``` + +- **判定**: R1-R4 +- **前置**: T403 通过 + TestComponent 脚本已编译 +- **注意**: 如 TestComponent 未编译完成则 skip + +#### T430: list_button_click_events(依赖 T429) + +```bash +mcp_tool "list_button_click_events" '{"path":"Canvas/TestButton"}' +``` + +- **判定**: R1-R5 + `data` 为数组 +- **前置**: T429 通过 + +#### T431: simulate_button_click(依赖 T403) + +```bash +mcp_tool "simulate_button_click" '{"path":"Canvas/TestButton"}' +``` + +- **判定**: R1-R4 +- **前置**: T403 通过 + +#### T432: emit_node_event + +```bash +mcp_tool "emit_node_event" '{"path":"Canvas/TestNode","eventName":"test-event","data":{"value":42}}' +``` + +- **判定**: R1-R4 + +### 4h. 组件方法调用 + +#### T433: invoke_component_method + +```bash +mcp_tool "invoke_component_method" '{"path":"Canvas/TestNode","componentType":"cc.Label","method":"toString"}' +``` + +- **判定**: R1-R4 +- **注意**: 调用 Label 组件的 toString 方法作为安全测试 + +### 4i. 运行时控制工具 + +#### T434: pause_runtime + +```bash +mcp_tool "pause_runtime" '{}' +``` + +- **判定**: R1-R4 +- **注意**: 需要 preview 运行中;如未运行则 skip + +#### T435: set_time_scale(依赖 T434) + +```bash +mcp_tool "set_time_scale" '{"scale":0.5}' +``` + +- **判定**: R1-R4 +- **前置**: T434 通过或 skip + +#### T436: resume_runtime + +```bash +mcp_tool "resume_runtime" '{}' +``` + +- **判定**: R1-R4 + +#### T437: run_scene_asset + +```bash +# 使用 T106 记录的场景 uuid +mcp_tool "run_scene_asset" "{\"uuid\":\"\"}" +``` + +- **判定**: R1-R4 +- **注意**: 会重新加载场景 + +### 4j. 输入模拟工具 + +> **前置条件**: 需要 Cocos Creator 编辑器窗口可见且处于前台。 + +#### T438: simulate_mouse_click + +```bash +mcp_tool "simulate_mouse_click" '{"x":400,"y":300,"windowKind":"editor"}' +``` + +- **判定**: R1-R4 +- **注意**: 需要编辑器窗口可见 + +#### T439: simulate_mouse_drag + +```bash +mcp_tool "simulate_mouse_drag" '{"startX":100,"startY":100,"endX":300,"endY":300,"windowKind":"editor"}' +``` + +- **判定**: R1-R4 + +#### T440: simulate_key_press + +```bash +mcp_tool "simulate_key_press" '{"keyCode":"S","modifiers":["control"],"windowKind":"editor"}' +``` + +- **判定**: R1-R4 +- **注意**: Ctrl+S 会触发保存场景 + +#### T441: simulate_key_combo + +```bash +mcp_tool "simulate_key_combo" '{"keyCode":"P","modifiers":["control"],"windowKind":"editor"}' +``` + +- **判定**: R1-R4 + +#### T442: simulate_preview_input + +```bash +mcp_tool "simulate_preview_input" '{"mode":"click","x":200,"y":200}' +``` + +- **判定**: R1-R4 +- **注意**: 需要 preview 窗口运行 + +### 4k. 构建与编辑器工具 + +#### T443: open_build_panel + +```bash +mcp_tool "open_build_panel" '{}' +``` + +- **判定**: R1-R4 +- **注意**: 会打开构建面板 + +#### T444: run_project_preview + +```bash +mcp_tool "run_project_preview" '{}' +``` + +- **判定**: R1-R4 +- **注意**: 会启动预览;后续依赖 preview 的测试(T434-T442)应在此之后执行 + +### 4l. 偏好与广播工具 + +#### T445: get_editor_preference + +```bash +mcp_tool "get_editor_preference" '{"name":"general","path":"language"}' +``` + +- **判定**: R1-R4 + `data` 存在 + +#### T446: set_editor_preference + +```bash +mcp_tool "set_editor_preference" '{"name":"general","path":"language","value":"zh"}' +``` + +- **判定**: R1-R4 +- **清理**: 将语言设回原值 + +#### T447: broadcast_editor_message + +```bash +mcp_tool "broadcast_editor_message" '{"message":"scene:ready","data":{"test":true}}' +``` + +- **判定**: R1-R4 + +### 4m. 项目指令工具 + +#### T448: write_project_instruction + +```bash +mcp_tool "write_project_instruction" '{"name":"AGENTS.md","content":"# Test Instructions\nThis is a test.\n"}' +``` + +- **判定**: R1-R4 + +#### T449: create_project_skill + +```bash +mcp_tool "create_project_skill" '{"skillName":"test-skill","content":"# Test Skill\nTest content.\n"}' +``` + +- **判定**: R1-R4 + +#### T450: create_cocos_mcp_project_skill + +```bash +mcp_tool "create_cocos_mcp_project_skill" '{}' +``` + +- **判定**: R1-R4 + +#### T451: reset_component_property + +```bash +mcp_tool "reset_component_property" '{"path":"Canvas/TestNode","componentType":"cc.Label","property":"string"}' +``` + +- **判定**: R1-R4 + +--- + +## 8. Phase 5: MCP Resources 测试 + +### T501: cocos://project/context + +```bash +mcp_resource "cocos://project/context" +``` + +- **判定**: R1-R2 + `result.contents` 为数组 + 首项有 `text` 字段 + 非空 + +### T502: cocos://project/summary + +```bash +mcp_resource "cocos://project/summary" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T503: cocos://scene/active + +```bash +mcp_resource "cocos://scene/active" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T504: cocos://scene/current + +```bash +mcp_resource "cocos://scene/current" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T505: cocos://selection/current + +```bash +mcp_resource "cocos://selection/current" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T506: cocos://selection/asset + +```bash +mcp_resource "cocos://selection/asset" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T507: cocos://errors/scripts + +```bash +mcp_resource "cocos://errors/scripts" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T508: cocos://logs/editor + +```bash +mcp_resource "cocos://logs/editor" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T509: cocos://logs/project + +```bash +mcp_resource "cocos://logs/project" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T510: cocos://mcp/interactions + +```bash +mcp_resource "cocos://mcp/interactions" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +### T511: Resource Template — cocos://scene/node/{path} + +```bash +mcp_resource "cocos://scene/node/Canvas/TestNode" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + 包含 `TestNode` + +### T512: Resource Template — cocos://asset/path/{relative_path} + +```bash +mcp_resource "cocos://asset/path/assets/scripts/TestComponent.ts" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + 包含 `TestComponent` + +### T513: Resource Template — cocos://asset/info/{uuid_or_path} + +```bash +mcp_resource "cocos://asset/info/db://assets/scene.scene" +``` + +- **判定**: R1-R2 + `result.contents[0].text` 非空 + +--- + +## 9. Phase 6: MCP Prompts 测试 + +### T601: fix_script_errors + +```bash +mcp_prompt "fix_script_errors" +``` + +- **判定**: R1-R2 + `result.messages` 为数组 + 长度 >= 1 + 首项有 `content.text` + 包含 `execute_javascript` + +### T602: create_playable_prototype + +```bash +mcp_prompt "create_playable_prototype" +``` + +- **判定**: R1-R2 + `result.messages[0].content.text` 非空 + 包含 `execute_javascript` + +### T603: scene_validation + +```bash +mcp_prompt "scene_validation" +``` + +- **判定**: R1-R2 + `result.messages[0].content.text` 非空 + 包含 `execute_javascript` + +### T604: auto_wire_scene + +```bash +mcp_prompt "auto_wire_scene" +``` + +- **判定**: R1-R2 + `result.messages[0].content.text` 非空 + 包含 `execute_javascript` + +--- + +## 10. 测试报告与结论 + +### 10.1 汇总统计 + +测试全部完成后,AI Agent 输出以下汇总: + +``` +========== funplay-cocos-mcp 集成测试报告 ========== +测试时间: +Cocos Creator 版本: <从 T101 获取> +MCP 服务器版本: <从 T001 获取> +工具配置档: full +测试端口: <实际端口> +==================================================== +Phase 0 (协议合规): /8 通过 +Phase 1 (Core 只读): /38 通过 +Phase 2 (Core 变更): /7 通过 +Phase 3 (Full 只读): /15 通过 +Phase 4 (Full 变更): /51 通过 +Phase 5 (Resources): /13 通过 +Phase 6 (Prompts): /4 通过 +---------------------------------------------------- +总计: / 通过, 失败, 跳过 +==================================================== +``` + +### 10.2 失败详情格式 + +每个失败项输出: + +``` +[FAIL] + 调用: + 参数: + 响应: + 判定: <哪条规则失败> + 可能原因: +``` + +### 10.3 兼容性结论模板 + +``` +兼容性结论: + +依据: +- : 全部 101 个工具 + 13 个资源 + 4 个 prompt 测试通过 +- : 核心功能通过,但有 个工具失败,失败工具列表: <...> +- : 协议层或核心工具大面积失败 + +3.8.8 特定问题: +- <列出仅在 3.8.8 下出现的问题,如 API 不兼容、方法不存在等> + +建议: +- <基于测试结果给出的建议> +``` + +### 10.4 结果文件格式 + +完整测试结果以 JSON 文件保存: + +```json +{ + "testDate": "2026-06-30T12:00:00Z", + "cocosVersion": "3.8.8", + "mcpVersion": "0.4.0", + "toolProfile": "full", + "serverPort": 8765, + "summary": { + "total": 136, + "passed": 125, + "failed": 3, + "skipped": 2 + }, + "results": [ + { + "id": "T001", + "phase": 0, + "tool": "initialize", + "status": "pass", + "duration_ms": 120, + "detail": "protocol 2025-11-25 negotiated" + }, + { + "id": "T201", + "phase": 2, + "tool": "create_node", + "status": "fail", + "duration_ms": 5000, + "detail": "ok=false, error=Scene not loaded", + "response": "..." + } + ], + "conclusion": "PARTIAL", + "issues": [ + "T201 create_node: Scene not loaded - 可能需要先 open_scene", + "T432 simulate_mouse_click: No visible window - 需要编辑器窗口在前台" + ] +} +``` + +--- + +## 附录 A: 测试用例索引 + +| Phase | 范围 | 用例编号 | 数量 | +|---|---|---|---:| +| 0 | MCP 协议合规 | T001-T008 | 8 | +| 1 | Core 只读工具 | T101-T138 | 38 | +| 2 | Core 变更工具(链式) | T201-T207 | 7 | +| 3 | Full 额外只读 | T301-T315 | 15 | +| 4 | Full 额外变更 | T401-T451 | 51 | +| 5 | MCP Resources | T501-T513 | 13 | +| 6 | MCP Prompts | T601-T604 | 4 | +| **总计** | | | **136** | + +## 附录 B: 工具与测试用例映射 + +| 工具名 | 配置档 | 测试 ID | 类型 | +|---|---|---|---| +| initialize | — | T001 | 协议 | +| tools/list | — | T002 | 协议 | +| resources/list | — | T003 | 协议 | +| resources/templates/list | — | T004 | 协议 | +| prompts/list | — | T005 | 协议 | +| (Accept header) | — | T006 | 协议 | +| (unknown method) | — | T007 | 协议 | +| GET /tools | — | T008 | 协议 | +| get_project_info | core | T101 | 只读 | +| get_editor_state | core | T102 | 只读 | +| get_tool_catalog | core | T103 | 只读 | +| get_scene_info | core | T104 | 只读 | +| get_hierarchy | core | T105 | 只读 | +| list_scenes | core | T106 | 只读 | +| open_scene | core | T107 | 状态 | +| list_assets | core | T108 | 只读 | +| inspect_asset | core | T109 | 只读 | +| inspect_asset_dependencies | core | T110 | 只读 | +| validate_asset_dependencies | core | T111 | 只读 | +| inspect_prefab | core | T112 | 只读 | +| inspect_prefab_instance | core | T113 | 只读 | +| validate_prefab_references | core | T114 | 只读 | +| validate_scene | core | T115 | 只读 | +| get_selection | core | T116 | 只读 | +| set_selection | core | T117 | 变更 | +| get_runtime_state | core | T118 | 只读 | +| get_performance_snapshot | core | T119 | 只读 | +| get_build_status | core | T120 | 只读 | +| list_editor_windows | core | T121 | 只读 | +| get_recent_logs | core | T122 | 只读 | +| search_project_logs | core | T123 | 只读 | +| clear_logs | core | T124 | 变更 | +| run_script_diagnostics | core | T125 | 只读 | +| get_script_diagnostic_context | core | T126 | 只读 | +| check_for_updates | core | T127 | 只读 | +| list_project_instructions | core | T128 | 只读 | +| read_project_instruction | core | T129 | 只读 | +| capture_editor_screenshot | core | T130 | 只读 | +| capture_scene_screenshot | core | T131 | 只读 | +| capture_preview_screenshot | core | T132 | 只读 | +| execute_javascript (scene) | core | T133 | 执行 | +| execute_javascript (editor) | core | T134 | 执行 | +| execute_scene_script | core | T135 | 执行 | +| execute_editor_script | core | T136 | 执行 | +| open_asset | core | T137 | 状态 | +| select_asset | core | T138 | 状态 | +| create_node | full | T201 | 变更 | +| set_node_transform | full | T202 | 变更 | +| delete_node | full | T203 | 变更 | +| add_component | full | T204 | 变更 | +| set_component_property | full | T205 | 变更 | +| remove_component | full | T206 | 变更 | +| save_current_scene | full | T207 | 状态 | +| find_nodes | full | T301 | 只读 | +| inspect_node | full | T302 | 只读 | +| list_components | full | T303 | 只读 | +| inspect_component | full | T304 | 只读 | +| list_cameras | full | T305 | 只读 | +| list_animations | full | T306 | 只读 | +| list_prefabs | full | T307 | 只读 | +| get_editor_selection | full | T308 | 只读 | +| exists | full | T309 | 只读 | +| read_file | full | T310 | 只读 | +| get_file_snippet | full | T311 | 只读 | +| list_directory | full | T312 | 只读 | +| search_files | full | T313 | 只读 | +| capture_desktop_screenshot | full | T314 | 只读 | +| capture_game_screenshot | full | T315 | 只读 | +| create_canvas | full | T401 | 变更 | +| create_label | full | T402 | 变更 | +| create_button | full | T403 | 变更 | +| create_sprite | full | T404 | 变更 | +| create_camera | full | T405 | 变更 | +| set_camera_properties | full | T406 | 变更 | +| add_animation_clip | full | T407 | 变更 | +| play_animation | full | T408 | 变更 | +| stop_animation | full | T409 | 变更 | +| instantiate_prefab | full | T410 | 变更 | +| create_prefab_instance | full | T411 | 变更 | +| inspect_prefab_instance | full | T412 | 只读 | +| set_node_transform (实例修改) | full | T413 | 变更 | +| apply_prefab_instance | full | T414 | 变更 | +| inspect_prefab (验证保存) | full | T415 | 只读 | +| read_file (验证 .prefab 持久化) | full | T416 | 只读 | +| revert_prefab_instance | full | T417 | 变更 | +| edit_prefab_json | full | T418 | 变更 | +| inspect_prefab (验证 JSON 编辑) | full | T419 | 只读 | +| duplicate_prefab | full | T420 | 变更 | +| validate_prefab_references | full | T421, T422 | 只读 | +| write_file | full | T423 | 变更 | +| replace_in_file | full | T424 | 变更 | +| refresh_assets | full | T425 | 状态 | +| delete_asset | full | T428 | 变更 | +| bind_button_click_event | full | T429 | 变更 | +| list_button_click_events | full | T430 | 只读 | +| simulate_button_click | full | T431 | 变更 | +| emit_node_event | full | T432 | 变更 | +| invoke_component_method | full | T433 | 变更 | +| pause_runtime | full | T434 | 变更 | +| set_time_scale | full | T435 | 变更 | +| resume_runtime | full | T436 | 变更 | +| run_scene_asset | full | T437 | 变更 | +| simulate_mouse_click | full | T438 | 变更 | +| simulate_mouse_drag | full | T439 | 变更 | +| simulate_key_press | full | T440 | 变更 | +| simulate_key_combo | full | T441 | 变更 | +| simulate_preview_input | full | T442 | 变更 | +| open_build_panel | full | T443 | 状态 | +| run_project_preview | full | T444 | 状态 | +| get_editor_preference | full | T445 | 只读 | +| set_editor_preference | full | T446 | 变更 | +| broadcast_editor_message | full | T447 | 状态 | +| write_project_instruction | full | T448 | 变更 | +| create_project_skill | full | T449 | 变更 | +| create_cocos_mcp_project_skill | full | T450 | 变更 | +| reset_component_property | full | T451 | 变更 | + +## 附录 C: AI Agent 执行注意事项 + +### C.1 环境依赖 + +| 测试阶段 | 环境要求 | 失败处理 | +|---|---|---| +| Phase 0 | MCP 服务器运行 | 全部中止 | +| Phase 1 | 场景已加载 | 场景相关测试 skip | +| Phase 2 | 场景已加载 | 链式 skip | +| Phase 3 | 场景已加载 + 测试脚本已编译 | 脚本相关 skip | +| Phase 4a-4d | 场景已加载 + 预制体已创建 | 链式 skip | +| Phase 4i | preview 运行中 | skip | +| Phase 4j | 编辑器窗口可见且前台 | skip | +| Phase 5 | 服务器运行 | 全部 skip | +| Phase 6 | 服务器运行 | 全部 skip | + +### C.2 链式测试依赖图 + +``` +T106 (list_scenes) ──→ T107 (open_scene) + ──→ T437 (run_scene_asset) + +T108 (list_assets) ──→ T109 (inspect_asset) + ──→ T110 (inspect_asset_dependencies) + ──→ T111 (validate_asset_dependencies) + ──→ T137 (open_asset) + ──→ T138 (select_asset) + +T201 (create_node) ──→ T202 (set_node_transform) + ──→ T203 (delete_node) + +T204 (add_component) ──→ T205 (set_component_property) + ──→ T206 (remove_component) + +T307 (list_prefabs) ──→ T112 (inspect_prefab) + ──→ T114 (validate_prefab_references) + +T410 (instantiate_prefab) ──→ T412 (inspect_prefab_instance) + ──→ T413 (set_node_transform 修改实例) + ──→ T414 (apply_prefab_instance 保存回预制体) + │ ──→ T415 (inspect_prefab 验证已保存) + │ ──→ T416 (read_file 验证 .prefab 持久化) + ──→ T417 (revert_prefab_instance 还原实例) + +T418 (edit_prefab_json) ──→ T419 (inspect_prefab 验证 JSON 编辑) + +T420 (duplicate_prefab) ──→ T422 (validate_prefab_references 验证副本) + +T421 (validate_prefab_references 验证原件) + +T401 (create_canvas) ──→ T402 (create_label) + ──→ T403 (create_button) ──→ T429 (bind_button_click_event) + │ ──→ T431 (simulate_button_click) + └──→ T404 (create_sprite) + +T405 (create_camera) ──→ T406 (set_camera_properties) + +T407 (add_animation_clip) ──→ T408 (play_animation) + ──→ T409 (stop_animation) + +T423 (write_file) ──→ T424 (replace_in_file) + ──→ T428 (delete_asset) [清理] + +T434 (pause_runtime) ──→ T435 (set_time_scale) + ──→ T436 (resume_runtime) + +T444 (run_project_preview) ──→ T434-T442 (需要 preview 运行的测试) +``` + +### C.3 推荐执行顺序 + +为最大化测试覆盖并减少 skip,建议按以下顺序执行: + +``` +1. Phase 0 (协议) — 必须全部通过 +2. 测试数据初始化 (1.6 节) — 含预制体创建 +3. Phase 1 (Core 只读) — 先 T106 拿场景 uuid, T108 拿资产 uuid +4. Phase 3 (Full 只读) — T307 验证预制体已创建 +5. Phase 2 (Core 变更链式) — T201→T202→T203, T204→T205→T206 +6. Phase 4a-4c (UI/摄像机/动画) +7. Phase 4d (预制体生命周期) — T410→T412→T413→T414→T415→T416, T417, T418→T419, T420→T422, T421 +8. Phase 4e-4h (文件/资源/事件/组件方法) +9. T444 (run_project_preview) — 启动 preview +10. Phase 4i (Runtime) — 需要 preview 运行 +11. Phase 4j (Input) — 需要 preview 运行 + 窗口可见 +12. Phase 4k-4m (Build/Preference/Instruction) +13. Phase 5 (Resources) +12. Phase 6 (Prompts) +13. 生成测试报告 +``` + +### C.4 错误处理策略 + +| 场景 | 策略 | +|---|---| +| 协议测试失败 | 中止全部测试,报告协议不兼容 | +| 场景未加载 | 跳过所有场景相关测试,继续非场景测试 | +| 预制体不存在 | 跳过预制体相关测试,记录 skip | +| preview 未运行 | 先执行 T438 启动 preview,再重试 | +| 窗口不可见 | 跳过截图和输入模拟测试 | +| 链式前驱失败 | 后续依赖步骤标记 skip | +| 超时(>30秒无响应) | 标记 fail,记录超时,继续下一个 | +| HTTP 500 | 标记 fail,记录响应体,继续 | + +### C.5 execute_javascript 链式编排示例 + +对于需要多步链式操作且数据传递复杂的场景,可使用 execute_javascript 在编辑器上下文中一次性完成: + +```bash +# 示例:创建节点 → 添加组件 → 设置属性 → 验证 → 清理,一步完成 +mcp_tool "execute_javascript" '{ + "context": "editor", + "code": "const createResult = await helpers.callTool(\"create_node\", {name:\"ChainNode\",parentPath:\"Canvas\"}); const createData = JSON.parse(createResult); const nodeUuid = createData.data.uuid; await helpers.callTool(\"add_component\", {path:\"Canvas/ChainNode\",componentType:\"cc.Sprite\"}); await helpers.callTool(\"set_component_property\", {path:\"Canvas/ChainNode\",componentType:\"cc.Sprite\",property:\"color\",value:{r:255,g:0,b:0,a:255}}); const inspectResult = await helpers.callTool(\"inspect_component\", {path:\"Canvas/ChainNode\",componentType:\"cc.Sprite\"}); const inspectData = JSON.parse(inspectResult); await helpers.callTool(\"remove_component\", {path:\"Canvas/ChainNode\",componentType:\"cc.Sprite\"}); await helpers.callTool(\"delete_node\", {uuid:nodeUuid}); return { created: true, inspected: inspectData.ok, cleaned: true };" +}' +``` + +- **判定**: R1-R5 + `data.created === true` + `data.inspected === true` + `data.cleaned === true` +- **优势**: 减少网络往返,原子性更好,适合复杂链式验证 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b448fcf..618b7db 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -2,18 +2,18 @@ -Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 tools; the `full` profile exposes 101 tools. +Generated from `lib/tool-registry.js`. The default `core` profile exposes 38 tools; the `full` profile exposes 110 tools. ## Profile Summary | Profile | Tool Count | Purpose | |---|---:|---| -| `core` | 37 | Focused default surface for common editor automation. | -| `full` | 101 | All built-in tools, including destructive and low-level helpers. | +| `core` | 38 | Focused default surface for common editor automation. | +| `full` | 110 | All built-in tools, including destructive and low-level helpers. | ## Core Tools -`capture_editor_screenshot`, `capture_preview_screenshot`, `capture_scene_screenshot`, `check_for_updates`, `clear_logs`, `execute_editor_script`, `execute_javascript`, `execute_scene_script`, `get_build_status`, `get_editor_state`, `get_hierarchy`, `get_performance_snapshot`, `get_project_info`, `get_recent_logs`, `get_runtime_state`, `get_scene_info`, `get_script_diagnostic_context`, `get_selection`, `get_tool_catalog`, `inspect_asset`, `inspect_asset_dependencies`, `inspect_prefab`, `inspect_prefab_instance`, `list_assets`, `list_editor_windows`, `list_project_instructions`, `list_scenes`, `open_asset`, `open_scene`, `read_project_instruction`, `run_script_diagnostics`, `search_project_logs`, `select_asset`, `set_selection`, `validate_asset_dependencies`, `validate_prefab_references`, `validate_scene` +`capture_editor_screenshot`, `capture_preview_screenshot`, `capture_scene_screenshot`, `check_for_updates`, `clear_logs`, `execute_editor_script`, `execute_javascript`, `execute_scene_script`, `get_build_status`, `get_editor_state`, `get_hierarchy`, `get_performance_snapshot`, `get_project_info`, `get_recent_logs`, `get_runtime_state`, `get_scene_info`, `get_script_diagnostic_context`, `get_selection`, `get_tool_catalog`, `inspect_asset`, `inspect_asset_dependencies`, `inspect_prefab`, `inspect_prefab_instance`, `list_assets`, `list_editor_windows`, `list_project_instructions`, `list_scenes`, `open_asset`, `open_scene`, `query_scene_state`, `read_project_instruction`, `run_script_diagnostics`, `search_project_logs`, `select_asset`, `set_selection`, `validate_asset_dependencies`, `validate_prefab_references`, `validate_scene` ## Tools By Category @@ -30,7 +30,10 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 too | Tool | Profiles | Access | Description | |---|---|---|---| +| `batch_asset_ops` | `full` | mutating | [core] Batch import or delete assets in the Cocos asset database. | +| `create_scene` | `full` | stateful | [core] Create a new scene asset in the Cocos project and optionally open it. | | `delete_asset` | `full` | mutating | Delete an asset from asset-db by uuid, db url, or path. | +| `find_unused_assets` | `full` | read-only | [core] Find assets not referenced by any scene, prefab, or animation in the project. | | `inspect_asset` | `core`, `full` | read-only | [specialist] Inspect asset-db info, metadata, and serialized asset data by uuid or path. Prefer this when you need a precise structured asset read. | | `inspect_asset_dependencies` | `core`, `full` | read-only | [specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset. | | `list_assets` | `core`, `full` | read-only | [specialist] Query project assets from asset-db by pattern or asset type. Prefer this when you need exact asset discovery; otherwise use execute_javascript for broader automation. | @@ -146,6 +149,7 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 too | Tool | Profiles | Access | Description | |---|---|---|---| +| `create_script` | `full` | stateful | [core] Create a new TypeScript component script with a standard Cocos template. | | `get_performance_snapshot` | `core`, `full` | read-only | [specialist] Return scene scale and runtime performance-oriented counters such as node/component counts, UI counts, depth, memory, and warnings. | | `list_editor_windows` | `core`, `full` | read-only | [specialist] List available Electron windows so screenshots or input-targeting can choose the correct window. Use this when window targeting is the explicit problem. | @@ -154,6 +158,7 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 too | Tool | Profiles | Access | Description | |---|---|---|---| | `apply_prefab_instance` | `full` | stateful | [core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message. | +| `create_prefab` | `full` | stateful | [core] Create a prefab asset from a scene node. | | `create_prefab_instance` | `full` | stateful | [core] Create a linked prefab instance in the editor hierarchy using Cocos scene create-node when available. | | `duplicate_prefab` | `full` | stateful | [core] Create a new prefab asset by duplicating an existing prefab file without copying its .meta UUID. | | `edit_prefab_json` | `full` | stateful | [core] Edit a prefab JSON file by JSON path assignment or literal search/replace, then validate references. | @@ -191,6 +196,7 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 too | Tool | Profiles | Access | Description | |---|---|---|---| +| `copy_paste_node` | `full` | stateful | [core] Copy, cut, or paste scene nodes via the Cocos editor clipboard. | | `create_node` | `full` | stateful | Create a new node under the active scene or a specified parent path. | | `delete_node` | `full` | mutating | Delete a node by path, uuid, or name. | | `execute_scene_script` | `core`, `full` | mutating | [compat] Execute JavaScript in the active Cocos scene context. Prefer execute_javascript with context="scene" as the main unified tool; use this when you specifically want the scene-only compatibility entrypoint. | @@ -198,6 +204,9 @@ Generated from `lib/tool-registry.js`. The default `core` profile exposes 37 too | `get_hierarchy` | `core`, `full` | read-only | [specialist] Return a structured hierarchy tree from the active scene or a specific node path. Prefer execute_javascript for broader reasoning or repair; use this when you want a predictable hierarchy snapshot. | | `get_scene_info` | `core`, `full` | read-only | [specialist] Return a structured summary of the active Cocos scene. Prefer execute_javascript for multi-step inspection or mutation; use this when you specifically want a compact scene snapshot. | | `inspect_node` | `full` | read-only | [core] Inspect a specific node by path, uuid, or name. | +| `query_scene_state` | `core`, `full` | stateful | [specialist] Query scene state: dirty (unsaved changes), ready, or soft-reload. | +| `rename_node` | `full` | stateful | [core] Rename a scene node. | +| `reparent_node` | `full` | stateful | [core] Move a node to a new parent in the scene hierarchy. | | `set_node_transform` | `full` | mutating | Update node position, rotation, scale, or active state. | ### Screenshots diff --git a/docs/editor-messages-3.8.8.md b/docs/editor-messages-3.8.8.md new file mode 100644 index 0000000..b81ac64 --- /dev/null +++ b/docs/editor-messages-3.8.8.md @@ -0,0 +1,722 @@ +# Cocos Creator 3.8.8 编辑器 Message 列表 + +> 数据来源: Cocos Creator 3.8.8 编辑器 app.asar 内置扩展包 +> 提取时间: 2026-06-30 +> i18n 已解析为中文文本 + +## 目录 + +1. [animation-graph](#animation-graph) (10 messages) +2. [animator](#animator) (38 messages) +3. [asset-db](#asset-db) (66 messages) +4. [builder](#builder) (59 messages) +5. [engine](#engine) (12 messages) +6. [information](#information) (4 messages) +7. [menu](#menu) (3 messages) +8. [messages](#messages) (11 messages) +9. [metrics](#metrics) (11 messages) +10. [placeholder](#placeholder) (1 messages) +11. [preferences](#preferences) (10 messages) +12. [preview](#preview) (22 messages) +13. [program](#program) (5 messages) +14. [programming](#programming) (12 messages) +15. [project](#project) (19 messages) +16. [scene](#scene) (218 messages) +17. [server](#server) (7 messages) +18. [shortcuts](#shortcuts) (8 messages) +19. [tester](#tester) (4 messages) +20. [utils](#utils) (2 messages) +21. [window](#window) (4 messages) +22. [cocos-service](#cocos-service) (16 messages) +23. [im-plugin](#im-plugin) (11 messages) + +**总计: 553 条 message** + +--- + +## animation-graph + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open | open | No | | | | +| dialog-warn | dialogWarn | No | | | | +| apply | default.apply | No | | | | +| unselect | default.unselect | No | | | | +| delete | default.delete | No | | | | +| copy | default.copy | No | | | | +| duplicate | default.duplicate | No | | | | +| paste | default.paste | No | | | | +| scene:ready | profileBindWatchWhenSceneReady, default.refresh | No | | | | +| animation-graph:changed | default.refresh | No | | | | + +## animator + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open | open | No | | | | +| scene:ready | default.scene:ready | No | | | | +| scene:close | default.scene:close | No | | | | +| scene:change-node | default.scene:change-node | No | | | | +| scene:animation-start | default.scene:animation-start | No | | | | +| scene:animation-end | default.scene:animation-end | No | | | | +| scene:animation-change | default.scene:animation-change | No | | | | +| scene:animation-state-change | default.scene:animation-state-change | No | | | | +| scene:change-mode | default.scene:change-mode | No | | | | +| scene:animation-clip-change | default.scene:animation-clip-change | No | | | | +| selection:activated | default.selection:activated | No | | | | +| asset-db:asset-change | default.asset-db:asset-change | No | | | | +| asset-db:asset-delete | default.asset-db:asset-delete | No | | | | +| inspector-drop-animation | dropClipToNode | No | | | | +| change-debug-mode | default.change-debug-mode | No | | | | +| copy | default.copy | No | | | | +| paste | default.paste | No | | | | +| select-all | default.selectAll | No | | | | +| delete | default.deleteSelected | No | | | | +| create | default.createKey | No | | | | +| focus | default.showAllKeys | No | | | | +| show-selected-keys | default.showSelectedKeys | No | | | | +| next-step | default.nextStep | No | | | | +| prev-step | default.prevStep | No | | | | +| jump-to-next-key | default.jumpToNextKey | No | | | | +| jump-to-prev-key | default.jumpToPrevKey | No | | | | +| jump-to-first-frame | default.jumpFirstFrame | No | | | | +| jump-to-last-frame | default.jumpLastFrame | No | | | | +| play-or-pause | default.playOrPause | No | | | | +| stop | default.stop | No | | | | +| clear-selected | default.clearSelect | No | | | | +| switch-animation-mode | default.changeRecordState | No | | | | +| open-docs | openDocs | No | | | | +| query-last-clip-cache | queryLatestClipCache | No | | | | +| save-clip-cache | saveClipCacheToFile | No | | | | +| update-cache-config | updateCacheConfig | No | | | | +| enable-embedded-player | default.enableEmbeddedPlayer | No | | | | +| enable-auxiliary-curve | default.enableAuxiliaryCurve | No | | | | + +## asset-db + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| asset-db:ready | ready | Yes | 资源数据库准备就绪时的广播 | | Editor.Message.broadcast('asset-db:ready'); | +| asset-db:close | close | Yes | 资源数据库关闭时的广播 | | Editor.Message.broadcast('asset-db:close'); | +| asset-db:asset-add | | Yes | 当资源数据库准备就绪后,再新增资源时的广播 | | Editor.Message.broadcast('asset-db:asset-add'); | +| asset-db:asset-change | | Yes | 当一个资源被修改时的广播 | | Editor.Message.broadcast('asset-db:asset-change'); | +| asset-db:asset-delete | | Yes | 当一个资源被删除时的广播 | | Editor.Message.broadcast('asset-db:asset-delete'); | +| project:change-high-quality | projectChangeHighQuality | No | | | | +| query-ready | queryReady | Yes | 检查资源数据库是否启动完毕 | @returns {boolean} 是否准备就绪 | await Editor.Message.request('asset-db', 'query-ready'); | +| create-asset | createAsset | Yes | 新建一个资源 | - url {string} 资源的 URL,例如 db://assets/abc.json
- content {string \| null} 写入文件的 string,为 null 则新建文件夹
- option {AssetOperationOption}
- option.overwrite {boolean} 是否强制覆盖,默认 false
- option.rename {boolean} 冲突是否自动更名,默认 false

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'create-asset', url, content); | +| new-asset | newAsset | No | 新建一个资源 | | | +| import-asset | importAsset | Yes | 将一个文件或文件夹导入到资源数据库内 | - source {string} 本地的文件绝对地址
- target {string} 导入到数据库的 url 地址
- option {AssetOperationOption}
- option.overwrite {boolean} 是否强制覆盖,默认 false
- option.rename {boolean} 冲突是否自动更名,默认 false

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'import-asset', path, url, { overwrite: true, rename: true }); | +| copy-asset | copyAsset | Yes | 复制某个资源 | - source {string} 源资源的 URL 路径,例如 db://assets/abc.json
- target {string} 复制到的目标位置 URL
- option {AssetOperationOption}
- option.overwrite {boolean} 是否强制覆盖,默认 false
- option.rename {boolean} 冲突是否自动更名,默认 false

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'copy-asset', sourceUrl, targetUrl, { overwrite: true, rename: true }); | +| move-asset | moveAsset | Yes | 将一个资源移动到某个地方 | - source {string} 需要移动的源资源 URL 路径,例如 db://assets/abc.json
- target {string} 移动到的目标位置 URL
- option {AssetOperationOption}
- option.overwrite {boolean} 是否强制覆盖,默认 false
- option.rename {boolean} 冲突是否自动更名,默认 false

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'move-asset', sourceUrl, targetUrl); | +| rename-asset | renameAsset | No | 重命名指定资源 | | | +| delete-asset | deleteAsset | Yes | 删除一个资源 | - url {string} 资源的 URL

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'delete-asset', pathOrUrlOrUUID); | +| open-asset | openAsset | Yes | 尝试使用记录的打开程序打开一个资源 | - urlOrUUID {string} 尝试打开的资源 URL 或者 UUID | await Editor.Message.request('asset-db', 'open-asset', urlOrUUID); | +| save-asset | saveAsset | Yes | 保存资源 | - urlOrUUID {string} 资源的 URL 或者 UUID
- content {string \| Buffer} 资源的内容字符串,如果是 typeArray,请使用 Buffer.from 转换。

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'save-asset', urlOrUUID, content); | +| save-asset-meta | saveAssetMeta | Yes | 保存资源的 meta 信息 | - urlOrUUID {string} 资源的 URL 或者 UUID
- content {string} 资源 meta 序列化后的内容字符串

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'save-asset-meta', urlOrUUID, content); | +| reimport-asset | reimportAsset | Yes | 重新导入资源 | - urlOrUUID {string} 资源的 URL 或者 UUID | await Editor.Message.request('asset-db', 'reimport-asset', urlOrUUID); | +| refresh-asset | refreshAsset | Yes | 刷新一个资源所在的 url 位置,删除资源会被销毁,新增资源会导入 | - urlOrUUID {string} 资源的 URL 或者 UUID | await Editor.Message.request('asset-db', 'refresh-asset', urlOrUUID); | +| refresh-all-effect | refreshAllEffect | No | | | | +| query-path | queryPath | Yes | 查询一个资源的路径 | - urlOrUUID {string} 资源的 URL 或者 UUID

@returns {string} 返回一个资源的磁盘绝对路径 | await Editor.Message.request('asset-db', 'query-path', urlOrUUID); | +| query-url | queryUrl | Yes | 查询一个资源的 URL | - uuid {string} 资源的 UUID

@returns {string} 返回一个资源的 url | await Editor.Message.request('asset-db', 'query-url', uuidOrPath); | +| query-uuid | queryUUID | Yes | 查询一个资源的 UUID | - url {string} 资源的 URL

@returns {string} 返回一个资源的 uuid | await Editor.Message.request('asset-db', 'query-uuid', urlOrUUID); | +| query-assets | queryAssets | Yes | 根据条件查询资源数组 | - pattern? {string} 路径匹配模式,glob 格式 (db://**)
- ccType? {string} 资源类型,例如 cc.Texture2D
- importer? {string} 资源导入器类型,例如 texture

@returns {AssetInfo[]} 返回资源数组 | await Editor.Message.request('asset-db', 'query-assets', { ccType: 'cc.Script' }); | +| query-asset-info | queryAssetInfo | Yes | 查询一个资源的基本信息 | - urlOrUUID {string} 资源的 url 地址或者 uuid

@returns {AssetInfo} 返回一个资源信息 | await Editor.Message.request('asset-db', 'query-asset-info', urlOrUUIDOrPath); | +| query-missing-asset-info | queryMissingAssetInfo | No | | | | +| query-asset-meta | queryAssetMeta | Yes | 查询一个资源的 META 信息 | - urlOrUUID {string} 资源的 url 地址或者 uuid

@returns {AssetMeta} 返回一个资源 meta 信息 | await Editor.Message.request('asset-db', 'query-asset-meta', urlOrUUID); | +| query-asset-dependinces | queryAssetDependencies | No | | | | +| query-asset-used | queryAssetUsed | No | | | | +| query-asset-dependencies | queryAssetDependencies | Yes | 查询一个资源依赖的资源或脚本 uuid 数组 | - urlOrUUID {string} 资源的 url 地址或者 uuid
- type? {QueryAssetType} 查询的资源类型,默认 asset, 可选值:asset, script, all

@returns {string[]} 返回一个资源依赖的资源或脚本 uuid 数组 | await Editor.Message.request('asset-db', 'query-asset-dependencies', urlOrUUID, type); | +| query-asset-users | queryAssetUsers | Yes | 查询一个资源被哪些资源或脚本直接使用到 | - urlOrUUID {string} 资源的 url 地址或者 uuid
- type? {QueryAssetType} 查询的资源类型,默认 asset, 可选值:asset, script, all

@returns {string[]} 返回一个资源被哪些资源或脚本直接使用到 | await Editor.Message.request('asset-db', 'query-asset-users', urlOrUUID, type); | +| query-asset-data | queryAssetData | No | | | | +| generate-available-url | generateAvailableUrl | Yes | 根据传入的 url 生成一个可用的新 url | - url {string} 资源的 url

@returns {string} 返回一个新的或者和传入参数一样的 url | await Editor.Message.request('asset-db', 'generate-available-url', url); | +| query-asset-mtime | queryAssetMtime | No | | | | +| refresh | refresh | No | | | | +| is-busy | isBusy | No | | | | +| pause | pause | No | | | | +| resume | resume | No | | | | +| open-devtools | open-devtools | No | | | | +| query-db-info | query-db-info | No | | | | +| query-db-infos | query-db-infos | No | | | | +| query-db-list | query-db-list | No | | | | +| create-asset-dialog | create-asset-dialog | No | | | | +| create-asset-template | create-asset-template | No | | | | +| init-asset | init-asset | No | | | | +| query-all-importer | query-all-importer | No | | | | +| query-all-asset-types | query-all-asset-types | No | | | | +| execute-script | executeScript | No | | | | +| execute-custom-operation | executeCustomOperation | No | | | | +| start-db | startDatabase | No | | | | +| stop-db | stopDatabase | No | | | | +| notice-reload-editor | noticeReloadEditor | No | | | | +| notice-config-changed | noticeConfigChanged | No | | | | +| update-config | updateConfig | No | | | | +| refresh-all-database | refreshAllDatabase | No | | | | +| refresh-default-user-data-config | refreshDefaultUserDataConfig | No | | | | +| update-default-user-data | updateDefaultUserData | No | | | | +| query-create-list | queryCreateList | No | | | | +| query-icon-config-map | queryIconConfigMap | No | | | | +| query-asset-config-map | queryAssetConfigMap | No | | | | +| query-create-menu-list | queryCreateMenuList | No | | | | +| query-asset-userData-config | queryAssetUserDataConfig | No | | | | +| query-asset-thumbnail | queryAssetThumbnail | No | | | | +| batch-message-handler | batchMessageHandler | No | | | | +| show-asset-template-dir | showAssetTemplateDir | No | | | | +| query-global-internal-library | query-global-internal-library | No | | | | + +## builder + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open | open | Yes | 打开构建面板 | @returns {null} | await Editor.Message.request('builder', 'open'); | +| open-bundle | openBundle | No | | | | +| change-build-bundle | build-bundle.changeBuildBundle | No | | | | +| open-page | default.open-page | No | | | | +| query-worker-ready | query-worker-ready | Yes | 查询构建进程是否启动 | @returns {boolean} - 构建进程是否准备好 | await Editor.Message.request('builder', 'query-worker-ready'); | +| execute-build-stage | execute-build-stage | No | | | | +| open-platform-debug-tools | open-platform-debug-tools | No | | | | +| create-build-plugin-template | createBuildPluginTemplate | No | | | | +| create-build-template | create-build-template | No | | | | +| create-application-template | createApplicationTemplate | No | | | | +| open-devtools | openWorkerDevTool | No | | | | +| generate-preview-setting | generate-preview-setting | No | | | | +| query-tasks-info | query-tasks-info | No | | | | +| add-task | add-task | No | | | | +| select-all-task | default.select-all-task | No | | | | +| clear-selected-task | default.clear-selected-task | No | | | | +| add-bundle-task | add-bundle-task | No | | | | +| recompile-task | recompile-task | No | | | | +| remove-task | remove-task | No | | | | +| break-task | break-task | No | | | | +| query-task | query-task | No | | | | +| update-task | update-task | No | | | | +| save-task | save-task | No | | | | +| preview-pac | preview-pac | No | | | | +| query-atlas-files | query-atlas-files | No | | | | +| command-build | command-build | No | | | | +| asset-db:ready | asset-db:ready, default.asset-db:ready | No | | | | +| asset-db:close | asset-db:close | No | | | | +| asset-db:asset-delete | default.asset-db:asset-delete, build-bundle.asset-db:asset-delete | No | | | | +| asset-db:asset-add | default.asset-db:asset-add, build-bundle.asset-db:asset-add | No | | | | +| asset-db:asset-change | default.asset-db:asset-change, build-bundle.asset-db:asset-change | No | | | | +| request-to-build-worker | request-to-build-worker | No | | | | +| execute-hook-task | execute-hook-task | No | | | | +| preferences-changed | preferences-changed | No | | | | +| query-compress-config | query-compress-config | No | | | | +| query-platform-config | query-platform-config | No | | | | +| query-bundle-config | query-bundle-config | No | | | | +| migrate-options | migrateOptions | No | | | | +| builder:task-changed | default.builder:task-changed, build-bundle.builder:task-changed | No | | | | +| builder:task-add | build-bundle.builder:task-add | No | | | | +| builder:task-delete | default.builder:task-delete, build-bundle.builder:task-delete | No | | | | +| builder:bundle-task-changed | build-bundle.bundle-task:changed | No | | | | +| change-debug-mode | default.change-debug-mode | No | | | | +| build-worker:ready | default.onBuildWorkerReady, build-bundle.onBuildWorkerReady | Yes | 构建进程启动 | | Editor.Message.broadcast('build-worker:ready'); | +| build-worker:closed | default.onBuildWorkerClosed, build-bundle.onBuildWorkerClosed | Yes | 构建进程关闭 | | Editor.Message.broadcast('build-worker:closed'); | +| build-by-shortcut | default.buildByShortcut | No | | | | +| console:update-log-level | console:update-log-level | No | | | | +| register-package | registerPackage | No | | | | +| unregister-package | unRegisterPackage | No | | | | +| programming:pack-build-end | programming:pack-build-end | No | | | | +| open-docs | openDocs | No | | | | +| check-and-complete-options | check-and-complete-options | No | | | | +| open-panel-devtools | openPanelDevTools | No | | | | +| open-worker-devtools | openWorkerDevTool | No | | | | +| clear-all-cache | clearAllCache | No | | | | +| clear-assets-cache | clearProjectAssetsCache | No | | | | +| clear-engine-cache | clearEngineCache | No | | | | +| copy-build-notice | copy-build-notice | No | | | | +| export-bundle-config | build-bundle.exportBundleBuildConfig | No | | | | + +## engine + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| rebuild | rebuild | No | | | | +| relaunch | relaunch | No | | | | +| import-engine-error | importEngineError | No | | | | +| pipeline-config-change | onPipelineConfigChange | No | | | | +| query-info | query-info | No | | | | +| query-engine-info | query-engine-info | No | | | | +| query-modules-config | query-modules-config | No | | | | +| change-custom-engine-config | changeCustomEngineConfig | No | | | | +| engine:engine-modules-global-config-changed | onEngineModulesChanged | No | | | | +| engine-custom-macro-changed | onCustomMacroChanged | No | | | | +| query-engine-modules-profile | queryEngineModulesProfile | No | | | | +| filter-engine-modules | filterEngineModules | No | | | | + +## information + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| query-information | queryInformation | No | | | | +| open-information-dialog | openInformationDialog | No | | | | +| has-dialog | hasDialog | No | | | | +| close-dialog | closeDialog | No | | | | + +## menu + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| engine:engine-modules-global-config-changed | engineModuleChanged | No | | | | +| edit-mode:enter | modeChanged | No | | | | +| shortcuts:change | onShortcutChange | No | | | | + +## messages + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| start-record | addListener | No | | | | +| stop-record | removeListener | No | | | | +| start-auto-save | startAutoSave | No | | | | +| stop-auto-save | stopAutoSave | No | | | | +| open | open | No | | | | +| open-debug | openDebug | No | | | | +| broadcast | debug.broadcast | No | | | | +| request | debug.request | No | | | | +| send | debug.send | No | | | | +| reply | debug.reply | No | | | | +| query-message-state | queryMessageState | No | | | | + +## metrics + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| metrics:init | init | No | | | | +| metrics:trackEvent | trackEvent | No | | | | +| metrics:trackException | trackException | No | | | | +| metrics:trackProcessMemory | trackProcessMemory | No | | | | +| metrics:trackTimeStart | trackTimeStart | No | | | | +| metrics:trackTimeEnd | trackTimeEnd | No | | | | +| metrics:_trackEventWithTimer | _trackEventWithTimer | No | | | | +| metrics:_sendEventGroup | _sendEventGroup | No | | | | +| metrics:_trackCrashEvent | _trackCrashEvent | No | | | | +| query-google-v4-data | query-google-v4-data | No | | | | +| query-google-metrics-v4-html | query-google-metrics-v4-html | No | | | | + +## placeholder + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| install-extension | installExtension | No | | | | + +## preferences + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open-settings | openSettings | Yes | 打开偏好设置面板 | - tab {string} 需要打开的选项卡(功能插件的名称)
- ...args: {any[]} 打开选项卡带的其他参数

@returns {null} | await Editor.Message.request('preferences', 'open-settings'); | +| change-settings-tab | settings.changeTab | No | | | | +| refresh-settings-tab | settings.refreshTab | No | | | | +| query-settings-tab | settings.queryTab | No | | | | +| query-preferences-configs | queryPreferencesConfigs | No | | | | +| query-configs-from-path | queryConfigsFromPath | No | | | | +| query-config | queryConfig | Yes | 查询偏好配置 | - name {string} 插件或分类名
- path? {string} 配置路径
- type? {'default' \| 'global' \| 'local'} 配置类型

@returns {any} 返回配置数据 | await Editor.Message.request('preferences', 'query-config', 'preview', 'general', 'global'); | +| set-config | setConfig | Yes | 设置偏好配置 | - name {string} 插件名
- path {string} 配置路径
- value {any} 配置数据
- type? {'default' \| 'global' \| 'local'} 配置类型

@returns {boolean} 是否设置成功 | await Editor.Message.request('preferences', 'set-config', 'preview', 'general.auto_refresh', false, 'global'); | +| import-config | importConfig | No | | | | +| export-config | exportConfig | No | | | | + +## preview + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open | open | No | | | | +| ready | ready | No | | | | +| generate-settings | generateSettings | No | | | | +| query-preview-url | queryPreviewUrl | No | | | | +| query-connect-num | queryConnectNum | No | | | | +| scene:save | currentSceneSave | No | | | | +| programming:compiled | programming:compiled | No | | | | +| programming:compile-start | programming:compile-start | No | | | | +| open-terminal | open-terminal | No | | | | +| restart-simulator | restart-simulator | No | | | | +| reload-terminal | reload-terminal | No | | | | +| get-preview-ip | get-preview-ip | No | | | | +| set-preview-ip | set-preview-ip | No | | | | +| create-template | create-template | No | | | | +| change-platform | change-platform | No | | | | +| programming:pack-build-end | on-pack-build-end | No | | | | +| build-simulator-engine-ts | buildSimulatorEngineTS | No | | | | +| preview-scene-in-browser | previewSceneInBrowser | No | | | | +| write-setting-file | writeSettingFile | No | | | | +| asset-db:asset-change | asset-db:asset-change | No | | | | +| build-worker:ready | build-worker:ready | No | | | | +| build-worker:closed | build-worker:closed | No | | | | + +## program + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| query-program-info | queryProgramInfo | No | | | | +| query-program-config | queryProgramConfig | No | | | | +| open-program | openProgram | No | | | | +| execute-program | executeProgram | No | | | | +| open-url | openUrl | No | | | | + +## programming + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| packer-driver/start | packer-driver/start | No | | | | +| packer-driver/get-loader-context | packer-driver/get-loader-context | No | | | | +| packer-driver/ready | packer-driver/ready | No | | | | +| packer-driver/update-auto-update-import-config | packer-driver/update-auto-update-import-config | No | | | | +| packer-driver/query-script-deps | packer-driver/query-script-deps | No | | | | +| packer-driver/query-script-users | packer-driver/query-script-users | No | | | | +| packer-driver/query-cc-editor-module-map | packer-driver/query-cc-editor-module-map | No | | | | +| query-shared-settings | query-shared-settings | No | | | | +| clear-code-cache | clear-code-cache | No | | | | +| open-dev-tools | open-dev-tools | No | | | | +| engine:engine-custom-macro-changed | custom-macro-changed | No | | | | +| query-sorted-plugins | querySortedPlugins | No | | | | + +## project + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| asset-db:ready | settings.assetDbReady | No | | | | +| open-settings | openSettings | Yes | 打开项目设置面板 | - name {string} 要打开的选项卡所属的插件注册名称
- tab {string} 在注册功能时使用的键
- ...args: {any[]} 打开选项卡时附带的其他参数

@returns {null} | await Editor.Message.request('project', 'open-settings'); | +| open-joint | openJoint | No | | | | +| change-settings-tab | settings.changeTab | No | | | | +| refresh-settings-tab | settings.refreshTab | No | | | | +| query-settings-tab | settings.queryTab | No | | | | +| change-script-config | changeScriptConfig | No | | | | +| change-design-resolution | changeDesignResolution | No | | | | +| query-design-resolution | queryDesignResolution | No | | | | +| change-custom-layer | changeCustomLayer | No | | | | +| change-sorting-layer | changeSortingLayer | No | | | | +| change-hight-quality | changeHighQuality | No | | | | +| calc-joint-layout | calcJointLayouts | No | | | | +| query-project-configs | queryProjectConfigs | No | | | | +| query-configs-from-path | queryConfigsFromPath | No | | | | +| query-config | queryConfig | Yes | 查询项目配置 | - name {string} 插件名
- path? {string} 配置路径
- type? {'default' \| 'project'} 配置类型

@returns {any} 返回配置数据 | await Editor.Message.request('project', 'query-config', 'engine', 'modules'); | +| set-config | setConfig | Yes | 设置项目配置 | - name {string} 插件名
- path {string} 配置路径
- value {any} 配置数据

@returns {boolean} 设置成功与否 | await Editor.Message.request('project', 'set-config', 'project', 'general.downloadMaxConcurrency', 10); | +| import-config | importConfig | No | | | | +| export-config | exportConfig | No | | | | + +## scene + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| query-scene-bounds | default.query-scene-bounds | No | | | | +| change-native-config | default.change-native-config | No | | | | +| debug-view | default.change-debug-view-option | No | | | | +| quit-editor | default.quit-editor | No | | | | +| scene:preview-stop | preview.onEditorPreviewStop | No | | | | +| open | open | No | | | | +| full-screen | default.fullScreen | No | | | | +| i18n:change | default.i18n:change | No | | | | +| scene:ready | onSceneReady, default.scene:ready | Yes | 场景打开通知 | - uuid {string} uuid of scene | Editor.Message.broadcast('scene:ready', assetUuid); | +| scene:close | default.scene:close, onSceneClosed | Yes | 场景关闭通知 | | Editor.Message.broadcast('scene:close'); | +| asset-db:ready | default.asset-db:ready | No | | | | +| asset-db:close | default.asset-db:close | No | | | | +| selection:hover | default.selection:hover | No | | | | +| selection:select | default.selection:select | No | | | | +| selection:unselect | default.selection:unselect | No | | | | +| asset-db:asset-add | default.asset-db:asset-add | No | | | | +| asset-db:asset-change | default.asset-db:asset-change | No | | | | +| asset-db:asset-delete | default.asset-db:asset-delete | No | | | | +| programming:pack-build-end | default.programming:pack-build-end | No | | | | +| project:change-design-resolution | default.project:change-design-resolution | No | | | | +| project:change-custom-layer | default.project:change-custom-layer | No | | | | +| project:change-sorting-layer | default.project:change-sorting-layer | No | | | | +| project:update-physics-group | default.project:update-physics-group | No | | | | +| project:change-high-quality | default.project:change-high-quality | No | | | | +| engine:engine-modules-global-config-changed | default.engine:engine-modules-global-config-changed | No | | | | +| open-devtools | default.open-devtools | No | | | | +| open-preview-devtools | default.open-preview-devtools | No | | | | +| graphical-tools | default.graphicalTools | No | | | | +| open-scene | default.open-scene | Yes | 打开场景 | - uuid {string} 场景资源的 UUID | await Editor.Message.request('scene', 'open-scene', sceneUuid); | +| load-empty-scene | default.load-empty-scene | No | | | | +| save-scene | default.save-scene | Yes | 保存场景 | | await Editor.Message.request('scene', 'save-scene'); | +| save-as-scene | default.save-as-scene | Yes | 场景另存为 | | await Editor.Message.request('scene', 'save-as-scene'); | +| close-scene | default.close-scene | Yes | 关闭场景 | | await Editor.Message.request('scene', 'close-scene'); | +| set-property | default.set-property | Yes | 设置某个元素内的属性 | - options {SetPropertyOptions}
- uuid {string} 修改属性的对象的 uuid
- path {string} 属性挂载对象的搜索路径
- dump {IProperty} 属性 dump 出来的数据 | await Editor.Message.request('scene', 'set-property', {
uuid: nodeUuid,
path: '__comps__.1.defaultClip',
dump: {
type: 'cc.AnimationClip',
value: {
uuid: animClipUuid,
},
},
}); | +| reset-property | default.reset-property | Yes | 重置元素属性到默认值 | - options {SetPropertyOptions}
- uuid {string} 修改属性的对象的 uuid
- path {string} 属性挂载对象的搜索路径 | await Editor.Message.request('scene', 'reset-property', {
uuid: nodeUuid,
path: 'position',
}); | +| preview-set-property | default.preview-set-property | No | | | | +| cancel-preview-set-property | default.cancel-preview-set-property | No | | | | +| update-property-from-null | default.update-property-from-null | No | | | | +| set-node-and-children-layer | default.set-node-and-children-layer | No | | | | +| move-array-element | default.move-array-element | Yes | 移动数组内某个元素的位置 | - options {MoveArrayOptions}
- uuid {string} 节点的 uuid
- path {string} 数组的搜索路径
- target {number} 目标 item 原来的索引
- offset {number} 偏移量

@returns {boolean} 操作是否成功 | await Editor.Message.request('scene', 'move-array-element', {
uuid: nodeUuid,
path: '__comps__',
target: 1,
offset: -1,
}); | +| remove-array-element | default.remove-array-element | Yes | 删除数组内某个元素的位置 | - options {MoveArrayOptions}
- uuid {string} 节点的 uuid
- path {string} 数组的搜索路径
- index {number} 目标 item 的索引

@returns {boolean} 操作是否成功 | await Editor.Message.request('scene', 'remove-array-element', {
uuid: nodeUuid,
path: '__comps__',
index: 0,
}); | +| select-all-nodes | default.select-all-nodes | No | | | | +| copy-node | default.copy-node | Yes | 拷贝节点,给下一步粘贴(创建)节点准备数据 | - uuids {string \| string[]} 节点的 uuid

@returns {string \| string[]} 返回节点的 uuid | await Editor.Message.request('scene', 'copy-node', uuids); | +| duplicate-node | default.duplicate-node | Yes | 复制节点 | - uuids {string \| string[]} 节点的 uuid

@returns {string \| string[]} 返回新节点的 uuid | await Editor.Message.request('scene', 'duplicate-node', uuids); | +| paste-node | default.paste-node | Yes | 粘贴节点 | - options {PasteNodeOptions}
- target {string} 目标节点 uuid
- uuids {string \| string[]} 被复制的节点 uuid
- keepWorldTransform {boolean} 是否保持新节点的世界坐标不变

@returns {string \| string[]} 返回新节点的 uuid | await Editor.Message.request('scene', 'paste-node', {
target: nodeUuid,
uuids: nodeUuids,
}); | +| cut-node | default.cut-node | Yes | 剪切节点 | - uuids {string \| string[]} 节点的 uuid

@returns {string \| string[]} 返回节点的 uuid | await Editor.Message.request('scene', 'cut-node', uuids); | +| set-parent | default.set-parent | Yes | 设置节点父级 | - options {CutNodeOptions}
- parent {string} 父节点 uuid
- uuids {string\|string[]} 需要设置的子节点 uuid
- keepWorldTransform {boolean} 是否保持新节点的世界坐标不变

@returns {string \| string[]} 返回节点的 uuid | await Editor.Message.request('scene','set-parent', {
parent: nodeUuid,
uuids: nodeUuids,
}); | +| create-node | default.create-node | Yes | 创建节点 | - options {CreateNodeOptions}
- parent {string} 父节点 uuid
- components? {string[]} 组件名字

- name? {string} 节点名字
- dump? {INode \| IScene} node 初始化应用的 dump 数据
- keepWorldTransform? {boolean} 是否保持新节点的世界坐标不变
- type? {string} 资源类型
- canvasRequired? {boolean} 是否需要有 cc.Canvas
- unlinkPrefab? {boolean} 是否要解绑为普通节点
- assetUuid? {string} asset uuid,从资源实例化节点

注意: 使用 assetUuid 从预制体创建节点时,无论 unlinkPrefab 传 false 还是不传,都不会自动建立 Prefab 关联。需额外调用 link-prefab 消息建立关联。

@returns {string \| string[]} 返回新节点的 uuid | await Editor.Message.request('scene', 'create-node', {
name: 'New Node'
parent: nodeUuid,
}); | +| reset-node | default.reset-node | Yes | 重置节点的位置, 角度和缩放 | - uuid {string} 节点的 uuid

@returns {boolean} 操作是否成功 | await Editor.Message.request('scene', 'reset-node', {
uuid: nodeUuid,
}); | +| reset-component | default.reset-component | Yes | 重置组件 | - options {ResetComponentOptions}
- uuid {string} 组件的 uuid

@returns {boolean} 操作是否成功 | await Editor.Message.request('scene', 'reset-component', {
uuid: componentUuid,
}); | +| restore-prefab | default.restore-prefab | Yes | 使用预制体资源还原对应预制件节点(内置撤销记录) | - uuid {string} 节点的 uuid
- assetUuid {string} 资源的 uuid

@returns {boolean} 操作是否成功 | await Editor.Message.request('scene', 'restore-prefab', nodeUuid, assetUuid); | +| remove-node | default.remove-node | Yes | 删除节点 | - options {RemoveNodeOptions}
- uuid: {string \| string[]} 节点的 uuid | await Editor.Message.request('scene', 'remove-node', {
uuid: nodeUuid
}); | +| create-component | default.create-component | Yes | 创建组件 | - options {CreateComponentOptions}
- uuid {string} 节点的 uuid
- component {string} 组件 classId (cid)(推荐方式) 或者 className 类名 | Editor.Message.request('scene', 'create-component', {
uuid: nodeUuid,
component: 'cc.Sprite'
}); | +| remove-component | default.remove-component | Yes | 删除组件 | - options {CreateComponentOptions}
- uuid {string} 节点的 uuid
- component {string} 组件 classId (cid)(推荐方式) 或者 className 类名 | await Editor.Message.request('scene', 'remove-component', {
uuid: componentUuid,
}); | +| execute-component-method | default.execute-component-method | Yes | 执行组件上的方法 | - options {ExecuteComponentMethodOptions}
- uuid {string} 组件的 uuid
- name {string} 方法名
- args {any[]} 参数 | await Editor.Message.request('scene', 'execute-component-method', {
uuid: componentUuid,
name: 'getNoisePreview',
args: [100, 100],
}); | +| execute-scene-script | default.execute-scene-script | Yes | 执行某个插件注册的方法 | - options {ExecuteSceneScriptMethodsOptions}
- name {string} 注册进来的插件名字
- method {string} 执行的方法名字
- args {any[]} 参数数组 | await Editor.Message.request('scene', 'execute-scene-script', {
name: 'animation-graph',
method: 'query',
args: [],
}); | +| snapshot | default.snapshot | Yes | 快照当前场景状态 | | await Editor.Message.request('scene', 'snapshot'); | +| snapshot-abort | default.snapshot-abort | Yes | 中止快照 | | await Editor.Message.request('scene', 'snapshot-abort'); | +| begin-recording | default.begin-recording | Yes | 开始记录节点 Undo 数据 | | const undoID = await Editor.Message.request('scene', 'begin-recording', nodeUuid); | +| end-recording | default.end-recording | Yes | 结束记录节点 Undo 数据 | | await Editor.Message.request('scene', 'end-recording', undoID); | +| cancel-recording | default.cancel-recording | Yes | 取消记录节点 Undo 数据 | | await Editor.Message.request('scene', 'cancel-recording', undoID); | +| undo | default.undo | No | | | | +| redo | default.redo | No | | | | +| soft-reload | default.soft-reload | Yes | 软刷新场景 | | await Editor.Message.request('scene', 'soft-reload'); | +| preview-material | default.preview-material | No | | | | +| light-probe-update-tetrahedron | default.light-probe-update-tetrahedron | No | | | | +| change-gizmo-tool | default.change-gizmo-tool | Yes | 更改 Gizmo 工具 | - name {string} 工具名字 'position' \| 'rotation' \| 'scale'\| 'rect' | await Editor.Message.request('scene', 'change-gizmo-tool', 'position'); | +| query-gizmo-tool-name | default.query-gizmo-tool-name | Yes | 获取当前 Gizmo 工具的名字 | @returns {string} 'position' \| 'rotation' \| 'scale' \| 'rect' | await Editor.Message.request('scene', 'query-gizmo-tool-name'); | +| change-gizmo-pivot | default.change-gizmo-pivot | Yes | 更改变换基准点 | - name {string} 变换基准点 'pivot' \| 'center' | await Editor.Message.request('scene', 'query-gizmo-pivot'); | +| query-gizmo-pivot | default.query-gizmo-pivot | Yes | 获取当前 Gizmo 基准点名字 | @returns {string} 'pivot' \| 'center' | await Editor.Message.request('scene', 'query-gizmo-pivot'); | +| query-gizmo-view-mode | default.query-gizmo-view-mode | Yes | 查询视图模式(查看/选择) | @return {string} 'view' \| 'select' | await Editor.Message.request('scene', 'query-gizmo-view-mode'); | +| change-gizmo-coordinate | default.change-gizmo-coordinate | Yes | 更改坐标系 | - type {string} 坐标系 'local' \| 'global' | await Editor.Message.request('scene', 'change-gizmo-coordinate', 'global'); | +| query-gizmo-coordinate | default.query-gizmo-coordinate | Yes | 获取当前坐标系名字 | @returns {string} 'local' \| 'global' | await Editor.Message.request('scene', 'query-gizmo-coordinate'); | +| change-is2D | default.change-is2D | Yes | 更改2D/3D视图模式 | - is2D {boolean} 2D/3D视图 | await Editor.Message.request('scene', 'change-is2D', true); | +| query-is2D | default.query-is2D | Yes | 获取当前视图模式 | @returns {boolean} true:2D, false:3D | await Editor.Message.request('scene', 'query-is2D'); | +| set-grid-visible | default.set-grid-visible | Yes | 显示/隐藏网格 | - visible {boolean} 显示/隐藏网格 | await Editor.Message.request('scene', 'set-grid-visible', false); | +| query-is-grid-visible | default.query-is-grid-visible | Yes | 查询网格显示状态 | @returns {boolean} true: visible, false: invisible | await Editor.Message.request('scene', 'query-is-grid-visible'); | +| set-icon-gizmo-3d | default.set-icon-gizmo-3d | Yes | 设置 IconGizmo 为 3D 或 2D 模式 | - is3D {boolean} 3D/2D IconGizmo | await Editor.Message.request('scene', 'set-icon-gizmo-3d', false); | +| query-is-icon-gizmo-3d | default.query-is-icon-gizmo-3d | Yes | 查询 IconGizmo 模式 | @returns {boolean} true: 3D, false: 2D | await Editor.Message.request('scene', 'query-is-icon-gizmo-3d'); | +| set-icon-gizmo-size | default.set-icon-gizmo-size | Yes | 设置 IconGizmo 的大小 | - size {number} IconGizmo 的大小 | await Editor.Message.request('scene', 'set-icon-gizmo-size', 60); | +| query-icon-gizmo-size | default.query-icon-gizmo-size | Yes | 查询 IconGizmo 的大小 | @returns {number} IconGizmo 的大小 | await Editor.Message.request('scene', 'query-icon-gizmo-size'); | +| query-transform-snap-configs | default.query-transform-snap-configs | No | | | | +| set-transform-snap-configs | default.set-transform-snap-configs | No | | | | +| query-rect-snapping-configs | default.query-rect-snapping-configs | No | | | | +| set-rect-snapping-configs | default.set-rect-snapping-configs | No | | | | +| focus-camera | default.focus-camera | Yes | 聚焦场景相机到节点上 | - uuids {string[] \| null} 节点 uuid | await Editor.Message.request('scene', 'focus-camera', nodeUuids); | +| align-with-view | default.align-with-view | Yes | 将场景相机位置与角度应用到选中节点上 | @returns {null} | await Editor.Message.request('scene', 'align-with-view'); | +| align-view-with-node | default.align-view-with-node | Yes | 将选中节点位置与角度应用到当前视角 | @returns {null} | await Editor.Message.request('scene', 'align-with-view-node'); | +| zoom-scene-view-down | default.zoom-scene-view-down | No | | | | +| zoom-scene-view-up | default.zoom-scene-view-up | No | | | | +| toggle-active-selected-node | default.toggle-active-selected-node | No | | | | +| toggle-active-unselected-node | default.toggle-active-unselected-node | No | | | | +| toggle-active-all-nodes | default.toggle-active-all-nodes | No | | | | +| apply-material | default.apply-material | No | | | | +| query-physics-material | default.query-physics-material | No | | | | +| change-physics-material | default.change-physics-material | No | | | | +| apply-physics-material | default.apply-physics-material | No | | | | +| query-animation-graph-variant | default.query-animation-graph-variant | No | | | | +| change-animation-graph-variant | default.change-animation-graph-variant | No | | | | +| apply-animation-graph-variant | default.apply-animation-graph-variant | No | | | | +| query-animation-mask | default.query-animation-mask | No | | | | +| change-animation-mask | default.change-animation-mask | No | | | | +| apply-animation-mask | default.apply-animation-mask | No | | | | +| apply-render-texture | default.apply-render-texture | No | | | | +| copy-camera-data-to-nodes | default.copy-camera-data-to-nodes | No | | | | +| record-animation | default.record-animation | No | | | | +| change-animation-root | default.change-animation-root | No | | | | +| set-edit-time | default.set-edit-time | No | | | | +| change-clip-state | default.change-clip-state | No | | | | +| change-edit-clip | default.change-edit-clip | No | | | | +| save-clip | default.save-clip | No | | | | +| animation-operation | default.animation-operation | No | | | | +| create-prefab | default.create-prefab | No | 创建预制体资源(内置撤销记录) | | | +| getdata-prefab | default.getdata-prefab | No | | | | +| link-prefab | default.link-prefab | No | 将节点关联到预制体资源,建立 Prefab 关联关系 | - uuid {string} 节点的 uuid
- assetUuid {string} 预制体资源的 uuid

@returns {boolean} 操作是否成功 | await Editor.Message.request('scene', 'link-prefab', nodeUuid, assetUuid); | +| unlink-prefab | default.unlink-prefab | No | | | | +| apply-prefab | default.apply-prefab | No | 应用预制体节点修改到对应资源(内置撤销记录) | | | +| apply-removed-component | default.apply-removed-component | No | 应用预制体删除组件的修改到对应资源(内置撤销记录) | | | +| revert-removed-component | default.revert-removed-component | No | 还原预制体节点被移除的组件(内置撤销记录) | | | +| query-is-ready | default.query-is-ready | Yes | 查询当前场景是否准备就绪 | | await Editor.Message.request('scene', 'query-is-ready'); | +| query-node | default.query-node | Yes | 查询一个节点的数据 | - uuid {string} 节点的 uuid

@returns {Object} 节点的 dump 数据 | await Editor.Message.request('scene', 'query-node', nodeUuid); | +| query-component | default.query-component | Yes | 查询一个组件的数据 | - uuid {string} 组件的 uuid

@returns {Object} 组件的 dump 数据 | await Editor.Message.request('scene', 'query-component', nodeUuid); | +| query-node-tree | default.query-node-tree | Yes | 查询节点树的信息 | - uuid? {string} 根节点 uuid,不传入则以场景节点为根节点

@returns {Object}
- name {string} 节点名字或者 'scene'
- active {boolean} 节点激活状态
- type {string} cc.Scene or cc.Node
- uuid {string} 节点的 uuid
- children {[]} 子节点数组
- prefab {number} prefab状态, 1 表示是 prefab, 2 表示是 prefab 但丢失资源
- isScene {boolean} 是否是场景节点
- components {[Object]} 组件数组
- type {string} 组件类型
- value {string} 组件的 uuid
- extends {[string]} 组件的继承链数组 | await Editor.Message.request('scene', 'query-node-tree', nodeUuid); | +| query-nodes-by-asset-uuid | default.query-nodes-by-asset-uuid | Yes | 查询使用了资源 UUID 的节点 | - 查询使用了资源 UUID 的节点

@returns {string[]} 节点的 uuid | await Editor.Message.request('scene', 'query-nodes-by-asset-uuid', assetUuid); | +| query-nodes-miss-assets | default.query-nodes-miss-assets | No | | | | +| query-component-function-of-node | default.query-component-function-of-node | No | | | | +| query-all-effects | default.query-all-effects | No | | | | +| query-material | default.query-material | No | | | | +| query-effect | default.query-effect | No | | | | +| query-serialized-material | default.query-serialized-material | No | | | | +| query-render-pipeline | default.query-render-pipeline | No | | | | +| change-render-pipeline | default.change-render-pipeline | No | | | | +| apply-render-pipeline | default.apply-render-pipeline | No | | | | +| query-creatable-asset-types | default.query-creatable-asset-types | No | | | | +| query-scene-json | default.query-scene-json | No | | | | +| query-current-scene | default.query-current-scene | No | | | | +| query-dirty | default.query-dirty | Yes | 查询当前场景是否有修改 | | await Editor.Message.request('scene', 'query-dirty'); | +| query-classes | default.query-classes | Yes | 查询所有在引擎中注册的类 | @returns {[Object]}
- extends? {string} 过滤出基于此类名扩展而来的类 | await Editor.Message.request('scene', 'query-classes'); | +| query-components | default.query-components | Yes | 查询当前场景的所有组件 | @returns {[Object]}
- name {string} 组件名字
- path {string} 菜单路径 | await Editor.Message.request('scene', 'query-components'); | +| query-component-has-script | default.query-component-has-script | Yes | 查询引擎组件列表是否含有指定类名的脚本 | - name 脚本的类名 Class

@returns {boolean} 存在 true, 不存在 false | await Editor.Message.request('scene', 'query-component-has-script', 'cc.Sprite'); | +| query-script-name | default.query-script-name | No | | | | +| query-script-cid | default.query-script-cid | No | | | | +| query-layer-builtin | default.query-layer-builtin | No | | | | +| query-sorting-layer-builtin | default.query-sorting-layer-builtin | No | | | | +| query-scene-mode | default.query-scene-mode | No | | | | +| query-animation-state | default.query-animation-state | No | | | | +| query-current-animation-info | default.query-current-animation-info | No | | | | +| query-animation-root | default.query-animation-root | No | | | | +| query-animation-root-info | default.query-animation-root-info | No | | | | +| query-animation-edit-info | default.query-animation-edit-info | No | | | | +| query-animation-clip | default.query-animation-clip | No | | | | +| query-animation-properties | default.query-animation-properties | No | | | | +| query-animation-clips-info | default.query-animation-clips-info | No | | | | +| query-animation-clips-time | default.query-animation-clips-time | No | | | | +| query-property-value-at-frame | default.query-property-value-at-frame | No | | | | +| query-aux-curve-value-at-frame | default.query-aux-curve-value-at-frame | No | | | | +| query-auxiliary-curves | default.query-auxiliary-curves | No | | | | +| generate-available-name | default.generate-available-name | No | | | | +| editor-preview-set-play | default.editor-preview-set-play | No | | | | +| editor-refresh-gizmo-config | default.editor-refresh-gizmo-config | No | | | | +| editor-preview-call-method | default.editor-preview-call-method | No | | | | +| editor-preview-change-style-web | default.editor-preview-change-style | No | | | | +| editor-preview-change-style-native | preview.editorPreviewChangeStyle | No | | | | +| editor-preview-change-config | default.editor-preview-change-config, preview.editorPreviewChangeConfig | No | | | | +| scene:show-loading | default.scene:show-loading | No | | | | +| scene:hide-loading | default.scene:hide-loading | No | | | | +| execute-model-preview-animation-operation | default.execute-model-preview-animation-operation | No | | | | +| regenerate-polygon-2d-points | default.regenerate-polygon-2d-points | No | | | | +| export-particle-plist | default.export-particle-plist | No | | | | +| change-node-lock | default.change-node-lock | No | | | | +| unit-test | default.unit-test | No | | | | +| change-target-resolution | default.change-target-resolution | No | | | | +| query-latest-cache | queryLatestCache | No | | | | +| clear-scene-cache | clearSceneCache | No | | | | +| save-scene-cache-to-file | saveSceneCacheToFile | No | | | | +| update-cache-config | updateCacheConfig | No | | | | +| query-enum-list-with-path | default.query-enum-list-with-path | No | | | | +| query-wrap-mode-enum-list | default.query-wrap-mode-enum-list | No | | | | +| set-scene-light-on | default.set-scene-light-on | No | | | | +| query-scene-light-on | default.query-scene-light-on | No | | | | +| native-ipc | default.native-ipc | No | | | | +| native-scene | default.native-scene | No | | | | +| panel-browser | panelToBrowser | No | | | | +| browser-panel | default.on-browser-panel | No | | | | +| is-native | default.query-is-native | No | | | | +| query-preview-data | default.query-preview-data | No | | | | +| call-preview-function | default.call-preview-function | No | | | | +| query-thumbnail | default.query-thumbnail | No | | | | +| query-preview-game-view-data | preview.getGameViewData | No | | | | +| set-preview-window-visible | preview.setPreviewWindowVisible, setPreviewWindowVisible | No | | | | +| create-preview-window | preview.createPreviewWindow | No | | | | +| resize-native-window | resizeNativeWindow | No | | | | +| create-native-window | createNativeWindow | No | | | | +| preview-panel-ready | onPreviewPanelReady | No | | | | +| preview-panel-stop | onPreviewPanelStop | No | | | | +| toggle-light-probe-edit-mode | default.toggle-light-probe-edit-mode | No | | | | +| query-light-probe-edit-mode | default.query-light-probe-edit-mode | No | | | | +| toggle-light-probe-bounding-box-edit-mode | default.toggle-light-probe-bounding-box-edit-mode | No | | | | +| query-light-probe-bounding-box-edit-mode | default.query-light-probe-bounding-box-edit-mode | No | | | | +| scene:light-probe-edit-mode-changed | | Yes | 光照探针编辑模式切换通知 | - mode {boolean} 切换后的探针编辑模式 | Editor.Message.broadcast('scene:light-probe-edit-mode-changed', true); | +| scene:light-probe-bounding-box-edit-mode-changed | | Yes | 光照探针组件包围盒编辑模式切换通知 | - mode {boolean} 切换后的探针组件包围盒编辑模式 | Editor.Message.broadcast('scene:light-probe-bounding-box-edit-mode-changed', true); | +| lod-apply-current-camera-size | default.lod-apply-current-camera-size | No | | | | +| lod-insert | default.lod-insert | No | | | | +| lod-erase | default.lod-erase | No | | | | +| shortcuts:change | default.shortcuts:change | No | | | | +| window:zoom-level-change | default.window:zoom-level-change | No | | | | +| window:focus-zoom-level-change | default.window:focus-zoom-level-change | No | | | | +| multi-open-scene | default.multi-open-scene | No | | | | +| multi-close-scene | default.multi-close-scene | No | | | | +| multi-scene-dirty | default.multi-scene-dirty | No | | | | +| multi-scene-query | default.multi-scene-query | No | | | | +| multi-scene-focus | default.multi-scene-focus | No | | | | +| multi-scene-focus-query | default.multi-scene-focus-query | No | | | | +| multi-save-all-scene | default.multi-save-all-scene | No | | | | +| multi-is-multi-edit-mode | default.multi-is-multi-edit-mode | No | | | | +| multi-close-to-the-right | default.multi-close-to-the-right | No | | | | +| multi-move-tabs-to | default.multi-move-tabs-to | No | | | | +| multi-close-others | default.multi-close-others | No | | | | + +## server + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| query-ip-list | queryIPList | Yes | 查询 IP 列表 | @returns {string[]} - IP 列表 | await Editor.Message.request('server', 'query-ip-list'); | +| query-https-enabled | queryHTTPSEnabled | No | | | | +| query-sort-ip-list | querySortIpList | Yes | 获取排序后的 ip 列表 | @returns {string[]} - 排序后的 IP 列表 | await Editor.Message.request('server', 'query-sort-ip-list'); | +| query-port | queryPort | Yes | 查询编辑器服务器当前启动的端口号 | @returns {number} - 端口号 | await Editor.Message.request('server', 'query-port'); | +| scan-lan | scanLAN | No | | | | +| change-preview-port | change-preview-port | No | | | | +| change-https-options | change-https-options | No | | | | + +## shortcuts + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open | open | No | | | | +| query-shortcut-map | queryShortcutMap | No | | | | +| change-shortcut | changeShortcut | No | | | | +| change-tab | default.changeTab | No | | | | +| reset-shortcut | resetShortcut | No | | | | +| query-packages-shortcut-list | queryPackagesShortcutList | No | | | | +| remove-custom-shortcut | removeCustomShortcut | No | | | | +| shortcuts:change | default.onShortcutChanged | No | | | | + +## tester + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open | open | No | | | | +| forwarding-to-window | forwarding-to-window | No | | | | +| * | panel.* | No | | | | +| auto-test | auto-test | No | | | | + +## utils + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| export-dts | exportDTS | No | | | | +| tester-tag | testerTag | No | | | | + +## window + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| focus-window-zoom-in | onFocusWindowZoomIn | No | | | | +| focus-window-zoom-out | onFocusWindowZoomOut | No | | | | +| focus-window-zoom-to-initial | onFocusWindowZoomToInitial | No | | | | +| window-zoom-level-change | onWindowZoomLevelChange | No | | | | + +## cocos-service + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| open-service-panel | openServicePanel | No | | | | +| enable-service | enableService | No | | | | +| open-popup | openPopup | No | | | | +| close-popup | closePopup | No | | | | +| print-log | printLog | No | | | | +| api-request | apiRequest | No | | | | +| popup-show | popupShow | No | | | | +| plugin-msg | pluginMsg | No | | | | +| plugin-msg-service | default.pluginMsg | No | | | | +| plugin-msg-popup | simple.pluginMsg, dockable.pluginMsg | No | | | | +| service-ready | serviceReady | No | | | | +| i18n:change | i18nChange | No | | | | +| service-status-changed | onServiceStatusChanged | No | | | | +| drop-scene | dropScene | No | | | | +| drop-hierarchy | dropHierarchy | No | | | | +| drop-assets | dropAssets | No | | | | + +## im-plugin + +| Message | Methods | Public | Description | Doc | Example | +|---------|---------|--------|-------------|-----|---------| +| show-panel | showPanel | No | | | | +| silent-check-and-update | silentCheckAndUpdate | No | | | | +| im-plugin:check | default.check | No | | | | +| silent-check | silentCheck | No | | | | +| close-panel | closePanel | No | | | | +| whether-close | whetherClose | No | | | | +| get-sign-in-url | getSignInUrl | No | | | | +| get-redirect-url | getRedirectUrl | No | | | | +| update-toolbar | updateToolbarMain | No | | | | +| save-toolbar-info | saveToolbarInfo | No | | | | +| get-saved-toolbar-info | getSavedToolbarInfo | No | | | | + +--- + +## Message 调用方式 + +在扩展中可以通过以下方式发送 message: + +```typescript +// 发送消息 (等待返回结果) +const result = await Editor.Message.request(packageName, messageName, ...args); + +// 发送消息 (不等待返回结果) +Editor.Message.send(packageName, messageName, ...args); + +// 广播消息 +Editor.Message.broadcast(messageName, ...args); +``` + +其中 `packageName` 为上述各表格的扩展包名称 (如 `asset-db`, `scene`, `builder` 等)。 diff --git a/lib/diagnostics.js b/lib/diagnostics.js index e8ed7fc..14ccabc 100644 --- a/lib/diagnostics.js +++ b/lib/diagnostics.js @@ -68,7 +68,11 @@ function findTsConfig(projectPath, explicitPath) { function runExec(file, args, cwd) { return new Promise((resolve) => { - execFile(file, args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => { + const options = { cwd, maxBuffer: 8 * 1024 * 1024 }; + if (process.platform === 'win32' && /\.(cmd|bat)$/.test(file)) { + options.shell = true; + } + execFile(file, args, options, (error, stdout, stderr) => { resolve({ code: error && typeof error.code === 'number' ? error.code : 0, stdout: stdout || '', diff --git a/lib/prefabs.js b/lib/prefabs.js index a2dbc86..5ab3cf6 100644 --- a/lib/prefabs.js +++ b/lib/prefabs.js @@ -169,7 +169,9 @@ async function duplicatePrefab(projectPath, options = {}) { throw new Error(`Prefab source file was not found: ${source}`); } - const targetPath = resolveProjectPath(projectPath, target.endsWith('.prefab') ? target : `${target}.prefab`); + const rawTarget = target.endsWith('.prefab') ? target : `${target}.prefab`; + const targetUnderAssets = rawTarget.replace(/^assets[\\/]/, ''); + const targetPath = resolveProjectPath(projectPath, path.join('assets', targetUnderAssets)); const assetsRoot = path.join(projectPath, 'assets'); const relativeToAssets = path.relative(assetsRoot, targetPath); if (relativeToAssets.startsWith('..') || path.isAbsolute(relativeToAssets)) { @@ -259,8 +261,32 @@ async function revertPrefabInstance(nodeUuid) { throw lastError || new Error('No prefab revert editor message was available.'); } +async function createPrefab(options = {}) { + const nodeUuid = String(options.nodeUuid || '').trim(); + const savePath = String(options.savePath || '').trim(); + if (!nodeUuid) { + throw new Error('nodeUuid is required.'); + } + if (!savePath) { + throw new Error('savePath is required.'); + } + if (!savePath.endsWith('.prefab')) { + throw new Error('savePath must end with .prefab'); + } + // Note: 'create-prefab' is not a public message in Cocos Creator 3.8.8 + // (Public: No in editor-messages-3.8.8.md). It works but may change in future versions. + const result = await requestEditorMessage('scene', 'create-prefab', nodeUuid, savePath); + return { + created: true, + nodeUuid, + savePath, + result, + }; +} + module.exports = { applyPrefabInstance, + createPrefab, duplicatePrefab, editPrefabJson, inspectPrefab, diff --git a/lib/resources.js b/lib/resources.js index 3f5b10e..ec431de 100644 --- a/lib/resources.js +++ b/lib/resources.js @@ -70,6 +70,7 @@ class ResourceProvider { createResource('cocos://logs/editor', `${projectName} Editor Logs`, 'Recent MCP runtime logs and tool interaction history.'), createResource('cocos://logs/project', `${projectName} Project Logs`, 'Recent tails from common project log files.'), createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'), + createResource('cocos://mcp/execute-context', `${projectName} Execute Context Guide`, 'Available variables and code patterns for execute_javascript scene and editor contexts.'), ]; } @@ -131,6 +132,8 @@ class ResourceProvider { return this.getProjectLogsText(projectPath); case 'cocos://mcp/interactions': return this.interactionLog.summary(); + case 'cocos://mcp/execute-context': + return this.getExecuteContextGuide(); default: break; } @@ -286,6 +289,100 @@ class ResourceProvider { ].join('\n')) .join('\n\n---\n\n'); } + + getExecuteContextGuide() { + return [ + 'Execute JavaScript Context Guide', + '', + 'This document describes the variables and code patterns available in', + 'execute_javascript for both scene and editor contexts.', + '', + '=== Scene Context (context="scene") ===', + '', + 'Injected Variables:', + ' cc - Cocos engine module. Use cc.Sprite, cc.Node, cc.Vec3, etc.', + ' scene - Active scene root node (cc.Node). Same as director.getScene().', + ' director - cc.director.', + ' require - Node.js require (with Cocos module paths).', + ' Editor - Editor global API (if running in editor).', + ' args - User-passed args object.', + '', + 'Return Patterns:', + ' 1. Direct return: return { myResult: 42 };', + ' 2. run function: async function run(env) { return result; }', + ' 3. module.exports: module.exports = async (env) => result;', + '', + 'Key Rules:', + ' - Use cc.Sprite, cc.Node, cc.UITransform — NOT bare Sprite/Node.', + ' - Do NOT redeclare scene, director, cc — they are already in scope.', + ' - Find nodes by traversing scene.children recursively (see pattern below).', + ' - Load assets with callback-style assetManager.loadAny (see pattern below).', + '', + 'Pattern: Find a node by UUID', + ' let target = null;', + ' function search(n) {', + ' if (n.uuid === "NODE_UUID") { target = n; return; }', + ' for (const c of n.children) { search(c); if (target) return; }', + ' }', + ' search(scene);', + '', + 'Pattern: Load and set an asset reference (SpriteFrame, Texture, etc.)', + ' const { Sprite, assetManager, SpriteFrame } = cc;', + ' const sf = await new Promise((resolve, reject) => {', + ' assetManager.loadAny(', + ' { uuid: "ASSET_UUID", type: SpriteFrame },', + ' (err, asset) => { if (err) reject(err); else resolve(asset); }', + ' );', + ' });', + ' sprite.spriteFrame = sf;', + '', + 'Pattern: Create a node with components', + ' const { Node, UITransform, Sprite } = cc;', + ' const node = new Node("MyNode");', + ' node.layer = parent.layer;', + ' node.addComponent(UITransform);', + ' node.addComponent(Sprite);', + ' parent.addChild(node);', + '', + '=== Editor Context (context="editor") ===', + '', + 'Injected Variables:', + ' require - Node.js require.', + ' Editor - Editor global API.', + ' args - User-passed args object.', + ' context - Runtime context (projectPath, projectName, cocosVersion, etc.).', + ' helpers - { getStatus, listTools, readResource, callTool,', + ' listClientTargets, getClientConfig, configureClient }.', + ' fs - Node.js fs module.', + ' path - Node.js path module.', + ' os - Node.js os module.', + '', + 'Pattern: Call asset-db from editor context', + ' const info = await Editor.Message.request("asset-db", "query-asset-info", "ASSET_UUID");', + '', + 'Pattern: Call a scene method from editor context', + ' const result = await Editor.Message.request(', + ' "scene", "execute-scene-script", { name: "funplay-cocos-mcp", method: "inspectNode", args: { uuid: "NODE_UUID" } }', + ' );', + '', + '=== Common Pitfalls ===', + '', + '1. set_component_property cannot set asset references (SpriteFrame, Texture,', + ' Material, Font, AudioClip). Use execute_javascript with loadAny instead.', + '', + '2. create_node only accepts parentPath (e.g. "Canvas/Player"), not parentUuid.', + ' Use get_hierarchy first to find the full path.', + '', + '3. assetManager.loadAny(uuid) without callback returns null in scene context.', + ' Always use callback style: loadAny({uuid, type}, (err, asset) => {}).', + '', + '4. Custom script components (e.g. TestComponent) require the script to be', + ' compiled before add_component or findComponent can find them.', + '', + '5. Node hierarchy paths are slash-separated from scene root, e.g.', + ' "should_hide_in_hierarchy/TestPrefab/Sprite". Use get_hierarchy to verify.', + ].join('\n'); + } } module.exports = { diff --git a/lib/tool-registry.js b/lib/tool-registry.js index 91911dc..73111ae 100644 --- a/lib/tool-registry.js +++ b/lib/tool-registry.js @@ -31,6 +31,7 @@ const { } = require('./project-instructions'); const { applyPrefabInstance, + createPrefab, duplicatePrefab, editPrefabJson, inspectPrefab, @@ -41,6 +42,8 @@ const { createAssetsAdvancedTools } = require('./tools/assets-advanced'); const { createCocosProjectTools } = require('./tools/cocos-project'); const { buildSnippet, createFileTools, refreshAssets } = require('./tools/files'); const { createSceneEventTools } = require('./tools/scene-events'); +const { createSceneManagementTools } = require('./tools/scene-management'); +const { createScriptTools } = require('./tools/scripts'); const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); const { checkForUpdate } = require('./update-checker'); const { assertJavascriptSafety } = require('./javascript-safety'); @@ -358,8 +361,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[primary] Execute JavaScript in either the scene or editor context. Use context=\"scene\" for live scene/runtime inspection and mutation, or context=\"editor\" for Editor APIs, asset-db workflows, MCP orchestration, local filesystem access, and higher-level automation. Prefer this as the main flexible tool when many narrow tools would be noisy.', inputSchema: createSchema( { - context: { type: 'string', description: 'Execution context: scene or editor.' }, - code: { type: 'string', description: 'JavaScript code to execute. May directly return a value, define run(env), or export a function.' }, + context: { type: 'string', enum: ['scene', 'editor'], description: 'Execution context. Scene injects: cc, scene, director, require, Editor, args. Editor injects: require, Editor, args, context, helpers, fs, path, os. Read cocos://mcp/execute-context resource for details.' }, + code: { type: 'string', description: 'JavaScript code. May directly return a value, define run(env), or use module.exports. In scene context, use cc.Sprite, cc.Node (not bare Sprite/Node). Load assets via cc.assetManager.loadAny({uuid, type}, cb) callback style.' }, args: { type: 'object', description: 'Optional JSON object passed into the script.' }, safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' }, }, @@ -387,7 +390,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[compat] Execute JavaScript in the active Cocos scene context. Prefer execute_javascript with context="scene" as the main unified tool; use this when you specifically want the scene-only compatibility entrypoint.', inputSchema: createSchema( { - code: { type: 'string', description: 'JavaScript code to execute inside the scene script context.' }, + code: { type: 'string', description: 'JavaScript code. Injected vars: cc, scene, director, require, Editor, args. Use cc.Sprite etc. (not bare Sprite). See cocos://mcp/execute-context resource.' }, args: { type: 'object', description: 'Optional JSON object passed to the scene script.' }, safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' }, }, @@ -404,7 +407,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[compat] Execute JavaScript in the editor/browser context. Prefer execute_javascript with context="editor" as the main unified tool; use this when you specifically want the editor-only compatibility entrypoint.', inputSchema: createSchema( { - code: { type: 'string', description: 'JavaScript code to execute inside the editor context.' }, + code: { type: 'string', description: 'JavaScript code. Injected vars: require, Editor, args, context, helpers, fs, path, os. See cocos://mcp/execute-context resource.' }, args: { type: 'object', description: 'Optional JSON object passed to the editor script.' }, safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' }, }, @@ -665,7 +668,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { name: { type: 'string', description: 'Name of the node to create.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root. Use get_hierarchy to find the full path.' }, position: { type: 'object', description: 'Optional position {x,y,z}.' }, scale: { type: 'object', description: 'Optional scale {x,y,z}.' }, eulerAngles: { type: 'object', description: 'Optional rotation {x,y,z} in degrees.' }, @@ -681,7 +684,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: 'Delete a node by path, uuid, or name.', inputSchema: createSchema( { - path: { type: 'string', description: 'Hierarchy path.' }, + path: { type: 'string', description: 'Hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, }, @@ -695,7 +698,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: 'Update node position, rotation, scale, or active state.', inputSchema: createSchema( { - path: { type: 'string', description: 'Hierarchy path.' }, + path: { type: 'string', description: 'Hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, position: { type: 'object', description: 'Position {x,y,z}.' }, @@ -715,6 +718,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt handler: async () => getRuntimeContext(), }, ...createCocosProjectTools({ createSchema }), + ...createSceneManagementTools({ createSchema, sceneBridge }), { name: 'list_scenes', profile: 'core', @@ -757,6 +761,19 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt return { count: assets.length, prefabs: assets.slice(0, 200) }; }, }, + { + name: 'create_prefab', + profile: 'full', + description: '[core] Create a prefab asset from a scene node.', + inputSchema: createSchema( + { + nodeUuid: { type: 'string', description: 'UUID of the scene node to save as prefab.' }, + savePath: { type: 'string', description: 'Asset-db URL, e.g. db://assets/prefabs/Enemy.prefab.' }, + }, + ['nodeUuid', 'savePath'] + ), + handler: async (args) => createPrefab(args), + }, { name: 'inspect_prefab', profile: 'core', @@ -836,7 +853,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { prefabUuid: { type: 'string', description: 'Prefab asset uuid, db url, or path.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root.' }, name: { type: 'string', description: 'Optional override node name.' }, position: { type: 'object', description: 'Optional position {x,y,z}; fallback runtime path only.' }, }, @@ -881,7 +898,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[specialist] Inspect whether a scene node is linked to a prefab instance and return prefab metadata when available.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, }, @@ -895,7 +912,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[core] Apply a scene prefab instance back to its associated prefab asset using the Cocos editor scene apply-prefab message.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, }, @@ -909,7 +926,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[core] Revert a scene prefab instance from its associated prefab asset using available Cocos editor prefab revert messages.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, }, @@ -924,7 +941,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { prefabUuid: { type: 'string', description: 'Prefab asset uuid.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root.' }, name: { type: 'string', description: 'Optional override node name.' }, position: { type: 'object', description: 'Optional position {x,y,z}.' }, }, @@ -1034,7 +1051,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[core] List components attached to a scene node.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, }, @@ -1048,10 +1065,10 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[core] Inspect a component attached to a node.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, - componentName: { type: 'string', description: 'Component class name.' }, + componentName: { type: 'string', description: 'Component class name, e.g. Sprite, Label, Button. Custom scripts must be compiled.' }, index: { type: 'number', description: 'Optional component index.' }, }, [] @@ -1064,10 +1081,10 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: 'Add a component to a node by component class name.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, - componentName: { type: 'string', description: 'Component class name, for example Sprite or cc.UITransform.' }, + componentName: { type: 'string', description: 'Component class name, e.g. Sprite, Label, Button, UITransform. Custom script components require the script to be compiled first.' }, }, ['componentName'] ), @@ -1079,10 +1096,10 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: 'Remove a component from a node by name or index.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, - componentName: { type: 'string', description: 'Component class name.' }, + componentName: { type: 'string', description: 'Component class name, e.g. Sprite, Label, Button. Custom scripts must be compiled.' }, index: { type: 'number', description: 'Optional component index.' }, }, [] @@ -1095,13 +1112,13 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: 'Set a component property by dot path using a JSON value.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, - componentName: { type: 'string', description: 'Component class name.' }, + componentName: { type: 'string', description: 'Component class name, e.g. Sprite, Label, Button. Custom scripts must be compiled.' }, index: { type: 'number', description: 'Optional component index.' }, - propertyPath: { type: 'string', description: 'Property path such as color.r or enabled.' }, - valueJson: { type: 'string', description: 'JSON encoded value to assign, for example true, 12, \"hero\", or {\"x\":1}.' }, + propertyPath: { type: 'string', description: 'Property path using dot notation, e.g. "color.r", "enabled", "size.width".' }, + valueJson: { type: 'string', description: 'JSON encoded value, e.g. true, 12, "hero", {"x":1}. NOTE: Cannot set asset references (SpriteFrame, Texture, Material, etc.) — use execute_javascript with cc.assetManager.loadAny({uuid, type}, cb) for those.' }, }, ['propertyPath', 'valueJson'] ), @@ -1121,12 +1138,12 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: 'Reset or clear a component property by dot path.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, - componentName: { type: 'string', description: 'Component class name.' }, + componentName: { type: 'string', description: 'Component class name, e.g. Sprite, Label, Button. Custom scripts must be compiled.' }, index: { type: 'number', description: 'Optional component index.' }, - propertyPath: { type: 'string', description: 'Property path such as color.r or enabled.' }, + propertyPath: { type: 'string', description: 'Property path using dot notation, e.g. "color.r", "enabled", "size.width".' }, }, ['propertyPath'] ), @@ -1139,7 +1156,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { name: { type: 'string', description: 'Canvas node name.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root.' }, width: { type: 'number', description: 'Canvas width.' }, height: { type: 'number', description: 'Canvas height.' }, position: { type: 'object', description: 'Optional position {x,y,z}.' }, @@ -1155,7 +1172,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { name: { type: 'string', description: 'Label node name.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root.' }, text: { type: 'string', description: 'Label text.' }, fontSize: { type: 'number', description: 'Font size.' }, width: { type: 'number', description: 'UI width.' }, @@ -1174,7 +1191,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { name: { type: 'string', description: 'Button node name.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root.' }, text: { type: 'string', description: 'Button text.' }, width: { type: 'number', description: 'Button width.' }, height: { type: 'number', description: 'Button height.' }, @@ -1194,8 +1211,8 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { name: { type: 'string', description: 'Sprite node name.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, - spriteFrameUuid: { type: 'string', description: 'Optional SpriteFrame asset uuid.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root.' }, + spriteFrameUuid: { type: 'string', description: 'SpriteFrame asset uuid, e.g. "57520716-48c8-4a19-8acf-41c9f8777fb0@f9941". Use list_assets to find available SpriteFrames.' }, width: { type: 'number', description: 'UI width.' }, height: { type: 'number', description: 'UI height.' }, color: { type: 'string', description: 'Sprite color as #RRGGBB or #RRGGBBAA.' }, @@ -1219,7 +1236,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt inputSchema: createSchema( { name: { type: 'string', description: 'Camera node name.' }, - parentPath: { type: 'string', description: 'Optional parent node path.' }, + parentPath: { type: 'string', description: 'Parent node hierarchy path, e.g. "Canvas/Player". Slash-separated from scene root.' }, priority: { type: 'number', description: 'Camera priority.' }, visibility: { type: 'number', description: 'Camera visibility mask.' }, clearFlags: { type: 'number', description: 'Camera clear flags.' }, @@ -1272,7 +1289,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: 'Add an AnimationClip asset to a node Animation component.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, clipUuid: { type: 'string', description: 'AnimationClip asset uuid.' }, @@ -1288,7 +1305,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[core] Play an Animation component clip on a node.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, clipName: { type: 'string', description: 'Optional clip name.' }, @@ -1303,7 +1320,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[core] Stop an Animation component clip on a node.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, clipName: { type: 'string', description: 'Optional clip name.' }, @@ -1499,7 +1516,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt description: '[core] Emit a custom event on a target scene node with an optional JSON payload.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, eventName: { type: 'string', description: 'Event name to emit.' }, @@ -1524,16 +1541,17 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt handler: async (args) => sceneBridge.call('simulateButtonClick', args), }, ...createSceneEventTools({ createSchema, sceneBridge }), + ...createScriptTools({ createSchema, getRuntimeContext }), { name: 'invoke_component_method', profile: 'full', description: '[core] Invoke a method on a component for runtime validation and test hooks.', inputSchema: createSchema( { - path: { type: 'string', description: 'Node hierarchy path.' }, + path: { type: 'string', description: 'Node hierarchy path, e.g. "Canvas/Player".' }, uuid: { type: 'string', description: 'Node uuid.' }, name: { type: 'string', description: 'Fallback exact node name.' }, - componentName: { type: 'string', description: 'Component class name.' }, + componentName: { type: 'string', description: 'Component class name, e.g. Sprite, Label, Button. Custom scripts must be compiled.' }, index: { type: 'number', description: 'Optional component index.' }, methodName: { type: 'string', description: 'Method name to invoke.' }, args: { type: 'array', description: 'Optional argument array.' }, diff --git a/lib/tools/assets-advanced.js b/lib/tools/assets-advanced.js index 7a8a59d..37734bf 100644 --- a/lib/tools/assets-advanced.js +++ b/lib/tools/assets-advanced.js @@ -97,8 +97,12 @@ function collectUuidReferences(content) { async function inspectAssetDependencies(projectPath, target, options = {}) { const info = await queryAssetInfo(target); + const isDirectory = info && (info.isDirectory || info.type === 'folder' || (info.url && !path.extname(info.url))); const filePath = assetFilePath(projectPath, info); if (!filePath) { + if (isDirectory) { + throw new Error(`Cannot inspect dependencies of a directory asset: ${target}. Please specify a file asset (e.g. a .prefab, .scene, or .mat file).`); + } throw new Error(`Asset file was not found: ${target}`); } @@ -210,12 +214,190 @@ function createAssetsAdvancedTools({ createSchema, getRuntimeContext }) { return await validateAssetDependencies(projectPath, args); }, }, + { + name: 'batch_asset_ops', + profile: 'full', + description: '[core] Batch import or delete assets in the Cocos asset database.', + inputSchema: createSchema( + { + action: { + type: 'string', + enum: ['import', 'delete'], + description: 'Batch operation.', + }, + sourceDirectory: { + type: 'string', + description: 'Local filesystem source path (import only).', + }, + targetDirectory: { + type: 'string', + description: 'Asset-db target URL, e.g. db://assets/textures (import only).', + }, + urls: { + type: 'array', + items: { type: 'string' }, + description: 'Asset URLs to delete (delete only).', + }, + }, + ['action'] + ), + handler: async (args) => { + const action = String(args.action || '').trim(); + + if (action === 'import') { + const sourceDirectory = String(args.sourceDirectory || '').trim(); + const targetDirectory = String(args.targetDirectory || '').trim(); + if (!sourceDirectory) { + throw new Error('sourceDirectory is required for action: import.'); + } + if (!targetDirectory) { + throw new Error('targetDirectory is required for action: import.'); + } + if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { + throw new Error('Editor.Message.request is unavailable in the Cocos extension host.'); + } + const result = await Editor.Message.request('asset-db', 'import-asset', sourceDirectory, targetDirectory); + return { action, sourceDirectory, targetDirectory, result }; + } + + if (action === 'delete') { + const urls = Array.isArray(args.urls) ? args.urls.filter(Boolean) : []; + if (urls.length === 0) { + throw new Error('urls is required for action: delete.'); + } + if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { + throw new Error('Editor.Message.request is unavailable in the Cocos extension host.'); + } + const results = []; + for (const url of urls) { + try { + await Editor.Message.request('asset-db', 'delete-asset', url); + results.push({ url, deleted: true }); + } catch (error) { + results.push({ url, deleted: false, error: error.message }); + } + } + return { action, urls, results }; + } + + throw new Error(`Unknown action: ${action}. Must be one of: import, delete.`); + }, + }, + { + name: 'find_unused_assets', + profile: 'full', + description: '[core] Find assets not referenced by any scene, prefab, or animation in the project.', + inputSchema: createSchema( + { + directory: { + type: 'string', + description: 'Asset-db directory to scan. Default: db://assets.', + }, + excludeDirectories: { + type: 'array', + items: { type: 'string' }, + description: 'Directories to exclude from the reference scan.', + }, + assetTypes: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by Cocos asset type, e.g. ["cc.Texture2D", "cc.AudioClip"].', + }, + limit: { type: 'number', description: 'Max results. Default 200.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + return await findUnusedAssets(projectPath, args); + }, + }, ]; } +const SERIALIZED_EXTENSIONS = ['.scene', '.prefab', '.anim', '.mat']; +const SKIP_UNUSED_EXTENSIONS = ['.ts', '.scene']; + +async function findUnusedAssets(projectPath, options = {}, listAssetsFn) { + const listAssetsRef = typeof listAssetsFn === 'function' ? listAssetsFn : listAssets; + const directory = options.directory || 'db://assets'; + const excludeDirs = Array.isArray(options.excludeDirectories) ? options.excludeDirectories : []; + const assetTypes = Array.isArray(options.assetTypes) ? options.assetTypes : undefined; + const limit = Number.isFinite(options.limit) ? Math.max(1, options.limit) : 200; + + const allAssets = await listAssetsRef({ + pattern: directory + '/**', + ccType: assetTypes, + }); + + const referencedUuids = new Set(); + const assetsDir = path.join(projectPath, 'assets'); + + function scanDir(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (_) { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') { + continue; + } + const relativePath = path.relative(assetsDir, fullPath).replace(/\\/g, '/'); + if (excludeDirs.some((excl) => relativePath.startsWith(String(excl)))) { + continue; + } + scanDir(fullPath); + } else { + const ext = path.extname(entry.name).toLowerCase(); + if (!SERIALIZED_EXTENSIONS.includes(ext)) { + continue; + } + try { + const content = fs.readFileSync(fullPath, 'utf8'); + const refs = collectUuidReferences(content); + for (const ref of refs) { + referencedUuids.add(ref.uuid); + } + } catch (_) { + // Skip unreadable files + } + } + } + } + + scanDir(assetsDir); + + const unused = allAssets.filter((asset) => { + if (!asset || !asset.uuid) { + return false; + } + if (asset.isDirectory) { + return false; + } + const url = asset.url || asset.source || ''; + const ext = path.extname(url).toLowerCase(); + if (SKIP_UNUSED_EXTENSIONS.includes(ext)) { + return false; + } + return !referencedUuids.has(asset.uuid); + }); + + return { + totalScanned: allAssets.length, + unusedCount: unused.length, + referencedCount: referencedUuids.size, + unused: unused.slice(0, limit), + }; +} + module.exports = { collectUuidReferences, createAssetsAdvancedTools, + findUnusedAssets, inspectAssetDependencies, validateAssetDependencies, }; diff --git a/lib/tools/cocos-project.js b/lib/tools/cocos-project.js index 7f7b9ac..547067e 100644 --- a/lib/tools/cocos-project.js +++ b/lib/tools/cocos-project.js @@ -174,6 +174,7 @@ function createCocosProjectTools({ createSchema }) { [] ), handler: async (args) => await tryEditorRequests([ + { channel: 'preview', method: 'open', args: [args || {}] }, { channel: 'preview', method: 'start', args: [args || {}] }, { channel: 'preview', method: 'open-preview', args: [args || {}] }, { channel: 'builder', method: 'preview', args: [args || {}] }, diff --git a/lib/tools/scene-management.js b/lib/tools/scene-management.js new file mode 100644 index 0000000..e62f6f7 --- /dev/null +++ b/lib/tools/scene-management.js @@ -0,0 +1,244 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { tryEditorRequests } = require('./cocos-project'); +const { openAsset } = require('../assets'); + +const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'resources', 'template.scene'); + +function buildSceneContent(sceneName, templatePath) { + const tplPath = templatePath || TEMPLATE_PATH; + let content; + try { + content = fs.readFileSync(tplPath, 'utf8'); + } catch (err) { + throw new Error(`Failed to read scene template at ${tplPath}: ${err.message}`); + } + const json = JSON.parse(content); + if (json[0] && json[0]._name !== undefined) { + json[0]._name = sceneName; + } + if (json[1] && json[1]._name !== undefined) { + json[1]._name = sceneName; + } + return JSON.stringify(json); +} + +function createSceneManagementTools({ createSchema, sceneBridge }) { + return [ + { + name: 'create_scene', + profile: 'full', + description: '[core] Create a new scene asset in the Cocos project and optionally open it.', + inputSchema: createSchema( + { + sceneName: { type: 'string', description: 'Name of the new scene (without extension).' }, + savePath: { type: 'string', description: 'Asset-db URL, e.g. db://assets/scenes/NewScene.scene.' }, + open: { type: 'boolean', description: 'Whether to open the scene after creation. Default true.' }, + overwrite: { type: 'boolean', description: 'Overwrite if the asset already exists. Default false.' }, + }, + ['sceneName', 'savePath'] + ), + handler: async (args) => { + const sceneName = String(args.sceneName || '').trim(); + const savePath = String(args.savePath || '').trim(); + if (!sceneName) { + throw new Error('sceneName is required.'); + } + if (!savePath) { + throw new Error('savePath is required.'); + } + if (!savePath.endsWith('.scene')) { + throw new Error('savePath must end with .scene'); + } + + const content = buildSceneContent(sceneName); + const createArgs = [savePath, content]; + if (args.overwrite === true) { + createArgs.push({ overwrite: true }); + } + + const result = await tryEditorRequests([ + { channel: 'asset-db', method: 'create-asset', args: createArgs }, + ]); + + const url = (result.result && (result.result.url || result.result.source)) || savePath; + const uuid = result.result && result.result.uuid; + + if (args.open !== false) { + try { + await openAsset(url); + } catch (_) { + // Opening is best-effort; creation succeeded. + } + } + + return { + created: true, + sceneName, + url, + uuid, + opened: args.open !== false, + }; + }, + }, + { + name: 'query_scene_state', + profile: 'core', + description: '[specialist] Query scene state: dirty (unsaved changes), ready, or soft-reload.', + inputSchema: createSchema( + { + action: { + type: 'string', + enum: ['is_dirty', 'is_ready', 'soft_reload'], + description: 'State query or action.', + }, + }, + ['action'] + ), + handler: async (args) => { + const action = String(args.action || '').trim(); + const IPC_MAP = { + is_dirty: [ + { channel: 'scene', method: 'query-dirty' }, + ], + is_ready: [ + { channel: 'scene', method: 'query-is-ready' }, + ], + soft_reload: [ + { channel: 'scene', method: 'soft-reload' }, + ], + }; + const candidates = IPC_MAP[action]; + if (!candidates) { + throw new Error(`Unknown action: ${action}. Must be one of: is_dirty, is_ready, soft_reload.`); + } + const result = await tryEditorRequests(candidates); + return { + action, + result: result.result, + }; + }, + }, + { + name: 'copy_paste_node', + profile: 'full', + description: '[core] Copy, cut, or paste scene nodes via the Cocos editor clipboard.', + inputSchema: createSchema( + { + action: { + type: 'string', + enum: ['copy', 'cut', 'paste', 'duplicate'], + description: 'Clipboard or duplication action.', + }, + uuids: { + type: 'array', + items: { type: 'string' }, + description: 'Node UUIDs to copy, cut, or duplicate.', + }, + target: { type: 'string', description: 'Target parent node UUID for paste.' }, + keepWorldTransform: { + type: 'boolean', + description: 'Keep world transform on paste. Default false.', + }, + }, + ['action'] + ), + handler: async (args) => { + const action = String(args.action || '').trim(); + + if (action === 'copy' || action === 'cut') { + const uuids = Array.isArray(args.uuids) ? args.uuids.filter(Boolean) : []; + if (uuids.length === 0) { + throw new Error(`uuids is required for action: ${action}.`); + } + const method = action === 'copy' ? 'copy-node' : 'cut-node'; + const result = await tryEditorRequests([ + { channel: 'scene', method, args: [uuids] }, + ]); + return { action, uuids, result: result.result }; + } + + if (action === 'duplicate') { + const uuids = Array.isArray(args.uuids) ? args.uuids.filter(Boolean) : []; + if (uuids.length === 0) { + throw new Error('uuids is required for action: duplicate.'); + } + const result = await tryEditorRequests([ + { channel: 'scene', method: 'duplicate-node', args: [uuids] }, + ]); + return { action, uuids, result: result.result }; + } + + if (action === 'paste') { + const target = String(args.target || '').trim(); + if (!target) { + throw new Error('target is required for action: paste.'); + } + const keepWorldTransform = args.keepWorldTransform === true; + const pasteOpts = { target, keepWorldTransform }; + const uuids = Array.isArray(args.uuids) ? args.uuids.filter(Boolean) : []; + if (uuids.length > 0) { + pasteOpts.uuids = uuids; + } + const result = await tryEditorRequests([ + { + channel: 'scene', + method: 'paste-node', + args: [pasteOpts], + }, + ]); + return { action, target, keepWorldTransform, result: result.result }; + } + + throw new Error(`Unknown action: ${action}. Must be one of: copy, cut, paste, duplicate.`); + }, + }, + { + name: 'rename_node', + profile: 'full', + description: '[core] Rename a scene node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node UUID.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + newName: { type: 'string', description: 'New name for the node.' }, + }, + ['newName'] + ), + handler: async (args) => sceneBridge.call('renameNode', args), + }, + { + name: 'reparent_node', + profile: 'full', + description: '[core] Move a node to a new parent in the scene hierarchy.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path to move.' }, + uuid: { type: 'string', description: 'Node UUID to move.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + targetPath: { type: 'string', description: 'New parent node path.' }, + targetUuid: { type: 'string', description: 'New parent node UUID.' }, + targetName: { type: 'string', description: 'Fallback exact new parent name.' }, + siblingIndex: { + type: 'number', + description: 'Optional insert position among siblings. Default: append.', + }, + keepWorldTransform: { + type: 'boolean', + description: 'Keep world transform after reparenting. Default false.', + }, + }, + [] + ), + handler: async (args) => sceneBridge.call('reparentNode', args), + }, + ]; +} + +module.exports = { + buildSceneContent, + createSceneManagementTools, +}; diff --git a/lib/tools/scripts.js b/lib/tools/scripts.js new file mode 100644 index 0000000..8e04ea5 --- /dev/null +++ b/lib/tools/scripts.js @@ -0,0 +1,148 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { resolveProjectPath } = require('../path-safety'); +const { refreshAssets } = require('./files'); + +const PRIMITIVE_TYPES = new Set(['Number', 'String', 'Boolean']); + +function generateComponentTemplate(className, properties = []) { + const ccTypes = new Set(['Component']); + const propLines = []; + + for (const prop of properties) { + const propName = String(prop.name || 'property').trim(); + const propType = String(prop.type || 'Number').trim(); + const defaultValue = prop.default !== undefined ? prop.default : getDefaultForType(propType); + + if (!PRIMITIVE_TYPES.has(propType)) { + ccTypes.add(propType); + } + + if (PRIMITIVE_TYPES.has(propType)) { + propLines.push(` @property`); + propLines.push(` ${propName}: ${typeToTs(propType)} = ${defaultValue};`); + } else { + propLines.push(` @property(${propType})`); + propLines.push(` ${propName}: ${propType} | null = null;`); + } + } + + const importTypes = Array.from(ccTypes).join(', '); + const propsBlock = propLines.length > 0 + ? propLines.join('\n') + : ' // Add properties here'; + + return `import { _decorator, ${importTypes} } from 'cc'; +const { ccclass, property } = _decorator; + +@ccclass('${className}') +export class ${className} extends Component { +${propsBlock} + + start() { + } + + update(deltaTime: number) { + } +} +`; +} + +function generatePlainTemplate(className) { + return `export class ${className} { +} +`; +} + +function getDefaultForType(type) { + switch (type) { + case 'Number': return '0'; + case 'String': return "''"; + case 'Boolean': return 'false'; + default: return 'null'; + } +} + +function typeToTs(type) { + switch (type) { + case 'Number': return 'number'; + case 'String': return 'string'; + case 'Boolean': return 'boolean'; + default: return type; + } +} + +function createScriptTools({ createSchema, getRuntimeContext }) { + return [ + { + name: 'create_script', + profile: 'full', + description: '[core] Create a new TypeScript component script with a standard Cocos template.', + inputSchema: createSchema( + { + scriptName: { type: 'string', description: 'Class name in PascalCase, e.g. PlayerController.' }, + savePath: { type: 'string', description: 'Asset-db URL or project path, e.g. db://assets/scripts/PlayerController.ts.' }, + template: { + type: 'string', + enum: ['component', 'plain'], + description: 'Script template. Default: component.', + }, + properties: { + type: 'array', + items: { type: 'object' }, + description: 'Optional @property declarations. Each item: { name, type, default }. Type can be Number, String, Boolean, or a CC class name like Label, Sprite, Node.', + }, + }, + ['scriptName', 'savePath'] + ), + handler: async (args) => { + const scriptName = String(args.scriptName || '').trim(); + let savePath = String(args.savePath || '').trim(); + if (!scriptName) { + throw new Error('scriptName is required.'); + } + if (!savePath) { + throw new Error('savePath is required.'); + } + if (!savePath.endsWith('.ts')) { + throw new Error('savePath must end with .ts'); + } + if (savePath.startsWith('db://')) { + savePath = savePath.slice('db://'.length); + } + + const templateType = args.template === 'plain' ? 'plain' : 'component'; + const properties = Array.isArray(args.properties) ? args.properties : []; + + const content = templateType === 'plain' + ? generatePlainTemplate(scriptName) + : generateComponentTemplate(scriptName, properties); + + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, savePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); + + const refreshMessage = await refreshAssets(projectPath, fullPath); + + return { + created: true, + scriptName, + savePath, + template: templateType, + className: scriptName, + contentLength: content.length, + refreshMessage, + }; + }, + }, + ]; +} + +module.exports = { + createScriptTools, + generateComponentTemplate, + generatePlainTemplate, +}; diff --git a/package.json b/package.json index 71324d0..6457061 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "docs/", "lib/", "panel/", + "resources/", "browser.js", "scene.js", "server.json", @@ -49,7 +50,7 @@ "access": "public" }, "scripts": { - "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check bin/funplay-cocos-mcp.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/javascript-safety.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-profiles.js && node --check lib/tool-registry.js && node --check lib/tools/assets-advanced.js && node --check lib/tools/cocos-project.js && node --check lib/tools/files.js && node --check lib/tools/scene-events.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/generate-tool-docs.js && node --check scripts/release.js", + "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check bin/funplay-cocos-mcp.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/javascript-safety.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-profiles.js && node --check lib/tool-registry.js && node --check lib/tools/assets-advanced.js && node --check lib/tools/cocos-project.js && node --check lib/tools/files.js && node --check lib/tools/scene-events.js && node --check lib/tools/scene-management.js && node --check lib/tools/scripts.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/generate-tool-docs.js && node --check scripts/release.js", "test": "node --test", "docs:generate": "node scripts/generate-tool-docs.js", "docs:check": "node scripts/generate-tool-docs.js --check", diff --git a/resources/template.scene b/resources/template.scene new file mode 100644 index 0000000..65bb89d --- /dev/null +++ b/resources/template.scene @@ -0,0 +1,106 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Template", + "scene": { "__id__": 1 } + }, + { + "__type__": "cc.Scene", + "_name": "Template", + "_children": [], + "_globals": { "__id__": 2 } + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { "__id__": 3 }, + "shadows": { "__id__": 4 }, + "_skybox": { "__id__": 5 }, + "fog": { "__id__": 6 }, + "octree": { "__id__": 7 }, + "skin": { "__id__": 8 }, + "lightProbeInfo": { "__id__": 9 }, + "postSettings": { "__id__": 10 }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { "__type__": "cc.Vec4", "x": 0, "y": 0, "z": 0, "w": 0.520833125 }, + "_skyColor": { "__type__": "cc.Vec4", "x": 0, "y": 0, "z": 0, "w": 0.520833125 }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { "__type__": "cc.Vec4", "x": 0, "y": 0, "z": 0, "w": 0 }, + "_groundAlbedo": { "__type__": "cc.Vec4", "x": 0, "y": 0, "z": 0, "w": 0 }, + "_skyColorLDR": { "__type__": "cc.Vec4", "x": 0.2, "y": 0.5, "z": 0.8, "w": 1 }, + "_skyIllumLDR": 20000, + "_groundAlbedoLDR": { "__type__": "cc.Vec4", "x": 0.2, "y": 0.2, "z": 0.2, "w": 1 } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { "__type__": "cc.Vec3", "x": 0, "y": 1, "z": 0 }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { "__type__": "cc.Color", "r": 76, "g": 76, "b": 76, "a": 255 }, + "_maxReceived": 4, + "_size": { "__type__": "cc.Vec2", "x": 512, "y": 512 } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": null, + "_envmap": null, + "_envmapLDR": null, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": false, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { "__type__": "cc.Color", "r": 200, "g": 200, "b": 200, "a": 255 }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { "__type__": "cc.Vec3", "x": -1024, "y": -1024, "z": -1024 }, + "_maxPos": { "__type__": "cc.Vec3", "x": 1024, "y": 1024, "z": 1024 }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": false, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] diff --git a/scene.js b/scene.js index 5c7eab3..f33bbfb 100644 --- a/scene.js +++ b/scene.js @@ -761,6 +761,82 @@ exports.methods = { }; }, + async renameNode(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const oldName = node.name; + const oldPath = getNodePath(node); + const newName = String(options.newName || '').trim(); + if (!newName) { + throw new Error('newName is required.'); + } + + node.name = newName; + + return { + renamed: true, + oldName, + newName, + oldPath, + newPath: getNodePath(node), + uuid: node.uuid, + }; + }, + + async reparentNode(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const oldPath = getNodePath(node); + const oldParent = node.parent; + const keepWorldTransform = options.keepWorldTransform === true; + const oldWorldPos = keepWorldTransform ? node.worldPosition.clone() : null; + const oldWorldRot = keepWorldTransform ? node.worldRotation.clone() : null; + const oldWorldScale = keepWorldTransform ? node.worldScale.clone() : null; + + let newParent = null; + if (options.targetUuid) { + newParent = findNodeByUuid(options.targetUuid); + } else if (options.targetPath) { + newParent = findNodeByPath(options.targetPath); + } else if (options.targetName) { + newParent = findNodeByName(options.targetName); + } + + if (!newParent) { + throw new Error('Target parent node was not found.'); + } + + const siblingIndex = Number.isFinite(options.siblingIndex) ? options.siblingIndex : -1; + if (siblingIndex >= 0 && siblingIndex <= newParent.children.length) { + newParent.insertChild(node, siblingIndex); + } else { + node.parent = newParent; + } + + if (keepWorldTransform) { + node.worldPosition = oldWorldPos; + node.worldRotation = oldWorldRot; + node.worldScale = oldWorldScale; + } + + return { + reparented: true, + uuid: node.uuid, + oldPath, + newPath: getNodePath(node), + oldParent: oldParent ? oldParent.name : null, + newParent: newParent.name, + siblingIndex: node.getSiblingIndex(), + keepWorldTransform, + }; + }, + async setNodeTransform(options = {}) { const node = findNode(options); if (!node) { @@ -847,7 +923,7 @@ exports.methods = { const component = findComponent(node, options); if (!component) { - throw new Error('Target component was not found.'); + throw new Error(`Target component was not found${options.componentName ? ': ' + options.componentName : ''}. Ensure the script is compiled and the component is attached to the node.`); } const componentName = component.constructor ? component.constructor.name : 'UnknownComponent'; @@ -867,7 +943,7 @@ exports.methods = { const component = findComponent(node, options); if (!component) { - throw new Error('Target component was not found.'); + throw new Error(`Target component was not found${options.componentName ? ': ' + options.componentName : ''}. Ensure the script is compiled and the component is attached to the node.`); } return { @@ -892,7 +968,7 @@ exports.methods = { const component = findComponent(node, options); if (!component) { - throw new Error('Target component was not found.'); + throw new Error(`Target component was not found${options.componentName ? ': ' + options.componentName : ''}. Ensure the script is compiled and the component is attached to the node.`); } setValueByPath(component, options.propertyPath, options.value); @@ -913,7 +989,7 @@ exports.methods = { const component = findComponent(node, options); if (!component) { - throw new Error('Target component was not found.'); + throw new Error(`Target component was not found${options.componentName ? ': ' + options.componentName : ''}. Ensure the script is compiled and the component is attached to the node.`); } resetValueByPath(component, options.propertyPath); @@ -1410,7 +1486,7 @@ exports.methods = { const component = findComponent(target, { componentName }); if (!component) { - throw new Error(`Target component was not found: ${componentName}`); + throw new Error(`Target component was not found: ${componentName}. Ensure the script is compiled and the component is attached to the target node.`); } if (typeof component[handlerName] !== 'function') { throw new Error(`Target component method was not found: ${componentName}.${handlerName}`); @@ -1465,7 +1541,7 @@ exports.methods = { } const component = findComponent(node, options); if (!component) { - throw new Error('Target component was not found.'); + throw new Error(`Target component was not found${options.componentName ? ': ' + options.componentName : ''}. Ensure the script is compiled and the component is attached to the node.`); } const methodName = String(options.methodName || '').trim(); if (!methodName || typeof component[methodName] !== 'function') { diff --git a/test/find-unused-assets.test.js b/test/find-unused-assets.test.js new file mode 100644 index 0000000..5f39993 --- /dev/null +++ b/test/find-unused-assets.test.js @@ -0,0 +1,136 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { findUnusedAssets, collectUuidReferences } = require('../lib/tools/assets-advanced'); + +function createMockAssetsDir() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-unused-test-')); + const assetsDir = path.join(tmpDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + return { tmpDir, assetsDir }; +} + +function writeFile(dir, relPath, content) { + const fullPath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); +} + +test('findUnusedAssets returns assets not referenced by any serialized file', async () => { + const { tmpDir, assetsDir } = createMockAssetsDir(); + try { + const usedUuid = 'aaa11111-bbbb-cccc-dddd-eeeeeeee1111'; + writeFile(assetsDir, 'textures/used.png', 'fake-png'); + writeFile(assetsDir, 'textures/used.png.meta', JSON.stringify({ uuid: usedUuid })); + + const unusedUuid = 'aaa22222-bbbb-cccc-dddd-eeeeeeee2222'; + writeFile(assetsDir, 'textures/unused.png', 'fake-png'); + writeFile(assetsDir, 'textures/unused.png.meta', JSON.stringify({ uuid: unusedUuid })); + + writeFile(assetsDir, 'scenes/game.scene', JSON.stringify({ + __type__: 'cc.SceneAsset', + scene: { __uuid__: usedUuid }, + })); + + const mockListAssets = async () => [ + { uuid: usedUuid, url: 'db://assets/textures/used.png', type: 'cc.Texture2D' }, + { uuid: unusedUuid, url: 'db://assets/textures/unused.png', type: 'cc.Texture2D' }, + ]; + + const result = await findUnusedAssets(tmpDir, {}, mockListAssets); + assert.equal(result.totalScanned, 2); + assert.equal(result.unusedCount, 1); + assert.equal(result.unused[0].uuid, unusedUuid); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('findUnusedAssets skips .ts and .scene files from unused list', async () => { + const { tmpDir, assetsDir } = createMockAssetsDir(); + try { + const scriptUuid = 'bbb11111-bbbb-cccc-dddd-eeeeeeee3333'; + const sceneUuid = 'bbb22222-bbbb-cccc-dddd-eeeeeeee4444'; + + writeFile(assetsDir, 'scripts/MyScript.ts', 'export class MyScript {}'); + writeFile(assetsDir, 'scripts/MyScript.ts.meta', JSON.stringify({ uuid: scriptUuid })); + writeFile(assetsDir, 'scenes/level.scene', '{}'); + writeFile(assetsDir, 'scenes/level.scene.meta', JSON.stringify({ uuid: sceneUuid })); + + const mockListAssets = async () => [ + { uuid: scriptUuid, url: 'db://assets/scripts/MyScript.ts', type: 'cc.ScriptAsset' }, + { uuid: sceneUuid, url: 'db://assets/scenes/level.scene', type: 'cc.SceneAsset' }, + ]; + + const result = await findUnusedAssets(tmpDir, {}, mockListAssets); + assert.equal(result.totalScanned, 2); + assert.equal(result.unusedCount, 0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('findUnusedAssets respects excludeDirectories', async () => { + const { tmpDir, assetsDir } = createMockAssetsDir(); + try { + const referencedUuid = 'ccc11111-bbbb-cccc-dddd-eeeeeeee5555'; + const unreferencedUuid = 'ccc22222-bbbb-cccc-dddd-eeeeeeee6666'; + + writeFile(assetsDir, 'textures/a.png', 'fake'); + writeFile(assetsDir, 'textures/a.png.meta', JSON.stringify({ uuid: referencedUuid })); + writeFile(assetsDir, 'internal/b.png', 'fake'); + writeFile(assetsDir, 'internal/b.png.meta', JSON.stringify({ uuid: unreferencedUuid })); + + writeFile(assetsDir, 'internal/hidden.scene', JSON.stringify({ + __type__: 'cc.SceneAsset', + tex: { __uuid__: referencedUuid }, + })); + + const mockListAssets = async () => [ + { uuid: referencedUuid, url: 'db://assets/textures/a.png', type: 'cc.Texture2D' }, + { uuid: unreferencedUuid, url: 'db://assets/internal/b.png', type: 'cc.Texture2D' }, + ]; + + const result = await findUnusedAssets(tmpDir, { + excludeDirectories: ['internal'], + }, mockListAssets); + + assert.equal(result.totalScanned, 2); + assert.equal(result.unusedCount, 2); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('findUnusedAssets respects limit option', async () => { + const { tmpDir, assetsDir } = createMockAssetsDir(); + try { + const assets = []; + for (let i = 0; i < 5; i++) { + const uuid = `ddd${i}1111-bbbb-cccc-dddd-eeeeeeee777${i}`; + writeFile(assetsDir, `textures/tex${i}.png`, 'fake'); + writeFile(assetsDir, `textures/tex${i}.png.meta`, JSON.stringify({ uuid })); + assets.push({ uuid, url: `db://assets/textures/tex${i}.png`, type: 'cc.Texture2D' }); + } + + const mockListAssets = async () => assets; + + const result = await findUnusedAssets(tmpDir, { limit: 3 }, mockListAssets); + assert.equal(result.totalScanned, 5); + assert.equal(result.unusedCount, 5); + assert.equal(result.unused.length, 3); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('collectUuidReferences is re-exported and works', () => { + const refs = collectUuidReferences(JSON.stringify({ + __uuid__: 'abc12345-def6-7890-abcd-ef1234567890', + })); + assert.ok(refs.some((r) => r.uuid === 'abc12345-def6-7890-abcd-ef1234567890')); +}); diff --git a/test/scene-management.test.js b/test/scene-management.test.js new file mode 100644 index 0000000..1a782ef --- /dev/null +++ b/test/scene-management.test.js @@ -0,0 +1,359 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { createSceneManagementTools, buildSceneContent } = require('../lib/tools/scene-management'); + +function makeTools(overrides = {}) { + const createSchema = (properties, required) => { + const schema = { type: 'object', properties }; + if (required && required.length) { + schema.required = required; + } + return schema; + }; + const calls = []; + const sceneBridge = { + call: async (method, args) => { + calls.push({ method, args }); + return { ok: true, method, args }; + }, + }; + const tools = createSceneManagementTools({ createSchema, sceneBridge, ...overrides }); + const byName = {}; + for (const tool of tools) { + byName[tool.name] = tool; + } + return { tools, byName, calls, sceneBridge, createSchema }; +} + +function mockEditor(requestImpl) { + global.Editor = { + Message: { + request: requestImpl, + }, + }; +} + +function unmockEditor() { + delete global.Editor; +} + +test('create_scene requires sceneName and savePath', async () => { + const { byName } = makeTools(); + await assert.rejects(() => byName.create_scene.handler({ savePath: 'db://assets/x.scene' }), /sceneName is required/); + await assert.rejects(() => byName.create_scene.handler({ sceneName: 'X' }), /savePath is required/); +}); + +test('create_scene rejects non-.scene savePath', async () => { + const { byName } = makeTools(); + await assert.rejects( + () => byName.create_scene.handler({ sceneName: 'X', savePath: 'db://assets/x.prefab' }), + /must end with \.scene/ + ); +}); + +test('buildSceneContent reads template and replaces scene name', () => { + const content = buildSceneContent('MyNewScene'); + const json = JSON.parse(content); + assert.equal(json[0]._name, 'MyNewScene'); + assert.equal(json[1]._name, 'MyNewScene'); + assert.equal(json[0].__type__, 'cc.SceneAsset'); + assert.equal(json[1].__type__, 'cc.Scene'); + assert.ok(Array.isArray(json[1]._children), 'Scene should have _children array'); + assert.equal(json[1]._children.length, 0, 'Template scene should have no children'); + const globalTypes = json.slice(2).map((o) => o.__type__); + assert.ok(globalTypes.includes('cc.SceneGlobals')); + assert.ok(globalTypes.includes('cc.AmbientInfo')); + assert.ok(globalTypes.includes('cc.ShadowsInfo')); + assert.ok(globalTypes.includes('cc.SkyboxInfo')); + assert.ok(globalTypes.includes('cc.FogInfo')); + assert.ok(globalTypes.includes('cc.OctreeInfo')); + assert.ok(globalTypes.includes('cc.SkinInfo')); + assert.ok(globalTypes.includes('cc.LightProbeInfo')); + assert.ok(globalTypes.includes('cc.PostSettingsInfo')); +}); + +test('buildSceneContent throws on missing template file', () => { + assert.throws( + () => buildSceneContent('Test', '/nonexistent/path/template.scene'), + /Failed to read scene template/ + ); +}); + +test('create_scene creates asset with template content and returns result', async () => { + const requests = []; + mockEditor(async (channel, method, ...args) => { + requests.push({ channel, method, args }); + return { uuid: 'scene-uuid-1', url: 'db://assets/scenes/NewScene.scene' }; + }); + try { + const { byName } = makeTools(); + const result = await byName.create_scene.handler({ + sceneName: 'NewScene', + savePath: 'db://assets/scenes/NewScene.scene', + open: false, + }); + assert.equal(result.created, true); + assert.equal(result.sceneName, 'NewScene'); + assert.equal(result.uuid, 'scene-uuid-1'); + assert.equal(result.opened, false); + assert.equal(requests.length, 1); + assert.equal(requests[0].method, 'create-asset'); + assert.equal(requests[0].args[0], 'db://assets/scenes/NewScene.scene'); + const content = requests[0].args[1]; + assert.ok(typeof content === 'string' && content.length > 0, 'content should be non-empty string'); + const json = JSON.parse(content); + assert.equal(json[0]._name, 'NewScene'); + assert.equal(json[1]._name, 'NewScene'); + assert.equal(json[0].__type__, 'cc.SceneAsset'); + assert.equal(json[1].__type__, 'cc.Scene'); + } finally { + unmockEditor(); + } +}); + +test('create_scene with overwrite passes option to create-asset', async () => { + const requests = []; + mockEditor(async (channel, method, ...args) => { + requests.push({ channel, method, args }); + return { uuid: 'scene-uuid-2', url: 'db://assets/scenes/OverwriteScene.scene' }; + }); + try { + const { byName } = makeTools(); + await byName.create_scene.handler({ + sceneName: 'OverwriteScene', + savePath: 'db://assets/scenes/OverwriteScene.scene', + open: false, + overwrite: true, + }); + assert.equal(requests[0].args.length, 3, 'should have 3 args: url, content, option'); + assert.deepEqual(requests[0].args[2], { overwrite: true }); + } finally { + unmockEditor(); + } +}); + +test('query_scene_state is_dirty calls query-dirty', async () => { + const requests = []; + mockEditor(async (channel, method) => { + requests.push({ channel, method }); + return true; + }); + try { + const { byName } = makeTools(); + const result = await byName.query_scene_state.handler({ action: 'is_dirty' }); + assert.equal(result.action, 'is_dirty'); + assert.equal(result.result, true); + assert.equal(requests[0].method, 'query-dirty'); + } finally { + unmockEditor(); + } +}); + +test('query_scene_state is_ready calls query-is-ready', async () => { + const requests = []; + mockEditor(async (channel, method) => { + requests.push({ channel, method }); + return true; + }); + try { + const { byName } = makeTools(); + const result = await byName.query_scene_state.handler({ action: 'is_ready' }); + assert.equal(result.action, 'is_ready'); + assert.equal(requests[0].method, 'query-is-ready'); + } finally { + unmockEditor(); + } +}); + +test('query_scene_state soft_reload calls soft-reload', async () => { + const requests = []; + mockEditor(async (channel, method) => { + requests.push({ channel, method }); + return null; + }); + try { + const { byName } = makeTools(); + const result = await byName.query_scene_state.handler({ action: 'soft_reload' }); + assert.equal(result.action, 'soft_reload'); + assert.equal(requests[0].method, 'soft-reload'); + } finally { + unmockEditor(); + } +}); + +test('query_scene_state rejects unknown action', async () => { + const { byName } = makeTools(); + await assert.rejects(() => byName.query_scene_state.handler({ action: 'unknown' }), /Unknown action/); +}); + +test('copy_paste_node copy requires uuids', async () => { + const { byName } = makeTools(); + await assert.rejects(() => byName.copy_paste_node.handler({ action: 'copy' }), /uuids is required/); +}); + +test('copy_paste_node copy calls copy-node', async () => { + const requests = []; + mockEditor(async (channel, method, ...args) => { + requests.push({ channel, method, args }); + return { ok: true }; + }); + try { + const { byName } = makeTools(); + const result = await byName.copy_paste_node.handler({ + action: 'copy', + uuids: ['uuid-1', 'uuid-2'], + }); + assert.equal(result.action, 'copy'); + assert.deepEqual(result.uuids, ['uuid-1', 'uuid-2']); + assert.equal(requests[0].method, 'copy-node'); + assert.deepEqual(requests[0].args[0], ['uuid-1', 'uuid-2']); + } finally { + unmockEditor(); + } +}); + +test('copy_paste_node cut calls cut-node', async () => { + const requests = []; + mockEditor(async (channel, method, ...args) => { + requests.push({ channel, method, args }); + return { ok: true }; + }); + try { + const { byName } = makeTools(); + const result = await byName.copy_paste_node.handler({ + action: 'cut', + uuids: ['uuid-1'], + }); + assert.equal(result.action, 'cut'); + assert.equal(requests[0].method, 'cut-node'); + } finally { + unmockEditor(); + } +}); + +test('copy_paste_node paste requires target', async () => { + const { byName } = makeTools(); + await assert.rejects(() => byName.copy_paste_node.handler({ action: 'paste' }), /target is required/); +}); + +test('copy_paste_node paste calls paste-node with target and keepWorldTransform', async () => { + const requests = []; + mockEditor(async (channel, method, ...args) => { + requests.push({ channel, method, args }); + return { ok: true }; + }); + try { + const { byName } = makeTools(); + const result = await byName.copy_paste_node.handler({ + action: 'paste', + target: 'parent-uuid', + keepWorldTransform: true, + }); + assert.equal(result.action, 'paste'); + assert.equal(result.target, 'parent-uuid'); + assert.equal(result.keepWorldTransform, true); + assert.equal(requests[0].method, 'paste-node'); + assert.deepEqual(requests[0].args[0], { target: 'parent-uuid', keepWorldTransform: true }); + } finally { + unmockEditor(); + } +}); + +test('copy_paste_node paste with uuids passes them in options', async () => { + const requests = []; + mockEditor(async (channel, method, ...args) => { + requests.push({ channel, method, args }); + return { ok: true }; + }); + try { + const { byName } = makeTools(); + await byName.copy_paste_node.handler({ + action: 'paste', + target: 'parent-uuid', + uuids: ['copied-uuid-1'], + }); + assert.equal(requests[0].method, 'paste-node'); + assert.deepEqual(requests[0].args[0], { + target: 'parent-uuid', + keepWorldTransform: false, + uuids: ['copied-uuid-1'], + }); + } finally { + unmockEditor(); + } +}); + +test('copy_paste_node duplicate calls duplicate-node', async () => { + const requests = []; + mockEditor(async (channel, method, ...args) => { + requests.push({ channel, method, args }); + return ['new-uuid-1']; + }); + try { + const { byName } = makeTools(); + const result = await byName.copy_paste_node.handler({ + action: 'duplicate', + uuids: ['uuid-1'], + }); + assert.equal(result.action, 'duplicate'); + assert.deepEqual(result.uuids, ['uuid-1']); + assert.equal(requests[0].method, 'duplicate-node'); + assert.deepEqual(requests[0].args[0], ['uuid-1']); + } finally { + unmockEditor(); + } +}); + +test('copy_paste_node duplicate requires uuids', async () => { + const { byName } = makeTools(); + await assert.rejects(() => byName.copy_paste_node.handler({ action: 'duplicate' }), /uuids is required/); +}); + +test('copy_paste_node rejects unknown action', async () => { + const { byName } = makeTools(); + await assert.rejects(() => byName.copy_paste_node.handler({ action: 'invalid' }), /Unknown action/); +}); + +test('rename_node calls sceneBridge with renameNode', async () => { + const { byName, calls } = makeTools(); + await byName.rename_node.handler({ path: 'Canvas/TestNode', newName: 'RenamedNode' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'renameNode'); + assert.equal(calls[0].args.path, 'Canvas/TestNode'); + assert.equal(calls[0].args.newName, 'RenamedNode'); +}); + +test('reparent_node calls sceneBridge with reparentNode', async () => { + const { byName, calls } = makeTools(); + await byName.reparent_node.handler({ + path: 'Canvas/TestNode', + targetPath: 'Canvas/OtherParent', + siblingIndex: 2, + keepWorldTransform: true, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'reparentNode'); + assert.equal(calls[0].args.path, 'Canvas/TestNode'); + assert.equal(calls[0].args.targetPath, 'Canvas/OtherParent'); + assert.equal(calls[0].args.siblingIndex, 2); + assert.equal(calls[0].args.keepWorldTransform, true); +}); + +test('all 5 tools have correct profiles', () => { + const { byName } = makeTools(); + assert.equal(byName.create_scene.profile, 'full'); + assert.equal(byName.query_scene_state.profile, 'core'); + assert.equal(byName.copy_paste_node.profile, 'full'); + assert.equal(byName.rename_node.profile, 'full'); + assert.equal(byName.reparent_node.profile, 'full'); +}); + +test('all 5 tools have inputSchema with type object', () => { + const { byName } = makeTools(); + for (const name of Object.keys(byName)) { + assert.equal(byName[name].inputSchema.type, 'object', `${name} should have object schema`); + assert.ok(byName[name].inputSchema.properties, `${name} should have properties`); + } +}); diff --git a/test/scripts.test.js b/test/scripts.test.js new file mode 100644 index 0000000..600eeb7 --- /dev/null +++ b/test/scripts.test.js @@ -0,0 +1,139 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { + createScriptTools, + generateComponentTemplate, + generatePlainTemplate, +} = require('../lib/tools/scripts'); + +test('generateComponentTemplate produces valid component with start and update', () => { + const code = generateComponentTemplate('PlayerController'); + assert.ok(code.includes("import { _decorator, Component } from 'cc'")); + assert.ok(code.includes("@ccclass('PlayerController')")); + assert.ok(code.includes('export class PlayerController extends Component')); + assert.ok(code.includes('start()')); + assert.ok(code.includes('update(deltaTime: number)')); +}); + +test('generateComponentTemplate includes primitive @property', () => { + const code = generateComponentTemplate('Enemy', [ + { name: 'speed', type: 'Number', default: 5 }, + { name: 'label', type: 'String', default: "'Enemy'" }, + ]); + assert.ok(code.includes('@property')); + assert.ok(code.includes('speed: number = 5')); + assert.ok(code.includes("label: string = 'Enemy'")); + assert.ok(!code.includes('Label')); +}); + +test('generateComponentTemplate includes CC type @property', () => { + const code = generateComponentTemplate('Weapon', [ + { name: 'target', type: 'Label' }, + { name: 'sprite', type: 'Sprite' }, + ]); + assert.ok(code.includes('@property(Label)')); + assert.ok(code.includes('target: Label | null = null')); + assert.ok(code.includes('@property(Sprite)')); + assert.ok(code.includes('sprite: Sprite | null = null')); + assert.ok(code.includes('import { _decorator, Component, Label, Sprite }')); +}); + +test('generateComponentTemplate uses defaults when property default is omitted', () => { + const code = generateComponentTemplate('Item', [ + { name: 'count', type: 'Number' }, + { name: 'flag', type: 'Boolean' }, + ]); + assert.ok(code.includes('count: number = 0')); + assert.ok(code.includes('flag: boolean = false')); +}); + +test('generateComponentTemplate with no properties has placeholder comment', () => { + const code = generateComponentTemplate('Empty'); + assert.ok(code.includes('// Add properties here')); +}); + +test('generatePlainTemplate produces minimal class', () => { + const code = generatePlainTemplate('Utils'); + assert.ok(code.includes('export class Utils')); + assert.ok(!code.includes('@ccclass')); + assert.ok(!code.includes('@property')); +}); + +test('create_script writes file and returns metadata', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-script-test-')); + try { + const createSchema = (properties, required) => { + const schema = { type: 'object', properties }; + if (required && required.length) { + schema.required = required; + } + return schema; + }; + const getRuntimeContext = () => ({ projectPath: tmpDir }); + const tools = createScriptTools({ createSchema, getRuntimeContext }); + const tool = tools[0]; + + const result = await tool.handler({ + scriptName: 'TestComp', + savePath: 'assets/scripts/TestComp.ts', + template: 'component', + properties: [{ name: 'speed', type: 'Number', default: 10 }], + }); + + assert.equal(result.created, true); + assert.equal(result.scriptName, 'TestComp'); + assert.equal(result.template, 'component'); + assert.equal(result.className, 'TestComp'); + + const written = fs.readFileSync(path.join(tmpDir, 'assets', 'scripts', 'TestComp.ts'), 'utf8'); + assert.ok(written.includes('export class TestComp extends Component')); + assert.ok(written.includes('speed: number = 10')); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('create_script rejects non-.ts savePath', async () => { + const createSchema = () => ({ type: 'object', properties: {} }); + const getRuntimeContext = () => ({ projectPath: '/tmp' }); + const tools = createScriptTools({ createSchema, getRuntimeContext }); + await assert.rejects( + () => tools[0].handler({ scriptName: 'X', savePath: 'assets/scripts/X.js' }), + /must end with \.ts/ + ); +}); + +test('create_script requires scriptName and savePath', async () => { + const createSchema = () => ({ type: 'object', properties: {} }); + const getRuntimeContext = () => ({ projectPath: '/tmp' }); + const tools = createScriptTools({ createSchema, getRuntimeContext }); + await assert.rejects(() => tools[0].handler({ savePath: 'X.ts' }), /scriptName is required/); + await assert.rejects(() => tools[0].handler({ scriptName: 'X' }), /savePath is required/); +}); + +test('create_script plain template writes minimal class', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-script-test-')); + try { + const createSchema = () => ({ type: 'object', properties: {} }); + const getRuntimeContext = () => ({ projectPath: tmpDir }); + const tools = createScriptTools({ createSchema, getRuntimeContext }); + + const result = await tools[0].handler({ + scriptName: 'Helper', + savePath: 'assets/scripts/Helper.ts', + template: 'plain', + }); + + assert.equal(result.template, 'plain'); + const written = fs.readFileSync(path.join(tmpDir, 'assets', 'scripts', 'Helper.ts'), 'utf8'); + assert.ok(written.includes('export class Helper')); + assert.ok(!written.includes('@ccclass')); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/test/tool-registry.test.js b/test/tool-registry.test.js index 280b694..319b4bb 100644 --- a/test/tool-registry.test.js +++ b/test/tool-registry.test.js @@ -21,7 +21,7 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos- test('core profile exposes the documented focused tool set', () => { const tools = createRegistry('core').listTools(); - assert.equal(tools.length, 37); + assert.equal(tools.length, 38); assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true); assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true); assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true); @@ -31,12 +31,13 @@ test('core profile exposes the documented focused tool set', () => { assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true); assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true); assert.equal(tools.some((tool) => tool.name === 'set_selection'), true); + assert.equal(tools.some((tool) => tool.name === 'query_scene_state'), true); assert.equal(tools.some((tool) => tool.name === 'write_file'), false); }); test('full profile exposes all built-in tools', () => { const tools = createRegistry('full').listTools(); - assert.equal(tools.length, 101); + assert.equal(tools.length, 110); assert.equal(tools.some((tool) => tool.name === 'write_file'), true); assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true); assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);