docs(10-20): 修订插件渲染端/Store边界/代码块(用户第3轮)

- plugin-system 补充渲染端插件接口与完整示例,删除排序细节
- data-stores 删除 fromRaw 与缓存更新等阶段性深层说明,边界改为「极少需要自己补定义」
- 全部七篇代码块补成完整类/函数结构
- replay-consistency 保留 logReplaySafetyDetail 查详细信息的调用说明
- index 说明高级教程同时覆盖数据端与渲染端
This commit is contained in:
unanmed 2026-10-21 10:46:55 +08:00
parent 74116b8d67
commit 6351cfea6e
8 changed files with 385 additions and 153 deletions

View File

@ -98,3 +98,35 @@
**构建:** `pnpm exec prettier --write` 后 `--check` 四篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
**状态:已处理**
---
## 第 3 轮
**时间:** 2026-10-20
**用户意见(5 条):**
| # | 意见 | 落点 |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| U3-01 | 插件系统应同时讲**渲染端插件**(渲染端虽未完成,但渲染端插件系统已几乎定稿);不要讲排序细节(作者根本不需要知道,用到该接口的可能性几乎为零) | `plugin-system.md` |
| U3-02 | 明确写出「需要自己补充 Store 定义非常少见」,放进「边界与注意」,并指向后续高级教程;不要写「登记不是创建运行时对象」这类当前阶段不必了解的内容 | `data-stores.md` |
| U3-03 | `MainDataPlugin` 的 `afterLoad` 代码未包裹 class,导致高亮出错;应改成完整结构。之后统一注意:代码块必须是完整结构,不要单独写某个类或成员 | `plugin-system.md`(及全部) |
| U3-04 | 录像一致性里指向 logger 的调用可以保留——教读者如何查看详细信息是必要的;函数调用方式可能会变,之后可能要改 | `replay-consistency.md` |
| U3-05 | 高级教程不只包含数据端,也包含渲染端(只是渲染端未完成、暂时无法写);导览里**不要写「不要求懂渲染端」** | `index.md` |
### 第 3 轮处置
**修改方式:** 内联执行,仅修改 `docs/advanced/*.md` 与本职记录(本轮执行因断电中断后继续完成,未重做前两轮内容)。
| # | 处置 |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| U3-01 | `plugin-system` 新增「渲染端插件接口」小节:按 `packages-user/client-modules/src/types.ts` 的 `IClientPluginDefine` 列出 `id` / `dataDeps` / `clientDeps` 与三个时机参数,给出完整的 `MyClientPlugin` 类与 `src/client/plugin/list.ts`、`src/client/entry.ts`、`state.getClientPlugin` 的注册取用方式;删除「dataDeps 与排序」整节(`orderPlugins` 的 `order` / `cyclic` / `missing` 字段),依赖执行语义并入「状态与顺序」。 |
| U3-02 | `data-stores` 在「边界与注意」首条写明<strong>需要自己补充 Store 定义的情况非常少见</strong>,并指向 `高级 II · 加载与插件生命周期`;删除 `state.maps.fromRaw(raw)` 与「从原始数据到运行时对象只有这一次转换」整段、以及「它不保证更新已经存在的对象……静态图块 / 动态图块缓存」一条(列表重新编号),小结中同源的 `fromRaw` 一句一并删除。 |
| U3-03 | `plugin-system` 的 `MainDataPlugin` 片段补成完整类(`implements IDataMainPlugin`、字段、构造器、`afterLoad` 方法体,其余成员用注释省略);复查七篇代码块,把 `hooks-in-depth` 中两段脱离上下文的注册 / 卸载语句包成完整函数,其余代码块均以 `import`、完整类 / 接口或完整声明开头。 |
| U3-04 | `replay-consistency` 在「录像记录缺失」警告框中保留并说明警告附带的 `Mota.require('@user/data-common').logReplaySafetyDetail(0);` 调用,注明可粘到控制台展开调用路径、调用形式之后可能调整;验证节同步指向该调用。未恢复底层收集机制。 |
| U3-05 | `index.md` 删去「不要求你懂渲染端实现」,改为高级教程同时覆盖数据端与渲染端、渲染端尚未完工所以当前只收录数据端内容;篇目表第 3 行与「本层不覆盖什么」同步该说法。 |
| D-62 | 七篇代码块统一复查:不再出现单独的方法成员、顶层 `return` 或缺失类 / 函数外壳的语句。 |
**构建:** `pnpm exec prettier --write docs/advanced/*.md` 后 `--check` 七篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
**状态:已处理**

View File

