template/docs/intermediate/enemy-calculator.md

252 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 伤害计算
属性算准之后,战斗还差最后一步换算:这些 `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<IEnemyAttr, IHeroAttr>
): 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<IEnemyAttr, IHeroAttr> {
// [模板] calculate 签名固定:读 handler 里的 enemy 与 hero,返回 { damage, turn }
calculate(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>
): IEnemyDamageInfoBase {
// [模板] 取出只读的怪物与勇士对象(只读,供读取,不可改写)
const { enemy, hero } = handler;
// [需改] 在这里写你的公式;只读输入,不改任何外部状态
return { damage: 0, turn: 0 };
}
// [模板] getCriticalLimit 签名固定:返回临界计算的上界
getCriticalLimit(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
attribute: CriticalableHeroStatus<IHeroAttr>
): 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<IEnemyAttr, IHeroAttr>
): 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<IEnemyAttr, IHeroAttr>,
attribute: CriticalableHeroStatus<IHeroAttr>
): 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`)与其他属性的默认上界。
现有约定是**伤害值随属性提高单调递减**。公式不单调也能运行,只是算出的临界点不准。更完整的临界算法与二分过程会在**高级教程**中讲解(预留标记:`高级教程 · 临界计算`),待高级教程就位后替换为链接。
<!-- TODO(advanced): 临界计算 -->