/** * src/host/boot-game-host.js — 通用游戏宿主(Runner v2 P2 · 装载任意 GamePackage 的引擎宿主) * owner:Runner v2 P2 | 消费方:①ref 取证 bootRef(薄包装,传wanglanmei专属参数保 P1 取证不回归); * ②studio host 引擎包分支(从 window.__GameBundle 取 GameHostFactory,经本宿主 boot 入 feed 真玩) * * ════════════════════════════════════════════════════════════════════════════ * 【本文件是什么 · P2 泛化的核心】 * P1 的 bootRef(games/wanglanmei-ref/src/main.js)把「装载wanglanmei一款游戏跑引擎」的 * engineInit 五回调骨架 + 受控面背书 + 输入接线 + 单一推进时钟全部实现了,但游戏工厂来源 * (静态 `import createShopGame`)、插件清单、视口三处写死成wanglanmei专属。 * P2 把这套骨架**整块照搬、语义零变**地提升到本通用模块 bootGameHost(opts),并把上述三处 * **参数化**为 opts,使「装载任意 GamePackage」成立——这是「泛化既有 bootRef」,不是新建。 * * 【放置位置 · 为何落 src/host 而非 wanglanmei 专属目录】 * 通用宿主的消费方有二(ref 取证 + studio host),不再隶属wanglanmei——故按 spec §3.3 * 落「host/runtime 共享位」。src/host 与 src/core / src/plugins 同级,import 方向顺 * (→ ../core/plugin.js / ../plugins/* / ../../host-dev/engine-caps.js),拆仓不断。 * * 【Q4 铁律不破】本模块不顶层 import 引擎(littlejsengine)——引擎名字空间经 opts.engine 注入 * (real 通道由 entry 包装层动态 import 后传入;stub/node 通道为 undefined,保 node import-safe)。 * 游戏工厂(opts.factory)零引擎 import,一律经 boot.ctx.getEngine() 拿引擎能力。 * * 【装载契约(game-runtime/src/core/game-host.d.ts,零改·复用)】 * - opts.factory:GameHostFactory = (opts?) => GameInstance(本宿主调它建游戏实例)。 * - boot 脊柱 = { ctx, mainContext, canvas, seed }(game-host.d.ts:25-30);本宿主在 game.init 时一次性注入。 * - GameInstance 五生命周期 init/update/render/onInput?/destroy(game-host.d.ts:42-48)经引擎五回调驱动。 * * 【双通道(P1 沿用)】 * - engineMode='stub':prepareCanvas(canvas) 2D + 浏览器 RAF + 单一墙钟(行为与 P1 stub 逐行等价,回滚锚)。 * - engineMode='real':注入引擎、setGLEnable(false)、engineInit 五回调驱动(绘制面=引擎 mainContext,与引擎粒子同屏)。 * ════════════════════════════════════════════════════════════════════════════ */ 'use strict'; // 受控面/注册器/协议号(src/core,跨通道复用;本模块不背书引擎,引擎经 opts.engine 注入)。 import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../core/plugin.js'; // runtime-probe:宿主自管六锚链(取证用;P2 通用宿主保留它作启动链证据,与 P1 同款编排)。 import { createRuntimeProbePlugin } from '../plugins/runtime-probe/impl.js'; // 引擎能力背书工厂(EngineCapabilities 三面)—— host-dev / ref real 通道共用同一份(P1 已模块化,本模块零改 import)。 import { createEngineCaps } from '../../host-dev/engine-caps.js'; // 引擎数学门面构造(纯函数,零引擎 import):real 通道用它包装引擎 lerp/smoothStep/Ease。 import { buildEngineMath } from '../../host-dev/engine-math.js'; /** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐;P1 沿用)。 */ const DPR = 2; const FIXED_DT = 1 / 60; /** * 启动通用游戏宿主。返回 host 句柄(含驱动钩子,供 onReady 组装取证 / 上层驱动)。 * * @param {Object} opts 宿主选项(P2 泛化的全部参数化入口;ref/studio 各自按形填充)。 * @param {HTMLCanvasElement} opts.canvas 可见画布(stub=2D 绘制面;real=仅占位,引擎自建 mainCanvas)。 * @param {HTMLElement} [opts.statusEl] 状态行(仅人看,可空)。 * @param {any} [opts.engine] 引擎名字空间(littlejsengine),entry 包装层在 real 通道注入;stub/node 为 undefined。 * @param {number} opts.seed 主随机种子(确定性;调用方已解析,不再读 URL)。 * @param {'play'|'evidence'} [opts.mode='play'] 运行模式(evidence=stub 下不起 RAF,由 host.stepFrames/tap 驱动)。 * @param {'stub'|'real'} [opts.engineMode='real'] 引擎通道('real' 引擎掌帧 / 'stub' 浏览器 RAF 回滚桩)。 * 注:ref 缺省 stub(兜逐像素回归)由 bootRef 包装层决定;studio 引擎包分支一律 real。 * @param {{w:number,h:number}} opts.viewport 逻辑视口(替 P1 写死的 VIEW_W/VIEW_H;P2 阶段 ref 用 390×844,后续从 GamePackage 取)。 * @param {import('../core/game-host.d.ts').GameHostFactory} opts.factory 游戏工厂(替 P1 静态 import createShopGame)。 * @param {(boot: import('../core/api.d.ts').PluginContext, host: object) => (Record|undefined)} [opts.buildFactoryOpts] * 构造工厂 opts(host wiring,如wanglanmei的 runtime:{plugins,bundle,getRealAudioCtx})。返回值原样传给 opts.factory。 * - 入参 boot=本帧受控面 PluginContext,host={bundle, plugins, getRealAudioCtx}(宿主内部句柄,供 wiring 取用)。 * - 缺省返回 undefined(纯净工厂:仅吃 boot 脊柱,不需 host 私有 wiring;P3 多数生成游戏走此默认)。 * @param {Object} [opts.plugins] 业务插件实例集(host 拥有,已注册+init;游戏经工厂 opts 复用,一职一路)。 * - 形如 { collision, physics, gamefeel, ... }(键名由游戏约定)。缺省=空集(无业务插件,仅 runtime-probe)。 * @param {Array} [opts.registerOrder] 插件注册偏序(决定 init 顺序;缺省=Object.values(opts.plugins) 的插入序)。 * @param {(host: object, internals: object) => void} [opts.onReady] 就绪钩子(受控面/游戏全就绪后回调)。 * ref 用它挂 window.__refForensics/__refChecklist/__refHost;studio/P3 通用宿主可不传(不强制每款实现取证)。 * internals={ bundle, game, probe, checklist, getGameForensics, mark, stepOneFrame }(宿主内部件,供取证组装)。 * @param {((id:string)=>void)} [opts.recHook] 引擎能力 call-ID 取证钩子(透传 createEngineCaps;缺省 no-op,渠道产物无副作用)。 * @returns {Promise} host 句柄(seed/mode/engineMode/frameCount/stepFrames/startRaf/stopRaf/tap/do/state 等驱动面)。 */ export async function bootGameHost(opts) { const canvas = opts.canvas; const statusEl = opts.statusEl || null; // 【Q4 · Option C】引擎名字空间经 opts.engine 注入(real 通道由 entry 包装层传;stub/node 为 undefined,保 node import-safe)。 const LJS = opts.engine; const seed = opts.seed >>> 0; // 调用方已解析种子(不再读 URL;ref 在包装层解析后传入)。 const mode = opts.mode || 'play'; // 'play' | 'evidence' const engineMode = opts.engineMode || 'real'; // 'real'(引擎掌帧)| 'stub'(浏览器 RAF 回滚桩)。 // 【P2 泛化 · D4】逻辑视口参数化(替 P1 写死的 render.js W/H)。 const VIEW_W = opts.viewport.w; const VIEW_H = opts.viewport.h; // 【P2 泛化 · D1】游戏工厂来源参数化(替 P1 静态 import createShopGame)。 const factory = opts.factory; // 【P2 泛化 · host wiring】构造工厂 opts 的回调(wanglanmei注 runtime;P3 纯净工厂缺省 undefined)。 const buildFactoryOpts = opts.buildFactoryOpts || (() => undefined); // 【P2 泛化 · D3】业务插件实例集(host 拥有;游戏经工厂 opts 复用)。缺省空集(仅 runtime-probe)。 const pluginInstances = opts.plugins || {}; // 插件注册偏序(决定 initAll 顺序与六锚偏序);缺省取 plugins 值的插入序。 const registerOrder = opts.registerOrder || Object.values(pluginInstances); // 就绪钩子(ref 挂取证全局;通用宿主可不传)。 const onReady = opts.onReady || (() => {}); // 引擎 call-ID 取证钩子(透传 createEngineCaps;缺省 no-op)。 const recHook = opts.recHook || (() => {}); /** 状态行(仅人看)。 */ function setStatus(msg) { if (statusEl) statusEl.textContent = msg; } /* ── 未捕获错误归集(评估门:console 零未捕获错误)── */ /** @type {Array<{type:string,message:string}>} */ const uncaught = []; window.addEventListener('error', (e) => uncaught.push({ type: 'error', message: String((e && e.message) || e) })); window.addEventListener('unhandledrejection', (e) => uncaught.push({ type: 'unhandledrejection', message: String((e && e.reason && e.reason.message) || (e && e.reason) || e) }) ); /* ── 受控音频工厂(用户手势解锁后才 new;宿主=受控面提供方)── */ let realAudioCtx = null; let audioUnlocked = false; function audioFactory() { if (!audioUnlocked) return null; if (realAudioCtx) return realAudioCtx; const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; realAudioCtx = new AC(); return realAudioCtx; } /* ── 步进/逻辑帧钟【唯一推进时钟(single source of truth),P1 mustFix #2 沿用】 ── * 本 mockNowMs 是唯一真相,在 game.update 驱动点之前自增(stub:stepOneFrame 内;real:_engUpdate 内); * bundle.clock 读它;游戏侧经 boot.ctx.time.nowMs() 读同一钟(并轨成单钟,避免双钟分裂致输入缓冲补打静默失效)。 */ let mockNowMs = 0; /* ── 受控面 bundle + 游戏实例(real:engineInit 就绪后建;stub:立即)。延后建避免快照错 ctx(§12-M4)。 ── */ /** @type {ReturnType|null} */ let bundle = null; /** @type {import('../core/game-host.d.ts').GameInstance|null} */ let game = null; /** @type {ReturnType|null} */ let probe = null; let initRes = null; let pluginSet = []; /** checklist(游戏侧自填;逐帧刷新,供取证)。 */ const checklist = {}; /** 游戏取证视图(real:setupCommon 内 game.init 后取)。 */ let gameForensics = null; /** 安全打点(probe 异常不中断启动链)。 */ function mark(anchor, extra) { try { if (probe) probe.mark(anchor, extra); } catch (e) { uncaught.push({ type: 'mark', message: String((e && e.message) || e) }); } } /** 逐帧刷新游戏取证(游戏侧实现 _refreshChecklist 时调;通用宿主不强制)。 */ function refreshForensics() { if (game && typeof game._refreshChecklist === 'function') game._refreshChecklist(checklist); } /* ════════════════════════════════════════════════════════════════════ * 共用收尾:建 bundle → 注册插件 → 建游戏 → init → 打链 → 就绪钩子(real/stub 都调) * renderCtx:游戏 render(g) 的绘制面(real=引擎 mainContext / stub=可见画布 ctx)。 * inputCanvasGetter:输入 rect 映射用的可见画布(real=引擎 mainCanvas / stub=可见画布)。 * ════════════════════════════════════════════════════════════════════ */ async function setupCommon(renderCtx, inputCanvasGetter) { // 1) bundle:context2d=绘制面(受控面 getContext2d 一次性快照此 ctx,§12-M4 必须就绪后建)。 // engineFactory:real 经 createEngineCaps 真背书引擎;stub 短路返 null(保 null-engine 回归)。 bundle = createHostDevContext({ context2d: renderCtx, seed, audioFactory, clock: () => mockNowMs, engineFactory: () => createEngineCaps({ LJS: engineMode === 'real' ? LJS : null, // stub 不持引擎名字空间(createEngineCaps 首行按 engineMode 短路返 null) engineMath: engineMode === 'real' ? buildEngineMath(LJS) : null, engineMode, recHook, // 透传 opts.recHook(ref 注 window.__engineCalls 取证;渠道产物缺省 no-op) }), }); // 2) 注册业务插件(registerOrder 决定偏序,保六锚 canonical 顺序)。 const registry = new PluginRegistry(); for (const p of registerOrder) registry.register(p); registry.useContext(bundle.context); initRes = registry.initAll(); // 3) runtime-probe:宿主自管(六锚 canonical 顺序,t_input_bound 恰在 first_paint 后)。 pluginSet = registry.list().concat(['runtime-probe']); probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true }); const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe'); mark('t_boot', { by: 'game-host', stage: 'pre-init', engineMode }); mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok }); // 4) 经装载契约建游戏实例(host 经 buildFactoryOpts 注入 wiring:wanglanmei runtime / P3 纯净工厂为 undefined)。 const factoryOpts = buildFactoryOpts(bundle.context, { bundle, plugins: pluginInstances, getRealAudioCtx: () => realAudioCtx }); game = factory(factoryOpts); // 5) game.init:建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱(ctx/mainContext/canvas/seed)。 await game.init({ ctx: bundle.context, mainContext: renderCtx, canvas: inputCanvasGetter(), seed, }); // 游戏取证视图(可选;游戏未实现 _forensicsView 时 gameForensics 保持 null,通用宿主不依赖)。 gameForensics = (game && typeof game._forensicsView === 'function') ? game._forensicsView() : null; mark('t_game_init', { hasForensics: !!gameForensics }); // 6) 首帧 + 输入锚 + 启动链收尾(与 P1 同序:first_paint → probe.init 绑输入 → game_start)。 stepOneFrame(); mark('t_first_paint', { frame: bundle.frameCount() }); try { probe.init(probeCtx); // 绑输入 → t_input_bound(恰在 first_paint 之后,偏序成立) } catch (e) { uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) }); } mark('t_game_start', { mode, engineMode }); // 7) 就绪钩子:ref 挂取证全局;通用宿主可不传。internals 暴露宿主内部件供取证组装。 refreshForensics(); onReady(host, { bundle, game, probe, checklist, uncaught, pluginSet, initRes, getGameForensics: () => gameForensics, mark, stepOneFrame, }); } /* ── 单帧推进(stub RAF / evidence stepFrames / 首帧共用):game.update + game.render(绘制面=renderCtx)。 ── */ /** @type {CanvasRenderingContext2D|null} */ let stubRenderCtx = null; function stepOneFrame(dt) { const d = typeof dt === 'number' ? dt : FIXED_DT; if (!game) return; // 唯一时钟在 game.update 之前自增(stub 驱动点):使 bundle.clock 与游戏逻辑读同一推进值(P1 mustFix #2 口径)。 mockNowMs += d * 1000; game.update(d); // stub 通道绘制:game.render(g) 自管铺底(与 P1 逐行等价);real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制。 if (engineMode === 'stub' && stubRenderCtx) { game.render(stubRenderCtx); } refreshForensics(); } /** 确定性推进 n 帧(evidence 驱动 / 启动首帧)。 */ function stepFrames(n) { for (let i = 0; i < n; i++) stepOneFrame(FIXED_DT); } /* ── 首个用户手势:解锁音频(创建/恢复 AudioContext)+ 通知游戏起 BGM。幂等。 ── */ function unlockAudioOnce() { if (audioUnlocked) return; audioUnlocked = true; const ac = audioFactory(); if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {}); if (game && typeof game._onAudioUnlock === 'function') game._onAudioUnlock(); } /* ── host 驱动句柄(evidence 驱动 / 上层 host.do;通用面,不含 ref 专属取证——取证由 onReady 组装)── */ const host = { seed, mode, engineMode, protocol: CORE_PROTOCOL_VERSION, frameCount: () => (bundle ? bundle.frameCount() : 0), stepFrames, startRaf: () => startRaf(), stopRaf: () => stopRaf(), /** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */ tap(x, y) { unlockAudioOnce(); if (bundle) { bundle.inputBridge._emit('pointerdown', { x, y }); bundle.inputBridge._emit('pointerup', { x, y }); } }, /** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API)。 */ do(action) { if (game && typeof game._handleAction === 'function') game._handleAction(action); }, /** 游戏状态快照(游戏实现 _forensicsView().state 时透传;否则 booting)。 */ state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }), }; /* ════════════════════════════════════════════════════════════════════ * 输入桥接:DOM 事件 → 归一化坐标 → 受控桥 _emit * real:监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4);stub:挂可见画布(逐行不变)。 * ════════════════════════════════════════════════════════════════════ */ function attachInput(targetCanvas) { const inputBridge = bundle.inputBridge; function toCanvasXY(clientX, clientY) { const rect = targetCanvas.getBoundingClientRect(); return { x: ((clientX - rect.left) / rect.width) * VIEW_W, y: ((clientY - rect.top) / rect.height) * VIEW_H, }; } targetCanvas.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); inputBridge._emit('pointerdown', toCanvasXY(ev.clientX, ev.clientY)); }); targetCanvas.addEventListener('pointermove', (ev) => { inputBridge._emit('pointermove', toCanvasXY(ev.clientX, ev.clientY)); }); targetCanvas.addEventListener('pointerup', (ev) => { inputBridge._emit('pointerup', toCanvasXY(ev.clientX, ev.clientY)); }); // 键盘桥接:挂 window(键盘无 canvas rect,坐标无关),补齐 getInput 契约的 key 两态。 // 对齐同仓 host-dev/host.js:427-428 已有范式;W-G1 scale-20 两轮对抗诊断实证: // 原 attachInput 漏此桥 → 所有方向键/空格游戏(2048/Tetris/Asteroids)输入永远收不到、score≈0、phase 永久 playing。 window.addEventListener('keydown', (ev) => { unlockAudioOnce(); inputBridge._emit('keydown', { key: ev.key }); }); window.addEventListener('keyup', (ev) => inputBridge._emit('keyup', { key: ev.key })); } /* ── RAF(stub play 模式)── */ let rafId = 0; let rafRunning = false; let lastTs = 0; function rafLoop(ts) { if (!rafRunning) return; if (lastTs === 0) lastTs = ts; let dt = (ts - lastTs) / 1000; lastTs = ts; if (dt > 0.05) dt = 0.05; // 钳大跳帧 stepOneFrame(dt); rafId = window.requestAnimationFrame(rafLoop); } function startRaf() { if (engineMode !== 'stub') return; // real 通道引擎内部 RAF 驱动五回调,host 不起 RAF if (rafRunning) return; rafRunning = true; lastTs = 0; rafId = window.requestAnimationFrame(rafLoop); } function stopRaf() { rafRunning = false; if (rafId) window.cancelAnimationFrame(rafId); rafId = 0; } /* ════════════════════════════════════════════════════════════════════ * stub 通道(回滚锚):prepareCanvas(canvas) 2D + 共用收尾 + 输入挂可见画布 + 浏览器 RAF/evidence * ════════════════════════════════════════════════════════════════════ */ /** Canvas 设为逻辑 VIEW_W×VIEW_H / 物理 ×DPR,并把 2D 上下文缩放到逻辑坐标(stub 通道用)。 */ function prepareCanvas(c) { c.width = VIEW_W * DPR; c.height = VIEW_H * DPR; c.style.width = VIEW_W + 'px'; c.style.height = VIEW_H + 'px'; const g = c.getContext('2d', { willReadFrequently: true }); g.setTransform(DPR, 0, 0, DPR, 0, 0); return g; } async function setupStub() { const g = prepareCanvas(canvas); stubRenderCtx = g; await setupCommon(g, () => canvas); // 绘制面=可见画布 ctx;输入画布=可见画布 attachInput(canvas); // 输入监听挂可见画布 if (mode === 'evidence') { setStatus(`evidence 就绪 seed=${seed} · 用 host 驱动(stepFrames/tap)`); } else { setStatus(`seed=${seed} · 点开始(首次点击开声)`); startRaf(); } return host; } /* ════════════════════════════════════════════════════════════════════ * real 通道(引擎掌帧):setGLEnable(false) → engineInit 五回调 → setupReal(bundle/game/输入/就绪标记全在 then 内) * 仿 host.js:897-928。控制流=实质重构(不是局部插桩,§12-M4)。 * ════════════════════════════════════════════════════════════════════ */ // 引擎五回调实体经 holder 转发到 setupReal 内真实现(engineInit 须先拿到回调)。 /** @type {() => void} */ let _engUpdate = () => {}; /** @type {() => void} */ let _engRender = () => {}; function engGameInit() { window.__gameHostEngineInitFired = true; } function engGameUpdate() { _engUpdate(); } function engGameUpdatePost() { /* 无需逻辑步后处理 */ } function engGameRender() { _engRender(); } function engGameRenderPost() { /* 后处理走 game.render 内(如 palettePost.renderPost);此回调留空 */ } /** * setupReal:引擎 engineInit 就绪后(mainContext 存在)的收尾。一切 bundle/game/输入/就绪标记都在此(§12-M1/M4)。 * @param {CanvasRenderingContext2D} mainContext 引擎 mainContext(纯 2D 绘制面) */ async function setupReal(mainContext) { // 共用收尾:bundle context2d=mainContext(§12-M4 必须 then 内以 mainContext 建);输入画布=引擎 mainCanvas。 await setupCommon(mainContext, () => LJS.mainCanvas); // 输入监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4)。 attachInput(LJS.mainCanvas); // 引擎五回调实体接线:每逻辑步 game.update(FIXED_DT);每渲染帧 game.render(mainContext)(引擎已清屏)。 // real 下 DPR 补偿:引擎 mainContext 无 DPR 变换,游戏按逻辑像素绘制 → save/restore 内补 setTransform(DPR) + imageSmoothingEnabled=false。 // 唯一时钟在 game.update 之前自增(与 stub stepOneFrame 同口径,P1 mustFix #2)。 _engUpdate = () => { if (game) { mockNowMs += FIXED_DT * 1000; game.update(FIXED_DT); } }; _engRender = () => { if (!game) return; mainContext.save(); mainContext.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保游戏逻辑像素口径 try { mainContext.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ } game.render(mainContext); mainContext.restore(); }; // 引擎自建 mainCanvas 打 id(CDP 取证选择器=#game-engine;real 下可见画布是引擎 mainCanvas)。 if (LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine'; // 暴露引擎双时钟(仿 host.js:840;掌帧断言读 window.__engine.snapshot())。 window.__engine = { frame() { return LJS.frame; }, time() { return LJS.time; }, timeReal() { return LJS.timeReal; }, paused() { return LJS.paused; }, timeScale() { return LJS.timeScale; }, snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; }, }; setStatus(`engine RAF running · seed=${seed} engine=real`); } /* ════════════════════════════════════════════════════════════════════ * 通道分流:stub(回滚锚)| real(引擎掌帧) * ════════════════════════════════════════════════════════════════════ */ if (engineMode !== 'real') { // stub 回滚通道:行为与 P1 stub 逐行等价(逐像素门兜)。 return await setupStub(); } // 【Option C 守卫】real 通道必须由 entry 包装层注入引擎(opts.engine);缺失说明未走 entry 包装。 if (!LJS) { const err = 'real 通道未注入引擎(opts.engine 缺失):请经 entry 包装层启动(host 不直接 import 引擎,Q4/Option C)'; setStatus('ENGINE INJECT MISSING: ' + err); throw new Error(err); } // real 通道:引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。 // 1) setGLEnable(false):纯 2D 渲进 mainContext,readback 无 WebGL 坑(harness §5-坑5)。 // 2) setShowSplashScreen(false):关开屏遮挡(默认已 false,显式兜底)。 // 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true,每帧画非确定 averageFPS 进 mainContext,污染 readback,必须关)。 // 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0) 不铺底;强制不透明 BLACK,底色语义与 stub clearBlack 对齐。 LJS.setGLEnable(false); LJS.setShowSplashScreen(false); LJS.setDebugWatermark(false); LJS.setCanvasClearColor(LJS.BLACK); // 固定 backing=VIEW_W×VIEW_H × DPR(取证固定环境)。 LJS.setCanvasFixedSize(LJS.vec2(VIEW_W * DPR, VIEW_H * DPR)); // rootElement:用专用空容器(引擎 engineInit 会整体覆盖容器样式,不能复用承载页面壳的容器)。建独立 div 挂 body。 const engineRoot = document.createElement('div'); engineRoot.id = 'engine-root'; document.body.appendChild(engineRoot); // engineInit 异步:resolve 后 mainContext 就绪 → setupReal 建 bundle/game/输入/就绪标记。 await LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot) .then(() => setupReal(LJS.mainContext)) .catch((e) => { // 引擎接管失败:镜像错因,重抛供上层捕获。 window.__gameHostBootError = String((e && e.message) || e); setStatus('ENGINE INIT FAILED: ' + window.__gameHostBootError); throw e; }); return host; }