diff --git a/docs/intermediate/enemy-calculator.md b/docs/intermediate/enemy-calculator.md new file mode 100644 index 0000000..1257c5d --- /dev/null +++ b/docs/intermediate/enemy-calculator.md @@ -0,0 +1,113 @@ +# 伤害计算 + +伤害计算器决定一次战斗的伤害与回合数。它实现的是 `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())`,因此默认使用内置公式。 + +## 内置公式逐段导读 + +`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` 里把「每回合伤害」按等差数列求和: + +```ts +import { IEnemyAttr, IHeroAttr } from '@user/data-common'; +import { + CriticalableHeroStatus, + IDamageCalculator, + IEnemyDamageInfoBase, + IReadonlyEnemyHandler +} from '@user/data-system'; + +export class ArithmeticCalculator implements IDamageCalculator< + IEnemyAttr, + IHeroAttr +> { + calculate( + handler: IReadonlyEnemyHandler + ): IEnemyDamageInfoBase { + const { enemy, hero } = handler; + const heroPerDamage = hero.getFinalAttribute('atk') - enemy.getAttribute('def'); + if (heroPerDamage <= 0) { + return { damage: Infinity, turn: 0 }; + } + + const turn = Math.ceil(enemy.getAttribute('hp') / heroPerDamage); + const base = Math.max( + 0, + enemy.getAttribute('atk') - hero.getFinalAttribute('def') + ); + const step = 5; + + // 首项 base,公差 step,共 turn 项 + const damage = turn * base + (step * turn * (turn - 1)) / 2; + return { damage: Math.floor(damage), turn }; + } + + getCriticalLimit( + handler: IReadonlyEnemyHandler, + attribute: CriticalableHeroStatus + ): number { + return handler.hero.getFinalAttribute(attribute); + } +} +``` + +这是只保留本示例所需分支的最小计算器;内置公式里的无敌、吸血、支援等分支需要你按上文顺序自行保留。 + +## 临界 + +临界计算回答「勇士某项属性提高到多少时,战斗结果会发生变化」。相关接口: + +- `getCriticalLimit(handler, attribute)`:返回该属性的计算上界; +- `calculateViewCritical(enemy, attribute, precision?)` 与 `calculateEnemyCritical(...)`:伤害系统上的临界计算方法,返回 `IEnemyCritical` 的生成器; +- `CriticalableHeroStatus`:只允许数值类型的勇士属性,例如 `atk`、`def`、`hp`。 + +内置 `getCriticalLimit` 对 `atk` 的处理是:怪物有坚固时返回 `Infinity`,否则返回 `def + hp`;其他属性直接返回勇士当前最终值。 + +现有约定是**伤害值随属性提高单调递减**。临界值唯一依赖「最后的伤害值」,因此公式不单调也能运行,只是算出的临界点不准。写自定义公式时尽量保持单调,临界结果才有意义。 diff --git a/docs/intermediate/enemy-final.md b/docs/intermediate/enemy-final.md new file mode 100644 index 0000000..bf0cf6c --- /dev/null +++ b/docs/intermediate/enemy-final.md @@ -0,0 +1,79 @@ +# 最终属性效果 + +最终效果(final effect)是构建计算后怪物的最后一个阶段:它只对怪物自身的基础属性做收尾调整,不查询怪物上下文,也不影响其他怪物。它适合实现「只看自己(和勇士)就能决定」的转换,例如坚固、模仿。整体流程见[战斗总览](./combat-overview.md)。 + +## 接口 + +`IEnemyFinalEffect` 只有两个成员: + +- `priority`:优先级,越高越先执行; +- `apply(handler)`:读取 `handler.enemy` 与 `handler.hero`,修改 `handler.enemy` 的基础属性。 + +注册方式: + +```ts +state.enemyContext.registerFinalEffect(effect); +``` + +`initializeEnemy` 已经注册了内置的 `MainEnemyFinalEffect`(`src/data/enemy/final.ts`)。它处理两个属性: + +- **坚固(code 3)**:把怪物防御提升到不低于「勇士攻击 - 1」; +- **模仿(code 8)**:把怪物攻击与防御设置为勇士的最终攻击与最终防御。 + +## 三方对比 + +光环、查询、最终效果都在构建计算后怪物时执行,但读取与影响的范围不同: + +| 效果 | 读取其他怪物/上下文 | 影响其他怪物 | 典型用途 | +|------|---------------------|--------------|----------| +| 光环 | 否(只按范围命中) | 是 | 给周围怪物加属性或特殊属性 | +| 查询 | 是 | 否(只改自身) | 抱团、同化、号角 | +| 最终 | 否 | 否 | 坚固、模仿、仿攻 | + +最终效果可以读取勇士属性(模仿与仿攻都需要),但不会扫描周围怪物,也不会改变其他怪物。 + +## 示例:仿攻 + +「仿攻」让怪物的攻击力等于勇士的最终攻击力。仿攻与内置的模仿类似,区别是只改攻击、不改防御: + +```ts +// special.ts:32 - 仿攻 +map.set( + 32, + defineNonePropertySpecial(32, { + name: '仿攻', + desc: '怪物攻击等于勇士攻击。', + color: '#B0C0DD' + }) +); +``` + +```ts +import { IEnemyAttr, IHeroAttr } from '@user/data-common'; +import { IEnemyFinalEffect, IEnemyHandler } from '@user/data-system'; + +export class AttackCopyFinal implements IEnemyFinalEffect< + IEnemyAttr, + IHeroAttr +> { + readonly priority = 0; + + apply(handler: IEnemyHandler): void { + if (!handler.enemy.hasSpecial(32)) return; + handler.enemy.setAttribute( + 'atk', + handler.hero.getFinalAttribute('atk') + ); + } +} + +state.enemyContext.registerFinalEffect(new AttackCopyFinal()); +``` + +`apply` 里直接 `setAttribute`,因为最终阶段拿到的 `handler.enemy` 就是可修改的怪物对象。最终阶段之后不再有别的属性修正,所以这里写进去的值会直接进入伤害计算。 + +`priority` 只需要记住一句:数值越大越先执行;内置最终效果的 `priority` 是 `0`。 + +## 与伤害计算的分工 + +最终效果改的是怪物的基础属性,伤害计算器负责决定「这些属性如何变成伤害与回合数」。如果某个效果只是让怪物攻击更高,优先考虑写成光环、查询或最终效果;只有需要改变公式本身时,才去改[伤害计算](./enemy-calculator.md)。 diff --git a/docs/intermediate/enemy-query.md b/docs/intermediate/enemy-query.md new file mode 100644 index 0000000..d26e9a9 --- /dev/null +++ b/docs/intermediate/enemy-query.md @@ -0,0 +1,176 @@ +# 怪物查询 + +查询(query)让特殊属性在构建计算后怪物时读取上下文:周围有哪些怪物、它们带什么特殊属性、勇士在哪里。读取之后,查询可以修改怪物自身的基础属性,或增删、修改怪物的特殊属性。基础概念见[战斗总览](./combat-overview.md)。 + +:::warning +查询效果是**需要自己注册的新增内容**。当前 `src/data/enemy/initialize.ts` 只注册了光环转换器、最终效果、战斗脚本与钩子,没有注册任何查询效果。下面的接口与示例在你自己调用注册方法之前不会生效。 +::: + +## 常规查询(CommonQuery) + +常规查询面向「某个特殊属性要读外部状态,然后改自己」。注册方式: + +```ts +state.enemyContext.registerCommonQueryEffect(code, effect); +``` + +`IEnemyCommonQueryEffect` 只有两个成员: + +- `priority`:优先级; +- `apply(handler, special, query)`:处理逻辑。`handler.enemy` 是正在计算的怪物,`special` 是触发查询的特殊属性,`query()` 返回怪物上下文。 + +`query()` 不只是取上下文,它还告诉上下文「这个怪物依赖外部状态」。调用后,周围怪物或位置变化时,上下文会把这个怪物标记为需要重新查询。建议始终通过 `query()` 取上下文,而不是直接读 `handler.context`。 + +## 特殊查询(SpecialQuery) + +特殊查询面向「修改目标怪物拥有的特殊属性」,例如给范围内的怪物添加、删除或改写特殊属性。注册方式: + +```ts +state.enemyContext.registerSpecialQueryEffect(effect); +``` + +`IEnemySpecialQueryEffect` 有: + +- `priority`:优先级; +- `for(ctx)`:返回一个 `IEnemySpecialQueryModifier`。 + +修饰器包含四个方法: + +- `shouldQuery(handler)`:判断当前怪物是否要执行这次查询; +- `add(handler)`:返回要添加给怪物的特殊属性数组; +- `delete(handler)`:返回要删除的特殊属性数组; +- `modify(handler, special)`:修改怪物的某个特殊属性,真正修改了返回 `true`。 + +`add` 与 `delete` 不应同时返回非空结果:上下文遇到这种组合会告警并放弃这次修改。 + +## 优先级 + +常规查询与特殊查询的 `priority` 和光环共用同一套优先级,从高到低处理。高优先级的效果先执行,低优先级能看到它留下的结果;反过来不行。查询新增的光环只能影响之后的阶段,规则与[怪物光环](./enemy-aura.md)一致。 + +## 示例:抱团 + +「抱团」的规则是:范围内每有一个带同种特殊属性的怪物,自身攻击力就增加固定值。用常规查询实现: + +```ts +import { ManhattanRange } from '@motajs/common'; +import { IEnemyAttr, IHeroAttr } from '@user/data-common'; +import { IEnemyCommonQueryEffect } from '@user/data-system'; + +const effect: IEnemyCommonQueryEffect = { + priority: 10, + apply(handler, special, query) { + const { radius, atk } = special.value as { + radius: number; + atk: number; + }; + const ctx = query(); + const range = new ManhattanRange(); + let count = 0; + for (const [locator, view] of ctx.scanRange(range, { + cx: handler.locator.x, + cy: handler.locator.y, + radius + })) { + if ( + locator.x === handler.locator.x && + locator.y === handler.locator.y + ) { + continue; + } + if (view.getComputedEnemy().hasSpecial(special.code)) { + count++; + } + } + handler.enemy.addAttribute('atk', count * atk); + } +}; + +state.enemyContext.registerCommonQueryEffect(40, effect); +``` + +这个需求如果改用光环实现会麻烦得多:光环只能逐个目标施加减益或增益,很难先统计「范围内有多少个同类」再一次性加攻。查询可以在一次 `apply` 里完成扫描与累加。 + +## 示例:同化 + +「同化」的规则是:把范围内其他怪物的特殊属性复制到自己身上;数值型属性加算叠加,非数值型属性取位置索引最小的那个。用特殊查询实现: + +```ts +import { IEnemyAttr, IHeroAttr } from '@user/data-common'; +import { IEnemySpecialQueryEffect } from '@user/data-system'; +import { ISpecial } from '@user/data-base'; + +const effect: IEnemySpecialQueryEffect = { + priority: 15, + for(ctx) { + return { + shouldQuery: handler => handler.enemy.hasSpecial(41), + add(handler) { + const result: ISpecial[] = []; + const others = [...ctx.iterateEnemy()] + .filter(([, view]) => view.getComputedEnemy() !== handler.enemy) + .sort( + ([a], [b]) => + ctx.indexer.index(a.x, a.y) - + ctx.indexer.index(b.x, b.y) + ); + for (const [, view] of others) { + for (const special of view.getComputedEnemy().iterateSpecials()) { + if (special.code === 41) continue; + if (handler.enemy.hasSpecial(special.code)) continue; + result.push(special.clone()); + } + } + return result; + }, + delete() { + return []; + }, + modify(handler, special) { + if (typeof special.value !== 'number') return false; + let sum = special.value; + let found = false; + for (const [, view] of ctx.iterateEnemy()) { + const other = view + .getComputedEnemy() + .getSpecial(special.code); + if (!other || typeof other.value !== 'number') continue; + sum += other.value; + found = true; + } + if (!found) return false; + special.setValue(sum); + return true; + } + }; + } +}; + +state.enemyContext.registerSpecialQueryEffect(effect); +``` + +`add` 负责处理「自己还没有的属性」,`modify` 负责处理「自己已经有、需要叠加数值」的属性。两者配合,就能把范围内怪物的特殊属性合并到自身。 + +## 示例:号角 + +「号角」的规则是:场上每有一只怪物,自身就获得一份加成。它不关心范围与同类,用常规查询遍历全场即可: + +```ts +import { IEnemyAttr, IHeroAttr } from '@user/data-common'; +import { IEnemyCommonQueryEffect } from '@user/data-system'; + +const effect: IEnemyCommonQueryEffect = { + priority: 5, + apply(handler, special, query) { + const ctx = query(); + let count = 0; + for (const _ of ctx.iterateEnemy()) { + count++; + } + handler.enemy.addAttribute('atk', count * (special.value as number)); + } +}; + +state.enemyContext.registerCommonQueryEffect(42, effect); +``` + +三个示例分别覆盖了「按范围统计同类」「合并特殊属性」「遍历全场计数」三种典型用法。把注册调用放进 `initializeEnemy`,查询就会参与构建计算后怪物。