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:
parent
a10945d74f
commit
05fcc7f94a
@ -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.0s、d1 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 美术方向与资产、包体分包
|
||||
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
# 山海行纪(北极星顶级线② · 肉鸽割草)
|
||||
|
||||
持符巡山使夜巡镇兽:拖动 = 浮动摇杆走位,符器自动索敌出手,击杀掉灵晶、磁吸升级三选一,兽潮按波表爬坡。规格 SoT = [`开发设计书`](../../../docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md);当前进度 = **M2 build 体系**(武器 12×8 级五族 / 被动 14×5 级 / 进化 12 / 难度 D0–D1 / 暂停 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 / 难度 D0–D1 / 暂停 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 胜率 43–63% 全 ≥40% ②最强 build 85% ≤85% ③随机 build 中位胜率 55% ∈35–55% ④武器出现↔获胜 |r|max 0.250 ≤0.6(初始武器雷符链恒在、无变异,④对其不适用,可行性由定向臂覆盖)。**仿真墙钟 spike 实测**:单局 mean 1.88s / p95 3.29s(300s 局=18000 帧,≈135µs/帧),6 worker 吞吐 180 局/分,门档 6.0 分钟,已回填书 §8.3(唯一书内改动)。游戏测试 27/27(M0/M1 十三测零倒退 + build 表门九测 + 手 4 暴击全链一测 + 触发器② UI 四测)· 三场景冒烟全过(S1 38.6s 死/S2 满血 60s/S3 win 981 杀 30 次升级)· 逻辑压测 p95 1.433ms(多武器+区域实体增量,6ms 门内)· 整库插件 269/269(基线 260 全含;canvas-ui-kit v1.1 additive 滚动列表 +3 测)· manifest 19/19 · 包体首屏 287,663B(gz 99,767B,0.6MB 门内;`dist/build-report.json`)。**M2 调参实录**(数据表内收敛,12 轮):波表增 hpMul/speedMul 爬坡列(§8.1 g^w 表化+反风筝速度压力)、三选一改持有件偏权池(ownedWeight 2,治「新件洪水稀释构筑」)、P2 档修成真「贪心磁吸+避险」混合矢量(纯二值版后期永不捡晶,build 进度饿死)。触发器② CDP 走查+截图留证与真机校准仍待 mini-desktop 窗口(headless 结构断言已先行钉死,见 `test/ui-trigger2.test.mjs`)。
|
||||
|
||||
## 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)、潮汐脉冲按 60–90s 节律 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% ∈35–55% 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 窗口与创始人。
|
||||
|
||||
@ -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);
|
||||
|
||||
152
game-runtime/games/shanhai-xingji/scripts/record-run.mjs
Normal file
152
game-runtime/games/shanhai-xingji/scripts/record-run.mjs
Normal file
@ -0,0 +1,152 @@
|
||||
/**
|
||||
* record-run.mjs — 《山海行纪》首图真玩全程录证(M3 门;headless 面)
|
||||
* owner:北极星顶级线② | 口径:M3 门「首图真玩全程录证」的 headless 层 =
|
||||
* P2 档 driver 全程真跑 + 状态轨迹(5s 采样)+ 关键帧(首杀/升级/事件/潮汐/Boss/终态)
|
||||
* + §3.1 验收用例机断言;CDP 真浏览器全程录证(输入级+截图)待 mini-desktop 窗口,不在本脚本冒称。
|
||||
*
|
||||
* 【录证runs】D0 与 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}] 潮汐脉冲按 60–90s 节律真发生`, `次数=${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);
|
||||
@ -6,7 +6,8 @@
|
||||
* 聚合存活率/击杀/DPS 曲线与四判据;纯 node,吃 balance 表,零渲染零 DOM(§8.3 路 A)。
|
||||
*
|
||||
* 【两臂设计】
|
||||
* A 臂(定向):每件武器一条定向 build(初始雷符链 + 该武器 + 其进化被动 + 族向支援被动),
|
||||
* A 臂(定向):每件武器一条定向 build(M3 口径:主打件 + 初始雷符链 + 强载体携伴 ×2,
|
||||
* 被动 = 进化被动 + 攻/速/甲/回 + 族向支援;20 分钟 meta 下纯输出组活不到 Boss,mid 档实测定型),
|
||||
* P2 走位 + 定向选卡 → 判据①「每件武器至少存在一条含它的可行 build(D1、P2、胜率 ≥40%)」;
|
||||
* B 臂(随机):均匀随机抽 K 条 build(3 武器 + 4 被动,受控 rng 可复现),同策略跑 →
|
||||
* 判据③「随机 build 中位胜率 35–55%」+ 判据④「武器被选中→获胜相关性 |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 ≈7–11s,
|
||||
* # M2 300s 口径曾为 1080 局;判据判定,非绿退出码 1)
|
||||
* node scripts/sim-scan.mjs --mid # 调参可信档:A 12×12 + B 16×10 = 304 局(≈8–11 分钟)
|
||||
* 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 ≈9–12s,门档 A 12×25 + B 24×12 = 588 局 ≈ 18–22 分钟@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 });
|
||||
|
||||
@ -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,
|
||||
* 机制跃迁非纯数值)/ 难度表 D0–D5(本期启用 D0/D1)/ 暴击(手 4 事件源)/
|
||||
* 全屏收割道具「聚灵珠」(手 6/手 8 事件源)。派生语义单一实现 = src/build-model.js。
|
||||
* 【M3 增量】20 分钟正式波表(WAVES 重排,含 18:00 放量收割期)/ 潮汐脉冲(TIDE)/
|
||||
* 精英词缀(ELITE+AFFIXES 6)/ 图 1 双 Boss(BOSSES,战技组入表)/ 局内事件 4 种(EVENTS)/
|
||||
* XP 经济二次曲线(单局升级 22–28 次锚)/ 难度表增词缀密度轴(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 = [
|
||||
|
||||
/**
|
||||
* 难度表 D0–D5(§5.2「数值曲线抬升+词缀密度+波表压缩」;本期启用 D0/D1,
|
||||
* D2–D5 为草案骨架〔词缀密度/波表压缩两轴随 M5 落地,locked 表示出击页暂锁〕)。
|
||||
* D2–D5 为草案骨架〔波表压缩轴随 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,单局升级 22–28 次」〔扫描回填校准〕。
|
||||
*/
|
||||
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.35–0.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 的表化,本表逐行 g≈1.10–1.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,60–90s 一次):
|
||||
* 自 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:受击 4–6px 取 5
|
||||
pickup: { sfx: 'pickup' },
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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×(L−1) + quad×(L−1)²,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:60–90s 一次,×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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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, '等级不回退');
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user