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
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.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<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() 可能尚未就绪,补上本次回调已拿到的场景名
|
||
|
|
}
|
||
|
|
});
|
||
|
|
})();
|