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

This commit is contained in:
unanmed 2026-10-28 20:26:13 +08:00
parent 507fcc59ef
commit e69dc0af06
12 changed files with 1805 additions and 1249 deletions

View File

@ -1,175 +1,187 @@
# 高级 III(原理与架构)读者审阅
# 高级 III 独立读者审阅(收敛复审)
## 读者人格(逐字,本层专用,未复用其它层)
## 人格(逐字)
> 假设你是一位有高度定制需求的数据端作者,已经读完高级 I 与高级 II,具备足够的编码能力:能独立读懂引擎的大部分数据端系统(会读类、接口、泛型、装饰器与 TypedArray 操作),但你既不是引擎的编写者,也没有通读过全部源码。你正在阅读高级 III「原理与架构」,想判断这些原理能否帮你把现有系统改对。
假设你是一位有高度定制需求的数据端作者,已经读完高级 I 与高级 II,具备足够的编码能力:能独立读懂引擎的大部分数据端系统(会读类、接口、泛型、装饰器与 TypedArray 操作),但你既不是引擎的编写者,也没有通读过全部源码。你正在阅读高级 III「原理与架构」,想判断这些原理能否帮你把现有系统改对。
**未复用中级 / 高级 I / 高级 II 人格**:本记录全程使用上述高级 III 人格提示词。中级人格见 `10-11-READER-REVIEW.md`,高级 I 人格见 `10-19-READER-REVIEW.md` 与 `10-31-READER-REVIEW.md`,高级 II 人格见 `10-26-READER-REVIEW.md`;这些均未被复用。
## 审阅范围与声明
## 审阅范围(八页)
- 本轮是收敛复审(修订第 5 轮后)的全新独立阅读,不是上一轮清单的延续。
- 只阅读了 Markdown,顺序为:`docs/intermediate/*.md`(25 篇)→ `docs/advanced/i/*.md`(9 篇)→ `docs/advanced/ii/*.md`(10 篇)→ `docs/advanced/iii/*.md`(8 篇)。
- 未阅读任何 `.ts` / `.tsx` / `.js` 源码;未阅读上一版 `.planning/.../10-40-READER-REVIEW.md`。
- 未派发子代理,未调用 `opencode` 命令,未提交,未修改本文件之外的任何文件。
- 本文件只回答问题与记录概率;不给出正确性判断,也不提出修复。概率仅表示「该人格在把现有系统改对时回查该节的频率」,低概率不等于该节应删。
- docs/advanced/iii/index.md(导览)
- docs/advanced/iii/loading-system.md
- docs/advanced/iii/replay-internals.md
- docs/advanced/iii/event-system.md
- docs/advanced/iii/anon-tokyo.md
- docs/advanced/iii/passability-and-pathfinding.md
- docs/advanced/iii/core-state.md
- docs/advanced/iii/plugin-internals.md
## 全新问题清单
审阅对象是 10-39 已接入站点、互链完整的八页;未重审高级 I / II。执行者未向审阅子代理提供自己的分析结论,子代理以独立上下文自行阅读,可打开被引用的源码核对,但不修改任何文件。
以下只列「读完当前文档后仍然无法确定」的问题;能由文档直接回答或由两页交叉推出的事项不列入。
## 第 1 轮 · 逐页逐节使用概率
### Q1. `docs/advanced/iii/plugin-internals.md` · 「三个阶段怎么执行」(并与「失败会怎样」合读)
### 高级 III 导览(index.md)
**已知:** `awake` 在主对象构造期同步执行,返回值不被等待;同步抛错会让 `new CoreState` 直接抛出。`initialize` / `afterLoad` 会被 `await`,拒绝会让 `load()` 以同一错误结束,插件停在前一状态;文档还区分了「插件阶段失败可再次驱动 `load()`」与「加载器自身失败不能重试」。
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 高级 III 是给谁的 | 中 | 入口处读一次确认自己是受众,之后不会回头用 |
| 与高级 I、高级 II 的区别 | 中 | 只用来校准「这层讲机制不讲用法」的预期,一次性内容 |
| 前置 | 高 | 决定是否回头补读插件系统/可存档内容等四篇,规划阅读前真的会照着执行 |
| 八篇地图 | 高 | 反复回来查「哪个系统在哪篇」,是本层的目录 |
| 阅读路径 | 高 | 排查问题时按需线跳转,直接决定我先读哪篇 |
| 本层不覆盖什么 | 中 | 出现渲染端/编辑器问题时用来止损,避免在这层白找 |
| 怎么验证你读懂了 | 中 | 读完自测一次,不作日常参考 |
**仍未解答:** 如果误把 `awake` 写成 `async`,其 Promise 拒绝时,这个拒绝是否被引擎观察、会让构造或加载失败,还是变成未处理的 Promise 拒绝?此时插件会停在哪一状态,后续能否通过 `load()` 或状态检查发现?文档只覆盖了 `awake` 的同步抛错与 `initialize` / `afterLoad` 的拒绝,没有覆盖「异步 `awake` 的拒绝」这一条边界。
### 加载系统(loading-system.md)
### Q2. `docs/advanced/iii/loading-system.md` · 「扩展点:加一种资源」「进度」「边界与注意」
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 系统模型 | 高 | 动加载前先分清 task/manager/loader 各管什么,避免改错层 |
| 一次加载的阶段 | 高 | 追加配置/任务、排查「配置没被读到」全靠阶段顺序,本页核心 |
| 配置文件与处理器 | 中 | 加配置时要看相对路径与路径改写;JSONC 前缀细节只在解析失败排查时用 |
| 扩展点:加一份配置 | 高 | 我的每个自定义系统几乎都带一份配置,最高频扩展点 |
| 扩展点:加一种资源 | 中 | 大部分自定义数据走 addExtraConfig 就够;只有非 JSON 或要流式处理才用自定义任务 |
| 扩展点:换一个加载环境 | 低 | 只跑网页端;只有做离线校验或本地资源包时才会回来 |
| 进度 | 低 | 进度是展示用;我只需要「等 loaded() 而不是等比例」这一句 |
| 渲染端如何消费加载进度 | 低 | 数据端视角几乎不用,只为理解边界读一次 |
| 边界与注意 | 高 | 改完加载相关代码后的自查清单 |
**已知:** 自定义资源由 `LoadTask` + processor + starter 组成,通过 `addCustomTask` 加入;任务结果只通过返回的 Promise 交付,不进 `getConfig`;`loader.loaded()` 表示整次加载完成;非法配置文件会产出空对象而不会中断整次加载。
### 录像系统底层(replay-internals.md)
**仍未解答:** 当某个自定义任务的 `starter.start` 或 `processor.process` 拒绝时,`loader.start()` / `state.load()` 会怎样收敛?`loaded()` 是否仍会兑现,其它已加入的任务是否继续,`LoadManager` 上是否有可观察的失败计数或失败状态?这决定了我能否把「可选资源」做成失败可降级,还是必须自己在任务外层包住并使用独立 Promise。
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 系统模型 | 高 | 出现存档/回放不一致时先分清数组、注册表、沙箱谁负责什么 |
| 录制一步 | 高 | 登记自定义指令、判断主动/被动归类时直接用 |
| 沙箱播放 | 高 | 排查回放分歧、实现选择框之类的被动步消费者时的核心 |
| 存档形状 | 高 | 存档兼容与「内部引用不能就地改」的拷贝策略直接相关 |
| 内部实现:三块缓冲区 | 中 | 平时用不到;二进制损坏排查或写录像编辑器时才读 |
| 内部实现:索引与读流 | 中 | 解释读取流为什么过期、随机读为什么慢,改录像前必读但低频 |
| 旧引擎基线对比 | 低 | 没有旧样板内容要迁移,一次性背景知识 |
| 边界与注意 | 高 | 参数上限、内部引用、流过期这些坑高频自查 |
### Q3. `docs/advanced/iii/anon-tokyo.md` · 「内部实现:编译与执行」「与事件对象的衔接」
### 事件系统(event-system.md)
**已知:** 内建函数在编译期按名字查表,缺失会在 `compile` 时抛错;`GameEvent` 在首次 `execute` 时编译并缓存,`setRaw` 会清空缓存;全局函数缺失则在执行到 `Call` 时才抛错。
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 系统模型 | 高 | 分清仓库/收集/执行/解释器职责,是挂事件前的基础 |
| 事件与触发器 | 高 | 挂触发器前先确认数据端是否派发,避免写出永不触发的事件 |
| 收集:这一次该执行谁 | 高 | 设计优先级、排查执行顺序(点前块后、优先级只在组内比)时用 |
| 执行与折叠 | 高 | 做门控逻辑时选执行/折叠模式,且要防执行器状态共享的坑 |
| 事件层 | 中 | alias=event 的登记规则偶尔要查;主体认知在高级 II 已建立 |
| 自定义一个内建函数 | 高 | 我的自定义内容几乎都要新动作,注册路径照抄 |
| 边界与注意 | 高 | 事件不执行时的告警码对照自查 |
**仍未解答:** 加载完成后,是否存在受支持的「预编译全部事件」入口或校验流程,让缺失的内建函数在玩家首次触发或录像回放之前就暴露?还是只能等事件第一次 `execute` 才编译并发现?文档描述了编译缓存与懒编译,但没有说明数据端是否提供全量预编译、或在加载阶段收集编译错误的方式。
### Anon-Tokyo 解释器(anon-tokyo.md)
### Q4. `docs/advanced/iii/event-system.md` · 「执行与折叠」(并与高级 I 的移动器 `onEnd` 约束合读)
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 系统模型 | 中 | 平时经内建函数间接使用;只有排查脚本问题才需要这条链 |
| 语句模型 | 中 | 手写/生成语句数组时用;大多数时候语句由编辑器产出 |
| 与事件对象的衔接 | 中 | 运行时 setRaw 换事件、缓存失效行为需要时才读 |
| 内部实现:编译与执行 | 中 | 「编译期抛错 vs 调用期抛错」的排查清单有用;执行循环细节我不会动 |
| 自定义内建函数与全局函数 | 低 | 内建函数写法在事件系统页;全局函数当前数据端未用,只剩一次性事实 |
| 边界与注意 | 高 | 调试脚本时的快速核对清单(Exit/Return、作用域三层) |
**已知:** `EventExecutor.execute` 逐条顺序执行且不捕获异常;任意一条事件拒绝会让整个 `execute` 拒绝,后续调用项与 `onAfterExecuteEvent` 都不再执行。移动器的步进方法或钩子抛错会让 `onEnd` 永不兑现;默认移动的触碰事件、战斗流程都在同一次已记录操作内。
### 通行性谓词与寻路原理(passability-and-pathfinding.md)
**仍未解答:** 当事件拒绝发生在一段已记录的移动/触碰链路中时,底层移动控制器是随事件错误一起拒绝,还是保持挂起(`onEnd` 不兑现)?沙箱播放会把这一步视为 `Failed`、`Ignored`,还是只表现为播放停住?文档分别说明了「事件拒绝」与「移动器挂起」,但没有说明两者相遇时回放侧的可观察结果与恢复边界。
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 系统模型 | 高 | 改通行规则前先确认判断统一落在谓词上 |
| 通行性谓词契约 | 高 | canPass/shouldHit 的区分是改一切通行规则的地基 |
| 默认实现怎么判断 | 高 | 包装默认谓词前必须知道默认规则查哪些位 |
| 同一契约、两个实例 | 高 | 本页对我最关键:只换一侧是最容易犯的错 |
| 勇士移动的三种结果 | 高 | 自定义移动与触发器联动时必须对应三种结果 |
| 构图:从起点只保留可达节点 | 中 | 排查「为什么搜不到/为什么这格是终端」时才读 |
| 搜索:最小损失与终端约束 | 中 | NoPath/TargetUnder 语义要懂;搜索内部细节低频 |
| 系统层做什么 | 中 | 绑定/换楼层/并发限制的契约偶尔要查;用法在高级 II |
| 换一套通行语义 | 高 | 我要改通行规则时的直接模板 |
| 边界与注意 | 高 | 高频自查(两实例不共享、终端只能作终点等) |
## D-78:8 个高级 III 页面的分节使用概率
### CoreState 组装与初始化(core-state.md)
### 概率口径
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 系统模型 | 高 | 四层结构与依赖方向是所有后续判断的底座 |
| 入口的职责 | 中 | 入口装配是一次性工作,要动它时再读 |
| 加载管线 | 高 | 排查启动问题(插件没初始化、加载没推进)的核心 |
| 存档挂载 | 高 | 注册可存档对象/绑定执行器是我的日常操作 |
| 在游戏内另建一个数据端实例 | 高 | 离线校验回放、对比双实例结果正对我的回放一致性需求 |
| 内部实现:装配顺序 | 中 | 我不重排也不手搓子系统,读一次满足「为什么不能重排」 |
| 边界与注意 | 高 | 高频自查 |
- **高**:把现有数据端系统改对时,经常需要回查或作为改动前置。
- **中**:只有特定定制(新增资源、脚本、二次实例、调试格式)才会命中。
- **低**:背景、对比、边角或首次阅读后很少回查。
### 插件内部机制(plugin-internals.md)
### 页面总览
| 小节 | 概率 | 理由 |
| --- | --- | --- |
| 系统模型 | 中 | 一次性建立「表→依赖图→拓扑序→状态机→三阶段」的流水线印象 |
| 三个阶段怎么执行 | 高 | 等待语义(awake 不等待、另两个 await)决定我把哪段代码放哪个钩子 |
| 取回一个插件 | 高 | 运行期跨插件调用的标准写法,判空与状态检查照抄 |
| 失败会怎样 | 高 | 插件静默不执行时的排查路径,控制台提示对照表 |
| 内部实现:依赖排序 | 中 | 只为看懂告警在说什么;算法本身不会去调 |
| 状态机 | 中 | 与「取回一个插件」配合判断插件走到哪步,中频 |
| 边界与注意 | 高 | 高频自查 |
| 页面 | 总体概率 | 理由 |
| ---- | -------- | ---- |
| `index.md` | 高 | 决定阅读路径与跨页命名;首次进入与跳转都依赖它。 |
| `core-state.md` | 高 | 四层装配、脏标记、加载与存档挂载是多数改动的落点判断依据。 |
| `plugin-internals.md` | 高 | 扩展系统几乎都要判断依赖顺序、阶段与失败行为。 |
| `event-system.md` | 高 | 改事件收集、触发器派发与内建函数时必读。 |
| `passability-and-pathfinding.md` | 高 | 改移动、碰撞、地图终端与寻路一致性时必读。 |
| `replay-internals.md` | 高 | 涉及录像、回放、检查点与读档录像保护时必读。 |
| `loading-system.md` | 中 | 只有新增资源/配置或换加载环境时才深入。 |
| `anon-tokyo.md` | 中 | 改事件脚本语义或内建函数时要读,日常系统改动较少回查。 |
## 第 1 轮 · 问题清单
### `index.md`
### 阻断性(2)
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 高级 III 是给谁的 | 中 | 一次性的读者定位。 |
| 与高级 I、高级 II 的区别 | 中 | 帮助判断该在哪一层找答案。 |
| 前置 | 中 | 跳转用,不承载机制。 |
| 八篇地图 | 高 | 每次定位主题都从这里选页。 |
| 阅读路径 | 高 | 原理线与按需线的选择直接影响后续连续性。 |
| 本层不覆盖什么 | 低 | 范围声明,改动时很少回查。 |
| 编辑器识别与性能排查 | 中 | 需要跳往中级/高级 II 的散落入口。 |
| 跨页接口约定 | 高 | 插件依赖、存档执行器、怪物刷新、装备槽等命名对照最常回查。 |
| 怎么验证你读懂了 | 低 | 自检清单,不参与实际改动。 |
| # | 问题 | 定位 | 为什么阻断 | 源码依据 |
| --- | --- | --- | --- | --- |
| B-01 | 正文称「放在 `onCoreConfigLoaded` 或 `onExtraConfigLoaded` 里,或者在加载开始前调用,都来得及」。对 `addExtraConfig` 而言,`onExtraConfigLoaded` 已经太晚:额外配置的读取循环发生在该钩子**之前**,此时追加只写进映射表,本次加载永远读不到;`start()` 因 `dataLoaded` 守卫也不会补读。 | `loading-system.md`「一次加载的阶段」 | 读者会把 `addExtraConfig` 注入 `onExtraConfigLoaded`,配置静默不加载、`getConfig` 返回 null、下游拿到空内容且无报错——属于在错误时机注入。 | `packages-user/data-state/src/loader/loader.ts:119-141`(额外配置循环先于 `onExtraConfigLoaded`)、`loader.ts:61-63`(只写映射表)、`loader.ts:84-85`(`loading || dataLoaded` 直接 return) |
| B-02 | 正文与边界把「`start()` 已被调用」当作追加分界线(「等加载已经开始再追加,就不会再被本次加载读取」)。但 `onCoreConfigLoaded` / `onExtraConfigLoaded` 都在 `start()` 内部触发,却是 `addCustomTask` 的合法窗口;同一处先列合法窗口又说「已开始就不读取」,自相矛盾。 | `loading-system.md`「一次加载的阶段」「边界与注意」 | 读者会对「插件时机 vs 加载阶段」建立错误模型:要么避开本可用的窗口,要么在自定义任务没加载时归因错误。 | `loader.ts:114-116`(`onCoreConfigLoaded` 在 `start()` 中途触发)、`loader.ts:139-147`(`onExtraConfigLoaded` 后收集 `customTask`,加载中途注册仍会被读取) |
### `core-state.md`
### 非阻断(9,只记录,本轮不修)
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 系统模型 | 高 | 四层职责与依赖方向是判断改动落点的第一入口。 |
| 扩展点上的 state 类型 | 高 | 自定义谓词、顶层实现、内建函数、执行器都要处理类型宽度。 |
| 系统层里的脏标记与按需刷新 | 高 | 「改了属性但伤害没变」的高频根因所在。 |
| 构建阶段与注册项 | 中 | 只有特殊查询缺少自然光环时才需要锚点。 |
| 入口的职责 | 中 | 启动与二次实例才涉及 `createCoreState*`。 |
| 加载管线 | 高 | 插件阶段、`load()` 驱动与不可中断边界必须知道。 |
| 存档挂载 | 高 | 自定义可存档内容、执行器与录像顺序都在这里收口。 |
| 在游戏内另建一个数据端实例 | 低 | 只有校验、回放对比、离线模拟才用。 |
| 内部实现:装配顺序 | 中 | 解释「为什么不能重排」,改系统时作为约束回查。 |
| 边界与注意 | 高 | 加载、插件共享、执行器、存档键等易错点集中。 |
| # | 问题 | 定位 |
| --- | --- | --- |
| N-01 | `getReplayed()` 声称「不包含当前正在播放的步」,实现返回 `reader.index`,实际可能多算 1~2 步;做进度显示会偏大。建议核对语义后统一文档与实现。 | `replay-internals.md`「沙箱播放」 |
| N-02 | 「被依赖的插件先完成同一阶段」对 `async awake` 不成立(`awake` 不等待);上文有告诫,本段却是绝对化表述,存在冲突。建议在本段补限定。 | `plugin-internals.md`「三个阶段怎么执行」 |
| N-03 | `CounterSaveable` 示例的 `saveState` / `loadState` 省略了 `compression` 参数;TS 可赋值但契约不完整,读者不知道有压缩级别。建议按真实签名书写。 | `core-state.md`「存档挂载」 |
| N-04 | 「`CoreState.load()` 已经替你完成迭代」措辞不严:`load()` 本身也是异步迭代器,仍必须被消费。 | `loading-system.md`「边界与注意」 |
| N-05 | `judgeStep` 注释写「频繁判断时应复用同一个实例」,示例却在函数体内每次 `new DefaultPassPredicateImpl(state)`,注释与代码自相矛盾。 | `passability-and-pathfinding.md`「默认实现怎么判断」 |
| N-06 | 示例注释「配置缺失时加载器给的是空对象」混淆两种情形:id 未登记/未加载时 `getConfig` 返回 `null`,只有解析失败才给 `{}`。 | `loading-system.md`「扩展点:加一份配置」 |
| N-07 | 旧引擎基线对比中的细节(`core.status.route`、LZString、`getNumberById` / `blocksInfo`)在本仓库无实现可核对(未核对)。建议注明来源或补可核对引用。 | `replay-internals.md`「旧引擎基线对比」 |
| N-08 | `AnonTokyoExecutionContext` 上存在空的 `dump()`;正文说「没有导出能力」成立,但读者翻 API 会疑惑,建议明确 `dump` 是空实现。 | `anon-tokyo.md`「边界与注意」 |
| N-09 | 「数据端入口只重新导出创建函数」略松:`src/data/index.ts` 同时重导出 constant、initialize、types。措辞收紧即可。 | `core-state.md`「入口的职责」 |
### `loading-system.md`
### 可删 / 可后置(只记录)
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 系统模型 | 高 | 先分清 Task / Manager / Loader / Starter 四层职责。 |
| 一次加载的阶段 | 高 | 追加配置与任务的时间窗都在这里。 |
| 配置文件与处理器 | 中 | 只有写额外配置或排查空对象时深入。 |
| 扩展点:加一份配置 | 高 | 新增内容配置的常规入口。 |
| 扩展点:加一种资源 | 中 | 只有自定义资源类型才需要。 |
| starter 从哪来 | 中 | 自定义任务必然遇到,但与资源扩展绑定。 |
| 扩展点:换一个加载环境 | 低 | 多数项目沿用 Web starter。 |
| 进度 | 中 | 做加载界面或旁观进度时才用。 |
| 渲染端如何消费加载进度 | 低 | 数据端作者通常不改渲染端。 |
| 边界与注意 | 高 | 时间窗、并行、空对象、starter 等错误集中。 |
- `loading-system.md`「渲染端如何消费加载进度」:以本层读者身份几乎不会用,且导览声明本层只写数据端;建议压缩成两三句并入「边界与注意」,或移到渲染端文档,只保留「接口级消费、不改顺序」。
- `replay-internals.md`「旧引擎基线对比」:无旧内容迁移需求时是一次性背景,且仓库内不可核对;不建议删(对比有教学价值),但可压缩成要点列表并保持文末后置。
- `loading-system.md`「进度」:计数表偏渲染端展示,可压缩;务必保留「不要等比例做业务判断,等 `loaded()` / 读配置」。
- `loading-system.md`「扩展点:换一个加载环境」:低频,篇幅已不长,可后置。
- `anon-tokyo.md`「自定义内建函数与全局函数」:主体是指路加「全局函数当前未用」的一次性事实,可压到几行,不建议删。
- `index.md`「与高级 I、高级 II 的区别」:两三句即可承载现有信息,可压缩。
- 各页「内部实现」节(装配顺序、依赖排序、三块缓冲区、索引与读流、编译与执行)日常低频,但回答「为什么不能重排 / 为什么连带阻断 / 为什么流会过期」,不建议删。
### `event-system.md`
### 第 1 轮核对总结
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 系统模型 | 高 | 仓库/收集/执行/解释器四角色是理解事件链路的前提。 |
| 事件与触发器 | 高 | 判断哪些触发器已派发、哪些需手动派发。 |
| 收集:这一次该执行谁 | 高 | 点事件与图块事件的组序、静态/动态顺序常被改动影响。 |
| 执行与折叠 | 高 | 短路、折叠、异常与共享状态都在这里。 |
| 事件层 | 中 | 只在换楼层或自定义图层时需要确认。 |
| 自定义一个内建函数 | 高 | 扩展事件动作的主路径。 |
| 边界与注意 | 高 | 触发器过滤、异常、录像边界等易错点集中。 |
通行性两实例、事件收集不过滤触发器与「点事件整组在前、图块事件整组在后」、执行器过滤与折叠、Anon-Tokyo 编译期/运行期抛错与 `Exit`/`Return`、CoreState 四层装配与五个存档 id、`load()` 四步异步迭代器、插件环/缺失连带阻断与四状态、录像 `finalize` 触发条件与存档内部引用,均与源码一致。八页中仅加载系统的时间窗口表述存在阻断级错误(B-01 / B-02);其余主题未发现会让读者改错层、换错实例或混淆系统模型的问题。
### `anon-tokyo.md`
## 迭代记录
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 系统模型 | 中 | 需要先知道解释器与事件对象的边界。 |
| 语句模型 | 中 | 只有写结构化事件脚本或内建函数时才细读。 |
| 与事件对象的衔接 | 中 | 改事件执行/编译缓存时回查。 |
| 内部实现:编译与执行 | 低 | 解释器内部循环,日常改动很少触碰。 |
| 自定义内建函数与全局函数 | 中 | 需要取值/复用逻辑时会读;全局函数标准数据端不可用。 |
| 边界与注意 | 中 | 语法保留项、返回值丢弃、`save` 无语义等提醒。 |
### 第 1 轮 · 修正记录
### `passability-and-pathfinding.md`
仅改 `docs/advanced/iii/loading-system.md` 与本记录;未改 `config.ts`、高级 I / II 页面与源码。非阻断项本轮不修,按 D-14 留在清单。
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 系统模型 | 高 | 勇士移动与寻路共用谓词契约的总图。 |
| 通行性谓词契约 | 高 | `canPass` / `shouldHit` 是所有通行改动的根。 |
| 默认实现怎么判断 | 高 | 事件层、`onlyEvents`、`eventPass`、方向位都在这里。 |
| 同一契约、两个实例 | 高 | 勇士侧与寻路侧不同步是高频问题。 |
| 勇士移动的三种结果 | 高 | 改碰撞与触发器派发时必读。 |
| 构图:从起点只保留可达节点 | 高 | 终端与可达性决定寻路结果。 |
| 搜索:最小损失与终端约束 | 中 | 多数改动不动搜索,只动构图或损失函数。 |
| 系统层做什么 | 中 | 只有替换移动器/瞬移策略时才细读。 |
| 换一套通行语义 | 高 | 自定义谓词与顶层实现的实际落点。 |
| 边界与注意 | 高 | 空格、怪物占格、动态图块、换楼层等边界集中。 |
| # | 处置 | 说明 |
| --- | --- | --- |
| B-01 | 已修正 | 「一次加载的阶段」改写为以「对应收集循环是否跑过」判断窗口:`addExtraConfig` 只能在加载开始前或 `onCoreConfigLoaded` 中调用,放到 `onExtraConfigLoaded` 已太晚,本次加载不会再读取。 |
| B-02 | 已修正 | 同段明确 `addCustomTask` 可在加载开始前、`onCoreConfigLoaded` 或 `onExtraConfigLoaded` 中调用;自定义任务阶段开始后或加载结束后追加不会被读取。并补「`start()` 只真正执行一次、错过窗口没有补读机会」。「边界与注意」同步改写 `addCustomTask` 条目,并新增 `addExtraConfig` 条目。 |
### `replay-internals.md`
- 源码依据:`packages-user/data-state/src/loader/loader.ts:84-85`(`start()` 只执行一次)、`:114-116`(`onCoreConfigLoaded` 在 `start()` 中途触发)、`:119-141`(额外配置循环先于 `onExtraConfigLoaded`)、`:143-147`(`onExtraConfigLoaded` 之后才收集自定义任务)。
- 未把使用概率等元信息写入正文;未改写或新增任何「用户最终确认」标记;未删减已锁定要覆盖的内容。
- 本轮构建:`node node_modules/prettier/bin/prettier.cjs --check docs/advanced/iii` 通过;`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` 退出码 0,`build complete in 19.57s`,无 dead link。
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 系统模型 | 高 | 数组、注册表、沙箱三者的分工必须先清楚。 |
| 录制一步 | 高 | 参数类型、丢弃规则与入口记录是常用路径。 |
| 结构化参数怎么编码 | 中 | 只有新增复杂指令参数时才需要。 |
| 沙箱播放 | 高 | 检查点、被动步、重置与播放控制都在这里。 |
| 存档形状 | 高 | 缓冲区直引、宽度、读档续写是录像改动的核心。 |
| 内部实现:三块缓冲区 | 中 | 排查格式或性能时用,日常改动少。 |
| 内部实现:索引与读流 | 中 | 理解随机读、过期与编辑成本时用。 |
| 旧引擎基线对比 | 低 | 只作历史对比,不是当前接口。 |
| 边界与注意 | 高 | 参数上限、宽度截断、过期、`finalize` 边界集中。 |
### `plugin-internals.md`
| 章节 | 概率 | 理由 |
| ---- | ---- | ---- |
| 系统模型 | 中 | 五段流程是理解依赖顺序的骨架。 |
| 三个阶段怎么执行 | 高 | 扩展系统必须知道时机、等待与异常行为。 |
| 需要主插件的装配时 | 高 | 是否声明 `main-data-plugin` 直接影响默认装配可用性。 |
| 取回一个插件 | 中 | 运行期按 id 调用时才用。 |
| 失败会怎样 | 高 | 成环、缺失、重复 id、异常与重试边界都集中。 |
| 内部实现:依赖排序 | 中 | 解释顺序为何固定、平局为何按列表顺序。 |
| 状态机 | 中 | 排查卡在哪个阶段时需要。 |
| 边界与注意 | 高 | 依赖固定、不可追加、阻断不可查等约束集中。 |
### 最高与最低使用概率汇总
- **最高**:`core-state.md`「系统模型」「系统层里的脏标记与按需刷新」「存档挂载」;`passability-and-pathfinding.md`「默认实现怎么判断」「同一契约、两个实例」;`event-system.md`「收集」「自定义一个内建函数」;`replay-internals.md`「沙箱播放」「存档形状」;`plugin-internals.md`「三个阶段怎么执行」「需要主插件的装配时」。
- **最低**:`replay-internals.md`「旧引擎基线对比」;`loading-system.md`「渲染端如何消费加载进度」「扩展点:换一个加载环境」;`anon-tokyo.md`「内部实现:编译与执行」;`core-state.md`「在游戏内另建一个数据端实例」;`index.md`「本层不覆盖什么」「怎么验证你读懂了」。
## 阅读连续性记录
1. **入口顺序与阅读路径不完全一致。** `index.md` 的「八篇地图」表按加载→录像→事件→解释器→通行→CoreState→插件排列,而「阅读路径」的原理线从 CoreState 开始。按表顺序首次阅读会先遇到加载系统,此时还没有四层装配与插件生命周期的心智模型;按路径顺序则从组合根进入,后续每页引用 `markDirty`、插件阶段、存档执行器时都能接上。该页同时给出两条线,读者需要自己决定以哪条为主。
2. **CoreState 是多数页的隐性前置。** `plugin-internals.md` 直接引用其加载管线与存档挂载;`loading-system.md` 用 `CoreState.load()` 包裹加载器;`event-system.md` 的派发落点依赖已记录的移动操作;`replay-internals.md` 的 `reseter` 适配器建立在 `state.loadState` 之上。先读 `core-state.md` 能显著减少后续的来回跳转。
3. **事件系统与解释器是连续的一对。** `event-system.md` 停在「收集—过滤—执行」,把编译、作用域与迭代器明确交给 `anon-tokyo.md`;后者用「与事件对象的衔接」回链。两页之间没有重复展开,衔接点清楚。
4. **通行页与高级 II 的寻路页形成跨层断点。** `passability-and-pathfinding.md` 只讲契约、构图与搜索,损失函数、瞬移策略与系统绑定示例明确回链 `advanced/ii/pathfinding.md`。只读高级 III 会在「怎么注入/怎么调策略」处需要回跳高级 II。
5. **录像页与高级 II 的自定义指令页形成跨层断点。** 二进制形状、沙箱与检查点在本页,指令码/指令对象/注册在高级 II;两页共享 `ReplayCode`、`finalize`、被动步等概念,但示例分处两层,读者需要自行拼接完整实现路径。
6. **存档与录像必须合读。** `core-state.md`「存档挂载」说明执行器落在录像禁用包裹之外、默认注册顺序把 `@system/replay` 放最后;`replay-internals.md`「存档形状」说明缓冲区直引与读档续写。只看其中一页,容易把「读档顺序」当成契约,或把录像快照当成稳定副本;两页合读后边界才完整。
7. **索引页的「编辑器识别与性能排查」是散落入口。** 本层没有独立篇目,相关内容指向中级特殊属性/自定义事件、高级 II 的属性页与 II 的范围/存档页。按标题在本层找「编辑器协议」或「性能章节」会落空,需要顺着链接跳层。
8. **低概率章节不影响主线连续性。** 旧引擎基线、渲染端消费、解释器内部循环、二次实例等节可以后置;它们被引用时都有明确回链,跳读不会破坏四层装配、事件收集、通行契约与回放沙箱这条主线。

