commit efd3330922df9b5a0586d7d720f9eb75426b4e4c Author: winlifes <110321103+Winlifes@users.noreply.github.com> Date: Wed Apr 15 17:43:04 2026 +0800 Initial commit: funplay cocos mcp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b66a1a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +node_modules/ +temp/ +Temp/ +Library/ +library/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..514ceef --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# Funplay Cocos MCP + +一个嵌入在 `Cocos Creator 3.x` 扩展里的 MCP Server,对齐了你现有 `unity-mcp` 的核心思路: + +- 编辑器内嵌 HTTP MCP 服务 +- `execute_javascript` 作为统一主工具 +- `tools / resources / prompts` 三层能力 +- 场景树、节点、文件系统、项目上下文的统一访问 + +当前实现更偏向 Unity MCP 的 `core` 能力集,而不是一次性把全部 Unity 工具逐个照搬。 + +## 已实现能力 + +- MCP 协议: + - `initialize` + - `tools/list` + - `tools/call` + - `resources/list` + - `resources/read` + - `resources/templates/list` + - `prompts/list` + - `prompts/get` +- 核心工具: + - `execute_javascript` + - `execute_scene_script` + - `execute_editor_script` + - `get_scene_info` + - `get_hierarchy` + - `find_nodes` + - `inspect_node` + - `list_components` + - `inspect_component` + - `list_cameras` + - `list_animations` + - `play_animation` + - `stop_animation` + - `get_project_info` + - `list_scenes` + - `open_scene` + - `list_prefabs` + - `list_assets` + - `inspect_asset` + - `open_asset` + - `select_asset` + - `get_editor_selection` + - `read_file` + - `get_file_snippet` + - `write_file` + - `replace_in_file` + - `search_files` + - `list_directory` + - `exists` + - `refresh_assets` + - `run_script_diagnostics` + - `get_script_diagnostic_context` + - `capture_desktop_screenshot` + - `capture_editor_screenshot` + - `capture_scene_screenshot` + - `capture_game_screenshot` + - `capture_preview_screenshot` + - `list_editor_windows` + - `get_runtime_state` + - `pause_runtime` + - `resume_runtime` + - `set_time_scale` + - `emit_node_event` + - `simulate_button_click` + - `invoke_component_method` + - `simulate_mouse_click` + - `simulate_mouse_drag` + - `simulate_key_press` + - `simulate_key_combo` + - `simulate_preview_input` +- `full` 模式附加工具: + - `create_node` + - `delete_node` + - `set_node_transform` + - `add_component` + - `remove_component` + - `set_component_property` + - `reset_component_property` + - `create_canvas` + - `create_label` + - `create_button` + - `create_sprite` + - `create_camera` + - `set_camera_properties` + - `add_animation_clip` + - `instantiate_prefab` + - `run_scene_asset` + - `delete_asset` +- 资源: + - `cocos://project/context` + - `cocos://project/summary` + - `cocos://scene/active` + - `cocos://scene/current` + - `cocos://selection/current` + - `cocos://selection/asset` + - `cocos://errors/scripts` + - `cocos://mcp/interactions` + +## 安装方式 + +把当前目录作为 Cocos Creator 扩展放到: + +- 项目级:`<你的项目>/extensions/funplay-cocos-mcp` +- 全局级:`/funplay-cocos-mcp` + +然后重启 Cocos Creator,扩展会默认自动启动 MCP 服务。 + +默认地址: + +- `http://127.0.0.1:8765/` + +## 图形化面板 + +扩展启用后,可以在 Cocos Creator 顶部菜单打开: + +- `Funplay -> MCP Server` + +面板里可以直接完成: + +- 启动 / 停止 / 重启 MCP Server +- 查看当前 URL、端口、Profile、项目名 +- 保存 `host / port / toolProfile / autostart` 配置 +- 一键写入 MCP Client 配置 +- 复制 Codex TOML 或 JSON MCP Client 配置 +- 查看工具列表、资源列表、最近交互日志 +- 直接从 Cocos 里调用工具并填写 JSON 参数 +- Quick Actions 优先给出 `execute_javascript` +- 一键测试 `execute_javascript`、`get_project_info`、`get_scene_info`、`get_hierarchy`、截图、诊断等常用工具 + +一键配置当前支持: + +- Claude Code / Claude Desktop:`~/.claude.json` +- Cursor:`~/.cursor/mcp.json` +- VS Code:`~/.vscode/mcp.json` +- Trae:`~/.trae/mcp.json` +- Kiro:`~/.kiro/settings/mcp.json` +- Codex:`~/.codex/config.toml` + +面板会使用当前端口写入 `funplay_cocos` MCP server。写入后请重启对应客户端。 + +## 可选配置 + +在 Cocos 项目根目录放一个 `funplay-cocos-mcp.config.json`: + +```json +{ + "host": "127.0.0.1", + "port": 8765, + "toolProfile": "core", + "autostart": true, + "maxInteractionLogEntries": 50 +} +``` + +也支持环境变量: + +- `COCOS_MCP_HOST` +- `COCOS_MCP_PORT` +- `COCOS_MCP_PROFILE` + +## MCP Client 示例 + +### Codex + +```toml +[mcp_servers.funplay_cocos] +url = "http://127.0.0.1:8765/" +``` + +### Claude / Cursor + +```json +{ + "mcpServers": { + "funplay_cocos": { + "url": "http://127.0.0.1:8765/" + } + } +} +``` + +## 推荐首测 + +先在 MCP Client 里调用: + +1. `get_project_info` +2. `get_scene_info` +3. `get_hierarchy` +4. `resources/read` with `cocos://project/context` +5. `execute_javascript`(`context: "scene"`) +6. `execute_javascript`(`context: "editor"`) +7. `run_script_diagnostics` +8. `capture_desktop_screenshot` +9. `list_components` +10. `get_script_diagnostic_context` +11. `get_runtime_state` +12. `capture_editor_screenshot` +13. `list_editor_windows` +14. `capture_scene_screenshot` +15. `simulate_mouse_click` + +其中 `execute_javascript` 是统一主工具,`execute_scene_script` / `execute_editor_script` 主要保留给兼容调用。 + +`execute_javascript` 示例,场景上下文: + +```json +{ + "context": "scene", + "code": "return { sceneName: scene.name, rootCount: scene.children.length };" +} +``` + +`execute_javascript` 示例,编辑器上下文: + +```json +{ + "context": "editor", + "code": "return { projectPath: context.projectPath, toolCount: helpers.listTools().length };" +} +``` + +`execute_scene_script.code` 兼容示例: + +```js +return { + sceneName: scene.name, + rootChildren: scene.children.map((node) => node.name), +}; +``` + +## 和 Unity MCP 的对应关系 + +- Unity `execute_code` → Cocos `execute_javascript` +- Unity `execute_code` 的场景/编辑器兼容拆分入口 → `execute_scene_script` / `execute_editor_script` +- Unity `get_scene_info` → Cocos `get_scene_info` +- Unity `get_hierarchy` → Cocos `get_hierarchy` +- Unity `read_file/write_file/search_files` → 同名 Cocos 文件工具 +- Unity `resources/prompts` → 同结构的 Cocos 资源与提示词 +- Unity `core/full` → Cocos `toolProfile=core|full` + +## 当前边界 + +这版已经进入第二阶段,补上了: + +- 资产查询 / 打开 / 删除 / 选中 +- TypeScript 脚本诊断 +- 本机桌面截图回传 MCP image + +现在第三段也补上了: + +- 组件增删查改 +- Scene / Prefab 资产工具 +- 脚本修复辅助链路:`get_script_diagnostic_context` + `replace_in_file` + +第四段继续补上了: + +- UI 专用工具:Canvas / Label / Button / Sprite 创建 +- Camera 专用工具:列出、创建、属性设置 +- Animation 专用工具:列出、添加 Clip、播放、停止 +- 运行态控制:暂停、恢复、时间缩放、状态查询 +- 输入/交互模拟:按钮点击、节点事件、组件方法调用 +- 截图增强:桌面截图、Editor 窗口截图、场景截图别名 + +这次把之前两块缺口也补了: + +- `capture_scene_screenshot` 现在优先按 Editor 内部面板区域裁剪,不再只是整窗截图别名 +- 新增 `capture_game_screenshot` / `capture_preview_screenshot` +- 新增 `list_editor_windows` 方便先看 Cocos/Electron 当前有哪些窗口 +- 新增基于 Electron `webContents.sendInputEvent` 的底层输入注入: + - `simulate_mouse_click` + - `simulate_mouse_drag` + - `simulate_key_press` + - `simulate_key_combo` + - `simulate_preview_input` + +暂时还没有直接补齐 Unity MCP 里的这些能力: + +- 对 Scene/Game 面板做“语义级”识别仍然是 best-effort,依赖当前 Cocos 面板 DOM 结构 +- Preview/Simulator 底层输入已经支持,但不同窗口标题和焦点状态可能需要先用 `list_editor_windows` 确认目标 +- 动画曲线/状态机深度编辑 + +如果你要,我下一步可以继续补下一阶段,专门给你做: + +1. `SceneView/GameView 精确截图裁剪` +2. `Preview/Simulator 键鼠事件注入` +3. `动画曲线和状态机编辑` +4. `更像 unity-mcp 的完整工具分层` diff --git a/browser.js b/browser.js new file mode 100644 index 0000000..8fcdb15 --- /dev/null +++ b/browser.js @@ -0,0 +1,351 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const manifest = require('./package.json'); +const { configureTarget, getTargetStatuses } = require('./lib/client-config'); +const { loadConfig, getProjectPath, getProjectName, getCocosVersion } = require('./lib/config'); +const { McpServer } = require('./lib/server'); +const { createToolRegistry } = require('./lib/tool-registry'); +const { ResourceProvider } = require('./lib/resources'); +const { PromptProvider } = require('./lib/prompts'); +const { InteractionLog } = require('./lib/interaction-log'); + +const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp'; +const LOG_PREFIX = '[Funplay Cocos MCP]'; +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + +class ExtensionService { + constructor() { + this.config = null; + this.server = null; + this.toolRegistry = null; + this.resourceProvider = null; + this.promptProvider = null; + this.interactionLog = new InteractionLog(); + } + + load() { + console.log(`${LOG_PREFIX} Extension loading...`); + this.reloadRuntime(); + if (this.config.autostart) { + console.log(`${LOG_PREFIX} Autostart is enabled, starting MCP server.`); + return this.startServer(); + } + console.log(`${LOG_PREFIX} Autostart is disabled. MCP server is idle.`); + return this.getStatus(); + } + + unload() { + console.log(`${LOG_PREFIX} Extension unloading...`); + if (this.server) { + this.server.stop(); + this.server = null; + } + console.log(`${LOG_PREFIX} Extension unloaded.`); + } + + openPanel() { + if (!global.Editor || !Editor.Panel || typeof Editor.Panel.open !== 'function') { + throw new Error('Editor.Panel.open is unavailable in this Cocos extension host.'); + } + return Editor.Panel.open(EXTENSION_NAME); + } + + reloadRuntime() { + this.config = loadConfig(); + console.log( + `${LOG_PREFIX} Runtime config loaded: host=${this.config.host}, port=${this.config.port}, ` + + `profile=${this.config.toolProfile}, autostart=${this.config.autostart}` + ); + this.interactionLog = new InteractionLog(this.config.maxInteractionLogEntries); + const sceneBridge = { + call: async (method, payload) => { + if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { + throw new Error('Editor.Message.request is unavailable in the Cocos extension host.'); + } + + return await Editor.Message.request('scene', 'execute-scene-script', { + name: EXTENSION_NAME, + method, + args: [payload || {}], + }); + }, + }; + + const runtimeContext = () => ({ + extensionName: EXTENSION_NAME, + version: manifest.version || '0.0.0', + config: this.config, + projectPath: getProjectPath(), + projectName: getProjectName(), + cocosVersion: getCocosVersion(), + packagePath: path.dirname(__filename), + }); + + this.toolRegistry = createToolRegistry({ + getRuntimeContext: runtimeContext, + interactionLog: this.interactionLog, + sceneBridge, + editorExecutor: async (payload) => await this.executeEditorScript(payload, runtimeContext), + }); + this.resourceProvider = new ResourceProvider(runtimeContext, sceneBridge, this.interactionLog); + this.promptProvider = new PromptProvider(runtimeContext); + } + + async startServer() { + if (this.server && this.server.isRunning()) { + console.log(`${LOG_PREFIX} Start requested but MCP server is already running at ${this.getStatus().url}`); + return this.getStatus(); + } + + console.log(`${LOG_PREFIX} Starting MCP server...`); + this.reloadRuntime(); + this.server = new McpServer({ + config: this.config, + interactionLog: this.interactionLog, + toolRegistry: this.toolRegistry, + resourceProvider: this.resourceProvider, + promptProvider: this.promptProvider, + serverName: `Funplay Cocos MCP - ${getProjectName()}`, + serverVersion: manifest.version || '0.0.0', + }); + + await this.server.start(); + console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`); + return this.getStatus(); + } + + async stopServer() { + console.log(`${LOG_PREFIX} Stop requested.`); + if (this.server) { + await this.server.stop(); + this.server = null; + console.log(`${LOG_PREFIX} MCP server stopped.`); + } else { + console.log(`${LOG_PREFIX} Stop requested but MCP server was not running.`); + } + return this.getStatus(); + } + + async restartServer() { + console.log(`${LOG_PREFIX} Restart requested.`); + await this.stopServer(); + const status = await this.startServer(); + console.log(`${LOG_PREFIX} Restart completed. MCP server running=${status.running}, url=${status.url}`); + return status; + } + + getStatus() { + return { + running: Boolean(this.server && this.server.isRunning()), + host: this.config.host, + port: this.config.port, + toolProfile: this.config.toolProfile, + autostart: this.config.autostart, + projectPath: getProjectPath(), + projectName: getProjectName(), + cocosVersion: getCocosVersion(), + url: `http://${this.config.host}:${this.config.port}/`, + }; + } + + getPanelState() { + this.ensureRuntime(); + const status = this.getStatus(); + const tools = this.toolRegistry.listTools(); + const resources = this.resourceProvider.listResources(); + const prompts = this.promptProvider.listPrompts(); + + return { + status, + tools, + resources, + prompts, + recentInteractions: this.interactionLog.list(20), + config: this.config, + clientConfig: this.getClientConfig(), + clientTargets: getTargetStatuses(this.config), + }; + } + + listToolsForPanel() { + this.ensureRuntime(); + return this.toolRegistry.listTools(); + } + + async callToolFromPanel(name, args) { + this.ensureRuntime(); + console.log(`${LOG_PREFIX} Panel calling tool: ${name}`); + return await this.toolRegistry.callTool(name, args || {}); + } + + async executeEditorScript(payload, runtimeContext) { + const code = String(payload && payload.code || ''); + if (!code.trim()) { + throw new Error('code is required.'); + } + + const args = payload && payload.args ? payload.args : {}; + const context = runtimeContext(); + const helpers = { + getStatus: () => this.getStatus(), + listTools: () => this.toolRegistry.listTools(), + readResource: async (uri) => await this.resourceProvider.readResource(uri), + callTool: async (name, toolArgs) => await this.toolRegistry.callTool(name, toolArgs || {}), + listClientTargets: () => getTargetStatuses(this.config), + getClientConfig: () => this.getClientConfig(), + configureClient: async (targetId) => this.configureClient(targetId), + }; + + const runner = new AsyncFunction( + 'require', + 'Editor', + 'args', + 'context', + 'helpers', + 'fs', + 'path', + 'os', + ` + const module = { exports: {} }; + const exports = module.exports; + ${code} + if (typeof run === 'function') { + return await run({ Editor, args, context, helpers, fs, path, os, require }); + } + if (typeof module.exports === 'function') { + return await module.exports({ Editor, args, context, helpers, fs, path, os, require }); + } + if (module.exports && typeof module.exports.run === 'function') { + return await module.exports.run({ Editor, args, context, helpers, fs, path, os, require }); + } + ` + ); + + return await runner(require, global.Editor, args, context, helpers, fs, path, os); + } + + async readResourceFromPanel(uri) { + this.ensureRuntime(); + console.log(`${LOG_PREFIX} Panel reading resource: ${uri}`); + return await this.resourceProvider.readResource(uri); + } + + getClientConfig() { + const url = `http://${this.config.host}:${this.config.port}/`; + return { + url, + codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`, + json: JSON.stringify({ + mcpServers: { + funplay_cocos: { + url, + }, + }, + }, null, 2), + }; + } + + configureClient(targetId) { + this.ensureRuntime(); + console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`); + const result = configureTarget(this.config, targetId); + console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`); + return { + ...result, + clientTargets: getTargetStatuses(this.config), + }; + } + + async saveConfig(partialConfig) { + this.ensureRuntime(); + const nextPort = partialConfig && partialConfig.port !== undefined + ? Number(partialConfig.port) + : this.config.port; + const nextMaxEntries = partialConfig && partialConfig.maxInteractionLogEntries !== undefined + ? Number(partialConfig.maxInteractionLogEntries) + : this.config.maxInteractionLogEntries; + const nextConfig = { + host: partialConfig && partialConfig.host ? String(partialConfig.host) : this.config.host, + port: Number.isInteger(nextPort) && nextPort > 0 && nextPort <= 65535 ? nextPort : this.config.port, + toolProfile: partialConfig && partialConfig.toolProfile + ? (partialConfig.toolProfile === 'full' ? 'full' : 'core') + : this.config.toolProfile, + autostart: partialConfig && typeof partialConfig.autostart === 'boolean' + ? partialConfig.autostart + : this.config.autostart, + maxInteractionLogEntries: Number.isInteger(nextMaxEntries) + ? Math.max(10, Math.min(500, nextMaxEntries)) + : this.config.maxInteractionLogEntries, + }; + + const configPath = this.config.configPath; + fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2) + '\n', 'utf8'); + const wasRunning = Boolean(this.server && this.server.isRunning()); + if (wasRunning) { + await this.stopServer(); + } + this.reloadRuntime(); + if (wasRunning) { + await this.startServer(); + } + return this.getPanelState(); + } + + ensureRuntime() { + if (!this.config || !this.toolRegistry || !this.resourceProvider || !this.promptProvider) { + this.reloadRuntime(); + } + } +} + +const service = new ExtensionService(); + +module.exports = { + load() { + return service.load(); + }, + unload() { + return service.unload(); + }, + methods: { + openPanel() { + return service.openPanel(); + }, + startServer() { + return service.startServer(); + }, + stopServer() { + return service.stopServer(); + }, + restartServer() { + return service.restartServer(); + }, + getStatus() { + return service.getStatus(); + }, + getPanelState() { + return service.getPanelState(); + }, + saveConfig(config) { + return service.saveConfig(config); + }, + listToolsForPanel() { + return service.listToolsForPanel(); + }, + callToolFromPanel(name, args) { + return service.callToolFromPanel(name, args); + }, + readResourceFromPanel(uri) { + return service.readResourceFromPanel(uri); + }, + getClientConfig() { + return service.getClientConfig(); + }, + configureClient(targetId) { + return service.configureClient(targetId); + }, + }, +}; diff --git a/lib/assets.js b/lib/assets.js new file mode 100644 index 0000000..dc8ebd2 --- /dev/null +++ b/lib/assets.js @@ -0,0 +1,182 @@ +'use strict'; + +async function safeRequest(channel, method, ...args) { + if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { + throw new Error('Editor.Message.request is unavailable in the Cocos extension host.'); + } + return await Editor.Message.request(channel, method, ...args); +} + +function buildAssetTargetCandidates(uuidOrPath) { + const raw = String(uuidOrPath || '').trim().replace(/\\/g, '/'); + const candidates = []; + const add = (value) => { + if (value && !candidates.includes(value)) { + candidates.push(value); + } + }; + + add(raw); + + if (raw.startsWith('assets/')) { + add(`db://${raw}`); + } else if (raw.startsWith('/assets/')) { + add(`db://${raw.slice(1)}`); + } + + if (raw.includes('/assets/')) { + add(`db://assets/${raw.split('/assets/').pop()}`); + } + + if (raw.startsWith('db://assets/') && !raw.match(/\.[a-z0-9]+$/i)) { + add(`${raw}.scene`); + add(`${raw}.prefab`); + add(`${raw}.ts`); + } + + return candidates; +} + +async function requestFirst(method, uuidOrPath) { + const candidates = buildAssetTargetCandidates(uuidOrPath); + let lastError = null; + + for (const candidate of candidates) { + try { + const result = await safeRequest('asset-db', method, candidate); + if (result != null) { + return result; + } + } catch (error) { + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + return null; +} + +async function listAssets(options = {}) { + const payload = {}; + if (options.pattern) { + payload.pattern = options.pattern; + } + if (options.ccType) { + payload.ccType = options.ccType; + } + const result = await safeRequest('asset-db', 'query-assets', payload); + return Array.isArray(result) ? result : []; +} + +async function queryAssetInfo(uuidOrPath) { + if (!uuidOrPath) { + throw new Error('Asset uuid or path is required.'); + } + + const direct = await requestFirst('query-asset-info', uuidOrPath); + if (direct) { + return direct; + } + + const url = await queryAssetUrl(uuidOrPath).catch(() => null); + if (url) { + const fromUrl = await safeRequest('asset-db', 'query-asset-info', url); + if (fromUrl) { + return fromUrl; + } + } + + throw new Error(`Asset not found: ${uuidOrPath}`); +} + +async function queryAssetMeta(uuidOrPath) { + if (!uuidOrPath) { + throw new Error('Asset uuid or path is required.'); + } + + const direct = await requestFirst('query-asset-meta', uuidOrPath); + if (direct) { + return direct; + } + + const info = await queryAssetInfo(uuidOrPath); + return await safeRequest('asset-db', 'query-asset-meta', info.uuid || info.url || uuidOrPath); +} + +async function queryAssetData(uuidOrPath) { + if (!uuidOrPath) { + throw new Error('Asset uuid or path is required.'); + } + + const direct = await requestFirst('query-asset-data', uuidOrPath); + if (direct) { + return direct; + } + + const info = await queryAssetInfo(uuidOrPath); + return await safeRequest('asset-db', 'query-asset-data', info.uuid || info.url || uuidOrPath); +} + +async function queryAssetUrl(uuidOrPath) { + if (!uuidOrPath) { + throw new Error('Asset uuid or path is required.'); + } + const result = await requestFirst('query-url', uuidOrPath); + if (result) { + return result; + } + throw new Error(`Asset URL not found: ${uuidOrPath}`); +} + +async function openAsset(uuidOrPath) { + const info = await queryAssetInfo(uuidOrPath); + await safeRequest('asset-db', 'open-asset', info.uuid || uuidOrPath); + return info; +} + +async function deleteAsset(uuidOrPath) { + let url = String(uuidOrPath || ''); + if (!url.startsWith('db://')) { + const info = await queryAssetInfo(uuidOrPath); + url = info.url || (await queryAssetUrl(info.uuid || uuidOrPath)); + } + + await safeRequest('asset-db', 'delete-asset', url); + return { deleted: true, url }; +} + +function selectAsset(uuid) { + if (!global.Editor || !Editor.Selection || typeof Editor.Selection.select !== 'function') { + throw new Error('Editor.Selection.select is unavailable in this Cocos environment.'); + } + + Editor.Selection.clear('asset'); + Editor.Selection.select('asset', uuid); + return { selected: true, uuid }; +} + +function getCurrentSelection() { + if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') { + throw new Error('Editor.Selection API is unavailable in this Cocos environment.'); + } + + return { + asset: Editor.Selection.getSelected('asset') || '', + node: Editor.Selection.getSelected('node') || '', + type: typeof Editor.Selection.getLastSelectedType === 'function' ? Editor.Selection.getLastSelectedType() : '', + }; +} + +module.exports = { + deleteAsset, + getCurrentSelection, + listAssets, + openAsset, + queryAssetData, + queryAssetInfo, + queryAssetMeta, + queryAssetUrl, + selectAsset, +}; diff --git a/lib/client-config.js b/lib/client-config.js new file mode 100644 index 0000000..b412b5a --- /dev/null +++ b/lib/client-config.js @@ -0,0 +1,156 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const SERVER_NAME = 'funplay_cocos'; + +function ensureParent(filePath) { + const dir = path.dirname(filePath); + if (dir && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function readJson(filePath) { + if (!fs.existsSync(filePath)) { + return {}; + } + + const text = fs.readFileSync(filePath, 'utf8').trim(); + if (!text) { + return {}; + } + + return JSON.parse(text); +} + +function writeJson(filePath, value) { + ensureParent(filePath); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8'); +} + +function configureJsonTarget(target) { + const root = readJson(target.configPath); + const rootKey = target.rootKey || 'mcpServers'; + if (!root[rootKey] || typeof root[rootKey] !== 'object' || Array.isArray(root[rootKey])) { + root[rootKey] = {}; + } + root[rootKey][SERVER_NAME] = target.entry; + writeJson(target.configPath, root); +} + +function configureTomlTarget(target) { + ensureParent(target.configPath); + const sectionHeader = `[mcp_servers.${SERVER_NAME}]`; + const section = `${sectionHeader}\nurl = "${target.url}"\n`; + let content = fs.existsSync(target.configPath) ? fs.readFileSync(target.configPath, 'utf8') : ''; + + if (content.includes(sectionHeader)) { + const start = content.indexOf(sectionHeader); + const afterHeader = start + sectionHeader.length; + const nextSection = content.indexOf('\n[', afterHeader); + const end = nextSection >= 0 ? nextSection : content.length; + content = `${content.slice(0, start)}${section}${content.slice(end)}`; + } else { + if (content.length > 0 && !content.endsWith('\n')) { + content += '\n'; + } + if (content.length > 0) { + content += '\n'; + } + content += section; + } + + fs.writeFileSync(target.configPath, content, 'utf8'); +} + +function buildTargets(config) { + const home = os.homedir(); + const url = `http://${config.host}:${config.port}/`; + + return [ + { + id: 'claude_code', + name: 'Claude Code / Claude Desktop', + configPath: path.join(home, '.claude.json'), + rootKey: 'mcpServers', + entry: { type: 'http', url }, + }, + { + id: 'cursor', + name: 'Cursor', + configPath: path.join(home, '.cursor', 'mcp.json'), + rootKey: 'mcpServers', + entry: { url }, + }, + { + id: 'vscode', + name: 'VS Code', + configPath: path.join(home, '.vscode', 'mcp.json'), + rootKey: 'servers', + entry: { type: 'http', url }, + }, + { + id: 'trae', + name: 'Trae', + configPath: path.join(home, '.trae', 'mcp.json'), + rootKey: 'mcpServers', + entry: { url }, + }, + { + id: 'kiro', + name: 'Kiro', + configPath: path.join(home, '.kiro', 'settings', 'mcp.json'), + rootKey: 'mcpServers', + entry: { type: 'http', url }, + }, + { + id: 'codex', + name: 'Codex', + configPath: path.join(home, '.codex', 'config.toml'), + isToml: true, + url, + }, + ]; +} + +function getTargetStatuses(config) { + return buildTargets(config).map((target) => ({ + id: target.id, + name: target.name, + configPath: target.configPath, + configured: fs.existsSync(target.configPath), + isToml: Boolean(target.isToml), + })); +} + +function configureTarget(config, targetId) { + const targets = buildTargets(config); + const target = targets.find((item) => item.id === targetId); + if (!target) { + throw new Error(`Unknown MCP client target: ${targetId}`); + } + + if (target.isToml) { + configureTomlTarget(target); + } else { + configureJsonTarget(target); + } + + return { + id: target.id, + name: target.name, + configPath: target.configPath, + configured: true, + restartHint: `Please restart ${target.name} for the MCP configuration to take effect.`, + }; +} + +module.exports = { + SERVER_NAME, + buildTargets, + configureTarget, + getTargetStatuses, +}; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..7dc21ea --- /dev/null +++ b/lib/config.js @@ -0,0 +1,89 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEFAULTS = { + host: '127.0.0.1', + port: 8765, + toolProfile: 'core', + autostart: true, + maxInteractionLogEntries: 50, +}; + +function getProjectPath() { + if (global.Editor && Editor.Project && typeof Editor.Project.path === 'string' && Editor.Project.path) { + return Editor.Project.path; + } + return process.cwd(); +} + +function getProjectName() { + return path.basename(getProjectPath()); +} + +function getCocosVersion() { + if (global.Editor && Editor.App) { + if (typeof Editor.App.version === 'string' && Editor.App.version) { + return Editor.App.version; + } + if (typeof Editor.App.ver === 'string' && Editor.App.ver) { + return Editor.App.ver; + } + } + return 'unknown'; +} + +function loadJson(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + return { + __error: `Failed to parse config file '${filePath}': ${error.message}`, + }; + } +} + +function clampPort(value) { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + return DEFAULTS.port; + } + return port; +} + +function normalizeProfile(value) { + return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core'; +} + +function loadConfig() { + const projectPath = getProjectPath(); + const configPath = path.join(projectPath, 'funplay-cocos-mcp.config.json'); + const fileConfig = loadJson(configPath) || {}; + + return { + ...DEFAULTS, + ...fileConfig, + host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host, + port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port), + toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile), + autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart, + maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries) + ? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries)) + : DEFAULTS.maxInteractionLogEntries, + configPath, + configError: fileConfig.__error || '', + }; +} + +module.exports = { + DEFAULTS, + getProjectPath, + getProjectName, + getCocosVersion, + loadConfig, +}; diff --git a/lib/diagnostics.js b/lib/diagnostics.js new file mode 100644 index 0000000..e8ed7fc --- /dev/null +++ b/lib/diagnostics.js @@ -0,0 +1,150 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); + +function exists(filePath) { + try { + return fs.existsSync(filePath); + } catch (error) { + return false; + } +} + +function findTypescriptBinary(projectPath) { + const tscName = process.platform === 'win32' ? 'tsc.cmd' : 'tsc'; + const possibleRoots = [ + global.Editor && Editor.App && Editor.App.path, + process.resourcesPath, + global.Editor && Editor.App && Editor.App.path ? path.dirname(Editor.App.path) : '', + global.Editor && Editor.App && Editor.App.path ? path.resolve(Editor.App.path, '..') : '', + ].filter(Boolean); + + const editorBundledCandidates = []; + for (const root of possibleRoots) { + editorBundledCandidates.push( + path.join(root, 'resources', '3d', 'engine', 'node_modules', '.bin', tscName), + path.join(root, 'resources', '3d', 'engine', 'node_modules', 'typescript', 'bin', 'tsc'), + path.join(root, 'resources', '3d', 'engine', 'node_modules', '@cocos', 'typescript', 'bin', 'tsc'), + path.join(root, 'app.asar.unpacked', 'node_modules', 'typescript', 'bin', 'tsc'), + path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', '.bin', tscName), + path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', 'typescript', 'bin', 'tsc'), + path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', '@cocos', 'typescript', 'bin', 'tsc') + ); + } + + const candidates = [ + path.join(projectPath, 'node_modules', '.bin', tscName), + path.join(projectPath, 'node_modules', 'typescript', 'bin', 'tsc'), + ...editorBundledCandidates, + process.platform === 'win32' ? 'npx.cmd' : 'npx', + ]; + + for (const candidate of candidates) { + if (candidate.includes(path.sep) && exists(candidate)) { + return candidate; + } + if (!candidate.includes(path.sep)) { + return candidate; + } + } + + return process.platform === 'win32' ? 'npx.cmd' : 'npx'; +} + +function findTsConfig(projectPath, explicitPath) { + if (explicitPath) { + return path.isAbsolute(explicitPath) ? explicitPath : path.join(projectPath, explicitPath); + } + + const candidates = [ + path.join(projectPath, 'tsconfig.json'), + path.join(projectPath, 'temp', 'tsconfig.cocos.json'), + ]; + + return candidates.find((candidate) => exists(candidate)) || ''; +} + +function runExec(file, args, cwd) { + return new Promise((resolve) => { + execFile(file, args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => { + resolve({ + code: error && typeof error.code === 'number' ? error.code : 0, + stdout: stdout || '', + stderr: stderr || '', + error: error ? error.message : '', + }); + }); + }); +} + +function parseTscOutput(output) { + const lines = String(output || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const diagnostics = []; + const regex = /^(.*)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.*)$/i; + for (const line of lines) { + const match = regex.exec(line); + if (!match) { + continue; + } + + diagnostics.push({ + file: match[1], + line: Number(match[2]), + column: Number(match[3]), + code: match[4], + message: match[5], + }); + } + + return diagnostics; +} + +async function runScriptDiagnostics(projectPath, options = {}) { + const tsconfigPath = findTsConfig(projectPath, options.tsconfigPath); + if (!tsconfigPath || !exists(tsconfigPath)) { + return { + ok: false, + tool: 'typescript', + summary: 'No tsconfig.json was found in the Cocos project.', + diagnostics: [], + stdout: '', + stderr: '', + }; + } + + const binary = findTypescriptBinary(projectPath); + const args = binary.endsWith('npx') || binary.endsWith('npx.cmd') + ? ['tsc', '--noEmit', '-p', tsconfigPath, '--pretty', 'false'] + : ['--noEmit', '-p', tsconfigPath, '--pretty', 'false']; + + const result = await runExec(binary, args, projectPath); + const mergedOutput = [result.stdout, result.stderr, result.error].filter(Boolean).join('\n').trim(); + const diagnostics = parseTscOutput(mergedOutput); + const ok = result.code === 0 && diagnostics.length === 0; + + return { + ok, + tool: 'typescript', + binary, + tsconfigPath, + exitCode: result.code, + summary: ok + ? 'TypeScript diagnostics completed successfully with no errors.' + : diagnostics.length + ? `Found ${diagnostics.length} TypeScript error(s).` + : mergedOutput || 'TypeScript diagnostics reported a non-zero exit code.', + diagnostics, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +module.exports = { + runScriptDiagnostics, +}; diff --git a/lib/electron-tools.js b/lib/electron-tools.js new file mode 100644 index 0000000..ea7a382 --- /dev/null +++ b/lib/electron-tools.js @@ -0,0 +1,281 @@ +'use strict'; + +function getElectron() { + try { + return require('electron'); + } catch (error) { + throw new Error(`Electron APIs are unavailable: ${error.message}`); + } +} + +function normalizeText(value) { + return String(value || '').trim().toLowerCase(); +} + +function getAllWindows() { + const electron = getElectron(); + const BrowserWindow = electron.BrowserWindow; + if (!BrowserWindow || typeof BrowserWindow.getAllWindows !== 'function') { + throw new Error('Electron BrowserWindow API is unavailable.'); + } + + return BrowserWindow.getAllWindows().filter((window) => window && !window.isDestroyed()); +} + +function inferWindowKind(title) { + const normalized = normalizeText(title); + if (normalized.includes('simulator')) { + return 'simulator'; + } + if (normalized.includes('preview')) { + return 'preview'; + } + if (normalized.includes('cocos creator') || normalized.includes('cocos')) { + return 'editor'; + } + return 'unknown'; +} + +function listWindows() { + return getAllWindows().map((window, index) => ({ + index, + id: typeof window.id === 'number' ? window.id : index, + title: typeof window.getTitle === 'function' ? window.getTitle() : '', + bounds: typeof window.getBounds === 'function' ? window.getBounds() : null, + visible: typeof window.isVisible === 'function' ? window.isVisible() : true, + focused: typeof window.isFocused === 'function' ? window.isFocused() : false, + kind: inferWindowKind(typeof window.getTitle === 'function' ? window.getTitle() : ''), + })); +} + +function pickWindow(options = {}) { + const electron = getElectron(); + const BrowserWindow = electron.BrowserWindow; + const windows = getAllWindows(); + if (!windows.length) { + throw new Error('No Electron windows are available.'); + } + + const titleContains = normalizeText(options.titleContains); + const windowKind = normalizeText(options.windowKind || 'focused'); + const focusedWindow = BrowserWindow.getFocusedWindow && BrowserWindow.getFocusedWindow(); + + const titleMatches = (window) => { + if (!titleContains) { + return true; + } + return normalizeText(window.getTitle && window.getTitle()).includes(titleContains); + }; + + const kindMatches = (window) => { + const kind = inferWindowKind(window.getTitle && window.getTitle()); + switch (windowKind) { + case 'focused': + return true; + case 'editor': + case 'simulator': + case 'preview': + return kind === windowKind; + default: + return true; + } + }; + + const candidates = windows.filter((window) => kindMatches(window) && titleMatches(window)); + const target = (focusedWindow && candidates.includes(focusedWindow) && focusedWindow) + || candidates.find((window) => typeof window.isVisible === 'function' ? window.isVisible() : true) + || candidates[0] + || windows[0]; + + if (!target) { + throw new Error(`No BrowserWindow matched windowKind='${windowKind}' titleContains='${titleContains}'.`); + } + + return target; +} + +async function executeJavaScript(window, script) { + if (!window || !window.webContents || typeof window.webContents.executeJavaScript !== 'function') { + throw new Error('Target window does not support webContents.executeJavaScript.'); + } + return await window.webContents.executeJavaScript(script, true); +} + +function buildPanelBoundsScript(panelName) { + const panel = JSON.stringify(String(panelName || 'scene')); + return ` + (() => { + const panelName = ${panel}.toLowerCase(); + const results = []; + + const isVisible = (element) => { + if (!element || typeof element.getBoundingClientRect !== 'function') return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 4 && rect.height > 4; + }; + + const textOf = (element) => { + const parts = [ + element.getAttribute && element.getAttribute('name'), + element.getAttribute && element.getAttribute('title'), + element.id, + element.className, + element.textContent, + ]; + return parts.filter(Boolean).join(' ').toLowerCase(); + }; + + const collectAll = (root, bucket) => { + if (!root) return; + const nodes = root.querySelectorAll ? root.querySelectorAll('*') : []; + for (const node of nodes) { + bucket.push(node); + if (node.shadowRoot) { + collectAll(node.shadowRoot, bucket); + } + } + }; + + const all = []; + collectAll(document, all); + + const scoreElement = (element) => { + const text = textOf(element); + let score = 0; + if (text.includes(panelName)) score += 100; + if (text.includes('panel-frame')) score += 5; + if (panelName === 'scene' && text.includes('game')) score -= 10; + if (panelName === 'game' && text.includes('scene')) score -= 10; + return score; + }; + + const largestChildRect = (element) => { + const bucket = [element]; + if (element.shadowRoot) collectAll(element.shadowRoot, bucket); + const children = bucket + .filter((node) => isVisible(node)) + .map((node) => { + const rect = node.getBoundingClientRect(); + const text = textOf(node); + let score = rect.width * rect.height; + if (node.tagName && node.tagName.toLowerCase() === 'canvas') score += 50000; + if (text.includes(panelName)) score += 20000; + if (text.includes('canvas') || text.includes('preview') || text.includes('viewport')) score += 10000; + return { rect, score, tag: node.tagName || '', text }; + }) + .sort((a, b) => b.score - a.score); + return children[0] || null; + }; + + const candidates = all + .filter((element) => isVisible(element)) + .map((element) => { + const panelScore = scoreElement(element); + if (panelScore <= 0) return null; + const rectInfo = largestChildRect(element) || { rect: element.getBoundingClientRect(), score: 0, tag: element.tagName || '', text: textOf(element) }; + return { + score: panelScore + rectInfo.score, + elementTag: element.tagName || '', + rect: rectInfo.rect, + text: textOf(element), + innerTag: rectInfo.tag, + innerText: rectInfo.text, + }; + }) + .filter(Boolean) + .sort((a, b) => b.score - a.score); + + const fallbackCanvases = all + .filter((element) => isVisible(element) && element.tagName && element.tagName.toLowerCase() === 'canvas') + .map((element) => { + const rect = element.getBoundingClientRect(); + const text = textOf(element); + let score = rect.width * rect.height; + if (text.includes(panelName)) score += 50000; + return { score, rect, elementTag: 'CANVAS', text, innerTag: 'CANVAS', innerText: text }; + }) + .sort((a, b) => b.score - a.score); + + const target = candidates[0] || fallbackCanvases[0]; + if (!target) return null; + + return { + x: Math.max(0, Math.floor(target.rect.left)), + y: Math.max(0, Math.floor(target.rect.top)), + width: Math.max(1, Math.floor(target.rect.width)), + height: Math.max(1, Math.floor(target.rect.height)), + elementTag: target.elementTag, + innerTag: target.innerTag, + text: target.text, + innerText: target.innerText, + }; + })(); + `; +} + +async function getPanelBounds(window, panelName) { + const result = await executeJavaScript(window, buildPanelBoundsScript(panelName)); + if (!result || !result.width || !result.height) { + throw new Error(`Could not locate a visible '${panelName}' panel in the target window.`); + } + return result; +} + +function buildPanelFocusScript(panelName, offsetX, offsetY) { + return ` + (() => { + const panelName = ${JSON.stringify(String(panelName || 'scene').toLowerCase())}; + const offsetX = ${Number(offsetX || 0)}; + const offsetY = ${Number(offsetY || 0)}; + const isVisible = (element) => { + if (!element || typeof element.getBoundingClientRect !== 'function') return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 4 && rect.height > 4; + }; + const textOf = (element) => [ + element.getAttribute && element.getAttribute('name'), + element.getAttribute && element.getAttribute('title'), + element.id, + element.className, + element.textContent, + ].filter(Boolean).join(' ').toLowerCase(); + const collectAll = (root, bucket) => { + if (!root) return; + const nodes = root.querySelectorAll ? root.querySelectorAll('*') : []; + for (const node of nodes) { + bucket.push(node); + if (node.shadowRoot) collectAll(node.shadowRoot, bucket); + } + }; + const all = []; + collectAll(document, all); + const target = all.find((element) => isVisible(element) && textOf(element).includes(panelName)); + const rect = target ? target.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; + const x = Math.floor(rect.left + rect.width / 2 + offsetX); + const y = Math.floor(rect.top + rect.height / 2 + offsetY); + const focusable = document.elementFromPoint(x, y) || target || document.body; + if (focusable && typeof focusable.focus === 'function') focusable.focus(); + return { x, y }; + })(); + `; +} + +async function getPanelPoint(window, panelName, offsetX, offsetY) { + return await executeJavaScript(window, buildPanelFocusScript(panelName, offsetX, offsetY)); +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +module.exports = { + executeJavaScript, + getAllWindows, + getPanelBounds, + getPanelPoint, + listWindows, + pickWindow, + sleep, +}; diff --git a/lib/input.js b/lib/input.js new file mode 100644 index 0000000..fc4dbae --- /dev/null +++ b/lib/input.js @@ -0,0 +1,151 @@ +'use strict'; + +const { getPanelPoint, listWindows, pickWindow, sleep } = require('./electron-tools'); + +function normalizeButton(button) { + const value = String(button || 'left').toLowerCase(); + return ['left', 'right', 'middle'].includes(value) ? value : 'left'; +} + +function normalizeModifiers(modifiers) { + return Array.isArray(modifiers) ? modifiers.map((item) => String(item)) : []; +} + +async function focusTarget(window, panel, x, y) { + if (typeof window.focus === 'function') { + window.focus(); + } + if (panel) { + return await getPanelPoint(window, panel, x, y); + } + return { x: Math.floor(x || 0), y: Math.floor(y || 0) }; +} + +async function resolvePoint(window, panel, x, y) { + if (panel) { + return await getPanelPoint(window, panel, x, y); + } + return { x: Math.floor(x || 0), y: Math.floor(y || 0) }; +} + +async function sendMouseClick(options = {}) { + const window = pickWindow(options); + const point = await focusTarget(window, options.panel, options.x, options.y); + const button = normalizeButton(options.button); + const clickCount = Number.isFinite(options.clickCount) ? Math.max(1, options.clickCount) : 1; + const modifiers = normalizeModifiers(options.modifiers); + + window.webContents.sendInputEvent({ + type: 'mouseMove', + x: point.x, + y: point.y, + button, + modifiers, + }); + window.webContents.sendInputEvent({ + type: 'mouseDown', + x: point.x, + y: point.y, + button, + clickCount, + modifiers, + }); + window.webContents.sendInputEvent({ + type: 'mouseUp', + x: point.x, + y: point.y, + button, + clickCount, + modifiers, + }); + + return { + sent: true, + type: 'mouse_click', + point, + button, + clickCount, + windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '', + }; +} + +async function sendMouseDrag(options = {}) { + const window = pickWindow(options); + const start = await focusTarget(window, options.panel, options.startX, options.startY); + const end = await resolvePoint(window, options.panel, options.endX ?? 0, options.endY ?? 0); + const steps = Number.isFinite(options.steps) ? Math.max(1, Math.min(60, options.steps)) : 10; + const button = normalizeButton(options.button); + const modifiers = normalizeModifiers(options.modifiers); + + window.webContents.sendInputEvent({ type: 'mouseMove', x: start.x, y: start.y, button, modifiers }); + window.webContents.sendInputEvent({ type: 'mouseDown', x: start.x, y: start.y, button, clickCount: 1, modifiers }); + for (let step = 1; step <= steps; step += 1) { + const x = Math.round(start.x + ((end.x - start.x) * step) / steps); + const y = Math.round(start.y + ((end.y - start.y) * step) / steps); + window.webContents.sendInputEvent({ type: 'mouseMove', x, y, button, modifiers }); + if (options.stepDelayMs) { + await sleep(options.stepDelayMs); + } + } + window.webContents.sendInputEvent({ type: 'mouseUp', x: end.x, y: end.y, button, clickCount: 1, modifiers }); + + return { + sent: true, + type: 'mouse_drag', + from: start, + to: end, + steps, + windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '', + }; +} + +async function sendKeyPress(options = {}) { + const window = pickWindow(options); + if (typeof window.focus === 'function') { + window.focus(); + } + if (options.panel) { + await getPanelPoint(window, options.panel, 0, 0); + } + + const keyCode = String(options.keyCode || '').trim(); + if (!keyCode) { + throw new Error('keyCode is required.'); + } + + const modifiers = normalizeModifiers(options.modifiers); + window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); + if (options.text) { + window.webContents.sendInputEvent({ type: 'char', keyCode: String(options.text), modifiers }); + } + window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); + + return { + sent: true, + type: 'key_press', + keyCode, + modifiers, + windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '', + }; +} + +async function sendKeyCombo(options = {}) { + const modifiers = normalizeModifiers(options.modifiers); + const keyCode = String(options.keyCode || '').trim(); + if (!keyCode) { + throw new Error('keyCode is required.'); + } + return await sendKeyPress({ + ...options, + keyCode, + modifiers, + }); +} + +module.exports = { + listWindows, + sendKeyCombo, + sendKeyPress, + sendMouseClick, + sendMouseDrag, +}; diff --git a/lib/interaction-log.js b/lib/interaction-log.js new file mode 100644 index 0000000..83ca795 --- /dev/null +++ b/lib/interaction-log.js @@ -0,0 +1,40 @@ +'use strict'; + +class InteractionLog { + constructor(limit = 200) { + this.limit = limit; + this.entries = []; + } + + add(toolName, status, summary) { + this.entries.unshift({ + toolName, + status, + summary, + timestamp: new Date().toISOString(), + }); + + if (this.entries.length > this.limit) { + this.entries.length = this.limit; + } + } + + list(limit = 20) { + return this.entries.slice(0, Math.max(1, limit)); + } + + summary(limit = 20) { + const items = this.list(limit); + if (!items.length) { + return 'No MCP interactions recorded yet.'; + } + + return items + .map((entry) => `[${entry.timestamp}] ${entry.status.toUpperCase()} ${entry.toolName}: ${entry.summary}`) + .join('\n'); + } +} + +module.exports = { + InteractionLog, +}; diff --git a/lib/prompts.js b/lib/prompts.js new file mode 100644 index 0000000..6d80cad --- /dev/null +++ b/lib/prompts.js @@ -0,0 +1,66 @@ +'use strict'; + +class PromptProvider { + constructor(getRuntimeContext) { + this.getRuntimeContext = getRuntimeContext; + } + + listPrompts() { + const { projectName } = this.getRuntimeContext(); + return [ + this.createPrompt('fix_script_errors', `Use execute_javascript first to diagnose and repair current Cocos script problems in '${projectName}'.`), + this.createPrompt('create_playable_prototype', `Use execute_javascript first to build a playable Cocos prototype in '${projectName}' from a short idea.`), + this.createPrompt('scene_validation', `Use execute_javascript first to validate a scene change in '${projectName}' with hierarchy checks and focused inspection.`), + this.createPrompt('auto_wire_scene', `Use execute_javascript first to inspect a target setup in '${projectName}' and wire missing scene relationships.`), + ]; + } + + getPrompt(name) { + const { projectName, projectPath } = this.getRuntimeContext(); + let text = ''; + + switch (name) { + case 'fix_script_errors': + text = 'Prefer `execute_javascript` first: use `context="editor"` for diagnostics, filesystem edits, and asset-db workflows, and `context="scene"` when runtime or scene validation is needed. Run script diagnostics, inspect source snippets for each error, patch the smallest safe regions with focused file edits, refresh assets, and verify the project returns to a healthy state.'; + break; + case 'create_playable_prototype': + text = 'Prefer `execute_javascript` as the primary tool: use `context="scene"` for node/component/runtime orchestration and `context="editor"` for editor-side automation. Create a playable Cocos prototype from the provided idea. Build scene nodes, scripts, prefabs, UI, camera, animation hooks, helper controls, and verify the result with runtime state and screenshots.'; + break; + case 'scene_validation': + text = 'Prefer `execute_javascript` as the first tool, usually with `context="scene"`. Inspect the active scene, verify hierarchy, nodes, components, prefab instances, cameras, animations, runtime state, screenshots, and targeted checks.'; + break; + case 'auto_wire_scene': + text = 'Prefer `execute_javascript` as the first tool, using `context="scene"` for hierarchy and component repair and `context="editor"` for file or asset-side repair. Inspect the target node structure, identify missing scene references, UI children, camera/animation setup, or expected children, and repair them with the smallest safe change.'; + break; + default: + text = `Prompt not found: ${name}`; + break; + } + + const fullText = `Target Cocos project: ${projectName}\nProject path: ${projectPath}\n\n${text}`; + return { + description: fullText, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: fullText, + }, + }, + ], + }; + } + + createPrompt(name, description) { + return { + name, + description, + arguments: [], + }; + } +} + +module.exports = { + PromptProvider, +}; diff --git a/lib/resources.js b/lib/resources.js new file mode 100644 index 0000000..0e06fb0 --- /dev/null +++ b/lib/resources.js @@ -0,0 +1,253 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets'); +const { runScriptDiagnostics } = require('./diagnostics'); + +function createResource(uri, name, description) { + return { uri, name, description, mimeType: 'text/plain' }; +} + +function createTemplate(uriTemplate, name, description) { + return { uriTemplate, name, description, mimeType: 'text/plain' }; +} + +function truncate(text, limit = 12000) { + if (typeof text !== 'string') { + return text; + } + if (text.length <= limit) { + return text; + } + return `${text.slice(0, limit)}\n... (truncated)`; +} + +function toText(value) { + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value, null, 2); +} + +function summarizeSelection() { + if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') { + return 'Selection API is unavailable in this Cocos environment.'; + } + + try { + const selectedNode = Editor.Selection.getSelected('node'); + const selectedAsset = Editor.Selection.getSelected('asset'); + return [ + `Selected node: ${selectedNode || '(none)'}`, + `Selected asset: ${selectedAsset || '(none)'}`, + ].join('\n'); + } catch (error) { + return `Failed to inspect current selection: ${error.message}`; + } +} + +class ResourceProvider { + constructor(getRuntimeContext, sceneBridge, interactionLog) { + this.getRuntimeContext = getRuntimeContext; + this.sceneBridge = sceneBridge; + this.interactionLog = interactionLog; + } + + listResources() { + const { projectName } = this.getRuntimeContext(); + return [ + createResource('cocos://project/context', `${projectName} Project Context`, 'Live Cocos project context summary.'), + createResource('cocos://project/summary', `${projectName} Project Summary`, 'Project path, folder summary, and asset overview.'), + createResource('cocos://scene/active', `${projectName} Active Scene`, 'Summary of the active Cocos scene.'), + createResource('cocos://scene/current', `${projectName} Current Scene`, 'Alias of the active Cocos scene summary.'), + createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'), + createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'), + createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'), + createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'), + ]; + } + + listResourceTemplates() { + return [ + createTemplate('cocos://scene/node/{path}', 'Scene Node', 'Inspect a scene node by hierarchy path.'), + createTemplate('cocos://asset/path/{relative_path}', 'Asset By Path', 'Read a script or text asset by project-relative path.'), + createTemplate('cocos://asset/info/{uuid_or_path}', 'Asset Info', 'Inspect an asset by uuid, db url, or path.'), + ]; + } + + async readResource(uri) { + const text = await this.resolveResourceText(uri); + return { + contents: [ + { + uri, + mimeType: 'text/plain', + text, + }, + ], + }; + } + + async resolveResourceText(uri) { + const { projectName, projectPath, cocosVersion, version, config } = this.getRuntimeContext(); + + switch (uri) { + case 'cocos://project/context': + return [ + 'Funplay Cocos MCP Project Context', + `Project: ${projectName}`, + `Project Path: ${projectPath}`, + `Cocos Creator: ${cocosVersion}`, + `Extension Version: ${version}`, + `Tool Profile: ${config.toolProfile}`, + `Server: http://${config.host}:${config.port}/`, + '', + 'Selection', + summarizeSelection(), + '', + 'Active Scene', + await this.safeSceneCall('getSceneInfo', { maxDepth: 2 }), + ].join('\n'); + case 'cocos://project/summary': + return this.buildProjectSummary(projectPath, cocosVersion, version); + case 'cocos://scene/active': + case 'cocos://scene/current': + return await this.safeSceneCall('getSceneInfo', { maxDepth: 3 }); + case 'cocos://selection/current': + return summarizeSelection(); + case 'cocos://selection/asset': + return await this.getSelectedAssetText(); + case 'cocos://errors/scripts': + return await this.getScriptDiagnosticsText(projectPath); + case 'cocos://mcp/interactions': + return this.interactionLog.summary(); + default: + break; + } + + if (uri.startsWith('cocos://scene/node/')) { + const nodePath = decodeURIComponent(uri.slice('cocos://scene/node/'.length)); + return await this.safeSceneCall('inspectNode', { path: nodePath }); + } + + if (uri.startsWith('cocos://asset/path/')) { + const relativePath = decodeURIComponent(uri.slice('cocos://asset/path/'.length)); + return this.readAssetByPath(relativePath); + } + + if (uri.startsWith('cocos://asset/info/')) { + const uuidOrPath = decodeURIComponent(uri.slice('cocos://asset/info/'.length)); + return await this.getAssetInfoText(uuidOrPath); + } + + return `Resource not found: ${uri}`; + } + + buildProjectSummary(projectPath, cocosVersion, version) { + const topLevel = fs.existsSync(projectPath) + ? fs + .readdirSync(projectPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)) + : []; + + const assetsDir = path.join(projectPath, 'assets'); + const scriptCount = this.countFiles(assetsDir, ['.ts', '.js']); + const prefabCount = this.countFiles(assetsDir, ['.prefab']); + const sceneCount = this.countFiles(assetsDir, ['.scene']); + + return [ + 'Project Summary', + `Project Root: ${projectPath}`, + `Assets Path: ${assetsDir}`, + `Cocos Creator: ${cocosVersion}`, + `Extension Version: ${version}`, + `Scripts: ${scriptCount}`, + `Prefabs: ${prefabCount}`, + `Scenes: ${sceneCount}`, + '', + `Top-Level Directories (${topLevel.length})`, + ...topLevel.map((name) => `- ${name}`), + ].join('\n'); + } + + countFiles(rootDir, extensions) { + if (!fs.existsSync(rootDir)) { + return 0; + } + + let count = 0; + const stack = [rootDir]; + while (stack.length) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (extensions.includes(path.extname(entry.name).toLowerCase())) { + count += 1; + } + } + } + return count; + } + + readAssetByPath(relativePath) { + const { projectPath } = this.getRuntimeContext(); + const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath); + if (!fs.existsSync(fullPath)) { + return `Asset not found: ${relativePath}`; + } + return truncate(`[${relativePath}]\n${fs.readFileSync(fullPath, 'utf8')}`); + } + + async safeSceneCall(method, payload) { + try { + const result = await this.sceneBridge.call(method, payload); + return toText(result); + } catch (error) { + return `Scene bridge error (${method}): ${error.message}`; + } + } + + async getSelectedAssetText() { + try { + const selection = getCurrentSelection(); + if (!selection.asset) { + return 'No asset is currently selected.'; + } + + return await this.getAssetInfoText(selection.asset); + } catch (error) { + return `Selected asset lookup failed: ${error.message}`; + } + } + + async getAssetInfoText(uuidOrPath) { + try { + const info = await queryAssetInfo(uuidOrPath); + const meta = await queryAssetMeta(uuidOrPath).catch(() => null); + return JSON.stringify({ info, meta }, null, 2); + } catch (error) { + return `Asset lookup failed: ${error.message}`; + } + } + + async getScriptDiagnosticsText(projectPath) { + try { + const result = await runScriptDiagnostics(projectPath); + return JSON.stringify(result, null, 2); + } catch (error) { + return `Script diagnostics failed: ${error.message}`; + } + } +} + +module.exports = { + ResourceProvider, +}; diff --git a/lib/screenshots.js b/lib/screenshots.js new file mode 100644 index 0000000..d398b1d --- /dev/null +++ b/lib/screenshots.js @@ -0,0 +1,110 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFile } = require('child_process'); +const { getPanelBounds, pickWindow } = require('./electron-tools'); + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function exec(file, args) { + return new Promise((resolve, reject) => { + execFile(file, args, { maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || stdout || error.message)); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +async function captureDesktopScreenshot(projectPath, options = {}) { + const outputDir = path.join(projectPath, 'temp', 'mcp-captures'); + ensureDir(outputDir); + const filePath = path.join(outputDir, options.fileName || `desktop-${Date.now()}.png`); + + if (process.platform === 'darwin') { + await exec('screencapture', ['-x', filePath]); + } else if (process.platform === 'win32') { + const script = ` + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + $bitmap.Save('${filePath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bitmap.Dispose() + `; + await exec('powershell', ['-NoProfile', '-Command', script]); + } else { + try { + await exec('gnome-screenshot', ['-f', filePath]); + } catch (error) { + await exec('import', ['-window', 'root', filePath]); + } + } + + const data = fs.readFileSync(filePath).toString('base64'); + return { + filePath, + dataUri: `data:image/png;base64,${data}`, + size: fs.statSync(filePath).size, + platform: os.platform(), + }; +} + +async function captureEditorWindowScreenshot(projectPath, options = {}) { + const outputDir = path.join(projectPath, 'temp', 'mcp-captures'); + ensureDir(outputDir); + const filePath = path.join(outputDir, options.fileName || `editor-${Date.now()}.png`); + + const target = pickWindow(options); + + const image = await target.capturePage(); + const png = image.toPNG(); + fs.writeFileSync(filePath, png); + + return { + filePath, + dataUri: `data:image/png;base64,${png.toString('base64')}`, + size: png.length, + title: typeof target.getTitle === 'function' ? target.getTitle() : '', + }; +} + +async function capturePanelScreenshot(projectPath, options = {}) { + const outputDir = path.join(projectPath, 'temp', 'mcp-captures'); + ensureDir(outputDir); + const filePath = path.join(outputDir, options.fileName || `${options.panel || 'panel'}-${Date.now()}.png`); + + const target = pickWindow(options); + const bounds = await getPanelBounds(target, options.panel || 'scene'); + const image = await target.capturePage({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }); + const png = image.toPNG(); + fs.writeFileSync(filePath, png); + + return { + filePath, + dataUri: `data:image/png;base64,${png.toString('base64')}`, + size: png.length, + title: typeof target.getTitle === 'function' ? target.getTitle() : '', + bounds, + }; +} + +module.exports = { + captureDesktopScreenshot, + captureEditorWindowScreenshot, + capturePanelScreenshot, +}; diff --git a/lib/server.js b/lib/server.js new file mode 100644 index 0000000..8cb834f --- /dev/null +++ b/lib/server.js @@ -0,0 +1,234 @@ +'use strict'; + +const http = require('http'); +const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; +const LOG_PREFIX = '[Funplay Cocos MCP Server]'; + +function json(response, statusCode, payload) { + response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(payload)); +} + +function textContent(value) { + if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) { + return [ + { + type: 'image', + data: value.slice(IMAGE_DATA_URI_PREFIX.length), + mimeType: 'image/png', + }, + { + type: 'text', + text: 'Screenshot captured successfully.', + }, + ]; + } + + return [ + { + type: 'text', + text: typeof value === 'string' ? value : JSON.stringify(value, null, 2), + }, + ]; +} + +class McpServer { + constructor(options) { + this.config = options.config; + this.toolRegistry = options.toolRegistry; + this.resourceProvider = options.resourceProvider; + this.promptProvider = options.promptProvider; + this.interactionLog = options.interactionLog; + this.serverName = options.serverName; + this.serverVersion = options.serverVersion; + this.server = null; + } + + isRunning() { + return Boolean(this.server && this.server.listening); + } + + async start() { + if (this.isRunning()) { + console.log(`${LOG_PREFIX} Start skipped: already running.`); + return; + } + + console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${this.config.port}...`); + this.server = http.createServer(async (request, response) => { + try { + if (request.method === 'GET' && request.url === '/health') { + console.log(`${LOG_PREFIX} GET /health`); + return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }); + } + + if (request.method !== 'POST') { + console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`); + return json(response, 405, { error: 'Method Not Allowed' }); + } + + const body = await this.readBody(request); + if (!body) { + return json(response, 400, this.createError(null, -32700, 'Parse error: empty body')); + } + + const rpc = JSON.parse(body); + if (rpc && rpc.method) { + console.log(`${LOG_PREFIX} RPC ${rpc.method}`); + } + const result = await this.handleRpcRequest(rpc); + if (result == null) { + response.writeHead(204); + response.end(); + return; + } + + return json(response, 200, result); + } catch (error) { + console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`); + return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`)); + } + }); + + await new Promise((resolve, reject) => { + this.server.once('error', reject); + this.server.listen(this.config.port, this.config.host, () => { + this.server.off('error', reject); + console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.config.port}/`); + resolve(); + }); + }); + } + + async stop() { + if (!this.server) { + console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`); + return; + } + + console.log(`${LOG_PREFIX} Closing HTTP server...`); + const active = this.server; + this.server = null; + await new Promise((resolve, reject) => { + active.close((error) => { + if (error) { + console.error(`${LOG_PREFIX} Close failed: ${error.message}`); + reject(error); + return; + } + console.log(`${LOG_PREFIX} HTTP server closed.`); + resolve(); + }); + }); + } + + readBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + request.on('error', reject); + }); + } + + async handleRpcRequest(request) { + if (!request || request.jsonrpc !== '2.0') { + return this.createError(request && request.id, -32600, 'Invalid Request'); + } + + const method = request.method; + if (typeof method !== 'string' || !method) { + return this.createError(request.id, -32600, 'Invalid Request: method is required'); + } + + if (method === 'initialize') { + return this.createResult(request.id, { + protocolVersion: '2024-11-05', + serverInfo: { + name: this.serverName, + version: this.serverVersion, + }, + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + }); + } + + if (method === 'notifications/initialized' || method === 'notifications/cancelled' || method.startsWith('notifications/')) { + return null; + } + + if (method === 'tools/list') { + return this.createResult(request.id, { tools: this.toolRegistry.listTools() }); + } + + if (method === 'tools/call') { + const params = request.params || {}; + if (typeof params.name !== 'string' || !params.name) { + return this.createError(request.id, -32602, "Invalid params: 'name' is required"); + } + + try { + const output = await this.toolRegistry.callTool(params.name, params.arguments || {}); + return this.createResult(request.id, { content: textContent(output) }); + } catch (error) { + return this.createError(request.id, -32603, error.message); + } + } + + if (method === 'resources/list') { + return this.createResult(request.id, { resources: this.resourceProvider.listResources() }); + } + + if (method === 'resources/read') { + const params = request.params || {}; + if (typeof params.uri !== 'string' || !params.uri) { + return this.createError(request.id, -32602, "Invalid params: 'uri' is required"); + } + return this.createResult(request.id, await this.resourceProvider.readResource(params.uri)); + } + + if (method === 'resources/templates/list') { + return this.createResult(request.id, { resourceTemplates: this.resourceProvider.listResourceTemplates() }); + } + + if (method === 'prompts/list') { + return this.createResult(request.id, { prompts: this.promptProvider.listPrompts() }); + } + + if (method === 'prompts/get') { + const params = request.params || {}; + if (typeof params.name !== 'string' || !params.name) { + return this.createError(request.id, -32602, "Invalid params: 'name' is required"); + } + return this.createResult(request.id, this.promptProvider.getPrompt(params.name, params.arguments || {})); + } + + return this.createError(request.id, -32601, `Method not found: ${method}`); + } + + createResult(id, result) { + return { + jsonrpc: '2.0', + id, + result, + }; + } + + createError(id, code, message) { + return { + jsonrpc: '2.0', + id, + error: { + code, + message, + }, + }; + } +} + +module.exports = { + McpServer, +}; diff --git a/lib/tool-registry.js b/lib/tool-registry.js new file mode 100644 index 0000000..f3132e1 --- /dev/null +++ b/lib/tool-registry.js @@ -0,0 +1,1288 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + deleteAsset, + getCurrentSelection, + listAssets, + openAsset, + queryAssetData, + queryAssetInfo, + queryAssetMeta, + selectAsset, +} = require('./assets'); +const { runScriptDiagnostics } = require('./diagnostics'); +const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input'); +const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); +const { safeStringify } = require('./utils'); + +function createSchema(properties, required) { + const schema = { + type: 'object', + properties, + }; + if (required && required.length) { + schema.required = required; + } + return schema; +} + +function toOutput(value) { + if (typeof value === 'string') { + return value; + } + return safeStringify(value); +} + +function resolveProjectPath(projectPath, rawPath) { + return path.isAbsolute(rawPath) ? rawPath : path.join(projectPath, rawPath); +} + +function matchesPattern(fileName, pattern) { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`, 'i').test(fileName); +} + +function searchFiles(rootDir, pattern, limit) { + const results = []; + if (!fs.existsSync(rootDir)) { + return results; + } + + const stack = [rootDir]; + while (stack.length && results.length < limit) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') { + continue; + } + + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (matchesPattern(entry.name, pattern)) { + results.push(fullPath); + if (results.length >= limit) { + break; + } + } + } + } + + return results; +} + +function readLines(filePath) { + return fs.readFileSync(filePath, 'utf8').split(/\r?\n/); +} + +function buildSnippet(filePath, lineNumber, contextLines = 3) { + const lines = readLines(filePath); + const start = Math.max(1, Number(lineNumber || 1) - Math.max(0, contextLines)); + const end = Math.min(lines.length, Number(lineNumber || 1) + Math.max(0, contextLines)); + const snippet = []; + for (let line = start; line <= end; line += 1) { + const marker = line === Number(lineNumber || 1) ? '>' : ' '; + snippet.push(`${marker} ${String(line).padStart(4, ' ')} | ${lines[line - 1]}`); + } + return snippet.join('\n'); +} + +function replaceAllLiteral(content, search, replacement) { + if (!search) { + throw new Error('search text is required.'); + } + return content.split(search).join(replacement); +} + +async function refreshAssets(projectPath, targetPath) { + if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') { + return 'Asset refresh API is unavailable; Cocos Creator should pick up file changes automatically.'; + } + + const relative = path.relative(path.join(projectPath, 'assets'), targetPath).replace(/\\/g, '/'); + if (!relative.startsWith('..')) { + const dbUrl = `db://assets/${relative}`; + try { + await Editor.Message.request('asset-db', 'refresh-asset', dbUrl); + return `Refreshed asset database for ${dbUrl}`; + } catch (error) { + try { + await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets'); + return `Refreshed asset database after writing ${dbUrl}`; + } catch (innerError) { + return `File written, but asset refresh failed: ${innerError.message}`; + } + } + } + + return 'File written outside assets directory; no asset-db refresh was needed.'; +} + +function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, editorExecutor }) { + const tools = [ + { + name: 'execute_javascript', + profile: 'core', + description: '[primary] Execute JavaScript in either the scene or editor context. Use context=\"scene\" for live scene/runtime inspection and mutation, or context=\"editor\" for Editor APIs, asset-db workflows, MCP orchestration, local filesystem access, and higher-level automation. Prefer this as the main flexible tool when many narrow tools would be noisy.', + inputSchema: createSchema( + { + context: { type: 'string', description: 'Execution context: scene or editor.' }, + code: { type: 'string', description: 'JavaScript code to execute. May directly return a value, define run(env), or export a function.' }, + args: { type: 'object', description: 'Optional JSON object passed into the script.' }, + }, + ['context', 'code'] + ), + handler: async (args) => { + const context = String(args.context || '').toLowerCase(); + if (context === 'scene') { + return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} }); + } + if (context === 'editor') { + if (typeof editorExecutor !== 'function') { + throw new Error('Editor JavaScript execution is unavailable.'); + } + return await editorExecutor({ code: args.code, args: args.args || {} }); + } + throw new Error(`Unknown execution context '${args.context}'. Expected 'scene' or 'editor'.`); + }, + }, + { + name: 'execute_scene_script', + profile: 'core', + description: '[compat] Execute JavaScript in the active Cocos scene context. Prefer execute_javascript with context="scene" as the main unified tool; use this when you specifically want the scene-only compatibility entrypoint.', + inputSchema: createSchema( + { + code: { type: 'string', description: 'JavaScript code to execute inside the scene script context.' }, + args: { type: 'object', description: 'Optional JSON object passed to the scene script.' }, + }, + ['code'] + ), + handler: async (args) => sceneBridge.call('executeCode', { code: args.code, args: args.args || {} }), + }, + { + name: 'execute_editor_script', + profile: 'core', + description: '[compat] Execute JavaScript in the editor/browser context. Prefer execute_javascript with context="editor" as the main unified tool; use this when you specifically want the editor-only compatibility entrypoint.', + inputSchema: createSchema( + { + code: { type: 'string', description: 'JavaScript code to execute inside the editor context.' }, + args: { type: 'object', description: 'Optional JSON object passed to the editor script.' }, + }, + ['code'] + ), + handler: async (args) => { + if (typeof editorExecutor !== 'function') { + throw new Error('Editor JavaScript execution is unavailable.'); + } + return await editorExecutor({ code: args.code, args: args.args || {} }); + }, + }, + { + name: 'get_scene_info', + profile: 'core', + description: '[core] Get a summary of the active Cocos scene.', + inputSchema: createSchema( + { + maxDepth: { type: 'number', description: 'Maximum child depth to include in the scene summary.' }, + includeComponents: { type: 'boolean', description: 'Include component names for nodes.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('getSceneInfo', args), + }, + { + name: 'get_hierarchy', + profile: 'core', + description: '[core] Browse the scene hierarchy tree from the active scene or a specific node path.', + inputSchema: createSchema( + { + rootPath: { type: 'string', description: 'Optional node path to use as the traversal root.' }, + maxDepth: { type: 'number', description: 'Maximum child depth to include.' }, + includeComponents: { type: 'boolean', description: 'Include component names for each node.' }, + includeInactive: { type: 'boolean', description: 'Include inactive nodes in the result.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('getHierarchy', args), + }, + { + name: 'find_nodes', + profile: 'core', + description: '[core] Find scene nodes by exact name, partial path, or component type.', + inputSchema: createSchema( + { + name: { type: 'string', description: 'Exact node name to match.' }, + pathContains: { type: 'string', description: 'Substring that must appear in the node path.' }, + component: { type: 'string', description: 'Component constructor name to match.' }, + includeInactive: { type: 'boolean', description: 'Include inactive nodes.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('findNodes', args), + }, + { + name: 'inspect_node', + profile: 'core', + description: '[core] Inspect a specific node by path, uuid, or name.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Hierarchy path such as Canvas/Player.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('inspectNode', args), + }, + { + name: 'create_node', + profile: 'full', + description: 'Create a new node under the active scene or a specified parent path.', + inputSchema: createSchema( + { + name: { type: 'string', description: 'Name of the node to create.' }, + parentPath: { type: 'string', description: 'Optional parent node path.' }, + position: { type: 'object', description: 'Optional position {x,y,z}.' }, + scale: { type: 'object', description: 'Optional scale {x,y,z}.' }, + eulerAngles: { type: 'object', description: 'Optional rotation {x,y,z} in degrees.' }, + active: { type: 'boolean', description: 'Optional active state for the node.' }, + }, + ['name'] + ), + handler: async (args) => sceneBridge.call('createNode', args), + }, + { + name: 'delete_node', + profile: 'full', + description: 'Delete a node by path, uuid, or name.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('deleteNode', args), + }, + { + name: 'set_node_transform', + profile: 'full', + description: 'Update node position, rotation, scale, or active state.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + position: { type: 'object', description: 'Position {x,y,z}.' }, + scale: { type: 'object', description: 'Scale {x,y,z}.' }, + eulerAngles: { type: 'object', description: 'Rotation {x,y,z} in degrees.' }, + active: { type: 'boolean', description: 'Optional active state.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('setNodeTransform', args), + }, + { + name: 'get_project_info', + profile: 'core', + description: '[core] Return the active Cocos project path, version, and MCP server configuration.', + inputSchema: createSchema({}, []), + handler: async () => getRuntimeContext(), + }, + { + name: 'list_scenes', + profile: 'core', + description: '[core] List scene assets in the project.', + inputSchema: createSchema( + { + pattern: { type: 'string', description: 'Optional asset-db pattern. Defaults to db://assets/**.' }, + }, + [] + ), + handler: async (args) => { + const assets = await listAssets({ pattern: args.pattern || 'db://assets/**', ccType: 'cc.SceneAsset' }); + return { count: assets.length, scenes: assets.slice(0, 200) }; + }, + }, + { + name: 'open_scene', + profile: 'core', + description: '[core] Open a scene asset in Cocos Creator by uuid, db url, or path.', + inputSchema: createSchema( + { + target: { type: 'string', description: 'Scene uuid, db url, or path.' }, + }, + ['target'] + ), + handler: async (args) => await openAsset(args.target), + }, + { + name: 'list_prefabs', + profile: 'core', + description: '[core] List prefab assets in the project.', + inputSchema: createSchema( + { + pattern: { type: 'string', description: 'Optional asset-db pattern. Defaults to db://assets/**.' }, + }, + [] + ), + handler: async (args) => { + const assets = await listAssets({ pattern: args.pattern || 'db://assets/**', ccType: 'cc.Prefab' }); + return { count: assets.length, prefabs: assets.slice(0, 200) }; + }, + }, + { + name: 'instantiate_prefab', + profile: 'full', + description: 'Instantiate a prefab into the active scene by prefab uuid.', + inputSchema: createSchema( + { + prefabUuid: { type: 'string', description: 'Prefab asset uuid.' }, + parentPath: { type: 'string', description: 'Optional parent node path.' }, + name: { type: 'string', description: 'Optional override node name.' }, + position: { type: 'object', description: 'Optional position {x,y,z}.' }, + }, + ['prefabUuid'] + ), + handler: async (args) => sceneBridge.call('instantiatePrefab', args), + }, + { + name: 'run_scene_asset', + profile: 'full', + description: 'Load a scene asset by uuid directly into the current runtime scene context.', + inputSchema: createSchema( + { + sceneUuid: { type: 'string', description: 'Scene asset uuid.' }, + }, + ['sceneUuid'] + ), + handler: async (args) => sceneBridge.call('runSceneAsset', args), + }, + { + name: 'list_assets', + profile: 'core', + description: '[core] Query project assets from asset-db by pattern or asset type.', + inputSchema: createSchema( + { + pattern: { type: 'string', description: 'Optional asset-db pattern such as db://assets/** or a folder url.' }, + ccType: { type: 'string', description: 'Optional Cocos asset type, such as cc.Prefab or cc.SceneAsset.' }, + }, + [] + ), + handler: async (args) => { + const assets = await listAssets(args); + return { + count: assets.length, + assets: assets.slice(0, 200), + }; + }, + }, + { + name: 'inspect_asset', + profile: 'core', + description: '[core] Inspect asset-db info, metadata, and serialized asset data by uuid or path.', + inputSchema: createSchema( + { + target: { type: 'string', description: 'Asset uuid, db url, or path.' }, + includeData: { type: 'boolean', description: 'Include serialized asset data when available.' }, + }, + ['target'] + ), + handler: async (args) => { + const info = await queryAssetInfo(args.target); + const meta = await queryAssetMeta(args.target).catch(() => null); + const data = args.includeData ? await queryAssetData(args.target).catch(() => null) : null; + return { info, meta, data }; + }, + }, + { + name: 'open_asset', + profile: 'core', + description: '[core] Open an asset inside Cocos Creator by uuid, db url, or path.', + inputSchema: createSchema( + { + target: { type: 'string', description: 'Asset uuid, db url, or path.' }, + }, + ['target'] + ), + handler: async (args) => await openAsset(args.target), + }, + { + name: 'delete_asset', + profile: 'full', + description: 'Delete an asset from asset-db by uuid, db url, or path.', + inputSchema: createSchema( + { + target: { type: 'string', description: 'Asset uuid, db url, or path.' }, + }, + ['target'] + ), + handler: async (args) => await deleteAsset(args.target), + }, + { + name: 'select_asset', + profile: 'core', + description: '[core] Select an asset in the Cocos editor.', + inputSchema: createSchema( + { + target: { type: 'string', description: 'Asset uuid, db url, or path.' }, + }, + ['target'] + ), + handler: async (args) => { + const info = await queryAssetInfo(args.target); + return selectAsset(info.uuid || args.target); + }, + }, + { + name: 'get_editor_selection', + profile: 'core', + description: '[core] Return the current node and asset selection in the Cocos editor.', + inputSchema: createSchema({}, []), + handler: async () => getCurrentSelection(), + }, + { + name: 'list_components', + profile: 'core', + description: '[core] List components attached to a scene node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('listComponents', args), + }, + { + name: 'inspect_component', + profile: 'core', + description: '[core] Inspect a component attached to a node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + componentName: { type: 'string', description: 'Component class name.' }, + index: { type: 'number', description: 'Optional component index.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('inspectComponent', args), + }, + { + name: 'add_component', + profile: 'full', + description: 'Add a component to a node by component class name.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + componentName: { type: 'string', description: 'Component class name, for example Sprite or cc.UITransform.' }, + }, + ['componentName'] + ), + handler: async (args) => sceneBridge.call('addComponent', args), + }, + { + name: 'remove_component', + profile: 'full', + description: 'Remove a component from a node by name or index.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + componentName: { type: 'string', description: 'Component class name.' }, + index: { type: 'number', description: 'Optional component index.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('removeComponent', args), + }, + { + name: 'set_component_property', + profile: 'full', + description: 'Set a component property by dot path using a JSON value.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + componentName: { type: 'string', description: 'Component class name.' }, + index: { type: 'number', description: 'Optional component index.' }, + propertyPath: { type: 'string', description: 'Property path such as color.r or enabled.' }, + valueJson: { type: 'string', description: 'JSON encoded value to assign, for example true, 12, \"hero\", or {\"x\":1}.' }, + }, + ['propertyPath', 'valueJson'] + ), + handler: async (args) => { + let value; + try { + value = JSON.parse(args.valueJson); + } catch (error) { + throw new Error(`valueJson must be valid JSON: ${error.message}`); + } + return sceneBridge.call('setComponentProperty', { ...args, value }); + }, + }, + { + name: 'reset_component_property', + profile: 'full', + description: 'Reset or clear a component property by dot path.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + componentName: { type: 'string', description: 'Component class name.' }, + index: { type: 'number', description: 'Optional component index.' }, + propertyPath: { type: 'string', description: 'Property path such as color.r or enabled.' }, + }, + ['propertyPath'] + ), + handler: async (args) => sceneBridge.call('resetComponentProperty', args), + }, + { + name: 'create_canvas', + profile: 'full', + description: 'Create a Cocos Canvas node with UITransform.', + inputSchema: createSchema( + { + name: { type: 'string', description: 'Canvas node name.' }, + parentPath: { type: 'string', description: 'Optional parent node path.' }, + width: { type: 'number', description: 'Canvas width.' }, + height: { type: 'number', description: 'Canvas height.' }, + position: { type: 'object', description: 'Optional position {x,y,z}.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('createCanvas', args), + }, + { + name: 'create_label', + profile: 'full', + description: 'Create a UI Label node under a parent.', + inputSchema: createSchema( + { + name: { type: 'string', description: 'Label node name.' }, + parentPath: { type: 'string', description: 'Optional parent node path.' }, + text: { type: 'string', description: 'Label text.' }, + fontSize: { type: 'number', description: 'Font size.' }, + width: { type: 'number', description: 'UI width.' }, + height: { type: 'number', description: 'UI height.' }, + color: { type: 'string', description: 'Text color as #RRGGBB or #RRGGBBAA.' }, + position: { type: 'object', description: 'Optional position {x,y,z}.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('createLabel', args), + }, + { + name: 'create_button', + profile: 'full', + description: 'Create a UI Button node with child Label.', + inputSchema: createSchema( + { + name: { type: 'string', description: 'Button node name.' }, + parentPath: { type: 'string', description: 'Optional parent node path.' }, + text: { type: 'string', description: 'Button text.' }, + width: { type: 'number', description: 'Button width.' }, + height: { type: 'number', description: 'Button height.' }, + fontSize: { type: 'number', description: 'Text font size.' }, + backgroundColor: { type: 'string', description: 'Background color as #RRGGBB or #RRGGBBAA.' }, + textColor: { type: 'string', description: 'Text color as #RRGGBB or #RRGGBBAA.' }, + position: { type: 'object', description: 'Optional position {x,y,z}.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('createButton', args), + }, + { + name: 'create_sprite', + profile: 'full', + description: 'Create a UI Sprite node, optionally assigning a SpriteFrame asset uuid.', + inputSchema: createSchema( + { + name: { type: 'string', description: 'Sprite node name.' }, + parentPath: { type: 'string', description: 'Optional parent node path.' }, + spriteFrameUuid: { type: 'string', description: 'Optional SpriteFrame asset uuid.' }, + width: { type: 'number', description: 'UI width.' }, + height: { type: 'number', description: 'UI height.' }, + color: { type: 'string', description: 'Sprite color as #RRGGBB or #RRGGBBAA.' }, + position: { type: 'object', description: 'Optional position {x,y,z}.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('createSprite', args), + }, + { + name: 'list_cameras', + profile: 'core', + description: '[core] List Camera components in the active scene.', + inputSchema: createSchema({}, []), + handler: async (args) => sceneBridge.call('listCameras', args), + }, + { + name: 'create_camera', + profile: 'full', + description: 'Create a Camera node in the active scene.', + inputSchema: createSchema( + { + name: { type: 'string', description: 'Camera node name.' }, + parentPath: { type: 'string', description: 'Optional parent node path.' }, + priority: { type: 'number', description: 'Camera priority.' }, + visibility: { type: 'number', description: 'Camera visibility mask.' }, + clearFlags: { type: 'number', description: 'Camera clear flags.' }, + position: { type: 'object', description: 'Optional position {x,y,z}.' }, + eulerAngles: { type: 'object', description: 'Optional rotation {x,y,z}.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('createCamera', args), + }, + { + name: 'set_camera_properties', + profile: 'full', + description: 'Set selected Camera component properties.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Camera node path.' }, + uuid: { type: 'string', description: 'Camera node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + priority: { type: 'number', description: 'Camera priority.' }, + visibility: { type: 'number', description: 'Camera visibility mask.' }, + clearFlags: { type: 'number', description: 'Camera clear flags.' }, + projection: { type: 'number', description: 'Projection enum value.' }, + orthoHeight: { type: 'number', description: 'Ortho height.' }, + fov: { type: 'number', description: 'Field of view.' }, + near: { type: 'number', description: 'Near clip.' }, + far: { type: 'number', description: 'Far clip.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('setCameraProperties', args), + }, + { + name: 'list_animations', + profile: 'core', + description: '[core] List Animation components in the active scene or under one node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Optional node path.' }, + uuid: { type: 'string', description: 'Optional node uuid.' }, + name: { type: 'string', description: 'Optional exact node name.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('listAnimations', args), + }, + { + name: 'add_animation_clip', + profile: 'full', + description: 'Add an AnimationClip asset to a node Animation component.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + clipUuid: { type: 'string', description: 'AnimationClip asset uuid.' }, + makeDefault: { type: 'boolean', description: 'Set this clip as defaultClip.' }, + }, + ['clipUuid'] + ), + handler: async (args) => sceneBridge.call('addAnimationClip', args), + }, + { + name: 'play_animation', + profile: 'core', + description: '[core] Play an Animation component clip on a node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + clipName: { type: 'string', description: 'Optional clip name.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('playAnimation', args), + }, + { + name: 'stop_animation', + profile: 'core', + description: '[core] Stop an Animation component clip on a node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + clipName: { type: 'string', description: 'Optional clip name.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('stopAnimation', args), + }, + { + name: 'read_file', + profile: 'core', + description: '[core] Read a file from the Cocos project.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + }, + ['path'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${args.path}`); + } + const content = fs.readFileSync(fullPath, 'utf8'); + return content.length > 12000 ? `${content.slice(0, 12000)}\n... (truncated)` : content; + }, + }, + { + name: 'get_file_snippet', + profile: 'core', + description: '[core] Read a focused snippet around a file line number.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + line: { type: 'number', description: 'Target line number, starting at 1.' }, + contextLines: { type: 'number', description: 'Number of surrounding context lines.' }, + }, + ['path', 'line'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${args.path}`); + } + return buildSnippet(fullPath, args.line, Number.isFinite(args.contextLines) ? args.contextLines : 3); + }, + }, + { + name: 'write_file', + profile: 'core', + description: '[core] Write or overwrite a file in the Cocos project.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + content: { type: 'string', description: 'File content to write.' }, + }, + ['path', 'content'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, args.content, 'utf8'); + return `Wrote ${args.content.length} chars to ${args.path}\n${await refreshAssets(projectPath, fullPath)}`; + }, + }, + { + name: 'replace_in_file', + profile: 'core', + description: '[core] Replace text in a file, useful for script auto-fix loops.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute file path.' }, + search: { type: 'string', description: 'Literal text to search for.' }, + replace: { type: 'string', description: 'Replacement text.' }, + replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of only the first.' }, + }, + ['path', 'search', 'replace'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const fullPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(fullPath)) { + throw new Error(`File not found: ${args.path}`); + } + + const original = fs.readFileSync(fullPath, 'utf8'); + if (!original.includes(args.search)) { + throw new Error(`Search text was not found in ${args.path}`); + } + + const updated = args.replaceAll + ? replaceAllLiteral(original, args.search, args.replace) + : original.replace(args.search, args.replace); + + fs.writeFileSync(fullPath, updated, 'utf8'); + return `Updated ${args.path} (${args.replaceAll ? 'all matches' : 'first match'})\n${await refreshAssets(projectPath, fullPath)}`; + }, + }, + { + name: 'search_files', + profile: 'core', + description: '[core] Search project files by simple wildcard pattern.', + inputSchema: createSchema( + { + pattern: { type: 'string', description: "Wildcard file pattern such as '*.ts' or 'Player*'." }, + directory: { type: 'string', description: 'Project-relative search root. Defaults to assets.' }, + limit: { type: 'number', description: 'Maximum number of results to return.' }, + }, + ['pattern'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const searchRoot = resolveProjectPath(projectPath, args.directory || 'assets'); + if (!fs.existsSync(searchRoot)) { + throw new Error(`Directory not found: ${args.directory || 'assets'}`); + } + + const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(500, args.limit)) : 100; + const results = searchFiles(searchRoot, args.pattern, limit).map((fullPath) => + path.relative(projectPath, fullPath).replace(/\\/g, '/') + ); + return { + count: results.length, + files: results, + }; + }, + }, + { + name: 'list_directory', + profile: 'core', + description: '[core] List files and directories inside a project directory.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute directory path.' }, + }, + ['path'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const targetPath = resolveProjectPath(projectPath, args.path); + if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) { + throw new Error(`Directory not found: ${args.path}`); + } + + const entries = fs + .readdirSync(targetPath, { withFileTypes: true }) + .filter((entry) => !entry.name.startsWith('.')) + .map((entry) => ({ + name: entry.name, + type: entry.isDirectory() ? 'directory' : 'file', + })); + return { + path: args.path, + entries, + }; + }, + }, + { + name: 'exists', + profile: 'core', + description: '[core] Check whether a project file or directory exists.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Project-relative or absolute path.' }, + }, + ['path'] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const targetPath = resolveProjectPath(projectPath, args.path); + return { + path: args.path, + exists: fs.existsSync(targetPath), + isFile: fs.existsSync(targetPath) ? fs.statSync(targetPath).isFile() : false, + isDirectory: fs.existsSync(targetPath) ? fs.statSync(targetPath).isDirectory() : false, + }; + }, + }, + { + name: 'refresh_assets', + profile: 'core', + description: '[core] Best-effort asset database refresh for a file or the assets root.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Optional project-relative file path to refresh.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const targetPath = resolveProjectPath(projectPath, args.path || 'assets'); + return await refreshAssets(projectPath, targetPath); + }, + }, + { + name: 'run_script_diagnostics', + profile: 'core', + description: '[core] Run a TypeScript no-emit check for the current Cocos project and return parsed diagnostics.', + inputSchema: createSchema( + { + tsconfigPath: { type: 'string', description: 'Optional path to the tsconfig file to use.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + return await runScriptDiagnostics(projectPath, args); + }, + }, + { + name: 'get_runtime_state', + profile: 'core', + description: '[core] Get Cocos director runtime state, pause state, frame count, and scheduler time scale.', + inputSchema: createSchema({}, []), + handler: async (args) => sceneBridge.call('getRuntimeState', args), + }, + { + name: 'pause_runtime', + profile: 'core', + description: '[core] Pause Cocos director game logic execution.', + inputSchema: createSchema({}, []), + handler: async (args) => sceneBridge.call('pauseRuntime', args), + }, + { + name: 'resume_runtime', + profile: 'core', + description: '[core] Resume Cocos director game logic execution.', + inputSchema: createSchema({}, []), + handler: async (args) => sceneBridge.call('resumeRuntime', args), + }, + { + name: 'set_time_scale', + profile: 'core', + description: '[core] Set Cocos scheduler time scale for runtime validation.', + inputSchema: createSchema( + { + scale: { type: 'number', description: 'Time scale from 0 to 100.' }, + }, + ['scale'] + ), + handler: async (args) => sceneBridge.call('setTimeScale', args), + }, + { + name: 'emit_node_event', + profile: 'core', + description: '[core] Emit a custom event on a target scene node with an optional JSON payload.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + eventName: { type: 'string', description: 'Event name to emit.' }, + payload: { type: 'object', description: 'Optional event payload object.' }, + }, + ['eventName'] + ), + handler: async (args) => sceneBridge.call('emitNodeEvent', args), + }, + { + name: 'simulate_button_click', + profile: 'core', + description: '[core] Simulate a Cocos Button click by emitting click events on the target button node.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Button node hierarchy path.' }, + uuid: { type: 'string', description: 'Button node uuid.' }, + name: { type: 'string', description: 'Fallback exact button node name.' }, + }, + [] + ), + handler: async (args) => sceneBridge.call('simulateButtonClick', args), + }, + { + name: 'invoke_component_method', + profile: 'core', + description: '[core] Invoke a method on a component for runtime validation and test hooks.', + inputSchema: createSchema( + { + path: { type: 'string', description: 'Node hierarchy path.' }, + uuid: { type: 'string', description: 'Node uuid.' }, + name: { type: 'string', description: 'Fallback exact node name.' }, + componentName: { type: 'string', description: 'Component class name.' }, + index: { type: 'number', description: 'Optional component index.' }, + methodName: { type: 'string', description: 'Method name to invoke.' }, + args: { type: 'array', description: 'Optional argument array.' }, + }, + ['methodName'] + ), + handler: async (args) => sceneBridge.call('invokeComponentMethod', args), + }, + { + name: 'get_script_diagnostic_context', + profile: 'core', + description: '[core] Run TypeScript diagnostics and attach source snippets for each error.', + inputSchema: createSchema( + { + tsconfigPath: { type: 'string', description: 'Optional path to the tsconfig file to use.' }, + contextLines: { type: 'number', description: 'Number of surrounding source lines per diagnostic.' }, + limit: { type: 'number', description: 'Maximum diagnostics to include.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const result = await runScriptDiagnostics(projectPath, args); + const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(50, args.limit)) : 10; + const contextLines = Number.isFinite(args.contextLines) ? Math.max(0, Math.min(20, args.contextLines)) : 3; + const diagnostics = result.diagnostics.slice(0, limit).map((diagnostic) => ({ + ...diagnostic, + snippet: fs.existsSync(diagnostic.file) + ? buildSnippet(diagnostic.file, diagnostic.line, contextLines) + : 'Source file not found.', + })); + + return { + ...result, + diagnostics, + }; + }, + }, + { + name: 'capture_desktop_screenshot', + profile: 'core', + description: '[core] Capture a screenshot from the local desktop and return it as an MCP image payload.', + inputSchema: createSchema( + { + fileName: { type: 'string', description: 'Optional output file name under temp/mcp-captures.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const result = await captureDesktopScreenshot(projectPath, args); + return result.dataUri; + }, + }, + { + name: 'capture_editor_screenshot', + profile: 'core', + description: '[core] Capture the focused Cocos Creator editor window using Electron and return it as an MCP image payload.', + inputSchema: createSchema( + { + fileName: { type: 'string', description: 'Optional output file name under temp/mcp-captures.' }, + titleContains: { type: 'string', description: 'Optional window title substring fallback if no window is focused.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const result = await captureEditorWindowScreenshot(projectPath, args); + return result.dataUri; + }, + }, + { + name: 'capture_scene_screenshot', + profile: 'core', + description: '[core] Capture the Scene panel region from the editor window with panel-level cropping when available.', + inputSchema: createSchema( + { + fileName: { type: 'string', description: 'Optional output file name under temp/mcp-captures.' }, + windowKind: { type: 'string', description: 'Window target kind: focused, editor, simulator, or preview.' }, + titleContains: { type: 'string', description: 'Optional window title substring fallback if no window is focused.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const result = await capturePanelScreenshot(projectPath, { ...args, panel: 'scene', windowKind: args.windowKind || 'editor' }); + return result.dataUri; + }, + }, + { + name: 'capture_game_screenshot', + profile: 'core', + description: '[core] Capture the Game/Preview panel region from the editor window with panel-level cropping when available.', + inputSchema: createSchema( + { + fileName: { type: 'string', description: 'Optional output file name under temp/mcp-captures.' }, + windowKind: { type: 'string', description: 'Window target kind: focused, editor, simulator, or preview.' }, + titleContains: { type: 'string', description: 'Optional window title substring fallback if no window is focused.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const result = await capturePanelScreenshot(projectPath, { ...args, panel: 'game', windowKind: args.windowKind || 'editor' }); + return result.dataUri; + }, + }, + { + name: 'list_editor_windows', + profile: 'core', + description: '[core] List available Electron windows so input injection and precise screenshots can target the correct one.', + inputSchema: createSchema({}, []), + handler: async () => listWindows(), + }, + { + name: 'simulate_mouse_click', + profile: 'core', + description: '[core] Send a low-level Electron mouse click to the editor, preview, or simulator window.', + inputSchema: createSchema( + { + windowKind: { type: 'string', description: 'Window target kind: focused, editor, simulator, or preview.' }, + panel: { type: 'string', description: 'Optional panel hint such as scene or game.' }, + titleContains: { type: 'string', description: 'Optional window title substring.' }, + x: { type: 'number', description: 'Panel-relative or window-relative x offset from center/focus target.' }, + y: { type: 'number', description: 'Panel-relative or window-relative y offset from center/focus target.' }, + button: { type: 'string', description: 'Mouse button: left, right, or middle.' }, + clickCount: { type: 'number', description: 'Click count.' }, + modifiers: { type: 'array', description: 'Optional key modifiers array.' }, + }, + [] + ), + handler: async (args) => await sendMouseClick(args), + }, + { + name: 'simulate_mouse_drag', + profile: 'core', + description: '[core] Send a low-level Electron mouse drag to the editor, preview, or simulator window.', + inputSchema: createSchema( + { + windowKind: { type: 'string', description: 'Window target kind: focused, editor, simulator, or preview.' }, + panel: { type: 'string', description: 'Optional panel hint such as scene or game.' }, + titleContains: { type: 'string', description: 'Optional window title substring.' }, + startX: { type: 'number', description: 'Start x offset.' }, + startY: { type: 'number', description: 'Start y offset.' }, + endX: { type: 'number', description: 'End x offset.' }, + endY: { type: 'number', description: 'End y offset.' }, + button: { type: 'string', description: 'Mouse button: left, right, or middle.' }, + steps: { type: 'number', description: 'How many intermediate move steps to send.' }, + stepDelayMs: { type: 'number', description: 'Optional delay between drag steps.' }, + modifiers: { type: 'array', description: 'Optional key modifiers array.' }, + }, + [] + ), + handler: async (args) => await sendMouseDrag(args), + }, + { + name: 'simulate_key_press', + profile: 'core', + description: '[core] Send a low-level Electron key press to the editor, preview, or simulator window.', + inputSchema: createSchema( + { + windowKind: { type: 'string', description: 'Window target kind: focused, editor, simulator, or preview.' }, + panel: { type: 'string', description: 'Optional panel hint such as scene or game.' }, + titleContains: { type: 'string', description: 'Optional window title substring.' }, + keyCode: { type: 'string', description: 'Electron keyCode such as A, Space, Enter, ArrowLeft.' }, + text: { type: 'string', description: 'Optional text payload for char events.' }, + modifiers: { type: 'array', description: 'Optional key modifiers array.' }, + }, + ['keyCode'] + ), + handler: async (args) => await sendKeyPress(args), + }, + { + name: 'simulate_key_combo', + profile: 'core', + description: '[core] Send a low-level Electron modified key press such as Ctrl+S or Cmd+P.', + inputSchema: createSchema( + { + windowKind: { type: 'string', description: 'Window target kind: focused, editor, simulator, or preview.' }, + panel: { type: 'string', description: 'Optional panel hint such as scene or game.' }, + titleContains: { type: 'string', description: 'Optional window title substring.' }, + keyCode: { type: 'string', description: 'Electron keyCode such as S, P, Enter.' }, + modifiers: { type: 'array', description: 'Modifier array such as [\"command\"] or [\"control\",\"shift\"].' }, + }, + ['keyCode', 'modifiers'] + ), + handler: async (args) => await sendKeyCombo(args), + }, + { + name: 'simulate_preview_input', + profile: 'core', + description: '[core] Convenience wrapper for low-level preview/simulator input. Uses mouse click by default or key press when keyCode is provided.', + inputSchema: createSchema( + { + windowKind: { type: 'string', description: 'Window target kind, usually preview or simulator.' }, + panel: { type: 'string', description: 'Optional panel hint such as game.' }, + titleContains: { type: 'string', description: 'Optional window title substring.' }, + mode: { type: 'string', description: 'click, drag, key, or combo.' }, + x: { type: 'number', description: 'Mouse x offset.' }, + y: { type: 'number', description: 'Mouse y offset.' }, + startX: { type: 'number', description: 'Drag start x offset.' }, + startY: { type: 'number', description: 'Drag start y offset.' }, + endX: { type: 'number', description: 'Drag end x offset.' }, + endY: { type: 'number', description: 'Drag end y offset.' }, + keyCode: { type: 'string', description: 'Electron keyCode for key or combo mode.' }, + text: { type: 'string', description: 'Optional char payload.' }, + button: { type: 'string', description: 'Mouse button.' }, + modifiers: { type: 'array', description: 'Modifier array.' }, + }, + [] + ), + handler: async (args) => { + const mode = String(args.mode || (args.keyCode ? 'key' : 'click')).toLowerCase(); + const base = { ...args, windowKind: args.windowKind || 'preview', panel: args.panel || 'game' }; + if (mode === 'drag') return await sendMouseDrag(base); + if (mode === 'combo') return await sendKeyCombo(base); + if (mode === 'key') return await sendKeyPress(base); + return await sendMouseClick(base); + }, + }, + { + name: 'capture_preview_screenshot', + profile: 'core', + description: '[core] Capture the preview/simulator window or game panel as an MCP image payload.', + inputSchema: createSchema( + { + fileName: { type: 'string', description: 'Optional output file name under temp/mcp-captures.' }, + windowKind: { type: 'string', description: 'Window target kind, usually preview or simulator.' }, + titleContains: { type: 'string', description: 'Optional window title substring.' }, + }, + [] + ), + handler: async (args) => { + const { projectPath } = getRuntimeContext(); + const result = await capturePanelScreenshot(projectPath, { ...args, panel: 'game', windowKind: args.windowKind || 'preview' }); + return result.dataUri; + }, + }, + ]; + + return { + listTools() { + const { config } = getRuntimeContext(); + return tools + .filter((tool) => config.toolProfile === 'full' || tool.profile === 'core') + .map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + }, + async callTool(name, args) { + const { config } = getRuntimeContext(); + const tool = tools.find((item) => item.name === name); + if (!tool) { + throw new Error(`Unknown tool '${name}'`); + } + if (config.toolProfile !== 'full' && tool.profile !== 'core') { + throw new Error(`Tool '${name}' is not exposed by the current MCP tool profile '${config.toolProfile}'.`); + } + + try { + const result = await tool.handler(args || {}); + const output = toOutput(result); + interactionLog.add(name, 'success', output.slice(0, 500)); + return output; + } catch (error) { + interactionLog.add(name, 'error', error.message); + throw error; + } + }, + }; +} + +module.exports = { + createToolRegistry, +}; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..a689402 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,31 @@ +'use strict'; + +function safeJsonParse(text, fallback = null) { + try { + return JSON.parse(text); + } catch (error) { + return fallback; + } +} + +function safeStringify(value) { + const seen = new WeakSet(); + return JSON.stringify( + value, + (key, current) => { + if (typeof current === 'object' && current !== null) { + if (seen.has(current)) { + return '[Circular]'; + } + seen.add(current); + } + return current; + }, + 2 + ); +} + +module.exports = { + safeJsonParse, + safeStringify, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..40b70b4 --- /dev/null +++ b/package.json @@ -0,0 +1,99 @@ +{ + "name": "funplay-cocos-mcp", + "package_version": 2, + "version": "0.1.0", + "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", + "author": "Funplay", + "license": "MIT", + "main": "browser.js", + "scripts": { + "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/utils.js" + }, + "panels": { + "default": { + "title": "Funplay Cocos MCP", + "type": "dockable", + "main": "panel/index.js", + "size": { + "min-width": 520, + "min-height": 480, + "width": 760, + "height": 720 + } + } + }, + "contributions": { + "menu": [ + { + "path": "Funplay", + "label": "MCP Server", + "message": "open-panel" + } + ], + "messages": { + "open-panel": { + "methods": [ + "openPanel" + ] + }, + "start-server": { + "methods": [ + "startServer" + ] + }, + "stop-server": { + "methods": [ + "stopServer" + ] + }, + "restart-server": { + "methods": [ + "restartServer" + ] + }, + "get-status": { + "methods": [ + "getStatus" + ] + }, + "get-panel-state": { + "methods": [ + "getPanelState" + ] + }, + "save-config": { + "methods": [ + "saveConfig" + ] + }, + "list-tools": { + "methods": [ + "listToolsForPanel" + ] + }, + "call-tool": { + "methods": [ + "callToolFromPanel" + ] + }, + "read-resource": { + "methods": [ + "readResourceFromPanel" + ] + }, + "get-client-config": { + "methods": [ + "getClientConfig" + ] + }, + "configure-client": { + "methods": [ + "configureClient" + ] + } + }, + "scene": { + "script": "scene.js" + } + } +} diff --git a/panel/index.js b/panel/index.js new file mode 100644 index 0000000..8cd449a --- /dev/null +++ b/panel/index.js @@ -0,0 +1,345 @@ +'use strict'; + +const PKG = 'funplay-cocos-mcp'; + +function request(message, ...args) { + return Editor.Message.request(PKG, message, ...args); +} + +function stringify(value) { + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value, null, 2); +} + +module.exports = Editor.Panel.define({ + template: ` +
+
+
+

