docs(10-20): 修订模板结构与注释(用户第5轮)

This commit is contained in:
unanmed 2026-10-21 13:01:21 +08:00
parent 5dfcde853a
commit 2f181c8f2e
7 changed files with 210 additions and 109 deletions

View File

@ -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。
**状态:已处理**

View File

@ -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`)并覆盖。
## 为什么运行期只读

View File

@ -51,7 +51,7 @@ function healHero(hero: IHeroState<IHeroAttr>): void {
### 伤害计算器
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。下面是它的完整模板:
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。如果你要替换内置公式,可以参考下面的模板;它只负责声明计算规则,注册那一步由 `useCalculator` 完成:
```ts
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
@ -70,7 +70,7 @@ export class MyDamageCalculator implements IDamageCalculator<
calculate(
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>
): IEnemyDamageInfoBase {
// 按 handler 里的属性算出结果
// 按 handler 里的属性算出结果;这里只保留结构
return { damage: 0, turn: 0 };
}
@ -78,13 +78,13 @@ export class MyDamageCalculator implements IDamageCalculator<
handler: IReadonlyDamageHandler<IEnemyAttr, IHeroAttr>,
attribute: CriticalableHeroStatus<IHeroAttr>
): number {
// 临界计算的上限;多数需求用不到
// 临界计算的上限;没有特殊需求就照内置实现保留
return 0;
}
}
```
`calculate` 是日常唯一需要关心的成员:它接收 `handler`,返回 `{ damage, turn }`。`getCriticalLimit` 只在临界值计算时用到,改公式时按内置实现 `MainDamageCalculator` 的写法保留即可。内置实现通过 `useCalculator(new ...)` 注册一次。
模板声明了 `MyDamageCalculator` 类,实现 `IDamageCalculator` 接口。你需要改的是 `calculate` 的方法体,以及返回的 `damage` 与 `turn`;`getCriticalLimit` 只在临界值计算时用到,改公式时按内置实现 `MainDamageCalculator` 的写法保留即可。`calculate` 是日常唯一需要关心的成员:它接收 `handler`,返回 `{ damage, turn }`。内置实现通过 `useCalculator(new ...)` 注册一次。
<strong>引擎在哪个时机执行</strong>:任何需要伤害数值的地方。显示伤害预览、计算临界值、真正结算战斗,都会调用计算器;同一场战斗里,它可能被反复调用很多次。
@ -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));
}
```

View File

