mirror of
https://github.com/motajs/template.git
synced 2026-10-22 07:10:17 +08:00
docs(10-19): 修复高级 I 读者阻断问题(第1轮)
This commit is contained in:
parent
1a22d25e58
commit
b8afe95e1b
@ -40,3 +40,69 @@
|
||||
|
||||
- `cmd /c "pnpm exec prettier --write docs/advanced/*.md"`:七篇全部 unchanged,已符合格式。
|
||||
- `cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"`:`build complete`,无 dead link。
|
||||
|
||||
---
|
||||
|
||||
## 读者审阅 · 第 1 轮(Advanced I persona)
|
||||
|
||||
**人格提示词(逐字):**
|
||||
> 假设你是一位对代码有一定了解、了解基本语法(变量、函数、类、import 等),但代码能力一般、不熟悉引擎内部架构的游戏作者。你已经读完中级教程,正在阅读高级教程 I,想判断自己能不能照着文档完成插件等扩展。
|
||||
|
||||
**明确「未复用中级人格」**:本次不使用中级教程的「不懂代码的游戏作者」人格。
|
||||
|
||||
### 阻断性(5)
|
||||
|
||||
| # | 问题 | 定位 |
|
||||
|---|------|------|
|
||||
| B-01 | 插件注册示例写成 `[new MyPlugin()]`,照抄会覆盖原有 `DataShopPlugin`;且新建插件时 `list.ts` 顶部并没有该插件的 import | `plugin-system.md`「注册到 dataPlugins」 |
|
||||
| B-02 | `ShopManager.loadState(state: IShopManagerSave): void` 方法体用了 `compression`,照抄会报「未定义」,示例本身不能编译 | `saveable-content.md`「ISaveableContent:对象与存档形状」 |
|
||||
| B-03 | 示例注册 `@plugin/my-store`,验证却查 `@plugin/example-counter`;照做会拿到 `null`,误以为注册失败 | `saveable-content.md`「示例:把可存档对象接到插件里」「怎么验证」第三项 |
|
||||
| B-04 | 反复要求「存档/读档」「三档压缩各跑一次」「删掉存档里的一个键」,但没给最外层触发存档/读档的入口,也没说怎样取得并修改存档 `Map`,无法按文档完成验证 | `saveable-content.md`「怎么验证」;`plugin-system.md`、`declarative-vs-imperative.md` 对应验证段 |
|
||||
| B-05 | 指定改 `FlagSystem.setField`,但同段又说该文件的 `setField` / `deleteField` 都已有标记;照「复制装饰器一行」会变成两个标记,注释掉后仍剩一个,告警不会按文档消失 | `replay-consistency.md`「给一个内置方法补录像标记」 |
|
||||
|
||||
### 非阻断(摘要)
|
||||
|
||||
- 插件模板里 `mySystem!: MySystem` / `new MySystem(state)` 未给定义;`[需改]` 可补救。
|
||||
- 注册位置表述不一致(「更晚阶段也可以」vs「必须在 awake」)。
|
||||
- 模板里的 `saveState` / `loadState` 省略了 `compression`,未说明是否合法。
|
||||
- 跨对象引用示例只给了 `@plugin/example-quest`,没有对应注册与类定义。
|
||||
- 装饰器表格写成 `@shouldReplay()` / `@ignoreReplay()`,实际需要 message/reason。
|
||||
- 任务叫「加录像标记」,但装饰器不写录像,读者易误解。
|
||||
- 收集区间目前只有测试调用;正文未说明生产环境如何使用该检查。
|
||||
- 钩子示例默认 `afterLoad` 时 `state.maps.getMap('floor-1')` 已存在,可能静默跳过。
|
||||
- 「图层移除会卸载内部转发器」表述不准确:自己 `addHook` 的实例仍需手动 `unload()`。
|
||||
- `declarative-vs-imperative.md` 的 `IDamageCalculator` 未提 `getCriticalLimit`;验证里 `view` 未定义。
|
||||
- `data-stores.md`:`tileStore.num(...)` 可能返回 `undefined` 未判空;`afterLoad` 登记后未说明何时 `fromRaw` / `addLayer`;`IItemRawData` 的 `effect`/`equip` 并非二选一;`EnemyStore` 缺可操作入口。
|
||||
- 多处模板/示例省略 import,对「新建文件」的读者不能直接运行。
|
||||
|
||||
---
|
||||
|
||||
## 第 1 轮修复记录(2026-10-20)
|
||||
|
||||
**范围:** 仅 `docs/advanced/` 六篇;未改 `config.ts`、生产代码与渲染端内容。
|
||||
|
||||
### 阻断性问题
|
||||
|
||||
| # | 修复 | 落点 |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| B-01 | 注册示例改为保留 `DataShopPlugin` 并追加 `new MyPlugin()`;明确新建插件文件必须补 `import`,只有改已有插件才复用原有 import | `plugin-system.md`「注册到 dataPlugins」 |
|
||||
| B-02 | `ShopManager.loadState` 补上 `compression: SaveCompression`,与 `ISaveableContent` 及源码一致 | `saveable-content.md`「ISaveableContent:对象与存档形状」 |
|
||||
| B-03 | 示例注册、验证统一使用 `@plugin/my-store`;模板补 `getValue` 供验证 | `saveable-content.md` 注册 / 示例 / 怎么验证 |
|
||||
| B-04 | 三个验证段补上 `state.saveState` / `state.loadState` 最外层入口;给出存档 → 改 → 读档、三档压缩、删除映射键的可复制写法 | `saveable-content.md`、`plugin-system.md`、`declarative-vs-imperative.md` |
|
||||
| B-05 | 练习对象从已有标记的 `setField` 改为未标记的 `getOrInsert`;验证改为调用 `getOrInsert`,去掉标记后告警确实消失 | `replay-consistency.md` 六步实操 |
|
||||
|
||||
### 高价值非阻断
|
||||
|
||||
- `plugin-system.md`:说明 `MySystem` 需自行定义或连同字段一起删除;模板标注为新建文件并保留 `import`;验证段给出可取回插件对象的完整写法。
|
||||
- `saveable-content.md`:统一 `awake` / `afterLoad` 注册口径;说明省略 `compression` 的 TypeScript 兼容性;跨对象引用补 `QuestSaveable` 类、`@plugin/shop` 注册位置与执行器断言写法。
|
||||
- `replay-consistency.md`:装饰器表改为 `@shouldReplay(message)` / `@ignoreReplay(reason)`;补生产环境无自动收集开关的说明;再次强调标记不产生录像步。
|
||||
- `declarative-vs-imperative.md`:补 `getCriticalLimit`;验证片段改用 `getEnemyByLoc` 取得 `view` 并说明坐标;补存档 / 读档入口。
|
||||
- `data-stores.md`:`tileStore.num` 验证补 `undefined` 判断;明确 `effect` / `equip` 均为必填;补 `afterLoad` 登记后 `fromRaw` / `addLayer` 的时机;把 `EnemyStore` 改为可操作的自建注册表建议。
|
||||
- `hooks-in-depth.md`:说明 `afterLoad` 时 `getMap('floor-1')` 可能为 `null`,不可静默跳过;修正「图层移除」只卸载内部转发器、不清理自挂钩子的表述;补新文件 `import` 说明。
|
||||
|
||||
### 构建
|
||||
|
||||
- `cmd /c "pnpm exec prettier --write docs/advanced/*.md"`:通过。
|
||||
- `cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"`:`build complete`,无 dead link。
|
||||
|
||||
|
||||
|
||||
@ -57,7 +57,7 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
|
||||
|
||||
### itemStore:道具定义
|
||||
|
||||
以<strong>道具图块数字</strong>为键,存 `IItemRawData`:`num`、`category`(`ItemCategory`)、`name`、`text`、`hideInToolbox`、`effect`(非装备类道具的效果对象)、`equip`(装备类道具的数值与事件)。
|
||||
以<strong>道具图块数字</strong>为键,存 `IItemRawData`:`num`、`category`(`ItemCategory`)、`name`、`text`、`hideInToolbox`、`effect`(使用 / 拾取效果)、`equip`(装备数值与事件)。`effect` 与 `equip` <strong>都是必填字段</strong>,不是二选一:非装备类除了 `effect`,也要给出一个 `equip`(通常由内容数据填默认值);装备类除了 `equip`,同样要提供 `effect`(可以是空实现)。只写其中一个过不了类型检查。
|
||||
|
||||
它在构造时接收 `tileStore`,所以 `getData(token)` 同时接受数字与 id,内部先用 `tileStore.num(token)` 换算。
|
||||
|
||||
@ -150,16 +150,18 @@ const raw = state.tileStore.getData(num);
|
||||
|
||||
`data-common` 里还有一个 `EnemyStore`(`IEnemyStore`,`addEnemy` / `getEnemy`),存怪物原始数据:`num`、`attribute`、`special`。
|
||||
|
||||
但它<strong>不在 `IDataCommon` 上</strong>:主对象没有把 `enemyStore` 暴露成字段。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。本篇只讲 `IDataCommon` 上的四个 Store;需要怪物原始数据时,先确认当前版本把它接在了哪里,再决定从哪读。
|
||||
但它<strong>不在 `IDataCommon` 上</strong>:主对象没有把 `enemyStore` 暴露成字段,`@user/data-common` 的公开入口也没有导出 `EnemyStore` 类;仓库里目前只有测试直接 `new EnemyStore(state.tileStore)` 来构造它。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。如果你确实需要一张怪物原始数据表,最稳妥的做法是在自己的插件里维护一个同形状的 `Map` 注册表(`addEnemy` / `getEnemy` 的语义可照 `IEnemyStore`),把改动留在自己的代码里。
|
||||
|
||||
## 模板:登记一条原始定义
|
||||
|
||||
Store 的注册没有「一个空类 + 需改点」那种逐对象模板,原因和别的机制不同:插件、可存档对象、光环这些机制都要你实现一个接口,所以能给出骨架;`addTile` / `addItem` / `addMap` / `addEvent` 不是这样——它们是<strong>逐条登记一条原始定义</strong>,你不需要实现任何方法,只需要把一条条定义交给它。
|
||||
|
||||
所以这里的骨架是「一条定义长什么样」。下面以图块为例,复制后按 `[需改]` 修改:
|
||||
所以这里的骨架是「一条定义长什么样」。下面以图块为例,复制后按 `[需改]` 修改。新建文件时把 `import` 放在顶部,把 `state.tileStore.addTile(...)` 这段调用放进插件的 `awake` / `afterLoad` 里:
|
||||
|
||||
```ts
|
||||
// [模板] 登记一条图块定义:复制后按 [需改] 修改
|
||||
import { TileType } from '@user/data-common';
|
||||
|
||||
state.tileStore.addTile({
|
||||
// [需改] 图块数字与 id 必须各自唯一
|
||||
num: 10001,
|
||||
@ -189,7 +191,7 @@ state.tileStore.addTile({
|
||||
|
||||
<strong>注意</strong>:`addEvent` 的签名与另外三个不同,它的第一个参数是事件 id,第二个参数才是事件实例。
|
||||
|
||||
顶部通常已有 `TileType` 这类名字的 `import`;若报缺少某个名字,再在顶部补对应的 `import`。
|
||||
上面这段按新建文件写,`TileType` 从 `@user/data-common` 引入;如果放进已有文件,顶部可能已经引入过,不要重复粘。
|
||||
|
||||
## 把一批自定义内容注册进 Store
|
||||
|
||||
@ -213,6 +215,8 @@ awake(state: ICoreState): void {
|
||||
|
||||
<strong>注意</strong>:不要在玩家动作、事件回调或每帧里登记;同一个 `num` 重复登记会告警并覆盖,等于把登记做成了「碰运气」。
|
||||
|
||||
在 `afterLoad` 里登记还要多确认一步:`mapStore.addMap` 只是把原始数据放进注册表,<strong>不会自动创建运行时楼层</strong>,需要时自己调用 `state.maps.fromRaw(raw)`。`addTile` 也只影响之后构造的图层;如果目标图层已经由 `fromRaw` 建好,新图块不会自动补进去,必须在构造图层之前完成登记,或自己重新构造楼层 / 图层(`fromRaw` / `addLayer`)。
|
||||
|
||||
## 改内置还是自己写
|
||||
|
||||
- 只是补几条内容定义——在已有的加载 / 注册位置加条目,不要为一批原始数据新造 Store,也不要扩展 `IDataCommon`;
|
||||
@ -236,9 +240,12 @@ awake(state: ICoreState): void {
|
||||
<strong>第一项:读回定义</strong>。
|
||||
|
||||
```ts
|
||||
// [验证] num 可能为 undefined:id 没登记时先停下来
|
||||
const num = state.tileStore.num('my-custom-tile');
|
||||
const raw = state.tileStore.getData(num);
|
||||
console.log(raw, state.tileStore.getType(num));
|
||||
if (num !== undefined) {
|
||||
const raw = state.tileStore.getData(num);
|
||||
console.log(raw, state.tileStore.getType(num));
|
||||
}
|
||||
```
|
||||
|
||||
`raw` 应为对象而不是 `null`,`getType(num)` 应等于你登记的类型。道具、地图、事件同理:`itemStore.getData(token)`、`mapStore.getMap(floorId)`、`eventStore.getEvent(id)` 都应查得到。
|
||||
|
||||
@ -86,7 +86,7 @@ hero.hp = hero.hp + 50;
|
||||
|
||||
### 伤害计算器
|
||||
|
||||
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。为了实现「把这组属性换算成勇士掉多少血、要打几回合」,我们需要实现 `IDamageCalculator` 接口,核心方法是 `calculate(handler)`,返回 `{ damage, turn }`。内置实现是 `src/data/enemy/calculator.ts` 里的 `MainDamageCalculator`,它在 `initializeEnemy` 中通过 `useCalculator(new MainDamageCalculator())` 注册。
|
||||
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。为了实现「把这组属性换算成勇士掉多少血、要打几回合」,我们需要实现 `IDamageCalculator` 接口,核心方法是 `calculate(handler)`,返回 `{ damage, turn }`。接口还有 `getCriticalLimit(handler, attribute)`,供临界值计算使用;一般不需要自己实现,改公式时按 `MainDamageCalculator` 的写法保留它即可。内置实现是 `src/data/enemy/calculator.ts` 里的 `MainDamageCalculator`,它在 `initializeEnemy` 中通过 `useCalculator(new MainDamageCalculator())` 注册。
|
||||
|
||||
<strong>引擎在哪个时机执行</strong>:任何需要伤害数值的地方。显示伤害预览、计算临界值、真正结算战斗,都会调用计算器;同一场战斗里,它可能被反复调用很多次。
|
||||
|
||||
@ -245,6 +245,9 @@ calculate(handler) {
|
||||
|
||||
```ts
|
||||
// [验证] 同一个 handler 连续计算两次,结果应完全一致
|
||||
const view = state.enemyContext.getEnemyByLoc(0, 0);
|
||||
if (!view) return;
|
||||
|
||||
const system = state.enemyContext.getDamageSystem();
|
||||
const info = system?.getDamageInfo(view);
|
||||
|
||||
@ -255,10 +258,24 @@ if (system && info) {
|
||||
}
|
||||
```
|
||||
|
||||
如果两次结果不同,或者第二次算完之后状态多出了东西,说明计算器里有副作用,回到 `calculate` 里检查有没有写 flag、删记录或改对象。
|
||||
把 `(0, 0)` 换成场上有怪物的坐标;那里没有怪物时,`getEnemyByLoc` 会返回 `null`。如果两次结果不同,或者第二次算完之后状态多出了东西,说明计算器里有副作用,回到 `calculate` 里检查有没有写 flag、删记录或改对象。
|
||||
|
||||
<strong>第三项:存档后改状态,再读档,确认恢复</strong>。
|
||||
|
||||
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,`state.loadState(snapshot, compression)` 用同一份映射恢复。最外层的调用就是这样一对:
|
||||
|
||||
```ts
|
||||
// [验证] 存档 → 改状态 → 读档
|
||||
import { SaveCompression } from '@user/data-common';
|
||||
|
||||
state.flags.setFieldValue('demo', 1);
|
||||
const snapshot = state.saveState(SaveCompression.NoCompression);
|
||||
|
||||
state.flags.setFieldValue('demo', 2);
|
||||
state.loadState(snapshot, SaveCompression.NoCompression);
|
||||
console.log(state.flags.getFieldValue('demo')); // 应为 1
|
||||
```
|
||||
|
||||
先存档,然后修改你声明的状态(例如改一个 flag、移动一次怪物、使用一个道具),再读档。读档后应该回到存档时的状态。如果某个字段没有恢复,检查它所属的对象有没有实现 `ISaveableContent`、有没有通过 `state.addSaveableContent` 注册,以及 `saveState` 返回的对象里有没有漏掉这个字段。
|
||||
|
||||
## 小结
|
||||
|
||||
@ -240,7 +240,9 @@ layer.addHook(hook);
|
||||
|
||||
### 图层移除会卸载内部转发器
|
||||
|
||||
`GameMap.removeLayer(layer)` 会移除图层,并卸载 `addLayer` 时为它创建的内部 `StateMapLayerHook`。因此图层移除后,地图级钩子不会再收到这个图层的 resize。你自己挂在图层上的钩子会随图层对象一起失去作用;如果还持有控制器,也可以显式 `unload()`。
|
||||
`GameMap.removeLayer(layer)` 会移除图层,并卸载 `addLayer` 时为它创建的内部 `StateMapLayerHook`。因此图层移除后,地图级钩子不会再收到这个图层的 resize。
|
||||
|
||||
但它<strong>不会清理你自己挂在图层上的钩子</strong>:图层自身的钩子列表仍然保留,也不会调用你钩子的 `destroy`。如果你还持有这个图层对象的引用,直接调用它的方法仍会触发那些钩子;要释放定时器、外部监听器等资源,需要自己保存 `addHook` 返回的控制器并显式 `controller.unload()`。
|
||||
|
||||
## 异步钩子
|
||||
|
||||
@ -279,6 +281,8 @@ layer.addHook(hook);
|
||||
|
||||
<strong>改哪里</strong>:在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中;下面的片段省略了外层函数或方法,`return` 需要放在函数体内。
|
||||
|
||||
注意:`afterLoad` 时 `floor-1` 未必已经由加载流程构造出来(取决于你的内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`。片段里的 `if (!map) return` 只是让代码能编译;实际使用时不要这样静默跳过——至少用 `logger.warn` 或 `emitTip` 报出来,或者把注册推迟到楼层创建之后再执行。
|
||||
|
||||
<strong>复制什么</strong>:
|
||||
|
||||
```ts
|
||||
@ -304,6 +308,8 @@ const hook = new GroundBlockHooks(state, layer);
|
||||
const controller = layer.addHook(hook);
|
||||
```
|
||||
|
||||
这是新建类;新文件顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IMapLayer, IMapLayerHooks } from '@user/data-base';`。注册代码所在的外层函数或方法还要能拿到 `state`。
|
||||
|
||||
<strong>改什么</strong>:
|
||||
|
||||
- `'floor-1'` 换成你的楼层 id;
|
||||
@ -323,7 +329,7 @@ const controller = layer.addHook(hook);
|
||||
|
||||
<strong>目标</strong>:在地图级 `onResizeLayer` 里只处理目标图层。地图级钩子会收到该地图所有图层的尺寸变化,必须自己筛。
|
||||
|
||||
<strong>改哪里</strong>:在能拿到 `map` 与目标图层的时机注册;这次注册到 `map`,不是 `layer`。
|
||||
<strong>改哪里</strong>:在能拿到 `map` 与目标图层的时机注册;这次注册到 `map`,不是 `layer`。同样注意 `floor-1` 可能尚未构造,`getMap` 返回 `null` 时不要静默 `return`,至少打一条提示。
|
||||
|
||||
<strong>复制什么</strong>:
|
||||
|
||||
@ -350,6 +356,8 @@ if (!targetLayer) return;
|
||||
map.addHook(new TargetLayerResizeHooks(state, targetLayer));
|
||||
```
|
||||
|
||||
这个新文件顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IGameMapHooks, IMapLayer } from '@user/data-base';`。
|
||||
|
||||
<strong>改什么</strong>:楼层 id、图层 alias、比较字段与提示内容。比较必须用 `layer !== this.targetLayer`,不要用 `layer.alias`。
|
||||
|
||||
<strong>怎么验证</strong>:调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)。该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸。把 `targetLayer` 换成另一个图层,提示应对应另一层。
|
||||
|
||||
@ -62,13 +62,24 @@ export class MyPlugin implements IDataPluginDefine {
|
||||
|
||||
### 注册到 dataPlugins
|
||||
|
||||
插件写好后,在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组里登记。文件顶部通常已经有 `IDataPluginDefine` 和你插件的 `import`,不要再粘;只改这一行:
|
||||
插件写好后,在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组里<strong>追加</strong>一项,不要清空或替换已有插件。列表里现在已经有商店插件:
|
||||
|
||||
```ts
|
||||
import { IDataPluginDefine } from '@user/data-state';
|
||||
import { DataShopPlugin } from './shop';
|
||||
// [需改] 新建插件文件时,必须补上你自己的 import
|
||||
import { MyPlugin } from './my-plugin';
|
||||
|
||||
/** 数据端插件列表 */
|
||||
export const dataPlugins: IDataPluginDefine[] = [new MyPlugin()];
|
||||
export const dataPlugins: IDataPluginDefine[] = [
|
||||
new DataShopPlugin(),
|
||||
// [需改] 追加到末尾,不要替换上面的已有插件
|
||||
new MyPlugin()
|
||||
];
|
||||
```
|
||||
|
||||
如果你改的是<strong>已有插件</strong>,它对应的 `import` 通常已经在文件顶部,不要重复粘;如果你是<strong>新建插件</strong>,`list.ts` 里不会有你的类名,必须自己补一行 `import { MyPlugin } from './my-plugin';`(路径指向你的插件目录)。只把数组写成 `[new MyPlugin()]` 会把商店插件挤掉。
|
||||
|
||||
数据端启动时会遍历这个数组,把插件收进注册表。同一个 `id` 出现两次时,后一个会被忽略,控制台报一条错误(错误码 `78`),所以请保证 `id` 唯一。
|
||||
|
||||
## 三个生命周期进阶
|
||||
@ -289,6 +300,8 @@ export class MyPlugin implements IDataPluginDefine {
|
||||
}
|
||||
```
|
||||
|
||||
这段是<strong>新建文件</strong>,顶部的 `import` 必须一起保留。`MySystem` 是你自己写的类,文档不会替你定义:先按你的需求写出这个类;暂时没有对应对象时,把 `mySystem` 字段、`new MySystem(state)` 以及相关的 `addSaveableContent` 一起删掉,否则编译器会报「找不到名称 MySystem」。
|
||||
|
||||
<strong>改什么</strong>:
|
||||
|
||||
1. <strong>类名与 id</strong>:`MyPlugin` 与 `my-plugin` 换成你的名字,`id` 全局唯一。
|
||||
@ -431,7 +444,28 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
|
||||
|
||||
<strong>第三项:存档后读档,确认插件注册的内容恢复</strong>。
|
||||
|
||||
让插件注册的可存档内容进入一个可观察状态(例如改一个计数),存档,再改动它,然后读档。读档后应回到存档时的值。没恢复时依次检查:对象有没有实现 `ISaveableContent`、有没有在 `awake` 里用 `state.addSaveableContent` 注册、`saveState` 返回的数据里有没有漏字段。
|
||||
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,`state.loadState(snapshot, compression)` 再用同一份映射恢复。把下面几行放进一个能拿到 `state` 的临时函数执行一次,就是最外层的「存档 → 改 → 读档」:
|
||||
|
||||
```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)的「怎么验证」。
|
||||
|
||||
<strong>第四项:故意把依赖写错一次</strong>。
|
||||
|
||||
|
||||
@ -24,10 +24,10 @@
|
||||
|
||||
两个装饰器都写在<strong>类的方法</strong>上方,作用对象是「录像安全检查」,不是录像本身。
|
||||
|
||||
| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 |
|
||||
| ----------------- | --------------------------------- | ---------------------------------------------------- | -------------- |
|
||||
| `@shouldReplay()` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 |
|
||||
| `@ignoreReplay()` | 确实不该计入录像的被动 / 延迟路径 | 把<strong>整段</strong>检查标记为忽略 | 不会 |
|
||||
| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 |
|
||||
| ------------------------ | --------------------------------- | ---------------------------------------------------- | -------------- |
|
||||
| `@shouldReplay(message)` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 |
|
||||
| `@ignoreReplay(reason)` | 确实不该计入录像的被动 / 延迟路径 | 把<strong>整段</strong>检查标记为忽略 | 不会 |
|
||||
|
||||
### @shouldReplay:登记一条路径
|
||||
|
||||
@ -131,7 +131,7 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0);
|
||||
|
||||
这是当前必须知道的事实:`beginReplaySafetyCollection` 与 `endReplaySafetyCollection` 在仓库里目前<strong>只有测试在调用</strong>(`packages-user/data-common/src/replay/__test__/func.test.ts`),`logReplaySafetyDetail` 也一样。
|
||||
|
||||
所以本篇描述的是<strong>「收集区间开启时」的检测口径</strong>:区间开着,两个装饰器才有意义;区间没开,`@shouldReplay` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「怎么验证」里的方式手动开一次区间。
|
||||
所以本篇描述的是<strong>「收集区间开启时」的检测口径</strong>:区间开着,两个装饰器才有意义;区间没开,`@shouldReplay` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「怎么验证」里的方式手动开一次区间。生产环境目前也没有自动开启收集区间的开关;如果要在实际游玩中检查,只能在自定义的调试流程或录像入口里,自己把一段操作包在 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 之间。
|
||||
|
||||
### 边界二:读档期间录像被禁用
|
||||
|
||||
@ -147,9 +147,9 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0);
|
||||
|
||||
这一节按六步给出可直接照做的改法。
|
||||
|
||||
<strong>目标</strong>:让一个会改变状态的玩家动作入口,在收集区间里能被登记出来,并亲眼看到「有标记」与「没标记」的差别。
|
||||
<strong>目标</strong>:让一个会改变状态的方法入口,在收集区间里能被登记出来,并亲眼看到「有标记」与「没标记」的差别。
|
||||
|
||||
<strong>改哪里</strong>:`packages-user/data-base/src/flag/system.ts`,`FlagSystem.setField` 的上方。这个文件里 `setField` 与 `deleteField` 都已经有标记,照着它们的位置改最直观。
|
||||
<strong>改哪里</strong>:`packages-user/data-base/src/flag/system.ts`,`FlagSystem.getOrInsert` 的上方。这个文件里 `setField` 与 `deleteField` 都已经有标记;`getOrInsert` 还没有,而它在字段不存在时会新建一条 Flag 字段,同样会改变状态,正好用来观察「加标记 / 去掉标记」的差别。
|
||||
|
||||
<strong>复制什么</strong>:只复制装饰器那一行,粘在方法声明的上一行:
|
||||
|
||||
@ -160,9 +160,9 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0);
|
||||
|
||||
这里不含 `import`:`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不要再粘一次。若你换到别的文件、编辑器报缺少 `shouldReplay`,再在顶部补这一行。
|
||||
|
||||
<strong>改什么</strong>:把消息文本改成这个方法实际改变了什么。内置用的都是英文短语,例如 `Flag field set should be replayed.`;语言本身不影响行为,写清意思即可。
|
||||
<strong>改什么</strong>:把消息文本改成这个方法实际改变了什么。例如可以写 `Flag field inserted should be replayed.`;语言本身不影响行为,写清意思即可。
|
||||
|
||||
<strong>怎么验证</strong>:手动开一次收集区间,看告警里有没有这条路径。这几个函数不在 `state` 上,需要在文件顶部引入(只用于调试,平时不需要):
|
||||
<strong>怎么验证</strong>:手动开一次收集区间,看告警里有没有这条路径。这几个函数不在 `state` 上,需要引入(只用于调试,平时不需要):
|
||||
|
||||
```ts
|
||||
import {
|
||||
@ -171,14 +171,15 @@ import {
|
||||
} from '@user/data-common';
|
||||
|
||||
beginReplaySafetyCollection(state.replaySystem);
|
||||
state.flags.setField('replay-check', 1);
|
||||
state.flags.getOrInsert('replay-check', 1);
|
||||
endReplaySafetyCollection();
|
||||
```
|
||||
|
||||
- 区间内没有产生任何新录像步,`setField` 又带着标记,所以控制台应出现告警(错误码 `161`),列表里能看到 `setField: ...` 这一条;
|
||||
- 把 `setField` 上的标记注释掉再跑一次,告警会<strong>消失</strong>。这不是修好了,而是检查器看不见这个入口了——这正是「漏标记比漏记录更难查」的原因。
|
||||
- 区间内没有产生任何新录像步,`getOrInsert` 又带着标记,所以控制台应出现告警(错误码 `161`),列表里能看到 `getOrInsert: ...` 这一条;
|
||||
- 把 `getOrInsert` 上的标记注释掉再跑一次,告警会<strong>消失</strong>。这不是修好了,而是检查器看不见这个入口了——这正是「漏标记比漏记录更难查」的原因。
|
||||
- 两个过程都不会产生录像步:标记只登记路径,真正的记录仍要 `record` 或内置录制入口。
|
||||
|
||||
<strong>注意</strong>:只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。
|
||||
<strong>注意</strong>:只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。不要选一个内部已经调用了带标记方法的方法来做这个练习——那样即使去掉外层标记,里层仍会登记路径,告警不会消失。
|
||||
|
||||
## 改内置还是自己写
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ export interface ISaveableContent<T> {
|
||||
`loadState` 拿到的是普通数据,不是你的类实例。它要做的是把这份数据装回对象:该清空的清空,该重建的重建,不要假设旧字段还留着。商店管理器就是典型写法:
|
||||
|
||||
```ts
|
||||
loadState(state: IShopManagerSave): void {
|
||||
loadState(state: IShopManagerSave, compression: SaveCompression): void {
|
||||
this.shops.clear();
|
||||
for (const save of state.list) {
|
||||
const shop: IShopState = new ShopState(save.id, this.state);
|
||||
@ -100,7 +100,8 @@ saveState(): IMySave {
|
||||
实现好之后,用 `state.addSaveableContent(id, content)` 把它交给存档系统:
|
||||
|
||||
```ts
|
||||
state.addSaveableContent('@plugin/example-counter', this.counter);
|
||||
// [示例] 下文插件示例里的对象叫 this.store,注册 id 就沿用它
|
||||
state.addSaveableContent('@plugin/my-store', this.store);
|
||||
```
|
||||
|
||||
`id` 是这个对象在存档里的唯一标识。整份数据端存档就是一个 `Map<string, unknown>`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。
|
||||
@ -109,7 +110,7 @@ state.addSaveableContent('@plugin/example-counter', this.counter);
|
||||
|
||||
需要按 id 取回对象时,用 `state.getSaveableContent<T>(id)`。它返回 `ISaveableContent<T> | null`,没有这个 id 时返回 `null`。
|
||||
|
||||
插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适,注册顺序也最早、最可控;放到更晚的阶段注册,虽然同样能进入存档表,但会排到已有对象之后,读档顺序也跟着改变。内置内容也是同样的注册方式,主插件在 `awake` 里注册了五项:
|
||||
插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适,注册顺序也最早、最可控。`initialize` / `afterLoad` 也能调用 `addSaveableContent`(接口本身不限制),但会排到已有对象之后,读档顺序也随之后移;只有对象必须等加载内容才能构造时才这样做,并保证注册发生在任何 `saveState` / `loadState` 调用之前。内置内容也是同样的注册方式,主插件在 `awake` 里注册了五项:
|
||||
|
||||
| 存档 id | 对应对象 |
|
||||
| ---------------- | -------------------- |
|
||||
@ -195,13 +196,33 @@ export interface ISaveableExecutor<T> {
|
||||
正确做法是<strong>存 id 或键,读档时再解析</strong>。常见的键有楼层 `floorId`、商店 id、可存档 id,以及你自己维护的稳定 `uid`。例如一个任务系统要记住「目标商店」:
|
||||
|
||||
```ts
|
||||
// [需改] 存档里只存 id,不存 IShopState 对象
|
||||
// [需改] 引用方:存档里只存目标 id,不存 IShopState 对象
|
||||
export interface IQuestSave {
|
||||
readonly targetShopId: string;
|
||||
readonly targetFloorId: 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`)。
|
||||
|
||||
读档时怎么把它变回对象,有三种策略:
|
||||
|
||||
| 策略 | 做法 | 适用场景 |
|
||||
@ -212,23 +233,32 @@ export interface IQuestSave {
|
||||
|
||||
选择时看注册顺序:执行器只保证「该对象自己的 `loadState` 已完成」,不保证后面的对象已完成。如果你要引用的对象注册在引用者之后,执行器里拿到的可能还是旧状态,这时应该改成惰性解析,或者调整注册顺序,让被引用者先注册。
|
||||
|
||||
「拥有者注册在被引用者之后」是最省事的做法:先注册商店管理器,再注册引用商店的任务系统;这样任务系统的执行器运行时,商店已经恢复完毕。依赖关系不固定时,才需要惰性解析兜底。
|
||||
「拥有者注册在被引用者之后」是最省事的做法:先注册商店管理器(商店插件在 `awake` 里用 `@plugin/shop` 注册),再注册引用商店的任务系统;这样任务系统的执行器运行时,商店已经恢复完毕。依赖关系不固定时,才需要惰性解析兜底。
|
||||
|
||||
引用者自己的注册与绑定如下,顺序不能反:
|
||||
|
||||
```ts
|
||||
// [需改] 读档后在执行器里解析引用
|
||||
// [需改] 先注册引用者,再绑定执行器
|
||||
state.addSaveableContent('@plugin/example-quest', this.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 map = state.maps.getMap(data.targetFloorId);
|
||||
const manager = state.getSaveableContent<IShopManagerSave>(
|
||||
'@plugin/shop'
|
||||
) as IShopManager | null;
|
||||
const shop = manager?.getShop(data.targetShopId) ?? null;
|
||||
// [需改] 找不到时怎么处理,由你决定;不要假设它一定存在
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
绑定放在注册之后:
|
||||
|
||||
```ts
|
||||
state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor());
|
||||
```
|
||||
执行器文件还要从 `../shop` 引入 `IShopManager` / `IShopManagerSave`(路径按你的目录调整)。
|
||||
|
||||
## 模板:一个可存档对象的最小骨架
|
||||
|
||||
@ -240,6 +270,8 @@ state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor());
|
||||
|
||||
```ts
|
||||
// [模板] 一个可存档对象的最小骨架:复制后按 [需改] 修改
|
||||
import { ISaveableContent } from '@user/data-common';
|
||||
|
||||
export interface IMySave {
|
||||
// [需改] 存档形状,只能用 structured-cloneable 的类型
|
||||
readonly values: Map<string, number>;
|
||||
@ -254,6 +286,11 @@ export class MySaveable implements ISaveableContent<IMySave> {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
// [需改] 验证时会用到;不需要就删掉
|
||||
getValue(key: string): number | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
|
||||
saveState(): IMySave {
|
||||
// [需改] 必须交出深拷贝,不能返回 this
|
||||
return { values: structuredClone(this.values) };
|
||||
@ -275,7 +312,7 @@ export class MySaveable implements ISaveableContent<IMySave> {
|
||||
2. <strong>字段</strong>:把 `values` 换成你真正要保存的数据。只放重建必需的信息,缓存与派生数据不要放。
|
||||
3. <strong>存档形状</strong>:`IMySave` 里列出 `saveState` 返回的字段,并确认每个字段都能被 `structuredClone` 处理。
|
||||
4. <strong>loadState</strong>:按你的数据结构清空并重建。类实例要重新 `new`,不要直接使用存档里的普通对象。
|
||||
5. <strong>压缩参数</strong>:如果三档实现相同,方法可以省略 `compression` 参数;需要区分时再补上。
|
||||
5. <strong>压缩参数</strong>:`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`。实现里不需要区分时,可以省略参数:TypeScript 允许实现接口时少写参数,调用方仍会按接口传两个实参,多出的那个被忽略。需要区分时,再把 `compression: SaveCompression` 加回来。
|
||||
|
||||
<strong>注意</strong>:`saveState` 里不要修改对象状态,也不要返回 `this` 或带方法的类实例。`loadState` 不要假设旧数据还在,先清空再重建,才不会残留上次的状态。
|
||||
|
||||
@ -312,7 +349,7 @@ export class MyPlugin implements IDataPluginDefine {
|
||||
1. <strong>类名与 id</strong>:`MyPlugin` 与 `my-plugin` 换成你的插件名,`id` 全局唯一。
|
||||
2. <strong>持有字段</strong>:`store` 与 `MySaveable` 换成模板里你的类;对象构造需要 `state` 时,也在这里传入。
|
||||
3. <strong>存档 id</strong>:`@plugin/my-store` 换成你自己的 id,保证唯一,并与插件 id 分开命名。
|
||||
4. <strong>注册位置</strong>:必须在 `awake` 里注册,且只注册一次。
|
||||
4. <strong>注册位置</strong>:优先在 `awake` 里注册;对象必须等加载内容才能构造时,可以放到插件 `afterLoad`,但注册顺序会排在已有对象之后。无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。
|
||||
|
||||
文件顶部通常已经有 `IDataPluginDefine` / `ICoreState`;若报缺少 `MySaveable` 或 `ISaveableContent`,再补对应的 `import`,不要重复粘。
|
||||
|
||||
@ -341,20 +378,58 @@ export class MyPlugin implements IDataPluginDefine {
|
||||
|
||||
## 怎么验证
|
||||
|
||||
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面把验证代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行。
|
||||
|
||||
<strong>第一项:改状态,存档,再改,读档</strong>。
|
||||
|
||||
把目标字段改到一个可观察的值,存档;再把它改成另一个值,然后读档。读档后应回到存档时的值。同时确认<strong>没有存</strong>的缓存被重新生成,而不是保留了读档前的旧值。
|
||||
文件顶部补上两个 `import`,再把下面的代码放进临时函数:
|
||||
|
||||
```ts
|
||||
// [验证] 存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类
|
||||
import { SaveCompression } from '@user/data-common';
|
||||
import { MyPlugin } from './plugin/my-plugin';
|
||||
|
||||
const plugin = state.getDataPlugin<MyPlugin>('my-plugin')?.plugin;
|
||||
if (!plugin) return;
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
读档后应回到存档时的值。同时确认<strong>没有存</strong>的缓存被重新生成,而不是保留了读档前的旧值。
|
||||
|
||||
<strong>第二项:三档压缩各跑一次</strong>。
|
||||
|
||||
分别用 `SaveCompression.NoCompression`、`LowCompression`、`HighCompression` 存档并读档。三档都应恢复出相同结果;如果只有某一档出错,说明 `saveState` 与 `loadState` 对压缩级别的处理不一致。
|
||||
对同一个对象循环三档:
|
||||
|
||||
```ts
|
||||
// [验证] 三档压缩都应恢复出同一个值
|
||||
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` 对压缩级别的处理不一致。
|
||||
|
||||
<strong>第三项:确认注册成功</strong>。
|
||||
|
||||
在启动完成后调用:
|
||||
|
||||
```ts
|
||||
const content = state.getSaveableContent('@plugin/example-counter');
|
||||
const content = state.getSaveableContent('@plugin/my-store');
|
||||
console.log(content);
|
||||
```
|
||||
|
||||
@ -362,6 +437,16 @@ console.log(content);
|
||||
|
||||
<strong>第四项:故意删掉存档里的一个键</strong>。
|
||||
|
||||
`saveState` 返回的是 `ReadonlyMap`,要删键先复制成可修改的 `Map`:
|
||||
|
||||
```ts
|
||||
// [验证] 删掉本对象的存档数据再读档,应告警 177
|
||||
const snapshot = state.saveState(SaveCompression.NoCompression);
|
||||
const edited = new Map(snapshot);
|
||||
edited.delete('@plugin/my-store');
|
||||
state.loadState(edited, SaveCompression.NoCompression);
|
||||
```
|
||||
|
||||
读档时控制台应出现缺键告警(`177`),并跳过该对象,而不是静默通过。存档里多出未注册的 id 时,应出现多键告警(`178`)。这两条能帮你区分「没存进去」和「读的时候没对上」。
|
||||
|
||||
<strong>第五项:检查跨对象引用</strong>。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user