docs(10-11): 按 D-49 调整战斗篇(改内置优先)

- 7 篇战斗教程改为「先改内置」为主线,示例类移到末尾并加 D-49 caveat
- enemy-specials/aura/final/calculator/map-damage/combat-script/status-hooks
- READER-REVIEW 追加 batch 8(D-49 战斗框架)记录
This commit is contained in:
unanmed 2026-10-19 18:09:59 +08:00
parent 9c57673e0a
commit 486784fdcf
8 changed files with 564 additions and 113 deletions

View File

@ -484,3 +484,34 @@
- 构建门禁:`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` → **green**,输出 `build complete`,dead link = 0。
- 边界:仅改两篇文档与本记录;未改 `config.ts` 与生产代码;未新增文件;未引入指向 `docs/advanced/` 的真实链接(D-47 预留仍为代码 span + HTML 注释);未创建 `10-11-SUMMARY.md`,未改 `STATE.md` / `ROADMAP.md`(按本次派发要求)。
---
## 用户循环 · batch 8 记录(D-49 战斗框架:改内置优先)
**范围:** 用户修订循环第 1 轮 batch 8(D-49)——战斗组其余 7 篇:`enemy-specials`、`enemy-aura`、`enemy-final`、`enemy-calculator`、`enemy-map-damage`、`enemy-combat-script`、`enemy-status-hooks`(`enemy-query` 已在 batch 7 完成,本次不动)。按 `10-CONTEXT.md` D-49:①示例贴合引擎现状、定义 `class` 再 `new`,不用 `const obj = {...}`;②主体讲**改内置类 / 改内置文件**,末尾才简述「自己写类」;③明确写出「不懂代码建议直接改内置,而不是自己写新类」。保留 D-48 六步实操、D-47 高级教程预留与既有 U 系列修正。
### 逐条处置
| 文档 | 处置 |
| --- | --- |
| `enemy-specials.md` | 原「自定义一个新特殊属性」改为「改内置:往属性表里加一条属性」,开篇点明「本篇主线是改内置、特殊属性没有自己写类的做法」,六步照旧;末尾新增「如果内置实现里实在找不到落点」并写入 D-49 caveat「不懂代码建议直接改内置实现」。 |
| `enemy-aura.md` | 删去旧的「改内置,还是自己写」(原称光环**必须**写转换器 + 视图),改为「改内置:调整内置光环的行为」:给出「想改什么 → 改哪个方法」对照表,并以「让普通光环同时影响金币与经验」为六步实操(改 `special.ts` 的 `ISpecial23Value` + `CommonAura.apply`),附「改另外两类内置光环」要点;原两个自写类示例(`WeakAura` / `ChainAura`)整体移到末尾「自己写光环类」,并在节首写入 D-49 caveat。 |
| `enemy-final.md` | 删去「改内置,还是自己写」,改为「改内置:在 `MainEnemyFinalEffect.apply` 里加分支」,把原 `AttackCopyFinal` 示例改写为**改内置分支**的六步实操;原自写类形态移到末尾「自己写最终效果类」并写入 caveat。「三方对比」「为什么没有既读又改」原样保留。 |
| `enemy-calculator.md` | 删去「改内置,还是自己写」,改为「改内置:在 `MainDamageCalculator.calculate` 里加分支」,把「等差数列属性」改写为在内置 `calculate` 里追加两行的六步实操;原自写最小计算器 `ArithmeticCalculator` 移到末尾「自己写计算器类」并写入 caveat。「计算器必须无副作用」warning 与内置公式逐段导读原样保留。 |
| `enemy-map-damage.md` | 删去「改内置,还是自己写」,改为「改内置:调整内置视图与转换器」,以「让内置激光也打斜向」为六步实操(把 `MainMapDamageConverter.convert` 的 `FaceGroup.Dir4` 改成 `Dir8`),并附「另外几处常见的改法」;原自写类示例 `EightDirLaser` 移到末尾「自己写地图伤害类」并写入 caveat。 |
| `enemy-combat-script.md` | 删去「改内置,还是自己写」,改为「改内置:在 `MainCombatScript` 里加逻辑」,给出战前拦下「无敌」与战后回血两段改内置六步实操;原 `SealCombatScript` / `HealAfterCombatScript` 移到末尾「自己写脚本类」并写入 caveat。「脚本 vs 钩子」选型表保留。 |
| `enemy-status-hooks.md` | 删去「改内置,还是自己写」式叙述,改为「改内置:往内置钩子类里加逻辑」,给出「什么时候生效 → 改哪个钩子」对照表,并把「下次战斗伤害乘二」改写为三处**内置**修改(`hook.ts` 挂状态、`script.ts` 消费、`calculator.ts` 只读)的实操;原自写 `ChargedHooks` / `ConsumeDoubleScript` 移到末尾「自己写钩子类」并写入 caveat;修正自写脚本 `priority` 与内置 `0` 冲突的隐患(改为 `-10`)。 |
### 共用复制说明(各篇统一)
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 `code-basics.md` 的「照抄教程里的代码时,怎么处理 import」)。
### 校验
- 源码核对:`src/data/enemy/special.ts`(`ISpecial23Value` 字段、23 号默认值)、`src/data/enemy/aura.ts`(`CommonAura.apply` / `createRange` / `getRangeParam`、`GuardAura.apply` / `getRangeParam` 3×3、`SpecialAuraModifier.add` / `modify`)、`src/data/enemy/final.ts`(`MainEnemyFinalEffect.apply` 的坚固 / 模仿分支)、`src/data/enemy/calculator.ts`(`calculate` 段落顺序、`damage += (turn - 1) * enemyPerDamage`、`getCriticalLimit`)、`src/data/enemy/mapDamage.ts`(`MainMapDamageConverter.convert` 的 `FaceGroup.Dir4`、各视图 `getRange` / `getRangeParam` / `getDamageWithoutCheck`、`MainMapDamageReducer.reduce`)、`src/data/enemy/script.ts`(`MainCombatScript.before` / `after`)、`src/data/enemy/hook.ts`(`EnemyCombatHooks.onAfterCombat` 分支顺序、四个钩子类)、`packages-user/data-system/src/combat/combat.ts`(`addCombatScript` 同优先级告警忽略、脚本 `after` 早于 `onAfterCombat`)、`packages-user/data-common/src/common/faceManager.ts`(`FaceGroup.Dir8`)。
- 结构门禁:7 篇均 CRLF、单 H1(`STRUCT-OK`)。
- 格式门禁:`node node_modules/prettier/bin/prettier.cjs --write`(D-45,`endOfLine: crlf`)已跑。
- 构建门禁:`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` → **green**,输出 `build complete`,dead link = 0。
- 边界:仅改 7 篇文档 + 本记录;未改 `config.ts` 与生产代码;未新增文件;未引入指向 `docs/advanced/` 的真实链接(D-47 预留仍为代码 span + HTML 注释);未创建 `10-11-SUMMARY.md`,未改 `STATE.md` / `ROADMAP.md`(按本次派发要求)。

View File

