# 存档系统与数据持久化手册 > 文件位置:`Docs/Guides/04_SaveSystem_DataPersistence_Guide.md` > 版本:1.0 · 适用项目:zeling_v2 --- ## 目录 1. [架构概览](#1-架构概览) 2. [SaveData 数据模型](#2-savedata-数据模型) 3. [存档读写完整时序](#3-存档读写完整时序) - 3.1 [保存流程(Save)](#31-保存流程save) - 3.2 [加载流程(Load)](#32-加载流程load) - 3.3 [场景切换时的世界状态恢复](#33-场景切换时的世界状态恢复) 4. [ISaveable 接入模式](#4-isaveable-接入模式) 5. [存档槽管理与 UI 集成](#5-存档槽管理与-ui-集成) 6. [序列化与完整性校验](#6-序列化与完整性校验) 7. [存档迁移(SaveMigrator)](#7-存档迁移savemigrator) 8. [Inspector 配置指南](#8-inspector-配置指南) 9. [扩展指南](#9-扩展指南) 10. [商业对标评估](#10-商业对标评估) 11. [常见问题排查](#11-常见问题排查) --- ## 1. 架构概览 存档系统由四个层次组成,严格单向依赖,Core 层不持有任何 Unity UI 引用: ``` ┌───────────────────────────────────────────────────────────┐ │ UI 层(SaveSlotController / SaveSlotUI) │ │ 展示槽摘要、触发存档槽确认 │ └────────────────────┬──────────────────────────────────────┘ │ EVT_SlotConfirmed(IntEventChannelSO) ┌────────────────────▼──────────────────────────────────────┐ │ 服务接口层(ISaveService / ISaveableRegistry) │ │ SaveServiceAdapter — 将 MonoBehaviour API 包装为接口 │ └────────────────────┬──────────────────────────────────────┘ │ ServiceLocator.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 写入磁盘。 ### 顶层结构 ```csharp 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 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) │ │ └─ 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(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 接入模式 ### 接口定义 ```csharp public interface ISaveable { void OnSave(SaveData saveData); // 将自身状态写入 saveData(保存前调用) void OnLoad(SaveData saveData); // 从 saveData 恢复自身状态(加载后调用) } ``` ### 标准接入模板 ```csharp public class MyWorldObject : MonoBehaviour, ISaveable { [SerializeField] private string _uniqueId; // Inspector 中配置唯一 ID(场景内唯一) private ISaveableRegistry _registry; private bool _isCollected; private void OnEnable() { _registry = ServiceLocator.Get(); _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` 在 `GameSaveManager` Inspector 中配置(`[SerializeField] private string _hmacKey`)。 > 不可将 Key 硬编码在代码中。生产发布前应使用随机生成的唯一 Key 替换默认值。 --- ## 7. 存档迁移(SaveMigrator) 当代码层 `SaveMigrator.CurrentVersion` > 存档文件 `Meta.Version` 时,迁移器自动执行。 ### 添加新迁移补丁 在 `SaveMigrator.cs` 的 `Migrate()` 方法中追加: ```csharp // 版本 2 → 3:为 Inventory 增加 FavoriteSlots 字段 if (data.Meta.Version < 3) { data.Inventory ??= new InventorySaveData(); data.Inventory.FavoriteSlots ??= new List(); 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. 扩展指南 ### 添加新的存档子数据节点 1. 新建 `[Serializable]` 数据类,如 `SkillsSaveData`。 2. 在 `SaveData` 中添加公开字段:`public SkillsSaveData Skills;`。 3. 递增 `SaveMigrator.CurrentVersion` 并添加迁移补丁(初始化默认值)。 4. 在需要持久化的组件中实现 `ISaveable.OnSave` / `OnLoad` 读写 `saveData.Skills`。 ### 接入云存档(iCloud / Google Play Games) 1. 实现 `ISaveStorage` 接口,新建 `CloudSaveStorage.cs`。 2. 在 `GameServiceRegistrar.Awake()` 中根据平台选择注入: ```csharp #if UNITY_IOS _saveStorage = GetComponent(); #elif UNITY_ANDROID _saveStorage = GetComponent(); #else _saveStorage = GetComponent(); #endif ServiceLocator.Register(_saveStorage); ``` 3. 云存档冲突解决策略建议:以 `Meta.SaveCount` 最大者为准(总是取保存次数更多的版本)。 ### 添加存档槽截图预览 在 `GameSaveManager.SaveAsync()` 末尾注入: ```csharp // 截图后以 Texture2D PNG 压缩存储到 Meta.PreviewImageBase64 var tex = await ScreenCapture.CaptureScreenshotAsTextureAsync(); _current.Meta.PreviewImageBase64 = Convert.ToBase64String(tex.EncodeToPNG()); ``` 在 `SaveSlotUI` 中使用 `Meta.PreviewImageBase64` 创建 `Sprite` 并显示。 ### 实现自动存档(定时 + 过图触发) ```csharp // 在 PlayerController 的场景加载完成回调中: private void HandleSceneWorldStateRestored() { ServiceLocator.Get().SaveAsync(_currentSlot); } // 在 GameManager 的定时协程中: private IEnumerator AutoSaveLoop() { while (true) { yield return new WaitForSeconds(300f); // 每 5 分钟 if (_fsm.Current == GameStateId.Gameplay) await ServiceLocator.Get().SaveAsync(_currentSlot); } } ``` --- ## 10. 商业对标评估 以下评估以"丝之歌"级别商业 2D 动作游戏(Steam/主机发行标准)为基准,逐项审核当前存档系统的完备性与商业成熟度。 ### ✅ 已达到商业标准 | 维度 | 当前实现 | 评价 | |---|---|---| | **三存档槽** | SlotIndex 0/1/2 | 符合行业惯例,足够主流平台需求 | | **钢铁之魂保护** | IsSteelSoul + HMAC 拒绝加载被篡改存档 | 与 Hollow Knight 同等保护级别 | | **存档摘要展示** | SlotSummary(时长/位置/零珠/血量)| 满足玩家快速辨识存档内容的需求 | | **存档版本迁移** | SaveMigrator 补丁链 | 支持无缝热更新存档结构 | | **DLC 扩展点** | `Dictionary` | 未来 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 验证。 **解决:** 1. 开发阶段:统一使用版本控制中记录的固定 Key(在 `ProjectSettings` 或 `StreamingAssets` 中管理)。 2. 生产阶段:构建流水线注入唯一 Key,每个游戏版本的 Key 不变(只在大版本号更迭时更换)。 --- ### ❌ 存档文件体积异常(> 500 KB) **原因:** 某个 ISaveable 在 `OnSave` 中写入了图像数据、大型列表或未过滤的字典。 **解决:** 在 `SaveAsync` 后添加日志: ```csharp Debug.Log($"[SaveManager] Slot {slot} size: {Encoding.UTF8.GetByteCount(json)} bytes"); ``` 逐一排查各 `ISaveable.OnSave` 调用前后的数据差量,定位膨胀来源。 --- *文档最后更新:2026-06-04*