feat(polaris): 《山海行纪》M2——build 体系全表化+仿真参数扫描首跑,四判据 1080 局全绿

武器 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 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-06 19:20:09 -07:00
parent b7c843a0e6
commit 7cac2cb1b6
22 changed files with 2395 additions and 313 deletions

View File

@ -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 美术方向与资产、包体分包

View File

@ -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

View File

@ -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 / 难度 D0D1 / 暂停 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 胜率 4363% 全 ≥40% ②最强 build 85% ≤85% ③随机 build 中位胜率 55% ∈3555% ④武器出现↔获胜 |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`)。

View File

@ -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),

View File

@ -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; }

View File

@ -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)); // 无事:缓圆防原地堆怪
}

View File

@ -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(D1P2胜率 40%);
* B (随机):均匀随机抽 K build(3 武器 + 4 被动,受控 rng 可复现),同策略跑
* 判据③随机 build 中位胜率 3555%+ 判据④武器被选中获胜相关性 |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(3555%)' : '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); });

View File

@ -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,
* 机制跃迁非纯数值)/ 难度表 D0D5(本期启用 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, // 受击后短无敌窗(秒)——防同帧多怪叠伤秒杀
};
/** 虚拟摇杆(手 13 断言的参数面;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.0Lv8 合成 3.2,§8.1)/ ca=+弹数(累计)
* evo:进化(条件=满级+passiveEVO_PASSIVE_MIN):mods 数值面 + 机制跃迁字段(chain/explode/
* dot/slow/mirror/gemPull非纯数值,§5.2);理论 DPS 跃迁带 1.82.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 },
];
/**
* 难度表 D0D5(§5.2数值曲线抬升+词缀密度+波表压缩;本期启用 D0/D1,
* D2D5 为草案骨架词缀密度/波表压缩两轴随 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.350.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 真机校准后锁;推导基准=帧级反馈惯例(30ms2@60fps闪白 80ms5)
* :crit/bossKill 两档的**事件源**分别随 M2 暴击轴M3 Boss 落地档位先在表上钉死量值,
* 接线断言已在插件级机验,游戏侧事件源落地即通(如实分层,不称 4 全链已验)
* 手感反馈档位表(hit-feedback 插件直接吃表设计书 §6 4/5/6/7/10 的量值面)
* 全部量值为首版草案真机校准后锁;推导基准=帧级反馈惯例(30ms2@60fps闪白 80ms5)
* 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:受击 46px 取 5
pickup: { sfx: 'pickup' },
levelup: { sfx: 'powerup' },
harvest: { flashMs: 80, flashPeak: 0.2, sfx: 'powerup' }, // 聚灵珠:全图磁吸的仪式感(手 6 族闪屏惯例 ≤80ms)
},
/** 手 6:死亡爆浆粒子 814/只(池化在 juice maxParticles;数量按 eid 确定性取带内值)。 */
burstPerKill: { min: 8, max: 14 },

View File

@ -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.82.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<string, number>} 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;接触伤 ×(1armor))。 */
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<string, number>} 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 };

View File

@ -2,29 +2,33 @@
* game-logic.js 山海行纪游戏本体(GameInstance:场景流 + 摇杆输入 + HUD/弹层 + 取证)
* owner:北极星顶级线② 消费方:game.js(L1 wrapper)
*
* 分层本文件是:场景流(menuplayover)摇杆接线(核在 virtual-joystick 插件, 13)
* 三选一弹层(canvas-ui-kit cardPicker)反馈泵(sim 事件 hit-feedback 档位表)
* 分层本文件是:场景流(menurun)摇杆接线(核在 virtual-joystick 插件, 13)
* 三选一弹层(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<typeof createSim>|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 可选;D2D5 表内 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);
},
};

View File

@ -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';
}

View File

@ -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<object>} 活敌(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.350.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.350.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; },
};
}

View File

@ -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 Lv83.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.350.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))})`);
}
});

View File