@ -72,8 +72,6 @@ Store 在<strong>加载 / 初始化阶段</strong>被填一次,之后整个游
- `getMap(floorId)`:原始地图数据,查不到返回 `null`;
- `addMap(map)`:登记一份原始数据,同一个 `floorId` 直接覆盖,不告警。
这份原始数据不会自己变成可玩的地图。运行时楼层由 `state.maps.fromRaw(raw)` 构造:它按 `width` 与 `map` 算出高度、建图层、铺图块、挂事件。从原始数据到运行时对象只有这一次转换。
### enemyStore:怪物原始数据
以怪物图块数字为键,存 `IEnemyRawData`:`num`、`attribute`(怪物属性)、`special`(特殊属性,键为特殊属性编号、值为参数)。
@ -96,45 +94,49 @@ Store 在<strong>加载 / 初始化阶段</strong>被填一次,之后整个游
1. <strong>它不进存档</strong>。五个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。读档不会恢复你的改动,也不会把它退回原样。
2. <strong>它不进录像</strong>。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致。
3. <strong>它不保证更新已经存在的对象</strong>。有的运行时对象每次都现查 Store(静态图块的 `raw()`、事件派发),有的在构造时就把定义缓存下来(动态图块、背包道具、运行时楼层)。改完之后谁变了、谁没变,取决于取用方式。
4. <strong>它绕过了检查</strong>。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。
3. <strong>它绕过了检查</strong>。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。
所以规则很简单:<strong>登记只在加载期做一次,运行期只读</strong>。
至于登记这件事本身,绝大多数情况也不用你动手:`add*` 是给数据端自己用的接口——内容加载还没写完,写完后由数据端统一登记,不在你的代码里。日常你只需要按下面这样读:
```ts
// 先按图块 id 查数字,再取完整定义;没有登记时 num 为 undefined
const num = state.tileStore.num('my-tile');
if (num !== undefined) {
// raw 为 null 表示这条定义不存在
const raw = state.tileStore.getData(num);
console.log(raw, state.tileStore.getType(num));
}
import { ICoreState } from '@user/data-state';
// 道具、地图、怪物同样按各自的键查
const item = state.itemStore.getData('my-item');
const map = state.mapStore.getMap('my-floor');
const enemy = state.enemyStore.getEnemy('my-enemy');
// 读示例:放进能拿到 state 的方法里
export function readStores(state: ICoreState): void {
// 先按图块 id 查数字,再取完整定义;没有登记时 num 为 undefined
const num = state.tileStore.num('my-tile');
if (num !== undefined) {
// raw 为 null 表示这条定义不存在
const raw = state.tileStore.getData(num);
console.log(raw, state.tileStore.getType(num));
}
// 道具、地图、怪物同样按各自的键查
const item = state.itemStore.getData('my-item');
const map = state.mapStore.getMap('my-floor');
const enemy = state.enemyStore.getEnemy('my-enemy');
console.log(item, map, enemy);
}
```
读 Store 随时可以:查表、算数、拼提示文本、给界面提供数据,都是正常用法。查表不会改变任何东西,也不产生录像步。唯一要注意的是<strong>不要就地修改返回的对象</strong>:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。
## 边界与注意
- <strong>确实要补几条定义</strong>。把调用集中放在插件的一个时机方法里(`awake` / `initialize` / `afterLoad`,见[插件系统](./plugin-system.md)),只在启动流程里执行一次;不要放进玩家动作、事件回调、每帧逻辑或战斗结算。
- <strong>登记不会创建运行时对象</strong>。`addMap` 只是把原始数据放进注册表,要可玩还得自己调用 `state.maps.fromRaw(raw)`;`addTile` 也只影响之后构造的图层,图层已经建好就不会自动补进去。
- <strong>重复登记同一个 `num` 会覆盖</strong>。图块告警 `133` / `134`,道具与怪物告警 `181`,事件告警 `170`;不撞键才是安全的登记。
- <strong>需要自己补充 Store 定义的情况非常少见</strong>。`add*` 是数据端自用的登记接口:内容加载还没有写完,写完后由数据端统一登记。绝大多数情况下你只需要读,不需要自己补定义。确实需要补时,属于后续高级教程的内容(`高级 II · 加载与插件生命周期`,待编写),并且要把调用集中放在插件的一个时机方法里(见[插件系统](./plugin-system.md)),只在启动流程里执行一次。
- <strong>返回值不是副本</strong>。改 `getData` / `getMap` / `getEvent` 的返回值等于改注册表;需要不同的定义就登记新的,不要改旧的。
- <strong>读档不会恢复 Store</strong>。改过的定义不会随存档保存,读档也不会把它退回去。
- <strong>`eventStore.getEvent` 给的是实例本身</strong>,不要在上面做缓存并当成快照。
<!-- TODO(advanced-II): 在初始化阶段补充 Store 定义 -->
## 小结
- 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。
- 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。
- 读随时可以;返回的通常是注册表里的实例,不要就地修改。
- 登记由数据端负责,绝大多数情况不需要自己调用 `add*`,运行期尤其不要写。
- 运行时楼层由 `state.maps.fromRaw(raw)` 一次性构造;改 Store 不会重建它。
返回[高级 I 导览](./index.md)。

View File

