13 KiB
钩子深入
中级教程的钩子回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。做法很简单——建议一个对象一个钩子实例。共享实例是允许的,但不建议;除非你明确了解钩子的内部实现与后果,否则不要这样做。
钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再给一个完整模板,最后是注册、移除与异步的接口事实。
阅读前需要会基础语法:class、new、import、async / await。本篇只讲数据端,示例都按当前源码里的接口书写。
建议一个对象一个实例
一个钩子类只是定义。真正参与事件的是你用 new 创建出来的实例,以及它被注册到的那个对象实例。
Hookable 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。所以:
- 同一个钩子类可以
new出多个实例,分别注册到不同对象; - 给图层 A 注册的钩子,不会出现在图层 B 上;
- 一个实例只应注册到一个对象。
以图层为例,GameMap.addLayer(alias) 每次调用都会创建一个新的 MapLayer。同一张地图的 ground 与 wall 是两个不同的 MapLayer 实例,各自有独立的钩子列表。地图对象本身也是 Hookable,但它的钩子列表与图层完全分开,接收的是另一套接口(IGameMapHooks 与 IMapLayerHooks),事件也从不同对象发出。
把实例固定在一个对象上,身份就固定在注册处:钩子在哪个对象上被触发,就是因为它注册在哪个对象上。多数钩子方法因此不需要再判断「这次是不是我要的对象」。把同一个实例注册到多个对象是允许的,但回调里就得自己区分触发者,还要承担共享状态带来的后果;除非你明确了解钩子的内部实现,否则不要这样做,本篇也不展开这种用法。
示例:图层级钩子
如果你想给某个图层挂一个只处理它的钩子,可以参考下面的模板:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。
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}`);
}
}
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():
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) 会收到该地图每个图层的尺寸变化——事件来自多个图层,对象却只有一个。这种回调里要自己筛掉不关心的图层:
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:
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():
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 导览。