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