You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1844 lines
74 KiB

2 weeks ago
// ============================================================================
// 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.87';
// ============================================================================
// 全局 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 = DEFAULT_HOST;
@property({ tooltip: 'WS 服务端口' })
port = DEFAULT_PORT;
@property({ tooltip: '断开后自动重连延迟(秒)' })
reconnectDelay = 2;
@property({ tooltip: '启用时以该帧率向插件推送预览画面(0 表示关闭)' })
previewFps = 0;
private _ws: WebSocket | null = null;
private _timer: ReturnType<typeof setInterval> | null = null;
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private _frameTimer: ReturnType<typeof setInterval> | 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<AudioSource, number>();
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<string>();
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<string>();
// 白名单:引擎内部序列化字段 → 公开访问器(如 Renderable 的 _materials → sharedMaterials),
// 避免把 _node/_parent/_children 等内部结构字段暴露出来。
const PUBLIC_MAP: Record<string, string> = { _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() 可能尚未就绪,补上本次回调已拿到的场景名
}
});
})();