# 状态钩子 中毒、衰弱、重生这类状态有个共同点:它们不会在一次计算里结束,而是要在移动、战斗、切换楼层等时机反复生效。只靠一次属性调整实现不了,必须挂到这些时机上——这就是钩子(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` 上)是两族不同的钩子,别混用。 ## 战斗流程中的时序 战斗钩子的执行顺序是: 1. 战前脚本 `before`; 2. `onBeforeCombat` 钩子; 3. 战后脚本 `after`; 4. `onAfterCombat` 钩子。 因此,想在「奖励结算之后」处理状态,应该用 `onAfterCombat`;想在战斗开始前拦截,应该用脚本的 `before`,见[战斗脚本](./enemy-combat-script.md)。 ## 示例:下次战斗伤害乘二 钩子能改状态,但「伤害乘二」最终要由伤害公式来兑现。这个例子展示一个状态如何跨多个系统:战斗钩子负责「挂上状态」,伤害计算器负责「读状态」,战斗脚本负责「一次性消费状态」。 :::warning 计算器不能消费状态 不要在伤害计算器里 `deleteField` 之类地去「消费」这个 flag。计算器在一次战斗里会被反复调用(算临界、显示伤害、图鉴等),谁先调用谁就把 flag 用掉了,真正开打时反而不翻倍;而且这属于改动外部状态,违反「计算器必须无副作用」的规则(见[伤害计算](./enemy-calculator.md))。正确做法是:**计算器只读 flag**,由「一场战斗只跑一次」的**战斗脚本**去消费它。 ::: 先定义一个无参数特殊属性(code 45)。下面代码中用 `[模板]` 标出可原样照抄的行,用 `[需改]` 标出必须按需求修改的行: ```ts // special.ts:45 - 蓄力 // [需改] 照常加进 src/data/enemy/special.ts map.set( 45, // [需改] 编号 defineNonePropertySpecial(45, { name: '蓄力', // [需改] 名称 desc: '战斗后,下一次战斗伤害翻倍。', // [需改] 说明 color: '#F99' // [需改] 颜色 }) ); ``` **第一步:用战斗钩子「挂上状态」。** 它不修改内置的 `EnemyCombatHooks`,而是作为另一个钩子并存(示例里的 `@motajs/*`、`@user/*` 是包名,含义见 [src 导览](./src-tour.md) 的「包从哪来」): ```ts // [模板] 依赖导入 import { IEnemyAttr, IHeroAttr } from '@user/data-common'; import { ICoreState } from '@user/data-state'; import { ICombatFlowHooks, IEnemyDamageInfo } from '@user/data-system'; // [需改] 类名 export class ChargedHooks implements ICombatFlowHooks { constructor(readonly state: ICoreState) {} // [模板] 存下数据端状态,供读写 flag // [模板] onAfterCombat 签名固定:一场战斗结束后调用一次 async onAfterCombat( info: IEnemyDamageInfo ): Promise { // [需改] 只有带「蓄力」(code 45)的怪物战斗后,才设置这个 flag if (info.handler.enemy.hasSpecial(45)) { // [需改] 在全局 flag 里记下「下次战斗伤害翻倍」 this.state.flags.setFieldValue('nextBattleDouble', true); } } } // [需改] 注册:挂到战斗流程上 state.combat.addHook(new ChargedHooks(state)); ``` **第二步:用战斗脚本「消费状态」。** 脚本的 `after` 一场战斗只执行一次,正好用来把 flag 清掉: ```ts // [模板] 依赖导入 import { IEnemyAttr, IHeroAttr } from '@user/data-common'; import { ICoreState } from '@user/data-state'; import { ICombatScript } from '@user/data-system'; // [需改] 类名 export class ConsumeDoubleScript implements ICombatScript< IEnemyAttr, IHeroAttr > { constructor(readonly state: ICoreState) {} // [模板] 存下状态以清 flag readonly priority = 0; // [需改] 优先级按需要设置 // [模板] 本脚本不拦截战斗,直接放行 async before(): Promise { return true; } // [模板] after 一场战斗只跑一次,是消费 flag 的正确位置 async after(): Promise { // [需改] 战斗结束后清掉 flag,保证只翻倍一次 this.state.flags.deleteField('nextBattleDouble'); } } // [需改] 注册 state.combat.addCombatScript(new ConsumeDoubleScript(state)); ``` **第三步:让伤害计算器「只读」这个 flag**(不改、不删): ```ts // [模板] 放在 calculate 返回之前:只读,不修改任何外部状态 const double = handler.state.flags.getFieldValueDefaults( 'nextBattleDouble', false ); if (double) { damage *= 2; // [需改] 翻倍逻辑;注意不要在这里 deleteField } ``` 把这段读 flag 的逻辑放在 `calculate` 返回之前即可;伤害公式的其余部分见[伤害计算](./enemy-calculator.md)。 这个例子体现了「一个状态要用多个系统」:`onAfterCombat` 钩子负责记录状态,计算器负责读取并翻倍,战斗脚本负责在真正的战斗里消费掉它。若把消费放进计算器,就会因计算器被反复调用而提前把 flag 用掉——只有放进「每次战斗只跑一次」的脚本里才可靠。 状态里用到的临时加减或百分比,优先用修饰器实现,概念与 `save` 参数见[勇士属性](./hero-attribute.md)。