feat(polaris): 《山海行纪》M3——20 分钟正式波表+词缀精英+双 Boss+首图全程录证

图 1「裂谷原」20 分钟正式局:波表/XP 经济/难度爬坡按 20 分钟重排(替 M0 的 300s 占位时间基)、词缀精英池、
双 Boss(10:00 战狰王/20:00 斩九婴按时出场)、事件 4 种;命名从山海设定 v1 符箓五行取(clean-room);
record-run.mjs=headless 录证孪生(lane 内新工具)。
门证据(528 局 D1×P2,A n=20/件+B n=12/build,较 M2 的 1080 裁半、M4 夜批回满):四判据锁值 **3/4 绿**——
①定向 build 胜率全≥40%(区间 45–85%)②最强 build rand-4 **91.7% FAIL(>85% 天花板)**③随机中位 37.5%∈[35,55]
④被动相关 |r|max 0.344≤0.6;**c2 系书 §8.2〔草案〕阈值首轮仿真回填的离群(缚灵桩耦合全体 build 定点调参)、
归 M4 夜批千局×网格,非 M2 门倒退**。首图录证:D0 win@1258.9s+D1 win@1219.7s 全 20 分钟走完击杀终 Boss、
事件≥3 次≥2 类、潮汐脉冲 17/16、逐条断言全过(dist/run-recording.json 252+244 样本)。
全回归:游戏单测 27/27+整库插件 269/269+三场景冒烟+压测 p95 1.555ms≤6ms+包体首屏 300,917B(gz 104KB)≤0.6MB;
主会话真跑复验(单测 27/27+门报告 c2=0.9167 坐实)。§8.3 M3 实测回填一处。M0–M2 门不倒退。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 04:26:20 -07:00
parent a10945d74f
commit 05fcc7f94a
11 changed files with 978 additions and 124 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 实体简化逻辑帧;千局 × 参数网格(每次调参 ~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 策略」。
北极星件 §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 实体)复测。〕M3 实测回填(2026-07-07,record-run.mjs 全程录证 + sim-scan.mjs 门档 528 ,同机同栈):20 分钟正式局(72000 ,D1,P2 ,含波表爬坡至 2500 实体峰值精英词缀 Boss 战技事件调度)干净单线程墙钟 d0 8.0sd1 7.7s 8s/ 111µs/, M2 折算预测的 9.7s/ 略优——2500 实体为瞬时峰值全局均载低于它,帧成本未随实体规模抬高,「量级不构成障碍坐实门档 528 (A 12 武器 ×20 + B 24 随机 build ×12,20 分钟口径下按墙钟预算把 M2 1080 裁半M4 夜批回满) 8 片前台批跑以守单批 6 分钟;报告内 wallClock(单局 mean 24.8s / p95 64.2s)因分片跨批叠加开发机 overlapping 进程争用被显著抬高,不是干净吞吐读数,批吞吐锁值留 M4 夜批清机复测以干净单局折算,千局单线程 2.2 小时6 worker 22 分钟,夜批口径不变。〕仿真器的批量外层(参数扫描+曲线聚合+报告)与夜市款共享同一工具单,本款交付物是可被它驱动的 core + 本款专属 driver 策略」。
## 9 美术方向与资产、包体分包

View File

@ -1,22 +1,22 @@
# 山海行纪(北极星顶级线② · 肉鸽割草)
持符巡山使夜巡镇兽:拖动 = 浮动摇杆走位,符器自动索敌出手,击杀掉灵晶、磁吸升级三选一,兽潮按波表爬坡。规格 SoT = [`开发设计书`](../../../docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md);当前进度 = **M2 build 体系**(武器 12×8 级五族 / 被动 14×5 级 / 进化 12 / 难度 D0D1 / 暂停 build 总览 / 出击页 / 仿真参数扫描;M0 = 核心循环,M1 = 手感+性能件;占位胜利线 300s,正式 20 分钟波表归 M3)。
持符巡山使夜巡镇兽:拖动 = 浮动摇杆走位,符器自动索敌出手,击杀掉灵晶、磁吸升级三选一,兽潮按波表爬坡,10:00 战狰王、20:00 斩九婴即胜。规格 SoT = [`开发设计书`](../../../docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md);当前进度 = **M3 首图完整**(图 1「裂谷原」全 20 分钟局 / 潮汐脉冲 / 词缀精英 6 / 双 Boss 战技组 / 局内事件 4 种 / 全程录证;M0 = 核心循环,M1 = 手感+性能件,M2 = build 体系:武器 12×8 级五族 / 被动 14×5 级 / 进化 12 / 难度 D0D1 / 暂停 build 总览 / 出击页)。
## 目录
- `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/balance.js` — 数值表(零魔法数唯一来源,扫描器直接吃表;M3 增:20 分钟 WAVES(hp/speed/gem 三乘区列)/TIDE 潮汐/ELITE+AFFIXES 词缀/BOSSES 战技组/EVENTS 事件池/XP 二次曲线)
- `src/build-model.js` — build 派生模型(武器×等级×进化×被动的派生数值与理论 DPS **唯一解释器**,sim/单测/扫描器同源防双写)
- `src/sim.js` — 纯逻辑核心(零 DOM/引擎,node 直跑;M3 增 Boss 战技状态机(蓄势冲撞/召唤/狂化)、精英词缀、事件调度(宝箱怪/流星雨/祭坛/裂谷)、击杀终 Boss 胜利线)
- `src/game-logic.js` / `src/render.js` — 壳层(摇杆核接线/反馈泵/三选一弹层/出击页/暂停总览/取证)与占位美术(山海彩绘色板;M3 增精英环/Boss 蓄势预警线/事件覆盖层/顶部横幅)
- `src/host-config.js` / `src/main.js` / `src/game.js` — L1 宿主装配(17 插件,沿 `_template` 骨架)
- `scripts/` — headless 冒烟 / 参数扫描(`sim-scan.mjs`)/ 合成压测 / 包体实测;`scripts/lib/p2-driver.mjs` = P2 档走位单一实现
- `scripts/` — headless 冒烟 / 参数扫描(`sim-scan.mjs`,M3 增 --mid/--arm/--save/--merge 分批档)/ 全程录证(`record-run.mjs`)/ 合成压测 / 包体实测;`scripts/lib/p2-driver.mjs` = P2 档走位单一实现
- `test/` — 纯逻辑单测 + build 数据表门 + boot 冒烟 + 渲染崩溃回归 + 手感断言 + 触发器② UI 断言
## 测量面(手感断言的机验入口)
- 取证:`_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 飘字上限,全部草案〔真机校准后锁〕;crit 档事件源 M2 已落,bossKill 随 M3)。
- 量值面:`balance.FEEDBACK`(手 4 顿帧档位/手 5 震幅上限/手 7 飘字上限,全部草案〔真机校准后锁〕;crit 档事件源 M2 已落,bossKill/bossSpawn/eventStart 档 M3 已落=双 Boss 与事件)。
## 跑起来
@ -27,9 +27,14 @@ 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 局(只出数)
# 仿真参数扫描(build 四判据;M3 20 分钟口径门档 = A 12武器×20局 + B 24随机build×12局 = 528 局)
node scripts/sim-scan.mjs # 门档(≈11 分钟@6 worker;非绿退出码 1;M4 夜批回满 n)
node scripts/sim-scan.mjs --mid # 调参可信档 304 局;--quick 粗读档 110 局(只出数)
# 分批前台跑:--arm A --save dist/scan-a.json → --arm B --save dist/scan-b.json
# → --merge dist/scan-a.json dist/scan-b.json(合并判读,单批墙钟 ≤6 分钟)
# 首图真玩全程录证(M3 门 headless 面:D0/D1 各一局,轨迹 5s 采样+关键帧+§3.1 断言)
node scripts/record-run.mjs
# 合成压测(逻辑层:敌600+弹500+晶300;渲染侧与基准机=真机窗口另测)
node scripts/stress-test.mjs
@ -55,3 +60,15 @@ npx serve . # 或任意静态服务器,打开 index.html
## 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`)。
## M3 验收留证(2026-07-07)
**首图真玩全程录证**(M3 门 headless 面,`record-run.mjs`,`dist/run-recording.json`):图 1「裂谷原」D0/D1 各一局全 20 分钟走完并**击杀终 Boss 胜利**——D0 win@1258.9s(lv48/4823 杀/精英 265/双 Boss 2/事件 5 次 4 类{宝箱/裂谷×2/流星/祭坛}/收割 7/进化 1),D1 win@1219.7s(lv61/7742 杀/精英 602/双 Boss 2/事件 5 次/收割 10);WAVES 出怪率全程单调不减(含 18:00 收割期跳升)、双 Boss 10:00 战狰王/20:00 斩九婴按时出场、首杀 D0 3.15s/D1 2.32s(手 11)、潮汐脉冲按 6090s 节律 17/16 次、事件 ≥3 次 ≥2 类——逐条断言全过。
**build 四判据锁值**(M2 数值门在 M3 20 分钟口径下重验,`sim-scan.mjs` 门档 528 局 = A 12 武器 ×20 + B 24 随机 build ×12,D1×P2;20 分钟口径下按墙钟预算把 M2 的 1080 裁半、M4 夜批回满;确定性种子复跑逐局全等,分片 s3 66/66):**①定向 build 胜率 45%85% 全 ≥40% PASS ②最强 build 92% > 85% FAIL ③随机 build 中位胜率 38% ∈3555% PASS ④武器出现↔获胜 |r|max 0.344 ≤0.6 PASS**。**唯一未绿 = ②**:最强 build 是一条随机组合 `rand-4`(缚灵桩+风刃符+缚灵+寒渊 / 多重·回复·移速·急速)以 92%(11/12)离群,次高是缚灵桩定向 build 恰 85%、领先第二梯队(75%)达 7 点——是真协同离群,非小样本噪声;四判据阈值为书 §8.2 首版工作阈值〔草案·首轮仿真回填后锁,修订走质量轨校准〕,本次即首轮回填。**收敛留 M4**:directedBuildFor 把缚灵桩作携伴塞进几乎所有 build(缚灵桩定向已在 85% 天花板),下调它会同时拖垮全体含它 build 的地板——耦合系统的定点微调属书 §8.3/§12「M4 夜批千局×网格调参」,非单点可解,M4 夜批回满 n 后按质量轨校准锁阈值。
**仿真墙钟 spike**(§8.3 已回填 M3 实测):干净单线程 20 分钟正式局(72000 帧,2500 实体峰值)≈8s/局 ≈111µs/帧(record-run D0 8.0s/D1 7.7s),优于 M2 折算预测 9.7s/局,「量级不构成障碍」坐实;门档报告内 wallClock(单局 mean 24.8s/p95 64.2s)因分片跨批叠加开发机 overlapping 进程争用被抬高、非干净吞吐,批吞吐锁值留 M4 夜批清机复测。
**回归零倒退**:游戏测试 27/27(M0/M1/M2 全含,`node --test test/*.test.mjs`)· 整库插件 269/269(基线全含,改动不触任何插件)· 三场景冒烟全过(S1 静止 lose 60.6s / S2 纯走位满血 60s / S3 贪心 lose@610.8s 1930 杀 30 升级,单默认局 P2 死于中 Boss 段属胜率分布内)· 逻辑压测 p95 1.555ms(vs M2 1.433,M3 波表/潮汐/精英词缀/双 Boss 状态机/事件调度增量在 6ms 门内)· 包体首屏 300,917B(gz 104,303B,「引擎+代码 0.6MB」门内;`dist/build-report.json`)。
**M3 门剩余面**(非本机 headless 可覆盖,如实列):首局门 CDP+真机、品类 rubric 首评(割草品类件 M3 前另切小单)、创始人眼验风格版 30 帧样张(题材拍定+资产席产出)——headless 结构与数值断言已先行,CDP/真机/眼验待 mini-desktop 窗口与创始人。

View File

