# 钩子深入 中级教程的[钩子](../intermediate/hooks.md)篇回答的是「什么需求该用哪个钩子」;这一篇回答另一个问题:当同一个钩子类被多个地图、多个图层、多个移动器触发时,你怎么知道这次回调是不是你关心的那个对象。 钩子本身不难,难在身份。一张地图有多个图层,一个楼层集合里又有多张地图;给其中一个图层挂的钩子,不会自动只对那个图层生效,除非你在注册方式或回调里把身份限定住。限定错了,效果就会加到别的楼层或图层上。 阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。 ## 钩子挂在实例上 一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的实例,以及它被注册到的那个主对象实例。 `Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。因此: - 同一个钩子类可以 `new` 出很多个实例,分别注册到不同对象; - 给图层 A 注册的钩子,不会出现在图层 B 上; - 同一个实例也可以注册到多个对象,但这时它必须能在回调里区分身份,否则就会串。 以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,它的钩子列表与图层完全分开。 这也是为什么「给地图加钩子」和「给图层加钩子」不能混:它们接收的是两套不同的接口,事件也从不同的对象发出。 ## 多对象场景 先看几种容易出错的组合: | 场景 | 实际存在的对象 | | ---------------------- | --------------------------------------------------- | | 一张地图有多个图层 | 每个图层一个 `MapLayer`,各自独立 | | 楼层集合里有多张地图 | 每张地图一个 `IGameMap`,各自独立 | | 场上有多个动态图块 | 每个动态图块一个 `IDynamicTile`,各自带一个 `mover` | | 勇士与动态图块同时存在 | 勇士的移动器与图块的移动器是不同实例 | | 同一楼层 id 被重新创建 | 旧引用与新引用是两个 `IGameMap`,名字相同但引用不同 | 钩子被触发,只能说明「它所属的实例发生了事件」,不能说明那就是你想要的那个实例。比如地图级钩子的 `onResizeLayer(layer, width, height)` 会收到该地图每个图层的尺寸变化;如果你不比较 `layer`,就会对每个图层都执行一次。 地图与图层之间还有一层内部转发:`GameMap.addLayer` 会为每个图层注册一个 `StateMapLayerHook`,把图层的 `onResize` 转发到地图的 `onResizeLayer`,并带上具体 `layer`。所以地图级钩子能拿到图层身份;图层级钩子的大多数回调参数里没有 layer,只有动态图块方法能通过 `tile.layer` 间接拿到。 ## 身份从哪里来 判断能不能在回调里做身份校验,先看这个钩子族有没有把身份传回来: | 钩子族 | 回调里能拿到的身份 | 能否直接比较 | | -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------- | | `IMapLayerHooks.onResize` / `onUpdateArea` / `onUpdateBlock` | 没有 layer 参数,只有尺寸、区域或图块坐标 | 不能 | | `IMapLayerHooks.onOpenDoor` / `onCloseDoor` | 没有 layer 参数,只有门的位置或图块数字 | 不能 | | `IMapLayerHooks.onCreateDynamic` / `onDeleteDynamic` / `onUpdateDynamicPosition` | `tile`,而 `tile.layer` 是它所属的图层 | 可以比较 `tile.layer` | | `IGameMapHooks.onChangeBackground` | 只有背景图块数字 | 不能判断是哪张地图 | | `IGameMapHooks.onUpdateLayer` | `layerList`,当前地图的图层集合 | 只能看集合内容,不能直接判断是哪张地图触发 | | `IGameMapHooks.onResizeLayer` | `layer`、宽度、高度 | 可以比较 `layer`;`layer.map` 还能拿到所属地图 | | `IObjectMoverHooks` 的各方法 | `tile` 与 `mover` | 可以比较 `tile` 或 `mover` | | `IHeroLocationHooks.onSetFloor` | `map`,即设置后的楼层对象 | 可以比较 `map` | | `IHeroLocationHooks.onSetPos` | 只有坐标 | 不能;但勇士位置通常只有一个 | | `IHeroStateHooks.onBeforeChangeFloor` / `onAfterChangeFloor` | `info`,其中 `info.target` 是目标楼层 | 可以比较 `info.target` | 这张表决定了两种做法:没有身份参数的钩子,只能靠「每个对象一个实例」把身份固定在注册处;有身份参数的钩子,才可以共享一个实例,在回调里用 `===` 比较。 ### 不要用 alias 判断身份 `alias` 是图层的查找名,不是身份。两张不同的地图可以有同名图层;同一张地图上重复调用 `addLayer('ground')` 也会再建一个图层,并覆盖 `aliasLayerMap` 里的映射。用 `layer.alias === 'ground'` 只能说明「名字叫 ground」,不能说明「就是当初那个图层对象」。 要判断身份,用对象引用:`layer === this.targetLayer`、`map === this.targetMap`、`tile === this.targetTile`。引用比较不受重名影响。 ## 做法 A:每个对象一个钩子实例 这是图层钩子的默认做法,也是最不容易出错的做法。 构造钩子时把目标对象保存为字段,注册时只注册到那个对象上。以图层为例,构造时保存 `readonly layer`: ```ts // 每层一个实例的图层钩子 export class MyLayerHooks implements IMapLayerHooks { constructor( readonly state: ICoreState, readonly layer: IMapLayer ) {} // 这个方法没有 layer 参数;触发者就是注册时的 this.layer onUpdateBlock(block: number, x: number, y: number): void { // 只在这个图层上做事 } // 动态图块钩子会回传 tile,可以再核对一次 onCreateDynamic(tile: IDynamicTile): void { if (tile.layer !== this.layer) return; // 只处理目标图层上的动态图块 } } ``` 注意 `onUpdateBlock` 的签名里没有 `layer`。所以不要在里面写 `if (layer !== this.layer) return`,那个 `layer` 根本不存在。这个实例只注册在 `this.layer` 上,触发者必然是它;身份在注册时就确定了。 `readonly layer` 的作用是让钩子能访问目标图层本身,例如读宽高、调用图层方法,或者在动态图块回调里核对 `tile.layer`。它不是用来比较一个不存在的参数。 如果钩子是通用的、构造时还拿不到图层,可以利用 `awake` 收到的控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`: ```ts // 在 awake 里取得所属图层 awake(controller: IMapLayerHookController): void { // controller.layer 就是注册这个钩子的图层 } ``` 这种做法适合「先写一个通用钩子类,注册到哪一层就服务哪一层」的工厂。无论用哪种方式,核心都是:一个实例只服务一个对象。 ## 做法 B:共享实例,用 `===` 比较 如果钩子回调会回传身份,可以只创建一个实例,注册到多个对象,在回调里筛掉不关心的触发者。 例如地图级钩子 `IGameMapHooks.onResizeLayer(layer, width, height)` 会收到该地图所有图层的 resize。共享实例时,构造时保存目标图层 `targetLayer`: ```ts // 共享实例:在回调里比较目标图层 export class MyMapResizeHooks 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}`); } } ``` 同样的思路可以用于: - `IHeroLocationHooks.onSetFloor(map)`:比较 `map`; - `IObjectMoverHooks` 的 `onMoveStart` / `onStepEnd` 等:比较 `tile` 或 `mover`; - `IHeroStateHooks.onBeforeChangeFloor` / `onAfterChangeFloor`:比较 `info.target`; - `IMapLayerHooks` 的动态图块方法:比较 `tile.layer`。 但 `IMapLayerHooks` 的 `onResize`、`onUpdateBlock`、`onOpenDoor` 等方法没有 layer 参数,不能用这种做法。它们必须走做法 A。 ### 共享实例不要保存「当前对象」 共享实例只适合保存固定的目标,例如 `targetLayer`、`targetTile`。不要保存「当前正在处理的对象」: ```ts // 不要这样:共享实例里保存“当前对象” onResizeLayer(layer: IMapLayer): void { this.currentLayer = layer; // 后续异步逻辑再读 this.currentLayer } ``` 异步钩子可能交错执行:A 图层的事件还没处理完,B 图层的事件已经进来,`currentLayer` 会被覆盖。需要当前对象时,用回调参数或局部变量;需要跨异步步骤保留,就在回调开头把它存进局部常量。 ## 同一钩子被多个对象复用 「能共享」不等于「应该共享」。按钩子族分开看: | 钩子族 | 能否共享一个实例 | 条件 | | --------------------------------------- | ---------------- | ------------------------------------------------------------ | | `IMapLayerHooks`(无 layer 参数的方法) | 不能 | 回调无法区分图层,必须每层一个实例 | | `IMapLayerHooks`(动态图块方法) | 可以 | 回调里比较 `tile.layer` | | `IGameMapHooks.onResizeLayer` | 可以 | 回调里比较 `layer` | | `IGameMapHooks.onChangeBackground` | 不建议 | 回调没有地图身份;跨地图共享会分不清是谁的背景变化 | | `IGameMapHooks.onUpdateLayer` | 不建议 | 只有图层集合,无法可靠判断是哪张地图触发 | | `IObjectMoverHooks` | 可以 | 回调里比较 `tile` 或 `mover`;若每个对象有独立状态则不要共享 | | `IHeroLocationHooks.onSetFloor` | 可以 | 回调里比较 `map` | | `IHeroStateHooks`(切层钩子) | 可以 | 回调里比较 `info.target`;若只服务一个勇士则无需共享 | 如果钩子内部要为每个对象保存不同状态(计数、上次位置、临时标记),即使接口允许共享,也建议每对象一个实例。共享实例的字段只有一份,很难避免互相覆盖。 为多个对象创建实例时,用一个工厂把「创建 + 注册」收在一起,避免漏注册或注册到错误对象: ```ts // 为每个图层各建一个实例 function addLayerHook(state: ICoreState, layer: IMapLayer) { return layer.addHook(new MyLayerHooks(state, layer)); } ``` 调用方对每个目标图层调用一次即可。不要在事件回调里临时 `new` 钩子再注册;那样每次事件都会多一个钩子,且旧钩子很难清理。 ## 注册、移除与生命周期 ### addHook 返回控制器 `addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器: ```ts // 注册并保留控制器 const hook = new MyLayerHooks(state, layer); const controller = layer.addHook(hook); ``` 控制器最重要的方法是 `unload()`,它会按控制器移除对应钩子。对图层来说,控制器还带 `layer` 与 `getMapData()`,可以在 `awake` 或后续逻辑里使用。 ### awake 在加入已加载列表之前 `addHook` 的调用顺序是: 1. 创建控制器; 2. 写入映射与钩子列表; 3. 调用 `hook.awake(controller)`; 4. 把钩子加入已加载列表; 5. 返回控制器。 `forEachHook` 只遍历已加载列表。因此,在 `awake` 内部由其他代码触发的事件,不会回调到这个钩子——它还没进已加载列表。需要在启动时同步一次状态,就在 `awake` 里直接做,不要指望「在 `awake` 里调用 `setBlock` 会触发自己的 `onUpdateBlock`」。 ### 移除需要原实例 `removeHook(hook)` 按钩子对象的引用查找。传入注册时的**同一个实例**才会移除;传一个新建的、字段相同的实例不会匹配,也不会报错: ```ts // 正确:传回注册时的实例 layer.removeHook(hook); // 也可以:用注册时返回的控制器卸载 controller.unload(); ``` ```ts // 错误:这是一个新实例,removeHook 找不到它 layer.removeHook(new MyLayerHooks(state, layer)); ``` 移除时会调用钩子的 `destroy(controller)`,适合清理定时器、外部监听器等资源。`destroy` 与 `awake` 都收到控制器,可以在这里读取所属对象。 ### forEachHook 的顺序不保证 引擎文档明确写了 `forEachHook` 的执行顺序不固定。不要把「我先注册,所以先执行」当成契约。需要固定顺序时,把步骤写进同一个钩子内部按 `await` 串行,或改用本身带顺序语义的机制;不要依赖多个钩子的注册顺序。 ### 同一实例重复注册 `addHook` 不会去重。对同一个实例调用两次,会创建两个控制器、两条内部记录;结果是这个实例被调用两次,而且第一次的控制器仍然留在内部映射里: ```ts // 不要这样:同一个实例注册两次 layer.addHook(hook); layer.addHook(hook); ``` 第二次注册会覆盖 `hookMap` 里「实例到记录」的映射,但第一次的记录仍在已加载列表里。之后 `removeHook(hook)` 只会移除第二次的记录;如果你没有保存第一次返回的控制器,第一次的记录就很难再按实例清理。这是 `addHook` 的行为事实,不是引擎缺陷;按「每个实例只注册一次」使用即可。 ### 图层移除会卸载内部转发器 `GameMap.removeLayer(layer)` 会移除图层,并卸载 `addLayer` 时为它创建的内部 `StateMapLayerHook`。因此图层移除后,地图级钩子不会再收到这个图层的 resize。 但它不会清理你自己挂在图层上的钩子:图层自身的钩子列表仍然保留,也不会调用你钩子的 `destroy`。如果你还持有这个图层对象的引用,直接调用它的方法仍会触发那些钩子;要释放定时器、外部监听器等资源,需要自己保存 `addHook` 返回的控制器并显式 `controller.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` 的遍历顺序,都不能决定异步完成顺序。两个钩子都实现 `onStepEnd` 时,一个内部 `await` 了 100ms,另一个没有等待,后者会先兑现;这并不表示引擎把后者排在了前面。 确实需要顺序时: - 把有先后依赖的步骤写进同一个钩子,在方法体里按 `await` 串行; - 战斗场景改用带 `priority` 的战斗脚本,脚本会按优先级依次执行(细节见 `高级教程 II · 战斗脚本与优先级`); - 不要试图在多个独立钩子之间约定顺序,钩子没有跨实例的优先级接口。 这里有一处容易忽略的细节:`onDeleteDynamic` 在 `deleteDynamic` 这条异步删除路径上会被 `Promise.all` 等待;但 `toStatic` 是同步转换,那里触发的 `onDeleteDynamic` 不会等待返回值。不要把「声明为异步」误当成「所有调用路径都会等它」。 > 钩子在系统级流程中如何被消费、跨系统如何安排优先级,属于 `高级教程 II · 系统级实现`;钩子调度的底层原理属于 `高级教程 III · 架构原理`。本篇只写你能直接使用的那一层。 ## 示例:只处理指定图层 假设要只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。做法是写一个图层钩子类,把目标图层保存在构造参数里;由于 `onUpdateBlock` 没有 layer 参数,身份由「注册到哪个图层」决定。下面的类放在新文件里,顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IMapLayer, IMapLayerHooks } from '@user/data-base';`: ```ts export class GroundBlockHooks implements IMapLayerHooks { constructor( readonly state: ICoreState, // 注册时传入的目标图层;这个实例只服务它 readonly layer: IMapLayer ) {} onUpdateBlock(block: number, x: number, y: number): void { // 只在目标图层上做事;onUpdateBlock 没有 layer 参数,触发者必然是 this.layer this.state.emitTip(`ground 图层更新:(${x}, ${y}) -> ${block}`); } } ``` 然后在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中。这里的 `'floor-1'` 换成你的楼层 id,`'ground'` 换成目标图层 alias: ```ts const map = state.maps.getMap('floor-1'); // afterLoad 时楼层未必已经构造出来,不要静默跳过 if (!map) { state.emitTip('floor-1 尚未构造,钩子未注册'); return; } const layer = [...map.layerList].find(l => l.alias === 'ground'); if (!layer) { state.emitTip('ground 图层不存在'); return; } // 注册到 layer,不是 map;保存控制器以便之后卸载 const controller = layer.addHook(new GroundBlockHooks(state, layer)); ``` `afterLoad` 时 `floor-1` 未必已经由加载流程构造出来(取决于你的内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`。上面的提示只是示例,实际使用时至少要报出来,或者把注册推迟到楼层创建之后再执行。 注册后可以这样确认隔离:对目标图层调用 `layer.setBlock(1, 0, 0)`(换一个与原来不同的图块数字),提示应出现;从 `map.layerList` 里取另一个图层,调用它的 `setBlock(1, 0, 0)`,提示不应出现;调用 `controller.unload()` 后再改目标图层,提示不应再出现。 `onUpdateBlock` 只在图块数字真的变化时触发;设置成相同数字不会触发。不要用 `alias` 当身份,也不要把同一个钩子实例注册到所有图层。 ## 示例:地图级钩子里筛图层 换一个层级:地图级钩子 `IGameMapHooks.onResizeLayer` 会收到该地图所有图层的尺寸变化,因此必须在回调里自己筛。下面的类同样把目标图层固定为字段,但注册到 `map`: ```ts 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}`); } } ``` 这个新文件顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IGameMapHooks, IMapLayer } from '@user/data-base';`。注册代码同样放在能拿到 `state` 的时机方法里,这次注册到 `map`,不是 `layer`: ```ts const map = state.maps.getMap('floor-1'); if (!map) { state.emitTip('floor-1 尚未构造,钩子未注册'); return; } const targetLayer = [...map.layerList].find(l => l.alias === 'ground'); if (!targetLayer) { state.emitTip('ground 图层不存在'); return; } // 比较必须用对象引用,不能用 layer.alias map.addHook(new TargetLayerResizeHooks(state, targetLayer)); ``` 调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)即可验证:该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸;把 `targetLayer` 换成另一个图层,提示应对应另一层。 `onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器;`onChangeBackground` 没有地图参数,跨地图共享实例时无法判断是谁的背景变化。地图级钩子建议每张地图一个实例,除非你只使用带身份参数的方法。 ## 改内置还是自己写 如果你只是给现有系统加行为,先看 `src/data/hook/*` 里已经注册的类。它们已经在 `src/data/hook/initialize.ts` 的 `initializeHooks` 中 `addHook`,改方法体即可,不需要自己写类,也不需要再注册。 地图与图层没有对应的内置 `src/data/hook` 类;这类需求需要自己写一个小类。把类写小:只做身份限定与转发,真正的系统逻辑放进插件或独立系统,不要让钩子类承担一整套功能。 如果这是一整套新系统,优先通过插件实现(见[插件系统](./plugin-system.md)),钩子在事件发生时调用它。这样新增逻辑留在 `src` 里,升级内置包时要跟的差异更少。 ## 常见错误 - 把地图级钩子挂到图层上。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用。反过来,把图层钩子挂到地图上也不会收到图层事件。 - 用 alias 判断身份。alias 只是查找名,不同地图可以重名,重复 `addLayer` 还会覆盖映射。身份用对象引用比较。 - 共享实例却保存当前对象。异步钩子交错时,后一个对象会覆盖前一个对象的值。共享实例只保存固定的目标字段。 - 重复注册同一个实例。`addHook` 不去重,同一实例会被多次调用,旧控制器还留在内部列表里。每个实例只注册一次。 - 依赖异步钩子的顺序。引擎用 `Promise.all` 并发等待,完成顺序不等于注册顺序。需要顺序就把步骤放进同一个钩子串行。 - 在 awake 里等自己触发的事件。`awake` 调用时钩子还没加入已加载列表,`forEachHook` 不会遍历到它。初始同步直接在 `awake` 里做。 - 移除时 new 了一个新实例。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。 ## 验证 确认图层隔离:按「只处理指定图层」注册后,分别修改目标图层与另一个图层。目标图层应有提示,另一个图层不应有;把钩子注册到另一个图层,现象应对调。 确认地图级筛选:按「地图级钩子里筛图层」注册后,调用 `map.resizeLayer` 改一次尺寸。地图级钩子会对每个图层触发,但你的处理只应对目标图层生效。把比较条件删掉,提示会变成每个图层一次——这能反过来证明筛选确实在起作用。 确认移除生效:保存 `addHook` 返回的控制器,调用 `controller.unload()`,再触发同一事件,处理不应再执行。也可以改用 `removeHook(原实例)`,效果应一致;传一个新实例则不应生效。 确认异步完成顺序不等于注册顺序:注册两个都实现 `onStepEnd` 的异步钩子,第一个内部延迟 100ms,第二个不延迟。故意把慢的注册在前面,移动一步后,快的应先提示。这个现象只说明 `Promise.all` 并发等待,不说明引擎按注册顺序执行。 ## 小结 - 钩子挂在**实例**上,不挂在类上;同一个类可以服务多个对象,但每个对象的钩子列表独立。 - 没有身份参数的钩子(如 `IMapLayerHooks.onUpdateBlock`)必须每对象一个实例;有身份参数的钩子(如 `onResizeLayer`、`IObjectMoverHooks`)才能共享实例并用 `===` 比较。 - 不要用 alias 判断身份;对象引用才唯一。 - `addHook` 返回控制器,`removeHook` 要传原实例,控制器可 `unload`;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。 - `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。 返回[高级 I 导览](./index.md)。