docs(10-20): 修订存档/录像/Store/钩子(用户第4轮B)

This commit is contained in:
unanmed 2026-10-21 12:27:45 +08:00
parent b451da203d
commit c578990810
6 changed files with 256 additions and 398 deletions

View File

@ -171,5 +171,21 @@
**构建:** `pnpm exec prettier --check docs/advanced/plugin-system.md docs/advanced/declarative-vs-imperative.md` 通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
### 第 4 轮处置(存档 / 录像 / Store / 钩子 / 导览)
**修改方式:** 内联执行(执行器直接编辑),仅修改 `docs/advanced/saveable-content.md`、`docs/advanced/replay-consistency.md`、`docs/advanced/data-stores.md`、`docs/advanced/hooks-in-depth.md`、`docs/advanced/index.md` 与本职记录。
| # | 处置 |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| U4-07 | 四篇改为先模板后解释:`saveable-content` 先给 `state.ts` / `types.ts` / `plugin.ts` 完整骨架,再逐项解释存档形状、`saveState` / `loadState`、注册时机与压缩;`replay-consistency` 先把带 `@shouldReplay` 的 `ShopSystem` 模板提到开头;`data-stores` 先给一次读取五个 Store 的完整模板;`hooks-in-depth` 先给图层钩子类 + 注册函数的完整模板。 |
| U4-09 | 删除「存档和读档都按注册顺序遍历」「先注册被引用者」等顺序保证;明确写为「顺序不做保证」,执行器只保证当前对象已恢复。跨对象引用压缩为一段:存 id 或键,读档后或真正需要时再重建 / 查找,并注明该场景极其少见、不展开。验证只保留一次「存档 → 改状态 → 读档」检查,删除三档压缩验证、注册检查、缺键 / 多键告警验证与跨对象引用检查(这些内容来自 `__test__`,不属于发布文档)。 |
| U4-10 | `replay-consistency` 在模板之后明确写出 `@shouldReplay` 与 `@ignoreReplay` 只在数据端使用,渲染端不参与重放与安全检查;「边界与注意」与小结同步该说法。 |
| U4-11 | `data-stores` 删除五个 Store 括号里的 `ITileStore` / `IItemStore` / `IMapStore` / `IEnemyStore` / `IGameEventStore` 标识;字段与接口说明改为叙述,只保留 Store 能做什么,细节(如 `effect` / `equip` 必填)移到「边界与注意」。 |
| U4-12 | 删除「逐个看接口」大节,把五个 Store 平铺为五个二级节;每节先讲「存什么」,再讲自身接口能做什么,不再只讲 Store 对象本身。 |
| U4-13 | `hooks-in-depth` 改为「建议一个对象一个实例」;明确共享实例允许但不建议,除非明确了解钩子内部实现与后果,并注明本篇不展开共享用法;边界与小结同步。 |
| U4-11 / U4-12 / U4-13 | `index.md` 同步:录像行注明「数据端」、Store 行改为「五个 Store 各存什么、能做什么」、钩子行改为「建议一个对象一个钩子实例」,「改内容或机制」路径与「读完这一层」同步措辞。 |
**构建:** `pnpm exec prettier --write` 后 `--check` 五篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
**状态:处理中**

View File

