docs(10-iii): 重写高级 III 文档并补充写作规范

This commit is contained in:
AncTe committed 2026-10-28 20:27:55 +08:00
1 parent 507fcc59ef
commit e69dc0af06
12 files changed
+1805 -1249

No files matched your search

-4
View File
@@ -249,10 +249,6 @@ export default defineConfig({
text: '事件系统',
link: '/advanced/iii/event-system'
},
{
text: 'Anon-Tokyo 解释器',
link: '/advanced/iii/anon-tokyo'
},
{
text: '通行性谓词与寻路原理',
link: '/advanced/iii/passability-and-pathfinding'
-167
View File
@@ -1,167 +0,0 @@
# Anon-Tokyo 解释器
事件脚本在数据里不是一段字符串,而是结构化的语句数组。解释器把语句数组编译成可执行程序,再由迭代器逐步求值。数据端只负责把事件对象交给它,编译产物会被事件对象缓存。
本篇讲这个模型:语言特性、编译产物、执行上下文与迭代器;事件怎么收集、触发器怎么过滤见[事件系统](./event-system.md)。脚本语法与解析器内部不在本篇范围。
## 系统模型
| 角色 | 职责 |
| --------------------------- | -------------------------------------- |
| `LanguageFeature` | 语言特性:内建函数表与全局函数表 |
| `AnonTokyoInterpreter` | 注册函数、把语句编译成程序、执行程序 |
| `AnonTokyoExecutable` | 编译产物:程序节点序列,可直接执行 |
| `AnonTokyoExecutionContext` | 一次执行的调用栈与环境 |
| `AnonTokyoIterator` | 执行循环:反复求值当前节点 |
| `Statement` / `Block` | 语句与语句数组 |
| `Scope` | 每次求值时可见的作用域 |
| `OP` | 节点返回的操作:前进、调用、返回、退出 |
一次执行的模型如下。语言特性先交给解释器,解释器据此编译语句;每次执行都会新建上下文,上下文里保存调用栈与环境,迭代器在上下文中逐步推进。
```text
LanguageFeature(builtInFunctions + globalFunctions)
│ 构造解释器
▼
AnonTokyoInterpreter ──compile(script)──> AnonTokyoExecutable(程序节点)
│ │ exec(parameters, env)
│ ▼
└──exec(script, parameters, env)──> AnonTokyoExecutionContext(调用栈 + env)
│
▼
AnonTokyoIterator(反复求值当前节点)
```
## 语句模型
脚本是一个 `Statement[]`。语句种类由 `StatementType` 枚举列出:
| 种类 | 形状与作用 |
| ----------------- | ------------------------------------------- |
| `Expression` | 执行一个表达式,不改变控制流 |
| `Call` | 按名字调用内建函数或全局函数 |
| `Return` | 记录返回值并结束当前程序 |
| `If` | 按条件选择分支 |
| `Switch` | 按模式匹配选择分支 |
| `Loop` | 循环:初始化、条件、迭代器与循环体 |
| `Break` | 跳出循环,可带标签 |
| `Continue` | 进入下一次循环 |
| `Block` | 块:语句数组,用 `Block = Statement[]` 表示 |
| `Exit` | 直接结束整个执行 |
| `FunctionDeclare` | 函数声明:语句种类枚举中的一项 |
块不是独立对象,而是语句数组;函数声明只出现在语句种类枚举里,可执行语句联合类型没有对应成员。
表达式是接收 `Scope` 并返回值的函数;值可以是字面量,也可以是表达式。`Scope` 分三层:
| 层 | 内容 |
| ------- | -------------------------------- |
| `args` | 调用时传入的参数 |
| `local` | 当前程序运行期的局部变量 |
| `env` | 调用链共享的环境,由事件系统传入 |
每个执行节点返回一种操作,迭代器据此决定下一步:
| 操作 | 含义 |
| -------- | ---------------------------- |
| `Move` | 跳到下一个节点 |
| `Call` | 按名字调用全局函数,然后继续 |
| `Return` | 记录返回值并结束当前程序 |
| `Exit` | 结束整个执行,不记录返回值 |
这一段只讲形状,不展开解析器如何把语句变成节点。
## 与事件对象的衔接
`GameEvent` 持有原始语句 `rawEvent` 与编译产物 `compiled`。`compile()` 调解释器编译并缓存;`execute(param, env)` 优先用缓存,没有缓存时先编译;如果编译函数返回空,则退回 `interpreter.exec(rawEvent, param, env)` 直接执行原始语句。`setRaw` 替换原始语句时会清空缓存,下一次执行重新编译。
解释器本身不判断触发器、不收集坐标、不查事件仓库。这些都在事件系统里:收集器产出调用列表,执行器比较 `event.trigger` 与 `env.trigger` 后才调用 `event.execute`。因此同一个解释器可以被多个事件对象共享,编译缓存也各归各的事件对象。
## 内部实现:编译与执行
此章节讲解一下 Anon-Tokyo 的执行循环的内部实现。
编译期做两件事:把语句分析成程序节点,并按名字解析内建函数。内建函数在编译时查表,名字不存在会直接抛错;全局函数在解释器构造时先编译成程序,调用时再按名字查表,名字不存在也会直接抛错。
运行期从 `exec` 开始:为一次执行创建上下文,上下文持有调用栈与环境;执行程序时创建迭代器,迭代器反复求值当前节点,并根据节点返回的操作推进。`Call` 会把新的迭代器压栈,执行完再弹栈;`Return` 记录返回值;`Exit` 结束整个执行。程序自然走完时按隐式返回处理。
下面这个例子把同一段脚本编译一次,再执行多次:
```ts
import {
AnonTokyoInterpreter,
Scope,
Statement,
StatementType
} from '@motajs/anon-tokyo';
// 内建函数:打印传入的值,不返回值
const logValue = {
name: 'logValue',
func: (param: { value: number }) => {
console.log('value =', param.value);
}
};
// 解释器:内建函数在这里注册,全局函数表为空
const interpreter = new AnonTokyoInterpreter({
builtInFunctions: [logValue],
globalFunctions: []
});
// 脚本:先调用内建函数,再把参数乘二返回
const script: Statement[] = [
{
type: StatementType.Call,
functionName: 'logValue',
builtIn: true,
parameters: {
// 参数可以是字面量,也可以是接收 Scope 的表达式
value: (scope: Scope) => scope.args.value
}
},
{
type: StatementType.Return,
// 返回值同样可以来自表达式
value: (scope: Scope) => scope.args.value * 2
}
];
// 编译一次:内建函数名字写错会在这里抛错
const executable = interpreter.compile(script);
// 执行多次:每次都会创建新的执行上下文,args 互不影响
console.log(await executable.exec({ value: 10 }, {})); // 20
console.log(await executable.exec({ value: 21 }, {})); // 42
```
编译只做一次,执行上下文每次新建;这就是事件对象缓存 `compiled` 的意义:同一事件反复触发时不必重复编译。
## 自定义内建函数与全局函数
解释器的语言特性由 `LanguageFeature` 描述:
| 字段 | 内容 |
| ------------------ | ------------------------------------------------------ |
| `builtInFunctions` | 内建函数表:每个项有 `name` 与 `func(parameters, env)` |
| `globalFunctions` | 全局函数表:名字 → 语句数组,构造解释器时编译 |
内建函数是事件脚本最常用的扩展点。完整的类写法、参数接口与注册路径见[事件系统](./event-system.md#自定义一个内建函数),本篇不重复示例。
数据端当前把 `config.events` 作为内建函数表,并把全局函数表传为空数组。全局函数允许脚本按名字调用另一段语句;需要时由创建解释器的一方提供,当前数据端没有使用。
## 边界与注意
- **缺少内建函数会在编译时抛错**:名字不存在时,`compile` 直接抛错。
- **缺少全局函数会在调用时抛错**:名字不存在时,执行到 `Call` 才抛错。
- **语句是结构化对象**:不是可解析的字符串,不要把脚本当文本再解析一遍。
- **作用域三层不要混用**:参数放 `args`,局部变量放 `local`,跨调用共享的上下文放 `env`。
- **`Exit` 与 `Return` 语义不同**:前者结束整个执行且不记录返回值,后者记录并返回一个值。
- **执行上下文没有导出能力**:没有可用的导出或转储功能,不要依赖它做调试快照。
- **可选字段当前没有额外语义**:内建函数上的 `save` 字段不要被当成存档或录像开关。
- **编译产物属于事件对象**:修改原始语句后必须重新编译,`setRaw` 会清空缓存。
- **解释器不负责派发**:触发器过滤与坐标收集在事件系统,不在解释器。
相关背景可看[高级 I 的 data-common Store](../i/data-stores.md)与[中级教程的自定义事件](../../intermediate/events.md)。
返回[高级 III 导览](./index.md)。
+124 -142
View File
@@ -1,195 +1,177 @@
# CoreState 组装与初始化
数据端主对象 `CoreState` 是数据端的组合根。构造它时,所有子系统按层装配好、插件注册并排序、装配期回调同步执行;调用 `load()` 时,加载按固定次序推进。存档对象、存档执行器与加载器也都挂在它身上。
新增一个子系统时,最先遇到的问题通常不是「类写在哪」,而是「它应该拿谁、由谁创建、什么时候创建」。`CoreState` 是数据端的组合根:它先把跨系统共用的对象按依赖顺序建好,再让插件在这套对象上继续装配。装配顺序错了,代码不一定立刻报错,但会在第一次访问某个字段时拿到 `undefined`。
本篇只讲数据端的装配与生命周期。数据端插件怎么写见[插件系统](../i/plugin-system.md),可存档内容的形状与压缩见[可存档内容](../i/saveable-content.md)。
本篇只讲四层为什么存在、每层放什么、构造器实际按什么顺序建对象,以及 `MainDataPlugin` 在插件阶段补了哪些默认装配。加载如何推进见[加载系统](./loading-system.md),可存档对象与执行器见[可存档内容](../i/saveable-content.md),插件排序与阶段推进见[插件内部机制](./plugin-internals.md)。
## 系统模型
组合根存在的理由只有三条:读者拿到的所有入口都从它出发;层与层之间由它注入,不需要各自寻找依赖;生命周期只有一套,构造、加载、存档都按同一顺序推进。
| 层 | 职责 |
| ----------- | ---------------------------------------------------- |
| L0 公共数据 | 工具、仓库与跨系统共用的运行期对象,不依赖其它子系统 |
| L1 状态基座 | 保存需要进存档的运行时状态,并允许系统层操作这些状态 |
| L2 系统层 | 在状态之上执行游戏逻辑,自身不作为存档内容 |
| L3 顶层 | 面向启动流程,把前面的对象接成加载入口 |
| 层 | 放什么 | 一句话职责 |
| ---------------- | ------------------------------------------------ | -------------------------------------- |
| Layer 0 公共数据 | 朝向、图块 / 道具 / 地图 / 事件 / 怪物仓库、录像 | 不依赖其它层,提供工具性接口与原始数据 |
| Layer 1 状态基座 | 标记、地图、勇士、怪物管理 | 保存需要进存档的运行时状态 |
| Layer 2 系统层 | 怪物上下文、战斗、事件、寻路 | 在状态之上执行游戏逻辑 |
| Layer 3 顶层 | 加载管理器、加载器 | 面向启动流程的入口 |
```text
CoreState
├─ Layer 0 公共数据:朝向、仓库、录像
├─ Layer 1 状态基座:标记、地图、勇士、怪物管理
├─ Layer 2 系统层:怪物上下文、战斗、事件、寻路
├─ Layer 3 顶层:加载管理器、加载器
├─ 插件注册表:id → 插件,按依赖排出固定顺序
└─ 存档:可存档对象映射 + 执行器绑定
```mermaid
flowchart TD
L3[L3 顶层<br/>loadManager / loader] --> L2[L2 系统层<br/>enemyContext / combat / eventSystem / pathfinding]
L2 --> L1[L1 状态基座<br/>flags / maps / hero / enemyManager]
L1 --> L0[L0 公共数据<br/>stores / face / replay]
```
层号只表示装配先后与依赖方向:上层可以拿下层,下层不反向依赖上层。需要回指主对象的子系统在构造时就会拿到它,这一点在后面的装配顺序里展开。
箭头表示依赖:L3 依赖 L2,L2 依赖 L1,L1 依赖 L0。构造顺序则相反,从 L0 开始逐层向上。`CoreState` 同时实现四层叠加后的接口,所以运行时是同一个对象;但每个子系统只接收它需要的那一层接口。
## 入口的职责
## 层不是目录
启动数据端只需要两个函数和一个主插件:
L0–L3 是构造依赖的分组,不是源码目录。一个包可以同时包含 L0 的仓库与 L1 的状态类;一个文件也可以定义多个层的接口。判断一个对象属于哪一层,看它「构造时依赖谁、是否进存档、是否只服务启动」,而不是看它放在哪个文件夹。
- 数据端入口只重新导出创建函数。业务代码从统一入口导入 `createCoreState`,不需要直接接触构造器。
- 配置入口 `createCoreStateConfig(starter)` 组装四样东西:加载 starter、核心配置地址、插件列表与事件注册。插件列表把主插件放在最前面,再展开插件列表文件里的业务插件。
- 创建函数 `createCoreState(starter)` 用上面的配置构造实例,返回尚未异步初始化的主对象;加载要由调用方推进,见[加载管线](#加载管线)。
这也意味着不能用 import 路径推断分层:`data-base` 里有 L1 的地图与勇士,也有 L0 的图块与朝向工具;`data-system` 整体偏 L2,但它的接口仍然只依赖 L1。真正决定依赖方向的是构造参数与类型,而不是包名。
主插件 `MainDataPlugin` 承担数据端的内置装配,在 `awake` 里依次做:
新增子系统时,默认通过插件实现,不先问「它属于哪一层」。只有必须改变引擎默认能力的极少数情况,才需要判断它落在 L0、L1 还是 L2:看它构造时依赖谁、是否进存档、是否只服务启动。插件与渲染端不需要挤进四层。
- 注册四方向与八方向朝向处理器;
- 为寻路注入通行谓词,把图层先置空,并把移动器指向勇士;
- 为勇士换上默认的顶层移动实现;
- 添加默认加载钩子;
- 初始化敌人、录像与钩子;
- 注册五个可存档对象。
## 一条依赖链在运行时怎么走
这五个存档 id 与对象一一对应:
以「勇士走一步」为例,四层在同一条链上各做一件事,但没有一层反向持有更高层:
| 存档 id | 对象 |
| ---------------- | -------------------- |
| `@system/hero` | `state.hero` |
| `@system/flags` | `state.flags` |
| `@system/maps` | `state.maps` |
| `@system/enemy` | `state.enemyManager` |
| `@system/replay` | `state.replaySystem` |
1. 输入层让勇士位置对象的移动器排一步。移动器属于 L1 的 `hero`,它只管理坐标、朝向与步骤队列。
2. 移动器把这一步交给 `DefaultHeroMoveTopImpl`。顶层实现由插件安装,不属于 CoreState 的四层构造;它持有 L1 的地图、L2 的事件执行器与一个依赖 L0/L1 的通行谓词。
3. 谓词读取 L0 的 `faceManager` 与 L1 地图上的图块通行位,回答「能不能进、会不会撞」。
4. 每一步进入结算阶段时,移动器都会调用事件系统;坐标改变时是旧格到新格,失败步则是同一格的前后两次。L2 的事件系统从 L1 的地图收集候选,从 L0 的 `eventStore` 取事件,再由解释器执行脚本;脚本可以写 L1 的 Flag、勇士或地图。
5. 寻路是另一条链:L2 的 `pathfinding` 读取 L1 的图层,反复调用同一个谓词构图与搜索,最后把方向步骤交回 L1 的移动器。
存档 id 与插件 id 是两套命名。注册规则、重复 id 的处理与执行器绑定见[可存档内容](../i/saveable-content.md#注册到存档系统)。
这条链说明分层不是「调用顺序从上到下」,而是「每一层只依赖更低的接口」。顶层实现、谓词与插件可以同时接触多层,但它们是把能力注入低层,而不是让低层反过来 import 高层。
主插件的 `afterLoad` 在加载完成后才执行:它从加载器取核心配置,再用配置里的初始属性与坐标创建勇士的属性与位置。属性与位置不能放进 `awake`,因为那时核心配置还没读出来。
## L0:公共数据
## 加载管线
| 对象 | 负责什么 | 为什么在 L0 | 依赖 |
| -------------- | -------------------------------- | ------------------------------ | ----------- |
| `roleFace` | 图块数字到朝向图的绑定 | 只处理图块标识,不认识游戏进度 | 无 |
| `faceManager` | 按组与字符串 id 注册方向处理器 | 移动、构图、图块转换共用 | 无 |
| `tileStore` | 图块原始定义:通行位、事件、类型 | 只读定义,所有层都可能查询 | 无 |
| `itemStore` | 道具原始定义 | 只读定义,不依赖地图或勇士 | `tileStore` |
| `mapStore` | 地图原始数据 | 只读定义,供地图状态按需创建 | 无 |
| `eventStore` | 事件 id 到事件对象的映射 | 事件对象不依赖地图状态 | 无 |
| `enemyStore` | 怪物原始定义 | 只读定义,供敌人管理器创建实例 | `tileStore` |
| `replaySystem` | 录像数组、指令注册表与播放沙箱 | 每个系统都可能用到它 | 无 |
`state.load()` 是一个异步迭代器,按固定四步推进:
L0 的共同点是「构造时不需要 L1/L2/L3」。录像系统放在 L0 的唯一理由是每个系统都可能用到它;可存档与否不是分层依据,关键是它不依赖更高层。
```text
load()
├─ 1. 插件 initialize 异步,加载开始前
├─ 2. loader.start() 异步迭代,真正加载数据
├─ 3. 插件 afterLoad 异步,加载完成后
└─ 4. 检查插件状态 未到 Loaded 的插件逐个告警
```
`tileStore` 必须早于 `itemStore` 与 `enemyStore`,因为后两者在构造时就要拿图块定义。`faceManager` 在 L0 只是建表,真正的 Dir4 / Dir8 处理器由 `MainDataPlugin` 注册;这保证默认玩法策略不写死在对象图里。
`initialize` 放在加载前,是因为此时还可以往加载器里追加配置;插件要在加载前完成的异步准备也放在这里。`loader.start()` 依次读取核心配置、额外配置与自定义任务,并产出加载进度。`afterLoad` 放在加载后,因为此时核心配置、地图与其它内容才可读。最后一步只做检查:正常走完三个阶段的插件状态都是 `Loaded`,不是的会被点名。
L0 的仓库大多是只读定义:`tileStore`、`itemStore`、`mapStore`、`enemyStore` 保存的是「内容是什么」,不是「这一局进行到哪」。把它们放在 L0,读档时不需要恢复,也不会因为一局的状态变化而改写定义。`roleFace` 与 `faceManager` 是工具性的查找表;`replaySystem` 放在 L0,只因为每个系统都可能用到它。
`load()` 与 `loader.start()` 都是异步迭代器:拿到迭代器不等于加载开始,必须把迭代推进完。`load()` 只负责初次加载,玩家读档不会重跑这条管线。
L0 的边界是「可以被任何层使用,但不主动使用任何层」。如果某个对象需要知道当前楼层、勇士位置或 Flag,它就不属于 L0;如果它只需要图块定义或方向处理器,放在 L0 可以避免在 L1/L2 里重复建一份。
## 存档挂载
## L1:状态基座
存档系统只认识两样东西:注册过的可存档对象,以及绑定在这些对象上的执行器。两者都挂在主对象上。
| 对象 | 负责什么 | 为什么在 L1 | 依赖 |
| -------------- | ---------------------------------- | ------------------------------------ | ------------------------------------ |
| `flags` | 全局 Flag 字段与值 | 需要进存档,系统层只读写 | 无 |
| `maps` | 楼层、图层、图块与事件的运行时状态 | 地图改动要进存档,且被寻路与事件读取 | `CoreState`(以 `IDataCommon` 回指) |
| `hero` | 勇士属性、位置、道具、装备与跟随者 | 勇士状态是存档主体,系统层只操作它 | `CoreState`(以 `IDataCommon` 回指) |
| `enemyManager` | 怪物实例、原始定义与特殊属性 | 敌人状态需要进存档,且依赖图块定义 | `tileStore` |
- 注册:`state.addSaveableContent(id, content)`。id 在整份存档里唯一;重复注册会告警,并且只有第一个注册的对象生效。
- 绑定:`state.bindSaveableExecuter(content 或 id, executor)`。传对象时,对象必须已经注册,否则告警并忽略;传 id 时,找不到对应对象会静默返回。
- 读档:主对象先清掉残留的录像禁用标记,再逐个对象执行 `loadState`。每个对象的 `loadState` 都被录像禁用与恢复包裹,读档本身不会记进录像;执行器在这层包裹之外运行,因为它可能需要做出需要记录的行为。
L1 的共同点是「保存游戏进度」。它们可以被 L2 读写,也可以被 L3 的读档流程恢复,但自身不决定战斗、事件或寻路的执行策略。`maps` 与 `hero` 在构造时接收 `CoreState`,但类型是 `IDataCommon`,只看得到 L0;这是向下依赖,不是反向依赖。
下面这个函数注册一个计数对象并绑定执行器:
L1 的对象可以互相引用,但要通过主对象或公开接口,而不是在构造时抓取彼此的私有字段。`enemyManager` 只拿 `tileStore`,不拿 `maps` 或 `hero`,所以它可以在没有地图的环境里独立创建。
```ts
// 存档形状:只放重建必需的字段
interface ICounterSave {
readonly count: number;
}
L1 的边界是「保存重建所需的状态,不保存可以重算的中间结果」。战斗中的临时伤害、寻路得到的路径、事件收集出的候选列表都不属于 L1;它们是 L2 在某一刻算出来的派生数据。把派生数据塞进 L1,会让存档体积膨胀,也会在读档后留下与新状态不一致的缓存。
// 可存档对象:负责在活对象与存档数据之间转换
class CounterSaveable implements ISaveableContent<ICounterSave> {
private count = 0;
L1 也不负责「什么时候写」。它只提供可读写的状态与存档能力;触发写入的是 L2 的系统或插件。这样同一份状态可以被不同玩法复用,而不必把玩法写进状态对象。
add(delta: number): void {
this.count += delta;
}
## L2:系统层
// 返回独立的新对象,不能返回 this
saveState(): ICounterSave {
return { count: this.count };
}
| 对象 | 负责什么 | 为什么在 L2 | 依赖 |
| -------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------- |
| `enemyContext` | 怪物上下文:战斗前后的属性修正与特殊逻辑 | 需要读 L1 状态,但自身不保存进度 | `CoreState`(以 `IStateSystem` 回指) |
| `combat` | 战斗流程:伤害、夹击、死亡与掉落 | 在状态之上执行,读档后可按需重建 | `CoreState` |
| `eventSystem` | 事件仓库、解释器、收集器与执行器 | 需要事件对象与地图状态,但不作为存档内容 | `CoreState`、`config.events` |
| `pathfinding` | 构图、搜索与把路径交给移动器 | 需要图层、谓词与移动器,但不保存地图数据 | `CoreState` |
// 按存档覆盖运行时状态,不保留旧值
loadState(state: ICounterSave): void {
this.count = state.count;
}
}
L2 的共同点是「执行逻辑」。它们会修改 L1 的状态,但不自己保存一份副本;读档后,L1 被恢复,L2 继续按新状态工作。`eventSystem` 在构造时接收入口提供的内建函数表,并创建解释器;`pathfinding` 只创建求解器与系统对象,谓词、图层与移动器由 `MainDataPlugin` 在 `awake` 中注入。
// 执行器:当前对象 loadState 返回后立即运行
class CounterExecutor implements ISaveableExecutor<ICounterSave> {
afterLoad(data: ICounterSave, state: ICoreState): void {
// 此时只保证当前对象已经恢复,不要假设其它对象也恢复完毕
console.log('计数已恢复:', data.count, state);
}
}
L2 不能依赖 L3。比如事件系统不应该在构造时读取 `loader`,寻路也不应该依赖加载进度;需要加载结果时,由插件在加载后把值注入 L2,或由 L2 的调用方传入。
// 注册并绑定:通常写在插件的 awake 里
export function registerCounter(state: ICoreState): void {
const counter = new CounterSaveable();
// 存档 id 必须唯一;重复注册会告警并忽略后一个
state.addSaveableContent('@plugin/counter', counter);
// 传对象或注册 id 都可以;对象要先注册,再绑定
state.bindSaveableExecuter(counter, new CounterExecutor());
}
```
L2 的边界是「执行策略,不是内容定义」。事件系统知道怎么收集与执行,但不知道某个事件 id 对应哪段脚本;寻路知道怎么构图与搜索,但不知道当前楼层长什么样;战斗知道怎么结算,但不知道怪物原始定义从哪来。内容定义在 L0,状态在 L1,L2 只把两者接起来。
## 在游戏内另建一个数据端实例
L2 也不是「所有逻辑都必须放这里」。默认玩法策略(用哪个谓词、给移动器装哪个顶层实现、注册哪些加载钩子)属于插件,不属于 `CoreState` 的构造。把它们放进 L2,会让系统对象在构造时就需要知道具体玩法,失去替换空间。
构造器不依赖全局单例。只要传一份完整配置,就能在游戏内或脚本里再建一个独立的数据端实例,与主实例并行运行;回放、结果对比、离线校验都可以这样做。
## L3:顶层
配置有四个必填字段:加载 starter、核心配置地址、插件列表与事件注册。缺任何一项,构造期就会失败;也不能只给加载 starter 与核心配置地址。
| 对象 | 负责什么 | 为什么在 L3 | 依赖 |
| ------------- | ---------------------------------------- | ----------------------------------------------- | ---------------------- |
| `loadManager` | 汇总加载任务、推进任务并累计进度 | 只服务加载流程,不参与日常结算 | 无 |
| `loader` | 按阶段加载核心配置、额外配置与自定义内容 | 需要 `loadManager` 与 starter,且只在启动期使用 | `loadManager`、starter |
```ts
// 再建一个数据端实例:与主实例完全独立
export async function createSecondaryState(
loadStarter: ILoadTaskStarter,
coreURL: string,
dataPlugins: ICoreStateConfig['dataPlugins'],
events: ICoreStateConfig['events']
): Promise<ICoreState> {
// 四个字段都必填:构造期就要用它们装配加载器与插件
const config: ICoreStateConfig = {
loadStarter,
coreURL,
dataPlugins,
events
};
L3 的共同点是「启动入口」。它们不持有游戏进度,也不执行战斗或事件;它们把外部资源读进来,供插件在加载后使用。`CoreState` 在构造器里调用 `loader.addCoreConfig('core', config.coreURL)`,把核心配置登记为第一个加载任务;真正的加载由 `state.load()` 推进,不在本篇展开。
// 新实例不共享主实例的勇士、地图、事件仓库或存档
const state = new CoreState(config);
L3 是四层里最容易被低层误用的层。低层如果为了拿一个配置而保存 `state.loader`,就把「对象图」和「启动流程」绑死了;正确做法是让插件在加载后读取配置,再把结果注入低层。
// load() 是异步迭代器:必须消费它,加载才真正推进
for await (const _loadedBytes of state.load()) {
// 这里可以读取加载进度,也可以忽略
}
L3 的边界是「只在启动期有用」。`loadManager` 与 `loader` 不参与日常战斗、事件或寻路;它们读取外部资源,供插件在加载后创建对象。把运行期逻辑写进 L3,会让「加载完成」与「游戏运行」两条时间线纠缠在一起,读档时也很难判断哪些对象应该重建。
return state;
}
```
## 构造器的实际顺序
两个实例各自持有自己的子系统与存档映射,互不影响。要让第二个实例与主实例行为一致,就传同一组插件列表与事件注册,并确保主插件也在列表里;需要另一套规则时,换一组插件与事件即可。
前面按层说明职责;落到代码上,四层由构造器按固定顺序建出来。`CoreState` 的构造器按下面的顺序执行,顺序本身就是依赖声明:
## 内部实现:装配顺序
1. L0:`roleFace`、`faceManager`、`tileStore`、`itemStore`、`mapStore`、`eventStore`、`enemyStore`、`replaySystem`。`itemStore` 与 `enemyStore` 在 `tileStore` 之后。
2. L1:`flags`、`maps`、`hero`、`enemyManager`。`maps` 与 `hero` 接收主对象,`enemyManager` 接收 `tileStore`。
3. L2:`enemyContext`、`combat`、`eventSystem`、`pathfinding`。它们都接收主对象;`eventSystem` 还接收 `config.events`。
4. L3:`loadManager`,再用它和 `config.loadStarter` 创建 `loader`;随后登记核心配置。
5. 插件表:遍历 `config.dataPlugins`,按 `id` 建表;重复 id 告警 78 并保留第一个。
6. 依赖表:把每个插件的 `dataDeps` 交给 `orderPlugins`,在构造期计算一次拓扑序。
7. `awake()`:按拓扑序对处于 `NotAwakened` 的插件执行同步装配。
此章节讲解一下 CoreState 的装配顺序的内部实现。
第 5 到第 7 步发生在四层全部构造完成之后。原因是 `awake` 可能访问任意一层:主插件要注册朝向、注入寻路谓词、给勇士移动器装顶层实现、注册加载钩子与可存档内容。任何一层还没建好,插件拿到的就是 `undefined`。
构造器按四层顺序创建全部子系统,再处理插件。层与层之间不是随便排列的:每一层只使用已经装好的下层,并把自己暴露给上层。
`config.dataPlugins` 里 `MainDataPlugin` 排在最前,只是入口的列表顺序;真正的执行顺序仍由 `dataDeps` 决定。它没有隐式前置地位,依赖它注册的默认装配或存档 id 时,必须在 `dataDeps` 中显式写 `'main-data-plugin'`。
| 层 | 装入的子系统 | 为什么在这一层 |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| L0 公共数据 | 朝向绑定器 `roleFace`、朝向管理器 `faceManager`、图块仓库 `tileStore`、道具仓库 `itemStore`、地图仓库 `mapStore`、事件仓库 `eventStore`、怪物仓库 `enemyStore`、录像系统 `replaySystem` | 不依赖其它子系统,先建好供所有层使用 |
| L1 状态基座 | 标记系统 `flags`、地图状态 `maps`、勇士状态 `hero`、怪物管理 `enemyManager` | 保存需要进存档的运行时状态 |
| L2 系统层 | 怪物上下文 `enemyContext`、战斗流程 `combat`、事件系统 `eventSystem`、寻路系统 `pathfinding` | 在状态之上执行逻辑,构造时需要拿到主对象 |
| L3 顶层 | 加载管理器 `loadManager`(`LoadManager`)、数据加载器 `loader` | 面向启动流程;加载器随后登记核心配置地址 |
## MainDataPlugin 实际装配
L0 内部也有先后:道具仓库与怪物仓库依赖图块仓库,所以图块仓库先建。L1 与 L2 里有几个子系统需要回指主对象,构造时就把当前实例传进去:地图、勇士、怪物上下文、战斗、事件系统与寻路都属于这一类。L3 的加载器不反向引用主对象,它只拿加载管理器与 starter,再把核心配置登记为自己的第一份配置。
`CoreState` 只负责建对象与排序,默认玩法策略放在 `MainDataPlugin.awake`。它按下面的顺序补装配:
四层装完后才处理插件:先遍历插件列表建立注册表,重复 id 在这里告警并跳过;再按每个插件声明的 `dataDeps` 建依赖表,调用一次 `orderPlugins` 得到固定顺序;最后同步执行 `awake`,把已排好序的插件逐个装配。装配期回调结束后,构造器返回,异步初始化与加载还没有开始。插件状态推进与依赖排序的细节见[插件内部机制](./plugin-internals.md)。
1. 创建 `Dir4FaceHandler` 与 `Dir8FaceHandler`,按 `FaceGroup` 和字符串 id 注册到 `faceManager`。之后移动与构图才能取到方向处理器。
2. 创建 `DefaultPassPredicateImpl`,注入 `pathfinding.finder`;把图层先设为 `null`,因为此时勇士还不在任何楼层;把勇士的移动器绑定给寻路系统。
3. 创建 `DefaultHeroMoveTopImpl`,装到 `state.hero.location.mover`。它负责边界、通行谓词与 `enter` / `leave` / `hit` 的事件派发。
4. 给加载器注册默认加载钩子,让加载阶段能按核心配置追加内容。
5. 初始化敌人、录像指令与钩子;这些初始化需要主对象与已注册的仓库。
6. 注册默认可存档对象:勇士、Flag、地图、敌人管理器与录像。注册决定存档里出现哪些 key;不要用注册顺序推断读档先后。
7. 保存敌人初始化返回的弱引用处理器,供后续战斗逻辑使用。
这些动作不放进 `CoreState` 构造器,是因为它们属于「默认玩法」而不是「对象图」:换一套玩法可以替换插件,而不需要改四层构造。`MainDataPlugin` 仍然只是一个普通插件,它的 `awake` 与其它插件共享同一套拓扑序与状态机。
`new CoreState(config)` 返回时,四层对象与插件的 `awake` 都已完成,但这不等于「游戏可以开始」:`faceManager` 已注册 Dir4 / Dir8,L0 的仓库可用;L1 的对象存在但还没有被内容填充;寻路已拿到谓词与移动器,图层仍是 `null`;`loader` 已登记核心配置但还没有开始读取;`initialize` 与 `afterLoad` 还没有执行。这个状态适合写单元测试,不适合直接开始游戏。
入口只做三件事:`createCoreStateConfig(starter)` 汇总加载 starter、核心配置路径、插件列表与内建函数表;`createCoreState(starter)` 用这份配置构造实例并返回尚未异步加载的主对象;`MainDataPlugin` 在插件阶段完成上面的默认装配。`src/data.ts` 只 re-export `createCoreState`,不承担装配逻辑。`ICoreStateConfig` 只有这四个字段,业务参数通过插件构造器或配置字段传入,不要扩 `CoreState` 的构造签名。构造器也不发起网络或文件读取,`loadStarter` 只保存引用,I/O 留给加载阶段。
## 新增子系统放哪一层(插件优先)
新增子系统默认全部通过插件实现:在 `awake` 里创建管理器并挂到插件实例上,在 `afterLoad` 里读取加载内容。不要因为「写起来更直接」就往 `CoreState` 的四层里加字段。只有必须改变引擎默认能力、且要被所有项目共享的极少数情况,才动 L0 / L1 / L2。
| 改动类型 | 落点 | 原因 |
| ------------------------------ | ------------------------ | ---------------------------------------- |
| 新增业务子系统 | 插件 | 默认做法,不改变 `CoreState` 的对象图 |
| 新的可存档进度 | 插件自己持有的可存档对象 | 由插件注册,读档时恢复 |
| 新的执行逻辑 | 插件自己的管理器 | 读写 L1,但不作为存档内容 |
| 新的默认玩法装配 | 插件的 `awake` | 需要主对象与已构造的四层,但不改对象图 |
| 依赖加载内容的初始化 | 插件的 `afterLoad` | 配置与地图在加载后才可读 |
| 纯展示、输入或动画 | 渲染端,不在数据端四层 | 数据端只维护状态,不持有画面 |
| 必须改变引擎默认能力(极少数) | 对应的 L0 / L1 / L2 | 插件无法表达、且需要所有项目共享时才考虑 |
新增图块、道具、怪物或地图定义不属于新增子系统,仍然由 L0 的对应 Store 承载;它们是内容,不是运行时逻辑。以任务系统为例:任务进度可存档,任务逻辑是执行逻辑,合适做法是写一个插件,在 `awake` 里创建任务管理器并注册为可存档对象,在 `afterLoad` 里读取任务配置,而不是往 L1 里硬加一个 `quests` 字段。只有任务系统要成为引擎级能力、被所有项目共享时,才考虑放进 L1 / L2,并同时承担构造顺序与接口成本。
再以「新的通行规则」为例:规则是执行策略,不是内容定义。它应该实现为谓词,由插件注入寻路与移动器,而不是写进 `tileStore` 的静态通行位。静态通行位描述「图块本身能不能走」,谓词描述「这一局按什么规则判断」;两者混在一起,换规则就要改内容。
## 边界与注意
- **加载必须被迭代推进**:`createCoreState` 只构造实例;`load()` 是异步迭代器,不消费它,加载不会开始,插件也不会进入 `Loaded`。
- **配置四个字段都必填**:加载 starter、核心配置地址、插件列表、事件注册缺一不可。
- **重复可存档 id 只保留第一个**:后一个注册会告警并被忽略,不是覆盖。
- **执行器绑未注册对象会告警**:传对象前先调用 `addSaveableContent`;传 id 时找不到对象则静默返回。
- **读档的键要对得上**:存档缺少某个已注册 id 会告警并跳过;存档多出未注册 id 会在读完时告警。
- **插件未到完成态会被告警**:`load()` 结束时会逐个检查,状态不是 `Loaded` 的插件都会被点名。
- **读档期间录像被临时禁用**:每个对象的 `loadState` 都被禁用与恢复包裹,执行器不在包裹内。
- **不要重排装配顺序**:层与层之间的依赖是固定的,重排会让需要回指主对象的子系统拿不到已装配的下层。
- **不要手动构造内部子系统**:需要新能力时新增插件,或通过公开入口扩展;自己创建内部对象会绕过存档、录像与生命周期管理。
四层顺序错了,很少在构造器里立刻抛错,常见的是第一次使用时拿到错误结果:低层保存了高层引用,第一次调用才拿到 `undefined`;朝向处理器还没注册,默认谓词直接返回不通行;进入楼层前 `finder` 的图层是 `null`,`find` 得到 `InvalidInput`;事件内建函数缺失,会在编译阶段抛错;插件依赖写错,被阻断的插件停在 `NotAwakened`,下游看到的是「对象缺失」。
- **依赖只能向下**:L0 不得引用 L1/L2/L3,L1 不得引用 L2/L3,L2 不得引用 L3;跨层能力由上层注入。
- **四层装配顺序固定**:L0 → L1 → L2 → L3,同层内部也按依赖先后;重排会让构造参数拿到未初始化的对象。
- **插件在四层之后**:注册、去重、排序与 `awake` 都发生在子系统装配完成之后,`awake` 可以看到整个数据端。
- **构造不等于加载**:`createCoreState` 只返回实例,异步加载由加载系统推进;两者的时间线不要混在一起。
- **系统层不进存档**:L2 只修改 L1 的状态,读档后继续按新状态工作;可存档对象的注册与执行器见[可存档内容](../i/saveable-content.md)。
- **默认子系统用插件**:需要新能力时新增插件,由主对象注入已有依赖,避免绕过初始化顺序;不要手动构造内部子系统,也不要往四层里加字段。
- **依赖主插件要显式声明**:用到 `main-data-plugin` 注册的默认装配或初始化结果时,在 `dataDeps` 里写 `'main-data-plugin'`。
返回[高级 III 导览](./index.md)。
+110 -213
View File
@@ -1,135 +1,95 @@
# 事件系统
事件把「一段脚本」和「什么时候跑」接在一起。数据端把它拆成五个角色:事件仓库按 id 保存事件对象,事件对象持有触发器与语句,收集器按坐标算出这次该执行谁,执行器逐条过滤并执行,解释器负责把语句跑起来。
事件最容易出现的一种误判是「脚本写了,但没跑」。原因通常不在脚本,而在两段完全不同的逻辑:先按坐标与图层收集候选事件,再在执行时按触发器与仓库内容筛选。收集阶段不知道脚本语义,执行阶段才拿到事件对象;把这两步混在一起,就会以为收集器会按 `trigger` 过滤,或者以为点事件与图块事件可以按优先级跨组比较。
本篇讲前四个角色,以及怎么新增一个内建函数;脚本如何编译成可执行程序、执行上下文如何迭代,属于[Anon-Tokyo 解释器](./anon-tokyo.md)一篇。序列化事件注册与地图事件 id 绑定属于内容配置,由编辑器负责,本篇不展开。
本篇讲事件仓库、事件对象、收集器、执行器与解释器这五个角色,以及一个事件从注册到执行的过程。事件内容由编辑器的事件编辑器产出,本篇不讨论编辑器本身;脚本如何编译成程序属于实现细节,不单独展开。
## 系统模型
| 角色 | 职责 |
| ---------------------- | --------------------------------------------------------- |
| `GameEventStore` | 按 id 保存事件对象,`getEvent(id)` 取回实例 |
| `GameEvent` | 持有触发器、原始语句与缓存的编译产物 |
| `GameEventSystem` | 持有事件仓库、解释器与执行器,`collectEvent` 产出调用列表 |
| `EventExecutor` | 逐条查询仓库、过滤触发器、执行并合并返回值 |
| `AnonTokyoInterpreter` | 把语句编译成可执行程序,或直接执行语句 |
| 角色 | 负责什么 |
| ---------------------- | ---------------------------------------------- |
| `GameEventStore` | 按 id 保存事件对象,按 id 取回 |
| `GameEvent` | 持有触发器、原始语句与缓存的编译产物 |
| `GameEventSystem` | 持有仓库、解释器与执行器,按坐标收集候选调用项 |
| `EventExecutor` | 逐条查仓库、过滤触发器、执行并合并返回值 |
| `AnonTokyoInterpreter` | 把语句编译成可执行程序,或直接执行语句 |
一次派发的数据流如下。收集器只看坐标与图层,执行器只看调用列表,事件对象负责把语句变成可执行程序。三者分开后,换仓库或换解释器都不必改收集逻辑。
```text
事件仓库 GameEventStore(id → 事件对象)
│
▼
收集器 GameEventSystem.collectEvent(layer, trigger, x, y)
│ 产出 IGameEventInvocation[]
▼
执行器 EventExecutor.execute(invocations, param)
│ 逐条查仓库、比较 trigger、调用 event.execute
▼
事件对象 GameEvent ──缓存的编译产物──> AnonTokyoInterpreter
```mermaid
flowchart TD
S[GameEventStore<br/>id → GameEvent] --> C[GameEventSystem.collectEvent]
C --> I[调用列表<br/>id + env]
I --> E[EventExecutor.execute]
E --> F{仓库中有该 id 且 trigger 匹配?}
F -->|否| K[告警或静默跳过]
F -->|是| G[GameEvent.execute]
G --> X[AnonTokyoInterpreter]
```
数据端目前只在勇士移动的进入、离开、触碰三个时机走这条链路;其余触发器的状态见下一节。
收集结果不是事件实例,而是 `{ id, env }` 形式的调用项。这样收集阶段只关心坐标、图层与顺序,事件对象被替换或仓库被换掉时,候选列表仍然成立。真正的触发器过滤发生在执行器。
## 事件与触发器
## 事件对象与触发器
`EventTrigger` 是触发器枚举,完整取值如下:
`GameEvent` 保存触发器、原始语句数组与编译产物。第一次执行时编译,之后复用缓存;`setRaw` 换掉原始语句并清空缓存;`setTrigger` 只改触发器,不影响已经编译好的程序。解释器由构造时传入,通常来自 `state.eventSystem.executor.interpreter`。
| 值 | 含义 | 数据端当前派发 |
| --------------------- | -------------------------------------- | -------------- |
| `None` | 无触发器,事件需要手动执行 | 未派发 |
| `OnTouch` | 碰到图块时触发,能直接走进去的不算触碰 | 勇士移动 |
| `OnEnter` | 进入图块时触发 | 勇士移动 |
| `OnLeave` | 离开图块时触发 | 勇士移动 |
| `OnBeforeBattle` | 与怪物战斗前触发,返回值决定是否战斗 | 未派发 |
| `OnAfterBattle` | 与怪物战斗后触发 | 未派发 |
| `OnBeforeOpenDoor` | 开门前触发,返回值决定是否能够成功开门 | 未派发 |
| `OnAfterOpenDoor` | 开门后触发 | 未派发 |
| `OnAfterGetItem` | 成功拾取道具时触发 | 未派发 |
| `OnBeforeChangeFloor` | 楼层切换前触发,返回值决定是否执行切换 | 未派发 |
| `OnAfterChangeFloor` | 楼层切换后触发 | 未派发 |
`EventTrigger` 共 11 个值。数据端目前只有三个值接到了派发点,其余是枚举保留,挂上去不会自动执行:
当前数据端内置派发只覆盖进入、离开、触碰三个时机,其余值是枚举的一部分,由对应系统使用或预留。写脚本时不要假设挂上某个触发器就一定会被执行;新增触发器取值并补上派发点属于引擎级改动,不属于内容扩展。
| 值 | 含义 | 数据端派发状态 |
| --------------------- | ---------------------------------------------------- | ------------------ |
| `None` | 无触发器,事件不由触发器启动 | 保留值 |
| `OnTouch` | 玩家撞上目标格时触发;直接走入不触发 | 勇士移动的撞击 |
| `OnEnter` | 玩家进入目标格时触发 | 勇士移动的进入 |
| `OnLeave` | 玩家离开目标格时触发 | 勇士移动的离开 |
| `OnBeforeBattle` | 与指定怪物战斗前触发,返回值表示是否进入战斗 | 未派发,待引擎实现 |
| `OnAfterBattle` | 与指定怪物战斗后触发 | 未派发,待引擎实现 |
| `OnBeforeOpenDoor` | 开启指定门之前触发,返回值表示是否成功开门 | 未派发,待引擎实现 |
| `OnAfterOpenDoor` | 开启指定门之后触发 | 未派发,待引擎实现 |
| `OnAfterGetItem` | 成功拾取指定道具时触发 | 未派发,待引擎实现 |
| `OnBeforeChangeFloor` | 使用指定位置或图块切换楼层前触发,返回值表示是否切换 | 未派发,待引擎实现 |
| `OnAfterChangeFloor` | 使用指定位置或图块切换楼层后触发 | 未派发,待引擎实现 |
事件对象由 `GameEvent` 实现。它保存 `trigger`、`rawEvent` 与 `compiled`:`compile()` 编译并缓存结果,`execute(param, env)` 优先使用缓存,`setRaw(raw)` 在替换原始语句时清空缓存,`setTrigger(trigger)` 修改触发器。只读侧 `IReadonlyGameEvent` 暴露 `interpreter`、`trigger`、`rawEvent`、`compiled` 与 `compile()`、`execute()`。
触发器过滤不放在收集器里,是因为同一坐标可能因 `OnEnter`、`OnLeave`、`OnTouch` 被反复收集。收集器只把本次时机写进 `env.trigger`,执行器再决定这个事件是否属于本次时机。把过滤提前到收集器,会让「收集」依赖事件脚本的语义,仓库换一个实现就可能漏掉候选。
## 收集:这一次该执行谁 {#收集这一次该执行谁}
## 事件层
`collectEvent(layer, trigger, x, y)` 先判断 `layer.inMap(x, y)`;坐标不在地图内直接返回空数组。
`IGameMap.eventLayer` 指向当前楼层的默认事件层,类型是 `IMapLayer | null`。内容加载时,`alias === 'event'` 的图层会被默认指定为事件层;也可以用 `setEventLayer` 显式换一个图层。事件层不是特殊类型,仍然是普通图层,具备图块、点事件、图块事件、存档与钩子。
点事件按坐标读取:`layer.getPointEvent(x, y)` 返回「优先级 → 事件 id」的只读映射。图块事件按实例读取:当前格的静态图块(若存在)与每个动态图块各有一份 `tileEvent()` 映射。
切换楼层后,使用的是目标楼层自己的事件层。事件对象按 id 存在 `state.eventStore`;点事件与图块事件只保存 id,不保存脚本本身。事件 id 的登记接口是 `eventStore.addEvent`;谁在加载期登记属于内容加载,不在本篇展开。
两组分别按优先级降序排序,然后拼接:点事件整组在前,图块事件整组在后。也就是说,优先级只在组内比较;一个低优先级的点事件仍然排在高优先级的图块事件之前。
把事件层做成普通图层,而不是一个专门的事件容器,是为了复用图层的全部能力:它仍然有静态图块、动态图块、点事件、图块事件、存档与钩子。事件系统只需要一个「当前楼层的事件层」指针,不需要为事件单独维护坐标表;换楼层时换指针即可,图层本身的存档与事件视图继续工作。
`collectEvent` 不按事件自身的触发器过滤。它把该坐标上的候选事件都放进列表,并把本次触发时机写进每个调用项的环境;真正的触发器过滤发生在执行器。
## 收集:这一次该执行谁
每个调用项形如 `{ id, env }`,`env` 是这次调用的完整环境:
触发器只回答「这次是什么时机」;要把它变成一次执行,还需要收集候选与执行过滤。`collectEvent(layer, trigger, x, y)` 先判断坐标是否在地图内;越界直接返回空数组。它不按事件自身的触发器过滤,而是把该坐标上的候选事件都放进列表,并把本次触发时机写进每个调用项的环境。
| 字段 | 含义 |
| ---------------- | ------------------------------------ |
| `state` | 数据端主对象 |
| `type` | 事件类型:普通事件、点事件或图块事件 |
| `trigger` | 本次触发时机 |
| `system` | 事件系统对象 |
| `heroLocator` | 触发时勇士的位置 |
| `heroFloor` | 触发时勇士所在楼层 |
| `triggerLocator` | 触发坐标 |
| `tile` | 触发事件的图块,点事件为空 |
| `layer` | 触发事件的图层,可能为空 |
| `map` | 触发事件的地图,可能为空 |
候选分两组:
下面的函数先打印某格的来源与优先级,再按本次触发时机取调用列表,按执行顺序打印:
1. 点事件:`layer.getPointEvent(x, y)` 返回「优先级 → 事件 id」的只读映射。
2. 图块事件:当前格的静态图块与每个动态图块各有一份 `tileEvent()` 映射。
```ts
import { EventTrigger } from '@user/data-common';
import { IMapLayer } from '@user/data-base';
import { BlockEventType, IGameEventSystem } from '@user/data-system';
两组各自按优先级降序排序,再按「点事件组在前、图块事件组在后」拼接。组间顺序不受优先级数值影响:即使某个图块事件的优先级更高,整组点事件仍然先进入调用列表。这样「这个坐标发生什么」始终先于「这个物体发生什么」,跨组比较不会让物体事件压过坐标语义。排序只发生在收集结果上,不会修改图层的事件视图;同一份视图可以被多个坐标、多次触发反复读取。
/**
* 打印某格的事件来源、优先级与执行顺序。
* 只看事件层;坐标不在地图内时 collectEvent 会返回空数组。
*/
function printEvents(
system: IGameEventSystem,
layer: IMapLayer,
x: number,
y: number
): void {
// 点事件:优先级 → 事件 id
const point = layer.getPointEvent(x, y);
if (point) {
for (const [priority, id] of point) {
console.log('点事件', priority, id);
}
}
同一份事件视图内,一个优先级只保存一个事件 id。对已存在的优先级再次 `set` 会用新 id 覆盖旧 id,并触发重复优先级告警 136;作者看到的结果是后写入的 id 生效。不同来源出现相同优先级时,不再重新排序,而是保留收集顺序:点事件组仍然整体在前;图块事件组内部静态图块在前,多个动态图块按图层顺序排列。调用项只有 `id` 与 `env`,不带优先级,优先级只体现在列表顺序里。
// 图块事件:静态图块与每个动态图块各有一份
const loc = layer.getLocationData(x, y);
if (loc?.static) {
for (const [priority, id] of loc.static.tileEvent().get()) {
console.log('静态图块事件', priority, id);
}
}
for (const tile of loc?.dynamics ?? []) {
for (const [priority, id] of tile.tileEvent().get()) {
console.log('动态图块事件', priority, id);
}
}
`env` 记录本次触发的完整上下文:
// 本次触发时机写进 env.trigger;事件自身的触发器由执行器过滤
const invocations = system.collectEvent(layer, EventTrigger.OnEnter, x, y);
invocations.forEach((invocation, index) => {
const { env } = invocation;
const source =
env.type === BlockEventType.PointEvent ? '点事件' : '图块事件';
console.log(index, env.trigger, source, invocation.id);
});
}
```
| 字段 | 含义 |
| ---------------- | ---------------------------------------- |
| `state` | 数据端主对象,静态类型是 `IStateBase` |
| `type` | 来源类型:点事件或图块事件 |
| `trigger` | 本次触发时机,来自 `collectEvent` 的参数 |
| `system` | 事件系统对象,可用来再次收集或执行 |
| `heroLocator` | 触发时勇士的位置 |
| `heroFloor` | 触发时勇士所在楼层 id |
| `triggerLocator` | 触发坐标 |
| `tile` | 触发事件的图块;点事件为 `null` |
| `layer` | 触发事件的图层;可能为 `null` |
| `map` | 触发事件的地图;可能为 `null` |
调用项本身只有 `id` 与 `env`,不带优先级;优先级体现在列表顺序里。想同时看到优先级,就像上面这样直接读事件视图。
`env.state` 的静态类型是 `IStateBase`,拿不到 L3 的 `loader` 等成员。内建函数需要更高层能力时,应从事件系统的持有者或插件依赖取得,而不是假设 `env.state` 具备全部成员;这不是类型疏漏,而是为了让事件环境只暴露数据层能力,避免脚本反向依赖启动层。
## 执行与折叠
`env` 是收集时创建的调用上下文,不是会随游戏状态实时更新的视图。`heroLocator`、`triggerLocator` 与 `tile` 记录的是触发那一刻的值;事件脚本执行期间勇士再移动,这些字段不会跟着变。需要当前状态时读 `env.state.hero`,不要把 `env.heroLocator` 当成实时坐标。
## 执行:候选到结果
`EventExecutor.execute(invocations, param)` 按列表顺序逐条处理:
@@ -137,134 +97,71 @@ function printEvents(
2. 事件自身的 `trigger` 与 `invocation.env.trigger` 不一致:静默跳过。
3. 否则 `await event.execute(param, env)`,把结果加入结果列表。
执行模式决定是否提前结束:`Normal` 全部执行;`CutIfFalsy` 遇到 falsy 结果立即结束;`CutIfTruthy` 遇到 truthy 结果立即结束。折叠模式决定返回值:`NoReduce` 返回结果数组;`OrReduce` 从 `false` 起做或运算;`AndReduce` 从 `true` 起做与运算。`OrReduce` 与 `AndReduce` 遇到非布尔值会告警 172,然后按 JavaScript 的短路规则继续。
执行模式决定是否提前结束:
模式和折叠方式都是执行器上的状态,用 `setMode` 与 `setReduce` 修改;它们会被后续执行共享,改完记得按需复位。
| 模式 | 行为 |
| ------------------------------ | ------------------------ |
| `EventExecuteMode.Normal` | 全部执行 |
| `EventExecuteMode.CutIfFalsy` | 遇到 falsy 结果立即结束 |
| `EventExecuteMode.CutIfTruthy` | 遇到 truthy 结果立即结束 |
`execute` 带录像标记,回放时会重放这次执行,不需要额外登记。
折叠模式决定返回值:
```ts
import {
EventReduceMode,
IGameEventExecutor,
IGameEventInvocation
} from '@user/data-system';
| 模式 | 返回值 |
| --------------------------- | --------------------- |
| `EventReduceMode.NoReduce` | 按执行顺序组成数组 |
| `EventReduceMode.OrReduce` | 从 `false` 起做或运算 |
| `EventReduceMode.AndReduce` | 从 `true` 起做与运算 |
/**
* 用「或」折叠执行一组事件,返回合并后的布尔结果。
* 调用方负责保证事件 id 已经在事件仓库中注册。
*/
async function executeOr(
executor: IGameEventExecutor,
invocations: IGameEventInvocation[]
): Promise<boolean> {
// 折叠模式是执行器状态;需要时再改回 NoReduce
executor.setReduce(EventReduceMode.OrReduce);
// param 会原样传给每个事件;没有额外参数时给空对象
return executor.execute<boolean>(invocations, { custom: {} });
}
```
非布尔值在折叠时告警 172,但按正常短路规则继续。`mode` 与 `reduce` 是执行器上的共享状态,不是每次调用的参数;改完要复位,否则会影响后续调用。
## 事件层
把执行策略放在执行器上,而不是每次调用传参数,是为了让「同一批事件按什么策略执行」成为系统级配置:战斗、对话、踩踏可以各自在调用前设置模式,调用后恢复。
事件层不是特殊类型,而是 `alias` 为 `event` 的普通图层。它具备图层的全部能力:图块、点事件视图、图块事件视图、存档与钩子都可以照常使用。
`param` 是 `IBlockEventParam`,形状为 `{ custom }`,执行器把它原样交给事件。脚本里它成为顶层 `scope.args`。内建函数的 `func(param, env)` 第一个参数不是它,而是 `Call.parameters` 求值后的对象;两者不会自动传递。
`IGameMap.eventLayer` 指向当前楼层的默认事件层,类型是 `IMapLayer | null`。地图构造时,图层加载流程会把 `alias` 为 `event` 的图层登记为事件层;切换楼层后,使用的是目标楼层自己的 `eventLayer`。
`EventExecutor.execute` 带 `@shouldReplay`,含义是「执行事件这条路径必须产生录像记录」,不是自动写录像。`onBeforeExecuteEvent` 与 `onAfterExecuteEvent` 是同步钩子;任意事件执行拒绝会让 `execute` 拒绝,后续调用项与 `onAfterExecuteEvent` 不再执行。
勇士移动时,移动实现取当前地图的 `eventLayer` 调用 `collectEvent`;没有事件层时直接返回。点事件与图块事件各自的归属与读写接口见[点事件与图块事件](../ii/point-and-tile-events.md)。
钩子可以修改传入的调用列表与参数对象,所以它们适合做「执行前裁剪候选、执行后记录结果」。但钩子不是事件脚本的一部分:它们看不到 `scope`,也不参与折叠;需要修改事件结果时,仍然要在脚本或事件对象里做。
## 从注册到执行
事件由编辑器的 Blockly 风格事件编辑器产出,作者拖拽语句块,而不是手写脚本。编辑器输出的是一份事件数据:触发器、语句块与参数。内容加载时,运行时按 id 把这份数据登记到 `state.eventStore`;点事件与图块事件只保存这个 id,不保存脚本本身。
一次触发按四步走:
1. 编辑器产出事件数据,运行时在加载期把它注册到事件仓库,得到一个 id。
2. 触发发生时,`collectEvent(layer, trigger, x, y)` 按坐标与图层收集候选,把本次触发时机写进每个调用项的 `env`,返回 `{ id, env }` 列表。
3. `EventExecutor.execute` 按列表顺序逐条处理:先按 id 从仓库取事件,找不到就告警并跳过;再比较事件自身的 `trigger` 与 `env.trigger`,不一致就静默跳过。
4. 触发器匹配的事件按顺序执行,结果按当前执行模式与折叠模式合并。
执行器只按调用列表顺序逐条执行。列表顺序来自收集阶段:点事件组整体在前,图块事件组在后,组内按优先级降序;同一优先级重复设置时后写入的 id 生效并告警。
编辑器里的 `Expression` 只计算值,不产生副作用;要修改 Flag、勇士或地图,必须调用内建函数。`Expression` 的结果不会自动写回,需要被后续语句读取时由事件数据自行组织。脚本第一次执行时编译,之后复用编译产物;`setRaw` 换掉语句块并清空缓存,`setTrigger` 只改触发器,不影响已经编译好的程序。
移动器在撞击分支用目标格收集 `OnTouch`,在结算阶段用旧格收集 `OnLeave`、新格收集 `OnEnter`。触发器不匹配的事件会在执行器被静默跳过,所以同一个坐标可以参与多次不同时机的收集。
## 自定义一个内建函数
事件脚本里能调用的每个动作都是内建函数。新增一个动作分三步:写类、声明参数接口、加入注册列表。
在数据端的标准装配里,事件脚本能调用的动作来自内建函数表。新增一个动作分三步:写类、声明参数接口、加入注册列表。
内建函数类实现 `BuiltInFunction<P, IBlockEventEnv>`:`name` 是脚本里调用的名字,`func(param, env)` 是执行逻辑。下面两个完整的类分别演示写状态与改勇士属性:
内建函数类实现 `BuiltInFunction<P, IBlockEventEnv>`:`name` 是编辑器里显示并调用的名字,`func(param, env)` 是执行逻辑。`param` 来自事件里这次调用的参数,不是事件参数;`env` 是本次触发的上下文。
```ts
import { BuiltInFunction } from '@motajs/anon-tokyo';
import { IBlockEventEnv } from '@user/data-system';
import { IHealHeroEventParam, ISetFlagEventParam } from './types';
写好类后,把实例放进 `createEventRegistrations()` 返回的数组,由数据端入口放进 `ICoreStateConfig.events`,`GameEventSystem` 构造解释器时把它作为 `builtInFunctions` 传入。注册一次后,编辑器里的调用就能按 `name` 找到它。
// 把指定的 Flag 字段设为指定值
export class EventSetFlag implements BuiltInFunction<
ISetFlagEventParam,
IBlockEventEnv
> {
name: string = 'setFlag';
`func` 的第二个参数是本次调用的环境。`env.state` 是入口,Flag、勇士、地图、事件仓库与录像系统都从它取;`env.layer` 与 `env.map` 是触发事件的图层与地图,`env.trigger` 是本次触发时机,`env.tile` 是触发图块(点事件为空),`env.triggerLocator` 是触发坐标,`env.heroLocator` 与 `env.heroFloor` 是勇士的位置与楼层。
func(param: ISetFlagEventParam, env: IBlockEventEnv): void {
// env.state 是数据端主对象,Flag 系统挂在它上面
env.state.flags.setFieldValue(param.field, param.value);
}
}
// 按给定数值回复勇士生命值
export class EventHealHero implements BuiltInFunction<
IHealHeroEventParam,
IBlockEventEnv
> {
name: string = 'healHero';
func(param: IHealHeroEventParam, env: IBlockEventEnv): void {
// 走可修改属性接口,不要直接改 hero.attribute
env.state.hero.getModifiableAttribute().add('hp', param.hp);
}
}
```
参数接口单独声明,通常与其它事件参数一起放在 `src/data/event/types.ts`:
```ts
/** 事件:设置 Flag */
export interface ISetFlagEventParam {
/** Flag 名 */
readonly field: string;
/** 要设置的值 */
readonly value: number | string | boolean;
}
/** 事件:勇士回血 */
export interface IHealHeroEventParam {
/** 回复的生命值 */
readonly hp: number;
}
```
最后把实例加入 `createEventRegistrations()` 返回的数组。这个数组由 `src/data/entry.ts` 放进 `ICoreStateConfig.events`,随配置进入 `CoreState`;`GameEventSystem` 构造解释器时把它作为 `builtInFunctions` 传进去。注册一次后,脚本就能按 `name` 调用。
```ts
import { BuiltInFunction } from '@motajs/anon-tokyo';
import { EventHealHero, EventSetFlag } from './custom';
/**
* 创建全部内建事件的注册项。
* 新增的内建函数加进这个数组即可。
*/
export function createEventRegistrations(): BuiltInFunction[] {
return [
// ...已有的内建函数
new EventSetFlag(),
new EventHealHero()
];
}
```
`func` 的第二个参数是本次调用的环境。`env.state` 是入口,Flag、勇士、地图、事件仓库与录像系统都从它取;`env.layer` 与 `env.map` 是触发事件的图层与地图,`env.trigger` 是本次触发时机,`env.tile` 是触发事件的图块(点事件为空),`env.triggerLocator` 是触发坐标,`env.heroLocator` 与 `env.heroFloor` 是勇士的位置与所在楼层。
需要默认图层时用 `getPossibleLayer(env)`:它先取 `env.layer`,再取 `env.map.eventLayer`,最后按 `env.heroFloor` 找地图的事件层。相同思路的 `getPossibleMap(env)` 用于取地图。
`env.layer` 与 `env.map` 都可能为空。需要操作地图时,先取 `env.layer`,再取 `env.map`,最后用 `env.heroFloor` 从 `state.maps` 找当前地图,再取它的 `eventLayer`;不要假设事件一定由某个具体图层触发。这条兜底顺序能覆盖「事件由脚本临时执行、没有真实触发坐标」的情况。
分步的内建函数写法与更多示例见[自定义事件](../../intermediate/events.md);事件仓库本身的用法见 [data-common 的 Store](../i/data-stores.md)。
## 边界与注意
- **坐标越界返回空列表**:`collectEvent` 不告警,只返回空数组。
- **点事件与图块事件互不替代**:同一格可以同时有两者,也可以只有其中一种。
- **同一优先级只能挂一个事件**:重复设置会覆盖并告警 136。
- **事件 id 必须已注册**:调用列表里的 id 在仓库里找不到时,执行器告警 171 并跳过。
- **触发器不匹配会被静默跳过**:事件自身的 `trigger` 与本次时机不一致,不会告警。
- **短路模式下后面的条目不再执行**:`CutIfFalsy` 与 `CutIfTruthy` 命中后立即结束。
- **折叠模式遇到非布尔值只告警**:`OrReduce` 与 `AndReduce` 会告警 172,但不会中断执行。
- **多条事件是异步顺序执行**:不要假设它们并发,也不要依赖未声明的执行顺序。
- **执行器状态是共享的**:`mode` 与 `reduce` 会影响后续执行,改完记得复位。
- **事件注册属于内容配置**:序列化事件注册与地图事件 id 绑定由编辑器负责,本篇不展开。
- **只有三个触发器有派发点**:`OnTouch`、`OnEnter`、`OnLeave` 由勇士移动派发;其余枚举值已定义,派发点仍待引擎实现。
- **点事件组整体优先**:无论数值高低,点事件组都先于图块事件组;优先级只在组内比较。
- **同优先级会覆盖并告警**:同一事件视图内再次设置同一优先级,旧 id 被新 id 覆盖并告警 136;跨来源同优先级保留收集顺序。
- **执行前有两道检查**:id 找不到会告警 171 并跳过;触发器与本次时机不一致则静默跳过。
- **两种 param 不是一回事**:事件参数从 `scope.args` 读取,内建函数的 `param` 是脚本 `Call.parameters` 求值后的对象。
- **执行器状态是共享的**:`mode` 与 `reduce` 改完要复位,否则会影响后续调用。
- **事件层不是特殊图层**:它只是被指定为事件层的普通图层;`alias === 'event'` 是内容加载的默认约定,`setEventLayer` 可以改。
返回[高级 III 导览](./index.md)。
+74 -38
View File
@@ -1,62 +1,98 @@
# 高级 III 导览
高级 III 是高级教程的最高难度层,讲数据端系统的原理与架构:这些系统为什么这样拆分、数据在它们之间怎么流动、哪些约束不能打破。它面向已经能独立改动数据端、现在需要判断改动该落在哪一层的作者。
高级 III 不提供新的玩法接口,它解决的是「改动应该落在哪里」。同一段逻辑写进插件、事件、寻路、录像或加载,代码都能运行,但会在读档、回放或换楼层时表现出完全不同的结果。本层把数据端的装配、依赖、收集、执行、通行与存储拆开讲,帮助你在动手前判断归属。
这一页说明这一层是给谁的、与高级 I 和高级 II 的区别、需要哪些前置、收录了哪八篇,以及两条阅读路径。本层只写数据端;渲染端原理不在当前范围。
本页只做导航:说明这一层给谁读、与高级 I / II 的边界、七篇正文的分工与阅读顺序。具体机制不在这里展开,遇到系统细节时直接翻对应篇目。
## 高级 III 是给谁的
- 你能读懂类、接口、泛型、装饰器与 TypedArray,不需要逐行解释也能跟上类型签名;
- 你需要判断一个改动该落在状态、系统还是加载层,而不是只把效果调出来;
- 你要排查存档与回放不一致的问题,需要知道状态从哪里来、由谁装配、什么时候写入;
- 你愿意先理解机制再动手,接受「顺序固定」与「不要重排装配」这类约束。
- 能读懂项目里的类型签名,知道一个接口大致约束了哪些能力。
- 手里有一个具体改动,需要先判断它属于装配、插件、事件、寻路、录像还是加载。
- 正在排查存档、回放或加载不一致,想弄清数据是在哪一步丢掉的。
- 接受固定顺序:对象构造、插件阶段、资源加载与读档各有自己的时间线,不能随意互换。
如果只是新增一个事件动作或调用一次寻路,高级 I / II 已经足够。本层适合在改机制之前查阅,也适合在排查「看起来能跑、换一个场景就坏」的问题时建立模型。
## 与高级 I、高级 II 的区别
高级 I 讲扩展机制怎么用:插件、存档、录像一致性与钩子各自怎么接。高级 II 讲具体系统怎么改到自己的需求:范围、图块、装备、寻路、属性与战斗上下文。高级 III 讲这些系统为什么这样设计、数据怎么流、失败会在哪里出现。
| 层级 | 回答的问题 | 典型内容 |
| -------- | ------------------------------------------ | ------------------------------------------ |
| 高级 I | 扩展机制怎么接进来 | 插件、可存档内容、录像一致性、移动器、钩子 |
| 高级 II | 某个具体系统怎么改 | 寻路用法、自定义录像指令、点事件、装备系统 |
| 高级 III | 为什么这样拆、数据怎么流、失败会落在哪一步 | 装配顺序、依赖图、收集与执行、通行与存储 |
三层是难度标签,不是领域分类:同一主题可能在不同层以不同深度出现。读高级 III 时默认你已经会使用相关接口,因此这里不重复调用步骤,只讲结构、顺序与边界。
三层是难度标签,不是领域分类。同一个功能可能同时涉及插件写法(I)、业务系统(II)与装配顺序(III);区别在于本层解释的是「为什么这个位置不能换」,而不是「这个接口怎么调」。
## 阅读时先做的三个判断
进入某一篇之前,先回答三个问题,能避免把页面当成接口清单来读:
1. **这次改的是定义、进度、执行还是启动?** 定义落在仓库,进度落在可存档状态,执行落在系统层,启动落在加载与插件阶段。判断错层,代码往往能跑,但读档或换楼层时才暴露。
2. **对象由谁持有、谁负责创建?** 同一个能力挂在主对象、插件还是事件环境上,生命周期完全不同。先确认持有者,再看它什么时候被构造。
3. **失败会出现在哪一步?** 构造期是 `undefined`,加载期是配置为空,读档期是 key 缺失,运行期是状态不一致。页面里的「边界」一节通常就是按这个顺序组织的。
这三个判断不替代系统细节;它们只是帮助你在七篇正文里找到正确的入口。
## 前置
建议先读完[高级 I 导览](../i/)与[高级 II 导览](../ii/)。其中四篇会反复被本层引用:
建议先读完[高级 I 导览](../i/)与[高级 II 导览](../ii/)。以下四篇会被反复引用:
- [插件系统](../i/plugin-system.md):插件顺序与生命周期是装配页与插件内部页的直接前置;
- [可存档内容](../i/saveable-content.md):存档 id、对象与执行器的约定贯穿装配页;
- [录像一致性](../i/replay-consistency.md):录像页默认你知道哪些方法要登记;
- [自动寻路](../ii/pathfinding.md):寻路页只讲原理与契约,用法与损失函数在那篇。
- [插件系统](../i/plugin-system.md):插件类怎么写、三个时机各自适合做什么。
- [可存档内容](../i/saveable-content.md):存档 id、对象与执行器的约定。
- [录像一致性](../i/replay-consistency.md):哪些方法必须进入录像。
- [自动寻路](../ii/pathfinding.md):`moveTo`、损失函数与瞬移策略的用法。
## 八篇地图
CoreState 是本层多数页面的隐性前置:事件、录像与寻路都建立在四层已经装配完成这一前提上。没有这个前提,后面的「收集」「构图」「播放」都会变成孤立的接口说明。
| 序 | 篇目 | 它能让你做什么 |
| --- | -------------------------------------------------------- | ------------------------------------------------ |
| 1 | [加载系统](./loading-system.md) | 看清三层加载模型、阶段顺序与三类扩展点 |
| 2 | [录像系统底层](./replay-internals.md) | 理解录像的二进制形状、沙箱播放与存档字段 |
| 3 | [事件系统](./event-system.md) | 分清仓库、收集、执行与解释器各自负责什么 |
| 4 | [Anon-Tokyo 解释器](./anon-tokyo.md) | 看懂语句如何编译成程序、执行上下文如何推进 |
| 5 | [通行性谓词与寻路原理](./passability-and-pathfinding.md) | 理解通行契约、两个谓词实例与构图搜索的分工 |
| 6 | [CoreState 组装与初始化](./core-state.md) | 看清四层装配、加载管线与存档挂载 |
| 7 | [插件内部机制](./plugin-internals.md) | 知道插件顺序怎么算、状态怎么推进、依赖写错会怎样 |
## 七篇地图
本页是第八篇,负责导览;其余七篇各自独立成文。
| 篇目 | 它回答的问题 | 什么时候读 |
| -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------ |
| [CoreState 组装与初始化](./core-state.md) | 四层为什么这样分、构造顺序与入口各自负责什么 | 新增子系统、排查装配顺序或对象缺失时 |
| [插件内部机制](./plugin-internals.md) | 插件表怎么变成确定顺序、状态怎么推进、依赖写错会怎样 | 插件没有执行、阶段顺序不对或要声明依赖时 |
| [事件系统](./event-system.md) | 坐标如何变成候选事件、触发器在哪一步过滤、返回值怎么折叠 | 事件不触发、收集顺序不对或要新增内建函数时 |
| [通行性谓词与寻路原理](./passability-and-pathfinding.md) | 一个谓词如何同时服务移动与构图、三种移动结果从哪来 | 改通行规则、排查「寻路能走但勇士不能走」时 |
| [录像系统底层](./replay-internals.md) | 输入如何被记录、二进制录像如何布局、播放如何消费读取流 | 新增录像行为、排查回放不一致或读取流过期时 |
| [加载系统](./loading-system.md) | 资源如何分批加载、阶段之间允许追加什么、配置怎么读取 | 扩展加载内容、排查加载时机或进度问题时 |
| [高级 III 导览](./index.md) | 本层给谁读、各页负责什么、遇到问题先翻哪里 | 第一次进入本层,或需要确认阅读顺序时 |
表的顺序就是原理线的顺序:先建立对象图与生命周期,再进入插件、事件、通行、录像,最后才是加载。加载放在最后,不是因为不重要,而是它依赖前面几层已经能独立解释。
## 阅读路径
- **原理线**:CoreState 组装与初始化 → 插件内部机制 → 事件系统 → Anon-Tokyo 解释器 → 通行性谓词与寻路原理 → 录像系统底层 → 加载系统。适合想一次把数据端的结构与数据流打通。
- **按需线**:先读 CoreState 组装与初始化,建立分层与加载管线的心智模型,再跳到与当前问题最相关的一篇。每篇都单独成立,不需要按顺序读。
- **原理线**:CoreState → 插件内部机制 → 事件系统 → 通行与寻路 → 录像 → 加载。适合一次把数据端的结构与主数据流打通。
- **按需线**:先读 CoreState,建立分层与装配顺序,再跳到当前问题对应的页面。除 CoreState 外,各页可以独立阅读,但都需要假设「四层已装配完成」。
- **排查线**:回放不一致先看录像,再看 CoreState 的装配前提;事件不触发先看事件系统的收集与执行;寻路结果与实际移动不符先看通行页的谓词与默认实现;加载时机不对直接看加载系统。
## 怎么使用这一层
- **先读模型,再看接口**:每页开头的角色表与流程图是整页的骨架;先知道谁负责什么,再去看方法名,才不会把「某个类有哪些成员」当成设计。
- **示例按任务读**:示例通常展示一个完整链路,而不是单个方法。读的时候先看它解决什么问题,再看它把哪几步串起来,最后看它在哪里接入现有系统。
- **边界当排查入口**:每页末尾的「边界与注意」列的是真实失败模式。遇到「能跑但结果不对」时,先从边界里找对应现象,再回到正文确认模型。
- **跨页概念先查归属**:同一个词在不同页面可能有不同职责;不确定某段逻辑该由谁解释时,先看「跨页约定」表,避免在错误页面里找实现。
本层不要求你记住所有接口;它要求你在改动前能说出「这个对象属于哪一层、谁创建它、失败会在哪一步出现」。具体签名回到类型声明或高级 I / II 的对应页面。
## 跨页约定
| 概念 | 归属页 | 其它页只做什么 |
| -------------------------------------------- | ------------ | -------------------------------- |
| 四层职责、构造顺序、入口、插件装配 | CoreState | 默认读者已经拿到装配完成的实例 |
| `dataDeps`、拓扑序、状态机与三阶段 | 插件内部机制 | 只引用「依赖先完成同一阶段」 |
| 事件仓库、触发器、收集顺序、执行器、内建函数 | 事件系统 | 只说明事件对象如何被脚本执行 |
| 通行谓词、默认实现、构图、搜索、系统层 | 通行与寻路 | 只说明三种移动结果对应哪些触发器 |
| 录像步、`IReplayArray`、指令、沙箱、读取流 | 录像 | 只说明回放会重新走同一套游戏逻辑 |
| 加载任务、阶段、配置、自定义内容、进度 | 加载系统 | 只说明加载器在启动流程中的位置 |
| 可存档对象形状、执行器与压缩 | 高级 I | 本层只在装配或读档边界处引用 |
统一术语:**装配**指构造期建对象,**加载**指资源加载,**读档**指从存档恢复状态。三者不是同一件事;本层把「构造」与「加载」严格分开,避免把 `new CoreState` 当成数据已经可读。
## 本层不覆盖什么
- 渲染端的结构性内容:渲染场景、绘制流程与客户端主对象属于渲染端,本层只写数据端;
- 编辑器与内容配置:编辑器属性项、地图事件 id 绑定与序列化事件注册由编辑器负责;
- API 签名清单:本层只讲机制与取舍,不罗列接口参数;
- legacy 包:旧引擎实现只作为对比基线出现,不作为扩展入口。
- 渲染端的结构、动画与输入处理;这里只讲数据端。
- 编辑器、内容配置格式与资源制作流程。
- 完整 API 签名清单;接口只在解释机制时出现。
- legacy 引擎的接口细节;旧实现只作为对比基线,不逐项复述。
- 高级 I / II 已经讲过的插件写法、存档实现、寻路用法与录像指令注册步骤。
## 怎么验证你读懂了
- 能说出 CoreState 的四层装配顺序,并解释为什么不能重排;
- 能说明插件依赖成环或缺失时,为什么下游也会被阻断;
- 能解释事件为什么按「点事件整组在前、图块事件整组在后」的顺序执行;
- 能说出通行性谓词为什么会有两个实例,以及怎样让两侧保持一致;
- 能说明录像为什么能按步号读取,以及读取流为什么会过期;
- 能画出加载的三段阶段,并说清额外配置与自定义任务各自该在什么时候追加。
返回[高级 III 导览](./index.md)。
+209 -170
View File
@@ -1,186 +1,248 @@
# 加载系统
一次加载把游戏运行所需的外部数据取到内存:核心配置、地图与图块、道具与敌人,以及你自己定义的资源。数据端把这套过程拆成可组合的三层:单个资源由 `LoadTask` 描述,多个任务由 `LoadManager` 聚合,阶段顺序由 `MotaDataLoader` 编排。
一次加载把游戏运行所需的外部数据取到内存:核心配置、地图与图块、道具与敌人,以及作者追加的资源。这些数据的来源、大小和用途各不相同,却要放进同一个进度条里等待。数据端因此把加载拆成三层:
这样拆分的好处是每一层都能单独替换:换一个加载环境只需要换 starter,加一种资源只需要加一个任务,加一份配置只需要在正确的阶段追加。
- **单个资源**:一个文件、一次请求、一份结果,是最小单位。
- **任务集合**:把若干资源放在一起推进,累计任务数与字节数。
- **阶段编排**:决定先加载哪一批、每批完成后允许追加什么,最后统一收尾。
本篇先讲模型与阶段顺序,再讲三类扩展点;进度只作为可观察量,最后用一小节说明渲染端如何在接口层消费同一个加载器。
三层依次向上依赖。本篇先给出整体模型,再解释模型里的每个概念,最后说明配置、钩子与自定义内容。
## 系统模型
| 对象 | 职责 |
| ------------------ | ------------------------------------------------------------------------------ |
| `LoadTask<T, R>` | 描述一个资源:用 starter 取响应,按 `dataType` 读取,再交给 processor 产出 `R` |
| `LoadManager` | 聚合多个任务,累计任务数与字节数,并用异步迭代器推进加载 |
| `MotaDataLoader` | 按阶段编排核心配置、额外配置与自定义任务,并暴露 `getConfig` |
| `ILoadTaskStarter` | 环境适配点:决定「怎么取响应」,网页端由 `WebLoadStarter` 用 `fetch` 实现 |
| 对象 | 负责什么 | 什么时候会用到 |
| ---------------- | -------------------------------------------------- | ---------------------------------- |
| `LoadTask` | 代表一个资源:一个地址、一种读取方式、一个处理结果 | 一般由加载器创建,扩展时才直接接触 |
| `LoadManager` | 收集一批任务,推进它们并累计任务数与字节数 | 读进度、显示进度条 |
| `MotaDataLoader` | 按阶段登记并加载核心配置、额外配置和自定义内容 | 登记配置、注册钩子、追加自定义内容 |
`LoadTask` 的泛型有两个参数:`T` 是响应数据类型(`LoadDataType`),`R` 是 processor 处理后的结果。构造任务时至少要给 `identifier`、`url` 与 `dataType`;任务本身不知道环境,所有取数据的工作都交给 starter。
一个任务只关心自己的地址与结果,管理器只关心这一批还有多少没完成,加载器只关心阶段的先后。
`LoadManager` 不关心任务加载的是什么,只维护两个集合与两个计数:已加入的任务、已完成的任务,以及已加载字节与加载总量。它提供的 `load()` 是一个异步迭代器,迭代值就是当前已加载的字节数。
`MotaDataLoader` 是数据端的加载入口。它持有 `manager` 与 `starter`,负责把配置文件与自定义任务按顺序交给管理器,并在阶段之间触发钩子。
`ILoadTaskStarter` 把「取响应」抽象成 `start(request)`,返回 `IResponseLike`。`WebLoadStarter` 只是用 `fetch` 实现它;换到别的环境时,换一个 starter 即可,任务、处理器与阶段编排都不用改。
```text
LoadTask ──starter──> IResponseLike ──processor──> R
│
LoadManager 聚合多个 LoadTask,维护任务数与字节数
│
MotaDataLoader 按阶段把任务交给 LoadManager
```mermaid
flowchart TD
L[MotaDataLoader<br/>决定先加载哪一批] --> M[LoadManager<br/>汇总任务与进度]
M --> T[LoadTask<br/>处理一个资源]
T --> F[取数据<br/>把文件读进内存]
T --> P[处理数据<br/>转成业务对象]
```
## 核心概念
下面从最底层的「响应」开始,自下而上解释模型里用到的接口与类型。
### `IResponseLike`:响应的最小契约
取数据的结果不是一个裸文件,而是一个响应对象。`IResponseLike` 只要求三样信息:`url`(最终地址)、`headers`(可按名称读取响应头)、`body`(可以为 `null`,不为空时能按块读取)。在此之上,它要求能把响应体转换成五种形式之一:`arrayBuffer()`、`bytes()`、`blob()`、`json()`、`text()`。
浏览器的标准 `Response` 在结构上满足这份契约,所以网页端不需要额外包装。自定义 starter 可以返回自己的对象,但必须完整提供上述成员;只实现实际会用到的读取方法在类型上并不成立。
### `ILoadTaskStarter`:决定去哪里、怎么取
starter 是「取数据」的适配点,只有一个方法:
`start(request: ILoadTaskRequest): Promise<IResponseLike>`
`ILoadTaskRequest` 包含 `url`、可选的 `method`、`body` 和 `headers`。starter 不解析内容,也不决定业务含义,只负责按请求配置拿到响应。网页端的 `WebLoadStarter` 直接调用 `fetch(request.url, { method, body, headers })`,把得到的 `Response` 原样返回。
### `LoadDataType`:原始响应按什么格式读
dataType 告诉任务「把响应体读成什么」。它不影响请求本身,只决定交给处理器的原始值类型。五个取值与产物如下:
| `LoadDataType` | 产物 |
| -------------------------- | ------------------------- |
| `LoadDataType.ArrayBuffer` | `ArrayBuffer` |
| `LoadDataType.Uint8Array` | `Uint8Array` |
| `LoadDataType.Blob` | `Blob` |
| `LoadDataType.Text` | `string` |
| `LoadDataType.JSON` | 解析后的 JSON 值(`any`) |
前三种保留二进制,`Text` 做文本解码,`JSON` 在文本解码后再执行 `JSON.parse`。选错类型不会在请求阶段报错,而是在读取或解析阶段失败,因此它必须和文件的实际内容一致。
### `ILoadTaskProcessor`:把原始值变成业务结果
processor 负责「处理数据」:`process(response, task): Promise<R>`。第一个参数是上一步按 dataType 读出的原始值,第二个参数是任务本身,需要地址、标识符或任务钩子时可以从这里取。返回的 `R` 就是任务最终兑现的结果。
processor 可以做校验、兼容旧格式、补默认值,也可以把文本解析成结构化对象。任务允许不设置 processor,此时原始值会直接作为结果;配置加载总是设置 processor,因为它还要去掉 JSONC 前缀。
### `LoadTask<T, R>`:把一个资源串起来
`LoadTask` 把前面四件事收在一起:`identifier`(任务名)、`url`、`dataType`,以及 starter 与 processor。`T` 是 dataType 的类型参数,`R` 是处理结果类型。
- `start()` 返回异步迭代器,每加载一段就产出一个已加载字节数;它会先向 starter 要响应,再按 dataType 读取,最后交给 processor。
- `loaded()` 返回 `Promise<R>`,处理完成时兑现。
- `getLoadedData()` 读取已完成的结果,未完成时是 `null`。
任务本身不写配置表,也不决定自己何时开始;这两件事分别由加载器和阶段顺序负责。
| 概念 | 一句话职责 | 关键成员 |
| -------------------- | ------------------ | -------------------------------------- |
| `IResponseLike` | 响应的最小契约 | `url`、`headers`、`body`、五种读取方法 |
| `ILoadTaskStarter` | 决定去哪里取 | `start(request)` |
| `LoadDataType` | 原始值按什么格式读 | 五个枚举值 |
| `ILoadTaskProcessor` | 原始值转业务结果 | `process(response, task)` |
| `LoadTask` | 把一个资源串起来 | `start()`、`loaded()` |
## 一次加载的阶段
`MotaDataLoader.start()` 按固定顺序推进,前一段没有完成就不会进入下一段:
1. 加载核心配置(`addCoreConfig` 登记的任务)。
2. 触发 `onCoreConfigLoaded`,此时可以继续追加额外配置。
2. 触发 `onCoreConfigLoaded`,此时可以追加额外配置与自定义内容。
3. 加载额外配置(`addExtraConfig` 登记的任务)。
4. 触发 `onExtraConfigLoaded`,此时可以追加自定义任务。
5. 加载自定义任务(`addCustomTask` 登记的任务)。
4. 触发 `onExtraConfigLoaded`,此时可以继续追加自定义内容。
5. 加载自定义内容 / 自定义任务(`addCustomContent` / `addCustomTask` 登记的任务)。
6. 全部任务完成,`loaded()` 兑现。
```text
核心配置 → onCoreConfigLoaded → 额外配置 → onExtraConfigLoaded → 自定义任务 → 完成
```mermaid
flowchart TD
A[核心配置] --> B[onCoreConfigLoaded]
B --> C[额外配置]
C --> D[onExtraConfigLoaded]
D --> E[自定义内容 / 自定义任务]
E --> F[loaded 兑现]
```
这个顺序决定了两条最常用的规则;判断「来不来得及」看的是对应的收集循环有没有跑过,而不是 `start()` 有没有被调用:
阶段顺序决定了两条最常用的规则:
- `addExtraConfig` 必须在第 3 步的收集循环之前:在加载开始前,或在 `onCoreConfigLoaded` 里调用。放到 `onExtraConfigLoaded` 里已经太晚,本次加载不会再读取它。
- `addCustomTask` 必须在第 5 步的收集循环之前:在加载开始前,或在 `onCoreConfigLoaded` / `onExtraConfigLoaded` 里调用都可以。等到自定义任务已经开始加载,或加载结束后再追加,本次加载不会读取它。
- `addExtraConfig` 必须在额外配置的收集循环之前调用:加载开始前,或 `onCoreConfigLoaded` 里都可以;放到 `onExtraConfigLoaded` 已经错过本次加载。
- `addCustomContent` 与 `addCustomTask` 必须在自定义内容的收集循环之前调用:加载开始前,或两个加载钩子里都可以;加载结束后再追加不会被读取。优先使用 `addCustomContent`,只有手里已经有现成任务实例时才用低一层的 `addCustomTask`。
`start()` 只会真正执行一次,第二次调用会因为已经加载过而直接返回,所以错过窗口没有补读机会。
`loader.start()` 在一次运行中只会推进一次,第二次调用直接返回,所以错过窗口没有补读机会。
[`CoreState.load()`](./core-state.md#加载管线) 在加载器外面又包了两层插件时机:插件的 `initialize()` 在 `loader.start()` 之前执行,`afterLoad()` 在加载完成之后执行。因此 `initialize` 适合追加配置或自定义任务,`afterLoad` 适合读取已经加载好的配置。插件三个时机的完整语义见[插件系统](../i/plugin-system.md#生命周期)。
## 配置文件与 getConfig
## 配置文件与处理器
配置文件是带系统前缀的 JSONC:注释集中在开头,最后一行注释固定为 `// --- SYSTEM PREFIX END --- //`,之后才是 JSON。加载器按这行标记切分,再对后半段做 `JSON.parse`。前缀块只供人阅读,标记之前的内容不会成为字段;完全没有标记时,整个文件按 JSON 解析。
配置文件是一种带有系统前缀的 JSONC:注释集中放在文件开头,最后一行注释固定为 `// --- SYSTEM PREFIX END --- //`,之后才是 JSON 内容。加载器用 `PrefixedJSONCProcessor` 按这行前缀切分,再对后半段做 `JSON.parse`。
前缀缺失或 JSON 解析失败时,加载器会报错并把该配置按空对象处理。也就是说,单个坏文件不会让整次加载中断,但依赖这份配置的后续逻辑会读到空内容。
两类配置的职责不同:
| 方法 | 用途 | 加载时机 |
| ------------------------- | -------------------------------------- | ------------------------------------ |
| `addCoreConfig(id, url)` | 核心配置,决定内容清单与顶层信息 | 最先加载 |
| `addExtraConfig(id, url)` | 额外配置,按核心配置给出的内容目录展开 | 核心配置与 `onCoreConfigLoaded` 之后 |
额外配置的 `url` 相对于内容目录:开发环境是 `src/content`,构建后内容目录会被移动到游戏根目录。配置文件路径由加载器统一改写,不需要自己补环境前缀。默认的数据端钩子 `DefaultDataLoaderHook` 会读取核心配置里的内容目录,追加 `enemy`、`item`、`map-list`、`tile` 等额外配置。
配置文件由编辑器产出,不需要手写 JSONC。
## 扩展点:加一份配置
新增一份配置,通常是在插件的 `initialize` 里调用 `addExtraConfig`。`initialize` 在 `loader.start()` 之前执行,正好赶在额外配置阶段之前。
```ts
import { IDataPluginDefine, ICoreState } from '@user/data-state';
/** 任务配置的形状,按你的配置文件内容声明 */
interface IQuestConfig {
/** 任务 id 到任务定义的映射 */
readonly quests: Record<string, { title: string; reward: number }>;
}
// 在数据端插件里追加一份额外配置
export class QuestConfigPlugin implements IDataPluginDefine {
// 插件标识符,在数据端插件注册表里唯一
readonly id = 'quest-config-plugin';
// 本插件不依赖其他插件
readonly dataDeps: string[] = [];
// 不需要同步装配,留空
awake(): void {}
// initialize 在加载开始前执行,适合追加配置
async initialize(state: ICoreState): Promise<void> {
// 路径相对于内容目录,文件由编辑器产出
state.loader.addExtraConfig('quest', 'quest/quest.jsonc');
}
// 额外配置加载完成后,按 id 取回配置
async afterLoad(state: ICoreState): Promise<void> {
const config = state.loader.getConfig<IQuestConfig>('quest');
// 配置缺失时加载器给的是空对象,使用前先判断
if (!config) return;
// 在这里把 config.quests 交给自己的任务系统
```jsonc
// Type: src/data/types IQuestConfig
// 前缀块可以写任意说明,加载器不会读取这些文字
// --- SYSTEM PREFIX END --- //
{
"quests": {
"main-01": {
"title": "初入魔塔",
"reward": 100
}
}
}
```
`addExtraConfig` 的第一个参数是配置 id,也是 `getConfig` 取回时使用的键;第二个参数是相对于内容目录的路径。同一份配置只需要登记一次,读取时用同一个 id。
两类配置的职责与路径作用域不同:
如果确实要新增核心配置,用 `addCoreConfig`,并且必须在加载开始前调用。绝大多数扩展只需要额外配置。
| 方法 | 用途 | `url` 相对于谁 | 开发环境中的实际路径 |
| ------------------------- | -------------------------------------- | -------------- | ---------------------------------------------------------- |
| `addCoreConfig(id, url)` | 核心配置,决定内容清单与顶层信息 | `src` | 入口传 `content/core.jsonc`,读 `src/content/core.jsonc` |
| `addExtraConfig(id, url)` | 额外配置,按核心配置给出的内容目录展开 | `src` | 入口传 `content/enemy.jsonc`,读 `src/content/enemy.jsonc` |
## 扩展点:加一种资源
`addCoreConfig` 与 `addExtraConfig` 的路径都相对于 `src`:默认入口传入的正是 `content/core.jsonc`,额外配置也按 `src` 下的实际位置填写,例如传 `content/quest/quest.jsonc` 得到的是 `src/content/quest/quest.jsonc`。构建后 `src` 前缀会被去掉,`content` 目录仍在游戏根目录下,配置文件路径由加载器统一改写,不需要自己补环境前缀。
新增一种资源类型,需要三样东西:一个描述资源的 `LoadTask`、一个把原始响应转换成你需要的结果的 processor,以及一个能取响应的 starter。准备好后调用 `addCustomTask`。
默认的 `DefaultDataLoaderHook` 会读取核心配置里的内容目录,追加 `enemy`、`item`、`map-list`、`tile` 等额外配置。
`getConfig<T>(id)` 只有在配置表中存在对应项时才返回值:从未登记、任务尚未完成或加载失败时都返回 `null`。配置文件已经跑过处理器、但标记出现多次或 JSON 解析失败时,处理器会产出空对象并照样存进配置表,所以返回 `{}`。空对象是 truthy,`if (!config)` 拦不住它。判断「没拿到配置」用 `config === null`,判断「拿到了但没有内容」再用字段检查。
## 注册一个加载钩子
钩子接口是 `IMotaDataLoaderHooks`,用 `loader.addHook(hook)` 注册。回调可以是 `async`,加载器会在阶段结束时统一等待;注册返回的控制器可以 `unload()` 注销。
```ts
import {
ILoadTaskStarter,
ILoadTaskProcessor,
LoadDataType,
LoadTask
} from '@motajs/loader';
import { IMotaDataLoader } from '@user/data-state';
ICoreState,
IMotaDataLoader,
IMotaDataLoaderHooks
} from '@user/data-state';
/** 自定义资源处理后的结果形状 */
interface IQuestTable {
/** 任务 id 到任务标题的映射 */
readonly titles: Record<string, string>;
}
/** 把 JSON 响应整理成任务系统直接可用的结构 */
class QuestProcessor implements ILoadTaskProcessor<
LoadDataType.JSON,
IQuestTable
> {
async process(response: any): Promise<IQuestTable> {
// 这里只做形状转换,真正的校验放在使用配置的地方
return { titles: response.titles ?? {} };
/** 在核心配置加载后追加任务配置 */
class QuestConfigHook implements IMotaDataLoaderHooks {
async onCoreConfigLoaded(loader: IMotaDataLoader): Promise<void> {
// 额外配置的 url 相对于 src
loader.addExtraConfig('quest', 'content/quest/quest.jsonc');
}
}
/**
* 追加一个自定义资源任务。
* 在加载开始前,或 onCoreConfigLoaded / onExtraConfigLoaded 中调用。
*/
export function addQuestTask(
loader: IMotaDataLoader,
starter: ILoadTaskStarter
): Promise<IQuestTable> {
// 自定义任务的地址不会被加载器改写:开发环境在 src 下,构建后在根目录
const url = import.meta.env.DEV
? `${import.meta.env.BASE_URL}src/content/quest/quest.json`
: `${import.meta.env.BASE_URL}content/quest/quest.json`;
const task = new LoadTask<LoadDataType.JSON, IQuestTable>({
// 任务标识符,用于区分不同资源
identifier: 'quest-table',
// 使用上面按环境计算好的地址
url,
// 响应按 JSON 读取,processor 收到的就是解析后的对象
dataType: LoadDataType.JSON
});
// 处理器负责把原始数据变成业务直接可用的结果
task.setProcessor(new QuestProcessor());
// 任务不会自动继承加载器的 starter,必须显式设置
task.setStarter(starter);
// 返回的 Promise 在这个任务完成后兑现
return loader.addCustomTask(task);
/** 通常写在插件的 awake 或初始化代码里 */
export function registerQuestConfigHook(state: ICoreState): void {
// 注册后立即参与本次加载;需要提前移除时调用返回控制器的 unload()
state.loader.addHook(new QuestConfigHook());
}
```
`dataType` 五选一:`ArrayBuffer`、`Uint8Array`、`Blob`、`Text`、`JSON`。它决定 processor 收到的是哪种原始值;不设置 processor 时,任务结果就是这份原始值。
同一阶段登记的配置并行加载,完成先后不保证。需要读取另一份配置的内容时,把逻辑放到 `onExtraConfigLoaded` 之后,或等插件 `afterLoad`;不要假设先登记的配置先完成。
自定义任务会和配置任务一起交给同一个 `LoadManager`,所以它们都会出现在加载进度里。`addCustomTask` 返回的 Promise 只表示这一个任务完成,不表示整次加载完成;整次加载的完成时机看 `loader.loaded()`。
## 加载自定义内容
## 扩展点:换一个加载环境
`addCustomContent` 用于追加一个 `src` 下的文件,并直接拿到处理后的结果。它替你创建 `LoadTask`、使用加载器自己的 starter,再把任务放进自定义内容这一批,因此不需要手动拼装任务。
换加载环境只需要实现 `ILoadTaskStarter`:`start(request)` 接收请求配置,返回 `Promise<IResponseLike>`。网页端返回的 `Response` 本身就满足 `IResponseLike`,所以自定义 starter 通常只是改请求地址、请求方式或错误处理。
```ts
addCustomContent<T, L extends LoadDataType>(
init: ICustomContentInit<T, L>
): Promise<T>;
```
`ICustomContentInit` 的四个字段:
| 字段 | 含义 |
| ------------ | ------------------------------------------------------- |
| `identifier` | 任务标识,不能与已有加载任务重复 |
| `url` | 文件路径,相对于 `src`;文件必须放在 `src` 下,格式不限 |
| `dataType` | 按什么格式读取响应 |
| `processor` | 把原始值转换成 `T` 的处理器 |
返回的 `Promise<T>` 在任务处理完成后兑现,结果不会写进 `getConfig` 的配置表。调用时机是加载开始前、`onCoreConfigLoaded` 或 `onExtraConfigLoaded`;加载完成后再调用会被忽略,任务不会执行,返回的 Promise 也不会兑现。
一个完整的用法如下:processor 把按行文本转成任务数据,插件在 `awake` 里登记并保存 Promise,在 `afterLoad` 里等待结果。
```ts
import { ICoreState, IDataPluginDefine } from '@user/data-state';
import { ILoadTask, ILoadTaskProcessor, LoadDataType } from '@motajs/loader';
interface IQuestBook {
title: string;
entries: string[];
}
/** 把 UTF-8 文本转成任务数据 */
class QuestBookProcessor implements ILoadTaskProcessor<
LoadDataType.Text,
IQuestBook
> {
process(
text: string,
_task: ILoadTask<LoadDataType.Text, IQuestBook>
): Promise<IQuestBook> {
const [title, ...entries] = text.split('\n').filter(Boolean);
return Promise.resolve({ title, entries });
}
}
/** 在加载前登记,加载完成后再使用结果 */
export class QuestBookPlugin implements IDataPluginDefine {
readonly id = 'quest-book';
readonly dataDeps: readonly string[] = [];
private book!: Promise<IQuestBook>;
awake(state: ICoreState): void {
this.book = state.loader.addCustomContent({
identifier: 'quest-book',
url: 'content/quest-book.txt',
dataType: LoadDataType.Text,
processor: new QuestBookProcessor()
});
}
async initialize(): Promise<void> {}
async afterLoad(): Promise<void> {
const book = await this.book;
// 在这里使用 book
}
}
```
`addCustomTask` 是更低一层的逃生口:已经有一个构造好的 `ILoadTask` 实例时,直接把它交给加载器并等待结果。新增一种资源类型如果需要的不止这些,应反馈给引擎维护者,不要按现有任务拼一套旁路流程。
## 自定义 starter
换加载环境只需要实现 `ILoadTaskStarter`:`start(request)` 接收请求配置,返回 `Promise<IResponseLike>`。网页端返回的 `Response` 本身满足 `IResponseLike`,因此自定义 starter 通常只是改请求地址、请求方式或错误处理。
```ts
import {
@@ -189,13 +251,12 @@ import {
IResponseLike
} from '@motajs/loader';
/** 把请求改写到另一个来源的启动器 */
/** 把所有请求转发到另一个资源来源 */
export class BaseURLLoadStarter implements ILoadTaskStarter {
// 目标来源,例如另一台资源服务器
constructor(private readonly baseURL: string) {}
start(request: ILoadTaskRequest): Promise<IResponseLike> {
// 请求地址与请求配置都由调用方给出,starter 只负责取响应
// 请求由任务给出,starter 只负责取响应
return fetch(new URL(request.url, this.baseURL), {
method: request.method,
body: request.body,
@@ -205,15 +266,11 @@ export class BaseURLLoadStarter implements ILoadTaskStarter {
}
```
把 starter 实例传给创建数据端主对象的入口即可,例如 `createCoreState(new BaseURLLoadStarter('https://example.com/'))`。任务、处理器与阶段顺序都不需要改。
同样的抽象也支持本地数据包:把本地目录里的文件包装成 `IResponseLike`,就能让同一套加载流程从本地读取。仓库当前没有提供这种实现,本篇不展开。
把 starter 实例传给数据端入口即可,例如 `createCoreState(new BaseURLLoadStarter('https://example.com/'))`。任务、处理器与阶段顺序都不需要改。
## 进度
`LoadManager` 的 `load()` 返回异步迭代器:每推进一次,迭代值就是当前已加载的字节数。`MotaDataLoader.start()` 把同样的迭代值转发出来,所以 `for await` 迭代加载器就能跟着加载进度走。
加载过程中可以读取这些计数:
加载由 `state.load()` 驱动。界面要显示进度时,读 `loader.manager` 上的计数即可,不需要接管加载:
| 成员 | 含义 |
| ------------------ | ------------------------------ |
@@ -223,32 +280,14 @@ export class BaseURLLoadStarter implements ILoadTaskStarter {
| `getLoadedByte()` | 已加载的字节数 |
| `getByteRatio()` | 已加载字节与总量之比 |
这些计数只用于展示。数据端不依赖进度决定任何业务逻辑;需要等待加载结果时,应当读配置、读任务结果或等 `loaded()`,而不是等某个比例。数据端与渲染端的分工见[数据端与渲染端](../../intermediate/data-vs-render.md)。
## 渲染端如何消费加载进度
渲染端复用同一套配置与同一个加载器:它在数据端配置的基础上创建渲染端主对象,再追加自己的渲染侧配置。`RenderLoaderHooks` 就是在 `onCoreConfigLoaded` 里追加贴图、音频、动画与字体配置的钩子。
加载场景只做接口级消费:迭代同一个 `loader.start()`,从 `loader.manager` 读取已加载任务数、已加入任务数、已加载字节与字节比例,交给进度条绘制。它不改变加载顺序,也不参与配置解析。
```text
state.loader.start()
├─ 迭代值:当前已加载字节
└─ state.loader.manager
├─ getLoadedTasks() / getAddedTasks()
└─ getLoadedByte() / getByteRatio()
```
这些计数只用于展示。需要等待加载结果时,应当读配置、读任务结果或等 `loaded()`,而不是等某个比例。渲染端可以复用同一个加载器:在 `onCoreConfigLoaded` 里追加贴图、音频、动画与字体等渲染侧配置,再从同一个 `loader.manager` 读取计数绘制进度条。
## 边界与注意
- <strong>配置 id 是键</strong>:同一个 id 只登记一份配置,后来用同一个 id 追加不会变成第二份。需要两份配置时使用不同 id。
- <strong>addCustomTask 的调用时机</strong>:只能在加载开始前,或 `onCoreConfigLoaded` / `onExtraConfigLoaded` 中调用;等到自定义任务已经开始加载或加载结束后再调用,本次加载不会读取它。
- <strong>addExtraConfig 的调用时机</strong>:只能在加载开始前或 `onCoreConfigLoaded` 中调用;在 `onExtraConfigLoaded` 里追加已经错过本次加载。
- <strong>前缀缺失或解析失败</strong>:加载器会报错并把该配置按空对象处理,整次加载继续。依赖这份配置的代码要能接受空内容。
- <strong>自定义任务的路径</strong>:配置文件路径由加载器统一改写;自己构造的 `LoadTask` 地址不会被改写,需要按开发环境与构建产物分别给出正确路径。
- <strong>自定义 starter 的返回契约</strong>:`start` 必须返回满足 `IResponseLike` 的对象;标准 `Response` 可以直接用。
- <strong>自定义任务要设置 starter</strong>:任务不会自动继承加载器的 starter,漏设时任务不会开始加载。
- <strong>字节进度可能不准</strong>:所有任务的总大小要等响应头都到达后才能确定,总量未知时字节比例只是近似值。
- <strong>start 必须被迭代</strong>:调用 `loader.start()` 只是拿到异步迭代器,真正推进加载的是迭代过程。`CoreState.load()` 已经替你完成迭代;手动驱动时要自己消费完。
- <strong>配置 id 是键</strong>:同一个 id 在配置表中只保留一份,配置完成时若 id 已被占用,后来的任务会告警并被丢弃;需要两份配置时使用不同 id。
- <strong>addExtraConfig 的时机</strong>:只能在加载开始前或 `onCoreConfigLoaded` 中调用;在 `onExtraConfigLoaded` 里追加已经错过本次加载。
- <strong>addCustomContent 的时机与路径</strong>:只能在加载开始前、`onCoreConfigLoaded` 或 `onExtraConfigLoaded` 中调用;`url` 相对于 `src`,文件必须位于 `src` 下,加载完成后再调用会被忽略。
- <strong>解析失败是空对象</strong>:JSONC 标记出现多次或 JSON 非法时,加载器告警并把该配置存成 `{}`,整次加载继续;`getConfig` 对未登记 id 返回 `null`,对解析失败返回 `{}`。
- <strong>自定义 starter 的返回契约</strong>:`start` 必须返回满足 `IResponseLike` 的对象,标准 `Response` 可以直接使用。
返回[高级 III 导览](./index.md)。
+133 -128
View File
@@ -1,152 +1,149 @@
# 通行性谓词与寻路原理
通行性谓词回答两个问题:这一步能不能走进目标格,以及这次移动会不会撞上目标格。它是数据端唯一的通行语义契约:勇士移动在每一步开始时问它,寻路在构图时也问它。因此在默认实现下,「寻路选出的路」与「勇士实际能走的路」由同一套规则算出。
「寻路能走,但勇士走不过去」通常不是寻路算错了,而是两侧看到的通行规则或绑定的图层不同。勇士移动在每一步开始时问一次通行谓词,寻路在构图时对每个邻格问同一个契约;只要有一侧换了谓词、换了事件层,或者把「能走但会撞」当成了「不能走」,结果就会分叉。
本篇讲这个契约、默认实现如何判断、它为什么会出现两个实例,以及寻路如何构图、搜索并把步骤交给移动器。损失函数与瞬移策略属于[自动寻路](../ii/pathfinding.md)的用法,本篇不重复。
本篇讲通行谓词契约、默认实现怎么判断、勇士移动的三种结果,以及寻路如何构图、搜索并把步骤交给移动器。损失函数与瞬移策略的用法属于[自动寻路](../ii/pathfinding.md),本篇不重复。
## 系统模型
通行性判断只有一组输入:当前位置、目标位置、移动方向与地图。勇士移动与寻路构图都把这组输入打包成 `IPassCheckHandler`,再调用同一个 `IPassPredicate`。两者的差别只在「拿到结果后做什么」。
| 对象 | 职责 |
| 对象 | 负责什么 |
| -------------------------- | ------------------------------------------------------ |
| `IPassCheckHandler` | 一次判断的输入:当前格、目标格、方向与地图 |
| `IPassPredicate` | 通行性契约:`canPass` 与 `shouldHit` |
| `DefaultPassPredicateImpl` | 数据端默认实现:按事件层与图块的通行位判断 |
| `MapGraphBuilder` | 从起点出发构图,只保留可达节点,并标记终端 |
| `DefaultPassPredicateImpl` | 默认实现:按事件层与图块的通行位判断 |
| `MapGraphBuilder` | 从起点构图,只保留可达节点,并标记终端 |
| `PathFinder` | 在图上搜索最小损失路径 |
| `PathfindingSystem` | 绑定移动器、图层、谓词与方向处理器,并把步骤交给移动器 |
两个消费方的调用方式如下。寻路本身不判断通行,它只是反复调用谓词,把「能不能走」翻译成图的边。
```text
勇士移动:一步开始
└─ IPassPredicate.canPass / shouldHit
├─ canPass 为 false → 不能走
├─ canPass 为 true 且 shouldHit → 撞停
└─ 两者都放行 → 正常一步
寻路:构图
└─ 对每个相邻格调用同一组方法
├─ canPass 为 false → 不建边
├─ shouldHit 为 true → 目标格记为终端
└─ 其余 → 建一条可通行边
```mermaid
flowchart TD
H[勇士移动一步] --> P[IPassPredicate]
G[MapGraphBuilder.build] --> P
P --> C{canPass?}
C -->|否| X[停止 / 不建边]
C -->|是| S{shouldHit?}
S -->|是| T[撞击 / 终端节点]
S -->|否| N[正常进入 / 普通边]
T --> R[结算或搜索]
N --> R
R --> M[交给移动器或完成本步]
```
寻路整体是「构图 → 搜索 → 交给移动器」:`MapGraphBuilder` 把当前楼层变成一张有向图,`PathFinder` 在图上找一条最小损失路径,`PathfindingSystem` 再把路径拆成步骤交给绑定的移动器。三者都不自己判断通行,判断统一落在谓词上。
关键分工:寻路本身不判断通行,它只是反复调用谓词,把结果翻译成图的边与终端;移动器也不判断通行,它只负责把算出的落点写回对象。谓词是两侧共同的语言,但它不持有「当前是哪个对象」。
## 通行性谓词契约
## 通行谓词契约
`IPassPredicate` 只有两个方法,接收同一个 `IPassCheckHandler`:
`canPass` 与 `shouldHit` 回答两个不同的问题:
- `canPass(handler)`:判断能否从当前格进入目标格。
- `shouldHit(handler)`:判断这次移动是否应该产生撞击。
- `canPass` 为假:真的进不去。勇士会停止,位置不变;构图不会建这条边,也不会继续扩展目标格。
- `shouldHit` 为真:走得到,但会撞。勇士会停在原地并触发撞击;构图仍然建边,但把目标格记为终端。
两者是不同的问题。`shouldHit` 为真并不表示「不能走」,而是「走得到,但会撞」;`canPass` 为假才是真的进不去。勇士移动正是按这个顺序判断的:先看 `canPass`,再在放行时看 `shouldHit`。寻路构图也用同一组结果:`canPass` 决定建不建边,`shouldHit` 决定目标格是不是终端。
`IPassCheckHandler` 把一次判断需要的输入打包在一起:`currLoc`、`nextLoc`、`direction`、`map` 与 `state`。谓词不保存「当前是哪个对象」,所以它可以被移动与构图复用;会不会共享实例,取决于它有没有自己的状态。
`IPassCheckHandler` 带着 `currLoc`、`nextLoc`、`direction` 与 `map`,还带着数据端主对象。谓词不保存「当前是哪个对象在走」的状态,同一个判断函数可以同时被勇士和寻路调用。
移动器只知道「这一步落到哪」,不知道地图规则、事件或对象身份。把通行判断塞进移动器,会让每个移动器都重新实现一遍地图查询,也会让动态图块、NPC 与勇士的规则混在一起。通行谓词把「规则」抽出来,移动器只消费结果,构图也只消费结果;同一套规则可以同时服务两种消费者。
## 默认实现怎么判断
代价是移动器必须在正确的时机调用谓词,并把结果翻译成自己的结果码。这个翻译层不能省:`cannotMove`、`hit`、`step` 对应不同的停止与事件行为,谓词本身不执行这些行为。职责分开后,替换谓词不会改变移动器的落点逻辑,替换移动器也不会改变地图规则。
`DefaultPassPredicateImpl` 是数据端内置的默认实现。它在 `canPass` 里分层判断:
### 默认实现怎么判断
1. 先从朝向管理器取四方向处理器,把传入方向降级到四向;取不到处理器或降级为 `Unknown` 时直接不通行。
2. 没有地图、地图没有事件层时直接不通行。
3. 在事件层上,当前格静态图块的 `pass.outPass` 必须允许朝移动方向离开;目标格静态图块的 `pass.inPass` 必须允许从反方向进入。事件层的图块无论是否标记「只判事件」都参与判断。
4. 在其它图层上,只检查 `pass.onlyEvents` 为真的图块;没标记的图块不参与通行判断。标记了的图块同样检查离开与进入两个方向。
`DefaultPassPredicateImpl` 的 `canPass` 按下面的顺序判断:
`shouldHit` 只看事件层的目标格:目标格静态图块存在,并且它的 `eventPass` 为假时,返回真。`eventPass` 是图块的事件通行标记:为真表示玩家会走上去触发事件,为假表示玩家会撞在格子上触发事件。因此默认规则下,带事件但不可走上去的格子会成为寻路的终端,而不是普通中间格。
1. 从朝向管理器取四方向处理器,把传入方向降级;取不到处理器或降级为 `Unknown` 时直接不通行。
2. 没有地图,或地图没有事件层时直接不通行。
3. 在事件层上,当前格静态图块的 `outPass` 必须允许朝移动方向离开;目标格静态图块的 `inPass` 必须允许从反方向进入。事件层的图块无论是否标记「只判事件」都参与判断。
4. 在其它图层上,只检查 `onlyEvents` 为真的图块;没标记的图块不参与通行判断。标记了的图块同样检查离开与进入两个方向。
下面这段函数用默认谓词判断某一步的结果,三种返回值与勇士移动的分支一一对应:
当前格或目标格没有静态图块时,该侧不施加限制:空格不会因为缺少 `pass` 而被拒绝。默认玩法按四方向组织;斜向输入只用降级结果确认能否得到有效方向,通行位掩码仍按本次移动方向计算,不能把「降级后的方向」当成掩码依据。
通行位到方向的映射是固定的:
| 位 | 掩码 | 在 `outPass` 中 | 在 `inPass` 中 |
| ------- | ---- | --------------- | ---------------------------------- |
| `Up` | 1 | 允许向上离开 | 允许从上方进入(本次移动方向为下) |
| `Right` | 2 | 允许向右离开 | 允许从右方进入(本次移动方向为左) |
| `Down` | 4 | 允许向下离开 | 允许从下方进入(本次移动方向为上) |
| `Left` | 8 | 允许向左离开 | 允许从左方进入(本次移动方向为右) |
`outPass` 直接使用移动方向;`inPass` 使用移动方向的反方向。例如从下方往上进入目标格时,移动方向是 `Up`,目标格检查的是 `inPass.Down`。
位掩码允许一个图块同时允许多个方向:`Up` 与 `Right` 两个位可以同时成立。判断时用按位与,而不是相等比较;把方向映射成掩码后,`mask & pass` 非零就表示允许。这样图块定义不需要为每个方向组合单独列一项,新增方向也只需要分配新的位。
`shouldHit` 只看事件层的目标格:目标格静态图块存在,并且它的 `eventPass` 为假时返回真。`eventPass` 为真表示玩家会走上去触发事件,为假表示玩家会撞在格子上触发事件。因此默认规则下,带事件但不可走上去的格子会成为寻路的终端,而不是普通中间格。
默认实现只读取静态图块。动态图块与其它占格对象不在这套判断里;需要它们挡路时,写自定义谓词。
事件层与其它图层的差别不是性能,而是语义:事件层描述「这一格在玩法上是什么」,所以它的图块无论是否标记 `onlyEvents` 都参与判断;其它图层大多只用于表现或装饰,只有显式标记 `onlyEvents` 的图块才会阻挡移动。这样作者在背景层放一张看起来像墙的图,不会意外改变通行性;要让装饰层参与,必须显式声明。
`outPass` / `inPass` 与 `eventPass` 不是同一类标记。前两者是通行位,回答「从哪个方向能离开 / 进入」;`eventPass` 不参与 `canPass`,只参与 `shouldHit`,回答「进入这一格时是走上去触发,还是撞停触发」。一个格子可以 `canPass` 为真、`eventPass` 为假:勇士不会真正进入目标格,而是在目标格前停下并触发撞击,构图上它是终端。把 `eventPass` 当成通行位,会让「可撞击的墙」变成完全不可达。
::: warning
默认谓词没有内部状态、每个方法无副作用,所以移动侧与寻路侧各自持有一个实例也会得到一致结果。只有给自定义谓词加了自己的状态(缓存、计数器、动态开关)时,才必须把同一个实例注入两侧,否则会出现不同步:寻路按一份状态构图,勇士按另一份状态移动。
:::
## 勇士移动如何使用谓词
用谓词判断一步只有三个分支:
```ts
// src/data/hero/passCheck.ts
import { ITileLocator } from '@motajs/common';
import { FaceDirection } from '@user/data-common';
import { IPassCheckHandler } from '@user/data-base';
import { ICoreState } from '@user/data-state';
import { DefaultPassPredicateImpl } from './predicate';
import { IPassCheckHandler, IPassPredicate } from '@user/data-base';
/** 一步的三种结果:不能走、走得到但会撞、正常进入。 */
/** 一步的三种结果:不能走、走得到但会撞、正常进入 */
export type StepResult = 'cannotMove' | 'hit' | 'step';
/**
* 用默认谓词判断从 curr 朝 direction 走到 next 的结果。
* 默认谓词内部按事件层与 onlyEvents 图块检查通行位,并用 eventPass 判断撞击。
*/
/** 用调用方传入的谓词判断一步;不要在每次判断时新建谓词 */
export function judgeStep(
state: ICoreState,
curr: ITileLocator,
next: ITileLocator,
direction: FaceDirection
predicate: IPassPredicate,
handler: IPassCheckHandler
): StepResult {
// 判断一次也要构造完整输入,地图与状态都从数据端主对象取
const handler: IPassCheckHandler = {
currLoc: curr,
nextLoc: next,
direction,
map: state.hero.location.map,
state
};
// 谓词本身只保存数据端主对象,频繁判断时应复用同一个实例
const predicate = new DefaultPassPredicateImpl(state);
if (!predicate.canPass(handler)) return 'cannotMove';
if (predicate.shouldHit(handler)) return 'hit';
return 'step';
}
```
## 同一契约、两个实例
这个函数只表达契约,不包含移动器行为。真正的移动器还会先判断边界、记录录像、处理 `ignoreTerrain`,并在结算阶段派发事件。
这是本页最容易讲错的一点:勇士移动与寻路用的谓词来自同一个类,但不是同一个对象。
`HeroMover` 在每一步开始时先记录方向,再判断边界与通行。谓词的结果直接对应三种移动结果:
| 消费方 | 实例来源 | 注入方式 |
| -------- | --------------------------------------------------- | ----------------------------------- |
| 勇士移动 | `DefaultHeroMoveTopImpl` 构造时自己新建一个默认谓词 | 顶层实现的 `predicate()` 返回它 |
| 寻路构图 | 数据端初始化时另建一个默认谓词 | `finder.usePassPredicate(...)` 注入 |
| 结果 | 判定 | 后续行为 |
| ------------ | -------------------- | ------------------------------------------------------------------- |
| `CannotMove` | `canPass` 为假 | 调用 `cannotEnter`(默认空实现),停止移动,位置不变 |
| `Hit` | `canPass` 为真且会撞 | 调用 `hit`,触发 `OnTouch`,停止移动 |
| `Step` | 两者都为假 | 写入目标格,结算时先 `leave` 再 `enter`,触发 `OnLeave` / `OnEnter` |
`DefaultHeroMoveTopImpl` 把谓词保存在字段里,`predicate()` 每次返回同一个实例。[数据端初始化时](./core-state.md)又为寻路求解器新建了一个默认谓词,通过 `state.pathfinding.finder.usePassPredicate(...)` 注入。两者行为一致,因为它们由同一个类实现、读同一份地图数据;但它们没有共享状态,也没有自动同步。
撞击与进入是两条不同的路径:`Hit` 不会进入目标格,它的撞击分支触发 `OnTouch`;`Step` 才写入新坐标,并在结算阶段离开旧格、进入新格。默认的 `cannotEnter` 是空实现,所以「不能走」的专属分支不会派发事件。
因此,改勇士侧的谓词不会改变寻路侧,改寻路侧的谓词也不会改变勇士侧。要让两边保持一致,必须两边都换:要么把同一个谓词实例同时交给顶层实现和求解器,要么在两侧分别构造同一个自定义类。
还有一条容易忽略的边界:移动器的结算钩子在每步之后都会运行,不区分这一步是否真的改变了坐标。因此 `CannotMove` 与 `Hit` 虽然不写入新坐标,仍会在原格走一遍结算;`DefaultHeroMoveTopImpl` 会以相同的前后坐标收集 `OnLeave` 与 `OnEnter`。判断「有没有走成」要看坐标,不要只看触发器次数。边界越界或缺少顶层实现时还会产生内部的 `Stop`,同样会停止且不改变坐标。
## 勇士移动的三种结果
`HeroMover` 还有两个开关会改变这条链:`ignoreTerrain` 为真时跳过谓词与进出 / 撞击事件,只剩边界检查;`allowOutBound` 为真时连边界也跳过。打开 `ignoreTerrain` 后改谓词看不到效果,因为谓词根本没有被调用。这两个开关属于移动器配置,不属于谓词;不要用它们代替通行规则。
勇士移动在每一步开始时调用顶层实现的 `predicate()`,再用返回的谓词判断这一步:
方向移动在判断通行之前就会写入录像。即使这一步最终是 `CannotMove` 或 `Hit`,回放也会重放同一次尝试,得到同样的失败结果。这不是遗漏:如果只记录成功的步,回放时失败尝试带来的告警、事件或状态变化就会消失。事件脚本驱动的移动会临时关闭录制,避免把脚本位移记成玩家输入,见[录像系统底层](./replay-internals.md)。
- 不能走:`canPass` 为假。调用顶层实现的 `cannotEnter(handler)`,然后停止本次移动,位置不变。当前默认实现里这个回调是空实现,没有对应的内置触发器。
- 撞停:`canPass` 为真、`shouldHit` 也为真。调用顶层实现的 `hit(handler)`,然后停止本次移动,位置不变。
- 正常一步:两者都放行。本步返回目标格,移动器把坐标写过去;结算时先触发离开当前格,再触发进入目标格。
移动侧只有三个派发点:`OnTouch` 来自 `hit` 分支,`OnLeave` 与 `OnEnter` 来自结算阶段;正常 `Step` 时两者分别作用于旧格与新格,失败步时两者作用于同一格。其余触发器目前没有移动侧派发点,见[事件系统](./event-system.md#事件对象与触发器)。
三种结果与触发器派发的对应关系只有一句:撞停走触碰,正常一步走离开与进入,不能走目前没有内置触发器。触发器本身的收集与执行属于事件系统,本篇不展开。
## 自动寻路系统中的构图
## 构图:从起点只保留可达节点
接下来讲自动寻路系统中的构图步骤,构图由 `MapGraphBuilder` 负责。`MapGraphBuilder.build(start)` 先检查图层、谓词、方向处理器与起点;缺任何一项,或起点越界,都返回 `null`。然后用广度优先扩展:
`MapGraphBuilder` 负责把图层变成有向图。构图前必须已经注入图层、通行谓词与方向处理器;起点还必须在地图范围内。缺任何一项,`build(start)` 都会告警并返回 `null`。
- 对每个邻格调用谓词,`canPass` 为假时不建边,也不继续扩展该邻格。
- `canPass` 放行但 `shouldHit` 为真时,仍然建边,同时把邻格记为终端。
- 只有从起点可达的节点才进入图;不可达区域不会被搜索看到。
构图从起点开始做广度扩展:
BFS 会跳过 `face.mapMovement()` 里偏移为 `(0, 0)` 的项,否则原地邻居会让队列永远不空。每个节点的出边按方向处理器给出的邻域生成,所以四方向处理器只产生上下左右,八方向处理器会产生斜向边。
1. 用方向处理器的 `mapMovement()` 枚举邻域,跳过零偏移的原地条目。
2. 邻格不在地图内就跳过;否则用当前格、邻格、方向与地图构造 `IPassCheckHandler`。
3. 谓词的 `canPass` 为假时不建边,也不继续扩展该邻格。
4. `canPass` 放行但 `shouldHit` 为真时,仍然建边,同时把邻格记为终端。
5. 只有从起点可达的节点才进入图;不可达区域根本不会出现在图里。
每个节点的损失由损失函数计算,未注入时按 1 处理。损失只决定「进入这一格要花多少代价」,不决定能不能进入。`NaN` 与负数会告警并回退为 1;当前实现接受 `Infinity`,但它不是通行判断工具,不可进入仍应由谓词控制。
每个节点的损失由损失函数计算,未注入时按 1 处理;损失值非法时也回退为 1。损失函数只决定「进入这一格要花多少代价」,不决定能不能进入。
图只在本次 `build` 中存在,寻路系统不缓存上一次的图。换楼层、换图层或换谓词后必须重新构图;旧图不会自动失效,也不会被复用。
终端是构图阶段留下的约束:终端格可以作为路径的终点,但不能作为路径的中间点。默认实现里,`shouldHit` 为真的格子就是终端,所以「走得到但会撞」的格子只在作为目标时才会出现在路径末尾。
只保留从起点可达的节点,是一个很重要的取舍:不可达区域不会进入搜索,搜索不必为它们分配距离,也不会因为地图另一半的复杂结构而变慢。代价是每次寻路都要重新 BFS 一遍;但地图规模通常远小于「把整张图预先建好并长期维护」的成本,而且换楼层、开门、推石头之后,旧图本来就不可信。
## 搜索:最小损失与终端约束
终端节点仍然建边,是因为它需要作为路径终点。如果把 `shouldHit` 的格子直接当成不可通行,搜索就永远到不了它,玩家也无法把「撞一下触发事件」的格子设为目标。建边加终端标记,把「能到达」与「能穿过」分开表达。
`PathFinder` 在构好的图上搜索最小损失路径。它不重新判断通行,也不重新计算损失,只读取节点上的 `cost` 与 `terminal`。
## 自动寻路的搜索与交给移动器
搜索的输入输出可以归结为几条约束:
图建好后,搜索只读取节点上的 `cost` 与 `terminal`,不重新判断通行,也不重新计算损失。`PathFinder.search` 用最小堆按累计损失扩展节点;过期堆条目会被跳过,直到取出目标节点或堆空。
- 起点等于终点时直接返回空路径,表示目标就在脚下。
- 目标不在图里时返回空路径;图只包含从起点可达的节点,不可达自然找不到。
- 终端节点只能作为终点:搜索不会把一个终端节点当作通往其它节点的中间点。
- 找到路径时返回按起点到终点排列的步骤,每一步带方向、出发位置与到达位置。
终端格可以作为路径的终点,但不能作为路径的中间点;默认实现里,`shouldHit` 为真的格子就是终端,所以「走得到但会撞」的格子只在作为目标时才会出现在路径末尾。起点自身若被标为终端,不会继续扩展;这是构图与搜索共同遵守的约束。
`find(start, target)` 把构图与搜索串起来,并把结果归入四个状态:
@@ -157,43 +154,51 @@ export function judgeStep(
| `PathfindingStatus.NoPath` | 图上找不到路径,例如目标被终端格或不可通行区域挡住 |
| `PathfindingStatus.TargetUnder` | 起点与终点相同,目标就在脚下 |
`NoPath` 是正常结果,不是错误。它说明按当前谓词与当前图层,从起点走不到目标;调用方不需要为它做特殊恢复。
`TargetUnder` 在构图之前就会返回,因为它不需要图:起点与终点相同,移动没有意义。不要把它当成 `NoPath` 的一种;它的语义是「已经到达」,调用方通常只需要跳过本次移动。
## 系统层做什么
`NoPath` 是正常结果,不是错误。它只说明按当前谓词与当前图层,从起点走不到目标。目标越界也会落到 `NoPath`:图里没有这个节点,搜索直接返回空路径。
`PathfindingSystem` 是寻路的对外入口,负责把求解器与移动器接起来:
损失与通行是两件事。损失函数只决定「进入这一格要花多少代价」,它不参与「能不能进」;把 `Infinity` 当通行判断,会让构图仍然建边、搜索却得到一个无法解释的代价。正确做法是让谓词拒绝不可进入的格子,损失函数只处理「可进入但更贵」的情况,例如沼泽、岩浆或减速地形。
- 用 `useMover(mover)` 绑定移动器;`getPath(target)` 从移动器的 `tile` 读取起点,没有绑定移动器时直接返回 `InvalidInput`。
- 图层、通行谓词、方向处理器与损失函数都通过 `finder` 注入;换楼层时重新绑定图层。
- `moveTo(target)` 总是逐步移动:取到 `Success` 的路径后,把每一步的方向交给移动器,再启动移动器。
- `teleportTo(target)` 先询问瞬移回退策略;没有策略或策略返回真时退回逐步移动,只有策略放行才把移动器瞬移到最后一个步骤的终点。
- 上一次移动还没有结束时,新的 `moveTo` 或 `teleportTo` 返回 `null`,不会接管当前移动。
- `interrupt()` 会等待底层移动真正停止,再允许开始下一次移动。
终端与损失也互不替代。终端描述「这一格只能作为终点」,损失描述「进入它要花多少」。一个终端格可以有很高的损失,也可以在作为目标时被搜索到;它不是「不可达」的别名。
系统层不保存地图数据,也不缓存上一次的图;每次寻路都从当前绑定的图层重新构图。完整的绑定流程、损失函数与瞬移策略示例见[自动寻路](../ii/pathfinding.md)。
损失也不能反过来表达优先级。搜索只按累计损失最小选路,不读取图块类型或事件;需要「优先走某类格子」时,用损失函数调整进入代价,不要在地图或事件里塞隐藏标记。
## 换一套通行语义
搜索得到路径后,`PathfindingSystem` 负责把求解器与移动器接起来:
- `useMover(mover)` 绑定移动器,之后 `getPath(target)` 从移动器的 `tile` 读取起点。
- `moveTo(target)` 取到 `Success` 的路径后,把每一步的方向交给移动器,再启动移动器;它总是逐步移动。
- `teleportTo(target)` 也先取路径,但在策略允许时直接瞬移到最后一个步骤的终点;没有策略时默认退化为逐步移动。
- `interrupt()` 停止当前自动寻路。已有移动进行中时,新的 `moveTo` / `teleportTo` 返回 `null`,不会接管当前移动。
系统层不保存地图数据,也不缓存上一次的图;每次寻路都从当前绑定的图层重新构图。`teleportTo` 的瞬移只落在终点,途经格不会触发踩踏事件;需要补触发时用瞬移钩子,见[自动寻路](../ii/pathfinding.md#瞬移钩子)。
初始状态下寻路的图层是 `null`:`MainDataPlugin` 只注入谓词与移动器,因为勇士还没有进入楼层。此时 `getPath` 会得到 `InvalidInput`;进入楼层后由切换楼层的逻辑绑定事件层与方向处理器,寻路才真正可用。不要在没有楼层时反复重试 `getPath`,也不要在绑定前假设图已经存在。
不缓存图是刻意的:图依赖图层内容、谓词状态与方向处理器,任何一项变了,旧图就可能给出错误路径。把失效判断留给调用方,会变成「忘了通知就悄悄走错」;每次重构图虽然多花一点时间,但结果始终对应当前状态。寻路是玩家操作触发的低频动作,这个成本通常可以接受。
换楼层时,除了 `useMapLayer(newLayer)`,还要确认方向处理器仍然匹配新图层。方向处理器决定邻域,不随楼层自动切换;如果新楼层使用不同的方向体系,必须重新 `useFaceHandler`。旧图层对象在切换后不应继续用于寻路,否则图会按旧坐标与旧宽度构建。
## 自定义谓词
换通行语义就是换谓词。实现 `IPassPredicate` 的两个方法,输入都是 `IPassCheckHandler`;需要保留默认规则时,在内部包一个默认实现,再叠加自己的判断。
下面这个类在默认规则之上禁止勇士走进指定图块数字的格子:
下面这个类在默认规则之上禁止进入指定图块数字的格子:
```ts
// src/data/hero/noTilePassPredicate.ts
import { IPassCheckHandler, IPassPredicate } from '@user/data-base';
import { IStateSystem } from '@user/data-system';
import { DefaultPassPredicateImpl } from './predicate';
/** 在默认规则之上,禁止进入指定图块数字的格子。 */
/** 在默认规则之上,禁止进入指定图块数字的格子 */
export class NoTilePassPredicate implements IPassPredicate {
// 默认谓词负责事件层、onlyEvents 图块与 eventPass 的判断
private readonly base: IPassPredicate;
constructor(
state: IStateSystem,
// 要禁止进入的图块数字,按自己的内容配置传入
private readonly blockedTile: number
) {
// 默认谓词负责事件层、onlyEvents 与 eventPass 的判断
this.base = new DefaultPassPredicateImpl(state);
}
@@ -220,32 +225,32 @@ export class NoTilePassPredicate implements IPassPredicate {
}
```
寻路侧只需要把求解器的谓词换掉:
寻路侧把求解器的谓词换掉:
```ts
// src/data/initialize.ts 的 awake 中
const LAVA_TILE = 30;
import { ICoreState } from '@user/data-state';
import { NoTilePassPredicate } from './noTilePassPredicate';
// 换掉寻路构图使用的谓词;之后构图与终端标记都按新规则计算
state.pathfinding.finder.usePassPredicate(
new NoTilePassPredicate(state, LAVA_TILE)
);
/** 让寻路构图使用新的通行规则 */
export function useLavaRule(state: ICoreState): void {
const predicate = new NoTilePassPredicate(state, 30);
// 之后构图与终端标记都按新规则计算
state.pathfinding.finder.usePassPredicate(predicate);
}
```
勇士侧要换的是顶层实现,而不是直接往移动器里塞谓词。`DefaultHeroMoveTopImpl` 在构造时已经建好自己的默认谓词;保留其它行为时,包一层顶层实现并转发,让 `predicate()` 返回你的谓词。完整写法见[移动 Mover](../i/mover.md#勇士的顶层实现)。如果这套规则就是本作唯一的规则,也可以直接修改默认实现,让勇士与寻路都拿到新语义。
勇士侧要换的是顶层实现,而不是直接往移动器里塞谓词。`DefaultHeroMoveTopImpl` 在构造时已经建好自己的默认谓词;保留其它行为时,包一层顶层实现并转发,让 `predicate()` 返回同一个谓词实例。完整写法见[移动 Mover](../i/mover.md#勇士的顶层实现)。只替换 `finder` 侧的谓词不会同步勇士,反之亦然;要让两侧一致,必须把同一套规则分别注入移动与构图。
方向处理器决定「哪些方向算相邻」,它的选择会同时影响移动与构图;四方向与八方向的差别见[FaceHandler 系列](../i/face-handler.md#用在哪里)。损失函数与瞬移策略不改变通行性,它们分别决定进入代价与是否逐步移动,见[自定义损失函数](../ii/pathfinding.md#自定义损失函数)与[自定义瞬移策略](../ii/pathfinding.md#自定义瞬移策略)。
方向处理器决定「哪些方向算相邻」,它的选择会同时影响移动与构图。损失函数与瞬移策略不改变通行性,它们分别决定进入代价与是否逐步移动。
## 边界与注意
- 两个实例不共享:勇士侧与寻路侧各有一个谓词对象,改一侧不会自动改另一侧;要一致就两边都换。
- 图层必须属于同一数据端:`useMapLayer` 会校验图层来源,不属于当前状态时告警并忽略这次绑定。
- 未注入方向处理器时构图失败:`build` 返回 `null`,`find` 得到 `InvalidInput`;初始化只注入谓词与移动器,图层与方向处理器要在进入楼层时补上。
- 起点越界会告警并返回失败:`build` 先检查起点是否在图层范围内,越界时不会产生图。
- 终端只能作终点:`shouldHit` 标记的格子不能作为路径中间点,只能作为目标格。
- 无路径是正常结果:目标不可达时返回 `NoPath`,不会移动,也不需要特殊处理。
- 目标在脚下不移动:起点与终点相同时直接返回 `TargetUnder`。
- 换楼层要重新绑定图层:绑定的是具体图层对象,换楼层后要换成新楼层的事件层。
- 瞬移会跳过途经格:瞬移分支只落到终点,途经格的进入、离开与撞击都不会发生;需要补触发时用钩子,或用回退策略退回逐步移动。
- **`canPass` 与 `shouldHit` 不能混用**:`shouldHit` 为真是「可以走但会撞」,不是「不能走」;把两者取反会让移动与寻路同时出错。
- **两个实例不共享**:默认装配下勇士与寻路各持有一个谓词;改一侧不会自动改另一侧,需要一致时两侧都换。
- **构图依赖三项注入**:图层、通行谓词与方向处理器缺任何一项,`build` 都会返回 `null`,`find` 得到 `InvalidInput`。
- **默认实现只读静态图块**:动态图块与其它占格对象不参与判断;需要它们挡路时写自定义谓词。
- **图层必须属于同一数据端**:寻路绑定其它实例的图层会告警并忽略;换楼层后必须重新绑定。
- **终端只能作终点**:`shouldHit` 的格子不能作为路径中间点;`NoPath` 是正常结果。
- **失败步也会走结算**:`CannotMove` 与 `Hit` 不改变坐标,但结算仍会在原格收集 `OnLeave` / `OnEnter`;不要用触发器次数判断是否真的移动。
返回[高级 III 导览](./index.md)。
+151 -74
View File
@@ -1,56 +1,171 @@
# 插件内部机制
插件系统对使用者只暴露三样东西:声明 `dataDeps`、在三个时机里做事、按 id 取回插件。本篇讲这三样背后的机制:插件表怎么变成确定顺序、状态怎么推进、依赖写错会怎样。
插件看起来只是「在三个时机里写代码」,但实际执行顺序不取决于它在数组里的位置。两个插件互不依赖时,列表顺序只决定平局顺序;一旦声明了 `dataDeps`,顺序就由依赖图决定。依赖写错时,系统也不会抛一个明确的异常,而是把插件及其下游从执行集合里移除。
怎么写一个插件见[插件系统](../i/plugin-system.md),本篇不重复插件类写法与注册步骤。
本篇只讲机制:插件表怎么变成确定顺序、状态怎么推进、三个阶段为什么不能互换、失败时作者会看到什么。怎么写一个插件见[插件系统](../i/plugin-system.md),本篇不重复插件类写法。
## 系统模型
插件机制由五段组成,顺序固定:
| 组成 | 负责什么 |
| ------ | -------------------------------------- |
| 插件表 | 按 id 保存插件与当前状态 |
| 依赖图 | 把 `dataDeps` 展开成「谁必须先于谁」 |
| 拓扑序 | 把依赖图压成一份固定执行顺序 |
| 状态机 | 记录每个插件走到哪个阶段 |
| 三阶段 | 按同一顺序执行装配、加载前与加载后回调 |
```text
插件列表(id → 插件)
└─ 依赖图(每个插件的 dataDeps)
└─ 拓扑序(含环与缺失依赖检测)
└─ 状态机(四个状态)
└─ 三阶段执行(awake → initialize → afterLoad)
```mermaid
flowchart TD
A[插件表<br/>id 与 dataDeps] --> B[依赖图<br/>缺失与环检测]
B --> C[拓扑序<br/>构造期只算一次]
C --> D[状态机<br/>每个阶段只处理前一状态]
D --> E[awake<br/>同步、不等待]
E --> F[initialize<br/>加载前、await]
F --> G[afterLoad<br/>加载后、await]
```
插件列表在主对象构造时建立。每个插件带一个 `id` 与一份 `dataDeps`;`dataDeps` 是它依赖的其它数据端插件 id。依赖图只由这两样东西构成,不读运行期状态。拓扑序在构造期算一次,之后三个阶段都按这同一个顺序执行。
插件表在 `CoreState` 构造时建立。依赖图只由插件的 `id` 与 `dataDeps` 构成,不读运行期状态;拓扑序也只算一次,三个阶段复用同一份结果。这样插件顺序在启动前就是确定的,运行期修改 `dataDeps` 不会改变已经算好的顺序。
| 组成 | 作用 |
| ------ | -------------------------------------- |
| 插件表 | 按 id 保存插件与它的当前状态 |
| 依赖图 | 把 `dataDeps` 展开成「谁必须在谁之前」 |
| 拓扑序 | 把依赖图压成一个固定执行顺序 |
| 状态机 | 记录每个插件走到哪一步 |
| 三阶段 | 依次执行装配期、加载前与加载后的回调 |
## 三个阶段
## 三个阶段怎么执行
三个阶段按同一顺序执行,差别在于时机与是否等待:
三个阶段共享同一份拓扑序,差别在时机与是否等待:
| 阶段 | 时机 | 是否等待 | 适合做什么 |
| ------------ | ------------ | ------------ | ------------------------------------ |
| `awake` | 主对象构造期 | 否,同步返回 | 创建插件自己的对象、注册可存档内容 |
| `initialize` | 加载开始前 | 是 | 加载前的异步准备、追加加载配置 |
| `initialize` | 加载开始前 | 是 | 异步准备、追加加载配置 |
| `afterLoad` | 加载完成后 | 是 | 读取加载内容、创建依赖内容的初始状态 |
每个阶段收到同一份依赖参数:按 `dataDeps` 顺序排列的插件包装对象,形如 `{ status, plugin }`。参数类型是只读的插件包装数组,读者不应修改它;要调用依赖的能力,取 `.plugin` 并断言成对方的对外接口。
`awake` 的返回值不会被等待,写 `async awake` 也不会阻塞后面的插件;需要等待的事情放到 `initialize`。`initialize` 与 `afterLoad` 都会 `await`,只要有一个不兑现,后面的插件与加载就不会继续。
`awake` 的返回值不会被等待,写 `async awake` 也不会阻塞后续插件;需要等待的事情放到 `initialize`。`initialize` 与 `afterLoad` 都会 `await`,只要有一个不兑现,后面的插件与加载就不会继续。
三个阶段都保证:被依赖的插件先完成同一阶段。插件 B 的 `awake` 开始时,A 的 `awake` 一定已经完成;不构成依赖关系的插件之间,不要假定谁先谁后。
## 取回一个插件
三个阶段收到的依赖是解析后的包装数组,形如 `{ status, plugin }`,顺序与 `dataDeps` 一致。需要对方能力时取 `.plugin`,不要把它当成第二份依赖声明。依赖缺失或成环的插件不会进入执行顺序,所以拿到依赖后可以直接使用,不需要在回调里再判空。
按 id 取回用 `state.getDataPlugin<T>(id)`,返回只读的插件包装对象或 `null`。取回结果不能直接调用,先判空、再检查状态:
::: warning
`MainDataPlugin` 不是隐式前置,它只是插件列表里的普通插件。标准入口把它放在最前,不等于插件系统承诺它先执行;用到它注册的存档 id、默认装配或初始化结果时,必须在 `dataDeps` 里写 `'main-data-plugin'`。
:::
## 三个阶段为什么不能互换
三个阶段的差别不只是「先后」,而是「这时能看到什么」:
- `awake` 发生在四层构造完成之后、加载开始之前。它能创建对象、注册可存档内容、给已有系统注入默认实现,但读不到核心配置与地图内容;把读取配置的逻辑放进来,只会得到 `null`。
- `initialize` 在加载开始前执行,适合做异步准备与追加加载配置。它可以等待网络或文件,但不能读取尚未加载的内容;这里不兑现,加载不会开始。
- `afterLoad` 在加载完成后执行,核心配置、地图与内容都可读。它适合创建依赖内容的初始状态;但它也在启动路径上,放太多重活会推迟游戏开始。
如果把 `afterLoad` 的逻辑提前到 `awake`,读取配置会失败;如果把 `awake` 的同步装配挪到 `initialize`,依赖它的插件在 `awake` 阶段就拿不到对象。三个时机各自对应一段确定的时间线,不能靠「反正都会执行」来互换。
`awake` 不等待返回值,是这条时间线上最容易踩的坑:写成 `async awake` 不会让后续插件等它。需要等待的事情放进 `initialize`,需要读取加载内容的事情放进 `afterLoad`。
## 一个最小依赖声明
下面这个插件只做一件事:声明自己依赖主插件,然后在 `awake` 里创建自己的管理器。它展示了依赖声明与阶段方法的最小形状。
```ts
import { ICoreState, IDataPluginDefine, PluginStatus } from '@user/data-state';
import { ICoreState, IDataPluginDefine } from '@user/data-state';
/** 插件自己的管理器;具体逻辑不属于本页 */
class QuestManager {
constructor(readonly state: ICoreState) {}
}
/** 对外接口,依赖它的插件按这个类型取用 */
export interface IQuestPlugin extends IDataPluginDefine {
readonly manager: QuestManager;
}
/** 任务插件:显式依赖主插件,避免用到尚未注册的默认装配 */
export class QuestPlugin implements IQuestPlugin {
readonly id: string = 'quest-plugin';
// 主插件不是隐式前置,用到它的默认装配就必须写在这里
readonly dataDeps: readonly string[] = ['main-data-plugin'];
manager!: QuestManager;
awake(state: ICoreState): void {
// 此时四层与主插件的 awake 都已完成,可以安全创建自己的对象
this.manager = new QuestManager(state);
}
async initialize(): Promise<void> {}
async afterLoad(): Promise<void> {}
}
```
这个类没有构造器,也没有把 `state` 存成公共字段;需要主对象时在 `awake` 里接收。`dataDeps` 只写 id,不写插件对象;三个阶段收到的依赖参数是引擎解析后的包装数组,不是声明时写的东西。
## 依赖参数怎么用
三个阶段收到的依赖数组有三个约束:
- **顺序与 `dataDeps` 一致**:第 `i` 个元素对应 `dataDeps[i]`,不要按插件在列表里的位置索引。
- **元素是包装对象**:形如 `{ status, plugin }`,取能力要读 `.plugin`;`status` 会随阶段推进更新。
- **不要修改数组或往里塞对象**:它只是本次回调的解析结果,不是第二份依赖声明;需要新依赖时改 `dataDeps`,不要在下游临时拼。
启动阶段优先用依赖参数,让引擎保证「同一阶段内依赖先完成」;运行期才用 `getDataPlugin` 按 id 取回。把依赖参数缓存到字段里再长期使用,等价于持有一个会变化的状态包装对象;需要长期引用时缓存 `.plugin`,并在使用前确认插件已经到达你需要的阶段。
依赖参数不需要判空。被阻断的插件不会进入执行顺序,回调根本不会执行;如果回调执行了,说明依赖已经解析成功。对依赖参数写 `?.` 只会掩盖 `dataDeps` 的声明错误,让本应被阻断的插件带着空依赖继续跑。启动阶段唯一需要检查的是「依赖是否已经到达你需要的阶段」,不是「依赖是否存在」。
## 阶段推进的可见性
三个阶段的推进是「整段推进」,不是按依赖链各自跑到底:所有可执行插件的 `awake` 先完成,才开始 `initialize`;所有 `initialize` 兑现后,才开始加载;加载完成后才进入 `afterLoad`。同一阶段内按拓扑序执行,所以依赖先完成;不同依赖链之间可能有交错,但不会出现「A 的 initialize 在 B 的 awake 之前」。
`awake` 不等待返回值,意味着 `async awake` 里第一个 `await` 之后的代码可能与后续插件的 `awake` 或 `initialize` 并发。不要把需要顺序的异步逻辑放在 `awake`;需要等待的事情放 `initialize`,需要读加载内容的事情放 `afterLoad`。
## 状态机
每个插件在注册表里带一个状态,四个状态与推进条件如下:
| 状态 | 含义 | 进入条件 |
| ------------- | ------------------- | ----------------- |
| `NotAwakened` | 还没执行装配期回调 | 注册时的初始状态 |
| `Awakened` | 已执行 `awake` | `awake` 正常返回 |
| `Initialized` | 已执行 `initialize` | `initialize` 兑现 |
| `Loaded` | 三阶段都完成 | `afterLoad` 兑现 |
每个阶段只处理处于前一状态的插件:`awake` 只处理 `NotAwakened`,`initialize` 只处理 `Awakened`,`afterLoad` 只处理 `Initialized`。状态不符会跳过并告警 80。状态只前进不后退,同一个实例不会重新走一遍阶段,也没有运行期重置。
被阻断的插件不会进入执行顺序,因此永远停在 `NotAwakened`。`getDataPlugin` 只返回包装对象与状态,不提供「为什么被阻断」的查询;看到 `NotAwakened` 时,无法仅凭它区分「还没开始」与「被依赖阻断」。排查要回到控制台里的 76 / 77 提示,再顺着依赖链找。
状态是每个插件各自的,不是全局阶段计数器。依赖数组里的 `{ status, plugin }` 是同一个包装对象,它的 `status` 会随阶段推进更新:你在自己 `awake` 里看到的依赖状态是 `Awakened`,在 `afterLoad` 里看到的通常已经是 `Loaded`。不要缓存依赖包装对象再假设状态会停在某个值;需要判断时重新读 `status`。
## 与 CoreState 的分工
`CoreState` 负责四层对象图与构造顺序,插件系统负责在这张图上继续装配。插件不往 `CoreState` 上加新字段,也不修改四层构造;它把自己的对象挂在插件实例上,需要跨插件访问时通过 `getDataPlugin` 或 `dataDeps` 取。
这条分工决定了两件事:
- 插件可以在 `awake` 里访问任意一层,因为四层已经全部构造完成;但它不能假设某个「默认装配」已经存在,除非显式依赖提供它的插件。
- 插件的对象不进入 `CoreState` 的静态类型;依赖方要拿到能力,必须通过插件接口断言,而不是 `state.somePluginField`。这也让插件可以被移除或替换,而不改变数据端主对象的形状。
`CoreState` 的构造器建立插件表与拓扑序,并在最后同步执行 `awake`;`initialize` 与 `afterLoad` 由加载流程驱动。插件系统因此可以独立于具体插件存在,测试时也可以只构造插件表验证排序。
## 失败时作者看到什么
| 情况 | 作者要避免 | 控制台看到 | 结果 |
| ------------ | ------------------------------------------ | ----------------------------- | ------------------------------------------ |
| 依赖成环 | 两个插件互相声明依赖,或链路上绕回自己 | 76,列出环上的 id | 环上插件与下游都不执行,停在 `NotAwakened` |
| 依赖缺失 | id 拼错、对方改名、忘记把插件放进列表 | 77,插件 id + 缺失的依赖 id | 该插件与下游都不执行 |
| 重复 id | 两个插件使用同一个 `id` | 78 | 只保留第一个,后一个被跳过 |
| 阶段状态不符 | 假设某个阶段可以重跑、手动推进或从中间插入 | 80,插件 id 与期望 / 实际状态 | 该阶段跳过该插件,后续阶段也不会补跑 |
这些失败不会抛出异常,也不会自动重试。设计上,一个插件的依赖写错不应该让整个数据端无法构造;引擎选择把出错的插件与下游排除,让其余插件继续启动。代价是错误被推迟:作者看到的常见现象不是「插件报错」,而是「某个对象不存在」或「某个 id 没有注册」。被阻断的插件不会执行任何阶段,依赖它的下游也不会执行;所以错误往往在下游第一次访问时才发现,而不是在出错的插件本身。
依赖关系在启动前固定,运行期修改 `dataDeps` 不会改变已经算好的顺序;也没有接口在运行时追加插件。新增插件要改列表并重新创建实例,不要指望在 `load()` 之后再插一个。
`getDataPlugin` 返回的 `NotAwakened` 不是「还没轮到」的同义词:它既可能是执行顺序还没走到,也可能是插件被 76 / 77 阻断。插件系统没有提供「阻断原因」的查询接口,所以看到这个状态时,先查控制台里的环或缺失提示,再检查依赖链上的每个 id,而不是在调用点反复判空。
## 取回一个插件
按 id 取回用 `state.getDataPlugin<T>(id)`,返回只读包装或 `null`。启动阶段优先用 `dataDeps` 让引擎保证顺序;运行期才按 id 取回,并且必须判空、检查状态。
```ts
import { ICoreState, PluginStatus } from '@user/data-state';
import { IMyPlugin } from './types';
// 启动完成后取回插件:判空与状态检查都不能省
/** 启动完成后取回插件:判空与状态检查都不能省 */
export function useMyPlugin(state: ICoreState): void {
const holder = state.getDataPlugin<IMyPlugin>('my-plugin');
if (!holder) {
@@ -65,59 +180,21 @@ export function useMyPlugin(state: ICoreState): void {
}
```
`getDataPlugin` 只查注册表,不保证插件已经加载完成。启动阶段要用某个插件时,优先在 `dataDeps` 里声明依赖,让顺序由引擎保证;按 id 取回更适合可选的、运行中才发生的调用。
`getDataPlugin` 只查注册表,不保证插件已经加载完成。需要依赖某个插件的能力时,优先在 `dataDeps` 里声明;按 id 取回更适合可选的、运行中才发生的调用。
不要在构造完成前取插件;在 `awake` 里取一个没有声明依赖的插件,也可能拿到尚未装配的对象。取回之前先确认加载流程走到哪一步,见 [CoreState 组装与初始化](./core-state.md#加载管线)。
如果取回的目的是启动装配,先考虑声明依赖;按 id 取回会把顺序责任转移到调用方,而调用方通常不知道目标插件走到哪一阶段。
## 失败会怎样
## 依赖排序只做一次
依赖写错或 id 重复时,插件不会报「找不到类」,而是静默不执行,只在控制台留下提示。会看到的现象有这几类:
- **依赖成环**:环上的插件全部被排除,依赖它们的下游也一起被排除;控制台会给出环上的 id。
- **缺失依赖**:`dataDeps` 里写了没有注册的 id,该插件被排除,下游同样被排除;控制台按「插件 id + 缺失的依赖 id」提示。
- **重复 id**:构造期只保留第一个,后一个被跳过;控制台告警一次。
- **状态不符**:某个阶段发现插件不在预期的前置状态,会跳过它并告警;正常流程不该出现,出现通常说明流程被重复驱动。
- **启动后仍未完成**:`load()` 结束时检查所有插件,状态不是 `Loaded` 的逐个告警。
被阻断的插件不会执行任何阶段,因此它的对象、存档注册与加载后初始化都不存在。排查时先看控制台里的插件 id,再顺着 `dataDeps` 往上游找。
## 内部实现:依赖排序
此章节讲解一下插件依赖排序的内部实现。
排序分四步,全部在构造期完成:
1. 查缺失依赖。遍历插件表,若某个 `dataDeps` 里的 id 不在表里,就把该插件与它缺失的 id 记下来。
2. 查环。从每个插件出发,沿依赖边搜索;如果能回到插件自身,就把它标记为环上插件。
3. 传播阻断。先把环上插件与缺失依赖的插件加入阻断集合,再反复扫描:只要某个插件的任一依赖被阻断,它也被阻断。阻断会沿下游扩散。
4. 输出拓扑序。反复扫描未被阻断的插件,每轮输出「所有依赖都已输出」的插件;直到没有新插件可输出为止。
结果分成三份:可执行顺序、环上插件集合、缺失依赖表。构造期只算这一次,之后三个阶段都复用同一份结果。`orderPlugins` 是内部函数,读者不需要自己调用;理解它的目的只是知道顺序为什么固定、依赖写错为什么会连带下游。
## 状态机
每个插件在注册表里带一个状态,四个状态与推进条件如下:
| 状态 | 含义 | 进入条件 |
| ------------- | ------------------- | ----------------- |
| `NotAwakened` | 还没执行装配期回调 | 注册时的初始状态 |
| `Awakened` | 已执行 `awake` | `awake` 正常返回 |
| `Initialized` | 已执行 `initialize` | `initialize` 兑现 |
| `Loaded` | 三阶段都完成 | `afterLoad` 兑现 |
每个阶段只处理处于前一状态的插件:`awake` 只处理 `NotAwakened`,`initialize` 只处理 `Awakened`,`afterLoad` 只处理 `Initialized`。状态不对就跳过并告警。
被阻断的插件不会进入执行顺序,因此永远停在 `NotAwakened`;`load()` 结束时的检查会把它点出来。状态只前进不后退:同一个实例不会重新走一遍阶段,也不支持运行期重置。
排序在构造期完成一次,之后三个阶段都复用同一份结果。作者不需要理解排序过程,只需要在 `dataDeps` 里声明依赖:被依赖的插件会先完成同一阶段。成环或缺失依赖会阻断该插件及其下游,它们不会进入执行顺序。运行期不能追加插件,也不能通过修改 `dataDeps` 改变已经算好的顺序;需要不同的顺序,只能改列表并重新创建数据端。
## 边界与注意
- **依赖在启动前固定**:构造期把 `dataDeps` 抄进依赖表,之后修改数组不会改变顺序。
- **不支持运行期追加插件**:注册表只在构造期建立,没有追加接口;新增插件要改插件列表并重新创建实例。
- **不支持运行期追加插件**:注册表只在构造期建立,没有追加接口;新增插件要改列表并重新创建实例。
- **环或缺失会阻断下游**:不只是出错的插件本身,依赖它的插件也不会执行任何阶段。
- **重复 id 只保留第一个**:构造期告警并跳过后一个,插件列表里不会出现两个同 id 的实例。
- **状态不符会被跳过**:每个阶段只处理处于前一状态的插件,状态不对会告警;不要重复驱动加载流程来「补跑」。
- **数据端依赖与渲染端依赖是两套**:`dataDeps` 只写数据端插件 id,渲染端另有 `clientDeps`;两者不要混用。
- **取回结果要判空并检查状态**:`getDataPlugin` 可能返回 `null`,也可能拿到尚未到达目标状态的插件。
- **`orderPlugins` 不供读者调用**:它是构造期的内部机制;要控制顺序,声明依赖即可。
- **重复 id 只保留第一个**:构造期告警并跳过后一个,注册表里不会出现两个同 id 实例。
- **依赖主插件要显式声明**:用到 `main-data-plugin` 注册的默认装配、存档 id 或初始化结果时,在 `dataDeps` 里写 `'main-data-plugin'`。
- **`getDataPlugin` 要判空并检查状态**:它只查注册表,不保证插件已进入你需要的阶段;数据端与渲染端的 `dataDeps` / `clientDeps` 也是两套,不要混用。
返回[高级 III 导览](./index.md)。
+211 -173
View File
@@ -1,42 +1,155 @@
# 录像系统底层
录像要解决的问题很具体:把玩家的一段操作序列记下来,换一台机器、只跑数据端,也能重放出同一局。要做到这一点,录像不能只存「发生了什么」,还要让读取方按同样的顺序、同样的参数重新执行。
录像不是最终状态的快照,而是一串按顺序重放的输入。录制端记下玩家做了什么,播放端把同样的动作重新交给游戏逻辑,让位置、属性、地图改动与事件结果重新算出来。只要录制的是「入口动作」而不是「派生结果」,同一份录像就能换机器、只跑数据端重放出同一局。
中级教程的[录像指令](../../intermediate/replay-commands.md)讲过用途与核心原则,高级 I 的[录像一致性](../i/replay-consistency.md)讲过哪些方法该记。本篇讲这套机制底下的东西:录像存成什么形状、录制与播放怎么配合、存档里放了什么。新增一条自定义指令不在本篇,见高级 II 的[自定义录像指令](../ii/custom-replay-command.md)。
本篇讲录像的存储布局、录制入口、主动与被动指令、播放沙箱与读取流。哪些方法必须进入录像见[录像一致性](../i/replay-consistency.md);怎么新增一条自定义指令见[自定义录像指令](../ii/custom-replay-command.md)。
## 系统模型
录像系统由三部分组成:
| 对象 | 负责什么 |
| ------------------- | ------------------------------------------------------------ |
| `ReplaySystem` | 对外入口:注册指令、记录一步、创建沙箱、保存与读回录像缓冲区 |
| `ReplayArray` | 存储本体:指令区、参数区、索引区,以及顺序读取流 |
| `IReplayCommand` | 单条指令的执行对象,分主动与被动,可选一个连续步骤的收尾方法 |
| `ReplaySandbox` | 播放器:按读取流推进,处理主动与被动步,提供播放控制 |
| `IReplayReadStream` | 只读视图:保存当前步号、总步数与是否过期,按指针顺序读取 |
| 对象 | 职责 |
| ---------------- | ------------------------------------------------------------ |
| `ReplaySystem` | 对外入口:注册指令、记录一步、创建沙箱、存档与读档 |
| `ReplayArray` | 存储本体:指令区、参数区、索引区,以及顺序读取流 |
| `ReplaySandbox` | 播放器:按读取流推进,处理主动与被动步,提供播放控制 |
| `IReplayCommand` | 单条指令的执行对象,分主动与被动,可选一个连续步骤的收尾方法 |
`ReplayArray` 才是录像本身;`ReplaySystem` 持有它和一张「指令码 → 指令对象」的注册表。注册表只负责把指令码解释成可执行对象,所以录像里只存指令码与参数,不存指令对象。同一份录像可以反复播放,也可以只录制不播放。
`ReplaySystem` 持有 `ReplayArray`、一张「指令码 → 指令对象」的注册表,以及一个可选的当前沙箱。数组才是录像本身;注册表只负责把指令码解释成可执行对象;沙箱只在播放时创建,所以同一份录像可以反复播放,也可以只录制不播放。
录制与播放走的是同一份数据:录制时 `record(code, ...params)` 把一步追加到数组末尾;播放时沙箱从数组创建读取流,按步取出、查表、执行。存档只保存数组本身与长度、宽度,既不含指令对象,也不含沙箱状态。
```text
record(code, ...params)
└─ ReplayArray
指令区:追加一条指令
参数区:追加载荷
索引区:记录参数起点
createReplaySandbox(...)
└─ ReplaySandbox
读取流按步取出 → 查指令注册表 → 主动执行 / 被动交给外部
saveState()
└─ { length, commandWidth, commandArray, paramArray }
```mermaid
flowchart TD
R["record(code, ...params)"] --> A[ReplayArray.add<br/>标准化参数]
A --> C[指令区<br/>参数个数 + 指令码]
A --> P[参数区<br/>类型码 + 载荷]
A --> I[索引区<br/>每步参数起点 u32]
I --> S[createReadStream<br/>命令游标 + 参数游标]
C --> S
P --> S
S --> D[decodeCommand / decodeParamList]
D --> X[主动指令 execute]
D --> V[被动指令 getPassive]
```
## 录制一步
## 一条录像步是什么
`record(code, ...params)` 只做一件事:把一步追加到录像末尾。`code` 是 `ReplayCode` 里的指令码或你自己注册的码;`...params` 是这一步带的参数,取值只能是 `number`、`string`、`boolean`、`bigint`。
一步由 `code` 与 `params` 组成。`code` 是 `ReplayCode` 里的指令码,或你自己注册的码;`params` 是这一步带的原始参数。参数只支持 `number`、`string`、`boolean` 与 `bigint` 四种原始类型。对象、数组、函数与 `undefined` 无法编码:写入时告警并丢弃该参数,不保留占位,后面的参数会前移。
因此录制入口必须先决定这一步到底带哪些参数,不要把「可能为空」的值混进参数列表。参数类型码是录像格式的一部分,遵循只增不改:新语义必须分配新码,不能复用旧含义。
录像要跨机器、跨会话读取,所以参数必须能被二进制编码;指令对象是运行期行为,不能进录像。这两条决定了 `ReplayArray` 只存原始值,`ReplaySystem` 单独持有注册表。
## IReplayArray:三块区域与两个视图
`ReplayArray` 实现 `IReplayArray`,是录像的存储本体。它对外同时提供「按步号随机读」与「从头顺序读」两种视图,但底层只有三块连续缓冲区:
| 区域 | 内容 | 定位方式 |
| ------ | ---------------------------------------------------------------- | --------------------------------- |
| 指令区 | 每步固定长度:1 字节参数个数,后接 1 或 2 字节指令码 | 第 `i` 步在 `i * commandSize` |
| 参数区 | 变长:每个参数由类型码与载荷组成,按写入顺序紧凑排列 | 每步的起始字节由索引区给出 |
| 索引区 | 每步一个 32 位无符号整数,记录该步参数在参数区的起始字节 | 第 `i` 项的值为 `indexArray[i]` |
| 读取流 | 不复制数据,只保存当前步号、命令游标、参数游标、总步数与过期标记 | 由 `createReadStream(startIndex)` |
| 随机读 | `get(index)` 直接按索引区定位参数,再解码该步 | 适合编辑器,不适合播放循环 |
指令区的步长由 `commandWidth` 决定:8 位宽时每步 2 字节,16 位宽时每步 3 字节。参数区没有固定步长,所以必须依赖索引区;没有索引区,读取第 `n` 步就要从第 0 步一路解码过来。索引区在整体替换缓冲区或 `rebuildIndexArray()` 时重建;正常写入时增量维护,正常播放不修改它。
写入路径有 `add`、`insert`、`delete`、`set` 四个入口。`add` 追加到末尾;`insert` 允许插到 `[0, length]`,插到 `length` 等同于追加;`delete` 与 `set` 的合法区间是 `[0, length)`。越界不会修改任何缓冲区,只告警 179。四个入口都会先标准化参数,再计算本步参数的总字节数。
索引区的维护方式决定了编辑成本:`insert` 要把插入点之后的指令、参数与索引整体后移;`delete` 要整体前移;`set` 在参数长度变化时按差值平移后续参数,并调整后续索引。成本与后续步数成正比,所以这些方法只适合录像编辑器一类低频操作,不适合放进录制或播放循环。
参数区与指令区都会按需扩容。扩容时新建更大的 `ArrayBuffer`,把旧内容复制过去,再把后续写入指向新缓冲区。指令区与参数区分别扩容,所以录像体积不会因为某一步参数特别长而要求所有步都留出同样空间。
## 参数区:类型码与载荷
参数区的每个参数都以 1 字节类型码开头,随后是类型相关的载荷。类型码是录像格式的一部分,只增不改;10 到 31 目前留空。
| 类型码 | 类型 | 载荷 | 总字节 |
| ------ | ------------- | -------------------------------------------- | ------ |
| 0 | `boolean` | 1 字节,0 或 1 | 2 |
| 1 | `int8` | 1 字节有符号整数 | 2 |
| 2 | `int16` | 2 字节有符号整数 | 3 |
| 3 | `int32` | 4 字节有符号整数 | 5 |
| 4 | 非负 `int64` | 幅值拆成低 32 位与高 32 位 | 9 |
| 5 | 负 `int64` | 幅值拆成低 32 位与高 32 位,符号由类型码表示 | 9 |
| 6 | `float64` | 8 字节 IEEE 754 浮点数 | 9 |
| 7 | 非负 `bigint` | 1 字节幅值长度 `n`,后接 `n` 字节小端幅值 | n + 2 |
| 8 | 负 `bigint` | 1 字节幅值长度 `n`,后接 `n` 字节小端幅值 | n + 2 |
| 9 | `string` | 4 字节长度前缀,后接 UTF-8 字节 | n + 5 |
| 32–255 | 短字符串 | 类型码减 32 就是字节长度,后接 UTF-8 字节 | n + 1 |
`number` 会按值域选择最窄的整数类型:`[-128, 127]` 用 `int8`,`[-32768, 32767]` 用 `int16`,`[-2147483648, 2147483647]` 用 `int32`;超出这个范围但仍是整数的值用非负 / 负 `int64`;非整数用 `float64`。这解释了为什么同一步里的 `100` 只占 2 字节,而 `100.5` 要占 9 字节。
字符串有两种编码。UTF-8 字节长度不超过 223 时使用短字符串码:类型码直接等于 `长度 + 32`,所以空字符串是 32,`'abc'` 是 35,后面直接跟 3 个字节。更长的字符串退回类型码 9,带 4 字节长度前缀。短字符串没有长度前缀,因此读取时只能靠类型码反推长度。
`bigint` 与 `int64` 都用「幅值 + 符号码」而不是二进制补码。这样解码时不需要处理符号扩展,代价是负数的载荷仍然是幅值。`bigint` 的幅值长度只写 1 字节,因此能表示的幅值范围由这个长度字段决定;超出可表示范围时该参数会被丢弃并告警,不会截断成另一个值。
## 读取流:指针怎么走
`createReadStream(startIndex)` 不复制录像,只保存两个游标:
- **命令游标**:`startIndex * commandSize`,指向该步在指令区的起点。
- **参数游标**:`indexArray[startIndex]`,指向该步在参数区的起点。
`read()` 先解码命令,得到 `paramCount` 与 `command`;再按 `paramCount` 从参数游标开始逐个解码参数,每解码一个就把参数游标推进该参数的 `byteLength`;最后把命令游标推进一个 `commandSize`,把步号加一。返回的 `IReplayStepHandler` 里同时有 `command`、`params` 与 `index`,`index` 是刚读出的那一步的步号。
读取流不会在每次 `read()` 时重新查索引区,所以顺序播放是线性的。反过来,`get(index)` 每次都从索引区取起点,再重新解码该步参数,适合随机定位,不适合每一步都调用。
索引区是派生数据,不是录像内容。它可以从指令区与参数区重新算出来:遍历每一步,解码参数个数与参数长度,累加得到每步的起始字节。整体替换缓冲区后,索引区必须重建;正常写入时则顺手维护。重建是线性成本,只适合加载、导入或修复,不适合播放循环。
## 读取流的生命周期
读取流从 `createReadStream(startIndex)` 开始,登记到数组的读取流集合里;每次 `read()` 推进游标;写操作把它标记为过期并移出集合;`destroy()` 提前移出集合,适合中途放弃播放。
过期之后不要继续依赖读出的数据:偏移可能已经指向被搬移或覆盖的位置。沙箱会在开始播放与每轮循环前检查过期并停止;直接使用读取流的代码应当同样检查 `expired`,或者干脆保证读写不并发。
任何写操作都会让已有读取流过期:`add`、`insert`、`delete`、`set`、`setCommandWidth` 与整体替换缓冲区都会遍历当前登记的所有读取流,把它们标记为 `expired` 并清出登记表。过期后直接调用 `read()` 会告警 155,但读流本身仍会按旧游标继续返回数据;沙箱会在开始播放或每轮循环前检查 `expired`,一旦为真就告警 156 并停止。**不要边写边读**:过期标记只是让调用方有机会停下,不会替你回滚已经改变的偏移。
## 一次写入的字节布局
以 8 位指令宽度、`record(42, 100)` 为例。`record` 把参数整理成数组后调用 `array.add(42, [100])`;`100` 落在 `int8` 范围内,所以只占 2 字节。
| 位置 | 字节 | 含义 |
| ------------ | ------------- | -------------------------- |
| 指令区 `[0]` | `01` | 参数个数 = 1 |
| 指令区 `[1]` | `2A` | 指令码 = 42 |
| 参数区 `[0]` | `01` | 类型码 = 1(`int8`) |
| 参数区 `[1]` | `64` | 载荷 = 100 |
| 索引区 `[0]` | `00 00 00 00` | 第 0 步参数起点 = 0(u32) |
读取流从第 0 步开始:命令游标为 0,参数游标为 0;`read()` 读出参数个数 1、指令码 42、参数 100,然后把命令游标推到 2、参数游标推到 2、步号变成 1。下一次 `read()` 发现步号已经等于总步数,返回 `null`。
如果改用 16 位宽度,指令区变成 3 字节一组:`01 00 2A`。参数区与索引区不变。宽度只影响指令码的存储,不影响参数编码;这也是为什么录像可以只切换宽度而不重写参数区。
如果指令码本身超过 255,却仍在 8 位宽度下写入,`setUint8` 只会保留低 8 位,录像里读回的就不是原来的码。缩窄宽度时会对每个超过 255 的码告警 154,但写入时不会逐个检查;所以指令码可能超过 255 时,应在录制开始前就切到 16 位。
## 录制发生在哪里
`record(code, ...params)` 把一步追加到录像末尾,并触发记录钩子。普通业务入口应当先 `record`,再执行真正的逻辑;回放时由对应指令重放同一段逻辑。
```ts
import { ReplayCode } from '@user/data-common';
import { ICoreState } from '@user/data-state';
/** 玩家入口:一步操作先写进录像,再交给游戏逻辑执行 */
export class PlayerInputRecorder {
constructor(private readonly state: ICoreState) {}
/** 玩家使用一个道具 */
useItem(itemId: number): void {
// 道具 id 是这一步的唯一参数
this.state.replaySystem.record(ReplayCode.UseItem, itemId);
// 这里再执行道具效果;回放时读出的参数与录制时一致
this.state.hero.items.useItem(itemId);
}
}
```
玩家移动通常不经过业务代码。`HeroMover` 在每一步开始时、判断通行之前,直接把方向写入录像;瞬移写瞬移指令与目标坐标。因此即使这一步最终不能走,回放也会重放同一次尝试,得到同样的状态、同样的告警与同样的事件。把记录放在通行判断之后,失败尝试就会消失,回放与录制时的状态序列不再一致。
事件脚本驱动的移动会临时关闭录制,避免把脚本位移记成玩家输入。这不是「可选优化」:脚本移动是事件的派生结果,回放时事件会重新执行,再记一遍就会多出一步。
`@shouldReplay` 只做录像安全检查,不会自动写录像;哪些方法需要这个标记,见[录像一致性](../i/replay-consistency.md)。
## 主动与被动指令
指令对象分两类:
@@ -45,179 +158,104 @@ saveState()
| `ReplayCommandType.Active` | 自己执行这一步,并返回执行结果 |
| `ReplayCommandType.Passive` | 不执行自己的逻辑,只把参数交出去;外部执行完再回报状态 |
两类指令的 `execute` 都返回 `ReplayCommandResult`;主动步用它决定播放是否继续,被动步则由 `next(status)` 的状态决定。三种取值是:
主动步的 `execute` 返回 `ReplayCommandResult`,沙箱据此决定播放是否继续:`Success` 继续,`Failed` 中止,`Ignored` 输出信息后继续。
- `Success`:这一步成功,继续播放。
- `Failed`:这一步失败,播放中止。
- `Ignored`:失败但可以忽略,控制台输出信息后继续。
被动步由沙箱交给外部,外部通过 `getPassive()` 拿到参数,完成自己的操作后调用 `next(status)`;只有 `next` 之后读取流才推进,重复调用 `getPassive()` 会得到同一步。沙箱在执行主动步之前先读下一步,因为被动步总是紧跟在主动步之后,主动步内部需要靠这一步判断下一步是不是被动步。主动步执行完,若下一步是被动步且没有被取走,沙箱会告警 194 并跳过它。
`finalize` 是连续步骤的收尾:**指令切换或录像结束时**,上一条主动指令会收到一次 `finalize`,同一指令连续多步之间不会逐步触发。典型用途是连续多步操作后统一提交:每步只更新暂存状态,等到收尾时再一次性结算。
被动步的指令对象仍然要注册,因为沙箱要用它判断类型;但它的 `execute` 不会被调用。它的 `params` 是给外部消费者的,`command` 用来确认「这确实是我预期的那条被动指令」。
玩家入口通常把「记录」与「执行」放在一起:
## finalize 与连续移动
连续多步的同一条主动指令可以在切换指令或录像结束时收到一次 `finalize`,用于统一收尾。单步指令通常不需要它。沙箱在两种情况调用上一条主动指令的 `finalize`:读到的下一步是另一条主动指令,或读取流已经结束。
内置的 `ReplayMove` 正是靠 `finalize` 合并连续移动:每一步的 `execute` 只调用 `mover.step(dir)` 入队,不立即启动;当下一步不再是同一条移动指令,或读取流结束时,`finalize` 才调用一次 `mover.start()` 并等待整段移动结束。这样连续移动只启动一次移动器,动画连续,回放不会逐步打断。
```ts
import { ReplayCode } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import {
FaceDirection,
ReplayCommandResult,
ReplayCommandType
} from '@user/data-common';
import { IStateSystem } from '@user/data-system';
import { BaseReplayCommand } from './commands';
/** 玩家输入入口:一步操作先写进录像,再交给游戏逻辑执行 */
export class PlayerInputRecorder {
constructor(private readonly state: ICoreState) {}
/**
* 玩家方向移动。每步只入队,连续移动由 finalize 统一启动。
* 指令对象在注册时创建一次,回放期间复用。
*/
export class ReplayMove extends BaseReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Active;
/** 玩家向上走一步 */
stepUp(): void {
// 向上一步没有额外参数,方向由指令码本身表达
this.state.replaySystem.record(ReplayCode.Up);
// 这里再调用移动器执行真正的移动;回放时由对应指令重放同一段逻辑
protected readonly name: string = 'move';
protected readonly paramTypes: readonly string[] = [];
constructor(
state: IStateSystem,
private readonly direction: FaceDirection
) {
super(state);
}
/** 玩家使用一个道具 */
useItem(itemId: number): void {
// 道具 id 是这一步的参数,会按类型写进参数区
this.state.replaySystem.record(ReplayCode.UseItem, itemId);
// 这里再执行道具效果;回放时读出的参数与录制时一致
async wrappedExecute(): Promise<ReplayCommandResult> {
const mover = this.state.hero.location.mover;
if (mover.moving) {
// 上一条移动还没结束,继续入队会打乱本次回放的落点
return ReplayCommandResult.Failed;
}
mover.step(this.direction);
return ReplayCommandResult.Success;
}
async finalize(): Promise<ReplayCommandResult> {
const controller = this.state.hero.location.mover.start();
if (!controller) {
return ReplayCommandResult.Failed;
}
await controller.onEnd;
return ReplayCommandResult.Success;
}
}
```
玩家入口只负责记录与执行,回放入口只负责执行,这样同一次操作不会在录制时被记两遍。哪些路径必须记,见[录像一致性](../i/replay-consistency.md#什么该记)。
`BaseReplayCommand` 已经按 `paramTypes` 校验参数个数与类型;`wrappedExecute` 只处理业务。指令对象在注册时创建一次,之后每次回放复用,所以不要在 `execute` 里累积跨回放的状态;需要跨步数据时,在 `finalize` 末尾清理。
## 沙箱播放
## 播放沙箱
创建沙箱前先把游戏状态重置到录像起点:`createReplaySandbox` 接收 `route`、`reseter` 与可选的 `startIndex`、`save`,先调用 `reseter.reset(save)`,再从 `startIndex`(默认 0)创建读取流。
录制端讲完,接下来看播放端如何从同一份缓冲区推进。播放由调用方创建沙箱。`createReplaySandbox` 先调用 `reseter.reset(save)` 把游戏状态恢复到录像起点:传了存档就恢复到该存档,没传就恢复到初始状态。随后创建 `ReplaySandbox`,构造时从 `ReplayArray` 创建读取流,起始索引默认 0。
播放循环只处理两种步:
沙箱自己不重置状态,也不注册指令;它只负责按读取流推进。它不拥有录像数组,也不拥有游戏状态:数组由 `ReplaySystem` 持有,状态由 `reseter` 负责恢复。调用方要在创建沙箱前把两者都准备好,并在播放结束后释放沙箱,避免后续录制写入一个仍在读取的数组。
- 主动步:沙箱查表后直接调用 `execute`,用它的返回值决定是否继续。
- 被动步:沙箱不执行。它把 `{ step, next }` 交给外部,外部拿到参数、完成自己的操作,再调用 `next(status)`;只有 `next` 之后读取流才推进。
控制入口都在沙箱上:
被动步必须紧跟在一个主动步之后,并在主动步执行期间被取走。主动步返回后仍未消费的被动步会被告警并跳过,等待它的代码也就等不到结果。`getPassive()` 只在下一步确实是被动步时返回 handler,否则返回 `null`;在 `next` 之前重复调用,拿到的是同一步。
- `play()` 开始连续播放,`pause()` 请求暂停,`resume()` 从暂停恢复,`stop()` 终止,`step()` 单步播放,`setSpeed(speed)` 设置倍率,`getReplayed()` 返回读取流已经读出的步数。
- `playing` 表示是否处于播放流程,`pausing` 表示是否暂停,`ended` 表示是否已经播完;三者不是同一个状态。
- `play()` 在已经播放或已经结束时直接返回;`pause()` 返回的 `Promise` 在当前步结束时兑现。
播放控制都挂在沙箱上:
沙箱的推进顺序是:先取下一步(`appendingStep`),再执行当前主动步。主动步执行期间,被动步通过 `getPassive()` 暴露;执行完主动步后,如果下一步还是被动步且没有被取走,沙箱告警并跳过。这样被动步不需要自己知道「什么时候该出现」,只需要在主动步打开界面后读取。
| 成员 | 作用 |
| ----------------- | ------------------------------------ |
| `play()` | 开始连续播放 |
| `pause()` | 暂停播放 |
| `resume()` | 从暂停恢复 |
| `stop()` | 终止播放,恢复为正常游戏状态 |
| `step()` | 单步播放一步 |
| `setSpeed(speed)` | 设置播放倍率,1 为正常速度 |
| `getReplayed()` | 已播放步数,不包含当前正在播放的步 |
| `getPassive()` | 取出下一步被动步,不是被动步时返回空 |
播放期间不要让录制继续。移动器在回放时仍会走同一套步进逻辑,仍会往 `ReplayArray` 写入方向;任何写入都会让当前读取流过期,沙箱在下一轮循环检查到过期后停止。回放不是「只读模式」,它只是把同一套逻辑再跑一遍,所以录制开关必须由播放方负责。
接管被动步的一方通常是选择框之类的界面系统:
`getReplayed()` 返回的是读取流已经读出的步数,不是「已经执行完成的步数」;沙箱会预先读下一步,所以这个值可能比当前正在执行的步号领先一。需要判断是否播完,用 `ended`,不要用 `getReplayed() === length`。
```ts
import { IReplaySandbox, ReplayCommandResult } from '@user/data-common';
## 为什么是二进制
/** 播放期间的选择框:被动步由这里消费 */
export class ReplayChoiceConsumer {
constructor(private readonly sandbox: IReplaySandbox) {}
旧实现把录像存成文本数组,读取时要顺序解码到目标步,参数也全部退化成文本。换成二进制布局后,只有三个直接好处:
/**
* 让选择框读取录像里的选择。
* 主动步负责让选择框出现,本方法负责把被动步的参数交给界面。
*/
async consume(showChoice: (index: number) => void): Promise<void> {
// 下一步不是被动步时返回 null,直接走正常的交互逻辑
const passive = this.sandbox.getPassive();
if (!passive) return;
1. 减小存档体积。
2. 提高存档效率。
3. 不需要复杂编码/解码操作。
// 参数按录制时的类型读回;这里约定第一个参数是选项下标
const index = passive.step.params[0] as number;
// 用录像里的选择代替玩家点击,让选择框走同一段后续逻辑
showChoice(index);
二进制布局的代价是格式一旦发布就很难改:类型码、指令码与宽度都属于存档格式。新增语义必须追加新码,不能复用旧码;这也是录像页反复强调「只增不改」的原因。
// 回报成功,沙箱才会读取下一步;不调用 next 会一直停在这一步
await passive.next(ReplayCommandResult.Success);
}
}
```
## 存档形状
`saveState()` 返回四个字段:
| 字段 | 含义 |
| -------------- | ------------------------ |
| `length` | 录像总步数 |
| `commandWidth` | 指令码宽度,8 位或 16 位 |
| `commandArray` | 指令区缓冲区 |
| `paramArray` | 参数区缓冲区 |
`commandArray` 与 `paramArray` 是内部缓冲区的直接引用,不是拷贝。需要长期保存或交给别的存储时,先复制一份;直接就地修改会改到正在使用的录像。
读档走 `loadState()`:设置宽度与两个缓冲区、写入长度,然后重建索引,并把此前创建的所有读取流标记为过期。正在播放的沙箱不会自动恢复,需要重新创建。
录像的指令码与参数类型码遵循只增不改:新语义必须分配新值,不能复用旧码。格式只增不改,也不承诺旧文本格式能被新版本读回。
录像作为可存档对象由主插件注册,装配与挂载顺序见[CoreState 组装与初始化](./core-state.md#存档挂载)。
## 内部实现:三块缓冲区
此章节讲解一下录像二进制格式的内部实现。一条录像步在存储里由三块缓冲区配合表示:
- `指令区`:每条指令占固定宽度,第一个字节是参数个数,后面是指令码。宽度为 `Uint8` 时指令码占 1 字节,为 `Uint16` 时占 2 字节。固定宽度让第 n 步的指令位置可以直接算出来。
- `参数区`:所有步骤的参数首尾相接,每个参数先是 1 字节类型标记,再是载荷。参数长度可以不同,读取时靠类型标记判断该往后走多少。
- `索引区`:一个与步数等长的整数数组,记录每一步的参数在参数区里的起始位置。参数区是变长的,没有索引就无法从步号直接跳到参数。
参数类型标记决定载荷怎么读:
| 类型码 | 类型 | 载荷形态 |
| -------- | ----------- | ---------------------------------------------- |
| 0 | boolean | 1 字节的 0 或 1 |
| 1 | int8 | 单字节有符号整数 |
| 2 | int16 | 16 位有符号整数 |
| 3 | int32 | 32 位有符号整数 |
| 4 | 非负 int64 | 幅值拆成高低两段 32 位整数 |
| 5 | 负 int64 | 幅值拆成高低两段 32 位整数,读回时取负 |
| 6 | float | 双精度浮点数 |
| 7 | 非负 bigint | 1 字节长度,后跟小端排列的幅值字节 |
| 8 | 负 bigint | 1 字节长度,后跟小端排列的幅值字节,读回时取负 |
| 9 | string | 32 位字节长度,后跟 UTF-8 编码的字节 |
| 32 ~ 255 | 短字符串 | 类型码减去 32 就是字节长度,后跟 UTF-8 字节 |
两个缓冲区都按需扩容:创建时预留初始条数与字节数,不够时按固定乘数扩大,各自有上限。指令区最多容纳一百万步,参数区最多一千万字节;达到上限后不再扩容。索引区随指令区一起扩大。
## 内部实现:索引与读流
索引让「按步号直接定位参数」成为可能:读取第 n 步时,先从指令区拿到参数个数与指令码,再从索引区拿到参数起点,最后按类型标记逐个解码参数。
随机读单步并不快:`get(index)` 是独立的随机读取,单步调用比顺序流更重。它是给「确实要跳到某一步」的场景准备的,不是播放路径。
播放走读取流:`createReadStream(startIndex)` 返回一个带 `index`、`length`、`expired` 的流,`read()` 每调用一次前进一步,读完返回 `null`。任何写操作都会让已有读取流失效:追加、插入、删除、改写、切换宽度、整块读档都会把流标记为 `expired`,过期后继续 `read()` 会告警。需要修改录像时,先停止播放并重建沙箱。
## 旧引擎基线对比
以下为旧引擎的文本录像,仅作对比基线,不是当前接口。
| 维度 | 旧引擎文本录像 | 当前二进制录像 |
| -------- | ------------------------------------------------------------------ | -------------------------------------------------------------- |
| 容器 | `core.status.route` 字符串数组,存盘时序列化成一段文本 | `ReplayArray` 的指令区、参数区、索引区三块缓冲区 |
| 单步表示 | `U` / `D` / `L` / `R` 加数量、`I<编号>:` 等文本记号 | 固定宽度指令(参数个数 + 指令码)加类型化参数 |
| 序列化 | 文本拼串,移动做游程压缩,再用 LZString 转 Base64;`!!` 表示不压缩 | 直接读写二进制缓冲区分段,不经过文本 |
| 随机访问 | 必须从头顺序解码到目标步 | 索引区记录参数起点,可按步号定位 |
| 参数类型 | 全部是文本,数字与字符串靠位置和分隔符解析 | 每个参数带类型标记,保留布尔、整数、浮点、bigint 与字符串 |
| 编辑 | 改完字符串数组后整体重新编码 | `insert` / `delete` / `set` 搬移缓冲区并平移索引,仍是线性成本 |
| 物品标识 | 物品 id 与地图内容编号互转(`getNumberById` / `blocksInfo`) | 指令码与参数直接写入,不依赖地图编号 |
新格式的优势集中在几点:
- <strong>紧凑</strong>:每条指令固定宽度,参数按实际范围选择最小类型,不需要先拼文本再压缩。
- <strong>类型保真</strong>:类型标记把布尔、各宽度整数、浮点、bigint 与字符串分开,读回不会把 `1` 当成字符串,也不会把 `true` 当成数字。
- <strong>可随机访问</strong>:索引区让按步号定位成为可能;文本录像只能从头解码。
- <strong>可编辑</strong>:插入、删除、改写会同步平移索引;文本录像每次都要改整串并重新编码。
- <strong>不依赖地图编号</strong>:指令码与参数直接存储;旧文本录像的物品等标识要先换算成地图内容编号,换一份内容就可能对不上。
格式演进只能靠追加,不能靠「读的时候猜」。同一段字节如果在新版本里被解释成另一种类型,旧录像不会报错,只会重放出错误的状态;这种错误通常比直接读取失败更难排查。
## 边界与注意
- <strong>参数个数上限</strong>:每条指令最多 255 个参数,超出部分会被截断并告警。
- <strong>不支持的参数类型会被丢弃</strong>:只支持 `number`、`string`、`boolean`、`bigint`;对象等无法编码的值在写入前被丢掉,参数个数随之变化。
- <strong>单步随机读取很慢</strong>:`get(index)` 需要单独解码,不适合在播放或批量处理的循环里反复调用。
- <strong>数组编辑是线性成本</strong>:`insert`、`delete`、`set` 会搬移后续指令与参数并平移索引,越界时不修改并告警;只适合录像编辑器之类的低频操作。
- <strong>大整数幅值上限</strong>:bigint 的幅值必须小于 2 的 2040 次方;超出会告警并丢弃该参数。
- <strong>缩窄指令宽度会截断</strong>:从 16 位缩到 8 位时,超过 255 的指令码会被截断并告警。已有指令码应保持在 255 以内,或者继续使用 16 位宽度。
- <strong>存档返回的是内部引用</strong>:不要就地修改 `commandArray` 与 `paramArray`;长期保存要先复制。读档会重建索引并让旧读取流失效,正在播放的沙箱要重建。
- <strong>旧格式不保证可读回</strong>:旧引擎的文本录像不是当前二进制格式;格式只增不改,也不承诺跨版本读回。
- **参数只支持四种原始类型**:对象、数组、函数与 `undefined` 会被丢弃,后续参数随之前移。
- **单步参数最多 255 个**:超过部分会被截断并告警。
- **`get(index)` 是随机读**:每次都要解码参数,不适合放进播放循环。
- **编辑成本是线性的**:`insert`、`delete`、`set` 都要搬移后续缓冲区与索引;越界不修改并告警。
- **任何写入都会让读取流过期**:播放期间继续录制会让回放停在中途,播放方必须先停录。
- **宽度属于整份录像**:录制前切换;缩窄会截断超出范围的指令码,而写入时不会替你检查。
返回[高级 III 导览](./index.md)。