diff --git a/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md b/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md index 10e9e7a4..0dff861f 100644 --- a/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md +++ b/docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md @@ -182,7 +182,7 @@ clean-room 红线沿靶档双代理隔离方法,全程生效:对标游戏只允 ### 8.3 快进仿真器方案(消费 NSTAR §2.C 路 A,不另起炉灶) -北极星件 §2.C 已裁「先工具后席、路 A 工程离屏快进」,本款照用并从 M0 起守它的前提纪律:core 层纯逻辑、`update(dt)` 注入时间、随机经受控源、空间与移动逻辑不耦渲染(O-4 拍定的硬约束对本款同样生效——走位/碰撞/索敌全在逻辑层,渲染只画)。node 驱动 headless 单局:walker 策略档 P0(静止,验「不动必死」)/P1(绕圈风筝)/P2(贪心磁吸+避险,纪律最优);沿 NSTAR driver 档位裁定,L3 期先 P2 单档跑通、下沿体验标注未覆盖,包络后补。批量量级:单局 20 分钟 =72000 帧,2500 实体简化逻辑帧;千局 × 参数网格(每次调参 ~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 美术方向与资产、包体分包 diff --git a/game-runtime/games/shanhai-xingji/README.md b/game-runtime/games/shanhai-xingji/README.md index c0ba498b..2f81ff9e 100644 --- a/game-runtime/games/shanhai-xingji/README.md +++ b/game-runtime/games/shanhai-xingji/README.md @@ -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 窗口与创始人。 diff --git a/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs b/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs index cb485857..dad46f57 100644 --- a/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs +++ b/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs @@ -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); diff --git a/game-runtime/games/shanhai-xingji/scripts/record-run.mjs b/game-runtime/games/shanhai-xingji/scripts/record-run.mjs new file mode 100644 index 00000000..db43b114 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/record-run.mjs @@ -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); diff --git a/game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs b/game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs index 38749727..8b800900 100644 --- a/game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs +++ b/game-runtime/games/shanhai-xingji/scripts/sim-scan.mjs @@ -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 }); diff --git a/game-runtime/games/shanhai-xingji/src/balance.js b/game-runtime/games/shanhai-xingji/src/balance.js index cb7794da..f3065183 100644 --- a/game-runtime/games/shanhai-xingji/src/balance.js +++ b/game-runtime/games/shanhai-xingji/src/balance.js @@ -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' }, diff --git a/game-runtime/games/shanhai-xingji/src/game-logic.js b/game-runtime/games/shanhai-xingji/src/game-logic.js index d8e5a322..e9a252ad 100644 --- a/game-runtime/games/shanhai-xingji/src/game-logic.js +++ b/game-runtime/games/shanhai-xingji/src/game-logic.js @@ -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; diff --git a/game-runtime/games/shanhai-xingji/src/render.js b/game-runtime/games/shanhai-xingji/src/render.js index 88dc12fb..03a7ed2a 100644 --- a/game-runtime/games/shanhai-xingji/src/render.js +++ b/game-runtime/games/shanhai-xingji/src/render.js @@ -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'; } diff --git a/game-runtime/games/shanhai-xingji/src/sim.js b/game-runtime/games/shanhai-xingji/src/sim.js index 02f9ad45..5773f80b 100644 --- a/game-runtime/games/shanhai-xingji/src/sim.js +++ b/game-runtime/games/shanhai-xingji/src/sim.js @@ -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, }; } diff --git a/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs b/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs index 689abfb7..429ecf63 100644 --- a/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs +++ b/game-runtime/games/shanhai-xingji/test/feel-assertions.test.mjs @@ -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, '等级不回退'); }); diff --git a/game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs b/game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs index 12852ad8..f51f37a7 100644 --- a/game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs +++ b/game-runtime/games/shanhai-xingji/test/ui-trigger2.test.mjs @@ -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 () => {