@ -2,7 +2,7 @@
有些怪物并不直接改自己,而是影响周围的怪物:站在它旁边的怪物攻击更高、金币更少,或者被「传染」上另一层光环。这类特殊属性的执行方式就是光环(aura)。
光环的效果作用在范围内的其他怪物身上(也可以包括自己)。它要回答两个问题:先算谁、后算谁?改基础属性还是改特殊属性?下面先从优先级讲起,再看内置光环与自定义写法。基础概念见[战斗总览](./combat-overview.md)。
光环的效果作用在范围内的其他怪物身上(也可以包括自己)。它要回答两个问题:先算谁、后算谁?改基础属性还是改特殊属性?下面先从优先级讲起,再看内置光环、怎么改它们,最后才是自己写类。基础概念见[战斗总览](./combat-overview.md)。
光环属于「不读取外部环境、但会修改外部」的那一类:它只按范围命中目标,不会先观察环境再决定自己。(为什么没有「既读取外部、又修改外部」的能力,见[最终属性效果](./enemy-final.md)。)
@ -51,7 +51,7 @@
用大白话说:**转换器**像一个「分诊台」——引擎拿到一条特殊属性后,会挨个问每个已注册的转换器「这条你管吗?」(`shouldConvert`),哪个回答「管」,就由它把这条属性翻译成一份**视图**;**视图**则是一张「操作说明」,写清这条光环的范围多大、优先级多少、具体要改目标的哪些属性(`apply` / `applySpecial`)。
因此,**每一条特殊属性都可以对应不同的转换器**,不必都挤进同一个里;反过来,只要 `shouldConvert` 认得出来,多个 code 也可以共用同一个转换器。内置的三条光环就各有各的转换器(`CommonAuraConverter` / `GuardAuraConverter` / `SpecialAuraConverter`),下面两个自定义示例也各自写了一个转换器。
因此,**每一条特殊属性都可以对应不同的转换器**,不必都挤进同一个里;反过来,只要 `shouldConvert` 认得出来,多个 code 也可以共用同一个转换器。内置的三条光环就各有各的转换器(`CommonAuraConverter` / `GuardAuraConverter` / `SpecialAuraConverter`),文末「自己写光环类」的两个示例也各自写了一个转换器。
视图的核心成员:
@ -72,16 +72,118 @@
下方示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」。
### 改内置,还是自己写
## 改内置:调整内置光环的行为
光环与前面几篇不同:**它必须写一个「转换器 + 视图」的类**,没有「只改内置一处」的省事办法。原因是「怎么认出这条光环、怎么影响目标」在光环里是拆成两个对象表达的,引擎没有提供更简单的注册入口。(若你的需求恰好与内置三条光环同构,也可以直接改 `src/data/enemy/aura.ts` 里的内置实现。)
内置三类光环都写在 `src/data/enemy/aura.ts`,而且已经在 `initializeEnemy` 里注册好了。所以**改内置就是在这些类的现有方法里加分支、改参数**:不用新建类,也不用再调用 `registerAuraConverter`。这是本篇的主线做法,绝大多数需求都能这样完成。
- **转换器**:只有 `shouldConvert` 与 `convert` 两个方法,负责「认领」特殊属性并构造视图。`shouldConvert` 通常照抄、只换一个 code;`convert` 里构造视图那一行要按你的视图构造函数改。
- **视图**:真正描述效果的地方(范围、优先级、`apply` / `applySpecial`),这部分必须按你的需求写。
先按「想改什么」找到落点:
下面两个示例用 `[模板]` 标出可原样照抄的行(多为接口签名、`shouldConvert`、范围对象的构建),用 `[需改]` 标出必须按需求改写的行(code、范围、优先级,以及 `apply` / `applySpecial` 里做的事)。
| 想改的东西 | 改哪个方法 |
| ----------------- | ---------------------------------------------------------------------------- |
| 光环影响哪些属性 | `CommonAura.apply`(基础属性)、`SpecialAuraModifier.add/modify`(特殊属性) |
| 光环影响多大范围 | `CommonAura.createRange` 与 `getRangeParam`、`GuardAura.getRangeParam` |
| 光环什么时候执行 | 视图里的 `readonly priority` |
| 光环认领哪个 code | 转换器里的 `shouldConvert` |
## 示例:基础光环(减少周围怪物的金币与经验)
三组「转换器 + 视图」的对应关系是:普通光环 `CommonAuraConverter` → `CommonAura`;支援 `GuardAuraConverter` → `GuardAura`;特殊光环 `SpecialAuraConverter` → `SpecialAura`(特殊属性由 `SpecialAuraModifier` 改动)。日常调整效果不必动 `shouldConvert`,只有想让某个内置转换器改认另一个 code 时才需要改它。
### 示例:让普通光环同时影响金币与经验
**目标**:普通光环(code 23)除了现有的生命 / 攻击 / 防御百分比,还能按参数调整范围内怪物的金币与经验。
**改哪里**:`src/data/enemy/special.ts` 的 `ISpecial23Value` 与 code 23 的默认值;`src/data/enemy/aura.ts` 的 `CommonAura.apply`。两处都是内置文件,改完不需要注册。
**复制什么**:先给参数接口补两个字段——
```ts
/** 23 - 光环 */
export interface ISpecial23Value {
// [模板] 原有字段保持不变
range: number;
square: boolean;
hpBuff: number;
atkBuff: number;
defBuff: number;
// [需改] 新增:金币 / 经验变化百分比,正数提升、负数下降
moneyBuff: number;
expBuff: number;
}
```
再把 code 23 的默认值补齐(不补的话,旧数据读到的这两个字段是 `undefined`)——
```ts
// 23 - 光环
map.set(
23,
defineCommonSerializableSpecial<ISpecial23Value>(
23,
{
range: 0,
square: false,
hpBuff: 0,
atkBuff: 0,
defBuff: 0,
moneyBuff: 0, // [需改] 新增字段的默认值
expBuff: 0 // [需改] 新增字段的默认值
}
// ...省略:name / desc / color 原样保留
)
);
```
最后在 `CommonAura.apply` 末尾追加两个分支——
```ts
apply(
handler: IEnemyHandler<IEnemyAttr, any>,
baseEnemy: IReadonlyEnemy<IEnemyAttr>
): void {
const { enemy } = handler;
const { hpBuff, atkBuff, defBuff, moneyBuff, expBuff } =
this.special.value;
// ...省略:原有的 hpBuff / atkBuff / defBuff 三段,保持不变...
// [需改] 新增:同样用 baseEnemy 的原始值算百分比
if (moneyBuff !== 0) {
const money = (baseEnemy.getAttribute('money') * moneyBuff) / 100;
enemy.addAttribute('money', Math.floor(money));
}
if (expBuff !== 0) {
const exp = (baseEnemy.getAttribute('exp') * expBuff) / 100;
enemy.addAttribute('exp', Math.floor(exp));
}
}
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**改什么**:新增字段的名字与含义、`apply` 里改的属性换成你自己的。如果只是想改现有数值的算法(例如把取整改成四舍五入),只改 `apply` 里对应那一段,不用动参数接口。
**怎么验证**:给一只怪物挂上带 `moneyBuff: -20` 的光环,让它旁边站另一只怪物,看后者显示的金币是否下降 20%。
**注意**:`apply` 里一律用 `baseEnemy`(未修改的原始怪物)算百分比,否则多个光环会把彼此的结果继续乘进去。改内置不需要再注册——`initializeEnemy` 已经注册过这些转换器。
### 改另外两类内置光环
- **支援(24)**:`GuardAura.apply` 把来源怪物登记进目标的 `guard` 属性,`GuardAura.getRangeParam` 固定返回周围 3×3。想让支援范围更大,或改成按某个参数决定,就改这两个方法。
- **特殊光环(26)**:`SpecialAuraModifier.add` 负责「目标没有的属性就补上」,`modify` 负责「已有的数值属性按 `mulAdd` 加算或乘算」。要支持新的叠加方式,改 `modify` 里的判断。
- **调整执行顺序**:直接改对应视图的 `readonly priority`。内置值是支援 5、普通光环 10、特殊光环 20,改大就会更早执行。
## 自己写光环类
只有在「内置三类光环表达不了、往内置方法体里也塞不进去」时才需要自己写类。典型情况是:一种全新的特殊属性要有自己的范围、优先级和影响方式,而它既不改基础属性、也不符合特殊光环那种「按 code 列表加属性」的形态。
自己写类要同时处理接口签名、`shouldConvert` 认领、注册时机与优先级,改动面比改内置方法体大得多。
**如果你不怎么懂代码,建议直接改内置光环类(`src/data/enemy/aura.ts` 的 `CommonAura` / `GuardAura` / `SpecialAura`),而不是自己写新的类。** 上面「改内置」一节已经覆盖了绝大多数需求:调数值、调范围、调优先级、给目标加属性,都能在现有方法里完成。
自己写的做法是「转换器 + 视图」两个类:转换器只有 `shouldConvert` 与 `convert` 两个方法,负责认领特殊属性并构造视图;视图负责范围、优先级与 `apply` / `applySpecial`。下面两个示例用 `[模板]` 标出可原样照抄的行(多为接口签名、`shouldConvert`、范围对象的构建),用 `[需改]` 标出必须按需求改写的行(code、范围、优先级,以及 `apply` / `applySpecial` 里做的事)。
两个示例都新建了自己的 code(30、31),不修改内置的 23 / 24 / 26。如果需求只是「让已有光环多做一点事」,用上一节的改内置写法更省事。
### 示例:基础光环(减少周围怪物的金币与经验)
先从一个只改基础属性的例子开始:让范围内的怪物金币与经验下降。它用 code 30(示例自选的未占用号)定义一条带参数的特殊属性,再配一个转换器与视图。
@ -227,7 +329,7 @@ state.enemyContext.registerAuraConverter(new WeakAuraConverter());
**注意**:`apply` 里用 `baseEnemy` 的原始值做百分比,因此多个光环不会把彼此的结果继续乘进去。只改基础属性就实现 `apply`;要改目标的特殊属性才需要 `applySpecial`。
## 示例:特殊光环(给周围怪物上光环)
### 示例:特殊光环(给周围怪物上光环)
如果光环要给目标「加一条特殊属性」,而不是直接改基础属性,就把逻辑放进 `applySpecial`。下面的例子给范围内没有普通光环的怪物加上 code 23 的普通光环。

View File

@ -31,12 +31,11 @@ state.enemyContext.getDamageSystem()?.useCalculator(new MainDamageCalculator());
计算器在一次战斗里会被**反复调用**:算临界值要二分十几次,渲染伤害数值、图鉴与提示等也可能在真正开打前各调用一次。因此它**必须只读输入、算出结果,不能改动任何外部状态**——不能写 flag、不能删 flag,也不能改怪物或勇士对象。只要存在副作用,同一只怪物在不同时刻就会算出不同结果,临界与显示都会错乱。需要「一次性」生效的状态,请放到别处去改(战斗脚本、钩子等),计算器只负责读取。
:::
### 改内置,还是自己写
## 改内置:在 `MainDamageCalculator.calculate` 里加分支
- **改内置公式**:直接在 `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate` 里改公式或加分支。多数「调参类」属性(先攻、连击、破甲等)都实现在这里。
- **写自己的计算器**:实现 `IDamageCalculator` 接口,再用 `useCalculator` 替换。适合整条公式都要换、或不想改动内置文件时。
内置公式全部写在 `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate` 一个方法里,而且它已经在 `initializeEnemy` 里注册好了。所以**改内置就是在这个方法里改公式、加分支**——不新建类,也不用再调用 `useCalculator`。这是本篇的主线做法,多数「调参类」属性(先攻、连击、破甲等)都实现在这里。
两者只能二选一:`useCalculator` 是**替换**当前计算器,选了自己的就会把内置的整个换掉,内置的那些分支需要你在自己的 `calculate` 里重新保留。
改之前先弄清楚公式的段落顺序,否则很容易加错位置。
## 内置公式逐段导读
@ -60,10 +59,6 @@ state.enemyContext.getDamageSystem()?.useCalculator(new MainDamageCalculator());
- 要让属性按回合数累积、按条件改变倍率、或增加新的伤害分支:在 `calculate` 的对应段落里改公式;
- 只是给勇士一侧加临时数值:用[勇士属性](./hero-attribute.md)里的修饰器,计算器会自动读到最终属性。
## 支援怪
支援怪部分的递归计算较绕:计算当前怪物时,会把每个支援者的回合数与伤害也加进来。初次阅读可以暂时跳过这一段,不影响自定义普通怪物的公式;等需要处理支援组合时再回来看。
## 示例:等差数列属性
有时公式本身要随回合变化,而光环、查询、最终效果都做不到这一点——它们改的是一次计算里的固定属性。这时就该动计算器。
@ -74,9 +69,60 @@ state.enemyContext.getDamageSystem()?.useCalculator(new MainDamageCalculator());
**目标**:让怪物的每回合伤害按固定公差逐回合递增(本示例公差为 5)。
**改哪里**:`src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate`(改内置),或另建文件写一个计算器再用 `useCalculator` 替换。
**改哪里**:`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」)。
**改什么**:`step`(公差,本示例为 `5`)换成你要的递增规则。注意 `turn` 在内置方法里已经算过,插入时不要重复声明同名变量——把新增两行放在 `damage += (turn - 1) * enemyPerDamage;` 之后即可。
**怎么验证**:给怪物挂上后开战,看显示的回合伤害是否随回合数按公差递增、总伤害是否等于求和结果。
**注意**:这段只改「怪物每回合伤害」的数值,内置的无敌、吸血、支援、先攻等分支全部原样保留——这正是改内置比自写计算器省事的地方。计算器**必须无副作用**(见上方 warning)。
## 自己写计算器类
只有「整条公式都要换掉」时才需要自己写计算器。如果只是加一条分支、改一段系数,改内置方法体就够了。
自己写类要同时处理接口签名、`getCriticalLimit` 与注册,而且替换会丢掉全部内置分支,改动面比在内置 `calculate` 里加一段大得多。
**如果你不怎么懂代码,建议直接改内置的 `MainDamageCalculator.calculate`(在 `src/data/enemy/calculator.ts` 里加分支),而不是自己写新的计算器。** 上面「改内置」一节已经覆盖了绝大多数需求;自写计算器意味着你要自己重新实现无敌、吸血、支援、连击、先攻、破甲、反击、净化、固伤、仇恨、自爆这些分支。
做法是实现 `IDamageCalculator` 接口,再用 `useCalculator` 替换:
- **接口**:`calculate` 返回 `{ damage, turn }`,`getCriticalLimit` 返回临界上界;
- **替换**:`useCalculator` 是**替换**当前计算器,选了自己的就会把内置的整个换掉。
下面用「等差数列」演示自写类的最小形态(只保留本示例所需分支):
```ts
// [需改] 类名:换成你自己的计算器名
@ -108,8 +154,10 @@ export class ArithmeticCalculator implements IDamageCalculator<
// [需改] 公差:每回合递增的伤害(本示例固定为 5)
const step = 5;
// [需改] 等差数列求和:首项 base,公差 step,共 turn 项
const damage = turn * base + (step * turn * (turn - 1)) / 2;
// [需改] 等差数列求和:勇士先手,怪物共出手 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 };
}
@ -122,16 +170,42 @@ export class ArithmeticCalculator implements IDamageCalculator<
return handler.hero.getFinalAttribute(attribute);
}
}
// [需改] 注册这行要挪进 initializeEnemy(见下方「注册在哪里」)
state.enemyContext.getDamageSystem()?.useCalculator(new ArithmeticCalculator());
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**改什么**:`step`(公差,本示例为 `5`)与 `base` 的算法换成你要的递增规则;换算成「改内置」时,把这段逻辑并入 `calculate` 的对应段落即可。
**改什么**:`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` 一起生效。
## 临界
临界计算回答「勇士某项属性提高到多少时,战斗结果会发生变化」。相关接口:

View File

@ -36,12 +36,86 @@ state.combat.addCombatScript(new MainCombatScript());
- **战前**:非强制战斗时,如果勇士当前生命小于本次伤害,返回 `false`,阻止战斗;
- **战后**:把怪物与支援者的经验、金币加到勇士身上;`curse` flag 为 `true` 时不加;最后发出一次全局提示。
### 改内置,还是自己写
## 改内置:在 `MainCombatScript` 里加逻辑
- **改内置脚本**:直接改 `src/data/enemy/script.ts` 的 `MainCombatScript`(内置的战前拦截与战后结算就写在里面)。
- **写自己的脚本**:实现 `ICombatScript` 接口,再用 `addCombatScript` 追加。适合额外规则,或不想改动内置文件时。
内置脚本只有一个类 `MainCombatScript`,写在 `src/data/enemy/script.ts`,而且已经在 `initializeEnemy` 里注册好了。所以**改内置就是改它的 `before` / `after` 方法体**——不新建类,也不用再调用 `addCombatScript`。这是本篇的主线做法。
`addCombatScript` 是**追加**而不是替换:内置脚本仍会执行,新脚本按 `priority` 与它一起排序;但同优先级的脚本不能重复注册。
先按「什么时候做」找落点:
| 想做的事 | 改哪里 |
| --------------------------- | ---------------------------------------------- |
| 战斗开始前拦下这一场 | `before` 里加判断,返回 `false` |
| 战斗结束后改勇士状态 / 结算 | `after` 里追加逻辑 |
| 调整内置逻辑的先后 | 内置方法体里的语句顺序(同类内按书写顺序执行) |
内置 `before` 的结构是「先看是不是强制战斗,再看勇士血量够不够」;内置 `after` 是「先算经验金币,再判断诅咒,最后发提示」。加逻辑时接在对应位置即可。
### 示例:战前拦下带无敌的怪物
**目标**:带「无敌」(code 18)的怪物不能被打,点它时直接放弃战斗并给出提示。
**改哪里**:`src/data/enemy/script.ts` 的 `MainCombatScript.before`(内置文件,改完不需要注册)。
**复制什么**:在内置判断之后接一段——
```ts
async before(
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>,
handler: ICombatFlowHandler<IEnemyAttr, IHeroAttr>
): Promise<boolean> {
const hero = handler.hero;
// 如果伤害大于勇士血量,直接不允许战斗
// ...省略:内置判断原样保留...
// [需改] 带「无敌」(code 18)的怪物直接拦下
if (handler.enemy.hasSpecial(18)) {
handler.state.emitTip('这个怪物无法被击败。');
// [模板] 返回 false:放弃本次战斗
return false;
}
// [模板] 返回 true:允许继续
return true;
}
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**改什么**:`hasSpecial(18)` 的判断与提示文字换成你自己的。
**怎么验证**:给一只怪物挂上「无敌」后点它开战,应看到提示、且勇士不入场、战斗不结算。
**注意**:`before` 一旦返回 `false`,后续战前脚本、战后脚本与 `onAfterCombat` 钩子都不会执行。这里的 18 号在内置属性编号表里就是「无敌」,地图伤害使用同一套编号(阻击是 16),所以这个判断与其他系统没有编号冲突。内置 `MainCombatScript` 已经在 `initializeEnemy` 注册过,改完直接生效,**不要**再 `addCombatScript(new MainCombatScript())`——同优先级重复注册会被忽略并告警。
### 示例:战后回复固定量生命
**目标**:每场战斗结束后,给勇士回复固定量生命(本示例 20 点)。
**改哪里**:`src/data/enemy/script.ts` 的 `MainCombatScript.after`(内置文件,改完不需要注册)。
**复制什么**:在内置结算之后追加一段——
```ts
async after(
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>,
handler: ICombatFlowHandler<IEnemyAttr, IHeroAttr>
): Promise<void> {
const hero = handler.hero;
// ...省略:内置的经验金币结算、诅咒判断与全局提示...
// [需改] 战后给勇士回 20 点血
hero.add('hp', 20);
}
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**改什么**:回复量 `20` 与回复的属性换成你自己的。如果希望回血发生在奖励结算**之前**,就把这段放在 `after` 开头而不是末尾。
**怎么验证**:打完一场战斗,看勇士生命是否回复了设定量,且奖励结算仍正常。
**注意**:`handler.hero` 是可修改对象,直接 `add` 即可。这段对所有战斗生效——它写在无条件的 `after` 里,不带任何 `hasSpecial` 判断。如果只想对特定怪物生效,在外面套一个 `if (info.handler.enemy.hasSpecial(...))`。
## 脚本 vs 钩子
@ -57,7 +131,17 @@ state.combat.addCombatScript(new MainCombatScript());
简单记:**要拦截或按顺序结算,用脚本;只想在战斗前后做附加动作,用钩子。**
## 战前示例:拥有某特殊属性时无法战斗
## 自己写脚本类
只有两种情况需要自己写脚本:一是规则要按自己的 `priority` 与内置脚本拉开顺序(内置脚本固定是 0),二是同一条规则要能被单独注册或移除。
自己写类要同时处理接口签名、优先级与注册,而且同优先级会与内置脚本冲突,改动面比在内置方法体里加一段大。
**如果你不怎么懂代码,建议直接改内置的 `MainCombatScript`(在 `src/data/enemy/script.ts` 的 `before` / `after` 里加逻辑),而不是自己写新的脚本类。** 上面「改内置」一节已经覆盖了拦截与结算两类需求;只有确实需要独立优先级时才用下面的写法。
做法是实现 `ICombatScript` 接口,再用 `addCombatScript` 追加。`addCombatScript` 是**追加**而不是替换:内置脚本仍会执行,新脚本按 `priority` 与它一起排序;但同优先级的脚本不能重复注册。
### 示例:拥有某特殊属性时无法战斗
下面的脚本检查怪物是否拥有 code 18 的特殊属性(无敌,编号见[特殊属性](./enemy-specials.md)的「内置属性编号表」),有则直接放弃战斗(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」)。代码中用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求改的行。
@ -103,9 +187,9 @@ state.combat.addCombatScript(new SealCombatScript());
**怎么验证**:给一只怪物挂上「无敌」后点它开战,应看到提示、且勇士不入场、战斗不结算。
**注意**:`before` 一旦返回 `false`,后续战前脚本、战后脚本与 `onAfterCombat` 钩子都不会执行。这里的 18 号在内置属性编号表里就是「无敌」,地图伤害使用同一套编号(阻击是 16),所以这个判断与其他系统没有编号冲突。
**注意**:自写脚本与内置脚本**同时**存在,不是替换。`before` 一旦返回 `false`,后续战前脚本、战后脚本与 `onAfterCombat` 钩子都不会执行。
## 战后示例:战后玩家回复固定量生命值
### 示例:战后玩家回复固定量生命值
下面的脚本在每场战斗后给勇士回复 20 点生命:
@ -151,7 +235,7 @@ state.combat.addCombatScript(new HealAfterCombatScript());
**注意**:`handler.hero` 是可修改对象,直接 `add` 即可。`priority` 越大越先执行;战前与战后各自按这个顺序遍历脚本列表。把 `priority` 设成负值可在内置脚本之后执行,避免在奖励结算前改动勇士状态。
## 注册在哪里
### 注册在哪里
`addCombatScript` 要写进 `initializeEnemy`。下面是 `src/data/enemy/initialize.ts` 中该函数的样子(省略了无关的行):

View File

@ -17,20 +17,69 @@
state.enemyContext.registerFinalEffect(effect);
```
### 改内置,还是自己写
最终效果有两种做法,按需要选一种:
- **改内置实现**:直接在 `src/data/enemy/final.ts` 的 `MainEnemyFinalEffect.apply` 里加一个分支。内置的坚固、模仿就是这么写的,适合与现有逻辑同类的机制。
- **写自己的类**:实现 `IEnemyFinalEffect` 接口,再用 `registerFinalEffect` 注册。适合独立机制,或不想改动内置文件时。
两种做法进入的是同一条流水线,会按各自的 `priority` 与内置效果一起执行。下面示例用「写自己的类」演示。
`initializeEnemy`(`src/data/enemy/initialize.ts`)已经注册了内置的 `MainEnemyFinalEffect`(`src/data/enemy/final.ts`)。它处理两个属性:
- **坚固(code 3)**:把怪物防御提升到不低于「勇士攻击 - 1」;
- **模仿(code 8)**:把怪物攻击与防御设置为勇士的最终攻击与最终防御。
## 改内置:在 `MainEnemyFinalEffect.apply` 里加分支
最终效果的内置实现只有一个类、一个方法:`src/data/enemy/final.ts` 的 `MainEnemyFinalEffect.apply`。它已经在 `initializeEnemy` 里注册,所以**改内置就是在 `apply` 里加一个 `if` 分支**——不新建类,也不用再调用 `registerFinalEffect`。这是本篇的主线做法。
内置方法体的结构很简单:先取出勇士的最终攻防,然后按 code 依次判断,命中就改怪物的基础属性。加一条新效果,就是在这串判断后面再接一段。
以「仿攻」为例:让怪物的攻击力等于勇士的最终攻击力。它与内置的模仿类似,区别是只改攻击、不改防御(这里的 code 32 是示例自选的未占用编号;内置的最终效果是坚固 code 3、模仿 code 8,内置编号见[特殊属性](./enemy-specials.md))。
示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」。下面代码用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求改的行。
**目标**:让带「仿攻」(code 32)的怪物,攻击力始终等于勇士的最终攻击力。
**改哪里**:`src/data/enemy/special.ts` 登记一条属性;`src/data/enemy/final.ts` 的 `MainEnemyFinalEffect.apply` 加一个分支。两处都是内置文件,改完不需要注册。
**复制什么**:先在 `special.ts` 里登记属性——
```ts
// special.ts:32 - 仿攻
// [需改] 照 special.ts 里其他属性的写法,新增这样一条定义,编号与名称换成你自己的
map.set(
32, // [需改] 编号:示例自选的未占用号
defineNonePropertySpecial(32, {
name: '仿攻', // [需改] 显示名称
desc: '怪物攻击等于勇士攻击。', // [需改] 悬停时显示的说明
color: '#B0C0DD' // [需改] 名称的颜色
})
);
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
再在 `final.ts` 的 `apply` 末尾接一个分支——
```ts
apply(handler: IEnemyHandler<IEnemyAttr, IHeroAttr>): void {
const enemy = handler.enemy;
const heroAtk = handler.hero.getFinalAttribute('atk');
const heroDef = handler.hero.getFinalAttribute('def');
// 3 - 坚固
// ...省略:内置分支原样保留...
// 8 - 模仿
// ...省略:内置分支原样保留...
// [需改] 32 - 仿攻:只改攻击,改成勇士的最终攻击
if (enemy.hasSpecial(32)) {
enemy.setAttribute('atk', heroAtk);
}
}
```
**改什么**:编号(`32`)、名称、描述与颜色换成你自己的;新分支里 `hasSpecial(32)` 的触发条件与 `setAttribute` 的效果换成你要的逻辑。如果要读勇士的其他属性,在方法开头多取一次即可(`getFinalAttribute` 拿到的就是勇士的最终值)。
**怎么验证**:给一只怪物挂上「仿攻」后重建并开战,看它显示的攻击是否等于勇士当前最终攻击(勇士换装备后应同步变化)。
**注意**:`apply` 里 `handler.enemy` 已是可修改对象,直接 `setAttribute` 即可;最终阶段之后不再有别的属性修正,这里写进去的值会直接进入伤害计算。`MainEnemyFinalEffect` 的 `priority` 是 `0`,同一个类里的分支按书写顺序执行。
## 三方对比
光环、查询、最终效果都在构建计算后怪物时执行,但读取与影响的范围不同。把三者放在一起看,分工就清楚了:
@ -51,34 +100,15 @@ state.enemyContext.registerFinalEffect(effect);
所以上表里「读取外部」与「影响外部」同时为「是」的那一格被刻意留空:需要「读外部之后改变自己的结果」,就用查询;需要「影响其他怪物」,就用光环。两者分开、按固定阶段执行,循环依赖就不会出现。
## 示例:仿攻
## 自己写最终效果类
「仿攻」让怪物的攻击力等于勇士的最终攻击力。仿攻与内置的模仿类似,区别是只改攻击、不改防御(这里的 code 32 是示例自选的未占用编号;内置的最终效果是坚固 code 3、模仿 code 8,内置编号见[特殊属性](./enemy-specials.md))。
只有两种情况需要自己写类:一是效果要按自己的节奏独立执行(比如想单独控制 `priority`、让它在内置效果之前跑完),二是你不打算改动内置文件。
示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」。下面代码用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求改的行。
自己写类要同时处理接口签名、优先级与注册,改动面比在内置 `apply` 里加一个分支大。
**目标**:让带「仿攻」(code 32)的怪物,攻击力始终等于勇士的最终攻击力。
**如果你不怎么懂代码,建议直接改内置的 `MainEnemyFinalEffect.apply`(在 `src/data/enemy/final.ts` 里加分支),而不是自己写新的类。** 上面「改内置」一节已经覆盖了绝大多数需求;只有确实需要独立优先级或独立类时,才用下面的写法。
**改哪里**:`src/data/enemy/special.ts` 登记一条属性;另建一个文件放最终效果的类;`src/data/enemy/initialize.ts` 注册这个效果(见文末「注册在哪里」)。
**复制什么**:先在 `special.ts` 里登记属性——
```ts
// special.ts:32 - 仿攻
// [需改] 照 special.ts 里其他属性的写法,新增这样一条定义,编号与名称换成你自己的
map.set(
32, // [需改] 编号:示例自选的未占用号
defineNonePropertySpecial(32, {
name: '仿攻', // [需改] 显示名称
desc: '怪物攻击等于勇士攻击。', // [需改] 悬停时显示的说明
color: '#B0C0DD' // [需改] 名称的显示颜色
})
);
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
再写最终效果的类——
做法是实现 `IEnemyFinalEffect` 接口,再用 `registerFinalEffect` 注册。它与内置效果进入同一条流水线,会按各自的 `priority` 一起执行。下面仍以「仿攻」为例,演示自写类的形态:
```ts
// [需改] 类名:换成你自己的效果名
@ -105,11 +135,13 @@ export class AttackCopyFinal implements IEnemyFinalEffect<
state.enemyContext.registerFinalEffect(new AttackCopyFinal());
```
**改什么**:编号(`32`)、名称、描述与颜色换成你自己的;`hasSpecial(32)` 的触发条件与 `setAttribute` 里的效果换成你要的逻辑。
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**怎么验证**:给一只怪物挂上「仿攻」后重建并开战,看它显示的攻击是否等于勇士当前最终攻击(勇士换装备后应同步变化)。
**改什么**:编号(`32`)、类名、`priority` 与 `apply` 里的效果换成你自己的。
**注意**:`apply` 里 `handler.enemy` 已是可修改对象,直接 `setAttribute` 即可;最终阶段之后不再有别的属性修正,这里写进去的值会直接进入伤害计算。`priority` 越大越先执行,内置最终效果的 `priority` 是 `0`。
**怎么验证**:同「改内置」一节——挂上属性后开战,看攻击是否跟随勇士最终攻击变化。
**注意**:自写类与内置效果会**同时**执行,不是替换关系。若两者都处理同一个 code,属性会被改两次,后执行的覆盖先执行的;确定要自写时,记得把内置里对应的分支去掉或改认别的 code。
### 注册在哪里

