/** * host-dev/host.js — host-dev 宿主页引导(浏览器侧 host,core-protocol-v0 受控面真实接线) * owner:T1b-α 集成段 | 消费方:host-dev/index.html(经 entry.js 打包)+ 集成段 CDP 证据 harness * * ════════════════════════════════════════════════════════════════════════════ * 【本文件做什么】 * 在真实浏览器里,把 core 的「host-dev 受控面桩」接到真 DOM/Canvas/输入/音频上: * · Canvas 390×844、DPR2(取证固定环境,与 browser-evidence.cjs FIXED_ENV 对齐); * · RAF 帧循环 → 驱动 bundle.tick(dt) → 推进所有插件 onFrame(host-dev 桩语义模拟引擎主循环); * · 真实 pointer + keyboard 事件 → 归一化坐标 → 灌进 core 的 HostDevInputBridge._emit(受控输入面); * · AudioContext 工厂(用户手势解锁后才 new,符合浏览器自动播放策略 + P6 容错降级); * · URL 参数 ?seed= 注入主随机种子;?mode=smoke 挂全九插件并做各自最小调用演示; * · window.__probe 导出 probe 的 JSONL + verifyChain 结果;window.__hostdev 暴露确定性取证入口。 * * 【α 阶段不接 littlejsengine(裁定留痕)】 * 发行版 v0 = core + 插件。引擎接线(LittleJS gameUpdate/overlay canvas)由参考件循环按需决定。 * 故本 host 用「浏览器 RAF + bundle.tick + 一张普通 2D canvas」充当「host-dev 引擎桩」, * 插件经受控面 getContext2d/onFrame 拿到的就是这张 canvas 与这个帧驱动——受控面同形, * 将来换真引擎只换本 host 实现,插件零改(这正是受控面「引擎可换」铁律的兑现点)。 * * 【确定性取证(与 harness 固定环境对齐)】 * 取证不走真实 RAF 墙钟,而走 window.__hostdev 暴露的「步进时钟 + 定量推帧」入口: * 固定 seed + 固定帧号 ⇒ 逐像素确定 ⇒ ImageData FNV-1a 哈希可作回归断言。 * ════════════════════════════════════════════════════════════════════════════ */ 'use strict'; import { PluginRegistry, createHostDevContext, registerAllPlugins, createExamplePlugin, createRuntimeProbePlugin, CORE_PROTOCOL_VERSION, } from '../src/all-plugins.js'; // 【A0:引擎主循环接管】littlejsengine import 唯一落点之一(集成段 host,与 entry.js 并列)。 // Q4 铁律:禁把引擎 import 泄进受控面契约(api.d.ts/plugin.js)或任何插件 impl;只活在本 host。 // esbuild 走 package main=dist/littlejs.esm.js(ESM,开发构建);box2d 惰性(box2dInit 永不调)→ 不引 wasm。 // 名字空间整体导入(namespace import 不阻塞 tree-shaking 的运行时正确性,仅文档完整性): // engineInit / setGLEnable / setShowSplashScreen / setCanvasFixedSize / setDebugWatermark / // setCanvasClearColor / BLACK / vec2 / mainCanvas / mainContext / frame / time / timeReal / // paused / timeScale 等全经 LJS.* 触达。 import * as LJS from 'littlejsengine'; // 【A1】引擎数学门面构造(纯函数,零引擎 import):real 通道工厂用它包装引擎 lerp/smoothStep/Ease。 // 单列在 engine-math.js(不 import 引擎)→ node 单测可 import 它注入 mock ljs 验门面,规避 F7(host.js 顶层 import 引擎在 node 抛 window)。 import { buildEngineMath } from './engine-math.js'; // 【P1 提升】引擎能力背书工厂(EngineCapabilities 三面:math/particles/audio.synth)已从本文件闭包提升为可 import 模块, // host-dev / wanglanmei-ref real 通道共用同一份(搬运·语义零变)。本模块不 import 引擎(经入参 LJS 注入)。 import { createEngineCaps } from './engine-caps.js'; /* ────────────────────────────────────────────────────────────────────────── * 取证固定环境常量(与 test/harness/browser-evidence.cjs 的 FIXED_ENV 对齐) * ────────────────────────────────────────────────────────────────────────── */ const VIEWPORT_W = 390; // 移动竖屏基准宽 const VIEWPORT_H = 844; // 移动竖屏基准高 const DPR = 2; // 固定 devicePixelRatio(取证可复现,不随真实屏幕变) // 取证默认步进时间步(秒):60fps 名义步长,固定值保证「同帧号→同物理推进→同像素」。 const FIXED_DT = 1 / 60; /** * 读 URL 参数。 * @param {string} name * @param {string|null} def * @returns {string|null} */ function getParam(name, def) { try { const v = new URLSearchParams(window.location.search).get(name); return v == null ? (def == null ? null : def) : v; } catch { return def == null ? null : def; } } /** * 解析种子参数:?seed=12345 → 12345;缺省/非法 → 一个固定默认种子(保证「不传也确定」)。 * @returns {number} */ function resolveSeed() { const raw = getParam('seed', null); if (raw == null) return 0x1234abcd >>> 0; // 默认主种子(确定) const n = Number(raw); return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0; } /* ────────────────────────────────────────────────────────────────────────── * Canvas 准备:固定逻辑尺寸 390×844 + DPR2 物理像素 * ────────────────────────────────────────────────────────────────────────── */ /** * 把一个 canvas 设为「逻辑 390×844、物理 ×DPR」,并把 2D 上下文缩放到逻辑坐标系。 * 插件经受控面拿到的 ctx2d 即此上下文:插件按逻辑像素绘制,物理分辨率由 DPR 放大(取证清晰)。 * @param {HTMLCanvasElement} canvas * @returns {CanvasRenderingContext2D} */ function prepareCanvas(canvas) { canvas.width = VIEWPORT_W * DPR; // 物理像素宽(780) canvas.height = VIEWPORT_H * DPR; // 物理像素高(1688) canvas.style.width = VIEWPORT_W + 'px'; // CSS 逻辑宽 canvas.style.height = VIEWPORT_H + 'px'; // CSS 逻辑高 // willReadFrequently:取证要频繁 getImageData,提示浏览器走可读优化路径。 const ctx = canvas.getContext('2d', { willReadFrequently: true }); // 缩放到逻辑坐标系:插件以 390×844 逻辑像素绘制,自动映射到 ×DPR 物理像素。 ctx.setTransform(DPR, 0, 0, DPR, 0, 0); return ctx; } /** * 用纯黑不透明底铺满画布(取证「非空」语义:背景本身是不透明像素, * 但 vfx 像素证据用「区域几何命中 + 直方图主色变化」判,不靠全屏非空,故底色不掩盖效果差异)。 * @param {CanvasRenderingContext2D} ctx */ function clearBlack(ctx) { ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); // 临时回到物理像素铺底 ctx.fillStyle = '#000'; ctx.fillRect(0, 0, VIEWPORT_W * DPR, VIEWPORT_H * DPR); ctx.restore(); } /* ────────────────────────────────────────────────────────────────────────── * 归一化输入:真实 DOM 事件坐标 → 画布逻辑坐标(390×844 内) * ────────────────────────────────────────────────────────────────────────── */ /** * 把一个鼠标/触摸 client 坐标换算成画布逻辑坐标(0..390 / 0..844)。 * @param {HTMLCanvasElement} canvas * @param {number} clientX * @param {number} clientY * @returns {{x:number, y:number}} */ function toCanvasXY(canvas, clientX, clientY) { const rect = canvas.getBoundingClientRect(); // CSS 尺寸即逻辑尺寸(390×844),故直接相对偏移即逻辑坐标。 const x = ((clientX - rect.left) / rect.width) * VIEWPORT_W; const y = ((clientY - rect.top) / rect.height) * VIEWPORT_H; return { x, y }; } /* ────────────────────────────────────────────────────────────────────────── * 引导主流程 * ────────────────────────────────────────────────────────────────────────── */ /** * 启动 host-dev。挂载到给定 canvas,按 URL 参数决定是否跑 smoke。 * * @param {Object} opts * @param {HTMLCanvasElement} opts.canvas 目标画布(已在 DOM 中)。 * @param {HTMLElement} [opts.statusEl] 状态文本节点(可空,用于页面可视反馈)。 * @returns {object} hostHandle(也会挂到 window.__hostdev)。 */ export function bootHostDev(opts) { const canvas = opts.canvas; const statusEl = opts.statusEl || null; const seed = resolveSeed(); const mode = getParam('mode', 'idle'); // 'idle' | 'smoke' const ctx2d = prepareCanvas(canvas); clearBlack(ctx2d); // 未捕获错误收集器:冒烟门要求「console 零未捕获错误」,这里把页面级错误也归集到探针,便于 CDP 断言。 /** @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) }); }); /* ── 受控音频工厂:用户手势解锁后才创建 AudioContext(浏览器自动播放策略)── 未解锁前工厂返回 null → P6 getAudioContext 返回 null → 全发声静默 no-op(不抛错)。 */ /** @type {AudioContext|null} */ let realAudioCtx = null; let audioUnlocked = false; /** @returns {AudioContext|null} */ function audioFactory() { // 仅在「已解锁」时创建;未解锁返回 null(P6 容错降级路径,blip 静默)。 if (!audioUnlocked) return null; if (realAudioCtx) return realAudioCtx; const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; // 环境无 Web Audio:降级 null(不抛错)。 realAudioCtx = new AC(); return realAudioCtx; } // 【P1 提升】引擎能力背书工厂 makeEngineCaps 已从此处闭包提升为可 import 模块 host-dev/engine-caps.js // 的 createEngineCaps({LJS, engineMath, engineMode, recHook})(搬运·语义零变;host-dev / ref real 通道共用)。 // buildBundle 处以闭包 makeEngineCaps 桥接(保 engineFactory 注入口径不变 + host-dev 探针 rec 经 recHook 注入)。 // 逐行保语义回归基线=门0 8/8 + real 门 6/6(提升不得改任何判据数值)。 // 创建 host-dev 受控上下文:注入「渲染面 / 主种子 / 音频工厂」。 // 【A0 改点】受控面 getContext2d 的 backing:real 模式=引擎 engineInit 自建的 mainContext(纯 2D, // setGLEnable(false) 后引擎只渲 2D 进 mainCanvas);stub 模式=host prepareCanvas(#game) 的 ctx2d(回滚桩)。 // 因 engineInit 异步,real 模式 bundle 在「引擎就绪后」创建(见 setupAfterEngine),getContext2d // 一次性快照到真 mainContext(受控面 plugin.js 的 getContext2d 读闭包捕获值,故必须就绪后建 bundle)。 // 【时钟】A0 仍用 host 步进钟 mockNowMs 作 ctx.time backing(受控面零改,A1 才透传引擎 time)。 // ⚠️ real 模式下 mockNowMs 由 engGameUpdate 每逻辑步累加 FIXED_DT*1000,本质是「逻辑帧钟」(=frame×16.67ms), // **不是墙钟**;门0 的 P75 埋点候选钟应读引擎 timeReal(纯墙钟,经 window.__engine.timeReal 暴露),不读 mockNowMs。 let mockNowMs = 0; // 步进时钟读数(毫秒):stub=墙钟近似(rafLoop)/evidence(stepFrames)+real(engGameUpdate)=逻辑帧钟。 // 记录所有经取证入口 spawn 的 continuous 发射器 id,reset() 时统一 stopEmitter 防其持续喷发。 /** @type {Set} */ const activeEmitterIds = new Set(); // per-plugin 派生随机实例登记(name → RandomSource)。 // 背景:core 的 per-plugin 派生流让每个插件 init 时拿到「自己专属的派生随机实例」(与默认 context.random 不同实例)。 // 插件(如 particles-juice)在 init 时捕获 `ctx.random` 并据其确定粒子序列。要让 vfx 像素证据「同参数重跑一致」, // host 必须能复位**插件那一份**随机实例——故这里合法地包裹 bundle.deriveContextFor(host 拥有 bundle), // 在注册器为每插件派生上下文时记下该插件的 random 引用,供 vfxFrame/paletteFrame 取证前 reseed。 /** @type {Record} */ const derivedRandoms = {}; // 引擎通道选择:?engine=real(缺省,引擎掌帧地基)| ?engine=stub(回滚桩,行为与 α 逐行等价,创始人裁)。 const engineMode = getParam('engine', 'real'); // bundle 改为延后创建(real:engineInit 就绪后;stub:立即)。所有 bundle.xxx 引用须在 setupAfterEngine 内执行。 /** @type {ReturnType|null} */ let bundle = null; // 当前绘制面(real=engine.mainContext;stub=host ctx2d)。由 setupAfterEngine 设定。 /** @type {CanvasRenderingContext2D|null} */ let renderCtx = null; /** * 建 bundle(受控上下文)并挂 deriveContextFor 记账层。renderCtxArg=绘制面(real=engine.mainContext / stub=ctx2d)。 * 必须在「绘制面就绪」后调(real 模式 = engineInit().then 之后;stub 模式 = 立即)。 * @param {CanvasRenderingContext2D} renderCtxArg */ function buildBundle(renderCtxArg) { const b = createHostDevContext({ context2d: renderCtxArg, // 受控面 getContext2d 一次性快照此 ctx(real=引擎 mainContext / stub=host #game) seed, audioFactory, clock: () => mockNowMs, // 【P1 提升】注入引擎能力工厂;工厂内按 engineMode 短路(stub→null,保 null-engine 回归)。 // host-dev 仍注入 recHook(门② 探针只在 host-dev 取证产物里,host-dev 非渠道产物,符合现状 host.js 原 rec 语义)。 // recHook 失败静默(取证副作用绝不连坐运行时);createEngineCaps 在 engineMode!=='real' 已 return null,故探针只活 real 通道。 engineFactory: () => createEngineCaps({ LJS, engineMath: buildEngineMath(LJS), engineMode, recHook: (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } }, }), }); // 合法包裹 bundle.deriveContextFor(host 拥有 bundle):注册器为每插件派生上下文时,记下该插件的 // 派生 random 实例引用。这样 host 取证时能复位「插件那一份」随机(见 derivedRandoms 注释)。 // 不改 core,不污染受控面——只是 host 在自己持有的 bundle 上挂一层记账。 const _origDerive = b.deriveContextFor.bind(b); b.deriveContextFor = function (pluginName, scope) { const ctx = _origDerive(pluginName, scope); derivedRandoms[pluginName] = ctx.random; // 记下该插件专属随机实例 return ctx; }; return b; } /** * 【A0 编排核心】引擎/桩就绪后的统一收尾:建 bundle → 注册插件 → 驱动 probe 六锚点 → 挂 __hostdev → 按 mode 收尾。 * 两条通道都调它:real(engineInit().then 内,renderCtxArg=LJS.mainContext)/ stub(立即,renderCtxArg=ctx2d)。 * 引擎掌帧的必然结构:引擎先起、canvas/mainContext 才有,故 bundle 与所有依赖 bundle 的编排都在此函数内。 * @param {CanvasRenderingContext2D} renderCtxArg 绘制面 */ function setupAfterEngine(renderCtxArg) { renderCtx = renderCtxArg; // 设当前绘制面(renderParticles/renderPostFx/renderFrame 读它) bundle = buildBundle(renderCtxArg); // 建 bundle(context2d=绘制面) // 注册 8 件业务能力插件 + _example(第 9 件,凑「全九插件」覆盖)。 // runtime-probe **不**走批量注册(__skip):它需由 host 按「启动六锚点」canonical 顺序手动驱动—— // 因为 probe 的 init 会在订阅输入时立刻打 t_input_bound(order=4),若让它随 initAll 早早 init, // t_input_bound 会先于 t_plugins_ready/t_game_init/t_first_paint 打点,破坏六锚点偏序(verifyChain 会判 out_of_order)。 // 故 host 显式控制:先 mark t_boot→plugins_ready→game_init→first_paint,再 init probe(绑输入→t_input_bound),最后 t_game_start。 const registry = new PluginRegistry(); const { instances } = registerAllPlugins(registry, { // 给 particles 固定种子,保证粒子像素确定(不依赖全局主随机推进次序)。 'particles-juice': { seed }, __skip: ['runtime-probe'], }); // 第 9 件:_example(空插件样例),单独注册以达成「全九插件」覆盖。 const exampleInst = createExamplePlugin(); registry.register(exampleInst); instances['_example'] = exampleInst; // 绑定受控上下文并 init 已注册插件(8 业务 + _example;错误隔离)。probe 由 host 另行驱动。 registry.useContext(bundle.context); const initRes = registry.initAll(); // ── runtime-probe:host 自管,按 canonical 六锚点顺序驱动 ── // pluginSet 取「已注册业务插件 + 自身」的稳定快照(probe 内部会排序,避免哈希漂移)。 const pluginSetSnapshot = registry.list().concat(['runtime-probe']); const probe = /** @type {any} */ (createRuntimeProbePlugin({ pluginSet: pluginSetSnapshot, inputAnchorType: 'pointerdown', // 与 host pointer 桥接一致:真点击 → firstInput 旁证 autoBoot: true, })); instances['runtime-probe'] = probe; // 为 probe 派生一份受控上下文(与其它插件同口径:per-plugin 随机 + 输入 scope 化,dispose 时定向回收)。 const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe'); const registeredList = pluginSetSnapshot; // ── 启动六锚点链(canonical 顺序:boot→plugins_ready→game_init→first_paint→input_bound→game_start)── // 关键编排(见上 registry 段注释):先 host 手动 mark 前四锚(t_boot 由 host 显式打,使 probe.bootMarked=true), // 再 probe.init 绑定输入(此刻才打 t_input_bound,order=4,恰在 first_paint 之后),最后 t_game_start。 // 这样 verifyChain 的偏序校验通过(六锚点严格不回退)。 // 注:前四锚在 probe.init 前打,probe 受控时钟尚未注入(tMono=0)——这是 probe「t_input_bound 必须 init 才能绑」 // 与「六锚点偏序」耦合下的取舍(probe 是冻结 lane 件,不在本工位改)。功能正确(verifyChain 不校 tMono); // t_input_bound/t_game_start 在 init 后打,tMono 为真实受控时间。已在 REPORT 留痕。 function markBootChain() { // 1) t_boot:运行时最早可观测点(host 显式打,置 bootMarked 防 probe.init 重复打)。 safeMark('t_boot', { by: 'host', stage: 'pre-init' }); // 2) t_plugins_ready:插件注册器 initAll 完成(8 业务 + _example 就绪)。 safeMark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok }); // 3) t_game_init:游戏侧初始化(host-dev 无真游戏,标记「场景对象构建好」)。 safeMark('t_game_init', { note: 'host-dev stub scene built' }); // 4) t_first_paint:首帧渲染(推进一帧并渲染后打点)。 stepFrames(1); safeMark('t_first_paint', { frame: bundle.frameCount() }); // 5) 现在才 init probe → 绑定受控输入面 → 打 t_input_bound(order=4,恰在 first_paint 之后)。 try { probe.init(probeCtx); } catch (e) { uncaught.push({ type: 'probe-init', message: String(e && e.message || e) }); } // 6) t_game_start:玩法主循环正式启动。 safeMark('t_game_start', { note: 'host-dev loop start' }); } /** 安全打点:probe 不存在/抛错时记入 uncaught,绝不中断启动链。 */ function safeMark(anchor, extra) { if (probe && typeof probe.mark === 'function') { try { probe.mark(anchor, extra); } catch (e) { uncaught.push({ type: 'mark', message: String(e && e.message || e) }); } } } /* ── 帧推进:步进时钟 + bundle.tick(host-dev 桩模拟引擎主循环一次 update)── 渲染:tick 后调各「表现层插件」的 render(粒子/后处理)把当前帧绘到 canvas。 */ /** * 确定性推进 n 帧(每帧固定 dt + 固定时钟步进 + render)。取证与启动链都走它。 * @param {number} n 帧数 */ function stepFrames(n) { for (let i = 0; i < n; i++) { mockNowMs += FIXED_DT * 1000; // 步进时钟前进(毫秒) bundle.tick(FIXED_DT); // 驱动所有插件 onFrame(含 particles 自动 step) renderFrame(); // 表现层插件把本帧画到 canvas } } /** * 复位「粒子插件那一份」派生随机到指定种子(取证确定性的关键)。 * derivedRandoms['particles-juice'] 是注册器为该插件派生的随机实例(插件 init 时已捕获它), * 故 reseed 它即真正影响后续 spawnEmitter 的粒子序列。若该实例未记到(理论不应),回退 reseed 默认随机。 * @param {number} s 种子(32 位无符号) */ function reseedParticleRandom(s) { const r = derivedRandoms['particles-juice']; if (r && typeof r.reseed === 'function') r.reseed(s >>> 0); else bundle.context.random.reseed(s >>> 0); // 兜底(不应触发) } /** * 画粒子层(引擎 gameRender 回调里调;桩模式 renderFrame 里调)。host 提供可见色(粒子不内置颜色)。 * 【A0】绘制面=renderCtx(real=engine.mainContext / stub=host ctx2d)。 * 坐标系兑现:real 模式引擎 mainContext **无 DPR 变换**(引擎 updateCanvas 每帧把变换重置为单位阵), * 而插件按 390×844 逻辑像素绘制 → host 在 save/restore 内补 setTransform(DPR) 保「逻辑像素口径不变」。 * imageSmoothingEnabled=false:关 2D 亚像素插值平滑(real 2D 哈希稳定的真旋钮;引擎 glSetAntialias 对 2D 是 no-op)。 */ function renderParticles() { const g = renderCtx; if (!g) return; const pj = /** @type {any} */ (instances['particles-juice']); if (pj && typeof pj.render === 'function') { g.save(); if (engineMode === 'real') { g.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保插件逻辑像素口径 try { g.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ } } // 【A2 语义更新】引擎粒子自带色(colorStartA/B→colorEndA/B,host 工厂已映射),引擎自渲落 mainContext,不读此 fillStyle; // 插件 pj.render(g) 删 sim 后只画 juice 闪白叠加层(叠加层自设 #ffffff fillStyle)。此行降级为「juice 叠加层兜底可见色」的防御(最小改动保留,不动 save/restore 结构)。 g.fillStyle = '#7fd0ff'; // 防御:为 juice 叠加层兜底可见色(叠加层自设 fillStyle,引擎粒子不读此) try { pj.render(g); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); } g.restore(); } } /** 画后处理叠加层(引擎 gameRenderPost 回调里调;桩模式 renderFrame 里调)。real 模式同补 DPR 变换。 */ function renderPostFx() { const g = renderCtx; if (!g) return; const pp = /** @type {any} */ (instances['palette-post']); if (pp && typeof pp.renderPost === 'function') { g.save(); if (engineMode === 'real') { g.setTransform(DPR, 0, 0, DPR, 0, 0); // palette-post 也按逻辑像素铺 try { g.imageSmoothingEnabled = false; } catch { /* 忽略 */ } } try { pp.renderPost(g); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); } g.restore(); } } /** 桩模式渲染一帧(合并粒子+后处理,底由 host 自铺黑)。引擎模式不用它(引擎分回调驱动 + 引擎清屏)。 */ function renderFrame() { if (renderCtx) clearBlack(renderCtx); // 桩模式每帧重铺黑底,避免上一帧残留干扰像素哈希 renderParticles(); renderPostFx(); } // 把渲染/步进实现暴露给「bootHostDev 作用域的引擎五回调」(引擎掌帧时由引擎内部 RAF 驱动)。 // engStep:引擎每逻辑步推进一次 bundle(固定步长 1/60,onFrame dt 须恒定,particles 时步确定)。 _renderParticles = renderParticles; _renderPostFx = renderPostFx; _engStep = function () { if (!bundle) return; mockNowMs += FIXED_DT * 1000; // host 步进钟随引擎逻辑步同步推进(real 模式此钟=逻辑帧钟,非墙钟) bundle.tick(FIXED_DT); // 驱动所有插件 onFrame(含 particles 自动 step) }; /* ── 真实输入桥接:DOM 事件 → 归一化坐标 → core 输入桥 _emit ── 注意:core 的 HostDevInputBridge 时间戳取自受控时间源(步进时钟),不取原生 event.timeStamp。 这里把真实交互喂进受控面,使 gamefeel 输入缓冲 / probe 的 firstInput 都能被真实 pointer 触发。 【A0】real 模式监听挂「引擎 mainCanvas」(坐标按引擎 canvas rect,与引擎坐标系一致),不挂 #game/window, 避免与引擎自带 document 监听坐标错位;引擎那套 document 监听 A0 期无人消费(getInput 仍走本桥), 故不构成受控面语义双发(真正收敛到引擎单源是 A1)。keydown/keyup 仍挂 window(键盘无 canvas rect 问题)。 */ const inputBridge = bundle.inputBridge; // 输入事件目标 canvas:real=引擎 mainCanvas(就绪后存在);stub=host #game。 const inputTarget = (engineMode === 'real' && LJS.mainCanvas) ? LJS.mainCanvas : canvas; function onPointer(type, ev) { const { x, y } = toCanvasXY(inputTarget, ev.clientX, ev.clientY); inputBridge._emit(type, { x, y }); } inputTarget.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); onPointer('pointerdown', ev); }); inputTarget.addEventListener('pointermove', (ev) => onPointer('pointermove', ev)); inputTarget.addEventListener('pointerup', (ev) => onPointer('pointerup', ev)); window.addEventListener('keydown', (ev) => { unlockAudioOnce(); inputBridge._emit('keydown', { key: ev.key }); }); window.addEventListener('keyup', (ev) => inputBridge._emit('keyup', { key: ev.key })); /** 首个用户手势:解锁音频(创建/恢复 AudioContext)。幂等。 */ function unlockAudioOnce() { if (audioUnlocked) return; audioUnlocked = true; const ac = audioFactory(); // 此刻 audioUnlocked=true → 真正创建 if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') { ac.resume().catch(() => {}); } } /* ── 回滚桩:浏览器 RAF 循环(仅 engineMode==='stub' 用,保留可回滚点,创始人裁)── real 模式不走它——引擎内部 RAF 驱动 bootHostDev 作用域的引擎五回调(engGameUpdate/Render/...)。 取证模式不依赖它(取证用 stepFrames 定量推进);交互演示需要它让画面动起来(stub 通道沿 α 用墙钟 dt)。 */ let rafId = 0; let lastTs = 0; let rafRunning = false; 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; // dt 封顶钳制(防卡顿后大跳) mockNowMs += dt * 1000; if (bundle) bundle.tick(dt); renderFrame(); rafId = window.requestAnimationFrame(rafLoop); } function startRaf() { if (rafRunning) return; rafRunning = true; lastTs = 0; rafId = window.requestAnimationFrame(rafLoop); } function stopRaf() { rafRunning = false; if (rafId) window.cancelAnimationFrame(rafId); rafId = 0; } /* ──────────────────────────────────────────────────────────────────────── * 全九插件最小调用演示(?mode=smoke) * 各插件最小调用一次,验证「挂得上、调得通、不抛错」。表现层插件(粒子/调色) * 的真实像素证据由 CDP harness 经 window.__hostdev 的确定性入口单独取,不在此处断言像素。 * ──────────────────────────────────────────────────────────────────────── */ function runSmoke() { setStatus('smoke: running 9-plugin minimal calls...'); // 1) collision:经实例内置 broadphase(spatial)插入一个 AABB 再查询一发(实例级能力面; // 几何窄相纯函数是模块级导出,不在实例上,故冒烟走 spatial.insert/query 验证实例能力可用)。 // 注意 Aabb = 中心+半尺寸 { x, y, hw, hh }(非 w/h),见 collision/api.d.ts。 const col = /** @type {any} */ (instances['collision']); col.spatial.insert(1, { x: 100, y: 100, hw: 10, hh: 10 }); const hits = col.spatial.query({ x: 105, y: 105, hw: 20, hh: 20 }); const hit = Array.isArray(hits) && hits.length > 0; // 2) physics-lite:纯运动原语,能力面经模块级导出(非实例方法),故冒烟只确认实例已 init // (probe 的 t_plugins_ready 已覆盖全插件就绪);物理纯函数的数值证据由其单测覆盖,不在冒烟重复。 const phyOk = !!instances['physics-lite']; // 3) gamefeel:输入缓冲已在 init 接好;演示「真输入→缓冲命中」留 CDP firstInput 旁证。 const gf = /** @type {any} */ (instances['gamefeel']); const hasBuffer = !!(gf && gf.inputBuffer); // 4) particles-juice:burst 一发(演示发射器;像素证据走 __hostdev.vfxFrame)。 const pj = /** @type {any} */ (instances['particles-juice']); pj.spawnEmitter('burst', VIEWPORT_W / 2, VIEWPORT_H / 2); // 5) palette-post:开 vignette 一帧(演示后处理;像素证据走 __hostdev.paletteFrame)。 const pp = /** @type {any} */ (instances['palette-post']); pp.setPost({ vignette: { enabled: true, strength: 0.6 } }); // 6) audio-music:blip 一声(无手势则 getAudioContext=null → 静默 no-op,不抛错)。 const am = /** @type {any} */ (instances['audio-music']); am.playSfx('blip'); // 7) save-progress:set/get 一对(内存 adapter 兜底)。 const sp = /** @type {any} */ (instances['save-progress']); sp.set('smoke', { ok: true, t: Date.now() }); const readBack = sp.get('smoke'); // 8) runtime-probe:六锚点链已打(markBootChain);这里只读校验。 // 9) _example:调用其探针(无玩法语义的空插件,验证「第 9 件挂得上」)。 const ex = /** @type {any} */ (instances['_example']); const exProbe = (ex && typeof ex.probe === 'function') ? ex.probe() : null; // 推几帧让粒子有可见状态(供截图与像素哈希)。 stepFrames(8); setStatus('smoke: done. uncaught=' + uncaught.length); return { collisionHit: hit, physicsOk: phyOk, gamefeelHasBuffer: hasBuffer, particleCount: pj.particleCount(), saveReadBack: readBack, audioSilentNoops: (am && typeof am.probe === 'function') ? am.probe().silentNoops : null, exampleProbe: exProbe, }; } function setStatus(msg) { if (statusEl) statusEl.textContent = msg; } /* ──────────────────────────────────────────────────────────────────────── * 探针导出 + 确定性取证入口(挂 window) * ──────────────────────────────────────────────────────────────────────── */ /** 导出 probe 的 JSONL + verifyChain 结果 + 未捕获错误清单(CDP 取证用)。 */ function probeExport() { const jsonl = (probe && typeof probe.toJSONL === 'function') ? probe.toJSONL() : ''; const verify = (probe && typeof probe.verify === 'function') ? probe.verify() : { ok: false, reason: 'no-probe' }; const records = (probe && typeof probe.getRecords === 'function') ? probe.getRecords() : []; return { protocol: CORE_PROTOCOL_VERSION, seed, mode, jsonl, verify, anchors: records.map((r) => r.anchor), recordCount: records.length, uncaught: uncaught.slice(), registeredPlugins: registeredList, initOk: initRes.ok, initErrors: initRes.errors.map((e) => ({ name: e.name, message: e.error.message })), }; } // window.__probe:题面要求的导出口(JSONL + verifyChain)。 window.__probe = probeExport; /** * 确定性取证入口(CDP harness 用)。所有方法都「先复位到确定起点 → 操作 → 推固定帧」, * 保证「同参数重跑逐像素一致」。 */ const hostdev = { VIEWPORT_W, VIEWPORT_H, DPR, seed, /** 取协议版本。 */ protocol() { return CORE_PROTOCOL_VERSION; }, /** 取当前帧数。 */ frameCount() { return bundle.frameCount(); }, /** 探针导出(同 window.__probe)。 */ probe: probeExport, /** 启动真实 RAF 交互循环。 */ startRaf, /** 停止真实 RAF。 */ stopRaf, /** 确定性推进 n 帧(不依赖真实 RAF)。 */ stepFrames, /** * vfx 像素证据入口:用指定预设在「固定 seed + 固定帧号」渲染,返回时画面即第 frames 帧。 * 取证纪律:调用方须**先调 reset()** 清残留与活动发射器,再单次调本方法(见 harness 顺序), * 杜绝跨组粒子叠加污染像素哈希。 * 流程:关后处理 → reseed 到 presetSeed → spawn 预设(continuous 模式记 id 以便 reset 停)→ 推 frames 帧。 * @param {'burst'|'trail'|'drift'} preset 预设名 * @param {number} presetSeed 该次取证用的随机种子(与默认主种子独立,便于对照) * @param {number} frames 推进帧数(固定帧号) * @param {{x?:number,y?:number}} [origin] 发射原点(缺省屏幕中心) */ vfxFrame(preset, presetSeed, frames, origin) { const pj = /** @type {any} */ (instances['particles-juice']); const pp = /** @type {any} */ (instances['palette-post']); // 关掉后处理,避免 vignette/dither 干扰「纯粒子」像素对照。 pp.setPost({ vignette: { enabled: false }, dither: { enabled: false }, scanline: { enabled: false } }); // 重设**粒子插件那一份**派生随机(非默认 context.random),使粒子序列在「干净起点」确定可复现。 reseedParticleRandom(presetSeed >>> 0); const ox = (origin && typeof origin.x === 'number') ? origin.x : VIEWPORT_W / 2; const oy = (origin && typeof origin.y === 'number') ? origin.y : VIEWPORT_H / 2; const id = pj.spawnEmitter(preset, ox, oy); // continuous 预设(trail/drift)返回长效 id;记下,reset() 时统一 stopEmitter 防其持续喷发。 if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id); if (engineMode === 'stub') { // 桩通道:手摇定量推帧(确定性逐像素,原样 α 行为)。逐像素哈希回归由本通道独占兜底。 stepFrames(frames); return { particleCount: pj.particleCount(), frame: bundle.frameCount(), engineMode }; } // 【A0 P0 修正】real 通道**不承担逐像素确定性**(由 stub 通道兜)。 // 原因(已核验 littlejs.esm.js:256 `for(;frameTimeBufferMS>=0;frameTimeBufferMS-=1e3/frameRate)`): // 引擎一个 RAF 内的追帧步数 N 由墙钟抖动决定,外部轮询 `frameCount>=startFrame+frames` 会 OVERSHOOT // (从 frames-1 一跳到 frames+2),采到哪一帧不确定 → same-seed 重跑像素不一致。故 real 不做 `>=`+异步采样。 // real 通道只证「引擎掌帧 + 渲染落 2D mainContext(门0 G0-2/G0-7)」,不证 same-pixel。 // 【A2 更新】插件 sim 已删——real 通道粒子真身=引擎 ParticleEmitter(经 getEngine().particles 构造,自入 engineObjects), // 由引擎内部 RAF 每帧自 update+render 落 mainContext,与 host tick 解耦。这里 bundle.tick 仅推进插件 juice 计时(补层); // 引擎粒子的发射/积分/渲染全由引擎掌(gameRender 回调里 renderParticles 现只叠 juice 闪白层,不再画粒子)。 for (let i = 0; i < frames; i++) { mockNowMs += FIXED_DT * 1000; bundle.tick(FIXED_DT); } return { particleCount: pj.particleCount(), frame: bundle.frameCount(), engineMode, perPixelDeterministic: false }; }, /** * 后处理像素证据入口:开/关某后处理效果并渲染,返回时画面即该状态。 * 取证纪律:调用方对「开/关两帧」应各自先 reset() 再调本方法,且两次 effSeed 一致—— * 保证两帧除目标后处理外底完全相同(差异仅来自后处理本身)。 * @param {'vignette'|'dither'|'scanline'} effect * @param {boolean} enabled * @param {number} effSeed 底层粒子的种子(开关两帧须一致) * @param {number} frames 推进帧数 */ paletteFrame(effect, enabled, effSeed, frames) { const pj = /** @type {any} */ (instances['particles-juice']); const pp = /** @type {any} */ (instances['palette-post']); // 关掉全部后处理,再按需开目标效果——保证开/关两帧除目标效果外完全一致。 pp.setPost({ vignette: { enabled: false }, dither: { enabled: false }, scanline: { enabled: false } }); const cfg = {}; cfg[effect] = { enabled, strength: 0.7 }; pp.setPost(cfg); // 确定性底:reseed 粒子插件随机后 spawn 一发 drift(宽幅铺像素,便于 dither/vignette 在其上产生可见差异)。 reseedParticleRandom(effSeed >>> 0); const id = pj.spawnEmitter('drift', VIEWPORT_W / 2, VIEWPORT_H / 2); if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id); if (engineMode === 'stub') { stepFrames(frames); // 桩:逐像素确定(开/关两帧除目标后处理外一致),原样 return { effect, enabled, frame: bundle.frameCount(), engineMode }; } // 【A0 P0 修正】real 通道不承担逐像素确定性(同 vfxFrame,避免 overshoot)。仅推进 host 状态,引擎自渲。 for (let i = 0; i < frames; i++) { mockNowMs += FIXED_DT * 1000; bundle.tick(FIXED_DT); } return { effect, enabled, frame: bundle.frameCount(), engineMode, perPixelDeterministic: false }; }, /** * 复位渲染状态到「干净黑底 + 无后处理 + 随机源回主种子 + 无活动粒子/发射器」。 * vfx/palette 取证每组之前必调,杜绝跨组残留污染像素哈希。 * 实现:先 stopEmitter 掉所有记下的 continuous 发射器(防其继续喷发),再连续推帧让现存粒子 * 自然消亡(drift lifeMax=3s → 推 ≥200 帧覆盖),最后铺黑底 + reseed 回主种子。 * @param {number} [extraFrames] 推帧消亡帧数(缺省 220,覆盖最长寿命 drift 3s@60fps=180)。 */ reset(extraFrames) { const pj = /** @type {any} */ (instances['particles-juice']); const pp = /** @type {any} */ (instances['palette-post']); // 1) 停掉所有记下的活动发射器(continuous 不停会一直补粒子,永远清不空)。 for (const id of activeEmitterIds) { try { pj.stopEmitter(id); } catch (e) { /* 幂等,忽略 */ } } activeEmitterIds.clear(); pp.setPost({ vignette: { enabled: false }, dither: { enabled: false }, scanline: { enabled: false } }); // 2) 不再 spawn,连续推帧让现存粒子消亡(手调 bundle.tick,粒子状态是 host 侧 bundle 管的, // real 模式同样有效;屏幕清空 real 靠引擎下一拍 updateCanvas 重设 width,stub 靠下方 clearBlack)。 const n = typeof extraFrames === 'number' ? extraFrames : 220; for (let i = 0; i < n; i++) { mockNowMs += FIXED_DT * 1000; bundle.tick(FIXED_DT); } // 桩模式 host 自铺黑清屏;real 模式引擎每帧自清(updateCanvas 重设 mainCanvas.width),此处仅对 stub 生效。 if (engineMode === 'stub' && renderCtx) clearBlack(renderCtx); // 回主种子(默认 context.random 与粒子插件随机都复位,便于下一组从确定起点取)。 bundle.context.random.reseed(seed >>> 0); reseedParticleRandom(seed >>> 0); return { residualParticles: pj.particleCount() }; }, /** 模拟一次真实点击(CDP 也会用 Input.dispatch,但这里给纯 JS 旁路便于自验)。 */ emitPointerDown(x, y) { inputBridge._emit('pointerdown', { x: typeof x === 'number' ? x : VIEWPORT_W / 2, y: typeof y === 'number' ? y : VIEWPORT_H / 2 }); }, /** 解锁音频(供 CDP 在「有手势」场景下显式解锁后验 blip 不再静默)。 */ unlockAudio: unlockAudioOnce, /** 当前是否已解锁音频。 */ audioUnlocked() { return audioUnlocked; }, /** 【真接线门②】清空引擎调用记录(CDP 取证前调,隔离上一组残留)。 */ resetEngineCalls() { try { window.__engineCalls = []; } catch (_) { /* 忽略 */ } return true; }, /** * 【真接线门②】经各插件**真实公开 API** 触发引擎能力调用,证「插件运行时真的经 ctx.getEngine() 调到引擎」。 * 每路独立 try/catch(互不连坐),返回各路是否成功触发;真凭据看 window.__engineCalls 里 rec 的 call-ID。 * @returns {{particles:boolean, audioSfx:boolean, easing:boolean}} */ engineProbe() { const fired = { particles: false, audioSfx: false, easing: false }; // 音频合成需 AudioContext 非空(audio-music.playSfx 在 ac==null 时早返不合成 → rec 不触发),故先解锁。 try { unlockAudioOnce(); } catch (_) { /* 忽略 */ } // 粒子:经 particles-juice 真实 spawnEmitter(与 vfxFrame 同路,独立触一次保门②可证)→ getEngine().particles.spawnEmitter。 try { const pj = /** @type {any} */ (instances['particles-juice']); if (pj && pj.spawnEmitter) { pj.spawnEmitter('burst', VIEWPORT_W / 2, VIEWPORT_H / 2); fired.particles = true; } } catch (e) { console.error('[a6 probe] particles:', e && e.message ? e.message : e); } // 音频:经 audio-music 真实 playSfx('blip') → _synth.synthSfx → getEngine().audio.synth.synthSfx → 引擎 zzfxG。 try { const am = /** @type {any} */ (instances['audio-music']); if (am && am.playSfx) { am.playSfx('blip'); fired.audioSfx = true; } } catch (e) { console.error('[a6 probe] audio:', e && e.message ? e.message : e); } // 缓动:经 gamefeel 真实 easing 门面 quadIn → getEngine().math.easing.quadIn → 引擎 Ease.POWER(2)。 try { const gf = /** @type {any} */ (instances['gamefeel']); if (gf && gf.easing && gf.easing.quadIn) { gf.easing.quadIn(0.5); fired.easing = true; } } catch (e) { console.error('[a6 probe] easing:', e && e.message ? e.message : e); } return fired; }, /** 取当前绘制面对应的 canvas 引用(real=引擎 mainCanvas / stub=host #game)。供 CDP 抓正确画布。 */ canvas() { return (engineMode === 'real' && LJS.mainCanvas) ? LJS.mainCanvas : canvas; }, }; // 【A0/门0 R-C】real 模式给引擎自建 mainCanvas 打 id,CDP 取证选择器用 #game-engine(不是退役的 #game)。 if (engineMode === 'real' && LJS.mainCanvas) { LJS.mainCanvas.id = 'game-engine'; } // 【A0/门0 P1】暴露引擎钟给 probe/门0 实测脚本(host 私有探活口,非受控面 PluginContext 成员,不破抽象边界)。 // 双时钟物理隔离: // · engine.time=污染钟(受 timeScale/暂停,littlejs.esm.js:259 time=frame++/frameRate)——仅作「接管断言」旁证。 // · engine.timeReal=纯墙钟(:217 timeReal+=frameTimeDeltaMS*debugScale/1e3,不受 pause/timeScale/帧率钳制) // ——这才是门9 P75 埋点候选钟(real 模式 host mockNowMs 退化为逻辑帧钟,不可作墙钟 P75)。 // 读自引擎不同导出变量(ESM live binding),互不覆盖。 if (engineMode === 'real') { window.__engine = { frame() { return LJS.frame; }, // 单调帧号 time() { return LJS.time; }, // 污染钟(timeScale/暂停影响)——接管断言用 timeReal() { return LJS.timeReal; }, // 纯墙钟——P75 埋点候选(门9 旁路读它,不读 mockNowMs) paused() { return LJS.paused; }, timeScale() { return LJS.timeScale; }, // 门0 一次取齐 snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; }, }; } window.__hostdev = hostdev; // real 模式异步挂载点(CDP 取证应等 window.__hostBootedReal 再驱动) // ── 执行启动链打点 + 按模式收尾 ── markBootChain(); if (mode === 'smoke') { // smoke 模式:跑全九插件最小调用演示;不自动启动 RAF(保画面停在确定帧,便于截图与 firstInput 旁证)。 const smokeResult = runSmoke(); window.__smokeResult = smokeResult; } else if (mode === 'evidence') { // evidence 模式:只初始化,**不** runSmoke。stub 通道不自动 RAF(确定性取证); // real 通道引擎内部 RAF 已在跑(画面动),但逐像素确定性由 stub 通道兜(real 不手摇)。 setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed + ' engine=' + engineMode); } else { // idle(缺省):real 模式引擎内部 RAF 已驱动五回调(无需 host startRaf);stub 模式起 host RAF。 setStatus('idle: ' + (engineMode === 'real' ? 'engine RAF running. ' : '') + 'click canvas to spawn particles. seed=' + seed); if (engineMode === 'stub') startRaf(); } } // ← setupAfterEngine 结束 /* ──────────────────────────────────────────────────────────────────────── * 【A0】引擎五回调(engineMode==='real'):engineInit 接管帧驱动 + 渲染。 * 这些函数定义在 bootHostDev 作用域(engineInit 须在 setupAfterEngine 之前拿到它们), * 实体逻辑经模块级 holder(_renderParticles/_renderPostFx/_engStep)转发到 setupAfterEngine 内的真实现。 * · gameInit:引擎首帧前一次性初始化(host-dev 无真游戏,留痕供 probe 旁证)。 * · gameUpdate:引擎每逻辑步调一次 → host 固定步长 1/60 推进 bundle(driver=引擎内部 RAF)。 * · gameUpdatePost:逻辑步后处理钩子(host-dev 无需,留空)。 * · gameRender:引擎清屏后 → host 画粒子层到 engine.mainContext。 * · gameRenderPost:渲染末尾 → host 画后处理叠加层。 * ──────────────────────────────────────────────────────────────────────── */ /** @type {() => void} */ let _renderParticles = () => {}; /** @type {() => void} */ let _renderPostFx = () => {}; /** @type {() => void} */ let _engStep = () => {}; function engGameInit() { window.__engineGameInitFired = true; } function engGameUpdate() { _engStep(); } function engGameUpdatePost() { /* host-dev 无需逻辑步后处理 */ } function engGameRender() { _renderParticles(); } function engGameRenderPost() { _renderPostFx(); } // ── 通道分流:real(引擎掌帧,地基)| stub(回滚桩,创始人裁)── if (engineMode === 'stub') { // 回滚通道:行为与 α 桩逐行等价(host prepareCanvas(#game) + 浏览器 RAF + bundle.tick)。 setupAfterEngine(ctx2d); window.__hostBootedReal = false; // 标记非 real 通道 return window.__hostdev; } // real 通道(缺省,A0 地基):引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。 // 1) setGLEnable(false):引擎跳 glCanvas(glInit:8765 守卫),纯 2D 渲进 mainContext,readback 无 WebGL 坑。 // 2) setShowSplashScreen(false):关开屏遮挡(默认已 false,显式兜底)。 // 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true,:1144 每帧画进 mainContext, // averageFPS 是墙钟派生非确定值 → 污染 readback、击穿「干净取证」前提,**必须显式关**)。 // 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0),updateCanvas:378 `if(a>0&&!glEnable)` // 为假 → 不铺底,清屏只靠 mainCanvas.width 重设(置回透明黑)。强制设不透明 BLACK,使底色语义与 α clearBlack 对齐。 LJS.setGLEnable(false); LJS.setShowSplashScreen(false); LJS.setDebugWatermark(false); LJS.setCanvasClearColor(LJS.BLACK); // 固定 backing=780×1688(=逻辑 390×844 × DPR2,取证固定环境)。注意:DPR 体现在 backing 锁死, // CSS 显示尺寸由引擎按窗口 aspect-fit 自管(updateCanvas fixed-size 分支 100%/''),与插件 setTransform(DPR) 无关。 LJS.setCanvasFixedSize(LJS.vec2(VIEWPORT_W * DPR, VIEWPORT_H * DPR)); // rootElement:用专用空容器(引擎 engineInit:405 `rootElement.style.cssText=styleRoot` 会**整体覆盖**容器样式, // 故不能复用承载 #status/#game 的 #wrap,否则其 flex 布局被清。建一个独立 div 挂 body。 const engineRoot = document.createElement('div'); engineRoot.id = 'engine-root'; document.body.appendChild(engineRoot); // engineInit 异步:resolve 后 mainContext 就绪 → 建 bundle/注册/打链/收尾。 LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot) .then(() => { setupAfterEngine(LJS.mainContext); // 绘制面=引擎 mainContext window.__hostBootedReal = true; // CDP 取证就绪标记 }) .catch((e) => { // 引擎接管失败:记错误。【P2 修正】同时镜像到 __hostBootError,让现有 waitForBoot(只查 __hostBootError) // 即时抛错而非 12s 超时;门0/CDP 据此判 real 失败 → 回滚桩(创始人裁)。 window.__engineInitError = String((e && e.message) || e); window.__hostBootError = window.__engineInitError; setStatus('ENGINE INIT FAILED: ' + window.__engineInitError + '(可加 ?engine=stub 回滚桩)'); }); // 注意:real 模式 __hostdev 在 setupAfterEngine 内异步挂载,故此处返回占位对象。 // index.html 用 window.__hostBooted(同步置)探活;CDP 取证须等 window.__hostBootedReal 再驱动 __hostdev。 return { pending: true, engineMode }; }