docs(10-05): write events, replay-commands, and data-plugins docs

- events: BuiltInFunction, IBlockEventEnv, createEventRegistrations, setFlag/healHero examples
- replay-commands: core principle, Active/Passive, @shouldReplay/@ignoreReplay (no example)
- data-plugins: IDataPluginDefine lifecycle, dataPlugins registration, placeholder example
This commit is contained in:
unanmed 2026-10-17 18:59:50 +08:00
parent 7a7cda24b2
commit 2266c15595
3 changed files with 269 additions and 0 deletions

View File

@ -0,0 +1,62 @@
# 数据插件
插件是数据端的**强拓展系统**:它把一组相关的对象与逻辑打包在一起,挂到数据端主对象 `state` 上,并在加载流程的固定时机初始化。本篇只讲基础用法;插件系统更底层的工作原理见高级教程。
## 插件定义 IDataPluginDefine
一个插件实现 `IDataPluginDefine` 接口:
| 成员 | 说明 |
| --- | --- |
| `id` | 插件标识符,全局唯一 |
| `dependencies` | 依赖的其它插件 id 数组,不允许循环依赖 |
| `awake(state, dependencies)` | 同步初始化 |
| `initialize(state, dependencies)` | 异步初始化 |
| `afterLoad(state, dependencies)` | 加载完成后初始化 |
## 时机次序
数据端启动时,每个插件会依次经历三个阶段:
```text
awake(装配)→ initialize(加载前)→ afterLoad(加载后)
```
- **`awake`**:数据端主对象创建完毕、正式初始化之前调用。同步执行,适合在这里装配插件自己的对象、注册可存档内容;
- **`initialize`**:加载开始**前**调用。返回 `Promise`,可以阻塞加载——它不兑现,依赖它的插件就不会开始初始化,游戏加载也不会开始;
- **`afterLoad`**:主对象加载**完毕后**调用。返回 `Promise`,同样可阻塞。适合做依赖加载内容(地图、数据等)的初始化。
三个阶段都按依赖顺序执行:被依赖的插件先完成同一阶段,才轮到依赖它的一方。`dependencies` 里传入的正是这些依赖插件的对象。
## 注册插件
插件在 `src/data/plugin/list.ts` 的 `dataPlugins` 数组中登记:
```ts
import { IDataPluginDefine } from '@user/data-state';
import { DataShopPlugin } from './shop';
/** 数据端插件列表 */
export const dataPlugins: IDataPluginDefine[] = [new DataShopPlugin()];
```
数据端启动时按这个列表加装插件。把你自己写的插件 `new` 出来加进数组即可(插件目录见[源码导览](./src-tour.md))。
## 可存档内容
插件常常带有需要进存档的数据。在 `awake` 中用 `state.addSaveableContent(id, content)` 把可存档对象注册进存档系统,读档时它会被一并恢复:
```ts
awake(state: ICoreState): void {
// 注册插件的可存档内容
state.addSaveableContent('@plugin/my-plugin', this.saveable);
}
```
`id` 是这个可存档内容在存档中的标识,需唯一。之后可用 `state.getDataPlugin(id)` 取回已加装的插件(见[数据端总览](./data-state-overview.md))。
## 示例
:::info 占位 / 待提供
一个较简单的插件示例待补充。是否将它纳入本篇,取决于示例本身的难度。
:::

161
docs/intermediate/events.md Normal file
View File

