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>
167 lines
14 KiB
JavaScript
167 lines
14 KiB
JavaScript
/**
|
||
* host-dev/engine-caps.js — 引擎能力背书工厂(EngineCapabilities 三面:math/particles/audio.synth)
|
||
* owner:Runner v2 P1(提升自 host.js:197-318 的 makeEngineCaps 闭包)| 消费方:host-dev/host.js + games/wanglanmei-ref/src/main.js(real 通道)
|
||
*
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
* 【为什么单列本模块(P1 提升·搬运非新造)】
|
||
* host-dev 与 wanglanmei-ref real 通道都要把「插件能力→引擎」薄包装层接进受控面 EngineCapabilities。
|
||
* 原实现是 host.js bootHostDev 内的闭包 makeEngineCaps(host.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/Ease;gamefeel 侧保内置实现作 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 回归;F7:node 本就 import 不了引擎)
|
||
|
||
// math 门面(buildEngineMath 真活包装引擎,调用方传入)外再裹一层 rec:lerp/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 ──薄包装引擎 ParticleEmitter:EngineEmitterSpec(像素口径)→ 引擎世界口径,返封装句柄。
|
||
particles: {
|
||
/**
|
||
* 受控面 spawnEmitter:EngineEmitterSpec(像素口径)→ 引擎 ParticleEmitter(世界口径),返封装句柄(禁泄漏引擎对象)。
|
||
* 边界模型(创始人 2026-06-13 拍):引擎有 ParticleEmitter → 此处薄包装;插件侧删自研 sim 积分循环,一职一路。
|
||
* 引擎自渲:new ParticleEmitter 经 EngineObject 构造自入 engineObjects(esm.js:3580),引擎主循环每帧自调
|
||
* update(发射+积分,esm.js:8348)+ render(drawTile 落 mainContext,esm.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 默认 32,esm.js:2785;host 未 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/emitRate(I1:引擎无「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} → 引擎 Color(esm.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, // emitTime(0=forever)
|
||
emitRate, // emitRate
|
||
spec.coneAngle != null ? spec.coneAngle : Math.PI, // emitConeAngle(半锥角)
|
||
undefined, // tileInfo: untextured(2D fillRect 路)
|
||
colorStart, colorStart, colorEnd, colorEnd, // colorStartA/B + colorEndA/B(A=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, // gravityScale(host 未 setGravity,全局 gravity=0 故暂不生效,OQ-4 follow-up)
|
||
Math.PI, // particleConeAngle(粒子自转锥,留默认)
|
||
0.1, // fadeRate(默认)
|
||
0, // randomness: 0(spec 已显式给参,关引擎默认 .2 额外抖动求确定)
|
||
false, // collideTiles: false
|
||
spec.additive != null ? spec.additive : false, // additive
|
||
true // randomColorLinear(A=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*volume,esm.js:7391;×0.3 在引擎 gain 层 soundVolume=.3,esm.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;
|
||
}
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|