From 2266c1559581676d6c324c72f7beaa426ebb8c99 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sat, 17 Oct 2026 18:59:50 +0800 Subject: [PATCH] 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 --- docs/intermediate/data-plugins.md | 62 +++++++++++ docs/intermediate/events.md | 161 +++++++++++++++++++++++++++ docs/intermediate/replay-commands.md | 46 ++++++++ 3 files changed, 269 insertions(+) create mode 100644 docs/intermediate/data-plugins.md create mode 100644 docs/intermediate/events.md create mode 100644 docs/intermediate/replay-commands.md diff --git a/docs/intermediate/data-plugins.md b/docs/intermediate/data-plugins.md new file mode 100644 index 0000000..7929475 --- /dev/null +++ b/docs/intermediate/data-plugins.md @@ -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 占位 / 待提供 +一个较简单的插件示例待补充。是否将它纳入本篇,取决于示例本身的难度。 +::: diff --git a/docs/intermediate/events.md b/docs/intermediate/events.md new file mode 100644 index 0000000..041a31b --- /dev/null +++ b/docs/intermediate/events.md @@ -0,0 +1,161 @@ +# 自定义事件 + +事件系统让地图事件可以调用一段代码。引擎内置了一批事件函数,例如设置图块、移动勇士;你也可以按同样的方式**自定义新的事件函数**,让事件语句调用到它。本篇只讲如何自定义,不展开事件系统的原理。 + +## 内建函数(BuiltInFunction) + +事件里能调用的每一个动作都是一个**内建函数**,接口为 `BuiltInFunction`: + +```ts +interface BuiltInFunction
{ + 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)。 + +## 编辑器识别 + +编辑器若要为自定义事件生成图形化配置,需要先能识别出这个事件的参数结构。目前这一识别方式尚未最终确定。在它确定之前,按本篇的方式编写普通内建函数即可。 diff --git a/docs/intermediate/replay-commands.md b/docs/intermediate/replay-commands.md new file mode 100644 index 0000000..9a4a820 --- /dev/null +++ b/docs/intermediate/replay-commands.md @@ -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` 来掩盖控制台的录像告警。它只应用于「确实不该计入录像」的方法,滥用很可能导致录像出错。 +:::