@ -0,0 +1,161 @@
# 自定义事件
事件系统让地图事件可以调用一段代码。引擎内置了一批事件函数,例如设置图块、移动勇士;你也可以按同样的方式**自定义新的事件函数**,让事件语句调用到它。本篇只讲如何自定义,不展开事件系统的原理。
## 内建函数(BuiltInFunction)
事件里能调用的每一个动作都是一个**内建函数**,接口为 `BuiltInFunction`:
```ts
interface BuiltInFunction<P, E> {
name: string;
save?: boolean;
func: (parameters: P, env: E) => any;
}
```
- `name`:事件语句里调用它时使用的名字,需全局唯一;
- `func(param, env)`:执行逻辑。`param` 是调用时传入的参数对象,`env` 是当前事件的环境。
一个内建函数通常写成一个类,`name` 是它的属性,`func` 是它的方法。内置的 `setBlock`、`moveHero` 等就是这样实现的。
## 事件环境 env
`func` 的第二个参数是事件环境 `IBlockEventEnv`,里面放的是「这次事件在什么情况下被触发」。常用的字段:
| 字段 | 说明 |
| --- | --- |
| `state` | 数据端主对象,一切接口的入口 |
| `type` | 事件类型(普通事件 / 点事件 / 图块事件) |
| `trigger` | 本次事件的触发器类型 |
| `heroLocator` | 触发事件时勇士的位置 |
| `heroFloor` | 触发事件时勇士所在楼层 |
| `triggerLocator` | 触发者的位置,可能不存在 |
| `tile` | 触发事件的图块,可能不存在 |
| `layer` | 触发事件的图层,可能不存在 |
| `map` | 触发事件的地图,可能不存在 |
| `system` | 事件系统对象 |
其余字段见 API 参考。多数情况下你只需要 `state`:通过它能访问勇士、地图、Flag 等(见[数据端总览](./data-state-overview.md))。
从 `env` 取图层时要小心它可能不存在,内置的 `getPossibleLayer(env)` 就是用来兜底的:它依次尝试 `env.layer`、`env.map`、`env.heroFloor` 对应的地图,帮你拿到一个可用的事件图层。
## 参数接口
每个内建函数都有自己的参数接口,定义在 `src/data/event/types.ts`。例如:
```ts
interface ISetBlockEventParam {
readonly x: number;
readonly y: number;
readonly tile: number | string;
}
```
自定义事件的参数接口也建议放在这个文件里,与内置参数放在一起。
## 注册事件
光写内建函数还不够,还要把它注册进事件系统。注册入口是 `src/data/event/registrations.ts` 的 `createEventRegistrations()`,它返回一个 `BuiltInFunction[]`:
```ts
export function createEventRegistrations(): BuiltInFunction[] {
return [
// 地图控制
new EventSetBlock(),
new EventMoveBlock(),
new EventRemoveBlock(),
// 玩家控制
new EventMoveHero(),
new EventStepHero(),
// 事件控制
new EventInsertEvents(),
new EventInsertEvent()
];
}
```
目前它注册了 7 个内置事件:地图控制 `setBlock` / `moveBlock` / `removeBlock`、玩家控制 `moveHero` / `stepHero`、事件控制 `insertEvents` / `insertEvent`。把你自己写的事件函数 `new` 出来加进这个数组,就完成注册。
## 内置事件导读
内置事件的实现分别放在 `src/data/event/` 下:
- `map.ts`:图块相关。`setBlock` 取到事件图层后把指定坐标设为某个图块,`moveBlock` 把图块按步骤移动,`removeBlock` 删除图块;
- `hero.ts`:勇士相关。`moveHero` 让勇士按给定步骤移动,`stepHero` 让勇士前进一步;
- `event.ts`:事件相关。`insertEvents` 按 id 临时插入多个已有事件,`insertEvent` 临时执行一段事件语句。
想照着写,挑一个内置事件读它的 `func` 即可。
## 示例:设置 Flag
下面这个事件函数把参数里的 Flag 名与值写进 Flag 系统:
```ts
import { BuiltInFunction } from '@motajs/anon-tokyo';
import { IBlockEventEnv } from '@user/data-system';
interface ISetFlagEventParam {
/** Flag 名 */
readonly field: string;
/** 要设置的值 */
readonly value: number | string | boolean;
}
export class EventSetFlag implements BuiltInFunction<
ISetFlagEventParam,
IBlockEventEnv
> {
name: string = 'setFlag';
func(param: ISetFlagEventParam, env: IBlockEventEnv) {
env.state.flags.setFieldValue(param.field, param.value);
}
}
```
写好后加进 `createEventRegistrations()`:
```ts
export function createEventRegistrations(): BuiltInFunction[] {
return [
// ... 原有事件
new EventSetFlag() // [!code ++]
];
}
```
Flag 的读写接口见 [Flag 变量](./flags.md)。
## 示例:勇士回血
再写一个让勇士回复生命值的事件函数。它读取参数里的回复量,通过可修改属性接口加到勇士的 `hp` 上:
```ts
import { BuiltInFunction } from '@motajs/anon-tokyo';
import { IBlockEventEnv } from '@user/data-system';
interface IHealHeroEventParam {
/** 回复的生命值 */
readonly hp: number;
}
export class EventHealHero implements BuiltInFunction<
IHealHeroEventParam,
IBlockEventEnv
> {
name: string = 'healHero';
func(param: IHealHeroEventParam, env: IBlockEventEnv) {
env.state.hero.getModifiableAttribute().add('hp', param.hp);
}
}
```
勇士属性的读写方式见[勇士属性](./hero-attribute.md)。
## 编辑器识别
编辑器若要为自定义事件生成图形化配置,需要先能识别出这个事件的参数结构。目前这一识别方式尚未最终确定。在它确定之前,按本篇的方式编写普通内建函数即可。

