template/docs/dev/map-store-save.md
unanmed 70a58ef4dc refactor: 地图存储
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 13:33:49 +08:00

12 KiB
Raw Blame History

需求综述

当前 LayerState 只存储当前激活地图的数据,切换地图时原地图内容彻底丢失, 无法参与存档系统。为此引入 IMapStore,集中管理所有楼层的 LayerState 并实现 ISaveableContent 接口以支持存档读档。

核心目标:

  • 多楼层数据同时存在于内存中,通过 id 访问;
  • 通过 active 标记区分"玩家可能到达"与"无需关注"的楼层,节省存档开销;
  • 通过 compareWith 提供参考基准,配合分级压缩大幅减少存档体积;
  • IStateBase.layer 类型由 ILayerState 改为 IMapStore 操作楼层必须先通过 getLayerState(id) 取得具体楼层。

实现思路

1. 给 LayerState 添加 active 成员

ILayerState 新增 readonly active: booleansetActiveStatus(active: boolean): void 两个接口,LayerState 实现类中 active 默认为 false

两者的关系:

  • ILayerState.setActiveStatus:直接操作楼层对象;
  • IMapStore.setMapActiveStatus(id, active):通过 id 操作, 内部查找对应楼层后调用其 setActiveStatus

2. 脏数据追踪dirty tracking

为支持 LowCompressionHighCompression 的差分存档, 需要知道哪些楼层相对于参考基准是否发生了修改。

推荐方案:楼层级简单脏标记 + 存档时实际比较