View File

@ -0,0 +1,626 @@
# 高级 III 重写简报(Phase A)
> 只做阅读、分析与重写设计;没有修改 `docs/` 或源码,没有派发子代理,没有调用 `opencode`,没有提交。
>
> 阅读量:中级教程 25 篇、高级教程 28 篇(总导览 1 + 高级 I 9 + 高级 II 10 + 高级 III 8)、规划与规范 6 份(`AGENTS.md`、`dev.md`、`10-CONTEXT.md`、`10-ADVANCED-III-RESEARCH.md`、`10-40-READER-REVIEW.md`、`10-41-PLAN.md`),以及约 59 个源码、配置与测试文件;合计约 118 个文件。源码覆盖 replay、event、anon-tokyo、path、map、hero、data-state、loader、save 与相关测试。
---
## 0. 现状判断
### 0.1 工作区里的高级 III 是一次回退式改写
`git diff --stat -- docs/advanced/iii` 覆盖八页,合计 `593 insertions, 1017 deletions`;其中七篇目标页是主要压缩来源:
| 页面 | HEAD 行数(约) | 当前行数 | 被删掉的结构 |
| --- | --- | --- | --- |
| `core-state.md` | 195 | 66 | 加载管线、存档挂载、第二个实例、内部实现:装配顺序 |
| `passability-and-pathfinding.md` | 249 | 179 | 同一契约、两个实例;勇士移动的三种结果;系统层做什么 |
| `replay-internals.md` | 约 230 | 142 | 存档形状;内部实现:三块缓冲区;内部实现:索引与读流 |
| `event-system.md` | 约 280 | 151 | 收集章节的完整 env 表与示例;执行器的录像标记 |
| `anon-tokyo.md` | 约 190 | 124 | 与事件对象的衔接;内部实现:编译与执行 |
| `plugin-internals.md` | 约 150 | 124 | 三阶段的依赖参数与等待语义细节;失败模式的展开 |
| `index.md` | 约 110 | 42 | 读者画像、与 I/II 的区别、不覆盖范围、自检 |
这不是「某一段写错」,而是把有模型、有理由、有例子的页面改成了接口清单。用户说的「只读局部代码就改」,在这批改写里表现为:保留了当前类名与方法表,删掉了需要跨文件理解才能写出的设计段落。
### 0.2 重写目标
七篇目标页(`index`、`replay-internals`、`event-system`、`anon-tokyo`、`passability-and-pathfinding`、`core-state`、`plugin-internals`)按高级 I 的库文档风格重写:先模型,再数据流,再接口,再一个完整示例,最后边界;`loading-system.md` 是用户认可的样板,只作为参照,不在本次重写范围。
---
## A. 风格分析:中级与高级 I/II 到底怎么教
### A1 中级教程的写法
中级篇目的共同结构是「场景开场 → 分清概念 → 接口 → 一个任务型示例 → 常见注意 → 下一篇」。具体特征:
1. 开场先给一个玩家能感知的场景。`combat-overview.md` 从「点一下怪物,勇士走过去、挥几刀」开始;`replay-commands.md` 从「录像验证要能重放出和原来一模一样的结果」开始。场景之后立刻说明「本篇讲什么、不展开什么」。
2. 先分清楚概念,再给接口。`hero-equipment.md` 先分「装备槽」与「装备实例仓库」;`enemy-specials.md` 先分「code 与 value」「无参 / 有参」;`enemy-aura.md` 先分「转换器 / 视图 / 优先级」。
3. 示例是任务型的,不是接口演示。每篇都有「目标 / 改哪里 / 复制什么 / 改什么 / 怎么验证 / 注意」六段(D-48),示例完整、可粘贴,注释解释每一行。
4. 「为什么」用短段落讲,不写成源码分析。`combat-overview.md` 的「为什么分四步」用装配零件的类比;`enemy-final.md` 用「读取外部 / 影响外部」的二维表解释为什么没有混合效果。
5. 边界集中在文末。每篇末尾列 3–6 条真实易错点,不在正文里到处插警告。
6. 大量使用对照表。四类道具、五种地图伤害、光环 / 查询 / 最终三方对比、脚本 vs 钩子,都是「同一维度的几个选项」压成一张表。
### A2 高级 I 的写法
高级 I 是重写时最应该模仿的层,因为它已经脱离「六步实操」,但仍然对读者友好:
1. 开篇先给一个真实工程问题。`plugin-system.md` 从「假设你要加一个任务系统,不做成插件,代码会散在几个地方」开始;`saveable-content.md` 从「存档不是把内存倒进文件,而是状态快照」开始;`mover.md` 从「移动器把想怎么走和实际怎么走拆开」开始。
2. 先给角色表,再给流程图,再逐项解释。`plugin-system.md` 的角色表是「插件 / 依赖 / 三时机」;`mover.md` 的角色表是 `IObjectMovable` / `IObjectMover` / `IMoverController`;`face-handler.md` 的角色表是五个组件。
3. 强调「分工」与「不变量」。`declarative-vs-imperative.md` 用「执行时机由谁决定」贯穿全篇;`saveable-content.md` 反复强调「只存重建必需的数据」「返回值必须独立」「读档顺序不保证」;`mover.md` 反复强调「移动器只算落点,不判断通行」。
4. 示例是完整类或完整函数,注释很密。`saveable-content.md` 给 `MySaveable`、`MyPlugin` 两个完整类;`mover.md` 给完整的 `SimpleMover`;`face-handler.md` 给完整的 `HexFaceHandler`。
5. 「为什么这样设计」集中成段。`plugin-system.md` 的生命周期一节先给时间线,再解释每个时机为什么不能互换;`mover.md` 的「边界与注意」把「start 返回 null 时步骤还在队列里」「执行期间链式方法无效」讲成因果,而不是命令。
6. 边界与小结分开。高级 I 几乎每篇都有 `## 边界与注意` 和 `## 小结`,边界列真实失败模式,小结只回收 4–6 条结论。
7. 「怎么验证」不是每篇都有,但有验证的篇目会给可执行函数。`mover.md` 的验证函数会打印钩子顺序、坐标与 `done`;`saveable-content.md` 的验证函数做「存档 → 改状态 → 读档 → 比较」。
### A3 高级 II 的写法
高级 II 比高级 I 更接近「改造一个具体系统」,因此多了三种结构:
1. 「参与对象与分工」表。`pathfinding.md` 用一张表列出 `IPathfindingSystem` / `IPathFinder` / `IMapGraphBuilder` / `IPassPredicate` / `IObjectMover` / `IPathfindingController`,每个对象一行职责。
2. 「完整流程」编号步骤 + mermaid。`pathfinding.md` 的四段流程、`enemy-context-and-combat.md` 的 `buildup` 五段,都是「编号步骤 + 一段解释 + 一张图」。
3. 「同一件事的多个视角」对照。`pathfinding.md` 的 `moveTo` vs `teleportTo`、`static-vs-dynamic-tiles.md` 的静态 vs 动态、`equipment-system.md` 的 `getEquipped` vs `compareEquip`。
4. 示例之后给「怎么验证」与「改什么」。高级 II 仍保留任务型示例,但示例通常是完整类(`AtkBonusSorter`、`NoTilePassPredicate` 一类)。
5. 把「设计原因」写成一句可复述的规则。例如 `enemy-context-and-combat.md` 的「不要直接改 `getBaseEnemy()`」「新光环需要全量重建」;`equipment-system.md` 的「装备加成只能由 equip / unequip 维护」。这些句子是读者以后排查问题的抓手。
### A4 可直接模仿的具体写法
1. 开篇 2–4 句:一个玩家可见或工程可见的问题 → 本篇讲什么 → 本篇不讲什么、去哪里看。
2. `## 系统模型`:一张角色表 + 一张流程(mermaid 或 text),表里只放「角色 / 职责」,不放成员清单。
3. 每个机制小节的第一段先回答「它在整个流程里的位置」,再给接口。
4. 接口只列「读者会碰到的成员」,并在表前写一句「这里只列常用的,完整签名见 API 参考」。
5. 示例前先写「这个例子要解决什么」,示例后用 2–4 句解释「它声明了什么、实现了什么、在哪里接入」。
6. 关键不变量用加粗短句或独立段落,例如「录像只记录入口动作,不记录派生结果」。
7. 「为什么这样设计」用 2–3 段,不用源码口吻;讲取舍与后果,不讲私有字段。
8. 结尾固定:`## 怎么验证`(3–8 行可执行片段或检查步骤)→ `## 边界与注意`(5–6 条)→ `返回[高级 III 导览](./index.md)`。
9. 跨页引用尽量带锚点(D-74),例如 `../i/mover.md#勇士的顶层实现`。
10. 不在正文写「本节使用概率低」「源码第 X 行」「以后渲染端会讲」这类元信息。
### A5 当前高级 III 的反模式
1. 用成员表替代模型。`replay-internals` 直接给 `IReplayArray` 的 15 个成员,读者知道有哪些方法,却不知道「为什么录像要分成指令区 / 参数区 / 索引区」。
2. 删掉「为什么」只留「是什么」。`core-state` 当前只讲四层和固定顺序,删掉了加载管线、存档挂载与第二实例,读者无法判断「新增一个可存档系统要动哪里」。
3. 把「两个实例」「两个阶段」「三层作用域」这类最重要的对照删掉。`passability` 当前没有「同一契约、两个实例」,`plugin-internals` 当前没有三阶段的等待语义细节,`anon-tokyo` 当前没有「与事件对象的衔接」。
4. 示例变成接口演示。`event-system` 当前只给一个 `EventHealHero`,没有展示「收集 → 执行」的完整链路;`replay-internals` 当前的 `PlayerInputRecorder` 只展示 `record`,没有展示播放时如何避免再次录制。
5. 章节顺序不稳定。当前 `core-state` 先讲「为什么分四层」再讲「每层职责」再讲「不能反向依赖」再讲「固定顺序」,同一件事被拆成四段;`plugin-internals` 把「依赖排序」放在「状态机」之前,但依赖排序是内部实现,按 D-69 应后置。
---
## B. 逐页重写大纲
> 通用约束:每页开头写清「本篇只讲 X;Y 见某页」;每页只保留 5–6 条文末边界;示例必须是完整类 / 完整函数;不使用「模板」「[需改]」这类高级教程已禁用的标记(D-83);不写源码叙述(D-80);不写用户问题复盘(D-81)。
### B0 `index.md`
**读者与目的**:已经读完高级 I / II、现在需要判断「一个改动该落在装配、事件、寻路、录像还是加载」的作者。这一页只做导航与边界,不复述任何系统细节。
**当前问题**:当前只有「前置 / 七篇地图 / 阅读路径」三节,删掉了「给谁」「与 I/II 的区别」「不覆盖什么」「跨页约定」「自检」;地图表的顺序(加载在前)与阅读路径(CoreState 在前)互相矛盾,读者第一次进入会先读加载系统,却还没有四层装配与插件生命周期的心智模型。
**章节大纲**
1. `## 高级 III 是给谁的`:4 条具体能力(能读类型签名;需要判断改动落点;要排查存档/回放不一致;接受固定顺序),不要写成泛泛的「高级读者」。
2. `## 与高级 I、高级 II 的区别`:三行表说明「I 讲扩展机制怎么接、II 讲具体系统怎么改、III 讲为什么这样拆 / 数据怎么流 / 失败在哪里」。明确「分层是难度标签,不是领域分类」。
3. `## 前置`:四个必读链接(插件系统、可存档内容、录像一致性、自动寻路)+ 一句「CoreState 是本层多数页的隐性前置」。不要罗列全部 I/II 篇目。
4. `## 八篇地图`:8 行表(本页 + 7 篇),列「篇目 / 它回答的问题 / 什么时候读」。顺序按阅读主线:CoreState → 插件内部 → 事件 → Anon-Tokyo → 通行与寻路 → 录像 → 加载。不要按「数据流从加载开始」排表,否则与阅读路径冲突。
5. `## 阅读路径`:三条线。原理线按上面的顺序;按需线先读 CoreState 再跳;排查线给三个入口(回放不一致 → 录像 → CoreState 存档挂载;事件不触发 → 事件 → Anon-Tokyo;寻路能走但勇士不能走 → 通行页的「两个实例」)。
6. `## 跨页约定`:一张「概念 → 归属页」表(见 C1)。这一节是防止重复的关键。
7. `## 本层不覆盖什么`:渲染端结构、编辑器与内容配置、API 签名清单、legacy 包;旧引擎只作为对比基线。
8. `## 怎么验证你读懂了`:6 条自检,分别对应 6 个系统(装配顺序、插件阻断、事件组序、两个谓词实例、录像按步号读取、加载三阶段)。
**核心模型与理由**:导览页的模型是「层与页的映射」:每一页负责一个不可再分的问题域,页与页之间只通过明确的接口相接。它不解释任何系统,只让读者在正确的时间翻到正确的页。
**示例**:不放代码。可以用一张 mermaid 阅读路径图,但表与图只能留一个,避免重复。
**接口引入**:不引入接口;只出现篇目名与概念名。
**删除**:删掉「七篇地图」的错误计数与「加载在前」的表序;删掉只有三节的骨架;不要保留「本页是第八篇」这类计数句。
**边界(保留 5 条)**:本层不写渲染端与编辑器;不重复 I/II 的用法;不做 API 全量参考;旧引擎只作对比基线;篇目顺序按阅读主线,不按模块目录。
**链接**:7 篇正文 + `../i/` + `../ii/` + 中级相关篇目(按需)。
**怎么验证**:不设验证节;自检清单替代。
---
### B1 `replay-internals.md`
**读者与目的**:要新增录像行为、排查回放不一致、或理解「为什么录像能跨机器重放」的作者。这一页讲存储、录制入口、播放沙箱与存档形状;「哪些方法该登记」在高级 I,「怎么新增一条指令」在高级 II。
**当前问题**:当前页把 `IReplayArray` 的成员表当成主体,删掉了「存档形状」与两节内部实现;没有讲「玩家移动是在移动器里记录的」「播放必须禁用录制」「`finalize` 为什么用于连续移动」这三个决定行为的点。读者读完仍不知道录像为什么能重放、播放为什么会自己坏掉。
**章节大纲**
1. `## 开篇`:问题(录像不是状态快照,而是按顺序重放的输入序列)→ 本篇讲存储 / 录制 / 播放 / 存档 → 不写「该不该记」(链接高级 I)与「新增指令」(链接高级 II)。
2. `## 系统模型`:三角色表 `ReplaySystem` / `ReplayArray` / `ReplaySandbox`,加 `IReplayCommand`。关键不变量:录像数组只存指令码与原始参数,指令对象在运行时注册表里,所以录像可以脱离代码对象序列化。给一张 text 流程:`record → 指令区 / 参数区 / 索引区;createReplaySandbox → 读流 → 查表 → 主动执行 / 被动交给外部`。
3. `## 一条录像步是什么`:`code` + `params`;参数只支持四种原始类型(number / string / boolean / bigint);对象、数组、函数、undefined 会被丢弃且不留占位。解释「为什么参数必须可编码」:录像要跨机器、跨会话;参数类型码只增不改。
4. `## 录制发生在哪里`:先讲 `record()`,再讲一个更容易被忽略的事实:玩家移动不是由业务代码调 `record`,而是移动器在每一步开始时写入方向指令;事件脚本里的 `moveHero` / `stepHero` 会临时禁用录制。解释「为什么在步进开始时记录」:即使这一步最终不能走,回放也要重放同一次尝试,才能得到同样的状态与同样的告警/事件。补一句 `@shouldReplay` 只做检查、不写录像(链接高级 I)。
5. `## 主动与被动`:两类指令;主动步的 `execute` 返回 `Success` / `Failed` / `Ignored`;被动步必须在主动步执行期间通过 `getPassive()` 取走并 `next()`,否则沙箱告警并跳过。解释「为什么被动步必须紧跟主动步」:被动步的参数只有主动步知道什么时候该消费。
6. `## finalize 与连续移动`:`finalize` 在指令切换或录像结束时触发;给出内置 `ReplayMove` 的真实形态:每步只 `mover.step(dir)` 入队,`finalize` 才 `mover.start()`。解释「为什么这样做」:连续移动只启动一次移动器,动画连续,回放不会逐步打断。
7. `## 播放沙箱`:`createReplaySandbox({ route, reseter, startIndex?, save? })` 先 `reseter.reset(save)`,再创建读取流;`play` / `pause` / `resume` / `stop` / `step` / `setSpeed` / `getReplayed`。必须单独讲:播放期间要 `replaySystem.disable()` / `revert()` 包住,否则移动器会再次写入录像,任何写入都会让当前读流 `expired`,回放会在中途停住。给一个完整的播放函数示例。
8. `## 存档形状`:四个字段 `length` / `commandWidth` / `commandArray` / `paramArray`;`saveState` 返回的是缓冲区副本(当前实现会切片),所以长期保存安全,但不要把它当成「活的录像」;`loadState` 会重建索引并让旧读流过期,正在播放的沙箱要重建。一句「录像作为可存档对象由主插件注册」并链接 CoreState 的存档挂载。
9. `## 为什么是二进制`:保留 3–4 条优势(紧凑、类型保真、可按步号定位、不依赖地图编号),用 3 行对照表,不展开旧引擎 API。旧引擎只作为「为什么不是字符串数组」的基线。
10. `## 可扩展点`:`registerCommand(code, command)` + 链接高级 II;`IReplayArray` 的插入/删除/改写只适合录像编辑器,讲清线性成本与越界告警即可。
11. `## 怎么验证`:录两步 → `saveState` → `loadState` → 用 `disable/revert` 包住播放 → 比较 `length`、终点坐标与关键状态;再故意不 disable,观察读流过期(只作为理解,不作为推荐做法)。
12. `## 边界与注意`:6 条。
13. `返回[高级 III 导览](./index.md)`。
**核心模型与理由**:录像是「输入日志」而不是「状态快照」。因此:入口必须记录玩家意图,派生结果在回放时重算;指令对象不能进录像;读流是只读视图,任何写操作都要让它失效;播放本身不能再录。这四条是整页的骨架。
**示例**:`PlayerInputRecorder`(完整类,展示玩家入口「先 record、再执行」);`playRoute`(完整函数,展示 `createReplaySandbox` + `disable/revert` + `play` + 等待结束 + `releaseSandbox`);可选 `ReplayChoiceConsumer`(完整类,展示被动步的 `getPassive` / `next`)。如果篇幅超限,用 6–8 行说明替代,不删「播放禁用录制」的例子。
**接口引入顺序**:`ReplayCode` → `ReplayParamValue` → `IReplayStepHandler` → `ReplayCommandType` / `ReplayCommandResult` → `IReplayCommand` → `IReplayArray`(只列 `length` / `get` / `createReadStream` / `disable` / `revert`)→ `IReplaySandbox` / `IReplayPassiveHandler`。不要一上来给 `IReplayArray` 全表。
**删除**:删掉当前 `IReplayArray 与存储格式` 的 15 行成员表(改成「常用成员 + 三块存储」);删掉「沙箱通常由系统内部创建」(实际由调用方创建);删掉「参数支持 … 需要时使用的 bigint」这种含糊说法;删掉「与旧引擎的区别」的 3 行泛表,改为「为什么是二进制」的短论证。
**边界(保留 6 条)**:参数超过 255 个会被截断并告警;不支持的类型会被丢弃,参数个数随之变化;`get(index)` 是随机读,不适合放进播放循环;插入 / 删除 / 改写是线性成本,越界不修改并告警;`bigint` 幅值有上限(小于 2 的 2040 次方);缩窄指令宽度会截断超过 255 的指令码。
**可丢弃的边界**:「录像不带指令对象」(属于模型);「被动步必须 next」(属于主动/被动小节);「旧格式不保证读回」(对比节正文一句即可)。
**链接**:`../i/replay-consistency.md`、`../ii/custom-replay-command.md`、`./core-state.md#存档挂载`、`../i/mover.md`。
**怎么验证**:见第 11 节;验证函数要包含「播放前 disable、播放后 revert」这一对,否则示例本身会误导。
---
### B2 `event-system.md`
**读者与目的**:要挂事件、改收集顺序、新增内建函数的作者。这一页讲「事件怎么被收集、过滤、执行」;脚本怎么编译与执行在 `anon-tokyo`。
**当前问题**:当前页删掉了 env 字段表与完整收集示例,也删掉了「`EventExecutor.execute` 带录像标记」这一事实;「事件层就是 alias 为 event 的普通图层」说得过满(`setEventLayer` 可指定,alias 只是内容加载时的默认约定);「事件由内容侧注册」在当前仓库没有对应加载路径,不能写成既有行为。
**章节大纲**
1. `## 开篇`:事件把「一段脚本」和「什么时候跑」接在一起;本篇讲仓库、收集、执行、触发器与内建函数;脚本编译见 Anon-Tokyo;编辑器注册不在本篇。
2. `## 系统模型`:五角色表(`GameEventStore` / `GameEvent` / `GameEventSystem` / `EventExecutor` / `AnonTokyoInterpreter`)+ text 流程。关键不变量:收集阶段只认坐标与图层,执行阶段才认事件对象与触发器;收集结果是 `{ id, env }`,不是事件实例。
3. `## 事件对象与触发器`:`GameEvent` 的 `trigger` / `rawEvent` / `compiled`,`compile` 缓存,`setRaw` 清缓存,`setTrigger`。`EventTrigger` 11 个值的表 + 「当前数据端派发」列:只有 `OnTouch` / `OnEnter` / `OnLeave` 有派发点(勇士移动的 hit / enter / leave),其余是枚举保留。解释「为什么触发器过滤不放在收集器」:同一坐标可能因不同时机被收集,收集器不应知道事件脚本的语义。
4. `## 收集:这一次该执行谁`:`collectEvent(layer, trigger, x, y)` 的边界(越界返回空);点事件来自 `layer.getPointEvent`,图块事件来自静态图块与每个动态图块的 `tileEvent()`;两组各自降序、点事件整组在前。解释「为什么整组优先」:点事件描述「这个坐标发生什么」,图块事件描述「这个物体发生什么」;跨组比较优先级会让物体事件压过坐标语义,难以推理。给 env 字段表(state / type / trigger / system / heroLocator / heroFloor / triggerLocator / tile / layer / map),并注明 `env.state` 的类型宽度是 `IStateBase`,拿不到 L3 的 loader 等成员(链接 CoreState 的类型分层)。
5. `## 执行与折叠`:`execute` 的三步(仓库/事件缺失告警并跳过;trigger 不匹配静默跳过;`await event.execute`);`EventExecuteMode` 三种短路;`EventReduceMode` 三种折叠;非布尔值告警但按短路规则继续。讲清楚:`mode` 与 `reduce` 是执行器上的共享状态,不是每次调用的参数,改完要复位。`EventExecutor.execute` 带 `@shouldReplay`,含义是「执行事件这条路径必须产生录像记录」,不是自动写录像。`onBeforeExecuteEvent` / `onAfterExecuteEvent` 是同步钩子;任意事件拒绝会让 `execute` 拒绝,后续调用项与 `onAfterExecuteEvent` 不再执行。
6. `## 两种 param 不是一回事`:`IBlockEventParam`(`{ custom }`)会进入脚本的 `scope.args`;内建函数的 `param` 是 `Call.parameters` 求值后的对象。两者不会自动传递。这部分可以并入执行小节,但必须保留。
7. `## 事件层`:默认事件层由内容里 `alias === 'event'` 的图层登记(`MapState.fromRaw`),也可以用 `setEventLayer` 显式指定;它仍是普通图层,具备图块、点事件、图块事件、存档与钩子。不要写「事件层一定是 alias event」。
8. `## 自定义一个内建函数`:完整类 + 参数接口 + `createEventRegistrations`;`env.state` / `env.layer` / `env.map` / `env.trigger` / `env.tile` 的用法;`getPossibleLayer` / `getPossibleMap` 的兜底顺序。不要重复中级教程的六步实操。
9. `## 怎么验证`:写一个 `OnEnter` 事件,调用 `collectEvent` + `executor.execute`,断言块被改 / flag 被写;再故意把 trigger 改成不匹配,确认静默跳过。
10. `## 边界与注意`:6 条。
11. `返回[高级 III 导览](./index.md)`。
**核心模型与理由**:事件系统是「坐标 → 候选 id → 事件对象 → 解释器」的管道。它的设计取舍是:收集便宜且通用,执行才做语义判断;事件对象缓存编译产物;执行器把「执行策略」与「事件内容」分开。读者只要抓住「收集不筛触发器、执行才筛」,就不会把顺序与过滤混在一起。
**示例**:`EventHealHero`(完整类,展示内建函数形状与 `env.state`);`runEventsAt`(完整函数,展示 `collectEvent` + `executor.execute` 的完整链路);保留 `createEventRegistrations` 的完整函数。
**接口引入顺序**:`EventTrigger` → `GameEvent` / `IReadonlyGameEvent` → `IGameEventInvocation` → `IBlockEventEnv` → `IBlockEventParam` → `EventExecuteMode` / `EventReduceMode` → `BuiltInFunction`。env 表放在收集小节,不要放在开头。
**删除**:删掉「事件由内容侧注册进 state.eventStore」这种没有当前实现支撑的绝对句(改为「登记接口是 `eventStore.addEvent`;谁在加载期登记属于内容加载,不在本篇」);删掉「事件层就是 alias 为 event 的普通图层」的绝对化表述;删掉「执行器状态改完记得复位」以外的泛泛提醒。
**边界(保留 6 条)**:坐标越界返回空列表,不告警;点事件与图块事件互不替代;同一优先级重复设置会覆盖并告警 136;事件 id 未注册时执行器告警 171 并跳过;触发器不匹配时静默跳过,不告警;执行器 `mode` / `reduce` 是共享状态,改完要复位。
**可丢弃的边界**:「多条事件异步顺序执行」(放到执行小节正文);「短路模式后面的条目不再执行」(属于 mode 表);「事件注册属于内容配置」(放到开篇范围句)。
**链接**:`./anon-tokyo.md`、`../ii/point-and-tile-events.md`、`../i/data-stores.md`、`../../intermediate/events.md`、`../i/mover.md#勇士的顶层实现`。
**怎么验证**:见第 9 节;验证重点是「收集顺序」与「trigger 过滤位置」,不是再写一个业务事件。
---
### B3 `anon-tokyo.md`
**读者与目的**:要写复杂事件脚本、排查编译错误、理解脚本执行模型的作者。这一页讲语句、作用域、编译产物、执行上下文与迭代;事件怎么收集见事件系统。
**当前问题**:当前页删掉了「与事件对象的衔接」与独立的「内部实现:编译与执行」,把作用域并入语句模型;`save` 字段、`dump()`、`Exit` vs `Return` 的边界也没有了。读者不知道编译产物由谁缓存、为什么 `Call` 的返回值拿不到、为什么某些脚本写法会出错。
**章节大纲**
1. `## 开篇`:脚本在数据里是结构化语句数组,不是字符串;解释器把语句编译成程序,再由上下文与迭代器求值;数据端只负责把事件对象交给它。
2. `## 系统模型`:角色表 `LanguageFeature` / `AnonTokyoInterpreter` / `AnonTokyoExecutable` / `AnonTokyoExecutionContext` / `AnonTokyoIterator` / `Statement` / `Scope` / `OP` + text 流程。关键不变量:编译产物可复用,执行上下文不能复用;一次执行一个上下文,上下文里是调用栈与环境。
3. `## 语句模型`:`StatementType` 表;只把可执行语句讲清楚:`Expression` / `Call` / `Return` / `If` / `Switch` / `Loop` / `Break` / `Continue` / `Exit`。`Block` 不是独立对象,是 `Statement[]`;`FunctionDeclare` 只存在于枚举里,当前可执行语句联合没有它。不要暗示「可以声明局部函数」。
4. `## 作用域`:`args` / `local` / `env` 三层;`args` 是调用参数,`local` 是本次迭代器自己的局部变量,`env` 沿调用链共享。解释「为什么没有声明语法」:局部变量就是 `local` 上的字段;不要把参数放进 `local`,也不要把局部状态放进 `env`。
5. `## 编译与执行`:`compile(script)` 做控制流分析并生成程序节点;内建函数在编译期按名字解析(缺失直接抛错);全局函数在解释器构造时先编译,调用时按名字查表(缺失抛错)。`exec` 创建上下文,迭代器反复求值当前节点,按 `OP` 推进:`Move` / `Call` / `Return` / `Exit`。`Call` 是语句,不是表达式,内建/全局的返回值不会写回作用域;需要计算值时用 `Expression` 存到 `scope.local`。
6. `## 与事件对象的衔接`:`GameEvent` 持有 `rawEvent` 与 `compiled`;`compile` 缓存;`execute` 优先用缓存,`setRaw` 清缓存;解释器不收集坐标、不判断触发器、不查事件仓库。解释「为什么同一解释器可以被多个事件共享」:编译缓存属于事件对象,运行上下文属于每次执行。
7. `## 自定义内建函数与全局函数`:`LanguageFeature` 两张表;内建函数是数据端最常用的扩展点(链接事件系统);数据端当前把 `config.events` 作为内建函数表,全局函数表为空,标准装配没有暴露全局函数配置入口。不要给 `save` 赋予存档/录像语义。
8. `## 内部实现:编译与执行`(D-69,放后):编译期把语句展开成带跳转的流程节点,再生成按编号索引的程序节点;相邻且可合并的节点会被合并执行。这里要讲一条作者能感知的约束:相邻节点会被合并执行,合并后的表达式不能依赖定义处的外部变量(闭包);需要的数据通过 `scope.args` / `scope.local` / `scope.env` 传入。不要写 `new AsyncFunction`、`toString` 这类源码叙述,只写行为约束。执行上下文没有可用的导出/转储能力。
9. `## 怎么验证`:编译一次、执行两次,确认两次 `args` 互不影响;故意写错内建函数名,确认 `compile` 阶段抛错;写一个 `Call` 后跟 `Return`,确认 `Call` 的返回值不会自动出现在作用域里。
10. `## 边界与注意`:6 条。
11. `返回[高级 III 导览](./index.md)`。
**核心模型与理由**:解释器的设计是「编译一次、执行多次、每次新建上下文」。这带来三个后果:编译期错误(内建函数名)早暴露;运行期错误(全局函数名、表达式异常)晚暴露;`env` 是唯一跨调用共享的通道,`args` / `local` 都是每次执行自己的。所有边界都从这三条推出来。
**示例**:一个完整的顶层文件:定义内建函数 `logValue`,构造解释器,编译包含 `Call` 与 `Return` 的脚本,执行两次并打印不同结果。保留逐行注释。可选:在同一脚本里加一个 `If`,展示 `branches` 与 `otherwise`;如果加,保持一个例子,不要拆成多个半截片段。
**接口引入顺序**:`Statement` / `StatementType` → `Scope` → `LanguageFeature` → `AnonTokyoInterpreter` → `AnonTokyoExecutable` → `AnonTokyoExecutionContext` / `AnonTokyoIterator` → `OP`。`ExecutionNode` 只在内部实现小节出现。
**删除**:删掉把「作用域」并进语句模型的写法(恢复独立小节);删掉「编译期先做控制流分析…」这种一句话带过的源码描述,改为内部实现小节的行为说明;删掉「`dump` 可用」的任何暗示(当前是空实现);删掉「`save` 字段」的未证实语义。
**边界(保留 6 条)**:语句是结构化对象,不是可解析字符串;`args` / `local` / `env` 不要混用;`Call` 是语句,返回值不会写回作用域;内建函数名在编译期解析,缺失直接抛错;全局函数名在执行到 `Call` 时才解析,缺失抛错;执行上下文没有导出/转储能力,`save` 字段没有额外语义。
**可丢弃的边界**:「`Exit` 与 `Return` 不同」(放到语句模型表);「解释器不负责派发」(放到衔接节);「修改原始语句要重新编译」(放到衔接节)。
**链接**:`./event-system.md`、`../i/data-stores.md`、`../../intermediate/events.md`。
**怎么验证**:见第 9 节;验证函数要展示「编译一次、执行两次」与「编译期抛错」两件事。
---
### B4 `passability-and-pathfinding.md`
**读者与目的**:要改移动规则、排查「寻路能走但勇士不能走」、或替换通行语义的作者。这一页讲谓词契约、默认实现、两个实例、三种移动结果、构图与搜索;`moveTo` / 损失函数 / 瞬移策略的用法在高级 II。
**当前问题**:当前页把十个章节压成五节,删掉了「同一契约、两个实例」「勇士移动的三种结果」「系统层做什么」;这三节恰恰是读者最容易踩坑的地方。当前页也没有说明默认谓词只读静态图块、动态图块不参与判断。
**章节大纲**
1. `## 开篇`:通行性谓词回答「能不能走进目标格」与「会不会撞上目标格」;勇士移动与寻路构图都用它;本篇讲契约、默认实现、两个实例与构图搜索。
2. `## 系统模型`:角色表 `IPassCheckHandler` / `IPassPredicate` / `DefaultPassPredicateImpl` / `MapGraphBuilder` / `PathFinder` / `PathfindingSystem` + text 流程(勇士移动一步 vs 寻路构图)。一句关键:寻路本身不判断通行,它只是反复调用谓词,把结果翻译成图的边。
3. `## 谓词契约`:`canPass` 与 `shouldHit` 是两个问题;`shouldHit` 为真不是「不能走」,而是「走得到但会撞」;`canPass` 为假才是真的进不去。`IPassCheckHandler` 的 `currLoc` / `nextLoc` / `direction` / `map` / `state`。谓词不保存「当前是哪个对象」,所以可以被两侧复用。
4. `## 默认实现怎么判断`:四步(取 Dir4 处理器并降级;事件层存在;事件层当前格 `outPass` 与目标格 `inPass`;其它图层只检查 `onlyEvents`)。方向位表:`outPass` 用移动方向,`inPass` 用反方向。空格不施加限制。`shouldHit` 只看事件层目标格,`eventPass === false` 时撞停。明确边界:只读静态图块,动态图块不参与判定;需要动态图块挡路时写自定义谓词。斜向输入只说「先降级到四方向再判定」,不要声称斜向有独立的通行位;默认玩法按四方向描述。
5. `## 同一契约、两个实例`:表格对照「勇士移动:`DefaultHeroMoveTopImpl` 构造时自建谓词,`predicate()` 返回它」与「寻路:`MainDataPlugin.awake` 另建一个,`finder.usePassPredicate` 注入」。结论:行为一致、对象不同、状态不共享;改一侧不会自动改另一侧,要一致就两边都换。说明「无状态谓词各建一个没问题,有状态谓词必须共享同一个实例」。
6. `## 勇士移动的三种结果`:`CannotMove` → `cannotEnter`(默认空实现)→ 停止,位置不变;`Hit` → `hit` → 触发 `OnTouch` → 停止;`Step` → 写入目标格 → 结算时先 `leave` 再 `enter`(`OnLeave` / `OnEnter`)。这一节把「撞击」与「进入」两种事件的来源讲清楚,链接事件系统。
7. `## 构图:从起点只保留可达节点`:`build(start)` 的前置(图层、谓词、方向处理器、起点在界内,缺一返回 `null`);BFS 用 `mapMovement()`,跳过零偏移;`canPass` 为假不建边也不扩展;`shouldHit` 建边并标终端;只有可达节点进图。损失函数按节点计算,未注入按 1;`NaN` / 负数回退 1,`Infinity` 是合法损失(这里要与高级 II 的「有限数」说法对齐,见 E3)。
8. `## 搜索:最小损失与终端约束`:`PathFinder.search` 只读 `cost` / `terminal`,不重新判断通行;终端只能作终点;四个状态 `Success` / `InvalidInput` / `NoPath` / `TargetUnder`;`NoPath` 是正常结果。
9. `## 系统层做什么`:`useMover` / `getPath`(从 mover 的 tile 取起点)/ `moveTo`(总是逐步)/ `teleportTo`(策略允许才瞬移)/ `interrupt`;忙碌时返回 `null`;瞬移跳过途经格。不要重复高级 II 的 `useCostFunction` / `useFallbackPolicy` 示例,只给职责与边界。
10. `## 换一套通行语义`:完整 `NoTilePassPredicate` 类(包住默认谓词,再叠加禁止图块);寻路侧 `finder.usePassPredicate(...)`;勇士侧 `useTopImplementation(...)` 或改默认实现。强调「只改一侧不会同步」。
11. `## 怎么验证`:用默认谓词判断一步(`cannotMove` / `hit` / `step`);再用 `getPath` 看状态;最后 `moveTo` 并等待 `onEnd`。
12. `## 边界与注意`:6 条。
13. `返回[高级 III 导览](./index.md)`。
**核心模型与理由**:整个页面的核心是「一个契约、两个消费者、两条路径」。契约必须无状态,才能让两个消费者各自持有实例而结果一致;一旦自定义谓词带状态,就必须把同一实例注入两侧。构图只保留可达节点,是为了让搜索不必处理不可达区域;终端标记把「走得到但会撞」编码进图,而不是在搜索时重新判断通行。
**示例**:`judgeStep`(完整函数,展示用默认谓词判断一步的三种结果);`NoTilePassPredicate`(完整类,展示「包住默认谓词 + 叠加规则」);两条注入片段(`finder.usePassPredicate` 与 `useTopImplementation`),写成完整函数。
**接口引入顺序**:`IPassCheckHandler` → `IPassPredicate` → `PassBit` / `ITilePassData`(解释默认实现时才出现)→ `MapGraphBuilder` → `PathFinder` → `PathfindingSystem`。`ILayerLocation` 只在损失函数一句里出现。
**删除**:删掉当前「契约」小节里「同一套规则算出」的绝对句(改为「在默认装配下」);删掉「把传入方向降级到四向」后直接讲方向位的误导(补一句「默认玩法按四方向」);删掉「构图、搜索与移动」把三件事混在一节的写法;恢复「两个实例」「三种结果」「系统层」。
**边界(保留 6 条)**:两个实例不共享,改一侧不会自动改另一侧;图层必须属于同一数据端,否则告警并忽略绑定;未注入方向处理器时构图失败,`find` 得到 `InvalidInput`;起点越界会告警并返回失败;终端只能作终点,不能作中间点;默认实现只读静态图块,动态图块不参与,需要时写自定义谓词。
**可丢弃的边界**:「无路径是正常结果」(放到搜索小节);「目标在脚下不移动」(放到状态表);「换楼层要重绑」(放到系统层或 CoreState)。
**链接**:`../ii/pathfinding.md#自定义损失函数`、`../ii/pathfinding.md#自定义瞬移策略`、`../i/mover.md#勇士的顶层实现`、`../i/face-handler.md#用在哪里`、`./event-system.md`、`./core-state.md`。
**怎么验证**:见第 11 节;验证要覆盖「默认谓词的三结果」与「换谓词后寻路状态变化」。
---
### B5 `core-state.md`
**读者与目的**:要新增子系统、新增可存档对象、调整启动流程、或在游戏内创建第二个数据端实例的作者。这一页是整层的组合根:四层装配、入口、加载管线、存档挂载、第二实例。
**当前问题**:当前页只剩「为什么四层 / 每层职责 / 不能反向依赖 / 固定顺序 / 入口」五节,删掉了加载管线、存档挂载、第二个实例与内部装配顺序;读者无法回答「`load()` 到底做了什么」「五个存档 id 是哪五个」「为什么读档不会重跑插件 afterLoad」「怎样在 Node/游戏内再建一个数据端」。
**章节大纲**
1. `## 开篇`:`CoreState` 是数据端的组合根;构造它时按层装配、注册插件并排序、同步 `awake`;调 `load()` 时加载按固定次序推进。本篇讲装配与生命周期;插件怎么写见高级 I,存档形状见高级 I,插件排序细节见 plugin-internals。
2. `## 系统模型`:四层表(L0 公共数据 / L1 状态基座 / L2 系统层 / L3 顶层)+ 依赖方向图。补一张「类型宽度」表:`IDataCommon` → `IStateBase` → `IStateSystem` → `ICoreState`,说明运行时是同一个对象,编译期成员逐层增加;这正是「为什么某些扩展点的 `state` 看不到 `loader`」的原因。这一节回答「改动该落在哪一层」。
3. `## 为什么分成四层、为什么不能反向依赖`:合并当前的「为什么」与「不能反向依赖」。讲三条后果:循环引用检查会报错;低层构造时高层还没建好,只能拿到 `undefined`;低层绑定高层后无法独立替换/测试。不要重复「每层职责」的列表。
4. `## 入口的职责`:`src/data.ts` 只 re-export;`entry.ts` 的 `createCoreStateConfig(starter)` 组装四个字段(`loadStarter` / `coreURL` / `dataPlugins` / `events`),`dataPlugins` 把 `MainDataPlugin` 放在最前再展开业务插件;`createCoreState(starter)` 返回尚未异步初始化的实例。强调 `MainDataPlugin` 只是列表里的第一个插件,排序仍由 `dataDeps` 决定(链接 plugin-internals)。
5. `## 加载管线`:`state.load()` 是异步迭代器:`await initialize()` → `yield* loader.start()` → `await afterLoad()` → 检查所有插件是否 `Loaded`。解释每一步为什么在这个位置:`initialize` 在加载前,仍可追加配置;`afterLoad` 在加载后,核心配置、地图与内容才可读;最后一步只做检查。强调:`load()` 只负责初次加载,玩家读档不会重跑;必须消费迭代器,否则加载不开始。
6. `## 存档挂载`:`addSaveableContent(id, content)`;五个内置 id(`@system/hero` / `@system/flags` / `@system/maps` / `@system/enemy` / `@system/replay`);`bindSaveableExecuter` 的对象/id 两种写法;`loadState` 先清录像禁用标记,再对每个对象 `disable()` → `loadState` → `revert()`,执行器在包裹之外。解释「为什么执行器不在包裹内」:执行器可能需要做出需要记录的行为。顺序不保证,重复 id 告警 112,缺 key 177,多 key 178。存档形状与三级压缩链接高级 I,不在这里重讲。
7. `## 在游戏内另建一个数据端实例`:构造器不依赖全局单例;传完整 `ICoreStateConfig` 即可得到独立实例;与主实例不共享 hero / maps / eventStore / 存档。给完整函数 `createSecondaryState`(含 `for await` 消费 `load()`)。用途:回放对比、离线校验、测试。不要建议读者重排构造顺序。
8. `## 内部实现:装配顺序`(D-69,放后):精确 L0 → L1 → L2 → L3 的创建顺序与同层依赖(`tileStore` 先于 `itemStore` / `enemyStore`;`maps` / `hero` 回指主对象;`eventSystem` 接收 `config.events`;`loader` 依赖 `loadManager` 并登记核心配置)。插件注册、去重、`orderPlugins`、`awake` 都在四层之后。不要写私有字段名。
9. `## 怎么验证`:创建一个第二实例,检查 `hero` / `eventStore` 不共享;检查五个存档 id 都存在;检查插件状态在 `load()` 后为 `Loaded`。
10. `## 边界与注意`:6 条。
11. `返回[高级 III 导览](./index.md)`。
**核心模型与理由**:`CoreState` 是「对象图的唯一构造点 + 生命周期的唯一编排点」。四层不是目录分类,而是构造依赖的顺序;插件阶段放在最后,是因为 `awake` 可以看到整个数据端;加载与读档是两条不同的路径(`load()` 只管初次加载,`loadState` 管快照恢复)。这三条决定了所有扩展点的落位。
**示例**:`createSecondaryState`(完整函数,展示完整配置、`new CoreState`、消费 `load()`)。存档挂载不写完整 `ISaveableContent` 实现(高级 I 已讲),只给五个 id 表与一段「注册 + 绑定执行器」的短函数。
**接口引入顺序**:`ICoreStateConfig` → `ICoreState` / `IStateSystem` / `IStateBase` / `IDataCommon` → `IDataPlugin` / `PluginStatus`(只作为插件阶段的名词)→ `ISaveableContent` / `ISaveableExecutor`(只作为存档挂载的名词,细节链接高级 I)。不要在开头堆四个接口。
**删除**:删掉当前「每层职责」与「固定初始化顺序」的重复(合并成「层次 + 依赖」与「内部实现:装配顺序」两节);删掉只有五节的骨架;删掉「L3 只有 loader / loadManager」的暗示(还要讲 `getDataPlugin` / `load` / `bindSaveableExecuter`,但不必列全)。
**边界(保留 6 条)**:`load()` 必须被迭代,否则加载不开始;`ICoreStateConfig` 四个字段必填;重复的 saveable id 只保留第一个并告警 112;执行器绑定未注册对象会告警 113,按 id 找不到会静默返回;读档缺 key 告警 177,多 key 告警 178;`load()` 结束时状态不是 `Loaded` 的插件会被逐个告警。
**可丢弃的边界**:「读档期间录像被禁用」(放到存档挂载正文);「系统层不进存档」(放到四层表);「不要手动构造内部子系统」(放到边界最后一条或开篇范围句)。
**链接**:`./plugin-internals.md`、`./loading-system.md`、`../i/plugin-system.md`、`../i/saveable-content.md#注册到存档系统`、`../i/saveable-content.md#读档收尾与执行器`。
**怎么验证**:见第 9 节;验证要覆盖「第二实例独立」与「五个存档 id」。
---
### B6 `plugin-internals.md`
**读者与目的**:要写插件、排查插件未执行或阶段顺序问题的作者。这一页只讲机制:插件表怎么变成确定顺序、状态怎么推进、依赖写错会怎样;插件类怎么写见高级 I。
**当前问题**:当前页已经补上了「`MainDataPlugin` 不是隐式前置」这条重要警告,但把三阶段的依赖参数、`awake` 不等待、阶段失败的展开删掉了;「依赖排序」放在「状态机」之前,读者还没理解阶段就先读内部算法。
**章节大纲**
1. `## 开篇`:插件系统对使用者只暴露三件事(声明 `dataDeps`、在三个时机做事、按 id 取回);本篇讲背后的顺序、状态与失败;写法见高级 I。
2. `## 系统模型`:五段流程(插件表 → 依赖图 → 拓扑序 → 状态机 → 三阶段)+ 表。关键不变量:依赖图只由 `id` 与 `dataDeps` 构成,不读运行期状态;拓扑序在构造期算一次,三个阶段复用。
3. `## 三个阶段怎么执行`:三行表(时机 / 是否等待 / 适合做什么);每个阶段收到按 `dataDeps` 顺序解析的 `{ status, plugin }` 包装数组,取能力要 `.plugin` 并断言成对方接口;`awake` 的返回值不被等待,`async awake` 不会阻塞;`initialize` / `afterLoad` 会被 `await`。明确警告:`MainDataPlugin` 不是隐式前置,用到它注册的存档 id / 默认装配时必须在 `dataDeps` 里写 `'main-data-plugin'`。
4. `## 状态机`:四状态与推进条件;每个阶段只处理前一状态的插件;状态不对会跳过并告警;被阻断的插件永远停在 `NotAwakened`;状态只前进不后退,不支持运行期重置。
5. `## 失败会怎样`:表格化五类失败(环 76 / 缺失 77 / 重复 id 78 / 状态不符 80 / 启动后未完成 81);说明「不是抛异常,而是排除 + 控制台提示」;下游会被一起阻断。补一句:没有查询阻断原因的公开 API,`getDataPlugin` 看到 `NotAwakened` 无法区分「还没开始」与「被阻断」。
6. `## 取回一个插件`:`getDataPlugin<T>(id)` 返回只读包装或 `null`;先判空、再检查 `status === Loaded`;启动阶段优先用 `dataDeps` 让引擎保证顺序,运行期才用按 id 取回。给完整函数示例。
7. `## 内部实现:依赖排序`(D-69,放后):四步(缺失检测 / 环检测 / 阻断传播 / 拓扑输出);输出时按插件表的插入顺序扫描,互不依赖的插件保持列表顺序,因此「固定顺序」只对同一份列表成立;结果分三份(order / cyclic / missing),构造期只算一次。
8. `## 边界与注意`:6 条。
9. `返回[高级 III 导览](./index.md)`。
**核心模型与理由**:插件的「顺序」来自依赖图,不来自列表位置;列表位置只决定互不依赖插件的平局顺序。三个阶段共享同一份拓扑序,状态机保证每个插件每个阶段只执行一次;失败不是异常,而是把插件及其下游从执行集合里移除,所以作者看到的往往是「某些对象不存在」,而不是一条明确的错误。
**示例**:`useMyPlugin`(完整函数,展示 `getDataPlugin` 的判空 + 状态检查)。可选:一个最小的 `dataDeps` 声明类,只展示「依赖主插件要显式写 id」;如果篇幅超限,用一句正文 + 代码行替代,不要写成完整插件教程。
**接口引入顺序**:`IDataPluginDefine` / `IPluginBase` → `PluginStatus` → `IDataPlugin`(包装)→ `IPluginOrderResult`(内部实现小节)。不要在开头列 `orderPlugins` 的返回类型。
**删除**:删掉重复的「插件表 / 依赖图 / 拓扑序」表与 text 流程(保留一个);把「依赖排序」从状态机之前移到最后;删掉「失败会怎样」里已经在上文讲过的状态机内容;保留并突出 `MainDataPlugin` 警告。
**边界(保留 6 条)**:依赖在启动前固定,运行期修改 `dataDeps` 不会改变顺序;不支持运行期追加插件;环或缺失会阻断下游,不只是出错的插件本身;重复 id 只保留第一个;`getDataPlugin` 要判空并检查 `status`;`dataDeps` 与渲染端 `clientDeps` 是两套,不要混用。
**可丢弃的边界**:「状态不符会被跳过」(放到状态机);「`orderPlugins` 不供读者调用」(放到内部实现小节);「启动后未完成会告警」(放到失败表)。
**链接**:`../i/plugin-system.md`、`./core-state.md#加载管线`、`./core-state.md#存档挂载`。
**怎么验证**:把某个 `dataDeps` 改成不存在的 id,确认控制台出现 77、插件停在 `NotAwakened`;再在 `load()` 后检查 `getDataPlugin` 的 `status`。
---
## C. 跨页设计叙事
### C1 页面所有权矩阵
| 概念 / 问题 | 归属页 | 其它页只做什么 |
| --- | --- | --- |
| 四层装配、构造顺序、`load()` 管线、五个存档 id、第二实例 | `core-state` | 其它页只引用「已装配完成」这一前提 |
| 插件 `dataDeps`、拓扑序、状态机、失败模式 | `plugin-internals` | 高级 I 讲怎么写插件;`core-state` 只讲装配阶段 |
| 事件仓库、触发器、收集顺序、执行器、内建函数 | `event-system` | `anon-tokyo` 只讲脚本怎么跑;`passability` 只讲触发器由哪三种移动结果派发 |
| 语句、作用域、编译产物、执行上下文、`Call` 语义 | `anon-tokyo` | `event-system` 只讲 `GameEvent` 如何缓存编译产物 |
| 通行谓词、默认实现、两个实例、构图、搜索、系统层 | `passability-and-pathfinding` | 高级 II 讲 `moveTo` / 损失函数 / 瞬移策略的用法 |
| 录像数组、指令注册、录制入口、沙箱、存档形状 | `replay-internals` | 高级 I 讲该不该记;高级 II 讲怎么新增指令 |
| 三层加载、阶段、配置、自定义资源、进度 | `loading-system` | `core-state` 只讲 `load()` 的三步顺序 |
### C2 每页默认读者已经知道什么
- `core-state`:只需高级 I 的插件与存档名词;本页负责建立 L0–L3 与 `ICoreState` 的类型宽度。
- `plugin-internals`:默认读者知道怎么写插件(高级 I),本页只解释顺序与失败。
- `event-system`:默认读者知道 `state`、地图图层、`eventStore` 的基本用法;本页解释收集与执行。
- `anon-tokyo`:默认读者知道 `GameEvent` 与内建函数的存在;本页解释语言与执行模型。
- `passability-and-pathfinding`:默认读者知道地图图层、`IObjectMover`、高级 II 的寻路用法;本页解释契约与构图搜索。
- `replay-internals`:默认读者知道 `@shouldReplay` 与自定义指令的写法;本页解释存储、录制入口、播放与存档。
- `index`:不假设读者知道任何系统细节,只假设读完 I/II。
### C3 不得重复的内容
- 不在 `core-state` 重讲 `ISaveableContent` 的字段、三级压缩、`saveState` 返回值约束(高级 I)。
- 不在 `plugin-internals` 重讲插件类写法、`awake` / `initialize` / `afterLoad` 的入门解释(高级 I)。
- 不在 `replay-internals` 重讲 `@shouldReplay` 的放置规则、`ReplayCode` 的追加流程、自定义指令的完整示例(高级 I / II)。
- 不在 `passability-and-pathfinding` 重讲 `moveTo` / `teleportTo` 的完整用法、损失函数与瞬移策略的示例(高级 II)。
- 不在 `event-system` 重讲点事件 / 图块事件的读写接口(高级 II 已有专篇)。
- 不在 `anon-tokyo` 重讲内建函数的完整注册流程(事件系统已讲)。
- 不在 `index` 复述任何系统机制。
### C4 统一术语与阅读顺序
- 统一写「装配」表示构造期建对象,「加载」表示 `loader.start()`,「读档」表示 `loadState()`;不要用「初始化」同时指三者。
- 统一写「录像步」「指令」「沙箱」「读取流」;不要混用「录像条目」「命令」「播放器」。
- 统一写「通行谓词」「构图」「搜索」「终端」;不要混用「碰撞判断」「寻路图」「终点格」。
- 阅读主线:`core-state` → `plugin-internals` → `event-system` → `anon-tokyo` → `passability-and-pathfinding` → `replay-internals` → `loading-system`。`index` 的表与路径图都按这个顺序。
---
## D. 源码依据的设计笔记
> 以下内容用于写作时核对「为什么」与「不变量」;正文不要写文件路径与私有字段(D-80)。
### D1 录像
**为什么这样设计**
- 录像是输入日志:记录玩家意图与指令参数,不是最终状态。回放时重新执行同一套逻辑,派生结果自然重算。
- 指令对象不能进录像:录像要能存成二进制、跨机器读取;指令对象是运行时行为,所以 `ReplayArray` 只存 `code + params`,`ReplaySystem` 单独持有注册表。
- 格式遵循只增不改:`ReplayCode` 的数值与参数类型码都是格式的一部分,新语义必须新码。
- 读流是只读视图:`ReplayArray` 的任何写操作(`add` / `insert` / `delete` / `set` / `setCommandWidth` / `setReplayArray`)都会让已有读流 `expired`,防止「边改边读」得到不一致数据。
**关键生命周期**
1. 录制:`ReplaySystem.record(code, ...params)` → `ReplayArray.add`;参数被规范化,不支持的参数被丢弃且不留占位;`record` 会触发 `onRecordCommand` 钩子。
2. 玩家移动的特殊路径:`HeroMover.onStepStart` 在判断通行之前,直接把 `Up` / `Right` / `Down` / `Left` 写入 `replay.array.add`;瞬移写 `Teleport`。因此移动尝试即使失败也会被记录;`onRecordCommand` 不会为这条路径触发。事件脚本驱动的 `moveHero` / `stepHero` 会 `replaySystem.disable()` / `revert()`,避免把脚本移动记成玩家输入。
3. 播放:`createReplaySandbox({ route, reseter, ... })` 先 `reseter.reset(save)`,再建 `ReplaySandbox`;沙箱构造时 `route.createReadStream(startIndex)`。
4. 单步:沙箱在主动步执行前先读下一步(`appendingStep`),因为被动步必须在主动步执行期间被消费;主动步执行完,若下一步是被动步且没被取走,日志告警并跳过(code 194)。
5. `finalize`:当前主动指令与上一步不同,或读流结束时,调用上一条指令的 `finalize()`;`ReplayMove` 正是靠它把连续移动合并为一次 `mover.start()`。
6. 存档:`ReplaySystem.saveState` 返回 `{ length, commandWidth, commandArray: getCommandArray().slice(), paramArray: getParamArray().slice() }`;`loadState` 调用 `setReplayArray`,重建索引并使旧读流过期。
7. 禁用:`disabled` 是嵌套计数,`disable()` +1、`revert()` -1、`clearDisableFlag()` 归零;`CoreState.loadState` 在开始处清空,读档期间逐对象包裹。
**常见误解**
- 「录像记录的是结果」:错误;它记录入口动作与参数,结果在回放时重算。
- 「播放沙箱会自动暂停录制」:错误;沙箱不碰 `disabled`,播放方必须自己 `disable` / `revert`,否则移动器会再次写入并让读流过期。
- 「`saveState` 返回内部缓冲区」:当前实现是切片副本;不要按旧版文档写「直接引用」。
- 「`get(index)` 可以用来顺序播放」:它是随机读,播放应使用 `createReadStream`。
- 「`ReplaySystem.replaying` 会被自动维护」:当前没有代码设置它;不要在正文承诺它的语义。
### D2 事件
**为什么这样设计**
- 收集与执行分离:收集只依赖图层与坐标,可以在不知道脚本的情况下工作;执行才依赖事件仓库、触发器与解释器。
- 收集结果只带 `{ id, env }`:事件对象可能被替换、仓库可能被替换,收集阶段不应持有事件实例。
- 组序固定:点事件整组优先于图块事件整组,组内才按优先级降序;这是让「坐标语义」稳定压过「物体语义」,而不是让数值优先级跨组比较。
- 触发器过滤在执行器:同一坐标可能因 `OnEnter` / `OnLeave` / `OnTouch` 多次被收集,收集器只负责把时机写进 `env.trigger`。
- 执行器是共享对象:`mode` / `reduce` 是执行器状态,不是每次调用的参数。
**关键生命周期**
1. 事件登记:`GameEventStore.addEvent(id, event)`;重复 id 告警 170 并覆盖。
2. 事件编译:`GameEvent.compile()` 调解释器并缓存;`setRaw` 清缓存;`execute` 优先用缓存。
3. 收集:`GameEventSystem.collectEvent(layer, trigger, x, y)`;越界返回空;点事件 + 静态图块事件 + 动态图块事件;两组各自降序后拼接。
4. 执行:`EventExecutor.execute(invocations, param)`;仓库/事件缺失告警 171 跳过;trigger 不匹配静默跳过;`await event.execute`;按 `mode` 短路;按 `reduce` 折叠;调用 `onAfterExecuteEvent`。
5. 派发:`DefaultHeroMoveTopImpl` 在 `enter` / `leave` / `hit` 中调用 `collectEvent`,分别对应 `OnEnter` / `OnLeave` / `OnTouch`;`cannotEnter` 是空实现。
6. 内建函数:`BuiltInFunction { name, func(param, env) }`;注册进 `config.events`;`GameEventSystem` 构造解释器时传入。
**常见误解**
- 「事件层一定是特殊图层」:它是被指定为事件层的普通图层;内容加载时 `alias === 'event'` 会默认指定,`setEventLayer` 也可以改。
- 「收集时就按触发器过滤」:不会;过滤在执行器。
- 「`param` 就是事件参数」:不是;`param` 是 `Call.parameters` 求值后的对象,事件参数在脚本的 `scope.args`。
- 「`@shouldReplay` 会自动写录像」:不会;它只登记检查路径。
- 「所有 `EventTrigger` 都已派发」:只有 `OnTouch` / `OnEnter` / `OnLeave` 有派发点。
### D3 Anon-Tokyo
**为什么这样设计**
- 脚本是结构化数据,所以可以被内容系统序列化、被事件对象缓存编译结果;不需要在运行时解析文本。
- 编译与执行分离:`compile` 解析内建函数名并生成程序节点;`exec` 每次新建上下文,避免不同执行之间共享调用栈或局部变量。
- `Scope` 三层:`args` 是本次调用参数,`local` 是本次迭代器的局部变量,`env` 是调用链共享的环境;这个分层让参数、局部状态、外部上下文不会互相污染。
- `Call` 是语句而不是表达式:内建函数与全局函数的返回值不写回作用域;需要值就用 `Expression` 写入 `scope.local`。
- 编译期解析内建函数:名字写错在 `compile` 就失败;全局函数在解释器构造时编译,调用时才查表。
**关键生命周期**
1. 构造解释器:`new AnonTokyoInterpreter({ builtInFunctions, globalFunctions })`;内建函数装表,全局函数立刻编译。
2. 编译:`compile(script)` 做控制流分析,生成按编号索引的程序节点;遇到 `Call` 且 `builtIn` 为真时,编译期就解析内建函数。
3. 执行:`executable.exec(parameters, env)` 新建上下文;上下文持有调用栈与 `env`;迭代器持有 `args`、`local` 与当前节点。
4. 推进:节点返回 `Move` / `Call` / `Return` / `Exit`;`Call` 把控制权交给全局函数(新迭代器压栈),`Return` 记录返回值,`Exit` 结束整个执行。
5. 事件衔接:`GameEvent.compile` 缓存 `AnonTokyoExecutable`;`GameEvent.execute` 优先用缓存;`setRaw` 清空缓存。
**常见误解**
- 「脚本是可解析的字符串」:不是;它是 `Statement[]`。
- 「`Call` 的返回值会出现在后续语句」:不会;`Call` 是语句,值要自己写进 `scope.local`。
- 「`local` 与 `env` 可以混用」:不要;`local` 每次执行独立,`env` 沿调用链共享。
- 「表达式的闭包一定会被保留」:相邻节点会合并执行,合并后的表达式不能依赖定义处的外部变量;数据通过 `scope` 传。
- 「`save` 字段会自动存档/录像」:当前没有语义。
- 「执行上下文可以导出」:没有可用的导出/转储能力。
### D4 通行性与寻路
**为什么这样设计**
- 一个契约:`canPass` 与 `shouldHit` 同时服务勇士移动与寻路构图,保证「寻路选出的路」与「勇士实际能走的路」在默认装配下一致。
- 两个问题:`canPass` 是「进不进得去」,`shouldHit` 是「进得去但会不会撞」;把两者分开,才能让「带事件的墙」既不可进入、又能在寻路里作为目标终端。
- 两个实例:`DefaultHeroMoveTopImpl` 自建一个默认谓词,`MainDataPlugin` 为 finder 另建一个;行为一致、对象不同、状态不共享。
- 构图只保留可达节点:搜索不必处理不可达区域;终端标记把「走得到但会撞」编码进图,搜索不再重新判断通行。
- 损失与通行分开:损失函数只决定进入代价,不决定能否进入;不可通行由谓词负责。
**关键生命周期**
1. 初始化:`MainDataPlugin.awake` 创建 `DefaultPassPredicateImpl` 注入 finder,图层先置空,`useMover(state.hero.location.mover)`;再创建 `DefaultHeroMoveTopImpl` 并 `useTopImplementation`。
2. 勇士一步:`HeroMover.onStepStart` 记录方向/瞬移,判断边界,调用 `topImpl.predicate()` 的 `canPass` / `shouldHit`,返回 `CannotMove` / `Hit` / `Step`。
3. 结算:`onStepEnd` 对 `CannotMove` 调 `cannotEnter` 并停止,对 `Hit` 调 `hit`(触发 `OnTouch`)并停止,对 `Step` 返回目标格;`onStepSettled` 先 `leave`(`OnLeave`)再 `enter`(`OnEnter`)。
4. 构图:`MapGraphBuilder.build(start)` 检查图层、谓词、方向处理器与起点,BFS 只沿 `canPass` 的边扩展;`shouldHit` 标记终端;只把可达节点放进图。
5. 搜索:`PathFinder.search` 用最小损失堆,只读 `cost` / `terminal`;终端只能作终点。
6. 系统层:`getPath` 从 mover 的 tile 取起点;`moveTo` 总是逐步;`teleportTo` 由策略决定;忙碌返回 `null`;`interrupt` 等底层停止。
**常见误解**
- 「勇士与寻路共享同一个谓词对象」:不是;默认装配是两个实例。
- 「`shouldHit` 为真就是不能走」:不是;是走得到但会撞。
- 「动态图块会挡路」:默认实现不读动态图块;需要时写自定义谓词。
- 「损失函数可以用 `Infinity` 标记不可达」:不要把 `Infinity` 当通行判断;通行由谓词控制。当前实现对 `Infinity` 是接受的,`NaN` / 负数才回退 1。
- 「终端节点可以作为中间点」:不可以;它只能是目标。
### D5 CoreState
**为什么这样设计**
- 组合根:所有入口从 `CoreState` 出发,层与层之间由它注入,生命周期只有一套。
- 四层是构造依赖:L0 工具与仓库 → L1 可存档状态 → L2 操作状态的系统 → L3 启动入口;依赖只能向下。
- 插件最后装配:`awake` 可能访问任意一层,必须在四层齐了之后。
- 加载与读档分开:`load()` 只做初次加载(插件三阶段 + loader);`loadState()` 做快照恢复(逐对象 `loadState` + 执行器)。
**关键生命周期**
1. 构造:L0(`roleFace` / `faceManager` / `tileStore` / `itemStore` / `mapStore` / `eventStore` / `enemyStore` / `replaySystem`)→ L1(`flags` / `maps` / `hero` / `enemyManager`)→ L2(`enemyContext` / `combat` / `eventSystem` / `pathfinding`)→ L3(`loadManager` / `loader` + `addCoreConfig`)。
2. 插件:遍历 `config.dataPlugins` 建表(重复 id 告警 78)→ `orderPlugins` 排序 → 按顺序执行 `awake`,状态置 `Awakened`。
3. 加载:`load()` → `initialize()`(`Awakened` → `Initialized`)→ `loader.start()` → `afterLoad()`(`Initialized` → `Loaded`)→ 检查未完成插件(告警 81)。
4. 存档:`addSaveableContent`(重复 id 112)→ `bindSaveableExecuter`(未注册对象 113)→ `saveState` 遍历注册表 → `loadState` 清 disable flag、逐对象 `disable` / `loadState` / `revert`、执行器在包裹外、缺 key 177 / 多 key 178。
5. 五个内置 id:`@system/hero` / `@system/flags` / `@system/maps` / `@system/enemy` / `@system/replay`。
6. 第二实例:`new CoreState(fullConfig)` 不依赖全局单例;必须传四个字段;`load()` 要被消费。
**常见误解**
- 「`new CoreState` 就等于加载完成」:不是;返回的是未异步初始化的实例。
- 「读档会重跑插件 afterLoad」:不会;只在初次加载后运行。
- 「存档顺序有保证」:没有;执行器只保证当前对象已恢复。
- 「`loader` 是低层」:它是 L3;低层扩展点的 `state` 看不到它。
- 「可以只传 starter 与 coreURL」:构造器需要完整 `ICoreStateConfig`;测试里的省略写法与契约不一致,不要照抄。
### D6 插件内部
**为什么这样设计**
- 顺序来自依赖图,不来自列表位置;列表位置只决定互不依赖插件的平局顺序。
- 三个阶段共享同一份拓扑序;状态机保证每个插件每个阶段只执行一次。
- 失败不是异常,而是把插件及其下游从执行集合里移除;所以作者看到的往往是「对象不存在」而不是明确错误。
- `MainDataPlugin` 只是普通插件;用它的默认装配/存档 id 时必须显式依赖。
**关键生命周期**
1. 构造期:建插件表(`id` → `{ status, plugin }`,重复 id 告警 78)→ 建依赖表 → `orderPlugins` → `awake`。
2. `orderPlugins`:缺失依赖检测 → 环检测 → 阻断传播(环/缺失的下游)→ 反复扫描输出就绪插件;按插件表插入顺序扫描。
3. 三阶段:`awake`(同步、不等待)→ `initialize`(加载前、await)→ `afterLoad`(加载后、await);每个阶段只处理前一状态的插件,状态不符告警 80。
4. 失败:环 76 / 缺失 77 / 重复 78 / 状态 80 / 未完成 81;被阻断的插件停在 `NotAwakened`。
5. 取回:`getDataPlugin` 返回 `{ status, plugin }` 或 `null`;启动阶段优先用 `dataDeps`。
**常见误解**
- 「列表里排第一就先执行」:不是;只影响平局顺序。
- 「依赖写错会抛异常」:不会;插件被排除,控制台提示。
- 「`getDataPlugin` 拿到就能用」:还要检查 `status === Loaded`。
- 「被阻断的插件可以通过再次 `load()` 补跑」:不会;状态只前进,没有重置。
- 「`MainDataPlugin` 自动在前」:只是列表顺序;没有隐式依赖。
---
## E. 风险
### E1 结构性问题(最高)
1. 当前工作区是一次回退式改写:八页合计删除 1017 行、只新增 593 行(七篇目标页占主要部分),删掉的正是「加载管线 / 存档挂载 / 第二实例 / 两个实例 / 三种结果 / 存档形状 / 内部实现」这些需要跨文件理解才能写出的设计段落。
2. 页面被组织成接口清单,而不是「模型 → 数据流 → 为什么 → 扩展点」。读者能查到方法,却无法判断「这个改动该落在哪一层」。
3. 跨页架构断裂:`index` 不再是 8 页地图(当前写「七篇地图」且表序与阅读路径冲突);`core-state` 与 `plugin-internals` 的边界被压缩后互相缺环;`event-system` 与 `anon-tokyo` 的衔接被删;`replay-internals` 与 `core-state` 的存档挂载互相引用不到。
### E2 当前文档 actively wrong / too shallow
1. `replay-internals`:没有「播放必须禁用录制」这一关键约束;没有「移动器在步进开始时记录」的入口说明;没有存档形状;`finalize` 只被描述成「连续步骤收尾」,没有说明 `ReplayMove` 的「入队 + 最后 start」用法。读者按当前页写播放,会在移动器再次记录时让读流过期。
2. `event-system`:把事件层写成「alias 为 event 的普通图层」;把事件注册写成「内容侧注册」;没有 env 表;没有 `@shouldReplay` 的准确含义;没有执行器异常行为。这些会让读者误判「事件层可随意替换」「事件失败会被吞掉」。
3. `passability-and-pathfinding`:删掉了「同一契约、两个实例」与「勇士移动三种结果」;没有说明默认谓词只看静态图块;「同一套规则」说得过满。读者会以为改勇士规则会自动同步寻路。
4. `core-state`:删掉了加载管线、存档挂载、第二实例与内部装配顺序;读者无法判断「新增可存档对象要注册到哪里」「读档为什么不重跑 afterLoad」。
5. `anon-tokyo`:删掉了「与事件对象的衔接」与内部实现;`save`、`dump()`、`Exit` / `Return`、闭包约束都没有了。
6. `plugin-internals`:三阶段的依赖参数、`awake` 不等待、失败展开被压缩;`MainDataPlugin` 警告虽然补上了,但读者仍不知道「什么时候必须声明它」。
### E3 以前的「只读局部代码」结论(需用户裁决)
1. `docs/advanced/ii/pathfinding.md` 说损失函数返回值应是非负的有限数、`Infinity` 非法;当前 `MapGraphBuilder.resolveCost` 明确把 `Infinity` 当合法损失,只有 `NaN` / 负数回退 1。这是一条与实现冲突的旧结论;重写通行页时应写成「`Infinity` 不是通行判断工具,当前实现接受它」,不要照抄「有限数」。
2. `docs/advanced/ii/custom-replay-command.md` 说「播放时不要禁用录制」;`replayPlayback.test.ts` 的播放函数明确用 `disable()` / `revert()` 包裹,且 `HeroMover` 在步进时会再次 `array.add`。如果不禁用,任何写入都会让读流过期。重写录像页时应写「播放期间必须禁用录制」;是否回头修 II 需用户决定。
3. HEAD 版 `replay-internals.md` 说 `saveState` 返回内部缓冲区直接引用;当前 `ReplaySystem.saveState` 使用 `.slice()`,是副本。重写时以当前实现为准,旧句不能保留。
4. `docs/advanced/iii/loading-system.md`(已验收样板,不在本次范围)把 `addExtraConfig` 的 `url` 例子写成 `content/quest/quest.jsonc`;加载器实际会再前置 `content/`,得到 `src/content/content/quest/quest.jsonc`。`IMotaDataLoader.addExtraConfig` 的注释写「相对于 src」,实现是「相对于 src/content」。这是一条注释/实现不一致(D-72:只在对话报告,不写进正文);因为 loading-system 已验收,建议只记录、不改。
5. `passability-and-pathfinding` 的 HEAD 版与当前版都说「先把方向降级到四向,再检查通行位」;默认实现只用降级结果判断是否为 `Unknown`,通行位掩码仍按原始方向计算。四方向玩法下没有差异,斜向输入下会不同。重写时按「默认四方向」表述,不要把「降级后的方向用于掩码」写成事实。
6. `event-system` 的「事件由内容侧注册」在当前仓库没有加载实现:只有测试构造 `GameEvent`,`src/data` 只注册内建函数。重写时写「登记接口是 `eventStore.addEvent`」,不要写「内容加载已经这样做」。
7. `ReplaySystem.replaying` 字段当前没有代码设置;`BuiltInFunction.save` 没有被解释器读取;`AnonTokyoExecutionContext.dump()` 是空实现。文档不要给这些字段/方法赋予未证实的语义。
### E4 只报告、不写入文档的源码观察
- `HeroMover.onStepStart` 用 `replay.array.add` 直接记录,绕过 `ReplaySystem.record`,所以 `onRecordCommand` 钩子不会为玩家移动触发;这是设计还是遗漏需用户确认。
- `src/data/event/hero.ts` 的 `EventMoveHero` / `EventStepHero` 在 `controller` 为 `null` 时直接返回,没有 `revert()`,可能留下禁用层;文档不要描述「必定成对」。
- `compile.ts` 里有 `console.log` 调试输出;不影响文档,但说明该子系统仍在整理。
- `IGameEventSystem` 的 deferred 条目提到「同一系统同时只执行一个事件」尚未实现;文档不要写成既有约束。
### E5 需要用户决定的问题
1. 是否把「恢复 HEAD 已有章节」作为重写的基线,还是允许按本简报重新设计章节(例如把 `core-state` 的「内部实现」合并为更短的一节)?
2. `passability` 页是否要写「斜向输入与默认四方向掩码」的差异?这属于实现细节,可能超出「原理」范围。
3. `replay-internals` 是否保留「旧引擎基线对比」?当前读者审阅把它标为低概率;建议压缩成 3 行「为什么是二进制」,不再列旧字段。
4. `event-system` 是否要写「事件执行异常会让 `execute` 拒绝、后续项不执行」?这是当前实现的真实行为,但可能与未来「串行执行」约束冲突;建议写成当前行为并标注边界。
5. 高级 II 的 `pathfinding.md` 与 `custom-replay-command.md` 的冲突是否要在本轮一并修正?本次任务范围只允许写这份简报,不能改 II。
---
## F. 写作顺序与验收清单
1. 先写 `core-state` 与 `plugin-internals`:它们是其它页的隐性前置;写完后再写 `event-system` / `anon-tokyo`,最后 `passability` / `replay`,最后 `index`。
2. 每页完成后对照 A4 的十条检查:是否有模型、是否有数据流、是否有「为什么」、示例是否完整、接口是否按需引入、边界是否 5–6 条、是否有验证、是否有返回导览、是否无源码叙述、是否无模板标记。
3. 跨页检查:`index` 的 8 页表与阅读路径一致;每页开头有「本篇只讲 X;Y 见 Z」;C1 的每个概念只在一个页面展开;C3 的重复项为零。
4. 事实检查:至少复核 E3 的 7 条;涉及「录像存档引用」「播放禁用录制」「损失函数 Infinity」「事件层 alias」「事件注册」的句子必须与当前源码一致。
5. 格式检查:prettier、CRLF、单 H1、相对链接可解析、无死链;构建通过。
6. 审阅检查:按 D-46 的独立读者循环与 D-78 的章节使用概率;重点问「读完能否判断改动落点」「两个实例 / 两个阶段是否讲清」「示例能否照做」。
---
## 附:本简报的核心结论
- 高级 III 的问题不是「某几个接口写错」,而是页面被压缩成接口清单,失去了「模型 → 数据流 → 设计理由 → 扩展点」的主干。
- 重写必须以 `core-state` 为根,以「一个契约、两个实例」「两个阶段」「三层作用域」「输入日志而非状态快照」这些不变量为骨架。
- 所有示例都应从「读者要完成的任务」出发,而不是从「类有哪些成员」出发;所有边界都应来自真实的失败模式,而不是接口注释的复述。

