From 6351cfea6e6b8d0fc95b3a08835f4061610b1aa4 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 21 Oct 2026 10:46:55 +0800 Subject: [PATCH] =?UTF-8?q?docs(10-20):=20=E4=BF=AE=E8=AE=A2=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=B8=B2=E6=9F=93=E7=AB=AF/Store=E8=BE=B9=E7=95=8C/?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=EF=BC=88=E7=94=A8=E6=88=B7=E7=AC=AC?= =?UTF-8?q?3=E8=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugin-system 补充渲染端插件接口与完整示例,删除排序细节 - data-stores 删除 fromRaw 与缓存更新等阶段性深层说明,边界改为「极少需要自己补定义」 - 全部七篇代码块补成完整类/函数结构 - replay-consistency 保留 logReplaySafetyDetail 查详细信息的调用说明 - index 说明高级教程同时覆盖数据端与渲染端 --- .../10-docs-restructure/10-20-USER-REVIEW.md | 32 ++++ docs/advanced/data-stores.md | 40 +++-- docs/advanced/declarative-vs-imperative.md | 53 +++++- docs/advanced/hooks-in-depth.md | 96 +++++++--- docs/advanced/index.md | 8 +- docs/advanced/plugin-system.md | 165 ++++++++++++------ docs/advanced/replay-consistency.md | 4 +- docs/advanced/saveable-content.md | 140 ++++++++++----- 8 files changed, 385 insertions(+), 153 deletions(-) diff --git a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md index 97d5b5b..1b70da8 100644 --- a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md @@ -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` 在「边界与注意」首条写明需要自己补充 Store 定义的情况非常少见,并指向 `高级 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。 + +**状态:已处理** diff --git a/docs/advanced/data-stores.md b/docs/advanced/data-stores.md index c4667c0..4c08d04 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -72,8 +72,6 @@ Store 在加载 / 初始化阶段被填一次,之后整个游 - `getMap(floorId)`:原始地图数据,查不到返回 `null`; - `addMap(map)`:登记一份原始数据,同一个 `floorId` 直接覆盖,不告警。 -这份原始数据不会自己变成可玩的地图。运行时楼层由 `state.maps.fromRaw(raw)` 构造:它按 `width` 与 `map` 算出高度、建图层、铺图块、挂事件。从原始数据到运行时对象只有这一次转换。 - ### enemyStore:怪物原始数据 以怪物图块数字为键,存 `IEnemyRawData`:`num`、`attribute`(怪物属性)、`special`(特殊属性,键为特殊属性编号、值为参数)。 @@ -96,45 +94,49 @@ Store 在加载 / 初始化阶段被填一次,之后整个游 1. 它不进存档。五个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。读档不会恢复你的改动,也不会把它退回原样。 2. 它不进录像。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致。 -3. 它不保证更新已经存在的对象。有的运行时对象每次都现查 Store(静态图块的 `raw()`、事件派发),有的在构造时就把定义缓存下来(动态图块、背包道具、运行时楼层)。改完之后谁变了、谁没变,取决于取用方式。 -4. 它绕过了检查。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。 +3. 它绕过了检查。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。 所以规则很简单:登记只在加载期做一次,运行期只读。 至于登记这件事本身,绝大多数情况也不用你动手:`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 随时可以:查表、算数、拼提示文本、给界面提供数据,都是正常用法。查表不会改变任何东西,也不产生录像步。唯一要注意的是不要就地修改返回的对象:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。 ## 边界与注意 -- 确实要补几条定义。把调用集中放在插件的一个时机方法里(`awake` / `initialize` / `afterLoad`,见[插件系统](./plugin-system.md)),只在启动流程里执行一次;不要放进玩家动作、事件回调、每帧逻辑或战斗结算。 -- 登记不会创建运行时对象。`addMap` 只是把原始数据放进注册表,要可玩还得自己调用 `state.maps.fromRaw(raw)`;`addTile` 也只影响之后构造的图层,图层已经建好就不会自动补进去。 -- 重复登记同一个 `num` 会覆盖。图块告警 `133` / `134`,道具与怪物告警 `181`,事件告警 `170`;不撞键才是安全的登记。 +- 需要自己补充 Store 定义的情况非常少见。`add*` 是数据端自用的登记接口:内容加载还没有写完,写完后由数据端统一登记。绝大多数情况下你只需要读,不需要自己补定义。确实需要补时,属于后续高级教程的内容(`高级 II · 加载与插件生命周期`,待编写),并且要把调用集中放在插件的一个时机方法里(见[插件系统](./plugin-system.md)),只在启动流程里执行一次。 - 返回值不是副本。改 `getData` / `getMap` / `getEvent` 的返回值等于改注册表;需要不同的定义就登记新的,不要改旧的。 - 读档不会恢复 Store。改过的定义不会随存档保存,读档也不会把它退回去。 - `eventStore.getEvent` 给的是实例本身,不要在上面做缓存并当成快照。 + + ## 小结 - 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。 - 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。 - 读随时可以;返回的通常是注册表里的实例,不要就地修改。 - 登记由数据端负责,绝大多数情况不需要自己调用 `add*`,运行期尤其不要写。 -- 运行时楼层由 `state.maps.fromRaw(raw)` 一次性构造;改 Store 不会重建它。 返回[高级 I 导览](./index.md)。 diff --git a/docs/advanced/declarative-vs-imperative.md b/docs/advanced/declarative-vs-imperative.md index 4ee1ce5..8109ab9 100644 --- a/docs/advanced/declarative-vs-imperative.md +++ b/docs/advanced/declarative-vs-imperative.md @@ -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): void { + hero.hp = hero.hp + 50; +} ``` 这种写法的执行时机非常直观:代码走到哪,效果就发生到哪。但它会带来几个后果: @@ -97,8 +101,13 @@ const aura: ISpecial23Value = { 引擎在哪个时机执行:对应动作发生时。主对象会通过 `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 + ): IEnemyDamageInfoBase { + const damage = 100; // 这里只是示意,真实公式会读 handler + // 副作用:把这次结果写进 flag + this.state.flags.setField('lastDamage', damage); + return { damage, turn: 1 }; + } + + // 临界计算同样会被反复调用,这里只保留结构 + getCriticalLimit( + handler: IReadonlyDamageHandler, + attribute: CriticalableHeroStatus + ): number { + return 0; + } } ``` diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md index 83a456d..8fe478b 100644 --- a/docs/advanced/hooks-in-depth.md +++ b/docs/advanced/hooks-in-depth.md @@ -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(); +} ``` 几条接口事实: diff --git a/docs/advanced/index.md b/docs/advanced/index.md index ff17733..566c40c 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -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 @@ ## 本层不覆盖什么 -- 渲染端内容:渲染端尚未完工,高级 I 只写数据端。 +- 渲染端内容:渲染端尚未完工,高级教程当前只收录数据端内容,渲染端专题待完工后补上。 - 高级 II 的系统级实现:战斗上下文、光环 / 查询 / 优先级、伤害与地图伤害、渲染层与布局、加载与插件生命周期、自定义录像行为。 - 高级 III 的原理与架构:双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试。 - API 参考:接口签名与类型清单不属于教程正文。 diff --git a/docs/advanced/plugin-system.md b/docs/advanced/plugin-system.md index a65fcf4..e2eaf52 100644 --- a/docs/advanced/plugin-system.md +++ b/docs/advanced/plugin-system.md @@ -40,14 +40,67 @@ ## 数据端与渲染端 -插件分数据端与渲染端两套注册表。数据端插件实现 `IDataPluginDefine`;渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`),生命周期相同,但多一份依赖声明: +插件分数据端与渲染端两套注册表:数据端插件实现 `IDataPluginDefine`,注册在 `dataPlugins`;渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`),注册在 `clientPlugins`。两套的生命周期时机相同,区别在依赖声明与生命周期参数。 | 插件类型 | 依赖声明 | 能依赖 | | -------- | ------------------------- | ----------------------- | | 数据端 | `dataDeps` | 数据端插件 | | 渲染端 | `dataDeps` + `clientDeps` | 数据端插件 + 渲染端插件 | -依赖方向是单向的:渲染端可以依赖数据端,数据端不能依赖渲染端。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。写数据端插件时只需要关心 `dataDeps`;渲染端插件不在本篇展开。 +依赖方向是单向的:渲染端可以依赖数据端,数据端不能依赖渲染端。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。 + +### 渲染端插件接口 + +`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 {} + + async afterLoad(): Promise {} +} +``` + +两个依赖数组都要声明,没有依赖就留空。渲染端插件的注册方式与数据端对应:类写在自己的目录里,追加到 `src/client/plugin/list.ts` 的 `clientPlugins` 数组末尾;入口 `src/client/entry.ts` 同样把主插件放在数组前面。需要取回渲染端插件时用 `state.getClientPlugin(id)`,返回 `Readonly> | null`,判空方式与 `getDataPlugin` 一致。 + +渲染端尚未完工,渲染端对象具体有哪些、插件能拿它们做什么,要等完工后再写。上面这些是当前已经定稿的接口。 ## 注册到 dataPlugins @@ -119,31 +172,17 @@ load() 开始 | `PluginStatus.Initialized` | 已执行 `initialize`,未加载完 | | `PluginStatus.Loaded` | 三个时机都已完成 | -三个阶段都走同一份依赖排序结果:被依赖的插件先完成同一阶段,依赖它的一方才会开始。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。 +三个阶段都按同一套依赖关系执行:被依赖的插件先完成同一阶段,依赖它的一方才会开始。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。不构成依赖关系的插件之间,不要假定谁先谁后;需要顺序就显式声明依赖。 -## dataDeps 与排序 +依赖关系在启动前定好,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变执行顺序,也没有接口在运行时追加插件。 -引擎内部用 `orderPlugins` 计算顺序,结果包含三样东西: +依赖写错时,整条链路会被阻断: -| 字段 | 含义 | -| --------- | ------------------------------------------------------- | -| `order` | 可以初始化的插件拓扑序,被依赖者在前 | -| `cyclic` | 处于依赖环中的插件 id | -| `missing` | 直接引用了不存在依赖的插件 id,以及它缺失的依赖 id 列表 | +- 循环依赖:环上的插件全部被排除,控制台报一条错误(错误码 `76`),列出环上的 id; +- 缺失依赖:`dataDeps` 里写了一个没有注册的 id,该插件被排除,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`); +- 下游阻断:依赖上述插件的插件也会一起被排除,三个时机一个都不执行。 -排序只保证一件事:依赖关系成立时,被依赖的插件先完成,依赖它的后完成。不构成依赖关系的插件之间,引擎按登记顺序扫描,但这不是可以依赖的契约——需要谁先谁后,就显式声明依赖。 - -这份顺序在主对象构造时算一次,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变顺序,也没有接口在运行时追加插件。依赖在启动前定好。 - -### 失败语义:循环依赖与缺失依赖 - -依赖写错不会「部分生效」,而是整条链路被阻断: - -- 循环依赖:能沿依赖边绕回自身的插件都会被记进 `cyclic`,控制台报一条错误(错误码 `76`),列出环上的 id; -- 缺失依赖:`dataDeps` 里写了一个没有注册的 id,该插件会被记进 `missing`,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`); -- 下游阻断:依赖了上述插件的插件,也会一起被排除。被排除的插件不会进入 `order`,三个时机一个都不会执行。 - -一处依赖写错,受影响的不只是那一个插件,而是依赖它的整条链。排查时先看控制台里的错误码和 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。 +排查时先看控制台里的错误码与 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。渲染端插件也按同样方式处理 `clientDeps`。 ## 插件之间怎么调用 @@ -179,10 +218,15 @@ export class PluginB implements IDataPluginDefine { 没有声明依赖、或者在启动之后才按需访问时,用 `state.getDataPlugin(id)`。它返回 `Readonly> | null`。下面这段放在能拿到 `state` 的方法里: ```ts +import { ICoreState, PluginStatus } from '@user/data-state'; +import { PluginA } from './plugin-a'; + // 取回插件,先判空,再确认状态 -const holder = state.getDataPlugin('plugin-a'); -if (holder && holder.status === PluginStatus.Loaded) { - holder.plugin.doSomething(); +export function callPluginA(state: ICoreState): void { + const holder = state.getDataPlugin('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 { - // 核心配置只有在加载完成后才拿得到 - const coreConfig = state.loader.getConfig('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 { + // 核心配置只有在加载完成后才拿得到 + const coreConfig = state.loader.getConfig('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 { 启动完成后,用 `state.getDataPlugin` 取回插件,确认它已经进了注册表: ```ts -// [验证] 取回插件并检查状态 -const holder = state.getDataPlugin('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('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); - 在 awake 里 await。`awake` 的返回值会被忽略,写 `async awake` 也不会被等待。异步准备放到 `initialize`。 - 把 dataDeps 写成对象。它必须是 id 字符串数组;插件对象是三个时机方法收到的参数,不是声明时写的东西。 - 依赖成环。环上的插件全部被排除,依赖它们的下游也不执行。依赖关系要单向。 -- 依赖 id 拼错。插件不会报「找不到类」,而是被记为缺失依赖并从 `order` 中排除;控制台会给出插件 id 与缺失的 id。 +- 依赖 id 拼错。插件不会报「找不到类」,而是被当作缺失依赖排除;控制台会给出插件 id 与缺失的 id。 - 用 getDataPlugin 不判空。它可能返回 `null`,也可能处于你还没等到阶段。 - 把插件 id 当显示名随意改。别的插件的 `dataDeps` 会立刻失效,存档 id 也不会跟着变。 - 直接改其他系统的内部字段。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。 @@ -376,7 +441,7 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin); - 插件把一组对象与它们的生命周期收进一个独立单元,启动时在 `dataPlugins` 里 `new` 一次。 - `dataDeps` 声明的是 id 字符串;三个时机方法收到的是解析后的插件对象。 -- 渲染端插件可以依赖数据端插件,数据端插件不能依赖渲染端插件。 +- 渲染端插件实现 `IClientPluginDefine`,用 `dataDeps` 与 `clientDeps` 分别声明两类依赖;渲染端可以依赖数据端,数据端不能依赖渲染端。 - `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。 - 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。 - 插件 id 是对外契约,不要随意改名或复用;它与可存档 id 是两套命名。 diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md index 89b0f20..aebd019 100644 --- a/docs/advanced/replay-consistency.md +++ b/docs/advanced/replay-consistency.md @@ -39,6 +39,8 @@ :::warning 录像记录缺失 如果带 `@shouldReplay` 的方法执行了,而这次执行没有产生任何录像记录,控制台会输出一条「录像记录缺失」的警告,并列出这次执行经过的标记方法。看到它,说明这条路径该记却没记。检测的内部原理留到 `高级 III · 录像深层原理`。 + +警告里还会附一条可直接复制的调用,形如 `Mota.require('@user/data-common').logReplaySafetyDetail(0);`。把它粘到控制台执行,就能展开这次检查的完整调用路径,逐层看到经过的标记方法。这个调用形式之后可能会调整,以控制台实际打印的那条为准。 ::: ### @shouldReplay:只登记路径,不写录像 @@ -98,7 +100,7 @@ export class ShopSystem implements IShopSystem { 录像一致性最终要靠回放对比:录一段包含目标操作的录像,回放后检查关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。 -如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。 +如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。想看更细的调用路径,就执行警告里附带的 `logReplaySafetyDetail(...)` 调用;命令形式若有变化,以控制台输出为准。 参数与顺序需要自己核对,装饰器不会替你检查这些。 diff --git a/docs/advanced/saveable-content.md b/docs/advanced/saveable-content.md index 9aae6cd..a9e4d72 100644 --- a/docs/advanced/saveable-content.md +++ b/docs/advanced/saveable-content.md @@ -51,12 +51,24 @@ export interface ISaveableContent { `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 = 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 { 存档的顶层不会自动做深拷贝。 `CoreState.saveState` 只是把每个对象 `saveState` 的返回值按 id 放进一个 `Map`,不会再 `structuredClone` 一遍。因此 `saveState` 必须自己返回一个独立的新对象:不能返回 `this`,也不能让返回值里的字段与活对象共享引用。否则之后对游戏对象的修改会直接改到已保存的数据上,读档时拿到的就是被污染的值。 ```ts +import { ISaveableContent } from '@user/data-common'; + // 错误:把活对象交给存档系统;顶层不会再复制 -saveState(): IMySave { - return this; +export class BadSaveable implements ISaveableContent { + 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`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。 @@ -176,7 +203,7 @@ export interface ISaveableExecutor { ```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 { 引用者自己的注册与绑定如下,顺序不能反: ```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`,要调用管理器自己的 `getShop`,需要断言成 `IShopManager`: @@ -356,19 +392,22 @@ export class MyPlugin implements IDataPluginDefine { 存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,键是注册 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('my-plugin')?.plugin; -if (!plugin) return; +// 验证:存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类 +export function verifySaveLoad(state: ICoreState): void { + const plugin = state.getDataPlugin('my-plugin')?.plugin; + if (!plugin) return; -plugin.store.setValue('count', 1); -const snapshot = state.saveState(SaveCompression.NoCompression); + plugin.store.setValue('count', 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('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`)。这两条能帮你区分「没存进去」和「读的时候没对上」。