feat: 动态图层

This commit is contained in:
AncTe committed 2026-05-11 23:35:46 +08:00
1 parent 4c48b63e7e
commit fbdd609001
19 files changed
+2063 -15

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
# 需求综述
实现游戏引擎存档系统(`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.LowCompression`
- `private commonSaveLevel: SaveCompression`:默认 `SaveCompression.HighCompressoin`
- `private saveTimeTolerance: number`:默认 `100`(ms)
- `private autosaveTimeTolerance: number`:默认 `50`(ms)
## 3. ISaveRead 数据结构
栈与数据库读写均使用新接口 `ISaveRead`:
```ts
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)`
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. 将读取到的记录中的 `compression` 和 `data` 字段组装成 `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.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`
- [x] 在 `warn` 对象中新增代码 `114`:普通存档耗时超限警告
- [x] 在 `warn` 对象中新增代码 `115`:自动存档耗时超限警告
### `packages-user/data-state/src/save/system.ts`
- [x] 新增 `private undoStack: ISaveRead[]` 成员:存储 undo 历史快照
- [x] 新增 `private redoStack: ISaveRead[]` 成员:存储 redo 历史快照
- [x] 新增 `private stackSize: number` 成员:undo/redo 栈容量上限,默认 `20`
- [x] 新增 `private autosaveLevel: SaveCompression` 成员:
默认 `SaveCompression.LowCompression`
- [x] 新增 `private commonSaveLevel: SaveCompression` 成员:
默认 `SaveCompression.HighCompressoin`
- [x] 新增 `private saveTimeTolerance: number` 成员:普通存档耗时阈值,默认 `100`
- [x] 新增 `private autosaveTimeTolerance: number` 成员:自动存档耗时阈值,默认 `50`
- [x] 编写构造函数:初始化 Dexie 实例,定义 `saves`(主键 `id`)和
`global`(主键 `key`)两张表的 schema
- [x] 编写 `config` 方法:将配置项写入私有成员
- [x] 编写 `setAutosaveStackSize` 方法:更新 stackSize,修剪超长的 undo/redo 栈
- [x] 编写 `autosave` 方法:遍历 state 序列化为 `ISaveRead` 压入 undoStack,
清空 redoStack,超长时修剪栈底
- [x] 编写 `undoAutosave` 方法:将 current 序列化为 `ISaveRead` 压入 redoStack,
弹出 undoStack 栈顶返回 `ISaveRead`(或 null)
- [x] 编写 `redoAutosave` 方法:与 undoAutosave 对称
- [x] 编写 `getUndoStack` / `getRedoStack` 方法:使用 `slice()` 返回栈的浅拷贝快照
- [x] 编写 `saveAutosaveToDB` 方法:取 undoStack 栈顶以 `id = -1` 写入 `saves` 表,
performance 监控,超限时调用 `logger.warn(115, ...)`
- [x] 编写 `save` 方法:遍历 state 序列化为 `ISaveRead` 写入 `saves` 表,
更新 `lastSlot`,performance 监控,超限时调用 `logger.warn(114, ...)`
- [x] 编写 `load` 方法:从 Dexie `saves` 表读取记录组装为 `ISaveRead` 返回
(不存在返回 null);`load(-1)` 可读取持久化的自动存档
- [x] 编写 `deleteSave` 方法:从 Dexie `saves` 表删除指定记录
- [x] 编写 `getLastSlot` 方法:从全局存储读取 `'lastSlot'`,不存在时返回 `0`
- [x] 编写 `getGlobal` / `setGlobal` 方法:直接读写 Dexie `global` 表,不进行 JSON 序列化
- [x] 编写 `startGlobalTransaction` 方法:
使用 Dexie 事务包裹 handle,传入 GlobalTransaction 实例
### `packages-user/data-state/src/save/system.ts`(GlobalTransaction 部分)
- [x] 编写 `GlobalTransaction.get` 方法:在事务上下文中直接读取 table 中 key 对应的 value
- [x] 编写 `GlobalTransaction.set` 方法:在事务上下文中直接写入 key-value,不进行 JSON 序列化