template/docs/intermediate/enemy-status-hooks.md
unanmed 5c77676bca docs(10-11): 用户修订循环 batch2(代码讲解/修正)
按用户领域视角修订循环 batch 2(U-12/U-13/U-16/U-17/U-18/U-19)修正战斗各篇:

- U-12/U-16:转换器 / 视图 / 效果 / 计算器 / 脚本 / 钩子代码块补逐行中文注释,并用 [模板] / [需改] 标出可照抄与必须修改的部分
- U-13:新增「注册在哪里」,给出 initializeEnemy 函数体(无关行省略)与注册调用位置
- U-17:每篇明确「改内置实现」vs「写自己的类」的区别与各自效果
- U-18:enemy-map-damage 的「受地形遮挡的激光」示例替换为更简单的「八方向激光」(FaceGroup.Dir8)
- U-19:enemy-calculator 补「计算器必须无副作用」;enemy-status-hooks 的「下次战斗伤害乘二」改为钩子挂状态 + 计算器只读 + 战斗脚本消费

docs 8 篇;10-11-READER-REVIEW.md 追加 batch 2 记录。
2026-10-18 14:30:51 +08:00

8.0 KiB
Raw Blame History

状态钩子

中毒、衰弱、重生这类状态有个共同点:它们不会在一次计算里结束,而是要在移动、战斗、切换楼层等时机反复生效。只靠一次属性调整实现不了,必须挂到这些时机上——这就是钩子(hook)。

而且这类状态往往不能只挂一个钩子。中毒要同时管正常走路与瞬移,重生要同时管战后与切层。钩子本身的用法见钩子,整体流程见战斗总览。

为什么一个状态要用多个钩子

以中毒为例,它会从两个时机扣血:

  • 勇士正常走一步时,在移动钩子的 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 中注册:

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 上;它和钩子里「切换楼层前后」的 IHeroStateHooks.onBeforeChangeFloor / onAfterChangeFloor(挂在 state.hero 上)是两族不同的钩子,别混用。

战斗流程中的时序

战斗钩子的执行顺序是:

  1. 战前脚本 before;
  2. onBeforeCombat 钩子;
  3. 战后脚本 after;
  4. onAfterCombat 钩子。

因此,想在「奖励结算之后」处理状态,应该用 onAfterCombat;想在战斗开始前拦截,应该用脚本的 before,见战斗脚本。

示例:下次战斗伤害乘二

钩子能改状态,但「伤害乘二」最终要由伤害公式来兑现。这个例子展示一个状态如何跨多个系统:战斗钩子负责「挂上状态」,伤害计算器负责「读状态」,战斗脚本负责「一次性消费状态」。

:::warning 计算器不能消费状态 不要在伤害计算器里 deleteField 之类地去「消费」这个 flag。计算器在一次战斗里会被反复调用(算临界、显示伤害、图鉴等),谁先调用谁就把 flag 用掉了,真正开打时反而不翻倍;而且这属于改动外部状态,违反「计算器必须无副作用」的规则(见伤害计算)。正确做法是:计算器只读 flag,由「一场战斗只跑一次」的战斗脚本去消费它。 :::

先定义一个无参数特殊属性(code 45)。下面代码中用 [模板] 标出可原样照抄的行,用 [需改] 标出必须按需求修改的行:

// special.ts:45 - 蓄力
// [需改] 照常加进 src/data/enemy/special.ts
map.set(
    45, // [需改] 编号
    defineNonePropertySpecial(45, {
        name: '蓄力', // [需改] 名称
        desc: '战斗后,下一次战斗伤害翻倍。', // [需改] 说明
        color: '#F99' // [需改] 颜色
    })
);

第一步:用战斗钩子「挂上状态」。 它不修改内置的 EnemyCombatHooks,而是作为另一个钩子并存(示例里的 @motajs/*、@user/* 是包名,含义见 src 导览 的「包从哪来」):

// [模板] 依赖导入
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<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);
        }
    }
}

// [需改] 注册:挂到战斗流程上
state.combat.addHook(new ChargedHooks(state));

第二步:用战斗脚本「消费状态」。 脚本的 after 一场战斗只执行一次,正好用来把 flag 清掉:

// [模板] 依赖导入
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<boolean> {
        return true;
    }

    // [模板] after 一场战斗只跑一次,是消费 flag 的正确位置
    async after(): Promise<void> {
        // [需改] 战斗结束后清掉 flag,保证只翻倍一次
        this.state.flags.deleteField('nextBattleDouble');
    }
}

// [需改] 注册
state.combat.addCombatScript(new ConsumeDoubleScript(state));

第三步:让伤害计算器「只读」这个 flag(不改、不删):

// [模板] 放在 calculate 返回之前:只读,不修改任何外部状态
const double = handler.state.flags.getFieldValueDefaults(
    'nextBattleDouble',
    false
);
if (double) {
    damage *= 2; // [需改] 翻倍逻辑;注意不要在这里 deleteField
}

把这段读 flag 的逻辑放在 calculate 返回之前即可;伤害公式的其余部分见伤害计算。

这个例子体现了「一个状态要用多个系统」:onAfterCombat 钩子负责记录状态,计算器负责读取并翻倍,战斗脚本负责在真正的战斗里消费掉它。若把消费放进计算器,就会因计算器被反复调用而提前把 flag 用掉——只有放进「每次战斗只跑一次」的脚本里才可靠。

状态里用到的临时加减或百分比,优先用修饰器实现,概念与 save 参数见勇士属性。