View File

@ -0,0 +1,46 @@
# 录像指令
录像系统(`state.replaySystem`)把玩家在游戏中的操作记录成一条条**录像步**,之后可以重新播放。它的主要用途是**录像验证**——只运行数据端、重放录像,就能确认同样的操作是否得到同样的结果(原理见[数据端与渲染端](./data-vs-render.md))。
## 核心原则
:::info 核心原则
**玩家直接产生、会影响最终成绩的行为,必须计入录像。**
:::
这句话包含两个条件:
- **玩家直接产生**:是玩家亲手做出的操作,例如移动、使用道具、装备道具。由代码或时间自动发生的变化不算;
- **会影响最终成绩**:这个操作会改变游戏结果。只改渲染表现、不进数据端的操作不算。
满足两个条件的操作,都必须作为一条录像步记录下来。否则重放这条录像时,玩家的操作没有被重新执行,结果就会与原始游玩对不上。
## 主动录像与被动录像
录像步分两类,由 `ReplayCommandType` 表示:
- **主动录像(`Active`)**:指令自己就会执行对应的操作。引擎内置的移动、瞬移、使用道具(见[勇士道具](./hero-items.md))、装备(见[勇士装备](./hero-equipment.md))都是主动录像;
- **被动录像(`Passive`)**:指令不自己执行,而是让外部通过录像沙箱的 `getPassive()` 取到这一步的参数,再由外部决定怎么处理。
## 新增自定义录像指令
录像指令属于**引擎内容**,需要改动 `packages-user` 下的包,而不是只在 `src` 里写几行代码。新增一条自定义录像指令大致分三步:
1. 在录像指令码枚举 `ReplayCode` 中分配一个新码。该枚举属于录像格式的一部分,遵循「只增不改」——新语义必须用新码,不能复用旧码;
2. 实现 `IReplayCommand`:`type` 指明是主动还是被动录像,`execute(step)` 执行这一步对应的操作并返回 `ReplayCommandResult`;若需要「连续步骤的后处理」,再实现可选的 `finalize()`;
3. 用 `state.replaySystem.registerCommand(code, command)` 把指令注册进录像系统。引擎内置指令的注册代码在 `src/data/replay/initialize.ts` 中。
`execute` 的返回值 `ReplayCommandResult` 有三种:`Success`(成功)、`Failed`(失败)、`Ignored`(失败但可忽略,继续执行后续录像,并会在控制台输出信息)。
## 录像安全检测:@shouldReplay 与 @ignoreReplay
录像系统会检查「一步录像执行后,游戏是否真的记录下了新的录像步」。为了让这个检查覆盖到正确的方法,可以用两个方法装饰器给方法打标:
- `@shouldReplay(message)`:把方法标记为**应该计入录像**。它会为这次检查登记一段区间,并把 `message` 作为信息记录;一旦检查发现问题,就用这些信息告诉你哪些调用路径没有被计入录像;
- `@ignoreReplay(message)`:把方法标记为**忽略本次录像检查**,一般用于异步内容等不会立刻修改状态的方法。
检测区间的开始与结束由录像系统在每步录像执行前后统一控制:执行前开始收集,执行后结束并核对区间内是否产生了新的录像步。若没有,就在控制台输出告警,并列出登记过却未被计入的 `@shouldReplay` 路径。
:::warning
不要用 `@ignoreReplay` 来掩盖控制台的录像告警。它只应用于「确实不该计入录像」的方法,滥用很可能导致录像出错。
:::