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.
1658 lines
270 KiB
1658 lines
270 KiB
|
2 weeks ago
|
"use strict";
|
||
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||
|
|
};
|
||
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||
|
|
// @ts-ignore
|
||
|
|
const package_json_1 = __importDefault(require("../../../../package.json"));
|
||
|
|
/**
|
||
|
|
* 预览:地址读取/刷新/连通性探测 + 无 iframe 的 DOM 渲染 + 左右分隔条拖拽。
|
||
|
|
*
|
||
|
|
* 面板内直接渲染游戏(不用 iframe)的加载链路:
|
||
|
|
* 1. 按序加载引擎脚本(<script> 标签跨域加载 JS 不受 CORS 限制)
|
||
|
|
* 2. import-map 内的相对/绝对路径基于「面板 origin」解析会 404,
|
||
|
|
* 因此先读取原始 map 内容,把所有路径重写为预览服务绝对 URL 后再注入
|
||
|
|
* 3. 最终 System.import(preview-app) 与引擎内部 fetch 资源属于跨域请求,
|
||
|
|
* 是否成功取决于预览服务器是否返回 CORS 头(实测验证点)
|
||
|
|
*/
|
||
|
|
/** socket.io 最小 stub:EventEmitter 语义,满足 preview-app 的 n.on()/t.emit() 调用。
|
||
|
|
* 不发起真实 socket.io 连接(面板 origin 与预览服务不一致,连接必然失败且无意义)。 */
|
||
|
|
function socketIoStub() {
|
||
|
|
const handlers = {};
|
||
|
|
return {
|
||
|
|
on(evt, fn) {
|
||
|
|
(handlers[evt] = handlers[evt] || []).push(fn);
|
||
|
|
return this;
|
||
|
|
},
|
||
|
|
off(evt) {
|
||
|
|
delete handlers[evt];
|
||
|
|
return this;
|
||
|
|
},
|
||
|
|
emit(evt, ...args) {
|
||
|
|
(handlers[evt] || []).slice().forEach((fn) => fn(...args));
|
||
|
|
return this;
|
||
|
|
},
|
||
|
|
close() {
|
||
|
|
for (const k of Object.keys(handlers)) {
|
||
|
|
delete handlers[k];
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/** 设备尺寸表(键与真实预览服务渲染的 data-device 完全一致)。
|
||
|
|
* 面板内 exactFitScreen=false(SubFrame)模式下引擎画布跟随 #GameDiv 容器尺寸、
|
||
|
|
* 忽略 screen.windowSize,因此设备切换要在前端把 #GameDiv 设成目标比例居中显示。
|
||
|
|
* 精简集:每档只留一个代表机型,分辨率不重复;键必须能被 preview-app._devices 识别。 */
|
||
|
|
const DEVICE_SIZES = {
|
||
|
|
'Default': { w: 960, h: 640, label: '设计分辨率' },
|
||
|
|
'Apple iPhone 12; 13; 14; 12/13 Pro': { w: 390, h: 844, label: 'iPhone 12/13/14' },
|
||
|
|
'Apple iPhone 14 Pro': { w: 393, h: 852, label: 'iPhone 14 Pro' },
|
||
|
|
'Apple iPhone 12/13 Pro Max; 14 Plus': { w: 428, h: 926, label: 'iPhone Pro Max/14 Plus' },
|
||
|
|
'Apple iPhone XR; 11': { w: 414, h: 896, label: 'iPhone XR/11' },
|
||
|
|
'Apple iPhone X; XS; 11 Pro; 12/13 Mini': { w: 375, h: 812, label: 'iPhone X/XS' },
|
||
|
|
'Apple iPad 10.2': { w: 1620, h: 2160, label: 'iPad 10.2' },
|
||
|
|
'HUAWEI Mate 40 Pro': { w: 448, h: 924, label: 'HUAWEI Mate 40 Pro' },
|
||
|
|
'OPPO Reno 2': { w: 360, h: 800, label: 'OPPO Reno 2' },
|
||
|
|
'Xiaomi Redmi 8': { w: 360, h: 760, label: 'Xiaomi Redmi 8' },
|
||
|
|
'Lenovo Legion 2 Pro': { w: 360, h: 820, label: 'Lenovo Legion 2 Pro' },
|
||
|
|
};
|
||
|
|
exports.default = {
|
||
|
|
async initPreviewInfo() {
|
||
|
|
// 设计分辨率默认 960×640,随后用项目真实配置覆盖(Default 等比视口以此为基准)
|
||
|
|
this.prefDesignW = 960;
|
||
|
|
this.prefDesignH = 640;
|
||
|
|
try {
|
||
|
|
const [info, proj] = await Promise.all([
|
||
|
|
Editor.Message.request(package_json_1.default.name, 'get-preview-info'),
|
||
|
|
Editor.Message.request(package_json_1.default.name, 'get-projects-settings'),
|
||
|
|
]);
|
||
|
|
if (info && info.url) {
|
||
|
|
this.previewUrl = info.url;
|
||
|
|
}
|
||
|
|
const d = proj && proj.designResolution;
|
||
|
|
if (d && Number(d.width) > 0 && Number(d.height) > 0) {
|
||
|
|
this.prefDesignW = Number(d.width);
|
||
|
|
this.prefDesignH = Number(d.height);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_a) {
|
||
|
|
// 保持默认地址/分辨率
|
||
|
|
}
|
||
|
|
},
|
||
|
|
/** 当前项目设计分辨率(读取失败回到 960×640)。 */
|
||
|
|
prefDesign() {
|
||
|
|
return {
|
||
|
|
w: Number(this.prefDesignW) > 0 ? Number(this.prefDesignW) : 960,
|
||
|
|
h: Number(this.prefDesignH) > 0 ? Number(this.prefDesignH) : 640,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
/** 地址栏回车 / 点「刷新」/ 遮罩重试:重新渲染游戏画面。 */
|
||
|
|
onLoadPreview() {
|
||
|
|
this.previewLoaded = false;
|
||
|
|
this.previewRenderErr = '';
|
||
|
|
this.renderPreview();
|
||
|
|
},
|
||
|
|
// ===== 预览工具条:新版面工具条通过驱动「隐藏 H5 工具条」与引擎联动 =====
|
||
|
|
// 引擎 preview-app 用 document.querySelector('#view-select'/'#btn-rotate'/'#input-set-fps'/
|
||
|
|
// '#btn-show-fps'/'#btn-pause'/'#btn-step') 绑定事件,面板内的该工具条被 CSS 隐藏,
|
||
|
|
// 只作数据契约。这里用真实 DOM 事件模拟用户操作,与手点效果一致。
|
||
|
|
/** 设备/分辨率切换:点选隐藏 #view-select 的对应选项。 */
|
||
|
|
onPreviewDevice(device) {
|
||
|
|
this.previewDevice = device;
|
||
|
|
const el = document.querySelector('#view-select');
|
||
|
|
if (!el)
|
||
|
|
return;
|
||
|
|
el.setAttribute('value', device);
|
||
|
|
const name = el.querySelector('#name');
|
||
|
|
if (name)
|
||
|
|
name.textContent = device;
|
||
|
|
el.querySelectorAll('.options li').forEach((li) => {
|
||
|
|
if (li.getAttribute('data-device') === device) {
|
||
|
|
li.classList.add('selected');
|
||
|
|
li.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||
|
|
li.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
|
||
|
|
li.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
li.classList.remove('selected');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
// 面板内 SubFrame 模式引擎画布跟随 #GameDiv 容器尺寸,设备切换需在此设容器比例,
|
||
|
|
// 否则 preview-app 只改 screen.windowSize 不产生视觉变化(真实 H5 才生效)。
|
||
|
|
this.applyDeviceViewport(device);
|
||
|
|
},
|
||
|
|
/** 设备/缩放视口:把 #GameDiv 设成 设备比例 × 缩放因子 的真实尺寸并居中。
|
||
|
|
* 引擎 SubFrame 模式按 #GameDiv 实际尺寸重建画布,因此画面比例与输入映射始终一致,
|
||
|
|
* 任意缩放比下点击都不会错位。横竖屏方向(previewRotated)交换宽高。
|
||
|
|
* 非尺寸映射(FullScreen/Webpage)回到铺满、不应用缩放。 */
|
||
|
|
applyDeviceViewport(device, zoom = this.previewZoom) {
|
||
|
|
const gd = document.getElementById('GameDiv');
|
||
|
|
if (!gd)
|
||
|
|
return;
|
||
|
|
let d = DEVICE_SIZES[device];
|
||
|
|
const host = document.getElementById('yyka-game-host');
|
||
|
|
if (!d || !host) {
|
||
|
|
gd.style.width = '';
|
||
|
|
gd.style.height = '';
|
||
|
|
gd.style.margin = '';
|
||
|
|
window.dispatchEvent(new Event('resize'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (device === 'Default') {
|
||
|
|
// 设计分辨率取项目真实配置(勿用固定 960×640)
|
||
|
|
const pd = this.prefDesign();
|
||
|
|
d = { w: pd.w, h: pd.h, label: '设计分辨率' };
|
||
|
|
}
|
||
|
|
let dw = d.w, dh = d.h;
|
||
|
|
if (this.previewRotated) {
|
||
|
|
dw = d.h;
|
||
|
|
dh = d.w; // 竖屏 ↔ 横屏,交换宽高
|
||
|
|
}
|
||
|
|
// 预览区(.game-view)基准,等比放入该设备(zoom=1 恰好放入)。
|
||
|
|
// 设备模式只留 12px 外边距,外壳尽量大,让游戏窗口撑满壳内部、四角被圆角裁切成全面屏;
|
||
|
|
// Default 留 32px。
|
||
|
|
const isDev = device !== 'Default' && !!d;
|
||
|
|
const pad = isDev ? 12 : 32;
|
||
|
|
const aw = Math.max(1, host.clientWidth - pad);
|
||
|
|
const ah = Math.max(1, host.clientHeight - pad);
|
||
|
|
let w = aw, h = aw * (dh / dw);
|
||
|
|
if (h > ah) {
|
||
|
|
h = ah;
|
||
|
|
w = ah * (dw / dh);
|
||
|
|
}
|
||
|
|
const z = Math.max(0.2, Math.min(8, zoom));
|
||
|
|
const winW = Math.max(10, w * z), winH = Math.max(10, h * z);
|
||
|
|
const frame = host.querySelector('.device-frame');
|
||
|
|
const isDevice = !!d && device !== 'Default';
|
||
|
|
if (frame)
|
||
|
|
frame.classList.toggle('device', isDevice);
|
||
|
|
if (isDevice && frame) {
|
||
|
|
// 真机比例:屏幕(#GameDiv)按设备宽高比尽量放大居中;外壳跟随屏幕,
|
||
|
|
// 边框贴着屏幕四周一起缩放(外壳尺寸 = 屏幕 + 细边框)。
|
||
|
|
// 旋转(previewRotated)已在 dw/dh 交换过,因此外壳形状会随横竖屏变化。
|
||
|
|
const bezX = 42, bezY = 54; // 预留余量:外壳整体在预览区内缩一圈,四周露出背景,避免贴平成"平面"
|
||
|
|
const mw = host.clientWidth, mh = host.clientHeight;
|
||
|
|
let sw = mw - bezX, sh = sw * (dh / dw);
|
||
|
|
if (sh > mh - bezY) {
|
||
|
|
sh = mh - bezY;
|
||
|
|
sw = sh * (dw / dh);
|
||
|
|
}
|
||
|
|
let fw = sw * z, fh = sh * z;
|
||
|
|
// 缩放上限:放大时整体不超出预览视口,四周留 effPad 余地,等比收缩防溢出
|
||
|
|
const effPad = 12;
|
||
|
|
const maxW = mw - effPad, maxH = mh - effPad;
|
||
|
|
if (fw > maxW || fh > maxH) {
|
||
|
|
const r = Math.min(maxW / fw, maxH / fh);
|
||
|
|
fw *= r;
|
||
|
|
fh *= r;
|
||
|
|
}
|
||
|
|
frame.style.width = Math.max(20, Math.round(fw)) + 'px';
|
||
|
|
frame.style.height = Math.max(20, Math.round(fh)) + 'px';
|
||
|
|
frame.style.margin = 'auto';
|
||
|
|
gd.style.width = '100%';
|
||
|
|
gd.style.height = '100%';
|
||
|
|
gd.style.margin = '0';
|
||
|
|
// 灵动岛随缩放联动:取机身短边的一定比例作为宽度,
|
||
|
|
// 横竖屏(短边不变)尺寸稳定,缩放大小时同步缩放(使用像素避免横屏相对放大)。
|
||
|
|
const iso = frame.querySelector('.dynamic-island');
|
||
|
|
if (iso) {
|
||
|
|
iso.style.width = Math.max(24, Math.round(Math.min(fw, fh) * 0.26)) + 'px';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// 普通/Default 视口:按设计分辨率等比居中,同样限幅不超视口并留边距。
|
||
|
|
// 尺寸载体用 .device-frame 外壳而非 #GameDiv:面板 CSS 对 #GameDiv 有
|
||
|
|
// width/height:100%!important,写在其上的内联像素不生效;若外壳保持
|
||
|
|
// max-content,整条链会回落成 canvas 元素固有尺寸(引擎把 canvas.width
|
||
|
|
// 设成 screen.windowSize = #GameDiv.clientWidth×dpr,dpr>1 时不断放大),
|
||
|
|
// 导致游戏窗口比视口大、被裁剪。给外壳写确定的 fit 像素后 #GameDiv=100%
|
||
|
|
// 跟随,尺寸完全确定,不再有 max-content 反馈放大(与设备模式同构)。
|
||
|
|
const effPad = 32;
|
||
|
|
const maxW = host.clientWidth - effPad, maxH = host.clientHeight - effPad;
|
||
|
|
let winW2 = w * z, winH2 = h * z;
|
||
|
|
if (winW2 > maxW || winH2 > maxH) {
|
||
|
|
const r = Math.min(maxW / winW2, maxH / winH2);
|
||
|
|
winW2 *= r;
|
||
|
|
winH2 *= r;
|
||
|
|
}
|
||
|
|
const pxW = Math.max(20, Math.round(winW2)) + 'px';
|
||
|
|
const pxH = Math.max(20, Math.round(winH2)) + 'px';
|
||
|
|
if (frame) {
|
||
|
|
frame.style.width = pxW;
|
||
|
|
frame.style.height = pxH;
|
||
|
|
frame.style.margin = 'auto';
|
||
|
|
gd.style.width = '100%';
|
||
|
|
gd.style.height = '100%';
|
||
|
|
gd.style.margin = '0';
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
gd.style.width = pxW;
|
||
|
|
gd.style.height = pxH;
|
||
|
|
gd.style.margin = 'auto';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// 引擎画面铺满策略:设备模式精确铺满(屏幕顶满边框内缘,无内部黑边),
|
||
|
|
// 普通/Default 保持等比适配。screen._exactFitScreen 由 Cocos SubFrame adapter 读取。
|
||
|
|
try {
|
||
|
|
const ccw = window.cc || window.cclegacy;
|
||
|
|
const sc = ccw && (ccw.screen || (ccw.game && ccw.game.screen));
|
||
|
|
const targets = sc ? [sc, sc.screenAdapter] : [];
|
||
|
|
for (const t of targets) {
|
||
|
|
if (t && '_exactFitScreen' in t)
|
||
|
|
t._exactFitScreen = !!isDevice;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* 引擎未就绪时忽略 */
|
||
|
|
}
|
||
|
|
window.dispatchEvent(new Event('resize'));
|
||
|
|
},
|
||
|
|
/** 强制走 SubFrame 视口(画布跟随 #GameDiv 尺寸,避免用整窗尺寸生成过大的画布导致
|
||
|
|
* 画面比视口大/被裁剪)。批量覆盖所有已知 screenAdapter 来源,并重试等待 System 异步
|
||
|
|
* 模块就绪;每成功覆盖一次即重放视口 + 触发 resize 重算。 */
|
||
|
|
async forceSubFrameViewport(alive) {
|
||
|
|
const apply = (adapter) => {
|
||
|
|
if (adapter && '_exactFitScreen' in adapter && adapter._exactFitScreen !== false) {
|
||
|
|
adapter._exactFitScreen = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
// 1) 同步来源:window.cc.screen 及其 screenAdapter
|
||
|
|
const ccw = window.cc || window.cclegacy;
|
||
|
|
if (ccw && ccw.screen) {
|
||
|
|
apply(ccw.screen);
|
||
|
|
apply(ccw.screen.screenAdapter);
|
||
|
|
apply(ccw.game && ccw.game.screen);
|
||
|
|
apply(ccw.game && ccw.game.screen.screenAdapter);
|
||
|
|
}
|
||
|
|
// 2) 异步来源:System.import 得到的真实 screenAdapter,重试直至可用或超时(刷新前返回)
|
||
|
|
const SysMod = window.System;
|
||
|
|
const deadline = Date.now() + 3000;
|
||
|
|
while (alive() && Date.now() < deadline) {
|
||
|
|
let got = false;
|
||
|
|
if (SysMod && typeof SysMod.import === 'function') {
|
||
|
|
try {
|
||
|
|
const saMod = await SysMod.import('q-bundled:///fs/pal/screen-adapter/web/screen-adapter.js');
|
||
|
|
const sa = (saMod && (saMod.screenAdapter || (saMod.default && saMod.default.screenAdapter))) || null;
|
||
|
|
if (sa) {
|
||
|
|
apply(sa);
|
||
|
|
got = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_a) {
|
||
|
|
/* 模块未就绪,继续重试 */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (got)
|
||
|
|
break;
|
||
|
|
await new Promise((r) => setTimeout(r, 120));
|
||
|
|
}
|
||
|
|
if (alive()) {
|
||
|
|
this.applyDeviceViewport(this.previewDevice || 'Default');
|
||
|
|
window.dispatchEvent(new Event('resize'));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
onPreviewRotate() {
|
||
|
|
const btn = document.querySelector('#btn-rotate');
|
||
|
|
if (btn)
|
||
|
|
btn.click();
|
||
|
|
this.previewRotated = !this.previewRotated; // 面板侧视觉指示
|
||
|
|
// 设备/默认视口模式下旋转后按新方向重算 #GameDiv 比例
|
||
|
|
if (this.previewDevice && DEVICE_SIZES[this.previewDevice]) {
|
||
|
|
this.applyDeviceViewport(this.previewDevice);
|
||
|
|
}
|
||
|
|
this.syncPreviewState();
|
||
|
|
},
|
||
|
|
/** 帧率上限设置:写隐藏 #input-set-fps 并派发 input/change。 */
|
||
|
|
onPreviewFps(val) {
|
||
|
|
const n = Math.max(1, Math.min(240, Number(val) || 60));
|
||
|
|
this.previewFps = n;
|
||
|
|
const inp = document.querySelector('#input-set-fps');
|
||
|
|
if (inp) {
|
||
|
|
inp.value = String(n);
|
||
|
|
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
||
|
|
inp.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
/** 显示FPS开关:触发隐藏 #btn-show-fps 并回读激活态。 */
|
||
|
|
onPreviewShowFps() {
|
||
|
|
const btn = document.querySelector('#btn-show-fps');
|
||
|
|
if (btn)
|
||
|
|
btn.click();
|
||
|
|
this.syncPreviewState();
|
||
|
|
},
|
||
|
|
/** 暂停/继续:触发隐藏 #btn-pause。 */
|
||
|
|
onPreviewPause() {
|
||
|
|
const btn = document.querySelector('#btn-pause');
|
||
|
|
if (btn)
|
||
|
|
btn.click();
|
||
|
|
this.syncPreviewState();
|
||
|
|
},
|
||
|
|
/** 步进一帧:仅暂停态可用,触发隐藏 #btn-step。 */
|
||
|
|
onPreviewStep() {
|
||
|
|
const btn = document.querySelector('#btn-step');
|
||
|
|
if (btn)
|
||
|
|
btn.click();
|
||
|
|
},
|
||
|
|
// ─────────── 窗囗级全局静音(default / 预览面板共享)───────────
|
||
|
|
onPreviewMute() {
|
||
|
|
this.previewMuted = !this.previewMuted;
|
||
|
|
this._applyMute();
|
||
|
|
},
|
||
|
|
/** 确保 context 有 master gain(如无则注入),并返回它。 */
|
||
|
|
_ensureMaster(c) {
|
||
|
|
if (!c || c.__yykaMaster)
|
||
|
|
return c && c.__yykaMaster;
|
||
|
|
try {
|
||
|
|
const mg = c.createGain();
|
||
|
|
c.__yykaMaster = mg;
|
||
|
|
const list = this.__muteCtxs();
|
||
|
|
if (list.indexOf(c) < 0)
|
||
|
|
list.push(c);
|
||
|
|
return mg;
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
/** context 采集列表(模块级,换场景/重渲染保持)。 */
|
||
|
|
__muteCtxs() {
|
||
|
|
const key = '__yykaAudioCtxs';
|
||
|
|
const w = window;
|
||
|
|
if (!w[key])
|
||
|
|
w[key] = [];
|
||
|
|
return w[key];
|
||
|
|
},
|
||
|
|
/** 全局静音标志:进入静音前先把当前实例状态写进全局,供 connect 劫持读取。 */
|
||
|
|
__muteGlobal() {
|
||
|
|
const key = '__yykaGlobalMute';
|
||
|
|
const w = window;
|
||
|
|
return { get: () => !!w[key], set: (v) => { w[key] = v; } };
|
||
|
|
},
|
||
|
|
/** 应用静音到单个 context:master gain 实时开关(g=0 无声 / g=1 有声)。 */
|
||
|
|
_applyMuteToCtx(c, muted) {
|
||
|
|
try {
|
||
|
|
if (!c)
|
||
|
|
return;
|
||
|
|
if (c.__yykaMaster) {
|
||
|
|
c.__yykaMaster.gain.value = muted ? 0 : 1;
|
||
|
|
}
|
||
|
|
else if (c.createGain) {
|
||
|
|
const mg = this._ensureMaster(c);
|
||
|
|
if (mg) {
|
||
|
|
mg.gain.value = muted ? 0 : 1;
|
||
|
|
try {
|
||
|
|
mg.connect(c.destination);
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!muted && typeof c.resume === 'function' && c.state === 'suspended') {
|
||
|
|
try {
|
||
|
|
c.resume();
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
},
|
||
|
|
/** 应用全局静音:写标志 + 引擎音量 + 全部 context master gain + 媒体元素。无轮询。 */
|
||
|
|
_applyMute() {
|
||
|
|
const muted = !!this.previewMuted;
|
||
|
|
this.__muteGlobal().set(muted);
|
||
|
|
const lg = window.cc || window.cclegacy;
|
||
|
|
const ae = lg && (lg.AudioEngine || (lg.audio && lg.audio.AudioEngine));
|
||
|
|
if (ae) {
|
||
|
|
try {
|
||
|
|
if (typeof ae.setMusicVolume === 'function')
|
||
|
|
ae.setMusicVolume(muted ? 0 : 1);
|
||
|
|
if (typeof ae.setEffectsVolume === 'function')
|
||
|
|
ae.setEffectsVolume(muted ? 0 : 1);
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
}
|
||
|
|
const ctxs = this.__muteCtxs();
|
||
|
|
for (const c of ctxs)
|
||
|
|
this._applyMuteToCtx(c, muted);
|
||
|
|
try {
|
||
|
|
document.querySelectorAll('audio, video').forEach((el) => {
|
||
|
|
try {
|
||
|
|
el.muted = muted;
|
||
|
|
if (muted)
|
||
|
|
el.pause();
|
||
|
|
else
|
||
|
|
el.play().catch(() => { });
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
});
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
},
|
||
|
|
/**
|
||
|
|
* 安装窗囗级音频静音 hook(事件驱动,无轮询):
|
||
|
|
* 1) 包装 AudioContext 构造器:新建即挂 master 并采集;
|
||
|
|
* 2) 劫持 AudioNode.prototype.connect:任何节点连到 context.destination 时接进 master gain。
|
||
|
|
* 即使引擎复用模块级缓存的原始 context 单例,connect 时也会动态注入 master,
|
||
|
|
* 声音必经 master,g=0 立即无声(攻击音效瞬时哑,无需轮询)。
|
||
|
|
*/
|
||
|
|
_hookAudioCtx() {
|
||
|
|
const w = window;
|
||
|
|
const selfRef = this;
|
||
|
|
const hijackConnect = (NodeCtor) => {
|
||
|
|
if (!NodeCtor || NodeCtor.__yykaConnHook)
|
||
|
|
return;
|
||
|
|
NodeCtor.__yykaConnHook = true;
|
||
|
|
const orig = NodeCtor.prototype.connect;
|
||
|
|
NodeCtor.prototype.connect = function (dest, ...rest) {
|
||
|
|
try {
|
||
|
|
const ctx = this && this.context;
|
||
|
|
// master 节点自身连到输出时要放行(直连 destination),否则会把自己
|
||
|
|
// 重定向到自身形成自连,音频图被破坏→永久静音(无论开关状态)。
|
||
|
|
if (ctx && this === ctx.__yykaMaster) {
|
||
|
|
return orig.apply(this, [dest, ...rest]);
|
||
|
|
}
|
||
|
|
if (ctx && ctx.destination && dest === ctx.destination) {
|
||
|
|
let mg = ctx.__yykaMaster;
|
||
|
|
if (!mg && ctx.createGain) {
|
||
|
|
mg = ctx.createGain();
|
||
|
|
ctx.__yykaMaster = mg;
|
||
|
|
const list = selfRef.__muteCtxs();
|
||
|
|
if (list.indexOf(ctx) < 0)
|
||
|
|
list.push(ctx);
|
||
|
|
try {
|
||
|
|
orig.call(mg, ctx.destination);
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
}
|
||
|
|
if (mg) {
|
||
|
|
mg.gain.value = selfRef.__muteGlobal().get() ? 0 : 1;
|
||
|
|
dest = mg;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
return orig.apply(this, [dest, ...rest]);
|
||
|
|
};
|
||
|
|
};
|
||
|
|
const AudioNodeCtor = w.AudioNode;
|
||
|
|
if (AudioNodeCtor) {
|
||
|
|
try {
|
||
|
|
hijackConnect(AudioNodeCtor);
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
}
|
||
|
|
const wrapCtor = (Ctor, name) => {
|
||
|
|
if (!Ctor || Ctor.__patchYyka)
|
||
|
|
return;
|
||
|
|
const Old = Ctor;
|
||
|
|
const Self = function (...a) {
|
||
|
|
const c = new Old(...a);
|
||
|
|
try {
|
||
|
|
const mg = selfRef._ensureMaster(c);
|
||
|
|
if (mg) {
|
||
|
|
mg.connect(c.destination);
|
||
|
|
mg.gain.value = selfRef.__muteGlobal().get() ? 0 : 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
return c;
|
||
|
|
};
|
||
|
|
Self.prototype = Old.prototype;
|
||
|
|
Self.__patchYyka = true;
|
||
|
|
w[name] = Self;
|
||
|
|
};
|
||
|
|
wrapCtor(w.AudioContext, 'AudioContext');
|
||
|
|
wrapCtor(w.webkitAudioContext, 'webkitAudioContext');
|
||
|
|
try {
|
||
|
|
const OE = document.createElement.bind(document);
|
||
|
|
document.createElement = function (tag, ...rest) {
|
||
|
|
const el = OE(tag, ...rest);
|
||
|
|
const t = String(tag).toLowerCase();
|
||
|
|
if (t === 'audio' || t === 'video') {
|
||
|
|
try {
|
||
|
|
if (selfRef.__muteGlobal().get())
|
||
|
|
el.muted = true;
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
}
|
||
|
|
return el;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
},
|
||
|
|
/** 刷新预览:重置缩放并重新渲染。 */
|
||
|
|
doRefreshPreview() {
|
||
|
|
this.previewZoom = 1;
|
||
|
|
this.previewLoaded = false;
|
||
|
|
this.previewRenderErr = '';
|
||
|
|
this.renderPreview();
|
||
|
|
},
|
||
|
|
/** 重渲染前拆解上一次游戏实例:停主循环、清调度器(计时器/update)、停音频、释放资源。
|
||
|
|
* 面板内无法像浏览器预览那样整页 reload,cc 全局是单例,旧实例若不清理,
|
||
|
|
* 刷新后音乐/计时器/主循环会残留叠加、不关闭面板就无法复原。
|
||
|
|
* 每步单独 try/catch 以防某 API 缺失/报错阻断重载流程。 */
|
||
|
|
teardownPreview() {
|
||
|
|
const lg = window.cc || window.cclegacy;
|
||
|
|
if (!lg || !lg.game)
|
||
|
|
return; // 首次渲染尚无实例,跳过
|
||
|
|
const stop = (fn) => {
|
||
|
|
try {
|
||
|
|
fn();
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* 忽略单个清理失败 */
|
||
|
|
}
|
||
|
|
};
|
||
|
|
// 停主循环更新(暂停 update/游戏逻辑)
|
||
|
|
const g = lg.game;
|
||
|
|
stop(() => g.pause && g.pause());
|
||
|
|
stop(() => g.pauseByEngine && g.pauseByEngine());
|
||
|
|
// 停 director 更新
|
||
|
|
const dir = lg.director;
|
||
|
|
stop(() => dir.pause && dir.pause());
|
||
|
|
// 清调度器:项目用 scheduleOnce/setTimeout 等定时器
|
||
|
|
stop(() => {
|
||
|
|
const sch = dir && dir.getScheduler && dir.getScheduler();
|
||
|
|
sch && sch.unscheduleAll && sch.unscheduleAll();
|
||
|
|
});
|
||
|
|
// 停止音频(项目音乐/音效,AudioEngine 全局 close 停全部)
|
||
|
|
stop(() => {
|
||
|
|
const ae = lg.AudioEngine || (lg.audio && lg.audio.AudioEngine);
|
||
|
|
ae && ae.close && ae.close();
|
||
|
|
});
|
||
|
|
// 释放已加载资源,避免新场景复用旧资源句柄造成叠加
|
||
|
|
stop(() => lg.assetManager && lg.assetManager.releaseAll && lg.assetManager.releaseAll());
|
||
|
|
},
|
||
|
|
/** 从隐藏 H5 工具条回读状态,同步面板按钮激活态(loading 完整后调用一次 + 每次操作后)。 */
|
||
|
|
syncPreviewState() {
|
||
|
|
const showReady = () => {
|
||
|
|
const show = document.querySelector('#btn-show-fps');
|
||
|
|
const pause = document.querySelector('#btn-pause');
|
||
|
|
const step = document.querySelector('#btn-step');
|
||
|
|
let paused = false;
|
||
|
|
if (pause)
|
||
|
|
paused = pause.classList.contains('checked');
|
||
|
|
if (!paused && step)
|
||
|
|
paused = getComputedStyle(step).display !== 'none';
|
||
|
|
this.previewShowFps = !!(show && show.classList.contains('checked'));
|
||
|
|
this.previewPaused = paused;
|
||
|
|
};
|
||
|
|
// 等待 #GameCanvas 就绪后再读;宿主重建瞬间可能取到空 DOM
|
||
|
|
const waitFor = () => {
|
||
|
|
if (document.querySelector('#btn-pause')) {
|
||
|
|
showReady();
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
window.setTimeout(waitFor, 60);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
waitFor();
|
||
|
|
},
|
||
|
|
/** 预览区滚轮缩放:调整缩放因子并重算 #GameDiv 真实尺寸(等比放大缩小,居中)。
|
||
|
|
* 用对称乘法步进(放大 ×1.12 / 缩小 ÷1.12)保证可逆;引擎按容器尺寸重建画布,
|
||
|
|
* 因此放大缩小过程输入映射始终正确,不会出现放大了点不到的情况。 */
|
||
|
|
onPreviewWheel(e) {
|
||
|
|
e.preventDefault();
|
||
|
|
// 关键:拦截滚轮传播。否则事件还会继续传到本插件加载的编辑器 preview-app
|
||
|
|
// Ui(其滚轮处理器会把设计分辨率重置成默认横屏 960×640,导致竖屏变横屏)。
|
||
|
|
// 这里在捕获阶段阻止冒泡到内容层,保证只由我们自己的 applyDeviceViewport 控制缩放。
|
||
|
|
e.stopPropagation();
|
||
|
|
e.stopImmediatePropagation();
|
||
|
|
if (!document.getElementById('GameDiv'))
|
||
|
|
return;
|
||
|
|
const zoomIn = e.deltaY < 0;
|
||
|
|
const step = Math.pow(1.12, zoomIn ? 1 : -1);
|
||
|
|
this.previewZoom = Math.min(8, Math.max(0.2, Math.round(this.previewZoom * step * 100) / 100));
|
||
|
|
// 仅对尺寸映射的设备/默认视口应用缩放(FullScreen/Webpage 铺满不缩放)
|
||
|
|
const dev = this.previewDevice || 'Default';
|
||
|
|
if (DEVICE_SIZES[dev]) {
|
||
|
|
this.applyDeviceViewport(dev);
|
||
|
|
}
|
||
|
|
// 兜底:编辑器 preview-app Ui 可能在同一次滚轮/resize 里异步把设计分辨率重置成
|
||
|
|
// 默认横屏(960×640)。下一帧再重放一次视口(等比 + _exactFitScreen=false),
|
||
|
|
// 把设备/默认设计分辨率强制拉回项目真实竖屏比例。
|
||
|
|
cancelAnimationFrame(this._wheelReapply);
|
||
|
|
this._wheelReapply = requestAnimationFrame(() => {
|
||
|
|
this._wheelReapply = 0;
|
||
|
|
const dev0 = this.previewDevice || 'Default';
|
||
|
|
if (DEVICE_SIZES[dev0])
|
||
|
|
this.applyDeviceViewport(dev0);
|
||
|
|
});
|
||
|
|
},
|
||
|
|
/** 把相对/绝对路径拼到预览服务 origin 上(面板 origin 解析会 404)。 */
|
||
|
|
abs(path) {
|
||
|
|
const base = (this.previewUrl || '').trim().replace(/\/+$/, '') || 'http://localhost:7456';
|
||
|
|
if (/^(https?:)?\/\//i.test(path) || path.startsWith('data:') || path.startsWith('blob:')) {
|
||
|
|
return path;
|
||
|
|
}
|
||
|
|
return base + (path.startsWith('/') ? path : '/' + path);
|
||
|
|
},
|
||
|
|
/** 游戏宿主容器:创建在顶层 document.body 上。面板内容可能渲染在 shadow DOM 中
|
||
|
|
* (Cocos ui-panel 机制),Ui 构造的 document.querySelector 无法穿透 shadow root,
|
||
|
|
* 因此游戏 DOM 必须挂在顶层 document 上才能被找到。 */
|
||
|
|
ensureGameHost() {
|
||
|
|
let host = document.getElementById('yyka-game-host');
|
||
|
|
if (!host) {
|
||
|
|
host = document.createElement('div');
|
||
|
|
host.id = 'yyka-game-host';
|
||
|
|
// flex column:toolbar 隐藏后 content(flex:auto) 才能撑满宿主高度
|
||
|
|
// z-index 40:盖住面板主体内容(z-index ≤ 10),
|
||
|
|
// 但低于 .mcp-mask(50)/.node-picker-mask(9999),避免遮盖面板弹层
|
||
|
|
host.style.cssText =
|
||
|
|
'position:fixed;z-index:40;overflow:hidden;background:#171717;display:none;' +
|
||
|
|
'flex-direction:column;align-items:stretch;';
|
||
|
|
document.body.appendChild(host);
|
||
|
|
}
|
||
|
|
// 滚轮缩放:游戏放大后 #GameDiv 会超出预览区,若把 wheel 绑在宿主上,
|
||
|
|
// 当鼠标悬停在溢出的画面区域时事件可能被子元素拦截/丢失,导致放大了缩不回来。
|
||
|
|
// 挂在 document(window 捕获)的捕获阶段,保证鼠标落在游戏宿主内任意元素(含溢出部分)
|
||
|
|
// 都能触发;且在编辑器 preview-app Ui 的任何滚轮处理器之前先执行(见 onPreviewWheel)。
|
||
|
|
// 用 this._docZoomBound 防重复绑定(window 常驻,host 可反复重建)。
|
||
|
|
if (!this._docZoomBound) {
|
||
|
|
this._docZoomBound = true;
|
||
|
|
window.addEventListener('wheel', (e) => {
|
||
|
|
const h = document.getElementById('yyka-game-host');
|
||
|
|
if (h && (h.contains(e.target) || e.target === h)) {
|
||
|
|
this.onPreviewWheel(e);
|
||
|
|
}
|
||
|
|
}, { capture: true, passive: false });
|
||
|
|
}
|
||
|
|
return host;
|
||
|
|
},
|
||
|
|
/** 让游戏宿主跟随面板预览区域(.game-view)的视口位置与尺寸。
|
||
|
|
* getBoundingClientRect 对 shadow DOM 内元素同样返回视口坐标,配合 fixed 定位可精确覆盖。
|
||
|
|
* 面板被拖动/切走/弹窗遮挡时宿主必须跟随或隐藏,否则会「漂浮」在屏幕上盖住其它窗口。 */
|
||
|
|
syncGameHost() {
|
||
|
|
const host = document.getElementById('yyka-game-host');
|
||
|
|
if (!host)
|
||
|
|
return;
|
||
|
|
const ref = this.$refs.gameView || document.querySelector('.game-view');
|
||
|
|
if (!ref)
|
||
|
|
return;
|
||
|
|
const r = ref.getBoundingClientRect();
|
||
|
|
const vw = window.innerWidth;
|
||
|
|
const vh = window.innerHeight;
|
||
|
|
// 尺寸为 0(v-show 隐藏)或完全移出视口(面板拖动/独立成窗)时隐藏宿主,避免漂浮遮挡
|
||
|
|
const offscreen = r.width <= 1 || r.height <= 1 || r.left >= vw || r.top >= vh || r.left + r.width <= 0 || r.top + r.height <= 0;
|
||
|
|
const display = offscreen ? 'none' : 'flex';
|
||
|
|
// 仅在状态变化时写样式,避免定期轮询造成无谓重排
|
||
|
|
if (this._hostDisplay === display && this._hostLeft === r.left && this._hostTop === r.top &&
|
||
|
|
this._hostW === r.width && this._hostH === r.height) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._hostDisplay = display;
|
||
|
|
this._hostLeft = r.left;
|
||
|
|
this._hostTop = r.top;
|
||
|
|
this._hostW = r.width;
|
||
|
|
this._hostH = r.height;
|
||
|
|
host.style.display = display; // 与 cssText 的 flex column 布局一致
|
||
|
|
if (display === 'none')
|
||
|
|
return;
|
||
|
|
host.style.left = r.left + 'px';
|
||
|
|
host.style.top = r.top + 'px';
|
||
|
|
host.style.width = r.width + 'px';
|
||
|
|
host.style.height = r.height + 'px';
|
||
|
|
},
|
||
|
|
/** 停止定期宿主同步(渲染被取代或面板关闭时调用)。 */
|
||
|
|
stopHostSync() {
|
||
|
|
if (this._hostSyncTimer) {
|
||
|
|
window.clearInterval(this._hostSyncTimer);
|
||
|
|
this._hostSyncTimer = 0;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
/** 监听预览区域尺寸/显隐变化,自动同步游戏宿主位置(分隔条拖拽、窗口缩放、v-show 切换)。 */
|
||
|
|
observeGameHost() {
|
||
|
|
if (this._hostObserved)
|
||
|
|
return;
|
||
|
|
this._hostObserved = true;
|
||
|
|
const ref = this.$refs.gameView || document.querySelector('.game-view');
|
||
|
|
if (!ref || typeof ResizeObserver === 'undefined')
|
||
|
|
return;
|
||
|
|
const ro = new ResizeObserver(() => this.syncGameHost());
|
||
|
|
ro.observe(ref);
|
||
|
|
},
|
||
|
|
/** 构建完整游戏 DOM(toolbar 控件条 + content/GameDiv/canvas/splash/bulletin/error),
|
||
|
|
* 挂载到顶层 document 的游戏宿主上,并同步定位到面板预览区域。
|
||
|
|
* Ui 构造的 document.querySelector('#GameCanvas') 依赖这些元素在顶层 document 可见。 */
|
||
|
|
buildGameDom() {
|
||
|
|
const host = this.ensureGameHost();
|
||
|
|
host.innerHTML = '';
|
||
|
|
const el = (tag, cls = '', id = '') => {
|
||
|
|
const e = document.createElement(tag);
|
||
|
|
if (cls)
|
||
|
|
e.className = cls;
|
||
|
|
if (id)
|
||
|
|
e.id = id;
|
||
|
|
return e;
|
||
|
|
};
|
||
|
|
// 内联样式:等价预览模板 index.css 的关键布局(只作用于 #yyka-game-host 内部)。
|
||
|
|
// 缺少这些样式会导致:toolbar 裸奔显示(布局乱)、
|
||
|
|
// #GameDiv/#Cocos3dGameContainer/#GameCanvas 塌陷为 0 尺寸(黑屏)。
|
||
|
|
const style = el('style');
|
||
|
|
style.textContent = `
|
||
|
|
#yyka-game-host, #yyka-game-host * { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
|
||
|
|
#yyka-game-host .toolbar { display: none !important; } /* 面板内不需要预览器控件条 */
|
||
|
|
#yyka-game-host .content {
|
||
|
|
flex: auto; min-height: 0;
|
||
|
|
display: flex; flex-direction: column;
|
||
|
|
align-items: center; justify-content: center; overflow: hidden;
|
||
|
|
}
|
||
|
|
#yyka-game-host .contentWrap {
|
||
|
|
width: 100%; height: 100%; overflow: hidden;
|
||
|
|
display: flex; align-items: center; justify-content: center; flex-direction: column;
|
||
|
|
}
|
||
|
|
#yyka-game-host #GameDiv,
|
||
|
|
#yyka-game-host #Cocos3dGameContainer,
|
||
|
|
#yyka-game-host #GameCanvas { width: 100%; height: 100%; }
|
||
|
|
#yyka-game-host #GameDiv { overflow: hidden; position: relative; }
|
||
|
|
#yyka-game-host .wrapper { position: relative; }
|
||
|
|
/* 设备外壳容器:默认只作居中包裹;选中设备时加 .device 呈现手机屏幕外观 */
|
||
|
|
#yyka-game-host .device-frame {
|
||
|
|
display: flex; align-items: center; justify-content: center; width: max-content; height: max-content;
|
||
|
|
}
|
||
|
|
/* 手机外壳(全面屏):窄边中框 + 圆角裁剪(游戏窗口直角被圆角遮掉,呈全面屏),
|
||
|
|
顶部挖孔屏胶囊刘海 + 底部下巴 */
|
||
|
|
#yyka-game-host .device-frame.device {
|
||
|
|
position: relative;
|
||
|
|
border-radius: 18px;
|
||
|
|
padding: 0;
|
||
|
|
overflow: hidden; /* 让内部游戏窗口直角被圆角裁切 */
|
||
|
|
background: linear-gradient(160deg, #3a3f47 0%, #23262b 45%, #141619 100%);
|
||
|
|
color: #fff;
|
||
|
|
box-shadow:
|
||
|
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||
|
|
0 2px 5px rgba(0, 0, 0, 0.5),
|
||
|
|
0 12px 26px rgba(0, 0, 0, 0.45),
|
||
|
|
0 26px 52px rgba(0, 0, 0, 0.55);
|
||
|
|
}
|
||
|
|
/* 边框覆盖层:叠在屏幕之上(不能在 background/box-shadow 画,那些会被子元素屏幕盖住),
|
||
|
|
中央透明透出屏幕,四边机身色形成可见的手机边框,圆角跟随外壳 */
|
||
|
|
#yyka-game-host .device-frame.device::before {
|
||
|
|
content: ""; position: absolute; inset: 0; z-index: 5; pointer-events: none;
|
||
|
|
border: 6px solid #26292f;
|
||
|
|
border-radius: 16px;
|
||
|
|
box-shadow:
|
||
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||
|
|
inset 0 -1px 0 rgba(0, 0, 0, 0.55),
|
||
|
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||
|
|
}
|
||
|
|
/* 底部点亮条(叠在边框下方,像 home 键光条) */
|
||
|
|
#yyka-game-host .device-frame.device::after {
|
||
|
|
content: ""; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
|
||
|
|
width: 46px; height: 2px; border-radius: 2px; background: rgba(255, 255, 255, 0.16);
|
||
|
|
z-index: 6;
|
||
|
|
}
|
||
|
|
/* 灵动岛挖孔镜头:设备模式下覆盖在屏幕顶部中央,圆角胶囊外观。
|
||
|
|
尺寸用相对值(宽度取屏幕宽的一定的百分比),随外壳整体缩放同步变化,
|
||
|
|
避免缩放时机身放大而灵动岛保持固定像素导致“相对变小/反向”的现象。 */
|
||
|
|
#yyka-game-host .device-frame .dynamic-island {
|
||
|
|
display: none;
|
||
|
|
position: absolute; top: 4%; left: 50%; transform: translateX(-50%);
|
||
|
|
width: 26%; aspect-ratio: 4 / 1; border-radius: 999px; background: #000;
|
||
|
|
z-index: 6; pointer-events: none;
|
||
|
|
box-shadow:
|
||
|
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||
|
|
inset 0 0 6px rgba(255, 255, 255, 0.05),
|
||
|
|
0 2px 8px rgba(0, 0, 0, 0.5);
|
||
|
|
}
|
||
|
|
#yyka-game-host .device-frame.device .dynamic-island { display: block; }
|
||
|
|
#yyka-game-host #splash { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; visibility: hidden; }
|
||
|
|
#yyka-game-host #bulletin { position: absolute; left: 50%; top: 50%; display: none; }
|
||
|
|
#yyka-game-host .error {
|
||
|
|
color: #fff; position: absolute; width: 70%; max-width: 960px; top: 5vh; right: 0; left: 0;
|
||
|
|
margin: auto; display: none; z-index: 2; background: #171717; padding: 16px;
|
||
|
|
border-radius: 4px; word-break: break-all;
|
||
|
|
}
|
||
|
|
#yyka-game-host canvas { background-color: transparent; outline: none; }
|
||
|
|
`;
|
||
|
|
host.appendChild(style);
|
||
|
|
// 预览器 UI 控件条:Ui 构造函数用 document.querySelector 强依赖这些元素,缺失即抛
|
||
|
|
// "UI element xxx doesn't exist"。显示层面由 CSS 隐藏。
|
||
|
|
const toolbar = el('div', 'toolbar disabled');
|
||
|
|
// 设备列表 data-device 与真实预览服务渲染的完全一致,preview-app 据此键应用分辨率。
|
||
|
|
toolbar.innerHTML = `
|
||
|
|
<div class="item"><div id="view-select" class="view-select-container" value="Default" tabindex="-1">
|
||
|
|
<div class="view-select"><div class="label"><span id="name"></span></div><i class="arrow-triangle"></i></div>
|
||
|
|
<div class="options"><ul>
|
||
|
|
<li class="selected" data-device="Default">设计分辨率 (960X640)</li>
|
||
|
|
<li class="" data-device="FullScreen">全屏</li>
|
||
|
|
<li class="" data-device="WebpageFullScreen">网页全屏</li>
|
||
|
|
<li class="separator"></li>
|
||
|
|
<li class="" data-device="Apple iPhone 12; 13; 14; 12/13 Pro">iPhone 12/13/14 (390X844)</li>
|
||
|
|
<li class="" data-device="Apple iPhone 14 Pro">iPhone 14 Pro (393X852)</li>
|
||
|
|
<li class="" data-device="Apple iPhone 12/13 Pro Max; 14 Plus">iPhone Pro Max/14 Plus (428X926)</li>
|
||
|
|
<li class="" data-device="Apple iPhone XR; 11">iPhone XR/11 (414X896)</li>
|
||
|
|
<li class="" data-device="Apple iPhone X; XS; 11 Pro; 12/13 Mini">iPhone X/XS (375X812)</li>
|
||
|
|
<li class="" data-device="Apple iPad 10.2">iPad 10.2 (1620X2160)</li>
|
||
|
|
<li class="" data-device="HUAWEI Mate 40 Pro">HUAWEI Mate 40 Pro (448X924)</li>
|
||
|
|
<li class="" data-device="OPPO Reno 2">OPPO Reno 2 (360X800)</li>
|
||
|
|
<li class="" data-device="Xiaomi Redmi 8">Xiaomi Redmi 8 (360X760)</li>
|
||
|
|
<li class="" data-device="Lenovo Legion 2 Pro">Lenovo Legion 2 Pro (360X820)</li>
|
||
|
|
</ul></div>
|
||
|
|
</div></div>
|
||
|
|
<div class="item"><button id="btn-rotate" class="checked">Rotate</button></div>
|
||
|
|
<span class="item">Debug Mode:</span>
|
||
|
|
<div class="item"><select id="opts-debug-mode" value="INFO">
|
||
|
|
<option value="NONE">None</option><option value="VERBOSE">Verbose</option>
|
||
|
|
<option value="INFO">Info</option><option value="WARN">Warn</option>
|
||
|
|
<option value="ERROR">Error</option><option value="INFO_FOR_WEB_PAGE">Info For Web Page</option>
|
||
|
|
<option value="WARN_FOR_WEB_PAGE">Warn For Web Page</option>
|
||
|
|
<option value="ERROR_FOR_WEB_PAGE">Error For Web Page</option>
|
||
|
|
</select></div>
|
||
|
|
<div class="item"><button id="btn-show-fps" class="">Show FPS</button></div>
|
||
|
|
<div class="item"><span>FPS:</span><input id="input-set-fps" type="number" value="60" /></div>
|
||
|
|
<div id="step-length"><span>Step Length: </span><input type="text" value="1"><span>ms</span></div>
|
||
|
|
<div class="item"><button id="btn-pause">Pause</button><button id="btn-step" style="display:none;">Step</button></div>`;
|
||
|
|
host.appendChild(toolbar);
|
||
|
|
const gameDiv = el('div', 'wrapper', 'GameDiv');
|
||
|
|
const container = el('div', '', 'Cocos3dGameContainer');
|
||
|
|
const canvas = el('canvas', '', 'GameCanvas');
|
||
|
|
canvas.tabIndex = -1;
|
||
|
|
container.appendChild(canvas);
|
||
|
|
gameDiv.appendChild(container);
|
||
|
|
const splash = el('div', '', 'splash');
|
||
|
|
const bar = el('div', 'progress-bar stripes');
|
||
|
|
bar.innerHTML = '<span></span>';
|
||
|
|
splash.appendChild(bar);
|
||
|
|
gameDiv.appendChild(splash);
|
||
|
|
const bulletin = el('div', '', 'bulletin');
|
||
|
|
const empty = el('div', 'inner', 'sceneIsEmpty');
|
||
|
|
empty.textContent = '预览场景中啥都没有,加点什么,或在编辑器中打开其它场景吧';
|
||
|
|
bulletin.appendChild(empty);
|
||
|
|
gameDiv.appendChild(bulletin);
|
||
|
|
const err = el('div', 'error', 'error');
|
||
|
|
err.appendChild(el('div', 'title')).textContent = 'Error (Please open the console to see detailed errors)';
|
||
|
|
err.appendChild(el('div', 'error-main'));
|
||
|
|
err.appendChild(el('div', 'error-stack'));
|
||
|
|
gameDiv.appendChild(err);
|
||
|
|
const frame = el('div', 'device-frame'); // 手机外壳容器:选中设备时呈现手机屏幕样式
|
||
|
|
frame.appendChild(gameDiv);
|
||
|
|
frame.appendChild(el('div', 'dynamic-island')); // 灵动岛挖孔摄像头(覆盖屏幕之上)
|
||
|
|
const wrap = el('div', 'contentWrap');
|
||
|
|
wrap.appendChild(frame);
|
||
|
|
const content = el('div', 'content');
|
||
|
|
content.appendChild(wrap);
|
||
|
|
host.appendChild(content);
|
||
|
|
// 确认 #GameCanvas 已挂载到顶层 document(Ui 构造依赖)
|
||
|
|
if (document.querySelector('#GameCanvas')) {
|
||
|
|
this.logs.push({ evt: 'sys', msg: '[预览] 游戏 DOM 就绪 (#GameCanvas 已挂载到顶层 document)' });
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
this.logs.push({ evt: 'sys', msg: '[预览] 警告: #GameCanvas 未挂载到顶层 document' });
|
||
|
|
}
|
||
|
|
this.syncGameHost();
|
||
|
|
this.observeGameHost();
|
||
|
|
},
|
||
|
|
/** 注入补充 import-map:preview-app 内部硬编码 import("/socket.io/socket.io.js"),
|
||
|
|
* 该路径基于面板 origin 解析会 404,需映射到预览服务绝对地址。
|
||
|
|
* 必须在 system.js 加载前注入,SystemJS 初始化时才会收集。 */
|
||
|
|
injectExtraImportMap() {
|
||
|
|
const inj = document.createElement('script');
|
||
|
|
inj.type = 'systemjs-importmap';
|
||
|
|
inj.textContent = JSON.stringify({
|
||
|
|
imports: {
|
||
|
|
'/socket.io/socket.io.js': this.abs('/socket.io/socket.io.js'),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
document.head.appendChild(inj);
|
||
|
|
},
|
||
|
|
/** 在 /socket.io/socket.io.js(映射后的绝对地址)注册 SystemJS stub 模块。
|
||
|
|
* 必须在 system.js 加载之后、System.import(preview-app) 之前调用。 */
|
||
|
|
registerSocketIoStub() {
|
||
|
|
const Sys = window.System;
|
||
|
|
const socketUrl = this.abs('/socket.io/socket.io.js');
|
||
|
|
if (!Sys || typeof Sys.register !== 'function' || Sys.has(socketUrl)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
Sys.register(socketUrl, [], (_export) => ({
|
||
|
|
// 注意:execute 不得返回真值(_export 的返回值是导出的值,如 socketIoStub 函数)。
|
||
|
|
// SystemJS 的 doExec 对真值直接调 execPromise.then(...),非 Promise 即抛
|
||
|
|
// "execPromise.then is not a function";返回 undefined 走同步完成分支。
|
||
|
|
execute: () => {
|
||
|
|
_export('default', socketIoStub);
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
this.logs.push({ evt: 'sys', msg: `[预览] socket.io stub 已注册 (${socketUrl})` });
|
||
|
|
},
|
||
|
|
/** 预览环境补齐:面板运行在编辑器 Electron 窗口内,引擎 cc/env 模块用
|
||
|
|
* tryDefineGlobal('CC_EDITOR', 探测 electron process) 会把 EDITOR 误判为 true,
|
||
|
|
* 从而走进编辑器桥接分支并引用未定义的 EditorExtends 全局导致崩溃
|
||
|
|
* (真实浏览器预览无 process,EDITOR=false 故正常)。必须在引擎 bundle 加载前
|
||
|
|
* 预置正确的预览标志 + EditorExtends 桩。 */
|
||
|
|
patchPreviewEnv() {
|
||
|
|
// 预置环境标志:cc/env 的 tryDefineGlobal 优先读 window 上的同名全局
|
||
|
|
window.CC_EDITOR = false;
|
||
|
|
window.CC_PREVIEW = true;
|
||
|
|
window.CC_DEV = true;
|
||
|
|
window.CC_JSB = false;
|
||
|
|
// EditorExtends 桩:兜住引擎中无 EDITOR 守卫的引用
|
||
|
|
// (如 Node._registerIfAttached 的 `if (EditorExtends.Node && ...)`),
|
||
|
|
// 各成员为 no-op,避免 ReferenceError/TypeError,也不产生编辑器副作用。
|
||
|
|
window.EditorExtends = {
|
||
|
|
Node: { add() { }, remove() { }, getNode() { return null; }, emit() { } },
|
||
|
|
Component: { add() { }, remove() { }, addMenu() { }, removeMenu() { } },
|
||
|
|
PrefabUtils: { addPrefabInstance() { } },
|
||
|
|
serialize: { asAsset() { return null; } },
|
||
|
|
UuidUtils: { isUuid() { return false; }, decompressUuid() { return null; } },
|
||
|
|
};
|
||
|
|
this.logs.push({ evt: 'sys', msg: '[预览] 预览环境已补齐 (CC_EDITOR=false, EditorExtends stub)' });
|
||
|
|
},
|
||
|
|
/** 包装 window.fetch + XMLHttpRequest:记录预览服务(origin 匹配)请求的非 2xx 响应,
|
||
|
|
* 面板内看不到网络面板时的调试兜底;编辑器自身请求不受影响。
|
||
|
|
* 引擎 AssetManager 部分下载走 XHR,只包装 fetch 会漏掉资源加载失败。
|
||
|
|
* 注意:每次渲染都调用本方法重装,但基于 window 上保存的「原始引用」重新包装
|
||
|
|
* (__yykaRawIo 只存一次),既保证最新版本逻辑生效,又避免层层嵌套。 */
|
||
|
|
hookFetchLogger() {
|
||
|
|
const self = this;
|
||
|
|
const w = window;
|
||
|
|
// 原始 IO 引用只保存一次:旧版本没有此标记时首次取「当前实现」(可能是旧包装),
|
||
|
|
// 功能等价;WebContents 重建后 window 全新,这里取到原生实现。
|
||
|
|
if (!w.__yykaRawIo) {
|
||
|
|
w.__yykaRawIo = {
|
||
|
|
fetch: w.fetch.bind(w),
|
||
|
|
xhrOpen: XMLHttpRequest.prototype.open,
|
||
|
|
xhrSend: XMLHttpRequest.prototype.send,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const raw = w.__yykaRawIo;
|
||
|
|
const base = (this.previewUrl || '').trim().replace(/\/+$/, '');
|
||
|
|
const inPreview = (u) => !!base && u.indexOf(base) === 0;
|
||
|
|
// 只记录关键请求:非 200(失败/CORS 拦截 status=0)或 URL 含 /scene/(场景加载结果)
|
||
|
|
const shouldLog = (u, status) => status !== 200 || /\/scene\//i.test(u);
|
||
|
|
// 纯相对路径(无 scheme、不以 / 或 // 开头)在编辑器 document 下会解析到编辑器 origin,
|
||
|
|
// 而 preview-app/引擎以相对路径请求场景与资源(scene/xxx.json、assets/general/import/...)。
|
||
|
|
// 浏览器预览页面就在预览 origin 下所以正常;面板内必须改写成预览服务绝对地址,
|
||
|
|
// 否则场景 JSON 404 → no-scene 黑屏。
|
||
|
|
// 特别注意:引擎 wasm 走根相对 /engine_external/?url=external:...(wasm-web.ts),
|
||
|
|
// 按面板 origin 解析会失败,且该 fetch 的 catch 是空实现(promise 永不 resolve),
|
||
|
|
// 导致 game.init 永久挂起 → no-scene 黑屏且无任何报错。必须一并改写为预览服务 origin。
|
||
|
|
const toAbs = (u) => {
|
||
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(u) || u.startsWith('//')) {
|
||
|
|
return u;
|
||
|
|
}
|
||
|
|
if (u.startsWith('/engine_external/')) {
|
||
|
|
return self.abs(u);
|
||
|
|
}
|
||
|
|
return u.startsWith('/') ? u : self.abs(u);
|
||
|
|
};
|
||
|
|
w.fetch = (input, init) => {
|
||
|
|
const raw0 = input;
|
||
|
|
const realInput = typeof raw0 === 'string' ? toAbs(raw0) : raw0;
|
||
|
|
const realStr = typeof realInput === 'string'
|
||
|
|
? realInput
|
||
|
|
: realInput instanceof URL
|
||
|
|
? realInput.href
|
||
|
|
: String((realInput && realInput.url) || '');
|
||
|
|
return raw.fetch(realInput, init)
|
||
|
|
.then((res) => {
|
||
|
|
if (inPreview(realStr) && shouldLog(realStr, res.status)) {
|
||
|
|
self.logs.push({ evt: 'sys', msg: `[预览网络] fetch ${res.status} ${realStr}` });
|
||
|
|
}
|
||
|
|
return res;
|
||
|
|
})
|
||
|
|
.catch((e) => {
|
||
|
|
// 引擎的 wasm fetch 静默吞错,这里补日志定位加载失败的真实 URL
|
||
|
|
if (inPreview(realStr) || /\/engine_external\//i.test(realStr)) {
|
||
|
|
self.logs.push({ evt: 'sys', msg: `[预览网络] fetch 失败 ${realStr}: ${String((e && e.message) || e)}` });
|
||
|
|
}
|
||
|
|
throw e;
|
||
|
|
});
|
||
|
|
};
|
||
|
|
// XHR:改写相对路径 + 记录关键请求(场景/纹理/音频等)
|
||
|
|
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||
|
|
const u = toAbs(String(url));
|
||
|
|
this.__yykaUrl = u;
|
||
|
|
return raw.xhrOpen.apply(this, [method, u, ...rest]);
|
||
|
|
};
|
||
|
|
XMLHttpRequest.prototype.send = function (...args) {
|
||
|
|
this.addEventListener('load', () => {
|
||
|
|
const u = this.__yykaUrl || '';
|
||
|
|
if (inPreview(u) && shouldLog(u, this.status)) {
|
||
|
|
self.logs.push({ evt: 'sys', msg: `[预览网络] XHR ${this.status} ${u}` });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
this.addEventListener('error', () => {
|
||
|
|
const u = this.__yykaUrl || '';
|
||
|
|
if (inPreview(u)) {
|
||
|
|
self.logs.push({ evt: 'sys', msg: `[预览网络] XHR 失败 ${u}` });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return raw.xhrSend.apply(this, args);
|
||
|
|
};
|
||
|
|
// 引擎纹理/图片用 Image 元素加载,相对 src 同样基于 document base 解析,一并改写。
|
||
|
|
// 同时强制跨源图片带 crossOrigin='anonymous':引擎的 download-dom-image 只在
|
||
|
|
// location.protocol !== 'file:' 时设置,面板/file:// 等跨源场景下漏设会导致
|
||
|
|
// WebGL 纹理上传失败(texSubImage2D SecurityError → 粉色贴图)。
|
||
|
|
try {
|
||
|
|
const srcDesc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
|
||
|
|
if (srcDesc && srcDesc.set) {
|
||
|
|
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
||
|
|
get: function () {
|
||
|
|
return srcDesc.get.call(this);
|
||
|
|
},
|
||
|
|
set: function (v) {
|
||
|
|
const nv = typeof v === 'string' ? toAbs(v) : v;
|
||
|
|
if (typeof nv === 'string' && /^https?:/i.test(nv)) {
|
||
|
|
this.crossOrigin = 'anonymous';
|
||
|
|
}
|
||
|
|
return srcDesc.set.call(this, nv);
|
||
|
|
},
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
// 引擎 bundle 脚本(assets/internal/index.js、assets/main/index.js 等)由 downloadScript
|
||
|
|
// 直接设 script.src(相对路径),不经过 fetch/XHR,必须同样改写为预览服务绝对地址,
|
||
|
|
// 否则按面板 origin 解析 404 → loadBuiltinAssets 失败 → game.init 挂起 → no-scene。
|
||
|
|
try {
|
||
|
|
const scriptDesc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src');
|
||
|
|
if (scriptDesc && scriptDesc.set) {
|
||
|
|
Object.defineProperty(HTMLScriptElement.prototype, 'src', {
|
||
|
|
get: function () {
|
||
|
|
return scriptDesc.get.call(this);
|
||
|
|
},
|
||
|
|
set: function (v) {
|
||
|
|
return scriptDesc.set.call(this, typeof v === 'string' ? toAbs(v) : v);
|
||
|
|
},
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
},
|
||
|
|
/** 全量转发 console 输出(log/info/warn/error/debug)到面板日志栏。
|
||
|
|
* 必须在加载引擎脚本前安装:preview-app 的 bootstrap 是 fire-and-forget 的
|
||
|
|
* async 函数,内部每一步(Ui 构造/System.import("cc")/game.init/game.run/onStart)
|
||
|
|
* 抛错都被内层 catch 用 console.error 吞掉,而 onStart 的场景加载又在
|
||
|
|
* bootFn 返回后才跑——若转发装得太晚,场景加载失败的真实原因完全不可见。
|
||
|
|
* 引擎内部报错同样走 console.error(不触发 window.onerror)。
|
||
|
|
* 每次渲染基于 window 保存的「原始方法引用」重新包装,逻辑最新且不层层嵌套;
|
||
|
|
* 相同消息 10 秒内合并计数(如 "foo (x3)")防每帧刷屏。 */
|
||
|
|
hookConsoleForward() {
|
||
|
|
const self = this;
|
||
|
|
const w = window;
|
||
|
|
if (!w.__yykaRawConsole) {
|
||
|
|
const o = {};
|
||
|
|
for (const m of ['log', 'info', 'warn', 'error', 'debug']) {
|
||
|
|
o[m] = console[m].bind(console);
|
||
|
|
}
|
||
|
|
w.__yykaRawConsole = o;
|
||
|
|
}
|
||
|
|
const raw = w.__yykaRawConsole;
|
||
|
|
for (const m of ['log', 'info', 'warn', 'error', 'debug']) {
|
||
|
|
console[m] = (...args) => {
|
||
|
|
const text = args
|
||
|
|
.map((a) => {
|
||
|
|
if (a instanceof Error) {
|
||
|
|
return a.stack || a.message;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
if (typeof a === 'string') {
|
||
|
|
return a;
|
||
|
|
}
|
||
|
|
const s = JSON.stringify(a);
|
||
|
|
return s === undefined ? String(a) : s;
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
return String(a);
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.join(' ');
|
||
|
|
const now = Date.now();
|
||
|
|
const key = `${m}|${text}`;
|
||
|
|
const last = self._consoleDedup && self._consoleDedup.get(key);
|
||
|
|
if (last && now - last.t < 10000) {
|
||
|
|
last.n++;
|
||
|
|
last.t = now;
|
||
|
|
// 已合并的消息只更新计数(数组尾元素),不再新增条目
|
||
|
|
const arr = self.logs;
|
||
|
|
for (let i = arr.length - 1; i >= 0; i--) {
|
||
|
|
const it = arr[i];
|
||
|
|
if (it && it.evt === 'console' && it.key === key) {
|
||
|
|
it.text = it.base + ` (x${last.n})`;
|
||
|
|
it.t = now;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
if (!self._consoleDedup || self._consoleDedup.size > 3000) {
|
||
|
|
// 重渲染/长时间运行后清理去重状态,防止 Map 无限增长
|
||
|
|
self._consoleDedup = new Map();
|
||
|
|
}
|
||
|
|
self._consoleDedup.set(key, { n: 1, t: now });
|
||
|
|
self.logs.push({ evt: 'console', level: m, key, base: text, text, t: now });
|
||
|
|
}
|
||
|
|
// 上限保护:日志栏渲染性能
|
||
|
|
while (self.logs.length > 3000) {
|
||
|
|
self.logs.shift();
|
||
|
|
}
|
||
|
|
const orig = raw[m];
|
||
|
|
return orig(...args);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
},
|
||
|
|
/** 加载单个外部脚本,返回 Promise(onload / onerror / 超时兜底)。 */
|
||
|
|
loadScript(src, attrs = {}) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const s = document.createElement('script');
|
||
|
|
s.src = src;
|
||
|
|
for (const k in attrs) {
|
||
|
|
s.setAttribute(k, attrs[k]);
|
||
|
|
}
|
||
|
|
const timer = window.setTimeout(() => reject(new Error('脚本加载超时: ' + src)), 20000);
|
||
|
|
s.onload = () => {
|
||
|
|
window.clearTimeout(timer);
|
||
|
|
resolve(s);
|
||
|
|
};
|
||
|
|
s.onerror = () => {
|
||
|
|
window.clearTimeout(timer);
|
||
|
|
reject(new Error('脚本加载失败: ' + src));
|
||
|
|
};
|
||
|
|
document.head.appendChild(s);
|
||
|
|
});
|
||
|
|
},
|
||
|
|
/** 以原生 <script type="systemjs-importmap" src=...> 加载 import-map(与 docs/view.html 一致)。
|
||
|
|
* 由 SystemJS 自己 fetch 并按「map 自身 URL」解析相对目标与链式裸说明符,
|
||
|
|
* 从而保持引擎 bundle 内 q-bundled:/// 命名模块的相对 import 上下文正确。
|
||
|
|
*
|
||
|
|
* 早期实现改为「面板 fetch + 重写 + 内联注入 JSON」:手动把 ./、/起始路径绝对化,
|
||
|
|
* 破坏了 SystemJS 对 bundle 内物理/网格 loader 相对 import
|
||
|
|
* (../../../../virtual/external%253A...wasm.wasm.js) 的解析——被锁成网络
|
||
|
|
* /virtual/... (404, Error#3),且 q-bundled:///virtual/... 虚拟模块注册上下文丢失
|
||
|
|
* (has→false)。原生 src 加载完全复刻 view.html 的工作链路。
|
||
|
|
*/
|
||
|
|
async applyImportMap(src) {
|
||
|
|
const url = this.abs(src);
|
||
|
|
const inj = document.createElement('script');
|
||
|
|
inj.type = 'systemjs-importmap';
|
||
|
|
inj.src = url;
|
||
|
|
document.head.appendChild(inj);
|
||
|
|
this.logs.push({ evt: 'sys', msg: `[预览] ${src} import-map 以原生 src 注入 (由 SystemJS 加载)` });
|
||
|
|
},
|
||
|
|
/** 面板内直接渲染游戏(无 iframe)。每一步结果写入日志栏,便于定位失败环节。 */
|
||
|
|
async renderPreview() {
|
||
|
|
const seq = (this._renderSeq = (this._renderSeq || 0) + 1); // 新渲染取代旧渲染,旧的立即停止
|
||
|
|
this.stopHostSync(); // 清理上一次渲染的定期宿主同步
|
||
|
|
// 刷新时先拆解旧游戏实例,避免音乐/计时器/主循环残留叠加
|
||
|
|
this.teardownPreview();
|
||
|
|
const url = (this.previewUrl || '').trim();
|
||
|
|
if (!url) {
|
||
|
|
this.previewRenderErr = '未配置预览地址';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const log = (msg) => this.logs.push({ evt: 'sys', msg: `[预览] ${msg}` });
|
||
|
|
const alive = () => this._renderSeq === seq;
|
||
|
|
// 安装窗口级音频静音 hook(事件驱动、幂等),在引擎脚本加载前保证静音覆盖所有播放路径
|
||
|
|
try {
|
||
|
|
this._hookAudioCtx();
|
||
|
|
}
|
||
|
|
catch (_) { /* ignore */ }
|
||
|
|
// 版本标识:日志开头直接确认面板加载的是哪个版本,避免旧产物误判
|
||
|
|
log(`渲染 v${package_json_1.default.version}`);
|
||
|
|
// 环境诊断:确认面板 origin(file:/自定义协议决定引擎对 crossOrigin/location 的处理路径)
|
||
|
|
log(`环境 href=${window.location.href} base=${document.baseURI} proto=${window.location.protocol} ` +
|
||
|
|
`trace=${!!window.__yykaTraceInstalled} rawIo=${!!window.__yykaRawIo}`);
|
||
|
|
// 全局错误捕获:拿到完整堆栈,定位引擎初始化失败的真实模块。
|
||
|
|
// 标记放 window 上而非实例字段:插件重载/面板复用可能遗留实例 true,
|
||
|
|
// 导致新版本代码跳过安装(曾发生:v1.0.156+ 的 /engine_external/、script.src、
|
||
|
|
// crossOrigin 钩子从未生效,面板端 wasm 仍静默挂起)。
|
||
|
|
if (!window.__yykaErrHooked) {
|
||
|
|
window.__yykaErrHooked = true;
|
||
|
|
window.addEventListener('error', (ev) => {
|
||
|
|
const m = ev.error ? ev.error.stack || ev.error.message || String(ev.error) : ev.message;
|
||
|
|
this.logs.push({ evt: 'sys', msg: `[预览错误] ${m}` });
|
||
|
|
});
|
||
|
|
window.addEventListener('unhandledrejection', (ev) => {
|
||
|
|
const r = ev.reason;
|
||
|
|
this.logs.push({ evt: 'sys', msg: `[预览错误] unhandledrejection: ${r && r.stack ? r.stack : String(r)}` });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 每次渲染强制重装最新版钩子:内部基于全局原始引用重新包装,幂等且不层层嵌套
|
||
|
|
this.hookFetchLogger();
|
||
|
|
log('网络 hook 已安装 (fetch/XHR/Image/script 路径重写 + 请求结果记录)');
|
||
|
|
// console 全量转发同样提前安装:preview-app bootstrap 内部错误走 console.error 被吞,
|
||
|
|
// 且场景加载(onStart)在 bootFn 返回后才跑,转发装晚了会漏掉失败的真实原因
|
||
|
|
this.hookConsoleForward();
|
||
|
|
log('console 转发已安装 (引擎/preview-app 输出全量可见)');
|
||
|
|
// 注入 <base href>:SystemJS 的 baseUrl 读取 document.querySelector('base[href]'),
|
||
|
|
// external:emscripten/... 虚拟 wasm 模块按 base 解析,面板 base 是 file:// 导致
|
||
|
|
// 解析到不存在的本地路径而加载失败(bt.DbvtBroadphase_new is not a function 根因),
|
||
|
|
// 改到预览服务 origin 后命中其 virtual handler
|
||
|
|
try {
|
||
|
|
let baseEl = document.querySelector('base[href]');
|
||
|
|
if (!baseEl) {
|
||
|
|
baseEl = document.createElement('base');
|
||
|
|
document.head.appendChild(baseEl);
|
||
|
|
}
|
||
|
|
baseEl.href = (this.previewUrl || 'http://localhost:7456/').trim().replace(/\/+$/, '') + '/';
|
||
|
|
log(`<base href> 已注入 (${baseEl.href})`);
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
this.checkPreview(); // 快速探测在线状态(内部节流)
|
||
|
|
this.previewRenderErr = '';
|
||
|
|
this.previewLoaded = false;
|
||
|
|
log('开始加载引擎脚本…');
|
||
|
|
await this.loadScript(this.abs('/scripting/polyfills/bundle.js'));
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('polyfills ✓');
|
||
|
|
await this.applyImportMap('/scripting/engine/bin/.cache/dev/preview/import-map.json');
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('引擎 import-map ✓');
|
||
|
|
await this.applyImportMap('/scripting/x/import-map.json');
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('项目 import-map ✓');
|
||
|
|
await this.applyImportMap('/scripting/import-map-global');
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('cc import-map ✓');
|
||
|
|
// 补充映射(socket.io 等 preview-app 硬编码路径),须在 system.js 之前注入
|
||
|
|
this.injectExtraImportMap();
|
||
|
|
await this.loadScript(this.abs('/scripting/systemjs/system.js'));
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('systemjs ✓');
|
||
|
|
// socket.io stub:preview-app bootstrap 会 import('/socket.io/socket.io.js') 并调用 n.default()。
|
||
|
|
// 面板内跨域 + 重复渲染时 SystemJS 的全局探测不可靠(io 全局已存在则探测不到新全局,default 缺失),
|
||
|
|
// 直接在该路径注册 stub 模块,保证 n.default() 必定可用且不发起真实 socket.io 连接。
|
||
|
|
this.registerSocketIoStub();
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
// 注意:不设 System.config({baseURL})、不包装 System.import。
|
||
|
|
// 这两样是 view.html 所没有的额外编排,会干扰 engine bundle 内
|
||
|
|
// q-bundled:/// 命名模块的相对 import 解析(../../../../virtual/... 被拉起
|
||
|
|
// 成网络 /virtual/ 404、has→false)。完全复刻 view.html:只靠 <base href>。
|
||
|
|
// 分辨率细节映射(跨域 fetch,失败会反映到启动错误)
|
||
|
|
const Sys = window.System;
|
||
|
|
if (Sys && typeof Sys.setResolutionDetailMapCallback === 'function') {
|
||
|
|
Sys.setResolutionDetailMapCallback(() => {
|
||
|
|
const u = this.abs('/scripting/x/resolution-detail-map.json');
|
||
|
|
return fetch(u).then((r) => r.json()).then((json) => ({ json, url: u }));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 分辨率细节映射已在上方 setResolutionDetailMapCallback 设置。
|
||
|
|
// 注意:view.html 不包装 System.import;这里也不做,保持与 view.html 一致的加载链路。
|
||
|
|
// 引擎 bundle 加载前置好环境(CC_EDITOR=false + EditorExtends 桩),
|
||
|
|
// 否则编辑器 Electron 窗口内 EDITOR 误判为 true 会触发 EditorExtends 崩溃
|
||
|
|
this.patchPreviewEnv();
|
||
|
|
await this.loadScript(this.abs('/scripting/engine/bin/.cache/dev/preview/bundled/index.js'));
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('引擎 bundle ✓');
|
||
|
|
await this.loadScript(this.abs('/settings.js?scene=current_scene'));
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('settings ✓');
|
||
|
|
const settings = window._CCSettings;
|
||
|
|
// 强制 SubFrame 尺寸模式:ScreenAdapter 的 _windowType 在 exactFitScreen=true
|
||
|
|
// 时走 BrowserWindow 分支,用 window.innerWidth/Height(编辑器窗口尺寸,非预览区域)
|
||
|
|
// 设置 #GameDiv 与画布 buffer,导致布局错位 + 黑屏。
|
||
|
|
// exactFitScreen=false → SubFrame → 尺寸按 #GameDiv 实际尺寸(clientWidth/Height)计算。
|
||
|
|
// 顶层字段走 _compatibleWithOldParams 转 overrideSettings,screen 字段走 settings.json。
|
||
|
|
if (settings && typeof settings === 'object') {
|
||
|
|
settings.exactFitScreen = false;
|
||
|
|
settings.screen = settings.screen || {};
|
||
|
|
settings.screen.exactFitScreen = false;
|
||
|
|
log('exactFitScreen=false (SubFrame 尺寸模式)');
|
||
|
|
}
|
||
|
|
// 引擎脚本全部就绪后,Vue 渲染必然已完成:此刻确保游戏 DOM 就位,
|
||
|
|
// 保证 Ui 构造的 document.querySelector('#GameCanvas') 必定命中
|
||
|
|
this.buildGameDom();
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
// 运行期跟踪:包装 game.run/loadWithJson/runSceneImmediate 并转发 console 错误,
|
||
|
|
// 精确定位场景加载卡点(必须在 preview-app 调用之前安装)
|
||
|
|
this.installPreviewTrace();
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
log('启动预览应用…');
|
||
|
|
try {
|
||
|
|
const mod = await window.System.import(this.abs('/preview-app/index.js') + '?__t=' + seq);
|
||
|
|
// 兼容 { bootstrap } 与 { default: { bootstrap } } 两种导出结构
|
||
|
|
const bootFn = (mod && typeof mod.bootstrap === 'function' && mod.bootstrap) ||
|
||
|
|
(mod && mod.default && typeof mod.default.bootstrap === 'function' && mod.default.bootstrap);
|
||
|
|
if (!bootFn) {
|
||
|
|
throw new Error('preview-app 未导出 bootstrap 函数,模块结构: ' + JSON.stringify(Object.keys(mod || {})));
|
||
|
|
}
|
||
|
|
if (!alive())
|
||
|
|
return;
|
||
|
|
// 双保险:无论 preview-app 以何种方式传 config 给 game.init,
|
||
|
|
// 都确保 ScreenAdapter 走 SubFrame 尺寸模式。
|
||
|
|
// querySettings 优先读 settings._override,再读 settings.json;
|
||
|
|
// settings.init 只会逐项 set overrideSettings,不会整体覆盖已有键。
|
||
|
|
const legacy = window.cc || window.cclegacy;
|
||
|
|
if (legacy && legacy.settings) {
|
||
|
|
legacy.settings._override = legacy.settings._override || {};
|
||
|
|
legacy.settings._override.screen = legacy.settings._override.screen || {};
|
||
|
|
legacy.settings._override.screen.exactFitScreen = false;
|
||
|
|
}
|
||
|
|
// devices 为 Ui 控件条强依赖(设备列表),缺了 Ui 构造会直接崩溃(设备表必须
|
||
|
|
// 包含工具栏 li 里出现的全部 data-device;漏设备抛 undefined.name)。
|
||
|
|
const _pd = this.prefDesign();
|
||
|
|
await bootFn({
|
||
|
|
engineBaseUrl: this.abs('/scripting/engine'),
|
||
|
|
settings,
|
||
|
|
devices: Object.assign({ 'Default': { name: `设计分辨率 (${_pd.w}×${_pd.h})`, width: _pd.w, height: _pd.h }, 'FullScreen': { name: '全屏', width: _pd.w, height: _pd.h }, 'WebpageFullScreen': { name: '网页全屏', width: _pd.w, height: _pd.h } }, Object.entries(DEVICE_SIZES)
|
||
|
|
.filter(([k]) => !['Default', 'FullScreen', 'WebpageFullScreen'].includes(k))
|
||
|
|
.reduce((acc, [k, v]) => {
|
||
|
|
acc[k] = { name: v.label, width: v.w, height: v.h };
|
||
|
|
return acc;
|
||
|
|
}, {})),
|
||
|
|
});
|
||
|
|
this.previewLoaded = true;
|
||
|
|
this.previewZoom = 1; // 新一次渲染从 100% 缩放开始
|
||
|
|
this.syncPreviewState(); // 同步工具条激活态
|
||
|
|
log('游戏已启动 ✓');
|
||
|
|
// 先定位并显示游戏宿主(display:none → flex),让 #GameDiv 拥有真实尺寸;
|
||
|
|
// 必须在 screenAdapter 注入的 resize 之前完成,否则 resize 时容器尺寸为 0,
|
||
|
|
// 画布 buffer 会按 0 尺寸计算(表现为 buf 远小于实际区域)。
|
||
|
|
this.syncGameHost();
|
||
|
|
// 默认按当前设备/设计分辨率等比居中显示(避免画面被拉伸成方形)。
|
||
|
|
// 需在宿主已定位、Engine resize 兜底之前应用,#GameDiv 才拿到真实容器尺寸。
|
||
|
|
this.applyDeviceViewport(this.previewDevice || 'Default');
|
||
|
|
// ===== exactFitScreen 最终兜底:直接改 ScreenAdapter 实例字段 =====
|
||
|
|
// 三层 settings 覆盖(_CCSettings / screen 字段 / _override)都依赖 preview-app
|
||
|
|
// 如何把 settings 传给 game.init 与 Settings.init 的 override 合并方式,
|
||
|
|
// 实测未生效(win 仍是编辑器窗口尺寸/dpr)。直接改实例字段最可靠:
|
||
|
|
// _windowType = _exactFitScreen ? BrowserWindow : SubFrame,
|
||
|
|
// 改 false 后触发 resize 走 SubFrame 分支按 #GameDiv 实际尺寸重算画布。
|
||
|
|
// System.import 是异步的,刷新那一刻可能还没 resolve 就开始渲染 → 画布仍是
|
||
|
|
// 浏览器窗口尺寸(比预览视口大)。这里用重试循环等到 adapter 就绪,并在每次
|
||
|
|
// 成功后重放视口,确保 SubFrame(跟随 #GameDiv 尺寸)在渲染前必然生效。
|
||
|
|
await this.forceSubFrameViewport(alive);
|
||
|
|
// 兜底注入可能把 _exactFitScreen 强制成 false(供 Default 铺满),
|
||
|
|
// 会覆盖掉上面 applyDeviceViewport 为设备模式设的 true,导致画面不按当前分辨率/缩放显示;
|
||
|
|
// 这里在注入之后按当前设备与缩放重放一次,恢复正确视口。
|
||
|
|
this.applyDeviceViewport(this.previewDevice || 'Default');
|
||
|
|
this.syncGameHost();
|
||
|
|
// 场景加载跟踪:若场景加载完成/失败,日志栏可见
|
||
|
|
try {
|
||
|
|
const legacy2 = window.cc || window.cclegacy;
|
||
|
|
const director = legacy2 && legacy2.director;
|
||
|
|
if (director && typeof director.on === 'function') {
|
||
|
|
const evt = (legacy2.Director && legacy2.Director.EVENT_AFTER_SCENE_LAUNCH) ||
|
||
|
|
'director_after_scene_launch';
|
||
|
|
director.on(evt, () => {
|
||
|
|
if (alive()) {
|
||
|
|
const sc = director.getScene && director.getScene();
|
||
|
|
log(`场景已加载: ${(sc && sc.name) || '?'}`);
|
||
|
|
// 场景激活后才重建最终画布/相机视口;此时重放视口并触发 resize,
|
||
|
|
// 让相机按 #GameDiv 最终尺寸计算 viewport(等效于用户滚轮),
|
||
|
|
// 修复刷新后相机视角跑偏。
|
||
|
|
this.applyDeviceViewport(this.previewDevice || 'Default');
|
||
|
|
this.syncGameHost();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (e) {
|
||
|
|
log('场景监听注册失败: ' + String((e === null || e === void 0 ? void 0 : e.message) || e));
|
||
|
|
}
|
||
|
|
// 场景加载看门狗:无场景时周期性输出引擎状态快照,定位主循环/资源管线卡点
|
||
|
|
this.startSceneWatchdog();
|
||
|
|
// 定期同步:面板拖动/窗口移动/布局变化时 ResizeObserver 可能漏报
|
||
|
|
// (只监听尺寸不监听位置),轮询兜底保证宿主不「漂浮」
|
||
|
|
this._hostSyncTimer = window.setInterval(() => this.syncGameHost(), 300);
|
||
|
|
}
|
||
|
|
catch (err) {
|
||
|
|
const msg = String((err === null || err === void 0 ? void 0 : err.message) || err);
|
||
|
|
this.previewLoaded = false;
|
||
|
|
this.previewRenderErr = msg;
|
||
|
|
log('启动失败 ✗ ' + msg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (err) {
|
||
|
|
const msg = String((err === null || err === void 0 ? void 0 : err.message) || err);
|
||
|
|
this.previewRenderErr = msg;
|
||
|
|
log('加载失败 ✗ ' + msg);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
/** 运行期跟踪(必须在 preview-app 调用 game.run 之前安装):
|
||
|
|
* 包装 game.run/onStart、pause/resume、loadWithJson、runSceneImmediate,
|
||
|
|
* 并把 console.log/info/warn/error/debug 全量转发到面板日志。场景加载黑屏时
|
||
|
|
* 可直接从日志栏定位卡点:主循环未启动(onStart 未执行)/ 场景 JSON 请求 /
|
||
|
|
* 依赖加载 / 反序列化。
|
||
|
|
* 注意:引擎 bundle 是 System.register 模块,<script> 加载只注册不执行,
|
||
|
|
* window.cc 要等 System.import(preview-app) 才就绪;因此这里轮询等待安装窗口,
|
||
|
|
* 避免漏掉 game.run/loadWithJson 的包装(preview-app 的 game.init 是异步的,
|
||
|
|
* 25ms 轮询足够赶在 game.run 之前装上)。 */
|
||
|
|
installPreviewTrace() {
|
||
|
|
if (this._traceInstalled)
|
||
|
|
return;
|
||
|
|
const self = this;
|
||
|
|
const log = (msg) => self.logs.push({ evt: 'sys', msg: `[预览跟踪] ${msg}` });
|
||
|
|
const installNow = () => {
|
||
|
|
const legacy = window.cc || window.cclegacy;
|
||
|
|
if (!legacy) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
self._traceInstalled = true;
|
||
|
|
const g = legacy.game;
|
||
|
|
if (g) {
|
||
|
|
const origRun = g.run && g.run.bind(g);
|
||
|
|
if (origRun) {
|
||
|
|
g.run = (onStart) => {
|
||
|
|
log('game.run 被调用');
|
||
|
|
const wrapped = () => {
|
||
|
|
log('onStart 开始(场景加载流程启动)');
|
||
|
|
try {
|
||
|
|
const r = onStart && onStart();
|
||
|
|
if (r && typeof r.then === 'function') {
|
||
|
|
r.then(() => log('onStart 完成'), (e) => log('onStart 异常: ' + String((e && (e.message || e)) || e)));
|
||
|
|
}
|
||
|
|
return r;
|
||
|
|
}
|
||
|
|
catch (e) {
|
||
|
|
log('onStart 同步异常: ' + String((e === null || e === void 0 ? void 0 : e.message) || e));
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
return origRun(wrapped);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
for (const m of ['pause', 'resume', 'pauseByEngine', 'resumeByEngine']) {
|
||
|
|
const orig = g[m] && g[m].bind(g);
|
||
|
|
if (!orig)
|
||
|
|
continue;
|
||
|
|
g[m] = (...args) => {
|
||
|
|
log(`game.${m}()`);
|
||
|
|
return orig(...args);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const am = legacy.assetManager;
|
||
|
|
if (am && am.loadWithJson) {
|
||
|
|
const orig = am.loadWithJson.bind(am);
|
||
|
|
am.loadWithJson = (json, options, onProgress, onComplete) => {
|
||
|
|
log(`loadWithJson 开始 id=${options && options.assetId}`);
|
||
|
|
const wProgress = (finished, total) => {
|
||
|
|
log(`loadWithJson 进度 ${finished}/${total}`);
|
||
|
|
if (onProgress)
|
||
|
|
onProgress(finished, total);
|
||
|
|
};
|
||
|
|
const wComplete = (err, data) => {
|
||
|
|
log(`loadWithJson 完成 err=${err ? String((err === null || err === void 0 ? void 0 : err.message) || err) : '无'} ` +
|
||
|
|
`scene=${data && data.scene ? (data.scene.name || data.scene._name) : '?'}`);
|
||
|
|
if (onComplete)
|
||
|
|
onComplete(err, data);
|
||
|
|
};
|
||
|
|
return orig(json, options, wProgress, wComplete);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const dr = legacy.director;
|
||
|
|
if (dr && dr.runSceneImmediate) {
|
||
|
|
const orig = dr.runSceneImmediate.bind(dr);
|
||
|
|
dr.runSceneImmediate = (scene, onBeforeLoadScene, onLaunched) => {
|
||
|
|
log(`runSceneImmediate 调用 scene=${scene && (scene.name || scene._name)}`);
|
||
|
|
return orig(scene, onBeforeLoadScene, onLaunched);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// console 全量转发已由 hookConsoleForward 在渲染早期安装,此处不再重复包装
|
||
|
|
log('运行期跟踪已安装 (game.run/loadWithJson/runSceneImmediate)');
|
||
|
|
return true;
|
||
|
|
};
|
||
|
|
if (installNow()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// System.register 惰性执行:window.cc 尚未就绪,轮询等待(最多约 10s)
|
||
|
|
let tries = 0;
|
||
|
|
const timer = window.setInterval(() => {
|
||
|
|
tries++;
|
||
|
|
if (installNow() || tries > 400) {
|
||
|
|
window.clearInterval(timer);
|
||
|
|
}
|
||
|
|
}, 25);
|
||
|
|
},
|
||
|
|
/** 场景加载看门狗:bootFn 成功后每 2 秒检查一次 director.getScene(),
|
||
|
|
* 若长时间无场景则输出引擎状态快照(paused/pacer/assets),区分
|
||
|
|
* 「主循环未运行」与「资源管线挂起」两类卡点。重渲染时自动失效。 */
|
||
|
|
startSceneWatchdog() {
|
||
|
|
const seq = this._renderSeq;
|
||
|
|
const log = (msg) => this.logs.push({ evt: 'sys', msg: `[预览跟踪] ${msg}` });
|
||
|
|
const legacy = window.cc || window.cclegacy;
|
||
|
|
let waited = 0;
|
||
|
|
const MAX_TIMEOUT_LOG = 2; // 场景久不出现时最多提示几轮,避免定时器持续刷屏
|
||
|
|
let timeoutLogs = 0;
|
||
|
|
const timer = window.setInterval(() => {
|
||
|
|
var _a, _b, _c, _d;
|
||
|
|
if (this._renderSeq !== seq || !this.previewLoaded) {
|
||
|
|
window.clearInterval(timer);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
waited += 2;
|
||
|
|
let sc = null;
|
||
|
|
try {
|
||
|
|
sc = (_b = (_a = legacy === null || legacy === void 0 ? void 0 : legacy.director) === null || _a === void 0 ? void 0 : _a.getScene) === null || _b === void 0 ? void 0 : _b.call(_a);
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
if (sc) {
|
||
|
|
log(`场景已出现: ${sc.name || sc._name || '?'}`);
|
||
|
|
window.clearInterval(timer);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (waited >= 24 && timeoutLogs < MAX_TIMEOUT_LOG) {
|
||
|
|
let paused = '?';
|
||
|
|
let pacer = '?';
|
||
|
|
try {
|
||
|
|
const gg = legacy === null || legacy === void 0 ? void 0 : legacy.game;
|
||
|
|
paused = String(!!(gg && gg._paused));
|
||
|
|
pacer = gg && gg._pacer ? String(!!gg._pacer._isPlaying) : '无pacer';
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
let assets = 0;
|
||
|
|
try {
|
||
|
|
assets = ((_d = (_c = legacy === null || legacy === void 0 ? void 0 : legacy.assetManager) === null || _c === void 0 ? void 0 : _c.assets) === null || _d === void 0 ? void 0 : _d.count) || 0;
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
timeoutLogs++;
|
||
|
|
log(`场景等待超时 ${waited}s: paused=${paused} pacer=${pacer} assets=${assets}`);
|
||
|
|
if (timeoutLogs >= MAX_TIMEOUT_LOG) {
|
||
|
|
window.clearInterval(timer);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, 2000);
|
||
|
|
},
|
||
|
|
/** 探测预览服务是否在线(no-cors 请求,跨域只判连通性,不读内容)。 */
|
||
|
|
async checkPreview() {
|
||
|
|
if (this.previewChecking) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// 节流:至少 3 秒探测一次
|
||
|
|
if (Date.now() - this.previewCheckAt < 3000) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.previewChecking = true;
|
||
|
|
const url = (this.previewUrl || '').trim();
|
||
|
|
try {
|
||
|
|
if (!url) {
|
||
|
|
this.previewOnline = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const ctl = new AbortController();
|
||
|
|
const timer = window.setTimeout(() => ctl.abort(), 6000);
|
||
|
|
await fetch(url, { method: 'GET', mode: 'no-cors', cache: 'no-store', signal: ctl.signal });
|
||
|
|
window.clearTimeout(timer);
|
||
|
|
this.previewOnline = true;
|
||
|
|
}
|
||
|
|
catch (_a) {
|
||
|
|
this.previewOnline = false;
|
||
|
|
}
|
||
|
|
finally {
|
||
|
|
this.previewCheckAt = Date.now();
|
||
|
|
this.previewChecking = false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
onResize() {
|
||
|
|
if (this.showPerf) {
|
||
|
|
this.drawPerf();
|
||
|
|
}
|
||
|
|
},
|
||
|
|
startDrag(kind, e) {
|
||
|
|
e.preventDefault();
|
||
|
|
const el = e.currentTarget;
|
||
|
|
if (el && el.setPointerCapture) {
|
||
|
|
try {
|
||
|
|
el.setPointerCapture(e.pointerId);
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* 捕获失败也不影响 window 级监听 */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
document.body.style.userSelect = 'none';
|
||
|
|
document.body.style.cursor = 'col-resize';
|
||
|
|
this.drag = {
|
||
|
|
kind,
|
||
|
|
startX: e.clientX,
|
||
|
|
startW: kind === 'left' ? this.treeWidth : this.detailWidth,
|
||
|
|
pointerId: e.pointerId,
|
||
|
|
el,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
onDragMove(e) {
|
||
|
|
if (!this.drag) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const dx = e.clientX - this.drag.startX;
|
||
|
|
const w = this.drag.kind === 'left' ? this.drag.startW + dx : this.drag.startW - dx;
|
||
|
|
const clamped = Math.max(140, Math.min(480, w));
|
||
|
|
if (this.drag.kind === 'left') {
|
||
|
|
this.treeWidth = clamped;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
this.detailWidth = clamped;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
endDrag() {
|
||
|
|
if (!this.drag) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this.drag.el && this.drag.el.hasPointerCapture && this.drag.el.hasPointerCapture(this.drag.pointerId)) {
|
||
|
|
try {
|
||
|
|
this.drag.el.releasePointerCapture(this.drag.pointerId);
|
||
|
|
}
|
||
|
|
catch (_) {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
document.body.style.userSelect = '';
|
||
|
|
document.body.style.cursor = '';
|
||
|
|
this.drag = null;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHJldmlldy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3NvdXJjZS9wYW5lbHMvZGVmYXVsdC9tZXRob2RzL3ByZXZpZXcudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBQSxhQUFhO0FBQ2IsNEVBQW1EO0FBRW5EOzs7Ozs7Ozs7R0FTRztBQUVIO3lEQUN5RDtBQUN6RCxTQUFTLFlBQVk7SUFDakIsTUFBTSxRQUFRLEdBQStCLEVBQUUsQ0FBQztJQUNoRCxPQUFPO1FBQ0gsRUFBRSxDQUFDLEdBQVcsRUFBRSxFQUFZO1lBQ3hCLENBQUMsUUFBUSxDQUFDLEdBQUcsQ0FBQyxHQUFHLFFBQVEsQ0FBQyxHQUFHLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFDLENBQUM7WUFDL0MsT0FBTyxJQUFJLENBQUM7UUFDaEIsQ0FBQztRQUNELEdBQUcsQ0FBQyxHQUFXO1lBQ1gsT0FBTyxRQUFRLENBQUMsR0FBRyxDQUFDLENBQUM7WUFDckIsT0FBTyxJQUFJLENBQUM7UUFDaEIsQ0FBQztRQUNELElBQUksQ0FBQyxHQUFXLEVBQUUsR0FBRyxJQUFXO1lBQzVCLENBQUMsUUFBUSxDQUFDLEdBQUcsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDLEtBQUssRUFBRSxDQUFDLE9BQU8sQ0FBQyxDQUFDLEVBQUUsRUFBRSxFQUFFLENBQUMsRUFBRSxDQUFDLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQztZQUMzRCxPQUFPLElBQUksQ0FBQztRQUNoQixDQUFDO1FBQ0QsS0FBSztZQUNELEtBQUssTUFBTSxDQUFDLElBQUksTUFBTSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsRUFBRSxDQUFDO2dCQUNwQyxPQUFPLFFBQVEsQ0FBQyxDQUFDLENBQUMsQ0FBQztZQUN2QixDQUFDO1FBQ0wsQ0FBQztLQUNKLENBQUM7QUFDTixDQUFDO0FBRUQ7OzsyREFHMkQ7QUFDM0QsTUFBTSxZQUFZLEdBQTREO0lBQzFFLFNBQVMsRUFBRSxFQUFFLENBQUMsRUFBRSxHQUFHLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsT0FBTyxFQUFFO0lBQzdDLG9DQUFvQyxFQUFFLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxDQUFDLEVBQUUsR0FBRyxFQUFFLEtBQUssRUFBRSxpQkFBaUIsRUFBRTtJQUNsRixxQkFBcUIsRUFBRSxFQUFFLENBQUMsRUFBRSxHQUFHLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsZUFBZSxFQUFFO0lBQ2pFLHFDQUFxQyxFQUFFLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxDQUFDLEVBQUUsR0FBRyxFQUFFLEtBQUssRUFBRSx3QkFBd0IsRUFBRTtJQUMxRixxQkFBcUIsRUFBRSxFQUFFLENBQUMsRUFBRSxHQUFHLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsY0FBYyxFQUFFO0lBQ2hFLHdDQUF3QyxFQUFFLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxDQUFDLEVBQUUsR0FBRyxFQUFFLEtBQUssRUFBRSxhQUFhLEVBQUU7SUFDbEYsaUJBQWlCLEVBQUUsRUFBRSxDQUFDLEVBQUUsSUFBSSxFQUFFLENBQUMsRUFBRSxJQUFJLEVBQUUsS0FBSyxFQUFFLFdBQVcsRUFBRTtJQUMzRCxvQkFBb0IsRUFBRSxFQUFFLENBQUMsRUFBRSxHQUFHLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsb0JBQW9CLEVBQUU7SUFDckUsYUFBYSxFQUFFLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxDQUFDLEVBQUUsR0FBRyxFQUFFLEtBQUssRUFBRSxhQUFhLEVBQUU7SUFDdkQsZ0JBQWdCLEVBQUUsRUFBRSxDQUFDLEVBQUUsR0FBRyxFQUFFLENBQUMsRUFBRSxHQUFHLEVBQUUsS0FBSyxFQUFFLGdCQUFnQixFQUFFO0lBQzdELHFCQUFxQixFQUFFLEVBQUUsQ0FBQyxFQUFFLEdBQUcsRUFBRSxDQUFDLEVBQUUsR0FBRyxFQUFFLEtBQUssRUFBRSxxQkFBcUIsRUFBRTtDQUMxRSxDQUFDO0FBRUYsa0JBQWU7SUFDWCxLQUFLLENBQUMsZUFBZTtRQUNqQixpREFBaUQ7UUFDakQsSUFBSSxDQUFDLFdBQVcsR0FBRyxHQUFHLENBQUM7UUFDdkIsSUFBSSxDQUFDLFdBQVcsR0FBRyxHQUFHLENBQUM7UUFDdkIsSUFBSSxDQUFDO1lBQ0QsTUFBTSxDQUFDLElBQUksRUFBRSxJQUFJLENBQUMsR0FBRyxNQUFNLE9BQU8sQ0FBQyxHQUFHLENBQUM7Z0JBQ25DLE1BQU0sQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDLHNCQUFXLENBQUMsSUFBVyxFQUFFLGtCQUF5QixDQUFDO2dCQUMxRSxNQUFNLENBQUMsT0FBTyxDQUFDLE9BQU8sQ0FBQyxzQkFBVyxDQUFDLElBQVcsRUFBRSx1QkFBOEIsQ0FBQzthQUNsRixDQUFDLENBQUM7WUFDSCxJQUFJLElBQUksSUFBSSxJQUFJLENBQUMsR0FBRyxFQUFFLENBQUM7Z0JBQ25CLElBQUksQ0FBQyxVQUFVLEdBQUcsSUFBSSxDQUFDLEdBQUcsQ0FBQztZQUMvQixDQUFDO1lBQ0QsTUFBTSxDQUFDLEdBQUcsSUFBSSxJQUFJLElBQUksQ0FBQyxnQkFBZ0IsQ0FBQztZQUN4QyxJQUFJLENBQUMsSUFBSSxNQUFNLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsSUFBSSxNQUFNLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsRUFBRSxDQUFDO2dCQUNuRCxJQUFJLENBQUMsV0FBVyxHQUFHLE1BQU0sQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLENBQUM7Z0JBQ25DLElBQUksQ0FBQyxXQUFXLEdBQUcsTUFBTSxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsQ0FBQztZQUN4QyxDQUFDO1FBQ0wsQ0FBQztRQUFDLFdBQU0sQ0FBQztZQUNMLGFBQWE7UUFDakIsQ0FBQztJQUNMLENBQUM7SUFDRCxpQ0FBaUM7SUFDakMsVUFBVTtRQUNOLE9BQU87WUFDSCxDQUFDLEVBQUUsTUFBTSxDQUFDLElBQUksQ0FBQyxXQUFXLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsV0FBVyxDQUFDLENBQUMsQ0FBQyxDQUFDLEdBQUc7WUFDaEUsQ0FBQyxFQUFFLE1BQU0sQ0FBQyxJQUFJLENBQUMsV0FBVyxDQUFDLEdBQUcsQ0FBQyxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUMsQ0FBQyxHQUFHO1NBQ25FLENBQUM7SUFDTixDQUFDO0lBQ0Qsb0NBQW9DO0lBQ3BDLGFBQWE7UUFDVCxJQUFJLENBQUMsYUFBYSxHQUFHLEtBQUssQ0FBQztRQUMzQixJQUFJLENBQUMsZ0JBQWdCLEdBQUcsRUFBRSxDQUFDO
|