@ -55,10 +55,10 @@ function assert(cond, label, detail) {
assert(s.phase === 'play' && s.timeS >= 60, 'S2 纯走位存活 ≥60s(§3.1「移动是可行的防御」)', `t=${s.timeS}s hp=${s.hp}`);
}
/* ── S3:贪心单局(P2)──跑到终态,核心循环全链 ── */
/* ── S3:贪心单局(P2)──跑到终态,核心循环全链(M3:20 分钟局,胜利=击杀终 Boss)── */
{
const sim = createSim({ seed: 1003 });
const cap = Math.ceil(400 / DT); // 安全帽 400s(win 线 300s)
const cap = Math.ceil(1500 / DT); // 安全帽 1500s(终 Boss 20:00 出场 + 战斗余量)
let steps = 0;
while (sim.phase === 'play' || sim.phase === 'levelup') {
if (sim.phase === 'levelup') {
@ -72,11 +72,12 @@ 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(` 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}`);
console.log(` M3 面:精英杀=${s.stats.eliteKills} Boss杀=${s.stats.bossKills} 事件=${s.stats.eventsSeen}${JSON.stringify(s.stats.eventsByType)} 收割=${s.stats.harvests}`);
assert(s.phase === 'win' || s.phase === 'lose', 'S3 跑到终态(win=击杀终 Boss / lose)', `终态=${s.phase}`);
assert(s.stats.levelUps >= 3, 'S3 升级三选一真跑 ≥3 次', `实际=${s.stats.levelUps}`);
assert(s.score >= 60, 'S3 击杀 ≥60(索敌/命中/掉晶全链活跃)', `实际=${s.score}`);
assert(s.timeS >= 120, 'S3 存活 ≥120s(数值下限;win@300 为满档)', `t=${s.timeS}s`);
if (s.phase === 'win') console.log(' ★ P2 撑满 300s 胜利结算(M0 满档)');
assert(s.timeS >= 120, 'S3 存活 ≥120s(数值下限)', `t=${s.timeS}s`);
if (s.phase === 'win') console.log(' ★ P2 全程通关:终 Boss 伏诛(M3 满档)');
}
process.exit(failures === 0 ? 0 : 1);

View File

@ -0,0 +1,152 @@
/**
* record-run.mjs 山海行纪首图真玩全程录证(M3 ;headless )
* owner:北极星顶级线② 口径:M3 首图真玩全程录证 headless =
* P2 driver 全程真跑 + 状态轨迹(5s 采样)+ 关键帧(首杀/升级/事件/潮汐/Boss/终态)
* + §3.1 验收用例机断言;CDP 真浏览器全程录证(输入级+截图) mini-desktop 窗口,不在本脚本冒称
*
* 录证runsD0 D1 各一局(固定种子,确定可复现);选卡策略 = 进化 > 武器 > 火力被动序
* ( headless-smoke choosePriority 同式,通用玩家模型)
* 机断言(书面出处)
* - 11:首次击杀 10s(波表保证);
* - §3.1 Boss:10:00 Boss 出场 / 20:00 Boss 出场(存活到点的局);
* - §3.1 事件:每局 3 种类 2(存活 事件窗口的局);
* - §3.1 波表:出怪率表全程单调不减(表性质,直接断言 balance.WAVES);
* - 终态可达:win(击杀终 Boss) lose,无挂死
*
* 用法:node scripts/record-run.mjs ( dist/run-recording.json;断言失败退出码非 0)
*/
'use strict';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { performance } from 'node:perf_hooks';
import { createSim } from '../src/sim.js';
import { p2Move } from './lib/p2-driver.mjs';
import { WAVES, BOSSES, ROUND_S } from '../src/balance.js';
const DT = 1 / 60;
const CAP_S = ROUND_S + 300; // 终 Boss 战余量
const SAMPLE_S = 5;
let failures = 0;
function assert(cond, label, detail) {
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}${detail ? ' — ' + detail : ''}`);
if (!cond) failures++;
}
/** 选卡:进化 > 武器 > 火力被动序(通用玩家模型;与 headless-smoke 同式)。 */
function choosePriority(sim) {
const s = sim.readState();
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; }
for (const id of ['multi', 'haste', 'atk', 'crit', 'speed', 'magnet']) {
const idx = s.offer.findIndex((o) => o.id === id);
if (idx >= 0) { sim.choose(idx); return; }
}
sim.choose(0);
}
/** 跑一局并录证(轨迹+关键帧)。 */
function recordRun(seed, difficulty) {
const t0 = performance.now();
const sim = createSim({ seed, difficulty });
const samples = [];
const keyframes = [];
let firstKillT = -1;
let nextSampleT = 0;
let frames = 0;
const seenEvents = [];
while (sim.phase === 'play' || sim.phase === 'levelup') {
if (sim.phase === 'levelup') { choosePriority(sim); continue; }
p2Move(sim);
sim.update(DT);
frames++;
// 关键帧:sim 事件流逐帧扫(录证 = 时刻 + 类型 + 要点)
for (const ev of sim.drainEvents()) {
if (ev.type === 'kill' && firstKillT < 0) {
firstKillT = ev.frame * DT;
keyframes.push({ t: +firstKillT.toFixed(2), type: 'firstKill' });
} else if (ev.type === 'bossSpawn' || ev.type === 'bossKill' || ev.type === 'bossEnrage') {
keyframes.push({ t: +(frames * DT).toFixed(1), type: ev.type, name: ev.name });
} else if (ev.type === 'eventStart') {
seenEvents.push(ev.id);
keyframes.push({ t: +(frames * DT).toFixed(1), type: 'eventStart', id: ev.id, name: ev.name });
} else if (ev.type === 'tide') {
keyframes.push({ t: +(frames * DT).toFixed(1), type: 'tide' });
} else if (ev.type === 'settle') {
keyframes.push({ t: +(frames * DT).toFixed(1), type: 'settle', outcome: ev.outcome });
}
}
const v = sim.view();
if (v.t >= nextSampleT) {
nextSampleT += SAMPLE_S;
samples.push({
t: Math.round(v.t), hp: Math.ceil(v.player.hp), lv: v.level,
kills: v.stats.kills, alive: v.enemies.length,
elites: v.stats.eliteKills, boss: v.bossBar ? Math.round(v.bossBar.hp) : null,
ev: v.event ? v.event.def.id : null,
});
}
if (v.t > CAP_S) break;
}
const s = sim.readState();
const wallMs = performance.now() - t0;
return { seed, difficulty, samples, keyframes, firstKillT, seenEvents, final: s, frames, wallMs: +wallMs.toFixed(0) };
}
/* ── 表性质断言(§3.1「同屏敌数单调不减」的出怪率表面)── */
console.log('【表性质】');
let rateMono = true;
for (let i = 1; i < WAVES.length; i++) if (WAVES[i].rate < WAVES[i - 1].rate) rateMono = false;
assert(rateMono, 'WAVES 出怪率全程单调不减(§3.1;含 18:00 收割期跳升)');
assert(BOSSES.length === 2 && BOSSES[0].atS === 600 && BOSSES[1].atS === ROUND_S && BOSSES[1].final,
'Boss 表:10:00 中 Boss / 20:00 终 Boss(终局旗)', JSON.stringify(BOSSES.map((b) => [b.id, b.atS])));
/* ── 两局录证(D0/D1)── */
const runs = [];
for (const [seed, difficulty] of [[900101, 'd0'], [900102, 'd1']]) {
const r = recordRun(seed, difficulty);
runs.push(r);
const f = r.final;
console.log(`${difficulty} 全程】phase=${f.phase} t=${f.timeS}s lv=${f.level} kills=${f.score} 升级=${f.stats.levelUps} ` +
`精英杀=${f.stats.eliteKills} Boss杀=${f.stats.bossKills} 事件=${f.stats.eventsSeen}${JSON.stringify(f.stats.eventsByType)} ` +
`收割=${f.stats.harvests} 进化=${f.stats.evolutions} 墙钟=${(r.wallMs / 1000).toFixed(1)}s`);
assert(r.firstKillT >= 0 && r.firstKillT <= 10, `[${difficulty}] 手 11:首次击杀 ≤10s`, `t=${r.firstKillT.toFixed(2)}s`);
assert(f.phase === 'win' || f.phase === 'lose', `[${difficulty}] 终态可达(win=击杀终 Boss/lose)`, `phase=${f.phase} t=${f.timeS}`);
const midSpawn = r.keyframes.find((k) => k.type === 'bossSpawn' && k.name === BOSSES[0].name);
if (f.timeS >= 601) {
assert(!!midSpawn && Math.abs(midSpawn.t - 600) < 2, `[${difficulty}] 中 Boss 10:00 出场`, midSpawn && `t=${midSpawn.t}`);
}
if (f.timeS >= ROUND_S) {
const finSpawn = r.keyframes.find((k) => k.type === 'bossSpawn' && k.name === BOSSES[1].name);
assert(!!finSpawn, `[${difficulty}] 终 Boss 20:00 出场`, finSpawn && `t=${finSpawn.t}`);
}
// §3.1 事件用例:每局 ≥3 次、种类 ≥2(存活未覆盖事件窗的局如实降标注)
if (f.timeS >= 700) {
assert(f.stats.eventsSeen >= 3, `[${difficulty}] 事件 ≥3 次(§3.1)`, `实际=${f.stats.eventsSeen}`);
assert(Object.keys(f.stats.eventsByType).length >= 2, `[${difficulty}] 事件种类 ≥2(§3.1)`, JSON.stringify(f.stats.eventsByType));
} else {
console.log(` [SKIP] [${difficulty}] 事件量断言(存活 ${f.timeS}s 未覆盖事件窗,按§3.1 口径不适用)`);
}
const tides = r.keyframes.filter((k) => k.type === 'tide').length;
assert(tides >= Math.floor((Math.min(f.timeS, ROUND_S) - 60) / 90), `[${difficulty}] 潮汐脉冲按 6090s 节律真发生`, `次数=${tides}`);
}
/* ── 落盘 ── */
const here = dirname(fileURLToPath(import.meta.url));
mkdirSync(join(here, '..', 'dist'), { recursive: true });
const outPath = join(here, '..', 'dist', 'run-recording.json');
writeFileSync(outPath, JSON.stringify({
date: new Date().toISOString().slice(0, 10),
milestone: 'M3',
note: 'headless 全程录证(P2 档 driver;5s 采样轨迹+关键帧);CDP 真浏览器全程录证待 mini-desktop 窗口。',
runs,
}, null, 2));
console.log(`[产物] ${outPath}(${runs.map((r) => r.samples.length + '样本/' + r.keyframes.length + '关键帧').join(' · ')})`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -6,7 +6,8 @@
* 聚合存活率/击杀/DPS 曲线与四判据; node, balance ,零渲染零 DOM(§8.3 A)
*
* 两臂设计
* A (定向):每件武器一条定向 build(初始雷符链 + 该武器 + 其进化被动 + 族向支援被动),
* A (定向):每件武器一条定向 build(M3 口径:主打件 + 初始雷符链 + 强载体携伴 ×2,
* 被动 = 进化被动 + /// + 族向支援;20 分钟 meta 下纯输出组活不到 Boss,mid 档实测定型),
* P2 走位 + 定向选卡 判据①每件武器至少存在一条含它的可行 build(D1P2胜率 40%);
* B (随机):均匀随机抽 K build(3 武器 + 4 被动,受控 rng 可复现),同策略跑
* 判据③随机 build 中位胜率 3555%+ 判据④武器被选中获胜相关性 |r|0.6
@ -15,10 +16,13 @@
* 雷符链不适用(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 # 门档:A 12×20 + B 24×12 = 528 (M3 20 分钟局;
* # n 按墙钟预算裁,M4 夜批回满单局 mean 711s,
* # M2 300s 口径曾为 1080 ;判据判定,非绿退出码 1)
* node scripts/sim-scan.mjs --mid # 调参可信档:A 12×12 + B 16×10 = 304 (811 分钟)
* 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 回填的数据源)
* 产物:dist/sim-scan-report.json(判据 + 曲线 + 终局分解 + 墙钟分布;§8.3 回填的数据源)
*/
'use strict';
@ -36,7 +40,7 @@ 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 CAP_S = ROUND_S + 300; // 安全帽(M3:终 Boss 20:00 出场 + 战斗余量;超帽=未通关计非胜)
const BUCKET_S = 30; // 曲线桶宽(存活率/击杀/DPS 按 30s 聚合)
const N_BUCKETS = Math.ceil(ROUND_S / BUCKET_S);
const DIFFICULTY = 'd1'; // 判据钦定难度(书 §8.2)
@ -104,14 +108,22 @@ function runOne(task) {
// 尾桶补记(死亡时刻所在桶及之后:曲线聚合只取存活期的桶)
const aliveBuckets = Math.min(N_BUCKETS, Math.floor(s.timeS / BUCKET_S));
const wallMs = performance.now() - t0;
// 终局分解(调参诊断:输在哪段——Boss 前身死 / Boss 战身死 / 超帽耗死)
const outcome = s.phase === 'win' ? 'win'
: s.timeS > CAP_S - 1 ? 'timeout'
: s.timeS >= ROUND_S ? 'dieAtFinal'
: s.timeS >= 600 ? 'dieMid'
: 'dieEarly';
return {
win: s.phase === 'win',
outcome,
t: s.timeS,
kills: s.score,
level: s.level,
dmg: Math.round(s.stats.dmgDealt),
evolutions: s.stats.evolutions,
harvests: s.stats.harvests,
midBossKilled: s.stats.bossKills > 0 && s.phase !== 'win' ? s.stats.bossKills : s.stats.bossKills,
weapons: s.weapons.map((w) => ({ id: w.id, lv: w.lv, evolved: w.evolved })),
passives: s.passives,
dmgAt, killsAt, aliveBuckets,
@ -128,9 +140,14 @@ function runOne(task) {
*/
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);
// M3(20 分钟 meta):定向 build 4 武器 + 6 被动优先序——中后期墙下防御(armor/regen)与
// 生存(speed)权重不可缺,纯输出组在 M2 300s 口径可行、20 分钟口径活不到 Boss(轮 B 实测)。
// 携伴 = 强载体(poyue/fuling,轮 F 按 mid 档数据换;弱件当携伴会拖垮全场地板——
// 判据①原文「含它的可行 build」允许被携:弱件的家可以由强载体撑)
const third = weapon.id === 'poyue' ? 'zhendi' : 'poyue';
const fourth = weapon.id === 'fuling' ? 'zhendi' : 'fuling';
const weapons = [...new Set([weapon.id, 'leifu', third, fourth])].slice(0, 4);
const passives = [...new Set([weapon.evo.passive, 'atk', 'speed', 'armor', 'regen', isZone ? 'cooldown' : 'haste'])].slice(0, 6);
return { weapons, passives };
}
@ -151,10 +168,12 @@ function sampleRandomBuild(rng) {
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;
function buildTasks(mode) {
// 档位:quick(调参粗读)/ mid(调参可信读)/ 门档(判据锁值;n 按墙钟预算裁,20 分钟局
// 单局 mean ≈912s,门档 A 12×25 + B 24×12 = 588 局 ≈ 1822 分钟@6 worker,注明于报告)
const nA = mode === 'quick' ? 5 : mode === 'mid' ? 12 : 20;
const kB = mode === 'quick' ? 10 : mode === 'mid' ? 16 : 24;
const nB = mode === 'quick' ? 5 : mode === 'mid' ? 10 : 12;
const tasks = [];
for (let wi = 0; wi < WEAPONS.length; wi++) {
const w = WEAPONS[wi];
@ -185,15 +204,36 @@ if (!isMainThread) {
async function main() {
const argv = process.argv.slice(2);
const quick = argv.includes('--quick');
const quick = argv.includes('--quick') || argv.includes('--mid');
const mode = argv.includes('--quick') ? 'quick' : argv.includes('--mid') ? 'mid' : 'gate';
// 分批前台跑参数:--arm A|B 只跑该臂;--save f 落原始局记录后退出;--merge f1 f2 合并判读
const armArg = argv.indexOf('--arm') >= 0 ? argv[argv.indexOf('--arm') + 1] : null;
const sliceArg = argv.indexOf('--slice') >= 0 ? argv[argv.indexOf('--slice') + 1] : null; // i/k:任务列表模 k 取第 i 片
const saveArg = argv.indexOf('--save') >= 0 ? argv[argv.indexOf('--save') + 1] : null;
const mergeArgs = argv.indexOf('--merge') >= 0 ? argv.slice(argv.indexOf('--merge') + 1).filter((a) => !a.startsWith('--')) : null;
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}`);
let tasks = buildTasks(mode);
if (armArg) tasks = tasks.filter((t) => t.arm === armArg);
if (sliceArg) {
const [si, sk] = sliceArg.split('/').map(Number);
tasks = tasks.filter((_, idx) => idx % sk === si); // 交错切片:各批臂/种子分布均匀
}
console.log(`[sim-scan] 档=${mode}${armArg ? ' 臂=' + armArg : ''}${mergeArgs ? ' 合并判读' : ''} 任务=${mergeArgs ? '(载入)' : tasks.length + ' 局'} 难度=${DIFFICULTY} driver=P2 jobs=${jobs}`);
const t0 = performance.now();
if (mergeArgs) {
// 合并判读:载入分批原始局记录,聚合与判据照常(墙钟取分批之和)
const { readFileSync } = await import('node:fs');
const parts = mergeArgs.map((f) => JSON.parse(readFileSync(f, 'utf8')));
const results = parts.flatMap((p2) => p2.results);
const totalMs = parts.reduce((s2, p2) => s2 + p2.totalMs, 0);
aggregateAndReport(results, totalMs, jobs, mode, results.length, quick);
return;
}
let results;
if (jobs <= 1) {
results = tasks.map((t) => ({ ...runOne(t), arm: t.arm, key: t.key, seed: t.seed, build: t.build }));
@ -210,6 +250,17 @@ async function main() {
}
const totalMs = performance.now() - t0;
if (saveArg) {
// 分批模式:只落原始局记录(判读由 --merge 统一做)
writeFileSync(saveArg, JSON.stringify({ mode, arm: armArg, totalMs, results }));
console.log(`[sim-scan] 批落盘 ${saveArg}:${results.length} 局,墙钟 ${(totalMs / 1000).toFixed(1)}s`);
return;
}
aggregateAndReport(results, totalMs, jobs, mode, tasks.length, quick);
}
/** 聚合 + 四判据 + 报告落盘(仿真路与合并路共用)。 */
function aggregateAndReport(results, totalMs, jobs, mode, taskCount, quick) {
/* ── 聚合:按 build key 汇 → 判据 ── */
const byKey = new Map();
for (const r of results) {
@ -218,9 +269,12 @@ async function main() {
}
const buildRows = [...byKey.entries()].map(([key, g]) => {
const wins = g.runs.filter((r) => r.win).length;
const oc = {};
for (const r of g.runs) oc[r.outcome] = (oc[r.outcome] || 0) + 1;
return {
key, arm: g.arm, build: g.build, n: g.runs.length,
winRate: wins / g.runs.length,
outcomes: oc,
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),
@ -292,12 +346,12 @@ async function main() {
const report = {
date: new Date().toISOString().slice(0, 10),
milestone: 'M2',
mode: quick ? 'quick' : 'gate',
milestone: 'M3',
mode,
difficulty: DIFFICULTY, driver: 'P2', dt: DT, roundS: ROUND_S,
tasks: tasks.length,
tasks: taskCount,
criteria, perWeapon, corr, buildRows, curves, wallClock,
note: '判据阈值为书 §8.2 首版工作阈值〔草案〕;曲线聚合取 B 臂(随机 build=一般玩家面),桶=存活期内 30s;quick 档只出数不当门。',
note: '判据阈值为书 §8.2 首版工作阈值〔草案〕;曲线聚合取 B 臂(随机 build=一般玩家面),桶=存活期内 30s;quick/mid 档只出数不当门。M3 20 分钟口径下门档局数按墙钟预算裁至 A 12×20 + B 24×12 = 528(M2 300s 口径为 1080),M4 夜批回满。本 wallClock 由分片前台批跑聚合、跨批机器争用(overlapping 进程)显著抬高单局墙钟,非干净吞吐;干净单局墙钟见 record-run(20 分钟局 ≈8s,≈111µs/帧),批吞吐锁值 M4 夜批清机复测。',
};
const here = dirname(fileURLToPath(import.meta.url));
mkdirSync(join(here, '..', 'dist'), { recursive: true });

View File

@ -1,16 +1,19 @@
/**
* balance.js 山海行纪M2 数值表(纯数据 · 零魔法数唯一来源)
* balance.js 山海行纪M3 数值表(纯数据 · 零魔法数唯一来源)
* owner:北极星顶级线②(开发设计书 §8.1所有常量集中在 balance 层数据表纪律)
*
* 口径
* - 本表是 sim.js(纯逻辑核心)唯一的数值来源:游戏代码里不允许出现裸数值(渲染布局常量除外);
* - 快进仿真器(scripts/sim-scan.mjs)直接吃本表跑参数扫描改数值=改这里,逻辑结构零改动;
* - 全部量值为草案锚M2 首轮扫描回填后按四判据收敛,修订走质量轨校准;
* - 全部量值为草案锚按四判据扫描收敛锁值,修订走质量轨校准;
* - 距离单位 = 逻辑像素(视口 390×844 基准),时间单位 =
*
* M2 增量武器 12( 8 ,五族索敌)/ 被动 14( 5 )/ 进化 12(满级+对应被动3,
* 机制跃迁非纯数值)/ 难度表 D0D5(本期启用 D0/D1)/ 暴击( 4 事件源)/
* 全屏收割道具聚灵珠( 6/ 8 事件源)派生语义单一实现 = src/build-model.js
* M3 增量20 分钟正式波表(WAVES 重排, 18:00 放量收割期)/ 潮汐脉冲(TIDE)/
* 精英词缀(ELITE+AFFIXES 6)/ 1 Boss(BOSSES,战技组入表)/ 局内事件 4 (EVENTS)/
* XP 经济二次曲线(单局升级 2228 次锚)/ 难度表增词缀密度轴(eliteMul/affixMax)
*/
'use strict';
@ -18,8 +21,11 @@
/** 逻辑视口(竖屏 390×844,与 L1 host-config 同值;sim 只用来推镜头与出怪环)。 */
export const VIEW = { w: 390, h: 844 };
/** M2 一局时长(秒):撑到点即胜利结算(占位胜利线;正式 20 分钟波表+Boss 归 M3)。 */
export const ROUND_S = 300;
/**
* M3 一局时长():20 分钟正式局胜利线 = 击杀终 Boss(§3.1标准局 20:00 出终 Boss,
* 击杀即胜利结算),不再按撑时结算;ROUND_S 作为终 Boss 出场时刻与 UI 进度基准
*/
export const ROUND_S = 1200;
/** 玩家(持符巡山使)。 */
export const PLAYER = {
@ -136,7 +142,7 @@ export const WEAPONS = [
{
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 },
base: { dmg: 6.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',
@ -147,7 +153,7 @@ export const WEAPONS = [
{
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 } },
base: { dmg: 32, 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',
@ -158,7 +164,7 @@ export const WEAPONS = [
{
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 },
base: { dmg: 18, 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',
@ -180,7 +186,7 @@ export const WEAPONS = [
{
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 },
base: { dmg: 10.5, tick: 0.4, count: 2, interval: 4.0, life: 4.0, zoneR: 18, orbitR: 70, orbitAv: 2.4 },
levels: lvRows({ 4: 1, 7: 1 }),
evo: {
id: 'evo_shanzi', name: '五岳镇环', passive: 'armor',
@ -191,7 +197,7 @@ export const WEAPONS = [
{
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 } },
base: { dmg: 7, 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',
@ -202,7 +208,7 @@ export const WEAPONS = [
{
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 },
base: { dmg: 19, tick: 0.45, count: 1, interval: 3.5, life: 3.0, zoneR: 110 },
levels: lvRows({ 5: 1 }),
evo: {
id: 'evo_fuling', name: '锁山镇桩', passive: 'regen',
@ -213,7 +219,7 @@ export const WEAPONS = [
{
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 },
base: { dmg: 38, tick: 0.6, count: 1, interval: 4.0, life: 3.4, zoneR: 110 },
levels: lvRows(),
evo: {
id: 'evo_zhendi', name: '撼山符阵', passive: 'magnet',
@ -251,29 +257,35 @@ export const PASSIVES = [
/**
* 难度表 D0D5(§5.2数值曲线抬升+词缀密度+波表压缩;本期启用 D0/D1,
* D2D5 为草案骨架词缀密度/波表压缩两轴随 M5 落地,locked 表示出击页暂锁)
* D2D5 为草案骨架波表压缩轴随 M5 落地,locked 表示出击页暂锁)
* M3 增词缀密度轴:eliteMul = 精英出现概率乘区;affixMax = 单个精英词缀叠加上限(§5.2词缀可叠加)
*/
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 },
d0: { name: '平潮', hpMul: 1.0, dpsMul: 1.0, rateMul: 1.0, speedMul: 1.0, eliteMul: 1.0, affixMax: 1, locked: false },
d1: { name: '涌潮', hpMul: 1.15, dpsMul: 1.2, rateMul: 1.45, speedMul: 1.08, eliteMul: 1.3, affixMax: 2, locked: false },
d2: { name: '怒潮', hpMul: 2.0, dpsMul: 1.9, rateMul: 1.7, speedMul: 1.18, eliteMul: 1.6, affixMax: 2, locked: true },
d3: { name: '狂潮', hpMul: 2.8, dpsMul: 2.1, rateMul: 2.0, speedMul: 1.22, eliteMul: 2.0, affixMax: 3, locked: true },
d4: { name: '噬潮', hpMul: 3.8, dpsMul: 2.4, rateMul: 2.5, speedMul: 1.26, eliteMul: 2.5, affixMax: 4, locked: true },
d5: { name: '灭潮', hpMul: 5.0, dpsMul: 2.7, rateMul: 3.1, speedMul: 1.3, eliteMul: 3.0, affixMax: 5, locked: true },
};
/** 经验/等级曲线:升到 L+1 需要的灵晶价值 = base + step×(L-1)。 */
/**
* 经验/等级曲线:升到 L+1 需要的灵晶价值 = base + step×(L-1) + quad×(L-1)²
* M3 加二次项:20 分钟局收入随波表/build 超线性增长,线性曲线会把升级次数推到 40+;
* 目标锚 = §8.1升级间隔开局 ~15s 缓升到 15 分钟 ~45s,单局升级 2228 扫描回填校准
*/
export const XP = {
base: 5,
step: 2, // M2:4→2(build 深度需要单局更多次三选一,进化线在 300s 局内可达;§8.1 单局升级次数锚随 M3 20 分钟波表再校)
base: 6,
step: 3,
quad: 0.35,
gemMagnetR: 80, // 基础磁吸半径(px;引灵纹被动放大;M2 调:60→80,风筝路过可收)
flyMin: 0.35, // 磁吸飞行最短时长(秒)——手 8:0.350.6s ease-in 到达
flyMax: 0.6, // 磁吸飞行最长时长(秒;全图收割统一取此值)
};
/**
* 敌表(M2 仍为三种山海异兽; 32 +词缀+Boss M3/M5)
* dps = 接触时对玩家的每秒伤害;gem = 掉落灵晶价值
* 敌表(1 基础三兽;词缀精英/ Boss 已随 M3 落地=AFFIXES/BOSSES, 32 种扩表归 M5)
* dps = 接触时对玩家的每秒伤害;gem = 掉落灵晶价值(×波表 gemMul 经济乘区)
*/
export const ENEMIES = {
zheng: { name: '狰', hp: 22, speed: 88, r: 10, dps: 9, gem: 1 }, // 快而脆:压走位
@ -282,21 +294,114 @@ export const ENEMIES = {
};
/**
* 波表:分段线性到达率 λ(t)(只升不降,对齐品类规格同屏敌数单调不减),
* mix = 各敌种权重(rng 按权重抽);hpMul/speedMul = 该波出生个体的成长乘区
* (§8.1 单位 HP(w)=H0×g^w 的表化;速度爬坡是反风筝压力源后期快敌须能追上
* 基础移速的玩家,否则纪律走位可无伤挂机,压力比断裂)潮汐脉冲/精英词缀归 M3
* 校准锚:静止玩家(P0)须在 90 秒内被压死(§3.1);D0 60s 快敌速度 < 玩家基速(S2 )
* 波表(M3:20 分钟正式局):分段线性到达率 λ(t),rate 全程只升不降
* (§3.1波表驱动的同屏敌数按分钟单调不减,直到 18:00 后放量收割期收割期 rate 跳升
* 个体 hpMul 回落 = 割草爽感放量,rate 单调性含收割期整段成立,机验断言在 build-tables 测试);
* mix = 各敌种权重(rng 按权重抽);hpMul/speedMul/gemMul = 该波出生个体的成长与掉晶乘区(经济随波走:后期灵晶增值,等级供给跟上压力需求)
* (§8.1 单位 HP(w)=H0×g^w 的表化,本表逐行 g1.101.13;速度爬坡是反风筝压力源
* 后期快敌须能追上基础移速的玩家,否则纪律走位可无伤挂机)潮汐脉冲(×2.5)另见 TIDE,不进本表
* 校准锚:静止玩家(P0)须在 90 秒内被压死(§3.1);D0 60s 快敌速度 < 玩家基速(S2 );
* 首杀 10s 由首段 rate 保证( 11)
*/
export const WAVES = [
{ 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 } },
{ t: 0, rate: 0.9, hpMul: 1.0, speedMul: 1.0, gemMul: 1.0, mix: { zheng: 1.0 } },
{ t: 45, rate: 1.5, hpMul: 1.1, speedMul: 1.02, gemMul: 1.0, mix: { zheng: 0.9, taowu: 0.1 } },
{ t: 90, rate: 1.9, hpMul: 1.2, speedMul: 1.05, gemMul: 1.15, mix: { zheng: 0.85, taowu: 0.15 } },
{ t: 150, rate: 2.3, hpMul: 1.32, speedMul: 1.08, gemMul: 1.3, mix: { zheng: 0.75, taowu: 0.25 } },
{ t: 240, rate: 2.7, hpMul: 1.42, speedMul: 1.1, gemMul: 1.45, mix: { zheng: 0.65, taowu: 0.3, qiongqi: 0.05 } },
{ t: 330, rate: 3.1, hpMul: 1.55, speedMul: 1.13, gemMul: 1.55, mix: { zheng: 0.6, taowu: 0.3, qiongqi: 0.1 } },
{ t: 420, rate: 3.5, hpMul: 1.68, speedMul: 1.15, gemMul: 1.65, mix: { zheng: 0.55, taowu: 0.32, qiongqi: 0.13 } },
{ t: 540, rate: 3.9, hpMul: 1.82, speedMul: 1.18, gemMul: 1.75, mix: { zheng: 0.5, taowu: 0.34, qiongqi: 0.16 } },
{ t: 660, rate: 4.3, hpMul: 1.95, speedMul: 1.2, gemMul: 1.85, mix: { zheng: 0.5, taowu: 0.32, qiongqi: 0.18 } },
{ t: 780, rate: 4.7, hpMul: 2.05, speedMul: 1.22, gemMul: 1.95, mix: { zheng: 0.48, taowu: 0.32, qiongqi: 0.2 } },
{ t: 900, rate: 5.1, hpMul: 2.15, speedMul: 1.24, gemMul: 2.05, mix: { zheng: 0.46, taowu: 0.32, qiongqi: 0.22 } },
{ t: 1020, rate: 5.5, hpMul: 2.25, speedMul: 1.26, gemMul: 2.15, mix: { zheng: 0.45, taowu: 0.33, qiongqi: 0.22 } },
// ── 18:00 放量收割期(§3.1):密度跳升 + 个体血量回落 + 多出脆皮(割草放量)──
{ t: 1080, rate: 8.5, hpMul: 1.9, speedMul: 1.24, gemMul: 2.15, mix: { zheng: 0.6, taowu: 0.28, qiongqi: 0.12 } },
{ t: 1140, rate: 9.5, hpMul: 2.0, speedMul: 1.25, gemMul: 2.15, mix: { zheng: 0.6, taowu: 0.28, qiongqi: 0.12 } },
];
/**
* 潮汐脉冲(§8.1:λ(t) 爬坡 + 潮汐脉冲 ×2.5,6090s 一次):
* startS ,每隔 [minGapS,maxGapS](受控 rng )触发一次,持续 durS 内出怪率 ×mul
*/
export const TIDE = { startS: 60, minGapS: 60, maxGapS: 90, mul: 2.5, durS: 8 };
/**
* 精英(§5.2 精英词缀;词缀强化个体):普通敌种 × 基础精英乘区 + 1..affixMax 个词缀
* 出现概率自 startS 起从 baseChance 线性爬到 maxChance(rampEndS ),×难度 eliteMul
*/
export const ELITE = {
startS: 120,
baseChance: 0.015,
maxChance: 0.08,
rampEndS: 900,
extraAffixChance: 0.35, // 第 2+ 个词缀的逐个追加概率(受难度 affixMax 封顶)
hpMul: 2.8, dmgMul: 1.4, gemMul: 4, rMul: 1.3, speedMul: 0.95,
};
/** 词缀池(6 种,§5.2;命名半文半白,设定 v1 构词)。字段即效果,sim 按键消费。 */
export const AFFIXES = [
{ id: 'xunji', name: '迅疾', speedMul: 1.4 }, // 快:压走位
{ id: 'tiejia', name: '铁甲', hpMul: 2.0 }, // 厚:压火力
{ id: 'baolie', name: '暴烈', dpsMul: 1.7 }, // 疼:压贴身
{ id: 'zaisheng', name: '再生', regenPctPerS: 0.025 }, // 磨:压持续输出
{ id: 'panshi', name: '磐石', slowResist: 0.7 }, // 稳:克减速流(减速效果 ×0.3)
{ id: 'liebian', name: '裂变', onDeathSpawn: { type: 'zheng', n: 2, hpMul: 0.5, gemMul: 0 } }, // 分:死后裂出小兽
];
/**
* 1裂谷原 Boss(§3.1:10:00 Boss / 20:00 Boss,击杀终 Boss 即胜利)
* 战技组全入表:charge(蓄势定向冲撞:telegraphS 减速蓄势 锁定蓄势起点方向冲刺 durS,可侧移躲)/
* summon(周身召唤小兽)/ enrage(血量阈值狂化,一次性乘区)
* 命名(设定 v1 §1):狰为图 1 基础兽族 Boss狰王·赤鬣; Boss 取山海经公版
* 异兽九婴原创演绎Boss 个体吃难度 hpMul/dpsMul;slowResist 防减速流把 Boss 钉死;
* 不进同屏活敌上限不受软分离推挤;gem 掉落拆成 gemPer/颗的灵晶堆(磁吸玩法)
*/
export const BOSSES = [
{
id: 'zhengwang', name: '狰王·赤鬣', atS: 600,
hp: 3000, speed: 55, r: 30, dps: 38, gem: 60, gemPer: 4,
slowResist: 0.8, dropHarvest: true, final: false,
skills: {
charge: { cooldown: 9, telegraphS: 0.9, speedMul: 5.2, durS: 0.7 },
summon: { cooldown: 14, n: 6, type: 'zheng' },
},
},
{
id: 'jiuying', name: '九婴', atS: 1200,
hp: 7400, speed: 48, r: 38, dps: 60, gem: 100, gemPer: 4,
slowResist: 0.8, dropHarvest: false, final: true,
skills: {
charge: { cooldown: 8, telegraphS: 0.8, speedMul: 5.5, durS: 0.75 },
summon: { cooldown: 18, n: 5, type: 'taowu' },
enrage: { hpPct: 0.3, speedMul: 1.35, dpsMul: 1.4 },
},
},
];
/**
* 局内事件(M3 先落 4 , 8 M5;§3.1 验收用例:每局 3 种类 2,奖励值回风险)
* 调度:firstAtS 后每隔 [minGapS,maxGapS](受控 rng)按权重抽一种; Boss 出场后不再起新事件
*/
export const EVENTS = {
firstAtS: 100, minGapS: 200, maxGapS: 260, maxPerRun: 5,
pool: [
// 宝箱怪:驮宝异兽逃跑(不攻击),限时追杀;击杀 = 聚灵珠 + 灵晶堆(追离安全区 = 冒险)
{ id: 'chest', name: '宝箱怪', weight: 3, durS: 22,
beast: { hp: 260, speed: 150, r: 12 }, reward: { harvest: true, gems: { n: 10, value: 3 } } },
// 流星雨:玩家周域随机落火(先预警圈后炸),炸玩家也炸兽(风险与收益同源:引兽入雨)
{ id: 'meteor', name: '流星雨', weight: 3, durS: 18,
strikes: { gapS: 0.9, r: 68, telegraphS: 1.1, scatterR: 300, dmgPlayer: 18, dmgEnemy: 220 } },
// 祭坛充能:远处符坛,站内充能(离开暂停不清零);充满 = 治疗 + 灵晶雨(站桩 = 冒险)
{ id: 'altar', name: '祭坛充能', weight: 2, durS: 26,
altar: { dist: 260, r: 80, channelS: 6, reward: { heal: 35, gems: { n: 12, value: 2 } } } },
// 裂谷涌兽:定点涌出精英+小兽群,中心灵晶堆(冲进去打 = 冒险)
{ id: 'rift', name: '裂谷涌兽', weight: 2, durS: 20,
rift: { dist: 300, elites: 3, minions: 10, loot: { n: 14, value: 2 } } },
],
};
/** 同屏活敌上限(玩法帧预算的逻辑闸;压测另行合成 600,见 stress-test)。 */
export const MAX_ALIVE = 220;
@ -325,7 +430,9 @@ export const FEEDBACK = {
tiers: {
hit: { text: true }, // 普通命中:顿帧 0(手 4)+ 飘字
crit: { hitStopMs: 30, text: true }, // 暴击 30ms(M2 事件源已落:破煞诀/天雷引)
bossKill: { hitStopMs: 80, shakeAmpPx: 8, shakeMs: 150 }, // Boss 击杀 80ms(事件源随 M3)
bossKill: { hitStopMs: 80, shakeAmpPx: 8, shakeMs: 150, sfx: 'hit' }, // Boss 击杀 80ms(M3 事件源已落:双 Boss)
bossSpawn: { shakeAmpPx: 8, shakeMs: 300, sfx: 'powerup' }, // 手 5:Boss 登场 8px(M3 事件源)
eventStart: { sfx: 'powerup' }, // 局内事件开场提示音
kill: { sfx: 'hit' }, // 击杀:音效;爆浆粒子走 juice.burst
playerHurt: { shakeAmpPx: 5, shakeMs: 150, flashMs: 80, flashPeak: 0.3, sfx: 'hurt' }, // 手 5:受击 46px 取 5
pickup: { sfx: 'pickup' },

View File

@ -99,6 +99,8 @@ export function createGame(opts) {
/** 渲染层手感状态(事件驱动的短命特效;逻辑不依赖)。 */
const fx = { list: [], best: 0 };
/** 横幅类事件(顶部通告,寿命 2.2s;其余火花 0.5s)。 */
const BANNER_TYPES = new Set(['tide', 'eventStart', 'bossSpawn', 'bossEnrage', 'bossKill']);
/** 度量(取证 measures)。 */
const measures = { frames: 0, pointerDowns: 0, chooses: 0, restarts: 0, pauses: 0 };
@ -155,6 +157,12 @@ export function createGame(opts) {
} else if (ev.type === 'harvest') {
// 手 6 族闪屏 + 手 8 全图磁吸的仪式感档(磁吸本体在 sim 内已完成)
hf.feedback('harvest', { x: ev.x, y: ev.y });
} else if (ev.type === 'bossSpawn') {
hf.feedback('bossSpawn', { x: ev.x, y: ev.y }); // 手 5:Boss 登场 8px 震屏(M3 事件源)
} else if (ev.type === 'bossKill') {
hf.feedback('bossKill', { x: ev.x, y: ev.y }); // 手 4:Boss 击杀 80ms 顿帧(M3 事件源)
} else if (ev.type === 'eventStart') {
hf.feedback('eventStart', { x: ev.x, y: ev.y }); // 局内事件开场提示音
}
}
@ -170,14 +178,15 @@ export function createGame(opts) {
const ts = (juice && juice.getTimeScale) ? juice.getTimeScale() : 1;
sim.update(dt * ts);
// 手感事件 → 渲染特效队列 + 反馈泵(hit-feedback 档位表)
// 横幅类(潮汐/事件/Boss)存活 2.2s 保可读,普通火花 0.5s
const evs = sim.drainEvents();
for (const e of evs) {
if (fx.list.length < 120) fx.list.push({ ...e, age: 0 });
if (fx.list.length < 120) fx.list.push({ ...e, age: 0, ttl: BANNER_TYPES.has(e.type) ? 2.2 : 0.5 });
pumpFeedback(e);
}
for (let i = fx.list.length - 1; i >= 0; i--) {
fx.list[i].age += dt;
if (fx.list[i].age > 0.5) fx.list.splice(i, 1);
if (fx.list[i].age > (fx.list[i].ttl || 0.5)) fx.list.splice(i, 1);
}
// 三选一弹层与 sim 相位同步(弹出即冻结在 sim 侧;此处只管 UI 开合)
const ph0 = sim.phase;

View File

@ -93,6 +93,7 @@ export function drawGame(g, s) {
const camY = v.player.y - viewport.h / 2;
drawGround(g, camX, camY, viewport);
drawEventOverlays(g, v, camX, camY);
drawZones(g, v, camX, camY);
drawGems(g, v, camX, camY);
drawDrops(g, v, camX, camY);
@ -100,6 +101,7 @@ export function drawGame(g, s) {
drawProjectiles(g, v, camX, camY);
drawPlayer(g, v, viewport);
drawFx(g, s.fx, camX, camY, viewport);
drawBanners(g, s.fx, viewport);
// 击杀爆浆等粒子 + 屏级闪白(juice.render 一并画;在 HUD 之下保持信息可读)
if (s.juice && s.juice.render) s.juice.render(g);
// 伤害飘字(手 7:hit-feedback 聚合数据,这里只负责画)
@ -181,14 +183,68 @@ function drawPlayer(g, v, viewport) {
g.globalAlpha = 1;
}
/** 词缀记号色(精英头顶小点;占位期固定映射)。 */
const AFFIX_COLOR = {
xunji: '#BFE8A0', tiejia: '#E8DCC0', baolie: '#E8703A',
zaisheng: '#7FD1C0', panshi: '#C9A87A', liebian: '#F2C14E',
};
function drawEnemies(g, v, camX, camY) {
for (let i = 0; i < v.enemies.length; i++) {
const e = v.enemies[i];
const x = e.x - camX, y = e.y - camY;
if (x < -40 || y < -40 || x > 430 || y > 884) continue; // 屏外裁剪
if (x < -80 || y < -80 || x > 470 || y > 924) continue; // 屏外裁剪(放宽容 Boss 大体型)
// 宝箱怪:驮宝异兽(灵晶色,醒目=「值得追」;不攻击只逃)
if (e.isChest) {
g.fillStyle = e.hurtFlash > 0 ? C.white : C.harvest;
g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill();
g.fillStyle = C.bg;
g.fillRect(x - 3, y - 5, 6, 10); // 背上的宝匣(占位)
if (e.hp < e.maxHp) {
g.fillStyle = C.hpBack; g.fillRect(x - e.r, y - e.r - 7, e.r * 2, 3);
g.fillStyle = C.hp; g.fillRect(x - e.r, y - e.r - 7, e.r * 2 * Math.max(0, e.hp / e.maxHp), 3);
}
continue;
}
// Boss:大体型 + 蓄势预警线(telegraphT>0 闪烁,指示冲撞方向=可侧移躲)+ 犄角图腾
if (e.isBoss) {
const st = e.boss;
if (st && st.telegraphT > 0 && ((v.frame >> 2) & 1) === 0) {
g.strokeStyle = C.hp;
g.lineWidth = 3;
g.globalAlpha = 0.6;
g.beginPath();
g.moveTo(x, y);
g.lineTo(x + st.chargeDirX * 180, y + st.chargeDirY * 180);
g.stroke();
g.globalAlpha = 1;
}
g.fillStyle = e.hurtFlash > 0 ? C.white : C.zheng;
g.strokeStyle = C.player;
g.lineWidth = 3;
g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill(); g.stroke();
g.fillStyle = C.player;
for (let k = -1; k <= 1; k++) g.fillRect(x + k * e.r * 0.45 - 2, y - e.r - 8, 4, 10); // 犄角三刺
g.fillStyle = C.bg;
g.beginPath(); g.arc(x - e.r * 0.3, y - e.r * 0.15, e.r * 0.1, 0, Math.PI * 2); g.fill();
g.beginPath(); g.arc(x + e.r * 0.3, y - e.r * 0.15, e.r * 0.1, 0, Math.PI * 2); g.fill();
continue; // Boss 血条走 HUD 顶栏(drawBossBar),不画头顶小条
}
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.elite) {
g.strokeStyle = C.player;
g.lineWidth = 2;
g.beginPath(); g.arc(x, y, e.r + 3, 0, Math.PI * 2); g.stroke();
if (e.affixes) {
for (let k = 0; k < e.affixes.length; k++) {
g.fillStyle = AFFIX_COLOR[e.affixes[k]] || C.white;
g.beginPath(); g.arc(x - (e.affixes.length - 1) * 3 + k * 6, y - e.r - 6, 2.2, 0, Math.PI * 2); g.fill();
}
}
}
// 减速态:环形描边(寒渊/盾环机制的可读性)
if (e.slowT > 0) {
g.strokeStyle = WCOLOR.hanyuan;
@ -294,6 +350,83 @@ function drawDrops(g, v, camX, camY) {
}
}
/**
* 局内事件覆盖层(流星预警圈/祭坛符阵/裂谷标记;逻辑态全来自 sim.view().event,只画不算)
* 绘制纪律(M1 arc 崩溃教训)一切算出来的半径过 Math.max(0,) 钳制:预警圈半径由
* telegraph 剩余时间插值,时间轴任何越界(负值/超额 dt)都不许把负半径喂进 canvas.arc
* (真浏览器抛 IndexSizeError RAF 链断);位提取只用无符号右移 >>>
*/
function drawEventOverlays(g, v, camX, camY) {
const ev = v.event;
if (!ev) return;
const def = ev.def;
if (def.id === 'meteor' && ev.strikes) {
for (const s of ev.strikes) {
const x = s.x - camX, y = s.y - camY;
if (x < -100 || y < -100 || x > 490 || y > 944) continue;
// 预警圈:随剩余时间收拢(1→0);半径钳非负(纪律见函数头)
const p = Math.max(0, Math.min(1, s.tT / def.strikes.telegraphS));
const R = Math.max(0.5, def.strikes.r * (0.4 + 0.6 * p));
g.strokeStyle = '#E8703A';
g.globalAlpha = 0.35 + 0.45 * (1 - p); // 越临近越亮
g.lineWidth = 2;
g.beginPath(); g.arc(x, y, R, 0, Math.PI * 2); g.stroke();
g.globalAlpha = 1;
}
} else if (def.id === 'altar' && ev.altar) {
const a = ev.altar;
const x = a.x - camX, y = a.y - camY;
if (x < -160 || y < -160 || x > 550 || y > 1004) return;
g.strokeStyle = C.gem;
g.globalAlpha = 0.5;
g.lineWidth = 2;
g.beginPath(); g.arc(x, y, def.altar.r, 0, Math.PI * 2); g.stroke();
// 充能弧(progress/channelS;钳 [0,1])
const p = Math.max(0, Math.min(1, a.progress / def.altar.channelS));
if (p > 0) {
g.globalAlpha = 0.9;
g.lineWidth = 4;
g.beginPath(); g.arc(x, y, Math.max(0.5, def.altar.r - 8), -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p); g.stroke();
}
g.globalAlpha = 0.8;
g.fillStyle = C.gem;
g.fillRect(x - 4, y - 12, 8, 24); // 符坛(占位柱)
g.globalAlpha = 1;
} else if (def.id === 'rift' && ev.rift) {
const x = ev.rift.x - camX, y = ev.rift.y - camY;
if (x < -140 || y < -140 || x > 530 || y > 984) return;
g.strokeStyle = C.zheng;
g.globalAlpha = 0.45;
g.lineWidth = 3;
g.beginPath(); g.arc(x, y, 60, 0, Math.PI * 2); g.stroke();
g.globalAlpha = 0.25;
g.fillStyle = C.zheng;
g.beginPath(); g.arc(x, y, 60, 0, Math.PI * 2); g.fill();
g.globalAlpha = 1;
}
}
/** 顶部横幅(潮汐/事件开场/Boss 登场·狂化;fx 事件驱动,按 age 淡出)。 */
function drawBanners(g, fx, viewport) {
for (const e of fx.list) {
let text = null;
if (e.type === 'tide') text = '潮 汐 来 袭';
else if (e.type === 'eventStart') text = `异象 · ${e.name || ''}`;
else if (e.type === 'bossSpawn') text = `${e.name || ''} 现身`;
else if (e.type === 'bossEnrage') text = `${e.name || ''} 狂性大发`;
else if (e.type === 'bossKill') text = `${e.name || ''} 伏诛`;
if (!text) continue;
const p = Math.max(0, Math.min(1, e.age / (e.ttl || 0.5)));
g.globalAlpha = 1 - p * p;
g.fillStyle = e.type === 'tide' ? C.gem : C.player;
g.font = 'bold 20px system-ui, sans-serif';
g.textAlign = 'center';
g.fillText(text, viewport.w / 2, 120);
g.globalAlpha = 1;
g.textAlign = 'left';
}
}
/** 手感事件特效(sim 事件驱动的短命火花;粒子本体归 hit-feedback/juice)。 */
function drawFx(g, fx, camX, camY, viewport) {
for (const e of fx.list) {
@ -586,13 +719,14 @@ function drawSettle(g, v, viewport, best) {
`击杀 ${v.stats.kills} · 等级 Lv ${v.level}`,
`灵晶 ${v.stats.gems} · 最佳击杀 ${Math.max(best, v.stats.kills)}`,
`进化 ${v.stats.evolutions} · 全图收割 ${v.stats.harvests}`,
`精英 ${v.stats.eliteKills} · Boss ${v.stats.bossKills} · 异象 ${v.stats.eventsSeen}`,
];
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);
g.fillText(names, viewport.w / 2, viewport.h * 0.36 + 5 * 28 + 6);
drawBigButton(g, layoutButton(viewport, 'restart'), '再 巡');
g.textAlign = 'left';
}

View File

@ -1,23 +1,24 @@
/**
* sim.js 山海行纪纯逻辑核心(M2:build 体系全量)
* sim.js 山海行纪纯逻辑核心(M3: 1 20 分钟局)
* owner:北极星顶级线② 消费方:game-logic.js(渲染壳)/ scripts/headless-smoke.mjs(headless)/
* scripts/sim-scan.mjs(参数扫描,§8.3 A)
* scripts/sim-scan.mjs(参数扫描,§8.3 A)/ scripts/record-run.mjs(全程录证)
*
*
* 纯逻辑铁律(开发设计书 §8.3 / NSTAR O-4 拍定,从第一行代码就位)
* - DOM / canvas / 零引擎 import:本文件 node 直跑;
* - 时间只经 update(dt) 注入,随机只经受控 rng(rng.js);
* - 走位 / 碰撞 / 索敌 / 磁吸 / 波次 / 升级 / 结算全在本层,渲染层只画不算;
* - 手感事件(命中/暴击/击杀/升级/拾取/受击/收割)以事件队列吐给渲染层消费(drainEvents),
* 逻辑不依赖渲染是否消费
* - 手感事件(命中/暴击/击杀/升级/拾取/受击/收割/Boss/事件)以事件队列吐给渲染层消费
* (drainEvents),逻辑不依赖渲染是否消费
*
* M2 范围武器 12(五族索敌:最近敌/随机敌/朝移动方向/环绕自身/原地投放)+ 被动 14 +
* 进化 12(满级+对应被动3 下一次三选一必然出现进化项,§3.1)+ 槽位 6+6 +
* 难度 D0/D1(表驱动乘区)+ 暴击轴( 4 事件源)+ 聚灵珠全图磁吸( 6/ 8 事件源)
* 数值全部来自 balance.js ;派生语义单一实现 = build-model.js(sim 与验收测试同源)
* M2 范围武器 12(五族索敌)+ 被动 14 + 进化 12 + 槽位 6+6 + 难度表 + 暴击轴 + 聚灵珠
* M3 范围20 分钟正式局:胜利线 = 击杀终 Boss(§3.1);潮汐脉冲(TIDE ×2.5);精英词缀
* (ELITE×AFFIXES,可叠加按难度 affixMax); 1 Boss(10:00 狰王 / 20:00 九婴,
* 战技组 charge/summon/enrage 全表驱动;Boss 不进活敌上限不受软分离抗减速);
* 局内事件 4 (宝箱怪/流星雨/祭坛充能/裂谷涌兽,单活跃事件调度)
* 实体形态proj(投射物:走格命中+穿透) zone(区域:orbit 绕身/place 驻场, tick
* 对半径内敌结算)两类;AoE(爆裂/脉冲/链跳)先收集受害者再按下标降序结算处理,
* swap-remove 回池共存不踩下标(击杀把尾元素换到当前位,降序保证未处理下标不受影响)
* 对半径内敌结算)两类;AoE(爆裂/脉冲/链跳/流星)先收集受害者再按下标降序结算
* 处理,swap-remove 回池共存不踩下标(击杀把尾元素换到当前位,降序保证未处理下标不受影响)
*
*/
@ -29,11 +30,16 @@ import { createUniformGrid, separateOne, seekStep } from '../../../src/plugins/s
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, WEAPONS, PASSIVES, XP, ENEMIES, WAVES,
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,
TIDE, ELITE, AFFIXES, BOSSES, EVENTS,
} from './balance.js';
/** 词缀索引(id → 定义;spawn 热路径免重查)。 */
const AFFIX_BY_ID = {};
for (const a of AFFIXES) AFFIX_BY_ID[a.id] = a;
/** ease-in(二次):磁吸飞行「先慢后快」曲线(手 8)。 */
function easeInQuad(p) { return p * p; }
@ -91,17 +97,19 @@ export function createSim(opts) {
}
addWeapon(START_WEAPON_ID);
/* 经验与等级。 */
/* 经验与等级(xpNeed = base + step×(L1) + quad×(L1)²,M3 二次曲线——§8.1 升级次数锚)。 */
const xpNeedAt = (L) => Math.round(XP.base + XP.step * (L - 1) + XP.quad * (L - 1) * (L - 1));
let level = 1;
let xp = 0; // float(纳灵诀乘区可产小数)
let xpNeed = XP.base;
let xpNeed = xpNeedAt(1);
/** 当前三选一候选(phase==='levelup' 时非空;元素 = {kind:'weapon'|'passive'|'evo', id})。 */
let offer = [];
/* 统计(结算与取证;dmgByWeapon = 扫描器 DPS 归因面)。 */
/* 统计(结算与取证;dmgByWeapon = 扫描器 DPS 归因面;M3 增精英/Boss/事件)。 */
const stats = {
kills: 0, gems: 0, levelUps: 0, dmgDealt: 0, dmgTaken: 0,
evolutions: 0, harvests: 0, dmgByWeapon: {},
eliteKills: 0, bossKills: 0, eventsSeen: 0, eventsByType: {},
};
/* ── 实体池(entity-pool 沉淀件:swap-remove + free list,局内零 GC 目标)── */
@ -119,9 +127,22 @@ export function createSim(opts) {
let lastHarvestDropT = -Infinity;
/** 实体单调 id(取证 sourceId;局内唯一)。 */
let eidSeq = 0;
/** Boss 血条预览位(M3 才有真 Boss;_debugFill 可注入以验 HUD 绘制面)。 */
/** Boss 血条覆写位(_debugFill 注入渲染面预览;真 Boss 活体优先见 liveBossBar)。 */
let bossBar = null;
/* ── M3 局面调度状态 ── */
/** 潮汐脉冲:下一次触发时刻与当前脉冲截止(受控 rng 取带内间隔)。 */
let nextTideT = TIDE.startS + rng() * (TIDE.maxGapS - TIDE.minGapS);
let tideUntil = -1;
/** Boss 出场旗(与 BOSSES 表同下标)+ 在场 Boss 计数(血条快路)。 */
const bossSpawned = BOSSES.map(() => false);
let finalBossSpawned = false;
let aliveBosses = 0;
/** 局内事件:下一次调度时刻 + 当前活跃事件(单活跃;终 Boss 后不再起新)。 */
let nextEventT = EVENTS.firstAtS;
/** @type {null | {def:object, endT:number, strikes?:Array, altar?:object, rift?:object, chestEid?:number, strikeAcc?:number}} */
let activeEvent = null;
/** 手感事件队列(渲染层 drainEvents 消费;含 frame 戳)。 */
let events = [];
function emit(type, x, y, extra) {
@ -145,6 +166,19 @@ export function createSim(opts) {
for (const k in mix) { roll -= mix[k]; if (roll <= 0) return k; }
return Object.keys(mix)[0]; // 浮点兜底
}
/** 实体字段基线复位(池复用防脏字段;所有生成路径共用)。 */
function initEnemyFields(e, typeId, def) {
e.id = ++eidSeq;
e.type = typeId;
e.hurtFlash = 0; // 渲染层读:受击闪白剩余(秒)
e.slowT = 0; e.slowPct = 0; // 减速(寒渊/盾环等机制)
e.dotT = 0; e.dotDps = 0; e.dotWid = null; // 持续伤(灼烧/溺蚀)
e.elite = false; e.affixes = null; e.regenPct = 0; e.slowResist = 0; e.split = null;
e.isBoss = false; e.isChest = false; e.boss = null;
e.r = def.r;
e.gem = def.gem;
}
/** 波次出怪(含精英词缀滚点:概率线性爬坡 × 难度 eliteMul,词缀 1..affixMax 叠加)。 */
function spawnEnemy() {
if (enemies.length >= MAX_ALIVE) return;
const w = currentWave();
@ -152,23 +186,148 @@ export function createSim(opts) {
const def = ENEMIES[typeId];
const ang = rng() * Math.PI * 2;
const e = enemyPool.acquire();
e.id = ++eidSeq;
e.type = typeId;
initEnemyFields(e, typeId, def);
e.x = player.x + Math.cos(ang) * SPAWN_R;
e.y = player.y + Math.sin(ang) * SPAWN_R;
// 个体成长 = 波表乘区(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; // 持续伤(灼烧/溺蚀)
e.speed = def.speed * (w.speedMul || 1) * diff.speedMul;
e.dps = def.dps * diff.dpsMul;
e.gem = def.gem * (w.gemMul || 1); // 经济随波走(后期灵晶增值,§8.1 经济侧)
// 精英词缀(§5.2):startS 后按爬坡概率;基础精英乘区 + 词缀效果逐个叠
if (t >= ELITE.startS) {
const ramp = Math.min(1, (t - ELITE.startS) / (ELITE.rampEndS - ELITE.startS));
const chance = (ELITE.baseChance + (ELITE.maxChance - ELITE.baseChance) * ramp) * diff.eliteMul;
if (rng() < chance) applyElite(e);
}
enemies.push(e);
}
/** 击杀第 i 个敌:掉晶、聚灵珠判定、统计、事件、swap-remove 回池。 */
/** 把普通个体升格为精英(基础乘区 + 1..affixMax 个词缀;词缀效果全表驱动)。 */
function applyElite(e) {
e.elite = true;
e.hp *= ELITE.hpMul; e.maxHp = e.hp;
e.dps *= ELITE.dmgMul;
e.gem *= ELITE.gemMul;
e.r *= ELITE.rMul;
e.speed *= ELITE.speedMul;
const picks = [];
const pool = AFFIXES.map((a) => a.id);
let n = 1;
while (n < diff.affixMax && rng() < ELITE.extraAffixChance) n++;
for (let k = 0; k < n && pool.length > 0; k++) {
const idx = (rng() * pool.length) | 0;
picks.push(pool.splice(idx, 1)[0]);
}
e.affixes = picks;
for (const id of picks) {
const a = AFFIX_BY_ID[id];
if (a.speedMul) e.speed *= a.speedMul;
if (a.hpMul) { e.hp *= a.hpMul; e.maxHp = e.hp; }
if (a.dpsMul) e.dps *= a.dpsMul;
if (a.regenPctPerS) e.regenPct = a.regenPctPerS;
if (a.slowResist) e.slowResist = a.slowResist;
if (a.onDeathSpawn) e.split = a.onDeathSpawn;
}
}
/** 定点生成小兽(Boss 召唤/裂谷涌兽/裂变;吃当前波乘区,尊重活敌上限)。 */
function spawnMinion(typeId, x, y, hpMul, gemMul) {
if (enemies.length >= MAX_ALIVE) return null;
const def = ENEMIES[typeId];
const w = currentWave();
const e = enemyPool.acquire();
initEnemyFields(e, typeId, def);
e.x = x; e.y = y;
e.hp = def.hp * (w.hpMul || 1) * diff.hpMul * (hpMul || 1); e.maxHp = e.hp;
e.speed = def.speed * (w.speedMul || 1) * diff.speedMul;
e.dps = def.dps * diff.dpsMul;
e.gem = def.gem * (w.gemMul || 1) * (gemMul == null ? 1 : gemMul);
enemies.push(e);
return e;
}
/** 定点生成精英(裂谷涌兽事件)。 */
function spawnEliteAt(x, y) {
const e = spawnMinion(pickType(currentWave().mix), x, y, 1, 1);
if (e) applyElite(e);
return e;
}
/** Boss 出场(§3.1 定点:10:00/20:00):不进活敌上限、战技状态机随体、难度乘区吃 hp/dps。 */
function spawnBoss(def) {
const ang = rng() * Math.PI * 2;
const e = enemyPool.acquire();
initEnemyFields(e, 'boss', { r: def.r, gem: def.gem });
e.isBoss = true;
e.bossDef = def;
e.x = player.x + Math.cos(ang) * SPAWN_R * 0.9;
e.y = player.y + Math.sin(ang) * SPAWN_R * 0.9;
e.hp = def.hp * diff.hpMul; e.maxHp = e.hp;
e.speed = def.speed * diff.speedMul;
e.dps = def.dps * diff.dpsMul;
e.slowResist = def.slowResist || 0;
// 战技状态(表驱动冷却;telegraph→charge 两段)
e.boss = {
chargeCd: def.skills.charge ? def.skills.charge.cooldown : Infinity,
summonCd: def.skills.summon ? def.skills.summon.cooldown : Infinity,
telegraphT: 0, chargeT: 0, chargeDirX: 0, chargeDirY: 0, enraged: false,
};
enemies.push(e);
aliveBosses++;
emit('bossSpawn', e.x, e.y, { name: def.name, bossId: def.id, final: !!def.final });
return e;
}
/** 宝箱怪(事件):不攻击、见人就逃、限时;击杀=聚灵珠+灵晶堆。 */
function spawnChest(beast) {
const ang = rng() * Math.PI * 2;
const e = enemyPool.acquire();
initEnemyFields(e, 'chest', { r: beast.r, gem: 0 });
e.isChest = true;
e.x = player.x + Math.cos(ang) * 260;
e.y = player.y + Math.sin(ang) * 260;
e.hp = beast.hp * diff.hpMul; e.maxHp = e.hp;
e.speed = beast.speed; e.dps = 0;
enemies.push(e);
return e;
}
/** 灵晶堆(Boss 掉落/事件奖励;idle 散落,磁吸/收割统一收)。 */
function dropGemPile(x, y, n, value, ringR) {
for (let k = 0; k < n; k++) {
const ang = rng() * Math.PI * 2;
const rad = rng() * (ringR || 50);
const g = gemPool.acquire();
g.x = x + Math.cos(ang) * rad; g.y = y + Math.sin(ang) * rad;
g.value = value;
g.state = 'idle'; g.flyT = 0; g.flyDur = 0; g.sx = 0; g.sy = 0;
gems.push(g);
}
}
/** 击杀第 i 个敌:掉晶、聚灵珠判定、统计、事件、swap-remove 回池(Boss/宝箱怪/裂变分支)。 */
function killEnemy(i) {
const e = enemies[i];
stats.kills++;
emit('kill', e.x, e.y, { etype: e.type, eid: e.id });
if (e.elite) stats.eliteKills++;
emit('kill', e.x, e.y, { etype: e.type, eid: e.id, elite: !!e.elite });
if (e.isBoss) {
// Boss 击杀:灵晶堆 + 手 4 bossKill 档;终 Boss = 胜利结算(§3.1 击杀即胜利)
const def = e.bossDef;
stats.bossKills++;
aliveBosses--;
dropGemPile(e.x, e.y, Math.round(def.gem / def.gemPer), def.gemPer, 70);
if (def.dropHarvest) drops.push({ x: e.x, y: e.y, id: ++eidSeq, ttl: HARVEST.ttlS });
emit('bossKill', e.x, e.y, { name: def.name, bossId: def.id, final: !!def.final });
enemyPool.release(swapRemove(enemies, i));
if (def.final) { phase = 'win'; emit('settle', player.x, player.y, { outcome: 'win' }); }
return;
}
if (e.isChest) {
// 宝箱怪:聚灵珠 + 灵晶堆(奖励值回风险,§3.1);不走普通掉晶
const ev = activeEvent && activeEvent.def.id === 'chest' ? activeEvent.def : null;
const reward = ev ? ev.reward : { harvest: true, gems: { n: 8, value: 2 } };
if (reward.harvest) drops.push({ x: e.x, y: e.y, id: ++eidSeq, ttl: HARVEST.ttlS });
dropGemPile(e.x, e.y, reward.gems.n, reward.gems.value, 40);
emit('chestKill', e.x, e.y, {});
if (activeEvent && activeEvent.chestEid === e.id) activeEvent.chestEid = -1;
enemyPool.release(swapRemove(enemies, i));
return;
}
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;
@ -178,7 +337,16 @@ export function createSim(opts) {
lastHarvestDropT = t;
drops.push({ x: e.x, y: e.y, id: ++eidSeq, ttl: HARVEST.ttlS });
}
// 裂变词缀:死后裂出小兽(追加在数组尾,降序结算批次不受影响;下一帧重建网格收编)
const sp = e.split;
const sx = e.x, sy = e.y;
enemyPool.release(swapRemove(enemies, i));
if (sp) {
for (let k = 0; k < sp.n; k++) {
const ang = rng() * Math.PI * 2;
spawnMinion(sp.type, sx + Math.cos(ang) * 14, sy + Math.sin(ang) * 14, sp.hpMul, sp.gemMul);
}
}
}
/* ── 伤害管线(暴击→事件→死亡收集;AoE 用「收集-降序结算」防下标漂移)── */
@ -362,7 +530,7 @@ export function createSim(opts) {
xp -= xpNeed;
level++;
stats.levelUps++;
xpNeed = XP.base + XP.step * (level - 1);
xpNeed = xpNeedAt(level);
offer = rollOffer();
if (offer.length === 0) return; // 全满:静默升级不冻结
phase = 'levelup'; // 全局冻结(手 9:弹出即冻结,输入只作用于选卡)
@ -399,10 +567,31 @@ export function createSim(opts) {
frame++;
t += dt;
/* 1) 波次生成:λ(t)×难度 rateMul 积分出怪 */
spawnAcc += currentWave().rate * diff.rateMul * dt;
/* 1) 波次生成:λ(t)×难度 rateMul×潮汐脉冲 积分出怪(TIDE:6090s 一次,×2.5 持续 durS) */
if (t >= nextTideT) {
tideUntil = t + TIDE.durS;
nextTideT = t + TIDE.minGapS + rng() * (TIDE.maxGapS - TIDE.minGapS);
emit('tide', player.x, player.y, {});
}
const tideMul = t < tideUntil ? TIDE.mul : 1;
spawnAcc += currentWave().rate * diff.rateMul * tideMul * dt;
while (spawnAcc >= 1) { spawnAcc -= 1; spawnEnemy(); }
/* 1b) Boss 出场(§3.1 定点 10:00/20:00;终 Boss 落场后不再起新事件) */
for (let bi = 0; bi < BOSSES.length; bi++) {
if (!bossSpawned[bi] && t >= BOSSES[bi].atS) {
bossSpawned[bi] = true;
if (BOSSES[bi].final) finalBossSpawned = true;
spawnBoss(BOSSES[bi]);
}
}
/* 1c) 局内事件调度(单活跃;§3.1 每局 ≥3 次、种类 ≥2 由 gap 表保证,录证脚本断言) */
if (!activeEvent && !finalBossSpawned && stats.eventsSeen < EVENTS.maxPerRun && t >= nextEventT) {
startEvent();
}
if (activeEvent) updateEvent(dt);
/* 2) 玩家移动(方向已归一,速度恒定=手 3 斜向归一)+ 回春露回复 */
const speed = PLAYER.speed * pd.speedMul;
player.x += player.dirX * speed * dt;
@ -413,13 +602,15 @@ export function createSim(opts) {
/* 3) 网格重建(本帧索敌/分离/弹命中/区域结算共享) */
rebuildGrid();
/* 4) 敌:直线追踪(减速乘区)+ 邻域软分离 + 持续伤 + 接触伤 */
/* 4) :线()+ + +
(M3 分支:Boss 战技状态机 / 宝箱怪逃逸 / 再生词缀;Boss 不受软分离推挤) */
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;
if (e.slowT > 0) { e.slowT -= dt; if (e.slowT <= 0) e.slowPct = 0; }
if (e.regenPct > 0 && e.hp < e.maxHp) e.hp = Math.min(e.maxHp, e.hp + e.maxHp * e.regenPct * dt); // 再生词缀
if (e.dotT > 0) {
e.dotT -= dt;
const dd = e.dotDps * dt;
@ -429,8 +620,20 @@ export function createSim(opts) {
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);
let d;
if (e.isBoss) {
d = bossStep(e, spd, dt); // 战技状态机(蓄势/冲撞/召唤/狂化)
} else if (e.isChest) {
// 宝箱怪:背向玩家逃逸(不攻击;追杀窗由事件时限决定)
const fx = e.x - player.x, fy = e.y - player.y;
const fd = Math.hypot(fx, fy) || 1;
e.x += (fx / fd) * spd * dt; e.y += (fy / fd) * spd * dt;
d = fd;
separateOne(e, i, grid, SEPARATION);
} else {
d = seekStep(e, player.x, player.y, spd, dt);
separateOne(e, i, grid, SEPARATION);
}
if (d < e.r + player.r) contactDps += e.dps;
}
if (dotDead.length > 0) {
@ -474,8 +677,11 @@ export function createSim(opts) {
if (hitIdx >= 0) {
const e = enemies[hitIdx];
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; }
// 机制:减速/灼烧先挂(死了也无妨,字段随实体回池复位;磐石词缀/Boss 抗减速打折)
if (wd.slow) {
const pct = wd.slow.pct * (1 - (e.slowResist || 0));
if (e.slowPct < pct || e.slowT <= 0) { e.slowPct = 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;
@ -519,7 +725,8 @@ export function createSim(opts) {
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; }
const pct = z.wd.slow.pct * (1 - (e.slowResist || 0)); // 磐石词缀/Boss 抗减速
if (e.slowPct < pct || e.slowT <= 0) { e.slowPct = pct; e.slowT = z.wd.slow.s; }
}
}
if (z.wd.dot) {
@ -585,12 +792,163 @@ export function createSim(opts) {
}
}
/* 10) 升级判定(冻结在弹层;choose 后回 play) */
tryLevelUp();
if (phase !== 'play') return;
/* 10) (;choose play)
胜利线 = 击杀终 Boss( 6/7 内即时判,§3.1);win 后本帧不得再进弹层覆写终态 */
if (phase === 'play') tryLevelUp();
}
/* 11) 撑满即胜(占位胜利线;20 分钟波表+终 Boss 归 M3) */
if (t >= ROUND_S) { phase = 'win'; emit('settle', player.x, player.y, { outcome: 'win' }); }
/**
* Boss 战技状态机(表驱动):蓄势(telegraphS 减速慢行,锁定蓄势起点方向) 冲撞
* (durS 内按锁定方向 speedMul 倍速直冲,可侧移躲) 常态追击;召唤按冷却周身出小兽;
* 狂化(enrage)血量阈值一次性乘区返回与玩家距离(接触伤判定复用)
*/
function bossStep(e, spd, dt) {
const def = e.bossDef, st = e.boss, sk = def.skills;
// 狂化:阈值一次性触发
if (sk.enrage && !st.enraged && e.hp / e.maxHp <= sk.enrage.hpPct) {
st.enraged = true;
e.speed *= sk.enrage.speedMul || 1;
e.dps *= sk.enrage.dpsMul || 1;
emit('bossEnrage', e.x, e.y, { name: def.name });
}
let d;
if (st.chargeT > 0) {
// 冲撞:锁定方向直冲(不吃减速——冲势;接触伤照常)
st.chargeT -= dt;
e.x += st.chargeDirX * e.speed * sk.charge.speedMul * dt;
e.y += st.chargeDirY * e.speed * sk.charge.speedMul * dt;
d = Math.hypot(player.x - e.x, player.y - e.y);
} else if (st.telegraphT > 0) {
// 蓄势:减速慢行(渲染层读 telegraphT 画预警);到点转冲撞
st.telegraphT -= dt;
d = seekStep(e, player.x, player.y, spd * 0.25, dt);
if (st.telegraphT <= 0) st.chargeT = sk.charge.durS;
} else {
d = seekStep(e, player.x, player.y, spd, dt);
// 冷却:蓄势冲撞(锁定蓄势起点方向 = 侧移可躲)
if (sk.charge) {
st.chargeCd -= dt;
if (st.chargeCd <= 0) {
st.chargeCd = sk.charge.cooldown;
st.telegraphT = sk.charge.telegraphS;
const dx = player.x - e.x, dy = player.y - e.y;
const dd = Math.hypot(dx, dy) || 1;
st.chargeDirX = dx / dd; st.chargeDirY = dy / dd;
emit('bossTelegraph', e.x, e.y, { name: def.name });
}
}
// 召唤:周身环出小兽
if (sk.summon) {
st.summonCd -= dt;
if (st.summonCd <= 0) {
st.summonCd = sk.summon.cooldown;
for (let k = 0; k < sk.summon.n; k++) {
const ang = (Math.PI * 2 * k) / sk.summon.n + rng() * 0.5;
spawnMinion(sk.summon.type, e.x + Math.cos(ang) * (e.r + 26), e.y + Math.sin(ang) * (e.r + 26), 1, 1);
}
emit('bossSummon', e.x, e.y, { name: def.name });
}
}
}
return d;
}
/* ── 局内事件(表驱动;单活跃)── */
/** 起一个事件:按权重抽种类,初始化事件态,排下一次调度。 */
function startEvent() {
let total = 0;
for (const ev of EVENTS.pool) total += ev.weight;
let roll = rng() * total;
let def = EVENTS.pool[0];
for (const ev of EVENTS.pool) { roll -= ev.weight; if (roll <= 0) { def = ev; break; } }
nextEventT = t + EVENTS.minGapS + rng() * (EVENTS.maxGapS - EVENTS.minGapS);
stats.eventsSeen++;
stats.eventsByType[def.id] = (stats.eventsByType[def.id] || 0) + 1;
activeEvent = { def, endT: t + def.durS };
if (def.id === 'chest') {
const e = spawnChest(def.beast);
activeEvent.chestEid = e ? e.id : -1;
} else if (def.id === 'meteor') {
activeEvent.strikes = [];
activeEvent.strikeAcc = def.strikes.gapS; // 开场即落第一颗
} else if (def.id === 'altar') {
const ang = rng() * Math.PI * 2;
activeEvent.altar = {
x: player.x + Math.cos(ang) * def.altar.dist,
y: player.y + Math.sin(ang) * def.altar.dist,
progress: 0, done: false,
};
} else if (def.id === 'rift') {
const ang = rng() * Math.PI * 2;
const rx = player.x + Math.cos(ang) * def.rift.dist;
const ry = player.y + Math.sin(ang) * def.rift.dist;
activeEvent.rift = { x: rx, y: ry };
for (let k = 0; k < def.rift.elites; k++) spawnEliteAt(rx + (rng() * 2 - 1) * 40, ry + (rng() * 2 - 1) * 40);
for (let k = 0; k < def.rift.minions; k++) spawnMinion(pickType(currentWave().mix), rx + (rng() * 2 - 1) * 70, ry + (rng() * 2 - 1) * 70, 1, 1);
dropGemPile(rx, ry, def.rift.loot.n, def.rift.loot.value, 46);
}
emit('eventStart', player.x, player.y, { id: def.id, name: def.name });
}
/** 事件逐帧:流星预警/爆点、祭坛充能、宝箱怪限时;到时收尾。 */
function updateEvent(dt) {
const def = activeEvent.def;
if (def.id === 'meteor') {
// 播撒:每 gapS 一颗(玩家周域受控随机点,先预警后爆)
activeEvent.strikeAcc += dt;
if (activeEvent.strikeAcc >= def.strikes.gapS && t < activeEvent.endT) {
activeEvent.strikeAcc -= def.strikes.gapS;
const ang = rng() * Math.PI * 2;
const rad = rng() * def.strikes.scatterR;
activeEvent.strikes.push({ x: player.x + Math.cos(ang) * rad, y: player.y + Math.sin(ang) * rad, tT: def.strikes.telegraphS });
}
for (let i = activeEvent.strikes.length - 1; i >= 0; i--) {
const s = activeEvent.strikes[i];
s.tT -= dt;
if (s.tT > 0) continue;
// 爆点:炸玩家(过 iframe/护甲同接触伤路)也炸兽(收集-降序)
const prr = def.strikes.r + player.r;
const pdx = s.x - player.x, pdy = s.y - player.y;
if (pdx * pdx + pdy * pdy < prr * prr && player.iframe <= 0) {
const dmg = def.strikes.dmgPlayer * (1 - pd.armor);
player.hp -= dmg;
stats.dmgTaken += dmg;
player.iframe = PLAYER.hurtIframe;
emit('playerHurt', player.x, player.y, { dmg, src: 'meteor' });
if (player.hp <= 0) { player.hp = 0; phase = 'lose'; emit('settle', player.x, player.y, { outcome: 'lose' }); }
}
const list = collectVictims(s.x, s.y, def.strikes.r, -1);
if (applyToVictims(list, def.strikes.dmgEnemy, null, false)) rebuildGrid();
emit('meteorHit', s.x, s.y, {});
activeEvent.strikes.splice(i, 1);
if (phase !== 'play') return; // 玩家被流星砸死:立即封帧
}
} else if (def.id === 'altar') {
const a = activeEvent.altar;
if (!a.done) {
const dx = player.x - a.x, dy = player.y - a.y;
if (dx * dx + dy * dy < def.altar.r * def.altar.r) a.progress += dt; // 离开暂停不清零
if (a.progress >= def.altar.channelS) {
a.done = true;
player.hp = Math.min(player.maxHp, player.hp + def.altar.reward.heal);
dropGemPile(player.x, player.y, def.altar.reward.gems.n, def.altar.reward.gems.value, 60);
emit('altarDone', a.x, a.y, {});
activeEvent = null; // 充能完成即收尾
return;
}
}
} else if (def.id === 'chest') {
// 宝箱怪被杀时 chestEid 置 -1(killEnemy);到时未杀 → 逃脱消散
if (activeEvent.chestEid === -1) { activeEvent = null; return; }
}
if (t >= activeEvent.endT) {
if (def.id === 'chest' && activeEvent.chestEid > 0) {
// 逃脱:找到残留宝箱怪静默消散(无奖励;swap-remove 回池)
for (let i = 0; i < enemies.length; i++) {
if (enemies[i].id === activeEvent.chestEid) { enemyPool.release(swapRemove(enemies, i)); break; }
}
}
activeEvent = null;
}
}
/** 灵晶转飞行态(磁吸/聚灵珠/撼山符阵共用)。 */
@ -651,6 +1009,17 @@ export function createSim(opts) {
* 取证状态快照(JSON 安全;九门/e2e/仿真器共用)
* 约定:phase 终态 = win|lose(九门 latch 同义集);score = 击杀数(随进展单调上升)
*/
/** 活体 Boss 血条(最后入场且存活的 Boss;_debugFill 注入的预览优先=渲染面单测用)。 */
function liveBossBar() {
if (bossBar) return bossBar;
if (aliveBosses <= 0) return null; // 零 Boss 快路(P2 driver 每帧调 view,免全表扫)
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (e.isBoss) return { name: e.bossDef.name, hp: Math.max(0, e.hp), maxHp: e.maxHp };
}
return null;
}
function readState() {
return {
phase,
@ -665,7 +1034,9 @@ export function createSim(opts) {
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 } },
stats: { ...stats, dmgByWeapon: { ...stats.dmgByWeapon }, eventsByType: { ...stats.eventsByType } },
bossBar: liveBossBar(),
event: activeEvent ? { id: activeEvent.def.id, name: activeEvent.def.name, endT: Math.round(activeEvent.endT * 10) / 10 } : null,
seed,
};
}
@ -675,7 +1046,8 @@ export function createSim(opts) {
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,
weapons, passives: plv, pd, bossBar: liveBossBar(), difficulty: diffKey,
event: activeEvent, tideActive: t < tideUntil,
magnetR: XP.gemMagnetR * pd.magnetMul,
};
}

View File

@ -142,23 +142,25 @@ test('手 4 暴击档全链(M2 事件源):sim crit 事件 → 反馈泵 → hf
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) {
// 缓圆走位;三选一优先凑暴击源(破煞诀被动,其次天雷引=innate 暴击)——任一到手后暴击必然发生
// (M3 XP 曲线变陡后单靠破煞诀在窗口内不保证抽到,双源覆盖;固定种子轨迹确定)
let hasCritSrc = false, minTs = 1, guard = 0;
while (guard++ < 15000) {
const st = F.state();
if (st.phase === 'levelup') {
const i = st.offer.findIndex((o) => o.id === 'crit');
let i = st.offer.findIndex((o) => o.id === 'crit');
if (i < 0) i = st.offer.findIndex((o) => o.id === 'tianlei');
game._handleAction({ type: 'choose', i: i >= 0 ? i : 0 });
if (i >= 0) critLv++;
if (i >= 0) hasCritSrc = true;
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;
if (hasCritSrc && F.measures().events.some((e) => e.eventName === 'crit')) break;
}
assert.ok(critLv > 0, `应凑到破煞诀(guard=${guard})`);
assert.ok(hasCritSrc, `应凑到暴击源(破煞诀或天雷引;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, '取证字段齐(帧戳+聚合键)');
@ -180,10 +182,14 @@ test('手 9 组件语义:弹出即可点(动画期选卡即生效)、弹层动
assert.equal(F.state().phase, 'levelup', '应到升级弹层');
const targets = F.state().targets;
assert.ok(targets.length >= 1 && targets[0].kind === 'card', 'targets = picker.layout 卡心');
// 弹出后未推满动画立即点卡(手 9:输入无黑窗)
// 弹出后未推满动画立即点卡(手 9:输入无黑窗)。选中即生效判据 = chooses 计数 +1;
// 相位允许 play 或连锁 levelup(一次拾取跨多级时 choose 后立即复查再弹,M0 起即正确行为;
// M3 经济下早期灵晶堆积使连锁更常见)。
const lv = F.state().level;
const chooses0 = F.measures().chooses;
game.handlePointer('down', targets[0].x, targets[0].y);
step(2);
assert.equal(F.state().phase, 'play', '动画期单击直选生效');
assert.equal(F.measures().chooses, chooses0 + 1, '动画期单击直选生效(选中登记)');
assert.ok(['play', 'levelup'].includes(F.state().phase), `选后回 play 或连锁再弹(${F.state().phase})`);
assert.ok(F.state().level >= lv, '等级不回退');
});

View File

@ -110,10 +110,12 @@ test('触发器②-2 三选一弹层:三卡布局齐、中心命中映射 0/1/2
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
// 单击直选:点第 2 张卡中心 → 选中登记(相位允许 play 或连锁 levelup=连升,正确行为)
const chooses0 = F.measures().chooses;
game.handlePointer('down', targets[1].x, targets[1].y);
step(2);
assert.equal(F.state().phase, 'play', '单击直选生效(弹出即可点,手 9)');
assert.equal(F.measures().chooses, chooses0 + 1, '单击直选生效(弹出即可点,手 9)');
assert.ok(['play', 'levelup'].includes(F.state().phase), `选后相位合法(${F.state().phase})`);
});
test('触发器②-3 出击页:可滚动(拖拽 scrollY 变化)可命中(点难度行切换 + 开巡进局)', async () => {