From 498dd3675c104308e946a8e076ff18d06b4daf33 Mon Sep 17 00:00:00 2001 From: liukk222 Date: Sat, 1 Aug 2026 23:04:57 +0800 Subject: [PATCH] feat(store): add Godot MCP plugins --- Plugin/GodotBridge/GodotBridge.js | 382 ++++++++++++++++++ Plugin/GodotBridge/GodotBridge.zip | Bin 0 -> 8958 bytes Plugin/GodotBridge/README.md | 93 +++++ Plugin/GodotBridge/config.env.example | 11 + Plugin/GodotBridge/package.json | 9 + Plugin/GodotBridge/plugin-manifest.json | 45 +++ .../GodotEventReceiver/GodotEventReceiver.js | 169 ++++++++ .../GodotEventReceiver/GodotEventReceiver.zip | Bin 0 -> 3910 bytes Plugin/GodotEventReceiver/config.env.example | 11 + .../GodotEventReceiver/plugin-manifest.json | 36 ++ plugins.json | 22 +- 11 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 Plugin/GodotBridge/GodotBridge.js create mode 100644 Plugin/GodotBridge/GodotBridge.zip create mode 100644 Plugin/GodotBridge/README.md create mode 100644 Plugin/GodotBridge/config.env.example create mode 100644 Plugin/GodotBridge/package.json create mode 100644 Plugin/GodotBridge/plugin-manifest.json create mode 100644 Plugin/GodotEventReceiver/GodotEventReceiver.js create mode 100644 Plugin/GodotEventReceiver/GodotEventReceiver.zip create mode 100644 Plugin/GodotEventReceiver/config.env.example create mode 100644 Plugin/GodotEventReceiver/plugin-manifest.json diff --git a/Plugin/GodotBridge/GodotBridge.js b/Plugin/GodotBridge/GodotBridge.js new file mode 100644 index 0000000..747456a --- /dev/null +++ b/Plugin/GodotBridge/GodotBridge.js @@ -0,0 +1,382 @@ +#!/usr/bin/env node +'use strict'; + +/* + * GodotBridge - VCP 插件 + * 作为标准 MCP Client 连接 Godot MCP Native 插件, + * 通过渐进式工具发现让 VCP Agent 操作 Godot 项目。 + * + * 协议: stdio (VCP) <-> Streamable HTTP (Godot MCP) + * 依赖: 仅 Node.js 内置模块 (http/https) + */ + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +// ---------- 配置读取 ---------- +const CONFIG = { + url: process.env.GODOT_MCP_URL || 'http://127.0.0.1:9080/mcp', + token: process.env.GODOT_MCP_TOKEN || '', + timeout: parseInt(process.env.REQUEST_TIMEOUT_MS || '30000', 10), + protocolVersion: process.env.MCP_PROTOCOL_VERSION || '2025-06-18', +}; + +// ---------- 工具领域分类 ---------- +// 依据 Godot MCP Native 的命名前缀归类,供 list_domains / discover_tools 使用。 +function classifyDomain(toolName) { + const n = String(toolName || '').toLowerCase(); + if (/(^|-)runtime(-|$)|runtime-/.test(n)) return 'runtime'; + if (/node/.test(n)) return 'node'; + if (/script|symbol/.test(n)) return 'script'; + if (/scene/.test(n)) return 'scene'; + if (/debug|breakpoint|stack|profiler|debugger/.test(n)) return 'debug'; + if (/editor|inspector|screenshot|export/.test(n)) return 'editor'; + if (/project|resource|input-action|autoload|test|uid|dependency|tileset/.test(n)) return 'project'; + return 'other'; +} + +// ---------- MCP over Streamable HTTP 客户端 ---------- +let _requestId = 0; +function nextId() { return ++_requestId; } + +// 会话状态:Streamable HTTP 首次 initialize 后可能返回 Mcp-Session-Id +let _sessionId = null; + +function postJsonRpc(method, params) { + return new Promise((resolve, reject) => { + let target; + try { + target = new URL(CONFIG.url); + } catch (e) { + return reject(new Error(`无效的 GODOT_MCP_URL: ${CONFIG.url}`)); + } + + const payload = JSON.stringify({ + jsonrpc: '2.0', + id: nextId(), + method, + params: params || {}, + }); + + const headers = { + 'Content-Type': 'application/json', + // Streamable HTTP 要求客户端声明可接受 json 与 event-stream + 'Accept': 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(payload), + }; + if (CONFIG.token) headers['Authorization'] = `Bearer ${CONFIG.token}`; + if (_sessionId) headers['Mcp-Session-Id'] = _sessionId; + + const isHttps = target.protocol === 'https:'; + const lib = isHttps ? https : http; + const options = { + hostname: target.hostname, + port: target.port || (isHttps ? 443 : 80), + path: target.pathname + target.search, + method: 'POST', + headers, + timeout: CONFIG.timeout, + }; + + const req = lib.request(options, (res) => { + // 捕获会话 ID + const sid = res.headers['mcp-session-id']; + if (sid) _sessionId = sid; + + let raw = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { raw += chunk; }); + res.on('end', () => { + if (res.statusCode >= 400) { + return reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`)); + } + const parsed = parseMcpResponse(raw, res.headers['content-type'] || ''); + if (parsed == null) { + return reject(new Error(`无法解析 MCP 响应: ${raw.slice(0, 500)}`)); + } + if (parsed.error) { + return reject(new Error(`MCP 错误 ${parsed.error.code}: ${parsed.error.message}`)); + } + resolve(parsed.result); + }); + }); + + req.on('timeout', () => { req.destroy(new Error(`请求超时 (${CONFIG.timeout}ms)`)); }); + req.on('error', (err) => { + if (err.code === 'ECONNREFUSED') { + return reject(new Error(`无法连接 Godot MCP (${CONFIG.url})。请确认 Godot 编辑器已启动并启用了 MCP Native 插件的 HTTP 模式。`)); + } + reject(err); + }); + + req.write(payload); + req.end(); + }); +} + +// Streamable HTTP 可能返回 application/json 或 text/event-stream(SSE) +function parseMcpResponse(raw, contentType) { + const text = String(raw || '').trim(); + if (!text) return null; + + if (contentType.includes('text/event-stream') || text.startsWith('event:') || text.includes('\ndata:') || text.startsWith('data:')) { + // 逐行提取 data: 负载,取最后一个可解析为带 id 的 JSON-RPC 对象 + const dataLines = text.split(/\r?\n/).filter((l) => l.startsWith('data:')); + for (let i = dataLines.length - 1; i >= 0; i--) { + const jsonStr = dataLines[i].slice(5).trim(); + try { + const obj = JSON.parse(jsonStr); + if (obj && (obj.result !== undefined || obj.error !== undefined)) return obj; + } catch (e) { /* 跳过非 JSON 行 */ } + } + return null; + } + + try { + return JSON.parse(text); + } catch (e) { + return null; + } +} + +// initialize 握手 —— 每个进程生命周期执行一次 +let _initialized = false; +async function ensureInitialized() { + if (_initialized) return; + await postJsonRpc('initialize', { + protocolVersion: CONFIG.protocolVersion, + capabilities: {}, + clientInfo: { name: 'VCP-GodotBridge', version: '1.0.0' }, + }); + // 通知服务器初始化完成(notification 无需等待结果,容错处理) + try { + await postJsonRpc('notifications/initialized', {}); + } catch (e) { /* 部分实现不要求此通知,忽略 */ } + _initialized = true; +} + +// 拉取全部工具(支持分页 cursor) +async function fetchAllTools() { + await ensureInitialized(); + const tools = []; + let cursor; + do { + const params = cursor ? { cursor } : {}; + const result = await postJsonRpc('tools/list', params); + if (result && Array.isArray(result.tools)) tools.push(...result.tools); + cursor = result ? result.nextCursor : undefined; + } while (cursor); + return tools; +} + +// ---------- 子命令处理 ---------- +async function handleStatus() { + const tools = await fetchAllTools(); + const domains = {}; + for (const t of tools) { + const d = classifyDomain(t.name); + domains[d] = (domains[d] || 0) + 1; + } + return { + connected: true, + endpoint: CONFIG.url, + sessionId: _sessionId || null, + protocolVersion: CONFIG.protocolVersion, + totalTools: tools.length, + domains, + }; +} + +async function handleListDomains() { + const tools = await fetchAllTools(); + const domains = {}; + for (const t of tools) { + const d = classifyDomain(t.name); + domains[d] = (domains[d] || 0) + 1; + } + return { + totalTools: tools.length, + domains, + hint: '使用 discover_tools 并传入 domain 查看某领域的工具清单。', + }; +} + +async function handleDiscoverTools(args) { + const domain = (args.domain || '').trim().toLowerCase(); + if (!domain) throw new Error('discover_tools 需要参数 domain。可先用 list_domains 查看可用领域。'); + const tools = await fetchAllTools(); + const filtered = tools + .filter((t) => classifyDomain(t.name) === domain) + .map((t) => ({ + name: t.name, + description: firstLine(t.description), + })); + if (filtered.length === 0) { + return { domain, count: 0, tools: [], hint: '该领域无工具或领域名有误,请用 list_domains 核对。' }; + } + return { + domain, + count: filtered.length, + tools: filtered, + hint: '使用 get_tool_schema 传入 tool 查看某工具完整参数。', + }; +} + +async function handleGetToolSchema(args) { + const toolName = (args.tool || '').trim(); + if (!toolName) throw new Error('get_tool_schema 需要参数 tool。'); + const tools = await fetchAllTools(); + const found = tools.find((t) => t.name === toolName); + if (!found) { + const suggestions = tools + .filter((t) => t.name.includes(toolName) || toolName.includes(t.name)) + .slice(0, 5) + .map((t) => t.name); + throw new Error(`未找到工具 "${toolName}"。${suggestions.length ? '相近工具: ' + suggestions.join(', ') : '请用 discover_tools 核对名称。'}`); + } + return { + name: found.name, + description: found.description, + inputSchema: found.inputSchema || {}, + }; +} + +async function handleCallTool(args) { + const toolName = (args.tool || '').trim(); + if (!toolName) throw new Error('call_tool 需要参数 tool。'); + + let toolArgs = args.arguments; + if (typeof toolArgs === 'string') { + const s = toolArgs.trim(); + if (s === '' ) { + toolArgs = {}; + } else { + try { + toolArgs = JSON.parse(s); + } catch (e) { + throw new Error(`arguments 不是合法 JSON: ${e.message}`); + } + } + } + if (toolArgs == null) toolArgs = {}; + if (typeof toolArgs !== 'object' || Array.isArray(toolArgs)) { + throw new Error('arguments 必须是一个 JSON 对象。'); + } + + await ensureInitialized(); + const result = await postJsonRpc('tools/call', { + name: toolName, + arguments: toolArgs, + }); + + return adaptToolResult(toolName, result); +} + +// ---------- 结果适配 ---------- +function adaptToolResult(toolName, result) { + if (!result) return { tool: toolName, content: '(空响应)' }; + + const out = { tool: toolName }; + if (result.isError) out.isError = true; + + if (Array.isArray(result.content)) { + const texts = []; + const media = []; + for (const item of result.content) { + if (!item || typeof item !== 'object') continue; + if (item.type === 'text') { + texts.push(item.text); + } else if (item.type === 'image') { + // 避免把大段 base64 直接塞进文本结果,只保留元信息 + media.push({ type: 'image', mimeType: item.mimeType || 'image/png', bytes: item.data ? item.data.length : 0 }); + } else if (item.type === 'resource') { + texts.push(`[resource] ${JSON.stringify(item.resource || {}).slice(0, 400)}`); + } else { + texts.push(JSON.stringify(item).slice(0, 400)); + } + } + if (texts.length) out.text = texts.join('\n'); + if (media.length) out.media = media; + } + + if (result.structuredContent !== undefined) { + out.structured = result.structuredContent; + } + + if (out.text === undefined && out.structured === undefined && out.media === undefined) { + out.raw = result; + } + return out; +} + +function firstLine(str) { + if (!str) return ''; + const s = String(str).trim(); + const idx = s.indexOf('\n'); + return idx === -1 ? s : s.slice(0, idx); +} + +// ---------- 输入读取与分发 ---------- +function readStdin() { + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (c) => { data += c; }); + process.stdin.on('end', () => resolve(data)); + // 若无 stdin(超时保护) + setTimeout(() => { if (!data) resolve(''); }, CONFIG.timeout + 5000); + }); +} + +function parseInput(raw) { + const text = String(raw || '').trim(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch (e) { + // 兼容极简 key=value 情况(一般 VCP 传 JSON,此处兜底) + return {}; + } +} + +async function main() { + const raw = await readStdin(); + const input = parseInput(raw); + const command = (input.command || 'status').trim(); + + try { + let data; + switch (command) { + case 'status': + data = await handleStatus(); + break; + case 'list_domains': + data = await handleListDomains(); + break; + case 'discover_tools': + data = await handleDiscoverTools(input); + break; + case 'get_tool_schema': + data = await handleGetToolSchema(input); + break; + case 'call_tool': + data = await handleCallTool(input); + break; + default: + throw new Error(`未知 command "${command}"。可用: status | list_domains | discover_tools | get_tool_schema | call_tool`); + } + process.stdout.write(JSON.stringify({ + status: 'success', + result: JSON.stringify(data, null, 2), + })); + } catch (err) { + process.stdout.write(JSON.stringify({ + status: 'error', + error: err && err.message ? err.message : String(err), + })); + process.exitCode = 1; + } +} + +main(); \ No newline at end of file diff --git a/Plugin/GodotBridge/GodotBridge.zip b/Plugin/GodotBridge/GodotBridge.zip new file mode 100644 index 0000000000000000000000000000000000000000..d01b7e92d8bf4fe0a5e3997c0cb4a67f322ff1a4 GIT binary patch literal 8958 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 YD}>aQ5D@>f4dKsl{F8tHs{cIwAH$-Qj{pDw literal 0 HcmV?d00001 diff --git a/Plugin/GodotBridge/README.md b/Plugin/GodotBridge/README.md new file mode 100644 index 0000000..aa6ab0b --- /dev/null +++ b/Plugin/GodotBridge/README.md @@ -0,0 +1,93 @@ +# GodotBridge — VCP × Godot MCP 桥接插件 + +作为标准 **MCP Client** 连接 [Godot MCP Native](https://github.com/yurineko73/Godot-MCP-Native) 插件,让 VCP Agent 通过渐进式工具发现来读写 Godot 项目的场景、脚本、节点、资源,并控制编辑器与运行时调试。 + +## 架构定位 + +``` +VCP Agent ──stdio──> GodotBridge (本插件) ──Streamable HTTP──> Godot MCP Native ──> Godot Editor / Runtime +``` + +- **本插件只做协议适配**:MCP 初始化、会话管理、工具发现、参数校验、结果规范化。 +- **不修改 Godot 侧**:Godot MCP Native 的 155 个工具原样复用。 +- **零第三方依赖**:仅使用 Node.js 内置 `http/https` 实现 MCP 客户端。 + +## 前置条件 + +1. 目标 Godot 项目已复制 `addons/godot_mcp` 并在「项目设置 → 插件」中启用。 +2. MCP 面板配置为 HTTP 模式,默认端口 `9080`。 +3. Godot 编辑器保持运行(桥接依赖其在线)。 + +## 配置 + +复制 `config.env.example` 为 `config.env`: + +| 变量 | 说明 | 默认 | +|---|---|---| +| `GODOT_MCP_URL` | Godot MCP 的 HTTP 端点 | `http://127.0.0.1:9080/mcp` | +| `GODOT_MCP_TOKEN` | 可选,Godot 启用认证时的 Bearer Token | 空 | +| `REQUEST_TIMEOUT_MS` | 单次请求超时 | `30000` | +| `MCP_PROTOCOL_VERSION` | MCP 协议版本 | `2025-06-18` | + +> Token 不要提交到版本控制。 + +## 子命令(渐进式发现) + +为节约上下文,不把 155 个工具 Schema 一次性塞给模型,而是分层查询: + +| command | 作用 | 参数 | +|---|---|---| +| `status` | 检查连接与工具总数 | — | +| `list_domains` | 列出所有工具领域及数量 | — | +| `discover_tools` | 列出某领域的工具清单 | `domain` | +| `get_tool_schema` | 查看单个工具完整参数 | `tool` | +| `call_tool` | 调用任意 Godot MCP 工具 | `tool`, `arguments` | + +领域分类:`node` / `script` / `scene` / `editor` / `debug` / `runtime` / `project` / `other`。 + +## 调用示例 + +检查连接: +``` +<<<[TOOL_REQUEST]>>> +tool_name:「始」GodotBridge「末」, +command:「始」status「末」 +<<<[END_TOOL_REQUEST]>>> +``` + +查看某领域工具: +``` +<<<[TOOL_REQUEST]>>> +tool_name:「始」GodotBridge「末」, +command:「始」discover_tools「末」, +domain:「始」scene「末」 +<<<[END_TOOL_REQUEST]>>> +``` + +调用工具(获取场景树): +``` +<<<[TOOL_REQUEST]>>> +tool_name:「始」GodotBridge「末」, +command:「始」call_tool「末」, +tool:「始」get-scene-tree「末」, +arguments:「始」{}「末」 +<<<[END_TOOL_REQUEST]>>> +``` + +## 实现要点 + +- **Streamable HTTP**:`Accept: application/json, text/event-stream`,自动解析 JSON 与 SSE 两种响应。 +- **会话保持**:捕获 `Mcp-Session-Id` 响应头并在后续请求携带。 +- **初始化握手**:进程内首次调用时执行 `initialize` + `notifications/initialized`。 +- **分页拉取**:`tools/list` 跟随 `nextCursor` 直到取完。 +- **结果适配**:文本合并、图片仅保留元信息(避免大段 base64 污染文本)、`structuredContent` 单独返回。 + +## 后续扩展(可选) + +当前为「请求—响应」型 MVP。若需 Godot **主动推送**运行时事件(崩溃、断点命中、场景切换),需: +- Godot 侧新增 `addons/godot_mcp/vcp_bridge/`(事件通道); +- 本插件新增独立事件接收器,与请求通道隔离。 + +## 作者 + +ATRI —— 我是高性能的嘛! \ No newline at end of file diff --git a/Plugin/GodotBridge/config.env.example b/Plugin/GodotBridge/config.env.example new file mode 100644 index 0000000..93309a7 --- /dev/null +++ b/Plugin/GodotBridge/config.env.example @@ -0,0 +1,11 @@ +# Godot MCP Native 的 Streamable HTTP 端点 +GODOT_MCP_URL=http://127.0.0.1:9080/mcp + +# 可选:Godot 侧启用认证时填写 Bearer Token(不要提交到版本控制) +GODOT_MCP_TOKEN= + +# 单次请求超时(毫秒) +REQUEST_TIMEOUT_MS=30000 + +# MCP 协议版本 +MCP_PROTOCOL_VERSION=2025-06-18 \ No newline at end of file diff --git a/Plugin/GodotBridge/package.json b/Plugin/GodotBridge/package.json new file mode 100644 index 0000000..8615d1e --- /dev/null +++ b/Plugin/GodotBridge/package.json @@ -0,0 +1,9 @@ +{ + "name": "godot-bridge", + "version": "1.0.0", + "description": "VCP GodotBridge - MCP client plugin for Godot MCP Native", + "main": "GodotBridge.js", + "author": "ATRI", + "license": "MIT", + "dependencies": {} +} \ No newline at end of file diff --git a/Plugin/GodotBridge/plugin-manifest.json b/Plugin/GodotBridge/plugin-manifest.json new file mode 100644 index 0000000..4f5e1ca --- /dev/null +++ b/Plugin/GodotBridge/plugin-manifest.json @@ -0,0 +1,45 @@ +{ + "manifestVersion": "1.0.0", + "name": "GodotBridge", + "version": "1.0.0", + "displayName": "Godot MCP 桥接器", + "description": "作为标准 MCP Client 连接 Godot MCP Native 插件(默认 http://127.0.0.1:9080/mcp),让 VCP Agent 通过渐进式工具发现来读写 Godot 项目的场景、脚本、节点、资源,并控制编辑器与运行时调试。", + "author": "ATRI", + "pluginType": "synchronous", + "entryPoint": { + "type": "nodejs", + "command": "node GodotBridge.js" + }, + "communication": { + "protocol": "stdio", + "timeout": 60000 + }, + "configSchema": { + "GODOT_MCP_URL": { + "type": "string", + "description": "Godot MCP Native 的 Streamable HTTP 端点,默认 http://127.0.0.1:9080/mcp。" + }, + "GODOT_MCP_TOKEN": { + "type": "string", + "description": "可选。Godot 侧启用 auth_enabled 时的 Bearer Token。" + }, + "REQUEST_TIMEOUT_MS": { + "type": "integer", + "description": "单次 MCP 请求超时(毫秒),默认 30000。" + }, + "MCP_PROTOCOL_VERSION": { + "type": "string", + "description": "MCP 协议版本号,默认 2025-06-18。" + } + }, + "capabilities": { + "systemPromptPlaceholders": [], + "invocationCommands": [ + { + "commandIdentifier": "GodotBridge", + "description": "通过 command 字段选择子命令来操作 Godot 项目。为节约上下文,请遵循渐进式发现:先 list_domains 看领域,再 discover_tools 看某领域工具,需要时 get_tool_schema 查参数,最后 call_tool 执行。\n\n【子命令】\n- status: 检查与 Godot MCP 的连接及可用工具总数。无需其它参数。\n- list_domains: 列出所有工具领域(node/script/scene/editor/debug/runtime/project/other)及各自工具数量。\n- discover_tools: 列出指定领域的工具名与简介。参数 domain (字符串, 必需)。\n- get_tool_schema: 查看单个工具的完整参数 Schema。参数 tool (字符串, 必需)。\n- call_tool: 调用任意 Godot MCP 工具。参数 tool (字符串, 必需)、arguments (JSON 字符串或对象, 可选)。\n\n【示例:查看场景树】\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」GodotBridge「末」,\ncommand:「始」call_tool「末」,\ntool:「始」get-scene-tree「末」,\narguments:「始」{}「末」\n<<<[END_TOOL_REQUEST]>>>\n\n【示例:修改节点属性】\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」GodotBridge「末」,\ncommand:「始」call_tool「末」,\ntool:「始」update-node-property「末」,\narguments:「始」{\"node_path\":\"/root/Player\",\"property\":\"speed\",\"value\":300}「末」\n<<<[END_TOOL_REQUEST]>>>" + } + ], + "responseFormatToAI": "Godot MCP 返回:\n{result}" + } +} \ No newline at end of file diff --git a/Plugin/GodotEventReceiver/GodotEventReceiver.js b/Plugin/GodotEventReceiver/GodotEventReceiver.js new file mode 100644 index 0000000..1eb9551 --- /dev/null +++ b/Plugin/GodotEventReceiver/GodotEventReceiver.js @@ -0,0 +1,169 @@ +'use strict'; + +/* + * GodotEventReceiver - VCP service 插件(二期反向通道) + * 作为 WebSocket 服务端,接收 Godot 侧 godot_vcp_bridge 主动推送的事件。 + * + * 方向: Godot (WS client) --> 本插件 (WS server) + * 与一期 GodotBridge (VCP 请求 -> Godot 响应) 方向相反、通道隔离。 + * + * 依赖: ws(VCPToolBox 已内置) + */ + +const path = require('path'); +const fs = require('fs').promises; +const WebSocket = require('ws'); + +const LOG_DIR_NAME = 'log'; +const LOG_FILE_NAME = 'godot_events.txt'; + +let wss = null; +let logFilePath = null; +let pluginConfig = {}; +let broadcastVCPInfoFunction = null; // 由 server.js 注入(同 VCPLog 范式) + +function debugLog(...args) { + if (pluginConfig && pluginConfig.DebugMode) console.log('[GodotEventReceiver]', ...args); +} + +async function ensureLogFile(basePath) { + const dir = path.join(basePath, LOG_DIR_NAME); + try { + await fs.mkdir(dir, { recursive: true }); + logFilePath = path.join(dir, LOG_FILE_NAME); + await fs.access(logFilePath).catch(async () => { + await fs.writeFile(logFilePath, `Godot event log initialized at ${new Date().toISOString()}\n`, 'utf-8'); + }); + } catch (e) { + console.error('[GodotEventReceiver] 无法创建日志目录/文件:', e.message); + } +} + +async function writeLog(line) { + if (!logFilePath) return; + try { + await fs.appendFile(logFilePath, `${new Date().toISOString()} - ${line}\n`, 'utf-8'); + } catch (e) { + console.error('[GodotEventReceiver] 写日志失败:', e.message); + } +} + +// 校验连接令牌:优先 Authorization: Bearer,其次 ?token= +function checkAuth(req) { + const token = String(pluginConfig.GODOT_EVENT_TOKEN || '').trim(); + if (!token) return true; // 未配置令牌则放行 + const auth = req.headers['authorization'] || ''; + const m = auth.match(/^Bearer\s+(.+)$/i); + if (m && m[1].trim() === token) return true; + try { + const url = new URL(req.url, 'http://localhost'); + if (url.searchParams.get('token') === token) return true; + } catch (e) { /* ignore */ } + return false; +} + +// 统一事件信封 -> VCP 前端广播 payload +function toVcpInfo(evt) { + return { + type: 'godot_event', + source: 'GodotEventReceiver', + event_type: evt.event_type || 'unknown', + project_id: evt.project_id || null, + session_id: evt.session_id || null, + timestamp: evt.timestamp || new Date().toISOString(), + payload: evt.payload !== undefined ? evt.payload : evt, + }; +} + +function handleMessage(raw, ws) { + let evt; + try { + evt = JSON.parse(raw.toString()); + } catch (e) { + debugLog('收到非 JSON 消息,忽略:', raw.toString().slice(0, 120)); + return; + } + + // 心跳 + if (evt.event_type === 'ping' || evt.type === 'ping') { + try { ws.send(JSON.stringify({ event_type: 'pong', timestamp: new Date().toISOString() })); } catch (e) { /* ignore */ } + return; + } + + writeLog(`[${evt.event_type || 'unknown'}] ${JSON.stringify(evt)}`); + debugLog('事件:', evt.event_type, '| project:', evt.project_id); + + const shouldBroadcast = pluginConfig.GODOT_EVENT_BROADCAST !== false + && String(pluginConfig.GODOT_EVENT_BROADCAST) !== 'false'; + if (shouldBroadcast && broadcastVCPInfoFunction) { + try { broadcastVCPInfoFunction(toVcpInfo(evt)); } + catch (e) { debugLog('广播失败:', e.message); } + } +} + +function startWebSocketServer() { + const port = parseInt(pluginConfig.GODOT_EVENT_PORT || '5090', 10); + + wss = new WebSocket.Server({ port }, () => { + console.log(`[GodotEventReceiver] 监听 Godot 事件于 ws://127.0.0.1:${port}`); + }); + + wss.on('connection', (ws, req) => { + if (!checkAuth(req)) { + debugLog('连接令牌校验失败,拒绝'); + try { ws.close(4001, 'unauthorized'); } catch (e) { /* ignore */ } + return; + } + const peer = req.socket.remoteAddress; + console.log(`[GodotEventReceiver] Godot 客户端已连接: ${peer}`); + writeLog(`connection opened from ${peer}`); + + ws.on('message', (data) => handleMessage(data, ws)); + ws.on('close', () => { + debugLog('Godot 客户端断开:', peer); + writeLog(`connection closed from ${peer}`); + }); + ws.on('error', (err) => debugLog('连接错误:', err.message)); + + // 握手确认 + try { ws.send(JSON.stringify({ event_type: 'welcome', server: 'VCP-GodotEventReceiver', timestamp: new Date().toISOString() })); } catch (e) { /* ignore */ } + }); + + wss.on('error', (err) => { + console.error('[GodotEventReceiver] WebSocket 服务器错误:', err.message); + }); +} + +// ---------- VCP 插件生命周期 ---------- +function initialize(config) { + pluginConfig = config || {}; + const basePath = path.join(pluginConfig.PROJECT_BASE_PATH || __dirname, 'Plugin', 'GodotEventReceiver'); + // 若未提供 PROJECT_BASE_PATH,退回到插件自身目录 + ensureLogFile(pluginConfig.PROJECT_BASE_PATH ? basePath : __dirname); + startWebSocketServer(); + console.log(`[GodotEventReceiver] 初始化完成。端口: ${pluginConfig.GODOT_EVENT_PORT || 5090},令牌校验: ${pluginConfig.GODOT_EVENT_TOKEN ? '开启' : '关闭'}`); +} + +// server.js 注入中央 WebSocketServer 广播函数(同 VCPLog) +function setBroadcastFunctions(broadcastInfoFunc) { + broadcastVCPInfoFunction = broadcastInfoFunc; + debugLog('broadcastVCPInfoFunction 已注入'); +} + +async function shutdown() { + debugLog('关闭中...'); + if (wss) { + for (const client of wss.clients) { + try { client.close(1001, 'server shutdown'); } catch (e) { /* ignore */ } + } + wss.close(); + wss = null; + } + await writeLog('GodotEventReceiver shutdown.'); +} + +module.exports = { + initialize, + shutdown, + setBroadcastFunctions, +}; \ No newline at end of file diff --git a/Plugin/GodotEventReceiver/GodotEventReceiver.zip b/Plugin/GodotEventReceiver/GodotEventReceiver.zip new file mode 100644 index 0000000000000000000000000000000000000000..5cae6c68311f7558bbd1bc18d5ec3d632f12266c GIT binary patch literal 3910 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 KR~01s`}QBu)