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:
+82
File diff suppressed because one or more lines are too long
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* McpConsoleCapture — ゲームプレビュー時のconsole.log/warn/errorをMCPサーバーに送信する。
|
||||
*
|
||||
* MCPサーバーの POST /log エンドポイントにバッチ送信する。
|
||||
* MCPサーバー未起動時は黙って無視(本番影響なし)。
|
||||
*
|
||||
* Usage:
|
||||
* import { initMcpConsoleCapture } from "./McpConsoleCapture";
|
||||
* initMcpConsoleCapture(); // or initMcpConsoleCapture({ mcpBaseUrl: "http://127.0.0.1:3001" });
|
||||
*/
|
||||
|
||||
export interface McpConsoleCaptureConfig {
|
||||
/** MCPサーバーのベースURL (default: "http://127.0.0.1:3000") */
|
||||
mcpBaseUrl?: string;
|
||||
/** バッチ送信間隔ms (default: 500) */
|
||||
flushInterval?: number;
|
||||
/** 1回の送信最大件数 (default: 50) */
|
||||
maxBatchSize?: number;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
let _buffer: LogEntry[] = [];
|
||||
let _timer: any = null;
|
||||
let _initialized = false;
|
||||
let _config: Required<McpConsoleCaptureConfig>;
|
||||
|
||||
function formatArgs(args: any[]): string {
|
||||
return args.map(a => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return `${a.message}\n${a.stack || ""}`;
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (_buffer.length === 0) return;
|
||||
const entries = _buffer.splice(0, _config.maxBatchSize);
|
||||
fetch(`${_config.mcpBaseUrl}/log`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(entries),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/** ゲーム起動時に1回呼ぶ。console.log/warn/errorをフックしてMCPに送信開始 */
|
||||
export function initMcpConsoleCapture(config?: McpConsoleCaptureConfig): void {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
_config = {
|
||||
mcpBaseUrl: config?.mcpBaseUrl ?? "http://127.0.0.1:3000",
|
||||
flushInterval: config?.flushInterval ?? 500,
|
||||
maxBatchSize: config?.maxBatchSize ?? 50,
|
||||
};
|
||||
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
function hook(level: LogEntry["level"], original: (...args: any[]) => void) {
|
||||
return function (...args: any[]) {
|
||||
original.apply(console, args);
|
||||
_buffer.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formatArgs(args),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
console.log = hook("log", originalLog);
|
||||
console.warn = hook("warn", originalWarn);
|
||||
console.error = hook("error", originalError);
|
||||
|
||||
_timer = setInterval(flush, _config.flushInterval);
|
||||
console.log("[McpConsoleCapture] initialized");
|
||||
}
|
||||
|
||||
/** 停止 */
|
||||
export function stopMcpConsoleCapture(): void {
|
||||
if (_timer) {
|
||||
clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
flush();
|
||||
_initialized = false;
|
||||
}
|
||||
+176
File diff suppressed because one or more lines are too long
+315
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* McpDebugClient — MCPサーバーとCocosCreatorプレビューの双方向通信
|
||||
*
|
||||
* MCPサーバーのコマンドキューをポーリングし、コマンドを実行して結果を返す。
|
||||
* MCPサーバー未起動時は黙って無視(本番影響なし)。
|
||||
*
|
||||
* ビルトインコマンド:
|
||||
* - screenshot: RenderTexture経由でゲーム画面キャプチャ
|
||||
* - click: ノード名指定でクリックイベント発火
|
||||
*
|
||||
* カスタムコマンドを追加可能(プロジェクト固有の機能):
|
||||
* initMcpDebugClient({
|
||||
* customCommands: {
|
||||
* state: () => ({ success: true, data: myDb.dump() }),
|
||||
* navigate: async (args) => { await myRouter.go(args.page); return { success: true }; },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
|
||||
import { director, Node, Button, Camera, RenderTexture, gfx } from "cc";
|
||||
|
||||
export interface McpDebugClientConfig {
|
||||
/** MCPサーバーのベースURL (default: "http://127.0.0.1:3000") */
|
||||
mcpBaseUrl?: string;
|
||||
/** ポーリング間隔ms (default: 500) */
|
||||
pollInterval?: number;
|
||||
/** プロジェクト固有のコマンドハンドラー */
|
||||
customCommands?: Record<string, (args: any) => any | Promise<any>>;
|
||||
}
|
||||
|
||||
interface McpCommand {
|
||||
id: string;
|
||||
type: string;
|
||||
args?: any;
|
||||
}
|
||||
|
||||
let _polling = false;
|
||||
let _timer: any = null;
|
||||
let _config: Required<Pick<McpDebugClientConfig, "mcpBaseUrl" | "pollInterval">> & { customCommands: Record<string, (args: any) => any | Promise<any>> };
|
||||
|
||||
async function pollCommand(): Promise<void> {
|
||||
if (_polling) return;
|
||||
_polling = true;
|
||||
try {
|
||||
const res = await fetch(`${_config.mcpBaseUrl}/game/command`);
|
||||
if (!res.ok) return;
|
||||
const cmd: McpCommand | null = await res.json();
|
||||
if (!cmd) return;
|
||||
|
||||
console.log(`[McpDebugClient] command: ${cmd.type}`, cmd.args || "");
|
||||
const result = await executeCommand(cmd);
|
||||
|
||||
await fetch(`${_config.mcpBaseUrl}/game/result`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(result),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// MCP server not running
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCommand(cmd: McpCommand): Promise<any> {
|
||||
try {
|
||||
// ビルトインコマンド
|
||||
switch (cmd.type) {
|
||||
case "screenshot":
|
||||
return { id: cmd.id, ...takeScreenshot() };
|
||||
case "click":
|
||||
return { id: cmd.id, ...clickNode(cmd.args?.name) };
|
||||
case "record_start":
|
||||
return { id: cmd.id, ...startRecording(cmd.args) };
|
||||
case "record_stop":
|
||||
return { id: cmd.id, ...(await stopRecording()) };
|
||||
}
|
||||
// カスタムコマンド
|
||||
const handler = _config.customCommands[cmd.type];
|
||||
if (handler) {
|
||||
const result = await handler(cmd.args);
|
||||
return { id: cmd.id, ...result };
|
||||
}
|
||||
return { id: cmd.id, success: false, error: `Unknown command: ${cmd.type}` };
|
||||
} catch (e: any) {
|
||||
return { id: cmd.id, success: false, error: e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ビルトインコマンド ───
|
||||
|
||||
function takeScreenshot(): { success: boolean; data?: any; error?: string } {
|
||||
try {
|
||||
const scene = director.getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
let camera: Camera | null = null;
|
||||
const findCamera = (node: Node) => {
|
||||
const cam = node.getComponent(Camera);
|
||||
if (cam && cam.enabled) { camera = cam; return; }
|
||||
for (const child of node.children) {
|
||||
if (camera) return;
|
||||
findCamera(child);
|
||||
}
|
||||
};
|
||||
findCamera(scene);
|
||||
if (!camera) return { success: false, error: "No camera found" };
|
||||
|
||||
const cam = camera as Camera;
|
||||
const width = Math.floor(cam.camera.width);
|
||||
const height = Math.floor(cam.camera.height);
|
||||
|
||||
const rt = new RenderTexture();
|
||||
rt.reset({ width, height });
|
||||
|
||||
const prevTarget = cam.targetTexture;
|
||||
cam.targetTexture = rt;
|
||||
director.root!.frameMove(0);
|
||||
cam.targetTexture = prevTarget;
|
||||
|
||||
const region = new gfx.BufferTextureCopy();
|
||||
region.texOffset.x = 0;
|
||||
region.texOffset.y = 0;
|
||||
region.texExtent.width = width;
|
||||
region.texExtent.height = height;
|
||||
|
||||
const buffer = new Uint8Array(width * height * 4);
|
||||
const gfxTex = rt.getGFXTexture()!;
|
||||
director.root!.device.copyTextureToBuffers(gfxTex, [buffer], [region]);
|
||||
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = width;
|
||||
cvs.height = height;
|
||||
const ctx = cvs.getContext("2d")!;
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const srcRow = (height - 1 - y) * width * 4;
|
||||
const dstRow = y * width * 4;
|
||||
for (let x = 0; x < width * 4; x++) {
|
||||
imageData.data[dstRow + x] = buffer[srcRow + x];
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = cvs.toDataURL("image/png");
|
||||
rt.destroy();
|
||||
|
||||
return { success: true, data: { dataUrl, width, height } };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function clickNode(name?: string): { success: boolean; error?: string } {
|
||||
if (!name) return { success: false, error: "name argument required" };
|
||||
const scene = director.getScene();
|
||||
if (!scene) return { success: false, error: "No active scene" };
|
||||
|
||||
const found = findNodeByName(scene, name);
|
||||
if (!found) return { success: false, error: `Node '${name}' not found` };
|
||||
|
||||
found.emit(Button.EventType.CLICK, found);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── 録画(MediaRecorder経由) ───
|
||||
|
||||
let _mediaRecorder: MediaRecorder | null = null;
|
||||
let _recordChunks: Blob[] = [];
|
||||
let _recordStream: MediaStream | null = null;
|
||||
let _recordId: string | null = null;
|
||||
|
||||
const QUALITY_PRESETS: Record<string, number> = {
|
||||
low: 0.15,
|
||||
medium: 0.25,
|
||||
high: 0.40,
|
||||
ultra: 0.60,
|
||||
};
|
||||
|
||||
function startRecording(args?: { fps?: number; videoBitsPerSecond?: number; quality?: string; coefficient?: number; format?: "webm" | "mp4" }): { success: boolean; error?: string; data?: any } {
|
||||
// 前回の録画状態が残っていたら強制クリア
|
||||
if (_mediaRecorder) {
|
||||
try { if (_mediaRecorder.state !== "inactive") _mediaRecorder.stop(); } catch {}
|
||||
_recordStream?.getTracks().forEach(t => { try { t.stop(); } catch {} });
|
||||
_mediaRecorder = null;
|
||||
_recordStream = null;
|
||||
_recordChunks = [];
|
||||
_recordId = null;
|
||||
}
|
||||
try {
|
||||
// GameView canvas取得
|
||||
const canvas = document.getElementById("GameCanvas") as HTMLCanvasElement
|
||||
|| document.querySelector("canvas") as HTMLCanvasElement;
|
||||
if (!canvas) return { success: false, error: "canvas not found" };
|
||||
|
||||
const fps = args?.fps ?? 30;
|
||||
const quality = args?.quality ?? "medium";
|
||||
const coef = args?.coefficient ?? QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.medium;
|
||||
const autoBps = Math.round(canvas.width * canvas.height * fps * coef);
|
||||
const bps = args?.videoBitsPerSecond ?? autoBps;
|
||||
const format = args?.format ?? "mp4";
|
||||
_recordStream = canvas.captureStream(fps);
|
||||
_recordChunks = [];
|
||||
_recordId = `rec_${new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").substring(0, 19)}`;
|
||||
|
||||
// 指定フォーマット優先、非対応ならwebmにfallback
|
||||
const mp4Candidates = [
|
||||
"video/mp4;codecs=h264",
|
||||
"video/mp4;codecs=avc1.42E01E",
|
||||
"video/mp4",
|
||||
];
|
||||
const webmCandidates = [
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm",
|
||||
];
|
||||
const candidates = format === "mp4"
|
||||
? [...mp4Candidates, ...webmCandidates]
|
||||
: webmCandidates;
|
||||
let mimeType = "";
|
||||
for (const c of candidates) {
|
||||
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(c)) {
|
||||
mimeType = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mimeType) return { success: false, error: "No supported MediaRecorder mimeType" };
|
||||
|
||||
_mediaRecorder = new MediaRecorder(_recordStream, { mimeType, videoBitsPerSecond: bps });
|
||||
_mediaRecorder.ondataavailable = (e: BlobEvent) => {
|
||||
if (e.data.size > 0) _recordChunks.push(e.data);
|
||||
};
|
||||
_mediaRecorder.start();
|
||||
return { success: true, data: { id: _recordId, mimeType, fps, videoBitsPerSecond: bps, quality, canvasWidth: canvas.width, canvasHeight: canvas.height } };
|
||||
} catch (e: any) {
|
||||
_mediaRecorder = null;
|
||||
_recordStream = null;
|
||||
return { success: false, error: e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording(): Promise<{ success: boolean; error?: string; data?: any }> {
|
||||
if (!_mediaRecorder || !_recordId) return { success: false, error: "not recording" };
|
||||
const id = _recordId;
|
||||
const mimeType = _mediaRecorder.mimeType;
|
||||
return new Promise((resolve) => {
|
||||
_mediaRecorder!.onstop = async () => {
|
||||
try {
|
||||
const blob = new Blob(_recordChunks, { type: mimeType });
|
||||
_recordStream?.getTracks().forEach(t => t.stop());
|
||||
_mediaRecorder = null;
|
||||
_recordStream = null;
|
||||
_recordChunks = [];
|
||||
_recordId = null;
|
||||
|
||||
// Blob → base64 → POST /game/recording
|
||||
const base64 = await blobToBase64(blob);
|
||||
const uploadRes = await fetch(`${_config.mcpBaseUrl}/game/recording`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, base64, mimeType }),
|
||||
});
|
||||
const uploadData = await uploadRes.json();
|
||||
resolve({ success: true, data: { id, size: blob.size, ...uploadData } });
|
||||
} catch (e: any) {
|
||||
resolve({ success: false, error: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
_mediaRecorder!.stop();
|
||||
});
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
// "data:video/webm;base64,XXX" → XXX だけ抽出
|
||||
const commaIdx = result.indexOf(",");
|
||||
resolve(commaIdx >= 0 ? result.substring(commaIdx + 1) : result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function findNodeByName(root: Node, name: string): Node | null {
|
||||
if (root.name === name) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findNodeByName(child, name);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 初期化・停止 ───
|
||||
|
||||
export function initMcpDebugClient(config?: McpDebugClientConfig): void {
|
||||
if (_timer) return;
|
||||
_config = {
|
||||
mcpBaseUrl: config?.mcpBaseUrl ?? "http://127.0.0.1:3000",
|
||||
pollInterval: config?.pollInterval ?? 500,
|
||||
customCommands: config?.customCommands ?? {},
|
||||
};
|
||||
_timer = setInterval(pollCommand, _config.pollInterval);
|
||||
console.log("[McpDebugClient] initialized");
|
||||
}
|
||||
|
||||
export function stopMcpDebugClient(): void {
|
||||
if (_timer) {
|
||||
clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cocos-creator-mcp stdio bridge
|
||||
*
|
||||
* stdin で line-delimited JSON-RPC を読み取り、HTTP MCP エンドポイントに転送する。
|
||||
* レスポンスは stdout に 1 行ずつ書き出す。
|
||||
*
|
||||
* Claude Code VSCode 拡張の HTTP MCP OAuth 強制バグ (#26917 等) を回避するための
|
||||
* 代替トランスポート。ユーザーは .mcp.json で stdio 型として設定する:
|
||||
*
|
||||
* {
|
||||
* "mcpServers": {
|
||||
* "cocos-creator-mcp": {
|
||||
* "command": "node",
|
||||
* "args": [
|
||||
* "C:/path/to/cocos-creator-mcp/client/stdio-bridge.js"
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Optional 環境変数:
|
||||
* COCOS_MCP_URL - MCP サーバーの URL (default: http://127.0.0.1:3000/mcp)
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
const readline = require("readline");
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:3000/mcp";
|
||||
const serverUrl = new URL(process.env.COCOS_MCP_URL || DEFAULT_URL);
|
||||
|
||||
let sessionId = null;
|
||||
|
||||
function log(...args) {
|
||||
// デバッグログは stderr に出す(stdout は JSON-RPC 応答専用)
|
||||
console.error("[stdio-bridge]", ...args);
|
||||
}
|
||||
|
||||
function postJsonRpc(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
};
|
||||
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: serverUrl.hostname,
|
||||
port: serverUrl.port || 80,
|
||||
path: serverUrl.pathname,
|
||||
method: "POST",
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
const responseSession = res.headers["mcp-session-id"];
|
||||
if (responseSession && !sessionId) {
|
||||
sessionId = responseSession;
|
||||
log("session established:", sessionId);
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
resolve({ status: res.statusCode || 0, body: text });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (err) => reject(err));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function writeResponse(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + "\n");
|
||||
}
|
||||
|
||||
async function handleLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
writeResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: null,
|
||||
error: { code: -32700, message: "Parse error", data: e.message },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { status, body } = await postJsonRpc(trimmed);
|
||||
// Notifications (no id) may yield empty body
|
||||
if (!body) {
|
||||
if (request.id !== undefined) {
|
||||
writeResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
error: { code: -32603, message: "Empty response from server", data: { status } },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Pass through server response (single JSON object per line)
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
writeResponse(parsed);
|
||||
} catch {
|
||||
// サーバーが SSE 等で返した場合のフォールバック
|
||||
process.stdout.write(body + (body.endsWith("\n") ? "" : "\n"));
|
||||
}
|
||||
} catch (err) {
|
||||
writeResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id !== undefined ? request.id : null,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: "HTTP bridge error",
|
||||
data: err && err.message ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let inflight = 0;
|
||||
let stdinClosed = false;
|
||||
|
||||
function maybeExit() {
|
||||
if (stdinClosed && inflight === 0) {
|
||||
log("stdin closed, no inflight, exiting");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
log(`starting, target=${serverUrl.href}`);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
inflight++;
|
||||
handleLine(line)
|
||||
.catch((e) => log("handler error:", e))
|
||||
.finally(() => {
|
||||
inflight--;
|
||||
maybeExit();
|
||||
});
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
stdinClosed = true;
|
||||
maybeExit();
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user