# 伤害计算 属性算准之后,战斗还差最后一步换算:这些 `hp`、`atk`、`def` 到底意味着勇士会掉多少血、要打几回合?负责这件事的就是伤害计算器。 它实现的是 `f(enemy, hero)`:输入计算后的怪物与勇士属性,输出 `{ damage, turn }`。本篇导读内置公式,并说明要改公式时应该动哪里。整体流程见[战斗总览](./combat-overview.md)。 ## 伤害计算器接口 为了把计算后的怪物与勇士属性换算成「勇士会掉多少血、要打几回合」,我们需要实现 `IDamageCalculator` 接口。日常只需要关心它的 `calculate(handler)`:返回 `IEnemyDamageInfoBase`,即 `{ damage, turn }`。 接口里还有一个 `getCriticalLimit(handler, attribute)`,只有临界计算会用到。大多数需求用不到它,先知道有这么个方法即可,细节留到高级教程。 `handler` 是 `IReadonlyDamageHandler`,包含: - `enemy`:计算后的怪物对象; - `hero`:勇士属性对象; - `context`:怪物上下文; - `locator`:怪物位置; - `state`:数据端状态对象。 注册方式: ```ts state.enemyContext.getDamageSystem()?.useCalculator(new MainDamageCalculator()); ``` `initializeEnemy` 在创建 `DamageSystem` 后就调用了 `useCalculator(new MainDamageCalculator())`,因此默认使用内置公式。 :::warning 计算器必须无副作用 所谓「副作用」,就是除了算出结果之外,还顺手改动了别的东西:写了一个 flag、删了一条记录、改了怪物或勇士的属性,这些都属于副作用。 计算器在一次战斗里会被**反复调用**:算临界值要二分十几次,渲染伤害数值、图鉴与提示等也可能在真正开打前各调用一次。因此它**必须只读输入、算出结果,不能改动任何外部状态**——不能写 flag、不能删 flag,也不能改怪物或勇士对象。只要存在副作用,同一只怪物在不同时刻就会算出不同结果,临界与显示都会错乱。需要「一次性」生效的状态,请放到别处去改(战斗脚本、钩子等),计算器只负责读取。 ::: ## 改内置:在 `MainDamageCalculator.calculate` 里加分支 内置公式全部写在 `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate` 一个方法里,而且它已经在 `initializeEnemy` 里注册好了。所以**改内置就是在这个方法里改公式、加分支**——不新建类,也不用再调用 `useCalculator`。这是本篇的主线做法,多数「调参类」属性(先攻、连击、破甲等)都实现在这里。 改之前先弄清楚公式的段落顺序,否则很容易加错位置。 ## 内置公式逐段导读 与其一次背下整条公式,不如按顺序看每一段在算什么。`MainDamageCalculator.calculate`(`src/data/enemy/calculator.ts`)按以下顺序计算: 1. **读取属性**:从 `hero` 读 `hp`、`atk`、`def`、`mdef`,从 `enemy` 读 `atk`、`def`、`hp`。 2. **无敌分支**:满足条件时直接返回 `{ damage: Infinity, turn: 0 }`,表示无法击败。 3. **勇士每回合伤害**:`atk - monDef`。如果小于等于 0,同样返回 `Infinity`。 4. **吸血**:按勇士生命值比例增加伤害,必要时把伤害加到怪物生命上。 5. **怪物每回合伤害**:`monAtk - def`;魔攻时忽略勇士防御;连击则乘以攻击次数。 6. **回合数**:`turn = ceil(monHp / heroPerDamage)`。 7. **支援**:怪物带有支援者时,递归计算每个支援者的伤害与回合数,并把它们累加到当前怪物上。 8. **先攻、破甲、反击、净化**:按各自规则增加伤害或每回合伤害。 9. **总伤害**:`damage += (turn - 1) * enemyPerDamage`,再减去魔防;未开启负伤时,负伤害归零。 10. **固伤、仇恨、自爆**:固伤与仇恨不受魔防影响;自爆把伤害抬到「勇士生命 - 1」。 11. **返回**:`damage` 向下取整,连同 `turn` 一起返回。 「加自己的属性加在哪一步」取决于你想改什么: - 只是让怪物某项属性更高:不要改计算器,放进光环、查询或最终效果,让 `enemy.getAttribute(...)` 读到新值即可; - 要让属性按回合数累积、按条件改变倍率、或增加新的伤害分支:在 `calculate` 的对应段落里改公式; - 只是给勇士一侧加临时数值:用[勇士属性](./hero-attribute.md#修饰器-数值与百分比加成)里的修饰器,计算器会自动读到最终属性。 ## 示例:等差数列属性 有时公式本身要随回合变化,而光环、查询、最终效果都做不到这一点——它们改的是一次计算里的固定属性。这时就该动计算器。 「等差数列属性」指怪物攻击每回合按固定值递增。属性本身在一次计算中是常量,所以不能靠给 `atk` 加值实现;要在 `calculate` 里把「每回合伤害」按等差数列求和: 示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md#包从哪来-src、packages-user-与-packages) 的「包从哪来」。下面用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求改的行。 **目标**:让怪物的每回合伤害按固定公差逐回合递增(本示例公差为 5)。 **改哪里**:`src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate`(内置文件,改完不需要注册)。 **复制什么**:在总伤害累加之后、魔防扣减之前,把等差数列多出来的部分一次加上。下面只摘出相关几段,`...省略...` 表示原有代码保持不动—— ```ts calculate( handler: IReadonlyEnemyHandler ): IEnemyDamageInfoBase { const { enemy, locator, hero } = handler; // ...省略:读取属性、无敌分支、勇士每回合伤害、吸血... // ...省略:内置的「怪物每回合伤害」与连击、取零... // 此时 enemyPerDamage 是怪物每回合的基础伤害 // ...省略:内置的回合数计算与支援递归... // 此时 turn 已经是最终的回合数(支援怪的回合也加进来了) // ...省略:先攻、破甲、反击、净化... // 内置原本就有的总伤害累加 damage += (turn - 1) * enemyPerDamage; // [需改] 等差数列:第 1 次出手是 enemyPerDamage,之后每次再加 step // [模板] 怪物共出手 turn - 1 次,额外伤害合计 step * (turn - 1) * (turn - 2) / 2 const step = 5; // [需改] 公差:每回合递增的伤害 const count = Math.max(turn - 1, 0); damage += (step * count * (count - 1)) / 2; // ...省略:魔防、负伤归零、固伤、仇恨、自爆、返回... } ``` > 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md#照抄教程里的代码时-怎么处理-import) 的「照抄教程里的代码时,怎么处理 import」)。 **改什么**:`step`(公差,本示例为 `5`)换成你要的递增规则。注意 `turn` 在内置方法里已经算过,插入时不要重复声明同名变量——把新增两行放在 `damage += (turn - 1) * enemyPerDamage;` 之后即可。 **怎么验证**:给怪物挂上后开战,看显示的回合伤害是否随回合数按公差递增、总伤害是否等于求和结果。 **注意**:这段只改「怪物每回合伤害」的数值,内置的无敌、吸血、支援、先攻等分支全部原样保留——这正是改内置比自写计算器省事的地方。计算器**必须无副作用**(见上方 warning)。 ## 自己写计算器类 只有「整条公式都要换掉」时才需要自己写计算器。如果只是加一条分支、改一段系数,改内置方法体就够了。 自己写类要同时处理接口签名、`getCriticalLimit` 与注册,而且替换会丢掉全部内置分支,改动面比在内置 `calculate` 里加一段大得多。 **如果你不怎么懂代码,建议直接改内置的 `MainDamageCalculator.calculate`(在 `src/data/enemy/calculator.ts` 里加分支),而不是自己写新的计算器。** 上面「改内置」一节已经覆盖了绝大多数需求;自写计算器意味着你要自己重新实现无敌、吸血、支援、连击、先攻、破甲、反击、净化、固伤、仇恨、自爆这些分支。 做法是实现 `IDamageCalculator` 接口,再用 `useCalculator` 替换。先看接口模板,再看一个具体示例。 ### 模板:复制什么、改什么 下面是计算器机制本身的骨架,不含任何具体公式。复制到 `src/data/enemy/calculator.ts` 末尾后按 `[需改]` 修改(不要另建文件,计算器的实现集中在 `calculator.ts`): ```ts // [需改] 类名:换成你自己的计算器名 export class XxxCalculator implements IDamageCalculator { // [模板] calculate 签名固定:读 handler 里的 enemy 与 hero,返回 { damage, turn } calculate( handler: IReadonlyEnemyHandler ): IEnemyDamageInfoBase { // [模板] 取出只读的怪物与勇士对象(只读,供读取,不可改写) const { enemy, hero } = handler; // [需改] 在这里写你的公式;只读输入,不改任何外部状态 return { damage: 0, turn: 0 }; } // [模板] getCriticalLimit 签名固定:返回临界计算的上界 getCriticalLimit( handler: IReadonlyEnemyHandler, attribute: CriticalableHeroStatus ): number { // [模板] 一般不用改:返回勇士当前最终值即可;高级教程会细讲临界 return handler.hero.getFinalAttribute(attribute); } } ``` 注册(**替换**当前计算器): ```ts state.enemyContext.getDamageSystem()?.useCalculator(new XxxCalculator()); ``` 复制后需要改的只有:类名、`calculate` 里的公式、必要时 `getCriticalLimit` 的返回值。**计算器必须无副作用**(见上方 warning)——模板里的 `calculate` 只读输入,不要写 flag、不要改 `enemy` / `hero`。 ### 示例:等差数列 下面用「等差数列」演示自写类的最小形态(只保留本示例所需分支)。它只是填空示范,不是模板本身: ```ts // [需改] 类名:换成你自己的计算器名 export class ArithmeticCalculator implements IDamageCalculator< IEnemyAttr, IHeroAttr > { // [模板] calculate 签名固定:读 handler 里的 enemy 与 hero,返回 { damage, turn } calculate( handler: IReadonlyEnemyHandler ): IEnemyDamageInfoBase { // [模板] 取出只读的怪物与勇士对象(只读,供读取,不可改写) const { enemy, hero } = handler; // [模板] 勇士每回合对怪物造成的伤害 = 勇士攻击 - 怪物防御 const heroPerDamage = hero.getFinalAttribute('atk') - enemy.getAttribute('def'); // [模板] 打不动(≤ 0)时返回 Infinity,表示无法击败 if (heroPerDamage <= 0) { return { damage: Infinity, turn: 0 }; } // [模板] 回合数 = 怪物血量 / 每回合伤害,向上取整 const turn = Math.ceil(enemy.getAttribute('hp') / heroPerDamage); // [需改] 怪物每回合的基础伤害:这里按「怪物攻击 - 勇士防御」并最低取 0 const base = Math.max( 0, enemy.getAttribute('atk') - hero.getFinalAttribute('def') ); // [需改] 公差:每回合递增的伤害(本示例固定为 5) const step = 5; // [需改] 等差数列求和:勇士先手,怪物共出手 turn - 1 次 // 首项 base、公差 step、共 turn - 1 项 const count = Math.max(turn - 1, 0); const damage = count * base + (step * count * (count - 1)) / 2; // [模板] 返回结果,damage 向下取整 return { damage: Math.floor(damage), turn }; } // [模板] getCriticalLimit 一般不用改:这里直接返回勇士当前最终值 getCriticalLimit( handler: IReadonlyEnemyHandler, attribute: CriticalableHeroStatus ): number { return handler.hero.getFinalAttribute(attribute); } } // [需改] 注册这行要挪进 initializeEnemy(见下方「注册在哪里」) state.enemyContext.getDamageSystem()?.useCalculator(new ArithmeticCalculator()); ``` > 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md#照抄教程里的代码时-怎么处理-import) 的「照抄教程里的代码时,怎么处理 import」)。 **改什么**:`step`(公差,本示例为 `5`)与 `base` 的算法换成你要的递增规则。 **怎么验证**:同「改内置」一节——开战看回合伤害是否按公差递增、总伤害是否等于求和结果。 **注意**:这是只保留本示例分支的最小计算器;若用 `useCalculator` 替换,内置的无敌、吸血、支援等分支需要你按上文顺序自行保留。计算器**必须无副作用**(见上方 warning)。 ### 注册在哪里 `useCalculator` 要写进 `initializeEnemy`。下面是 `src/data/enemy/initialize.ts` 中该函数的样子(省略了无关的行): ```ts export function initializeEnemy( state: ICoreState ): IEnemyInitializationReturns { // ...省略:怪物管理器、特殊属性与上下文的初始化... const ctx = state.enemyContext; const damageSystem = new DamageSystem(ctx); // 注册内置伤害计算器 damageSystem.useCalculator(new MainDamageCalculator()); // [需改] 你自己的计算器在这里替换(注意是替换,不是追加): // damageSystem.useCalculator(new ArithmeticCalculator()); // ...省略:地图伤害、光环、最终效果、脚本与钩子... } ``` 把 `useCalculator(new ArithmeticCalculator())` 放在内置那一行之后,你的公式就会随 `initializeEnemy` 一起生效。 ## 临界(了解即可) 临界计算回答「勇士某项属性提高到多少时,战斗结果会发生变化」,它会用到 `getCriticalLimit`。多数需求不必改这个方法:内置实现已经处理了 `atk`(怪物有坚固时返回 `Infinity`,否则返回 `def + hp`)与其他属性的默认上界。 现有约定是**伤害值随属性提高单调递减**。公式不单调也能运行,只是算出的临界点不准。更完整的临界算法与二分过程会在**高级教程**中讲解(预留标记:`高级教程 · 临界计算`),待高级教程就位后替换为链接。