LayerState 内部维护 private dirty: boolean = false

  • 当楼层内任意 MapLayer 触发 onUpdateBlockonUpdateAreaonResize 钩子时,将 dirty 置为 true
  • dirty 只在 compareWith 首次调用时根据实际数据对比结果初始化, 初始化后的 gameplay 过程中不再重置(仅置 true

saveState 时:

  • dirty = false,跳过该楼层(初始化后从未被触碰过);
  • dirty = true
    • LowCompression:与参考基准进行全量比较,若完全一致则跳过 (消除"改了又改回"场景下的误判),否则存储所有行;
    • HighCompression:逐行与参考基准比较,只存储不一致的行。

loadState 时:若存档中此楼层没有任何数据(即未出现在 floors 中), 读档后将 dirty 置为 false(视为与参考基准一致)。

不在 MapLayer 内维护 dirtyRows,行级比较在 saveState 时直接对照参考基准进行。 这避免了每次 setBlock/putMapData 都更新行级标记的热路径开销, 且存档时实际比较已能消除误判,无需 probablyDirty + setInterval 或哈希方案。

存档时比较的开销Uint32Array 内存连续,实际耗时极低,且保存操作本身是低频的, 若将来发现存档耗时问题,可考虑将比较逻辑移至 Web Worker。

3. compareWith 接口与参数类型

compareWith(ref: Map<string, Map<number, Uint32Array>>): void;

外层 Map 以楼层 id 为键,内层 Map 以图层 zIndex 为键, 值为对应图层的完整图块数组Uint32Array含所有行的扁平数据

使用此类型而非 IMapStore 的理由:接口更轻量,调用方可直接从游戏原始数据构建, 无需额外持有一个完整的 IMapStore 实例。

关于图层标识符:继续使用 zIndex,在单个楼层内 zIndex 是语义唯一的, 与已有 MapLayer.zIndex 接口保持一致。

compareWith 以首次调用为唯一基准,再次调用不更新参考(以游戏原始数据为基准, 避免存档之间产生依赖关系)。

实现步骤:

  1. refData 已存在,直接返回;
  2. 保存 ref 引用到 private refData
  3. 遍历当前所有楼层,对每个楼层在 ref 中查找对应 id
    • 不存在:dirty = true(新楼层,视为全脏);
    • 存在:对每个 MapLayer(按 zIndex 匹配)做全量比较, 若所有行与参考数据完全一致则 dirty = false,否则 dirty = true

4. 楼层的创建与管理

MapStore 内部以 Map<string, LayerState> 存储所有楼层。 getLayerState(id) 对不存在的 id 直接返回 null,不自动创建。

只提供一个创建接口:

  • createLayerState(id: string): ILayerState:创建并注册一个空白楼层 (无任何 MapLayer,用户拿到后再调用 addLayer 配置图层结构),返回楼层对象。

注册时若 id 已存在,发出 logger 警告并覆盖。

compareWith 已调用后再通过上述接口新增楼层,新楼层直接视为全脏(dirty = true 因为 refData 中不存在对应数据。

5. 存档数据格式

类型定义

/** 单个 MapLayer 的存档数据 */
interface IMapLayerSave {
    readonly width: number;
    readonly height: number;
    /**
     * key = 行索引value = 该行完整的 Uint32Array 数据;
     * NoCompression/LowCompression 时包含所有行0 到 height - 1
     * HighCompression 时只包含与参考基准不同的行;
     * 读档时,不在此 Map 中的行从参考基准还原。
     */
    readonly rows: ReadonlyMap<number, Uint32Array>;
}

/** 单个楼层的存档数据 */
interface ILayerStateSave {
    readonly background: number;
    /**
     * key = zIndexvalue = 对应图层存档数据;
     * 使用 Map 格式以支持图层的动态增删。
     */
    readonly layers: ReadonlyMap<number, IMapLayerSave>;
}

/** 整个 MapStore 的存档数据 */
interface IMapStoreSave {
    /**
     * key = 楼层 id只包含 active 的楼层;
     * inactive 的楼层不写入,读档时无需处理。
     */
    readonly floors: ReadonlyMap<string, ILayerStateSave>;
}

各压缩等级存储策略

压缩级别 楼层粒度 行粒度
NoCompression 存储所有 active 楼层 存储该楼层所有行
LowCompression 跳过 dirty = false 的楼层dirty 楼层全量比较后仍一致的也跳过 存储该楼层所有行
HighCompression 同 LowCompression 只存储与参考基准不同的行

读档策略

读档时直接操作数组引用(通过 setMapRef),避免逐行拷贝的额外开销:

  1. 若参考基准(refData)未设置,抛出 logger 错误,不进行任何读档操作
  2. 遍历 state.floors,对每个楼层 id
    • 若当前 MapStore 中不存在该 id发出 logger 警告并跳过;
    • 对该楼层每个图层,先从参考基准取出对应 zIndex 的数组, 将其深拷贝为新数组作为底层(确保未存档行使用参考基准值);
    • 再将 ILayerStateSave.layers 中对应图层的 rows 数据写入该数组的对应行;
    • 调用 MapLayer.setMapRef(array) 直接替换内部引用,无需额外拷贝;
  3. 对未出现在 state.floors 中的 active 楼层, 从参考基准深拷贝完整数组后调用 setMapRef 还原,并将 dirty 置为 false

6. saveState / loadState 实现

根据压缩等级分别编写三个存档函数和三个读档函数, saveState(compression)loadState(state, compression) 根据 compression 分发, 无需在每个楼层的遍历循环内部判断等级:

  • private saveNoCompression(): IMapStoreSave
  • private saveLowCompression(): IMapStoreSave
  • private saveHighCompression(): IMapStoreSave
  • private loadNoCompression(state: IMapStoreSave): void
  • private loadLowCompression(state: IMapStoreSave): void
  • private loadHighCompression(state: IMapStoreSave): void

saveState 结果需通过 structuredClone 深拷贝后返回。

7. IMapStore 接口设计(新增到 map/types.ts

interface IMapStore extends ISaveableContent<IMapStoreSave> {
    /** 所有楼层的 id 集合 */
    readonly maps: ReadonlySet<string>;

    // --- 楼层访问 ---
    /** 获取指定 id 的楼层状态,不存在则返回 null */
    getLayerState(id: string): ILayerState | null;
    /** 获取指定 id 的楼层状态,要求楼层必须是 active 的,否则返回 null */
    getActiveMap(id: string): ILayerState | null;

    // --- 楼层管理 ---
    /** 创建并注册一个空白楼层,返回楼层状态对象 */
    createLayerState(id: string): ILayerState;

    // --- active 管理 ---
    /** 获取指定 id 的楼层是否激活,不存在的 id 返回 false */
    isMapActive(id: string): boolean;
    /** 设置指定 id 楼层的激活状态 */
    setMapActiveStatus(id: string, active: boolean): void;
    /** 迭代所有 active 的楼层yield [id, ILayerState] */
    iterateActiveMaps(): Iterable<[string, ILayerState]>;
    /** 迭代所有 inactive 的楼层yield [id, ILayerState] */
    iterateInactiveMaps(): Iterable<[string, ILayerState]>;
    /** 迭代所有楼层yield [id, ILayerState] */
    iterateAllMaps(): Iterable<[string, ILayerState]>;

    // --- 差分压缩基准 ---
    /**
     * 设置压缩参考基准,以首次调用为唯一基准,再次调用不更新。
     * @param ref 外层 key = 楼层 id内层 key = zIndexvalue = 图层完整图块数据
     */
    compareWith(ref: Map<string, Map<number, Uint32Array>>): void;
}

8. ILayerState 接口修改

在现有 ILayerState 上新增:

/** 此楼层是否处于激活状态 */
readonly active: boolean;
/** 设置楼层激活状态 */
setActiveStatus(active: boolean): void;

9. IStateBase 修改

IStateBase.layer: ILayerState 改为 IStateBase.layer: IMapStore


涉及文件

需要引用的文件

  • @user/common/types.ts: ISaveableContent, SaveCompression
  • @user/data-base/map/types.ts: 全部现有地图接口(IMapLayer, ILayerState, 等)

需要修改的文件

@user/data-base/src/map/types.ts

  • 新增 IMapLayerSave 接口:单个 MapLayer 存档数据格式
  • 新增 ILayerStateSave 接口:单个楼层存档数据格式
  • 新增 IMapStoreSave 接口MapStore 整体存档数据格式
  • 修改 ILayerState:新增 readonly active: booleansetActiveStatus(active: boolean): void
  • 修改 IMapLayer:新增 setMapRef(array: Uint32Array): void
  • 新增 IMapStore 接口:继承 ISaveableContent<IMapStoreSave> 含全部接口(见第 7 节)

@user/data-base/src/map/mapLayer.ts

@user/data-base/src/map/layerState.ts

  • 新增 active: boolean = false 成员:楼层激活状态
  • 实现 setActiveStatus(active: boolean): void
  • 新增 private dirty: boolean = false 成员:楼层级脏标记
  • 修改 StateMapLayerHook.onUpdateAreaonUpdateBlockonResize 在转发钩子的同时,将 state.dirtytrue
  • 新增 isDirty(): boolean 方法:返回 this.dirty,供 MapStore 读取
  • 新增 setDirty(dirty: boolean): void 方法: 供 MapStore.compareWith 时根据实际比较结果设置

@user/data-base/src/map/mapLayer.ts

  • 新增 setMapRef(array: Uint32Array): void 方法: 直接替换内部图块数组引用,跳过拷贝,供 MapStore 读档时使用。 需确保传入数组长度与 width × height 匹配, 并触发必要的钩子通知(不触发 onResize,应触发 onUpdateArea 通知全区域更新)。 在方法注释中明确标注:调用后不得再持有或修改传入的数组。

@user/data-base/src/map/mapStore.ts(新文件)

  • 实现 MapStore 类,实现 IMapStore
  • private mapData: Map<string, LayerState>:楼层 id 到状态对象的映射
  • readonly maps: ReadonlySet<string>:所有楼层 id 的只读集合视图
  • private refData: Map<string, Map<number, Uint32Array>> | null:参考基准
  • 实现 getLayerStategetActiveMapcreateLayerState
  • 实现 isMapActivesetMapActiveStatusiterateActiveMapsiterateInactiveMapsiterateAllMaps
  • 实现 compareWith
  • 实现 saveNoCompressionsaveLowCompressionsaveHighCompression
  • 实现 loadNoCompressionloadLowCompressionloadHighCompression
  • 实现 saveState(compression)loadState(state, compression) 分发

@user/data-base/src/map/index.ts

  • 补充导出 mapStore.ts

@user/data-base/src/types.ts

  • IStateBase.layer 类型由 ILayerState 改为 IMapStore