From 7cac2cb1b606b0e8dd08354a6df3712dfffaa7ba Mon Sep 17 00:00:00 2001 From: lili Date: Mon, 6 Jul 2026 19:20:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(polaris):=20=E3=80=8A=E5=B1=B1=E6=B5=B7?= =?UTF-8?q?=E8=A1=8C=E7=BA=AA=E3=80=8BM2=E2=80=94=E2=80=94build=20?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=E5=85=A8=E8=A1=A8=E5=8C=96+=E4=BB=BF?= =?UTF-8?q?=E7=9C=9F=E5=8F=82=E6=95=B0=E6=89=AB=E6=8F=8F=E9=A6=96=E8=B7=91?= =?UTF-8?q?,=E5=9B=9B=E5=88=A4=E6=8D=AE=201080=20=E5=B1=80=E5=85=A8?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 武器 12×8 级(五族)/被动 14×5 级/进化 12/难度 D0–D5/暴击轴/聚灵珠全入 balance 表零魔法数; build-model.js=派生数值与理论 DPS 唯一解释器(sim/单测/扫描器同源);sim 多武器核(proj/zone 双形态、 五族索敌、链跳/爆裂/减速/灼烧,AoE 收集-降序结算防下标漂移);出击页滚动列表+难度选择、暂停 build 总览; canvas-ui-kit v1.0→1.1 additive(createScrollList 滚动数学+拖拽单击消歧,零绘制)。 门证据:build 四判据 1080 局全绿(12 武器定向胜率全≥40%/最强 85%≤85%/随机中位 55% 带内/被动相关 |r|max=0.25;②③带沿如实注记,确定性种子主会话复跑逐值一致);扫描墙钟实测回填书 §8.3(单局 mean 1.88s、 6 worker 180 局/分,原「<1s」外推偏乐观一个量级,夜批结论不变);UI 三项 headless 断言全真→触发器② 证据面建议不触发 Phaser 复评(CDP 补证待窗口,终判在主会话);游戏 27/27+三场景+压测 p95 1.43ms+ 整库 269/269+manifest 19/19,M0/M1 零倒退;主会话真跑复验。 Co-Authored-By: Claude Fable 5 --- ...-07-06-北极星顶级线-肉鸽割草-开发设计书.md | 2 +- game-runtime/SIZES.md | 34 +- game-runtime/games/shanhai-xingji/README.md | 34 +- .../shanhai-xingji/scripts/build-report.mjs | 4 +- .../shanhai-xingji/scripts/headless-smoke.mjs | 32 +- .../shanhai-xingji/scripts/lib/p2-driver.mjs | 69 +++ .../games/shanhai-xingji/scripts/sim-scan.mjs | 320 ++++++++++ .../games/shanhai-xingji/src/balance.js | 302 +++++++-- .../games/shanhai-xingji/src/build-model.js | 130 ++++ .../games/shanhai-xingji/src/game-logic.js | 159 +++-- .../games/shanhai-xingji/src/render.js | 359 ++++++++++- game-runtime/games/shanhai-xingji/src/sim.js | 572 ++++++++++++++---- .../shanhai-xingji/test/build-tables.test.mjs | 216 +++++++ .../test/feel-assertions.test.mjs | 35 +- .../test/headless-boot.test.mjs | 19 +- .../games/shanhai-xingji/test/sim.test.mjs | 11 +- .../shanhai-xingji/test/ui-trigger2.test.mjs | 201 ++++++ .../src/plugins/canvas-ui-kit/PLUGIN.md | 9 +- .../src/plugins/canvas-ui-kit/api.d.ts | 46 ++ .../src/plugins/canvas-ui-kit/impl.js | 89 ++- .../src/plugins/canvas-ui-kit/manifest.json | 11 +- .../canvas-ui-kit/test/canvas-ui-kit.test.mjs | 54 +- 22 files changed, 2395 insertions(+), 313 deletions(-) create mode 100644 game-runtime/games/shanhai-xingji/scripts/lib/p2-driver.mjs create mode 100644 game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs create mode 100644 game-runtime/games/shanhai-xingji/src/build-model.js create mode 100644 game-runtime/games/shanhai-xingji/test/build-tables.test.mjs create mode 100644 game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs diff --git a/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md b/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md index 2621a9ce..10e9e7a4 100644 --- a/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md +++ b/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md @@ -182,7 +182,7 @@ clean-room 红线沿靶档双代理隔离方法,全程生效:对标游戏只允 ### 8.3 快进仿真器方案(消费 NSTAR §2.C 路 A,不另起炉灶) -北极星件 §2.C 已裁「先工具后席、路 A 工程离屏快进」,本款照用并从 M0 起守它的前提纪律:core 层纯逻辑、`update(dt)` 注入时间、随机经受控源、空间与移动逻辑不耦渲染(O-4 拍定的硬约束对本款同样生效——走位/碰撞/索敌全在逻辑层,渲染只画)。node 驱动 headless 单局:walker 策略档 P0(静止,验「不动必死」)/P1(绕圈风筝)/P2(贪心磁吸+避险,纪律最优);沿 NSTAR driver 档位裁定,L3 期先 P2 单档跑通、下沿体验标注未覆盖,包络后补。批量量级外推:单局 20 分钟 =72000 帧,2500 实体简化逻辑帧,估 <1s/局;千局 × 参数网格(每次调参 ~50 组合 × 20 局)夜批可跑——精确墙钟 M2 spike 实测坐实,此处为外推〔与 §2.C 同一口径:量级不构成障碍,精确值待实测〕。仿真器的批量外层(参数扫描+曲线聚合+报告)与夜市款共享同一工具单,本款交付物是「可被它驱动的 core + 本款专属 driver 策略」。 +北极星件 §2.C 已裁「先工具后席、路 A 工程离屏快进」,本款照用并从 M0 起守它的前提纪律:core 层纯逻辑、`update(dt)` 注入时间、随机经受控源、空间与移动逻辑不耦渲染(O-4 拍定的硬约束对本款同样生效——走位/碰撞/索敌全在逻辑层,渲染只画)。node 驱动 headless 单局:walker 策略档 P0(静止,验「不动必死」)/P1(绕圈风筝)/P2(贪心磁吸+避险,纪律最优);沿 NSTAR driver 档位裁定,L3 期先 P2 单档跑通、下沿体验标注未覆盖,包络后补。批量量级:单局 20 分钟 =72000 帧,2500 实体简化逻辑帧;千局 × 参数网格(每次调参 ~50 组合 × 20 局)夜批可跑。〔M2 实测回填(2026-07-06,scripts/sim-scan.mjs 门档 1080 局,开发机 darwin/arm64 · node 22 · 6 worker 并行):300s 占位局(18000 帧,D1,P2 档)单局墙钟 mean 1.88s / p95 3.29s ≈ 135µs/帧,批吞吐 180 局/分——一次调参(50 组合 × 20 局 = 1000 局)约 5.6 分钟,远优于夜批口径;按帧成本折算 20 分钟正式局(72000 帧)≈ 9.7s/局、千局 ≈ 2.7 小时,夜批口径成立。原外推「估 <1s/局」偏乐观约一个量级(实体规模+P2 driver 每帧感知成本),但「量级不构成障碍」的结论不变;帧成本随实体峰值(M3 波表/2500 实体)复测。〕仿真器的批量外层(参数扫描+曲线聚合+报告)与夜市款共享同一工具单,本款交付物是「可被它驱动的 core + 本款专属 driver 策略」。 ## 9 美术方向与资产、包体分包 diff --git a/game-runtime/SIZES.md b/game-runtime/SIZES.md index c15fac5d..c3dfbd50 100644 --- a/game-runtime/SIZES.md +++ b/game-runtime/SIZES.md @@ -11,22 +11,22 @@ | +example | 10503 | 3895 | 544 | 240 | 2048 | ✔ ≤2048 | | +audio-music | 17029 | 6134 | 6526 | 2239 | 12288 | ✔ ≤12288 | | +auto-targeting | 18225 | 6667 | 1196 | 533 | 2048 | ✔ ≤2048 | -| +canvas-ui-kit | 20928 | 7895 | 2703 | 1228 | 3072 | ✔ ≤3072 | -| +collision | 26156 | 9976 | 5228 | 2081 | 8192 | ✔ ≤8192 | -| +entity-pool | 26915 | 10240 | 759 | 264 | 1536 | ✔ ≤1536 | -| +gamefeel | 30930 | 11421 | 4015 | 1181 | 4096 | ✔ ≤4096 | -| +hit-feedback | 33002 | 12288 | 2072 | 867 | 3072 | ✔ ≤3072 | -| +hud-ui | 38294 | 14044 | 5292 | 1756 | 4096 | ✔ ≤4096 | -| +palette-post | 43201 | 15722 | 4907 | 1678 | 8192 | ✔ ≤8192 | -| +particles-juice | 47513 | 17314 | 4312 | 1592 | 10240 | ✔ ≤10240 | -| +physics-lite | 50025 | 18246 | 2512 | 932 | 6144 | ✔ ≤6144 | -| +runtime-probe | 52987 | 19553 | 2962 | 1307 | 6144 | ✔ ≤6144 | -| +save-progress | 58937 | 21518 | 5950 | 1965 | 2048 | ✔ ≤2048 | -| +scene-fsm | 60267 | 21994 | 1330 | 476 | 3072 | ✔ ≤3072 | -| +session-score | 61225 | 22301 | 958 | 307 | 2048 | ✔ ≤2048 | -| +swarm-steering | 62990 | 22979 | 1765 | 678 | 2048 | ✔ ≤2048 | -| +timer-scheduler | 64441 | 23432 | 1451 | 453 | 3072 | ✔ ≤3072 | -| +virtual-joystick | 66571 | 24306 | 2130 | 874 | 2048 | ✔ ≤2048 | +| +canvas-ui-kit | 22044 | 8387 | 3819 | 1720 | 3072 | ✔ ≤3072 | +| +collision | 27272 | 10452 | 5228 | 2065 | 8192 | ✔ ≤8192 | +| +entity-pool | 28031 | 10708 | 759 | 256 | 1536 | ✔ ≤1536 | +| +gamefeel | 32046 | 11894 | 4015 | 1186 | 4096 | ✔ ≤4096 | +| +hit-feedback | 34120 | 12771 | 2074 | 877 | 3072 | ✔ ≤3072 | +| +hud-ui | 39412 | 14537 | 5292 | 1766 | 4096 | ✔ ≤4096 | +| +palette-post | 44315 | 16200 | 4903 | 1663 | 8192 | ✔ ≤8192 | +| +particles-juice | 48631 | 17784 | 4316 | 1584 | 10240 | ✔ ≤10240 | +| +physics-lite | 51141 | 18715 | 2510 | 931 | 6144 | ✔ ≤6144 | +| +runtime-probe | 54103 | 20028 | 2962 | 1313 | 6144 | ✔ ≤6144 | +| +save-progress | 60053 | 22002 | 5950 | 1974 | 2048 | ✔ ≤2048 | +| +scene-fsm | 61383 | 22450 | 1330 | 448 | 3072 | ✔ ≤3072 | +| +session-score | 62341 | 22769 | 958 | 319 | 2048 | ✔ ≤2048 | +| +swarm-steering | 64108 | 23457 | 1767 | 688 | 2048 | ✔ ≤2048 | +| +timer-scheduler | 65559 | 23908 | 1451 | 451 | 3072 | ✔ ≤3072 | +| +virtual-joystick | 67685 | 24784 | 2126 | 876 | 2048 | ✔ ≤2048 | -合计插件 gz 增量 ≈ **20651B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。 +合计插件 gz 增量 ≈ **21129B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。 diff --git a/game-runtime/games/shanhai-xingji/README.md b/game-runtime/games/shanhai-xingji/README.md index f94ebc78..c0ba498b 100644 --- a/game-runtime/games/shanhai-xingji/README.md +++ b/game-runtime/games/shanhai-xingji/README.md @@ -1,30 +1,36 @@ # 山海行纪(北极星顶级线② · 肉鸽割草) -持符巡山使夜巡镇兽:拖动 = 浮动摇杆走位,雷符自动索敌出手,击杀掉灵晶、磁吸升级三选一,兽潮按波表爬坡。规格 SoT = [`开发设计书`](../../../docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md);当前进度 = **M1 手感+性能件**(测量面+手感断言接通+L2 插件沉淀+canvas-ui-kit 首版;M0 = 核心循环,占位胜利线 300s)。 +持符巡山使夜巡镇兽:拖动 = 浮动摇杆走位,符器自动索敌出手,击杀掉灵晶、磁吸升级三选一,兽潮按波表爬坡。规格 SoT = [`开发设计书`](../../../docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md);当前进度 = **M2 build 体系**(武器 12×8 级五族 / 被动 14×5 级 / 进化 12 / 难度 D0–D1 / 暂停 build 总览 / 出击页 / 仿真参数扫描;M0 = 核心循环,M1 = 手感+性能件;占位胜利线 300s,正式 20 分钟波表归 M3)。 ## 目录 -- `src/balance.js` — 数值表(零魔法数唯一来源,仿真器直接吃表;M1 增 FEEDBACK 手感档位表) -- `src/sim.js` — 纯逻辑核心(零 DOM/引擎,node 直跑;M1 起消费 entity-pool/swarm-steering/auto-targeting 沉淀件纯核,实体带取证 id) -- `src/game-logic.js` / `src/render.js` — 壳层(摇杆核接线/反馈泵/三选一弹层/取证)与占位美术(山海彩绘色板;飘字/震屏/粒子接 hit-feedback+juice) -- `src/host-config.js` / `src/main.js` / `src/game.js` — L1 宿主装配(17 插件,沿 `_template` 骨架;M1 六件新插件后端组合在 host-config) -- `scripts/` — headless 冒烟 / 合成压测 / 包体实测;`test/` — 纯逻辑单测 + 全链路 boot 冒烟 + 渲染崩溃回归 + 手感断言接通 +- `src/balance.js` — 数值表(零魔法数唯一来源,扫描器直接吃表;M2 全内容入表:WEAPONS/PASSIVES/DIFFICULTY/CRIT/HARVEST/OFFER 权重池) +- `src/build-model.js` — build 派生模型(M2:武器×等级×进化×被动的派生数值与理论 DPS **唯一解释器**,sim/单测/扫描器同源防双写) +- `src/sim.js` — 纯逻辑核心(零 DOM/引擎,node 直跑;M2 增 proj/zone 双实体形态、暴击、聚灵珠、AoE「收集-降序结算」) +- `src/game-logic.js` / `src/render.js` — 壳层(摇杆核接线/反馈泵/三选一弹层/出击页/暂停总览/取证)与占位美术(山海彩绘色板) +- `src/host-config.js` / `src/main.js` / `src/game.js` — L1 宿主装配(17 插件,沿 `_template` 骨架) +- `scripts/` — headless 冒烟 / 参数扫描(`sim-scan.mjs`)/ 合成压测 / 包体实测;`scripts/lib/p2-driver.mjs` = P2 档走位单一实现 +- `test/` — 纯逻辑单测 + build 数据表门 + boot 冒烟 + 渲染崩溃回归 + 手感断言 + 触发器② UI 断言 -## M1 测量面(手感断言的机验入口) +## 测量面(手感断言的机验入口) -- 取证:`_forensicsView().measures.events[]` 逐条 `{eventName, frame, timeMs, sourceId, visualEventFrame, sfxStartFrame}`(hit-feedback 探针;音画同帧断言=比对两帧号)+ `measures.joystick`(摇杆探针)。 +- 取证:`_forensicsView().measures.events[]` 逐条 `{eventName, frame, timeMs, sourceId, visualEventFrame, sfxStartFrame}`(hit-feedback 探针;音画同帧断言=比对两帧号)+ `measures.joystick`(摇杆探针)+ `measures.menu`(M2:出击页滚动/难度选择探针)。 - 轨迹 driver:`plugins.vjoy.runTrajectory({from,to,ms}, advance)`(与壳层共核,注入即驱真游戏;e2e CDP drag 的 headless 孪生)。 -- 量值面:`balance.FEEDBACK`(手 4 顿帧档位/手 5 震幅上限/手 7 飘字上限,全部草案〔真机校准后锁〕)。 +- 量值面:`balance.FEEDBACK`(手 4 顿帧档位/手 5 震幅上限/手 7 飘字上限,全部草案〔真机校准后锁〕;crit 档事件源 M2 已落,bossKill 随 M3)。 ## 跑起来 ```bash -# 纯逻辑单测 + boot 冒烟 + 渲染回归 + 手感断言接通(本机零依赖;13 测) -node --test test/sim.test.mjs test/headless-boot.test.mjs test/render-negative-radius.test.mjs test/feel-assertions.test.mjs +# 全部测试(本机零依赖;27 测 = M0/M1 十三测零倒退 + M2 build 表门/暴击全链/触发器② UI 断言) +node --test test/*.test.mjs # 三场景 headless 单局(P0 静止必死 ≤90s / 关火纯走位 ≥60s / P2 贪心跑到终态) node scripts/headless-smoke.mjs +# 仿真参数扫描(M2 门:build 四判据;门档 A 12武器×40局 + B 30随机build×20局 = 1080 局) +node scripts/sim-scan.mjs # 门档(≈6 分钟@6 worker;非绿退出码 1) +node scripts/sim-scan.mjs --quick # 调参档 110 局(只出数) + # 合成压测(逻辑层:敌600+弹500+晶300;渲染侧与基准机=真机窗口另测) node scripts/stress-test.mjs @@ -36,7 +42,7 @@ node scripts/build-report.mjs npx serve . # 或任意静态服务器,打开 index.html ``` -桌面调试:WASD/方向键移动,`1/2/3` 选卡,空格开局/重开。 +桌面调试:WASD/方向键移动,`1/2/3` 选卡,`P`/`Esc` 暂停(build 总览),空格开局/重开;出击页拖动列表滚动、点「潮汛」行切难度(D0/D1)。 ## M0 验收留证(2026-07-06) @@ -45,3 +51,7 @@ npx serve . # 或任意静态服务器,打开 index.html ## M1 验收留证(2026-07-06) 游戏测试 13/13(M0 七测零倒退+渲染崩溃回归+手感接通五测)· 插件库整库 260/260 · manifest 19/19 · 六件新插件 gz 预算全 ✔(SIZES.md 增量法)· 三场景冒烟数值与 M0 逐值一致(724 杀/39.1s)· 压测 p95 1.174ms(vs M0 1.167ms,沉淀换装零回退)· 包体 258,905B gz 89,268B(+11KB=六件内核内联,0.6MB 门内)。**浏览器崩溃修复**:play 首帧 `drawGround` 哈希有符号右移产负半径 -0.5 → `IndexSizeError` → RAF 链断(mini-desktop CDP 验出);修 `>>>` 位提取+校验型桩回归(`test/render-negative-radius.test.mjs`,修前红修后绿)。真机校准锁值(60fps/震感/音频端到端)与九门 survivor driver 完整跑仍待 mini-desktop/真机窗口,详见开发汇报断言表。 + +## M2 验收留证(2026-07-06) + +**build 四判据全绿**(`sim-scan.mjs` 门档 1080 局,D1×P2,确定性种子复跑逐值一致):①每件武器定向 build 胜率 43–63% 全 ≥40% ②最强 build 85% ≤85% ③随机 build 中位胜率 55% ∈35–55% ④武器出现↔获胜 |r|max 0.250 ≤0.6(初始武器雷符链恒在、无变异,④对其不适用,可行性由定向臂覆盖)。**仿真墙钟 spike 实测**:单局 mean 1.88s / p95 3.29s(300s 局=18000 帧,≈135µs/帧),6 worker 吞吐 180 局/分,门档 6.0 分钟,已回填书 §8.3(唯一书内改动)。游戏测试 27/27(M0/M1 十三测零倒退 + build 表门九测 + 手 4 暴击全链一测 + 触发器② UI 四测)· 三场景冒烟全过(S1 38.6s 死/S2 满血 60s/S3 win 981 杀 30 次升级)· 逻辑压测 p95 1.433ms(多武器+区域实体增量,6ms 门内)· 整库插件 269/269(基线 260 全含;canvas-ui-kit v1.1 additive 滚动列表 +3 测)· manifest 19/19 · 包体首屏 287,663B(gz 99,767B,0.6MB 门内;`dist/build-report.json`)。**M2 调参实录**(数据表内收敛,12 轮):波表增 hpMul/speedMul 爬坡列(§8.1 g^w 表化+反风筝速度压力)、三选一改持有件偏权池(ownedWeight 2,治「新件洪水稀释构筑」)、P2 档修成真「贪心磁吸+避险」混合矢量(纯二值版后期永不捡晶,build 进度饿死)。触发器② CDP 走查+截图留证与真机校准仍待 mini-desktop 窗口(headless 结构断言已先行钉死,见 `test/ui-trigger2.test.mjs`)。 diff --git a/game-runtime/games/shanhai-xingji/scripts/build-report.mjs b/game-runtime/games/shanhai-xingji/scripts/build-report.mjs index 9598d252..37cf18ac 100644 --- a/game-runtime/games/shanhai-xingji/scripts/build-report.mjs +++ b/game-runtime/games/shanhai-xingji/scripts/build-report.mjs @@ -42,8 +42,8 @@ const gz = (buf) => gzipSync(buf, { level: 9 }).length; const report = { date: '2026-07-06', - milestone: 'M1', - note: 'M1 尚无 atlas/BGM/字体资产(美术随 M3 风格版):两本账此期同值(引擎+代码+页面壳,含 M1 六件 L2 插件内核内联)。平台编译期体积门实现待接(§9.2 口径),本文件即 X4 的自产字节数证据。', + milestone: 'M2', + note: 'M2 尚无 atlas/BGM/字体资产(美术随 M3 风格版):两本账此期同值(引擎+代码+页面壳;M2 增 build 体系内容表+扫描外壳不进包=scripts 不打包)。平台编译期体积门实现待接(§9.2 口径),本文件即自产字节数证据。', budget: { firstScreenMB: 2.0, totalMB: 10.0, engineCodeBudgetMB: 0.6 }, bundleBytes: bundle.length, bundleGzBytes: gz(bundle), diff --git a/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs b/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs index da83f1ba..cb485857 100644 --- a/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs +++ b/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs @@ -13,6 +13,7 @@ 'use strict'; import { createSim } from '../src/sim.js'; +import { p2Move } from './lib/p2-driver.mjs'; const DT = 1 / 60; let failures = 0; @@ -70,7 +71,7 @@ function assert(cond, label, detail) { } const s = sim.readState(); console.log(`S3 贪心单局(P2):phase=${s.phase} t=${s.timeS}s kills=${s.score} lv=${s.level} 升级=${s.stats.levelUps} 灵晶=${s.stats.gems} hp=${s.hp}`); - console.log(` 被动终态=${JSON.stringify(s.upgrades)}`); + console.log(` build 终态=武器 ${JSON.stringify(s.weapons.map((w) => `${w.id}${w.evolved ? '★' : ''}L${w.lv}`))} 被动 ${JSON.stringify(s.passives)}`); assert(s.phase === 'win' || s.phase === 'lose', 'S3 跑到终态(win/lose latch)', `终态=${s.phase}`); assert(s.stats.levelUps >= 3, 'S3 升级三选一真跑 ≥3 次', `实际=${s.stats.levelUps}`); assert(s.score >= 60, 'S3 击杀 ≥60(索敌/命中/掉晶全链活跃)', `实际=${s.score}`); @@ -95,31 +96,14 @@ function kiterMove(sim) { else sim.setMove(Math.cos(v.t * 0.7), Math.sin(v.t * 0.7)); // 无威胁:慢速绕圈防原地堆怪 } -/** P2 贪心:威胁逃逸优先,安全时吸最近灵晶。 */ -function p2Move(sim) { - const v = sim.view(); - let tx = 0, ty = 0, threat = 0; - for (const e of v.enemies) { - const dx = v.player.x - e.x, dy = v.player.y - e.y; - const d = Math.hypot(dx, dy); - if (d < 190 && d > 1e-3) { const w = 1 / (d * d); tx += (dx / d) * w; ty += (dy / d) * w; threat += w; } - } - if (threat > 0.00012) { sim.setMove(tx, ty); return; } - // 安全:找最近灵晶 - let gx = 0, gy = 0, bestD = 320; - for (const g of v.gems) { - const dx = g.x - v.player.x, dy = g.y - v.player.y; - const d = Math.hypot(dx, dy); - if (d < bestD) { bestD = d; gx = dx; gy = dy; } - } - if (bestD < 320) sim.setMove(gx, gy); - else sim.setMove(Math.cos(v.t * 0.5), Math.sin(v.t * 0.5)); -} - -/** 选卡优先级:弹数 > 攻速 > 伤害 > 移速 > 磁吸(P2 火力贪心)。 */ +/** 选卡优先级(M2 offer 契约:kind=evo|weapon|passive):进化 > 武器 > 被动(火力贪心序)。 */ function choosePriority(sim) { const s = sim.readState(); - const prio = ['count', 'rate', 'atk', 'speed', 'magnet']; + const evoIdx = s.offer.findIndex((o) => o.kind === 'evo'); + if (evoIdx >= 0) { sim.choose(evoIdx); return; } + const wIdx = s.offer.findIndex((o) => o.kind === 'weapon'); + if (wIdx >= 0) { sim.choose(wIdx); return; } + const prio = ['multi', 'haste', 'atk', 'crit', 'speed', 'magnet']; for (const id of prio) { const idx = s.offer.findIndex((o) => o.id === id); if (idx >= 0) { sim.choose(idx); return; } diff --git a/game-runtime/games/shanhai-xingji/scripts/lib/p2-driver.mjs b/game-runtime/games/shanhai-xingji/scripts/lib/p2-driver.mjs new file mode 100644 index 00000000..95e7de11 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/lib/p2-driver.mjs @@ -0,0 +1,69 @@ +/** + * p2-driver.mjs — P2 档 headless 走位策略(书 §8.3 档位:「贪心磁吸+避险,纪律最优」) + * owner:北极星顶级线② | 消费方:scripts/headless-smoke.mjs / scripts/sim-scan.mjs(单一实现,防双写漂移) + * + * 【实现语义(M2 修订)】混合矢量而非二值切换: + * - 高威胁(近敌反平方场强 ≥ T_HI):纯逃逸; + * - 中威胁(T_LO..T_HI):逃逸 0.65 + 拾取 0.35 加权(边风筝边收——「贪心磁吸」的本义; + * 二值版在后期敌群常驻时永不捡晶,build 进度饿死,M2 首轮扫描实测揪出); + * - 低威胁:直奔目标(聚灵珠优先于灵晶:全图磁吸价值 ≫ 单晶);无目标缓圆防原地堆怪。 + * 全程确定性(只读 sim.view(),无随机)。 + */ + +'use strict'; + +/** 威胁场阈值(反平方和;敌在 50px ≈ 0.0004/只)。 */ +const T_HI = 0.0009; +const T_LO = 0.00012; +/** 感知半径。 */ +const R_THREAT = 210; +const R_GEM = 340; +const R_DROP = 460; + +/** P2 走位:每帧调用一次(O(敌+晶))。 */ +export function p2Move(sim) { + const v = sim.view(); + // 逃逸场(近敌反平方) + let ex = 0, ey = 0, threat = 0; + for (const e of v.enemies) { + const dx = v.player.x - e.x, dy = v.player.y - e.y; + const d = Math.hypot(dx, dy); + if (d < R_THREAT && d > 1e-3) { + const w = 1 / (d * d); + ex += (dx / d) * w; ey += (dy / d) * w; threat += w; + } + } + // 拾取目标:聚灵珠优先(全图磁吸,权重更高),其次最近灵晶 + let gx = 0, gy = 0, hasGoal = false, goalIsDrop = false; + let bestD = R_DROP; + for (const d0 of v.drops) { + const dx = d0.x - v.player.x, dy = d0.y - v.player.y; + const d = Math.hypot(dx, dy); + if (d < bestD) { bestD = d; gx = dx; gy = dy; hasGoal = true; goalIsDrop = true; } + } + if (!hasGoal) { + bestD = R_GEM; + for (const g of v.gems) { + if (g.state !== 'idle') continue; + const dx = g.x - v.player.x, dy = g.y - v.player.y; + const d = Math.hypot(dx, dy); + if (d < bestD) { bestD = d; gx = dx; gy = dy; hasGoal = true; } + } + } + + if (threat >= T_HI) { sim.setMove(ex, ey); return; } + if (threat >= T_LO) { + if (hasGoal) { + // 聚灵珠 = 一次全图收割,冒险值更高:对半权重;散晶 0.35 + const gw = goalIsDrop ? 0.5 : 0.35; + const en = Math.hypot(ex, ey) || 1; + const gn = Math.hypot(gx, gy) || 1; + sim.setMove((ex / en) * (1 - gw) + (gx / gn) * gw, (ey / en) * (1 - gw) + (gy / gn) * gw); + } else { + sim.setMove(ex, ey); + } + return; + } + if (hasGoal) { sim.setMove(gx, gy); return; } + sim.setMove(Math.cos(v.t * 0.5), Math.sin(v.t * 0.5)); // 无事:缓圆防原地堆怪 +} diff --git a/game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs b/game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs new file mode 100644 index 00000000..38749727 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs @@ -0,0 +1,320 @@ +/** + * sim-scan.mjs — 《山海行纪》仿真器参数扫描外壳(M2 首跑;书 §8.2 build 四判据 + §8.3 墙钟 spike) + * owner:北极星顶级线② | 消费方:M2 门「build 四判据全绿(P2 档)」+「仿真墙钟 spike 实测回填 §8.3」 + * + * 【定位】headless 单局脚本(headless-smoke)之上的**批量外壳**:武器×被动组合抽样批跑, + * 聚合存活率/击杀/DPS 曲线与四判据;纯 node,吃 balance 表,零渲染零 DOM(§8.3 路 A)。 + * + * 【两臂设计】 + * A 臂(定向):每件武器一条定向 build(初始雷符链 + 该武器 + 其进化被动 + 族向支援被动), + * P2 走位 + 定向选卡 → 判据①「每件武器至少存在一条含它的可行 build(D1、P2、胜率 ≥40%)」; + * B 臂(随机):均匀随机抽 K 条 build(3 武器 + 4 被动,受控 rng 可复现),同策略跑 → + * 判据③「随机 build 中位胜率 35–55%」+ 判据④「武器被选中→获胜相关性 |r|≤0.6」。 + * 判据②「最强 build 胜率 ≤85%」在 A+B 全体 build 上取 max。 + * 诚实口径:初始武器恒为雷符链(角色 roster 归 M4),其「被选中」无变异 → 判据④对 + * 雷符链不适用(r 记 null 并注明);其可行性由 A 臂定向 build 覆盖。 + * + * 【用法】 + * node scripts/sim-scan.mjs # 门档:A 12×40 + B 30×20 = 1080 局,判据判定,非绿退出码 1 + * node scripts/sim-scan.mjs --quick # 调参档:A 12×5 + B 10×5 = 110 局(只出数,不当门) + * node scripts/sim-scan.mjs --jobs 6 # worker 并行度(缺省 = min(cpu-1, 6);1 = 单线程) + * 产物:dist/sim-scan-report.json(判据 + 曲线 + 墙钟分布;§8.3 回填的数据源) + */ + +'use strict'; + +import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'; +import { performance } from 'node:perf_hooks'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { cpus } from 'node:os'; + +import { createSim } from '../src/sim.js'; +import { createRng } from '../src/rng.js'; +import { WEAPONS, ROUND_S } from '../src/balance.js'; +import { p2Move } from './lib/p2-driver.mjs'; + +const DT = 1 / 60; +const CAP_S = ROUND_S + 20; // 安全帽(win 线 300s) +const BUCKET_S = 30; // 曲线桶宽(存活率/击杀/DPS 按 30s 聚合) +const N_BUCKETS = Math.ceil(ROUND_S / BUCKET_S); +const DIFFICULTY = 'd1'; // 判据钦定难度(书 §8.2) + +/** 判据阈值(书 §8.2 原文带值;首版工作阈值〔草案〕)。 */ +const CRITERIA = { + perWeaponMinWinRate: 0.40, // ① 每件武器的定向 build 胜率 ≥40% + maxBuildWinRate: 0.85, // ② 最强 build 胜率 ≤85% + randomMedianBand: [0.35, 0.55], // ③ 随机 build 中位胜率带 + weaponWinCorrCap: 0.6, // ④ |r| ≤ 0.6 +}; + +/* ══════ driver:P2 档 = scripts/lib/p2-driver.mjs 单一实现(与 headless-smoke 同源)══════ */ + +/** + * 定向选卡策略:进化 > build 内武器 > build 内被动 > 持有被动升级 > 持有武器升级 > 第一张。 + * (聚焦型玩家模型:不主动开 build 外新槽,被迫时选已持有件的升级。) + */ +function choosePolicy(sim, build) { + const s = sim.readState(); + const owned = new Set(s.weapons.map((w) => w.id)); + const pick = (pred) => { + const i = s.offer.findIndex(pred); + if (i >= 0) { sim.choose(i); return true; } + return false; + }; + if (pick((o) => o.kind === 'evo' && build.weapons.includes(o.id))) return; + if (pick((o) => o.kind === 'evo' && owned.has(o.id))) return; + if (pick((o) => o.kind === 'weapon' && build.weapons.includes(o.id))) return; + if (pick((o) => o.kind === 'passive' && build.passives.includes(o.id))) return; + if (pick((o) => o.kind === 'passive' && (s.passives[o.id] | 0) > 0)) return; + if (pick((o) => o.kind === 'weapon' && owned.has(o.id))) return; + sim.choose(0); +} + +/* ══════════════════ 单局(纯函数;worker 与主线程共用)══════════════════ */ + +/** + * 跑一局并出指标。 + * @param {{seed:number, build:{weapons:string[], passives:string[]}, difficulty:string}} task + */ +function runOne(task) { + const t0 = performance.now(); + const sim = createSim({ seed: task.seed, difficulty: task.difficulty }); + const dmgAt = new Array(N_BUCKETS + 1).fill(0); + const killsAt = new Array(N_BUCKETS + 1).fill(0); + let frames = 0; + let nextBucket = 1; + for (;;) { + const ph = sim.phase; + if (ph === 'levelup') { choosePolicy(sim, task.build); continue; } + if (ph !== 'play') break; + p2Move(sim); + sim.update(DT); + frames++; + const v = sim.view(); + if (v.t >= nextBucket * BUCKET_S && nextBucket <= N_BUCKETS) { + dmgAt[nextBucket] = v.stats.dmgDealt; + killsAt[nextBucket] = v.stats.kills; + nextBucket++; + } + if (v.t > CAP_S) break; + } + const s = sim.readState(); + // 尾桶补记(死亡时刻所在桶及之后:曲线聚合只取存活期的桶) + const aliveBuckets = Math.min(N_BUCKETS, Math.floor(s.timeS / BUCKET_S)); + const wallMs = performance.now() - t0; + return { + win: s.phase === 'win', + t: s.timeS, + kills: s.score, + level: s.level, + dmg: Math.round(s.stats.dmgDealt), + evolutions: s.stats.evolutions, + harvests: s.stats.harvests, + weapons: s.weapons.map((w) => ({ id: w.id, lv: w.lv, evolved: w.evolved })), + passives: s.passives, + dmgAt, killsAt, aliveBuckets, + frames, wallMs, + }; +} + +/* ══════════════════ 任务编排 ══════════════════ */ + +/** + * A 臂:每武器一条定向 build(判据①「含它的可行 build」原文允许携伴:恒 3 武器 = + * 主打件 + 初始雷符链 + 生存协同件;被动恒 4 = 进化被动 + 攻击 + 移速(生存轴)+ 族向支援。 + * 纯输出被动组在 D1 速度压力下活不到后期——移速是本品类定向构筑的生存底,轮 5 实测揪出。 + */ +function directedBuildFor(weapon) { + const isZone = weapon.kind === 'zone'; + const passives = [...new Set([weapon.evo.passive, 'atk', 'speed', isZone ? 'cooldown' : 'haste', 'multi'])].slice(0, 4); + const third = weapon.id === 'shanzi' ? 'xuanming' : 'shanzi'; + const weapons = [...new Set(['leifu', weapon.id, third, 'fengren'])].slice(0, 3); + return { weapons, passives }; +} + +/** B 臂:受控 rng 均匀抽 build(3 件非初始武器 + 4 被动)。 */ +function sampleRandomBuild(rng) { + const pool = WEAPONS.map((w) => w.id).filter((id) => id !== 'leifu'); + const wPick = []; + while (wPick.length < 3) { + const c = pool[(rng() * pool.length) | 0]; + if (!wPick.includes(c)) wPick.push(c); + } + const pPoolAll = ['atk', 'haste', 'multi', 'area', 'crit', 'speed', 'armor', 'regen', 'magnet', 'cooldown', 'projspeed', 'pierce', 'duration', 'xpgain']; + const pPick = []; + while (pPick.length < 4) { + const c = pPoolAll[(rng() * pPoolAll.length) | 0]; + if (!pPick.includes(c)) pPick.push(c); + } + return { weapons: ['leifu', ...wPick], passives: pPick }; +} + +function buildTasks(quick) { + const nA = quick ? 5 : 40; // A 臂 n=40:±8% 标准误(n=20 的 ±11% 会把真率 50% 的武器随机打到 <40%) + const kB = quick ? 10 : 30; + const nB = quick ? 5 : 20; + const tasks = []; + for (let wi = 0; wi < WEAPONS.length; wi++) { + const w = WEAPONS[wi]; + const build = directedBuildFor(w); + for (let r = 0; r < nA; r++) { + tasks.push({ arm: 'A', key: w.id, seed: (1000000 + wi * 1000 + r) >>> 0, build, difficulty: DIFFICULTY }); + } + } + const rng = createRng(0xB111D); + for (let b = 0; b < kB; b++) { + const build = sampleRandomBuild(rng); + for (let r = 0; r < nB; r++) { + tasks.push({ arm: 'B', key: `rand-${b}`, seed: (2000000 + b * 1000 + r) >>> 0, build, difficulty: DIFFICULTY }); + } + } + return tasks; +} + +/* ══════════════════ worker 通道 ══════════════════ */ + +if (!isMainThread) { + // worker:吃任务批,逐局跑,回传结果(前台串行 per worker;确定性只依赖 seed) + const out = workerData.tasks.map((t) => ({ ...runOne(t), arm: t.arm, key: t.key, seed: t.seed, build: t.build })); + parentPort.postMessage(out); +} + +/* ══════════════════ 主线程:编排 + 聚合 + 判据 ══════════════════ */ + +async function main() { + const argv = process.argv.slice(2); + const quick = argv.includes('--quick'); + const jobsArg = argv.find((a) => a.startsWith('--jobs')); + const jobs = jobsArg ? Math.max(1, parseInt(jobsArg.split(/[= ]/)[1] || argv[argv.indexOf(jobsArg) + 1], 10) || 1) + : Math.max(1, Math.min(6, cpus().length - 1)); + + const tasks = buildTasks(quick); + console.log(`[sim-scan] 档=${quick ? 'quick 调参' : '门档'} 任务=${tasks.length} 局 难度=${DIFFICULTY} driver=P2 jobs=${jobs}`); + const t0 = performance.now(); + + let results; + if (jobs <= 1) { + results = tasks.map((t) => ({ ...runOne(t), arm: t.arm, key: t.key, seed: t.seed, build: t.build })); + } else { + // 均匀切片派 worker(本文件自身为 worker 脚本;结果顺序与任务无关,聚合按 key) + const chunks = Array.from({ length: jobs }, () => []); + tasks.forEach((t, i) => chunks[i % jobs].push(t)); + results = (await Promise.all(chunks.filter((c) => c.length).map((chunk) => new Promise((resolve, reject) => { + const w = new Worker(fileURLToPath(import.meta.url), { workerData: { tasks: chunk } }); + w.once('message', resolve); + w.once('error', reject); + w.once('exit', (code) => { if (code !== 0) reject(new Error(`worker exit ${code}`)); }); + })))).flat(); + } + const totalMs = performance.now() - t0; + + /* ── 聚合:按 build key 汇 → 判据 ── */ + const byKey = new Map(); + for (const r of results) { + if (!byKey.has(r.key)) byKey.set(r.key, { arm: r.arm, build: r.build, runs: [] }); + byKey.get(r.key).runs.push(r); + } + const buildRows = [...byKey.entries()].map(([key, g]) => { + const wins = g.runs.filter((r) => r.win).length; + return { + key, arm: g.arm, build: g.build, n: g.runs.length, + winRate: wins / g.runs.length, + avgKills: Math.round(g.runs.reduce((s, r) => s + r.kills, 0) / g.runs.length), + avgLevel: +(g.runs.reduce((s, r) => s + r.level, 0) / g.runs.length).toFixed(1), + avgSurviveS: +(g.runs.reduce((s, r) => s + Math.min(r.t, ROUND_S), 0) / g.runs.length).toFixed(1), + evoRate: +(g.runs.filter((r) => r.evolutions > 0).length / g.runs.length).toFixed(2), + }; + }); + + // ① 定向 build 胜率(按武器) + const perWeapon = buildRows.filter((b) => b.arm === 'A').map((b) => ({ weapon: b.key, winRate: b.winRate, n: b.n, avgSurviveS: b.avgSurviveS, evoRate: b.evoRate })); + const c1Fails = perWeapon.filter((w) => w.winRate < CRITERIA.perWeaponMinWinRate); + // ② 最强 build + const maxBuild = buildRows.reduce((a, b) => (b.winRate > a.winRate ? b : a), buildRows[0]); + // ③ 随机中位 + const randRates = buildRows.filter((b) => b.arm === 'B').map((b) => b.winRate).sort((a, b) => a - b); + const median = randRates.length % 2 ? randRates[(randRates.length - 1) / 2] + : (randRates[randRates.length / 2 - 1] + randRates[randRates.length / 2]) / 2; + // ④ 武器出现↔获胜 phi 相关(B 臂逐局;presence = 终局持有;leifu 恒在 → null 注明) + const bRuns = results.filter((r) => r.arm === 'B'); + const corr = WEAPONS.map((w) => { + if (w.id === 'leifu') return { weapon: 'leifu', r: null, note: '初始武器恒在,无变异不适用;可行性由 A 臂覆盖' }; + let n11 = 0, n10 = 0, n01 = 0, n00 = 0; + for (const r of bRuns) { + const has = r.weapons.some((x) => x.id === w.id); + if (has && r.win) n11++; else if (has) n10++; else if (r.win) n01++; else n00++; + } + const n1x = n11 + n10, n0x = n01 + n00, nx1 = n11 + n01, nx0 = n10 + n00; + const den = Math.sqrt(n1x * n0x * nx1 * nx0); + const phi = den > 0 ? (n11 * n00 - n10 * n01) / den : 0; + return { weapon: w.id, r: +phi.toFixed(3), n: bRuns.length, presentRuns: n1x }; + }); + const c4Fails = corr.filter((c) => c.r != null && Math.abs(c.r) > CRITERIA.weaponWinCorrCap); + + /* ── 曲线聚合(B 臂 = 「一般玩家面」;存活率/击杀/DPS 按 30s 桶)── */ + const curves = { bucketS: BUCKET_S, survival: [], kills: [], dps: [] }; + for (let k = 1; k <= N_BUCKETS; k++) { + const aliveRuns = bRuns.filter((r) => r.aliveBuckets >= k); + curves.survival.push(+(aliveRuns.length / Math.max(1, bRuns.length)).toFixed(3)); + const kills = aliveRuns.map((r) => r.killsAt[k] - r.killsAt[k - 1]); + const dmg = aliveRuns.map((r) => r.dmgAt[k] - r.dmgAt[k - 1]); + curves.kills.push(kills.length ? Math.round(kills.reduce((s, x) => s + x, 0) / kills.length) : 0); + curves.dps.push(dmg.length ? Math.round(dmg.reduce((s, x) => s + x, 0) / dmg.length / BUCKET_S) : 0); + } + + /* ── 墙钟分布(§8.3 spike 实测)── */ + const wall = results.map((r) => r.wallMs).sort((a, b) => a - b); + const frames = results.reduce((s, r) => s + r.frames, 0); + const pct = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))]; + const wallClock = { + machine: `${process.platform}/${process.arch} node ${process.version}(开发机)`, + jobs, runs: results.length, + perRunMs: { + mean: +(wall.reduce((s, x) => s + x, 0) / wall.length).toFixed(1), + p50: +pct(wall, 0.5).toFixed(1), p95: +pct(wall, 0.95).toFixed(1), max: +wall[wall.length - 1].toFixed(1), + }, + usPerFrame: +((wall.reduce((s, x) => s + x, 0) * 1000) / frames).toFixed(2), + totalWallS: +(totalMs / 1000).toFixed(1), + runsPerMin: +((results.length / (totalMs / 1000)) * 60).toFixed(1), + framesSimulated: frames, + }; + + /* ── 判据判定 ── */ + const criteria = { + c1: { pass: c1Fails.length === 0, desc: `每件武器定向 build 胜率 ≥${CRITERIA.perWeaponMinWinRate}(D1,P2)`, fails: c1Fails }, + c2: { pass: maxBuild.winRate <= CRITERIA.maxBuildWinRate, desc: `最强 build 胜率 ≤${CRITERIA.maxBuildWinRate}`, max: { key: maxBuild.key, winRate: maxBuild.winRate } }, + c3: { pass: median >= CRITERIA.randomMedianBand[0] && median <= CRITERIA.randomMedianBand[1], desc: `随机 build 中位胜率 ∈ [${CRITERIA.randomMedianBand}]`, median: +median.toFixed(3) }, + c4: { pass: c4Fails.length === 0, desc: `武器出现↔获胜 |r| ≤ ${CRITERIA.weaponWinCorrCap}(B 臂;leifu 不适用)`, fails: c4Fails }, + }; + const allPass = criteria.c1.pass && criteria.c2.pass && criteria.c3.pass && criteria.c4.pass; + + const report = { + date: new Date().toISOString().slice(0, 10), + milestone: 'M2', + mode: quick ? 'quick' : 'gate', + difficulty: DIFFICULTY, driver: 'P2', dt: DT, roundS: ROUND_S, + tasks: tasks.length, + criteria, perWeapon, corr, buildRows, curves, wallClock, + note: '判据阈值为书 §8.2 首版工作阈值〔草案〕;曲线聚合取 B 臂(随机 build=一般玩家面),桶=存活期内 30s;quick 档只出数不当门。', + }; + const here = dirname(fileURLToPath(import.meta.url)); + mkdirSync(join(here, '..', 'dist'), { recursive: true }); + const outPath = join(here, '..', 'dist', 'sim-scan-report.json'); + writeFileSync(outPath, JSON.stringify(report, null, 2)); + + /* ── 控制台摘要 ── */ + console.log(`\n[判据①] 定向 build 胜率(n=${perWeapon[0].n}/件):`); + for (const w of perWeapon) console.log(` ${w.winRate >= CRITERIA.perWeaponMinWinRate ? 'PASS' : 'FAIL'} ${w.weapon.padEnd(9)} win=${(w.winRate * 100).toFixed(0)}% 均存活=${w.avgSurviveS}s 进化率=${w.evoRate}`); + console.log(`[判据②] 最强 build=${maxBuild.key} 胜率=${(maxBuild.winRate * 100).toFixed(0)}% ${criteria.c2.pass ? 'PASS(≤85%)' : 'FAIL(>85%)'}`); + console.log(`[判据③] 随机中位胜率=${(median * 100).toFixed(0)}% ${criteria.c3.pass ? 'PASS(35–55%)' : 'FAIL'}(K=${randRates.length})`); + console.log(`[判据④] |r|max=${Math.max(...corr.filter((c) => c.r != null).map((c) => Math.abs(c.r))).toFixed(3)} ${criteria.c4.pass ? 'PASS(≤0.6)' : 'FAIL'}(leifu 不适用,见报告)`); + console.log(`[曲线] 存活率@${BUCKET_S}s 桶: ${curves.survival.join(' ')}`); + console.log(`[墙钟] ${wallClock.runs} 局 total=${wallClock.totalWallS}s jobs=${jobs} 单局 mean=${wallClock.perRunMs.mean}ms p95=${wallClock.perRunMs.p95}ms ≈${wallClock.usPerFrame}µs/帧 吞吐=${wallClock.runsPerMin} 局/分`); + console.log(`[产物] ${outPath}`); + console.log(allPass ? '\n[PASS] build 四判据全绿' : '\n[FAIL] 四判据未全绿(quick 档只作调参参考)'); + process.exit(quick ? 0 : (allPass ? 0 : 1)); +} + +if (isMainThread) main().catch((e) => { console.error('[sim-scan] 失败:', e); process.exit(2); }); diff --git a/game-runtime/games/shanhai-xingji/src/balance.js b/game-runtime/games/shanhai-xingji/src/balance.js index 6146302c..cb7794da 100644 --- a/game-runtime/games/shanhai-xingji/src/balance.js +++ b/game-runtime/games/shanhai-xingji/src/balance.js @@ -1,12 +1,16 @@ /** - * balance.js — 《山海行纪》M0 数值表(纯数据 · 零魔法数唯一来源) + * balance.js — 《山海行纪》M2 数值表(纯数据 · 零魔法数唯一来源) * owner:北极星顶级线②(开发设计书 §8.1「所有常量集中在 balance 层数据表」纪律) * * 【口径】 * - 本表是 sim.js(纯逻辑核心)唯一的数值来源:游戏代码里不允许出现裸数值(渲染布局常量除外); - * - 快进仿真器(NSTAR §2.C 路 A)直接吃本表跑参数网格——改数值=改这里,逻辑结构零改动; - * - 全部量值为 M0 首版锚〔草案·首轮仿真回填后锁,修订走质量轨校准〕; + * - 快进仿真器(scripts/sim-scan.mjs)直接吃本表跑参数扫描——改数值=改这里,逻辑结构零改动; + * - 全部量值为草案锚〔M2 首轮扫描回填后按四判据收敛,修订走质量轨校准〕; * - 距离单位 = 逻辑像素(视口 390×844 基准),时间单位 = 秒。 + * + * 【M2 增量】武器 12(各 8 级,五族索敌)/ 被动 14(各 5 级)/ 进化 12(满级+对应被动≥3, + * 机制跃迁非纯数值)/ 难度表 D0–D5(本期启用 D0/D1)/ 暴击(手 4 事件源)/ + * 全屏收割道具「聚灵珠」(手 6/手 8 事件源)。派生语义单一实现 = src/build-model.js。 */ 'use strict'; @@ -14,58 +18,261 @@ /** 逻辑视口(竖屏 390×844,与 L1 host-config 同值;sim 只用来推镜头与出怪环)。 */ export const VIEW = { w: 390, h: 844 }; -/** M0 一局时长(秒):撑到点即胜利结算(占位胜利线;正式 20 分钟波表+Boss 归 M3)。 */ +/** M2 一局时长(秒):撑到点即胜利结算(占位胜利线;正式 20 分钟波表+Boss 归 M3)。 */ export const ROUND_S = 300; /** 玩家(持符巡山使)。 */ export const PLAYER = { hp: 100, // 生命上限 - speed: 120, // 移速(px/s;被动可乘区放大) + speed: 120, // 移速(px/s;被动乘区放大) r: 12, // 碰撞半径 - hurtIframe: 0.25, // 受击后短无敌窗(秒)——防同帧多怪叠伤秒杀,M0 手感保底 + hurtIframe: 0.25, // 受击后短无敌窗(秒)——防同帧多怪叠伤秒杀 }; /** 虚拟摇杆(手 1–3 断言的参数面;UI 层消费,sim 只收归一化方向)。 */ export const JOYSTICK = { - radius: 64, // 摇杆逻辑半径(px) + radius: 64, // 摇杆逻辑半径(px) deadzoneRatio: 0.12, // 死区 = 半径的 12%(手 1:死区 ≤12%) }; -/** 武器「雷符链」(M0 唯一武器;索敌策略 = 最近敌)。 */ -export const WEAPON = { - dmg: 12, // 单发伤害 - interval: 0.55, // 发射间隔(秒;攻速被动按乘区缩短) - count: 1, // 每轮弹数(弹数被动 +1) - projSpeed: 420, // 弹速(px/s) - projR: 6, // 弹半径 - pierce: 1, // 穿透数(命中 N 个敌后消失) - life: 1.6, // 弹寿命(秒;超时消失) - searchR: 560, // 索敌半径(px;超出不开火,防对着空气射) +/** 暴击(手 4 事件源):总暴击率 = 被动 crit + 武器 innate critAdd,伤害 ×mul。 */ +export const CRIT = { mul: 2.0, chanceCap: 0.8 }; + +/** build 槽位(§11 暂停 build 总览:武器 6 槽 + 被动 6 槽)。 */ +export const SLOTS = { weapons: 6, passives: 6 }; + +/** + * 三选一权重池(进化项按条件强制进池)。 + * ownedWeight:持有件(升级项)的抽取权重(新件恒 1)——偏向已持有 = 构筑能成型; + * 均匀池实测(M2 首轮扫描)定向 build 常整局抽不到主打件,新件洪水稀释构筑。 + */ +export const OFFER = { count: 3, ownedWeight: 2 }; + +/** 进化条件:武器满级 且 对应被动 ≥ 本级(§3.1 验收用例原文口径)。 */ +export const EVO_PASSIVE_MIN = 3; + +/** 链式跳击索敌半径(进化机制「链」共用)。 */ +export const CHAIN_R = 130; + +/** + * 全屏收割道具「聚灵珠」(手 8:触发全图磁吸;手 6 族:拾取闪屏+音效档)。 + * 击杀掉落:冷却窗(minIntervalS)过后每杀 dropChance 概率掉 1 颗; + * 不受磁吸(必须走位踩取=拾取决策),接触即触发:全部散落灵晶转飞行态。 + */ +export const HARVEST = { + dropChance: 0.03, // 冷却后每次击杀的掉落概率 + minIntervalS: 40, // 两颗之间的最短间隔(秒) + ttlS: 30, // 珠在场存续(秒;过期消散腾出场上限,防远珠堵死后续掉落) + r: 14, // 道具半径(接触拾取判定 = r + player.r) }; /** - * 升级三选一池(M0 池 5)。effect 语义由 sim.applyUpgrade 消费: - * atk=伤害乘区 +25%/级 · rate=间隔 ×0.85/级 · count=弹数 +1/级 · speed=移速 +12%/级 · magnet=磁吸半径 +40%/级 + * ══════════════════ 武器表(12 件 × 8 级,五族索敌全覆盖)══════════════════ + * family:nearest 最近敌 | random 随机敌 | movedir 朝移动方向 | orbit 环绕自身 | place 原地投放 + * kind:proj 投射物 | zone 区域(orbit/place 都是 zone:按 tick 对半径内敌结算) + * levels:8 行,dm=伤害乘区(等级系数,Lv1=1.0→Lv8 合成 ≈3.2,§8.1)/ ca=+弹数(累计)。 + * evo:进化(条件=满级+passive≥EVO_PASSIVE_MIN):mods 数值面 + 机制跃迁字段(chain/explode/ + * dot/slow/mirror/gemPull——非纯数值,§5.2);理论 DPS 跃迁带 1.8–2.5(build-model 单测锁死)。 + * 命名纪律(山海设定 v1 §1):武器=符箓法器,从符箓五行取名;进化名用古籍公版意象 + * (毕方=山海经异兽,白虹贯日/荧惑=古天文语),零现代作品词。 */ -export const UPGRADES = [ - { id: 'atk', name: '符力灌注', desc: '雷符伤害 +25%', maxLv: 5 }, - { id: 'rate', name: '疾雷咒', desc: '施符间隔 -15%', maxLv: 5 }, - { id: 'count', name: '连环符', desc: '每轮加放一道雷符', maxLv: 3 }, - { id: 'speed', name: '踏山步', desc: '移速 +12%', maxLv: 5 }, - { id: 'magnet', name: '引灵纹', desc: '灵晶吸取半径 +40%', maxLv: 5 }, +const LV = [1.0, 1.22, 1.45, 1.7, 2.0, 2.35, 2.75, 3.2]; // 标准等级系数(纯伤害路) +/** + * 生成 8 级行。caAt = {级: +弹数};respreadCount 给定(proj 族)时, + * 加弹级之后的伤害乘区按弹数回摊(dm×合成弹数/基础弹数 恒 = 标准曲线,等级系数不超 3.2 带); + * zone 族不回摊(理论 DPS 用单触点模型,加环/加桩是群体收益,不进等级系数)。 + */ +const lvRows = (caAt, respreadCount) => LV.map((target, i) => { + const ca = Object.keys(caAt || {}).filter((k) => Number(k) <= i + 1).reduce((s, k) => s + caAt[k], 0); + const dm = respreadCount ? target * respreadCount / (respreadCount + ca) : target; + return { dm: Math.round(dm * 1000) / 1000, ca }; +}); + +export const WEAPONS = [ + { + id: 'leifu', name: '雷符链', desc: '掷出雷符,追击最近的异兽', + family: 'nearest', kind: 'proj', maxLv: 8, + base: { dmg: 13, interval: 0.55, count: 1, projSpeed: 420, projR: 6, pierce: 1, life: 1.6, searchR: 560 }, + levels: lvRows(), + evo: { + id: 'evo_leifu', name: '九霄雷契', passive: 'haste', + desc: '雷符成契,一化为二,命中处雷光链跳', + mods: { dmgMul: 1.0, countAdd: 1, chain: { n: 2, dmgPct: 0.5 } }, + }, + }, + { + id: 'jinge', name: '金戈符', desc: '重金之符,贯穿成列的兽潮', + family: 'nearest', kind: 'proj', maxLv: 8, + base: { dmg: 30, interval: 1.15, count: 1, projSpeed: 520, projR: 7, pierce: 3, life: 1.2, searchR: 560 }, + levels: lvRows(), + evo: { + id: 'evo_jinge', name: '白虹贯日', passive: 'atk', + desc: '金气化虹,直贯到底,无物可挡', + mods: { dmgMul: 2.1, pierceAdd: 96, projRMul: 1.6 }, + }, + }, + { + id: 'hanyuan', name: '寒渊符', desc: '寒渊之水凝箭,中者滞行', + family: 'nearest', kind: 'proj', maxLv: 8, + base: { dmg: 11.5, interval: 0.6, count: 1, projSpeed: 400, projR: 6, pierce: 2, life: 1.5, searchR: 560, slow: { pct: 0.22, s: 0.9 } }, + levels: lvRows(), + evo: { + id: 'evo_hanyuan', name: '玄冰锁渊', passive: 'duration', + desc: '寒气入髓,冰晶炸裂,群兽皆缓', + mods: { dmgMul: 1.9, slow: { pct: 0.4, s: 1.6 }, explode: { r: 44, dmgPct: 0.35 } }, + }, + }, + { + id: 'fentian', name: '焚天羽', desc: '火羽自天而落,炸开一团焚焰', + family: 'random', kind: 'proj', maxLv: 8, + base: { dmg: 19, interval: 0.9, count: 2, projSpeed: 340, projR: 7, pierce: 1, life: 1.8, searchR: 520, explode: { r: 36, dmgPct: 0.6 } }, + levels: lvRows({ 5: 1 }, 2), + evo: { + id: 'evo_fentian', name: '毕方燎原', passive: 'area', + desc: '毕方振翅,焰羽成原,余火灼烧', + mods: { dmgMul: 1.6, explode: { r: 64, dmgPct: 0.8 }, dot: { dpsPct: 0.6, s: 2.0 } }, + }, + }, + { + id: 'xinghuo', name: '星火符', desc: '撒出一把星火,四下点射', + family: 'random', kind: 'proj', maxLv: 8, + base: { dmg: 6, interval: 0.5, count: 3, projSpeed: 380, projR: 5, pierce: 1, life: 1.4, searchR: 520 }, + levels: lvRows({ 4: 1, 7: 1 }, 3), + evo: { + id: 'evo_xinghuo', name: '荧惑连珠', passive: 'multi', + desc: '星火连珠,溅火跳灼近旁', + mods: { dmgMul: 1.45, countAdd: 2, chain: { n: 1, dmgPct: 0.6 } }, + }, + }, + { + id: 'tianlei', name: '天雷引', desc: '引天雷落于随机异兽,天生易出重击', + family: 'random', kind: 'proj', maxLv: 8, + base: { dmg: 29, interval: 0.8, count: 1, projSpeed: 1600, projR: 8, pierce: 1, life: 0.5, searchR: 520, critAdd: 0.10, explode: { r: 40, dmgPct: 0.35 } }, + levels: lvRows(), + evo: { + id: 'evo_tianlei', name: '九天惊雷', passive: 'crit', + desc: '雷霆落处炸开雷环,重击更烈', + mods: { dmgMul: 1.7, explode: { r: 52, dmgPct: 0.5 }, critAdd: 0.15 }, + }, + }, + { + id: 'fengren', name: '风刃符', desc: '沿走位方向斩出风刃,穿行而过', + family: 'movedir', kind: 'proj', maxLv: 8, + base: { dmg: 16, interval: 0.7, count: 1, projSpeed: 460, projR: 7, pierce: 4, life: 1.5 }, + levels: lvRows({ 6: 1 }, 1), + evo: { + id: 'evo_fengren', name: '罡风裂空', passive: 'speed', + desc: '罡风前后齐出,刃幅更阔', + mods: { dmgMul: 1.9, projRMul: 1.4, mirror: true }, + }, + }, + { + id: 'poyue', name: '破岳戟', desc: '掷出沉重戟符,推碾一线', + family: 'movedir', kind: 'proj', maxLv: 8, + base: { dmg: 44, interval: 1.5, count: 1, projSpeed: 300, projR: 10, pierce: 5, life: 1.6 }, + levels: lvRows(), + evo: { + id: 'evo_poyue', name: '崩山裂岳', passive: 'pierce', + desc: '戟落如山崩,每击震裂四旁', + mods: { dmgMul: 1.9, explode: { r: 56, dmgPct: 0.6 }, pierceAdd: 4 }, + }, + }, + { + id: 'shanzi', name: '山字盾环', desc: '山字符化盾,绕身而转', + family: 'orbit', kind: 'zone', maxLv: 8, + base: { dmg: 9, tick: 0.4, count: 2, interval: 4.0, life: 4.0, zoneR: 16, orbitR: 70, orbitAv: 2.4 }, + levels: lvRows({ 4: 1, 7: 1 }), + evo: { + id: 'evo_shanzi', name: '五岳镇环', passive: 'armor', + desc: '盾环化岳,环阔而沉,触者皆缓', + mods: { dmgMul: 2.0, orbitRMul: 1.25, zoneRMul: 1.3, slow: { pct: 0.25, s: 0.8 } }, + }, + }, + { + id: 'xuanming', name: '玄冥轮', desc: '玄水成轮,急旋割群', + family: 'orbit', kind: 'zone', maxLv: 8, + base: { dmg: 6, tick: 0.3, count: 3, interval: 3.5, life: 3.5, zoneR: 15, orbitR: 52, orbitAv: 3.6, slow: { pct: 0.15, s: 0.5 } }, + levels: lvRows({ 5: 1 }), + evo: { + id: 'evo_xuanming', name: '玄冥噬月', passive: 'cooldown', + desc: '轮影蚀物,沾者溺蚀不止', + mods: { dmgMul: 1.7, dot: { dpsPct: 0.5, s: 1.5 }, slow: { pct: 0.3, s: 1.0 } }, + }, + }, + { + id: 'fuling', name: '缚灵桩', desc: '就地立桩,桩阵电缚近旁异兽', + family: 'place', kind: 'zone', maxLv: 8, + base: { dmg: 19.5, tick: 0.45, count: 1, interval: 3.5, life: 3.0, zoneR: 110 }, + levels: lvRows({ 5: 1 }), + evo: { + id: 'evo_fuling', name: '锁山镇桩', passive: 'regen', + desc: '桩成锁山之阵,阵内皆滞', + mods: { dmgMul: 1.75, zoneRMul: 1.3, lifeMul: 7 / 6, slow: { pct: 0.3, s: 0.6 } }, + }, + }, + { + id: 'zhendi', name: '震地符', desc: '按符入地,周身地脉阵阵震荡', + family: 'place', kind: 'zone', maxLv: 8, + base: { dmg: 34, tick: 0.6, count: 1, interval: 4.0, life: 3.4, zoneR: 110 }, + levels: lvRows(), + evo: { + id: 'evo_zhendi', name: '撼山符阵', passive: 'magnet', + desc: '地脉长鸣,震波连绵,灵晶应震而聚', + mods: { dmgMul: 1.4, tickMul: 0.8, lifeMul: 4 / 3, gemPull: true }, + }, + }, ]; +/** + * ══════════════════ 被动表(14 件 × 5 级)══════════════════ + * effect 语义由 build-model.deriveWeapon / derivePlayer 消费(每级线性叠,数值全在 per): + * dmg 伤害乘区 | interval 投射间隔乘区(proj 族) | extraChance 每轮追加一发概率 | + * area 半径乘区(弹半径/区域半径/爆裂半径) | crit 暴击率 | move 移速 | armor 受伤减免 | + * regen 每秒回复 | magnet 磁吸半径 | cycle 区域族循环间隔乘区(zone 族) | + * projSpeed 弹速/环转速 | pierce +穿透(proj 族) | duration 存续乘区 | xpGain 灵晶价值乘区。 + * M0 池 5(atk/rate/count/speed/magnet)重调进 14 池:命名沿用,量值按 14 轴乘区重标。 + */ +export const PASSIVES = [ + { id: 'atk', name: '符力灌注', desc: '所有符器伤害 +12%', maxLv: 5, effect: 'dmg', per: 0.12 }, + { id: 'haste', name: '疾雷咒', desc: '投射符器施放间隔 -6%', maxLv: 5, effect: 'interval', per: 0.06 }, + { id: 'multi', name: '连环符', desc: '每轮 15% 概率追加一发', maxLv: 5, effect: 'extraChance', per: 0.15 }, + { id: 'area', name: '扩符纹', desc: '符器作用范围 +10%', maxLv: 5, effect: 'area', per: 0.10 }, + { id: 'crit', name: '破煞诀', desc: '重击率 +6%(重击伤害翻倍)', maxLv: 5, effect: 'crit', per: 0.06 }, + { id: 'speed', name: '踏山步', desc: '移速 +7%', maxLv: 5, effect: 'move', per: 0.07 }, + { id: 'armor', name: '山纹甲', desc: '受到的接触伤害 -7%', maxLv: 5, effect: 'armor', per: 0.07 }, + { id: 'regen', name: '回春露', desc: '每秒回复 0.5 点生命', maxLv: 5, effect: 'regen', per: 0.5 }, + { id: 'magnet', name: '引灵纹', desc: '灵晶吸取半径 +30%', maxLv: 5, effect: 'magnet', per: 0.30 }, + { id: 'cooldown', name: '凝息诀', desc: '盾环/桩阵类循环间隔 -6%', maxLv: 5, effect: 'cycle', per: 0.06 }, + { id: 'projspeed', name: '疾风引', desc: '弹速与环转速 +12%', maxLv: 5, effect: 'projSpeed', per: 0.12 }, + { id: 'pierce', name: '透甲箓', desc: '投射符器穿透 +1', maxLv: 5, effect: 'pierce', per: 1 }, + { id: 'duration', name: '驻灵符', desc: '符器存续时长 +12%', maxLv: 5, effect: 'duration', per: 0.12 }, + { id: 'xpgain', name: '纳灵诀', desc: '灵晶价值 +8%', maxLv: 5, effect: 'xpGain', per: 0.08 }, +]; + +/** + * 难度表 D0–D5(§5.2「数值曲线抬升+词缀密度+波表压缩」;本期启用 D0/D1, + * D2–D5 为草案骨架〔词缀密度/波表压缩两轴随 M5 落地,locked 表示出击页暂锁〕)。 + */ +export const DIFFICULTY = { + d0: { name: '平潮', hpMul: 1.0, dpsMul: 1.0, rateMul: 1.0, speedMul: 1.0, locked: false }, + d1: { name: '涌潮', hpMul: 1.7, dpsMul: 1.75, rateMul: 1.55, speedMul: 1.15, locked: false }, + d2: { name: '怒潮', hpMul: 2.0, dpsMul: 1.5, rateMul: 1.6, speedMul: 1.1, locked: true }, + d3: { name: '狂潮', hpMul: 2.8, dpsMul: 1.8, rateMul: 2.0, speedMul: 1.15, locked: true }, + d4: { name: '噬潮', hpMul: 3.8, dpsMul: 2.2, rateMul: 2.5, speedMul: 1.2, locked: true }, + d5: { name: '灭潮', hpMul: 5.0, dpsMul: 2.7, rateMul: 3.1, speedMul: 1.25, locked: true }, +}; + /** 经验/等级曲线:升到 L+1 需要的灵晶价值 = base + step×(L-1)。 */ export const XP = { base: 5, - step: 4, - gemMagnetR: 60, // 基础磁吸半径(px;引灵纹被动放大) + step: 2, // M2:4→2(build 深度需要单局更多次三选一,进化线在 300s 局内可达;§8.1 单局升级次数锚随 M3 20 分钟波表再校) + gemMagnetR: 80, // 基础磁吸半径(px;引灵纹被动放大;M2 调:60→80,风筝路过可收) flyMin: 0.35, // 磁吸飞行最短时长(秒)——手 8:0.35–0.6s ease-in 到达 - flyMax: 0.6, // 磁吸飞行最长时长(秒) + flyMax: 0.6, // 磁吸飞行最长时长(秒;全图收割统一取此值) }; /** - * 敌表(M0 三种山海异兽,全 melee 接触伤;远程/词缀/Boss 归 M1+)。 + * 敌表(M2 仍为三种山海异兽;敌 32 种+词缀+Boss 归 M3/M5)。 * dps = 接触时对玩家的每秒伤害;gem = 掉落灵晶价值。 */ export const ENEMIES = { @@ -75,36 +282,40 @@ export const ENEMIES = { }; /** - * M0 波表:分段线性到达率 λ(t)(只升不降,对齐品类规格「同屏敌数单调不减」), - * mix = 各敌种权重(rng 按权重抽)。潮汐脉冲/精英词缀归 M1。 - * 校准锚:静止玩家(P0)须在 90 秒内被压死(§3.1 验收用例)。 + * 波表:分段线性到达率 λ(t)(只升不降,对齐品类规格「同屏敌数单调不减」), + * mix = 各敌种权重(rng 按权重抽);hpMul/speedMul = 该波出生个体的成长乘区 + * (§8.1 单位 HP(w)=H0×g^w 的表化;速度爬坡是反风筝压力源——后期快敌须能追上 + * 基础移速的玩家,否则纪律走位可无伤挂机,压力比断裂)。潮汐脉冲/精英词缀归 M3。 + * 校准锚:静止玩家(P0)须在 90 秒内被压死(§3.1);D0 前 60s 快敌速度 < 玩家基速(S2 锚)。 */ export const WAVES = [ - { t: 0, rate: 0.9, mix: { zheng: 1.0 } }, - { t: 25, rate: 1.6, mix: { zheng: 0.85, taowu: 0.15 } }, - { t: 60, rate: 2.2, mix: { zheng: 0.65, taowu: 0.35 } }, - { t: 110, rate: 2.8, mix: { zheng: 0.55, taowu: 0.33, qiongqi: 0.12 } }, - { t: 180, rate: 3.6, mix: { zheng: 0.5, taowu: 0.32, qiongqi: 0.18 } }, - { t: 240, rate: 4.4, mix: { zheng: 0.45, taowu: 0.33, qiongqi: 0.22 } }, + { t: 0, rate: 0.9, hpMul: 1.0, speedMul: 1.0, mix: { zheng: 1.0 } }, + { t: 25, rate: 1.8, hpMul: 1.15, speedMul: 1.05, mix: { zheng: 0.85, taowu: 0.15 } }, + { t: 60, rate: 2.8, hpMul: 1.3, speedMul: 1.12, mix: { zheng: 0.65, taowu: 0.35 } }, + { t: 110, rate: 3.8, hpMul: 1.5, speedMul: 1.2, mix: { zheng: 0.55, taowu: 0.33, qiongqi: 0.12 } }, + { t: 180, rate: 5.2, hpMul: 1.75, speedMul: 1.32, mix: { zheng: 0.5, taowu: 0.32, qiongqi: 0.18 } }, + { t: 240, rate: 6.5, hpMul: 2.0, speedMul: 1.42, mix: { zheng: 0.45, taowu: 0.33, qiongqi: 0.22 } }, ]; -/** 同屏活敌上限(M0 玩法帧预算的逻辑闸;压测另行合成 600,见 stress-test)。 */ +/** 同屏活敌上限(玩法帧预算的逻辑闸;压测另行合成 600,见 stress-test)。 */ export const MAX_ALIVE = 220; +/** 同场区域(zone)实体上限(盾环/桩阵合计;超出丢最旧,防被动叠出失控)。 */ +export const MAX_ZONES = 40; + /** 出怪环半径(px):以玩家为圆心、视口对角线外(390×844 对角 ≈465),怪从屏外进场。 */ export const SPAWN_R = 520; -/** 空间网格 cell(px):≈ 2×最大敌半径(穷奇 r20)+余量;索敌/分离/弹命中三消费方共享。 */ +/** 空间网格 cell(px):≈ 2×最大敌半径(穷奇 r20)+余量;索敌/分离/弹命中/区域结算共享。 */ export const GRID_CELL = 48; /** 敌-敌软分离:邻域上限 8(O(n) 软推挤,不做全对碰撞——设计书 §7.3)。 */ export const SEPARATION = { neighborCap: 8, push: 0.5 }; /** - * 手感反馈档位表(M1;hit-feedback 插件直接吃表——设计书 §6 手 4/5/6/7/10 的量值面)。 - * 全部量值为首版草案〔M1 真机校准后锁〕;推导基准=帧级反馈惯例(30ms≈2帧@60fps、闪白 80ms≈5帧)。 - * 注:crit/bossKill 两档的**事件源**分别随 M2 暴击轴、M3 Boss 落地——档位先在表上钉死量值, - * 接线断言已在插件级机验,游戏侧事件源落地即通(如实分层,不称「手 4 全链已验」)。 + * 手感反馈档位表(hit-feedback 插件直接吃表——设计书 §6 手 4/5/6/7/10 的量值面)。 + * 全部量值为首版草案〔真机校准后锁〕;推导基准=帧级反馈惯例(30ms≈2帧@60fps、闪白 80ms≈5帧)。 + * M2:crit 档事件源随暴击轴落地(破煞诀/天雷引);harvest 档随聚灵珠落地;bossKill 事件源仍归 M3。 */ export const FEEDBACK = { mergeWindowMs: 500, // 手 4:顿帧 0.5s 窗口内合并(防连锁卡顿) @@ -113,12 +324,13 @@ export const FEEDBACK = { textTtlMs: 700, // 飘字寿命 tiers: { hit: { text: true }, // 普通命中:顿帧 0(手 4)+ 飘字 - crit: { hitStopMs: 30, text: true }, // 暴击 30ms(事件源随 M2) + crit: { hitStopMs: 30, text: true }, // 暴击 30ms(M2 事件源已落:破煞诀/天雷引) bossKill: { hitStopMs: 80, shakeAmpPx: 8, shakeMs: 150 }, // Boss 击杀 80ms(事件源随 M3) kill: { sfx: 'hit' }, // 击杀:音效;爆浆粒子走 juice.burst playerHurt: { shakeAmpPx: 5, shakeMs: 150, flashMs: 80, flashPeak: 0.3, sfx: 'hurt' }, // 手 5:受击 4–6px 取 5 pickup: { sfx: 'pickup' }, levelup: { sfx: 'powerup' }, + harvest: { flashMs: 80, flashPeak: 0.2, sfx: 'powerup' }, // 聚灵珠:全图磁吸的仪式感(手 6 族闪屏惯例 ≤80ms) }, /** 手 6:死亡爆浆粒子 8–14/只(池化在 juice maxParticles;数量按 eid 确定性取带内值)。 */ burstPerKill: { min: 8, max: 14 }, diff --git a/game-runtime/games/shanhai-xingji/src/build-model.js b/game-runtime/games/shanhai-xingji/src/build-model.js new file mode 100644 index 00000000..ca34c1be --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/build-model.js @@ -0,0 +1,130 @@ +/** + * build-model.js — 《山海行纪》build 派生模型(纯函数 · 零状态 · node 直跑) + * owner:北极星顶级线② | 消费方:sim.js(局内派生)/ test/build-tables.test.mjs(等级系数与 + * 进化跃迁带断言)/ scripts/sim-scan.mjs(参数扫描的理论面报告) + * + * 【定位】balance.js 是数据,本文件是数据的**唯一解释器**:武器 12×8 级 × 进化 × 被动 14 的 + * 派生数值(deriveWeapon / derivePlayer)与理论 DPS 模型(canonicalDps)都只在这里实现—— + * sim 与验收测试消费同一份语义,杜绝「表改了、门没跟着改」的双写漂移。 + * + * 【理论 DPS 口径(设计书 §3.1 进化跃迁带 1.8–2.5 的机验模型)】 + * 单目标持续输出模型,被动全零、只含武器自有乘区: + * - proj 族:dmg × 弹数 / 间隔 × 暴击期望(innate critAdd,×CRIT.mul); + * - zone 族:dmg / tick × 驻场占空比 min(1, life/cycle) × 暴击期望(单触点模型: + * 加环/加桩是群体收益,不进单目标账); + * - dot(灼烧/溺蚀):+dot.dps(持续命中视作全程续燃,不吃暴击); + * - chain/explode/slow/mirror/gemPull 是群体/防御/经济收益,不进单目标账(表注明)。 + */ + +'use strict'; + +import { CRIT, PASSIVES, WEAPONS } from './balance.js'; + +/** 被动效果 → per 值查表(effect 语义见 balance.PASSIVES 注释)。 */ +const PASSIVE_BY_EFFECT = {}; +for (const p of PASSIVES) PASSIVE_BY_EFFECT[p.effect] = p; +const PASSIVE_BY_ID = {}; +for (const p of PASSIVES) PASSIVE_BY_ID[p.id] = p; + +/** 读某效果轴的合成值:base + per×级(加法轴)或 1±per×级(乘区轴由调用处拼)。 */ +function lvOf(plv, effect) { + const p = PASSIVE_BY_EFFECT[effect]; + if (!p) return 0; + return (plv && plv[p.id]) | 0; +} + +/** + * 玩家侧派生(被动 14 里作用于玩家/全局的轴)。 + * @param {Record} plv 被动等级表(id → 级;缺省 0) + */ +export function derivePlayer(plv) { + const P = PASSIVE_BY_EFFECT; + return { + speedMul: 1 + P.move.per * lvOf(plv, 'move'), + magnetMul: 1 + P.magnet.per * lvOf(plv, 'magnet'), + /** 受伤减免(0..1;接触伤 ×(1−armor))。 */ + armor: P.armor.per * lvOf(plv, 'armor'), + /** 每秒回复(绝对值)。 */ + regen: P.regen.per * lvOf(plv, 'regen'), + /** 基础暴击率(武器 innate critAdd 另加)。 */ + critBase: P.crit.per * lvOf(plv, 'crit'), + /** 灵晶价值乘区。 */ + xpMul: 1 + P.xpGain.per * lvOf(plv, 'xpGain'), + /** 每轮追加一发概率(proj 与 zone 族同语义)。 */ + extraChance: P.extraChance.per * lvOf(plv, 'extraChance'), + }; +} + +/** + * 武器派生(等级行 + 进化 mods + 被动乘区 → sim 热路径直读的扁平数值包)。 + * @param {object} def balance.WEAPONS 元素 + * @param {number} lv 1..maxLv + * @param {boolean} evolved 已进化 + * @param {Record} plv 被动等级表 + */ +export function deriveWeapon(def, lv, evolved, plv) { + const b = def.base; + const row = def.levels[Math.max(0, Math.min(def.maxLv, lv) - 1)]; + const m = (evolved && def.evo && def.evo.mods) || {}; + const P = PASSIVE_BY_EFFECT; + const areaMul = (1 + P.area.per * lvOf(plv, 'area')); + const durMul = (1 + P.duration.per * lvOf(plv, 'duration')); + const psMul = (1 + P.projSpeed.per * lvOf(plv, 'projSpeed')); + const dmg = b.dmg * row.dm * (m.dmgMul || 1) * (1 + P.dmg.per * lvOf(plv, 'dmg')); + const isZone = def.kind === 'zone'; + const d = { + id: def.id, family: def.family, kind: def.kind, + dmg, + count: b.count + (row.ca || 0) + (m.countAdd || 0), + /** proj=发射间隔(疾雷咒);zone=循环间隔(凝息诀)。 */ + interval: b.interval * (m.intervalMul || 1) * (isZone + ? (1 - P.cycle.per * lvOf(plv, 'cycle')) + : (1 - P.interval.per * lvOf(plv, 'interval'))), + life: (b.life || 0) * (m.lifeMul || 1) * durMul, + critAdd: (b.critAdd || 0) + (m.critAdd || 0), + /** 机制字段(进化优先覆盖同名基础机制)。 */ + slow: m.slow || b.slow || null, + chain: m.chain || b.chain || null, + mirror: !!m.mirror, + gemPull: !!m.gemPull, + }; + if (isZone) { + d.tick = b.tick * (m.tickMul || 1); + d.zoneR = b.zoneR * (m.zoneRMul || 1) * areaMul; + d.orbitR = (b.orbitR || 0) * (m.orbitRMul || 1); + d.orbitAv = (b.orbitAv || 0) * psMul; + } else { + d.projSpeed = b.projSpeed * psMul; + d.projR = b.projR * (m.projRMul || 1) * areaMul; + d.pierce = b.pierce + (m.pierceAdd || 0) + PASSIVE_BY_EFFECT.pierce.per * lvOf(plv, 'pierce'); + d.searchR = b.searchR || 0; // nearest/random 族索敌半径(movedir 族无此键=0,按走位方向射) + } + const ex = (m.explode || b.explode) || null; + d.explode = ex ? { r: ex.r * areaMul, dmgPct: ex.dmgPct } : null; + d.dot = (m.dot || b.dot) ? { dps: dmg * (m.dot || b.dot).dpsPct, s: (m.dot || b.dot).s } : null; + return d; +} + +/** + * 理论单目标 DPS(口径见文件头;被动全零)。 + * @returns {number} + */ +export function canonicalDps(def, lv, evolved) { + const d = deriveWeapon(def, lv, evolved, {}); + const critE = 1 + Math.min(CRIT.chanceCap, d.critAdd) * (CRIT.mul - 1); + let dps; + if (def.kind === 'zone') { + const uptime = Math.min(1, d.life / d.interval); + dps = d.dmg / d.tick * uptime * critE; + } else { + dps = d.dmg * d.count / d.interval * critE; + } + if (d.dot) dps += d.dot.dps; + return dps; +} + +/** 武器表按 id 索引(消费方免重复建索引)。 */ +export const WEAPON_BY_ID = {}; +for (const w of WEAPONS) WEAPON_BY_ID[w.id] = w; + +export { PASSIVE_BY_ID, PASSIVE_BY_EFFECT }; diff --git a/game-runtime/games/shanhai-xingji/src/game-logic.js b/game-runtime/games/shanhai-xingji/src/game-logic.js index 4e511d7a..d8e5a322 100644 --- a/game-runtime/games/shanhai-xingji/src/game-logic.js +++ b/game-runtime/games/shanhai-xingji/src/game-logic.js @@ -2,29 +2,33 @@ * game-logic.js — 《山海行纪》游戏本体(GameInstance:场景流 + 摇杆输入 + HUD/弹层 + 取证) * owner:北极星顶级线② | 消费方:game.js(L1 wrapper) * - * 【分层】本文件是「壳」:场景流(menu→play→over)、摇杆接线(核在 virtual-joystick 插件,手 1–3)、 - * 三选一弹层(canvas-ui-kit cardPicker)、反馈泵(sim 事件 → hit-feedback 档位表)、 + * 【分层】本文件是「壳」:场景流(menu→run)、摇杆接线(核在 virtual-joystick 插件,手 1–3)、 + * 三选一弹层(canvas-ui-kit cardPicker)、出击页(canvas-ui-kit scrollList,M2)、 + * 暂停 build 总览(§11:武器 6 槽+被动 6 槽+本局统计)、反馈泵(sim 事件 → hit-feedback 档位表)、 * 取证契约(_forensicsView / _handleAction)。玩法全部在 sim.js(纯逻辑);绘制全部在 render.js。 * - * 【M1 接线纪律】插件实例经宿主装配注入(plugins.vjoy/uiKit/hitFeedback/juice); - * 未注入时回退直建纯核(同一实现文件,单测 plugins:{} 场景不降能力)—— - * 注入态下 vjoy.core 与本壳共用同一颗核,插件的轨迹 driver 注入即驱真游戏(G_input 路)。 + * 【M2 接线增量】 + * - offer 自描述:sim.readState().offer 直接携带 name/desc/badge(武器/被动/进化统一),壳层零表查; + * - 手 4 事件源:sim 吐 crit 事件 → hit-feedback 'crit' 档(30ms 顿帧+异色飘字); + * - 手 6/8 事件源:聚灵珠 harvest 事件 → 'harvest' 档(闪屏+音效;全图磁吸在 sim 内完成); + * - 暂停:壳层冻结(不推 sim.update;sim 的 t 因此不前进),build 总览面板 + 恢复钮; + * - 出击页:难度 D0/D1 可选(D2+ 表内 locked),滚动列表可滚可命中(触发器②第三项)。 * * 【取证契约(九门/e2e)】 - * - state():phase(menu|play|levelup|win|lose)/ score(击杀,随进展单调上升)/ targets(可点目标: - * menu=开始钮、levelup=三张卡、终态=重开钮;play 期为空列——摇杆连续输入走轨迹 driver); - * - measures():帧/输入/事件计数 + joystick 探针 + **events[]**(hit-feedback 取证: - * eventName/frame/timeMs/sourceId/visualEventFrame/sfxStartFrame,手 10 音画同帧断言的数据源); - * - _handleAction:start / restart / choose{i} / move{x,y}(e2e 绕坐标确定性驱动)。 + * - state():phase(menu|play|levelup|paused|win|lose)/ score(击杀,随进展单调上升)/ + * targets(可点目标:menu=开始钮、levelup=三张卡、paused=恢复钮、终态=重开钮); + * - measures():帧/输入/事件计数 + joystick 探针 + events[](hit-feedback 取证)+ + * menu 探针(scrollY/maxScroll/selDifficulty,出击页断言面); + * - _handleAction:start / restart / choose{i} / move{x,y} / pause / resume。 */ 'use strict'; import { createSim } from './sim.js'; -import { JOYSTICK, UPGRADES, ROUND_S, FEEDBACK } from './balance.js'; -import { drawGame, layoutButton } from './render.js'; +import { JOYSTICK, ROUND_S, FEEDBACK, DIFFICULTY } from './balance.js'; +import { drawGame, layoutButton, layoutPauseButton, menuLayout } from './render.js'; import { createJoystickCore } from '../../../src/plugins/virtual-joystick/impl.js'; -import { createCardPicker } from '../../../src/plugins/canvas-ui-kit/impl.js'; +import { createCardPicker, createScrollList } from '../../../src/plugins/canvas-ui-kit/impl.js'; /** * 建游戏实例(L3)。 @@ -37,8 +41,10 @@ export function createGame(opts) { const hf = plugins.hitFeedback || null; // 手感反馈+取证(缺席=事件面静默降级) const juice = plugins.juice || null; // 粒子/顿帧/震屏/闪白执行后端 - /** 壳层场景:menu | run(run 内 phase 由 sim 出:play/levelup/win/lose)。 */ + /** 壳层场景:menu(出击页)| run(run 内 phase 由 sim 出:play/levelup/win/lose)。 */ let scene = 'menu'; + /** 壳层暂停(只在 sim.phase==='play' 时可进;冻结=不推 sim.update)。 */ + let paused = false; /** @type {ReturnType|null} 当前局(进 run 时新建;seed 来自 bundle.seed)。 */ let sim = null; let runSeed = 0x51AB37; @@ -62,6 +68,32 @@ export function createGame(opts) { }, }); + /* ── 出击页(M2:一页 meta;角色 8/地图 5 的解锁体系归 M4/M5,此处按 v1 现实呈现)── */ + /** 难度选择(D0/D1 可选;D2–D5 表内 locked)。 */ + let selDifficulty = 'd0'; + /** 出击页行模型(header 不可点;char/map v1 各一件解锁;diff 按表)。 */ + const menuRows = buildMenuRows(); + function buildMenuRows() { + const rows = []; + rows.push({ type: 'header', label: '巡使' }); + rows.push({ type: 'char', name: '持符巡山使 · 雷符链', locked: false, selected: true }); + for (let i = 0; i < 7; i++) rows.push({ type: 'char', name: '未授箓', locked: true }); + rows.push({ type: 'header', label: '巡域' }); + rows.push({ type: 'map', name: '图一 · 裂谷原', locked: false, selected: true }); + for (let i = 0; i < 4; i++) rows.push({ type: 'map', name: '未探明', locked: true }); + rows.push({ type: 'header', label: '潮汛' }); + for (const key of Object.keys(DIFFICULTY)) { + rows.push({ type: 'diff', key, name: `${key.toUpperCase()} · ${DIFFICULTY[key].name}`, locked: DIFFICULTY[key].locked }); + } + return rows; + } + /** 滚动列表(canvas-ui-kit v1.1;拖=滚 / 点=选)。 */ + const menuList = (plugins.uiKit && plugins.uiKit.makeScrollList ? plugins.uiKit.makeScrollList.bind(plugins.uiKit) : createScrollList)({ + rect: menuLayout(viewport).listRect, + rowH: menuLayout(viewport).rowH, + count: menuRows.length, + }); + /** 键盘移动(桌面调试):按键集合。 */ const keys = { up: false, down: false, left: false, right: false }; @@ -69,21 +101,29 @@ export function createGame(opts) { const fx = { list: [], best: 0 }; /** 度量(取证 measures)。 */ - const measures = { frames: 0, pointerDowns: 0, chooses: 0, restarts: 0 }; + const measures = { frames: 0, pointerDowns: 0, chooses: 0, restarts: 0, pauses: 0 }; let inited = false; function startRun() { - sim = createSim({ seed: runSeed }); + sim = createSim({ seed: runSeed, difficulty: selDifficulty }); scene = 'run'; + paused = false; joyCore.reset(); fx.list.length = 0; if (hf && hf.resetForensics) hf.resetForensics(); // 取证按局复位(events[] 只含本局) } + function setPaused(v) { + if (!sim || sim.phase !== 'play') return; + if (v === paused) return; + paused = v; + if (paused) { measures.pauses++; joyCore.reset(); if (sim) sim.setMove(0, 0); } // 松杆,恢复后重新按 + } + /** 键盘合成方向 → sim(与摇杆互斥:摇杆活跃时键盘让位)。 */ function applyKeys() { - if (joyCore.vector().active || !sim) return; + if (joyCore.vector().active || !sim || paused) return; const x = (keys.right ? 1 : 0) - (keys.left ? 1 : 0); const y = (keys.down ? 1 : 0) - (keys.up ? 1 : 0); sim.setMove(x, y); @@ -94,6 +134,9 @@ export function createGame(opts) { if (!hf) return; if (ev.type === 'hit') { hf.feedback('hit', { x: ev.x, y: ev.y, sourceId: ev.eid, value: Math.round(ev.dmg) }); + } else if (ev.type === 'crit') { + // 手 4:暴击档(30ms 顿帧;飘字 crit 标记 → 渲染 ×1.5 异色 = 手 7) + hf.feedback('crit', { x: ev.x, y: ev.y, sourceId: ev.eid, value: Math.round(ev.dmg), crit: true }); } else if (ev.type === 'kill') { hf.feedback('kill', { x: ev.x, y: ev.y, sourceId: ev.eid }); if (juice && juice.burst && sim) { @@ -109,6 +152,9 @@ export function createGame(opts) { hf.feedback('pickup', { x: ev.x, y: ev.y }); } else if (ev.type === 'levelup') { hf.feedback('levelup', { x: ev.x, y: ev.y }); + } else if (ev.type === 'harvest') { + // 手 6 族闪屏 + 手 8 全图磁吸的仪式感档(磁吸本体在 sim 内已完成) + hf.feedback('harvest', { x: ev.x, y: ev.y }); } } @@ -119,9 +165,8 @@ export function createGame(opts) { if (!inited) return; measures.frames++; picker.update(dt * 1000); // 弹层动画钟(帧驱动;手 9 ≤250ms) - if (scene !== 'run' || !sim) return; - // 顿帧:juice.timeScale 缩放世界 dt(手 4;当前档位表内只有 crit/bossKill 带顿帧, - // 其事件源随 M2/M3 内容落地——今日 ts 恒 1,不改 M0 行为) + if (scene !== 'run' || !sim || paused) return; // 暂停=壳层冻结(sim.t 不前进) + // 顿帧:juice.timeScale 缩放世界 dt(手 4;crit 档 M2 已接事件源,bossKill 随 M3) const ts = (juice && juice.getTimeScale) ? juice.getTimeScale() : 1; sim.update(dt * ts); // 手感事件 → 渲染特效队列 + 反馈泵(hit-feedback 档位表) @@ -138,10 +183,7 @@ export function createGame(opts) { const ph0 = sim.phase; if (ph0 === 'levelup' && !picker.state().visible) { const s = sim.readState(); - picker.open(s.offer.map((o) => { - const up = UPGRADES[o.i]; - return { title: up.name, desc: up.desc, badge: `Lv${(s.upgrades[up.id] || 0) + 1}` }; - })); + picker.open(s.offer.map((o) => ({ title: o.name, desc: o.desc, badge: o.badge }))); } else if (ph0 !== 'levelup' && picker.state().visible && !picker.state().closing) { picker.close(); } @@ -154,26 +196,50 @@ export function createGame(opts) { }, render(g) { - drawGame(g, { scene, sim, joy: joyCore.vector(), fx, viewport, best: bestKills(), hf, juice, picker }); + drawGame(g, { + scene, sim, joy: joyCore.vector(), fx, viewport, best: bestKills(), hf, juice, picker, + paused, menu: { rows: menuRows, list: menuList, selDifficulty }, + }); }, onResize() { /* 固定逻辑视口 390×844,缩放由页面 CSS 承担 */ }, - destroy() { sim = null; scene = 'menu'; }, + destroy() { sim = null; scene = 'menu'; paused = false; }, /* ── 输入(L1 wrapper 路由;各 phase 自行判定)── */ - /** 三事件路由入口:down=定摇杆原点/点 UI,move=拖杆,up=松杆(杆核在 virtual-joystick)。 */ + /** 三事件路由入口:down=定摇杆原点/点 UI,move=拖杆/拖列表,up=松杆/列表消歧。 */ handlePointer(type, x, y) { if (!inited) return; - if (type === 'down') { - measures.pointerDowns++; - if (scene === 'menu') { - const btn = layoutButton(viewport, 'start'); - if (hit(btn, x, y)) startRun(); + if (type === 'down') measures.pointerDowns++; + + /* 出击页:滚动列表三事件 + 开巡钮 */ + if (scene === 'menu') { + const r = menuList.pointer(type, x, y); + if (r && r.kind === 'tap') { + const row = menuRows[r.index]; + if (row && row.type === 'diff' && !row.locked) selDifficulty = row.key; // 点潮汛行=切难度 return; } - const ph = sim ? sim.phase : 'play'; + if (type === 'down') { + const btn = layoutButton(viewport, 'start'); + if (hit(btn, x, y)) startRun(); + } + return; + } + + const ph = sim ? sim.phase : 'play'; + + /* 暂停面板:只认恢复钮(点面板外也不恢复,防误触) */ + if (paused) { + if (type === 'down') { + const btn = layoutButton(viewport, 'resume'); + if (hit(btn, x, y)) setPaused(false); + } + return; + } + + if (type === 'down') { if (ph === 'levelup') { // 手 9:卡片单击直选(picker 恒按终态几何命中=弹出即可点);点非卡区不消费(冻结维持) const i = picker.hitTest(x, y); @@ -185,7 +251,9 @@ export function createGame(opts) { if (hit(btn, x, y)) { measures.restarts++; runSeed = (runSeed + 0x9E3779B9) >>> 0; startRun(); } return; } - // play:按下即定摇杆原点(浮动原点=触点,手 1;核内完成死区/越界跟手) + // play:先查暂停钮(HUD 右上),否则按下即定摇杆原点(浮动原点=触点,手 1) + const pb = layoutPauseButton(viewport); + if (hit(pb, x, y)) { setPaused(true); return; } joyCore.pointer('down', x, y); } else if (type === 'move') { if (scene === 'run' && sim && sim.phase === 'play') joyCore.pointer('move', x, y); @@ -198,6 +266,8 @@ export function createGame(opts) { handleKey(key, down) { const k = String(key || '').toLowerCase(); if (down && scene === 'menu' && (k === ' ' || k === 'enter')) { startRun(); return; } + if (down && sim && (k === 'p' || k === 'escape')) { setPaused(!paused); return; } + if (paused) return; if (down && sim && (sim.phase === 'win' || sim.phase === 'lose') && (k === ' ' || k === 'enter')) { runSeed = (runSeed + 0x9E3779B9) >>> 0; startRun(); return; } @@ -216,14 +286,17 @@ export function createGame(opts) { _forensicsView() { return { state: () => { - const base = { phase: scene === 'menu' ? 'menu' : (sim ? sim.phase : 'play'), best: bestKills(), roundS: ROUND_S }; + const base = { phase: scene === 'menu' ? 'menu' : (paused ? 'paused' : (sim ? sim.phase : 'play')), best: bestKills(), roundS: ROUND_S }; if (scene === 'menu' || !sim) { const btn = layoutButton(viewport, 'start'); return { ...base, score: 0, targets: [{ x: btn.x + btn.w / 2, y: btn.y + btn.h / 2, kind: 'start', occupied: true }] }; } const s = sim.readState(); let targets = []; - if (s.phase === 'levelup') { + if (paused) { + const btn = layoutButton(viewport, 'resume'); + targets = [{ x: btn.x + btn.w / 2, y: btn.y + btn.h / 2, kind: 'resume', occupied: true }]; + } else if (s.phase === 'levelup') { // 几何唯一事实源 = picker.layout()(与命中判定同源;弹出即可点=手 9) const cards = picker.layout(); targets = cards.map((c, i) => ({ x: c.x + c.w / 2, y: c.y + c.h / 2, kind: 'card', i, id: s.offer[i] && s.offer[i].id, occupied: true })); @@ -231,14 +304,14 @@ export function createGame(opts) { const btn = layoutButton(viewport, 'restart'); targets = [{ x: btn.x + btn.w / 2, y: btn.y + btn.h / 2, kind: 'restart', occupied: true }]; } - return { ...base, ...s, targets }; + return { ...base, ...s, phase: base.phase, targets }; }, - // measures:M0 计数 + 摇杆探针 + hit-feedback 取证 events[](书面 schema: - // eventName/frame/timeMs/sourceId,并带 visualEventFrame/sfxStartFrame 供手 10 断言) + // measures:计数 + 摇杆探针 + hit-feedback 取证 events[] + 出击页探针(触发器②断言面) measures: () => ({ ...measures, joystick: joyCore.probe(), events: hf ? hf.eventsSnapshot() : [], + menu: { ...menuList.probe(), selDifficulty, rows: menuRows.length }, }), }; }, @@ -247,11 +320,13 @@ export function createGame(opts) { _handleAction(action) { if (!action || !inited) return; if (action.type === 'start' && scene === 'menu') startRun(); - else if (action.type === 'restart' && sim && (sim.phase === 'win' || sim.phase === 'lose')) { runSeed = (runSeed + 0x9E3779B9) >>> 0; startRun(); } - else if (action.type === 'choose' && sim && sim.phase === 'levelup') { + else if (action.type === 'restart' && sim && !paused && (sim.phase === 'win' || sim.phase === 'lose')) { runSeed = (runSeed + 0x9E3779B9) >>> 0; startRun(); } + else if (action.type === 'choose' && sim && !paused && sim.phase === 'levelup') { if (sim.choose(action.i | 0)) { measures.chooses++; picker.close(); } } - else if (action.type === 'move' && sim) sim.setMove(Number(action.x) || 0, Number(action.y) || 0); + else if (action.type === 'move' && sim && !paused) sim.setMove(Number(action.x) || 0, Number(action.y) || 0); + else if (action.type === 'pause') setPaused(true); + else if (action.type === 'resume') setPaused(false); }, }; diff --git a/game-runtime/games/shanhai-xingji/src/render.js b/game-runtime/games/shanhai-xingji/src/render.js index 70902e7e..88dc12fb 100644 --- a/game-runtime/games/shanhai-xingji/src/render.js +++ b/game-runtime/games/shanhai-xingji/src/render.js @@ -1,15 +1,18 @@ /** - * render.js — 《山海行纪》M0 渲染层(canvas 2D 占位美术,山海彩绘色板) + * render.js — 《山海行纪》M2 渲染层(canvas 2D 占位美术,山海彩绘色板) * owner:北极星顶级线② | 消费方:game-logic.js(render 委托) * - * 【定位】M0 全部用程序化占位形状(色块/描边/简单图腾),风格版样张(M3 眼验)前不铺真美术; - * 但色板即定调:敦煌彩绘系——暗底 + 赭石/石青/石绿/描金(设计书 §4 候选 B 视觉意象)。 + * 【定位】占位期全部用程序化形状(色块/描边/简单图腾),风格版样张(M3 眼验)前不铺真美术; + * 色板即定调:敦煌彩绘系——暗底 + 赭石/石青/石绿/描金(设计书 §4 候选 B 视觉意象)。 * 本层只画不算:一切状态来自 sim.view()(只读引用,热路径零拷贝)。 + * 【M2 增量】出击页(滚动列表行绘制)/ 暂停 build 总览(武器 6 槽+被动 6 槽+本局统计,§11)/ + * 区域实体(盾环轨道珠/驻场符阵)/ 聚灵珠 / HUD 暂停钮与 Boss 血条位(真 Boss 归 M3, + * 血条渲染面先落,_debugFill 可注入预览)/ 投射物按武器分色。 */ 'use strict'; -import { JOYSTICK, ROUND_S } from './balance.js'; +import { JOYSTICK, ROUND_S, DIFFICULTY, SLOTS, PASSIVES } from './balance.js'; /** 山海彩绘色板(占位期唯一取色处)。 */ const C = { @@ -21,34 +24,60 @@ const C = { taowu: '#2B5F8A', // 石青(梼杌) qiongqi: '#3F7A5C', // 石绿(穷奇) gem: '#7FD1C0', // 灵晶 - proj: '#F2C14E', // 雷符 + proj: '#F2C14E', // 雷符(缺省弹色) hud: '#E8DCC0', hudDim: 'rgba(232,220,192,0.55)', hpBack: '#40231c', hp: '#C6543A', xpBack: '#1e2a24', xp: '#7FD1C0', panel: 'rgba(20,16,9,0.88)', panelEdge: '#D8A03E', white: '#FFF6E5', + locked: 'rgba(232,220,192,0.28)', + harvest: '#9FE8D8', // 聚灵珠 }; -/** 结算/菜单主按钮布局(kind: 'start' | 'restart')。 */ +/** 投射物/区域按武器分色(占位:五行大类取色;未列走缺省描金)。 */ +const WCOLOR = { + leifu: '#F2C14E', jinge: '#E8DCC0', hanyuan: '#8FD0E8', + fentian: '#E8703A', xinghuo: '#F2A25C', tianlei: '#FFE98A', + fengren: '#BFE8A0', poyue: '#C9A87A', + shanzi: '#D8A03E', xuanming: '#6FA8D8', + fuling: '#B08AE8', zhendi: '#C97A5A', +}; + +/** 结算/菜单主按钮布局(kind: 'start' | 'restart' | 'resume')。 */ export function layoutButton(viewport, kind) { const w = 220, h = 64; - const y = kind === 'start' ? viewport.h * 0.62 : viewport.h * 0.66; + const y = kind === 'start' ? viewport.h * 0.78 + : kind === 'resume' ? viewport.h * 0.735 + : viewport.h * 0.66; return { x: (viewport.w - w) / 2, y, w, h, kind }; } +/** HUD 暂停钮(右上,顶栏之下;play 期可点=进 build 总览)。 */ +export function layoutPauseButton(viewport) { + return { x: viewport.w - 50, y: 50, w: 36, h: 36 }; +} + +/** 出击页布局(滚动列表几何的唯一事实源;game-logic 建 scrollList 与此同源)。 */ +export function menuLayout(viewport) { + return { + titleY: viewport.h * 0.09, + listRect: { x: 20, y: 150, w: viewport.w - 40, h: 440 }, + rowH: 52, + }; +} + /** * 主绘制入口。 * @param {CanvasRenderingContext2D} g * @param {{scene:string, sim:object|null, joy:object, fx:object, viewport:{w:number,h:number}, - * best:number, hf?:object|null, juice?:object|null, picker?:object|null}} s - * - joy:virtual-joystick 核的 vector() 快照(active/originX/originY/knobDx/knobDy); - * - hf:hit-feedback 实例(飘字数据源);juice:粒子/震屏/闪白后端;picker:三选一弹层。 + * best:number, hf?:object|null, juice?:object|null, picker?:object|null, + * paused?:boolean, menu?:{rows:Array, list:object, selDifficulty:string}}} s */ export function drawGame(g, s) { const { viewport } = s; g.save(); - // 底色(整屏重涂,палette-post 暗角叠加在 host 管线后段) + // 底色(整屏重涂,palette-post 暗角叠加在 host 管线后段) g.fillStyle = C.bg; g.fillRect(0, 0, viewport.w, viewport.h); @@ -64,20 +93,25 @@ export function drawGame(g, s) { const camY = v.player.y - viewport.h / 2; drawGround(g, camX, camY, viewport); + drawZones(g, v, camX, camY); drawGems(g, v, camX, camY); + drawDrops(g, v, camX, camY); drawEnemies(g, v, camX, camY); drawProjectiles(g, v, camX, camY); drawPlayer(g, v, viewport); - drawFx(g, s.fx, camX, camY); + drawFx(g, s.fx, camX, camY, viewport); // 击杀爆浆等粒子 + 屏级闪白(juice.render 一并画;在 HUD 之下保持信息可读) if (s.juice && s.juice.render) s.juice.render(g); // 伤害飘字(手 7:hit-feedback 聚合数据,这里只负责画) if (s.hf && s.hf.texts) drawFloatTexts(g, s.hf.texts(), camX, camY); drawHud(g, v, viewport, s.best); - if (s.joy && s.joy.active) drawJoystick(g, s.joy); + if (!s.paused && s.joy && s.joy.active) drawJoystick(g, s.joy); - // 三选一弹层(手 9:组件恒按终态几何命中;开合动画只作用视觉) - if (s.picker && s.picker.state().visible) { + // 暂停 build 总览(§11:武器 6 槽+被动 6 槽+本局统计;壳层冻结期唯一交互=恢复钮) + if (s.paused) { + drawPauseOverview(g, v, viewport); + } else if (s.picker && s.picker.state().visible) { + // 三选一弹层(手 9:组件恒按终态几何命中;开合动画只作用视觉) drawLevelupTitle(g, v, s.picker, viewport); s.picker.draw(g); } else if (v.phase === 'win' || v.phase === 'lose') { @@ -106,7 +140,6 @@ function drawFloatTexts(g, texts, camX, camY) { function drawLevelupTitle(g, v, picker, viewport) { const rects = picker.layout(); if (!rects.length) return; - g.fillStyle = 'rgba(10,8,4,0.0)'; // 遮罩由 picker 画;此处只出标题 g.fillStyle = C.hud; g.font = 'bold 24px system-ui, sans-serif'; g.textAlign = 'center'; @@ -156,6 +189,17 @@ function drawEnemies(g, v, camX, camY) { const col = e.type === 'zheng' ? C.zheng : (e.type === 'taowu' ? C.taowu : C.qiongqi); g.fillStyle = e.hurtFlash > 0 ? C.white : col; // 手 6:受击闪白 ≤80ms(sim.hurtFlash 递减) g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill(); + // 减速态:环形描边(寒渊/盾环机制的可读性) + if (e.slowT > 0) { + g.strokeStyle = WCOLOR.hanyuan; + g.lineWidth = 1.5; + g.beginPath(); g.arc(x, y, e.r + 2.5, 0, Math.PI * 2); g.stroke(); + } + // 灼烧态:头顶火点 + if (e.dotT > 0) { + g.fillStyle = '#E8703A'; + g.beginPath(); g.arc(x, y - e.r - 4, 2.4, 0, Math.PI * 2); g.fill(); + } // 图腾眼(占位:两点) g.fillStyle = C.bg; g.beginPath(); g.arc(x - e.r * 0.35, y - e.r * 0.2, e.r * 0.14, 0, Math.PI * 2); g.fill(); @@ -170,12 +214,13 @@ function drawEnemies(g, v, camX, camY) { } function drawProjectiles(g, v, camX, camY) { - g.strokeStyle = C.proj; g.lineWidth = 3; for (let i = 0; i < v.projs.length; i++) { const p = v.projs[i]; const x = p.x - camX, y = p.y - camY; - // 雷符占位:短尾线段(沿速度反向) + if (x < -30 || y < -30 || x > 420 || y > 874) continue; + // 符弹占位:短尾线段(沿速度反向)+ 按武器分色 + g.strokeStyle = WCOLOR[p.wid] || C.proj; const d = Math.hypot(p.vx, p.vy) || 1; g.beginPath(); g.moveTo(x - (p.vx / d) * 10, y - (p.vy / d) * 10); @@ -184,6 +229,37 @@ function drawProjectiles(g, v, camX, camY) { } } +/** 区域实体:orbit=轨道符珠(实心);place=驻场符阵(半透明大环+中心桩,按 tick 呼吸)。 */ +function drawZones(g, v, camX, camY) { + for (let i = 0; i < v.zones.length; i++) { + const z = v.zones[i]; + const x = z.x - camX, y = z.y - camY; + const col = WCOLOR[z.wid] || C.proj; + if (z.kind === 'orbit') { + if (x < -30 || y < -30 || x > 420 || y > 874) continue; + g.fillStyle = col; + g.globalAlpha = 0.9; + g.beginPath(); g.arc(x, y, Math.min(z.wd.zoneR, 14), 0, Math.PI * 2); g.fill(); + g.globalAlpha = 0.25; + g.beginPath(); g.arc(x, y, z.wd.zoneR, 0, Math.PI * 2); g.fill(); + g.globalAlpha = 1; + } else { + const R = z.wd.zoneR; + if (x < -R - 20 || y < -R - 20 || x > 410 + R || y > 864 + R) continue; + // 脉冲呼吸:刚脉冲完最亮,随 tickAcc 渐弱(表现与逻辑 tick 同源) + const pulse = 1 - Math.min(1, z.tickAcc / z.wd.tick); + g.strokeStyle = col; + g.globalAlpha = 0.2 + 0.35 * pulse; + g.lineWidth = 2; + g.beginPath(); g.arc(x, y, R, 0, Math.PI * 2); g.stroke(); + g.globalAlpha = 0.5; + g.fillStyle = col; + g.fillRect(x - 3, y - 10, 6, 20); // 中心桩(占位符柱) + g.globalAlpha = 1; + } + } +} + function drawGems(g, v, camX, camY) { g.fillStyle = C.gem; for (let i = 0; i < v.gems.length; i++) { @@ -197,8 +273,29 @@ function drawGems(g, v, camX, camY) { } } -/** 手感事件特效(sim 事件驱动的短命火花;M1 换 hit-feedback 插件承载)。 */ -function drawFx(g, fx, camX, camY) { +/** 聚灵珠(全屏收割道具;不受磁吸=必须走位踩取,画得比灵晶醒目)。 */ +function drawDrops(g, v, camX, camY) { + for (let i = 0; i < v.drops.length; i++) { + const d = v.drops[i]; + const x = d.x - camX, y = d.y - camY; + if (x < -30 || y < -30 || x > 420 || y > 874) continue; + const breathe = 0.75 + 0.25 * Math.sin(v.frame * 0.12); + g.globalAlpha = breathe; + g.fillStyle = C.harvest; + const r = 12; + g.beginPath(); + g.moveTo(x, y - r); g.lineTo(x + r, y); g.lineTo(x, y + r); g.lineTo(x - r, y); + g.closePath(); g.fill(); + g.strokeStyle = C.white; + g.lineWidth = 1.5; + g.globalAlpha = breathe * 0.8; + g.beginPath(); g.arc(x, y, r + 5, 0, Math.PI * 2); g.stroke(); + g.globalAlpha = 1; + } +} + +/** 手感事件特效(sim 事件驱动的短命火花;粒子本体归 hit-feedback/juice)。 */ +function drawFx(g, fx, camX, camY, viewport) { for (const e of fx.list) { const x = e.x - camX, y = e.y - camY; const p = e.age / 0.5; @@ -214,7 +311,13 @@ function drawFx(g, fx, camX, camY) { } else if (e.type === 'playerHurt') { g.globalAlpha = (1 - p) * 0.25; // 受击红晕(全屏,轻) g.fillStyle = C.hp; - g.fillRect(0, 0, 390, 844); + g.fillRect(0, 0, viewport.w, viewport.h); + } else if (e.type === 'harvest') { + // 全图磁吸涟漪:从玩家扩到全屏(手 8 事件的可读性锚) + g.globalAlpha = (1 - p) * 0.6; + g.strokeStyle = C.harvest; + g.lineWidth = 3; + g.beginPath(); g.arc(x, y, 20 + p * 420, 0, Math.PI * 2); g.stroke(); } g.globalAlpha = 1; } @@ -240,6 +343,18 @@ function drawHud(g, v, viewport, best) { g.fillStyle = C.hudDim; g.textAlign = 'left'; g.fillText(`Lv ${v.level}`, 14, 62); + // Boss 血条位(§11/触发器② HUD 四件之四;真 Boss 事件源归 M3,bossBar 为空不画) + if (v.bossBar) drawBossBar(g, v.bossBar, viewport); + // 暂停钮(右上;点击进 build 总览) + const pb = layoutPauseButton(viewport); + g.globalAlpha = 0.85; + g.strokeStyle = C.hudDim; + g.lineWidth = 2; + g.strokeRect(pb.x, pb.y, pb.w, pb.h); + g.fillStyle = C.hud; + g.fillRect(pb.x + 11, pb.y + 9, 4, 18); + g.fillRect(pb.x + 21, pb.y + 9, 4, 18); + g.globalAlpha = 1; // 血条(玩家脚下) const px = viewport.w / 2, py = viewport.h / 2 + v.player.r + 10; g.fillStyle = C.hpBack; g.fillRect(px - 24, py, 48, 5); @@ -247,6 +362,23 @@ function drawHud(g, v, viewport, best) { g.textAlign = 'left'; } +/** Boss 血条(顶部计时下方通栏;M3 真 Boss 落地即接,渲染面本期先立)。 */ +function drawBossBar(g, boss, viewport) { + const w = viewport.w - 90, x = 45, y = 70; + g.fillStyle = C.hpBack; + g.fillRect(x, y, w, 10); + g.fillStyle = C.hp; + g.fillRect(x, y, w * Math.max(0, Math.min(1, boss.hp / boss.maxHp)), 10); + g.strokeStyle = C.panelEdge; + g.lineWidth = 1; + g.strokeRect(x, y, w, 10); + g.fillStyle = C.hud; + g.font = 'bold 12px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(String(boss.name || ''), viewport.w / 2, y - 4); + g.textAlign = 'left'; +} + function drawJoystick(g, joy) { // 浮动摇杆(virtual-joystick 核 vector() 快照):外圈(原点)+ 杆头;半透明不遮战场 g.globalAlpha = 0.35; @@ -259,18 +391,181 @@ function drawJoystick(g, joy) { g.globalAlpha = 1; } +/** 出击页(一页 meta:巡使/巡域/潮汛三段滚动列表 + 开巡钮;触发器②第三项)。 */ function drawMenu(g, s) { - const { viewport, best } = s; + const { viewport, best, menu } = s; + const L = menuLayout(viewport); g.fillStyle = C.player; g.font = 'bold 40px system-ui, sans-serif'; g.textAlign = 'center'; - g.fillText('山海行纪', viewport.w / 2, viewport.h * 0.3); + g.fillText('山海行纪', viewport.w / 2, L.titleY); g.fillStyle = C.hudDim; g.font = '15px system-ui, sans-serif'; - g.fillText('持符巡山 · 百兽夜行', viewport.w / 2, viewport.h * 0.3 + 34); - g.fillText('拖动 = 走位 · 雷符自动出手', viewport.w / 2, viewport.h * 0.44); - if (best > 0) g.fillText(`最佳击杀 ${best}`, viewport.w / 2, viewport.h * 0.48); - drawBigButton(g, layoutButton(viewport, 'start'), '开 巡'); + g.fillText('持符巡山 · 百兽夜行', viewport.w / 2, L.titleY + 30); + if (best > 0) g.fillText(`最佳击杀 ${best}`, viewport.w / 2, L.titleY + 54); + + if (menu && menu.list) { + const rect = L.listRect; + // 列表容器 + g.fillStyle = 'rgba(20,16,9,0.6)'; + g.strokeStyle = 'rgba(216,160,62,0.5)'; + g.lineWidth = 1.5; + if (g.roundRect) { g.beginPath(); g.roundRect(rect.x, rect.y, rect.w, rect.h, 10); g.fill(); g.stroke(); } + else { g.fillRect(rect.x, rect.y, rect.w, rect.h); g.strokeRect(rect.x, rect.y, rect.w, rect.h); } + // 行(clip 到容器;几何全部来自 scrollList=命中同源) + g.save(); + g.beginPath(); g.rect(rect.x, rect.y, rect.w, rect.h); g.clip(); + const range = menu.list.visibleRange(); + for (let i = range.first; i <= range.last; i++) { + const row = menu.rows[i]; + const r = menu.list.rowRect(i); + if (!row) continue; + if (row.type === 'header') { + g.fillStyle = C.panelEdge; + g.font = 'bold 15px system-ui, sans-serif'; + g.textAlign = 'left'; + g.fillText(`— ${row.label} —`, r.x + 14, r.y + r.h / 2 + 5); + continue; + } + const selected = row.type === 'diff' ? row.key === menu.selDifficulty : !!row.selected; + if (selected) { + g.fillStyle = 'rgba(216,160,62,0.16)'; + g.fillRect(r.x + 4, r.y + 4, r.w - 8, r.h - 8); + g.strokeStyle = C.panelEdge; + g.lineWidth = 1.5; + g.strokeRect(r.x + 4, r.y + 4, r.w - 8, r.h - 8); + } + g.fillStyle = row.locked ? C.locked : C.hud; + g.font = '16px system-ui, sans-serif'; + g.textAlign = 'left'; + g.fillText(`${row.locked ? '🔒 ' : ''}${row.name}`, r.x + 20, r.y + r.h / 2 + 6); + if (selected) { + g.fillStyle = C.gem; + g.font = 'bold 13px system-ui, sans-serif'; + g.textAlign = 'right'; + g.fillText('已选', r.x + r.w - 16, r.y + r.h / 2 + 5); + } + } + g.restore(); + // 滚动指示(内容溢出才画) + const max = menu.list.maxScroll(); + if (max > 0) { + const frac = rect.h / (rect.h + max); + const barH = Math.max(24, rect.h * frac); + const barY = rect.y + (rect.h - barH) * (menu.list.scrollY() / max); + g.fillStyle = 'rgba(216,160,62,0.4)'; + g.fillRect(rect.x + rect.w - 5, barY, 3, barH); + } + } + const startBtn = layoutButton(viewport, 'start'); + drawBigButton(g, startBtn, '开 巡'); + g.fillStyle = C.hudDim; + g.font = '13px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText('拖动 = 走位 · 符器自动出手', viewport.w / 2, startBtn.y + startBtn.h + 26); + g.textAlign = 'left'; +} + +/** 暂停 build 总览(§11:武器 6 槽 + 被动 6 槽 + 本局统计 + 恢复钮)。 */ +function drawPauseOverview(g, v, viewport) { + g.fillStyle = 'rgba(10,8,4,0.82)'; + g.fillRect(0, 0, viewport.w, viewport.h); + const px = 20, pw = viewport.w - 40; + g.fillStyle = C.panel; + g.strokeStyle = C.panelEdge; + g.lineWidth = 2; + if (g.roundRect) { g.beginPath(); g.roundRect(px, 96, pw, 590, 14); g.fill(); g.stroke(); } + else { g.fillRect(px, 96, pw, 590); g.strokeRect(px, 96, pw, 590); } + + g.fillStyle = C.player; + g.font = 'bold 24px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText('暂停 · 本局构筑', viewport.w / 2, 134); + + drawSlotGrid(g, viewport, 158, '符器', collectWeaponSlots(v)); + drawSlotGrid(g, viewport, 330, '被动', collectPassiveSlots(v)); + + // 本局统计 + const mm = String(Math.floor(v.t / 60)).padStart(2, '0'); + const ss = String(Math.floor(v.t % 60)).padStart(2, '0'); + const diff = DIFFICULTY[v.difficulty]; + g.fillStyle = C.panelEdge; + g.font = 'bold 15px system-ui, sans-serif'; + g.textAlign = 'left'; + g.fillText('— 本局 —', px + 14, 516); + g.fillStyle = C.hud; + g.font = '14px system-ui, sans-serif'; + const stats = [ + [`时间 ${mm}:${ss}`, `等级 Lv ${v.level}`], + [`击杀 ${v.stats.kills}`, `灵晶 ${v.stats.gems}`], + [`总伤 ${Math.round(v.stats.dmgDealt)}`, `进化 ${v.stats.evolutions} · 收割 ${v.stats.harvests}`], + [`潮汛 ${diff ? diff.name : v.difficulty || ''}`, ''], + ]; + stats.forEach((line, i) => { + g.fillText(line[0], px + 18, 542 + i * 22); + g.fillText(line[1], px + pw / 2 + 6, 542 + i * 22); + }); + + drawBigButton(g, layoutButton(viewport, 'resume'), '继 续'); + g.textAlign = 'left'; +} + +/** 槽位数据:武器 6 槽(进化显进化名+「进」徽)。 */ +function collectWeaponSlots(v) { + const slots = []; + for (let i = 0; i < SLOTS.weapons; i++) { + const w = v.weapons[i]; + slots.push(w ? { + name: w.evolved ? w.def.evo.name : w.def.name, + tag: w.evolved ? '进' : `Lv${w.lv}`, + evolved: w.evolved, + } : null); + } + return slots; +} + +/** 槽位数据:被动 6 槽(按持有序)。 */ +function collectPassiveSlots(v) { + const slots = []; + for (const p of PASSIVES) { + const lv = v.passives[p.id] | 0; + if (lv > 0) slots.push({ name: p.name, tag: `Lv${lv}`, evolved: false }); + } + while (slots.length < SLOTS.passives) slots.push(null); + return slots.slice(0, SLOTS.passives); +} + +/** 3×2 槽位栅格(空槽画虚位)。 */ +function drawSlotGrid(g, viewport, y0, label, slots) { + const px = 20, pw = viewport.w - 40; + g.fillStyle = C.panelEdge; + g.font = 'bold 15px system-ui, sans-serif'; + g.textAlign = 'left'; + g.fillText(`— ${label} —`, px + 14, y0); + const cols = 3, cw = (pw - 16 * 2 - 8 * (cols - 1)) / cols, ch = 62; + for (let i = 0; i < slots.length; i++) { + const cx = px + 16 + (i % cols) * (cw + 8); + const cy = y0 + 10 + Math.floor(i / cols) * (ch + 8); + const s = slots[i]; + g.strokeStyle = s ? (s.evolved ? C.player : 'rgba(216,160,62,0.55)') : 'rgba(232,220,192,0.18)'; + g.lineWidth = s && s.evolved ? 2 : 1.5; + if (g.roundRect) { g.beginPath(); g.roundRect(cx, cy, cw, ch, 8); g.stroke(); } + else g.strokeRect(cx, cy, cw, ch); + if (!s) { + g.fillStyle = 'rgba(232,220,192,0.18)'; + g.font = '14px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText('—', cx + cw / 2, cy + ch / 2 + 5); + continue; + } + g.fillStyle = s.evolved ? C.player : C.hud; + g.font = 'bold 13px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(s.name, cx + cw / 2, cy + 26); + g.fillStyle = s.evolved ? C.gem : C.hudDim; + g.font = '12px system-ui, sans-serif'; + g.fillText(s.tag, cx + cw / 2, cy + 46); + } g.textAlign = 'left'; } @@ -281,7 +576,7 @@ function drawSettle(g, v, viewport, best) { g.fillStyle = win ? C.player : C.hp; g.font = 'bold 36px system-ui, sans-serif'; g.textAlign = 'center'; - g.fillText(win ? '巡山礼成' : '殒于兽潮', viewport.w / 2, viewport.h * 0.32); + g.fillText(win ? '巡山礼成' : '殒于兽潮', viewport.w / 2, viewport.h * 0.26); g.fillStyle = C.hud; g.font = '16px system-ui, sans-serif'; const mm = String(Math.floor(v.t / 60)).padStart(2, '0'); @@ -290,8 +585,14 @@ function drawSettle(g, v, viewport, best) { `存活 ${mm}:${ss} / ${String(Math.floor(ROUND_S / 60)).padStart(2, '0')}:00`, `击杀 ${v.stats.kills} · 等级 Lv ${v.level}`, `灵晶 ${v.stats.gems} · 最佳击杀 ${Math.max(best, v.stats.kills)}`, + `进化 ${v.stats.evolutions} · 全图收割 ${v.stats.harvests}`, ]; - lines.forEach((s2, i) => g.fillText(s2, viewport.w / 2, viewport.h * 0.42 + i * 28)); + lines.forEach((s2, i) => g.fillText(s2, viewport.w / 2, viewport.h * 0.36 + i * 28)); + // 终局 build 一览(武器名单行,进化件着金) + g.font = '13px system-ui, sans-serif'; + const names = v.weapons.map((w) => (w.evolved ? `★${w.def.evo.name}` : w.def.name)).join(' · '); + g.fillStyle = C.hudDim; + g.fillText(names, viewport.w / 2, viewport.h * 0.36 + 4 * 28 + 6); drawBigButton(g, layoutButton(viewport, 'restart'), '再 巡'); g.textAlign = 'left'; } diff --git a/game-runtime/games/shanhai-xingji/src/sim.js b/game-runtime/games/shanhai-xingji/src/sim.js index 9b2e086a..02f9ad45 100644 --- a/game-runtime/games/shanhai-xingji/src/sim.js +++ b/game-runtime/games/shanhai-xingji/src/sim.js @@ -1,21 +1,23 @@ /** - * sim.js — 《山海行纪》纯逻辑核心(M0:核心循环全量) - * owner:北极星顶级线② | 消费方:game-logic.js(渲染壳)/ scripts/headless-smoke.mjs(headless)/ 快进仿真器(§2.C 路 A) + * sim.js — 《山海行纪》纯逻辑核心(M2:build 体系全量) + * owner:北极星顶级线② | 消费方:game-logic.js(渲染壳)/ scripts/headless-smoke.mjs(headless)/ + * scripts/sim-scan.mjs(参数扫描,§8.3 路 A) * * ════════════════════════════════════════════════════════════════════════════ * 【纯逻辑铁律(开发设计书 §8.3 / NSTAR O-4 拍定,从第一行代码就位)】 * - 零 DOM / 零 canvas / 零引擎 import:本文件 node 直跑; * - 时间只经 update(dt) 注入,随机只经受控 rng(rng.js); * - 走位 / 碰撞 / 索敌 / 磁吸 / 波次 / 升级 / 结算全在本层,渲染层只画不算; - * - 手感事件(命中/击杀/升级/拾取/受击)以事件队列吐给渲染层消费(drainEvents), - * 逻辑不依赖渲染是否消费——这是 M1 音画同帧断言(visualEventFrame vs sfxStartFrame)的地基。 + * - 手感事件(命中/暴击/击杀/升级/拾取/受击/收割)以事件队列吐给渲染层消费(drainEvents), + * 逻辑不依赖渲染是否消费。 * - * 【M0 范围】移动 + 最近敌索敌自动开火(雷符链)+ 3 敌种波次 + 灵晶磁吸 + 升级三选一(池 5)+ 死亡/撑满结算。 - * 【实体上限与性能(M1 已沉淀)】对象池/均匀网格/分离/追踪/索敌自 M0 内联实现沉淀为 L2 插件 - * (entity-pool / swarm-steering / auto-targeting,设计书 §7.3),本层改为消费其纯函数核—— - * 插件核零 ctx 零 DOM,不破本层纯逻辑铁律;esbuild 打包时内联进 bundle。 - * 【M1 增量】实体带单调 id(取证 sourceId:飘字同帧同源聚合、事件溯源都挂它); - * hit/kill 事件携带 eid+dmg(hit-feedback 探针的数据源)。 + * 【M2 范围】武器 12(五族索敌:最近敌/随机敌/朝移动方向/环绕自身/原地投放)+ 被动 14 + + * 进化 12(满级+对应被动≥3 → 下一次三选一必然出现进化项,§3.1)+ 槽位 6+6 + + * 难度 D0/D1(表驱动乘区)+ 暴击轴(手 4 事件源)+ 聚灵珠全图磁吸(手 6/手 8 事件源)。 + * 数值全部来自 balance.js 表;派生语义单一实现 = build-model.js(sim 与验收测试同源)。 + * 【实体形态】proj(投射物:走格命中+穿透)与 zone(区域:orbit 绕身/place 驻场,按 tick + * 对半径内敌结算)两类;AoE(爆裂/脉冲/链跳)按「先收集受害者、再按下标降序结算」处理, + * 与 swap-remove 回池共存不踩下标(击杀把尾元素换到当前位,降序保证未处理下标不受影响)。 * ════════════════════════════════════════════════════════════════════════════ */ @@ -25,93 +27,118 @@ import { createRng } from './rng.js'; import { createPool, swapRemove } from '../../../src/plugins/entity-pool/impl.js'; import { createUniformGrid, separateOne, seekStep } from '../../../src/plugins/swarm-steering/impl.js'; import { nearestViaGrid } from '../../../src/plugins/auto-targeting/impl.js'; +import { deriveWeapon, derivePlayer, WEAPON_BY_ID, PASSIVE_BY_ID } from './build-model.js'; import { - ROUND_S, PLAYER, WEAPON, UPGRADES, XP, ENEMIES, WAVES, - MAX_ALIVE, SPAWN_R, GRID_CELL, SEPARATION, + ROUND_S, PLAYER, WEAPONS, PASSIVES, XP, ENEMIES, WAVES, + MAX_ALIVE, MAX_ZONES, SPAWN_R, GRID_CELL, SEPARATION, + CRIT, SLOTS, OFFER, EVO_PASSIVE_MIN, CHAIN_R, HARVEST, DIFFICULTY, } from './balance.js'; /** ease-in(二次):磁吸飞行「先慢后快」曲线(手 8)。 */ function easeInQuad(p) { return p * p; } +/** 初始武器(角色 roster 归 M4;v1 固定持符巡山使 = 雷符链)。 */ +const START_WEAPON_ID = 'leifu'; + /** * 创建一局纯逻辑模拟。 - * @param {{seed?: number, fireEnabled?: boolean}} [opts] + * @param {{seed?: number, fireEnabled?: boolean, difficulty?: string}} [opts] * - seed:受控随机种子(缺省固定值,保证「不传也确定」); - * - fireEnabled:自动开火开关(缺省 true;headless「关火纯走位存活 ≥60s」对照局用 false)。 + * - fireEnabled:自动开火开关(缺省 true;headless「关火纯走位存活 ≥60s」对照局用 false); + * - difficulty:难度键(d0..d5,缺省 d0;表驱动乘区,判据①②③④跑 d1)。 * @returns {object} sim 句柄(update/setMove/choose/readState/drainEvents/…) */ export function createSim(opts) { const seed = (opts && opts.seed != null ? opts.seed : 0x51AB37) >>> 0; const fireEnabled = !(opts && opts.fireEnabled === false); + const diffKey = (opts && opts.difficulty && DIFFICULTY[opts.difficulty]) ? opts.difficulty : 'd0'; + const diff = DIFFICULTY[diffKey]; const rng = createRng(seed); /* ── 局面状态 ── */ let t = 0; // 局内时间(秒) let phase = 'play'; // play | levelup | win | lose(levelup=全局冻结,手 9) - let frame = 0; // 逻辑帧号(事件戳用;M1 音画同帧断言的基准轴) + let frame = 0; // 逻辑帧号(事件戳用;音画同帧断言的基准轴) const player = { x: 0, y: 0, hp: PLAYER.hp, maxHp: PLAYER.hp, r: PLAYER.r, dirX: 0, dirY: 0, // 归一化移动方向(setMove 注入;零向量=站定) + lastDirX: 0, lastDirY: -1, // 最近一次非零方向(movedir 族武器的射向;初始朝上) iframe: 0, // 受击无敌窗剩余(秒) }; - /* 被动等级(池 5;数值语义见 balance.UPGRADES 注释)。 */ - const upLv = { atk: 0, rate: 0, count: 0, speed: 0, magnet: 0 }; - /** 派生数值(升级后重算,热路径读缓存不重乘)。 */ - const derived = { dmg: 0, interval: 0, count: 0, speed: 0, magnetR: 0 }; - function recalcDerived() { - derived.dmg = WEAPON.dmg * (1 + 0.25 * upLv.atk); - derived.interval = WEAPON.interval * Math.pow(0.85, upLv.rate); - derived.count = WEAPON.count + upLv.count; - derived.speed = PLAYER.speed * (1 + 0.12 * upLv.speed); - derived.magnetR = XP.gemMagnetR * (1 + 0.40 * upLv.magnet); + /* ── build 状态(武器 6 槽 + 被动 6 槽)── */ + /** @type {Array<{id:string, def:object, lv:number, evolved:boolean, fireAcc:number, d:object}>} */ + const weapons = []; + /** 被动等级(id → 级;未持有无键)。 */ + const plv = {}; + /** 玩家侧派生(升级后重算,热路径读缓存)。 */ + let pd = derivePlayer(plv); + + function addWeapon(id) { + const def = WEAPON_BY_ID[id]; + const w = { id, def, lv: 1, evolved: false, fireAcc: 0, d: null }; + w.d = deriveWeapon(def, w.lv, w.evolved, plv); + weapons.push(w); + return w; } - recalcDerived(); + /** 全量重算派生(任何 build 变化后调;12 件 ×O(1),非热路径)。 */ + function recalcAll() { + pd = derivePlayer(plv); + for (const w of weapons) w.d = deriveWeapon(w.def, w.lv, w.evolved, plv); + } + addWeapon(START_WEAPON_ID); /* 经验与等级。 */ let level = 1; - let xp = 0; + let xp = 0; // float(纳灵诀乘区可产小数) let xpNeed = XP.base; - /** 当前三选一候选(phase==='levelup' 时非空;元素 = UPGRADES 下标)。 */ + /** 当前三选一候选(phase==='levelup' 时非空;元素 = {kind:'weapon'|'passive'|'evo', id})。 */ let offer = []; - /* 统计(结算与取证)。 */ - const stats = { kills: 0, gems: 0, levelUps: 0, dmgDealt: 0, dmgTaken: 0 }; + /* 统计(结算与取证;dmgByWeapon = 扫描器 DPS 归因面)。 */ + const stats = { + kills: 0, gems: 0, levelUps: 0, dmgDealt: 0, dmgTaken: 0, + evolutions: 0, harvests: 0, dmgByWeapon: {}, + }; /* ── 实体池(entity-pool 沉淀件:swap-remove + free list,局内零 GC 目标)── */ - /** @type {Array} 活敌(dense;死亡 swap-remove 回池)。 */ const enemies = []; const enemyPool = createPool(); const projs = []; const projPool = createPool(); const gems = []; const gemPool = createPool(); - /** 实体单调 id(取证 sourceId;局内唯一,跨局不复位也无妨——只作聚合键)。 */ + /** 区域实体(orbit 环 / place 桩;按 tick 脉冲结算)。 */ + const zones = []; + const zonePool = createPool(); + /** 聚灵珠(全屏收割道具;不受磁吸,接触拾取)。 */ + const drops = []; + let lastHarvestDropT = -Infinity; + /** 实体单调 id(取证 sourceId;局内唯一)。 */ let eidSeq = 0; + /** Boss 血条预览位(M3 才有真 Boss;_debugFill 可注入以验 HUD 绘制面)。 */ + let bossBar = null; /** 手感事件队列(渲染层 drainEvents 消费;含 frame 戳)。 */ let events = []; function emit(type, x, y, extra) { - // 事件只增不查,渲染层每帧清空;headless 不消费也不涨(drainEvents 未调时按上限截断防泄漏) if (events.length < 256) events.push({ type, x, y, frame, ...(extra || {}) }); } - /* ── 均匀网格(swarm-steering 沉淀件;每帧重建,索敌/分离/弹命中三消费方共享一张)── */ + /* ── 均匀网格(swarm-steering 沉淀件;每帧重建,索敌/分离/弹命中/区域结算共享一张)── */ const grid = createUniformGrid({ cellSize: GRID_CELL }); function rebuildGrid() { grid.rebuild(enemies); } - /* ── 波次生成 ── */ + /* ── 波次生成(难度乘区:rateMul 抬到达率,hp/dps/speed 乘在个体上)── */ let spawnAcc = 0; - /** 当前波段(WAVES 按 t 单调,线性扫;段数个位数,无需二分)。 */ function currentWave() { let w = WAVES[0]; for (let i = 1; i < WAVES.length; i++) { if (t >= WAVES[i].t) w = WAVES[i]; else break; } return w; } - /** 按权重抽敌种 id。 */ function pickType(mix) { let total = 0; for (const k in mix) total += mix[k]; let roll = rng() * total; @@ -129,69 +156,206 @@ export function createSim(opts) { e.type = typeId; e.x = player.x + Math.cos(ang) * SPAWN_R; e.y = player.y + Math.sin(ang) * SPAWN_R; - e.hp = def.hp; e.maxHp = def.hp; - e.r = def.r; e.speed = def.speed; e.dps = def.dps; e.gem = def.gem; - e.hurtFlash = 0; // 渲染层读:受击闪白剩余(秒;逻辑只递减,表现归渲染) + // 个体成长 = 波表乘区(hpMul/speedMul,§8.1 g^w 表化)× 难度乘区 + e.hp = def.hp * (w.hpMul || 1) * diff.hpMul; e.maxHp = e.hp; + e.r = def.r; e.speed = def.speed * (w.speedMul || 1) * diff.speedMul; e.dps = def.dps * diff.dpsMul; e.gem = def.gem; + e.hurtFlash = 0; // 渲染层读:受击闪白剩余(秒) + e.slowT = 0; e.slowPct = 0; // 减速(寒渊/盾环等机制) + e.dotT = 0; e.dotDps = 0; e.dotWid = null; // 持续伤(灼烧/溺蚀) enemies.push(e); } - /** 击杀第 i 个敌:掉晶、统计、事件(带 eid 供取证溯源)、swap-remove 回池。 */ + /** 击杀第 i 个敌:掉晶、聚灵珠判定、统计、事件、swap-remove 回池。 */ function killEnemy(i) { const e = enemies[i]; stats.kills++; emit('kill', e.x, e.y, { etype: e.type, eid: e.id }); - // 掉落灵晶(1 颗,价值=敌表 gem;拾取态从 idle 起) const g = gemPool.acquire(); g.x = e.x; g.y = e.y; g.value = e.gem; g.state = 'idle'; g.flyT = 0; g.flyDur = 0; g.sx = 0; g.sy = 0; gems.push(g); + // 聚灵珠掉落(手 8 全图磁吸的事件源):冷却窗过后按概率掉,场上限 2 颗(带 TTL 防堵) + if (t - lastHarvestDropT >= HARVEST.minIntervalS && drops.length < 2 && rng() < HARVEST.dropChance) { + lastHarvestDropT = t; + drops.push({ x: e.x, y: e.y, id: ++eidSeq, ttl: HARVEST.ttlS }); + } enemyPool.release(swapRemove(enemies, i)); } - /* ── 武器(最近敌索敌;目标死亡下一发立即换目标=每发射前重选)── */ - let fireAcc = 0; + /* ── 伤害管线(暴击→事件→死亡收集;AoE 用「收集-降序结算」防下标漂移)── */ /** - * 最近敌(auto-targeting 沉淀件:网格逐环扩张,替代 M0 全量线性扫描)。 - * 网格桶位是本帧步 3 重建的,步 4 敌已再移 ≤speed·dt(~1.5px),桶位允许该量级陈旧 - * (距离用实时坐标算,只影响临界等距时的选择;弹命中路 M0 起就是同一口径)。 + * 对第 i 个敌结算一次伤害。 + * @returns {boolean} 是否致死(致死时本函数**不**移除实体,由调用方按降序统一 killEnemy) */ - function nearestEnemy() { - return nearestViaGrid(grid, player.x, player.y, WEAPON.searchR); + function damageEnemy(i, raw, wid, canCrit) { + const e = enemies[i]; + let dmg = raw; + let crit = false; + if (canCrit) { + const w = wid ? weaponById(wid) : null; + const chance = Math.min(CRIT.chanceCap, pd.critBase + (w ? w.d.critAdd : 0)); + if (chance > 0 && rng() < chance) { crit = true; dmg *= CRIT.mul; } + } + e.hp -= dmg; + e.hurtFlash = 0.08; // 手 6:受击闪白 ≤80ms(渲染层读) + stats.dmgDealt += dmg; + if (wid) stats.dmgByWeapon[wid] = (stats.dmgByWeapon[wid] || 0) + dmg; + emit(crit ? 'crit' : 'hit', e.x, e.y, { dmg, eid: e.id, crit, wid }); // 手 4:crit 档事件源 + return e.hp <= 0; } - function fireVolley() { - const n = derived.count; + function weaponById(id) { + for (const w of weapons) if (w.id === id) return w; + return null; + } + /** 收集 (x,y) 半径 r 内敌下标(排除 excludeIdx;结果升序)。 */ + const victimScratch = []; + function collectVictims(x, y, r, excludeIdx) { + victimScratch.length = 0; + const ring = Math.max(1, Math.ceil(r / GRID_CELL)); + grid.forNeighbors(x, y, ring, (j) => { + if (j === excludeIdx) return; + const e = enemies[j]; + const dx = e.x - x, dy = e.y - y; + const rr = r + e.r; + if (dx * dx + dy * dy < rr * rr) victimScratch.push(j); + }); + victimScratch.sort((a, b) => a - b); + return victimScratch; + } + /** 对收集到的受害者按降序结算(致死即杀);返回是否有击杀(调用方决定是否重建网格)。 */ + function applyToVictims(list, raw, wid, canCrit) { + let killed = false; + for (let k = list.length - 1; k >= 0; k--) { + const idx = list[k]; + if (idx >= enemies.length) continue; // 同批前序击杀已把尾部换走 + if (damageEnemy(idx, raw, wid, canCrit)) { killEnemy(idx); killed = true; } + } + return killed; + } + /** 链跳(进化机制):从 (x,y) 起对最近 n 个邻敌补一跳(不再连锁、不吃暴击)。 */ + function chainHits(x, y, excludeIdx, n, dmg, wid) { + const list = collectVictims(x, y, CHAIN_R, excludeIdx); + if (list.length === 0) return false; + // 取最近 n 个(n≤2:插入选择即可,零排序开销) + list.sort((a, b) => { + const ea = enemies[a], eb = enemies[b]; + return ((ea.x - x) ** 2 + (ea.y - y) ** 2) - ((eb.x - x) ** 2 + (eb.y - y) ** 2); + }); + const picks = list.slice(0, n).sort((a, b) => a - b); + return applyToVictims(picks, dmg, wid, false); + } + + /* ── 武器开火(五族索敌)── */ + /** 随机敌(searchR 内蓄水池抽样;O(alive) 每发,只在开火帧走)。 */ + function randomEnemyIdx(searchR) { + const r2 = searchR * searchR; + let pick = -1, seen = 0; + for (let i = 0; i < enemies.length; i++) { + const e = enemies[i]; + const dx = e.x - player.x, dy = e.y - player.y; + if (dx * dx + dy * dy > r2) continue; + seen++; + if (rng() < 1 / seen) pick = i; + } + return pick; + } + function spawnProj(ux, uy, wd, wid) { + const p = projPool.acquire(); + p.x = player.x; p.y = player.y; + p.vx = ux * wd.projSpeed; p.vy = uy * wd.projSpeed; + p.dmg = wd.dmg; p.r = wd.projR; + p.life = wd.life; p.pierce = wd.pierce; + p.wid = wid; p.wd = wd; // 机制字段快照引用(chain/explode/slow/dot;升级中弹沿旧参,确定且无害) + projs.push(p); + } + /** 单方向散射(第 2+ 发 ±8° 扇形摆开)。 */ + function volleyToward(ux, uy, n, wd, wid) { for (let s = 0; s < n; s++) { - const ti = nearestEnemy(); - if (ti < 0) return; // 索敌半径内无敌:本轮不射(不对空气开火) + const spread = (s === 0) ? 0 : (s % 2 === 1 ? 1 : -1) * 0.14 * ((s + 1) >> 1); + const cos = Math.cos(spread), sin = Math.sin(spread); + spawnProj(ux * cos - uy * sin, ux * sin + uy * cos, wd, wid); + } + } + function fireWeapon(w) { + const wd = w.d; + let n = wd.count + (pd.extraChance > 0 && rng() < pd.extraChance ? 1 : 0); // 连环符:每轮概率追加 + if (wd.kind === 'zone') { spawnZones(w, n); emit('fire', player.x, player.y, { wid: w.id }); return; } + if (wd.family === 'movedir') { + volleyToward(player.lastDirX, player.lastDirY, n, wd, w.id); + if (wd.mirror) volleyToward(-player.lastDirX, -player.lastDirY, n, wd, w.id); // 罡风裂空:前后齐出 + emit('fire', player.x, player.y, { wid: w.id }); + return; + } + // nearest / random:逐发选目标(目标死亡下一发立即换目标=每发射前重选) + let fired = false; + for (let s = 0; s < n; s++) { + const ti = wd.family === 'random' + ? randomEnemyIdx(wd.searchR || 0) + : nearestViaGrid(grid, player.x, player.y, wd.searchR || 0); + if (ti < 0) break; // 索敌半径内无敌:不对空气开火 const e = enemies[ti]; const dx = e.x - player.x, dy = e.y - player.y; const d = Math.hypot(dx, dy) || 1; - // 多发散射:同目标方向 ±8° 扇形摆开(第 2+ 发),保证多弹不完全重叠 const spread = (s === 0) ? 0 : (s % 2 === 1 ? 1 : -1) * 0.14 * ((s + 1) >> 1); const cos = Math.cos(spread), sin = Math.sin(spread); - const ux = (dx / d) * cos - (dy / d) * sin; - const uy = (dx / d) * sin + (dy / d) * cos; - const p = projPool.acquire(); - p.x = player.x; p.y = player.y; - p.vx = ux * WEAPON.projSpeed; p.vy = uy * WEAPON.projSpeed; - p.dmg = derived.dmg; p.r = WEAPON.projR; - p.life = WEAPON.life; p.pierce = WEAPON.pierce; - projs.push(p); + spawnProj((dx / d) * cos - (dy / d) * sin, (dx / d) * sin + (dy / d) * cos, wd, w.id); + fired = true; + } + if (fired) emit('fire', player.x, player.y, { wid: w.id }); + } + /** 区域族施放:orbit=等分角环绕;place=玩家脚下(多桩带确定性小偏移)。 */ + function spawnZones(w, n) { + const wd = w.d; + for (let s = 0; s < n; s++) { + if (zones.length >= MAX_ZONES) { zonePool.release(swapRemove(zones, 0)); } // 超限丢最旧 + const z = zonePool.acquire(); + z.wid = w.id; z.wd = wd; z.kind = wd.family; + z.life = wd.life; z.tickAcc = 0; z.id = ++eidSeq; + if (wd.family === 'orbit') { + z.angle = (Math.PI * 2 * s) / n; + z.x = player.x + Math.cos(z.angle) * wd.orbitR; + z.y = player.y + Math.sin(z.angle) * wd.orbitR; + } else { + const jx = s === 0 ? 0 : (rng() * 2 - 1) * 24; + const jy = s === 0 ? 0 : (rng() * 2 - 1) * 24; + z.angle = 0; + z.x = player.x + jx; z.y = player.y + jy; + } + zones.push(z); } - emit('fire', player.x, player.y); } - /* ── 升级三选一 ── */ - /** 从未满级的池里确定性抽 3 个(不足 3 给剩余全部;全满级则直接跳过升级弹层)。 */ + /* ── 升级三选一(权重池 v1 均匀;进化项条件满足即强制进池,§3.1)── */ + function passiveCount() { let c = 0; for (const k in plv) if (plv[k] > 0) c++; return c; } function rollOffer() { - const avail = []; - for (let i = 0; i < UPGRADES.length; i++) if (upLv[UPGRADES[i].id] < UPGRADES[i].maxLv) avail.push(i); - if (avail.length === 0) return []; - // Fisher-Yates 抽前 3(受控 rng) - for (let i = avail.length - 1; i > 0; i--) { - const j = (rng() * (i + 1)) | 0; - const tmp = avail[i]; avail[i] = avail[j]; avail[j] = tmp; + const out = []; + // 1) 进化项:满级+对应被动≥3 且未进化 → 必然出现(全部塞入,至多 3) + for (const w of weapons) { + if (out.length >= OFFER.count) break; + if (w.lv >= w.def.maxLv && !w.evolved && (plv[w.def.evo.passive] | 0) >= EVO_PASSIVE_MIN) { + out.push({ kind: 'evo', id: w.id }); + } } - return avail.slice(0, Math.min(3, avail.length)); + // 2) 常规候选池:持有升级(权重 ownedWeight)+ 空槽新件(权重 1) + const cands = []; + for (const w of weapons) if (w.lv < w.def.maxLv) cands.push({ kind: 'weapon', id: w.id, w: OFFER.ownedWeight }); + for (const p of PASSIVES) if ((plv[p.id] | 0) > 0 && plv[p.id] < p.maxLv) cands.push({ kind: 'passive', id: p.id, w: OFFER.ownedWeight }); + if (weapons.length < SLOTS.weapons) { + for (const w of WEAPONS) if (!weaponById(w.id)) cands.push({ kind: 'weapon', id: w.id, w: 1 }); + } + if (passiveCount() < SLOTS.passives) { + for (const p of PASSIVES) if (!(plv[p.id] > 0)) cands.push({ kind: 'passive', id: p.id, w: 1 }); + } + // 按权重不放回抽取(受控 rng;偏持有 = 构筑能成型,新件不淹没升级项) + while (out.length < OFFER.count && cands.length > 0) { + let total = 0; + for (const c of cands) total += c.w; + let roll = rng() * total; + let idx = 0; + for (; idx < cands.length - 1; idx++) { roll -= cands[idx].w; if (roll <= 0) break; } + const picked = cands.splice(idx, 1)[0]; + out.push({ kind: picked.kind, id: picked.id }); + } + return out; } function tryLevelUp() { if (xp < xpNeed) return; @@ -200,13 +364,32 @@ export function createSim(opts) { stats.levelUps++; xpNeed = XP.base + XP.step * (level - 1); offer = rollOffer(); - if (offer.length === 0) return; // 全满级:静默升级不冻结 + if (offer.length === 0) return; // 全满:静默升级不冻结 phase = 'levelup'; // 全局冻结(手 9:弹出即冻结,输入只作用于选卡) emit('levelup', player.x, player.y, { level }); } + /** offer 展示行(readState 用;名称/描述/角标全部由表生成)。 */ + function describeOffer(o, i) { + if (o.kind === 'evo') { + const w = weaponById(o.id); + return { i, kind: o.kind, id: o.id, name: w.def.evo.name, desc: w.def.evo.desc, badge: '进化' }; + } + if (o.kind === 'weapon') { + const owned = weaponById(o.id); + const def = WEAPON_BY_ID[o.id]; + return owned + ? { i, kind: o.kind, id: o.id, name: def.name, desc: def.desc, badge: `Lv${owned.lv + 1}` } + : { i, kind: o.kind, id: o.id, name: def.name, desc: def.desc, badge: '新符器' }; + } + const p = PASSIVE_BY_ID[o.id]; + const cur = plv[o.id] | 0; + return { i, kind: o.kind, id: o.id, name: p.name, desc: p.desc, badge: cur > 0 ? `Lv${cur + 1}` : '新被动' }; + } /* ══════════════════════════ 主循环 ══════════════════════════ */ + const dotDead = []; // 持续伤致死收集(帧内复用) + /** * 前进一逻辑步。phase 非 play 时冻结(levelup 弹层 / 终态 latch)。 * @param {number} dt 步长(秒;host 恒定步长注入) @@ -216,32 +399,47 @@ export function createSim(opts) { frame++; t += dt; - /* 1) 波次生成:λ(t) 积分出怪 */ - spawnAcc += currentWave().rate * dt; + /* 1) 波次生成:λ(t)×难度 rateMul 积分出怪 */ + spawnAcc += currentWave().rate * diff.rateMul * dt; while (spawnAcc >= 1) { spawnAcc -= 1; spawnEnemy(); } - /* 2) 玩家移动(方向已归一,速度恒定=手 3 斜向归一) */ - player.x += player.dirX * derived.speed * dt; - player.y += player.dirY * derived.speed * dt; + /* 2) 玩家移动(方向已归一,速度恒定=手 3 斜向归一)+ 回春露回复 */ + const speed = PLAYER.speed * pd.speedMul; + player.x += player.dirX * speed * dt; + player.y += player.dirY * speed * dt; if (player.iframe > 0) player.iframe -= dt; + if (pd.regen > 0 && player.hp < player.maxHp) player.hp = Math.min(player.maxHp, player.hp + pd.regen * dt); - /* 3) 网格重建(本帧索敌/分离/弹命中共享) */ + /* 3) 网格重建(本帧索敌/分离/弹命中/区域结算共享) */ rebuildGrid(); - /* 4) 敌:直线追踪 + 邻域软分离(swarm-steering 沉淀件)+ 接触伤 */ + /* 4) 敌:直线追踪(减速乘区)+ 邻域软分离 + 持续伤 + 接触伤 */ let contactDps = 0; + dotDead.length = 0; for (let i = 0; i < enemies.length; i++) { const e = enemies[i]; if (e.hurtFlash > 0) e.hurtFlash -= dt; - // seekStep 返回移动前距离(接触判定复用,与 M0 口径一致免二次开方) - const d = seekStep(e, player.x, player.y, e.speed, dt); - // 邻域软分离(上限 8;重叠对推错开,防怪堆成一个点) + if (e.slowT > 0) { e.slowT -= dt; if (e.slowT <= 0) e.slowPct = 0; } + if (e.dotT > 0) { + e.dotT -= dt; + const dd = e.dotDps * dt; + e.hp -= dd; + stats.dmgDealt += dd; + if (e.dotWid) stats.dmgByWeapon[e.dotWid] = (stats.dmgByWeapon[e.dotWid] || 0) + dd; + if (e.hp <= 0) { dotDead.push(i); continue; } // 已死:跳过移动,循环后统一收 + } + const spd = e.slowPct > 0 ? e.speed * (1 - e.slowPct) : e.speed; + const d = seekStep(e, player.x, player.y, spd, dt); separateOne(e, i, grid, SEPARATION); - // 接触伤(dps 累计,统一结在玩家身上;受击窗内减免=只累计不清零,窗口由 iframe 控) if (d < e.r + player.r) contactDps += e.dps; } + if (dotDead.length > 0) { + for (let k = dotDead.length - 1; k >= 0; k--) killEnemy(dotDead[k]); // 降序回收,防下标漂移 + rebuildGrid(); + } if (contactDps > 0 && player.iframe <= 0) { - const dmg = contactDps * Math.max(dt, PLAYER.hurtIframe); // 以 iframe 窗为结算粒度,防高频小额抖动 + // 山纹甲减免;以 iframe 窗为结算粒度,防高频小额抖动 + const dmg = contactDps * Math.max(dt, PLAYER.hurtIframe) * (1 - pd.armor); player.hp -= dmg; stats.dmgTaken += dmg; player.iframe = PLAYER.hurtIframe; @@ -249,22 +447,24 @@ export function createSim(opts) { if (player.hp <= 0) { player.hp = 0; phase = 'lose'; emit('settle', player.x, player.y, { outcome: 'lose' }); return; } } - /* 5) 武器开火(fireEnabled=false 的对照局跳过=「关火纯走位」验收用例) */ + /* 5) 武器开火(12 件各按自身间隔;fireEnabled=false 的对照局跳过) */ if (fireEnabled) { - fireAcc += dt; - while (fireAcc >= derived.interval) { fireAcc -= derived.interval; fireVolley(); } + for (const w of weapons) { + w.fireAcc += dt; + while (w.fireAcc >= w.d.interval) { w.fireAcc -= w.d.interval; fireWeapon(w); } + } } - /* 6) 弹:走格命中(穿透 N 后回池;寿命尽回池) */ + /* 6) 弹:走格命中(穿透 N 后回池;寿命尽回池;链跳/爆裂/减速/灼烧机制在命中点结算) */ for (let i = projs.length - 1; i >= 0; i--) { const p = projs[i]; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; let dead = p.life <= 0; if (!dead) { - // 命中:查弹所在格邻域(cell 48 ≥ 弹步长 420*dt(60fps=7px)+最大敌半径,±1 格够) let hitIdx = -1; - grid.forNeighbors(p.x, p.y, 1, (j) => { + const ring = Math.max(1, Math.ceil(p.r / GRID_CELL) + 1); + grid.forNeighbors(p.x, p.y, ring, (j) => { if (hitIdx >= 0) return; const e = enemies[j]; const dx = e.x - p.x, dy = e.y - p.y; @@ -273,11 +473,24 @@ export function createSim(opts) { }); if (hitIdx >= 0) { const e = enemies[hitIdx]; - e.hp -= p.dmg; - e.hurtFlash = 0.08; // 手 6:受击闪白 ≤80ms(渲染层读) - stats.dmgDealt += p.dmg; - emit('hit', e.x, e.y, { dmg: p.dmg, eid: e.id }); // eid=飘字同帧同源聚合键(手 7) - if (e.hp <= 0) { killEnemy(hitIdx); rebuildGrid(); } // 击杀改变下标布局:重建网格保后续命中正确 + const wd = p.wd; + // 机制:减速/灼烧先挂(死了也无妨,字段随实体回池复位) + if (wd.slow && (e.slowPct < wd.slow.pct || e.slowT <= 0)) { e.slowPct = wd.slow.pct; e.slowT = wd.slow.s; } + if (wd.dot && e.dotDps <= wd.dot.dps) { e.dotDps = wd.dot.dps; e.dotT = wd.dot.s; e.dotWid = p.wid; } + const lethal = damageEnemy(hitIdx, p.dmg, p.wid, true); + const hx = e.x, hy = e.y; + // 直击致死先收(swap-remove 会挪动下标,后续 AoE 一律基于重建后的网格取受害者) + let exclude = hitIdx; + if (lethal) { killEnemy(hitIdx); rebuildGrid(); exclude = -1; } + // 爆裂:命中点半径内其余敌(直击者不吃二次;已死则无需排除) + if (wd.explode) { + const list = collectVictims(hx, hy, wd.explode.r, exclude); + if (applyToVictims(list, p.dmg * wd.explode.dmgPct, p.wid, false)) rebuildGrid(); + } + // 链跳:补最近 n 邻(直击者除外;网格已保证新鲜) + if (wd.chain) { + if (chainHits(hx, hy, exclude, wd.chain.n, p.dmg * wd.chain.dmgPct, p.wid)) rebuildGrid(); + } p.pierce--; if (p.pierce <= 0) dead = true; } @@ -288,29 +501,82 @@ export function createSim(opts) { } } - /* 7) 灵晶:磁吸(半径内转飞行态,ease-in 0.35–0.6s 追踪玩家=手 8) */ - const magR2 = derived.magnetR * derived.magnetR; + /* 7) 区域(orbit 随身转 / place 驻场):按 tick 对半径内敌脉冲结算;撼山符阵脉冲兼聚灵。 + 任何脉冲产生击杀立即重建网格——同帧后续区域/查询不得吃陈旧桶(下标已被 swap-remove 挪动)。 */ + for (let i = zones.length - 1; i >= 0; i--) { + const z = zones[i]; + z.life -= dt; + if (z.life <= 0) { zonePool.release(swapRemove(zones, i)); continue; } + if (z.kind === 'orbit') { + z.angle += z.wd.orbitAv * dt; + z.x = player.x + Math.cos(z.angle) * z.wd.orbitR; + z.y = player.y + Math.sin(z.angle) * z.wd.orbitR; + } + z.tickAcc += dt; + if (z.tickAcc >= z.wd.tick) { + z.tickAcc -= z.wd.tick; + const list = collectVictims(z.x, z.y, z.wd.zoneR, -1); + if (z.wd.slow) { + for (const j of list) { + const e = enemies[j]; + if (e.slowPct < z.wd.slow.pct || e.slowT <= 0) { e.slowPct = z.wd.slow.pct; e.slowT = z.wd.slow.s; } + } + } + if (z.wd.dot) { + for (const j of list) { + const e = enemies[j]; + if (e.dotDps <= z.wd.dot.dps) { e.dotDps = z.wd.dot.dps; e.dotT = z.wd.dot.s; e.dotWid = z.wid; } + } + } + if (applyToVictims(list, z.wd.dmg, z.wid, true)) rebuildGrid(); + // 撼山符阵:脉冲把区域内散晶震飞向玩家(全图磁吸的局部形态) + if (z.wd.gemPull) { + for (const g of gems) { + if (g.state !== 'idle') continue; + const dx = g.x - z.x, dy = g.y - z.y; + if (dx * dx + dy * dy < z.wd.zoneR * z.wd.zoneR) startGemFly(g, XP.flyMax); + } + } + } + } + + /* 8) 聚灵珠:接触拾取 → 全图磁吸(手 8)+ 收割事件(手 6 族闪屏档);过期消散 */ + for (let i = drops.length - 1; i >= 0; i--) { + const d0 = drops[i]; + d0.ttl -= dt; + if (d0.ttl <= 0) { drops.splice(i, 1); continue; } + const dx = d0.x - player.x, dy = d0.y - player.y; + const rr = HARVEST.r + player.r; + if (dx * dx + dy * dy < rr * rr) { + let n = 0; + for (const g of gems) if (g.state === 'idle') { startGemFly(g, XP.flyMax); n++; } + stats.harvests++; + emit('harvest', player.x, player.y, { count: n }); + drops.splice(i, 1); + } + } + + /* 9) 灵晶:磁吸(半径内转飞行态,ease-in 0.35–0.6s 追踪玩家=手 8) */ + const magnetR = XP.gemMagnetR * pd.magnetMul; + const magR2 = magnetR * magnetR; for (let i = gems.length - 1; i >= 0; i--) { const g = gems[i]; if (g.state === 'idle') { const dx = player.x - g.x, dy = player.y - g.y; const d2 = dx * dx + dy * dy; if (d2 < magR2) { - g.state = 'fly'; g.flyT = 0; - g.sx = g.x; g.sy = g.y; // 飞行时长按起始距离在 [flyMin, flyMax] 内取(近快远慢的绝对时长一致感) - const dRatio = Math.min(1, Math.sqrt(d2) / derived.magnetR); - g.flyDur = XP.flyMin + (XP.flyMax - XP.flyMin) * dRatio; + const dRatio = Math.min(1, Math.sqrt(d2) / magnetR); + startGemFly(g, XP.flyMin + (XP.flyMax - XP.flyMin) * dRatio); } } else { g.flyT += dt; const p01 = Math.min(1, g.flyT / g.flyDur); const k = easeInQuad(p01); - // 追踪活玩家位置(非固定终点):先慢后快吸向玩家 g.x = g.sx + (player.x - g.sx) * k; g.y = g.sy + (player.y - g.sy) * k; if (p01 >= 1) { - xp += g.value; + xp += g.value * pd.xpMul; // 纳灵诀:灵晶价值乘区 stats.gems += g.value; emit('pickup', player.x, player.y, { value: g.value }); swapRemove(gems, i); @@ -319,14 +585,21 @@ export function createSim(opts) { } } - /* 8) 升级判定(冻结在弹层;choose 后回 play) */ + /* 10) 升级判定(冻结在弹层;choose 后回 play) */ tryLevelUp(); if (phase !== 'play') return; - /* 9) 撑满即胜(M0 占位胜利线) */ + /* 11) 撑满即胜(占位胜利线;20 分钟波表+终 Boss 归 M3) */ if (t >= ROUND_S) { phase = 'win'; emit('settle', player.x, player.y, { outcome: 'win' }); } } + /** 灵晶转飞行态(磁吸/聚灵珠/撼山符阵共用)。 */ + function startGemFly(g, dur) { + g.state = 'fly'; g.flyT = 0; + g.sx = g.x; g.sy = g.y; + g.flyDur = dur; + } + /* ══════════════════════════ 输入与命令面 ══════════════════════════ */ /** @@ -337,6 +610,7 @@ export function createSim(opts) { const d = Math.hypot(x, y); if (d < 1e-6) { player.dirX = 0; player.dirY = 0; return; } player.dirX = x / d; player.dirY = y / d; + player.lastDirX = player.dirX; player.lastDirY = player.dirY; // movedir 族射向 } /** 三选一选择(i = offer 下标;phase!=='levelup' 时 no-op)。单击直选=手 9。 */ @@ -344,12 +618,23 @@ export function createSim(opts) { if (phase !== 'levelup') return false; const pick = offer[i]; if (pick == null) return false; - const up = UPGRADES[pick]; - upLv[up.id]++; - recalcDerived(); + if (pick.kind === 'evo') { + const w = weaponById(pick.id); + w.evolved = true; + stats.evolutions++; + emit('chosen', player.x, player.y, { kind: 'evo', id: pick.id }); + } else if (pick.kind === 'weapon') { + const w = weaponById(pick.id); + if (w) w.lv++; + else addWeapon(pick.id); + emit('chosen', player.x, player.y, { kind: 'weapon', id: pick.id, lv: w ? w.lv : 1 }); + } else { + plv[pick.id] = (plv[pick.id] | 0) + 1; + emit('chosen', player.x, player.y, { kind: 'passive', id: pick.id, lv: plv[pick.id] }); + } + recalcAll(); offer = []; phase = 'play'; - emit('chosen', player.x, player.y, { id: up.id, lv: upLv[up.id] }); // 升级可能连锁(一次拾取跨多级):立即复查 tryLevelUp(); return true; @@ -372,29 +657,39 @@ export function createSim(opts) { score: stats.kills, timeS: Math.round(t * 10) / 10, pos: { x: Math.round(player.x * 10) / 10, y: Math.round(player.y * 10) / 10 }, - level, xp, xpNeed, + level, xp: Math.round(xp * 10) / 10, xpNeed, hp: Math.ceil(player.hp), maxHp: player.maxHp, enemies: enemies.length, projs: projs.length, gems: gems.length, - upgrades: { ...upLv }, - offer: offer.map((idx) => ({ i: idx, id: UPGRADES[idx].id, name: UPGRADES[idx].name })), - stats: { ...stats }, + zones: zones.length, drops: drops.length, + difficulty: diffKey, + weapons: weapons.map((w) => ({ id: w.id, name: w.def.name, lv: w.lv, evolved: w.evolved })), + passives: { ...plv }, + offer: offer.map(describeOffer), + stats: { ...stats, dmgByWeapon: { ...stats.dmgByWeapon } }, seed, }; } /** 渲染视图(热路径直读引用,渲染层只读不写;不做 JSON 拷贝以守帧预算)。 */ function view() { - return { t, phase, frame, player, enemies, projs, gems, level, xp, xpNeed, offer, stats, derived }; + return { + t, phase, frame, player, enemies, projs, gems, zones, drops, + level, xp, xpNeed, offer: offer.map(describeOffer), stats, + weapons, passives: plv, pd, bossBar, difficulty: diffKey, + magnetR: XP.gemMagnetR * pd.magnetMul, + }; } /** - * 【仅测试/压测用】直接合成实体填充局面(M0 门「合成压测场景」与单测夹具;生产路径不调用)。 - * enemies/projs/gems 按给定数量在玩家周围环带内确定性铺开(用受控 rng)。 + * 【仅测试/压测用】直接合成实体填充局面(合成压测与单测夹具;生产路径不调用)。 + * M2 增:boss = {name,hp,maxHp} 注入 Boss 血条预览(HUD 绘制面断言;真 Boss 归 M3)。 */ function _debugFill(counts) { const ne = (counts && counts.enemies) || 0; const np = (counts && counts.projs) || 0; const ng = (counts && counts.gems) || 0; + if (counts && counts.boss) bossBar = { ...counts.boss }; + if (counts && counts.drop) drops.push({ x: player.x + counts.drop.dx, y: player.y + counts.drop.dy, id: ++eidSeq, ttl: HARVEST.ttlS }); const types = Object.keys(ENEMIES); for (let i = 0; i < ne; i++) { const def = ENEMIES[types[i % types.length]]; @@ -407,15 +702,17 @@ export function createSim(opts) { e.y = player.y + Math.sin(ang) * rad; e.hp = def.hp; e.maxHp = def.hp; e.r = def.r; e.speed = def.speed; e.dps = def.dps; e.gem = def.gem; - e.hurtFlash = 0; + e.hurtFlash = 0; e.slowT = 0; e.slowPct = 0; e.dotT = 0; e.dotDps = 0; e.dotWid = null; enemies.push(e); } + const wd0 = weapons[0].d; for (let i = 0; i < np; i++) { const ang = rng() * Math.PI * 2; const p = projPool.acquire(); p.x = player.x + Math.cos(ang) * 30; p.y = player.y + Math.sin(ang) * 30; - p.vx = Math.cos(ang) * WEAPON.projSpeed; p.vy = Math.sin(ang) * WEAPON.projSpeed; - p.dmg = derived.dmg; p.r = WEAPON.projR; p.life = WEAPON.life; p.pierce = WEAPON.pierce; + p.vx = Math.cos(ang) * wd0.projSpeed; p.vy = Math.sin(ang) * wd0.projSpeed; + p.dmg = wd0.dmg; p.r = wd0.projR; p.life = wd0.life; p.pierce = wd0.pierce; + p.wid = weapons[0].id; p.wd = wd0; projs.push(p); } for (let i = 0; i < ng; i++) { @@ -428,5 +725,36 @@ export function createSim(opts) { } } - return { update, setMove, choose, drainEvents, readState, view, _debugFill, get phase() { return phase; } }; + /** + * 【仅测试用】直接授予武器/被动等级(进化触发条件、暴击轴、槽位闸等单测夹具; + * 生产路径不调用——正式获取只走 choose)。 + */ + function _debugGrant(kind, id, lv) { + if (kind === 'weapon') { + const w = weaponById(id) || addWeapon(id); + w.lv = Math.max(1, Math.min(w.def.maxLv, lv | 0)); + } else if (kind === 'evo') { + const w = weaponById(id); + if (!w) return false; + w.evolved = true; + } else { + const p = PASSIVE_BY_ID[id]; + if (!p) return false; + plv[id] = Math.max(0, Math.min(p.maxLv, lv | 0)); + } + recalcAll(); + return true; + } + + /** 【仅测试用】直灌经验并触发升级判定(免铺晶;offer 语义与正式路径完全一致)。 */ + function _debugGrantXp(amount) { + xp += Number(amount) || 0; + tryLevelUp(); + } + + return { + update, setMove, choose, drainEvents, readState, view, + _debugFill, _debugGrant, _debugGrantXp, + get phase() { return phase; }, + }; } diff --git a/game-runtime/games/shanhai-xingji/test/build-tables.test.mjs b/game-runtime/games/shanhai-xingji/test/build-tables.test.mjs new file mode 100644 index 00000000..2456a0e9 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/test/build-tables.test.mjs @@ -0,0 +1,216 @@ +/** + * build-tables.test.mjs — M2 build 体系数据表与机制门(node:test 零依赖;lane 本机可跑) + * + * 【承重(书面口径逐条)】 + * - §5.2 内容量表:武器 12(各 8 级,五族全覆盖)/ 被动 14(各 5 级)/ 进化 12; + * - §8.1 等级系数:Lv1=1.0 → Lv8≈3.2(canonical 模型带 [2.9,3.5],容 count 粒度摆动); + * - §3.1 进化验收用例原文:条件 = 武器满级 且 对应被动 ≥3 级,「满足后下一次升级三选一里 + * 必然出现进化项」;进化 DPS 跃迁落基础满级的 [1.8,2.5] 倍带(理论模型口径见 build-model 头注); + * - 手 4 事件源:暴击(破煞诀)伤害 ×2 且吐 crit 事件; + * - 手 6/手 8 事件源:聚灵珠接触拾取 → 全部散晶转飞行 + harvest 事件; + * - §11 槽位:武器 6 / 被动 6,满槽后三选一不再出新件; + * - 难度表:D1 乘区真作用在个体(hp×1.45)。 + */ + +'use strict'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSim } from '../src/sim.js'; +import { canonicalDps, deriveWeapon, derivePlayer } from '../src/build-model.js'; +import { WEAPONS, PASSIVES, DIFFICULTY, ENEMIES, CRIT, SLOTS, EVO_PASSIVE_MIN } from '../src/balance.js'; + +const DT = 1 / 60; + +test('§5.2 表完整性:武器 12×8 级五族全覆盖、被动 14×5 级、进化 12 配对合法且互异', () => { + assert.equal(WEAPONS.length, 12, '武器 12'); + assert.equal(PASSIVES.length, 14, '被动 14'); + const wids = new Set(WEAPONS.map((w) => w.id)); + assert.equal(wids.size, 12, '武器 id 唯一'); + const families = new Set(WEAPONS.map((w) => w.family)); + assert.deepEqual([...families].sort(), ['movedir', 'nearest', 'orbit', 'place', 'random'], '五族索敌全覆盖'); + const pids = new Set(PASSIVES.map((p) => p.id)); + assert.equal(pids.size, 14, '被动 id 唯一'); + for (const p of PASSIVES) assert.equal(p.maxLv, 5, `${p.id} 5 级`); + const evoNames = new Set(); + const evoPassives = new Set(); + for (const w of WEAPONS) { + assert.equal(w.maxLv, 8, `${w.id} 8 级`); + assert.equal(w.levels.length, 8, `${w.id} 等级行 8`); + assert.ok(w.name && w.desc, `${w.id} 名称与描述`); + assert.ok(w.evo && w.evo.name && w.evo.desc, `${w.id} 有进化线`); + assert.ok(pids.has(w.evo.passive), `${w.id} 进化被动 ${w.evo.passive} 在被动表内`); + evoNames.add(w.evo.name); + evoPassives.add(w.evo.passive); + // 机制跃迁非纯数值(§5.2):每条进化至少带一个机制字段 + const m = w.evo.mods; + const mech = m.chain || m.explode || m.dot || m.slow || m.mirror || m.gemPull || m.pierceAdd >= 90; + assert.ok(mech, `${w.id} 进化带机制跃迁字段(非纯数值)`); + } + assert.equal(evoNames.size, 12, '进化 12 条名称互异'); + assert.equal(evoPassives.size, 12, '进化配对被动 12 个互异(14 池内)'); + // 派生模型对全表可解(不抛、数值有限) + for (const w of WEAPONS) { + for (let lv = 1; lv <= 8; lv++) { + const d = deriveWeapon(w, lv, false, { atk: 5, haste: 5, multi: 5, area: 5, crit: 5, speed: 5, armor: 5, regen: 5, magnet: 5, cooldown: 5, projspeed: 5, pierce: 5, duration: 5, xpgain: 5 }); + assert.ok(Number.isFinite(d.dmg) && d.dmg > 0, `${w.id} Lv${lv} 派生伤害有限`); + assert.ok(Number.isFinite(d.interval) && d.interval > 0, `${w.id} Lv${lv} 派生间隔有限`); + } + } + const pd = derivePlayer({ speed: 5, armor: 5 }); + assert.ok(pd.speedMul > 1 && pd.armor > 0 && pd.armor < 1, '玩家派生有限'); +}); + +test('§8.1 等级系数:全部 12 件 Lv8/Lv1 理论 DPS 比落 [2.9,3.5](锚 ≈3.2)', () => { + for (const w of WEAPONS) { + const r = canonicalDps(w, 8, false) / canonicalDps(w, 1, false); + assert.ok(r >= 2.9 && r <= 3.5, `${w.id} 等级系数 ${r.toFixed(2)} 应落 [2.9,3.5]`); + } +}); + +test('§3.1 进化跃迁带:全部 12 条 evo/Lv8 理论 DPS 比落 [1.8,2.5]', () => { + for (const w of WEAPONS) { + const r = canonicalDps(w, 8, true) / canonicalDps(w, 8, false); + assert.ok(r >= 1.8 && r <= 2.5, `${w.id} 进化跃迁 ${r.toFixed(2)} 应落 [1.8,2.5]`); + } +}); + +test('§3.1 进化必现:满级+被动≥3 后下一次三选一必然出现进化项;条件不满足必不出现', () => { + // 条件满足:雷符链 Lv8 + 疾雷咒 Lv3 + const sim = createSim({ seed: 501, fireEnabled: false }); + sim._debugGrant('weapon', 'leifu', 8); + sim._debugGrant('passive', 'haste', EVO_PASSIVE_MIN); + sim._debugGrantXp(99); + assert.equal(sim.phase, 'levelup', '直灌经验触发弹层'); + let s = sim.readState(); + const evo = s.offer.find((o) => o.kind === 'evo' && o.id === 'leifu'); + assert.ok(evo, `进化项必然出现(offer=${JSON.stringify(s.offer.map((o) => o.kind + ':' + o.id))})`); + assert.equal(evo.name, '九霄雷契', '进化名来自表'); + assert.equal(evo.badge, '进化', '进化角标'); + // 选中进化 → 武器 evolved,理论派生生效(count +1) + sim.choose(evo.i); + s = sim.readState(); + assert.ok(s.weapons.find((w) => w.id === 'leifu' && w.evolved), '进化落位'); + assert.equal(s.stats.evolutions, 1); + + // 条件不满足(被动只 2 级):同种子重放,进化项必不出现 + const sim2 = createSim({ seed: 501, fireEnabled: false }); + sim2._debugGrant('weapon', 'leifu', 8); + sim2._debugGrant('passive', 'haste', EVO_PASSIVE_MIN - 1); + sim2._debugGrantXp(99); + const s2 = sim2.readState(); + assert.ok(!s2.offer.some((o) => o.kind === 'evo'), '条件不满足不许出现进化项'); +}); + +test('手 4 事件源:破煞诀暴击 → crit 事件且伤害恰 ×CRIT.mul', () => { + const sim = createSim({ seed: 502 }); + sim._debugGrant('passive', 'crit', 5); // 30% 暴击率 + let crits = [], hits = []; + let guard = 0; + while (crits.length < 4 && guard++ < 3600) { + if (sim.phase === 'levelup') { sim.choose(0); continue; } + if (sim.phase !== 'play') break; + // 小敌群持续补(压测同法)+ 风筝走位:样本充足且存活稳定(大群会围死风筝圆) + if (sim.view().enemies.length < 6) sim._debugFill({ enemies: 6 }); + sim.setMove(Math.cos(guard * 0.02), Math.sin(guard * 0.02)); + sim.update(DT); + for (const ev of sim.drainEvents()) { + if (ev.type === 'crit') crits.push(ev); + else if (ev.type === 'hit') hits.push(ev); + } + } + assert.ok(crits.length >= 4, `30% 暴击率应出足量 crit 事件(crits=${crits.length}, hits=${hits.length})`); + assert.ok(hits.length > 0, '普通命中并存'); + // 同武器同级:crit.dmg = hit.dmg × CRIT.mul(全部命中来自雷符链 Lv1) + const critLeifu = crits.find((e) => e.wid === 'leifu'); + const hitLeifu = hits.find((e) => e.wid === 'leifu'); + assert.ok(critLeifu && hitLeifu, '两类事件都有雷符链源'); + assert.ok(Math.abs(critLeifu.dmg - hitLeifu.dmg * CRIT.mul) < 1e-9, `暴击伤害 ×${CRIT.mul}(${critLeifu.dmg} vs ${hitLeifu.dmg})`); + assert.equal(critLeifu.crit, true, 'crit 事件带 crit 标记'); +}); + +test('手 6/手 8 事件源:聚灵珠接触拾取 → 全部散晶转飞行 + harvest 事件 + 经验入账', () => { + const sim = createSim({ seed: 503, fireEnabled: false }); + sim._debugFill({ gems: 24, drop: { dx: 40, dy: 0 } }); + const v = sim.view(); + assert.equal(v.drops.length, 1, '聚灵珠在场'); + const idleBefore = v.gems.filter((g) => g.state === 'idle').length; + assert.ok(idleBefore >= 20, `远处散晶大多不在磁吸内(idle=${idleBefore})`); + // 走位踩珠(向右 40px;聚灵珠不受磁吸=必须接触) + sim.setMove(1, 0); + let harvested = null; + let guard = 0; + while (!harvested && guard++ < 120) { + sim.update(DT); + harvested = sim.drainEvents().find((e) => e.type === 'harvest') || null; + } + assert.ok(harvested, '接触聚灵珠吐 harvest 事件'); + assert.ok(harvested.count >= idleBefore - 4, `全图散晶转飞行(count=${harvested.count})`); + assert.equal(sim.view().drops.length, 0, '珠已消费'); + assert.equal(sim.view().stats.harvests, 1); + // 0.6s(flyMax)内全部到达玩家并入账 + sim.setMove(0, 0); + for (let f = 0; f < 60; f++) sim.update(DT); + const s = sim.readState(); + assert.equal(s.gems, 0, '散晶全部收讫(手 8:0.35–0.6s 到达)'); + assert.ok(s.xp > 0 || s.level > 1, '经验入账'); +}); + +test('§11 槽位闸:武器 6 槽满后三选一不出新武器;被动 6 槽同理', () => { + const sim = createSim({ seed: 504, fireEnabled: false }); + // 灌满 6 武器槽(全部未满级)+ 6 被动槽(全部未满级) + for (const id of ['leifu', 'jinge', 'hanyuan', 'fentian', 'xinghuo', 'tianlei']) sim._debugGrant('weapon', id, 3); + for (const id of ['atk', 'haste', 'multi', 'area', 'crit', 'speed']) sim._debugGrant('passive', id, 2); + const ownedW = new Set(['leifu', 'jinge', 'hanyuan', 'fentian', 'xinghuo', 'tianlei']); + const ownedP = new Set(['atk', 'haste', 'multi', 'area', 'crit', 'speed']); + // 连滚 8 次三选一(每次直灌恰好一级的经验),全部候选必须是持有件的升级 + for (let round = 0; round < 8; round++) { + sim._debugGrantXp(sim.readState().xpNeed - sim.readState().xp + 0.5); + assert.equal(sim.phase, 'levelup', `第 ${round + 1} 轮弹层`); + const s = sim.readState(); + for (const o of s.offer) { + if (o.kind === 'weapon') assert.ok(ownedW.has(o.id), `满槽后武器候选只能是持有件(见 ${o.id})`); + else if (o.kind === 'passive') assert.ok(ownedP.has(o.id), `满槽后被动候选只能是持有件(见 ${o.id})`); + } + sim.choose(0); + } + const end = sim.readState(); + assert.equal(end.weapons.length, 6, '武器恒 6 槽'); + assert.equal(Object.keys(end.passives).filter((k) => end.passives[k] > 0).length, 6, '被动恒 6 槽'); + assert.ok(SLOTS.weapons === 6 && SLOTS.passives === 6, '槽位常量与书面一致'); +}); + +test('难度表:D1 乘区作用在个体(hp ×1.45);D0/D1 解锁、D2+ 锁定', () => { + assert.equal(DIFFICULTY.d0.locked, false); + assert.equal(DIFFICULTY.d1.locked, false); + assert.ok(DIFFICULTY.d2.locked && DIFFICULTY.d5.locked, 'D2+ 本期锁定(草案骨架)'); + const sim = createSim({ seed: 505, fireEnabled: false, difficulty: 'd1' }); + let guard = 0; + while (sim.view().enemies.length === 0 && guard++ < 300) sim.update(DT); + const e = sim.view().enemies[0]; + assert.ok(e, '出怪'); + const base = ENEMIES[e.type].hp; + assert.ok(Math.abs(e.maxHp - base * DIFFICULTY.d1.hpMul) < 1e-9, `D1 个体 hp=${e.maxHp} 应为基础 ${base}×${DIFFICULTY.d1.hpMul}`); + assert.equal(sim.readState().difficulty, 'd1', '难度键入取证面'); +}); + +test('五族武器全部真出伤:nearest/random/movedir/orbit/place 各至少一件 dmgByWeapon>0', () => { + const sim = createSim({ seed: 506 }); + // 五族各授一件(nearest=leifu 初始已有) + sim._debugGrant('weapon', 'tianlei', 3); // random + sim._debugGrant('weapon', 'fengren', 3); // movedir + sim._debugGrant('weapon', 'shanzi', 3); // orbit(首轮循环 4s,须活过) + sim._debugGrant('weapon', 'fuling', 3); // place + sim._debugFill({ enemies: 80 }); + // 风筝走位(movedir 族有射向 + 玩家活过 orbit/place 的循环周期;静止会被围死) + for (let f = 0; f < 900; f++) { + if (sim.phase === 'levelup') { sim.choose(0); continue; } + if (sim.phase !== 'play') break; + sim.setMove(Math.cos(f * 0.02), Math.sin(f * 0.02)); + sim.update(DT); + } + const by = sim.readState().stats.dmgByWeapon; + for (const id of ['leifu', 'tianlei', 'fengren', 'shanzi', 'fuling']) { + assert.ok((by[id] || 0) > 0, `${id} 应有真实伤害产出(dmgByWeapon=${JSON.stringify(Object.keys(by))})`); + } +}); diff --git a/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs b/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs index 730c5e5a..689abfb7 100644 --- a/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs +++ b/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs @@ -138,14 +138,45 @@ test('手 5/6 接线:受击触发震屏(幅度≤上限)、击杀触发爆浆 bu assert.ok(bursts.every((c) => c >= 8 && c <= 14), `爆浆数量全落 8–14 带(实际 ${JSON.stringify([...new Set(bursts)].sort())})`); }); +test('手 4 暴击档全链(M2 事件源):sim crit 事件 → 反馈泵 → hf 取证 events[] + 顿帧 timeScale 下探', async () => { + const { game, plugins, step } = await assemble(26); + game._handleAction({ type: 'start' }); + const F = game._forensicsView(); + // 缓圆走位;三选一里见「破煞诀」必选(否则第一张)——凑出暴击轴后暴击必然发生(30% 级联) + let critLv = 0, minTs = 1, guard = 0; + while (guard++ < 7000) { + const st = F.state(); + if (st.phase === 'levelup') { + const i = st.offer.findIndex((o) => o.id === 'crit'); + game._handleAction({ type: 'choose', i: i >= 0 ? i : 0 }); + if (i >= 0) critLv++; + continue; + } + if (st.phase !== 'play') break; + game._handleAction({ type: 'move', x: Math.cos(guard * 0.006), y: Math.sin(guard * 0.006) }); + step(1); + if (plugins.juice.getTimeScale) minTs = Math.min(minTs, plugins.juice.getTimeScale()); + if (critLv > 0 && F.measures().events.some((e) => e.eventName === 'crit')) break; + } + assert.ok(critLv > 0, `应凑到破煞诀(guard=${guard})`); + const crit = F.measures().events.find((e) => e.eventName === 'crit'); + assert.ok(crit, '暴击事件进 hf 取证面(sim→泵→hf 全链)'); + assert.ok(crit.visualEventFrame === crit.frame && crit.sourceId != null, '取证字段齐(帧戳+聚合键)'); + assert.ok(minTs < 1, `暴击顿帧应观测到 timeScale 下探(min=${minTs.toFixed(2)};档位 30ms)`); +}); + test('手 9 组件语义:弹出即可点(动画期选卡即生效)、弹层动画配置 =250ms', async () => { const { game, plugins, step } = await assemble(25); void plugins; game._handleAction({ type: 'start' }); const F = game._forensicsView(); - // 喂经验到升级(AFK 打怪捡晶;上限 3000 帧) + // 喂经验到升级:确定性缓圆走位(半径 ≈ 速度/角速,扫过击杀环收晶;静止局灵晶大多 + // 落在磁吸半径外、到达升级与否对 rng 流擦线敏感——M1 期即只差 1 颗,非本测意图) let guard = 0; - while (F.state().phase !== 'levelup' && guard++ < 3000) step(1); + while (F.state().phase !== 'levelup' && guard++ < 4000) { + game._handleAction({ type: 'move', x: Math.cos(guard * 0.006), y: Math.sin(guard * 0.006) }); + step(1); + } assert.equal(F.state().phase, 'levelup', '应到升级弹层'); const targets = F.state().targets; assert.ok(targets.length >= 1 && targets[0].kind === 'card', 'targets = picker.layout 卡心'); diff --git a/game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs b/game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs index 0259dd42..61125e77 100644 --- a/game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs +++ b/game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs @@ -84,10 +84,15 @@ test('无浏览器全链路:boot→开巡→AFK 击杀→三选一真点卡→ host.stepFrames(600); assert.ok(F.state().score > 0, `AFK 10s 应有击杀(score=${F.state().score})`); - // ④ 驱动到升级弹层:真 tap 第一张卡(手 9 单击直选)。 + // ④ 驱动到升级弹层:缓圆走位收晶(静止局灵晶多落磁吸外,对 rng 流擦线敏感), + // 到达后真 tap 第一张卡(手 9 单击直选)。 let guard = 0; - while (F.state().phase === 'play' && guard++ < 300) host.stepFrames(30); - if (F.state().phase === 'levelup') { + while (F.state().phase === 'play' && guard++ < 300) { + host.do({ type: 'move', x: Math.cos(guard * 0.18), y: Math.sin(guard * 0.18) }); + host.stepFrames(30); + } + assert.equal(F.state().phase, 'levelup', `缓圆走位应在上限内攒到首次升级(guard=${guard})`); + { const lvl = F.state().level; tapTarget(host, F, 'card'); host.stepFrames(2); @@ -95,13 +100,15 @@ test('无浏览器全链路:boot→开巡→AFK 击杀→三选一真点卡→ assert.ok(F.state().level >= lvl, '等级不回退'); } - // ⑤ 驱动到终局:AFK 必被兽潮压死(S1 口径 ≤90s;弹层一律选第一张)。 + // ⑤ 驱动到终局(win/lose 任一:站定被压死,或 build 攒硬撑到 300s 胜利 latch—— + // M2 经济修复后 AFK 乱选也可能筑塔硬抗,「必死」不再是可靠前提;两种终局都出「再巡」钮)。 + host.do({ type: 'move', x: 0, y: 0 }); guard = 0; - while ((F.state().phase === 'play' || F.state().phase === 'levelup') && guard++ < 400) { + while ((F.state().phase === 'play' || F.state().phase === 'levelup') && guard++ < 800) { if (F.state().phase === 'levelup') { tapTarget(host, F, 'card'); host.stepFrames(2); } else host.stepFrames(30); } - assert.equal(F.state().phase, 'lose', `AFK 应被压死切 lose(guard=${guard})`); + assert.ok(['win', 'lose'].includes(F.state().phase), `应到终局(phase=${F.state().phase}, guard=${guard})`); const kills = F.state().score; assert.ok(kills > 0 && F.state().best >= kills, `最佳击杀应 ≥ 本局(best=${F.state().best}, kills=${kills})`); diff --git a/game-runtime/games/shanhai-xingji/test/sim.test.mjs b/game-runtime/games/shanhai-xingji/test/sim.test.mjs index 6487e48e..33c87004 100644 --- a/game-runtime/games/shanhai-xingji/test/sim.test.mjs +++ b/game-runtime/games/shanhai-xingji/test/sim.test.mjs @@ -74,14 +74,21 @@ test('手 9 三选一:弹出即全局冻结(t 不前进),choose 应用被动并 for (let f = 0; f < 60; f++) sim.update(DT); // 冻结期空转 1 秒 assert.equal(sim.readState().timeS, tAt, '冻结期 t 不前进(手 9)'); const lvBefore = sim.readState().level; + const picksBefore = buildPickSum(sim.readState()); const okChoose = sim.choose(0); assert.ok(okChoose, 'choose 生效'); assert.equal(sim.phase, 'play', '选卡后复跑'); assert.equal(sim.readState().level, lvBefore, '等级已在弹出时结算,choose 不再加'); - const ups = sim.readState().upgrades; - assert.equal(Object.values(ups).reduce((s, x) => s + x, 0), 1, '被动 +1 级'); + assert.equal(buildPickSum(sim.readState()), picksBefore + 1, 'choose 恰好落一件(武器级/新武器/被动级三者之一)'); }); +/** build 获取总量:武器等级和 + 被动等级和(choose 一次恰 +1 的守恒量)。 */ +function buildPickSum(s) { + const w = s.weapons.reduce((sum, x) => sum + x.lv, 0); + const p = Object.values(s.passives).reduce((sum, x) => sum + x, 0); + return w + p; +} + test('手 1 摇杆死区(壳层):死区内不动,死区外归一移动', async () => { const game = createGame({ plugins: {}, viewport: { w: 390, h: 844 } }); await game.init({}); // 无输入面:跳过订阅(wrapper 才订阅;本测直接调 handlePointer) diff --git a/game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs b/game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs new file mode 100644 index 00000000..12852ad8 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs @@ -0,0 +1,201 @@ +/** + * ui-trigger2.test.mjs — 触发器②三项交付的 headless 断言面(M2 收口,书 §7.2) + * + * 【口径(如实分层)】触发器②书面判定 = 「CDP 走查断言 + 截图留证」(mini-desktop 窗口); + * 本文件先把三项交付的**结构与交互语义**在本机钉死(录制型 canvas 桩:捕获 fillText 文案与 + * 矩形调用),CDP 走查只剩「真浏览器像素/输入」一层。三项 = ①局内 HUD(计时/击杀/XP 条/ + * Boss 血条)绘制齐 ②升级三选一弹层三卡可点击直选 ③出击页(一页 meta)可滚动可命中。 + */ + +'use strict'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { PluginRegistry, createHostDevContext } from '../../../src/core/plugin.js'; +import { buildShanhaiHostConfig } from '../src/host-config.js'; +import { menuLayout, layoutPauseButton, layoutButton } from '../src/render.js'; +import { DIFFICULTY } from '../src/balance.js'; + +const DT = 1 / 60; +const VP = { w: 390, h: 844 }; + +/** 录制型 2D 桩:捕获 fillText 文案与 fillRect/strokeRect 几何(其余调用全吞)。 */ +function makeRecording2d() { + const rec = { texts: [], fillRects: [], strokeRects: [] }; + const grad = { addColorStop() {} }; + const base = { + canvas: { width: 780, height: 1688 }, + createLinearGradient: () => grad, + createRadialGradient: () => grad, + getImageData: () => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 }), + putImageData() {}, + measureText: () => ({ width: 0 }), + fillText: (s, x, y) => rec.texts.push({ s: String(s), x, y }), + fillRect: (x, y, w, h) => rec.fillRects.push({ x, y, w, h }), + strokeRect: (x, y, w, h) => rec.strokeRects.push({ x, y, w, h }), + }; + const fnCache = new Map(); + const g = new Proxy(base, { + get(target, prop) { + if (prop in target) return target[prop]; + if (!fnCache.has(prop)) fnCache.set(prop, () => {}); + return fnCache.get(prop); + }, + set(target, prop, v) { target[prop] = v; return true; }, + }); + return { g, rec }; +} + +/** 真装配 harness(与 feel-assertions 同式:全插件 + 双泵)。 */ +async function assemble(seed) { + const bundle = createHostDevContext({ context2d: null }); + const cfg = buildShanhaiHostConfig({ seed: seed || 1 }); + const reg = new PluginRegistry(); + reg.useContext(bundle.context); + for (const p of cfg.registerOrder) reg.register(p); + assert.equal(reg.initAll().ok, true); + const game = cfg.factory({ runtime: { plugins: cfg.plugins, viewport: cfg.viewport } }); + await game.init({}); + const step = (n) => { for (let i = 0; i < (n || 1); i++) { game.update(DT); bundle.tick(DT); } }; + return { game, plugins: cfg.plugins, step }; +} + +test('触发器②-1 HUD 四件绘制齐:计时/击杀/XP 条 + Boss 血条(渲染面,_debugFill 注入预览)', async () => { + const { game, step } = await assemble(31); + game._handleAction({ type: 'start' }); + step(90); // 1.5s:计时走起、XP 条有底 + const { g, rec } = makeRecording2d(); + game.render(g); + // 计时(mm:ss)与击杀 + assert.ok(rec.texts.some((t) => /^\d\d:\d\d$/.test(t.s)), `HUD 计时(${JSON.stringify(rec.texts.slice(0, 8).map((t) => t.s))})`); + assert.ok(rec.texts.some((t) => /^击杀 \d+$/.test(t.s)), 'HUD 击杀数'); + assert.ok(rec.texts.some((t) => /^Lv \d+$/.test(t.s)), 'HUD 等级'); + // 通栏 XP 条:y=0 高 6 的底条(w=全宽) + assert.ok(rec.fillRects.some((r) => r.y === 0 && r.h === 6 && r.w === VP.w), 'XP 条底(通栏)'); + // 暂停钮(右上) + const pb = layoutPauseButton(VP); + assert.ok(rec.strokeRects.some((r) => r.x === pb.x && r.y === pb.y), 'HUD 暂停钮'); + // Boss 血条位:注入预览(真 Boss 事件源归 M3;渲染面本期交付) + const F = game._forensicsView(); + void F; + // 经取证不可达 sim 内部,走 debug 注入面:重新造带 boss 的局并直画 + const { createSim } = await import('../src/sim.js'); + const { drawGame } = await import('../src/render.js'); + const sim2 = createSim({ seed: 32 }); + sim2._debugFill({ boss: { name: '幽谷玄狰', hp: 55, maxHp: 100 } }); + sim2.update(DT); + const rec2 = makeRecording2d(); + drawGame(rec2.g, { scene: 'run', sim: sim2, joy: { active: false }, fx: { list: [] }, viewport: VP, best: 0, paused: false }); + assert.ok(rec2.rec.texts.some((t) => t.s === '幽谷玄狰'), 'Boss 名绘出'); + assert.ok(rec2.rec.fillRects.some((r) => r.y === 70 && r.h === 10 && r.w === VP.w - 90), 'Boss 血条底(y70 通栏)'); + assert.ok(rec2.rec.fillRects.some((r) => r.y === 70 && r.h === 10 && Math.abs(r.w - (VP.w - 90) * 0.55) < 1e-6), 'Boss 血条按 hp 比例填充'); +}); + +test('触发器②-2 三选一弹层:三卡布局齐、中心命中映射 0/1/2、单击直选生效', async () => { + const { game, step } = await assemble(33); + game._handleAction({ type: 'start' }); + const F = game._forensicsView(); + let guard = 0; + while (F.state().phase !== 'levelup' && guard++ < 4000) { + game._handleAction({ type: 'move', x: Math.cos(guard * 0.006), y: Math.sin(guard * 0.006) }); + step(1); + } + assert.equal(F.state().phase, 'levelup', '到达三选一'); + const targets = F.state().targets; + assert.equal(targets.length, 3, '三张卡(targets=picker.layout 同源)'); + targets.forEach((tg, i) => assert.equal(tg.kind, 'card', `卡 ${i}`)); + // 渲染面:三张卡文案真绘出(offer 自描述:名称+角标) + const { g, rec } = makeRecording2d(); + game.render(g); + const offer = F.state().offer; + for (const o of offer) assert.ok(rec.texts.some((t) => t.s === o.name), `卡面绘出「${o.name}」`); + // 单击直选:点第 2 张卡中心 → 立即生效回 play + game.handlePointer('down', targets[1].x, targets[1].y); + step(2); + assert.equal(F.state().phase, 'play', '单击直选生效(弹出即可点,手 9)'); +}); + +test('触发器②-3 出击页:可滚动(拖拽 scrollY 变化)可命中(点难度行切换 + 开巡进局)', async () => { + const { game, step } = await assemble(35); + const F = game._forensicsView(); + assert.equal(F.state().phase, 'menu', '初始在出击页'); + const L = menuLayout(VP); + const cx = L.listRect.x + L.listRect.w / 2; + // 滚动前探针 + let m = F.measures().menu; + assert.equal(m.scrollY, 0); + assert.ok(m.maxScroll > 0, `内容溢出容器(maxScroll=${m.maxScroll})=真滚动面`); + assert.equal(m.selDifficulty, 'd0', '缺省 D0'); + // 两段拖拽上滑共 600px → 滚动(row17=D1 行需 ≥466 才进视区) + game.handlePointer('down', cx, L.listRect.y + 400); + game.handlePointer('move', cx, L.listRect.y + 0); + game.handlePointer('up', cx, L.listRect.y + 0); + m = F.measures().menu; + assert.equal(m.scrollY, 400, `第一段拖拽滚动生效(scrollY=${m.scrollY})`); + game.handlePointer('down', cx, L.listRect.y + 300); + game.handlePointer('move', cx, L.listRect.y + 100); + game.handlePointer('up', cx, L.listRect.y + 100); + m = F.measures().menu; + assert.equal(m.scrollY, 600, `第二段拖拽累计(scrollY=${m.scrollY})`); + // 渲染面:滚动后头部行(巡使段)已滚出,后段(潮汛)可见 + const { g, rec } = makeRecording2d(); + game.render(g); + assert.ok(rec.texts.some((t) => t.s.includes('潮汛')), '滚动后潮汛段进入视区'); + // 命中:点 D1 行(潮汛段第 2 行)→ 难度切换 + // 行几何从探针换算:潮汛 D1 行 index = 3(头)+8(巡使)+1(头)+5(巡域)... 用文案定位更稳: + // 直接借 scrollList 数学——rows: 0 头巡使,1..8 巡使,9 头巡域,10..14 巡域,15 头潮汛,16=d0,17=d1 + const rowH = L.rowH; + const d1RowY = L.listRect.y + 17 * rowH - m.scrollY + rowH / 2; + assert.ok(d1RowY > L.listRect.y && d1RowY < L.listRect.y + L.listRect.h, `D1 行在视区(y=${d1RowY})`); + game.handlePointer('down', cx, d1RowY); + game.handlePointer('up', cx, d1RowY); + m = F.measures().menu; + assert.equal(m.selDifficulty, 'd1', '点 D1 行切难度'); + assert.equal(DIFFICULTY.d1.locked, false, 'D1 本期解锁'); + // 锁定行点击无效(D2 = 行 18) + const d2RowY = L.listRect.y + 18 * rowH - m.scrollY + rowH / 2; + if (d2RowY < L.listRect.y + L.listRect.h) { + game.handlePointer('down', cx, d2RowY); + game.handlePointer('up', cx, d2RowY); + assert.equal(F.measures().menu.selDifficulty, 'd1', '锁定难度点击不生效'); + } + // 开巡:按所选难度进局 + const sb = layoutButton(VP, 'start'); + game.handlePointer('down', sb.x + sb.w / 2, sb.y + sb.h / 2); + step(2); + assert.equal(F.state().phase, 'play', '开巡进局'); + assert.equal(F.state().difficulty, 'd1', '所选难度真传入局(D1)'); +}); + +test('暂停 build 总览(§11):pause → 总览面板(武器/被动/统计)绘制齐 + 恢复钮可点', async () => { + const { game, step } = await assemble(37); + game._handleAction({ type: 'start' }); + const F = game._forensicsView(); + step(120); // 2s 进局 + // 暂停钮命中(play 期 HUD 右上) + const pb = layoutPauseButton(VP); + game.handlePointer('down', pb.x + pb.w / 2, pb.y + pb.h / 2); + assert.equal(F.state().phase, 'paused', '点暂停钮进暂停'); + const tBefore = F.state().timeS; + step(120); // 冻结 2s + assert.equal(F.state().timeS, tBefore, '暂停期 sim 时间不前进(壳层冻结)'); + // 总览面板绘制:标题/分区/初始武器名/空槽/统计 + const { g, rec } = makeRecording2d(); + game.render(g); + const texts = rec.texts.map((t) => t.s); + assert.ok(texts.includes('暂停 · 本局构筑'), '总览标题'); + assert.ok(texts.includes('— 符器 —') && texts.includes('— 被动 —') && texts.includes('— 本局 —'), '三分区'); + assert.ok(texts.includes('雷符链'), '武器槽显初始武器'); + assert.ok(texts.filter((s) => s === '—').length >= 5, '空槽虚位(武器 5 空 + 被动 6 空)'); + assert.ok(texts.some((s) => /^击杀 \d+$/.test(s)), '本局统计'); + // 恢复钮 = 取证 targets 唯一目标;点击回 play + const targets = F.state().targets; + assert.equal(targets.length, 1); + assert.equal(targets[0].kind, 'resume'); + game.handlePointer('down', targets[0].x, targets[0].y); + assert.equal(F.state().phase, 'play', '恢复继续'); + const t2 = F.state().timeS; + step(60); + assert.ok(F.state().timeS > t2, '恢复后时间续走'); +}); diff --git a/game-runtime/src/plugins/canvas-ui-kit/PLUGIN.md b/game-runtime/src/plugins/canvas-ui-kit/PLUGIN.md index 9d2b9045..11804aa6 100644 --- a/game-runtime/src/plugins/canvas-ui-kit/PLUGIN.md +++ b/game-runtime/src/plugins/canvas-ui-kit/PLUGIN.md @@ -1,15 +1,16 @@ # canvas-ui-kit — canvas UI 组合件(卡片弹层 + 顶栏布局) -> version 1.0.0 | core-protocol-v0 | gzBudgetBytes 3072 +> version 1.1.0 | core-protocol-v0 | gzBudgetBytes 3072 ## 1. 能力 hud-ui 绘制原语之上的**组合件**首版(渠道 canvas-only 纪律下的 UI 骨架): - `createCardPicker({viewport, animMs, style, draw})`:模态卡片选择器——N 卡竖排布局、开合动画状态机(缺省 250ms 缓出,`update(dtMs)` 帧驱动)、`hitTest` 命中映射。**结构性保证:`layout()`/`hitTest()` 恒返回终态几何**,动画只作用绘制偏移——弹出瞬间即可点,不存在「动画播完才可交互」的输入黑窗。 +- `createScrollList({rect, rowH, count, tapSlopPx})`(v1.1,书面 §11「滚动容器」件,随《山海行纪》M2 出击页落地):纵向滚动列表——滚动数学(钳制、无惯性)+ **拖拽/单击消歧**(累计位移 ≤ tapSlopPx 的抬起判单击并报行下标,超出判滚动)+ 行命中映射(rowRect/visibleRange/rowIndexAt)。**零绘制**:行内容由调用方 clip 到 rect 自绘。 - `layoutTopBar(viewport, {height, sideW, barH})`:顶栏锚位布局(通栏 + 左/中/右三锚 + 底缘通栏条),HUD 摆件的通用骨架。 -卡面文案(title/desc/badge)、配色字体全由调用方注入;绘制委托 `draw` 后端(hud-ui 实例形状),缺省内置极简占位绘制。不提供:滚动容器/网格列表/树布局(书面 §11 后续三件,随 meta 页波)、任何成品视觉。 +卡面/行内容文案、配色字体全由调用方注入;绘制委托 `draw` 后端(hud-ui 实例形状),缺省内置极简占位绘制。不提供:网格列表/树布局(书面 §11 余下两件,随 meta 页波)、任何成品视觉。 ## 2. 集成点 @@ -36,7 +37,7 @@ picker.draw(g); ## 4. 测试 - `node --test src/plugins/canvas-ui-kit/test/canvas-ui-kit.test.mjs` -- 覆盖:布局几何(N=1..3 竖排居中)/ **弹出即可点**(open 后零 update 即 hitTest 命中)/ 动画时长(openness 在 animMs 内到 1、closing 播完自动隐藏)/ 命中边界与 miss 计数 / 绘制走后端(计数桩)与无后端降级 / 顶栏锚位不重叠且在视口内 / 插件全流程接纳。 +- 覆盖:布局几何(N=1..3 竖排居中)/ **弹出即可点**(open 后零 update 即 hitTest 命中)/ 动画时长(openness 在 animMs 内到 1、closing 播完自动隐藏)/ 命中边界与 miss 计数 / 绘制走后端(计数桩)与无后端降级 / 顶栏锚位不重叠且在视口内 / 插件全流程接纳 / 滚动列表三测(几何与钳制、拖拽滚动与单击消歧、越界与空表边界)。 - 像素级观感归集成段浏览器证据(两层测试纪律)。 ## 5. 边界与字节预算 @@ -47,6 +48,6 @@ picker.draw(g); ## 6. v2 声明 -- 滚动容器 / 网格列表 / 树布局(书面 §11 点名的三件)随 meta 页波(M4 前)补; +- 网格列表 / 树布局(书面 §11 余下两件)随 meta 页波(M4 前)补;滚动容器已随 v1.1 落(无惯性动量,动量滚动等真实观感需求再加); - 卡片逐张错峰入场(stagger)未做——首版整组同步,错峰等真实观感需求; - 手柄/键盘焦点导航未做——触屏主输入先行。 diff --git a/game-runtime/src/plugins/canvas-ui-kit/api.d.ts b/game-runtime/src/plugins/canvas-ui-kit/api.d.ts index 2e5a7f50..d791122f 100644 --- a/game-runtime/src/plugins/canvas-ui-kit/api.d.ts +++ b/game-runtime/src/plugins/canvas-ui-kit/api.d.ts @@ -68,6 +68,50 @@ export interface CardPicker { /** 建模态卡片选择器(纯函数)。 */ export declare function createCardPicker(opts: CardPickerOptions): CardPicker; +/** 滚动列表配置(v1.1)。 */ +export interface ScrollListOptions { + /** 容器矩形(屏幕坐标;行绘制由调用方 clip 到此)。 */ + rect: { x: number; y: number; w: number; h: number }; + /** 行高(缺省 52)。 */ + rowH?: number; + /** 行数(可 setCount 后调)。 */ + count?: number; + /** 单击/拖拽消歧阈值(px,缺省 8;累计位移 ≤ 此值的抬起判为单击)。 */ + tapSlopPx?: number; +} + +/** 指针事件结果(null = 未消费/未消歧)。 */ +export type ScrollPointerResult = + | null + | { kind: 'grab' } + | { kind: 'drag' } + | { kind: 'release' } + | { kind: 'tap'; index: number }; + +/** + * 纵向滚动列表(v1.1,书面 §11「滚动容器」件):滚动数学 + 拖拽/单击消歧 + 行命中映射。 + * 零绘制——行内容由调用方用 rowRect/visibleRange 自绘;无惯性动量(v2 声明)。 + */ +export interface ScrollList { + /** 指针事件入口(down/move/up;down 落在容器内即捕获)。 */ + pointer(type: 'down' | 'move' | 'up', x: number, y: number): ScrollPointerResult; + /** 行屏幕几何(已含滚动偏移)。 */ + rowRect(i: number): { x: number; y: number; w: number; h: number }; + /** 屏内可见行区间(含部分露出)。 */ + visibleRange(): { first: number; last: number }; + /** 屏幕 y → 行下标(容器外或越界 -1)。 */ + rowIndexAt(y: number): number; + scrollY(): number; + setScroll(v: number): void; + maxScroll(): number; + setCount(n: number): void; + rect(): { x: number; y: number; w: number; h: number }; + probe(): { taps: number; drags: number; grabs: number; scrollY: number; maxScroll: number; count: number; captured: boolean }; +} + +/** 建纵向滚动列表(纯函数)。 */ +export declare function createScrollList(opts: ScrollListOptions): ScrollList; + /** 顶栏锚位布局:通栏 + 左/中/右三锚 + 底缘通栏条(进度条位)。 */ export declare function layoutTopBar( viewport: { w: number; h: number }, @@ -84,6 +128,8 @@ export declare function layoutTopBar( export interface CanvasUiKitPlugin extends Plugin { /** 建卡片选择器(未显式给 draw 时继承插件级绘制后端)。 */ makeCardPicker(opts: CardPickerOptions): CardPicker; + /** 建纵向滚动列表(v1.1;纯骨架零绘制)。 */ + makeScrollList(opts: ScrollListOptions): ScrollList; layoutTopBar: typeof layoutTopBar; probe(): { disposed: boolean; hasDrawBackend: boolean }; } diff --git a/game-runtime/src/plugins/canvas-ui-kit/impl.js b/game-runtime/src/plugins/canvas-ui-kit/impl.js index 06ddfc14..3d8741fb 100644 --- a/game-runtime/src/plugins/canvas-ui-kit/impl.js +++ b/game-runtime/src/plugins/canvas-ui-kit/impl.js @@ -162,6 +162,91 @@ export function createCardPicker(opts) { }; } +/** + * 建纵向滚动列表(v1.1 新增,书面 §11「滚动容器」件;meta 页/出击页承载)。 + * 【定位】滚动数学 + 拖拽/点击消歧状态机 + 行命中映射;**零绘制**——行内容由调用方 + * 用 rowRect/visibleRange 自绘(clip 到 rect),与本件「组件是骨架、视觉全注入」哲学一致。 + * 【消歧语义(触屏惯例)】按下捕获 → 拖动累计位移超过 tapSlopPx 判定为滚动(scroll 随 + * 手指走,越界钳制);抬起时若累计位移 ≤ tapSlopPx 且仍在容器内 = 单击 → 报行下标。 + * 【时钟纪律】无时钟(无惯性动量;v2 声明);纯事件驱动,headless 可全断言。 + * + * @param {{rect:{x:number,y:number,w:number,h:number}, rowH?:number, count?:number, tapSlopPx?:number}} opts + * @returns {import('./api.d.ts').ScrollList} + */ +export function createScrollList(opts) { + const rect = opts.rect; + const rowH = opts.rowH > 0 ? opts.rowH : 52; + const tapSlop = opts.tapSlopPx > 0 ? opts.tapSlopPx : 8; + let count = Math.max(0, (opts.count | 0)); + let scroll = 0; + let captured = false; + let lastY = 0; + let moved = 0; + const stats = { taps: 0, drags: 0, grabs: 0 }; + + const maxScroll = () => Math.max(0, count * rowH - rect.h); + const clamp = (v) => Math.max(0, Math.min(maxScroll(), v)); + const inside = (x, y) => x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; + /** 屏幕 y → 行下标(容器外或越界 -1;闭包版,pointer 内不依赖 this 绑定)。 */ + const rowIndexAt = (y) => { + if (y < rect.y || y > rect.y + rect.h) return -1; + const idx = Math.floor((y - rect.y + scroll) / rowH); + return idx >= 0 && idx < count ? idx : -1; + }; + + return { + /** + * 指针事件入口(down/move/up 同摇杆三事件路由口径)。 + * @returns {null | {kind:'grab'} | {kind:'drag'} | {kind:'release'} | {kind:'tap', index:number}} + */ + pointer(type, x, y) { + if (type === 'down') { + if (!inside(x, y)) return null; + captured = true; lastY = y; moved = 0; + stats.grabs++; + return { kind: 'grab' }; + } + if (type === 'move') { + if (!captured) return null; + const dy = y - lastY; + lastY = y; + moved += Math.abs(dy); + scroll = clamp(scroll - dy); + if (moved > tapSlop) { stats.drags++; return { kind: 'drag' }; } + return null; + } + if (type === 'up') { + if (!captured) return null; + captured = false; + if (moved <= tapSlop && inside(x, y)) { + const idx = rowIndexAt(y); + if (idx >= 0) { stats.taps++; return { kind: 'tap', index: idx }; } + } + return { kind: 'release' }; + } + return null; + }, + /** 行屏幕几何(已含滚动偏移;调用方据此自绘与摆内容)。 */ + rowRect(i) { + return { x: rect.x, y: rect.y + i * rowH - scroll, w: rect.w, h: rowH }; + }, + /** 屏内可见行区间(含部分露出;count=0 时 last { + const rect = { x: 20, y: 100, w: 350, h: 300 }; + const list = createScrollList({ rect, rowH: 50, count: 20 }); + assert.equal(list.maxScroll(), 20 * 50 - 300, '最大滚动 = 内容高 - 容器高'); + assert.deepEqual(list.rowRect(0), { x: 20, y: 100, w: 350, h: 50 }); + // 滚动后行几何随偏移走 + list.setScroll(75); + assert.equal(list.rowRect(0).y, 100 - 75); + assert.deepEqual(list.visibleRange(), { first: 1, last: 7 }, '75px 偏移下可见 1..7(含部分露出)'); + // 钳制:越界取边界 + list.setScroll(99999); + assert.equal(list.scrollY(), list.maxScroll()); + list.setScroll(-50); + assert.equal(list.scrollY(), 0); + // 内容不足一屏:不可滚 + list.setCount(3); + assert.equal(list.maxScroll(), 0); +}); + +test('滚动列表 v1.1:拖拽滚动与单击消歧(tapSlop 语义)', () => { + const rect = { x: 0, y: 100, w: 390, h: 300 }; + const list = createScrollList({ rect, rowH: 50, count: 20, tapSlopPx: 8 }); + // 容器外按下:不消费 + assert.equal(list.pointer('down', 200, 50), null); + // 单击(位移 ≤ slop):报行下标(y=175 → 行 1) + assert.deepEqual(list.pointer('down', 200, 175), { kind: 'grab' }); + assert.equal(list.pointer('move', 200, 178), null, 'slop 内的微动不判拖'); + assert.deepEqual(list.pointer('up', 200, 178), { kind: 'tap', index: 1 }); + // 拖拽(位移 > slop):滚动随手指走,抬起不产 tap + list.pointer('down', 200, 350); + const r1 = list.pointer('move', 200, 250); // 上划 100px → 内容下移(scroll +100) + assert.deepEqual(r1, { kind: 'drag' }); + assert.equal(list.scrollY(), 100); + assert.deepEqual(list.pointer('up', 200, 250), { kind: 'release' }, '拖拽抬起不判单击'); + // 滚动后的单击命中换算:(y175 − rect.y100 + scroll100)/50 = 3.5 → 行 3 + list.pointer('down', 200, 175); + assert.deepEqual(list.pointer('up', 200, 175), { kind: 'tap', index: 3 }); + assert.ok(list.probe().taps === 2 && list.probe().drags >= 1); +}); + +test('滚动列表 v1.1:越底越界拖不越钳;count=0 全 -1', () => { + const rect = { x: 0, y: 0, w: 390, h: 200 }; + const list = createScrollList({ rect, rowH: 50, count: 5 }); // 内容 250,可滚 50 + list.pointer('down', 100, 150); + list.pointer('move', 100, 0); // 猛拖 150px,只能滚到 50 + assert.equal(list.scrollY(), 50); + list.pointer('up', 100, 0); + const empty = createScrollList({ rect, rowH: 50, count: 0 }); + assert.equal(empty.rowIndexAt(100), -1); + assert.deepEqual(empty.visibleRange().last < empty.visibleRange().first, true, '空表可见区间为空'); +}); + test('插件全流程接纳;makeCardPicker 继承插件级绘制后端', () => { const reg = new PluginRegistry(); const bundle = createHostDevContext({ context2d: null });