Files
zeling_v2/Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
2026-06-05 18:41:33 +08:00

23 KiB
Raw Blame History

存档系统与数据持久化手册

文件位置:Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
版本1.0 · 适用项目zeling_v2


目录

  1. 架构概览
  2. SaveData 数据模型
  3. 存档读写完整时序
  4. ISaveable 接入模式
  5. 存档槽管理与 UI 集成
  6. 序列化与完整性校验
  7. 存档迁移SaveMigrator
  8. Inspector 配置指南
  9. 扩展指南
  10. 商业对标评估
  11. 常见问题排查

1. 架构概览

存档系统由四个层次组成严格单向依赖Core 层不持有任何 Unity UI 引用:

┌───────────────────────────────────────────────────────────┐
│  UI 层SaveSlotController / SaveSlotUI                  │
│    展示槽摘要、触发存档槽确认                               │
└────────────────────┬──────────────────────────────────────┘
                     │ EVT_SlotConfirmedIntEventChannelSO
┌────────────────────▼──────────────────────────────────────┐
│  服务接口层ISaveService / ISaveableRegistry            │
│    SaveServiceAdapter — 将 MonoBehaviour API 包装为接口    │
└────────────────────┬──────────────────────────────────────┘
                     │ ServiceLocator<ISaveService>.Get()
┌────────────────────▼──────────────────────────────────────┐
│  管理器层GameSaveManager                               │
│    协调 ISaveable 集合、调用 ISaveStorage、管理内存状态     │
└────────────────────┬──────────────────────────────────────┘
                     │
┌────────────────────▼──────────────────────────────────────┐
│  存储层ISaveStorage / LocalFileStorage                 │
│    负责实际的磁盘 IOJSON 文件,可替换为云存档)           │
└───────────────────────────────────────────────────────────┘

横切关注点:
  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 02 为普通槽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-SHA256Base64用于完整性校验
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. 遍历 _saveablesHashSet<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 职责分离

SaveSlotControllerScriptableObject 事件驱动)
│
├─ 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.JsonJsonConvert
  • 设置: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      → 警告日志,允许(向后兼容)

注意_secretKeyGameSaveManager Inspector 中配置([SerializeField] private string _hmacKey)。
不可将 Key 硬编码在代码中。生产发布前应使用随机生成的唯一 Key 替换默认值。


7. 存档迁移SaveMigrator

当代码层 SaveMigrator.CurrentVersion > 存档文件 Meta.Version 时,迁移器自动执行。

添加新迁移补丁

SaveMigrator.csMigrate() 方法中追加:

// 版本 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_SaveCompletedVoidEventChannelSO 触发存档点 UI 反馈动画

LocalFileStorage与 GameSaveManager 同节点)

Inspector 字段 赋值 说明
_saveDirectory 留空(默认 Application.persistentDataPath/saves/ 可覆盖为绝对路径(仅测试用)
_fileExtension ".json" 存档文件后缀

SaveServiceAdapter与 GameSaveManager 同节点)

无需配置——它是一个纯粹的接口转发层,仅需保证与 GameSaveManager 在同一 GameObject 上。

事件频道速查

SO 名称 类型 发布者 订阅者
EVT_SlotConfirmed IntEventChannelSO SaveSlotController MainMenuController
EVT_SaveCompleted(可选) VoidEventChannelSO GameSaveManager SavePointControllerUI 动画)
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() 中根据平台选择注入:
    #if UNITY_IOS
        _saveStorage = GetComponent<iCloudSaveStorage>();
    #elif UNITY_ANDROID
        _saveStorage = GetComponent<GooglePlaySaveStorage>();
    #else
        _saveStorage = GetComponent<LocalFileStorage>();
    #endif
    ServiceLocator.Register<ISaveStorage>(_saveStorage);
    
  3. 云存档冲突解决策略建议:以 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 云加密
存档大小监控 未见大小上限检测 主机平台认证通常要求存档 ≤ 12 MB建议在 SaveAsync 后记录日志文件大小
QuickSave 槽编号 使用 SlotIndex = 98(魔法数字) 建议用具名常量 SaveSlots.QuickSave = 98 替换,避免他处误用
存档损坏降级恢复 Normal 模式下校验失败仅记录警告 建议在校验失败时尝试加载同槽的 .bak 备份文件(每次存档前备份一次)
写入原子性 直接覆盖目标文件 建议先写 .tmp 文件,写入成功后再原子重命名,防止写入中断导致存档损坏

商业发行前必须补全

维度 当前状态 影响
HMAC 密钥管理 _hmacKey 为 Inspector 可见字段 若使用默认值,钢铁之魂保护形同虚设;正式构建前必须在构建管线中注入随机密钥并从 Inspector 中移除
平台存档路径合规 仅实现 persistentDataPath 本地存储 PS/Xbox 认证要求使用平台 SDK 的存档 APIPS: savedata://Xbox: XboxStorage

11. 常见问题排查

加载存档后部分对象状态未恢复(仍显示默认状态)

原因 1 目标组件的 OnEnableEVT_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,导致新场景的 ISaveableOnLoad 之前已触发 EVT_SceneWorldStateRestored
解决: 调用顺序必须为:LoadAsync(slot)awaitRaise(EVT_SceneLoadRequest)


存档校验失败:Invalid checksum 警告频繁出现

原因: 多个编辑器/构建版本使用了不同的 _hmacKey 值,旧存档无法通过当前 Key 验证。
解决:

  1. 开发阶段:统一使用版本控制中记录的固定 KeyProjectSettingsStreamingAssets 中管理)。
  2. 生产阶段:构建流水线注入唯一 Key每个游戏版本的 Key 不变(只在大版本号更迭时更换)。

存档文件体积异常(> 500 KB

原因: 某个 ISaveable 在 OnSave 中写入了图像数据、大型列表或未过滤的字典。
解决:SaveAsync 后添加日志:

Debug.Log($"[SaveManager] Slot {slot} size: {Encoding.UTF8.GetByteCount(json)} bytes");

逐一排查各 ISaveable.OnSave 调用前后的数据差量,定位膨胀来源。


文档最后更新2026-06-04