lili d754b71e65 fix(host): attachInput 补 keydown/keyup window 桥接(getInput 契约 key 两态)
boot-game-host.js attachInput() 原只桥 pointerdown/move/up,漏 keydown/keyup → ctx.getInput() 键盘通道空,所有方向键/空格游戏(2048/Tetris/Asteroids)输入死。对齐同仓 host-dev/host.js:427-428 范式挂 window 桥 ev.key。纯增量、零兼容风险(inputBridge _assertType 白名单已含 keydown/keyup)。W-G1 scale-20 两轮对抗诊断实证:失败严格沿键盘 vs 指针分界。属 L0 域冻结契约修复,经创始人授权隔离执行,供 cherry-pick 到 dev/2.0.0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:34:43 -07:00

458 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, unknown>|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<object>} [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<object>} 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<typeof createHostDevContext>|null} */
let bundle = null;
/** @type {import('../core/game-host.d.ts').GameInstance|null} */
let game = null;
/** @type {ReturnType<typeof createRuntimeProbePlugin>|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;
}