[P1][CR-B5] 五套技能/战斗管线并存、四个耗蓝函数:收敛到数据驱动的 registry #525

Open
opened 2026-09-29 06:43:57 +00:00 by troytt · 0 comments
Owner

来源:#516|优先级 P1|评审编号 B5|基线 e475c2e

问题描述

放一个技能、结算一次伤害、算一次耗蓝,这些事在代码里各有好几套实现,彼此的数值和行为并不一致。更麻烦的是,玩家实际走的路径和测试最多的路径不是同一条。

证据

1. 五条技能/战斗路径

# 入口 使用者 位置
1 skills.ts 的 castSkill / tickProjectiles GameEngine 的键盘施法(使用 DEMO_SKILLS) skills.ts:4669、skills.ts:4795,由 engine.ts:5 引入
2 scene/backend/skill-caster.ts 的 castSkill 所有鼠标施法,也就是玩家实际走的路径 skill-caster.ts:107
3 skills/registry.ts 的 executeSkill113c,以及 MissileEngine / CombatPipeline / StateBus / AuraScanner / SummonManager 只在 WorldArena 中实例化,只能通过 ?arena=1 进入 registry.ts:309、world-arena.ts:161-163、arena-overlay.ts:6
4 combat.ts 的近战与 tickCombatMulti 联机 combat.ts:869-893
5 skills.ts 的 executeSkill 只有测试在用 skills.ts:6877

2. 四个耗蓝函数

函数 算法 使用者 位置
getSkillMana 取值顺序:HUD 条目 → batch1 技能固定按 1 级算 → 写死的 switch → default: return 10 鼠标施法(skill-caster) weapon-combat.ts:330-373
getSkillManaCost 每个技能一个手写的 calculateXxxStats(level).manaCost getSkillMana 的 batch1 分支;skills.ts 的 executeSkill skills.ts:5703
calculateManaCost 浮点运算:(mana + lvlmana × (lvl − 1)) × 2^(manashift − 8),保留 1 位小数;manashift 缺失时按 8 处理 HUD 快捷键、技能树的显示 skill-calc-engine.ts:405-412
computeSkillManaCost256 定点运算:max(minmana << 8, (mana + lvlmana × (slvl − 1)) << manashift),对应 D2Common 的 SKILLS_GetManaCost registry / arena calc-ast.ts:135-149

鼠标施法用的 getSkillMana 会优先读 HUD 条目里的 manaCost。没有 HUD 条目时:

  • batch1 技能永远按 1 级扣蓝;
  • 其他技能用 switch 里的固定值;
  • 未知技能一律扣 10。

所以同一个技能在同一等级下,HUD 显示的耗蓝、实际扣的蓝和 arena 里的结果可能各不相同。

3. 桩代码、死代码与重复数据

  • 转发桩:skills/impl/ 下 221 个模块中,有 218 个是约 44 行的转发桩,只是把调用转给 evaluateSkillCore113c / executeSkillCore113c。示例:skill-007-fire-arrow.ts:21-41。

  • 只被测试引用的模块:以下模块在 src 中没有任何 import,只被测试引用:

    • formulas.ts(1.9k 行)
    • game/skill-tree.ts(4.9k 行)
    • skills/assassin-traps.ts、skills/druid-summon.ts、skills/druid-elemental.ts、skills/paladin-*.ts 等职业 helper
  • 重复数据:skills.ts 内嵌了约 3.2k 行手抄数据:

    它们和 data-registry 从 MPQ 读到的 Missiles.txt / Overlay.txt 重复。

