docs(10-20): 修订插件/存档/录像(用户第2轮)

- 插件系统适配 dataDeps 与数据端/渲染端依赖方向
- 可存档内容明确顶层不自动 structuredClone,并补充执行器切换楼层示例
- 录像一致性改为分析式判断,提前说明录像记录缺失警告,删除收集区间细节
- 三篇收束边界与注意,删除改内置对比与重复示例
This commit is contained in:
unanmed 2026-10-20 21:45:55 +08:00
parent a7dc542671
commit 8912f5d77d
4 changed files with 254 additions and 395 deletions

View File

@ -31,3 +31,50 @@
- `cmd /c "pnpm exec prettier --write docs/advanced/*.md"`:七篇全部 unchanged。
- `cmd /c "node node_modules/prettier/bin/prettier.cjs --check ..."`:全部通过。
- `cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"`:`build complete`,无 dead link。
---
## 第 2 轮
**时间:** 2026-10-20
**用户意见(17 条):**
| # | 意见 | 落点 |
|---|------|------|
| U2-01 | 不要用 `content`(`src/content`)中的内容举例——那是编辑器负责的,没人会直接改文件 | 全部 |
| U2-02 | 地图伤害按「计算后怪物」结算,中级教程已讲过,高级教程不必重提 | 相关篇目 |
| U2-03 | 「声明式 vs 命令式」的验证节可以不加——本篇是概念性描述 | `declarative-vs-imperative.md` |
| U2-04 | 用户已修改插件系统结构:**渲染端可以依赖数据端插件,反之不行**;需重新适配文档 | `plugin-system.md`(及相关) |
| U2-05 | 存档顶层**不会**自动 `structuredClone`;必须在 `saveState` 调用端保证返回对象独立、不与任何其他对象同引用(妥协:大存档 `structuredClone` 很慢) | `saveable-content.md` |
| U2-06 | 存档执行器可举例:读档后勇士需切换至目标楼层 → 用执行器调用 `changeFloor`(实际内部实现不同,仅示例) | `saveable-content.md` |
| U2-07 | 「该不该记录」不是试出来的,通常是分析出来的;若不知如何分析,就把所有「修改了游戏状态且该状态可能导致结果改变」的方法都加上 `@shouldReplay` | `replay-consistency.md` |
| U2-08 | `@shouldReplay` 应明确:方法执行了但没探测到录像记录 → 控制台输出「录像记录缺失」警告;此说明要提前,不要脱节;不讲工作原理(留高级 II/III) | `replay-consistency.md` |
| U2-09 | 不讲 `beginReplaySafetyCollection` / `endReplaySafetyCollection`(太底层;调用后续会补;作者绝大多数情况不需要自己写) | `replay-consistency.md` |
| U2-10 | 示例应讲「在自己的方法上加标记」,而不是给内置方法补录;可用商店插件举例 | `replay-consistency.md` |
| U2-11 | 四个 Store 一览表太挤,改为无序列表 | `data-stores.md` |
| U2-12 | Store 登记是因为数据端未写完;说明「绝大多数情况下不需要自己登记」即可 | `data-stores.md` |
| U2-13 | `EnemyStore` 之前漏了,现已补上;同步文档 | `data-stores.md` |
| U2-14 | 模板允许在初始化期间登记内容,但现阶段不必讲(极少遇到),需要讲也放后面 | `data-stores.md` |
| U2-15 | 不要到处讲「改内置还是自己写」——像 Store 几乎不可能需要自定义,不要为它专门开一节;该要求仅针对中级教程 | 全部(尤其 `data-stores.md`) |
| U2-16 | 验证不必每篇都有;Store 只需讲怎么用接口。文档核心目的是让读者知道怎么用接口;几乎不可能自定义的 Store 用内置接口即可,无需验证 | `data-stores.md` 及相关 |
| U2-17 | 「钩子深入」给人感觉怪:一般不会多个钩子对象共用一个钩子,通常一个钩子对象对应一个钩子实例。不必讲共用;只教「一个对象一个实例」,身份来源、不要用 alias 判断身份等内容可删 | `hooks-in-depth.md` |
### 第 2 轮处置(`plugin-system` / `saveable-content` / `replay-consistency`)
**修改方式:** 内联执行,仅修改上述三篇与本职记录。
| # | 处置 |
|---|------|
| U2-04 | 按当前 `IDataPluginDefine` / `IClientPluginDefine` 源码把 `dependencies` 属性统一改为 `dataDeps`;新增「数据端与渲染端」小节,说明渲染端可依赖数据端、数据端不能依赖渲染端,并删去数据端文档中的渲染端细节。 |
| U2-05 | 重写「返回值必须独立」一节:明确 `CoreState.saveState` 只负责收集返回值、顶层不再 `structuredClone`;`saveState` 必须自行返回独立对象,并说明大存档深拷贝的性能取舍。 |
| U2-06 | 在「存档顺序与执行器」中加入 `HeroFloorExecutor` 示例:读档后按 `location.floorId` 取楼层并调用 `hero.changeFloor`;注明仅示意、实际内部实现不同,且执行器接口是同步的。 |
| U2-07 | 把「该不该记」改为分析式判断(能否被玩家触发 / 是否改状态 / 是否可能影响结果);判断不了时,给所有「改状态且可能影响结果」的方法都加 `@shouldReplay`。 |
| U2-08 | 在「两个装饰器」之后提前加入「录像记录缺失」警告说明:带标记的方法执行了却没有录像记录时,控制台会告警;不展开内部机制。 |
| U2-09 | 删除 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 及相关收集区间、错误码、`logReplaySafetyDetail` 内容;验证改为回放对比与告警观察。 |
| U2-10 | 删除给内置 `FlagSystem.getOrInsert` 补标记的示例,改为商店插件 `ShopSystem.buyItem` 上添加 `@shouldReplay` 的示例。 |
| U2-15 | 删除三篇中的「改内置还是自己写 / 改现有插件还是新建插件」专门小节;仅保留插件承载新系统的正向说明。 |
| D-60 / D-61 | 三篇按简洁优先删去重复示例与过度解释;常见错误统一收束为文末「边界与注意」小节,主干不再混入边界情况。 |
**构建:** `pnpm exec prettier --check` 三篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
**状态:处理中**

View File

