template/docs/intermediate/enemy-calculator.md

15 KiB
Raw Blame History

伤害计算

属性算准之后,战斗还差最后一步换算:这些 hp、atk、def 到底意味着勇士会掉多少血、要打几回合?负责这件事的就是伤害计算器。

它实现的是 f(enemy, hero):输入计算后的怪物与勇士属性,输出 { damage, turn }。本篇导读内置公式,并说明要改公式时应该动哪里。整体流程见战斗总览。

伤害计算器接口

为了把计算后的怪物与勇士属性换算成「勇士会掉多少血、要打几回合」,我们需要实现 IDamageCalculator 接口。日常只需要关心它的 calculate(handler):返回 IEnemyDamageInfoBase,即 { damage, turn }。

接口里还有一个 getCriticalLimit(handler, attribute),只有临界计算会用到。大多数需求用不到它,先知道有这么个方法即可,细节留到高级教程。

handler 是 IReadonlyDamageHandler,包含:

  • enemy:计算后的怪物对象;
  • hero:勇士属性对象;
  • context:怪物上下文;
  • locator:怪物位置;
  • state:数据端状态对象。

注册方式:

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 的对应段落里改公式;
  • 只是给勇士一侧加临时数值:用勇士属性里的修饰器,计算器会自动读到最终属性。

示例:等差数列属性

有时公式本身要随回合变化,而光环、查询、最终效果都做不到这一点——它们改的是一次计算里的固定属性。这时就该动计算器。

「等差数列属性」指怪物攻击每回合按固定值递增。属性本身在一次计算中是常量,所以不能靠给 atk 加值实现;要在 calculate 里把「每回合伤害」按等差数列求和:

示例里的 @motajs/*、@user/* 是包名,含义见 src 导览 的「包从哪来」。下面用 [模板] 标出可原样照抄的行,用 [需改] 标出必须按需求改的行。

目标:让怪物的每回合伤害按固定公差逐回合递增(本示例公差为 5)。

改哪里:src/data/enemy/calculator.ts 的 MainDamageCalculator.calculate(内置文件,改完不需要注册)。

复制什么:在总伤害累加之后、魔防扣减之前,把等差数列多出来的部分一次加上。下面只摘出相关几段,...省略... 表示原有代码保持不动——

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(详见 代码基础 的「照抄教程里的代码时,怎么处理 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):

// [需改] 类名:换成你自己的计算器名
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);
    }
}

注册(替换当前计算器):

state.enemyContext.getDamageSystem()?.useCalculator(new XxxCalculator());

复制后需要改的只有:类名、calculate 里的公式、必要时 getCriticalLimit 的返回值。计算器必须无副作用(见上方 warning)——模板里的 calculate 只读输入,不要写 flag、不要改 enemy / hero。

示例:等差数列

下面用「等差数列」演示自写类的最小形态(只保留本示例所需分支)。它只是填空示范,不是模板本身:

// [需改] 类名:换成你自己的计算器名
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(详见 代码基础 的「照抄教程里的代码时,怎么处理 import」)。

改什么:step(公差,本示例为 5)与 base 的算法换成你要的递增规则。

怎么验证:同「改内置」一节——开战看回合伤害是否按公差递增、总伤害是否等于求和结果。

注意:这是只保留本示例分支的最小计算器;若用 useCalculator 替换,内置的无敌、吸血、支援等分支需要你按上文顺序自行保留。计算器必须无副作用(见上方 warning)。

注册在哪里

useCalculator 要写进 initializeEnemy。下面是 src/data/enemy/initialize.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)与其他属性的默认上界。

现有约定是伤害值随属性提高单调递减。公式不单调也能运行,只是算出的临界点不准。更完整的临界算法与二分过程会在高级教程中讲解(预留标记:高级教程 · 临界计算),待高级教程就位后替换为链接。