View File

@ -114,9 +114,12 @@
- **D-45:** prettier 格式化。文档写完后**必须**跑 prettier 格式化——prettier 也管 markdown;不格式化虽不报 eslint 错,但用户日后手动修改会产生大量格式变动。推荐命令:`node node_modules/prettier/bin/prettier.cjs --write "docs/intermediate/*.md"`(或等价的 `pnpm exec prettier --write`)。若格式化会阻断某条命令执行,**先跳过并在对话中提出**,由用户自行格式化。
- **D-46:** 两阶段修订循环。修订必须依次经过两阶段,缺一不可:
1. **子代理自审循环**:如 plan-checker,以及**按文档实际情况选择合适视角**的子代理反向审阅(面向新手的文档可用「不懂代码的读者」人格;面向有基础作者或特定领域的文档,应选更贴近该受众/领域的视角)→ 问题清单 → 迭代修正。**审阅视角不固定为「不懂代码的读者」,须因文档而异。**
1. **独立读者提问循环**:派一个与写作者不共享上下文的子代理,按目标读者的前置阅读顺序阅读文档(先读前置教程,再读本次文档),**只读文档,不读源码**。子代理的任务不是审阅、不是判断对错,而是以读者身份提出「读完后仍无法从文档中得到答案」的问题;每条问题附上所在位置、已经从文档知道什么、还缺什么。子代理**不得**指出文档错误、不得提修改方案、不得做正确性判定。计划文件的质量检查(如 plan-checker)仍按 GSD 流程执行,不属于本条读者审阅。
2. **用户主导的修订循环**:用户以更贴近本领域的视角提出问题 → AI 派子代理逐条修改文档 → 回报 → 用户继续提,直到用户满意并确认。
**必须经过用户循环且获用户确认后才可收尾**,不得由 AI 自行收尾。适用于后续所有文档写作。
**审阅记录只追加、不覆盖**:每轮新增带轮次标题的记录,历史轮次逐字保留;不得用后一轮内容覆盖前一轮。轮次可写在同一文件的追加小节,也可每轮独立成文件。
**读者问题过滤**:只接纳正常定制流程中实际会遇到、容易触发或容易理解错的问题;假设性、极低概率、需要刻意构造的边界不进入修订清单。教学文档不追求面面俱到,样板已内置的异常与边界由 logger 错误码承担,正文不逐条枚举。写作者与用户可判定某问题「不写入文档」。
**审阅范围**:只审本次目标文档;已由用户审阅并验收的前置文档(如中级、高级 I / II)不重复审阅。
**文档正确性由写作者与用户负责,不由独立读者负责。** 必须经过用户循环且获用户确认后才可收尾,不得由 AI 自行收尾。适用于后续所有文档写作。
- **D-47:** 高级教程预留,修订 D-16。凡「留到高级教程细讲」的内容,必须**显式说明**并**预留链接标记**。由于 `docs/advanced/` 尚未建立,**不得**写指向它的真实 markdown 链接(会造成死链、构建失败);改用非断链占位(如代码 span `高级教程 · <topic>` 或 `<!-- TODO(advanced): <topic> -->`),待高级教程存在后再替换为真链接。此条在「高级教程引用」范围内取代 D-16 的「禁元叙述」。
- **D-48:** 实操写法,长期规范。系统/原理讲解保持现状(可以讲、且现在这样就好);需要重做的是**「实操/示例」部分**。每处实操统一写成可分步照抄的形式:
1. **目标**:先说要做出什么效果;
@ -213,6 +216,16 @@
- **D-82(先讲系统模型):** 讲一个系统时,**先讲清它的模型是什么**(概念、组成、数据流),再讲接口与用法;不要一上来就讲怎么获取、怎么调用。适用于后续所有系统类文档。
- **D-83(高级教程不写模板/需改标记):** 高级教程中**不再写「模板」「[需改]」等标记或模板章节名**;示例代码只需用**注释说明下一行在做什么**。注意区分:游戏数据领域的「怪物模板 / 原始模板 / 默认模板」等术语不受此限。适用于高级 I / II / III。
### 文档写作概念补充(2026-10-26)
- **D-84(行文思路):** 文档按**读者的理解顺序**组织,不按代码目录或接口清单组织:先给读者要解决的问题或场景,再讲系统模型(角色、分工、数据流),然后讲机制与设计理由,之后才是接口、用法和完整示例,最后用少量边界收尾。
- **D-85(文档与样板的关系):** 写文档前必须先理解样板完整的**设计思路、调用链和生命周期**,不能只读被讲到的那一段代码;否则文档可能局部正确、整体却与项目脱节。文档要结合样板实际怎么构造、怎么运行,但不能写成源码导读或行号注释。
- **D-86(章节过渡与拆分):** 每节只回答一个问题;不同消费者、不同阶段要分开讲,不能在同一节里来回跳。**只在认知跳跃大的地方**用 1–2 句说明「接下来讲什么、由谁负责、与上文什么关系」;自然承接的段落不硬加过渡。
- **D-87(示例写法):** 示例服务于机制理解,必须完整、与正文概念对应;优先给完整类或完整函数,并用注释解释关键行为,而不是堆接口演示。由编辑器产出、作者不会手写的内容(如事件脚本),不要给手写代码示例。
- **D-88(详略与长度):** 篇幅由内容决定。原理与架构层必须讲透设计理由、对象职责和数据流;不能为了「精简」删掉核心模型,也不能为了凑长度堆接口表和边角。只写常见、容易触发或容易误解的边界,其余交给引擎告警/日志。
- **D-89(正确性责任与审阅):** 文档正确性由写作者和用户负责。独立读者只读文档、按前置顺序阅读,只提出「读完后仍无法从文档得到答案」的问题;不读源码、不判断对错、不提修改方案。审阅记录只追加、不覆盖。
- **D-90(提交节奏):** 文档修订循环中**不逐轮提交**;由用户明确确认后,再统一提交一次。
</decisions>
<specifics>
@ -288,6 +301,8 @@
- **Agent 参考**(第 6 部分)——「游戏数值设计、游戏地图设计」的具体内涵与内容来源。
- 中级教程中标注「占位 / 待提供」的示例(`data-plugins` 的简单插件示例)。
- **API 参考(第 5 部分)正文重建** —— `docs/api/` 旧内容已随本批(Wave 0 / 10-01)删除,生成机制 `docs/.vitepress/api.ts` 保留;新的按包 API 正文待后续派发重建。
- **高级 I 录像一致性补讲 `disable` / `revert`** —— 中级、高级 I、高级 III 目前都没有专门讲这两个接口;应在后续高级 I 修订时于 `docs/advanced/i/replay-consistency.md` 补一段,说明用途、成对使用与典型场景(读档重置、播放状态重置等)。
- **事件系统串行执行约束** —— 设计要求同一个 `IGameEventSystem` 同时只执行一个事件;执行期间再次调用 `execute` 应等待前一个事件执行完毕。当前代码尚未实现该约束,属于待补实现,文档暂不写成既有行为。
</deferred>

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'

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)。

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)。

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)。

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)。

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)。

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)。

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)。

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)。