@ -1,8 +1,8 @@
# 插件系统
如果你已经用过中级教程里的[数据插件](../intermediate/data-plugins.md),你会知道插件能把一组功能打包、挂到 `state` 上,并按固定时机初始化。那一篇停在「会用」;这一篇讲「能自己设计并维护」。
如果你已经用过中级教程里的[数据插件](../intermediate/data-plugins.md),你会知道插件能把一组功能打包、挂到 `state` 上,并按固定时机初始化。那一篇停在「会用」;这一篇讲怎么自己写一个插件、让插件彼此协作,以及为什么插件的 `id` 不能随便改。
插件是高级 I 的核心:后面的可存档内容、录像一致性、钩子深入,实际都挂在某个插件上。读完本篇,你应该能独立写出一个插件、让插件彼此协作,并知道为什么插件的 `id` 不能随便改。
插件是高级 I 的核心:后面的可存档内容、录像一致性、钩子深入,实际都挂在某个插件上。
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。不需要懂引擎内部实现;涉及内部机制的地方,会先说清「你能拿它做什么」。
@ -19,52 +19,39 @@
这和[声明式接口](./declarative-vs-imperative.md)是同一个思路:你不去改引擎的初始化流程,只声明「在哪个时机做什么」,时机由引擎统一调用。
还有一层现实理由:`src` 是你自己的代码,升级引擎时一般不用动;而内置包(`packages` / `packages-user`)在升级时可能会变。新增系统优先做成插件,等于把改动留在自己的地盘里,升级时要跟的差异更少。
## 插件接口
## 最小插件
### IDataPluginDefine 的五个成员
一个插件实现 `IDataPluginDefine`:
一个数据端插件实现 `IDataPluginDefine`:
| 成员 | 类型 | 说明 |
| --------------------------------- | ---------------------------------------- | ---------------------------- |
| `id` | `string` | 插件标识符,全局唯一 |
| `dependencies` | `string[]` | 依赖的插件 id 数组 |
| `dataDeps` | `readonly string[]` | 依赖的其他数据端插件 id |
| `awake(state, dependencies)` | `(state, dependencies) => void` | 同步初始化,主对象构造期调用 |
| `initialize(state, dependencies)` | `(state, dependencies) => Promise<void>` | 异步初始化,加载开始前调用 |
| `afterLoad(state, dependencies)` | `(state, dependencies) => Promise<void>` | 加载完毕后调用 |
只看类本身,最小实现如下(完整文件见下文「示例:一个完整插件」)。方法里用不到的参数可以省略,现有的 `DataShopPlugin` 就只收了 `state`:
这里最容易混淆的是两处名字:
```ts
import { ICoreState, IDataPluginDefine } from '@user/data-state';
// 一个最小插件:只有 id、dependencies 和三个时机方法
export class MyPlugin implements IDataPluginDefine {
readonly id = 'my-plugin';
readonly dependencies: string[] = [];
awake(state: ICoreState): void {
// 在这里装配插件自己的对象
}
async initialize(state: ICoreState): Promise<void> {}
async afterLoad(state: ICoreState): Promise<void> {}
}
```
这里最容易混淆的是 `dependencies` 的两种形态:
- <strong>定义插件时</strong>,`dependencies` 是依赖插件的 <strong>id 字符串数组</strong>;
- <strong>三个时机方法收到时</strong>,`dependencies` 参数是这些 id 解析后的<strong>插件对象数组</strong>,元素类型是 `IDataPlugin`,含 `plugin` 与 `status` 两个字段。
- <strong>声明依赖时</strong>,属性叫 `dataDeps`,写的是依赖插件的 <strong>id 字符串数组</strong>;
- <strong>三个时机方法收到时</strong>,参数叫 `dependencies`,类型是 `Readonly<IDataPlugin>[]`,元素是解析后的插件包装对象,含 `plugin` 与 `status` 两个字段。
所以「声明依赖」写的是 `id`,真正拿到的却是对象。两者不要混着写。
### 注册到 dataPlugins
## 数据端与渲染端
插件写好后,在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组里<strong>追加</strong>一项,不要清空或替换已有插件。列表里现在已经有商店插件:
插件分数据端与渲染端两套注册表。数据端插件实现 `IDataPluginDefine`;渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`),生命周期相同,但多一份依赖声明:
| 插件类型 | 依赖声明 | 能依赖 |
| -------- | ------------------------- | ----------------------- |
| 数据端 | `dataDeps` | 数据端插件 |
| 渲染端 | `dataDeps` + `clientDeps` | 数据端插件 + 渲染端插件 |
依赖方向是单向的:<strong>渲染端可以依赖数据端,数据端不能依赖渲染端</strong>。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。写数据端插件时只需要关心 `dataDeps`;渲染端插件不在本篇展开。
## 注册到 dataPlugins
插件写好后,在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组里<strong>追加</strong>一项,不要清空或替换已有插件:
```ts
import { IDataPluginDefine } from '@user/data-state';
@ -80,11 +67,11 @@ export const dataPlugins: IDataPluginDefine[] = [
];
```
如果你改的是<strong>已有插件</strong>,它对应的 `import` 通常已经在文件顶部,不要重复粘;如果你是<strong>新建插件</strong>,`list.ts` 里不会有你的类名,必须自己补一行 `import { MyPlugin } from './my-plugin';`(路径指向你的插件目录)。只把数组写成 `[new MyPlugin()]` 会把商店插件挤掉。
新建插件时,`list.ts` 里不会有你的类名,必须自己补一行 `import`,路径指向你的插件目录。只把数组写成 `[new MyPlugin()]` 会把商店插件挤掉。
数据端启动时会遍历这个数组,把插件收进注册表。同一个 `id` 出现两次时,后一个会被忽略,控制台报一条错误(错误码 `78`),所以请保证 `id` 唯一。
## 三个生命周期进阶
## 生命周期
三个时机在启动流程里的位置是固定的。按 `CoreState` 的实际执行顺序,可以画成这样:
@ -97,8 +84,6 @@ load() 开始
└─ afterLoad 异步,加载完毕后
```
数据端主对象在构造时就会调用 `awake`;之后由 `load()` 依次执行 `initialize`、加载、`afterLoad`。`state` 本身的入口与常用对象见[数据端总览](../intermediate/data-state-overview.md)。
### awake:主对象构造期,同步执行
`awake` 在主对象构造过程中被调用,此时地图、配置都还没加载。它返回 `void`,<strong>引擎不会等待它的返回值</strong>——即使你把它写成 `async`,引擎也会立刻走到下一个插件。
@ -136,9 +121,7 @@ load() 开始
三个阶段都走同一份依赖排序结果:被依赖的插件先完成同一阶段,依赖它的一方才会开始。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。
## dependencies 与排序
### 排序规则
## dataDeps 与排序
引擎内部用 `orderPlugins` 计算顺序,结果包含三样东西:
@ -150,19 +133,17 @@ load() 开始
排序只保证一件事:<strong>依赖关系成立时,被依赖的插件先完成,依赖它的后完成</strong>。不构成依赖关系的插件之间,引擎按登记顺序扫描,但这不是可以依赖的契约——需要谁先谁后,就显式声明依赖。
这份顺序在主对象构造时算一次,之后不会重算。启动后修改某个插件的 `dependencies` 数组不会改变顺序,也没有接口在运行时追加插件。依赖在启动前定好。
这份顺序在主对象构造时算一次,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变顺序,也没有接口在运行时追加插件。依赖在启动前定好。
### 失败语义:循环依赖与缺失依赖
依赖写错不会「部分生效」,而是整条链路被阻断:
- <strong>循环依赖</strong>:能沿依赖边绕回自身的插件都会被记进 `cyclic`,控制台报一条错误(错误码 `76`),列出环上的 id;
- <strong>缺失依赖</strong>:`dependencies` 里写了一个没有注册的 id,该插件会被记进 `missing`,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
- <strong>缺失依赖</strong>:`dataDeps` 里写了一个没有注册的 id,该插件会被记进 `missing`,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
- <strong>下游阻断</strong>:依赖了上述插件的插件,也会一起被排除。被排除的插件不会进入 `order`,三个时机一个都不会执行。
也就是说,一处依赖写错,受影响的不只是那一个插件,而是依赖它的整条链。排查时先看控制台里的错误码和 id,再顺链往上找。
依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。只依赖你确实要调用的插件。
一处依赖写错,受影响的不只是那一个插件,而是依赖它的整条链。排查时先看控制台里的错误码和 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。
## 插件之间怎么调用
@ -174,13 +155,10 @@ load() 开始
// 插件 B 依赖插件 A,并在初始化时调用 A 的方法
export class PluginB implements IDataPluginDefine {
readonly id = 'plugin-b';
readonly dependencies = ['plugin-a'];
readonly dataDeps = ['plugin-a'];
awake(
state: ICoreState,
dependencies: IDataPlugin<IDataPluginDefine>[]
): void {
// 参数顺序与 dependencies 数组一致
awake(state: ICoreState, dependencies: Readonly<IDataPlugin>[]): void {
// 参数顺序与 dataDeps 数组一致
const a = dependencies[0]?.plugin as PluginA | undefined;
if (!a) return;
a.doSomething();
@ -217,20 +195,16 @@ if (holder && holder.status === PluginStatus.Loaded) {
## 插件 id 稳定性与版本迁移
### 插件 id 与可存档 id 是两套命名
插件有插件 id,它注册的可存档内容另有存档 id,两者互不相干:
这是最容易混的一处。插件有插件 id,它注册的可存档内容另有存档 id,两者互不相干:
| 名称 | 例子 | 用在哪 |
| ------- | ------------------ | ------------------------------------------- |
| 插件 id | `data-shop-plugin` | `dataPlugins` 注册表、其他插件的 `dataDeps` |
| 存档 id | `@plugin/shop` | `state.addSaveableContent(id, content)` |
| 名称 | 例子 | 用在哪 |
| ------- | ------------------ | ----------------------------------------------- |
| 插件 id | `data-shop-plugin` | `dataPlugins` 注册表、其他插件的 `dependencies` |
| 存档 id | `@plugin/shop` | `state.addSaveableContent(id, content)` |
商店插件就是现成的例子:它的插件 id 是 `data-shop-plugin`,在 `awake` 里注册的存档内容却是 `@plugin/shop`。这两个字符串没有任何推导关系,不要以为改了插件 id 存档 id 会跟着变。
商店插件就是现成的例子:它的插件 id 是 `data-shop-plugin`,在 `awake` 里注册的存档内容却是 `@plugin/shop`。这两个字符串没有任何推导关系,不要以为改了插件 id 存档 id 会跟着变,也不要从插件 id 猜存档 id。
### 迁移时的最小做法
插件 id 会被别的插件的 `dependencies` 引用,所以它是一份对外契约:
插件 id 会被别的插件的 `dataDeps` 引用,所以它是一份对外契约:
- <strong>不要随意改名</strong>。改名后,依赖它的插件会变成「缺失依赖」,整条链被阻断;
- <strong>不要复用旧 id 表示新语义</strong>。旧依赖方仍然能找到这个 id,但拿到的是另一套对象,运行时会以「方法不存在」之类的形式炸掉,而且很难从依赖声明上看出来。
@ -238,9 +212,9 @@ if (holder && holder.status === PluginStatus.Loaded) {
需要演进时,按改动大小选最小做法:
1. <strong>行为兼容的升级</strong>:保留原 `id`,只改实现。依赖方不需要动。
2. <strong>语义确实变了</strong>:新增一个 id,不要复用旧 id;同时把所有依赖方的 `dependencies` 一起改掉,不要分几次发布。
2. <strong>语义确实变了</strong>:新增一个 id,不要复用旧 id;同时把所有依赖方的 `dataDeps` 一起改掉,不要分几次发布。
3. <strong>必须过渡</strong>:让旧 id 继续注册一个兼容实现(或做转发),等所有依赖方迁完再删。
4. <strong>存档 id 尽量不动</strong>:读档时按存档 id 找内容,改了它,旧存档里的数据就对不上——当前对象会报「缺少存档数据」,旧数据则会成为多余键。确实要改时,需要自己写迁移逻辑(读档后从旧 id 取数据写进新对象),不要只改字符串。
4. <strong>存档 id 尽量不动</strong>:读档时按存档 id 找内容,改了它,旧存档里的数据就对不上。确实要改时,需要自己写迁移逻辑(读档后从旧 id 取数据写进新对象),不要只改字符串。
## 注册与目录
@ -255,8 +229,6 @@ if (holder && holder.status === PluginStatus.Loaded) {
然后在 `list.ts` 里 `new` 出来加进 `dataPlugins`。最终传给 `CoreState` 的插件数组在 `src/data/entry.ts` 里拼成:主插件在前,`dataPlugins` 展开在后。日常只需要改 `list.ts`,不必动 `entry.ts`。
这是少数<strong>应该新建文件与目录</strong>的场景:插件本身就是一个独立单元,把它的类、接口和对象放在一起,比全部塞进 `initialize.ts` 更容易维护。除此之外的普通效果直接写在对应的内置实现文件里,不要为每个效果新建目录。
## 示例:一个完整插件
假设要新建一个插件:它要能被启动流程加载,并拥有自己的对象与存档内容。插件放在 `src/data/plugin/` 下自己的目录里,下面这份代码写在新建的 `plugin.ts` 中,顶部的 `import` 要一起保留:
@ -268,8 +240,8 @@ import { ICoreState, IDataPlugin, IDataPluginDefine } from '@user/data-state';
export class MyPlugin implements IDataPluginDefine {
// 插件 id:全局唯一,发布后不要改,其他插件可能依赖它
readonly id = 'my-plugin';
// 依赖的插件 id 字符串数组;没有依赖就留空
readonly dependencies: string[] = [];
// 依赖的数据端插件 id;没有依赖就留空
readonly dataDeps: string[] = [];
// 插件自己的对象;暂时没有就删掉这一行以及 awake 里对应的装配
mySystem!: MySystem;
@ -283,47 +255,25 @@ export class MyPlugin implements IDataPluginDefine {
async initialize(
state: ICoreState,
dependencies: IDataPlugin<IDataPluginDefine>[]
dependencies: Readonly<IDataPlugin>[]
): Promise<void> {
// 加载开始前要做的事;可以 await,但会阻塞加载
}
async afterLoad(
state: ICoreState,
dependencies: IDataPlugin<IDataPluginDefine>[]
dependencies: Readonly<IDataPlugin>[]
): Promise<void> {
// 需要读取加载内容时写在这里
}
}
```
`MySystem` 是你自己写的类,文档不会替你定义。先按需求写出这个类;暂时没有对应对象时,把 `mySystem` 字段、`new MySystem(state)` 以及相关的 `addSaveableContent` 一起删掉,否则编译器会报「找不到名称 MySystem」。
`MySystem` 是你自己写的类,文档不会替你定义。暂时没有对应对象时,把 `mySystem` 字段、`new MySystem(state)` 以及相关的 `addSaveableContent` 一起删掉,否则编译器会报「找不到名称 MySystem」。
写好后,把插件追加到 `src/data/plugin/list.ts` 的 `dataPlugins` 数组末尾,并补上新建文件的 `import`:
写好后,把插件追加到 `src/data/plugin/list.ts` 的 `dataPlugins` 数组末尾,并补上新建文件的 `import`,写法见前面的「注册到 dataPlugins」。
```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()
];
```
只把数组写成 `[new MyPlugin()]` 会把商店插件挤掉。注册进 `dataPlugins` 只是第一步:`awake` 里的异步工作不会被等待;`initialize` 与 `afterLoad` 里启动的异步任务必须 `await`,否则阶段会提前结束。注册位置只有 `list.ts` 的数组,不要另外找地方调用插件方法。
需要定制时,通常只改这几处:
- <strong>类名与 id</strong>:`MyPlugin` 与 `my-plugin` 换成你的名字,`id` 全局唯一。
- <strong>dependencies</strong>:填依赖插件的 id 字符串;没有就保持空数组,不要填插件对象。
- <strong>awake 里的装配</strong>:换成你自己的对象。需要进存档的对象必须实现 `ISaveableContent`(`saveState` / `loadState`),细节见[可存档内容](./saveable-content.md)。
- <strong>存档 id</strong>:`@plugin/my-plugin` 换成你自己的存档 id,与插件 id 分开命名。
- <strong>initialize / afterLoad</strong>:用不到就留空方法;用到时按需加逻辑。
需要定制的通常只有三处:类名与 `id`;`dataDeps`(没有依赖就留空,不要填插件对象);`awake` 里的装配。需要进存档的对象必须实现 `ISaveableContent`(`saveState` / `loadState`),细节见[可存档内容](./saveable-content.md)。
## 示例:读一个现有插件
@ -334,7 +284,7 @@ export const dataPlugins: IDataPluginDefine[] = [
```ts
export class DataShopPlugin implements IShopPlugin {
id: string = 'data-shop-plugin';
dependencies: string[] = [];
dataDeps: string[] = [];
shop!: IShopSystem;
manager!: IShopManager;
@ -364,7 +314,7 @@ export class DataShopPlugin implements IShopPlugin {
- `id` 是 `data-shop-plugin`,没有依赖,所以它会在 `awake` 的第一批里执行;
- `awake` 先保存 `state`,再创建系统与管理器。这两个对象都需要主对象,所以只能在这里 `new`;
- `state.addSaveableContent('@plugin/shop', this.manager)` 把管理器注册为存档内容。<strong>注册的是管理器,不是插件本身</strong>;存档 id 也另起了一套命名。这样读档时恢复的是商店数据,插件对象本身不需要进存档;
- `state.addSaveableContent('@plugin/shop', this.manager)` 把管理器注册为存档内容。<strong>注册的是管理器,不是插件本身</strong>;存档 id 也另起了一套命名;
- `initialize` 与 `afterLoad` 都是空的:商店不依赖加载内容,装配在 `awake` 就完成了。
`IShopPlugin` 在 `IDataPluginDefine` 之上加了 `shop`、`manager`、`createShopState`,这就是「插件对外暴露能力」的写法。别的插件依赖它时,就能直接调用这些成员。
@ -396,51 +346,10 @@ async afterLoad(state: ICoreState): Promise<void> {
}
```
这段为什么不能放进 `awake`:`coreConfig` 来自 `core.jsonc`,要等加载器读完才有。`awake` 阶段 `getConfig` 拿不到内容,创建勇士就会失败。`afterLoad` 正好是「加载已完成、但游戏还没正式开始」的时机。
它同时说明了另一个点:`afterLoad` 拿到的 `state` 与 `awake` 是同一个主对象,`awake` 里存下的引用(如 `this.heroFace`)此时仍然可用。
## 改现有插件还是新建插件
先分清两种改动:
- 只是给现有插件加一点行为,例如给商店多一个查询接口、调整某个系统的默认值——<strong>直接改现有插件的代码</strong>,在它的类里加方法或改实现;
- 要加一组独立的功能,有自己的状态、自己的管理器、自己的存档内容——<strong>新建插件</strong>。新建的插件同样留在 `src` 里,升级时不用跟着内置包的差异走。
这里说的「改内置」只指改 `packages` / `packages-user` 里的代码。新增系统优先做成插件,正是为了不碰它们;只有现有插件与扩展点都覆盖不了、必须替换内置行为时,才去动内置包。
## 常见错误
- <strong>在 awake 里 await</strong>。`awake` 的返回值会被忽略,写 `async awake` 也不会被等待。异步准备放到 `initialize`。
- <strong>把 dependencies 写成对象</strong>。它必须是 id 字符串数组;插件对象是三个时机方法收到的参数,不是声明时写的东西。
- <strong>依赖成环</strong>。环上的插件全部被排除,依赖它们的下游也不执行。依赖关系要单向。
- <strong>依赖 id 拼错</strong>。插件不会报「找不到类」,而是被记为缺失依赖并从 `order` 中排除;控制台会给出插件 id 与缺失的 id。
- <strong>用 getDataPlugin 不判空</strong>。它可能返回 `null`,也可能处于你还没等到阶段。
- <strong>把插件 id 当显示名随意改</strong>。别的插件的 `dependencies` 会立刻失效,存档 id 也不会跟着变。
- <strong>直接改其他系统的内部字段</strong>。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。
- <strong>重复注册同一个 id</strong>。后一个会被忽略并报错(`78`),你以为生效的插件其实没有进注册表。
这段为什么不能放进 `awake`:`coreConfig` 来自 `core.jsonc`,要等加载器读完才有。`awake` 阶段 `getConfig` 拿不到内容,创建勇士就会失败。`afterLoad` 正好是「加载已完成、但游戏还没正式开始」的时机。它同时说明了另一个点:`afterLoad` 拿到的 `state` 与 `awake` 是同一个主对象,`awake` 里存下的引用(如 `this.heroFace`)此时仍然可用。
## 验证
想确认三个时机按顺序发生,可以在插件的三个方法里各放一条可观察信号:
```ts
// [验证] 用提示确认时机顺序
awake(state: ICoreState): void {
state.emitTip('my-plugin: awake');
}
async initialize(state: ICoreState): Promise<void> {
state.emitTip('my-plugin: initialize');
}
async afterLoad(state: ICoreState): Promise<void> {
state.emitTip('my-plugin: afterLoad');
}
```
把插件加进 `dataPlugins` 后启动,三条提示应依次出现:`awake` 最早,`initialize` 在加载开始前,`afterLoad` 在加载结束后。用控制台输出也可以,只要顺序能被观察到。
启动完成后,用 `state.getDataPlugin` 取回插件,确认它已经进了注册表:
```ts
@ -451,36 +360,25 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
`PluginStatus` 从 `@user/data-state` 引入,`MyPlugin` 从你的插件文件引入。`status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。
要确认插件注册的存档内容能恢复,先存档,再改动它,然后读档。两个入口同样在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,`state.loadState(snapshot, compression)` 再用同一份映射恢复。把下面几行放进一个能拿到 `state` 的临时函数执行一次:
想确认依赖阻断,可以把 `dataDeps` 里的某个 id 改成一个不存在的名字再启动:控制台应报缺失依赖错误(`77`,带插件 id 与缺失的依赖 id),并且你的插件没有任何时机输出。插件注册的存档内容如何验证,见[可存档内容](./saveable-content.md)的「验证」一节。
```ts
// [验证] 存档,改掉可观察值,再读档;类名与路径换成你自己的
import { SaveCompression } from '@user/data-common';
import { MyPlugin } from './plugin/my-plugin';
## 边界与注意
const plugin = state.getDataPlugin<MyPlugin>('my-plugin')?.plugin;
if (plugin) {
// store 换成你插件里实际注册的可存档对象
plugin.store.setValue('count', 1);
const snapshot = state.saveState(SaveCompression.NoCompression);
plugin.store.setValue('count', 2);
state.loadState(snapshot, SaveCompression.NoCompression);
console.log(plugin.store.getValue('count')); // 应为 1
}
```
这里假设插件用[可存档内容](./saveable-content.md)里的 `MySaveable` 作为注册对象,它带有 `setValue` / `getValue`;换成你自己的对象时,把 `plugin.store` 与这两个方法一起替换掉。读档后应回到存档时的值。没恢复时依次检查:对象有没有实现 `ISaveableContent`、有没有在 `awake` 里用 `state.addSaveableContent` 注册、`saveState` 返回的数据里有没有漏字段。完整的验证写法(含三档压缩与修改映射)见[可存档内容](./saveable-content.md)的「验证」一节。
最后可以故意把 `dependencies` 里的某个 id 改成一个不存在的名字,重新启动。此时应出现两条现象:控制台报缺失依赖错误(错误码 `77`,带插件 id 与缺失的依赖 id),你的插件没有任何时机输出。改回正确 id 后应恢复正常。这一步能让你亲眼看到「阻断」不是只影响被写错的插件。
- <strong>在 awake 里 await</strong>。`awake` 的返回值会被忽略,写 `async awake` 也不会被等待。异步准备放到 `initialize`。
- <strong>把 dataDeps 写成对象</strong>。它必须是 id 字符串数组;插件对象是三个时机方法收到的参数,不是声明时写的东西。
- <strong>依赖成环</strong>。环上的插件全部被排除,依赖它们的下游也不执行。依赖关系要单向。
- <strong>依赖 id 拼错</strong>。插件不会报「找不到类」,而是被记为缺失依赖并从 `order` 中排除;控制台会给出插件 id 与缺失的 id。
- <strong>用 getDataPlugin 不判空</strong>。它可能返回 `null`,也可能处于你还没等到阶段。
- <strong>把插件 id 当显示名随意改</strong>。别的插件的 `dataDeps` 会立刻失效,存档 id 也不会跟着变。
- <strong>直接改其他系统的内部字段</strong>。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。
## 小结
- 插件把一组对象与它们的生命周期收进一个独立单元,启动时在 `dataPlugins` 里 `new` 一次。
- `dependencies` 声明的是 id 字符串;三个时机方法收到的是解析后的插件对象。
- `dataDeps` 声明的是 id 字符串;三个时机方法收到的是解析后的插件对象。
- 渲染端插件可以依赖数据端插件,数据端插件不能依赖渲染端插件。
- `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。
- 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。
- 插件 id 是对外契约,不要随意改名或复用;它与可存档 id 是两套命名。
- 新增系统优先写成插件;只有必要时才改内置包。
返回[高级 I 导览](./index.md)。

View File

@ -6,7 +6,7 @@
阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../intermediate/code-basics.md)。
## 录像一致性要解决什么
## 什么该记
同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。
@ -18,202 +18,110 @@
两个条件缺一不可:由玩家亲手做出(移动、使用道具、装备、触发事件),并且会改变游戏结果。代码自己跑出来的、只影响画面的,都不算。
问题在于:这条原则好背,难在执行。一个方法到底该不该记,作者常常是「先跑一遍,看回放对不对」——这正是录像一致性检查想替你省掉的试错。
一个方法该不该记,<strong>通常是分析出来的,不是试出来的</strong>。先问三件事:
## 两个装饰器各做什么
- 这个方法能不能被玩家直接触发?
- 它会不会改变游戏状态?
- 这个改变有没有可能影响最终结果?
三个答案都是「是」,就加 `@shouldReplay`。如果你暂时判断不了,最稳妥的做法是:<strong>把所有「修改了游戏状态、且该状态可能导致结果改变」的方法都加上标记</strong>。漏标的代价是录像悄悄错掉;多标的代价只是告警里多一条路径,前者难查得多。
只读查询、纯计算、展示与搜索路径不要标。它们不改变游戏状态,标了只会让告警列表变长,把真正的漏记埋进噪声里。
## 两个装饰器
两个装饰器都写在<strong>类的方法</strong>上方,作用对象是「录像安全检查」,不是录像本身。
| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 |
| ------------------------ | --------------------------------- | ---------------------------------------------------- | -------------- |
| `@shouldReplay(message)` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 |
| `@ignoreReplay(reason)` | 确实不该计入录像的被动 / 延迟路径 | 把<strong>整段</strong>检查标记为忽略 | 不会 |
| 装饰器 | 写在哪 | 做什么 |
| ------------------------ | --------------------------------- | ------------------------------------- |
| `@shouldReplay(message)` | 会改变游戏状态的方法上 | 把「这个方法被调用过」登记成一条路径 |
| `@ignoreReplay(reason)` | 确实不该计入录像的被动 / 延迟路径 | 把<strong>整段</strong>检查标记为忽略 |
### @shouldReplay:登记一条路径
:::warning 录像记录缺失
如果带 `@shouldReplay` 的方法执行了,而这次执行没有产生任何录像记录,控制台会输出一条<strong>「录像记录缺失」</strong>的警告,并列出这次执行经过的标记方法。看到它,说明这条路径该记却没记。检测的内部原理留到 `高级 III · 录像深层原理`。
:::
`@shouldReplay(message)` 把方法标记为「应该计入录像」。它的实际行为比名字朴素:
### @shouldReplay:只登记路径,不写录像
- 收集区间没开启时,它什么也不做,方法与普通方法完全一样;
- 收集区间开启时,它把这次调用登记成一条路径,名字形如 `方法名: message`,然后照常执行方法;
- 它<strong>不写录像</strong>。真正记录仍要靠 `state.replaySystem.record(code, ...params)`,或者走内置的录制入口。
`@shouldReplay(message)` 把方法标记为「应该计入录像」。它<strong>不写录像</strong>:真正记录仍要靠 `state.replaySystem.record(code, ...params)`,或者走内置的录制入口。它做的是让安全检查知道「这条路径执行过」,以便和录像里实际产生的步数对照。
内置的录制入口就在会改状态的方法里,例如[勇士道具](../intermediate/hero-items.md)的 `useItem`:
`message` 是写给人看的说明,用来在警告里指认方法;语言和措辞不影响行为,写清这个方法改变了什么状态即可。
### @ignoreReplay:忽略整段检查
`@ignoreReplay(reason)` 容易被误解成「只忽略这个方法」。实际不是:它把<strong>整段</strong>安全检查标记为忽略,包括同一段里其它正常登记的 `@shouldReplay` 路径。
所以它不能用来「消掉某条告警」。你消掉的是整段检查,真正的漏记会被一起放过。它只适合确实不该计入录像的路径,例如异步内容、延迟回调这类不会立刻改变状态的方法。源码注释也明确写了这层意思,并提醒不要用它规避告警。`reason` 目前不会出现在告警输出里,写它是为了后来的人知道这里为什么可以忽略。
## 在自己的方法上加标记
标记加在<strong>你自己的、会改状态的方法</strong>上。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记:
```ts
const replay = this.state.replaySystem;
replay.array.add(ReplayCode.UseItem, [raw.num]);
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` 声明的是「这段路径执行了,就该有对应的录像步」,而录像步本身要另外写。
装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。
### @ignoreReplay:忽略整段检查,不是忽略一个方法
`@ignoreReplay(reason)` 容易被误解成「只忽略这个方法」。实际不是:它只是把一个全局标记置为 `true`,而这个标记在整段收集区间内都不会被清掉。于是<strong>只要区间内有一次调用走到带 `@ignoreReplay` 的方法,整段区间的核对就会被跳过</strong>——包括同一区间里其它正常登记的 `@shouldReplay` 路径。
这带来两个直接后果:
- 它不能用来「消掉某条告警」。你消掉的是整段检查,真正的漏记会被一起放过;
- 它只适合确实不该计入录像的路径,例如异步内容、延迟回调这类不会立刻改变状态的方法。源码注释也明确写了这层意思,并提醒不要用它规避告警。
`reason` 参数是写给人看的说明,目前不会出现在告警输出里。写它的目的是让后来的人知道这里为什么可以忽略,而不是让它生效。
## 该写在哪
判断标准只有一条:<strong>这个方法是不是「玩家动作能走到、并且真的会改变游戏状态」</strong>。是,就加 `@shouldReplay`;不是,就不加。
内置代码里已经有大量先例,可以照着找感觉:
| 位置 | 方法 |
| -------- | ------------------------------------------------------------------------ |
| Flag | `FlagSystem.setField` / `deleteField`、`FlagCommonField.set` / `add` |
| 勇士道具 | `HeroItems.addItem` / `useItem`、`HeroEquipsStore.add` / `delete` |
| 勇士装备 | `HeroEquipment.equip` / `unequip` |
| 勇士属性 | `HeroAttribute` 的 `set` / `add` / `mul` / `div`,以及修饰器的注册与移除 |
| 勇士位置 | `HeroLocation.setFloor` / `setPos` |
| 地图图层 | `MapLayer.setBlock` / `removeBlock` / `openDoor` / 动态图块相关方法 |
不要加在这三类地方:
- <strong>只读查询</strong>:`getField`、`getBlock`、`getItemState`、`getCategory` 这类方法不改变状态,不可能产生录像步;
- <strong>纯计算 / 试算</strong>:最终属性、临界值、伤害试算、比较器,这些都只是「算一遍看看」,不是游戏状态变化;
- <strong>会被反复调用的展示或搜索路径</strong>:临界搜索、寻路试算、排序比较会被调用很多次,登记它们只会让告警列表变长,把真正的漏记埋进噪声里。
`@ignoreReplay` 的落点更窄:只用于确实不该计入录像的异步 / 被动路径,并且要写清理由。它是「这块我确认过,不该记」,不是「这块我不想看」。
同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。入口方法与被它调用的内部方法可以都标:安全检查会按调用嵌套组织路径,越靠里越接近真正改变状态的位置。但把标记集中在真正改状态的那一层,告警会更短、更好读。
## 能保证什么,不能保证什么
这是本篇最需要记住的一节。检查能做的事很有限,不要把它当成正确性证明。
检查能做的事很有限,不要把它当成正确性证明。
<strong>能保证的</strong>:
- 收集区间内如果<strong>一条新录像步都没有产生</strong>,控制台会告警,并列出这段区间里登记过的 `@shouldReplay` 路径;
- 告警里给出的路径能帮你定位「哪条调用链走了,但没有留下录像步」;
- 路径按调用嵌套组织,越靠里的路径越接近真正该记录的位置。
<strong>能保证的</strong>:带标记的路径执行了却没有留下录像记录时,控制台会告警,并列出这段执行经过的标记方法;路径按调用嵌套组织,能帮你定位是哪条调用链没有记。
<strong>不能保证的</strong>:
| 它不检查 | 说明 |
| -------------- | ------------------------------------------------------------ |
| 逻辑是否正确 | 方法算错了,只要区间里有新录像步,检查照样通过 |
| 逻辑是否正确 | 方法算错了,只要产生了录像记录,检查照样通过 |
| 参数是否正确 | 录像步的参数写错了、写少了,检查不看内容 |
| 顺序是否正确 | 它只比较录像长度,不比较先后 |
| 顺序是否正确 | 它只关心有没有记录,不比较先后 |
| 是否一一对应 | 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步 |
| 是否漏了入口 | 没有标记的方法不会被登记,自然也不会出现在告警里 |
| 是否会自动记录 | `@shouldReplay` 只登记路径,记录仍要 `record` 或内置录制入口 |
最后两条是实践中最容易踩的:<strong>没标记的方法,检查器根本不知道它存在</strong>;而标了标记的方法,也不会因此自动写录像。
## 检测机制与边界
检查由两个函数控制,它们在收集区间的两端各做一半工作。
`beginReplaySafetyCollection(system)` 开始收集:
- 记下当前录像长度 `beforeLength`,作为「区间内有没有新增」的基准;
- 清空上一次登记的路径,把当前收集对象设为根;
- 如果已经在收集中,告警(错误码 `159`)并直接返回。
`endReplaySafetyCollection()` 结束收集并核对,按顺序判断三件事,任一成立就提前返回:
1. 区间内出现过 `@ignoreReplay`(整段忽略);
2. 录像长度比 `beforeLength` 大(区间内确实记录了新录像步);
3. 没有任何登记路径(没有可报告的内容)。
三条都不成立时,才会输出告警(错误码 `161`),列出登记过的路径。告警里同时会给出一条可直接粘进控制台的命令:
```ts
Mota.require('@user/data-common').logReplaySafetyDetail(0);
```
把 `0` 换成告警里给出的编号,就能打印出完整的嵌套路径树。明细只保留最近的一批(源码注释写的是 50 个),更早的编号再查询会告警(错误码 `162`)。
### 边界一:收集区间由谁开启
这是当前必须知道的事实:`beginReplaySafetyCollection` 与 `endReplaySafetyCollection` 在仓库里目前<strong>只有测试在调用</strong>(`packages-user/data-common/src/replay/__test__/func.test.ts`),`logReplaySafetyDetail` 也一样。
所以本篇描述的是<strong>「收集区间开启时」的检测口径</strong>:区间开着,两个装饰器才有意义;区间没开,`@shouldReplay` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「验证」一节里的方式手动开一次区间。生产环境目前也没有自动开启收集区间的开关;如果要在实际游玩中检查,只能在自定义的调试流程或录像入口里,自己把一段操作包在 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 之间。
### 边界二:读档期间录像被禁用
`CoreState.loadState` 在恢复每个可存档对象之前会调用 `replaySystem.disable()`,恢复之后调用 `revert()`,所以读档本身不会被记进录像。读档开始时还会先清掉残留的禁用标记,避免上一次的 `disable` 层数漏回去。
但<strong>执行器不在包裹范围内</strong>:`bindSaveableExecuter` 绑定的 `afterLoad` 在该对象的 `loadState` 之后立即运行,此时录像已经恢复可用,因为它可能确实要做需要记录的行为。这意味着在执行器里操作会影响成绩的状态要格外小心。细节见[可存档内容](./saveable-content.md)。
### 边界三:装饰器只认类的方法
两个装饰器都按「类方法装饰器」实现,只能写在 `class` 的方法上方。写在普通函数、对象属性、箭头函数上都不会生效。
## 给一个内置方法补录像标记
假设要让 `FlagSystem.getOrInsert` 这个会改变状态的入口在收集区间里被登记出来。它位于 `packages-user/data-base/src/flag/system.ts`;同一个文件里的 `setField` 与 `deleteField` 都已经有标记,`getOrInsert` 还没有——它在字段不存在时会新建一条 Flag 字段,同样会改变状态,正好用来观察「加标记 / 去掉标记」的差别。
标记就是方法声明上方的一行装饰器。`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不需要再引入:
```ts
// 加在 FlagSystem.getOrInsert 的上一行;消息写清这个方法改变了什么状态
@shouldReplay('Flag field inserted should be replayed.')
```
若你换到别的文件、编辑器报缺少 `shouldReplay`,再在顶部补这一行 import。消息文本只写给人看,语言不影响行为,写清意思即可。
手动开一次收集区间,就能看到这条路径有没有被登记。这几个函数不在 `state` 上,需要从 `@user/data-common` 引入(只用于调试,平时不需要):
```ts
import {
beginReplaySafetyCollection,
endReplaySafetyCollection
} from '@user/data-common';
beginReplaySafetyCollection(state.replaySystem);
// 调用一个会改状态、但不会产生录像步的方法
state.flags.getOrInsert('replay-check', 1);
endReplaySafetyCollection();
```
区间内没有产生任何新录像步,`getOrInsert` 又带着标记,所以控制台应出现告警(错误码 `161`),列表里能看到 `getOrInsert: ...` 这一条。把 `getOrInsert` 上的标记注释掉再跑一次,告警会<strong>消失</strong>——这不是修好了,而是检查器看不见这个入口了,这也正是「漏标记比漏记录更难查」的原因。两个过程都不会产生录像步:标记只登记路径,真正的记录仍要 `record` 或内置录制入口。
标记只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。做这个练习时不要选一个内部已经调用了带标记方法的方法——那样即使去掉外层标记,里层仍会登记路径,告警不会消失。
## 改内置还是自己写
绝大多数情况不需要新写类:
- 给一个会改状态的入口补标记——直接在已有实现文件里改,例如 `packages-user/data-base/src/flag/`、`hero/`、`map/` 下对应的文件;
- 只有在确实需要新的录像入口(新的指令、新的主动或被动行为)时,才考虑新写类。这已经超出「标记」的范围,属于自定义录像行为(`高级 II · 自定义录像行为`)。
补一行装饰器的成本极低,对照原代码就能看出差异。需要一整套新系统时,优先用插件组织,把录像入口收敛在插件自己的方法上,见[插件系统](./plugin-system.md)。录像格式与沙盒的深层原理留到 `高级 III · 录像深层原理`。
## 常见错误
- <strong>给只读方法或计算器加 `@shouldReplay`</strong>。它们不产生录像步,只会让告警列表变长,把真正的漏记埋掉。
- <strong>用 `@ignoreReplay` 压告警</strong>。它忽略的是整段区间,真正的漏记会一起被放过。
- <strong>以为加了装饰器就会自动记录</strong>。装饰器只登记路径,录像步仍要 `record` 或走内置录制入口。
- <strong>同一个方法写两个标记</strong>。`@shouldReplay` 与 `@ignoreReplay` 同时写含义互相矛盾;一个方法只需要一个标记,装饰器要紧贴方法声明。
- <strong>在异步回调里记录却没处理顺序</strong>。录像步会落在错误的时点,检查也看不出来。
- <strong>在读档过程中记录</strong>。`loadState` 被 `disable` 包裹,记录不会生效;执行器不在这层保护里,反而要更谨慎。
- <strong>把装饰器写在普通函数上</strong>。它只支持类的方法。
- <strong>以为没告警就是没问题</strong>。收集区间没开、或者入口压根没标记,都不会有告警。
## 验证
开一次收集区间,就能看到登记路径:`beginReplaySafetyCollection` → 调用一个带标记的方法 → `endReplaySafetyCollection`。控制台应给出告警(`161`)和路径列表;粘上告警里给出的 `logReplaySafetyDetail(n)` 命令,应打印出完整嵌套路径。
录像一致性最终要靠回放对比:录一段包含目标操作的录像,回放后检查关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。
随后把同一个方法上的标记注释掉再跑一次。告警消失说明检查器「看不见」这个入口,而不是问题解决了——这也是为什么标记必须加在正确的层。
如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。
再让区间内产生一条录像步:在区间内调用一个真正会记录录像的方法(例如走一步、使用一个道具),再结束收集。此时不应告警,因为检查只关心「有没有新录像步」,不关心是哪一条。
参数与顺序需要自己核对,装饰器不会替你检查这些。
参数与顺序需要自己核对。装饰器不会替你检查这些:录一段包含目标操作的录像,回放后对比关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。
## 边界与注意
最后确认读档不产生录像:存档、读档,然后检查录像长度有没有变化。`loadState` 期间的记录被禁用,长度应当不变;若有变化,检查是不是在绑定的执行器里做了会记录的操作。
- <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` 在收集区间内登记路径,`@ignoreReplay` 把整段区间标记为忽略;两者都不写录像。
- 标记写在会改状态的玩家动作入口上,不写在只读、试算、展示与搜索路径上。
- 检查能发现「区间内一条录像步都没有」,但不管逻辑、参数、顺序与一一对应。
- 收集区间目前只有测试在开启;需要时手动 `beginReplaySafetyCollection` / `endReplaySafetyCollection`。
- 该不该记通常靠分析;判断不了时,把所有会改状态、可能影响结果的方法都加上 `@shouldReplay`。
- `@shouldReplay` 只登记路径,`@ignoreReplay` 把整段检查标记为忽略;两者都不写录像。
- 带标记的方法执行了却没有录像记录时,控制台会输出「录像记录缺失」警告。
- 检查不管逻辑、参数、顺序与一一对应,这些要自己核对。
- 读档期间录像被临时禁用,执行器不在保护范围内。
- 需要新的录像入口时再扩展;一整套新系统优先用插件组织。
返回[高级 I 导览](./index.md)。

View File

@ -6,7 +6,7 @@
阅读前需要会基础语法:`class`、`new`、`Map`、`import`。如果你还没写过插件,建议先看[数据插件](../intermediate/data-plugins.md)与[插件系统](./plugin-system.md);可存档内容通常就注册在插件的 `awake` 里。
## 存档一致性:什么该存,什么不该存
## 什么该存,什么不该存
判断一个字段该不该进存档,只看一件事:<strong>读档之后,它还能不能从别的地方算回来</strong>。
@ -19,21 +19,16 @@
| 勇士位置、属性、道具 | 存 | 影响后续所有结算 |
| 地图图块改动、点事件 | 存 | 玩家改造过世界,无法从原始地图推回 |
| Flag 变量 | 存 | 跨事件、跨楼层的持久状态 |
| 敌人当前状态 | 存 | 影响战斗、地图伤害与后续查询 |
| 敌人当前状态 | 存 | 影响战斗与后续查询 |
| 静态图块缓存、坐标索引 | 不存 | 由地图数据派生,随时可以重建 |
| 临时动画、提示、界面状态 | 不存 | 读档后重新产生,或根本不影响游戏结果 |
| 战斗上下文、寻路等 L2 系统 | 不存 | 只操作 Layer 1 数据,读档后按需重新建立 |
漏存和多存会带来两类不同的问题:
漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是<strong>只交出重建所需的最小信息</strong>,`loadState` 的原则是<strong>先清空旧状态,再按存档重建</strong>。缓存不要存,读档后第一次用到时重新生成即可。
- <strong>漏存</strong>:读档后字段回到默认值,或者只恢复了一半。表现可能是玩家位置对了但背包空了,也可能是地图对了但某个开关又关上了。更隐蔽的是录像:数据端状态不一致,回放结果自然对不上。
- <strong>多存</strong>:把缓存或派生数据也写进存档。读档时缓存被旧值覆盖,和刚恢复的真实数据对不上;存档体积还会随缓存膨胀。更麻烦的是升级之后,旧缓存的结构可能已经不适配新代码。
## ISaveableContent
所以 `saveState` 的原则是<strong>只交出重建所需的最小信息</strong>,`loadState` 的原则是<strong>先清空旧状态,再按存档重建</strong>。缓存不要存,读档后第一次用到时重新生成即可;Flag 系统就是这么做的:它只存每个字段的值,`FlagCommonField` 对象本身在读档时重新 `new` 出来。
## ISaveableContent:对象与存档形状
可存档对象要实现 `ISaveableContent<T>`,它只有两个方法:
可存档对象要实现 `ISaveableContent<T>`:
```ts
export const enum SaveCompression {
@ -51,7 +46,7 @@ export interface ISaveableContent<T> {
}
```
这里的 `T` 是这个对象的<strong>存档形状</strong>,不是对象本身的类型。`saveState` 返回它,`loadState` 接收它。`saveState` 的返回值必须是一份深拷贝,接口注释也明确要求经过 `structuredClone`;直接把 `this` 返回去,等于把活对象交给存档系统,之后任何修改都会污染已经保存的数据。
这里的 `T` 是这个对象的<strong>存档形状</strong>,不是对象本身的类型。`saveState` 返回它,`loadState` 接收它。
`loadState` 拿到的是普通数据,不是你的类实例。它要做的是把这份数据装回对象:该清空的清空,该重建的重建,不要假设旧字段还留着。商店管理器就是典型写法:
@ -68,9 +63,18 @@ loadState(state: IShopManagerSave, compression: SaveCompression): void {
它没有把存档里的普通对象直接塞进 `Map`,而是重新 `new ShopState`,再让每个商店自己恢复内部状态。这样 `Map` 里装的仍然是带方法的类实例,而不是一堆失去原型的普通对象。
## structuredClone 约束
## 返回值必须独立
存档数据会经过 `structuredClone`,它只认识一部分类型。写 `saveState` 之前,先确认你要返回的东西在支持范围内。
<strong>存档的顶层不会自动做深拷贝。</strong> `CoreState.saveState` 只是把每个对象 `saveState` 的返回值按 id 放进一个 `Map`,不会再 `structuredClone` 一遍。因此 `saveState` 必须自己返回一个<strong>独立的新对象</strong>:不能返回 `this`,也不能让返回值里的字段与活对象共享引用。否则之后对游戏对象的修改会直接改到已保存的数据上,读档时拿到的就是被污染的值。
```ts
// 错误:把活对象交给存档系统;顶层不会再复制
saveState(): IMySave {
return this;
}
```
返回值还要能被持久化。存档最终写入存储层,而存储层按结构化克隆处理数据,只认识一部分类型:
| 支持 | 说明 |
| --------------------------- | ------------------ |
@ -80,20 +84,11 @@ loadState(state: IShopManagerSave, compression: SaveCompression): void {
| `TypedArray`、`ArrayBuffer` | 复制底层二进制数据 |
| `Date` | 复制时间值 |
不支持的包括函数、`Symbol`、DOM 节点、`WeakMap` 与 `WeakSet`。遇到函数这类值,`structuredClone` 会直接抛错,而不是悄悄跳过。
不支持的包括函数、`Symbol`、DOM 节点、`WeakMap` 与 `WeakSet`。遇到函数这类值,结构化克隆会直接抛错,而不是悄悄跳过。
还有一条更隐蔽的限制:<strong>类实例会丢失原型与方法</strong>。`structuredClone` 只复制实例自身的可枚举属性,复制结果是一个普通对象。所以下面这种写法是错的:
还有一条更隐蔽的限制:<strong>类实例会丢失原型与方法</strong>。结构化克隆只复制实例自身的可枚举属性,复制结果是一个普通对象。所以 `saveState` 要返回纯数据,`loadState` 里再重新构造类实例。
```ts
// 错误:类实例的方法不会跟着进存档
saveState(): IMySave {
return this;
}
```
读档后你拿到的不是 `MySaveable`,而是一个 `{ values: ... }` 形状的普通对象,`this.add`、`this.get` 都不存在。正确做法是在 `saveState` 里主动取出数据,在 `loadState` 里重新构造类实例。
嵌套对象会被逐层克隆,得到的是与原对象断开的新图。也正因如此,<strong>不要把活对象引用塞进存档数据</strong>:读档后它只是另一份副本,不再和原对象共享身份。跨对象引用应该存 id 或键,具体做法见后文。
深拷贝有成本。小型状态直接用 `structuredClone` 最省事;大存档整棵复制会明显变慢,这时可以手工构造一个新对象,只复制重建必需的字段。手工构造时,数组、`Map` 这类容器也要新建一份,不能把原容器直接挂上去。目的不是「一定要调用 `structuredClone`」,而是返回值不能与活对象共享引用。
## 注册可存档对象
@ -110,7 +105,7 @@ state.addSaveableContent('@plugin/my-store', this.store);
需要按 id 取回对象时,用 `state.getSaveableContent<T>(id)`。它返回 `ISaveableContent<T> | null`,没有这个 id 时返回 `null`。
插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适,注册顺序也最早、最可控。`initialize` / `afterLoad` 也能调用 `addSaveableContent`(接口本身不限制),但会排到已有对象之后,读档顺序也随之后移;只有对象必须等加载内容才能构造时才这样做,并保证注册发生在任何 `saveState` / `loadState` 调用之前。内置内容也是同样的注册方式,主插件在 `awake` 里注册了五项:
插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适,注册顺序也最早、最可控。`initialize` / `afterLoad` 也能调用 `addSaveableContent`,但会排到已有对象之后,读档顺序也随之后移;只有对象必须等加载内容才能构造时才这样做。内置内容也是同样的注册方式,主插件在 `awake` 里注册了五项:
| 存档 id | 对应对象 |
| ---------------- | -------------------- |
@ -124,7 +119,7 @@ state.addSaveableContent('@plugin/my-store', this.store);
## 三级压缩
`SaveCompression` 有三档,含义写在枚举注释里:
`SaveCompression` 有三档:
| 级别 | 含义 | 典型用途 |
| ----------------- | -------------------------------- | ------------------------ |
@ -142,12 +137,12 @@ state.addSaveableContent('@plugin/my-store', this.store);
参考数组来自加载时的原始地图数据。低压缩和高压缩都假设「读档时还能拿到同一份参考」,所以它们只记录差异。这也是压缩能生效的前提:<strong>压缩级别只改变数据怎么存,不能改变游戏结果</strong>。
自定义对象可以三档用同一套实现,例如都返回 `structuredClone` 后的完整数据。这没有问题,但必须满足两个条件:
自定义对象可以三档用同一套实现,例如都返回独立构造的完整数据。这没有问题,但必须满足两个条件:
1. 三档都能被自己的 `loadState` 正确读回,不能只有 `NoCompression` 能读;
2. 任何一档都不能改变语义,读档后的游戏状态必须一致。
如果以后要实现真正的差异压缩,参考数组从哪来、差异怎么合并,属于存档格式与压缩算法的细节,见 `高级 II · 存档格式演进` 与 `高级 II · 压缩算法细节`。
真正的差异压缩涉及存档格式与压缩算法,属于 `高级 II · 存档格式演进` 与 `高级 II · 压缩算法细节`。
## 存档顺序与执行器
@ -158,8 +153,6 @@ state.addSaveableContent('@plugin/my-store', this.store);
- 存档里缺少某个已注册 id 的数据:跳过该对象,控制台告警(错误码 `177`),它的执行器也不会运行;
- 存档里多出某个未注册 id 的数据:读档结束后统一告警(错误码 `178`),列出多余的 id。
前者常见于旧存档或改过 id,后者常见于删掉了某个可存档对象但旧存档还留着数据。
如果读档后还需要做一步收尾,用 `state.bindSaveableExecuter(content 或 id, executor)` 绑定执行器。执行器实现 `ISaveableExecutor<T>`:
```ts
@ -174,7 +167,36 @@ export interface ISaveableExecutor<T> {
关键在时机:<strong>执行器在该对象的 `loadState` 返回后立即运行</strong>,不是等所有对象都读完之后统一运行。此时它前面的对象已经恢复完毕,后面的对象还没开始。这个顺序直接决定了跨对象引用该怎么解析。
还有一处与录像有关的细节:读档开始时,主对象会先清掉残留的录像禁用标记,再逐个恢复对象。每个对象的 `loadState` 都被 `replaySystem.disable()` 与 `revert()` 包裹,读档本身不会被记进录像;但执行器<strong>不在包裹范围内</strong>,因为它可能确实要做需要记录的行为。在执行器里操作会影响成绩的状态时要谨慎,录像一致性的完整规则见[录像一致性](./replay-consistency.md)。
读档开始时,主对象会先清掉残留的录像禁用标记,再逐个恢复对象。每个对象的 `loadState` 都被 `replaySystem.disable()` 与 `revert()` 包裹,读档本身不会被记进录像;但执行器<strong>不在包裹范围内</strong>,因为它可能确实要做需要记录的行为。
### 示例:读档后切换到目标楼层
假设你的读档流程需要在勇士恢复之后,再走一遍完整的楼层切换(触发切换钩子等),可以把这个收尾交给执行器:
```ts
import { IHeroAttr } from '@user/data-common';
import { IHeroStateSave, IStateBase } from '@user/data-base';
import { 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
state.bindSaveableExecuter('@system/hero', new HeroFloorExecutor());
```
执行器接口的 `afterLoad` 是同步的,所以它只能「发起」异步操作;需要等待完成的流程要放在你自己的读档调用处。
### afterLoad 的两种含义
@ -185,13 +207,11 @@ export interface ISaveableExecutor<T> {
| 插件 `afterLoad(state, deps)` | 初次加载流程末尾,每个插件只运行一次 | 读配置、创建依赖加载内容的初始对象 |
| 执行器 `afterLoad(data, state)` | 每次读档,在该对象 `loadState` 之后立即 | 解析跨对象引用、做读档收尾 |
插件 `afterLoad` 只在<strong>初次加载</strong>后运行。玩家读档时,加载流程不会重跑,插件 `afterLoad` 也不会再执行。所以它不能替代读档执行器:需要「每次读档都做」的事,必须绑执行器。
反过来,执行器也不适合做「初次加载」的初始化。它只在读档时运行,首次进入游戏时没有存档可读,自然不会触发。
插件 `afterLoad` 只在<strong>初次加载</strong>后运行。玩家读档时,加载流程不会重跑,插件 `afterLoad` 也不会再执行。所以它不能替代读档执行器:需要「每次读档都做」的事,必须绑执行器。反过来,执行器也不适合做「初次加载」的初始化,它只在读档时运行。
## 跨对象引用
存档里不要直接存另一个对象的引用。原因在 `structuredClone`:引用会被复制成新对象,读档后原对象和新对象是两份不同的东西,身份已经丢了;如果那个对象是类实例,方法也一并丢掉。
存档里不要直接存另一个对象的引用。返回值必须独立:直接放活对象,存档就会与游戏对象共享同一份引用,之后互相污染;即使存储层复制了它,读档后身份也已经丢了,类实例的方法也会一并丢掉。
正确做法是<strong>存 id 或键,读档时再解析</strong>。常见的键有楼层 `floorId`、商店 id、可存档 id,以及你自己维护的稳定 `uid`。例如一个任务系统要记住「目标商店」:
@ -231,9 +251,7 @@ export class QuestSaveable implements ISaveableContent<IQuestSave> {
| 执行器延后解析 | 在引用者的执行器里解析;执行器运行时,注册在它之前的对象都已恢复 | 被引用者注册在前,但引用者需要收尾 |
| 惰性解析 | 存档只存 id,第一次真正用到时再按 id 查 | 被引用者可能后注册、可能不存在 |
选择时看注册顺序:执行器只保证「该对象自己的 `loadState` 已完成」,不保证后面的对象已完成。如果你要引用的对象注册在引用者之后,执行器里拿到的可能还是旧状态,这时应该改成惰性解析,或者调整注册顺序,让被引用者先注册。
「拥有者注册在被引用者之后」是最省事的做法:先注册商店管理器(商店插件在 `awake` 里用 `@plugin/shop` 注册),再注册引用商店的任务系统;这样任务系统的执行器运行时,商店已经恢复完毕。依赖关系不固定时,才需要惰性解析兜底。
执行器只保证「该对象自己的 `loadState` 已完成」,不保证后面的对象已完成。如果你要引用的对象注册在引用者之后,执行器里拿到的可能还是旧状态,这时应该改成惰性解析,或者调整注册顺序,让被引用者先注册。
引用者自己的注册与绑定如下,顺序不能反:
@ -262,12 +280,12 @@ export class QuestSaveExecutor implements ISaveableExecutor<IQuestSave> {
## 示例:一个可存档对象
假设你要让一组自定义计数跟着存档走。先写出可存档对象:它实现 `ISaveableContent<T>`,`T` 是存档形状。下面这个类可以放在插件目录里(例如 `src/data/plugin/my-plugin/state.ts`),不需要为它单独新建一套目录:
假设你要让一组自定义计数跟着存档走。先写出可存档对象:它实现 `ISaveableContent<T>`,`T` 是存档形状。下面这个类可以放在插件目录里(例如 `src/data/plugin/my-plugin/state.ts`):
```ts
import { ISaveableContent } from '@user/data-common';
// 存档形状:只放重建必需的数据,字段类型必须能通过 structuredClone
// 存档形状:只放重建必需的数据,字段类型必须能被结构化克隆
export interface IMySave {
readonly values: Map<string, number>;
}
@ -287,7 +305,7 @@ export class MySaveable implements ISaveableContent<IMySave> {
}
saveState(): IMySave {
// 必须交出深拷贝,不能返回 this,也不能返回带方法的类实例
// 返回独立的新对象,不能返回 this,也不能与 this.values 共享引用
return { values: structuredClone(this.values) };
}
@ -305,16 +323,16 @@ export class MySaveable implements ISaveableContent<IMySave> {
压缩参数可以按需省略:`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`,TypeScript 允许实现时少写参数,调用方仍会按接口传两个实参,多出的那个被忽略;需要区分压缩级别时,再把 `compression: SaveCompression` 加回来。
`saveState` 里不要修改对象状态;`loadState` 不要假设旧数据还在。这两条是这一节里最容易出错的地方。
`saveState` 里不要修改对象状态;`loadState` 不要假设旧数据还在。
## 示例:把可存档对象接到插件里
可存档对象本身不会自动进入存档,还要在插件里持有并注册。下面的代码写在你插件的 `plugin.ts` 里(改已有文件,文件顶部通常已经有 `IDataPluginDefine` / `ICoreState`;若报缺少 `MySaveable`,再补对应 `import`):
可存档对象本身不会自动进入存档,还要在插件里持有并注册。下面的代码写在你插件的 `plugin.ts` 里:
```ts
export class MyPlugin implements IDataPluginDefine {
readonly id = 'my-plugin';
readonly dependencies: string[] = [];
readonly dataDeps: string[] = [];
// 持有可存档对象,供插件其他方法使用
store!: MySaveable;
@ -333,32 +351,9 @@ export class MyPlugin implements IDataPluginDefine {
`store` 与 `MySaveable` 换成你自己的类;对象构造需要 `state` 时,也在这里传入。注册优先放在 `awake`:对象必须等加载内容才能构造时,也可以放到插件 `afterLoad`,但注册顺序会排在已有对象之后。无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。
启动后把某个键改到一个可观察的值,存档;再改一次,然后读档。读档后应回到存档时的值。如果没恢复,检查 `loadState` 有没有先 `clear()`;如果读档后调用方法报「不是函数」,检查是不是把类实例直接塞进了存档。
## 改内置还是写成插件
先看你要改的东西属于哪一类:
- 只是给现有系统补存档,例如某个内置对象漏了一个字段——<strong>直接改它的 `saveState` 与 `loadState`</strong>,不要在外面再包一层;
- 要新增一组独立功能,有自己的状态、自己的管理器、自己的生命周期——做成插件,并给插件里的对象实现 `ISaveableContent`。
新增系统优先走插件:可存档对象跟着插件留在 `src`,升级内置包时不必对照差异。只有必须修改内置对象的存档行为时,才动 `packages` / `packages-user` 里的实现。
## 常见错误
- <strong>saveState 返回 this</strong>。活对象被交给存档系统,后续修改会污染已保存的数据;读档后也不是原来的类实例。
- <strong>返回带方法的类实例</strong>。`structuredClone` 会丢掉原型与方法,读档后调用方法直接报错。
- <strong>把缓存或索引也存进去</strong>。读档时旧缓存覆盖新算出的结果,状态对不上;这类数据应该重建。
- <strong>漏掉某个字段</strong>。读档后该字段回到默认值,表现为「只恢复了一半」。
- <strong>重复注册同一个 id</strong>。后一个被忽略并告警(`112`),你以为生效的对象其实没进存档。
- <strong>执行器里访问尚未恢复的对象</strong>。执行器只保证当前对象已恢复;注册在后面的对象还没读。
- <strong>把插件 afterLoad 当读档钩子</strong>。它只在初次加载后运行一次,玩家读档时不会再触发。
- <strong>在 saveState 里改状态</strong>。存档是只读快照,`saveState` 不应该有副作用;需要整理数据时,先复制再整理。
- <strong>跨对象引用直接存对象</strong>。`structuredClone` 会复制成新对象,读档后身份丢失,两个系统各拿一份。
## 验证
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面的代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行,文件顶部补上两个 `import`:
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面的代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行:
```ts
// [验证] 存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类
@ -376,7 +371,7 @@ state.loadState(snapshot, SaveCompression.NoCompression);
console.log(plugin.store.getValue('count')); // 应为 1
```
读档后应回到存档时的值。同时确认<strong>没有存</strong>的缓存被重新生成,而不是保留了读档前的旧值。
读档后应回到存档时的值。如果读到的是修改后的值,说明 `saveState` 返回了与活对象共享的引用,而不是独立副本。
对同一个对象把三档压缩各跑一次,可以确认三种策略读回来的结果一致:
@ -417,20 +412,31 @@ edited.delete('@plugin/my-store');
state.loadState(edited, SaveCompression.NoCompression);
```
读档时控制台应出现缺键告警(`177`),并跳过该对象,而不是静默通过。存档里多出未注册的 id 时,应出现多键告警(`178`)。这两条能帮你区分「没存进去」和「读的时候没对上」。
读档时控制台应出现缺键告警(`177`),并跳过该对象;存档里多出未注册的 id 时,应出现多键告警(`178`)。这两条能帮你区分「没存进去」和「读的时候没对上」。
跨对象引用的检查需要构造一个引用其他对象的场景:读档后确认引用仍指向正确的对象,而不是 `null` 或另一份副本。若拿到的是旧状态,检查被引用对象的注册顺序,或改成执行器延后解析。执行器时机的检查则是在执行器里打印一条日志:它应在对应对象的 `loadState` 之后立即出现,而不是等所有对象读完才统一出现;如果顺序不对,说明你把执行器当成了全局读档完成钩子。
跨对象引用的检查需要构造一个引用其他对象的场景:读档后确认引用仍指向正确的对象,而不是 `null` 或另一份副本。若拿到的是旧状态,检查被引用对象的注册顺序,或改成执行器延后解析。
## 边界与注意
- <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` 后的数据,`loadState` 要清空并重建。
- 存档数据必须是 structured-cloneable 的;类实例会丢原型与方法,读档时要自己重建。
- `ISaveableContent<T>` 的 `T` 是存档形状;`saveState` 必须返回独立于活对象的数据,顶层不会替你 `structuredClone`。
- 存档数据必须能被结构化克隆;类实例会丢原型与方法,读档时要自己重建。
- 用 `addSaveableContent` 注册,id 唯一;重复 id 后一个被忽略(`112`)。
- 三档压缩只改变存储方式,不改变结果;每一档都必须能被自己的 `loadState` 读回。
- 存档与读档按注册顺序执行;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。
- 插件 `afterLoad` 只在初次加载后运行一次,不能替代读档执行器。
- 跨对象引用存 id 或键,读档时按顺序或延迟解析,不要存活对象引用。
- 新增系统优先通过插件承载可存档对象;只有必要时才改内置的存档实现。
返回[高级 I 导览](./index.md)。