MineMonopoly如何支持中途存档:IndexedDB 快照、恢复注入与版本兼容

多人游戏的存档需要把正在运行的对象图转换为可恢复的数据,同时保持对象关系和运行时行为。MineMonopoly 的游戏逻辑运行在 GameProcessWorker 中,玩家、地产、机会卡和修饰器之间存在引用关系,地图脚本还可以向对象挂载自定义字段。因此,存档系统需要明确哪些数据属于游戏状态,哪些数据属于运行时行为,以及恢复时如何重新建立这些行为。

存档边界:保存状态,不保存运行时对象

项目把一次游戏存档建模为 SaveSnapshot。快照记录当前回合、倍率、导出数据、自定义数据、游戏日志、玩家快照和地产快照,并可携带地图路径系统的运行时状态。PlayerSnapshot 和 PropertySnapshot 分别描述玩家与地产的可恢复字段,修饰器则使用 ModifierSnapshot 保存。

这个边界避免了直接序列化整个对象图。Player 实例中包含 commandBus、modifierManager、buffManager、回合阶段、用户对象和角色初始化函数等运行时成员,它们不能简单地通过 JSON 还原。Player.getSnapshot() 先收集可序列化字段,再单独写入骰子、机会卡、Buff 和修饰器;Property.getSnapshot() 同样把等级、所有者和修饰器作为需要专门处理的字段。恢复过程反过来执行:普通字段直接回填,等级和所有者通过对象自身的方法恢复,修饰器交由 restoreModifiers() 重建。

这种设计把序列化职责放回实体本身。GameProcess 不需要了解每个字段的内部结构,只需要组织快照、创建基础对象,并在合适的时机调用恢复方法。新增一个需要持久化的实体字段时,可以在实体的快照逻辑中处理,而不必修改整个存档管线。

IndexedDB:本地存储层只负责记录

SaveManager 负责存档的业务操作,默认使用 IndexedDBSaveStorage。存储层以 mine-monopoly-saves 为数据库名,以 saves 为对象仓库,使用记录的 id 作为主键,并通过 mapId_version 索引按地图和版本查询。

一条 SaveRecord 除了快照,还包含地图 ID、地图版本、地图名称、保存时间、回合数、玩家数量、玩家用户 ID 和玩家名称。保存新记录时,管理器会查询同一地图版本的已有记录,并将最近一条快照保存为 previousSnapshot。这为回退和恢复失败后的处理保留了一个明确的数据入口。

存储层没有承担玩家校验、地图迁移或游戏初始化。SaveManager.validatePlayers() 会检查当前房间的用户是否属于存档;存档中存在但当前房间缺席的玩家会被标记为 AI 托管对象。这样,IndexedDB 只处理持久化读写,房间和游戏进程分别处理参与者校验与运行时恢复,职责边界更清晰。

恢复流程:先建立运行环境,再注入快照

恢复不能直接把快照写进一个尚未初始化的游戏进程。Room 读取存档后,会把快照作为待处理数据交给 Worker。GameProcess.start() 按照正常启动流程初始化玩家和地产,执行游戏初始化阶段;如果存在待恢复数据,则在向客户端发送第一次 GameInit 之前调用 restoreFromSnapshot()。

当前流程可以概括为:

  1. 读取存档并检查地图与玩家;
  2. 根据存档中的角色 ID 创建玩家,避免使用错误的角色初始化代码产生副作用;
  3. 初始化玩家、地产和地图脚本;
  4. 恢复回合数、倍率、玩家、地产、日志以及地图路径运行时状态;
  5. 执行 postRestore 阶段,让地图脚本重建依赖运行时对象的状态;
  6. 广播已经恢复的 GameInit,等待客户端完成初始化后进入游戏循环。

这里的关键是“恢复后再广播”。如果 Worker 先把初始状态发送给客户端,再异步修改内部对象,客户端就可能短暂看到一套错误状态,也会增加状态同步的分支。当前实现把恢复放在第一次 GameInit 之前,使客户端首次收到的游戏数据就是恢复后的结果。

postRestore 阶段解决了另一类问题:快照只能保存数据,不能自动重建由地图脚本创建的运行时状态。例如脚本可能根据存档字段重新注册按钮、创建临时结构或设置事件监听。恢复完成后再运行专门阶段,可以把这部分逻辑从通用恢复代码中分离出来。

版本兼容:迁移字段,而不是复制旧对象

存档格式会随着地图和移动系统变化。项目中的 recoverMapPathSnapshot() 专门处理旧存档缺少 positionMapItemId 的情况。它先规范化当前地图,检查存档中的位置 ID 是否有效;如果缺失,则尝试用旧的 positionIndex 通过地图索引转换。如果旧索引也无法对应有效地图项,就记录警告并把玩家回退到地图起点。

迁移函数返回新的快照和警告列表,而不是直接修改原记录。SaveManager.loadForMap() 会同时迁移当前快照和 previousSnapshot,房间层可以把警告写入恢复日志。这样的加法式迁移允许旧字段继续存在,也不要求立即升级 IndexedDB 的结构。迁移逻辑只负责数据转换,游戏对象的创建和恢复仍由 Worker 完成。

玩家和地产恢复时还保留了对旧格式的兼容处理。例如旧存档中的自定义字段可能位于 exportData,恢复逻辑会将其展开到实体实例上。兼容代码应当集中在恢复入口,避免把历史格式判断散落在正常游戏逻辑中。

修饰器恢复:保存模板引用与运行时状态

修饰器是存档中最容易出问题的部分。一个修饰器通常包含条件函数和执行函数,而函数的闭包可能引用玩家、游戏进程或局部变量。仅保存 fn.toString() 只能得到函数源码,不能得到闭包环境,因此无法保证恢复后行为一致。

项目后来将修饰器模板从初始化代码中分离出来。存档保存修饰器模板的引用和运行时状态,恢复时根据当前地图中的模板重新建立实例。这样,存档不必复制完整函数闭包,模板也可以在多个角色或地产上复用,同时保持运行时实例的独立状态。这个方案要求地图版本与模板定义保持兼容,因此存档加载必须先确认地图身份和版本。

设计取舍

这套存档系统有几个明确的取舍。第一,快照只保存可恢复状态,不追求把任意 JavaScript 对象完整序列化;这使格式更稳定,但要求地图脚本通过显式字段保存自己的状态。第二,恢复沿用完整的游戏启动流程,而不是单独构造一套“快速恢复”流程;启动步骤更多,但初始化逻辑只有一份,减少了正常启动与读档启动之间的差异。第三,迁移采取小步、可记录警告的方式,优先保证旧存档可读,同时把无法无损恢复的字段暴露给日志。

对在线游戏而言,存档并不是一个独立的数据库功能。它必须和对象模型、脚本扩展、地图版本、玩家身份以及客户端初始化顺序共同设计。只有先定义状态边界,再安排恢复时机,IndexedDB 才能成为可靠的持久化工具,而不是一份无法重新运行的对象副本。