chore: 纳入项目全部源码进根仓库统一管理
删除 YouleNexus 内嵌 .git,统一由根仓库唯一管理。提交: - projects/ 旧 H5 模板 Game_Surface_3 与各子游戏源码 - cocoscreator_projects/YouleNexus 工程 + cocos-creator-mcp 扩展源码 - CLAUDE.md、.mcp.json 等配置 library/temp/node_modules/dist 等缓存由 .gitignore 排除。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"cocos-creator-mcp": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"G:/Works/YouleGamesCocosCreator/cocoscreator_projects/YouleNexus/extensions/cocos-creator-mcp/client/stdio-bridge.js"
|
||||
],
|
||||
"env": {
|
||||
"COCOS_MCP_URL": "http://127.0.0.1:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 第一准则:服务器零改动
|
||||
|
||||
**任何前端开发都必须完全遵循前后端数据包的协议与数据结构,做到服务器零改动。** 这是本仓库不可逾越的最高准则,优先于一切其它考量:
|
||||
|
||||
- 协议信封、`route`/`rpc` 命名、字段名与类型、`roomtype` 配置数组、`deskinfo` 快照结构等,**必须与现有协议逐字节对齐**(依据 `docs/protocol/`),不得为前端方便而改变格式或新增/重命名字段。
|
||||
- 遇到协议层有疑问时,**先读 `docs/protocol/` 对应章节核对,不要臆测**;宁可前端多做适配,也绝不要求服务器配合改动。
|
||||
- 新的 Cocos 前端(`YouleNexus`)的目标,就是在不改服务器的前提下复刻该协议契约、实现联调兼容。
|
||||
|
||||
## 仓库总览
|
||||
|
||||
这是友乐(Youle)棋牌游戏平台的前端工作区,核心是「**一套前端模板 + 多个子游戏**」的架构,并正在迁移到 Cocos Creator。三个顶层目录:
|
||||
|
||||
- **`projects/`** —— 旧版 HTML5 前端。`projects/Game_Surface_3` 是**前端模板(平台外壳)**,其余目录(`doudizhu` / `erqiwang` / `niuniu` / `majiang_jx` / `guanpai-jx` / `pdk_card_client-jinxian` / `sangelaok` / `zpy` / `gamehall3` …)都是**基于该模板扩展出来的子游戏**,平台层几乎不动,只改子游戏层 + 美术数据。
|
||||
- **`docs/protocol/`** —— 前端模板框架与服务器收发包的**协议规范**(权威文档,8 篇)。改动任何网络层代码前**必须先读对应章节**,服务器不可改,前端必须严格对齐协议。
|
||||
- **`cocoscreator_projects/YouleNexus/`** —— 新的 Cocos Creator 3.8+ 前端工程,目标是按 `docs/protocol` 复刻协议契约,做到服务器零改动即可联调。
|
||||
|
||||
## 前端模板 Game_Surface_3 与子游戏(`projects/`)
|
||||
|
||||
**四层结构(自底向上):**
|
||||
1. **引擎层** —— `js/gameabc.min.js`(gameabc 精灵引擎)+ Spine(`spine-canvas.js` / `SpineMgr.js`)+ Canvas 渲染;提供触屏/定时器/资源/WebSocket 底层回调。
|
||||
2. **桥接层** —— `js/gamemain.js`:统一事件总线,把引擎回调分发到平台 UI、子游戏 `Game_Modify`、对战逻辑。
|
||||
3. **平台层** —— `js/00_Surface/*`(12 个文件):登录、大厅、房间、聊天、社交、网络、资源、排行、任务等通用能力。**子游戏间几乎不变**。
|
||||
4. **子游戏层** —— `js/01_SubGame/*`(3 个文件):只实现本游戏的对局逻辑与房间 UI。
|
||||
|
||||
**平台层关键文件(`js/00_Surface/`):** `02_Const.js`(`ConstVal`/`AppList`/路由表)、`04_Data.js`(`GameData` 全局态)、`09_Net.js`(`Net._SendData()` 收发分发)、`12_Logic.js`(`Logic` 连接/重连/分发编排)、`07_Desk.js`(`Desk` 房间状态机)、`06_Player.js`(`C_Player`/`Player` 数据结构)、`11_GameUI.js`(平台 UI)、`00_minhttp.js`(WebSocket 底层封装)。
|
||||
|
||||
**子游戏层(`js/01_SubGame/`,开发子游戏 = 改这 3 个文件 + 美术数据):**
|
||||
- `00_SubGame_Config.js` —— `Game_Config`(房间数、气泡位置、分享、调试开关)
|
||||
- `01_SubGame_modify.js` —— `Game_Modify` / `gameCombat`(对局实现 + 房间/胜负 UI)
|
||||
- `02_SubGame_Input.js` —— 平台回调钩子(模板里是桩,子游戏覆写为真实逻辑)
|
||||
- 另需补充:`gamemain.js`(扩展对局初始化)、专用算法模块(牌型/出牌等)、美术数据 `output/gameabc_data.min.js`。
|
||||
|
||||
**状态归属:** 平台层维护 `Desk`(房间/座位)、`C_Player`(自己)、`GameData`(连接/资产);子游戏维护对局态(手牌、出牌历史、轮次、分数)于自身命名空间,并需可序列化为 `deskinfo` 以支持断线重连。
|
||||
|
||||
## 网络协议(`docs/protocol/`,新旧前端共同契约)
|
||||
|
||||
- **传输层:** 单条长连 **WebSocket,纯 JSON 文本**(无二进制/protobuf)。先 HTTP GET 配置服务拿 `urlserver`,再 `ws://<ip:port>` 连接;支持 `connect_agentserver`/`connect_roomserver` 切换服务器。
|
||||
- **消息信封:**
|
||||
- 客户端→服务器(单层):`{ "app":"youle", "route":"agent|room|platform|<game_route>", "rpc":"<name>", "data":{…} }`
|
||||
- 服务器→客户端(**双层包裹**):外层 `{ "data": <inner> }`;inner 可能是字符串需 `JSON.parse`。**收包必做过滤**:`@toconcon`(前 9 字符,握手包,忽略)、`data.com === "@serverheartbeat"`(服务器心跳,~20s 一次,**不回复**)。
|
||||
- **路由分发:** `route ∈ {agent, room, platform}` → 平台层处理(`Net.<rpc>` → `Desk.*` / `C_Player.*`);`route` 为其它值 → 交子游戏 `Game_Modify._ReceiveData(msg)`,由子游戏 `switch(msg.rpc)` 派发对局包。平台请求通用身份字段:`agentid`/`gameid`/`playerid`(房间操作再加 `roomcode`)。
|
||||
- **心跳/超时/重连:** 客户端 30s 收包超时(`ConstVal.Max.heartbeat = 30000`),超时报“网络慢”并重连;断线时 `Logic` 轮询候选服务器,重连后重发 `player_login`。
|
||||
- **核心数据结构(详见 `04-数据结构.md`):** `C_Player`(本地玩家)、`Player(seat)`(座位玩家)、`Desk`(房间/牌桌)、`player_login` 响应(账号资产 + 可选房间恢复段:`roomcode`/`isbattle`/`deskinfo`…)。
|
||||
- **两个不可妥协的约束:** ① `roomtype` 房间配置数组结构必须与目标子游戏**逐字节一致**(服务器据此解析规则);② `deskinfo` 对局快照须可序列化/反序列化以支持 `isbattle==1` 时 `Game_Modify.Reconnect(deskinfo)` 断线重连。
|
||||
|
||||
**协议文档索引:** `00-框架架构设计` · `01-传输层与架构` · `02-协议-agent路由` · `03-协议-room路由` · `04-数据结构` · `05-游戏内协议与桥接` · `06-子游戏开发模式与Cocos方案`。改协议相关代码前先读对应篇。
|
||||
|
||||
## Cocos Creator 新前端:优先使用 cocos-creator-mcp
|
||||
|
||||
新工程 `cocoscreator_projects/YouleNexus`。**任何涉及该工程的编辑器操作**(场景、节点、组件、prefab、资源、预览/截图/录制等),都应**优先使用 `cocos-creator-mcp` 这组 MCP 工具**(命名空间 `mcp__cocos-creator-mcp__*`,共 164 个)直接驱动编辑器,而不是手改 `.scene` / `.prefab` / `.meta` 等序列化文件——手改极易破坏 UUID 引用与序列化结构。
|
||||
|
||||
**前置条件(缺一不可,否则工具调用必然失败):**
|
||||
1. Cocos Creator 3.8+ 已打开 `YouleNexus` 工程;
|
||||
2. 扩展 `cocos-creator-mcp` 已启用,并在其面板里点了 **Start Server**(监听 `http://127.0.0.1:3000/mcp`)。
|
||||
|
||||
**开工前先探活**:先调用 `mcp__cocos-creator-mcp__server_get_status`(或 `curl http://127.0.0.1:3000/health`,期望 `{"status":"ok","tools":164}`)。**连不上时,提示用户在编辑器里启动服务,不要盲目重试**——MCP 桥(`extensions/cocos-creator-mcp/client/stdio-bridge.js`)只转发,无法替你启动编辑器服务。
|
||||
|
||||
**工具分类(按前缀):** `scene_*`(场景生命周期/层级/查询/撤销)、`node_*`(增删改/变换/树)、`component_*`、`prefab_*`、`asset_*`、`project_*`、`builder_*`(预览/构建)、`debug_*`(截图、录制、game command、控制台日志)、`view_*`(gizmo/相机/网格)、`refimage_*`、`preferences_*`、`server_*`。
|
||||
|
||||
> MCP 的项目级安装方式与排障细节(扩展位置、`package.json` name 必须为 `cocos-creator-mcp`、镜像 404 等坑)记录在项目记忆 `cocos-creator-mcp-setup`。
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"image": {
|
||||
"type": "sprite-frame"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
#///////////////////////////
|
||||
# Cocos Creator 3D Project
|
||||
#///////////////////////////
|
||||
library/
|
||||
temp/
|
||||
local/
|
||||
build/
|
||||
profiles/
|
||||
native
|
||||
#//////////////////////////
|
||||
# NPM
|
||||
#//////////////////////////
|
||||
node_modules/
|
||||
|
||||
#//////////////////////////
|
||||
# VSCode
|
||||
#//////////////////////////
|
||||
.vscode/
|
||||
|
||||
#//////////////////////////
|
||||
# WebStorm
|
||||
#//////////////////////////
|
||||
.idea/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Yusuke Harada
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,539 @@
|
||||
# Cocos Creator MCP
|
||||
|
||||
[MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server extension for Cocos Creator 3.8+.
|
||||
|
||||
AI assistants like Claude can control Cocos Creator editor through this extension — creating nodes, editing scenes, managing prefabs, building projects, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **164 Tools** across 13 categories — comprehensive editor automation
|
||||
- **Streamable HTTP (SSE)** — Native support for MCP's Streamable HTTP transport
|
||||
- **JSON-RPC 2.0** — Standard MCP protocol compliance
|
||||
- **Prefab Property Persistence** — Component properties are correctly preserved when saving prefabs
|
||||
- **Preview in Editor** — Start editor preview programmatically (no manual button click needed)
|
||||
- **Screenshot Capture** — Capture editor window and game preview screenshots (WebP / PNG)
|
||||
- **Video Recording** — Record game preview canvas to video (MP4 / WebM) via Preview Recorder panel
|
||||
- **Game Command Control** — Send commands to running game preview (screenshot, click, navigate, state, inspect)
|
||||
- **Client Scripts** — Drop-in TypeScript files for game preview integration (`client/`)
|
||||
- **Auto Start** — Server starts automatically when the extension loads
|
||||
- **Tool Call Logging** — All tool invocations logged with timing for debugging
|
||||
- **UUID Validation** — Input validation helpers for better error messages
|
||||
- **i18n** — English, Japanese, Chinese
|
||||
- **Regression Tests** — 200+ assertions covering core tool flows
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install
|
||||
|
||||
Copy or symlink this extension into your Cocos Creator project's `extensions/` directory:
|
||||
|
||||
```bash
|
||||
# Windows (Junction — no admin required)
|
||||
mklink /J "your-project\extensions\cocos-creator-mcp" "path\to\cocos-creator-mcp"
|
||||
|
||||
# macOS / Linux
|
||||
ln -s /path/to/cocos-creator-mcp your-project/extensions/cocos-creator-mcp
|
||||
```
|
||||
|
||||
### 2. Build
|
||||
|
||||
```bash
|
||||
cd cocos-creator-mcp
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3. Enable in Cocos Creator
|
||||
|
||||
1. Open your project in Cocos Creator
|
||||
2. Go to **Extension > Extension Manager**
|
||||
3. Enable **Cocos Creator MCP**
|
||||
4. Open the panel: **Extension > Cocos Creator MCP > Open Panel**
|
||||
5. Click **Start Server** (or set `autoStart: true` in config)
|
||||
|
||||
### 4. Connect from Claude Code
|
||||
|
||||
Pick one of the two transports below.
|
||||
|
||||
#### Option A — stdio bridge (recommended for Claude Code VSCode extension)
|
||||
|
||||
The Claude Code VSCode extension currently has a bug where it unconditionally
|
||||
tries OAuth Dynamic Client Registration for HTTP-type MCP servers and fails
|
||||
with `SDK auth failed` (see upstream issues
|
||||
[#26917](https://github.com/anthropics/claude-code/issues/26917),
|
||||
[#38102](https://github.com/anthropics/claude-code/issues/38102),
|
||||
[#29697](https://github.com/anthropics/claude-code/issues/29697)).
|
||||
To avoid it entirely, use the bundled stdio bridge. It speaks JSON-RPC on
|
||||
stdin/stdout and forwards to the HTTP server internally.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cocos-creator-mcp": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"<ABSOLUTE_PATH_TO>/cocos-creator-mcp/client/stdio-bridge.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optional env var: `COCOS_MCP_URL` (default `http://127.0.0.1:3000/mcp`).
|
||||
|
||||
#### Option B — direct HTTP
|
||||
|
||||
Works with Claude Code CLI, Cursor, Cline, and other clients that don't force
|
||||
OAuth on HTTP MCP. The server ships minimal dummy OAuth endpoints
|
||||
(`/.well-known/oauth-*`, `/oauth/register|authorize|token`) so OAuth-requiring
|
||||
clients can still complete a pro-forma flow on localhost.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cocos-creator-mcp": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The dummy OAuth endpoints will be removed once upstream issues
|
||||
> ([#26917](https://github.com/anthropics/claude-code/issues/26917),
|
||||
> [#38102](https://github.com/anthropics/claude-code/issues/38102))
|
||||
> are resolved or real authentication is introduced.
|
||||
|
||||
### 5. Verify
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/health
|
||||
# {"status":"ok","tools":164}
|
||||
```
|
||||
|
||||
## Available Tools (164)
|
||||
|
||||
<details>
|
||||
<summary><strong>Scene (6)</strong> — Scene lifecycle and hierarchy</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `scene_get_hierarchy` | Get the node tree (with optional component info) |
|
||||
| `scene_open` | Open a scene by UUID or db:// path |
|
||||
| `scene_save` | Save the current scene |
|
||||
| `scene_get_list` | List all .scene files |
|
||||
| `scene_close` | Close the current scene |
|
||||
| `scene_get_current` | Get name and UUID of the current scene |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Scene Advanced (30)</strong> — Undo, clipboard, queries, property manipulation</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `scene_execute_script` | Execute a custom scene script method |
|
||||
| `scene_snapshot` | Take a snapshot for undo |
|
||||
| `scene_snapshot_abort` | Abort the current undo snapshot |
|
||||
| `scene_begin_undo` | Begin recording undo operations |
|
||||
| `scene_end_undo` | End undo recording |
|
||||
| `scene_cancel_undo` | Cancel undo recording |
|
||||
| `scene_query_dirty` | Check if scene has unsaved changes |
|
||||
| `scene_query_ready` | Check if scene is fully loaded |
|
||||
| `scene_query_classes` | List all available component classes |
|
||||
| `scene_query_components` | Query components for a node |
|
||||
| `scene_query_component_has_script` | Check if a component has a script file |
|
||||
| `scene_query_node_tree` | Get raw node tree from editor |
|
||||
| `scene_query_node` | Get full property dump of a node |
|
||||
| `scene_query_component` | Get full property dump of a component |
|
||||
| `scene_query_nodes_by_asset` | Find nodes referencing an asset |
|
||||
| `scene_query_scene_bounds` | Get scene bounding rect |
|
||||
| `scene_soft_reload` | Soft reload scene |
|
||||
| `scene_reset_node_transform` | Reset transform to default |
|
||||
| `scene_reset_property` | Reset a specific property to default |
|
||||
| `scene_reset_component` | Reset a component to defaults |
|
||||
| `scene_copy_node` | Copy node to clipboard |
|
||||
| `scene_paste_node` | Paste node from clipboard |
|
||||
| `scene_cut_node` | Cut node to clipboard |
|
||||
| `scene_create` | Create a new empty scene |
|
||||
| `scene_save_as` | Save scene to a new file |
|
||||
| `scene_set_parent` | Reparent node(s) with official API |
|
||||
| `scene_restore_prefab` | Restore prefab node to original state |
|
||||
| `scene_execute_component_method` | Call a method on a component |
|
||||
| `scene_move_array_element` | Reorder array property element |
|
||||
| `scene_remove_array_element` | Remove array property element |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Scene View (19)</strong> — Gizmo, camera, grid, viewport</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `view_change_gizmo_tool` | Switch gizmo tool (move/rotate/scale/rect) |
|
||||
| `view_query_gizmo_tool` | Get current gizmo tool |
|
||||
| `view_change_gizmo_pivot` | Change pivot mode (center/pivot) |
|
||||
| `view_query_gizmo_pivot` | Get current pivot mode |
|
||||
| `view_change_gizmo_coordinate` | Change coordinate system (local/global) |
|
||||
| `view_query_gizmo_coordinate` | Get current coordinate system |
|
||||
| `view_change_mode_2d_3d` | Switch 2D/3D view |
|
||||
| `view_query_mode_2d_3d` | Get current view mode |
|
||||
| `view_set_grid_visible` | Show/hide grid |
|
||||
| `view_query_grid_visible` | Check grid visibility |
|
||||
| `view_set_icon_gizmo_3d` | Toggle 3D icon gizmos |
|
||||
| `view_query_icon_gizmo_3d` | Check 3D icon gizmo state |
|
||||
| `view_set_icon_gizmo_size` | Set icon gizmo size |
|
||||
| `view_query_icon_gizmo_size` | Get icon gizmo size |
|
||||
| `view_focus_on_node` | Focus camera on node(s) |
|
||||
| `view_align_with_view` | Align node with camera view |
|
||||
| `view_align_view_with_node` | Align camera with node |
|
||||
| `view_get_status` | Get all view settings at once |
|
||||
| `view_reset` | Reset scene view to default |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Node (14)</strong> — Create, edit, move, delete nodes</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `node_create` | Create node (with optional components) |
|
||||
| `node_get_info` | Get node details (position, scale, components) |
|
||||
| `node_find_by_name` | Find nodes by name |
|
||||
| `node_set_property` | Set node property |
|
||||
| `node_set_transform` | Set position/rotation/scale at once |
|
||||
| `node_set_active` | Set node visibility |
|
||||
| `node_set_layer` | Set node layer |
|
||||
| `node_delete` | Delete node |
|
||||
| `node_move` | Move node to new parent |
|
||||
| `node_duplicate` | Duplicate node |
|
||||
| `node_get_all` | List all nodes |
|
||||
| `node_detect_type` | Detect node type (2D/3D/Node) |
|
||||
| `node_create_tree` | Create a node hierarchy in one call (v1.6) |
|
||||
| `node_set_layout` | Set UITransform + Widget + color/opacity at once (v1.13) |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Component (8)</strong> — Add, remove, configure components</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `component_add` | Add component (e.g. `cc.Label`, `cc.Sprite`) |
|
||||
| `component_remove` | Remove component |
|
||||
| `component_get_components` | List components on node |
|
||||
| `component_set_property` | Set component property (Label.string, fontSize, etc.) |
|
||||
| `component_get_info` | Get full component dump by UUID |
|
||||
| `component_get_available` | List all available component classes |
|
||||
| `component_auto_bind` | Auto-match `@property` fields to nodes by name (v1.12) |
|
||||
| `component_query_enum` | Query enum values of a component property (v1.6) |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Prefab (12)</strong> — Prefab lifecycle and validation</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `prefab_list` | List all prefabs |
|
||||
| `prefab_create` | Create prefab from node (properties preserved) |
|
||||
| `prefab_instantiate` | Instantiate prefab into scene |
|
||||
| `prefab_get_info` | Get prefab asset info |
|
||||
| `prefab_update` | Apply prefab changes |
|
||||
| `prefab_revert` | Revert prefab instance to original |
|
||||
| `prefab_duplicate` | Copy prefab to new path |
|
||||
| `prefab_validate` | Validate prefab for broken references |
|
||||
| `prefab_open` | Open prefab for editing (v1.5) |
|
||||
| `prefab_close` | Close prefab editing mode and return to scene (v1.5) |
|
||||
| `prefab_create_and_replace` | Create prefab and replace instance in one call (v1.5) |
|
||||
| `prefab_create_from_spec` | Create node tree + auto-bind + prefab_create in one call (v1.12) |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Asset (18)</strong> — CRUD, queries, metadata, dependencies</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `asset_create` | Create new asset |
|
||||
| `asset_delete` | Delete asset |
|
||||
| `asset_move` | Move/rename asset |
|
||||
| `asset_copy` | Copy asset |
|
||||
| `asset_save` | Save asset |
|
||||
| `asset_reimport` | Re-import asset |
|
||||
| `asset_import` | Import external file into project |
|
||||
| `asset_query_path` | Get file path for UUID |
|
||||
| `asset_query_uuid` | Get UUID for path |
|
||||
| `asset_query_url` | Get URL for UUID |
|
||||
| `asset_get_details` | Get asset metadata |
|
||||
| `asset_get_dependencies` | Get asset dependencies |
|
||||
| `asset_open_external` | Open in external editor |
|
||||
| `asset_save_meta` | Save asset meta/importer settings |
|
||||
| `asset_generate_available_url` | Generate non-conflicting asset path |
|
||||
| `asset_query_ready` | Check if asset DB is ready |
|
||||
| `asset_query_users` | Find assets that reference this asset |
|
||||
| `asset_query_missing` | Check for missing references |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Project (8)</strong> — Project info, settings, engine</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `project_get_info` | Get project name and path |
|
||||
| `project_refresh_assets` | Refresh asset database |
|
||||
| `project_get_asset_info` | Get asset info by UUID |
|
||||
| `project_find_asset` | Find assets by glob pattern |
|
||||
| `project_get_settings` | Get project settings |
|
||||
| `project_set_settings` | Set a project setting |
|
||||
| `project_get_engine_info` | Get engine version and paths |
|
||||
| `project_query_scripts` | Query all script plugins |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Preferences (4)</strong> — Editor preferences</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `preferences_get` | Get preference value |
|
||||
| `preferences_set` | Set preference value |
|
||||
| `preferences_get_all` | Get all preferences for a protocol |
|
||||
| `preferences_reset` | Reset preference to default |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Debug (22)</strong> — Editor info, logs, preview, screenshots, recording, game control</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `debug_get_editor_info` | Get editor version and environment |
|
||||
| `debug_list_messages` | List available Editor messages |
|
||||
| `debug_execute_script` | Execute scene script method |
|
||||
| `debug_get_console_logs` | Get console log entries (scene + game preview) |
|
||||
| `debug_clear_console` | Clear editor console and log buffers |
|
||||
| `debug_preview` | Start Preview in Editor (play button) |
|
||||
| `debug_clear_code_cache` | Clear code cache (Developer > Cache) |
|
||||
| `debug_screenshot` | Capture editor window screenshot |
|
||||
| `debug_game_command` | Send command to game preview (screenshot/click/navigate/state/inspect) |
|
||||
| `debug_batch_screenshot` | Navigate to multiple pages and screenshot each |
|
||||
| `debug_record_start` | Start recording game preview canvas (webm/mp4) |
|
||||
| `debug_record_stop` | Stop recording and save video file |
|
||||
| `debug_reload_extension` | Reload this MCP extension (after build) |
|
||||
| `debug_list_extensions` | List installed extensions |
|
||||
| `debug_get_extension_info` | Get extension details |
|
||||
| `debug_get_project_logs` | Read project log entries |
|
||||
| `debug_search_project_logs` | Search patterns in project logs |
|
||||
| `debug_get_log_file_info` | Get log file metadata |
|
||||
| `debug_validate_scene` | Validate scene for common issues |
|
||||
| `debug_query_devices` | List connected devices |
|
||||
| `debug_open_url` | Open URL in system browser |
|
||||
| `debug_wait_compile` | Wait for TypeScript compile to finish (v1.12) |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Server (7)</strong> — Editor server and network</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `server_query_ip_list` | Get editor server IPs |
|
||||
| `server_query_port` | Get editor server port |
|
||||
| `server_get_status` | Get full server status |
|
||||
| `server_check_connectivity` | Check if editor server is reachable |
|
||||
| `server_get_network_interfaces` | Get network interface details |
|
||||
| `server_get_build_hash` | Get build hash of MCP dist files (v1.6) |
|
||||
| `server_check_code_sync` | Check if runtime matches dist hash (v1.6) |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Builder (5)</strong> — Build and preview</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `builder_open_panel` | Open Build panel |
|
||||
| `builder_get_settings` | Get build configuration |
|
||||
| `builder_query_tasks` | Query active build tasks |
|
||||
| `builder_run_preview` | Start preview server |
|
||||
| `builder_stop_preview` | Stop preview server |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Reference Image (11)</strong> — Scene overlay images</summary>
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `refimage_add` | Add a reference image |
|
||||
| `refimage_remove` | Remove a reference image |
|
||||
| `refimage_list` | List all reference images |
|
||||
| `refimage_clear_all` | Remove all reference images |
|
||||
| `refimage_switch` | Switch active reference image |
|
||||
| `refimage_set_position` | Set image position |
|
||||
| `refimage_set_scale` | Set image scale |
|
||||
| `refimage_set_opacity` | Set image opacity |
|
||||
| `refimage_query_config` | Get reference image config |
|
||||
| `refimage_query_current` | Get current active image info |
|
||||
| `refimage_refresh` | Refresh image display |
|
||||
</details>
|
||||
|
||||
## Client Scripts
|
||||
|
||||
The `client/` directory contains TypeScript files for runtime communication between the game preview and the MCP server. Since the extension is installed in `extensions/`, these files can be imported directly — no copying needed.
|
||||
|
||||
### McpConsoleCapture
|
||||
|
||||
Captures `console.log/warn/error` from the game preview and sends them to the MCP server.
|
||||
|
||||
```typescript
|
||||
// Import from extensions/ (adjust relative path as needed)
|
||||
import { initMcpConsoleCapture } from "../../extensions/cocos-creator-mcp/client/McpConsoleCapture";
|
||||
initMcpConsoleCapture();
|
||||
```
|
||||
|
||||
### McpDebugClient
|
||||
|
||||
Enables AI-driven game control: screenshots, node clicking, and custom commands.
|
||||
|
||||
```typescript
|
||||
import { initMcpDebugClient } from "../../extensions/cocos-creator-mcp/client/McpDebugClient";
|
||||
|
||||
initMcpDebugClient({
|
||||
customCommands: {
|
||||
// Add project-specific commands
|
||||
state: () => ({ success: true, data: { dump: MyDb.dump() } }),
|
||||
navigate: async (args) => {
|
||||
await MyRouter.goTo(args.page);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Built-in commands** (no setup needed):
|
||||
- `screenshot` — Capture game screen via RenderTexture
|
||||
- `click` — Click a node by name
|
||||
|
||||
**Custom commands** (project-specific):
|
||||
- Register any handler via `customCommands` option
|
||||
- Called via `debug_game_command` MCP tool
|
||||
|
||||
Both scripts silently ignore when the MCP server is not running, so they are safe to leave in development builds.
|
||||
|
||||
## Console Log Capture (Details)
|
||||
|
||||
`debug_get_console_logs` captures logs from two sources:
|
||||
|
||||
### Scene Process Logs (automatic)
|
||||
|
||||
Console output from scene scripts (`console.log/warn/error` in the scene renderer process) is automatically captured. No setup required.
|
||||
|
||||
### Game Preview Logs (opt-in)
|
||||
|
||||
Game code runs in a browser during preview, which is a separate process. To capture game preview logs, your game code needs to send logs to the MCP server's `/log` endpoint.
|
||||
|
||||
**Setup:**
|
||||
|
||||
Add a console capture script to your game project:
|
||||
|
||||
```typescript
|
||||
const MCP_LOG_URL = "http://127.0.0.1:3000/log";
|
||||
const FLUSH_INTERVAL = 500;
|
||||
|
||||
let buffer: Array<{ timestamp: string; level: string; message: string }> = [];
|
||||
|
||||
function hook(level: string, original: (...args: any[]) => void) {
|
||||
return function (...args: any[]) {
|
||||
original.apply(console, args);
|
||||
buffer.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" "),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
console.log = hook("log", console.log);
|
||||
console.warn = hook("warn", console.warn);
|
||||
console.error = hook("error", console.error);
|
||||
|
||||
setInterval(() => {
|
||||
if (buffer.length === 0) return;
|
||||
const entries = buffer.splice(0, 50);
|
||||
fetch(MCP_LOG_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(entries),
|
||||
}).catch(() => {}); // silently ignore if MCP server is not running
|
||||
}, FLUSH_INTERVAL);
|
||||
```
|
||||
|
||||
**`POST /log` format:**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "timestamp": "2026-03-26T12:00:00.000Z", "level": "log", "message": "Hello" },
|
||||
{ "timestamp": "2026-03-26T12:00:01.000Z", "level": "error", "message": "Something failed" }
|
||||
]
|
||||
```
|
||||
|
||||
Both scene and game logs are merged chronologically when retrieved via `debug_get_console_logs`. Each log entry includes a `source` field (`"scene"` or `"game"`) to distinguish the origin.
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are stored in `{project}/settings/cocos-creator-mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 3000,
|
||||
"autoStart": true
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `port` | `3000` | HTTP server port |
|
||||
| `autoStart` | `false` | Start server automatically when extension loads |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
node test/regression.mjs # default port 3000
|
||||
node test/regression.mjs 3001 # custom port
|
||||
```
|
||||
|
||||
## Version History
|
||||
|
||||
- **v0.1** — MCP server + scene/node tools (13 tools)
|
||||
- **v0.5** — Component, prefab, project, debug tools (27 tools)
|
||||
- **v1.0** — Full tool coverage (145 tools, 13 categories, 224 test assertions)
|
||||
- **v1.1** — Console log capture (scene process auto-capture + game preview via `/log` endpoint)
|
||||
- **v1.2** — AI autonomous development: Preview in Editor, screenshot capture, game command control, code cache clear, scene save fix. Client scripts for game preview integration (`client/`)
|
||||
- **v1.3** — `scene:set-property` for prefab save support, prefab_create overwrite guard, param alias (`component` → `componentType`)
|
||||
- **v1.5** — `prefab_create_and_replace`, batch `set_property`, `prefab_open`
|
||||
- **v1.6** — `debug_batch_screenshot`, widget support in `create_tree`, `component_query_enum`, `server_check_code_sync`
|
||||
- **v1.8.0** — Preview Recorder panel: `debug_record_start` / `debug_record_stop` (MediaRecorder via canvas.captureStream, MP4/WebM, quality presets)
|
||||
- **v1.8.1** — Fix: `component_set_property` cc.Asset references (cc.Font etc.) falling back to cc.Node when type is unspecified
|
||||
- **v1.8.2** — Preview Recorder: screenshot button (webp/png toggle, max width), section-based UI layout
|
||||
- **v1.9.0** — Preview Recorder auto-archive of old recordings + preflight "preview not running" check
|
||||
- **v1.10.0** — `scene_create` asset-db fallback, stringified args preventive validation, test coverage expansion
|
||||
- **v1.11.0** — HTTP MCP OAuth workaround (stdio bridge + dummy OAuth endpoints for Claude Code VSCode upstream bug) + dialog prevention for scene switching tools (`force` param, `ensureSceneSafeToSwitch`, `safeSaveScene`) + regression tests for both
|
||||
- **v1.12.0** — Prefab authoring efficiency: `component_auto_bind` (auto-match `@property` fields to node names), `debug_wait_compile` (wait for TS compile to finish), `prefab_create_from_spec` (create node tree + auto-bind + prefab_create in one call)
|
||||
- **v1.13.0** — `nodeName` parameter on component/get_components/auto_bind (no UUID required), `screenshot` auto-return option on `component_set_property` / `node_set_layout`, `node_set_layout` unified tool (UITransform + Widget + color/opacity in one call), dialog auto-response for untitled+dirty scenes, shared screenshot / node-resolve utilities
|
||||
- **v1.14.0** — Widget `_alignFlags` auto-recalc bug fix: `setProperty` / `setProperties` / `node_set_layout` now re-query `isAlign*` values from scene and rebuild `_alignFlags` bitmask after isAlign updates (Editor bug where bitmask was not updated automatically, causing prefabs to save with `_alignFlags: 45` stuck state). Also `node_create` component addition now waits for editor reflection (`waitForComponent`) to fix flaky tests
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run watch # Watch mode
|
||||
npm run build # One-time build
|
||||
```
|
||||
|
||||
After building, reload the extension in Cocos Creator:
|
||||
- **Extension Manager** — disable then re-enable
|
||||
- **Developer > Reload** — reloads main process
|
||||
- **Full restart** — required for scene script or new category changes
|
||||
|
||||
## Requirements
|
||||
|
||||
- Cocos Creator 3.8+
|
||||
- Node.js 18+
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **`scene_create`**: Does not work on Cocos Creator 3.8.x because the underlying `scene:new-scene` Editor message is not exposed on that version. As a workaround, create the `.scene` JSON file directly under `db://assets/` and call `project_refresh_assets` so the editor picks it up. See [#13](https://github.com/harady/cocos-creator-mcp/issues/13) for details.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* McpConsoleCapture — ゲームプレビュー時のconsole.log/warn/errorをMCPサーバーに送信する。
|
||||
*
|
||||
* MCPサーバーの POST /log エンドポイントにバッチ送信する。
|
||||
* MCPサーバー未起動時は黙って無視(本番影響なし)。
|
||||
*
|
||||
* Usage:
|
||||
* import { initMcpConsoleCapture } from "./McpConsoleCapture";
|
||||
* initMcpConsoleCapture(); // or initMcpConsoleCapture({ mcpBaseUrl: "http://127.0.0.1:3001" });
|
||||
*/
|
||||
|
||||
export interface McpConsoleCaptureConfig {
|
||||
/** MCPサーバーのベースURL (default: "http://127.0.0.1:3000") */
|
||||
mcpBaseUrl?: string;
|
||||
/** バッチ送信間隔ms (default: 500) */
|
||||
flushInterval?: number;
|
||||
/** 1回の送信最大件数 (default: 50) */
|
||||
maxBatchSize?: number;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
let _buffer: LogEntry[] = [];
|
||||
let _timer: any = null;
|
||||
let _initialized = false;
|
||||
let _config: Required<McpConsoleCaptureConfig>;
|
||||
|
||||
function formatArgs(args: any[]): string {
|
||||
return args.map(a => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return `${a.message}\n${a.stack || ""}`;
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (_buffer.length === 0) return;
|
||||
const entries = _buffer.splice(0, _config.maxBatchSize);
|
||||
fetch(`${_config.mcpBaseUrl}/log`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(entries),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/** ゲーム起動時に1回呼ぶ。console.log/warn/errorをフックしてMCPに送信開始 */
|
||||
export function initMcpConsoleCapture(config?: McpConsoleCaptureConfig): void {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
_config = {
|
||||
mcpBaseUrl: config?.mcpBaseUrl ?? "http://127.0.0.1:3000",
|
||||
flushInterval: config?.flushInterval ?? 500,
|
||||
maxBatchSize: config?.maxBatchSize ?? 50,
|
||||
};
|
||||
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
function hook(level: LogEntry["level"], original: (...args: any[]) => void) {
|
||||
return function (...args: any[]) {
|
||||
original.apply(console, args);
|
||||
_buffer.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formatArgs(args),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
console.log = hook("log", originalLog);
|
||||
console.warn = hook("warn", originalWarn);
|
||||
console.error = hook("error", originalError);
|
||||
|
||||
_timer = setInterval(flush, _config.flushInterval);
|
||||
console.log("[McpConsoleCapture] initialized");
|
||||
}
|
||||
|
||||
/** 停止 */
|
||||
export function stopMcpConsoleCapture(): void {
|
||||
if (_timer) {
|
||||
clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
flush();
|
||||
_initialized = false;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* McpDebugClient — MCPサーバーとCocosCreatorプレビューの双方向通信
|
||||
*
|
||||
* MCPサーバーのコマンドキューをポーリングし、コマンドを実行して結果を返す。
|
||||
* MCPサーバー未起動時は黙って無視(本番影響なし)。
|
||||
*
|
||||
* ビルトインコマンド:
|
||||
* - screenshot: RenderTexture経由でゲーム画面キャプチャ
|
||||
* - click: ノード名指定でクリックイベント発火
|
||||
*
|
||||
* カスタムコマンドを追加可能(プロジェクト固有の機能):
|
||||
* initMcpDebugClient({
|
||||
* customCommands: {
|
||||
* state: () => ({ success: true, data: myDb.dump() }),
|
||||
* navigate: async (args) => { await myRouter.go(args.page); return { success: true }; },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
|
||||
import { director, Node, Button, Camera, RenderTexture, gfx } from "cc";
|
||||
|
||||
export interface McpDebugClientConfig {
|
||||
/** MCPサーバーのベースURL (default: "http://127.0.0.1:3000") */
|
||||
mcpBaseUrl?: string;
|
||||
/** ポーリング間隔ms (default: 500) */
|
||||
pollInterval?: number;
|
||||
/** プロジェクト固有のコマンドハンドラー */
|
||||
customCommands?: Record<string, (args: any) => any | Promise<any>>;
|
||||
}
|
||||
|
||||
interface McpCommand {
|
||||
id: string;
|
||||
type: string;
|
||||
args?: any;
|
||||
}
|
||||
|
||||
let _polling = false;
|
||||
let _timer: any = null;
|
||||
let _config: Required<Pick<McpDebugClientConfig, "mcpBaseUrl" | "pollInterval">> & { customCommands: Record<string, (args: any) => any | Promise<any>> };
|
||||
|
||||
async function pollCommand(): Promise<void> {
|
||||
if (_polling) return;
|
||||
_polling = true;
|
||||
try {
|
||||
const res = await fetch(`${_config.mcpBaseUrl}/game/command`);
|
||||
if (!res.ok) return;
|
||||
const cmd: McpCommand | null = await res.json();
|
||||
if (!cmd) return;
|
||||
|
||||
console.log(`[McpDebugClient] command: ${cmd.type}`, cmd.args || "");
|
||||
const result = await executeCommand(cmd);
|
||||
|
||||
await fetch(`${_config.mcpBaseUrl}/game/result`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(result),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// MCP server not running
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCommand(cmd: McpCommand): Promise<any> {
|
||||
try {
|
||||
// ビルトインコマンド
|
||||
switch (cmd.type) {
|
||||
case "screenshot":
|
||||
return { id: cmd.id, ...takeScreenshot() };
|
||||
case "click":
|
||||
return { id: cmd.id, ...clickNode(cmd.args?.name) };
|
||||
case "record_start":
|
||||
return { id: cmd.id, ...startRecording(cmd.args) };
|
||||
case "record_stop":
|
||||
return { id: cmd.id, ...(await stopRecording()) };
|
||||
}
|
||||
// カスタムコマンド
|
||||
const handler = _config.customCommands[cmd.type];
|
||||
if (handler) {
|
||||
const result = await handler(cmd.args);
|
||||
return { id: cmd.id, ...result };
|
||||
}
|
||||
return { id: cmd.id, success: false, error: `Unknown command: ${cmd.type}` };
|
||||
} catch (e: any) {
|
||||
return { id: cmd.id, success: false, error: e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ビルトインコマンド ───
|
||||
|
||||
function takeScreenshot(): { success: boolean; data?: any; error?: string } {
|
||||
try {
|
||||
const scene = director.getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
let camera: Camera | null = null;
|
||||
const findCamera = (node: Node) => {
|
||||
const cam = node.getComponent(Camera);
|
||||
if (cam && cam.enabled) { camera = cam; return; }
|
||||
for (const child of node.children) {
|
||||
if (camera) return;
|
||||
findCamera(child);
|
||||
}
|
||||
};
|
||||
findCamera(scene);
|
||||
if (!camera) return { success: false, error: "No camera found" };
|
||||
|
||||
const cam = camera as Camera;
|
||||
const width = Math.floor(cam.camera.width);
|
||||
const height = Math.floor(cam.camera.height);
|
||||
|
||||
const rt = new RenderTexture();
|
||||
rt.reset({ width, height });
|
||||
|
||||
const prevTarget = cam.targetTexture;
|
||||
cam.targetTexture = rt;
|
||||
director.root!.frameMove(0);
|
||||
cam.targetTexture = prevTarget;
|
||||
|
||||
const region = new gfx.BufferTextureCopy();
|
||||
region.texOffset.x = 0;
|
||||
region.texOffset.y = 0;
|
||||
region.texExtent.width = width;
|
||||
region.texExtent.height = height;
|
||||
|
||||
const buffer = new Uint8Array(width * height * 4);
|
||||
const gfxTex = rt.getGFXTexture()!;
|
||||
director.root!.device.copyTextureToBuffers(gfxTex, [buffer], [region]);
|
||||
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = width;
|
||||
cvs.height = height;
|
||||
const ctx = cvs.getContext("2d")!;
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const srcRow = (height - 1 - y) * width * 4;
|
||||
const dstRow = y * width * 4;
|
||||
for (let x = 0; x < width * 4; x++) {
|
||||
imageData.data[dstRow + x] = buffer[srcRow + x];
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = cvs.toDataURL("image/png");
|
||||
rt.destroy();
|
||||
|
||||
return { success: true, data: { dataUrl, width, height } };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function clickNode(name?: string): { success: boolean; error?: string } {
|
||||
if (!name) return { success: false, error: "name argument required" };
|
||||
const scene = director.getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const found = findNodeByName(scene, name);
|
||||
if (!found) return { success: false, error: `Node '${name}' not found` };
|
||||
|
||||
found.emit(Button.EventType.CLICK, found);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── 録画(MediaRecorder経由) ───
|
||||
|
||||
let _mediaRecorder: MediaRecorder | null = null;
|
||||
let _recordChunks: Blob[] = [];
|
||||
let _recordStream: MediaStream | null = null;
|
||||
let _recordId: string | null = null;
|
||||
|
||||
const QUALITY_PRESETS: Record<string, number> = {
|
||||
low: 0.15,
|
||||
medium: 0.25,
|
||||
high: 0.40,
|
||||
ultra: 0.60,
|
||||
};
|
||||
|
||||
function startRecording(args?: { fps?: number; videoBitsPerSecond?: number; quality?: string; coefficient?: number; format?: "webm" | "mp4" }): { success: boolean; error?: string; data?: any } {
|
||||
// 前回の録画状態が残っていたら強制クリア
|
||||
if (_mediaRecorder) {
|
||||
try { if (_mediaRecorder.state !== "inactive") _mediaRecorder.stop(); } catch {}
|
||||
_recordStream?.getTracks().forEach(t => { try { t.stop(); } catch {} });
|
||||
_mediaRecorder = null;
|
||||
_recordStream = null;
|
||||
_recordChunks = [];
|
||||
_recordId = null;
|
||||
}
|
||||
try {
|
||||
// GameView canvas取得
|
||||
const canvas = document.getElementById("GameCanvas") as HTMLCanvasElement
|
||||
|| document.querySelector("canvas") as HTMLCanvasElement;
|
||||
if (!canvas) return { success: false, error: "canvas not found" };
|
||||
|
||||
const fps = args?.fps ?? 30;
|
||||
const quality = args?.quality ?? "medium";
|
||||
const coef = args?.coefficient ?? QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.medium;
|
||||
const autoBps = Math.round(canvas.width * canvas.height * fps * coef);
|
||||
const bps = args?.videoBitsPerSecond ?? autoBps;
|
||||
const format = args?.format ?? "mp4";
|
||||
_recordStream = canvas.captureStream(fps);
|
||||
_recordChunks = [];
|
||||
_recordId = `rec_${new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").substring(0, 19)}`;
|
||||
|
||||
// 指定フォーマット優先、非対応ならwebmにfallback
|
||||
const mp4Candidates = [
|
||||
"video/mp4;codecs=h264",
|
||||
"video/mp4;codecs=avc1.42E01E",
|
||||
"video/mp4",
|
||||
];
|
||||
const webmCandidates = [
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm",
|
||||
];
|
||||
const candidates = format === "mp4"
|
||||
? [...mp4Candidates, ...webmCandidates]
|
||||
: webmCandidates;
|
||||
let mimeType = "";
|
||||
for (const c of candidates) {
|
||||
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(c)) {
|
||||
mimeType = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mimeType) return { success: false, error: "No supported MediaRecorder mimeType" };
|
||||
|
||||
_mediaRecorder = new MediaRecorder(_recordStream, { mimeType, videoBitsPerSecond: bps });
|
||||
_mediaRecorder.ondataavailable = (e: BlobEvent) => {
|
||||
if (e.data.size > 0) _recordChunks.push(e.data);
|
||||
};
|
||||
_mediaRecorder.start();
|
||||
return { success: true, data: { id: _recordId, mimeType, fps, videoBitsPerSecond: bps, quality, canvasWidth: canvas.width, canvasHeight: canvas.height } };
|
||||
} catch (e: any) {
|
||||
_mediaRecorder = null;
|
||||
_recordStream = null;
|
||||
return { success: false, error: e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording(): Promise<{ success: boolean; error?: string; data?: any }> {
|
||||
if (!_mediaRecorder || !_recordId) return { success: false, error: "not recording" };
|
||||
const id = _recordId;
|
||||
const mimeType = _mediaRecorder.mimeType;
|
||||
return new Promise((resolve) => {
|
||||
_mediaRecorder!.onstop = async () => {
|
||||
try {
|
||||
const blob = new Blob(_recordChunks, { type: mimeType });
|
||||
_recordStream?.getTracks().forEach(t => t.stop());
|
||||
_mediaRecorder = null;
|
||||
_recordStream = null;
|
||||
_recordChunks = [];
|
||||
_recordId = null;
|
||||
|
||||
// Blob → base64 → POST /game/recording
|
||||
const base64 = await blobToBase64(blob);
|
||||
const uploadRes = await fetch(`${_config.mcpBaseUrl}/game/recording`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, base64, mimeType }),
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
resolve({ success: true, data: { id, size: blob.size, ...uploadData } });
|
||||
} catch (e: any) {
|
||||
resolve({ success: false, error: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
_mediaRecorder!.stop();
|
||||
});
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
// "data:video/webm;base64,XXX" → XXX だけ抽出
|
||||
const commaIdx = result.indexOf(",");
|
||||
resolve(commaIdx >= 0 ? result.substring(commaIdx + 1) : result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function findNodeByName(root: Node, name: string): Node | null {
|
||||
if (root.name === name) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findNodeByName(child, name);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 初期化・停止 ───
|
||||
|
||||
export function initMcpDebugClient(config?: McpDebugClientConfig): void {
|
||||
if (_timer) return;
|
||||
_config = {
|
||||
mcpBaseUrl: config?.mcpBaseUrl ?? "http://127.0.0.1:3000",
|
||||
pollInterval: config?.pollInterval ?? 500,
|
||||
customCommands: config?.customCommands ?? {},
|
||||
};
|
||||
_timer = setInterval(pollCommand, _config.pollInterval);
|
||||
console.log("[McpDebugClient] initialized");
|
||||
}
|
||||
|
||||
export function stopMcpDebugClient(): void {
|
||||
if (_timer) {
|
||||
clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cocos-creator-mcp stdio bridge
|
||||
*
|
||||
* stdin で line-delimited JSON-RPC を読み取り、HTTP MCP エンドポイントに転送する。
|
||||
* レスポンスは stdout に 1 行ずつ書き出す。
|
||||
*
|
||||
* Claude Code VSCode 拡張の HTTP MCP OAuth 強制バグ (#26917 等) を回避するための
|
||||
* 代替トランスポート。ユーザーは .mcp.json で stdio 型として設定する:
|
||||
*
|
||||
* {
|
||||
* "mcpServers": {
|
||||
* "cocos-creator-mcp": {
|
||||
* "command": "node",
|
||||
* "args": [
|
||||
* "C:/path/to/cocos-creator-mcp/client/stdio-bridge.js"
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Optional 環境変数:
|
||||
* COCOS_MCP_URL - MCP サーバーの URL (default: http://127.0.0.1:3000/mcp)
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
const readline = require("readline");
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:3000/mcp";
|
||||
const serverUrl = new URL(process.env.COCOS_MCP_URL || DEFAULT_URL);
|
||||
|
||||
let sessionId = null;
|
||||
|
||||
function log(...args) {
|
||||
// デバッグログは stderr に出す(stdout は JSON-RPC 応答専用)
|
||||
console.error("[stdio-bridge]", ...args);
|
||||
}
|
||||
|
||||
function postJsonRpc(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
};
|
||||
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: serverUrl.hostname,
|
||||
port: serverUrl.port || 80,
|
||||
path: serverUrl.pathname,
|
||||
method: "POST",
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
const responseSession = res.headers["mcp-session-id"];
|
||||
if (responseSession && !sessionId) {
|
||||
sessionId = responseSession;
|
||||
log("session established:", sessionId);
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
resolve({ status: res.statusCode || 0, body: text });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (err) => reject(err));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function writeResponse(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + "\n");
|
||||
}
|
||||
|
||||
async function handleLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
writeResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: null,
|
||||
error: { code: -32700, message: "Parse error", data: e.message },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { status, body } = await postJsonRpc(trimmed);
|
||||
// Notifications (no id) may yield empty body
|
||||
if (!body) {
|
||||
if (request.id !== undefined) {
|
||||
writeResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
error: { code: -32603, message: "Empty response from server", data: { status } },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Pass through server response (single JSON object per line)
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
writeResponse(parsed);
|
||||
} catch {
|
||||
// サーバーが SSE 等で返した場合のフォールバック
|
||||
process.stdout.write(body + (body.endsWith("\n") ? "" : "\n"));
|
||||
}
|
||||
} catch (err) {
|
||||
writeResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id !== undefined ? request.id : null,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: "HTTP bridge error",
|
||||
data: err && err.message ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let inflight = 0;
|
||||
let stdinClosed = false;
|
||||
|
||||
function maybeExit() {
|
||||
if (stdinClosed && inflight === 0) {
|
||||
log("stdin closed, no inflight, exiting");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
log(`starting, target=${serverUrl.href}`);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
inflight++;
|
||||
handleLine(line)
|
||||
.catch((e) => log("handler error:", e))
|
||||
.finally(() => {
|
||||
inflight--;
|
||||
maybeExit();
|
||||
});
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
stdinClosed = true;
|
||||
maybeExit();
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
description: "MCP (Model Context Protocol) server for Cocos Creator",
|
||||
panel_title: "Cocos Creator MCP",
|
||||
open_panel: "Open Panel",
|
||||
recorder_title: "Preview Recorder",
|
||||
open_recorder: "Open Recorder",
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
description: "Cocos Creator用 MCP (Model Context Protocol) サーバー",
|
||||
panel_title: "Cocos Creator MCP",
|
||||
open_panel: "パネルを開く",
|
||||
recorder_title: "Preview Recorder",
|
||||
open_recorder: "Previewレコーダー",
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
description: "Cocos Creator MCP (模型上下文协议) 服务器",
|
||||
panel_title: "Cocos Creator MCP",
|
||||
open_panel: "打开面板",
|
||||
recorder_title: "Preview Recorder",
|
||||
open_recorder: "打开录制器",
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
nodejs
|
||||
@@ -0,0 +1,310 @@
|
||||
{
|
||||
"name": "@iflow-mcp/harady-cocos-creator-mcp",
|
||||
"version": "1.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@iflow-mcp/harady-cocos-creator-mcp",
|
||||
"version": "1.14.0",
|
||||
"dependencies": {
|
||||
"vue": "^3.1.4",
|
||||
"wasm-vips": "^0.0.17"
|
||||
},
|
||||
"bin": {
|
||||
"iflow-mcp_harady-cocos-creator-mcp": "client/stdio-bridge.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cocos/creator-types": "^3.8.6",
|
||||
"@types/node": "^18.17.1",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cocos/creator-types": {
|
||||
"version": "3.8.7",
|
||||
"resolved": "https://registry.npmmirror.com/@cocos/creator-types/-/creator-types-3.8.7.tgz",
|
||||
"integrity": "sha512-QTtuC+w1s1yQ8DPlgFSiIGS+Pko6v2QdrrQRN8/SttEBo53JjyXEWHXpRkg1lGiHq0u/aadm0cN/Y4jtEmis/w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
|
||||
"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@vue/shared": "3.5.39",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
|
||||
"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.39",
|
||||
"@vue/shared": "3.5.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
|
||||
"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@vue/compiler-core": "3.5.39",
|
||||
"@vue/compiler-dom": "3.5.39",
|
||||
"@vue/compiler-ssr": "3.5.39",
|
||||
"@vue/shared": "3.5.39",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.15",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
|
||||
"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.39",
|
||||
"@vue/shared": "3.5.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz",
|
||||
"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
|
||||
"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.39",
|
||||
"@vue/shared": "3.5.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
|
||||
"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.39",
|
||||
"@vue/runtime-core": "3.5.39",
|
||||
"@vue/shared": "3.5.39",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
|
||||
"integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.39",
|
||||
"@vue/shared": "3.5.39"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz",
|
||||
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz",
|
||||
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.39",
|
||||
"@vue/compiler-sfc": "3.5.39",
|
||||
"@vue/runtime-dom": "3.5.39",
|
||||
"@vue/server-renderer": "3.5.39",
|
||||
"@vue/shared": "3.5.39"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-vips": {
|
||||
"version": "0.0.17",
|
||||
"resolved": "https://registry.npmmirror.com/wasm-vips/-/wasm-vips-0.0.17.tgz",
|
||||
"integrity": "sha512-nhkqUNJDUymImoXGrVfImC4wzIFTb9KfBpAngb7dcEQNPP1gVTx4+WL3VVVDSXQpMsyeacsQDOx0+DM33Rpurg==",
|
||||
"engines": {
|
||||
"node": ">=16.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"package_version": 2,
|
||||
"name": "cocos-creator-mcp",
|
||||
"version": "1.14.0",
|
||||
"author": "harady",
|
||||
"editor": ">=3.8.0",
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/postbuild.js",
|
||||
"watch": "tsc -w"
|
||||
},
|
||||
"description": "MCP Server for Cocos Creator [2ade3d2fa124]",
|
||||
"main": "./dist/main.js",
|
||||
"bin": {
|
||||
"iflow-mcp_harady-cocos-creator-mcp": "client/stdio-bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.1.4",
|
||||
"wasm-vips": "^0.0.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cocos/creator-types": "^3.8.6",
|
||||
"@types/node": "^18.17.1",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"panels": {
|
||||
"default": {
|
||||
"title": "i18n:cocos-creator-mcp.panel_title",
|
||||
"type": "dockable",
|
||||
"main": "dist/panels/default",
|
||||
"size": {
|
||||
"min-width": 350,
|
||||
"min-height": 200,
|
||||
"width": 450,
|
||||
"height": 350
|
||||
}
|
||||
},
|
||||
"recorder": {
|
||||
"title": "i18n:cocos-creator-mcp.recorder_title",
|
||||
"type": "dockable",
|
||||
"main": "dist/panels/recorder",
|
||||
"size": {
|
||||
"min-width": 400,
|
||||
"min-height": 400,
|
||||
"width": 500,
|
||||
"height": 600
|
||||
}
|
||||
}
|
||||
},
|
||||
"contributions": {
|
||||
"menu": [
|
||||
{
|
||||
"path": "i18n:menu.extension/Cocos Creator MCP",
|
||||
"label": "i18n:cocos-creator-mcp.open_panel",
|
||||
"message": "open-panel"
|
||||
},
|
||||
{
|
||||
"path": "i18n:menu.extension/Cocos Creator MCP",
|
||||
"label": "i18n:cocos-creator-mcp.open_recorder",
|
||||
"message": "open-recorder"
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"open-panel": {
|
||||
"methods": [
|
||||
"openPanel"
|
||||
]
|
||||
},
|
||||
"open-recorder": {
|
||||
"methods": [
|
||||
"openRecorder"
|
||||
]
|
||||
},
|
||||
"start-server": {
|
||||
"methods": [
|
||||
"startServer"
|
||||
]
|
||||
},
|
||||
"stop-server": {
|
||||
"methods": [
|
||||
"stopServer"
|
||||
]
|
||||
},
|
||||
"get-server-status": {
|
||||
"methods": [
|
||||
"getServerStatus"
|
||||
]
|
||||
},
|
||||
"update-port": {
|
||||
"methods": [
|
||||
"updatePort"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
"script": "./dist/scene.js",
|
||||
"methods": [
|
||||
"getNodeInfo",
|
||||
"getAllNodes",
|
||||
"findNodesByName",
|
||||
"setNodeProperty",
|
||||
"setComponentProperty",
|
||||
"getSceneHierarchy",
|
||||
"moveNode",
|
||||
"addComponentToNode",
|
||||
"removeComponentFromNode"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@iflow-mcp/harady-cocos-creator-mcp
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"push_platform": "github",
|
||||
"fork_url": "https://github.com/iflow-mcp/harady-cocos-creator-mcp",
|
||||
"fork_branch": "iflow"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
|
||||
// dist/ 配下の全 .js ファイルの内容からハッシュを生成
|
||||
// コードが同じなら常に同じ値になる
|
||||
const distDir = path.join(__dirname, "..", "dist");
|
||||
const hash = crypto.createHash("sha256");
|
||||
|
||||
// dist/ 配下を再帰的に走査して全 .js ファイルを収集
|
||||
function collectJsFiles(dir, prefix = "") {
|
||||
let files = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files = files.concat(collectJsFiles(path.join(dir, entry.name), relPath));
|
||||
} else if (entry.name.endsWith(".js")) {
|
||||
files.push(relPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const jsFiles = collectJsFiles(distDir).sort();
|
||||
|
||||
for (const file of jsFiles) {
|
||||
const content = fs.readFileSync(path.join(distDir, file), "utf8");
|
||||
// __BUILD_HASH__ プレースホルダーは除外して計算(自己参照を避ける)
|
||||
hash.update(content.replace(/__BUILD_HASH__/g, ""));
|
||||
}
|
||||
|
||||
const buildHash = hash.digest("hex").substring(0, 12);
|
||||
|
||||
// mcp-server.js にハッシュを埋め込む
|
||||
const serverFile = path.join(distDir, "mcp-server.js");
|
||||
const serverContent = fs.readFileSync(serverFile, "utf8");
|
||||
fs.writeFileSync(serverFile, serverContent.replace("__BUILD_HASH__", buildHash));
|
||||
|
||||
// package.json の description にハッシュを付与
|
||||
const pkgFile = path.join(__dirname, "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
||||
const baseDesc = pkg.description.replace(/\s*\[[a-f0-9]+\]$/, ""); // 既存ハッシュを除去
|
||||
pkg.description = `${baseDesc} [${buildHash}]`;
|
||||
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 4) + "\n");
|
||||
|
||||
console.log("BUILD_HASH:", buildHash);
|
||||
|
||||
// ── dist自動同期: ゲームプロジェクトの extensions/ にコピー ──
|
||||
// SYNC_TARGET 環境変数 or settings ファイルで同期先を指定
|
||||
const settingsFile = path.join(__dirname, "..", "sync-targets.json");
|
||||
let syncTargets = [];
|
||||
|
||||
if (process.env.SYNC_TARGET) {
|
||||
syncTargets = process.env.SYNC_TARGET.split(",").map(s => s.trim());
|
||||
} else if (fs.existsSync(settingsFile)) {
|
||||
syncTargets = JSON.parse(fs.readFileSync(settingsFile, "utf8")).targets || [];
|
||||
}
|
||||
|
||||
if (syncTargets.length > 0) {
|
||||
const rootDir = path.join(__dirname, "..");
|
||||
const filesToSync = ["dist", "client", "i18n", "static", "package.json", "package-lock.json", "node_modules"];
|
||||
|
||||
for (const target of syncTargets) {
|
||||
const targetDir = path.resolve(target);
|
||||
if (!fs.existsSync(path.dirname(targetDir))) {
|
||||
console.warn(`SYNC SKIP: parent dir not found: ${targetDir}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`SYNC: ${targetDir}`);
|
||||
for (const entry of filesToSync) {
|
||||
const src = path.join(rootDir, entry);
|
||||
const dst = path.join(targetDir, entry);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
if (fs.statSync(src).isDirectory()) {
|
||||
copyDirSync(src, dst);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
console.log(`SYNC DONE: ${targetDir}`);
|
||||
}
|
||||
} else {
|
||||
console.log("SYNC: no targets configured (set SYNC_TARGET or create sync-targets.json)");
|
||||
}
|
||||
|
||||
function copyDirSync(src, dst) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const dstPath = path.join(dst, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, dstPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, dstPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const HOURS_24 = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 24時間以上前のファイルを OLD_yyyyMM フォルダに移動する.
|
||||
*/
|
||||
export function archiveOldFiles(dir: string): void {
|
||||
try {
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let archived = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) continue;
|
||||
if (now - stat.mtimeMs < HOURS_24) continue;
|
||||
|
||||
const fileDate = new Date(stat.mtimeMs);
|
||||
const yyyy = fileDate.getFullYear();
|
||||
const mm = String(fileDate.getMonth() + 1).padStart(2, "0");
|
||||
const archiveDir = path.join(dir, `OLD_${yyyy}${mm}`);
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
|
||||
fs.renameSync(filePath, path.join(archiveDir, file));
|
||||
archived++;
|
||||
}
|
||||
|
||||
if (archived > 0) {
|
||||
console.log(`[cocos-creator-mcp] ${archived}件のファイルを月別フォルダにアーカイブ (${dir})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[cocos-creator-mcp] アーカイブ失敗:", e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { McpServer } from "./mcp-server";
|
||||
import { SceneTools } from "./tools/scene-tools";
|
||||
import { NodeTools } from "./tools/node-tools";
|
||||
import { ComponentTools } from "./tools/component-tools";
|
||||
import { PrefabTools } from "./tools/prefab-tools";
|
||||
import { ProjectTools } from "./tools/project-tools";
|
||||
import { DebugTools } from "./tools/debug-tools";
|
||||
import { SceneAdvancedTools } from "./tools/scene-advanced-tools";
|
||||
import { SceneViewTools } from "./tools/scene-view-tools";
|
||||
import { AssetTools } from "./tools/asset-tools";
|
||||
import { PreferencesTools } from "./tools/preferences-tools";
|
||||
import { ServerTools } from "./tools/server-tools";
|
||||
import { BuilderTools } from "./tools/builder-tools";
|
||||
import { ReferenceImageTools } from "./tools/reference-image-tools";
|
||||
import { ServerConfig, DEFAULT_CONFIG } from "./types";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let server: McpServer | null = null;
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
|
||||
}
|
||||
|
||||
function loadConfig(): ServerConfig {
|
||||
try {
|
||||
const p = getSettingsPath();
|
||||
if (fs.existsSync(p)) {
|
||||
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { ...DEFAULT_CONFIG, ...data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[cocos-creator-mcp] Failed to load settings, using defaults");
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
function saveConfig(config: ServerConfig): void {
|
||||
try {
|
||||
const p = getSettingsPath();
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2), "utf-8");
|
||||
} catch (e) {
|
||||
console.error("[cocos-creator-mcp] Failed to save settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function createServer(config: ServerConfig): McpServer {
|
||||
const s = new McpServer(config);
|
||||
s.register(new SceneTools());
|
||||
s.register(new NodeTools());
|
||||
const componentTools = new ComponentTools();
|
||||
s.register(componentTools);
|
||||
s.register(new PrefabTools(componentTools));
|
||||
s.register(new ProjectTools());
|
||||
s.register(new DebugTools());
|
||||
s.register(new SceneAdvancedTools());
|
||||
s.register(new SceneViewTools());
|
||||
s.register(new AssetTools());
|
||||
s.register(new PreferencesTools());
|
||||
s.register(new ServerTools());
|
||||
s.register(new BuilderTools());
|
||||
s.register(new ReferenceImageTools());
|
||||
return s;
|
||||
}
|
||||
|
||||
export const methods: Record<string, (...args: any[]) => any> = {
|
||||
openPanel() {
|
||||
Editor.Panel.open("cocos-creator-mcp");
|
||||
},
|
||||
|
||||
openRecorder() {
|
||||
Editor.Panel.open("cocos-creator-mcp.recorder");
|
||||
},
|
||||
|
||||
async startServer() {
|
||||
if (server?.isRunning) return { running: true, port: server.port };
|
||||
const config = loadConfig();
|
||||
server = createServer(config);
|
||||
await server.start();
|
||||
return { running: true, port: server.port };
|
||||
},
|
||||
|
||||
async stopServer() {
|
||||
if (server) {
|
||||
await server.stop();
|
||||
server = null;
|
||||
}
|
||||
return { running: false };
|
||||
},
|
||||
|
||||
async updatePort(_port: number) {
|
||||
const config = loadConfig();
|
||||
config.port = _port;
|
||||
saveConfig(config);
|
||||
// Restart server if running
|
||||
if (server?.isRunning) {
|
||||
await server.stop();
|
||||
server = createServer(config);
|
||||
await server.start();
|
||||
}
|
||||
return { port: _port, running: server?.isRunning ?? false };
|
||||
},
|
||||
|
||||
getServerStatus() {
|
||||
return {
|
||||
running: server?.isRunning ?? false,
|
||||
port: server?.port ?? loadConfig().port,
|
||||
toolCount: server?.getAllTools().length ?? 0,
|
||||
toolNames: server?.getAllTools().map((t) => t.name).sort() ?? [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export async function load() {
|
||||
console.log("[cocos-creator-mcp] Extension loaded");
|
||||
const config = loadConfig();
|
||||
server = createServer(config);
|
||||
|
||||
if (config.autoStart) {
|
||||
try {
|
||||
await server.start();
|
||||
console.log(`[cocos-creator-mcp] Auto-started on port ${config.port}`);
|
||||
} catch (e) {
|
||||
console.error("[cocos-creator-mcp] Auto-start failed:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function unload() {
|
||||
if (server) {
|
||||
await server.stop();
|
||||
server = null;
|
||||
}
|
||||
console.log("[cocos-creator-mcp] Extension unloaded");
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import http from "http";
|
||||
import { ToolCategory, ToolDefinition, JsonRpcRequest, JsonRpcResponse, ServerConfig, DEFAULT_CONFIG } from "./types";
|
||||
import { archiveOldFiles } from "./archive";
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
||||
const SESSION_ID = `cocos-mcp-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
||||
|
||||
/** ビルド時にコードベースのSHA256ハッシュが埋め込まれる */
|
||||
export const BUILD_HASH = "__BUILD_HASH__";
|
||||
|
||||
// ─── Game Preview Log Buffer ───
|
||||
|
||||
interface GameLogEntry {
|
||||
timestamp: string;
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_GAME_LOG_BUFFER = 500;
|
||||
const _gameLogs: GameLogEntry[] = [];
|
||||
|
||||
/** Access game preview log buffer from debug-tools */
|
||||
export function getGameLogs(count: number, level?: string): { logs: GameLogEntry[]; total: number } {
|
||||
let logs = _gameLogs;
|
||||
if (level) {
|
||||
logs = logs.filter(l => l.level === level);
|
||||
}
|
||||
return { logs: logs.slice(-count), total: _gameLogs.length };
|
||||
}
|
||||
|
||||
export function clearGameLogs(): void {
|
||||
_gameLogs.length = 0;
|
||||
}
|
||||
|
||||
// ─── Game Debug Command Queue ───
|
||||
|
||||
interface GameCommand {
|
||||
id: string;
|
||||
type: string;
|
||||
args?: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface GameCommandResult {
|
||||
id: string;
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
let _pendingCommand: GameCommand | null = null;
|
||||
let _commandResult: GameCommandResult | null = null;
|
||||
let _commandIdCounter = 0;
|
||||
|
||||
/** Queue a command for the game to execute */
|
||||
export function queueGameCommand(type: string, args?: any): string {
|
||||
const id = `cmd_${++_commandIdCounter}_${Date.now()}`;
|
||||
_pendingCommand = { id, type, args, timestamp: new Date().toISOString() };
|
||||
_commandResult = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Get the result of the last command (poll until available) */
|
||||
export function getCommandResult(): GameCommandResult | null {
|
||||
return _commandResult;
|
||||
}
|
||||
|
||||
/** Clear command state */
|
||||
export function clearCommandState(): void {
|
||||
_pendingCommand = null;
|
||||
_commandResult = null;
|
||||
}
|
||||
|
||||
// ─── Recording Storage ───
|
||||
|
||||
interface RecordingInfo {
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const _recordings = new Map<string, RecordingInfo>();
|
||||
|
||||
/** Get completed recording info by id */
|
||||
export function getRecording(id: string): RecordingInfo | undefined {
|
||||
return _recordings.get(id);
|
||||
}
|
||||
|
||||
export function setRecording(id: string, info: RecordingInfo): void {
|
||||
_recordings.set(id, info);
|
||||
}
|
||||
|
||||
export class McpServer {
|
||||
private server: http.Server | null = null;
|
||||
private tools: Map<string, ToolCategory> = new Map();
|
||||
private toolIndex: Map<string, ToolCategory> = new Map(); // toolName -> category
|
||||
private config: ServerConfig;
|
||||
|
||||
constructor(config?: Partial<ServerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/** Register a tool category */
|
||||
register(category: ToolCategory): void {
|
||||
this.tools.set(category.categoryName, category);
|
||||
for (const tool of category.getTools()) {
|
||||
this.toolIndex.set(tool.name, category);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all tool definitions */
|
||||
getAllTools(): ToolDefinition[] {
|
||||
const all: ToolDefinition[] = [];
|
||||
for (const cat of this.tools.values()) {
|
||||
all.push(...cat.getTools());
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Start HTTP server */
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
this.server.listen(this.config.port, "127.0.0.1", () => {
|
||||
console.log(`[cocos-creator-mcp] Server started on http://127.0.0.1:${this.config.port}/mcp`);
|
||||
resolve();
|
||||
});
|
||||
this.server.on("error", (e) => {
|
||||
console.error(`[cocos-creator-mcp] Server error:`, e);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop HTTP server */
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.server.close(() => {
|
||||
this.server = null;
|
||||
console.log("[cocos-creator-mcp] Server stopped");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this.config.port;
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// CORS
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url || "/";
|
||||
const origin = `http://127.0.0.1:${this.config.port}`;
|
||||
|
||||
// ─── OAuth endpoints (MCP spec 2025-06-18 / RFC 9728 / RFC 8414 / RFC 7591) ───
|
||||
//
|
||||
// Claude Code の VSCode 拡張は HTTP トランスポートの MCP サーバーに対して
|
||||
// 無条件で OAuth discovery / DCR を試みる (#26917 等の既知バグ)。
|
||||
// cocos-creator-mcp は localhost-only のローカル開発ツールで本物の認証は不要だが、
|
||||
// クライアントを満足させるため OAuth エンドポイント群をダミー実装して常時許可する。
|
||||
//
|
||||
// TODO: 以下のいずれかが発生したら削除する
|
||||
// 1. anthropics/claude-code #26917 / #38102 等の HTTP OAuth バグが修正される
|
||||
// 2. 本物の認証機構を実装する必要が出る(偽 OAuth と衝突するため)
|
||||
// 3. MCP spec が PKCE 検証・トークンローテーション必須等に更新される
|
||||
// 4. stdio ブリッジが十分定着して HTTP transport 自体を deprecate する
|
||||
|
||||
// RFC 9728 Protected Resource Metadata
|
||||
if (url === "/.well-known/oauth-protected-resource" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
resource: `${origin}/mcp`,
|
||||
authorization_servers: [origin],
|
||||
bearer_methods_supported: ["header"],
|
||||
scopes_supported: ["mcp"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 8414 Authorization Server Metadata
|
||||
if (url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/oauth/authorize`,
|
||||
token_endpoint: `${origin}/oauth/token`,
|
||||
registration_endpoint: `${origin}/oauth/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256", "plain"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 7591 Dynamic Client Registration — accept anything, return dummy client
|
||||
if (url === "/oauth/register" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
let reg: any = {};
|
||||
try { reg = JSON.parse(body); } catch { /* ignore */ }
|
||||
const clientId = `cocos-mcp-client-${Date.now()}`;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_name: reg.client_name || "cocos-creator-mcp client",
|
||||
redirect_uris: reg.redirect_uris || [],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth authorization endpoint — auto-consent, redirect immediately with code
|
||||
if (url.startsWith("/oauth/authorize") && req.method === "GET") {
|
||||
const parsed = new URL(url, origin);
|
||||
const redirectUri = parsed.searchParams.get("redirect_uri") || "";
|
||||
const state = parsed.searchParams.get("state") || "";
|
||||
if (!redirectUri) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "invalid_request", error_description: "redirect_uri required" }));
|
||||
return;
|
||||
}
|
||||
const code = `cocos-mcp-code-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const location = `${redirectUri}${redirectUri.includes("?") ? "&" : "?"}code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
res.writeHead(302, { Location: location });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth token endpoint — always issue a dummy token
|
||||
if (url === "/oauth/token" && req.method === "POST") {
|
||||
await readBody(req); // drain
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
access_token: "cocos-mcp-public-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 86400,
|
||||
scope: "mcp",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", tools: this.getAllTools().length }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Game debug command queue — game polls for commands
|
||||
if (url === "/game/command" && req.method === "GET") {
|
||||
const cmd = _pendingCommand;
|
||||
_pendingCommand = null; // consume
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(cmd));
|
||||
return;
|
||||
}
|
||||
|
||||
// Game debug command result — game posts result
|
||||
if (url === "/game/result" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
_commandResult = JSON.parse(body);
|
||||
} catch { /* ignore */ }
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Game preview recording receiver
|
||||
if (url === "/game/recording" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
const { id, base64, mimeType, savePath } = JSON.parse(body);
|
||||
if (!id || !base64) throw new Error("id/base64 required");
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const buffer = Buffer.from(base64, "base64");
|
||||
|
||||
// savePath指定があればそこに保存(絶対パスまたはプロジェクト相対パス)
|
||||
const projectPath = (global as any).Editor?.Project?.path
|
||||
|| process.cwd();
|
||||
let dir: string;
|
||||
if (savePath) {
|
||||
dir = path.isAbsolute(savePath) ? savePath : path.join(projectPath, savePath);
|
||||
} else {
|
||||
dir = path.join(projectPath, "temp", "recordings");
|
||||
}
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const mt = (mimeType || "").toLowerCase();
|
||||
const ext = mt.includes("webm") ? "webm"
|
||||
: mt.includes("mp4") ? "mp4"
|
||||
: "bin";
|
||||
const fileName = `${id}.${ext}`;
|
||||
const filePath = path.join(dir, fileName);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
setRecording(id, {
|
||||
path: filePath,
|
||||
size: buffer.length,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
if (this.config.autoArchiveRecordings) {
|
||||
archiveOldFiles(dir);
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ success: true, path: filePath, size: buffer.length }));
|
||||
} catch (e: any) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ success: false, error: e.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Game preview log receiver
|
||||
if (url === "/log" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
const entries: GameLogEntry[] = JSON.parse(body);
|
||||
for (const entry of (Array.isArray(entries) ? entries : [entries])) {
|
||||
_gameLogs.push({
|
||||
timestamp: entry.timestamp || new Date().toISOString(),
|
||||
level: entry.level || "log",
|
||||
message: entry.message || "",
|
||||
});
|
||||
// __debug_state__ ログから userId を debug-menu.json に保存
|
||||
try {
|
||||
const msg = JSON.parse(entry.message || "");
|
||||
if (msg.__debug_state__ && msg.userId) {
|
||||
const _fs = require("fs");
|
||||
const _path = require("path");
|
||||
const projectPath = (global as any).Editor?.Project?.path || process.cwd();
|
||||
const settingsPath = _path.join(projectPath, "settings", "debug-menu.json");
|
||||
_fs.writeFileSync(settingsPath, JSON.stringify({ userId: msg.userId }, null, 2), "utf-8");
|
||||
}
|
||||
} catch { /* not debug_state */ }
|
||||
}
|
||||
if (_gameLogs.length > MAX_GAME_LOG_BUFFER) {
|
||||
_gameLogs.splice(0, _gameLogs.length - MAX_GAME_LOG_BUFFER);
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// MCP endpoint
|
||||
if (url === "/mcp") {
|
||||
if (req.method === "GET") {
|
||||
// SSE keepalive stream
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
// Send initial comment to keep connection alive
|
||||
res.write(": connected\n\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
await this.handleMcpPost(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 404
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
|
||||
private async handleMcpPost(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await readBody(req);
|
||||
let rpc: JsonRpcRequest;
|
||||
try {
|
||||
rpc = JSON.parse(body);
|
||||
} catch {
|
||||
this.sendJsonRpc(res, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const accept = req.headers["accept"] || "";
|
||||
const wantSse = accept.includes("text/event-stream");
|
||||
|
||||
let response: JsonRpcResponse;
|
||||
|
||||
switch (rpc.method) {
|
||||
case "initialize":
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: {
|
||||
name: "cocos-creator-mcp",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
case "notifications/initialized":
|
||||
// No response needed for notification
|
||||
res.writeHead(204, { "Mcp-Session-Id": SESSION_ID });
|
||||
res.end();
|
||||
return;
|
||||
|
||||
case "tools/list":
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
result: { tools: this.getAllTools() },
|
||||
};
|
||||
break;
|
||||
|
||||
case "tools/call": {
|
||||
const toolName = rpc.params?.name;
|
||||
const args = rpc.params?.arguments || {};
|
||||
const category = this.toolIndex.get(toolName);
|
||||
|
||||
if (!category) {
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32602, message: `Unknown tool: ${toolName}` },
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const start = Date.now();
|
||||
console.log(`[cocos-creator-mcp] ▶ ${toolName}`, Object.keys(args).length > 0 ? JSON.stringify(args).substring(0, 200) : "");
|
||||
const timeoutMs = (toolName.startsWith("prefab_") || toolName === "scene_open") ? 120000 : 30000;
|
||||
const result = await withTimeout(category.execute(toolName, args), timeoutMs, `Tool ${toolName} timed out`);
|
||||
console.log(`[cocos-creator-mcp] ✓ ${toolName} (${Date.now() - start}ms)`);
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
result,
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error(`[cocos-creator-mcp] ✗ ${toolName}:`, e.message || String(e));
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32603, message: e.message || String(e) },
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32601, message: `Method not found: ${rpc.method}` },
|
||||
};
|
||||
}
|
||||
|
||||
if (wantSse) {
|
||||
this.sendSse(res, [response]);
|
||||
} else {
|
||||
this.sendJsonRpc(res, response);
|
||||
}
|
||||
}
|
||||
|
||||
private sendJsonRpc(res: http.ServerResponse, data: JsonRpcResponse): void {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Mcp-Session-Id": SESSION_ID,
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
private sendSse(res: http.ServerResponse, messages: JsonRpcResponse[]): void {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Mcp-Session-Id": SESSION_ID,
|
||||
});
|
||||
for (const msg of messages) {
|
||||
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
(e) => { clearTimeout(timer); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared node UUID resolution — resolves nodeName to UUID via scene script.
|
||||
*/
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export interface ResolvedNode {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node UUID from either `uuid` or `nodeName` parameter.
|
||||
* If `uuid` is provided, it is returned as-is.
|
||||
* If `nodeName` is provided, searches the scene for a matching node.
|
||||
* Throws if neither is provided or no node is found.
|
||||
*/
|
||||
export async function resolveNodeUuid(args: { uuid?: string; nodeName?: string }): Promise<ResolvedNode> {
|
||||
if (args.uuid) {
|
||||
return { uuid: args.uuid, name: "" };
|
||||
}
|
||||
if (!args.nodeName) {
|
||||
throw new Error("Either 'uuid' or 'nodeName' is required");
|
||||
}
|
||||
const result = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method: "findNodesByName",
|
||||
args: [args.nodeName],
|
||||
});
|
||||
if (!result?.success || !result?.data?.length) {
|
||||
throw new Error(`Node not found: "${args.nodeName}"`);
|
||||
}
|
||||
// 最初のマッチを使用
|
||||
const node = result.data[0];
|
||||
return { uuid: node.uuid, name: node.name || args.nodeName };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
const { createApp } = require("vue");
|
||||
|
||||
const panelDataMap = new WeakMap<any, any>();
|
||||
|
||||
module.exports = Editor.Panel.define({
|
||||
template: `
|
||||
<div id="app">
|
||||
<h2>Cocos Creator MCP</h2>
|
||||
<div class="status">
|
||||
<span>Status: <strong :class="running ? 'on' : 'off'">{{ running ? 'Running' : 'Stopped' }}</strong></span>
|
||||
</div>
|
||||
<div class="port-row">
|
||||
<label>Port:</label>
|
||||
<input type="number" v-model.number="editPort" :disabled="running" min="1024" max="65535" />
|
||||
<ui-button v-if="!running && editPort !== port" @confirm="applyPort" class="small-btn">Apply</ui-button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<ui-button v-if="!running" @confirm="start">Start Server</ui-button>
|
||||
<ui-button v-if="running" @confirm="stop">Stop Server</ui-button>
|
||||
</div>
|
||||
<div v-if="running" class="info">
|
||||
<p>Endpoint: <code>http://127.0.0.1:{{ port }}/mcp</code></p>
|
||||
<p>Tools: <strong>{{ toolCount }}</strong></p>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
`,
|
||||
style: `
|
||||
#app { padding: 12px; font-family: sans-serif; color: #ccc; }
|
||||
h2 { margin: 0 0 8px 0; font-size: 16px; }
|
||||
.status { margin: 8px 0; }
|
||||
.on { color: #4f4; }
|
||||
.off { color: #f66; }
|
||||
.port-row { margin: 8px 0; display: flex; align-items: center; gap: 8px; }
|
||||
.port-row label { font-size: 12px; }
|
||||
.port-row input { width: 80px; padding: 3px 6px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; font-size: 12px; }
|
||||
.port-row input:disabled { opacity: 0.5; }
|
||||
.actions { margin: 8px 0; }
|
||||
.info { margin: 8px 0; padding: 8px; background: var(--color-normal-fill-emphasis); border-radius: 4px; }
|
||||
.info p { margin: 4px 0; font-size: 12px; }
|
||||
.info code { background: #333; padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
||||
.error { margin: 8px 0; color: #f66; font-size: 12px; }
|
||||
`,
|
||||
$: { app: "#app" },
|
||||
ready() {
|
||||
if (!this.$.app) return;
|
||||
const app = createApp({
|
||||
data() {
|
||||
return {
|
||||
running: false,
|
||||
port: 3000,
|
||||
editPort: 3000,
|
||||
toolCount: 0,
|
||||
error: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async start(this: any) {
|
||||
try {
|
||||
this.error = "";
|
||||
const result = await Editor.Message.request("cocos-creator-mcp", "start-server");
|
||||
this.running = result.running;
|
||||
this.port = result.port;
|
||||
this.editPort = result.port;
|
||||
await this.refresh();
|
||||
} catch (e: any) {
|
||||
this.error = e.message || String(e);
|
||||
}
|
||||
},
|
||||
async stop(this: any) {
|
||||
try {
|
||||
await Editor.Message.request("cocos-creator-mcp", "stop-server");
|
||||
this.running = false;
|
||||
this.toolCount = 0;
|
||||
} catch (e: any) {
|
||||
this.error = e.message || String(e);
|
||||
}
|
||||
},
|
||||
async applyPort(this: any) {
|
||||
try {
|
||||
this.error = "";
|
||||
const result = await Editor.Message.request("cocos-creator-mcp", "update-port", this.editPort);
|
||||
this.port = result.port;
|
||||
this.running = result.running;
|
||||
if (this.running) await this.refresh();
|
||||
} catch (e: any) {
|
||||
this.error = e.message || String(e);
|
||||
}
|
||||
},
|
||||
async refresh(this: any) {
|
||||
try {
|
||||
const status = await Editor.Message.request("cocos-creator-mcp", "get-server-status");
|
||||
this.running = status.running;
|
||||
this.port = status.port;
|
||||
this.editPort = status.port;
|
||||
this.toolCount = status.toolCount;
|
||||
} catch (e: any) {
|
||||
console.warn("[cocos-creator-mcp] refresh failed:", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted(this: any) {
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
console.warn("[cocos-creator-mcp] mounted refresh failed:", e);
|
||||
}
|
||||
},
|
||||
});
|
||||
app.mount(this.$.app);
|
||||
panelDataMap.set(this, app);
|
||||
},
|
||||
close() {
|
||||
const app = panelDataMap.get(this);
|
||||
if (app) app.unmount();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
const { createApp: createAppRec } = require("vue");
|
||||
|
||||
const panelDataMapRec = new WeakMap<any, any>();
|
||||
|
||||
module.exports = Editor.Panel.define({
|
||||
template: `
|
||||
<div id="app">
|
||||
<h2>Preview Recorder</h2>
|
||||
|
||||
<div class="controls">
|
||||
<button v-if="!recording" @click="start" class="btn btn-start">● 録画開始</button>
|
||||
<button v-else @click="stop" class="btn btn-stop" :disabled="stopping">■ 録画停止{{ stopping ? '中...' : '' }}</button>
|
||||
<button @click="screenshot" class="btn btn-shot" :disabled="shooting">📸 スクショ{{ shooting ? '中...' : '' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="recording" class="status-row">
|
||||
<span class="rec-dot">●</span> REC <strong>{{ elapsed }}s</strong>
|
||||
<span class="info">{{ recordingInfo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">録画設定</div>
|
||||
<div class="row">
|
||||
<label>FPS:</label>
|
||||
<input type="number" v-model.number="fps" :disabled="recording" min="10" max="60" />
|
||||
<label>形式:</label>
|
||||
<select v-model="format" :disabled="recording">
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>品質:</label>
|
||||
<select v-model="quality" @change="onQualityChange" :disabled="recording">
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="ultra">最高</option>
|
||||
<option value="custom">カスタム</option>
|
||||
</select>
|
||||
<label title="ビットレート = 幅 × 高さ × FPS × 品質係数">品質係数:</label>
|
||||
<input type="number" v-model.number="coefficient" @input="onCoefChange"
|
||||
:disabled="recording" min="0.01" max="2" step="0.01" class="custom-bitrate"
|
||||
title="ビットレート = 幅 × 高さ × FPS × 品質係数" />
|
||||
<button @click="resetQuality" class="btn btn-small" :disabled="recording" title="録画設定を初期値に戻す">↺</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title">スクショ設定</div>
|
||||
<div class="row">
|
||||
<label>形式:</label>
|
||||
<select v-model="shotFormat" :disabled="shooting">
|
||||
<option value="png">PNG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="section-title">保存先</div>
|
||||
<div class="row">
|
||||
<input type="text" v-model="savePath" :disabled="recording" class="path-input" placeholder="temp/recordings" />
|
||||
<button @click="selectSaveFolder" class="btn btn-small" :disabled="recording">📁 選択</button>
|
||||
<button @click="resetSavePath" class="btn btn-small" :disabled="recording" title="保存先を初期値に戻す">↺</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button @click="openSaveFolder" class="btn btn-small">📂 保存フォルダを開く</button>
|
||||
<label class="checkbox-label" title="ONにすると、録画/スクショ保存時に24時間以上前のファイルを OLD_yyyyMM フォルダに自動移動します"><input type="checkbox" v-model="autoArchive" @change="onAutoArchiveChange" /> 古いファイルを自動整理</label>
|
||||
<button class="btn btn-help" @click="showArchiveHelp = !showArchiveHelp" title="自動整理の説明">?</button>
|
||||
</div>
|
||||
<div v-if="showArchiveHelp" class="help-box">
|
||||
<strong>自動整理について</strong><br>
|
||||
ONにすると、録画やスクショを保存するたびに、24時間以上前の古いファイルを<br>
|
||||
<code>OLD_yyyyMM/</code> フォルダ(例: OLD_202604/)に自動で移動します。<br>
|
||||
保存フォルダ直下には直近のファイルだけが残り、整理された状態を保てます。
|
||||
</div>
|
||||
|
||||
<div v-if="lastResult" class="result" :class="lastError ? 'error' : 'success'">
|
||||
<div v-if="!lastError">
|
||||
<strong>✓ {{ lastResult.kind === 'shot' ? 'スクショ保存' : '録画完了' }}</strong><br>
|
||||
<code>{{ lastResult.path }}</code><br>
|
||||
{{ (lastResult.size / 1024).toFixed(1) }} KB
|
||||
</div>
|
||||
<div v-else>
|
||||
<strong>✗ エラー:</strong> {{ lastResult.error || lastResult.message || 'unknown' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
※ ゲームプレビュー実行中に使ってください(GameDebugClientが必要)
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
style: `
|
||||
#app { padding: 16px; font-family: sans-serif; color: #ccc; font-size: 12px; }
|
||||
h2 { margin: 0 0 12px 0; font-size: 18px; }
|
||||
.controls { margin: 12px 0; }
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.btn:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.btn-start { background: #d44; }
|
||||
.btn-start:hover { background: #e55; }
|
||||
.btn-stop { background: #888; }
|
||||
.btn-stop:hover { background: #999; }
|
||||
.btn-shot { background: #468; margin-left: 8px; }
|
||||
.btn-shot:hover { background: #579; }
|
||||
.section-title {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 4px;
|
||||
padding: 4px 0 3px 0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #8af;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 4px 10px;
|
||||
background: #4a8;
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.btn-small:hover { background: #5b9; }
|
||||
.status-row { margin: 10px 0; font-size: 14px; }
|
||||
.rec-dot { color: #f44; animation: blink 1s infinite; }
|
||||
@keyframes blink { 50% { opacity: 0.3; } }
|
||||
.info { color: #888; font-size: 11px; margin-left: 8px; }
|
||||
.row { margin: 10px 0; display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
|
||||
.row label { font-size: 12px; }
|
||||
.row input { width: 60px; padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
|
||||
.row select { padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
|
||||
.path-input { flex: 1; min-width: 200px; width: auto !important; font-family: monospace; }
|
||||
.custom-bitrate { width: 70px !important; }
|
||||
.unit { font-size: 11px; color: #888; }
|
||||
.result { margin: 12px 0; padding: 10px; border-radius: 4px; font-size: 12px; line-height: 1.5; }
|
||||
.result.success { background: #1a3a1a; color: #afa; }
|
||||
.result.error { background: #3a1a1a; color: #faa; }
|
||||
.result code { font-size: 10px; word-break: break-all; background: #000; padding: 2px 4px; border-radius: 2px; }
|
||||
.checkbox-label { font-size: 12px; display: flex; align-items: center; gap: 4px; margin-left: auto; cursor: pointer; }
|
||||
.checkbox-label input[type="checkbox"] { cursor: pointer; }
|
||||
.btn-help { padding: 2px 7px; background: #555; font-size: 11px; font-weight: bold; border-radius: 50%; min-width: 20px; margin-left: 4px; }
|
||||
.btn-help:hover { background: #777; }
|
||||
.help-box { margin: 8px 0; padding: 10px; background: #1a2a3a; border: 1px solid #345; border-radius: 4px; font-size: 11px; line-height: 1.6; color: #bcd; }
|
||||
.help-box code { background: #000; padding: 1px 4px; border-radius: 2px; font-size: 10px; }
|
||||
.note { margin-top: 16px; padding-top: 8px; border-top: 1px solid #333; font-size: 10px; color: #888; }
|
||||
`,
|
||||
$: { app: "#app" },
|
||||
ready() {
|
||||
if (!this.$.app) return;
|
||||
const MCP_BASE = "http://127.0.0.1:3000";
|
||||
const STORAGE_KEY = "cocos-mcp-recorder-settings";
|
||||
const PERSISTED_KEYS = ["fps", "quality", "coefficient", "format", "savePath", "shotFormat"];
|
||||
// localStorage から設定を読み込み
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
const saved = loadSettings();
|
||||
// プロジェクト設定から autoArchiveRecordings を読み込み
|
||||
const loadProjectConfig = () => {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
} catch { /* ignore */ }
|
||||
return {};
|
||||
};
|
||||
const projectCfg = loadProjectConfig();
|
||||
const app = createAppRec({
|
||||
data() {
|
||||
return {
|
||||
recording: false,
|
||||
stopping: false,
|
||||
shooting: false,
|
||||
elapsed: "0.0",
|
||||
recordingInfo: "",
|
||||
fps: saved.fps ?? 30,
|
||||
quality: saved.quality ?? "medium",
|
||||
coefficient: saved.coefficient ?? 0.25,
|
||||
format: saved.format ?? "mp4",
|
||||
savePath: saved.savePath ?? "temp/recordings",
|
||||
shotFormat: saved.shotFormat ?? "png",
|
||||
autoArchive: projectCfg.autoArchiveRecordings ?? false,
|
||||
showArchiveHelp: false,
|
||||
lastResult: null as any,
|
||||
lastError: false,
|
||||
_startTime: 0,
|
||||
_timer: null as any,
|
||||
_aliveCheckTimer: null as any,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
fps(this: any) { this.saveSettings(); },
|
||||
quality(this: any) { this.saveSettings(); },
|
||||
coefficient(this: any) { this.saveSettings(); },
|
||||
format(this: any) { this.saveSettings(); },
|
||||
savePath(this: any) { this.saveSettings(); },
|
||||
shotFormat(this: any) { this.saveSettings(); },
|
||||
},
|
||||
methods: {
|
||||
saveSettings(this: any) {
|
||||
const data: any = {};
|
||||
for (const key of PERSISTED_KEYS) data[key] = this[key];
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch { /* ignore */ }
|
||||
},
|
||||
async isPreviewRunning(this: any): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0", id: 98, method: "tools/call",
|
||||
params: {
|
||||
name: "debug_game_command",
|
||||
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
return !!parsed?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async start(this: any) {
|
||||
this.lastResult = null;
|
||||
if (!await this.isPreviewRunning()) {
|
||||
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してから録画してください。" };
|
||||
this.lastError = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "debug_record_start",
|
||||
arguments: {
|
||||
fps: this.fps,
|
||||
coefficient: this.coefficient,
|
||||
format: this.format,
|
||||
savePath: this.savePath,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success && parsed.data?.id) {
|
||||
this.recording = true;
|
||||
const d = parsed.data;
|
||||
const mbps = d?.videoBitsPerSecond ? (d.videoBitsPerSecond / 1_000_000).toFixed(2) : "?";
|
||||
this.recordingInfo = `${d?.canvasWidth || "?"}x${d?.canvasHeight || "?"} @ ${d?.fps || "?"}fps / ${mbps}Mbps / ${d?.mimeType || ""}`;
|
||||
this._startTime = Date.now();
|
||||
this._timer = setInterval(() => {
|
||||
this.elapsed = ((Date.now() - this._startTime) / 1000).toFixed(1);
|
||||
}, 100);
|
||||
// プレビュー停止検知用ポーリング(2秒毎)
|
||||
this._aliveCheckTimer = setInterval(() => {
|
||||
this.checkPreviewAlive();
|
||||
}, 2000);
|
||||
} else {
|
||||
// 可能な限り詳細なエラー情報を表示
|
||||
const errDetail = parsed?.data?.error
|
||||
|| parsed?.error
|
||||
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|
||||
|| (parsed ? JSON.stringify(parsed).substring(0, 200) : "no response")
|
||||
|| "録画開始失敗";
|
||||
this.lastResult = { error: errDetail };
|
||||
this.lastError = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.lastResult = { error: `通信エラー: ${e.message}` };
|
||||
this.lastError = true;
|
||||
}
|
||||
},
|
||||
async stop(this: any) {
|
||||
this.stopping = true;
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "debug_record_stop",
|
||||
arguments: { timeout: 60000 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success && parsed.data?.path) {
|
||||
this.lastResult = { path: parsed.data.path, size: parsed.data.size };
|
||||
this.lastError = false;
|
||||
} else {
|
||||
const errDetail = parsed?.data?.error
|
||||
|| parsed?.error
|
||||
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|
||||
|| "録画停止失敗";
|
||||
this.lastResult = { error: errDetail };
|
||||
this.lastError = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.lastResult = { error: `通信エラー: ${e.message}` };
|
||||
this.lastError = true;
|
||||
} finally {
|
||||
this.recording = false;
|
||||
this.stopping = false;
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
|
||||
}
|
||||
},
|
||||
onQualityChange(this: any) {
|
||||
const map: Record<string, number> = { low: 0.15, medium: 0.25, high: 0.40, ultra: 0.60 };
|
||||
if (this.quality !== "custom") this.coefficient = map[this.quality];
|
||||
},
|
||||
onCoefChange(this: any) {
|
||||
const map: Record<number, string> = { 0.15: "low", 0.25: "medium", 0.40: "high", 0.60: "ultra" };
|
||||
this.quality = map[this.coefficient] || "custom";
|
||||
},
|
||||
resetQuality(this: any) {
|
||||
this.fps = 30;
|
||||
this.quality = "medium";
|
||||
this.coefficient = 0.25;
|
||||
this.format = "mp4";
|
||||
},
|
||||
resetSavePath(this: any) {
|
||||
this.savePath = "temp/recordings";
|
||||
},
|
||||
onAutoArchiveChange(this: any) {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
|
||||
let cfg: any = {};
|
||||
if (fs.existsSync(p)) cfg = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
cfg.autoArchiveRecordings = this.autoArchive;
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), "utf-8");
|
||||
} catch (e) {
|
||||
console.warn("[recorder] 設定保存失敗:", e);
|
||||
}
|
||||
},
|
||||
async screenshot(this: any) {
|
||||
this.shooting = true;
|
||||
if (!await this.isPreviewRunning()) {
|
||||
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してからスクショしてください。" };
|
||||
this.lastError = true;
|
||||
this.shooting = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "debug_game_command",
|
||||
arguments: { type: "screenshot", args: {}, timeout: 5000, maxWidth: 0, imageFormat: this.shotFormat },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success && parsed.path) {
|
||||
// savePath 配下にコピー
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const projectPath = Editor.Project.path;
|
||||
let destDir = this.savePath || "temp/recordings";
|
||||
if (!path.isAbsolute(destDir)) destDir = path.join(projectPath, destDir);
|
||||
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const ext = path.extname(parsed.path) || ".png";
|
||||
const destPath = path.join(destDir, `screenshot_${ts}${ext}`);
|
||||
fs.copyFileSync(parsed.path, destPath);
|
||||
// 設定に応じて古いファイルをアーカイブ
|
||||
try {
|
||||
const settingsPath = path.join(projectPath, "settings", "cocos-creator-mcp.json");
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
||||
if (cfg.autoArchiveRecordings) {
|
||||
const { archiveOldFiles } = require("../../archive");
|
||||
archiveOldFiles(destDir);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
this.lastResult = { kind: "shot", path: destPath, size: parsed.size };
|
||||
this.lastError = false;
|
||||
} else {
|
||||
const errDetail = parsed?.error || parsed?.message || "スクショ失敗";
|
||||
this.lastResult = { error: errDetail };
|
||||
this.lastError = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.lastResult = { error: `通信エラー: ${e.message}` };
|
||||
this.lastError = true;
|
||||
} finally {
|
||||
this.shooting = false;
|
||||
}
|
||||
},
|
||||
async checkPreviewAlive(this: any) {
|
||||
if (!this.recording) return;
|
||||
try {
|
||||
// MCP経由で軽量なinspectコマンドを送る
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0", id: 99, method: "tools/call",
|
||||
params: {
|
||||
name: "debug_game_command",
|
||||
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success) {
|
||||
// プレビュー生存
|
||||
return;
|
||||
}
|
||||
// 応答なし → プレビュー停止とみなして録画停止状態に
|
||||
console.warn("[PreviewRecorder] preview not responding, stopping recording state");
|
||||
this.recording = false;
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
|
||||
this.lastResult = { error: "プレビューが停止したため録画を中断しました(動画は保存されません)" };
|
||||
this.lastError = true;
|
||||
} catch (e) {
|
||||
// ネットワークエラーは通信エラーとして無視(一時的かもしれない)
|
||||
}
|
||||
},
|
||||
async selectSaveFolder(this: any) {
|
||||
try {
|
||||
const result = await (Editor.Dialog as any).select({
|
||||
title: "保存先フォルダを選択",
|
||||
type: "directory",
|
||||
multi: false,
|
||||
});
|
||||
if (result?.filePaths?.length) {
|
||||
const path = require("path");
|
||||
const projectPath = Editor.Project.path;
|
||||
const absPath = result.filePaths[0];
|
||||
// プロジェクト配下なら相対パスで保持
|
||||
const relPath = path.relative(projectPath, absPath);
|
||||
if (!relPath.startsWith("..") && !path.isAbsolute(relPath)) {
|
||||
this.savePath = relPath.replace(/\\/g, "/");
|
||||
} else {
|
||||
this.savePath = absPath;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[PreviewRecorder] selectSaveFolder failed:", e);
|
||||
}
|
||||
},
|
||||
openSaveFolder(this: any) {
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const projectPath = Editor.Project.path;
|
||||
let dir = this.savePath || "temp/recordings";
|
||||
if (!path.isAbsolute(dir)) dir = path.join(projectPath, dir);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
try {
|
||||
const { spawn } = require("child_process");
|
||||
const platform = process.platform;
|
||||
const [cmd, ...args] = platform === "win32"
|
||||
? ["explorer.exe", dir.replace(/\//g, "\\")]
|
||||
: platform === "darwin"
|
||||
? ["open", dir]
|
||||
: ["xdg-open", dir];
|
||||
const p = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
p.unref();
|
||||
} catch (e: any) {
|
||||
console.error("[PreviewRecorder] openSaveFolder failed:", e);
|
||||
this.lastResult = { error: `フォルダを開けませんでした: ${e.message}` };
|
||||
this.lastError = true;
|
||||
}
|
||||
},
|
||||
openFolder(this: any) {
|
||||
if (!this.lastResult?.path) return;
|
||||
const filePath = this.lastResult.path;
|
||||
try {
|
||||
const { shell } = require("electron");
|
||||
if (shell?.showItemInFolder) {
|
||||
shell.showItemInFolder(filePath);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* fallback */ }
|
||||
// フォールバック: OS別コマンド
|
||||
try {
|
||||
const { exec } = require("child_process");
|
||||
const platform = process.platform;
|
||||
if (platform === "win32") {
|
||||
exec(`explorer.exe /select,"${filePath.replace(/\//g, "\\")}"`);
|
||||
} else if (platform === "darwin") {
|
||||
exec(`open -R "${filePath}"`);
|
||||
} else {
|
||||
const dir = require("path").dirname(filePath);
|
||||
exec(`xdg-open "${dir}"`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[PreviewRecorder] openFolder failed:", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
app.mount(this.$.app);
|
||||
panelDataMapRec.set(this, app);
|
||||
},
|
||||
beforeClose() { },
|
||||
close() {
|
||||
const app = panelDataMapRec.get(this);
|
||||
if (app) app.unmount();
|
||||
panelDataMapRec.delete(this);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Scene script — runs inside CocosCreator's scene renderer process.
|
||||
* These methods are called via Editor.Message.request('scene', 'execute-scene-script', ...)
|
||||
* and have access to the `cc` module (engine runtime).
|
||||
*/
|
||||
|
||||
import { join } from "path";
|
||||
module.paths.push(join(Editor.App.path, "node_modules"));
|
||||
|
||||
// ─── Console Log Buffer ───
|
||||
|
||||
interface ConsoleLogEntry {
|
||||
timestamp: string;
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_BUFFER = 500;
|
||||
const _consoleLogs: ConsoleLogEntry[] = [];
|
||||
|
||||
const _originalLog = console.log;
|
||||
const _originalWarn = console.warn;
|
||||
const _originalError = console.error;
|
||||
|
||||
function formatArgs(args: any[]): string {
|
||||
return args.map(a => {
|
||||
if (typeof a === "string") return a;
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
function pushLog(level: ConsoleLogEntry["level"], args: any[]): void {
|
||||
_consoleLogs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formatArgs(args),
|
||||
});
|
||||
if (_consoleLogs.length > MAX_LOG_BUFFER) {
|
||||
_consoleLogs.splice(0, _consoleLogs.length - MAX_LOG_BUFFER);
|
||||
}
|
||||
}
|
||||
|
||||
console.log = function (...args: any[]) {
|
||||
_originalLog.apply(console, args);
|
||||
pushLog("log", args);
|
||||
};
|
||||
|
||||
console.warn = function (...args: any[]) {
|
||||
_originalWarn.apply(console, args);
|
||||
pushLog("warn", args);
|
||||
};
|
||||
|
||||
console.error = function (...args: any[]) {
|
||||
_originalError.apply(console, args);
|
||||
pushLog("error", args);
|
||||
};
|
||||
|
||||
function getScene() {
|
||||
const { director } = require("cc");
|
||||
return director.getScene();
|
||||
}
|
||||
|
||||
function findNode(uuid: string) {
|
||||
const scene = getScene();
|
||||
if (!scene) return null;
|
||||
|
||||
// Recursive search — getChildByUuid is not recursive in cc
|
||||
const queue = [...scene.children];
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
if (node.uuid === uuid) return node;
|
||||
if (node.children) queue.push(...node.children);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build a node tree from a JSON spec.
|
||||
* Returns { uuid, name, children: [...] }
|
||||
*/
|
||||
function buildNodeRecursive(parent: any, spec: any): any {
|
||||
const { Node, js, Vec3 } = require("cc");
|
||||
|
||||
const node = new Node(spec.name || "Node");
|
||||
parent.addChild(node);
|
||||
|
||||
// Add components
|
||||
if (spec.components && Array.isArray(spec.components)) {
|
||||
const { Sprite, Label } = require("cc");
|
||||
for (const compName of spec.components) {
|
||||
const CompClass = js.getClassByName(compName);
|
||||
if (CompClass) {
|
||||
if (!node.getComponent(CompClass)) {
|
||||
const comp = node.addComponent(CompClass);
|
||||
// Sprite: sizeMode=CUSTOM でUITransformサイズの上書きを防ぐ
|
||||
if (comp instanceof Sprite) {
|
||||
comp.sizeMode = 0; // SizeMode.CUSTOM
|
||||
}
|
||||
// Label: useSystemFont + 色を黒に(白パネル上で見えるように)
|
||||
if (comp instanceof Label) {
|
||||
comp.useSystemFont = true;
|
||||
const { Color } = require("cc");
|
||||
comp.color = new Color(51, 51, 51, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set component properties
|
||||
// Format: { "cc.UITransform.contentSize": {width:720, height:1280} }
|
||||
if (spec.properties) {
|
||||
for (const [key, value] of Object.entries(spec.properties)) {
|
||||
const dotIdx = key.lastIndexOf(".");
|
||||
if (dotIdx < 0) continue;
|
||||
const compName = key.substring(0, dotIdx);
|
||||
const propName = key.substring(dotIdx + 1);
|
||||
|
||||
const CompClass = js.getClassByName(compName);
|
||||
if (!CompClass) continue;
|
||||
const comp = node.getComponent(CompClass);
|
||||
if (!comp) continue;
|
||||
|
||||
try {
|
||||
// contentSize needs special handling (Size type)
|
||||
if (propName === "contentSize" && value && typeof value === "object") {
|
||||
comp.setContentSize((value as any).width ?? 0, (value as any).height ?? 0);
|
||||
} else {
|
||||
comp[propName] = value;
|
||||
}
|
||||
} catch { /* skip invalid property */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Set UITransform anchorPoint if specified
|
||||
if (spec.anchorPoint) {
|
||||
const { UITransform } = require("cc");
|
||||
const ut = node.getComponent(UITransform);
|
||||
if (ut) ut.setAnchorPoint(spec.anchorPoint.x ?? 0.5, spec.anchorPoint.y ?? 0.5);
|
||||
}
|
||||
|
||||
// Set node properties (position, scale, active)
|
||||
if (spec.active === false) node.active = false;
|
||||
if (spec.position) node.setPosition(spec.position.x || 0, spec.position.y || 0, spec.position.z || 0);
|
||||
if (spec.scale) node.setScale(spec.scale.x ?? 1, spec.scale.y ?? 1, spec.scale.z ?? 1);
|
||||
|
||||
// Set Widget properties if specified
|
||||
// Format: { top: 0, bottom: 0, left: 0, right: 0 } — each field enables the corresponding alignment
|
||||
if (spec.widget) {
|
||||
const { Widget } = require("cc");
|
||||
let w = node.getComponent(Widget);
|
||||
if (!w) w = node.addComponent(Widget);
|
||||
const wSpec = spec.widget;
|
||||
if (wSpec.top !== undefined) { w.isAlignTop = true; w.top = wSpec.top; }
|
||||
if (wSpec.bottom !== undefined) { w.isAlignBottom = true; w.bottom = wSpec.bottom; }
|
||||
if (wSpec.left !== undefined) { w.isAlignLeft = true; w.left = wSpec.left; }
|
||||
if (wSpec.right !== undefined) { w.isAlignRight = true; w.right = wSpec.right; }
|
||||
if (wSpec.horizontalCenter !== undefined) { w.isAlignHorizontalCenter = true; w.horizontalCenter = wSpec.horizontalCenter; }
|
||||
if (wSpec.verticalCenter !== undefined) { w.isAlignVerticalCenter = true; w.verticalCenter = wSpec.verticalCenter; }
|
||||
}
|
||||
|
||||
// Build children
|
||||
const childResults: any[] = [];
|
||||
if (spec.children && Array.isArray(spec.children)) {
|
||||
for (const childSpec of spec.children) {
|
||||
childResults.push(buildNodeRecursive(node, childSpec));
|
||||
}
|
||||
}
|
||||
|
||||
return { uuid: node.uuid, name: node.name, children: childResults };
|
||||
}
|
||||
|
||||
function collectNodeInfo(node: any, includeComponents: boolean = false): any {
|
||||
const info: any = {
|
||||
uuid: node.uuid,
|
||||
name: node.name,
|
||||
active: node.active,
|
||||
position: { x: node.position.x, y: node.position.y, z: node.position.z },
|
||||
scale: { x: node.scale.x, y: node.scale.y, z: node.scale.z },
|
||||
parent: node.parent?.uuid || null,
|
||||
childCount: node.children?.length || 0,
|
||||
};
|
||||
if (includeComponents && node.components) {
|
||||
info.components = node.components.map((c: any) => ({
|
||||
type: c.constructor.name,
|
||||
uuid: c.uuid,
|
||||
enabled: c.enabled,
|
||||
}));
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
export const methods: Record<string, (...args: any[]) => any> = {
|
||||
getSceneHierarchy(includeComponents: boolean = false) {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const walk = (node: any): any => {
|
||||
const item: any = {
|
||||
uuid: node.uuid,
|
||||
name: node.name,
|
||||
active: node.active,
|
||||
children: [],
|
||||
};
|
||||
if (includeComponents && node.components) {
|
||||
item.components = node.components.map((c: any) => ({
|
||||
type: c.constructor.name,
|
||||
uuid: c.uuid,
|
||||
enabled: c.enabled,
|
||||
}));
|
||||
}
|
||||
if (node.children) {
|
||||
item.children = node.children.map((ch: any) => walk(ch));
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sceneName: scene.name,
|
||||
sceneUuid: scene.uuid,
|
||||
hierarchy: scene.children.map((ch: any) => walk(ch)),
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
getNodeInfo(uuid: string) {
|
||||
try {
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
return { success: true, data: collectNodeInfo(node, true) };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
getAllNodes() {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const nodes: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
nodes.push(collectNodeInfo(node));
|
||||
if (node.children) node.children.forEach(walk);
|
||||
};
|
||||
scene.children.forEach(walk);
|
||||
return { success: true, data: nodes };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 指定ノードの子孫から名前で検索する。auto_bind 用。
|
||||
* rootUuid が空の場合はシーン全体を検索(後方互換)。
|
||||
*/
|
||||
findDescendantsByName(rootUuid: string, name: string) {
|
||||
try {
|
||||
let root: any;
|
||||
if (rootUuid) {
|
||||
root = findNode(rootUuid);
|
||||
if (!root) return { success: false, error: `Root node ${rootUuid} not found` };
|
||||
} else {
|
||||
root = getScene();
|
||||
if (!root) return { success: false, error: "No active scene" };
|
||||
}
|
||||
|
||||
const results: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (node.name === name) results.push(collectNodeInfo(node));
|
||||
if (node.children) node.children.forEach(walk);
|
||||
};
|
||||
if (root.children) root.children.forEach(walk);
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 指定ノードの全子孫を depth 付きで返す。auto_bind の一括検索用。
|
||||
*/
|
||||
getAllDescendants(rootUuid: string) {
|
||||
try {
|
||||
const root = findNode(rootUuid);
|
||||
if (!root) return { success: false, error: `Node ${rootUuid} not found` };
|
||||
|
||||
const results: Array<{uuid: string, name: string, depth: number}> = [];
|
||||
const walk = (node: any, depth: number) => {
|
||||
results.push({ uuid: node.uuid, name: node.name, depth });
|
||||
if (node.children) node.children.forEach((c: any) => walk(c, depth + 1));
|
||||
};
|
||||
if (root.children) root.children.forEach((c: any) => walk(c, 1));
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
findNodesByName(name: string) {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const results: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (node.name === name) results.push(collectNodeInfo(node));
|
||||
if (node.children) node.children.forEach(walk);
|
||||
};
|
||||
scene.children.forEach(walk);
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
findNodeByPath(path: string) {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const parts = path.split("/");
|
||||
let current: any = null;
|
||||
|
||||
const walk = (node: any): any => {
|
||||
if (node.name === parts[0]) return node;
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = walk(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (const child of scene.children) {
|
||||
current = walk(child);
|
||||
if (current) break;
|
||||
}
|
||||
if (!current) return { success: false, error: `Node "${parts[0]}" not found` };
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const child = current.children?.find((c: any) => c.name === parts[i]);
|
||||
if (!child) return { success: false, error: `Child "${parts[i]}" not found in "${current.name}"` };
|
||||
current = child;
|
||||
}
|
||||
|
||||
return { success: true, data: collectNodeInfo(current) };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
setNodeProperty(uuid: string, property: string, value: any) {
|
||||
try {
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
switch (property) {
|
||||
case "position":
|
||||
node.setPosition(value.x ?? 0, value.y ?? 0, value.z ?? 0);
|
||||
break;
|
||||
case "rotation":
|
||||
node.setRotationFromEuler(value.x ?? 0, value.y ?? 0, value.z ?? 0);
|
||||
break;
|
||||
case "scale":
|
||||
node.setScale(value.x ?? 1, value.y ?? 1, value.z ?? 1);
|
||||
break;
|
||||
case "active":
|
||||
node.active = !!value;
|
||||
break;
|
||||
case "name":
|
||||
node.name = String(value);
|
||||
break;
|
||||
default:
|
||||
(node as any)[property] = value;
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
setComponentProperty(uuid: string, componentType: string, property: string, value: any) {
|
||||
try {
|
||||
const { js } = require("cc");
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
const CompClass = js.getClassByName(componentType);
|
||||
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
|
||||
|
||||
const comp = node.getComponent(CompClass);
|
||||
if (!comp) return { success: false, error: `Component ${componentType} not on node ${uuid}` };
|
||||
|
||||
comp[property] = value;
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
addComponentToNode(uuid: string, componentType: string) {
|
||||
try {
|
||||
const { js } = require("cc");
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
const CompClass = js.getClassByName(componentType);
|
||||
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
|
||||
|
||||
const comp = node.addComponent(CompClass);
|
||||
return { success: true, data: { uuid: comp.uuid, type: componentType } };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
moveNode(uuid: string, parentUuid: string) {
|
||||
try {
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
const parent = findNode(parentUuid);
|
||||
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
|
||||
node.setParent(parent);
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a node tree from a JSON spec in one call.
|
||||
* Spec format:
|
||||
* {
|
||||
* name: string,
|
||||
* components?: string[], // e.g. ["cc.UITransform", "cc.Layout"]
|
||||
* properties?: Record<string, any>, // e.g. { "cc.UITransform.contentSize": {width:720,height:1280} }
|
||||
* children?: NodeSpec[]
|
||||
* }
|
||||
*/
|
||||
buildNodeTree(parentUuid: string, spec: any) {
|
||||
try {
|
||||
const { Node, js, UITransform } = require("cc");
|
||||
const parent = findNode(parentUuid);
|
||||
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
|
||||
|
||||
const result = buildNodeRecursive(parent, spec);
|
||||
return { success: true, data: result };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
testLog(message: string = "test message") {
|
||||
console.log("[testLog]", message);
|
||||
const methods = Object.keys(exports.methods || {});
|
||||
return { success: true, bufferSize: _consoleLogs.length, methods };
|
||||
},
|
||||
|
||||
getConsoleLogs(count: number = 50, level?: string) {
|
||||
let logs = _consoleLogs;
|
||||
if (level) {
|
||||
logs = logs.filter(l => l.level === level);
|
||||
}
|
||||
return { success: true, logs: logs.slice(-count), total: _consoleLogs.length };
|
||||
},
|
||||
|
||||
clearConsoleLogs() {
|
||||
_consoleLogs.length = 0;
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async setPropertyViaEditor(nodeUuid: string, path: string, dump: any) {
|
||||
try {
|
||||
// scene:set-property API
|
||||
// uuid: ノードUUID(コンポーネントUUIDではない)
|
||||
// path: __comps__.{index}.{property} 形式
|
||||
// dump: { value, type } 形式
|
||||
const opts = { uuid: nodeUuid, path, dump };
|
||||
const result = await (Editor as any).Message.request("scene", "set-property", opts);
|
||||
return { success: true, result };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message || String(e) };
|
||||
}
|
||||
},
|
||||
|
||||
removeComponentFromNode(uuid: string, componentType: string) {
|
||||
try {
|
||||
const { js } = require("cc");
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
const CompClass = js.getClassByName(componentType);
|
||||
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
|
||||
|
||||
const comp = node.getComponent(CompClass);
|
||||
if (!comp) return { success: false, error: `Component ${componentType} not on node` };
|
||||
|
||||
node.removeComponent(comp);
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Shared screenshot utility — used by debug-tools and other tools that need screenshots.
|
||||
*/
|
||||
|
||||
export interface ProcessedImage {
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
size: number;
|
||||
format: string;
|
||||
originalSize: string;
|
||||
savedSize: string;
|
||||
}
|
||||
|
||||
export async function processImage(pngBuffer: Buffer, maxWidth: number, desiredFormat?: string): Promise<ProcessedImage> {
|
||||
try {
|
||||
const Vips = require("wasm-vips");
|
||||
const vips = await Vips();
|
||||
let image = vips.Image.newFromBuffer(pngBuffer);
|
||||
if (maxWidth > 0 && image.width > maxWidth) {
|
||||
image = image.thumbnailImage(maxWidth);
|
||||
}
|
||||
if (desiredFormat === "png") {
|
||||
const pngOut = image.pngsaveBuffer();
|
||||
const result = { buffer: Buffer.from(pngOut), width: image.width, height: image.height, format: "png" };
|
||||
image.delete();
|
||||
return result;
|
||||
}
|
||||
const outBuf = image.webpsaveBuffer({ Q: 85 });
|
||||
const result = { buffer: Buffer.from(outBuf), width: image.width, height: image.height, format: "webp" };
|
||||
image.delete();
|
||||
return result;
|
||||
} catch {
|
||||
// Fallback: NativeImage resize + JPEG
|
||||
const electron = require("electron");
|
||||
let image = electron.nativeImage.createFromBuffer(pngBuffer);
|
||||
if (maxWidth > 0) {
|
||||
const size = image.getSize();
|
||||
if (size.width > maxWidth) {
|
||||
const ratio = maxWidth / size.width;
|
||||
image = image.resize({ width: Math.round(size.width * ratio), height: Math.round(size.height * ratio) });
|
||||
}
|
||||
}
|
||||
const size = image.getSize();
|
||||
const buffer = image.toJPEG(85);
|
||||
return { buffer, width: size.width, height: size.height, format: "jpeg" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeEditorScreenshot(savePath?: string | undefined, maxWidth?: number): Promise<ScreenshotResult> {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const electron = require("electron");
|
||||
const windows = electron.BrowserWindow.getAllWindows();
|
||||
if (!windows || windows.length === 0) {
|
||||
throw new Error("No editor window found");
|
||||
}
|
||||
// Find the main (largest) window
|
||||
let win = windows[0];
|
||||
let maxArea = 0;
|
||||
for (const w of windows) {
|
||||
const bounds = w.getBounds();
|
||||
const area = bounds.width * bounds.height;
|
||||
if (area > maxArea) {
|
||||
maxArea = area;
|
||||
win = w;
|
||||
}
|
||||
}
|
||||
// Bring to front and wait for render
|
||||
win.show();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const nativeImage = await win.webContents.capturePage();
|
||||
const originalSize = nativeImage.getSize();
|
||||
const pngBuffer = nativeImage.toPNG();
|
||||
|
||||
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
|
||||
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth);
|
||||
|
||||
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
|
||||
if (!savePath) {
|
||||
const dir = path.join(Editor.Project.tmpDir, "screenshots");
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
savePath = path.join(dir, `screenshot_${timestamp}.${ext}`);
|
||||
}
|
||||
|
||||
const finalPath = savePath!;
|
||||
fs.writeFileSync(finalPath, buffer);
|
||||
return {
|
||||
success: true, path: finalPath, size: buffer.length, format,
|
||||
originalSize: `${originalSize.width}x${originalSize.height}`,
|
||||
savedSize: `${width}x${height}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ToolResult } from "./types";
|
||||
|
||||
/** Helper to create a successful text result */
|
||||
export function ok(data: any): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Helper to create an error result */
|
||||
export function err(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate that a string looks like a CocosCreator UUID (not empty, reasonable format) */
|
||||
export function validateUuid(uuid: string, label: string = "uuid"): string | null {
|
||||
if (!uuid || typeof uuid !== "string") {
|
||||
return `${label} is required`;
|
||||
}
|
||||
if (uuid.trim().length === 0) {
|
||||
return `${label} cannot be empty`;
|
||||
}
|
||||
// CocosCreator UUIDs: either standard format (8-4-4-4-12) or compressed (22 chars with +/=)
|
||||
// Be permissive — just reject obviously wrong values
|
||||
if (uuid.length < 10) {
|
||||
return `${label} "${uuid}" is too short to be a valid UUID`;
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
/** Validate UUID and return err() if invalid, null if valid */
|
||||
export function checkUuid(uuid: string, label: string = "uuid"): ToolResult | null {
|
||||
const error = validateUuid(uuid, label);
|
||||
return error ? err(error) : null;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class AssetTools implements ToolCategory {
|
||||
readonly categoryName = "asset";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "asset_create",
|
||||
description: "Create a new asset file in the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path for the new asset" },
|
||||
content: { type: "string", description: "File content (for text-based assets)" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_delete",
|
||||
description: "Delete an asset from the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset to delete" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_move",
|
||||
description: "Move/rename an asset.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Current db:// path" },
|
||||
destination: { type: "string", description: "New db:// path" },
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_copy",
|
||||
description: "Copy an asset to a new location.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Source db:// path" },
|
||||
destination: { type: "string", description: "Destination db:// path" },
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_save",
|
||||
description: "Save an asset by UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_reimport",
|
||||
description: "Re-import an asset to refresh it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_path",
|
||||
description: "Get the file path for an asset UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_uuid",
|
||||
description: "Get the UUID for an asset path.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_url",
|
||||
description: "Get the URL for an asset UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_get_details",
|
||||
description: "Get detailed metadata for an asset (type, size, importer, etc.).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_get_dependencies",
|
||||
description: "Get all assets that a given asset depends on.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_open_external",
|
||||
description: "Open an asset in the system's default external editor.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_import",
|
||||
description: "Import an external file into the project assets.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Source file path on disk" },
|
||||
target: { type: "string", description: "Target db:// path in project" },
|
||||
},
|
||||
required: ["source", "target"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_save_meta",
|
||||
description: "Save asset meta information (importer settings, etc.).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
meta: { type: "string", description: "Meta JSON string" },
|
||||
},
|
||||
required: ["uuid", "meta"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_generate_available_url",
|
||||
description: "Generate a non-conflicting asset URL (avoids name collisions).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "Desired db:// path" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_ready",
|
||||
description: "Check if the asset database is ready.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
// ── 以下、既存MCP未対応のEditor API ──
|
||||
{
|
||||
name: "asset_query_users",
|
||||
description: "Find all assets that use/reference a given asset.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Asset UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_missing",
|
||||
description: "Check if an asset has missing references.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Asset UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "asset_create":
|
||||
await (Editor.Message.request as any)("asset-db", "create-asset", args.path, args.content || null);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_delete":
|
||||
await (Editor.Message.request as any)("asset-db", "delete-asset", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_move":
|
||||
await (Editor.Message.request as any)("asset-db", "move-asset", args.source, args.destination);
|
||||
return ok({ success: true, source: args.source, destination: args.destination });
|
||||
case "asset_copy":
|
||||
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
|
||||
return ok({ success: true, source: args.source, destination: args.destination });
|
||||
case "asset_save":
|
||||
await (Editor.Message.request as any)("asset-db", "save-asset", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "asset_reimport":
|
||||
await (Editor.Message.request as any)("asset-db", "reimport-asset", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_query_path": {
|
||||
const path = await (Editor.Message.request as any)("asset-db", "query-path", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid, path });
|
||||
}
|
||||
case "asset_query_uuid": {
|
||||
const uuid = await (Editor.Message.request as any)("asset-db", "query-uuid", args.path);
|
||||
return ok({ success: true, path: args.path, uuid });
|
||||
}
|
||||
case "asset_query_url": {
|
||||
const url = await (Editor.Message.request as any)("asset-db", "query-url", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid, url });
|
||||
}
|
||||
case "asset_get_details": {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
|
||||
const meta = await (Editor.Message.request as any)("asset-db", "query-asset-meta", args.uuid).catch(() => null);
|
||||
return ok({ success: true, info, meta });
|
||||
}
|
||||
case "asset_get_dependencies": {
|
||||
// Try multiple API names as they vary by CocosCreator version
|
||||
let deps;
|
||||
try {
|
||||
deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid);
|
||||
} catch {
|
||||
try {
|
||||
deps = await (Editor.Message.request as any)("asset-db", "query-asset-depends", args.uuid);
|
||||
} catch {
|
||||
deps = [];
|
||||
}
|
||||
}
|
||||
return ok({ success: true, uuid: args.uuid, dependencies: deps });
|
||||
}
|
||||
case "asset_open_external":
|
||||
await (Editor.Message.request as any)("asset-db", "open-asset", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_import":
|
||||
await (Editor.Message.request as any)("asset-db", "import-asset", args.source, args.target);
|
||||
return ok({ success: true, source: args.source, target: args.target });
|
||||
case "asset_save_meta":
|
||||
await (Editor.Message.request as any)("asset-db", "save-asset-meta", args.uuid, args.meta);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "asset_generate_available_url": {
|
||||
const url = await (Editor.Message.request as any)("asset-db", "generate-available-url", args.url);
|
||||
return ok({ success: true, url });
|
||||
}
|
||||
case "asset_query_ready": {
|
||||
const ready = await (Editor.Message.request as any)("asset-db", "query-ready");
|
||||
return ok({ success: true, ready });
|
||||
}
|
||||
case "asset_query_users": {
|
||||
const users = await (Editor.Message.request as any)("asset-db", "query-asset-users", args.uuid).catch(() => []);
|
||||
return ok({ success: true, uuid: args.uuid, users });
|
||||
}
|
||||
case "asset_query_missing": {
|
||||
const missing = await (Editor.Message.request as any)("asset-db", "query-missing-asset-info", args.uuid).catch(() => null);
|
||||
return ok({ success: true, uuid: args.uuid, missing, hasMissing: !!missing });
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class BuilderTools implements ToolCategory {
|
||||
readonly categoryName = "builder";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "builder_open_panel",
|
||||
description: "Open the Build panel in the editor.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_get_settings",
|
||||
description: "Get the current build settings/configuration.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_query_tasks",
|
||||
description: "Query active build tasks.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_run_preview",
|
||||
description: "Start the preview server for browser testing.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_stop_preview",
|
||||
description: "Stop the preview server.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "builder_open_panel":
|
||||
Editor.Panel.open("builder");
|
||||
return ok({ success: true });
|
||||
case "builder_get_settings": {
|
||||
const settings = await (Editor.Message.request as any)("builder", "query-build-options").catch(() => null);
|
||||
return ok({ success: true, settings });
|
||||
}
|
||||
case "builder_query_tasks": {
|
||||
const tasks = await (Editor.Message.request as any)("builder", "query-tasks").catch(() => []);
|
||||
return ok({ success: true, tasks });
|
||||
}
|
||||
case "builder_run_preview":
|
||||
await (Editor.Message.request as any)("preview", "start");
|
||||
return ok({ success: true, message: "Preview started" });
|
||||
case "builder_stop_preview":
|
||||
await (Editor.Message.request as any)("preview", "stop");
|
||||
return ok({ success: true, message: "Preview stopped" });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import { resolveNodeUuid } from "../node-resolve";
|
||||
import { takeEditorScreenshot } from "../screenshot";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export class ComponentTools implements ToolCategory {
|
||||
readonly categoryName = "component";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "component_add",
|
||||
description: "Add a component to a node. Use cc.XXX format (e.g. 'cc.Label', 'cc.Sprite', 'cc.Button').",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
|
||||
},
|
||||
required: ["uuid", "componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_remove",
|
||||
description: "Remove a component from a node.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
componentType: { type: "string", description: "Component class name to remove" },
|
||||
},
|
||||
required: ["uuid", "componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_get_components",
|
||||
description: "Get all components on a node with their properties.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_set_property",
|
||||
description: "Set one or more properties on a component. For single: use property+value. For batch: use properties array. Use nodeName instead of uuid to find node by name. Set screenshot=true to capture editor screenshot after changes. Examples: Label.string, Label.fontSize, Sprite.color, UITransform.contentSize.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid — avoids UUID lookup)" },
|
||||
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
|
||||
property: { type: "string", description: "Property name (single mode)" },
|
||||
value: { description: "Value to set (single mode)" },
|
||||
properties: {
|
||||
type: "array",
|
||||
description: "Batch mode: array of {property, value} objects to set multiple properties at once",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
property: { type: "string", description: "Property name" },
|
||||
value: { description: "Value to set" },
|
||||
},
|
||||
required: ["property", "value"],
|
||||
},
|
||||
},
|
||||
screenshot: { type: "boolean", description: "If true, capture editor screenshot after setting properties and return the file path (default: false)" },
|
||||
},
|
||||
required: ["componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_get_info",
|
||||
description: "Get detailed dump of a specific component by its UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
componentUuid: { type: "string", description: "Component UUID (not node UUID)" },
|
||||
},
|
||||
required: ["componentUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_get_available",
|
||||
description: "List all available component classes that can be added to nodes.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "component_auto_bind",
|
||||
description: "Automatically bind @property references by matching property names to descendant node names. Searches only descendants of the target node. Validates component type existence. Supports array properties (Slot_0, Slot_1...). Mode: 'fuzzy' (default) tries exact match first, then case-insensitive; 'strict' requires exact match only.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
|
||||
componentType: { type: "string", description: "Script component class name (e.g. 'QuestReadyPageView')" },
|
||||
force: { type: "boolean", description: "If true, rebind even already-bound properties (default: false)" },
|
||||
mode: { type: "string", enum: ["fuzzy", "strict"], description: "Matching mode: 'fuzzy' (default) or 'strict'" },
|
||||
},
|
||||
required: ["componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_query_enum",
|
||||
description: "Get enum values for a component property. Useful for knowing what values Layout.type, Layout.resizeMode, etc. accept.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
componentType: { type: "string", description: "Component class (e.g. 'cc.Layout')" },
|
||||
property: { type: "string", description: "Property name (e.g. 'type', 'resizeMode')" },
|
||||
},
|
||||
required: ["uuid", "componentType", "property"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
// パラメータエイリアス: component → componentType
|
||||
const compType = args.componentType || args.component;
|
||||
|
||||
// nodeName → uuid 解決(対応ツールのみ)
|
||||
const needsResolve = ["component_set_property", "component_get_components", "component_auto_bind"];
|
||||
if (needsResolve.includes(toolName) && !args.uuid && args.nodeName) {
|
||||
try {
|
||||
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
|
||||
args.uuid = resolved.uuid;
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "component_add":
|
||||
return this.addComponent(args.uuid, compType);
|
||||
case "component_remove":
|
||||
return this.removeComponent(args.uuid, compType);
|
||||
case "component_get_components":
|
||||
return this.getComponents(args.uuid);
|
||||
case "component_set_property": {
|
||||
const properties = parseMaybeJson(args.properties);
|
||||
let result: ToolResult;
|
||||
if (properties && Array.isArray(properties)) {
|
||||
const parsed = properties.map((p: any) => ({ ...p, value: parseMaybeJson(p.value) }));
|
||||
result = await this.setProperties(args.uuid, compType, parsed);
|
||||
} else {
|
||||
result = await this.setProperty(args.uuid, compType, args.property, parseMaybeJson(args.value));
|
||||
}
|
||||
// screenshot オプション
|
||||
if (args.screenshot) {
|
||||
try {
|
||||
const ss = await takeEditorScreenshot();
|
||||
const data = JSON.parse(result.content[0].text);
|
||||
data.screenshot = { path: ss.path, size: ss.savedSize };
|
||||
return ok(data);
|
||||
} catch (ssErr: any) {
|
||||
// スクショ失敗してもプロパティ設定結果は返す
|
||||
const data = JSON.parse(result.content[0].text);
|
||||
data.screenshotError = ssErr.message || String(ssErr);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case "component_get_info": {
|
||||
try {
|
||||
const dump = await (Editor.Message.request as any)("scene", "query-component", args.componentUuid);
|
||||
return ok({ success: true, component: dump });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "component_get_available": {
|
||||
try {
|
||||
const classes = await (Editor.Message.request as any)("scene", "query-classes");
|
||||
return ok({ success: true, classes });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "component_auto_bind":
|
||||
return this.autoBind(args.uuid, compType, args.force ?? false, args.mode ?? "fuzzy");
|
||||
case "component_query_enum":
|
||||
return this.queryEnum(args.uuid, compType, args.property);
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async addComponent(uuid: string, componentType: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("addComponentToNode", [uuid, componentType]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async queryEnum(nodeUuid: string, componentType: string, property: string): Promise<ToolResult> {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
if (!nodeDump) return err("Node not found");
|
||||
|
||||
const comps = nodeDump.__comps__ || [];
|
||||
for (const comp of comps) {
|
||||
const compType = comp.type;
|
||||
if (!compType) continue;
|
||||
// Match by cc.XXX format
|
||||
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
|
||||
if (compType !== `cc.${normalizedType}` && compType !== componentType) continue;
|
||||
|
||||
const propDump = comp.value?.[property];
|
||||
if (!propDump) return err(`Property '${property}' not found on ${componentType}`);
|
||||
if (propDump.type !== "Enum") {
|
||||
return ok({ success: true, property, type: propDump.type, note: "Not an enum property", currentValue: propDump.value });
|
||||
}
|
||||
return ok({
|
||||
success: true,
|
||||
property,
|
||||
currentValue: propDump.value,
|
||||
enumList: propDump.enumList,
|
||||
});
|
||||
}
|
||||
return err(`Component ${componentType} not found on node`);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async removeComponent(uuid: string, componentType: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("removeComponentFromNode", [uuid, componentType]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getComponents(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!result.success) return ok(result);
|
||||
return ok({
|
||||
success: true,
|
||||
uuid,
|
||||
name: result.data?.name,
|
||||
components: result.data?.components || [],
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @property 名とノード名を自動マッチングしてバインドする。
|
||||
*
|
||||
* - 検索スコープ: 対象ノードの子孫のみ
|
||||
* - 複数ヒット時: 階層の浅いノード(直接の子)を優先
|
||||
* - 型検証: Component 参照型の場合、該当コンポーネントの存在を確認
|
||||
* - 配列対応: @property([Node]) → 連番ノード名 (Slots_0, Slots_1...)
|
||||
* - mode:
|
||||
* - "fuzzy" (default): 完全一致 → case-insensitive → not_found+候補
|
||||
* - "strict": 完全一致のみ → not_found+候補
|
||||
*/
|
||||
private async autoBind(nodeUuid: string, componentType: string, force: boolean, mode: string): Promise<ToolResult> {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
if (!nodeDump) return err("Node not found");
|
||||
|
||||
const comps = nodeDump.__comps__ || [];
|
||||
const compName = componentType.replace("cc.", "");
|
||||
const compIndex = comps.findIndex((c: any) => {
|
||||
const t = c.type || "";
|
||||
return t === compName || t === `cc.${compName}`;
|
||||
});
|
||||
if (compIndex < 0) return err(`Component ${componentType} not found on node`);
|
||||
|
||||
// 子孫ノード一覧を一括取得(検索効率化)
|
||||
const allDescendants = await this.sceneScript("getAllDescendants", [nodeUuid]);
|
||||
const descendantList: Array<{uuid: string, name: string, depth: number}> =
|
||||
allDescendants?.success ? allDescendants.data : [];
|
||||
|
||||
const compDump = comps[compIndex];
|
||||
const properties = compDump.value || {};
|
||||
const skipKeys = new Set(["uuid", "name", "enabled", "node", "__scriptAsset", "__prefab", "_name", "_objFlags", "_enabled"]);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const [propName, propDumpRaw] of Object.entries(properties)) {
|
||||
if (skipKeys.has(propName) || propName.startsWith("_")) continue;
|
||||
|
||||
const propDump = propDumpRaw as any;
|
||||
const propType = propDump.type as string;
|
||||
if (!propType) continue;
|
||||
|
||||
const extendsArr = (propDump.extends || []) as string[];
|
||||
|
||||
// 配列型の判定
|
||||
const isArray = propType === "Array" || Array.isArray(propDump.value);
|
||||
if (isArray) {
|
||||
const arrayResult = await this.autoBindArray(nodeUuid, compIndex, propName, propDump, descendantList, mode);
|
||||
results.push(arrayResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isNodeRef = propType === "cc.Node";
|
||||
const isComponentRef = extendsArr.includes("cc.Component");
|
||||
if (!isNodeRef && !isComponentRef) continue;
|
||||
|
||||
// 既にバインド済みならスキップ
|
||||
const currentValue = propDump.value;
|
||||
if (!force && currentValue?.uuid) {
|
||||
results.push({ property: propName, status: "already_bound" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 名前マッチ: 完全一致 → fuzzy時は case-insensitive
|
||||
const matchResult = this.findMatchingNode(propName, descendantList, mode);
|
||||
|
||||
if (matchResult && isComponentRef) {
|
||||
// 型検証: コンポーネントが存在するか
|
||||
const hasComp = await this.nodeHasComponent(matchResult.uuid, propType);
|
||||
if (!hasComp) {
|
||||
results.push({ property: propName, type: propType, status: "type_mismatch",
|
||||
nodeName: matchResult.name, message: `Node "${matchResult.name}" has no ${propType} component` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchResult) {
|
||||
// 候補サジェスト
|
||||
const suggestions = this.getSuggestions(propName, descendantList);
|
||||
results.push({ property: propName, type: propType, status: "not_found", suggestions });
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = `__comps__.${compIndex}.${propName}`;
|
||||
const dump = await this.buildDumpWithTypeInfo(nodeUuid, path, matchResult.uuid);
|
||||
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, path, dump]);
|
||||
const status = matchResult.exact ? "bound" : "fuzzy_bound";
|
||||
results.push({ property: propName, status, nodeName: matchResult.name, success: setResult?.success !== false });
|
||||
}
|
||||
|
||||
const boundCount = results.filter(r => r.status === "bound" || r.status === "fuzzy_bound").length;
|
||||
const fuzzyCount = results.filter(r => r.status === "fuzzy_bound").length;
|
||||
const notFoundCount = results.filter(r => r.status === "not_found").length;
|
||||
return ok({ success: true, boundCount, fuzzyCount, notFoundCount, results });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 子孫リストからプロパティ名にマッチするノードを検索。
|
||||
* 完全一致を優先、fuzzy モードでは case-insensitive もフォールバック。
|
||||
* 複数ヒット時は階層の浅い(depth が小さい)ものを優先。
|
||||
*/
|
||||
private findMatchingNode(
|
||||
propName: string, descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
|
||||
): { uuid: string; name: string; exact: boolean } | null {
|
||||
const candidates = this.propertyNameToNodeNames(propName);
|
||||
|
||||
// 1. 完全一致
|
||||
for (const candidate of candidates) {
|
||||
const matches = descendants
|
||||
.filter(d => d.name === candidate)
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
if (matches.length > 0) {
|
||||
return { uuid: matches[0].uuid, name: matches[0].name, exact: true };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. fuzzy: case-insensitive
|
||||
if (mode === "fuzzy") {
|
||||
const lowerCandidates = candidates.map(c => c.toLowerCase());
|
||||
const matches = descendants
|
||||
.filter(d => lowerCandidates.includes(d.name.toLowerCase()))
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
if (matches.length > 0) {
|
||||
return { uuid: matches[0].uuid, name: matches[0].name, exact: false };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* not_found 時に似た名前のノードをサジェストする。
|
||||
*/
|
||||
private getSuggestions(propName: string, descendants: Array<{uuid: string, name: string, depth: number}>): string[] {
|
||||
const lower = propName.toLowerCase();
|
||||
return descendants
|
||||
.filter(d => d.name.toLowerCase().includes(lower) || lower.includes(d.name.toLowerCase()))
|
||||
.map(d => d.name)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* ノードに指定型のコンポーネントが存在するか確認。
|
||||
*/
|
||||
private async nodeHasComponent(nodeUuid: string, propType: string): Promise<boolean> {
|
||||
const typeName = propType.replace("cc.", "");
|
||||
const info = await this.sceneScript("getNodeInfo", [nodeUuid]);
|
||||
if (!info?.success || !info?.data?.components) return false;
|
||||
return info.data.components.some((c: any) => c.type === typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配列 @property の自動バインド。
|
||||
* プロパティ名 "slots" → "Slots_0", "Slots_1", ... の連番ノードを検索。
|
||||
*/
|
||||
private async autoBindArray(
|
||||
nodeUuid: string, compIndex: number, propName: string, propDump: any,
|
||||
descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
|
||||
): Promise<any> {
|
||||
const elementType = propDump.value?.[0]?.type as string | undefined;
|
||||
if (!elementType) {
|
||||
return { property: propName, status: "skip", reason: "empty array or unknown element type" };
|
||||
}
|
||||
|
||||
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
|
||||
const foundElements: any[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (true) {
|
||||
const candidateName = `${pascal}_${index}`;
|
||||
// 完全一致 or case-insensitive
|
||||
let match = descendants.find(d => d.name === candidateName);
|
||||
if (!match && mode === "fuzzy") {
|
||||
const lower = candidateName.toLowerCase();
|
||||
match = descendants.find(d => d.name.toLowerCase() === lower);
|
||||
}
|
||||
if (!match) break;
|
||||
|
||||
const elementPath = `__comps__.${compIndex}.${propName}.${index}`;
|
||||
const dump = await this.buildDumpWithTypeInfo(nodeUuid, elementPath, match.uuid);
|
||||
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, elementPath, dump]);
|
||||
const exact = match.name === candidateName;
|
||||
foundElements.push({ index, nodeName: match.name, exact, success: setResult?.success !== false });
|
||||
index++;
|
||||
}
|
||||
|
||||
if (foundElements.length === 0) {
|
||||
return { property: propName, status: "not_found", type: "Array", candidates: [`${pascal}_0`, `${pascal}_1`, "..."] };
|
||||
}
|
||||
const hasFuzzy = foundElements.some(e => !e.exact);
|
||||
return { property: propName, status: hasFuzzy ? "fuzzy_bound" : "bound", type: "Array", count: foundElements.length, elements: foundElements };
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase プロパティ名からノード名の候補を生成。
|
||||
* closeButton → ["CloseButton", "closeButton"]
|
||||
*/
|
||||
private propertyNameToNodeNames(propName: string): string[] {
|
||||
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
|
||||
const names = [pascal];
|
||||
if (pascal !== propName) names.push(propName);
|
||||
return names;
|
||||
}
|
||||
|
||||
private async setProperty(uuid: string, componentType: string, property: string, value: any): Promise<ToolResult> {
|
||||
try {
|
||||
// コンポーネントのインデックスを取得
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
|
||||
return err(`Node ${uuid} not found or has no components`);
|
||||
}
|
||||
const compName = componentType.replace("cc.", "");
|
||||
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
|
||||
if (compIndex < 0) {
|
||||
return err(`Component ${componentType} not found on node ${uuid}`);
|
||||
}
|
||||
|
||||
// scene:set-property でプロパティ変更(Prefab保存時にも反映される)
|
||||
// パス形式: __comps__.{index}.{property}
|
||||
const path = `__comps__.${compIndex}.${property}`;
|
||||
|
||||
// プロパティの型情報をquery-nodeから取得して、適切なdump形式を構築
|
||||
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
|
||||
|
||||
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
|
||||
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
|
||||
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
|
||||
if (componentType === "cc.Widget" && property.startsWith("isAlign")) {
|
||||
await this.recalcWidgetAlignFlags(uuid, compIndex);
|
||||
}
|
||||
|
||||
return ok({ success: true, path, dump, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async setProperties(uuid: string, componentType: string, properties: Array<{property: string, value: any}>): Promise<ToolResult> {
|
||||
try {
|
||||
if (!componentType) return err("componentType is required");
|
||||
if (!properties.length) return err("properties array is empty");
|
||||
|
||||
// コンポーネントのインデックスを取得(1回だけ)
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
|
||||
return err(`Node ${uuid} not found or has no components`);
|
||||
}
|
||||
const compName = componentType.replace("cc.", "");
|
||||
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
|
||||
if (compIndex < 0) {
|
||||
return err(`Component ${componentType} not found on node ${uuid}`);
|
||||
}
|
||||
|
||||
const results: any[] = [];
|
||||
for (const { property, value } of properties) {
|
||||
const path = `__comps__.${compIndex}.${property}`;
|
||||
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
|
||||
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property, success: result?.success !== false, path });
|
||||
}
|
||||
|
||||
const allOk = results.every(r => r.success);
|
||||
|
||||
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
|
||||
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
|
||||
if (componentType === "cc.Widget" && properties.some(p => p.property.startsWith("isAlign"))) {
|
||||
await this.recalcWidgetAlignFlags(uuid, compIndex);
|
||||
}
|
||||
|
||||
return ok({ success: allOk, results });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* cc.Widget の isAlign* プロパティ現在値から _alignFlags ビットマスクを再計算して設定する。
|
||||
*
|
||||
* CocosCreator Editor は isAlign* を setPropertyViaEditor で変更しても
|
||||
* _alignFlags を自動更新しないバグがある。このヘルパーで明示的に同期する。
|
||||
*
|
||||
* _alignFlags ビット定義:
|
||||
* isAlignLeft=1, isAlignRight=2, isAlignTop=4, isAlignBottom=8,
|
||||
* isAlignHorizontalCenter=16, isAlignVerticalCenter=32
|
||||
*/
|
||||
private async recalcWidgetAlignFlags(uuid: string, wIdx: number): Promise<void> {
|
||||
const ALIGN_BITS: Record<string, number> = {
|
||||
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
|
||||
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
|
||||
};
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
|
||||
if (!nodeDump) return;
|
||||
const wCompDump = nodeDump.__comps__?.[wIdx];
|
||||
if (!wCompDump) return;
|
||||
let alignFlags = 0;
|
||||
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
|
||||
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
|
||||
}
|
||||
const path = `__comps__.${wIdx}._alignFlags`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: alignFlags, type: "Number" }]);
|
||||
} catch (_e) {
|
||||
// _alignFlags 再計算の失敗は致命的でないため無視
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* プロパティの型情報をEditor APIから取得し、適切なdump形式を構築する。
|
||||
*
|
||||
* UUID文字列が渡された場合、プロパティの型に応じて:
|
||||
* - Node/Component参照型 → {type: propType, value: {uuid: nodeUuid}}
|
||||
* - Asset参照型(cc.Prefab等) → {type: propType, value: {uuid: assetUuid}}
|
||||
* - String型 → {value, type: "String"}
|
||||
*/
|
||||
private async buildDumpWithTypeInfo(nodeUuid: string, path: string, value: any): Promise<any> {
|
||||
// プリミティブ型はそのまま
|
||||
if (typeof value === "number") return { value, type: "Number" };
|
||||
if (typeof value === "boolean") return { value, type: "Boolean" };
|
||||
|
||||
// オブジェクト形式 {uuid: "xxx", type: "cc.Node"} はそのまま
|
||||
// type 指定なしの {uuid: "xxx"} はプロパティの実際の型を解決するため文字列扱いに変換する
|
||||
if (value !== null && typeof value === "object" && typeof value.uuid === "string") {
|
||||
if (typeof value.type === "string") {
|
||||
return { type: value.type, value: { uuid: value.uuid } };
|
||||
}
|
||||
// type 未指定: 文字列として処理してプロパティ型から解決
|
||||
value = value.uuid;
|
||||
}
|
||||
|
||||
// @path: プレフィックスの場合: パスからノードUUIDを解決
|
||||
if (typeof value === "string" && value.startsWith("@path:")) {
|
||||
const nodePath = value.slice(6);
|
||||
const result = await this.sceneScript("findNodeByPath", [nodePath]);
|
||||
if (result?.success && result.data?.uuid) {
|
||||
value = result.data.uuid;
|
||||
} else {
|
||||
throw new Error(`Node not found at path: ${nodePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 文字列の場合: プロパティの型情報を取得して判定
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
if (nodeDump) {
|
||||
const propDump = this.resolveDumpPath(nodeDump, path);
|
||||
if (propDump?.type) {
|
||||
const propType = propDump.type as string;
|
||||
const extendsArr = (propDump.extends || []) as string[];
|
||||
const isComponentRef = extendsArr.includes("cc.Component");
|
||||
const isNodeRef = propType === "cc.Node";
|
||||
const isAssetRef = extendsArr.includes("cc.Asset");
|
||||
|
||||
if (isComponentRef) {
|
||||
// コンポーネント参照: ノードUUIDからコンポーネントUUIDを解決
|
||||
const compUuid = await this.resolveComponentUuid(value, propType);
|
||||
return { type: propType, value: { uuid: compUuid || value } };
|
||||
}
|
||||
if (isNodeRef) {
|
||||
return { type: propType, value: { uuid: value } };
|
||||
}
|
||||
if (isAssetRef) {
|
||||
return { type: propType, value: { uuid: value } };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// query-node失敗時はフォールバック
|
||||
}
|
||||
return { value, type: "String" };
|
||||
}
|
||||
|
||||
// その他のオブジェクト(contentSize, color等の構造体)
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
const wrapped: any = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
wrapped[k] = { value: v };
|
||||
}
|
||||
return { value: wrapped };
|
||||
}
|
||||
|
||||
return { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* query-nodeのdumpからドットパスでプロパティを解決する。
|
||||
* 例: "__comps__.2.scrollView" → nodeDump.__comps__[2].value.scrollView
|
||||
*/
|
||||
private resolveDumpPath(nodeDump: any, path: string): any {
|
||||
const parts = path.split(".");
|
||||
let current = nodeDump;
|
||||
for (const part of parts) {
|
||||
if (!current) return null;
|
||||
if (part === "__comps__") {
|
||||
current = current.__comps__;
|
||||
} else if (/^\d+$/.test(part)) {
|
||||
current = current[parseInt(part)]?.value;
|
||||
} else {
|
||||
current = current?.[part];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* ノードUUIDからコンポーネントUUIDを解決する。
|
||||
* propType(例: "cc.ScrollView", "MissionListPanel")に一致するコンポーネントを探す。
|
||||
*/
|
||||
private async resolveComponentUuid(nodeUuid: string, propType: string): Promise<string | null> {
|
||||
try {
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
|
||||
if (!nodeInfo?.success || !nodeInfo?.data?.components) return null;
|
||||
const typeName = propType.replace("cc.", "");
|
||||
const comp = nodeInfo.data.components.find((c: any) => c.type === typeName);
|
||||
return comp?.uuid || null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { getGameLogs, clearGameLogs, queueGameCommand, getCommandResult } from "../mcp-server";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import { ensureSceneSafeToSwitch } from "./scene-tools";
|
||||
import { processImage, takeEditorScreenshot } from "../screenshot";
|
||||
|
||||
export class DebugTools implements ToolCategory {
|
||||
readonly categoryName = "debug";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "debug_get_editor_info",
|
||||
description: "Get Cocos Creator editor information (version, platform, language).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_list_messages",
|
||||
description: "List available Editor messages for a given extension or built-in module.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
target: { type: "string", description: "Message target (e.g. 'scene', 'asset-db', 'extension')" },
|
||||
},
|
||||
required: ["target"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_execute_script",
|
||||
description: "Execute a custom scene script method. The method must be registered in scene.ts.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
method: { type: "string", description: "Method name from scene.ts" },
|
||||
args: { type: "array", description: "Arguments to pass", items: {} },
|
||||
},
|
||||
required: ["method"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_get_console_logs",
|
||||
description: "Get recent console log entries. Automatically captures scene process logs (console.log/warn/error in scene scripts). Game preview logs can also be captured by sending POST requests to /log endpoint — see README for setup.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "number", description: "Max number of entries (default 50)" },
|
||||
level: { type: "string", description: "Filter by level: 'log', 'warn', or 'error'" },
|
||||
source: { type: "string", description: "Filter by source: 'scene' or 'game'. Returns both if omitted." },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_clear_console",
|
||||
description: "Clear the editor console.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_list_extensions",
|
||||
description: "List all installed extensions.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_get_project_logs",
|
||||
description: "Read recent project log entries from the log file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
lines: { type: "number", description: "Number of lines to read (default 100)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_search_project_logs",
|
||||
description: "Search for a pattern in project logs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Search pattern (regex supported)" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_get_log_file_info",
|
||||
description: "Get information about the project log file (size, path, last modified).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
// ── 以下、既存MCP未対応のEditor API ──
|
||||
{
|
||||
name: "debug_query_devices",
|
||||
description: "List connected devices (for native debugging).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_open_url",
|
||||
description: "Open a URL in the system browser from the editor.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { url: { type: "string", description: "URL to open" } },
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_validate_scene",
|
||||
description: "Validate the current scene for common issues.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_game_command",
|
||||
description: "Send a command to the running game preview. Requires GameDebugClient in the game. Commands: 'screenshot' (capture game canvas), 'state' (dump GameDb), 'navigate' (go to a page), 'click' (click a node by name), 'inspect' (get runtime node info: UITransform sizes, Widget, Layout, position). Returns the result from the game.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", description: "Command type: 'screenshot', 'state', 'navigate', 'click', 'inspect'" },
|
||||
args: { type: "object", description: "Command arguments (e.g. {page: 'HomePageView'} for navigate, {name: 'ButtonName'} for click)" },
|
||||
timeout: { type: "number", description: "Max wait time in ms (default 5000)" },
|
||||
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960, 0 = no resize)" },
|
||||
imageFormat: { type: "string", description: "Screenshot output format: 'webp' (default, Q=85) or 'png' (lossless)" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_screenshot",
|
||||
description: "Take a screenshot of the editor window and save to a file. Returns the file path of the saved PNG.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
savePath: { type: "string", description: "File path to save the PNG (default: temp/screenshots/screenshot_<timestamp>.png)" },
|
||||
maxWidth: { type: "number", description: "Max width in pixels for resize (default: 960, 0 = no resize). Aspect ratio is preserved." },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_preview",
|
||||
description: "Start or stop the game preview. Uses Preview in Editor (auto-opens MainScene if needed). Falls back to browser preview if editor preview fails.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", description: "'start' (default) or 'stop'" },
|
||||
waitForReady: { type: "boolean", description: "If true, wait until GameDebugClient connects after start (default: false)" },
|
||||
waitTimeout: { type: "number", description: "Max wait time in ms for waitForReady (default: 15000)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_clear_code_cache",
|
||||
description: "Clear the code cache (equivalent to Developer > Cache > Clear code cache) and soft-reload the scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_reload_extension",
|
||||
description: "Reload the MCP extension itself. Use after npm run build to apply code changes without restarting CocosCreator. Response is sent before reload starts.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_get_extension_info",
|
||||
description: "Get detailed information about a specific extension.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Extension name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_record_start",
|
||||
description: "Start recording the game preview canvas to a video file. Uses MediaRecorder on the game side. Bitrate is auto-calculated from canvas resolution × fps × quality coefficient unless videoBitsPerSecond is set explicitly.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fps: { type: "number", description: "Frames per second (default: 30)" },
|
||||
quality: { type: "string", description: "'low'/'medium'/'high'/'ultra' (default: medium). Coefficients: 0.15/0.25/0.40/0.60" },
|
||||
coefficient: { type: "number", description: "Custom bitrate coefficient (width × height × fps × coefficient). Overrides quality." },
|
||||
videoBitsPerSecond: { type: "number", description: "Explicit bitrate in bps. Overrides quality-based calculation." },
|
||||
format: { type: "string", description: "'mp4' (default) or 'webm'. mp4 falls back to webm if not supported." },
|
||||
savePath: { type: "string", description: "Save directory (project-relative or absolute). Default: temp/recordings" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_record_stop",
|
||||
description: "Stop recording started by debug_record_start. Returns the saved WebM file path and size. Video is saved to project's temp/recordings/rec_<datetime>.webm.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeout: { type: "number", description: "Max wait time in ms for file upload (default: 30000)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_batch_screenshot",
|
||||
description: "Navigate to multiple pages and take a screenshot of each. Requires game preview running with GameDebugClient. Returns an array of screenshot file paths.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pages: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of page names to screenshot (e.g. ['HomePageView', 'ShopPageView'])",
|
||||
},
|
||||
delay: { type: "number", description: "Delay in ms between navigate and screenshot (default: 1000)" },
|
||||
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960)" },
|
||||
},
|
||||
required: ["pages"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_wait_compile",
|
||||
description: "Wait for TypeScript compilation to complete. Monitors the packer-driver debug log for 'Target(editor) ends' message. Use after modifying .ts files to ensure changes are compiled before operating on Prefabs. With clean=true, deletes compiled output first to force a fresh recompile (slower but guaranteed).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeout: { type: "number", description: "Max wait time in ms (default: 15000)" },
|
||||
clean: { type: "boolean", description: "If true, delete compiled output first to force fresh recompile (default: false)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "debug_get_editor_info":
|
||||
return this.getEditorInfo();
|
||||
case "debug_list_messages":
|
||||
return this.listMessages(args.target);
|
||||
case "debug_execute_script":
|
||||
return this.executeScript(args.method, args.args || []);
|
||||
case "debug_get_console_logs":
|
||||
return this.getConsoleLogs(args.count || 50, args.level, args.source);
|
||||
case "debug_clear_console":
|
||||
Editor.Message.send("console", "clear");
|
||||
// Clear scene process log buffer
|
||||
await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method: "clearConsoleLogs",
|
||||
args: [],
|
||||
}).catch(() => {});
|
||||
// Clear game preview log buffer
|
||||
clearGameLogs();
|
||||
return ok({ success: true });
|
||||
case "debug_list_extensions":
|
||||
return this.listExtensions();
|
||||
case "debug_get_project_logs":
|
||||
return this.getProjectLogs(args.lines || 100);
|
||||
case "debug_search_project_logs":
|
||||
return this.searchProjectLogs(args.pattern);
|
||||
case "debug_get_log_file_info":
|
||||
return this.getLogFileInfo();
|
||||
case "debug_query_devices": {
|
||||
const devices = await (Editor.Message.request as any)("device", "query").catch(() => []);
|
||||
return ok({ success: true, devices });
|
||||
}
|
||||
case "debug_open_url":
|
||||
await (Editor.Message.request as any)("program", "open-url", args.url);
|
||||
return ok({ success: true, url: args.url });
|
||||
case "debug_game_command":
|
||||
return this.gameCommand(args.type || args.command, parseMaybeJson(args.args), args.timeout || 5000, args.maxWidth, args.imageFormat);
|
||||
case "debug_screenshot":
|
||||
return this.takeScreenshot(args.savePath, args.maxWidth);
|
||||
case "debug_preview":
|
||||
return this.handlePreview(args.action || "start", args.waitForReady, args.waitTimeout || 15000);
|
||||
case "debug_clear_code_cache":
|
||||
return this.clearCodeCache();
|
||||
case "debug_reload_extension":
|
||||
return this.reloadExtension();
|
||||
case "debug_validate_scene":
|
||||
return this.validateScene();
|
||||
case "debug_get_extension_info":
|
||||
return this.getExtensionInfo(args.name);
|
||||
case "debug_batch_screenshot":
|
||||
return this.batchScreenshot(args.pages, args.delay || 1000, args.maxWidth);
|
||||
case "debug_record_start":
|
||||
return this.gameCommand("record_start", { fps: args.fps, quality: args.quality, coefficient: args.coefficient, videoBitsPerSecond: args.videoBitsPerSecond, format: args.format, savePath: args.savePath }, 5000);
|
||||
case "debug_record_stop":
|
||||
return this.gameCommand("record_stop", undefined, args.timeout || 30000);
|
||||
case "debug_wait_compile":
|
||||
return this.waitCompile(args.timeout || 15000, args.clean ?? false);
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getEditorInfo(): Promise<ToolResult> {
|
||||
return ok({
|
||||
success: true,
|
||||
version: Editor.App.version,
|
||||
path: Editor.App.path,
|
||||
home: Editor.App.home,
|
||||
language: Editor.I18n?.getLanguage?.() || "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessages(target: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("extension", "query-info", target);
|
||||
return ok({ success: true, target, info });
|
||||
} catch (e: any) {
|
||||
const knownMessages: Record<string, string[]> = {
|
||||
"scene": [
|
||||
"query-node-tree", "create-node", "remove-node", "duplicate-node",
|
||||
"set-property", "create-prefab", "save-scene", "execute-scene-script",
|
||||
"query-is-dirty", "query-classes", "soft-reload", "snapshot",
|
||||
"change-gizmo-tool", "query-gizmo-tool-name", "focus-camera-on-nodes",
|
||||
],
|
||||
"asset-db": [
|
||||
"query-assets", "query-asset-info", "query-asset-meta",
|
||||
"refresh-asset", "save-asset", "create-asset", "delete-asset",
|
||||
"move-asset", "copy-asset", "open-asset", "reimport-asset",
|
||||
"query-path", "query-uuid", "query-url", "query-asset-depends",
|
||||
],
|
||||
};
|
||||
const messages = knownMessages[target];
|
||||
if (messages) {
|
||||
return ok({ success: true, target, messages, note: "Static list (query failed)" });
|
||||
}
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async executeScript(method: string, args: any[]): Promise<ToolResult> {
|
||||
const result = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method,
|
||||
args,
|
||||
});
|
||||
return ok(result);
|
||||
}
|
||||
|
||||
private async getConsoleLogs(count: number, level?: string, source?: string): Promise<ToolResult> {
|
||||
// 1. Try Editor's native console API first (may be supported in future CocosCreator versions)
|
||||
if (!source) {
|
||||
try {
|
||||
const logs = await (Editor.Message.request as any)("console", "query-last-logs", count);
|
||||
if (Array.isArray(logs) && logs.length > 0) {
|
||||
return ok({ success: true, logs, source: "editor-api", note: "Using native Editor console API" });
|
||||
}
|
||||
} catch { /* Not supported in this version — use fallback */ }
|
||||
}
|
||||
|
||||
// 2. Fallback: collect from scene process buffer + game preview buffer
|
||||
let sceneLogs: any[] = [];
|
||||
let gameLogs: any[] = [];
|
||||
|
||||
// 2a. Scene process logs (console wrapper in scene.ts)
|
||||
if (!source || source === "scene") {
|
||||
try {
|
||||
const result = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method: "getConsoleLogs",
|
||||
args: [count * 2, level], // request more, will trim after merge
|
||||
});
|
||||
if (result?.logs) {
|
||||
sceneLogs = result.logs.map((l: any) => ({ ...l, source: "scene" }));
|
||||
}
|
||||
} catch { /* scene not available */ }
|
||||
}
|
||||
|
||||
// 2b. Game preview logs (received via POST /log endpoint)
|
||||
if (!source || source === "game") {
|
||||
const gameResult = getGameLogs(count * 2, level);
|
||||
gameLogs = gameResult.logs.map((l: any) => ({ ...l, source: "game" }));
|
||||
}
|
||||
|
||||
// Merge and sort by timestamp, take last `count`
|
||||
const merged = [...sceneLogs, ...gameLogs]
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||
.slice(-count);
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
logs: merged,
|
||||
total: { scene: sceneLogs.length, game: (source === "scene" ? 0 : gameLogs.length) },
|
||||
});
|
||||
}
|
||||
|
||||
private async listExtensions(): Promise<ToolResult> {
|
||||
try {
|
||||
const list = await (Editor.Message.request as any)("extension", "query-all");
|
||||
return ok({ success: true, extensions: list });
|
||||
} catch {
|
||||
return ok({ success: true, extensions: [], note: "Extension query not supported" });
|
||||
}
|
||||
}
|
||||
|
||||
private async getExtensionInfo(name: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("extension", "query-info", name);
|
||||
return ok({ success: true, name, info });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getProjectLogs(lines: number): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
|
||||
if (!fs.existsSync(logPath)) return ok({ success: true, logs: [], note: "Log file not found" });
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
const allLines = content.split("\n");
|
||||
const recent = allLines.slice(-lines);
|
||||
return ok({ success: true, lines: recent.length, logs: recent });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async searchProjectLogs(pattern: string): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
|
||||
if (!fs.existsSync(logPath)) return ok({ success: true, matches: [] });
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
const regex = new RegExp(pattern, "gi");
|
||||
const matches = content.split("\n").filter((line: string) => regex.test(line));
|
||||
return ok({ success: true, pattern, count: matches.length, matches: matches.slice(0, 100) });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getLogFileInfo(): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
|
||||
if (!fs.existsSync(logPath)) return ok({ success: true, exists: false });
|
||||
const stat = fs.statSync(logPath);
|
||||
return ok({ success: true, exists: true, path: logPath, size: stat.size, modified: stat.mtime });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePreview(action: string, waitForReady?: boolean, waitTimeout?: number): Promise<ToolResult> {
|
||||
if (action === "stop") {
|
||||
return this.stopPreview();
|
||||
}
|
||||
const result = await this.startPreview();
|
||||
if (waitForReady) {
|
||||
const resultData = JSON.parse(result.content[0].text);
|
||||
if (resultData.success) {
|
||||
const ready = await this.waitForGameReady(waitTimeout || 15000);
|
||||
resultData.gameReady = ready;
|
||||
if (!ready) {
|
||||
resultData.note = (resultData.note || "") + " GameDebugClient did not connect within timeout.";
|
||||
}
|
||||
return ok(resultData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async waitForGameReady(timeout: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
// Check if game has sent any log or command result recently
|
||||
const gameResult = getGameLogs(1);
|
||||
if (gameResult.total > 0) return true;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async startPreview(): Promise<ToolResult> {
|
||||
try {
|
||||
await this.ensureMainSceneOpen();
|
||||
|
||||
// ツールバーのVueインスタンス経由でplay()を呼ぶ(UI状態も同期される)
|
||||
const played = await this.executeOnToolbar("start");
|
||||
if (played) {
|
||||
return ok({ success: true, action: "start", mode: "editor" });
|
||||
}
|
||||
|
||||
// フォールバック: 直接API
|
||||
const isPlaying = await (Editor.Message.request as any)("scene", "editor-preview-set-play", true);
|
||||
return ok({ success: true, isPlaying, action: "start", mode: "editor", note: "direct API (toolbar UI may not sync)" });
|
||||
} catch (e: any) {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
await electron.shell.openExternal("http://127.0.0.1:7456");
|
||||
return ok({ success: true, action: "start", mode: "browser" });
|
||||
} catch (e2: any) {
|
||||
return err(e2.message || String(e2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async stopPreview(): Promise<ToolResult> {
|
||||
try {
|
||||
// ツールバー経由で停止(UI同期)
|
||||
const stopped = await this.executeOnToolbar("stop");
|
||||
if (!stopped) {
|
||||
// フォールバック: 直接API
|
||||
await (Editor.Message.request as any)("scene", "editor-preview-set-play", false);
|
||||
}
|
||||
// scene:preview-stop ブロードキャストでツールバーUI状態をリセット
|
||||
Editor.Message.broadcast("scene:preview-stop");
|
||||
// シーンビューに戻す
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await this.ensureMainSceneOpen();
|
||||
return ok({ success: true, action: "stop" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async executeOnToolbar(action: "start" | "stop"): Promise<boolean> {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const allContents = electron.webContents.getAllWebContents();
|
||||
for (const wc of allContents) {
|
||||
try {
|
||||
// play()をawaitしない — プレビュー完了を待つとタイムアウトするため
|
||||
if (action === "start") {
|
||||
const result = await wc.executeJavaScript(
|
||||
`(function() { if (window.xxx && window.xxx.play && !window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
|
||||
);
|
||||
if (result) return true;
|
||||
} else {
|
||||
const result = await wc.executeJavaScript(
|
||||
`(function() { if (window.xxx && window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
|
||||
);
|
||||
if (result) return true;
|
||||
}
|
||||
} catch { /* not the toolbar webContents */ }
|
||||
}
|
||||
} catch { /* electron API not available */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
private async ensureMainSceneOpen(): Promise<void> {
|
||||
const hierarchy = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method: "getSceneHierarchy",
|
||||
args: [false],
|
||||
}).catch(() => null);
|
||||
|
||||
if (!hierarchy?.sceneName || hierarchy.sceneName === "scene-2d") {
|
||||
// プロジェクト設定のStart Sceneを参照
|
||||
let sceneUuid: string | null = null;
|
||||
try {
|
||||
sceneUuid = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Start Sceneが未設定 or "current_scene" の場合、最初のシーンを使う
|
||||
if (!sceneUuid || sceneUuid === "current_scene") {
|
||||
const scenes = await Editor.Message.request("asset-db", "query-assets", {
|
||||
ccType: "cc.SceneAsset",
|
||||
pattern: "db://assets/**/*",
|
||||
});
|
||||
if (Array.isArray(scenes) && scenes.length > 0) {
|
||||
sceneUuid = scenes[0].uuid;
|
||||
}
|
||||
}
|
||||
|
||||
if (sceneUuid) {
|
||||
// debug_preview 内部の自動遷移は preview を優先して force=true
|
||||
// (dialog 出るより preview 開始を優先する運用)
|
||||
await ensureSceneSafeToSwitch(true);
|
||||
await (Editor.Message.request as any)("scene", "open-scene", sceneUuid);
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async clearCodeCache(): Promise<ToolResult> {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const menu = electron.Menu.getApplicationMenu();
|
||||
if (!menu) return err("Application menu not found");
|
||||
|
||||
const findMenuItem = (items: any[], path: string[]): any => {
|
||||
for (const item of items) {
|
||||
if (item.label === path[0]) {
|
||||
if (path.length === 1) return item;
|
||||
if (item.submenu?.items) return findMenuItem(item.submenu.items, path.slice(1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cacheItem = findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]);
|
||||
if (!cacheItem) return err("Menu item 'Developer > Cache > Clear code cache' not found");
|
||||
|
||||
cacheItem.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
return ok({ success: true, note: "Code cache cleared via menu" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async gameCommand(type: string, args: any, timeout: number, maxWidth?: number, imageFormat?: string): Promise<ToolResult> {
|
||||
const cmdId = queueGameCommand(type, args);
|
||||
|
||||
// Poll for result
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const result = getCommandResult();
|
||||
if (result && result.id === cmdId) {
|
||||
// If screenshot, save to file and return path
|
||||
if (type === "screenshot" && result.success && result.data?.dataUrl) {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const dir = path.join(Editor.Project.tmpDir, "screenshots");
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const base64 = result.data.dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
const pngBuffer = Buffer.from(base64, "base64");
|
||||
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
|
||||
const electron = require("electron");
|
||||
const origImage = electron.nativeImage.createFromBuffer(pngBuffer);
|
||||
const originalSize = origImage.getSize();
|
||||
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth, imageFormat);
|
||||
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
|
||||
const filePath = path.join(dir, `game_${timestamp}.${ext}`);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return ok({
|
||||
success: true, path: filePath, size: buffer.length, format,
|
||||
originalSize: `${originalSize.width}x${originalSize.height}`,
|
||||
savedSize: `${width}x${height}`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return ok({ success: true, note: "Screenshot captured but file save failed", error: e.message });
|
||||
}
|
||||
}
|
||||
return ok(result);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
return err(`Game did not respond within ${timeout}ms. Is GameDebugClient running in the preview?`);
|
||||
}
|
||||
|
||||
private async takeScreenshot(savePath?: string, maxWidth?: number): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await takeEditorScreenshot(savePath, maxWidth);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadExtension(): Promise<ToolResult> {
|
||||
// Schedule reload after response is sent
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (Editor.Message.request as any)("extension", "reload", "cocos-creator-mcp");
|
||||
} catch (e: any) {
|
||||
console.error("[MCP] Extension reload failed:", e.message);
|
||||
}
|
||||
}, 500);
|
||||
return ok({ success: true, note: "Extension reload scheduled. MCP server will restart in ~1s. NOTE: Adding new tool definitions or modifying scene.ts requires a full CocosCreator restart (reload is not sufficient)." });
|
||||
}
|
||||
|
||||
private async batchScreenshot(pages: string[], delay: number, maxWidth?: number): Promise<ToolResult> {
|
||||
const results: any[] = [];
|
||||
const timeout = 10000;
|
||||
|
||||
for (const page of pages) {
|
||||
// Navigate
|
||||
const navResult = await this.gameCommand("navigate", { page }, timeout, maxWidth);
|
||||
const navData = JSON.parse(navResult.content[0].text);
|
||||
if (!navData.success) {
|
||||
results.push({ page, success: false, error: "navigate failed" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait for page to render
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
// Screenshot
|
||||
const ssResult = await this.gameCommand("screenshot", {}, timeout, maxWidth);
|
||||
const ssData = JSON.parse(ssResult.content[0].text);
|
||||
results.push({
|
||||
page,
|
||||
success: ssData.success || false,
|
||||
path: ssData.path,
|
||||
error: ssData.success ? undefined : (ssData.error || ssData.message),
|
||||
});
|
||||
}
|
||||
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
return ok({
|
||||
success: true,
|
||||
total: pages.length,
|
||||
succeeded,
|
||||
failed: pages.length - succeeded,
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
private async validateScene(): Promise<ToolResult> {
|
||||
try {
|
||||
const tree = await Editor.Message.request("scene", "query-node-tree");
|
||||
const issues: string[] = [];
|
||||
const checkNodes = (nodes: any[]) => {
|
||||
if (!nodes) return;
|
||||
for (const node of nodes) {
|
||||
if (!node.name) issues.push(`Node ${node.uuid} has no name`);
|
||||
if (node.children) checkNodes(node.children);
|
||||
}
|
||||
};
|
||||
if (Array.isArray(tree)) checkNodes(tree);
|
||||
return ok({ success: true, issueCount: issues.length, issues });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeScript コンパイル完了を待つ。
|
||||
* packer-driver の debug.log に "Target(editor) ends" が現れるのを監視する。
|
||||
* 既にコンパイル済み(直近数秒以内に完了ログあり)なら即座に返す。
|
||||
*/
|
||||
private async waitCompile(timeout: number, clean: boolean): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "logs", "debug.log");
|
||||
const chunksDir = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "targets", "editor", "chunks");
|
||||
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return err(`Compile log not found: ${logPath}`);
|
||||
}
|
||||
|
||||
const MARKER = "Target(editor) ends";
|
||||
|
||||
// clean モード: コードキャッシュクリア + soft-reload で再コンパイルを強制
|
||||
if (clean) {
|
||||
// Developer > Cache > Clear code cache をクリック
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const menu = electron.Menu.getApplicationMenu();
|
||||
const findMenuItem = (items: any[], labels: string[]): any => {
|
||||
for (const item of items) {
|
||||
if (item.label === labels[0]) {
|
||||
if (labels.length === 1) return item;
|
||||
if (item.submenu?.items) return findMenuItem(item.submenu.items, labels.slice(1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const cacheItem = menu ? findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]) : null;
|
||||
if (cacheItem) cacheItem.click();
|
||||
} catch (_e) { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
// soft-reload でシーンを再読み込み → コンパイルトリガー
|
||||
await (Editor.Message.request as any)("scene", "soft-reload").catch(() => {});
|
||||
}
|
||||
|
||||
// refresh-asset でファイル変更を CC に通知してコンパイルをトリガー
|
||||
await (Editor.Message.request as any)("asset-db", "refresh-asset", "db://assets").catch(() => {});
|
||||
|
||||
const initialSize = fs.statSync(logPath).size;
|
||||
const startTime = Date.now();
|
||||
const POLL_INTERVAL = 200;
|
||||
const DETECT_GRACE_MS = 2000; // CC がファイル変更を検知するまでの猶予
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
||||
|
||||
const currentSize = fs.statSync(logPath).size;
|
||||
|
||||
// ログが成長していない
|
||||
if (currentSize <= initialSize) {
|
||||
// clean モードでは必ずコンパイルが走るので猶予判定しない
|
||||
if (clean) continue;
|
||||
// 猶予期間内はまだ待つ (CC の検知が遅い可能性)
|
||||
if (Date.now() - startTime < DETECT_GRACE_MS) continue;
|
||||
// 猶予期間を過ぎてもログが成長しない → コンパイル不要
|
||||
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime, note: "No compilation triggered (no changes detected)" });
|
||||
}
|
||||
|
||||
// ログが成長した → 新しい部分にマーカーがあるか確認
|
||||
const fd = fs.openSync(logPath, "r");
|
||||
const newBytes = currentSize - initialSize;
|
||||
const buffer = Buffer.alloc(newBytes);
|
||||
fs.readSync(fd, buffer, 0, newBytes, initialSize);
|
||||
fs.closeSync(fd);
|
||||
const newContent = buffer.toString("utf8");
|
||||
|
||||
if (newContent.includes(MARKER)) {
|
||||
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime });
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ success: true, compiled: false, timeout: true, waitedMs: timeout });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import { resolveNodeUuid } from "../node-resolve";
|
||||
import { takeEditorScreenshot } from "../screenshot";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export class NodeTools implements ToolCategory {
|
||||
readonly categoryName = "node";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "node_create",
|
||||
description: "Create a new node in the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Node name" },
|
||||
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
|
||||
components: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Component class names to add (e.g. ['cc.Label', 'cc.Sprite'])",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_get_info",
|
||||
description: "Get detailed information about a node by UUID, including components.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_find_by_name",
|
||||
description: "Find all nodes matching a given name.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Node name to search" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_property",
|
||||
description: "Set a property on a node (name, active, position, rotation, scale, etc.).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
property: { type: "string", description: "Property name (e.g. 'name', 'active', 'position')" },
|
||||
value: { description: "Value to set. For position/rotation/scale use {x,y,z}." },
|
||||
},
|
||||
required: ["uuid", "property", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_transform",
|
||||
description: "Set position, rotation, and/or scale of a node at once.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
position: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
|
||||
description: "Position {x,y,z}",
|
||||
},
|
||||
rotation: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
|
||||
description: "Euler rotation {x,y,z}",
|
||||
},
|
||||
scale: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
|
||||
description: "Scale {x,y,z}",
|
||||
},
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_delete",
|
||||
description: "Delete a node by UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_move",
|
||||
description: "Move a node to a new parent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
parentUuid: { type: "string", description: "New parent node UUID" },
|
||||
},
|
||||
required: ["uuid", "parentUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_duplicate",
|
||||
description: "Duplicate a node.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID to duplicate" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_get_all",
|
||||
description: "Get a flat list of all nodes in the current scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_active",
|
||||
description: "Set a node's active (visible) state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
active: { type: "boolean", description: "Whether the node is active" },
|
||||
},
|
||||
required: ["uuid", "active"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_layer",
|
||||
description: "Set a node's layer.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
layer: { type: "number", description: "Layer value" },
|
||||
},
|
||||
required: ["uuid", "layer"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_detect_type",
|
||||
description: "Detect node type (2D, 3D, or regular Node) based on its components.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_create_tree",
|
||||
description: "Create a full node tree from a JSON spec in one call. Much faster than creating nodes one by one. Spec format: { name, components?: ['cc.UITransform'], properties?: {'cc.UITransform.contentSize': {width:720,height:1280}}, widget?: {top:0, bottom:0, left:0, right:0}, active?: bool, position?: {x,y,z}, children?: [...] }",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
parent: { type: "string", description: "Parent node UUID" },
|
||||
spec: { description: "Node tree specification (JSON object with name, components, properties, children)" },
|
||||
},
|
||||
required: ["parent", "spec"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_layout",
|
||||
description: "Set UITransform (contentSize, anchorPoint) and Widget (margins) on a node in one call. Much faster than calling component_set_property multiple times for layout adjustments. Set screenshot=true to capture the editor after changes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
|
||||
contentSize: {
|
||||
type: "object",
|
||||
properties: { width: { type: "number" }, height: { type: "number" } },
|
||||
description: "UITransform contentSize {width, height}",
|
||||
},
|
||||
anchorPoint: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" } },
|
||||
description: "UITransform anchorPoint {x, y} (0-1)",
|
||||
},
|
||||
widget: {
|
||||
type: "object",
|
||||
properties: {
|
||||
top: { type: "number" }, bottom: { type: "number" },
|
||||
left: { type: "number" }, right: { type: "number" },
|
||||
horizontalCenter: { type: "number" }, verticalCenter: { type: "number" },
|
||||
isAlignTop: { type: "boolean" }, isAlignBottom: { type: "boolean" },
|
||||
isAlignLeft: { type: "boolean" }, isAlignRight: { type: "boolean" },
|
||||
isAlignHorizontalCenter: { type: "boolean" }, isAlignVerticalCenter: { type: "boolean" },
|
||||
},
|
||||
description: "Widget alignment margins. Setting a value (e.g. top:0) automatically enables the corresponding alignment (isAlignTop:true).",
|
||||
},
|
||||
color: {
|
||||
type: "object",
|
||||
properties: { r: { type: "number" }, g: { type: "number" }, b: { type: "number" }, a: { type: "number" } },
|
||||
description: "Node color {r,g,b,a} (0-255)",
|
||||
},
|
||||
opacity: { type: "number", description: "Node opacity (0-255)" },
|
||||
screenshot: { type: "boolean", description: "If true, capture editor screenshot after changes (default: false)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
const rejected = await this.rejectIfPreviewRunning(toolName);
|
||||
if (rejected) return rejected;
|
||||
|
||||
switch (toolName) {
|
||||
case "node_create":
|
||||
return this.createNode(args.name, args.parent, args.components);
|
||||
case "node_get_info":
|
||||
return this.getNodeInfo(args.uuid);
|
||||
case "node_find_by_name":
|
||||
return this.findByName(args.name);
|
||||
case "node_set_property":
|
||||
return this.setProperty(args.uuid, args.property, parseMaybeJson(args.value));
|
||||
case "node_set_transform":
|
||||
return this.setTransform(args.uuid, args.position, args.rotation, args.scale);
|
||||
case "node_delete":
|
||||
return this.deleteNode(args.uuid);
|
||||
case "node_move":
|
||||
return this.moveNode(args.uuid, args.parentUuid);
|
||||
case "node_duplicate":
|
||||
return this.duplicateNode(args.uuid);
|
||||
case "node_get_all":
|
||||
return this.getAllNodes();
|
||||
case "node_set_active":
|
||||
return this.setProperty(args.uuid, "active", args.active);
|
||||
case "node_set_layer":
|
||||
return this.setProperty(args.uuid, "layer", args.layer);
|
||||
case "node_create_tree":
|
||||
return this.createNodeTree(args.parent, parseMaybeJson(args.spec));
|
||||
case "node_set_layout":
|
||||
return this.setLayout(args);
|
||||
case "node_detect_type": {
|
||||
try {
|
||||
const info = await this.sceneScript("getNodeInfo", [args.uuid]);
|
||||
if (!info.success) return ok(info);
|
||||
const comps = info.data?.components || [];
|
||||
const compTypes = comps.map((c: any) => c.type);
|
||||
let nodeType = "Node";
|
||||
if (compTypes.includes("UITransform")) nodeType = "2D";
|
||||
else if (compTypes.includes("MeshRenderer") || compTypes.includes("Camera")) nodeType = "3D";
|
||||
return ok({ success: true, uuid: args.uuid, nodeType, components: compTypes });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Scene editing tools that must not run during preview */
|
||||
private static readonly SCENE_EDIT_TOOLS = new Set([
|
||||
"node_create", "node_delete", "node_move", "node_duplicate",
|
||||
"node_set_property", "node_set_transform", "node_set_active", "node_set_layer",
|
||||
"node_create_tree", "node_set_layout",
|
||||
]);
|
||||
|
||||
private async rejectIfPreviewRunning(toolName: string): Promise<ToolResult | null> {
|
||||
if (!NodeTools.SCENE_EDIT_TOOLS.has(toolName)) return null;
|
||||
try {
|
||||
const state = await Editor.Message.request("preview", "query-info");
|
||||
if (state && (state as any).running) {
|
||||
return err(`"${toolName}" はプレビュー中に実行できません。先にプレビューを停止してください。`);
|
||||
}
|
||||
} catch { /* query failed — allow execution */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createNode(name: string, parent?: string, components?: string[]): Promise<ToolResult> {
|
||||
try {
|
||||
// Use Editor API to create node
|
||||
const uuid = await Editor.Message.request("scene", "create-node", {
|
||||
parent: parent || undefined,
|
||||
name,
|
||||
assetUuid: undefined,
|
||||
});
|
||||
|
||||
// Wait until the node is queryable in the scene process
|
||||
await this.waitForNode(uuid);
|
||||
|
||||
// Add components if specified
|
||||
if (components && components.length > 0) {
|
||||
for (const comp of components) {
|
||||
await this.sceneScript("addComponentToNode", [uuid, comp]);
|
||||
// Wait until the component is reflected in query-node
|
||||
await this.waitForComponent(uuid, comp);
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ success: true, uuid, name });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a node becomes queryable in the scene process.
|
||||
* Editor.Message.request("scene", "create-node") returns before the node
|
||||
* is fully registered in the scene hierarchy, so subsequent scene script
|
||||
* calls (findNode) may fail without this wait.
|
||||
*/
|
||||
private async waitForNode(uuid: string, maxRetries = 10, intervalMs = 100): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const result = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (result?.success) return;
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
// Don't throw — let the caller proceed and get a more specific error if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a component added via addComponentToNode is reflected in query-node.
|
||||
* sceneScript returns before the Editor API (query-node) reflects the change,
|
||||
* so polling is needed to avoid race conditions in subsequent tool calls.
|
||||
*/
|
||||
private async waitForComponent(nodeUuid: string, componentType: string, maxRetries = 10, intervalMs = 100): Promise<void> {
|
||||
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
const comps: any[] = nodeDump?.__comps__ || [];
|
||||
const found = comps.some((c) =>
|
||||
c.type === componentType || c.type === `cc.${normalizedType}` || c.type === normalizedType
|
||||
);
|
||||
if (found) return;
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
// Don't throw — component may still work; let caller get a specific error if needed
|
||||
}
|
||||
|
||||
private async createNodeTree(parentUuid: string, spec: any): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("buildNodeTree", [parentUuid, spec]);
|
||||
if (!result?.success) return err(result?.error || "buildNodeTree failed");
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getNodeInfo(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async findByName(name: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("findNodesByName", [name]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async setProperty(uuid: string, property: string, value: any): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("setNodeProperty", [uuid, property, value]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async setTransform(uuid: string, position?: any, rotation?: any, scale?: any): Promise<ToolResult> {
|
||||
try {
|
||||
const results: any[] = [];
|
||||
if (position) {
|
||||
results.push(await this.sceneScript("setNodeProperty", [uuid, "position", position]));
|
||||
}
|
||||
if (rotation) {
|
||||
results.push(await this.sceneScript("setNodeProperty", [uuid, "rotation", rotation]));
|
||||
}
|
||||
if (scale) {
|
||||
results.push(await this.sceneScript("setNodeProperty", [uuid, "scale", scale]));
|
||||
}
|
||||
const anyFailed = results.find((r) => !r.success);
|
||||
if (anyFailed) return ok(anyFailed);
|
||||
return ok({ success: true, uuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteNode(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
await Editor.Message.request("scene", "remove-node", { uuid });
|
||||
return ok({ success: true, uuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async moveNode(uuid: string, parentUuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
await (Editor.Message.request as any)("scene", "set-property", {
|
||||
uuid,
|
||||
path: "parent",
|
||||
dump: { type: "cc.Node", value: { uuid: parentUuid } },
|
||||
});
|
||||
return ok({ success: true, uuid, parentUuid });
|
||||
} catch (e: any) {
|
||||
// Fallback: try scene script
|
||||
try {
|
||||
const result = await this.sceneScript("moveNode", [uuid, parentUuid]);
|
||||
return ok(result);
|
||||
} catch (e2: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateNode(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await Editor.Message.request("scene", "duplicate-node", uuid);
|
||||
// duplicate-node returns an array of UUIDs
|
||||
const newUuid = Array.isArray(result) ? result[0] : result;
|
||||
return ok({ success: true, sourceUuid: uuid, newUuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getAllNodes(): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("getAllNodes", []);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UITransform + Widget + color/opacity をまとめて設定する。
|
||||
* Widget の値を指定すると、対応する isAlign* フラグを自動で true にする。
|
||||
*/
|
||||
private async setLayout(args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
// nodeName → uuid 解決
|
||||
let uuid = args.uuid;
|
||||
if (!uuid && args.nodeName) {
|
||||
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
|
||||
uuid = resolved.uuid;
|
||||
}
|
||||
if (!uuid) return err("Either 'uuid' or 'nodeName' is required");
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
// UITransform の設定
|
||||
const contentSize = parseMaybeJson(args.contentSize);
|
||||
const anchorPoint = parseMaybeJson(args.anchorPoint);
|
||||
if (contentSize || anchorPoint) {
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
|
||||
const comps = nodeInfo.data?.components || [];
|
||||
const utIdx = comps.findIndex((c: any) => c.type === "UITransform");
|
||||
if (utIdx < 0) return err("Node has no UITransform component");
|
||||
|
||||
if (contentSize) {
|
||||
const path = `__comps__.${utIdx}.contentSize`;
|
||||
const dump = { value: { width: { value: contentSize.width }, height: { value: contentSize.height } } };
|
||||
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: "contentSize", success: r?.success !== false });
|
||||
}
|
||||
if (anchorPoint) {
|
||||
const path = `__comps__.${utIdx}.anchorPoint`;
|
||||
const dump = { value: { x: { value: anchorPoint.x }, y: { value: anchorPoint.y } } };
|
||||
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: "anchorPoint", success: r?.success !== false });
|
||||
}
|
||||
}
|
||||
|
||||
// Widget の設定
|
||||
const widget = parseMaybeJson(args.widget);
|
||||
if (widget) {
|
||||
// Widget コンポーネントを探す(なければ追加)
|
||||
let nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
|
||||
let comps = nodeInfo.data?.components || [];
|
||||
let wIdx = comps.findIndex((c: any) => c.type === "Widget");
|
||||
if (wIdx < 0) {
|
||||
await this.sceneScript("addComponentToNode", [uuid, "cc.Widget"]);
|
||||
// 再取得
|
||||
nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
comps = nodeInfo.data?.components || [];
|
||||
wIdx = comps.findIndex((c: any) => c.type === "Widget");
|
||||
if (wIdx < 0) return err("Failed to add Widget component");
|
||||
results.push({ property: "Widget", action: "added" });
|
||||
}
|
||||
|
||||
// isAlign* を自動設定(値があれば true にする)
|
||||
const alignMap: Record<string, string> = {
|
||||
top: "isAlignTop", bottom: "isAlignBottom",
|
||||
left: "isAlignLeft", right: "isAlignRight",
|
||||
horizontalCenter: "isAlignHorizontalCenter",
|
||||
verticalCenter: "isAlignVerticalCenter",
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(widget)) {
|
||||
// isAlign* を明示指定した場合はそのまま設定
|
||||
const path = `__comps__.${wIdx}.${key}`;
|
||||
if (typeof value === "boolean") {
|
||||
const dump = { value, type: "Boolean" };
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: `Widget.${key}`, success: true });
|
||||
} else if (typeof value === "number") {
|
||||
// まず対応する isAlign* を true にする
|
||||
const alignKey = alignMap[key];
|
||||
if (alignKey && widget[alignKey] === undefined) {
|
||||
const alignPath = `__comps__.${wIdx}.${alignKey}`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, alignPath, { value: true, type: "Boolean" }]);
|
||||
}
|
||||
const dump = { value, type: "Number" };
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: `Widget.${key}`, success: true });
|
||||
}
|
||||
}
|
||||
|
||||
// _alignFlags を isAlign* 現在値から再計算して設定
|
||||
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
|
||||
try {
|
||||
const ALIGN_BITS: Record<string, number> = {
|
||||
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
|
||||
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
|
||||
};
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
|
||||
if (nodeDump) {
|
||||
const wCompDump = nodeDump.__comps__?.[wIdx];
|
||||
if (wCompDump) {
|
||||
let alignFlags = 0;
|
||||
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
|
||||
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
|
||||
}
|
||||
const flagPath = `__comps__.${wIdx}._alignFlags`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, flagPath, { value: alignFlags, type: "Number" }]);
|
||||
results.push({ property: "Widget._alignFlags", value: alignFlags });
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// _alignFlags 再計算の失敗は致命的でないため無視
|
||||
}
|
||||
}
|
||||
|
||||
// color
|
||||
const color = parseMaybeJson(args.color);
|
||||
if (color) {
|
||||
const r = await this.sceneScript("setNodeProperty", [uuid, "color", color]);
|
||||
results.push({ property: "color", success: r?.success !== false });
|
||||
}
|
||||
|
||||
// opacity
|
||||
if (args.opacity !== undefined) {
|
||||
// cc.UIOpacity を使う(なければ color.a で設定)
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
const comps = nodeInfo?.data?.components || [];
|
||||
const opIdx = comps.findIndex((c: any) => c.type === "UIOpacity");
|
||||
if (opIdx >= 0) {
|
||||
const path = `__comps__.${opIdx}.opacity`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: args.opacity, type: "Number" }]);
|
||||
results.push({ property: "UIOpacity.opacity", success: true });
|
||||
} else {
|
||||
// UIOpacity がない場合は color.a を直接設定
|
||||
const currentColor = nodeInfo?.data?.color || { r: 255, g: 255, b: 255, a: 255 };
|
||||
currentColor.a = args.opacity;
|
||||
const r = await this.sceneScript("setNodeProperty", [uuid, "color", currentColor]);
|
||||
results.push({ property: "color.a", success: r?.success !== false });
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = results.every(r => r.success !== false);
|
||||
let response: any = { success: allOk, uuid, results };
|
||||
|
||||
// screenshot
|
||||
if (args.screenshot) {
|
||||
try {
|
||||
const ss = await takeEditorScreenshot();
|
||||
response.screenshot = { path: ss.path, size: ss.savedSize };
|
||||
} catch (ssErr: any) {
|
||||
response.screenshotError = ssErr.message || String(ssErr);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(response);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a scene script method */
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { ensureSceneSafeToSwitch, safeSaveScene } from "./scene-tools";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import type { ComponentTools } from "./component-tools";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
/** prefab_instantiate で配置したネスト Prefab 情報を記憶 */
|
||||
interface NestedPrefabEntry {
|
||||
nodeUuid: string;
|
||||
prefabAssetUuid: string;
|
||||
parentUuid: string;
|
||||
}
|
||||
|
||||
export class PrefabTools implements ToolCategory {
|
||||
readonly categoryName = "prefab";
|
||||
private _pendingNestedPrefabs: NestedPrefabEntry[] = [];
|
||||
/** prefab_open で開いた Prefab アセット UUID */
|
||||
private _currentPrefabUuid: string | null = null;
|
||||
private _componentTools: ComponentTools | null;
|
||||
|
||||
constructor(componentTools?: ComponentTools) {
|
||||
this._componentTools = componentTools ?? null;
|
||||
}
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "prefab_list",
|
||||
description: "List all prefab files in the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_create",
|
||||
description: "Create a prefab from an existing node in the scene. The node remains in the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID to create prefab from" },
|
||||
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
|
||||
},
|
||||
required: ["uuid", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_instantiate",
|
||||
description: "Instantiate a prefab into the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prefabUuid: { type: "string", description: "Prefab asset UUID" },
|
||||
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
|
||||
},
|
||||
required: ["prefabUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_get_info",
|
||||
description: "Get information about a prefab asset (name, path, UUID).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Prefab asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_update",
|
||||
description: "Update (re-save) a prefab from its instance node in the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID of the prefab instance in the scene" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_duplicate",
|
||||
description: "Duplicate a prefab asset to a new path.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Source prefab db:// path" },
|
||||
destination: { type: "string", description: "Destination db:// path" },
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_validate",
|
||||
description: "Validate a prefab for missing references or broken links.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Prefab asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_revert",
|
||||
description: "Revert a prefab instance node to its original prefab state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID of the prefab instance" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_create_and_replace",
|
||||
description: "Create a prefab from a node AND replace the original node with a prefab instance. This is the recommended way to extract a nested prefab — one command instead of create → delete → instantiate.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID to create prefab from" },
|
||||
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
|
||||
},
|
||||
required: ["uuid", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_open",
|
||||
description: "Open a prefab in editing mode. Equivalent to double-clicking the prefab in CocosCreator. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Prefab asset UUID" },
|
||||
path: { type: "string", description: "Prefab db:// path (alternative to uuid)" },
|
||||
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_close",
|
||||
description: "Save and close the current prefab editing mode, then return to the main scene. Returns an error if the current prefab is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
save: { type: "boolean", description: "Save prefab before closing (default true)" },
|
||||
sceneUuid: { type: "string", description: "Scene UUID to return to (default: project's start scene or first scene)" },
|
||||
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_create_from_spec",
|
||||
description: "Create a prefab from a JSON spec in one call. Combines node_create_tree + component_auto_bind + prefab_create into a single operation. Spec extends node_create_tree format with optional autoBind field. Example: { name: 'MyPopup', components: ['cc.UITransform', 'MyPopupView'], autoBind: 'MyPopupView', children: [{ name: 'CloseButton', components: ['cc.Button'] }] }",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
|
||||
spec: { description: "Node tree specification with optional autoBind field (string for component type to auto-bind)" },
|
||||
autoBindMode: { type: "string", enum: ["fuzzy", "strict"], description: "Auto-bind matching mode (default: fuzzy)" },
|
||||
},
|
||||
required: ["path", "spec"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
switch (toolName) {
|
||||
case "prefab_list":
|
||||
return this.listPrefabs();
|
||||
case "prefab_create":
|
||||
return this.createPrefab(args.uuid, args.path);
|
||||
case "prefab_instantiate":
|
||||
return this.instantiatePrefab(args.prefabUuid, args.parent);
|
||||
case "prefab_get_info":
|
||||
return this.getPrefabInfo(args.uuid);
|
||||
case "prefab_update":
|
||||
return this.updatePrefab(args.uuid);
|
||||
case "prefab_duplicate": {
|
||||
try {
|
||||
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
|
||||
return ok({ success: true, source: args.source, destination: args.destination });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "prefab_validate": {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
|
||||
const deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid).catch(() => []);
|
||||
return ok({ success: true, uuid: args.uuid, info, dependencies: deps, valid: !!info });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "prefab_revert":
|
||||
return this.revertPrefab(args.uuid);
|
||||
case "prefab_create_and_replace":
|
||||
return this.createAndReplace(args.uuid, args.path);
|
||||
case "prefab_open":
|
||||
return this.openPrefab(args.uuid, args.path, !!args.force);
|
||||
case "prefab_close":
|
||||
return this.closePrefab(args.save !== false, args.sceneUuid, !!args.force);
|
||||
case "prefab_create_from_spec":
|
||||
return this.createFromSpec(args.path, parseMaybeJson(args.spec), args.autoBindMode ?? "fuzzy");
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async listPrefabs(): Promise<ToolResult> {
|
||||
try {
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/**/*.prefab",
|
||||
});
|
||||
const prefabs = (results || []).map((a: any) => ({
|
||||
uuid: a.uuid,
|
||||
path: a.path || a.url,
|
||||
name: a.name,
|
||||
}));
|
||||
return ok({ success: true, prefabs });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async createPrefab(nodeUuid: string, path: string): Promise<ToolResult> {
|
||||
try {
|
||||
// 既存Prefabがある場合は警告を返す(上書きダイアログでタイムアウトするため)
|
||||
const existing = await this.assetExists(path);
|
||||
if (existing) {
|
||||
return err(
|
||||
`Prefab already exists at "${path}". Use prefab_update instead to update an existing prefab. ` +
|
||||
`Workflow: 1) prefab_instantiate to place in scene, 2) modify properties, 3) prefab_update to save.`
|
||||
);
|
||||
}
|
||||
const result = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
|
||||
return ok({ success: true, nodeUuid, path, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async assetExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const pattern = path.replace(/\.prefab$/, "") + ".*";
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
|
||||
return (results || []).length > 0;
|
||||
} catch {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
|
||||
return !!info;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async instantiatePrefab(prefabUuid: string, parent?: string): Promise<ToolResult> {
|
||||
try {
|
||||
const nodeUuid = await Editor.Message.request("scene", "create-node", {
|
||||
parent: parent || undefined,
|
||||
assetUuid: prefabUuid,
|
||||
});
|
||||
|
||||
// Prefab 編集モード中の場合、ネスト Prefab 情報を記憶
|
||||
// prefab_update 時に JSON 後処理で asset/instance/nestedPrefabInstanceRoots を設定
|
||||
if (parent) {
|
||||
this._pendingNestedPrefabs.push({
|
||||
nodeUuid,
|
||||
prefabAssetUuid: prefabUuid,
|
||||
parentUuid: parent,
|
||||
});
|
||||
}
|
||||
|
||||
return ok({ success: true, nodeUuid, prefabUuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async updatePrefab(nodeUuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await (Editor.Message.request as any)("scene", "apply-prefab", nodeUuid);
|
||||
|
||||
// ネスト Prefab の JSON 後処理
|
||||
if (this._pendingNestedPrefabs.length > 0) {
|
||||
await this._fixNestedPrefabJson(nodeUuid);
|
||||
}
|
||||
|
||||
return ok({ success: true, nodeUuid, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* prefab_update 後に Prefab JSON を後処理して、ネスト Prefab 参照を正しく設定する.
|
||||
*/
|
||||
private async _fixNestedPrefabJson(_rootNodeUuid: string): Promise<void> {
|
||||
if (this._pendingNestedPrefabs.length === 0) return;
|
||||
|
||||
try {
|
||||
// シーンを保存して Prefab JSON を書き出す
|
||||
// 現在シーンが untitled (scene-2d) の場合、save-scene はダイアログを出すので
|
||||
// safeSaveScene でスキップする。untitled でシーンにインスタンスが居るケースは
|
||||
// 本来 prefab_open モードでのみ発生するので save が効く想定だが、
|
||||
// テスト等で直接呼ばれた場合の保護として skip する。
|
||||
const saved = await safeSaveScene();
|
||||
if (!saved) {
|
||||
console.warn(
|
||||
"[PrefabTools] _fixNestedPrefabJson: save-scene skipped (untitled scene). " +
|
||||
"Nested prefab JSON post-processing may be incomplete."
|
||||
);
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
// prefab_open で記憶した UUID からファイルパスを取得
|
||||
if (!this._currentPrefabUuid) return;
|
||||
|
||||
const prefabPath = await (Editor.Message.request as any)(
|
||||
"asset-db", "query-path", this._currentPrefabUuid
|
||||
);
|
||||
if (!prefabPath) return;
|
||||
if (!fs.existsSync(prefabPath)) return;
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(prefabPath, "utf-8"));
|
||||
|
||||
// 各ネスト Prefab エントリを処理
|
||||
for (const entry of this._pendingNestedPrefabs) {
|
||||
// fileId でノードを検索(nodeUuid はシーン内 UUID、Prefab JSON 内では fileId)
|
||||
let flpNodeIdx = -1;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i].__type__ === "cc.PrefabInfo" && data[i].fileId === entry.nodeUuid) {
|
||||
// この PrefabInfo を持つノードを探す
|
||||
for (let j = 0; j < data.length; j++) {
|
||||
if (data[j]._prefab?.__id__ === i) {
|
||||
flpNodeIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// fileId で見つからない場合、ノード名で検索
|
||||
if (flpNodeIdx < 0) {
|
||||
// Prefab アセット名を取得
|
||||
const assetInfo = await (Editor.Message.request as any)("asset-db", "query-asset-info", entry.prefabAssetUuid);
|
||||
const assetName = assetInfo?.name?.replace(".prefab", "") || "";
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i].__type__ === "cc.Node" && (data[i]._name === assetName || data[i]._name === undefined)) {
|
||||
const prefabIdx = data[i]._prefab?.__id__;
|
||||
if (prefabIdx != null && data[prefabIdx]?.asset?.__id__ === 0 && !data[prefabIdx]?.instance) {
|
||||
flpNodeIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flpNodeIdx < 0) continue;
|
||||
|
||||
const prefabInfoIdx = data[flpNodeIdx]._prefab?.__id__;
|
||||
if (prefabInfoIdx == null) continue;
|
||||
|
||||
// PrefabInfo を修正
|
||||
const prefabInfo = data[prefabInfoIdx];
|
||||
prefabInfo.root = { __id__: flpNodeIdx };
|
||||
prefabInfo.asset = {
|
||||
__uuid__: entry.prefabAssetUuid,
|
||||
__expectedType__: "cc.Prefab",
|
||||
};
|
||||
|
||||
// PrefabInstance を追加
|
||||
if (!prefabInfo.instance) {
|
||||
const instanceIdx = data.length;
|
||||
data.push({
|
||||
__type__: "cc.PrefabInstance",
|
||||
fileId: crypto.randomBytes(16).toString("base64").replace(/[+/=]/g, "").substring(0, 22),
|
||||
prefabRootNode: { __id__: 1 }, // Prefab 編集モードのルート
|
||||
mountedChildren: [],
|
||||
mountedComponents: [],
|
||||
propertyOverrides: [],
|
||||
removedComponents: [],
|
||||
});
|
||||
prefabInfo.instance = { __id__: instanceIdx };
|
||||
}
|
||||
|
||||
// 子ノード・コンポーネントをクリア(Prefab アセットから復元される)
|
||||
data[flpNodeIdx]._children = [];
|
||||
data[flpNodeIdx]._components = [];
|
||||
|
||||
// ルートの nestedPrefabInstanceRoots に追加
|
||||
const rootPrefabIdx = data[1]._prefab?.__id__;
|
||||
if (rootPrefabIdx != null) {
|
||||
const rootPrefab = data[rootPrefabIdx];
|
||||
if (!rootPrefab.nestedPrefabInstanceRoots) {
|
||||
rootPrefab.nestedPrefabInstanceRoots = [];
|
||||
}
|
||||
const alreadyNested = rootPrefab.nestedPrefabInstanceRoots.some(
|
||||
(r: any) => r?.__id__ === flpNodeIdx
|
||||
);
|
||||
if (!alreadyNested) {
|
||||
rootPrefab.nestedPrefabInstanceRoots.push({ __id__: flpNodeIdx });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(prefabPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
this._pendingNestedPrefabs = [];
|
||||
} catch (e: any) {
|
||||
console.warn("[PrefabTools] _fixNestedPrefabJson failed:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async revertPrefab(nodeUuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await (Editor.Message.request as any)("scene", "revert-prefab", nodeUuid);
|
||||
return ok({ success: true, nodeUuid, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getPrefabInfo(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
|
||||
return ok({ success: true, info });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async createAndReplace(nodeUuid: string, path: string): Promise<ToolResult> {
|
||||
try {
|
||||
// 1. Check if prefab already exists
|
||||
const existing = await this.assetExists(path);
|
||||
if (existing) {
|
||||
return err(
|
||||
`Prefab already exists at "${path}". Delete it first or use a different path.`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Get node info (parent, sibling index, transform) before creating prefab
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
|
||||
if (!nodeInfo?.success) {
|
||||
return err(`Node ${nodeUuid} not found`);
|
||||
}
|
||||
const parentUuid = nodeInfo.data?.parent;
|
||||
|
||||
// 3. Create prefab from the node
|
||||
const prefabAssetUuid = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
|
||||
if (!prefabAssetUuid) {
|
||||
return err("create-prefab returned no asset UUID");
|
||||
}
|
||||
|
||||
// 4. Delete the original node
|
||||
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
|
||||
|
||||
// 5. Instantiate the prefab at the same parent
|
||||
const newNodeUuid = await Editor.Message.request("scene", "create-node", {
|
||||
parent: parentUuid || undefined,
|
||||
assetUuid: prefabAssetUuid,
|
||||
});
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
prefabAssetUuid,
|
||||
prefabPath: path,
|
||||
originalNodeUuid: nodeUuid,
|
||||
newInstanceUuid: newNodeUuid,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async openPrefab(uuid?: string, path?: string, force: boolean = false): Promise<ToolResult> {
|
||||
try {
|
||||
// Prefab を開くときも内部的にシーン切替が発生するので dirty untitled チェック
|
||||
await ensureSceneSafeToSwitch(force);
|
||||
|
||||
// Resolve UUID from path if needed
|
||||
let assetUuid = uuid;
|
||||
if (!assetUuid && path) {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
|
||||
assetUuid = info?.uuid;
|
||||
}
|
||||
if (!assetUuid) {
|
||||
return err("Either uuid or path is required");
|
||||
}
|
||||
|
||||
// Open prefab in editing mode (equivalent to double-click)
|
||||
await (Editor.Message.request as any)("asset-db", "open-asset", assetUuid);
|
||||
// Wait for prefab editing mode to initialize
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
this._currentPrefabUuid = assetUuid;
|
||||
this._pendingNestedPrefabs = [];
|
||||
|
||||
return ok({ success: true, uuid: assetUuid, mode: "prefab-edit" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async closePrefab(save: boolean, sceneUuid?: string, force: boolean = false): Promise<ToolResult> {
|
||||
try {
|
||||
// 1. Save prefab if requested
|
||||
// prefab edit モード中は "current scene" = 編集中 Prefab なので save-scene は Prefab を保存する。
|
||||
// ただし untitled フォールバックに当たった場合はダイアログ回避のためスキップする。
|
||||
if (save) {
|
||||
await safeSaveScene();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// 2. Determine which scene to return to
|
||||
let targetScene = sceneUuid;
|
||||
if (!targetScene) {
|
||||
// Try project's start scene
|
||||
try {
|
||||
targetScene = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Fallback to first scene
|
||||
if (!targetScene || targetScene === "current_scene") {
|
||||
const scenes = await Editor.Message.request("asset-db", "query-assets", {
|
||||
ccType: "cc.SceneAsset",
|
||||
pattern: "db://assets/**/*",
|
||||
});
|
||||
if (Array.isArray(scenes) && scenes.length > 0) {
|
||||
targetScene = scenes[0].uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Open the scene
|
||||
if (targetScene) {
|
||||
// prefab edit モードから戻る遷移もダイアログが出うる
|
||||
await ensureSceneSafeToSwitch(force);
|
||||
await (Editor.Message.request as any)("scene", "open-scene", targetScene);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
return ok({ success: true, returnedToScene: targetScene });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async createFromSpec(prefabPath: string, spec: any, autoBindMode: string): Promise<ToolResult> {
|
||||
try {
|
||||
// 1. 既存 Prefab チェック
|
||||
const existing = await this.assetExists(prefabPath);
|
||||
if (existing) {
|
||||
return err(
|
||||
`Prefab already exists at "${prefabPath}". Delete it first or use a different path.`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. シーンの最初の cc.Node UUID を取得(Scene UUID ではなく Canvas 等)
|
||||
const hier = await this.sceneScript("getSceneHierarchy", [false]);
|
||||
const hierarchy = hier?.hierarchy || [];
|
||||
const firstNode = hierarchy[0];
|
||||
if (!firstNode?.uuid) return err("Could not find a node in the current scene to use as parent");
|
||||
const parentUuid = firstNode.uuid;
|
||||
|
||||
// 3. ノードツリーを構築
|
||||
const autoBind = spec.autoBind;
|
||||
const cleanSpec = { ...spec };
|
||||
delete cleanSpec.autoBind;
|
||||
|
||||
const treeResult = await this.sceneScript("buildNodeTree", [parentUuid, cleanSpec]);
|
||||
if (!treeResult?.success) return err(treeResult?.error || "buildNodeTree failed");
|
||||
const nodeUuid = treeResult.data?.uuid;
|
||||
if (!nodeUuid) return err("buildNodeTree returned no root node UUID");
|
||||
|
||||
// 4. フォント・SpriteFrame を Editor API 経由で設定(アセット依存追跡のため)
|
||||
await this._applyDefaultAssets(nodeUuid);
|
||||
|
||||
// 5. autoBind 実行 (旧4)
|
||||
let autoBindResult: any = null;
|
||||
if (autoBind) {
|
||||
if (!this._componentTools) {
|
||||
return err("autoBind requires ComponentTools dependency (internal configuration error)");
|
||||
}
|
||||
const bindToolResult = await this._componentTools.execute("component_auto_bind", {
|
||||
uuid: nodeUuid,
|
||||
componentType: autoBind,
|
||||
force: false,
|
||||
mode: autoBindMode,
|
||||
});
|
||||
try {
|
||||
autoBindResult = JSON.parse(bindToolResult.content[0].text);
|
||||
} catch { autoBindResult = bindToolResult; }
|
||||
}
|
||||
|
||||
// 6. Prefab 作成
|
||||
const prefabAssetUuid = await (Editor.Message.request as any)(
|
||||
"scene", "create-prefab", nodeUuid, prefabPath
|
||||
);
|
||||
if (!prefabAssetUuid) {
|
||||
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
|
||||
return err("create-prefab returned no asset UUID");
|
||||
}
|
||||
|
||||
// 7. 一時ノードを削除
|
||||
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
prefabAssetUuid,
|
||||
path: prefabPath,
|
||||
nodeTree: treeResult.data,
|
||||
autoBind: autoBindResult,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* buildNodeTree で作成したノードツリーの Label にプロジェクトフォントを設定する。
|
||||
* Editor API (scene:set-property) 経由で設定することでアセット依存が正しく追跡される。
|
||||
*/
|
||||
private async _applyDefaultAssets(rootUuid: string): Promise<void> {
|
||||
// プロジェクトのデフォルトフォントを検索(resources/fonts/ 配下の TTFFont)
|
||||
let fontUuid: string | null = null;
|
||||
try {
|
||||
const assets = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/resources/fonts/**",
|
||||
ccType: "cc.TTFFont",
|
||||
});
|
||||
if (Array.isArray(assets) && assets.length > 0) {
|
||||
fontUuid = assets[0].uuid;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!fontUuid) return;
|
||||
|
||||
// 全子孫ノードを取得
|
||||
const descendants = await this.sceneScript("getAllDescendants", [rootUuid]);
|
||||
if (!descendants?.success) return;
|
||||
const allNodes = [{ uuid: rootUuid, name: "root" }, ...descendants.data];
|
||||
|
||||
for (const node of allNodes) {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", node.uuid);
|
||||
if (!nodeDump) continue;
|
||||
const comps = nodeDump.__comps__ || [];
|
||||
for (let i = 0; i < comps.length; i++) {
|
||||
const compType = comps[i].type || "";
|
||||
// Label にフォント設定
|
||||
if (compType === "cc.Label") {
|
||||
const fontDump = comps[i].value?.font;
|
||||
if (!fontDump?.value?.uuid) {
|
||||
await (Editor.Message.request as any)("scene", "set-property", {
|
||||
uuid: node.uuid,
|
||||
path: `__comps__.${i}.font`,
|
||||
dump: { type: "cc.TTFFont", value: { uuid: fontUuid } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* skip nodes that can't be queried */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class PreferencesTools implements ToolCategory {
|
||||
readonly categoryName = "preferences";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "preferences_get",
|
||||
description: "Get a preference value by key.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name (e.g. 'general', 'builder', 'engine')" },
|
||||
key: { type: "string", description: "Preference key" },
|
||||
},
|
||||
required: ["protocol", "key"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preferences_set",
|
||||
description: "Set a preference value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name" },
|
||||
key: { type: "string", description: "Preference key" },
|
||||
value: { description: "Value to set" },
|
||||
},
|
||||
required: ["protocol", "key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preferences_get_all",
|
||||
description: "Get all preferences for a given protocol.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name (e.g. 'general')" },
|
||||
},
|
||||
required: ["protocol"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preferences_reset",
|
||||
description: "Reset a preference to its default value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name" },
|
||||
key: { type: "string", description: "Preference key to reset" },
|
||||
},
|
||||
required: ["protocol", "key"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "preferences_get": {
|
||||
const value = Editor.Profile.getConfig(args.protocol, args.key);
|
||||
return ok({ success: true, protocol: args.protocol, key: args.key, value });
|
||||
}
|
||||
case "preferences_set":
|
||||
Editor.Profile.setConfig(args.protocol, args.key, args.value);
|
||||
return ok({ success: true, protocol: args.protocol, key: args.key });
|
||||
case "preferences_get_all": {
|
||||
const config = Editor.Profile.getConfig(args.protocol);
|
||||
return ok({ success: true, protocol: args.protocol, config });
|
||||
}
|
||||
case "preferences_reset":
|
||||
Editor.Profile.removeConfig(args.protocol, args.key);
|
||||
return ok({ success: true, protocol: args.protocol, key: args.key });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class ProjectTools implements ToolCategory {
|
||||
readonly categoryName = "project";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "project_get_info",
|
||||
description: "Get project information (name, path, engine version).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_refresh_assets",
|
||||
description: "Refresh the asset database to detect file changes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_get_asset_info",
|
||||
description: "Get information about an asset by UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_find_asset",
|
||||
description: "Find assets by name pattern (glob). Returns matching asset paths and UUIDs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Glob pattern (e.g. 'db://assets/**/*.ts', 'db://assets/**/Button*')" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_get_settings",
|
||||
description: "Get project settings for a given protocol.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Settings protocol (e.g. 'general', 'engine')" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_set_settings",
|
||||
description: "Set a project setting.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string" },
|
||||
key: { type: "string" },
|
||||
value: {},
|
||||
},
|
||||
required: ["protocol", "key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_get_engine_info",
|
||||
description: "Get engine version and path information.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "project_query_scripts",
|
||||
description: "Query all script plugins in the project.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
switch (toolName) {
|
||||
case "project_get_info":
|
||||
return this.getInfo();
|
||||
case "project_refresh_assets":
|
||||
return this.refreshAssets();
|
||||
case "project_get_asset_info":
|
||||
return this.getAssetInfo(args.uuid);
|
||||
case "project_find_asset":
|
||||
return this.findAsset(args.pattern);
|
||||
case "project_get_settings": {
|
||||
try {
|
||||
const config = await (Editor.Message.request as any)("project", "query-config", args.protocol || "general");
|
||||
return ok({ success: true, config });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "project_set_settings": {
|
||||
try {
|
||||
await (Editor.Message.request as any)("project", "set-config", args.protocol, args.key, args.value);
|
||||
return ok({ success: true });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "project_get_engine_info": {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("engine", "query-info");
|
||||
return ok({ success: true, info });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "project_query_scripts": {
|
||||
try {
|
||||
const scripts = await (Editor.Message.request as any)("programming", "query-sorted-plugins");
|
||||
return ok({ success: true, scripts });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getInfo(): Promise<ToolResult> {
|
||||
try {
|
||||
return ok({
|
||||
success: true,
|
||||
name: Editor.Project.name,
|
||||
path: Editor.Project.path,
|
||||
tmpDir: Editor.Project.tmpDir,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshAssets(): Promise<ToolResult> {
|
||||
try {
|
||||
await Editor.Message.request("asset-db", "refresh-asset", "db://assets");
|
||||
return ok({ success: true });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getAssetInfo(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
|
||||
return ok({ success: true, info });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async findAsset(pattern: string): Promise<ToolResult> {
|
||||
try {
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
|
||||
const assets = (results || []).map((a: any) => ({
|
||||
uuid: a.uuid,
|
||||
path: a.path || a.url,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
}));
|
||||
return ok({ success: true, assets });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class ReferenceImageTools implements ToolCategory {
|
||||
readonly categoryName = "referenceImage";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "refimage_add",
|
||||
description: "Add a reference image to the scene view.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Image file path or db:// path" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_remove",
|
||||
description: "Remove a reference image by index.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
index: { type: "number", description: "Image index" },
|
||||
},
|
||||
required: ["index"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_list",
|
||||
description: "List all reference images in the scene view.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_clear_all",
|
||||
description: "Remove all reference images.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_switch",
|
||||
description: "Switch to a specific reference image by index.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
index: { type: "number", description: "Image index to switch to" },
|
||||
},
|
||||
required: ["index"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_set_position",
|
||||
description: "Set the position of the current reference image.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
},
|
||||
required: ["x", "y"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_set_scale",
|
||||
description: "Set the scale of the current reference image.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scale: { type: "number", description: "Scale factor" },
|
||||
},
|
||||
required: ["scale"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_set_opacity",
|
||||
description: "Set the opacity of the current reference image.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
opacity: { type: "number", description: "Opacity (0-255)" },
|
||||
},
|
||||
required: ["opacity"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_query_config",
|
||||
description: "Get the current reference image configuration.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_query_current",
|
||||
description: "Get info about the currently active reference image.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_refresh",
|
||||
description: "Refresh the reference image display.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "refimage_add":
|
||||
await (Editor.Message.request as any)("scene", "add-reference-image", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "refimage_remove":
|
||||
await (Editor.Message.request as any)("scene", "remove-reference-image", args.index);
|
||||
return ok({ success: true, index: args.index });
|
||||
case "refimage_list": {
|
||||
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
|
||||
return ok({ success: true, config });
|
||||
}
|
||||
case "refimage_clear_all":
|
||||
await (Editor.Message.request as any)("scene", "clear-all-reference-images");
|
||||
return ok({ success: true });
|
||||
case "refimage_switch":
|
||||
await (Editor.Message.request as any)("scene", "switch-reference-image", args.index);
|
||||
return ok({ success: true, index: args.index });
|
||||
case "refimage_set_position":
|
||||
await (Editor.Message.request as any)("scene", "set-reference-image-position", args.x, args.y);
|
||||
return ok({ success: true, x: args.x, y: args.y });
|
||||
case "refimage_set_scale":
|
||||
await (Editor.Message.request as any)("scene", "set-reference-image-scale", args.scale);
|
||||
return ok({ success: true, scale: args.scale });
|
||||
case "refimage_set_opacity":
|
||||
await (Editor.Message.request as any)("scene", "set-reference-image-opacity", args.opacity);
|
||||
return ok({ success: true, opacity: args.opacity });
|
||||
case "refimage_query_config": {
|
||||
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
|
||||
return ok({ success: true, config });
|
||||
}
|
||||
case "refimage_query_current": {
|
||||
const current = await (Editor.Message.request as any)("scene", "query-current-reference-image").catch(() => null);
|
||||
return ok({ success: true, current });
|
||||
}
|
||||
case "refimage_refresh":
|
||||
await (Editor.Message.request as any)("scene", "refresh-reference-image");
|
||||
return ok({ success: true });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { ensureSceneSafeToSwitch } from "./scene-tools";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export class SceneAdvancedTools implements ToolCategory {
|
||||
readonly categoryName = "sceneAdvanced";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "scene_execute_script",
|
||||
description: "Execute a scene script method by name with arguments.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
method: { type: "string", description: "Scene script method name" },
|
||||
args: { type: "array", description: "Arguments to pass", items: {} },
|
||||
},
|
||||
required: ["method"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_snapshot",
|
||||
description: "Take a snapshot of the current scene state (for undo).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_dirty",
|
||||
description: "Check if the current scene has unsaved changes.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_classes",
|
||||
description: "Query all available component classes in the scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_components",
|
||||
description: "Query available components for a given node.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_node_tree",
|
||||
description: "Query the raw node tree from the editor (alternative to scene_get_hierarchy).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_nodes_by_asset",
|
||||
description: "Find all nodes that reference a given asset UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
assetUuid: { type: "string", description: "Asset UUID to search for" },
|
||||
},
|
||||
required: ["assetUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_soft_reload",
|
||||
description: "Soft reload the current scene without losing state.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_reset_node_transform",
|
||||
description: "Reset a node's transform to default (position 0,0,0 / rotation 0,0,0 / scale 1,1,1).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_copy_node",
|
||||
description: "Copy a node to clipboard.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_paste_node",
|
||||
description: "Paste node from clipboard under a parent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
parentUuid: { type: "string", description: "Parent node UUID" },
|
||||
},
|
||||
required: ["parentUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_create",
|
||||
description: "Create a new empty 2D scene. If path is omitted, uses the editor's built-in new-scene command (may not work on CC 3.8.x). If path is specified, creates a .scene file via asset-db as a fallback. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Scene asset path (e.g. 'db://assets/scenes/NewScene.scene'). If omitted, uses editor's new-scene command." },
|
||||
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_cut_node",
|
||||
description: "Cut a node to clipboard (removes from scene).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Node UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_reset_property",
|
||||
description: "Reset a specific property on a node or component to its default value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node or component UUID" },
|
||||
path: { type: "string", description: "Property path (e.g. 'position', 'color')" },
|
||||
},
|
||||
required: ["uuid", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_reset_component",
|
||||
description: "Reset a component to its default state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Component UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_execute_component_method",
|
||||
description: "Call a method on a component at edit-time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Component UUID" },
|
||||
method: { type: "string", description: "Method name" },
|
||||
args: { type: "array", description: "Method arguments", items: {} },
|
||||
},
|
||||
required: ["uuid", "method"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_move_array_element",
|
||||
description: "Move an array element to a new position in a property.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node or component UUID" },
|
||||
path: { type: "string", description: "Array property path" },
|
||||
target: { type: "number", description: "Current index" },
|
||||
offset: { type: "number", description: "Move offset (+1 = down, -1 = up)" },
|
||||
},
|
||||
required: ["uuid", "path", "target", "offset"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_remove_array_element",
|
||||
description: "Remove an element from an array property by index.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node or component UUID" },
|
||||
path: { type: "string", description: "Array property path" },
|
||||
index: { type: "number", description: "Index to remove" },
|
||||
},
|
||||
required: ["uuid", "path", "index"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_snapshot_abort",
|
||||
description: "Abort the current undo snapshot.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_ready",
|
||||
description: "Check if the scene is fully loaded and ready.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_component_has_script",
|
||||
description: "Check if a component has an associated script file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string", description: "Component class name" } },
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_restore_prefab",
|
||||
description: "Restore a prefab node to its original prefab state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Node UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_begin_undo",
|
||||
description: "Begin recording undo operations.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_end_undo",
|
||||
description: "End undo recording and save the undo step.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_cancel_undo",
|
||||
description: "Cancel the current undo recording.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_save_as",
|
||||
description: "Save the current scene to a new file (shows save dialog).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
// ── 以下、既存MCP未対応のEditor API ──
|
||||
{
|
||||
name: "scene_set_parent",
|
||||
description: "Reparent node(s) using the official Editor API (alternative to node_move).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuids: { type: "array", items: { type: "string" }, description: "Node UUID(s) to move" },
|
||||
parent: { type: "string", description: "New parent node UUID" },
|
||||
keepWorldTransform: { type: "boolean", description: "Keep world position (default false)" },
|
||||
},
|
||||
required: ["uuids", "parent"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_node",
|
||||
description: "Get a full property dump of a node (all serialized data).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Node UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_component",
|
||||
description: "Get a full property dump of a component by its UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Component UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_scene_bounds",
|
||||
description: "Get the bounding rect of the current scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "scene_execute_script":
|
||||
return ok(await this.sceneScript(args.method, args.args || []));
|
||||
case "scene_snapshot":
|
||||
return ok(await (Editor.Message.request as any)("scene", "snapshot"));
|
||||
case "scene_query_dirty": {
|
||||
const dirty = await (Editor.Message.request as any)("scene", "query-dirty");
|
||||
return ok({ success: true, dirty });
|
||||
}
|
||||
case "scene_query_classes": {
|
||||
const classes = await (Editor.Message.request as any)("scene", "query-classes");
|
||||
return ok({ success: true, classes });
|
||||
}
|
||||
case "scene_query_components": {
|
||||
const comps = await (Editor.Message.request as any)("scene", "query-components", args.uuid);
|
||||
return ok({ success: true, components: comps });
|
||||
}
|
||||
case "scene_query_node_tree": {
|
||||
const tree = await Editor.Message.request("scene", "query-node-tree");
|
||||
return ok({ success: true, tree });
|
||||
}
|
||||
case "scene_query_nodes_by_asset": {
|
||||
const nodes = await (Editor.Message.request as any)("scene", "query-nodes-by-asset-uuid", args.assetUuid);
|
||||
return ok({ success: true, nodes });
|
||||
}
|
||||
case "scene_soft_reload":
|
||||
await (Editor.Message.request as any)("scene", "soft-reload");
|
||||
return ok({ success: true });
|
||||
case "scene_reset_node_transform":
|
||||
return await this.resetTransform(args.uuid);
|
||||
case "scene_copy_node":
|
||||
await (Editor.Message.request as any)("scene", "copy-node", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "scene_paste_node": {
|
||||
const result = await (Editor.Message.request as any)("scene", "paste-node", args.parentUuid);
|
||||
return ok({ success: true, result });
|
||||
}
|
||||
case "scene_create":
|
||||
return this.createScene(args.path, !!args.force);
|
||||
case "scene_cut_node":
|
||||
await (Editor.Message.request as any)("scene", "cut-node", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "scene_reset_property":
|
||||
await (Editor.Message.request as any)("scene", "reset-property", { uuid: args.uuid, path: args.path });
|
||||
return ok({ success: true });
|
||||
case "scene_reset_component":
|
||||
await (Editor.Message.request as any)("scene", "reset-component", { uuid: args.uuid });
|
||||
return ok({ success: true });
|
||||
case "scene_execute_component_method": {
|
||||
const result = await (Editor.Message.request as any)("scene", "execute-component-method", { uuid: args.uuid, name: args.method, args: args.args || [] });
|
||||
return ok({ success: true, result });
|
||||
}
|
||||
case "scene_move_array_element":
|
||||
await (Editor.Message.request as any)("scene", "move-array-element", { uuid: args.uuid, path: args.path, target: args.target, offset: args.offset });
|
||||
return ok({ success: true });
|
||||
case "scene_remove_array_element":
|
||||
await (Editor.Message.request as any)("scene", "remove-array-element", { uuid: args.uuid, path: args.path, index: args.index });
|
||||
return ok({ success: true });
|
||||
case "scene_snapshot_abort":
|
||||
await (Editor.Message.request as any)("scene", "snapshot-abort");
|
||||
return ok({ success: true });
|
||||
case "scene_query_ready": {
|
||||
const ready = await (Editor.Message.request as any)("scene", "query-is-ready");
|
||||
return ok({ success: true, ready });
|
||||
}
|
||||
case "scene_query_component_has_script": {
|
||||
const hasScript = await (Editor.Message.request as any)("scene", "query-component-has-script", args.name);
|
||||
return ok({ success: true, name: args.name, hasScript });
|
||||
}
|
||||
case "scene_restore_prefab":
|
||||
await (Editor.Message.request as any)("scene", "restore-prefab", { uuid: args.uuid });
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "scene_begin_undo":
|
||||
await (Editor.Message.request as any)("scene", "begin-recording");
|
||||
return ok({ success: true });
|
||||
case "scene_end_undo":
|
||||
await (Editor.Message.request as any)("scene", "end-recording");
|
||||
return ok({ success: true });
|
||||
case "scene_cancel_undo":
|
||||
await (Editor.Message.request as any)("scene", "cancel-recording");
|
||||
return ok({ success: true });
|
||||
case "scene_save_as": {
|
||||
const result = await (Editor.Message.request as any)("scene", "save-as-scene");
|
||||
return ok({ success: true, result });
|
||||
}
|
||||
case "scene_set_parent":
|
||||
await (Editor.Message.request as any)("scene", "set-parent", {
|
||||
parent: args.parent,
|
||||
uuids: args.uuids,
|
||||
keepWorldTransform: args.keepWorldTransform || false,
|
||||
});
|
||||
return ok({ success: true });
|
||||
case "scene_query_node": {
|
||||
const dump = await (Editor.Message.request as any)("scene", "query-node", args.uuid);
|
||||
return ok({ success: true, node: dump });
|
||||
}
|
||||
case "scene_query_component": {
|
||||
const dump = await (Editor.Message.request as any)("scene", "query-component", args.uuid);
|
||||
return ok({ success: true, component: dump });
|
||||
}
|
||||
case "scene_query_scene_bounds": {
|
||||
const bounds = await (Editor.Message.request as any)("scene", "query-scene-bounds");
|
||||
return ok({ success: true, bounds });
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async resetTransform(uuid: string): Promise<ToolResult> {
|
||||
const result = await this.sceneScript("setNodeProperty", [uuid, "position", { x: 0, y: 0, z: 0 }]);
|
||||
await this.sceneScript("setNodeProperty", [uuid, "rotation", { x: 0, y: 0, z: 0 }]);
|
||||
await this.sceneScript("setNodeProperty", [uuid, "scale", { x: 1, y: 1, z: 1 }]);
|
||||
return ok({ success: true, uuid });
|
||||
}
|
||||
|
||||
private async createScene(path?: string, force: boolean = false): Promise<ToolResult> {
|
||||
// ダイアログ割り込み防止: 現在シーンが dirty かつ untitled の場合は事前エラー
|
||||
try { await ensureSceneSafeToSwitch(force); }
|
||||
catch (e: any) { return err(e.message || String(e)); }
|
||||
|
||||
// まず scene:new-scene を試行(path 未指定時のみ)
|
||||
if (!path) {
|
||||
try {
|
||||
await (Editor.Message.request as any)("scene", "new-scene");
|
||||
return ok({ success: true });
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || String(e);
|
||||
if (msg.includes("Message does not exist") || msg.includes("scene - new-scene")) {
|
||||
// CC 3.8.x → asset-db fallback にフォール
|
||||
const fallbackPath = await this.generateAvailableScenePath();
|
||||
return this.createSceneViaAssetDb(fallbackPath);
|
||||
}
|
||||
return err(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// path 指定 → asset-db fallback
|
||||
return this.createSceneViaAssetDb(path);
|
||||
}
|
||||
|
||||
private async generateAvailableScenePath(): Promise<string> {
|
||||
const basePath = "db://assets/NewScene.scene";
|
||||
try {
|
||||
const result = await (Editor.Message.request as any)("asset-db", "generate-available-url", basePath);
|
||||
if (result) return result;
|
||||
} catch { /* fallback */ }
|
||||
return `db://assets/NewScene_${Date.now()}.scene`;
|
||||
}
|
||||
|
||||
private async createSceneViaAssetDb(path: string): Promise<ToolResult> {
|
||||
try {
|
||||
if (!path.endsWith(".scene")) path += ".scene";
|
||||
|
||||
const sceneName = path.split("/").pop()!.replace(".scene", "");
|
||||
const uid = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const sid = () => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let s = "";
|
||||
for (let i = 0; i < 21; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
||||
return s;
|
||||
};
|
||||
|
||||
const sceneJson = this.buildMinimalSceneJson(sceneName, uid, sid);
|
||||
const content = JSON.stringify(sceneJson, null, 2);
|
||||
|
||||
await (Editor.Message.request as any)("asset-db", "create-asset", path, content);
|
||||
|
||||
// シーンを開く
|
||||
try {
|
||||
// ensureSceneSafeToSwitch は createScene 入口で既に通過済みなのでここでは再チェックしない
|
||||
const queryResult = await (Editor.Message.request as any)("asset-db", "query-uuid", path);
|
||||
if (queryResult) {
|
||||
await (Editor.Message.request as any)("scene", "open-scene", queryResult);
|
||||
}
|
||||
} catch { /* open failure is not critical */ }
|
||||
|
||||
return ok({ success: true, path, method: "asset-db-fallback" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private buildMinimalSceneJson(name: string, uid: () => string, sid: () => string): any[] {
|
||||
const sceneId = uid();
|
||||
const canvasNodeId = sid();
|
||||
const cameraNodeId = sid();
|
||||
|
||||
const vec3 = (x: number, y: number, z: number) => ({ __type__: "cc.Vec3", x, y, z });
|
||||
const quat = () => ({ __type__: "cc.Quat", x: 0, y: 0, z: 0, w: 1 });
|
||||
|
||||
return [
|
||||
// [0] SceneAsset
|
||||
{
|
||||
__type__: "cc.SceneAsset",
|
||||
_name: name,
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_native: "",
|
||||
scene: { __id__: 1 },
|
||||
},
|
||||
// [1] Scene
|
||||
{
|
||||
__type__: "cc.Scene",
|
||||
_name: name,
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_parent: null,
|
||||
_children: [{ __id__: 2 }],
|
||||
_active: true,
|
||||
_components: [],
|
||||
_prefab: null,
|
||||
_lpos: vec3(0, 0, 0),
|
||||
_lrot: quat(),
|
||||
_lscale: vec3(1, 1, 1),
|
||||
_mobility: 0,
|
||||
_layer: 1073741824,
|
||||
_euler: vec3(0, 0, 0),
|
||||
autoReleaseAssets: false,
|
||||
_globals: { __id__: 10 },
|
||||
_id: sceneId,
|
||||
},
|
||||
// [2] Canvas node
|
||||
{
|
||||
__type__: "cc.Node",
|
||||
_name: "Canvas",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_parent: { __id__: 1 },
|
||||
_children: [{ __id__: 3 }],
|
||||
_active: true,
|
||||
_components: [{ __id__: 5 }, { __id__: 6 }, { __id__: 7 }],
|
||||
_prefab: null,
|
||||
_lpos: vec3(0, 0, 0),
|
||||
_lrot: quat(),
|
||||
_lscale: vec3(1, 1, 1),
|
||||
_mobility: 0,
|
||||
_layer: 33554432,
|
||||
_euler: vec3(0, 0, 0),
|
||||
_id: canvasNodeId,
|
||||
},
|
||||
// [3] Camera node
|
||||
{
|
||||
__type__: "cc.Node",
|
||||
_name: "Camera",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_parent: { __id__: 2 },
|
||||
_children: [],
|
||||
_active: true,
|
||||
_components: [{ __id__: 4 }],
|
||||
_prefab: null,
|
||||
_lpos: vec3(0, 0, 1000),
|
||||
_lrot: quat(),
|
||||
_lscale: vec3(1, 1, 1),
|
||||
_mobility: 0,
|
||||
_layer: 1073741824,
|
||||
_euler: vec3(0, 0, 0),
|
||||
_id: cameraNodeId,
|
||||
},
|
||||
// [4] Camera component
|
||||
{
|
||||
__type__: "cc.Camera",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 3 },
|
||||
_enabled: true,
|
||||
_projection: 1,
|
||||
_priority: 0,
|
||||
_fov: 45,
|
||||
_fovAxis: 0,
|
||||
_orthoHeight: 10,
|
||||
_near: 1,
|
||||
_far: 2000,
|
||||
_color: { __type__: "cc.Color", r: 0, g: 0, b: 0, a: 255 },
|
||||
_depth: 1,
|
||||
_stencil: 0,
|
||||
_clearFlags: 6,
|
||||
_rect: { __type__: "cc.Rect", x: 0, y: 0, width: 1, height: 1 },
|
||||
_visibility: 1108344832,
|
||||
_id: "",
|
||||
},
|
||||
// [5] UITransform on Canvas
|
||||
{
|
||||
__type__: "cc.UITransform",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 2 },
|
||||
_enabled: true,
|
||||
_contentSize: { __type__: "cc.Size", width: 720, height: 1280 },
|
||||
_anchorPoint: { __type__: "cc.Vec2", x: 0.5, y: 0.5 },
|
||||
_id: "",
|
||||
},
|
||||
// [6] Canvas component
|
||||
{
|
||||
__type__: "cc.Canvas",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 2 },
|
||||
_enabled: true,
|
||||
_cameraComponent: { __id__: 4 },
|
||||
_alignCanvasWithScreen: true,
|
||||
_id: "",
|
||||
},
|
||||
// [7] Widget on Canvas (fullscreen)
|
||||
{
|
||||
__type__: "cc.Widget",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 2 },
|
||||
_enabled: true,
|
||||
_alignFlags: 15,
|
||||
_target: null,
|
||||
_left: 0,
|
||||
_right: 0,
|
||||
_top: 0,
|
||||
_bottom: 0,
|
||||
_isAbsLeft: true,
|
||||
_isAbsRight: true,
|
||||
_isAbsTop: true,
|
||||
_isAbsBottom: true,
|
||||
_originalWidth: 0,
|
||||
_originalHeight: 0,
|
||||
_id: "",
|
||||
},
|
||||
// [8] cc.PrefabInfo for scene
|
||||
// [9] (reserved)
|
||||
// [10] SceneGlobals
|
||||
{
|
||||
__type__: "cc.SceneGlobals",
|
||||
ambient: { __id__: 11 },
|
||||
shadows: { __id__: 12 },
|
||||
_skybox: { __id__: 13 },
|
||||
fog: { __id__: 14 },
|
||||
},
|
||||
// [11] AmbientInfo
|
||||
{ __type__: "cc.AmbientInfo", _skyLightingColor: { __type__: "cc.Vec4", x: 0.2, y: 0.2, z: 0.2, w: 1 } },
|
||||
// [12] ShadowsInfo
|
||||
{ __type__: "cc.ShadowsInfo" },
|
||||
// [13] SkyboxInfo
|
||||
{ __type__: "cc.SkyboxInfo" },
|
||||
// [14] FogInfo
|
||||
{ __type__: "cc.FogInfo" },
|
||||
];
|
||||
}
|
||||
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
/** シーン名から「untitled (保存先なし)」かどうかを判定 */
|
||||
const UNTITLED_SCENE_NAMES = new Set(["scene-2d", "scene-3d", "Untitled", "NewScene", ""]);
|
||||
|
||||
/**
|
||||
* 現在のシーンの dirty 状態を取得。
|
||||
* CC バージョンによって query-dirty / query-is-dirty のどちらか(または両方)が存在するので両方試行。
|
||||
*/
|
||||
export async function queryCurrentSceneDirty(): Promise<boolean> {
|
||||
try {
|
||||
const r = await (Editor.Message.request as any)("scene", "query-dirty");
|
||||
return !!r;
|
||||
} catch { /* try alternate */ }
|
||||
try {
|
||||
const r = await (Editor.Message.request as any)("scene", "query-is-dirty");
|
||||
return !!r;
|
||||
} catch { /* assume clean if both fail */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在のシーン情報(名前・UUID)を取得。
|
||||
*/
|
||||
export async function queryCurrentSceneInfo(): Promise<{ sceneName: string; sceneUuid: string }> {
|
||||
try {
|
||||
const result: any = await Editor.Message.request(
|
||||
"scene",
|
||||
"execute-scene-script",
|
||||
{ name: EXT_NAME, method: "getSceneHierarchy", args: [false] }
|
||||
);
|
||||
return { sceneName: result?.sceneName || "", sceneUuid: result?.sceneUuid || "" };
|
||||
} catch {
|
||||
return { sceneName: "", sceneUuid: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在のシーンが untitled (scene-2d 等) かを判定。
|
||||
*/
|
||||
export async function isCurrentSceneUntitled(): Promise<boolean> {
|
||||
const { sceneName } = await queryCurrentSceneInfo();
|
||||
return UNTITLED_SCENE_NAMES.has(sceneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* save-scene をダイアログ安全に呼び出す。
|
||||
*
|
||||
* - 現在のシーンが untitled (scene-2d 等) → no-op(ダイアログ防止)
|
||||
* - それ以外 → save-scene を実行
|
||||
*
|
||||
* 任意の MCP ツール内部で save-scene を呼ぶ場合は必ずこれ経由にする。
|
||||
* 直接 `Editor.Message.request("scene", "save-scene")` を呼ぶと、
|
||||
* 現在シーンが untitled のときにモーダルダイアログが出て MCP が固まる。
|
||||
*
|
||||
* @returns 実際に保存を実行したか
|
||||
*/
|
||||
export async function safeSaveScene(): Promise<boolean> {
|
||||
if (await isCurrentSceneUntitled()) {
|
||||
console.warn(
|
||||
"[cocos-creator-mcp] safeSaveScene: current scene is untitled, " +
|
||||
"skipping save-scene to avoid modal dialog."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await (Editor.Message.request as any)("scene", "save-scene");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* シーン切替系ツール(scene_open/close/new など)の前処理。
|
||||
*
|
||||
* - clean → OK
|
||||
* - dirty + 保存先ありのシーン → save-scene で自動保存
|
||||
* - dirty + untitled シーン → ダイアログを自動応答("Don't Save")して変更を破棄
|
||||
*
|
||||
* 目的: "Save changes?" ダイアログで MCP がブロックされるのを防ぐ。
|
||||
*/
|
||||
export async function ensureSceneSafeToSwitch(force: boolean = false): Promise<void> {
|
||||
const isDirty = await queryCurrentSceneDirty();
|
||||
if (!isDirty) return;
|
||||
|
||||
const { sceneName } = await queryCurrentSceneInfo();
|
||||
const isUntitled = UNTITLED_SCENE_NAMES.has(sceneName);
|
||||
|
||||
if (isUntitled) {
|
||||
// untitled + dirty: ダイアログが出るとMCPがブロックされるので、
|
||||
// Electron dialog をパッチして「保存しない」を自動応答する。
|
||||
// パッチは次回のシーン切替(呼び出し元の scene_open 等)でダイアログが出る瞬間に効く。
|
||||
patchDialogForDiscard();
|
||||
console.warn(
|
||||
`[cocos-creator-mcp] ensureSceneSafeToSwitch: untitled scene "${sceneName}" ` +
|
||||
`is dirty — dialog will auto-respond "Don't Save" to avoid blocking MCP.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ここに来る時点で untitled ではない(上で判定済み)ので直接 save-scene OK
|
||||
await (Editor.Message.request as any)("scene", "save-scene");
|
||||
} catch (e: any) {
|
||||
if (force) {
|
||||
console.warn(
|
||||
`[cocos-creator-mcp] ensureSceneSafeToSwitch: save-scene failed but force=true — proceeding. ` +
|
||||
`Error: ${e.message || e}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to auto-save dirty scene "${sceneName}" before switch: ${e.message || e}. ` +
|
||||
`Save manually and retry, or pass force=true to bypass.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron の dialog.showMessageBox(Sync) を一時的にパッチして、
|
||||
* 次回のダイアログで「保存しない (Don't Save)」を自動選択する。
|
||||
*
|
||||
* CocosCreator の "Save changes?" ダイアログは通常:
|
||||
* buttons: ["Save", "Cancel", "Don't Save"] → index 2 = Don't Save
|
||||
* または:
|
||||
* buttons: ["Save", "Don't Save", "Cancel"] → index 1 = Don't Save
|
||||
*
|
||||
* ボタンテキストから "Don't Save" / "not" / "discard" を探し、
|
||||
* 見つからなければ最後のボタン(通常 Don't Save)を選択する。
|
||||
*/
|
||||
function patchDialogForDiscard(): void {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const dialog = electron.dialog;
|
||||
const origSync = dialog.showMessageBoxSync;
|
||||
const origAsync = dialog.showMessageBox;
|
||||
|
||||
function findDiscardIndex(buttons: string[]): number {
|
||||
if (!buttons || buttons.length === 0) return 0;
|
||||
const idx = buttons.findIndex((b: string) => {
|
||||
const lower = b.toLowerCase();
|
||||
return lower.includes("don't save") || lower.includes("not")
|
||||
|| lower.includes("discard") || lower.includes("保存しない");
|
||||
});
|
||||
return idx >= 0 ? idx : buttons.length - 1;
|
||||
}
|
||||
|
||||
// Sync version
|
||||
dialog.showMessageBoxSync = function (...args: any[]) {
|
||||
dialog.showMessageBoxSync = origSync; // 1回で復元
|
||||
const options = args.length > 1 ? args[1] : args[0];
|
||||
const buttons = options?.buttons || [];
|
||||
const result = findDiscardIndex(buttons);
|
||||
console.warn(`[cocos-creator-mcp] dialog auto-responded: button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Async version
|
||||
dialog.showMessageBox = function (...args: any[]) {
|
||||
dialog.showMessageBox = origAsync; // 1回で復元
|
||||
const options = args.length > 1 ? args[1] : args[0];
|
||||
const buttons = options?.buttons || [];
|
||||
const result = findDiscardIndex(buttons);
|
||||
console.warn(`[cocos-creator-mcp] dialog auto-responded (async): button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
|
||||
return Promise.resolve({ response: result, checkboxChecked: false });
|
||||
};
|
||||
|
||||
// 安全策: 5秒後に未使用なら復元(次のダイアログが出なかったケース)
|
||||
setTimeout(() => {
|
||||
if (dialog.showMessageBoxSync !== origSync) {
|
||||
dialog.showMessageBoxSync = origSync;
|
||||
}
|
||||
if (dialog.showMessageBox !== origAsync) {
|
||||
dialog.showMessageBox = origAsync;
|
||||
}
|
||||
}, 5000);
|
||||
} catch (e: any) {
|
||||
console.error(`[cocos-creator-mcp] patchDialogForDiscard failed: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class SceneTools implements ToolCategory {
|
||||
readonly categoryName = "scene";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "scene_get_hierarchy",
|
||||
description: "Get the node hierarchy of the current scene. Returns tree structure with uuid, name, active, and optionally components.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
includeComponents: {
|
||||
type: "boolean",
|
||||
description: "Include component info for each node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_open",
|
||||
description: "Open a scene by its asset UUID or database path (e.g. 'db://assets/scenes/Main.scene'). If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog that would block MCP. Pass force=true to bypass this guard.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scene: {
|
||||
type: "string",
|
||||
description: "Scene UUID or db:// path",
|
||||
},
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
|
||||
},
|
||||
},
|
||||
required: ["scene"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_save",
|
||||
description: "Save the currently open scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_get_list",
|
||||
description: "List all scene files in the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_close",
|
||||
description: "Close the current scene. If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog. Pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_get_current",
|
||||
description: "Get the name and UUID of the currently open scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
switch (toolName) {
|
||||
case "scene_get_hierarchy":
|
||||
return this.getHierarchy(args.includeComponents ?? false);
|
||||
case "scene_open":
|
||||
return this.openScene(args.scene, !!args.force);
|
||||
case "scene_save":
|
||||
return this.saveScene();
|
||||
case "scene_get_list":
|
||||
return this.getSceneList();
|
||||
case "scene_close":
|
||||
try {
|
||||
await ensureSceneSafeToSwitch(!!args.force);
|
||||
await (Editor.Message.request as any)("scene", "close-scene");
|
||||
return ok({ success: true });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
case "scene_get_current":
|
||||
return this.getHierarchy(false).then((r) => {
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
return ok({ success: true, sceneName: parsed.sceneName, sceneUuid: parsed.sceneUuid });
|
||||
}).catch((e) => err(String(e)));
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getHierarchy(includeComponents: boolean): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await Editor.Message.request(
|
||||
"scene",
|
||||
"execute-scene-script",
|
||||
{
|
||||
name: EXT_NAME,
|
||||
method: "getSceneHierarchy",
|
||||
args: [includeComponents],
|
||||
}
|
||||
);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
// Fallback: use query-node-tree
|
||||
try {
|
||||
const tree = await Editor.Message.request("scene", "query-node-tree");
|
||||
return ok({ success: true, hierarchy: tree });
|
||||
} catch (e2: any) {
|
||||
return err(e2.message || String(e2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async openScene(scene: string, force: boolean): Promise<ToolResult> {
|
||||
try {
|
||||
await ensureSceneSafeToSwitch(force);
|
||||
await Editor.Message.request("asset-db", "open-asset", scene);
|
||||
return ok({ success: true, scene });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async saveScene(): Promise<ToolResult> {
|
||||
try {
|
||||
// 現在のシーンが保存済みファイルか確認(新規シーンの場合はダイアログが出るのでスキップ)
|
||||
const scenes = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/**/*.scene",
|
||||
}).catch(() => []);
|
||||
|
||||
// シーン名を取得して既存シーンか判定
|
||||
const hierarchy = await Editor.Message.request(
|
||||
"scene", "execute-scene-script",
|
||||
{ name: "cocos-creator-mcp", method: "getSceneHierarchy", args: [false] }
|
||||
).catch(() => null);
|
||||
|
||||
const sceneName = hierarchy?.sceneName;
|
||||
const isNewScene = !sceneName || sceneName === "scene-2d" || sceneName === "Untitled";
|
||||
if (isNewScene) {
|
||||
return ok({ success: true, note: "New/untitled scene, skip save to avoid dialog" });
|
||||
}
|
||||
|
||||
// シーンがdirtyでない場合は保存不要
|
||||
const isDirty = await (Editor.Message.request as any)("scene", "query-is-dirty").catch(() => true);
|
||||
if (!isDirty) {
|
||||
return ok({ success: true, note: "Scene not dirty, skip save" });
|
||||
}
|
||||
|
||||
const result = await (Editor.Message.request as any)("scene", "save-scene", false);
|
||||
return ok({ success: true, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getSceneList(): Promise<ToolResult> {
|
||||
try {
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/**/*.scene",
|
||||
});
|
||||
const scenes = (results || []).map((a: any) => ({
|
||||
uuid: a.uuid,
|
||||
path: a.path || a.url,
|
||||
name: a.name,
|
||||
}));
|
||||
return ok({ success: true, scenes });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class SceneViewTools implements ToolCategory {
|
||||
readonly categoryName = "sceneView";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "view_change_gizmo_tool",
|
||||
description: "Change the gizmo tool (move, rotate, scale, rect).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["move", "rotate", "scale", "rect"], description: "Gizmo tool name" },
|
||||
},
|
||||
required: ["tool"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_gizmo_tool",
|
||||
description: "Get the currently active gizmo tool.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_change_gizmo_pivot",
|
||||
description: "Change gizmo pivot mode (center or pivot).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pivot: { type: "string", enum: ["center", "pivot"], description: "Pivot mode" },
|
||||
},
|
||||
required: ["pivot"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_gizmo_pivot",
|
||||
description: "Get the current gizmo pivot mode.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_change_gizmo_coordinate",
|
||||
description: "Change gizmo coordinate system (local or global).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
coordinate: { type: "string", enum: ["local", "global"], description: "Coordinate system" },
|
||||
},
|
||||
required: ["coordinate"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_gizmo_coordinate",
|
||||
description: "Get the current gizmo coordinate system.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_change_mode_2d_3d",
|
||||
description: "Switch between 2D and 3D view mode.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["2d", "3d"], description: "View mode" },
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_mode_2d_3d",
|
||||
description: "Get the current 2D/3D view mode.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_set_grid_visible",
|
||||
description: "Show or hide the scene grid.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
visible: { type: "boolean", description: "Whether to show the grid" },
|
||||
},
|
||||
required: ["visible"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_grid_visible",
|
||||
description: "Check if the scene grid is visible.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_focus_on_node",
|
||||
description: "Focus the scene camera on specific node(s).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuids: { type: "array", items: { type: "string" }, description: "Node UUIDs to focus on" },
|
||||
},
|
||||
required: ["uuids"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_get_status",
|
||||
description: "Get the current scene view status (gizmo tool, pivot, coordinate, grid, etc.).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_set_icon_gizmo_3d",
|
||||
description: "Toggle 3D icon gizmos on/off.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
required: ["enabled"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_icon_gizmo_3d",
|
||||
description: "Check if 3D icon gizmos are enabled.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_set_icon_gizmo_size",
|
||||
description: "Set the icon gizmo size.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { size: { type: "number" } },
|
||||
required: ["size"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_icon_gizmo_size",
|
||||
description: "Get the current icon gizmo size.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_align_with_view",
|
||||
description: "Align the selected node with the current camera view.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_align_view_with_node",
|
||||
description: "Align the camera view with the selected node.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_reset",
|
||||
description: "Reset the scene view to default state.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "view_change_gizmo_tool":
|
||||
await (Editor.Message.request as any)("scene", "change-gizmo-tool", args.tool);
|
||||
return ok({ success: true, tool: args.tool });
|
||||
case "view_query_gizmo_tool": {
|
||||
const tool = await (Editor.Message.request as any)("scene", "query-gizmo-tool-name");
|
||||
return ok({ success: true, tool });
|
||||
}
|
||||
case "view_change_gizmo_pivot":
|
||||
await (Editor.Message.request as any)("scene", "change-gizmo-pivot", args.pivot);
|
||||
return ok({ success: true, pivot: args.pivot });
|
||||
case "view_query_gizmo_pivot": {
|
||||
const pivot = await (Editor.Message.request as any)("scene", "query-gizmo-pivot");
|
||||
return ok({ success: true, pivot });
|
||||
}
|
||||
case "view_change_gizmo_coordinate":
|
||||
await (Editor.Message.request as any)("scene", "change-gizmo-coordinate", args.coordinate);
|
||||
return ok({ success: true, coordinate: args.coordinate });
|
||||
case "view_query_gizmo_coordinate": {
|
||||
const coord = await (Editor.Message.request as any)("scene", "query-gizmo-coordinate");
|
||||
return ok({ success: true, coordinate: coord });
|
||||
}
|
||||
case "view_change_mode_2d_3d":
|
||||
await (Editor.Message.request as any)("scene", "change-view-mode-2d-3d", args.mode);
|
||||
return ok({ success: true, mode: args.mode });
|
||||
case "view_query_mode_2d_3d": {
|
||||
const mode = await (Editor.Message.request as any)("scene", "query-view-mode-2d-3d");
|
||||
return ok({ success: true, mode });
|
||||
}
|
||||
case "view_set_grid_visible":
|
||||
await (Editor.Message.request as any)("scene", "set-grid-visible", args.visible);
|
||||
return ok({ success: true, visible: args.visible });
|
||||
case "view_query_grid_visible": {
|
||||
const visible = await (Editor.Message.request as any)("scene", "query-grid-visible");
|
||||
return ok({ success: true, visible });
|
||||
}
|
||||
case "view_focus_on_node":
|
||||
await (Editor.Message.request as any)("scene", "focus-camera-on-nodes", args.uuids);
|
||||
return ok({ success: true, uuids: args.uuids });
|
||||
case "view_get_status": {
|
||||
const [tool, pivot, coord, mode, grid] = await Promise.all([
|
||||
(Editor.Message.request as any)("scene", "query-gizmo-tool-name").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-gizmo-pivot").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-gizmo-coordinate").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-view-mode-2d-3d").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-grid-visible").catch(() => null),
|
||||
]);
|
||||
return ok({ success: true, tool, pivot, coordinate: coord, mode, gridVisible: grid });
|
||||
}
|
||||
case "view_set_icon_gizmo_3d":
|
||||
await (Editor.Message.request as any)("scene", "set-icon-gizmo-3d", args.enabled);
|
||||
return ok({ success: true, enabled: args.enabled });
|
||||
case "view_query_icon_gizmo_3d": {
|
||||
const enabled = await (Editor.Message.request as any)("scene", "query-is-icon-gizmo-3d");
|
||||
return ok({ success: true, enabled });
|
||||
}
|
||||
case "view_set_icon_gizmo_size":
|
||||
await (Editor.Message.request as any)("scene", "set-icon-gizmo-size", args.size);
|
||||
return ok({ success: true, size: args.size });
|
||||
case "view_query_icon_gizmo_size": {
|
||||
const size = await (Editor.Message.request as any)("scene", "query-icon-gizmo-size");
|
||||
return ok({ success: true, size });
|
||||
}
|
||||
case "view_align_with_view":
|
||||
await (Editor.Message.request as any)("scene", "align-with-view");
|
||||
return ok({ success: true });
|
||||
case "view_align_view_with_node":
|
||||
await (Editor.Message.request as any)("scene", "align-view-with-node");
|
||||
return ok({ success: true });
|
||||
case "view_reset":
|
||||
await (Editor.Message.request as any)("scene", "reset-scene-view");
|
||||
return ok({ success: true });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { BUILD_HASH } from "../mcp-server";
|
||||
|
||||
export class ServerTools implements ToolCategory {
|
||||
readonly categoryName = "server";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "server_query_ip_list",
|
||||
description: "Get the list of IP addresses the editor server is listening on.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_query_port",
|
||||
description: "Get the port number of the editor's built-in server.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_get_status",
|
||||
description: "Get the editor server status (IP, port, connectivity).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_get_build_hash",
|
||||
description: "Get the build hash of the MCP server. The hash is derived from the code content, so identical code always produces the same hash.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_check_connectivity",
|
||||
description: "Check if the editor server is reachable.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_get_network_interfaces",
|
||||
description: "Get detailed network interface information.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_check_code_sync",
|
||||
description: "Check if the running MCP extension code matches the latest build. Compares the runtime BUILD_HASH with the hash of dist/ files. Returns whether extension reload or CC restart is needed.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "server_query_ip_list": {
|
||||
const ips = await (Editor.Message.request as any)("server", "query-ip-list");
|
||||
return ok({ success: true, ips });
|
||||
}
|
||||
case "server_query_port": {
|
||||
const port = await (Editor.Message.request as any)("server", "query-port");
|
||||
return ok({ success: true, port });
|
||||
}
|
||||
case "server_get_status": {
|
||||
const [ips, port] = await Promise.all([
|
||||
(Editor.Message.request as any)("server", "query-ip-list").catch(() => []),
|
||||
(Editor.Message.request as any)("server", "query-port").catch(() => null),
|
||||
]);
|
||||
return ok({ success: true, ips, port, buildId: BUILD_HASH });
|
||||
}
|
||||
case "server_get_build_hash": {
|
||||
return ok({ success: true, buildHash: BUILD_HASH });
|
||||
}
|
||||
case "server_check_connectivity": {
|
||||
try {
|
||||
const port = await (Editor.Message.request as any)("server", "query-port");
|
||||
return ok({ success: true, reachable: true, port });
|
||||
} catch {
|
||||
return ok({ success: true, reachable: false });
|
||||
}
|
||||
}
|
||||
case "server_get_network_interfaces": {
|
||||
const os = require("os");
|
||||
const interfaces = os.networkInterfaces();
|
||||
return ok({ success: true, interfaces });
|
||||
}
|
||||
case "server_check_code_sync": {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const extDir = path.join(Editor.Project.path, "extensions", "cocos-creator-mcp", "dist");
|
||||
if (!fs.existsSync(extDir)) {
|
||||
return ok({ success: true, synced: false, note: "Extension dist/ not found", runtimeHash: BUILD_HASH });
|
||||
}
|
||||
const hash = crypto.createHash("sha256");
|
||||
const collectJs = (dir: string, prefix = ""): string[] => {
|
||||
let files: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) files = files.concat(collectJs(path.join(dir, entry.name), rel));
|
||||
else if (entry.name.endsWith(".js")) files.push(rel);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
for (const file of collectJs(extDir).sort()) {
|
||||
let content = fs.readFileSync(path.join(extDir, file), "utf8");
|
||||
// mcp-server.js の baked hash を postbuild 前と同じプレースホルダに逆置換
|
||||
// (postbuild は __BUILD_HASH__ が残った状態でハッシュ計算しているため、同じ入力にする)
|
||||
if (file === "mcp-server.js") {
|
||||
content = content.replace(/exports\.BUILD_HASH = "[a-f0-9]{12}"/, 'exports.BUILD_HASH = "__BUILD_HASH__"');
|
||||
}
|
||||
hash.update(content.replace(/__BUILD_HASH__/g, ""));
|
||||
}
|
||||
const diskHash = hash.digest("hex").substring(0, 12);
|
||||
const synced = diskHash === BUILD_HASH;
|
||||
return ok({
|
||||
success: true,
|
||||
synced,
|
||||
runtimeHash: BUILD_HASH,
|
||||
diskHash,
|
||||
action: synced ? "none" : "Extension reload or CC restart needed",
|
||||
});
|
||||
} catch (e: any) {
|
||||
return ok({ success: true, synced: false, error: e.message, runtimeHash: BUILD_HASH });
|
||||
}
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** MCP Tool definition (JSON Schema based) */
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Result returned from tool execution */
|
||||
export interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/** Interface that all tool categories must implement */
|
||||
export interface ToolCategory {
|
||||
readonly categoryName: string;
|
||||
getTools(): ToolDefinition[];
|
||||
execute(toolName: string, args: Record<string, any>): Promise<ToolResult>;
|
||||
}
|
||||
|
||||
/** JSON-RPC 2.0 request */
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number;
|
||||
method: string;
|
||||
params?: any;
|
||||
}
|
||||
|
||||
/** JSON-RPC 2.0 response */
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number | null;
|
||||
result?: any;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** Server configuration */
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
autoStart: boolean;
|
||||
autoArchiveRecordings: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ServerConfig = {
|
||||
port: 3000,
|
||||
autoStart: false,
|
||||
autoArchiveRecordings: false,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* MCP クライアントが JSON オブジェクトを文字列化して送信する問題への共通対策.
|
||||
* スキーマに type が未宣言のオブジェクト引数は文字列で届く場合がある.
|
||||
*/
|
||||
export function parseMaybeJson<T = any>(value: any): T {
|
||||
if (typeof value === "string") {
|
||||
try { return JSON.parse(value); } catch { /* string のまま */ }
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Prefab保存テスト — プロパティがPrefabに保持されるか検証
|
||||
*
|
||||
* 前提: CocosCreatorでcocos-creator-mcpサーバーが起動中、MainScene開いてる
|
||||
*/
|
||||
|
||||
const PORT = process.argv[2] || 3001;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
let rpcId = 0;
|
||||
|
||||
async function rpc(method, params = {}) {
|
||||
const res = await fetch(`${BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function callTool(name, args = {}) {
|
||||
const res = await rpc("tools/call", { name, arguments: args });
|
||||
if (res.error) return { _rpcError: res.error };
|
||||
const text = res.result?.content?.[0]?.text;
|
||||
return text ? JSON.parse(text) : res.result;
|
||||
}
|
||||
|
||||
async function sceneScript(method, args) {
|
||||
// Call execute-scene-script through a raw JSON-RPC (not via tool)
|
||||
// Actually we need to go through the MCP server... let's use node_set_property hack
|
||||
// Or better: add a scene_script tool call
|
||||
// For now, use the existing setComponentProperty that IS registered in scene.ts
|
||||
|
||||
// We can't call execute-scene-script directly from outside.
|
||||
// But we CAN call the node_set_property tool which internally calls setNodeProperty scene script.
|
||||
// For component property, we need a dedicated tool.
|
||||
|
||||
// Workaround: Use curl to call the Editor's Messages API if available
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("\n🧪 Prefab Property Persistence Test\n");
|
||||
|
||||
// Step 1: Create test node with Label
|
||||
console.log("1. Creating test node with Label...");
|
||||
const created = await callTool("node_create", {
|
||||
name: "PrefabPropertyTest",
|
||||
parent: "83A39G7PtBqLLEoVjvUo4h", // Canvas
|
||||
components: ["cc.Label"],
|
||||
});
|
||||
console.log(` Node UUID: ${created.uuid}`);
|
||||
const nodeUuid = created.uuid;
|
||||
|
||||
// Step 2: Set position (this should persist)
|
||||
console.log("2. Setting position to (100, 200, 0)...");
|
||||
await callTool("node_set_transform", {
|
||||
uuid: nodeUuid,
|
||||
position: { x: 100, y: 200, z: 0 },
|
||||
});
|
||||
|
||||
// Step 3: Verify position was set
|
||||
const info1 = await callTool("node_get_info", { uuid: nodeUuid });
|
||||
console.log(` Position: (${info1.data?.position?.x}, ${info1.data?.position?.y})`);
|
||||
console.log(` Components: ${info1.data?.components?.map(c => c.type).join(", ")}`);
|
||||
|
||||
// Step 4: Try to create prefab via Editor API
|
||||
console.log("3. Attempting to create prefab via Editor.Message...");
|
||||
|
||||
// We'll try the create-prefab scene message
|
||||
// This requires calling: Editor.Message.request("scene", "create-prefab", nodeUuid, url)
|
||||
// Since we don't have a tool for this, let's try different approaches:
|
||||
|
||||
// Approach A: Try "create-prefab" message (3 args: nodeUuid, url, ?)
|
||||
const prefabUrl = "db://assets/test/PrefabPropertyTest.prefab";
|
||||
|
||||
// We need to add this capability. For now, let's check if we can use
|
||||
// asset-db to create an asset
|
||||
console.log(` Target: ${prefabUrl}`);
|
||||
|
||||
// Actually, let me just check the scene script createPrefabFromNode
|
||||
// which IS registered in package.json scene methods
|
||||
// But wait - our own MCP's scene.ts doesn't have createPrefabFromNode
|
||||
|
||||
console.log("\n⚠️ Prefab creation tool not yet implemented in our MCP.");
|
||||
console.log(" This is what needs to be built for v0.5.\n");
|
||||
|
||||
// Step 5: Test what the Editor API returns for prefab-related messages
|
||||
console.log("4. Probing available prefab-related Editor Messages...");
|
||||
|
||||
const messages = [
|
||||
["scene", "create-prefab"],
|
||||
["scene", "save-prefab"],
|
||||
["asset-db", "create-asset"],
|
||||
];
|
||||
|
||||
for (const [target, msg] of messages) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0", id: ++rpcId, method: "tools/call",
|
||||
params: { name: "node_get_info", arguments: { uuid: "test" } }
|
||||
}),
|
||||
});
|
||||
console.log(` ${target}:${msg} — needs testing`);
|
||||
} catch (e) {
|
||||
console.log(` ${target}:${msg} — error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
console.log("\n5. Cleaning up test node...");
|
||||
await callTool("node_delete", { uuid: nodeUuid });
|
||||
console.log(" Deleted.\n");
|
||||
|
||||
console.log("═".repeat(50));
|
||||
console.log(" Summary:");
|
||||
console.log(" - Node creation with components: ✅");
|
||||
console.log(" - Position/transform setting: ✅");
|
||||
console.log(" - Component property setting: ❓ (needs component tool)");
|
||||
console.log(" - Prefab creation: ❓ (needs prefab tool)");
|
||||
console.log(" - Property persistence in prefab: ❓ (blocked by above)");
|
||||
console.log("═".repeat(50));
|
||||
console.log("\n→ v0.5 で component + prefab ツール実装後に再検証");
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("Fatal:", e); process.exit(1); });
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 独立测试服务器 - 模拟Cocos Creator MCP服务器的最小化实现
|
||||
* 用于测试stdio-bridge.js的基本功能
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const PORT = 3000;
|
||||
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
||||
|
||||
// 模拟的工具列表(从README中提取的164个工具中的几个示例)
|
||||
const MOCK_TOOLS = [
|
||||
{
|
||||
name: "scene_get_hierarchy",
|
||||
description: "Get the node tree (with optional component info)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
includeComponents: { type: "boolean", description: "Include component info in hierarchy" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "node_create",
|
||||
description: "Create node (with optional components)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Node name" },
|
||||
parentUuid: { type: "string", description: "Parent node UUID" }
|
||||
},
|
||||
required: ["name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "scene_save",
|
||||
description: "Save the current scene",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url === '/mcp') {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const request = JSON.parse(body);
|
||||
console.log('[TEST SERVER] Received:', request.method);
|
||||
|
||||
const response = {
|
||||
jsonrpc: "2.0",
|
||||
id: request.id
|
||||
};
|
||||
|
||||
switch (request.method) {
|
||||
case 'initialize':
|
||||
response.result = {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: "cocos-creator-mcp",
|
||||
version: "1.14.0"
|
||||
}
|
||||
};
|
||||
break;
|
||||
|
||||
case 'tools/list':
|
||||
response.result = {
|
||||
tools: MOCK_TOOLS
|
||||
};
|
||||
break;
|
||||
|
||||
case 'tools/call':
|
||||
const toolName = request.params.name;
|
||||
response.result = {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
tool: toolName,
|
||||
message: `Called ${toolName} successfully`
|
||||
})
|
||||
}]
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
response.error = {
|
||||
code: -32601,
|
||||
message: "Method not found"
|
||||
};
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(response));
|
||||
} catch (e) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
});
|
||||
} else if (req.method === 'GET' && req.url === '/health') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ status: 'ok', tools: MOCK_TOOLS.length }));
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`[TEST SERVER] Started on http://127.0.0.1:${PORT}/mcp`);
|
||||
console.log(`[TEST SERVER] Health check: http://127.0.0.1:${PORT}/health`);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[TEST SERVER] Shutting down...');
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./source",
|
||||
"types": [
|
||||
"node",
|
||||
"@cocos/creator-types/editor"
|
||||
]
|
||||
},
|
||||
"exclude": ["client", "test", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "YouleNexus",
|
||||
"uuid": "15252ac4-d24e-4b2c-86b0-51caaf24f779",
|
||||
"creator": {
|
||||
"version": "3.8.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"__version__": "1.3.9"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"__version__": "3.0.9",
|
||||
"game": {
|
||||
"name": "未知游戏",
|
||||
"app_id": "UNKNOW",
|
||||
"c_id": "0"
|
||||
},
|
||||
"appConfigMaps": [
|
||||
{
|
||||
"app_id": "UNKNOW",
|
||||
"config_id": "f9e835"
|
||||
}
|
||||
],
|
||||
"configs": [
|
||||
{
|
||||
"app_id": "UNKNOW",
|
||||
"config_id": "f9e835",
|
||||
"config_name": "Default",
|
||||
"config_remarks": "",
|
||||
"services": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"__version__": "1.0.1"
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"__version__": "1.0.12",
|
||||
"modules": {
|
||||
"configs": {
|
||||
"defaultConfig": {
|
||||
"name": "Default Config",
|
||||
"cache": {
|
||||
"base": {
|
||||
"_value": true
|
||||
},
|
||||
"gfx-webgl": {
|
||||
"_value": true
|
||||
},
|
||||
"gfx-webgl2": {
|
||||
"_value": true
|
||||
},
|
||||
"gfx-webgpu": {
|
||||
"_value": false
|
||||
},
|
||||
"animation": {
|
||||
"_value": true
|
||||
},
|
||||
"skeletal-animation": {
|
||||
"_value": false
|
||||
},
|
||||
"3d": {
|
||||
"_value": false
|
||||
},
|
||||
"meshopt": {
|
||||
"_value": false
|
||||
},
|
||||
"2d": {
|
||||
"_value": true
|
||||
},
|
||||
"sorting-2d": {
|
||||
"_value": false
|
||||
},
|
||||
"rich-text": {
|
||||
"_value": true
|
||||
},
|
||||
"mask": {
|
||||
"_value": true
|
||||
},
|
||||
"graphics": {
|
||||
"_value": true
|
||||
},
|
||||
"ui-skew": {
|
||||
"_value": false
|
||||
},
|
||||
"affine-transform": {
|
||||
"_value": true
|
||||
},
|
||||
"ui": {
|
||||
"_value": true
|
||||
},
|
||||
"particle": {
|
||||
"_value": false
|
||||
},
|
||||
"physics": {
|
||||
"_value": false,
|
||||
"_option": "physics-ammo"
|
||||
},
|
||||
"physics-ammo": {
|
||||
"_value": false,
|
||||
"_flags": {
|
||||
"LOAD_BULLET_MANUALLY": false
|
||||
}
|
||||
},
|
||||
"physics-cannon": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-physx": {
|
||||
"_value": false,
|
||||
"_flags": {
|
||||
"LOAD_PHYSX_MANUALLY": false
|
||||
}
|
||||
},
|
||||
"physics-builtin": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-2d": {
|
||||
"_value": true,
|
||||
"_option": "physics-2d-box2d"
|
||||
},
|
||||
"physics-2d-box2d": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-2d-box2d-wasm": {
|
||||
"_value": false,
|
||||
"_flags": {
|
||||
"LOAD_BOX2D_MANUALLY": false
|
||||
}
|
||||
},
|
||||
"physics-2d-builtin": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-2d-box2d-jsb": {
|
||||
"_value": false
|
||||
},
|
||||
"intersection-2d": {
|
||||
"_value": true
|
||||
},
|
||||
"primitive": {
|
||||
"_value": false
|
||||
},
|
||||
"profiler": {
|
||||
"_value": true
|
||||
},
|
||||
"occlusion-query": {
|
||||
"_value": false
|
||||
},
|
||||
"geometry-renderer": {
|
||||
"_value": false
|
||||
},
|
||||
"debug-renderer": {
|
||||
"_value": false
|
||||
},
|
||||
"particle-2d": {
|
||||
"_value": true
|
||||
},
|
||||
"audio": {
|
||||
"_value": true
|
||||
},
|
||||
"video": {
|
||||
"_value": true
|
||||
},
|
||||
"webview": {
|
||||
"_value": true
|
||||
},
|
||||
"tween": {
|
||||
"_value": true
|
||||
},
|
||||
"websocket": {
|
||||
"_value": false
|
||||
},
|
||||
"websocket-server": {
|
||||
"_value": false
|
||||
},
|
||||
"terrain": {
|
||||
"_value": false
|
||||
},
|
||||
"light-probe": {
|
||||
"_value": false
|
||||
},
|
||||
"tiled-map": {
|
||||
"_value": true
|
||||
},
|
||||
"vendor-google": {
|
||||
"_value": false
|
||||
},
|
||||
"spine": {
|
||||
"_value": true,
|
||||
"_option": "spine-3.8"
|
||||
},
|
||||
"spine-3.8": {
|
||||
"_value": true,
|
||||
"_flags": {
|
||||
"LOAD_SPINE_MANUALLY": false
|
||||
}
|
||||
},
|
||||
"spine-4.2": {
|
||||
"_value": false,
|
||||
"_flags": {
|
||||
"LOAD_SPINE_MANUALLY": false
|
||||
}
|
||||
},
|
||||
"dragon-bones": {
|
||||
"_value": true
|
||||
},
|
||||
"marionette": {
|
||||
"_value": false
|
||||
},
|
||||
"procedural-animation": {
|
||||
"_value": false
|
||||
},
|
||||
"custom-pipeline-post-process": {
|
||||
"_value": false
|
||||
},
|
||||
"render-pipeline": {
|
||||
"_value": true,
|
||||
"_option": "custom-pipeline"
|
||||
},
|
||||
"custom-pipeline": {
|
||||
"_value": true
|
||||
},
|
||||
"legacy-pipeline": {
|
||||
"_value": false
|
||||
},
|
||||
"xr": {
|
||||
"_value": false
|
||||
}
|
||||
},
|
||||
"includeModules": [
|
||||
"2d",
|
||||
"affine-transform",
|
||||
"animation",
|
||||
"audio",
|
||||
"base",
|
||||
"custom-pipeline",
|
||||
"dragon-bones",
|
||||
"gfx-webgl",
|
||||
"gfx-webgl2",
|
||||
"graphics",
|
||||
"intersection-2d",
|
||||
"mask",
|
||||
"particle-2d",
|
||||
"physics-2d-box2d",
|
||||
"profiler",
|
||||
"rich-text",
|
||||
"spine-3.8",
|
||||
"tiled-map",
|
||||
"tween",
|
||||
"ui",
|
||||
"video",
|
||||
"webview"
|
||||
],
|
||||
"noDeprecatedFeatures": {
|
||||
"value": false,
|
||||
"version": ""
|
||||
},
|
||||
"flags": {}
|
||||
}
|
||||
},
|
||||
"globalConfigKey": "defaultConfig"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"__version__": "1.0.1",
|
||||
"information": {
|
||||
"customSplash": {
|
||||
"id": "customSplash",
|
||||
"label": "customSplash",
|
||||
"enable": true,
|
||||
"customSplash": {
|
||||
"complete": false,
|
||||
"form": "https://creator-api.cocos.com/api/form/show?sid=1316b2c5c5aea2b1143818ff1b28cd92"
|
||||
}
|
||||
},
|
||||
"removeSplash": {
|
||||
"id": "removeSplash",
|
||||
"label": "removeSplash",
|
||||
"enable": true,
|
||||
"removeSplash": {
|
||||
"complete": false,
|
||||
"form": "https://creator-api.cocos.com/api/form/show?sid=1316b2c5c5aea2b1143818ff1b28cd92"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"__version__": "1.0.4"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"__version__": "1.0.6"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
/* Base configuration. Do not edit this field. */
|
||||
"extends": "./temp/tsconfig.cocos.json",
|
||||
|
||||
/* Add your custom configuration here. */
|
||||
"compilerOptions": {
|
||||
"strict": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\gameabc.min.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\gamemain.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Game_Surface_1_Event.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\niuniu_surface_Event.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Const.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Data.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Desk.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Func.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Game.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\GameUI.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\jquery.min.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Logic.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\minhttp.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Net.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\niuniu_surface_Event.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Player.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Utl_Input.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\Utl_Output.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\Surface\youle.develop.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\banWords.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\SubGame_Config.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\SubGame_modify.js
|
||||
E:\YouLeGames\client\games2\Projects\Game_Surface_1\js\SubGame\Banwords.js
|
||||
@@ -0,0 +1,6 @@
|
||||
LayerStartID=620
|
||||
SpiritStartID=3001
|
||||
ImgResStartID=1
|
||||
TxtResStartID=200
|
||||
VoiResStartID=1
|
||||
GameStartFile=index.html
|
||||
@@ -0,0 +1 @@
|
||||
var app_getbattery=1;
|
||||
@@ -0,0 +1 @@
|
||||
var app_version=0;var app_gameconfig='1';var app_gamedir='1';var app_gamestart='1';var app_agent='1';var app_appversion='1';var app_market='2';var app_channel='1';var app_Launchtype=0; var app_getwifisignalLevel=1;var app_gamename=0;
|
||||
@@ -0,0 +1 @@
|
||||
var app_gamesname=new Array('1');
|
||||
@@ -0,0 +1 @@
|
||||
var app_getnetwork=1;
|
||||
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 809 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 358 B |
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 689 B |