feat(t1b-beta): A1-A4 引擎能力接线落地——受控面工厂/粒子/音频/手感经 getEngine 薄包装(easing 改判)

承 A0 引擎掌帧地基,按创始人「引擎↔插件边界模型」(引擎有→薄包装·引擎无→自研补层·
同一职不留两条并行路)接线 A1-A4。host.js 注入真 engineFactory,getEngine() 在 host-dev 真活。

A1 受控面+引擎工厂地基+easing 门面:
- host.js makeEngineCaps 一处构造 EngineCapabilities{particles,audio.synth,math};
- 新 host-dev/engine-math.js 包装引擎 lerp/smoothStep + Ease 曲线(11 条等价曲线门面);
- api.d.ts additive 扩 EngineEasing + EngineMath.easing?(不破已冻 lerp/smoothStep)+ 块级降级措辞校准为 null no-op;
- 受控面 random/time 保确定性(引擎粒子用全局 rand 不可种子对齐→random 留插件自管,真缺件)。

A2 particles 薄包装+删 sim:
- EngineEmitterSpec additive 扩 10 可选字段(count/coneAngle/speed/gravityScale/sizeStart-End/colorStart-End/additive);
- spawnEmitter 改走 getEngine().particles 映射引擎 ParticleEmitter,删自研逐粒子积分 sim;null-engine→no-op 句柄;
- juice 全段(hitStop/屏震/闪白)补层保留;测试转向映射纯单测+真渲出现(逐像素确定性随 sim 退役,§1.4 记账)。

A3 audio 包装+删 vendored+补 ×0.3:
- 删 vendor/zzfx.js+zzfxm.js;audio.synth 包装引擎 zzfxG/zzfxM;
- 播放层补 ×0.3 主音量(引擎样本不烤 0.3,gain 层施)+ SAMPLE_RATE=44100 本地常量;
- impl.js 走 getEngine().audio.synth,null-engine→合成 no-op(不回退 vendored)。

A4 gamefeel easing 改判=门面包装(对抗门源码证伪纠偏):
- easing 改走 getEngine().math.easing 门面(quadIn→Ease.POWER(2) 等),内置降名 builtinEasing 作 null-engine fallback(纯数学族特例);
- backInOut/elasticInOut 引擎 IN_OUT 拼接差 6.6%/17%(另一条曲线)→门面刻意不暴,这二者恒走内置=真缺件补层;
- collision/physics-lite/palette-post 加边界裁定注释(真缺件补层,源码精校:引擎仅 isOverlapping/isIntersecting 返 boolean,无 Manifold/任意几何/RayHit)。

对抗门两逮(均前置于实现·零返工):
- A4 easing 误判 fatal:前轮按小写 quadIn grep 假阴性误判「引擎无 easing」;引擎实有 Ease(esm.js:15118)→创始人拍本波包装;
- collision 注释 under-claim:已精校为源码准措辞。新红线 §1.5:引擎能力普查禁按预期名 grep,须读 export 块/.d.ts。

本机门全绿(亲验):
- node --test 168/168 绿;all-plugins+host-bundle 双 esbuild rc=0(35594B/209516B);
- 真接线门①引擎真入产物 littlejsengine 163641B;门③负扫=0 插件直 import+vendor 已删+0 自研 sim+0 假注释;Q4 引擎 import 仅 entry.js+host.js。

A6(mini-desktop real 像素门 + 真接线门② runtime probe call-ID)待主会话驱动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-13 11:48:28 +00:00
parent 471e542473
commit e591e9b705
26 changed files with 2827 additions and 857 deletions

View File

@ -0,0 +1,311 @@
# A1–A6 纠正后接线政策(引擎↔插件 边界·一职一路·终值裁定)
> **定位**:Phase A 引擎真接线的**唯一政策据**。覆盖旧稿(本文件 06-13 08:37 版「双码路·降级实现体不删」模型,以及 `2026-06-13-A1..A4-edit-plan.md` 四份旧错模型草稿)——**那些以"保留完整 sim 作降级实现体、双码路、null-engine 不 no-op"为前提的裁定一律作废**。
> **裁定权属**:创始人 2026-06-13「引擎↔插件 边界模型」拍板(不可违)。本政策据其落地、不改裁。
> **源码已亲验**(policy 官 opus 核,复审/实现须再核,行号标注处可逐条 grep/sed 复验):见 §0.2 事实台账。
>
> **本政策与旧稿的根本分歧(一句话)**:旧稿把"引擎可用时的旧实现"留作"引擎不可用时的降级实现体",是**同一职两条并行路**;创始人裁定**同一件事不留两条并行路**——引擎覆盖的能力,null-engine 一侧只许**优雅 no-op**,删完整 sim(留=有意替代)。
>
> **本轮 scoped 修订(2026-06-13·easing 改判)**:上轮工作流 A1/A2/A3 复审 PASS、政策主体经源码复审证实正确;**仅 A4 的 easing 裁定被推翻**——前轮按小写 `quadIn` grep 引擎漏判"引擎无 easing"(grep 假阴性),实则引擎有完整 `Ease` 曲线族(§0.2)。创始人改判 **easing 本波包装**(纯数学族特例:门面 `getEngine().math.easing` 包装引擎 `Ease`,内置仅留 null-engine fallback)。本轮**只改 easing 相关裁定 + 新增 §1.5 红线**;其余裁定(particles/audio 删 sim/vendored、受控面、collision/physics/palette 补层、null no-op、RNG、门③负扫主体)**一字未改**。改判落点:§0.1(A1/A4/门③行)·§0.2(新增 3 易事实行)·§1.3(特例含 easing)·§1.4-1.5(分歧表+新红线)·§2.2/§2.3(透传面+移出补层)·§3(null 语义拆 easing 出补层)·§4.0-4.1/§4.4(lane 拓扑+A1 加 easing 面+A4 改面)·§5.1(fallback 测保留)·§6(N12 重述+新增 N12b)·§7(item 5 已解)·§8(一页纸)。
---
## 0. 结论速览
### 0.1 六条裁定速览(先行)
| # | 议题 | 一句话裁定 |
|---|---|---|
| **总则** | 一职一路 | 同一件事只有一条权威路:引擎有→插件薄包装(经 `ctx.getEngine()`,**删平行自研**);引擎无→插件自研补层(合法保留,全通道可跑可确定性测)。**判定锚 = "引擎是否真有这件事",不是"插件历史上有没有写过"。** |
| **A1** | 工厂骨架 + math(含 easing 门面) | `host.js` 建**真 `engineFactory`**(real 通道注入;stub 通道**仍不注入**=保 null-engine 回归战场),一处构造 `{particles, audio:{synth}, math}` 三能力。A1 先落**骨架 + `math`**:`math.lerp/smoothStep` 包装引擎 `lerp/smoothStep`,**并加 `math.easing` 门面包装引擎 `Ease`**(`Ease.POWER(2/3)`/`Ease.BACK`/`Ease.ELASTIC`/`Ease.OUT/IN_OUT` 等曲线族,`esm.js:15118`/`d.ts:5514`)——纯数学族,最简、无状态。A2/A3 在同一工厂内填 `particles`/`audio` 体 → **A1→A2/A3 串行(同改 host.js 工厂 + api.d.ts)**。 |
| **A2** | particles 包装 | `EngineEmitterSpec` **additive 扩字段**(仅加可选字段承全发射参数,**不破已冻 6 参消费者**)+ `spawnEmitter` 薄包装引擎 `ParticleEmitter`;**删 sim 积分循环**(`spawnOne`/`integrateParticles`/`stepEmitter`/`refreshEnv` 及其环境场)。null-engine→`spawnEmitter` 返**惰性 no-op 句柄**。**juice(hitStop/屏震/闪白/脉冲)非引擎覆盖,是补层,全留全测。** |
| **A3** | audio 包装 | **删 vendored** `vendor/zzfx.js`+`vendor/zzfxm.js`;`audio.synth.synthSfx/synthSong` 包装引擎 `zzfxG/zzfxM`;播放层(**真缺件自研**:把 PCM 塞进受控 AudioContext + **补 ×0.3 主音量**,因引擎 zzfxG 不烤 ×0.3)保留。null-engine(无 `getAudioContext`/无 `getEngine`)→ 发声 no-op。**validateSong/resolveVoiceStates/预设 schema = 纯逻辑补层,全留全测。** |
| **A4** | gamefeel 消费 math(含 easing) | gamefeel **消费 `getEngine().math`**:lerp/smoothStep 用点走门面(不得平行自研);其 `easing`(quad/cubic/elastic/back)引擎**有**(`Ease` 曲线族,见 §0.2 易事实)→ **改经 `getEngine().math.easing` 门面包装引擎 `Ease`,内置实现仅留作 null-engine fallback**(纯数学族特例,§1.3)。InputBuffer/CoyoteTimer/ComboWindow 经 `getInput`/`time` 补层全留。**A4 仅消费 A1 已落的 math/easing 门面,不改 host.js/api.d.ts → 与 A2/A3 并行。** |
| **门③** | 负向扫描 | 本机 grep/ls 逐条断言(§6):插件零 `littlejsengine` 直接 import;`vendor/zzfx.js`+`vendor/zzfxm.js` 已删;particles sim 函数已删;插件 impl 内**对"引擎已提供能力"零平行自研裸实现**。**补层件(juice/collision/physics/palette/输入时间窗/audio 播放层 ×0.3)不在应删清单;easing 改为"门面包装引擎 `Ease`,内置仅留 null-engine fallback"——其内置实现作 fallback 保留(纯数学族特例,非有意替代),但 gamefeel 须经 `getEngine().math.easing` 取曲线(不得绕门面平行自研直用内置)。** |
### 0.2 源码事实台账(已亲验·与 prompt 陈述一致,差异已标)
| 事实 | 验证点 | 结论 |
|---|---|---|
| 工厂注入式·lazy | `plugin.js:381-401`(收 `o.engineFactory`)·`plugin.js:438-456`(`getEngineShared` lazy 单例)·`plugin.js:489-491`(`getEngine()` 转发) | ✅ 与陈述一致。`getEngine()` 现返 null(host.js 未注真工厂)。 |
| `EngineCapabilities` 三能力一处构造 | `api.d.ts:137-144` = `{particles, audio:{synth}, math}` | ✅ A1/A2/A3 共改 host.js 工厂 + api.d.ts(**非文件互斥**)。 |
| host.js A0 已掌帧 | `host.js:44`(`import * as LJS`)·`:756`(`engineInit` 五回调)·`:743-749`(setGLEnable(false)/水印/clearColor/fixedSize)·`:758`(绘制面=`LJS.mainContext`)·`:678-688`(`window.__engine` 钟)·`engineMode='real'` 缺省(`:202`) | ✅ A0 真接 engineInit 五回调掌帧。**host.js `buildBundle`(`:215-221`) 现未传 `engineFactory`** → getEngine() 两通道皆 null(A1 须给 real 注入)。 |
| 引擎 `ParticleEmitter` 全参 | `littlejs.esm.js:8227`(class)·构造收 angle/emitSize/emitTime/emitRate/emitConeAngle/colorStartA/B/colorEndA/B/particleTime/sizeStart/sizeEnd/.../gravityScale/additive 等 ~27 参 | ✅ 够渲全配置。冻结 `EngineEmitterSpec`(`api.d.ts:84-97`)仅暴 6 参(pos/angle/emitSize/emitTime/emitRate/particleTime)。 |
| **引擎粒子随机=全局 `rand()`,非 per-emitter 可种子** | `RandomGenerator`(`littlejs.esm.js:1789`,xorshift 可种子)存在,但 `ParticleEmitter` 内部抖动调引擎全局 `rand()`(默认 `randomness=.2`),**不吃插件 `ctx.random` 子流** | ⚠️ **新增关键事实**(prompt 未明列):引擎渲染的粒子**从插件视角不可种子复现** → 直接否决"引擎码路逐像素确定"(见 §5.3 RNG 裁定)。 |
| vendored zzfx 烤 ×0.3 进样本 | `vendor/zzfx.js:159` `volume *= ZZFX_MASTER_VOLUME`(=`:54` 0.3) | ✅ 与陈述一致。 |
| 引擎 zzfxG **不**烤 ×0.3 | `littlejs.esm.js:7326`(`function zzfxG`)函数体内**无** `soundVolume`/`*.3`(grep 空);×0.3 在 gain 层:`:3121` `soundVolume=.3` + `:6777` `audioMasterGain.gain.value=soundVolume` | ✅ 与陈述一致。**故包装引擎 zzfxG 后,×0.3 须在播放层补**(A3)。 |
| 引擎 `zzfxM`/`lerp`/`smoothStep` 在 | `:10837`(zzfxM)·`:1443`(lerp `(valueA,valueB,percent)`)·`:1500`(smoothStep `percent*percent*(3-2*percent)`) | ✅ 口径与 `api.d.ts:124-129` 门面一致。 |
| Q4 confinement 现状 | `grep -rn littlejsengine src/` → **仅 host.js/entry.js(集成段)+ 注释**;**插件 impl 零直接 import**;`src/plugins/*` 命中全为 PLUGIN.md/api.d.ts 注释 | ✅ 成立,须保持(§6 门③ 第1条)。 |
| 插件现零 `getEngine()` 调用 | `grep -rn 'getEngine\b' src/plugins/` → **空** | ✅ **重要**:今日三件包装插件**都还没接引擎**,走的是各自 α 自研体。A2/A3/A4 才接。 |
| **gamefeel 现零 math 消费** | `gamefeel/impl.js` 有自有 `easing`(`:139`),**无任何 `lerp`/`smoothStep`/`getEngine` 调用** | ⚠️ A4 现状:当前 impl 走自有 `easing` 体(`:59-145`),无 lerp/smoothStep 用点;A4 须把 `easing` 改经门面(见下行)。 |
| **引擎有完整 `Ease` 曲线族(推翻"引擎无 easing"旧判)** | `esm.js:15118` `const Ease`(`LINEAR`/`POWER(n)→x=>x**n`/`SINE`/`CIRC`/`EXPO`/`BACK`/`ELASTIC`/`SPRING`/`BOUNCE` 基曲线 + `:15212` `IN`/`:15221` `OUT`/`:15229` `IN_OUT` 修饰器)·export 块 `:16681` `Ease,`·`d.ts:5514` `namespace Ease`(`:5516 POWER`/`:5520 BACK`/`:5521 ELASTIC`)+ `:5369 class Tween`/`:5414 setEase`。gamefeel `quadIn=Ease.POWER(2)`/`cubicIn=POWER(3)`/`backIn=BACK`/`elasticIn=ELASTIC` 语义对应。 | ✅ **改判核心**:引擎**有** easing → gamefeel 自研 easing=**有意替代**,须改经 `getEngine().math.easing` 门面包装引擎 `Ease`(内置留作 null-engine fallback,纯数学族特例 §1.3)。**前轮误判"引擎无 easing/easing=补层"根因=grep 假阴性**(按小写 `quadIn` 搜,漏引擎大写 `POWER`/`BACK`/`ELASTIC`)→ 见 §1.5 新红线。 |
| **easing 字节恒等性实测(本机 node 亲验,差异已标)** | 内联引擎 `Ease` 曲线 vs gamefeel 公式,x∈[0,1] 扫 2000 点:`quadIn===Ease.POWER(2)`、`quadOut===OUT(POWER(2))` **逐字节恒等**;`cubicIn/cubicOut/backIn/elasticIn` **数学等价但 NOT 逐字节恒等**(浮点求值顺序差,逐点差 <1e-9,ULP 级,如 backIn@0.37 差 1.4e-17) | ⚠️ **诚实记账**:纯数学族特例(§1.3)的资格锚是"公式恒等、无状态、无语义冲突",**不强求逐字节恒等**。easing 输出仅供 juice 计时/脉冲的视觉插值,从不被字节级复现门所校(same-pixel 哈希已于 §5.3 退役)→ ULP 级差无害。故 easing 按特例**门面包装 + 内置 fallback**成立;**但 fallback 内置实现的存在理由是"引擎缺位时等价可用",不得据此倒推"必须逐字节对齐引擎"**(cubic/back/elastic 本就只 ≈ 引擎)。 |
---
## 1. 总则:一职一路(同一件事不留两条并行路)
### 1.1 判定唯一锚
> **"引擎是否真有这件事" = 唯一判定锚。** 不看"插件历史上写没写过"。
```
┌─ 引擎真有此能力(littlejsengine dist 内有对应类/函数) ─→ 【包装】
某能力 ─判定─┤ 插件经 ctx.getEngine().*,删任何平行自研裸实现
└─ 引擎真无此能力 ───────────────────────────────────→ 【补层】
插件自研(合法,=保留的历史自研),全通道跑+确定性测
```
### 1.2 三条铁律(违反即"有意替代"=门③命中)
1. **引擎有 → 薄包装**:经 `ctx.getEngine()` 够引擎;**禁在插件内并行保留一份做同样事的自研实现**(哪怕作"降级/回退"也禁——这正是创始人否决的"双码路")。
2. **引擎无 → 补层**:插件自研合法;全通道(host-dev/node + 集成段)可跑、可确定性单测。
3. **null-engine(host-dev/node 无引擎)语义**:引擎覆盖的能力 → **优雅 no-op**(不再留完整 sim 降级);仅**真缺件**(补层)→ 自研全通道跑。
### 1.3 math 纯数学特例(单列·含 easing,创始人 2026-06-13 拍)
> 纯通用数学族(`lerp`/`smoothStep`/`clamp01` **及缓动曲线 `easing`**)引擎与插件**公式恒等、无状态、无语义冲突**——此族是**唯一可保"插件内置作 null-engine fallback"的类别**(非有意替代),但**门面仍是 `getEngine().math`(含 `math.easing`)**。
>
> **特例资格锚 = 三性:公式恒等(数学上同一曲线)+ 无状态 + 无语义冲突**,**不要求逐字节恒等**(实测 quad 逐字节恒等,cubic/back/elastic 数学等价但浮点求值顺序致 ULP 级差 <1e-9,§0.2)——easing 仅供视觉插值、无字节级复现门校(same-pixel 已退役 §5.3),ULP 差无害,故 easing 满足三性、吃此特例。
>
> **边界(与 particles/audio 严格区分)**:此特例**只覆盖纯数学族(lerp/smoothStep/easing 这类无状态恒等曲线)**。`particles`/`audio` 有状态、有语义分歧(引擎粒子用全局 rand 不可种子对齐 §5.3;zzfxG 不烤 ×0.3 §0.2)→ **不吃此特例,null-engine 一侧严格 no-op,禁留 fallback sim/vendored**。
>
> **改判留痕**:旧稿此处曾判"gamefeel `easing` 引擎没有→归补层全留"——该判**作废**(根因 grep 假阴性,引擎实有 `Ease` 曲线族,§0.2 + §1.5)。easing 现按本特例**门面包装引擎 `Ease` + 内置 fallback**。
### 1.4 与旧稿的分歧点(显式作废)
| 旧稿(作废) | 本政策(生效·创始人裁) |
|---|---|
| null-engine = 保留完整旧实现作"降级实现体",双码路 | null-engine = 引擎覆盖能力**优雅 no-op**,删 sim |
| 门③只删"引擎码路内平行重造那份",降级实现体不删 | 门③删**一切对引擎已提供能力的平行自研裸实现**(sim 即在删表) |
| 逐像素确定性归 stub 通道(靠 null-engine 走 sim 产确定像素)| sim 删除 → **stub 通道 same-pixel 回归随 sim 退役**;真渲证据归 mini-desktop real 像素门(§5.1) |
| **gamefeel `easing` 引擎没有 → 归补层全留**(前轮误判) | **引擎实有 `Ease` 曲线族**(§0.2)→ easing 改**门面包装 + 内置 fallback**(纯数学族特例 §1.3);根因 grep 假阴性,见 §1.5 |
### 1.5 红线:引擎能力普查禁按预期名 grep(前轮 easing 误判教训)
> **红线**:做"引擎是否真有此能力"普查时,**禁止按"插件里的预期名/小写驼峰名"去 grep 引擎源码**(如搜 `quadIn`/`backIn` 判定引擎有无 easing)。**必须读引擎的 export 块 / `.d.ts` 实际导出清单**,按引擎自己的命名(`Ease.POWER`/`Ease.BACK`/`Ease.ELASTIC` + `OUT/IN_OUT` 修饰器)逐条核对语义对应。
>
> **教训实证**:前轮把 gamefeel `easing` 误判为"引擎无→补层",根因即按小写 `quadIn` grep 引擎,漏了引擎大写的 `POWER(2)`/`BACK`/`ELASTIC`(命名空间 `Ease`,`esm.js:15118`/`d.ts:5514`)。该误判一路传导到 §0.1/§1.3/§2.3/§4.4/§5/§6 多处"easing=补层"裁定,险些把"有意替代"放行。**判定锚是"引擎是否真有这件事"(§1.1),而"真有"须以引擎导出清单为准,不以插件命名反查为准。**
>
> 推论:grep 假阴性是**双向**风险——既可能漏判"引擎有"(本次 easing),也可能漏判"插件留了平行自研"(门③负扫 §6)。故 §6 负扫的"零平行自研"断言同样不可只按预期函数名搜,命中为空须辅以"读 impl 实际导出/调用面"人核。
---
## 2. 引擎有/无 逐能力清单(受控面 + 透传面)
> 口径:**包装**=引擎有,插件经受控面/透传面够引擎,删平行自研;**补层**=引擎无,插件自研全留。
### 2.1 受控面(`PluginContext` 6 项)—— 引擎背书后皆为包装
| 受控面能力 | 引擎背书源 | 裁定 | null-engine 语义 |
|---|---|---|---|
| `random`(RandomSource) | 引擎 `RandomGenerator`(xorshift 可种子,`:1789`) | **包装**(β 换引擎背书,契约形状不变) | host-dev `SeededRandom`(mulberry32) 作等价实现(**注**:跨实现同 seed 序列**不同**,见 §5.3) |
| `time`(TimeSource) | 引擎主循环钟(`LJS.time`/`timeReal`,`:678-688` 已暴露) | **包装**(A1 后透传引擎钟;A0 仍 host 步进钟) | host-dev `HostDevTime`(步进钟)等价 |
| `getInput`(InputSource) | 引擎 DOM/输入事件 → 归一化 InputEvent | **包装**(集成段换"引擎事件→归一化"包装) | host-dev `HostDevInputBridge`(真 DOM 桥,host.js:395-406 已接) |
| `getContext2d` | 引擎 `mainContext`(`:758` 已接) | **包装**(A0 已落,绘制面=引擎 mainContext) | host-dev 返注入 ctx2d / node 返 null(插件容忍 null) |
| `getAudioContext` | 真 Web Audio AudioContext(host.js:172-180 工厂) | **包装** | 无工厂返 null + 告警一次(发声 no-op) |
| `getEngine`(透传面) | A1 真工厂(host.js 待建) | **包装通道本体** | 无工厂返 null + 告警一次(插件容错) |
### 2.2 透传面 `getEngine()` 3 组(白名单·`api.d.ts:137-144`)—— 包装
| 透传能力 | 引擎导出 | 裁定 | null-engine 语义 |
|---|---|---|---|
| `particles.spawnEmitter` | `ParticleEmitter`(`:8227`) | **包装**(A2);删 sim 积分循环 | **no-op 句柄**(`spawnEmitter` 返 `{isActive:()=>false, stop:()=>{}}`,不喷粒子) |
| `audio.synth.synthSfx` | `zzfxG`(`:7326`) | **包装**(A3);删 vendored `zzfxG` | 返 null(插件发声 no-op,**不回退 vendored**——vendored 已删) |
| `audio.synth.synthSong` | `zzfxM`(`:10837`) | **包装**(A3);删 vendored `zzfxM` | 返 null(同上 no-op) |
| `math.lerp` / `math.smoothStep` | `lerp`(`:1443`)/`smoothStep`(`:1500`) | **包装**(A1 工厂落实现;A4 消费) | 插件内置纯函数等价(§1.3 特例,**纯数学族是唯一保留内置 fallback 的一类**) |
| `math.easing`(quad/cubic/elastic/back 全 family) | `Ease` 曲线族(`POWER`/`BACK`/`ELASTIC`/`OUT`/`IN_OUT`,`esm.js:15118`/`d.ts:5514`) | **包装**(A1 工厂落 easing 门面;A4 gamefeel 消费)——**改判**:引擎有,删 gamefeel 平行自研直用 | 插件内置 easing 纯函数 fallback(§1.3 特例;quad 字节恒等,cubic/back/elastic ULP 级等价无害,§0.2) |
### 2.3 补层(引擎无此能力 / 或有意非引擎闭环)—— 全留全测,**不进门③应删清单**
| 补层能力 | 归属插件 | 为何是补层(引擎无/有意非引擎) |
|---|---|---|
| **juice:hitStop / 屏震 / 闪白 / 弹性脉冲** | particles-juice | 引擎无"受控面输出偏移量+游戏层 render.js 自行 translate 应用"的闭环;屏震引擎化=render.js〔agent 生成域〕切引擎相机,**非插件经通道**(`api.d.ts:60-66` P2 裁定)。juice 计时/衰减纯逻辑全留。 |
| **audio 播放层 + ×0.3 主音量** | audio-music | 引擎 zzfxG **不烤 ×0.3**(事实台账)→ 包装引擎合成核后,"把 PCM 塞进受控 AudioContext + 补 ×0.3 + gain/loop/stop"是**真缺件自研**(`impl.js:233-276 playChannels`),全留。 |
| **validateSong / resolveVoiceStates / 情绪分层 / 预设 schema** | audio-music | 纯数据逻辑,引擎无对应 → 补层全留(`impl.js:80-215`)。 |
| ~~**easing 标准库**(quad/cubic/elastic/back 全 family)~~ **【改判·已移出补层】** | gamefeel | ❌ 旧判"引擎无 quad/cubic/elastic/back→补层"作废:**引擎实有 `Ease` 曲线族**(`POWER`/`BACK`/`ELASTIC`/`OUT/IN_OUT`,§0.2)→ easing 改归**纯数学族特例包装**(§1.3/§2.2):经 `getEngine().math.easing` 门面包装引擎 `Ease`,`impl.js:57-145` 内置实现**仅留作 null-engine fallback**(非补层、非有意替代)。 |
| **InputBuffer / CoyoteTimer / ComboWindow** | gamefeel | 引擎无"输入缓冲窗/coyote 宽限/连击窗"时间窗记账 → 补层全留(经 `getInput`/`time`,`impl.js:155-366`)。 |
| **collision 全套 / physics-lite 抛体弹簧 / palette-post 后处理** | 各插件 | 引擎无对应受控面能力 → 补层(本政策不约束,列此仅作边界提醒)。 |
| **render() 的 `g.arc/g.fill/g.fillRect`** | particles-juice | §7 未裁渲染入通道;但见 §2.4——sim 删后渲谁,需明确。 |
### 2.4 render() 的归属(sim 删除后的明确裁定)
> **裁定**:sim 删除后,particles 插件的 `render()`(`impl.js:441-463` 画本地粒子)**随本地粒子池一并退役**——引擎码路下粒子真身在引擎对象列表,由引擎 `gameRender → host.js:renderParticles`(real 通道)自落 `mainContext`,**插件不再自绘引擎粒子**。
>
> 保留与否细分:
> - **闪白叠加层**(`render()` 内 flash 全屏白,`impl.js:452-460`)属 **juice 补层**的输出,**保留**(juice 不进引擎,其叠加层仍由插件/游戏层画)。
> - **粒子逐颗 arc/fill**(`impl.js:444-451`)= 画"本地 sim 粒子",**随 sim 删除而删**(引擎粒子引擎自渲)。
> - 故 `render()` 不整体删,而是**瘦身为"只画 juice 叠加层(闪白等)"**;粒子绘制段删。host.js:renderParticles 现 `g.fillStyle='#7fd0ff'; pj.render(g)`(`:353-354`)的语义随之变为"画 juice 叠加层"——**A2 须同步核 host.js renderParticles 注释/语义**(host.js 属集成段,A2 可改)。
---
## 3. null-engine no-op 语义(逐能力定:no-op 还是补层等价实现)
> 总则:**引擎覆盖能力 → no-op;真缺件补层 → 全通道等价实现。** 逐条钉死,杜绝"借 null-engine 之名留 sim"。
| 能力 | 引擎覆盖? | null-engine 语义(逐条钉死) |
|---|---|---|
| `particles.spawnEmitter` | 是 | **no-op 句柄**:返 `{ isActive:()=>false, stop:()=>{} }`;不喷粒子、不积分、不渲。`particleCount()`/`emitterCount()` 恒返 0(语义=本地池已废)。**禁回退 sim。** |
| `audio.synth.synthSfx/synthSong` | 是 | **返 null** → 插件发声 no-op(`playSfx`/`play`/`loop` 静默 + 告警一次)。**禁回退 vendored**(已删)。 |
| `math.lerp/smoothStep` | 是(纯数学特例) | **插件内置纯函数等价**(公式与引擎恒等,非有意替代;§1.3)。纯数学族是**唯一保留内置 fallback**的一类。 |
| `math.easing`(quad/cubic/elastic/back) | 是(纯数学特例,引擎 `Ease`) | **插件内置 easing 纯函数 fallback**(§1.3 特例:门面 `getEngine().math.easing`,null→退内置)。**改判**:旧判此族归"否(补层)→全通道等价"作废;现归纯数学族特例(与 lerp/smoothStep 同档)。 |
| juice(hitStop/屏震/闪白/脉冲) | 否(补层) | **全通道等价实现**(不依赖引擎,host-dev/node/集成段行为一致)。计时/衰减/偏移输出照常。 |
| audio 播放层 ×0.3 / gain / loop | 否(补层) | 依赖 `getAudioContext`(受控面,非 getEngine):有上下文→真播+×0.3;无→发声 no-op(受控面既有语义,`impl.js:315-326`)。 |
| validateSong/resolveVoices/预设 | 否(纯逻辑补层) | **恒可用**(与引擎/音频上下文无关,全通道跑)。 |
| InputBuffer/Coyote/Combo | 否(补层) | **全通道等价实现**(经 `getInput`/`time` 受控面,引擎背书与否不改逻辑)。**注**:easing 已移出本行(改归纯数学族特例,见上 `math.easing` 行)。 |
**关键区分**:`getEngine()`==null 触发"引擎覆盖能力 no-op";`getAudioContext()`==null 触发"audio 播放 no-op"——**两者是不同受控面的不同 null 语义**,不可混。audio 插件 init 同时持有两者:synth 经 `getEngine().audio.synth`(null→合成 no-op),播放经 `getAudioContext()`(null→发声 no-op)。
---
## 4. 4 lane 文件归属与落点(串并行依赖)
### 4.0 依赖拓扑(关键:工厂一处构造 → A2/A3 共改 host.js+api.d.ts → 串行)
```mermaid
graph LR
A1["A1 工厂骨架+math(含 easing 门面)<br/>host.js 建 engineFactory(real)<br/>math.lerp/smoothStep+math.easing 包装引擎 Ease<br/>api.d.ts EngineMath 需加 easing 面<br/>plugin.js 已就绪(无需改)"]
A2["A2 particles 体<br/>host.js 工厂填 particles<br/>api.d.ts 扩 EngineEmitterSpec<br/>impl.js 删 sim+薄包装"]
A3["A3 audio 体<br/>host.js 工厂填 audio.synth<br/>impl.js 删 vendored+薄包装+播放层补×0.3"]
A4["A4 gamefeel<br/>impl.js easing 改经 math.easing 门面(内置留 fallback)<br/>+消费 lerp/smoothStep 用点<br/>(不改 host.js/api.d.ts)"]
A1 -->|串行: 同改 host.js 工厂骨架| A2
A1 -->|串行: 同改 host.js 工厂骨架| A3
A1 -.->|A4 消费 math/easing 门面, A1 落后即可| A4
A2 -.并行(文件不互斥).- A3
```
> **串行根因**:`engineFactory` 在 **host.js 一处**构造 `{particles, audio:{synth}, math}`。A1 建骨架(含 math 实现),A2 在同一工厂对象内填 `particles` 体、A3 填 `audio.synth` 体 → **A1 必须先于 A2/A3**;A2 与 A3 改的是工厂内不同键 + 不同插件 impl,**文件级可并行但建议串行落**(避免 host.js 工厂函数合并冲突)。
> **A4 可并行**:gamefeel 仅消费 `getEngine().math`(A1 落地后即存在),**不碰 host.js 工厂、不碰 api.d.ts** → 与 A2/A3 全程并行。
### 4.1 A1 — 工厂骨架 + math(含 easing 门面)(先行,串行头)
| 文件 | 改动 | 类别 |
|---|---|---|
| `host-dev/host.js` | `buildBundle`(`:215-221`)/`setupAfterEngine` 内**新建 `makeEngineCaps()`**:real 通道把它作 `engineFactory` 传入 `createHostDevContext`;**stub 通道仍不传**(保 null-engine 回归)。骨架构造 `{ particles: <A2填>, audio:{synth:<A3填>}, math: <A1实现 lerp/smoothStep + easing 门面,包装 LJS.lerp/LJS.smoothStep + LJS.Ease> }`。 | 包装·工厂落点(引擎 import 唯一活在此,Q4 守住) |
| `src/core/api.d.ts` | `EngineMath`(`:124-129`)现仅 `lerp`/`smoothStep` → **A1 须 additive 加 `easing` 成员**(声明 easing 门面形状,建议与 gamefeel `EasingLib` 同名键 `quadIn/quadOut/.../backInOut`,或暴 `power(n)/back/elastic` 基曲线 + `out/inOut` 修饰器——A1 据"gamefeel 消费面最小化"二选一定,见下实现细节)。**铁律:additive 加成员,不动已冻 `lerp`/`smoothStep`**。 | 契约 additive 扩展(加 easing 面) |
| `src/core/plugin.js` | **无需改**(`:381-456` 工厂收纳 + lazy 单例 + 转发已就绪)。 | 已就绪 |
> **A1 的 math/easing 实现细节**:
> - `math.lerp = (a,b,p) => LJS.lerp(a,b,p)`;`math.smoothStep = (p) => LJS.smoothStep(p)`(口径已对齐,事实台账)。
> - `math.easing`:门面包装引擎 `LJS.Ease`。映射对照(已 node 核语义对应,§0.2):`quadIn=Ease.POWER(2)`、`quadOut=Ease.OUT(Ease.POWER(2))`、`cubicIn=Ease.POWER(3)`、`cubicOut=Ease.OUT(Ease.POWER(3))`、`backIn=Ease.BACK`、`backOut=Ease.OUT(Ease.BACK)`、`elasticIn=Ease.ELASTIC`、`elasticOut=Ease.OUT(Ease.ELASTIC)`、inOut 用 `Ease.IN_OUT(...)`。
> - **字节恒等性诚实记账(§0.2)**:仅 `quadIn/quadOut` 与引擎逐字节恒等;`cubic/back/elastic` 数学等价但 ULP 级差 <1e-9(浮点求值顺序异)。**这不破纯数学族特例资格**(资格锚=公式恒等+无状态+无语义冲突,非字节恒等;easing 仅视觉插值无字节门校)。门面与内置 fallback **二者皆可用、皆正确**,调用方不依赖二者逐字节一致。
> - **A1 不需任何插件消费即可独立验**(工厂返回的 math/easing 可单测:断 `math.easing.quadIn(0.5)` 等于 `Ease.POWER(2)(0.5)`,断 null 工厂时 gamefeel 退内置 easing)。
### 4.2 A2 — particles 包装 + 删 sim(串行,依赖 A1 工厂骨架)
| 文件 | 改动 | 类别 |
|---|---|---|
| `host-dev/host.js` | `makeEngineCaps()` 内**填 `particles` 体**:`spawnEmitter(spec)` → `new LJS.ParticleEmitter(...映射 spec)`,返句柄 `{isActive, stop}` 封装引擎对象(坐标做受控面像素↔引擎世界换算)。同步**核 `renderParticles`(`:343-357`) 语义**(见 §2.4:粒子引擎自渲,此处改"画 juice 叠加层"或保留为引擎粒子着色入口,A2 据引擎 ParticleEmitter 渲染机制定)。 | 包装·工厂填体 |
| `src/core/api.d.ts` | `EngineEmitterSpec`(`:84-97`)**additive 扩可选字段**承全发射参数(如 `emitConeAngle?`/`gravityScale?`/`colorStartA?`/`sizeStart?`/`sizeEnd?`/`particleConeAngle?`/`count?` 等)。**铁律:仅加 `?` 可选字段,不动已冻 6 参、不改其语义** → 已冻 6 参消费者零感知。 | 契约 additive 扩展 |
| `src/plugins/particles-juice/impl.js` | **删 sim**:`spawnOne`(`:241-265`)/`integrateParticles`(`:290-316`)/`stepEmitter`(`:272-284`)/`refreshEnv`(`:336-347`)/环境场变量(`:322-333`)/`step` 内粒子段(`:417-433`)。`spawnEmitter`(`:519-543`) 改薄包装:init 取一次 `_engParticles=ctx.getEngine()?.particles`(缓存,禁每帧调),有→`_engParticles.spawnEmitter(toEngineSpec(cfg,x,y))`;null→返 no-op 句柄。`render()` 瘦身(§2.4)。**juice 全段保留**(`:354-407` stepJuice + `:582-651` juice 能力面)。 | 包装 + 删平行自研 |
| 同目录 `test/*.mjs` | **测试转向**(§5.1):删"sim 物理输出/同 seed 逐字段复现"断言;新增"`toEngineSpec` 纯函数映射"单测 + "注入 mock 引擎断 spawnEmitter 真调引擎能力面参数对" + "null-engine 返 no-op 句柄、particleCount 恒 0"。juice 既有确定性测**全保留**。 | 测试纪律 |
### 4.3 A3 — audio 包装 + 删 vendored + 播放层补 ×0.3(串行,依赖 A1 工厂骨架)
| 文件 | 改动 | 类别 |
|---|---|---|
| `host-dev/host.js` | `makeEngineCaps()` 内**填 `audio.synth` 体**:`synthSfx(params)` → `LJS.zzfxG(...params)`(返 PCM 数组);`synthSong(...)` → `LJS.zzfxM(...)`(返 [L,R])。失败返 null。 | 包装·工厂填体 |
| `src/plugins/audio-music/vendor/zzfx.js` | **删除整文件**。 | 删 vendored |
| `src/plugins/audio-music/vendor/zzfxm.js` | **删除整文件**。 | 删 vendored |
| `src/plugins/audio-music/impl.js` | 删 `import {zzfxG...} from vendor/zzfx.js`(`:29`) + `import {zzfxM} from vendor/zzfxm.js`(`:30`)。init 取一次 `_synth=ctx.getEngine()?.audio?.synth`(缓存)。`renderSong`(`:334-343`) 改 `_synth?.synthSong(...)`(null→返 null→播放 no-op);`playSfx`(`:454-469`) 改 `_synth?.synthSfx(preset.params)`(null→no-op)。**播放层 `playChannels`(`:233-276`) 保留**,但 ×0.3 主音量须**显式补在 gain**:引擎 zzfxG 不烤 ×0.3,故 `gain.gain.value = clamp01(masterVolume * 0.3 * ...)`(**真缺件自研补层**,常量 0.3 留 impl,注释引擎 soundVolume 来源)。⚠️ `ZZFX_SAMPLE_RATE`(原 vendor 导出,`playChannels:240` createBuffer 用)随 vendor 删 → A3 须在 impl 内补采样率常量(44100,引擎同口径)或经 synth 面回传。 | 包装 + 删平行自研 + 补层(×0.3 / 采样率) |
| 同目录 `test/*.mjs` | **测试转向**(§5.1):删 `import {zzfxG} from vendor`(文件已删)+ "vendored 合成确定性/样本量级"断言(合成核已归引擎,node 无引擎不测引擎合成)。**保留并强化**:validateSong/resolveVoiceStates/预设 schema/无上下文静默降级(纯逻辑补层,全留);新增"注入 mock 引擎 synth 断 play/playSfx 真调 synthSfx/synthSong 参数对" + "播放层 ×0.3 增益正确"(mock AudioContext 断 gain.value)。 | 测试纪律 |
### 4.4 A4 — gamefeel 消费 math/easing 门面(并行,不碰 host.js/api.d.ts)
| 文件 | 改动 | 类别 |
|---|---|---|
| `src/plugins/gamefeel/impl.js` | **核心改面(改判后实有消费点):`easing` 标准库改经门面取引擎 `Ease`**。init 取一次 `_engMath=ctx.getEngine()?.math`(缓存,禁每帧调);`easing` 各曲线改为 `_engMath?.easing?.quadIn ?? 内置 quadIn`(依此类推 quad/cubic/elastic/back 全 family),即**门面优先、null-engine 退内置纯函数 fallback**(§1.3 特例)。**内置 easing 实现(`:57-145`)保留作 fallback**(合法,非有意替代、非补层)。lerp/smoothStep 若 impl 后续引入用点同走 `_engMath?.lerp ?? 内置`。InputBuffer/CoyoteTimer/ComboWindow(经 `getInput`/`time`)**补层全留**。 | 包装(math/easing 门面) + fallback 内置 + 补层(时间窗)全留 |
| `host-dev/host.js` / `api.d.ts` | **不改**(A4 仅消费 A1 已落的 `getEngine().math.easing` 门面;门面声明/实现由 A1 落)。 | — |
| 同目录 `test/*.mjs` | **测试转向**:① 新增"easing 经门面:注入 mock 引擎 `math.easing`,断 gamefeel `easing.quadIn` 真转发到门面";② 新增"null-engine:`getEngine()` 返 null 时 easing 退内置纯函数、端点恒等 f(0)=0/f(1)=1 不变";③ 内置 easing 既有纯函数确定性测**全保留**(fallback 路径仍须覆盖,**不删=不掏空**)。InputBuffer/Coyote/Combo 既有测全保留。 | 测试纪律 |
> **A4 的"无 orphan"自检(改判后已自然满足)**:改判前忧"gamefeel 无 math 用点 → 门面备而未用 = orphan";**改判后 easing 即真实消费点**(gamefeel 本就重度用 easing,§0.2 实证 impl 有完整 easing 体)→ 门面有真消费、非 orphan。A4 无需再为"制造消费"纠结。**反向自检**:A4 须确保改后 gamefeel 对外行为(easing 输出值)**不变**——门面与内置 fallback 数学等价(quad 字节恒等、cubic/back/elastic ULP 级 <1e-9,§0.2),对 juice 视觉无可感差异;REPORT 须注明"easing 改经门面后输出与改前数学等价(ULP 级),无行为回归"。
---
## 5. 3 个 concern-P0 处置(创始人关切·硬约束)
### 5.1 concern-P0-①:测试不可掏空(删 sim→换映射纯单测 + 真渲出现)
**风险**:删 sim/vendored 后,依赖它们的 node 单测(particles 同 seed 逐字段复现、audio import vendored zzfxG 断确定性)若直接删 = **掏空断言**,把"删实现"伪装成"测试通过"。
**裁定(三段式,缺一不可)**:
1. **删 sim 物理/合成核断言**(它们测的是已删的平行自研,留着即 import 失败/死代码)——**但同时立即补等价覆盖**:
- particles:① **`toEngineSpec(cfg,x,y)` 纯函数映射单测**(受控面像素口径 → `EngineEmitterSpec`,断字段换算对,**纯函数 node 可测、无需引擎**);② **mock 引擎注入测**(fake `EngineCapabilities`,断 `spawnEmitter` 真调 `engineCaps.particles.spawnEmitter` 且入参=映射结果、句柄 stop 真转发);③ **null-engine 测**(断 `spawnEmitter` 返 no-op 句柄、`particleCount()` 恒 0、不抛)。
- audio:① validateSong/resolveVoiceStates/预设 schema/无上下文降级**全保留并为主**;② **mock 引擎 synth 注入测**(断 `play/playSfx` 真调 `synthSfx/synthSong` 参数对);③ **播放层 ×0.3 增益测**(mock AudioContext 断 `gain.gain.value` 含 ×0.3 因子)。
2. **juice/时间窗等补层断言全保留 + easing fallback 断言全保留**(juice/InputBuffer/Coyote/Combo 是引擎无、全通道跑的真缺件,**绝不删**;easing 虽改归门面包装,其**内置 fallback 路径的纯函数确定性测仍须保留**——fallback 是 null-engine 下的真实运行路径,删之即掏空 fallback 覆盖)——这是"测试不掏空"的正面锚:补层 + easing fallback 确定性测一条不少。**额外新增**:easing 经门面转发测 + null-engine 退内置测(§4.4)。
3. **真渲出现(real render appears)= 像素门兜底**:sim 删后 node 不再有"粒子物理→像素"链,**真粒子是否真渲**由 **mini-desktop real 通道像素门(门0 G0-7 / A6)** 证:`?engine=real&mode=evidence` → spawn 一发 → 引擎 RAF 渲 → 抓 `#game-engine` canvas → **断非空 + 有色像素命中 > 0 + 无白字水印**(`gate0-engine-takeover.cjs:94-122` 已有此断言形态)。**这是"删了 sim 但粒子真的渲出来了"的唯一正面证据**,不可省(由主会话事后在 mini-desktop 驱动,本工作流不做 A6)。
> **掏空判据(机读)**:A2/A3 提交后,对比测试文件——**删除的断言数 ≤ 新增的等价断言数**,且**补层断言总数不减**。若某 lane 出现"净删断言且无等价补入",视为掏空,门禁挡回。
### 5.2 concern-P0-②:门③负扫不可绕过
**风险**:删 sim/vendored 后,残留半截自研(如 impl 仍留 `integrateParticles` 但不调)、或把引擎 import 偷偷散进插件 = 绕过边界。
**裁定**:门③负向扫描(§6 清单)**逐条 grep/ls 机读**,作为 A2/A3/A4 **完成条件的硬门**,不可"声称已删"代替"扫描证空"。负扫脚本化(建议落 `test/harness/negative-scan.sh`,本机可跑,非 chrome):每条断言输出 `PASS/FAIL + 命中行`,**任一 FAIL 阻断合并**。负扫**不依赖 chrome/CDP**(纯 grep/ls),本机即可跑,与门②(mini-desktop CDP 调用 ID)互补:门②证"真调了引擎",门③证"没留平行自研"。
### 5.3 concern-P0-③:RNG-seed 语义(受控面 engine-backing 须保确定性可种子)
**已亲验关键事实(否决"引擎粒子可种子复现")**:
- 受控面 `ctx.random` 可由引擎 `RandomGenerator`(xorshift 可种子,`:1789`)背书——**可种子**,但**与 host-dev `SeededRandom`(mulberry32) 公式不同 → 同 seed 跨实现序列不同**(api.d.ts:226 已承认"契约形状不变,仅换实现")。
- 但**引擎 `ParticleEmitter` 内部粒子抖动调引擎全局 `rand()`**(默认 `randomness=.2`),**不吃插件 `ctx.random` 子流** → **引擎渲染的粒子从插件视角不可种子复现**。
**裁定(分两层,钉死)**:
1. **受控面 `random` 层**:engine-backing 须**保持可种子**(`RandomGenerator(seed)` + `reseed`),契约 `RandomSource`(`api.d.ts:35-42`)形状不变。**确定性范围限定**:同一实现内(纯 host-dev 或纯引擎)同 seed 可复现;**跨实现(host-dev↔引擎)不保证逐值对齐**——此为已接受的实现替换代价(非缺陷)。补层件(particles juice 计时、gamefeel 时间窗)若用 `ctx.random`,其确定性测**须在单一实现内做**(node 单测用 host-dev SeededRandom,逐值断言;不跨引擎断同值)。
2. **引擎粒子随机层**:引擎 ParticleEmitter 用全局 rand,**插件无法种子对齐** → 按创始人模型"**不能种子对齐则 random 留插件自管按真缺件**"的精神:**粒子的可种子确定性需求,不强加到引擎粒子上**。后果:
- 老"particles 同 seed 逐字段复现"测**随 sim 退役**(sim 删,引擎粒子本就不可种子复现)——**不是掏空**,是"被测对象(可种子的本地 sim)已不存在"。其确定性价值由"`toEngineSpec` 纯函数映射可复现"(同 cfg→同 spec,§5.1-①)继承。
- 若**未来**某玩法确需"可种子复现的粒子"(如确定性回放),那属"引擎缺件"→ 届时按补层自研一份可种子粒子层(合法),但**MVP 不预造**(YAGNI,无 orphan)。
3. **stub 通道 same-pixel 回归**:旧稿靠"null-engine 走 sim 产确定像素"做逐像素哈希回归。**sim 删除后此回归退役**(创始人模型不留 sim)。逐像素哈希在本波**不再作门**;真渲证据降级为 real 像素门的"非空+有色+无水印"(§5.1-③,非 same-pixel)。**此为创始人"删 sim"裁定的直接代价,已在 §1.4 显式记账。**
---
## 6. 真接线门③负扫断言清单(逐条可 grep/ls 验·本机可跑)
> 全部本机执行(grep/ls,**不碰 chrome/CDP**)。每条给"断言 + 命令 + 期望"。任一 FAIL 阻断 A2/A3/A4 合并。命令均在 `game-runtime/` 根执行。
| # | 断言 | 命令 | 期望 |
|---|---|---|---|
| **N1** | 插件 impl 零直接 import littlejsengine | `grep -rn "from 'littlejsengine'\|from \"littlejsengine\"\|require('littlejsengine'" src/plugins/ --include=*.js` | **0 命中** |
| **N2** | littlejsengine import 仅活在集成段 host | `grep -rln "import .* from 'littlejsengine'" src/ host-dev/` | **仅 `host-dev/host.js` + `host-dev/entry.js`** |
| **N3** | vendored zzfx.js 已删 | `ls src/plugins/audio-music/vendor/zzfx.js` | **No such file** |
| **N4** | vendored zzfxm.js 已删 | `ls src/plugins/audio-music/vendor/zzfxm.js` | **No such file**(vendor/ 目录若空可一并删) |
| **N5** | audio impl 不再 import vendored | `grep -n "vendor/zzfx" src/plugins/audio-music/impl.js` | **0 命中** |
| **N6** | particles sim 积分循环已删 | `grep -n "function integrateParticles\|function stepEmitter\|function spawnOne\|function refreshEnv" src/plugins/particles-juice/impl.js` | **0 命中** |
| **N7** | particles 无残留环境场平行物理 | `grep -n "envGravity\|envDrag\|_globalSizeCurve\|_sizeCurveOf" src/plugins/particles-juice/impl.js` | **0 命中**(环境场随 sim 删) |
| **N8** | particles 经 getEngine 包装(正面:真接了) | `grep -n "getEngine()" src/plugins/particles-juice/impl.js` | **≥1 命中**(init 取 particles 能力) |
| **N9** | audio 经 getEngine().audio.synth 包装 | `grep -n "getEngine()" src/plugins/audio-music/impl.js` | **≥1 命中** |
| **N10** | 插件零裸 `new AudioContext` | `grep -rn "new AudioContext\|new webkitAudioContext\|window.AudioContext\|window.webkitAudioContext" src/plugins/ --include=*.js` | **0 命中**(音频只经 getAudioContext 受控面) |
| **N11** | 插件零原生 RAF/addEventListener/Date.now/Math.random(受控面铁律) | `grep -rn "requestAnimationFrame\|addEventListener\|Date.now\|performance.now\|Math.random" src/plugins/ --include=*.js` | **0 命中**(时间/输入/随机走受控面;注释除外,命中须人核确为注释) |
| **N12** | 补层件/easing fallback 未被误删(正面守门) | `grep -n "stepJuice\|shakeScreen\|hitStop" src/plugins/particles-juice/impl.js`;`grep -n "validateSong\|resolveVoiceStates" src/plugins/audio-music/impl.js`;`grep -n "elasticIn\|backOut\|class InputBuffer" src/plugins/gamefeel/impl.js` | **各 ≥1 命中**(juice/纯逻辑补层在;**easing 内置实现保留作 null-engine fallback**——`elasticIn/backOut` 命中=fallback 在,**非补层**;时间窗 InputBuffer 补层在) |
| **N12b** | gamefeel easing 经门面取引擎(改判正面:真接了门面) | `grep -n "getEngine()" src/plugins/gamefeel/impl.js` | **≥1 命中**(A4 后 gamefeel init 经 `getEngine().math.easing` 取曲线;**改判前此处为空**——空=easing 仍平行自研直用内置=有意替代,FAIL) |
| **N13** | ×0.3 主音量在 audio 播放层补(缺件补层正面) | `grep -n "0.3\|ZZFX_MASTER\|soundVolume\|MASTER_VOLUME" src/plugins/audio-music/impl.js` | **≥1 命中**(播放 gain 层补 ×0.3,注释引擎来源) |
| **N14** | EngineEmitterSpec 仅 additive 扩(已冻 6 参未动) | 人核 `src/core/api.d.ts:84-97`:已冻 6 参(pos/angle?/emitSize?/emitTime?/emitRate?/particleTime?)字面与语义不变;新增字段全带 `?` | 已冻 6 参字面不变;新增字段全 `?` |
> **门② 互补(mini-desktop·非本工作流)**:real 通道 CDP 读 `window.__engineCalls`(A2/A3 包装层埋一次性"调引擎能力 callId"探针),断每个包装层 ≥1 条"engineSymbol 命中真引擎导出"。门②证"真调了引擎",门③证"没留平行自研"——两门合起来 = 一职一路的双向证据。**门②/A6 由主会话事后在 mini-desktop 驱动。**
---
## 7. 移交主会话的待办(非本工作流执行)
| # | 待办 | 归属 |
|---|---|---|
| 1 | A1→A2/A3 串行实现(host.js 工厂一处构造,避免合并冲突);A4 并行 | 主会话派 lane(建议 opus:删 sim/接引擎有裁量) |
| 2 | 负扫脚本落地 `test/harness/negative-scan.sh`(§6 十五条机读:N1–N14 + 新增 N12b gamefeel easing 门面正面守门) | lane 或主会话 |
| 3 | 门②(`__engineCalls` 调用 ID)+ A6 real 像素门 + 真接线门② runtime probe,**mini-desktop CDP** | 主会话(本机禁 chrome) |
| 4 | `EngineEmitterSpec` 是否需 `count` 精确控 burst(additive 扩已开口子,host 背书还原度由 real 像素门兜,非 same-pixel)——若 real 像素门发现 burst 数量明显失真再议 | A2 lane + 主会话裁 |
| 5 | ~~A4 gamefeel 是否真有 math 消费点~~ **【改判已解】easing 即真实消费点**(§4.4):A4 把 gamefeel `easing` 改经 `getEngine().math.easing` 门面(内置留 fallback),REPORT 注明"easing 改门面后输出与改前数学等价(ULP 级)、无行为回归" | A4 lane |
| 6 | A3 删 vendor 后 `ZZFX_SAMPLE_RATE` 替代(impl 内补 44100 常量或经 synth 面回传) | A3 lane |
---
## 8. 一页纸总结(给实现 lane)
- **一句话**:引擎有的(particles/synth/lerp/smoothStep/**easing**/受控面 random/time/input/audioctx)→ **薄包装删平行自研**;引擎无的(juice/时间窗/audio 播放层 ×0.3/collision/physics/palette)→ **补层全留全测**。**改判**:easing 引擎有(`Ease` 曲线族)→ 移入"薄包装",归纯数学族特例(门面 `getEngine().math.easing` + 内置 fallback)。
- **null-engine**:引擎覆盖能力 **no-op**(particles 返空句柄、synth 返 null);补层**全通道等价跑**;**纯数学族(lerp/smoothStep/easing)是唯一"内置等价 fallback"特例**(门面优先、null 退内置)。
- **删表**:particles `spawnOne/integrateParticles/stepEmitter/refreshEnv/环境场/step 粒子段/render 粒子绘制段`;audio `vendor/zzfx.js + vendor/zzfxm.js + 两 import`。**easing 内置实现不删**(留作 fallback),但 gamefeel 须改"经 `getEngine().math.easing` 门面取曲线、内置仅 fallback"。
- **不删表**:补层=juice 全段、audio 播放层+×0.3+validateSong+resolveVoices+预设、gamefeel InputBuffer+Coyote+Combo;**easing 内置=纯数学族 fallback(非补层,亦不删)**。
- **测试**:删被测对象(sim/vendored)的断言**必须**等量补"映射纯单测 + mock 引擎注入 + null-engine no-op";补层断言一条不减;**easing 改门面后:内置 fallback 纯函数测保留 + 新增门面转发测/null 退内置测**;真渲出现归 mini-desktop real 像素门。
- **RNG**:受控面 random 保可种子(限单一实现内逐值复现);引擎粒子用全局 rand 不可种子对齐——老"粒子同 seed 复现"测随 sim 退役(非掏空),same-pixel 哈希回归本波退役。
- **easing 改判(本轮唯一新增裁定)**:引擎实有 `Ease`(前轮 grep 假阴性误判为"引擎无")→ easing 本波**包装**(纯数学族特例:门面包装引擎 `Ease`,内置仅 null-engine fallback,§1.3/§2.2/§4.1/§4.4);新红线 §1.5"引擎能力普查禁按预期名 grep"。**其余裁定(particles/audio 删 sim/vendored、受控面、collision/physics/palette 补层、null no-op、RNG、门③负扫主体)一字未改。**
- **门③**:§6 十五条(N1–N14 + N12b)grep/ls 机读,本机可跑,任一 FAIL 阻断合并。

View File

@ -0,0 +1,357 @@
# A1 edit-plan — 工厂地基 + 受控面 + easing 门面(本轮 scoped 重跑·行级落点)
> **lane**:A1(串行头,A2/A3 前置;A4 消费前置)|**角色**:设计官(opus)产出精确 edit-plan,**不改代码**。
> **政策唯一据**:[`2026-06-13-A1-A6-接线政策.md`](./2026-06-13-A1-A6-接线政策.md)(easing 改判版;创始人 2026-06-13「引擎↔插件 边界模型」拍板落地)。
> **本稿覆盖(overwrite)**:覆盖本文件旧版(2026-06-13 早,标题「受控面 + 引擎工厂地基」、主张「不改 api.d.ts 契约形状」)——该版**早于 easing 改判**,与"easing 本波包装(须 additive 扩 api.d.ts 加 easing 面)"裁定冲突,全文作废。本稿据 **easing 改判政策 + 本次源码亲验(含 node 逐点实测)** 重写。
> **本轮 scoped 边界**:上轮 A1/A2/A3 复审 PASS、政策主体证实正确;**本轮只重做 A1 加 easing 门面**。A1 仍只改 `host-dev/host.js`(工厂地基 + easing 门面体)+ `src/core/api.d.ts`(additive 加 easing 面 + 校准 EngineCapabilities 块注释);`src/core/plugin.js` **零改**(工厂收纳/lazy/转发已就绪)。A1 **不改任何插件 impl**(particles/audio/gamefeel 归 A2/A3/A4)。
---
## 0. 源码已核事实台账(本稿亲验,复审/实现须再核·"引擎有此能力"断言带行号)
| # | 事实 | 验证点(文件:行,可复验) | 结论 |
|---|---|---|---|
| F1 | 引擎能力工厂注入式·lazy 单例·已就绪 | `plugin.js:397`(收 `o.engineFactory`)·`:438-456`(`getEngineShared` lazy)·`:489-491`(`getEngine()` 转发) | ✅ A1 只须给 real 通道注入工厂;plugin.js 零改 |
| F2 | host.js `buildBundle` 现**未传** `engineFactory` | `host.js:215-221`(`createHostDevContext({context2d,seed,audioFactory,clock})`,无 `engineFactory` 键) | ✅ 两通道 getEngine() 皆 null;A1 须在 real 通道补注入(保 stub=null) |
| F3 | host.js A0 已 import 引擎、已掌帧 | `host.js:44`(`import * as LJS from 'littlejsengine'`)·`:756`(engineInit 五回调)·`:758`(绘制面 `LJS.mainContext`) | ✅ 引擎 import 唯一活点;A1 工厂体在此作用域内,Q4 守住 |
| F4 | 引擎 `lerp(valueA,valueB,percent)` 存在且**钳制 percent** | `esm.js:1443` `lerp(valueA,valueB,percent){ return valueA+clamp(percent)*(valueB-valueA); }`·`.d.ts:987` | ✅ 与 `api.d.ts:126` 门面签名逐字一致(且自带 percent 钳制) |
| F5 | 引擎 `smoothStep(percent)` 存在 | `esm.js:1500` `smoothStep(percent){ return percent*percent*(3-2*percent); }`·`.d.ts:1002` | ✅ 与 `api.d.ts:128` 门面签名逐字一致 |
| F6 | 引擎 `Ease` 曲线族存在、为 const 对象、经 `LJS.Ease` 触达 | `esm.js:15118` `const Ease`(`LINEAR`/`POWER(n)→x=>x**n`/`SINE`/`CIRC`/`EXPO`/`BACK`/`ELASTIC`/`SPRING`/`BOUNCE` + `:15212` 起 `IN`/`OUT`/`IN_OUT`/`PIECEWISE`/`BEZIER` 修饰器)·export 块 `:16681` `Ease,`·`.d.ts:5514` `export namespace Ease`(POWER/BACK/ELASTIC/OUT/IN_OUT 全在) | ✅ 改判核心:引擎**有** easing(前轮按小写 `quadIn` grep 假阴性漏判,§红线) |
| F7 | **`import * as LJS from 'littlejsengine'` 在 node 直接抛错** | node 实跑:`ReferenceError: window is not defined`(模块顶层 `esm.js:5556 const isTouchDevice=!headlessMode && window.ontouchstart!==undefined`) | ⚠️ **关键约束**:node/host-dev **不能 import 引擎** → 工厂体(用 LJS.Ease/lerp)只能在浏览器 real 通道构造;null-engine(node/stub)必退内置 fallback。这是"引擎 import 只活集成段 host"+"null fallback"的**物理根因**,亦决定 node 单测不可 import 引擎(测门面须内联引擎曲线作 oracle) |
| F8 | gamefeel impl **零 lerp/smoothStep 调用点** | `grep -n "lerp\|smoothStep" gamefeel/impl.js` → 空 | ✅ A4 的 math 真实消费点=**easing**(非 lerp);lerp/smoothStep 门面 A1 落、A4 暂不消费(gamefeel 当前不用,非 orphan:门面是 EngineMath 既有契约面,lerp/smoothStep 早冻) |
| F9 | gamefeel impl 现零 `getEngine()` 调用 | `grep -n getEngine gamefeel/impl.js` → 空 | ✅ A4 才接门面;A1 只落门面供其接 |
| F10 | `EngineMath`/`EngineCapabilities` 块行号 + 现降级措辞 | `api.d.ts:124-129`(EngineMath 仅 lerp/smoothStep)·`:137-144`(EngineCapabilities 三面)·`:133-135`(旧双码路措辞「粒子退受控面自管/合成核退 vendored/数学退插件内置」) | ✅ A1 additive 落点=EngineMath 加 easing 成员 + 新 EngineEasing 接口;校准 EngineCapabilities 块注释为 null no-op |
| F11 | `EngineMath` 现零 .js 消费 | `grep -rn "EngineMath\|\.math\.easing" src/ --include=*.js` → 空(仅 api.d.ts) | ✅ additive 加 easing 成员零破既有运行时消费者 |
### 0.1 easing 字节恒等性逐点扫描(node 亲验·**决定门面边界**)
> **命令**:内联引擎 `Ease` 曲线(逐字节复制 `esm.js:15118`,**不 import 引擎**以规避 F7)+ gamefeel 公式(逐字节复制 `impl.js:57-145`),x∈[0,1] 扫 **2001 点**,比 `Object.is` 字节恒等数与 `maxAbsDiff`。
| gamefeel 曲线 | 引擎映射 | maxAbsDiff | 字节恒等点 | 等价性结论 |
|---|---|---|---|---|
| `quadIn` | `Ease.POWER(2)` | **0** | 2001/2001 | ✅ **逐字节恒等** |
| `quadOut` | `Ease.OUT(POWER(2))` | **0** | 2001/2001 | ✅ **逐字节恒等** |
| `quadInOut` | `Ease.IN_OUT(POWER(2))` | 1.1e-16 | 1899/2001 | ✅ ULP 等价 |
| `cubicIn` | `Ease.POWER(3)` | 1.1e-16 | 1517/2001 | ✅ ULP 等价 |
| `cubicOut` | `Ease.OUT(POWER(3))` | 1.1e-16 | 1837/2001 | ✅ ULP 等价 |
| `cubicInOut` | `Ease.IN_OUT(POWER(3))` | 1.1e-16 | 1637/2001 | ✅ ULP 等价 |
| `backIn` | `Ease.BACK` | 6.7e-16 | 526/2001 | ✅ ULP 等价(gamefeel `C3=1.70158+1`,引擎字面 `2.70158`,同曲线) |
| `backOut` | `Ease.OUT(BACK)` | 6.7e-16 | 1356/2001 | ✅ ULP 等价 |
| `elasticIn` | `Ease.ELASTIC` | 3.6e-16 | 329/2001 | ✅ ULP 等价(公式形异·曲线同:gamefeel `sin((t*10-10.75)·2π/3)` vs 引擎 `sin((37-40x)π/6)`,逐点等价——§红线"公式形异不等于曲线异"印证) |
| `elasticOut` | `Ease.OUT(ELASTIC)` | 2.1e-15 | 1452/2001 | ✅ ULP 等价 |
| **`backInOut`** | `Ease.IN_OUT(BACK)` | **6.6e-2(6.6%)** | 1/2001 | ❌ **真分歧·非 ULP** |
| **`elasticInOut`** | `Ease.IN_OUT(ELASTIC)` | **1.7e-1(17%)** | 7/2001 | ❌ **真分歧·非 ULP** |
> **决定性发现(本稿核心,重塑门面边界)**:引擎 `IN_OUT(f)=PIECEWISE(f, OUT(f))`(`esm.js:15229`/`:15234`)的 inOut 拼接法,与 gamefeel `backInOut`/`elasticInOut`(easings.net 标准 `easeInOutBack`/`easeInOutElastic` 闭式,impl.js:98-130)**是不同曲线**:back 差 **6.6%**、elastic 差 **17%**,**远超 ULP**(quad/cubic 的 inOut 反而等价,因二次/三次拼接两法巧合一致)。
> **推论裁定(见 §3 Q-A1-1)**:A1 easing 门面**只包装这 11 条等价曲线**;`backInOut`/`elasticInOut` 引擎**无等价件**(`IN_OUT(BACK/ELASTIC)` 是另一条曲线)→ 归"引擎无此精确曲线",gamefeel 保内置实现(**补层**,非 fallback、非有意替代)。**若强行用引擎 IN_OUT 包装这二者 = 改变 gamefeel 输出 6.6%/17% = 行为回归,违政策 §4.4「无行为回归」铁律。**
> **诚实记账(政策 §0.2 一致)**:纯数学族特例资格锚=公式恒等+无状态+无语义冲突,**不强求逐字节恒等**(仅 quad 字节恒等,cubic/back/elastic ULP <1e-15);easing 仅供 juice 视觉插值、无字节级复现门校(same-pixel 已退役)→ ULP 差无害。**但 backInOut/elasticInOut 的 6.6%/17% 不是 ULP、是真曲线分歧,超出"特例可容"范围 → 必须排除门面。**
---
## 1. 改动总览(三文件)
| 文件 | 改动性质 | 行级落点 |
|---|---|---|
| `host-dev/host.js` | **新增** `makeEngineCaps()` 工厂 + real 通道注入 + `clampUnit` 辅助 | `buildBundle`(`:215-221`) 的 `createHostDevContext({...})` 加 `engineFactory: makeEngineCaps`;工厂函数体 + `clampUnit` 新增于 `bootHostDev` 作用域(`import * as LJS` 已在 `:44`) |
| `src/core/api.d.ts` | **契约 additive 扩** | 新增 `EngineEasing` 接口;`EngineMath`(`:124-129`)加 `easing?: EngineEasing` **可选成员**;校准 `EngineCapabilities` 块注释(旧双码路→null no-op) |
| `src/core/plugin.js` | **零改** | 工厂收纳/lazy/转发已就绪(F1) |
> **A2/A3 串行依赖点**:`makeEngineCaps()` 在 host.js 一处构造 `{particles, audio:{synth}, math}`。A1 落 `math`(含 easing)**实现体** + `particles`/`audio.synth` **空骨架占位**(占位函数,A2/A3 替换填体)→ A1→A2/A3 串行(同改此工厂,避免合并冲突)。**A4 并行**(仅消费 A1 落的 `getEngine().math.easing` 门面,不碰 host.js/api.d.ts)。
---
## 2. 逐文件行级落点
### 2.1 `host-dev/host.js` — `makeEngineCaps()` 工厂 + real 注入 + clampUnit
#### 2.1.1 注入点(`buildBundle`,`:215-221`)
现状(`:215-221`):
```js
function buildBundle(renderCtxArg) {
const b = createHostDevContext({
context2d: renderCtxArg,
seed,
audioFactory,
clock: () => mockNowMs,
});
```
改为(additive 加一键 `engineFactory`):
```js
function buildBundle(renderCtxArg) {
const b = createHostDevContext({
context2d: renderCtxArg,
seed,
audioFactory,
clock: () => mockNowMs,
engineFactory: makeEngineCaps, // 【A1】注入引擎能力工厂;工厂内按 engineMode 短路(stub→null,保 null-engine 回归)
});
```
> **real/stub 分流纪律(关键)**:`buildBundle` 被 `setupAfterEngine`(`:242`) 调,**real 与 stub 都经它**(real:engineInit().then 内 `:758`;stub:`:731` 立即)。若无条件注入真工厂,stub 通道也会拿到引擎能力 → **破 null-engine 回归战场**(政策 §0.1 A1:stub 通道**仍不注入**=保回归)。
> **落法**:`makeEngineCaps` **首行按 `engineMode`(`:202` 闭包可见)短路**——`if (engineMode !== 'real') return null;`。即工厂键恒注入,但 stub 通道工厂**返回 null**(等价"未注入",触发 plugin.js `getEngineShared` 的 null 分支:告警一次 + getEngine() 返 null)。效果:
> - real:`makeEngineCaps()` 返真 `EngineCapabilities`(含 LJS.Ease 包装);
> - stub:返 null → getEngine()==null → gamefeel 退内置 easing(A4 fallback)、particles/audio no-op。
> **为何工厂内短路而非 buildBundle 条件传键**:F7 决定 stub/node 本就 import 不了引擎,**工厂内短路是 import 安全的天然位置**(工厂体只在 real 短路放行后才触 LJS.Ease);且注入面恒定、语义集中在工厂单点。**推荐工厂内短路**。
#### 2.1.2 `clampUnit` 辅助(新增,建议紧邻 `:53 FIXED_DT` 等常量区或工厂前)
```js
/** 【A1 easing 门面】把 t 钳到 [0,1](与 gamefeel clamp01 同口径)。引擎 Ease 不自钳,门面须钳齐保端点恒等+越界一致。 */
const clampUnit = (t) => (t < 0 ? 0 : t > 1 ? 1 : t);
```
> **为何必须钳**:gamefeel 内置每条 easing 都 `clamp01(t)` 打头(impl.js:59 等)+ 测断越界钳制(`gamefeel.test.mjs:51-60` 断 `fn(-0.5)===fn(0)`/`fn(1.5)===fn(1)`)。门面是"替换 gamefeel easing 的等价件",须与被替换者行为一致(钳制);引擎 `Ease.POWER`/`LINEAR` 等**不自钳**(`POWER:(n)=>(x)=>x**n`,越界 x 直接幂运算)。**不钳 → 门面对越界 t 行为 ≠ 内置 fallback → A4 改门面后行为回归(R2)。**
#### 2.1.3 工厂函数体(新增于 `bootHostDev` 作用域,建议紧邻 `audioFactory` `:172-180` 之后)
```js
/**
* 【A1】引擎能力背书工厂:构造 EngineCapabilities 三面(math 含 easing 门面 / particles / audio.synth)。
* - 仅 real 通道返真能力面;stub/node 通道短路返 null(保 null-engine 回归 + 规避 F7 引擎 import 在 node 抛错)。
* - 引擎 import 唯一活点=本 host(LJS@:44);插件零直接 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%,§0.1;gamefeel 对其保内置=补层)。
* - particles/audio.synth=A1 留空骨架占位,A2/A3 在同一工厂内替换填体(串行落,避免本函数合并冲突)。
* @returns {import('../src/core/api.d.ts').EngineCapabilities|null}
*/
function makeEngineCaps() {
if (engineMode !== 'real') return null; // stub/node:不背书引擎(保 null-engine 回归;F7:node 本就 import 不了引擎)
return {
// ── math(A1 落实现,含 easing 门面)──
math: {
lerp: (a, b, p) => LJS.lerp(a, b, p), // 逐字节包装(F4,引擎自带 percent 钳制)
smoothStep: (p) => LJS.smoothStep(p), // 逐字节包装(F5)
// easing 门面:包装引擎 LJS.Ease 曲线族(F6),仅 §0.1 已验等价的 11 条;每条钳 t∈[0,1]。
easing: {
linear: (t) => LJS.Ease.LINEAR(clampUnit(t)),
quadIn: (t) => LJS.Ease.POWER(2)(clampUnit(t)),
quadOut: (t) => LJS.Ease.OUT(LJS.Ease.POWER(2))(clampUnit(t)),
quadInOut: (t) => LJS.Ease.IN_OUT(LJS.Ease.POWER(2))(clampUnit(t)),
cubicIn: (t) => LJS.Ease.POWER(3)(clampUnit(t)),
cubicOut: (t) => LJS.Ease.OUT(LJS.Ease.POWER(3))(clampUnit(t)),
cubicInOut: (t) => LJS.Ease.IN_OUT(LJS.Ease.POWER(3))(clampUnit(t)),
backIn: (t) => LJS.Ease.BACK(clampUnit(t)),
backOut: (t) => LJS.Ease.OUT(LJS.Ease.BACK)(clampUnit(t)),
elasticIn: (t) => LJS.Ease.ELASTIC(clampUnit(t)),
elasticOut: (t) => LJS.Ease.OUT(LJS.Ease.ELASTIC)(clampUnit(t)),
// backInOut/elasticInOut 刻意不暴(引擎 IN_OUT 拼接≠gamefeel 闭式,§0.1 真分歧);gamefeel 对其恒走内置。
},
},
// ── particles(A2 填体)──A1 空骨架:spawnEmitter 返 no-op 句柄占位。
particles: {
spawnEmitter: (_spec) => ({ isActive: () => false, stop: () => {} }),
},
// ── audio.synth(A3 填体)──A1 空骨架:synth 返 null 占位。
audio: {
synth: {
synthSfx: (_params) => null,
synthSong: (_i, _p, _s, _bpm) => null,
},
},
};
}
```
> **引擎 Ease 优化提示(非必须)**:`LJS.Ease.POWER(2)`/`OUT(POWER(2))`/`IN_OUT(...)` 每次调用都新建闭包。easing 门面在热路径(juice 每帧)→ 实现 lane 可在工厂内**预构造一次**这些曲线函数(如 `const _p2=LJS.Ease.POWER(2); ... quadIn:(t)=>_p2(clampUnit(t))`),避免每次 easing 调用重建 POWER/OUT 闭包。本稿不强制(功能等价),列作性能注。
#### 2.1.4 A1 不改点(明示边界)
- `:343-357 renderParticles` 注释/语义 → **归 A2**(sim 删后语义变"画 juice 叠加层")。A1 不碰。
- audio 工厂 `:172-180`、受控面 time(步进钟 mockNowMs `:187-190`)/input(DOM 桥 `:395-406`)/random(mulberry32)backing → A1 本轮**不动**(见 §3 Q-A1-2/3 + 政策边界)。
> **受控面 random/time/input 的 A1 处置(裁定锚 + 本轮 scoped 边界)**:
> - **time**:政策 §2.1 方向"A1 后透传引擎 timeReal",但 host.js `:187-190` 明示"A0 仍用 host 步进钟,A1 才透传"。**本轮 scoped=easing 重跑**,prompt 裁定要点的"time 走引擎 timeReal"是方向陈述、未列为本轮落点;切引擎钟牵动门0 P75 埋点(mockNowMs 逻辑帧钟 vs timeReal 墙钟 `:672-688`)+确定性取证,**blast radius 远超 easing,不混入本轮**。→ §3 Q-A1-2 记 open。
> - **random**:政策 §5.3 已亲验"引擎粒子用全局 rand 不可种子对齐 + 跨实现同 seed 序列不同"→ random engine-backing 是**有损替换**。prompt 裁定要点亦明言"引擎 RNG 不能种子对齐则 **random 留插件自管(真缺件,合法)**"。现确定性测/双开哈希依赖 mulberry32 逐值 → **本轮保 host-dev SeededRandom 不切**(符合 prompt 裁定)。→ §3 Q-A1-3 记 open。
> - **input**:host.js `:395-406` 已真接 DOM 事件→归一化(real 挂引擎 mainCanvas)→ 已是"引擎事件→归一化、不泄漏裸对象"的实质(符合 prompt 裁定 input 要点)。A1 不改。
### 2.2 `src/core/api.d.ts` — 新增 `EngineEasing` + `EngineMath` 加 easing 成员
#### 2.2.1 新增 `EngineEasing` 接口(插入于 `EngineMath` 前,`:123` 之前)
```ts
/**
* 受控引擎缓动门面(β easing 改判新增;core-protocol-v0.1)。
* 包装引擎 Ease 曲线族(POWER/BACK/ELASTIC + OUT/IN_OUT 修饰器,littlejs.esm.js:15118)。
* **门面边界(已 node 逐点亲验,2001 点 x∈[0,1])**:仅暴与引擎 Ease **等价**的 11 条曲线
* (quadIn/quadOut 逐字节恒等;quadInOut/cubic 全族/backIn/backOut/elasticIn/elasticOut 为 ULP 级 <1e-15 等价)。
* **刻意不含 backInOut/elasticInOut**——引擎 IN_OUT(BACK/ELASTIC) 经 PIECEWISE 拼接是**另一条曲线**
* (back 差 6.6%、elastic 差 17%,非 ULP);此二者由 gamefeel 内置实现承(引擎无此精确曲线,是补层非 fallback)。
* **入参钳制**:门面调引擎 Ease 前钳 t 到 [0,1](引擎 Ease 不自钳,gamefeel 内置每条钳;门面须钳齐保端点恒等+越界一致)。
* **降级语义**:getEngine()==null(host-dev/node/stub)时整条 EngineCapabilities 为 null,消费方(gamefeel)退插件内置 easing 纯函数 fallback。
*/
export interface EngineEasing {
/** 线性(恒等,门面钳制 t∈[0,1])。 */
linear(t: number): number;
/** 二次缓入(=引擎 Ease.POWER(2),逐字节恒等)。 */
quadIn(t: number): number;
/** 二次缓出(=引擎 Ease.OUT(POWER(2)),逐字节恒等)。 */
quadOut(t: number): number;
/** 二次缓入缓出(=引擎 Ease.IN_OUT(POWER(2)),ULP 等价)。 */
quadInOut(t: number): number;
/** 三次缓入(=引擎 Ease.POWER(3),ULP 等价)。 */
cubicIn(t: number): number;
/** 三次缓出(=引擎 Ease.OUT(POWER(3)),ULP 等价)。 */
cubicOut(t: number): number;
/** 三次缓入缓出(=引擎 Ease.IN_OUT(POWER(3)),ULP 等价)。 */
cubicInOut(t: number): number;
/** 回弹缓入(=引擎 Ease.BACK,ULP 等价;中段越界 [0,1])。 */
backIn(t: number): number;
/** 回弹缓出(=引擎 Ease.OUT(BACK),ULP 等价)。 */
backOut(t: number): number;
/** 弹性缓入(=引擎 Ease.ELASTIC,ULP 等价;中段振荡)。 */
elasticIn(t: number): number;
/** 弹性缓出(=引擎 Ease.OUT(ELASTIC),ULP 等价)。 */
elasticOut(t: number): number;
// 注:backInOut/elasticInOut 刻意不入此面(引擎 IN_OUT 拼接是另一条曲线,非 ULP 等价,§0.1)。
}
```
#### 2.2.2 `EngineMath`(`:124-129`) additive 加 `easing?` 可选成员
现状(`:124-129`):
```ts
export interface EngineMath {
/** 线性插值(封装引擎 lerp,口径一致)。 */
lerp(valueA: number, valueB: number, percent: number): number;
/** smoothstep 缓动(封装引擎 smoothStep,口径一致)。 */
smoothStep(percent: number): number;
}
```
改为(**additive 仅加可选成员,不动 lerp/smoothStep 字面与签名**):
```ts
export interface EngineMath {
/** 线性插值(封装引擎 lerp,口径一致)。 */
lerp(valueA: number, valueB: number, percent: number): number;
/** smoothstep 缓动(封装引擎 smoothStep,口径一致)。 */
smoothStep(percent: number): number;
/**
* 缓动门面(β easing 改判新增·可选成员):封装引擎 Ease 曲线族(11 条等价曲线,见 EngineEasing)。
* additive 可选 → 已冻 lerp/smoothStep 消费者零感知;消费方 A4 防御式取(getEngine()?.math?.easing?.quadIn ?? 内置)。
*/
easing?: EngineEasing;
}
```
### 2.3 `src/core/api.d.ts` — `EngineCapabilities` 块注释校准(旧双码路 → null no-op)
现状(`:131-144`):
```ts
/**
* 受控引擎能力透传面(β 新增)。挂在 PluginContext.getEngine() 上。
* **封装边界**:所有方法/类型均为归一化封装,零 littlejsengine 裸对象/裸类型泄漏。
* **降级语义**:host-dev/node 无引擎时 getEngine() 返 null,插件须整体容错(粒子退受控面自管、
* 合成核退 vendored、数学退插件内置)。集成段 host 注入真实引擎背书实现。
*/
export interface EngineCapabilities {
/** 受控粒子能力(封装 ParticleEmitter)。 */
particles: EngineParticles;
/** 受控合成核(封装 zzfxG/zzfxM)。A2 据 SIZES 定是否真用,不用则清。 */
audio: { synth: EngineAudioSynth };
/** 受控引擎数学库(封装 lerp/smoothStep)。 */
math: EngineMath;
}
```
改为(**仅改块级注释 + math 成员行注释加 easing**;particles/audio 成员行顺手把"退受控面自管/退 vendored"旧措辞改 no-op 一句话,与块注释一致):
```ts
/**
* 受控引擎能力透传面(β 新增)。挂在 PluginContext.getEngine() 上。
* **封装边界**:所有方法/类型均为归一化封装,零 littlejsengine 裸对象/裸类型泄漏。
* **降级语义(一职一路·创始人 2026-06-13 裁,旧"双码路降级"作废)**:host-dev/node/stub 无引擎时
* getEngine() 返 null——引擎覆盖的有状态能力(particles/audio.synth)一侧**优雅 no-op**(不留 sim/vendored 降级);
* 仅纯数学族(math.lerp/smoothStep/easing)插件侧保**内置纯函数 fallback**(公式恒等/ULP 等价,非有意替代,
* 纯数学族是唯一可留 fallback 的类别)。集成段 host(real) 注入真实引擎背书实现。
*/
export interface EngineCapabilities {
/** 受控粒子能力(封装 ParticleEmitter;null-engine→spawnEmitter 返 no-op 句柄,A2 落)。 */
particles: EngineParticles;
/** 受控合成核(封装 zzfxG/zzfxM;null-engine→返 null 发声 no-op,A3 落删 vendored)。 */
audio: { synth: EngineAudioSynth };
/** 受控引擎数学库(封装 lerp/smoothStep + easing 门面;null-engine→消费方退内置纯函数 fallback)。 */
math: EngineMath;
}
```
> **校准边界纪律**:A1 只改 `EngineCapabilities` **块级注释** + 三成员**行注释**(一句话同步 null no-op 语义)。**不涉** `EngineParticles`/`EngineAudioSynth` 的**方法级注释**(`:99-121`,归 A2/A3)——尤其 `EngineAudioSynth.synthSfx`(`:115`)「插件回退 vendored」措辞归 A3 改,A1 不动。
---
## 3. open questions(须主会话/创始人裁·带本稿倾向)
| # | 问题 | 本稿倾向 | 影响面 |
|---|---|---|---|
| **Q-A1-1** | `backInOut`/`elasticInOut` 引擎无等价件(IN_OUT 拼接差 6.6%/17%,非 ULP)。门面是否暴这二者? | **不暴**。门面只收 11 条等价曲线;这二者归"引擎无此精确曲线",gamefeel 保内置实现(补层,A4 对其直用内置、不经门面)。**强行用引擎 IN_OUT 包装=改 gamefeel 输出 6.6%/17%=行为回归(违 §4.4)。** | **A1 门面面集 + A4**:A4 须对 backInOut/elasticInOut 例外(恒走内置;门面 `?? 内置` 天然兜底,但 REPORT 须注明这二者"门面无、恒走内置"非 orphan、非掏空) |
| **Q-A1-2** | time backing 本轮是否切引擎 `timeReal`? | **不切**(本轮 easing scoped)。切引擎钟牵动门0 P75 埋点 + 确定性取证(mockNowMs 逻辑帧钟 vs timeReal 墙钟),blast radius 远超 easing,留独立 lane。 | host.js time backing;门0 P75 钟选择 |
| **Q-A1-3** | random backing 本轮是否切引擎 `RandomGenerator`? | **不切**(符合 prompt 裁定"不能种子对齐则 random 留插件自管")。引擎粒子用全局 rand 不可种子对齐(政策 §5.3)+ 现确定性测/双开哈希依赖 mulberry32 逐值。 | host.js random backing;确定性测 |
| **Q-A1-4** | `EngineMath.easing` 设必填还是可选? | **可选 `easing?:`**。消费方 A4 本就防御式 `?.easing?.quadIn ?? 内置`;可选不破未来手构造点(虽现唯一构造点=host.js makeEngineCaps,A1 同步给)。 | api.d.ts 契约严格度 |
| **Q-A1-5** | `clampUnit` 门面钳制是否偏离"裸暴引擎 Ease"? | **须钳**。门面是"替换 gamefeel easing 的等价件"(gamefeel 每条钳 + 测断越界钳制 `:51-60`),非"裸暴引擎";门面钳制是兑现"无行为回归"的必要项。 | A1 工厂 easing 体(每条 clampUnit) |
---
## 4. 契约影响小结(additive 不破消费者·硬证)
1. **`EngineEasing` 全新接口**:无既有消费者 → 纯增,零破。
2. **`EngineMath` 加 `easing?` 可选成员**:
- 唯一字段消费者=`EngineCapabilities.math`(`api.d.ts:143`) + 计划 A4;现**无 .js 读 `.math.easing`**(F11 grep 证:EngineMath 仅 api.d.ts 出现)。
- 可选成员 → 既有"只读 lerp/smoothStep"消费者(当前无)零感知;唯一构造点=host.js makeEngineCaps(A1 同步给 easing)。
3. **`EngineCapabilities` 块注释校准**:纯注释,零运行时/类型影响。
4. **`EngineEmitterSpec`/`EngineParticles`/`EngineAudioSynth`/`PluginContext`/`Plugin`**:A1 **零改**(A2/A3/A4 各自领域)。
5. **plugin.js 运行时**:零改(工厂收纳/lazy/转发已就绪,F1)。
> **blast radius**:仅 host.js(+`makeEngineCaps` 工厂、+1 注入键、+`clampUnit`)+ api.d.ts(+`EngineEasing` 接口、+`EngineMath.easing?` 可选成员、块注释校准)。无既有消费者受影响(A4 是新消费方,本轮并行落)。
> **回滚**:删 `engineFactory` 注入键 + `makeEngineCaps` + `clampUnit` + `EngineEasing` + `EngineMath.easing?` 即回 A0 态(getEngine() 复归全 null,gamefeel 走内置 easing,行为与 A0 逐字节等价)。
---
## 5. 风险
| # | 风险 | 缓解 |
|---|---|---|
| **R1** | stub 通道误注入引擎工厂 → 破 null-engine 回归战场 | `makeEngineCaps` 首行 `if(engineMode!=='real')return null`(§2.1.1);门③ N12b 后续验 gamefeel real 走门面/stub 退内置 |
| **R2** | 门面 easing 未钳 t → 越界行为 ≠ gamefeel 内置 → A4 改门面后行为回归 | 每条门面 `clampUnit(t)` 打头(§2.1.2/Q-A1-5);A1 测 T-A1-c 断"门面越界钳制==内置越界钳制" |
| **R3** | `backInOut`/`elasticInOut` 误并入门面 → 用引擎 IN_OUT 改 gamefeel 输出(差 6.6%/17%) | 门面**刻意不含**这二者(§0.1/§2.2/Q-A1-1);A4 对其恒走内置;REPORT 须注明非 orphan/非掏空 |
| **R4** | F7:node/stub import 引擎抛错 | 工厂体只在 real 通道执行(engineMode 短路 + buildBundle 在 engineInit().then 内调 `:758`);A1 node 单测**不构造工厂、不 import 引擎**(测门面用内联引擎曲线 oracle,§6) |
| **R5** | `EngineMath.easing` 设必填致未来手构造点编译报错 | 采可选 `easing?:`(Q-A1-4) |
| **R6** | A2/A3 与 A1 同改 `makeEngineCaps` 合并冲突 | A1 留 particles/audio.synth **空骨架占位**(§2.1.3),A2/A3 串行**替换占位体**(非新增块);A1→A2→A3 串行落(政策 §4.0) |
| **R7** | easing 门面每调重建 POWER/OUT/IN_OUT 闭包(热路径开销) | 实现 lane 可工厂内预构造曲线一次(§2.1.3 性能注);功能等价,非阻断 |
---
## 6. 测试计划(掏空替代具体到测什么·本机 node --test 可跑)
> A1 改的是 host.js 工厂(浏览器侧,node 不可 import 引擎 F7)+ api.d.ts(类型)。**A1 不改任何插件 impl**,故 A1 本身**零删既有断言**(无掏空风险源在 A1)。A1 新增测覆盖"门面正确性",且**不依赖真引擎**(内联引擎曲线作 oracle / mock 注入)。
### 6.1 A1 必须新增(node --test,零引擎依赖)
| 测点 | 测什么 | 落点 | 不依赖引擎? |
|---|---|---|---|
| **T-A1-a** | `makeEngineCaps` 在 stub 通道短路返 null | 模拟 `engineMode!=='real'` → 工厂返 null(断 getEngine()==null 路径成立) | ✅(测短路,不调引擎) |
| **T-A1-b** | easing 门面 11 条曲线**逐点等价引擎 Ease** | **内联引擎 Ease 曲线**(逐字节复制 esm.js:15118,标注行号,**不 import 引擎**规避 F7)作 oracle + 门面映射逐点比;断 quadIn/quadOut 逐字节恒等、其余 9 条 ULP <1e-9;**断 backInOut/elasticInOut 不在门面**(`caps.math.easing.backInOut===undefined`) | ✅(内联曲线,非 import) |
| **T-A1-c** | easing 门面**越界钳制** == gamefeel 内置 | 门面 `quadIn(-0.5)===quadIn(0)`、`quadIn(1.5)===quadIn(1)`;且与 gamefeel `easing.quadIn` 越界行为逐值一致(import gamefeel easing 比) | ✅ |
| **T-A1-d** | easing 门面**端点恒等** f(0)=0/f(1)=1(11 条全) | 逐条断端点(与 gamefeel 测 `:44-60` 同口径,<1e-9) | ✅ |
| **T-A1-e** | lerp/smoothStep 门面口径 | 内联引擎 lerp/smoothStep 公式(F4/F5)+ 门面逐值比;断 lerp 钳 percent(`lerp(0,10,-1)===0`/`lerp(0,10,2)===10`,因引擎 lerp 自钳) | ✅ |
> **T-A1-b 的诚实纪律**:node 不能 import 引擎(F7),故"门面等价引擎"须**内联引擎 Ease 曲线源码**(逐字节复制 + 标注源 `esm.js:15118` 行号)作 oracle——这不是掏空,是把"门面映射对不对 + §0.1 字节恒等/ULP 边界 + 二条 inOut 排除"钉死的正面锚。**集成段 real 通道"门面真调引擎"的证据由门②(mini-desktop CDP `__engineCalls`)兜**(A1 不做,主会话事后)。
> **测 makeEngineCaps 的取法**:makeEngineCaps 是 host.js 内闭包(不导出)。两条取法择一:① 把 `makeEngineCaps`/`clampUnit` 抽为 host.js 模块级**可导出纯函数**(不依赖 bootHostDev 闭包变量,仅依赖传入的 `LJS`/`engineMode`)→ node 测 import 它、注入"内联引擎曲线 mock 作 LJS"调用;② 不抽取,则测**等价复刻**门面映射逻辑(测 oracle 自身)。**推荐 ①**:makeEngineCaps 的 easing 体本就只依赖 `LJS.Ease`/`LJS.lerp`/`clampUnit`,抽为 `export function buildEngineMath(LJS)` 纯函数(host.js 内 makeEngineCaps 调它)→ node 测注入 mock LJS(内联引擎 Ease 曲线)验门面映射,**既真测了门面构造逻辑、又不 import 真引擎**(F7 安全)。此抽取是 testability 收益,blast radius 仅 host.js 内部组织(不改契约)。
### 6.2 A1 不动的既有测(明示不掏空)
- `gamefeel.test.mjs` 现有 easing 性质测(端点/钳制/单调/超调/振荡 `:44-115`)**A1 不碰**(A4 才改 gamefeel;A4 保留这些 + 新增门面转发/null 退内置测,政策 §4.4)。
- core `plugin.js` 既有测(工厂收纳/lazy/转发)**A1 不碰**(plugin.js 零改)。
### 6.3 掏空判据(A1 自检)
- A1 **净新增断言、零删断言**(不改 impl/既有测)→ 天然不掏空。
- 验证命令:A1 新增测落 `host-dev/test/engine-caps.test.mjs`(或 core 下),跑 `node --test host-dev/test/*.mjs`;并跑 `node --test src/plugins/gamefeel/test/*.mjs` 确认 A1 未误伤 gamefeel 既有测(应全绿不变)。
---
## 7. 完成条件(A1)
1. `host.js`:`makeEngineCaps` 落地(real 返三面=math〔11 条 easing 门面 + lerp/smoothStep〕、stub 短路 null、particles/audio.synth 空骨架占位);`engineFactory: makeEngineCaps` 注入 `buildBundle`;`clampUnit` 到位;(推荐)`buildEngineMath(LJS)` 抽为可导出纯函数供测。
2. `api.d.ts`:`EngineEasing` 接口 + `EngineMath.easing?` 可选成员 + `EngineCapabilities` 块注释校准(null no-op 语义)。
3. `plugin.js` 零改(核对未动)。
4. A1 新增测 T-A1-a..e 全绿(`node --test`,零引擎依赖);gamefeel 既有测全绿不变。
5. open questions Q-A1-1..5 提交裁决——尤其 **Q-A1-1**(backInOut/elasticInOut 排除门面)须传达 A4:这二者恒走内置、门面无、非 orphan/非掏空。
6. **本轮交付边界**:A1 是 A2/A3 串行前置、A4 消费前置;门②/A6 real 像素门由主会话事后在 mini-desktop 驱动(本工作流不做)。

View File

@ -0,0 +1,393 @@
# A2 edit-plan — particles 扩 spec + 薄包装引擎 ParticleEmitter(删自研 sim)
> **定位**:Phase A 真接线 **A2 lane 唯一执行据**。覆盖旧稿(本文件 06-13 08:47 版「双码路·sim 留作降级」模型)——以本文件为准。
> **政策据**:`docs/agent-specs/2026-06-13-A1-A6-接线政策.md`(创始人 2026-06-13「引擎↔插件 边界模型」拍板,不可违)。本 plan 据其落地、不改裁。
> **本 plan 不改任何代码**,只产出逐文件行级落点 / 删除项 / 契约影响 / 测试计划 / 风险 / open questions。实现由后续 lane 据此执行。
> **源码断言纪律**:凡「引擎有此能力」断言,均带 `littlejs.d.ts` / `littlejs.esm.js` 行号佐证(policy 官 opus + A2 设计官已逐条 grep/sed 亲验;实现/复审须再核)。
---
## 0. 结论速览
### 0.1 一句话
`EngineEmitterSpec`(`api.d.ts:84-97`)**additive 扩可选字段**承全发射参数(对照引擎 `ParticleEmitter` 27 参,仅加 `?` 字段,绝不破已冻 6 参消费者);host.js 工厂 `particles` 体把 spec **映射到引擎 `ParticleEmitter`**(像素↔世界阻抗换算 + 句柄封装,禁裸 `Vector2`/裸对象泄漏);`impl.js` 的 `spawnEmitter` 改走 `getEngine().particles`,**删自研逐粒子积分 sim**;null-engine → no-op 句柄(不留完整 sim 降级)。**juice 全段(hitStop/屏震/闪白/脉冲)是补层,全留全测。**
### 0.2 A2 依赖与边界(来自 policy §4.0/§4.2)
- **串行依赖 A1**:A1 先在 host.js 建 `makeEngineCaps()` 工厂骨架(real 通道注入 + `math` 体);A2 在**同一工厂对象**内填 `particles` 体 → A2 必须在 A1 之后落,避免 host.js 工厂函数合并冲突。
- **与 A3 文件级可并行**(改工厂内不同键 + 不同插件 impl),但建议串行落(同改 host.js `makeEngineCaps`/`api.d.ts`)。
- **A2 改 3 个产物文件**:`host-dev/host.js`(集成段,工厂填 `particles` 体 + 核 `renderParticles` 语义)、`src/core/api.d.ts`(additive 扩 `EngineEmitterSpec`)、`src/plugins/particles-juice/impl.js`(删 sim + 薄包装)。外加 `test/particles-juice.test.mjs`(测试转向)、`PLUGIN.md`(边界声明同步)。
### 0.3 三项最硬的阻抗决策(A2 grounding 新增,policy 未逐条列;全带源码佐证)
| # | 阻抗点 | 引擎事实(佐证) | A2 裁定 |
|---|---|---|---|
| **I1** | **burst「立即喷 N 颗」无引擎原语** | 引擎 `ParticleEmitter` 靠 `emitRate × 时间窗` 发射(`esm.js:8384-8390` update 内 `emitTimeBuffer += timeDelta` 循环);`emitTime=0` → `isActive()` 恒 true(`esm.js:8482` `!this.emitTime` 为真)→ 永不自停。**无「spawn 恰好 N 颗」公开方法**(`emitParticle()` 是单颗内部 API,每帧由引擎 update 自调,不暴露受控面)。 | host 工厂把 `mode:'burst'+count:N` 映射为**短命发射器**:`emitTime = 1/60`(一逻辑帧窗)、`emitRate = N × 60`(该窗内恰发 ≈N 颗)。窗满 `isActive()` 转 false → 引擎下一拍 `destroy(true)`(`esm.js:8385-8386` 无粒子即销)。**还原度由 real 像素门兜(非 same-pixel)**;若 burst 数量明显失真,见 OQ-1 精确控法。 |
| **I2** | **untextured 粒子默认色 = WHITE→CLEAR_WHITE(淡到透明)** | `Particle.render()`(`esm.js:8658`)→ `drawTile(pos,size,tileInfo=undefined,color,...)`;`glEnable=false`(host A0 `host.js:743`)走 2D 路 `drawCanvas2D` → `context.fillStyle=color.toString(); fillRect(...)`(`esm.js:4350-4357`)。色源 = 发射器 `colorStartA/B→colorEndA/B`,**默认 `WHITE→CLEAR_WHITE`**(`esm.js:8268-8271`;CLEAR_WHITE alpha=0)→ 粒子从白淡到透明,黑底上近不可见。 | **spec 必须承载颜色**(additive 扩 `colorStart?`/`colorEnd?`,归一化 `{r,g,b,a}` 0..1);host 映射到引擎 `Color`(`LJS.rgb(r,g,b,a)`,`esm.js:2163`)。缺省给可见亮色(不透明白 `colorStart={1,1,1,1}` / `colorEnd={1,1,1,0}`),**保 real 像素门「有色像素命中>0」**(policy §5.1-③ / `gate0-engine-takeover.cjs:94-122`)。⚠️ host `renderParticles` 现 `fillStyle='#7fd0ff'`(`host.js:353`)对引擎粒子**无效**(引擎粒子自带色),须随 §2.2-B 一并核。 |
| **I3** | **speed 单位 = 世界单位/帧@60fps(非 px/sec)** | 引擎构造 `speed` 注释(`d.ts:3270` / `esm.js:8253`):「in world units per frame (at 60fps, so multiply units/sec by 1/60)」。`emitParticle()` 速度 `vec2(speed*sin, speed*cos)`(`esm.js:8454`),无再乘 dt。 | host 阻抗换算:spec 的 `speed`(**像素/秒**,与现 `EmitterConfig.speedMin/Max` 同口径,`api.d.ts:73-75`)→ 引擎 `speed = (px/sec) ÷ cameraScale ÷ 60`。`cameraScale` 默认 32(`esm.js:2785`);A1/A2 须确认 host 是否 `setCameraScale`(OQ-2)。**位置同理**:spec `pos` 像素 → 引擎世界坐标经 `LJS.screenToWorld`(`esm.js:4990`)。 |
---
## 1. 引擎能力对照(EngineEmitterSpec 扩展依据 · 全带源码行号)
> 目标:`EngineEmitterSpec` 仅暴 6 参(`api.d.ts:84-97`:pos/angle?/emitSize?/emitTime?/emitRate?/particleTime?),**不足以渲染现有 `EmitterConfig` 全配置**(speed/life/gravity/drag/spread/sizeCurve/alphaCurve,见 `particles-juice/api.d.ts:61-88`)。下表逐字段比对引擎 `ParticleEmitter`(`d.ts:3262` 构造 27 参 / `esm.js:8258-8287` 默认值)确认「引擎真有此能力」,据此 additive 扩字段。
| 现 `EmitterConfig` 字段 | 引擎对应参数(行号佐证) | 扩 `EngineEmitterSpec` 字段(全 `?` 可选) | host 映射要点 |
|---|---|---|---|
| `angle`(锥中心向) | `angle`(`d.ts:3264`;已在 spec `angle?`) | 已有 | 直传(弧度;引擎 `velocityAngle = this.angle + coneAngle`,`esm.js:8448`) |
| `spread`(半锥角) | `emitConeAngle`(`d.ts:3268`,默认 `PI` `esm.js:8263`;`coneAngle = rand(emitConeAngle,-emitConeAngle)` `esm.js:8444`) | **+ `coneAngle?`** | spec `spread`(半锥角)→ 引擎 `emitConeAngle`(引擎在 ±cone 内均匀采样,语义等价半锥角)。 |
| `speedMin/speedMax` | `speed`(`d.ts:3273`,单位/帧@60;`randomness` 抖动 `esm.js:8443`) | **+ `speed?`**(标量,px/sec) | I3 换算 `÷cameraScale÷60`;min/max 区间→引擎单 `speed`(区间不可精确表达,取中值,OQ-3)。 |
| `lifeMin/lifeMax` | `particleTime`(`d.ts:3271`;已在 spec `particleTime?`;`randomness` 抖动) | 已有 `particleTime?` | 同 speed:区间→单 `particleTime`(取中值,OQ-3)。 |
| `gravity` | `gravityScale`(`d.ts:3284`;粒子受全局 `gravity*gravityScale` `esm.js:3631`) | **+ `gravityScale?`** | spec `gravity` → 引擎 `gravityScale`;依赖 host 全局 `gravity`(默认 0 → gravityScale 无效,OQ-4)。 |
| `drag` | `damping`(`d.ts:3275`,每帧速度乘子,1=无阻 `esm.js:8325`) | **+ `damping?`** | spec `drag`(每秒线性阻力)→ 引擎 `damping = 1 - drag/60`(每帧乘子近似)。 |
| `sizeCurve`(尺寸随生命) | `sizeStart`/`sizeEnd`(`d.ts:3272` 两端点,引擎线性插值 `radius = p2*sizeStart + p1*sizeEnd` `esm.js:8669`) | **+ `sizeStart?`/`sizeEnd?`** | 曲线两端点→引擎两端点(引擎仅线性插值,非任意曲线;非线性段降级为端点,OQ-5)。尺寸像素→世界 `÷cameraScale`。 |
| `alphaCurve`(透明随生命) | `colorStartA.a`/`colorEndA.a` + `fadeRate`(色 alpha 端点 + `esm.js:8676` alpha 线性插值 + `esm.js:8669-8672` fade) | **+ `colorStart?`/`colorEnd?`**(含 a) | 透明度并入颜色 a 通道(I2)。 |
| `count`(burst 颗数) | 无直接参数(I1:靠 emitRate×emitTime) | **+ `count?`** | host 据 count 算 `emitRate=count×60`、`emitTime=1/60`(I1)。 |
| `mode`(burst/continuous) | `emitTime`(`d.ts:3266`:0=forever=continuous;>0=有限窗;已在 spec `emitTime?`) | 已有 `emitTime?` + 上述 `count?` | continuous→`emitTime=0`+`emitRate=rate`;burst→`emitTime=1/60`+`emitRate=count×60`。 |
| `emitSize`(发射尺寸,现 `EmitterConfig` **无**此字段) | `emitSize`(`d.ts:3265`;已在 spec `emitSize?`) | 已有 | 像素→世界 `÷cameraScale`;现预设点发射,缺省 0。 |
| `additive`(叠加混合) | `additive`(`d.ts:3286`,默认 false `esm.js:8273`) | **+ `additive?`** | 可选,默认 false;juice 闪白不经此(juice 是补层)。 |
> **结论**:扩 10 个可选字段足以承载现有 `EmitterConfig` 全配置(部分区间/任意曲线语义有近似损失,记 OQ-3/OQ-5,由 real 像素门兜还原度)。**所有扩字段引擎均有对应参数**(上表行号),无凭空发明。
---
## 2. 逐文件改动点(行级落点)
### 2.1 `src/core/api.d.ts` —— additive 扩 `EngineEmitterSpec`(契约,仅加可选字段)
**落点**:`EngineEmitterSpec`(当前 `api.d.ts:84-97`),在已冻 6 参后**追加**可选字段。已冻 6 参(`pos`/`angle?`/`emitSize?`/`emitTime?`/`emitRate?`/`particleTime?`)**字面与语义零改动**。
新增字段(全 `?`,均带「引擎对应参数 + 行号」注释,附像素↔世界口径说明):
```ts
// —— 以下为 A2 additive 扩字段(承全发射参数;引擎 ParticleEmitter 行号见注释;全可选,不破已冻 6 参消费者)——
/** burst 一次性喷发颗数(host 据此算 emitRate=count×60 + emitTime=1/60;引擎无「spawn 恰好 N」原语,esm:8384-8390)。 */
count?: number;
/** 半锥角(弧度)→ 引擎 emitConeAngle(d.ts:3268,默认 PI)。 */
coneAngle?: number;
/** 初速(像素/秒)→ 引擎 speed(d.ts:3273,单位=世界/帧@60;host 换算 ÷cameraScale÷60)。 */
speed?: number;
/** 重力(像素/秒²)→ 引擎 gravityScale(d.ts:3284;依赖 host 全局 gravity,见 plan OQ-4)。 */
gravityScale?: number;
/** 每帧速度阻尼乘子 1=无阻(d.ts:3275 damping;host 由 drag 换算 1-drag/60)。 */
damping?: number;
/** 粒子起始尺寸(像素,host ÷cameraScale)→ 引擎 sizeStart(d.ts:3272)。 */
sizeStart?: number;
/** 粒子结束尺寸(像素,host ÷cameraScale)→ 引擎 sizeEnd(d.ts:3272,引擎仅两端点线性插值)。 */
sizeEnd?: number;
/** 起始颜色(归一化 {r,g,b,a} 0..1)→ 引擎 colorStartA(缺省不透明白,保像素门可见,见 plan I2)。 */
colorStart?: { r: number; g: number; b: number; a: number };
/** 结束颜色(归一化 {r,g,b,a} 0..1)→ 引擎 colorEndA(缺省 a=0 淡出)。 */
colorEnd?: { r: number; g: number; b: number; a: number };
/** 是否叠加混合 → 引擎 additive(d.ts:3286,默认 false)。 */
additive?: boolean;
```
**契约影响**:见 §3(additive、不破消费者,附机读判据)。
> **不在 spec 暴露**:引擎裸 `Vector2`/`Color`/`TileInfo`(铁律 `api.d.ts:57` 第1条:禁裸 Vector2 泄漏)。颜色用归一化 POJO `{r,g,b,a}`,与 `Vec2`(`api.d.ts:70-73`)同范式(POJO 非引擎实例)。`tileInfo`/`localSpace`/`collideTiles`/`angleSpeed`/`renderOrder`/`fadeRate` 等引擎参**不暴露**(YAGNI,现 `EmitterConfig` 无对应需求;铁律 `api.d.ts:61` 白名单最小完整)。
### 2.2 `host-dev/host.js` —— `makeEngineCaps()` 填 `particles` 体 + 核 `renderParticles` 语义
**落点 A(工厂填体,依赖 A1 已建 `makeEngineCaps`)**:A1 在 `buildBundle`(`host.js:215-221`,现 `createHostDevContext` 调用**未传** `engineFactory` → `getEngine()` 两通道皆 null)附近新建 `makeEngineCaps()` 并经 `engineFactory` 传入 `createHostDevContext`(real 通道;stub 通道仍不传,保 null-engine 回归)。A2 在该工厂对象的 `particles` 键填实现体:
```js
// makeEngineCaps() 内(A1 建骨架,A2 填 particles 体)。host 已 import * as LJS(host.js:44)。
particles: {
/**
* 受控面 spawnEmitter:EngineEmitterSpec(像素口径)→ 引擎 ParticleEmitter(世界口径),返封装句柄。
* @param {import('../src/core/api.d.ts').EngineEmitterSpec} spec 归一化发射参数(像素/中性形态)
* @returns {import('../src/core/api.d.ts').EngineEmitterHandle} 封装句柄(禁泄漏引擎对象)
*/
spawnEmitter(spec) {
// 1) 像素↔世界阻抗换算(cameraScale 默认 32 esm:2785;I3)。
const worldPos = LJS.screenToWorld(LJS.vec2(spec.pos.x, spec.pos.y)); // 像素→世界(esm:4990)
const SCALE = LJS.cameraScale; // 世界单位/像素
// 2) burst/continuous → emitTime/emitRate(I1)。
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 颗/秒
// 3) speed 像素/秒 → 世界/帧@60(I3,引擎单位来源 esm:8253)。
const engSpeed = (spec.speed != null ? spec.speed : 0) / SCALE / 60;
// 4) drag 每秒 → damping 每帧乘子(spec 已折算,这里直透)。
const engDamping = spec.damping != null ? spec.damping : 1;
// 5) 颜色:归一化 {r,g,b,a} → 引擎 Color(esm:2163;缺省不透明白→透明,保像素门可见,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) 构造引擎发射器(EngineObject 自入 engineObjects,引擎主循环自驱 update/render,esm:3580/8384/8654)。
const em = new LJS.ParticleEmitter(
worldPos,
spec.angle != null ? spec.angle : 0,
(spec.emitSize != null ? spec.emitSize : 0) / SCALE, // emitSize 像素→世界
emitTime,
emitRate,
spec.coneAngle != null ? spec.coneAngle : Math.PI, // emitConeAngle
undefined, // tileInfo: untextured(2D fillRect 路 esm:4350)
colorStart, colorStart, colorEnd, colorEnd, // colorStartA/B + colorEndA/B(A/B 同值=不随机色)
spec.particleTime != null ? spec.particleTime : 0.5,
(spec.sizeStart != null ? spec.sizeStart : 6) / SCALE, // sizeStart 像素→世界
(spec.sizeEnd != null ? spec.sizeEnd : 0) / SCALE, // sizeEnd 像素→世界
engSpeed,
0, // angleSpeed: 0(无玩法语义,不旋转)
engDamping, // damping
1, // angleDamping
spec.gravityScale != null ? spec.gravityScale : 0, // gravityScale(OQ-4)
Math.PI, // particleConeAngle(粒子自转锥,留默认)
0.1, // fadeRate(默认)
0, // randomness: 0(spec 已显式给参,关引擎额外抖动求确定)
false, // collideTiles
spec.additive != null ? spec.additive : false, // additive
true // randomColorLinear(A/B 同值时无影响)
);
// 7) 封装句柄(禁泄漏引擎对象;isActive/stop 转发,stop 幂等销毁)。
return {
isActive() { return em.isActive(); }, // esm:8482
stop() { if (!em.destroyed) em.destroy(true); }, // esm:8487 幂等(已 destroyed 直返)
};
},
},
```
> **关键说明**:
> - **引擎自渲**:`new ParticleEmitter` 经 `EngineObject` 构造自入 `engineObjects`(`esm.js:3580`),引擎主循环 `engineObjectsUpdate`(`esm.js:472`)每帧调其 `update()`(发射 + 积分,`esm.js:8348`),render 段(`esm.js:296-297`)调 `render()`→`particle.render()`→`drawTile`(`esm.js:8654/8658`)落 `mainContext`。**host 无需手动 add/render 引擎粒子**。
> - **`randomness=0`**:spec 已显式给参,关引擎默认 `.2` 额外抖动(`esm.js:8281`),求「同 spec→同视觉」最大确定(虽引擎仍用全局 `rand()` 做锥角采样,policy §5.3 已认引擎粒子不可种子对齐,这里只减少非必要随机)。
> - **A/B 色同值**:引擎 `colorStartA/B` 是「在 A、B 间随机取色」(`randColor` `esm.js:8446`);A2 不需随机色,A=B 即固定色。
**落点 B(核 `renderParticles` 语义,policy §2.4)**:`renderParticles`(`host.js:343-357`)现把 `pj.render(g)`(画**本地 sim 粒子** + juice 闪白叠加层)画进 `mainContext`,并提供 `fillStyle='#7fd0ff'`(`host.js:353`)。sim 删除后:
- **引擎粒子由引擎自渲**(落点 A 说明)→ host `renderParticles` 调 `pj.render(g)` 时,插件 `render()` 已瘦身为「只画 juice 闪白叠加层」(§2.3 落点 C),不再画粒子。
- `fillStyle='#7fd0ff'`(`host.js:353`)原为「给无内置色的本地粒子上色」——引擎粒子自带色(I2),此行对引擎粒子**无效**;juice 闪白叠加层用自己的 `#ffffff`(`impl.js:455`),也不读该 fillStyle。**裁定**:改注释保留(最小改动,不动 save/restore 结构)或删除——A2 取**改注释保留**(OQ-7),注释改为「为 juice 叠加层兜底可见色(叠加层自设 fillStyle,此为防御)」。
- `setTransform(DPR)` + `imageSmoothingEnabled=false`(`host.js:350-351`)针对 juice 叠加层逻辑像素绘制仍需,**保留不动**。
- **同步核**:`host.js:586` 注释「屏幕像素由引擎下一拍 gameRender→renderParticles 落到 mainContext」——粒子落 mainContext 现由引擎 render 段完成(在 gameRender **之前**),gameRender/renderParticles 只剩 juice 叠加层。A2 须核对该注释一致。
> **A2 不改 host 的 evidence/spawn 取证入口本身**(A6/门② 域,本工作流不做);只改工厂 `particles` 体 + `renderParticles` 注释语义。
### 2.3 `src/plugins/particles-juice/impl.js` —— 删 sim + 薄包装 + render 瘦身
#### 落点 A:删自研 sim(policy 删表 §8)
| 删除项 | 当前行 | 说明 |
|---|---|---|
| `function spawnOne(em)` | `impl.js:241-265` | 本地逐粒子生成(随机走 rng)→ 引擎接管,删。 |
| `function stepEmitter(em, dt)` | `impl.js:272-284` | 本地发射器步进(rate 累加)→ 引擎 update 接管,删。 |
| `function integrateParticles(dt)` | `impl.js:290-316` | 本地物理积分(重力/阻力/位置)→ 引擎接管,删。 |
| 环境场变量 `envGravity`/`envDrag`/`_globalSizeCurve`/`_globalAlphaCurve` | `impl.js:322-329` | sim 全局环境近似 → 随 sim 删(policy N7)。 |
| `function _sizeCurveOf`/`_alphaCurveOf` | `impl.js:331-333` | sim 曲线取值 → 删。 |
| `function refreshEnv()` | `impl.js:336-347` | sim 环境聚合 → 删。 |
| `function step(dt)` 内**粒子段** | `impl.js:417-433`:`refreshEnv()`(:419)+ 发射器循环(:421-423)+ 清理(:425-428)+ `integrateParticles(dt)`(:430) | 仅保留 `steps += 1`(:418)+ `stepJuice(dt)`(:432)。粒子/发射器步进段删。 |
| 模块内状态 `particles`/`emitters`/`emitterIdSeq` | `impl.js:198-201` | 本地粒子池/发射器表 → 引擎接管,删(连带 `MAX_PARTICLES`/`dropped` 背压,:187/203)。⚠️ `emitterIdSeq` 保留(仍作句柄登记键,见落点 B)。 |
> **`step(dt)` 删后形态**:`function step(dt){ steps += 1; stepJuice(dt); }`——juice 仍每帧推进(补层)。`autoStep` 帧回调(`impl.js:483-487`)保留(驱动 juice 步进)。
#### 落点 B:`spawnEmitter` 改薄包装(`impl.js:519-543`)
新增模块内闭包状态(替代旧 `particles`/`emitters`):
```js
// init 时取一次引擎粒子能力(缓存,禁每帧调 getEngine;policy「init 取一次」)。
// 在 init(ctx)(impl.js:475-488)内加:
// _engParticles = ctx.getEngine()?.particles || null;
let _engParticles = null;
/** @type {Map<number, import('../../core/api.d.ts').EngineEmitterHandle>} 活动句柄表(key=自增 id,替代旧 emitters)。 */
const _engHandles = new Map();
/** null-engine no-op 句柄(particleCount 恒 0,不喷不渲;policy §3)。 */
const NOOP_HANDLE = { isActive() { return false; }, stop() {} };
```
`spawnEmitter` 改:
```js
spawnEmitter(configOrPreset, x, y, overrides) {
// 解析配置(保留:预设深拷贝 / 内联深拷贝 / overrides 合并,impl.js:520-530 逻辑不变)。
let cfg = typeof configOrPreset === 'string' ? getPreset(configOrPreset) : deepCloneConfig(configOrPreset);
if (!cfg) return -1; // 未知预设名容错(语义不变,返 -1)
if (overrides) cfg = Object.assign(cfg, overrides);
const id = emitterIdSeq++;
// null-engine:登记 no-op 句柄(policy §3,禁回退 sim)。
if (!_engParticles) {
_engHandles.set(id, NOOP_HANDLE);
return id;
}
// 有引擎:EmitterConfig → EngineEmitterSpec 纯映射,调引擎能力面,登记句柄。
const handle = _engParticles.spawnEmitter(toEngineSpec(cfg, x, y));
_engHandles.set(id, handle);
return id;
}
```
新增**纯函数** `toEngineSpec(cfg, x, y)`(impl.js 模块级,node 可单测、无需引擎):
```js
/**
* EmitterConfig(插件像素口径)→ EngineEmitterSpec(受控面像素中性形态)纯映射。
* 纯函数:无随机、无引擎、无副作用 → node 单测直断(policy §5.1-①)。
* 阻抗换算(px→world、sec→frame)在 host 工厂做;本函数只做「插件语义→受控面 spec」结构映射。
* @param {EmitterConfig} cfg
* @param {number} x 像素
* @param {number} y 像素
* @returns {import('../../core/api.d.ts').EngineEmitterSpec}
*/
function toEngineSpec(cfg, x, y) {
return {
pos: { x, y },
angle: cfg.angle,
coneAngle: cfg.spread, // 半锥角
emitSize: 0, // 现 EmitterConfig 无 emitSize 字段,点发射
emitTime: cfg.mode === 'continuous' ? 0 : 1 / 60, // continuous=forever / burst=1帧窗(I1)
emitRate: cfg.mode === 'continuous' ? (cfg.rate || 0) : undefined,
count: cfg.mode === 'burst' ? (cfg.count || 0) : undefined,
particleTime: (cfg.lifeMin + cfg.lifeMax) / 2, // 区间中值(OQ-3)
speed: (cfg.speedMin + cfg.speedMax) / 2, // 区间中值(OQ-3)
gravityScale: cfg.gravity || 0, // 透传(host 据全局重力解释,OQ-4)
damping: cfg.drag != null ? (1 - cfg.drag / 60) : 1,
sizeStart: evalCurve(cfg.sizeCurve, 0), // 曲线起点(纯函数 evalCurve)
sizeEnd: evalCurve(cfg.sizeCurve, 1), // 曲线终点(引擎仅两端点线性,OQ-5)
colorStart: { r: 1, g: 1, b: 1, a: evalCurve(cfg.alphaCurve, 0) }, // 中性白 + alpha 端点(I2)
colorEnd: { r: 1, g: 1, b: 1, a: evalCurve(cfg.alphaCurve, 1) },
additive: false,
};
}
```
> **`toEngineSpec` 保留消费 `evalCurve`**:`evalCurve`(`impl.js:61-86`)是纯函数曲线求值,**不删**(既是 spec 映射依赖,也是 sizeCurve/alphaCurve 端点提取器)。`PRESETS`/`getPreset`/`deepCloneConfig`(`impl.js:99-168`)**全保留**(参数包仍是 spec 来源)。
#### 落点 C:`stopEmitter` / `particleCount` / `emitterCount` / `render` 调整
- `stopEmitter(id)`(`impl.js:549-552`):改查 `_engHandles.get(id)?.stop()`(转发引擎句柄 stop;幂等,未知 id 静默)。
- `particleCount()`(`impl.js:565-567`):引擎接管后插件无本地池 → **恒返 0**(policy §3「本地池已废」语义)。⚠️ 行为变更,见 §5 风险 R-2。
- `emitterCount()`(`impl.js:570-572`):返 `_engHandles` 中 `isActive()` 为 true 的句柄数(按活性过滤,反映真实活动发射器)。
- `render(ctx2d)`(`impl.js:441-463`):**瘦身**——删粒子逐颗 arc/fill 段(:444-451,引擎自渲粒子);**保留**闪白叠加层段(:452-460,juice 补层输出)+ alpha 复位(:462)。`render` 改为「只画 juice 闪白叠加」。
- `step(dt)`(对外 `impl.js:555-557` + 内部 `:417`):见落点 A,内部只剩 juice。
- `dispose()`(`impl.js:493-507`):清理改为 `_engHandles.forEach(h=>h.stop()); _engHandles.clear();`(替代旧 `particles.length=0; emitters.clear()`,:499-500),其余 juice 复位(:501-505)不变。
- `probe()`(`impl.js:666-674`):`particles` 字段(:668)改 0(无本地池);`emitters` 改 `_engHandles.size`(:669);`dropped`(:670)随背压删→恒 0。⚠️ 与 R-2 同源。
#### 落点 D:头注释 + 确定性铁律注释同步
- 文件头「粒子系统:发射器 + 纯数据驱动更新 + 经受控 2D 绘制」(`impl.js:8-12`)→ 改为「粒子系统:经 `getEngine().particles` 薄包装引擎 `ParticleEmitter`(引擎自渲);juice 套件 = 补层」。
- 「确定性铁律」(`impl.js:15-17`「同 seed + 同 tick → 粒子状态逐字段复现」)→ 改为「juice 计时/衰减确定性走 ctx.time/帧 dt;**引擎粒子用引擎全局随机,不保插件种子复现**(边界模型裁定,见 policy §5.3)」。
- 「两层测试纪律」(`impl.js:18-21`)→ 改为「node 层测 `toEngineSpec` 纯映射 + mock 引擎注入 + null-engine no-op + juice 确定性;真渲出现归 mini-desktop real 像素门」。
- 「不 import littlejsengine」(`impl.js:24-25`)→ **保持**(仍经 `ctx.getEngine()`,零直接 import;policy N1/N2 守住)。impl.js `import littlejsengine` **零新增**(Q4 confinement 守住)。
### 2.4 `src/plugins/particles-juice/PLUGIN.md` —— 边界声明同步
- 「渲染验证边界声明(两层测试)」(`PLUGIN.md:90`)→ 补「粒子真身在引擎对象列表,引擎自渲;插件 `render()` 仅画 juice 闪白叠加层」。
- 测试覆盖列表(`PLUGIN.md:76`)→ 删「确定性(同 seed 逐字段复现)」「render 调用面 arc/fill 次数=粒子数」;加「`toEngineSpec` 纯映射 / mock 引擎注入 / null-engine no-op」。
- juice 段(`PLUGIN.md:13-18` 等)**不动**(补层全留)。
### 2.5 `manifest.json` —— 不改
`exports.named`(`createParticlesJuicePlugin/evalCurve/getPreset/PRESETS`)**不变**(`toEngineSpec` 是模块内私有,不导出)。`gzBudgetBytes:10240` 不变(删 sim 体积只减不增)。
---
## 3. 契约影响(api.d.ts 改动 · 明示 additive / 不破消费者)
| 维度 | 结论 | 机读判据(对齐 policy N14) |
|---|---|---|
| **改动性质** | **纯 additive**:仅在 `EngineEmitterSpec`(`api.d.ts:84-97`)已冻 6 参后**追加 10 个可选字段**(全带 `?`)。 | 已冻 6 参(`pos`/`angle?`/`emitSize?`/`emitTime?`/`emitRate?`/`particleTime?`)字面+语义零改;新增字段全 `?`。 |
| **已冻消费者** | **零感知**:`EngineEmitterSpec` 当前唯一消费者 = host 工厂 `particles.spawnEmitter`(A2 才填体,今日尚无消费者,policy §0.2「插件现零 getEngine 调用」)+ `EngineParticles.spawnEmitter`(`api.d.ts:106`)签名不变。可选字段缺省 = 旧行为(host 给默认值)。 | `EngineParticles`/`EngineEmitterHandle`/`EngineCapabilities`(`api.d.ts:100-144`)形状**不动**。 |
| **TS 兼容** | additive 可选字段对结构类型 = 协变扩展,不破现有 `EngineEmitterSpec` 字面量(旧字面量仍合法,新字段 optional)。 | 人核:旧 6 参字面量在新接口下仍类型通过。 |
| **跨端单一事实源** | `EngineEmitterSpec` 仅在 `core/api.d.ts` 定义;`particles-juice/api.d.ts` 经 `import type` 复用 `PluginContext`(`particles-juice/api.d.ts:19`),**不重复定义 EngineEmitterSpec** → 改一处即全端同步。 | grep `EngineEmitterSpec` 定义点唯一(仅 core/api.d.ts)。 |
> **注释纠偏(additive-外,A2 须做,不改任何签名)**:
> - `api.d.ts:233` `PluginContext.getEngine` 降级注释「粒子退受控面自管 / 合成核退 vendored / 数学退插件内置纯函数」是**旧模型措辞**(policy §1.4 已废「降级实现体」)。A2 须把「粒子退受控面自管」改为「粒子→no-op(不留 sim 降级)」,与 policy §3 一致(否则契约注释与裁定漂移)。
> - `api.d.ts:59-60` getEngine 透传面注释「粒子退受控面自管」(铁律 3)同源,一并核(A2 改粒子相关措辞;合成核/数学措辞归 A3/A1,A2 不动)。
> - `api.d.ts:113-114/117/120/140` 的「插件回退 vendored」「A2 据 SIZES 定」是 **A3 audio 域**措辞,**A2 不动**。
---
## 4. 测试计划(掏空替代方案 · 具体到测什么)
> **铁律(policy §5.1 掏空判据)**:删除的断言数 ≤ 新增的等价断言数,且补层断言总数不减。
> **node 环境关键约束(A2 设计官亲验)**:`node -e "import('littlejsengine')"` → **`window is not defined`**(dist 模块加载即引用 window)。故 **node 测绝不能 import 真引擎**;引擎包装的验证一律用 **mock `EngineCapabilities`**,真引擎验证归 mini-desktop real 像素门(门②/A6,本工作流不做)。
### 4.1 删除的断言(被测对象已随 sim 退役)
| 现测(行) | 处置 | 理由 |
|---|---|---|
| 「粒子数守恒:burst 喷 24 颗 / decay 归零」(`test:100-121`) | **删** | 本地粒子池删,`particleCount()` 恒 0;粒子守恒由引擎管。 |
| 「生命周期边界:age 跨 life 回收」(`test:123-135`) | **删** | 本地积分删,引擎管生命。 |
| 「确定性:同 seed 逐字段复现」(`test:139-179`,含 `snapshotParticles`/`round6`) | **删** | policy §5.3:引擎粒子用全局 rand 不可种子对齐,sim 退役→被测对象不存在(**非掏空**,policy §5.1-① 明示)。 |
| 「render 调用面:arc/fill 次数=粒子数」(`test:317-332`,arc/fill 断言段) | **删** | 插件不再画粒子(引擎自渲);arc/fill 不再由粒子产生。**保留**闪白 fillRect 断言(见 4.3)。 |
### 4.2 新增等价断言(补回,缺一即掏空)
| 新测 | 测什么 | 不依赖引擎? |
|---|---|---|
| **`toEngineSpec` 纯映射单测** | 给定各预设 `EmitterConfig`(burst/trail/drift),逐字段断 `toEngineSpec` 输出:burst→`emitTime=1/60`+`count`+`emitRate=undefined`;continuous→`emitTime=0`+`emitRate=rate`+`count=undefined`;`coneAngle===spread`;`particleTime===(lifeMin+lifeMax)/2`;`speed===(speedMin+speedMax)/2`;`damping===1-drag/60`;`sizeStart/End===evalCurve 端点`;`colorStart/End.a===alphaCurve 端点`。**逐字段断言**(继承旧「逐字段复现」的确定性价值,policy §5.1-① / §5.3-2)。 | ✅ 纯函数,node 直跑 |
| **mock 引擎注入测** | 构造 fake `ctx`(`getEngine()` 返 mock `{particles:{spawnEmitter:spy}}`,其余受控面同 host-dev),init 后 `spawnEmitter('burst',x,y)` → 断 spy 被调 1 次、入参 `deepEqual` `toEngineSpec(burstCfg深拷贝,x,y)`、返回 id 有效、`stopEmitter(id)` 转发 mock handle.stop(spy)。 | ✅ mock,不引真引擎 |
| **null-engine no-op 测** | 缺省 host-dev(无 engineFactory→`getEngine()` 返 null)→ `spawnEmitter` 返有效 id 不抛、`particleCount()===0`、`emitterCount()===0`、`stopEmitter` 不抛、`render(stub)` 无 arc 调用(不画粒子)、`dispose` 不抛。 | ✅ 现 createHostDevContext 缺省即 null-engine |
| **`toEngineSpec` 颜色可见性** | 断三预设映射后颜色端点:至少一端 `a>0`(保 real 像素门「有色像素命中>0」前提,I2)——burst alpha 1→0(起点可见)、trail/drift 同理起点 a>0。防全程全透明。 | ✅ 纯函数 |
### 4.3 补层断言全保留(juice/曲线/预设 = 真缺件,policy §5.1-②,一条不删)
| 保留测(行) | 内容 |
|---|---|
| 曲线求值(`test:59-96`) | `evalCurve` 端点恒等 + 单调 + 钳制(`evalCurve` 不删,仍 spec 映射依赖)。 |
| hit-stop 时序(`test:183-209`) | juice 补层,全留。 |
| 屏震衰减(`test:213-240`) | juice 补层,全留。 |
| 闪白/脉冲(`test:244-276`) | juice 补层,全留。 |
| **闪白 fillRect 调用面**(`test:333-340`,从 `:317` 测拆出保留段) | `render(stub)` 闪白期一次全屏 `fillRect:0,0,390,844`——juice 叠加层(保留,是「插件 render 仍画 juice」正面锚)。 |
| 无 canvas 降级(`test:342-349`) | `render()` 无 ctx2d 不抛(保留)。 |
| 预设 schema 自洽 + 命名中性 + 深拷贝(`test:280-313`) | `PRESETS`/`getPreset`/`deepCloneConfig` 不删,全留。 |
| 全流程接纳 + dispose 幂等(`test:353-386`) | register/init/dispose 生命周期(保留);其中 `particleCount` 断言(:379/382)改为「恒 0」语义一致(dispose 后仍 0)。 |
> **掏空机读自检(policy §5.1 判据)**:删 4 组断言(守恒/边界/同 seed/render-arc)↔ 补 4 组(toEngineSpec 映射/mock 注入/null no-op/颜色可见)+ juice/曲线/预设补层断言**零减**。删 ≤ 补,补层不减 → 不掏空。
### 4.4 门③负扫(A2 相关条,policy §6,本机 grep/ls 可跑)
| # | 断言 | 期望 |
|---|---|---|
| N1 | `grep -rn "from 'littlejsengine'" src/plugins/ --include=*.js` | 0 命中 |
| N6 | `grep -n "function integrateParticles\|function stepEmitter\|function spawnOne\|function refreshEnv" impl.js` | 0 命中 |
| N7 | `grep -n "envGravity\|envDrag\|_globalSizeCurve\|_sizeCurveOf" impl.js` | 0 命中 |
| N8 | `grep -n "getEngine()" impl.js` | ≥1 命中(init 取 particles) |
| N12 | `grep -n "stepJuice\|shakeScreen\|hitStop" impl.js` | ≥1 命中(juice 补层在) |
| N14 | 人核 `api.d.ts:84-97` 已冻 6 参字面不变 + 新字段全 `?` | 通过 |
> 真渲出现(real render appears)= mini-desktop real 像素门兜底(`?engine=real&mode=evidence` → spawn → 引擎 RAF 渲 → 抓 canvas 断非空+有色像素命中>0+无水印,`gate0-engine-takeover.cjs:94-122`)。**本工作流不做(主会话 mini-desktop 驱动,policy §7)。**
---
## 5. 风险
| # | 风险 | 影响 | 缓解 |
|---|---|---|---|
| **R-1** | **burst 数量失真**(I1:emitRate×emitTime 非精确 N;引擎 `emitTimeBuffer` 浮点累加 + 全局 `particleEmitRateScale` 缩放 `esm.js:8388`) | burst 实发颗数可能 ≠ count(±1~2 或受全局 scale 影响) | spec 留 `count` 开口子;还原度由 real 像素门兜(非 same-pixel,policy §5.1-③);明显失真→OQ-1 精确控法。**确认 host 未改 `particleEmitRateScale`(默认 1 `esm.js:2972`)**。 |
| **R-2** | **`particleCount()`/`probe().particles` 恒返 0 是行为变更** | 现有消费者(游戏层/probe)读到 0;测试 `test:108/119/134/379/382` 依赖它 | policy §3 明示「本地池已废,恒 0」。impl 现唯一消费 = 测试与 probe。**搜全仓 particleCount 消费点确认无生产依赖**(OQ-6);测试按 4.1/4.3 改。 |
| **R-3** | **任意曲线 → 引擎两端点线性插值的语义损失**(OQ-5:sizeCurve 的 decay/easeInOut 非线性段被拍平为线性) | 粒子尺寸/透明视觉曲线变直(非崩溃,仅观感) | 受控面只暴端点是引擎能力边界(引擎 `sizeStart/sizeEnd` 仅两端 `esm.js:8669`);非线性曲线属「引擎无」→ 若某玩法强依赖,按补层自研(YAGNI,MVP 不预造,policy §5.3-2 精神)。real 像素门验「有粒子+主色」即可,不验曲线形状。 |
| **R-4** | **speed/gravity 阻抗换算口径错**(OQ-2/OQ-4:cameraScale 未知、全局重力默认 0 致 gravityScale 无效) | 粒子飞太快/太慢、重力不生效 | A1/A2 落地前确认 host `cameraScale`(OQ-2)与全局 `gravity`(OQ-4);real 像素门下「粒子是否在合理范围内可见」是最终裁判(数量级错→粒子飞出画布→命中像素 0→门挂)。 |
| **R-5** | **A1 工厂骨架未就绪即落 A2**(串行依赖破坏) | host.js `makeEngineCaps` 不存在,A2 无处填 particles 体 → 合并冲突/编译错 | 严守 A1→A2 串行(policy §4.0);A2 实现前先 grep 确认 host.js 已有 `makeEngineCaps()`。 |
| **R-6** | **引擎粒子坐标系 vs host renderParticles 的 DPR/transform** | 引擎自渲粒子走引擎 `drawCanvas2D`(自带世界→屏幕变换),与 host `renderParticles` 的 `setTransform(DPR)`(`host.js:350`,针对 juice 叠加层逻辑像素)是**两套坐标系** | 引擎粒子由引擎 render 段画(gameRender **之前** `esm.js:296`),不经 host `setTransform`;host `renderParticles` 的 transform 只作用其内 `pj.render(g)`(juice 叠加层)。两者时序隔离 → 不冲突。**A2 须在 real 像素门确认引擎粒子位置正确**(screenToWorld 换算对)。 |
---
## 6. Open Questions(落地前须澄清;标归属)
| # | 问题 | 影响面 | 归属/建议 |
|---|---|---|---|
| **OQ-1** | burst 是否需精确 `count` 控?现 `emitRate×emitTime` 近似。引擎 `emitParticle()`(`esm.js:8420`)是单颗 API——可否在句柄封装层「构造后立即手调 N 次 `em.emitParticle()` 再设 `emitTime=0`(或 `emitRate=0`)」精确发 N? | burst 还原度 | A2 lane + 主会话裁(policy §7 待办4)。**建议**:先近似 + real 像素门兜;像素门发现 burst 失真再上「手调 emitParticle×N」精确法(注意 emitParticle 仍走全局 rand,位置不可种子复现,但数量精确)。 |
| **OQ-2** | host 是否 `setCameraScale`?默认 `cameraScale=32`(`esm.js:2785`)。speed/size/pos 换算全依赖它,且须与 `setCanvasFixedSize`(`host.js:749`,780×1688)self-consistent(cameraScale 决定世界可视范围)。 | I3 阻抗换算正确性 | A1/A2 lane。grep host.js `setCameraScale`;若未设用默认 32。 |
| **OQ-3** | speed/life **区间**(min/max)→ 引擎**单值** speed/particleTime 的近似:取中值(现写法)?还是用引擎 `randomness=(max-min)/(max+min)` 让引擎抖动还原区间散布? | 粒子运动散布观感 | A2 lane。**建议**:取中值(最简);若散布不足再用 randomness 近似(引擎 randomness 是对称百分比抖动,非任意区间)。 |
| **OQ-4** | 引擎全局 `gravity` 默认 0 → `gravityScale` 无效。spec gravity 怎么生效?host `setGravity`?还是把 gravity 折进初速?MVP 是否支持粒子重力? | drift 的重力下落(burst/trail gravity=0,影响小) | A1/A2 lane。**建议**:MVP 暂不支持粒子重力(仅 drift gravity=8 受影响,观感损失小),记 follow-up;或 host 设统一全局 gravity(须像素口径换算)。 |
| **OQ-5** | 非线性 sizeCurve/alphaCurve(decay/easeInOut)被引擎两端点线性插值拍平——可接受否? | 粒子尺寸/透明曲线观感 | A2 lane + 创始人。**建议**:可接受(引擎能力边界,R-3);非线性曲线非 MVP 必需。 |
| **OQ-6** | 全仓 `particleCount()` 消费点是否有生产依赖(非测试/probe)?恒返 0 会否破坏游戏层逻辑? | R-2 行为变更面 | A2 lane。`grep -rn "particleCount" src/ ../game-studio/ ../game-cloud/`——若仅测试/probe 消费,安全;若游戏层有逻辑依赖,须评估。 |
| **OQ-7** | host `renderParticles` 的 `fillStyle='#7fd0ff'`(`host.js:353`)删除还是改注释保留? | 集成段整洁度 | A2 lane。**建议**:改注释保留(最小改动,不动 save/restore 结构,R-6)。 |
---
## 7. 一页纸(给 A2 实现 lane)
- **改 3 产物**:`api.d.ts`(additive 扩 `EngineEmitterSpec` 10 可选字段,已冻 6 参不动 + getEngine 降级注释纠偏)|`host.js`(`makeEngineCaps().particles` 体:spec→引擎 `ParticleEmitter`,像素↔世界换算 + 句柄封装;核 `renderParticles` 语义)|`impl.js`(删 sim 7 项,`spawnEmitter` 改走 `getEngine().particles` + 新增纯函数 `toEngineSpec`,render 瘦身只画 juice)。
- **删表**:`spawnOne`/`stepEmitter`/`integrateParticles`/`refreshEnv`/环境场 4 变量/`_sizeCurveOf`/`_alphaCurveOf`/`step` 粒子段/`render` 粒子绘制段/本地粒子池 + 背压。
- **不删表(补层全留)**:juice 全段(hitStop/屏震/闪白/脉冲)、`evalCurve`、`PRESETS`/`getPreset`/`deepCloneConfig`、render 闪白叠加段。
- **null-engine**:`spawnEmitter` 返 no-op 句柄登记(particleCount 恒 0,不喷不渲),**禁回退 sim**。
- **测试**:删 4 组(守恒/边界/同 seed 逐字段/render-arc)↔ 补 4 组(`toEngineSpec` 纯映射逐字段 / mock 引擎注入断真调+入参 / null-engine no-op / 颜色可见性);juice/曲线/预设补层断言零减。**node 禁 import 真引擎(window undefined),引擎验证用 mock,真渲归 mini-desktop real 像素门**。
- **三阻抗(带源码)**:I1 burst 无原语→`emitTime=1/60`+`emitRate=count×60`(`esm:8384-8390/8482`)|I2 默认色淡到透明→spec 承色 + 缺省可见亮色(`esm:4350-4357/8268`)|I3 speed=世界/帧@60→`÷cameraScale÷60`(`esm:8253`,cameraScale 默认 32 `esm:2785`)。
- **门**:门③ N1/N6/N7/N8/N12/N14 本机 grep 过;契约 N14 已冻 6 参不破。**A2 不做 A6/门②(mini-desktop,主会话事后驱动)**。
- **依赖**:A1→A2 串行(同改 host.js `makeEngineCaps`/`api.d.ts`);与 A3 文件级可并行。

View File

@ -0,0 +1,207 @@
# A3 lane edit-plan — audio 包装 + 删 vendored + 播放层补 ×0.3(行级落点)
> **定位**:Phase A 引擎真接线 **A3 lane(audio-music)** 的唯一执行据。覆盖旧错模型草稿 `2026-06-13-A1..A4-edit-plan.md`(双码路·降级不删模型,已废)及本文件 06-13 早 stub 稿。
> **政策上位据**:`docs/agent-specs/2026-06-13-A1-A6-接线政策.md`(创始人 2026-06-13「引擎↔插件边界模型」拍板落地)。本 plan 据其 §4.3 / §5 / §6 落到行级,不改裁。
> **本 plan 只写文档,不改代码。** 实现 lane 据此逐文件改。
> **裁定权属**:创始人模型(一职一路·引擎覆盖能力 null-engine 优雅 no-op 不留 sim)。
> **源码已亲验**(policy 官 opus 复核;行号处可逐条 grep/sed 复验):见 §1 事实台账。
---
## 0. A3 一句话与边界
- **一句话**:`audio-music` 的合成核(`zzfxG`/`zzfxM`)= 引擎有 → **薄包装**(经 `ctx.getEngine().audio.synth`),**删 vendored**(`vendor/zzfx.js`+`vendor/zzfxm.js` 真删、引用清零);播放层(把 PCM 塞进受控 `AudioContext` + **补 ×0.3 主音量** + gain/loop/stop)= 引擎无的真缺件 → **补层全留全测**;纯逻辑(`validateSong`/`resolveVoiceStates`/预设 schema)= 引擎无 → **补层全留全测**。
- **null-engine 双 null 语义(不可混)**:
- `getEngine()==null`(无引擎工厂)→ **合成 no-op**(`_synth` 为 null,`renderSong`/`playSfx` 拿不到样本 → 不发声)。**禁回退 vendored**(已删)。
- `getAudioContext()==null`(无音频上下文)→ **发声 no-op**(受控面既有语义,`impl.js:315-326 audioOrSilent`,保留不动)。
- **不在 A3 范围**:A1 工厂骨架(`makeEngineCaps` 创建 + `engineFactory` 注入 real 通道 + math 体)由 A1 落;A3 只在 A1 建好的工厂里**填 `audio.synth` 体**。`particles`(A2)/`math`(A1)键 A3 不碰。门②/A6 real 像素·声音验证在 mini-desktop(非本工作流)。
---
## 1. 源码事实台账(A3 相关·已亲验,行号可复验)
> 引擎路径 `node_modules/littlejsengine/dist/littlejs.esm.js`(下简称 esm.js)。每条均已 grep/sed/node 实测。
| # | 事实 | 验证点(已实测) | 结论 |
|---|---|---|---|
| F1 | 引擎 `zzfxG` 存在且导出 | esm.js:7326 `function zzfxG(...)`;export 块 esm.js:16568 `zzfxG,` | ✅ `LJS.zzfxG` 可用(host.js `import * as LJS`,host.js:44) |
| F2 | 引擎 `zzfxM` 存在、导出、签名同 vendored | esm.js:10837 `function zzfxM(instruments, patterns, sequence, BPM = 125)`;export esm.js:16626 `zzfxM,` | ✅ 签名与 vendored `zzfxm.js:48` **逐字一致** → 包装零阻抗 |
| F3 | **引擎 `zzfxG` 不烤 ×0.3** | esm.js:7391 `b[i++] = s * volume`(`volume` 全程**未** `*=0.3`;7326-7430 区间 grep `soundVolume`/`*=.3`/`s*0.3` **空**) | ✅ 政策核心断言成立 → ×0.3 **须播放层补** |
| F4 | 引擎 ×0.3 活在 gain 层(非样本) | esm.js:3121 `let soundVolume = .3;`·esm.js:6777 `audioMasterGain.gain.value = soundVolume` | ✅ 引擎自身=「样本不烤·gain 施 0.3」;我们包装 `zzfxG` 拿到**未乘 0.3 样本**,故播放层补 0.3 与引擎语义一致 |
| F5 | vendored `zzfxG` **烤** ×0.3 进样本 | `vendor/zzfx.js:159` `volume *= ZZFX_MASTER_VOLUME`(`vendor/zzfx.js:54` `=0.3`) | ✅ 与引擎差异点。**换引擎后若不补 0.3,响度 ×3.33** → 必补 |
| F6 | 引擎 `audioDefaultSampleRate=44100` 导出 | esm.js:6763 `const audioDefaultSampleRate = 44100;`;export esm.js:16559 | ✅ 与 vendored `ZZFX_SAMPLE_RATE=44100`(`vendor/zzfx.js:57`)同值 → 补采样率常量可硬编 44100 或经 synth 面回传 |
| F7 | **引擎 ESM 模块求值期触 `window`** → node 不可 import | `node --input-type=module -e "import 'littlejsengine'"` → `ReferenceError: window is not defined at esm.js:5556`(`const isTouchDevice = !headlessMode && window.ontouchstart...`) | ✅ **关键**:node 单测**无法**跑真引擎合成核 → node 层只能测「映射/注入/纯逻辑/播放层 mock」,真合成证据归 mini-desktop(grounds 政策「node 无引擎不测引擎合成」) |
| F8 | 引擎 `zzfxG` 用全局 `rand()`(=Math.random) | esm.js:7356 `(1 + rand(randomness,-randomness))`;`rand` 定义 esm.js:1723 `Math.random()*(valueA-valueB)` | ⚠️ randomness>0 时引擎合成**非确定**(吃 Math.random,不吃 ctx.random)。**但预设全 `randomness=0`**(`impl.js:59/61/63` 参数下标1=0)→ randomness 项坍缩为 `(1+0)` → **我们用的预设确定**。该确定性**不再由 node 单测把守**(F7:node 无引擎);移交 mini-desktop 或不再作 node 门(§4)。 |
| F9 | vendored 引用面(删除影响域) | `grep -rn "vendor/zzfx\|zzfxG\|zzfxM\|ZZFX_SAMPLE_RATE\|ZZFX_MASTER_VOLUME\|zzfxGetNote" src/ host-dev/` 仅命中:`vendor/*.js`(自身) + `impl.js:29-30/240/338/464` + `test/*.mjs:24-25/258/355-363` + `api.d.ts`/`plugin.js`(注释) | ✅ 删除面**封闭**:除 impl/test/vendor 自身 + 注释外**零外部 importer**。`zzfxGetNote`(`vendor/zzfx.js:243`)**无任何消费者** → 随 vendor 删(不需迁移) |
| F10 | host.js `buildBundle` 现未传 `engineFactory` | host.js:215-221 `createHostDevContext({context2d, seed, audioFactory, clock})` **无 engineFactory** | ✅ getEngine() 现返 null(A1 须给 real 通道注入工厂)。A3 在 A1 建好的 `makeEngineCaps()` 内**填 audio.synth 键** |
| F11 | host.js LJS import 唯一活集成段·插件今日零接引擎 | host.js:44 `import * as LJS from 'littlejsengine'`;`grep -rn getEngine src/plugins/` **空** | ✅ Q4 confinement 成立;A3 后插件经 `ctx.getEngine()` 接,零直接 import |
> **F3/F4 合读的关键结论**:包装引擎 `zzfxG` 后,`audio.synth.synthSfx` 返回的 PCM 是**未乘 0.3** 的(≈ 比 vendored 响 1/0.3=3.33×)。故 A3 **必须**在播放层把 ×0.3 显式补回(否则集成段声音爆 3.33×)。这是 A3 的「真缺件自研补层」之一(另一为采样率常量)。
---
## 2. 逐文件改动点(行级落点)
### 2.1 `host-dev/host.js` — A1 工厂内填 `audio.synth` 体(包装·集成段)
> **依赖 A1**:A1 在 host.js(`setupAfterEngine`/`buildBundle` 邻域)新建 `makeEngineCaps()` 并把它作 `engineFactory` 传入 `createHostDevContext`(real 通道注入,stub 通道不注入)。A3 **不新建工厂、不改注入逻辑**,只在 A1 建好的 `makeEngineCaps()` 函数体里**填 `audio.synth` 子对象**。
| 落点 | 改动 | 类别 |
|---|---|---|
| `makeEngineCaps()` 体内(A1 创建;A3 填 `audio` 键) | 把工厂返回对象的 `audio` 键实现为 `{ synth: { synthSfx, synthSong } }`,二者薄包装引擎:<br>① `synthSfx(params)` → `try { return LJS.zzfxG(...params); } catch (e) { logHostWarn(...); return null; }`(返 PCM 单声道数组,**未乘 0.3**,与 api.d.ts:110-115 口径一致)<br>② `synthSong(instruments, patterns, sequence, bpm)` → `try { return LJS.zzfxM(instruments, patterns, sequence, bpm); } catch (e) { ...; return null; }`(返 `[L,R]` 双声道,与 api.d.ts:119-120 口径一致)<br>合成失败/异常返 `null`(插件侧 → 发声 no-op)。 | 包装·工厂填体(引擎 import 唯一活此,Q4 守住) |
| `makeEngineCaps()` 邻近 / 文件常量区 | (**可选**,见 §3-OQ1)若采纳「采样率经 synth 面回传」方案B:在 `audio.synth` 上加只读字段 `sampleRate: LJS.audioDefaultSampleRate`(=44100,F6)。否则 A3 在 impl 内硬编 44100,host 此处不加。**二选一,§3 给裁(默认方案A 不加)**。 | 补层(采样率来源·可选) |
> **host.js 不删任何现有逻辑**:`audioFactory`(host.js:172-180)、`buildBundle`(host.js:215-221)、解锁逻辑(host.js:412) A3 全不碰(那是 `getAudioContext` 受控面,与 `getEngine().audio.synth` 是两条不同面)。A3 在 host.js 仅**新增** `audio.synth` 填体(A1 工厂内)。
> **engineMode 语义**:real 通道 → A1 注入 `makeEngineCaps`(含 A3 填的 audio.synth)→ `getEngine()!=null` → 插件走引擎合成;stub 通道 → A1 不注入 → `getEngine()==null` → 插件合成 no-op(保 null-engine 回归战场)。A3 不改此分流。
### 2.2 `src/plugins/audio-music/vendor/zzfx.js` — 整文件删除
| 落点 | 改动 | 类别 |
|---|---|---|
| 全文件 | **`rm src/plugins/audio-music/vendor/zzfx.js`**(含 `zzfxG`/`zzfxGetNote`/`ZZFX_MASTER_VOLUME`/`ZZFX_SAMPLE_RATE`)。`zzfxGetNote`(vendor/zzfx.js:243) 无消费者(F9)随删。`ZZFX_SAMPLE_RATE` 的消费者(impl.js:240)改走 §2.4 本地常量/synth 回传。`ZZFX_MASTER_VOLUME`(0.3) 的语义迁到播放层显式 ×0.3(§2.4)。 | 删 vendored |
### 2.3 `src/plugins/audio-music/vendor/zzfxm.js` — 整文件删除
| 落点 | 改动 | 类别 |
|---|---|---|
| 全文件 | **`rm src/plugins/audio-music/vendor/zzfxm.js`**(`zzfxM` 归引擎)。其对 `./zzfx.js` 的 import(zzfxm.js:33)随两文件同删自然消失。 | 删 vendored |
| `vendor/` 目录 | 删两文件后 `vendor/` 空 → **一并 `rmdir vendor/`**(无其它内容,F9 已确认目录仅此两文件)。 | 清理空目录 |
### 2.4 `src/plugins/audio-music/impl.js` — 改走 getEngine().audio.synth + 删 import + 播放层补 ×0.3(包装 + 删平行自研 + 补层)
| 落点(行号=改前现状) | 改动 | 类别 |
|---|---|---|
| impl.js:28-30(vendored import) | **删** `import { zzfxG, ZZFX_SAMPLE_RATE } from './vendor/zzfx.js';`(impl.js:29)+ `import { zzfxM } from './vendor/zzfxm.js';`(impl.js:30)+ 注释行 impl.js:28。 | 删 vendored 引用 |
| 文件顶部常量区(原 import 处附近,新增) | **新增本地采样率常量**(替代删掉的 `ZZFX_SAMPLE_RATE`):`const SAMPLE_RATE = 44100; // 引擎 audioDefaultSampleRate(littlejs.esm.js:6763)同口径;vendor 删后本地补,createBuffer 用`。**新增主音量补偿常量**:`const ENGINE_MASTER_VOLUME = 0.3; // 引擎 zzfxG 不烤主音量(样本=s*volume,esm.js:7391),×0.3 在引擎 gain 层(soundVolume=.3,esm.js:3121/6777)。包装引擎合成核后样本未乘 0.3,故播放层显式补,避免比 vendored(曾烤 0.3)响 3.33×`。(若采纳 OQ1 方案B「synth 面回传采样率」,`SAMPLE_RATE` 改读 `_synth?.sampleRate ?? 44100`,但 `ENGINE_MASTER_VOLUME` 仍本地常量。) | 补层(采样率 + ×0.3 常量) |
| `createAudioMusicPlugin` 闭包状态区(impl.js:291-308 邻近,新增) | **新增** `let _synth = null; // 引擎合成核(getEngine().audio.synth),init 取一次缓存;null=无引擎→合成 no-op`。 | 包装·缓存引擎能力 |
| `init(ctx)` 体(impl.js:396-400) | **保留** `getAudio = () => ctx.getAudioContext();`(受控面音频上下文,不动)。**新增一行**:`const eng = ctx.getEngine && ctx.getEngine(); _synth = eng ? eng.audio.synth : null; // 取一次缓存,禁每帧调;null=无引擎工厂→合成 no-op(不回退 vendored,已删)`。**禁**在此处对 `_synth==null` 单独告警(与 `getEngine()` 自身的 host-dev「无工厂告警一次」去重;插件层告警走 audioOrSilent 的音频上下文面)。 | 包装·init 接引擎 |
| `renderSong()`(impl.js:334-343,体内 impl.js:338 `return zzfxM(...)`) | 改为 `if (!song) return null; if (!_synth) return null; ... return _synth.synthSong(song.instruments, song.patterns, song.sequence, bpm);`。catch 不变(impl.js:339-342)。**语义**:无引擎合成核 → 返 null → `startPlayback` 拿不到 channels → 不发声(no-op,与无曲谱同路)。 | 包装·改调引擎 |
| `playSfx(name)`(impl.js:454-469,体内 impl.js:464 `const mono = zzfxG(...preset.params)`) | 预设查表(impl.js:455-459)不变;`audioOrSilent()`(impl.js:460-461)不变(音频上下文 null → 发声 no-op)。impl.js:464 改 `const mono = _synth ? _synth.synthSfx(preset.params) : null;`,其后加 `if (!mono) return; // 无引擎合成核或合成失败 → 发声 no-op`。`playChannels(ac, [mono, mono], masterVolume, false)`(impl.js:465)保留,×0.3 补偿在 playChannels 内(见下)。catch 不变(impl.js:466-468)。 | 包装·改调引擎 + no-op |
| `playChannels(audioCtx, channels, masterVolume, loop)`(impl.js:233-276,**补层保留 + 补 ×0.3**) | 函数体**整体保留**(受控 AudioContext 塞 buffer + source + gain + start + stop 句柄 = 真缺件自研补层)。两处改:<br>① impl.js:240 `createBuffer(channelCount, length, ZZFX_SAMPLE_RATE)` → `createBuffer(channelCount, length, SAMPLE_RATE)`(用本地常量,vendor 删后)。<br>② **×0.3 补偿落点**=impl.js:250 `gain.gain.value = clamp01(masterVolume);` → `gain.gain.value = clamp01(masterVolume * ENGINE_MASTER_VOLUME); // ×0.3 补:引擎 zzfxG 不烤主音量(vendored 曾烤),在 gain 层补回,与引擎 soundVolume 语义一致`。<br>**为何落 gain 不落样本**:与引擎自身做法一致(F4:引擎也在 gain 施 0.3),且不改样本数值口径(synth 面契约「返未乘 0.3 的 PCM」清晰)。 | 补层(保留)+ 补 ×0.3 |
| `startPlayback`(impl.js:363-375,体内 impl.js:372 `const gain = clamp01(masterVolume * overallIntensityGain())`) | **逻辑不变**:`renderSong()` 现经 `_synth.synthSong`(已改),返 null 时 impl.js:371 `if (!channels) return;` 已兜(无引擎→不播)。impl.js:372 的 `gain` 是「主音量×情绪增益」传给 `playChannels` 的 `masterVolume` 形参——**×0.3 在 playChannels 内补**(§上),故此处**不再叠 0.3**(避免双补)。**核对响度等效**:传入 `playChannels` 的 `masterVolume`=`masterVolume*overallIntensityGain()`,playChannels 内 `*ENGINE_MASTER_VOLUME` → 最终 gain=`masterVolume*情绪增益*0.3`;vendored 时代(样本已含 0.3、gain=`masterVolume*情绪增益`)→ 两者**等效**。✅ | 核对·防双补(不改码) |
| 顶部模块 JSDoc(impl.js:28 注释「vendored 合成内核」/impl.js:18-19 受控面铁律) | 更新注释:合成核改述「经 `ctx.getEngine().audio.synth` 包装引擎 zzfxG/zzfxM;播放层补 ×0.3(引擎不烤主音量);无引擎→合成 no-op」。删「vendored ZzFX/ZzFXM(见 ./vendor/)」表述。受控面铁律段同步(仍只用 getAudioContext + 新增 getEngine().audio.synth)。 | 注释同步 |
### 2.5 `src/core/api.d.ts` — EngineAudioSynth 注释去「回退 vendored」改 no-op 语义(契约·非破坏)
> **契约影响判定:纯注释改,零形状变更,零消费者破坏。** `EngineAudioSynth` 的方法签名(`synthSfx(params): number[]|null` / `synthSong(...): number[][]|null`)**一字不动**;仅改 JSDoc 文字(旧语义「回退 vendored」已随 vendored 删除失效,须改「no-op」)。
| 落点(行号=改前现状) | 改动 | additive/破坏判定 |
|---|---|---|
| api.d.ts:114 `@returns PCM 样本数组;无引擎或合成失败返 null(插件回退 vendored)。` | 改 `@returns PCM 单声道样本数组(**未乘主音量 0.3**,与引擎 zzfxG 同口径,gain 层补);无引擎或合成失败返 null(插件**发声 no-op,不回退 vendored**——已删)。` | **非破坏**(注释;签名不变) |
| api.d.ts:118 `@returns 双声道样本数组;无引擎/失败返 null(插件回退 vendored zzfxM)。` | 改 `@returns [L,R] 双声道样本数组(未乘 0.3);无引擎/失败返 null(插件发声 no-op,不回退 vendored)。` | **非破坏**(注释) |
| api.d.ts:109 接口头注释 `…返回 PCM 样本数组,与 vendored 同口径,不播放` | 改 `…返回 PCM 样本数组(未乘主音量 0.3,与引擎 zzfxG 样本口径一致;×0.3 在播放层 gain 补),不播放` | **非破坏**(注释) |
| api.d.ts:140-141 `EngineCapabilities.audio` 注释 `A2 据 SIZES 定是否真用,不用则清。` | 改 `受控合成核(封装 zzfxG/zzfxM)。**A3 已接(删 vendored,包装引擎)**;返样本未乘 0.3,播放层补。` | **非破坏**(注释;键形状不变) |
| api.d.ts:233(`PluginContext.getEngine` 注释)`…合成核退 vendored…` 分句 | 仅改合成核分句:`…合成核退 **发声 no-op(vendored 已删)**…`;同句「粒子退受控面自管 / 数学退插件内置」由 A2/A1 各自定,A3 不动。**⚠️ 与 A2 同行**(particles 分句在同一行)→ §6-R2 注意合并。 | **非破坏**(注释;不同分句) |
> **与政策 §1.4 一致性**:旧 api.d.ts 注释承诺「合成核退 vendored」是双码路语义,创始人已否决。A3 改注释为「no-op」是把契约文字与新模型对齐——**注释改是契约对齐的必要部分,非可选**(否则契约文档与实现矛盾,门③人核会判漂移)。
---
## 3. 契约影响汇总(api.d.ts additive/破坏明示)
| 改动 | 形状变更? | 消费者破坏? | 结论 |
|---|---|---|---|
| `EngineAudioSynth.synthSfx/synthSong` 签名 | **无**(一字不动) | 无 | 安全 |
| `EngineAudioSynth` / `EngineCapabilities.audio` / `getEngine` 注释 | 仅文字 | 无 | 安全(注释对齐新模型,必改) |
| `EngineEmitterSpec`(A2 域) | A3 **不碰** | — | 不在 A3 范围 |
| `EngineMath`(A1 域) | A3 **不碰** | — | 不在 A3 范围 |
> **A3 对外契约净变更 = 0 形状变更**(只删 vendored 内部模块 + 改注释)。`vendor/zzfx.js`、`vendor/zzfxm.js` 是**插件内部模块**(非 `contracts/` 跨端契约,非 `*-api` 包),删除不触发跨端契约联动。`api.d.ts` 是 runtime 内手写门面,注释改不影响任何 `.d.ts` 消费者编译(无 tsc,JSDoc 文字)。
**OQ1(采样率来源·§7 重述)**:两方案二选一——
- **方案A(推荐·更简·本 plan 默认)**:impl 内硬编 `const SAMPLE_RATE = 44100`,host synth 面**不加** sampleRate 字段。理由:44100 是 ZzFX/引擎双方不变常量(F6),硬编一处、零契约面扩张,YAGNI。
- **方案B**:host `audio.synth.sampleRate = LJS.audioDefaultSampleRate`,impl 读 `_synth?.sampleRate ?? 44100`。理由:采样率「权威来源」收口到引擎。代价:synth 面多一只读字段(api.d.ts EngineAudioSynth 加 `sampleRate: number` = **additive 非破坏**,但徒增面)。
- **裁**:默认走**方案A**(除非主会话认为采样率必须引擎权威);本 plan §2 按方案A 写,方案B 落点已在 §2.1 标注「可选」。
---
## 4. 测试计划(掏空替代方案·具体到测什么)
> **铁律(政策 §5.1 concern-P0-①)**:删被测对象(vendored 合成核)的断言**必须**等量补「映射/注入/纯逻辑/播放层 mock」断言;补层断言**一条不减**。删除断言数 ≤ 新增等价断言数,且补层断言总数不减。**机读掏空判据**:A3 提交后比对 `audio-music.test.mjs`,净删断言且无等价补入 = 掏空,门禁挡回。
文件:`src/plugins/audio-music/test/audio-music.test.mjs`
### 4.1 删除项(被测对象已不存在 / import 已失效)
| 删除 | 现位置 | 删除理由 |
|---|---|---|
| `import { zzfxG } from '../vendor/zzfx.js';` | 测试 :24 | vendor 删,import 必失败 |
| `import { zzfxM } from '../vendor/zzfxm.js';` | 测试 :25 | 同上 |
| test「音效预设:每个预设可被 zzfxG 渲染出非空有限样本」 | 测试 :256-264 | 直调 vendored `zzfxG`(已删);合成核归引擎,node 无引擎(F7)不能跑真合成 → **此断言被测对象消失**。**等价补**=见 4.2-T2(预设 schema 仍测「参数有限数字数组/randomness=0」,去掉「能渲染样本」段——渲染核已不在 node 可达面)。 |
| test「vendored zzfxG / zzfxM 确定性(randomness=0)」 | 测试 :354-365 | 直调 vendored(已删);引擎合成核 node 不可 import(F7)。**确定性价值迁移**:① 预设 randomness=0 仍由 schema 测把守(4.2-T2);② 真合成确定性证据移交 mini-desktop(非 node 门,政策 §5.1-③/§5.3 已记账「same-pixel/确定性回归本波退役」)。**非掏空**=被测对象(node 可跑的 vendored 合成)已不存在。 |
### 4.2 新增/强化项(等价覆盖·node 可跑)
| # | 新增断言 | 测什么 | 为何等价 |
|---|---|---|---|
| **T1** | **mock 引擎 synth 注入测**:构造 fake `engineCaps = { particles:<空/A2占位>, audio:{ synth:{ synthSfx:(p)=>{ recordedSfx=p; return [0.1,0.2,0.3] }, synthSong:(...a)=>{ recordedSong=a; return [[0.1],[0.1]] } } }, math:<lerp/smoothStep 桩> }`,经 `createHostDevContext({ engineFactory: () => engineCaps, audioFactory: ()=>mockAudio })` 注入(core 已支持 engineFactory opt,api.d.ts:338);`p.play()` 断 `recordedSong` **被赋值** 且 `=(instruments,patterns,sequence,bpm)`;`p.playSfx('blip')` 断 `recordedSfx === SFX_PRESETS.blip.params`。 | 「插件经 getEngine().audio.synth 真调引擎合成核 + 参数对」 | 替代「直调 vendored zzfxG/zzfxM」——证明合成核**经引擎面**被正确调用(一职一路正面证据) |
| **T2** | **预设 schema(保留·去渲染段)**:测试 :241-254「≥3 中性命名、参数有限数字数组、randomness=0」**全保留**;仅删 :256-264「能渲染样本」。 | 预设参数契约(名/数字/randomness=0) | 预设 schema 是纯数据补层,与合成核解耦,**全留** |
| **T3** | **播放层 ×0.3 增益测**:mock AudioContext 的 `createGain`(现 :64-66 返 `{gain:{value:1},connect,disconnect}`,`value` 可写已支持)—— `p.play()`(masterVolume=1、无情绪分层→情绪增益1)后读 `recordedGainNode.gain.value`,断 `≈ 1*1*0.3 = 0.3`(±1e-9)。再 `createAudioMusicPlugin({masterVolume:0.5})` → 断 gain ≈ `0.5*0.3=0.15`。需让 mock createGain 记下返回的 gain 节点引用供断言。 | 「播放层补 ×0.3 正确,不多不少」 | **新缺件补层正面锚**:证明换引擎(样本不烤 0.3)后响度补偿对(防 3.33× 爆音 / 防双补) |
| **T4** | **null-engine 合成 no-op 测**:`createHostDevContext({ /* 不传 engineFactory */ audioFactory:()=>mockAudio })`(getEngine()→null,但 getAudioContext 有);`p.init` 后 `p.loadSong(validSong()); p.play(); p.playSfx('blip')` 断 **不抛**、`mockAudio` 的 `createBufferSource` **未被调**(无样本→不进 playChannels)、`probe().looping===false`。**区分双 null**:本测 getAudioContext 非 null(隔离「合成 no-op」与「发声 no-op」)。 | 「无引擎工厂→合成 no-op,不回退 vendored,不发声」 | 替代「合成核确定性」——证明 null-engine 语义=优雅 no-op(政策 §3 钉死) |
| **T5** | **纯逻辑补层全保留(守门)**:`validateSong`(测试 :100-161)/`resolveVoiceStates` 情绪分层全套(:165-237)/`无音频上下文静默降级`(:276-307)/`mock 音频上下文走通发声`(:309-338)/`情绪整体增益`(:340-350)/`listPresets/getPreset`(:266-272)/`注册器接纳`(:83-96) **一条不删**。 | 纯逻辑 + 受控面降级 + 注册生命周期 | 补层断言总数**不减**=不掏空正面锚 |
> **mock 改造点(T1/T3/T4 需)**:① `makeMockAudio()`(测试 :40-69)`createGain`(:64-66)需让外层拿到返回的 gain 节点引用(T3 读 value);② `createHostDevContext` opts 加 `engineFactory`(T1 注入 fake;T4 不传)。两者均**不掏空既有 mock 路径测**(:309-338「mock 音频上下文走通发声」仍跑——但注意:该测原依赖 vendored 合成产真样本,改后需在该测也注入 fake engineCaps.synth 才能产样本走通 playChannels;**实现 lane 须把 :309-338 与 :340-350 两个"走通发声"测一并注入 fake synth**,否则无引擎→无样本→createBufferSource 不被调,断言 `calls.starts>=1` 会失败)。**这是 A3 实现的隐藏改点:所有"走通发声路径"的既有测,从依赖真 vendored 样本改为依赖注入的 fake engineCaps.synth 样本。** |
### 4.3 掏空自检(A3 实现 lane 提交前自跑)
- 删断言计数:去 2 import + 2 test(「能渲染样本」「确定性」)。新增 ≥4 test(T1/T3/T4 + T2 保留主体)。**净增** → 不掏空。
- 补层断言总数:T5 列的纯逻辑/降级/生命周期断言 **0 删** → 补层不减(注意 :309-350 两个走通发声测需改为注入 fake synth,**改注入≠删断言**,断言保留)。
- 运行:`node --test src/plugins/audio-music/test/audio-music.test.mjs` 须全绿(node 可跑,不碰引擎真合成核 F7)。
---
## 5. 门③负扫断言(A3 相关·政策 §6,本机 grep/ls 可跑)
> 命令均在 `game-runtime/` 根执行,纯 grep/ls,**不碰 chrome/CDP**。任一 FAIL 阻断合并。
| # | 断言 | 命令 | 期望 |
|---|---|---|---|
| N3 | vendored zzfx.js 已删 | `ls src/plugins/audio-music/vendor/zzfx.js` | No such file |
| N4 | vendored zzfxm.js 已删 | `ls src/plugins/audio-music/vendor/zzfxm.js` | No such file(`vendor/` 空目录一并删) |
| N5 | audio impl 不再 import vendored | `grep -n "vendor/zzfx" src/plugins/audio-music/impl.js` | 0 命中 |
| N9 | audio 经 getEngine().audio.synth 包装 | `grep -n "getEngine()" src/plugins/audio-music/impl.js` | ≥1 命中(init 取 _synth) |
| N10 | 插件零裸 new AudioContext | `grep -rn "new AudioContext\|new webkitAudioContext\|window.AudioContext" src/plugins/ --include=*.js` | 0 命中(音频只经 getAudioContext 受控面) |
| N13 | ×0.3 主音量在播放层补 | `grep -n "0.3\|ENGINE_MASTER_VOLUME\|soundVolume\|MASTER_VOLUME" src/plugins/audio-music/impl.js` | ≥1 命中(gain 层补 ×0.3,注释引擎来源) |
| N12(部分) | 补层未误删 | `grep -n "validateSong\|resolveVoiceStates\|playChannels" src/plugins/audio-music/impl.js` | 各 ≥1(纯逻辑 + 播放层补层在) |
> 门②(mini-desktop·非本工作流):real 通道 CDP 验「真调引擎合成核 `__engineCalls` 命中 synthSfx/synthSong」+ 真出声,由主会话事后驱动。门③(本机)证「没留 vendored / 没并行自研」。
---
## 6. 风险
| # | 风险 | 影响 | 缓解 |
|---|---|---|---|
| R1 | **×0.3 双补或漏补** | 漏补→集成段声音爆 3.33×;双补→声音 ×0.09 几不可闻 | §2.4 已核:×0.3 **只在 playChannels 的 gain 落一次**;`startPlayback:372` 传入的是「主音量×情绪增益」(不含 0.3),playChannels 再 ×0.3。T3 断言 gain=`masterVolume*情绪*0.3` 把守。**实现 lane 须确认 playSfx 路径**(impl.js:465 传 `masterVolume` 而非 startPlayback 的 gain)也只经 playChannels ×0.3 一次(playSfx 不叠情绪增益,gain=`masterVolume*0.3`,符合"音效不受情绪分层"语义)。 |
| R2 | **api.d.ts A2/A3 同文件并发改注释** | 合并冲突 / 漏改 | A2 改 api.d.ts:84-97(EngineEmitterSpec) + particles 注释;A3 改 api.d.ts:109-121/140-141/233。**api.d.ts:233 getEngine 注释 A2/A3 同行**(particles 分句 + 合成核分句)→ **串行落 A2→A3 或细心合并该行**(政策 §4.0:A2/A3 文件级可并行但建议串行)。 |
| R3 | **node 测误以为能跑引擎合成** | 实现 lane 若试 `import LJS` 进 test → node 崩(F7 window) | 测试纪律明确:T1/T4 用 **fake engineCaps POJO**(不 import 真引擎);真引擎合成证据归 mini-desktop。**禁** test 文件 import littlejsengine。 |
| R4 | **既有"走通发声"测改注入遗漏** | 测试 :309-350 原靠真 vendored 样本,改后无样本→断言 `calls.starts>=1` 挂 | §4.2-T5 注脚已点明:**所有"走通发声路径"测须从依赖真 vendored 样本改为注入 fake engineCaps.synth 样本**。这是 A3 隐藏改点,实现 lane 必处理(改注入≠掏空,断言保留)。 |
| R5 | **采样率方案分歧(OQ1)** | 方案不定则 impl 改不下去 | §3 已给默认裁(方案A 硬编 44100);本 plan §2 按方案A 写,方案B 落点已标可选。主会话若选 B,仅 §2.1 加 sampleRate 字段 + impl 读 `_synth?.sampleRate`。 |
| R6 | **stub 通道发声回归丢失** | 删 vendored 后 stub 通道(getEngine null)无法合成 → stub 下无声 | **这是政策预期**(§3:getEngine null→合成 no-op)。stub 通道本就为「null-engine 回归战场」,无声是正确语义(非缺陷)。真出声证据走 real 通道 mini-desktop。**实现 lane 勿因 stub 无声而误加回退。** |
| R7 | **PLUGIN.md / 字节预算文档过期** | PLUGIN.md :86 字节预算按 vendored gz 算(≈5.5KB),删 vendored 后失真 | PLUGIN.md 更新**非 A3 硬门**(文档非代码),建议同步:合成核归引擎(不计插件字节,引擎已在包内)→ 插件 gz 仅 impl(≈3.1KB)+ 播放层。**列入 §7 移交**(可 A3 顺手,或主会话收口)。 |
---
## 7. Open Questions(移交主会话裁/确认)
| # | 问题 | 默认处置 | 归属 |
|---|---|---|---|
| OQ1 | 采样率来源:硬编 44100(方案A)vs synth 面回传(方案B) | **方案A**(硬编,YAGNI;§3 给裁) | 主会话确认(若要引擎权威则 B) |
| OQ2 | `EngineAudioSynth` 是否需暴露 `masterVolume` 给插件做更精细控 | 否(YAGNI;×0.3 与 44100 均插件内常量足够) | 主会话 |
| OQ3 | PLUGIN.md 字节预算 / vendor 说明更新是否纳入 A3 | 建议 A3 顺手改注释,字节预算由收口核 | A3 lane + 主会话 |
| OQ4 | 真出声/响度验证(×0.3 后听感)= mini-desktop real 通道 | 非本工作流;门②/A6 主会话事后驱动 | 主会话(本机禁 chrome) |
| OQ5 | A2/A3 改 api.d.ts:233 同行合并策略 | 串行落 A2→A3(政策建议) | 主会话派 lane 时定序 |
---
## 8. 完成条件(A3 lane 自验)
1. `vendor/zzfx.js` + `vendor/zzfxm.js` 真删、`vendor/` 空目录删(N3/N4);impl 零 vendored import(N5)。
2. impl `init` 取 `_synth = ctx.getEngine()?.audio?.synth`(N9);`renderSong`/`playSfx` 经 `_synth.synthSong/synthSfx`;null→合成 no-op(T4)。
3. 播放层 `playChannels` 保留 + gain 补 `×ENGINE_MASTER_VOLUME(0.3)`(N13/T3);采样率用本地 `SAMPLE_RATE=44100`。
4. `api.d.ts` EngineAudioSynth/audio/getEngine 注释改 no-op 语义(签名零变)。
5. 测试:删 2 import + 2「合成核」test;补 T1/T3/T4 + T2 主体保留 + T5 全留(含 :309-350 走通发声测改注入 fake synth);`node --test ...audio-music.test.mjs` 全绿;掏空自检(净增断言、补层不减)过。
6. 门③ N3/N4/N5/N9/N10/N12/N13 本机 grep/ls 全 PASS。
7. host.js A1 工厂内 `audio.synth` 填体(依赖 A1 先建 `makeEngineCaps`+注入 real 通道);Q4 confinement 不破(插件零直接 import littlejsengine)。
> **未验项(移交 mini-desktop·非本工作流)**:真引擎合成核出声 / ×0.3 后真实响度 / real 通道门② `__engineCalls` 命中 synthSfx/synthSong。本机不跑 chrome,由主会话事后在 mini-desktop 驱动。

View File

@ -0,0 +1,196 @@
# A4 edit-plan — gamefeel easing 经 math.easing 门面包装引擎 Ease(内置留 null-engine fallback)+ 补层裁定标注
> **lane**:A4(gamefeel **easing 包装** + collision/physics-lite/palette-post 补层裁定注释标痕)。设计官 = opus。
> **唯一政策据**:[`docs/agent-specs/2026-06-13-A1-A6-接线政策.md`](./2026-06-13-A1-A6-接线政策.md)(创始人 2026-06-13「引擎↔插件 边界模型」拍板 + **本轮 easing 改判**落地)。
> **本文覆盖(作废)**:本文件 06-13 旧版 A4 edit-plan(其核心判定 F3「引擎无 easing family→easing=补层→math 门面备而未用」**整体作废**——根因 grep 假阴性,引擎实有 `Ease` 曲线族,见 §1 F3-NEW / 政策 §1.5)。**以本稿为唯一据。**
> **定位**:仅产出**逐文件行级 edit-plan**,**不改代码**。供实现 lane 据此落地。
> **源码已亲验**(A4 设计官 opus 核,引擎断言带 `littlejs.esm.js`/`littlejs.d.ts` 行号,可逐条复验;引擎 ESM 不能裸 node import,恒等性由 in-range 纯 JS 复刻核,见 §1 F-EQ)。
---
## 0. 结论速览(先行)
| # | 结论 |
|---|---|
| **核心判定(改判后)** | 引擎**实有** `Ease` 曲线族(`esm.js:15118`:`POWER(n)`/`BACK`/`ELASTIC` + `OUT/IN/IN_OUT` 修饰器,export `:16681`)→ gamefeel 自研 easing = **有意替代**,须改经 **A1 建的 `getEngine().math.easing` 门面**包装引擎 `Ease`。gamefeel **内置 easing 实现(`impl.js:57-145`)保留作 null-engine fallback**(纯数学族特例 §1.3,合法、非有意替代、非补层)。**改判前"easing=补层/备而未用门面"裁定整体作废。** |
| **改面(gamefeel·真消费)** | `gamefeel/impl.js`:① `easing` 单例从"直接绑内置纯函数"改为"**门面优先 + 内置 fallback**"——init 缓存一次 `_engMath=ctx.getEngine()?.math`,`easing.quadIn` 等改为"有门面→走 `_engMath.easing.quadIn`(经 clamp01 包裹保钳行为)、null→走内置 `builtinEasing.quadIn`";② 内置 family 整体降为 `builtinEasing`(仅 fallback,保留全实现);③ 文件头边界裁定段**重写**(删"引擎无 easing/easing=补层/不得误判为待包装"假注释,改"easing 经 getEngine 门面包装引擎 Ease,内置仅 null-engine fallback")。 |
| **★ 载荷决策:clamp 分歧(钉死,§2.1-决策点)** | 引擎 `Ease` 曲线**不钳**入参(`Ease.POWER(2)(2)===4`);gamefeel 内置 easing **钳** [0,1](`quadIn(2)===1`),现存测「入参越界被钳制」(`test:55-61`)断言钳行为。**裁定:gamefeel 门面分支须 `clamp01(t)` 后再调引擎曲线**(`_engMath.easing.quadIn(clamp01(t))`),保越界钳语义不变 → quad 仍逐字节恒等、现存钳测零改即绿。**不可裸转发 `_engMath.easing.quadIn(t)`(破钳测)。** |
| **补层标注范围** | collision + physics-lite + palette-post **共 3 文件**各加 1 段补层裁定注释(**gamefeel 自身 easing 改归"门面包装"不再标"补层"**,但其 InputBuffer/Coyote/Combo 时间窗补层在文件头同段一并澄清)。**「音频定位」不在本 lane**(在 `audio-music`,A3 owner,§5 open-Q-1)。 |
| **契约影响** | **零**。A4 **不碰任何 `.d.ts`**(core `api.d.ts` 的 `EngineMath` 加 `easing` 成员由 **A1** 落,非本 lane)、不碰 `host.js`、不碰 particles/audio。A4 仅**消费** A1 已落的 `getEngine().math.easing` 门面。**无 additive/破消费者之说——本 lane 零契约改。** |
| **测试(按 15 块基线,非 9)** | gamefeel test 实 **15 个 test 块**(6 easing + 9 时间窗/插件)。补层/fallback 断言**一条不减**(15 块全留——6 个 easing 块即"内置 fallback 纯函数确定性测",null-engine 下真实路径,**绝不删**);**新增 2 块**:① 门面转发测(注入 mock 引擎 `math.easing`,断 `easing.quadIn` 真走门面 + clamp01 包裹);② null-engine 退内置测(`getEngine()` null 时 easing 走内置、端点恒等/钳行为不变)。删 0 ≤ 增 ≥2 ∧ 补层不减 → 非掏空。 |
| **门③** | 本 lane 触 **N11**(gamefeel 零 `Date.now/Math.random/RAF/addEventListener`,仅 `Math.pow/PI/sin/min` 纯数学·F1 旁证 PASS)、**N12**(gamefeel fallback 正面守门 `elasticIn/backOut/class InputBuffer` ≥1 命中·PASS,**语义改为"内置作 fallback 保留"**)、**N12b(改判新增·正面)**(`getEngine()` 在 gamefeel impl ≥1 命中——A4 后真接门面;**改判前为空=有意替代=FAIL**)。N3-N9/N13/N14 非本 lane。 |
---
## 1. 源码事实台账(A4 亲验·行号可复验)
| # | 事实 | 验证点 | 结论 |
|---|---|---|---|
| **F1** | **gamefeel impl 现零 `getEngine`/`lerp`/`smoothStep` 用点** | `grep -n "lerp\|smoothStep\|getEngine" src/plugins/gamefeel/impl.js` → **0 命中**;现有 `easing`(`:139`)直接绑内置纯函数(`:59-133`),`Math.pow/PI/sin`(`:66/77/81/82/88/95/103/105/127/129`)、`Math.min`(`:340`)皆在 easing 族/ComboWindow 封顶内 | ✅ **改判核心现状**:今日 gamefeel **未接引擎**,easing 走纯自研体;A4 须把 easing **改经门面**(§1.5 政策"今日三件包装插件都还没接引擎,A2/A3/A4 才接") |
| **F3-NEW** | **★ 引擎实有完整 `Ease` 曲线族(推翻旧 F3"引擎无 easing")** | `esm.js:15118` `const Ease = {...}`:`LINEAR:(x)=>x`(`:15123`)、`POWER:(n)=>(x)=>x**n`(`:15132`)、`SINE`/`CIRC`/`EXPO`、`BACK:(x)=>x*x*(2.70158*x-1.70158)`(`:15157`)、`ELASTIC:(x)=>...-(2**(10*x-10))*sin(((37-40*x)*PI)/6)`(`:15165`)、`SPRING`/`BOUNCE`、修饰器 `IN:(f)=>f`/`OUT:(f)=>(x)=>1-f(1-x)`/`IN_OUT:(f)=>PIECEWISE(f,OUT(f))`;export 块 `:16681 Ease,`;`d.ts:5514 namespace Ease`(`:5516 POWER`/`:5520 BACK`/`:5521 ELASTIC`)+ `:5369 class Tween`/`:5414 setEase` | ✅ **旧 F3 作废**:引擎**有** quad/cubic(=`POWER(2/3)`)/back(=`BACK`)/elastic(=`ELASTIC`) + out/inOut(=`OUT`/`IN_OUT`) 全 family → gamefeel 自研 easing = **有意替代** → 须改门面包装(政策 §1.5 红线:旧判根因按小写 `quadIn` grep 漏引擎大写 `POWER/BACK/ELASTIC`) |
| **F-EQ** | **★ 字节恒等性 + clamp 分歧实测(in-range 纯 JS 复刻核,引擎 ESM 不能裸 node import)** | in-range(x∈[0,1] 扫 2001 点):`quadIn===Ease.POWER(2)` **逐字节恒等**(maxDiff=0);`cubicIn/backIn/elasticIn` 数学等价但**NOT 逐字节恒等**(ULP 级,maxDiff 分别 1.1e-16/6.7e-16/3.6e-16)。**clamp 分歧(关键)**:引擎曲线**不钳**入参——`Ease.POWER(2)(2)===4`、`Ease.BACK(1.5)===5.289`;gamefeel 内置**钳**——`quadIn(2)===1`、`backIn(1.5)===1.0` | ⚠️ **二关键结论**:(1) 纯数学族特例资格锚=公式恒等(非字节恒等,政策 §0.2/§1.3),quad 字节恒等、cubic/back/elastic ULP 级无害(easing 仅视觉插值无字节门校)→ 特例成立;(2) **clamp 分歧载荷**:门面分支须 `clamp01(t)` 后调引擎曲线(§2.1-决策点),否则破现存钳测(`test:55-61`) |
| **F-LOAD** | **引擎 ESM 不能裸 node import**(恒等性验证方式说明) | `node -e "import littlejs.esm.js"` → `ReferenceError: window is not defined`(`esm.js:5556` `window.ontouchstart`) | ✅ 印证 Q4 confinement(引擎 import 只活集成段 host,有 DOM/jsdom 环境)。**A4 恒等性证据走 in-range 纯 JS 复刻(F-EQ 已核)或集成段 real 像素门**,node 单测**不裸 import 引擎**(测门面用 mock 引擎,§3.2) |
| **F4** | **`EngineMath` 现仅 `lerp`/`smoothStep`,无 `easing` 成员**(A1 待加,非本 lane) | core `api.d.ts:124-129` `interface EngineMath { lerp; smoothStep }`(**无 easing**);`:137-144 EngineCapabilities`;`:236 getEngine()`;`:338 engineFactory?` | ⚠️ **依赖 A1**:`EngineMath` 加 `easing` 成员 = **A1 落点**(政策 §4.1:A1 additive 加 `easing` 面 + host.js 工厂实现 `math.easing` 包装 `LJS.Ease`)。A4 **不改 api.d.ts**,仅消费 A1 落地后的 `getEngine().math.easing`;A1 未落时 `_engMath?.easing` 为 undefined → 退内置 fallback(A4 helper 须容此) |
| **F5** | **gamefeel 不触 host.js 工厂** | `grep -n "gamefeel\|getEngine\|engineFactory\|makeEngineCaps" host-dev/host.js` → gamefeel 仅 `:391/:465-494`(InputBuffer 探针,与引擎 math/easing **无关**);无 getEngine/工厂关联 | ✅ A4 **全程不碰 host.js**(政策 §4.4 钉死)→ 与 A2/A3 并行无冲突 |
| **F7** | **补层 3 文件现状**(标注目标:collision/physics-lite/palette-post) | collision(`impl.js:1-23`)/physics-lite(`:1-21`)/palette-post(`:1-28`) 各有富文件头(纯数学/几何/后处理、「不 import littlejsengine」「确定性铁律」),**无"为何引擎不覆盖此能力"的边界裁定行** | ✅ A4 各加 **1 段裁定注释**(补层根因 + 引擎为何不覆盖),供生成 agent/后人不误判为待包装 |
| **F8** | **gamefeel test = 15 块全补层/fallback 导入**(非旧稿误称 9 块) | `grep -c "^\s*test(" gamefeel.test.mjs` → **15**(easing 6 块 `:43/55/63/80/85/100` + InputBuffer 3 `:118/137/148` + Coyote 2 `:163/180` + Combo 2 `:201/213` + 插件 2 `:235/268`);import(`:18-24`)`{createGamefeelPlugin, InputBuffer, CoyoteTimer, ComboWindow, easing}`——全 fallback/补层,A4 零删 | ✅ **掏空基线=15 块**(旧稿"9 块"错)。6 个 easing 块即"内置 fallback 确定性测",null-engine 真实路径,**绝不删**;A4 仅**新增** 2 块门面/null 测 |
> **prompt「音频定位」字面对齐**(亲验):「音频定位」实体在 `audio-music/impl.js`(pan/positional),**非 gamefeel**,且 `audio-music` 是 **A3 owner**。本 lane 边界「绝不碰 audio」→ 音频定位补层注释归 A3(§5 open-Q-1 钉死)。本 lane **只动 gamefeel/impl.js+test 与 collision/physics-lite/palette-post 注释,绝不碰 host.js 工厂/api.d.ts/particles/audio**。
---
## 2. 逐文件行级 edit-plan
> 约定:行号 = **当前文件**行号(落地前以 `sed -n` 复核;注释/代码插入会顺延后续行号,实现 lane 据**段锚点**定位而非死记行号)。
### 2.1 `src/plugins/gamefeel/impl.js` —— easing 改经 math.easing 门面(内置降为 fallback)+ 边界裁定段重写
#### ★ 决策点(clamp 分歧·钉死,实现 lane 不可偏离)
> **问题**:引擎 `Ease` 曲线**不钳**入参(`Ease.POWER(2)(2)===4`、`Ease.BACK(1.5)===5.289`,F-EQ);gamefeel 内置 easing **钳** [0,1](每条 `clamp01(t)` 起手,`:59/61/66/71/...`),且现存测「入参越界被钳制 t<0→f(0)、t>1→f(1)」(`test:55-61`)对**全 family**断言钳行为。
> **裁定**:gamefeel 门面分支**先 `clamp01(t)` 再调引擎曲线** → `quadIn: (t) => _engMath ? _engMath.easing.quadIn(clamp01(t)) : builtinEasing.quadIn(t)`。理由三:
> 1) **保钳语义**:现存「越界钳制」测(`:55-61`)对门面/fallback 两路均须绿——门面分支经 `clamp01` 包裹后 `quadIn(1.5)===quadIn(1)` 成立(引擎曲线收到的是钳后的 1);
> 2) **保字节恒等**:`clamp01(t)` 与内置 quadIn 起手 `clamp01` 同口径 → 门面 `quadIn(t)` 与内置 `quadIn(t)` 在 [0,1] 内逐字节恒等(quad,F-EQ)、越界亦同(都先钳);
> 3) **最简、无双口径**:clamp 责任留 gamefeel 一处(gamefeel 本就钳),不要求 A1 门面承钳——A1 门面 `math.easing.quadIn=Ease.POWER(2)`(裸引擎曲线、不钳)即可,**钳由消费方 gamefeel 施**(与内置 fallback 同口径,零行为分叉)。
> **反面(禁止)**:裸转发 `_engMath.easing.quadIn(t)`(不钳)→ `quadIn(1.5)` 走门面得 `Ease.POWER(2)(1.5)===2.25`≠fallback 的 1 → **破「越界钳制」测 + 门面/fallback 行为分叉** → 挡回。
> **A1 协同备注(移交,非本 lane 改)**:A1 门面 `math.easing` 暴**裸引擎曲线**(不钳),钳由 gamefeel 施。**REPORT 须把此 clamp 口径写清移交 A1**(A1 若误在门面内加钳,则 gamefeel 双钳无害但冗余;若 A1 门面承钳而 gamefeel 不钳,则 fallback 路径不钳→行为分叉,故钳留 gamefeel 最稳)。
#### 行级落点
| 落点(段锚点) | 改动 | 类别 |
|---|---|---|
| **文件头注释块:「本文件定位」段第 3 项 easing 描述(`:9` "缓动标准库(easing):...纯函数")** | **改写该项措辞**:`3) 缓动标准库(easing):linear/quad/cubic/elastic/back 的 in/out/inOut 全 family——经 ctx.getEngine().math.easing 门面包装引擎 Ease(A1 工厂建),null-engine(host-dev/node 无引擎)退内置纯函数 fallback(纯数学族特例,政策 §1.3)。` | 注释·定位段措辞改判 |
| **文件头注释块:「受控面用法」段 easing 句(`:14` "缓动为纯函数不触受控面")** | **改写**:`缓动经 getEngine().math.easing 门面取引擎 Ease 曲线(门面优先),无引擎时退内置纯函数 fallback——内置 fallback 不触受控面、确定性。` | 注释·受控面段措辞改判 |
| **文件头注释块:「模板哲学红线」段(`:17-19`)之后、`'use strict';`(`:22`)之前** | **新增一段**「【引擎↔插件 边界裁定(2026-06-13 创始人拍·政策 §1.1/§1.3/§1.5/§4.4)】」:<br>① **easing 族 = 门面包装引擎 `Ease`(改判·非补层)**:引擎**有**完整 `Ease` 曲线族(`littlejs.esm.js:15118`:`POWER(2/3)`=quad/cubic、`BACK`=back、`ELASTIC`=elastic + `OUT/IN_OUT` 修饰器)→ gamefeel easing **经 `ctx.getEngine().math.easing` 门面包装引擎 `Ease`**(A1 工厂建);内置实现(下方 `builtinEasing`)**仅留作 null-engine fallback**(纯数学族特例,公式与引擎恒等:quad 逐字节恒等、cubic/back/elastic ULP 级 <1e-9,**非有意替代、非补层**)。**钳口径**:门面分支 `clamp01(t)` 后调引擎裸曲线(保越界钳语义,见 edit-plan 决策点)。<br>② **【删假注释红线】严禁出现"引擎无 easing / easing=补层 / 不得误判为待包装"等措辞**——前轮按小写 `quadIn` grep 引擎假阴性误判"引擎无 easing",已被政策 §1.5 推翻;引擎实有 `Ease`,easing 是**包装**不是补层。<br>③ **InputBuffer/CoyoteTimer/ComboWindow = 补层**:引擎无"输入缓冲窗/coyote 宽限/连击窗"时间窗记账 → 经受控面 `getInput`/`time` 自研,全留全测(**这三件才是 gamefeel 的补层**,与 easing 区分)。 | 注释·边界裁定留痕(**删假注释 + easing 门面包装 + 时间窗补层根因**) |
| **缓动库段注释块(`:50-55`)** | **改写段头注释**:把「一、缓动标准库(纯函数,端点恒等...)」改为「一、缓动内置实现 `builtinEasing`(**null-engine fallback**,纯函数端点恒等 f(0)=0/f(1)=1)——引擎可用时经门面走引擎 `Ease`,此处仅 host-dev/node 无引擎时的 fallback(政策 §1.3 纯数学特例)」。设计注释(`:53-54` out/inOut 镜像/拼接说明)保留。 | 注释·段头措辞改判("标准库"→"内置 fallback") |
| **内置 easing 各函数(`:57-133`:quadIn/Out/InOut、cubicIn/Out/InOut、elasticIn/Out/InOut、backIn/Out/InOut、linear)** | **实现体零改**(公式全留,作 fallback)。**仅建议**把这批 `const quadIn=...` 等的**集合归拢**——见下 `easing` 单例改动;函数本身 `const` 定义可原地不动。 | fallback 实现保留(零改公式) |
| **`easing` 导出单例(`:135-145` `export const easing = Object.freeze({...})`)** | **核心改造**:现 `easing` 直接 freeze 内置函数集。改为:<br>① **先把内置集改名 `builtinEasing`**(模块内部 const,不 export 或仅 export-for-test):`const builtinEasing = Object.freeze({ linear, quadIn, quadOut, ..., backInOut });`(即把原 `easing` 体改名 `builtinEasing`)。<br>② **`easing` 改为"门面优先 + fallback"工厂产物**——但 `easing` 当前是**模块级单例**(无 ctx),而门面句柄 `_engMath` 须 init 时经 ctx 取 → **二选一(§5 R1 钉死)**:<br> **方案 A(默认·插件实例级 easing)**:`createGamefeelPlugin` 的 plugin 对象**新增 `easing` getter**,返回一个"经本插件 `_engMath` 门面优先、null 退 `builtinEasing`"的 family 对象;模块级 `export const easing` **保留=`builtinEasing` 同物**(向后兼容:现有 `import {easing}` 消费者拿到的是 fallback 实现,行为与改判前逐字节一致——因模块级无 ctx 本就拿不到引擎)。<br> **方案 B(模块级 easing 接受可选 engMath 参数)**:把 `easing` 各函数改为 `(t, engMath) => engMath ? engMath.easing.quadIn(clamp01(t)) : builtinQuadIn(t)`——**破坏现有 `EasingFn=(t)=>number` 契约签名**(`api.d.ts:27`),**否决**(改契约+破现有 6 个 easing 测的 `fn(t)` 单参调用)。<br>**裁定:采方案 A**——模块级 `export const easing` 维持 `=builtinEasing`(签名/行为不变,零破现有消费者与 6 测);门面包装经**插件实例 `plugin.easing` getter** 暴露(gamefeel 作插件被 host 装载时,经 ctx 取 `_engMath`,easing 门面优先)。**新增门面测针对 `plugin.easing`**(§3.2)。 | 包装·easing 门面化(方案 A:模块级 fallback 兼容 + 插件实例门面 getter) |
| **`createGamefeelPlugin`(`:378-418`):闭包顶部 `let inputBuffer = null;`(`:380-381`) 邻处 + `init(ctx)`(`:397-404`) 体内 + plugin 对象(`:385-416`)** | ① 闭包顶部**新增** `let engMath = null;`(与 `inputBuffer`/`disposed` 同级,`:381` 邻)。<br>② `init(ctx)` 体内**新增**(在接 `inputBuffer` 之后,`:403` 后):<br>`// 缓存引擎 math 门面句柄(A1 工厂注入 getEngine().math,含 easing);host-dev/node 无引擎时 null,easing 退内置 builtinEasing fallback(政策 §1.3 纯数学特例)。`<br>`engMath = ctx.getEngine() ? ctx.getEngine().math : null;`<br>③ plugin 对象**新增 `easing` getter**(与 `inputBuffer` getter `:390-392` 同级):<br>`// 手感缓动 family:门面优先(经 init 缓存的 engMath.easing 包装引擎 Ease,clamp01 包裹保钳),null-engine 退内置 builtinEasing fallback。`<br>`get easing() { return makeEasingFacade(engMath); }`(`makeEasingFacade` 为模块级 helper,见下行;或直接 inline 返回 family 对象)。<br>**严守受控面铁律**:经 `ctx.getEngine()`,**零直接 import littlejsengine**(Q4 confinement 守住,门③ N1/N2)。 | 包装·init 缓存门面句柄 + 插件实例 easing getter(真接线,N12b 正面命中) |
| **模块级 helper(建议落在 `builtinEasing` 定义之后、`InputBuffer` class 之前,`:146` 邻)** | **新增 `makeEasingFacade(engMath)`**(export-for-test):返回一个 family 对象,每条曲线 = "有 `engMath?.easing?.<name>`→`(t)=>engMath.easing.<name>(clamp01(t))`、否则 `builtinEasing.<name>`"。<br>`// 造一份"门面优先、null 退内置 fallback"的缓动 family(政策 §1.3 特例)。`<br>`// 门面分支 clamp01(t) 后调引擎裸曲线(引擎 Ease 不钳,gamefeel 须钳保越界语义,见 edit-plan 决策点;与 builtinEasing 起手 clamp01 同口径,行为零分叉)。`<br>`// engMath 缺位/缺 easing 成员(A1 未落)→整体退 builtinEasing。`<br>`export function makeEasingFacade(engMath) {`<br>` const e = engMath && engMath.easing;`<br>` if (!e) return builtinEasing;` // 无门面:整体退内置(fallback)<br>` const wrap = (name) => (e[name] ? (t) => e[name](clamp01(t)) : builtinEasing[name]);`<br>` return Object.freeze({ linear: wrap('linear'), quadIn: wrap('quadIn'), quadOut: wrap('quadOut'), quadInOut: wrap('quadInOut'), cubicIn: wrap('cubicIn'), cubicOut: wrap('cubicOut'), cubicInOut: wrap('cubicInOut'), elasticIn: wrap('elasticIn'), elasticOut: wrap('elasticOut'), elasticInOut: wrap('elasticInOut'), backIn: wrap('backIn'), backOut: wrap('backOut'), backInOut: wrap('backInOut') });`<br>`}`<br>**设计要点**:① per-curve 检测 `e[name]`(A1 门面可能只暴部分曲线或全暴;缺某条则该条退内置,稳健);② `clamp01(t)` 包裹(决策点);③ 返回 frozen family,签名 `(t)=>number` 与 `EasingFn`(`api.d.ts:27`)一致(消费侧零感知门面/fallback)。 | 包装·门面工厂 helper(per-curve 门面优先 + clamp01 包裹 + fallback 兜底,export-for-test 防 orphan 死代码) |
> **「无 orphan」自检(政策 §4.4·改判后自然满足)**:改判前忧"gamefeel 无 math 用点→门面 orphan";**改判后 easing 即真实重度消费点**(gamefeel 本就全程用 easing,F1/F8 实证有完整 easing 体 + 6 个 easing 测)→ `plugin.easing` getter / `makeEasingFacade` 有真消费(插件被装载即经 ctx 取门面),**非 orphan**。**反向自检**:改后 gamefeel `easing` 对外输出值须与改前数学等价——门面与 fallback 数学等价(quad 字节恒等、cubic/back/elastic ULP 级 <1e-9,clamp 口径经决策点对齐),juice 视觉无可感差异。**REPORT 须注明**:"easing 改经门面后输出与改前数学等价(quad 字节恒等 / cubic·back·elastic ULP 级 <1e-9)、越界钳语义经 clamp01 包裹保持不变,无行为回归"。
### 2.2 `src/plugins/collision/impl.js` —— 补层裁定注释(1 段)
| 落点(段锚点) | 改动 | 类别 |
|---|---|---|
| **文件头注释块:「受控面纪律」段(`:11-17`)之后、「模板哲学红线」段(`:19`)之前**(行号亲验:`受控面纪律`@`:11`、`模板哲学红线`@`:19`) | **新增一段**「【引擎↔插件 边界裁定(2026-06-13·政策 §2.3·**真缺件补层**)】collision 全套(空间哈希 broadphase / 圆-圆·圆-AABB·AABB-AABB·凸多边形 SAT 窄相 + MTV / 射线投射)= **补层**——引擎 `littlejsengine` **无对应受控面碰撞查询能力**(已源码复审证实:引擎 `EngineObject` 提供的是"对象级物理积分 + 碰撞响应",**非"给定任意几何体求 MTV / RayHit"的纯查询 API**)→ 自研合法保留,全为纯数学几何、确定性可测。**不得误判为待包装**(无引擎对应可包装;区别于 easing/particles/audio 那类引擎有→须包装的能力)。」 | 注释·补层裁定留痕(引擎物理碰撞响应≠几何查询 API) |
### 2.3 `src/plugins/physics-lite/impl.js` —— 补层裁定注释(1 段)
| 落点(段锚点) | 改动 | 类别 |
|---|---|---|
| **文件头注释块:「与碰撞插件解耦」段(`:12-15`)之后、「受控面纪律」段(`:16`)之前**(行号亲验:`与碰撞插件解耦`@`:12`、`受控面纪律`@`:16`、`模板哲学红线`@`:19`) | **新增一段**「【引擎↔插件 边界裁定(2026-06-13·政策 §2.3·**真缺件补层**)】physics-lite(抛体弹道积分 / 弹簧-阻尼 / 运动学约束移动 / 速度限幅摩擦)= **补层**——引擎有完整刚体物理(`EngineObject` 质量/速度/碰撞响应),但**那是"对象级全刚体物理仿真"**(已源码复审证实:`EngineObject` 是全刚体、非离散运动小工具);本插件要的是"**街机手感的离散运动数学小工具**(无对象、纯函数/小状态、调用方自控积分)",引擎**无此受控面能力** → 自研合法保留。文件内"线性插值口径"指欧拉残余部分步(`semiImplicitStep` 残余 dt),**非引擎 `lerp` 用点**(不可误接门面)。**不得误判为待包装。**」 | 注释·补层裁定留痕(引擎全刚体仿真≠街机离散运动小工具;澄清非 lerp 用点) |
### 2.4 `src/plugins/palette-post/impl.js` —— 补层裁定注释(1 段)
| 落点(段锚点) | 改动 | 类别 |
|---|---|---|
| **文件头注释块:「P5 红线」段(`:13-17`)之后、「确定性铁律」段(`:18`)之前**(行号亲验:`P5 红线`@`:13`、`确定性铁律`@`:18`) | **新增一段**「【引擎↔插件 边界裁定(2026-06-13·政策 §2.3·**真缺件补层**)】palette-post(索引色映射换色引擎 / 两表插值过渡 / HSL 偏移 + vignette/dither/scanline 后处理)= **补层**——引擎 `littlejsengine` **无"运行时换色 + canvas2D 屏幕后处理"的受控面能力**(已源码复审证实:引擎后处理走 WebGL glOverlay 着色器管线,本插件走 canvas 2D 受控面 `getContext2d`;且 P5 红线要求"只给换色引擎不给成品色板")→ 自研合法保留,纯函数/确定性查表。**不得误判为待包装。**」 | 注释·补层裁定留痕(引擎 WebGL 后处理≠canvas2D 受控面换色) |
---
## 3. 测试计划(按 15 块基线·掏空判据正面锚 + 门面/null 新增覆盖)
> 政策 §5.1 掏空判据(机读):**删除断言数 ≤ 新增等价断言数,且补层/fallback 断言总数不减。** A4 **零删**(被测对象 15 块全留——6 easing 块即 fallback 路径确定性测,9 时间窗/插件块即补层测),仅**新增** 2 块门面/null 覆盖 → 天然满足(删 0,补 ≥2 块/≥6 断言)。
### 3.1 保留不动(15 块全留·掏空正面锚 + fallback 路径覆盖)
`src/plugins/gamefeel/test/gamefeel.test.mjs` **全部 15 个 test 块零改**(行号亲验):
- **easing 6 块(`:43/55/63/80/85/100`)= 内置 fallback 纯函数确定性测**:端点恒等 f(0)=0/f(1)=1、**越界钳制 t<0→f(0)/t>1→f(1)**(`:55-61`,**钳行为锚——决策点保此测绿**)、单调族无越界、quad/cubicInOut 中点 0.5、back 中段越界、elastic 振荡有限。<br>**政策 §1.3/§5.1-②:这 6 块测的是 easing 内置 fallback——null-engine(host-dev/node 无引擎,gamefeel 测正走此路)下的真实运行路径,绝不删(删之即掏空 fallback 覆盖)。** 现有测经 `import {easing}`(=`builtinEasing`,方案 A)跑 fallback 实现,零改即绿。
- **InputBuffer 3 块(`:118/137/148`)/ CoyoteTimer 2 块(`:163/180`)/ ComboWindow 2 块(`:201/213`)= 时间窗补层测**:窗口边界、只缓冲关注类型、dispose 注销、coyote 边界/consume 复位、连击累加/断连/封顶。<br>**这是 gamefeel 真补层(引擎无),政策 §5.1-② 明令绝不删——"测试不掏空"的正面锚。**
- **插件协议 2 块(`:235/268`)**:经 PluginRegistry 接受控面 + 注册器兜底回收。
### 3.2 新增(按 15 块基线·门面转发 + null 退内置,掏空判据"等量补"硬要求)
> **政策本轮硬要求**(prompt 引):「删 easing 自研断言须等量补'门面包装真调断言(mock 引擎注入断 easing 走 Ease)+ 内置 fallback 恒等断言'」。A4 **零删** easing 断言(6 块全留作 fallback 测)→ **fallback 恒等断言已在 §3.1 满足**;**门面包装真调断言**由下方**新增 2 块**补足。
`gamefeel.test.mjs` **新增 2 个 test 块**:
**块①:easing 门面转发(注入 mock 引擎 `math.easing`,断真走门面 + clamp01 包裹)**
```
test('easing 门面:插件经 getEngine().math.easing 门面包装引擎 Ease(真转发 + clamp01 钳)', () => {
// 1) 造 fake EngineCapabilities,math.easing 用"打标记"曲线(非真 Ease,便于断转发):
// const calls = [];
// const fakeEasing = {}; for (const n of ['linear','quadIn',...,'backInOut'])
// fakeEasing[n] = (t)=>{ calls.push([n,t]); return 0.5; };
// const fakeEngine = { particles:{...no-op...}, audio:{synth:{...}}, math:{ lerp:()=>0, smoothStep:()=>0, easing: fakeEasing } };
// 2) 经 host-dev context 注入 engineFactory 返 fakeEngine(createHostDevContext({ engineFactory: ()=>fakeEngine })),
// 装载 gamefeel 插件、initAll,取 plugin.easing:
// assert.equal(plugin.easing.quadIn(0.3), 0.5); // 门面分支真返回引擎结果
// assert.deepEqual(calls.at(-1), ['quadIn', 0.3]); // 真转发到引擎 easing.quadIn
// 3) ★ clamp01 包裹证据(决策点):越界入参经 clamp01 后才进引擎曲线:
// plugin.easing.quadIn(1.5); assert.deepEqual(calls.at(-1), ['quadIn', 1]); // 1.5→clamp→1
// plugin.easing.quadIn(-0.5); assert.deepEqual(calls.at(-1), ['quadIn', 0]); // -0.5→clamp→0
// (若实现误裸转发不钳,calls 会记 1.5/-0.5,断言挡回——这是 clamp 口径的逐字节兜底)
});
```
**块②:null-engine 退内置 fallback(getEngine() 返 null 时 easing 走 builtinEasing,端点/钳不变)**
```
test('easing null-engine:getEngine() 返 null 时 easing 退内置 fallback(端点恒等 + 越界钳不变)', () => {
// host-dev 默认无 engineFactory → getEngine() 返 null(政策 §3 null 语义)。
// 装载 gamefeel、initAll,取 plugin.easing(应 === builtinEasing 行为):
// 端点恒等:assert.ok(Math.abs(plugin.easing.quadIn(0))<1e-9); assert.ok(Math.abs(plugin.easing.quadIn(1)-1)<1e-9);
// 越界钳(与门面分支同口径):assert.ok(Math.abs(plugin.easing.quadIn(1.5)-plugin.easing.quadIn(1))<1e-9);
// fallback 与模块级 easing 一致(方案 A 兼容):assert.equal(plugin.easing.quadIn(0.5), easing.quadIn(0.5)); // 均=0.25
// ★ 证 null 不抛:plugin.easing 各曲线可调、返有限值(无 _engMath.easing 时不 throw)。
});
```
> **import 调整**:`gamefeel.test.mjs:18-24` import 列表**新增** `makeEasingFacade`(若块①/②直接用 `plugin.easing` getter 测,可不导 helper;但建议导出 `makeEasingFacade` 供纯函数级单测)。**`easing` 仍导入**(=builtinEasing,§3.1 现有 6 测继续用)。其余导入零改。
### 3.3 掏空判据自检(A4 提交后机读·按 15 块基线)
| 项 | 期望 |
|---|---|
| 删除断言数 | **0**(15 块全留,6 easing 块=fallback 测、9 时间窗/插件块=补层测,无一删) |
| 新增等价断言数 | **≥6**(块① 门面转发 2 + clamp 钳 2、块② 端点 2 + 越界钳 1 + null 不抛/一致 ≥1) |
| 补层断言总数 | **不减**(9 时间窗/插件块原样) |
| fallback 断言总数 | **不减**(6 easing 块原样——政策硬要求"内置 fallback 恒等断言"已由现存 6 块满足) |
| 门面包装真调断言 | **新增 ≥1**(块① mock 引擎断 `easing.quadIn` 真走 `Ease`,政策硬要求) |
| 结论 | 删 0 ≤ 增 ≥6 ∧ 补层不减 ∧ fallback 不减 ∧ 门面真调断言已补 → **非掏空** |
> **REPORT 须注明**:① 采方案 A(模块级 `easing`=builtinEasing 兼容 + 插件实例 `plugin.easing` 门面 getter);② easing 改门面后输出与改前数学等价(quad 字节恒等 / cubic·back·elastic ULP <1e-9)、越界钳经 clamp01 保持;③ 6 个 easing 块作 fallback 测全留、新增 2 块门面/null 测。**补层注释主线(3 文件)无条件加。**
---
## 4. 契约影响
| 契约件 | A4 影响 | 说明 |
|---|---|---|
| **core `api.d.ts`** | **零改** | `EngineMath`(`:124-129`)加 `easing` 成员 = **A1 落点**(政策 §4.1 additive 加 easing 面,**不动已冻 lerp/smoothStep**);A4 只**消费** A1 落地后的 `getEngine().math.easing`,**不改契约**。**`EngineCapabilities` 文档注释(亲验两处:`:134-135` "数学退插件内置/合成核退 vendored/粒子退受控面自管" + `:233` 同义"数学退插件内置纯函数")是旧降级模型措辞,与新政策"null→no-op/数学退内置 fallback"有漂移——但 api.d.ts 属 A1/A2/A3 territory(政策 §4.4 钉死 A4 不碰),本 lane 仅记账,§5 open-Q-3 移交 A1。** |
| **gamefeel `api.d.ts`** | **零改** | `EasingLib`(`:33-64`)/`EasingFn`(`:27`)/`InputBuffer`/`CoyoteTimer`/`ComboWindow`/`createGamefeelPlugin`/`GamefeelPlugin` 形状不变。**`easing` 模块级单例签名不变**(`:67 declare const easing: EasingLib`,方案 A 维持 `=builtinEasing`,`EasingFn=(t)=>number` 不变);`plugin.easing` getter 返回的也是 `EasingLib` 形状(family 对象,同签名)→ **可选 additive 加 `GamefeelPlugin.easing: EasingLib` 声明**(与现有 `inputBuffer` getter `:227` 同档,additive、不破现有消费者)。`makeEasingFacade` 是 **impl 内部 export-for-test 件**,**不进 gamefeel 公开 api.d.ts**。 |
| **`host.js`/particles/audio** | **零改** | 本 lane 边界硬约束:绝不碰 host.js 工厂 / particles / audio(F5 亲验 gamefeel 不触 host.js 工厂;音频定位归 A3,open-Q-1)。 |
> **结论:A4 契约影响 = 零破消费者**。① core `api.d.ts` 不动(`EngineMath` 加 easing 是 A1 的 additive);② gamefeel `api.d.ts` 模块级 `easing`/`EasingFn` 签名行为不变(方案 A 兼容现有 `import {easing}` 消费者,逐字节一致——因模块级无 ctx 本拿不到引擎、恒走 fallback=改判前实现);③ 唯一可选 additive = `GamefeelPlugin.easing` getter 声明(与 `inputBuffer` getter 同档,加可选成员、不破已有形状)。**A4 与 A2/A3 全程并行**(不碰 host.js/api.d.ts 工厂面)。
---
## 5. 风险 / open questions
| # | 项 | 类别 | 处置 |
|---|---|---|---|
| **R1** | easing 门面化的暴露方式(模块级 `easing` vs 插件实例 `plugin.easing`) | 设计(已裁) | §2.1 钉死**方案 A**:模块级 `export const easing` 维持 `=builtinEasing`(签名/行为/字节不变,零破现有 `import {easing}` 消费者与 6 个 easing 测);门面包装经**插件实例 `plugin.easing` getter** 暴露(gamefeel 被 host 装载时经 ctx 取 `_engMath`,门面优先)。**否决方案 B**(模块级 easing 加 engMath 参数→破 `EasingFn=(t)=>number` 契约 + 破现有 `fn(t)` 单参测)。 |
| **R2** | clamp 分歧:裸转发引擎曲线(不钳)破现存「越界钳制」测 + 门面/fallback 行为分叉 | 风险(已防·载荷) | §2.1 决策点钉死:门面分支 `_engMath.easing.quadIn(clamp01(t))`(**先钳再调引擎裸曲线**)→ 越界钳语义保持、与 fallback 同口径、quad 字节恒等。块① 测含"clamp01 钳证据"(`quadIn(1.5)` 断引擎收到 1)逐字节兜底。**钳留 gamefeel(与内置同口径),不要求 A1 门面承钳——REPORT 移交 A1 写清此口径。** |
| **R3** | cubic/back/elastic 门面与 fallback NOT 逐字节恒等(ULP 级 <1e-9) | 风险(已记账·无害) | F-EQ/政策 §0.2 诚实记账:纯数学族特例资格锚=公式恒等(**非字节恒等**),easing 仅视觉插值、无字节级复现门校(same-pixel 已退役)→ ULP 级差无害。**REPORT 须注明"cubic/back/elastic 门面与 fallback ULP 级等价(<1e-9),quad 字节恒等"**,不得宣称全 family 字节恒等。 |
| **R4** | A1 未落时 `_engMath.easing` 为 undefined(`EngineMath` 当前无 easing 成员,F4) | 低风险(已防) | `makeEasingFacade` per-curve 检测 `e[name]`:`engMath` 无 / 无 `.easing` / 缺某曲线 → 该路退 `builtinEasing`(§2.1 helper 设计)。故 A1 未落、或 A1 只暴部分曲线,gamefeel 均安全退内置,不抛。**A4 可在 A1 落地前独立验**(null-engine 路径=现有 6 测 + 新增块②)。 |
| **R5** | 注释/代码插入致后续行号顺延,实现 lane 死记行号定位失败 | 低风险 | 落点以**段锚点**("X 段后/Y 段前")描述,非纯行号;实现 lane 落地前 `sed -n` 复核锚点(physics-lite 文件头段行号 §2.3 已标"以 sed 复核")。 |
| **R6** | `engMath` 缓存后仅被 `plugin.easing` getter 读(经 `makeEasingFacade(engMath)`)→ 确有读取点,无"assigned but never used" | 无(改判后消解) | 改判前忧 `engMath` 备而未用;改判后 `plugin.easing` getter 每次调 `makeEasingFacade(engMath)` → `engMath` 有真读取点,非死变量。 |
| **open-Q-1** | **「音频定位」补层注释归属** | open-Q(已裁) | prompt 列本 lane 含「音频定位」,但实体在 `audio-music/impl.js`(pan/positional,A3 owner,本 lane「绝不碰 audio」)→ **裁定:音频定位补层注释归 A3**(A3 删 vendored + 接 synth 时一并加),本 lane 不动。**移交主会话/A3 确认。** 若坚持 A4 标注则与 A3 同改 `audio-music/impl.js` 冲突,违本 lane 边界——不可行。 |
| **open-Q-2** | A1 门面 `math.easing` 的暴露形态(暴 `quadIn/...` 命名键 vs 暴 `POWER/BACK + OUT/IN_OUT` 基曲线+修饰器) | open-Q(移交 A1) | 政策 §4.1 给 A1 二选一。**A4 helper 设计假定 A1 暴 `quadIn/quadOut/.../backInOut` 命名键**(与 gamefeel `EasingLib` 同名,§2.1 `wrap(name)` 直接按名取)。**若 A1 改暴基曲线+修饰器**(`power(n)/back/elastic + out/inOut`),则 A4 `makeEasingFacade` 须改为"组合调用"(如 `quadIn=(t)=>engMath.easing.power(2)(clamp01(t))`、`quadOut=(t)=>engMath.easing.out(engMath.easing.power(2))(clamp01(t))`)——**移交主会话协调 A1/A4 门面形状**,建议 A1 暴**命名键**(gamefeel 消费最简、A4 helper 零组合)。 |
| **open-Q-3** | core `api.d.ts:134-135` + `:233` `EngineCapabilities` 旧降级措辞漂移(两处) | open-Q(移交 A1) | 亲验两处 `:134-135`/`:233` "数学退插件内置/合成核退 vendored/粒子退受控面自管"是旧双码路模型措辞,与新政策"null→no-op / 数学退内置 fallback"冲突。**A4 不碰 api.d.ts**(政策钉死)→ 移交 **A1**(已在 api.d.ts territory)顺手校准"数学退内置 fallback"措辞。本 lane 仅记账。 |
| **open-Q-4** | 门③ N11 对 gamefeel `Math.*` 命中需否人核 | open-Q(已自验 PASS) | N11 grep `Math.random/Date.now/performance.now/RAF/addEventListener`;gamefeel 仅 `Math.pow/PI/sin/min`(easing/封顶)+ `clamp01`,**零 `Math.random` 等受控面违规**(F1 旁证)→ N11 对 gamefeel **PASS**,无需人核豁免。**N12b(改判新增)**:A4 后 `getEngine()` 在 gamefeel impl ≥1 命中(init 取门面)→ 正面守门 PASS(改判前空=FAIL)。 |
---
## 6. 一页纸总结(给实现 lane)
- **一句话(改判后)**:引擎**实有** `Ease` 曲线族(旧"引擎无 easing"是 grep 假阴性,政策 §1.5)→ gamefeel `easing` 改**经 `getEngine().math.easing` 门面包装引擎 `Ease`**(A1 工厂建门面),**内置实现降为 `builtinEasing` 仅 null-engine fallback**(纯数学族特例 §1.3,合法非补层);**删一切"引擎无 easing/easing=补层"假注释**。**零契约改、不碰 host.js/api.d.ts/particles/audio。**
- **改面**:① `gamefeel/impl.js`——文件头边界裁定段**重写**(删假注释 + easing 门面包装 + 时间窗补层根因)、内置 family 改名 `builtinEasing`(公式零改作 fallback)、新增 `makeEasingFacade(engMath)` helper(门面优先 + **`clamp01(t)` 包裹保钳** + per-curve fallback 兜底)、`init` 缓存 `engMath`、plugin 加 `easing` getter;② collision/③ physics-lite/④ palette-post 各文件头加 1 段补层裁定注释(引擎为何不覆盖·真缺件补层)。
- **★ clamp 决策(载荷·不可偏离)**:引擎 `Ease` 不钳、gamefeel 内置钳 + 现存「越界钳制」测断钳 → 门面分支 **`_engMath.easing.quadIn(clamp01(t))`(先钳再调引擎裸曲线)**,保钳语义 + 字节恒等(quad)+ 门面/fallback 零分叉。**裸转发不钳=破测=挡回。**
- **测试(15 块基线,非 9)**:15 块全留(6 easing 块=fallback 确定性测、9 时间窗/插件块=补层测,**零删=零掏空**);**新增 2 块**——门面转发(mock 引擎断 `easing.quadIn` 真走 `Ease` + clamp01 钳证据)+ null 退内置(端点/钳不变/不抛)。删 0 ≤ 增 ≥6 ∧ 补层不减 ∧ fallback 不减 ∧ 门面真调断言已补 → 非掏空。
- **不碰**:`host.js`、`api.d.ts`(core `EngineMath` 加 easing 是 **A1**;gamefeel api.d.ts 仅可选 additive `plugin.easing` 声明)、particles、audio(音频定位归 A3,open-Q-1)。
- **门③**:本 lane 触 N11(gamefeel 零受控面违规·PASS)+ N12(fallback 正面守门 `elasticIn/backOut/InputBuffer` ≥1·语义改"内置作 fallback 保留")+ **N12b(改判新增正面:`getEngine()` ≥1 命中=真接门面,改判前空=有意替代 FAIL)**。
- **并行性**:A4 零碰 host.js/api.d.ts 工厂面 → 与 A2/A3 **全程并行**(政策 §4.0)。
- **移交 A1(REPORT 写清)**:① easing 门面 `math.easing` 暴**裸引擎曲线(不钳)**,钳由 gamefeel 施(open-Q-2);② 建议 A1 暴 `quadIn/.../backInOut` 命名键(A4 helper 零组合最简);③ `EngineCapabilities` 旧降级措辞校准(open-Q-3)。

View File

@ -7,16 +7,16 @@
| 叠加项 | 累计 raw | 累计 gz | 增量 raw | 增量 gz | gz 预算 | 判定 |
|---|--:|--:|--:|--:|--:|:--|
| 基线壳 | 0 | 0 | — | — | — | 基准 |
| +core | 8724 | 3222 | 8724 | 3222 | — | — |
| +example | 9268 | 3467 | 544 | 245 | 2048 | ✔ ≤2048 |
| +audio-music | 16819 | 6540 | 7551 | 3073 | 12288 | ✔ ≤12288 |
| +collision | 21677 | 8539 | 4858 | 1999 | 8192 | ✔ ≤8192 |
| +gamefeel | 25176 | 9560 | 3499 | 1021 | 4096 | ✔ ≤4096 |
| +palette-post | 29587 | 11157 | 4411 | 1597 | 8192 | ✔ ≤8192 |
| +particles-juice | 34490 | 13060 | 4903 | 1903 | 10240 | ✔ ≤10240 |
| +physics-lite | 36895 | 13988 | 2405 | 928 | 6144 | ✔ ≤6144 |
| +runtime-probe | 39855 | 15187 | 2960 | 1199 | 6144 | ✔ ≤6144 |
| +save-progress | 43893 | 16485 | 4038 | 1298 | 2048 | ✔ ≤2048 |
| +core | 9327 | 3387 | 9327 | 3387 | — | — |
| +example | 9871 | 3630 | 544 | 243 | 2048 | ✔ ≤2048 |
| +audio-music | 15944 | 5702 | 6073 | 2072 | 12288 | ✔ ≤12288 |
| +collision | 20797 | 7733 | 4853 | 2031 | 8192 | ✔ ≤8192 |
| +gamefeel | 24714 | 8895 | 3917 | 1162 | 4096 | ✔ ≤4096 |
| +palette-post | 29123 | 10505 | 4409 | 1610 | 8192 | ✔ ≤8192 |
| +particles-juice | 33383 | 12086 | 4260 | 1581 | 10240 | ✔ ≤10240 |
| +physics-lite | 35786 | 13015 | 2403 | 929 | 6144 | ✔ ≤6144 |
| +runtime-probe | 38746 | 14215 | 2960 | 1200 | 6144 | ✔ ≤6144 |
| +save-progress | 42785 | 15487 | 4039 | 1272 | 2048 | ✔ ≤2048 |
合计插件 gz 增量 ≈ **13263B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。
合计插件 gz 增量 ≈ **12100B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。

View File

@ -0,0 +1,87 @@
/**
* host-dev/engine-math.js — 引擎数学库门面构造(纯函数,零引擎 import)
* owner:T1b-β A1 | 消费方:host-dev/host.js(real 通道工厂 makeEngineCaps 调它)+ A1 node 单测
*
* ════════════════════════════════════════════════════════════════════════════
* 【为什么单列本模块(不埋进 host.js)】
* A1 须在 real 通道把 EngineCapabilities.math(lerp/smoothStep + easing 门面)真活包装引擎。
* 把门面构造抽为「吃 ljs 参数」的纯函数,且放在**零引擎 import 的独立模块**,是 testability 刚需:
* · F7(node 实跑亲验):`import * as LJS from 'littlejsengine'` 在 node 顶层抛 `window is not defined`
* → host.js 因顶层 import 引擎,node 根本 import 不了 host.js(实测同样抛 window)。
* · 故门面纯函数必须住在**不 import 引擎**的模块里,node 测才能 import 它、注入「内联引擎 Ease 曲线的
* mock ljs」(逐字节复制 esm.js:15118)验门面映射正确性——全程不碰真引擎。
* blast radius:仅 host.js 内部组织(host.js 改为从本模块 import clampUnit/buildEngineMath);
* 不改契约形状(api.d.ts)、不改 plugin.js、不改任何插件 impl。
*
* 【引擎↔插件 边界(创始人 2026-06-13 拍)】
* 纯通用数学族特例(lerp/smoothStep/easing 曲线):引擎与门面公式逐字节/ULP 等价、无状态、无语义冲突
* → math façade=getEngine().math。本模块只构造门面(包装传入的 ljs),不持引擎引用、不做降级 sim。
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
/**
* 把 t 钳到 [0,1](与 gamefeel clamp01 同口径)。
* 为何必须钳:gamefeel 内置每条 easing 都以 clamp01(t) 打头(gamefeel/impl.js:59 等),且其测断越界钳制
* (gamefeel.test.mjs 断 fn(-0.5)===fn(0) / fn(1.5)===fn(1));门面是「替换 gamefeel easing 的等价件」,
* 须与被替换者越界行为一致。而引擎 Ease.POWER/LINEAR 等**不自钳**(POWER:(n)=>(x)=>x**n,越界 x 直接幂运算)
* → 门面不钳则对越界 t 行为 ≠ 内置 fallback → A4 改门面后行为回归。
* @param {number} t
* @returns {number} 钳制到 [0,1] 的值
*/
export function clampUnit(t) {
return t < 0 ? 0 : t > 1 ? 1 : t;
}
/**
* 【A1】构造引擎数学库门面(EngineMath:lerp/smoothStep + easing 11 条等价曲线)。
* 纯函数:仅依赖传入的 ljs(引擎名字空间,含 lerp/smoothStep/Ease)。host real 通道传真 LJS;node 测传 mock。
*
* ── math.lerp / math.smoothStep ──逐字节包装引擎(口径已对齐,引擎 lerp 自带 percent 钳制 esm.js:1444)。
* ── math.easing ──门面包装引擎 LJS.Ease 曲线族(esm.js:15118),**仅暴已 node 逐点亲验等价的 11 条曲线**:
* quadIn=POWER(2)(逐字节恒等)/ quadOut=OUT(POWER(2))(逐字节恒等)/ quadInOut=IN_OUT(POWER(2))(ULP)/
* cubicIn=POWER(3) / cubicOut=OUT(POWER(3)) / cubicInOut=IN_OUT(POWER(3))(ULP)/
* backIn=BACK / backOut=OUT(BACK) / elasticIn=ELASTIC / elasticOut=OUT(ELASTIC)(均 ULP <1e-15)。
* 每条调引擎曲线前先 clampUnit(t)(引擎 Ease 不自钳,须钳齐保端点恒等+越界一致)。
* **刻意不暴 backInOut/elasticInOut**:引擎 IN_OUT(BACK/ELASTIC) 经 PIECEWISE 拼接是**另一条曲线**
* (back 差 6.6%、elastic 差 17%,非 ULP)→ gamefeel 对这二者恒走内置(引擎无此精确曲线=补层,非 fallback)。
*
* ── 性能:POWER(2)/OUT(POWER(2))/IN_OUT(...) 每次调用会新建闭包;easing 门面在 juice 热路径每帧调,
* 故在此**预构造一次**这些曲线函数(_p2/_p3/_qOut/... 缓存),各 easing 方法只做 clampUnit + 调缓存曲线。
*
* @param {any} ljs 引擎名字空间(须含 lerp/smoothStep/Ease;real=LJS,测=内联引擎曲线的 mock)。
* @returns {import('../src/core/api.d.ts').EngineMath} 引擎数学门面(lerp/smoothStep/easing)。
*/
export function buildEngineMath(ljs) {
const Ease = ljs.Ease;
// 预构造曲线一次(避免每次 easing 调用重建 POWER/OUT/IN_OUT 闭包;功能等价,纯性能优化)。
const _p2 = Ease.POWER(2); // 二次基曲线 x**2
const _p3 = Ease.POWER(3); // 三次基曲线 x**3
const _qOut = Ease.OUT(_p2); // 二次缓出 1-(1-x)**2
const _qInOut = Ease.IN_OUT(_p2); // 二次缓入缓出(PIECEWISE 拼接)
const _cOut = Ease.OUT(_p3); // 三次缓出
const _cInOut = Ease.IN_OUT(_p3); // 三次缓入缓出
const _backOut = Ease.OUT(Ease.BACK); // 回弹缓出
const _elasticOut = Ease.OUT(Ease.ELASTIC); // 弹性缓出
return {
// ── lerp/smoothStep:逐字节包装引擎(F4/F5)──
lerp: (a, b, p) => ljs.lerp(a, b, p),
smoothStep: (p) => ljs.smoothStep(p),
// ── easing 门面:11 条等价曲线,每条钳 t∈[0,1] 后调预构造的引擎曲线 ──
easing: {
linear: (t) => Ease.LINEAR(clampUnit(t)),
quadIn: (t) => _p2(clampUnit(t)),
quadOut: (t) => _qOut(clampUnit(t)),
quadInOut: (t) => _qInOut(clampUnit(t)),
cubicIn: (t) => _p3(clampUnit(t)),
cubicOut: (t) => _cOut(clampUnit(t)),
cubicInOut: (t) => _cInOut(clampUnit(t)),
backIn: (t) => Ease.BACK(clampUnit(t)),
backOut: (t) => _backOut(clampUnit(t)),
elasticIn: (t) => Ease.ELASTIC(clampUnit(t)),
elasticOut: (t) => _elasticOut(clampUnit(t)),
// backInOut/elasticInOut 刻意不暴(引擎 IN_OUT 拼接≠gamefeel 闭式,真分歧 6.6%/17%);gamefeel 对其恒走内置。
},
};
}

View File

@ -42,6 +42,9 @@ import {
// setCanvasClearColor / BLACK / vec2 / mainCanvas / mainContext / frame / time / timeReal /
// paused / timeScale 等全经 LJS.* 触达。
import * as LJS from 'littlejsengine';
// 【A1】引擎数学门面构造(纯函数,零引擎 import):real 通道工厂 makeEngineCaps 用它包装引擎 lerp/smoothStep/Ease。
// 单列在 engine-math.js(不 import 引擎)→ node 单测可 import 它注入 mock ljs 验门面,规避 F7(host.js 顶层 import 引擎在 node 抛 window)。
import { buildEngineMath } from './engine-math.js';
/* ──────────────────────────────────────────────────────────────────────────
* 取证固定环境常量(与 test/harness/browser-evidence.cjs 的 FIXED_ENV 对齐)
@ -179,6 +182,127 @@ export function bootHostDev(opts) {
return realAudioCtx;
}
/**
* 【A1】引擎能力背书工厂:构造 EngineCapabilities 三面(math 含 easing 门面 / particles / audio.synth)。
* - 仅 real 通道返真能力面;stub/node 通道短路返 null(保 null-engine 回归战场 + 规避 F7 引擎 import 在 node 抛错)。
* real/stub 都经 buildBundle(setupAfterEngine 调),若无条件背书引擎,stub 通道也会拿到引擎能力 → 破 null 回归;
* 故首行按 engineMode 短路:stub→返 null(等价「未注入」,触发 plugin.js getEngineShared 的 null 分支:告警一次 + getEngine() 返 null)。
* - 引擎 import 唯一活点=本 host(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 对其保内置=补层)。
* - particles/audio.synth=A1 留空骨架占位,A2/A3 在同一工厂内**替换填体**(串行落,避免本函数合并冲突)。
* @returns {import('../src/core/api.d.ts').EngineCapabilities|null}
*/
function makeEngineCaps() {
if (engineMode !== 'real') return null; // stub/node:不背书引擎(保 null-engine 回归;F7:node 本就 import 不了引擎)
return {
// ── math(A1 落实现,含 easing 门面)──经模块级纯函数 buildEngineMath 包装真引擎 LJS(lerp/smoothStep/Ease)。
math: buildEngineMath(LJS),
// ── particles(A2 填体)──薄包装引擎 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) {
// 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(A3 填体)──薄包装引擎 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) => {
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) => {
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;
}
},
},
},
};
}
// 创建 host-dev 受控上下文:注入「渲染面 / 主种子 / 音频工厂」。
// 【A0 改点】受控面 getContext2d 的 backing:real 模式=引擎 engineInit 自建的 mainContext(纯 2D,
// setGLEnable(false) 后引擎只渲 2D 进 mainCanvas);stub 模式=host prepareCanvas(#game) 的 ctx2d(回滚桩)。
@ -218,6 +342,7 @@ export function bootHostDev(opts) {
seed,
audioFactory,
clock: () => mockNowMs,
engineFactory: makeEngineCaps, // 【A1】注入引擎能力工厂;工厂内按 engineMode 短路(stub→null,保 null-engine 回归)
});
// 合法包裹 bundle.deriveContextFor(host 拥有 bundle):注册器为每插件派生上下文时,记下该插件的
// 派生 random 实例引用。这样 host 取证时能复位「插件那一份」随机(见 derivedRandoms 注释)。
@ -350,7 +475,9 @@ export function bootHostDev(opts) {
g.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保插件逻辑像素口径
try { g.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ }
}
g.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见)——语义同 α
// 【A2 语义更新】引擎粒子自带色(colorStartA/B→colorEndA/B,host 工厂已映射),引擎自渲落 mainContext,不读此 fillStyle;
// 插件 pj.render(g) 删 sim 后只画 juice 闪白叠加层(叠加层自设 #ffffff fillStyle)。此行降级为「juice 叠加层兜底可见色」的防御(最小改动保留,不动 save/restore 结构)。
g.fillStyle = '#7fd0ff'; // 防御:为 juice 叠加层兜底可见色(叠加层自设 fillStyle,引擎粒子不读此)
try { pj.render(g); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); }
g.restore();
}
@ -582,8 +709,9 @@ export function bootHostDev(opts) {
// 引擎一个 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。
// 【A2 更新】插件 sim 已删——real 通道粒子真身=引擎 ParticleEmitter(经 getEngine().particles 构造,自入 engineObjects),
// 由引擎内部 RAF 每帧自 update+render 落 mainContext,与 host tick 解耦。这里 bundle.tick 仅推进插件 juice 计时(补层);
// 引擎粒子的发射/积分/渲染全由引擎掌(gameRender 回调里 renderParticles 现只叠 juice 闪白层,不再画粒子)。
for (let i = 0; i < frames; i++) {
mockNowMs += FIXED_DT * 1000;
bundle.tick(FIXED_DT);

View File

@ -0,0 +1,222 @@
/**
* engine-caps.test.mjs — A1 引擎能力门面单测(node --test 直跑,零真引擎依赖)
* owner:T1b-β A1 | 运行:node --test host-dev/test/engine-caps.test.mjs
*
* ════════════════════════════════════════════════════════════════════════════
* 【测什么 / 为什么这样测】
* A1 落地:① host-dev/engine-math.js 的 buildEngineMath(包装引擎 lerp/smoothStep/Ease 成 math 门面,
* 含 easing 11 条等价曲线);② host.js 工厂 makeEngineCaps 注入 createHostDevContext.engineFactory
* → getEngine() 在 real 通道返三面、stub/无工厂返 null(plugin.js getEngineShared 已就绪,A1 只接线)。
*
* 【F7 铁律】`import * as LJS from 'littlejsengine'` 在 node 顶层抛 `window is not defined`(已实测)
* → 本测**不 import 真引擎、不 import host.js**(host.js 顶层 import 引擎,node 同样 import 不了)。
* 故:
* · 测门面映射正确性 → 注入**内联引擎 Ease 曲线的 mock ljs**(逐字节复制 littlejs.esm.js:15118,
* 下方 makeEngineOracle 标注源行号)作 oracle,比对 buildEngineMath 产出的门面逐点等价。
* 这不是掏空——是把「门面映射对不对 + 字节恒等/ULP 边界 + backInOut/elasticInOut 排除」钉死的正面锚。
* · 测工厂注入/getEngine 接线 → 用真 createHostDevContext(plugin.js,零引擎),注入「返回 buildEngineMath
* 门面的 mock 工厂」验 getEngine().math.easing 通;无工厂验 getEngine()==null(real runtime 契约)。
* 集成段 real 通道「门面真调真引擎」的证据由门②(mini-desktop CDP __engineCalls)兜(A1 不做,主会话事后)。
* ════════════════════════════════════════════════════════════════════════════
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildEngineMath, clampUnit } from '../engine-math.js';
import { createHostDevContext } from '../../src/core/plugin.js';
// gamefeel 内置 easing(A4 的 null-engine fallback):T-A1-c 用它比对门面越界钳制一致。
import { easing as gamefeelEasing } from '../../src/plugins/gamefeel/impl.js';
/* ──────────────────────────────────────────────────────────────────────────
* 引擎 Ease oracle(逐字节复制 littlejs.esm.js:15118 `const Ease`,不 import 真引擎,规避 F7)。
* lerp/smoothStep 同复制 esm.js:1444/:1500。每处标源行号,复审可逐字节复验。
* ────────────────────────────────────────────────────────────────────────── */
function makeEngineOracle() {
// esm.js:1444 lerp(自带 percent 钳制:clamp(percent))
const clamp = (v, min = 0, max = 1) => (v < min ? min : v > max ? max : v);
const lerp = (valueA, valueB, percent) => valueA + clamp(percent) * (valueB - valueA);
// esm.js:1500 smoothStep
const smoothStep = (percent) => percent * percent * (3 - 2 * percent);
// esm.js:15118 const Ease(仅复制 A1 门面用到的曲线 + 修饰器)
const PI = Math.PI;
const sin = Math.sin;
const Ease = {
LINEAR: (x) => x,
POWER: (n) => (x) => x ** n,
BACK: (x) => x * x * (2.70158 * x - 1.70158),
ELASTIC: (x) =>
x === 0 ? 0 :
x === 1 ? 1 :
-(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
IN: (f) => f,
OUT: (f) => (x) => 1 - f(1 - x),
IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
PIECEWISE: (...fns) => {
const n = fns.length;
return (x) => {
const i = (x * n - 1e-9) >> 0;
return (fns[i]((x - i / n) * n) + i) / n;
};
},
};
return { lerp, smoothStep, Ease };
}
/** 门面 easing 键 → oracle 引擎曲线(同 A1 映射;用于逐点比对)。 */
function oracleEasingMap(oracle) {
const E = oracle.Ease;
return {
linear: E.LINEAR,
quadIn: E.POWER(2),
quadOut: E.OUT(E.POWER(2)),
quadInOut: E.IN_OUT(E.POWER(2)),
cubicIn: E.POWER(3),
cubicOut: E.OUT(E.POWER(3)),
cubicInOut: E.IN_OUT(E.POWER(3)),
backIn: E.BACK,
backOut: E.OUT(E.BACK),
elasticIn: E.ELASTIC,
elasticOut: E.OUT(E.ELASTIC),
};
}
const EASING_11 = [
'linear', 'quadIn', 'quadOut', 'quadInOut',
'cubicIn', 'cubicOut', 'cubicInOut',
'backIn', 'backOut', 'elasticIn', 'elasticOut',
];
/* ════════════════════════════════════════════════════════════════════════════
* T-A1-a:工厂注入 / getEngine 接线(真 createHostDevContext,零引擎)
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-a|无 engineFactory(stub/node 短路语义):getEngine() 返 null(plugin.js 容错降级路径成立)', () => {
// host.js makeEngineCaps 在 stub 通道短路返 null ≡「createHostDevContext 不收工厂」→ getEngine()==null。
const bundle = createHostDevContext({ seed: 1 });
assert.equal(bundle.context.getEngine(), null, '无工厂时 getEngine() 必须返 null(不抛错)');
// 再调一次仍 null(lazy 单例 + 告警闸不影响返回值)。
assert.equal(bundle.context.getEngine(), null);
});
test('T-A1-a|有 engineFactory(real 通道注入语义):getEngine() 返三面、math.easing 门面在', () => {
const oracle = makeEngineOracle();
// 模拟 host.js makeEngineCaps real 分支:math=buildEngineMath(LJS),particles/audio.synth 空骨架占位。
const fakeFactory = () => ({
math: buildEngineMath(oracle),
particles: { spawnEmitter: () => ({ isActive: () => false, stop: () => {} }) },
audio: { synth: { synthSfx: () => null, synthSong: () => null } },
});
const bundle = createHostDevContext({ seed: 1, engineFactory: fakeFactory });
const eng = bundle.context.getEngine();
assert.ok(eng, 'getEngine() 应返真能力面');
assert.ok(eng.math && typeof eng.math.lerp === 'function', 'math.lerp 在');
assert.ok(eng.math.easing && typeof eng.math.easing.quadIn === 'function', 'math.easing.quadIn 在');
// 工厂只调一次(lazy 单例):第二次 getEngine 返同一对象。
assert.equal(bundle.context.getEngine(), eng, 'getEngine 应 lazy 单例(同一实例)');
});
/* ════════════════════════════════════════════════════════════════════════════
* T-A1-b:easing 门面 11 条曲线逐点等价引擎 Ease oracle + backInOut/elasticInOut 排除
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-b|easing 门面 11 条逐点等价引擎 Ease(quad 字节恒等 / 余 ULP)+ 二条 inOut 不在门面', () => {
const oracle = makeEngineOracle();
const math = buildEngineMath(oracle);
const oracleMap = oracleEasingMap(oracle);
// 逐点扫 x∈[0,1] 共 201 点。门面内部钳 t,与 oracle 在 [0,1] 内同曲线 → 比内点。
for (const name of EASING_11) {
const fac = math.easing[name];
const orc = oracleMap[name];
assert.equal(typeof fac, 'function', `门面应有 ${name}`);
let maxDiff = 0;
let byteEqualAll = true;
for (let i = 0; i <= 200; i++) {
const x = i / 200;
const a = fac(x);
const b = orc(x);
const d = Math.abs(a - b);
if (d > maxDiff) maxDiff = d;
if (!Object.is(a, b)) byteEqualAll = false;
}
// 全 11 条至少 ULP 等价(<1e-9,§0.1 实测 maxAbsDiff ≤ 2.1e-15)。
assert.ok(maxDiff < 1e-9, `${name} 门面与引擎 oracle 应 ULP 等价,实测 maxDiff=${maxDiff}`);
// quadIn/quadOut 应逐字节恒等(§0.1 实测 2001/2001 字节恒等)。
if (name === 'quadIn' || name === 'quadOut') {
assert.ok(byteEqualAll, `${name} 应与引擎 oracle 逐字节恒等`);
}
}
// backInOut/elasticInOut 刻意不入门面(引擎 IN_OUT 拼接是另一条曲线,差 6.6%/17%,非 ULP)。
assert.equal(math.easing.backInOut, undefined, 'backInOut 不应在门面(引擎无此等价曲线)');
assert.equal(math.easing.elasticInOut, undefined, 'elasticInOut 不应在门面(引擎无此等价曲线)');
});
/* ════════════════════════════════════════════════════════════════════════════
* T-A1-c:easing 门面越界钳制 == gamefeel 内置(fallback 行为一致,防 A4 改门面后回归)
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-c|easing 门面越界钳制:fn(-0.5)===fn(0)、fn(1.5)===fn(1),且与 gamefeel 内置越界逐值一致', () => {
const oracle = makeEngineOracle();
const math = buildEngineMath(oracle);
for (const name of EASING_11) {
const fn = math.easing[name];
// 门面自身越界钳制:越下界 ≡ f(0),越上界 ≡ f(1)。
assert.ok(Object.is(fn(-0.5), fn(0)), `${name}(-0.5) 应 === ${name}(0)`);
assert.ok(Object.is(fn(1.5), fn(1)), `${name}(1.5) 应 === ${name}(1)`);
// 与 gamefeel 内置 fallback 的越界行为逐值一致(gamefeel 同名曲线对越界亦 clamp01)。
const gf = gamefeelEasing[name];
assert.equal(typeof gf, 'function', `gamefeel 应有同名 ${name}`);
// 越界点门面与内置应同值(二者都钳到端点,端点值 ULP 等价)。
assert.ok(Math.abs(fn(-0.5) - gf(-0.5)) < 1e-9, `${name} 门面与内置在 t=-0.5 应等价`);
assert.ok(Math.abs(fn(1.5) - gf(1.5)) < 1e-9, `${name} 门面与内置在 t=1.5 应等价`);
}
});
/* ════════════════════════════════════════════════════════════════════════════
* T-A1-d:easing 门面端点恒等 f(0)=0 / f(1)=1(11 条全)
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-d|easing 门面端点恒等:11 条全 f(0)=0、f(1)=1(与 gamefeel 测同口径 <1e-9)', () => {
const oracle = makeEngineOracle();
const math = buildEngineMath(oracle);
for (const name of EASING_11) {
const fn = math.easing[name];
assert.ok(Math.abs(fn(0) - 0) < 1e-9, `${name}(0) 应 ≈ 0,实测 ${fn(0)}`);
assert.ok(Math.abs(fn(1) - 1) < 1e-9, `${name}(1) 应 ≈ 1,实测 ${fn(1)}`);
}
});
/* ════════════════════════════════════════════════════════════════════════════
* T-A1-e:lerp/smoothStep 门面口径(含引擎 lerp 自带 percent 钳制)
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-e|lerp/smoothStep 门面逐值等价引擎 + lerp 自钳 percent', () => {
const oracle = makeEngineOracle();
const math = buildEngineMath(oracle);
// smoothStep 逐点比 oracle。
for (let i = 0; i <= 100; i++) {
const p = i / 100;
assert.ok(Math.abs(math.smoothStep(p) - oracle.smoothStep(p)) < 1e-12, `smoothStep(${p}) 应等价`);
}
// lerp 内点逐值比。
assert.equal(math.lerp(0, 10, 0.5), 5, 'lerp(0,10,0.5)=5');
assert.equal(math.lerp(2, 4, 0), 2, 'lerp 起点');
assert.equal(math.lerp(2, 4, 1), 4, 'lerp 终点');
// 引擎 lerp 自带 percent 钳制(clamp(percent)):越界 percent 被钳到 [0,1]。
assert.equal(math.lerp(0, 10, -1), 0, 'lerp percent<0 钳到 0 → 起点');
assert.equal(math.lerp(0, 10, 2), 10, 'lerp percent>1 钳到 1 → 终点');
});
/* ════════════════════════════════════════════════════════════════════════════
* 辅助:clampUnit 自身(门面钳制基元)
* ════════════════════════════════════════════════════════════════════════════ */
test('辅助|clampUnit 钳到 [0,1]', () => {
assert.equal(clampUnit(-0.5), 0);
assert.equal(clampUnit(1.5), 1);
assert.equal(clampUnit(0.3), 0.3);
assert.equal(clampUnit(0), 0);
assert.equal(clampUnit(1), 1);
});

View File

@ -58,6 +58,8 @@ export interface FrameHandle {
* 插件经 ctx.getEngine().* 够引擎,host-dev 桩与集成段 host 同形(换引擎只换 host)。
* 3) host-dev 无引擎时整条通道为 null(getEngine() 返 null),插件须容错降级——
* 对齐 getAudioContext() 的「返 null 不抛、告警一次」范式(见下 PluginContext)。
* 一职一路(创始人 2026-06-13 裁):引擎覆盖的有状态能力(粒子→no-op 句柄/合成核→null 发声 no-op)不留 sim/vendored 降级;
* 仅纯数学族(math.lerp/smoothStep/easing)插件侧保内置纯函数 fallback(公式恒等/ULP 等价,非有意替代)。
* 4) 白名单最小完整(YAGNI,每项指到插件 impl 真有引擎调用点):仅收 particles-juice 发射器 /
* audio-music 合成核 / gamefeel 数学库 3 项。**裁决②-3 的 hitStop/屏震不进通道**——它们在
* impl 里是「受控面输出偏移+游戏层(render.js)自己应用」的闭环,插件不调引擎;屏震引擎化=
@ -94,6 +96,28 @@ export interface EngineEmitterSpec {
emitRate?: number;
/** 单粒子生命(秒)。 */
particleTime?: number;
// —— 以下为 A2 additive 扩字段(承全发射参数;引擎 ParticleEmitter 构造行号见注释;全可选,不破已冻 6 参消费者)——
// 已冻 6 参(pos/angle?/emitSize?/emitTime?/emitRate?/particleTime?)字面与语义零改动;新增字段全 `?`。
/** burst 一次性喷发颗数(host 据此算 emitRate=count×60 + emitTime=1/60;引擎无「spawn 恰好 N」原语,靠 emitRate×emitTime 近似,esm.js:8384-8390)。 */
count?: number;
/** 半锥角(弧度)→ 引擎 emitConeAngle(d.ts:3268,默认 PI;引擎在 ±cone 内均匀采样,语义=半锥角)。 */
coneAngle?: number;
/** 初速(像素/秒)→ 引擎 speed(d.ts 注释:世界单位/帧@60;host 换算 ÷cameraScale÷60)。 */
speed?: number;
/** 重力缩放 → 引擎 gravityScale(d.ts:gravityScale;依赖 host 全局 gravity,MVP 默认 0 故暂不生效,记 follow-up)。 */
gravityScale?: number;
/** 每帧速度阻尼乘子 1=无阻(d.ts damping;host 由 drag 换算 1-drag/60)。 */
damping?: number;
/** 粒子起始尺寸(像素,host ÷cameraScale)→ 引擎 sizeStart(d.ts:sizeStart)。 */
sizeStart?: number;
/** 粒子结束尺寸(像素,host ÷cameraScale)→ 引擎 sizeEnd(d.ts:sizeEnd,引擎仅两端点线性插值,非任意曲线)。 */
sizeEnd?: number;
/** 起始颜色(归一化 {r,g,b,a} 0..1,POJO 非引擎 Color)→ 引擎 colorStartA/B(缺省不透明白,保 real 像素门可见)。 */
colorStart?: { r: number; g: number; b: number; a: number };
/** 结束颜色(归一化 {r,g,b,a} 0..1)→ 引擎 colorEndA/B(缺省 a=0 淡出)。 */
colorEnd?: { r: number; g: number; b: number; a: number };
/** 是否叠加混合 → 引擎 additive(d.ts additive,默认 false)。 */
additive?: boolean;
}
/** 受控粒子能力面(封装引擎 ParticleEmitter;坐标=受控面像素,host 内部做像素↔世界阻抗换算)。 */
@ -106,40 +130,83 @@ export interface EngineParticles {
spawnEmitter(spec: EngineEmitterSpec): EngineEmitterHandle;
}
/** 受控合成核能力面(封装引擎 zzfxG/zzfxM;返回 PCM 样本数组,与 vendored 同口径,不播放)。 */
/** 受控合成核能力面(封装引擎 zzfxG/zzfxM;返回 PCM 样本数组,未乘主音量 0.3,与引擎样本口径一致,不播放;×0.3 在播放层 gain 补)。 */
export interface EngineAudioSynth {
/**
* ZzFX 单音效合成核:参数包 → PCM 单声道样本数组(不播放)。与插件 vendored zzfxG 同口径。
* @returns PCM 样本数组;无引擎或合成失败返 null(插件回退 vendored)。
* ZzFX 单音效合成核:参数包 → PCM 单声道样本数组(不播放)。包装引擎 zzfxG。
* @returns PCM 单声道样本数组(**未乘主音量 0.3**,与引擎 zzfxG 同口径,gain 层补);无引擎或合成失败返 null(插件**发声 no-op,不回退 vendored**——已删)。
*/
synthSfx(params: number[]): number[] | null;
/**
* ZzFXM 曲谱合成核:乐器/模式/序列/BPM → [L,R] 双声道样本(不播放)。
* @returns 双声道样本数组;无引擎/失败返 null(插件回退 vendored zzfxM)。
* ZzFXM 曲谱合成核:乐器/模式/序列/BPM → [L,R] 双声道样本(不播放)。包装引擎 zzfxM。
* @returns [L,R] 双声道样本数组(**未乘 0.3**);无引擎/失败返 null(插件**发声 no-op,不回退 vendored**——已删)。
*/
synthSong(instruments: number[][], patterns: number[][][], sequence: number[], bpm?: number): number[][] | null;
}
/** 受控引擎数学库能力面(封装引擎 lerp/smoothStep;口径对齐引擎,纯函数恒可用)。 */
/**
* 受控引擎缓动门面(β easing 改判新增·2026-06-13;core-protocol-v0.1)。
* 包装引擎 Ease 曲线族(POWER/BACK/ELASTIC + OUT/IN_OUT 修饰器,littlejs.esm.js:15118)。
* **门面边界(已 node 逐点亲验,2001 点 x∈[0,1])**:仅暴与引擎 Ease **等价**的 11 条曲线
* (quadIn/quadOut 逐字节恒等;quadInOut/cubic 全族/backIn/backOut/elasticIn/elasticOut 为 ULP 级 <1e-15 等价)。
* **刻意不含 backInOut/elasticInOut**——引擎 IN_OUT(BACK/ELASTIC) 经 PIECEWISE 拼接是**另一条曲线**
* (back 差 6.6%、elastic 差 17%,非 ULP);此二者由 gamefeel 内置实现承(引擎无此精确曲线,是补层非 fallback)。
* **入参钳制**:门面调引擎 Ease 前钳 t 到 [0,1](引擎 Ease 不自钳,gamefeel 内置每条钳;门面须钳齐保端点恒等+越界一致)。
* **降级语义**:getEngine()==null(host-dev/node/stub)时整条 EngineCapabilities 为 null,消费方(gamefeel)退插件内置 easing 纯函数 fallback。
*/
export interface EngineEasing {
/** 线性(恒等,门面钳制 t∈[0,1])。 */
linear(t: number): number;
/** 二次缓入(=引擎 Ease.POWER(2),逐字节恒等)。 */
quadIn(t: number): number;
/** 二次缓出(=引擎 Ease.OUT(POWER(2)),逐字节恒等)。 */
quadOut(t: number): number;
/** 二次缓入缓出(=引擎 Ease.IN_OUT(POWER(2)),ULP 等价)。 */
quadInOut(t: number): number;
/** 三次缓入(=引擎 Ease.POWER(3),ULP 等价)。 */
cubicIn(t: number): number;
/** 三次缓出(=引擎 Ease.OUT(POWER(3)),ULP 等价)。 */
cubicOut(t: number): number;
/** 三次缓入缓出(=引擎 Ease.IN_OUT(POWER(3)),ULP 等价)。 */
cubicInOut(t: number): number;
/** 回弹缓入(=引擎 Ease.BACK,ULP 等价;中段越界 [0,1])。 */
backIn(t: number): number;
/** 回弹缓出(=引擎 Ease.OUT(BACK),ULP 等价)。 */
backOut(t: number): number;
/** 弹性缓入(=引擎 Ease.ELASTIC,ULP 等价;中段振荡)。 */
elasticIn(t: number): number;
/** 弹性缓出(=引擎 Ease.OUT(ELASTIC),ULP 等价)。 */
elasticOut(t: number): number;
// 注:backInOut/elasticInOut 刻意不入此面(引擎 IN_OUT 拼接是另一条曲线,非 ULP 等价,§0.1)。
}
/** 受控引擎数学库能力面(封装引擎 lerp/smoothStep + easing 门面;口径对齐引擎,纯函数恒可用)。 */
export interface EngineMath {
/** 线性插值(封装引擎 lerp,口径一致)。 */
lerp(valueA: number, valueB: number, percent: number): number;
/** smoothstep 缓动(封装引擎 smoothStep,口径一致)。 */
smoothStep(percent: number): number;
/**
* 缓动门面(β easing 改判新增·可选成员):封装引擎 Ease 曲线族(11 条等价曲线,见 EngineEasing)。
* additive 可选 → 已冻 lerp/smoothStep 消费者零感知;消费方 A4 防御式取(getEngine()?.math?.easing?.quadIn ?? 内置)。
*/
easing?: EngineEasing;
}
/**
* 受控引擎能力透传面(β 新增)。挂在 PluginContext.getEngine() 上。
* **封装边界**:所有方法/类型均为归一化封装,零 littlejsengine 裸对象/裸类型泄漏。
* **降级语义**:host-dev/node 无引擎时 getEngine() 返 null,插件须整体容错(粒子退受控面自管、
* 合成核退 vendored、数学退插件内置)。集成段 host 注入真实引擎背书实现。
* **降级语义(一职一路·创始人 2026-06-13 裁,旧"双码路降级"作废)**:host-dev/node/stub 无引擎时
* getEngine() 返 null——引擎覆盖的有状态能力(particles/audio.synth)一侧**优雅 no-op**(不留 sim/vendored 降级);
* 仅纯数学族(math.lerp/smoothStep/easing)插件侧保**内置纯函数 fallback**(公式恒等/ULP 等价,非有意替代,
* 纯数学族是唯一可留 fallback 的类别)。集成段 host(real) 注入真实引擎背书实现。
*/
export interface EngineCapabilities {
/** 受控粒子能力(封装 ParticleEmitter)。 */
/** 受控粒子能力(封装 ParticleEmitter;null-engine→spawnEmitter 返 no-op 句柄,A2 落)。 */
particles: EngineParticles;
/** 受控合成核(封装 zzfxG/zzfxM)。A2 据 SIZES 定是否真用,不用则清。 */
/** 受控合成核(封装 zzfxG/zzfxM;**A3 已接**:删 vendored、包装引擎;返样本未乘 0.3 播放层补;null-engine→返 null 发声 no-op,不回退 vendored)。 */
audio: { synth: EngineAudioSynth };
/** 受控引擎数学库(封装 lerp/smoothStep)。 */
/** 受控引擎数学库(封装 lerp/smoothStep + easing 门面;null-engine→消费方退内置纯函数 fallback)。 */
math: EngineMath;
}
@ -230,7 +297,7 @@ export interface PluginContext {
/**
* 受控引擎能力透传面(β 新增;具名消费者:particles-juice 发射器 / audio-music 合成核 / gamefeel 数学库)。
* **host-dev/node 无引擎时返回 null 并 console 告警一次(不抛错)**——对齐 getAudioContext 范式,
* 插件须整体容错降级(粒子退受控面自管 / 合成核退 vendored / 数学退插件内置纯函数)。
* 插件须整体容错降级(一职一路·创始人 2026-06-13 裁):粒子→no-op 句柄(不留 sim 降级)/ 合成核退**发声 no-op(vendored 已删)** / 数学退插件内置纯函数 fallback。
* 集成段=LittleJS 真实句柄背书;**引擎 import 只在集成段 host,不进插件 impl(保引擎可换 + 满足门1 真接线)**。
*/
getEngine(): EngineCapabilities | null;

View File

@ -1,11 +1,11 @@
# audio-music —— 程序化音频/音乐插件(core-protocol-v0)
> owner:T1b-α lane-sys(P6)|消费方:agent 造游戏时需要「背景音乐 / 音效 / 情绪随玩法起伏」即接本插件。
> 能力面机器契约见 `manifest.json`;类型契约见 `api.d.ts`;合成内核为 vendored ZzFX/ZzFXM(MIT,见 `vendor/`)。
> 能力面机器契约见 `manifest.json`;类型契约见 `api.d.ts`;合成内核 = 引擎 ZzFX/ZzFXM(经 `ctx.getEngine().audio.synth` 薄包装;旧 vendored 副本已删,引擎↔插件边界模型·创始人 2026-06-13 拍)。
## 1. 能力
**定位**:一项**程序化音频**引擎能力,合成内核来自 vendored ZzFX/ZzFXM(代码合成,无音频成品文件)。提供三组能力:
**定位**:一项**程序化音频**引擎能力,合成内核来自引擎 ZzFX/ZzFXM(经受控面 `getEngine().audio.synth` 薄包装,代码合成、无音频成品文件)。提供三组能力:
- **ZzFXM 序列器封装**:`loadSong(曲谱数据)` / `play()` / `stop()` / `loop()`——把「曲谱数据(乐器/模式/序列/BPM)」渲染为立体声样本并经受控音频上下文播放;
- **情绪分层**(数据驱动):`setIntensityLayering(定义)` + `setIntensity(0..1)`,按强度对各声部做**启停 + 音量曲线**(线性/渐入/渐出/常量),实现「玩法越激烈、音乐层次越厚」;纯逻辑解算面 `resolveVoices(intensity)` 给出声部开关矩阵;
- **ZzFX 音效预设 ≥3**(中性命名):`blip`/`thud`/`chime` 参数包,`playSfx(name)` 一次性播放。
@ -15,9 +15,9 @@
## 2. 集成点
- **依赖**:无(`dependencies: []`)。
- **受控面用法**:仅用 `ctx.getAudioContext()`(lazy 受控音频上下文)。**不**用 `getContext2d`/`onFrame`/`getInput`/`random`。**绝不自行 `new AudioContext`**——vendored `zzfx.js` 已删除上游模块期的 `new AudioContext`,播放统一经受控上下文渲染(保引擎/宿主可换,且无声卡环境不崩)。
- **受控面用法**:用 `ctx.getAudioContext()`(lazy 受控音频上下文)+ `ctx.getEngine().audio.synth`(合成核透传面)。**不**用 `getContext2d`/`onFrame`/`getInput`/`random`。**绝不自行 `new AudioContext`**——播放统一经受控上下文渲染(保引擎/宿主可换,且无声卡环境不崩)。
- **注册方式**:`registry.register(createAudioMusicPlugin(opts))`。
- **配置项 `opts`**(`AudioMusicPluginOptions`):`{ masterVolume?: number }`——主音量 0..1(缺省 1),作用于所有发声(再乘各声部增益与 ZzFX 内置主音量 0.3)。
- **配置项 `opts`**(`AudioMusicPluginOptions`):`{ masterVolume?: number }`——主音量 0..1(缺省 1),作用于所有发声(再乘各声部增益)。注:引擎 zzfxG **不烤**主音量 0.3(vendored 曾烤),故播放层 gain 显式补 ×0.3(与引擎 soundVolume 语义一致),与 masterVolume 相乘后落 gain。
- **曲谱数据结构**(`SongData`,可 JSON 序列化、可内联 GameConfig):
- `instruments: number[][]`——乐器表,每项一组 ZzFX 参数;
- `patterns: number[][][]`——模式表,每 pattern 含若干声部通道 `[乐器索引, 声像(-1..+1), 音符1, 音符2, ...]`;
@ -78,18 +78,18 @@ reg.disposeAll();
- 路径:`src/plugins/audio-music/test/audio-music.test.mjs`
- 运行:`node --test src/plugins/audio-music/test/audio-music.test.mjs`(零依赖)
- **node 层测什么 / 什么留集成段(边界声明)**:
- **node 层(本测覆盖)**:① 曲谱数据结构校验(合法通过;空表/索引越界/乐器越界/声像越界/坏 bpm 被拒并报错);② 情绪分层**声部开关矩阵纯逻辑**(区间内外 on/off、四种音量曲线确定性、归一化、退化区间、越界钳制);③ 预设参数 **schema**(≥3 个中性命名、参数为有限数字数组、randomness=0、每预设可被 `zzfxG` 渲染出非空有限样本);④ **无音频上下文静默降级**(`getAudioContext()===null` 时 play/loop/playSfx 全 no-op + P6 告警一次 + 不抛);⑤ 注入 mock 音频上下文时**发声路径走通**(创建 buffer/source 并 start,用 mock 计数验证路径,非真实声音);⑥ vendored `zzfxG`/`zzfxM` 确定性。
- **node 层(本测覆盖)**:① 曲谱数据结构校验(合法通过;空表/索引越界/乐器越界/声像越界/坏 bpm 被拒并报错);② 情绪分层**声部开关矩阵纯逻辑**(区间内外 on/off、四种音量曲线确定性、归一化、退化区间、越界钳制);③ 预设参数 **schema**(≥3 个中性命名、参数为有限数字数组、randomness=0;注:合成核已归引擎,node 无引擎不测真合成 F7);④ **无音频上下文静默降级**(`getAudioContext()===null` 时 play/loop/playSfx 全 no-op + P6 告警一次 + 不抛);⑤ 注入 mock 音频上下文 **+ mock 引擎合成核**时**发声路径走通**(创建 buffer/source 并 start,用 mock 计数验证路径,非真实声音);⑥ **A3 引擎接线**:T1 注入 mock 引擎合成核断 play/playSfx 真调 synthSong/synthSfx 参数对、T3 播放层 ×0.3 主音量补偿、T4 null-engine(无 engineFactory)合成 no-op 不回退 vendored。
- **集成段(真 Web Audio,本测不覆盖)**:**实际发声**——把渲染出的立体声样本塞进真实 `AudioContext` 的 buffer 并 `start()` 出声、循环、停止、音效触发的听感与时序。node 层无声卡、无 Web Audio,故只验证「数据流/参数/降级/播放图层调用路径」,**发声本身在集成段 harness 出**(与 vfx 渲染哈希同口径:node 验状态、集成段验真实输出)。
## 5. 边界与字节预算
- **字节预算**:`gzBudgetBytes = 12288`(12KB,与 `manifest.json` 一致)。**vendor 占用(实测)**:`vendor/zzfx.js` = 9594B raw / 4189B gz(未压,含 MIT 许可注释 + 中文 vendor 说明)、`vendor/zzfxm.js` = 6359B raw / 2868B gz(同)。注释/许可块在集成段 esbuild minify 时移除,**去注释后的保守 gz 估算**:zzfx.js ≈1.5KB gz、zzfxm.js ≈0.8KB gz、impl.js ≈3.1KB gz → **合计 ≈5.5KB gz**(真 esbuild 含标识符 mangle 更小),对 12KB 预算有充裕余量。最终 gz 实测以集成段 `SIZES.md` 增量法为准;若实测超配额触发评审(执行版 §5)。
- **字节预算**:`gzBudgetBytes = 12288`(12KB,与 `manifest.json` 一致)。**A3 后合成核归引擎**(zzfxG/zzfxM 经 `getEngine().audio.synth` 薄包装,vendored 副本已删,不再计入插件字节——引擎已在包内)→ 插件 gz 仅剩 `impl.js`(纯逻辑校验 + 情绪分层 + 预设 + 播放层 + 引擎接线,≈3.1KB gz)+ 本地常量(SAMPLE_RATE/ENGINE_MASTER_VOLUME),较 α 版(含 vendor ≈5.5KB gz)更省,对 12KB 预算余量更充裕。最终 gz 实测以集成段 `SIZES.md` 增量法为准;若实测超配额触发评审(执行版 §5)。
- **行为边界**:
- `getAudioContext()===null`(无声卡/无 Web Audio 的评估环境)→ **全部发声 API 静默 no-op + 告警一次 + 绝不崩**(容错降级铁律);
- 任何 Web Audio 异常(创建 buffer/source 失败等)降级为「未播放」,不连坐游戏;
- 纯逻辑(曲谱校验 / 声部矩阵解算 / 读预设)与音频上下文无关,**恒可用**;
- **不做**:外部音频资源加载(只代码合成)、实时分轨重混(见 §6 v2)、空间化/混响等高级音频图。
- **受控面铁律**:只经 `PluginContext` 的 `getAudioContext()` 拿音频能力,**绝不直透 littlejsengine 裸对象 / 裸 AudioContext / 自行 new AudioContext**——保引擎/宿主可换。vendored 合成内核保留上游 MIT 许可与上游 URL 注释(`vendor/*.js`)。
- **受控面铁律**:只经 `PluginContext` 的 `getAudioContext()`(音频上下文)+ `getEngine().audio.synth`(合成核透传面)拿音频能力,**绝不直透 littlejsengine 裸对象 / 裸 AudioContext / 自行 new AudioContext / 在插件内 import 引擎或 vendored 合成核**——保引擎/宿主可换、一职一路(合成核已归引擎,vendored 副本已删)。
## 6. v2 声明

View File

@ -7,27 +7,39 @@
* ① ZzFXM 序列器封装(load 曲谱 / play / stop / loop);
* ② 情绪分层(intensity 0..1 → 声部启停 / 音量曲线,数据驱动);
* ③ ZzFX 音效预设 ≥3(中性命名 blip/thud/chime,参数包)。
* engine 级、无玩法语义。合成内核来自 vendored ZzFX/ZzFXM(见 ./vendor/,MIT,源码进插件不走 npm)。
* engine 级、无玩法语义。
*
* 【容错降级铁律(执行版 §3 lane-sys 行)】
* `ctx.getAudioContext() === null` 时,全部**发声** API 静默 no-op 并仅告警一次——绝不崩。
* 纯逻辑(曲谱校验 / 情绪分层声部矩阵 / 预设参数)与发声无关,无音频上下文也照常工作(node 层可测面)。
* 【合成核归属(引擎↔插件 边界模型·创始人 2026-06-13 拍)】
* 合成内核(zzfxG 样本生成 / zzfxM 曲谱渲染)= 引擎有 → 经 `ctx.getEngine().audio.synth` **薄包装**,
* vendored 副本(旧 zzfx / zzfxm 两文件)已删;一职一路,禁在插件内并行保留自研合成核作回退。
* 引擎 zzfxG **不烤主音量 ×0.3**(样本=s*volume,esm.js:7391;×0.3 在引擎 gain 层 soundVolume=.3,esm.js:3121/6777),
* 故包装引擎合成核拿到的是「未乘 0.3 的 PCM」→ 播放层须**显式补 ×0.3**(真缺件自研补层,见 playChannels)。
*
* 【容错降级铁律(双 null 语义不可混·政策 §3)】
* - `ctx.getEngine() === null`(无引擎合成核)→ `_synth=null`,renderSong/playSfx 拿不到样本 → **合成 no-op**(不发声)。
* **禁回退 vendored**(已删);stub/node 无引擎下无声是正确语义(真出声证据走 real 通道 mini-desktop)。
* - `ctx.getAudioContext() === null`(无音频上下文)→ 全部**发声** API 静默 no-op 并仅告警一次(受控面既有语义)——绝不崩。
* 纯逻辑(曲谱校验 / 情绪分层声部矩阵 / 预设参数)与发声/合成核无关,恒可用(node 层可测面)。
*
* 【受控面用法】
* 仅用 `ctx.getAudioContext()`(lazy 受控音频上下文)。不用 canvas/帧/输入/随机。
* **绝不自行 new AudioContext**——只经受控面拿(vendored zzfx.js 已删除模块期 new AudioContext,
* 播放走本文件 player 经受控上下文渲染)。
* 仅用 `ctx.getAudioContext()`(lazy 受控音频上下文)+ `ctx.getEngine().audio.synth`(合成核透传面)。不用 canvas/帧/输入/随机。
* **绝不自行 new AudioContext**——只经受控面拿(播放走本文件 player 经受控上下文渲染)。
*
* 【node 层 vs 集成段(PLUGIN.md §4)】
* - node 层测:曲谱数据结构校验 / 情绪分层声部开关矩阵纯逻辑 / 预设参数 schema / 无上下文静默降级;
* - 集成段测(真 Web Audio):实际发声(把通道样本塞进受控 AudioContext buffer 并 start)。
* - node 层测:曲谱数据结构校验 / 情绪分层声部开关矩阵纯逻辑 / 预设参数 schema / 无上下文静默降级 /
* 注入 mock 引擎合成核(断真调 synthSfx/synthSong 参数对)/ 播放层 ×0.3 增益 / null-engine 合成 no-op;
* - 集成段测(真 Web Audio + 真引擎合成核):实际发声(引擎 zzfxG/zzfxM 出样本 → 受控 AudioContext buffer 并 start)。
* 注:引擎 ESM 模块求值期触 window,node 不可 import(F7)→ 真引擎合成核证据归 mini-desktop real 通道。
*/
'use strict';
// vendored 合成内核(MIT,源码进插件):zzfxG=样本生成、zzfxM=曲谱渲染。
import { zzfxG, ZZFX_SAMPLE_RATE } from './vendor/zzfx.js';
import { zzfxM } from './vendor/zzfxm.js';
// 合成核已归引擎(经 ctx.getEngine().audio.synth 薄包装,vendored 副本已删);本文件不再 import 合成内核。
// 引擎采样率 audioDefaultSampleRate=44100(littlejs.esm.js:6763);vendor 删后本地补常量供 createBuffer 用。
const SAMPLE_RATE = 44100;
// 引擎 zzfxG 不烤主音量(样本=s*volume,esm.js:7391),×0.3 在引擎 gain 层(soundVolume=.3,esm.js:3121/6777)。
// 包装引擎合成核后样本未乘 0.3,故播放层显式补此系数——避免比 vendored(曾烤 0.3)响 1/0.3≈3.33×(爆音)。
const ENGINE_MASTER_VOLUME = 0.3;
/**
* @typedef {import('../../core/api.d.ts').Plugin} Plugin
@ -237,7 +249,8 @@ function playChannels(audioCtx, channels, masterVolume, loop) {
const length = Math.max(left.length, right.length);
if (length === 0) return null; // 空样本不播。
const channelCount = 2;
const buffer = audioCtx.createBuffer(channelCount, length, ZZFX_SAMPLE_RATE);
// 采样率用本地常量 SAMPLE_RATE(=引擎 audioDefaultSampleRate 44100;vendor 删后本地补)。
const buffer = audioCtx.createBuffer(channelCount, length, SAMPLE_RATE);
buffer.getChannelData(0).set(left);
buffer.getChannelData(1).set(right.length ? right : left);
@ -246,8 +259,9 @@ function playChannels(audioCtx, channels, masterVolume, loop) {
source.loop = !!loop;
// 经增益节点接主音量,再连 destination。
// ×0.3 补:引擎 zzfxG 不烤主音量(vendored 曾烤),在 gain 层补回,与引擎 soundVolume(.3) 语义一致;只补一次(防双补)。
const gain = audioCtx.createGain();
gain.gain.value = clamp01(masterVolume);
gain.gain.value = clamp01(masterVolume * ENGINE_MASTER_VOLUME);
source.connect(gain);
gain.connect(audioCtx.destination);
source.start();
@ -291,6 +305,12 @@ export function createAudioMusicPlugin(opts) {
// ── 内部状态(闭包封装)────────────────────────────────────────────────────
/** @type {() => (AudioContext|null)} 受控音频上下文取用器;init 注入 ctx.getAudioContext 后替换。 */
let getAudio = () => null;
/**
* @type {import('../../core/api.d.ts').EngineAudioSynth|null}
* 引擎合成核(=ctx.getEngine().audio.synth)。init 取一次缓存(禁每帧调 getEngine);
* null=无引擎工厂 → renderSong/playSfx 拿不到样本 → 合成 no-op(不回退 vendored,已删)。
*/
let _synth = null;
/** @type {SongData|null} 已载入且合法的曲谱(load 校验通过才存)。 */
let song = null;
/** @type {IntensityLayering} 情绪分层定义(缺省空声部)。 */
@ -326,16 +346,18 @@ export function createAudioMusicPlugin(opts) {
}
/**
* 渲染当前曲谱为 [L,R] 通道(经 vendored zzfxM)。无曲谱返回 null。
* 渲染当前曲谱为 [L,R] 通道(经引擎合成核 _synth.synthSong)。无曲谱或无引擎合成核返回 null。
* 注:情绪分层对「哪些声部出声/音量」的精细作用,集成段在播放图层做(按 resolveVoices 增益分轨);
* 本 alpha 版渲染整曲后用主音量 × 情绪整体增益播放(精细分轨留 v2,PLUGIN.md 已声明)。
* @returns {number[][]|null}
*/
function renderSong() {
if (!song) return null;
// 无引擎合成核(getEngine()==null)→ 合成 no-op:返 null,startPlayback 拿不到 channels → 不发声(与无曲谱同路,不回退 vendored)。
if (!_synth) return null;
try {
const bpm = typeof song.bpm === 'number' && song.bpm > 0 ? song.bpm : 125;
return zzfxM(song.instruments, song.patterns, song.sequence, bpm);
return _synth.synthSong(song.instruments, song.patterns, song.sequence, bpm);
} catch (e) {
logError('[audio-music] 曲谱渲染异常,降级为静默', e);
return null;
@ -397,6 +419,11 @@ export function createAudioMusicPlugin(opts) {
// 只经受控面拿音频上下文;绝不自行 new AudioContext(保引擎/宿主可换)。
getAudio = () => ctx.getAudioContext();
// 不在此处主动取一次——lazy:首次发声时再取(避免无谓创建 AudioContext / 触发降级告警)。
// 取一次缓存引擎合成核(getEngine().audio.synth);禁每帧调 getEngine。
// null=无引擎工厂 → 合成 no-op(不回退 vendored,已删)。此处不单独对 _synth==null 告警,
// 与 getEngine() 自身的 host-dev「无工厂告警一次」去重(插件层告警走 audioOrSilent 的音频上下文面)。
const eng = ctx.getEngine && ctx.getEngine();
_synth = eng ? eng.audio.synth : null;
},
/**
@ -458,10 +485,12 @@ export function createAudioMusicPlugin(opts) {
return;
}
const ac = audioOrSilent();
if (ac == null) return; // 静默降级。
if (ac == null) return; // 静默降级(无音频上下文)。
try {
// 单音效:zzfxG 生成单声道样本 → 复制为双声道经播放器播(一次性,不循环)。
const mono = zzfxG(...preset.params);
// 单音效:引擎合成核 synthSfx 生成单声道样本 → 复制为双声道经播放器播(一次性,不循环)。
// 样本未乘主音量 0.3(引擎 zzfxG 不烤),×0.3 在 playChannels 的 gain 层补;playSfx 不叠情绪增益(音效不受情绪分层)。
const mono = _synth ? _synth.synthSfx(preset.params) : null;
if (!mono) return; // 无引擎合成核或合成失败 → 发声 no-op(不回退 vendored)。
playChannels(ac, [mono, mono], masterVolume, false);
} catch (e) {
logError('[audio-music] 音效播放异常,降级为静默 name=' + name, e);

View File

@ -13,5 +13,5 @@
"SFX_PRESETS"
]
},
"description": "程序化音频引擎能力:ZzFXM 序列器封装(load 曲谱/play/stop/loop)+情绪分层(intensity 0..1→声部启停/音量曲线,数据驱动)+ZzFX 音效预设 blip/thud/chime。vendored ZzFX/ZzFXM(MIT,源码进插件)。getAudioContext()===null 时全发声静默 no-op。无玩法语义。"
"description": "程序化音频引擎能力:ZzFXM 序列器封装(load 曲谱/play/stop/loop)+情绪分层(intensity 0..1→声部启停/音量曲线,数据驱动)+ZzFX 音效预设 blip/thud/chime。合成核=引擎 ZzFX/ZzFXM(经 getEngine().audio.synth 薄包装,vendored 副本已删,播放层补主音量 0.3)。getAudioContext()===null 或 getEngine()===null 时全发声静默 no-op。无玩法语义。"
}

View File

@ -1,23 +1,24 @@
✔ P6 可被 PluginRegistry 全流程接纳(无音频上下文也不崩) (11.474419ms)
✔ 曲谱校验:合法曲谱通过 (1.0552ms)
✔ 曲谱校验:各类非法结构被拒并报错 (1.569723ms)
✔ loadSong:合法才载入;非法不载入并返回错误 (0.727395ms)
✔ 情绪分层:区间内外 on/off + 线性音量 (1.244861ms)
✔ 情绪分层:四种音量曲线确定性 (0.585115ms)
✔ 情绪分层:baseVolume 缩放 + 退化区间(min===max) + 越界钳制 (0.591888ms)
✔ plugin.resolveVoices 透出纯逻辑(与 setIntensityLayering 联动) (0.769715ms)
✔ 音效预设:≥3 个中性命名预设,参数为有限数字数组 (0.795475ms)
✔ 音效预设:每个预设可被 zzfxG 渲染出非空有限样本 (34.059045ms)
✔ plugin.listPresets / getPreset (0.812601ms)
✔ 无音频上下文:play/loop/playSfx 全静默 no-op + 告警一次 + 不抛 (1.091631ms)
✔ 注入 mock 音频上下文:play 走通渲染并 start;stop 终止;loop 置位 (49.411807ms)
✔ 情绪整体增益作用于播放(mock 路径不抛;强度 0 时底鼓常在仍可播) (10.393813ms)
✔ vendored zzfxG / zzfxM 确定性(randomness=0 → 同输入同输出) (24.994613ms)
ℹ tests 15
✔ P6 可被 PluginRegistry 全流程接纳(无音频上下文也不崩) (9.928685ms)
✔ 曲谱校验:合法曲谱通过 (1.159554ms)
✔ 曲谱校验:各类非法结构被拒并报错 (1.329467ms)
✔ loadSong:合法才载入;非法不载入并返回错误 (0.937043ms)
✔ 情绪分层:区间内外 on/off + 线性音量 (1.24376ms)
✔ 情绪分层:四种音量曲线确定性 (0.618594ms)
✔ 情绪分层:baseVolume 缩放 + 退化区间(min===max) + 越界钳制 (0.679474ms)
✔ plugin.resolveVoices 透出纯逻辑(与 setIntensityLayering 联动) (2.359383ms)
✔ 音效预设:≥3 个中性命名预设,参数为有限数字数组 (0.617573ms)
✔ plugin.listPresets / getPreset (0.576692ms)
✔ 无音频上下文:play/loop/playSfx 全静默 no-op + 告警一次 + 不抛 (1.19315ms)
✔ 注入 mock 音频上下文 + mock 引擎合成核:play 走通渲染并 start;stop 终止;loop 置位 (1.78922ms)
✔ 情绪整体增益作用于播放(mock 路径不抛;强度 0 时底鼓常在仍可播) (0.619827ms)
✔ T1 经引擎合成核:play 真调 synthSong(乐器/模式/序列/bpm)、playSfx 真调 synthSfx(预设参数) (0.832325ms)
✔ T3 播放层 ×0.3 主音量补偿:gain.value = masterVolume × 情绪增益 × 0.3 (1.117184ms)
✔ T4 null-engine 合成 no-op:无 engineFactory 时 play/playSfx 不发声、不抛、不回退 vendored (0.804349ms)
ℹ tests 16
ℹ suites 0
ℹ pass 15
ℹ pass 16
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 337.602578
ℹ duration_ms 220.437406

View File

@ -5,10 +5,16 @@
* 覆盖(执行版 §3 lane-sys 行 node 层口径;**发声留集成段**,本测不断言真实声音):
* 1. 曲谱数据结构校验:合法曲谱通过;各类非法(空表/索引越界/乐器越界/声像越界/坏 bpm)被拒并报错;
* 2. 情绪分层声部开关矩阵(纯逻辑):区间内外 on/off、四种音量曲线、归一化、退化区间、越界钳制;
* 3. 预设参数 schema:≥3 个中性命名预设、参数为有限数字数组、各预设可被 zzfxG 渲染出非空有限样本;
* 3. 预设参数 schema:≥3 个中性命名预设、参数为有限数字数组、randomness=0(合成核已归引擎,node 无引擎不测真合成 F7);
* 4. 无音频上下文静默降级:getAudioContext()===null 时 play/loop/playSfx 全 no-op + 告警一次 + 不抛;
* 注入 mock 上下文则发声路径走通(用 mock AudioContext 验证「会去渲染并 start」,非真实声音)。
* 外加:全流程被注册器接纳 / vendored 合成内核确定性(randomness=0)。
* 注入 mock 上下文 + mock 引擎合成核则发声路径走通(mock AudioContext 验「会去渲染并 start」,非真实声音)。
* 外加(A3 引擎接线·政策 §4.3/§5.1):全流程被注册器接纳 /
* T1 注入 mock 引擎合成核断 play/playSfx 真调 synthSong/synthSfx 参数对(一职一路正面证据)/
* T3 播放层 ×0.3 主音量增益正确(缺件补层正面锚,防 3.33× 爆音/防双补)/
* T4 null-engine(无 engineFactory)合成 no-op、不回退 vendored、不发声。
*
* 【合成核已归引擎】vendored zzfx.js/zzfxm.js 已删(A3);真引擎合成核 node 不可 import(引擎 ESM 求值期触 window,F7)
* → node 层只测「映射/注入/纯逻辑/播放层 mock」;真引擎合成出声证据归 mini-desktop real 通道(非本测)。**本测禁 import littlejsengine。**
*/
import { test } from 'node:test';
@ -21,8 +27,6 @@ import {
resolveVoiceStates,
SFX_PRESETS,
} from '../impl.js';
import { zzfxG } from '../vendor/zzfx.js';
import { zzfxM } from '../vendor/zzfxm.js';
/** 静音 console 跑一段(避免降级/校验告警污染 RESULT.txt)。 */
function silenced(fn) {
@ -36,14 +40,16 @@ function silenced(fn) {
/**
* 一个最小 mock AudioContext 工厂(验证「发声路径会去渲染并 start」,非真实声音)。
* 记录 createBuffer/createBufferSource/start 的调用,便于断言路径被走到。
* `calls.lastGain` 暴露最近一次 createGain 返回的增益节点引用(T3 读 gain.value 断 ×0.3 补偿)。
*/
function makeMockAudio() {
const calls = { buffers: 0, sources: 0, starts: 0, stops: 0 };
const calls = { buffers: 0, sources: 0, starts: 0, stops: 0, lastGain: null, lastBufferRate: 0 };
const audio = {
sampleRate: 44100,
destination: { __dest: true },
createBuffer(channels, length, rate) {
calls.buffers += 1;
calls.lastBufferRate = rate; // 供「采样率用 SAMPLE_RATE=44100」断言。
const data = Array.from({ length: channels }, () => new Float32Array(length));
return {
length,
@ -62,12 +68,42 @@ function makeMockAudio() {
};
},
createGain() {
return { gain: { value: 1 }, connect() { return this; }, disconnect() {} };
// value 可写;记下最近一次返回的 gain 节点供 T3 断言 ×0.3 补偿。
const node = { gain: { value: 1 }, connect() { return this; }, disconnect() {} };
calls.lastGain = node;
return node;
},
};
return { audio, calls };
}
/**
* 构造一个 fake 引擎能力面(EngineCapabilities POJO,**不 import 真引擎**,规避 F7 node 无引擎)。
* audio.synth 记录被调入参(验「插件经 getEngine().audio.synth 真调合成核 + 参数对」),返回非空样本供播放层走通。
* particles/math 用最小占位(A3 只验 audio.synth,但工厂须形状完整供 plugin 取 .audio.synth)。
* @returns {{ caps: object, rec: { sfxParams: any, songArgs: any } }}
*/
function makeFakeEngineCaps() {
const rec = { sfxParams: null, songArgs: null };
const caps = {
// particles:最小 no-op 占位(A3 不消费,仅保形状)。
particles: { spawnEmitter: () => ({ isActive: () => false, stop: () => {} }) },
// audio.synth:记录入参并返非空样本(synthSfx 返单声道、synthSong 返 [L,R]),供播放层 createBuffer/start 走通。
audio: {
synth: {
synthSfx: (params) => { rec.sfxParams = params; return [0.1, 0.2, 0.3]; },
synthSong: (instruments, patterns, sequence, bpm) => {
rec.songArgs = [instruments, patterns, sequence, bpm];
return [[0.1, 0.2], [0.1, 0.2]]; // [L,R] 双声道非空样本
},
},
},
// math:最小恒等占位(A3 不消费)。
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p * p * (3 - 2 * p) },
};
return { caps, rec };
}
/** 一首合法的最小曲谱(1 乐器、1 模式含 1 声部、序列 [0])。 */
function validSong() {
return {
@ -253,15 +289,9 @@ test('音效预设:≥3 个中性命名预设,参数为有限数字数组',
}
});
test('音效预设:每个预设可被 zzfxG 渲染出非空有限样本', () => {
for (const name of Object.keys(SFX_PRESETS)) {
const samples = zzfxG(...SFX_PRESETS[name].params);
assert.ok(Array.isArray(samples) && samples.length > 0, `${name} 应渲染出非空样本`);
assert.ok(samples.every(Number.isFinite), `${name} 样本应全为有限数`);
// 量级在合理范围(ZzFX 主音量 0.3 × preset volume ≤ 1)。
assert.ok(Math.max(...samples.map(Math.abs)) <= 1, `${name} 样本量级 ≤ 1`);
}
});
// 注:旧 test「每个预设可被 zzfxG 渲染出非空有限样本」已删——合成核归引擎,node 无引擎不可跑真合成(F7)。
// 其「预设参数 schema」价值由上一测「≥3 中性命名/有限数字数组/randomness=0」承(schema 与合成核解耦,全留);
// 「能渲染出样本」的正面证据由 T1(mock 引擎注入断真调 synthSfx 参数对)+ mini-desktop real 通道真合成承。
test('plugin.listPresets / getPreset', () => {
const p = createAudioMusicPlugin();
@ -306,9 +336,14 @@ test('无音频上下文:play/loop/playSfx 全静默 no-op + 告警一次 +
assert.equal(p.probe().looping, false);
});
test('注入 mock 音频上下文:play 走通渲染并 start;stop 终止;loop 置位', () => {
test('注入 mock 音频上下文 + mock 引擎合成核:play 走通渲染并 start;stop 终止;loop 置位', () => {
const { audio, calls } = makeMockAudio();
const { context } = createHostDevContext({ context2d: null, audioFactory: () => /** @type {any} */ (audio) });
const { caps } = makeFakeEngineCaps(); // A3:合成核归引擎,须注入 fake engineCaps.synth 才有样本走通播放层。
const { context } = createHostDevContext({
context2d: null,
audioFactory: () => /** @type {any} */ (audio),
engineFactory: () => /** @type {any} */ (caps),
});
const p = createAudioMusicPlugin();
p.init(context);
p.loadSong(validSong());
@ -339,7 +374,12 @@ test('注入 mock 音频上下文:play 走通渲染并 start;stop 终止;l
test('情绪整体增益作用于播放(mock 路径不抛;强度 0 时底鼓常在仍可播)', () => {
const { audio } = makeMockAudio();
const { context } = createHostDevContext({ context2d: null, audioFactory: () => /** @type {any} */ (audio) });
const { caps } = makeFakeEngineCaps(); // 同上:注入 fake 引擎合成核才有样本走通播放层。
const { context } = createHostDevContext({
context2d: null,
audioFactory: () => /** @type {any} */ (audio),
engineFactory: () => /** @type {any} */ (caps),
});
const p = createAudioMusicPlugin();
p.init(context);
p.loadSong(validSong());
@ -349,17 +389,111 @@ test('情绪整体增益作用于播放(mock 路径不抛;强度 0 时底鼓
assert.equal(p.probe().intensity, 0, 'play(0) 应把强度置 0');
});
/* ── 5. vendored 合成内核确定性(randomness=0)──────────────────────────────── */
test('vendored zzfxG / zzfxM 确定性(randomness=0 → 同输入同输出)', () => {
const a = zzfxG(1, 0, 440, 0, 0.1, 0.1);
const b = zzfxG(1, 0, 440, 0, 0.1, 0.1);
assert.equal(a.length, b.length);
assert.deepEqual(a, b, 'zzfxG randomness=0 应确定');
/* ── 5. A3 引擎合成核接线(政策 §4.3/§5.1)——替代旧「vendored 合成内核确定性」───
* 旧测直调 vendored zzfxG/zzfxM(已删,node 无引擎不可测真合成 F7)→ 删;
* 等价补:T1 经 getEngine().audio.synth 真调合成核(参数对)/ T3 播放层 ×0.3 补偿 / T4 null-engine 合成 no-op。 */
// T1:注入 mock 引擎合成核 → 断 plugin 经 getEngine().audio.synth 真调 synthSong/synthSfx 且入参对(一职一路正面证据)。
test('T1 经引擎合成核:play 真调 synthSong(乐器/模式/序列/bpm)、playSfx 真调 synthSfx(预设参数)', () => {
const { audio } = makeMockAudio();
const { caps, rec } = makeFakeEngineCaps();
const { context } = createHostDevContext({
context2d: null,
audioFactory: () => /** @type {any} */ (audio),
engineFactory: () => /** @type {any} */ (caps),
});
const p = createAudioMusicPlugin();
p.init(context);
const song = validSong();
const [l1] = zzfxM(song.instruments, song.patterns, song.sequence, song.bpm);
const [l2] = zzfxM(song.instruments, song.patterns, song.sequence, song.bpm);
assert.deepEqual(l1, l2, 'zzfxM 应确定(乐器 randomness=0)');
assert.ok(l1.some((v) => v !== 0), '渲染出的曲子应非全静音');
p.loadSong(song);
// play → renderSong 经 _synth.synthSong:断被调且入参=(instruments,patterns,sequence,bpm)。
p.play();
assert.ok(rec.songArgs != null, 'play 应经引擎合成核 synthSong(rec.songArgs 被赋值)');
assert.deepEqual(rec.songArgs[0], song.instruments, 'synthSong 入参 instruments 对');
assert.deepEqual(rec.songArgs[1], song.patterns, 'synthSong 入参 patterns 对');
assert.deepEqual(rec.songArgs[2], song.sequence, 'synthSong 入参 sequence 对');
assert.equal(rec.songArgs[3], 125, 'synthSong 入参 bpm 对(合法曲谱归一化为 125)');
p.stop();
// playSfx → 经 _synth.synthSfx:断被调且入参 === 预设参数包(同引用,证明直接透传预设 params)。
p.playSfx('blip');
assert.equal(rec.sfxParams, SFX_PRESETS.blip.params, 'playSfx 应经引擎合成核 synthSfx 且入参=预设参数包');
});
// T3:播放层 ×0.3 主音量补偿——mock AudioContext 断 gain.value=masterVolume×情绪增益×0.3(不多不少;防 3.33× 爆音/防双补)。
test('T3 播放层 ×0.3 主音量补偿:gain.value = masterVolume × 情绪增益 × 0.3', () => {
const { caps } = makeFakeEngineCaps();
// 用例 A:masterVolume=1、无情绪分层(整体增益 1)→ gain.value ≈ 1×1×0.3 = 0.3。
{
const { audio, calls } = makeMockAudio();
const { context } = createHostDevContext({
context2d: null,
audioFactory: () => /** @type {any} */ (audio),
engineFactory: () => /** @type {any} */ (caps),
});
const p = createAudioMusicPlugin(); // masterVolume 缺省 1
p.init(context);
p.loadSong(validSong());
p.play();
assert.ok(calls.lastGain != null, 'play 应创建 gain 节点');
assert.ok(Math.abs(calls.lastGain.gain.value - 0.3) < 1e-9, 'masterVolume=1、增益1 → gain=0.3(×0.3 补一次,未漏未双补)');
// 采样率用本地 SAMPLE_RATE=44100(vendor 删后本地补常量)。
assert.equal(calls.lastBufferRate, 44100, 'createBuffer 采样率应为 44100(本地 SAMPLE_RATE 常量)');
}
// 用例 B:masterVolume=0.5、无情绪分层 → gain.value ≈ 0.5×0.3 = 0.15。
{
const { audio, calls } = makeMockAudio();
const { context } = createHostDevContext({
context2d: null,
audioFactory: () => /** @type {any} */ (audio),
engineFactory: () => /** @type {any} */ (caps),
});
const p = createAudioMusicPlugin({ masterVolume: 0.5 });
p.init(context);
p.loadSong(validSong());
p.play();
assert.ok(Math.abs(calls.lastGain.gain.value - 0.15) < 1e-9, 'masterVolume=0.5 → gain=0.15(0.5×0.3)');
}
// 用例 C:playSfx 路径(不叠情绪增益)masterVolume=1 → gain ≈ 1×0.3 = 0.3。
{
const { audio, calls } = makeMockAudio();
const { context } = createHostDevContext({
context2d: null,
audioFactory: () => /** @type {any} */ (audio),
engineFactory: () => /** @type {any} */ (caps),
});
const p = createAudioMusicPlugin();
p.init(context);
p.playSfx('thud');
assert.ok(Math.abs(calls.lastGain.gain.value - 0.3) < 1e-9, 'playSfx masterVolume=1 → gain=0.3(音效不受情绪分层,×0.3 一次)');
}
});
// T4:null-engine(无 engineFactory)→ 合成 no-op:不抛、不发声(createBufferSource 未被调)、不回退 vendored;区分双 null(getAudioContext 非 null)。
test('T4 null-engine 合成 no-op:无 engineFactory 时 play/playSfx 不发声、不抛、不回退 vendored', () => {
const { audio, calls } = makeMockAudio();
// 注入 audioFactory(getAudioContext 非 null)但**不**注入 engineFactory(getEngine 返 null)→ 隔离「合成 no-op」与「发声 no-op」。
const { context } = createHostDevContext({
context2d: null,
audioFactory: () => /** @type {any} */ (audio),
});
const p = createAudioMusicPlugin();
p.init(context);
p.loadSong(validSong());
assert.equal(p.probe().hasAudio, true, '有 audioFactory → hasAudio=true(音频上下文在,区分双 null)');
assert.doesNotThrow(() => {
p.play(); // 无引擎合成核 → renderSong 返 null → 不进 playChannels
p.loop(); // 同上
p.playSfx('blip'); // _synth=null → synthSfx 未调 → 发声 no-op
}, 'null-engine 时发声 API 不应抛');
// 无样本 → 从未进 playChannels → createBufferSource/start 未被调(证明「合成 no-op,不发声」)。
assert.equal(calls.sources, 0, 'null-engine 合成 no-op:不应创建 bufferSource(无样本不发声)');
assert.equal(calls.starts, 0, 'null-engine 合成 no-op:不应 start');
assert.equal(p.probe().looping, false, 'null-engine loop 不置位(无样本未真播)');
});

View File

@ -1,245 +0,0 @@
/**
* vendor/zzfx.js — ZzFX 合成核心(buildSamples → zzfxG)的厂商源码 vendored 副本
*
* 【vendor 说明(执行版 §3 lane-sys 行:ZzFX 源码进插件,不走 npm)】
* 上游:ZzFX - Zuper Zmall Zound Zynth v1.3.2 by Frank Force
* 上游 URL:https://github.com/KilledByAPixel/ZzFX (文件 ZzFX.js)
* 许可:MIT(许可全文见下方原始注释块,保留不删)。
*
* 【本副本相对上游的改动(仅为「受控音频上下文解耦」,不改合成算法)】
* 1. 只 vendored「纯样本合成」部分:`buildSamples`(上游 ZZFX.buildSamples 的函数体逐行照搬,
* 算法零改)+ `getNote`,导出为 ESM 函数 `zzfxG` / `zzfxGetNote`。
* 2. **删除**上游的 `audioContext: new AudioContext` 字面量与 `play/playSamples`(它们在模块求值期
* 就 new AudioContext,会在无声卡/无 Web Audio 的 node 评估环境崩)。播放改由插件经**受控
* AudioContext**(core `ctx.getAudioContext()`)完成——见 ../impl.js 的 player,符合「getAudioContext
* ()===null 时全 API 静默 no-op」。
* 3. 体质量音量缩放:上游 buildSamples 内 `volume *= this.volume`(this.volume=0.3 主音量)。本副本
* 把该主音量固化为常量 ZZFX_MASTER_VOLUME=0.3(与上游默认一致),保「样本数值与上游一致」。
* 除此之外,合成循环(波形/包络/滤波/pitchJump/repeat 等)**逐行保持上游算法**,确保产出样本与
* 上游 ZzFX.buildSamples(同参数、randomness=0)一致。
*
* 【为何 vendored 而非 npm】
* 执行版 §1 本机零 npm 依赖纪律 + lane-sys 行「ZzFX 源码进插件」。vendored 单文件、零依赖、可单测。
*/
/*
ZzFX MIT License
Copyright (c) 2019 - Frank Force
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
'use strict';
/** ZzFX 主音量(上游 ZZFX.volume 默认 0.3)。固化为常量以脱离 this,保样本数值与上游一致。 */
export const ZZFX_MASTER_VOLUME = 0.3;
/** ZzFX 采样率(上游 ZZFX.sampleRate 默认 44100)。ZzFXM 渲染器亦依赖此值(导出供 zzfxm.js 引用)。 */
export const ZZFX_SAMPLE_RATE = 44100;
/**
* 构建一段 ZzFX 样本(上游 ZZFX.buildSamples 的函数体逐行照搬,算法零改)。
*
* 返回一维样本数组(Float,[-1,1] 量级,已乘主音量 0.3)。**纯函数、不触碰 AudioContext**:
* 当 randomness=0 时**完全确定**(无 Math.random 依赖),可在 node 层单测做数据流断言。
*
* 20 个参数语义与上游一致(详见上游 README);常用前若干:
* @param {number} [volume=1] 音量(再乘主音量 0.3)
* @param {number} [randomness=.05] 频率随机抖动(>0 引入 Math.random;node 层确定性测须传 0)
* @param {number} [frequency=220] 基频(Hz)
* @param {number} [attack=0] 起音(秒)
* @param {number} [sustain=0] 延音(秒)
* @param {number} [release=.1] 释音(秒)
* @param {number} [shape=0] 波形(0 sin/1 triangle/2 saw/3 tan/4 noise/5 square)
* @param {number} [shapeCurve=1] 波形曲线
* @param {number} [slide=0] 滑音
* @param {number} [deltaSlide=0] 滑音变化
* @param {number} [pitchJump=0] 音高跳变
* @param {number} [pitchJumpTime=0] 音高跳变时刻
* @param {number} [repeatTime=0] 重复周期
* @param {number} [noise=0] 噪声
* @param {number} [modulation=0] 调制
* @param {number} [bitCrush=0] 位压缩
* @param {number} [delay=0] 延迟
* @param {number} [sustainVolume=1] 延音音量
* @param {number} [decay=0] 衰减
* @param {number} [tremolo=0] 颤音
* @param {number} [filter=0] 滤波
* @returns {number[]} 样本缓冲
*/
export function zzfxG(
// ── 以下参数列表与算法体逐行照搬上游 ZZFX.buildSamples(仅把 this.volume/this.sampleRate 换成模块常量)──
volume = 1,
randomness = 0.05,
frequency = 220,
attack = 0,
sustain = 0,
release = 0.1,
shape = 0,
shapeCurve = 1,
slide = 0,
deltaSlide = 0,
pitchJump = 0,
pitchJumpTime = 0,
repeatTime = 0,
noise = 0,
modulation = 0,
bitCrush = 0,
delay = 0,
sustainVolume = 1,
decay = 0,
tremolo = 0,
filter = 0
) {
// init parameters
let sampleRate = ZZFX_SAMPLE_RATE,
PI2 = Math.PI * 2,
abs = Math.abs,
sign = (v) => (v < 0 ? -1 : 1),
startSlide = (slide *= (500 * PI2) / sampleRate / sampleRate),
startFrequency = (frequency *=
((1 + randomness * 2 * Math.random() - randomness) * PI2) / sampleRate),
modOffset = 0, // modulation offset
repeat = 0, // repeat offset
crush = 0, // bit crush offset
jump = 1, // pitch jump timer
length, // sample length
b = [], // sample buffer
t = 0, // sample time
i = 0, // sample index
s = 0, // sample value
f, // wave frequency
// biquad LP/HP filter
quality = 2,
w = (PI2 * abs(filter) * 2) / sampleRate,
cos = Math.cos(w),
alpha = Math.sin(w) / 2 / quality,
a0 = 1 + alpha,
a1 = (-2 * cos) / a0,
a2 = (1 - alpha) / a0,
b0 = (1 + sign(filter) * cos) / 2 / a0,
b1 = -(sign(filter) + cos) / a0,
b2 = b0,
x2 = 0,
x1 = 0,
y2 = 0,
y1 = 0;
// scale by sample rate
const minAttack = 9; // prevent pop if attack is 0
attack = attack * sampleRate || minAttack;
decay *= sampleRate;
sustain *= sampleRate;
release *= sampleRate;
delay *= sampleRate;
deltaSlide *= (500 * PI2) / sampleRate ** 3;
modulation *= PI2 / sampleRate;
pitchJump *= PI2 / sampleRate;
pitchJumpTime *= sampleRate;
repeatTime = (repeatTime * sampleRate) | 0;
volume *= ZZFX_MASTER_VOLUME; // 上游为 this.volume(0.3);固化常量保数值一致
// generate waveform
for (
length = (attack + decay + sustain + release + delay) | 0;
i < length;
b[i++] = s * volume // sample
) {
if (!(++crush % ((bitCrush * 100) | 0))) {
// bit crush
s = shape
? shape > 1
? shape > 2
? shape > 3 // wave shape
? shape > 4
? t / PI2 % 1 < shapeCurve / 2
? 1
: -1 // 5 square duty
: Math.sin(t ** 3) // 4 noise
: Math.max(Math.min(Math.tan(t), 1), -1) // 3 tan
: 1 - ((2 * t / PI2 % 2) + 2) % 2 // 2 saw
: 1 - 4 * abs(Math.round(t / PI2) - t / PI2) // 1 triangle
: Math.sin(t); // 0 sin
s =
(repeatTime
? 1 - tremolo + tremolo * Math.sin((PI2 * i) / repeatTime) // tremolo
: 1) *
(shape > 4 ? s : sign(s) * abs(s) ** shapeCurve) * // shape curve
(i < attack
? i / attack // attack
: i < attack + decay // decay
? 1 - ((i - attack) / decay) * (1 - sustainVolume) // decay falloff
: i < attack + decay + sustain // sustain
? sustainVolume // sustain volume
: i < length - delay // release
? ((length - i - delay) / release) * // release falloff
sustainVolume // release volume
: 0); // post release
s = delay
? s / 2 +
(delay > i
? 0 // delay
: ((i < length - delay ? 1 : (length - i) / delay) * // release delay
b[(i - delay) | 0]) /
2 /
volume) // sample delay
: s;
if (filter)
// apply filter
s = y1 = b2 * x2 + b1 * (x2 = x1) + b0 * (x1 = s) - a2 * y2 - a1 * (y2 = y1);
}
f =
(frequency += slide += deltaSlide) * // frequency
Math.cos(modulation * modOffset++); // modulation
t += f + f * noise * Math.sin(i ** 5); // noise
if (jump && ++jump > pitchJumpTime) {
// pitch jump
frequency += pitchJump; // apply pitch jump
startFrequency += pitchJump; // also apply to start
jump = 0; // stop pitch jump time
}
if (repeatTime && !(++repeat % repeatTime)) {
// repeat
frequency = startFrequency; // reset frequency
slide = startSlide; // reset slide
jump ||= 1; // reset pitch jump time
}
}
return b; // return sample buffer
}
/**
* 获取标准全音阶上某音符的频率(上游 ZZFX.getNote 照搬)。
* @param {number} [semitoneOffset=0] 半音偏移
* @param {number} [rootNoteFrequency=440] 根音频率(Hz)
* @returns {number} 频率(Hz)
*/
export function zzfxGetNote(semitoneOffset = 0, rootNoteFrequency = 440) {
return rootNoteFrequency * 2 ** (semitoneOffset / 12);
}

View File

@ -1,146 +0,0 @@
/**
* vendor/zzfxm.js — ZzFXM 音乐渲染器(zzfxM)的厂商源码 vendored 副本
*
* 【vendor 说明(执行版 §3 lane-sys 行:ZzFXM 序列器源码进插件,不走 npm)】
* 上游:ZzFX Music Renderer v2.0.4 by Keith Clark and Frank Force
* 上游 URL:https://github.com/KilledByAPixel/ZzFXM (文件 zzfxm.js)
* 许可:MIT(ZzFXM 仓库 LICENSE,与 ZzFX 同口径 MIT;许可声明见下)。
*
* 【MIT License(ZzFXM)】
* Copyright (c) Keith Clark and Frank Force
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the above copyright notice and this permission notice being included in all copies or
* substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
*
* 【本副本相对上游的改动(仅 ESM 化接线,不改渲染算法)】
* 1. 上游 zzfxM 依赖两个全局:`zzfxR`(采样率)与 `zzfxG`(样本生成器,=ZzFX buildSamples)。
* 本副本改为 **ESM import**:从 ./zzfx.js 引入 `zzfxG` 与 `ZZFX_SAMPLE_RATE`(作 zzfxR)。
* 2. 上游为隐式全局赋值 `zzfxM = (...) => {...}`;本副本改为 `export function zzfxM(...)`。
* 算法体(声部/拍/采样缓冲/淘汰缓存/声像)**逐行保持上游**,确保产出 [L,R] 通道与上游一致。
*
* 【纯函数性质】
* zzfxM 只做「曲谱数据 → [左声道, 右声道] 样本数组」的离线渲染,**不触碰 AudioContext**:
* 当各乐器 randomness=0 时完全确定,可在 node 层单测做曲谱数据流断言。播放(把通道数据塞进
* 受控 AudioContext 的 buffer 并 start)由插件的 player 负责(见 ../impl.js)。
*/
'use strict';
// ESM 接线:从 vendored zzfx.js 引入样本生成器与采样率(上游用全局 zzfxG / zzfxR)。
import { zzfxG, ZZFX_SAMPLE_RATE } from './zzfx.js';
/** 上游全局 zzfxR(采样率)的 ESM 别名。 */
const zzfxR = ZZFX_SAMPLE_RATE;
/**
* 生成一首歌(上游 zzfxM 函数体逐行照搬,仅 zzfxG/zzfxR 由 import 提供)。
*
* @param {Array.<Array.<number>>} instruments 乐器表:每项为一组 ZzFX 参数(=zzfxG 入参)。
* @param {Array.<Array.<Array.<number>>>} patterns 模式表:每个 pattern = 若干 channel;
* 每个 channel = [乐器索引, 声像(-1..+1), 音符1, 音符2, ...]。
* @param {Array.<number>} sequence 播放序列:pattern 索引数组。
* @param {number} [BPM=125] 速度(BPM)。
* @returns {[number[], number[]]} [左声道样本, 右声道样本]。
*/
export function zzfxM(instruments, patterns, sequence, BPM = 125) {
let instrumentParameters;
let i;
let j;
let k;
let note;
let sample;
let patternChannel;
let notFirstBeat;
let stop;
let instrument;
let pitch;
let attenuation;
let outSampleOffset;
let isSequenceEnd;
let sampleOffset = 0;
let nextSampleOffset;
let sampleBuffer = [];
let leftChannelBuffer = [];
let rightChannelBuffer = [];
let channelIndex = 0;
let panning = 0;
let hasMore = 1;
let sampleCache = {};
let beatLength = (zzfxR / BPM * 60) >> 2;
// for each channel in order until there are no more
for (; hasMore; channelIndex++) {
// reset current values
sampleBuffer = [(hasMore = notFirstBeat = pitch = outSampleOffset = 0)];
// for each pattern in sequence
sequence.map((patternIndex, sequenceIndex) => {
// get pattern for current channel, use empty 1 note pattern if none found
patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0];
// check if there are more channels
hasMore |= !!patterns[patternIndex][channelIndex];
// get next offset, use the length of first channel
nextSampleOffset =
outSampleOffset +
(patterns[patternIndex][0].length - 2 - !notFirstBeat) * beatLength;
// for each beat in pattern, plus one extra if end of sequence
isSequenceEnd = sequenceIndex == sequence.length - 1;
for (
i = 2, k = outSampleOffset;
i < patternChannel.length + isSequenceEnd;
notFirstBeat = ++i
) {
// <channel-note>
note = patternChannel[i];
// stop if end, different instrument or new note
stop =
(i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd) ||
(instrument != (patternChannel[0] || 0)) | note | 0;
// fill buffer with samples for previous beat, most cpu intensive part
for (
j = 0;
j < beatLength && notFirstBeat;
// fade off attenuation at end of beat if stopping note, prevents clicking
j++ > beatLength - 99 && stop ? (attenuation += (attenuation < 1) / 99) : 0
) {
// copy sample to stereo buffers with panning
sample = ((1 - attenuation) * sampleBuffer[sampleOffset++]) / 2 || 0;
leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample;
rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample;
}
// set up for next note
if (note) {
// set attenuation
attenuation = note % 1;
panning = patternChannel[1] || 0;
if ((note |= 0)) {
// get cached sample
sampleBuffer = sampleCache[
[(instrument = patternChannel[(sampleOffset = 0)] || 0), note]
] =
sampleCache[[instrument, note]] ||
// add sample to cache
((instrumentParameters = [...instruments[instrument]]),
(instrumentParameters[2] =
(instrumentParameters[2] || 220) * 2 ** (note / 12 - 1)),
// allow negative values to stop notes
note > 0 ? zzfxG(...instrumentParameters) : []);
}
}
}
// update the sample offset
outSampleOffset = nextSampleOffset;
});
}
return [leftChannelBuffer, rightChannelBuffer];
}

View File

@ -13,6 +13,17 @@
* 插件对象(createCollisionPlugin)仅为满足 core 协议可注册,init 不申请受控面资源,
* 只托管一个 SpatialHash 实例并在 dispose 时清空(演示「能力插件亦可即插即用」)。
*
* 【引擎↔插件 边界裁定(2026-06-13 创始人拍·政策 §2.3·真缺件补层)】
* collision 全套(空间哈希 broadphase / 圆-圆·圆-AABB·AABB-AABB·凸多边形 SAT 窄相 + MTV / 射线投射)
* = **补层**。源码复审(§1.5 红线:能力普查须读引擎实际导出,不按预期名 grep):引擎仅有两个**返 boolean**
* 的几何原语——`isOverlapping(posA,sizeA,posB,sizeB)`(AABB-AABB 重叠,esm.js:1522)与
* `isIntersecting(start,end,pos,size)`(线段-AABB Liang-Barsky,:1539);其对象级碰撞走 EngineObject
* 「物理积分 + 碰撞响应」。本插件能力(任意几何 圆/凸多边形 求 **MTV / 穿透深度 / 接触法线 / RayHit**)
* 对这两个原语 §1.5 三兼容核全不过:**返回类型** boolean≠Manifold/RayHit、**几何域** 仅 AABB≠圆/多边形、
* **语义** 仅「是否重叠」≠「求最小平移量与法线」→ 真缺件,自研合法保留,全为纯数学几何、确定性可测。
* **不得误判为待包装**(引擎 boolean 原语不覆盖本插件能力;区别于 easing/particles/audio 那类引擎有→
* 须经 ctx.getEngine() 薄包装的能力)。
*
* 【模板哲学红线】
* 命名与抽象一律 engine 级:Circle/Aabb/Polygon/Segment/Manifold/RayHit/SpatialHash——
* 绝不出现「金币/敌人/子弹/关卡」等品类词。MTV/法线方向等约定写在 JSDoc 与 api.d.ts。

View File

@ -14,7 +14,7 @@
* 只经 PluginContext 受控面拿引擎能力,绝不直透 littlejsengine 裸对象 / 原生 DOM 事件。
*/
import type { Plugin, PluginContext, InputEventType, InputSource, TimeSource } from '../../core/api.d.ts';
import type { Plugin, PluginContext, InputEventType, InputSource, TimeSource, EngineMath } from '../../core/api.d.ts';
/* ──────────────────────────────────────────────────────────────────────────
* 缓动标准库(纯函数,端点恒等 f(0)=0、f(1)=1)
@ -63,9 +63,21 @@ export interface EasingLib {
backInOut: EasingFn;
}
/** 缓动标准库单例(纯函数集合)。 */
/**
* 缓动模块级单例(向后兼容导出,= 内置 builtinEasing fallback 实现)。
* 【2026-06-13 easing 改判】模块级无 ctx 拿不到引擎 → 此导出恒为内置纯函数 fallback(行为/签名不变);
* **门面包装引擎 Ease 经插件实例 `GamefeelPlugin.easing` getter 暴露**(init 经 ctx.getEngine().math 门面优先)。
*/
export declare const easing: EasingLib;
/**
* 缓动门面工厂(impl 内部 export-for-test):造一份「门面优先、null-engine 退内置 fallback」的缓动 family。
* 门面分支 clamp01(t) 后调引擎裸曲线(保越界钳语义);engMath 缺位/缺某曲线 → 该路退内置(backInOut/
* elasticInOut 因引擎门面刻意不暴而永久退内置,是补层非 fallback)。供单测验门面转发/null 退内置。
* @param engMath 引擎 math 门面句柄(ctx.getEngine()?.math),缺位时整体退内置 fallback
*/
export declare function makeEasingFacade(engMath: EngineMath | null | undefined): EasingLib;
/* ──────────────────────────────────────────────────────────────────────────
* 输入缓冲(可配窗口 ms,经受控面 getInput + time)
* ────────────────────────────────────────────────────────────────────────── */
@ -203,7 +215,7 @@ export declare class ComboWindow {
}
/* ──────────────────────────────────────────────────────────────────────────
* 插件工厂(把输入缓冲接上受控面;缓动为模块级纯函数)
* 插件工厂(把输入缓冲接上受控面;缓动经 plugin.easing getter 走 getEngine().math.easing 门面)
* ────────────────────────────────────────────────────────────────────────── */
/** gamefeel 插件可选配置。 */
@ -225,6 +237,13 @@ export interface GamefeelPlugin extends Plugin {
* dispose 时其订阅被注销(注册器另会兜底回收)。
*/
readonly inputBuffer: InputBuffer | null;
/**
* 缓动 family(2026-06-13 easing 改判·additive 新增):门面优先——init 经 ctx.getEngine().math.easing
* 门面包装引擎 Ease(clamp01 包裹保越界钳语义),null-engine 退内置纯函数 fallback。签名 (t)=>number 不变。
* 与模块级 `easing`(恒内置 fallback)区别:此 getter 在引擎可用时走引擎 Ease(11 条),backInOut/elasticInOut
* 永久走内置(引擎门面刻意不暴此二者)。
*/
readonly easing: EasingLib;
}
/**

View File

@ -6,14 +6,31 @@
* 「游戏手感原语」引擎能力实现,四项:
* 1) 输入缓冲(InputBuffer):可配窗口 ms,经受控面 getInput 订阅 + time 取时间;
* 2) coyote time 计时器(CoyoteTimer):条件失效后的时间宽限记账;
* 3) 缓动标准库(easing):linear/quad/cubic/elastic/back 的 in/out/inOut 全 family,纯函数;
* 3) 缓动标准库(easing):linear/quad/cubic/elastic/back 的 in/out/inOut 全 family——经
* ctx.getEngine().math.easing 门面包装引擎 Ease(A1 工厂建),null-engine(host-dev/node 无引擎)
* 退内置纯函数 fallback(纯数学族特例,政策 §1.3);
* 4) 连击计时窗(ComboWindow):相邻命中在窗口内则累计连击。
*
* 【受控面用法(具名消费者)】
* InputBuffer 经 ctx.getInput()(订阅归一化输入事件,禁直接 addEventListener)+ ctx.time(时间戳)工作;
* CoyoteTimer/ComboWindow 经受控 TimeSource 推进。缓动为纯函数不触受控面。
* CoyoteTimer/ComboWindow 经受控 TimeSource 推进。
* 缓动经 getEngine().math.easing 门面取引擎 Ease 曲线(门面优先),无引擎时退内置纯函数 fallback——
* 内置 fallback 不触受控面、确定性。
* **绝不读 Date.now/performance.now**——一切时间走受控时间源(取证可复现)。
*
* 【引擎↔插件 边界裁定(2026-06-13 创始人拍·政策 §1.1/§1.3/§1.5/§4.4)】
* ① **easing 族 = 门面包装引擎 Ease(改判·非补层)**:引擎**有**完整 Ease 曲线族
* (littlejs.esm.js:15118:POWER(2/3)=quad/cubic、BACK=back、ELASTIC=elastic + OUT/IN_OUT 修饰器)
* → gamefeel easing **经 ctx.getEngine().math.easing 门面包装引擎 Ease**(A1 工厂建);内置实现
* (下方 builtinEasing)**仅留作 null-engine fallback**(纯数学族特例,公式与引擎恒等:quad 逐字节
* 恒等、cubic/back/elastic ULP 级 <1e-9,**非有意替代、非补层**)。**钳口径**:门面分支 clamp01(t)
* 后调引擎裸曲线(引擎 Ease 不钳、gamefeel 须钳保越界语义,与 builtinEasing 起手 clamp01 同口径)。
* ② **【删假注释红线】严禁出现「引擎无 easing / easing=补层 / 不得误判为待包装」等措辞**——前轮按
* 小写 quadIn grep 引擎假阴性误判「引擎无 easing」,已被政策 §1.5 推翻;引擎实有 Ease,easing 是
* **包装**不是补层。
* ③ **InputBuffer/CoyoteTimer/ComboWindow = 补层**:引擎无「输入缓冲窗/coyote 宽限/连击窗」时间窗
* 记账 → 经受控面 getInput/time 自研,全留全测(**这三件才是 gamefeel 的补层**,与 easing 区分)。
*
* 【模板哲学红线】
* 命名 engine 级:InputBuffer/CoyoteTimer/ComboWindow/easing——绝不出现「跳跃/攻击/技能/连招」等品类词。
* coyote/连击/缓冲都是「时间窗记账」的通用机制,命中后做什么 = agent 生成域。
@ -30,6 +47,7 @@
* @typedef {import('./api.d.ts').GamefeelPlugin} GamefeelPlugin
* @typedef {import('./api.d.ts').GamefeelPluginOptions} GamefeelPluginOptions
* @typedef {import('../../core/api.d.ts').PluginContext} PluginContext
* @typedef {import('../../core/api.d.ts').EngineMath} EngineMath
* @typedef {import('../../core/api.d.ts').InputSource} InputSource
* @typedef {import('../../core/api.d.ts').InputSubscription} InputSubscription
* @typedef {import('../../core/api.d.ts').InputEventType} InputEventType
@ -48,7 +66,11 @@ function clamp01(t) {
}
/* ============================================================================
* 一、缓动标准库(纯函数,端点恒等 f(0)=0、f(1)=1)
* 一、缓动内置实现 builtinEasing(null-engine fallback,纯函数端点恒等 f(0)=0、f(1)=1)
*
* 定位(政策 §1.3 纯数学族特例):引擎可用时 gamefeel easing 经 getEngine().math.easing 门面走
* 引擎 Ease 曲线;**此处内置实现仅为 host-dev/node 无引擎时的 fallback**(公式与引擎恒等:quad 逐字节
* 恒等、cubic/back/elastic ULP 级 <1e-9)。**非有意替代、非补层**(纯数学族唯一可留内置 fallback 的类别)。
*
* 设计:每族给 in,out 由 out(t)=1-in(1-t) 镜像得,inOut 由前半 in、后半 out 拼接(标准做法),
* 这样端点恒等与对称性天然成立、代码省。elastic/back 用经典系数(与主流缓动库一致)。
@ -133,10 +155,11 @@ const backInOut = (t) => {
const linear = (t) => clamp01(t);
/**
* 缓动标准库单例(纯函数集合,确定性、不触受控面)。
* 缓动内置实现集合(纯函数,确定性、不触受控面)——**null-engine fallback**。
* 引擎可用时经门面走引擎 Ease;无引擎时退此集合(政策 §1.3 纯数学族特例)。
* @type {EasingLib}
*/
export const easing = Object.freeze({
const builtinEasing = Object.freeze({
linear,
quadIn, quadOut, quadInOut,
cubicIn, cubicOut, cubicInOut,
@ -144,6 +167,54 @@ export const easing = Object.freeze({
backIn, backOut, backInOut,
});
/**
* 缓动模块级单例(向后兼容导出)。
*
* 【方案 A·政策 §5.1 R1】模块级 easing **维持 = builtinEasing**(内置 fallback 实现):模块级无 ctx、
* 本就拿不到引擎 → 现有 `import { easing }` 消费者拿到的恒是 fallback 实现(行为/签名/字节与改判前一致,
* 零破坏)。**门面包装引擎 Ease 经插件实例 `plugin.easing` getter 暴露**(gamefeel 被 host 装载时经 ctx
* 取 engMath,门面优先)——见 makeEasingFacade / createGamefeelPlugin。
* @type {EasingLib}
*/
export const easing = builtinEasing;
/** 缓动曲线名清单(门面工厂逐条 wrap 用;与 EasingLib 键集一致)。 */
const EASING_NAMES = [
'linear',
'quadIn', 'quadOut', 'quadInOut',
'cubicIn', 'cubicOut', 'cubicInOut',
'elasticIn', 'elasticOut', 'elasticInOut',
'backIn', 'backOut', 'backInOut',
];
/**
* 造一份「门面优先、null-engine 退内置 fallback」的缓动 family(政策 §1.3 纯数学族特例)。
*
* 设计要点:
* ① **门面分支 clamp01(t) 后调引擎裸曲线**——引擎 Ease 不钳入参(Ease.POWER(2)(2)===4),gamefeel 须钳
* 保越界语义(与 builtinEasing 起手 clamp01 同口径,行为零分叉;见 edit-plan 决策点);
* ② **per-curve 检测 e[name]**——A1 门面 EngineEasing 只暴 11 条引擎等价曲线,**刻意不含 backInOut/
* elasticInOut**(引擎 IN_OUT(BACK/ELASTIC) 经 PIECEWISE 拼接是另一条曲线,back 差 6.6%、elastic 差 17%,
* 非 ULP 等价——core api.d.ts:152-153/180)→ 该二条 e[name]===undefined,wrap 永久退 builtinEasing
* (此二者是引擎无的精确曲线、由内置承,**对它们内置是补层非 fallback**);其余 11 条引擎可用时走门面;
* ③ engMath 缺位 / 缺 .easing 成员(A1 未落 / null-engine)→ 整体退 builtinEasing;
* ④ 返回 frozen family,签名 (t)=>number 与 EasingFn 一致(消费侧零感知门面/fallback)。
* @param {EngineMath|null|undefined} engMath 引擎 math 门面句柄(ctx.getEngine()?.math)
* @returns {EasingLib}
*/
export function makeEasingFacade(engMath) {
// engMath 缺位 / 无 easing 成员(A1 未落)→ 整体退内置 fallback。
const e = engMath && engMath.easing;
if (!e) return builtinEasing;
// 逐曲线:有引擎门面→clamp01 后转发引擎裸曲线;缺该曲线→退内置 fallback。
const wrap = (name) =>
typeof e[name] === 'function' ? (t) => e[name](clamp01(t)) : builtinEasing[name];
/** @type {Record<string, EasingFn>} */
const family = {};
for (const name of EASING_NAMES) family[name] = wrap(name);
return /** @type {EasingLib} */ (Object.freeze(family));
}
/* ============================================================================
* 二、输入缓冲(经受控面 getInput + time)
* ========================================================================== */
@ -366,12 +437,13 @@ export class ComboWindow {
}
/* ============================================================================
* 五、插件工厂(把输入缓冲接上受控面;缓动/coyote/连击为模块级导出)
* 五、插件工厂(把输入缓冲接上受控面;缓动经 getEngine().math.easing 门面包装引擎 Ease)
* ========================================================================== */
/**
* 创建 gamefeel 插件(可注册进 PluginRegistry)。
* init 时经 ctx.getInput()+ctx.time 接一个内置 InputBuffer(演示受控面接线);dispose 注销订阅。
* init 时经 ctx.getInput()+ctx.time 接一个内置 InputBuffer(演示受控面接线)+ 缓存 ctx.getEngine().math
* 门面句柄(供 easing getter 门面优先取引擎 Ease,null-engine 退内置 fallback);dispose 注销订阅。
* @param {GamefeelPluginOptions} [opts]
* @returns {GamefeelPlugin}
*/
@ -379,6 +451,10 @@ export function createGamefeelPlugin(opts) {
const options = opts || {};
/** @type {InputBuffer|null} */
let inputBuffer = null;
// 引擎 math 门面句柄(A1 工厂经 getEngine().math 注入,含 easing);init 时缓存一次。
// host-dev/node 无引擎时为 null,easing 退内置 builtinEasing fallback(政策 §1.3 纯数学族特例)。
/** @type {EngineMath|null} */
let engMath = null;
let disposed = false;
/** @type {GamefeelPlugin} */
@ -390,8 +466,14 @@ export function createGamefeelPlugin(opts) {
get inputBuffer() {
return inputBuffer;
},
// 手感缓动 family:门面优先(经 init 缓存的 engMath.easing 包装引擎 Ease,clamp01 包裹保钳),
// null-engine 退内置 builtinEasing fallback(政策 §1.3)。每次读重建 family(engMath 缓存、开销极小)。
get easing() {
return makeEasingFacade(engMath);
},
/**
* 初始化:经受控面 getInput()+time 接一个内置 InputBuffer(演示受控面正确接线)。
* 初始化:经受控面 getInput()+time 接一个内置 InputBuffer(演示受控面正确接线),
* 并缓存引擎 math 门面句柄(供 easing getter 门面优先取引擎 Ease)。
* @param {PluginContext} ctx 受控上下文(host 注入)。
*/
init(ctx) {
@ -401,6 +483,10 @@ export function createGamefeelPlugin(opts) {
windowMs: options.inputBufferMs,
types: options.inputTypes,
});
// 缓存引擎 math 门面句柄(A1 工厂注入 getEngine().math,含 easing);
// host-dev/node 无引擎时 getEngine() 返 null → engMath=null → easing 退内置 builtinEasing fallback。
const eng = ctx.getEngine();
engMath = eng ? eng.math : null;
},
/**
* 释放:注销内置 InputBuffer 的输入订阅(幂等)。注册器另会兜底回收本插件输入订阅。
@ -411,6 +497,8 @@ export function createGamefeelPlugin(opts) {
inputBuffer.dispose();
inputBuffer = null;
}
// 释放引擎门面句柄引用(dispose 后 easing getter 退内置 fallback,对称回收)。
engMath = null;
disposed = true;
},
};

View File

@ -2,12 +2,14 @@
* gamefeel/test/gamefeel.test.mjs — 手感原语插件零依赖单测(node --test 直跑)
* owner:T1b-α lane-math | 运行:node --test src/plugins/gamefeel/test/gamefeel.test.mjs
*
* 测试纪律(执行版 §3):
* 测试纪律(执行版 §3 + 2026-06-13 easing 改判):
* - 缓冲窗口边界(恰好窗口内/外 1ms);
* - coyote 边界;
* - 缓动函数性质(端点恒等 f(0)=0 f(1)=1 / 单调段无越界);
* - 缓动内置 fallback 函数性质(端点恒等 f(0)=0 f(1)=1 / 单调段无越界)——null-engine 下真实运行路径;
* - 缓动门面转发(注入 mock 引擎 math.easing,断 plugin.easing 真走门面 + clamp01 钳);
* - 缓动 null-engine 退内置 fallback(getEngine() 返 null 时端点/越界钳不变、不抛);
* - 连击窗口断言。
* 用 host-dev 桩(createHostDevContext + inputBridge._emit + 受控时钟)驱动,不依赖真浏览器。
* 用 host-dev 桩(createHostDevContext + inputBridge._emit + 受控时钟 + engineFactory 注入)驱动,不依赖真浏览器。
* 零第三方依赖。
*/
@ -21,6 +23,7 @@ import {
CoyoteTimer,
ComboWindow,
easing,
makeEasingFacade,
} from '../impl.js';
/**
@ -111,6 +114,95 @@ test('缓动:elastic 族端点恒等且中段有振荡(值有正有负越界
}
});
/* ----------------------------------------------------------------------------
* 1b. 缓动门面(2026-06-13 改判):plugin.easing 经 getEngine().math.easing 门面包装引擎 Ease;
* null-engine 退内置 fallback。掏空判据「门面真调断言 + fallback 恒等断言」的等价补入。
* -------------------------------------------------------------------------- */
test('缓动门面:插件经 getEngine().math.easing 门面包装引擎 Ease(真转发 + clamp01 钳)', () => {
// 1) 造 fake EngineCapabilities,math.easing 用「打标记」曲线(非真 Ease,便于断转发与入参):
const calls = [];
/** @type {Record<string, (t:number)=>number>} */
const fakeEasing = {};
for (const n of ['linear', 'quadIn', 'quadOut', 'quadInOut', 'cubicIn', 'cubicOut', 'cubicInOut',
'backIn', 'backOut', 'elasticIn', 'elasticOut']) {
fakeEasing[n] = (t) => { calls.push([n, t]); return 0.5; };
}
// 引擎门面刻意不暴 backInOut/elasticInOut(与 core EngineEasing 边界一致)→ 该二条应永久退内置。
const fakeEngine = {
particles: { spawnEmitter: () => ({ isActive: () => false, stop: () => {} }) },
audio: { synth: { synthSfx: () => null, synthSong: () => null } },
math: { lerp: () => 0, smoothStep: () => 0, easing: fakeEasing },
};
// 2) 经 host-dev context 注入 engineFactory 返 fakeEngine,装载 gamefeel、initAll,取 plugin.easing:
const { context } = createHostDevContext({ context2d: null, clock: () => 0, engineFactory: () => fakeEngine });
const reg = new PluginRegistry();
reg.useContext(context);
const plugin = createGamefeelPlugin();
reg.register(plugin);
assert.equal(reg.initAll().ok, true);
// 门面分支真返回引擎结果 + 真转发到引擎 easing.quadIn。
assert.equal(plugin.easing.quadIn(0.3), 0.5, '门面分支应返回引擎曲线结果(fake=0.5)');
assert.deepEqual(calls.at(-1), ['quadIn', 0.3], '应真转发到引擎 easing.quadIn,入参 0.3');
// ★ clamp01 包裹证据(决策点):越界入参经 clamp01 后才进引擎曲线(裸转发不钳=破测)。
plugin.easing.quadIn(1.5);
assert.deepEqual(calls.at(-1), ['quadIn', 1], '入参 1.5 应 clamp01 到 1 后再进引擎曲线');
plugin.easing.quadIn(-0.5);
assert.deepEqual(calls.at(-1), ['quadIn', 0], '入参 -0.5 应 clamp01 到 0 后再进引擎曲线');
// ★ 引擎门面不暴的 backInOut/elasticInOut 永久退内置(is 补层非 fallback):不调 fake、走内置实现。
const callsBefore = calls.length;
const bi = plugin.easing.backInOut(0.5);
assert.equal(calls.length, callsBefore, 'backInOut 应退内置(引擎门面不暴),不调 fake 引擎曲线');
assert.ok(Math.abs(bi - 0.5) < 1e-9, 'backInOut 内置实现中点应为 0.5(端点对称)');
reg.disposeAll();
});
test('缓动 null-engine:getEngine() 返 null 时 plugin.easing 退内置 fallback(端点恒等 + 越界钳不变 + 不抛)', () => {
// host-dev 默认无 engineFactory → getEngine() 返 null(政策 §3 null 语义)。
const { context } = createHostDevContext({ context2d: null, clock: () => 0 });
const reg = new PluginRegistry();
reg.useContext(context);
const plugin = createGamefeelPlugin();
reg.register(plugin);
assert.equal(reg.initAll().ok, true);
// 端点恒等:plugin.easing 退内置(应与模块级 easing/builtinEasing 行为一致)。
assert.ok(Math.abs(plugin.easing.quadIn(0)) < 1e-9, 'null-engine 下 quadIn(0)=0');
assert.ok(Math.abs(plugin.easing.quadIn(1) - 1) < 1e-9, 'null-engine 下 quadIn(1)=1');
// 越界钳(与门面分支同口径):quadIn(1.5)===quadIn(1)。
assert.ok(Math.abs(plugin.easing.quadIn(1.5) - plugin.easing.quadIn(1)) < 1e-9, 'null-engine 下越界仍钳到 f(1)');
// fallback 与模块级 easing 逐值一致(方案 A 兼容:模块级 easing=builtinEasing)。
assert.equal(plugin.easing.quadIn(0.5), easing.quadIn(0.5), 'null-engine plugin.easing 应与模块级 easing 一致(=0.25)');
assert.equal(plugin.easing.cubicIn(0.5), easing.cubicIn(0.5), 'null-engine cubicIn 与模块级一致');
// ★ 证 null 不抛:各曲线可调、返有限值(无 _engMath.easing 时不 throw)。
for (const name of Object.keys(easing)) {
assert.ok(Number.isFinite(plugin.easing[name](0.5)), `null-engine plugin.easing.${name}(0.5) 应有限不抛`);
}
reg.disposeAll();
});
test('makeEasingFacade 纯函数:engMath 缺位/缺 easing 成员 → 整体退内置 builtinEasing', () => {
// 缺位(null/undefined):整体退内置(=模块级 easing 行为)。
assert.equal(makeEasingFacade(null).quadIn(0.5), easing.quadIn(0.5), 'engMath=null 应退内置');
assert.equal(makeEasingFacade(undefined).cubicIn(0.5), easing.cubicIn(0.5), 'engMath=undefined 应退内置');
// 有 engMath 但无 easing 成员(A1 未落场景):整体退内置。
assert.equal(makeEasingFacade({ lerp: () => 0, smoothStep: () => 0 }).backIn(0.3), easing.backIn(0.3),
'engMath 缺 easing 成员应退内置');
// per-curve:门面只暴部分曲线(仅 quadIn),缺的曲线退内置、暴的走门面 + clamp01。
const calls = [];
const partial = { easing: { quadIn: (t) => { calls.push(t); return 0.7; } } };
const fac = makeEasingFacade(partial);
assert.equal(fac.quadIn(0.4), 0.7, '暴的曲线走门面');
assert.deepEqual(calls, [0.4], 'quadIn 真转发门面');
assert.equal(fac.cubicIn(0.5), easing.cubicIn(0.5), '门面缺的 cubicIn 应退内置');
});
/* ============================================================================
* 2. 输入缓冲窗口边界(恰好窗口内/外 1ms),经受控面 getInput + 受控时钟
* ========================================================================== */

View File

@ -15,6 +15,12 @@
* 绝不在插件内硬编码任何「复古/赛博/暖色」等成品色板或风格预设——那是 agent 生成域。
* 本文件零成品色板常量;任何色板都由调用方注入。
*
* 【引擎↔插件 边界裁定(2026-06-13 创始人拍·政策 §2.3·真缺件补层)】
* palette-post(索引色映射换色引擎 / 两表插值过渡 / HSL 偏移 + vignette/dither/scanline 后处理)
* = **补层**——引擎 littlejsengine **无「运行时换色 + canvas2D 屏幕后处理」的受控面能力**(已源码复审证实:
* 引擎后处理走 WebGL glOverlay 着色器管线,本插件走 canvas 2D 受控面 getContext2d;且 P5 红线要求
* 「只给换色引擎不给成品色板」)→ 自研合法保留,纯函数/确定性查表。**不得误判为待包装。**
*
* 【确定性铁律(lane-vfx 生命线)】
* 调色映射/HSL 偏移/抖动矩阵全为**纯函数 / 确定性查表**,不读 Math.random、不读时间。
* 过渡插值仅依赖入参 t;后处理仅依赖入参强度——同输入同输出(取证可复现)。

View File

@ -7,11 +7,12 @@
**定位**:纯表现层引擎能力,给游戏「打击感 + 环境氛围」。两类能力:
- **粒子系统**:
- 发射器 `spawnEmitter(configOrPreset, x, y, overrides?)`:位置(x/y)/方向锥(angle±spread)/速率(speedMin~Max)/生命(lifeMin~Max)/尺寸曲线/透明度曲线;两种模式 `burst`(一次性喷发 count 颗)/`continuous`(按 rate 持续发射)。
- 更新 **纯数据驱动**:`step(dt)` 积分(速度/重力/阻力)+ 生命衰减 + 死亡回收;粒子数守恒可断言。
- 渲染 `render(ctx2d?)`:经受控 2D 上下文绘制;**无 canvas 时安全降级**(只更新不绘制)。
- **粒子系统**(A2:薄包装引擎 `ParticleEmitter`,引擎自渲):
- 发射器 `spawnEmitter(configOrPreset, x, y, overrides?)`:位置(x/y)/方向锥(angle±spread)/速率(speedMin~Max)/生命(lifeMin~Max)/尺寸曲线/透明度曲线;两种模式 `burst`(一次性喷发 count 颗)/`continuous`(按 rate 持续发射)。插件把 `EmitterConfig`(像素口径参数包)纯映射为受控面 `EngineEmitterSpec`,经 `ctx.getEngine().particles` 交引擎构造 `ParticleEmitter`;**粒子真身在引擎对象列表,由引擎主循环自驱 update/render 落 mainContext**(插件不再自研逐粒子积分 sim,一职一路)。
- 渲染 `render(ctx2d?)`:**只画 juice 闪白叠加层**(粒子由引擎自渲);**无 canvas 时安全降级**(不绘制)。
- 计数 `particleCount()` **A2 后恒返 0**(无本地粒子池,粒子归引擎自管);`emitterCount()` 返活动发射器句柄数(按 `isActive()` 过滤)。
- 中性预设(参数包,命名中性、不含主题语义)≥3:`burst`(爆裂·全向高速短命)/`trail`(拖尾·窄锥低速带阻力)/`drift`(飘落·宽锥缓慢长命带轻重力)。`getPreset(name)` 取深拷贝可局部覆盖。
- **null-engine(host-dev/node 无引擎)**:`spawnEmitter` 返 no-op 句柄(不喷不渲,`particleCount` 恒 0,**禁回退 sim**);真渲出现由集成段 real 像素门兜底。
- **juice 套件**(元表现,输出值由游戏层应用):
- `hitStop(dur, floor?)`:经受控时间源的 timescale 钩子;`getTimeScale()` 返回 [floor,1] 的缩放,游戏层用它缩放世界 dt 实现「打击骤停」。
- `shakeScreen(dur, amplitude, frequency?)`:屏震衰减曲线;`getShakeOffset()` **只输出偏移量 {x,y}**,由游戏层 `ctx.translate` 应用(不强制坐标系)。结束收敛至 {0,0}。
@ -23,14 +24,15 @@
## 2. 集成点
- **依赖**:无(`dependencies: []`)。
- **受控面用法**(用到 `PluginContext` 4 项):
- `onFrame(cb)`:`autoStep`(缺省 true)时 init 注册逐帧 `step(dt)`;拿 `FrameHandle` 供 dispose 注销。**绝不直接 `requestAnimationFrame`**。
- `getContext2d()`:`render()` 时取受控 2D 上下文;node 侧返回 `null` 安全降级。**不自行 `document.querySelector`**。
- `random`:**一切随机走它**(粒子方向/速率/生命的均匀采样),确定性可复现;给 `seed` 则 init 时 `reseed`。**绝不读 `Math.random`**。注:经注册器 init 时,本插件 `random` 是按「主 seed + 插件名」派生的独立子流。
- `time`:受控时间源(本波 juice/粒子计时直接用帧 dt;保留时间源用于集成段 mock)。**绝不读 `Date.now`/`performance.now`**。
- **dt 约定**:`step(dt)` 的 dt 即调用方传入的帧时间步;粒子/发射器/juice 共用同一 dt(语义清晰、确定性可测)。若游戏层要慢动作,自行用 `getTimeScale()` 缩放它喂给「游戏世界」的 dt——粒子系统作为世界一部分由游戏层统一调度,插件内部不二次乘 timeScale。
- **受控面用法**(用到 `PluginContext` 5 项):
- `getEngine()`(A2 新增):init 取一次 `getEngine()?.particles` 缓存;`spawnEmitter` 经它把 spec 交引擎构造 `ParticleEmitter`(引擎自渲)。null(host-dev/node 无引擎)→ no-op 句柄,**禁回退 sim**。**插件零直接 import littlejsengine**。
- `onFrame(cb)`:`autoStep`(缺省 true)时 init 注册逐帧 `step(dt)`(A2 后只推 juice,粒子归引擎);拿 `FrameHandle` 供 dispose 注销。**绝不直接 `requestAnimationFrame`**。
- `getContext2d()`:`render()` 时取受控 2D 上下文画 juice 闪白叠加层;node 侧返回 `null` 安全降级。**不自行 `document.querySelector`**。
- `random`:**juice 不用随机**(计时/衰减确定性走 dt);`seed` 选项保留但 A2 后不影响粒子序列(引擎粒子用引擎全局随机,不可种子对齐)。**绝不读 `Math.random`**。
- `time`:受控时间源(juice 计时直接用帧 dt;保留时间源用于集成段 mock)。**绝不读 `Date.now`/`performance.now`**。
- **dt 约定**:`step(dt)` 的 dt 即调用方传入的帧时间步(A2 后仅 juice 用);引擎粒子由引擎主循环自驱、与 juice 各自时基。若游戏层要慢动作,自行用 `getTimeScale()` 缩放它喂给「游戏世界」的 dt。
- **注册方式**:`registry.register(createParticlesJuicePlugin(opts))`。
- **配置项 `opts`**:`{ seed?: number; maxParticles?: number(缺省2000); autoStep?: boolean(缺省true) }`。
- **配置项 `opts`**:`{ seed?: number; maxParticles?: number(A2 后对引擎粒子不约束,保契约兼容); autoStep?: boolean(缺省true) }`。
## 3. 示例
@ -63,9 +65,9 @@ fx.pulseScale(0.2, 0.2, 4);
// 5) 驱动若干帧(host-dev 用 tick;集成段由 LittleJS gameUpdate 驱动)。
tick(0.016);
console.log(fx.getTimeScale(), fx.getShakeOffset(), fx.getFlashAlpha());
// 游戏层渲染阶段:先 translate(getShakeOffset()),再 fx.render() 绘粒子+闪白叠加。
// 游戏层渲染阶段:粒子由引擎自渲;游戏层先 translate(getShakeOffset()),再 fx.render() 画 juice 闪白叠加层。
// 6) 释放(逆序,幂等):清空粒子/发射器、复位 juice。
// 6) 释放(逆序,幂等):停止并清空引擎发射器句柄、复位 juice。
reg.disposeAll();
```
@ -73,26 +75,28 @@ reg.disposeAll();
- 路径:`src/plugins/particles-juice/test/particles-juice.test.mjs`
- 运行:`node --test src/plugins/particles-juice/test/particles-juice.test.mjs`(零依赖,仅 `node:test`/`node:assert`)
- **node 层覆盖**(确定性状态与参数):① 曲线求值端点恒等 + 衰减/线性单调段 + t 越界钳制;② 粒子数守恒与生命周期(burst 立即喷发 count 颗、全部 decay 后归零、单帧跨 life 全回收);③ 确定性(同 seed 两实例逐帧 step 后粒子状态**逐字段**复现、异 seed 不同);④ hit-stop timescale 时序(触发即谷底→线性恢复→结束恒 1、0 时长不卡死);⑤ 屏震衰减收敛至 {0,0}(每轴幅度受 amplitude 界);⑥ 闪白/弹性脉冲(0 时长恒等、正常衰减收敛);⑦ 预设参数包 schema 自洽 + 命名中性 + 深拷贝不污染;⑧ render 调用面经「伪 2D 上下文桩」(arc/fill 次数=粒子数、闪白期一次全屏 fillRect)+ 无 canvas 安全降级;⑨ 全流程接纳 + dispose 幂等清空。
- **留集成段(浏览器证据 harness)**:**canvas 渲染像素哈希**——粒子/闪白叠加的真实绘制结果(ImageData FNV-1a 哈希 + 非空/色彩/几何断言 + 截图)。node 层**不引 node-canvas**(明令禁止),只验「调用面被正确驱动」,**像素级正确性留集成段**。
- **node 层覆盖**(A2 边界模型后):① 曲线求值端点恒等 + 衰减/线性单调段 + t 越界钳制;② **`toEngineSpec` 纯映射**(经 mock 引擎 spy 捕获 spec,逐字段断 burst/continuous 模式→emitTime/emitRate/count、区间取中值、曲线端点、颜色 a——继承旧「逐字段确定性」价值);③ **mock 引擎注入**(断 `spawnEmitter` 真调引擎能力面一次 + `stopEmitter` 转发 handle.stop + 未知预设名 -1 不调引擎);④ **null-engine no-op**(无引擎 → 返有效 id 不抛、`particleCount` 恒 0、render 不画粒子);⑤ **颜色可见性**(三预设映射后颜色端点至少一端 a>0,保 real 像素门「有色像素命中>0」前提);⑥ hit-stop timescale 时序(触发即谷底→线性恢复→结束恒 1、0 时长不卡死);⑦ 屏震衰减收敛至 {0,0}(每轴幅度受 amplitude 界);⑧ 闪白/弹性脉冲(0 时长恒等、正常衰减收敛);⑨ 预设参数包 schema 自洽 + 命名中性 + 深拷贝不污染;⑩ render 调用面经「伪 2D 上下文桩」(**A2 后只画 juice 闪白叠加:闪白期一次全屏 fillRect、粒子段零 arc**)+ 无 canvas 安全降级;⑪ 全流程接纳 + dispose 幂等(句柄清空、转发引擎 stop)。
- **留集成段(浏览器证据 harness · mini-desktop real 像素门)**:**真渲出现**——粒子真身由引擎 `ParticleEmitter` 自渲落 mainContext,`?engine=real&mode=evidence` → spawn → 引擎 RAF 渲 → 抓 canvas 断「非空 + 有色像素命中>0 + 无水印」。node 层**禁 import 真引擎**(dist 加载即引用 window 抛错)→ 引擎包装验证一律用 mock 引擎,**真渲像素正确性留集成段**。
## 5. 边界与字节预算
- **字节预算**:`gzBudgetBytes = 10240`(与 `manifest.json` 一致);集成段 `size.mjs` 增量法实测对照,超配额触发评审(执行版 §5)。
- **行为边界**:
- null 渲染环境降级(`render()` 无 canvas 时不崩,只更新数据不绘制);
- 粒子上限背压(超 `maxParticles` 的新粒子被丢弃并计入 `probe().dropped`,不失控分配);
- null-engine(无引擎):`spawnEmitter` 返 no-op 句柄(不喷不渲,`particleCount` 恒 0,**禁回退 sim**);
- null 渲染环境降级(`render()` 无 canvas 时不崩,只画 juice 闪白叠加层、无 canvas 则不绘制);
- 屏震/闪白/脉冲/hit-stop 的 0 时长输入安全(不卡死、立即归位);
- `amplitude` 为屏震**每轴**上界(合成矢量模可达约 √2·amp,属预期);
- **v1 环境简化**:重力/阻力与粒子尺寸/透明度曲线取「最近一个存活发射器」为全局环境近似(多发射器异构环境留 v2);
- `dispose` 幂等(重复安全、清空粒子与发射器、复位 juice、不留悬挂帧回调)。
- **受控面铁律**:只经 `PluginContext` 的 `getContext2d`/`onFrame`/`time`/`random` 拿引擎能力,**绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF / Math.random / Date.now**——保「引擎可换」。
- **渲染验证边界声明(两层测试)**:本插件 node 层**不做像素级渲染验证**(不引 node-canvas);`render()` 调用面经伪 2D 桩验证(draw 次数/顺序),**像素级哈希与视觉断言留集成段浏览器证据 harness**。
- **粒子环境近似(A2 引擎能力边界)**:speed/life 区间→引擎单值取中值;sizeCurve 任意曲线→引擎仅两端点线性插值(非线性段被拍平);重力依赖引擎全局 gravity(MVP 默认 0 → gravityScale 暂不生效,follow-up)。还原度由集成段 real 像素门兜(非 same-pixel);
- `dispose` 幂等(重复安全、转发引擎句柄 stop 并清表、复位 juice、不留悬挂帧回调)。
- **受控面铁律**:只经 `PluginContext` 的 `getContext2d`/`onFrame`/`time`/`random`/`getEngine` 拿引擎能力,**绝不直透 littlejsengine 裸对象 / 全局 canvas / 原生 RAF / Math.random / Date.now**——保「引擎可换」(粒子能力经 `ctx.getEngine().particles` 薄包装,引擎 import 只活集成段 host,插件零直接 import)。
- **渲染验证边界声明(两层测试)**:粒子真身在引擎对象列表、由引擎自渲落 mainContext,插件 `render()` 仅画 juice 闪白叠加层;本插件 node 层**不做像素级渲染验证**(不引 node-canvas、不 import 真引擎),`render()` 调用面经伪 2D 桩验证(A2 后:闪白 fillRect 在、粒子 arc 零),**真渲像素与视觉断言留集成段 mini-desktop real 像素门**。
## 6. v2 声明
本版(v1 / seed)刻意未做、留待实证后做:
- **多发射器异构环境**:当前重力/阻力/尺寸-透明度曲线取「最近发射器」全局近似;v2 改为粒子级携带各自发射器的环境与曲线引用(按需,权衡内存)。
本版刻意未做、留待实证后做:
- **粒子重力**(A2 follow-up):引擎 `gravityScale` 依赖引擎全局 `gravity`(host 未 `setGravity`,MVP 默认 0 故暂不生效);若某玩法强需粒子重力下落,由集成段统一设全局 gravity(须像素口径换算)或评估补层(仅 drift `gravity=8` 受影响,观感损失小)。
- **非线性曲线还原**(A2 OQ-5):引擎粒子尺寸/透明仅两端点线性插值,sizeCurve 的 decay/easeInOut 非线性段被拍平为线性;若某玩法强依赖曲线形状,按补层自研(YAGNI,MVP 不预造)。real 像素门验「有粒子+主色」即可,不验曲线形状。
- **burst 精确 count**(A2 OQ-1):当前 burst 用「1 逻辑帧窗 × count×60 速率」近似喷 ≈count 颗(引擎无「spawn 恰好 N」原语);若 real 像素门发现数量明显失真,再上「句柄层手调 `emitParticle()`×N」精确法。
- **更多预设与曲线类型**:当前 3 个中性预设(burst/trail/drift)+ 4 类曲线(constant/linear/easeInOut/decay)为最小可用;v2 按参考件实证缺口扩充(仍须命名中性、无主题语义)。
- **粒子旋转/纹理/混合模式**:当前粒子为纯圆点 + alpha;v2 视集成段需求加旋转角、混合模式(成品贴图始终属 agent 生成域,插件只提供渲染原语)。
- **粒子旋转/纹理/混合模式**:当前粒子为引擎 untextured 圆点 + alpha + 可选 additive;v2 视集成段需求开放旋转角/纹理(成品贴图始终属 agent 生成域,插件只提供渲染原语)。
- **juice 曲线可配置化**:当前屏震/闪白/脉冲为内置衰减形态;v2 可开放自定义衰减曲线(复用 Curve 类型)。

View File

@ -4,25 +4,34 @@
*
* 【本插件定位(engine 级,无玩法语义)】
* 提供两类纯表现层引擎能力,**不含任何玩法/美术成品/关卡/UI 假设**:
* 1) 粒子系统:发射器(位置/方向锥/速率/生命/尺寸与透明度曲线)+ 纯数据驱动更新 +
* 经受控 2D 上下文绘制(渲染可缺省,无 canvas 时只更新数据不绘制)。
* 1) 粒子系统:经 `ctx.getEngine().particles` **薄包装引擎 ParticleEmitter**(引擎自渲落 mainContext);
* 插件侧把 EmitterConfig(像素口径参数包)纯映射为 EngineEmitterSpec 后调引擎能力面,返封装句柄。
* **不再自研逐粒子积分 sim**(边界模型:引擎有 ParticleEmitter→薄包装,一职一路)。
* 2) juice 套件:hit-stop(经受控时间源的 timescale 钩子)/ 屏震(衰减曲线,**只输出偏移量**,
* 由游戏层自行 translate 应用)/ 闪白(全屏叠加 alpha 曲线)/ 弹性缩放脉冲。
* juice 是**补层**(引擎无对应受控面能力)——全留全测,全通道(host-dev/node/集成段)行为一致。
* 预设(burst/trail/drift)只是**参数包**,命名中性、不含主题语义(不是「火焰/烟雾/落叶」,
* 而是「爆裂/拖尾/飘落」这种纯运动学形态)——成品美术属 agent 生成域,不进插件。
*
* 【确定性铁律(lane-vfx 生命线)】
* 一切随机走 ctx.random(per-plugin 派生子流),**绝不读 Math.random**;一切时间走 ctx.time
* 或帧 dt,**绝不读 Date.now/performance.now**。同 seed + 同 tick 序列 → 粒子状态逐字段复现。
* 【引擎↔插件 边界模型(创始人 2026-06-13 拍,不可违)】
* 引擎有 → 薄包装(经 ctx.getEngine(),删平行自研 sim);引擎无 → 自研补层(juice)。
* null-engine(host-dev/node 无引擎,getEngine() 返 null):粒子能力 → **优雅 no-op 句柄**(不喷不渲,particleCount 恒 0,
* **禁回退 sim**);juice 补层 → 全通道等价跑(不依赖引擎)。
*
* 【两层测试纪律(执行版 §3 lane-vfx 行)】
* node 层只测「确定性状态与参数」(粒子数守恒/生命周期/曲线求值/timescale 时序/屏震收敛/预设 schema)。
* canvas 渲染像素哈希 = **集成段浏览器证据(harness)**,本插件 node 层不引 node-canvas(明令禁止)。
* 渲染路径代码须存在且可经「伪 2D 上下文桩」验证调用面,像素级留集成段(PLUGIN.md §5 声明此边界)。
* 【确定性铁律】
* juice 计时/衰减确定性走 ctx.time/帧 dt,**绝不读 Date.now/performance.now**——同 dt 序列 juice 状态逐字段复现。
* **引擎粒子用引擎全局随机(randomness 抖动 + 锥角采样),不保插件种子复现**(边界模型裁定,见 policy §5.3)。
*
* 【两层测试纪律】
* node 层测:① `toEngineSpec` 纯映射(EmitterConfig→EngineEmitterSpec 逐字段,无引擎可单测);
* ② mock 引擎注入(断 spawnEmitter 真调引擎能力面 + 入参对);③ null-engine no-op(返句柄不抛、particleCount 恒 0);
* ④ juice 确定性(计时/衰减/收敛)+ 曲线求值 + 预设 schema(补层全留)。
* **node 禁 import 真引擎**(dist 加载即引用 window 抛错)→ 引擎包装验证一律用 mock;
* 真渲出现(粒子真画出来)= mini-desktop real 像素门兜底(PLUGIN.md §5 声明此边界)。
*
* 【源码形态纪律(执行版 §1)】
* ESM JS + JSDoc;零工具链;本机 node --test 直跑全绿;不 import littlejsengine——
* 一切引擎能力经 ctx 受控面(getContext2d/onFrame/time/random)拿。
* ESM JS + JSDoc;零工具链;本机 node --test 直跑全绿;**不 import littlejsengine**——
* 一切引擎能力经 ctx 受控面(getContext2d/onFrame/time/random/getEngine)拿(Q4 confinement 守住)。
*/
'use strict';
@ -31,12 +40,13 @@
* @typedef {import('../../core/api.d.ts').Plugin} Plugin
* @typedef {import('../../core/api.d.ts').PluginContext} PluginContext
* @typedef {import('../../core/api.d.ts').FrameHandle} FrameHandle
* @typedef {import('../../core/api.d.ts').RandomSource} RandomSource
* @typedef {import('./api.d.ts').ParticlesJuicePlugin} ParticlesJuicePlugin
* @typedef {import('./api.d.ts').ParticlesJuiceOptions} ParticlesJuiceOptions
* @typedef {import('./api.d.ts').EmitterConfig} EmitterConfig
* @typedef {import('./api.d.ts').Particle} Particle
* @typedef {import('./api.d.ts').Curve} Curve
* @typedef {import('../../core/api.d.ts').EngineEmitterSpec} EngineEmitterSpec
* @typedef {import('../../core/api.d.ts').EngineEmitterHandle} EngineEmitterHandle
* @typedef {import('../../core/api.d.ts').EngineParticles} EngineParticles
* @typedef {import('./api.d.ts').ShakeState} ShakeState
* @typedef {import('./api.d.ts').FlashState} FlashState
* @typedef {import('./api.d.ts').PulseState} PulseState
@ -168,12 +178,55 @@ function deepCloneConfig(cfg) {
}
/* ============================================================================
* 三、粒子系统主体 —— 发射器 + 纯数据驱动更新 + 受控 2D 绘制
* 架构:插件持有「活动发射器集合」与「粒子池」。每帧 step(dt):
* ① 发射器按 mode/rate 补充粒子(随机走 ctx.random);
* ② 所有粒子按物理(速度/重力/阻力)积分 + 生命衰减;
* ③ 生命耗尽的粒子回收(粒子数守恒可断言)。
* 渲染 render() 与更新解耦:无 canvas 时只更新不绘制(node 侧),有 canvas 时经受控 2D 绘制。
* 三、EmitterConfig → EngineEmitterSpec 纯映射(受控面 spec 来源)
* 架构(A2 边界模型):插件不再持本地粒子池/积分 sim;spawnEmitter 把 EmitterConfig(像素口径参数包)
* 纯映射为 EngineEmitterSpec(受控面像素中性形态),交 ctx.getEngine().particles.spawnEmitter(引擎背书)。
* 像素↔世界阻抗换算(px→world、sec→frame)在 **host 工厂**做;本函数只做「插件语义→受控面 spec」结构映射,
* 无随机、无引擎、无副作用 → node 可直接单测(继承旧「逐字段确定性」价值,policy §5.1-①/§5.3)。
* ========================================================================== */
/**
* EmitterConfig(插件像素口径参数包)→ EngineEmitterSpec(受控面像素中性形态)纯映射。
* 纯函数:同 cfg+x+y → 同 spec(确定可复现);阻抗换算不在此(host 工厂做)。
* 语义近似(由 host real 像素门兜还原度,非 same-pixel):
* · speed/life **区间**(min/max)→ 引擎单值,取**中值**(引擎能力边界,OQ-3);
* · sizeCurve **任意曲线** → 引擎仅两端点线性插值,取曲线**端点**(非线性段被拍平,OQ-5);
* · alphaCurve → 并入颜色 a 通道(颜色 rgb 给中性白,引擎默认色淡到透明黑底不可见,I2)。
* @param {EmitterConfig} cfg 发射器参数包(已深拷贝定型)
* @param {number} x 发射原点 x(像素)
* @param {number} y 发射原点 y(像素)
* @returns {EngineEmitterSpec} 受控面发射 spec(像素口径,host 再换算到引擎世界口径)
*/
function toEngineSpec(cfg, x, y) {
const isContinuous = cfg.mode === 'continuous';
return {
pos: { x, y },
angle: cfg.angle,
coneAngle: cfg.spread, // 半锥角 → 引擎 emitConeAngle
emitSize: 0, // 现 EmitterConfig 无 emitSize 字段 → 点发射
emitTime: isContinuous ? 0 : 1 / 60, // continuous=forever(0) / burst=1 逻辑帧窗(I1)
emitRate: isContinuous ? (cfg.rate || 0) : undefined, // continuous 用 rate;burst 用 count(下行)
count: isContinuous ? undefined : (cfg.count || 0), // burst 颗数(host 据此算 emitRate=count×60)
particleTime: (cfg.lifeMin + cfg.lifeMax) / 2, // 生命区间中值(OQ-3)
speed: (cfg.speedMin + cfg.speedMax) / 2, // 初速区间中值(像素/秒,OQ-3)
gravityScale: cfg.gravity || 0, // 透传(host 据全局重力解释,MVP 全局 gravity=0 故暂不生效,OQ-4)
damping: cfg.drag != null ? (1 - cfg.drag / 60) : 1, // 每秒线性阻力 → 引擎每帧速度乘子近似
sizeStart: evalCurve(cfg.sizeCurve, 0), // 尺寸曲线起点(像素,host ÷cameraScale)
sizeEnd: evalCurve(cfg.sizeCurve, 1), // 尺寸曲线终点(引擎仅两端点线性,OQ-5)
// 颜色:中性白 + alpha 端点(透明度并入 a 通道;保 real 像素门可见,I2)。
colorStart: { r: 1, g: 1, b: 1, a: evalCurve(cfg.alphaCurve, 0) },
colorEnd: { r: 1, g: 1, b: 1, a: evalCurve(cfg.alphaCurve, 1) },
additive: false, // juice 闪白不经此(juice 是补层);粒子默认不叠加
};
}
/* ============================================================================
* 四、插件主体 —— 粒子薄包装(引擎自渲)+ juice 补层
* 架构(A2):
* · 粒子:init 取一次 ctx.getEngine()?.particles 缓存;spawnEmitter 把 cfg 经 toEngineSpec 交引擎能力面,
* 引擎构造 ParticleEmitter 自入对象列表、引擎主循环自驱 update/render(host 无需手动 add/render)。
* null-engine:登记 no-op 句柄(不喷不渲,particleCount 恒 0)。
* · juice:每帧 step(dt) 推进 hit-stop/屏震/闪白/脉冲(补层,全通道等价);render() 只画 juice 闪白叠加层。
* ========================================================================== */
/**
@ -183,24 +236,23 @@ function deepCloneConfig(cfg) {
*/
export function createParticlesJuicePlugin(opts) {
const options = opts || {};
// 全局粒子上限(防失控分配);超限时新发射被丢弃(背压,记 dropped 供探针)。
const MAX_PARTICLES = typeof options.maxParticles === 'number' ? options.maxParticles : 2000;
// 注:maxParticles 选项保留(契约 ParticlesJuiceOptions 仍声明),但 sim 删后本地不再分配粒子池 →
// 背压(dropped)随本地池退役;引擎粒子上限由引擎自管,此选项在 A2 后对引擎粒子无约束力(保契约兼容,不消费)。
// ── 受控面句柄与内部状态(闭包封装,不挂全局)──
/** @type {PluginContext|null} 受控上下文(init 时注入)。 */
let context = null;
/** @type {FrameHandle|null} onFrame 句柄;dispose 时 cancel。 */
let frameHandle = null;
/** @type {RandomSource|null} 受控随机源(确定性,per-plugin 派生)。 */
let rng = null;
/** @type {Particle[]} 活动粒子数组(紧凑存储;回收用「交换尾部」O(1) 删除)。 */
const particles = [];
/** @type {Map<number, _Emitter>} 活动发射器表(key=自增 id)。 */
const emitters = new Map();
// ── 引擎粒子能力(A2 薄包装)──
/** @type {EngineParticles|null} init 时取一次的引擎粒子能力面(缓存,禁每帧调 getEngine;null=null-engine)。 */
let _engParticles = null;
/** @type {Map<number, EngineEmitterHandle>} 活动发射器句柄表(key=自增 id,替代旧本地发射器表)。 */
const _engHandles = new Map();
let emitterIdSeq = 0;
/** @type {number} 因超上限被丢弃的粒子总数(探针/诊断)。 */
let dropped = 0;
/** null-engine 占位 no-op 句柄(particleCount 恒 0,不喷不渲;边界模型禁回退 sim)。 */
const NOOP_HANDLE = { isActive() { return false; }, stop() {} };
/** @type {number} 累计 step 帧数(探针)。 */
let steps = 0;
@ -224,127 +276,9 @@ export function createParticlesJuicePlugin(opts) {
/** @type {boolean} 是否已 dispose(幂等保护)。 */
let disposed = false;
/**
* @typedef {Object} _Emitter 内部发射器记录(不对外暴露原型)。
* @property {number} id
* @property {EmitterConfig} cfg 发射器参数包(已深拷贝定型)。
* @property {number} x 发射原点 x。
* @property {number} y 发射原点 y。
* @property {boolean} alive 是否存活(continuous 被 stop 后置 false 并待清)。
* @property {number} _acc continuous 模式的发射累加器(按 rate 累计应发数)。
*/
/**
* 发射一颗粒子(随机参数全走 rng)。超上限则丢弃并计数(背压)。
* @param {_Emitter} em 发射器
*/
function spawnOne(em) {
if (particles.length >= MAX_PARTICLES) {
dropped += 1;
return;
}
const cfg = em.cfg;
// 方向:锥中心 angle ± spread,均匀采样(走 rng.range)。
const dir = cfg.angle + rng.range(-cfg.spread, cfg.spread);
// 速率:[speedMin, speedMax) 均匀采样。
const speed = rng.range(cfg.speedMin, cfg.speedMax);
// 生命:[lifeMin, lifeMax) 均匀采样。
const life = rng.range(cfg.lifeMin, cfg.lifeMax);
/** @type {Particle} */
const p = {
x: em.x,
y: em.y,
vx: Math.cos(dir) * speed,
vy: Math.sin(dir) * speed,
age: 0, // 已存活时长(秒)。
life, // 总寿命(秒)。
size: evalCurve(cfg.sizeCurve, 0), // 初始尺寸 = 尺寸曲线在 t=0。
alpha: 1, // 初始透明度(每帧由 alphaCurve 重算)。
};
particles.push(p);
}
/**
* 推进发射器一帧(按 mode 补充粒子)。
* @param {_Emitter} em
* @param {number} dt 帧时间步(秒,已是「受 timeScale 影响后的游戏 dt」由游戏层决定;这里直接用)。
*/
function stepEmitter(em, dt) {
const cfg = em.cfg;
if (cfg.mode === 'continuous') {
if (!em.alive) return; // 已停的持续发射器不再补充(待自然清理)。
// 按 rate 累计应发数;累加器满 1 发一颗(确定性:不依赖随机决定是否发)。
em._acc += (cfg.rate || 0) * dt;
while (em._acc >= 1) {
em._acc -= 1;
spawnOne(em);
}
}
// burst 模式:spawn 时已一次性发完,step 不再补充。
}
/**
* 积分所有粒子一帧 + 回收死亡粒子(粒子数守恒可断言:存活数 = 原数 - 本帧死亡数)。
* @param {number} dt 帧时间步(秒)。
*/
function integrateParticles(dt) {
// 倒序遍历,便于「交换尾部」O(1) 删除而不打乱未遍历项。
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.age += dt;
if (p.age >= p.life) {
// 死亡回收:与尾部交换后 pop(O(1))。
const last = particles.pop();
if (i < particles.length) particles[i] = last;
continue;
}
// 物理积分(半隐式欧拉):先更速度(重力/阻力),再更位置。
// 取发射器无关的全局重力/阻力?——粒子自带不便,故由发射器配置在 spawn 时已决定初速;
// 重力/阻力是「环境」属性,这里用粒子所属发射器已不可知,改为粒子级常量场:
// 为保持纯数据与确定性,重力/阻力统一取自插件级环境(见 step 注入),此处用闭包 envGravity/envDrag。
p.vy += envGravity * dt;
const dragFactor = 1 - envDrag * dt;
p.vx *= dragFactor > 0 ? dragFactor : 0;
p.vy *= dragFactor > 0 ? dragFactor : 0;
p.x += p.vx * dt;
p.y += p.vy * dt;
// 表现:尺寸/透明度按归一化生命 t 求值(确定性纯函数)。
const t = p.age / p.life;
p.size = evalCurve(_sizeCurveOf(p), t);
p.alpha = evalCurve(_alphaCurveOf(p), t);
}
}
// 环境场(重力/阻力):为避免每颗粒子存一份曲线引用(省内存 + 保数据纯净),
// 本波采用「最近一个发射器的环境参数」作为全局环境近似。这是 v1 的刻意简化——
// 多发射器异构环境留 v2(PLUGIN.md §6 声明)。env 由 step 前从活动发射器聚合。
/** @type {number} 全局重力(每秒纵向加速度)。 */
let envGravity = 0;
/** @type {number} 全局阻力系数(每秒)。 */
let envDrag = 0;
// 粒子的尺寸/透明度曲线:v1 用「全局最近发射器曲线」,与 env 同口径简化。
/** @type {Curve} */
let _globalSizeCurve = { kind: 'constant', value: 3 };
/** @type {Curve} */
let _globalAlphaCurve = { kind: 'linear', from: 1, to: 0 };
/** 取粒子的尺寸曲线(v1:全局曲线)。 */
function _sizeCurveOf(_p) { return _globalSizeCurve; }
/** 取粒子的透明度曲线(v1:全局曲线)。 */
function _alphaCurveOf(_p) { return _globalAlphaCurve; }
/** 从活动发射器聚合全局环境与曲线(取「最后一个存活发射器」的参数,v1 简化)。 */
function refreshEnv() {
let chosen = null;
for (const em of emitters.values()) {
if (em.alive || em.cfg.mode === 'burst') chosen = em;
}
if (chosen) {
envGravity = chosen.cfg.gravity || 0;
envDrag = chosen.cfg.drag || 0;
_globalSizeCurve = chosen.cfg.sizeCurve || _globalSizeCurve;
_globalAlphaCurve = chosen.cfg.alphaCurve || _globalAlphaCurve;
}
}
// 【A2 删自研 sim】spawnOne / stepEmitter / integrateParticles / 环境场(envGravity/envDrag/_globalSizeCurve/_globalAlphaCurve)
// / _sizeCurveOf / _alphaCurveOf / refreshEnv 已随边界模型删除——粒子积分/发射/生命由引擎 ParticleEmitter 接管(一职一路,
// 引擎有此能力故插件不留平行自研)。粒子环境(重力/阻力/曲线端点)经 toEngineSpec 映射进引擎构造参数。
/**
* 推进 juice 套件一帧(hit-stop/屏震/闪白/脉冲各自按曲线衰减)。
@ -407,49 +341,27 @@ export function createParticlesJuicePlugin(opts) {
}
/**
* 主步进:推进发射器 + 粒子 + juice 一帧。host-dev 由 tick 驱动;集成段由引擎主循环驱动。
* @param {number} dt 帧时间步(秒)。约定:调用方传入的 dt 即「已应用 timeScale 的游戏 dt」用于
* 粒子/发射器;juice 自身计时用「未缩放真实 dt」——但 host-dev 桩只有一个 dt,
* 故 v1 约定:step(dt) 的 dt 为真实 dt,粒子/发射器内部不再二次乘 timeScale(游戏层若要慢动作,
* 自行用 getTimeScale() 缩放它喂给游戏世界的 dt;粒子系统作为「世界的一部分」由游戏层统一调度)。
* 这样 juice 与粒子共用同一 dt,语义清晰、确定性可测(PLUGIN.md §2 写明此约定)。
* 主步进(A2 删 sim 后只剩 juice 推进)。host-dev 由 tick 驱动;集成段由引擎主循环驱动。
* 【A2】粒子的发射/积分/生命/回收已交引擎 ParticleEmitter(引擎主循环每帧自驱),插件 step 不再碰粒子;
* 仅推进 juice 套件(hit-stop/屏震/闪白/脉冲,补层)。
* @param {number} dt 帧时间步(秒,juice 计时用;juice 与引擎粒子各自由各自时基驱动,语义清晰)。
*/
function step(dt) {
steps += 1;
refreshEnv();
// 1) 发射器补充粒子。
for (const em of emitters.values()) {
stepEmitter(em, dt);
}
// 2) 清理已停且无粒子贡献的 continuous 发射器(burst 发完即可移除)。
for (const [id, em] of emitters) {
if (em.cfg.mode === 'burst') emitters.delete(id); // burst 一次性,发完即删。
else if (!em.alive) emitters.delete(id); // 已 stop 的 continuous 删除。
}
// 3) 积分粒子 + 回收。
integrateParticles(dt);
// 4) juice 推进。
stepJuice(dt);
stepJuice(dt); // 仅 juice 补层(粒子归引擎)。
}
/**
* 渲染:把当前粒子 + juice 叠加层绘到受控 2D 上下文。
* **渲染与更新解耦**:无 canvas(node 侧 getContext2d 返回 null)时直接返回,只更新不绘制。
* 像素级正确性留集成段浏览器证据;node 层经「伪 2D 上下文桩」验证调用面(draw 次数/顺序)。
* 渲染(A2 瘦身):只画 **juice 闪白叠加层**到受控 2D 上下文。
* 【A2】粒子真身=引擎 ParticleEmitter,由引擎主循环自渲落 mainContext(drawTile,esm.js:8654/8658)——
* 插件 render() 不再逐颗画粒子(删 arc/fill 粒子段)。仅 juice 闪白叠加层(补层,引擎不画)由此输出。
* 无 canvas(node 侧 getContext2d 返回 null)时直接返回(安全降级)。
* @param {CanvasRenderingContext2D|null} [ctx2d] 可显式传上下文(测试用伪桩);缺省走 context.getContext2d()。
*/
function render(ctx2d) {
const g = ctx2d !== undefined ? ctx2d : (context ? context.getContext2d() : null);
if (!g) return; // 无渲染环境:安全降级(只更新不绘制)。
// 1) 粒子:逐颗以 (x,y,size,alpha) 画小圆(fillRect 亦可,这里用 arc 圆点)。
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
g.globalAlpha = p.alpha;
g.beginPath();
g.arc(p.x, p.y, p.size, 0, Math.PI * 2);
g.fill();
}
// 2) 闪白:全屏白色叠加(alpha 由 flash 给)。
if (!g) return; // 无渲染环境:安全降级。
// 闪白:全屏白色叠加(alpha 由 flash 给;juice 补层,引擎不画此叠加层)。
if (flash.alpha > 0) {
g.globalAlpha = flash.alpha;
g.fillStyle = '#ffffff';
@ -457,9 +369,9 @@ export function createParticlesJuicePlugin(opts) {
const cw = g.canvas ? g.canvas.width : 0;
const ch = g.canvas ? g.canvas.height : 0;
g.fillRect(0, 0, cw, ch);
// 复位 alpha,避免污染后续绘制(受控面礼貌;仅在画了叠加层后才需复位)。
g.globalAlpha = 1;
}
// 复位 alpha,避免污染后续绘制(受控面礼貌)。
g.globalAlpha = 1;
}
/** @type {ParticlesJuicePlugin} */
@ -474,12 +386,16 @@ export function createParticlesJuicePlugin(opts) {
*/
init(ctx) {
context = ctx;
rng = ctx.random;
// 【A2】init 取一次引擎粒子能力(缓存,禁每帧调 getEngine)。null=null-engine(host-dev/node)→ spawnEmitter 走 no-op 句柄。
// 引擎覆盖此能力,故 null 时不回退 sim(边界模型一职一路)。
_engParticles = ctx.getEngine()?.particles || null;
if (typeof options.seed === 'number') {
// 给定种子则重设受控随机源,使粒子序列确定可复现(取证友好)。
rng.reseed(options.seed);
// 给定种子则重设受控随机源(契约 ParticlesJuiceOptions.seed 既有语义保留)。
// 注:A2 后插件粒子用引擎全局随机(不读 ctx.random),故此 reseed 不再影响粒子序列;
// 保留是为不静默改变 seed 选项语义 + host 取证侧复位受控随机的一致性(host 另有 reseedParticleRandom 直接复位派生实例)。
ctx.random.reseed(options.seed);
}
// 默认注册逐帧 step(autoStep 缺省 true)。游戏层若要自管步进可传 autoStep:false 后手调 step()。
// 默认注册逐帧 step(autoStep 缺省 true)——A2 后 step 只推 juice(粒子归引擎)。游戏层可传 autoStep:false 自管。
if (options.autoStep !== false) {
frameHandle = ctx.onFrame((dtSeconds) => {
step(dtSeconds);
@ -488,7 +404,7 @@ export function createParticlesJuicePlugin(opts) {
},
/**
* 释放:注销帧回调、清空粒子/发射器、复位 juice。幂等。
* 释放:注销帧回调、停止并清空所有引擎发射器句柄、复位 juice。幂等。
*/
dispose() {
if (disposed) return;
@ -496,8 +412,9 @@ export function createParticlesJuicePlugin(opts) {
frameHandle.cancel();
frameHandle = null;
}
particles.length = 0;
emitters.clear();
// 【A2】停止所有引擎发射器句柄(转发引擎 stop→destroy),再清表(替代旧本地粒子池清空)。
_engHandles.forEach((h) => { try { h.stop(); } catch { /* 句柄 stop 幂等容错,忽略 */ } });
_engHandles.clear();
timeScale = 1;
hitStopRemain = 0;
shake.remain = 0; shake.x = 0; shake.y = 0;
@ -509,15 +426,17 @@ export function createParticlesJuicePlugin(opts) {
/* ── 粒子能力面 ─────────────────────────────────────────────────────── */
/**
* 生成一个发射器。burst 模式立即喷发 count 颗;continuous 模式按 rate 持续发射。
* 生成一个发射器(A2 薄包装引擎 ParticleEmitter)。burst 一次性喷发 count 颗;continuous 按 rate 持续。
* 流程:解析配置(预设/内联深拷贝 + overrides)→ 登记自增 id → 有引擎则 toEngineSpec 纯映射后调引擎能力面(引擎构造+自渲);
* null-engine 则登记 no-op 句柄(不喷不渲,禁回退 sim)。
* @param {EmitterConfig|string} configOrPreset 发射器参数包,或预设名('burst'|'trail'|'drift')。
* @param {number} x 发射原点 x。
* @param {number} y 发射原点 y。
* @param {number} x 发射原点 x(像素)。
* @param {number} y 发射原点 y(像素)。
* @param {Partial<EmitterConfig>} [overrides] 在预设/配置上的局部覆盖。
* @returns {number} 发射器 id(continuous 模式可用它 stopEmitter;burst 返回的 id 当帧即失效)。
* @returns {number} 发射器 id(可用它 stopEmitter;未知预设名返回 -1)。
*/
spawnEmitter(configOrPreset, x, y, overrides) {
// 解析配置:字符串视为预设名取深拷贝;对象视为内联配置深拷贝。
// 解析配置:字符串视为预设名取深拷贝;对象视为内联配置深拷贝(语义不变)。
let cfg = typeof configOrPreset === 'string'
? getPreset(configOrPreset)
: deepCloneConfig(configOrPreset);
@ -529,46 +448,49 @@ export function createParticlesJuicePlugin(opts) {
cfg = Object.assign(cfg, overrides);
}
const id = emitterIdSeq++;
/** @type {_Emitter} */
const em = { id, cfg, x, y, alive: true, _acc: 0 };
emitters.set(id, em);
// burst:立即喷发 count 颗(确定性:随机走 rng)。
if (cfg.mode === 'burst') {
const n = cfg.count || 0;
for (let i = 0; i < n; i++) spawnOne(em);
// null-engine:登记 no-op 句柄(边界模型禁回退 sim;particleCount 恒 0)。
if (!_engParticles) {
_engHandles.set(id, NOOP_HANDLE);
return id;
}
// 同步一次环境(供本帧 render 前若直接 integrate 也有正确 env)。
refreshEnv();
// 有引擎:EmitterConfig → EngineEmitterSpec 纯映射,调引擎能力面(引擎构造 ParticleEmitter 并自渲),登记句柄。
const handle = _engParticles.spawnEmitter(toEngineSpec(cfg, x, y));
_engHandles.set(id, handle);
return id;
},
/**
* 停止一个 continuous 发射器(不再补充新粒子;已有粒子自然消亡)。幂等(未知 id 静默)。
* 停止一个发射器(转发引擎句柄 stop→销毁引擎发射器)。幂等(未知 id / 已停均静默)。
* @param {number} id 发射器 id。
*/
stopEmitter(id) {
const em = emitters.get(id);
if (em) em.alive = false;
const h = _engHandles.get(id);
if (h) { try { h.stop(); } catch { /* 句柄 stop 幂等容错 */ } }
},
/** 手动推进一帧(autoStep:false 时由游戏层调)。@param {number} dt 帧时间步(秒)。 */
/** 手动推进一帧(autoStep:false 时由游戏层调)——A2 后只推 juice。@param {number} dt 帧时间步(秒)。 */
step(dt) {
step(dt);
},
/** 渲染当前帧(粒子 + 闪白叠加)到受控 2D 上下文。@param {CanvasRenderingContext2D|null} [ctx2d] 可传伪桩。 */
/** 渲染当前帧(仅 juice 闪白叠加;粒子归引擎自渲)到受控 2D 上下文。@param {CanvasRenderingContext2D|null} [ctx2d] 可传伪桩。 */
render(ctx2d) {
render(ctx2d);
},
/** 当前活动粒子数(守恒断言用)。 */
/**
* 当前活动粒子数。**A2 后恒返 0**:插件无本地粒子池(粒子真身在引擎对象列表,引擎自管计数)。
* 行为变更(边界模型「本地池已废」语义);唯一消费点=测试与 host 取证 probe,无游戏层生产逻辑依赖(已核全仓)。
*/
particleCount() {
return particles.length;
return 0;
},
/** 当前活动发射器数。 */
/** 当前活动发射器数(按句柄活性过滤:isActive() 为 true 的句柄数,反映真实活动发射器)。 */
emitterCount() {
return emitters.size;
let n = 0;
for (const h of _engHandles.values()) { if (h.isActive()) n += 1; }
return n;
},
/* ── juice 套件能力面 ───────────────────────────────────────────────── */
@ -662,12 +584,12 @@ export function createParticlesJuicePlugin(opts) {
};
},
/** @returns {EmitterProbe} 粒子系统计数快照(测试断言用)。 */
/** @returns {EmitterProbe} 粒子系统计数快照(测试断言用)。A2 后 particles/dropped 恒 0(本地池废),emitters=活动句柄数。 */
probe() {
return {
particles: particles.length,
emitters: emitters.size,
dropped,
particles: 0, // A2:无本地粒子池(粒子归引擎),恒 0
emitters: _engHandles.size, // 登记的发射器句柄数(含已停未清者;活动数见 emitterCount)
dropped: 0, // A2:背压随本地池退役,恒 0
steps,
disposed,
};

View File

@ -2,21 +2,28 @@
* particles-juice/test/particles-juice.test.mjs — P3 零依赖单测(node --test 直跑)
* owner:T1b-α lane-vfx | 运行:node --test src/plugins/particles-juice/test/particles-juice.test.mjs
*
* 【两层测试纪律(执行版 §3 lane-vfx 行)】
* node 层只测「确定性状态与参数」——粒子数守恒/生命周期/曲线求值/hit-stop 时序/屏震收敛/预设 schema。
* canvas 渲染像素哈希 = 集成段浏览器证据,本文件**不引 node-canvas**(明令禁止);
* render 调用面经「伪 2D 上下文桩」(记录调用序列)验证 draw 次数/顺序,像素级留集成段。
* 【两层测试纪律(A2 边界模型后)】
* node 层测「确定性状态与参数」:
* · toEngineSpec 纯映射(EmitterConfig→EngineEmitterSpec 逐字段,无引擎可单测);
* · mock 引擎注入(断 spawnEmitter 真调引擎能力面 + 入参逐字段 + stopEmitter 转发);
* · null-engine no-op(缺省 host-dev 无引擎 → 返句柄不抛、particleCount 恒 0、render 不画粒子);
* · 颜色可见性(映射后至少一端 a>0,保 real 像素门「有色像素命中>0」前提);
* · juice 确定性(hit-stop 时序/屏震收敛/闪白/脉冲)+ 曲线求值 + 预设 schema(补层全留)。
* **node 禁 import 真引擎**(dist 加载即引用 window 抛错)→ 引擎包装验证一律用 mock;
* 真渲出现(粒子真画出来)= 集成段 mini-desktop real 像素门,本文件不做。
*
* 覆盖:
* 1. 曲线求值:端点恒等(constant/linear/easeInOut/decay)+ 衰减/线性段单调 + t 越界钳制。
* 2. 粒子数守恒与生命周期:burst 立即喷发 count 颗;step 后无粒子越界存活;全部 decay 后归零。
* 3. 确定性:同 seed 两实例,逐帧 step 后粒子状态**逐字段**复现;异 seed 大概率不同。
* 4. hit-stop timescale 时序:触发即谷底,受控 dt 推进后线性恢复,结束恒 1。
* 5. 屏震衰减:振幅随时间衰减,结束偏移收敛至 {0,0}。
* 6. 闪白 / 弹性脉冲:0 时长=恒等/立即归位;正常衰减收敛。
* 7. 预设参数包 schema 自洽:三预设字段齐全、曲线合法、命名中性(无主题词)。
* 8. render 调用面(伪 2D 桩):粒子数 → arc/fill 调用次数匹配;无 canvas 安全降级。
* 9. 生命周期:register/initAll/disposeAll 全流程接纳;dispose 幂等 + 清空。
* 2. toEngineSpec 纯映射:burst/continuous 模式 → emitTime/emitRate/count;区间取中值;曲线端点;颜色 a。
* 3. mock 引擎注入:spawnEmitter 真调 engineCaps.particles.spawnEmitter(入参逐字段)+ stopEmitter 转发 handle.stop。
* 4. null-engine no-op:缺省无引擎 → spawnEmitter 返有效 id 不抛、particleCount 恒 0、render 不画粒子。
* 5. 颜色可见性:三预设映射后颜色端点至少一端 a>0(防全程全透明)。
* 6. hit-stop timescale 时序:触发即谷底,受控 dt 推进后线性恢复,结束恒 1。
* 7. 屏震衰减:振幅随时间衰减,结束偏移收敛至 {0,0}。
* 8. 闪白 / 弹性脉冲:0 时长=恒等/立即归位;正常衰减收敛。
* 9. 预设参数包 schema 自洽:三预设字段齐全、曲线合法、命名中性(无主题词)。
* 10. render 调用面(伪 2D 桩):juice 闪白期一次全屏 fillRect;无 canvas 安全降级;粒子段不再产生 arc。
* 11. 生命周期:register/initAll/disposeAll 全流程接纳;dispose 幂等 + 句柄清空。
*/
import { test } from 'node:test';
@ -35,7 +42,7 @@ const mockAudioFactory = () => /** @type {any} */ ({ __ac: true });
/**
* 伪 2D 上下文桩:记录调用序列(不实际绘制、不引 node-canvas)。
* 用于验证 render 的「调用面」——draw 调用次数/顺序,像素级正确性留集成段浏览器证据。
* 用于验证 render 的「调用面」——A2 后 render 只画 juice 闪白叠加层(fillRect),不再画粒子(无 arc/fill)。
* @param {number} [w] 模拟 canvas 宽(供全屏矩形取值)。
* @param {number} [h] 模拟 canvas 高。
*/
@ -54,6 +61,28 @@ function makeFake2dStub(w, h) {
return stub;
}
/**
* 构造一个「注入 mock 引擎」的受控上下文(走 createHostDevContext 的 engineFactory 真路径,与集成段 host 同形)。
* mock 引擎只暴 particles.spawnEmitter(spy);其余受控面同 host-dev 桩。
* @param {(spec:any)=>any} spawnSpy 收到 EngineEmitterSpec 的探针(返回一个 mock 句柄)。
* @param {number} [seed]
* @returns {{context: import('../../../core/api.d.ts').PluginContext}}
*/
function makeMockEngineContext(spawnSpy, seed) {
const bundle = createHostDevContext({
context2d: null,
seed: seed == null ? 1 : seed,
audioFactory: mockAudioFactory,
// engineFactory:返回带 spy 的引擎能力面(math/audio 占位 null,本测只验 particles)。
engineFactory: () => /** @type {any} */ ({
particles: { spawnEmitter: spawnSpy },
audio: { synth: { synthSfx: () => null, synthSong: () => null } },
math: { lerp: (a, b, p) => a + (b - a) * p, smoothStep: (p) => p },
}),
});
return { context: bundle.context };
}
/* ── 1. 曲线求值:端点恒等 + 单调段 + 钳制 ──────────────────────────────────── */
test('曲线求值:四类曲线端点恒等、衰减/线性单调、t 越界钳制', () => {
@ -95,90 +124,137 @@ test('曲线求值:四类曲线端点恒等、衰减/线性单调、t 越界
assert.equal(evalCurve({ kind: 'linear', from: 2, to: 10 }, 99), 10, 't>1 钳制到 1');
});
/* ── 2. 粒子数守恒与生命周期 ──────────────────────────────────────────────── */
/* ── 2. toEngineSpec 纯映射(逐字段;替代旧「同 seed 逐字段复现」的确定性价值)──────────
* toEngineSpec 是模块私有,不导出 → 经 mock 引擎 spy 捕获插件交给引擎能力面的 spec,
* 再用「从预设配置 + evalCurve 独立算出的期望值」逐字段断言(不反查内部实现,钉契约不钉实现)。
* ──────────────────────────────────────────────────────────────────────────── */
test('粒子数守恒:burst 立即喷发 count 颗,全部 decay 后归零', () => {
const { context, tick } = createHostDevContext({ context2d: null, seed: 1, audioFactory: mockAudioFactory });
test('toEngineSpec 纯映射(经 spy 捕获):burst 预设逐字段换算正确', () => {
let captured = null;
const { context } = makeMockEngineContext((spec) => { captured = spec; return { isActive: () => true, stop() {} }; });
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
assert.equal(p.particleCount(), 0, '初始无粒子');
// burst 预设 count=24,立即喷发。
p.spawnEmitter('burst', 100, 100);
assert.equal(p.particleCount(), 24, 'burst 后应有 24 颗(=预设 count)');
// burst 发射器一次性,step 后即被移除。
p.step(0.016);
assert.equal(p.emitterCount(), 0, 'burst 发射器 step 后应被移除');
p.spawnEmitter('burst', 100, 200);
assert.ok(captured, 'spawnEmitter 应把 spec 交给引擎能力面');
// 推进足够长(burst 生命上界 0.8s,跑 2s 必全死)。
let guard = 0;
while (p.particleCount() > 0 && guard < 1000) {
p.step(0.05);
guard++;
}
assert.equal(p.particleCount(), 0, '全部粒子 decay 后应归零(生命周期闭环)');
void tick;
const cfg = getPreset('burst'); // 期望值的独立来源(与插件内深拷贝同源)。
// 位置 / 锥角 / 模式映射。
assert.deepEqual(captured.pos, { x: 100, y: 200 }, 'pos 透传发射原点像素坐标');
assert.equal(captured.angle, cfg.angle, 'angle 透传');
assert.equal(captured.coneAngle, cfg.spread, 'coneAngle === spread(半锥角)');
assert.equal(captured.emitSize, 0, '现 EmitterConfig 无 emitSize → 点发射 0');
// burst:emitTime=1/60 + count + emitRate=undefined(I1)。
assert.equal(captured.emitTime, 1 / 60, 'burst → emitTime=1/60(1 逻辑帧窗)');
assert.equal(captured.count, cfg.count, 'burst → count=预设 count');
assert.equal(captured.emitRate, undefined, 'burst → emitRate=undefined(由 count 算)');
// 区间取中值。
assert.equal(captured.particleTime, (cfg.lifeMin + cfg.lifeMax) / 2, 'particleTime=生命区间中值');
assert.equal(captured.speed, (cfg.speedMin + cfg.speedMax) / 2, 'speed=初速区间中值(像素/秒)');
// 重力透传 + drag→damping 换算。
assert.equal(captured.gravityScale, cfg.gravity || 0, 'gravityScale 透传');
assert.equal(captured.damping, cfg.drag != null ? (1 - cfg.drag / 60) : 1, 'damping=1-drag/60');
// 尺寸/透明度曲线端点(用 evalCurve 独立算)。
assert.equal(captured.sizeStart, evalCurve(cfg.sizeCurve, 0), 'sizeStart=sizeCurve 端点 t=0');
assert.equal(captured.sizeEnd, evalCurve(cfg.sizeCurve, 1), 'sizeEnd=sizeCurve 端点 t=1');
assert.equal(captured.colorStart.a, evalCurve(cfg.alphaCurve, 0), 'colorStart.a=alphaCurve 端点 t=0');
assert.equal(captured.colorEnd.a, evalCurve(cfg.alphaCurve, 1), 'colorEnd.a=alphaCurve 端点 t=1');
// 颜色 rgb 中性白(透明度并入 a);additive 默认 false。
assert.deepEqual(
{ r: captured.colorStart.r, g: captured.colorStart.g, b: captured.colorStart.b },
{ r: 1, g: 1, b: 1 }, 'colorStart rgb=中性白');
assert.equal(captured.additive, false, 'additive 默认 false');
});
test('生命周期边界:粒子 age 跨过 life 当帧被回收(不残留越界存活)', () => {
const { context } = createHostDevContext({ context2d: null, seed: 2, audioFactory: mockAudioFactory });
test('toEngineSpec 纯映射(经 spy 捕获):continuous 预设 → emitTime=0 + emitRate + count=undefined', () => {
let captured = null;
const { context } = makeMockEngineContext((spec) => { captured = spec; return { isActive: () => true, stop() {} }; });
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
// 用 continuous 发一帧补点,然后大 dt 跨过所有生命,断言全清。
p.spawnEmitter('drift', 50, 0); // drift 生命上界 3s。
p.step(0.1); // 发若干 drift 粒子。
const n = p.particleCount();
assert.ok(n > 0, 'continuous 应发出粒子');
p.stopEmitter(0); // 停止补充。
p.step(10); // 一帧跨过所有生命(>3s)。
assert.equal(p.particleCount(), 0, '单帧跨过 life 的粒子应全部被回收');
p.spawnEmitter('trail', 0, 0); // continuous(rate=60)。
const cfg = getPreset('trail');
assert.equal(captured.emitTime, 0, 'continuous → emitTime=0(forever)');
assert.equal(captured.emitRate, cfg.rate, 'continuous → emitRate=预设 rate');
assert.equal(captured.count, undefined, 'continuous → count=undefined');
assert.equal(captured.coneAngle, cfg.spread, 'coneAngle===spread');
assert.equal(captured.damping, 1 - cfg.drag / 60, 'damping=1-drag/60(trail drag=1.5)');
});
/* ── 3. 确定性:同 seed 逐字段复现 ────────────────────────────────────────── */
/* ── 3. mock 引擎注入:spawnEmitter 真调引擎 + stopEmitter 转发句柄 ──────────────── */
test('确定性:同 seed 两实例逐帧 step 后粒子状态逐字段复现;异 seed 不同', () => {
function run(seed) {
const { context } = createHostDevContext({ context2d: null, seed, audioFactory: mockAudioFactory });
const p = createParticlesJuicePlugin({ autoStep: false, seed });
test('mock 引擎注入:spawnEmitter 真调引擎能力面一次;stopEmitter 转发 handle.stop;返回有效 id', () => {
let calls = 0;
let stopCalls = 0;
const { context } = makeMockEngineContext((_spec) => {
calls += 1;
return { isActive: () => true, stop() { stopCalls += 1; } };
});
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
const id = p.spawnEmitter('burst', 50, 50);
assert.equal(calls, 1, 'spawnEmitter 应调引擎能力面恰 1 次');
assert.ok(typeof id === 'number' && id >= 0, '返回有效发射器 id');
// emitterCount 反映活动句柄(mock isActive=true)。
assert.equal(p.emitterCount(), 1, '一个活动发射器句柄');
p.stopEmitter(id);
assert.equal(stopCalls, 1, 'stopEmitter 应转发到引擎句柄 stop 恰 1 次');
// 未知 id stopEmitter 静默不抛(幂等)。
assert.doesNotThrow(() => p.stopEmitter(99999), '未知 id stopEmitter 不抛');
// 未知预设名返回 -1,不调引擎。
const before = calls;
assert.equal(p.spawnEmitter('not-a-preset', 0, 0), -1, '未知预设名返回 -1');
assert.equal(calls, before, '未知预设名不调引擎');
});
/* ── 4. null-engine no-op(缺省 host-dev 无 engineFactory → getEngine()=null)────── */
test('null-engine no-op:无引擎时 spawnEmitter 返有效 id 不抛、particleCount 恒 0、render 不画粒子', () => {
// 缺省 createHostDevContext 不传 engineFactory → getEngine() 返 null(null-engine 战场)。
const { context } = createHostDevContext({ context2d: null, seed: 5, audioFactory: mockAudioFactory });
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
assert.equal(p.particleCount(), 0, '初始 particleCount=0');
const id = p.spawnEmitter('burst', 100, 100);
assert.ok(typeof id === 'number' && id >= 0, 'null-engine 下 spawnEmitter 仍返有效 id(no-op 句柄登记)');
assert.equal(p.particleCount(), 0, 'null-engine:particleCount 恒 0(不喷不渲,禁回退 sim)');
assert.equal(p.emitterCount(), 0, 'null-engine:no-op 句柄 isActive=false → emitterCount=0');
assert.doesNotThrow(() => p.stopEmitter(id), 'null-engine stopEmitter 不抛');
// render:未触发闪白 → 无任何绘制调用(无粒子 arc,无闪白 fillRect)。
const stub = makeFake2dStub(390, 844);
p.render(stub);
assert.equal(stub._calls.filter((c) => c.startsWith('arc:')).length, 0, 'null-engine render 无粒子 arc(粒子归引擎)');
assert.equal(stub._calls.filter((c) => c.startsWith('fillRect:')).length, 0, '未闪白无 fillRect');
assert.doesNotThrow(() => p.dispose(), 'null-engine dispose 不抛');
});
/* ── 5. 颜色可见性(保 real 像素门「有色像素命中>0」前提,I2)────────────────────── */
test('颜色可见性:三预设映射后颜色端点至少一端 a>0(防全程全透明)', () => {
const names = /** @type {const} */ (['burst', 'trail', 'drift']);
for (const name of names) {
let captured = null;
const { context } = makeMockEngineContext((spec) => { captured = spec; return { isActive: () => true, stop() {} }; });
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
p.spawnEmitter('burst', 0, 0);
for (let i = 0; i < 5; i++) p.step(0.016);
// 导出全部存活粒子的逐字段快照(排序无关:burst 同序生成,索引天然对齐)。
return JSON.stringify(snapshotParticles(p));
p.spawnEmitter(name, 0, 0);
assert.ok(captured, `${name} 应交 spec 给引擎`);
const aStart = captured.colorStart.a;
const aEnd = captured.colorEnd.a;
assert.ok(aStart > 0 || aEnd > 0, `${name}:颜色端点至少一端 a>0(起点可见,黑底不至全透明)`);
// 起点透明度由 alphaCurve(t=0) 给,三预设起点 a 均 >0(burst 1 / trail 0.8 / drift 0.7)。
assert.ok(aStart > 0, `${name}:起点 a>0(发射瞬间可见)`);
}
const a = run(12345);
const b = run(12345);
assert.equal(a, b, '同 seed 应逐字段复现粒子状态');
const c = run(99);
assert.notEqual(a, c, '异 seed 粒子状态应不同');
});
/**
* 用 render 伪桩间接抓粒子逐字段状态:本插件不暴露粒子数组,故用桩记录 arc 调用,
* arc 参数 = (x,y,size),配合 globalAlpha 即逐字段。这里用一个会记录 alpha 的桩。
* @param {any} plugin
*/
function snapshotParticles(plugin) {
const recs = [];
const stub = {
canvas: { width: 100, height: 100 },
globalAlpha: 1,
fillStyle: '#000',
beginPath() {},
arc(x, y, r) {
// 记录绘制时的 x/y/size/alpha(alpha 在 arc 前被设到 this.globalAlpha)。
recs.push({ x: round6(x), y: round6(y), size: round6(r), alpha: round6(stub.globalAlpha) });
},
fill() {},
fillRect() {},
};
plugin.render(stub);
return recs;
}
function round6(n) { return Math.round(n * 1e6) / 1e6; }
/* ── 4. hit-stop timescale 时序 ───────────────────────────────────────────── */
/* ── 6. hit-stop timescale 时序(juice 补层,全留)────────────────────────────── */
test('hit-stop:触发即谷底,受控 dt 推进后线性恢复,结束恒 1', () => {
const { context } = createHostDevContext({ context2d: null, seed: 3, audioFactory: mockAudioFactory });
@ -208,7 +284,7 @@ test('hit-stop:0 时长不卡死(timeScale 立即恒 1)', () => {
assert.equal(p.getTimeScale(), 1, '0 时长 hit-stop 不应改变 timeScale');
});
/* ── 5. 屏震衰减收敛 ──────────────────────────────────────────────────────── */
/* ── 7. 屏震衰减收敛(juice 补层,全留)──────────────────────────────────────── */
test('屏震:振幅随时间衰减,结束偏移收敛至 {0,0}', () => {
const { context } = createHostDevContext({ context2d: null, seed: 4, audioFactory: mockAudioFactory });
@ -239,7 +315,7 @@ test('屏震:振幅随时间衰减,结束偏移收敛至 {0,0}', () => {
assert.deepEqual(off, { x: 0, y: 0 }, '屏震结束后偏移收敛至 {0,0}');
});
/* ── 6. 闪白 / 弹性脉冲 ───────────────────────────────────────────────────── */
/* ── 8. 闪白 / 弹性脉冲(juice 补层,全留)────────────────────────────────────── */
test('闪白:0 时长=恒等(alpha 0);正常触发后衰减归零', () => {
const { context } = createHostDevContext({ context2d: null, audioFactory: mockAudioFactory });
@ -275,7 +351,7 @@ test('弹性脉冲:触发后绕 1.0 振荡,结束收敛回 1', () => {
assert.equal(p.getPulseScale(), 1, '结束后 scale 收敛回 1');
});
/* ── 7. 预设参数包 schema 自洽 + 命名中性 ─────────────────────────────────── */
/* ── 9. 预设参数包 schema 自洽 + 命名中性(补层全留)──────────────────────────── */
test('预设参数包 schema 自洽:三预设字段齐全、曲线合法、命名中性', () => {
const names = ['burst', 'trail', 'drift'];
@ -312,31 +388,32 @@ test('getPreset 深拷贝:改返回值不污染共享冻结模板', () => {
assert.equal(getPreset('unknown-preset'), null, '未知预设名返回 null');
});
/* ── 8. render 调用面(伪 2D 桩)──────────────────────────────────────────── */
/* ── 10. render 调用面(伪 2D 桩):A2 后只画 juice 闪白叠加(无粒子 arc)──────────── */
test('render 调用面:粒子数 → arc/fill 调用次数匹配;闪白叠加触发 fillRect', () => {
const { context } = createHostDevContext({ context2d: null, seed: 5, audioFactory: mockAudioFactory });
test('render 调用面:A2 后只画 juice 闪白叠加层(一次全屏 fillRect);粒子段不再产生 arc', () => {
// 用 mock 引擎(粒子归引擎自渲,插件 render 不画粒子);spawn 一发 burst 不应产生任何 arc。
const { context } = makeMockEngineContext((_spec) => ({ isActive: () => true, stop() {} }), 5);
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
p.spawnEmitter('burst', 50, 50); // 24 颗。
p.spawnEmitter('burst', 50, 50);
const stub = makeFake2dStub(390, 844);
p.render(stub);
// 每颗粒子一次 beginPath + arc + fill。
const arcCount = stub._calls.filter((c) => c.startsWith('arc:')).length;
const fillCount = stub._calls.filter((c) => c === 'fill').length;
assert.equal(arcCount, 24, 'arc 调用次数应等于粒子数');
assert.equal(fillCount, 24, 'fill 调用次数应等于粒子数');
// A2:插件不再逐颗画粒子(引擎自渲)→ 零 arc / 零 fill。
assert.equal(stub._calls.filter((c) => c.startsWith('arc:')).length, 0, 'A2 后 render 无粒子 arc(引擎自渲粒子)');
assert.equal(stub._calls.filter((c) => c === 'fill').length, 0, 'A2 后 render 无粒子 fill');
// 未触发闪白:无 fillRect。
assert.equal(stub._calls.filter((c) => c.startsWith('fillRect:')).length, 0, '未闪白不应 fillRect');
// 触发闪白后再 render:应出现一次全屏 fillRect。
// 触发闪白后再 render:应出现一次全屏 fillRect(juice 叠加层,保留的正面锚)。
p.flashScreen(0.2, 0.8);
const stub2 = makeFake2dStub(390, 844);
p.render(stub2);
const rects = stub2._calls.filter((c) => c.startsWith('fillRect:'));
assert.equal(rects.length, 1, '闪白期应有一次全屏 fillRect');
assert.equal(rects.length, 1, '闪白期应有一次全屏 fillRect(juice 叠加层)');
assert.equal(rects[0], 'fillRect:0,0,390,844', '全屏矩形应覆盖整 canvas');
// 闪白叠加层仍无粒子 arc。
assert.equal(stub2._calls.filter((c) => c.startsWith('arc:')).length, 0, '闪白叠加层不画粒子');
});
test('render 无 canvas 安全降级:getContext2d 返回 null 时不抛错', () => {
@ -344,43 +421,46 @@ test('render 无 canvas 安全降级:getContext2d 返回 null 时不抛错', (
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
p.spawnEmitter('burst', 0, 0);
// 不传桩 → 走 context.getContext2d()(node 侧 null)→ 应安全降级(只更新不绘制)。
// 不传桩 → 走 context.getContext2d()(node 侧 null)→ 应安全降级(不绘制)。
assert.doesNotThrow(() => p.render(), 'null 渲染环境 render 不应抛错');
});
/* ── 9. 生命周期:全流程接纳 + dispose 幂等 ───────────────────────────────── */
/* ── 11. 生命周期:全流程接纳 + dispose 幂等(句柄清空)────────────────────────── */
test('全流程接纳:register/initAll/disposeAll 无错;autoStep 经 tick 驱动', () => {
test('全流程接纳:register/initAll/disposeAll 无错;autoStep 经 tick 驱动 juice', () => {
const reg = new PluginRegistry();
const { context, tick } = createHostDevContext({ context2d: null, seed: 6, audioFactory: mockAudioFactory });
reg.useContext(context);
reg.register(createParticlesJuicePlugin()); // autoStep 默认 true。
reg.register(createParticlesJuicePlugin()); // autoStep 默认 true(null-engine 下粒子 no-op,juice 仍推进)。
const r = reg.initAll();
assert.equal(r.ok, true, 'init 应无错误');
assert.deepEqual(r.initialized, ['particles-juice']);
const plugin = reg.get('particles-juice');
plugin.spawnEmitter('trail', 0, 0); // continuous。
tick(0.05); // autoStep:经 tick 自动 step → 应发出粒子。
assert.ok(plugin.probe().steps >= 1, 'autoStep 应被 tick 驱动 step');
plugin.spawnEmitter('trail', 0, 0); // null-engine → no-op 句柄登记。
tick(0.05); // autoStep:经 tick 自动 step → 推进 steps(juice)。
assert.ok(plugin.probe().steps >= 1, 'autoStep 应被 tick 驱动 step(juice 推进)');
const d = reg.disposeAll();
assert.deepEqual(d.disposed, ['particles-juice']);
assert.equal(d.errors.length, 0);
});
test('dispose 幂等 + 清空:dispose 后粒子/发射器归零,再调安全', () => {
const { context } = createHostDevContext({ context2d: null, audioFactory: mockAudioFactory });
test('dispose 幂等 + 句柄清空:dispose 后 particleCount/emitterCount 归零,再调安全', () => {
// mock 引擎:spawn 登记真句柄,dispose 应转发 stop 并清表。
let stopCalls = 0;
const { context } = makeMockEngineContext((_spec) => ({ isActive: () => true, stop() { stopCalls += 1; } }), 7);
const p = createParticlesJuicePlugin({ autoStep: false });
p.init(context);
p.spawnEmitter('drift', 0, 0);
p.step(0.1);
assert.ok(p.particleCount() > 0);
assert.equal(p.emitterCount(), 1, 'spawn 后有一个活动发射器句柄');
assert.equal(p.particleCount(), 0, 'A2:particleCount 恒 0(粒子归引擎)');
p.dispose();
assert.equal(p.particleCount(), 0, 'dispose 后粒子清空');
assert.equal(p.emitterCount(), 0, 'dispose 后发射器清空');
assert.equal(stopCalls, 1, 'dispose 应转发引擎句柄 stop');
assert.equal(p.particleCount(), 0, 'dispose 后 particleCount=0');
assert.equal(p.emitterCount(), 0, 'dispose 后发射器句柄清空(emitterCount=0)');
assert.equal(p.probe().disposed, true);
assert.doesNotThrow(() => { p.dispose(); p.dispose(); }, '重复 dispose 安全(幂等)');
});

View File

@ -13,6 +13,13 @@
* moveWithConstraint 的碰撞回退是一个注入回调 ResolveMove;本文件**绝不 import collision 插件**,
* 也不内置任何碰撞检测——「怎么测、怎么回退」全由调用方决定。physics-lite 与 collision 零编译依赖。
*
* 【引擎↔插件 边界裁定(2026-06-13 创始人拍·政策 §2.3·真缺件补层)】
* physics-lite(抛体弹道积分 / 弹簧-阻尼 / 运动学约束移动 / 速度限幅摩擦)= **补层**——引擎虽有完整刚体
* 物理(EngineObject 质量/速度/碰撞响应),但**那是「对象级全刚体物理仿真」**(已源码复审证实:EngineObject
* 是全刚体、非离散运动小工具);本插件要的是「**街机手感的离散运动数学小工具**(无对象、纯函数/小状态、
* 调用方自控积分)」,引擎**无此受控面能力** → 自研合法保留。文件内「线性插值口径」指欧拉残余部分步
* (semiImplicitStep 残余 dt),**非引擎 lerp 用点**(不可误接 math 门面)。**不得误判为待包装。**
*
* 【受控面纪律】
* 运动数学为纯函数/有状态小对象,**不触受控面**;插件对象仅为满足协议可注册。
*