From c578990810bc95d4fb6dc66564669c900d995ade Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 21 Oct 2026 12:27:45 +0800 Subject: [PATCH] =?UTF-8?q?docs(10-20):=20=E4=BF=AE=E8=AE=A2=E5=AD=98?= =?UTF-8?q?=E6=A1=A3/=E5=BD=95=E5=83=8F/Store/=E9=92=A9=E5=AD=90=EF=BC=88?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AC=AC4=E8=BD=AEB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../10-docs-restructure/10-20-USER-REVIEW.md | 16 + docs/advanced/data-stores.md | 139 +++---- docs/advanced/hooks-in-depth.md | 76 ++-- docs/advanced/index.md | 22 +- docs/advanced/replay-consistency.md | 49 +-- docs/advanced/saveable-content.md | 352 ++++++------------ 6 files changed, 256 insertions(+), 398 deletions(-) diff --git a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md index 05a9473..5cdd0e8 100644 --- a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md @@ -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。 + **状态:处理中** diff --git a/docs/advanced/data-stores.md b/docs/advanced/data-stores.md index 4c08d04..d8153e8 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -2,91 +2,77 @@ `@user/data-common` 是数据端的 Layer 0 公共层,上面挂着五个内容注册表:`tileStore`、`itemStore`、`mapStore`、`enemyStore`、`eventStore`。它们看起来像「数据表」,实际是内容层原始定义的注册表——图块、道具、地图、怪物、事件的定义登记在这里;游戏运行时从这里查出来,再按定义构造自己的对象。 -中级教程的[数据端总览](../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 在加载 / 初始化阶段被填一次,之后整个游戏过程都靠它查表。它记录的不是「玩家做了什么」,而是「世界上有什么」。 +运行时读 Store 是正常操作:查表、算数、拼提示文本、给界面提供数据都可以。运行时写 Store 会绕过加载、存档与录像这一整套机制,问题会在不确定的时机表现出来。 -## 五个 Store +## tileStore:图块定义 -- `tileStore`(`ITileStore`):按图块数字存图块原始定义 `ITileRawData`。读 `getData` / `getEvent` / `getType`,以及 `num` / `id` / `identity` 换算;写 `addTile`。 -- `itemStore`(`IItemStore`):按道具图块数字存道具原始定义 `IItemRawData`。读 `getData(token)` / `getCategory(num)`;写 `addItem`。 -- `mapStore`(`IMapStore`):按楼层 id(`floorId`)存地图原始数据 `IMapRawData`。读 `getMap(floorId)`;写 `addMap`。 -- `enemyStore`(`IEnemyStore`):按怪物图块数字存怪物原始数据 `IEnemyRawData`。读 `getEnemy(token)`;写 `addEnemy`。 -- `eventStore`(`IGameEventStore`):按事件 id存事件实例。读 `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` 都是必填字段,不是二选一:非装备类也要给出一个 `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)`:返回存储的那个实例,不是副本;查不到返回 `null`; -- `addEvent(id, event)`:登记一个事件;重复 id 会告警(错误码 `170`)并覆盖。注意签名与另外几个不同,第一个参数是事件 id,第二个才是事件实例。 +它能按 id 取回存储的那个实例,不是副本。登记用 `addEvent(id, event)`,第一个参数是事件 id,第二个才是实例;重复 id 会告警(错误码 `170`)并覆盖。 ## 为什么运行期只读 @@ -98,34 +84,14 @@ Store 在加载 / 初始化阶段被填一次,之后整个游 所以规则很简单:登记只在加载期做一次,运行期只读。 -至于登记这件事本身,绝大多数情况也不用你动手:`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 随时可以:查表、算数、拼提示文本、给界面提供数据,都是正常用法。查表不会改变任何东西,也不产生录像步。唯一要注意的是不要就地修改返回的对象:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。 +读 Store 时唯一要注意的是不要就地修改返回的对象:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。 ## 边界与注意 - 需要自己补充 Store 定义的情况非常少见。`add*` 是数据端自用的登记接口:内容加载还没有写完,写完后由数据端统一登记。绝大多数情况下你只需要读,不需要自己补定义。确实需要补时,属于后续高级教程的内容(`高级 II · 加载与插件生命周期`,待编写),并且要把调用集中放在插件的一个时机方法里(见[插件系统](./plugin-system.md)),只在启动流程里执行一次。 +- 道具定义的两个效果字段都是必填。`effect` 与 `equip` 不是二选一:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。 - 返回值不是副本。改 `getData` / `getMap` / `getEvent` 的返回值等于改注册表;需要不同的定义就登记新的,不要改旧的。 - 读档不会恢复 Store。改过的定义不会随存档保存,读档也不会把它退回去。 - `eventStore.getEvent` 给的是实例本身,不要在上面做缓存并当成快照。 @@ -136,6 +102,7 @@ export function readStores(state: ICoreState): void { - 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。 - 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。 +- 每个 Store 只负责自己那类内容:读定义、做必要的换算,登记则由数据端在加载期统一完成。 - 读随时可以;返回的通常是注册表里的实例,不要就地修改。 - 登记由数据端负责,绝大多数情况不需要自己调用 `add*`,运行期尤其不要写。 diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md index 8fe478b..567a68f 100644 --- a/docs/advanced/hooks-in-depth.md +++ b/docs/advanced/hooks-in-depth.md @@ -1,30 +1,20 @@ # 钩子深入 -中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。结论只有一句——一个钩子对象,对应一个钩子实例。 +中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。做法很简单——建议一个对象一个钩子实例。共享实例是允许的,但不建议;除非你明确了解钩子的内部实现与后果,否则不要这样做。 -钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再用两个例子分别处理图层级与地图级的限定,最后是注册、移除与异步的接口事实。 +钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先给一个完整模板,再解释实例与对象的关系,最后是注册、移除与异步的接口事实。 阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。 -## 一个对象,一个钩子实例 +## 先看模板 -一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的实例,以及它被注册到的那个对象实例。 - -`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` 创建出来的实例,以及它被注册到的那个对象实例。 + +`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)` 会收到该地图每个图层的尺寸变化——事件来自多个图层,对象却只有一个。这种回调里要自己筛掉不关心的图层: ```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( ## 边界与注意 - 把地图级钩子挂到图层上。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用;反过来把图层钩子挂到地图上也不会收到图层事件。 -- 把同一个实例注册到多个对象。接口允许,但回调里就得自己区分触发者;一个对象一个实例更简单。 +- 把同一个实例注册到多个对象。接口允许,但不建议;除非你明确了解钩子的内部实现与后果,否则回调里区分触发者、共享状态带来的问题都会由你自己承担。 - 重复注册同一个实例。`addHook` 不去重,实例会被多次调用,旧控制器还留在内部列表里。 - 移除时 new 了一个新实例。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。 - 在 awake 里等自己触发的事件。`awake` 调用时钩子还不在已加载列表里,`forEachHook` 遍历不到它。 @@ -202,7 +198,7 @@ export function removeGroundBlockHooks( ## 小结 -- 一个钩子对象对应一个钩子实例:实例注册到哪个对象,就只服务那个对象。 +- 建议一个对象一个钩子实例:实例注册到哪个对象,就只服务那个对象。共享允许但不建议,除非明确了解内部实现与后果。 - 参数里没有图层身份的钩子(如 `onUpdateBlock`)靠注册位置限定;地图级钩子收到多图层事件时,用 `===` 比较回调传来的对象。 - `addHook` 返回控制器,`removeHook` 要传原实例;`awake` 在加入已加载列表前调用,`destroy` 在移除时调用。 - `addHook` 不去重,`forEachHook` 顺序不保证,异步钩子用 `Promise.all` 并发等待。 diff --git a/docs/advanced/index.md b/docs/advanced/index.md index 566c40c..3eea730 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -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 @@ 按目标不同,可以走两条路径: - 做扩展系统:声明式 → 插件 → 可存档内容 → 录像一致性。适合新增任务、商店、计数器这类有自己状态与生命周期的功能。读完能独立装配一个插件,并让它的数据跟着存档走。 -- 改内容或机制:声明式 → data-common 的 Store → 钩子深入。适合在现有机制上加行为、按接口读取内容定义。读完能分清哪些接口只读、哪些需要登记,也能把钩子限定到目标对象上。 +- 改内容或机制:声明式 → data-common 的 Store → 钩子深入。适合在现有机制上加行为、按接口读取内容定义。读完能分清哪些只需要读、哪些由数据端登记,也能把钩子限定到目标对象上。 两条路径都建议先读声明式篇。它决定你后面是在「加一条规则」,还是「写一段一次性脚本」;这个判断错了,插件、存档与录像都会跟着别扭。 @@ -80,7 +80,7 @@ - 能用自己的话解释声明式与命令式的区别,并说出一个「仍然该用命令式」的场景。 - 能拿到一个需求,判断它该做成插件,还是按现有接口读取即可。 - 能说出一个可存档对象必须满足的三个约束:只存重建必需的数据、`saveState` 返回独立对象(引擎不替你深拷贝)、`loadState` 先清空再重建。 -- 能说明一个钩子实例只服务哪个对象,并在回调拿到多个对象时用对象引用筛出目标。 +- 能说明为什么建议一个对象一个钩子实例,并在回调拿到多个对象时用对象引用筛出目标。 ## 继续阅读 diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md index aebd019..955b95e 100644 --- a/docs/advanced/replay-consistency.md +++ b/docs/advanced/replay-consistency.md @@ -6,6 +6,30 @@ 阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../intermediate/code-basics.md)。 +## 先看模板 + +标记加在你自己的、会改状态的方法上。商店插件是现成的例子,它的 `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` 也都按同样方式标注。 + +`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。 + ## 什么该记 同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。 @@ -55,27 +79,7 @@ 所以它不能用来「消掉某条告警」。你消掉的是整段检查,真正的漏记会被一起放过。它只适合确实不该计入录像的路径,例如异步内容、延迟回调这类不会立刻改变状态的方法。源码注释也明确写了这层意思,并提醒不要用它规避告警。`reason` 目前不会出现在告警输出里,写它是为了后来的人知道这里为什么可以忽略。 -## 在自己的方法上加标记 - -标记加在你自己的、会改状态的方法上。商店插件是现成的例子,它的 `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 { ## 边界与注意 +- 在渲染端加标记。`@shouldReplay` 只用于数据端;渲染端不参与录像验证,加了也不会进入检查。 - 读档期间录像被禁用。`CoreState.loadState` 在恢复每个可存档对象之前调用 `replaySystem.disable()`,之后调用 `revert()`,所以读档本身不会被记进录像。 - 执行器不在保护范围内。`bindSaveableExecuter` 绑定的 `afterLoad` 在该对象 `loadState` 之后立即运行,此时录像已经恢复可用。在执行器里操作会影响成绩的状态要格外小心,细节见[可存档内容](./saveable-content.md)。 - 装饰器只认类的方法。写在普通函数、对象属性、箭头函数上都不会生效。 @@ -121,7 +126,7 @@ export class ShopSystem implements IShopSystem { - 录像一致性只要求一件事:玩家直接产生、会影响最终成绩的行为,一条都不能少记。 - 该不该记通常靠分析;判断不了时,把所有会改状态、可能影响结果的方法都加上 `@shouldReplay`。 -- `@shouldReplay` 只登记路径,`@ignoreReplay` 把整段检查标记为忽略;两者都不写录像。 +- `@shouldReplay` 只用于数据端,只登记路径;`@ignoreReplay` 把整段检查标记为忽略;两者都不写录像。 - 带标记的方法执行了却没有录像记录时,控制台会输出「录像记录缺失」警告。 - 检查不管逻辑、参数、顺序与一一对应,这些要自己核对。 - 读档期间录像被临时禁用,执行器不在保护范围内。 diff --git a/docs/advanced/saveable-content.md b/docs/advanced/saveable-content.md index a9e4d72..5279a37 100644 --- a/docs/advanced/saveable-content.md +++ b/docs/advanced/saveable-content.md @@ -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; +} + +export class MySaveable implements ISaveableContent { + // 运行时的真实数据;换成你真正要保存的结构 + private readonly values: Map = 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 {} + + async afterLoad(): Promise {} +} +``` + +`state.ts` 声明存档形状并实现 `saveState` / `loadState`;`types.ts` 声明插件接口;`plugin.ts` 创建对象并在 `awake` 里注册。下面解释这两部分为什么这样写。 + ## 什么该存,什么不该存 判断一个字段该不该进存档,只看一件事:读档之后,它还能不能从别的地方算回来。 @@ -26,7 +105,7 @@ 漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是只交出重建所需的最小信息,`loadState` 的原则是先清空旧状态,再按存档重建。缓存不要存,读档后第一次用到时重新生成即可。 -## ISaveableContent +## saveState 与 loadState 可存档对象要实现 `ISaveableContent`: @@ -75,7 +154,7 @@ export class ShopManager implements IShopManager { 它没有把存档里的普通对象直接塞进 `Map`,而是重新 `new ShopState`,再让每个商店自己恢复内部状态。这样 `Map` 里装的仍然是带方法的类实例,而不是一堆失去原型的普通对象。 -## 返回值必须独立 +### 返回值必须独立 存档的顶层不会自动做深拷贝。 `CoreState.saveState` 只是把每个对象 `saveState` 的返回值按 id 放进一个 `Map`,不会再 `structuredClone` 一遍。因此 `saveState` 必须自己返回一个独立的新对象:不能返回 `this`,也不能让返回值里的字段与活对象共享引用。否则之后对游戏对象的修改会直接改到已保存的数据上,读档时拿到的就是被污染的值。 @@ -112,27 +191,21 @@ export class BadSaveable implements ISaveableContent { 深拷贝有成本。小型状态直接用 `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`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。 +用 `state.addSaveableContent(id, content)` 把对象交给存档系统。`id` 是这个对象在存档里的唯一标识。整份数据端存档就是一个 `Map`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。 `id` 必须唯一。重复注册同一个 id 时,后一个会被忽略,控制台打一条警告(错误码 `112`)。这不是「后一个覆盖前一个」,而是第一个注册的继续生效,所以两个系统抢同一个 id 时,第二个实际上没有进存档。 需要按 id 取回对象时,用 `state.getSaveableContent(id)`。它返回 `ISaveableContent | 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 · 压缩算法细节`。 -## 存档顺序与执行器 +## 读档收尾与执行器 -存档和读档都按注册顺序遍历。`saveables` 是一个 `Map`,`addSaveableContent` 的先后就是 `saveState` 与 `loadState` 的先后。`saveState` 依次调用每个对象的 `saveState`,把结果按 id 写进存档;`loadState` 再按同一顺序调用每个对象的 `loadState`。 +`saveState` 与 `loadState` 都会遍历已注册对象,但顺序不做保证。不要依赖某个对象先于另一个对象恢复,也不要为跨对象引用设计顺序。 -读档时有两种不一致会被告警: - -- 存档里缺少某个已注册 id 的数据:跳过该对象,控制台告警(错误码 `177`),它的执行器也不会运行; -- 存档里多出某个未注册 id 的数据:读档结束后统一告警(错误码 `178`),列出多余的 id。 +读档时,存档里缺少某个已注册 id 的数据会告警(错误码 `177`)并跳过该对象;多出未注册 id 的数据会在读档结束后告警(错误码 `178`)。这两条只说明映射对不上,不代表对象的内部状态正确。 如果读档后还需要做一步收尾,用 `state.bindSaveableExecuter(content 或 id, executor)` 绑定执行器。执行器实现 `ISaveableExecutor`: @@ -192,7 +268,7 @@ export interface ISaveableExecutor { 一个可存档对象只能绑定一个执行器,重复绑定会替换前一个;一个执行器可以绑定多个对象,适合做全局性的读档收尾。 -关键在时机:执行器在该对象的 `loadState` 返回后立即运行,不是等所有对象都读完之后统一运行。此时它前面的对象已经恢复完毕,后面的对象还没开始。这个顺序直接决定了跨对象引用该怎么解析。 +关键在时机:执行器在该对象的 `loadState` 返回后立即运行,不是等所有对象都读完之后统一运行。因此执行器只能保证「这个对象自己已经恢复」,不能假设其它对象已经恢复完毕。 读档开始时,主对象会先清掉残留的录像禁用标记,再逐个恢复对象。每个对象的 `loadState` 都被 `replaySystem.disable()` 与 `revert()` 包裹,读档本身不会被记进录像;但执行器不在包裹范围内,因为它可能确实要做需要记录的行为。 @@ -234,7 +310,7 @@ export function bindHeroFloorExecutor(state: ICoreState): void { | 位置 | 什么时候运行 | 用来做什么 | | ------------------------------- | --------------------------------------- | ---------------------------------- | | 插件 `afterLoad(state, deps)` | 初次加载流程末尾,每个插件只运行一次 | 读配置、创建依赖加载内容的初始对象 | -| 执行器 `afterLoad(data, state)` | 每次读档,在该对象 `loadState` 之后立即 | 解析跨对象引用、做读档收尾 | +| 执行器 `afterLoad(data, state)` | 每次读档,在该对象 `loadState` 之后立即 | 做读档收尾,例如切换楼层 | 插件 `afterLoad` 只在初次加载后运行。玩家读档时,加载流程不会重跑,插件 `afterLoad` 也不会再执行。所以它不能替代读档执行器:需要「每次读档都做」的事,必须绑执行器。反过来,执行器也不适合做「初次加载」的初始化,它只在读档时运行。 @@ -242,238 +318,35 @@ export function bindHeroFloorExecutor(state: ICoreState): void { 存档里不要直接存另一个对象的引用。返回值必须独立:直接放活对象,存档就会与游戏对象共享同一份引用,之后互相污染;即使存储层复制了它,读档后身份也已经丢了,类实例的方法也会一并丢掉。 -正确做法是存 id 或键,读档时再解析。常见的键有楼层 `floorId`、商店 id、可存档 id,以及你自己维护的稳定 `uid`。例如一个任务系统要记住「目标商店」: +正确做法是存 id 或键,读档后或真正需要时再按 id 重建或查找。常见的键有楼层 id、商店 id、可存档 id,以及你自己维护的稳定 `uid`。读档顺序不做保证,所以不要假设被引用的对象已经先恢复;在用到的那一刻查一次,查不到就按你的规则新建或跳过。 -```ts -// 引用方:存档里只存目标 id,不存 IShopState 对象 -export interface IQuestSave { - readonly targetShopId: string; -} - -export class QuestSaveable implements ISaveableContent { - 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`,要调用管理器自己的 `getShop`,需要断言成 `IShopManager`: - -```ts -// 读档后按 id 解析目标商店 -export class QuestSaveExecutor implements ISaveableExecutor { - afterLoad(data: IQuestSave, state: IStateBase): void { - const manager = state.getSaveableContent( - '@plugin/shop' - ) as IShopManager | null; - const shop = manager?.getShop(data.targetShopId) ?? null; - // 找不到时怎么处理,由你决定;不要假设它一定存在 - } -} -``` - -执行器文件还要从 `../shop` 引入 `IShopManager` / `IShopManagerSave`(路径按你的目录调整)。 - -## 示例:一个可存档对象 - -假设你要让一组自定义计数跟着存档走。先写出可存档对象:它实现 `ISaveableContent`,`T` 是存档形状。下面这个类可以放在插件目录里(例如 `src/data/plugin/my-plugin/state.ts`): - -```ts -import { ISaveableContent } from '@user/data-common'; - -// 存档形状:只放重建必需的数据,字段类型必须能被结构化克隆 -export interface IMySave { - readonly values: Map; -} - -export class MySaveable implements ISaveableContent { - // 运行时的真实数据;Map 只是常见选择,换成你真正要保存的结构 - private readonly values: Map = 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 {} - - async afterLoad(): Promise {} -} -``` - -`store` 与 `MySaveable` 换成你自己的类;对象构造需要 `state` 时,也在这里传入。注册优先放在 `awake`:对象必须等加载内容才能构造时,也可以放到插件 `afterLoad`,但注册顺序会排在已有对象之后。无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。 +这种跨存档引用极其少见。大多数自定义状态只依赖自己的字段;遇到引用时按上述方式处理即可,不需要为它设计顺序。 ## 验证 -存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面的代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行: +存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,`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('my-plugin')?.plugin; - if (!plugin) return; + const holder = state.getDataPlugin('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('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` 或另一份副本。若拿到的是旧状态,检查被引用对象的注册顺序,或改成执行器延后解析。 - ## 边界与注意 - saveState 返回 this 或内部活对象。顶层不会替你复制,存档映射会直接持有活对象,后续修改会污染已保存的数据。 @@ -481,20 +354,21 @@ export function verifyMissingKey(state: ICoreState): void { - 把缓存或索引也存进去。读档时旧缓存覆盖新算出的结果,状态对不上;这类数据应该重建。 - 漏掉某个字段。读档后该字段回到默认值,表现为「只恢复了一半」。 - 重复注册同一个 id。后一个被忽略并告警(`112`),你以为生效的对象其实没进存档。 -- 执行器里访问尚未恢复的对象。执行器只保证当前对象已恢复;注册在后面的对象还没读。 +- 依赖对象之间的恢复顺序。读档顺序不做保证,执行器只保证当前对象已恢复。 - 把插件 afterLoad 当读档钩子。它只在初次加载后运行一次,玩家读档时不会再触发。 - 在 saveState 里改状态。存档是只读快照,`saveState` 不应该有副作用。 - 跨对象引用直接存对象。存档与活对象共享引用,之后互相污染,读档后身份也已经丢失。 ## 小结 +- 先写可存档对象与插件注册模板,再逐项调整存档形状、返回值与注册时机。 - 只存重建必需的状态;缓存、派生数据与 L2 系统不进存档。 - `ISaveableContent` 的 `T` 是存档形状;`saveState` 必须返回独立于活对象的数据,顶层不会替你 `structuredClone`。 - 存档数据必须能被结构化克隆;类实例会丢原型与方法,读档时要自己重建。 - 用 `addSaveableContent` 注册,id 唯一;重复 id 后一个被忽略(`112`)。 - 三档压缩只改变存储方式,不改变结果;每一档都必须能被自己的 `loadState` 读回。 -- 存档与读档按注册顺序执行;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。 +- 存档与读档不保证顺序;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。 - 插件 `afterLoad` 只在初次加载后运行一次,不能替代读档执行器。 -- 跨对象引用存 id 或键,读档时按顺序或延迟解析,不要存活对象引用。 +- 跨对象引用存 id 或键,读档后或需要时再查;这个场景极其少见,不要为它设计顺序。 返回[高级 I 导览](./index.md)。