# 钩子深入 中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。做法很简单——建议一个对象一个钩子实例。共享实例是允许的,但不建议;除非你明确了解钩子的内部实现与后果,否则不要这样做。 钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再给一个完整模板,最后是注册、移除与异步的接口事实。 阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。 ## 建议一个对象一个实例 一个钩子类只是定义。真正参与事件的是你用 `new` 创建出来的实例,以及它被注册到的那个对象实例。 `Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。所以: - 同一个钩子类可以 `new` 出多个实例,分别注册到不同对象; - 给图层 A 注册的钩子,不会出现在图层 B 上; - 一个实例只应注册到一个对象。 以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,但它的钩子列表与图层完全分开,接收的是另一套接口(`IGameMapHooks` 与 `IMapLayerHooks`),事件也从不同对象发出。 把实例固定在一个对象上,身份就固定在注册处:钩子在哪个对象上被触发,就是因为它注册在哪个对象上。多数钩子方法因此不需要再判断「这次是不是我要的对象」。把同一个实例注册到多个对象是允许的,但回调里就得自己区分触发者,还要承担共享状态带来的后果;除非你明确了解钩子的内部实现,否则不要这样做,本篇也不展开这种用法。 ## 示例:图层级钩子 如果你想给某个图层挂一个只处理它的钩子,可以参考下面的模板:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。 ```ts import { IMapLayer, IMapLayerHooks } from '@user/data-base'; import { ICoreState } from '@user/data-state'; // 图层级钩子:只处理注册它的那个图层 export class GroundBlockHooks implements IMapLayerHooks { constructor( readonly state: ICoreState, // 注册时传入的目标图层;这个实例只服务它 readonly layer: IMapLayer ) {} onUpdateBlock(block: number, x: number, y: number): void { // 触发者必然是 this.layer;参数里没有 layer 可比较 this.state.emitTip(`图层更新:(${x}, ${y}) -> ${block}`); } } ``` ```ts import { IMapLayerHookController } from '@user/data-base'; import { ICoreState } from '@user/data-state'; import { GroundBlockHooks } from './ground-block-hooks'; // 找到目标图层后注册;通常放在插件的 afterLoad 里 export function registerGroundHooks( state: ICoreState ): IMapLayerHookController | null { const map = state.maps.getMap('floor-1'); if (!map) { // afterLoad 时楼层未必已经构造出来,不要静默跳过 state.emitTip('floor-1 尚未构造,钩子未注册'); return null; } const layer = [...map.layerList].find(v => v.alias === 'ground'); if (!layer) { state.emitTip('ground 图层不存在'); return null; } // 注册到 layer,不是 map;返回控制器以便之后卸载 return layer.addHook(new GroundBlockHooks(state, layer)); } ``` 这个模板声明了 `GroundBlockHooks` 类,实现 `IMapLayerHooks`;`registerGroundHooks` 负责按 `alias` 找到目标图层,再调用 `layer.addHook` 注册实例。你需要改的是:目标楼层 id 与图层 `alias`、`onUpdateBlock` 的方法体,以及 `registerGroundHooks` 的调用位置(通常放在插件的 `afterLoad`)。 模板里的 `readonly layer` 让钩子能访问目标图层本身(读宽高、调用图层方法),不是用来比较一个不存在的参数。`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。 如果钩子要在构造时保持通用、注册时才确定图层,可以在 `awake` 里取控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`: ```ts import { ICoreState } from '@user/data-state'; import { IMapLayerHookController, IMapLayerHooks } from '@user/data-base'; // 节选:awake 同样写在类里,通过控制器拿到注册它的图层 export class UniversalBlockHooks implements IMapLayerHooks { constructor(readonly state: ICoreState) {} awake(controller: IMapLayerHookController): void { // controller.layer 就是注册这个钩子的图层 console.log(controller.layer.alias); } onUpdateBlock(block: number, x: number, y: number): void { this.state.emitTip(`图层更新:(${x}, ${y}) -> ${block}`); } } ``` ## 示例:地图级钩子筛图层 地图级钩子挂在 `map` 上,仍然是一个地图一个实例。但 `IGameMapHooks.onResizeLayer(layer, width, height)` 会收到该地图每个图层的尺寸变化——事件来自多个图层,对象却只有一个。这种回调里要自己筛掉不关心的图层: ```ts import { IGameMapHooks, IMapLayer } from '@user/data-base'; import { ICoreState } from '@user/data-state'; // 地图级钩子:一个地图一个实例,回调里自己筛图层 export class TargetLayerResizeHooks implements IGameMapHooks { constructor( readonly state: ICoreState, // 注册时传入的目标图层,用来和回调参数比较 readonly targetLayer: IMapLayer ) {} onResizeLayer(layer: IMapLayer, width: number, height: number): void { // 只处理目标图层;其它图层直接返回 if (layer !== this.targetLayer) return; this.state.emitTip(`目标图层尺寸:${width} x ${height}`); } } ``` 注册到 `map`,不是 `layer`: ```ts import { ICoreState } from '@user/data-state'; import { TargetLayerResizeHooks } from './target-layer-resize-hooks'; // 注册到 map,不是 layer export function registerTargetLayerHooks(state: ICoreState): void { const map = state.maps.getMap('floor-1'); if (!map) { state.emitTip('floor-1 尚未构造,钩子未注册'); return; } const targetLayer = [...map.layerList].find(v => v.alias === 'ground'); if (!targetLayer) { state.emitTip('ground 图层不存在'); return; } map.addHook(new TargetLayerResizeHooks(state, targetLayer)); } ``` 这里的比较用对象引用(`===`)。`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器,把图层的 `onResize` 转发到地图级钩子并带上具体图层。调用 `map.resizeLayer(30, 30, true)` 时每个图层都会触发一次,你的处理只应在目标图层上生效。 ## 注册与移除 `addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器。控制器最重要的方法是 `unload()`: ```ts import { IMapLayerHookController } from '@user/data-base'; // 保存注册时返回的控制器,之后需要撤掉时调用 export function removeGroundBlockHooks( controller: IMapLayerHookController ): void { controller.unload(); } ``` 几条接口事实: - `awake` 在钩子加入「已加载列表」之前调用,而 `forEachHook` 只遍历已加载列表。所以在 `awake` 里由其它代码触发的事件不会回调到自己;需要初始同步就直接在 `awake` 里做。 - `removeHook(hook)` 按对象引用查找,必须传注册时的同一个实例。传一个新建的同字段实例不会匹配,也不会报错。 - `addHook` 不去重。同一个实例注册两次会得到两个控制器、两条记录,实例被调用两次;第二次还会覆盖「实例到记录」的映射,第一次的记录之后很难按实例清理。一个实例只注册一次。 - `forEachHook` 的执行顺序不保证。需要顺序时,把步骤写进同一个钩子内部按 `await` 串行。 - 移除钩子时会调用 `destroy(controller)`,适合清理定时器与外部监听器。 - `GameMap.removeLayer(layer)` 会移除图层并卸载它为图层创建的内部转发器,但不会卸载你自己挂在图层上的钩子,也不会调用它们的 `destroy`。要释放资源,自己保存控制器并显式 `unload()`。 ## 异步钩子 钩子分同步与异步两类。异步钩子返回 `Promise`,引擎会把本次触发的返回值收集起来,用 `Promise.all` 并发等待: | 钩子族 | 返回 `Promise` 的方法 | 引擎如何等待 | | --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------- | | `IObjectMoverHooks` | `onMoveStart`、`onMoveEnd`、`onStepStart`、`onStepEnd` | 移动流程里用 `Promise.all` 等待 | | `IMapLayerHooks` | `onOpenDoor`、`onCloseDoor`、`onDeleteDynamic` | `openDoor` / `closeDoor` / `deleteDynamic` 用 `Promise.all` 等待 | | `IHeroStateHooks` | `onBeforeChangeFloor`、`onAfterChangeFloor` | `changeFloor` 用 `Promise.all` 等待 | | `ICombatFlowHooks` | `onBeforeCombat`、`onAfterCombat` | 战斗流程用 `Promise.all` 等待 | | `IReplaySandboxHooks` | `onStep` | 播放与单步流程用 `Promise.all` 等待 | 其余常用钩子,例如 `IMapLayerHooks.onUpdateBlock`、`IHeroLocationHooks.onSetPos`、`IGameEventExecutorHooks` 的事件前后钩子,都是同步的。 `Promise.all` 只保证「本次触发的所有异步钩子都完成」,不保证谁先完成。注册顺序与 `forEachHook` 的遍历顺序,都不能决定异步完成顺序。确实需要顺序时,把有依赖的步骤写进同一个钩子按 `await` 串行;战斗场景改用带 `priority` 的战斗脚本(细节见 `高级教程 II · 战斗脚本与优先级`)。不要试图在多个独立钩子之间约定顺序,钩子没有跨实例的优先级接口。 `onDeleteDynamic` 只在 `deleteDynamic` 这条异步删除路径上被 `Promise.all` 等待;`toStatic` 是同步转换,那里触发的 `onDeleteDynamic` 不会等待返回值。 > 钩子在系统级流程中如何被消费、跨系统如何安排优先级,属于 `高级教程 II · 系统级实现`;钩子调度的底层原理属于 `高级教程 III · 架构原理`。本篇只写你能直接使用的那一层。 ## 边界与注意 - 把地图级钩子挂到图层上。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用;反过来把图层钩子挂到地图上也不会收到图层事件。 - 把同一个实例注册到多个对象。接口允许,但不建议;除非你明确了解钩子的内部实现与后果,否则回调里区分触发者、共享状态带来的问题都会由你自己承担。 - 重复注册同一个实例。`addHook` 不去重,实例会被多次调用,旧控制器还留在内部列表里。 - 移除时 new 了一个新实例。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。 - 在 awake 里等自己触发的事件。`awake` 调用时钩子还不在已加载列表里,`forEachHook` 遍历不到它。 - 依赖异步钩子的顺序。引擎用 `Promise.all` 并发等待,完成顺序不等于注册顺序。 - 以为移除图层会顺带清理自己的钩子。`removeLayer` 只卸载内部转发器,你自己挂的钩子要显式 `unload()`。 ## 小结 - 建议一个对象一个钩子实例:实例注册到哪个对象,就只服务那个对象。共享允许但不建议,除非明确了解内部实现与后果。 - 参数里没有图层身份的钩子(如 `onUpdateBlock`)靠注册位置限定;地图级钩子收到多图层事件时,用 `===` 比较回调传来的对象。 - `addHook` 返回控制器,`removeHook` 要传原实例;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。 - `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。 返回[高级 I 导览](./index.md)。