// ============================================================================ // yyka-runtime-inspector —— 游戏侧桥接脚本(常驻节点版,供用户安装进项目 assets) // // 特性: // · 常驻节点:onLoad 时 director.addPersistRootNode(this.node),跨场景存活。 // · 自动连接:脚本被加载后,监听场景启动并自动创建常驻节点,无需手动摆放节点 // 也无需手动调用 connect()。若想手动控制,可放置节点后设 autoConnect=false。 // · 自动重连:连接断开后按 reconnectDelay 自动重连。 // // 用法(两种任选其一): // A. 推荐:让安装按钮把本脚本装进 assets 后,脚本会在首次场景自动创建常驻节点。 // B. 手动:新建空节点,挂上 RuntimeBridge 组件,配置 host/port/autoConnect。 // 局域网真机时把 host 设为电脑 IP。 // // 端口默认 33000,与插件主进程内嵌的 WS 服务一致。 // ============================================================================ import { _decorator, Asset, assetManager, AudioSource, Canvas, Color, Collider, Collider2D, Component, Director, director, EventTouch, game, Graphics, Layers, Material, Node, PhysicsSystem, profiler, Quat, Rect, RenderRoot2D, RigidBody, RigidBody2D, Size, sys, SystemEvent, Touch, UITransform, Vec2, Vec3, Vec4, } from 'cc'; const { ccclass, property, menu } = _decorator; const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 33000; /** 固定连接令牌:与插件静态令牌一致,不做动态生成、不依赖组件属性。 */ const TOKEN = 'www.yyka.cn'; /** 自动引导时创建的常驻节点名 */ const PERSIST_NODE_NAME = '__yyka_runtime_bridge__'; /** 桥接脚本版本:面板据此判断游戏侧是否在跑旧脚本 */ const BRIDGE_VERSION = '1.0.254'; // ============================================================================ // 全局 console 拦截:把游戏里的 log/warn/error 转发给插件面板。 // 未连接时先积压到 consoleQueue,连接建立后再补发,避免丢日志。 // ============================================================================ const _origLog = console.log.bind(console); const _origWarn = console.warn.bind(console); const _origError = console.error.bind(console); const consoleQueue: { evt: string; level: string; text: string; t: number }[] = []; function fmtConsoleArgs(args: any[]): string { return args .map((a) => { try { if (typeof a === 'string') { return a; } if (a instanceof Error) { return a.stack || String(a); } const s = JSON.stringify(a); return s === undefined ? String(a) : s; } catch { return String(a); } }) .join(' '); } /** 已知的引擎内部告警噪音:变量填空类型时 getComponent 会警告「Type must be non-nil」, * 纯属引擎内部提示、无业务价值。判定命中则既不打印到游戏控制台也不转发给面板。 */ function isEngineNoise(args: any[]): boolean { const text = fmtConsoleArgs(args); return /getComponent: Type must be non-nil/.test(text); } function pushConsole(level: string, args: any[]): void { const item = { evt: 'console', level, text: fmtConsoleArgs(args), t: Date.now() }; const inst = RuntimeBridge._instance; if (inst) { inst.sendConsoleMsg(item); } else { consoleQueue.push(item); if (consoleQueue.length > 200) { consoleQueue.shift(); } } } function hookConsole(): void { if ((console as any).__yykaHooked) { return; } (console as any).__yykaHooked = true; const wrap = (orig: (...a: any[]) => void, level: string) => (...args: any[]) => { if (isEngineNoise(args)) { return; // 引擎内部告警:不打印到真实控制台,也不转发 } orig(...args); pushConsole(level, args); }; console.log = wrap(_origLog, 'log'); console.warn = wrap(_origWarn, 'warn'); console.error = wrap(_origError, 'error'); } /** 捕获未被 console 覆盖的全局异常/未处理 Promise 拒绝。 */ function hookGlobalErrors(): void { try { const g: any = globalThis as any; if (g.__yykaErrHooked) { return; } g.__yykaErrHooked = true; if (typeof g.addEventListener === 'function') { g.addEventListener('error', (e: any) => { const err = e && e.error; const text = e && e.message ? String(e.message) : (err ? String(err) : '未知错误'); const stack = err && err.stack ? `\n${err.stack}` : ''; pushConsole('error', [`[window.error] ${text}${stack}`]); }); g.addEventListener('unhandledrejection', (e: any) => { const r = e && e.reason; const text = r && r.stack ? r.stack : (r && r.message ? String(r.message) : String(r)); pushConsole('error', [`[unhandledrejection] ${text}`]); }); } } catch { /* ignore */ } } // 模块求值即启用日志拦截,尽可能早地捕获游戏日志与报错 hookConsole(); hookGlobalErrors(); hookApi(); /** 拦截空类型组件查询:getComponent/getComponentsInChildren 传入 undefined/空串时, * 引擎会在内部反复打印 `getComponent: Type must be non-nil` 刷屏(走引擎自有告警通道, * 不经过 console 钩子)。这里在 API 入口短路空类型,从源头消除告警, * 行为与引擎返回 undefined / [] 一致(仅不再报警)。 */ function hookApi(): void { try { if ((Node.prototype as any).__yykaApiHooked) { return; } (Node.prototype as any).__yykaApiHooked = true; const origGet = Node.prototype.getComponent; const origGetInChildren = Node.prototype.getComponentsInChildren; const empty = (t: any) => t === null || t === undefined || (typeof t === 'string' && !t.trim()); Node.prototype.getComponent = function (type: any, ...rest: any[]) { if (empty(type)) return null; return origGet.call(this, type, ...rest); } as any; Node.prototype.getComponentsInChildren = function (type: any) { if (empty(type)) return []; return origGetInChildren.call(this, type); } as any; } catch { /* ignore */ } } /** 识别当前运行环境类型,随连接 URL 上报给插件,供面板显示客户端来源。 */ function detectClientType(): string { try { if (sys.isNative) { const p = sys.platform; if (p === sys.Platform.ANDROID || p === sys.Platform.IOS) { return 'device'; } return 'simulator'; } if (sys.isBrowser) { const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; // 编辑器内置预览运行于 Electron 环境 if (/electron|cocoscreator/i.test(ua)) { return 'editor'; } return 'h5'; } } catch { /* 检测失败按未知处理 */ } return 'unknown'; } @ccclass('RuntimeBridge') @menu('Debug/RuntimeBridge') export class RuntimeBridge extends Component { /** 全局单例,避免重复实例 */ static _instance: RuntimeBridge | null = null; @property({ tooltip: '加载后是否自动连接' }) autoConnect = true; @property({ tooltip: 'WS 服务地址' }) host = '192.168.2.17'; @property({ tooltip: 'WS 服务端口' }) port = DEFAULT_PORT; @property({ tooltip: '断开后自动重连延迟(秒)' }) reconnectDelay = 2; @property({ tooltip: '启用时以该帧率向插件推送预览画面(0 表示关闭)' }) previewFps = 0; private _ws: WebSocket | null = null; private _timer: ReturnType | null = null; private _reconnectTimer: ReturnType | null = null; private _frameTimer: ReturnType | null = null; private _manualClose = false; /** 本次连接是否成功建立过(用于区分"被拒/不通"与"正常断开") */ private _everOpened = false; /** 从未成功建立连接的连续失败次数,防止无限重连刷屏 */ private _failCount = 0; /** 实际连接的地址(候选列表中当前项) */ private _connHost = DEFAULT_HOST; /** 候选连接地址:用户配置优先,失败自动尝试模拟器/真机常见的宿主机地址 */ private _addrCandidates: string[] = []; private _addrIdx = 0; private _muted = false; private _resVol = new WeakMap(); private _frameCount = 0; private _lastStatsT = 0; private _highlightOverlay: Node | null = null; // ===== 画面抓帧缓存 ===== // WebGL 画布未开 preserveDrawingBuffer 时,present 后缓冲区即被清空,非渲染时机 // 对 #GameCanvas 调 toDataURL 只能得到纯黑。因此改在导演每次绘帧完成(EVENT_AFTER_DRAW, // 此时默认缓冲仍持有本帧内容)时把画面缓存起来;截图/预览直接喂缓存帧,保证非黑。 private _lastFrame: string | null = null; private _capStamp = 0; private _capHooked = false; /** 最近一次场景启动时记录的场景名(H5 早期 getScene() 可能为 null,用缓存兜底)。 */ private _sceneName = ''; /** 当前场景名:优先实时取 director.getScene(),取不到/为空时用缓存兜底,再无名字时给占位名。 */ private currentSceneName(): string { const scene: any = director.getScene(); const live = scene && (scene.name || scene._name || ''); return String(live || this._sceneName || 'main'); } onLoad() { if (RuntimeBridge._instance) { // 已存在实例,销毁多余节点,避免重复连接 this.node.destroy(); return; } RuntimeBridge._instance = this; // 常驻:跨场景存活 if (!director.isPersistRootNode(this.node)) { director.addPersistRootNode(this.node); } // 记录当前场景名,供 query-tree 在 getScene() 尚未就绪时兜底 this._sceneName = director.getScene()?.name ?? ''; // 安装绘帧完成钩子:让截图/预览在 AFTER_DRAW 拿到非黑画面(WebGL 缓冲会被清)。 this.installFrameHook(); if (this.autoConnect && !this._ws) { this.connect(); } // 帧计数:用于统计 fps director.on(Director.EVENT_AFTER_UPDATE, this.onFrame, this); } private onFrame(): void { this._frameCount++; } /** 渲染/内存统计:drawCall / 三角形 / fps / 堆内存(MB)。 */ private collectStats(): { drawCall: number; triangles: number; fps: number; memUsed: number; memTotal: number } { // profiler.stats 里的字段可能是 PerfCounter 对象(带 value 属性),统一转成数字 const toNum = (v: any): number => { if (typeof v === 'number' && isFinite(v)) { return v; } if (v && typeof v === 'object' && typeof v.value === 'number' && isFinite(v.value)) { return v.value; } return 0; }; let drawCall = 0; let triangles = 0; try { // 最优先:gfx 设备统计(director.root.device.numDrawCalls / numTris), // 不依赖 profiler 模块是否被功能裁剪,数据源与 profiler 一致 const root: any = (director as any).root; const dev = root && root.device; if (dev) { const dc = toNum(dev.numDrawCalls); const tri = toNum(dev.numTris); if (dc || tri) { drawCall = dc; triangles = tri; } } // 其次:新版引擎的 profiler 统计(cc.profiler.stats: draws / tricount) // 注意 3.x 里字段名是 draws 而不是 drawCall if (!drawCall && !triangles) { const prof: any = (profiler as any) || (globalThis as any).cc?.profiler; const pst = prof && prof.stats; if (pst) { const dc = toNum(pst.draws ?? pst.drawCall ?? 0); const tri = toNum(pst.tricount ?? pst.triangles ?? 0); if (dc || tri) { drawCall = dc; triangles = tri; } } } // 再次:RenderScene 统计(默认关闭,需先 setStatistics(true)) if (!drawCall && !triangles) { const scene: any = director.getScene(); const rs: any = scene && scene.getRenderScene ? scene.getRenderScene() : null; if (rs) { try { rs.setStatistics && rs.setStatistics(true); } catch (e) { /* ignore */ } const st = rs.statistics; if (st) { const dc = toNum(st.drawCall ?? st.drawCalls ?? st.draws ?? 0); const tri = toNum(st.triangle ?? st.triangles ?? st.triangleCount ?? 0); if (dc || tri) { drawCall = dc; triangles = tri; } } } } // 兜底:director.getStats()(部分版本可用) if (!drawCall && !triangles) { const d: any = director as any; const stats: any = typeof d.getStats === 'function' ? d.getStats() : null; if (stats) { drawCall = toNum(stats.drawCall ?? stats.draws ?? 0); triangles = toNum(stats.triangles ?? stats.trianglesCount ?? 0); } } } catch (e) { drawCall = 0; triangles = 0; } // 堆内存(MB):浏览器预览可用 performance.memory;原生/无此接口则为 0 let memUsed = 0; let memTotal = 0; try { const pm: any = (globalThis as any).performance && (globalThis as any).performance.memory; if (pm) { memUsed = Math.round(pm.usedJSHeapSize / 1048576); memTotal = Math.round(pm.totalJSHeapSize / 1048576); } } catch (e) { memUsed = 0; memTotal = 0; } const now = Date.now(); const dtSec = this._lastStatsT ? (now - this._lastStatsT) / 1000 : 1; const fps = dtSec > 0 ? Math.round(this._frameCount / dtSec) : 0; this._frameCount = 0; this._lastStatsT = now; return { drawCall, triangles, fps, memUsed, memTotal }; } /** 资源/对象统计:场景节点与组件数 + assetManager 中各类资源数量。 */ private getResourceStats(): any { const out: any = { nodes: 0, components: 0, textures: 0, sprites: 0, materials: 0, meshes: 0, audioClips: 0, animations: 0, prefabs: 0, spriteAtlases: 0, jsons: 0, texts: 0, others: 0, totalAssets: 0, }; const scene = director.getScene(); if (scene) { scene.walk(() => { out.nodes++; }); try { out.components = scene.getComponentsInChildren(Component).length; } catch { /* ignore */ } } try { const am: any = assetManager as any; const assets: any = am && am.assets; if (assets && typeof assets.forEach === 'function') { out.totalAssets = 0; assets.forEach((asset: any) => { out.totalAssets++; const cls = asset && asset.constructor ? asset.constructor.name : ''; switch (cls) { case 'Texture2D': out.textures++; break; case 'SpriteFrame': out.sprites++; break; case 'Material': out.materials++; break; case 'Mesh': out.meshes++; break; case 'AudioClip': out.audioClips++; break; case 'AnimationClip': out.animations++; break; case 'Prefab': out.prefabs++; break; case 'SpriteAtlas': out.spriteAtlases++; break; case 'JsonAsset': out.jsons++; break; case 'TextAsset': out.texts++; break; default: out.others++; break; } }); } } catch { /* ignore */ } return out; } /** 列出已加载资产(面板资产选择弹层用)。payload: { type?: string } */ private listAssets(payload: any): any { const out: any[] = []; const typeSet = new Set(); const filter = payload && payload.type ? String(payload.type) : ''; try { const am: any = assetManager as any; const assets: any = am && am.assets; if (assets && typeof assets.forEach === 'function') { assets.forEach((asset: any) => { if (!asset) { return; } const cls = asset.constructor?.name ?? (asset as any).__classname__ ?? ''; if (cls) { typeSet.add(cls); } if (filter && cls !== filter) { return; } out.push({ name: String((asset as any).name ?? ''), uuid: String((asset as any).uuid ?? (asset as any)._id ?? ''), type: cls, }); }); } } catch { /* ignore */ } out.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')); return { assets: out, types: [...typeSet].sort() }; } /** 按 uuid 从已加载资产中查找资产对象。 */ private findAsset(uuid: string): any { if (!uuid) { return null; } try { const am: any = assetManager as any; const assets: any = am && am.assets; let found: any = null; if (assets && typeof assets.forEach === 'function') { assets.forEach((asset: any) => { if (found) { return; } if (asset && String((asset as any).uuid ?? (asset as any)._id ?? '') === uuid) { found = asset; } }); } return found; } catch { return null; } } onDestroy() { if (RuntimeBridge._instance === this) { RuntimeBridge._instance = null; } director.off(Director.EVENT_AFTER_UPDATE, this.onFrame, this); this._manualClose = true; this.stopPreview(); this.clearHighlight(); this.disconnect(); } /** 固定连接地址:不做多地址尝试/排队,始终用界面配置的 host(默认 127.0.0.1)。 */ private buildAddrCandidates(): string[] { return [(this.host || '').trim() || DEFAULT_HOST]; } /** 建立(或重建)连接。 */ connect() { this._manualClose = false; this._everOpened = false; if (this._ws && this._ws.readyState === WebSocket.OPEN) { return; } this.clearReconnect(); if (this._addrCandidates.length === 0) { this._addrCandidates = this.buildAddrCandidates(); this._addrIdx = 0; } this._connHost = this._addrCandidates[this._addrIdx] || this.host || DEFAULT_HOST; const q = `?token=${encodeURIComponent(TOKEN)}`; const ctype = detectClientType(); const sep = '&'; this._ws = new WebSocket(`ws://${this._connHost}:${this.port}${q}${sep}client=${ctype}`); this._ws.onopen = () => { this._everOpened = true; this._failCount = 0; this._addrIdx = 0; console.log('[yyka-runtime-inspector] 已连接:', this._connHost + ':' + this.port); this.flushConsole(); this.send({ evt: 'hello', nodeCount: this.countNodes() }); if (this.previewFps > 0) { this.startPreview(this.previewFps); } // 定时上报,验证全链路 this._timer = setInterval(() => { this.send({ evt: 'ping', t: Date.now(), ver: BRIDGE_VERSION, scene: this.currentSceneName(), nodeCount: this.countNodes(), }); }, 1000); }; this._ws.onmessage = (evt: MessageEvent) => { this.onMessage(evt); }; this._ws.onclose = (evt: CloseEvent) => { this.clearTimer(); this._ws = null; // 原生(libwebsockets)的失败信息主要在 onclose 里(不一定先触发 onerror), // 尽量提取 code/reason,打印出来辅助诊断。 const closeCode = evt && typeof evt.code === 'number' ? evt.code : -1; const closeReason = evt && evt.reason ? String(evt.reason) : ''; if (closeCode === 4001) { console.error('[yyka-runtime-inspector] 连接被拒绝:令牌(token)与插件不一致,请检查'); return; } if (closeCode === 4002) { // 被新连接顶替:本实例降级为"待机",不再自动重连,避免互踢循环 console.warn('[yyka-runtime-inspector] 已被新的游戏连接顶替,本实例停止自动重连'); return; } // 1000=正常关闭、1005=未收到关闭码,跳过;其余视为异常关闭,打印细节 if (closeCode !== 1000 && closeCode !== 1005) { console.warn( '[yyka-runtime-inspector] 连接关闭: ' + this._connHost + ':' + this.port + ' | code=' + closeCode + ' | reason=' + (closeReason || '(空)') + (this._everOpened ? '' : '(从未成功建立连接)') ); } if (!this._manualClose) { if (!this._everOpened) { // 从未成功建立连接就断开:换下一个候选地址重试(覆盖模拟器/真机网络差异) this._addrIdx++; if (this._addrIdx >= this._addrCandidates.length) { console.warn('[yyka-runtime-inspector] 连接失败,停止自动重连'); this._addrIdx = 0; return; } const next = this._addrCandidates[this._addrIdx]; console.warn('[yyka-runtime-inspector] ' + this._connHost + ':' + this.port + ' 连接失败,尝试 ' + next + ':' + this.port); } this.scheduleReconnect(); } }; this._ws.onerror = (err) => { // 尽量把错误对象转成可读字符串:浏览器/原生(libwebsockets)传的事件对象结构不同, // 兼容 字符串 / Error / {message,reason,error,type,code,name} / 其他对象(JSON 兜底)。 let reason = ''; try { const e: any = err; if (typeof err === 'string') { reason = err; } else if (err instanceof Error) { reason = err.message || err.stack || String(err); } else if (err && typeof err === 'object') { reason = e.message || e.reason || e.error || e.type || e.code || e.name || e.target?.url || ''; if (!reason) { try { const keys = Object.keys(e); reason = keys.length ? JSON.stringify(e) : String(err); } catch { reason = String(err); } } } else { reason = String(err); } } catch { reason = String(err); } if (!reason) { reason = String(err); } console.warn('[yyka-runtime-inspector] 连接失败: ' + this._connHost + ':' + this.port + ' | 原因=' + reason); }; } /** 供全局 console 拦截器调用:连接已建立时直接转发一条控制台消息。 */ sendConsoleMsg(item: { evt: string; level: string; text: string; t: number }): void { if (this._ws && this._ws.readyState === WebSocket.OPEN) { this.send(item); } else { consoleQueue.push(item); if (consoleQueue.length > 200) { consoleQueue.shift(); } } } /** 连接建立后补发积压的 console 日志。 */ private flushConsole(): void { while (consoleQueue.length) { const it = consoleQueue.shift(); if (it) { this.send(it); } } } /** 断开连接(不再自动重连)。 */ disconnect() { this.clearReconnect(); this.clearTimer(); if (this._ws) { this._ws.close(); this._ws = null; } } private scheduleReconnect() { if (this._manualClose) { return; } this._reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay * 1000); } private clearReconnect() { if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } } private clearTimer() { if (this._timer) { clearInterval(this._timer); this._timer = null; } } /** 接收来自插件的命令。可带 requestId(需回包),也可 fire-and-forget。 */ private onMessage(evt: MessageEvent): void { let msg: any = null; try { msg = JSON.parse(String(evt.data)); } catch { return; } if (!msg || typeof msg.cmd !== 'string') { return; } let data: any; try { data = this.handleCommand(msg.cmd, msg.payload ?? {}); } catch (e: any) { data = { error: String(e?.message ?? e) }; } // 带 requestId 的命令需要回包;否则忽略结果 if (typeof msg.requestId === 'number') { this.send({ requestId: msg.requestId, evt: 'response', data }); } } /** 分发命令并同步返回结果。 */ private handleCommand(cmd: string, payload: any): any { switch (cmd) { case 'query-tree': return this.buildTree(payload); case 'get-inspector': return this.getInspector(payload); case 'set-property': return this.setProperty(payload); case 'set-preview': { const enabled = !!payload?.enabled; const fps = payload?.fps || this.previewFps || 10; if (enabled) { this.startPreview(fps); } else { this.stopPreview(); } return { ok: true, preview: enabled, fps }; } case 'set-mute': { this.setMute(!!payload?.muted); return { ok: true, muted: this._muted }; } case 'get-stats': { return this.collectStats(); } case 'capture-frame': { const data = this.captureFrame(); return data ? { ok: true, data } : { error: '捕获画面失败' }; } case 'get-resource-stats': { return this.getResourceStats(); } case 'set-physics-debug': { this.setPhysicsDebug(!!payload?.enabled); return { ok: true, physicsDebug: !!payload?.enabled }; } case 'query-physics': { return this.queryPhysics(); } case 'simulate-click': { return this.simulateClick(payload); } case 'highlight': { return this.setHighlight(payload); } case 'list-scenes': { return this.listScenes(); } case 'load-scene': { return this.loadScene(payload); } case 'list-assets': { return this.listAssets(payload); } default: return { error: `未知命令: ${cmd}` }; } } /** 启动预览抓帧推送。 */ private startPreview(fps: number): void { this.stopPreview(); const period = Math.max(50, Math.round(1000 / fps)); this._frameTimer = setInterval(() => { const data = this.captureFrame(); if (data) { this.send({ evt: 'frame', data }); } }, period); } private stopPreview(): void { if (this._frameTimer) { clearInterval(this._frameTimer); this._frameTimer = null; } } /** 静音/恢复:遍历当前场景所有 AudioSource,记录原音量并置 0 或恢复。 */ private setMute(muted: boolean): void { this._muted = muted; this.applyMute(); } private applyMute(): void { const scene = director.getScene(); if (!scene) { return; } const sources = scene.getComponentsInChildren(AudioSource) as AudioSource[]; for (const s of sources) { if (!this._resVol.has(s)) { this._resVol.set(s, s.volume); } s.volume = this._muted ? 0 : (this._resVol.get(s) ?? 1); } } /** 开启/关闭物理调试绘制(3D 物理系统的 debug draw)。 */ private setPhysicsDebug(enabled: boolean): void { const PS: any = PhysicsSystem as any; try { const ps = PS && PS.instance; if (!ps) { return; } ps.enable = true; if (enabled) { const flagsCls = PS.DebugDrawFlags; const shape = flagsCls?.SHAPE ?? 1; const joint = flagsCls?.JOINT ?? 2; const constraint = flagsCls?.CONSTRAINT ?? 4; ps.debugDrawFlags = shape | joint | constraint; } else { ps.debugDrawFlags = (PS.DebugDrawFlags?.NONE) ?? 0; } } catch (e: any) { console.error('[yyka-runtime-inspector] setPhysicsDebug 失败:', e); } } /** 查询当前物理系统状态与场景内刚体/碰撞体数量。 */ private queryPhysics(): any { const out: any = { enabled: false, debugDrawFlags: 0, gravity: null, bodies: 0, colliders: 0, bodies2d: 0, colliders2d: 0, }; const PS: any = PhysicsSystem as any; try { const ps = PS && PS.instance; if (ps) { out.enabled = !!ps.enable; out.debugDrawFlags = ps.debugDrawFlags ?? 0; const g = ps.gravity; out.gravity = g ? { x: g.x, y: g.y, z: g.z } : null; } } catch { /* 忽略:物理模块未启用或版本差异 */ } const scene = director.getScene(); if (scene) { try { out.bodies = scene.getComponentsInChildren(RigidBody).length; out.colliders = scene.getComponentsInChildren(Collider).length; } catch { /* 忽略 */ } try { out.bodies2d = scene.getComponentsInChildren(RigidBody2D).length; out.colliders2d = scene.getComponentsInChildren(Collider2D).length; } catch { /* 忽略 */ } } return out; } /** 抓取当前游戏画面,编码为 JPEG base64。失败返回 null。 * 直接对 WebGL 画布 toDataURL 在非渲染时机是纯黑(缓冲被清), * 因此优先返回 AFTER_DRAW 钩子缓存的本帧画面;无缓存或非 WebGL 时才即时抓。 */ private captureFrame(): string | null { try { if (this._lastFrame) { return this._lastFrame; } const canvas: HTMLCanvasElement = (game as any).canvas; if (!canvas) { return null; } return canvas.toDataURL('image/jpeg', 0.6); } catch { return null; } } /** 安装绘帧完成钩子:渲染后把画面存入缓存(节流,避免每帧都做 JPEG 编码)。 * 必须在导演每帧的 AFTER_DRAW 触发才能拿到非黑画面。 */ private installFrameHook(): void { if (this._capHooked) { return; } this._capHooked = true; try { director.on(Director.EVENT_AFTER_DRAW, this._afterDrawCb); } catch { /* 引擎未就绪/事件名差异时忽略 */ } } private _afterDrawCb = (): void => { const now = Date.now(); if (now - this._capStamp < 80) { return; // 节流 ~12fps,够截图与低帧预览用,避免每帧 JPEG 编码开销 } this._capStamp = now; try { const canvas: HTMLCanvasElement = (game as any).canvas; if (canvas) { this._lastFrame = canvas.toDataURL('image/jpeg', 0.6); } } catch { /* 单帧失败不致命 */ } }; /** 返回单个节点的核心属性 + 组件列表及其可编辑属性。payload: { id } */ private getInspector(payload: any): any { const node = this.findNode(payload?.id); if (!node) { return { error: `未找到节点: ${payload?.id}` }; } const p = node.position; const e = node.eulerAngles ?? node.rotation; const s = node.scale; const w = node.worldPosition; const components = (node.components || []).map((c: any) => { const type: string = c.__classname__ ?? c.constructor?.name ?? 'Component'; const data: any = { type, enabled: c.enabled ?? true, props: this.enumerateEditable(c), }; // Widget 专属:输出对齐状态,供面板渲染官方风格的对齐面板 if (type === 'cc.Widget' || type === 'Widget') { const w: any = c; data.widget = { alignMode: typeof w.alignMode === 'number' ? w.alignMode : 0, targetName: w.target && typeof w.target === 'object' ? String((w.target as any).name ?? '') : '', left: w.left ?? 0, right: w.right ?? 0, top: w.top ?? 0, bottom: w.bottom ?? 0, isAbsLeft: !!w.isAbsLeft, isAbsRight: !!w.isAbsRight, isAbsTop: !!w.isAbsTop, isAbsBottom: !!w.isAbsBottom, isAlignLeft: !!w.isAlignLeft, isAlignRight: !!w.isAlignRight, isAlignTop: !!w.isAlignTop, isAlignBottom: !!w.isAlignBottom, isAlignHorizontalCenter: !!w.isAlignHorizontalCenter, isAlignVerticalCenter: !!w.isAlignVerticalCenter, }; } return data; }); return { id: (node as any)._id ?? '', name: node.name, props: { active: { t: 'bool', v: !!node.active, editable: true }, layer: { t: 'number', v: node.layer, editable: true }, position: { t: 'vec3', v: { x: p.x, y: p.y, z: p.z }, editable: true }, euler: { t: 'vec3', v: { x: e.x, y: e.y, z: e.z }, editable: true }, scale: { t: 'vec3', v: { x: s.x, y: s.y, z: s.z }, editable: true }, worldPosition: { t: 'vec3', v: { x: w.x, y: w.y, z: w.z }, editable: false }, }, components, }; } /** 写回一条属性。payload 见下 */ private setProperty(payload: any): any { const node = this.findNode(payload?.id); if (!node) { return { error: `未找到节点: ${payload?.id}` }; } const v = payload?.value; try { const field = payload?.field; const detail = (extra: any = {}) => ({ ok: true, node: node.name, ...extra }); if (field === 'position') { node.setPosition(new Vec3(v.x ?? 0, v.y ?? 0, v.z ?? 0)); this.syncRigidBody(node); return detail({ field, value: v }); } if (field === 'euler') { node.setRotationFromEuler(v.x ?? 0, v.y ?? 0, v.z ?? 0); this.syncRigidBody(node); return detail({ field, value: v }); } if (field === 'scale') { node.setScale(new Vec3(v.x ?? 1, v.y ?? 1, v.z ?? 1)); this.syncRigidBody(node); return detail({ field, value: v }); } if (field === 'active') { node.active = !!v; return detail({ field, value: !!v }); } if (field === 'layer') { node.layer = Number(v) || 0; return detail({ field, value: node.layer }); } // 组件属性:payload.includes { comp: 索引, key: 属性名, value: 原始值 } if (typeof payload?.comp === 'number' && node.components) { const comp: any = node.components[payload.comp]; if (!comp) { return { error: `组件索引越界: ${payload.comp}` }; } if (payload.key === '__enabled') { comp.enabled = !!v; return detail({ key: '__enabled', value: !!v }); } const cur = comp[payload.key]; const compType: string = comp.__classname__ ?? comp.constructor?.name ?? ''; // Widget 对齐属性:写回后立即触发对齐计算 if ((compType === 'cc.Widget' || compType === 'Widget') && ['left', 'right', 'top', 'bottom', 'isAbsLeft', 'isAbsRight', 'isAbsTop', 'isAbsBottom', 'isAlignLeft', 'isAlignRight', 'isAlignTop', 'isAlignBottom', 'isAlignHorizontalCenter', 'isAlignVerticalCenter', 'alignMode'].includes(payload.key)) { comp[payload.key] = v; try { comp.updateAlignment?.(); } catch { /* ignore */ } return detail({ key: payload.key, value: v }); } // Widget 对齐目标:按运行时节点 id 赋 target if ((compType === 'cc.Widget' || compType === 'Widget') && payload.key === '__widget_target') { const target = v ? this.findNode(String(v)) : null; comp.target = target ?? null; try { comp.updateAlignment?.(); } catch { /* ignore */ } return detail({ key: '__widget_target', value: target ? String((target as any).name ?? '') : '' }); } // 引用写回:value 携带运行时节点 id + 组件类型,从节点上取组件赋给引用 if (v && typeof v === 'object' && v.__refNode && cur && typeof cur === 'object') { const n = this.findNode(String(v.__refNode)); if (!n) { return { error: `未找到引用节点: ${v.__refNode}` }; } const target = v.componentType === 'cc.Node' ? n : (n as any).getComponent?.(v.componentType); if (!target) { return { error: `节点 ${n.name} 上不存在组件 ${v.componentType}` }; } comp[payload.key] = target; return detail({ key: payload.key, value: v }); } // 资产引用写回:value 携带资产 uuid(spriteFrame / material 等),从已加载资产中查找赋给引用 if (v && typeof v === 'object' && v.__refAsset) { const asset = this.findAsset(String(v.__refAsset)); if (!asset) { return { error: `未找到资产: ${v.__refAsset}` }; } if (Array.isArray(cur) && typeof payload.index === 'number') { const idx = payload.index; if (idx < 0 || idx >= cur.length) { return { error: `数组下标越界: ${idx}/${cur.length}` }; } cur[idx] = asset; return detail({ key: payload.key, index: idx, value: v }); } comp[payload.key] = asset; return detail({ key: payload.key, value: v }); } // 材质参数写回:payload.key='__material_prop',value={ propKey, value } // index 为数字时定位 sharedMaterials 数组项,否则用 customMaterial / material if (payload.key === '__material_prop') { let mat: any = null; if (typeof payload.index === 'number' && Array.isArray(comp.sharedMaterials)) { mat = comp.sharedMaterials[payload.index]; } else if (comp.customMaterial) { mat = comp.customMaterial; } else if (comp.material) { mat = comp.material; } if (!mat || typeof mat.setProperty !== 'function') { return { error: '材质不可用或不可编辑' }; } const info = payload.value || {}; const pv = info.value; let setVal: any = pv; if (pv && typeof pv === 'object') { if ('r' in pv) { setVal = new Color((pv.r ?? 0) / 255, (pv.g ?? 0) / 255, (pv.b ?? 0) / 255, (pv.a ?? 255) / 255); } else if ('w' in pv) { setVal = new Vec4(pv.x ?? 0, pv.y ?? 0, pv.z ?? 0, pv.w ?? 0); } else if ('z' in pv) { setVal = new Vec3(pv.x ?? 0, pv.y ?? 0, pv.z ?? 0); } else { setVal = new Vec2(pv.x ?? 0, pv.y ?? 0); } } try { mat.setProperty(String(info.propKey), setVal); } catch (e) { return { error: `设置材质参数失败: ${e}` }; } return detail({ key: '__material_prop', value: String(info.propKey) }); } // 数组项写回:payload.index 指明下标 if (Array.isArray(cur) && typeof payload.index === 'number') { const idx = payload.index; if (idx < 0 || idx >= cur.length) { return { error: `数组下标越界: ${idx}/${cur.length}` }; } const item = cur[idx]; if (item instanceof Vec3 && v && typeof v === 'object') { item.set(v.x ?? 0, v.y ?? 0, v.z ?? 0); } else if (item instanceof Vec2 && v && typeof v === 'object') { item.set(v.x ?? 0, v.y ?? 0); } else if (item instanceof Color && v && typeof v === 'object') { item.set((v.r ?? 0) / 255, (v.g ?? 0) / 255, (v.b ?? 0) / 255, (v.a ?? 255) / 255); } else if (item instanceof Quat && v && typeof v === 'object') { item.set(v.x ?? 0, v.y ?? 0, v.z ?? 0, v.w ?? 1); } else if (item instanceof Vec4 && v && typeof v === 'object') { item.set(v.x ?? 0, v.y ?? 0, v.z ?? 0, v.w ?? 0); } else if (item instanceof Rect && v && typeof v === 'object') { item.set(v.x ?? 0, v.y ?? 0, v.w ?? 0, v.h ?? 0); } else if (item instanceof Size && v && typeof v === 'object') { item.set(v.w ?? 0, v.h ?? 0); } else { cur[idx] = v; } return detail({ key: payload.key, index: idx, value: v }); } // 引用置空:原值是 cc 对象,写入 null if (v === null && cur && typeof cur === 'object') { comp[payload.key] = null; return detail({ key: payload.key, value: null }); } if (cur instanceof Vec3 && v && typeof v === 'object') { cur.set(v.x ?? 0, v.y ?? 0, v.z ?? 0); } else if (cur instanceof Vec2 && v && typeof v === 'object') { cur.set(v.x ?? 0, v.y ?? 0); } else if (cur instanceof Color && v && typeof v === 'object') { // 面板按 0-255 传输,Color 内部为 0-1 cur.set((v.r ?? 0) / 255, (v.g ?? 0) / 255, (v.b ?? 0) / 255, (v.a ?? 255) / 255); } else if (cur instanceof Quat && v && typeof v === 'object') { cur.set(v.x ?? 0, v.y ?? 0, v.z ?? 0, v.w ?? 1); } else if (cur instanceof Vec4 && v && typeof v === 'object') { cur.set(v.x ?? 0, v.y ?? 0, v.z ?? 0, v.w ?? 0); } else if (cur instanceof Rect && v && typeof v === 'object') { cur.set(v.x ?? 0, v.y ?? 0, v.w ?? 0, v.h ?? 0); } else if (cur instanceof Size && v && typeof v === 'object') { cur.set(v.w ?? 0, v.h ?? 0); } else { comp[payload.key] = v; } return detail({ key: payload.key, value: v }); } return { error: '无法识别的 set-property 载荷' }; } catch (err: any) { return { error: `写回失败: ${String(err?.message ?? err)}` }; } } /** 写回节点变换后同步刚体:清零速度并唤醒,避免物理模拟立即把变换拉回去。 */ private syncRigidBody(node: Node): void { const rb3 = node.getComponent(RigidBody); if (rb3) { try { rb3.wakeUp(); rb3.setLinearVelocity(new Vec3(0, 0, 0)); rb3.setAngularVelocity(new Vec3(0, 0, 0)); (rb3 as any).syncFromNode?.(); } catch { /* ignore */ } return; } const rb2 = node.getComponent(RigidBody2D); if (rb2) { try { rb2.wakeUp(); rb2.linearVelocity = new Vec2(0, 0); rb2.angularVelocity = 0; (rb2 as any).syncFromNode?.(); } catch { /* ignore */ } } } /** 枚举组件上由 @property 声明(装饰器生成的 __props__ 元数据)的可编辑属性。 * 相比 Object.keys 运行时反射,只显示显式声明的字段,更贴近编辑器属性面板。 */ private enumerateEditable(comp: any): any[] { if (!comp) { return []; } const out: any[] = []; const seen = new Set(); // 白名单:引擎内部序列化字段 → 公开访问器(如 Renderable 的 _materials → sharedMaterials), // 避免把 _node/_parent/_children 等内部结构字段暴露出来。 const PUBLIC_MAP: Record = { _materials: 'sharedMaterials' }; // 沿原型链收集静态 __props__(CCObject 起,含系统组件继承的属性) let cls: any = comp.constructor; let guard = 0; while (cls && cls !== Object && guard++ < 20) { if (Array.isArray(cls.__props__)) { const propTypes: any[] = Array.isArray(cls.__propTypes__) ? cls.__propTypes__ : []; for (const name of cls.__props__) { if (typeof name !== 'string' || !name) { continue; } const mappedKey = PUBLIC_MAP[name] ?? ''; if (!mappedKey && name.startsWith('_')) { continue; // 非白名单的下划线内部字段 } const displayKey = mappedKey || name; if (seen.has(displayKey)) { continue; // 同一公开属性只显示一次(如 _materials 与 sharedMaterials 并存时) } seen.add(displayKey); if (name === 'enabled' || name === 'node') { continue; // 结构性只读属性不参与编辑:enabled 由组件头部开关负责,node 是组件挂载节点 } let value: any; try { value = comp[mappedKey || name]; } catch { continue; } // 值为空的资产引用(如未赋值的 spriteFrame):按 __propTypes__ 推断类型,仍显示为可选择的空资产 if (value === null || value === undefined) { const idx = cls.__props__.indexOf(name); const pt = idx >= 0 ? propTypes[idx] : null; const typeName = this.assetTypeName(pt); if (typeName) { out.push({ key: mappedKey || name, t: 'asset', v: { type: typeName, name: '', uuid: '', settable: true }, editable: true, }); } continue; } let d: any = null; // 枚举:值为 number 且 __propTypes__ 对应当前字段的类型是枚举对象 if (typeof value === 'number' && propTypes.length) { const idx = cls.__props__.indexOf(name); const pt = idx >= 0 ? propTypes[idx] : null; const opts = this.tryEnumOptions(pt); if (opts) { d = { t: 'enum', v: value, options: opts, editable: true }; } } if (!d) { d = this.toDescriptor(value); } if (!d) { continue; } out.push({ key: mappedKey || name, ...d }); } } cls = Object.getPrototypeOf(cls); } return out; } /** 尝试把运行时枚举对象展开为 {name,value} 列表;非枚举返回 null。 */ private tryEnumOptions(enumObj: any): { name: string; value: number }[] | null { if (!enumObj || (typeof enumObj !== 'object' && typeof enumObj !== 'function')) { return null; } const out: { name: string; value: number }[] = []; let anyNum = false; try { for (const k of Object.keys(enumObj)) { const v = enumObj[k]; if (typeof v === 'number') { out.push({ name: k, value: v }); anyNum = true; } } } catch { return null; } return anyNum ? out : null; } /** 判断 __propTypes__ 项是否为 Asset 子类,返回类名(如 SpriteFrame);非资产类型返回空串。 */ private assetTypeName(pt: any): string { try { if (!pt) { return ''; } const cls = typeof pt === 'function' ? pt : pt.constructor; if (!cls || !cls.prototype) { return ''; } let proto = cls.prototype; while (proto) { if (proto === Asset.prototype) { return String(cls.name ?? ''); } proto = Object.getPrototypeOf(proto); } return ''; } catch { return ''; } } /** 把运行时值转成可 JSON 序列化、可编辑的表述;不可编辑对象返回 null。 */ private toDescriptor(value: any): any | null { if (value === null || value === undefined) { return null; } if (typeof value === 'number') { return { t: 'number', v: value, editable: true }; } if (typeof value === 'string') { return { t: 'string', v: value, editable: true }; } if (typeof value === 'boolean') { return { t: 'bool', v: value, editable: true }; } if (value instanceof Vec3) { return { t: 'vec3', v: { x: value.x, y: value.y, z: value.z }, editable: true }; } if (value instanceof Vec2) { return { t: 'vec2', v: { x: value.x, y: value.y }, editable: true }; } if (value instanceof Quat) { return { t: 'quat', v: { x: value.x, y: value.y, z: value.z, w: value.w }, editable: true }; } if (value instanceof Vec4) { return { t: 'vec4', v: { x: value.x, y: value.y, z: value.z, w: value.w }, editable: true }; } if (value instanceof Rect) { return { t: 'rect', v: { x: value.x, y: value.y, w: value.width, h: value.height }, editable: true }; } if (value instanceof Size) { return { t: 'size', v: { w: value.width, h: value.height }, editable: true }; } if (value instanceof Color) { return { t: 'color', v: { r: Math.round(value.r * 255), g: Math.round(value.g * 255), b: Math.round(value.b * 255), a: Math.round(value.a * 255) }, editable: true, }; } if (Array.isArray(value)) { const items: any[] = value.slice(0, 8).map((it) => { const d = this.toDescriptor(it); return d || { t: 'raw', v: this.rawPreview(it), editable: false }; }); return { t: 'array', v: items, len: value.length, editable: true }; } // 资产引用(SpriteFrame / Material / Texture2D / Prefab 等):官方 ui-asset 风格显示 if (value instanceof Asset) { const clsName = (value as any).__classname__ ?? value.constructor?.name ?? ''; const d: any = { t: 'asset', v: { type: String(clsName), name: String((value as any).name ?? ''), uuid: String((value as any).uuid ?? ''), settable: true, }, editable: true, // 允许置空 }; // 材质实例:附带可编辑参数(mainColor / float / vec 等),面板展开编辑 if (value instanceof Material) { d.t = 'material'; d.v.props = this.serializeMaterialProps(value); } return d; } // Cocos 对象引用(Node / Component;Vec3 等已在上方处理) if (value && typeof value === 'object' && (value as any).__classname__) { const clsName = (value as any).__classname__ ?? value.constructor?.name ?? ''; const refName = (value as any).name ?? ''; const refUuid = (value as any).uuid ?? (value as any)._id ?? ''; const compType = (value as any).__classname__ ?? ''; return { t: 'ref', v: { type: String(clsName), name: String(refName), uuid: String(refUuid), componentType: String(compType), settable: true, // 可写回为场景内另一节点的组件/节点 }, editable: true, // 允许置空 }; } return null; // 其他对象暂不编辑 } /** 数组/对象项的紧凑预览文本。 */ private rawPreview(v: any): string { try { const s = JSON.stringify(v); return s === undefined ? String(v) : s; } catch { return String(v); } } /** 序列化材质实例的可编辑参数(material.properties)。 */ private serializeMaterialProps(m: any): any[] { const out: any[] = []; try { const props: any = (m as any).properties; if (!props || typeof props !== 'object') { return out; } for (const k of Object.keys(props)) { const p = props[k]; if (!p || !('value' in p)) { continue; } const val = p.value; let d: any = null; if (typeof val === 'number') { d = { t: 'float', v: val }; } else if (val instanceof Color) { d = { t: 'color', v: { r: Math.round(val.r * 255), g: Math.round(val.g * 255), b: Math.round(val.b * 255), a: Math.round(val.a * 255) }, }; } else if (val instanceof Vec4) { d = { t: 'vec4', v: { x: val.x, y: val.y, z: val.z, w: val.w } }; } else if (val instanceof Vec3) { d = { t: 'vec3', v: { x: val.x, y: val.y, z: val.z } }; } else if (val instanceof Vec2) { d = { t: 'vec2', v: { x: val.x, y: val.y } }; } else if (val && typeof val === 'object' && (val as any).__classname__) { // 纹理等资产参数:只读显示 d = { t: 'texture', v: { type: String((val as any).__classname__ ?? ''), name: String((val as any).name ?? ''), }, }; } if (d) { out.push({ key: k, type: String(p.type ?? ''), ...d }); } } } catch { /* ignore */ } return out; } /** 序列化场景节点树。payload: { filter?: string } */ private buildTree(payload: any): any { const scene = director.getScene(); const sceneName = this.currentSceneName(); const filter = payload && payload.filter ? String(payload.filter).toLowerCase() : ''; const roots: any[] = []; if (scene) { for (const c of scene.children) { const sub = this.serializeNode(c, filter); if (sub) { roots.push(sub); } } } return { scene: sceneName, filter, roots }; } private serializeNode(node: Node, filter: string): any | null { const name = node.name ?? ''; // 隐藏插件内部节点(如高亮线框覆盖层),避免混入节点树 if (name === '__yyka_highlight__') { return null; } const children: any[] = []; if (node.children && node.children.length) { for (const c of node.children) { const sub = this.serializeNode(c, filter); if (sub) { children.push(sub); } } } const selfMatch = !filter || name.toLowerCase().includes(filter); if (children.length === 0 && !selfMatch) { return null; } return { id: (node as any)._id ?? '', name, active: !!node.activeInHierarchy, layer: node.layer, comps: node.components ? node.components.length : 0, type: this.nodeType(node), children, }; } /** 按优先级取节点主组件类名(供面板显示类型图标)。 */ private nodeType(node: Node): string { const ICONED = [ 'cc.Camera', 'cc.Canvas', 'cc.Label', 'cc.Sprite', 'cc.Button', 'cc.Mask', 'cc.Graphics', 'cc.RichText', 'cc.Layout', 'cc.ScrollView', 'cc.ScrollBar', 'cc.PageView', 'cc.PageViewIndicator', 'cc.EditBox', 'cc.Toggle', 'cc.ToggleContainer', 'cc.ProgressBar', 'cc.Slider', 'cc.Widget', 'cc.SafeArea', 'cc.VideoPlayer', 'cc.WebView', 'cc.MeshRenderer', 'cc.SkinnedMeshRenderer', 'cc.SkinnedMeshBatchRenderer', 'cc.ParticleSystem', 'cc.ParticleSystem2D', 'cc.Terrain', 'cc.TiledMap', 'cc.DirectionalLight', 'cc.PointLight', 'cc.SpotLight', 'cc.SphereLight', 'cc.RangedDirectionalLight', 'cc.AudioSource', 'cc.Animation', 'cc.SkeletalAnimation', 'cc.animation.AnimationController', 'cc.RigidBody', 'cc.RigidBody2D', 'cc.BoxCollider', 'cc.BoxCollider2D', 'cc.CircleCollider2D', 'cc.PolygonCollider2D', 'cc.MeshCollider', 'cc.SphereCollider', 'cc.CapsuleCollider', 'cc.ConeCollider', 'cc.CylinderCollider', 'cc.PlaneCollider', 'cc.SimplexCollider', 'cc.TerrainCollider', 'cc.Line', 'cc.MotionStreak', 'cc.UIMeshRenderer', 'cc.Billboard', 'cc.LODGroup', 'cc.ReflectionProbe', 'cc.LightProbeGroup', 'dragonBones.ArmatureDisplay', 'sp.Skeleton', ]; const set = new Set(ICONED); if (node.components) { for (const c of node.components) { const cls: string = (c as any).__classname__ ?? (c as any).constructor?.name ?? ''; const full = cls.includes('.') ? cls : `cc.${cls}`; if (set.has(full)) { return full; } } } return ''; } /** 按引擎 _id 查找场景中的节点。 */ private findNode(id: string): Node | null { const scene = director.getScene(); if (!scene) { return null; } const stack: Node[] = scene.children.slice(); while (stack.length) { const n = stack.pop()!; if ((n as any)._id === id) { return n; } if (n.children && n.children.length) { stack.push(...n.children); } } return null; } /** 高亮/遮罩指定节点。payload: { id, mode: 'highlight'|'mask' }。id 为空串则清除覆盖层。 */ private setHighlight(payload: any): any { this.clearHighlight(); const id = typeof payload?.id === 'string' ? payload.id : ''; if (!id) { return { ok: true, cleared: true }; } const node = this.findNode(id); if (!node) { return { error: `未找到节点: ${id}` }; } if (!node.activeInHierarchy) { return { error: `节点 ${node.name} 未激活,无法高亮` }; } const ut = node.getComponent(UITransform); if (!ut || ut.contentSize.width <= 0 || ut.contentSize.height <= 0) { return { error: `节点 ${node.name} 没有有效 UITransform,无法高亮` }; } const canvas = this.findCanvasRoot(); if (!canvas) { return { error: '场景中未找到 Canvas/RenderRoot2D,无法渲染高亮' }; } const bb = ut.getBoundingBoxToWorld(); const mode = payload?.mode === 'mask' ? 'mask' : 'highlight'; // 覆盖层挂在 Canvas 下(RenderRoot2D 提供渲染),UI_2D 层、置为最后一个子节点 const overlay = new Node('__yyka_highlight__'); overlay.layer = Layers.Enum.UI_2D; canvas.addChild(overlay); const g = overlay.addComponent(Graphics); // 目标节点世界包围盒 → Canvas 本地坐标 const canvasUt = canvas.getComponent(UITransform); const localMin = canvasUt ? canvasUt.convertToNodeSpaceAR(new Vec3(bb.x, bb.y, 0)) : new Vec3(bb.x, bb.y, 0); const localMax = canvasUt ? canvasUt.convertToNodeSpaceAR(new Vec3(bb.x + bb.width, bb.y + bb.height, 0)) : new Vec3(bb.x + bb.width, bb.y + bb.height, 0); if (mode === 'mask') { // 遮罩:Canvas 外圈半透明黑框,目标区域挖空 const cs = canvasUt ? canvasUt.contentSize : new Size(0, 0); const w = cs.width; const h = cs.height; const minX = localMin.x; const minY = localMin.y; const maxX = localMax.x; const maxY = localMax.y; g.fillColor = new Color(0, 0, 0, 170); g.rect(-w / 2, maxY, w, h / 2 - maxY); // 上 g.rect(-w / 2, -h / 2, w, minY + h / 2); // 下 g.rect(-w / 2, minY, minX + w / 2, maxY - minY); // 左 g.rect(maxX, minY, w / 2 - maxX, maxY - minY); // 右 g.fill(); } else { // 高亮:亮蓝色描边 + 半透明填充 g.lineWidth = 3; g.strokeColor = new Color(59, 130, 246, 255); g.fillColor = new Color(59, 130, 246, 40); g.rect(localMin.x, localMin.y, localMax.x - localMin.x, localMax.y - localMin.y); g.stroke(); g.fill(); } this._highlightOverlay = overlay; return { ok: true, mode, node: node.name }; } /** 移除当前高亮覆盖层(如有)。 */ private clearHighlight(): void { if (this._highlightOverlay) { const ov = this._highlightOverlay; this._highlightOverlay = null; if (ov.isValid) { ov.destroy(); } } } /** 查找场景中可作为 UI 渲染根的节点(带 Canvas 或 RenderRoot2D 组件)。 */ private findCanvasRoot(): Node | null { const scene = director.getScene(); if (!scene) { return null; } const stack: Node[] = scene.children.slice(); while (stack.length) { const n = stack.pop()!; if (n.getComponent(Canvas) || n.getComponent(RenderRoot2D)) { return n; } if (n.children && n.children.length) { stack.push(...n.children); } } return null; } /** 模拟一次触摸点击:按 id 取节点 UI 中心点,或按 x/y 坐标命中最上层 UI 节点,再派发 TOUCH_START/TOUCH_END。 */ private simulateClick(payload: any): any { const scene = director.getScene(); if (!scene) { return { error: '场景不存在' }; } let target: Node | null = null; const point = new Vec2(); if (typeof payload?.id === 'string' && payload.id) { target = this.findNode(payload.id); if (!target) { return { error: `未找到节点: ${payload.id}` }; } const ut = target.getComponent(UITransform); if (!ut) { return { error: `节点 ${target.name} 没有 UITransform,无法确定点击位置` }; } const w = target.worldPosition; point.set(w.x, w.y); } else if (typeof payload?.x === 'number' && typeof payload?.y === 'number') { point.set(payload.x, payload.y); target = this.hitTestUI(point); } else { return { error: '需要参数 id(节点 id)或 x/y(UI 坐标)' }; } if (!target) { return { error: `坐标 (${point.x}, ${point.y}) 未命中任何 UI 节点` }; } const touch = new Touch(); touch.setTouchInfo(0, point.x, point.y); const start = new EventTouch([touch], true, SystemEvent.EventType.TOUCH_START); target.dispatchEvent(start); const end = new EventTouch([touch], true, SystemEvent.EventType.TOUCH_END); target.dispatchEvent(end); return { ok: true, node: target.name, x: point.x, y: point.y }; } /** 节点是否挂在 Canvas/RenderRoot2D 下的 UI 树中。不在 UI 渲染根下的 UITransform 取包围盒会触发引擎崩溃。 */ private hasUiRoot(n: Node): boolean { let cur: Node | null = n; while (cur) { if (cur.getComponent(Canvas) || cur.getComponent(RenderRoot2D)) { return true; } cur = cur.parent; } return false; } /** 按 UI 坐标找命中的最上层节点(遍历顺序靠后的视为更上层)。 */ private hitTestUI(point: Vec2): Node | null { const scene = director.getScene(); if (!scene) { return null; } let hit: Node | null = null; scene.walk((n: Node) => { if (!n.activeInHierarchy || !this.hasUiRoot(n)) { return; } const ut = n.getComponent(UITransform); if (!ut || ut.contentSize.width <= 0 || ut.contentSize.height <= 0) { return; } try { if (ut.getBoundingBoxToWorld().contains(point)) { hit = n; } } catch { /* 个别节点包围盒计算异常,跳过即可,不影响命中判断 */ } }); return hit; } /** 列出当前场景及已加载场景名。 */ private listScenes(): any { const current = this.currentSceneName(); const map = (director as any)._loadedSceneMap; const scenes: string[] = []; if (map && typeof map === 'object') { for (const k of Object.keys(map)) { if (k && !scenes.includes(k)) { scenes.push(k); } } } if (current && !scenes.includes(current)) { scenes.unshift(current); } return { current, scenes }; } /** 运行时加载指定场景(跳转)。 */ private loadScene(payload: any): any { const name = String(payload?.name ?? ''); if (!name) { return { error: '缺少场景名' }; } const ok = director.loadScene(name); return ok ? { ok: true, scene: name } : { error: `场景加载失败或不存在: ${name}` }; } /** 发送一条 JSON 消息给插件。 */ private send(obj: unknown): void { if (this._ws && this._ws.readyState === WebSocket.OPEN) { let payload: string; try { payload = JSON.stringify(obj); } catch { // 序列化失败(如循环引用)时降级为字符串,避免在消息回调里抛异常 payload = JSON.stringify(String(obj)); } if (payload !== undefined) { this._ws.send(payload); } } } private countNodes(): number { const scene = director.getScene(); if (!scene) { return 0; } let n = 0; scene.walk(() => { n++; }); return n; } } // ---- 自动引导:脚本被加载后,在场景启动时创建常驻节点,无需手动摆放/调用 ---- // 注意:预览对脚本做热重载时,本模块会重新求值,但 director 中的常驻节点仍在。 // 因此一律按名字查重,已存在同名节点就跳过,绝不再新建,避免常驻节点重复累积。 (function bootstrap() { /** 递归查找场景中是否存在指定名字的节点。 */ function findByName(root: Node, name: string): Node | null { if (!root) { return null; } if (root.name === name) { return root; } const children = root.children; if (children) { for (const c of children) { const found = findByName(c, name); if (found) { return found; } } } return null; } director.on(Director.EVENT_AFTER_SCENE_LAUNCH, () => { const scene: any = director.getScene(); const sceneName = String((scene && (scene.name || scene._name)) || ''); if (RuntimeBridge._instance) { // 场景切换后刷新场景名缓存,供 getScene() 未就绪时兜底 RuntimeBridge._instance._sceneName = sceneName; return; } if (!scene) { return; } // 已存在同名常驻节点(可能是本模块之前的实例,或热重载前遗留)则跳过 if (findByName(scene, PERSIST_NODE_NAME)) { return; } const node = new Node(PERSIST_NODE_NAME); scene.addChild(node); const inst = node.addComponent(RuntimeBridge) as RuntimeBridge; // onLoad 中会 addPersistRootNode 并 connect if (sceneName && !inst._sceneName) { inst._sceneName = sceneName; // onLoad 时 getScene() 可能尚未就绪,补上本次回调已拿到的场景名 } }); })();