@ -138,14 +138,45 @@ test('手 5/6 接线:受击触发震屏(幅度≤上限)、击杀触发爆浆 bu
assert.ok(bursts.every((c) => c >= 8 && c <= 14), `爆浆数量全落 814 带(实际 ${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 卡心');

View File

@ -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})`);

View File

@ -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)

View File

@ -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, '恢复后时间续走');
});

View File

@ -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)未做——首版整组同步,错峰等真实观感需求;
- 手柄/键盘焦点导航未做——触屏主输入先行。

View File

@ -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 };
}

View File

@ -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<first)。 */
visibleRange() {
const first = Math.max(0, Math.floor(scroll / rowH));
const last = Math.min(count - 1, Math.floor((scroll + rect.h - 1e-9) / rowH));
return { first, last };
},
/** 屏幕 y → 行下标(容器外或越界 -1)。 */
rowIndexAt,
scrollY() { return scroll; },
setScroll(v) { scroll = clamp(Number(v) || 0); },
maxScroll,
setCount(n) { count = Math.max(0, n | 0); scroll = clamp(scroll); },
rect() { return { ...rect }; },
probe() { return { ...stats, scrollY: scroll, maxScroll: maxScroll(), count, captured }; },
};
}
/**
* 顶栏锚位布局: height 的通栏 + left/center/right 三锚矩形 + 底缘通栏条(进度条位)
* @param {{w:number,h:number}} viewport
@ -194,7 +279,7 @@ export function createCanvasUiKitPlugin(opts) {
let disposed = false;
return {
name: 'canvas-ui-kit',
version: '1.0.0',
version: '1.1.0',
dependencies: [],
init() { /* 布局/状态机纯计算;绘制走调用方 g,无受控面可注册 */ },
dispose() { disposed = true; },
@ -204,6 +289,8 @@ export function createCanvasUiKitPlugin(opts) {
if (merged.draw === undefined) merged.draw = defaultDraw;
return createCardPicker(merged);
},
/** 建纵向滚动列表(v1.1;纯骨架零绘制,见 createScrollList)。 */
makeScrollList(sOpts) { return createScrollList(sOpts); },
layoutTopBar,
probe() { return { disposed, hasDrawBackend: !!defaultDraw }; },
};

View File

@ -1,12 +1,17 @@
{
"name": "canvas-ui-kit",
"version": "1.0.0",
"version": "1.1.0",
"gzBudgetBytes": 3072,
"dependencies": [],
"exports": {
"impl": "impl.js",
"types": "api.d.ts",
"named": ["createCanvasUiKitPlugin", "createCardPicker", "layoutTopBar"]
"named": [
"createCanvasUiKitPlugin",
"createCardPicker",
"createScrollList",
"layoutTopBar"
]
},
"description": "canvas UI 组合件首版:模态卡片选择器(布局+开合动画状态机+命中映射,终态几何恒可点)+ 顶栏锚位布局;绘制委托 hud-ui 形状后端,文案配色全注入。"
"description": "canvas UI 组合件:模态卡片选择器(布局+开合动画状态机+命中映射,终态几何恒可点)+ 纵向滚动列表(v1.1:滚动数学+拖拽/单击消歧+行命中,零绘制)+ 顶栏锚位布局;绘制委托 hud-ui 形状后端,文案配色全注入。"
}

View File

@ -9,7 +9,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PluginRegistry, createHostDevContext } from '../../../core/plugin.js';
import { createCardPicker, layoutTopBar, createCanvasUiKitPlugin } from '../impl.js';
import { createCardPicker, createScrollList, layoutTopBar, createCanvasUiKitPlugin } from '../impl.js';
const VP = { w: 390, h: 844 };
@ -94,6 +94,58 @@ test('顶栏锚位:全在视口内、三锚互不重叠、通栏条贴顶', () =
assert.deepEqual({ x: t.strip.x, y: t.strip.y, w: t.strip.w }, { x: 0, y: 0, w: VP.w }, '通栏条贴顶全宽');
});
test('滚动列表 v1.1:几何/滚动钳制/可见区间', () => {
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 });