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>
458 lines
28 KiB
JavaScript
458 lines
28 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|