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>
815 lines
52 KiB
JavaScript
815 lines
52 KiB
JavaScript
/**
|
||
* 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<number>} */
|
||
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<string, import('../src/core/api.d.ts').RandomSource>} */
|
||
const derivedRandoms = {};
|
||
// 引擎通道选择:?engine=real(缺省,引擎掌帧地基)| ?engine=stub(回滚桩,行为与 α 逐行等价,创始人裁)。
|
||
const engineMode = getParam('engine', 'real');
|
||
// bundle 改为延后创建(real:engineInit 就绪后;stub:立即)。所有 bundle.xxx 引用须在 setupAfterEngine 内执行。
|
||
/** @type {ReturnType<typeof createHostDevContext>|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 };
|
||
}
|