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

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

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

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

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

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

167 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* host-dev/engine-caps.js — 引擎能力背书工厂EngineCapabilities 三面math/particles/audio.synth
* ownerRunner v2 P1提升自 host.js:197-318 的 makeEngineCaps 闭包)| 消费方host-dev/host.js + games/wanglanmei-ref/src/main.jsreal 通道)
*
* ════════════════════════════════════════════════════════════════════════════
* 【为什么单列本模块P1 提升·搬运非新造)】
* host-dev 与 wanglanmei-ref real 通道都要把「插件能力→引擎」薄包装层接进受控面 EngineCapabilities。
* 原实现是 host.js bootHostDev 内的闭包 makeEngineCapshost.js:197-318已包装 particles/audio.synth/math 三面)。
* P1 把它提升为「吃 {LJS, engineMath, engineMode, recHook} 的纯工厂函数」host-dev / ref 共用同一份——
* 这是「模块化已有件」(搬运,语义零变),非从零造。引擎 import 仍只活在调用方host.js / entry.js / ref entry.js
* 本模块不 import 引擎(引擎名字空间经入参 LJS 注入),故 node 可 import 它(规避 F7顶层 import 引擎在 node 抛 window
*
* 【逐行保语义(回归基线=门0 8/8 + real 门 6/6绝不可破
* - 首行 engineMode!=='real' → return null保 null-engine 回归短路stub/node 不背书引擎host.js:198 原语义)。
* - recHook 默认 no-op原闭包内 `window.__engineCalls.push` 由调用方经 recHook 注入(取证路径才注,渠道产物不注 → 探针无副作用)。
* - mathRaw = engineMath调用方传入 buildEngineMath(LJS) 的结果host.js:204 同口径)。
* - particles/audio.synth 三面包装体逐字搬自 host.js:214-316引擎引用经入参 LJS.*。
*
* 【引擎↔插件 边界(创始人 2026-06-13 拍)】引擎有 ParticleEmitter/zzfxG/zzfxM → 此处薄包装;插件侧删自研 sim/vendored 合成核,一职一路。
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
/**
* 【P1 提升】构造引擎能力背书工厂EngineCapabilities 三面math 含 easing 门面 / particles / audio.synth
* - 仅 real 通道返真能力面stub/node 通道短路返 null保 null-engine 回归战场 + 规避 F7 引擎 import 在 node 抛错)。
* real/stub 都经此工厂buildBundle/createHostDevContext 注入若无条件背书引擎stub 通道也会拿到引擎能力 → 破 null 回归;
* 故首行按 engineMode 短路stub→返 null等价「未注入」触发 plugin.js getEngineShared 的 null 分支:告警一次 + getEngine() 返 null
* - 引擎 import 唯一活点=调用方host.js / entry.js LJS@顶部 import插件零直接 import一律经 ctx.getEngine().*Q4 铁律)。
* - math/easing=纯数学族特例(政策 §1.3):门面逐字节/ULP 等价引擎 LJS.lerp/smoothStep/Easegamefeel 侧保内置实现作 null-engine fallback非有意替代
* easing 门面**只暴 11 条等价曲线**——backInOut/elasticInOut 不入面(引擎 IN_OUT 拼接是另一条曲线,差 6.6%/17%gamefeel 对其保内置=补层)。
*
* @param {Object} opts
* @param {any} opts.LJS 引擎名字空间(含 ParticleEmitter/zzfxG/zzfxM/screenToWorld/vec2/rgb/cameraScale 等)。
* @param {import('../src/core/api.d.ts').EngineMath} opts.engineMath 已构造的引擎数学门面buildEngineMath(LJS) 的结果,调用方传入)。
* @param {string} opts.engineMode 通道模式('real' 才背书引擎;其余短路返 null
* @param {((id:string)=>void)} [opts.recHook] 真接线门②取证钩子:每个引擎能力叶方法被调时记一条 call-ID。
* **默认 no-op**(不传或传 undefined → 探针无副作用、不挂 window.__engineCalls渠道产物物理保证
* @returns {import('../src/core/api.d.ts').EngineCapabilities|null} 引擎能力三面;非 real 返 null。
*/
export function createEngineCaps(opts) {
const LJS = opts.LJS;
const engineMath = opts.engineMath;
const engineMode = opts.engineMode;
// recHook 默认 no-op取证路径由调用方注入id => window.__engineCalls.push(id));渠道产物不注 → 探针零副作用。
const rec = opts.recHook || (() => { /* 默认 no-op探针绝不入渠道产物 */ });
if (engineMode !== 'real') return null; // stub/node不背书引擎保 null-engine 回归F7node 本就 import 不了引擎)
// math 门面buildEngineMath 真活包装引擎,调用方传入)外再裹一层 reclerp/smoothStep 直裹easing 子面逐曲线裹(保全部键与口径不变)。
const mathRaw = engineMath;
const math = {
lerp: (a, b, p) => { rec('math.lerp'); return mathRaw.lerp(a, b, p); },
smoothStep: (p) => { rec('math.smoothStep'); return mathRaw.smoothStep(p); },
easing: Object.fromEntries(Object.keys(mathRaw.easing).map((k) => [k, (t) => { rec('math.easing.' + k); return mathRaw.easing[k](t); }])),
};
return {
// ── math含 easing 门面;外层 rec 记调用,内层 mathRaw 真包装引擎 lerp/smoothStep/Ease──
math,
// ── particles ──薄包装引擎 ParticleEmitterEngineEmitterSpec像素口径→ 引擎世界口径,返封装句柄。
particles: {
/**
* 受控面 spawnEmitterEngineEmitterSpec像素口径→ 引擎 ParticleEmitter世界口径返封装句柄禁泄漏引擎对象
* 边界模型(创始人 2026-06-13 拍):引擎有 ParticleEmitter → 此处薄包装;插件侧删自研 sim 积分循环,一职一路。
* 引擎自渲new ParticleEmitter 经 EngineObject 构造自入 engineObjectsesm.js:3580引擎主循环每帧自调
* update发射+积分esm.js:8348+ renderdrawTile 落 mainContextesm.js:8654/8658→ host 无需手动 add/render。
* @param {import('../src/core/api.d.ts').EngineEmitterSpec} spec 归一化发射参数(像素/中性形态)
* @returns {import('../src/core/api.d.ts').EngineEmitterHandle} 封装句柄(仅 isActive/stop不见引擎对象
*/
spawnEmitter(spec) {
rec('particles.spawnEmitter'); // 真接线门②:记一次「插件经 ctx.getEngine().particles 真调引擎粒子构造」
// 1) 像素↔世界阻抗换算cameraScale 默认 32esm.js:2785host 未 setCameraScale 故用引擎 live binding 默认值I3/OQ-2
const worldPos = LJS.screenToWorld(LJS.vec2(spec.pos.x, spec.pos.y)); // 像素→世界esm.js:5067/screenToWorld
const SCALE = LJS.cameraScale; // 世界单位/像素(用 live binding随引擎相机变
// 2) burst/continuous → emitTime/emitRateI1引擎无「spawn 恰好 N」原语burst 用「1 逻辑帧窗 × count×60 速率」近似 ≈N 颗)。
const emitTime = spec.emitTime != null ? spec.emitTime
: (spec.count != null ? 1 / 60 : 0); // burst=1帧窗(满即自停) / continuous=0(forever)
const emitRate = spec.emitRate != null ? spec.emitRate
: (spec.count != null ? spec.count * 60 : 100); // burst: count×60 颗/秒(该窗内恰发 ≈count 颗)
// 3) speed像素/秒 → 世界/帧@60引擎 speed 单位来源 esm.js:8253无再乘 dt
const engSpeed = (spec.speed != null ? spec.speed : 0) / SCALE / 60;
// 4) damping每帧速度乘子spec 已由插件把 drag/秒折算为每帧乘子,这里直透;缺省 1=无阻)。
const engDamping = spec.damping != null ? spec.damping : 1;
// 5) 颜色:归一化 {r,g,b,a} → 引擎 Coloresm.js:1547 rgb。缺省不透明白→透明保 real 像素门「有色像素命中>0」I2
const cs = spec.colorStart || { r: 1, g: 1, b: 1, a: 1 };
const ce = spec.colorEnd || { r: 1, g: 1, b: 1, a: 0 };
const colorStart = LJS.rgb(cs.r, cs.g, cs.b, cs.a);
const colorEnd = LJS.rgb(ce.r, ce.g, ce.b, ce.a);
// 6) 构造引擎发射器27 参,按 d.ts:3232 构造签名逐位映射)。
const em = new LJS.ParticleEmitter(
worldPos, // pos世界
spec.angle != null ? spec.angle : 0, // angle
(spec.emitSize != null ? spec.emitSize : 0) / SCALE, // emitSize 像素→世界
emitTime, // emitTime0=forever
emitRate, // emitRate
spec.coneAngle != null ? spec.coneAngle : Math.PI, // emitConeAngle半锥角
undefined, // tileInfo: untextured2D fillRect 路)
colorStart, colorStart, colorEnd, colorEnd, // colorStartA/B + colorEndA/BA=B → 固定色不随机)
spec.particleTime != null ? spec.particleTime : 0.5, // particleTime
(spec.sizeStart != null ? spec.sizeStart : 6) / SCALE, // sizeStart 像素→世界
(spec.sizeEnd != null ? spec.sizeEnd : 0) / SCALE, // sizeEnd 像素→世界
engSpeed, // speed世界/帧@60
0, // angleSpeed: 0无玩法语义不旋转
engDamping, // damping
1, // angleDamping: 1无阻
spec.gravityScale != null ? spec.gravityScale : 0, // gravityScalehost 未 setGravity全局 gravity=0 故暂不生效OQ-4 follow-up
Math.PI, // particleConeAngle粒子自转锥留默认
0.1, // fadeRate默认
0, // randomness: 0spec 已显式给参,关引擎默认 .2 额外抖动求确定)
false, // collideTiles: false
spec.additive != null ? spec.additive : false, // additive
true // randomColorLinearA=B 时无影响)
);
// 7) 封装句柄禁泄漏引擎对象isActive/stop 转发stop 幂等销毁)。
return {
isActive() { return em.isActive(); }, // esm.js:8482
stop() { if (!em.destroyed) em.destroy(true); }, // 幂等(已 destroyed 直返,不二次销毁)
};
},
},
// ── audio.synth ──薄包装引擎 zzfxG/zzfxM参数包/曲谱 → PCM 样本(不播放)。
// 边界模型(创始人 2026-06-13 拍):引擎有 zzfxG/zzfxM → 此处薄包装;插件侧删 vendored 合成核,一职一路。
// 引擎 zzfxG **不烤主音量 ×0.3**(样本=s*volumeesm.js:7391×0.3 在引擎 gain 层 soundVolume=.3esm.js:3121/6777
// → 返回的样本是「未乘 0.3 的 PCM」与 api.d.ts EngineAudioSynth 口径一致×0.3 由插件播放层补audio-music/impl.js playChannels
// 合成异常/失败返 null插件侧 → 发声 no-op不回退 vendored
audio: {
synth: {
/**
* ZzFX 单音效合成核:参数包 → PCM 单声道样本数组(未乘 0.3,不播放)。薄包装引擎 LJS.zzfxG。
* @param {number[]} params ZzFX 参数包
* @returns {number[]|null} PCM 单声道样本;合成异常返 null
*/
synthSfx: (params) => {
rec('audio.synth.synthSfx'); // 真接线门②:记一次「插件经 ctx.getEngine().audio.synth 真调引擎 zzfxG」
try {
// 引擎 zzfxG 收 positional 参数(与 vendored 同签名esm.js:7326→ 展开参数包。
return LJS.zzfxG(...params);
} catch (e) {
// 合成异常降级为 null不连坐游戏记 stderr插件侧据 null 走发声 no-op。
console.error('[host][engine-audio] zzfxG 合成异常,返 null' + (e && e.message ? e.message : e));
return null;
}
},
/**
* ZzFXM 曲谱合成核:乐器/模式/序列/BPM → [L,R] 双声道样本(未乘 0.3,不播放)。薄包装引擎 LJS.zzfxM。
* @param {number[][]} instruments 乐器表
* @param {number[][][]} patterns 模式表
* @param {number[]} sequence 序列
* @param {number} [bpm] 节拍(缺省引擎默 125
* @returns {number[][]|null} [L,R] 双声道样本;合成异常返 null
*/
synthSong: (instruments, patterns, sequence, bpm) => {
rec('audio.synth.synthSong'); // 真接线门②:记一次「插件经 ctx.getEngine().audio.synth 真调引擎 zzfxM」
try {
// 引擎 zzfxM 签名与 vendored 逐字一致esm.js:10837 `zzfxM(instruments, patterns, sequence, BPM=125)`)。
return LJS.zzfxM(instruments, patterns, sequence, bpm);
} catch (e) {
console.error('[host][engine-audio] zzfxM 合成异常,返 null' + (e && e.message ? e.message : e));
return null;
}
},
},
},
};
}