13 KiB
CoreState 组装与初始化
数据端主对象 CoreState 是数据端的组合根。构造它时,所有子系统按层装配好、插件注册并排序、装配期回调同步执行;调用 load() 时,加载按固定次序推进。存档对象、存档执行器与加载器也都挂在它身上。
本篇只讲数据端的装配与生命周期。数据端插件怎么写见插件系统,可存档内容的形状与压缩见可存档内容。
系统模型
组合根存在的理由只有三条:读者拿到的所有入口都从它出发;层与层之间由它注入,不需要各自寻找依赖;生命周期只有一套,构造、加载、存档都按同一顺序推进。
| 层 | 放什么 | 一句话职责 |
|---|---|---|
| Layer 0 公共数据 | 朝向、图块 / 道具 / 地图 / 事件 / 怪物仓库、录像 | 不依赖其它层,提供工具性接口与原始数据 |
| Layer 1 状态基座 | 标记、地图、勇士、怪物管理 | 保存需要进存档的运行时状态 |
| Layer 2 系统层 | 怪物上下文、战斗、事件、寻路 | 在状态之上执行游戏逻辑 |
| Layer 3 顶层 | 加载管理器、加载器 | 面向启动流程的入口 |
CoreState
├─ Layer 0 公共数据:朝向、仓库、录像
├─ Layer 1 状态基座:标记、地图、勇士、怪物管理
├─ Layer 2 系统层:怪物上下文、战斗、事件、寻路
├─ Layer 3 顶层:加载管理器、加载器
├─ 插件注册表:id → 插件,按依赖排出固定顺序
└─ 存档:可存档对象映射 + 执行器绑定
层号只表示装配先后与依赖方向:上层可以拿下层,下层不反向依赖上层。需要回指主对象的子系统在构造时就会拿到它,这一点在后面的装配顺序里展开。
入口的职责
启动数据端只需要两个函数和一个主插件:
- 数据端入口只重新导出创建函数。业务代码从统一入口导入
createCoreState,不需要直接接触构造器。 - 配置入口
createCoreStateConfig(starter)组装四样东西:加载 starter、核心配置地址、插件列表与事件注册。插件列表把主插件放在最前面,再展开插件列表文件里的业务插件。 - 创建函数
createCoreState(starter)用上面的配置构造实例,返回尚未异步初始化的主对象;加载要由调用方推进,见加载管线。
主插件 MainDataPlugin 承担数据端的内置装配,在 awake 里依次做:
- 注册四方向与八方向朝向处理器;
- 为寻路注入通行谓词,把图层先置空,并把移动器指向勇士;
- 为勇士换上默认的顶层移动实现;
- 添加默认加载钩子;
- 初始化敌人、录像与钩子;
- 注册五个可存档对象。
这五个存档 id 与对象一一对应:
| 存档 id | 对象 |
|---|---|
@system/hero |
state.hero |
@system/flags |
state.flags |
@system/maps |
state.maps |
@system/enemy |
state.enemyManager |
@system/replay |
state.replaySystem |
存档 id 与插件 id 是两套命名。注册规则、重复 id 的处理与执行器绑定见可存档内容。
主插件的 afterLoad 在加载完成后才执行:它从加载器取核心配置,再用配置里的初始属性与坐标创建勇士的属性与位置。属性与位置不能放进 awake,因为那时核心配置还没读出来。
加载管线
state.load() 是一个异步迭代器,按固定四步推进:
load()
├─ 1. 插件 initialize 异步,加载开始前
├─ 2. loader.start() 异步迭代,真正加载数据
├─ 3. 插件 afterLoad 异步,加载完成后
└─ 4. 检查插件状态 未到 Loaded 的插件逐个告警
initialize 放在加载前,是因为此时还可以往加载器里追加配置;插件要在加载前完成的异步准备也放在这里。loader.start() 依次读取核心配置、额外配置与自定义任务,并产出加载进度。afterLoad 放在加载后,因为此时核心配置、地图与其它内容才可读。最后一步只做检查:正常走完三个阶段的插件状态都是 Loaded,不是的会被点名。
load() 与 loader.start() 都是异步迭代器:拿到迭代器不等于加载开始,必须把迭代推进完。load() 只负责初次加载,玩家读档不会重跑这条管线。
存档挂载
存档系统只认识两样东西:注册过的可存档对象,以及绑定在这些对象上的执行器。两者都挂在主对象上。
- 注册:
state.addSaveableContent(id, content)。id 在整份存档里唯一;重复注册会告警,并且只有第一个注册的对象生效。 - 绑定:
state.bindSaveableExecuter(content 或 id, executor)。传对象时,对象必须已经注册,否则告警并忽略;传 id 时,找不到对应对象会静默返回。 - 读档:主对象先清掉残留的录像禁用标记,再逐个对象执行
loadState。每个对象的loadState都被录像禁用与恢复包裹,读档本身不会记进录像;执行器在这层包裹之外运行,因为它可能需要做出需要记录的行为。
下面这个函数注册一个计数对象并绑定执行器:
// 存档形状:只放重建必需的字段
interface ICounterSave {
readonly count: number;
}
// 可存档对象:负责在活对象与存档数据之间转换
class CounterSaveable implements ISaveableContent<ICounterSave> {
private count = 0;
add(delta: number): void {
this.count += delta;
}
// 返回独立的新对象,不能返回 this
saveState(): ICounterSave {
return { count: this.count };
}
// 按存档覆盖运行时状态,不保留旧值
loadState(state: ICounterSave): void {
this.count = state.count;
}
}
// 执行器:当前对象 loadState 返回后立即运行
class CounterExecutor implements ISaveableExecutor<ICounterSave> {
afterLoad(data: ICounterSave, state: ICoreState): void {
// 此时只保证当前对象已经恢复,不要假设其它对象也恢复完毕
console.log('计数已恢复:', data.count, state);
}
}
// 注册并绑定:通常写在插件的 awake 里
export function registerCounter(state: ICoreState): void {
const counter = new CounterSaveable();
// 存档 id 必须唯一;重复注册会告警并忽略后一个
state.addSaveableContent('@plugin/counter', counter);
// 传对象或注册 id 都可以;对象要先注册,再绑定
state.bindSaveableExecuter(counter, new CounterExecutor());
}
在游戏内另建一个数据端实例
构造器不依赖全局单例。只要传一份完整配置,就能在游戏内或脚本里再建一个独立的数据端实例,与主实例并行运行;回放、结果对比、离线校验都可以这样做。
配置有四个必填字段:加载 starter、核心配置地址、插件列表与事件注册。缺任何一项,构造期就会失败;也不能只给加载 starter 与核心配置地址。
// 再建一个数据端实例:与主实例完全独立
export async function createSecondaryState(
loadStarter: ILoadTaskStarter,
coreURL: string,
dataPlugins: ICoreStateConfig['dataPlugins'],
events: ICoreStateConfig['events']
): Promise<ICoreState> {
// 四个字段都必填:构造期就要用它们装配加载器与插件
const config: ICoreStateConfig = {
loadStarter,
coreURL,
dataPlugins,
events
};
// 新实例不共享主实例的勇士、地图、事件仓库或存档
const state = new CoreState(config);
// load() 是异步迭代器:必须消费它,加载才真正推进
for await (const _loadedBytes of state.load()) {
// 这里可以读取加载进度,也可以忽略
}
return state;
}
两个实例各自持有自己的子系统与存档映射,互不影响。要让第二个实例与主实例行为一致,就传同一组插件列表与事件注册,并确保主插件也在列表里;需要另一套规则时,换一组插件与事件即可。
内部实现:装配顺序
此章节讲解一下 CoreState 的装配顺序的内部实现。
构造器按四层顺序创建全部子系统,再处理插件。层与层之间不是随便排列的:每一层只使用已经装好的下层,并把自己暴露给上层。
| 层 | 装入的子系统 | 为什么在这一层 |
|---|---|---|
| L0 公共数据 | 朝向绑定器 roleFace、朝向管理器 faceManager、图块仓库 tileStore、道具仓库 itemStore、地图仓库 mapStore、事件仓库 eventStore、怪物仓库 enemyStore、录像系统 replaySystem |
不依赖其它子系统,先建好供所有层使用 |
| L1 状态基座 | 标记系统 flags、地图状态 maps、勇士状态 hero、怪物管理 enemyManager |
保存需要进存档的运行时状态 |
| L2 系统层 | 怪物上下文 enemyContext、战斗流程 combat、事件系统 eventSystem、寻路系统 pathfinding |
在状态之上执行逻辑,构造时需要拿到主对象 |
| L3 顶层 | 加载管理器 loadManager(LoadManager)、数据加载器 loader |
面向启动流程;加载器随后登记核心配置地址 |
L0 内部也有先后:道具仓库与怪物仓库依赖图块仓库,所以图块仓库先建。L1 与 L2 里有几个子系统需要回指主对象,构造时就把当前实例传进去:地图、勇士、怪物上下文、战斗、事件系统与寻路都属于这一类。L3 的加载器不反向引用主对象,它只拿加载管理器与 starter,再把核心配置登记为自己的第一份配置。
四层装完后才处理插件:先遍历插件列表建立注册表,重复 id 在这里告警并跳过;再按每个插件声明的 dataDeps 建依赖表,调用一次 orderPlugins 得到固定顺序;最后同步执行 awake,把已排好序的插件逐个装配。装配期回调结束后,构造器返回,异步初始化与加载还没有开始。插件状态推进与依赖排序的细节见插件内部机制。
边界与注意
- 加载必须被迭代推进:
createCoreState只构造实例;load()是异步迭代器,不消费它,加载不会开始,插件也不会进入Loaded。 - 配置四个字段都必填:加载 starter、核心配置地址、插件列表、事件注册缺一不可。
- 重复可存档 id 只保留第一个:后一个注册会告警并被忽略,不是覆盖。
- 执行器绑未注册对象会告警:传对象前先调用
addSaveableContent;传 id 时找不到对象则静默返回。 - 读档的键要对得上:存档缺少某个已注册 id 会告警并跳过;存档多出未注册 id 会在读完时告警。
- 插件未到完成态会被告警:
load()结束时会逐个检查,状态不是Loaded的插件都会被点名。 - 读档期间录像被临时禁用:每个对象的
loadState都被禁用与恢复包裹,执行器不在包裹内。 - 不要重排装配顺序:层与层之间的依赖是固定的,重排会让需要回指主对象的子系统拿不到已装配的下层。
- 不要手动构造内部子系统:需要新能力时新增插件,或通过公开入口扩展;自己创建内部对象会绕过存档、录像与生命周期管理。