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:
parent
471e542473
commit
e591e9b705
311
docs/agent-specs/2026-06-13-A1-A6-接线政策.md
Normal file
311
docs/agent-specs/2026-06-13-A1-A6-接线政策.md
Normal 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 阻断合并。
|
||||
357
docs/agent-specs/2026-06-13-A1-edit-plan.md
Normal file
357
docs/agent-specs/2026-06-13-A1-edit-plan.md
Normal 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 驱动(本工作流不做)。
|
||||
393
docs/agent-specs/2026-06-13-A2-edit-plan.md
Normal file
393
docs/agent-specs/2026-06-13-A2-edit-plan.md
Normal 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 文件级可并行。
|
||||
207
docs/agent-specs/2026-06-13-A3-edit-plan.md
Normal file
207
docs/agent-specs/2026-06-13-A3-edit-plan.md
Normal 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 驱动。
|
||||
196
docs/agent-specs/2026-06-13-A4-edit-plan.md
Normal file
196
docs/agent-specs/2026-06-13-A4-edit-plan.md
Normal 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)。
|
||||
@ -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)。
|
||||
|
||||
|
||||
87
game-runtime/host-dev/engine-math.js
Normal file
87
game-runtime/host-dev/engine-math.js
Normal 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 对其恒走内置。
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
|
||||
222
game-runtime/host-dev/test/engine-caps.test.mjs
Normal file
222
game-runtime/host-dev/test/engine-caps.test.mjs
Normal 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);
|
||||
});
|
||||
91
game-runtime/src/core/api.d.ts
vendored
91
game-runtime/src/core/api.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 声明
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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。无玩法语义。"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 不置位(无样本未真播)');
|
||||
});
|
||||
|
||||
245
game-runtime/src/plugins/audio-music/vendor/zzfx.js
vendored
245
game-runtime/src/plugins/audio-music/vendor/zzfx.js
vendored
@ -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);
|
||||
}
|
||||
146
game-runtime/src/plugins/audio-music/vendor/zzfxm.js
vendored
146
game-runtime/src/plugins/audio-music/vendor/zzfxm.js
vendored
@ -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];
|
||||
}
|
||||
@ -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。
|
||||
|
||||
25
game-runtime/src/plugins/gamefeel/api.d.ts
vendored
25
game-runtime/src/plugins/gamefeel/api.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 + 受控时钟
|
||||
* ========================================================================== */
|
||||
|
||||
@ -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;后处理仅依赖入参强度——同输入同输出(取证可复现)。
|
||||
|
||||
@ -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 类型)。
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 安全(幂等)');
|
||||
});
|
||||
|
||||
@ -13,6 +13,13 @@
|
||||
* moveWithConstraint 的碰撞回退是一个注入回调 ResolveMove;本文件**绝不 import collision 插件**,
|
||||
* 也不内置任何碰撞检测——「怎么测、怎么回退」全由调用方决定。physics-lite 与 collision 零编译依赖。
|
||||
*
|
||||
* 【引擎↔插件 边界裁定(2026-06-13 创始人拍·政策 §2.3·真缺件补层)】
|
||||
* physics-lite(抛体弹道积分 / 弹簧-阻尼 / 运动学约束移动 / 速度限幅摩擦)= **补层**——引擎虽有完整刚体
|
||||
* 物理(EngineObject 质量/速度/碰撞响应),但**那是「对象级全刚体物理仿真」**(已源码复审证实:EngineObject
|
||||
* 是全刚体、非离散运动小工具);本插件要的是「**街机手感的离散运动数学小工具**(无对象、纯函数/小状态、
|
||||
* 调用方自控积分)」,引擎**无此受控面能力** → 自研合法保留。文件内「线性插值口径」指欧拉残余部分步
|
||||
* (semiImplicitStep 残余 dt),**非引擎 lerp 用点**(不可误接 math 门面)。**不得误判为待包装。**
|
||||
*
|
||||
* 【受控面纪律】
|
||||
* 运动数学为纯函数/有状态小对象,**不触受控面**;插件对象仅为满足协议可注册。
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user