Funplay Cocos MCP

+

Operate the embedded MCP server from Cocos Creator.

+
+
Unknown
+
+ +
+

Service

+
+
+ + Restart + Copy URL +
+
+ + +
+

Changes auto-save. Port/profile changes restart the server when needed.

+
+ +
+

MCP Client Config

+
+ + One-Click Configure +
+
+ +
+ +
+
+ Debug Output +

+        
+
+
+ `, + style: ` + :host { + color: var(--color-normal-contrast); + background: var(--color-normal-fill); + font-size: 13px; + } + .mcp-root { + padding: 14px; + box-sizing: border-box; + overflow: auto; + height: 100%; + } + .hero { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + h1 { + margin: 0; + font-size: 22px; + } + h2 { + margin: 0 0 10px 0; + font-size: 15px; + } + p { + margin: 4px 0 0 0; + color: var(--color-normal-contrast-weakest); + } + .tip { + margin: 0 0 10px 0; + } + code { + font-family: Menlo, monospace; + background: rgba(255,255,255,0.08); + padding: 1px 4px; + border-radius: 4px; + color: var(--color-normal-contrast); + } + .card { + border: 1px solid var(--color-normal-border); + border-radius: 8px; + background: var(--color-normal-fill-emphasis); + padding: 12px; + margin-bottom: 12px; + } + .row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-top: 8px; + } + .top-actions { + margin-top: 0; + margin-bottom: 10px; + } + .grid { + display: grid; + grid-template-columns: repeat(2, minmax(140px, 1fr)); + gap: 8px; + align-items: end; + } + label { + display: flex; + flex-direction: column; + gap: 4px; + color: var(--color-normal-contrast-weak); + } + .checkbox-line { + flex-direction: row; + align-items: center; + } + .checkbox-inline { + padding-top: 0; + color: var(--color-normal-contrast); + gap: 6px; + } + .status-pill { + border-radius: 999px; + padding: 6px 10px; + background: #555; + color: white; + font-weight: 600; + } + .status-pill.running { + background: #1f8f4d; + } + .status-pill.stopped { + background: #8f3d3d; + } + .muted { + color: var(--color-normal-contrast-weakest); + } + .status-line { + margin-bottom: 10px; + line-height: 1.5; + } + .client-status { + margin: 8px 0; + white-space: pre-wrap; + word-break: break-all; + } + details { + display: block; + } + summary { + cursor: pointer; + font-size: 15px; + font-weight: 600; + color: var(--color-normal-contrast); + outline: none; + user-select: none; + } + ui-textarea { + width: 100%; + min-height: 100px; + } + pre { + min-height: 120px; + max-height: 220px; + overflow: auto; + margin: 0; + background: #111; + color: #d7ffd7; + padding: 10px; + border-radius: 6px; + white-space: pre-wrap; + word-break: break-word; + } + .primary { + border-color: #4aa3ff; + } + `, + $: { + root: '.mcp-root', + statusPill: '#statusPill', + statusText: '#statusText', + enabledInput: '#enabledInput', + portInput: '#portInput', + profileSelect: '#profileSelect', + restartBtn: '#restartBtn', + copyUrlBtn: '#copyUrlBtn', + clientTargetSelect: '#clientTargetSelect', + configureClientBtn: '#configureClientBtn', + clientTargetStatus: '#clientTargetStatus', + clientConfigText: '#clientConfigText', + output: '#output', + }, + methods: { + async refresh() { + try { + this.state = await request('get-panel-state'); + this.renderState(); + } catch (error) { + this.showOutput(`Refresh failed: ${error.message}`); + } + }, + renderState() { + const state = this.state || {}; + const status = state.status || {}; + const config = state.config || {}; + const isRunning = Boolean(status.running); + + this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped'; + this.$.statusPill.classList.toggle('running', isRunning); + this.$.statusPill.classList.toggle('stopped', !isRunning); + this.$.statusText.textContent = `${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}`; + + this.$.enabledInput.value = Boolean(isRunning || config.autostart); + this.$.portInput.value = Number(config.port || status.port || 8765); + this.$.profileSelect.value = config.toolProfile || status.toolProfile || 'core'; + + this.$.clientConfigText.value = state.clientConfig ? state.clientConfig.codex : ''; + this.renderClientTargets(); + }, + renderClientTargets() { + const targets = (this.state && this.state.clientTargets) || []; + const selected = this.$.clientTargetSelect.value || (targets[0] && targets[0].id); + this.$.clientTargetSelect.innerHTML = targets + .map((target) => ``) + .join(''); + if (selected) { + this.$.clientTargetSelect.value = selected; + } + this.renderClientTargetStatus(); + }, + renderClientTargetStatus() { + const targets = (this.state && this.state.clientTargets) || []; + const target = targets.find((item) => item.id === this.$.clientTargetSelect.value) || targets[0]; + if (!target) { + this.$.clientTargetStatus.textContent = 'No client targets available.'; + return; + } + this.$.clientTargetStatus.textContent = `${target.configured ? 'Configured' : 'Not configured'}: ${target.configPath}`; + }, + showOutput(value) { + this.$.output.textContent = stringify(value); + }, + async persistConfig(options = {}) { + const { showOutput = false } = options; + try { + const panelState = await request('save-config', this.collectConfig()); + this.state = panelState; + this.renderState(); + if (showOutput) { + this.showOutput('Configuration saved.'); + } + return panelState; + } catch (error) { + this.showOutput(`Save config failed: ${error.message}`); + throw error; + } + }, + async runAction(action) { + try { + const result = await action(); + this.showOutput(result); + await this.refresh(); + } catch (error) { + this.showOutput(`Error: ${error.message}`); + } + }, + collectConfig() { + return { + host: (this.state && this.state.config && this.state.config.host) || (this.state && this.state.status && this.state.status.host) || '127.0.0.1', + port: Number(this.$.portInput.value || 8765), + toolProfile: this.$.profileSelect.value || 'core', + autostart: Boolean(this.$.enabledInput.value), + maxInteractionLogEntries: this.state && this.state.config ? this.state.config.maxInteractionLogEntries : 50, + }; + }, + async handleEnableToggle() { + const shouldEnable = Boolean(this.$.enabledInput.value); + const wasRunning = Boolean(this.state && this.state.status && this.state.status.running); + + await this.persistConfig(); + if (shouldEnable && !wasRunning) { + await this.runAction(() => request('start-server')); + return; + } + if (!shouldEnable && wasRunning) { + await this.runAction(() => request('stop-server')); + return; + } + await this.refresh(); + }, + }, + ready() { + this.state = null; + + this.$.restartBtn.addEventListener('click', () => this.runAction(() => request('restart-server'))); + this.$.copyUrlBtn.addEventListener('click', () => { + const status = this.state && this.state.status; + const text = status && status.url ? status.url : ''; + navigator.clipboard.writeText(text) + .then(() => this.showOutput('Copied URL to clipboard.')) + .catch(() => this.showOutput(text)); + }); + this.$.enabledInput.addEventListener('change', () => this.handleEnableToggle()); + this.$.portInput.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.profileSelect.addEventListener('change', () => this.persistConfig({ showOutput: true })); + this.$.clientTargetSelect.addEventListener('confirm', () => this.renderClientTargetStatus()); + this.$.clientTargetSelect.addEventListener('change', () => this.renderClientTargetStatus()); + this.$.configureClientBtn.addEventListener('click', () => { + const targetId = this.$.clientTargetSelect.value; + if (!targetId) { + this.showOutput('Select a client target first.'); + return; + } + this.runAction(() => request('configure-client', targetId)); + }); + + this.refresh(); + }, + close() {}, +}); diff --git a/scene.js b/scene.js new file mode 100644 index 0000000..efaf521 --- /dev/null +++ b/scene.js @@ -0,0 +1,1255 @@ +'use strict'; + +module.paths.push(Editor.App.path + '/node_modules'); + +const cc = require('cc'); + +const { + Node, + director, + Vec3, + Quat, + Color, + assetManager, + instantiate, + Prefab, + SceneAsset, + js, + Component, + Canvas, + UITransform, + Label, + Sprite, + Button, + Widget, + Camera, + Animation, + AnimationClip, +} = cc; +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + +function getScene() { + const scene = director.getScene(); + if (!scene) { + throw new Error('No active scene is loaded.'); + } + return scene; +} + +function getComponentNames(node) { + const components = Array.isArray(node.components) ? node.components : []; + return components + .map((component) => component && component.constructor && component.constructor.name) + .filter(Boolean); +} + +function getSerializableKeys(target) { + const keys = new Set(); + let current = target; + let depth = 0; + while (current && current !== Object.prototype && depth < 4) { + for (const key of Object.keys(current)) { + if (!key.startsWith('_')) { + keys.add(key); + } + } + current = Object.getPrototypeOf(current); + depth += 1; + } + return Array.from(keys); +} + +function getNodePath(node) { + const names = []; + let current = node; + const scene = getScene(); + + while (current && current !== scene) { + names.unshift(current.name); + current = current.parent; + } + + return names.join('/'); +} + +function vectorToObject(value) { + if (!value) { + return null; + } + return { x: value.x, y: value.y, z: value.z }; +} + +function quatToObject(value) { + if (!value) { + return null; + } + return { x: value.x, y: value.y, z: value.z, w: value.w }; +} + +function colorToObject(value) { + if (!value) { + return null; + } + return { r: value.r, g: value.g, b: value.b, a: value.a }; +} + +function summarizeNode(node, depth, maxDepth, includeComponents, includeInactive) { + if (!includeInactive && !node.active) { + return null; + } + + const summary = { + name: node.name, + path: getNodePath(node), + uuid: node.uuid, + active: Boolean(node.active), + layer: node.layer, + position: vectorToObject(node.position), + rotation: quatToObject(node.rotation), + scale: vectorToObject(node.scale), + }; + + if (includeComponents) { + summary.components = getComponentNames(node); + } + + if (depth < maxDepth) { + const children = []; + for (const child of node.children) { + const childSummary = summarizeNode(child, depth + 1, maxDepth, includeComponents, includeInactive); + if (childSummary) { + children.push(childSummary); + } + } + summary.children = children; + } else { + summary.childCount = node.children.length; + } + + return summary; +} + +function walkNodes(visitor, node) { + visitor(node); + for (const child of node.children) { + walkNodes(visitor, child); + } +} + +function findNodeByPath(nodePath) { + if (!nodePath) { + return null; + } + + const segments = String(nodePath) + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); + + let current = getScene(); + if (segments[0] === current.name) { + segments.shift(); + } + + for (const segment of segments) { + current = current.children.find((child) => child.name === segment); + if (!current) { + return null; + } + } + + return current; +} + +function findNodeByUuid(uuid) { + if (!uuid) { + return null; + } + + let match = null; + walkNodes((node) => { + if (!match && node.uuid === uuid) { + match = node; + } + }, getScene()); + return match; +} + +function findNodeByName(name) { + if (!name) { + return null; + } + + let match = null; + walkNodes((node) => { + if (!match && node.name === name) { + match = node; + } + }, getScene()); + return match; +} + +function findNode(input) { + return findNodeByUuid(input.uuid) || findNodeByPath(input.path) || findNodeByName(input.name); +} + +function resolveComponentClass(componentName) { + if (!componentName) { + return null; + } + if (typeof componentName !== 'string') { + return componentName; + } + + const direct = js && typeof js.getClassByName === 'function' ? js.getClassByName(componentName) : null; + if (direct) { + return direct; + } + + const candidates = [componentName, `cc.${componentName}`]; + for (const candidate of candidates) { + const found = js && typeof js.getClassByName === 'function' ? js.getClassByName(candidate) : null; + if (found) { + return found; + } + } + + return null; +} + +function findComponent(node, options = {}) { + if (!node) { + return null; + } + + if (Number.isInteger(options.index)) { + return node.components[options.index] || null; + } + + if (options.componentName) { + const exact = node.components.find((component) => component && component.constructor && component.constructor.name === options.componentName); + if (exact) { + return exact; + } + + const componentClass = resolveComponentClass(options.componentName); + if (componentClass) { + return node.getComponent(componentClass); + } + } + + return null; +} + +function getOrAddComponent(node, componentClass) { + return node.getComponent(componentClass) || node.addComponent(componentClass); +} + +function configureNodeBasics(node, options = {}) { + if (options.position) { + node.setPosition(options.position.x || 0, options.position.y || 0, options.position.z || 0); + } + if (options.scale) { + node.setScale(options.scale.x || 1, options.scale.y || 1, options.scale.z || 1); + } + if (options.eulerAngles) { + node.setRotationFromEuler( + options.eulerAngles.x || 0, + options.eulerAngles.y || 0, + options.eulerAngles.z || 0 + ); + } + if (typeof options.active === 'boolean') { + node.active = options.active; + } +} + +function configureUITransform(node, options = {}) { + const transform = getOrAddComponent(node, UITransform); + const width = Number.isFinite(options.width) ? options.width : 160; + const height = Number.isFinite(options.height) ? options.height : 60; + transform.setContentSize(width, height); + if (options.anchor) { + transform.setAnchorPoint(options.anchor.x ?? 0.5, options.anchor.y ?? 0.5); + } + return transform; +} + +function parseColor(value, fallback = Color.WHITE) { + if (!value) { + return fallback.clone ? fallback.clone() : fallback; + } + if (typeof value === 'string') { + const normalized = value.startsWith('#') ? value.slice(1) : value; + const number = Number.parseInt(normalized.length === 6 ? `${normalized}ff` : normalized, 16); + if (Number.isFinite(number)) { + return new Color( + (number >> 24) & 255, + (number >> 16) & 255, + (number >> 8) & 255, + number & 255 + ); + } + } + return new Color(value.r ?? 255, value.g ?? 255, value.b ?? 255, value.a ?? 255); +} + +function findComponentsByClass(componentClass) { + const results = []; + walkNodes((node) => { + if (node === getScene()) { + return; + } + const component = node.getComponent(componentClass); + if (component) { + results.push(component); + } + }, getScene()); + return results; +} + +function getScheduler() { + return typeof director.getScheduler === 'function' ? director.getScheduler() : null; +} + +function loadAnimationClipByUuid(uuid) { + return new Promise((resolve, reject) => { + assetManager.loadAny(uuid, (error, asset) => { + if (error) { + reject(error); + return; + } + if (!(asset instanceof AnimationClip)) { + reject(new Error(`Asset '${uuid}' is not an AnimationClip.`)); + return; + } + resolve(asset); + }); + }); +} + +function getValueByPath(target, propertyPath) { + const segments = String(propertyPath || '') + .split('.') + .map((segment) => segment.trim()) + .filter(Boolean); + + let current = target; + for (const segment of segments) { + if (current == null) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function setValueByPath(target, propertyPath, value) { + const segments = String(propertyPath || '') + .split('.') + .map((segment) => segment.trim()) + .filter(Boolean); + + if (!segments.length) { + throw new Error('propertyPath is required.'); + } + + let current = target; + for (let index = 0; index < segments.length - 1; index += 1) { + const segment = segments[index]; + if (current[segment] == null || typeof current[segment] !== 'object') { + current[segment] = {}; + } + current = current[segment]; + } + + current[segments[segments.length - 1]] = value; +} + +function resetValueByPath(target, propertyPath) { + const segments = String(propertyPath || '') + .split('.') + .map((segment) => segment.trim()) + .filter(Boolean); + + if (!segments.length) { + throw new Error('propertyPath is required.'); + } + + let current = target; + for (let index = 0; index < segments.length - 1; index += 1) { + current = current[segments[index]]; + if (current == null) { + return; + } + } + + const key = segments[segments.length - 1]; + if (current && Object.prototype.hasOwnProperty.call(current, key)) { + delete current[key]; + } else if (current) { + current[key] = undefined; + } +} + +function loadAssetByUuid(uuid) { + return new Promise((resolve, reject) => { + assetManager.loadAny(uuid, (error, asset) => { + if (error) { + reject(error); + return; + } + resolve(asset); + }); + }); +} + +function plain(value, depth = 0, seen = new WeakSet()) { + if (value == null) { + return value; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value instanceof Node) { + return { + name: value.name, + path: getNodePath(value), + uuid: value.uuid, + active: Boolean(value.active), + components: getComponentNames(value), + }; + } + + if (value instanceof Vec3) { + return vectorToObject(value); + } + + if (value instanceof Quat) { + return quatToObject(value); + } + + if (value instanceof Color) { + return colorToObject(value); + } + + if (Array.isArray(value)) { + if (depth >= 5) { + return `[Array(${value.length})]`; + } + return value.map((item) => plain(item, depth + 1, seen)); + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + + if (depth >= 5) { + return `[${value.constructor && value.constructor.name ? value.constructor.name : 'Object'}]`; + } + + const output = {}; + for (const key of Object.keys(value)) { + try { + output[key] = plain(value[key], depth + 1, seen); + } catch (error) { + output[key] = `[Unserializable: ${error.message}]`; + } + } + return output; + } + + return String(value); +} + +async function executeUserCode(code, args) { + const scene = getScene(); + const runner = new AsyncFunction('require', 'cc', 'Editor', 'scene', 'director', 'args', ` + const module = { exports: {} }; + const exports = module.exports; + ${code} + if (typeof run === 'function') { + return await run({ cc, Editor, scene, director, args }); + } + if (typeof module.exports === 'function') { + return await module.exports({ cc, Editor, scene, director, args }); + } + if (module.exports && typeof module.exports.run === 'function') { + return await module.exports.run({ cc, Editor, scene, director, args }); + } + `); + return await runner(require, cc, global.Editor, scene, director, args || {}); +} + +exports.methods = { + async getSceneInfo(options = {}) { + const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 2; + const includeComponents = options.includeComponents !== false; + const scene = getScene(); + return { + sceneName: scene.name, + uuid: scene.uuid, + childCount: scene.children.length, + nodes: scene.children + .map((child) => summarizeNode(child, 1, Math.max(1, maxDepth), includeComponents, true)) + .filter(Boolean), + }; + }, + + async getHierarchy(options = {}) { + const root = options.rootPath ? findNodeByPath(options.rootPath) : getScene(); + if (!root) { + throw new Error(`Node not found: ${options.rootPath}`); + } + + const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 3; + const includeComponents = options.includeComponents !== false; + const includeInactive = options.includeInactive !== false; + + if (root === getScene()) { + return { + sceneName: root.name, + nodes: root.children + .map((child) => summarizeNode(child, 1, Math.max(1, maxDepth), includeComponents, includeInactive)) + .filter(Boolean), + }; + } + + return summarizeNode(root, 0, Math.max(1, maxDepth), includeComponents, includeInactive); + }, + + async inspectNode(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + return { + name: node.name, + path: getNodePath(node), + uuid: node.uuid, + active: Boolean(node.active), + layer: node.layer, + siblingIndex: node.getSiblingIndex(), + position: vectorToObject(node.position), + rotation: quatToObject(node.rotation), + scale: vectorToObject(node.scale), + children: node.children.map((child) => ({ + name: child.name, + path: getNodePath(child), + uuid: child.uuid, + })), + components: getComponentNames(node), + }; + }, + + async findNodes(options = {}) { + const name = options.name ? String(options.name) : ''; + const pathContains = options.pathContains ? String(options.pathContains) : ''; + const component = options.component ? String(options.component) : ''; + const includeInactive = options.includeInactive !== false; + const results = []; + + walkNodes((node) => { + if (node === getScene()) { + return; + } + + if (!includeInactive && !node.active) { + return; + } + + const nodePath = getNodePath(node); + const components = getComponentNames(node); + + if (name && node.name !== name) { + return; + } + + if (pathContains && !nodePath.includes(pathContains)) { + return; + } + + if (component && !components.includes(component)) { + return; + } + + results.push({ + name: node.name, + path: nodePath, + uuid: node.uuid, + active: Boolean(node.active), + components, + }); + }, getScene()); + + return { + count: results.length, + nodes: results.slice(0, 200), + }; + }, + + async createNode(options = {}) { + const name = String(options.name || '').trim(); + if (!name) { + throw new Error('name is required.'); + } + + const parent = options.parentPath ? findNodeByPath(options.parentPath) : getScene(); + if (!parent) { + throw new Error(`Parent not found: ${options.parentPath}`); + } + + const node = new Node(name); + node.parent = parent; + + if (options.position) { + node.setPosition(options.position.x || 0, options.position.y || 0, options.position.z || 0); + } + + if (options.scale) { + node.setScale(options.scale.x || 1, options.scale.y || 1, options.scale.z || 1); + } + + if (options.eulerAngles) { + node.setRotationFromEuler( + options.eulerAngles.x || 0, + options.eulerAngles.y || 0, + options.eulerAngles.z || 0 + ); + } + + if (typeof options.active === 'boolean') { + node.active = options.active; + } + + return { + created: true, + name: node.name, + path: getNodePath(node), + uuid: node.uuid, + }; + }, + + async deleteNode(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const targetPath = getNodePath(node); + node.removeFromParent(); + node.destroy(); + + return { + deleted: true, + path: targetPath, + uuid: node.uuid, + }; + }, + + async setNodeTransform(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + if (options.position) { + node.setPosition(options.position.x || 0, options.position.y || 0, options.position.z || 0); + } + + if (options.scale) { + node.setScale(options.scale.x || 1, options.scale.y || 1, options.scale.z || 1); + } + + if (options.eulerAngles) { + node.setRotationFromEuler( + options.eulerAngles.x || 0, + options.eulerAngles.y || 0, + options.eulerAngles.z || 0 + ); + } + + if (typeof options.active === 'boolean') { + node.active = options.active; + } + + return { + updated: true, + name: node.name, + path: getNodePath(node), + active: Boolean(node.active), + position: vectorToObject(node.position), + rotation: quatToObject(node.rotation), + scale: vectorToObject(node.scale), + }; + }, + + async listComponents(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + return { + node: { + name: node.name, + path: getNodePath(node), + uuid: node.uuid, + }, + components: node.components.map((component, index) => ({ + index, + name: component && component.constructor ? component.constructor.name : 'UnknownComponent', + enabled: typeof component.enabled === 'boolean' ? component.enabled : undefined, + keys: getSerializableKeys(component).slice(0, 50), + })), + }; + }, + + async addComponent(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const componentClass = resolveComponentClass(options.componentName); + if (!componentClass) { + throw new Error(`Component class not found: ${options.componentName}`); + } + + const component = node.addComponent(componentClass); + return { + added: true, + node: getNodePath(node), + component: component.constructor ? component.constructor.name : options.componentName, + index: node.components.indexOf(component), + }; + }, + + async removeComponent(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const component = findComponent(node, options); + if (!component) { + throw new Error('Target component was not found.'); + } + + const componentName = component.constructor ? component.constructor.name : 'UnknownComponent'; + node.removeComponent(component); + return { + removed: true, + node: getNodePath(node), + component: componentName, + }; + }, + + async inspectComponent(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const component = findComponent(node, options); + if (!component) { + throw new Error('Target component was not found.'); + } + + return { + node: { + name: node.name, + path: getNodePath(node), + uuid: node.uuid, + }, + component: { + name: component.constructor ? component.constructor.name : 'UnknownComponent', + enabled: typeof component.enabled === 'boolean' ? component.enabled : undefined, + data: plain(component), + }, + }; + }, + + async setComponentProperty(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const component = findComponent(node, options); + if (!component) { + throw new Error('Target component was not found.'); + } + + setValueByPath(component, options.propertyPath, options.value); + return { + updated: true, + node: getNodePath(node), + component: component.constructor ? component.constructor.name : 'UnknownComponent', + propertyPath: options.propertyPath, + value: plain(getValueByPath(component, options.propertyPath)), + }; + }, + + async resetComponentProperty(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + + const component = findComponent(node, options); + if (!component) { + throw new Error('Target component was not found.'); + } + + resetValueByPath(component, options.propertyPath); + return { + reset: true, + node: getNodePath(node), + component: component.constructor ? component.constructor.name : 'UnknownComponent', + propertyPath: options.propertyPath, + value: plain(getValueByPath(component, options.propertyPath)), + }; + }, + + async instantiatePrefab(options = {}) { + const prefabUuid = String(options.prefabUuid || '').trim(); + if (!prefabUuid) { + throw new Error('prefabUuid is required.'); + } + + const parent = options.parentPath ? findNodeByPath(options.parentPath) : getScene(); + if (!parent) { + throw new Error(`Parent not found: ${options.parentPath}`); + } + + const asset = await loadAssetByUuid(prefabUuid); + if (!(asset instanceof Prefab)) { + throw new Error(`Asset '${prefabUuid}' is not a Prefab.`); + } + + const node = instantiate(asset); + node.parent = parent; + + if (options.name) { + node.name = options.name; + } + if (options.position) { + node.setPosition(options.position.x || 0, options.position.y || 0, options.position.z || 0); + } + + return { + instantiated: true, + prefabUuid, + node: { + name: node.name, + path: getNodePath(node), + uuid: node.uuid, + }, + }; + }, + + async runSceneAsset(options = {}) { + const sceneUuid = String(options.sceneUuid || '').trim(); + if (!sceneUuid) { + throw new Error('sceneUuid is required.'); + } + + const asset = await loadAssetByUuid(sceneUuid); + if (!(asset instanceof SceneAsset)) { + throw new Error(`Asset '${sceneUuid}' is not a SceneAsset.`); + } + + director.runSceneImmediate(asset); + const scene = getScene(); + return { + loaded: true, + sceneUuid, + sceneName: scene.name, + childCount: scene.children.length, + }; + }, + + async createCanvas(options = {}) { + const parent = options.parentPath ? findNodeByPath(options.parentPath) : getScene(); + if (!parent) { + throw new Error(`Parent not found: ${options.parentPath}`); + } + + const node = new Node(options.name || 'Canvas'); + node.parent = parent; + configureNodeBasics(node, options); + getOrAddComponent(node, Canvas); + configureUITransform(node, { + width: Number.isFinite(options.width) ? options.width : 1280, + height: Number.isFinite(options.height) ? options.height : 720, + }); + + return { + created: true, + name: node.name, + path: getNodePath(node), + uuid: node.uuid, + components: getComponentNames(node), + }; + }, + + async createLabel(options = {}) { + const parent = options.parentPath ? findNodeByPath(options.parentPath) : getScene(); + if (!parent) { + throw new Error(`Parent not found: ${options.parentPath}`); + } + + const node = new Node(options.name || 'Label'); + node.parent = parent; + configureNodeBasics(node, options); + configureUITransform(node, options); + const label = getOrAddComponent(node, Label); + label.string = options.text || 'Label'; + label.fontSize = Number.isFinite(options.fontSize) ? options.fontSize : 32; + label.lineHeight = Number.isFinite(options.lineHeight) ? options.lineHeight : label.fontSize + 8; + label.color = parseColor(options.color, Color.WHITE); + + return { + created: true, + path: getNodePath(node), + uuid: node.uuid, + text: label.string, + }; + }, + + async createButton(options = {}) { + const parent = options.parentPath ? findNodeByPath(options.parentPath) : getScene(); + if (!parent) { + throw new Error(`Parent not found: ${options.parentPath}`); + } + + const node = new Node(options.name || 'Button'); + node.parent = parent; + configureNodeBasics(node, options); + configureUITransform(node, { + ...options, + width: Number.isFinite(options.width) ? options.width : 180, + height: Number.isFinite(options.height) ? options.height : 64, + }); + const sprite = getOrAddComponent(node, Sprite); + sprite.color = parseColor(options.backgroundColor, new Color(64, 96, 255, 255)); + const button = getOrAddComponent(node, Button); + button.target = node; + + const labelNode = new Node(options.labelName || 'Label'); + labelNode.parent = node; + configureUITransform(labelNode, { + width: Number.isFinite(options.width) ? options.width : 180, + height: Number.isFinite(options.height) ? options.height : 64, + }); + const label = getOrAddComponent(labelNode, Label); + label.string = options.text || 'Button'; + label.fontSize = Number.isFinite(options.fontSize) ? options.fontSize : 28; + label.lineHeight = Number.isFinite(options.lineHeight) ? options.lineHeight : label.fontSize + 8; + label.color = parseColor(options.textColor, Color.WHITE); + + return { + created: true, + path: getNodePath(node), + uuid: node.uuid, + labelPath: getNodePath(labelNode), + components: getComponentNames(node), + }; + }, + + async createSprite(options = {}) { + const parent = options.parentPath ? findNodeByPath(options.parentPath) : getScene(); + if (!parent) { + throw new Error(`Parent not found: ${options.parentPath}`); + } + + const node = new Node(options.name || 'Sprite'); + node.parent = parent; + configureNodeBasics(node, options); + configureUITransform(node, options); + const sprite = getOrAddComponent(node, Sprite); + sprite.color = parseColor(options.color, Color.WHITE); + if (options.spriteFrameUuid) { + sprite.spriteFrame = await loadAssetByUuid(options.spriteFrameUuid); + } + + return { + created: true, + path: getNodePath(node), + uuid: node.uuid, + components: getComponentNames(node), + }; + }, + + async listCameras() { + const cameras = findComponentsByClass(Camera); + return { + count: cameras.length, + cameras: cameras.map((camera) => ({ + node: camera.node ? getNodePath(camera.node) : '', + uuid: camera.node ? camera.node.uuid : '', + enabled: Boolean(camera.enabled), + priority: camera.priority, + projection: camera.projection, + visibility: camera.visibility, + clearFlags: camera.clearFlags, + })), + }; + }, + + async createCamera(options = {}) { + const parent = options.parentPath ? findNodeByPath(options.parentPath) : getScene(); + if (!parent) { + throw new Error(`Parent not found: ${options.parentPath}`); + } + + const node = new Node(options.name || 'Camera'); + node.parent = parent; + configureNodeBasics(node, options); + const camera = getOrAddComponent(node, Camera); + if (Number.isFinite(options.priority)) { + camera.priority = options.priority; + } + if (Number.isFinite(options.visibility)) { + camera.visibility = options.visibility; + } + if (Number.isFinite(options.clearFlags)) { + camera.clearFlags = options.clearFlags; + } + + return { + created: true, + path: getNodePath(node), + uuid: node.uuid, + camera: plain(camera), + }; + }, + + async setCameraProperties(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target camera node was not found.'); + } + const camera = node.getComponent(Camera); + if (!camera) { + throw new Error('Camera component was not found on target node.'); + } + + for (const key of ['priority', 'visibility', 'clearFlags', 'projection', 'orthoHeight', 'fov', 'near', 'far']) { + if (options[key] !== undefined) { + camera[key] = options[key]; + } + } + + return { + updated: true, + node: getNodePath(node), + camera: plain(camera), + }; + }, + + async listAnimations(options = {}) { + const animations = options.path || options.uuid || options.name + ? [findNode(options)].filter(Boolean).map((node) => node.getComponent(Animation)).filter(Boolean) + : findComponentsByClass(Animation); + + return { + count: animations.length, + animations: animations.map((animation) => ({ + node: animation.node ? getNodePath(animation.node) : '', + uuid: animation.node ? animation.node.uuid : '', + enabled: Boolean(animation.enabled), + defaultClip: animation.defaultClip ? animation.defaultClip.name : '', + clips: Array.isArray(animation.clips) ? animation.clips.map((clip) => clip && clip.name).filter(Boolean) : [], + })), + }; + }, + + async addAnimationClip(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const clipUuid = String(options.clipUuid || '').trim(); + if (!clipUuid) { + throw new Error('clipUuid is required.'); + } + + const clip = await loadAnimationClipByUuid(clipUuid); + const animation = getOrAddComponent(node, Animation); + const clips = Array.isArray(animation.clips) ? animation.clips.slice() : []; + if (!clips.includes(clip)) { + clips.push(clip); + animation.clips = clips; + } + if (options.makeDefault !== false) { + animation.defaultClip = clip; + } + + return { + added: true, + node: getNodePath(node), + clip: clip.name, + clipUuid, + clips: animation.clips.map((item) => item && item.name).filter(Boolean), + }; + }, + + async playAnimation(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const animation = node.getComponent(Animation); + if (!animation) { + throw new Error('Animation component was not found on target node.'); + } + const state = options.clipName ? animation.play(options.clipName) : animation.play(); + return { + playing: true, + node: getNodePath(node), + clip: state && state.clip ? state.clip.name : options.clipName || '', + }; + }, + + async stopAnimation(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const animation = node.getComponent(Animation); + if (!animation) { + throw new Error('Animation component was not found on target node.'); + } + if (options.clipName) { + animation.stop(options.clipName); + } else { + animation.stop(); + } + return { + stopped: true, + node: getNodePath(node), + clip: options.clipName || '(all)', + }; + }, + + async getRuntimeState() { + const scheduler = getScheduler(); + return { + sceneName: getScene().name, + paused: typeof director.isPaused === 'function' ? director.isPaused() : false, + timeScale: scheduler && typeof scheduler.getTimeScale === 'function' ? scheduler.getTimeScale() : 1, + totalFrames: typeof director.getTotalFrames === 'function' ? director.getTotalFrames() : undefined, + }; + }, + + async pauseRuntime() { + if (typeof director.pause === 'function') { + director.pause(); + } + return await exports.methods.getRuntimeState(); + }, + + async resumeRuntime() { + if (typeof director.resume === 'function') { + director.resume(); + } + return await exports.methods.getRuntimeState(); + }, + + async setTimeScale(options = {}) { + const scale = Number(options.scale); + if (!Number.isFinite(scale) || scale < 0 || scale > 100) { + throw new Error('scale must be a number between 0 and 100.'); + } + const scheduler = getScheduler(); + if (!scheduler || typeof scheduler.setTimeScale !== 'function') { + throw new Error('director scheduler time scale API is unavailable.'); + } + scheduler.setTimeScale(scale); + return await exports.methods.getRuntimeState(); + }, + + async emitNodeEvent(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const eventName = String(options.eventName || '').trim(); + if (!eventName) { + throw new Error('eventName is required.'); + } + node.emit(eventName, options.payload || {}); + return { + emitted: true, + node: getNodePath(node), + eventName, + }; + }, + + async simulateButtonClick(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const button = node.getComponent(Button); + if (!button) { + throw new Error('Button component was not found on target node.'); + } + + if (Component && Component.EventHandler && typeof Component.EventHandler.emitEvents === 'function') { + Component.EventHandler.emitEvents(button.clickEvents, button); + } + node.emit(Button.EventType ? Button.EventType.CLICK : 'click', button); + + return { + clicked: true, + node: getNodePath(node), + clickEventCount: Array.isArray(button.clickEvents) ? button.clickEvents.length : 0, + }; + }, + + async invokeComponentMethod(options = {}) { + const node = findNode(options); + if (!node) { + throw new Error('Target node was not found.'); + } + const component = findComponent(node, options); + if (!component) { + throw new Error('Target component was not found.'); + } + const methodName = String(options.methodName || '').trim(); + if (!methodName || typeof component[methodName] !== 'function') { + throw new Error(`Component method not found: ${methodName}`); + } + + const result = component[methodName](...(Array.isArray(options.args) ? options.args : [])); + return { + invoked: true, + node: getNodePath(node), + component: component.constructor ? component.constructor.name : 'UnknownComponent', + methodName, + result: plain(result), + }; + }, + + async executeCode(options = {}) { + const code = String(options.code || ''); + if (!code.trim()) { + throw new Error('code is required.'); + } + + const result = await executeUserCode(code, options.args || {}); + return { + ok: true, + result: plain(result), + sceneName: getScene().name, + }; + }, +};