# CoreState 组装与初始化
新增一个子系统时,最先遇到的问题通常不是「类写在哪」,而是「它应该拿谁、由谁创建、什么时候创建」。`CoreState` 是数据端的组合根:它先把跨系统共用的对象按依赖顺序建好,再让插件在这套对象上继续装配。装配顺序错了,代码不一定立刻报错,但会在第一次访问某个字段时拿到 `undefined`。
本篇只讲四层为什么存在、每层放什么、构造器实际按什么顺序建对象,以及 `MainDataPlugin` 在插件阶段补了哪些默认装配。加载如何推进见[加载系统](./loading-system.md),可存档对象与执行器见[可存档内容](../i/saveable-content.md),插件排序与阶段推进见[插件内部机制](./plugin-internals.md)。
## 系统模型
| 层 | 职责 |
| ----------- | ---------------------------------------------------- |
| L0 公共数据 | 工具、仓库与跨系统共用的运行期对象,不依赖其它子系统 |
| L1 状态基座 | 保存需要进存档的运行时状态,并允许系统层操作这些状态 |
| L2 系统层 | 在状态之上执行游戏逻辑,自身不作为存档内容 |
| L3 顶层 | 面向启动流程,把前面的对象接成加载入口 |
```mermaid
flowchart TD
L3[L3 顶层
loadManager / loader] --> L2[L2 系统层
enemyContext / combat / eventSystem / pathfinding]
L2 --> L1[L1 状态基座
flags / maps / hero / enemyManager]
L1 --> L0[L0 公共数据
stores / face / replay]
```
箭头表示依赖:L3 依赖 L2,L2 依赖 L1,L1 依赖 L0。构造顺序则相反,从 L0 开始逐层向上。`CoreState` 同时实现四层叠加后的接口,所以运行时是同一个对象;但每个子系统只接收它需要的那一层接口。
## 层不是目录
L0–L3 是构造依赖的分组,不是源码目录。一个包可以同时包含 L0 的仓库与 L1 的状态类;一个文件也可以定义多个层的接口。判断一个对象属于哪一层,看它「构造时依赖谁、是否进存档、是否只服务启动」,而不是看它放在哪个文件夹。
这也意味着不能用 import 路径推断分层:`data-base` 里有 L1 的地图与勇士,也有 L0 的图块与朝向工具;`data-system` 整体偏 L2,但它的接口仍然只依赖 L1。真正决定依赖方向的是构造参数与类型,而不是包名。
新增子系统时,默认通过插件实现,不先问「它属于哪一层」。只有必须改变引擎默认能力的极少数情况,才需要判断它落在 L0、L1 还是 L2:看它构造时依赖谁、是否进存档、是否只服务启动。插件与渲染端不需要挤进四层。
## 一条依赖链在运行时怎么走
以「勇士走一步」为例,四层在同一条链上各做一件事,但没有一层反向持有更高层:
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 的移动器。
这条链说明分层不是「调用顺序从上到下」,而是「每一层只依赖更低的接口」。顶层实现、谓词与插件可以同时接触多层,但它们是把能力注入低层,而不是让低层反过来 import 高层。
## L0:公共数据
| 对象 | 负责什么 | 为什么在 L0 | 依赖 |
| -------------- | -------------------------------- | ------------------------------ | ----------- |
| `roleFace` | 图块数字到朝向图的绑定 | 只处理图块标识,不认识游戏进度 | 无 |
| `faceManager` | 按组与字符串 id 注册方向处理器 | 移动、构图、图块转换共用 | 无 |
| `tileStore` | 图块原始定义:通行位、事件、类型 | 只读定义,所有层都可能查询 | 无 |
| `itemStore` | 道具原始定义 | 只读定义,不依赖地图或勇士 | `tileStore` |
| `mapStore` | 地图原始数据 | 只读定义,供地图状态按需创建 | 无 |
| `eventStore` | 事件 id 到事件对象的映射 | 事件对象不依赖地图状态 | 无 |
| `enemyStore` | 怪物原始定义 | 只读定义,供敌人管理器创建实例 | `tileStore` |
| `replaySystem` | 录像数组、指令注册表与播放沙箱 | 每个系统都可能用到它 | 无 |
L0 的共同点是「构造时不需要 L1/L2/L3」。录像系统放在 L0 的唯一理由是每个系统都可能用到它;可存档与否不是分层依据,关键是它不依赖更高层。
`tileStore` 必须早于 `itemStore` 与 `enemyStore`,因为后两者在构造时就要拿图块定义。`faceManager` 在 L0 只是建表,真正的 Dir4 / Dir8 处理器由 `MainDataPlugin` 注册;这保证默认玩法策略不写死在对象图里。
L0 的仓库大多是只读定义:`tileStore`、`itemStore`、`mapStore`、`enemyStore` 保存的是「内容是什么」,不是「这一局进行到哪」。把它们放在 L0,读档时不需要恢复,也不会因为一局的状态变化而改写定义。`roleFace` 与 `faceManager` 是工具性的查找表;`replaySystem` 放在 L0,只因为每个系统都可能用到它。
L0 的边界是「可以被任何层使用,但不主动使用任何层」。如果某个对象需要知道当前楼层、勇士位置或 Flag,它就不属于 L0;如果它只需要图块定义或方向处理器,放在 L0 可以避免在 L1/L2 里重复建一份。
## L1:状态基座
| 对象 | 负责什么 | 为什么在 L1 | 依赖 |
| -------------- | ---------------------------------- | ------------------------------------ | ------------------------------------ |
| `flags` | 全局 Flag 字段与值 | 需要进存档,系统层只读写 | 无 |
| `maps` | 楼层、图层、图块与事件的运行时状态 | 地图改动要进存档,且被寻路与事件读取 | `CoreState`(以 `IDataCommon` 回指) |
| `hero` | 勇士属性、位置、道具、装备与跟随者 | 勇士状态是存档主体,系统层只操作它 | `CoreState`(以 `IDataCommon` 回指) |
| `enemyManager` | 怪物实例、原始定义与特殊属性 | 敌人状态需要进存档,且依赖图块定义 | `tileStore` |
L1 的共同点是「保存游戏进度」。它们可以被 L2 读写,也可以被 L3 的读档流程恢复,但自身不决定战斗、事件或寻路的执行策略。`maps` 与 `hero` 在构造时接收 `CoreState`,但类型是 `IDataCommon`,只看得到 L0;这是向下依赖,不是反向依赖。
L1 的对象可以互相引用,但要通过主对象或公开接口,而不是在构造时抓取彼此的私有字段。`enemyManager` 只拿 `tileStore`,不拿 `maps` 或 `hero`,所以它可以在没有地图的环境里独立创建。
L1 的边界是「保存重建所需的状态,不保存可以重算的中间结果」。战斗中的临时伤害、寻路得到的路径、事件收集出的候选列表都不属于 L1;它们是 L2 在某一刻算出来的派生数据。把派生数据塞进 L1,会让存档体积膨胀,也会在读档后留下与新状态不一致的缓存。
L1 也不负责「什么时候写」。它只提供可读写的状态与存档能力;触发写入的是 L2 的系统或插件。这样同一份状态可以被不同玩法复用,而不必把玩法写进状态对象。
## L2:系统层
| 对象 | 负责什么 | 为什么在 L2 | 依赖 |
| -------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------- |
| `enemyContext` | 怪物上下文:战斗前后的属性修正与特殊逻辑 | 需要读 L1 状态,但自身不保存进度 | `CoreState`(以 `IStateSystem` 回指) |
| `combat` | 战斗流程:伤害、夹击、死亡与掉落 | 在状态之上执行,读档后可按需重建 | `CoreState` |
| `eventSystem` | 事件仓库、解释器、收集器与执行器 | 需要事件对象与地图状态,但不作为存档内容 | `CoreState`、`config.events` |
| `pathfinding` | 构图、搜索与把路径交给移动器 | 需要图层、谓词与移动器,但不保存地图数据 | `CoreState` |
L2 的共同点是「执行逻辑」。它们会修改 L1 的状态,但不自己保存一份副本;读档后,L1 被恢复,L2 继续按新状态工作。`eventSystem` 在构造时接收入口提供的内建函数表,并创建解释器;`pathfinding` 只创建求解器与系统对象,谓词、图层与移动器由 `MainDataPlugin` 在 `awake` 中注入。
L2 不能依赖 L3。比如事件系统不应该在构造时读取 `loader`,寻路也不应该依赖加载进度;需要加载结果时,由插件在加载后把值注入 L2,或由 L2 的调用方传入。
L2 的边界是「执行策略,不是内容定义」。事件系统知道怎么收集与执行,但不知道某个事件 id 对应哪段脚本;寻路知道怎么构图与搜索,但不知道当前楼层长什么样;战斗知道怎么结算,但不知道怪物原始定义从哪来。内容定义在 L0,状态在 L1,L2 只把两者接起来。
L2 也不是「所有逻辑都必须放这里」。默认玩法策略(用哪个谓词、给移动器装哪个顶层实现、注册哪些加载钩子)属于插件,不属于 `CoreState` 的构造。把它们放进 L2,会让系统对象在构造时就需要知道具体玩法,失去替换空间。
## L3:顶层
| 对象 | 负责什么 | 为什么在 L3 | 依赖 |
| ------------- | ---------------------------------------- | ----------------------------------------------- | ---------------------- |
| `loadManager` | 汇总加载任务、推进任务并累计进度 | 只服务加载流程,不参与日常结算 | 无 |
| `loader` | 按阶段加载核心配置、额外配置与自定义内容 | 需要 `loadManager` 与 starter,且只在启动期使用 | `loadManager`、starter |
L3 的共同点是「启动入口」。它们不持有游戏进度,也不执行战斗或事件;它们把外部资源读进来,供插件在加载后使用。`CoreState` 在构造器里调用 `loader.addCoreConfig('core', config.coreURL)`,把核心配置登记为第一个加载任务;真正的加载由 `state.load()` 推进,不在本篇展开。
L3 是四层里最容易被低层误用的层。低层如果为了拿一个配置而保存 `state.loader`,就把「对象图」和「启动流程」绑死了;正确做法是让插件在加载后读取配置,再把结果注入低层。
L3 的边界是「只在启动期有用」。`loadManager` 与 `loader` 不参与日常战斗、事件或寻路;它们读取外部资源,供插件在加载后创建对象。把运行期逻辑写进 L3,会让「加载完成」与「游戏运行」两条时间线纠缠在一起,读档时也很难判断哪些对象应该重建。
## 构造器的实际顺序
前面按层说明职责;落到代码上,四层由构造器按固定顺序建出来。`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` 的插件执行同步装配。
第 5 到第 7 步发生在四层全部构造完成之后。原因是 `awake` 可能访问任意一层:主插件要注册朝向、注入寻路谓词、给勇士移动器装顶层实现、注册加载钩子与可存档内容。任何一层还没建好,插件拿到的就是 `undefined`。
`config.dataPlugins` 里 `MainDataPlugin` 排在最前,只是入口的列表顺序;真正的执行顺序仍由 `dataDeps` 决定。它没有隐式前置地位,依赖它注册的默认装配或存档 id 时,必须在 `dataDeps` 中显式写 `'main-data-plugin'`。
## MainDataPlugin 实际装配
`CoreState` 只负责建对象与排序,默认玩法策略放在 `MainDataPlugin.awake`。它按下面的顺序补装配:
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` 的静态通行位。静态通行位描述「图块本身能不能走」,谓词描述「这一局按什么规则判断」;两者混在一起,换规则就要改内容。
## 边界与注意
四层顺序错了,很少在构造器里立刻抛错,常见的是第一次使用时拿到错误结果:低层保存了高层引用,第一次调用才拿到 `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)。