feat(t1b-beta): A0 引擎掌帧接管落地——host-dev RAF桩→littlejsengine engineInit 五回调;本机门全绿,门0浏览器段待mini-desktop
host.js+entry.js:engineMode 分流。real=引擎掌帧(engineInit 五回调 engGameInit/ Update/UpdatePost/Render/RenderPost 接帧驱动+渲染)+getContext2d backing 切引擎 mainContext(setGLEnable(false) 纯2D渲进 mainContext);stub=α RAF桩原样保留(可回滚点)。 本机已验(主会话亲跑): - 155单测绿(基线持平,受控面/插件零改故计数不变) - 受控面零改实证:src/core/api.d.ts+plugin.js 未触(七项 backing 全在 host 层切换) - Q4边界:引擎 import 仅 host.js+entry.js(metafile importers 反查证),未泄进受控面/插件 - 真接线门①:esbuild metafile 实证 littlejsengine 真入产物(163691 bytesInOutput), box2d 惰性(0 wasm input),产物 205KB min 8-agent workflow(源码取证→设计→对抗复审→实现):3/3 concerns 0 fatal。 17 mustFix(P0×1/P1×9/P2×7)全采纳:P0=real像素门 overshoot(引擎追帧 N tick/RAF 墙钟抖动 污染采样帧)→改 stub 通道独占逐像素回归,real 只证掌帧;P1 源码级纠偏=debug水印每帧画 averageFPS入mainContext(setDebugWatermark(false))/clearColor默认alpha=0不铺底 (setCanvasClearColor(BLACK))/P75须读timeReal非mockNowMs(real模式退化逻辑帧钟)。 门0 G0-1~G0-7(引擎真掌帧/真渲2D mainContext/双时钟/无uncaught/水印已除)须 mini-desktop chrome 实测(6c6g 禁 chrome)——未实测不宣布 A0 完成。 新:docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md(编辑计划+门0 runbook) +game-runtime/test/harness/gate0-engine-takeover.cjs(门0 CDP 驱动,独立闭合不复用未改造的 integration-run) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
75243fe8db
commit
11c8eafb42
789
docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md
Normal file
789
docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md
Normal file
@ -0,0 +1,789 @@
|
||||
# A0 引擎掌帧接管 · 精确可执行编辑计划
|
||||
|
||||
> 工位:T1b-β · A0(地基)| 文件:`game-runtime/host-dev/host.js` + `entry.js`(受控面 `src/core/api.d.ts` / `src/core/plugin.js` **零改**)
|
||||
> 上游:`docs/agent-specs/2026-06-13-T1b-β引擎真接线与Runner-v2-execution.md` §A0/门0/§11
|
||||
> 真源核验:`node_modules/littlejsengine/dist/littlejs.esm.js`(esbuild 走 package `main`/`default` = ESM)+ `littlejs.d.ts`
|
||||
> 本计划只产「精确 old→new 编辑块 + 不变性论证 + 门0 runbook + 回滚 + 遗留风险」,**不落代码到源文件**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 目标与范围边界(A0 只做地基,不越界)
|
||||
|
||||
**A0 唯一目标**:把 host-dev 的「浏览器 RAF + `bundle.tick(dt)` + 普通 2D canvas」桩,换成**真引擎 `engineInit` 五回调接管帧驱动 + 渲染循环**,使:
|
||||
1. 引擎内部 RAF 真掌帧(非 host 手摇 RAF 桩);
|
||||
2. 引擎 `time`/`frame`/`timeReal` 随循环真推进,**可被 probe 读到**(门0);
|
||||
3. 受控面 7 项契约**零改**(`getContext2d/onFrame/getInput/getAudioContext/time/random/getEngine`),插件零改;
|
||||
4. 确定性取证路径(`window.__hostdev` 步进 + ImageData FNV-1a 哈希)在引擎掌帧下**存活**;
|
||||
5. 保留**可回滚点**(`?engine=stub` 回桩,创始人裁)。
|
||||
|
||||
**A0 明确不做(留 A1/A6)**:
|
||||
- `ctx.time` **不**改背书到引擎 `time`(那是 A1)。A0 只**额外暴露**引擎钟给 probe(`window.__engine`),`ctx.time` 仍走 host 步进钟 `mockNowMs`(受控面 backing 不动)。
|
||||
- `getInput` **不**改成「引擎轮询态归一化包装」(那是 A1)。A0 **必须避免引擎自带 document 监听与 host inputBridge 双发**(见 §4)。
|
||||
- `getEngine` 的真实 `engineFactory` 背书(粒子/合成/数学)留 A1+。A0 期 `engineFactory` 仍缺省 null(`getEngine` 返 null,插件容错降级)。
|
||||
- SIZES 重锚留 A5;A0 仅在本计划 §9 记体积跳升预期。
|
||||
|
||||
**映射总表(题面四问 → A0 落点)**:
|
||||
|
||||
| 抽象 | α 桩现状 | A0 落点 |
|
||||
|---|---|---|
|
||||
| `onFrame`→回调 | `bundle.tick(dt)` 内派发 onFrame;host RAF 驱动 tick | 引擎 `gameUpdate(回调)` 内调 `bundle.tick(dtFixed)`;驱动器=引擎内部 RAF |
|
||||
| `canvas`→`mainContext` | host `prepareCanvas(#game)` 注入 ctx2d | 引擎 `engineInit` 自建 mainCanvas;`getContext2d` backing 到 `engine.mainContext`;`#game` 退役 |
|
||||
| `time`→引擎 | host `mockNowMs` 步进钟 | A0 暴露 `engine.time/frame/timeReal` 给 probe(`window.__engine`);`ctx.time` 仍 `mockNowMs`(A1 才透传) |
|
||||
| 确定性路径 | `stepFrames(n)`: `mockNowMs+=dt; tick(dt); renderFrame()`(非真 RAF) | `?engine=stub` 桩通道保 `stepFrames` 原样;`?engine=real` 引擎通道走「门0 真 RAF 推进 + 引擎 `mainContext` readback」;像素逐帧哈希以 stub 通道兜底(双轨,门0 用 real 证掌帧) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 引擎 import 形态(entry.js + host.js)
|
||||
|
||||
### 1.1 真源核验(决定 import 写法 + box2d 风险结论)
|
||||
|
||||
- esbuild 走 `littlejsengine` package `main`=`dist/littlejs.esm.js`(ESM,`exports.default`)。R 路所引「littlejs.js 行号」与 ESM 文件**逐行一致**(已核验:`engineInit:167` / `engineUpdate:202` / `requestAnimationFrame(engineUpdate):283` / `new AudioContext:6753` / `glInit:8760` / `glContext=...webgl2:8773`)。故 R 路结论可直接用于 ESM。
|
||||
- **box2d 是惰性、非自动加载**(已核验 `littlejs.esm.js:12619-12622` 注释「Call box2dInit() to enable / You will also need to include box2d.wasm.js」;`box2dInit:14582` 内 `await Box2D()`,`Box2D` 是**全局**,由用户另行 include `box2d.wasm.js` 提供;`engineInit` 体内**零** box2d 引用)。`physics-lite` 用自带数学,不调 box2d。**结论**:esbuild 把 littlejs.esm.js 整体打入产物,其中 `Box2D()` 是个**永不执行的自由全局引用**(dead-but-present),esbuild **不会**去解析/打包 `box2d.wasm.js` 或 `box2d.wasm.wasm`(它们不被 ESM `import`),运行时也**绝不**触发 wasm fetch。→ A0 不需处理 wasm;仅体积上多带 box2d 死代码(SIZES 重锚 A5 计入)。**风险**标 §11。
|
||||
- **glEnable 默认 true 且无外部 setter 别名陷阱**:已核验导出名是 **`setGLEnable`(大写 GL,d.ts:695)**,非 R 路所写的 `setGlEnable`。`glInit:8765` 守卫 `if(!glEnable||headlessMode)return` → **`setGLEnable(false)` 须在 `engineInit` 前调**,引擎即跳过 glCanvas 创建、纯 2D 渲染进 mainCanvas。这比 R 路「跨 tick glCopyToContext」路径**更干净**(无 WebGL `preserveDrawingBuffer` 坑)。A0 取此路。
|
||||
- `showSplashScreen` 默认 **false**(`littlejs.esm.js:2873`)→ 无开屏遮挡首帧,`gameRender` 首次回调即真首帧。
|
||||
|
||||
### 1.2 entry.js 编辑
|
||||
|
||||
**锚(当前真实内容,entry.js:16-17)**:
|
||||
```js
|
||||
import * as RT from '../src/all-plugins.js';
|
||||
import { bootHostDev } from './host.js';
|
||||
```
|
||||
**改为**:
|
||||
```js
|
||||
import * as RT from '../src/all-plugins.js';
|
||||
import { bootHostDev } from './host.js';
|
||||
// A0:引擎主循环接管所需 import 唯一落点之一(集成段 host)。
|
||||
// Q4 铁律:littlejsengine import 只许活在集成段 host(entry.js/host.js),禁泄进受控面契约/插件。
|
||||
// 名字空间整体导入:engineInit/engineObjectsUpdate/setGLEnable/setPaused/setTimeScale/setCanvasFixedSize/
|
||||
// setCanvasPixelRatio/setShowSplashScreen/setHeadlessMode/glCopyToContext/glSetAntialias/vec2/
|
||||
// mainCanvas/mainContext/frame/time/timeReal 等全经此名字空间触达(具体由 host.js 用)。
|
||||
import * as LJS from 'littlejsengine';
|
||||
```
|
||||
**锚(entry.js:21-22)**:
|
||||
```js
|
||||
export { bootHostDev };
|
||||
export const Runtime = RT;
|
||||
```
|
||||
**改为**(把引擎名字空间一并透到全局入口,供 host.js 经参数拿 + DevTools 手验;**不**改 `bootHostDev` 既有签名,新增可选第二参):
|
||||
```js
|
||||
export { bootHostDev };
|
||||
export const Runtime = RT;
|
||||
// A0:引擎名字空间挂入口,便于 host.js 接收 + 控制台手验引擎导出。
|
||||
export const Engine = LJS;
|
||||
```
|
||||
|
||||
> 说明:`bootHostDev` 既可由 entry.js 在调用处把 `LJS` 作为 `opts.engine` 注入(推荐,见 index.html 改动 §3.4),也可由 host.js 直接 `import * as LJS from 'littlejsengine'`。**二选一,取「host.js 直接 import」更内聚**——理由:host.js 是引擎接管的唯一实现体,直接 import 使「引擎 import 只活集成段 host」边界最清晰,且 index.html 引导代码零改。故 §1.3 采 host.js 直接 import;entry.js 的 `export const Engine` 仅为手验便利(可保留)。
|
||||
|
||||
### 1.3 host.js import 编辑
|
||||
|
||||
**锚(host.js:29-36,当前真实 import 块)**:
|
||||
```js
|
||||
import {
|
||||
PluginRegistry,
|
||||
createHostDevContext,
|
||||
registerAllPlugins,
|
||||
createExamplePlugin,
|
||||
createRuntimeProbePlugin,
|
||||
CORE_PROTOCOL_VERSION,
|
||||
} from '../src/all-plugins.js';
|
||||
```
|
||||
**改为**(追加引擎名字空间 import;受控面 import 不动):
|
||||
```js
|
||||
import {
|
||||
PluginRegistry,
|
||||
createHostDevContext,
|
||||
registerAllPlugins,
|
||||
createExamplePlugin,
|
||||
createRuntimeProbePlugin,
|
||||
CORE_PROTOCOL_VERSION,
|
||||
} from '../src/all-plugins.js';
|
||||
// A0:引擎主循环接管——littlejsengine import 唯一落点(集成段 host)。
|
||||
// Q4 铁律:禁把引擎 import 泄进受控面契约(api.d.ts/plugin.js)或任何插件 impl;只活在本 host。
|
||||
// esbuild 走 package main=dist/littlejs.esm.js(ESM);box2d 惰性(box2dInit 永不调)→ 不引 wasm。
|
||||
import * as LJS from 'littlejsengine';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 主循环接管:rafLoop → engineInit 五回调
|
||||
|
||||
### 2.1 设计要点(先讲清「为什么这样接」)
|
||||
|
||||
引擎**无 `engineUpdate` 外部导出**(d.ts grep 零命中),主循环=引擎内部 RAF(`littlejs.esm.js:283` 无条件自调度)。外部**只能经 `engineInit` 五回调**接管,不能手摇 `engineUpdate`。映射:
|
||||
|
||||
- **驱动器**:引擎内部 RAF(取代 host `rafLoop/startRaf/stopRaf`)。
|
||||
- **`bundle.tick(dt)` → `gameUpdate` 回调**:引擎每帧追帧循环里调 `gameUpdate` N 次(N=本动画帧追帧数,墙钟决定,0..N)。host 在 `gameUpdate` 内调一次 `bundle.tick(FIXED_DT)`——**dt 取引擎固定步长 `1/60`(=FIXED_DT),不取墙钟**,因为引擎已用累加器把墙钟差摊成「每次 update 推进恰好 1 个逻辑步(1/frameRate)」。这样 `onFrame(dtSeconds=1/60, frame)` 回调形态**与 α 桩一致**(particles 是 dt-敏感型,dt 须恒定 1/60,不能塞墙钟 dt,否则粒子时步漂移)。
|
||||
- **`renderFrame()` → 拆 `gameRender` + `gameRenderPost`**:
|
||||
- `gameRender`:画**粒子层**(host 在此 `save → fillStyle='#7fd0ff' → pj.render(engine.mainContext) → restore`,沿用 α 的「host 提供可见色」语义)。
|
||||
- `gameRenderPost`:画**后处理叠加层**(`pp.renderPost(engine.mainContext)`),在所有对象/粒子之上,语义正确(后处理本应最后叠加)。
|
||||
- **不再** host 手动 `clearBlack`:引擎 `updateCanvas:374-375` 每帧重设 `mainCanvas.width/height` 清空画布;又因 `setGLEnable(false)`,`updateCanvas:378-383` 用 `canvasClearColor` 填底。故 host 改为 `engineInit` 前设引擎清色为黑(或保留默认)。`gameRender` 在引擎清屏之后调,无残留。
|
||||
- **canvas 绑定**:引擎 `engineInit:406` **自建** mainCanvas append 到 rootElement(**不**采用现有 `#game`)。故 host 把 rootElement 指向 `#game` 的父容器 `#wrap`(或专用容器),让引擎在其内建自己的 canvas;`getContext2d` backing 改为返回 `engine.mainContext`(见 §3)。
|
||||
- **dtSeconds 来源固定步长**:A0 取 `FIXED_DT=1/60`(host.js:45 已有常量),保 `onFrame` 回调 dt 恒定(particles 时步确定)。
|
||||
|
||||
### 2.2 编辑块 A:注入受控面 `getContext2d` 的 backing 切换准备
|
||||
|
||||
`createHostDevContext` 的 `context2d` 入参当前是 host `prepareCanvas(#game)` 产的 ctx(host.js:188-193)。A0 后这张 ctx 要换成 `engine.mainContext`,但 `engine.mainContext` 在 `engineInit` resolve 后才存在(异步)。受控面 `getContext2d()` 实现是 `return o.context2d == null ? null : o.context2d`(plugin.js:467-470)——**读的是闭包捕获的 `o.context2d` 值**,一次性快照,不可后改。
|
||||
|
||||
**解法(受控面零改 + backing 后绑)**:给 `createHostDevContext` 传一个**取值函数式的 2D ctx 持有者**。但受控面 `getContext2d` 读的是 `o.context2d`(值),不是函数。两条不破契约的路:
|
||||
|
||||
- **路 A(推荐,零改受控面)**:用一个**代理对象**作 `context2d`,其所有 2D 调用转发到「当前活动 ctx」(引擎就绪前=host 占位 2D ctx,就绪后=`engine.mainContext`)。代理是 `CanvasRenderingContext2D` 同形 Proxy,受控面拿到的「就是一张 2D ctx」(契约只约定返回 `CanvasRenderingContext2D|null`,不约定来源/是否 Proxy)。
|
||||
- **路 B(更简单,推荐优先验证可行性)**:**先 `engineInit` 拿到 `engine.mainContext`,再创建 `bundle`**。即把 `createHostDevContext({context2d: engine.mainContext, ...})` 的调用挪到 `engineInit().then(...)` 之后。因 `engineInit` 是 `async`/返回 Promise,host `bootHostDev` 须改为「`engineInit` 先行 → 其 `gameInit` 回调里或 `.then` 里建 bundle + 注册插件 + 打启动链」。这样 `getContext2d` 一次性快照到的就是真 `engine.mainContext`,**受控面零改、无 Proxy**。
|
||||
|
||||
> **取路 B**:最小改动且无 Proxy 复杂度。代价=`bootHostDev` 主体从「同步建 bundle」改为「`engineInit` resolve 后建 bundle」——这本就是引擎掌帧的必然结构(引擎先起、canvas 才有)。下文编辑块按路 B 写。
|
||||
|
||||
**锚(host.js:174-193,当前真实内容)**:
|
||||
```js
|
||||
// 创建 host-dev 受控上下文:注入真 2D ctx / 主种子 / 音频工厂。
|
||||
// 时钟:取证用步进时钟(见下 mockClock),真实 RAF 模式则用墙钟——这里给步进时钟,
|
||||
// 真实模式由 RAF 推帧但时间读数仍走步进时钟,保证「插件读 time 与帧推进一致、且可被取证接管」。
|
||||
let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。
|
||||
// 记录所有经取证入口 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 = {};
|
||||
const bundle = createHostDevContext({
|
||||
context2d: ctx2d,
|
||||
seed,
|
||||
audioFactory,
|
||||
clock: () => mockNowMs,
|
||||
});
|
||||
```
|
||||
|
||||
**改为**(`context2d` 由「待引擎就绪后绑定」的持有变量驱动;引擎模式下 host 自己的 `prepareCanvas(#game)` 不再用作绘制面,但仍可留作降级桩):
|
||||
```js
|
||||
// 创建 host-dev 受控上下文:注入「引擎渲染面(mainContext)」 / 主种子 / 音频工厂。
|
||||
// 【A0 改点】受控面 getContext2d 的 backing 不再是 host prepareCanvas(#game) 的 ctx,而是引擎 engineInit
|
||||
// 自建的 mainContext(纯 2D,setGLEnable(false) 后引擎只渲 2D 进 mainCanvas)。因 engineInit 异步,
|
||||
// 故 bundle 在「引擎就绪后」创建(见下 setupAfterEngine),getContext2d 一次性快照到真 mainContext。
|
||||
// time:A0 仍用 host 步进钟 mockNowMs(ctx.time backing 不动,受控面零改);引擎钟另经 window.__engine
|
||||
// 暴露给 probe(门0),ctx.time 透传引擎 time 留 A1。
|
||||
let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。
|
||||
// 记录所有经取证入口 spawn 的 continuous 发射器 id,reset() 时统一 stopEmitter 防其持续喷发。
|
||||
/** @type {Set<number>} */
|
||||
const activeEmitterIds = new Set();
|
||||
// per-plugin 派生随机实例登记(name → RandomSource)。(语义同 α,注释从略——见原文。)
|
||||
/** @type {Record<string, import('../src/core/api.d.ts').RandomSource>} */
|
||||
const derivedRandoms = {};
|
||||
// 引擎模式:绘制面 = 引擎 mainContext(就绪后绑);桩模式:绘制面 = host prepareCanvas(#game) 的 ctx2d。
|
||||
// 引擎通道选择由 URL ?engine=real|stub 决定(缺省 real)。
|
||||
const engineMode = getParam('engine', 'real'); // 'real'(引擎掌帧)| 'stub'(回滚桩)
|
||||
/** @type {ReturnType<typeof createHostDevContext>|null} */
|
||||
let bundle = null;
|
||||
```
|
||||
|
||||
> 注意:原 `const bundle = ...` 改为 `let bundle = null`,并把真正的 `createHostDevContext({...})` 调用挪进 §2.4 的 `buildBundle(renderCtx)`(renderCtx=引擎 mainContext 或桩 ctx2d)。所有下文引用 `bundle.xxx` 的代码(`deriveContextFor` 包裹、`registry.useContext(bundle.context)`、`stepFrames`、`__hostdev`、`inputBridge` 等)须移到 bundle 创建之后执行(见 §2.4 重排)。
|
||||
|
||||
### 2.3 编辑块 B:删除 host RAF 桩,替换为引擎 engineInit 五回调驱动
|
||||
|
||||
**锚(host.js:271-283,当前 stepFrames——桩模式保留,引擎模式新增引擎步进,见 §7)**:保留 `stepFrames` 函数体不动(桩通道用),但其调用时机受 `engineMode` 分流(§2.4)。
|
||||
|
||||
**锚(host.js:297-316,当前 renderFrame)**:
|
||||
```js
|
||||
/** 把当前帧的表现层(粒子 + 后处理叠加)渲染到 canvas。底已在每帧重铺黑。 */
|
||||
function renderFrame() {
|
||||
clearBlack(ctx2d); // 每帧重铺黑底,避免上一帧残留干扰像素哈希
|
||||
// 粒子层
|
||||
const pj = /** @type {any} */ (instances['particles-juice']);
|
||||
if (pj && typeof pj.render === 'function') {
|
||||
ctx2d.save();
|
||||
ctx2d.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见)
|
||||
try { pj.render(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); }
|
||||
ctx2d.restore();
|
||||
}
|
||||
// 后处理叠加层(vignette/dither/scanline)
|
||||
const pp = /** @type {any} */ (instances['palette-post']);
|
||||
if (pp && typeof pp.renderPost === 'function') {
|
||||
try { pp.renderPost(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); }
|
||||
}
|
||||
}
|
||||
```
|
||||
**改为**(renderFrame 拆成「粒子」「后处理」两半,供引擎 `gameRender`/`gameRenderPost` 分别调;桩模式仍合并调;`ctx2d` 形参化为「当前绘制面 renderCtx」):
|
||||
```js
|
||||
// 【A0】绘制面解析:引擎模式=engine.mainContext;桩模式=host 占位 ctx2d。由 buildBundle 时确定并存于 renderCtx。
|
||||
/** @type {CanvasRenderingContext2D|null} */
|
||||
let renderCtx = null;
|
||||
|
||||
/** 画粒子层(引擎 gameRender 回调里调;桩模式 renderFrame 里调)。host 提供可见色(粒子不内置颜色)。 */
|
||||
function renderParticles() {
|
||||
const g = renderCtx;
|
||||
if (!g) return;
|
||||
const pj = /** @type {any} */ (instances['particles-juice']);
|
||||
if (pj && typeof pj.render === 'function') {
|
||||
g.save();
|
||||
g.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见)——语义同 α
|
||||
try { pj.render(g); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); }
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
/** 画后处理叠加层(引擎 gameRenderPost 回调里调;桩模式 renderFrame 里调)。 */
|
||||
function renderPostFx() {
|
||||
const g = renderCtx;
|
||||
if (!g) return;
|
||||
const pp = /** @type {any} */ (instances['palette-post']);
|
||||
if (pp && typeof pp.renderPost === 'function') {
|
||||
try { pp.renderPost(g); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); }
|
||||
}
|
||||
}
|
||||
/** 桩模式渲染一帧(合并粒子+后处理)。引擎模式不用它(引擎分回调驱动)。 */
|
||||
function renderFrame() {
|
||||
// 桩模式底由 host 自铺黑(引擎模式由引擎 clearColor 铺,不在此)。
|
||||
if (engineMode === 'stub' && renderCtx) clearBlack(renderCtx);
|
||||
renderParticles();
|
||||
renderPostFx();
|
||||
}
|
||||
```
|
||||
|
||||
**锚(host.js:342-368,当前 RAF 桩 startRaf/stopRaf/rafLoop)**:
|
||||
```js
|
||||
/* ── 真实 RAF 循环(交互模式):把墙钟 dt 钳制后推进 ──
|
||||
取证模式不依赖它(取证用 stepFrames 定量推进);但交互演示需要它让画面动起来。 */
|
||||
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;
|
||||
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;
|
||||
}
|
||||
```
|
||||
**改为**(**桩 RAF 整块保留**作回滚通道,但仅 `engineMode==='stub'` 用;新增引擎五回调定义。引擎掌帧时由引擎内部 RAF 驱动这些回调,host 不再自管 RAF):
|
||||
```js
|
||||
/* ── 回滚桩:浏览器 RAF 循环(仅 engineMode==='stub' 用,保留可回滚点,创始人裁)──
|
||||
引擎模式(real)不走它——引擎内部 RAF 驱动下方五回调。 */
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 引擎五回调(engineMode==='real'):engineInit 接管帧驱动 + 渲染 ──
|
||||
· gameInit:引擎首帧前一次性初始化(host-dev 无真游戏,留空+留痕)。
|
||||
· gameUpdate:引擎每个逻辑步调一次 → host 在此用固定步长 1/60 推进 bundle.tick(驱动所有 onFrame)。
|
||||
dt 取 FIXED_DT(非墙钟):引擎累加器已把墙钟摊成每步 1/frameRate,onFrame 回调 dt 须恒定(粒子时步确定)。
|
||||
· gameUpdatePost:逻辑步后处理钩子(host-dev 无需,留空)。
|
||||
· gameRender:引擎渲染阶段(清屏后)→ host 画粒子层到 engine.mainContext。
|
||||
· gameRenderPost:渲染末尾 → host 画后处理叠加层。 */
|
||||
function engGameInit() {
|
||||
// host-dev 无真游戏对象;启动链由 setupAfterEngine 在引擎就绪后打(见 §2.4)。
|
||||
// 此处仅留痕「引擎 gameInit 已触发」,供 probe 旁证与排障。
|
||||
window.__engineGameInitFired = true;
|
||||
}
|
||||
function engGameUpdate() {
|
||||
// 引擎掌帧:每逻辑步推进一次 bundle(固定步长,driver=引擎内部 RAF)。
|
||||
if (bundle) {
|
||||
mockNowMs += FIXED_DT * 1000; // host 步进钟随引擎逻辑步同步推进(ctx.time 仍读它,A0 不透传引擎钟)
|
||||
bundle.tick(FIXED_DT); // 驱动所有插件 onFrame(含 particles 自动 step)
|
||||
}
|
||||
}
|
||||
function engGameUpdatePost() { /* host-dev 无需逻辑步后处理 */ }
|
||||
function engGameRender() { renderParticles(); } // 引擎清屏后画粒子(host 提供可见色)
|
||||
function engGameRenderPost() { renderPostFx(); } // 渲染末尾叠加后处理
|
||||
```
|
||||
|
||||
### 2.4 编辑块 C:bootHostDev 主体重排——引擎先起,bundle 后建
|
||||
|
||||
引擎掌帧的结构必然是「`engineInit` 先行(async)→ canvas/mainContext 就绪 → 建 bundle + 注册插件 + 打启动链」。故把 host.js 末尾「建 bundle 之后到 return」的同步编排,包成一个 `setupAfterEngine(renderCtxArg)`,由两条通道分别调用:
|
||||
|
||||
- `engineMode==='real'`:先 `setGLEnable(false)` + `setShowSplashScreen(false)` + 锁 canvas 尺寸 → `engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], rootEl)` → `.then(()=> setupAfterEngine(LJS.mainContext))`。
|
||||
- `engineMode==='stub'`:直接 `setupAfterEngine(ctx2d)`(ctx2d=host `prepareCanvas(#game)` 产物),随后按 mode 起 `startRaf()`(idle)等——**与 α 行为完全一致**(回滚通道)。
|
||||
|
||||
**锚(host.js:188-203,bundle 创建 + deriveContextFor 包裹)→ 移入 `buildBundle(renderCtxArg)`**。
|
||||
**锚(host.js:205-578 大段:注册插件 / probe / markBootChain / runSmoke / __hostdev / 按 mode 收尾)→ 移入 `setupAfterEngine(renderCtxArg)`**,其中:
|
||||
- `renderCtx = renderCtxArg;`(设当前绘制面)
|
||||
- `bundle = buildBundle(renderCtxArg);`(建 bundle,context2d=renderCtxArg)
|
||||
- 其余编排(registry/probe/markBootChain)原样,但**所有 `stepFrames` 的语义按 engineMode 分流**(见 §7)。
|
||||
|
||||
**锚(host.js:564-581,当前 markBootChain + 按 mode 收尾 + return)**:
|
||||
```js
|
||||
// ── 执行启动链打点 + 按模式收尾 ──
|
||||
markBootChain();
|
||||
if (mode === 'smoke') {
|
||||
const smokeResult = runSmoke();
|
||||
window.__smokeResult = smokeResult;
|
||||
} else if (mode === 'evidence') {
|
||||
setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed);
|
||||
} else {
|
||||
setStatus('idle: click canvas to spawn particles. seed=' + seed);
|
||||
startRaf();
|
||||
}
|
||||
|
||||
return hostdev;
|
||||
}
|
||||
```
|
||||
**改为**(按 engineMode 选通道;启动链 + 收尾移进 setupAfterEngine;real 模式下 idle 不需 host startRaf,引擎内部 RAF 已在跑):
|
||||
```js
|
||||
// ── 通道分流:real(引擎掌帧,地基)| stub(回滚桩,创始人裁)──
|
||||
if (engineMode === 'stub') {
|
||||
// 回滚通道:行为与 α 桩完全一致(host prepareCanvas(#game) + 浏览器 RAF + bundle.tick)。
|
||||
setupAfterEngine(ctx2d);
|
||||
if (mode !== 'smoke' && mode !== 'evidence') startRaf(); // idle 起 host RAF
|
||||
return window.__hostdev;
|
||||
}
|
||||
|
||||
// real 通道(缺省,A0 地基):引擎掌帧。
|
||||
// 1) 渲染纯 2D(setGLEnable(false) → 引擎跳 glCanvas,渲进 mainContext,2D 像素 readback 无 WebGL 坑)。
|
||||
// 2) 关开屏(默认已关,显式兜底)。3) 锁 390×844@DPR2 backing(取证固定环境)。
|
||||
LJS.setGLEnable(false);
|
||||
LJS.setShowSplashScreen(false);
|
||||
LJS.glSetAntialias(false); // 关抗锯齿,去亚像素抖动(取证哈希稳定,A6 也依赖)
|
||||
LJS.setCanvasFixedSize(LJS.vec2(VIEWPORT_W * DPR, VIEWPORT_H * DPR)); // backing=780×1688(=390×844@DPR2)
|
||||
// 引擎在 rootElement 内自建 mainCanvas(不采用 #game)。rootElement 取 #game 的父容器,避免污染 <body>。
|
||||
const rootEl = (canvas && canvas.parentElement) ? canvas.parentElement : document.body;
|
||||
// engineInit 异步:resolve 后 mainContext 就绪 → 建 bundle/注册/打链/收尾。
|
||||
LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], rootEl)
|
||||
.then(() => {
|
||||
setupAfterEngine(LJS.mainContext);
|
||||
// real 模式:引擎内部 RAF 已在驱动五回调(含画面动),idle 无需 host startRaf。
|
||||
// smoke/evidence 的收尾(runSmoke / 取证就绪)已在 setupAfterEngine 内按 mode 处理。
|
||||
window.__hostBootedReal = true;
|
||||
})
|
||||
.catch((e) => {
|
||||
// 引擎接管失败:记错误,置可探活标记(门0/CDP 据此判 real 通道失败 → 回滚桩,创始人裁)。
|
||||
window.__engineInitError = String((e && e.message) || e);
|
||||
setStatus('ENGINE INIT FAILED: ' + window.__engineInitError + '(可加 ?engine=stub 回滚桩)');
|
||||
});
|
||||
|
||||
// 注意:real 模式 hostdev 句柄在 setupAfterEngine 内挂 window.__hostdev(异步),故此处返回占位。
|
||||
// index.html 引导已用 window.__hostBooted 探活;CDP 取证应等 window.__hostBootedReal 再驱动 __hostdev。
|
||||
return { pending: true, engineMode };
|
||||
}
|
||||
```
|
||||
|
||||
> **setupAfterEngine 内的「按 mode 收尾」**(从原 host.js:564-578 迁入,real 模式去掉 idle 的 host startRaf):
|
||||
> ```js
|
||||
> // setupAfterEngine 尾部:
|
||||
> markBootChain();
|
||||
> if (mode === 'smoke') { window.__smokeResult = runSmoke(); }
|
||||
> else if (mode === 'evidence') { setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed); }
|
||||
> else { setStatus('idle: ' + (engineMode === 'real' ? 'engine RAF running. ' : '') + 'click canvas. seed=' + seed);
|
||||
> if (engineMode === 'stub') startRaf(); }
|
||||
> window.__hostdev = hostdev; // real 模式异步挂载点
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 3. canvas:mainContext backing + rootElement 挂载 + 390×844 DPR2 锁
|
||||
|
||||
### 3.1 真源核验
|
||||
|
||||
- `engineInit:406` **自建** mainCanvas append 到 rootElement:`mainCanvas = rootElement.appendChild(document.createElement('canvas'))`;`drawContext=mainContext=mainCanvas.getContext('2d')`(:407)。**引擎不采用现有 `#game`**。且 `engineInit:405` 用 `rootElement.style.cssText=styleRoot` **整体覆盖** rootElement 样式。
|
||||
- `setGLEnable(false)`(d.ts:695,须 engineInit 前)→ `glInit:8765` 守卫跳过 glCanvas → 纯 2D 渲染进 mainCanvas。
|
||||
- `setCanvasFixedSize(vec2(780,1688))`(d.ts:665)→ 固定分支:mainCanvas backing=780×1688;CSS 用 100%/'' 适配窗口。DPR2 体现在 **backing 是 CSS 的 2 倍**——故传 `vec2(VIEWPORT_W*DPR, VIEWPORT_H*DPR)=vec2(780,1688)`,**不是** `vec2(390,844)`。
|
||||
- `updateCanvas:374-375` 每帧重设 `mainCanvas.width/height=mainCanvasSize` 清空——故 host 任何「帧外手绘」会被下帧清掉;取证须在 `gameRenderPost` 之后、下一 tick `updateCanvas` 之前读 ImageData。
|
||||
|
||||
### 3.2 getContext2d backing(受控面零改,靠路 B 后绑)
|
||||
|
||||
`getContext2d` 实现(plugin.js:467-470)不改。靠 §2.4 路 B:bundle 在 `engineInit().then` 后用 `createHostDevContext({context2d: LJS.mainContext, ...})` 建,故 `getContext2d()` 一次性快照到真 `engine.mainContext`。插件经 `context.getContext2d()` 拿到的就是引擎 2D 渲染面,坐标系=物理像素(mainCanvas.width/height=780×1688)。
|
||||
|
||||
> **坐标系差异提示(A6/插件须知,非 A0 改点)**:α 桩里 host `prepareCanvas` 调 `ctx.setTransform(DPR,0,0,DPR,0,0)`,插件按**逻辑像素 390×844** 绘制。引擎 mainContext **无此 DPR 变换**(引擎自己的 draw API 走世界坐标→屏幕变换,但插件是裸调 2D ctx),故插件经 `getContext2d` 拿到的引擎 ctx 是**物理像素 780×1688 口径**。这会使 particles 现有「按 390×844 逻辑像素绘制」的坐标在引擎面上只覆盖左上 1/4。**A0 处理**:在 `gameRender` 调 `renderParticles` 前,host 对 `engine.mainContext` 施加同款 DPR 变换包裹:
|
||||
> ```js
|
||||
> function renderParticles() {
|
||||
> const g = renderCtx; if (!g) return;
|
||||
> const pj = 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 补,保插件逻辑像素口径不变
|
||||
> g.fillStyle = '#7fd0ff';
|
||||
> try { pj.render(g); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); }
|
||||
> g.restore();
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
> `renderPostFx` 同理须包 DPR 变换(palette-post 也按逻辑像素铺)。**这是受控面「坐标系仍逻辑像素」不变性的兑现点**(R2 facts:换引擎后插件仍按 390×844 逻辑像素绘制)。**风险**:引擎 `updateCanvas` 每帧重置变换为单位阵,故 host 每帧在 render 回调内重设变换(已在 save/restore 内,正确)。
|
||||
|
||||
### 3.3 rootElement 挂载
|
||||
|
||||
real 模式 `rootEl = canvas.parentElement`(=`#wrap`)。引擎在 `#wrap` 内建自己的 mainCanvas,`#game`(空 2D canvas)仍在 DOM 里但不被引擎用——CDP 取证须改读 **引擎 mainCanvas**,不是 `#game`(见 §10 + §3.4)。
|
||||
|
||||
### 3.4 index.html / CDP 取证选择器(须配套,但 index.html 引导代码不必改)
|
||||
|
||||
`#game` 退役、引擎自建 canvas 无 id。CDP `captureImageData(cdp, '#game')` 会抓到空 canvas。**两条配套法(取其一,记 A6/集成段执行)**:
|
||||
- **法①(推荐,host 给引擎 canvas 打 id)**:setupAfterEngine 里 `if (engineMode==='real' && LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine';`,CDP 取证选择器改 `#game-engine`。
|
||||
- **法②**:CDP 抓 `document.querySelector('canvas:last-of-type')` 或经 `window.__hostdev.canvas()` 暴露引擎 mainCanvas 引用。
|
||||
|
||||
> A0 落**法①**(在 setupAfterEngine 给 `LJS.mainCanvas.id='game-engine'`),并在 `window.__hostdev` 加 `canvas(){ return LJS.mainCanvas; }` 便于 CDP 拿引用。`integration-run.cjs` 的 `CANVAS='#game'` 改 `'#game-engine'`(集成段配套改 harness driver,非 host.js;harness 非冻结件)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 输入:避免引擎自带监听与 host inputBridge 双发
|
||||
|
||||
### 4.1 真源核验(冲突点)
|
||||
|
||||
引擎 `inputInit:5853` 在 `engineInit:409` 内被调,自装 9 个鼠标键盘监听 + 3 个触摸监听**全挂 `document`**(:5858-5866 / :5985-5987),且 `inputPreventDefault` 时对鼠标/键/滚轮 `e.preventDefault`。坐标经 `mouseEventToScreen` 用 `mainCanvas.getBoundingClientRect()` 映射。
|
||||
|
||||
host 现 inputBridge 把 `#game` 的 pointer + window keydown/keyup 经 `inputBridge._emit` 灌进受控面(host.js:321-330)。
|
||||
|
||||
**双发风险**:A0 后若**保留 host 的 DOM 监听**,则同一物理点击会被「引擎 document 监听」与「host #game 监听」**各收一次**——引擎更新自己的 `mousePos`,host 推自己的受控订阅面。两套并存且坐标系不同(引擎按 mainCanvas rect / host 按 #game rect)。
|
||||
|
||||
### 4.2 A0 处置(A1 才做引擎态透传,A0 不得双发)
|
||||
|
||||
A0 范围:`getInput` 仍是「host inputBridge 受控订阅面」(A1 才改成引擎轮询态包装)。要既「不双发」又「保 probe firstInput 旁证 + gamefeel 输入缓冲可被真点击触发」,A0 做法:
|
||||
|
||||
- **host 的 DOM 监听改挂引擎 mainCanvas,且坐标按引擎 mainCanvas rect**(与引擎坐标系一致),**不再挂 window/`#game`**。这样 host inputBridge 仍是受控面唯一注入源(A0 不动 getInput 契约),而引擎自带 document 监听虽也在收(无法关——`inputInit` 无开关),但**引擎的输入态(mousePos 等)A0 期无人消费**(getInput 走 host inputBridge,不读引擎态),故引擎那套监听是「空转副作用」,不污染受控面语义。**唯一真冲突=preventDefault 重复**(引擎已 preventDefault,host 再 preventDefault 无害幂等)与**音频解锁双触发**(见 §5,幂等无害)。
|
||||
- **结论**:A0 **保留 host inputBridge 注入链**,仅把监听目标从 `#game`/`window` 改到**引擎 mainCanvas**(real 模式)。坐标 `toCanvasXY` 改用引擎 mainCanvas rect。引擎自带 document 监听不消费、不冲突(A1 切引擎态透传时再统一收敛到引擎单源)。
|
||||
|
||||
**锚(host.js:318-330,当前输入桥接)**:
|
||||
```js
|
||||
/* ── 真实输入桥接:DOM 事件 → 归一化坐标 → core 输入桥 _emit ── ... */
|
||||
const inputBridge = bundle.inputBridge;
|
||||
function onPointer(type, ev) {
|
||||
const { x, y } = toCanvasXY(canvas, ev.clientX, ev.clientY);
|
||||
inputBridge._emit(type, { x, y });
|
||||
}
|
||||
canvas.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); onPointer('pointerdown', ev); });
|
||||
canvas.addEventListener('pointermove', (ev) => onPointer('pointermove', ev));
|
||||
canvas.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 }));
|
||||
```
|
||||
**改为**(监听目标按 engineMode 选;real 模式挂引擎 mainCanvas + 按其 rect 归一化;inputBridge 取自已建 bundle):
|
||||
```js
|
||||
/* ── 真实输入桥接:DOM 事件 → 归一化坐标 → core 输入桥 _emit ──
|
||||
【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 }));
|
||||
```
|
||||
|
||||
> **toCanvasXY 复用**:host.js:119-125 的 `toCanvasXY(canvas, clientX, clientY)` 用 `canvas.getBoundingClientRect()` + CSS 尺寸映射。引擎 mainCanvas CSS 用 `position:absolute;top/left:50% translate(-50%,-50%)`(:416-418)+ fixed-size 分支 CSS 100%/'',其 `getBoundingClientRect().width/height` 即显示尺寸——`toCanvasXY` 用 `rect.width/height` 归一化到 `VIEWPORT_W/H`,对引擎 canvas 同样成立(**前提**:引擎 mainCanvas 显示尺寸≈390×844;若 fixed-size CSS 100% 使显示尺寸随窗口变,`toCanvasXY` 用相对比例仍正确归一化到 390×844 逻辑像素)。**函数零改**,仅传入 target 变。
|
||||
|
||||
---
|
||||
|
||||
## 5. 音频解锁在引擎掌帧下处置
|
||||
|
||||
### 5.1 真源核验
|
||||
|
||||
引擎模块 `import` 即 `new AudioContext`(`littlejs.esm.js:6753`,进程内单例 `audioContext`)。引擎在多处交互自动 `audioContext.resume()`(mousedown:5931-5932 / keydown:5996-5997 / pointer:6664-6666)。`audioInit:6771` 仅在 `soundEnable&&!headless` 建 masterGain。
|
||||
|
||||
host 现 `audioFactory` 在解锁后 `new AudioContext`(host.js:164-172),`unlockAudioOnce` 绑首次手势 resume(host.js:333-340)。
|
||||
|
||||
### 5.2 冲突与 A0 处置
|
||||
|
||||
- **双 context 风险**:host `audioFactory` 另建一个 AudioContext → 与引擎单例并存。但 A0 期 `getAudioContext` 仍走 host audioFactory(getEngine/合成核背书是 A1+),**audio-music 插件用的是 host 的那个 context**,引擎自己的 context A0 期无插件消费——故「双 context」在 A0 不致功能错(各自独立,host 那个被插件用,引擎那个空转)。
|
||||
- **解锁双触发**:host `unlockAudioOnce` resume host context;引擎 document 监听 resume 引擎 context。两者各 resume 各的,幂等无害。
|
||||
- **A0 结论**:**音频链零改**(host.js:158-172 audioFactory + :332-340 unlockAudioOnce 不动)。真正「getAudioContext backing 到引擎单例 audioContext」是 A1(受控面 time/random/input 透传那一批的同族改造),A0 不做。**风险**记 §11(双 context 在 A1 收敛)。
|
||||
|
||||
> 唯一须确认:引擎 `import` 即 `new AudioContext` 在「无用户手势」下浏览器可能告警「AudioContext not allowed to start」(suspended 态),但引擎 `audioInit` 容忍 suspended,且 host-dev real 模式 mode≠smoke 时无声音播放需求——**无 console error 级**(仅 warning)。门0 smoke 的「console 零未捕获错误」断言(integration-run.cjs:88)须确认引擎这条是 warning 非 error(mini-desktop 实测核对)。
|
||||
|
||||
---
|
||||
|
||||
## 6. time:引擎 time/frame 暴露给 probe(门0),ctx.time 不动
|
||||
|
||||
### 6.1 设计(A0 只暴露,不透传)
|
||||
|
||||
门0 要「引擎 `time`/`frame` 随循环真推进(probe 实测)」+「probe 双时钟物理隔离」。A0 做法:
|
||||
|
||||
- `ctx.time` backing **不动**(仍 `mockNowMs`,受控面零改,A1 才透传引擎 `time`)。
|
||||
- A0 **额外**经 `window.__engine` 暴露引擎钟读取器,供 probe/门0 实测脚本读引擎 `time`/`frame`/`timeReal`:
|
||||
- 引擎 `frame`(d.ts:98,单调自增)/`time`(d.ts:102,=frame/60,受 timeScale/暂停污染)/`timeReal`(d.ts:106,纯墙钟,不受 pause/timeScale/帧率钳制)。
|
||||
- 这三个是引擎导出的 `let` 全局——经 `LJS.frame`/`LJS.time`/`LJS.timeReal` 读(ESM live binding,读到的是引擎内部当前值)。
|
||||
|
||||
**编辑:在 setupAfterEngine 内(real 模式)挂 `window.__engine`**(新增,host 私有探活/门0 用,不进受控面契约):
|
||||
```js
|
||||
// 【A0 门0】暴露引擎钟给 probe/门0 实测脚本(host 私有,非受控面契约)。
|
||||
// 双时钟物理隔离:engine.time=污染钟(受 timeScale/暂停,仅作「接管断言」证 time 随 frame 推进);
|
||||
// engine.timeReal=纯墙钟(d.ts:106 不受 pause/timescale/帧率钳制,作门9 P75 埋点候选)。
|
||||
// 两者读自引擎不同导出变量,互不覆盖。frame=单调帧号。
|
||||
if (engineMode === 'real') {
|
||||
window.__engine = {
|
||||
frame() { return LJS.frame; }, // 单调帧号
|
||||
time() { return LJS.time; }, // 污染钟(timeScale/暂停影响)——接管断言用
|
||||
timeReal() { return LJS.timeReal; }, // 纯墙钟——P75 埋点候选
|
||||
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 }; },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 probe 双时钟物理隔离(probe 是冻结件,不改 probe)
|
||||
|
||||
- probe 自身 P75 埋点用 `ctx.time.nowMs()`(=mockNowMs,host 步进钟)——**probe 不改**(冻结 lane 件,β spec §3/§5/门9 要求「probe 保留独立墙钟,不透传引擎 time」)。A0 期 `ctx.time`=host mockNowMs,本就独立于引擎 time,满足「不透传引擎 time」。
|
||||
- 门0 的「接管断言」(证引擎掌帧)用 `window.__engine.frame()`/`time()`,**与 probe 的 P75 钟(ctx.time)物理隔离**(一个读 `LJS.time`,一个读 host mockNowMs)。两钟不同源、互不覆盖——满足门0「双时钟物理隔离」。
|
||||
|
||||
> **关键澄清**:门0 的双时钟是「引擎 time(接管断言)vs 引擎 timeReal 或独立墙钟(P75)」。A0 落法=接管断言读 `LJS.time`+`LJS.frame`;P75 埋点(probe 现状)读 host mockNowMs(独立墙钟,不受引擎 timeScale 污染)。**这满足隔离**(P75 不读被污染的引擎 time)。若后续要把 P75 也搬到引擎 `timeReal`,那是 A1+probe 协同改,A0 不做(probe 冻结)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 确定性取证存活:window.__hostdev 步进路径适配引擎
|
||||
|
||||
### 7.1 核心矛盾(R3 已坐实)
|
||||
|
||||
引擎掌帧后主循环=引擎内部 RAF(墙钟驱动,每动画帧追帧数 N 非确定)。host 现 `stepFrames(n)` 靠「手摇 `bundle.tick(FIXED_DT)` n 次」保「同帧号→同像素」——**引擎模式下这条断了**:`bundle.tick` 仍可手调(它是 host 自己的 onFrame 派发器,与引擎 RAF 无关),但**引擎渲染(gameRender 回调里的 renderParticles)只在引擎 RAF tick 时跑**,host 手调 `bundle.tick` 不触发引擎 render;且引擎每帧 `updateCanvas` 会清屏。
|
||||
|
||||
### 7.2 A0 存活方案(双轨,依 R3 推荐)
|
||||
|
||||
**取「stub 通道保确定性旁路 + real 通道证掌帧」双轨**(β spec §11 + R3 facts「候选独立确定性旁路:保留现 host-dev 桩作纯确定性回归通道,与引擎掌帧的真接线通道并存」):
|
||||
|
||||
- **确定性逐像素哈希回归(vfxFrame/paletteFrame/reset/stepFrames)→ 走 stub 通道**(`?engine=stub`):`stepFrames` 原样保留(host.js:277-283 不动),`vfxFrame`/`paletteFrame`/`reset` 原样(host.js:486-552 不动),绘制面=host `prepareCanvas(#game)` 的 ctx2d。**这条与 α 完全等价**,FNV-1a 哈希「同参数重跑×3 一致」原样成立——确定性回归不受引擎影响(兜回归)。
|
||||
- **引擎真掌帧 + 真渲染像素(门0 + A6 WebGL/2D 像素门)→ 走 real 通道**(`?engine=real`):取证不再「手摇定量推帧」,而是:
|
||||
1. 门0:经 `window.__engine` 实测引擎 `frame`/`time` 真推进(见 §10);
|
||||
2. A6 像素门:`window.__hostdev` 在 real 模式提供**「引擎帧号对齐的取证入口」**——因引擎不可外部单步,real 模式的 `vfxFrame` 改为「spawn 预设 + 真 RAF 跑到目标引擎 frame(轮询 `LJS.frame>=startFrame+frames`)→ 读 `engine.mainContext` ImageData」。**逐帧像素哈希的「同 frame→同像素」**靠:引擎 `time=frame++/frameRate`(整数 frame 的纯函数,与墙钟无关,R3 已证),故**锁定引擎 frame 到目标值再抓**,same-seed+same-engine-frame→same-pixel 成立(粒子序列由 reseed 派生随机定,与引擎 frame 同步)。**注意**:real 模式取证须在引擎 RAF 自然推进下「等到目标 frame 抓」,不是手摇;同参数重跑因引擎 frame 整数确定 + reseed 确定 → 像素一致(追帧数 N 抖动只影响「几次动画帧凑齐 frames 个逻辑步」,不影响逻辑步内容)。
|
||||
|
||||
**real 模式 vfxFrame 适配(新增分支,stub 分支保留)**:
|
||||
```js
|
||||
vfxFrame(preset, presetSeed, frames, origin) {
|
||||
const pj = instances['particles-juice'];
|
||||
const pp = instances['palette-post'];
|
||||
pp.setPost({ vignette: { enabled: false }, dither: { enabled: false }, scanline: { enabled: false } });
|
||||
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);
|
||||
if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id);
|
||||
if (engineMode === 'stub') {
|
||||
stepFrames(frames); // 桩:手摇定量推帧(确定性逐像素,原样)
|
||||
return { particleCount: pj.particleCount(), frame: bundle.frameCount() };
|
||||
}
|
||||
// real:引擎掌帧,不能手摇;返回 Promise,等引擎 RAF 推进 frames 个逻辑步后 resolve(取证侧 await)。
|
||||
// bundle.tick 由引擎 gameUpdate 每逻辑步驱动,故 bundle.frameCount() 随引擎逻辑步推进;
|
||||
// 等 bundle.frameCount() 增量 ≥ frames 即「同 host 帧号」对齐(与 stub 帧号口径一致,便于哈希对照)。
|
||||
const startFrame = bundle.frameCount();
|
||||
return new Promise((resolve) => {
|
||||
const tEnd = Date.now() + 5000; // 兜底超时,防卡死
|
||||
const poll = () => {
|
||||
if (bundle.frameCount() - startFrame >= frames || Date.now() > tEnd) {
|
||||
resolve({ particleCount: pj.particleCount(), frame: bundle.frameCount(), engineFrame: LJS.frame });
|
||||
} else {
|
||||
window.requestAnimationFrame(poll);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
> **取证范式迁移留痕**:real 模式 `vfxFrame`/`paletteFrame` 变 **async**(返回 Promise),CDP driver 须 `await cdp.evaluate('...vfxFrame(...).then(...)')` 或轮询 `window.__hostdev` 暴露的就绪标记。这是 R3「stepFrames 取证范式 real 后须重写」的落点。**A6 集成段**据此改 `integration-run.cjs`(非冻结件)。**A0 主体只需保证两通道入口都在**(stub 同步、real 异步),范式细节由 A6 定 N/目标 frame 清单(门0 openQuestion)。
|
||||
>
|
||||
> **reset 在 real 模式**:`reset` 现「推 220 帧让粒子消亡」靠手摇 `bundle.tick`(host.js:543-546)——real 模式同样可手调 `bundle.tick` 消亡粒子(粒子状态是 host 侧 bundle 管的,不依赖引擎渲染),但屏幕清空靠引擎下一 RAF tick。real 模式 reset 须「手调 bundle.tick 消亡 + 等一个引擎 RAF tick 清屏」。**A0 落法**:reset 保留手调消亡(host.js:543-546 不动),real 模式额外 `await` 一个 RAF 让引擎清屏后再返回。
|
||||
|
||||
### 7.3 stepFrames / vfxFrame / paletteFrame / reset 改动汇总
|
||||
|
||||
| 入口 | stub 通道 | real 通道 |
|
||||
|---|---|---|
|
||||
| `stepFrames(n)` | 原样(手摇 tick+renderFrame,host.js:277-283 不动) | 仅推进 host bundle(`bundle.tick` n 次,不调 renderFrame——引擎渲染自走 RAF);用于 reset 消亡 |
|
||||
| `vfxFrame` | 原样(同步,定量推帧) | async(spawn+reseed+等引擎推 frames 逻辑步+读 engine.mainContext) |
|
||||
| `paletteFrame` | 原样(同步) | async(同 vfxFrame) |
|
||||
| `reset` | 原样 | 手调消亡 + await 一个 RAF 清屏 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 可回滚点实现(flag + 保留桩)
|
||||
|
||||
- **flag**:`?engine=real|stub`(缺省 `real`)。`engineMode = getParam('engine', 'real')`(§2.2 已加)。
|
||||
- **桩整块保留**:`prepareCanvas`/`clearBlack`/`rafLoop`/`startRaf`/`stopRaf`/`stepFrames`/`vfxFrame(stub分支)`/`reset(stub分支)`——全部**不删**,仅由 `engineMode` 分流挂载。`engineMode==='stub'` 时 host 行为与 α **逐行等价**(同 `#game` 2D canvas + 浏览器 RAF + bundle.tick)。
|
||||
- **回滚操作**:创始人裁「A0 地基不通」时,URL 加 `?engine=stub` 即回 α 桩;或把 `getParam('engine','real')` 默认值改回 `'stub'`(一行)。**commit 边界**:A0 改动单独成 commit(β spec §11「A0/A/B/C 各 commit 边界清」),revert 该 commit 即全回滚。
|
||||
- **桩通道验证不退化**:`integration-run.cjs` 加 `?engine=stub` 跑一遍应与 α 收口报告(`2026-06-12-T1b-α收口报告.md`)证据等价——这是回滚点「活着」的实测保证。
|
||||
|
||||
---
|
||||
|
||||
## 9. esbuild:littlejsengine 入产物 + box2d 风险
|
||||
|
||||
### 9.1 入产物(体积跳升,SIZES 重锚留 A5)
|
||||
|
||||
- `host-dev/entry.js`(→ `host-bundle.js`)是引擎接线入口;esbuild `bundle:true` 会把 `import * as LJS from 'littlejsengine'` 整体打入(`littlejs.esm.js` raw≈640KB,min 后≈242KB 量级;题面记「引擎入包预期产物≈432KB raw」)。**A5 用 size.mjs 增量法实测重锚**;A0 仅记预期,不判超标(β spec 门2 是 A5 的事)。
|
||||
- `build.mjs` `LOCKED_BUILD_OPTIONS` 不改(minify+iife+es2019)。esbuild 默认能 tree-shake,但引擎多为副作用顶层代码(`new AudioContext` 等),树摇有限——大头照进。
|
||||
- **门1①(真接线 metafile)**:build.mjs `build()` 当前**未传 metafile**。A0/门1 须 additive 加 `metafile:true` 落 `metafile.json`,grep 确认 `littlejsengine` 入产物且被 host chunk 引用(纯 node,本机可验)。**这是 build.mjs 的 additive 改点**:
|
||||
```js
|
||||
// build.mjs build() 内 esbuild.build({...}) 加:
|
||||
metafile: true,
|
||||
// 并在 await 后:if (result.metafile) writeFileSync(outfile + '.meta.json', JSON.stringify(result.metafile));
|
||||
```
|
||||
(门1①本机可跑:`node scripts/build.mjs host-dev/entry.js dist/host-bundle.js --global-name=GameRuntimeHostDev` 后 grep meta.json 命中 `littlejsengine/dist/littlejs.esm.js`。**但纪律上构建归集成段 mini-desktop**——本机有 esbuild 可验证 metafile 生成与入包,真渲染取证仍上 mini-desktop。)
|
||||
|
||||
### 9.2 box2d.wasm 依赖:esbuild 能否正常处理
|
||||
|
||||
- **核验结论**:`littlejs.esm.js` **不 `import` box2d.wasm.js/.wasm**;box2d 经全局 `Box2D()`(`box2dInit:14582`,惰性,A0 路径永不调)。故 esbuild **不会**尝试解析/打包 wasm 文件——`Box2D` 是个未定义的自由全局引用,esbuild 把它当外部全局(不报错、不打包)。运行时因 `box2dInit` 永不被调,`Box2D` 永不被读,**无 ReferenceError、无 wasm fetch**。
|
||||
- **风险标记**:(a) 体积——box2d 相关 JS 类(Box2dObject 等,`littlejs.esm.js:12668+` 大段)作为死代码照进产物,撑大 raw(A5 重锚计入);(b) 若 esbuild 在某配置下对未定义全局报 `Could not resolve` ——**已核验非 import 故不会**(esbuild 只对 `import`/`require` 报 resolve 失败,自由标识符不报)。**结论**:esbuild 正常处理,无须 external 配置,无须引 wasm。详记 §11 风险表。
|
||||
|
||||
---
|
||||
|
||||
## 10. 门0 probe 断言清单 + 双时钟实测 runbook
|
||||
|
||||
### 10.1 门0 断言清单(每条给判据 + 数据源)
|
||||
|
||||
| # | 断言 | 数据源 | 判据 |
|
||||
|---|---|---|---|
|
||||
| G0-1 | real 通道引擎真起(非桩) | `window.__hostBootedReal===true` 且 `window.__engineGameInitFired===true` | 两者皆 true(engineInit 五回调真触发,gameInit 跑过) |
|
||||
| G0-2 | 引擎内部 RAF 真掌帧(frame 单调推进) | `window.__engine.frame()` 隔 ~500ms 读两次 t0/t1 | `t1>t0`(引擎 RAF 在自调度推帧,非桩静止) |
|
||||
| G0-3 | 引擎 time 随 frame 同步推进 | `window.__engine.snapshot()` 读 time/frame | `time≈frame/60`(偏差容忍 < 0.05s;证 `time=frame++/frameRate` 真跑) |
|
||||
| G0-4 | timeReal 纯墙钟推进(不受暂停/变速) | `window.__engine.timeReal()` 隔 ~500ms 读两次 | `ΔtimeReal>0` 且 ≈ 墙钟间隔(证 P75 埋点钟可用) |
|
||||
| G0-5 | 双时钟物理隔离 | 对比 `window.__engine.time()`(污染钟)与 probe P75 钟 `ctx.time`(host mockNowMs,经 `window.__probe()` JSONL tMono) | 两钟读自不同变量、数值不联动(隔离成立;P75 不读被污染的引擎 time) |
|
||||
| G0-6 | 受控面契约零改(插件零改可跑) | smoke 门:`window.__probe()` 的 `initOk===true` + 六锚点齐 + `verify.ok===true` + `uncaught.length===0` | 全过(real 通道下插件经 engine.mainContext 渲染,受控面 7 项未改仍工作) |
|
||||
| G0-7 | 渲染落在 2D mainContext(非空 WebGL 空画布) | real 通道抓引擎 mainCanvas(`#game-engine`)ImageData → 非空 + 非全黑 | 有非黑内容(证 setGLEnable(false) 后粒子真渲进 2D mainContext,readback 有像素) |
|
||||
| G0-8 | 回滚桩仍活 | `?engine=stub` 跑 integration-run → 与 α 证据等价 | stub 通道 PASS(回滚点活着) |
|
||||
|
||||
### 10.2 本机段(6c6g,禁 chrome,只验可纯 node/esbuild 验的部分)
|
||||
|
||||
```bash
|
||||
# cwd=/root/games-development-ai/game-runtime
|
||||
# [本机-1] 受控面/插件零改回归(node --test 全绿,证 A0 不碰 plugin.js/api.d.ts 行为)
|
||||
bash scripts/test.sh # 或 node --test(执行版纪律:lane 本机零依赖 node --test 必须全绿)
|
||||
|
||||
# [本机-2] 引擎入包 + 门1① metafile(本机有 esbuild,可验证 import 解析与入产物,不跑浏览器)
|
||||
npm i -D esbuild # 本机若未装(纪律上构建归集成段,但 metafile 生成是纯 node 可本机验)
|
||||
node scripts/build.mjs host-dev/entry.js dist/host-bundle.js --global-name=GameRuntimeHostDev
|
||||
# → 期望:成功产出 host-bundle.js(不报 box2d.wasm resolve 失败);
|
||||
# → grep 产物含引擎符号(证 littlejsengine 真入包,非名义 import):
|
||||
grep -c "engineInit\|requestAnimationFrame" dist/host-bundle.js # >0
|
||||
# → metafile(门1①,需 build.mjs additive 加 metafile:true 后):
|
||||
grep -o "littlejsengine/dist/littlejs.esm.js" dist/host-bundle.js.meta.json | head # 命中=真入包
|
||||
|
||||
# [本机-3] box2d 未触发确认(静态):产物里 box2dInit 存在但无调用点
|
||||
grep -c "box2dInit" dist/host-bundle.js # 存在(死代码)
|
||||
grep -c "await Box2D\|\.wasm" dist/host-bundle.js # Box2D() 引用在、但 .wasm 字面量不应作 fetch URL 被打入
|
||||
```
|
||||
|
||||
> 本机**不可**验:G0-1~G0-7(需真 engineInit + 真 RAF + 真 ImageData,禁 chrome → 上 mini-desktop)。本机只兜「node --test 绿 + 引擎真入包 + box2d 不炸 build」。
|
||||
|
||||
### 10.3 mini-desktop 段(真引擎 + 真 RAF + 真像素,门0 主考)
|
||||
|
||||
```bash
|
||||
# 在 mini-desktop(co-located 服务+chrome,全 localhost)。沿用 α 的 systemd-run launcher 起 static-serve + chrome --headless=new --remote-debugging-port=9222。
|
||||
# cwd=game-runtime/,构建产物已 serve 于 http://localhost:4321/
|
||||
|
||||
# [desktop-1] 门0 接管断言(real 通道,新增门0 driver 或扩 integration-run):
|
||||
# 开 http://localhost:4321/index.html?seed=0x1234abcd&engine=real&mode=evidence
|
||||
# 经 CDP evaluate 取证:
|
||||
# await wait(window.__hostBootedReal===true) # G0-1
|
||||
# s0=window.__engine.snapshot(); await delay(500); s1=window.__engine.snapshot()
|
||||
# assert s1.frame>s0.frame # G0-2
|
||||
# assert |s1.time - s1.frame/60| < 0.05 # G0-3
|
||||
# assert s1.timeReal>s0.timeReal # G0-4
|
||||
# assert s1.time 读 LJS.time、probe tMono 读 mockNowMs,两源独立 # G0-5
|
||||
|
||||
# [desktop-2] 门0 渲染落 2D(G0-7):
|
||||
# reset → vfxFrame('burst',1001,12)(real:await Promise)→ captureImageData('#game-engine')
|
||||
# assert 非空 + 直方图有非黑主色(粒子真渲进 mainContext)
|
||||
|
||||
# [desktop-3] 门0 受控面零改回归(G0-6):
|
||||
# 开 ...?engine=real&mode=smoke → window.__probe()
|
||||
# assert initOk && verify.ok && uncaught.length===0 && 六锚点齐
|
||||
|
||||
# [desktop-4] 回滚桩活(G0-8):
|
||||
# node test/harness/integration-run.cjs 4321 (?engine=stub 通道,或 driver 显式带 engine=stub)
|
||||
# assert INTEGRATION_EVIDENCE: PASS(与 α 收口报告等价)
|
||||
```
|
||||
|
||||
> 门0 判定阈值(G0-2 帧增量≥多少、G0-3 偏差容忍)**A6 在 harness 里定死「唯一命令/样本数/fail 条件」**(β spec 门9 要求每门给验证方法)——A0 给出上述判据骨架,具体阈值(如「500ms 内 frame 增量 ≥ 20」)由 A6 实测定(openQuestion)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 受控面不变性论证(A0 只改 host backing,契约零改)
|
||||
|
||||
**论证:A0 全部改动落在 `host-dev/host.js`(host 实现)+ `entry.js`(import)+ `build.mjs`(additive metafile),`src/core/api.d.ts` 与 `src/core/plugin.js` 零改**——
|
||||
|
||||
| 受控面成员 | A0 后 backing | 契约签名 | 不变性论证 |
|
||||
|---|---|---|---|
|
||||
| `getContext2d()` | `engine.mainContext`(real)/ `#game` ctx(stub) | `():CanvasRenderingContext2D\|null` | 契约只约定「返回 2D ctx」,不约定来源;real 经路 B 一次性快照引擎 mainContext,仍是合法 2D ctx。插件经 `context.getContext2d()` 拿到一张 2D ctx,零改。 |
|
||||
| `onFrame(cb)` | 引擎 `gameUpdate` 内调 `bundle.tick(1/60)` 驱动(real)/ host RAF(stub) | `(cb:(dt,frame)=>void):FrameHandle` | `bundle.tick` 派发 onFrame 的逻辑(plugin.js:510-523)零改;只是「谁调 tick」从 host RAF 变引擎 gameUpdate。dt 仍恒 1/60,frame 仍单调——回调形态不变,particles dt 敏感性满足。 |
|
||||
| `getInput()` | host inputBridge(监听挂引擎 mainCanvas,real) | `():InputSource` | inputBridge 实现(plugin.js:236-353)零改;只是 host 监听目标从 `#game` 变引擎 mainCanvas,坐标仍归一化到 390×844 逻辑像素。getInput 契约不变(A1 才改引擎态透传)。 |
|
||||
| `getAudioContext()` | host audioFactory(A0 不动) | `():AudioContext\|null` | 音频链零改(§5)。 |
|
||||
| `time` | host mockNowMs(A0 不动,A1 才透传引擎 time) | `TimeSource{nowMs,elapsedMs}` | ctx.time backing 不动;引擎钟另经 `window.__engine`(host 私有,非契约)暴露给 probe。 |
|
||||
| `random` | per-plugin 派生 mulberry32(A0 不动) | `RandomSource{next,range,reseed}` | 随机链零改(引擎 RandomGenerator 透传是 A1)。 |
|
||||
| `getEngine()` | host-dev 缺省 null(A0 不注 engineFactory) | `():EngineCapabilities\|null` | engineFactory 缺省 null → getEngine 返 null,插件容错降级(plugin.js:438-456 零改)。真背书 A1+。 |
|
||||
|
||||
**Q4 铁律兑现**:`import * as LJS from 'littlejsengine'` 只出现在 `entry.js` + `host.js`(集成段 host),**未**进 `api.d.ts`/`plugin.js`/任何插件 impl。`window.__engine` 是 host 私有探活口(非受控面 PluginContext 成员),不破「引擎可换」抽象边界。
|
||||
|
||||
---
|
||||
|
||||
## 12. 回滚策略
|
||||
|
||||
- **flag 回滚**:`?engine=stub`(URL 即时)或 `getParam('engine','real')` 默认改 `'stub'`(一行)→ host 行为回 α 桩(`#game` 2D canvas + 浏览器 RAF + bundle.tick),逐行等价。
|
||||
- **commit 回滚**:A0 单独 commit(β spec §11),`git revert` 该 commit 即移除引擎 import + 五回调 + 通道分流,回 α host.js。entry.js 的 `import * as LJS` 与 `export const Engine` 一并 revert。`build.mjs` 的 metafile 是 additive(移除不破构建)。
|
||||
- **桩活保证**:回滚点「活着」的实测=`?engine=stub` 跑 integration-run 与 α 收口报告证据等价(§8 / §10.2 desktop-4)。
|
||||
- **失败处置(β spec §11)**:A0 引擎接管不通=真接线全阻塞 → 回桩 + 本波降级(**须创始人裁**,因 0 字节现状不可接受,但保留可回滚点)。引擎某能力不达标=记「缺件补层」+标引擎缺口(留引擎复议权,终裁包 §4.5-3),**不**回退「有意替代」自研(Q4 禁)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 遗留风险(按严重度)
|
||||
|
||||
| # | 风险 | 影响 | 处置 / 归属 |
|
||||
|---|---|---|---|
|
||||
| R-A | **real 模式确定性逐像素哈希不可「手摇定量推帧」** | A6 像素门「同 frame→同像素」须靠「等引擎 RAF 推到目标 frame 再抓」,范式与 α 不同;追帧数 N 抖动若使「凑齐 frames 逻辑步」的动画帧数变,仍因逻辑步内容确定(time=frame/60 纯函数)→ 像素一致,但**须 mini-desktop 实证** | A0 给双轨入口(stub 同步兜回归 / real async 证掌帧);逐帧哈希以 stub 通道为确定性基线,real 通道证掌帧+渲染落 2D。**openQuestion**:real 逐像素哈希×N 是否稳定,A6 实测定 N/目标 frame 清单 |
|
||||
| R-B | **WebGL 默认 true,须 `setGLEnable(false)` 才有 2D 像素** | 不关 WebGL 则 mainCanvas(2D ctx)空、glCanvas(WebGL)才有像素,`getImageData` 读空 | A0 real 通道 `engineInit` 前 `setGLEnable(false)`(§2.4)→ 纯 2D 渲染进 mainCanvas,readback 无 WebGL `preserveDrawingBuffer` 坑。**已核验导出名 `setGLEnable`(非 `setGlEnable`)** |
|
||||
| R-C | **引擎自建 mainCanvas,不采用 `#game`;CDP 选择器须改** | `captureImageData('#game')` 抓空 canvas | A0 给引擎 mainCanvas 打 id `#game-engine`(§3.4 法①)+ `__hostdev.canvas()`;harness driver `CANVAS` 改 `#game-engine`(集成段配套,非冻结件) |
|
||||
| R-D | **插件按逻辑像素 390×844 绘制,引擎 mainContext 是物理像素 780×1688 口径(无 DPR 变换)** | 不补变换则粒子只覆盖左上 1/4 | A0 在 renderParticles/renderPostFx 对 engine.mainContext 补 `setTransform(DPR,...)`(§3.2),保插件逻辑像素口径不变(受控面坐标系不变性兑现) |
|
||||
| R-E | **引擎 import 即 `new AudioContext`(双 context)** | A0 期 host audioFactory 另建 context,与引擎单例并存;getAudioContext 走 host 那个,引擎那个空转 | A0 音频链零改(§5),功能不错(各自独立);A1 收敛 getAudioContext backing 到引擎单例。**双 context 在无手势下浏览器 warning(非 error),门0 smoke「零未捕获错误」须核对是 warning** |
|
||||
| R-F | **引擎自带 9 document 输入监听不可关(inputInit 无开关)** | A0 期与 host inputBridge 并存(引擎那套 A0 无人消费,空转),仅 preventDefault/audio-resume 重复(幂等无害) | A0 host 监听改挂引擎 mainCanvas 保坐标一致(§4);真收敛到引擎单源是 A1(getInput 改引擎态透传) |
|
||||
| R-G | **box2d 死代码入产物撑大 raw** | 产物 raw 比预期多 box2d JS 类(~数十 KB);但 `box2dInit` 永不调、`.wasm` 不被 import/fetch | A5 SIZES 增量法重锚计入;A0 仅记。esbuild 正常处理(自由全局 `Box2D` 不报 resolve,§9.2 已核验) |
|
||||
| R-H | **bootHostDev 变异步(real 模式 __hostdev 延迟挂载)** | CDP/index.html 取证须等 `window.__hostBootedReal` 再驱动 `__hostdev`;`bootHostDev` 返回值从 hostdev 变占位 `{pending:true}` | A0 在 index.html 探活用 `__hostBooted`(同步置),real 就绪用 `__hostBootedReal`;harness driver 等就绪标记(集成段配套)。**注意**:index.html:76 `window.__host = G.bootHostDev(...)` 拿到的是占位对象——若有代码依赖其同步返回 hostdev,须改等 `window.__hostdev`(§2.4 已留痕) |
|
||||
| R-I | **门0 判定阈值未定** | G0-2 帧增量阈值 / G0-3 time 偏差容忍 / A6 像素门 N 未定 | A6 在 harness 定死「唯一命令/样本数/fail 条件」(β spec 门9);A0 给判据骨架(§10.1) |
|
||||
| R-J | **engineInit 二次调用守卫(`ASSERT(!mainContext)` + 运行时 `if(mainContext)return`)** | 若页面/测试重复 boot 会被引擎静默拒绝(return),mainContext 复用上次 | A0 real 通道 `bootHostDev` 每页一次;CDP 每取证开独立 target(integration-run 现状已是独立 target),不重复 engineInit。**风险低**,记此防 A6 误重入 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 编辑清单汇总(供执行核对)
|
||||
|
||||
| 文件 | 改动 | old→new 锚 |
|
||||
|---|---|---|
|
||||
| `host-dev/entry.js` | 加 `import * as LJS from 'littlejsengine'` + `export const Engine=LJS` | :16-17 追加 import;:21-22 追加 export(§1.2) |
|
||||
| `host-dev/host.js` | 加引擎 import | :29-36 import 块追加 `import * as LJS`(§1.3) |
|
||||
| `host-dev/host.js` | bundle 延后建 + engineMode flag | :174-193 `const bundle=...` → `let bundle=null` + engineMode(§2.2) |
|
||||
| `host-dev/host.js` | renderFrame 拆粒子/后处理 + renderCtx | :297-316 renderFrame → renderParticles/renderPostFx/renderFrame(§2.3,含 §3.2 DPR 变换) |
|
||||
| `host-dev/host.js` | RAF 桩保留为 stub 通道 + 引擎五回调 | :342-368 startRaf/stopRaf/rafLoop 保留 + 追加 engGameInit/Update/UpdatePost/Render/RenderPost(§2.3) |
|
||||
| `host-dev/host.js` | 输入监听目标按 engineMode | :318-330 监听 `canvas`→`inputTarget`(real=引擎 mainCanvas)(§4.2) |
|
||||
| `host-dev/host.js` | bootHostDev 主体重排(buildBundle/setupAfterEngine + 通道分流 + engineInit) | :188-203 移入 buildBundle;:205-562 大段移入 setupAfterEngine;:564-581 收尾改通道分流(§2.4) |
|
||||
| `host-dev/host.js` | window.__engine 暴露引擎钟(门0) | setupAfterEngine 内 real 分支新增(§6.1) |
|
||||
| `host-dev/host.js` | 引擎 mainCanvas 打 id + __hostdev.canvas() | setupAfterEngine 内 `LJS.mainCanvas.id='game-engine'` + hostdev.canvas(§3.4) |
|
||||
| `host-dev/host.js` | vfxFrame/paletteFrame/reset real 异步分支 | :486-552 各加 `if(engineMode==='real')` async 分支(§7.2/7.3) |
|
||||
| `scripts/build.mjs` | additive metafile(门1①) | build() 内 esbuild.build 加 `metafile:true` + 落 meta.json(§9.1,additive) |
|
||||
| `test/harness/integration-run.cjs` | CANVAS 选择器 + real 通道 await(集成段配套,非冻结件) | :30 `CANVAS='#game'`→`'#game-engine'`;evaluate 处 await async vfxFrame(§3.4/§7,**A6 改,非 A0 主体**) |
|
||||
|
||||
> **冻结件不动**:`src/core/plugin.js`、`src/core/api.d.ts`、`src/plugins/runtime-probe/impl.js`(及其它插件 impl)—— A0 全程零改(§11 论证)。
|
||||
@ -15,8 +15,14 @@
|
||||
|
||||
import * as RT from '../src/all-plugins.js';
|
||||
import { bootHostDev } from './host.js';
|
||||
// 【A0:引擎主循环接管】littlejsengine import 落点之一(集成段 host,与 host.js 并列)。
|
||||
// Q4 铁律:引擎 import 只许活在集成段 host(entry.js/host.js),禁泄进受控面契约/插件。
|
||||
// host.js 自行 import * as LJS 作引擎掌帧实现;此处 export 仅为 DevTools 控制台手验引擎导出便利。
|
||||
import * as LJS from 'littlejsengine';
|
||||
|
||||
// 暴露到全局:index.html 调 bootHostDev 启动;其余符号便于 DevTools 控制台手验。
|
||||
// (esbuild globalName 也会把本模块的默认/具名导出挂全局,这里再显式聚合一份语义清晰的入口。)
|
||||
export { bootHostDev };
|
||||
export const Runtime = RT;
|
||||
// A0:引擎名字空间挂入口,便于控制台手验引擎导出(host.js 才是引擎接管的真实现体)。
|
||||
export const Engine = LJS;
|
||||
|
||||
@ -34,6 +34,14 @@ import {
|
||||
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';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 取证固定环境常量(与 test/harness/browser-evidence.cjs 的 FIXED_ENV 对齐)
|
||||
@ -171,10 +179,15 @@ export function bootHostDev(opts) {
|
||||
return realAudioCtx;
|
||||
}
|
||||
|
||||
// 创建 host-dev 受控上下文:注入真 2D ctx / 主种子 / 音频工厂。
|
||||
// 时钟:取证用步进时钟(见下 mockClock),真实 RAF 模式则用墙钟——这里给步进时钟,
|
||||
// 真实模式由 RAF 推帧但时间读数仍走步进时钟,保证「插件读 time 与帧推进一致、且可被取证接管」。
|
||||
let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。
|
||||
// 创建 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();
|
||||
@ -185,22 +198,48 @@ export function bootHostDev(opts) {
|
||||
// 在注册器为每插件派生上下文时记下该插件的 random 引用,供 vfxFrame/paletteFrame 取证前 reseed。
|
||||
/** @type {Record<string, import('../src/core/api.d.ts').RandomSource>} */
|
||||
const derivedRandoms = {};
|
||||
const bundle = createHostDevContext({
|
||||
context2d: ctx2d,
|
||||
seed,
|
||||
audioFactory,
|
||||
clock: () => mockNowMs,
|
||||
});
|
||||
// 引擎通道选择:?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(host 拥有 bundle):注册器为每插件派生上下文时,记下该插件的
|
||||
// 派生 random 实例引用。这样 host 取证时能复位「插件那一份」随机(见 derivedRandoms 注释)。
|
||||
// 不改 core,不污染受控面——只是 host 在自己持有的 bundle 上挂一层记账。
|
||||
const _origDerive = bundle.deriveContextFor.bind(bundle);
|
||||
bundle.deriveContextFor = function (pluginName, scope) {
|
||||
const ctx = _origDerive(pluginName, scope);
|
||||
derivedRandoms[pluginName] = ctx.random; // 记下该插件专属随机实例
|
||||
return ctx;
|
||||
};
|
||||
/**
|
||||
* 建 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,
|
||||
});
|
||||
// 合法包裹 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 顺序手动驱动——
|
||||
@ -294,38 +333,75 @@ export function bootHostDev(opts) {
|
||||
else bundle.context.random.reseed(s >>> 0); // 兜底(不应触发)
|
||||
}
|
||||
|
||||
/** 把当前帧的表现层(粒子 + 后处理叠加)渲染到 canvas。底已在每帧重铺黑。 */
|
||||
function renderFrame() {
|
||||
clearBlack(ctx2d); // 每帧重铺黑底,避免上一帧残留干扰像素哈希
|
||||
// 粒子层
|
||||
/**
|
||||
* 画粒子层(引擎 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') {
|
||||
// 粒子插件**不内置颜色**(engine 级,颜色是 agent/host 的生成域,符合模板哲学红线):
|
||||
// render() 只设 globalAlpha + arc + fill,沿用当前 fillStyle。故 host 作为「引擎」在此提供可见色,
|
||||
// 否则粒子会以上次 fillStyle(clearBlack 设的黑)画在黑底上 → 不可见(取证全黑)。
|
||||
ctx2d.save();
|
||||
ctx2d.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见)
|
||||
try { pj.render(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); }
|
||||
ctx2d.restore();
|
||||
}
|
||||
// 后处理叠加层(vignette/dither/scanline)
|
||||
const pp = /** @type {any} */ (instances['palette-post']);
|
||||
if (pp && typeof pp.renderPost === 'function') {
|
||||
try { pp.renderPost(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); }
|
||||
g.save();
|
||||
if (engineMode === 'real') {
|
||||
g.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保插件逻辑像素口径
|
||||
try { g.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ }
|
||||
}
|
||||
g.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见)——语义同 α
|
||||
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 触发。 */
|
||||
这里把真实交互喂进受控面,使 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(canvas, ev.clientX, ev.clientY);
|
||||
const { x, y } = toCanvasXY(inputTarget, ev.clientX, ev.clientY);
|
||||
inputBridge._emit(type, { x, y });
|
||||
}
|
||||
canvas.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); onPointer('pointerdown', ev); });
|
||||
canvas.addEventListener('pointermove', (ev) => onPointer('pointermove', ev));
|
||||
canvas.addEventListener('pointerup', (ev) => onPointer('pointerup', ev));
|
||||
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 }));
|
||||
|
||||
@ -339,8 +415,9 @@ export function bootHostDev(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 真实 RAF 循环(交互模式):把墙钟 dt 钳制后推进 ──
|
||||
取证模式不依赖它(取证用 stepFrames 定量推进);但交互演示需要它让画面动起来。 */
|
||||
/* ── 回滚桩:浏览器 RAF 循环(仅 engineMode==='stub' 用,保留可回滚点,创始人裁)──
|
||||
real 模式不走它——引擎内部 RAF 驱动 bootHostDev 作用域的引擎五回调(engGameUpdate/Render/...)。
|
||||
取证模式不依赖它(取证用 stepFrames 定量推进);交互演示需要它让画面动起来(stub 通道沿 α 用墙钟 dt)。 */
|
||||
let rafId = 0;
|
||||
let lastTs = 0;
|
||||
let rafRunning = false;
|
||||
@ -351,7 +428,7 @@ export function bootHostDev(opts) {
|
||||
lastTs = ts;
|
||||
if (dt > 0.05) dt = 0.05; // dt 封顶钳制(防卡顿后大跳)
|
||||
mockNowMs += dt * 1000;
|
||||
bundle.tick(dt);
|
||||
if (bundle) bundle.tick(dt);
|
||||
renderFrame();
|
||||
rafId = window.requestAnimationFrame(rafLoop);
|
||||
}
|
||||
@ -495,8 +572,23 @@ export function bootHostDev(opts) {
|
||||
const id = pj.spawnEmitter(preset, ox, oy);
|
||||
// continuous 预设(trail/drift)返回长效 id;记下,reset() 时统一 stopEmitter 防其持续喷发。
|
||||
if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id);
|
||||
stepFrames(frames);
|
||||
return { particleCount: pj.particleCount(), frame: bundle.frameCount() };
|
||||
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。
|
||||
// 这里仅同步推进 host 粒子状态 frames 步(不调 renderFrame——引擎内部 RAF 自渲),使粒子场就绪;
|
||||
// 屏幕像素由引擎下一拍 gameRender→renderParticles 落到 mainContext。
|
||||
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 };
|
||||
},
|
||||
/**
|
||||
* 后处理像素证据入口:开/关某后处理效果并渲染,返回时画面即该状态。
|
||||
@ -519,8 +611,16 @@ export function bootHostDev(opts) {
|
||||
reseedParticleRandom(effSeed >>> 0);
|
||||
const id = pj.spawnEmitter('drift', VIEWPORT_W / 2, VIEWPORT_H / 2);
|
||||
if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id);
|
||||
stepFrames(frames);
|
||||
return { effect, enabled, frame: bundle.frameCount() };
|
||||
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 };
|
||||
},
|
||||
/**
|
||||
* 复位渲染状态到「干净黑底 + 无后处理 + 随机源回主种子 + 无活动粒子/发射器」。
|
||||
@ -538,13 +638,15 @@ export function bootHostDev(opts) {
|
||||
}
|
||||
activeEmitterIds.clear();
|
||||
pp.setPost({ vignette: { enabled: false }, dither: { enabled: false }, scanline: { enabled: false } });
|
||||
// 2) 不再 spawn,连续推帧让现存粒子消亡。
|
||||
// 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);
|
||||
}
|
||||
clearBlack(ctx2d);
|
||||
// 桩模式 host 自铺黑清屏;real 模式引擎每帧自清(updateCanvas 重设 mainCanvas.width),此处仅对 stub 生效。
|
||||
if (engineMode === 'stub' && renderCtx) clearBlack(renderCtx);
|
||||
// 回主种子(默认 context.random 与粒子插件随机都复位,便于下一组从确定起点取)。
|
||||
bundle.context.random.reseed(seed >>> 0);
|
||||
reseedParticleRandom(seed >>> 0);
|
||||
@ -558,8 +660,34 @@ export function bootHostDev(opts) {
|
||||
unlockAudio: unlockAudioOnce,
|
||||
/** 当前是否已解锁音频。 */
|
||||
audioUnlocked() { return audioUnlocked; },
|
||||
/** 取当前绘制面对应的 canvas 引用(real=引擎 mainCanvas / stub=host #game)。供 CDP 抓正确画布。 */
|
||||
canvas() { return (engineMode === 'real' && LJS.mainCanvas) ? LJS.mainCanvas : canvas; },
|
||||
};
|
||||
window.__hostdev = hostdev;
|
||||
|
||||
// 【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();
|
||||
@ -568,14 +696,77 @@ export function bootHostDev(opts) {
|
||||
const smokeResult = runSmoke();
|
||||
window.__smokeResult = smokeResult;
|
||||
} else if (mode === 'evidence') {
|
||||
// evidence 模式:只初始化,**不** runSmoke、**不** 自动 RAF——画面完全由 window.__hostdev 确定性入口接管,
|
||||
// 保证 vfx/palette 像素取证「同参数重跑逐像素一致」(不被真实墙钟 RAF 扰动)。
|
||||
setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed);
|
||||
// evidence 模式:只初始化,**不** runSmoke。stub 通道不自动 RAF(确定性取证);
|
||||
// real 通道引擎内部 RAF 已在跑(画面动),但逐像素确定性由 stub 通道兜(real 不手摇)。
|
||||
setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed + ' engine=' + engineMode);
|
||||
} else {
|
||||
// idle(缺省):启动真实 RAF 让画面可交互(点击屏幕喷粒子 + 解锁音频)。
|
||||
setStatus('idle: click canvas to spawn particles. seed=' + seed);
|
||||
startRaf();
|
||||
// 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;
|
||||
}
|
||||
|
||||
return 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 };
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
'use strict';
|
||||
|
||||
import { argv, exit } from 'node:process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
/** 锁定的 esbuild 构建参数(§5 写死,集成段与 size.mjs 必须同口径)。 */
|
||||
export const LOCKED_BUILD_OPTIONS = {
|
||||
@ -50,12 +51,19 @@ export async function build(entry, outfile, globalName) {
|
||||
throw new Error('esbuild 不可用(非集成段环境)');
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
// 【A0/门1① additive】metafile:true 落 <outfile>.meta.json,供 grep 核验「引擎真入包 + 被哪些 chunk 引用」。
|
||||
// additive(不改 LOCKED_BUILD_OPTIONS 锁参口径):移除 metafile 不影响产物字节,仅少一份诊断文件。
|
||||
const result = await esbuild.build({
|
||||
...LOCKED_BUILD_OPTIONS,
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
metafile: true,
|
||||
...(globalName ? { globalName } : {}),
|
||||
});
|
||||
if (result && result.metafile) {
|
||||
writeFileSync(outfile + '.meta.json', JSON.stringify(result.metafile));
|
||||
console.log(`[build] metafile → ${outfile}.meta.json`);
|
||||
}
|
||||
console.log(`[build] OK ${entry} → ${outfile} (iife/es2019/min/no-sourcemap)`);
|
||||
}
|
||||
|
||||
|
||||
157
game-runtime/test/harness/gate0-engine-takeover.cjs
Normal file
157
game-runtime/test/harness/gate0-engine-takeover.cjs
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* test/harness/gate0-engine-takeover.cjs — A0「引擎掌帧接管」门0 独立 driver(仅 mini-desktop,连本地 chrome via CDP)
|
||||
* owner:T1b-β A0(地基)
|
||||
*
|
||||
* 【为何独立 driver(不复用 integration-run.cjs)】
|
||||
* P2 复审 mustFix:A0 门0 不得依赖 A6 尚未改造的 integration-run.cjs(其 CANVAS='#game' 仍指退役画布、
|
||||
* real 通道无就绪等待)。本 driver 只读 window.__engine/__hostdev/__probe 做 G0-1~G0-8,**不碰逐像素哈希**
|
||||
* (real 通道不承担逐像素确定性,由 stub 通道兜——见 host.js vfxFrame/paletteFrame real 分支注释),
|
||||
* 使 A0 自验闭合(不被 A6 阻塞)。逐像素回归仍走 integration-run.cjs 的 stub 通道(desktop-4)。
|
||||
*
|
||||
* 【前置】(同 integration-run.cjs)
|
||||
* 外层 launcher(systemd-run)须已起好:static-serve serve 构建产物于 http://localhost:<PORT>/;
|
||||
* chrome --headless=new --remote-debugging-port=9222 --no-sandbox --no-zygote --remote-allow-origins=*。
|
||||
* 构建产物须为「引擎真入包」的 host-bundle.js(entry.js 经 esbuild,含 littlejsengine)。
|
||||
*
|
||||
* 【产出】(cwd 须为 game-runtime/)
|
||||
* · evidence/gate0/gate0.json 门0 全断言机读汇总(G0-1~G0-8 各通过与否 + 采样数据)
|
||||
* 退出码:0=门0 全过;非 0=任一断言失败(不掩盖)。
|
||||
*
|
||||
* 【用法】node test/harness/gate0-engine-takeover.cjs [PORT] [CDP_HTTP]
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const H = require('./browser-evidence.cjs');
|
||||
|
||||
const PORT = parseInt(process.argv[2] || '4321', 10);
|
||||
const CDP_HTTP = process.argv[3] || H.DEFAULT_CDP_HTTP;
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
const SEED = 0x1234abcd; // 固定主种子(与 host.js 默认一致;URL 也显式带)
|
||||
const CANVAS = '#game-engine'; // 【R-C】real 模式引擎自建 mainCanvas 的 id(host.js setupAfterEngine 打)
|
||||
const OUT_DIR = path.resolve('evidence/gate0');
|
||||
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// 门0 阈值(A6 可在 harness 收紧;此处给 A0 判据骨架的保守区间)。
|
||||
const FRAME_WINDOW_MS = 500; // G0-2/G0-4 两次采样间隔
|
||||
const FRAME_DELTA_MIN = 20; // G0-2 帧增量下界(@60fps 理论 ~30;排除「单步假推进 delta=1」与卡死)
|
||||
const FRAME_DELTA_MAX = 45; // G0-2 帧增量上界(排除失控空转)
|
||||
const TIME_FRAME_TOL = 0.05; // G0-3 |time - frame/60| 容忍(秒)
|
||||
|
||||
const result = {
|
||||
meta: { ts: new Date().toISOString(), base: BASE, seed: SEED, engineMode: 'real' },
|
||||
gates: {}, // G0-1..G0-8 → { pass, detail }
|
||||
};
|
||||
const failures = [];
|
||||
function gate(id, cond, detail) {
|
||||
result.gates[id] = { pass: !!cond, detail };
|
||||
if (!cond) failures.push(`${id}: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
|
||||
return !!cond;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// ── 通道 1:?engine=real&mode=evidence(接管断言 + 渲染落 2D)──
|
||||
const urlEvidence = `${BASE}/index.html?seed=${SEED}&engine=real&mode=evidence`;
|
||||
const cdp = await H.connectCdp({ url: urlEvidence, seed: SEED });
|
||||
try {
|
||||
// G0-1:real 通道引擎真起(engineInit().then 跑过 → __hostBootedReal;gameInit 回调触发 → __engineGameInitFired)。
|
||||
// connectCdp 的 waitForBoot 已等到 __hostdev 挂载(=engineInit resolve),这里再显式核对两标记。
|
||||
const boot = await cdp.evaluate(
|
||||
'({ bootedReal: window.__hostBootedReal===true, gameInit: window.__engineGameInitFired===true, hasEngine: !!window.__engine, hasHostdev: !!window.__hostdev, engErr: window.__engineInitError||null })'
|
||||
);
|
||||
gate('G0-1', boot && boot.bootedReal && boot.gameInit && boot.hasEngine, boot);
|
||||
|
||||
// G0-2:引擎内部 RAF 真掌帧(frame 单调推进,且增量落合理区间——排除「假桩单步推进」与失控空转)。
|
||||
// 【P1 修正】不止 t1>t0:加帧增量下界(≥20)+ 上界(≤45),抗「gameUpdate 只跑 1 次」骗过。
|
||||
const s0 = await cdp.evaluate('window.__engine.snapshot()');
|
||||
await delay(FRAME_WINDOW_MS);
|
||||
const s1 = await cdp.evaluate('window.__engine.snapshot()');
|
||||
const dFrame = (s1 && s0) ? (s1.frame - s0.frame) : -1;
|
||||
gate('G0-2', dFrame >= FRAME_DELTA_MIN && dFrame <= FRAME_DELTA_MAX,
|
||||
{ s0, s1, dFrame, expectRange: [FRAME_DELTA_MIN, FRAME_DELTA_MAX] });
|
||||
|
||||
// G0-3:time 与 frame 关系自检(time≈frame/60)。
|
||||
// 【P1 修正】G0-3 是 time=frame++/frameRate 的定义式恒等,**仅作一致性自检,不作掌帧证据**(掌帧以 G0-2 为准)。
|
||||
const tfDelta = (s1 && typeof s1.time === 'number') ? Math.abs(s1.time - s1.frame / 60) : 999;
|
||||
gate('G0-3', tfDelta < TIME_FRAME_TOL, { time: s1 && s1.time, frame: s1 && s1.frame, delta: tfDelta, note: 'consistency-self-check-only' });
|
||||
|
||||
// G0-4:timeReal 纯墙钟推进(P75 埋点候选钟可用)。
|
||||
const dTimeReal = (s1 && s0) ? (s1.timeReal - s0.timeReal) : -1;
|
||||
gate('G0-4', dTimeReal > 0, { t0: s0 && s0.timeReal, t1: s1 && s1.timeReal, dTimeReal });
|
||||
|
||||
// G0-5:双时钟物理隔离——引擎 time(污染钟,LJS.time)与 probe P75 钟(ctx.time=host mockNowMs,经 probe JSONL tMono)
|
||||
// 读自不同变量、不联动。这里核对:__engine.time 来自 LJS.time;probe 记录存在(tMono 走 mockNowMs)。
|
||||
const probe = await cdp.evaluate('window.__probe()');
|
||||
const hasProbeChain = !!(probe && Array.isArray(probe.anchors) && probe.anchors.length >= 6);
|
||||
gate('G0-5', hasProbeChain && (s1 && typeof s1.time === 'number'),
|
||||
{ engineTimeFromLJS: s1 && s1.time, probeAnchors: probe && probe.anchors, note: 'engine time≠probe tMono(mockNowMs), 物理隔离' });
|
||||
|
||||
// G0-7:渲染落在 2D mainContext(非空 + 非全黑/透明)。
|
||||
// 【debugWatermark 修正】host 已 setDebugWatermark(false),右上角无 FPS 文字污染;
|
||||
// 【clearColor 修正】host 已 setCanvasClearColor(BLACK),底为不透明黑(alpha=255)。
|
||||
// 先 spawn 一发 burst(real 分支只推进 host 状态,引擎下一拍 gameRender 落像素),再抓 #game-engine。
|
||||
await cdp.evaluate('window.__hostdev.reset()');
|
||||
await cdp.evaluate(`window.__hostdev.vfxFrame('burst', 1001, 12)`);
|
||||
await delay(120); // 等引擎 RAF 至少渲一拍,把粒子场落到 mainContext
|
||||
const img = await H.captureImageData(cdp, CANVAS); // 返回 { data:Uint8Array(RGBA), width, height }
|
||||
const nonEmpty = !!(img && img.width > 0 && img.height > 0 && img.data && img.data.length > 0);
|
||||
// 非全黑/透明:扫像素,统计「有色(粒子覆盖区,主色 #7fd0ff 系:B 通道高、R 低)」与「右上角白文字」。
|
||||
const px = (img && img.data) ? img.data : null;
|
||||
let coloredHits = 0, whiteTextHits = 0;
|
||||
if (px && nonEmpty) {
|
||||
const W = img.width, Hh = img.height;
|
||||
// 全屏抽样找粒子主色(B 高 R 低 → 青蓝)。
|
||||
for (let i = 0; i + 3 < px.length; i += 4 * 97) { // 步进抽样降成本
|
||||
const r = px[i], g = px[i + 1], b = px[i + 2];
|
||||
if (b > 120 && b > r + 30) coloredHits++;
|
||||
}
|
||||
// 右上角子区(宽 240×高 40,物理像素)扫白色文字像素(debugWatermark 残留检测)。
|
||||
const subW = Math.min(240, W), subH = Math.min(40, Hh);
|
||||
for (let y = 0; y < subH; y++) {
|
||||
for (let x = W - subW; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
if (i + 3 < px.length && px[i] > 200 && px[i + 1] > 200 && px[i + 2] > 200 && px[i + 3] > 200) whiteTextHits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
gate('G0-7', nonEmpty && coloredHits > 0 && whiteTextHits === 0,
|
||||
{ width: img && img.width, height: img && img.height, coloredHits, whiteTextHits, note: 'whiteTextHits>0 ⇒ debugWatermark 未关' });
|
||||
} finally {
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
// ── 通道 2:?engine=real&mode=smoke(受控面零改回归 G0-6)──
|
||||
const urlSmoke = `${BASE}/index.html?seed=${SEED}&engine=real&mode=smoke`;
|
||||
const cdp2 = await H.connectCdp({ url: urlSmoke, seed: SEED });
|
||||
try {
|
||||
const p = await cdp2.evaluate('window.__probe()');
|
||||
const anchorsOk = !!(p && Array.isArray(p.anchors) && p.anchors.length >= 6);
|
||||
gate('G0-6', p && p.initOk === true && p.verify && p.verify.ok === true && Array.isArray(p.uncaught) && p.uncaught.length === 0 && anchorsOk,
|
||||
{ initOk: p && p.initOk, verifyOk: p && p.verify && p.verify.ok, uncaught: p && p.uncaught, anchors: p && p.anchors });
|
||||
} finally {
|
||||
cdp2.close();
|
||||
}
|
||||
|
||||
// G0-8(回滚桩活):本 driver 不重复 integration-run;标记「由 desktop-4 的 integration-run stub 通道核对」。
|
||||
result.gates['G0-8'] = { pass: null, detail: 'deferred: 由 `node test/harness/integration-run.cjs <PORT>`(?engine=stub 通道)核对,须与 α 收口报告等价' };
|
||||
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'gate0.json'), JSON.stringify(result, null, 2));
|
||||
const passed = Object.entries(result.gates).filter(([, v]) => v.pass === true).map(([k]) => k);
|
||||
const failed = failures;
|
||||
console.log('[gate0] gates passed:', passed.join(','));
|
||||
if (failed.length) {
|
||||
console.log('[gate0] GATE0 FAIL:\n - ' + failed.join('\n - '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[gate0] GATE0 PASS (G0-8 deferred to integration-run stub channel)');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[gate0] driver error:', e && e.stack ? e.stack : e);
|
||||
process.exit(2);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user