diff --git a/.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md b/.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md index ea36ec7..427fbaf 100644 --- a/.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md @@ -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。 + + diff --git a/docs/advanced/data-stores.md b/docs/advanced/data-stores.md index b2b27c1..7b30493 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -57,7 +57,7 @@ Store 里的东西在加载 / 初始化阶段被填一次,之 ### itemStore:道具定义 -以道具图块数字为键,存 `IItemRawData`:`num`、`category`(`ItemCategory`)、`name`、`text`、`hideInToolbox`、`effect`(非装备类道具的效果对象)、`equip`(装备类道具的数值与事件)。 +以道具图块数字为键,存 `IItemRawData`:`num`、`category`(`ItemCategory`)、`name`、`text`、`hideInToolbox`、`effect`(使用 / 拾取效果)、`equip`(装备数值与事件)。`effect` 与 `equip` 都是必填字段,不是二选一:非装备类除了 `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`。 -但它不在 `IDataCommon` 上:主对象没有把 `enemyStore` 暴露成字段。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。本篇只讲 `IDataCommon` 上的四个 Store;需要怪物原始数据时,先确认当前版本把它接在了哪里,再决定从哪读。 +但它不在 `IDataCommon` 上:主对象没有把 `enemyStore` 暴露成字段,`@user/data-common` 的公开入口也没有导出 `EnemyStore` 类;仓库里目前只有测试直接 `new EnemyStore(state.tileStore)` 来构造它。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。如果你确实需要一张怪物原始数据表,最稳妥的做法是在自己的插件里维护一个同形状的 `Map` 注册表(`addEnemy` / `getEnemy` 的语义可照 `IEnemyStore`),把改动留在自己的代码里。 ## 模板:登记一条原始定义 Store 的注册没有「一个空类 + 需改点」那种逐对象模板,原因和别的机制不同:插件、可存档对象、光环这些机制都要你实现一个接口,所以能给出骨架;`addTile` / `addItem` / `addMap` / `addEvent` 不是这样——它们是逐条登记一条原始定义,你不需要实现任何方法,只需要把一条条定义交给它。 -所以这里的骨架是「一条定义长什么样」。下面以图块为例,复制后按 `[需改]` 修改: +所以这里的骨架是「一条定义长什么样」。下面以图块为例,复制后按 `[需改]` 修改。新建文件时把 `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({ 注意:`addEvent` 的签名与另外三个不同,它的第一个参数是事件 id,第二个参数才是事件实例。 -顶部通常已有 `TileType` 这类名字的 `import`;若报缺少某个名字,再在顶部补对应的 `import`。 +上面这段按新建文件写,`TileType` 从 `@user/data-common` 引入;如果放进已有文件,顶部可能已经引入过,不要重复粘。 ## 把一批自定义内容注册进 Store @@ -213,6 +215,8 @@ awake(state: ICoreState): void { 注意:不要在玩家动作、事件回调或每帧里登记;同一个 `num` 重复登记会告警并覆盖,等于把登记做成了「碰运气」。 +在 `afterLoad` 里登记还要多确认一步:`mapStore.addMap` 只是把原始数据放进注册表,不会自动创建运行时楼层,需要时自己调用 `state.maps.fromRaw(raw)`。`addTile` 也只影响之后构造的图层;如果目标图层已经由 `fromRaw` 建好,新图块不会自动补进去,必须在构造图层之前完成登记,或自己重新构造楼层 / 图层(`fromRaw` / `addLayer`)。 + ## 改内置还是自己写 - 只是补几条内容定义——在已有的加载 / 注册位置加条目,不要为一批原始数据新造 Store,也不要扩展 `IDataCommon`; @@ -236,9 +240,12 @@ awake(state: ICoreState): void { 第一项:读回定义。 ```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)` 都应查得到。 diff --git a/docs/advanced/declarative-vs-imperative.md b/docs/advanced/declarative-vs-imperative.md index be9116d..2cab286 100644 --- a/docs/advanced/declarative-vs-imperative.md +++ b/docs/advanced/declarative-vs-imperative.md @@ -86,7 +86,7 @@ hero.hp = hero.hp + 50; ### 伤害计算器 -你声明什么:一组属性到伤害结果的换算规则。为了实现「把这组属性换算成勇士掉多少血、要打几回合」,我们需要实现 `IDamageCalculator` 接口,核心方法是 `calculate(handler)`,返回 `{ damage, turn }`。内置实现是 `src/data/enemy/calculator.ts` 里的 `MainDamageCalculator`,它在 `initializeEnemy` 中通过 `useCalculator(new MainDamageCalculator())` 注册。 +你声明什么:一组属性到伤害结果的换算规则。为了实现「把这组属性换算成勇士掉多少血、要打几回合」,我们需要实现 `IDamageCalculator` 接口,核心方法是 `calculate(handler)`,返回 `{ damage, turn }`。接口还有 `getCriticalLimit(handler, attribute)`,供临界值计算使用;一般不需要自己实现,改公式时按 `MainDamageCalculator` 的写法保留它即可。内置实现是 `src/data/enemy/calculator.ts` 里的 `MainDamageCalculator`,它在 `initializeEnemy` 中通过 `useCalculator(new MainDamageCalculator())` 注册。 引擎在哪个时机执行:任何需要伤害数值的地方。显示伤害预览、计算临界值、真正结算战斗,都会调用计算器;同一场战斗里,它可能被反复调用很多次。 @@ -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、删记录或改对象。 第三项:存档后改状态,再读档,确认恢复。 +存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,`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` 返回的对象里有没有漏掉这个字段。 ## 小结 diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md index 5e23f5b..95de068 100644 --- a/docs/advanced/hooks-in-depth.md +++ b/docs/advanced/hooks-in-depth.md @@ -240,7 +240,9 @@ layer.addHook(hook); ### 图层移除会卸载内部转发器 -`GameMap.removeLayer(layer)` 会移除图层,并卸载 `addLayer` 时为它创建的内部 `StateMapLayerHook`。因此图层移除后,地图级钩子不会再收到这个图层的 resize。你自己挂在图层上的钩子会随图层对象一起失去作用;如果还持有控制器,也可以显式 `unload()`。 +`GameMap.removeLayer(layer)` 会移除图层,并卸载 `addLayer` 时为它创建的内部 `StateMapLayerHook`。因此图层移除后,地图级钩子不会再收到这个图层的 resize。 + +但它不会清理你自己挂在图层上的钩子:图层自身的钩子列表仍然保留,也不会调用你钩子的 `destroy`。如果你还持有这个图层对象的引用,直接调用它的方法仍会触发那些钩子;要释放定时器、外部监听器等资源,需要自己保存 `addHook` 返回的控制器并显式 `controller.unload()`。 ## 异步钩子 @@ -279,6 +281,8 @@ layer.addHook(hook); 改哪里:在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中;下面的片段省略了外层函数或方法,`return` 需要放在函数体内。 +注意:`afterLoad` 时 `floor-1` 未必已经由加载流程构造出来(取决于你的内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`。片段里的 `if (!map) return` 只是让代码能编译;实际使用时不要这样静默跳过——至少用 `logger.warn` 或 `emitTip` 报出来,或者把注册推迟到楼层创建之后再执行。 + 复制什么: ```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`。 + 改什么: - `'floor-1'` 换成你的楼层 id; @@ -323,7 +329,7 @@ const controller = layer.addHook(hook); 目标:在地图级 `onResizeLayer` 里只处理目标图层。地图级钩子会收到该地图所有图层的尺寸变化,必须自己筛。 -改哪里:在能拿到 `map` 与目标图层的时机注册;这次注册到 `map`,不是 `layer`。 +改哪里:在能拿到 `map` 与目标图层的时机注册;这次注册到 `map`,不是 `layer`。同样注意 `floor-1` 可能尚未构造,`getMap` 返回 `null` 时不要静默 `return`,至少打一条提示。 复制什么: @@ -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';`。 + 改什么:楼层 id、图层 alias、比较字段与提示内容。比较必须用 `layer !== this.targetLayer`,不要用 `layer.alias`。 怎么验证:调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)。该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸。把 `targetLayer` 换成另一个图层,提示应对应另一层。 diff --git a/docs/advanced/plugin-system.md b/docs/advanced/plugin-system.md index b555e79..b1f2b65 100644 --- a/docs/advanced/plugin-system.md +++ b/docs/advanced/plugin-system.md @@ -62,13 +62,24 @@ export class MyPlugin implements IDataPluginDefine { ### 注册到 dataPlugins -插件写好后,在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组里登记。文件顶部通常已经有 `IDataPluginDefine` 和你插件的 `import`,不要再粘;只改这一行: +插件写好后,在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组里追加一项,不要清空或替换已有插件。列表里现在已经有商店插件: ```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() +]; ``` +如果你改的是已有插件,它对应的 `import` 通常已经在文件顶部,不要重复粘;如果你是新建插件,`list.ts` 里不会有你的类名,必须自己补一行 `import { MyPlugin } from './my-plugin';`(路径指向你的插件目录)。只把数组写成 `[new MyPlugin()]` 会把商店插件挤掉。 + 数据端启动时会遍历这个数组,把插件收进注册表。同一个 `id` 出现两次时,后一个会被忽略,控制台报一条错误(错误码 `78`),所以请保证 `id` 唯一。 ## 三个生命周期进阶 @@ -289,6 +300,8 @@ export class MyPlugin implements IDataPluginDefine { } ``` +这段是新建文件,顶部的 `import` 必须一起保留。`MySystem` 是你自己写的类,文档不会替你定义:先按你的需求写出这个类;暂时没有对应对象时,把 `mySystem` 字段、`new MySystem(state)` 以及相关的 `addSaveableContent` 一起删掉,否则编译器会报「找不到名称 MySystem」。 + 改什么: 1. 类名与 id:`MyPlugin` 与 `my-plugin` 换成你的名字,`id` 全局唯一。 @@ -431,7 +444,28 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin); 第三项:存档后读档,确认插件注册的内容恢复。 -让插件注册的可存档内容进入一个可观察状态(例如改一个计数),存档,再改动它,然后读档。读档后应回到存档时的值。没恢复时依次检查:对象有没有实现 `ISaveableContent`、有没有在 `awake` 里用 `state.addSaveableContent` 注册、`saveState` 返回的数据里有没有漏字段。 +存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,`state.loadState(snapshot, compression)` 再用同一份映射恢复。把下面几行放进一个能拿到 `state` 的临时函数执行一次,就是最外层的「存档 → 改 → 读档」: + +```ts +// [验证] 存档,改掉可观察值,再读档;类名与路径换成你自己的 +import { SaveCompression } from '@user/data-common'; +import { MyPlugin } from './plugin/my-plugin'; + +const plugin = state.getDataPlugin('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)的「怎么验证」。 第四项:故意把依赖写错一次。 diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md index f551d29..587ab41 100644 --- a/docs/advanced/replay-consistency.md +++ b/docs/advanced/replay-consistency.md @@ -24,10 +24,10 @@ 两个装饰器都写在类的方法上方,作用对象是「录像安全检查」,不是录像本身。 -| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 | -| ----------------- | --------------------------------- | ---------------------------------------------------- | -------------- | -| `@shouldReplay()` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 | -| `@ignoreReplay()` | 确实不该计入录像的被动 / 延迟路径 | 把整段检查标记为忽略 | 不会 | +| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 | +| ------------------------ | --------------------------------- | ---------------------------------------------------- | -------------- | +| `@shouldReplay(message)` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 | +| `@ignoreReplay(reason)` | 确实不该计入录像的被动 / 延迟路径 | 把整段检查标记为忽略 | 不会 | ### @shouldReplay:登记一条路径 @@ -131,7 +131,7 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0); 这是当前必须知道的事实:`beginReplaySafetyCollection` 与 `endReplaySafetyCollection` 在仓库里目前只有测试在调用(`packages-user/data-common/src/replay/__test__/func.test.ts`),`logReplaySafetyDetail` 也一样。 -所以本篇描述的是「收集区间开启时」的检测口径:区间开着,两个装饰器才有意义;区间没开,`@shouldReplay` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「怎么验证」里的方式手动开一次区间。 +所以本篇描述的是「收集区间开启时」的检测口径:区间开着,两个装饰器才有意义;区间没开,`@shouldReplay` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「怎么验证」里的方式手动开一次区间。生产环境目前也没有自动开启收集区间的开关;如果要在实际游玩中检查,只能在自定义的调试流程或录像入口里,自己把一段操作包在 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 之间。 ### 边界二:读档期间录像被禁用 @@ -147,9 +147,9 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0); 这一节按六步给出可直接照做的改法。 -目标:让一个会改变状态的玩家动作入口,在收集区间里能被登记出来,并亲眼看到「有标记」与「没标记」的差别。 +目标:让一个会改变状态的方法入口,在收集区间里能被登记出来,并亲眼看到「有标记」与「没标记」的差别。 -改哪里:`packages-user/data-base/src/flag/system.ts`,`FlagSystem.setField` 的上方。这个文件里 `setField` 与 `deleteField` 都已经有标记,照着它们的位置改最直观。 +改哪里:`packages-user/data-base/src/flag/system.ts`,`FlagSystem.getOrInsert` 的上方。这个文件里 `setField` 与 `deleteField` 都已经有标记;`getOrInsert` 还没有,而它在字段不存在时会新建一条 Flag 字段,同样会改变状态,正好用来观察「加标记 / 去掉标记」的差别。 复制什么:只复制装饰器那一行,粘在方法声明的上一行: @@ -160,9 +160,9 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0); 这里不含 `import`:`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不要再粘一次。若你换到别的文件、编辑器报缺少 `shouldReplay`,再在顶部补这一行。 -改什么:把消息文本改成这个方法实际改变了什么。内置用的都是英文短语,例如 `Flag field set should be replayed.`;语言本身不影响行为,写清意思即可。 +改什么:把消息文本改成这个方法实际改变了什么。例如可以写 `Flag field inserted should be replayed.`;语言本身不影响行为,写清意思即可。 -怎么验证:手动开一次收集区间,看告警里有没有这条路径。这几个函数不在 `state` 上,需要在文件顶部引入(只用于调试,平时不需要): +怎么验证:手动开一次收集区间,看告警里有没有这条路径。这几个函数不在 `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` 上的标记注释掉再跑一次,告警会消失。这不是修好了,而是检查器看不见这个入口了——这正是「漏标记比漏记录更难查」的原因。 +- 区间内没有产生任何新录像步,`getOrInsert` 又带着标记,所以控制台应出现告警(错误码 `161`),列表里能看到 `getOrInsert: ...` 这一条; +- 把 `getOrInsert` 上的标记注释掉再跑一次,告警会消失。这不是修好了,而是检查器看不见这个入口了——这正是「漏标记比漏记录更难查」的原因。 +- 两个过程都不会产生录像步:标记只登记路径,真正的记录仍要 `record` 或内置录制入口。 -注意:只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。 +注意:只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。不要选一个内部已经调用了带标记方法的方法来做这个练习——那样即使去掉外层标记,里层仍会登记路径,告警不会消失。 ## 改内置还是自己写 diff --git a/docs/advanced/saveable-content.md b/docs/advanced/saveable-content.md index 4664a97..f16f0cc 100644 --- a/docs/advanced/saveable-content.md +++ b/docs/advanced/saveable-content.md @@ -56,7 +56,7 @@ export interface ISaveableContent { `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`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。 @@ -109,7 +110,7 @@ state.addSaveableContent('@plugin/example-counter', this.counter); 需要按 id 取回对象时,用 `state.getSaveableContent(id)`。它返回 `ISaveableContent | null`,没有这个 id 时返回 `null`。 -插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适,注册顺序也最早、最可控;放到更晚的阶段注册,虽然同样能进入存档表,但会排到已有对象之后,读档顺序也跟着改变。内置内容也是同样的注册方式,主插件在 `awake` 里注册了五项: +插件通常在 `awake` 里注册。此时主对象刚创建完,装配插件自己的对象正合适,注册顺序也最早、最可控。`initialize` / `afterLoad` 也能调用 `addSaveableContent`(接口本身不限制),但会排到已有对象之后,读档顺序也随之后移;只有对象必须等加载内容才能构造时才这样做,并保证注册发生在任何 `saveState` / `loadState` 调用之前。内置内容也是同样的注册方式,主插件在 `awake` 里注册了五项: | 存档 id | 对应对象 | | ---------------- | -------------------- | @@ -195,13 +196,33 @@ export interface ISaveableExecutor { 正确做法是存 id 或键,读档时再解析。常见的键有楼层 `floorId`、商店 id、可存档 id,以及你自己维护的稳定 `uid`。例如一个任务系统要记住「目标商店」: ```ts -// [需改] 存档里只存 id,不存 IShopState 对象 +// [需改] 引用方:存档里只存目标 id,不存 IShopState 对象 export interface IQuestSave { readonly targetShopId: string; - readonly targetFloorId: 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`)。 + 读档时怎么把它变回对象,有三种策略: | 策略 | 做法 | 适用场景 | @@ -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`,要调用管理器自己的 `getShop`,需要断言成 `IShopManager`: + +```ts +// [需改] 读档后按 id 解析目标商店 export class QuestSaveExecutor implements ISaveableExecutor { afterLoad(data: IQuestSave, state: IStateBase): void { - const map = state.maps.getMap(data.targetFloorId); + const manager = state.getSaveableContent( + '@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; @@ -254,6 +286,11 @@ export class MySaveable implements ISaveableContent { 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 { 2. 字段:把 `values` 换成你真正要保存的数据。只放重建必需的信息,缓存与派生数据不要放。 3. 存档形状:`IMySave` 里列出 `saveState` 返回的字段,并确认每个字段都能被 `structuredClone` 处理。 4. loadState:按你的数据结构清空并重建。类实例要重新 `new`,不要直接使用存档里的普通对象。 -5. 压缩参数:如果三档实现相同,方法可以省略 `compression` 参数;需要区分时再补上。 +5. 压缩参数:`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`。实现里不需要区分时,可以省略参数:TypeScript 允许实现接口时少写参数,调用方仍会按接口传两个实参,多出的那个被忽略。需要区分时,再把 `compression: SaveCompression` 加回来。 注意:`saveState` 里不要修改对象状态,也不要返回 `this` 或带方法的类实例。`loadState` 不要假设旧数据还在,先清空再重建,才不会残留上次的状态。 @@ -312,7 +349,7 @@ export class MyPlugin implements IDataPluginDefine { 1. 类名与 id:`MyPlugin` 与 `my-plugin` 换成你的插件名,`id` 全局唯一。 2. 持有字段:`store` 与 `MySaveable` 换成模板里你的类;对象构造需要 `state` 时,也在这里传入。 3. 存档 id:`@plugin/my-store` 换成你自己的 id,保证唯一,并与插件 id 分开命名。 -4. 注册位置:必须在 `awake` 里注册,且只注册一次。 +4. 注册位置:优先在 `awake` 里注册;对象必须等加载内容才能构造时,可以放到插件 `afterLoad`,但注册顺序会排在已有对象之后。无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。 文件顶部通常已经有 `IDataPluginDefine` / `ICoreState`;若报缺少 `MySaveable` 或 `ISaveableContent`,再补对应的 `import`,不要重复粘。 @@ -341,20 +378,58 @@ export class MyPlugin implements IDataPluginDefine { ## 怎么验证 +存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面把验证代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行。 + 第一项:改状态,存档,再改,读档。 -把目标字段改到一个可观察的值,存档;再把它改成另一个值,然后读档。读档后应回到存档时的值。同时确认没有存的缓存被重新生成,而不是保留了读档前的旧值。 +文件顶部补上两个 `import`,再把下面的代码放进临时函数: + +```ts +// [验证] 存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类 +import { SaveCompression } from '@user/data-common'; +import { MyPlugin } from './plugin/my-plugin'; + +const plugin = state.getDataPlugin('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 +``` + +读档后应回到存档时的值。同时确认没有存的缓存被重新生成,而不是保留了读档前的旧值。 第二项:三档压缩各跑一次。 -分别用 `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` 对压缩级别的处理不一致。 第三项:确认注册成功。 在启动完成后调用: ```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); 第四项:故意删掉存档里的一个键。 +`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`)。这两条能帮你区分「没存进去」和「读的时候没对上」。 第五项:检查跨对象引用。