View File

@ -49,24 +49,83 @@ mapDamage?.useConverter(new MainMapDamageConverter());
- 「捕捉」的视图类名沿用 `AmbushDamageView`(伏击),它读取的是 code 25「捕捉」。它返回 `MapDamageType.Unknown`,因为 `MapDamageType` 没有「捕捉」这一项;伤害恒为 0,作用是产生 `extra.catch`,由移动钩子触发强制战斗。捕捉本身不造成伤害是设计如此,不是未补齐的功能。
- `BetweenDamageView` 读取全局 flag `betweenAttackMax`,决定夹击伤害是否取「半血、当前怪物伤害、对侧怪物伤害」三者的较小值;对侧怪物通过 `hasSpecial(14)` 识别,与夹击自身使用的是同一个编号。
## 示例:八方向激光
## 改内置:调整内置视图与转换器
内置激光(code 22)用 `FaceGroup.Dir4`(上下左右四个方向)沿射线发射。下面的示例新增一个「八方向激光」特殊属性(code 43),改用 `FaceGroup.Dir8`(再加上四个斜向,共八个方向)发射,其余逻辑与内置激光一致。
内置五类地图伤害的视图与转换器都写在 `src/data/enemy/mapDamage.ts`,而且已经在 `initializeEnemy` 里注册好了。所以**改内置就是改这些类的现有方法、或往 `MainMapDamageConverter.convert` 里加一个分支**——不新建类,也不用再调用 `useConverter`。这是本篇的主线做法。
示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」;`map.set(...)` 的 `map` 是 `special.ts` 中 `createSpecials(state)` 建的属性表,见[特殊属性](./enemy-specials.md)。
先按「想改什么」找落点:
示例里出现的几个名字先简单说明:`FaceGroup.Dir8` 是「八方向」朝向组,`faceManager` 按朝向组管理朝向;`dir.mapMovement()` 把朝向处理器映射成各方向对应的移动量,示例据此取出八个方向(其中「原地不动」的未知方向偏移为零,`RayRange` 会自动跳过它);`IFaceHandler` / `IFaceDescriptor` 分别是「朝向处理器」与「单个朝向」的类型;`MapDamageType.Layer` 是伤害类型枚举里的「激光伤害」(定义在 `src/data/enemy/types.ts`)。
| 想改的东西 | 改哪里 |
| ---------------------- | --------------------------------------------------------------------- |
| 某类伤害的范围 | 对应视图的 `getRange` / `getRangeParam` |
| 某类伤害的数值或类型 | 对应视图的 `getDamageWithoutCheck` |
| 某类伤害读哪个特殊属性 | `MainMapDamageConverter.convert` 里对应的 `getSpecial` / `hasSpecial` |
| 多来源叠加的取舍规则 | `MainMapDamageReducer.reduce` |
| 某类伤害的触发条件 | 对应视图的 `getDamageWithoutCheck`(返回 `null` 即该点不受伤) |
### 改内置,还是自己写
五个视图都继承自 `BaseMapDamageView`,它已经实现了 `getDamageAt`(先判范围再取伤害)并提供 `createInfo(damage, type, extra?)` 帮你拼伤害信息。所以改一个视图时通常只需要碰 `getRangeParam` 与 `getDamageWithoutCheck` 两个方法。
- **改内置**:直接改 `src/data/enemy/mapDamage.ts` 里的 `LaserDamageView` / `MainMapDamageConverter`,或往转换器里加分支。内置五类地图伤害都实现在那里。
- **写自己的**:实现 `IMapDamageView`(描述一个来源的影响范围与某点的伤害)与 `IMapDamageConverter`(把特殊属性翻译成视图),再用 `useConverter` 注册。下面的示例就是这种写法。
### 示例:让激光也打斜向(八方向激光)
内置激光(code 22)用 `FaceGroup.Dir4`(上下左右四个方向)沿射线发射。要让同一条内置激光多覆盖四个斜向,最省事的做法是改内置:把转换器里取的朝向组换成 `FaceGroup.Dir8`,`LaserDamageView` 本身一行都不用改——它只是把传入的朝向处理器映射成方向列表,方向多了自然就多打四个斜向。
**目标**:内置激光(code 22)沿上下左右加四个斜向共八个方向发射,其余行为不变。
**改哪里**:`src/data/enemy/mapDamage.ts` 的 `MainMapDamageConverter.convert`(内置文件,改完不需要注册)。
**复制什么**:把取朝向处理器那一行换个方向组——
```ts
convert(
handler: IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>,
context: IEnemyContext<IEnemyAttr, IHeroAttr>
): IMapDamageView<any>[] {
const views: IMapDamageView<any>[] = [];
const { enemy, locator, hero } = handler;
// ...省略:领域 13、夹击 14、阻击 16 的分支,原样保留...
const laser = enemy.getSpecial<number>(22);
if (laser) {
// [需改] 内置是 FaceGroup.Dir4(四方向),改成 Dir8 即八方向
const face = handler.state.faceManager.get(FaceGroup.Dir8)!;
views.push(new LaserDamageView(context, locator, laser, face));
}
// ...省略:捕捉 25 的分支,原样保留...
return views;
}
```
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**改什么**:只需要把 `FaceGroup.Dir4` 换成 `FaceGroup.Dir8`。`LaserDamageView` 的构造函数把朝向处理器经 `dir.mapMovement()` 映射成方向列表,八方向组自然给出八项;其中「原地不动」的未知方向偏移为零,`RayRange` 会自动跳过它,不必额外处理。
**怎么验证**:给带激光(code 22)的怪物挂上后,站在它八个方向任一格的勇士都应受到伤害;斜向也要覆盖到(这正是与原本四方向激光的区别)。
**注意**:这次改的是**内置激光**,所有挂 code 22 的怪物都会变成八方向。如果只想让某一种新怪物打八方向、其余仍是四方向,就得给它一条新的特殊属性并另写转换器分支——那属于下一节的「自己写」。
### 另外几处常见的改法
- **领域(13)**:`ZoneDamageView.getRangeParam` 按 `square` 在矩形与曼哈顿之间切换。想换算法(例如改成全场),在模块顶部建一个 `const FULL_RANGE = new FullRange();`,让 `getRange` 返回它,并让 `getRangeParam` 返回 `undefined` 即可。
- **阻击(16)**:`RepulseDamageView.getRangeParam` 里的 `radius: 1` 决定触发距离,`getDamageWithoutCheck` 里的 `extra.repulse` 决定哪个怪物后退。想改触发距离,改 `getRangeParam` 的 `radius`。
- **捕捉(25)**:`AmbushDamageView` 的伤害恒为 0、只产出 `extra.catch`。要让捕捉附带伤害,改 `createInfo` 的第一个参数。
- **合并规则**:`MainMapDamageReducer.reduce` 现在是「伤害相加、类型取最大来源」。要改成取最大值、或优先某类型,改这个方法。
## 自己写地图伤害类
只有「内置五类都表达不了、往内置方法体里也塞不进去」时才需要自己写类。典型情况是:一条全新的特殊属性要有自己的范围形态与伤害算法,而它既不是射线、也不是矩形 / 曼哈顿。
自己写类要同时处理视图与转换器两个接口,还要用 `useConverter` 替换内置转换器,改动面比改内置方法体大得多。
**如果你不怎么懂代码,建议直接改内置的视图与转换器(`src/data/enemy/mapDamage.ts`),而不是自己写新的类。** 上面「改内置」一节已经覆盖了调范围、调数值、调方向、调合并规则这些需求;自写转换器意味着你要自己把内置五类视图也重新收集进去,否则它们会全部失效。
`useConverter` 是**替换**当前转换器:选了自己的就把内置的整个换掉,若要同时保留内置五类,需要在自己的转换器里把它们的视图也收集进去,或直接改内置转换器。
下面代码用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求改的行。
**目标**:新增一个「八方向激光」特殊属性(示例 code 43),沿上下左右加四个斜向共八个方向发射,其余与内置激光一致。
**目标**:新增一个「八方向激光」特殊属性(示例 code 43),沿上下左右加四个斜向共八个方向发射,且不影响内置的四方向激光。
**改哪里**:`src/data/enemy/special.ts` 登记属性;另建文件写伤害视图与转换器;`initializeEnemy` 里用 `useConverter` 替换(见下方「注册在哪里」)。
@ -187,7 +246,7 @@ mapDamage?.useConverter(new EightDirLaserConverter());
**改什么**:编号(`43`)、类名、方向组(本示例 `FaceGroup.Dir8`)与 `getDamageWithoutCheck` 返回的伤害 / 类型换成你自己的。
**怎么验证**:给带该属性的怪物挂上后,站在它八个方向任一格的勇士都应受到伤害;斜向也要覆盖到(这正是与内置四方向激光的区别)。
**怎么验证**:给带该属性的怪物挂上后,站在它八个方向任一格的勇士都应受到伤害;斜向也要覆盖到。同时确认原本带激光(code 22)的怪物仍按四方向工作——如果你没有在自己的转换器里收集内置视图,它不会再生效。
**注意**:`useConverter` 是**替换**当前转换器——选了自己的就把内置的整个换掉;要同时保留内置五类,需要在自己的转换器里把它们的视图也收集进去,或直接改内置转换器。
@ -216,8 +275,6 @@ export function initializeEnemy(
}
```
示例只用 `FaceGroup.Dir8` 替换内置的 `FaceGroup.Dir4`,其余与内置激光相同,就得到了八方向激光。
## 合并器
`MainMapDamageReducer.reduce` 的规则很直接:
@ -226,7 +283,7 @@ export function initializeEnemy(
- 取伤害最大的那条来源的 `type` 作为合并后的类型;
- 把所有来源的 `extra.catch` 与 `extra.repulse` 合并。
多个地图伤害落在同一格时,勇士只会受到合并后的一份伤害。需要不同的取舍规则时,实现 `IMapDamageReducer` 并 `useReducer` 替换即可。
多个地图伤害落在同一格时,勇士只会受到合并后的一份伤害。需要不同的取舍规则时,改内置的 `reduce` 方法即可;只有在整条合并逻辑都要换掉时才实现 `IMapDamageReducer` 并用 `useReducer` 替换。
## 实际结算

View File

@ -146,11 +146,11 @@ map.set(
);
```
## 自定义一个新特殊属性
## 改内置:往属性表里加一条属性
特殊属性没有「自己写一个类」的做法:这类数据本就登记在内置文件 `src/data/enemy/special.ts` 里,新增一条属性的方式就是**往这个文件里加一段 `map.set(...)`**(即下面步骤)。真正让它生效的代码,则按类型写在各内置实现文件里(见下方「属性实现位置」表)——这也是本篇与光环 / 查询那两篇最大的不同:那两边需要新建类,这里只需要改内置文件。
本篇的主线是**改内置**。特殊属性没有「自己写一个类」的做法:这类数据本就登记在内置文件 `src/data/enemy/special.ts` 里,新增一条属性的方式就是**往这个文件里加一段 `map.set(...)`**(即下面六步)。真正让它生效的代码,则按类型写在各内置实现文件里(见下方「属性实现位置」表)——这也是本篇与光环 / 查询那两篇最大的不同:那两边需要新建类,这里只需要改内置文件。
当你需要引擎里没有的机制时,就新增一条特殊属性。以「每回合给勇士造成固定伤害」为例(假设使用 code 50):
需要新机制时,先看它能不能归入已有的分类(调参、坚固 / 模仿、地图伤害、光环、状态钩子)。能归入,就照下面六步加一条属性,再在对应的内置实现里加一个分支;这是绝大多数需求的做法。以「每回合给勇士造成固定伤害」为例(假设使用 code 50):
**目标**:新增一条「每回合对勇士造成固定伤害」的特殊属性(示例 code 50)。
@ -213,3 +213,9 @@ map.set(
## 怪物比较
`MainEnemyComparer.compare` 负责判断两个怪物是否相同:先比较 `hp`、`atk`、`def`、`money`、`exp`、`point`,再逐个比较特殊属性的 `deepEqualsTo`。它用于怪物模板的脏数据判断。一般不需要改;只有当你希望「两个怪物是否视为同一模板」的判定标准发生变化时,才替换比较器。
## 如果内置实现里实在找不到落点
上面六步加上「属性实现位置」表,覆盖了内置特殊属性的全部实现分工。只有当你的机制与这八类都不同构、且无法拆成「属性 + 已有实现」时,才需要自己写实现代码:在 `src/data/enemy/` 下新建一个文件,按对应接口写一个类,再在 `initializeEnemy` 里注册。这时你仍然要回到本篇的 `special.ts` 登记这条属性,区别只是生效逻辑换了地方。
**如果你不怎么懂代码,建议直接改内置实现(`special.ts` 的属性定义 + 上表中的实现文件),而不是自己写新的类。** 自己写类要同时处理接口签名、注册时机与上下文刷新,改动面比在已有方法里加一个分支大得多,出错也更难排查。

View File

@ -47,6 +47,22 @@ state.hero.location.addHook(new EnemyChangeFloorHooks(state));
- 战斗流程:处理战前/战后;
- 勇士位置:处理切换楼层。这里的 `onSetFloor` 属于 `IHeroLocationHooks`,挂在 `state.hero.location` 上;它和[钩子](./hooks.md)里「切换楼层前后」的 `IHeroStateHooks.onBeforeChangeFloor` / `onAfterChangeFloor`(挂在 `state.hero` 上)是两族不同的钩子,别混用。
## 改内置:往内置钩子类里加逻辑
内置状态钩子一共四个类,全在 `src/data/enemy/hook.ts`,而且已经在 `initializeEnemy` 里注册好了。所以**改内置就是改这些类的现有方法体**——不新建类,也不用再调用 `addHook`。这是本篇的主线做法。
先按「什么时候生效」找落点:
| 想做的事 | 改哪里 |
| ---------------------- | ------------------------------------------------------- |
| 勇士每走一步时生效 | `EnemyMoveHooks.onStepEnd`(或它调用的 `handlePoison`) |
| 勇士瞬移后生效 | `EnemyPathfindingHooks.onAfterTeleportTo` |
| 战斗结束后生效 | `EnemyCombatHooks.onAfterCombat` |
| 切换楼层时生效 | `EnemyChangeFloorHooks.onSetFloor` |
| 影响勇士攻防的持续增减 | `EnemyCombatHooks` 的修饰器(构造时挂上,战后设值) |
内置 `EnemyCombatHooks.onAfterCombat` 已经按「中毒 → 衰弱 → 诅咒 → 仇恨 → 退化 → 重生」的顺序排好;新状态接在末尾即可。它开头还有两句状态校验(`info.handler.state !== this.state` 等),不要删——删了会让别的存档 / 沙盒里的战斗也触发这些效果。
## 战斗流程中的时序
战斗钩子的执行顺序是:
@ -58,6 +74,8 @@ state.hero.location.addHook(new EnemyChangeFloorHooks(state));
因此,想在「奖励结算之后」处理状态,应该用 `onAfterCombat`;想在战斗开始前拦截,应该用脚本的 `before`,见[战斗脚本](./enemy-combat-script.md)。
注意这个顺序带来一个有用的性质:**脚本的 `after` 每场只跑一次,且早于 `onAfterCombat`**。所以「本场消费、下场生效」的开关可以这样分工——脚本负责清掉上一场留下的标记,钩子负责在战斗结束时重新挂上。
## 示例:下次战斗伤害乘二
钩子能改状态,但「伤害乘二」最终要由伤害公式来兑现。这个例子展示一个状态如何跨多个系统:战斗钩子负责「挂上状态」,伤害计算器负责「读状态」,战斗脚本负责「一次性消费状态」。
@ -66,11 +84,11 @@ state.hero.location.addHook(new EnemyChangeFloorHooks(state));
不要在伤害计算器里 `deleteField` 之类地去「消费」这个 flag。计算器在一次战斗里会被反复调用(算临界、显示伤害、图鉴等),谁先调用谁就把 flag 用掉了,真正开打时反而不翻倍;而且这属于改动外部状态,违反「计算器必须无副作用」的规则(见[伤害计算](./enemy-calculator.md))。正确做法是:**计算器只读 flag**,由「一场战斗只跑一次」的**战斗脚本**去消费它。
:::
本示例要串起三个系统:先用战斗钩子在战斗结束时挂上「蓄力」状态,让伤害计算器只读这个 flag 把伤害翻倍,再由战斗脚本在一次战斗后把 flag 消费掉。
本示例要串起三个系统,三处都是改内置文件:在 `EnemyCombatHooks.onAfterCombat` 末尾挂上「蓄力」状态,在 `MainDamageCalculator.calculate` 里只读这个 flag 把伤害翻倍,在 `MainCombatScript.after` 里把 flag 消费掉。
**目标**:带「蓄力」(code 45)的怪物打完后,下一场战斗的伤害翻倍,且只翻倍一次。
**改哪里**:`src/data/enemy/special.ts` 登记属性;另建文件写战斗钩子与战斗脚本;`initializeEnemy` 里注册(见文末)。另外在伤害计算器里加一段只读逻辑(见第三步)。
**改哪里**:`src/data/enemy/special.ts` 登记属性;`src/data/enemy/hook.ts`、`calculator.ts`、`script.ts` 三处内置文件各加一段。全部是内置文件,改完不需要注册。
**复制什么**:先定义一个无参数特殊属性(code 45)——
@ -89,7 +107,74 @@ map.set(
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
**第一步:用战斗钩子「挂上状态」。** 它不修改内置的 `EnemyCombatHooks`,而是作为另一个钩子并存(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」):
**第一步:在战斗钩子里「挂上状态」。** 加在 `EnemyCombatHooks.onAfterCombat` 末尾——
```ts
async onAfterCombat(
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>
): Promise<void> {
// ...省略:内置的状态校验与中毒、衰弱、诅咒、仇恨、退化、重生各段...
// [需改] 带「蓄力」(code 45)的怪物战斗后,记下「下次战斗伤害翻倍」
if (info.handler.enemy.hasSpecial(45)) {
this.state.flags.setFieldValue('nextBattleDouble', true);
}
}
```
**第二步:在战斗脚本里「消费状态」。** 加在 `MainCombatScript.after` 末尾。它每场战斗只执行一次,而且早于 `onAfterCombat`,正好清掉上一场留下的标记:
```ts
async after(
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>,
handler: ICombatFlowHandler<IEnemyAttr, IHeroAttr>
): Promise<void> {
// ...省略:内置的经验金币结算、诅咒判断与全局提示...
// [需改] 战斗结束后清掉标记,保证只翻倍一次
handler.state.flags.deleteField('nextBattleDouble');
}
```
**第三步:让伤害计算器「只读」这个 flag**(不改、不删)。加在 `MainDamageCalculator.calculate` 返回之前——
```ts
// [模板] 放在 calculate 返回之前:只读,不修改任何外部状态
const double = handler.state.flags.getFieldValueDefaults(
'nextBattleDouble',
false
);
if (double) {
damage *= 2; // [需改] 翻倍逻辑;注意不要在这里 deleteField
}
```
**改什么**:编号(`45`)、flag 名(`nextBattleDouble`)、翻倍写法换成你自己的。
**怎么验证**:先打一场带「蓄力」的怪物(这场结束时 flag 被挂上),再打任意一只**不带**蓄力的怪物——第二场伤害应翻倍;接着打第三场,应恢复原值(第二场结束时 flag 已被脚本清掉)。如果第二场打的仍是带蓄力的怪物,它在结尾会重新挂上 flag,第三场就还会翻倍,这属于预期行为。
**注意**:这里要三个系统配合,缺一不可——钩子负责记录状态,计算器只读并翻倍,战斗脚本负责在真正的战斗里消费掉它。若把消费放进计算器,就会因计算器被反复调用(临界、显示、图鉴等)而提前把 flag 用掉,只有放进「每场战斗只跑一次」的脚本里才可靠。
三处都在内置类里,所以不必注册;如果你确实另写了钩子或脚本类,才需要在 `initializeEnemy` 里 `addHook` / `addCombatScript`(见下一节)。
状态里用到的临时加减或百分比,优先用修饰器实现,概念与 `save` 参数见[勇士属性](./hero-attribute.md)。
## 自己写钩子类
只有在「内置四个钩子类都放不下」时才需要自己写:比如这段逻辑要独立于内置状态、能单独注册或移除,或者你想让它排在某个内置钩子的前后。
自己写类要同时处理接口签名、挂载点与注册,而且同一个钩子接口上会与内置类并存、顺序不总由你控制,改动面比在内置方法体里加一段大。
**如果你不怎么懂代码,建议直接改内置钩子(`src/data/enemy/hook.ts` 的四个 `Enemy*Hooks` 类),而不是自己写新的类。** 上面「改内置」一节已经覆盖了移动、瞬移、战后、切层四个时机;自写钩子意味着你要自己确认挂载点、优先级与状态校验。
做法是实现对应接口的类,构造时保存 `state`,再 `addHook` 到对应挂载点:
- 移动:`state.hero.location.mover.addHook(new ...Hooks(state))`,实现 `IObjectMoverHooks`;
- 寻路:`state.pathfinding.addHook(new ...Hooks(state))`,实现 `IPathfindingSystemHooks`;
- 战斗:`state.combat.addHook(new ...Hooks(state))`,实现 `ICombatFlowHooks`;
- 切层:`state.hero.location.addHook(new ...Hooks(state))`,实现 `IHeroLocationHooks`。
下面仍以「蓄力」为例,演示自写类的形态:一个钩子负责挂状态,一个脚本负责消费(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」)。
```ts
// [需改] 类名
@ -108,13 +193,6 @@ export class ChargedHooks implements ICombatFlowHooks<IEnemyAttr, IHeroAttr> {
}
}
// [需改] 注册:挂到战斗流程上
state.combat.addHook(new ChargedHooks(state));
```
**第二步:用战斗脚本「消费状态」。** 脚本的 `after` 一场战斗只执行一次,正好用来把 flag 清掉:
```ts
// [需改] 类名
export class ConsumeDoubleScript implements ICombatScript<
IEnemyAttr,
@ -122,7 +200,8 @@ export class ConsumeDoubleScript implements ICombatScript<
> {
constructor(readonly state: ICoreState) {} // [模板] 存下状态以清 flag
readonly priority = 0; // [需改] 优先级按需要设置
// [需改] 优先级必须与内置脚本的 0 不同,否则 addCombatScript 会告警并忽略
readonly priority = -10;
// [模板] 本脚本不拦截战斗,直接放行
async before(): Promise<boolean> {
@ -136,29 +215,15 @@ export class ConsumeDoubleScript implements ICombatScript<
}
}
// [需改] 注册
// [需改] 注册:钩子挂到战斗流程,脚本追加进战斗流程
state.combat.addHook(new ChargedHooks(state));
state.combat.addCombatScript(new ConsumeDoubleScript(state));
```
**第三步:让伤害计算器「只读」这个 flag**(不改、不删):
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
```ts
// [模板] 放在 calculate 返回之前:只读,不修改任何外部状态
const double = handler.state.flags.getFieldValueDefaults(
'nextBattleDouble',
false
);
if (double) {
damage *= 2; // [需改] 翻倍逻辑;注意不要在这里 deleteField
}
```
**改什么**:编号(`45`)、类名、flag 名、`priority` 与翻倍写法换成你自己的。第三步「计算器只读」与上面改内置的写法完全相同,不再重复。
把这段读 flag 的逻辑放在 `calculate` 返回之前即可;伤害公式的其余部分见[伤害计算](./enemy-calculator.md)。
**怎么验证**:同「改内置」一节——打一场、再打一场,看第二场是否翻倍、第三场是否恢复。
**改什么**:编号(`45`)、类名、flag 名(`nextBattleDouble`)、翻倍写法与注册位置换成你自己的。
**怎么验证**:让带「蓄力」的怪物战斗一场,再打一场,看第二场伤害是否翻倍;连打第三场时应恢复原值(flag 已被消费)。
**注意**:这里要三个系统配合,缺一不可——钩子负责记录状态,计算器只读并翻倍,战斗脚本负责在真正的战斗里消费掉它。若把消费放进计算器,就会因计算器被反复调用(临界、显示、图鉴等)而提前把 flag 用掉,只有放进「每场战斗只跑一次」的脚本里才可靠。
状态里用到的临时加减或百分比,优先用修饰器实现,概念与 `save` 参数见[勇士属性](./hero-attribute.md)。
**注意**:自写钩子与内置 `EnemyCombatHooks` **同时**存在,不是替换。注册位置要放进 `initializeEnemy`(`src/data/enemy/initialize.ts`),与内置钩子那几行并列。