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 5cdd0e8..9aa50f8 100644 --- a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md @@ -189,3 +189,42 @@ **状态:处理中** +--- + +## 第 5 轮 + +**时间:** 2026-10-20 +**用户意见(7 条,多为长期结构规范):** + +| # | 意见 | 落点 | +|---|------|------| +| U5-01 | 「插件解决什么问题」与「注册与目录」对调顺序 | `plugin-system.md` | +| U5-02 | 数据端有 `getDataPlugin`,渲染端有 `getClientPlugin`,后者没讲 | `plugin-system.md` | +| U5-03 | 模板不能只是把代码放那:先有描述(「如果想要…,可参考下述模板」)→ 模板 → 讲解它干了什么、声明了什么类、实现了什么接口、需要自己改什么 | 全部 | +| U5-04 | 模板不放在文档最开头:先讲理念(能解决什么问题、可做哪些扩展),再展示模板,再围绕模板讲解 | 全部 | +| U5-05 | Store 是纯接口讲解,不需要模板;模板只用于需要自己实现接口或注册的场景,纯接口只给示例 | `data-stores.md`(长期规范) | +| U5-06 | Store 可以、也必须讲清楚存的对象类型,只是之前的写法读起来难受 | `data-stores.md` | +| U5-07 | 示例代码注释太少;示例注释越多越好,以降低理解难度 | 全部 | + +### 第 5 轮处置 + +**修改方式:** 内联执行(执行器直接编辑),仅修改 `docs/advanced/*.md` 与本职记录。 + +| # | 处置 | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| U5-01 | `plugin-system` 把「插件解决什么问题」移到「注册与目录」之前:开篇先讲插件解决什么问题、能承载哪些扩展,再看目录结构与注册位置。 | +| U5-02 | `plugin-system` 将「用 getDataPlugin 取回」改为「用 getDataPlugin / getClientPlugin 取回」:数据端与渲染端各给一段完整示例(`state.getDataPlugin` / `state.getClientPlugin`),说明两者都返回只读包装对象、判空与状态检查一致。 | +| U5-03 | 全部模板补齐三段结构:引导语(「如果想……,可以参考下面的模板」)→ 模板代码 → 说明声明了什么类、实现了什么接口、需要自己改什么。涉及插件两端模板、可存档三文件模板、录像 `ShopSystem` 模板、图层钩子模板、伤害计算器模板。 | +| U5-04 | 模板不再出现在文档开头:`plugin-system` 先讲理念再看目录与注册,`saveable-content` 先讲「什么该存」再给模板,`replay-consistency` 先讲「什么该记」再给标记模板,`hooks-in-depth` 先讲「一个对象一个实例」再给图层钩子模板。 | +| U5-05 | `data-stores` 去掉「模板」定位:读 Store 一节明确 Store 是纯接口使用、不是需要读者实现或注册的模板,只给一段读取示例(按 D-64 处理)。 | +| U5-06 | 五个 Store 的「存什么」改为分段叙述:每条记录以什么为键、包含哪些字段、`eventPass` 等字段的含义,都在正文里讲清,不再用密集 inline-code 罗列。 | +| U5-07 | 六篇示例补足解释性注释:`plugin-system` 的 `MyPlugin` / `MyClientPlugin` / `DataShopPlugin` / `MainDataPlugin` / 消费者插件,`saveable-content` 的 `MySaveable` / `MyPlugin` / `ShopManager`,`replay-consistency` 的 `ShopSystem`,`hooks-in-depth` 的图层与地图钩子,`data-stores` 的读取示例,`declarative-vs-imperative` 的伤害计算器与钩子注册。 | +| D-63 | 按上下文中的长期规范落实模板顺序:理念 → 引导语 → 模板 → 解释,适用于后续所有文档。 | +| D-64 | 按上下文中的长期规范落实模板范围:只有需要读者实现接口或注册内容时才给模板;纯接口使用(如 Store)只给示例。 | +| D-65 | 按上下文中的长期规范补足示例注释:示例必须带解释性注释,注释越多越好。 | + +**构建:** `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 d8153e8..b2041c5 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -6,9 +6,17 @@ 阅读前需要会基础语法:`class`、`new`、`Map`、`import`。 -## 先看读模板 +## Store 是什么 -五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。日常使用基本就是下面这些读法: +Store 里存的是「定义」,游戏里跑的是按定义构造出来的「实例」。图块定义告诉你这个数字代表什么地形、能不能走,真正的图块对象则在楼层加载时按定义创建。 + +Store 是查表用的注册表,不进存档。勇士、地图、Flag 这些游戏状态才进存档;读档不会把 Store 退回原样,也不会恢复你运行期对它的改动。 + +运行时读 Store 是正常操作:查表、算数、拼提示文本、给界面提供数据都可以。运行时写 Store 会绕过加载、存档与录像这一整套机制,问题会在不确定的时机表现出来。 + +## 读 Store 的示例 + +五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。它们不是需要你实现的模板——接口已经由 `data-common` 提供,你只需要调用。如果你想在代码里读这些定义,可以参考下面的示例: ```ts import { ICoreState } from '@user/data-state'; @@ -18,11 +26,13 @@ export function readStores(state: ICoreState): void { // 图块:先按 id 查数字,再取完整定义与类型 const tileNum = state.tileStore.num('my-tile'); if (tileNum !== undefined) { + // getData 返回定义,getType 返回逻辑类型 console.log(state.tileStore.getData(tileNum)); console.log(state.tileStore.getType(tileNum)); } // 道具与怪物:数字或 id 都接受 + // getData / getEnemy 返回定义本身,不是副本 const item = state.itemStore.getData('my-item'); const enemy = state.enemyStore.getEnemy('my-enemy'); @@ -34,45 +44,37 @@ export function readStores(state: ICoreState): void { } ``` -下面先说明 Store 与实例、存档的区别,再逐个讲每个 Store 存什么、能做什么。 - -## Store 是什么 - -Store 里存的是「定义」,游戏里跑的是按定义构造出来的「实例」。图块定义告诉你这个数字代表什么地形、能不能走,真正的图块对象则在楼层加载时按定义创建。 - -Store 是查表用的注册表,不进存档。勇士、地图、Flag 这些游戏状态才进存档;读档不会把 Store 退回原样,也不会恢复你运行期对它的改动。 - -运行时读 Store 是正常操作:查表、算数、拼提示文本、给界面提供数据都可以。运行时写 Store 会绕过加载、存档与录像这一整套机制,问题会在不确定的时机表现出来。 +这个示例只做读取:图块先换算数字,再取定义与类型;其余四个 Store 按各自的键直接查询。返回的不是副本,下面逐个讲每个 Store 存什么、能做什么。 ## tileStore:图块定义 -存的是图块的原始定义:图块数字、字符串 id、默认事件、逻辑类型、四方向通行性,以及事件是撞上去触发还是走上去触发。以图块数字为键。 +每条记录是一个图块的原始定义,以图块数字为键。定义里包含:图块数字本身;字符串 id,供数据里按名字引用;默认事件表,键是事件优先级、值是事件 id;图块逻辑类型,例如地形、动画、道具、怪物、NPC、自动元件;四方向通行性;以及 `eventPass`,它决定触发器的触发方式——`false` 是撞上去触发,`true` 是走上去触发。 -它能按数字取完整定义、默认事件和类型,也能在图块数字与字符串 id 之间换算。登记新定义用 `addTile`;数字或 id 与已有定义冲突时会告警(错误码 `133` / `134`)并覆盖。 +接口方面,它能按数字取完整定义、默认事件和类型,也能在图块数字与字符串 id 之间换算。登记新定义用 `addTile`;数字或 id 与已有定义冲突时会告警(错误码 `133` / `134`)并覆盖。 ## itemStore:道具定义 -存的是道具的原始定义:图块数字、分类、名称、描述、是否在道具栏隐藏、使用 / 拾取效果,以及装备数据。以道具图块数字为键。 +每条记录是一个道具的原始定义,以道具图块数字为键。定义里包含:图块数字;道具分类(永久、一次性、即捡即用、装备);显示名称与描述文本;是否在道具栏中隐藏;以及两个效果字段——`effect` 描述使用或拾取时做什么,`equip` 描述装备后的槽位、动画、数值与百分比加成。两个字段都是必填。 -它能按数字或 id 取整份定义,也能只取分类。登记用 `addItem`;同一个图块数字重复登记会告警(错误码 `181`)并覆盖。 +接口方面,它能按数字或 id 取整份定义,也能只取分类。登记用 `addItem`;同一个图块数字重复登记会告警(错误码 `181`)并覆盖。 ## mapStore:地图原始数据 -存的是地图的原始数据:楼层 id、地图宽度、每一层的图块矩阵、每个图层的字符串别名,以及每个图层指定位置上的事件。以楼层 id 为键。 +每条记录是一张地图的原始数据,以楼层 id 为键。记录里包含:楼层 id、地图宽度、每一层的图块矩阵、每个图层的字符串别名,以及每个图层指定位置上的事件。 -它能按楼层 id 取整份原始数据。登记用 `addMap`;同一个楼层 id 直接覆盖,不告警。 +接口方面,它能按楼层 id 取整份原始数据。登记用 `addMap`;同一个楼层 id 直接覆盖,不告警。 ## enemyStore:怪物定义 -存的是怪物的原始定义:图块数字、怪物属性,以及特殊属性的编号与参数。以怪物图块数字为键。 +每条记录是一只怪物的原始定义,以怪物图块数字为键。定义里包含:图块数字、怪物属性模板,以及特殊属性的编号与参数值。 -它能按数字或 id 取整份定义。登记用 `addEnemy`;同一个图块数字重复登记会告警(错误码 `181`,与道具共用)并覆盖。 +接口方面,它能按数字或 id 取整份定义。登记用 `addEnemy`;同一个图块数字重复登记会告警(错误码 `181`,与道具共用)并覆盖。 ## eventStore:事件实例 -存的是事件实例本身,不是事件定义。以事件 id 为键。 +存的是事件实例本身,不是事件定义。每条记录以事件 id 为键,取回的就是当初登记的那个对象。 -它能按 id 取回存储的那个实例,不是副本。登记用 `addEvent(id, event)`,第一个参数是事件 id,第二个才是实例;重复 id 会告警(错误码 `170`)并覆盖。 +接口方面,它能按 id 取回存储的那个实例,不是副本。登记用 `addEvent(id, event)`,第一个参数是事件 id,第二个才是实例;重复 id 会告警(错误码 `170`)并覆盖。 ## 为什么运行期只读 diff --git a/docs/advanced/declarative-vs-imperative.md b/docs/advanced/declarative-vs-imperative.md index 28a2360..22a67a8 100644 --- a/docs/advanced/declarative-vs-imperative.md +++ b/docs/advanced/declarative-vs-imperative.md @@ -51,7 +51,7 @@ function healHero(hero: IHeroState): void { ### 伤害计算器 -你声明什么:一组属性到伤害结果的换算规则。下面是它的完整模板: +你声明什么:一组属性到伤害结果的换算规则。如果你要替换内置公式,可以参考下面的模板;它只负责声明计算规则,注册那一步由 `useCalculator` 完成: ```ts import { IEnemyAttr, IHeroAttr } from '@user/data-common'; @@ -70,7 +70,7 @@ export class MyDamageCalculator implements IDamageCalculator< calculate( handler: IReadonlyDamageHandler ): IEnemyDamageInfoBase { - // 按 handler 里的属性算出结果 + // 按 handler 里的属性算出结果;这里只保留结构 return { damage: 0, turn: 0 }; } @@ -78,13 +78,13 @@ export class MyDamageCalculator implements IDamageCalculator< handler: IReadonlyDamageHandler, attribute: CriticalableHeroStatus ): number { - // 临界计算的上限;多数需求用不到 + // 临界计算的上限;没有特殊需求就照内置实现保留 return 0; } } ``` -`calculate` 是日常唯一需要关心的成员:它接收 `handler`,返回 `{ damage, turn }`。`getCriticalLimit` 只在临界值计算时用到,改公式时按内置实现 `MainDamageCalculator` 的写法保留即可。内置实现通过 `useCalculator(new ...)` 注册一次。 +模板声明了 `MyDamageCalculator` 类,实现 `IDamageCalculator` 接口。你需要改的是 `calculate` 的方法体,以及返回的 `damage` 与 `turn`;`getCriticalLimit` 只在临界值计算时用到,改公式时按内置实现 `MainDamageCalculator` 的写法保留即可。`calculate` 是日常唯一需要关心的成员:它接收 `handler`,返回 `{ damage, turn }`。内置实现通过 `useCalculator(new ...)` 注册一次。 引擎在哪个时机执行:任何需要伤害数值的地方。显示伤害预览、计算临界值、真正结算战斗,都会调用计算器;同一场战斗里,它可能被反复调用很多次。 @@ -126,6 +126,7 @@ import { MyHeroAttributeHooks } from './hero-attribute-hooks'; // 注册钩子:new 一个钩子实例,挂到关心的主对象上 export function registerHeroAttributeHooks(state: ICoreState): void { + // 钩子类实现 IHeroAttributeHooks,这里只负责创建并注册 state.hero.attribute.addHook(new MyHeroAttributeHooks(state)); } ``` diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md index 567a68f..d4c6e06 100644 --- a/docs/advanced/hooks-in-depth.md +++ b/docs/advanced/hooks-in-depth.md @@ -2,13 +2,27 @@ 中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。做法很简单——建议一个对象一个钩子实例。共享实例是允许的,但不建议;除非你明确了解钩子的内部实现与后果,否则不要这样做。 -钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先给一个完整模板,再解释实例与对象的关系,最后是注册、移除与异步的接口事实。 +钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再给一个完整模板,最后是注册、移除与异步的接口事实。 阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。 -## 先看模板 +## 建议一个对象一个实例 -图层级钩子最典型的写法是:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。 +一个钩子类只是定义。真正参与事件的是你用 `new` 创建出来的实例,以及它被注册到的那个对象实例。 + +`Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。所以: + +- 同一个钩子类可以 `new` 出多个实例,分别注册到不同对象; +- 给图层 A 注册的钩子,不会出现在图层 B 上; +- 一个实例只应注册到一个对象。 + +以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,但它的钩子列表与图层完全分开,接收的是另一套接口(`IGameMapHooks` 与 `IMapLayerHooks`),事件也从不同对象发出。 + +把实例固定在一个对象上,身份就固定在注册处:钩子在哪个对象上被触发,就是因为它注册在哪个对象上。多数钩子方法因此不需要再判断「这次是不是我要的对象」。把同一个实例注册到多个对象是允许的,但回调里就得自己区分触发者,还要承担共享状态带来的后果;除非你明确了解钩子的内部实现,否则不要这样做,本篇也不展开这种用法。 + +## 示例:图层级钩子 + +如果你想给某个图层挂一个只处理它的钩子,可以参考下面的模板:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。 ```ts import { IMapLayer, IMapLayerHooks } from '@user/data-base'; @@ -55,22 +69,10 @@ export function registerGroundHooks( } ``` +这个模板声明了 `GroundBlockHooks` 类,实现 `IMapLayerHooks`;`registerGroundHooks` 负责按 `alias` 找到目标图层,再调用 `layer.addHook` 注册实例。你需要改的是:目标楼层 id 与图层 `alias`、`onUpdateBlock` 的方法体,以及 `registerGroundHooks` 的调用位置(通常放在插件的 `afterLoad`)。 + 模板里的 `readonly layer` 让钩子能访问目标图层本身(读宽高、调用图层方法),不是用来比较一个不存在的参数。`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。 -## 建议一个对象一个实例 - -一个钩子类只是模板。真正参与事件的是你用 `new` 创建出来的实例,以及它被注册到的那个对象实例。 - -`Hookable` 的钩子列表是实例字段:每个地图、每个图层、每个移动器各自持有一份,而不是整个类共享一份。所以: - -- 同一个钩子类可以 `new` 出多个实例,分别注册到不同对象; -- 给图层 A 注册的钩子,不会出现在图层 B 上; -- 一个实例只应注册到一个对象。 - -以图层为例,`GameMap.addLayer(alias)` 每次调用都会创建一个新的 `MapLayer`。同一张地图的 `ground` 与 `wall` 是两个不同的 `MapLayer` 实例,各自有独立的钩子列表。地图对象本身也是 `Hookable`,但它的钩子列表与图层完全分开,接收的是另一套接口(`IGameMapHooks` 与 `IMapLayerHooks`),事件也从不同对象发出。 - -把实例固定在一个对象上,身份就固定在注册处:钩子在哪个对象上被触发,就是因为它注册在哪个对象上。多数钩子方法因此不需要再判断「这次是不是我要的对象」。把同一个实例注册到多个对象是允许的,但回调里就得自己区分触发者,还要承担共享状态带来的后果;除非你明确了解钩子的内部实现,否则不要这样做,本篇也不展开这种用法。 - 如果钩子要在构造时保持通用、注册时才确定图层,可以在 `awake` 里取控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`: ```ts @@ -100,9 +102,11 @@ export class UniversalBlockHooks implements IMapLayerHooks { import { IGameMapHooks, IMapLayer } from '@user/data-base'; import { ICoreState } from '@user/data-state'; +// 地图级钩子:一个地图一个实例,回调里自己筛图层 export class TargetLayerResizeHooks implements IGameMapHooks { constructor( readonly state: ICoreState, + // 注册时传入的目标图层,用来和回调参数比较 readonly targetLayer: IMapLayer ) {} diff --git a/docs/advanced/plugin-system.md b/docs/advanced/plugin-system.md index 42bb330..788c3fd 100644 --- a/docs/advanced/plugin-system.md +++ b/docs/advanced/plugin-system.md @@ -8,6 +8,19 @@ 阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。不需要懂引擎内部实现;涉及内部机制的地方,会先说清「你能拿它做什么」。 +## 插件解决什么问题 + +假设你要加一个任务系统:它有自己的状态、自己的管理器,还要在加载完地图后读一遍任务配置。不做成插件的话,这些代码会散在几个地方——对象在 `src/data/initialize.ts` 里 `new`,存档注册也写在那里,加载后的初始化再塞进某个钩子。功能一多,`initialize.ts` 会变成所有人的公共草稿纸。 + +插件把这一组东西收进一个独立单元: + +- 自己的对象在自己的目录里创建; +- 自己的存档内容在自己的 `awake` 里注册; +- 自己的加载后逻辑在自己的 `afterLoad` 里写; +- 启动时只需要在对应端的插件列表里 `new` 一次。 + +这和[声明式接口](./declarative-vs-imperative.md)是同一个思路:你不去改引擎的初始化流程,只声明「在哪个时机做什么」,时机由引擎统一调用。 + ## 注册与目录 数据端插件放在 `src/data/plugin/`,渲染端插件放在 `src/client/plugin/`,各自在自己的目录里: @@ -68,24 +81,11 @@ export const clientPlugins: IClientPluginDefine[] = [ 同一端的注册表里,`id` 重复时后一个会被忽略,控制台报一条错误(错误码 `78`),所以请保证 `id` 在各自的注册表里唯一。 -## 插件解决什么问题 - -假设你要加一个任务系统:它有自己的状态、自己的管理器,还要在加载完地图后读一遍任务配置。不做成插件的话,这些代码会散在几个地方——对象在 `src/data/initialize.ts` 里 `new`,存档注册也写在那里,加载后的初始化再塞进某个钩子。功能一多,`initialize.ts` 会变成所有人的公共草稿纸。 - -插件把这一组东西收进一个独立单元: - -- 自己的对象在自己的目录里创建; -- 自己的存档内容在自己的 `awake` 里注册; -- 自己的加载后逻辑在自己的 `afterLoad` 里写; -- 启动时只需要在对应端的插件列表里 `new` 一次。 - -这和[声明式接口](./declarative-vs-imperative.md)是同一个思路:你不去改引擎的初始化流程,只声明「在哪个时机做什么」,时机由引擎统一调用。 - ## 插件接口 数据端插件实现 `IDataPluginDefine`(来自 `@user/data-state`),渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`)。两者都由 `id`、依赖声明与三个时机方法组成;渲染端多一份 `clientDeps`,生命周期方法也多一个依赖参数。 -先看两边的完整模板,后面逐项解释。 +如果你想新增一个插件,可以参考下面两个模板。数据端与渲染端各一份,目录、类名与公开成员换成你自己的,其余结构保持不变。 ### 数据端插件模板 @@ -95,6 +95,7 @@ import { IDataPluginDefine } from '@user/data-state'; /** 插件对外暴露的能力;依赖它的插件按这个接口调用 */ export interface IMyPlugin extends IDataPluginDefine { + /** 插件公开给其他插件调用的方法 */ doSomething(): void; } ``` @@ -111,6 +112,7 @@ export class MyPlugin implements IMyPlugin { // 依赖的其他数据端插件 id;没有依赖就留空 readonly dataDeps: string[] = []; + // 同步初始化:创建插件自己的对象 awake(state: ICoreState): void { // 在这里装配插件自己的对象 // 需要进存档的对象,在这里调用 state.addSaveableContent(...) @@ -120,11 +122,13 @@ export class MyPlugin implements IMyPlugin { // 对外方法:其他插件、钩子等通过插件对象调用 } + // 异步初始化:加载开始前执行;不需要就留空 async initialize( state: ICoreState, dependencies: Readonly[] ): Promise {} + // 加载完毕后执行;需要读加载内容时再填 async afterLoad( state: ICoreState, dependencies: Readonly[] @@ -151,6 +155,7 @@ export class MyClientPlugin implements IClientPluginDefine { // 依赖的渲染端插件 id;没有依赖就留空 readonly clientDeps: string[] = []; + // 同步初始化:三个时机都会收到已解析的依赖 awake( state: IClientCore, dataDeps: IDataPlugin[], @@ -159,12 +164,14 @@ export class MyClientPlugin implements IClientPluginDefine { // 依赖已经解析好,直接取用即可 } + // 异步初始化:加载开始前执行 async initialize( state: IClientCore, dataDeps: IDataPlugin[], clientDeps: IClientPlugin[] ): Promise {} + // 加载完毕后执行 async afterLoad( state: IClientCore, dataDeps: IDataPlugin[], @@ -173,6 +180,8 @@ export class MyClientPlugin implements IClientPluginDefine { } ``` +两个模板都声明了一个实现插件接口的类:数据端的 `MyPlugin` 实现 `IMyPlugin extends IDataPluginDefine`,渲染端的 `MyClientPlugin` 实现 `IClientPluginDefine`。它们都没有构造器,插件需要的对象要在 `awake` 里自己创建。你需要改的是:`id`(在对应端唯一)、依赖数组(没有就留空)、以及三个时机方法的方法体——`awake` 里装配对象、`initialize` 里做加载前的异步准备、`afterLoad` 里做依赖加载内容的初始化。数据端模板把对外接口放在 `types.ts`,是为了让依赖它的插件能按接口调用;渲染端模板暂时没有单独拆文件。 + ### 逐项说明 **`id`**:两端的插件标识符,在各自的注册表里唯一。其他插件按这个字符串声明依赖,`getDataPlugin` / `getClientPlugin` 也按它取回插件。 @@ -269,6 +278,7 @@ import { IShopPlugin } from '../shop'; // 依赖商店插件,在初始化时调用它的公开成员 export class ShopConsumerPlugin implements IDataPluginDefine { + // 本插件的唯一标识 readonly id = 'shop-consumer-plugin'; // 依赖顺序与 dataDeps 数组一致 readonly dataDeps = ['data-shop-plugin']; @@ -276,16 +286,19 @@ export class ShopConsumerPlugin implements IDataPluginDefine { awake(state: ICoreState, dependencies: Readonly[]): void { // 依赖已经解析好,直接取 .plugin,不需要判空 const [{ plugin }] = dependencies; + // 断言成商店插件的对外接口,才能调用它公开的方法 const dataShop = plugin as IShopPlugin; const shop = dataShop.createShopState('my-shop'); shop.enable(); } + // 本示例不需要异步初始化,留空即可 async initialize( state: ICoreState, dependencies: Readonly[] ): Promise {} + // 本示例不需要加载后处理 async afterLoad( state: ICoreState, dependencies: Readonly[] @@ -297,32 +310,49 @@ export class ShopConsumerPlugin implements IDataPluginDefine { 要调用插件自己的成员,需要把 `.plugin` 断言成它的对外接口(例如 `IShopPlugin`)。所以写插件时,如果希望别人调用你的方法,就在 `types.ts` 里定一个 `IMyPlugin extends IDataPluginDefine`,把公开成员写进去。渲染端插件的 `dataDeps` / `clientDeps` 参数同理,分别断言成数据端接口与渲染端接口。 -### 用 getDataPlugin 取回 +### 用 getDataPlugin / getClientPlugin 取回 -没有声明依赖、或者在启动之后才按需访问时,用 `state.getDataPlugin(id)`。它返回 `Readonly> | null`: +没有声明依赖、或者在启动之后才按需访问时,数据端用 `state.getDataPlugin(id)`,渲染端用 `state.getClientPlugin(id)`。两端返回的都是只读的插件包装对象,取不到时为 `null`: ```ts import { ICoreState, PluginStatus } from '@user/data-state'; import { IShopPlugin } from './types'; -// 按 id 取回插件,确认状态后再调用 +// 数据端:按 id 取回插件,确认状态后再调用 export function enableShop(state: ICoreState): void { const holder = state.getDataPlugin('data-shop-plugin'); if (!holder || holder.status !== PluginStatus.Loaded) { + // 没注册、id 拼错,或还没执行到 afterLoad return; } holder.plugin.createShopState('my-shop').enable(); } ``` -这里和依赖参数相反,两个检查都不能省: +```ts +import { IClientCore } from '@user/client-modules'; +import { PluginStatus } from '@user/data-state'; +import { IClientShopPlugin } from './types'; + +// 渲染端:接口名与取回方法不同,判空与状态检查完全一致 +export function useShopUI(state: IClientCore): void { + const holder = + state.getClientPlugin('client-shop-plugin'); + if (!holder || holder.status !== PluginStatus.Loaded) { + // 渲染端插件同样可能还没到达 Loaded + return; + } + // 取回的是插件实例,公开成员可以直接使用 + console.log(holder.plugin.ShopUI); +} +``` + +`getDataPlugin` 返回 `Readonly> | null`,`getClientPlugin` 返回 `Readonly> | null`。这里和依赖参数相反,两个检查都不能省: - 返回 `null` 表示没有这个 `id`,可能是没注册,也可能是拼写错了; - `status` 没到你要的阶段,说明它还没执行到那一步,此时调用它的方法可能读到还没装配好的字段。 -渲染端用 `state.getClientPlugin(id)`,返回 `Readonly> | null`,判空方式与 `getDataPlugin` 一致。 - -如果在启动阶段就要用某个插件,优先声明依赖,让引擎保证顺序;`getDataPlugin` 更适合可选的、运行中才发生的调用。 +如果在启动阶段就要用某个插件,优先声明依赖,让引擎保证顺序;这两个按 id 取回的方法更适合可选的、运行中才发生的调用。 ### 插件之外也能调用 @@ -359,28 +389,37 @@ export class MyAttributeHooks implements IHeroAttributeHooks { 商店插件是 `dataPlugins` 里当前唯一的独立插件,适合逐段对照。它的代码在 `src/data/plugin/shop/plugin.ts`: ```ts +// 商店插件:把系统、管理器与存档注册收进一个单元 export class DataShopPlugin implements IShopPlugin { + // 数据端注册表里的唯一标识 id: string = 'data-shop-plugin'; + // 不依赖其他插件,因此会在第一批 awake dataDeps: string[] = []; + // 对外暴露的系统与管理器,其他插件可以直接调用 shop!: IShopSystem; manager!: IShopManager; /** 数据端主对象 */ + // awake 时保存,供创建商店状态使用 private state!: ICoreState; + // 其他插件通过这个方法创建一份商店状态 createShopState(id: string): IShopState { return new ShopState(id, this.state); } awake(state: ICoreState): void { this.state = state; + // 这两个对象都需要主对象,只能在这里 new this.shop = new ShopSystem(state); this.manager = new ShopManager(state); + // 注册的是管理器,不是插件本身 state.addSaveableContent('@plugin/shop', this.manager); } + // 商店不依赖加载内容,两个异步时机留空 async initialize(): Promise {} async afterLoad(): Promise {} @@ -425,11 +464,13 @@ export class MainDataPlugin implements IDataMainPlugin { logger.error(79, 'Data'); return; } + // 朝向组在 awake 里已经由入口传入,这里可以直接用 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, diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md index 955b95e..0998803 100644 --- a/docs/advanced/replay-consistency.md +++ b/docs/advanced/replay-consistency.md @@ -6,30 +6,6 @@ 阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../intermediate/code-basics.md)。 -## 先看模板 - -标记加在你自己的、会改状态的方法上。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记: - -```ts -import { shouldReplay } from '@user/data-common'; -import { IShopSystem, ShopTradeResult } from './types'; - -// 商店插件节选:完整类结构,未展示的成员用注释省略 -export class ShopSystem implements IShopSystem { - // ... 其他成员 - - // 购买会改变金币与商店库存,必须计入录像 - @shouldReplay('Buying items should be replayed.') - buyItem(item: number | string, count: number): ShopTradeResult { - // ... 原有的购买逻辑 - } -} -``` - -装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。 - -`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。 - ## 什么该记 同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。 @@ -52,6 +28,34 @@ export class ShopSystem implements IShopSystem { 只读查询、纯计算、展示与搜索路径不要标。它们不改变游戏状态,标了只会让告警列表变长,把真正的漏记埋进噪声里。 +## 给会改状态的方法加标记 + +标记加在你自己的、会改状态的方法上。如果你不确定该把标记写在哪里,可以参考下面的模板:它把一个会改状态的公开方法标成「应计入录像」,其余成员用注释省略。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记: + +```ts +// shouldReplay 从数据端公共包引入 +import { shouldReplay } from '@user/data-common'; +import { IShopSystem, ShopTradeResult } from './types'; + +// 商店插件节选:完整类结构,未展示的成员用注释省略 +export class ShopSystem implements IShopSystem { + // ... 其他成员 + + // 购买会改变金币与商店库存,必须计入录像 + // 装饰器参数是写给人看的说明,出现在告警里 + @shouldReplay('Buying items should be replayed.') + buyItem(item: number | string, count: number): ShopTradeResult { + // ... 原有的购买逻辑 + } +} +``` + +这个模板声明了 `ShopSystem` 类,实现 `IShopSystem` 接口;`@shouldReplay` 紧贴在 `buyItem` 方法上方,装饰的是这个方法。你需要改的是:把类与方法换成你真正会改状态的那一个,把 `message` 写成这个方法改变了什么状态;同一个类里还有其它会改状态的入口时,按同样方式逐个标注。 + +装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。 + +`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。 + ## 两个装饰器 两个装饰器都写在类的方法上方,作用对象是「录像安全检查」,不是录像本身。 diff --git a/docs/advanced/saveable-content.md b/docs/advanced/saveable-content.md index 5279a37..48231dd 100644 --- a/docs/advanced/saveable-content.md +++ b/docs/advanced/saveable-content.md @@ -6,9 +6,29 @@ 阅读前需要会基础语法:`class`、`new`、`Map`、`import`。如果你还没写过插件,建议先看[数据插件](../intermediate/data-plugins.md)与[插件系统](./plugin-system.md);可存档内容通常就注册在插件的 `awake` 里。 -## 先看模板 +## 什么该存,什么不该存 -可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。下面把两部分一次写完,后面再逐项解释。 +判断一个字段该不该进存档,只看一件事:读档之后,它还能不能从别的地方算回来。 + +- 算不回来、又会影响后续结果的,必须存; +- 每次都能从已有数据重新算出来的,不该存; +- 只影响当下表现的临时状态,也不该存。 + +| 状态 | 该不该存 | 原因 | +| -------------------------- | -------- | --------------------------------------- | +| 勇士位置、属性、道具 | 存 | 影响后续所有结算 | +| 地图图块改动、点事件 | 存 | 玩家改造过世界,无法从原始地图推回 | +| Flag 变量 | 存 | 跨事件、跨楼层的持久状态 | +| 敌人当前状态 | 存 | 影响战斗与后续查询 | +| 静态图块缓存、坐标索引 | 不存 | 由地图数据派生,随时可以重建 | +| 临时动画、提示、界面状态 | 不存 | 读档后重新产生,或根本不影响游戏结果 | +| 战斗上下文、寻路等 L2 系统 | 不存 | 只操作 Layer 1 数据,读档后按需重新建立 | + +漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是只交出重建所需的最小信息,`loadState` 的原则是先清空旧状态,再按存档重建。缓存不要存,读档后第一次用到时重新生成即可。 + +## 写一个可存档对象 + +可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。如果你有一份自定义状态需要跟着存档走,可以参考下面的模板;模板把两部分一次写完,后面再逐项解释。 ```ts // src/data/plugin/my-plugin/state.ts @@ -19,6 +39,7 @@ export interface IMySave { readonly values: Map; } +// 可存档对象:实现 ISaveableContent,泛型参数是存档形状 export class MySaveable implements ISaveableContent { // 运行时的真实数据;换成你真正要保存的结构 private readonly values: Map = new Map(); @@ -64,14 +85,18 @@ import { ICoreState } from '@user/data-state'; import { IMyPlugin } from './types'; import { MySaveable } from './state'; +// 插件:创建可存档对象,并在 awake 里注册 export class MyPlugin implements IMyPlugin { + // 插件 id,与存档 id 是两套命名 readonly id = 'my-plugin'; readonly dataDeps: string[] = []; // 供插件其他方法使用 + // 其他插件通过 IMyPlugin 访问 store!: MySaveable; awake(state: ICoreState): void { + // 对象在插件自己的目录里创建 this.store = new MySaveable(); // 存档 id 要唯一,并与插件 id 分开命名 state.addSaveableContent('@plugin/my-store', this.store); @@ -83,27 +108,9 @@ export class MyPlugin implements IMyPlugin { } ``` -`state.ts` 声明存档形状并实现 `saveState` / `loadState`;`types.ts` 声明插件接口;`plugin.ts` 创建对象并在 `awake` 里注册。下面解释这两部分为什么这样写。 +模板一共声明了三样东西:`state.ts` 里的 `IMySave` 是存档形状,`MySaveable` 实现 `ISaveableContent`,负责在活对象与存档数据之间转换;`types.ts` 里的 `IMyPlugin` 继承 `IDataPluginDefine`,把可存档对象作为公开成员暴露出去;`plugin.ts` 里的 `MyPlugin` 实现 `IMyPlugin`,在 `awake` 里创建对象并调用 `addSaveableContent` 注册。 -## 什么该存,什么不该存 - -判断一个字段该不该进存档,只看一件事:读档之后,它还能不能从别的地方算回来。 - -- 算不回来、又会影响后续结果的,必须存; -- 每次都能从已有数据重新算出来的,不该存; -- 只影响当下表现的临时状态,也不该存。 - -| 状态 | 该不该存 | 原因 | -| -------------------------- | -------- | --------------------------------------- | -| 勇士位置、属性、道具 | 存 | 影响后续所有结算 | -| 地图图块改动、点事件 | 存 | 玩家改造过世界,无法从原始地图推回 | -| Flag 变量 | 存 | 跨事件、跨楼层的持久状态 | -| 敌人当前状态 | 存 | 影响战斗与后续查询 | -| 静态图块缓存、坐标索引 | 不存 | 由地图数据派生,随时可以重建 | -| 临时动画、提示、界面状态 | 不存 | 读档后重新产生,或根本不影响游戏结果 | -| 战斗上下文、寻路等 L2 系统 | 不存 | 只操作 Layer 1 数据,读档后按需重新建立 | - -漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是只交出重建所需的最小信息,`loadState` 的原则是先清空旧状态,再按存档重建。缓存不要存,读档后第一次用到时重新生成即可。 +你需要改的是:`IMySave` 的字段(只保留重建必需的数据)、`MySaveable` 里真正的状态与对外方法、插件 `id`、注册 id,以及把 `MySaveable` 换成你自己的类。`initialize` / `afterLoad` 在模板里是空的,需要等加载内容时再填。下面解释 `saveState` / `loadState` 的约束,以及注册时要注意什么。 ## saveState 与 loadState @@ -136,15 +143,18 @@ 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); }