23 KiB
存档系统与数据持久化手册
文件位置:
Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
版本:1.0 · 适用项目:zeling_v2
目录
- 架构概览
- SaveData 数据模型
- 存档读写完整时序
- 3.1 保存流程(Save)
- 3.2 加载流程(Load)
- 3.3 场景切换时的世界状态恢复
- ISaveable 接入模式
- 存档槽管理与 UI 集成
- 序列化与完整性校验
- 存档迁移(SaveMigrator)
- Inspector 配置指南
- 扩展指南
- 商业对标评估
- 常见问题排查
1. 架构概览
存档系统由四个层次组成,严格单向依赖,Core 层不持有任何 Unity UI 引用:
┌───────────────────────────────────────────────────────────┐
│ UI 层(SaveSlotController / SaveSlotUI) │
│ 展示槽摘要、触发存档槽确认 │
└────────────────────┬──────────────────────────────────────┘
│ EVT_SlotConfirmed(IntEventChannelSO)
┌────────────────────▼──────────────────────────────────────┐
│ 服务接口层(ISaveService / ISaveableRegistry) │
│ SaveServiceAdapter — 将 MonoBehaviour API 包装为接口 │
└────────────────────┬──────────────────────────────────────┘
│ ServiceLocator<ISaveService>.Get()
┌────────────────────▼──────────────────────────────────────┐
│ 管理器层(GameSaveManager) │
│ 协调 ISaveable 集合、调用 ISaveStorage、管理内存状态 │
└────────────────────┬──────────────────────────────────────┘
│
┌────────────────────▼──────────────────────────────────────┐
│ 存储层(ISaveStorage / LocalFileStorage) │
│ 负责实际的磁盘 IO(JSON 文件,可替换为云存档) │
└───────────────────────────────────────────────────────────┘
横切关注点:
ISaveable ← 各游戏组件(Player、WorldObject、Quest 等)实现此接口
SaveData ← 唯一的内存数据容器,各 ISaveable 读写其子节点
核心设计原则:
GameSaveManager不感知任何具体业务数据——它只是一个调度者;业务数据由各ISaveable组件自行读写SaveData的对应字段。ISaveStorage是磁盘操作的唯一入口,方便在测试或平台适配时替换为内存存储或云存储。- 所有跨层通信通过
ServiceLocator或 SO 事件频道进行,Core 层与 UI 层完全解耦。
2. SaveData 数据模型
SaveData 是游戏唯一的持久化数据容器,序列化为 JSON 写入磁盘。
顶层结构
public class SaveData
{
public SaveMeta Meta; // 元数据:版本、时间戳、校验和
public PlayerSaveData Player; // 玩家:HP、零珠、形态、死亡影
public EquipmentSaveData Equipment; // 装备:符文、凹槽
public WorldSaveData World; // 世界:访问过的场景、已开门、已击败Boss、已拾取物品
public MapSaveData Map; // 地图:已探索房间、标记、传送点
public QuestSaveData Quests; // 任务:状态、目标进度
public AchievementSaveData Achievements;
public ToolsSaveData Tools; // 工具:已装备、已持有
public ChallengeRoomsSaveData ChallengeRooms; // 挑战间:最高分、最佳时间
public EventChainsSaveData EventChains; // 事件链:完成标记、世界旗标
public ShopsSaveData Shops; // 商店:已售出独特道具、购买次数
public InventorySaveData Inventory; // 背包:物品数量、新物品列表
public JournalSaveData Journal; // 图鉴:已发现敌人、已解锁传说
public StatsSaveData Stats; // 统计:击杀数、死亡数、移动距离
public NGPlusSaveData NGPlus; // NG+(null = 非 NG+ 存档)
public TutorialSaveData Tutorial; // 教程:已完成提示 ID
public SettingsSaveData Settings; // 设置:语言偏好等
public Dictionary<string, JObject> DLC; // DLC 扩展点(开放式)
}
SaveMeta 元数据
| 字段 | 类型 | 说明 |
|---|---|---|
Version |
int |
对应 SaveMigrator.CurrentVersion,用于向前迁移 |
SlotIndex |
int |
0–2 为普通槽,98 = QuickSave |
LastSaved |
string |
ISO 8601 时间戳(如 "2026-06-04T14:30:00Z") |
Playtime |
float |
累计游戏秒数 |
SavePointId |
string |
最后使用的检查点/存档点 ID |
NGPlusCount |
int |
NG+ 周目计数(0 = 初周目) |
SaveCount |
int |
累计保存次数(用于存档槽 UI 徽章显示) |
Checksum |
string |
HMAC-SHA256(Base64),用于完整性校验 |
IsSteelSoul |
bool |
钢铁之魂模式(一命通关)标志,锁定后不可修改 |
摘要数据(SlotSummary)
GetSlotSummaryAsync(int slot) 返回用于 UI 展示的轻量对象,不加载完整 SaveData:
| 字段 | 说明 |
|---|---|
Playtime |
游戏时长(格式化后用于 UI 显示) |
LocationName |
上次存档的场景显示名(本地化 Key) |
Currency |
零珠数量 |
MaxHP |
当前最大血量 |
IsSteelSoul |
是否为钢铁之魂存档(UI 显示特殊徽章) |
HasData |
该槽是否有存档(空槽 = false) |
3. 存档读写完整时序
3.1 保存流程(Save)
触发点:检查点交互 / 自动存档触发器 / 手动调用 ISaveService.SaveAsync(slot)
│
├─ GameSaveManager.SaveAsync(slot)
│ │
│ ├─ 1. await _saveLock.WaitAsync() ← 防止并发写入
│ │
│ ├─ 2. 更新 Meta
│ │ └─ Meta.LastSaved = DateTime.UtcNow.ToString("o")
│ │ Meta.Playtime += (Time.time - _sessionStartTime)
│ │ Meta.SaveCount += 1
│ │
│ ├─ 3. 遍历 _saveables(HashSet<ISaveable>)
│ │ └─ foreach ISaveable s → s.OnSave(_current)
│ │ (各组件将自身状态写入 _current 对应字段)
│ │
│ ├─ 4. 序列化 + 注入校验和
│ │ ├─ _current.Meta.Checksum = null ← 归零,确保 HMAC 计算一致
│ │ ├─ json = JsonConvert.SerializeObject(_current)
│ │ ├─ hmac = ComputeHMAC(json)
│ │ └─ json = InjectChecksum(json, hmac) ← 字符串替换,避免二次序列化
│ │
│ └─ 5. _storage.WriteAsync(slot, json)
│ └─ 写入本地文件(路径:{persistentDataPath}/saves/slot_{slot}.json)
│
└─ _saveLock.Release()
副作用:
EVT_SaveCompleted(可选)─► 触发存档点 UI 动画(旋转图标 → 对号)
3.2 加载流程(Load)
触发点:主菜单「继续」选择存档 / 死亡后在检查点复活
│
├─ GameSaveManager.LoadAsync(slot)
│ │
│ ├─ 1. json = await _storage.ReadAsync(slot)
│ │
│ ├─ 2. data = JsonConvert.DeserializeObject<SaveData>(json)
│ │
│ ├─ 3. 校验和验证
│ │ ├─ extractedHMAC = data.Meta.Checksum
│ │ ├─ data.Meta.Checksum = null
│ │ ├─ recomputedHMAC = ComputeHMAC(JsonConvert.SerializeObject(data))
│ │ └─ if (extractedHMAC != recomputedHMAC)
│ │ IsSteelSoul → 拒绝加载(防存档篡改作弊)
│ │ Normal Mode → 记录警告,允许加载(兼容旧版本存档)
│ │
│ ├─ 4. SaveMigrator.Migrate(ref data)
│ │ └─ 若 data.Meta.Version < CurrentVersion,执行版本迁移补丁
│ │
│ ├─ 5. _current = data;_currentSlot = slot
│ │
│ └─ 6. 遍历 _saveables(此时已注册的组件)
│ └─ foreach ISaveable s → s.OnLoad(_current)
│
└─ 加载完成
注意:
加载流程不触发 EVT_SceneLoadRequest。
场景切换由调用方(MainMenuController)在加载前发起。
OnLoad 在新场景的 OnEnable 阶段由 ISaveableRegistry 驱动。
3.3 场景切换时的世界状态恢复
SceneService.LoadSceneCoroutine()
│
├─ (省略)淡出 → 加载场景 → 等待一帧
│
├─ EVT_SceneWorldStateRestored.Raise()
│ └─ 新场景中所有已 OnEnable 的 ISaveable 组件收到通知
│ └─ 重新调用 ISaveableRegistry.OnSceneRestored()
│ └─ foreach ISaveable → s.OnLoad(_current)
│ (仅恢复场景相关状态:已拾取物品、已销毁对象、已开门等)
│
└─ EVT_FadeInRequest.Raise() ← 世界状态恢复完成后再淡入,避免闪烁
4. ISaveable 接入模式
接口定义
public interface ISaveable
{
void OnSave(SaveData saveData); // 将自身状态写入 saveData(保存前调用)
void OnLoad(SaveData saveData); // 从 saveData 恢复自身状态(加载后调用)
}
标准接入模板
public class MyWorldObject : MonoBehaviour, ISaveable
{
[SerializeField] private string _uniqueId; // Inspector 中配置唯一 ID(场景内唯一)
private ISaveableRegistry _registry;
private bool _isCollected;
private void OnEnable()
{
_registry = ServiceLocator.Get<ISaveableRegistry>();
_registry.Register(this); // 注册:若已加载则立即调用 OnLoad
}
private void OnDisable()
{
_registry?.Unregister(this); // 取消注册:防止悬空引用
}
public void OnSave(SaveData saveData)
{
if (_isCollected)
saveData.World.CollectedItemIds.Add(_uniqueId);
}
public void OnLoad(SaveData saveData)
{
_isCollected = saveData.World.CollectedItemIds.Contains(_uniqueId);
gameObject.SetActive(!_isCollected); // 已拾取则隐藏
}
}
接入规则
| 规则 | 原因 |
|---|---|
必须在 OnEnable 注册,OnDisable 取消注册 |
防止场景卸载后 GameSaveManager 持有悬空组件引用 |
OnSave 只写,OnLoad 只读 |
避免在 OnLoad 中触发副作用(如粒子、音频)导致加载时闪烁 |
_uniqueId 必须在场景内唯一 |
存档数据以 ID 为键,重复 ID 会造成存档互相覆盖 |
不要在 OnSave/OnLoad 中操作 Transform 或启动 Coroutine |
此时帧时序不稳定,应改在 OnLoad 后的首帧 Update 中执行 |
5. 存档槽管理与 UI 集成
存档槽 UI 职责分离
SaveSlotController(ScriptableObject 事件驱动)
│
├─ OnEnable()
│ └─ RefreshAsync()
│ └─ 并行调用 ISaveService.GetSlotSummaryAsync(0/1/2)
│ └─ 更新 SaveSlotUI[i](卡片显示)
│
├─ 模式:NewGame
│ ├─ 空槽点击 → 显示 NewGameModeController(普通 / 钢铁之魂选择)
│ └─ 已占用槽点击 → 显示 ConfirmDialogController(覆盖确认)
│ └─ 确认 → 显示 NewGameModeController
│
└─ 模式:Continue
└─ 有存档槽点击 → ISaveService.LoadAsync(slot)
→ Raise EVT_SlotConfirmed(slot)
→ MainMenuController.HandleSlotConfirmed()
→ Raise EVT_SceneLoadRequest
新游戏创建流程
NewGameModeController 玩家选择(普通 / 钢铁之魂)
│
└─ ISaveService.CreateSlot(slotIndex, steelSoul: bool)
├─ new SaveData(),写入默认值
├─ Meta.IsSteelSoul = steelSoul
├─ Meta.SlotIndex = slotIndex
└─ 存入内存 _current(不立即写磁盘)
└─ Raise EVT_SlotConfirmed(slotIndex)
└─ MainMenuController 触发场景加载
└─ 首次进入 Gameplay 后的第一个检查点交互触发首次写盘
存档槽 UI 元素规范
| UI 元素 | 数据来源 | 备注 |
|---|---|---|
| 存档时长 | SlotSummary.Playtime |
格式化为 HH:MM:SS |
| 最后位置 | SlotSummary.LocationName(本地化 Key) |
通过 LocalizationService 解析 |
| 零珠数量 | SlotSummary.Currency |
显示硬币图标 + 数值 |
| 血量进度 | SlotSummary.MaxHP |
用于显示心形图标数 |
| 钢铁之魂徽章 | SlotSummary.IsSteelSoul |
条件激活特殊 UI 装饰 |
| 空槽占位 | !SlotSummary.HasData |
显示「空」或「点击开始」提示 |
6. 序列化与完整性校验
JSON 序列化配置
- 库:
Newtonsoft.Json(JsonConvert) - 设置:
NullValueHandling.Ignore(减少文件体积) - 日期格式:ISO 8601(
"o"格式)
HMAC-SHA256 校验流程
保存时:
1. _current.Meta.Checksum = null
2. json = Serialize(_current)
3. hmac = HMACSHA256.ComputeHash(Encoding.UTF8.GetBytes(json), _secretKey)
4. json = json.Replace("\"Checksum\":null", "\"Checksum\":\"" + base64hmac + "\"")
写盘 → json(含校验和)
加载时:
1. json = 从磁盘读取
2. data = Deserialize(json)
3. stored = data.Meta.Checksum
4. data.Meta.Checksum = null
5. expected = HMACSHA256.ComputeHash(Serialize(data))
6. if stored != expected:
IsSteelSoul → 拒绝(防篡改保护)
Normal → 警告日志,允许(向后兼容)
注意:
_secretKey在GameSaveManagerInspector 中配置([SerializeField] private string _hmacKey)。
不可将 Key 硬编码在代码中。生产发布前应使用随机生成的唯一 Key 替换默认值。
7. 存档迁移(SaveMigrator)
当代码层 SaveMigrator.CurrentVersion > 存档文件 Meta.Version 时,迁移器自动执行。
添加新迁移补丁
在 SaveMigrator.cs 的 Migrate() 方法中追加:
// 版本 2 → 3:为 Inventory 增加 FavoriteSlots 字段
if (data.Meta.Version < 3)
{
data.Inventory ??= new InventorySaveData();
data.Inventory.FavoriteSlots ??= new List<string>();
data.Meta.Version = 3;
}
版本历史规范
| 版本 | 变更说明 | 迁移操作 |
|---|---|---|
| 1 | 初始版本 | — |
| 2 | 新增 Stats.DistanceTraveled |
默认 0f,无需迁移 |
| 3 | 新增 Inventory.FavoriteSlots |
初始化为空列表 |
每次存档结构变动都必须递增版本号并编写迁移补丁,否则旧存档加载时会报
NullReferenceException。
8. Inspector 配置指南
GameSaveManager(挂载在 Persistent 场景 [SERVICES] 下)
| Inspector 字段 | 赋值 | 说明 |
|---|---|---|
_storage |
LocalFileStorage 组件引用 |
磁盘 IO 实现;测试时可替换为 InMemorySaveStorage |
_hmacKey |
任意非空字符串(生产环境用随机 UUID) | 校验和计算密钥 |
_onSaveCompleted(可选) |
EVT_SaveCompleted(VoidEventChannelSO) |
触发存档点 UI 反馈动画 |
LocalFileStorage(与 GameSaveManager 同节点)
| Inspector 字段 | 赋值 | 说明 |
|---|---|---|
_saveDirectory |
留空(默认 Application.persistentDataPath/saves/) |
可覆盖为绝对路径(仅测试用) |
_fileExtension |
".json" |
存档文件后缀 |
SaveServiceAdapter(与 GameSaveManager 同节点)
无需配置——它是一个纯粹的接口转发层,仅需保证与 GameSaveManager 在同一 GameObject 上。
事件频道速查
| SO 名称 | 类型 | 发布者 | 订阅者 |
|---|---|---|---|
EVT_SlotConfirmed |
IntEventChannelSO | SaveSlotController | MainMenuController |
EVT_SaveCompleted(可选) |
VoidEventChannelSO | GameSaveManager | SavePointController(UI 动画) |
EVT_SceneWorldStateRestored |
VoidEventChannelSO | SceneService | (所有 ISaveable 组件间接响应) |
9. 扩展指南
添加新的存档子数据节点
- 新建
[Serializable]数据类,如SkillsSaveData。 - 在
SaveData中添加公开字段:public SkillsSaveData Skills;。 - 递增
SaveMigrator.CurrentVersion并添加迁移补丁(初始化默认值)。 - 在需要持久化的组件中实现
ISaveable.OnSave/OnLoad读写saveData.Skills。
接入云存档(iCloud / Google Play Games)
- 实现
ISaveStorage接口,新建CloudSaveStorage.cs。 - 在
GameServiceRegistrar.Awake()中根据平台选择注入:#if UNITY_IOS _saveStorage = GetComponent<iCloudSaveStorage>(); #elif UNITY_ANDROID _saveStorage = GetComponent<GooglePlaySaveStorage>(); #else _saveStorage = GetComponent<LocalFileStorage>(); #endif ServiceLocator.Register<ISaveStorage>(_saveStorage); - 云存档冲突解决策略建议:以
Meta.SaveCount最大者为准(总是取保存次数更多的版本)。
添加存档槽截图预览
在 GameSaveManager.SaveAsync() 末尾注入:
// 截图后以 Texture2D PNG 压缩存储到 Meta.PreviewImageBase64
var tex = await ScreenCapture.CaptureScreenshotAsTextureAsync();
_current.Meta.PreviewImageBase64 = Convert.ToBase64String(tex.EncodeToPNG());
在 SaveSlotUI 中使用 Meta.PreviewImageBase64 创建 Sprite 并显示。
实现自动存档(定时 + 过图触发)
// 在 PlayerController 的场景加载完成回调中:
private void HandleSceneWorldStateRestored()
{
ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
}
// 在 GameManager 的定时协程中:
private IEnumerator AutoSaveLoop()
{
while (true)
{
yield return new WaitForSeconds(300f); // 每 5 分钟
if (_fsm.Current == GameStateId.Gameplay)
await ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
}
}
10. 商业对标评估
以下评估以"丝之歌"级别商业 2D 动作游戏(Steam/主机发行标准)为基准,逐项审核当前存档系统的完备性与商业成熟度。
✅ 已达到商业标准
| 维度 | 当前实现 | 评价 |
|---|---|---|
| 三存档槽 | SlotIndex 0/1/2 | 符合行业惯例,足够主流平台需求 |
| 钢铁之魂保护 | IsSteelSoul + HMAC 拒绝加载被篡改存档 | 与 Hollow Knight 同等保护级别 |
| 存档摘要展示 | SlotSummary(时长/位置/零珠/血量) | 满足玩家快速辨识存档内容的需求 |
| 存档版本迁移 | SaveMigrator 补丁链 | 支持无缝热更新存档结构 |
| DLC 扩展点 | Dictionary<string, JObject> |
未来 DLC 内容无需修改核心存档类 |
| NG+ 数据 | NGPlusSaveData 节点保留 |
为周目系统预留专用空间 |
| ISaveable 自注册 | OnEnable/OnDisable 生命周期驱动 | 无需手动在 Manager 中维护组件列表 |
| 存储层抽象 | ISaveStorage 接口 |
云存档接入改动范围仅限适配层 |
⚠️ 建议改进项
| 维度 | 当前状态 | 建议 |
|---|---|---|
| 存档截图预览 | 未实现 | 主流商业游戏(如《原神》《空洞骑士》续作)普遍提供。建议在 Save 时截图并以 Base64 存入 Meta |
| 自动存档策略 | 未见独立的 AutoSaveService 实现 | 建议以两种触发结合:场景加载完成(过图自动存)+ 定时(每 5 分钟) |
| 存档文件加密 | 当前仅 HMAC 完整性校验,未加密 | PC 平台可接受,若有主机认证要求(PS/Xbox)需配合平台 SDK 云加密 |
| 存档大小监控 | 未见大小上限检测 | 主机平台认证通常要求存档 ≤ 1–2 MB,建议在 SaveAsync 后记录日志文件大小 |
| QuickSave 槽编号 | 使用 SlotIndex = 98(魔法数字) |
建议用具名常量 SaveSlots.QuickSave = 98 替换,避免他处误用 |
| 存档损坏降级恢复 | Normal 模式下校验失败仅记录警告 | 建议在校验失败时尝试加载同槽的 .bak 备份文件(每次存档前备份一次) |
| 写入原子性 | 直接覆盖目标文件 | 建议先写 .tmp 文件,写入成功后再原子重命名,防止写入中断导致存档损坏 |
❌ 商业发行前必须补全
| 维度 | 当前状态 | 影响 |
|---|---|---|
| HMAC 密钥管理 | _hmacKey 为 Inspector 可见字段 |
若使用默认值,钢铁之魂保护形同虚设;正式构建前必须在构建管线中注入随机密钥并从 Inspector 中移除 |
| 平台存档路径合规 | 仅实现 persistentDataPath 本地存储 |
PS/Xbox 认证要求使用平台 SDK 的存档 API(PS: savedata://,Xbox: XboxStorage) |
11. 常见问题排查
❌ 加载存档后部分对象状态未恢复(仍显示默认状态)
原因 1: 目标组件的 OnEnable 在 EVT_SceneWorldStateRestored 触发之后才执行(异步实例化的 Prefab)。
解决: 在组件的 OnEnable 中检查 _registry.IsLoaded,若为 true 则立即调用 OnLoad(_registry.CurrentData)。
原因 2: 忘记在 OnDisable 调用 _registry.Unregister(this),导致旧实例残留,新实例的 OnLoad 被跳过。
解决: 确保每个 ISaveable.OnEnable 都对应一个 OnDisable.Unregister。
❌ 点击「继续」后游戏场景加载但世界对象全部重置
原因: MainMenuController.HandleSlotConfirmed 在调用 ISaveService.LoadAsync 之前就发起了 EVT_SceneLoadRequest,导致新场景的 ISaveable 在 OnLoad 之前已触发 EVT_SceneWorldStateRestored。
解决: 调用顺序必须为:LoadAsync(slot) → await → Raise(EVT_SceneLoadRequest)。
❌ 存档校验失败:Invalid checksum 警告频繁出现
原因: 多个编辑器/构建版本使用了不同的 _hmacKey 值,旧存档无法通过当前 Key 验证。
解决:
- 开发阶段:统一使用版本控制中记录的固定 Key(在
ProjectSettings或StreamingAssets中管理)。 - 生产阶段:构建流水线注入唯一 Key,每个游戏版本的 Key 不变(只在大版本号更迭时更换)。
❌ 存档文件体积异常(> 500 KB)
原因: 某个 ISaveable 在 OnSave 中写入了图像数据、大型列表或未过滤的字典。
解决: 在 SaveAsync 后添加日志:
Debug.Log($"[SaveManager] Slot {slot} size: {Encoding.UTF8.GetByteCount(json)} bytes");
逐一排查各 ISaveable.OnSave 调用前后的数据差量,定位膨胀来源。
文档最后更新:2026-06-04