4. 后果

  • 测试覆盖错位:玩家实际走的路径 2 测试最少(skill-caster 没有直接的测试,见 #535);测试最多的路径 3 只在 arena 里运行。
  • 修 bug 要改多处:修一个技能的问题要同时改几处,漏改一处就会出现"arena 里对、游戏里错"。
  • 超大函数的来源:大部分超大函数和 skillId === N 式的分发(见 #527)出自路径 1 和路径 2。

根因

数据驱动的新内核(registry / MissileEngine)写好之后,没有把 GameEngine 和联机切换过去,旧路径也没有删掉。之后每次修 bug,都是在玩家正在用的那条旧路径上打补丁。

修复指南

  1. 确定唯一内核:以数据驱动的 registry + MissileEngine + CombatPipeline 作为唯一的技能/战斗模拟内核。它已经基于 Skills.txt / Missiles.txt 和 calc AST 实现,是几条路径里最接近 1.13c 的。
  2. 耗蓝只保留 computeSkillManaCost256:
    • 引擎里的法力以 1/256 为单位存储,与 1.13c 一致,显示时再换算成整数;
    • 删除 getSkillMana、getSkillManaCost、calculateManaCost。如果一次删不完,先让它们转发到 computeSkillManaCost256,再逐步删除。
  3. 迁移顺序(每一步都可以单独合入):
    1. 给 GameEngine 加一个适配层,让 registry 能在 GameEngine 的世界里执行:把目标选择、碰撞、伤害结算接到 engine 的数据结构上。
    2. 鼠标施法(路径 2)改为调用 registry。skill-caster.ts 只保留输入解析,技能逻辑逐个删除。
    3. 键盘施法(路径 1)同样切到 registry,然后删除 skills.ts 的 castSkill / tickProjectiles。
    4. 联机的 tickCombatMulti(路径 4)切到同一内核。如果决定由 M20 取代 P2P 锁步,这一步可以跳过。
    5. 删除 executeSkill(路径 5)和只被测试引用的模块。对应的测试要么迁移到 registry,要么一起删除。
  4. 删除转发桩:registry 直接按 Skills.txt 的 srvstfunc / srvdofunc 分发(见 #527),这样就不再需要 218 个转发桩。只有确实需要特殊逻辑的技能才写独立模块。
  5. 每一步都有对照测试:
    • 迁移某个技能之前,先为它写 fixture:固定种子和输入,记录伤害、耗蓝和投射物;
    • 迁移后结果必须与 fixture 一致,或者与 1.13c 数据表对得上;
    • 如果不一致,以数据表为准,并在 PR 里说明原因。
  6. 删除手抄数据:skills.ts 里的内嵌表改为从 data-registry 读取。

验收标准

  • 耗蓝只有一个实现:同一技能、同一等级下,鼠标施法、HUD 显示和 arena 给出的耗蓝相同。
  • 鼠标施法和键盘施法都经过 registry;skill-caster.ts 中不再有按 skillId === N 编写的技能逻辑。
  • formulas.ts、executeSkill 以及上面列出的仅测试使用的模块,都已接入生产路径或删除。
  • skills/impl/ 中不再有纯转发桩。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #524:getSkillMana 的兜底、近战伤害。
  • #527:超大函数与分发表。
  • #535:测试覆盖集中在 arena 栈。
  • #523:registry 路径同样要满足确定性。
> 来源:#516|优先级 P1|评审编号 B5|基线 `e475c2e` ## 问题描述 放一个技能、结算一次伤害、算一次耗蓝,这些事在代码里各有好几套实现,彼此的数值和行为并不一致。更麻烦的是,玩家实际走的路径和测试最多的路径不是同一条。 ## 证据 ### 1. 五条技能/战斗路径 | # | 入口 | 使用者 | 位置 | |---|---|---|---| | 1 | `skills.ts` 的 `castSkill` / `tickProjectiles` | GameEngine 的键盘施法(使用 `DEMO_SKILLS`) | [skills.ts:4669](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills.ts#L4669)、[skills.ts:4795](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills.ts#L4795),由 [engine.ts:5](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L5) 引入 | | 2 | `scene/backend/skill-caster.ts` 的 `castSkill` | 所有鼠标施法,也就是玩家实际走的路径 | [skill-caster.ts:107](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L107) | | 3 | `skills/registry.ts` 的 `executeSkill113c`,以及 MissileEngine / CombatPipeline / StateBus / AuraScanner / SummonManager | 只在 `WorldArena` 中实例化,只能通过 `?arena=1` 进入 | [registry.ts:309](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills/registry.ts#L309)、[world-arena.ts:161-163](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine/world-arena.ts#L161-L163)、[arena-overlay.ts:6](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/arena-overlay.ts#L6) | | 4 | `combat.ts` 的近战与 `tickCombatMulti` | 联机 | [combat.ts:869-893](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/combat.ts#L869-L893) | | 5 | `skills.ts` 的 `executeSkill` | 只有测试在用 | [skills.ts:6877](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills.ts#L6877) | ### 2. 四个耗蓝函数 | 函数 | 算法 | 使用者 | 位置 | |---|---|---|---| | `getSkillMana` | 取值顺序:HUD 条目 → batch1 技能固定按 1 级算 → 写死的 switch → `default: return 10` | 鼠标施法(skill-caster) | [weapon-combat.ts:330-373](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/weapon-combat.ts#L330-L373) | | `getSkillManaCost` | 每个技能一个手写的 `calculateXxxStats(level).manaCost` | `getSkillMana` 的 batch1 分支;`skills.ts` 的 `executeSkill` | [skills.ts:5703](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills.ts#L5703) | | `calculateManaCost` | 浮点运算:`(mana + lvlmana × (lvl − 1)) × 2^(manashift − 8)`,保留 1 位小数;`manashift` 缺失时按 8 处理 | HUD 快捷键、技能树的显示 | [skill-calc-engine.ts:405-412](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skill-calc-engine.ts#L405-L412) | | `computeSkillManaCost256` | 定点运算:`max(minmana << 8, (mana + lvlmana × (slvl − 1)) << manashift)`,对应 D2Common 的 `SKILLS_GetManaCost` | registry / arena | [calc-ast.ts:135-149](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine/calc-ast.ts#L135-L149) | 鼠标施法用的 `getSkillMana` 会优先读 HUD 条目里的 `manaCost`。没有 HUD 条目时: - batch1 技能**永远按 1 级**扣蓝; - 其他技能用 switch 里的固定值; - 未知技能一律扣 10。 所以同一个技能在同一等级下,HUD 显示的耗蓝、实际扣的蓝和 arena 里的结果可能各不相同。 ### 3. 桩代码、死代码与重复数据 - **转发桩**:`skills/impl/` 下 221 个模块中,有 218 个是约 44 行的转发桩,只是把调用转给 `evaluateSkillCore113c` / `executeSkillCore113c`。示例:[skill-007-fire-arrow.ts:21-41](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills/impl/ama/skill-007-fire-arrow.ts#L21-L41)。 - **只被测试引用的模块**:以下模块在 src 中没有任何 import,只被测试引用: - `formulas.ts`(1.9k 行) - `game/skill-tree.ts`(4.9k 行) - `skills/assassin-traps.ts`、`skills/druid-summon.ts`、`skills/druid-elemental.ts`、`skills/paladin-*.ts` 等职业 helper - **重复数据**:`skills.ts` 内嵌了约 3.2k 行手抄数据: - `CANONICAL_113C_MISSILES`,约 1.3k 行([skills.ts:618](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills.ts#L618)) - `CANONICAL_113C_OVERLAYS`,约 1.9k 行([skills.ts:2172](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/skills.ts#L2172)) 它们和 data-registry 从 MPQ 读到的 Missiles.txt / Overlay.txt 重复。 ### 4. 后果 - **测试覆盖错位**:玩家实际走的路径 2 测试最少(skill-caster 没有直接的测试,见 #535);测试最多的路径 3 只在 arena 里运行。 - **修 bug 要改多处**:修一个技能的问题要同时改几处,漏改一处就会出现"arena 里对、游戏里错"。 - **超大函数的来源**:大部分超大函数和 `skillId === N` 式的分发(见 #527)出自路径 1 和路径 2。 ## 根因 数据驱动的新内核(registry / MissileEngine)写好之后,没有把 GameEngine 和联机切换过去,旧路径也没有删掉。之后每次修 bug,都是在玩家正在用的那条旧路径上打补丁。 ## 修复指南 1. **确定唯一内核**:以数据驱动的 registry + MissileEngine + CombatPipeline 作为唯一的技能/战斗模拟内核。它已经基于 Skills.txt / Missiles.txt 和 calc AST 实现,是几条路径里最接近 1.13c 的。 2. **耗蓝只保留 `computeSkillManaCost256`**: - 引擎里的法力以 1/256 为单位存储,与 1.13c 一致,显示时再换算成整数; - 删除 `getSkillMana`、`getSkillManaCost`、`calculateManaCost`。如果一次删不完,先让它们转发到 `computeSkillManaCost256`,再逐步删除。 3. **迁移顺序**(每一步都可以单独合入): 1. 给 GameEngine 加一个适配层,让 registry 能在 GameEngine 的世界里执行:把目标选择、碰撞、伤害结算接到 engine 的数据结构上。 2. 鼠标施法(路径 2)改为调用 registry。`skill-caster.ts` 只保留输入解析,技能逻辑逐个删除。 3. 键盘施法(路径 1)同样切到 registry,然后删除 `skills.ts` 的 `castSkill` / `tickProjectiles`。 4. 联机的 `tickCombatMulti`(路径 4)切到同一内核。如果决定由 M20 取代 P2P 锁步,这一步可以跳过。 5. 删除 `executeSkill`(路径 5)和只被测试引用的模块。对应的测试要么迁移到 registry,要么一起删除。 4. **删除转发桩**:registry 直接按 Skills.txt 的 `srvstfunc` / `srvdofunc` 分发(见 #527),这样就不再需要 218 个转发桩。只有确实需要特殊逻辑的技能才写独立模块。 5. **每一步都有对照测试**: - 迁移某个技能之前,先为它写 fixture:固定种子和输入,记录伤害、耗蓝和投射物; - 迁移后结果必须与 fixture 一致,或者与 1.13c 数据表对得上; - 如果不一致,以数据表为准,并在 PR 里说明原因。 6. **删除手抄数据**:`skills.ts` 里的内嵌表改为从 data-registry 读取。 ## 验收标准 - [ ] 耗蓝只有一个实现:同一技能、同一等级下,鼠标施法、HUD 显示和 arena 给出的耗蓝相同。 - [ ] 鼠标施法和键盘施法都经过 registry;`skill-caster.ts` 中不再有按 `skillId === N` 编写的技能逻辑。 - [ ] `formulas.ts`、`executeSkill` 以及上面列出的仅测试使用的模块,都已接入生产路径或删除。 - [ ] `skills/impl/` 中不再有纯转发桩。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #524:`getSkillMana` 的兜底、近战伤害。 - #527:超大函数与分发表。 - #535:测试覆盖集中在 arena 栈。 - #523:registry 路径同样要满足确定性。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#525
No description provided.