zizi 99b6c82dec feat(runner-v2): P1 王蓝莓上引擎=游戏宿主装载契约 spike 实(mini-desktop real 引擎 5 门全过)
Runner v2 全弧第一相(HJ-RUNNER-ARC-001 / P1):用王蓝莓把"上层游戏宿主装载契约"
从零 spike 到"引擎真掌帧 + 游戏美术与引擎粒子同屏可玩",解锁 P2 泛化 / P3 生成。

契约与共享件
- 新增装载契约 game-runtime/src/core/game-host.d.ts(第9类 additive·暂名):
  GameHostBootContext / GameInstance / GameHostFactory;游戏=纯模块工厂,零引擎 import,
  能力经下层 ctx.getEngine()(受控面 6 项零改),绘制面 render(g) 的 g=引擎 mainContext
- makeEngineCaps 闭包提升为可 import 模块 host-dev/engine-caps.js(createEngineCaps):
  host-dev/ref/未来生成游戏共用;engineMode 短路 / __engineCalls 探针 / null-engine 语义逐行不变

王蓝莓 real 通道(games/wanglanmei-ref)
- 增 ?engine=real:主循环从浏览器 RAF 改挂 engineInit 五回调(掌帧唯一源=引擎);
  游戏美术绘制面从 #game 改引擎 mainContext(同屏合并);bundle 延后到 setupReal 内以
  mainContext 建(getContext2d 闭包快照修);保 ?engine=stub 逐像素确定性回滚
- game-shop.js 抽为独立 GameHostFactory(零引擎 import,P3 生成 agent 可复制范本)
- entry.js 动态 import('littlejsengine')=node-import-safe ∧ esbuild iife 入包两全
- 驱动 e2e-input.cdp.cjs real 改造:禁 stepFrames 手摇(防双掌帧),真触摸+轮询;
  门②同屏像素判据(暖色 warmHits ∧ 引擎粒子 brightHits 同张 #game-engine)

真机验收(mini-desktop real 引擎,5 门)
引擎掌帧 dFrame=31∈[20,45] / 同屏 warmHits=682∧brightHits=13028 同张 #game-engine /
particles.spawnEmitter+synthSfx call-ID 运行时真调引擎 / CDP 517 真触摸跑通整局
(第1天接待18·营收¥79.9·连击9) / console 零错;本机 88 单测全绿(含修复 4 红+补回归)
证据:games/wanglanmei-ref/evidence/p1-real/(e2e-evidence.json pass=true + 5 截图肉眼复核)

诚实 caveat:门② brightHits 在结算屏混入亮 UI,同屏合并由 warmHits(美术落引擎画布)+
call-ID(粒子真调引擎)+ 引擎架构(粒子渲同一 mainContext)三证成立,非阻断。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:32:50 +00:00

815 lines
52 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.

/**
* host-dev/host.js — host-dev 宿主页引导(浏览器侧 hostcore-protocol-v0 受控面真实接线)
* ownerT1b-α 集成段 消费方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) → 推进所有插件 onFramehost-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.jsESM开发构建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】引擎数学门面构造纯函数零引擎 importreal 通道工厂用它包装引擎 lerp/smoothStep/Ease。
// 单列在 engine-math.js不 import 引擎)→ node 单测可 import 它注入 mock ljs 验门面,规避 F7host.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() {
// 仅在「已解锁」时创建;未解锁返回 nullP6 容错降级路径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 的 backingreal 模式=引擎 engineInit 自建的 mainContext纯 2D
// setGLEnable(false) 后引擎只渲 2D 进 mainCanvasstub 模式=host prepareCanvas(#game) 的 ctx2d回滚桩
// 因 engineInit 异步real 模式 bundle 在「引擎就绪后」创建(见 setupAfterEnginegetContext2d
// 一次性快照到真 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 发射器 idreset() 时统一 stopEmitter 防其持续喷发。
/** @type {Set<number>} */
const activeEmitterIds = new Set();
// per-plugin 派生随机实例登记name → RandomSource
// 背景core 的 per-plugin 派生流让每个插件 init 时拿到「自己专属的派生随机实例」(与默认 context.random 不同实例)。
// 插件(如 particles-juice在 init 时捕获 `ctx.random` 并据其确定粒子序列。要让 vfx 像素证据「同参数重跑一致」,
// host 必须能复位**插件那一份**随机实例——故这里合法地包裹 bundle.deriveContextForhost 拥有 bundle
// 在注册器为每插件派生上下文时记下该插件的 random 引用,供 vfxFrame/paletteFrame 取证前 reseed。
/** @type {Record<string, import('../src/core/api.d.ts').RandomSource>} */
const derivedRandoms = {};
// 引擎通道选择:?engine=real缺省引擎掌帧地基| ?engine=stub回滚桩行为与 α 逐行等价,创始人裁)。
const engineMode = getParam('engine', 'real');
// bundle 改为延后创建realengineInit 就绪后stub立即。所有 bundle.xxx 引用须在 setupAfterEngine 内执行。
/** @type {ReturnType<typeof createHostDevContext>|null} */
let bundle = null;
// 当前绘制面real=engine.mainContextstub=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 一次性快照此 ctxreal=引擎 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.deriveContextForhost 拥有 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 收尾。
* 两条通道都调它realengineInit().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); // 建 bundlecontext2d=绘制面)
// 注册 8 件业务能力插件 + _example第 9 件,凑「全九插件」覆盖)。
// runtime-probe **不**走批量注册__skip它需由 host 按「启动六锚点」canonical 顺序手动驱动——
// 因为 probe 的 init 会在订阅输入时立刻打 t_input_boundorder=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-probehost 自管,按 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_boundorder=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_boundorder=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.tickhost-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】绘制面=renderCtxreal=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/Bhost 工厂已映射),引擎自渲落 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/60onFrame 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;
// 输入事件目标 canvasreal=引擎 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经实例内置 broadphasespatial插入一个 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-juiceburst 一发(演示发射器;像素证据走 __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-musicblip 一声(无手势则 getAudioContext=null → 静默 no-op不抛错
const am = /** @type {any} */ (instances['audio-music']);
am.playSfx('blip');
// 7) save-progressset/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 重设 widthstub 靠下方 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 打 idCDP 取证选择器用 #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 startRafstub 模式起 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 推进 bundledriver=引擎内部 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):引擎跳 glCanvasglInit:8765 守卫),纯 2D 渲进 mainContextreadback 无 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 };
}