@ -24,12 +24,16 @@
```ts
// 旧样板示意(伪代码,不可照抄)
// 事件脚本执行到这里时,直接遍历周围怪物,改写它们“当下”的攻击力
for (const enemy of nearbyEnemies) {
enemy.atk = enemy.atk + 10;
function strengthenNearbyEnemies(nearbyEnemies: IEnemyState[]): void {
for (const enemy of nearbyEnemies) {
enemy.atk = enemy.atk + 10;
}
}
// 另一段脚本里,勇士回血也是直接改当前数值
hero.hp = hero.hp + 50;
function healHero(hero: IHeroState<IHeroAttr>): void {
hero.hp = hero.hp + 50;
}
```
这种写法的执行时机非常直观:代码走到哪,效果就发生到哪。但它会带来几个后果:
@ -97,8 +101,13 @@ const aura: ISpecial23Value = {
<strong>引擎在哪个时机执行</strong>:对应动作发生时。主对象会通过 `forEachHook` 依次通知挂在自己身上的钩子。你只需要注册一次:
```ts
import { ICoreState } from '@user/data-state';
import { MyHeroAttributeHooks } from './hero-attribute-hooks';
// 注册钩子:new 一个钩子实例,挂到关心的主对象上
state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
export function registerHeroAttributeHooks(state: ICoreState): void {
state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
}
```
不需要时可以用 `removeHook` 摘掉。内置钩子已经在初始化时注册好,你自己写的钩子才需要 `addHook`。
@ -136,12 +145,38 @@ state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
看一个反例:
```ts
import { ICoreState } from '@user/data-state';
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
import {
CriticalableHeroStatus,
IDamageCalculator,
IEnemyDamageInfoBase,
IReadonlyDamageHandler
} from '@user/data-system';
// [反例] 有副作用的计算器:算出结果的同时,还改了外部状态
calculate(handler) {
const damage = 100; // 这里只是示意,真实公式会读 handler
// 副作用:把这次结果写进 flag
this.state.flags.setField('lastDamage', damage);
return { damage, turn: 1 };
export class BadDamageCalculator implements IDamageCalculator<
IEnemyAttr,
IHeroAttr
> {
constructor(private readonly state: ICoreState) {}
calculate(
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>
): IEnemyDamageInfoBase {
const damage = 100; // 这里只是示意,真实公式会读 handler
// 副作用:把这次结果写进 flag
this.state.flags.setField('lastDamage', damage);
return { damage, turn: 1 };
}
// 临界计算同样会被反复调用,这里只保留结构
getCriticalLimit(
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>,
attribute: CriticalableHeroStatus<IHeroAttr>
): number {
return 0;
}
}
```

View File

@ -44,20 +44,26 @@ export class GroundBlockHooks implements IMapLayerHooks {
注册时找到目标图层,把实例挂到它上面:
```ts
const map = state.maps.getMap('floor-1');
if (!map) {
// afterLoad 时楼层未必已经构造出来,不要静默跳过
state.emitTip('floor-1 尚未构造,钩子未注册');
return;
}
const layer = [...map.layerList].find(l => l.alias === 'ground');
if (!layer) {
state.emitTip('ground 图层不存在');
return;
}
import { ICoreState } from '@user/data-state';
import { GroundBlockHooks } from './ground-block-hooks';
// 注册到 layer,不是 map;保存控制器以便之后卸载
const controller = layer.addHook(new GroundBlockHooks(state, layer));
// 找到目标图层后注册;通常放在插件的 afterLoad 里
export function registerGroundHooks(state: ICoreState) {
const map = state.maps.getMap('floor-1');
if (!map) {
// afterLoad 时楼层未必已经构造出来,不要静默跳过
state.emitTip('floor-1 尚未构造,钩子未注册');
return null;
}
const layer = [...map.layerList].find(l => l.alias === 'ground');
if (!layer) {
state.emitTip('ground 图层不存在');
return null;
}
// 注册到 layer,不是 map;返回控制器以便之后卸载
return layer.addHook(new GroundBlockHooks(state, layer));
}
```
`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。
@ -65,8 +71,21 @@ const controller = layer.addHook(new GroundBlockHooks(state, layer));
如果钩子要在构造时保持通用、注册时才确定图层,可以在 `awake` 里取控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`:
```ts
awake(controller: IMapLayerHookController): void {
// controller.layer 就是注册这个钩子的图层
import { ICoreState } from '@user/data-state';
import { IMapLayerHookController, IMapLayerHooks } from '@user/data-base';
// 节选:awake 同样写在类里,通过控制器拿到注册它的图层
export class UniversalBlockHooks implements IMapLayerHooks {
constructor(readonly state: ICoreState) {}
awake(controller: IMapLayerHookController): void {
// controller.layer 就是注册这个钩子的图层
console.log(controller.layer.alias);
}
onUpdateBlock(block: number, x: number, y: number): void {
this.state.emitTip(`图层更新:(${x}, ${y}) -> ${block}`);
}
}
```
@ -92,18 +111,24 @@ export class TargetLayerResizeHooks implements IGameMapHooks {
注册到 `map`,不是 `layer`:
```ts
const map = state.maps.getMap('floor-1');
if (!map) {
state.emitTip('floor-1 尚未构造,钩子未注册');
return;
}
const targetLayer = [...map.layerList].find(l => l.alias === 'ground');
if (!targetLayer) {
state.emitTip('ground 图层不存在');
return;
}
import { ICoreState } from '@user/data-state';
import { TargetLayerResizeHooks } from './target-layer-resize-hooks';
map.addHook(new TargetLayerResizeHooks(state, targetLayer));
// 注册到 map,不是 layer
export function registerTargetLayerHooks(state: ICoreState): void {
const map = state.maps.getMap('floor-1');
if (!map) {
state.emitTip('floor-1 尚未构造,钩子未注册');
return;
}
const targetLayer = [...map.layerList].find(l => l.alias === 'ground');
if (!targetLayer) {
state.emitTip('ground 图层不存在');
return;
}
map.addHook(new TargetLayerResizeHooks(state, targetLayer));
}
```
这里的比较用对象引用(`===`)。`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器,把图层的 `onResize` 转发到地图级钩子并带上具体图层。调用 `map.resizeLayer(30, 30, true)` 时每个图层都会触发一次,你的处理只应在目标图层上生效。
@ -113,11 +138,24 @@ map.addHook(new TargetLayerResizeHooks(state, targetLayer));
`addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器。控制器最重要的方法是 `unload()`:
```ts
const hook = new GroundBlockHooks(state, layer);
const controller = layer.addHook(hook);
import { ICoreState } from '@user/data-state';
import { IMapLayer, IMapLayerHookController } from '@user/data-base';
import { GroundBlockHooks } from './ground-block-hooks';
// 注册,保存返回的控制器
export function addGroundBlockHooks(
state: ICoreState,
layer: IMapLayer
): IMapLayerHookController {
return layer.addHook(new GroundBlockHooks(state, layer));
}
// 之后需要撤掉时
controller.unload();
export function removeGroundBlockHooks(
controller: IMapLayerHookController
): void {
controller.unload();
}
```
几条接口事实:

View File

@ -6,13 +6,13 @@
## 高级教程是给谁的
高级教程面向有高度定制需求的数据端作者:
高级教程面向有高度定制需求的作者:
- 你要新增一套有自己状态、生命周期和存档内容的系统,而不只是改一个数值;
- 你要理解扩展机制本身,知道声明式接口、插件、存档与录像之间怎么配合;
- 你愿意读一点源码,但不需要成为引擎的编写者。
它不要求你懂渲染端实现,也不要求你改造引擎内部。高级 I 的所有例子都落在数据端:你写的内容、你注册的对象、你声明的规则。
它不要求你改造引擎内部。高级教程同时覆盖数据端与渲染端;渲染端尚未完工,暂时只能写数据端的内容,所以高级 I 的例子都落在数据端:你写的内容、你注册的对象、你声明的规则。
## 高级教程的三层
@ -34,7 +34,7 @@
| --- | -------------------- | ------------------------------------------------------ |
| 1 | 高级 I 导览 | 看清分层、阅读路径与取舍原则,决定先读哪篇 |
| 2 | 声明式 vs 命令式接口 | 分清两种思路,知道持续生效的规则与一次性动作各该写在哪 |
| 3 | 插件系统 | 独立写出插件,处理依赖、生命周期、id 稳定性与版本迁移 |
| 3 | 插件系统 | 独立写出插件,分清数据端与渲染端的依赖和生命周期 |
| 4 | 可存档内容 | 让自定义状态正确进入存档,并在读档后恢复到存档时的样子 |
| 5 | 录像一致性 | 判断哪些方法该登记、哪些路径不该记,以及检查能保证什么 |
| 6 | data-common 的 Store | 知道内容注册表存什么、接口怎么用,以及为什么运行时只读 |
@ -70,7 +70,7 @@
## 本层不覆盖什么
- <strong>渲染端内容</strong>:渲染端尚未完工,高级 I 只写数据端。
- <strong>渲染端内容</strong>:渲染端尚未完工,高级教程当前只收录数据端内容,渲染端专题待完工后补上。
- <strong>高级 II 的系统级实现</strong>:战斗上下文、光环 / 查询 / 优先级、伤害与地图伤害、渲染层与布局、加载与插件生命周期、自定义录像行为。
- <strong>高级 III 的原理与架构</strong>:双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试。
- <strong>API 参考</strong>:接口签名与类型清单不属于教程正文。

View File

@ -40,14 +40,67 @@
## 数据端与渲染端
插件分数据端与渲染端两套注册表。数据端插件实现 `IDataPluginDefine`;渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`),生命周期相同,但多一份依赖声明:
插件分数据端与渲染端两套注册表:数据端插件实现 `IDataPluginDefine`,注册在 `dataPlugins`;渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`),注册在 `clientPlugins`。两套的生命周期时机相同,区别在依赖声明与生命周期参数。
| 插件类型 | 依赖声明 | 能依赖 |
| -------- | ------------------------- | ----------------------- |
| 数据端 | `dataDeps` | 数据端插件 |
| 渲染端 | `dataDeps` + `clientDeps` | 数据端插件 + 渲染端插件 |
依赖方向是单向的:<strong>渲染端可以依赖数据端,数据端不能依赖渲染端</strong>。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。写数据端插件时只需要关心 `dataDeps`;渲染端插件不在本篇展开。
依赖方向是单向的:<strong>渲染端可以依赖数据端,数据端不能依赖渲染端</strong>。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。
### 渲染端插件接口
`IClientPluginDefine` 在 `id`、`dataDeps` 之上多了一份 `clientDeps`,生命周期方法也因此多一个依赖参数:
| 成员 | 说明 |
| ------------------------------- | ---------------------------------------- |
| `id: string` | 插件标识符,全局唯一 |
| `dataDeps: readonly string[]` | 依赖的数据端插件 id |
| `clientDeps: readonly string[]` | 依赖的渲染端插件 id |
| `awake(...)` | 同步初始化,渲染端主对象构造期调用 |
| `initialize(...)` | 异步初始化,加载开始前调用,会被 `await` |
| `afterLoad(...)` | 加载完毕后调用,会被 `await` |
三个时机方法的第一个参数是渲染端主对象 `IClientCore`,后两个参数依次是数据端依赖 `IDataPlugin[]` 与渲染端依赖 `IClientPlugin[]`。它们直接以 `dataDeps` / `clientDeps` 命名,不再统一叫 `dependencies`,所以不必按顺序猜是哪一类。
```ts
import {
IClientCore,
IClientPlugin,
IClientPluginDefine
} from '@user/client-modules';
import { IDataPlugin } from '@user/data-state';
import { IShopPlugin } from '../../data/plugin/shop';
import { IClientShopPlugin } from './client-shop';
// 渲染端插件:同时依赖一个数据端插件与一个渲染端插件
export class MyClientPlugin implements IClientPluginDefine {
readonly id = 'my-client-plugin';
readonly dataDeps: string[] = ['data-shop-plugin'];
readonly clientDeps: string[] = ['client-shop-plugin'];
awake(
state: IClientCore,
dataDeps: IDataPlugin[],
clientDeps: IClientPlugin[]
): void {
// 依赖对象已经按声明解析好,直接取用即可
const dataShop = dataDeps[0]?.plugin as IShopPlugin | undefined;
const clientShop = clientDeps[0]?.plugin as
| IClientShopPlugin
| undefined;
}
async initialize(): Promise<void> {}
async afterLoad(): Promise<void> {}
}
```
两个依赖数组都要声明,没有依赖就留空。渲染端插件的注册方式与数据端对应:类写在自己的目录里,追加到 `src/client/plugin/list.ts` 的 `clientPlugins` 数组末尾;入口 `src/client/entry.ts` 同样把主插件放在数组前面。需要取回渲染端插件时用 `state.getClientPlugin(id)`,返回 `Readonly<IClientPlugin<T>> | null`,判空方式与 `getDataPlugin` 一致。
渲染端尚未完工,渲染端对象具体有哪些、插件能拿它们做什么,要等完工后再写。上面这些是当前已经定稿的接口。
## 注册到 dataPlugins
@ -119,31 +172,17 @@ load() 开始
| `PluginStatus.Initialized` | 已执行 `initialize`,未加载完 |
| `PluginStatus.Loaded` | 三个时机都已完成 |
三个阶段都走同一份依赖排序结果:被依赖的插件先完成同一阶段,依赖它的一方才会开始。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。
三个阶段都按同一套依赖关系执行:<strong>被依赖的插件先完成同一阶段,依赖它的一方才会开始</strong>。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。不构成依赖关系的插件之间,不要假定谁先谁后;需要顺序就显式声明依赖。
## dataDeps 与排序
依赖关系在启动前定好,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变执行顺序,也没有接口在运行时追加插件。
引擎内部用 `orderPlugins` 计算顺序,结果包含三样东西:
依赖写错时,整条链路会被阻断:
| 字段 | 含义 |
| --------- | ------------------------------------------------------- |
| `order` | 可以初始化的插件拓扑序,被依赖者在前 |
| `cyclic` | 处于依赖环中的插件 id |
| `missing` | 直接引用了不存在依赖的插件 id,以及它缺失的依赖 id 列表 |
- <strong>循环依赖</strong>:环上的插件全部被排除,控制台报一条错误(错误码 `76`),列出环上的 id;
- <strong>缺失依赖</strong>:`dataDeps` 里写了一个没有注册的 id,该插件被排除,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
- <strong>下游阻断</strong>:依赖上述插件的插件也会一起被排除,三个时机一个都不执行。
排序只保证一件事:<strong>依赖关系成立时,被依赖的插件先完成,依赖它的后完成</strong>。不构成依赖关系的插件之间,引擎按登记顺序扫描,但这不是可以依赖的契约——需要谁先谁后,就显式声明依赖。
这份顺序在主对象构造时算一次,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变顺序,也没有接口在运行时追加插件。依赖在启动前定好。
### 失败语义:循环依赖与缺失依赖
依赖写错不会「部分生效」,而是整条链路被阻断:
- <strong>循环依赖</strong>:能沿依赖边绕回自身的插件都会被记进 `cyclic`,控制台报一条错误(错误码 `76`),列出环上的 id;
- <strong>缺失依赖</strong>:`dataDeps` 里写了一个没有注册的 id,该插件会被记进 `missing`,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
- <strong>下游阻断</strong>:依赖了上述插件的插件,也会一起被排除。被排除的插件不会进入 `order`,三个时机一个都不会执行。
一处依赖写错,受影响的不只是那一个插件,而是依赖它的整条链。排查时先看控制台里的错误码和 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。
排查时先看控制台里的错误码与 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。渲染端插件也按同样方式处理 `clientDeps`。
## 插件之间怎么调用
@ -179,10 +218,15 @@ export class PluginB implements IDataPluginDefine {
没有声明依赖、或者在启动之后才按需访问时,用 `state.getDataPlugin(id)`。它返回 `Readonly<IDataPlugin<T>> | null`。下面这段放在能拿到 `state` 的方法里:
```ts
import { ICoreState, PluginStatus } from '@user/data-state';
import { PluginA } from './plugin-a';
// 取回插件,先判空,再确认状态
const holder = state.getDataPlugin<PluginA>('plugin-a');
if (holder && holder.status === PluginStatus.Loaded) {
holder.plugin.doSomething();
export function callPluginA(state: ICoreState): void {
const holder = state.getDataPlugin<PluginA>('plugin-a');
if (holder && holder.status === PluginStatus.Loaded) {
holder.plugin.doSomething();
}
}
```
@ -324,25 +368,41 @@ export class DataShopPlugin implements IShopPlugin {
主插件 `src/data/initialize.ts` 的 `MainDataPlugin` 是 `afterLoad` 的典型用法。它的 `awake` 负责装配朝向、寻路、加载钩子与各系统;创建勇士则放到 `afterLoad`:
```ts
async afterLoad(state: ICoreState): Promise<void> {
// 核心配置只有在加载完成后才拿得到
const coreConfig = state.loader.getConfig<ICoreStateCoreConfig>('core');
if (!coreConfig) {
logger.error(79, 'Data');
return;
import { ICoreState, ICoreStateCoreConfig } from '@user/data-state';
import { FaceDirection, FaceGroup, IFacedTileLocator } from '@user/data-common';
import { logger } from '@motajs/common';
import { IDataMainPlugin } from './types';
// 主插件节选:只保留 afterLoad,其余成员用注释省略
export class MainDataPlugin implements IDataMainPlugin {
readonly id = 'main-data-plugin';
readonly dataDeps: string[] = [];
// 朝向组由入口在构造时传入
constructor(readonly heroFace: FaceGroup) {}
// ... awake / initialize 等成员省略
async afterLoad(state: ICoreState): Promise<void> {
// 核心配置只有在加载完成后才拿得到
const coreConfig = state.loader.getConfig<ICoreStateCoreConfig>('core');
if (!coreConfig) {
logger.error(79, 'Data');
return;
}
const dir = state.faceManager.get(this.heroFace);
if (!dir) {
logger.error(80);
return;
}
state.hero.createAttribute(coreConfig.init.hero.attribute);
const location: IFacedTileLocator = {
x: coreConfig.init.hero.x,
y: coreConfig.init.hero.y,
direction: FaceDirection.Down
};
state.hero.createLocation(location, dir);
}
const dir = state.faceManager.get(this.heroFace);
if (!dir) {
logger.error(80);
return;
}
state.hero.createAttribute(coreConfig.init.hero.attribute);
const location: IFacedTileLocator = {
x: coreConfig.init.hero.x,
y: coreConfig.init.hero.y,
direction: FaceDirection.Down
};
state.hero.createLocation(location, dir);
}
```
@ -353,12 +413,17 @@ async afterLoad(state: ICoreState): Promise<void> {
启动完成后,用 `state.getDataPlugin` 取回插件,确认它已经进了注册表:
```ts
// [验证] 取回插件并检查状态
const holder = state.getDataPlugin<MyPlugin>('my-plugin');
console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
import { ICoreState, PluginStatus } from '@user/data-state';
import { MyPlugin } from './my-plugin';
// 启动完成后取回插件并检查状态
export function checkMyPlugin(state: ICoreState): void {
const holder = state.getDataPlugin<MyPlugin>('my-plugin');
console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
}
```
`PluginStatus` 从 `@user/data-state` 引入,`MyPlugin` 从你的插件文件引入。`status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。
`PluginStatus` 与 `ICoreState` 从 `@user/data-state` 引入,`MyPlugin` 从你的插件文件引入。`status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。
想确认依赖阻断,可以把 `dataDeps` 里的某个 id 改成一个不存在的名字再启动:控制台应报缺失依赖错误(`77`,带插件 id 与缺失的依赖 id),并且你的插件没有任何时机输出。插件注册的存档内容如何验证,见[可存档内容](./saveable-content.md)的「验证」一节。
@ -367,7 +432,7 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
- <strong>在 awake 里 await</strong>。`awake` 的返回值会被忽略,写 `async awake` 也不会被等待。异步准备放到 `initialize`。
- <strong>把 dataDeps 写成对象</strong>。它必须是 id 字符串数组;插件对象是三个时机方法收到的参数,不是声明时写的东西。
- <strong>依赖成环</strong>。环上的插件全部被排除,依赖它们的下游也不执行。依赖关系要单向。
- <strong>依赖 id 拼错</strong>。插件不会报「找不到类」,而是被记为缺失依赖并从 `order` 中排除;控制台会给出插件 id 与缺失的 id。
- <strong>依赖 id 拼错</strong>。插件不会报「找不到类」,而是被当作缺失依赖排除;控制台会给出插件 id 与缺失的 id。
- <strong>用 getDataPlugin 不判空</strong>。它可能返回 `null`,也可能处于你还没等到阶段。
- <strong>把插件 id 当显示名随意改</strong>。别的插件的 `dataDeps` 会立刻失效,存档 id 也不会跟着变。
- <strong>直接改其他系统的内部字段</strong>。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。
@ -376,7 +441,7 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
- 插件把一组对象与它们的生命周期收进一个独立单元,启动时在 `dataPlugins` 里 `new` 一次。
- `dataDeps` 声明的是 id 字符串;三个时机方法收到的是解析后的插件对象。
- 渲染端插件可以依赖数据端插件,数据端插件不能依赖渲染端插件。
- 渲染端插件实现 `IClientPluginDefine`,用 `dataDeps` 与 `clientDeps` 分别声明两类依赖;渲染端可以依赖数据端,数据端不能依赖渲染端。
- `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。
- 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。
- 插件 id 是对外契约,不要随意改名或复用;它与可存档 id 是两套命名。

View File

@ -39,6 +39,8 @@
:::warning 录像记录缺失
如果带 `@shouldReplay` 的方法执行了,而这次执行没有产生任何录像记录,控制台会输出一条<strong>「录像记录缺失」</strong>的警告,并列出这次执行经过的标记方法。看到它,说明这条路径该记却没记。检测的内部原理留到 `高级 III · 录像深层原理`。
警告里还会附一条可直接复制的调用,形如 `Mota.require('@user/data-common').logReplaySafetyDetail(0);`。把它粘到控制台执行,就能展开这次检查的完整调用路径,逐层看到经过的标记方法。这个调用形式之后可能会调整,以控制台实际打印的那条为准。
:::
### @shouldReplay:只登记路径,不写录像
@ -98,7 +100,7 @@ export class ShopSystem implements IShopSystem {
录像一致性最终要靠回放对比:录一段包含目标操作的录像,回放后检查关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。
如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。
如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。想看更细的调用路径,就执行警告里附带的 `logReplaySafetyDetail(...)` 调用;命令形式若有变化,以控制台输出为准。
参数与顺序需要自己核对,装饰器不会替你检查这些。

View File

@ -51,12 +51,24 @@ export interface ISaveableContent<T> {
`loadState` 拿到的是普通数据,不是你的类实例。它要做的是把这份数据装回对象:该清空的清空,该重建的重建,不要假设旧字段还留着。商店管理器就是典型写法:
```ts
loadState(state: IShopManagerSave, compression: SaveCompression): void {
this.shops.clear();
for (const save of state.list) {
const shop: IShopState = new ShopState(save.id, this.state);
shop.loadState(save, compression);
this.shops.set(save.id, shop);
import { SaveCompression } from '@user/data-common';
import { IShopManager, IShopManagerSave, IShopState } from './types';
import { ShopState } from './state';
// 管理器节选:只保留 loadState,其余成员用注释省略
export class ShopManager implements IShopManager {
private readonly shops: Map<string, IShopState> = new Map();
// ... 其他成员省略
loadState(state: IShopManagerSave, compression: SaveCompression): void {
this.shops.clear();
for (const save of state.list) {
// 不能把普通对象直接塞进 Map,要重新构造类实例
const shop: IShopState = new ShopState(save.id, this.state);
shop.loadState(save, compression);
this.shops.set(save.id, shop);
}
}
}
```
@ -68,9 +80,19 @@ loadState(state: IShopManagerSave, compression: SaveCompression): void {
<strong>存档的顶层不会自动做深拷贝。</strong> `CoreState.saveState` 只是把每个对象 `saveState` 的返回值按 id 放进一个 `Map`,不会再 `structuredClone` 一遍。因此 `saveState` 必须自己返回一个<strong>独立的新对象</strong>:不能返回 `this`,也不能让返回值里的字段与活对象共享引用。否则之后对游戏对象的修改会直接改到已保存的数据上,读档时拿到的就是被污染的值。
```ts
import { ISaveableContent } from '@user/data-common';
// 错误:把活对象交给存档系统;顶层不会再复制
saveState(): IMySave {
return this;
export class BadSaveable implements ISaveableContent<BadSaveable> {
constructor(private readonly count: number) {}
saveState(): BadSaveable {
return this;
}
loadState(state: BadSaveable): void {
// 这里只是让示例结构完整;真正的错误在 saveState 的返回值
}
}
```
@ -95,8 +117,13 @@ saveState(): IMySave {
实现好之后,用 `state.addSaveableContent(id, content)` 把它交给存档系统:
```ts
// [示例] 下文插件示例里的对象叫 this.store,注册 id 就沿用它
state.addSaveableContent('@plugin/my-store', this.store);
import { ICoreState } from '@user/data-state';
import { MySaveable } from './state';
// 在插件或初始化函数里注册;下文插件示例里的对象叫 this.store
export function registerMyStore(state: ICoreState, store: MySaveable): void {
state.addSaveableContent('@plugin/my-store', store);
}
```
`id` 是这个对象在存档里的唯一标识。整份数据端存档就是一个 `Map<string, unknown>`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。
@ -176,7 +203,7 @@ export interface ISaveableExecutor<T> {
```ts
import { IHeroAttr } from '@user/data-common';
import { IHeroStateSave, IStateBase } from '@user/data-base';
import { ISaveableExecutor } from '@user/data-state';
import { ICoreState, ISaveableExecutor } from '@user/data-state';
// 示例:读档后让勇士回到存档记录的楼层。
// 实际内部实现并不是这样处理的,这里只示意执行器能做什么。
@ -192,8 +219,10 @@ export class HeroFloorExecutor implements ISaveableExecutor<
}
}
// 绑定到勇士的存档 id
state.bindSaveableExecuter('@system/hero', new HeroFloorExecutor());
// 绑定到勇士的存档 id:通常写在初始化阶段
export function bindHeroFloorExecutor(state: ICoreState): void {
state.bindSaveableExecuter('@system/hero', new HeroFloorExecutor());
}
```
执行器接口的 `afterLoad` 是同步的,所以它只能「发起」异步操作;需要等待完成的流程要放在你自己的读档调用处。
@ -256,9 +285,16 @@ export class QuestSaveable implements ISaveableContent<IQuestSave> {
引用者自己的注册与绑定如下,顺序不能反:
```ts
import { ICoreState } from '@user/data-state';
// 先注册引用者,再绑定执行器
state.addSaveableContent('@plugin/example-quest', this.quest);
state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor());
export function registerQuest(state: ICoreState, quest: QuestSaveable): void {
state.addSaveableContent('@plugin/example-quest', quest);
state.bindSaveableExecuter(
'@plugin/example-quest',
new QuestSaveExecutor()
);
}
```
执行器里按 `@plugin/shop` 取回商店管理器。`getSaveableContent` 返回的是 `ISaveableContent<T>`,要调用管理器自己的 `getShop`,需要断言成 `IShopManager`:
@ -356,19 +392,22 @@ export class MyPlugin implements IDataPluginDefine {
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap<string, unknown>`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面的代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行:
```ts
// [验证] 存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类
import { SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { MyPlugin } from './plugin/my-plugin';
const plugin = state.getDataPlugin<MyPlugin>('my-plugin')?.plugin;
if (!plugin) return;
// 验证:存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类
export function verifySaveLoad(state: ICoreState): void {
const plugin = state.getDataPlugin<MyPlugin>('my-plugin')?.plugin;
if (!plugin) return;
plugin.store.setValue('count', 1);
const snapshot = state.saveState(SaveCompression.NoCompression);
plugin.store.setValue('count', 1);
const snapshot = state.saveState(SaveCompression.NoCompression);
plugin.store.setValue('count', 2);
state.loadState(snapshot, SaveCompression.NoCompression);
console.log(plugin.store.getValue('count')); // 应为 1
plugin.store.setValue('count', 2);
state.loadState(snapshot, SaveCompression.NoCompression);
console.log(plugin.store.getValue('count')); // 应为 1
}
```
读档后应回到存档时的值。如果读到的是修改后的值,说明 `saveState` 返回了与活对象共享的引用,而不是独立副本。
@ -376,18 +415,27 @@ console.log(plugin.store.getValue('count')); // 应为 1
对同一个对象把三档压缩各跑一次,可以确认三种策略读回来的结果一致:
```ts
// [验证] 三档压缩都应恢复出同一个值
for (const compression of [
SaveCompression.NoCompression,
SaveCompression.LowCompression,
SaveCompression.HighCompression
]) {
plugin.store.setValue('count', 1);
const snapshot = state.saveState(compression);
import { SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { MyPlugin } from './plugin/my-plugin';
plugin.store.setValue('count', 2);
state.loadState(snapshot, compression);
console.log(compression, plugin.store.getValue('count')); // 每次都应为 1
// 验证:三档压缩都应恢复出同一个值
export function verifyCompression(state: ICoreState): void {
const plugin = state.getDataPlugin<MyPlugin>('my-plugin')?.plugin;
if (!plugin) return;
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
}
}
```
@ -396,8 +444,13 @@ for (const compression of [
启动完成后还可以直接确认注册成功:
```ts
const content = state.getSaveableContent('@plugin/my-store');
console.log(content);
import { ICoreState } from '@user/data-state';
// 启动完成后确认注册成功
export function checkRegistered(state: ICoreState): void {
const content = state.getSaveableContent('@plugin/my-store');
console.log(content);
}
```
应拿到你注册的对象,而不是 `null`。为 `null` 时先检查 `addSaveableContent` 的 id 是否拼错、是否真的执行到了 `awake`。
@ -405,11 +458,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);
import { SaveCompression } from '@user/data-common';
import { ICoreState } from '@user/data-state';
// 验证:删掉本对象的存档数据再读档,应告警 177
export function verifyMissingKey(state: ICoreState): void {
const snapshot = state.saveState(SaveCompression.NoCompression);
const edited = new Map(snapshot);
edited.delete('@plugin/my-store');
state.loadState(edited, SaveCompression.NoCompression);
}
```
读档时控制台应出现缺键告警(`177`),并跳过该对象;存档里多出未注册的 id 时,应出现多键告警(`178`)。这两条能帮你区分「没存进去」和「读的时候没对上」。