mirror of
https://github.com/motajs/template.git
synced 2026-11-16 00:44:43 +08:00
docs(10-33): 高级 I 移入 i/ 并重建总导览与侧边栏
- 九个高级 I 页面 git mv 至 docs/advanced/i/ - docs/advanced/index.md 重建为高级教程总导览(III 暂不提供链接) - 侧边栏新增总导览入口,I 组九项改指 /advanced/i/ - 修正 I 页与高级 II 入链的跨层相对路径
This commit is contained in:
1 parent
64eef31075
commit
aee8627e95
14 files changed
+163
-121
No files matched your search
@@ -0,0 +1,256 @@
|
||||
# data-common 的 Store
|
||||
|
||||
`@user/data-common` 是数据端的 Layer 0 公共层,上面挂着五个内容注册表:`tileStore`、`itemStore`、`mapStore`、`enemyStore`、`eventStore`。它们看起来像「数据表」,实际是<strong>内容层原始定义的注册表</strong>——图块、道具、地图、怪物的定义登记在这里,游戏运行时从这里查出来,再按定义构造自己的对象;事件则直接以实例登记。
|
||||
|
||||
中级教程的[数据端总览](../../intermediate/data-state-overview.md)列过这些入口,[图块存储](../../intermediate/tile-store.md)讲了 `tileStore` 的日常用法。这一篇逐个讲每个 Store 存的是什么、有哪些常用成员、它自己能做什么,以及为什么运行期只该读。
|
||||
|
||||
阅读前需要会基础语法:`class`、`new`、`Map`、`import`。
|
||||
|
||||
## Store 是什么
|
||||
|
||||
Store 里存的是「定义」,游戏里跑的是按定义构造出来的「实例」。图块定义告诉你这个数字代表什么地形、能不能走,真正的图块对象则在楼层加载时按定义创建。`eventStore` 稍有不同:它存的就是事件实例本身,不再经过一次构造。
|
||||
|
||||
Store 是查表用的注册表,不进存档。勇士、地图、Flag 这些游戏状态才进存档;读档不会把 Store 退回原样,也不会恢复你运行期对它的改动。
|
||||
|
||||
运行时读 Store 是正常操作:查表、算数、拼提示文本、给界面提供数据都可以。运行时写 Store 会绕过加载、存档与录像这一整套机制,问题会在不确定的时机表现出来。
|
||||
|
||||
## 怎么读 Store
|
||||
|
||||
五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。它们不是需要你补全的半成品——接口已经由 `data-common` 提供,你只需要调用。
|
||||
|
||||
查询接口在找不到内容时不会抛错:取定义一般返回 `null`,换算 id 与数字一般返回 `undefined`,取图块类型返回 `TileType.Unknown`,取默认事件返回空 `Map`。所以读之前先判断结果是否存在,再使用。
|
||||
|
||||
下面每个 Store 各占一节,各配一段只针对它的示例。示例放在能拿到 `state` 的方法里即可,例如事件函数、钩子或插件方法。
|
||||
|
||||
## tileStore:图块定义
|
||||
|
||||
每条记录是一个 `ITileRawData`,以图块数字为键。常用成员有:
|
||||
|
||||
- `num`:图块数字,也是这张表的键;
|
||||
- `id`:图块字符串 id,供数据与代码按名字引用;
|
||||
- `events`:默认事件表,键是事件优先级,值是事件 id;
|
||||
- `type`:图块逻辑类型,取值包括 `Unknown`、`None`、`Terrain`、`Animate`、`Item`、`Enemy`、`Npc`、`Autotile`、`Tileset`;
|
||||
- `pass`:通行性对象,`onlyEvents` 表示是否仅当图块处在事件层时生效,`outPass` 与 `inPass` 是「可以离开」与「可以进入」的方向掩码,方向位由 `PassBit` 的 `Up`、`Right`、`Down`、`Left` 组合而成;
|
||||
- `eventPass`:触发器的触发方式——`false` 是撞上去触发,`true` 是走上去触发。
|
||||
|
||||
接口方面,`getData(num)` 按数字取完整定义,`getType(num)` 取逻辑类型,`getEvent(num)` 取默认事件。`num` / `id` / `identity` 在数字与 id 之间换算,`idToNumber` / `numberToId` 则是更直白的单向版本。登记新定义用 `addTile`;数字或 id 与已有定义冲突时会告警(错误码 `133` / `134`)并覆盖。
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { PassBit, TileType } from '@user/data-common';
|
||||
|
||||
// 按图块 id 读取定义;id 未知或没有对应定义时直接返回
|
||||
export function inspectTile(state: ICoreState, id: string): void {
|
||||
// num 接受图块数字或 id;id 不存在时返回 undefined
|
||||
const num = state.tileStore.num(id);
|
||||
if (num === undefined) return;
|
||||
|
||||
// 完整原始定义;图块数字不存在时返回 null
|
||||
const data = state.tileStore.getData(num);
|
||||
if (data === null) return;
|
||||
|
||||
// 逻辑类型:可以据此区分地形、道具、怪物等图块
|
||||
const type = state.tileStore.getType(num);
|
||||
|
||||
// 默认事件:键为优先级、值为事件 id;无事件时是空 Map
|
||||
const events = state.tileStore.getEvent(num);
|
||||
|
||||
// pass 的方向是位掩码,这里判断能否向上离开
|
||||
const canLeaveUp = (data.pass.outPass & PassBit.Up) !== 0;
|
||||
|
||||
// id / identity 可以反向换算,identity 返回 [数字, id]
|
||||
const backId = state.tileStore.id(num);
|
||||
const [sameNum, sameId] = state.tileStore.identity(num);
|
||||
|
||||
console.log(backId, sameNum, sameId, type === TileType.Terrain, canLeaveUp);
|
||||
console.log(data.eventPass, events.size);
|
||||
}
|
||||
```
|
||||
|
||||
## itemStore:道具定义
|
||||
|
||||
每条记录是一个 `IItemRawData<IHeroAttr>`,以道具图块数字为键。常用成员有:
|
||||
|
||||
- `num`:道具在地图上的图块数字,也是这张表的键;
|
||||
- `category`:道具分类,取值为 `Unknown`、`Constant`(永久)、`Consumable`(一次性)、`Pick`(即捡即用)、`Equipment`(装备);
|
||||
- `name` 与 `text`:显示名称与描述文本;
|
||||
- `hideInToolbox`:是否在道具栏中隐藏;
|
||||
- `effect`:使用或拾取时的效果,包含可选的 `useEvent`(事件 id)、`useEffect(item, state)` 与 `canUse(item, state)`;
|
||||
- `equip`:装备后的数据,包含可装备的槽位 `slots`、动画 `animate`、数值加成 `value`、百分比加成 `percentage`,以及穿上 / 脱下时的事件 `loadEvent` / `unloadEvent`。
|
||||
|
||||
`effect` 与 `equip` 都是必填:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。
|
||||
|
||||
接口方面,`getData(token)` 按数字或 id 取整份定义,`getCategory(num)` 只取分类。登记用 `addItem`;同一个图块数字重复登记会告警(错误码 `181`)并覆盖。
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { ItemCategory } from '@user/data-common';
|
||||
|
||||
// 判断某个道具现在能否使用;数据与状态都从 Store 和 state 取
|
||||
export function canUseItem(state: ICoreState, token: number | string): boolean {
|
||||
// token 可以是道具图块数字,也可以是图块 id
|
||||
const item = state.itemStore.getData(token);
|
||||
if (item === null) return false;
|
||||
|
||||
// 分类决定使用时机:永久与一次性是「使用」,Pick 是「拾取」
|
||||
const category = state.itemStore.getCategory(item.num);
|
||||
if (
|
||||
category !== ItemCategory.Constant &&
|
||||
category !== ItemCategory.Consumable
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// canUse 可选;没有实现时视为可以使用
|
||||
return item.effect.canUse?.(item, state) ?? true;
|
||||
}
|
||||
```
|
||||
|
||||
## mapStore:地图原始数据
|
||||
|
||||
每条记录是一张地图的 `IMapRawData`,以楼层 id 为键。常用成员有:
|
||||
|
||||
- `floorId`:楼层 id,也是这张表的键;
|
||||
- `width`:地图宽度,用来把一维下标换算成坐标;
|
||||
- `map`:按图层纵深组织的图块矩阵,外层键是图层纵深,值是该层按行排列的图块数字数组;
|
||||
- `layerAlias`:每个图层的字符串别名,键是图层纵深;
|
||||
- `events`:指定位置上的事件,外层键是图层纵深,中层键是图块位置索引,内层键是事件优先级、值是事件 id。
|
||||
|
||||
接口只有两个:`getMap(floorId)` 按楼层 id 取整份原始数据,不存在返回 `null`;`addMap(map)` 登记地图,同一个楼层 id 直接覆盖,不告警。Store 没有「列出全部楼层」的接口,查询前需要知道楼层 id。
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
// 读取某层某格的图块与事件;下标按「行优先」排列
|
||||
export function readFloorTile(
|
||||
state: ICoreState,
|
||||
floorId: string,
|
||||
x: number,
|
||||
y: number
|
||||
): void {
|
||||
// 整张地图的原始数据;楼层 id 不存在时返回 null
|
||||
const map = state.mapStore.getMap(floorId);
|
||||
if (map === null) return;
|
||||
|
||||
// 一维下标:同一层按行依次排列,所以是 y * width + x
|
||||
const index = y * map.width + x;
|
||||
|
||||
// map 的外层键是图层纵深,0 通常是地面层
|
||||
const ground = map.map[0];
|
||||
if (ground === undefined) return;
|
||||
|
||||
// layerAlias 给出该层的可读名字
|
||||
const alias = map.layerAlias[0];
|
||||
|
||||
// events 按「图层 → 位置索引 → 优先级」三层查找
|
||||
const eventsAtTile = map.events[0]?.[index] ?? {};
|
||||
|
||||
console.log(alias, ground[index], eventsAtTile);
|
||||
}
|
||||
```
|
||||
|
||||
## enemyStore:怪物定义
|
||||
|
||||
每条记录是一个 `IEnemyRawData<IHeroAttr>`,以怪物图块数字为键。常用成员有:
|
||||
|
||||
- `num`:怪物的图块数字,也是这张表的键;
|
||||
- `attribute`:怪物属性模板,里面是名称、生命、攻击、防御、金币、经验、加点等基础数值;
|
||||
- `special`:怪物拥有的特殊属性,键是特殊属性编号,值是该项特殊属性的参数。
|
||||
|
||||
特殊属性编号不在这条记录里定义,而是由 `src/data/enemy/special.ts` 的 `createSpecials` 统一注册;怪物数据只写编号与参数。
|
||||
|
||||
接口方面,`getEnemy(token)` 按图块数字或 id 取整份定义。登记用 `addEnemy`;同一个图块数字重复登记会告警(错误码 `181`,与道具共用)并覆盖。
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
// 读取怪物模板并预览成长后的基础数值
|
||||
export function previewEnemy(
|
||||
state: ICoreState,
|
||||
token: number | string,
|
||||
level: number
|
||||
): void {
|
||||
// token 可以是怪物图块数字,也可以是图块 id
|
||||
const enemy = state.enemyStore.getEnemy(token);
|
||||
if (enemy === null) return;
|
||||
|
||||
// attribute 是怪物属性模板,不是当前战斗中的怪物实例
|
||||
const attr = enemy.attribute;
|
||||
|
||||
// special 的键是特殊属性编号,值是该项的参数
|
||||
const specialIds = Object.keys(enemy.special);
|
||||
|
||||
console.log(attr.name, attr.hp * level, attr.atk * level);
|
||||
console.log(attr.def, attr.money, attr.exp, specialIds);
|
||||
}
|
||||
```
|
||||
|
||||
## eventStore:事件实例
|
||||
|
||||
存的是事件实例本身,不是事件定义。每条记录是一个 `IReadonlyGameEvent`,以事件 id 为键,取回的就是当初登记的那个对象。常用成员有:
|
||||
|
||||
- `interpreter`:事件解释器;
|
||||
- `trigger`:触发器类型,决定事件由什么触发;
|
||||
- `rawEvent`:原始事件数据;
|
||||
- `compiled`:已编译的事件数据,未编译时为 `null`;
|
||||
- `compile()`:编译事件;
|
||||
- `execute(param, env)`:执行事件,参数与环境由事件自身约定。
|
||||
|
||||
接口方面,`getEvent(id)` 按 id 取回存储的那个实例,不是副本;可以使用泛型标注参数、环境与返回值类型。登记用 `addEvent(id, event)`,第一个参数是事件 id,第二个才是实例;重复 id 会告警(错误码 `170`)并覆盖。
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
// 取出事件实例并执行;参数与环境按事件自身的约定填写
|
||||
export async function runStoredEvent(
|
||||
state: ICoreState,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
// 泛型依次是参数、环境与返回值;这里假设事件接收一个计数值
|
||||
const event = state.eventStore.getEvent<
|
||||
{ count: number },
|
||||
Record<string, unknown>
|
||||
>(id);
|
||||
if (event === null) return;
|
||||
|
||||
// trigger 说明它原本由什么触发;手动执行不受触发器限制
|
||||
console.log(event.trigger, event.compiled !== null);
|
||||
|
||||
// execute 返回 Promise,是否等待由调用方决定
|
||||
await event.execute({ count: 1 }, {});
|
||||
}
|
||||
```
|
||||
|
||||
## 为什么运行期只读
|
||||
|
||||
在游戏进行中调用 `add*`,问题不止一个:
|
||||
|
||||
1. <strong>它不进存档</strong>。五个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。读档不会恢复你的改动,也不会把它退回原样。
|
||||
2. <strong>它不进录像</strong>。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致。
|
||||
3. <strong>它绕过了检查</strong>。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。
|
||||
|
||||
所以规则很简单:<strong>登记只在加载期做一次,运行期只读</strong>。
|
||||
|
||||
至于登记这件事本身,绝大多数情况也不用你动手:`add*` 是给数据端自己用的接口——内容加载还没写完,写完后由数据端统一登记,不在你的代码里。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- <strong>需要自己补充 Store 定义的情况非常少见</strong>。`add*` 是数据端自用的登记接口:内容加载还没有写完,写完后由数据端统一登记。绝大多数情况下你只需要读,不需要自己补定义。确实需要补时,属于后续高级教程的内容(`高级 II · 加载与插件生命周期`,待编写),并且要把调用集中放在插件的一个时机方法里(见[插件系统](./plugin-system.md)),只在启动流程里执行一次。
|
||||
- <strong>道具定义的两个效果字段都是必填</strong>。`effect` 与 `equip` 不是二选一:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。
|
||||
- <strong>返回值不是副本</strong>。`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例;需要不同的定义就登记新的,不要改旧的。
|
||||
- <strong>`tileStore.getEvent` 是例外</strong>。它每次新建一个 `Map`,改它不会影响 Store,但也不要在上面做缓存并当成快照。
|
||||
- <strong>读档不会恢复 Store</strong>。改过的定义不会随存档保存,读档也不会把它退回去。
|
||||
- <strong>`eventStore.getEvent` 给的是实例本身</strong>,不要在上面做缓存并当成快照。
|
||||
- <strong>查询前先判空</strong>。定义可能返回 `null`,换算可能返回 `undefined`,类型可能是 `Unknown`,事件表可能是空 `Map`。
|
||||
|
||||
<!-- TODO(advanced-II): 在初始化阶段补充 Store 定义 -->
|
||||
|
||||
## 小结
|
||||
|
||||
- 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档;`eventStore` 存的是事件实例。
|
||||
- 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。
|
||||
- 每个 Store 只负责自己那类内容:读定义、做必要的换算,登记则由数据端在加载期统一完成。
|
||||
- 读随时可以;返回的通常是注册表里的实例,不要就地修改。
|
||||
- 登记由数据端负责,绝大多数情况不需要自己调用 `add*`,运行期尤其不要写。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
@@ -0,0 +1,242 @@
|
||||
# 声明式 vs 命令式接口
|
||||
|
||||
如果你已经会用中级教程里的接口做效果,可能注意过一件事:同样是「让勇士打出更高伤害」,有的写法是在事件脚本里直接改攻击力,有的写法只是换一个伤害计算器。这不是新旧语法的差别,而是两种完全不同的思路。
|
||||
|
||||
高级教程后续的插件、存档、录像与钩子,都建立在第二种思路上。这一层如果没弄明白,后面每篇都会变成死记接口。本篇把两种思路讲透,并告诉你什么时候该用哪一种。
|
||||
|
||||
阅读前你只需要会基础语法:变量、函数、`class` 与 `new`、`import`。不要求懂引擎内部结构;涉及内部机制的地方,我们会先说「你能拿它做什么」。
|
||||
|
||||
## 一句话区别
|
||||
|
||||
- <strong>命令式</strong>:你写一步,代码走到就执行一步。执行时机由你写的位置决定。
|
||||
- <strong>声明式</strong>:你声明规则、数据或对象,引擎在定义好的时机调用。执行时机由引擎决定。
|
||||
|
||||
可以把它想成点菜:命令式是你走进厨房自己开火、下锅、装盘,每一步都要亲自做,而且只做这一次;声明式是你把菜单交给厨房,说明「要什么」,厨房在出餐时按流程做好。
|
||||
|
||||
所以,声明式<strong>不是不写代码</strong>。你仍然要写「怎么算、怎么改」;只是把「做什么」与「什么时候做」分开了。分开之后,同一个规则可以被引擎在不同场景反复使用,而不必在每个调用点重写一遍。
|
||||
|
||||
## 命令式:写到就执行
|
||||
|
||||
旧样板里,很多效果是直接写在某段脚本里的:需要强化周围怪物,就在事件脚本里遍历它们、直接改攻击力;需要勇士回血,就直接改勇士当前的血量。
|
||||
|
||||
下面只是示意图,用来说明执行方式,<strong>不是可以照抄的新接口</strong>:
|
||||
|
||||
```ts
|
||||
// 旧样板示意(伪代码,不可照抄)
|
||||
// 事件脚本执行到这里时,直接遍历周围怪物,改写它们“当下”的攻击力
|
||||
function strengthenNearbyEnemies(nearbyEnemies: IEnemyState[]): void {
|
||||
for (const enemy of nearbyEnemies) {
|
||||
enemy.atk = enemy.atk + 10;
|
||||
}
|
||||
}
|
||||
|
||||
// 另一段脚本里,勇士回血也是直接改当前数值
|
||||
function healHero(hero: IHeroState<IHeroAttr>): void {
|
||||
hero.hp = hero.hp + 50;
|
||||
}
|
||||
```
|
||||
|
||||
这种写法的执行时机非常直观:代码走到哪,效果就发生到哪。但它会带来几个后果:
|
||||
|
||||
- <strong>只在执行到的那一瞬间生效</strong>。脚本没被触发,效果就不存在;触发第二次,效果就再来一次。
|
||||
- <strong>想重新计算只能重跑</strong>。怪物移动、周围条件变化之后,旧数值不会自动更新;再跑一遍脚本,又容易把加成叠加两次。
|
||||
- <strong>每条路径都要自己同步</strong>。存档、读档、录像回放、重新进入楼层,这些路径都可能绕开你写的那段脚本,于是状态对不上。
|
||||
- <strong>没法凭空给怪物加特殊属性</strong>。直接改 `atk` 只是改了一个数字,不能让怪物获得一条领域属性;特殊属性本身的写法见[特殊属性](../../intermediate/enemy-specials.md)。
|
||||
|
||||
这不是说命令式一无是处。玩家点一下、走一步、用一次道具,本来就是「现在立刻发生」的动作,用命令式接口最直接。问题只出在:把<strong>持续生效的规则</strong>也写成了一次性命令。
|
||||
|
||||
## 声明式:引擎在合适的时机执行
|
||||
|
||||
新样板把规则与调用时机拆开。下面五组都是当前源码里真实存在的机制,也是后续高级内容的基础。
|
||||
|
||||
### 伤害计算器
|
||||
|
||||
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。如果你要替换内置公式,可以参考下面的示例;它只负责声明计算规则,注册那一步由 `useCalculator` 完成:
|
||||
|
||||
```ts
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
CriticalableHeroStatus,
|
||||
IDamageCalculator,
|
||||
IEnemyDamageInfoBase,
|
||||
IReadonlyDamageHandler
|
||||
} from '@user/data-system';
|
||||
|
||||
// 伤害计算器:只读输入,返回这次战斗的伤害与回合数
|
||||
export class MyDamageCalculator implements IDamageCalculator<
|
||||
IEnemyAttr,
|
||||
IHeroAttr
|
||||
> {
|
||||
calculate(
|
||||
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>
|
||||
): IEnemyDamageInfoBase {
|
||||
// 按 handler 里的属性算出结果;这里只保留结构
|
||||
return { damage: 0, turn: 0 };
|
||||
}
|
||||
|
||||
getCriticalLimit(
|
||||
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>,
|
||||
attribute: CriticalableHeroStatus<IHeroAttr>
|
||||
): number {
|
||||
// 临界计算的上限;没有特殊需求就照内置实现保留
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
示例声明了 `MyDamageCalculator` 类,实现 `IDamageCalculator` 接口。`calculate` 是日常唯一需要关心的成员:它接收 `handler`,返回 `{ damage, turn }`;`getCriticalLimit` 只在临界值计算时用到,按内置实现 `MainDamageCalculator` 的写法保留即可。内置实现通过 `useCalculator(new ...)` 注册一次。
|
||||
|
||||
<strong>引擎在哪个时机执行</strong>:任何需要伤害数值的地方。显示伤害预览、计算临界值、真正结算战斗,都会调用计算器;同一场战斗里,它可能被反复调用很多次。
|
||||
|
||||
<strong>为什么这比命令式更稳</strong>:你只描述 `f(enemy, hero)` 这一条规则,所有调用点共用同一份结果。不需要在「显示」和「结算」两处各写一遍公式,也不会出现显示一个数、实际打另一个数。代价是计算器<strong>必须无副作用</strong>,下面会单独解释。
|
||||
|
||||
改公式与注册的完整做法见[伤害计算](../../intermediate/enemy-calculator.md)。
|
||||
|
||||
### 特殊属性与光环
|
||||
|
||||
<strong>你声明什么</strong>:特殊属性本身,而不是某只怪物的属性对象。它们在 `src/data/enemy/special.ts` 的 `createSpecials(state)` 里统一注册:每个编号对应一条定义,说明这个属性能做什么、参数长什么样;光环再把编号映射成一个转换器,在初始化时通过 `ctx.registerAuraConverter(...)` 注册一次。怪物数据里只出现编号与参数,不会出现手写的属性对象。
|
||||
|
||||
<strong>引擎在哪个时机执行</strong>:构建「计算后怪物」时。`state.enemyContext.buildup()` 按固定顺序重建每只怪物,把特殊属性、查询与最终效果写进属性。
|
||||
|
||||
<strong>为什么这比命令式更稳</strong>:每次重建都从模板的原始值出发,反复重建不会让加成越叠越多;你改的是注册表里的一条定义,所有读取计算后怪物的场景都会自动看到新结果;存档也只需要保存模板。更细的构建流程与特殊属性写法见[战斗总览](../../intermediate/combat-overview.md)与[特殊属性](../../intermediate/enemy-specials.md)。
|
||||
|
||||
### 道具效果
|
||||
|
||||
<strong>你声明什么</strong>:道具被使用或拾取时要做的事,即道具数据上的效果对象。要实现效果,实现 `IItemEffect` 的 `useEffect(item, state)`;如果还要判断能不能用,再实现 `canUse(item, state)`。
|
||||
|
||||
<strong>引擎在哪个时机执行</strong>:道具系统处理使用与拾取时。`Pick` 类道具在拾取时触发,`Constant` 与 `Consumable` 类道具在使用时触发。这个时机以及道具数量、消耗、存档与录像,都由道具系统统一处理。
|
||||
|
||||
<strong>为什么这比命令式更稳</strong>:你只声明「这个道具有什么效果」,不用自己处理扣数量、判定类别、记录录像。只要道具是通过正常入口使用的,效果就会在正确时机发生,也不会漏掉存档同步。
|
||||
|
||||
### 可存档对象
|
||||
|
||||
<strong>你声明什么</strong>:一份自定义状态该怎么存、怎么读。实现 `ISaveableContent<T>`:`saveState(compression)` 返回要保存的数据,`loadState(state, compression)` 负责把数据读回来。返回的对象必须与运行中的对象<strong>互相独立</strong>,`saveState` 不会替你深拷贝。
|
||||
|
||||
<strong>引擎在哪个时机执行</strong>:存档与读档时。你只需要注册一次,例如 `state.addSaveableContent('@my/xxx', content)`,之后引擎会在存档、读档时按注册表逐个调用。
|
||||
|
||||
<strong>为什么这比命令式更稳</strong>:存与读写在同一个接口里,不会出现「存的时候漏了一个字段、读的时候又忘了恢复」。注册一次之后,压缩级别、调用顺序、跨对象引用都由存档系统统一管理。完整约束见[可存档内容](./saveable-content.md)。
|
||||
|
||||
### 钩子
|
||||
|
||||
<strong>你声明什么</strong>:某个动作发生时要做的事。实现对应的钩子族接口,把处理写进方法体。比如「勇士属性变化时做点什么」,就实现 `IHeroAttributeHooks` 的 `onUpdateAttribute`,再像下面这样注册一次:
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { MyHeroAttributeHooks } from './hero-attribute-hooks';
|
||||
|
||||
// 注册钩子:new 一个钩子实例,挂到关心的主对象上
|
||||
export function registerHeroAttributeHooks(state: ICoreState): void {
|
||||
// 钩子类实现 IHeroAttributeHooks,这里只负责创建并注册
|
||||
state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
|
||||
}
|
||||
```
|
||||
|
||||
<strong>引擎在哪个时机执行</strong>:对应动作发生时。主对象会通过 `forEachHook` 依次通知挂在自己身上的钩子。不需要时可以用 `removeHook` 摘掉。内置钩子已经在初始化时注册好,你自己写的钩子才需要 `addHook`。
|
||||
|
||||
<strong>为什么这比命令式更稳</strong>:你不需要去改每一个调用点。动作发生时引擎统一广播,多个钩子可以同时存在、各自处理;不再需要时也能干净地移除。完整用法见[钩子深入](./hooks-in-depth.md)。
|
||||
|
||||
这五组的共同点是:你写的不是一串执行步骤,而是「一份规则加一个注册动作」。注册之后,调用时机归引擎。你不需要,也不应该自己再找地方调用它。
|
||||
|
||||
## 对照表
|
||||
|
||||
| 对比项 | 旧样板:命令式 | 新样板:声明式 |
|
||||
| ---------- | ------------------------------ | -------------------------------------------- |
|
||||
| 你写什么 | 一步一步的操作 | 规则、数据或对象 |
|
||||
| 何时执行 | 代码被执行到的那一刻 | 引擎定义好的时机(构建、使用、存档、事件等) |
|
||||
| 执行次数 | 你写几次就执行几次 | 由引擎决定,可能反复调用 |
|
||||
| 副作用 | 每次都要自己保证一致 | 计算类必须无副作用,状态修改走指定时机 |
|
||||
| 存档与读档 | 自己记录、自己恢复 | 声明 `saveState` / `loadState`,引擎统一调用 |
|
||||
| 录像 | 容易漏记,回放结果对不上 | 入口动作记入录像,派生结果重算 |
|
||||
| 组合方式 | 脚本顺序就是执行顺序,容易覆盖 | 注册表与优先级组合,可叠加、可移除 |
|
||||
| 常见错误 | 忘记在别的路径同步状态 | 把声明式当成不用写逻辑,或在计算器里改状态 |
|
||||
|
||||
## 为什么这样设计
|
||||
|
||||
把执行时机交给引擎,换来的是下面几件事:
|
||||
|
||||
1. <strong>时机集中</strong>。所有伤害计算都在需要数值时执行,所有存档对象都在存档时执行。调用点只有引擎一处,不会出现「这个效果我在 A 处记得调用、在 B 处忘了」。
|
||||
2. <strong>每次重新计算,避免叠加</strong>。计算都从当前输入重新得出结果,算出来的是「现在应该是什么样」,而不是「上次结果上再加一点」。命令式反复执行同一段脚本,很容易越加越多。
|
||||
3. <strong>按注册表与优先级组合</strong>。计算器、效果、钩子都注册到对应系统里,多个扩展可以同时存在;需要顺序时由优先级决定,而不是靠你调整脚本摆放位置。
|
||||
4. <strong>录像只记录玩家入口动作</strong>。玩家的移动、使用道具等动作进入录像,伤害、属性变化这些派生结果在回放时重新计算。如果派生结果依赖某段命令式脚本执行了几次,回放就会和录制时对不上。
|
||||
|
||||
## 「副作用」是什么
|
||||
|
||||
所谓副作用,就是<strong>除了返回结果之外,还顺手改动了外部状态</strong>。比如写了一个 flag、删了一条记录、改了怪物或勇士的属性,这些都属于副作用。
|
||||
|
||||
看一个反例:
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
CriticalableHeroStatus,
|
||||
IDamageCalculator,
|
||||
IEnemyDamageInfoBase,
|
||||
IReadonlyDamageHandler
|
||||
} from '@user/data-system';
|
||||
|
||||
// [反例] 有副作用的计算器:算出结果的同时,还改了外部状态
|
||||
export class BadDamageCalculator implements IDamageCalculator<
|
||||
IEnemyAttr,
|
||||
IHeroAttr
|
||||
> {
|
||||
constructor(private readonly state: ICoreState) {}
|
||||
|
||||
calculate(
|
||||
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>
|
||||
): IEnemyDamageInfoBase {
|
||||
const damage = 100; // 这里只是示意,真实公式会读 handler
|
||||
// 副作用:把这次结果写进 flag
|
||||
this.state.flags.setField('lastDamage', damage);
|
||||
return { damage, turn: 1 };
|
||||
}
|
||||
|
||||
// 临界计算同样会被反复调用,这里只保留结构
|
||||
getCriticalLimit(
|
||||
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>,
|
||||
attribute: CriticalableHeroStatus<IHeroAttr>
|
||||
): number {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
计算器在一次战斗里会被反复调用:显示伤害要算、临界值要二分十几次、真正结算还要算。只要它顺手改了状态,第二次调用的输入就可能已经被第一次改过了。结果是显示、临界与实际战斗互相污染。
|
||||
|
||||
需要说明的是,副作用本身不是错。钩子、道具效果、存档对象本来就要改状态;错的是在<strong>只该读输入、且会被反复调用</strong>的位置改状态。判断标准很简单:这个方法会不会被引擎多次调用?如果会,它就应该只读输入、返回结果。
|
||||
|
||||
## 什么时候仍然用命令式
|
||||
|
||||
声明式不是万能的。下面这些场景仍然应该用命令式接口:
|
||||
|
||||
- <strong>玩家直接产生的一次性动作</strong>:走一步、使用一个道具、触发一段一次性剧情。它们的语义就是「现在立刻发生」。
|
||||
- <strong>只在该时刻生效、且不需要回放的脚本</strong>:如果一段脚本的效果本来就只应该发生一次,写成命令式没有问题。
|
||||
- <strong>需要立即得到返回值再决定下一步的流程</strong>:比如先判断能否使用道具,再决定是否扣除数量。
|
||||
|
||||
但要记住一条边界:<strong>这些入口动作必须计入录像</strong>。玩家按下的操作进入录像,回放时重新走一遍命令式接口,派生结果再由声明式规则重算。具体要求见[录像一致性](./replay-consistency.md)。
|
||||
|
||||
可以用一句话概括:<strong>一次性动作用命令式,持续生效的规则用声明式</strong>。
|
||||
|
||||
## 扩展点一览
|
||||
|
||||
前面五组机制各自要写什么、注册到哪里、引擎什么时候调用,放在一起看更清楚:
|
||||
|
||||
| 扩展点 | 你要实现的东西 | 注册入口 | 引擎调用时机 |
|
||||
| ---------- | ------------------------------------------ | ---------------------------------------- | ---------------------- |
|
||||
| 特殊属性 | `createSpecials` 里的一条定义 | `initializeEnemy` 统一注册,无需单独调用 | 读取或重建计算后怪物时 |
|
||||
| 光环 | 一个转换器与一个视图 | `ctx.registerAuraConverter(new …)` | 重建计算后怪物时 |
|
||||
| 伤害计算器 | `IDamageCalculator.calculate` | `useCalculator(new …)` | 显示、临界、战斗结算时 |
|
||||
| 道具效果 | `IItemEffect.useEffect` | 写在道具数据里 | 使用或拾取道具时 |
|
||||
| 可存档对象 | `ISaveableContent.saveState` / `loadState` | `state.addSaveableContent(id, content)` | 存档与读档时 |
|
||||
| 钩子 | 对应钩子族接口的方法 | `addHook(new …)` | 对应动作发生时 |
|
||||
|
||||
这些扩展点大多遵循「实现一个类,再注册一次」:类里只放引擎会调用的方法,注册放在初始化函数里。道具效果写在道具数据上,不需要单独注册。
|
||||
|
||||
## 小结
|
||||
|
||||
- 命令式是「写到就执行」,声明式是「声明规则,引擎在合适时机执行」。
|
||||
- 声明式不是不写代码,而是把「做什么」与「什么时候做」分开。
|
||||
- 持续生效的规则用声明式;玩家直接产生的一次性动作用命令式,但要计入录像。
|
||||
- 计算类扩展必须无副作用;状态修改放到引擎规定的时机里。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
@@ -0,0 +1,356 @@
|
||||
# FaceHandler 系列
|
||||
|
||||
方向在数据端有两个落点。一个是逻辑朝向 `FaceDirection`:勇士朝哪、动态图块这一步朝哪,都用它表示。另一个是图块数字:同一个角色可能有多张不同朝向的图,换方向时要换成对应的数字。
|
||||
|
||||
两个落点之间夹着一套换算。`IFaceHandler` 把任意朝向变成本组支持的方向、位移、反方向与旋转;`IRoleFaceBinder` 把朝向映射成图块数字。
|
||||
|
||||
本篇只讲数据端。这些值最后怎么画、动画怎么播,由渲染端读取后完成。
|
||||
|
||||
<!-- TODO(renderer): 朝向图块绘制 -->
|
||||
|
||||
## 朝向模型
|
||||
|
||||
朝向在数据端分两层:**逻辑朝向**决定对象怎么走、怎么转向;**图块数字**决定画哪张图。两层之间由五个组件连接:
|
||||
|
||||
| 组件 | 是什么 | 负责 |
|
||||
| ----------------- | -------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `FaceDirection` | 方向值,`const enum`,含 `Unknown` 在内共九个成员 | 移动方向、朝向、寻路步骤里的 `dir` |
|
||||
| `FaceGroup` | 方向组的编号,内置 `Dir4` 与 `Dir8`,表示「这一组认哪些方向」 | 从 `state.faceManager` 取处理器时作为 key |
|
||||
| `IFaceHandler` | 朝向处理器,把任意方向换算成本组支持的方向、偏移、反方向与旋转 | 移动器构造、寻路邻域、图块转向 |
|
||||
| `IFaceDescriptor` | 只读的 `{ x, y }` 增量,表示朝某方向走一步坐标加多少 | 处理器返回位移时使用 |
|
||||
| `IRoleFaceBinder` | 朝向图块绑定器 | 把朝向映射成图块数字 |
|
||||
|
||||
一条链路服务于移动:移动器或寻路拿到一个 `IFaceHandler`,把 `FaceDirection` 换成坐标偏移,用它算落点、扩展邻域。另一条链路服务于显示:`IRoleFaceBinder` 按「当前图块数字 + 目标朝向」查出该朝向对应的图块数字,换向时换图。
|
||||
|
||||
`IFaceHandler` 与 `IRoleFaceBinder` 都是共享对象:处理器不保存「当前处理的是哪个对象」的状态,绑定器保存的是图块数字之间的映射。两者都不直接绘制,渲染端读取结果后完成显示。
|
||||
|
||||
`FaceDirection` 的成员依次是 `Unknown`、`Left`、`Up`、`Right`、`Down`,再加上四个斜向 `LeftUp`、`RightUp`、`LeftDown`、`RightDown`。它是 `const enum`,数值由声明顺序决定;不要按数值硬编码,一律写成员名。`Unknown` 表示没有方向,它也会出现在处理器的输入与输出里。
|
||||
|
||||
`FaceGroup` 只有 `Dir4` 与 `Dir8` 两个成员,分别表示四方向(上下左右)与八方向(上下左右加斜向)。它本身不携带行为,只是一个编号;真正的换算逻辑在对应的 `IFaceHandler` 上。取处理器时写 `FaceGroup.Dir4` / `FaceGroup.Dir8`,不要自己写 `0`、`1`。
|
||||
|
||||
还有一个配套类型 `IFaceDescriptor`,它是只读的 `{ x, y }` 增量,处理器用它表示「朝这个方向走一步,坐标加多少」。它不是坐标,而是位移量。
|
||||
|
||||
## 拿到朝向处理器
|
||||
|
||||
`state.faceManager` 是朝向处理器的注册表,有两种取法:按数字分组取,或按字符串 id 取。两者找不到时都返回 `null`。
|
||||
|
||||
```ts
|
||||
import { FaceDirection, FaceGroup } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
function stepUp(state: ICoreState): void {
|
||||
const dir8 = state.faceManager.get<FaceDirection>(FaceGroup.Dir8);
|
||||
if (!dir8) {
|
||||
// 启动期没有注册时才会走到这里
|
||||
console.log('八方向处理器未注册');
|
||||
return;
|
||||
}
|
||||
|
||||
// 按字符串 id 取到的是同一个实例
|
||||
const byId = state.faceManager.getById('dir8');
|
||||
console.log(dir8 === byId); // true
|
||||
|
||||
const offset = dir8.movement(FaceDirection.Up);
|
||||
console.log(offset); // { x: 0, y: -1 }
|
||||
}
|
||||
```
|
||||
|
||||
`get` 的泛型决定返回的 `IFaceHandler<T>` 里 `T` 是什么:不写泛型时 `T` 退化为 `number`,`degrade`、`opposite`、`next` 的静态类型都会是 `number`;需要 `FaceDirection` 的强类型时写 `get<FaceDirection>(...)`。
|
||||
|
||||
内置处理器在启动期注册,四方向与八方向各注册了数字 key 与字符串 id。启动之后按约定只读使用:运行期再注册只会影响之后取用它的地方,不会替换已经构造好的对象里保存的处理器。
|
||||
|
||||
同一个角色的图块数字映射由 `state.roleFace` 负责,见下文「朝向图块映射」。
|
||||
|
||||
## 常用方法
|
||||
|
||||
八个方法可以按「输入方向、输出什么」分成三组:降级与换算、遍历、由偏移反推。`degrade` 是核心,因为调用方可以传进任意方向值,包括本组不认的方向。
|
||||
|
||||
| 方法 | 作用 | 输入先 `degrade` | `Unknown` 的结果 |
|
||||
| --------------------------- | -------------------------------------- | ------------------ | --------------------------- |
|
||||
| `degrade(dir)` | 把任意方向降级为本组支持的方向 | 否,它本身就是降级 | `Unknown` |
|
||||
| `movement(dir)` | 单步坐标偏移 | 是 | `{ x: 0, y: 0 }` |
|
||||
| `move(dir, count)` | 走 `count` 步的偏移,`count` 可为负 | 是 | `{ x: 0, y: 0 }` |
|
||||
| `opposite(dir)` | 本组内的反方向 | 是 | `Unknown` |
|
||||
| `next(dir, anticlockwise?)` | 顺时针(默认)或逆时针旋转一步 | 是 | `Unknown` |
|
||||
| `dirOf(dx, dy)` | 由偏移反推方向,多个符合时返回其中一个 | 不接收方向 | 匹配不到时 `Unknown` |
|
||||
| `mapDirection()` | 迭代本组支持的所有方向 | 不接收方向 | 包含 `Unknown` |
|
||||
| `mapMovement()` | 迭代本组所有方向及其偏移 | 不接收方向 | 包含 `Unknown` 对应的零偏移 |
|
||||
|
||||
最常见的是「把方向换成坐标」:
|
||||
|
||||
```ts
|
||||
import { FaceDirection, FaceGroup } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
/** 从勇士当前位置朝 direction 走一步后的坐标。 */
|
||||
function targetOf(
|
||||
state: ICoreState,
|
||||
direction: FaceDirection
|
||||
): { x: number; y: number } | null {
|
||||
const handler = state.faceManager.get<FaceDirection>(FaceGroup.Dir8);
|
||||
if (!handler) return null;
|
||||
|
||||
// 把 direction 换算成一步的坐标偏移
|
||||
const { x, y } = handler.movement(direction);
|
||||
const loc = state.hero.getLocation();
|
||||
return { x: loc.x + x, y: loc.y + y };
|
||||
}
|
||||
```
|
||||
|
||||
`move` 适合一次算多格,例如 `handler.move(FaceDirection.Left, 3)` 得到 `{ x: -3, y: 0 }`;传负数就是反方向。`opposite` 与 `next` 都在本组内求解:先 `degrade`,再查本组的映射表,所以传入本组不认的方向时,结果取决于 `degrade` 的归并。`next` 的第二个参数是 `anticlockwise`,省略时为 `false`,即顺时针。
|
||||
|
||||
`dirOf` 走的是相反方向:给你一个偏移量,问它是哪个方向。它不接收方向,也不会先 `degrade`;偏移量与表中某一项完全相等时才命中,多个方向共用同一偏移时只返回其中一个。不要用它反推「唯一方向」,也不要用它校验位移是否合法。
|
||||
|
||||
`mapDirection` 与 `mapMovement` 用于遍历,两者的结果都包含 `Unknown`,`mapMovement` 里 `Unknown` 对应的偏移是 `{ x: 0, y: 0 }`。自己按 `mapMovement` 扩展邻域时,要跳过零偏移条目,否则对象会原地走一步;寻路的构图循环就是这样处理的。
|
||||
|
||||
## 用在哪里
|
||||
|
||||
### 移动器与寻路
|
||||
|
||||
移动器构造时保存一个处理器,每一步用它把 `moveDirection` 换成落点偏移。内置的 `DynamicTile` 构造自己的 `DynamicTileMover` 时,从 `state.faceManager` 按 `DYNAMIC_MOVER_FACE`(即 `Dir8`)取处理器;自己写移动器时,处理器要作为构造参数传进去(见[移动 Mover](./mover.md#实现自己的移动器))。处理器必须与对象的方向体系一致:四方向对象配八方向处理器,或反过来,偏移与朝向都会对不上。
|
||||
|
||||
寻路复用同一套方向规则。构建有向图时,它按 `mapMovement()` 的条目向相邻格扩展,所以处理器直接决定「能往哪几个方向走」:四方向处理器只扩展上下左右,八方向处理器会扩展到斜向。注入方式是 `finder.useFaceHandler(handler)`;没有注入时构图会失败(找不到图层、谓词或处理器都会失败),详见[自动寻路](../ii/pathfinding.md)。
|
||||
|
||||
因为「方向即偏移」被多处共用,改处理器的行为会同时影响移动与寻路:调整四方向处理器的斜向归并,移动器的斜向输入和寻路的斜向分支会一起改变。只希望其中一处变化时,不要直接换掉共享的处理器,而是给那处单独注入一个处理器实例。
|
||||
|
||||
### 图块朝向
|
||||
|
||||
地图图层默认使用 `state.roleFace` 作为自己的朝向绑定器,图块转向时查的就是这份映射。`MapTileBase.setFaceDirection(direction)` 的处理顺序是:
|
||||
|
||||
1. 用当前图块数字与目标方向查 `getFaceOf`,查到就换图并返回新数字;
|
||||
2. 查不到时用 `Dir4` 处理器的 `degrade` 折一次,再查一次;
|
||||
3. 仍查不到则保持原图块数字。
|
||||
|
||||
第 2 步把方向降级为四方向后再查一次图,查到就换图并返回新的图块数字。
|
||||
|
||||
### 朝向图块映射
|
||||
|
||||
同一个角色可能有多张图,分别对应不同朝向。真正把「朝右」换成「朝右那张图的图块数字」的是 `IRoleFaceBinder`,地图图层通过 `state.roleFace` 使用它。
|
||||
|
||||
| 方法 | 作用 | 返回 |
|
||||
| ------------------------------ | ------------------------------------ | ------------------- |
|
||||
| `malloc(identifier, main)` | 给一个图块分配主朝向,登记为一个新组 | `void` |
|
||||
| `bind(identifier, main, face)` | 把一个图块绑定到某个主图块的某个朝向 | `void` |
|
||||
| `getFaceOf(identifier, face)` | 取该组在指定朝向下使用的图块数字 | `IFaceData \| null` |
|
||||
| `getFaceDirection(identifier)` | 取该图块数字自身的朝向 | `FaceDirection` |
|
||||
| `getMainFace(identifier)` | 取该图块数字所属组的主朝向 | `IFaceData \| null` |
|
||||
|
||||
`malloc` 是建组:`identifier` 作为主图块,`main` 是它的朝向(主朝向一般是朝下)。`bind` 把同组的另一张图块挂进来,`main` 必须是已经 `malloc` 过的图块数字。两者都只写映射,不发事件:
|
||||
|
||||
```ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
function bindFacingTiles(state: ICoreState): void {
|
||||
const binder = state.roleFace;
|
||||
// 把图块 1 登记为朝下的主图块,再把图块 2 绑成它的朝右图
|
||||
binder.malloc(1, FaceDirection.Down);
|
||||
binder.bind(2, 1, FaceDirection.Right);
|
||||
|
||||
// 传入组内任意一个图块数字,都能查到该朝向使用的图块
|
||||
console.log(binder.getFaceOf(1, FaceDirection.Right)); // { identifier: 2, face: Right }
|
||||
|
||||
// 2 这张图自带的朝向
|
||||
console.log(binder.getFaceDirection(2)); // Right
|
||||
|
||||
// 2 所属组的主朝向;返回的 identifier 仍是传入的图块数字
|
||||
console.log(binder.getMainFace(2)); // { identifier: 2, face: Down }
|
||||
}
|
||||
```
|
||||
|
||||
`bind` 有两个前置条件,任一不满足都会记一条错误日志并放弃绑定:`main` 没有 `malloc` 过,或绑定的朝向与主朝向相同。因此建组的顺序必须是先 `malloc` 主图块,再 `bind` 其余朝向。`getFaceOf` 与 `getMainFace` 找不到时返回 `null`;`getFaceDirection` 找不到时返回 `Unknown`,而不是 `null`。
|
||||
|
||||
示例里的 `1` 与 `2` 只是占位,换图前先确认这两个图块数字在当前地图里没有被别的朝向组占用。`malloc` 会覆盖同一个图块数字原有的组,`bind` 会覆盖该组同一朝向的旧映射,所以要从地图的图块数据里取真实数字,不要照抄示例。
|
||||
|
||||
## 两个内置处理器
|
||||
|
||||
内置的 `Dir8FaceHandler` 与 `Dir4FaceHandler` 都实现 `IFaceHandler<FaceDirection>`,区别只在认哪些方向、以及斜向怎么归并:
|
||||
|
||||
| 处理器 | 支持方向 | `degrade` 行为 | 典型用途 |
|
||||
| ----------------- | ---------------- | -------------------------------------------------- | -------------------------------- |
|
||||
| `Dir8FaceHandler` | 八个方向 | 原样返回,不做归并 | 八方向移动、勇士与动态图块的默认 |
|
||||
| `Dir4FaceHandler` | 上下左右四个方向 | 四个正方向原样;四个斜向折到左右;其余为 `Unknown` | 只走四方向的地图与寻路 |
|
||||
|
||||
`Dir4FaceHandler` 的归并需要记住一条:斜向折到左右,不是折到上下。
|
||||
|
||||
| 输入 | `degrade` 结果 |
|
||||
| ------------ | -------------- |
|
||||
| `LeftUp` | `Left` |
|
||||
| `LeftDown` | `Left` |
|
||||
| `RightUp` | `Right` |
|
||||
| `RightDown` | `Right` |
|
||||
| 其余非正方向 | `Unknown` |
|
||||
|
||||
这一点决定了四方向体系里的实际手感:斜向输入不会让对象斜着走,也不会变成上下位移,而是变成横向一步。需要别的归并策略,或方向体系根本不是方格(例如六边形布局)时,才需要自己写处理器(见下一节)。
|
||||
|
||||
两个处理器在 `Unknown` 上的行为一致:`movement` 与 `move` 返回零偏移,`opposite` 与 `next` 返回 `Unknown`,`mapDirection` 与 `mapMovement` 会把它列出来。查表失败时 `movement` 同样退回零偏移。
|
||||
|
||||
勇士与动态图块默认都走 `Dir8`,分组分别来自 `HERO_FACE` 与 `DYNAMIC_MOVER_FACE`。需要四方向时,改的是分组来源。
|
||||
|
||||
## 实现自己的朝向处理器
|
||||
|
||||
需要自定义处理器的场合通常不是「换一种斜向归并」,而是**坐标系本身不是方格**。例如六边形地图有六个邻接方向,既不是四方向也不是八方向;这类布局需要一套自己的方向规则,通过实现 `IFaceHandler` 提供。
|
||||
|
||||
实现时把「降级表」「运动表」「旋转顺序」放在模块顶层做共享常量,类只负责按契约查表。处理器是共享对象,实例上不保存任何「当前对象」的状态。几条契约要逐条满足:
|
||||
|
||||
- 所有接收方向的入参,都要在方法内部先经过 `degrade`;`dirOf`、`mapDirection`、`mapMovement` 不接收方向。
|
||||
- `Unknown` 不能被当成普通方向参与运算:`movement` 与 `move` 返回零偏移,`opposite` 与 `next` 返回 `Unknown`。
|
||||
- `mapMovement` 必须包含 `Unknown` 及其零偏移,调用方靠这张表把每个方向都映射到偏移。
|
||||
- `dirOf` 匹配不到时返回 `Unknown`,不要抛错;只遍历本组自己的偏移表。
|
||||
|
||||
下面以六边形布局为例:只认左右与四个斜向,上下不是邻接方向。偏移量按示例的行列坐标给出,换成别的坐标系时增量随之改变:
|
||||
|
||||
```ts
|
||||
import { IFaceDescriptor, IFaceHandler } from '@motajs/common';
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
|
||||
// 降级表:六边形只认六个邻接方向,其余方向无法降级
|
||||
const HEX_DEGRADE: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
|
||||
[FaceDirection.Left, FaceDirection.Left],
|
||||
[FaceDirection.Right, FaceDirection.Right],
|
||||
[FaceDirection.LeftUp, FaceDirection.LeftUp],
|
||||
[FaceDirection.RightUp, FaceDirection.RightUp],
|
||||
[FaceDirection.LeftDown, FaceDirection.LeftDown],
|
||||
[FaceDirection.RightDown, FaceDirection.RightDown]
|
||||
]);
|
||||
|
||||
// 运动表:六个方向的单步偏移,必须包含 Unknown 的零偏移
|
||||
const HEX_MOVEMENTS: ReadonlyMap<FaceDirection, IFaceDescriptor> = new Map([
|
||||
[FaceDirection.Unknown, { x: 0, y: 0 }],
|
||||
[FaceDirection.Left, { x: -1, y: 0 }],
|
||||
[FaceDirection.Right, { x: 1, y: 0 }],
|
||||
[FaceDirection.LeftUp, { x: -1, y: -1 }],
|
||||
[FaceDirection.RightUp, { x: 1, y: -1 }],
|
||||
[FaceDirection.LeftDown, { x: -1, y: 1 }],
|
||||
[FaceDirection.RightDown, { x: 1, y: 1 }]
|
||||
]);
|
||||
|
||||
// 顺时针顺序,不含 Unknown;逆时针与反方向都在这张表上取
|
||||
const HEX_CW: readonly FaceDirection[] = [
|
||||
FaceDirection.Right,
|
||||
FaceDirection.RightDown,
|
||||
FaceDirection.LeftDown,
|
||||
FaceDirection.Left,
|
||||
FaceDirection.LeftUp,
|
||||
FaceDirection.RightUp
|
||||
];
|
||||
|
||||
/** 六边形布局的朝向处理器:只认六个邻接方向。 */
|
||||
export class HexFaceHandler implements IFaceHandler<FaceDirection> {
|
||||
degrade(dir: number): FaceDirection {
|
||||
// 不在六个方向里的输入(含 Up、Down、Unknown 与非法值)都无法降级
|
||||
return HEX_DEGRADE.get(dir as FaceDirection) ?? FaceDirection.Unknown;
|
||||
}
|
||||
|
||||
movement(dir: number): IFaceDescriptor {
|
||||
// 先降级,再查表
|
||||
return HEX_MOVEMENTS.get(this.degrade(dir)) ?? { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
move(dir: number, count: number): IFaceDescriptor {
|
||||
// 等价于 movement 乘以 count;count 为负表示反向位移
|
||||
const { x, y } = this.movement(dir);
|
||||
return { x: x * count, y: y * count };
|
||||
}
|
||||
|
||||
opposite(dir: number): FaceDirection {
|
||||
// 六个方向里相隔三个位置的方向就是反方向
|
||||
const i = HEX_CW.indexOf(this.degrade(dir));
|
||||
return i < 0 ? FaceDirection.Unknown : HEX_CW[(i + 3) % HEX_CW.length];
|
||||
}
|
||||
|
||||
next(dir: number, anticlockwise: boolean = false): FaceDirection {
|
||||
const i = HEX_CW.indexOf(this.degrade(dir));
|
||||
if (i < 0) return FaceDirection.Unknown;
|
||||
|
||||
// 顺时针 +1,逆时针 -1,转满一圈回到起点
|
||||
const step = anticlockwise ? -1 : 1;
|
||||
return HEX_CW[(i + step + HEX_CW.length) % HEX_CW.length];
|
||||
}
|
||||
|
||||
dirOf(dx: number, dy: number): FaceDirection {
|
||||
// 只认本组的单步偏移;其它偏移没有对应方向
|
||||
for (const [dir, { x, y }] of HEX_MOVEMENTS) {
|
||||
if (x === dx && y === dy) return dir;
|
||||
}
|
||||
return FaceDirection.Unknown;
|
||||
}
|
||||
|
||||
mapDirection(): Iterable<FaceDirection> {
|
||||
return HEX_MOVEMENTS.keys();
|
||||
}
|
||||
|
||||
mapMovement(): Iterable<[FaceDirection, IFaceDescriptor]> {
|
||||
return HEX_MOVEMENTS.entries();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注册时选一个未被内置占用的分组键。数字键没有命名空间保护,将来内置新增分组时可能撞车;自定义分组更推荐字符串 id:
|
||||
|
||||
```ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { HexFaceHandler } from './hex-face-handler';
|
||||
|
||||
function registerHexFace(state: ICoreState): void {
|
||||
state.faceManager.registerById('hex', new HexFaceHandler());
|
||||
|
||||
const handler = state.faceManager.getById<FaceDirection>('hex');
|
||||
if (!handler) return;
|
||||
|
||||
// 六个邻接方向能换算偏移;上下会被降级为 Unknown
|
||||
console.log(handler.movement(FaceDirection.RightUp)); // { x: 1, y: -1 }
|
||||
console.log(handler.degrade(FaceDirection.Up)); // Unknown
|
||||
}
|
||||
```
|
||||
|
||||
注册只往表里放处理器,不会替任何已有对象换掉手上的那一个。真正生效的地方是显式取用它的代码:构造移动器时把处理器传进去,寻路时调用 `finder.useFaceHandler(handler)`。这些地方之后按新策略计算,已经构造好的移动器仍持有构造时的处理器。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- <strong>四方向的斜向折左右</strong>:`Dir4FaceHandler.degrade` 把 `LeftUp` 与 `LeftDown` 折到 `Left`,`RightUp` 与 `RightDown` 折到 `Right`,不是折到上下。需要别的归并策略时自建处理器。
|
||||
- <strong>get 与 getById 可能返回 null</strong>:自建分组、或在启动期之外取用处理器时,先判空再调用;直接对 `null` 调方法会在运行期报错。
|
||||
- <strong>dirOf 可能有多解</strong>:偏移量相同、方向不同的情况只返回其中一个,不要用它反推「唯一方向」,也不要用它校验位移是否合法。
|
||||
- <strong>Unknown 的偏移是零</strong>:`movement` 与 `move` 对 `Unknown` 返回 `{ x: 0, y: 0 }`,`opposite` 与 `next` 返回 `Unknown`;把它当普通方向做位置运算,对象会原地不动。
|
||||
- <strong>bind 前必须 malloc</strong>:主图块没有分配朝向时,`bind` 会记错误并放弃;绑定的朝向与主朝向相同也会被拒绝。
|
||||
- <strong>重复 malloc 会覆盖</strong>:同一个图块数字再次 `malloc` 会重建该组的映射,之前 `bind` 进去的其它朝向不会被保留;已经 `bind` 过的子图块仍持有旧映射表,`getFaceOf` 可能查到旧结果。重建整组后要重新 `bind` 所有子图块。
|
||||
- <strong>处理器是共享对象</strong>:不要在实例上保存「当前处理的是哪个对象」之类的状态;多个对象共用同一个处理器时,状态会互相覆盖。注册一次即可,也没有卸载流程;换处理器要改使用方的引用,而不是重新 `register`。
|
||||
- <strong>不同分组互不影响</strong>:`Dir4`、`Dir8` 与自定义分组各有一份处理器,换掉其中一个不会改动其余分组;注册也不替换已经构造好的对象手里的实例。
|
||||
- <strong>getMainFace 返回的是朝向</strong>:返回的 `IFaceData` 里,`identifier` 仍是你传入的图块数字,`face` 是它所属组的主朝向,不是主图块的数字。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
先确认内置处理器取得到,再逐项打印关键换算结果:
|
||||
|
||||
```ts
|
||||
import { FaceDirection, FaceGroup } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
/** 打印内置处理器的关键结果,确认方向换算符合预期。 */
|
||||
function verifyFaceHandler(state: ICoreState): void {
|
||||
const dir4 = state.faceManager.get<FaceDirection>(FaceGroup.Dir4);
|
||||
const dir8 = state.faceManager.get<FaceDirection>(FaceGroup.Dir8);
|
||||
if (!dir4 || !dir8) {
|
||||
console.log('处理器未注册,检查 initialize.ts 的注册');
|
||||
return;
|
||||
}
|
||||
|
||||
// 四方向:斜向应折到左右
|
||||
console.log('左上降级', dir4.degrade(FaceDirection.LeftUp)); // Left
|
||||
|
||||
// 八方向:向上走一步是纵坐标 -1
|
||||
console.log('向上单步', dir8.movement(FaceDirection.Up)); // { x: 0, y: -1 }
|
||||
|
||||
// 反方向与旋转;旋转方向以处理器认的方向集合为准
|
||||
console.log('向右的反方向', dir8.opposite(FaceDirection.Right)); // Left
|
||||
console.log('向上的顺时针', dir8.next(FaceDirection.Up));
|
||||
console.log('向上的逆时针', dir8.next(FaceDirection.Up, true));
|
||||
}
|
||||
```
|
||||
|
||||
再验证朝向图块的登记与读取:先 `malloc` 主图块,再 `bind` 另一朝向,然后核对 `getFaceOf`、`getFaceDirection` 与 `getMainFace` 的返回值;把 `bind` 挪到 `malloc` 之前,应当看到错误日志且映射没有建立。
|
||||
|
||||
最后验证自定义处理器:注册后取回,确认六个邻接方向能换算偏移、`movement(RightUp)` 为 `{ x: 1, y: -1 }`、`degrade(Up)` 为 `Unknown`;再把它交给一个新建的移动器或 `finder.useFaceHandler`,重算一次目标格与一条路径,确认只在六个方向上扩展。已构造好的移动器不会发生变化,若结果没变,先检查是否仍在用旧的处理器。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
@@ -0,0 +1,210 @@
|
||||
# 钩子深入
|
||||
|
||||
中级教程的[钩子](../../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。做法很简单——<strong>建议一个对象一个钩子实例</strong>。共享实例是允许的,但不建议;除非你明确了解钩子的内部实现与后果,否则不要这样做。
|
||||
|
||||
钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再给一个完整示例,最后是注册、移除与异步的接口事实。
|
||||
|
||||
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。
|
||||
|
||||
## 建议一个对象一个实例
|
||||
|
||||
一个钩子类只是定义。真正参与事件的是你用 `new` 创建出来的<strong>实例</strong>,以及它被注册到的那个对象实例。
|
||||
|
||||
`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` 注册实例。
|
||||
|
||||
示例里的 `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)` 会收到<strong>该地图每个图层</strong>的尺寸变化——事件来自多个图层,对象却只有一个。这种回调里要自己筛掉不关心的图层:
|
||||
|
||||
```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)` 按对象引用查找,必须传注册时的<strong>同一个实例</strong>。传一个新建的同字段实例不会匹配,也不会报错。
|
||||
- `addHook` 不去重。同一个实例注册两次会得到两个控制器、两条记录,实例被调用两次;第二次还会覆盖「实例到记录」的映射,第一次的记录之后很难按实例清理。一个实例只注册一次。
|
||||
- `forEachHook` 的执行顺序不保证。需要顺序时,把步骤写进同一个钩子内部按 `await` 串行。
|
||||
- 移除钩子时会调用 `destroy(controller)`,适合清理定时器与外部监听器。
|
||||
- `GameMap.removeLayer(layer)` 会移除图层并卸载它为图层创建的内部转发器,但<strong>不会</strong>卸载你自己挂在图层上的钩子,也不会调用它们的 `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` 的遍历顺序,都不能决定异步完成顺序。确实需要顺序时,把有依赖的步骤写进<strong>同一个钩子</strong>按 `await` 串行;战斗场景改用带 `priority` 的战斗脚本(细节见 `高级教程 II · 战斗脚本与优先级`)。不要试图在多个独立钩子之间约定顺序,钩子没有跨实例的优先级接口。
|
||||
|
||||
`onDeleteDynamic` 只在 `deleteDynamic` 这条异步删除路径上被 `Promise.all` 等待;`toStatic` 是同步转换,那里触发的 `onDeleteDynamic` 不会等待返回值。
|
||||
|
||||
> 钩子在系统级流程中如何被消费、跨系统如何安排优先级,属于 `高级教程 II · 系统级实现`;钩子调度的底层原理属于 `高级教程 III · 架构原理`。本篇只写你能直接使用的那一层。
|
||||
|
||||
<!-- TODO(advanced-II): 钩子的系统级消费与优先级 -->
|
||||
<!-- TODO(advanced-III): 钩子调度原理 -->
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- <strong>把地图级钩子挂到图层上</strong>。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用;反过来把图层钩子挂到地图上也不会收到图层事件。
|
||||
- <strong>把同一个实例注册到多个对象</strong>。接口允许,但不建议;除非你明确了解钩子的内部实现与后果,否则回调里区分触发者、共享状态带来的问题都会由你自己承担。
|
||||
- <strong>重复注册同一个实例</strong>。`addHook` 不去重,实例会被多次调用,旧控制器还留在内部列表里。
|
||||
- <strong>移除时 new 了一个新实例</strong>。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。
|
||||
- <strong>在 awake 里等自己触发的事件</strong>。`awake` 调用时钩子还不在已加载列表里,`forEachHook` 遍历不到它。
|
||||
- <strong>依赖异步钩子的顺序</strong>。引擎用 `Promise.all` 并发等待,完成顺序不等于注册顺序。
|
||||
- <strong>以为移除图层会顺带清理自己的钩子</strong>。`removeLayer` 只卸载内部转发器,你自己挂的钩子要显式 `unload()`。
|
||||
|
||||
## 小结
|
||||
|
||||
- 建议一个对象一个钩子实例:实例注册到哪个对象,就只服务那个对象。共享允许但不建议,除非明确了解内部实现与后果。
|
||||
- 参数里没有图层身份的钩子(如 `onUpdateBlock`)靠注册位置限定;地图级钩子收到多图层事件时,用 `===` 比较回调传来的对象。
|
||||
- `addHook` 返回控制器,`removeHook` 要传原实例;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。
|
||||
- `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
@@ -0,0 +1,106 @@
|
||||
# 高级 I 导览
|
||||
|
||||
高级教程写给已经能独立使用中级接口、现在想按自己的规则扩展数据端的人。到了这一层,问题不再是「这个接口怎么调」,而是「这个需求该放在哪一层、该由谁在什么时机执行」。弄错这一层,效果可能当下能跑,却会在存档、读档或录像回放时对不上。
|
||||
|
||||
这一页说明高级教程分几层、高级 I 包含哪些篇目、先读什么、以及一条贯穿全层的取舍原则:新增系统优先用插件,少改内置。
|
||||
|
||||
## 高级教程是给谁的
|
||||
|
||||
高级教程面向有高度定制需求的作者:
|
||||
|
||||
- 你要新增一套有自己状态、生命周期和存档内容的系统,而不只是改一个数值;
|
||||
- 你要理解扩展机制本身,知道声明式接口、插件、存档与录像之间怎么配合;
|
||||
- 你愿意读一点源码,但不需要成为引擎的编写者。
|
||||
|
||||
它不要求你改造引擎内部。高级教程的目标是同时覆盖数据端与渲染端;渲染端尚未完工,当前只写数据端内容,渲染端部分只用占位标记指出归属。所以高级 I 的例子都落在数据端:你写的内容、你注册的对象、你声明的规则。
|
||||
|
||||
## 高级教程的三层
|
||||
|
||||
高级教程按难度与主题分成三层。层与顶层的中级教程不是同一套划分:中级讲「怎么用」,高级讲「怎么扩展」。
|
||||
|
||||
| 层级 | 定位 | 主要主题 |
|
||||
| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| 高级 I · 扩展基础 | 以插件系统为核心,后续内容都是它的拓展 | 声明式接口、插件系统、可存档内容、录像一致性、data-common 的 Store、钩子深入、移动 Mover、FaceHandler 系列 |
|
||||
| [高级 II · 深入定制](../ii/) | 深入定制 | 范围、图块、装备、录像指令、寻路、勇士与怪物属性、战斗上下文 |
|
||||
| 高级 III · 原理与架构 | 原理与架构 | 双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试 |
|
||||
|
||||
高级 II 已经可阅读,入口见[高级 II 导览](../ii/);本层收录范围、图块、装备、录像指令、寻路、属性与战斗上下文。高级 III 仍用 `高级 III · <专题>` 这样的占位标记指出归属,暂不提供页面入口。
|
||||
|
||||
## 高级 I 包含什么
|
||||
|
||||
高级 I 一共九篇,包含本导览与八篇正文。
|
||||
|
||||
| 序 | 篇目 | 它能让你做什么 |
|
||||
| --- | -------------------- | ------------------------------------------------------------ |
|
||||
| 1 | 高级 I 导览 | 看清分层、阅读路径与取舍原则,决定先读哪篇 |
|
||||
| 2 | 声明式 vs 命令式接口 | 分清两种思路,知道持续生效的规则与一次性动作各该写在哪 |
|
||||
| 3 | 插件系统 | 独立写出插件,分清数据端与渲染端的依赖和生命周期 |
|
||||
| 4 | 可存档内容 | 让自定义状态正确进入存档,并在读档后恢复到存档时的样子 |
|
||||
| 5 | 录像一致性 | 判断数据端哪些方法该登记、哪些路径不该记,以及检查能保证什么 |
|
||||
| 6 | data-common 的 Store | 知道五个 Store 各存什么、能做什么,以及为什么运行时只读 |
|
||||
| 7 | 钩子深入 | 把钩子限定到一个对象上,建议一个对象一个钩子实例 |
|
||||
| 8 | 移动 Mover | 能排出一次移动、等它走完,并在需要时自己实现移动器 |
|
||||
| 9 | FaceHandler 系列 | 能选对朝向处理器、算出目标格与反方向,并把朝向映射到图块数字 |
|
||||
|
||||
推荐阅读顺序:声明式 vs 命令式 → 插件系统 → 可存档内容 → 录像一致性 → data-common 的 Store → 钩子深入。移动 Mover 与 FaceHandler 系列偏参考性质,等要写移动或朝向逻辑时再读;它们各自独立,不依赖前面的顺序。
|
||||
|
||||
插件系统是这一层的核心,可存档内容、录像一致性、data-common 的 Store 与钩子深入都建立在它提供的扩展位置上。也可以按需求跳读,但建议至少先读前两篇:它们决定你是在「声明一条规则」,还是在「写一段一次性脚本」。
|
||||
|
||||
## 阅读路径与前置
|
||||
|
||||
高级 I 假设你已经读完中级教程,尤其是数据端与渲染端、代码基础、数据插件这三篇;数据端总览、钩子、录像指令也会在具体页面里被直接引用。前置篇目的链接放在本页末尾。
|
||||
|
||||
按目标不同,可以走两条路径:
|
||||
|
||||
- <strong>做扩展系统</strong>:声明式 → 插件 → 可存档内容 → 录像一致性。适合新增任务、商店、计数器这类有自己状态与生命周期的功能。读完能独立装配一个插件,并让它的数据跟着存档走。
|
||||
- <strong>改内容或机制</strong>:声明式 → data-common 的 Store → 钩子深入,按需接上移动 Mover 或 FaceHandler 系列。适合在现有机制上加行为、按接口读取内容定义。读完能分清哪些只需要读、哪些由数据端登记,也能把钩子限定到目标对象上;要改移动或朝向规则时,知道该换哪个扩展点。
|
||||
|
||||
两条路径都建议先读声明式篇。它决定你后面是在「加一条规则」,还是「写一段一次性脚本」;这个判断错了,插件、存档与录像都会跟着别扭。
|
||||
|
||||
## 优先用插件,少改内置
|
||||
|
||||
新增系统优先通过插件系统实现;只有不得不修改内置系统时,才去改内部。理由很现实:版本迁移时 `src` 基本不会改,而内置包(`packages` / `packages-user`)有可能会改。直接改内置,升级时就要跟着差异走,维护成本更高;把改动留在自己的 `src` 里,升级时要对照的地方少得多。
|
||||
|
||||
| 需求类型 | 优先做法 | 什么情况下才改内置 |
|
||||
| -------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| 新增一套有自己状态、生命周期与存档内容的系统 | 写成插件,在插件里装配对象 | 现有插件已覆盖大部分能力,只差一个接口或默认值时 |
|
||||
| 给已有系统补一点行为 | 改内置类的方法体,或加一条分支 | 需要替换整套策略、或改动会破坏内置行为时,才独立成类 |
|
||||
| 读取内容定义(图块、道具、地图、怪物、事件) | 通过 `state.*Store` 的接口读,不新建 Store | 现有 Store 装不下,且确实需要新的系统级数据表时 |
|
||||
| 给会改状态的方法补录像标记 | 在内置方法上加一行装饰器 | 需要新的录像入口或自定义录像行为时(见[自定义录像指令](../ii/custom-replay-command.md)) |
|
||||
|
||||
判断标准不是改动多少行,而是改动落在谁的代码里:能由插件或内容定义完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个方法或一条分支上,方便升级时对照。
|
||||
|
||||
## 本层不覆盖什么
|
||||
|
||||
- <strong>渲染端内容</strong>:渲染端尚未完工,高级教程当前只收录数据端内容,渲染端专题待完工后补上。
|
||||
- <strong>高级 II 的深入定制内容</strong>:范围、图块、装备、录像指令、寻路、勇士与怪物属性、战斗上下文,见[高级 II 导览](../ii/)。
|
||||
- <strong>高级 III 的原理与架构</strong>:双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试。
|
||||
- <strong>API 参考</strong>:接口签名与类型清单不属于教程正文。
|
||||
|
||||
## 读完这一层应该能做什么
|
||||
|
||||
- 能用自己的话解释声明式与命令式的区别,并说出一个「仍然该用命令式」的场景。
|
||||
- 能拿到一个需求,判断它该做成插件,还是按现有接口读取即可。
|
||||
- 能说出一个可存档对象必须满足的三个约束:只存重建必需的数据、`saveState` 返回独立对象(引擎不替你深拷贝)、`loadState` 先清空再重建。
|
||||
- 能说明为什么建议一个对象一个钩子实例,并在回调拿到多个对象时用对象引用筛出目标。
|
||||
- 能排出一次移动、等到它结束,并在需要时实现自己的移动器或勇士顶层实现。
|
||||
- 能选对朝向处理器、算出目标格与反方向,并把朝向映射到图块数字。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
高级 I 正文:
|
||||
|
||||
- [声明式 vs 命令式接口](./declarative-vs-imperative.md)
|
||||
- [插件系统](./plugin-system.md)
|
||||
- [可存档内容](./saveable-content.md)
|
||||
- [录像一致性](./replay-consistency.md)
|
||||
- [data-common 的 Store](./data-stores.md)
|
||||
- [钩子深入](./hooks-in-depth.md)
|
||||
- [移动 Mover](./mover.md)
|
||||
- [FaceHandler 系列](./face-handler.md)
|
||||
|
||||
前置回顾(中级教程):
|
||||
|
||||
- [数据端与渲染端](../../intermediate/data-vs-render.md)
|
||||
- [代码基础](../../intermediate/code-basics.md)
|
||||
- [数据插件](../../intermediate/data-plugins.md)
|
||||
@@ -0,0 +1,533 @@
|
||||
# 移动 Mover
|
||||
|
||||
对象移动是数据端最常见的动作之一:勇士走一步、动态图块被推走、NPC 沿路径巡逻。移动器把「想怎么走」和「实际怎么走」拆开:先用链式调用排出一串移动步骤,再调用 `start()` 按顺序执行。
|
||||
|
||||
数据端只维护坐标与状态,画面上的位移、动画与速度表现由渲染端读取这些状态后完成。
|
||||
|
||||
<!-- TODO(renderer): 移动动画与速度表现 -->
|
||||
|
||||
## 移动器模型
|
||||
|
||||
移动器(mover)把一个**可移动对象**和一份**移动计划**绑在一起,按计划逐步推动对象。
|
||||
|
||||
- **可移动对象**实现 `IObjectMovable`,只提供坐标与朝向:`x`、`y`、`setPos`、`getCurrentFaceDirection`。勇士的位置对象与动态图块都是这种对象。
|
||||
- **移动计划**是一串步骤。一个步骤只描述一件小事:朝某个方向走、只转向、瞬移、跳跃、改速度或改动画方向。`step`、`face`、`tp` 这些规划方法只往队列里追加步骤,不会立刻移动对象。
|
||||
- **执行**由 `start()` 发起:它取出待执行队列并逐步执行,每一步算出落点,再把新坐标写回对象。`start()` 返回本次执行的控制器 `IMoverController`;用 `onEnd` 等待整次结束,用 `push` / `insert` 在执行中追加步骤,用 `stop` 提前收尾。
|
||||
|
||||
整次执行里有两个方向:**面朝朝向**(`faceDirection`,对象脸朝哪)与**移动朝向**(`moveDirection`,这一步往哪走)。多数步骤让两者一致;侧身走、后退时两者会分开。二者的具体差别见后文「读取与设置状态」。
|
||||
|
||||
移动器只负责「这一步落到哪」,不判断目标格能不能进入、撞到什么。通行规则、进入 / 离开 / 撞击事件与地图边界由调用方的规则层决定;勇士的规则见后文「勇士的顶层实现」。
|
||||
|
||||
移动相关的接口由 `@user/data-common` 提供,三个角色分工明确:
|
||||
|
||||
| 接口 | 角色 | 关键成员 |
|
||||
| ------------------ | -------------- | --------------------------------------------- |
|
||||
| `IObjectMovable` | 被移动的对象 | `x`、`y`、`setPos`、`getCurrentFaceDirection` |
|
||||
| `IObjectMover` | 移动器 | 链式规划、`start()`、状态、钩子 |
|
||||
| `IMoverController` | 一次执行的句柄 | `done`、`onEnd`、`push`、`insert`、`stop` |
|
||||
|
||||
多数场景只需要碰后两者:拿现成的移动器排队、执行、等待。
|
||||
|
||||
## 拿到现成的移动器
|
||||
|
||||
不要自己 `new` 勇士或动态图块的移动器:它们由引擎随对象创建,与对象同生命周期。重复 `new` 会得到第二个互不相干的移动器,位置与朝向都会对不上。
|
||||
|
||||
### 勇士
|
||||
|
||||
`state.hero.location.mover` 是勇士位置对象的移动器,类型是 `IHeroMover`。除了通用的链式方法与控制器,它还提供 `config`、`getConfig` 与 `useTopImplementation`(分别见后文「勇士的移动配置」与「勇士的顶层实现」)。
|
||||
|
||||
```ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
async function heroStepRight(state: ICoreState): Promise<void> {
|
||||
// 向右走一格并开始执行,拿到本次执行的控制器
|
||||
const controller = state.hero.location.mover
|
||||
.step(FaceDirection.Right)
|
||||
.start();
|
||||
await controller?.onEnd;
|
||||
}
|
||||
```
|
||||
|
||||
勇士位置对象本身的接口(坐标、楼层、朝向、存档)见[勇士位置](../../intermediate/hero-location.md)。
|
||||
|
||||
### 动态图块
|
||||
|
||||
动态图块(`IDynamicTile`)自带 `mover`,类型是 `IObjectMover<IDynamicTile>`,在创建动态图块时一并创建。拿到动态图块通常从图层对象入手:
|
||||
|
||||
```ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
async function pushTileRight(layer: IMapLayer): Promise<void> {
|
||||
// 把 (5, 5) 的静态图块转成动态图块;这一步会清掉原静态图块
|
||||
const tile = layer.transferToDynamic(5, 5);
|
||||
if (!tile) return;
|
||||
|
||||
// 向右推一格并开始执行,拿到本次执行的控制器
|
||||
const controller = tile.mover.step(FaceDirection.Right).start();
|
||||
await controller?.onEnd;
|
||||
}
|
||||
```
|
||||
|
||||
`layer.createDynamic(num, x, y)` 直接新建动态图块,`layer.getDynamicTilesAt(x, y)` 与 `layer.iterateDynamicTiles()` 用来找已存在的动态图块。只是推一格这类简单场景,用 `tile.step(dir, count)` 更省事,它内部就是 `mover.step(...)` 加 `mover.start()`。创建、转换与移动的完整说明见[静态图块与动态图块](../ii/static-vs-dynamic-tiles.md)。
|
||||
|
||||
### 其他可移动对象
|
||||
|
||||
只有自定义对象才需要自己建移动器:对象实现 `IObjectMovable`(`x`、`y`、`setPos`、`getCurrentFaceDirection`),再为它构造一个移动器实例,写法见后文「实现自己的移动器」。引擎内置对象没有「换一个移动器」的公开入口:勇士只能用 `useTopImplementation` 替换规则层,动态图块的 `mover` 字段是只读的。
|
||||
|
||||
## 排一个移动计划
|
||||
|
||||
规划方法是链式的:每次调用追加一个或几个步骤,返回移动器本身,所以可以连着写。规划阶段不会移动对象,对象要等 `start()` 才动。
|
||||
|
||||
| 方法 | 追加的步骤 | 作用 |
|
||||
| ------------------------------ | ------------------- | -------------------------------------------------- |
|
||||
| `step(dir, count?)` | `Dir` | 朝 `dir` 走 `count` 格,同时更新移动朝向与面朝朝向 |
|
||||
| `stepFace(move, face, count?)` | `DirFace` | 朝 `move` 走,面朝朝向显式设为 `face` |
|
||||
| `forward(count?)` | `Special(Forward)` | 沿当前面朝朝向前进 |
|
||||
| `backward(count?)` | `Special(Backward)` | 沿当前面朝朝向的反方向后退,面朝朝向不变 |
|
||||
| `face(dir)` | `Face` | 只转向,不移动 |
|
||||
| `tp(x, y, rel?)` | `Teleport` | 瞬移到目标格 |
|
||||
| `jump(x, y, rel?)` | `Jump` | 跳跃到目标格 |
|
||||
| `speed(value)` | `Speed` | 设置后续移动的每格耗时,单位 ms |
|
||||
| `animDir(dir)` | `AnimDir` | 设置后续步骤的动画播放方向 |
|
||||
| `push(steps)` | 一组步骤 | 把预先拼好的 `ObjectMoveStep` 数组原样追加 |
|
||||
| `clear()` | 无 | 清空尚未执行的队列 |
|
||||
|
||||
`step` 的 `count` 默认是 1。传 2 会追加两个 `Dir` 步骤,逐步执行,不是把一步拆成两格。`stepFace` 把移动朝向与面朝朝向分开,适合「侧身走」这类表现。
|
||||
|
||||
`forward` 与 `backward` 是相对步,都以当前的面朝朝向(`faceDirection`)为基准:`forward` 直接朝面朝朝向走,`backward` 朝面朝朝向的反方向走并保持朝向不变,所以连续后退会一直沿同一条轴线,不会因为上一步写入的反方向而左右摆动。
|
||||
|
||||
`tp` 与 `jump` 的 `rel` 省略时为 `false`,`x`、`y` 是目标格坐标;`rel` 为 `true` 时,`x`、`y` 是相对当前位置的偏移。数据端上两者都是直接落到目标格,区别只在步骤类型,渲染端可以据此播放不同的表现。
|
||||
|
||||
`speed` 与 `animDir` 只是写移动器状态,本身不产生位移。`speed` 的单位是毫秒每格,初始 `currentSpeed` 为 `100`;`animDir` 设置 `ObjectAnimDirection.Forward` 或 `Backward`。
|
||||
|
||||
`mover.push(steps)` 接收步骤对象数组,适合把循环生成或从别处收集来的步骤一次加入;`controller.push(...steps)` 是可变参数,直接把步骤对象逐个传入。两者名字相同、形状不同,别把数组塞给控制器。`clear()` 只清空尚未执行的队列,不影响正在执行的那一次。
|
||||
|
||||
规划方法只是往队列里放对象,移动器真正面对的是 `ObjectMoveStep` 联合类型。执行中用 `controller.push` 追加时,也要按这个形状传:
|
||||
|
||||
| `type` | 字段 | 效果 |
|
||||
| ------------------------- | ----------------------------------------------------- | ------------------------------------- |
|
||||
| `ObjectMoveType.Dir` | `move` | 走向 `move`,移动朝向与面朝朝向都更新 |
|
||||
| `ObjectMoveType.DirFace` | `move`、`face` | 走向 `move`,面朝朝向设为 `face` |
|
||||
| `ObjectMoveType.Speed` | `value` | 设置后续每格耗时 |
|
||||
| `ObjectMoveType.Face` | `value` | 只改面朝朝向 |
|
||||
| `ObjectMoveType.Special` | `direction`:`ObjectSpecialStep.Forward` / `Backward` | 前进或后退 |
|
||||
| `ObjectMoveType.AnimDir` | `dir`:`ObjectAnimDirection.Forward` / `Backward` | 设置动画播放方向 |
|
||||
| `ObjectMoveType.Teleport` | `x`、`y`、`rel` | 瞬移 |
|
||||
| `ObjectMoveType.Jump` | `x`、`y`、`rel` | 跳跃 |
|
||||
|
||||
`Speed` 与 `AnimDir` 不产生位移,`Face` 只改面朝朝向。`ObjectSpecialStep` 与 `ObjectAnimDirection` 都是 `const enum`,分别表示前进 / 后退与正向 / 反向播放。
|
||||
|
||||
## 开始与等待
|
||||
|
||||
`start()` 把当前待执行队列取出来执行,并返回 `IMoverController`;如果移动器已经在移动,返回 `null`,不会接管当前移动。
|
||||
|
||||
返回 `null` 时它连队列都没有动过:既不切片也不清空,刚排的步骤原样留在移动器里。等当前这次结束后再调用 `start()`,它们会照常执行;不想执行就用 `clear()` 丢掉。不要以为 `start()` 失败会把刚排的步骤一起清掉。
|
||||
|
||||
```ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
async function walkRightThenDown(state: ICoreState): Promise<void> {
|
||||
const mover = state.hero.location.mover;
|
||||
const before = state.hero.getLocation();
|
||||
|
||||
// 链式排出三个步骤:向右两格、转向下、再向下一格
|
||||
mover
|
||||
.step(FaceDirection.Right, 2) // 向右两格
|
||||
.face(FaceDirection.Down) // 只转向,不移动
|
||||
.step(FaceDirection.Down); // 向下一格
|
||||
|
||||
const controller = mover.start();
|
||||
if (!controller) {
|
||||
// 上一次移动还没结束;这里直接放弃,避免和它抢位置
|
||||
return;
|
||||
}
|
||||
|
||||
// done 在整次执行结束时才变 true
|
||||
console.log('开始', controller.done); // false
|
||||
await controller.onEnd;
|
||||
console.log('结束', controller.done); // true
|
||||
|
||||
const after = state.hero.getLocation();
|
||||
console.log('前', before.x, before.y, '后', after.x, after.y);
|
||||
}
|
||||
```
|
||||
|
||||
`onEnd` 在整次执行结束时兑现,`done` 是同一时点的布尔查询。等待优先用 `await controller.onEnd`;`done` 适合在同步代码里快速判断。`onEnd` 只在整次执行正常跑完时才兑现,任何一步或任一钩子抛错都会让它永远悬着,见后文「边界与注意」。
|
||||
|
||||
`start()` 还会把移动朝向(`moveDirection`)重置为 `Unknown`,面朝朝向(`faceDirection`)不受影响。因此执行期间再用移动器规划,写的是「下一次」的队列,不是本次。
|
||||
|
||||
勇士还有一层额外的可能:计划排好了也不一定真走。边界、通行与撞击由顶层实现判定,走不了时 `HeroMover` 会调用 `controller.stop()` 结束本次,位置不变(见后文「勇士的顶层实现」)。判断「走没走成」要看坐标,不能只看 `onEnd` 是否兑现。
|
||||
|
||||
## 执行中追加、插入与停止
|
||||
|
||||
`controller` 才是当前这次执行的入口。`push` 与 `insert` 只在「仍在移动、没有调用过 `stop`、尚未 `done`」时生效,其余情况静默忽略;`stop()` 总是把停止标记置真并返回本次的 `onEnd`:
|
||||
|
||||
- `push(...steps)`:追加到本次队列末尾。
|
||||
- `insert(...steps)`:插到当前步之后。当前步已经从队列取出,所以实际是插到剩余队列的最前面;多个步骤按参数顺序排列。
|
||||
- `stop()`:把内部停止标记置真,返回本次的 `onEnd`;当前步会走完,之后不再开始新步骤。
|
||||
|
||||
```ts
|
||||
import { FaceDirection, ObjectMoveType } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
async function extendWhileMoving(state: ICoreState): Promise<void> {
|
||||
const controller = state.hero.location.mover
|
||||
.step(FaceDirection.Right, 2)
|
||||
.start();
|
||||
if (!controller) return;
|
||||
|
||||
// 执行已经开始,但还没结束;追加的步骤会进入本次队列
|
||||
controller.push({ type: ObjectMoveType.Dir, move: FaceDirection.Down });
|
||||
|
||||
await controller.onEnd;
|
||||
}
|
||||
```
|
||||
|
||||
执行期间在移动器上调用 `step`、`face`、`speed` 等方法,只会写回移动器内部的待执行队列,本次执行不会消费它们。执行中要追加,只能用 `controller.push` 或 `controller.insert`。
|
||||
|
||||
`stop()` 的停止检查发生在每轮循环开头,所以不会打断当前步:第一步还没开始就调用,通常一步都不走;在某一步执行中调用,当前步会走完再停。在步进方法或步进钩子里调用 `stop()` 时不要 `await` 它返回的 Promise——它要等当前步结束才兑现,`await` 会把整次移动卡死。内置的 `HeroMover` 在撞击、无法进入等分支里就是直接调用、不等待。
|
||||
|
||||
停止后 `done` 会变真,本次控制器不能再追加。想继续走,要重新排计划并再次 `start()`。
|
||||
|
||||
## 勇士的移动配置
|
||||
|
||||
`IHeroMover.config` 只对勇士有效,三个字段都是开关:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --------------- | -------------------------- |
|
||||
| `ignoreTerrain` | 是否忽略地形碰撞检测 |
|
||||
| `autoSave` | 是否在特定时机触发自动存档 |
|
||||
| `allowOutBound` | 是否允许勇士到达地图之外 |
|
||||
|
||||
`config` 只覆盖传入的字段,没传的保持原值;`getConfig` 返回当前配置的只读快照。
|
||||
|
||||
```ts
|
||||
// 忽略地形碰撞,并允许走出地图
|
||||
state.hero.location.mover.config({ ignoreTerrain: true, allowOutBound: true });
|
||||
|
||||
// 读回当前配置
|
||||
const { ignoreTerrain, autoSave } = state.hero.location.mover.getConfig();
|
||||
```
|
||||
|
||||
`ignoreTerrain` 会让 `HeroMover` 跳过通行谓词与进出 / 撞击事件,只剩边界检查;`allowOutBound` 为真时连边界也跳过。因此打开 `ignoreTerrain` 后改谓词看不到效果。
|
||||
|
||||
`autoSave` 只是保存在移动器上的开关,数据端移动时不会读取它;需要它的系统通过 `getConfig()` 读取后自行处理。
|
||||
|
||||
配置是立即写入的,`start()` 不会把它恢复成默认值,也不会写进存档:读档只恢复位置与朝向(`x`、`y`、`direction`、`floorId`),移动器上的配置与其它状态都不在存档里,重建数据端后回到默认值。想每次移动都从默认值开始,就在排计划前显式再 `config` 一次。
|
||||
|
||||
## 钩子
|
||||
|
||||
`IObjectMoverHooks<T>` 是移动器上的钩子族,泛型参数就是被移动的对象类型。用 `mover.addHook(...)` 注册,用返回的控制器 `unload()` 或 `removeHook(...)` 移除,规则与其它钩子一致,见[钩子深入](./hooks-in-depth.md)。
|
||||
|
||||
| 钩子 | 触发时机 | 参数 | 异步 |
|
||||
| -------------- | ------------------------------------- | ------------------------------- | ---- |
|
||||
| `onMoveStart` | 整次移动开始,子类 `onMoveStart` 之后 | `tile`、`mover` | 是 |
|
||||
| `onMoveEnd` | 整次移动结束,子类 `onMoveEnd` 之后 | `tile`、`mover` | 是 |
|
||||
| `onStepStart` | 单步开始前,坐标仍是移动前 | `code`、`step`、`tile`、`mover` | 是 |
|
||||
| `onStepEnd` | 单步结束,坐标已更新 | `code`、`step`、`tile`、`mover` | 是 |
|
||||
| `onSetPos` | 调用移动器 `setPos` 时 | `x`、`y`、`tile`、`mover` | 否 |
|
||||
| `onSetFaceDir` | 调用移动器 `setFaceDir` 时 | `dir`、`tile`、`mover` | 否 |
|
||||
| `onSetMoveDir` | 调用移动器 `setMoveDir` 时 | `dir`、`tile`、`mover` | 否 |
|
||||
|
||||
`onSetFaceDir` 的 `dir` 是面朝朝向(`faceDirection`),`onSetMoveDir` 的 `dir` 是移动朝向(`moveDirection`)。
|
||||
|
||||
几条与使用直接相关的时序:
|
||||
|
||||
- `onStepStart` 触发时坐标还是移动前,`onStepEnd` 触发时坐标已经写入对象。两者之间是子类 `onStepEnd` 计算落点、基类写坐标、`onStepSettled` 运行。
|
||||
- `code` 是子类 `onStepStart` 返回的数字,会被传给 `onStepStart` 钩子、子类 `onStepEnd` 与 `onStepEnd` 钩子;基类不解释它的含义,由子类自己约定。
|
||||
- 前四个钩子返回 `Promise`,引擎用 `Promise.all` 并发等待。完成顺序不等于注册顺序;需要顺序时,把有依赖的步骤写进同一个钩子按 `await` 串行。细节见[钩子深入](./hooks-in-depth.md#异步钩子)。
|
||||
- `onSetPos`、`onSetFaceDir`、`onSetMoveDir` 是同步的,返回 `void`,并且只由对应的 setter 触发。
|
||||
|
||||
## 读取与设置状态
|
||||
|
||||
移动器把一次执行需要的状态都放在自己身上:
|
||||
|
||||
| 成员 | 含义 | 说明 |
|
||||
| --------------- | ------------ | ------------------------------------------------------ |
|
||||
| `moving` | 是否正在移动 | `start()` 后为真,整次结束才回到假 |
|
||||
| `tile` | 绑定的对象 | 构造时传入 |
|
||||
| `faceDirection` | 面朝朝向 | 由步骤或 `setFaceDir` 更新 |
|
||||
| `moveDirection` | 移动朝向 | 由步骤或 `setMoveDir` 更新,`start()` 重置为 `Unknown` |
|
||||
| `currAnimDir` | 当前动画方向 | 初始为 `ObjectAnimDirection.Forward` |
|
||||
| `currentSpeed` | 当前每格耗时 | 单位毫秒,初始为 `100` |
|
||||
| `faceHandler` | 朝向处理器 | 构造时传入,负责方向与偏移的换算 |
|
||||
|
||||
<strong>面朝朝向(`faceDirection`)</strong>指对象「脸朝哪」,<strong>移动朝向(`moveDirection`)</strong>指「这一步往哪走」,两者是不同的概念,不要混用。`step` 会让两者一起更新;`stepFace` 与 `backward` 会让两者不同,例如向右走但脸朝上,或者朝下后退时移动朝向是上。`forward()` 沿的是面朝朝向,而不是移动朝向。
|
||||
|
||||
`forward` 沿的是**面朝朝向**(`faceDirection`),不是移动朝向(`moveDirection`);`onSetMoveDir` 设置的是移动朝向,`onSetFaceDir` 设置的是面朝朝向。
|
||||
|
||||
`speed` 与 `animDir` 往队列里放的是步骤,真正写入 `currentSpeed`、`currAnimDir` 是在这一步执行时;执行后状态留在移动器上,`start()` 不会恢复默认值,所以会带到下一次移动。
|
||||
|
||||
`setPos`、`setFaceDir`、`setMoveDir` 与链式规划不同:它们立即生效、不排队。`setPos` 直接写对象坐标,`setFaceDir` 直接改面朝朝向,`setMoveDir` 直接改移动朝向。三者分别触发 `onSetPos`、`onSetFaceDir`、`onSetMoveDir` 钩子。
|
||||
|
||||
移动过程中的落点写入由基类直接调用 `tile.setPos`,不经过移动器的 `setPos`,所以不会触发 `onSetPos`。`onSetPos` 只对应你显式调用移动器 `setPos` 的那一次。
|
||||
|
||||
## 实现自己的移动器
|
||||
|
||||
内置的 `HeroMover` 与 `DynamicTileMover` 已经覆盖勇士与动态图块。只有当你有一个新的可移动对象、并且它的落点规则与这两者都不同时,才需要自己写一个。
|
||||
|
||||
`ObjectMover` 是移动器基类,负责队列、控制器、状态更新、钩子分发与坐标回写。子类只需要实现五个 `protected` 方法:
|
||||
|
||||
- `onMoveStart` 与 `onMoveEnd` 包住整次执行,分别在第一步之前与最后一步之后运行,返回的 Promise 会被等待。
|
||||
- `onStepStart` 在每步开始前运行。此时基类已经更新过移动朝向、面朝朝向、速度与动画方向,但坐标还没变。它返回本步的结果码,供后面的逻辑与钩子使用。
|
||||
- `onStepEnd` 计算这一步的落点,返回一个 `ITileLocator`。基类拿它和当前坐标比较,不同才调用 `tile.setPos`。不要在这里自己写坐标。
|
||||
- `onStepSettled` 在坐标写入之后运行,参数里带着移动前坐标 `before` 与落点 `curr`。这里适合触发进入 / 离开 / 踩踏一类的事件,或者把朝向同步回对象。
|
||||
|
||||
最小实现如下:
|
||||
|
||||
```ts
|
||||
import {
|
||||
FaceDirection,
|
||||
IObjectMovable,
|
||||
ObjectMover,
|
||||
ObjectMoveStep,
|
||||
ObjectMoveType,
|
||||
shouldReplay
|
||||
} from '@user/data-common';
|
||||
import { IFaceHandler, ITileLocator } from '@motajs/common';
|
||||
|
||||
// 自定义结果码:onStepStart 返回,onStepEnd 与钩子都会收到
|
||||
const enum SimpleMoveCode {
|
||||
/** 这一步可以执行 */
|
||||
Success,
|
||||
/** 这一步不执行,保持原地 */
|
||||
Blocked
|
||||
}
|
||||
|
||||
// 只处理四方向与瞬移的最小移动器;T 是可移动对象类型
|
||||
export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
|
||||
constructor(
|
||||
readonly tile: T,
|
||||
faceHandler: IFaceHandler<FaceDirection>
|
||||
) {
|
||||
// 初始朝向取对象当前朝向;之后由每一步的 prepareStep 维护
|
||||
super(faceHandler, tile.getCurrentFaceDirection());
|
||||
}
|
||||
|
||||
protected async onMoveStart(): Promise<void> {}
|
||||
|
||||
protected async onMoveEnd(): Promise<void> {}
|
||||
|
||||
protected async onStepStart(
|
||||
step: Readonly<ObjectMoveStep>
|
||||
): Promise<number> {
|
||||
// 跳跃步骤判为不可执行,其余步骤放行
|
||||
return step.type === ObjectMoveType.Jump
|
||||
? SimpleMoveCode.Blocked
|
||||
: SimpleMoveCode.Success;
|
||||
}
|
||||
|
||||
protected async onStepEnd(
|
||||
code: number,
|
||||
step: Readonly<ObjectMoveStep>
|
||||
): Promise<ITileLocator> {
|
||||
if (code !== SimpleMoveCode.Success) {
|
||||
return { x: this.tile.x, y: this.tile.y };
|
||||
}
|
||||
|
||||
switch (step.type) {
|
||||
case ObjectMoveType.Dir:
|
||||
case ObjectMoveType.DirFace:
|
||||
case ObjectMoveType.Special: {
|
||||
// 方向 → 偏移:moveDirection 已由基类按步骤内容算好
|
||||
const { x, y } = this.faceHandler.movement(this.moveDirection);
|
||||
return { x: this.tile.x + x, y: this.tile.y + y };
|
||||
}
|
||||
case ObjectMoveType.Teleport: {
|
||||
// rel 为 true 时,x / y 表示相对当前位置的偏移
|
||||
return {
|
||||
x: step.rel ? this.tile.x + step.x : step.x,
|
||||
y: step.rel ? this.tile.y + step.y : step.y
|
||||
};
|
||||
}
|
||||
default:
|
||||
// Face / Speed / AnimDir 不产生位移
|
||||
return { x: this.tile.x, y: this.tile.y };
|
||||
}
|
||||
}
|
||||
|
||||
// 标 @shouldReplay:重放时也要执行这个会写朝向的方法
|
||||
@shouldReplay('Simple tile facing should be replayed.')
|
||||
protected async onStepSettled(
|
||||
before: ITileLocator,
|
||||
curr: ITileLocator,
|
||||
step: Readonly<ObjectMoveStep>
|
||||
): Promise<void> {
|
||||
// 例如:this.tile.setFaceDirection(this.faceDirection);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
两条容易忘的约定:
|
||||
|
||||
- 通行判断不在移动器基类里。`SimpleMover` 只回答「这一步落到哪」,不检查目标格能不能进、有没有墙,也不触发踩踏事件;需要这些行为就照 `HeroMover` 的做法,把判定转发给一层独立的规则对象。
|
||||
- 方法体里只要有写操作(写朝向、写对象、派发事件),就给它标 `@shouldReplay`,标在真正写状态的那个方法上,不要标在只读查询上。原因与放置规则见[录像一致性](./replay-consistency.md)。
|
||||
|
||||
`faceHandler` 决定方向体系:四方向对象配 `Dir4FaceHandler`,八方向对象配 `Dir8FaceHandler`,混用会让偏移与朝向对不上。处理器本身的接口与自定义方式见[FaceHandler 系列](./face-handler.md#实现自己的朝向处理器)。
|
||||
|
||||
## 勇士的顶层实现
|
||||
|
||||
勇士的移动器自己不判定能不能走。`HeroMover` 在每一步把信息打包成 `IHeroMoveTopHandler`,交给 `IHeroMoveTopImpl` 判断。这是勇士唯一的公开扩展点:`state.hero.location.mover.useTopImplementation(impl)` 装上,传 `null` 会卸掉实现,之后每一步都会直接停下并告警。
|
||||
|
||||
| 方法 | 返回 | 作用 |
|
||||
| ------------------------ | ---------------- | ------------------------------------------------------ |
|
||||
| `predicate()` | `IPassPredicate` | 返回通行性谓词,`HeroMover` 用它判断能否进入、是否撞击 |
|
||||
| `inBound(x, y, floorId)` | `boolean` | 目标格是否在地图范围内 |
|
||||
| `enter(handler)` | `Promise<void>` | 正常进入目标格后触发 |
|
||||
| `leave(handler)` | `Promise<void>` | 离开当前格时触发 |
|
||||
| `hit(handler)` | `Promise<void>` | 撞到不可进入的目标格时触发 |
|
||||
| `cannotEnter(handler)` | `Promise<void>` | 被判定为无法进入时触发 |
|
||||
|
||||
`handler` 里带着 `currLoc`、`nextLoc`、`direction`、`floorId`、`face` 与 `state`,够你在判定里读位置、朝向与状态。`predicate()` 返回的谓词有两个方法:`canPass(handler)` 为假时本步不移动,走「无法进入」分支;`canPass` 为真且 `shouldHit(handler)` 为真时本步不移动,走「撞击」分支;两者都为真才正常移动。
|
||||
|
||||
内置的 `DefaultHeroMoveTopImpl` 是默认实现,它用通行谓词与事件系统完成边界判断、进出与撞击触发器。想保留默认行为、只改其中一环时,包一层并转发给默认实现最省事:
|
||||
|
||||
```ts
|
||||
// src/data/hero/moverTop.ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
import {
|
||||
IHeroMoveTopHandler,
|
||||
IHeroMoveTopImpl,
|
||||
IPassPredicate
|
||||
} from '@user/data-base';
|
||||
import { IStateSystem } from '@user/data-system';
|
||||
import { DefaultHeroMoveTopImpl } from './moverImpl';
|
||||
|
||||
/** 保留默认的边界、通行与事件,只禁止向左走。 */
|
||||
export class NoLeftMoveTopImpl implements IHeroMoveTopImpl {
|
||||
// 默认实现负责边界、通行谓词与四类事件派发
|
||||
private readonly base: DefaultHeroMoveTopImpl;
|
||||
// 每步都会调用 predicate(),构造时建好一次,避免每步新建
|
||||
private readonly passPredicate: IPassPredicate;
|
||||
|
||||
constructor(state: IStateSystem) {
|
||||
this.base = new DefaultHeroMoveTopImpl(state);
|
||||
const inner = this.base.predicate();
|
||||
this.passPredicate = {
|
||||
// 与默认谓词组合:向左的步骤一律不允许
|
||||
canPass: handler =>
|
||||
handler.direction !== FaceDirection.Left &&
|
||||
inner.canPass(handler),
|
||||
shouldHit: handler => inner.shouldHit(handler)
|
||||
};
|
||||
}
|
||||
|
||||
predicate(): IPassPredicate {
|
||||
return this.passPredicate;
|
||||
}
|
||||
|
||||
inBound(x: number, y: number, floorId: string | undefined): boolean {
|
||||
return this.base.inBound(x, y, floorId);
|
||||
}
|
||||
|
||||
enter(handler: IHeroMoveTopHandler): Promise<void> {
|
||||
return this.base.enter(handler);
|
||||
}
|
||||
|
||||
leave(handler: IHeroMoveTopHandler): Promise<void> {
|
||||
return this.base.leave(handler);
|
||||
}
|
||||
|
||||
hit(handler: IHeroMoveTopHandler): Promise<void> {
|
||||
return this.base.hit(handler);
|
||||
}
|
||||
|
||||
cannotEnter(handler: IHeroMoveTopHandler): Promise<void> {
|
||||
// 接口要求带 handler;默认实现用不到它,所以这里不转发
|
||||
return this.base.cannotEnter();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
装配时把它换进勇士移动器:
|
||||
|
||||
```ts
|
||||
// 依赖加载后才知道配置时,把这段放进 afterLoad
|
||||
state.hero.location.mover.useTopImplementation(new NoLeftMoveTopImpl(state));
|
||||
```
|
||||
|
||||
几点注意:
|
||||
|
||||
- `predicate()` 在每个方向步都会被调用,要返回构造时建好的稳定实例,不要在方法里新建对象(内置实现就是把 `DefaultPassPredicateImpl` 存在字段里)。
|
||||
- `config({ ignoreTerrain: true })` 会让 `HeroMover` 跳过整个谓词判断、只剩边界检查,此时改谓词看不到效果。
|
||||
- 寻路用的是同一形状的谓词,但由 `state.pathfinding.finder.usePassPredicate(...)` 独立注入,不会自动跟随勇士的顶层实现。改了勇士的通行规则、又希望寻路一致时,要把同一个谓词再注入给 `finder`,详见[自动寻路](../ii/pathfinding.md)。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- <strong>start 返回 null</strong>:移动器已经在移动时不会接管,此时也没有新的控制器可操作。要等或要停,只能用上一次 `start()` 保存的 `controller`:`await controller.onEnd`,或调用 `controller.stop()`。`IHeroMover` 没有 `stop()`,不要在移动器上找。返回 `null` 时刚排的步骤没有被消费,仍留在移动器里,下一次 `start()` 会执行;不想要就用 `clear()`。
|
||||
- <strong>moveDirection 被重置</strong>:`start()` 把移动朝向(`moveDirection`)设为 `Unknown`,面朝朝向(`faceDirection`)不受影响;`forward()` 用的是面朝朝向,不依赖这次重置。
|
||||
- <strong>执行期间链式方法无效</strong>:`mover.step`、`mover.face`、`mover.speed` 等写的是下一次的队列;执行中追加只能用 `controller.push` 或 `controller.insert`。
|
||||
- <strong>stop 不立刻中断</strong>:当前步会走完。第一步还没开始时调用,可能一步都不走。
|
||||
- <strong>不要在步进方法里 await stop</strong>:`stop()` 返回的 Promise 要等当前步结束才兑现,在步进方法或步进钩子里 `await` 会卡死整次移动。
|
||||
- <strong>步进方法或钩子抛错会卡住移动器,而且没有恢复入口</strong>:`ObjectMover` 没有为执行 Promise 兜底;任何一步或任一钩子抛错,`onEnd` 都不再兑现、`done` 保持假、`moving` 保持真,之后的 `start()` 会一直返回 `null`,这次拒绝也没有被捕获,会以未处理的 Promise 拒绝暴露出来。`moving` 对外是只读状态,`IHeroMover` / `IObjectMover` 没有重置或强制结束的方法;`controller.stop()` 只能置停止标记,无法兑现那个已经被拒绝的 Promise。结论是运行期没有恢复路径,只能重建数据端。要避免它:自定义步进方法与钩子把预期内的失败转成结果码或内部标记,用 `try` / `catch` 包住可能抛错的外部调用,不要让它冒泡。
|
||||
- <strong>setPos 不触发步进钩子</strong>:移动器的 `setPos` 只触发 `onSetPos`,不触发 `onStepStart` / `onStepEnd`;移动过程中的落点写入也不会触发 `onSetPos`。
|
||||
- <strong>speed 与 animDir 只是状态</strong>:它们不产生位移。没有消费方读取时,设置速度与动画方向不会产生可见效果。
|
||||
- <strong>faceHandler 必须与对象方向体系一致</strong>:四方向对象配八方向处理器,或反过来,都会让偏移与朝向对不上。
|
||||
- <strong>通行判断不在移动器里</strong>:移动器只算落点。能不能走、撞到什么、踩到什么,由调用方的规则决定。
|
||||
- <strong>控制器追加可能被忽略</strong>:已经 `stop` 或 `done` 之后,`controller.push` 与 `controller.insert` 静默不生效。
|
||||
- <strong>不要重复 new 现成移动器</strong>:`state.hero.location.mover` 与动态图块的 `mover` 与对象同生命周期,另建一个不会同步位置与朝向。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
先排一个固定的四步计划:向右两格、转向、向下一格,观察坐标、`done` 与钩子顺序:
|
||||
|
||||
```ts
|
||||
import { FaceDirection, IObjectMoverHooks } from '@user/data-common';
|
||||
import { IHeroLocation } from '@user/data-base';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
// 打印钩子顺序:期望 onMoveStart → 每步 onStepStart → onStepEnd → onMoveEnd
|
||||
class MoveOrderHooks implements IObjectMoverHooks<IHeroLocation> {
|
||||
async onMoveStart(): Promise<void> {
|
||||
console.log('onMoveStart');
|
||||
}
|
||||
|
||||
async onStepStart(code: number): Promise<void> {
|
||||
console.log('onStepStart', code);
|
||||
}
|
||||
|
||||
async onStepEnd(code: number): Promise<void> {
|
||||
console.log('onStepEnd', code);
|
||||
}
|
||||
|
||||
async onMoveEnd(): Promise<void> {
|
||||
console.log('onMoveEnd');
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPlan(state: ICoreState): Promise<void> {
|
||||
const mover = state.hero.location.mover;
|
||||
// 注册钩子后保存控制器,验证结束再卸载
|
||||
const hookController = mover.addHook(new MoveOrderHooks());
|
||||
|
||||
const before = state.hero.getLocation();
|
||||
const controller = mover
|
||||
.step(FaceDirection.Right, 2)
|
||||
.face(FaceDirection.Down)
|
||||
.step(FaceDirection.Down)
|
||||
.start();
|
||||
|
||||
if (!controller) {
|
||||
hookController.unload();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('done before end', controller.done); // false
|
||||
await controller.onEnd;
|
||||
console.log('done after end', controller.done); // true
|
||||
|
||||
const after = state.hero.getLocation();
|
||||
console.log('before', before.x, before.y, 'after', after.x, after.y);
|
||||
|
||||
hookController.unload();
|
||||
}
|
||||
```
|
||||
|
||||
预期:`done` 从假变真;整次共四个步骤(`Dir`、`Dir`、`Face`、`Dir`),坐标先水平移动两格,再向下移动一格;钩子顺序与表中一致,且每一步的 `onStepStart` 都在对应的 `onStepEnd` 之前。
|
||||
|
||||
再验证执行中追加:调用 `start()` 后立刻 `controller.push` 一个 `Dir` 步骤,最终坐标会多走这一步;改用 `mover.step` 追加则不会,因为那是下一次执行的队列。最后验证停止:在 `onStepEnd` 钩子里调用 `controller.stop()`(不要 `await`),移动应在当前步结束时停下,坐标只变化一格。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
@@ -0,0 +1,524 @@
|
||||
# 插件系统
|
||||
|
||||
如果你已经用过中级教程里的[数据插件](../../intermediate/data-plugins.md),你会知道插件能把一组功能打包、挂到 `state` 上,并按固定时机初始化。那一篇停在「会用」;这一篇讲怎么自己写一个插件、让插件彼此协作,以及怎么在插件之外调用它。
|
||||
|
||||
插件分数据端与渲染端两套,写法、注册方式与生命周期一一对应:数据端插件负责逻辑与状态,渲染端插件负责画面与交互。渲染端插件可以依赖数据端插件,反过来不行。下面两边并列讲,遇到不同的地方单独说明。
|
||||
|
||||
插件是高级 I 的核心:后面的可存档内容、录像一致性、钩子深入,实际都挂在某个插件上。
|
||||
|
||||
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。不需要懂引擎内部实现;涉及内部机制的地方,会先说清「你能拿它做什么」。
|
||||
|
||||
## 插件解决什么问题
|
||||
|
||||
假设你要加一个任务系统:它有自己的状态、自己的管理器,还要在加载完地图后读一遍任务配置。不做成插件的话,这些代码会散在几个地方——对象在 `src/data/initialize.ts` 里 `new`,存档注册也写在那里,加载后的初始化再塞进某个钩子。功能一多,`initialize.ts` 会变成所有人的公共草稿纸。
|
||||
|
||||
插件把这一组东西收进一个独立单元:
|
||||
|
||||
- 自己的对象在自己的目录里创建;
|
||||
- 自己的存档内容在自己的 `awake` 里注册;
|
||||
- 自己的加载后逻辑在自己的 `afterLoad` 里写;
|
||||
- 启动时只需要在对应端的插件列表里 `new` 一次。
|
||||
|
||||
这和[声明式接口](./declarative-vs-imperative.md)是同一个思路:你不去改引擎的初始化流程,只声明「在哪个时机做什么」,时机由引擎统一调用。
|
||||
|
||||
## 注册与目录
|
||||
|
||||
数据端插件放在 `src/data/plugin/`,渲染端插件放在 `src/client/plugin/`,各自在自己的目录里:
|
||||
|
||||
| 位置 | 放什么 |
|
||||
| ----------------------------- | ---------- |
|
||||
| `src/data/plugin/<插件名>/` | 数据端插件 |
|
||||
| `src/client/plugin/<插件名>/` | 渲染端插件 |
|
||||
|
||||
插件目录内部按需要拆文件,常见形状两端一致:
|
||||
|
||||
| 文件 | 放什么 |
|
||||
| --------------------------------------- | ------------------------------------------------------------- |
|
||||
| `plugin.ts` | 插件类本身,实现 `IDataPluginDefine` 或 `IClientPluginDefine` |
|
||||
| `types.ts` | 插件对外接口、存档数据结构 |
|
||||
| `state.ts` / `manager.ts` / `system.ts` | 插件自己的对象,按需创建 |
|
||||
| `index.ts` | 统一导出 |
|
||||
|
||||
写好后,把插件实例 `new` 进对应端的列表:
|
||||
|
||||
| 端 | 列表文件 | 数组 |
|
||||
| ------ | --------------------------- | --------------- |
|
||||
| 数据端 | `src/data/plugin/list.ts` | `dataPlugins` |
|
||||
| 渲染端 | `src/client/plugin/list.ts` | `clientPlugins` |
|
||||
|
||||
```ts
|
||||
// src/data/plugin/list.ts
|
||||
import { IDataPluginDefine } from '@user/data-state';
|
||||
import { DataShopPlugin } from './shop';
|
||||
// 新建插件文件时,必须补上你自己的 import
|
||||
import { MyPlugin } from './my-plugin';
|
||||
|
||||
/** 数据端插件列表 */
|
||||
export const dataPlugins: IDataPluginDefine[] = [
|
||||
new DataShopPlugin(),
|
||||
// 追加到末尾,不要替换上面的已有插件
|
||||
new MyPlugin()
|
||||
];
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/client/plugin/list.ts
|
||||
import { IClientPluginDefine } from '@user/client-modules';
|
||||
import { ClientShopPlugin } from './shop';
|
||||
import { MyClientPlugin } from './my-client';
|
||||
|
||||
/** 渲染端插件列表 */
|
||||
export const clientPlugins: IClientPluginDefine[] = [
|
||||
new ClientShopPlugin(),
|
||||
// 追加到末尾,不要替换上面的已有插件
|
||||
new MyClientPlugin()
|
||||
];
|
||||
```
|
||||
|
||||
新建插件时,`list.ts` 里不会有你的类名,必须自己补一行 `import`,路径指向你的插件目录。只把数组写成 `[new MyPlugin()]` 会把已有插件挤掉。
|
||||
|
||||
两个入口文件分别把主插件放在数组前面,再展开各自的列表:`src/data/entry.ts` 中是 `[new MainDataPlugin(HERO_FACE), ...dataPlugins]`,`src/client/entry.ts` 中是 `[new MainClientPlugin(), ...clientPlugins]`。日常只需要改两个 `list.ts`,不必动入口。
|
||||
|
||||
同一端的注册表里,`id` 重复时后一个会被忽略,控制台报一条错误(错误码 `78`),所以请保证 `id` 在各自的注册表里唯一。
|
||||
|
||||
## 插件接口
|
||||
|
||||
数据端插件实现 `IDataPluginDefine`(来自 `@user/data-state`),渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`)。两者都由 `id`、依赖声明与三个时机方法组成;渲染端多一份 `clientDeps`,生命周期方法也多一个依赖参数。
|
||||
|
||||
如果你想新增一个插件,可以参考下面两个示例。数据端与渲染端各一份,结构相同。
|
||||
|
||||
### 数据端插件示例
|
||||
|
||||
```ts
|
||||
// src/data/plugin/my-plugin/types.ts
|
||||
import { IDataPluginDefine } from '@user/data-state';
|
||||
|
||||
/** 插件对外暴露的能力;依赖它的插件按这个接口调用 */
|
||||
export interface IMyPlugin extends IDataPluginDefine {
|
||||
/** 插件公开给其他插件调用的方法 */
|
||||
doSomething(): void;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/data/plugin/my-plugin/plugin.ts
|
||||
import { ICoreState, IDataPlugin } from '@user/data-state';
|
||||
import { IMyPlugin } from './types';
|
||||
|
||||
// 数据端插件类;注册表按 id 创建它
|
||||
export class MyPlugin implements IMyPlugin {
|
||||
// 插件标识符:在数据端注册表里唯一
|
||||
readonly id = 'my-plugin';
|
||||
// 依赖的其他数据端插件 id;没有依赖就留空
|
||||
readonly dataDeps: string[] = [];
|
||||
|
||||
// 同步初始化:创建插件自己的对象
|
||||
awake(state: ICoreState): void {
|
||||
// 在这里装配插件自己的对象
|
||||
// 需要进存档的对象,在这里调用 state.addSaveableContent(...)
|
||||
}
|
||||
|
||||
doSomething(): void {
|
||||
// 对外方法:其他插件、钩子等通过插件对象调用
|
||||
}
|
||||
|
||||
// 异步初始化:加载开始前执行;不需要就留空
|
||||
async initialize(
|
||||
state: ICoreState,
|
||||
dependencies: Readonly<IDataPlugin>[]
|
||||
): Promise<void> {}
|
||||
|
||||
// 加载完毕后执行;需要读加载内容时再填
|
||||
async afterLoad(
|
||||
state: ICoreState,
|
||||
dependencies: Readonly<IDataPlugin>[]
|
||||
): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
### 渲染端插件示例
|
||||
|
||||
```ts
|
||||
import {
|
||||
IClientCore,
|
||||
IClientPlugin,
|
||||
IClientPluginDefine
|
||||
} from '@user/client-modules';
|
||||
import { IDataPlugin } from '@user/data-state';
|
||||
|
||||
// 渲染端插件类,文件放在 src/client/plugin/my-client/plugin.ts
|
||||
export class MyClientPlugin implements IClientPluginDefine {
|
||||
// 插件标识符:在渲染端注册表里唯一
|
||||
readonly id = 'my-client-plugin';
|
||||
// 依赖的数据端插件 id;没有依赖就留空
|
||||
readonly dataDeps: string[] = [];
|
||||
// 依赖的渲染端插件 id;没有依赖就留空
|
||||
readonly clientDeps: string[] = [];
|
||||
|
||||
// 同步初始化:三个时机都会收到已解析的依赖
|
||||
awake(
|
||||
state: IClientCore,
|
||||
dataDeps: IDataPlugin[],
|
||||
clientDeps: IClientPlugin[]
|
||||
): void {
|
||||
// 依赖已经解析好,直接取用即可
|
||||
}
|
||||
|
||||
// 异步初始化:加载开始前执行
|
||||
async initialize(
|
||||
state: IClientCore,
|
||||
dataDeps: IDataPlugin[],
|
||||
clientDeps: IClientPlugin[]
|
||||
): Promise<void> {}
|
||||
|
||||
// 加载完毕后执行
|
||||
async afterLoad(
|
||||
state: IClientCore,
|
||||
dataDeps: IDataPlugin[],
|
||||
clientDeps: IClientPlugin[]
|
||||
): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
两个示例都声明了一个实现插件接口的类:数据端的 `MyPlugin` 实现 `IMyPlugin extends IDataPluginDefine`,渲染端的 `MyClientPlugin` 实现 `IClientPluginDefine`。它们都没有构造器,插件需要的对象要在 `awake` 里自己创建。三个时机方法各管一段:`awake` 里装配对象、`initialize` 里做加载前的异步准备、`afterLoad` 里做依赖加载内容的初始化。数据端示例把对外接口放在 `types.ts`,是为了让依赖它的插件能按接口调用;渲染端示例暂时没有单独拆文件。
|
||||
|
||||
### 逐项说明
|
||||
|
||||
**`id`**:两端的插件标识符,在各自的注册表里唯一。其他插件按这个字符串声明依赖,`getDataPlugin` / `getClientPlugin` 也按它取回插件。
|
||||
|
||||
**`dataDeps`**:依赖的数据端插件 id 数组。数据端插件只能填数据端插件;渲染端插件可以填任何数据端插件。没有依赖就留空数组。
|
||||
|
||||
**`clientDeps`**:只有渲染端插件有,写依赖的渲染端插件 id。没有依赖同样留空。
|
||||
|
||||
依赖方向是单向的:<strong>渲染端可以依赖数据端,数据端不能依赖渲染端</strong>。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。
|
||||
|
||||
**依赖参数**:三个时机方法收到的依赖是解析后的插件包装对象,不是 id 字符串,形如 `{ status, plugin }`。数据端插件的参数统一叫 `dependencies`,类型是 `Readonly<IDataPlugin>[]`;渲染端插件分成 `dataDeps` 与 `clientDeps` 两个参数,类型分别是 `IDataPlugin[]` 与 `IClientPlugin[]`,顺序与声明一致。不需要时可以把依赖参数整个省略。
|
||||
|
||||
依赖在插件执行前已经解析完毕。如果声明在 `dataDeps` / `clientDeps` 里的依赖缺失或成环,插件本身不会被加载,所以拿到依赖后直接取 `.plugin` 用即可,<strong>不要判空</strong>。
|
||||
|
||||
**`awake`**:同步初始化,在主对象构造期调用。`awake` 返回 `void`,引擎不会等待它的返回值;需要 `await` 的东西放到 `initialize`。数据端此时地图与配置都还没加载,渲染端此时刚拿到渲染端主对象。
|
||||
|
||||
**`initialize`**:异步初始化,在加载开始前调用,会被 `await`。它不兑现,后面的插件与加载都不会继续。
|
||||
|
||||
**`afterLoad`**:加载完毕后调用,同样会被 `await`。适合读取加载内容之后再装配的对象。
|
||||
|
||||
三个时机的具体位置见下一节。
|
||||
|
||||
## 生命周期
|
||||
|
||||
三个时机在启动流程里的位置是固定的。两端一一对应,下面按数据端的执行顺序说明:
|
||||
|
||||
```text
|
||||
CoreState 构造
|
||||
└─ awake 同步执行,全部可初始化插件依次完成
|
||||
load() 开始
|
||||
├─ initialize 异步,加载开始前
|
||||
├─ loader.start() 真正加载数据、产出进度
|
||||
└─ afterLoad 异步,加载完毕后
|
||||
```
|
||||
|
||||
### awake:主对象构造期,同步执行
|
||||
|
||||
`awake` 在主对象构造过程中被调用,此时地图、配置都还没加载。它返回 `void`,<strong>引擎不会等待它的返回值</strong>——即使你把它写成 `async`,引擎也会立刻走到下一个插件。
|
||||
|
||||
所以 `awake` 适合做「不需要等外部数据」的装配:
|
||||
|
||||
- `new` 出插件自己的系统、管理器;
|
||||
- 把它们挂到插件对象上,供后续调用;
|
||||
- 调用 `state.addSaveableContent(id, content)` 注册需要进存档的对象。
|
||||
|
||||
需要在 `awake` 里等的东西(读文件、等网络、等加载器),都不属于这里,放到 `initialize`。渲染端插件同样在渲染端主对象构造期执行 `awake`,只是没有存档注册这一类数据端操作。
|
||||
|
||||
### initialize:加载开始前
|
||||
|
||||
`initialize` 在 `load()` 的最前面被调用,此时数据还没开始加载。它返回 `Promise`,引擎会 `await` 它。
|
||||
|
||||
只要这个 Promise 不兑现,后面的插件(包括依赖它的)都不会继续,`loader.start()` 也不会开始。所以这里可以放心做「加载前必须完成」的异步准备,但不要在里面等一个永远不会结束的任务。
|
||||
|
||||
### afterLoad:加载完毕后
|
||||
|
||||
`afterLoad` 在整个加载结束之后被调用,同样返回 `Promise` 并被等待。它适合「必须读到加载内容才能做」的初始化,例如读核心配置、读地图、根据数据创建对象。
|
||||
|
||||
`load()` 的最后一步就是 `await afterLoad()`:它不兑现,加载流程就不算结束。
|
||||
|
||||
### 状态与顺序
|
||||
|
||||
每个插件在注册表里都带着一个 `PluginStatus`,随阶段推进:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| -------------------------- | ----------------------------- |
|
||||
| `PluginStatus.NotAwakened` | 还没执行 `awake` |
|
||||
| `PluginStatus.Awakened` | 已执行 `awake`,未异步初始化 |
|
||||
| `PluginStatus.Initialized` | 已执行 `initialize`,未加载完 |
|
||||
| `PluginStatus.Loaded` | 三个时机都已完成 |
|
||||
|
||||
三个阶段都按同一套依赖关系执行:<strong>被依赖的插件先完成同一阶段,依赖它的一方才会开始</strong>。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。不构成依赖关系的插件之间,不要假定谁先谁后;需要顺序就显式声明依赖。
|
||||
|
||||
依赖关系在启动前定好,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变执行顺序,也没有接口在运行时追加插件。
|
||||
|
||||
依赖写错时,整条链路会被阻断:
|
||||
|
||||
- <strong>循环依赖</strong>:环上的插件全部被排除,控制台报一条错误(错误码 `76`),列出环上的 id;
|
||||
- <strong>缺失依赖</strong>:`dataDeps` 里写了一个没有注册的 id,该插件被排除,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
|
||||
- <strong>下游阻断</strong>:依赖上述插件的插件也会一起被排除,三个时机一个都不执行。
|
||||
|
||||
排查时先看控制台里的错误码与 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。渲染端插件之间的顺序按 `clientDeps` 同样处理;`dataDeps` 指向的数据端插件在渲染端启动前已经就绪。
|
||||
|
||||
## 调用插件
|
||||
|
||||
插件的能力不只在插件之间使用。其他插件、钩子、事件、命令式脚本、渲染端 UI,只要拿得到对应的主对象,都可以调用插件的公开成员。
|
||||
|
||||
### 在插件里用依赖参数
|
||||
|
||||
声明了依赖,最直接的方式就是用时机方法参数里的插件对象。注意它是 `{ status, plugin }` 包装对象,你要取 `.plugin`:
|
||||
|
||||
```ts
|
||||
import { ICoreState, IDataPlugin, IDataPluginDefine } from '@user/data-state';
|
||||
import { IShopPlugin } from '../shop';
|
||||
|
||||
// 依赖商店插件,在初始化时调用它的公开成员
|
||||
export class ShopConsumerPlugin implements IDataPluginDefine {
|
||||
// 本插件的唯一标识
|
||||
readonly id = 'shop-consumer-plugin';
|
||||
// 依赖顺序与 dataDeps 数组一致
|
||||
readonly dataDeps = ['data-shop-plugin'];
|
||||
|
||||
awake(state: ICoreState, dependencies: Readonly<IDataPlugin>[]): void {
|
||||
// 依赖已经解析好,直接取 .plugin,不需要判空
|
||||
const [{ plugin }] = dependencies;
|
||||
// 断言成商店插件的对外接口,才能调用它公开的方法
|
||||
const dataShop = plugin as IShopPlugin;
|
||||
const shop = dataShop.createShopState('my-shop');
|
||||
shop.enable();
|
||||
}
|
||||
|
||||
// 本示例不需要异步初始化,留空即可
|
||||
async initialize(
|
||||
state: ICoreState,
|
||||
dependencies: Readonly<IDataPlugin>[]
|
||||
): Promise<void> {}
|
||||
|
||||
// 本示例不需要加载后处理
|
||||
async afterLoad(
|
||||
state: ICoreState,
|
||||
dependencies: Readonly<IDataPlugin>[]
|
||||
): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
依赖数组的顺序与 `dataDeps` 一致。元素是注册表里同一个包装对象,它的 `status` 会随阶段推进更新:你在自己 `awake` 里看到的依赖状态是 `Awakened`,在 `afterLoad` 里看到的通常已经是 `Loaded`。
|
||||
|
||||
要调用插件自己的成员,需要把 `.plugin` 断言成它的对外接口(例如 `IShopPlugin`)。所以写插件时,如果希望别人调用你的方法,就在 `types.ts` 里定一个 `IMyPlugin extends IDataPluginDefine`,把公开成员写进去。渲染端插件的 `dataDeps` / `clientDeps` 参数同理,分别断言成数据端接口与渲染端接口。
|
||||
|
||||
### 用 getDataPlugin / getClientPlugin 取回
|
||||
|
||||
没有声明依赖、或者在启动之后才按需访问时,数据端用 `state.getDataPlugin(id)`,渲染端用 `state.getClientPlugin(id)`。两端返回的都是只读的插件包装对象,取不到时为 `null`:
|
||||
|
||||
```ts
|
||||
import { ICoreState, PluginStatus } from '@user/data-state';
|
||||
import { IShopPlugin } from './types';
|
||||
|
||||
// 数据端:按 id 取回插件,确认状态后再调用
|
||||
export function enableShop(state: ICoreState): void {
|
||||
const holder = state.getDataPlugin<IShopPlugin>('data-shop-plugin');
|
||||
if (!holder || holder.status !== PluginStatus.Loaded) {
|
||||
// 没注册、id 拼错,或还没执行到 afterLoad
|
||||
return;
|
||||
}
|
||||
holder.plugin.createShopState('my-shop').enable();
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { IClientCore } from '@user/client-modules';
|
||||
import { PluginStatus } from '@user/data-state';
|
||||
import { IClientShopPlugin } from './types';
|
||||
|
||||
// 渲染端:接口名与取回方法不同,判空与状态检查完全一致
|
||||
export function useShopUI(state: IClientCore): void {
|
||||
const holder =
|
||||
state.getClientPlugin<IClientShopPlugin>('client-shop-plugin');
|
||||
if (!holder || holder.status !== PluginStatus.Loaded) {
|
||||
// 渲染端插件同样可能还没到达 Loaded
|
||||
return;
|
||||
}
|
||||
// 取回的是插件实例,公开成员可以直接使用
|
||||
console.log(holder.plugin.ShopUI);
|
||||
}
|
||||
```
|
||||
|
||||
`getDataPlugin` 返回 `Readonly<IDataPlugin<T>> | null`,`getClientPlugin` 返回 `Readonly<IClientPlugin<T>> | null`。这里和依赖参数相反,两个检查都不能省:
|
||||
|
||||
- 返回 `null` 表示没有这个 `id`,可能是没注册,也可能是拼写错了;
|
||||
- `status` 没到你要的阶段,说明它还没执行到那一步,此时调用它的方法可能读到还没装配好的字段。
|
||||
|
||||
如果在启动阶段就要用某个插件,优先声明依赖,让引擎保证顺序;这两个按 id 取回的方法更适合可选的、运行中才发生的调用。
|
||||
|
||||
### 插件之外也能调用
|
||||
|
||||
调用方不必是插件。钩子、事件脚本、命令式函数,只要拿得到 `state`,都能按 id 取回插件并调用它的公开成员:
|
||||
|
||||
```ts
|
||||
import { ICoreState, PluginStatus } from '@user/data-state';
|
||||
import { IHeroAttr } from '@user/data-common';
|
||||
import { IHeroAttributeHooks } from '@user/data-base';
|
||||
import { IMyPlugin } from './types';
|
||||
|
||||
// 钩子里调用插件:钩子只负责在合适的时机触发
|
||||
export class MyAttributeHooks implements IHeroAttributeHooks<IHeroAttr> {
|
||||
constructor(private readonly state: ICoreState) {}
|
||||
|
||||
onUpdateAttribute<K extends keyof IHeroAttr>(
|
||||
name: K,
|
||||
value: IHeroAttr[K]
|
||||
): void {
|
||||
const holder = this.state.getDataPlugin<IMyPlugin>('my-plugin');
|
||||
if (holder?.status === PluginStatus.Loaded) {
|
||||
holder.plugin.doSomething();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里同样要判空与检查状态,因为 `getDataPlugin` 只是查注册表,不保证插件已经加载完成。渲染端插件用 `getClientPlugin`,写法一致。
|
||||
|
||||
## 示例:读一个现有插件
|
||||
|
||||
### DataShopPlugin:awake 里装配并注册存档
|
||||
|
||||
商店插件是 `dataPlugins` 里当前唯一的独立插件,适合逐段对照。它的代码在 `src/data/plugin/shop/plugin.ts`:
|
||||
|
||||
```ts
|
||||
// 商店插件:把系统、管理器与存档注册收进一个单元
|
||||
export class DataShopPlugin implements IShopPlugin {
|
||||
// 数据端注册表里的唯一标识
|
||||
id: string = 'data-shop-plugin';
|
||||
// 不依赖其他插件,因此会在第一批 awake
|
||||
dataDeps: string[] = [];
|
||||
|
||||
// 对外暴露的系统与管理器,其他插件可以直接调用
|
||||
shop!: IShopSystem;
|
||||
manager!: IShopManager;
|
||||
|
||||
/** 数据端主对象 */
|
||||
// awake 时保存,供创建商店状态使用
|
||||
private state!: ICoreState;
|
||||
|
||||
// 其他插件通过这个方法创建一份商店状态
|
||||
createShopState(id: string): IShopState {
|
||||
return new ShopState(id, this.state);
|
||||
}
|
||||
|
||||
awake(state: ICoreState): void {
|
||||
this.state = state;
|
||||
// 这两个对象都需要主对象,只能在这里 new
|
||||
this.shop = new ShopSystem(state);
|
||||
this.manager = new ShopManager(state);
|
||||
|
||||
// 注册的是管理器,不是插件本身
|
||||
state.addSaveableContent('@plugin/shop', this.manager);
|
||||
}
|
||||
|
||||
// 商店不依赖加载内容,两个异步时机留空
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async afterLoad(): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
逐段看:
|
||||
|
||||
- `id` 是 `data-shop-plugin`,没有依赖,所以它会在 `awake` 的第一批里执行;
|
||||
- `awake` 先保存 `state`,再创建系统与管理器。这两个对象都需要主对象,所以只能在这里 `new`;
|
||||
- `state.addSaveableContent('@plugin/shop', this.manager)` 把管理器注册为存档内容。<strong>注册的是管理器,不是插件本身</strong>;
|
||||
- `initialize` 与 `afterLoad` 都是空的:商店不依赖加载内容,装配在 `awake` 就完成了。
|
||||
|
||||
`IShopPlugin` 在 `IDataPluginDefine` 之上加了 `shop`、`manager`、`createShopState`,这就是「插件对外暴露能力」的写法。别的插件依赖它时,就能直接调用这些成员。
|
||||
|
||||
渲染端也有形状对应的 `ClientShopPlugin`(`src/client/plugin/shop/plugin.ts`):它的三个时机都是空的,只把 `Shop` 与 `ShopUI` 作为公开成员暴露出来,供其他渲染端插件使用。两端插件的结构一致,差别只在依赖与生命周期参数。
|
||||
|
||||
### MainDataPlugin:afterLoad 读取加载后的配置
|
||||
|
||||
主插件 `src/data/initialize.ts` 的 `MainDataPlugin` 是 `afterLoad` 的典型用法。它的 `awake` 负责装配朝向、寻路、加载钩子与各系统;创建勇士则放到 `afterLoad`:
|
||||
|
||||
```ts
|
||||
import { ICoreState, ICoreStateCoreConfig } from '@user/data-state';
|
||||
import { FaceDirection, FaceGroup, IFacedTileLocator } from '@user/data-common';
|
||||
import { logger } from '@motajs/common';
|
||||
import { IDataMainPlugin } from './types';
|
||||
|
||||
// 主插件节选:只保留 afterLoad,其余成员用注释省略
|
||||
export class MainDataPlugin implements IDataMainPlugin {
|
||||
readonly id = 'main-data-plugin';
|
||||
readonly dataDeps: string[] = [];
|
||||
|
||||
// 朝向组由入口在构造时传入
|
||||
constructor(readonly heroFace: FaceGroup) {}
|
||||
|
||||
// ... awake / initialize 等成员省略
|
||||
|
||||
async afterLoad(state: ICoreState): Promise<void> {
|
||||
// 核心配置只有在加载完成后才拿得到
|
||||
const coreConfig = state.loader.getConfig<ICoreStateCoreConfig>('core');
|
||||
if (!coreConfig) {
|
||||
logger.error(79, 'Data');
|
||||
return;
|
||||
}
|
||||
// 朝向组在 awake 里已经由入口传入,这里可以直接用
|
||||
const dir = state.faceManager.get(this.heroFace);
|
||||
if (!dir) {
|
||||
logger.error(80);
|
||||
return;
|
||||
}
|
||||
// 属性与位置都要靠加载后的配置才能创建
|
||||
state.hero.createAttribute(coreConfig.init.hero.attribute);
|
||||
const location: IFacedTileLocator = {
|
||||
x: coreConfig.init.hero.x,
|
||||
y: coreConfig.init.hero.y,
|
||||
direction: FaceDirection.Down
|
||||
};
|
||||
state.hero.createLocation(location, dir);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段为什么不能放进 `awake`:`coreConfig` 来自 `core.jsonc`,要等加载器读完才有。`awake` 阶段 `getConfig` 拿不到内容,创建勇士就会失败。`afterLoad` 正好是「加载已完成、但游戏还没正式开始」的时机。它同时说明了另一个点:`afterLoad` 拿到的 `state` 与 `awake` 是同一个主对象,`awake` 里存下的引用(如 `this.heroFace`)此时仍然可用。
|
||||
|
||||
## 验证
|
||||
|
||||
启动完成后,用 `state.getDataPlugin` 取回插件,确认它已经进了注册表:
|
||||
|
||||
```ts
|
||||
import { ICoreState, PluginStatus } from '@user/data-state';
|
||||
import { IMyPlugin } from './types';
|
||||
|
||||
// 启动完成后取回插件并检查状态
|
||||
export function checkMyPlugin(state: ICoreState): void {
|
||||
const holder = state.getDataPlugin<IMyPlugin>('my-plugin');
|
||||
console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
|
||||
}
|
||||
```
|
||||
|
||||
`PluginStatus` 与 `ICoreState` 从 `@user/data-state` 引入,`IMyPlugin` 是你写在插件 `types.ts` 里的对外接口。`status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。
|
||||
|
||||
想确认依赖阻断,可以把 `dataDeps` 里的某个 id 改成一个不存在的名字再启动:控制台应报缺失依赖错误(`77`,带插件 id 与缺失的依赖 id),并且你的插件没有任何时机输出。插件注册的存档内容如何验证,见[可存档内容](./saveable-content.md#验证)的「验证」一节。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- <strong>在 awake 里 await</strong>。`awake` 的返回值会被忽略,写 `async awake` 也不会被等待。异步准备放到 `initialize`。
|
||||
- <strong>把 dataDeps 写成对象</strong>。它必须是 id 字符串数组;插件对象是三个时机方法收到的参数,不是声明时写的东西。
|
||||
- <strong>对依赖参数判空</strong>。依赖在插件执行前已经解析好,写 `dataDeps[0]?.plugin` 只会掩盖声明错误;直接取 `dataDeps[0].plugin`。
|
||||
- <strong>依赖成环</strong>。环上的插件全部被排除,依赖它们的下游也不执行。依赖关系要单向。
|
||||
- <strong>依赖 id 拼错</strong>。插件不会报「找不到类」,而是被当作缺失依赖排除;控制台会给出插件 id 与缺失的 id。
|
||||
- <strong>用 getDataPlugin 不判空</strong>。它查的是注册表,可能返回 `null`,也可能还没到你需要的阶段。
|
||||
- <strong>直接改其他系统的内部字段</strong>。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。
|
||||
|
||||
## 小结
|
||||
|
||||
- 插件把一组对象与它们的生命周期收进一个独立单元,在对应端的 `list.ts` 里 `new` 一次。
|
||||
- 数据端与渲染端的写法一一对应;`dataDeps` 声明数据端依赖,渲染端另有 `clientDeps`;渲染端可以依赖数据端,数据端不能依赖渲染端。
|
||||
- `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。
|
||||
- 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。
|
||||
- 插件可以被其他插件调用,也可以在钩子、事件或任何拿得到主对象的地方按 id 取回调用。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
@@ -0,0 +1,138 @@
|
||||
# 录像一致性
|
||||
|
||||
录像验证的用法是:把玩家的操作录下来,之后只跑数据端、按顺序重放,看结果与原始游玩是否一致。要让这件事成立,前提是<strong>该记的行为一条都不能少</strong>。少记一条,重放时那一步就没有发生,之后每一步都建立在错误的状态上,而第一次出现差异的位置,往往离真正的原因很远。
|
||||
|
||||
中级教程的[录像指令](../../intermediate/replay-commands.md)讲了录像是什么、主动与被动的区别、以及怎么新增一条指令。这一篇往下一层:`@shouldReplay` 与 `@ignoreReplay` 这两个装饰器该写在什么方法上,它们能替你查出什么、查不出什么。
|
||||
|
||||
阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../../intermediate/code-basics.md)。
|
||||
|
||||
## 什么该记
|
||||
|
||||
同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。
|
||||
|
||||
核心原则在中级已经给过,这里再抄一遍,因为后面所有判断都从它出发:
|
||||
|
||||
:::info 核心原则
|
||||
玩家直接产生、会影响最终成绩的行为,必须计入录像。
|
||||
:::
|
||||
|
||||
两个条件缺一不可:由玩家亲手做出(移动、使用道具、装备、触发事件),并且会改变游戏结果。代码自己跑出来的、只影响画面的,都不算。
|
||||
|
||||
一个方法该不该记,<strong>通常是分析出来的,不是试出来的</strong>。先问三件事:
|
||||
|
||||
- 这个方法能不能被玩家直接触发?
|
||||
- 它会不会改变游戏状态?
|
||||
- 这个改变有没有可能影响最终结果?
|
||||
|
||||
三个答案都是「是」,就加 `@shouldReplay`。如果你暂时判断不了,最稳妥的做法是:<strong>把所有「修改了游戏状态、且该状态可能导致结果改变」的方法都加上标记</strong>。漏标的代价是录像悄悄错掉;多标的代价只是告警里多一条路径,前者难查得多。
|
||||
|
||||
只读查询、纯计算、展示与搜索路径不要标。它们不改变游戏状态,标了只会让告警列表变长,把真正的漏记埋进噪声里。
|
||||
|
||||
## 给会改状态的方法加标记
|
||||
|
||||
标记加在<strong>你自己的、会改状态的方法</strong>上。如果你不确定该把标记写在哪里,可以参考下面的示例:它把一个会改状态的公开方法标成「应计入录像」,其余成员用注释省略。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记:
|
||||
|
||||
```ts
|
||||
// shouldReplay 从数据端公共包引入
|
||||
import { shouldReplay } from '@user/data-common';
|
||||
import { IShopSystem, ShopTradeResult } from './types';
|
||||
|
||||
// 商店插件节选:完整类结构,未展示的成员用注释省略
|
||||
export class ShopSystem implements IShopSystem {
|
||||
// ... 其他成员
|
||||
|
||||
// 购买会改变金币与商店库存,必须计入录像
|
||||
// 装饰器参数是写给人看的说明,出现在告警里
|
||||
@shouldReplay('Buying items should be replayed.')
|
||||
buyItem(item: number | string, count: number): ShopTradeResult {
|
||||
// ... 原有的购买逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个示例声明了 `ShopSystem` 类,实现 `IShopSystem` 接口;`@shouldReplay` 紧贴在 `buyItem` 方法上方,装饰的是这个方法。`message` 应当写清这个方法改变了什么状态;同一个类里还有其它会改状态的入口时,按同样方式逐个标注。
|
||||
|
||||
装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。
|
||||
|
||||
<strong>`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。</strong>录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。
|
||||
|
||||
## 两个装饰器
|
||||
|
||||
两个装饰器都写在<strong>类的方法</strong>上方,作用对象是「录像安全检查」,不是录像本身。
|
||||
|
||||
| 装饰器 | 写在哪 | 做什么 |
|
||||
| ------------------------ | --------------------------------- | ------------------------------------- |
|
||||
| `@shouldReplay(message)` | 会改变游戏状态的方法上 | 把「这个方法被调用过」登记成一条路径 |
|
||||
| `@ignoreReplay(reason)` | 确实不该计入录像的被动 / 延迟路径 | 把<strong>整段</strong>检查标记为忽略 |
|
||||
|
||||
:::warning 录像记录缺失
|
||||
如果带 `@shouldReplay` 的方法执行了,而这次执行没有产生任何录像记录,控制台会输出一条<strong>「录像记录缺失」</strong>的警告,并列出这次执行经过的标记方法。看到它,说明这条路径该记却没记。检测的内部原理留到 `高级 III · 录像深层原理`。
|
||||
|
||||
警告里还会附一条可直接复制的调用,形如 `Mota.require('@user/data-common').logReplaySafetyDetail(0);`。把它粘到控制台执行,就能展开这次检查的完整调用路径,逐层看到经过的标记方法。这个调用形式之后可能会调整,以控制台实际打印的那条为准。
|
||||
:::
|
||||
|
||||
### @shouldReplay:只登记路径,不写录像
|
||||
|
||||
`@shouldReplay(message)` 把方法标记为「应该计入录像」。它<strong>不写录像</strong>:真正记录仍要靠 `state.replaySystem.record(code, ...params)`,或者走内置的录制入口。它做的是让安全检查知道「这条路径执行过」,以便和录像里实际产生的步数对照。
|
||||
|
||||
`message` 是写给人看的说明,用来在警告里指认方法;语言和措辞不影响行为,写清这个方法改变了什么状态即可。
|
||||
|
||||
### @ignoreReplay:忽略整段检查
|
||||
|
||||
`@ignoreReplay(reason)` 容易被误解成「只忽略这个方法」。实际不是:它把<strong>整段</strong>安全检查标记为忽略,包括同一段里其它正常登记的 `@shouldReplay` 路径。
|
||||
|
||||
所以它不能用来「消掉某条告警」。你消掉的是整段检查,真正的漏记会被一起放过。它只适合确实不该计入录像的路径,例如异步内容、延迟回调这类不会立刻改变状态的方法。源码注释也明确写了这层意思,并提醒不要用它规避告警。`reason` 目前不会出现在告警输出里,写它是为了后来的人知道这里为什么可以忽略。
|
||||
|
||||
同一个插件里,入口方法与被它调用的内部方法可以都标:安全检查会按调用嵌套组织路径,越靠里越接近真正改变状态的位置。但把标记集中在真正改状态的那一层,告警会更短、更好读。
|
||||
|
||||
## 能保证什么,不能保证什么
|
||||
|
||||
检查能做的事很有限,不要把它当成正确性证明。
|
||||
|
||||
<strong>能保证的</strong>:带标记的路径执行了却没有留下录像记录时,控制台会告警,并列出这段执行经过的标记方法;路径按调用嵌套组织,能帮你定位是哪条调用链没有记。
|
||||
|
||||
<strong>不能保证的</strong>:
|
||||
|
||||
| 它不检查 | 说明 |
|
||||
| -------------- | ------------------------------------------------------------ |
|
||||
| 逻辑是否正确 | 方法算错了,只要产生了录像记录,检查照样通过 |
|
||||
| 参数是否正确 | 录像步的参数写错了、写少了,检查不看内容 |
|
||||
| 顺序是否正确 | 它只关心有没有记录,不比较先后 |
|
||||
| 是否一一对应 | 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步 |
|
||||
| 是否漏了入口 | 没有标记的方法不会被登记,自然也不会出现在告警里 |
|
||||
| 是否会自动记录 | `@shouldReplay` 只登记路径,记录仍要 `record` 或内置录制入口 |
|
||||
|
||||
最后两条是实践中最容易踩的:<strong>没标记的方法,检查器根本不知道它存在</strong>;而标了标记的方法,也不会因此自动写录像。
|
||||
|
||||
## 验证
|
||||
|
||||
录像一致性最终要靠回放对比:录一段包含目标操作的录像,回放后检查关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。
|
||||
|
||||
如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。想看更细的调用路径,就执行警告里附带的 `logReplaySafetyDetail(...)` 调用;命令形式若有变化,以控制台输出为准。
|
||||
|
||||
参数与顺序需要自己核对,装饰器不会替你检查这些。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- <strong>在渲染端加标记</strong>。`@shouldReplay` 只用于数据端;渲染端不参与录像验证,加了也不会进入检查。
|
||||
- <strong>读档期间录像被禁用</strong>。`CoreState.loadState` 在恢复每个可存档对象之前调用 `replaySystem.disable()`,之后调用 `revert()`,所以读档本身不会被记进录像。
|
||||
- <strong>执行器不在保护范围内</strong>。`bindSaveableExecuter` 绑定的 `afterLoad` 在该对象 `loadState` 之后立即运行,此时录像已经恢复可用。在执行器里操作会影响成绩的状态要格外小心,细节见[可存档内容](./saveable-content.md)。
|
||||
- <strong>装饰器只认类的方法</strong>。写在普通函数、对象属性、箭头函数上都不会生效。
|
||||
- <strong>不要给只读方法或计算器加标记</strong>。它们不产生录像记录,只会让告警列表变长。
|
||||
- <strong>不要用 @ignoreReplay 压告警</strong>。它忽略的是整段检查,真正的漏记会一起被放过。
|
||||
- <strong>同一个方法只写一个标记</strong>。`@shouldReplay` 与 `@ignoreReplay` 同时写含义互相矛盾。
|
||||
- <strong>异步回调里的记录要处理顺序</strong>。录像步会落在错误的时点,检查也看不出来。
|
||||
- <strong>没有告警不等于没问题</strong>。入口没标记、或者检查没有覆盖到这段执行,都不会有告警。
|
||||
|
||||
需要自定义录像指令或新的录制入口时,属于 `高级 II · 自定义录像行为`;录像格式与沙盒原理见 `高级 III · 录像深层原理`。
|
||||
|
||||
## 小结
|
||||
|
||||
- 录像一致性只要求一件事:玩家直接产生、会影响最终成绩的行为,一条都不能少记。
|
||||
- 该不该记通常靠分析;判断不了时,把所有会改状态、可能影响结果的方法都加上 `@shouldReplay`。
|
||||
- `@shouldReplay` 只用于数据端,只登记路径;`@ignoreReplay` 把整段检查标记为忽略;两者都不写录像。
|
||||
- 带标记的方法执行了却没有录像记录时,控制台会输出「录像记录缺失」警告。
|
||||
- 检查不管逻辑、参数、顺序与一一对应,这些要自己核对。
|
||||
- 读档期间录像被临时禁用,执行器不在保护范围内。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
@@ -0,0 +1,384 @@
|
||||
# 可存档内容
|
||||
|
||||
存档不是「把内存里的东西倒进文件」,而是游戏状态的一份快照。读档之后,游戏必须从这份快照继续,按同一套规则运行:玩家位置、地图改动、Flag、怪物状态、插件自己的计数,都要回到存档那一刻。只要有一处没回到该有的样子,后面就会逐渐偏掉,而且往往要到很久以后才表现出来。
|
||||
|
||||
中级教程里的 [Flag 变量](../../intermediate/flags.md) 已经替你存好了一类简单状态。这一篇讲更一般的情况:当你要自己定义一类需要进存档的数据时,怎么声明、怎么注册、怎么保证读档后的结果和存档时一致。
|
||||
|
||||
阅读前需要会基础语法:`class`、`new`、`Map`、`import`。如果你还没写过插件,建议先看[数据插件](../../intermediate/data-plugins.md)与[插件系统](./plugin-system.md);可存档内容通常就注册在插件的 `awake` 里。
|
||||
|
||||
## 什么该存,什么不该存
|
||||
|
||||
判断一个字段该不该进存档,只看一件事:<strong>读档之后,它还能不能从别的地方算回来</strong>。
|
||||
|
||||
- 算不回来、又会影响后续结果的,必须存;
|
||||
- 每次都能从已有数据重新算出来的,不该存;
|
||||
- 只影响当下表现的临时状态,也不该存。
|
||||
|
||||
| 状态 | 该不该存 | 原因 |
|
||||
| -------------------------- | -------- | --------------------------------------- |
|
||||
| 勇士位置、属性、道具 | 存 | 影响后续所有结算 |
|
||||
| 地图图块改动、点事件 | 存 | 玩家改造过世界,无法从原始地图推回 |
|
||||
| Flag 变量 | 存 | 跨事件、跨楼层的持久状态 |
|
||||
| 敌人当前状态 | 存 | 影响战斗与后续查询 |
|
||||
| 静态图块缓存、坐标索引 | 不存 | 由地图数据派生,随时可以重建 |
|
||||
| 临时动画、提示、界面状态 | 不存 | 读档后重新产生,或根本不影响游戏结果 |
|
||||
| 战斗上下文、寻路等 L2 系统 | 不存 | 只操作 Layer 1 数据,读档后按需重新建立 |
|
||||
|
||||
漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是<strong>只交出重建所需的最小信息</strong>,`loadState` 的原则是<strong>先清空旧状态,再按存档重建</strong>。缓存不要存,读档后第一次用到时重新生成即可。
|
||||
|
||||
## 写一个可存档对象
|
||||
|
||||
可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。如果你有一份自定义状态需要跟着存档走,可以参考下面的示例;示例把两部分一次写完,后面再逐项解释。
|
||||
|
||||
```ts
|
||||
// src/data/plugin/my-plugin/state.ts
|
||||
import { ISaveableContent } from '@user/data-common';
|
||||
|
||||
// 存档形状:只放重建必需的数据,字段类型必须能被结构化克隆
|
||||
export interface IMySave {
|
||||
readonly values: Map<string, number>;
|
||||
}
|
||||
|
||||
// 可存档对象:实现 ISaveableContent,泛型参数是存档形状
|
||||
export class MySaveable implements ISaveableContent<IMySave> {
|
||||
// 运行时的真实数据:用 Map 保存键值对
|
||||
private readonly values: Map<string, number> = new Map();
|
||||
|
||||
// 对外方法:按 key 写入数值
|
||||
setValue(key: string, value: number): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
getValue(key: string): number | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
|
||||
saveState(): IMySave {
|
||||
// 返回独立的新对象,不能返回 this,也不能与 this.values 共享引用
|
||||
return { values: structuredClone(this.values) };
|
||||
}
|
||||
|
||||
loadState(state: IMySave): void {
|
||||
// 先清空旧状态,再按存档重建,避免残留上一次的数据
|
||||
this.values.clear();
|
||||
for (const [key, value] of state.values) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/data/plugin/my-plugin/types.ts
|
||||
import { IDataPluginDefine } from '@user/data-state';
|
||||
import { MySaveable } from './state';
|
||||
|
||||
// 插件对外接口,把可存档对象写进成员,方便其他插件取用
|
||||
export interface IMyPlugin extends IDataPluginDefine {
|
||||
readonly store: MySaveable;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/data/plugin/my-plugin/plugin.ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { IMyPlugin } from './types';
|
||||
import { MySaveable } from './state';
|
||||
|
||||
// 插件:创建可存档对象,并在 awake 里注册
|
||||
export class MyPlugin implements IMyPlugin {
|
||||
// 插件 id,与存档 id 是两套命名
|
||||
readonly id = 'my-plugin';
|
||||
readonly dataDeps: string[] = [];
|
||||
|
||||
// 供插件其他方法使用
|
||||
// 其他插件通过 IMyPlugin 访问
|
||||
store!: MySaveable;
|
||||
|
||||
awake(state: ICoreState): void {
|
||||
// 对象在插件自己的目录里创建
|
||||
this.store = new MySaveable();
|
||||
// 存档 id 要唯一,并与插件 id 分开命名
|
||||
state.addSaveableContent('@plugin/my-store', this.store);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async afterLoad(): Promise<void> {}
|
||||
}
|
||||
```
|
||||
|
||||
示例一共声明了三样东西:`state.ts` 里的 `IMySave` 是存档形状,`MySaveable` 实现 `ISaveableContent<IMySave>`,负责在活对象与存档数据之间转换;`types.ts` 里的 `IMyPlugin` 继承 `IDataPluginDefine`,把可存档对象作为公开成员暴露出去;`plugin.ts` 里的 `MyPlugin` 实现 `IMyPlugin`,在 `awake` 里创建对象并调用 `addSaveableContent` 注册。
|
||||
|
||||
`IMySave` 的字段只保留重建必需的数据,`MySaveable` 里放真正的状态与对外方法,插件 `id` 与注册 id 分别命名。`initialize` / `afterLoad` 在示例里是空的,需要等加载内容时再填。下面解释 `saveState` / `loadState` 的约束,以及注册时要注意什么。
|
||||
|
||||
## saveState 与 loadState
|
||||
|
||||
可存档对象要实现 `ISaveableContent<T>`:
|
||||
|
||||
```ts
|
||||
export const enum SaveCompression {
|
||||
/** 不进行压缩,仅提取必要数据 */
|
||||
NoCompression,
|
||||
/** 进行小幅度压缩,以性能为主要考虑目标 */
|
||||
LowCompression,
|
||||
/** 进行大幅度压缩,以体积为主要考虑目标 */
|
||||
HighCompression
|
||||
}
|
||||
|
||||
export interface ISaveableContent<T> {
|
||||
saveState(compression: SaveCompression): T;
|
||||
loadState(state: T, compression: SaveCompression): void;
|
||||
}
|
||||
```
|
||||
|
||||
这里的 `T` 是这个对象的<strong>存档形状</strong>,不是对象本身的类型。`saveState` 返回它,`loadState` 接收它。
|
||||
|
||||
`loadState` 拿到的是普通数据,不是你的类实例。它要做的是把这份数据装回对象:该清空的清空,该重建的重建,不要假设旧字段还留着。商店管理器就是典型写法:
|
||||
|
||||
```ts
|
||||
import { SaveCompression } from '@user/data-common';
|
||||
import { IShopManager, IShopManagerSave, IShopState } from './types';
|
||||
import { ShopState } from './state';
|
||||
|
||||
// 管理器节选:只保留 loadState,其余成员用注释省略
|
||||
export class ShopManager implements IShopManager {
|
||||
// 运行时保存的是带方法的商店实例
|
||||
private readonly shops: Map<string, IShopState> = new Map();
|
||||
|
||||
// ... 其他成员省略
|
||||
|
||||
loadState(state: IShopManagerSave, compression: SaveCompression): void {
|
||||
// 先清空旧状态,避免上一次读档的数据残留
|
||||
this.shops.clear();
|
||||
for (const save of state.list) {
|
||||
// 不能把普通对象直接塞进 Map,要重新构造类实例
|
||||
const shop: IShopState = new ShopState(save.id, this.state);
|
||||
// 让每个商店自己恢复内部状态
|
||||
shop.loadState(save, compression);
|
||||
this.shops.set(save.id, shop);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
它没有把存档里的普通对象直接塞进 `Map`,而是重新 `new ShopState`,再让每个商店自己恢复内部状态。这样 `Map` 里装的仍然是带方法的类实例,而不是一堆失去原型的普通对象。
|
||||
|
||||
### 返回值必须独立
|
||||
|
||||
<strong>存档的顶层不会自动做深拷贝。</strong> `CoreState.saveState` 只是把每个对象 `saveState` 的返回值按 id 放进一个 `Map`,不会再 `structuredClone` 一遍。因此 `saveState` 必须自己返回一个<strong>独立的新对象</strong>:不能返回 `this`,也不能让返回值里的字段与活对象共享引用。否则之后对游戏对象的修改会直接改到已保存的数据上,读档时拿到的就是被污染的值。
|
||||
|
||||
```ts
|
||||
import { ISaveableContent } from '@user/data-common';
|
||||
|
||||
// 错误:把活对象交给存档系统;顶层不会再复制
|
||||
export class BadSaveable implements ISaveableContent<BadSaveable> {
|
||||
constructor(private readonly count: number) {}
|
||||
|
||||
saveState(): BadSaveable {
|
||||
return this;
|
||||
}
|
||||
|
||||
loadState(state: BadSaveable): void {
|
||||
// 这里只是让示例结构完整;真正的错误在 saveState 的返回值
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
返回值还要能被持久化。存档最终写入存储层,而存储层按结构化克隆处理数据,只认识一部分类型:
|
||||
|
||||
| 支持 | 说明 |
|
||||
| --------------------------- | ------------------ |
|
||||
| 普通对象、数组 | 逐层复制 |
|
||||
| 数字、字符串、布尔、`null` | 原样复制 |
|
||||
| `Map`、`Set` | 复制成新的容器 |
|
||||
| `TypedArray`、`ArrayBuffer` | 复制底层二进制数据 |
|
||||
| `Date` | 复制时间值 |
|
||||
|
||||
不支持的包括函数、`Symbol`、DOM 节点、`WeakMap` 与 `WeakSet`。遇到函数这类值,结构化克隆会直接抛错,而不是悄悄跳过。
|
||||
|
||||
还有一条更隐蔽的限制:<strong>类实例会丢失原型与方法</strong>。结构化克隆只复制实例自身的可枚举属性,复制结果是一个普通对象。所以 `saveState` 要返回纯数据,`loadState` 里再重新构造类实例。
|
||||
|
||||
深拷贝有成本。小型状态直接用 `structuredClone` 最省事;大存档整棵复制会明显变慢,这时可以手工构造一个新对象,只复制重建必需的字段。手工构造时,数组、`Map` 这类容器也要新建一份,不能把原容器直接挂上去。目的不是「一定要调用 `structuredClone`」,而是返回值不能与活对象共享引用。
|
||||
|
||||
### 压缩参数可以省略
|
||||
|
||||
`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`,TypeScript 允许实现时少写参数。示例里就没有写 `compression`:调用方仍会按接口传两个实参,多出的那个被忽略。需要按压缩级别改变存法时,再把 `compression: SaveCompression` 加回来。
|
||||
|
||||
`saveState` 里不要修改对象状态;`loadState` 不要假设旧数据还在。
|
||||
|
||||
## 注册到存档系统
|
||||
|
||||
用 `state.addSaveableContent(id, content)` 把对象交给存档系统。`id` 是这个对象在存档里的唯一标识。整份数据端存档就是一个 `Map<string, unknown>`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。
|
||||
|
||||
`id` 必须唯一。重复注册同一个 id 时,后一个会被忽略,控制台打一条警告(错误码 `112`)。这不是「后一个覆盖前一个」,而是第一个注册的继续生效,所以两个系统抢同一个 id 时,第二个实际上没有进存档。
|
||||
|
||||
需要按 id 取回对象时,用 `state.getSaveableContent<T>(id)`。它返回 `ISaveableContent<T> | null`,没有这个 id 时返回 `null`。
|
||||
|
||||
内置内容也是同样的注册方式。主插件在 `awake` 里注册了五项:
|
||||
|
||||
| 存档 id | 对应对象 |
|
||||
| ---------------- | -------------------- |
|
||||
| `@system/hero` | `state.hero` |
|
||||
| `@system/flags` | `state.flags` |
|
||||
| `@system/maps` | `state.maps` |
|
||||
| `@system/enemy` | `state.enemyManager` |
|
||||
| `@system/replay` | `state.replaySystem` |
|
||||
|
||||
商店插件则注册 `@plugin/shop`,对应的是 `ShopManager`,不是插件对象本身。插件 id 与存档 id 是两套命名,不要互相推导。
|
||||
|
||||
### 注册时机
|
||||
|
||||
插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适。`initialize` / `afterLoad` 也能调用 `addSaveableContent`,但只在对象必须等加载内容才能构造时才这样做。注册时机只决定对象什么时候可用,不构成读档顺序的承诺。
|
||||
|
||||
无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。
|
||||
|
||||
## 三级压缩
|
||||
|
||||
`SaveCompression` 有三档:
|
||||
|
||||
| 级别 | 含义 | 典型用途 |
|
||||
| ----------------- | -------------------------------- | ------------------------ |
|
||||
| `NoCompression` | 不压缩,仅提取必要数据 | 调试、需要完整数据的场景 |
|
||||
| `LowCompression` | 小幅度压缩,以性能为主要考虑目标 | 常规存档 |
|
||||
| `HighCompression` | 大幅度压缩,以体积为主要考虑目标 | 体积敏感的分发或自动存档 |
|
||||
|
||||
三档不是「随便挑一个实现」,而是同一份数据在不同压缩策略下的三种写法。地图图层是现成的例子:
|
||||
|
||||
| 级别 | 图层实际存什么 |
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| `NoCompression` | 直接存完整的地图矩阵 |
|
||||
| `LowCompression` | 图层脏且与参考数组不一致时存完整矩阵;否则不存矩阵,读档时用参考数组 |
|
||||
| `HighCompression` | 图层脏时只存与参考数组不同的行;没有参考数组时退回完整矩阵 |
|
||||
|
||||
参考数组来自加载时的原始地图数据。低压缩和高压缩都假设「读档时还能拿到同一份参考」,所以它们只记录差异。这也是压缩能生效的前提:<strong>压缩级别只改变数据怎么存,不能改变游戏结果</strong>。
|
||||
|
||||
自定义对象可以三档用同一套实现,例如都返回独立构造的完整数据。这没有问题,但必须满足两个条件:
|
||||
|
||||
1. 三档都能被自己的 `loadState` 正确读回,不能只有 `NoCompression` 能读;
|
||||
2. 任何一档都不能改变语义,读档后的游戏状态必须一致。
|
||||
|
||||
真正的差异压缩涉及存档格式与压缩算法,属于 `高级 II · 存档格式演进` 与 `高级 II · 压缩算法细节`。
|
||||
|
||||
## 读档收尾与执行器
|
||||
|
||||
`saveState` 与 `loadState` 都会遍历已注册对象,但<strong>顺序不做保证</strong>。不要依赖某个对象先于另一个对象恢复,也不要为跨对象引用设计顺序。
|
||||
|
||||
读档时,存档里缺少某个已注册 id 的数据会告警(错误码 `177`)并跳过该对象;多出未注册 id 的数据会在读档结束后告警(错误码 `178`)。这两条只说明映射对不上,不代表对象的内部状态正确。
|
||||
|
||||
如果读档后还需要做一步收尾,用 `state.bindSaveableExecuter(content 或 id, executor)` 绑定执行器。执行器实现 `ISaveableExecutor<T>`:
|
||||
|
||||
```ts
|
||||
export interface ISaveableExecutor<T> {
|
||||
afterLoad(data: T, state: IStateBase): void;
|
||||
}
|
||||
```
|
||||
|
||||
绑定时可以传对象本身,也可以传注册 id。传 id 时如果找不到对应对象,会静默返回;传对象时如果它没有注册过,会打一条警告(错误码 `113`)。所以先注册,再绑定。
|
||||
|
||||
一个可存档对象只能绑定一个执行器,重复绑定会替换前一个;一个执行器可以绑定多个对象,适合做全局性的读档收尾。
|
||||
|
||||
关键在时机:<strong>执行器在该对象的 `loadState` 返回后立即运行</strong>,不是等所有对象都读完之后统一运行。因此执行器只能保证「这个对象自己已经恢复」,不能假设其它对象已经恢复完毕。
|
||||
|
||||
读档开始时,主对象会先清掉残留的录像禁用标记,再逐个恢复对象。每个对象的 `loadState` 都被 `replaySystem.disable()` 与 `revert()` 包裹,读档本身不会被记进录像;但执行器<strong>不在包裹范围内</strong>,因为它可能确实要做需要记录的行为。
|
||||
|
||||
### 示例:读档后切换到目标楼层
|
||||
|
||||
假设你的读档流程需要在勇士恢复之后,再走一遍完整的楼层切换(触发切换钩子等),可以把这个收尾交给执行器:
|
||||
|
||||
```ts
|
||||
import { IHeroAttr } from '@user/data-common';
|
||||
import { IHeroStateSave, IStateBase } from '@user/data-base';
|
||||
import { ICoreState, ISaveableExecutor } from '@user/data-state';
|
||||
|
||||
// 示例:读档后让勇士回到存档记录的楼层。
|
||||
// 实际内部实现并不是这样处理的,这里只示意执行器能做什么。
|
||||
export class HeroFloorExecutor implements ISaveableExecutor<
|
||||
IHeroStateSave<IHeroAttr>
|
||||
> {
|
||||
afterLoad(data: IHeroStateSave<IHeroAttr>, state: IStateBase): void {
|
||||
const { floorId, x, y, direction } = data.location;
|
||||
const floor = floorId ? state.maps.getMap(floorId) : null;
|
||||
if (!floor) return;
|
||||
// changeFloor 是异步的;执行器接口本身是同步的,这里只发起,不等待
|
||||
void state.hero.changeFloor({ target: floor, x, y, face: direction });
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定到勇士的存档 id:通常写在初始化阶段
|
||||
export function bindHeroFloorExecutor(state: ICoreState): void {
|
||||
state.bindSaveableExecuter('@system/hero', new HeroFloorExecutor());
|
||||
}
|
||||
```
|
||||
|
||||
执行器接口的 `afterLoad` 是同步的,所以它只能「发起」异步操作;需要等待完成的流程要放在你自己的读档调用处。
|
||||
|
||||
### afterLoad 的两种含义
|
||||
|
||||
`afterLoad` 这个名字在两处出现,含义完全不同:
|
||||
|
||||
| 位置 | 什么时候运行 | 用来做什么 |
|
||||
| ------------------------------- | --------------------------------------- | ---------------------------------- |
|
||||
| 插件 `afterLoad(state, deps)` | 初次加载流程末尾,每个插件只运行一次 | 读配置、创建依赖加载内容的初始对象 |
|
||||
| 执行器 `afterLoad(data, state)` | 每次读档,在该对象 `loadState` 之后立即 | 做读档收尾,例如切换楼层 |
|
||||
|
||||
插件 `afterLoad` 只在<strong>初次加载</strong>后运行。玩家读档时,加载流程不会重跑,插件 `afterLoad` 也不会再执行。所以它不能替代读档执行器:需要「每次读档都做」的事,必须绑执行器。反过来,执行器也不适合做「初次加载」的初始化,它只在读档时运行。
|
||||
|
||||
## 跨对象引用
|
||||
|
||||
存档里不要直接存另一个对象的引用。返回值必须独立:直接放活对象,存档就会与游戏对象共享同一份引用,之后互相污染;即使存储层复制了它,读档后身份也已经丢了,类实例的方法也会一并丢掉。
|
||||
|
||||
正确做法是<strong>存 id 或键,读档后或真正需要时再按 id 重建或查找</strong>。常见的键有楼层 id、商店 id、可存档 id,以及你自己维护的稳定 `uid`。读档顺序不做保证,所以不要假设被引用的对象已经先恢复;在用到的那一刻查一次,查不到就按你的规则新建或跳过。
|
||||
|
||||
这种跨存档引用极其少见。大多数自定义状态只依赖自己的字段;遇到引用时按上述方式处理即可,不需要为它设计顺序。
|
||||
|
||||
## 验证
|
||||
|
||||
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,`state.loadState(snapshot, compression)` 按同一份映射恢复。确认读档后回到存档时的值即可:
|
||||
|
||||
```ts
|
||||
import { SaveCompression } from '@user/data-common';
|
||||
import { ICoreState, PluginStatus } from '@user/data-state';
|
||||
import { IMyPlugin } from './types';
|
||||
|
||||
// 验证流程:存档 → 改状态 → 读档,确认恢复为存档时的值
|
||||
export function verifySaveLoad(state: ICoreState): void {
|
||||
const holder = state.getDataPlugin<IMyPlugin>('my-plugin');
|
||||
if (!holder || holder.status !== PluginStatus.Loaded) return;
|
||||
|
||||
holder.plugin.store.setValue('count', 1);
|
||||
const snapshot = state.saveState(SaveCompression.NoCompression);
|
||||
|
||||
holder.plugin.store.setValue('count', 2);
|
||||
state.loadState(snapshot, SaveCompression.NoCompression);
|
||||
console.log(holder.plugin.store.getValue('count')); // 应为 1
|
||||
}
|
||||
```
|
||||
|
||||
读档后应回到存档时的值。如果读到的是修改后的值,说明 `saveState` 返回了与活对象共享的引用,而不是独立副本。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- <strong>saveState 返回 this 或内部活对象</strong>。顶层不会替你复制,存档映射会直接持有活对象,后续修改会污染已保存的数据。
|
||||
- <strong>返回带方法的类实例</strong>。持久化时结构化克隆会丢掉原型与方法,读档后调用方法直接报错。
|
||||
- <strong>把缓存或索引也存进去</strong>。读档时旧缓存覆盖新算出的结果,状态对不上;这类数据应该重建。
|
||||
- <strong>漏掉某个字段</strong>。读档后该字段回到默认值,表现为「只恢复了一半」。
|
||||
- <strong>重复注册同一个 id</strong>。后一个被忽略并告警(`112`),你以为生效的对象其实没进存档。
|
||||
- <strong>依赖对象之间的恢复顺序</strong>。读档顺序不做保证,执行器只保证当前对象已恢复。
|
||||
- <strong>把插件 afterLoad 当读档钩子</strong>。它只在初次加载后运行一次,玩家读档时不会再触发。
|
||||
- <strong>在 saveState 里改状态</strong>。存档是只读快照,`saveState` 不应该有副作用。
|
||||
- <strong>跨对象引用直接存对象</strong>。存档与活对象共享引用,之后互相污染,读档后身份也已经丢失。
|
||||
|
||||
## 小结
|
||||
|
||||
- 先写可存档对象与插件注册,再逐项调整存档形状、返回值与注册时机。
|
||||
- 只存重建必需的状态;缓存、派生数据与 L2 系统不进存档。
|
||||
- `ISaveableContent<T>` 的 `T` 是存档形状;`saveState` 必须返回独立于活对象的数据,顶层不会替你 `structuredClone`。
|
||||
- 存档数据必须能被结构化克隆;类实例会丢原型与方法,读档时要自己重建。
|
||||
- 用 `addSaveableContent` 注册,id 唯一;重复 id 后一个被忽略(`112`)。
|
||||
- 三档压缩只改变存储方式,不改变结果;每一档都必须能被自己的 `loadState` 读回。
|
||||
- 存档与读档不保证顺序;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。
|
||||
- 插件 `afterLoad` 只在初次加载后运行一次,不能替代读档执行器。
|
||||
- 跨对象引用存 id 或键,读档后或需要时再查;这个场景极其少见,不要为它设计顺序。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
Reference in new issue
Block a user