mirror of
https://github.com/motajs/template.git
synced 2026-10-31 22:00:19 +08:00
- 特殊属性实现写进已有文件(aura.ts 等),仅查询属性新建 query.ts
- aura.ts / query.ts 建议用 //#region {code}-{name} 包裹
- 通俗解释「副作用」;地图伤害按计算后怪物计算,光环加的特殊属性会真实生效
- 依赖并修改外部环境应拆成查询 + 光环、用新怪物属性桥接(指向高级教程)
- getCriticalLimit 提一句并指向高级教程;接口讲解改为「为了实现 X,改 Y 接口」
- 追加 batch 10 读者审阅记录
260 lines
15 KiB
Markdown
260 lines
15 KiB
Markdown
# 状态钩子
|
||
|
||
中毒、衰弱、重生这类状态有个共同点:它们不会在一次计算里结束,而是要在移动、战斗、切换楼层等时机反复生效。只靠一次属性调整实现不了,必须挂到这些时机上——这就是钩子(hook)。
|
||
|
||
而且这类状态往往不能只挂一个钩子。中毒要同时管正常走路与瞬移,重生要同时管战后与切层。钩子本身的用法见[钩子](./hooks.md),整体流程见[战斗总览](./combat-overview.md)。
|
||
|
||
## 为什么一个状态要用多个钩子
|
||
|
||
以中毒为例,它会从两个时机扣血:
|
||
|
||
- 勇士正常走一步时,在移动钩子的 `onStepEnd` 里扣血;
|
||
- 勇士通过寻路瞬移到目标点时,在寻路钩子的 `onAfterTeleportTo` 里按路径长度扣血。
|
||
|
||
只挂其中一个钩子,另一种移动方式就会漏掉中毒。重生也一样:战后要先记录位置,切层时再把怪物放回去,两个时机分别属于战斗钩子与楼层钩子。
|
||
|
||
## 三类内置状态
|
||
|
||
| 状态 | 使用的钩子 | 实现位置 | 行为 |
|
||
| ---- | --------------------------------- | -------------------------------------------- | ---------------------------------------------------- |
|
||
| 中毒 | `onStepEnd` + `onAfterTeleportTo` | `EnemyMoveHooks` / `EnemyPathfindingHooks` | 读取 `poison` flag,按移动扣血;瞬移时按路径长度处理 |
|
||
| 衰弱 | `onAfterCombat` + 修饰器 | `EnemyCombatHooks` | 战后取最高虚弱值,用 `PercentageModifier` 降低攻防 |
|
||
| 重生 | `onAfterCombat` + `onSetFloor` | `EnemyCombatHooks` / `EnemyChangeFloorHooks` | 战后记录 `rebirth` flag;切层时把记录的事件块放回 |
|
||
|
||
补充说明:
|
||
|
||
- 中毒的每步扣血由 `EnemyMoveHooks.handlePoison` 处理,移动钩子还会顺带调用 `handleMapDamage`;
|
||
- 衰弱在 `EnemyCombatHooks` 构造时向勇士的 `atk` 与 `def` 挂两个 `PercentageModifier`,战后通过 `setWeakValue` 设置负百分比;
|
||
- 重生的战后记录以楼层为键存入 `rebirth` flag,切换楼层时由 `EnemyChangeFloorHooks.onSetFloor` 读取并放回。
|
||
|
||
同一个 `onAfterCombat` 还处理诅咒、仇恨、退化等状态,因此新增状态时要先检查现有钩子是否已经覆盖了你的时机。
|
||
|
||
## 注册
|
||
|
||
这些钩子在 `initializeEnemy` 中注册:
|
||
|
||
```ts
|
||
state.hero.location.mover.addHook(new EnemyMoveHooks(state));
|
||
state.pathfinding.addHook(new EnemyPathfindingHooks(state));
|
||
state.combat.addHook(new EnemyCombatHooks(state));
|
||
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` 等),不要删——删了会让别的存档 / 沙盒里的战斗也触发这些效果。
|
||
|
||
## 战斗流程中的时序
|
||
|
||
战斗钩子的执行顺序是:
|
||
|
||
1. 战前脚本 `before`;
|
||
2. `onBeforeCombat` 钩子;
|
||
3. 战后脚本 `after`;
|
||
4. `onAfterCombat` 钩子。
|
||
|
||
因此,想在「奖励结算之后」处理状态,应该用 `onAfterCombat`;想在战斗开始前拦截,应该用脚本的 `before`,见[战斗脚本](./enemy-combat-script.md)。
|
||
|
||
注意这个顺序带来一个有用的性质:**脚本的 `after` 每场只跑一次,且早于 `onAfterCombat`**。所以「本场消费、下场生效」的开关可以这样分工——脚本负责清掉上一场留下的标记,钩子负责在战斗结束时重新挂上。
|
||
|
||
## 示例:下次战斗伤害乘二
|
||
|
||
钩子能改状态,但「伤害乘二」最终要由伤害公式来兑现。这个例子展示一个状态如何跨多个系统:战斗钩子负责「挂上状态」,伤害计算器负责「读状态」,战斗脚本负责「一次性消费状态」。
|
||
|
||
:::warning 计算器不能消费状态
|
||
不要在伤害计算器里 `deleteField` 之类地去「消费」这个 flag。计算器在一次战斗里会被反复调用(算临界、显示伤害、图鉴等),谁先调用谁就把 flag 用掉了,真正开打时反而不翻倍;而且这属于改动外部状态,违反「计算器必须无副作用」的规则(见[伤害计算](./enemy-calculator.md))。正确做法是:**计算器只读 flag**,由「一场战斗只跑一次」的**战斗脚本**去消费它。
|
||
:::
|
||
|
||
本示例要串起三个系统,三处都是改内置文件:在 `EnemyCombatHooks.onAfterCombat` 末尾挂上「蓄力」状态,在 `MainDamageCalculator.calculate` 里只读这个 flag 把伤害翻倍,在 `MainCombatScript.after` 里把 flag 消费掉。
|
||
|
||
**目标**:带「蓄力」(code 45)的怪物打完后,下一场战斗的伤害翻倍,且只翻倍一次。
|
||
|
||
**改哪里**:`src/data/enemy/special.ts` 登记属性;`src/data/enemy/hook.ts`、`calculator.ts`、`script.ts` 三处内置文件各加一段。全部是内置文件,改完不需要注册。
|
||
|
||
**复制什么**:先定义一个无参数特殊属性(code 45)——
|
||
|
||
```ts
|
||
// special.ts:45 - 蓄力
|
||
// [需改] 照常加进 src/data/enemy/special.ts
|
||
map.set(
|
||
45, // [需改] 编号
|
||
defineNonePropertySpecial(45, {
|
||
name: '蓄力', // [需改] 名称
|
||
desc: '战斗后,下一次战斗伤害翻倍。', // [需改] 说明
|
||
color: '#F99' // [需改] 颜色
|
||
})
|
||
);
|
||
```
|
||
|
||
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
|
||
|
||
**第一步:在战斗钩子里「挂上状态」。** 加在 `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`。
|
||
|
||
### 模板:复制什么、改什么
|
||
|
||
下面是状态钩子机制本身的骨架(以战斗钩子为例),不含任何具体状态。复制到 `src/data/enemy/hook.ts` 末尾后按 `[需改]` 修改(不要另建文件,内置状态的钩子类集中在这个文件里):
|
||
|
||
```ts
|
||
// [需改] 类名与接口:按上表换成你要挂的钩子接口
|
||
export class XxxHooks implements ICombatFlowHooks<IEnemyAttr, IHeroAttr> {
|
||
// [模板] 构造时保存数据端状态,供读写 flag、取勇士属性等
|
||
constructor(readonly state: ICoreState) {}
|
||
|
||
// [需改] 方法名:按上表换成 onStepEnd / onAfterTeleportTo / onAfterCombat / onSetFloor
|
||
async onAfterCombat(
|
||
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>
|
||
): Promise<void> {
|
||
// [需改] 你的状态逻辑:读状态、改 flag、挂修饰器等
|
||
}
|
||
}
|
||
```
|
||
|
||
注册到对应挂载点:
|
||
|
||
```ts
|
||
// [需改] 挂载点与方法签名要匹配(见上表)
|
||
state.combat.addHook(new XxxHooks(state));
|
||
```
|
||
|
||
复制后需要改的只有:类名、实现的接口、钩子方法名与内部逻辑、挂载点。一个状态要多个钩子时,可以写在同一个类里(实现多个接口),也可以拆成几个类分别挂载。
|
||
|
||
### 示例:蓄力(下次战斗伤害乘二)
|
||
|
||
下面仍以「蓄力」为例,演示自写类的形态。它只是填空示范,不是模板本身:一个钩子负责挂状态,一个脚本负责消费(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」)。
|
||
|
||
```ts
|
||
// [需改] 类名
|
||
export class ChargedHooks implements ICombatFlowHooks<IEnemyAttr, IHeroAttr> {
|
||
constructor(readonly state: ICoreState) {} // [模板] 存下数据端状态,供读写 flag
|
||
|
||
// [模板] onAfterCombat 签名固定:一场战斗结束后调用一次
|
||
async onAfterCombat(
|
||
info: IEnemyDamageInfo<IEnemyAttr, IHeroAttr>
|
||
): Promise<void> {
|
||
// [需改] 只有带「蓄力」(code 45)的怪物战斗后,才设置这个 flag
|
||
if (info.handler.enemy.hasSpecial(45)) {
|
||
// [需改] 在全局 flag 里记下「下次战斗伤害翻倍」
|
||
this.state.flags.setFieldValue('nextBattleDouble', true);
|
||
}
|
||
}
|
||
}
|
||
|
||
// [需改] 类名
|
||
export class ConsumeDoubleScript implements ICombatScript<
|
||
IEnemyAttr,
|
||
IHeroAttr
|
||
> {
|
||
constructor(readonly state: ICoreState) {} // [模板] 存下状态以清 flag
|
||
|
||
// [需改] 优先级必须与内置脚本的 0 不同,否则 addCombatScript 会告警并忽略
|
||
readonly priority = -10;
|
||
|
||
// [模板] 本脚本不拦截战斗,直接放行
|
||
async before(): Promise<boolean> {
|
||
return true;
|
||
}
|
||
|
||
// [模板] after 一场战斗只跑一次,是消费 flag 的正确位置
|
||
async after(): Promise<void> {
|
||
// [需改] 战斗结束后清掉 flag,保证只翻倍一次
|
||
this.state.flags.deleteField('nextBattleDouble');
|
||
}
|
||
}
|
||
|
||
// [需改] 注册:钩子挂到战斗流程,脚本追加进战斗流程
|
||
state.combat.addHook(new ChargedHooks(state));
|
||
state.combat.addCombatScript(new ConsumeDoubleScript(state));
|
||
```
|
||
|
||
> 复制说明:片段已省略 `import`;文件顶部通常已有,不要再粘。若编辑器提示缺少某个名字,再在顶部补对应的 `import`(详见 [代码基础](./code-basics.md) 的「照抄教程里的代码时,怎么处理 import」)。
|
||
|
||
**改什么**:编号(`45`)、类名、flag 名、`priority` 与翻倍写法换成你自己的。第三步「计算器只读」与上面改内置的写法完全相同,不再重复。
|
||
|
||
**怎么验证**:同「改内置」一节——打一场、再打一场,看第二场是否翻倍、第三场是否恢复。
|
||
|
||
**注意**:自写钩子与内置 `EnemyCombatHooks` **同时**存在,不是替换。注册位置要放进 `initializeEnemy`(`src/data/enemy/initialize.ts`),与内置钩子那几行并列。
|