chore: 纳入项目全部源码进根仓库统一管理
删除 YouleNexus 内嵌 .git,统一由根仓库唯一管理。提交: - projects/ 旧 H5 模板 Game_Surface_3 与各子游戏源码 - cocoscreator_projects/YouleNexus 工程 + cocos-creator-mcp 扩展源码 - CLAUDE.md、.mcp.json 等配置 library/temp/node_modules/dist 等缓存由 .gitignore 排除。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const HOURS_24 = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 24時間以上前のファイルを OLD_yyyyMM フォルダに移動する.
|
||||
*/
|
||||
export function archiveOldFiles(dir: string): void {
|
||||
try {
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let archived = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) continue;
|
||||
if (now - stat.mtimeMs < HOURS_24) continue;
|
||||
|
||||
const fileDate = new Date(stat.mtimeMs);
|
||||
const yyyy = fileDate.getFullYear();
|
||||
const mm = String(fileDate.getMonth() + 1).padStart(2, "0");
|
||||
const archiveDir = path.join(dir, `OLD_${yyyy}${mm}`);
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
|
||||
fs.renameSync(filePath, path.join(archiveDir, file));
|
||||
archived++;
|
||||
}
|
||||
|
||||
if (archived > 0) {
|
||||
console.log(`[cocos-creator-mcp] ${archived}件のファイルを月別フォルダにアーカイブ (${dir})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[cocos-creator-mcp] アーカイブ失敗:", e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { McpServer } from "./mcp-server";
|
||||
import { SceneTools } from "./tools/scene-tools";
|
||||
import { NodeTools } from "./tools/node-tools";
|
||||
import { ComponentTools } from "./tools/component-tools";
|
||||
import { PrefabTools } from "./tools/prefab-tools";
|
||||
import { ProjectTools } from "./tools/project-tools";
|
||||
import { DebugTools } from "./tools/debug-tools";
|
||||
import { SceneAdvancedTools } from "./tools/scene-advanced-tools";
|
||||
import { SceneViewTools } from "./tools/scene-view-tools";
|
||||
import { AssetTools } from "./tools/asset-tools";
|
||||
import { PreferencesTools } from "./tools/preferences-tools";
|
||||
import { ServerTools } from "./tools/server-tools";
|
||||
import { BuilderTools } from "./tools/builder-tools";
|
||||
import { ReferenceImageTools } from "./tools/reference-image-tools";
|
||||
import { ServerConfig, DEFAULT_CONFIG } from "./types";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let server: McpServer | null = null;
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
|
||||
}
|
||||
|
||||
function loadConfig(): ServerConfig {
|
||||
try {
|
||||
const p = getSettingsPath();
|
||||
if (fs.existsSync(p)) {
|
||||
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
return { ...DEFAULT_CONFIG, ...data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[cocos-creator-mcp] Failed to load settings, using defaults");
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
function saveConfig(config: ServerConfig): void {
|
||||
try {
|
||||
const p = getSettingsPath();
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(config, null, 2), "utf-8");
|
||||
} catch (e) {
|
||||
console.error("[cocos-creator-mcp] Failed to save settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function createServer(config: ServerConfig): McpServer {
|
||||
const s = new McpServer(config);
|
||||
s.register(new SceneTools());
|
||||
s.register(new NodeTools());
|
||||
const componentTools = new ComponentTools();
|
||||
s.register(componentTools);
|
||||
s.register(new PrefabTools(componentTools));
|
||||
s.register(new ProjectTools());
|
||||
s.register(new DebugTools());
|
||||
s.register(new SceneAdvancedTools());
|
||||
s.register(new SceneViewTools());
|
||||
s.register(new AssetTools());
|
||||
s.register(new PreferencesTools());
|
||||
s.register(new ServerTools());
|
||||
s.register(new BuilderTools());
|
||||
s.register(new ReferenceImageTools());
|
||||
return s;
|
||||
}
|
||||
|
||||
export const methods: Record<string, (...args: any[]) => any> = {
|
||||
openPanel() {
|
||||
Editor.Panel.open("cocos-creator-mcp");
|
||||
},
|
||||
|
||||
openRecorder() {
|
||||
Editor.Panel.open("cocos-creator-mcp.recorder");
|
||||
},
|
||||
|
||||
async startServer() {
|
||||
if (server?.isRunning) return { running: true, port: server.port };
|
||||
const config = loadConfig();
|
||||
server = createServer(config);
|
||||
await server.start();
|
||||
return { running: true, port: server.port };
|
||||
},
|
||||
|
||||
async stopServer() {
|
||||
if (server) {
|
||||
await server.stop();
|
||||
server = null;
|
||||
}
|
||||
return { running: false };
|
||||
},
|
||||
|
||||
async updatePort(_port: number) {
|
||||
const config = loadConfig();
|
||||
config.port = _port;
|
||||
saveConfig(config);
|
||||
// Restart server if running
|
||||
if (server?.isRunning) {
|
||||
await server.stop();
|
||||
server = createServer(config);
|
||||
await server.start();
|
||||
}
|
||||
return { port: _port, running: server?.isRunning ?? false };
|
||||
},
|
||||
|
||||
getServerStatus() {
|
||||
return {
|
||||
running: server?.isRunning ?? false,
|
||||
port: server?.port ?? loadConfig().port,
|
||||
toolCount: server?.getAllTools().length ?? 0,
|
||||
toolNames: server?.getAllTools().map((t) => t.name).sort() ?? [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export async function load() {
|
||||
console.log("[cocos-creator-mcp] Extension loaded");
|
||||
const config = loadConfig();
|
||||
server = createServer(config);
|
||||
|
||||
if (config.autoStart) {
|
||||
try {
|
||||
await server.start();
|
||||
console.log(`[cocos-creator-mcp] Auto-started on port ${config.port}`);
|
||||
} catch (e) {
|
||||
console.error("[cocos-creator-mcp] Auto-start failed:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function unload() {
|
||||
if (server) {
|
||||
await server.stop();
|
||||
server = null;
|
||||
}
|
||||
console.log("[cocos-creator-mcp] Extension unloaded");
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import http from "http";
|
||||
import { ToolCategory, ToolDefinition, JsonRpcRequest, JsonRpcResponse, ServerConfig, DEFAULT_CONFIG } from "./types";
|
||||
import { archiveOldFiles } from "./archive";
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
||||
const SESSION_ID = `cocos-mcp-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
||||
|
||||
/** ビルド時にコードベースのSHA256ハッシュが埋め込まれる */
|
||||
export const BUILD_HASH = "__BUILD_HASH__";
|
||||
|
||||
// ─── Game Preview Log Buffer ───
|
||||
|
||||
interface GameLogEntry {
|
||||
timestamp: string;
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_GAME_LOG_BUFFER = 500;
|
||||
const _gameLogs: GameLogEntry[] = [];
|
||||
|
||||
/** Access game preview log buffer from debug-tools */
|
||||
export function getGameLogs(count: number, level?: string): { logs: GameLogEntry[]; total: number } {
|
||||
let logs = _gameLogs;
|
||||
if (level) {
|
||||
logs = logs.filter(l => l.level === level);
|
||||
}
|
||||
return { logs: logs.slice(-count), total: _gameLogs.length };
|
||||
}
|
||||
|
||||
export function clearGameLogs(): void {
|
||||
_gameLogs.length = 0;
|
||||
}
|
||||
|
||||
// ─── Game Debug Command Queue ───
|
||||
|
||||
interface GameCommand {
|
||||
id: string;
|
||||
type: string;
|
||||
args?: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface GameCommandResult {
|
||||
id: string;
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
let _pendingCommand: GameCommand | null = null;
|
||||
let _commandResult: GameCommandResult | null = null;
|
||||
let _commandIdCounter = 0;
|
||||
|
||||
/** Queue a command for the game to execute */
|
||||
export function queueGameCommand(type: string, args?: any): string {
|
||||
const id = `cmd_${++_commandIdCounter}_${Date.now()}`;
|
||||
_pendingCommand = { id, type, args, timestamp: new Date().toISOString() };
|
||||
_commandResult = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Get the result of the last command (poll until available) */
|
||||
export function getCommandResult(): GameCommandResult | null {
|
||||
return _commandResult;
|
||||
}
|
||||
|
||||
/** Clear command state */
|
||||
export function clearCommandState(): void {
|
||||
_pendingCommand = null;
|
||||
_commandResult = null;
|
||||
}
|
||||
|
||||
// ─── Recording Storage ───
|
||||
|
||||
interface RecordingInfo {
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const _recordings = new Map<string, RecordingInfo>();
|
||||
|
||||
/** Get completed recording info by id */
|
||||
export function getRecording(id: string): RecordingInfo | undefined {
|
||||
return _recordings.get(id);
|
||||
}
|
||||
|
||||
export function setRecording(id: string, info: RecordingInfo): void {
|
||||
_recordings.set(id, info);
|
||||
}
|
||||
|
||||
export class McpServer {
|
||||
private server: http.Server | null = null;
|
||||
private tools: Map<string, ToolCategory> = new Map();
|
||||
private toolIndex: Map<string, ToolCategory> = new Map(); // toolName -> category
|
||||
private config: ServerConfig;
|
||||
|
||||
constructor(config?: Partial<ServerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/** Register a tool category */
|
||||
register(category: ToolCategory): void {
|
||||
this.tools.set(category.categoryName, category);
|
||||
for (const tool of category.getTools()) {
|
||||
this.toolIndex.set(tool.name, category);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all tool definitions */
|
||||
getAllTools(): ToolDefinition[] {
|
||||
const all: ToolDefinition[] = [];
|
||||
for (const cat of this.tools.values()) {
|
||||
all.push(...cat.getTools());
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Start HTTP server */
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
this.server.listen(this.config.port, "127.0.0.1", () => {
|
||||
console.log(`[cocos-creator-mcp] Server started on http://127.0.0.1:${this.config.port}/mcp`);
|
||||
resolve();
|
||||
});
|
||||
this.server.on("error", (e) => {
|
||||
console.error(`[cocos-creator-mcp] Server error:`, e);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop HTTP server */
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.server.close(() => {
|
||||
this.server = null;
|
||||
console.log("[cocos-creator-mcp] Server stopped");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return this.config.port;
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// CORS
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url || "/";
|
||||
const origin = `http://127.0.0.1:${this.config.port}`;
|
||||
|
||||
// ─── OAuth endpoints (MCP spec 2025-06-18 / RFC 9728 / RFC 8414 / RFC 7591) ───
|
||||
//
|
||||
// Claude Code の VSCode 拡張は HTTP トランスポートの MCP サーバーに対して
|
||||
// 無条件で OAuth discovery / DCR を試みる (#26917 等の既知バグ)。
|
||||
// cocos-creator-mcp は localhost-only のローカル開発ツールで本物の認証は不要だが、
|
||||
// クライアントを満足させるため OAuth エンドポイント群をダミー実装して常時許可する。
|
||||
//
|
||||
// TODO: 以下のいずれかが発生したら削除する
|
||||
// 1. anthropics/claude-code #26917 / #38102 等の HTTP OAuth バグが修正される
|
||||
// 2. 本物の認証機構を実装する必要が出る(偽 OAuth と衝突するため)
|
||||
// 3. MCP spec が PKCE 検証・トークンローテーション必須等に更新される
|
||||
// 4. stdio ブリッジが十分定着して HTTP transport 自体を deprecate する
|
||||
|
||||
// RFC 9728 Protected Resource Metadata
|
||||
if (url === "/.well-known/oauth-protected-resource" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
resource: `${origin}/mcp`,
|
||||
authorization_servers: [origin],
|
||||
bearer_methods_supported: ["header"],
|
||||
scopes_supported: ["mcp"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 8414 Authorization Server Metadata
|
||||
if (url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/oauth/authorize`,
|
||||
token_endpoint: `${origin}/oauth/token`,
|
||||
registration_endpoint: `${origin}/oauth/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256", "plain"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 7591 Dynamic Client Registration — accept anything, return dummy client
|
||||
if (url === "/oauth/register" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
let reg: any = {};
|
||||
try { reg = JSON.parse(body); } catch { /* ignore */ }
|
||||
const clientId = `cocos-mcp-client-${Date.now()}`;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_name: reg.client_name || "cocos-creator-mcp client",
|
||||
redirect_uris: reg.redirect_uris || [],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth authorization endpoint — auto-consent, redirect immediately with code
|
||||
if (url.startsWith("/oauth/authorize") && req.method === "GET") {
|
||||
const parsed = new URL(url, origin);
|
||||
const redirectUri = parsed.searchParams.get("redirect_uri") || "";
|
||||
const state = parsed.searchParams.get("state") || "";
|
||||
if (!redirectUri) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "invalid_request", error_description: "redirect_uri required" }));
|
||||
return;
|
||||
}
|
||||
const code = `cocos-mcp-code-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const location = `${redirectUri}${redirectUri.includes("?") ? "&" : "?"}code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
res.writeHead(302, { Location: location });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth token endpoint — always issue a dummy token
|
||||
if (url === "/oauth/token" && req.method === "POST") {
|
||||
await readBody(req); // drain
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
access_token: "cocos-mcp-public-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 86400,
|
||||
scope: "mcp",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", tools: this.getAllTools().length }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Game debug command queue — game polls for commands
|
||||
if (url === "/game/command" && req.method === "GET") {
|
||||
const cmd = _pendingCommand;
|
||||
_pendingCommand = null; // consume
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(cmd));
|
||||
return;
|
||||
}
|
||||
|
||||
// Game debug command result — game posts result
|
||||
if (url === "/game/result" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
_commandResult = JSON.parse(body);
|
||||
} catch { /* ignore */ }
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Game preview recording receiver
|
||||
if (url === "/game/recording" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
const { id, base64, mimeType, savePath } = JSON.parse(body);
|
||||
if (!id || !base64) throw new Error("id/base64 required");
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const buffer = Buffer.from(base64, "base64");
|
||||
|
||||
// savePath指定があればそこに保存(絶対パスまたはプロジェクト相対パス)
|
||||
const projectPath = (global as any).Editor?.Project?.path
|
||||
|| process.cwd();
|
||||
let dir: string;
|
||||
if (savePath) {
|
||||
dir = path.isAbsolute(savePath) ? savePath : path.join(projectPath, savePath);
|
||||
} else {
|
||||
dir = path.join(projectPath, "temp", "recordings");
|
||||
}
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const mt = (mimeType || "").toLowerCase();
|
||||
const ext = mt.includes("webm") ? "webm"
|
||||
: mt.includes("mp4") ? "mp4"
|
||||
: "bin";
|
||||
const fileName = `${id}.${ext}`;
|
||||
const filePath = path.join(dir, fileName);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
setRecording(id, {
|
||||
path: filePath,
|
||||
size: buffer.length,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
if (this.config.autoArchiveRecordings) {
|
||||
archiveOldFiles(dir);
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ success: true, path: filePath, size: buffer.length }));
|
||||
} catch (e: any) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ success: false, error: e.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Game preview log receiver
|
||||
if (url === "/log" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
try {
|
||||
const entries: GameLogEntry[] = JSON.parse(body);
|
||||
for (const entry of (Array.isArray(entries) ? entries : [entries])) {
|
||||
_gameLogs.push({
|
||||
timestamp: entry.timestamp || new Date().toISOString(),
|
||||
level: entry.level || "log",
|
||||
message: entry.message || "",
|
||||
});
|
||||
// __debug_state__ ログから userId を debug-menu.json に保存
|
||||
try {
|
||||
const msg = JSON.parse(entry.message || "");
|
||||
if (msg.__debug_state__ && msg.userId) {
|
||||
const _fs = require("fs");
|
||||
const _path = require("path");
|
||||
const projectPath = (global as any).Editor?.Project?.path || process.cwd();
|
||||
const settingsPath = _path.join(projectPath, "settings", "debug-menu.json");
|
||||
_fs.writeFileSync(settingsPath, JSON.stringify({ userId: msg.userId }, null, 2), "utf-8");
|
||||
}
|
||||
} catch { /* not debug_state */ }
|
||||
}
|
||||
if (_gameLogs.length > MAX_GAME_LOG_BUFFER) {
|
||||
_gameLogs.splice(0, _gameLogs.length - MAX_GAME_LOG_BUFFER);
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// MCP endpoint
|
||||
if (url === "/mcp") {
|
||||
if (req.method === "GET") {
|
||||
// SSE keepalive stream
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
// Send initial comment to keep connection alive
|
||||
res.write(": connected\n\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
await this.handleMcpPost(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 404
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
|
||||
private async handleMcpPost(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await readBody(req);
|
||||
let rpc: JsonRpcRequest;
|
||||
try {
|
||||
rpc = JSON.parse(body);
|
||||
} catch {
|
||||
this.sendJsonRpc(res, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const accept = req.headers["accept"] || "";
|
||||
const wantSse = accept.includes("text/event-stream");
|
||||
|
||||
let response: JsonRpcResponse;
|
||||
|
||||
switch (rpc.method) {
|
||||
case "initialize":
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: {
|
||||
name: "cocos-creator-mcp",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
case "notifications/initialized":
|
||||
// No response needed for notification
|
||||
res.writeHead(204, { "Mcp-Session-Id": SESSION_ID });
|
||||
res.end();
|
||||
return;
|
||||
|
||||
case "tools/list":
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
result: { tools: this.getAllTools() },
|
||||
};
|
||||
break;
|
||||
|
||||
case "tools/call": {
|
||||
const toolName = rpc.params?.name;
|
||||
const args = rpc.params?.arguments || {};
|
||||
const category = this.toolIndex.get(toolName);
|
||||
|
||||
if (!category) {
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32602, message: `Unknown tool: ${toolName}` },
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const start = Date.now();
|
||||
console.log(`[cocos-creator-mcp] ▶ ${toolName}`, Object.keys(args).length > 0 ? JSON.stringify(args).substring(0, 200) : "");
|
||||
const timeoutMs = (toolName.startsWith("prefab_") || toolName === "scene_open") ? 120000 : 30000;
|
||||
const result = await withTimeout(category.execute(toolName, args), timeoutMs, `Tool ${toolName} timed out`);
|
||||
console.log(`[cocos-creator-mcp] ✓ ${toolName} (${Date.now() - start}ms)`);
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
result,
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error(`[cocos-creator-mcp] ✗ ${toolName}:`, e.message || String(e));
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32603, message: e.message || String(e) },
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32601, message: `Method not found: ${rpc.method}` },
|
||||
};
|
||||
}
|
||||
|
||||
if (wantSse) {
|
||||
this.sendSse(res, [response]);
|
||||
} else {
|
||||
this.sendJsonRpc(res, response);
|
||||
}
|
||||
}
|
||||
|
||||
private sendJsonRpc(res: http.ServerResponse, data: JsonRpcResponse): void {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Mcp-Session-Id": SESSION_ID,
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
private sendSse(res: http.ServerResponse, messages: JsonRpcResponse[]): void {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Mcp-Session-Id": SESSION_ID,
|
||||
});
|
||||
for (const msg of messages) {
|
||||
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
(e) => { clearTimeout(timer); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared node UUID resolution — resolves nodeName to UUID via scene script.
|
||||
*/
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export interface ResolvedNode {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node UUID from either `uuid` or `nodeName` parameter.
|
||||
* If `uuid` is provided, it is returned as-is.
|
||||
* If `nodeName` is provided, searches the scene for a matching node.
|
||||
* Throws if neither is provided or no node is found.
|
||||
*/
|
||||
export async function resolveNodeUuid(args: { uuid?: string; nodeName?: string }): Promise<ResolvedNode> {
|
||||
if (args.uuid) {
|
||||
return { uuid: args.uuid, name: "" };
|
||||
}
|
||||
if (!args.nodeName) {
|
||||
throw new Error("Either 'uuid' or 'nodeName' is required");
|
||||
}
|
||||
const result = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method: "findNodesByName",
|
||||
args: [args.nodeName],
|
||||
});
|
||||
if (!result?.success || !result?.data?.length) {
|
||||
throw new Error(`Node not found: "${args.nodeName}"`);
|
||||
}
|
||||
// 最初のマッチを使用
|
||||
const node = result.data[0];
|
||||
return { uuid: node.uuid, name: node.name || args.nodeName };
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
const { createApp } = require("vue");
|
||||
|
||||
const panelDataMap = new WeakMap<any, any>();
|
||||
|
||||
module.exports = Editor.Panel.define({
|
||||
template: `
|
||||
<div id="app">
|
||||
<h2>Cocos Creator MCP</h2>
|
||||
<div class="status">
|
||||
<span>Status: <strong :class="running ? 'on' : 'off'">{{ running ? 'Running' : 'Stopped' }}</strong></span>
|
||||
</div>
|
||||
<div class="port-row">
|
||||
<label>Port:</label>
|
||||
<input type="number" v-model.number="editPort" :disabled="running" min="1024" max="65535" />
|
||||
<ui-button v-if="!running && editPort !== port" @confirm="applyPort" class="small-btn">Apply</ui-button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<ui-button v-if="!running" @confirm="start">Start Server</ui-button>
|
||||
<ui-button v-if="running" @confirm="stop">Stop Server</ui-button>
|
||||
</div>
|
||||
<div v-if="running" class="info">
|
||||
<p>Endpoint: <code>http://127.0.0.1:{{ port }}/mcp</code></p>
|
||||
<p>Tools: <strong>{{ toolCount }}</strong></p>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
`,
|
||||
style: `
|
||||
#app { padding: 12px; font-family: sans-serif; color: #ccc; }
|
||||
h2 { margin: 0 0 8px 0; font-size: 16px; }
|
||||
.status { margin: 8px 0; }
|
||||
.on { color: #4f4; }
|
||||
.off { color: #f66; }
|
||||
.port-row { margin: 8px 0; display: flex; align-items: center; gap: 8px; }
|
||||
.port-row label { font-size: 12px; }
|
||||
.port-row input { width: 80px; padding: 3px 6px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; font-size: 12px; }
|
||||
.port-row input:disabled { opacity: 0.5; }
|
||||
.actions { margin: 8px 0; }
|
||||
.info { margin: 8px 0; padding: 8px; background: var(--color-normal-fill-emphasis); border-radius: 4px; }
|
||||
.info p { margin: 4px 0; font-size: 12px; }
|
||||
.info code { background: #333; padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
||||
.error { margin: 8px 0; color: #f66; font-size: 12px; }
|
||||
`,
|
||||
$: { app: "#app" },
|
||||
ready() {
|
||||
if (!this.$.app) return;
|
||||
const app = createApp({
|
||||
data() {
|
||||
return {
|
||||
running: false,
|
||||
port: 3000,
|
||||
editPort: 3000,
|
||||
toolCount: 0,
|
||||
error: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async start(this: any) {
|
||||
try {
|
||||
this.error = "";
|
||||
const result = await Editor.Message.request("cocos-creator-mcp", "start-server");
|
||||
this.running = result.running;
|
||||
this.port = result.port;
|
||||
this.editPort = result.port;
|
||||
await this.refresh();
|
||||
} catch (e: any) {
|
||||
this.error = e.message || String(e);
|
||||
}
|
||||
},
|
||||
async stop(this: any) {
|
||||
try {
|
||||
await Editor.Message.request("cocos-creator-mcp", "stop-server");
|
||||
this.running = false;
|
||||
this.toolCount = 0;
|
||||
} catch (e: any) {
|
||||
this.error = e.message || String(e);
|
||||
}
|
||||
},
|
||||
async applyPort(this: any) {
|
||||
try {
|
||||
this.error = "";
|
||||
const result = await Editor.Message.request("cocos-creator-mcp", "update-port", this.editPort);
|
||||
this.port = result.port;
|
||||
this.running = result.running;
|
||||
if (this.running) await this.refresh();
|
||||
} catch (e: any) {
|
||||
this.error = e.message || String(e);
|
||||
}
|
||||
},
|
||||
async refresh(this: any) {
|
||||
try {
|
||||
const status = await Editor.Message.request("cocos-creator-mcp", "get-server-status");
|
||||
this.running = status.running;
|
||||
this.port = status.port;
|
||||
this.editPort = status.port;
|
||||
this.toolCount = status.toolCount;
|
||||
} catch (e: any) {
|
||||
console.warn("[cocos-creator-mcp] refresh failed:", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted(this: any) {
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
console.warn("[cocos-creator-mcp] mounted refresh failed:", e);
|
||||
}
|
||||
},
|
||||
});
|
||||
app.mount(this.$.app);
|
||||
panelDataMap.set(this, app);
|
||||
},
|
||||
close() {
|
||||
const app = panelDataMap.get(this);
|
||||
if (app) app.unmount();
|
||||
},
|
||||
});
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
const { createApp: createAppRec } = require("vue");
|
||||
|
||||
const panelDataMapRec = new WeakMap<any, any>();
|
||||
|
||||
module.exports = Editor.Panel.define({
|
||||
template: `
|
||||
<div id="app">
|
||||
<h2>Preview Recorder</h2>
|
||||
|
||||
<div class="controls">
|
||||
<button v-if="!recording" @click="start" class="btn btn-start">● 録画開始</button>
|
||||
<button v-else @click="stop" class="btn btn-stop" :disabled="stopping">■ 録画停止{{ stopping ? '中...' : '' }}</button>
|
||||
<button @click="screenshot" class="btn btn-shot" :disabled="shooting">📸 スクショ{{ shooting ? '中...' : '' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="recording" class="status-row">
|
||||
<span class="rec-dot">●</span> REC <strong>{{ elapsed }}s</strong>
|
||||
<span class="info">{{ recordingInfo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-title">録画設定</div>
|
||||
<div class="row">
|
||||
<label>FPS:</label>
|
||||
<input type="number" v-model.number="fps" :disabled="recording" min="10" max="60" />
|
||||
<label>形式:</label>
|
||||
<select v-model="format" :disabled="recording">
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>品質:</label>
|
||||
<select v-model="quality" @change="onQualityChange" :disabled="recording">
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="ultra">最高</option>
|
||||
<option value="custom">カスタム</option>
|
||||
</select>
|
||||
<label title="ビットレート = 幅 × 高さ × FPS × 品質係数">品質係数:</label>
|
||||
<input type="number" v-model.number="coefficient" @input="onCoefChange"
|
||||
:disabled="recording" min="0.01" max="2" step="0.01" class="custom-bitrate"
|
||||
title="ビットレート = 幅 × 高さ × FPS × 品質係数" />
|
||||
<button @click="resetQuality" class="btn btn-small" :disabled="recording" title="録画設定を初期値に戻す">↺</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title">スクショ設定</div>
|
||||
<div class="row">
|
||||
<label>形式:</label>
|
||||
<select v-model="shotFormat" :disabled="shooting">
|
||||
<option value="png">PNG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="section-title">保存先</div>
|
||||
<div class="row">
|
||||
<input type="text" v-model="savePath" :disabled="recording" class="path-input" placeholder="temp/recordings" />
|
||||
<button @click="selectSaveFolder" class="btn btn-small" :disabled="recording">📁 選択</button>
|
||||
<button @click="resetSavePath" class="btn btn-small" :disabled="recording" title="保存先を初期値に戻す">↺</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button @click="openSaveFolder" class="btn btn-small">📂 保存フォルダを開く</button>
|
||||
<label class="checkbox-label" title="ONにすると、録画/スクショ保存時に24時間以上前のファイルを OLD_yyyyMM フォルダに自動移動します"><input type="checkbox" v-model="autoArchive" @change="onAutoArchiveChange" /> 古いファイルを自動整理</label>
|
||||
<button class="btn btn-help" @click="showArchiveHelp = !showArchiveHelp" title="自動整理の説明">?</button>
|
||||
</div>
|
||||
<div v-if="showArchiveHelp" class="help-box">
|
||||
<strong>自動整理について</strong><br>
|
||||
ONにすると、録画やスクショを保存するたびに、24時間以上前の古いファイルを<br>
|
||||
<code>OLD_yyyyMM/</code> フォルダ(例: OLD_202604/)に自動で移動します。<br>
|
||||
保存フォルダ直下には直近のファイルだけが残り、整理された状態を保てます。
|
||||
</div>
|
||||
|
||||
<div v-if="lastResult" class="result" :class="lastError ? 'error' : 'success'">
|
||||
<div v-if="!lastError">
|
||||
<strong>✓ {{ lastResult.kind === 'shot' ? 'スクショ保存' : '録画完了' }}</strong><br>
|
||||
<code>{{ lastResult.path }}</code><br>
|
||||
{{ (lastResult.size / 1024).toFixed(1) }} KB
|
||||
</div>
|
||||
<div v-else>
|
||||
<strong>✗ エラー:</strong> {{ lastResult.error || lastResult.message || 'unknown' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
※ ゲームプレビュー実行中に使ってください(GameDebugClientが必要)
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
style: `
|
||||
#app { padding: 16px; font-family: sans-serif; color: #ccc; font-size: 12px; }
|
||||
h2 { margin: 0 0 12px 0; font-size: 18px; }
|
||||
.controls { margin: 12px 0; }
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.btn:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
.btn-start { background: #d44; }
|
||||
.btn-start:hover { background: #e55; }
|
||||
.btn-stop { background: #888; }
|
||||
.btn-stop:hover { background: #999; }
|
||||
.btn-shot { background: #468; margin-left: 8px; }
|
||||
.btn-shot:hover { background: #579; }
|
||||
.section-title {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 4px;
|
||||
padding: 4px 0 3px 0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #8af;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 4px 10px;
|
||||
background: #4a8;
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.btn-small:hover { background: #5b9; }
|
||||
.status-row { margin: 10px 0; font-size: 14px; }
|
||||
.rec-dot { color: #f44; animation: blink 1s infinite; }
|
||||
@keyframes blink { 50% { opacity: 0.3; } }
|
||||
.info { color: #888; font-size: 11px; margin-left: 8px; }
|
||||
.row { margin: 10px 0; display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
|
||||
.row label { font-size: 12px; }
|
||||
.row input { width: 60px; padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
|
||||
.row select { padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
|
||||
.path-input { flex: 1; min-width: 200px; width: auto !important; font-family: monospace; }
|
||||
.custom-bitrate { width: 70px !important; }
|
||||
.unit { font-size: 11px; color: #888; }
|
||||
.result { margin: 12px 0; padding: 10px; border-radius: 4px; font-size: 12px; line-height: 1.5; }
|
||||
.result.success { background: #1a3a1a; color: #afa; }
|
||||
.result.error { background: #3a1a1a; color: #faa; }
|
||||
.result code { font-size: 10px; word-break: break-all; background: #000; padding: 2px 4px; border-radius: 2px; }
|
||||
.checkbox-label { font-size: 12px; display: flex; align-items: center; gap: 4px; margin-left: auto; cursor: pointer; }
|
||||
.checkbox-label input[type="checkbox"] { cursor: pointer; }
|
||||
.btn-help { padding: 2px 7px; background: #555; font-size: 11px; font-weight: bold; border-radius: 50%; min-width: 20px; margin-left: 4px; }
|
||||
.btn-help:hover { background: #777; }
|
||||
.help-box { margin: 8px 0; padding: 10px; background: #1a2a3a; border: 1px solid #345; border-radius: 4px; font-size: 11px; line-height: 1.6; color: #bcd; }
|
||||
.help-box code { background: #000; padding: 1px 4px; border-radius: 2px; font-size: 10px; }
|
||||
.note { margin-top: 16px; padding-top: 8px; border-top: 1px solid #333; font-size: 10px; color: #888; }
|
||||
`,
|
||||
$: { app: "#app" },
|
||||
ready() {
|
||||
if (!this.$.app) return;
|
||||
const MCP_BASE = "http://127.0.0.1:3000";
|
||||
const STORAGE_KEY = "cocos-mcp-recorder-settings";
|
||||
const PERSISTED_KEYS = ["fps", "quality", "coefficient", "format", "savePath", "shotFormat"];
|
||||
// localStorage から設定を読み込み
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
const saved = loadSettings();
|
||||
// プロジェクト設定から autoArchiveRecordings を読み込み
|
||||
const loadProjectConfig = () => {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
} catch { /* ignore */ }
|
||||
return {};
|
||||
};
|
||||
const projectCfg = loadProjectConfig();
|
||||
const app = createAppRec({
|
||||
data() {
|
||||
return {
|
||||
recording: false,
|
||||
stopping: false,
|
||||
shooting: false,
|
||||
elapsed: "0.0",
|
||||
recordingInfo: "",
|
||||
fps: saved.fps ?? 30,
|
||||
quality: saved.quality ?? "medium",
|
||||
coefficient: saved.coefficient ?? 0.25,
|
||||
format: saved.format ?? "mp4",
|
||||
savePath: saved.savePath ?? "temp/recordings",
|
||||
shotFormat: saved.shotFormat ?? "png",
|
||||
autoArchive: projectCfg.autoArchiveRecordings ?? false,
|
||||
showArchiveHelp: false,
|
||||
lastResult: null as any,
|
||||
lastError: false,
|
||||
_startTime: 0,
|
||||
_timer: null as any,
|
||||
_aliveCheckTimer: null as any,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
fps(this: any) { this.saveSettings(); },
|
||||
quality(this: any) { this.saveSettings(); },
|
||||
coefficient(this: any) { this.saveSettings(); },
|
||||
format(this: any) { this.saveSettings(); },
|
||||
savePath(this: any) { this.saveSettings(); },
|
||||
shotFormat(this: any) { this.saveSettings(); },
|
||||
},
|
||||
methods: {
|
||||
saveSettings(this: any) {
|
||||
const data: any = {};
|
||||
for (const key of PERSISTED_KEYS) data[key] = this[key];
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch { /* ignore */ }
|
||||
},
|
||||
async isPreviewRunning(this: any): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0", id: 98, method: "tools/call",
|
||||
params: {
|
||||
name: "debug_game_command",
|
||||
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
return !!parsed?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async start(this: any) {
|
||||
this.lastResult = null;
|
||||
if (!await this.isPreviewRunning()) {
|
||||
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してから録画してください。" };
|
||||
this.lastError = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "debug_record_start",
|
||||
arguments: {
|
||||
fps: this.fps,
|
||||
coefficient: this.coefficient,
|
||||
format: this.format,
|
||||
savePath: this.savePath,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success && parsed.data?.id) {
|
||||
this.recording = true;
|
||||
const d = parsed.data;
|
||||
const mbps = d?.videoBitsPerSecond ? (d.videoBitsPerSecond / 1_000_000).toFixed(2) : "?";
|
||||
this.recordingInfo = `${d?.canvasWidth || "?"}x${d?.canvasHeight || "?"} @ ${d?.fps || "?"}fps / ${mbps}Mbps / ${d?.mimeType || ""}`;
|
||||
this._startTime = Date.now();
|
||||
this._timer = setInterval(() => {
|
||||
this.elapsed = ((Date.now() - this._startTime) / 1000).toFixed(1);
|
||||
}, 100);
|
||||
// プレビュー停止検知用ポーリング(2秒毎)
|
||||
this._aliveCheckTimer = setInterval(() => {
|
||||
this.checkPreviewAlive();
|
||||
}, 2000);
|
||||
} else {
|
||||
// 可能な限り詳細なエラー情報を表示
|
||||
const errDetail = parsed?.data?.error
|
||||
|| parsed?.error
|
||||
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|
||||
|| (parsed ? JSON.stringify(parsed).substring(0, 200) : "no response")
|
||||
|| "録画開始失敗";
|
||||
this.lastResult = { error: errDetail };
|
||||
this.lastError = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.lastResult = { error: `通信エラー: ${e.message}` };
|
||||
this.lastError = true;
|
||||
}
|
||||
},
|
||||
async stop(this: any) {
|
||||
this.stopping = true;
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "debug_record_stop",
|
||||
arguments: { timeout: 60000 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success && parsed.data?.path) {
|
||||
this.lastResult = { path: parsed.data.path, size: parsed.data.size };
|
||||
this.lastError = false;
|
||||
} else {
|
||||
const errDetail = parsed?.data?.error
|
||||
|| parsed?.error
|
||||
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|
||||
|| "録画停止失敗";
|
||||
this.lastResult = { error: errDetail };
|
||||
this.lastError = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.lastResult = { error: `通信エラー: ${e.message}` };
|
||||
this.lastError = true;
|
||||
} finally {
|
||||
this.recording = false;
|
||||
this.stopping = false;
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
|
||||
}
|
||||
},
|
||||
onQualityChange(this: any) {
|
||||
const map: Record<string, number> = { low: 0.15, medium: 0.25, high: 0.40, ultra: 0.60 };
|
||||
if (this.quality !== "custom") this.coefficient = map[this.quality];
|
||||
},
|
||||
onCoefChange(this: any) {
|
||||
const map: Record<number, string> = { 0.15: "low", 0.25: "medium", 0.40: "high", 0.60: "ultra" };
|
||||
this.quality = map[this.coefficient] || "custom";
|
||||
},
|
||||
resetQuality(this: any) {
|
||||
this.fps = 30;
|
||||
this.quality = "medium";
|
||||
this.coefficient = 0.25;
|
||||
this.format = "mp4";
|
||||
},
|
||||
resetSavePath(this: any) {
|
||||
this.savePath = "temp/recordings";
|
||||
},
|
||||
onAutoArchiveChange(this: any) {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
|
||||
let cfg: any = {};
|
||||
if (fs.existsSync(p)) cfg = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
cfg.autoArchiveRecordings = this.autoArchive;
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), "utf-8");
|
||||
} catch (e) {
|
||||
console.warn("[recorder] 設定保存失敗:", e);
|
||||
}
|
||||
},
|
||||
async screenshot(this: any) {
|
||||
this.shooting = true;
|
||||
if (!await this.isPreviewRunning()) {
|
||||
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してからスクショしてください。" };
|
||||
this.lastError = true;
|
||||
this.shooting = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "debug_game_command",
|
||||
arguments: { type: "screenshot", args: {}, timeout: 5000, maxWidth: 0, imageFormat: this.shotFormat },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success && parsed.path) {
|
||||
// savePath 配下にコピー
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const projectPath = Editor.Project.path;
|
||||
let destDir = this.savePath || "temp/recordings";
|
||||
if (!path.isAbsolute(destDir)) destDir = path.join(projectPath, destDir);
|
||||
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const ext = path.extname(parsed.path) || ".png";
|
||||
const destPath = path.join(destDir, `screenshot_${ts}${ext}`);
|
||||
fs.copyFileSync(parsed.path, destPath);
|
||||
// 設定に応じて古いファイルをアーカイブ
|
||||
try {
|
||||
const settingsPath = path.join(projectPath, "settings", "cocos-creator-mcp.json");
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
||||
if (cfg.autoArchiveRecordings) {
|
||||
const { archiveOldFiles } = require("../../archive");
|
||||
archiveOldFiles(destDir);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
this.lastResult = { kind: "shot", path: destPath, size: parsed.size };
|
||||
this.lastError = false;
|
||||
} else {
|
||||
const errDetail = parsed?.error || parsed?.message || "スクショ失敗";
|
||||
this.lastResult = { error: errDetail };
|
||||
this.lastError = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.lastResult = { error: `通信エラー: ${e.message}` };
|
||||
this.lastError = true;
|
||||
} finally {
|
||||
this.shooting = false;
|
||||
}
|
||||
},
|
||||
async checkPreviewAlive(this: any) {
|
||||
if (!this.recording) return;
|
||||
try {
|
||||
// MCP経由で軽量なinspectコマンドを送る
|
||||
const res = await fetch(`${MCP_BASE}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0", id: 99, method: "tools/call",
|
||||
params: {
|
||||
name: "debug_game_command",
|
||||
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const content = json.result?.content?.[0]?.text;
|
||||
const parsed = content ? JSON.parse(content) : null;
|
||||
if (parsed?.success) {
|
||||
// プレビュー生存
|
||||
return;
|
||||
}
|
||||
// 応答なし → プレビュー停止とみなして録画停止状態に
|
||||
console.warn("[PreviewRecorder] preview not responding, stopping recording state");
|
||||
this.recording = false;
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
|
||||
this.lastResult = { error: "プレビューが停止したため録画を中断しました(動画は保存されません)" };
|
||||
this.lastError = true;
|
||||
} catch (e) {
|
||||
// ネットワークエラーは通信エラーとして無視(一時的かもしれない)
|
||||
}
|
||||
},
|
||||
async selectSaveFolder(this: any) {
|
||||
try {
|
||||
const result = await (Editor.Dialog as any).select({
|
||||
title: "保存先フォルダを選択",
|
||||
type: "directory",
|
||||
multi: false,
|
||||
});
|
||||
if (result?.filePaths?.length) {
|
||||
const path = require("path");
|
||||
const projectPath = Editor.Project.path;
|
||||
const absPath = result.filePaths[0];
|
||||
// プロジェクト配下なら相対パスで保持
|
||||
const relPath = path.relative(projectPath, absPath);
|
||||
if (!relPath.startsWith("..") && !path.isAbsolute(relPath)) {
|
||||
this.savePath = relPath.replace(/\\/g, "/");
|
||||
} else {
|
||||
this.savePath = absPath;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[PreviewRecorder] selectSaveFolder failed:", e);
|
||||
}
|
||||
},
|
||||
openSaveFolder(this: any) {
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const projectPath = Editor.Project.path;
|
||||
let dir = this.savePath || "temp/recordings";
|
||||
if (!path.isAbsolute(dir)) dir = path.join(projectPath, dir);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
try {
|
||||
const { spawn } = require("child_process");
|
||||
const platform = process.platform;
|
||||
const [cmd, ...args] = platform === "win32"
|
||||
? ["explorer.exe", dir.replace(/\//g, "\\")]
|
||||
: platform === "darwin"
|
||||
? ["open", dir]
|
||||
: ["xdg-open", dir];
|
||||
const p = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
p.unref();
|
||||
} catch (e: any) {
|
||||
console.error("[PreviewRecorder] openSaveFolder failed:", e);
|
||||
this.lastResult = { error: `フォルダを開けませんでした: ${e.message}` };
|
||||
this.lastError = true;
|
||||
}
|
||||
},
|
||||
openFolder(this: any) {
|
||||
if (!this.lastResult?.path) return;
|
||||
const filePath = this.lastResult.path;
|
||||
try {
|
||||
const { shell } = require("electron");
|
||||
if (shell?.showItemInFolder) {
|
||||
shell.showItemInFolder(filePath);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* fallback */ }
|
||||
// フォールバック: OS別コマンド
|
||||
try {
|
||||
const { exec } = require("child_process");
|
||||
const platform = process.platform;
|
||||
if (platform === "win32") {
|
||||
exec(`explorer.exe /select,"${filePath.replace(/\//g, "\\")}"`);
|
||||
} else if (platform === "darwin") {
|
||||
exec(`open -R "${filePath}"`);
|
||||
} else {
|
||||
const dir = require("path").dirname(filePath);
|
||||
exec(`xdg-open "${dir}"`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[PreviewRecorder] openFolder failed:", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
app.mount(this.$.app);
|
||||
panelDataMapRec.set(this, app);
|
||||
},
|
||||
beforeClose() { },
|
||||
close() {
|
||||
const app = panelDataMapRec.get(this);
|
||||
if (app) app.unmount();
|
||||
panelDataMapRec.delete(this);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Scene script — runs inside CocosCreator's scene renderer process.
|
||||
* These methods are called via Editor.Message.request('scene', 'execute-scene-script', ...)
|
||||
* and have access to the `cc` module (engine runtime).
|
||||
*/
|
||||
|
||||
import { join } from "path";
|
||||
module.paths.push(join(Editor.App.path, "node_modules"));
|
||||
|
||||
// ─── Console Log Buffer ───
|
||||
|
||||
interface ConsoleLogEntry {
|
||||
timestamp: string;
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_BUFFER = 500;
|
||||
const _consoleLogs: ConsoleLogEntry[] = [];
|
||||
|
||||
const _originalLog = console.log;
|
||||
const _originalWarn = console.warn;
|
||||
const _originalError = console.error;
|
||||
|
||||
function formatArgs(args: any[]): string {
|
||||
return args.map(a => {
|
||||
if (typeof a === "string") return a;
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
function pushLog(level: ConsoleLogEntry["level"], args: any[]): void {
|
||||
_consoleLogs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formatArgs(args),
|
||||
});
|
||||
if (_consoleLogs.length > MAX_LOG_BUFFER) {
|
||||
_consoleLogs.splice(0, _consoleLogs.length - MAX_LOG_BUFFER);
|
||||
}
|
||||
}
|
||||
|
||||
console.log = function (...args: any[]) {
|
||||
_originalLog.apply(console, args);
|
||||
pushLog("log", args);
|
||||
};
|
||||
|
||||
console.warn = function (...args: any[]) {
|
||||
_originalWarn.apply(console, args);
|
||||
pushLog("warn", args);
|
||||
};
|
||||
|
||||
console.error = function (...args: any[]) {
|
||||
_originalError.apply(console, args);
|
||||
pushLog("error", args);
|
||||
};
|
||||
|
||||
function getScene() {
|
||||
const { director } = require("cc");
|
||||
return director.getScene();
|
||||
}
|
||||
|
||||
function findNode(uuid: string) {
|
||||
const scene = getScene();
|
||||
if (!scene) return null;
|
||||
|
||||
// Recursive search — getChildByUuid is not recursive in cc
|
||||
const queue = [...scene.children];
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
if (node.uuid === uuid) return node;
|
||||
if (node.children) queue.push(...node.children);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build a node tree from a JSON spec.
|
||||
* Returns { uuid, name, children: [...] }
|
||||
*/
|
||||
function buildNodeRecursive(parent: any, spec: any): any {
|
||||
const { Node, js, Vec3 } = require("cc");
|
||||
|
||||
const node = new Node(spec.name || "Node");
|
||||
parent.addChild(node);
|
||||
|
||||
// Add components
|
||||
if (spec.components && Array.isArray(spec.components)) {
|
||||
const { Sprite, Label } = require("cc");
|
||||
for (const compName of spec.components) {
|
||||
const CompClass = js.getClassByName(compName);
|
||||
if (CompClass) {
|
||||
if (!node.getComponent(CompClass)) {
|
||||
const comp = node.addComponent(CompClass);
|
||||
// Sprite: sizeMode=CUSTOM でUITransformサイズの上書きを防ぐ
|
||||
if (comp instanceof Sprite) {
|
||||
comp.sizeMode = 0; // SizeMode.CUSTOM
|
||||
}
|
||||
// Label: useSystemFont + 色を黒に(白パネル上で見えるように)
|
||||
if (comp instanceof Label) {
|
||||
comp.useSystemFont = true;
|
||||
const { Color } = require("cc");
|
||||
comp.color = new Color(51, 51, 51, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set component properties
|
||||
// Format: { "cc.UITransform.contentSize": {width:720, height:1280} }
|
||||
if (spec.properties) {
|
||||
for (const [key, value] of Object.entries(spec.properties)) {
|
||||
const dotIdx = key.lastIndexOf(".");
|
||||
if (dotIdx < 0) continue;
|
||||
const compName = key.substring(0, dotIdx);
|
||||
const propName = key.substring(dotIdx + 1);
|
||||
|
||||
const CompClass = js.getClassByName(compName);
|
||||
if (!CompClass) continue;
|
||||
const comp = node.getComponent(CompClass);
|
||||
if (!comp) continue;
|
||||
|
||||
try {
|
||||
// contentSize needs special handling (Size type)
|
||||
if (propName === "contentSize" && value && typeof value === "object") {
|
||||
comp.setContentSize((value as any).width ?? 0, (value as any).height ?? 0);
|
||||
} else {
|
||||
comp[propName] = value;
|
||||
}
|
||||
} catch { /* skip invalid property */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Set UITransform anchorPoint if specified
|
||||
if (spec.anchorPoint) {
|
||||
const { UITransform } = require("cc");
|
||||
const ut = node.getComponent(UITransform);
|
||||
if (ut) ut.setAnchorPoint(spec.anchorPoint.x ?? 0.5, spec.anchorPoint.y ?? 0.5);
|
||||
}
|
||||
|
||||
// Set node properties (position, scale, active)
|
||||
if (spec.active === false) node.active = false;
|
||||
if (spec.position) node.setPosition(spec.position.x || 0, spec.position.y || 0, spec.position.z || 0);
|
||||
if (spec.scale) node.setScale(spec.scale.x ?? 1, spec.scale.y ?? 1, spec.scale.z ?? 1);
|
||||
|
||||
// Set Widget properties if specified
|
||||
// Format: { top: 0, bottom: 0, left: 0, right: 0 } — each field enables the corresponding alignment
|
||||
if (spec.widget) {
|
||||
const { Widget } = require("cc");
|
||||
let w = node.getComponent(Widget);
|
||||
if (!w) w = node.addComponent(Widget);
|
||||
const wSpec = spec.widget;
|
||||
if (wSpec.top !== undefined) { w.isAlignTop = true; w.top = wSpec.top; }
|
||||
if (wSpec.bottom !== undefined) { w.isAlignBottom = true; w.bottom = wSpec.bottom; }
|
||||
if (wSpec.left !== undefined) { w.isAlignLeft = true; w.left = wSpec.left; }
|
||||
if (wSpec.right !== undefined) { w.isAlignRight = true; w.right = wSpec.right; }
|
||||
if (wSpec.horizontalCenter !== undefined) { w.isAlignHorizontalCenter = true; w.horizontalCenter = wSpec.horizontalCenter; }
|
||||
if (wSpec.verticalCenter !== undefined) { w.isAlignVerticalCenter = true; w.verticalCenter = wSpec.verticalCenter; }
|
||||
}
|
||||
|
||||
// Build children
|
||||
const childResults: any[] = [];
|
||||
if (spec.children && Array.isArray(spec.children)) {
|
||||
for (const childSpec of spec.children) {
|
||||
childResults.push(buildNodeRecursive(node, childSpec));
|
||||
}
|
||||
}
|
||||
|
||||
return { uuid: node.uuid, name: node.name, children: childResults };
|
||||
}
|
||||
|
||||
function collectNodeInfo(node: any, includeComponents: boolean = false): any {
|
||||
const info: any = {
|
||||
uuid: node.uuid,
|
||||
name: node.name,
|
||||
active: node.active,
|
||||
position: { x: node.position.x, y: node.position.y, z: node.position.z },
|
||||
scale: { x: node.scale.x, y: node.scale.y, z: node.scale.z },
|
||||
parent: node.parent?.uuid || null,
|
||||
childCount: node.children?.length || 0,
|
||||
};
|
||||
if (includeComponents && node.components) {
|
||||
info.components = node.components.map((c: any) => ({
|
||||
type: c.constructor.name,
|
||||
uuid: c.uuid,
|
||||
enabled: c.enabled,
|
||||
}));
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
export const methods: Record<string, (...args: any[]) => any> = {
|
||||
getSceneHierarchy(includeComponents: boolean = false) {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const walk = (node: any): any => {
|
||||
const item: any = {
|
||||
uuid: node.uuid,
|
||||
name: node.name,
|
||||
active: node.active,
|
||||
children: [],
|
||||
};
|
||||
if (includeComponents && node.components) {
|
||||
item.components = node.components.map((c: any) => ({
|
||||
type: c.constructor.name,
|
||||
uuid: c.uuid,
|
||||
enabled: c.enabled,
|
||||
}));
|
||||
}
|
||||
if (node.children) {
|
||||
item.children = node.children.map((ch: any) => walk(ch));
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sceneName: scene.name,
|
||||
sceneUuid: scene.uuid,
|
||||
hierarchy: scene.children.map((ch: any) => walk(ch)),
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
getNodeInfo(uuid: string) {
|
||||
try {
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
return { success: true, data: collectNodeInfo(node, true) };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
getAllNodes() {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const nodes: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
nodes.push(collectNodeInfo(node));
|
||||
if (node.children) node.children.forEach(walk);
|
||||
};
|
||||
scene.children.forEach(walk);
|
||||
return { success: true, data: nodes };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 指定ノードの子孫から名前で検索する。auto_bind 用。
|
||||
* rootUuid が空の場合はシーン全体を検索(後方互換)。
|
||||
*/
|
||||
findDescendantsByName(rootUuid: string, name: string) {
|
||||
try {
|
||||
let root: any;
|
||||
if (rootUuid) {
|
||||
root = findNode(rootUuid);
|
||||
if (!root) return { success: false, error: `Root node ${rootUuid} not found` };
|
||||
} else {
|
||||
root = getScene();
|
||||
if (!root) return { success: false, error: "No active scene" };
|
||||
}
|
||||
|
||||
const results: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (node.name === name) results.push(collectNodeInfo(node));
|
||||
if (node.children) node.children.forEach(walk);
|
||||
};
|
||||
if (root.children) root.children.forEach(walk);
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 指定ノードの全子孫を depth 付きで返す。auto_bind の一括検索用。
|
||||
*/
|
||||
getAllDescendants(rootUuid: string) {
|
||||
try {
|
||||
const root = findNode(rootUuid);
|
||||
if (!root) return { success: false, error: `Node ${rootUuid} not found` };
|
||||
|
||||
const results: Array<{uuid: string, name: string, depth: number}> = [];
|
||||
const walk = (node: any, depth: number) => {
|
||||
results.push({ uuid: node.uuid, name: node.name, depth });
|
||||
if (node.children) node.children.forEach((c: any) => walk(c, depth + 1));
|
||||
};
|
||||
if (root.children) root.children.forEach((c: any) => walk(c, 1));
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
findNodesByName(name: string) {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const results: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (node.name === name) results.push(collectNodeInfo(node));
|
||||
if (node.children) node.children.forEach(walk);
|
||||
};
|
||||
scene.children.forEach(walk);
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
findNodeByPath(path: string) {
|
||||
try {
|
||||
const scene = getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const parts = path.split("/");
|
||||
let current: any = null;
|
||||
|
||||
const walk = (node: any): any => {
|
||||
if (node.name === parts[0]) return node;
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = walk(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (const child of scene.children) {
|
||||
current = walk(child);
|
||||
if (current) break;
|
||||
}
|
||||
if (!current) return { success: false, error: `Node "${parts[0]}" not found` };
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const child = current.children?.find((c: any) => c.name === parts[i]);
|
||||
if (!child) return { success: false, error: `Child "${parts[i]}" not found in "${current.name}"` };
|
||||
current = child;
|
||||
}
|
||||
|
||||
return { success: true, data: collectNodeInfo(current) };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
setNodeProperty(uuid: string, property: string, value: any) {
|
||||
try {
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
switch (property) {
|
||||
case "position":
|
||||
node.setPosition(value.x ?? 0, value.y ?? 0, value.z ?? 0);
|
||||
break;
|
||||
case "rotation":
|
||||
node.setRotationFromEuler(value.x ?? 0, value.y ?? 0, value.z ?? 0);
|
||||
break;
|
||||
case "scale":
|
||||
node.setScale(value.x ?? 1, value.y ?? 1, value.z ?? 1);
|
||||
break;
|
||||
case "active":
|
||||
node.active = !!value;
|
||||
break;
|
||||
case "name":
|
||||
node.name = String(value);
|
||||
break;
|
||||
default:
|
||||
(node as any)[property] = value;
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
setComponentProperty(uuid: string, componentType: string, property: string, value: any) {
|
||||
try {
|
||||
const { js } = require("cc");
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
const CompClass = js.getClassByName(componentType);
|
||||
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
|
||||
|
||||
const comp = node.getComponent(CompClass);
|
||||
if (!comp) return { success: false, error: `Component ${componentType} not on node ${uuid}` };
|
||||
|
||||
comp[property] = value;
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
addComponentToNode(uuid: string, componentType: string) {
|
||||
try {
|
||||
const { js } = require("cc");
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
const CompClass = js.getClassByName(componentType);
|
||||
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
|
||||
|
||||
const comp = node.addComponent(CompClass);
|
||||
return { success: true, data: { uuid: comp.uuid, type: componentType } };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
moveNode(uuid: string, parentUuid: string) {
|
||||
try {
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
const parent = findNode(parentUuid);
|
||||
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
|
||||
node.setParent(parent);
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a node tree from a JSON spec in one call.
|
||||
* Spec format:
|
||||
* {
|
||||
* name: string,
|
||||
* components?: string[], // e.g. ["cc.UITransform", "cc.Layout"]
|
||||
* properties?: Record<string, any>, // e.g. { "cc.UITransform.contentSize": {width:720,height:1280} }
|
||||
* children?: NodeSpec[]
|
||||
* }
|
||||
*/
|
||||
buildNodeTree(parentUuid: string, spec: any) {
|
||||
try {
|
||||
const { Node, js, UITransform } = require("cc");
|
||||
const parent = findNode(parentUuid);
|
||||
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
|
||||
|
||||
const result = buildNodeRecursive(parent, spec);
|
||||
return { success: true, data: result };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
testLog(message: string = "test message") {
|
||||
console.log("[testLog]", message);
|
||||
const methods = Object.keys(exports.methods || {});
|
||||
return { success: true, bufferSize: _consoleLogs.length, methods };
|
||||
},
|
||||
|
||||
getConsoleLogs(count: number = 50, level?: string) {
|
||||
let logs = _consoleLogs;
|
||||
if (level) {
|
||||
logs = logs.filter(l => l.level === level);
|
||||
}
|
||||
return { success: true, logs: logs.slice(-count), total: _consoleLogs.length };
|
||||
},
|
||||
|
||||
clearConsoleLogs() {
|
||||
_consoleLogs.length = 0;
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async setPropertyViaEditor(nodeUuid: string, path: string, dump: any) {
|
||||
try {
|
||||
// scene:set-property API
|
||||
// uuid: ノードUUID(コンポーネントUUIDではない)
|
||||
// path: __comps__.{index}.{property} 形式
|
||||
// dump: { value, type } 形式
|
||||
const opts = { uuid: nodeUuid, path, dump };
|
||||
const result = await (Editor as any).Message.request("scene", "set-property", opts);
|
||||
return { success: true, result };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message || String(e) };
|
||||
}
|
||||
},
|
||||
|
||||
removeComponentFromNode(uuid: string, componentType: string) {
|
||||
try {
|
||||
const { js } = require("cc");
|
||||
const node = findNode(uuid);
|
||||
if (!node) return { success: false, error: `Node ${uuid} not found` };
|
||||
|
||||
const CompClass = js.getClassByName(componentType);
|
||||
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
|
||||
|
||||
const comp = node.getComponent(CompClass);
|
||||
if (!comp) return { success: false, error: `Component ${componentType} not on node` };
|
||||
|
||||
node.removeComponent(comp);
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Shared screenshot utility — used by debug-tools and other tools that need screenshots.
|
||||
*/
|
||||
|
||||
export interface ProcessedImage {
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
size: number;
|
||||
format: string;
|
||||
originalSize: string;
|
||||
savedSize: string;
|
||||
}
|
||||
|
||||
export async function processImage(pngBuffer: Buffer, maxWidth: number, desiredFormat?: string): Promise<ProcessedImage> {
|
||||
try {
|
||||
const Vips = require("wasm-vips");
|
||||
const vips = await Vips();
|
||||
let image = vips.Image.newFromBuffer(pngBuffer);
|
||||
if (maxWidth > 0 && image.width > maxWidth) {
|
||||
image = image.thumbnailImage(maxWidth);
|
||||
}
|
||||
if (desiredFormat === "png") {
|
||||
const pngOut = image.pngsaveBuffer();
|
||||
const result = { buffer: Buffer.from(pngOut), width: image.width, height: image.height, format: "png" };
|
||||
image.delete();
|
||||
return result;
|
||||
}
|
||||
const outBuf = image.webpsaveBuffer({ Q: 85 });
|
||||
const result = { buffer: Buffer.from(outBuf), width: image.width, height: image.height, format: "webp" };
|
||||
image.delete();
|
||||
return result;
|
||||
} catch {
|
||||
// Fallback: NativeImage resize + JPEG
|
||||
const electron = require("electron");
|
||||
let image = electron.nativeImage.createFromBuffer(pngBuffer);
|
||||
if (maxWidth > 0) {
|
||||
const size = image.getSize();
|
||||
if (size.width > maxWidth) {
|
||||
const ratio = maxWidth / size.width;
|
||||
image = image.resize({ width: Math.round(size.width * ratio), height: Math.round(size.height * ratio) });
|
||||
}
|
||||
}
|
||||
const size = image.getSize();
|
||||
const buffer = image.toJPEG(85);
|
||||
return { buffer, width: size.width, height: size.height, format: "jpeg" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeEditorScreenshot(savePath?: string | undefined, maxWidth?: number): Promise<ScreenshotResult> {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const electron = require("electron");
|
||||
const windows = electron.BrowserWindow.getAllWindows();
|
||||
if (!windows || windows.length === 0) {
|
||||
throw new Error("No editor window found");
|
||||
}
|
||||
// Find the main (largest) window
|
||||
let win = windows[0];
|
||||
let maxArea = 0;
|
||||
for (const w of windows) {
|
||||
const bounds = w.getBounds();
|
||||
const area = bounds.width * bounds.height;
|
||||
if (area > maxArea) {
|
||||
maxArea = area;
|
||||
win = w;
|
||||
}
|
||||
}
|
||||
// Bring to front and wait for render
|
||||
win.show();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const nativeImage = await win.webContents.capturePage();
|
||||
const originalSize = nativeImage.getSize();
|
||||
const pngBuffer = nativeImage.toPNG();
|
||||
|
||||
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
|
||||
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth);
|
||||
|
||||
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
|
||||
if (!savePath) {
|
||||
const dir = path.join(Editor.Project.tmpDir, "screenshots");
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
savePath = path.join(dir, `screenshot_${timestamp}.${ext}`);
|
||||
}
|
||||
|
||||
const finalPath = savePath!;
|
||||
fs.writeFileSync(finalPath, buffer);
|
||||
return {
|
||||
success: true, path: finalPath, size: buffer.length, format,
|
||||
originalSize: `${originalSize.width}x${originalSize.height}`,
|
||||
savedSize: `${width}x${height}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ToolResult } from "./types";
|
||||
|
||||
/** Helper to create a successful text result */
|
||||
export function ok(data: any): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Helper to create an error result */
|
||||
export function err(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate that a string looks like a CocosCreator UUID (not empty, reasonable format) */
|
||||
export function validateUuid(uuid: string, label: string = "uuid"): string | null {
|
||||
if (!uuid || typeof uuid !== "string") {
|
||||
return `${label} is required`;
|
||||
}
|
||||
if (uuid.trim().length === 0) {
|
||||
return `${label} cannot be empty`;
|
||||
}
|
||||
// CocosCreator UUIDs: either standard format (8-4-4-4-12) or compressed (22 chars with +/=)
|
||||
// Be permissive — just reject obviously wrong values
|
||||
if (uuid.length < 10) {
|
||||
return `${label} "${uuid}" is too short to be a valid UUID`;
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
/** Validate UUID and return err() if invalid, null if valid */
|
||||
export function checkUuid(uuid: string, label: string = "uuid"): ToolResult | null {
|
||||
const error = validateUuid(uuid, label);
|
||||
return error ? err(error) : null;
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class AssetTools implements ToolCategory {
|
||||
readonly categoryName = "asset";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "asset_create",
|
||||
description: "Create a new asset file in the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path for the new asset" },
|
||||
content: { type: "string", description: "File content (for text-based assets)" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_delete",
|
||||
description: "Delete an asset from the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset to delete" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_move",
|
||||
description: "Move/rename an asset.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Current db:// path" },
|
||||
destination: { type: "string", description: "New db:// path" },
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_copy",
|
||||
description: "Copy an asset to a new location.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Source db:// path" },
|
||||
destination: { type: "string", description: "Destination db:// path" },
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_save",
|
||||
description: "Save an asset by UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_reimport",
|
||||
description: "Re-import an asset to refresh it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_path",
|
||||
description: "Get the file path for an asset UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_uuid",
|
||||
description: "Get the UUID for an asset path.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_url",
|
||||
description: "Get the URL for an asset UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_get_details",
|
||||
description: "Get detailed metadata for an asset (type, size, importer, etc.).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_get_dependencies",
|
||||
description: "Get all assets that a given asset depends on.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_open_external",
|
||||
description: "Open an asset in the system's default external editor.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path of the asset" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_import",
|
||||
description: "Import an external file into the project assets.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Source file path on disk" },
|
||||
target: { type: "string", description: "Target db:// path in project" },
|
||||
},
|
||||
required: ["source", "target"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_save_meta",
|
||||
description: "Save asset meta information (importer settings, etc.).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
meta: { type: "string", description: "Meta JSON string" },
|
||||
},
|
||||
required: ["uuid", "meta"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_generate_available_url",
|
||||
description: "Generate a non-conflicting asset URL (avoids name collisions).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "Desired db:// path" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_ready",
|
||||
description: "Check if the asset database is ready.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
// ── 以下、既存MCP未対応のEditor API ──
|
||||
{
|
||||
name: "asset_query_users",
|
||||
description: "Find all assets that use/reference a given asset.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Asset UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asset_query_missing",
|
||||
description: "Check if an asset has missing references.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Asset UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "asset_create":
|
||||
await (Editor.Message.request as any)("asset-db", "create-asset", args.path, args.content || null);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_delete":
|
||||
await (Editor.Message.request as any)("asset-db", "delete-asset", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_move":
|
||||
await (Editor.Message.request as any)("asset-db", "move-asset", args.source, args.destination);
|
||||
return ok({ success: true, source: args.source, destination: args.destination });
|
||||
case "asset_copy":
|
||||
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
|
||||
return ok({ success: true, source: args.source, destination: args.destination });
|
||||
case "asset_save":
|
||||
await (Editor.Message.request as any)("asset-db", "save-asset", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "asset_reimport":
|
||||
await (Editor.Message.request as any)("asset-db", "reimport-asset", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_query_path": {
|
||||
const path = await (Editor.Message.request as any)("asset-db", "query-path", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid, path });
|
||||
}
|
||||
case "asset_query_uuid": {
|
||||
const uuid = await (Editor.Message.request as any)("asset-db", "query-uuid", args.path);
|
||||
return ok({ success: true, path: args.path, uuid });
|
||||
}
|
||||
case "asset_query_url": {
|
||||
const url = await (Editor.Message.request as any)("asset-db", "query-url", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid, url });
|
||||
}
|
||||
case "asset_get_details": {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
|
||||
const meta = await (Editor.Message.request as any)("asset-db", "query-asset-meta", args.uuid).catch(() => null);
|
||||
return ok({ success: true, info, meta });
|
||||
}
|
||||
case "asset_get_dependencies": {
|
||||
// Try multiple API names as they vary by CocosCreator version
|
||||
let deps;
|
||||
try {
|
||||
deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid);
|
||||
} catch {
|
||||
try {
|
||||
deps = await (Editor.Message.request as any)("asset-db", "query-asset-depends", args.uuid);
|
||||
} catch {
|
||||
deps = [];
|
||||
}
|
||||
}
|
||||
return ok({ success: true, uuid: args.uuid, dependencies: deps });
|
||||
}
|
||||
case "asset_open_external":
|
||||
await (Editor.Message.request as any)("asset-db", "open-asset", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "asset_import":
|
||||
await (Editor.Message.request as any)("asset-db", "import-asset", args.source, args.target);
|
||||
return ok({ success: true, source: args.source, target: args.target });
|
||||
case "asset_save_meta":
|
||||
await (Editor.Message.request as any)("asset-db", "save-asset-meta", args.uuid, args.meta);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "asset_generate_available_url": {
|
||||
const url = await (Editor.Message.request as any)("asset-db", "generate-available-url", args.url);
|
||||
return ok({ success: true, url });
|
||||
}
|
||||
case "asset_query_ready": {
|
||||
const ready = await (Editor.Message.request as any)("asset-db", "query-ready");
|
||||
return ok({ success: true, ready });
|
||||
}
|
||||
case "asset_query_users": {
|
||||
const users = await (Editor.Message.request as any)("asset-db", "query-asset-users", args.uuid).catch(() => []);
|
||||
return ok({ success: true, uuid: args.uuid, users });
|
||||
}
|
||||
case "asset_query_missing": {
|
||||
const missing = await (Editor.Message.request as any)("asset-db", "query-missing-asset-info", args.uuid).catch(() => null);
|
||||
return ok({ success: true, uuid: args.uuid, missing, hasMissing: !!missing });
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class BuilderTools implements ToolCategory {
|
||||
readonly categoryName = "builder";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "builder_open_panel",
|
||||
description: "Open the Build panel in the editor.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_get_settings",
|
||||
description: "Get the current build settings/configuration.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_query_tasks",
|
||||
description: "Query active build tasks.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_run_preview",
|
||||
description: "Start the preview server for browser testing.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "builder_stop_preview",
|
||||
description: "Stop the preview server.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "builder_open_panel":
|
||||
Editor.Panel.open("builder");
|
||||
return ok({ success: true });
|
||||
case "builder_get_settings": {
|
||||
const settings = await (Editor.Message.request as any)("builder", "query-build-options").catch(() => null);
|
||||
return ok({ success: true, settings });
|
||||
}
|
||||
case "builder_query_tasks": {
|
||||
const tasks = await (Editor.Message.request as any)("builder", "query-tasks").catch(() => []);
|
||||
return ok({ success: true, tasks });
|
||||
}
|
||||
case "builder_run_preview":
|
||||
await (Editor.Message.request as any)("preview", "start");
|
||||
return ok({ success: true, message: "Preview started" });
|
||||
case "builder_stop_preview":
|
||||
await (Editor.Message.request as any)("preview", "stop");
|
||||
return ok({ success: true, message: "Preview stopped" });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+687
@@ -0,0 +1,687 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import { resolveNodeUuid } from "../node-resolve";
|
||||
import { takeEditorScreenshot } from "../screenshot";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export class ComponentTools implements ToolCategory {
|
||||
readonly categoryName = "component";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "component_add",
|
||||
description: "Add a component to a node. Use cc.XXX format (e.g. 'cc.Label', 'cc.Sprite', 'cc.Button').",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
|
||||
},
|
||||
required: ["uuid", "componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_remove",
|
||||
description: "Remove a component from a node.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
componentType: { type: "string", description: "Component class name to remove" },
|
||||
},
|
||||
required: ["uuid", "componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_get_components",
|
||||
description: "Get all components on a node with their properties.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_set_property",
|
||||
description: "Set one or more properties on a component. For single: use property+value. For batch: use properties array. Use nodeName instead of uuid to find node by name. Set screenshot=true to capture editor screenshot after changes. Examples: Label.string, Label.fontSize, Sprite.color, UITransform.contentSize.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid — avoids UUID lookup)" },
|
||||
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
|
||||
property: { type: "string", description: "Property name (single mode)" },
|
||||
value: { description: "Value to set (single mode)" },
|
||||
properties: {
|
||||
type: "array",
|
||||
description: "Batch mode: array of {property, value} objects to set multiple properties at once",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
property: { type: "string", description: "Property name" },
|
||||
value: { description: "Value to set" },
|
||||
},
|
||||
required: ["property", "value"],
|
||||
},
|
||||
},
|
||||
screenshot: { type: "boolean", description: "If true, capture editor screenshot after setting properties and return the file path (default: false)" },
|
||||
},
|
||||
required: ["componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_get_info",
|
||||
description: "Get detailed dump of a specific component by its UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
componentUuid: { type: "string", description: "Component UUID (not node UUID)" },
|
||||
},
|
||||
required: ["componentUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_get_available",
|
||||
description: "List all available component classes that can be added to nodes.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "component_auto_bind",
|
||||
description: "Automatically bind @property references by matching property names to descendant node names. Searches only descendants of the target node. Validates component type existence. Supports array properties (Slot_0, Slot_1...). Mode: 'fuzzy' (default) tries exact match first, then case-insensitive; 'strict' requires exact match only.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
|
||||
componentType: { type: "string", description: "Script component class name (e.g. 'QuestReadyPageView')" },
|
||||
force: { type: "boolean", description: "If true, rebind even already-bound properties (default: false)" },
|
||||
mode: { type: "string", enum: ["fuzzy", "strict"], description: "Matching mode: 'fuzzy' (default) or 'strict'" },
|
||||
},
|
||||
required: ["componentType"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "component_query_enum",
|
||||
description: "Get enum values for a component property. Useful for knowing what values Layout.type, Layout.resizeMode, etc. accept.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
componentType: { type: "string", description: "Component class (e.g. 'cc.Layout')" },
|
||||
property: { type: "string", description: "Property name (e.g. 'type', 'resizeMode')" },
|
||||
},
|
||||
required: ["uuid", "componentType", "property"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
// パラメータエイリアス: component → componentType
|
||||
const compType = args.componentType || args.component;
|
||||
|
||||
// nodeName → uuid 解決(対応ツールのみ)
|
||||
const needsResolve = ["component_set_property", "component_get_components", "component_auto_bind"];
|
||||
if (needsResolve.includes(toolName) && !args.uuid && args.nodeName) {
|
||||
try {
|
||||
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
|
||||
args.uuid = resolved.uuid;
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "component_add":
|
||||
return this.addComponent(args.uuid, compType);
|
||||
case "component_remove":
|
||||
return this.removeComponent(args.uuid, compType);
|
||||
case "component_get_components":
|
||||
return this.getComponents(args.uuid);
|
||||
case "component_set_property": {
|
||||
const properties = parseMaybeJson(args.properties);
|
||||
let result: ToolResult;
|
||||
if (properties && Array.isArray(properties)) {
|
||||
const parsed = properties.map((p: any) => ({ ...p, value: parseMaybeJson(p.value) }));
|
||||
result = await this.setProperties(args.uuid, compType, parsed);
|
||||
} else {
|
||||
result = await this.setProperty(args.uuid, compType, args.property, parseMaybeJson(args.value));
|
||||
}
|
||||
// screenshot オプション
|
||||
if (args.screenshot) {
|
||||
try {
|
||||
const ss = await takeEditorScreenshot();
|
||||
const data = JSON.parse(result.content[0].text);
|
||||
data.screenshot = { path: ss.path, size: ss.savedSize };
|
||||
return ok(data);
|
||||
} catch (ssErr: any) {
|
||||
// スクショ失敗してもプロパティ設定結果は返す
|
||||
const data = JSON.parse(result.content[0].text);
|
||||
data.screenshotError = ssErr.message || String(ssErr);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case "component_get_info": {
|
||||
try {
|
||||
const dump = await (Editor.Message.request as any)("scene", "query-component", args.componentUuid);
|
||||
return ok({ success: true, component: dump });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "component_get_available": {
|
||||
try {
|
||||
const classes = await (Editor.Message.request as any)("scene", "query-classes");
|
||||
return ok({ success: true, classes });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "component_auto_bind":
|
||||
return this.autoBind(args.uuid, compType, args.force ?? false, args.mode ?? "fuzzy");
|
||||
case "component_query_enum":
|
||||
return this.queryEnum(args.uuid, compType, args.property);
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async addComponent(uuid: string, componentType: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("addComponentToNode", [uuid, componentType]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async queryEnum(nodeUuid: string, componentType: string, property: string): Promise<ToolResult> {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
if (!nodeDump) return err("Node not found");
|
||||
|
||||
const comps = nodeDump.__comps__ || [];
|
||||
for (const comp of comps) {
|
||||
const compType = comp.type;
|
||||
if (!compType) continue;
|
||||
// Match by cc.XXX format
|
||||
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
|
||||
if (compType !== `cc.${normalizedType}` && compType !== componentType) continue;
|
||||
|
||||
const propDump = comp.value?.[property];
|
||||
if (!propDump) return err(`Property '${property}' not found on ${componentType}`);
|
||||
if (propDump.type !== "Enum") {
|
||||
return ok({ success: true, property, type: propDump.type, note: "Not an enum property", currentValue: propDump.value });
|
||||
}
|
||||
return ok({
|
||||
success: true,
|
||||
property,
|
||||
currentValue: propDump.value,
|
||||
enumList: propDump.enumList,
|
||||
});
|
||||
}
|
||||
return err(`Component ${componentType} not found on node`);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async removeComponent(uuid: string, componentType: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("removeComponentFromNode", [uuid, componentType]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getComponents(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!result.success) return ok(result);
|
||||
return ok({
|
||||
success: true,
|
||||
uuid,
|
||||
name: result.data?.name,
|
||||
components: result.data?.components || [],
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @property 名とノード名を自動マッチングしてバインドする。
|
||||
*
|
||||
* - 検索スコープ: 対象ノードの子孫のみ
|
||||
* - 複数ヒット時: 階層の浅いノード(直接の子)を優先
|
||||
* - 型検証: Component 参照型の場合、該当コンポーネントの存在を確認
|
||||
* - 配列対応: @property([Node]) → 連番ノード名 (Slots_0, Slots_1...)
|
||||
* - mode:
|
||||
* - "fuzzy" (default): 完全一致 → case-insensitive → not_found+候補
|
||||
* - "strict": 完全一致のみ → not_found+候補
|
||||
*/
|
||||
private async autoBind(nodeUuid: string, componentType: string, force: boolean, mode: string): Promise<ToolResult> {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
if (!nodeDump) return err("Node not found");
|
||||
|
||||
const comps = nodeDump.__comps__ || [];
|
||||
const compName = componentType.replace("cc.", "");
|
||||
const compIndex = comps.findIndex((c: any) => {
|
||||
const t = c.type || "";
|
||||
return t === compName || t === `cc.${compName}`;
|
||||
});
|
||||
if (compIndex < 0) return err(`Component ${componentType} not found on node`);
|
||||
|
||||
// 子孫ノード一覧を一括取得(検索効率化)
|
||||
const allDescendants = await this.sceneScript("getAllDescendants", [nodeUuid]);
|
||||
const descendantList: Array<{uuid: string, name: string, depth: number}> =
|
||||
allDescendants?.success ? allDescendants.data : [];
|
||||
|
||||
const compDump = comps[compIndex];
|
||||
const properties = compDump.value || {};
|
||||
const skipKeys = new Set(["uuid", "name", "enabled", "node", "__scriptAsset", "__prefab", "_name", "_objFlags", "_enabled"]);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const [propName, propDumpRaw] of Object.entries(properties)) {
|
||||
if (skipKeys.has(propName) || propName.startsWith("_")) continue;
|
||||
|
||||
const propDump = propDumpRaw as any;
|
||||
const propType = propDump.type as string;
|
||||
if (!propType) continue;
|
||||
|
||||
const extendsArr = (propDump.extends || []) as string[];
|
||||
|
||||
// 配列型の判定
|
||||
const isArray = propType === "Array" || Array.isArray(propDump.value);
|
||||
if (isArray) {
|
||||
const arrayResult = await this.autoBindArray(nodeUuid, compIndex, propName, propDump, descendantList, mode);
|
||||
results.push(arrayResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isNodeRef = propType === "cc.Node";
|
||||
const isComponentRef = extendsArr.includes("cc.Component");
|
||||
if (!isNodeRef && !isComponentRef) continue;
|
||||
|
||||
// 既にバインド済みならスキップ
|
||||
const currentValue = propDump.value;
|
||||
if (!force && currentValue?.uuid) {
|
||||
results.push({ property: propName, status: "already_bound" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 名前マッチ: 完全一致 → fuzzy時は case-insensitive
|
||||
const matchResult = this.findMatchingNode(propName, descendantList, mode);
|
||||
|
||||
if (matchResult && isComponentRef) {
|
||||
// 型検証: コンポーネントが存在するか
|
||||
const hasComp = await this.nodeHasComponent(matchResult.uuid, propType);
|
||||
if (!hasComp) {
|
||||
results.push({ property: propName, type: propType, status: "type_mismatch",
|
||||
nodeName: matchResult.name, message: `Node "${matchResult.name}" has no ${propType} component` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchResult) {
|
||||
// 候補サジェスト
|
||||
const suggestions = this.getSuggestions(propName, descendantList);
|
||||
results.push({ property: propName, type: propType, status: "not_found", suggestions });
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = `__comps__.${compIndex}.${propName}`;
|
||||
const dump = await this.buildDumpWithTypeInfo(nodeUuid, path, matchResult.uuid);
|
||||
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, path, dump]);
|
||||
const status = matchResult.exact ? "bound" : "fuzzy_bound";
|
||||
results.push({ property: propName, status, nodeName: matchResult.name, success: setResult?.success !== false });
|
||||
}
|
||||
|
||||
const boundCount = results.filter(r => r.status === "bound" || r.status === "fuzzy_bound").length;
|
||||
const fuzzyCount = results.filter(r => r.status === "fuzzy_bound").length;
|
||||
const notFoundCount = results.filter(r => r.status === "not_found").length;
|
||||
return ok({ success: true, boundCount, fuzzyCount, notFoundCount, results });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 子孫リストからプロパティ名にマッチするノードを検索。
|
||||
* 完全一致を優先、fuzzy モードでは case-insensitive もフォールバック。
|
||||
* 複数ヒット時は階層の浅い(depth が小さい)ものを優先。
|
||||
*/
|
||||
private findMatchingNode(
|
||||
propName: string, descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
|
||||
): { uuid: string; name: string; exact: boolean } | null {
|
||||
const candidates = this.propertyNameToNodeNames(propName);
|
||||
|
||||
// 1. 完全一致
|
||||
for (const candidate of candidates) {
|
||||
const matches = descendants
|
||||
.filter(d => d.name === candidate)
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
if (matches.length > 0) {
|
||||
return { uuid: matches[0].uuid, name: matches[0].name, exact: true };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. fuzzy: case-insensitive
|
||||
if (mode === "fuzzy") {
|
||||
const lowerCandidates = candidates.map(c => c.toLowerCase());
|
||||
const matches = descendants
|
||||
.filter(d => lowerCandidates.includes(d.name.toLowerCase()))
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
if (matches.length > 0) {
|
||||
return { uuid: matches[0].uuid, name: matches[0].name, exact: false };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* not_found 時に似た名前のノードをサジェストする。
|
||||
*/
|
||||
private getSuggestions(propName: string, descendants: Array<{uuid: string, name: string, depth: number}>): string[] {
|
||||
const lower = propName.toLowerCase();
|
||||
return descendants
|
||||
.filter(d => d.name.toLowerCase().includes(lower) || lower.includes(d.name.toLowerCase()))
|
||||
.map(d => d.name)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* ノードに指定型のコンポーネントが存在するか確認。
|
||||
*/
|
||||
private async nodeHasComponent(nodeUuid: string, propType: string): Promise<boolean> {
|
||||
const typeName = propType.replace("cc.", "");
|
||||
const info = await this.sceneScript("getNodeInfo", [nodeUuid]);
|
||||
if (!info?.success || !info?.data?.components) return false;
|
||||
return info.data.components.some((c: any) => c.type === typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配列 @property の自動バインド。
|
||||
* プロパティ名 "slots" → "Slots_0", "Slots_1", ... の連番ノードを検索。
|
||||
*/
|
||||
private async autoBindArray(
|
||||
nodeUuid: string, compIndex: number, propName: string, propDump: any,
|
||||
descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
|
||||
): Promise<any> {
|
||||
const elementType = propDump.value?.[0]?.type as string | undefined;
|
||||
if (!elementType) {
|
||||
return { property: propName, status: "skip", reason: "empty array or unknown element type" };
|
||||
}
|
||||
|
||||
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
|
||||
const foundElements: any[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (true) {
|
||||
const candidateName = `${pascal}_${index}`;
|
||||
// 完全一致 or case-insensitive
|
||||
let match = descendants.find(d => d.name === candidateName);
|
||||
if (!match && mode === "fuzzy") {
|
||||
const lower = candidateName.toLowerCase();
|
||||
match = descendants.find(d => d.name.toLowerCase() === lower);
|
||||
}
|
||||
if (!match) break;
|
||||
|
||||
const elementPath = `__comps__.${compIndex}.${propName}.${index}`;
|
||||
const dump = await this.buildDumpWithTypeInfo(nodeUuid, elementPath, match.uuid);
|
||||
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, elementPath, dump]);
|
||||
const exact = match.name === candidateName;
|
||||
foundElements.push({ index, nodeName: match.name, exact, success: setResult?.success !== false });
|
||||
index++;
|
||||
}
|
||||
|
||||
if (foundElements.length === 0) {
|
||||
return { property: propName, status: "not_found", type: "Array", candidates: [`${pascal}_0`, `${pascal}_1`, "..."] };
|
||||
}
|
||||
const hasFuzzy = foundElements.some(e => !e.exact);
|
||||
return { property: propName, status: hasFuzzy ? "fuzzy_bound" : "bound", type: "Array", count: foundElements.length, elements: foundElements };
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase プロパティ名からノード名の候補を生成。
|
||||
* closeButton → ["CloseButton", "closeButton"]
|
||||
*/
|
||||
private propertyNameToNodeNames(propName: string): string[] {
|
||||
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
|
||||
const names = [pascal];
|
||||
if (pascal !== propName) names.push(propName);
|
||||
return names;
|
||||
}
|
||||
|
||||
private async setProperty(uuid: string, componentType: string, property: string, value: any): Promise<ToolResult> {
|
||||
try {
|
||||
// コンポーネントのインデックスを取得
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
|
||||
return err(`Node ${uuid} not found or has no components`);
|
||||
}
|
||||
const compName = componentType.replace("cc.", "");
|
||||
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
|
||||
if (compIndex < 0) {
|
||||
return err(`Component ${componentType} not found on node ${uuid}`);
|
||||
}
|
||||
|
||||
// scene:set-property でプロパティ変更(Prefab保存時にも反映される)
|
||||
// パス形式: __comps__.{index}.{property}
|
||||
const path = `__comps__.${compIndex}.${property}`;
|
||||
|
||||
// プロパティの型情報をquery-nodeから取得して、適切なdump形式を構築
|
||||
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
|
||||
|
||||
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
|
||||
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
|
||||
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
|
||||
if (componentType === "cc.Widget" && property.startsWith("isAlign")) {
|
||||
await this.recalcWidgetAlignFlags(uuid, compIndex);
|
||||
}
|
||||
|
||||
return ok({ success: true, path, dump, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async setProperties(uuid: string, componentType: string, properties: Array<{property: string, value: any}>): Promise<ToolResult> {
|
||||
try {
|
||||
if (!componentType) return err("componentType is required");
|
||||
if (!properties.length) return err("properties array is empty");
|
||||
|
||||
// コンポーネントのインデックスを取得(1回だけ)
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
|
||||
return err(`Node ${uuid} not found or has no components`);
|
||||
}
|
||||
const compName = componentType.replace("cc.", "");
|
||||
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
|
||||
if (compIndex < 0) {
|
||||
return err(`Component ${componentType} not found on node ${uuid}`);
|
||||
}
|
||||
|
||||
const results: any[] = [];
|
||||
for (const { property, value } of properties) {
|
||||
const path = `__comps__.${compIndex}.${property}`;
|
||||
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
|
||||
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property, success: result?.success !== false, path });
|
||||
}
|
||||
|
||||
const allOk = results.every(r => r.success);
|
||||
|
||||
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
|
||||
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
|
||||
if (componentType === "cc.Widget" && properties.some(p => p.property.startsWith("isAlign"))) {
|
||||
await this.recalcWidgetAlignFlags(uuid, compIndex);
|
||||
}
|
||||
|
||||
return ok({ success: allOk, results });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* cc.Widget の isAlign* プロパティ現在値から _alignFlags ビットマスクを再計算して設定する。
|
||||
*
|
||||
* CocosCreator Editor は isAlign* を setPropertyViaEditor で変更しても
|
||||
* _alignFlags を自動更新しないバグがある。このヘルパーで明示的に同期する。
|
||||
*
|
||||
* _alignFlags ビット定義:
|
||||
* isAlignLeft=1, isAlignRight=2, isAlignTop=4, isAlignBottom=8,
|
||||
* isAlignHorizontalCenter=16, isAlignVerticalCenter=32
|
||||
*/
|
||||
private async recalcWidgetAlignFlags(uuid: string, wIdx: number): Promise<void> {
|
||||
const ALIGN_BITS: Record<string, number> = {
|
||||
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
|
||||
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
|
||||
};
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
|
||||
if (!nodeDump) return;
|
||||
const wCompDump = nodeDump.__comps__?.[wIdx];
|
||||
if (!wCompDump) return;
|
||||
let alignFlags = 0;
|
||||
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
|
||||
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
|
||||
}
|
||||
const path = `__comps__.${wIdx}._alignFlags`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: alignFlags, type: "Number" }]);
|
||||
} catch (_e) {
|
||||
// _alignFlags 再計算の失敗は致命的でないため無視
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* プロパティの型情報をEditor APIから取得し、適切なdump形式を構築する。
|
||||
*
|
||||
* UUID文字列が渡された場合、プロパティの型に応じて:
|
||||
* - Node/Component参照型 → {type: propType, value: {uuid: nodeUuid}}
|
||||
* - Asset参照型(cc.Prefab等) → {type: propType, value: {uuid: assetUuid}}
|
||||
* - String型 → {value, type: "String"}
|
||||
*/
|
||||
private async buildDumpWithTypeInfo(nodeUuid: string, path: string, value: any): Promise<any> {
|
||||
// プリミティブ型はそのまま
|
||||
if (typeof value === "number") return { value, type: "Number" };
|
||||
if (typeof value === "boolean") return { value, type: "Boolean" };
|
||||
|
||||
// オブジェクト形式 {uuid: "xxx", type: "cc.Node"} はそのまま
|
||||
// type 指定なしの {uuid: "xxx"} はプロパティの実際の型を解決するため文字列扱いに変換する
|
||||
if (value !== null && typeof value === "object" && typeof value.uuid === "string") {
|
||||
if (typeof value.type === "string") {
|
||||
return { type: value.type, value: { uuid: value.uuid } };
|
||||
}
|
||||
// type 未指定: 文字列として処理してプロパティ型から解決
|
||||
value = value.uuid;
|
||||
}
|
||||
|
||||
// @path: プレフィックスの場合: パスからノードUUIDを解決
|
||||
if (typeof value === "string" && value.startsWith("@path:")) {
|
||||
const nodePath = value.slice(6);
|
||||
const result = await this.sceneScript("findNodeByPath", [nodePath]);
|
||||
if (result?.success && result.data?.uuid) {
|
||||
value = result.data.uuid;
|
||||
} else {
|
||||
throw new Error(`Node not found at path: ${nodePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 文字列の場合: プロパティの型情報を取得して判定
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
if (nodeDump) {
|
||||
const propDump = this.resolveDumpPath(nodeDump, path);
|
||||
if (propDump?.type) {
|
||||
const propType = propDump.type as string;
|
||||
const extendsArr = (propDump.extends || []) as string[];
|
||||
const isComponentRef = extendsArr.includes("cc.Component");
|
||||
const isNodeRef = propType === "cc.Node";
|
||||
const isAssetRef = extendsArr.includes("cc.Asset");
|
||||
|
||||
if (isComponentRef) {
|
||||
// コンポーネント参照: ノードUUIDからコンポーネントUUIDを解決
|
||||
const compUuid = await this.resolveComponentUuid(value, propType);
|
||||
return { type: propType, value: { uuid: compUuid || value } };
|
||||
}
|
||||
if (isNodeRef) {
|
||||
return { type: propType, value: { uuid: value } };
|
||||
}
|
||||
if (isAssetRef) {
|
||||
return { type: propType, value: { uuid: value } };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// query-node失敗時はフォールバック
|
||||
}
|
||||
return { value, type: "String" };
|
||||
}
|
||||
|
||||
// その他のオブジェクト(contentSize, color等の構造体)
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
const wrapped: any = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
wrapped[k] = { value: v };
|
||||
}
|
||||
return { value: wrapped };
|
||||
}
|
||||
|
||||
return { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* query-nodeのdumpからドットパスでプロパティを解決する。
|
||||
* 例: "__comps__.2.scrollView" → nodeDump.__comps__[2].value.scrollView
|
||||
*/
|
||||
private resolveDumpPath(nodeDump: any, path: string): any {
|
||||
const parts = path.split(".");
|
||||
let current = nodeDump;
|
||||
for (const part of parts) {
|
||||
if (!current) return null;
|
||||
if (part === "__comps__") {
|
||||
current = current.__comps__;
|
||||
} else if (/^\d+$/.test(part)) {
|
||||
current = current[parseInt(part)]?.value;
|
||||
} else {
|
||||
current = current?.[part];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* ノードUUIDからコンポーネントUUIDを解決する。
|
||||
* propType(例: "cc.ScrollView", "MissionListPanel")に一致するコンポーネントを探す。
|
||||
*/
|
||||
private async resolveComponentUuid(nodeUuid: string, propType: string): Promise<string | null> {
|
||||
try {
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
|
||||
if (!nodeInfo?.success || !nodeInfo?.data?.components) return null;
|
||||
const typeName = propType.replace("cc.", "");
|
||||
const comp = nodeInfo.data.components.find((c: any) => c.type === typeName);
|
||||
return comp?.uuid || null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
+804
@@ -0,0 +1,804 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { getGameLogs, clearGameLogs, queueGameCommand, getCommandResult } from "../mcp-server";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import { ensureSceneSafeToSwitch } from "./scene-tools";
|
||||
import { processImage, takeEditorScreenshot } from "../screenshot";
|
||||
|
||||
export class DebugTools implements ToolCategory {
|
||||
readonly categoryName = "debug";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "debug_get_editor_info",
|
||||
description: "Get Cocos Creator editor information (version, platform, language).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_list_messages",
|
||||
description: "List available Editor messages for a given extension or built-in module.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
target: { type: "string", description: "Message target (e.g. 'scene', 'asset-db', 'extension')" },
|
||||
},
|
||||
required: ["target"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_execute_script",
|
||||
description: "Execute a custom scene script method. The method must be registered in scene.ts.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
method: { type: "string", description: "Method name from scene.ts" },
|
||||
args: { type: "array", description: "Arguments to pass", items: {} },
|
||||
},
|
||||
required: ["method"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_get_console_logs",
|
||||
description: "Get recent console log entries. Automatically captures scene process logs (console.log/warn/error in scene scripts). Game preview logs can also be captured by sending POST requests to /log endpoint — see README for setup.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "number", description: "Max number of entries (default 50)" },
|
||||
level: { type: "string", description: "Filter by level: 'log', 'warn', or 'error'" },
|
||||
source: { type: "string", description: "Filter by source: 'scene' or 'game'. Returns both if omitted." },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_clear_console",
|
||||
description: "Clear the editor console.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_list_extensions",
|
||||
description: "List all installed extensions.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_get_project_logs",
|
||||
description: "Read recent project log entries from the log file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
lines: { type: "number", description: "Number of lines to read (default 100)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_search_project_logs",
|
||||
description: "Search for a pattern in project logs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Search pattern (regex supported)" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_get_log_file_info",
|
||||
description: "Get information about the project log file (size, path, last modified).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
// ── 以下、既存MCP未対応のEditor API ──
|
||||
{
|
||||
name: "debug_query_devices",
|
||||
description: "List connected devices (for native debugging).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_open_url",
|
||||
description: "Open a URL in the system browser from the editor.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { url: { type: "string", description: "URL to open" } },
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_validate_scene",
|
||||
description: "Validate the current scene for common issues.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_game_command",
|
||||
description: "Send a command to the running game preview. Requires GameDebugClient in the game. Commands: 'screenshot' (capture game canvas), 'state' (dump GameDb), 'navigate' (go to a page), 'click' (click a node by name), 'inspect' (get runtime node info: UITransform sizes, Widget, Layout, position). Returns the result from the game.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", description: "Command type: 'screenshot', 'state', 'navigate', 'click', 'inspect'" },
|
||||
args: { type: "object", description: "Command arguments (e.g. {page: 'HomePageView'} for navigate, {name: 'ButtonName'} for click)" },
|
||||
timeout: { type: "number", description: "Max wait time in ms (default 5000)" },
|
||||
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960, 0 = no resize)" },
|
||||
imageFormat: { type: "string", description: "Screenshot output format: 'webp' (default, Q=85) or 'png' (lossless)" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_screenshot",
|
||||
description: "Take a screenshot of the editor window and save to a file. Returns the file path of the saved PNG.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
savePath: { type: "string", description: "File path to save the PNG (default: temp/screenshots/screenshot_<timestamp>.png)" },
|
||||
maxWidth: { type: "number", description: "Max width in pixels for resize (default: 960, 0 = no resize). Aspect ratio is preserved." },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_preview",
|
||||
description: "Start or stop the game preview. Uses Preview in Editor (auto-opens MainScene if needed). Falls back to browser preview if editor preview fails.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", description: "'start' (default) or 'stop'" },
|
||||
waitForReady: { type: "boolean", description: "If true, wait until GameDebugClient connects after start (default: false)" },
|
||||
waitTimeout: { type: "number", description: "Max wait time in ms for waitForReady (default: 15000)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_clear_code_cache",
|
||||
description: "Clear the code cache (equivalent to Developer > Cache > Clear code cache) and soft-reload the scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_reload_extension",
|
||||
description: "Reload the MCP extension itself. Use after npm run build to apply code changes without restarting CocosCreator. Response is sent before reload starts.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "debug_get_extension_info",
|
||||
description: "Get detailed information about a specific extension.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Extension name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_record_start",
|
||||
description: "Start recording the game preview canvas to a video file. Uses MediaRecorder on the game side. Bitrate is auto-calculated from canvas resolution × fps × quality coefficient unless videoBitsPerSecond is set explicitly.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fps: { type: "number", description: "Frames per second (default: 30)" },
|
||||
quality: { type: "string", description: "'low'/'medium'/'high'/'ultra' (default: medium). Coefficients: 0.15/0.25/0.40/0.60" },
|
||||
coefficient: { type: "number", description: "Custom bitrate coefficient (width × height × fps × coefficient). Overrides quality." },
|
||||
videoBitsPerSecond: { type: "number", description: "Explicit bitrate in bps. Overrides quality-based calculation." },
|
||||
format: { type: "string", description: "'mp4' (default) or 'webm'. mp4 falls back to webm if not supported." },
|
||||
savePath: { type: "string", description: "Save directory (project-relative or absolute). Default: temp/recordings" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_record_stop",
|
||||
description: "Stop recording started by debug_record_start. Returns the saved WebM file path and size. Video is saved to project's temp/recordings/rec_<datetime>.webm.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeout: { type: "number", description: "Max wait time in ms for file upload (default: 30000)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_batch_screenshot",
|
||||
description: "Navigate to multiple pages and take a screenshot of each. Requires game preview running with GameDebugClient. Returns an array of screenshot file paths.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pages: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of page names to screenshot (e.g. ['HomePageView', 'ShopPageView'])",
|
||||
},
|
||||
delay: { type: "number", description: "Delay in ms between navigate and screenshot (default: 1000)" },
|
||||
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960)" },
|
||||
},
|
||||
required: ["pages"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug_wait_compile",
|
||||
description: "Wait for TypeScript compilation to complete. Monitors the packer-driver debug log for 'Target(editor) ends' message. Use after modifying .ts files to ensure changes are compiled before operating on Prefabs. With clean=true, deletes compiled output first to force a fresh recompile (slower but guaranteed).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeout: { type: "number", description: "Max wait time in ms (default: 15000)" },
|
||||
clean: { type: "boolean", description: "If true, delete compiled output first to force fresh recompile (default: false)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "debug_get_editor_info":
|
||||
return this.getEditorInfo();
|
||||
case "debug_list_messages":
|
||||
return this.listMessages(args.target);
|
||||
case "debug_execute_script":
|
||||
return this.executeScript(args.method, args.args || []);
|
||||
case "debug_get_console_logs":
|
||||
return this.getConsoleLogs(args.count || 50, args.level, args.source);
|
||||
case "debug_clear_console":
|
||||
Editor.Message.send("console", "clear");
|
||||
// Clear scene process log buffer
|
||||
await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method: "clearConsoleLogs",
|
||||
args: [],
|
||||
}).catch(() => {});
|
||||
// Clear game preview log buffer
|
||||
clearGameLogs();
|
||||
return ok({ success: true });
|
||||
case "debug_list_extensions":
|
||||
return this.listExtensions();
|
||||
case "debug_get_project_logs":
|
||||
return this.getProjectLogs(args.lines || 100);
|
||||
case "debug_search_project_logs":
|
||||
return this.searchProjectLogs(args.pattern);
|
||||
case "debug_get_log_file_info":
|
||||
return this.getLogFileInfo();
|
||||
case "debug_query_devices": {
|
||||
const devices = await (Editor.Message.request as any)("device", "query").catch(() => []);
|
||||
return ok({ success: true, devices });
|
||||
}
|
||||
case "debug_open_url":
|
||||
await (Editor.Message.request as any)("program", "open-url", args.url);
|
||||
return ok({ success: true, url: args.url });
|
||||
case "debug_game_command":
|
||||
return this.gameCommand(args.type || args.command, parseMaybeJson(args.args), args.timeout || 5000, args.maxWidth, args.imageFormat);
|
||||
case "debug_screenshot":
|
||||
return this.takeScreenshot(args.savePath, args.maxWidth);
|
||||
case "debug_preview":
|
||||
return this.handlePreview(args.action || "start", args.waitForReady, args.waitTimeout || 15000);
|
||||
case "debug_clear_code_cache":
|
||||
return this.clearCodeCache();
|
||||
case "debug_reload_extension":
|
||||
return this.reloadExtension();
|
||||
case "debug_validate_scene":
|
||||
return this.validateScene();
|
||||
case "debug_get_extension_info":
|
||||
return this.getExtensionInfo(args.name);
|
||||
case "debug_batch_screenshot":
|
||||
return this.batchScreenshot(args.pages, args.delay || 1000, args.maxWidth);
|
||||
case "debug_record_start":
|
||||
return this.gameCommand("record_start", { fps: args.fps, quality: args.quality, coefficient: args.coefficient, videoBitsPerSecond: args.videoBitsPerSecond, format: args.format, savePath: args.savePath }, 5000);
|
||||
case "debug_record_stop":
|
||||
return this.gameCommand("record_stop", undefined, args.timeout || 30000);
|
||||
case "debug_wait_compile":
|
||||
return this.waitCompile(args.timeout || 15000, args.clean ?? false);
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getEditorInfo(): Promise<ToolResult> {
|
||||
return ok({
|
||||
success: true,
|
||||
version: Editor.App.version,
|
||||
path: Editor.App.path,
|
||||
home: Editor.App.home,
|
||||
language: Editor.I18n?.getLanguage?.() || "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessages(target: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("extension", "query-info", target);
|
||||
return ok({ success: true, target, info });
|
||||
} catch (e: any) {
|
||||
const knownMessages: Record<string, string[]> = {
|
||||
"scene": [
|
||||
"query-node-tree", "create-node", "remove-node", "duplicate-node",
|
||||
"set-property", "create-prefab", "save-scene", "execute-scene-script",
|
||||
"query-is-dirty", "query-classes", "soft-reload", "snapshot",
|
||||
"change-gizmo-tool", "query-gizmo-tool-name", "focus-camera-on-nodes",
|
||||
],
|
||||
"asset-db": [
|
||||
"query-assets", "query-asset-info", "query-asset-meta",
|
||||
"refresh-asset", "save-asset", "create-asset", "delete-asset",
|
||||
"move-asset", "copy-asset", "open-asset", "reimport-asset",
|
||||
"query-path", "query-uuid", "query-url", "query-asset-depends",
|
||||
],
|
||||
};
|
||||
const messages = knownMessages[target];
|
||||
if (messages) {
|
||||
return ok({ success: true, target, messages, note: "Static list (query failed)" });
|
||||
}
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async executeScript(method: string, args: any[]): Promise<ToolResult> {
|
||||
const result = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method,
|
||||
args,
|
||||
});
|
||||
return ok(result);
|
||||
}
|
||||
|
||||
private async getConsoleLogs(count: number, level?: string, source?: string): Promise<ToolResult> {
|
||||
// 1. Try Editor's native console API first (may be supported in future CocosCreator versions)
|
||||
if (!source) {
|
||||
try {
|
||||
const logs = await (Editor.Message.request as any)("console", "query-last-logs", count);
|
||||
if (Array.isArray(logs) && logs.length > 0) {
|
||||
return ok({ success: true, logs, source: "editor-api", note: "Using native Editor console API" });
|
||||
}
|
||||
} catch { /* Not supported in this version — use fallback */ }
|
||||
}
|
||||
|
||||
// 2. Fallback: collect from scene process buffer + game preview buffer
|
||||
let sceneLogs: any[] = [];
|
||||
let gameLogs: any[] = [];
|
||||
|
||||
// 2a. Scene process logs (console wrapper in scene.ts)
|
||||
if (!source || source === "scene") {
|
||||
try {
|
||||
const result = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method: "getConsoleLogs",
|
||||
args: [count * 2, level], // request more, will trim after merge
|
||||
});
|
||||
if (result?.logs) {
|
||||
sceneLogs = result.logs.map((l: any) => ({ ...l, source: "scene" }));
|
||||
}
|
||||
} catch { /* scene not available */ }
|
||||
}
|
||||
|
||||
// 2b. Game preview logs (received via POST /log endpoint)
|
||||
if (!source || source === "game") {
|
||||
const gameResult = getGameLogs(count * 2, level);
|
||||
gameLogs = gameResult.logs.map((l: any) => ({ ...l, source: "game" }));
|
||||
}
|
||||
|
||||
// Merge and sort by timestamp, take last `count`
|
||||
const merged = [...sceneLogs, ...gameLogs]
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||
.slice(-count);
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
logs: merged,
|
||||
total: { scene: sceneLogs.length, game: (source === "scene" ? 0 : gameLogs.length) },
|
||||
});
|
||||
}
|
||||
|
||||
private async listExtensions(): Promise<ToolResult> {
|
||||
try {
|
||||
const list = await (Editor.Message.request as any)("extension", "query-all");
|
||||
return ok({ success: true, extensions: list });
|
||||
} catch {
|
||||
return ok({ success: true, extensions: [], note: "Extension query not supported" });
|
||||
}
|
||||
}
|
||||
|
||||
private async getExtensionInfo(name: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("extension", "query-info", name);
|
||||
return ok({ success: true, name, info });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getProjectLogs(lines: number): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
|
||||
if (!fs.existsSync(logPath)) return ok({ success: true, logs: [], note: "Log file not found" });
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
const allLines = content.split("\n");
|
||||
const recent = allLines.slice(-lines);
|
||||
return ok({ success: true, lines: recent.length, logs: recent });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async searchProjectLogs(pattern: string): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
|
||||
if (!fs.existsSync(logPath)) return ok({ success: true, matches: [] });
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
const regex = new RegExp(pattern, "gi");
|
||||
const matches = content.split("\n").filter((line: string) => regex.test(line));
|
||||
return ok({ success: true, pattern, count: matches.length, matches: matches.slice(0, 100) });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getLogFileInfo(): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
|
||||
if (!fs.existsSync(logPath)) return ok({ success: true, exists: false });
|
||||
const stat = fs.statSync(logPath);
|
||||
return ok({ success: true, exists: true, path: logPath, size: stat.size, modified: stat.mtime });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePreview(action: string, waitForReady?: boolean, waitTimeout?: number): Promise<ToolResult> {
|
||||
if (action === "stop") {
|
||||
return this.stopPreview();
|
||||
}
|
||||
const result = await this.startPreview();
|
||||
if (waitForReady) {
|
||||
const resultData = JSON.parse(result.content[0].text);
|
||||
if (resultData.success) {
|
||||
const ready = await this.waitForGameReady(waitTimeout || 15000);
|
||||
resultData.gameReady = ready;
|
||||
if (!ready) {
|
||||
resultData.note = (resultData.note || "") + " GameDebugClient did not connect within timeout.";
|
||||
}
|
||||
return ok(resultData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async waitForGameReady(timeout: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
// Check if game has sent any log or command result recently
|
||||
const gameResult = getGameLogs(1);
|
||||
if (gameResult.total > 0) return true;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async startPreview(): Promise<ToolResult> {
|
||||
try {
|
||||
await this.ensureMainSceneOpen();
|
||||
|
||||
// ツールバーのVueインスタンス経由でplay()を呼ぶ(UI状態も同期される)
|
||||
const played = await this.executeOnToolbar("start");
|
||||
if (played) {
|
||||
return ok({ success: true, action: "start", mode: "editor" });
|
||||
}
|
||||
|
||||
// フォールバック: 直接API
|
||||
const isPlaying = await (Editor.Message.request as any)("scene", "editor-preview-set-play", true);
|
||||
return ok({ success: true, isPlaying, action: "start", mode: "editor", note: "direct API (toolbar UI may not sync)" });
|
||||
} catch (e: any) {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
await electron.shell.openExternal("http://127.0.0.1:7456");
|
||||
return ok({ success: true, action: "start", mode: "browser" });
|
||||
} catch (e2: any) {
|
||||
return err(e2.message || String(e2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async stopPreview(): Promise<ToolResult> {
|
||||
try {
|
||||
// ツールバー経由で停止(UI同期)
|
||||
const stopped = await this.executeOnToolbar("stop");
|
||||
if (!stopped) {
|
||||
// フォールバック: 直接API
|
||||
await (Editor.Message.request as any)("scene", "editor-preview-set-play", false);
|
||||
}
|
||||
// scene:preview-stop ブロードキャストでツールバーUI状態をリセット
|
||||
Editor.Message.broadcast("scene:preview-stop");
|
||||
// シーンビューに戻す
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await this.ensureMainSceneOpen();
|
||||
return ok({ success: true, action: "stop" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async executeOnToolbar(action: "start" | "stop"): Promise<boolean> {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const allContents = electron.webContents.getAllWebContents();
|
||||
for (const wc of allContents) {
|
||||
try {
|
||||
// play()をawaitしない — プレビュー完了を待つとタイムアウトするため
|
||||
if (action === "start") {
|
||||
const result = await wc.executeJavaScript(
|
||||
`(function() { if (window.xxx && window.xxx.play && !window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
|
||||
);
|
||||
if (result) return true;
|
||||
} else {
|
||||
const result = await wc.executeJavaScript(
|
||||
`(function() { if (window.xxx && window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
|
||||
);
|
||||
if (result) return true;
|
||||
}
|
||||
} catch { /* not the toolbar webContents */ }
|
||||
}
|
||||
} catch { /* electron API not available */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
private async ensureMainSceneOpen(): Promise<void> {
|
||||
const hierarchy = await Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method: "getSceneHierarchy",
|
||||
args: [false],
|
||||
}).catch(() => null);
|
||||
|
||||
if (!hierarchy?.sceneName || hierarchy.sceneName === "scene-2d") {
|
||||
// プロジェクト設定のStart Sceneを参照
|
||||
let sceneUuid: string | null = null;
|
||||
try {
|
||||
sceneUuid = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Start Sceneが未設定 or "current_scene" の場合、最初のシーンを使う
|
||||
if (!sceneUuid || sceneUuid === "current_scene") {
|
||||
const scenes = await Editor.Message.request("asset-db", "query-assets", {
|
||||
ccType: "cc.SceneAsset",
|
||||
pattern: "db://assets/**/*",
|
||||
});
|
||||
if (Array.isArray(scenes) && scenes.length > 0) {
|
||||
sceneUuid = scenes[0].uuid;
|
||||
}
|
||||
}
|
||||
|
||||
if (sceneUuid) {
|
||||
// debug_preview 内部の自動遷移は preview を優先して force=true
|
||||
// (dialog 出るより preview 開始を優先する運用)
|
||||
await ensureSceneSafeToSwitch(true);
|
||||
await (Editor.Message.request as any)("scene", "open-scene", sceneUuid);
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async clearCodeCache(): Promise<ToolResult> {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const menu = electron.Menu.getApplicationMenu();
|
||||
if (!menu) return err("Application menu not found");
|
||||
|
||||
const findMenuItem = (items: any[], path: string[]): any => {
|
||||
for (const item of items) {
|
||||
if (item.label === path[0]) {
|
||||
if (path.length === 1) return item;
|
||||
if (item.submenu?.items) return findMenuItem(item.submenu.items, path.slice(1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cacheItem = findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]);
|
||||
if (!cacheItem) return err("Menu item 'Developer > Cache > Clear code cache' not found");
|
||||
|
||||
cacheItem.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
return ok({ success: true, note: "Code cache cleared via menu" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async gameCommand(type: string, args: any, timeout: number, maxWidth?: number, imageFormat?: string): Promise<ToolResult> {
|
||||
const cmdId = queueGameCommand(type, args);
|
||||
|
||||
// Poll for result
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const result = getCommandResult();
|
||||
if (result && result.id === cmdId) {
|
||||
// If screenshot, save to file and return path
|
||||
if (type === "screenshot" && result.success && result.data?.dataUrl) {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const dir = path.join(Editor.Project.tmpDir, "screenshots");
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const base64 = result.data.dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
const pngBuffer = Buffer.from(base64, "base64");
|
||||
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
|
||||
const electron = require("electron");
|
||||
const origImage = electron.nativeImage.createFromBuffer(pngBuffer);
|
||||
const originalSize = origImage.getSize();
|
||||
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth, imageFormat);
|
||||
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
|
||||
const filePath = path.join(dir, `game_${timestamp}.${ext}`);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return ok({
|
||||
success: true, path: filePath, size: buffer.length, format,
|
||||
originalSize: `${originalSize.width}x${originalSize.height}`,
|
||||
savedSize: `${width}x${height}`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return ok({ success: true, note: "Screenshot captured but file save failed", error: e.message });
|
||||
}
|
||||
}
|
||||
return ok(result);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
return err(`Game did not respond within ${timeout}ms. Is GameDebugClient running in the preview?`);
|
||||
}
|
||||
|
||||
private async takeScreenshot(savePath?: string, maxWidth?: number): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await takeEditorScreenshot(savePath, maxWidth);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadExtension(): Promise<ToolResult> {
|
||||
// Schedule reload after response is sent
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (Editor.Message.request as any)("extension", "reload", "cocos-creator-mcp");
|
||||
} catch (e: any) {
|
||||
console.error("[MCP] Extension reload failed:", e.message);
|
||||
}
|
||||
}, 500);
|
||||
return ok({ success: true, note: "Extension reload scheduled. MCP server will restart in ~1s. NOTE: Adding new tool definitions or modifying scene.ts requires a full CocosCreator restart (reload is not sufficient)." });
|
||||
}
|
||||
|
||||
private async batchScreenshot(pages: string[], delay: number, maxWidth?: number): Promise<ToolResult> {
|
||||
const results: any[] = [];
|
||||
const timeout = 10000;
|
||||
|
||||
for (const page of pages) {
|
||||
// Navigate
|
||||
const navResult = await this.gameCommand("navigate", { page }, timeout, maxWidth);
|
||||
const navData = JSON.parse(navResult.content[0].text);
|
||||
if (!navData.success) {
|
||||
results.push({ page, success: false, error: "navigate failed" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait for page to render
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
// Screenshot
|
||||
const ssResult = await this.gameCommand("screenshot", {}, timeout, maxWidth);
|
||||
const ssData = JSON.parse(ssResult.content[0].text);
|
||||
results.push({
|
||||
page,
|
||||
success: ssData.success || false,
|
||||
path: ssData.path,
|
||||
error: ssData.success ? undefined : (ssData.error || ssData.message),
|
||||
});
|
||||
}
|
||||
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
return ok({
|
||||
success: true,
|
||||
total: pages.length,
|
||||
succeeded,
|
||||
failed: pages.length - succeeded,
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
private async validateScene(): Promise<ToolResult> {
|
||||
try {
|
||||
const tree = await Editor.Message.request("scene", "query-node-tree");
|
||||
const issues: string[] = [];
|
||||
const checkNodes = (nodes: any[]) => {
|
||||
if (!nodes) return;
|
||||
for (const node of nodes) {
|
||||
if (!node.name) issues.push(`Node ${node.uuid} has no name`);
|
||||
if (node.children) checkNodes(node.children);
|
||||
}
|
||||
};
|
||||
if (Array.isArray(tree)) checkNodes(tree);
|
||||
return ok({ success: true, issueCount: issues.length, issues });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeScript コンパイル完了を待つ。
|
||||
* packer-driver の debug.log に "Target(editor) ends" が現れるのを監視する。
|
||||
* 既にコンパイル済み(直近数秒以内に完了ログあり)なら即座に返す。
|
||||
*/
|
||||
private async waitCompile(timeout: number, clean: boolean): Promise<ToolResult> {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const logPath = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "logs", "debug.log");
|
||||
const chunksDir = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "targets", "editor", "chunks");
|
||||
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return err(`Compile log not found: ${logPath}`);
|
||||
}
|
||||
|
||||
const MARKER = "Target(editor) ends";
|
||||
|
||||
// clean モード: コードキャッシュクリア + soft-reload で再コンパイルを強制
|
||||
if (clean) {
|
||||
// Developer > Cache > Clear code cache をクリック
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const menu = electron.Menu.getApplicationMenu();
|
||||
const findMenuItem = (items: any[], labels: string[]): any => {
|
||||
for (const item of items) {
|
||||
if (item.label === labels[0]) {
|
||||
if (labels.length === 1) return item;
|
||||
if (item.submenu?.items) return findMenuItem(item.submenu.items, labels.slice(1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const cacheItem = menu ? findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]) : null;
|
||||
if (cacheItem) cacheItem.click();
|
||||
} catch (_e) { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
// soft-reload でシーンを再読み込み → コンパイルトリガー
|
||||
await (Editor.Message.request as any)("scene", "soft-reload").catch(() => {});
|
||||
}
|
||||
|
||||
// refresh-asset でファイル変更を CC に通知してコンパイルをトリガー
|
||||
await (Editor.Message.request as any)("asset-db", "refresh-asset", "db://assets").catch(() => {});
|
||||
|
||||
const initialSize = fs.statSync(logPath).size;
|
||||
const startTime = Date.now();
|
||||
const POLL_INTERVAL = 200;
|
||||
const DETECT_GRACE_MS = 2000; // CC がファイル変更を検知するまでの猶予
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
||||
|
||||
const currentSize = fs.statSync(logPath).size;
|
||||
|
||||
// ログが成長していない
|
||||
if (currentSize <= initialSize) {
|
||||
// clean モードでは必ずコンパイルが走るので猶予判定しない
|
||||
if (clean) continue;
|
||||
// 猶予期間内はまだ待つ (CC の検知が遅い可能性)
|
||||
if (Date.now() - startTime < DETECT_GRACE_MS) continue;
|
||||
// 猶予期間を過ぎてもログが成長しない → コンパイル不要
|
||||
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime, note: "No compilation triggered (no changes detected)" });
|
||||
}
|
||||
|
||||
// ログが成長した → 新しい部分にマーカーがあるか確認
|
||||
const fd = fs.openSync(logPath, "r");
|
||||
const newBytes = currentSize - initialSize;
|
||||
const buffer = Buffer.alloc(newBytes);
|
||||
fs.readSync(fd, buffer, 0, newBytes, initialSize);
|
||||
fs.closeSync(fd);
|
||||
const newContent = buffer.toString("utf8");
|
||||
|
||||
if (newContent.includes(MARKER)) {
|
||||
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime });
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ success: true, compiled: false, timeout: true, waitedMs: timeout });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+624
@@ -0,0 +1,624 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import { resolveNodeUuid } from "../node-resolve";
|
||||
import { takeEditorScreenshot } from "../screenshot";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export class NodeTools implements ToolCategory {
|
||||
readonly categoryName = "node";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "node_create",
|
||||
description: "Create a new node in the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Node name" },
|
||||
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
|
||||
components: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Component class names to add (e.g. ['cc.Label', 'cc.Sprite'])",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_get_info",
|
||||
description: "Get detailed information about a node by UUID, including components.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_find_by_name",
|
||||
description: "Find all nodes matching a given name.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Node name to search" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_property",
|
||||
description: "Set a property on a node (name, active, position, rotation, scale, etc.).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
property: { type: "string", description: "Property name (e.g. 'name', 'active', 'position')" },
|
||||
value: { description: "Value to set. For position/rotation/scale use {x,y,z}." },
|
||||
},
|
||||
required: ["uuid", "property", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_transform",
|
||||
description: "Set position, rotation, and/or scale of a node at once.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
position: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
|
||||
description: "Position {x,y,z}",
|
||||
},
|
||||
rotation: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
|
||||
description: "Euler rotation {x,y,z}",
|
||||
},
|
||||
scale: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
|
||||
description: "Scale {x,y,z}",
|
||||
},
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_delete",
|
||||
description: "Delete a node by UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_move",
|
||||
description: "Move a node to a new parent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
parentUuid: { type: "string", description: "New parent node UUID" },
|
||||
},
|
||||
required: ["uuid", "parentUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_duplicate",
|
||||
description: "Duplicate a node.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID to duplicate" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_get_all",
|
||||
description: "Get a flat list of all nodes in the current scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_active",
|
||||
description: "Set a node's active (visible) state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
active: { type: "boolean", description: "Whether the node is active" },
|
||||
},
|
||||
required: ["uuid", "active"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_layer",
|
||||
description: "Set a node's layer.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
layer: { type: "number", description: "Layer value" },
|
||||
},
|
||||
required: ["uuid", "layer"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_detect_type",
|
||||
description: "Detect node type (2D, 3D, or regular Node) based on its components.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_create_tree",
|
||||
description: "Create a full node tree from a JSON spec in one call. Much faster than creating nodes one by one. Spec format: { name, components?: ['cc.UITransform'], properties?: {'cc.UITransform.contentSize': {width:720,height:1280}}, widget?: {top:0, bottom:0, left:0, right:0}, active?: bool, position?: {x,y,z}, children?: [...] }",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
parent: { type: "string", description: "Parent node UUID" },
|
||||
spec: { description: "Node tree specification (JSON object with name, components, properties, children)" },
|
||||
},
|
||||
required: ["parent", "spec"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "node_set_layout",
|
||||
description: "Set UITransform (contentSize, anchorPoint) and Widget (margins) on a node in one call. Much faster than calling component_set_property multiple times for layout adjustments. Set screenshot=true to capture the editor after changes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
|
||||
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
|
||||
contentSize: {
|
||||
type: "object",
|
||||
properties: { width: { type: "number" }, height: { type: "number" } },
|
||||
description: "UITransform contentSize {width, height}",
|
||||
},
|
||||
anchorPoint: {
|
||||
type: "object",
|
||||
properties: { x: { type: "number" }, y: { type: "number" } },
|
||||
description: "UITransform anchorPoint {x, y} (0-1)",
|
||||
},
|
||||
widget: {
|
||||
type: "object",
|
||||
properties: {
|
||||
top: { type: "number" }, bottom: { type: "number" },
|
||||
left: { type: "number" }, right: { type: "number" },
|
||||
horizontalCenter: { type: "number" }, verticalCenter: { type: "number" },
|
||||
isAlignTop: { type: "boolean" }, isAlignBottom: { type: "boolean" },
|
||||
isAlignLeft: { type: "boolean" }, isAlignRight: { type: "boolean" },
|
||||
isAlignHorizontalCenter: { type: "boolean" }, isAlignVerticalCenter: { type: "boolean" },
|
||||
},
|
||||
description: "Widget alignment margins. Setting a value (e.g. top:0) automatically enables the corresponding alignment (isAlignTop:true).",
|
||||
},
|
||||
color: {
|
||||
type: "object",
|
||||
properties: { r: { type: "number" }, g: { type: "number" }, b: { type: "number" }, a: { type: "number" } },
|
||||
description: "Node color {r,g,b,a} (0-255)",
|
||||
},
|
||||
opacity: { type: "number", description: "Node opacity (0-255)" },
|
||||
screenshot: { type: "boolean", description: "If true, capture editor screenshot after changes (default: false)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
const rejected = await this.rejectIfPreviewRunning(toolName);
|
||||
if (rejected) return rejected;
|
||||
|
||||
switch (toolName) {
|
||||
case "node_create":
|
||||
return this.createNode(args.name, args.parent, args.components);
|
||||
case "node_get_info":
|
||||
return this.getNodeInfo(args.uuid);
|
||||
case "node_find_by_name":
|
||||
return this.findByName(args.name);
|
||||
case "node_set_property":
|
||||
return this.setProperty(args.uuid, args.property, parseMaybeJson(args.value));
|
||||
case "node_set_transform":
|
||||
return this.setTransform(args.uuid, args.position, args.rotation, args.scale);
|
||||
case "node_delete":
|
||||
return this.deleteNode(args.uuid);
|
||||
case "node_move":
|
||||
return this.moveNode(args.uuid, args.parentUuid);
|
||||
case "node_duplicate":
|
||||
return this.duplicateNode(args.uuid);
|
||||
case "node_get_all":
|
||||
return this.getAllNodes();
|
||||
case "node_set_active":
|
||||
return this.setProperty(args.uuid, "active", args.active);
|
||||
case "node_set_layer":
|
||||
return this.setProperty(args.uuid, "layer", args.layer);
|
||||
case "node_create_tree":
|
||||
return this.createNodeTree(args.parent, parseMaybeJson(args.spec));
|
||||
case "node_set_layout":
|
||||
return this.setLayout(args);
|
||||
case "node_detect_type": {
|
||||
try {
|
||||
const info = await this.sceneScript("getNodeInfo", [args.uuid]);
|
||||
if (!info.success) return ok(info);
|
||||
const comps = info.data?.components || [];
|
||||
const compTypes = comps.map((c: any) => c.type);
|
||||
let nodeType = "Node";
|
||||
if (compTypes.includes("UITransform")) nodeType = "2D";
|
||||
else if (compTypes.includes("MeshRenderer") || compTypes.includes("Camera")) nodeType = "3D";
|
||||
return ok({ success: true, uuid: args.uuid, nodeType, components: compTypes });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Scene editing tools that must not run during preview */
|
||||
private static readonly SCENE_EDIT_TOOLS = new Set([
|
||||
"node_create", "node_delete", "node_move", "node_duplicate",
|
||||
"node_set_property", "node_set_transform", "node_set_active", "node_set_layer",
|
||||
"node_create_tree", "node_set_layout",
|
||||
]);
|
||||
|
||||
private async rejectIfPreviewRunning(toolName: string): Promise<ToolResult | null> {
|
||||
if (!NodeTools.SCENE_EDIT_TOOLS.has(toolName)) return null;
|
||||
try {
|
||||
const state = await Editor.Message.request("preview", "query-info");
|
||||
if (state && (state as any).running) {
|
||||
return err(`"${toolName}" はプレビュー中に実行できません。先にプレビューを停止してください。`);
|
||||
}
|
||||
} catch { /* query failed — allow execution */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createNode(name: string, parent?: string, components?: string[]): Promise<ToolResult> {
|
||||
try {
|
||||
// Use Editor API to create node
|
||||
const uuid = await Editor.Message.request("scene", "create-node", {
|
||||
parent: parent || undefined,
|
||||
name,
|
||||
assetUuid: undefined,
|
||||
});
|
||||
|
||||
// Wait until the node is queryable in the scene process
|
||||
await this.waitForNode(uuid);
|
||||
|
||||
// Add components if specified
|
||||
if (components && components.length > 0) {
|
||||
for (const comp of components) {
|
||||
await this.sceneScript("addComponentToNode", [uuid, comp]);
|
||||
// Wait until the component is reflected in query-node
|
||||
await this.waitForComponent(uuid, comp);
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ success: true, uuid, name });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a node becomes queryable in the scene process.
|
||||
* Editor.Message.request("scene", "create-node") returns before the node
|
||||
* is fully registered in the scene hierarchy, so subsequent scene script
|
||||
* calls (findNode) may fail without this wait.
|
||||
*/
|
||||
private async waitForNode(uuid: string, maxRetries = 10, intervalMs = 100): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const result = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (result?.success) return;
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
// Don't throw — let the caller proceed and get a more specific error if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a component added via addComponentToNode is reflected in query-node.
|
||||
* sceneScript returns before the Editor API (query-node) reflects the change,
|
||||
* so polling is needed to avoid race conditions in subsequent tool calls.
|
||||
*/
|
||||
private async waitForComponent(nodeUuid: string, componentType: string, maxRetries = 10, intervalMs = 100): Promise<void> {
|
||||
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
|
||||
const comps: any[] = nodeDump?.__comps__ || [];
|
||||
const found = comps.some((c) =>
|
||||
c.type === componentType || c.type === `cc.${normalizedType}` || c.type === normalizedType
|
||||
);
|
||||
if (found) return;
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
// Don't throw — component may still work; let caller get a specific error if needed
|
||||
}
|
||||
|
||||
private async createNodeTree(parentUuid: string, spec: any): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("buildNodeTree", [parentUuid, spec]);
|
||||
if (!result?.success) return err(result?.error || "buildNodeTree failed");
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getNodeInfo(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async findByName(name: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("findNodesByName", [name]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async setProperty(uuid: string, property: string, value: any): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("setNodeProperty", [uuid, property, value]);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async setTransform(uuid: string, position?: any, rotation?: any, scale?: any): Promise<ToolResult> {
|
||||
try {
|
||||
const results: any[] = [];
|
||||
if (position) {
|
||||
results.push(await this.sceneScript("setNodeProperty", [uuid, "position", position]));
|
||||
}
|
||||
if (rotation) {
|
||||
results.push(await this.sceneScript("setNodeProperty", [uuid, "rotation", rotation]));
|
||||
}
|
||||
if (scale) {
|
||||
results.push(await this.sceneScript("setNodeProperty", [uuid, "scale", scale]));
|
||||
}
|
||||
const anyFailed = results.find((r) => !r.success);
|
||||
if (anyFailed) return ok(anyFailed);
|
||||
return ok({ success: true, uuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteNode(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
await Editor.Message.request("scene", "remove-node", { uuid });
|
||||
return ok({ success: true, uuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async moveNode(uuid: string, parentUuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
await (Editor.Message.request as any)("scene", "set-property", {
|
||||
uuid,
|
||||
path: "parent",
|
||||
dump: { type: "cc.Node", value: { uuid: parentUuid } },
|
||||
});
|
||||
return ok({ success: true, uuid, parentUuid });
|
||||
} catch (e: any) {
|
||||
// Fallback: try scene script
|
||||
try {
|
||||
const result = await this.sceneScript("moveNode", [uuid, parentUuid]);
|
||||
return ok(result);
|
||||
} catch (e2: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateNode(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await Editor.Message.request("scene", "duplicate-node", uuid);
|
||||
// duplicate-node returns an array of UUIDs
|
||||
const newUuid = Array.isArray(result) ? result[0] : result;
|
||||
return ok({ success: true, sourceUuid: uuid, newUuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getAllNodes(): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await this.sceneScript("getAllNodes", []);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UITransform + Widget + color/opacity をまとめて設定する。
|
||||
* Widget の値を指定すると、対応する isAlign* フラグを自動で true にする。
|
||||
*/
|
||||
private async setLayout(args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
// nodeName → uuid 解決
|
||||
let uuid = args.uuid;
|
||||
if (!uuid && args.nodeName) {
|
||||
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
|
||||
uuid = resolved.uuid;
|
||||
}
|
||||
if (!uuid) return err("Either 'uuid' or 'nodeName' is required");
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
// UITransform の設定
|
||||
const contentSize = parseMaybeJson(args.contentSize);
|
||||
const anchorPoint = parseMaybeJson(args.anchorPoint);
|
||||
if (contentSize || anchorPoint) {
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
|
||||
const comps = nodeInfo.data?.components || [];
|
||||
const utIdx = comps.findIndex((c: any) => c.type === "UITransform");
|
||||
if (utIdx < 0) return err("Node has no UITransform component");
|
||||
|
||||
if (contentSize) {
|
||||
const path = `__comps__.${utIdx}.contentSize`;
|
||||
const dump = { value: { width: { value: contentSize.width }, height: { value: contentSize.height } } };
|
||||
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: "contentSize", success: r?.success !== false });
|
||||
}
|
||||
if (anchorPoint) {
|
||||
const path = `__comps__.${utIdx}.anchorPoint`;
|
||||
const dump = { value: { x: { value: anchorPoint.x }, y: { value: anchorPoint.y } } };
|
||||
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: "anchorPoint", success: r?.success !== false });
|
||||
}
|
||||
}
|
||||
|
||||
// Widget の設定
|
||||
const widget = parseMaybeJson(args.widget);
|
||||
if (widget) {
|
||||
// Widget コンポーネントを探す(なければ追加)
|
||||
let nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
|
||||
let comps = nodeInfo.data?.components || [];
|
||||
let wIdx = comps.findIndex((c: any) => c.type === "Widget");
|
||||
if (wIdx < 0) {
|
||||
await this.sceneScript("addComponentToNode", [uuid, "cc.Widget"]);
|
||||
// 再取得
|
||||
nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
comps = nodeInfo.data?.components || [];
|
||||
wIdx = comps.findIndex((c: any) => c.type === "Widget");
|
||||
if (wIdx < 0) return err("Failed to add Widget component");
|
||||
results.push({ property: "Widget", action: "added" });
|
||||
}
|
||||
|
||||
// isAlign* を自動設定(値があれば true にする)
|
||||
const alignMap: Record<string, string> = {
|
||||
top: "isAlignTop", bottom: "isAlignBottom",
|
||||
left: "isAlignLeft", right: "isAlignRight",
|
||||
horizontalCenter: "isAlignHorizontalCenter",
|
||||
verticalCenter: "isAlignVerticalCenter",
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(widget)) {
|
||||
// isAlign* を明示指定した場合はそのまま設定
|
||||
const path = `__comps__.${wIdx}.${key}`;
|
||||
if (typeof value === "boolean") {
|
||||
const dump = { value, type: "Boolean" };
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: `Widget.${key}`, success: true });
|
||||
} else if (typeof value === "number") {
|
||||
// まず対応する isAlign* を true にする
|
||||
const alignKey = alignMap[key];
|
||||
if (alignKey && widget[alignKey] === undefined) {
|
||||
const alignPath = `__comps__.${wIdx}.${alignKey}`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, alignPath, { value: true, type: "Boolean" }]);
|
||||
}
|
||||
const dump = { value, type: "Number" };
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
|
||||
results.push({ property: `Widget.${key}`, success: true });
|
||||
}
|
||||
}
|
||||
|
||||
// _alignFlags を isAlign* 現在値から再計算して設定
|
||||
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
|
||||
try {
|
||||
const ALIGN_BITS: Record<string, number> = {
|
||||
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
|
||||
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
|
||||
};
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
|
||||
if (nodeDump) {
|
||||
const wCompDump = nodeDump.__comps__?.[wIdx];
|
||||
if (wCompDump) {
|
||||
let alignFlags = 0;
|
||||
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
|
||||
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
|
||||
}
|
||||
const flagPath = `__comps__.${wIdx}._alignFlags`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, flagPath, { value: alignFlags, type: "Number" }]);
|
||||
results.push({ property: "Widget._alignFlags", value: alignFlags });
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// _alignFlags 再計算の失敗は致命的でないため無視
|
||||
}
|
||||
}
|
||||
|
||||
// color
|
||||
const color = parseMaybeJson(args.color);
|
||||
if (color) {
|
||||
const r = await this.sceneScript("setNodeProperty", [uuid, "color", color]);
|
||||
results.push({ property: "color", success: r?.success !== false });
|
||||
}
|
||||
|
||||
// opacity
|
||||
if (args.opacity !== undefined) {
|
||||
// cc.UIOpacity を使う(なければ color.a で設定)
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
|
||||
const comps = nodeInfo?.data?.components || [];
|
||||
const opIdx = comps.findIndex((c: any) => c.type === "UIOpacity");
|
||||
if (opIdx >= 0) {
|
||||
const path = `__comps__.${opIdx}.opacity`;
|
||||
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: args.opacity, type: "Number" }]);
|
||||
results.push({ property: "UIOpacity.opacity", success: true });
|
||||
} else {
|
||||
// UIOpacity がない場合は color.a を直接設定
|
||||
const currentColor = nodeInfo?.data?.color || { r: 255, g: 255, b: 255, a: 255 };
|
||||
currentColor.a = args.opacity;
|
||||
const r = await this.sceneScript("setNodeProperty", [uuid, "color", currentColor]);
|
||||
results.push({ property: "color.a", success: r?.success !== false });
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = results.every(r => r.success !== false);
|
||||
let response: any = { success: allOk, uuid, results };
|
||||
|
||||
// screenshot
|
||||
if (args.screenshot) {
|
||||
try {
|
||||
const ss = await takeEditorScreenshot();
|
||||
response.screenshot = { path: ss.path, size: ss.savedSize };
|
||||
} catch (ssErr: any) {
|
||||
response.screenshotError = ssErr.message || String(ssErr);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(response);
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a scene script method */
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
+680
@@ -0,0 +1,680 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { ensureSceneSafeToSwitch, safeSaveScene } from "./scene-tools";
|
||||
import { parseMaybeJson } from "../utils";
|
||||
import type { ComponentTools } from "./component-tools";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
/** prefab_instantiate で配置したネスト Prefab 情報を記憶 */
|
||||
interface NestedPrefabEntry {
|
||||
nodeUuid: string;
|
||||
prefabAssetUuid: string;
|
||||
parentUuid: string;
|
||||
}
|
||||
|
||||
export class PrefabTools implements ToolCategory {
|
||||
readonly categoryName = "prefab";
|
||||
private _pendingNestedPrefabs: NestedPrefabEntry[] = [];
|
||||
/** prefab_open で開いた Prefab アセット UUID */
|
||||
private _currentPrefabUuid: string | null = null;
|
||||
private _componentTools: ComponentTools | null;
|
||||
|
||||
constructor(componentTools?: ComponentTools) {
|
||||
this._componentTools = componentTools ?? null;
|
||||
}
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "prefab_list",
|
||||
description: "List all prefab files in the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_create",
|
||||
description: "Create a prefab from an existing node in the scene. The node remains in the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID to create prefab from" },
|
||||
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
|
||||
},
|
||||
required: ["uuid", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_instantiate",
|
||||
description: "Instantiate a prefab into the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prefabUuid: { type: "string", description: "Prefab asset UUID" },
|
||||
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
|
||||
},
|
||||
required: ["prefabUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_get_info",
|
||||
description: "Get information about a prefab asset (name, path, UUID).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Prefab asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_update",
|
||||
description: "Update (re-save) a prefab from its instance node in the scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID of the prefab instance in the scene" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_duplicate",
|
||||
description: "Duplicate a prefab asset to a new path.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Source prefab db:// path" },
|
||||
destination: { type: "string", description: "Destination db:// path" },
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_validate",
|
||||
description: "Validate a prefab for missing references or broken links.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Prefab asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_revert",
|
||||
description: "Revert a prefab instance node to its original prefab state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID of the prefab instance" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_create_and_replace",
|
||||
description: "Create a prefab from a node AND replace the original node with a prefab instance. This is the recommended way to extract a nested prefab — one command instead of create → delete → instantiate.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID to create prefab from" },
|
||||
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
|
||||
},
|
||||
required: ["uuid", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_open",
|
||||
description: "Open a prefab in editing mode. Equivalent to double-clicking the prefab in CocosCreator. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Prefab asset UUID" },
|
||||
path: { type: "string", description: "Prefab db:// path (alternative to uuid)" },
|
||||
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_close",
|
||||
description: "Save and close the current prefab editing mode, then return to the main scene. Returns an error if the current prefab is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
save: { type: "boolean", description: "Save prefab before closing (default true)" },
|
||||
sceneUuid: { type: "string", description: "Scene UUID to return to (default: project's start scene or first scene)" },
|
||||
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prefab_create_from_spec",
|
||||
description: "Create a prefab from a JSON spec in one call. Combines node_create_tree + component_auto_bind + prefab_create into a single operation. Spec extends node_create_tree format with optional autoBind field. Example: { name: 'MyPopup', components: ['cc.UITransform', 'MyPopupView'], autoBind: 'MyPopupView', children: [{ name: 'CloseButton', components: ['cc.Button'] }] }",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
|
||||
spec: { description: "Node tree specification with optional autoBind field (string for component type to auto-bind)" },
|
||||
autoBindMode: { type: "string", enum: ["fuzzy", "strict"], description: "Auto-bind matching mode (default: fuzzy)" },
|
||||
},
|
||||
required: ["path", "spec"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
switch (toolName) {
|
||||
case "prefab_list":
|
||||
return this.listPrefabs();
|
||||
case "prefab_create":
|
||||
return this.createPrefab(args.uuid, args.path);
|
||||
case "prefab_instantiate":
|
||||
return this.instantiatePrefab(args.prefabUuid, args.parent);
|
||||
case "prefab_get_info":
|
||||
return this.getPrefabInfo(args.uuid);
|
||||
case "prefab_update":
|
||||
return this.updatePrefab(args.uuid);
|
||||
case "prefab_duplicate": {
|
||||
try {
|
||||
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
|
||||
return ok({ success: true, source: args.source, destination: args.destination });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "prefab_validate": {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
|
||||
const deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid).catch(() => []);
|
||||
return ok({ success: true, uuid: args.uuid, info, dependencies: deps, valid: !!info });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "prefab_revert":
|
||||
return this.revertPrefab(args.uuid);
|
||||
case "prefab_create_and_replace":
|
||||
return this.createAndReplace(args.uuid, args.path);
|
||||
case "prefab_open":
|
||||
return this.openPrefab(args.uuid, args.path, !!args.force);
|
||||
case "prefab_close":
|
||||
return this.closePrefab(args.save !== false, args.sceneUuid, !!args.force);
|
||||
case "prefab_create_from_spec":
|
||||
return this.createFromSpec(args.path, parseMaybeJson(args.spec), args.autoBindMode ?? "fuzzy");
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async listPrefabs(): Promise<ToolResult> {
|
||||
try {
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/**/*.prefab",
|
||||
});
|
||||
const prefabs = (results || []).map((a: any) => ({
|
||||
uuid: a.uuid,
|
||||
path: a.path || a.url,
|
||||
name: a.name,
|
||||
}));
|
||||
return ok({ success: true, prefabs });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async createPrefab(nodeUuid: string, path: string): Promise<ToolResult> {
|
||||
try {
|
||||
// 既存Prefabがある場合は警告を返す(上書きダイアログでタイムアウトするため)
|
||||
const existing = await this.assetExists(path);
|
||||
if (existing) {
|
||||
return err(
|
||||
`Prefab already exists at "${path}". Use prefab_update instead to update an existing prefab. ` +
|
||||
`Workflow: 1) prefab_instantiate to place in scene, 2) modify properties, 3) prefab_update to save.`
|
||||
);
|
||||
}
|
||||
const result = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
|
||||
return ok({ success: true, nodeUuid, path, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async assetExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const pattern = path.replace(/\.prefab$/, "") + ".*";
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
|
||||
return (results || []).length > 0;
|
||||
} catch {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
|
||||
return !!info;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async instantiatePrefab(prefabUuid: string, parent?: string): Promise<ToolResult> {
|
||||
try {
|
||||
const nodeUuid = await Editor.Message.request("scene", "create-node", {
|
||||
parent: parent || undefined,
|
||||
assetUuid: prefabUuid,
|
||||
});
|
||||
|
||||
// Prefab 編集モード中の場合、ネスト Prefab 情報を記憶
|
||||
// prefab_update 時に JSON 後処理で asset/instance/nestedPrefabInstanceRoots を設定
|
||||
if (parent) {
|
||||
this._pendingNestedPrefabs.push({
|
||||
nodeUuid,
|
||||
prefabAssetUuid: prefabUuid,
|
||||
parentUuid: parent,
|
||||
});
|
||||
}
|
||||
|
||||
return ok({ success: true, nodeUuid, prefabUuid });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async updatePrefab(nodeUuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await (Editor.Message.request as any)("scene", "apply-prefab", nodeUuid);
|
||||
|
||||
// ネスト Prefab の JSON 後処理
|
||||
if (this._pendingNestedPrefabs.length > 0) {
|
||||
await this._fixNestedPrefabJson(nodeUuid);
|
||||
}
|
||||
|
||||
return ok({ success: true, nodeUuid, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* prefab_update 後に Prefab JSON を後処理して、ネスト Prefab 参照を正しく設定する.
|
||||
*/
|
||||
private async _fixNestedPrefabJson(_rootNodeUuid: string): Promise<void> {
|
||||
if (this._pendingNestedPrefabs.length === 0) return;
|
||||
|
||||
try {
|
||||
// シーンを保存して Prefab JSON を書き出す
|
||||
// 現在シーンが untitled (scene-2d) の場合、save-scene はダイアログを出すので
|
||||
// safeSaveScene でスキップする。untitled でシーンにインスタンスが居るケースは
|
||||
// 本来 prefab_open モードでのみ発生するので save が効く想定だが、
|
||||
// テスト等で直接呼ばれた場合の保護として skip する。
|
||||
const saved = await safeSaveScene();
|
||||
if (!saved) {
|
||||
console.warn(
|
||||
"[PrefabTools] _fixNestedPrefabJson: save-scene skipped (untitled scene). " +
|
||||
"Nested prefab JSON post-processing may be incomplete."
|
||||
);
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
// prefab_open で記憶した UUID からファイルパスを取得
|
||||
if (!this._currentPrefabUuid) return;
|
||||
|
||||
const prefabPath = await (Editor.Message.request as any)(
|
||||
"asset-db", "query-path", this._currentPrefabUuid
|
||||
);
|
||||
if (!prefabPath) return;
|
||||
if (!fs.existsSync(prefabPath)) return;
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(prefabPath, "utf-8"));
|
||||
|
||||
// 各ネスト Prefab エントリを処理
|
||||
for (const entry of this._pendingNestedPrefabs) {
|
||||
// fileId でノードを検索(nodeUuid はシーン内 UUID、Prefab JSON 内では fileId)
|
||||
let flpNodeIdx = -1;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i].__type__ === "cc.PrefabInfo" && data[i].fileId === entry.nodeUuid) {
|
||||
// この PrefabInfo を持つノードを探す
|
||||
for (let j = 0; j < data.length; j++) {
|
||||
if (data[j]._prefab?.__id__ === i) {
|
||||
flpNodeIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// fileId で見つからない場合、ノード名で検索
|
||||
if (flpNodeIdx < 0) {
|
||||
// Prefab アセット名を取得
|
||||
const assetInfo = await (Editor.Message.request as any)("asset-db", "query-asset-info", entry.prefabAssetUuid);
|
||||
const assetName = assetInfo?.name?.replace(".prefab", "") || "";
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i].__type__ === "cc.Node" && (data[i]._name === assetName || data[i]._name === undefined)) {
|
||||
const prefabIdx = data[i]._prefab?.__id__;
|
||||
if (prefabIdx != null && data[prefabIdx]?.asset?.__id__ === 0 && !data[prefabIdx]?.instance) {
|
||||
flpNodeIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flpNodeIdx < 0) continue;
|
||||
|
||||
const prefabInfoIdx = data[flpNodeIdx]._prefab?.__id__;
|
||||
if (prefabInfoIdx == null) continue;
|
||||
|
||||
// PrefabInfo を修正
|
||||
const prefabInfo = data[prefabInfoIdx];
|
||||
prefabInfo.root = { __id__: flpNodeIdx };
|
||||
prefabInfo.asset = {
|
||||
__uuid__: entry.prefabAssetUuid,
|
||||
__expectedType__: "cc.Prefab",
|
||||
};
|
||||
|
||||
// PrefabInstance を追加
|
||||
if (!prefabInfo.instance) {
|
||||
const instanceIdx = data.length;
|
||||
data.push({
|
||||
__type__: "cc.PrefabInstance",
|
||||
fileId: crypto.randomBytes(16).toString("base64").replace(/[+/=]/g, "").substring(0, 22),
|
||||
prefabRootNode: { __id__: 1 }, // Prefab 編集モードのルート
|
||||
mountedChildren: [],
|
||||
mountedComponents: [],
|
||||
propertyOverrides: [],
|
||||
removedComponents: [],
|
||||
});
|
||||
prefabInfo.instance = { __id__: instanceIdx };
|
||||
}
|
||||
|
||||
// 子ノード・コンポーネントをクリア(Prefab アセットから復元される)
|
||||
data[flpNodeIdx]._children = [];
|
||||
data[flpNodeIdx]._components = [];
|
||||
|
||||
// ルートの nestedPrefabInstanceRoots に追加
|
||||
const rootPrefabIdx = data[1]._prefab?.__id__;
|
||||
if (rootPrefabIdx != null) {
|
||||
const rootPrefab = data[rootPrefabIdx];
|
||||
if (!rootPrefab.nestedPrefabInstanceRoots) {
|
||||
rootPrefab.nestedPrefabInstanceRoots = [];
|
||||
}
|
||||
const alreadyNested = rootPrefab.nestedPrefabInstanceRoots.some(
|
||||
(r: any) => r?.__id__ === flpNodeIdx
|
||||
);
|
||||
if (!alreadyNested) {
|
||||
rootPrefab.nestedPrefabInstanceRoots.push({ __id__: flpNodeIdx });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(prefabPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
this._pendingNestedPrefabs = [];
|
||||
} catch (e: any) {
|
||||
console.warn("[PrefabTools] _fixNestedPrefabJson failed:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async revertPrefab(nodeUuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await (Editor.Message.request as any)("scene", "revert-prefab", nodeUuid);
|
||||
return ok({ success: true, nodeUuid, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getPrefabInfo(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
|
||||
return ok({ success: true, info });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async createAndReplace(nodeUuid: string, path: string): Promise<ToolResult> {
|
||||
try {
|
||||
// 1. Check if prefab already exists
|
||||
const existing = await this.assetExists(path);
|
||||
if (existing) {
|
||||
return err(
|
||||
`Prefab already exists at "${path}". Delete it first or use a different path.`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Get node info (parent, sibling index, transform) before creating prefab
|
||||
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
|
||||
if (!nodeInfo?.success) {
|
||||
return err(`Node ${nodeUuid} not found`);
|
||||
}
|
||||
const parentUuid = nodeInfo.data?.parent;
|
||||
|
||||
// 3. Create prefab from the node
|
||||
const prefabAssetUuid = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
|
||||
if (!prefabAssetUuid) {
|
||||
return err("create-prefab returned no asset UUID");
|
||||
}
|
||||
|
||||
// 4. Delete the original node
|
||||
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
|
||||
|
||||
// 5. Instantiate the prefab at the same parent
|
||||
const newNodeUuid = await Editor.Message.request("scene", "create-node", {
|
||||
parent: parentUuid || undefined,
|
||||
assetUuid: prefabAssetUuid,
|
||||
});
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
prefabAssetUuid,
|
||||
prefabPath: path,
|
||||
originalNodeUuid: nodeUuid,
|
||||
newInstanceUuid: newNodeUuid,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async openPrefab(uuid?: string, path?: string, force: boolean = false): Promise<ToolResult> {
|
||||
try {
|
||||
// Prefab を開くときも内部的にシーン切替が発生するので dirty untitled チェック
|
||||
await ensureSceneSafeToSwitch(force);
|
||||
|
||||
// Resolve UUID from path if needed
|
||||
let assetUuid = uuid;
|
||||
if (!assetUuid && path) {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
|
||||
assetUuid = info?.uuid;
|
||||
}
|
||||
if (!assetUuid) {
|
||||
return err("Either uuid or path is required");
|
||||
}
|
||||
|
||||
// Open prefab in editing mode (equivalent to double-click)
|
||||
await (Editor.Message.request as any)("asset-db", "open-asset", assetUuid);
|
||||
// Wait for prefab editing mode to initialize
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
this._currentPrefabUuid = assetUuid;
|
||||
this._pendingNestedPrefabs = [];
|
||||
|
||||
return ok({ success: true, uuid: assetUuid, mode: "prefab-edit" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async closePrefab(save: boolean, sceneUuid?: string, force: boolean = false): Promise<ToolResult> {
|
||||
try {
|
||||
// 1. Save prefab if requested
|
||||
// prefab edit モード中は "current scene" = 編集中 Prefab なので save-scene は Prefab を保存する。
|
||||
// ただし untitled フォールバックに当たった場合はダイアログ回避のためスキップする。
|
||||
if (save) {
|
||||
await safeSaveScene();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// 2. Determine which scene to return to
|
||||
let targetScene = sceneUuid;
|
||||
if (!targetScene) {
|
||||
// Try project's start scene
|
||||
try {
|
||||
targetScene = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Fallback to first scene
|
||||
if (!targetScene || targetScene === "current_scene") {
|
||||
const scenes = await Editor.Message.request("asset-db", "query-assets", {
|
||||
ccType: "cc.SceneAsset",
|
||||
pattern: "db://assets/**/*",
|
||||
});
|
||||
if (Array.isArray(scenes) && scenes.length > 0) {
|
||||
targetScene = scenes[0].uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Open the scene
|
||||
if (targetScene) {
|
||||
// prefab edit モードから戻る遷移もダイアログが出うる
|
||||
await ensureSceneSafeToSwitch(force);
|
||||
await (Editor.Message.request as any)("scene", "open-scene", targetScene);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
return ok({ success: true, returnedToScene: targetScene });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async createFromSpec(prefabPath: string, spec: any, autoBindMode: string): Promise<ToolResult> {
|
||||
try {
|
||||
// 1. 既存 Prefab チェック
|
||||
const existing = await this.assetExists(prefabPath);
|
||||
if (existing) {
|
||||
return err(
|
||||
`Prefab already exists at "${prefabPath}". Delete it first or use a different path.`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. シーンの最初の cc.Node UUID を取得(Scene UUID ではなく Canvas 等)
|
||||
const hier = await this.sceneScript("getSceneHierarchy", [false]);
|
||||
const hierarchy = hier?.hierarchy || [];
|
||||
const firstNode = hierarchy[0];
|
||||
if (!firstNode?.uuid) return err("Could not find a node in the current scene to use as parent");
|
||||
const parentUuid = firstNode.uuid;
|
||||
|
||||
// 3. ノードツリーを構築
|
||||
const autoBind = spec.autoBind;
|
||||
const cleanSpec = { ...spec };
|
||||
delete cleanSpec.autoBind;
|
||||
|
||||
const treeResult = await this.sceneScript("buildNodeTree", [parentUuid, cleanSpec]);
|
||||
if (!treeResult?.success) return err(treeResult?.error || "buildNodeTree failed");
|
||||
const nodeUuid = treeResult.data?.uuid;
|
||||
if (!nodeUuid) return err("buildNodeTree returned no root node UUID");
|
||||
|
||||
// 4. フォント・SpriteFrame を Editor API 経由で設定(アセット依存追跡のため)
|
||||
await this._applyDefaultAssets(nodeUuid);
|
||||
|
||||
// 5. autoBind 実行 (旧4)
|
||||
let autoBindResult: any = null;
|
||||
if (autoBind) {
|
||||
if (!this._componentTools) {
|
||||
return err("autoBind requires ComponentTools dependency (internal configuration error)");
|
||||
}
|
||||
const bindToolResult = await this._componentTools.execute("component_auto_bind", {
|
||||
uuid: nodeUuid,
|
||||
componentType: autoBind,
|
||||
force: false,
|
||||
mode: autoBindMode,
|
||||
});
|
||||
try {
|
||||
autoBindResult = JSON.parse(bindToolResult.content[0].text);
|
||||
} catch { autoBindResult = bindToolResult; }
|
||||
}
|
||||
|
||||
// 6. Prefab 作成
|
||||
const prefabAssetUuid = await (Editor.Message.request as any)(
|
||||
"scene", "create-prefab", nodeUuid, prefabPath
|
||||
);
|
||||
if (!prefabAssetUuid) {
|
||||
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
|
||||
return err("create-prefab returned no asset UUID");
|
||||
}
|
||||
|
||||
// 7. 一時ノードを削除
|
||||
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
prefabAssetUuid,
|
||||
path: prefabPath,
|
||||
nodeTree: treeResult.data,
|
||||
autoBind: autoBindResult,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* buildNodeTree で作成したノードツリーの Label にプロジェクトフォントを設定する。
|
||||
* Editor API (scene:set-property) 経由で設定することでアセット依存が正しく追跡される。
|
||||
*/
|
||||
private async _applyDefaultAssets(rootUuid: string): Promise<void> {
|
||||
// プロジェクトのデフォルトフォントを検索(resources/fonts/ 配下の TTFFont)
|
||||
let fontUuid: string | null = null;
|
||||
try {
|
||||
const assets = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/resources/fonts/**",
|
||||
ccType: "cc.TTFFont",
|
||||
});
|
||||
if (Array.isArray(assets) && assets.length > 0) {
|
||||
fontUuid = assets[0].uuid;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!fontUuid) return;
|
||||
|
||||
// 全子孫ノードを取得
|
||||
const descendants = await this.sceneScript("getAllDescendants", [rootUuid]);
|
||||
if (!descendants?.success) return;
|
||||
const allNodes = [{ uuid: rootUuid, name: "root" }, ...descendants.data];
|
||||
|
||||
for (const node of allNodes) {
|
||||
try {
|
||||
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", node.uuid);
|
||||
if (!nodeDump) continue;
|
||||
const comps = nodeDump.__comps__ || [];
|
||||
for (let i = 0; i < comps.length; i++) {
|
||||
const compType = comps[i].type || "";
|
||||
// Label にフォント設定
|
||||
if (compType === "cc.Label") {
|
||||
const fontDump = comps[i].value?.font;
|
||||
if (!fontDump?.value?.uuid) {
|
||||
await (Editor.Message.request as any)("scene", "set-property", {
|
||||
uuid: node.uuid,
|
||||
path: `__comps__.${i}.font`,
|
||||
dump: { type: "cc.TTFFont", value: { uuid: fontUuid } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* skip nodes that can't be queried */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: "cocos-creator-mcp",
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class PreferencesTools implements ToolCategory {
|
||||
readonly categoryName = "preferences";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "preferences_get",
|
||||
description: "Get a preference value by key.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name (e.g. 'general', 'builder', 'engine')" },
|
||||
key: { type: "string", description: "Preference key" },
|
||||
},
|
||||
required: ["protocol", "key"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preferences_set",
|
||||
description: "Set a preference value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name" },
|
||||
key: { type: "string", description: "Preference key" },
|
||||
value: { description: "Value to set" },
|
||||
},
|
||||
required: ["protocol", "key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preferences_get_all",
|
||||
description: "Get all preferences for a given protocol.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name (e.g. 'general')" },
|
||||
},
|
||||
required: ["protocol"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preferences_reset",
|
||||
description: "Reset a preference to its default value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Protocol name" },
|
||||
key: { type: "string", description: "Preference key to reset" },
|
||||
},
|
||||
required: ["protocol", "key"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "preferences_get": {
|
||||
const value = Editor.Profile.getConfig(args.protocol, args.key);
|
||||
return ok({ success: true, protocol: args.protocol, key: args.key, value });
|
||||
}
|
||||
case "preferences_set":
|
||||
Editor.Profile.setConfig(args.protocol, args.key, args.value);
|
||||
return ok({ success: true, protocol: args.protocol, key: args.key });
|
||||
case "preferences_get_all": {
|
||||
const config = Editor.Profile.getConfig(args.protocol);
|
||||
return ok({ success: true, protocol: args.protocol, config });
|
||||
}
|
||||
case "preferences_reset":
|
||||
Editor.Profile.removeConfig(args.protocol, args.key);
|
||||
return ok({ success: true, protocol: args.protocol, key: args.key });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class ProjectTools implements ToolCategory {
|
||||
readonly categoryName = "project";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "project_get_info",
|
||||
description: "Get project information (name, path, engine version).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_refresh_assets",
|
||||
description: "Refresh the asset database to detect file changes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_get_asset_info",
|
||||
description: "Get information about an asset by UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Asset UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_find_asset",
|
||||
description: "Find assets by name pattern (glob). Returns matching asset paths and UUIDs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Glob pattern (e.g. 'db://assets/**/*.ts', 'db://assets/**/Button*')" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_get_settings",
|
||||
description: "Get project settings for a given protocol.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", description: "Settings protocol (e.g. 'general', 'engine')" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_set_settings",
|
||||
description: "Set a project setting.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string" },
|
||||
key: { type: "string" },
|
||||
value: {},
|
||||
},
|
||||
required: ["protocol", "key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project_get_engine_info",
|
||||
description: "Get engine version and path information.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "project_query_scripts",
|
||||
description: "Query all script plugins in the project.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
switch (toolName) {
|
||||
case "project_get_info":
|
||||
return this.getInfo();
|
||||
case "project_refresh_assets":
|
||||
return this.refreshAssets();
|
||||
case "project_get_asset_info":
|
||||
return this.getAssetInfo(args.uuid);
|
||||
case "project_find_asset":
|
||||
return this.findAsset(args.pattern);
|
||||
case "project_get_settings": {
|
||||
try {
|
||||
const config = await (Editor.Message.request as any)("project", "query-config", args.protocol || "general");
|
||||
return ok({ success: true, config });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "project_set_settings": {
|
||||
try {
|
||||
await (Editor.Message.request as any)("project", "set-config", args.protocol, args.key, args.value);
|
||||
return ok({ success: true });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "project_get_engine_info": {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("engine", "query-info");
|
||||
return ok({ success: true, info });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
case "project_query_scripts": {
|
||||
try {
|
||||
const scripts = await (Editor.Message.request as any)("programming", "query-sorted-plugins");
|
||||
return ok({ success: true, scripts });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getInfo(): Promise<ToolResult> {
|
||||
try {
|
||||
return ok({
|
||||
success: true,
|
||||
name: Editor.Project.name,
|
||||
path: Editor.Project.path,
|
||||
tmpDir: Editor.Project.tmpDir,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshAssets(): Promise<ToolResult> {
|
||||
try {
|
||||
await Editor.Message.request("asset-db", "refresh-asset", "db://assets");
|
||||
return ok({ success: true });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getAssetInfo(uuid: string): Promise<ToolResult> {
|
||||
try {
|
||||
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
|
||||
return ok({ success: true, info });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async findAsset(pattern: string): Promise<ToolResult> {
|
||||
try {
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
|
||||
const assets = (results || []).map((a: any) => ({
|
||||
uuid: a.uuid,
|
||||
path: a.path || a.url,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
}));
|
||||
return ok({ success: true, assets });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class ReferenceImageTools implements ToolCategory {
|
||||
readonly categoryName = "referenceImage";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "refimage_add",
|
||||
description: "Add a reference image to the scene view.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Image file path or db:// path" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_remove",
|
||||
description: "Remove a reference image by index.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
index: { type: "number", description: "Image index" },
|
||||
},
|
||||
required: ["index"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_list",
|
||||
description: "List all reference images in the scene view.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_clear_all",
|
||||
description: "Remove all reference images.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_switch",
|
||||
description: "Switch to a specific reference image by index.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
index: { type: "number", description: "Image index to switch to" },
|
||||
},
|
||||
required: ["index"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_set_position",
|
||||
description: "Set the position of the current reference image.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
x: { type: "number" },
|
||||
y: { type: "number" },
|
||||
},
|
||||
required: ["x", "y"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_set_scale",
|
||||
description: "Set the scale of the current reference image.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scale: { type: "number", description: "Scale factor" },
|
||||
},
|
||||
required: ["scale"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_set_opacity",
|
||||
description: "Set the opacity of the current reference image.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
opacity: { type: "number", description: "Opacity (0-255)" },
|
||||
},
|
||||
required: ["opacity"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refimage_query_config",
|
||||
description: "Get the current reference image configuration.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_query_current",
|
||||
description: "Get info about the currently active reference image.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "refimage_refresh",
|
||||
description: "Refresh the reference image display.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "refimage_add":
|
||||
await (Editor.Message.request as any)("scene", "add-reference-image", args.path);
|
||||
return ok({ success: true, path: args.path });
|
||||
case "refimage_remove":
|
||||
await (Editor.Message.request as any)("scene", "remove-reference-image", args.index);
|
||||
return ok({ success: true, index: args.index });
|
||||
case "refimage_list": {
|
||||
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
|
||||
return ok({ success: true, config });
|
||||
}
|
||||
case "refimage_clear_all":
|
||||
await (Editor.Message.request as any)("scene", "clear-all-reference-images");
|
||||
return ok({ success: true });
|
||||
case "refimage_switch":
|
||||
await (Editor.Message.request as any)("scene", "switch-reference-image", args.index);
|
||||
return ok({ success: true, index: args.index });
|
||||
case "refimage_set_position":
|
||||
await (Editor.Message.request as any)("scene", "set-reference-image-position", args.x, args.y);
|
||||
return ok({ success: true, x: args.x, y: args.y });
|
||||
case "refimage_set_scale":
|
||||
await (Editor.Message.request as any)("scene", "set-reference-image-scale", args.scale);
|
||||
return ok({ success: true, scale: args.scale });
|
||||
case "refimage_set_opacity":
|
||||
await (Editor.Message.request as any)("scene", "set-reference-image-opacity", args.opacity);
|
||||
return ok({ success: true, opacity: args.opacity });
|
||||
case "refimage_query_config": {
|
||||
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
|
||||
return ok({ success: true, config });
|
||||
}
|
||||
case "refimage_query_current": {
|
||||
const current = await (Editor.Message.request as any)("scene", "query-current-reference-image").catch(() => null);
|
||||
return ok({ success: true, current });
|
||||
}
|
||||
case "refimage_refresh":
|
||||
await (Editor.Message.request as any)("scene", "refresh-reference-image");
|
||||
return ok({ success: true });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { ensureSceneSafeToSwitch } from "./scene-tools";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
export class SceneAdvancedTools implements ToolCategory {
|
||||
readonly categoryName = "sceneAdvanced";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "scene_execute_script",
|
||||
description: "Execute a scene script method by name with arguments.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
method: { type: "string", description: "Scene script method name" },
|
||||
args: { type: "array", description: "Arguments to pass", items: {} },
|
||||
},
|
||||
required: ["method"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_snapshot",
|
||||
description: "Take a snapshot of the current scene state (for undo).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_dirty",
|
||||
description: "Check if the current scene has unsaved changes.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_classes",
|
||||
description: "Query all available component classes in the scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_components",
|
||||
description: "Query available components for a given node.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_node_tree",
|
||||
description: "Query the raw node tree from the editor (alternative to scene_get_hierarchy).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_nodes_by_asset",
|
||||
description: "Find all nodes that reference a given asset UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
assetUuid: { type: "string", description: "Asset UUID to search for" },
|
||||
},
|
||||
required: ["assetUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_soft_reload",
|
||||
description: "Soft reload the current scene without losing state.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_reset_node_transform",
|
||||
description: "Reset a node's transform to default (position 0,0,0 / rotation 0,0,0 / scale 1,1,1).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_copy_node",
|
||||
description: "Copy a node to clipboard.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node UUID" },
|
||||
},
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_paste_node",
|
||||
description: "Paste node from clipboard under a parent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
parentUuid: { type: "string", description: "Parent node UUID" },
|
||||
},
|
||||
required: ["parentUuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_create",
|
||||
description: "Create a new empty 2D scene. If path is omitted, uses the editor's built-in new-scene command (may not work on CC 3.8.x). If path is specified, creates a .scene file via asset-db as a fallback. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Scene asset path (e.g. 'db://assets/scenes/NewScene.scene'). If omitted, uses editor's new-scene command." },
|
||||
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_cut_node",
|
||||
description: "Cut a node to clipboard (removes from scene).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Node UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_reset_property",
|
||||
description: "Reset a specific property on a node or component to its default value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node or component UUID" },
|
||||
path: { type: "string", description: "Property path (e.g. 'position', 'color')" },
|
||||
},
|
||||
required: ["uuid", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_reset_component",
|
||||
description: "Reset a component to its default state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Component UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_execute_component_method",
|
||||
description: "Call a method on a component at edit-time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Component UUID" },
|
||||
method: { type: "string", description: "Method name" },
|
||||
args: { type: "array", description: "Method arguments", items: {} },
|
||||
},
|
||||
required: ["uuid", "method"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_move_array_element",
|
||||
description: "Move an array element to a new position in a property.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node or component UUID" },
|
||||
path: { type: "string", description: "Array property path" },
|
||||
target: { type: "number", description: "Current index" },
|
||||
offset: { type: "number", description: "Move offset (+1 = down, -1 = up)" },
|
||||
},
|
||||
required: ["uuid", "path", "target", "offset"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_remove_array_element",
|
||||
description: "Remove an element from an array property by index.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuid: { type: "string", description: "Node or component UUID" },
|
||||
path: { type: "string", description: "Array property path" },
|
||||
index: { type: "number", description: "Index to remove" },
|
||||
},
|
||||
required: ["uuid", "path", "index"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_snapshot_abort",
|
||||
description: "Abort the current undo snapshot.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_ready",
|
||||
description: "Check if the scene is fully loaded and ready.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_query_component_has_script",
|
||||
description: "Check if a component has an associated script file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string", description: "Component class name" } },
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_restore_prefab",
|
||||
description: "Restore a prefab node to its original prefab state.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Node UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_begin_undo",
|
||||
description: "Begin recording undo operations.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_end_undo",
|
||||
description: "End undo recording and save the undo step.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_cancel_undo",
|
||||
description: "Cancel the current undo recording.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "scene_save_as",
|
||||
description: "Save the current scene to a new file (shows save dialog).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
// ── 以下、既存MCP未対応のEditor API ──
|
||||
{
|
||||
name: "scene_set_parent",
|
||||
description: "Reparent node(s) using the official Editor API (alternative to node_move).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuids: { type: "array", items: { type: "string" }, description: "Node UUID(s) to move" },
|
||||
parent: { type: "string", description: "New parent node UUID" },
|
||||
keepWorldTransform: { type: "boolean", description: "Keep world position (default false)" },
|
||||
},
|
||||
required: ["uuids", "parent"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_node",
|
||||
description: "Get a full property dump of a node (all serialized data).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Node UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_component",
|
||||
description: "Get a full property dump of a component by its UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { uuid: { type: "string", description: "Component UUID" } },
|
||||
required: ["uuid"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_query_scene_bounds",
|
||||
description: "Get the bounding rect of the current scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "scene_execute_script":
|
||||
return ok(await this.sceneScript(args.method, args.args || []));
|
||||
case "scene_snapshot":
|
||||
return ok(await (Editor.Message.request as any)("scene", "snapshot"));
|
||||
case "scene_query_dirty": {
|
||||
const dirty = await (Editor.Message.request as any)("scene", "query-dirty");
|
||||
return ok({ success: true, dirty });
|
||||
}
|
||||
case "scene_query_classes": {
|
||||
const classes = await (Editor.Message.request as any)("scene", "query-classes");
|
||||
return ok({ success: true, classes });
|
||||
}
|
||||
case "scene_query_components": {
|
||||
const comps = await (Editor.Message.request as any)("scene", "query-components", args.uuid);
|
||||
return ok({ success: true, components: comps });
|
||||
}
|
||||
case "scene_query_node_tree": {
|
||||
const tree = await Editor.Message.request("scene", "query-node-tree");
|
||||
return ok({ success: true, tree });
|
||||
}
|
||||
case "scene_query_nodes_by_asset": {
|
||||
const nodes = await (Editor.Message.request as any)("scene", "query-nodes-by-asset-uuid", args.assetUuid);
|
||||
return ok({ success: true, nodes });
|
||||
}
|
||||
case "scene_soft_reload":
|
||||
await (Editor.Message.request as any)("scene", "soft-reload");
|
||||
return ok({ success: true });
|
||||
case "scene_reset_node_transform":
|
||||
return await this.resetTransform(args.uuid);
|
||||
case "scene_copy_node":
|
||||
await (Editor.Message.request as any)("scene", "copy-node", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "scene_paste_node": {
|
||||
const result = await (Editor.Message.request as any)("scene", "paste-node", args.parentUuid);
|
||||
return ok({ success: true, result });
|
||||
}
|
||||
case "scene_create":
|
||||
return this.createScene(args.path, !!args.force);
|
||||
case "scene_cut_node":
|
||||
await (Editor.Message.request as any)("scene", "cut-node", args.uuid);
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "scene_reset_property":
|
||||
await (Editor.Message.request as any)("scene", "reset-property", { uuid: args.uuid, path: args.path });
|
||||
return ok({ success: true });
|
||||
case "scene_reset_component":
|
||||
await (Editor.Message.request as any)("scene", "reset-component", { uuid: args.uuid });
|
||||
return ok({ success: true });
|
||||
case "scene_execute_component_method": {
|
||||
const result = await (Editor.Message.request as any)("scene", "execute-component-method", { uuid: args.uuid, name: args.method, args: args.args || [] });
|
||||
return ok({ success: true, result });
|
||||
}
|
||||
case "scene_move_array_element":
|
||||
await (Editor.Message.request as any)("scene", "move-array-element", { uuid: args.uuid, path: args.path, target: args.target, offset: args.offset });
|
||||
return ok({ success: true });
|
||||
case "scene_remove_array_element":
|
||||
await (Editor.Message.request as any)("scene", "remove-array-element", { uuid: args.uuid, path: args.path, index: args.index });
|
||||
return ok({ success: true });
|
||||
case "scene_snapshot_abort":
|
||||
await (Editor.Message.request as any)("scene", "snapshot-abort");
|
||||
return ok({ success: true });
|
||||
case "scene_query_ready": {
|
||||
const ready = await (Editor.Message.request as any)("scene", "query-is-ready");
|
||||
return ok({ success: true, ready });
|
||||
}
|
||||
case "scene_query_component_has_script": {
|
||||
const hasScript = await (Editor.Message.request as any)("scene", "query-component-has-script", args.name);
|
||||
return ok({ success: true, name: args.name, hasScript });
|
||||
}
|
||||
case "scene_restore_prefab":
|
||||
await (Editor.Message.request as any)("scene", "restore-prefab", { uuid: args.uuid });
|
||||
return ok({ success: true, uuid: args.uuid });
|
||||
case "scene_begin_undo":
|
||||
await (Editor.Message.request as any)("scene", "begin-recording");
|
||||
return ok({ success: true });
|
||||
case "scene_end_undo":
|
||||
await (Editor.Message.request as any)("scene", "end-recording");
|
||||
return ok({ success: true });
|
||||
case "scene_cancel_undo":
|
||||
await (Editor.Message.request as any)("scene", "cancel-recording");
|
||||
return ok({ success: true });
|
||||
case "scene_save_as": {
|
||||
const result = await (Editor.Message.request as any)("scene", "save-as-scene");
|
||||
return ok({ success: true, result });
|
||||
}
|
||||
case "scene_set_parent":
|
||||
await (Editor.Message.request as any)("scene", "set-parent", {
|
||||
parent: args.parent,
|
||||
uuids: args.uuids,
|
||||
keepWorldTransform: args.keepWorldTransform || false,
|
||||
});
|
||||
return ok({ success: true });
|
||||
case "scene_query_node": {
|
||||
const dump = await (Editor.Message.request as any)("scene", "query-node", args.uuid);
|
||||
return ok({ success: true, node: dump });
|
||||
}
|
||||
case "scene_query_component": {
|
||||
const dump = await (Editor.Message.request as any)("scene", "query-component", args.uuid);
|
||||
return ok({ success: true, component: dump });
|
||||
}
|
||||
case "scene_query_scene_bounds": {
|
||||
const bounds = await (Editor.Message.request as any)("scene", "query-scene-bounds");
|
||||
return ok({ success: true, bounds });
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async resetTransform(uuid: string): Promise<ToolResult> {
|
||||
const result = await this.sceneScript("setNodeProperty", [uuid, "position", { x: 0, y: 0, z: 0 }]);
|
||||
await this.sceneScript("setNodeProperty", [uuid, "rotation", { x: 0, y: 0, z: 0 }]);
|
||||
await this.sceneScript("setNodeProperty", [uuid, "scale", { x: 1, y: 1, z: 1 }]);
|
||||
return ok({ success: true, uuid });
|
||||
}
|
||||
|
||||
private async createScene(path?: string, force: boolean = false): Promise<ToolResult> {
|
||||
// ダイアログ割り込み防止: 現在シーンが dirty かつ untitled の場合は事前エラー
|
||||
try { await ensureSceneSafeToSwitch(force); }
|
||||
catch (e: any) { return err(e.message || String(e)); }
|
||||
|
||||
// まず scene:new-scene を試行(path 未指定時のみ)
|
||||
if (!path) {
|
||||
try {
|
||||
await (Editor.Message.request as any)("scene", "new-scene");
|
||||
return ok({ success: true });
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || String(e);
|
||||
if (msg.includes("Message does not exist") || msg.includes("scene - new-scene")) {
|
||||
// CC 3.8.x → asset-db fallback にフォール
|
||||
const fallbackPath = await this.generateAvailableScenePath();
|
||||
return this.createSceneViaAssetDb(fallbackPath);
|
||||
}
|
||||
return err(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// path 指定 → asset-db fallback
|
||||
return this.createSceneViaAssetDb(path);
|
||||
}
|
||||
|
||||
private async generateAvailableScenePath(): Promise<string> {
|
||||
const basePath = "db://assets/NewScene.scene";
|
||||
try {
|
||||
const result = await (Editor.Message.request as any)("asset-db", "generate-available-url", basePath);
|
||||
if (result) return result;
|
||||
} catch { /* fallback */ }
|
||||
return `db://assets/NewScene_${Date.now()}.scene`;
|
||||
}
|
||||
|
||||
private async createSceneViaAssetDb(path: string): Promise<ToolResult> {
|
||||
try {
|
||||
if (!path.endsWith(".scene")) path += ".scene";
|
||||
|
||||
const sceneName = path.split("/").pop()!.replace(".scene", "");
|
||||
const uid = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const sid = () => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let s = "";
|
||||
for (let i = 0; i < 21; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
||||
return s;
|
||||
};
|
||||
|
||||
const sceneJson = this.buildMinimalSceneJson(sceneName, uid, sid);
|
||||
const content = JSON.stringify(sceneJson, null, 2);
|
||||
|
||||
await (Editor.Message.request as any)("asset-db", "create-asset", path, content);
|
||||
|
||||
// シーンを開く
|
||||
try {
|
||||
// ensureSceneSafeToSwitch は createScene 入口で既に通過済みなのでここでは再チェックしない
|
||||
const queryResult = await (Editor.Message.request as any)("asset-db", "query-uuid", path);
|
||||
if (queryResult) {
|
||||
await (Editor.Message.request as any)("scene", "open-scene", queryResult);
|
||||
}
|
||||
} catch { /* open failure is not critical */ }
|
||||
|
||||
return ok({ success: true, path, method: "asset-db-fallback" });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private buildMinimalSceneJson(name: string, uid: () => string, sid: () => string): any[] {
|
||||
const sceneId = uid();
|
||||
const canvasNodeId = sid();
|
||||
const cameraNodeId = sid();
|
||||
|
||||
const vec3 = (x: number, y: number, z: number) => ({ __type__: "cc.Vec3", x, y, z });
|
||||
const quat = () => ({ __type__: "cc.Quat", x: 0, y: 0, z: 0, w: 1 });
|
||||
|
||||
return [
|
||||
// [0] SceneAsset
|
||||
{
|
||||
__type__: "cc.SceneAsset",
|
||||
_name: name,
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_native: "",
|
||||
scene: { __id__: 1 },
|
||||
},
|
||||
// [1] Scene
|
||||
{
|
||||
__type__: "cc.Scene",
|
||||
_name: name,
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_parent: null,
|
||||
_children: [{ __id__: 2 }],
|
||||
_active: true,
|
||||
_components: [],
|
||||
_prefab: null,
|
||||
_lpos: vec3(0, 0, 0),
|
||||
_lrot: quat(),
|
||||
_lscale: vec3(1, 1, 1),
|
||||
_mobility: 0,
|
||||
_layer: 1073741824,
|
||||
_euler: vec3(0, 0, 0),
|
||||
autoReleaseAssets: false,
|
||||
_globals: { __id__: 10 },
|
||||
_id: sceneId,
|
||||
},
|
||||
// [2] Canvas node
|
||||
{
|
||||
__type__: "cc.Node",
|
||||
_name: "Canvas",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_parent: { __id__: 1 },
|
||||
_children: [{ __id__: 3 }],
|
||||
_active: true,
|
||||
_components: [{ __id__: 5 }, { __id__: 6 }, { __id__: 7 }],
|
||||
_prefab: null,
|
||||
_lpos: vec3(0, 0, 0),
|
||||
_lrot: quat(),
|
||||
_lscale: vec3(1, 1, 1),
|
||||
_mobility: 0,
|
||||
_layer: 33554432,
|
||||
_euler: vec3(0, 0, 0),
|
||||
_id: canvasNodeId,
|
||||
},
|
||||
// [3] Camera node
|
||||
{
|
||||
__type__: "cc.Node",
|
||||
_name: "Camera",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
_parent: { __id__: 2 },
|
||||
_children: [],
|
||||
_active: true,
|
||||
_components: [{ __id__: 4 }],
|
||||
_prefab: null,
|
||||
_lpos: vec3(0, 0, 1000),
|
||||
_lrot: quat(),
|
||||
_lscale: vec3(1, 1, 1),
|
||||
_mobility: 0,
|
||||
_layer: 1073741824,
|
||||
_euler: vec3(0, 0, 0),
|
||||
_id: cameraNodeId,
|
||||
},
|
||||
// [4] Camera component
|
||||
{
|
||||
__type__: "cc.Camera",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 3 },
|
||||
_enabled: true,
|
||||
_projection: 1,
|
||||
_priority: 0,
|
||||
_fov: 45,
|
||||
_fovAxis: 0,
|
||||
_orthoHeight: 10,
|
||||
_near: 1,
|
||||
_far: 2000,
|
||||
_color: { __type__: "cc.Color", r: 0, g: 0, b: 0, a: 255 },
|
||||
_depth: 1,
|
||||
_stencil: 0,
|
||||
_clearFlags: 6,
|
||||
_rect: { __type__: "cc.Rect", x: 0, y: 0, width: 1, height: 1 },
|
||||
_visibility: 1108344832,
|
||||
_id: "",
|
||||
},
|
||||
// [5] UITransform on Canvas
|
||||
{
|
||||
__type__: "cc.UITransform",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 2 },
|
||||
_enabled: true,
|
||||
_contentSize: { __type__: "cc.Size", width: 720, height: 1280 },
|
||||
_anchorPoint: { __type__: "cc.Vec2", x: 0.5, y: 0.5 },
|
||||
_id: "",
|
||||
},
|
||||
// [6] Canvas component
|
||||
{
|
||||
__type__: "cc.Canvas",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 2 },
|
||||
_enabled: true,
|
||||
_cameraComponent: { __id__: 4 },
|
||||
_alignCanvasWithScreen: true,
|
||||
_id: "",
|
||||
},
|
||||
// [7] Widget on Canvas (fullscreen)
|
||||
{
|
||||
__type__: "cc.Widget",
|
||||
_name: "",
|
||||
_objFlags: 0,
|
||||
__editorExtras__: {},
|
||||
node: { __id__: 2 },
|
||||
_enabled: true,
|
||||
_alignFlags: 15,
|
||||
_target: null,
|
||||
_left: 0,
|
||||
_right: 0,
|
||||
_top: 0,
|
||||
_bottom: 0,
|
||||
_isAbsLeft: true,
|
||||
_isAbsRight: true,
|
||||
_isAbsTop: true,
|
||||
_isAbsBottom: true,
|
||||
_originalWidth: 0,
|
||||
_originalHeight: 0,
|
||||
_id: "",
|
||||
},
|
||||
// [8] cc.PrefabInfo for scene
|
||||
// [9] (reserved)
|
||||
// [10] SceneGlobals
|
||||
{
|
||||
__type__: "cc.SceneGlobals",
|
||||
ambient: { __id__: 11 },
|
||||
shadows: { __id__: 12 },
|
||||
_skybox: { __id__: 13 },
|
||||
fog: { __id__: 14 },
|
||||
},
|
||||
// [11] AmbientInfo
|
||||
{ __type__: "cc.AmbientInfo", _skyLightingColor: { __type__: "cc.Vec4", x: 0.2, y: 0.2, z: 0.2, w: 1 } },
|
||||
// [12] ShadowsInfo
|
||||
{ __type__: "cc.ShadowsInfo" },
|
||||
// [13] SkyboxInfo
|
||||
{ __type__: "cc.SkyboxInfo" },
|
||||
// [14] FogInfo
|
||||
{ __type__: "cc.FogInfo" },
|
||||
];
|
||||
}
|
||||
|
||||
private async sceneScript(method: string, args: any[]): Promise<any> {
|
||||
return Editor.Message.request("scene", "execute-scene-script", {
|
||||
name: EXT_NAME,
|
||||
method,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
const EXT_NAME = "cocos-creator-mcp";
|
||||
|
||||
/** シーン名から「untitled (保存先なし)」かどうかを判定 */
|
||||
const UNTITLED_SCENE_NAMES = new Set(["scene-2d", "scene-3d", "Untitled", "NewScene", ""]);
|
||||
|
||||
/**
|
||||
* 現在のシーンの dirty 状態を取得。
|
||||
* CC バージョンによって query-dirty / query-is-dirty のどちらか(または両方)が存在するので両方試行。
|
||||
*/
|
||||
export async function queryCurrentSceneDirty(): Promise<boolean> {
|
||||
try {
|
||||
const r = await (Editor.Message.request as any)("scene", "query-dirty");
|
||||
return !!r;
|
||||
} catch { /* try alternate */ }
|
||||
try {
|
||||
const r = await (Editor.Message.request as any)("scene", "query-is-dirty");
|
||||
return !!r;
|
||||
} catch { /* assume clean if both fail */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在のシーン情報(名前・UUID)を取得。
|
||||
*/
|
||||
export async function queryCurrentSceneInfo(): Promise<{ sceneName: string; sceneUuid: string }> {
|
||||
try {
|
||||
const result: any = await Editor.Message.request(
|
||||
"scene",
|
||||
"execute-scene-script",
|
||||
{ name: EXT_NAME, method: "getSceneHierarchy", args: [false] }
|
||||
);
|
||||
return { sceneName: result?.sceneName || "", sceneUuid: result?.sceneUuid || "" };
|
||||
} catch {
|
||||
return { sceneName: "", sceneUuid: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在のシーンが untitled (scene-2d 等) かを判定。
|
||||
*/
|
||||
export async function isCurrentSceneUntitled(): Promise<boolean> {
|
||||
const { sceneName } = await queryCurrentSceneInfo();
|
||||
return UNTITLED_SCENE_NAMES.has(sceneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* save-scene をダイアログ安全に呼び出す。
|
||||
*
|
||||
* - 現在のシーンが untitled (scene-2d 等) → no-op(ダイアログ防止)
|
||||
* - それ以外 → save-scene を実行
|
||||
*
|
||||
* 任意の MCP ツール内部で save-scene を呼ぶ場合は必ずこれ経由にする。
|
||||
* 直接 `Editor.Message.request("scene", "save-scene")` を呼ぶと、
|
||||
* 現在シーンが untitled のときにモーダルダイアログが出て MCP が固まる。
|
||||
*
|
||||
* @returns 実際に保存を実行したか
|
||||
*/
|
||||
export async function safeSaveScene(): Promise<boolean> {
|
||||
if (await isCurrentSceneUntitled()) {
|
||||
console.warn(
|
||||
"[cocos-creator-mcp] safeSaveScene: current scene is untitled, " +
|
||||
"skipping save-scene to avoid modal dialog."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await (Editor.Message.request as any)("scene", "save-scene");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* シーン切替系ツール(scene_open/close/new など)の前処理。
|
||||
*
|
||||
* - clean → OK
|
||||
* - dirty + 保存先ありのシーン → save-scene で自動保存
|
||||
* - dirty + untitled シーン → ダイアログを自動応答("Don't Save")して変更を破棄
|
||||
*
|
||||
* 目的: "Save changes?" ダイアログで MCP がブロックされるのを防ぐ。
|
||||
*/
|
||||
export async function ensureSceneSafeToSwitch(force: boolean = false): Promise<void> {
|
||||
const isDirty = await queryCurrentSceneDirty();
|
||||
if (!isDirty) return;
|
||||
|
||||
const { sceneName } = await queryCurrentSceneInfo();
|
||||
const isUntitled = UNTITLED_SCENE_NAMES.has(sceneName);
|
||||
|
||||
if (isUntitled) {
|
||||
// untitled + dirty: ダイアログが出るとMCPがブロックされるので、
|
||||
// Electron dialog をパッチして「保存しない」を自動応答する。
|
||||
// パッチは次回のシーン切替(呼び出し元の scene_open 等)でダイアログが出る瞬間に効く。
|
||||
patchDialogForDiscard();
|
||||
console.warn(
|
||||
`[cocos-creator-mcp] ensureSceneSafeToSwitch: untitled scene "${sceneName}" ` +
|
||||
`is dirty — dialog will auto-respond "Don't Save" to avoid blocking MCP.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ここに来る時点で untitled ではない(上で判定済み)ので直接 save-scene OK
|
||||
await (Editor.Message.request as any)("scene", "save-scene");
|
||||
} catch (e: any) {
|
||||
if (force) {
|
||||
console.warn(
|
||||
`[cocos-creator-mcp] ensureSceneSafeToSwitch: save-scene failed but force=true — proceeding. ` +
|
||||
`Error: ${e.message || e}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to auto-save dirty scene "${sceneName}" before switch: ${e.message || e}. ` +
|
||||
`Save manually and retry, or pass force=true to bypass.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron の dialog.showMessageBox(Sync) を一時的にパッチして、
|
||||
* 次回のダイアログで「保存しない (Don't Save)」を自動選択する。
|
||||
*
|
||||
* CocosCreator の "Save changes?" ダイアログは通常:
|
||||
* buttons: ["Save", "Cancel", "Don't Save"] → index 2 = Don't Save
|
||||
* または:
|
||||
* buttons: ["Save", "Don't Save", "Cancel"] → index 1 = Don't Save
|
||||
*
|
||||
* ボタンテキストから "Don't Save" / "not" / "discard" を探し、
|
||||
* 見つからなければ最後のボタン(通常 Don't Save)を選択する。
|
||||
*/
|
||||
function patchDialogForDiscard(): void {
|
||||
try {
|
||||
const electron = require("electron");
|
||||
const dialog = electron.dialog;
|
||||
const origSync = dialog.showMessageBoxSync;
|
||||
const origAsync = dialog.showMessageBox;
|
||||
|
||||
function findDiscardIndex(buttons: string[]): number {
|
||||
if (!buttons || buttons.length === 0) return 0;
|
||||
const idx = buttons.findIndex((b: string) => {
|
||||
const lower = b.toLowerCase();
|
||||
return lower.includes("don't save") || lower.includes("not")
|
||||
|| lower.includes("discard") || lower.includes("保存しない");
|
||||
});
|
||||
return idx >= 0 ? idx : buttons.length - 1;
|
||||
}
|
||||
|
||||
// Sync version
|
||||
dialog.showMessageBoxSync = function (...args: any[]) {
|
||||
dialog.showMessageBoxSync = origSync; // 1回で復元
|
||||
const options = args.length > 1 ? args[1] : args[0];
|
||||
const buttons = options?.buttons || [];
|
||||
const result = findDiscardIndex(buttons);
|
||||
console.warn(`[cocos-creator-mcp] dialog auto-responded: button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Async version
|
||||
dialog.showMessageBox = function (...args: any[]) {
|
||||
dialog.showMessageBox = origAsync; // 1回で復元
|
||||
const options = args.length > 1 ? args[1] : args[0];
|
||||
const buttons = options?.buttons || [];
|
||||
const result = findDiscardIndex(buttons);
|
||||
console.warn(`[cocos-creator-mcp] dialog auto-responded (async): button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
|
||||
return Promise.resolve({ response: result, checkboxChecked: false });
|
||||
};
|
||||
|
||||
// 安全策: 5秒後に未使用なら復元(次のダイアログが出なかったケース)
|
||||
setTimeout(() => {
|
||||
if (dialog.showMessageBoxSync !== origSync) {
|
||||
dialog.showMessageBoxSync = origSync;
|
||||
}
|
||||
if (dialog.showMessageBox !== origAsync) {
|
||||
dialog.showMessageBox = origAsync;
|
||||
}
|
||||
}, 5000);
|
||||
} catch (e: any) {
|
||||
console.error(`[cocos-creator-mcp] patchDialogForDiscard failed: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class SceneTools implements ToolCategory {
|
||||
readonly categoryName = "scene";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "scene_get_hierarchy",
|
||||
description: "Get the node hierarchy of the current scene. Returns tree structure with uuid, name, active, and optionally components.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
includeComponents: {
|
||||
type: "boolean",
|
||||
description: "Include component info for each node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_open",
|
||||
description: "Open a scene by its asset UUID or database path (e.g. 'db://assets/scenes/Main.scene'). If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog that would block MCP. Pass force=true to bypass this guard.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scene: {
|
||||
type: "string",
|
||||
description: "Scene UUID or db:// path",
|
||||
},
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
|
||||
},
|
||||
},
|
||||
required: ["scene"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_save",
|
||||
description: "Save the currently open scene.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_get_list",
|
||||
description: "List all scene files in the project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_close",
|
||||
description: "Close the current scene. If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog. Pass force=true to bypass.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scene_get_current",
|
||||
description: "Get the name and UUID of the currently open scene.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
switch (toolName) {
|
||||
case "scene_get_hierarchy":
|
||||
return this.getHierarchy(args.includeComponents ?? false);
|
||||
case "scene_open":
|
||||
return this.openScene(args.scene, !!args.force);
|
||||
case "scene_save":
|
||||
return this.saveScene();
|
||||
case "scene_get_list":
|
||||
return this.getSceneList();
|
||||
case "scene_close":
|
||||
try {
|
||||
await ensureSceneSafeToSwitch(!!args.force);
|
||||
await (Editor.Message.request as any)("scene", "close-scene");
|
||||
return ok({ success: true });
|
||||
} catch (e: any) { return err(e.message || String(e)); }
|
||||
case "scene_get_current":
|
||||
return this.getHierarchy(false).then((r) => {
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
return ok({ success: true, sceneName: parsed.sceneName, sceneUuid: parsed.sceneUuid });
|
||||
}).catch((e) => err(String(e)));
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getHierarchy(includeComponents: boolean): Promise<ToolResult> {
|
||||
try {
|
||||
const result = await Editor.Message.request(
|
||||
"scene",
|
||||
"execute-scene-script",
|
||||
{
|
||||
name: EXT_NAME,
|
||||
method: "getSceneHierarchy",
|
||||
args: [includeComponents],
|
||||
}
|
||||
);
|
||||
return ok(result);
|
||||
} catch (e: any) {
|
||||
// Fallback: use query-node-tree
|
||||
try {
|
||||
const tree = await Editor.Message.request("scene", "query-node-tree");
|
||||
return ok({ success: true, hierarchy: tree });
|
||||
} catch (e2: any) {
|
||||
return err(e2.message || String(e2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async openScene(scene: string, force: boolean): Promise<ToolResult> {
|
||||
try {
|
||||
await ensureSceneSafeToSwitch(force);
|
||||
await Editor.Message.request("asset-db", "open-asset", scene);
|
||||
return ok({ success: true, scene });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async saveScene(): Promise<ToolResult> {
|
||||
try {
|
||||
// 現在のシーンが保存済みファイルか確認(新規シーンの場合はダイアログが出るのでスキップ)
|
||||
const scenes = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/**/*.scene",
|
||||
}).catch(() => []);
|
||||
|
||||
// シーン名を取得して既存シーンか判定
|
||||
const hierarchy = await Editor.Message.request(
|
||||
"scene", "execute-scene-script",
|
||||
{ name: "cocos-creator-mcp", method: "getSceneHierarchy", args: [false] }
|
||||
).catch(() => null);
|
||||
|
||||
const sceneName = hierarchy?.sceneName;
|
||||
const isNewScene = !sceneName || sceneName === "scene-2d" || sceneName === "Untitled";
|
||||
if (isNewScene) {
|
||||
return ok({ success: true, note: "New/untitled scene, skip save to avoid dialog" });
|
||||
}
|
||||
|
||||
// シーンがdirtyでない場合は保存不要
|
||||
const isDirty = await (Editor.Message.request as any)("scene", "query-is-dirty").catch(() => true);
|
||||
if (!isDirty) {
|
||||
return ok({ success: true, note: "Scene not dirty, skip save" });
|
||||
}
|
||||
|
||||
const result = await (Editor.Message.request as any)("scene", "save-scene", false);
|
||||
return ok({ success: true, result });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
private async getSceneList(): Promise<ToolResult> {
|
||||
try {
|
||||
const results = await Editor.Message.request("asset-db", "query-assets", {
|
||||
pattern: "db://assets/**/*.scene",
|
||||
});
|
||||
const scenes = (results || []).map((a: any) => ({
|
||||
uuid: a.uuid,
|
||||
path: a.path || a.url,
|
||||
name: a.name,
|
||||
}));
|
||||
return ok({ success: true, scenes });
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
|
||||
export class SceneViewTools implements ToolCategory {
|
||||
readonly categoryName = "sceneView";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "view_change_gizmo_tool",
|
||||
description: "Change the gizmo tool (move, rotate, scale, rect).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["move", "rotate", "scale", "rect"], description: "Gizmo tool name" },
|
||||
},
|
||||
required: ["tool"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_gizmo_tool",
|
||||
description: "Get the currently active gizmo tool.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_change_gizmo_pivot",
|
||||
description: "Change gizmo pivot mode (center or pivot).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pivot: { type: "string", enum: ["center", "pivot"], description: "Pivot mode" },
|
||||
},
|
||||
required: ["pivot"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_gizmo_pivot",
|
||||
description: "Get the current gizmo pivot mode.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_change_gizmo_coordinate",
|
||||
description: "Change gizmo coordinate system (local or global).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
coordinate: { type: "string", enum: ["local", "global"], description: "Coordinate system" },
|
||||
},
|
||||
required: ["coordinate"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_gizmo_coordinate",
|
||||
description: "Get the current gizmo coordinate system.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_change_mode_2d_3d",
|
||||
description: "Switch between 2D and 3D view mode.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["2d", "3d"], description: "View mode" },
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_mode_2d_3d",
|
||||
description: "Get the current 2D/3D view mode.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_set_grid_visible",
|
||||
description: "Show or hide the scene grid.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
visible: { type: "boolean", description: "Whether to show the grid" },
|
||||
},
|
||||
required: ["visible"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_grid_visible",
|
||||
description: "Check if the scene grid is visible.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_focus_on_node",
|
||||
description: "Focus the scene camera on specific node(s).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uuids: { type: "array", items: { type: "string" }, description: "Node UUIDs to focus on" },
|
||||
},
|
||||
required: ["uuids"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_get_status",
|
||||
description: "Get the current scene view status (gizmo tool, pivot, coordinate, grid, etc.).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_set_icon_gizmo_3d",
|
||||
description: "Toggle 3D icon gizmos on/off.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
required: ["enabled"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_icon_gizmo_3d",
|
||||
description: "Check if 3D icon gizmos are enabled.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_set_icon_gizmo_size",
|
||||
description: "Set the icon gizmo size.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { size: { type: "number" } },
|
||||
required: ["size"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view_query_icon_gizmo_size",
|
||||
description: "Get the current icon gizmo size.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_align_with_view",
|
||||
description: "Align the selected node with the current camera view.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_align_view_with_node",
|
||||
description: "Align the camera view with the selected node.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "view_reset",
|
||||
description: "Reset the scene view to default state.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "view_change_gizmo_tool":
|
||||
await (Editor.Message.request as any)("scene", "change-gizmo-tool", args.tool);
|
||||
return ok({ success: true, tool: args.tool });
|
||||
case "view_query_gizmo_tool": {
|
||||
const tool = await (Editor.Message.request as any)("scene", "query-gizmo-tool-name");
|
||||
return ok({ success: true, tool });
|
||||
}
|
||||
case "view_change_gizmo_pivot":
|
||||
await (Editor.Message.request as any)("scene", "change-gizmo-pivot", args.pivot);
|
||||
return ok({ success: true, pivot: args.pivot });
|
||||
case "view_query_gizmo_pivot": {
|
||||
const pivot = await (Editor.Message.request as any)("scene", "query-gizmo-pivot");
|
||||
return ok({ success: true, pivot });
|
||||
}
|
||||
case "view_change_gizmo_coordinate":
|
||||
await (Editor.Message.request as any)("scene", "change-gizmo-coordinate", args.coordinate);
|
||||
return ok({ success: true, coordinate: args.coordinate });
|
||||
case "view_query_gizmo_coordinate": {
|
||||
const coord = await (Editor.Message.request as any)("scene", "query-gizmo-coordinate");
|
||||
return ok({ success: true, coordinate: coord });
|
||||
}
|
||||
case "view_change_mode_2d_3d":
|
||||
await (Editor.Message.request as any)("scene", "change-view-mode-2d-3d", args.mode);
|
||||
return ok({ success: true, mode: args.mode });
|
||||
case "view_query_mode_2d_3d": {
|
||||
const mode = await (Editor.Message.request as any)("scene", "query-view-mode-2d-3d");
|
||||
return ok({ success: true, mode });
|
||||
}
|
||||
case "view_set_grid_visible":
|
||||
await (Editor.Message.request as any)("scene", "set-grid-visible", args.visible);
|
||||
return ok({ success: true, visible: args.visible });
|
||||
case "view_query_grid_visible": {
|
||||
const visible = await (Editor.Message.request as any)("scene", "query-grid-visible");
|
||||
return ok({ success: true, visible });
|
||||
}
|
||||
case "view_focus_on_node":
|
||||
await (Editor.Message.request as any)("scene", "focus-camera-on-nodes", args.uuids);
|
||||
return ok({ success: true, uuids: args.uuids });
|
||||
case "view_get_status": {
|
||||
const [tool, pivot, coord, mode, grid] = await Promise.all([
|
||||
(Editor.Message.request as any)("scene", "query-gizmo-tool-name").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-gizmo-pivot").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-gizmo-coordinate").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-view-mode-2d-3d").catch(() => null),
|
||||
(Editor.Message.request as any)("scene", "query-grid-visible").catch(() => null),
|
||||
]);
|
||||
return ok({ success: true, tool, pivot, coordinate: coord, mode, gridVisible: grid });
|
||||
}
|
||||
case "view_set_icon_gizmo_3d":
|
||||
await (Editor.Message.request as any)("scene", "set-icon-gizmo-3d", args.enabled);
|
||||
return ok({ success: true, enabled: args.enabled });
|
||||
case "view_query_icon_gizmo_3d": {
|
||||
const enabled = await (Editor.Message.request as any)("scene", "query-is-icon-gizmo-3d");
|
||||
return ok({ success: true, enabled });
|
||||
}
|
||||
case "view_set_icon_gizmo_size":
|
||||
await (Editor.Message.request as any)("scene", "set-icon-gizmo-size", args.size);
|
||||
return ok({ success: true, size: args.size });
|
||||
case "view_query_icon_gizmo_size": {
|
||||
const size = await (Editor.Message.request as any)("scene", "query-icon-gizmo-size");
|
||||
return ok({ success: true, size });
|
||||
}
|
||||
case "view_align_with_view":
|
||||
await (Editor.Message.request as any)("scene", "align-with-view");
|
||||
return ok({ success: true });
|
||||
case "view_align_view_with_node":
|
||||
await (Editor.Message.request as any)("scene", "align-view-with-node");
|
||||
return ok({ success: true });
|
||||
case "view_reset":
|
||||
await (Editor.Message.request as any)("scene", "reset-scene-view");
|
||||
return ok({ success: true });
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
|
||||
import { ok, err } from "../tool-base";
|
||||
import { BUILD_HASH } from "../mcp-server";
|
||||
|
||||
export class ServerTools implements ToolCategory {
|
||||
readonly categoryName = "server";
|
||||
|
||||
getTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "server_query_ip_list",
|
||||
description: "Get the list of IP addresses the editor server is listening on.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_query_port",
|
||||
description: "Get the port number of the editor's built-in server.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_get_status",
|
||||
description: "Get the editor server status (IP, port, connectivity).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_get_build_hash",
|
||||
description: "Get the build hash of the MCP server. The hash is derived from the code content, so identical code always produces the same hash.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_check_connectivity",
|
||||
description: "Check if the editor server is reachable.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_get_network_interfaces",
|
||||
description: "Get detailed network interface information.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "server_check_code_sync",
|
||||
description: "Check if the running MCP extension code matches the latest build. Compares the runtime BUILD_HASH with the hash of dist/ files. Returns whether extension reload or CC restart is needed.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case "server_query_ip_list": {
|
||||
const ips = await (Editor.Message.request as any)("server", "query-ip-list");
|
||||
return ok({ success: true, ips });
|
||||
}
|
||||
case "server_query_port": {
|
||||
const port = await (Editor.Message.request as any)("server", "query-port");
|
||||
return ok({ success: true, port });
|
||||
}
|
||||
case "server_get_status": {
|
||||
const [ips, port] = await Promise.all([
|
||||
(Editor.Message.request as any)("server", "query-ip-list").catch(() => []),
|
||||
(Editor.Message.request as any)("server", "query-port").catch(() => null),
|
||||
]);
|
||||
return ok({ success: true, ips, port, buildId: BUILD_HASH });
|
||||
}
|
||||
case "server_get_build_hash": {
|
||||
return ok({ success: true, buildHash: BUILD_HASH });
|
||||
}
|
||||
case "server_check_connectivity": {
|
||||
try {
|
||||
const port = await (Editor.Message.request as any)("server", "query-port");
|
||||
return ok({ success: true, reachable: true, port });
|
||||
} catch {
|
||||
return ok({ success: true, reachable: false });
|
||||
}
|
||||
}
|
||||
case "server_get_network_interfaces": {
|
||||
const os = require("os");
|
||||
const interfaces = os.networkInterfaces();
|
||||
return ok({ success: true, interfaces });
|
||||
}
|
||||
case "server_check_code_sync": {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const extDir = path.join(Editor.Project.path, "extensions", "cocos-creator-mcp", "dist");
|
||||
if (!fs.existsSync(extDir)) {
|
||||
return ok({ success: true, synced: false, note: "Extension dist/ not found", runtimeHash: BUILD_HASH });
|
||||
}
|
||||
const hash = crypto.createHash("sha256");
|
||||
const collectJs = (dir: string, prefix = ""): string[] => {
|
||||
let files: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) files = files.concat(collectJs(path.join(dir, entry.name), rel));
|
||||
else if (entry.name.endsWith(".js")) files.push(rel);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
for (const file of collectJs(extDir).sort()) {
|
||||
let content = fs.readFileSync(path.join(extDir, file), "utf8");
|
||||
// mcp-server.js の baked hash を postbuild 前と同じプレースホルダに逆置換
|
||||
// (postbuild は __BUILD_HASH__ が残った状態でハッシュ計算しているため、同じ入力にする)
|
||||
if (file === "mcp-server.js") {
|
||||
content = content.replace(/exports\.BUILD_HASH = "[a-f0-9]{12}"/, 'exports.BUILD_HASH = "__BUILD_HASH__"');
|
||||
}
|
||||
hash.update(content.replace(/__BUILD_HASH__/g, ""));
|
||||
}
|
||||
const diskHash = hash.digest("hex").substring(0, 12);
|
||||
const synced = diskHash === BUILD_HASH;
|
||||
return ok({
|
||||
success: true,
|
||||
synced,
|
||||
runtimeHash: BUILD_HASH,
|
||||
diskHash,
|
||||
action: synced ? "none" : "Extension reload or CC restart needed",
|
||||
});
|
||||
} catch (e: any) {
|
||||
return ok({ success: true, synced: false, error: e.message, runtimeHash: BUILD_HASH });
|
||||
}
|
||||
}
|
||||
default:
|
||||
return err(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return err(e.message || String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** MCP Tool definition (JSON Schema based) */
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Result returned from tool execution */
|
||||
export interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/** Interface that all tool categories must implement */
|
||||
export interface ToolCategory {
|
||||
readonly categoryName: string;
|
||||
getTools(): ToolDefinition[];
|
||||
execute(toolName: string, args: Record<string, any>): Promise<ToolResult>;
|
||||
}
|
||||
|
||||
/** JSON-RPC 2.0 request */
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number;
|
||||
method: string;
|
||||
params?: any;
|
||||
}
|
||||
|
||||
/** JSON-RPC 2.0 response */
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number | null;
|
||||
result?: any;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** Server configuration */
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
autoStart: boolean;
|
||||
autoArchiveRecordings: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ServerConfig = {
|
||||
port: 3000,
|
||||
autoStart: false,
|
||||
autoArchiveRecordings: false,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* MCP クライアントが JSON オブジェクトを文字列化して送信する問題への共通対策.
|
||||
* スキーマに type が未宣言のオブジェクト引数は文字列で届く場合がある.
|
||||
*/
|
||||
export function parseMaybeJson<T = any>(value: any): T {
|
||||
if (typeof value === "string") {
|
||||
try { return JSON.parse(value); } catch { /* string のまま */ }
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user