@ -2,13 +2,27 @@
中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。做法很简单——<strong>建议一个对象一个钩子实例</strong>。共享实例是允许的,但不建议;除非你明确了解钩子的内部实现与后果,否则不要这样做。
钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先给一个完整模板,再解释实例与对象的关系,最后是注册、移除与异步的接口事实。
钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再给一个完整模板,最后是注册、移除与异步的接口事实。
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。
## 先看模板
## 建议一个对象一个实例
图层级钩子最典型的写法是:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。
一个钩子类只是定义。真正参与事件的是你用 `new` 创建出来的<strong>实例</strong>,以及它被注册到的那个对象实例。
`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` 创建出来的<strong>实例</strong>,以及它被注册到的那个对象实例。
`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
) {}

View File

@ -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<IDataPlugin>[]
): Promise<void> {}
// 加载完毕后执行;需要读加载内容时再填
async afterLoad(
state: ICoreState,
dependencies: Readonly<IDataPlugin>[]
@ -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<void> {}
// 加载完毕后执行
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<IDataPlugin>[]): void {
// 依赖已经解析好,直接取 .plugin,不需要判空
const [{ plugin }] = dependencies;
// 断言成商店插件的对外接口,才能调用它公开的方法
const dataShop = plugin as IShopPlugin;
const shop = dataShop.createShopState('my-shop');
shop.enable();
}
// 本示例不需要异步初始化,留空即可
async initialize(
state: ICoreState,
dependencies: Readonly<IDataPlugin>[]
): Promise<void> {}
// 本示例不需要加载后处理
async afterLoad(
state: ICoreState,
dependencies: Readonly<IDataPlugin>[]
@ -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<IDataPlugin<T>> | 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<IShopPlugin>('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<IClientShopPlugin>('client-shop-plugin');
if (!holder || holder.status !== PluginStatus.Loaded) {
// 渲染端插件同样可能还没到达 Loaded
return;
}
// 取回的是插件实例,公开成员可以直接使用
console.log(holder.plugin.ShopUI);
}
```
`getDataPlugin` 返回 `Readonly<IDataPlugin<T>> | null`,`getClientPlugin` 返回 `Readonly<IClientPlugin<T>> | null`。这里和依赖参数相反,两个检查都不能省:
- 返回 `null` 表示没有这个 `id`,可能是没注册,也可能是拼写错了;
- `status` 没到你要的阶段,说明它还没执行到那一步,此时调用它的方法可能读到还没装配好的字段。
渲染端用 `state.getClientPlugin(id)`,返回 `Readonly<IClientPlugin<T>> | null`,判空方式与 `getDataPlugin` 一致。
如果在启动阶段就要用某个插件,优先声明依赖,让引擎保证顺序;`getDataPlugin` 更适合可选的、运行中才发生的调用。
如果在启动阶段就要用某个插件,优先声明依赖,让引擎保证顺序;这两个按 id 取回的方法更适合可选的、运行中才发生的调用。
### 插件之外也能调用
@ -359,28 +389,37 @@ export class MyAttributeHooks implements IHeroAttributeHooks<IHeroAttr> {
商店插件是 `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<void> {}
async afterLoad(): Promise<void> {}
@ -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,

View File

@ -6,30 +6,6 @@
阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../intermediate/code-basics.md)。
## 先看模板
标记加在<strong>你自己的、会改状态的方法</strong>上。商店插件是现成的例子,它的 `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` 也都按同样方式标注。
<strong>`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。</strong>录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。
## 什么该记
同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。
@ -52,6 +28,34 @@ export class ShopSystem implements IShopSystem {
只读查询、纯计算、展示与搜索路径不要标。它们不改变游戏状态,标了只会让告警列表变长,把真正的漏记埋进噪声里。
## 给会改状态的方法加标记
标记加在<strong>你自己的、会改状态的方法</strong>上。如果你不确定该把标记写在哪里,可以参考下面的模板:它把一个会改状态的公开方法标成「应计入录像」,其余成员用注释省略。商店插件是现成的例子,它的 `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` 也都按同样方式标注。
<strong>`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。</strong>录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。
## 两个装饰器
两个装饰器都写在<strong>类的方法</strong>上方,作用对象是「录像安全检查」,不是录像本身。

View File

@ -6,9 +6,29 @@
阅读前需要会基础语法:`class`、`new`、`Map`、`import`。如果你还没写过插件,建议先看[数据插件](../intermediate/data-plugins.md)与[插件系统](./plugin-system.md);可存档内容通常就注册在插件的 `awake` 里。
## 先看模板
## 什么该存,什么不该存
可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。下面把两部分一次写完,后面再逐项解释。
判断一个字段该不该进存档,只看一件事:<strong>读档之后,它还能不能从别的地方算回来</strong>。
- 算不回来、又会影响后续结果的,必须存;
- 每次都能从已有数据重新算出来的,不该存;
- 只影响当下表现的临时状态,也不该存。
| 状态 | 该不该存 | 原因 |
| -------------------------- | -------- | --------------------------------------- |
| 勇士位置、属性、道具 | 存 | 影响后续所有结算 |
| 地图图块改动、点事件 | 存 | 玩家改造过世界,无法从原始地图推回 |
| Flag 变量 | 存 | 跨事件、跨楼层的持久状态 |
| 敌人当前状态 | 存 | 影响战斗与后续查询 |
| 静态图块缓存、坐标索引 | 不存 | 由地图数据派生,随时可以重建 |
| 临时动画、提示、界面状态 | 不存 | 读档后重新产生,或根本不影响游戏结果 |
| 战斗上下文、寻路等 L2 系统 | 不存 | 只操作 Layer 1 数据,读档后按需重新建立 |
漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是<strong>只交出重建所需的最小信息</strong>,`loadState` 的原则是<strong>先清空旧状态,再按存档重建</strong>。缓存不要存,读档后第一次用到时重新生成即可。
## 写一个可存档对象
可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。如果你有一份自定义状态需要跟着存档走,可以参考下面的模板;模板把两部分一次写完,后面再逐项解释。
```ts
// src/data/plugin/my-plugin/state.ts
@ -19,6 +39,7 @@ export interface IMySave {
readonly values: Map<string, number>;
}
// 可存档对象:实现 ISaveableContent,泛型参数是存档形状
export class MySaveable implements ISaveableContent<IMySave> {
// 运行时的真实数据;换成你真正要保存的结构
private readonly values: Map<string, number> = 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<IMySave>`,负责在活对象与存档数据之间转换;`types.ts` 里的 `IMyPlugin` 继承 `IDataPluginDefine`,把可存档对象作为公开成员暴露出去;`plugin.ts` 里的 `MyPlugin` 实现 `IMyPlugin`,在 `awake` 里创建对象并调用 `addSaveableContent` 注册。
## 什么该存,什么不该存
判断一个字段该不该进存档,只看一件事:<strong>读档之后,它还能不能从别的地方算回来</strong>。
- 算不回来、又会影响后续结果的,必须存;
- 每次都能从已有数据重新算出来的,不该存;
- 只影响当下表现的临时状态,也不该存。
| 状态 | 该不该存 | 原因 |
| -------------------------- | -------- | --------------------------------------- |
| 勇士位置、属性、道具 | 存 | 影响后续所有结算 |
| 地图图块改动、点事件 | 存 | 玩家改造过世界,无法从原始地图推回 |
| Flag 变量 | 存 | 跨事件、跨楼层的持久状态 |
| 敌人当前状态 | 存 | 影响战斗与后续查询 |
| 静态图块缓存、坐标索引 | 不存 | 由地图数据派生,随时可以重建 |
| 临时动画、提示、界面状态 | 不存 | 读档后重新产生,或根本不影响游戏结果 |
| 战斗上下文、寻路等 L2 系统 | 不存 | 只操作 Layer 1 数据,读档后按需重新建立 |
漏存会让字段回到默认值或只恢复一半;多存会把缓存、派生数据也写进去,读档时旧缓存覆盖新算出的结果,存档体积还会膨胀。所以 `saveState` 的原则是<strong>只交出重建所需的最小信息</strong>,`loadState` 的原则是<strong>先清空旧状态,再按存档重建</strong>。缓存不要存,读档后第一次用到时重新生成即可。
你需要改的是:`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<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);
}