10 KiB
需求综述
实现游戏引擎存档系统(SaveSystem)及全局事务(GlobalTransaction)两个类的完整逻辑。
存档系统分为两部分:
- 存档内容:按 slot id 保存/读取游戏当前状态(
Map<string, ISaveableContent<unknown>>), 使用 Dexie 将数据存入 IndexedDB。 - 全局存储:跨存档的 key-value 存储,用于存放存档 meta data、全局设置等。 支持事务处理,事务中的写入操作在发生错误时全部回滚。
此外,系统提供基于内存的自动存档,并支持 undo/redo 操作。
自动存档不主动写入 IndexedDB,只有显式调用 saveAutosaveToDB 时才将 undo
栈顶存档写入数据库。
存档操作使用 performance 接口监控耗时,超过配置阈值时通过 logger 发出警告。
实现思路
1. Dexie 数据库 Schema 设计
SaveSystem 构造函数接收数据库名称 name,在其中创建如下两张表:
| 表名 | 主键 | 说明 |
|---|---|---|
saves |
id(number) |
按 slot id 存储存档数据;id = -1 固定用于持久化自动存档 |
global |
key(string) |
全局 key-value 存储 |
saves表中每条记录结构为{ id: number, compression: SaveCompression, data: Map<string, unknown> }, 与ISaveRead对应,直接利用 IndexedDB 的结构化克隆存储,不进行 JSON 序列化。global表中每条记录结构为{ key: string, value: unknown }, 同样直接存储,不进行 JSON 序列化。
2. 内部状态
SaveSystem 需要维护如下私有成员:
private undoStack: ISaveRead[]:undo 栈,存储ISaveRead快照private redoStack: ISaveRead[]:redo 栈,存储ISaveRead快照private stackSize: number:undo/redo 栈最大容量(默认20)private autosaveLevel: SaveCompression:默认SaveCompression.LowCompressionprivate commonSaveLevel: SaveCompression:默认SaveCompression.HighCompressoinprivate saveTimeTolerance: number:默认100(ms)private autosaveTimeTolerance: number:默认50(ms)
3. ISaveRead 数据结构
栈与数据库读写均使用新接口 ISaveRead:
interface ISaveRead {
readonly compression: SaveCompression;
readonly data: Map<string, unknown>;
}
compression:存档时使用的压缩等级,读档时传回给loadState,使接收方能够正确解压。data:key 到每个可存档对象序列化数据的 Map,key 与ISaveableContent注册时的 id 对应。
内存栈和数据库均直接存储 ISaveRead,不需要引入辅助包装层。
存档系统本身不负责将数据写回游戏对象,调用方拿到 ISaveRead 后自行遍历并调用
各可存档对象的 loadState(data, compression) 完成状态恢复。
4. 各方法实现
config(config)
将 config 各字段写入对应私有成员,使用传入的值覆盖默认值。
setAutosaveStackSize(size)
将 stackSize 更新为 size。如果当前 undo 栈超过新的 size,
从栈底移除多余条目(保留最新的);redo 栈同理。
autosave(state)
- 遍历
state,对每个(key, content)调用content.saveState(this.autosaveLevel)获取序列化数据, 汇总为Map<string, unknown>,构建ISaveRead { compression: autosaveLevel, data }并压入undoStack; - 清空
redoStack(执行新的自动存档后无法再 redo); - 若
undoStack.length > stackSize,从栈底([0])移除多余条目。
IndexedDB 支持结构化克隆,Map、Set、TypedArray 等均可直接存储,无需 JSON 序列化。
undoAutosave(current)
- 若
undoStack为空,返回null; - 将
current序列化为ISaveRead { compression: autosaveLevel, data: Map }, 压入redoStack;检查redoStack.length > stackSize,超长时从栈底移除多余条目; - 弹出
undoStack栈顶(pop()),返回弹出的ISaveRead; - 调用方拿到返回的
ISaveRead后,自行遍历并对各游戏对象调用loadState完成恢复。
redoAutosave(current)
与 undoAutosave 逻辑对称:将 current 序列化压入 undoStack,
弹出 redoStack 栈顶并返回,调用方自行恢复状态。
getUndoStack() / getRedoStack()
使用 slice() 返回栈数组的浅拷贝快照,防止外部意外修改栈结构。
saveAutosaveToDB()
- 若
undoStack为空,直接返回(无需写入); - 记录
t0 = performance.now(); - 取
undoStack栈顶(ISaveRead),将其连同id = -1一起写入saves表; - 记录
t1 = performance.now();若t1 - t0 > autosaveTimeTolerance, 调用logger.warn(115, (t1 - t0).toFixed(0), this.autosaveTimeTolerance.toString())。
save(id, state)
- 记录
t0 = performance.now(); - 遍历
state,对每个(key, content)调用content.saveState(this.commonSaveLevel)汇总为Map<string, unknown>, 构建{ id, compression: commonSaveLevel, data }写入saves表; - 将
id写入全局存储'lastSlot'键(用于getLastSlot()); - 记录
t1 = performance.now();若t1 - t0 > saveTimeTolerance, 调用logger.warn(114, (t1 - t0).toFixed(0), this.saveTimeTolerance.toString())。
load(id)
- 从 Dexie
saves表查询id; - 若不存在返回
null; - 将读取到的记录中的
compression和data字段组装成ISaveRead返回。 调用方自行遍历data并对各游戏对象调用loadState完成恢复。
load(-1)可用于读取持久化的自动存档。
deleteSave(id)
直接从 Dexie saves 表删除对应记录。
getLastSlot()
从全局存储读取 'lastSlot' 键对应的值并返回;若不存在则返回 0。
getGlobal<T>(key) / setGlobal(key, value)
getGlobal:从 Dexieglobal表读取key对应的value字段并返回,类型断言为T;setGlobal:将{ key, value }直接写入 Dexieglobal表,无需 JSON 序列化。
startGlobalTransaction<R>(handle)
使用 Dexie.transaction('rw', this.db.table('global'), ...) 包裹 handle 调用,
传入 GlobalTransaction 实例,出错时自动回滚。
GlobalTransaction.get<T>(key) / GlobalTransaction.set(key, value)
在事务上下文中直接读写 table(即全局 global 表的引用),无需 JSON 序列化。
5. logger.json 新增 warn 代码
当前最大 warn 代码为 113,新增如下两条(写入 packages/common/src/logger.json
的 warn 对象,置于 113 之后):
| 代码 | 消息 |
|---|---|
114 |
Save operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level. |
115 |
Autosave operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level. |
涉及文件
需要引用的文件
dexie:Dexie / Table 类型,用于创建和操作 IndexedDB 数据库@motajs/common:logger,用于输出存档耗时超限警告@user/data-base:ISaveableContent、SaveCompression,存档接口与压缩枚举./types:ISaveRead、IGlobalTrasaction、ISaveSystem、ISaveSystemConfig
需要修改的文件
packages/common/src/logger.json
- 在
warn对象中新增代码114:普通存档耗时超限警告 - 在
warn对象中新增代码115:自动存档耗时超限警告
packages-user/data-state/src/save/system.ts
- 新增
private undoStack: ISaveRead[]成员:存储 undo 历史快照 - 新增
private redoStack: ISaveRead[]成员:存储 redo 历史快照 - 新增
private stackSize: number成员:undo/redo 栈容量上限,默认20 - 新增
private autosaveLevel: SaveCompression成员: 默认SaveCompression.LowCompression - 新增
private commonSaveLevel: SaveCompression成员: 默认SaveCompression.HighCompressoin - 新增
private saveTimeTolerance: number成员:普通存档耗时阈值,默认100 - 新增
private autosaveTimeTolerance: number成员:自动存档耗时阈值,默认50 - 编写构造函数:初始化 Dexie 实例,定义
saves(主键id)和global(主键key)两张表的 schema - 编写
config方法:将配置项写入私有成员 - 编写
setAutosaveStackSize方法:更新 stackSize,修剪超长的 undo/redo 栈 - 编写
autosave方法:遍历 state 序列化为ISaveRead压入 undoStack, 清空 redoStack,超长时修剪栈底 - 编写
undoAutosave方法:将 current 序列化为ISaveRead压入 redoStack, 弹出 undoStack 栈顶返回ISaveRead(或 null) - 编写
redoAutosave方法:与 undoAutosave 对称 - 编写
getUndoStack/getRedoStack方法:使用slice()返回栈的浅拷贝快照 - 编写
saveAutosaveToDB方法:取 undoStack 栈顶以id = -1写入saves表, performance 监控,超限时调用logger.warn(115, ...) - 编写
save方法:遍历 state 序列化为ISaveRead写入saves表, 更新lastSlot,performance 监控,超限时调用logger.warn(114, ...) - 编写
load方法:从 Dexiesaves表读取记录组装为ISaveRead返回 (不存在返回 null);load(-1)可读取持久化的自动存档 - 编写
deleteSave方法:从 Dexiesaves表删除指定记录 - 编写
getLastSlot方法:从全局存储读取'lastSlot',不存在时返回0 - 编写
getGlobal/setGlobal方法:直接读写 Dexieglobal表,不进行 JSON 序列化 - 编写
startGlobalTransaction方法: 使用 Dexie 事务包裹 handle,传入 GlobalTransaction 实例
packages-user/data-state/src/save/system.ts(GlobalTransaction 部分)
- 编写
GlobalTransaction.get方法:在事务上下文中直接读取 table 中 key 对应的 value - 编写
GlobalTransaction.set方法:在事务上下文中直接写入 key-value,不进行 JSON 序列化