template/docs/dev/save-system.md
unanmed 8c1becc9f1 refactor: 存档系统 & 勇士属性存档
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 17:29:13 +08:00

10 KiB
Raw Permalink Blame History

需求综述

实现游戏引擎存档系统(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 idnumber 按 slot id 存储存档数据;id = -1 固定用于持久化自动存档
global keystring 全局 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: numberundo/redo 栈最大容量(默认 20
  • private autosaveLevel: SaveCompression:默认 SaveCompression.LowCompression
  • private commonSaveLevel: SaveCompression:默认 SaveCompression.HighCompressoin
  • private saveTimeTolerance: number:默认 100ms
  • private autosaveTimeTolerance: number:默认 50ms

3. ISaveRead 数据结构

栈与数据库读写均使用新接口 ISaveRead

interface ISaveRead {
    readonly compression: SaveCompression;
    readonly data: Map<string, unknown>;
}
  • compression:存档时使用的压缩等级,读档时传回给 loadState,使接收方能够正确解压。
  • datakey 到每个可存档对象序列化数据的 Mapkey 与 ISaveableContent 注册时的 id 对应。

内存栈和数据库均直接存储 ISaveRead,不需要引入辅助包装层。 存档系统本身不负责将数据写回游戏对象,调用方拿到 ISaveRead 后自行遍历并调用 各可存档对象的 loadState(data, compression) 完成状态恢复。

4. 各方法实现

config(config)

将 config 各字段写入对应私有成员,使用传入的值覆盖默认值。

setAutosaveStackSize(size)

stackSize 更新为 size。如果当前 undo 栈超过新的 size 从栈底移除多余条目保留最新的redo 栈同理。

autosave(state)

  1. 遍历 state,对每个 (key, content) 调用 content.saveState(this.autosaveLevel) 获取序列化数据, 汇总为 Map<string, unknown>,构建 ISaveRead { compression: autosaveLevel, data } 并压入 undoStack
  2. 清空 redoStack(执行新的自动存档后无法再 redo
  3. undoStack.length > stackSize,从栈底([0])移除多余条目。

IndexedDB 支持结构化克隆Map、Set、TypedArray 等均可直接存储,无需 JSON 序列化。

undoAutosave(current)

  1. undoStack 为空,返回 null
  2. current 序列化为 ISaveRead { compression: autosaveLevel, data: Map } 压入 redoStack;检查 redoStack.length > stackSize,超长时从栈底移除多余条目;
  3. 弹出 undoStack 栈顶(pop()),返回弹出的 ISaveRead
  4. 调用方拿到返回的 ISaveRead 后,自行遍历并对各游戏对象调用 loadState 完成恢复。

redoAutosave(current)

undoAutosave 逻辑对称:将 current 序列化压入 undoStack 弹出 redoStack 栈顶并返回,调用方自行恢复状态。

getUndoStack() / getRedoStack()

使用 slice() 返回栈数组的浅拷贝快照,防止外部意外修改栈结构。

saveAutosaveToDB()

  1. undoStack 为空,直接返回(无需写入);
  2. 记录 t0 = performance.now()
  3. undoStack 栈顶(ISaveRead),将其连同 id = -1 一起写入 saves 表;
  4. 记录 t1 = performance.now();若 t1 - t0 > autosaveTimeTolerance 调用 logger.warn(115, (t1 - t0).toFixed(0), this.autosaveTimeTolerance.toString())

save(id, state)

  1. 记录 t0 = performance.now()
  2. 遍历 state,对每个 (key, content) 调用 content.saveState(this.commonSaveLevel) 汇总为 Map<string, unknown> 构建 { id, compression: commonSaveLevel, data } 写入 saves 表;
  3. id 写入全局存储 'lastSlot' 键(用于 getLastSlot()
  4. 记录 t1 = performance.now();若 t1 - t0 > saveTimeTolerance 调用 logger.warn(114, (t1 - t0).toFixed(0), this.saveTimeTolerance.toString())

load(id)

  1. 从 Dexie saves 表查询 id
  2. 若不存在返回 null
  3. 将读取到的记录中的 compressiondata 字段组装成 ISaveRead 返回。 调用方自行遍历 data 并对各游戏对象调用 loadState 完成恢复。

load(-1) 可用于读取持久化的自动存档。

deleteSave(id)

直接从 Dexie saves 表删除对应记录。

getLastSlot()

从全局存储读取 'lastSlot' 键对应的值并返回;若不存在则返回 0

getGlobal<T>(key) / setGlobal(key, value)

  • getGlobal:从 Dexie global 表读取 key 对应的 value 字段并返回,类型断言为 T
  • setGlobal:将 { key, value } 直接写入 Dexie global 表,无需 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.jsonwarn 对象,置于 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.

涉及文件

需要引用的文件

  • dexieDexie / Table 类型,用于创建和操作 IndexedDB 数据库
  • @motajs/commonlogger,用于输出存档耗时超限警告
  • @user/data-baseISaveableContentSaveCompression,存档接口与压缩枚举
  • ./typesISaveReadIGlobalTrasactionISaveSystemISaveSystemConfig

需要修改的文件

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 表, 更新 lastSlotperformance 监控,超限时调用 logger.warn(114, ...)
  • 编写 load 方法:从 Dexie saves 表读取记录组装为 ISaveRead 返回 (不存在返回 nullload(-1) 可读取持久化的自动存档
  • 编写 deleteSave 方法:从 Dexie saves 表删除指定记录
  • 编写 getLastSlot 方法:从全局存储读取 'lastSlot',不存在时返回 0
  • 编写 getGlobal / setGlobal 方法:直接读写 Dexie global 表,不进行 JSON 序列化
  • 编写 startGlobalTransaction 方法: 使用 Dexie 事务包裹 handle传入 GlobalTransaction 实例

packages-user/data-state/src/save/system.tsGlobalTransaction 部分)

  • 编写 GlobalTransaction.get 方法:在事务上下文中直接读取 table 中 key 对应的 value
  • 编写 GlobalTransaction.set 方法:在事务上下文中直接写入 key-value不进行 JSON 序列化