diff --git a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md new file mode 100644 index 0000000..ee0eeb2 --- /dev/null +++ b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md @@ -0,0 +1,33 @@ +# 高级 I 用户修订记录 + +## 第 1 轮 + +**时间:** 2026-10-20 +**用户意见:** 按 D-59 修订全部 7 篇高级 I 文档:删除中级教程式的「目标 / 改哪里 / 复制什么 / 改什么 / 怎么验证 / 注意」六步实操块,改为正常库文档风格——用带注释的完整示例讲解,概念与验证写成叙述,不再手把手分步。 +**修改方式:** 派发子代理执行(只改 `docs/advanced/*.md`)。 +**状态:已处理** + +### 逐篇处置 + +| 篇目 | 处置 | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.md` | 本身没有六步块;把「怎么验证你读懂了」改为「读完这一层应该能做什么」,保留分层、阅读路径与 D-55 取舍原则。 | +| `declarative-vs-imperative.md` | 「给怪物加一条光环」改为叙述 + 带注释的 `special` 示例(说明键、范围、矩形/十字与三项百分比),验证改为叙述;保留 `ISpecial23Value`、`aura.ts` 转换器与叠加告警。 | +| `plugin-system.md` | 模板六步改为「示例:一个完整插件」:完整 `MyPlugin` 类(含 `import`、生命周期与注释)→ `list.ts` 追加注册示例;删除 `[模板]` / `[需改]` 标记,验证改为叙述,保留 id 唯一性、依赖串/对象两种形态与错误码。 | +| `saveable-content.md` | 模板六步改为「示例:一个可存档对象」完整类 + 「示例:把可存档对象接到插件里」;删除 `[模板]` / `[需改]` 标记;`structuredClone`、压缩参数省略、注册时机等说明保留,验证改为叙述。 | +| `replay-consistency.md` | 「给一个内置方法补录像标记」的六步改为叙述 + 装饰器片段 + 收集区间示例;保留 `getOrInsert` 练习、错误码 `161`、去标记后告警消失的解释,验证改为叙述。 | +| `data-stores.md` | 「模板:登记一条原始定义」与「把一批自定义内容注册进 Store」两处六步改为带注释的 `addTile` 示例与批量登记示例;保留四个 Store 的字段、错误码与 `fromRaw` / `addLayer` 时机说明,验证改为叙述。 | +| `hooks-in-depth.md` | 「只处理指定图层」「地图级钩子里筛图层」两处六步改为完整钩子类 + 注册片段;保留 `===` 身份比较、`alias` 不是身份、`afterLoad` 可能取不到楼层时的显式提示,验证改为叙述。 | + +### 其他 + +- 所有高级 I 文档中的 `[模板]` / `[需改]` 代码注释标记一并移除,改为普通解释性注释。 +- D-47 占位(`高级 II · …` / `高级 III · …` 与 ``)保留。 +- D-55(新增系统优先用插件)与 D-58(不强制模板章节)保持原样。 +- 未修改 `config.ts`、生产代码与渲染端内容。 + +### 构建 + +- `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。 diff --git a/docs/advanced/data-stores.md b/docs/advanced/data-stores.md index 7b30493..f9d4a84 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -152,68 +152,70 @@ const raw = state.tileStore.getData(num); 但它不在 `IDataCommon` 上:主对象没有把 `enemyStore` 暴露成字段,`@user/data-common` 的公开入口也没有导出 `EnemyStore` 类;仓库里目前只有测试直接 `new EnemyStore(state.tileStore)` 来构造它。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。如果你确实需要一张怪物原始数据表,最稳妥的做法是在自己的插件里维护一个同形状的 `Map` 注册表(`addEnemy` / `getEnemy` 的语义可照 `IEnemyStore`),把改动留在自己的代码里。 -## 模板:登记一条原始定义 +## 示例:登记一条原始定义 -Store 的注册没有「一个空类 + 需改点」那种逐对象模板,原因和别的机制不同:插件、可存档对象、光环这些机制都要你实现一个接口,所以能给出骨架;`addTile` / `addItem` / `addMap` / `addEvent` 不是这样——它们是逐条登记一条原始定义,你不需要实现任何方法,只需要把一条条定义交给它。 +Store 没有「实现一个接口再填空」那种逐对象骨架:`addTile` / `addItem` / `addMap` / `addEvent` 是逐条登记一条原始定义的接口,你不需要实现任何方法,只需要把一条条定义交给它。因此这里要展示的是「一条定义长什么样」。 -所以这里的骨架是「一条定义长什么样」。下面以图块为例,复制后按 `[需改]` 修改。新建文件时把 `import` 放在顶部,把 `state.tileStore.addTile(...)` 这段调用放进插件的 `awake` / `afterLoad` 里: +以图块为例,下面是一条完整的 `addTile` 载荷。新建文件时把 `import` 放在顶部,把调用放进插件的 `awake` / `afterLoad` 里: ```ts -// [模板] 登记一条图块定义:复制后按 [需改] 修改 import { TileType } from '@user/data-common'; +// 登记一条图块定义;num 与 id 必须各自唯一 state.tileStore.addTile({ - // [需改] 图块数字与 id 必须各自唯一 num: 10001, id: 'my-custom-tile', - // [需改] 默认事件:优先级 → 事件 id + // 默认事件:优先级 → 事件 id events: {}, - // [需改] 图块逻辑类型 + // 图块逻辑类型 type: TileType.Terrain, pass: { - // [需改] 是否只在事件层生效 + // 是否只在事件层生效 onlyEvents: false, - // [需改] 可离开 / 可进入的方向掩码 + // 可离开 / 可进入的方向掩码(上 1、右 2、下 4、左 8) outPass: 0b1111, inPass: 0b1111 }, - // [需改] 走上去触发还是撞击触发 + // false 撞击触发,true 走上去触发 eventPass: false }); ``` -改什么: +实际使用时,把写死的 `num` / `id` 换成你内容文件里的条目;内容多时用循环读一遍,而不是手写几十条 `addTile`。`num` 与 `id` 必须唯一,撞了会告警并覆盖,等于悄悄丢掉了另一条定义。`outPass` / `inPass` 是方向位掩码,`0b1111` 表示四向都通。 -1. 数据来源:把写死的 `num` / `id` 换成你内容文件里的条目;内容多时用循环读一遍,而不是手写几十条 `addTile`。 -2. 键:`num` 与 `id` 必须唯一。撞了会告警并覆盖,等于悄悄丢掉了另一条定义。 -3. 通行掩码:`outPass` / `inPass` 是方向位掩码(上 1、右 2、下 4、左 8),`0b1111` 表示四向都通。 -4. 其它三个 Store:形状相同,把 `addTile` 换成对应调用即可——`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。所以这条骨架不需要为每个 Store 重写一遍。 - -注意:`addEvent` 的签名与另外三个不同,它的第一个参数是事件 id,第二个参数才是事件实例。 +另外三个 Store 的形状相同,把 `addTile` 换成对应调用即可:`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。注意 `addEvent` 的签名与另外三个不同,第一个参数是事件 id,第二个参数才是事件实例。 上面这段按新建文件写,`TileType` 从 `@user/data-common` 引入;如果放进已有文件,顶部可能已经引入过,不要重复粘。 ## 把一批自定义内容注册进 Store -这一节按六步给出可直接照做的改法。 - -目标:让新增的图块 / 道具 / 地图 / 事件在加载完成后能被查到,并确定它只被登记一次。 - -改哪里:你的插件类里接受 `state` 的那个时机方法。定义写死在代码里就用 `awake`;要等加载结果就用 `afterLoad`。下面是 `awake` 的写法: +批量登记时,把这些调用集中在插件的一个时机方法里:定义写死在代码里就用 `awake`;要等加载结果就用 `afterLoad`。例如: ```ts awake(state: ICoreState): void { - // 在这里调用 addTile / addItem / addMap / addEvent + // 图块定义直接写在代码里;也可以改成读数组后循环登记 + state.tileStore.addTile({ + num: 10001, + id: 'my-custom-tile', + events: {}, + type: TileType.Terrain, + pass: { onlyEvents: false, outPass: 0b1111, inPass: 0b1111 }, + eventPass: false + }); + + // 其它 Store 同理,逐条登记各自的原始定义 + // rawItem / rawMap / event 换成你实际构造的定义 + state.itemStore.addItem(rawItem); + state.mapStore.addMap(rawMap); + state.eventStore.addEvent('my-event', event); } ``` -复制什么:只复制上面的 `add*` 调用,粘进这个时机方法里。不含 `import`——插件的文件顶部通常已经有 `IDataCommon` / `ICoreState`,内容类型通常在 `@user/data-common` 里,已经引入过就不要重复粘;若编辑器报缺少某个名字,再在顶部补 `import ...`。 +把 `num`、`id` 与各字段换成你自己的数据。图块数字要与你的内容对得上,道具的 `num` 要与对应图块数字一致,地图的 `floorId` 要与切楼层用的 id 一致。插件的文件顶部通常已经有 `IDataCommon` / `ICoreState`,内容类型通常在 `@user/data-common` 里(`TileType` 也在其中);已经引入过就不要重复粘,编辑器报缺少某个名字时再补 `import`。 -改什么:把 `num`、`id` 与各字段换成你自己的数据。图块数字要与你的内容对得上,道具的 `num` 要与对应图块数字一致,地图的 `floorId` 要与切楼层用的 id 一致。 +启动后读回刚登记的内容即可确认:`getData` / `getMap` / `getEvent` 能查到,说明登记成功;查不到就检查是否真的执行到了这个时机方法、以及键是否拼错。 -怎么验证:启动后读回刚登记的内容,见下面的「怎么验证」第一项。`getData` / `getMap` / `getEvent` 能查到,说明登记成功;查不到就检查是否真的执行到了这个时机方法、以及键是否拼错。 - -注意:不要在玩家动作、事件回调或每帧里登记;同一个 `num` 重复登记会告警并覆盖,等于把登记做成了「碰运气」。 +不要在玩家动作、事件回调或每帧里登记;同一个 `num` 重复登记会告警并覆盖,等于把登记做成了「碰运气」。 在 `afterLoad` 里登记还要多确认一步:`mapStore.addMap` 只是把原始数据放进注册表,不会自动创建运行时楼层,需要时自己调用 `state.maps.fromRaw(raw)`。`addTile` 也只影响之后构造的图层;如果目标图层已经由 `fromRaw` 建好,新图块不会自动补进去,必须在构造图层之前完成登记,或自己重新构造楼层 / 图层(`fromRaw` / `addLayer`)。 @@ -235,9 +237,9 @@ awake(state: ICoreState): void { - 假设主对象上有 `enemyStore`。`EnemyStore` 存在,但 `IDataCommon` 没有把它暴露成字段。 - 在 `getEvent` 上做缓存却以为拿到了快照。它给的是实例本身,不是替你复制一份。 -## 怎么验证 +## 验证 -第一项:读回定义。 +启动后先读回刚登记的定义: ```ts // [验证] num 可能为 undefined:id 没登记时先停下来 @@ -250,23 +252,11 @@ if (num !== undefined) { `raw` 应为对象而不是 `null`,`getType(num)` 应等于你登记的类型。道具、地图、事件同理:`itemStore.getData(token)`、`mapStore.getMap(floorId)`、`eventStore.getEvent(id)` 都应查得到。 -第二项:在游戏进行中改一次,观察三个现象。 - -用同一个 `num` 再调用一次 `addTile` 改掉类型,然后: - -1. 看已经存在的对象——静态图块 `mapLayer.getTile(x, y)!.raw()` 会读到新定义,而已进背包的道具 `raw` 仍是旧定义;同一个改动在不同对象上表现并不一致; -2. 存档再读档,看改动是否保留——不会,Store 不在存档里; -3. 录一段包含相关操作的录像并回放,看结果是否与原始游玩一致——定义生效的时点变了,结果就可能对不上。 - -这三个观察合起来,就是「运行时不该改 Store」的理由。 - -第三项:把登记挪到加载期后重跑。 +接着在游戏进行中用同一个 `num` 再调用一次 `addTile` 改掉类型,观察三件事:已经存在的对象里,静态图块 `mapLayer.getTile(x, y)!.raw()` 会读到新定义,而已进背包的道具 `raw` 仍是旧定义——同一个改动在不同对象上表现并不一致;存档再读档,改动不会保留,因为 Store 不在存档里;录一段包含相关操作的录像并回放,结果可能因定义生效的时点变了而对不上。这三个观察合起来,就是「运行时不该改 Store」的理由。 把 `add*` 从玩家动作或回调里移到插件 `awake` / `afterLoad`,重启游戏再验证一遍:登记只发生一次,行为不再随操作次数变化。 -第四项:确认没有被重复登记。 - -重复登记会告警(`181` / `170` / `133` / `134`)。启动时留意控制台,没有这类告警,说明每个键只登记了一次。 +最后留意启动时的控制台:重复登记会告警(`181` / `170` / `133` / `134`)。没有这类告警,说明每个键只登记了一次。 ## 小结 diff --git a/docs/advanced/declarative-vs-imperative.md b/docs/advanced/declarative-vs-imperative.md index 2cab286..19eb49e 100644 --- a/docs/advanced/declarative-vs-imperative.md +++ b/docs/advanced/declarative-vs-imperative.md @@ -60,17 +60,18 @@ hero.hp = hero.hp + 50; #### 示例:给怪物加一条光环 -目标:让某只怪物给范围内的其他怪物增加 20% 攻击。 - -改哪里:`src/content/enemy.jsonc` 中目标怪物的数据对象,找到它的 `special` 字段。 - -复制什么:把下面的 `special` 字段贴进该怪物对象。JSON 没有 `import`,不需要补引入: +假设要让某只怪物给范围内的其他怪物增加 20% 攻击。做法是在 `src/content/enemy.jsonc` 里给这只怪物的数据对象声明一条 `special`,不需要写任何调用代码。JSON 没有 `import`,直接把字段放进对象即可: ```jsonc +// 目标怪物对象上的 special 字段 "special": { + // 键 "23" 是光环的特殊属性编号,不能改名 "23": { + // 影响范围,单位为格 "range": 1, + // false 表示十字范围,true 表示矩形范围 "square": false, + // 三项加成都是百分比:生命、攻击、防御 "hpBuff": 0, "atkBuff": 20, "defBuff": 0 @@ -78,11 +79,9 @@ hero.hp = hero.hp + 50; } ``` -改什么:只改参数。`range` 是范围,`square` 决定矩形还是十字,`hpBuff`、`atkBuff`、`defBuff` 是百分比。键 `"23"` 是光环的编号,不要改。 +`special` 的键必须是特殊属性编号,`"23"` 就代表光环这一条;参数类型 `ISpecial23Value` 定义在 `src/data/enemy/special.ts`,同时被 `aura.ts` 里的转换器与视图使用。字段值才是你要改的部分:`range` 是范围,`square` 决定矩形还是十字,`hpBuff`、`atkBuff`、`defBuff` 是百分比。 -怎么验证:重新进入楼层或触发一次重建,查看周围怪物的计算后攻击。数值应当按 `atkBuff` 的百分比提高;改回 0 后应当恢复原值。 - -注意:`special` 的键必须是特殊属性编号,不要把 `"23"` 改成别的名字。也不要在事件脚本里再手动加一遍攻击,否则光环与脚本会叠加。 +重新进入楼层或触发一次重建,然后查看周围怪物的计算后攻击:它应当按 `atkBuff` 的百分比提高,把 `atkBuff` 改回 0 后恢复到原值。验证时还要确认没有在事件脚本里手动再加一遍攻击——光环与脚本会叠加,数值会比预期高。 ### 伤害计算器 @@ -117,7 +116,7 @@ hero.hp = hero.hp + 50; 引擎在哪个时机执行:对应动作发生时。主对象会通过 `forEachHook` 依次通知挂在自己身上的钩子。你只需要注册一次: ```ts -// [模板] 注册钩子:new 一个钩子实例,挂到关心的主对象上 +// 注册钩子:new 一个钩子实例,挂到关心的主对象上 state.hero.attribute.addHook(new MyHeroAttributeHooks(state)); ``` @@ -231,17 +230,13 @@ calculate(handler) { - 把一次性动作硬做成声明式。玩家走一步就是现在发生,硬包成「声明规则等引擎调用」只会增加复杂度,还容易漏记录像。 - 只改模板却忘记刷新。正常流程下引擎会自动重建;只有绕过正常流程直接改怪物对象时,才需要手动标记那只怪物待重算。 -## 怎么验证 +## 验证 -声明式写对了,最明显的特征是:只改声明,不改调用代码,结果就会跟着变。可以按下面三项检查。 +声明式写对了,最明显的特征是:只改声明,不改调用代码,结果就会跟着变。 -第一项:改一处声明,观察结果是否跟着变。 +先试一次最直接的检查:在 `src/content/enemy.jsonc` 里把某只怪物的光环参数改大(例如把 `atkBuff` 从 20 改成 50),不要动 `aura.ts`,也不要改任何注册代码。重新进入楼层或触发一次重建,再看这只怪物周围的怪物属性:攻击加成应该按新参数变化。如果没变,先确认你改的是数据里的 `special` 字段,而不是另写了一段命令式脚本。 -在 `src/content/enemy.jsonc` 里把某只怪物的光环参数改大(例如把 `atkBuff` 从 20 改成 50),不要动 `aura.ts`,也不要改任何注册代码。重新进入楼层或触发一次重建,再看这只怪物周围的怪物属性:攻击加成应该按新参数变化。如果没变,先确认你改的是数据里的 `special` 字段,而不是另写了一段命令式脚本。 - -第二项:把计算器连续调用两次,确认结果一致。 - -用同一份输入调用两次,`damage` 与 `turn` 应该完全相同,并且 flag、怪物属性、勇士属性都不应发生变化。可以用下面的方式取到同一个 handler 后连算两次: +接着检查计算器有没有副作用:用同一份输入调用两次,`damage` 与 `turn` 应该完全相同,并且 flag、怪物属性、勇士属性都不应发生变化。可以取到同一个 handler 后连算两次: ```ts // [验证] 同一个 handler 连续计算两次,结果应完全一致 @@ -260,9 +255,7 @@ if (system && info) { 把 `(0, 0)` 换成场上有怪物的坐标;那里没有怪物时,`getEnemyByLoc` 会返回 `null`。如果两次结果不同,或者第二次算完之后状态多出了东西,说明计算器里有副作用,回到 `calculate` 里检查有没有写 flag、删记录或改对象。 -第三项:存档后改状态,再读档,确认恢复。 - -存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,`state.loadState(snapshot, compression)` 用同一份映射恢复。最外层的调用就是这样一对: +最后检查存档与读档。两个入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,`state.loadState(snapshot, compression)` 用同一份映射恢复: ```ts // [验证] 存档 → 改状态 → 读档 diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md index 95de068..44c13bb 100644 --- a/docs/advanced/hooks-in-depth.md +++ b/docs/advanced/hooks-in-depth.md @@ -68,19 +68,19 @@ 构造钩子时把目标对象保存为字段,注册时只注册到那个对象上。以图层为例,构造时保存 `readonly layer`: ```ts -// [模板] 每层一个实例的图层钩子 +// 每层一个实例的图层钩子 export class MyLayerHooks implements IMapLayerHooks { constructor( readonly state: ICoreState, readonly layer: IMapLayer ) {} - // [需改] 这个方法没有 layer 参数;触发者就是注册时的 this.layer + // 这个方法没有 layer 参数;触发者就是注册时的 this.layer onUpdateBlock(block: number, x: number, y: number): void { // 只在这个图层上做事 } - // [需改] 动态图块钩子会回传 tile,可以再核对一次 + // 动态图块钩子会回传 tile,可以再核对一次 onCreateDynamic(tile: IDynamicTile): void { if (tile.layer !== this.layer) return; // 只处理目标图层上的动态图块 @@ -95,7 +95,7 @@ export class MyLayerHooks implements IMapLayerHooks { 如果钩子是通用的、构造时还拿不到图层,可以利用 `awake` 收到的控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`: ```ts -// [模板] 在 awake 里取得所属图层 +// 在 awake 里取得所属图层 awake(controller: IMapLayerHookController): void { // controller.layer 就是注册这个钩子的图层 } @@ -110,7 +110,7 @@ awake(controller: IMapLayerHookController): void { 例如地图级钩子 `IGameMapHooks.onResizeLayer(layer, width, height)` 会收到该地图所有图层的 resize。共享实例时,构造时保存目标图层 `targetLayer`: ```ts -// [模板] 共享实例:在回调里比较目标图层 +// 共享实例:在回调里比较目标图层 export class MyMapResizeHooks implements IGameMapHooks { constructor( readonly state: ICoreState, @@ -118,7 +118,7 @@ export class MyMapResizeHooks implements IGameMapHooks { ) {} onResizeLayer(layer: IMapLayer, width: number, height: number): void { - // [需改] 只处理目标图层;其他图层直接返回 + // 只处理目标图层;其他图层直接返回 if (layer !== this.targetLayer) return; this.state.emitTip(`目标图层变为 ${width} x ${height}`); @@ -169,7 +169,7 @@ onResizeLayer(layer: IMapLayer): void { 为多个对象创建实例时,用一个工厂把「创建 + 注册」收在一起,避免漏注册或注册到错误对象: ```ts -// [模板] 为每个图层各建一个实例 +// 为每个图层各建一个实例 function addLayerHook(state: ICoreState, layer: IMapLayer) { return layer.addHook(new MyLayerHooks(state, layer)); } @@ -184,7 +184,7 @@ function addLayerHook(state: ICoreState, layer: IMapLayer) { `addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器: ```ts -// [模板] 注册并保留控制器 +// 注册并保留控制器 const hook = new MyLayerHooks(state, layer); const controller = layer.addHook(hook); ``` @@ -277,92 +277,88 @@ layer.addHook(hook); ## 示例:只处理指定图层 -目标:只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。 - -改哪里:在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中;下面的片段省略了外层函数或方法,`return` 需要放在函数体内。 - -注意:`afterLoad` 时 `floor-1` 未必已经由加载流程构造出来(取决于你的内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`。片段里的 `if (!map) return` 只是让代码能编译;实际使用时不要这样静默跳过——至少用 `logger.warn` 或 `emitTip` 报出来,或者把注册推迟到楼层创建之后再执行。 - -复制什么: +假设要只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。做法是写一个图层钩子类,把目标图层保存在构造参数里;由于 `onUpdateBlock` 没有 layer 参数,身份由「注册到哪个图层」决定。下面的类放在新文件里,顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IMapLayer, IMapLayerHooks } from '@user/data-base';`: ```ts -// [需改] 类名、提示内容 -class GroundBlockHooks implements IMapLayerHooks { +export class GroundBlockHooks implements IMapLayerHooks { constructor( readonly state: ICoreState, + // 注册时传入的目标图层;这个实例只服务它 readonly layer: IMapLayer ) {} onUpdateBlock(block: number, x: number, y: number): void { + // 只在目标图层上做事;onUpdateBlock 没有 layer 参数,触发者必然是 this.layer this.state.emitTip(`ground 图层更新:(${x}, ${y}) -> ${block}`); } } - -// [模板] 取楼层、取图层、注册 -const map = state.maps.getMap('floor-1'); -if (!map) return; -const layer = [...map.layerList].find(l => l.alias === 'ground'); -if (!layer) return; - -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`。 +然后在能拿到 `state` 与目标图层的时机注册,例如插件的 `afterLoad` 中。这里的 `'floor-1'` 换成你的楼层 id,`'ground'` 换成目标图层 alias: -改什么: +```ts +const map = state.maps.getMap('floor-1'); +// afterLoad 时楼层未必已经构造出来,不要静默跳过 +if (!map) { + state.emitTip('floor-1 尚未构造,钩子未注册'); + return; +} +const layer = [...map.layerList].find(l => l.alias === 'ground'); +if (!layer) { + state.emitTip('ground 图层不存在'); + return; +} -- `'floor-1'` 换成你的楼层 id; -- `'ground'` 换成目标图层 alias; -- 提示内容换成你的处理; -- `layer.addHook(hook)` 的目标必须是 `layer`,不是 `map`。 +// 注册到 layer,不是 map;保存控制器以便之后卸载 +const controller = layer.addHook(new GroundBlockHooks(state, layer)); +``` -怎么验证: +`afterLoad` 时 `floor-1` 未必已经由加载流程构造出来(取决于你的内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`。上面的提示只是示例,实际使用时至少要报出来,或者把注册推迟到楼层创建之后再执行。 -1. 对目标图层调用 `layer.setBlock(1, 0, 0)`,换一个与原来不同的图块数字,提示应出现; -2. 从 `map.layerList` 里取另一个图层,调用它的 `setBlock(1, 0, 0)`,提示不应出现; -3. 调用 `controller.unload()`,再改目标图层,提示不应再出现。 +注册后可以这样确认隔离:对目标图层调用 `layer.setBlock(1, 0, 0)`(换一个与原来不同的图块数字),提示应出现;从 `map.layerList` 里取另一个图层,调用它的 `setBlock(1, 0, 0)`,提示不应出现;调用 `controller.unload()` 后再改目标图层,提示不应再出现。 -注意:`onUpdateBlock` 只在图块数字真的变化时触发;设置成相同数字不会触发。不要用 `alias` 当身份,也不要把同一个钩子实例注册到所有图层。 +`onUpdateBlock` 只在图块数字真的变化时触发;设置成相同数字不会触发。不要用 `alias` 当身份,也不要把同一个钩子实例注册到所有图层。 ## 示例:地图级钩子里筛图层 -目标:在地图级 `onResizeLayer` 里只处理目标图层。地图级钩子会收到该地图所有图层的尺寸变化,必须自己筛。 - -改哪里:在能拿到 `map` 与目标图层的时机注册;这次注册到 `map`,不是 `layer`。同样注意 `floor-1` 可能尚未构造,`getMap` 返回 `null` 时不要静默 `return`,至少打一条提示。 - -复制什么: +换一个层级:地图级钩子 `IGameMapHooks.onResizeLayer` 会收到该地图所有图层的尺寸变化,因此必须在回调里自己筛。下面的类同样把目标图层固定为字段,但注册到 `map`: ```ts -// [需改] 类名与处理逻辑 -class TargetLayerResizeHooks implements IGameMapHooks { +export class TargetLayerResizeHooks implements IGameMapHooks { constructor( readonly state: ICoreState, readonly targetLayer: IMapLayer ) {} onResizeLayer(layer: IMapLayer, width: number, height: number): void { + // 只处理目标图层;其它图层直接返回 if (layer !== this.targetLayer) return; this.state.emitTip(`目标图层尺寸:${width} x ${height}`); } } +``` -// [模板] 取目标图层并注册到地图 +这个新文件顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IGameMapHooks, IMapLayer } from '@user/data-base';`。注册代码同样放在能拿到 `state` 的时机方法里,这次注册到 `map`,不是 `layer`: + +```ts const map = state.maps.getMap('floor-1'); -if (!map) return; +if (!map) { + state.emitTip('floor-1 尚未构造,钩子未注册'); + return; +} const targetLayer = [...map.layerList].find(l => l.alias === 'ground'); -if (!targetLayer) return; +if (!targetLayer) { + state.emitTip('ground 图层不存在'); + return; +} +// 比较必须用对象引用,不能用 layer.alias map.addHook(new TargetLayerResizeHooks(state, targetLayer)); ``` -这个新文件顶部需要 `import { ICoreState } from '@user/data-state';` 与 `import { IGameMapHooks, IMapLayer } from '@user/data-base';`。 +调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)即可验证:该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸;把 `targetLayer` 换成另一个图层,提示应对应另一层。 -改什么:楼层 id、图层 alias、比较字段与提示内容。比较必须用 `layer !== this.targetLayer`,不要用 `layer.alias`。 - -怎么验证:调用 `map.resizeLayer(30, 30, true)`(换一个与当前不同的尺寸)。该地图的每个图层都会触发一次 `onResizeLayer`,但提示只应出现一次,且宽高是目标图层的新尺寸。把 `targetLayer` 换成另一个图层,提示应对应另一层。 - -注意:`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器;`onChangeBackground` 没有地图参数,跨地图共享实例时无法判断是谁的背景变化。地图级钩子建议每张地图一个实例,除非你只使用带身份参数的方法。 +`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器;`onChangeBackground` 没有地图参数,跨地图共享实例时无法判断是谁的背景变化。地图级钩子建议每张地图一个实例,除非你只使用带身份参数的方法。 ## 改内置还是自己写 @@ -382,15 +378,15 @@ map.addHook(new TargetLayerResizeHooks(state, targetLayer)); - 在 awake 里等自己触发的事件。`awake` 调用时钩子还没加入已加载列表,`forEachHook` 不会遍历到它。初始同步直接在 `awake` 里做。 - 移除时 new 了一个新实例。`removeHook` 按引用查找,新实例不会匹配,也不会报错;保存注册时的实例或控制器。 -## 怎么验证 +## 验证 -第一项:确认图层隔离。按「只处理指定图层」注册后,分别修改目标图层与另一个图层。目标图层应有提示,另一个图层不应有。把钩子注册到另一个图层,现象应对调。 +确认图层隔离:按「只处理指定图层」注册后,分别修改目标图层与另一个图层。目标图层应有提示,另一个图层不应有;把钩子注册到另一个图层,现象应对调。 -第二项:确认地图级筛选。按「地图级钩子里筛图层」注册后,调用 `map.resizeLayer` 改一次尺寸。地图级钩子会对每个图层触发,但你的处理只应对目标图层生效。把比较条件删掉,提示会变成每个图层一次——这能反过来证明筛选确实在起作用。 +确认地图级筛选:按「地图级钩子里筛图层」注册后,调用 `map.resizeLayer` 改一次尺寸。地图级钩子会对每个图层触发,但你的处理只应对目标图层生效。把比较条件删掉,提示会变成每个图层一次——这能反过来证明筛选确实在起作用。 -第三项:确认移除生效。保存 `addHook` 返回的控制器,调用 `controller.unload()`,再触发同一事件,处理不应再执行。也可以改用 `removeHook(原实例)`,效果应一致;传一个新实例则不应生效。 +确认移除生效:保存 `addHook` 返回的控制器,调用 `controller.unload()`,再触发同一事件,处理不应再执行。也可以改用 `removeHook(原实例)`,效果应一致;传一个新实例则不应生效。 -第四项:确认异步完成顺序不等于注册顺序。注册两个都实现 `onStepEnd` 的异步钩子:第一个内部延迟 100ms,第二个不延迟。故意把慢的注册在前面,移动一步后,快的应先提示。这个现象只说明 `Promise.all` 并发等待,不说明引擎按注册顺序执行。 +确认异步完成顺序不等于注册顺序:注册两个都实现 `onStepEnd` 的异步钩子,第一个内部延迟 100ms,第二个不延迟。故意把慢的注册在前面,移动一步后,快的应先提示。这个现象只说明 `Promise.all` 并发等待,不说明引擎按注册顺序执行。 ## 小结 diff --git a/docs/advanced/index.md b/docs/advanced/index.md index 5b184b1..e44379a 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -75,7 +75,7 @@ - 高级 III 的原理与架构:双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试。 - API 参考:接口签名与类型清单不属于教程正文。 -## 怎么验证你读懂了 +## 读完这一层应该能做什么 - 能用自己的话解释声明式与命令式的区别,并说出一个「仍然该用命令式」的场景。 - 能拿到一个需求,判断它该做成插件、改内置,还是只需补一条内容定义。 diff --git a/docs/advanced/plugin-system.md b/docs/advanced/plugin-system.md index b1f2b65..9c2071c 100644 --- a/docs/advanced/plugin-system.md +++ b/docs/advanced/plugin-system.md @@ -38,13 +38,13 @@ 最小实现如下。方法里用不到的参数可以省略,现有的 `DataShopPlugin` 就只收了 `state`: ```ts -// [模板] 一个最小插件:只有 id、dependencies 和三个时机方法 +// 一个最小插件:只有 id、dependencies 和三个时机方法 export class MyPlugin implements IDataPluginDefine { readonly id = 'my-plugin'; readonly dependencies: string[] = []; awake(state: ICoreState): void { - // [需改] 在这里装配插件自己的对象 + // 在这里装配插件自己的对象 } async initialize(state: ICoreState): Promise {} @@ -67,13 +67,13 @@ export class MyPlugin implements IDataPluginDefine { ```ts import { IDataPluginDefine } from '@user/data-state'; import { DataShopPlugin } from './shop'; -// [需改] 新建插件文件时,必须补上你自己的 import +// 新建插件文件时,必须补上你自己的 import import { MyPlugin } from './my-plugin'; /** 数据端插件列表 */ export const dataPlugins: IDataPluginDefine[] = [ new DataShopPlugin(), - // [需改] 追加到末尾,不要替换上面的已有插件 + // 追加到末尾,不要替换上面的已有插件 new MyPlugin() ]; ``` @@ -169,7 +169,7 @@ load() 开始 声明了依赖,最直接的方式就是用方法参数里的插件对象。注意它是 `IDataPlugin` 包装对象,你要取 `.plugin`: ```ts -// [需改] 插件 B 依赖插件 A,并在初始化时调用 A 的方法 +// 插件 B 依赖插件 A,并在初始化时调用 A 的方法 export class PluginB implements IDataPluginDefine { readonly id = 'plugin-b'; readonly dependencies = ['plugin-a']; @@ -178,7 +178,7 @@ export class PluginB implements IDataPluginDefine { state: ICoreState, dependencies: IDataPlugin[] ): void { - // [需改] 参数顺序与 dependencies 数组一致 + // 参数顺序与 dependencies 数组一致 const a = dependencies[0]?.plugin as PluginA | undefined; if (!a) return; a.doSomething(); @@ -199,7 +199,7 @@ export class PluginB implements IDataPluginDefine { 没有声明依赖、或者在启动之后才按需访问时,用 `state.getDataPlugin(id)`。它返回 `Readonly> | null`: ```ts -// [需改] 取回插件,先判空,再确认状态 +// 取回插件,先判空,再确认状态 const holder = state.getDataPlugin('plugin-a'); if (holder && holder.status === PluginStatus.Loaded) { holder.plugin.doSomething(); @@ -255,32 +255,27 @@ if (holder && holder.status === PluginStatus.Loaded) { 这是少数应该新建文件与目录的场景:插件本身就是一个独立单元,把它的类、接口和对象放在一起,比全部塞进 `initialize.ts` 更容易维护。除此之外的普通效果直接写在对应的内置实现文件里,不要为每个效果新建目录。 -## 模板:一个插件的最小骨架 +## 示例:一个完整插件 -目标:新建一个插件,能被启动流程加载,并拥有自己的对象与存档内容。 - -改哪里:在 `src/data/plugin/` 下新建一个目录,放入 `plugin.ts` 与 `index.ts`;再改 `src/data/plugin/list.ts`。 - -复制什么:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是任何一个具体插件,商店插件只是示例,不要拿它当模板。 +假设要新建一个插件:它要能被启动流程加载,并拥有自己的对象与存档内容。插件放在 `src/data/plugin/` 下自己的目录里,下面这份代码写在新建的 `plugin.ts` 中,顶部的 `import` 要一起保留: ```ts -// [模板] 插件通用骨架:复制后按 [需改] 修改 import { ICoreState, IDataPlugin, IDataPluginDefine } from '@user/data-state'; -// [需改] 把 MyPlugin 换成你的插件类名 +// 插件类:实现 IDataPluginDefine 的五个成员 export class MyPlugin implements IDataPluginDefine { - // [需改] 插件 id:一旦发布就不要改,其他插件可能依赖它 + // 插件 id:全局唯一,发布后不要改,其他插件可能依赖它 readonly id = 'my-plugin'; - // [需改] 依赖的插件 id;没有依赖就留空数组 + // 依赖的插件 id 字符串数组;没有依赖就留空 readonly dependencies: string[] = []; - // [需改] 插件自己的对象;没有就删掉这一行 + // 插件自己的对象;暂时没有就删掉这一行以及 awake 里对应的装配 mySystem!: MySystem; awake(state: ICoreState): void { - // [需改] 装配对象,不要在这里 await + // 装配插件自己的对象;这里不要 await this.mySystem = new MySystem(state); - // [需改] 有需要存档的数据时注册;这里是存档 id,不是插件 id + // 有需要存档的数据时注册;这里是存档 id,不是插件 id state.addSaveableContent('@plugin/my-plugin', this.mySystem); } @@ -288,29 +283,45 @@ export class MyPlugin implements IDataPluginDefine { state: ICoreState, dependencies: IDataPlugin[] ): Promise { - // [需改] 加载开始前要做的事;可以 await,但会阻塞加载 + // 加载开始前要做的事;可以 await,但会阻塞加载 } async afterLoad( state: ICoreState, dependencies: IDataPlugin[] ): Promise { - // [需改] 需要读取加载内容时写在这里 + // 需要读取加载内容时写在这里 } } ``` -这段是新建文件,顶部的 `import` 必须一起保留。`MySystem` 是你自己写的类,文档不会替你定义:先按你的需求写出这个类;暂时没有对应对象时,把 `mySystem` 字段、`new MySystem(state)` 以及相关的 `addSaveableContent` 一起删掉,否则编译器会报「找不到名称 MySystem」。 +`MySystem` 是你自己写的类,文档不会替你定义。先按需求写出这个类;暂时没有对应对象时,把 `mySystem` 字段、`new MySystem(state)` 以及相关的 `addSaveableContent` 一起删掉,否则编译器会报「找不到名称 MySystem」。 -改什么: +写好后,把插件追加到 `src/data/plugin/list.ts` 的 `dataPlugins` 数组末尾,并补上新建文件的 `import`: -1. 类名与 id:`MyPlugin` 与 `my-plugin` 换成你的名字,`id` 全局唯一。 -2. dependencies:填依赖插件的 id 字符串;没有就保持空数组,不要填插件对象。 -3. awake 里的装配:换成你自己的对象。需要进存档的对象必须实现 `ISaveableContent`(`saveState` / `loadState`),细节见[可存档内容](./saveable-content.md)。 -4. 存档 id:`@plugin/my-plugin` 换成你自己的存档 id,与插件 id 分开命名。 -5. initialize / afterLoad:用不到就留空方法;用到时按需加逻辑。 +```ts +import { IDataPluginDefine } from '@user/data-state'; +import { DataShopPlugin } from './shop'; +// 新建插件文件时补上这一行;改已有插件则复用它原有的 import +import { MyPlugin } from './my-plugin'; -注意:`awake` 里的异步工作不会被等待;`initialize` 与 `afterLoad` 里启动的异步任务必须 `await`,否则阶段会提前结束。注册时只改 `list.ts` 的数组,不要另外找地方调用插件方法。 +/** 数据端插件列表 */ +export const dataPlugins: IDataPluginDefine[] = [ + new DataShopPlugin(), + // 追加到末尾,不要替换上面的已有插件 + new MyPlugin() +]; +``` + +只把数组写成 `[new MyPlugin()]` 会把商店插件挤掉。注册进 `dataPlugins` 只是第一步:`awake` 里的异步工作不会被等待;`initialize` 与 `afterLoad` 里启动的异步任务必须 `await`,否则阶段会提前结束。注册位置只有 `list.ts` 的数组,不要另外找地方调用插件方法。 + +需要定制时,通常只改这几处: + +- 类名与 id:`MyPlugin` 与 `my-plugin` 换成你的名字,`id` 全局唯一。 +- dependencies:填依赖插件的 id 字符串;没有就保持空数组,不要填插件对象。 +- awake 里的装配:换成你自己的对象。需要进存档的对象必须实现 `ISaveableContent`(`saveState` / `loadState`),细节见[可存档内容](./saveable-content.md)。 +- 存档 id:`@plugin/my-plugin` 换成你自己的存档 id,与插件 id 分开命名。 +- initialize / afterLoad:用不到就留空方法;用到时按需加逻辑。 ## 示例:读一个现有插件 @@ -407,11 +418,9 @@ async afterLoad(state: ICoreState): Promise { - 直接改其他系统的内部字段。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。 - 重复注册同一个 id。后一个会被忽略并报错(`78`),你以为生效的插件其实没有进注册表。 -## 怎么验证 +## 验证 -第一项:确认三个时机按顺序发生。 - -在你插件的三个方法里各放一条可观察信号,例如: +想确认三个时机按顺序发生,可以在插件的三个方法里各放一条可观察信号: ```ts // [验证] 用提示确认时机顺序 @@ -430,9 +439,7 @@ async afterLoad(state: ICoreState): Promise { 把插件加进 `dataPlugins` 后启动,三条提示应依次出现:`awake` 最早,`initialize` 在加载开始前,`afterLoad` 在加载结束后。用控制台输出也可以,只要顺序能被观察到。 -第二项:确认插件已经能被取回。 - -在启动完成后执行: +启动完成后,用 `state.getDataPlugin` 取回插件,确认它已经进了注册表: ```ts // [验证] 取回插件并检查状态 @@ -442,9 +449,7 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin); `status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。 -第三项:存档后读档,确认插件注册的内容恢复。 - -存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,`state.loadState(snapshot, compression)` 再用同一份映射恢复。把下面几行放进一个能拿到 `state` 的临时函数执行一次,就是最外层的「存档 → 改 → 读档」: +要确认插件注册的存档内容能恢复,先存档,再改动它,然后读档。两个入口同样在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,`state.loadState(snapshot, compression)` 再用同一份映射恢复。把下面几行放进一个能拿到 `state` 的临时函数执行一次: ```ts // [验证] 存档,改掉可观察值,再读档;类名与路径换成你自己的 @@ -453,7 +458,7 @@ import { MyPlugin } from './plugin/my-plugin'; const plugin = state.getDataPlugin('my-plugin')?.plugin; if (plugin) { - // [需改] store 换成你插件里实际注册的可存档对象 + // store 换成你插件里实际注册的可存档对象 plugin.store.setValue('count', 1); const snapshot = state.saveState(SaveCompression.NoCompression); @@ -463,13 +468,9 @@ if (plugin) { } ``` -这里假设插件用[可存档内容](./saveable-content.md)里的 `MySaveable` 作为注册对象,它带有 `setValue` / `getValue`;换成你自己的对象时,把 `plugin.store` 与这两个方法一起替换掉。 +这里假设插件用[可存档内容](./saveable-content.md)里的 `MySaveable` 作为注册对象,它带有 `setValue` / `getValue`;换成你自己的对象时,把 `plugin.store` 与这两个方法一起替换掉。读档后应回到存档时的值。没恢复时依次检查:对象有没有实现 `ISaveableContent`、有没有在 `awake` 里用 `state.addSaveableContent` 注册、`saveState` 返回的数据里有没有漏字段。完整的验证写法(含三档压缩与修改映射)见[可存档内容](./saveable-content.md)的「验证」一节。 -让插件注册的可存档内容进入一个可观察状态(例如改一个计数),存档,再改动它,然后读档。读档后应回到存档时的值。没恢复时依次检查:对象有没有实现 `ISaveableContent`、有没有在 `awake` 里用 `state.addSaveableContent` 注册、`saveState` 返回的数据里有没有漏字段。完整的验证写法(含三档压缩与修改映射)见[可存档内容](./saveable-content.md)的「怎么验证」。 - -第四项:故意把依赖写错一次。 - -把 `dependencies` 里的某个 id 改成一个不存在的名字,重新启动。此时应出现两条现象:控制台报缺失依赖错误(错误码 `77`,带插件 id 与缺失的依赖 id),你的插件没有任何时机输出。改回正确 id 后应恢复正常。这一步能让你亲眼看到「阻断」不是只影响被写错的插件。 +最后可以故意把 `dependencies` 里的某个 id 改成一个不存在的名字,重新启动。此时应出现两条现象:控制台报缺失依赖错误(错误码 `77`,带插件 id 与缺失的依赖 id),你的插件没有任何时机输出。改回正确 id 后应恢复正常。这一步能让你亲眼看到「阻断」不是只影响被写错的插件。 ## 小结 diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md index 587ab41..fde5ab7 100644 --- a/docs/advanced/replay-consistency.md +++ b/docs/advanced/replay-consistency.md @@ -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` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「怎么验证」里的方式手动开一次区间。生产环境目前也没有自动开启收集区间的开关;如果要在实际游玩中检查,只能在自定义的调试流程或录像入口里,自己把一段操作包在 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 之间。 +所以本篇描述的是「收集区间开启时」的检测口径:区间开着,两个装饰器才有意义;区间没开,`@shouldReplay` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「验证」一节里的方式手动开一次区间。生产环境目前也没有自动开启收集区间的开关;如果要在实际游玩中检查,只能在自定义的调试流程或录像入口里,自己把一段操作包在 `beginReplaySafetyCollection` / `endReplaySafetyCollection` 之间。 ### 边界二:读档期间录像被禁用 @@ -145,24 +145,18 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0); ## 给一个内置方法补录像标记 -这一节按六步给出可直接照做的改法。 +假设要让 `FlagSystem.getOrInsert` 这个会改变状态的入口在收集区间里被登记出来。它位于 `packages-user/data-base/src/flag/system.ts`;同一个文件里的 `setField` 与 `deleteField` 都已经有标记,`getOrInsert` 还没有——它在字段不存在时会新建一条 Flag 字段,同样会改变状态,正好用来观察「加标记 / 去掉标记」的差别。 -目标:让一个会改变状态的方法入口,在收集区间里能被登记出来,并亲眼看到「有标记」与「没标记」的差别。 - -改哪里:`packages-user/data-base/src/flag/system.ts`,`FlagSystem.getOrInsert` 的上方。这个文件里 `setField` 与 `deleteField` 都已经有标记;`getOrInsert` 还没有,而它在字段不存在时会新建一条 Flag 字段,同样会改变状态,正好用来观察「加标记 / 去掉标记」的差别。 - -复制什么:只复制装饰器那一行,粘在方法声明的上一行: +标记就是方法声明上方的一行装饰器。`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不需要再引入: ```ts -// [需改] 只新增这一行 -@shouldReplay('[需改] 这个方法改变了什么状态') +// 加在 FlagSystem.getOrInsert 的上一行;消息写清这个方法改变了什么状态 +@shouldReplay('Flag field inserted should be replayed.') ``` -这里不含 `import`:`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不要再粘一次。若你换到别的文件、编辑器报缺少 `shouldReplay`,再在顶部补这一行。 +若你换到别的文件、编辑器报缺少 `shouldReplay`,再在顶部补这一行 import。消息文本只写给人看,语言不影响行为,写清意思即可。 -改什么:把消息文本改成这个方法实际改变了什么。例如可以写 `Flag field inserted should be replayed.`;语言本身不影响行为,写清意思即可。 - -怎么验证:手动开一次收集区间,看告警里有没有这条路径。这几个函数不在 `state` 上,需要引入(只用于调试,平时不需要): +手动开一次收集区间,就能看到这条路径有没有被登记。这几个函数不在 `state` 上,需要从 `@user/data-common` 引入(只用于调试,平时不需要): ```ts import { @@ -171,15 +165,14 @@ import { } from '@user/data-common'; beginReplaySafetyCollection(state.replaySystem); +// 调用一个会改状态、但不会产生录像步的方法 state.flags.getOrInsert('replay-check', 1); endReplaySafetyCollection(); ``` -- 区间内没有产生任何新录像步,`getOrInsert` 又带着标记,所以控制台应出现告警(错误码 `161`),列表里能看到 `getOrInsert: ...` 这一条; -- 把 `getOrInsert` 上的标记注释掉再跑一次,告警会消失。这不是修好了,而是检查器看不见这个入口了——这正是「漏标记比漏记录更难查」的原因。 -- 两个过程都不会产生录像步:标记只登记路径,真正的记录仍要 `record` 或内置录制入口。 +区间内没有产生任何新录像步,`getOrInsert` 又带着标记,所以控制台应出现告警(错误码 `161`),列表里能看到 `getOrInsert: ...` 这一条。把 `getOrInsert` 上的标记注释掉再跑一次,告警会消失——这不是修好了,而是检查器看不见这个入口了,这也正是「漏标记比漏记录更难查」的原因。两个过程都不会产生录像步:标记只登记路径,真正的记录仍要 `record` 或内置录制入口。 -注意:只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。不要选一个内部已经调用了带标记方法的方法来做这个练习——那样即使去掉外层标记,里层仍会登记路径,告警不会消失。 +标记只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。做这个练习时不要选一个内部已经调用了带标记方法的方法——那样即使去掉外层标记,里层仍会登记路径,告警不会消失。 ## 改内置还是自己写 @@ -201,27 +194,17 @@ endReplaySafetyCollection(); - 把装饰器写在普通函数上。它只支持类的方法。 - 以为没告警就是没问题。收集区间没开、或者入口压根没标记,都不会有告警。 -## 怎么验证 +## 验证 -第一项:开一次收集区间,看登记路径。 +开一次收集区间,就能看到登记路径:`beginReplaySafetyCollection` → 调用一个带标记的方法 → `endReplaySafetyCollection`。控制台应给出告警(`161`)和路径列表;粘上告警里给出的 `logReplaySafetyDetail(n)` 命令,应打印出完整嵌套路径。 -用上一节的三行代码,`beginReplaySafetyCollection` → 调用一个带标记的方法 → `endReplaySafetyCollection`。控制台应给出告警(`161`)和路径列表;粘上告警里给出的 `logReplaySafetyDetail(n)` 命令,应打印出完整嵌套路径。 +随后把同一个方法上的标记注释掉再跑一次。告警消失说明检查器「看不见」这个入口,而不是问题解决了——这也是为什么标记必须加在正确的层。 -第二项:去掉标记,确认告警消失。 +再让区间内产生一条录像步:在区间内调用一个真正会记录录像的方法(例如走一步、使用一个道具),再结束收集。此时不应告警,因为检查只关心「有没有新录像步」,不关心是哪一条。 -把同一个方法上的标记注释掉再跑一次。告警消失说明检查器「看不见」这个入口,而不是问题解决了——这也是为什么标记必须加在正确的层。 +参数与顺序需要自己核对。装饰器不会替你检查这些:录一段包含目标操作的录像,回放后对比关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。 -第三项:让区间内产生一条录像步,确认检查通过。 - -在区间内调用一个真正会记录录像的方法(例如走一步、使用一个道具),再结束收集。此时不应告警,因为检查只关心「有没有新录像步」,不关心是哪一条。 - -第四项:自己核对参数与顺序。 - -装饰器不会替你检查这些。录一段包含目标操作的录像,回放后对比关键数据:位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。 - -第五项:确认读档不产生录像。 - -存档、读档,然后检查录像长度有没有变化。`loadState` 期间的记录被禁用,长度应当不变;若有变化,检查是不是在绑定的执行器里做了会记录的操作。 +最后确认读档不产生录像:存档、读档,然后检查录像长度有没有变化。`loadState` 期间的记录被禁用,长度应当不变;若有变化,检查是不是在绑定的执行器里做了会记录的操作。 ## 小结 diff --git a/docs/advanced/saveable-content.md b/docs/advanced/saveable-content.md index f16f0cc..f856992 100644 --- a/docs/advanced/saveable-content.md +++ b/docs/advanced/saveable-content.md @@ -196,7 +196,7 @@ export interface ISaveableExecutor { 正确做法是存 id 或键,读档时再解析。常见的键有楼层 `floorId`、商店 id、可存档 id,以及你自己维护的稳定 `uid`。例如一个任务系统要记住「目标商店」: ```ts -// [需改] 引用方:存档里只存目标 id,不存 IShopState 对象 +// 引用方:存档里只存目标 id,不存 IShopState 对象 export interface IQuestSave { readonly targetShopId: string; } @@ -206,7 +206,7 @@ export class QuestSaveable implements ISaveableContent { constructor(private readonly state: ICoreState) {} - /** [需改] 在你的任务逻辑里调用,记录目标商店 */ + /** 在你的任务逻辑里调用,记录目标商店 */ setTargetShop(id: string): void { this.targetShopId = id; } @@ -238,7 +238,7 @@ export class QuestSaveable implements ISaveableContent { 引用者自己的注册与绑定如下,顺序不能反: ```ts -// [需改] 先注册引用者,再绑定执行器 +// 先注册引用者,再绑定执行器 state.addSaveableContent('@plugin/example-quest', this.quest); state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor()); ``` @@ -246,58 +246,53 @@ state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor()); 执行器里按 `@plugin/shop` 取回商店管理器。`getSaveableContent` 返回的是 `ISaveableContent`,要调用管理器自己的 `getShop`,需要断言成 `IShopManager`: ```ts -// [需改] 读档后按 id 解析目标商店 +// 读档后按 id 解析目标商店 export class QuestSaveExecutor implements ISaveableExecutor { afterLoad(data: IQuestSave, state: IStateBase): void { const manager = state.getSaveableContent( '@plugin/shop' ) as IShopManager | null; const shop = manager?.getShop(data.targetShopId) ?? null; - // [需改] 找不到时怎么处理,由你决定;不要假设它一定存在 + // 找不到时怎么处理,由你决定;不要假设它一定存在 } } ``` 执行器文件还要从 `../shop` 引入 `IShopManager` / `IShopManagerSave`(路径按你的目录调整)。 -## 模板:一个可存档对象的最小骨架 +## 示例:一个可存档对象 -目标:写出一个能被注册、能正确存档与读档的对象,读档后它的内部数据与存档时一致。 - -改哪里:放进你自己的插件目录,通常与插件类放在一起;不需要为它单独新建一套目录。 - -复制什么:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果,下面的示例只演示怎么把它接到插件里。 +假设你要让一组自定义计数跟着存档走。先写出可存档对象:它实现 `ISaveableContent`,`T` 是存档形状。下面这个类可以放在插件目录里(例如 `src/data/plugin/my-plugin/state.ts`),不需要为它单独新建一套目录: ```ts -// [模板] 一个可存档对象的最小骨架:复制后按 [需改] 修改 import { ISaveableContent } from '@user/data-common'; +// 存档形状:只放重建必需的数据,字段类型必须能通过 structuredClone export interface IMySave { - // [需改] 存档形状,只能用 structured-cloneable 的类型 readonly values: Map; } export class MySaveable implements ISaveableContent { - // [需改] 运行时的真实数据;Map 只是常见选择 + // 运行时的真实数据;Map 只是常见选择,换成你真正要保存的结构 private readonly values: Map = new Map(); - // [需改] 对外方法按你的需求增删 + // 对外方法按你的需求增删 setValue(key: string, value: number): void { this.values.set(key, value); } - // [需改] 验证时会用到;不需要就删掉 + // 验证时会用到;不需要就删掉 getValue(key: string): number | undefined { return this.values.get(key); } saveState(): IMySave { - // [需改] 必须交出深拷贝,不能返回 this + // 必须交出深拷贝,不能返回 this,也不能返回带方法的类实例 return { values: structuredClone(this.values) }; } loadState(state: IMySave): void { - // [需改] 先清空旧状态,再按存档重建 + // 先清空旧状态,再按存档重建,避免残留上一次的数据 this.values.clear(); for (const [key, value] of state.values) { this.values.set(key, value); @@ -306,35 +301,27 @@ export class MySaveable implements ISaveableContent { } ``` -改什么: +落笔时按你的数据结构替换 `values` 与 `IMySave` 的字段:只放重建必需的信息,缓存与派生数据不要放;`loadState` 里该 `new` 的类实例要重新 `new`,不要直接使用存档里的普通对象。 -1. 类名与接口名:`MySaveable`、`IMySave` 换成你的名字。 -2. 字段:把 `values` 换成你真正要保存的数据。只放重建必需的信息,缓存与派生数据不要放。 -3. 存档形状:`IMySave` 里列出 `saveState` 返回的字段,并确认每个字段都能被 `structuredClone` 处理。 -4. loadState:按你的数据结构清空并重建。类实例要重新 `new`,不要直接使用存档里的普通对象。 -5. 压缩参数:`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`。实现里不需要区分时,可以省略参数:TypeScript 允许实现接口时少写参数,调用方仍会按接口传两个实参,多出的那个被忽略。需要区分时,再把 `compression: SaveCompression` 加回来。 +压缩参数可以按需省略:`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`,TypeScript 允许实现时少写参数,调用方仍会按接口传两个实参,多出的那个被忽略;需要区分压缩级别时,再把 `compression: SaveCompression` 加回来。 -注意:`saveState` 里不要修改对象状态,也不要返回 `this` 或带方法的类实例。`loadState` 不要假设旧数据还在,先清空再重建,才不会残留上次的状态。 +`saveState` 里不要修改对象状态;`loadState` 不要假设旧数据还在。这两条是这一节里最容易出错的地方。 ## 示例:把可存档对象接到插件里 -目标:让上一节模板里的对象跟着插件一起注册,并能在读档后恢复。 - -改哪里:你插件的 `plugin.ts`。 - -复制什么:只复制持有与注册的那几行: +可存档对象本身不会自动进入存档,还要在插件里持有并注册。下面的代码写在你插件的 `plugin.ts` 里(改已有文件,文件顶部通常已经有 `IDataPluginDefine` / `ICoreState`;若报缺少 `MySaveable`,再补对应 `import`): ```ts -// [需改] 在插件里持有并注册可存档对象 export class MyPlugin implements IDataPluginDefine { readonly id = 'my-plugin'; readonly dependencies: string[] = []; - // [需改] 换成你模板里的可存档对象 + // 持有可存档对象,供插件其他方法使用 store!: MySaveable; awake(state: ICoreState): void { this.store = new MySaveable(); + // 存档 id 要唯一,并与插件 id 分开命名 state.addSaveableContent('@plugin/my-store', this.store); } @@ -344,16 +331,9 @@ export class MyPlugin implements IDataPluginDefine { } ``` -改什么: +`store` 与 `MySaveable` 换成你自己的类;对象构造需要 `state` 时,也在这里传入。注册优先放在 `awake`:对象必须等加载内容才能构造时,也可以放到插件 `afterLoad`,但注册顺序会排在已有对象之后。无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。 -1. 类名与 id:`MyPlugin` 与 `my-plugin` 换成你的插件名,`id` 全局唯一。 -2. 持有字段:`store` 与 `MySaveable` 换成模板里你的类;对象构造需要 `state` 时,也在这里传入。 -3. 存档 id:`@plugin/my-store` 换成你自己的 id,保证唯一,并与插件 id 分开命名。 -4. 注册位置:优先在 `awake` 里注册;对象必须等加载内容才能构造时,可以放到插件 `afterLoad`,但注册顺序会排在已有对象之后。无论放在哪,只注册一次,并保证早于任何存档 / 读档调用。 - -文件顶部通常已经有 `IDataPluginDefine` / `ICoreState`;若报缺少 `MySaveable` 或 `ISaveableContent`,再补对应的 `import`,不要重复粘。 - -怎么验证:启动后把某个键改到一个可观察的值,存档;再改一次,然后读档。读档后应回到存档时的值。如果没恢复,检查 `loadState` 有没有先 `clear()`;如果读档后调用方法报「不是函数」,检查是不是把类实例直接塞进了存档。 +启动后把某个键改到一个可观察的值,存档;再改一次,然后读档。读档后应回到存档时的值。如果没恢复,检查 `loadState` 有没有先 `clear()`;如果读档后调用方法报「不是函数」,检查是不是把类实例直接塞进了存档。 ## 改内置还是写成插件 @@ -376,13 +356,9 @@ export class MyPlugin implements IDataPluginDefine { - 在 saveState 里改状态。存档是只读快照,`saveState` 不应该有副作用;需要整理数据时,先复制再整理。 - 跨对象引用直接存对象。`structuredClone` 会复制成新对象,读档后身份丢失,两个系统各拿一份。 -## 怎么验证 +## 验证 -存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面把验证代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行。 - -第一项:改状态,存档,再改,读档。 - -文件顶部补上两个 `import`,再把下面的代码放进临时函数: +存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面的代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行,文件顶部补上两个 `import`: ```ts // [验证] 存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类 @@ -402,9 +378,7 @@ console.log(plugin.store.getValue('count')); // 应为 1 读档后应回到存档时的值。同时确认没有存的缓存被重新生成,而不是保留了读档前的旧值。 -第二项:三档压缩各跑一次。 - -对同一个对象循环三档: +对同一个对象把三档压缩各跑一次,可以确认三种策略读回来的结果一致: ```ts // [验证] 三档压缩都应恢复出同一个值 @@ -424,9 +398,7 @@ for (const compression of [ 如果只有某一档出错,说明 `saveState` 与 `loadState` 对压缩级别的处理不一致。 -第三项:确认注册成功。 - -在启动完成后调用: +启动完成后还可以直接确认注册成功: ```ts const content = state.getSaveableContent('@plugin/my-store'); @@ -435,9 +407,7 @@ console.log(content); 应拿到你注册的对象,而不是 `null`。为 `null` 时先检查 `addSaveableContent` 的 id 是否拼错、是否真的执行到了 `awake`。 -第四项:故意删掉存档里的一个键。 - -`saveState` 返回的是 `ReadonlyMap`,要删键先复制成可修改的 `Map`: +要确认缺键与多键的告警,可以故意删掉存档里的一个键。`saveState` 返回的是 `ReadonlyMap`,要删键先复制成可修改的 `Map`: ```ts // [验证] 删掉本对象的存档数据再读档,应告警 177 @@ -449,13 +419,7 @@ state.loadState(edited, SaveCompression.NoCompression); 读档时控制台应出现缺键告警(`177`),并跳过该对象,而不是静默通过。存档里多出未注册的 id 时,应出现多键告警(`178`)。这两条能帮你区分「没存进去」和「读的时候没对上」。 -第五项:检查跨对象引用。 - -构造一个引用其他对象的场景,读档后确认引用仍指向正确的对象,而不是 `null` 或另一份副本。若拿到的是旧状态,检查被引用对象的注册顺序,或改成执行器延后解析。 - -第六项:检查执行器时机。 - -在执行器里打印一条日志,确认它在对应对象的 `loadState` 之后立即出现,而不是等所有对象读完才统一出现。如果顺序不对,说明你把执行器当成了全局读档完成钩子。 +跨对象引用的检查需要构造一个引用其他对象的场景:读档后确认引用仍指向正确的对象,而不是 `null` 或另一份副本。若拿到的是旧状态,检查被引用对象的注册顺序,或改成执行器延后解析。执行器时机的检查则是在执行器里打印一条日志:它应在对应对象的 `loadState` 之后立即出现,而不是等所有对象读完才统一出现;如果顺序不对,说明你把执行器当成了全局读档完成钩子。 ## 小结