@ -2,91 +2,77 @@
`@user/data-common` 是数据端的 Layer 0 公共层,上面挂着五个内容注册表:`tileStore`、`itemStore`、`mapStore`、`enemyStore`、`eventStore`。它们看起来像「数据表」,实际是<strong>内容层原始定义的注册表</strong>——图块、道具、地图、怪物、事件的定义登记在这里;游戏运行时从这里查出来,再按定义构造自己的对象。
中级教程的[数据端总览](../intermediate/data-state-overview.md)列过这些入口,[图块存储](../intermediate/tile-store.md)讲了 `tileStore` 的日常用法。这一篇讲每个 Store 存什么、接口怎么用,以及为什么运行期只该读。
中级教程的[数据端总览](../intermediate/data-state-overview.md)列过这些入口,[图块存储](../intermediate/tile-store.md)讲了 `tileStore` 的日常用法。这一篇讲每个 Store 里存的是什么、它自己能做什么,以及为什么运行期只该读。
阅读前需要会基础语法:`class`、`new`、`Map`、`import`。
## 先看读模板
五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。日常使用基本就是下面这些读法:
```ts
import { ICoreState } from '@user/data-state';
// 一次读出五个 Store 的常见内容;放进能拿到 state 的方法里
export function readStores(state: ICoreState): void {
// 图块:先按 id 查数字,再取完整定义与类型
const tileNum = state.tileStore.num('my-tile');
if (tileNum !== undefined) {
console.log(state.tileStore.getData(tileNum));
console.log(state.tileStore.getType(tileNum));
}
// 道具与怪物:数字或 id 都接受
const item = state.itemStore.getData('my-item');
const enemy = state.enemyStore.getEnemy('my-enemy');
// 地图按楼层 id 查;事件按事件 id 取实例
const map = state.mapStore.getMap('my-floor');
const event = state.eventStore.getEvent('my-event');
console.log(item, map, enemy, event);
}
```
下面先说明 Store 与实例、存档的区别,再逐个讲每个 Store 存什么、能做什么。
## Store 是什么
先把三个区分摆清楚:
Store 里存的是「定义」,游戏里跑的是按定义构造出来的「实例」。图块定义告诉你这个数字代表什么地形、能不能走,真正的图块对象则在楼层加载时按定义创建。
| 区分 | 一边 | 另一边 |
| ------------ | -------------------------------- | ------------------------------------ |
| 定义与实例 | Store 里存的是「定义」 | 游戏里跑的是按定义构造出来的「实例」 |
| 注册表与存档 | Store 是查表用的注册表,不进存档 | 勇士、地图、Flag 等游戏状态才进存档 |
| 查表与改表 | 运行时读 Store 是正常操作 | 运行时写 Store 会绕过一整套机制 |
Store 是查表用的注册表,不进存档。勇士、地图、Flag 这些游戏状态才进存档;读档不会把 Store 退回原样,也不会恢复你运行期对它的改动。
Store 在<strong>加载 / 初始化阶段</strong>被填一次,之后整个游戏过程都靠它查表。它记录的不是「玩家做了什么」,而是「世界上有什么」。
运行时读 Store 是正常操作:查表、算数、拼提示文本、给界面提供数据都可以。运行时写 Store 会绕过加载、存档与录像这一整套机制,问题会在不确定的时机表现出来。
## 五个 Store
## tileStore:图块定义
- `tileStore`(`ITileStore`):按<strong>图块数字</strong>存图块原始定义 `ITileRawData`。读 `getData` / `getEvent` / `getType`,以及 `num` / `id` / `identity` 换算;写 `addTile`。
- `itemStore`(`IItemStore<IHeroAttr>`):按<strong>道具图块数字</strong>存道具原始定义 `IItemRawData`。读 `getData(token)` / `getCategory(num)`;写 `addItem`。
- `mapStore`(`IMapStore`):按<strong>楼层 id</strong>(`floorId`)存地图原始数据 `IMapRawData`。读 `getMap(floorId)`;写 `addMap`。
- `enemyStore`(`IEnemyStore<IEnemyAttr>`):按<strong>怪物图块数字</strong>存怪物原始数据 `IEnemyRawData`。读 `getEnemy(token)`;写 `addEnemy`。
- `eventStore`(`IGameEventStore`):按<strong>事件 id</strong>存事件实例。读 `getEvent(id)`;写 `addEvent(id, event)`。
存的是图块的原始定义:图块数字、字符串 id、默认事件、逻辑类型、四方向通行性,以及事件是撞上去触发还是走上去触发。以图块数字为键。
五个都是 `IDataCommon` 上的只读字段,通过 `state` 直接访问,例如 `state.tileStore`。它们没有各自的「管理器」,也不需要你 `new` 一个出来。
它能按数字取完整定义、默认事件和类型,也能在图块数字与字符串 id 之间换算。登记新定义用 `addTile`;数字或 id 与已有定义冲突时会告警(错误码 `133` / `134`)并覆盖。
## 逐个看接口
## itemStore:道具定义
### tileStore:图块定义
存的是道具的原始定义:图块数字、分类、名称、描述、是否在道具栏隐藏、使用 / 拾取效果,以及装备数据。以道具图块数字为键。
以图块数字为键,存 `ITileRawData`:
它能按数字或 id 取整份定义,也能只取分类。登记用 `addItem`;同一个图块数字重复登记会告警(错误码 `181`)并覆盖。
| 字段 | 含义 |
| ----------- | -------------------------------------------------------------- |
| `num` | 图块数字 |
| `id` | 图块字符串 id |
| `events` | 默认事件,键为优先级、值为事件 id |
| `type` | 图块逻辑类型(`TileType`) |
| `pass` | 通行性(`ITilePassData`:`onlyEvents` / `outPass` / `inPass`) |
| `eventPass` | 事件可通行性:`false` 撞击触发,`true` 走上去触发 |
## mapStore:地图原始数据
- `getData(num)`:完整原始定义,不存在返回 `null`;
- `getEvent(num)`:默认事件,返回 `ReadonlyMap`。它每次都会新建一个 `Map`,改它不会影响 Store;
- `getType(num)`:图块类型,不存在返回 `TileType.Unknown`;
- `num` / `id` / `identity`:数字与 id 之间的换算,细节见[图块存储](../intermediate/tile-store.md);
- `addTile(data)`:登记一条定义。`num` 或 `id` 撞上已有定义时会告警(错误码 `133` / `134`)并覆盖。
存的是地图的原始数据:楼层 id、地图宽度、每一层的图块矩阵、每个图层的字符串别名,以及每个图层指定位置上的事件。以楼层 id 为键。
### itemStore:道具定义
它能按楼层 id 取整份原始数据。登记用 `addMap`;同一个楼层 id 直接覆盖,不告警。
以道具图块数字为键,存 `IItemRawData`:`num`、`category`(`ItemCategory`)、`name`、`text`、`hideInToolbox`、`effect`(使用 / 拾取效果)、`equip`(装备数值与事件)。`effect` 与 `equip` <strong>都是必填字段</strong>,不是二选一:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。
## enemyStore:怪物定义
- `getData(token)`:道具原始定义,同时接受数字与 id,查不到返回 `null`;
- `getCategory(num)`:道具分类,查不到返回 `ItemCategory.Unknown`;
- `addItem(data)`:登记一条定义;同一个 `num` 重复登记会告警(错误码 `181`),然后覆盖。
存的是怪物的原始定义:图块数字、怪物属性,以及特殊属性的编号与参数。以怪物图块数字为键。
### mapStore:地图原始数据
它能按数字或 id 取整份定义。登记用 `addEnemy`;同一个图块数字重复登记会告警(错误码 `181`,与道具共用)并覆盖。
以楼层 id 为键,存 `IMapRawData`:
## eventStore:事件实例
| 字段 | 含义 |
| ------------ | ---------------------------------------------------------- |
| `floorId` | 楼层 id |
| `width` | 地图宽度 |
| `map` | 地图数组,键为图层纵深、值为扁平存放的图块数字数组 |
| `layerAlias` | 每个图层的字符串别名,键为图层纵深 |
| `events` | 每个图层指定位置的事件:纵深 → 位置索引 → 优先级 → 事件 id |
存的是事件实例本身,不是事件定义。以事件 id 为键。
- `getMap(floorId)`:原始地图数据,查不到返回 `null`;
- `addMap(map)`:登记一份原始数据,同一个 `floorId` 直接覆盖,不告警。
### enemyStore:怪物原始数据
以怪物图块数字为键,存 `IEnemyRawData`:`num`、`attribute`(怪物属性)、`special`(特殊属性,键为特殊属性编号、值为参数)。
- `getEnemy(token)`:怪物原始数据,同时接受数字与 id,查不到返回 `null`;
- `addEnemy(data)`:登记一条定义;同一个 `num` 重复登记会告警(错误码 `181`,与道具共用)并覆盖。
它和另外四个 Store 一样挂在主对象上,直接 `state.enemyStore` 取用。
### eventStore:事件实例
以事件 id 为键,存事件实例(`IReadonlyGameEvent`)。
- `getEvent(id)`:返回<strong>存储的那个实例</strong>,不是副本;查不到返回 `null`;
- `addEvent(id, event)`:登记一个事件;重复 id 会告警(错误码 `170`)并覆盖。注意签名与另外几个不同,第一个参数是事件 id,第二个才是事件实例。
它能按 id 取回存储的那个实例,不是副本。登记用 `addEvent(id, event)`,第一个参数是事件 id,第二个才是实例;重复 id 会告警(错误码 `170`)并覆盖。
## 为什么运行期只读
@ -98,34 +84,14 @@ Store 在<strong>加载 / 初始化阶段</strong>被填一次,之后整个游
所以规则很简单:<strong>登记只在加载期做一次,运行期只读</strong>。
至于登记这件事本身,绝大多数情况也不用你动手:`add*` 是给数据端自己用的接口——内容加载还没写完,写完后由数据端统一登记,不在你的代码里。日常你只需要按下面这样读:
至于登记这件事本身,绝大多数情况也不用你动手:`add*` 是给数据端自己用的接口——内容加载还没写完,写完后由数据端统一登记,不在你的代码里。
```ts
import { ICoreState } from '@user/data-state';
// 读示例:放进能拿到 state 的方法里
export function readStores(state: ICoreState): void {
// 先按图块 id 查数字,再取完整定义;没有登记时 num 为 undefined
const num = state.tileStore.num('my-tile');
if (num !== undefined) {
// raw 为 null 表示这条定义不存在
const raw = state.tileStore.getData(num);
console.log(raw, state.tileStore.getType(num));
}
// 道具、地图、怪物同样按各自的键查
const item = state.itemStore.getData('my-item');
const map = state.mapStore.getMap('my-floor');
const enemy = state.enemyStore.getEnemy('my-enemy');
console.log(item, map, enemy);
}
```
读 Store 随时可以:查表、算数、拼提示文本、给界面提供数据,都是正常用法。查表不会改变任何东西,也不产生录像步。唯一要注意的是<strong>不要就地修改返回的对象</strong>:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。
读 Store 时唯一要注意的是<strong>不要就地修改返回的对象</strong>:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。
## 边界与注意
- <strong>需要自己补充 Store 定义的情况非常少见</strong>。`add*` 是数据端自用的登记接口:内容加载还没有写完,写完后由数据端统一登记。绝大多数情况下你只需要读,不需要自己补定义。确实需要补时,属于后续高级教程的内容(`高级 II · 加载与插件生命周期`,待编写),并且要把调用集中放在插件的一个时机方法里(见[插件系统](./plugin-system.md)),只在启动流程里执行一次。
- <strong>道具定义的两个效果字段都是必填</strong>。`effect` 与 `equip` 不是二选一:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。
- <strong>返回值不是副本</strong>。改 `getData` / `getMap` / `getEvent` 的返回值等于改注册表;需要不同的定义就登记新的,不要改旧的。
- <strong>读档不会恢复 Store</strong>。改过的定义不会随存档保存,读档也不会把它退回去。
- <strong>`eventStore.getEvent` 给的是实例本身</strong>,不要在上面做缓存并当成快照。
@ -136,6 +102,7 @@ export function readStores(state: ICoreState): void {
- 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。
- 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。
- 每个 Store 只负责自己那类内容:读定义、做必要的换算,登记则由数据端在加载期统一完成。
- 读随时可以;返回的通常是注册表里的实例,不要就地修改。
- 登记由数据端负责,绝大多数情况不需要自己调用 `add*`,运行期尤其不要写。

View File

@ -1,30 +1,20 @@
# 钩子深入
中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。结论只有一句——<strong>一个钩子对象,对应一个钩子实例</strong>。
中级教程的[钩子](../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`),事件也从不同对象发出。
「一个对象一个实例」把身份固定在注册处:钩子在哪个对象上被触发,就是因为它注册在哪个对象上。多数钩子方法因此不需要再判断「这次是不是我要的对象」。
## 示例:只处理一个图层
有些图层钩子方法的参数里没有图层身份,例如 `onUpdateBlock(block, x, y)` 只有坐标与图块数字。这类钩子只能靠注册位置限定:注册到哪个图层,就只服务哪个图层。
图层级钩子最典型的写法是:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。
```ts
import { IMapLayer, IMapLayerHooks } from '@user/data-base';
import { ICoreState } from '@user/data-state';
// 图层级钩子:只处理注册它的那个图层
export class GroundBlockHooks implements IMapLayerHooks {
constructor(
readonly state: ICoreState,
@ -39,23 +29,22 @@ export class GroundBlockHooks implements IMapLayerHooks {
}
```
`readonly layer` 的作用是让钩子能访问目标图层本身(读宽高、调用图层方法),不是用来比较一个不存在的参数。
注册时找到目标图层,把实例挂到它上面:
```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) {
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(l => l.alias === 'ground');
const layer = [...map.layerList].find(v => v.alias === 'ground');
if (!layer) {
state.emitTip('ground 图层不存在');
return null;
@ -66,7 +55,21 @@ export function registerGroundHooks(state: ICoreState) {
}
```
`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。
模板里的 `readonly layer` 让钩子能访问目标图层本身(读宽高、调用图层方法),不是用来比较一个不存在的参数。`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。
## 建议一个对象一个实例
一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的<strong>实例</strong>,以及它被注册到的那个对象实例。
`Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。所以:
- 同一个钩子类可以 `new` 出多个实例,分别注册到不同对象;
- 给图层 A 注册的钩子,不会出现在图层 B 上;
- 一个实例只应注册到一个对象。
以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,但它的钩子列表与图层完全分开,接收的是另一套接口(`IGameMapHooks` 与 `IMapLayerHooks`),事件也从不同对象发出。
把实例固定在一个对象上,身份就固定在注册处:钩子在哪个对象上被触发,就是因为它注册在哪个对象上。多数钩子方法因此不需要再判断「这次是不是我要的对象」。把同一个实例注册到多个对象是允许的,但回调里就得自己区分触发者,还要承担共享状态带来的后果;除非你明确了解钩子的内部实现,否则不要这样做,本篇也不展开这种用法。
如果钩子要在构造时保持通用、注册时才确定图层,可以在 `awake` 里取控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`:
@ -94,6 +97,9 @@ export class UniversalBlockHooks implements IMapLayerHooks {
地图级钩子挂在 `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,
@ -121,7 +127,7 @@ export function registerTargetLayerHooks(state: ICoreState): void {
state.emitTip('floor-1 尚未构造,钩子未注册');
return;
}
const targetLayer = [...map.layerList].find(l => l.alias === 'ground');
const targetLayer = [...map.layerList].find(v => v.alias === 'ground');
if (!targetLayer) {
state.emitTip('ground 图层不存在');
return;
@ -138,19 +144,9 @@ export function registerTargetLayerHooks(state: ICoreState): void {
`addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器。控制器最重要的方法是 `unload()`:
```ts
import { ICoreState } from '@user/data-state';
import { IMapLayer, IMapLayerHookController } from '@user/data-base';
import { GroundBlockHooks } from './ground-block-hooks';
import { IMapLayerHookController } from '@user/data-base';
// 注册,保存返回的控制器
export function addGroundBlockHooks(
state: ICoreState,
layer: IMapLayer
): IMapLayerHookController {
return layer.addHook(new GroundBlockHooks(state, layer));
}
// 之后需要撤掉时
// 保存注册时返回的控制器,之后需要撤掉时调用
export function removeGroundBlockHooks(
controller: IMapLayerHookController
): void {
@ -193,7 +189,7 @@ export function removeGroundBlockHooks(
## 边界与注意
- <strong>把地图级钩子挂到图层上</strong>。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用;反过来把图层钩子挂到地图上也不会收到图层事件。
- <strong>把同一个实例注册到多个对象</strong>。接口允许,但回调里就得自己区分触发者;一个对象一个实例更简单。
- <strong>把同一个实例注册到多个对象</strong>。接口允许,但不建议;除非你明确了解钩子的内部实现与后果,否则回调里区分触发者、共享状态带来的问题都会由你自己承担。
- <strong>重复注册同一个实例</strong>。`addHook` 不去重,实例会被多次调用,旧控制器还留在内部列表里。
- <strong>移除时 new 了一个新实例</strong>。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。
- <strong>在 awake 里等自己触发的事件</strong>。`awake` 调用时钩子还不在已加载列表里,`forEachHook` 遍历不到它。
@ -202,7 +198,7 @@ export function removeGroundBlockHooks(
## 小结
- 一个钩子对象对应一个钩子实例:实例注册到哪个对象,就只服务那个对象。
- 建议一个对象一个钩子实例:实例注册到哪个对象,就只服务那个对象。共享允许但不建议,除非明确了解内部实现与后果。
- 参数里没有图层身份的钩子(如 `onUpdateBlock`)靠注册位置限定;地图级钩子收到多图层事件时,用 `===` 比较回调传来的对象。
- `addHook` 返回控制器,`removeHook` 要传原实例;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。
- `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。

View File

@ -30,15 +30,15 @@
高级 I 一共七篇,包含本导览与六篇正文。
| 序 | 篇目 | 它能让你做什么 |
| --- | -------------------- | ------------------------------------------------------ |
| 1 | 高级 I 导览 | 看清分层、阅读路径与取舍原则,决定先读哪篇 |
| 2 | 声明式 vs 命令式接口 | 分清两种思路,知道持续生效的规则与一次性动作各该写在哪 |
| 3 | 插件系统 | 独立写出插件,分清数据端与渲染端的依赖和生命周期 |
| 4 | 可存档内容 | 让自定义状态正确进入存档,并在读档后恢复到存档时的样子 |
| 5 | 录像一致性 | 判断哪些方法该登记、哪些路径不该记,以及检查能保证什么 |
| 6 | data-common 的 Store | 知道内容注册表存什么、接口怎么用,以及为什么运行时只读 |
| 7 | 钩子深入 | 把钩子限定到一个对象上,一个对象一个钩子实例 |
| 序 | 篇目 | 它能让你做什么 |
| --- | -------------------- | ------------------------------------------------------------ |
| 1 | 高级 I 导览 | 看清分层、阅读路径与取舍原则,决定先读哪篇 |
| 2 | 声明式 vs 命令式接口 | 分清两种思路,知道持续生效的规则与一次性动作各该写在哪 |
| 3 | 插件系统 | 独立写出插件,分清数据端与渲染端的依赖和生命周期 |
| 4 | 可存档内容 | 让自定义状态正确进入存档,并在读档后恢复到存档时的样子 |
| 5 | 录像一致性 | 判断数据端哪些方法该登记、哪些路径不该记,以及检查能保证什么 |
| 6 | data-common 的 Store | 知道五个 Store 各存什么、能做什么,以及为什么运行时只读 |
| 7 | 钩子深入 | 把钩子限定到一个对象上,建议一个对象一个钩子实例 |
推荐阅读顺序:声明式 vs 命令式 → 插件系统 → 可存档内容 → 录像一致性 → data-common 的 Store → 钩子深入。
@ -51,7 +51,7 @@
按目标不同,可以走两条路径:
- <strong>做扩展系统</strong>:声明式 → 插件 → 可存档内容 → 录像一致性。适合新增任务、商店、计数器这类有自己状态与生命周期的功能。读完能独立装配一个插件,并让它的数据跟着存档走。
- <strong>改内容或机制</strong>:声明式 → data-common 的 Store → 钩子深入。适合在现有机制上加行为、按接口读取内容定义。读完能分清哪些接口只读、哪些需要登记,也能把钩子限定到目标对象上。
- <strong>改内容或机制</strong>:声明式 → data-common 的 Store → 钩子深入。适合在现有机制上加行为、按接口读取内容定义。读完能分清哪些只需要读、哪些由数据端登记,也能把钩子限定到目标对象上。
两条路径都建议先读声明式篇。它决定你后面是在「加一条规则」,还是「写一段一次性脚本」;这个判断错了,插件、存档与录像都会跟着别扭。
@ -80,7 +80,7 @@
- 能用自己的话解释声明式与命令式的区别,并说出一个「仍然该用命令式」的场景。
- 能拿到一个需求,判断它该做成插件,还是按现有接口读取即可。
- 能说出一个可存档对象必须满足的三个约束:只存重建必需的数据、`saveState` 返回独立对象(引擎不替你深拷贝)、`loadState` 先清空再重建。
- 能说明一个钩子实例只服务哪个对象,并在回调拿到多个对象时用对象引用筛出目标。
- 能说明为什么建议一个对象一个钩子实例,并在回调拿到多个对象时用对象引用筛出目标。
## 继续阅读

View File

@ -6,6 +6,30 @@
阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../intermediate/code-basics.md)。
## 先看模板
标记加在<strong>你自己的、会改状态的方法</strong>上。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记:
```ts
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 {
// ... 原有的购买逻辑
}
}
```
装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。
<strong>`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。</strong>录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。
## 什么该记
同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。
@ -55,27 +79,7 @@
所以它不能用来「消掉某条告警」。你消掉的是整段检查,真正的漏记会被一起放过。它只适合确实不该计入录像的路径,例如异步内容、延迟回调这类不会立刻改变状态的方法。源码注释也明确写了这层意思,并提醒不要用它规避告警。`reason` 目前不会出现在告警输出里,写它是为了后来的人知道这里为什么可以忽略。
## 在自己的方法上加标记
标记加在<strong>你自己的、会改状态的方法</strong>上。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记:
```ts
import { shouldReplay } from '@user/data-common';
export class ShopSystem implements IShopSystem {
// ... 其他成员
// 购买会改变金币与商店库存,必须计入录像
@shouldReplay('Buying items should be replayed.')
buyItem(item: number | string, count: number): ShopTradeResult {
// ... 原有的购买逻辑
}
}
```
装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。
同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。入口方法与被它调用的内部方法可以都标:安全检查会按调用嵌套组织路径,越靠里越接近真正改变状态的位置。但把标记集中在真正改状态的那一层,告警会更短、更好读。
同一个插件里,入口方法与被它调用的内部方法可以都标:安全检查会按调用嵌套组织路径,越靠里越接近真正改变状态的位置。但把标记集中在真正改状态的那一层,告警会更短、更好读。
## 能保证什么,不能保证什么
@ -106,6 +110,7 @@ export class ShopSystem implements IShopSystem {
## 边界与注意
- <strong>在渲染端加标记</strong>。`@shouldReplay` 只用于数据端;渲染端不参与录像验证,加了也不会进入检查。
- <strong>读档期间录像被禁用</strong>。`CoreState.loadState` 在恢复每个可存档对象之前调用 `replaySystem.disable()`,之后调用 `revert()`,所以读档本身不会被记进录像。
- <strong>执行器不在保护范围内</strong>。`bindSaveableExecuter` 绑定的 `afterLoad` 在该对象 `loadState` 之后立即运行,此时录像已经恢复可用。在执行器里操作会影响成绩的状态要格外小心,细节见[可存档内容](./saveable-content.md)。
- <strong>装饰器只认类的方法</strong>。写在普通函数、对象属性、箭头函数上都不会生效。
@ -121,7 +126,7 @@ export class ShopSystem implements IShopSystem {
- 录像一致性只要求一件事:玩家直接产生、会影响最终成绩的行为,一条都不能少记。
- 该不该记通常靠分析;判断不了时,把所有会改状态、可能影响结果的方法都加上 `@shouldReplay`。
- `@shouldReplay` 只登记路径,`@ignoreReplay` 把整段检查标记为忽略;两者都不写录像。
- `@shouldReplay` 只用于数据端,只登记路径;`@ignoreReplay` 把整段检查标记为忽略;两者都不写录像。
- 带标记的方法执行了却没有录像记录时,控制台会输出「录像记录缺失」警告。
- 检查不管逻辑、参数、顺序与一一对应,这些要自己核对。
- 读档期间录像被临时禁用,执行器不在保护范围内。

View File

@ -6,6 +6,85 @@
阅读前需要会基础语法:`class`、`new`、`Map`、`import`。如果你还没写过插件,建议先看[数据插件](../intermediate/data-plugins.md)与[插件系统](./plugin-system.md);可存档内容通常就注册在插件的 `awake` 里。
## 先看模板
可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。下面把两部分一次写完,后面再逐项解释。
```ts
// src/data/plugin/my-plugin/state.ts
import { ISaveableContent } from '@user/data-common';
// 存档形状:只放重建必需的数据,字段类型必须能被结构化克隆
export interface IMySave {
readonly values: Map<string, number>;
}
export class MySaveable implements ISaveableContent<IMySave> {
// 运行时的真实数据;换成你真正要保存的结构
private readonly values: Map<string, number> = new Map();
// 对外方法按你的需求增删
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';
export class MyPlugin implements IMyPlugin {
readonly id = 'my-plugin';
readonly dataDeps: string[] = [];
// 供插件其他方法使用
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` 声明存档形状并实现 `saveState` / `loadState`;`types.ts` 声明插件接口;`plugin.ts` 创建对象并在 `awake` 里注册。下面解释这两部分为什么这样写。
## 什么该存,什么不该存
判断一个字段该不该进存档,只看一件事:<strong>读档之后,它还能不能从别的地方算回来</strong>。
@ -26,7 +105,7 @@
漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是<strong>只交出重建所需的最小信息</strong>,`loadState` 的原则是<strong>先清空旧状态,再按存档重建</strong>。缓存不要存,读档后第一次用到时重新生成即可。
## ISaveableContent
## saveState 与 loadState
可存档对象要实现 `ISaveableContent<T>`:
@ -75,7 +154,7 @@ export class ShopManager implements IShopManager {
它没有把存档里的普通对象直接塞进 `Map`,而是重新 `new ShopState`,再让每个商店自己恢复内部状态。这样 `Map` 里装的仍然是带方法的类实例,而不是一堆失去原型的普通对象。
## 返回值必须独立
### 返回值必须独立
<strong>存档的顶层不会自动做深拷贝。</strong> `CoreState.saveState` 只是把每个对象 `saveState` 的返回值按 id 放进一个 `Map`,不会再 `structuredClone` 一遍。因此 `saveState` 必须自己返回一个<strong>独立的新对象</strong>:不能返回 `this`,也不能让返回值里的字段与活对象共享引用。否则之后对游戏对象的修改会直接改到已保存的数据上,读档时拿到的就是被污染的值。
@ -112,27 +191,21 @@ export class BadSaveable implements ISaveableContent<BadSaveable> {
深拷贝有成本。小型状态直接用 `structuredClone` 最省事;大存档整棵复制会明显变慢,这时可以手工构造一个新对象,只复制重建必需的字段。手工构造时,数组、`Map` 这类容器也要新建一份,不能把原容器直接挂上去。目的不是「一定要调用 `structuredClone`」,而是返回值不能与活对象共享引用。
## 注册可存档对象
### 压缩参数可以省略
实现好之后,用 `state.addSaveableContent(id, content)` 把它交给存档系统:
`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`,TypeScript 允许实现时少写参数。模板里就没有写 `compression`:调用方仍会按接口传两个实参,多出的那个被忽略。需要按压缩级别改变存法时,再把 `compression: SaveCompression` 加回来。
```ts
import { ICoreState } from '@user/data-state';
import { MySaveable } from './state';
`saveState` 里不要修改对象状态;`loadState` 不要假设旧数据还在。
// 在插件或初始化函数里注册;下文插件示例里的对象叫 this.store
export function registerMyStore(state: ICoreState, store: MySaveable): void {
state.addSaveableContent('@plugin/my-store', store);
}
```
## 注册到存档系统
`id` 是这个对象在存档里的唯一标识。整份数据端存档就是一个 `Map<string, unknown>`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。
用 `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` 里注册。此时主对象刚创建完,装配插件自己的对象正合适,注册顺序也最早、最可控。`initialize` / `afterLoad` 也能调用 `addSaveableContent`,但会排到已有对象之后,读档顺序也随之后移;只有对象必须等加载内容才能构造时才这样做。内置内容也是同样的注册方式,主插件在 `awake` 里注册了五项:
内置内容也是同样的注册方式。主插件在 `awake` 里注册了五项:
| 存档 id | 对应对象 |
| ---------------- | -------------------- |
@ -144,6 +217,12 @@ export function registerMyStore(state: ICoreState, store: MySaveable): void {
商店插件则注册 `@plugin/shop`,对应的是 `ShopManager`,不是插件对象本身。插件 id 与存档 id 是两套命名,不要互相推导。
### 注册时机
插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适。`initialize` / `afterLoad` 也能调用 `addSaveableContent`,但只在对象必须等加载内容才能构造时才这样做。注册时机只决定对象什么时候可用,不构成读档顺序的承诺。
无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。
## 三级压缩
`SaveCompression` 有三档:
@ -171,14 +250,11 @@ export function registerMyStore(state: ICoreState, store: MySaveable): void {
真正的差异压缩涉及存档格式与压缩算法,属于 `高级 II · 存档格式演进` 与 `高级 II · 压缩算法细节`。
## 存档顺序与执行器
## 读档收尾与执行器
存档和读档都按<strong>注册顺序</strong>遍历。`saveables` 是一个 `Map`,`addSaveableContent` 的先后就是 `saveState` 与 `loadState` 的先后。`saveState` 依次调用每个对象的 `saveState`,把结果按 id 写进存档;`loadState` 再按同一顺序调用每个对象的 `loadState`。
`saveState` 与 `loadState` 都会遍历已注册对象,但<strong>顺序不做保证</strong>。不要依赖某个对象先于另一个对象恢复,也不要为跨对象引用设计顺序。
读档时有两种不一致会被告警:
- 存档里缺少某个已注册 id 的数据:跳过该对象,控制台告警(错误码 `177`),它的执行器也不会运行;
- 存档里多出某个未注册 id 的数据:读档结束后统一告警(错误码 `178`),列出多余的 id。
读档时,存档里缺少某个已注册 id 的数据会告警(错误码 `177`)并跳过该对象;多出未注册 id 的数据会在读档结束后告警(错误码 `178`)。这两条只说明映射对不上,不代表对象的内部状态正确。
如果读档后还需要做一步收尾,用 `state.bindSaveableExecuter(content 或 id, executor)` 绑定执行器。执行器实现 `ISaveableExecutor<T>`:
@ -192,7 +268,7 @@ export interface ISaveableExecutor<T> {
一个可存档对象只能绑定一个执行器,重复绑定会替换前一个;一个执行器可以绑定多个对象,适合做全局性的读档收尾。
关键在时机:<strong>执行器在该对象的 `loadState` 返回后立即运行</strong>,不是等所有对象都读完之后统一运行。此时它前面的对象已经恢复完毕,后面的对象还没开始。这个顺序直接决定了跨对象引用该怎么解析。
关键在时机:<strong>执行器在该对象的 `loadState` 返回后立即运行</strong>,不是等所有对象都读完之后统一运行。因此执行器只能保证「这个对象自己已经恢复」,不能假设其它对象已经恢复完毕。
读档开始时,主对象会先清掉残留的录像禁用标记,再逐个恢复对象。每个对象的 `loadState` 都被 `replaySystem.disable()` 与 `revert()` 包裹,读档本身不会被记进录像;但执行器<strong>不在包裹范围内</strong>,因为它可能确实要做需要记录的行为。
@ -234,7 +310,7 @@ export function bindHeroFloorExecutor(state: ICoreState): void {
| 位置 | 什么时候运行 | 用来做什么 |
| ------------------------------- | --------------------------------------- | ---------------------------------- |
| 插件 `afterLoad(state, deps)` | 初次加载流程末尾,每个插件只运行一次 | 读配置、创建依赖加载内容的初始对象 |
| 执行器 `afterLoad(data, state)` | 每次读档,在该对象 `loadState` 之后立即 | 解析跨对象引用、做读档收尾 |
| 执行器 `afterLoad(data, state)` | 每次读档,在该对象 `loadState` 之后立即 | 做读档收尾,例如切换楼层 |
插件 `afterLoad` 只在<strong>初次加载</strong>后运行。玩家读档时,加载流程不会重跑,插件 `afterLoad` 也不会再执行。所以它不能替代读档执行器:需要「每次读档都做」的事,必须绑执行器。反过来,执行器也不适合做「初次加载」的初始化,它只在读档时运行。
@ -242,238 +318,35 @@ export function bindHeroFloorExecutor(state: ICoreState): void {
存档里不要直接存另一个对象的引用。返回值必须独立:直接放活对象,存档就会与游戏对象共享同一份引用,之后互相污染;即使存储层复制了它,读档后身份也已经丢了,类实例的方法也会一并丢掉。
正确做法是<strong>存 id 或键,读档时再解析</strong>。常见的键有楼层 `floorId`、商店 id、可存档 id,以及你自己维护的稳定 `uid`。例如一个任务系统要记住「目标商店」:
正确做法是<strong>存 id 或键,读档后或真正需要时再按 id 重建或查找</strong>。常见的键有楼层 id、商店 id、可存档 id,以及你自己维护的稳定 `uid`。读档顺序不做保证,所以不要假设被引用的对象已经先恢复;在用到的那一刻查一次,查不到就按你的规则新建或跳过。
```ts
// 引用方:存档里只存目标 id,不存 IShopState 对象
export interface IQuestSave {
readonly targetShopId: string;
}
export class QuestSaveable implements ISaveableContent<IQuestSave> {
private targetShopId: string | null = null;
constructor(private readonly state: ICoreState) {}
/** 在你的任务逻辑里调用,记录目标商店 */
setTargetShop(id: string): void {
this.targetShopId = id;
}
saveState(): IQuestSave {
return { targetShopId: this.targetShopId ?? '' };
}
loadState(state: IQuestSave): void {
this.targetShopId = state.targetShopId;
}
}
```
这两个类放在你自己的插件目录里(例如 `src/data/plugin/my-plugin/state.ts`)。新文件顶部需要:`import { ISaveableContent } from '@user/data-common';`、`import { ICoreState } from '@user/data-state';`;一会儿的执行器还要 `ISaveableExecutor`(同样来自 `@user/data-state`)与 `IStateBase`(来自 `@user/data-base`)。
读档时怎么把它变回对象,有三种策略:
| 策略 | 做法 | 适用场景 |
| -------------- | ---------------------------------------------------------------- | ---------------------------------- |
| 先注册被引用者 | 保证被引用对象的 `addSaveableContent` 早于引用者 | 依赖关系固定,启动时就能确定顺序 |
| 执行器延后解析 | 在引用者的执行器里解析;执行器运行时,注册在它之前的对象都已恢复 | 被引用者注册在前,但引用者需要收尾 |
| 惰性解析 | 存档只存 id,第一次真正用到时再按 id 查 | 被引用者可能后注册、可能不存在 |
执行器只保证「该对象自己的 `loadState` 已完成」,不保证后面的对象已完成。如果你要引用的对象注册在引用者之后,执行器里拿到的可能还是旧状态,这时应该改成惰性解析,或者调整注册顺序,让被引用者先注册。
引用者自己的注册与绑定如下,顺序不能反:
```ts
import { ICoreState } from '@user/data-state';
// 先注册引用者,再绑定执行器
export function registerQuest(state: ICoreState, quest: QuestSaveable): void {
state.addSaveableContent('@plugin/example-quest', quest);
state.bindSaveableExecuter(
'@plugin/example-quest',
new QuestSaveExecutor()
);
}
```
执行器里按 `@plugin/shop` 取回商店管理器。`getSaveableContent` 返回的是 `ISaveableContent<T>`,要调用管理器自己的 `getShop`,需要断言成 `IShopManager`:
```ts
// 读档后按 id 解析目标商店
export class QuestSaveExecutor implements ISaveableExecutor<IQuestSave> {
afterLoad(data: IQuestSave, state: IStateBase): void {
const manager = state.getSaveableContent<IShopManagerSave>(
'@plugin/shop'
) as IShopManager | null;
const shop = manager?.getShop(data.targetShopId) ?? null;
// 找不到时怎么处理,由你决定;不要假设它一定存在
}
}
```
执行器文件还要从 `../shop` 引入 `IShopManager` / `IShopManagerSave`(路径按你的目录调整)。
## 示例:一个可存档对象
假设你要让一组自定义计数跟着存档走。先写出可存档对象:它实现 `ISaveableContent<T>`,`T` 是存档形状。下面这个类可以放在插件目录里(例如 `src/data/plugin/my-plugin/state.ts`):
```ts
import { ISaveableContent } from '@user/data-common';
// 存档形状:只放重建必需的数据,字段类型必须能被结构化克隆
export interface IMySave {
readonly values: Map<string, number>;
}
export class MySaveable implements ISaveableContent<IMySave> {
// 运行时的真实数据;Map 只是常见选择,换成你真正要保存的结构
private readonly values: Map<string, number> = new Map();
// 对外方法按你的需求增删
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);
}
}
}
```
落笔时按你的数据结构替换 `values` 与 `IMySave` 的字段:只放重建必需的信息,缓存与派生数据不要放;`loadState` 里该 `new` 的类实例要重新 `new`,不要直接使用存档里的普通对象。
压缩参数可以按需省略:`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`,TypeScript 允许实现时少写参数,调用方仍会按接口传两个实参,多出的那个被忽略;需要区分压缩级别时,再把 `compression: SaveCompression` 加回来。
`saveState` 里不要修改对象状态;`loadState` 不要假设旧数据还在。
## 示例:把可存档对象接到插件里
可存档对象本身不会自动进入存档,还要在插件里持有并注册。下面的代码写在你插件的 `plugin.ts` 里:
```ts
export class MyPlugin implements IDataPluginDefine {
readonly id = 'my-plugin';
readonly dataDeps: string[] = [];
// 持有可存档对象,供插件其他方法使用
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> {}
}
```
`store` 与 `MySaveable` 换成你自己的类;对象构造需要 `state` 时,也在这里传入。注册优先放在 `awake`:对象必须等加载内容才能构造时,也可以放到插件 `afterLoad`,但注册顺序会排在已有对象之后。无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。
这种跨存档引用极其少见。大多数自定义状态只依赖自己的字段;遇到引用时按上述方式处理即可,不需要为它设计顺序。
## 验证
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面的代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行:
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,`state.loadState(snapshot, compression)` 按同一份映射恢复。确认读档后回到存档时的值即可:
```ts
import { SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { MyPlugin } from './plugin/my-plugin';
import { ICoreState, PluginStatus } from '@user/data-state';
import { IMyPlugin } from './types';
// 验证:存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类
// 验证:存档 → 改状态 → 读档;IMyPlugin / MySaveable 换成你自己的定义
export function verifySaveLoad(state: ICoreState): void {
const plugin = state.getDataPlugin<MyPlugin>('my-plugin')?.plugin;
if (!plugin) return;
const holder = state.getDataPlugin<IMyPlugin>('my-plugin');
if (!holder || holder.status !== PluginStatus.Loaded) return;
plugin.store.setValue('count', 1);
holder.plugin.store.setValue('count', 1);
const snapshot = state.saveState(SaveCompression.NoCompression);
plugin.store.setValue('count', 2);
holder.plugin.store.setValue('count', 2);
state.loadState(snapshot, SaveCompression.NoCompression);
console.log(plugin.store.getValue('count')); // 应为 1
console.log(holder.plugin.store.getValue('count')); // 应为 1
}
```
读档后应回到存档时的值。如果读到的是修改后的值,说明 `saveState` 返回了与活对象共享的引用,而不是独立副本。
对同一个对象把三档压缩各跑一次,可以确认三种策略读回来的结果一致:
```ts
import { SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { MyPlugin } from './plugin/my-plugin';
// 验证:三档压缩都应恢复出同一个值
export function verifyCompression(state: ICoreState): void {
const plugin = state.getDataPlugin<MyPlugin>('my-plugin')?.plugin;
if (!plugin) return;
for (const compression of [
SaveCompression.NoCompression,
SaveCompression.LowCompression,
SaveCompression.HighCompression
]) {
plugin.store.setValue('count', 1);
const snapshot = state.saveState(compression);
plugin.store.setValue('count', 2);
state.loadState(snapshot, compression);
console.log(compression, plugin.store.getValue('count')); // 每次都应为 1
}
}
```
如果只有某一档出错,说明 `saveState` 与 `loadState` 对压缩级别的处理不一致。
启动完成后还可以直接确认注册成功:
```ts
import { ICoreState } from '@user/data-state';
// 启动完成后确认注册成功
export function checkRegistered(state: ICoreState): void {
const content = state.getSaveableContent('@plugin/my-store');
console.log(content);
}
```
应拿到你注册的对象,而不是 `null`。为 `null` 时先检查 `addSaveableContent` 的 id 是否拼错、是否真的执行到了 `awake`。
要确认缺键与多键的告警,可以故意删掉存档里的一个键。`saveState` 返回的是 `ReadonlyMap`,要删键先复制成可修改的 `Map`:
```ts
import { SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
// 验证:删掉本对象的存档数据再读档,应告警 177
export function verifyMissingKey(state: ICoreState): void {
const snapshot = state.saveState(SaveCompression.NoCompression);
const edited = new Map(snapshot);
edited.delete('@plugin/my-store');
state.loadState(edited, SaveCompression.NoCompression);
}
```
读档时控制台应出现缺键告警(`177`),并跳过该对象;存档里多出未注册的 id 时,应出现多键告警(`178`)。这两条能帮你区分「没存进去」和「读的时候没对上」。
跨对象引用的检查需要构造一个引用其他对象的场景:读档后确认引用仍指向正确的对象,而不是 `null` 或另一份副本。若拿到的是旧状态,检查被引用对象的注册顺序,或改成执行器延后解析。
## 边界与注意
- <strong>saveState 返回 this 或内部活对象</strong>。顶层不会替你复制,存档映射会直接持有活对象,后续修改会污染已保存的数据。
@ -481,20 +354,21 @@ export function verifyMissingKey(state: ICoreState): void {
- <strong>把缓存或索引也存进去</strong>。读档时旧缓存覆盖新算出的结果,状态对不上;这类数据应该重建。
- <strong>漏掉某个字段</strong>。读档后该字段回到默认值,表现为「只恢复了一半」。
- <strong>重复注册同一个 id</strong>。后一个被忽略并告警(`112`),你以为生效的对象其实没进存档。
- <strong>执行器里访问尚未恢复的对象</strong>。执行器只保证当前对象已恢复;注册在后面的对象还没读。
- <strong>依赖对象之间的恢复顺序</strong>。读档顺序不做保证,执行器只保证当前对象已恢复。
- <strong>把插件 afterLoad 当读档钩子</strong>。它只在初次加载后运行一次,玩家读档时不会再触发。
- <strong>在 saveState 里改状态</strong>。存档是只读快照,`saveState` 不应该有副作用。
- <strong>跨对象引用直接存对象</strong>。存档与活对象共享引用,之后互相污染,读档后身份也已经丢失。
## 小结
- 先写可存档对象与插件注册模板,再逐项调整存档形状、返回值与注册时机。
- 只存重建必需的状态;缓存、派生数据与 L2 系统不进存档。
- `ISaveableContent<T>` 的 `T` 是存档形状;`saveState` 必须返回独立于活对象的数据,顶层不会替你 `structuredClone`。
- 存档数据必须能被结构化克隆;类实例会丢原型与方法,读档时要自己重建。
- 用 `addSaveableContent` 注册,id 唯一;重复 id 后一个被忽略(`112`)。
- 三档压缩只改变存储方式,不改变结果;每一档都必须能被自己的 `loadState` 读回。
- 存档与读档按注册顺序执行;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。
- 存档与读档不保证顺序;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。
- 插件 `afterLoad` 只在初次加载后运行一次,不能替代读档执行器。
- 跨对象引用存 id 或键,读档时按顺序或延迟解析,不要存活对象引用。
- 跨对象引用存 id 或键,读档后或需要时再查;这个场景极其少见,不要为它设计顺序。
返回[高级 I 导览](./index.md)。