Files
zeling_v2/Docs/Architecture/12_SaveModule.md
2026-05-08 11:04:00 +08:00

43 KiB
Raw Permalink Blame History

12 · 存档模块

命名空间 BaseGames.Core.Save
程序集 BaseGames.Core.Save
路径 Assets/Scripts/Core/Save/
依赖 Newtonsoft.Jsoncom.unity.nuget.newtonsoft-json


目录

  1. SaveData C# 数据结构
  2. ISaveStorage 接口
  3. ISaveable 接口
  4. SaveManager
  5. SaveMigrator
  6. 保存流程(详细时序)
  7. 加载流程(详细时序)
  8. 存档路径与文件规范

1. SaveData C# 数据结构

所有数据类使用 [Serializable],由 Newtonsoft.Json 序列化为 JSON。

namespace BaseGames.Core.Save
{
    // ─── 顶层 ──────────────────────────────────────────────────
    [Serializable]
    public class SaveData
    {
        [JsonExtensionData]
        public Dictionary<string, JToken> ExtensionData = new();

        public SaveMeta              Meta              = new();
        public PlayerSaveData        Player            = new();
        public EquipmentSaveData     Equipment         = new();
        public WorldSaveData         World             = new();
        public MapSaveData           Map               = new();
        public QuestSaveData         Quests            = new();
        public AchievementSaveData   Achievements      = new();
        public ToolsSaveData         Tools             = new();
        public ChallengeRoomsSaveData ChallengeRooms   = new();
        public EventChainsSaveData   EventChains       = new();
        public ShopsSaveData         Shops             = new();
        public StatsSaveData         Stats             = new();
        public NGPlusSaveData        NGPlus            = null;   // null = 非 NG+ 模式
        public Dictionary<string, JObject> DLC         = new();
    }

    // ─── Meta ───────────────────────────────────────────────────
    [Serializable]
    public class SaveMeta
    {
        public string  Version       = "2.1";   // 2.1: AbilityFlags uint 替换 Dictionary<string,bool>
        public int     SlotIndex;
        public string  LastSaved;       // ISO 8601
        public float   Playtime;
        public string  SavePointId;
        public int     NGPlusCount;
        public string  Checksum;        // SHA256 of 序列化后 JSON不含 checksum 字段)
    }

    // ─── Player ─────────────────────────────────────────────────
    [Serializable]
    public class PlayerSaveData
    {
        public float  PosX, PosY;
        public string Scene;
        public int    CurrentHP, MaxHP;
        public int    CurrentGeo, LifetimeGeo;

        // 能力解锁位掩码AbilityType [Flags] uint bitmask见 09_ProgressionModule §1
        // 存档版本 <2.1 的旧 Dictionary<string,bool> Abilities 由 SaveMigrator.MigrateV2ToV21 自动转换
        public uint AbilityFlags = 0;

        // 形态
        public string         ActiveFormId;
        public List<string>   UnlockedFormIds = new();

        // 死亡遗骸
        public DeathShadeSaveData DeathShade;

        // 护盾状态(见 20_ShieldModule §9
        // -1 = 满护盾(默认),>=0 = 当前耐久值
        public int  ShieldHP       = -1;
        public bool ShieldIsBroken = false;
    }

    [Serializable]
    public class DeathShadeSaveData
    {
        public float  PosX, PosY;
        public string SceneId;
        public int    GeoAmount;
    }

    // ─── Equipment ──────────────────────────────────────────────
    [Serializable]
    public class EquipmentSaveData
    {
        public List<string> EquippedCharmIds = new();
        public int          NotchesUsed;
        public int          MaxNotches;
        public List<string> OwnedCharmIds   = new();
        public List<string> UpgradedCharmIds = new();
        // 注:工具槽数据在独立的 ToolsSaveData顶层 SaveData.Tools不在此处重复
    }

    // ─── World ──────────────────────────────────────────────────
    [Serializable]
    public class WorldSaveData
    {
        public List<string>             VisitedScenes           = new();
        public List<string>             ActivatedSavePoints     = new();
        public List<string>             OpenedDoors             = new();
        public List<string>             DefeatedBossIds         = new();
        public List<string>             CollectedIds            = new();  // Collectibles
        public Dictionary<string, bool> Switches                = new();  // Levers/Buttons
        public Dictionary<string, int>  NpcRelations            = new();
        public HashSet<string>          ChallengeFirstClears    = new();  // 已首次通关的挑战 ID
    }

    // ─── Map ────────────────────────────────────────────────────
    [Serializable]
    public class MapSaveData
    {
        // key = 场景名value = 已探索房间 index 列表
        public Dictionary<string, List<int>> DiscoveredRooms = new();
        public Dictionary<string, bool>      MapPurchased    = new();
    }

    // ─── Quests ─────────────────────────────────────────────────
    [Serializable]
    public class QuestSaveData
    {
        public Dictionary<string, QuestState> QuestStates = new();
        public List<string> AvailableQuestIds = new();
    }

    [Serializable]
    public class QuestState
    {
        public string       Status;          // "NotStarted"|"Active"|"Completed"|"Failed"
        public int          ObjectiveIndex;
        public List<int>    ProgressCounts   = new();
        public string       GiverNpcId;
    }

    // ─── Achievements ───────────────────────────────────────────
    [Serializable]
    public class AchievementSaveData
    {
        public List<string> Unlocked = new();
        public Dictionary<string, AchievementProgress> Progress = new();
    }

    [Serializable]
    public class AchievementProgress
    {
        public int   Count;
        public float Percent;
    }

    // ─── Stats ──────────────────────────────────────────────────
    [Serializable]
    public class StatsSaveData
    {
        public int EnemyKills, Deaths, ParrySuccess, ParryFail;
        public int GeoEarned, GeoLost;
        public float DistanceTraveled;
        public int SaveCount;
        public Dictionary<string, int> SkillUseCounts = new();
        public Dictionary<string, int> DeathsByBoss   = new();
    }

    // ─── ToolsSaveData ───────────────────────────────────────────
    [Serializable]
    public class ToolsSaveData
    {
        public string ToolSlot0;
        public string ToolSlot1;
        public List<string> OwnedToolIds = new();
        public Dictionary<string, JObject> ToolStates = new();
    }

    // ─── ChallengeRoomsSaveData ──────────────────────────────────
    [Serializable]
    public class ChallengeRoomsSaveData
    {
        // key = 挑战房间 ID
        public Dictionary<string, ChallengeRoomRecord> Records = new();
    }

    [Serializable]
    public class ChallengeRoomRecord
    {
        public int    BestScore;
        public float  BestTime;
        public string BestRank;          // "S"/"A"/"B"/"C"
        public int    CompletionCount;
    }

    // ─── EventChainsSaveData ─────────────────────────────────────
    [Serializable]
    public class EventChainsSaveData
    {
        // key = EventChain IDvalue = 当前阶段名称(如 "Step2" / "Completed"
        public Dictionary<string, string> ChainStates = new();
        // 世界状态标志(跨系统布尔标志,如 "ForestBossDefeated"
        public Dictionary<string, bool>   WorldFlags  = new();
    }

    // ─── ShopsSaveData ───────────────────────────────────────────
    [Serializable]
    public class ShopsSaveData
    {
        // key = 商店 ID如 "Shop_Forest_Merchant"
        public Dictionary<string, ShopRecord> ShopRecords = new();
    }

    [Serializable]
    public class ShopRecord
    {
        public List<string>          SoldUniqueItems  = new();  // 已售出的唯一物品 ID
        public Dictionary<string, int> PurchaseCounts = new(); // 消耗品购买次数
    }

    // ─── NGPlusSaveData ──────────────────────────────────────────
    [Serializable]
    public class NGPlusSaveData
    {
        public int    NGPlusCount;       // NG+ 轮次1 = 第一次 NG+
        public bool   SteelSoulMode;     // 钢铁之魂(死亡即删档)
        public Dictionary<string, bool> NGPlusFlags = new(); // 专属标志
    }
}

2. ISaveStorage 接口

// 路径: Assets/Scripts/Core/Save/ISaveStorage.cs
public interface ISaveStorage
{
    Task WriteAsync(int slotIndex, string json);
    Task<string> ReadAsync(int slotIndex);
    Task DeleteAsync(int slotIndex);
    bool Exists(int slotIndex);
    IEnumerable<int> GetExistingSlots();
}

// 本地文件实现PC / Console
public class LocalFileStorage : ISaveStorage
{
    private readonly string _saveDir;   // Application.persistentDataPath/saves/

    public LocalFileStorage()
    {
        _saveDir = Path.Combine(Application.persistentDataPath, "saves");
        Directory.CreateDirectory(_saveDir);
    }

    public async Task WriteAsync(int slotIndex, string json)
    {
        var path = GetPath(slotIndex);
        await File.WriteAllTextAsync(path, json);
    }

    public async Task<string> ReadAsync(int slotIndex)
        => await File.ReadAllTextAsync(GetPath(slotIndex));

    public Task DeleteAsync(int slotIndex)
    {
        File.Delete(GetPath(slotIndex));
        return Task.CompletedTask;
    }

    public bool Exists(int slotIndex) => File.Exists(GetPath(slotIndex));
    public IEnumerable<int> GetExistingSlots()
    {
        for (int i = 0; i < 3; i++)
            if (Exists(i)) yield return i;
    }

    private string GetPath(int slot) => Path.Combine(_saveDir, $"save_{slot}.json");
}

3. ISaveable 接口

// 路径: Assets/Scripts/Core/Save/ISaveable.cs
// 各系统组件实现,向 SaveManager 提供/接受自己的存档数据
public interface ISaveable
{
    // 提取当前运行时状态 → 存入对应 SaveData 子结构
    void OnSave(SaveData saveData);

    // 从 saveData 恢复运行时状态
    void OnLoad(SaveData saveData);
}

// 示例实现PlayerStats
// public class PlayerStats : MonoBehaviour, ISaveable
// {
//     public void OnSave(SaveData d) => d.Player = GetSaveData();
//     public void OnLoad(SaveData d) => LoadSaveData(d.Player);
// }

4. SaveManager

// 路径: Assets/Scripts/Core/Save/SaveManager.cs
[DefaultExecutionOrder(-900)]
public class SaveManager : MonoBehaviour
{
    [SerializeField] private ISaveStorage _storage;   // Inspector 绑定实现类型
    [SerializeField] private BoolEventChannelSO _onSaveIndicatorVisible;  // → EVT_SaveIndicatorVisibleHUDController 订阅)

    // ── Singleton ────────────────────────────────────────
    public static SaveManager Instance { get; private set; }

    // 最低兼容版本SaveValidator 使用)
    public const string MinCompatibleVersion = "1.0";

    private void Awake()
    {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
    }

    // 注册的所有 ISaveable各系统在 OnEnable 时注册)
    private readonly List<ISaveable> _saveables = new();

    // 当前加载的存档数据(运行时缓存)
    private SaveData _current;
    private int _currentSlot = 0;

    // 用于 DeathScreen 的 Respawn 信息
    public static string LastCheckpointScene { get; private set; }
    public static string LastCheckpointSpawnId { get; private set; }

    public void Register(ISaveable saveable)    => _saveables.Add(saveable);
    public void Unregister(ISaveable saveable)  => _saveables.Remove(saveable);

    // 存档(由 EVT_SavePointActivated 触发)
    public async Task SaveAsync(int slot = -1)
    {
        int targetSlot = slot < 0 ? _currentSlot : slot;
        _current ??= new SaveData();

        foreach (var s in _saveables) s.OnSave(_current);

        _current.Meta.LastSaved  = DateTime.UtcNow.ToString("o");
        _current.Meta.SlotIndex  = targetSlot;
        _current.Meta.SaveCount++;

        string json      = JsonConvert.SerializeObject(_current, Formatting.Indented);
        _current.Meta.Checksum = ComputeChecksum(json);
        json = JsonConvert.SerializeObject(_current, Formatting.Indented); // 含 checksum

        await _storage.WriteAsync(targetSlot, json);

        LastCheckpointScene   = _current.Player.Scene;
        LastCheckpointSpawnId = _current.Meta.SavePointId;
    }

    // 读取存档(游戏启动 / 重生)
    public async Task<bool> LoadAsync(int slot)
    {
        if (!_storage.Exists(slot)) return false;

        string json  = await _storage.ReadAsync(slot);
        var loaded   = JsonConvert.DeserializeObject<SaveData>(json);

        if (!ValidateChecksum(loaded, json)) return false;  // 校验失败

        loaded = SaveMigrator.Migrate(loaded);  // 版本迁移

        _current     = loaded;
        _currentSlot = slot;

        foreach (var s in _saveables) s.OnLoad(_current);

        LastCheckpointScene   = _current.Player.Scene;
        LastCheckpointSpawnId = _current.Meta.SavePointId;
        return true;
    }

    public bool SlotExists(int slot) => _storage.Exists(slot);
    public IEnumerable<int> GetExistingSlots() => _storage.GetExistingSlots();

    // ── 挑战房间快速存读档 ────────────────────────────────────────────
    private const int QuickSaveSlot = 98;   // 专用快速存档槽(不覆盖普通存档)

    /// <summary>挑战房间开始时调用,保存挑战入口状态(不阻塞)。</summary>
    public void QuickSave() => _ = SaveAsync(QuickSaveSlot);

    /// <summary>挑战失败时调用,读取快速存档回挑战入口(不阻塞)。</summary>
    public void QuickLoad() => _ = LoadAsync(QuickSaveSlot);

    /// <summary>
    /// 判断指定挑战是否为首次通关,并将其标记为已通关(幂等:重复调用返回 false
    /// 首次调用返回 true触发首通奖励后续调用返回 false触发重复奖励
    /// </summary>
    public bool IsFirstClear(string challengeId)
    {
        if (_current == null) return false;
        if (_current.World.ChallengeFirstClears.Contains(challengeId)) return false;
        _current.World.ChallengeFirstClears.Add(challengeId);
        return true;
    }

    /// <summary>
    /// 判断指定 Boss 是否已被击败(查询 World.DefeatedBossIds 列表)。
    /// 由 ChallengeRoomTrigger 解锁条件校验使用。
    /// </summary>
    public bool IsBossDefeated(string bossId)
    {
        if (_current == null) return false;
        return _current.World.DefeatedBossIds.Contains(bossId);
    }

    // ── EventChain / WorldFlag 集成(参见 14_NarrativeModule §6.1)────────────────────

    /// <summary>返回所有已完成的 EventChain ID。</summary>
    public IEnumerable<string> GetCompletedChains()
        => _current?.EventChains.ChainStates.Keys ?? Enumerable.Empty<string>();

    /// <summary>将指定 EventChain 标记为 "Completed"。</summary>
    public void SetChainCompleted(string chainId)
    {
        _current ??= new SaveData();
        _current.EventChains.ChainStates[chainId] = "Completed";
    }

    /// <summary>读取世界状态布尔标志EventChains.WorldFlags。</summary>
    public bool GetFlag(string flagId)
        => _current?.EventChains.WorldFlags.TryGetValue(flagId, out var v) == true && v;

    /// <summary>写入世界状态布尔标志EventChains.WorldFlags。</summary>
    public void SetFlag(string flagId, bool value)
    {
        _current ??= new SaveData();
        _current.EventChains.WorldFlags[flagId] = value;
    }

    // ── 存档槽摘要(参见 10_UIModule §7.5)──────────────────────────────────────

    /// <summary>异步读取指定槽的摘要信息,用于主菜单存档槽 UI 显示。槽不存在时返回 null。</summary>
    public async Task<SlotSummary> GetSlotSummaryAsync(int slotIndex)
    {
        if (!_storage.Exists(slotIndex)) return null;
        string json = await _storage.ReadAsync(slotIndex);
        var data    = JsonConvert.DeserializeObject<SaveData>(json);
        return new SlotSummary
        {
            SlotIndex    = slotIndex,
            Playtime     = data.Meta.Playtime,
            LastSaved    = data.Meta.LastSaved,
            SceneName    = data.Player.Scene,
            ActiveFormId = data.Player.ActiveFormId,
        };
    }

    /// <summary>创建新存档槽:重置运行时缓存并绑定槽索引。由主菜单"新建存档"调用。</summary>
    public void CreateSlot(int slotIndex)
    {
        _currentSlot            = slotIndex;
        _current                = new SaveData();
        _current.Meta.SlotIndex = slotIndex;
    }

    private string ComputeChecksum(string json)
    {
        // HMAC-SHA256密鑅 = 设备 GUIDPC或 Steam UserId 绑定,防止将存档文件复制到其他设备后绕过 SteelSoul 限制
        var keyBytes = System.Text.Encoding.UTF8.GetBytes(GetHmacKey());
        using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes);
        var dataBytes = System.Text.Encoding.UTF8.GetBytes(json);
        return Convert.ToBase64String(hmac.ComputeHash(dataBytes));
    }

    /// <summary>
    /// 获取 HMAC 密鑅。密鑅由设备唯一标识符派生,绑定到设备,
    /// 防止玩家把存档文件复制到其他设备来绕过 SteelSoul 。
    /// </summary>
    private string GetHmacKey()
    {
        // 优先使用 IPlatformService 提供的用户 IDSteam UserId等作为密酅的一部分
        string platformId = ServiceLocator
            .GetOrDefault<IPlatformService>(NullPlatformService.Instance)
            .GetUserId();  // 平台无效时返回空字符串

        // 回退层:使用设备 GUID跨退出平台级绑定到设备
        string deviceId = string.IsNullOrEmpty(platformId)
            ? SystemInfo.deviceUniqueIdentifier
            : platformId;

        // 加盐:防止已知设备 ID 列表爆力
        const string salt = "ZelingV2SaveIntegrity_v1";
        return $"{deviceId}:{salt}";
    }

    private bool ValidateChecksum(SaveData data, string rawJson)
    {
        if (string.IsNullOrEmpty(data.Meta.Checksum)) return true;  // 旧存档兄容

        // 重新序列化时暂时置空 checksum 字段,再计算
        var savedChecksum = data.Meta.Checksum;
        data.Meta.Checksum = null;
        string jsonWithoutChecksum = JsonConvert.SerializeObject(data, Formatting.Indented);
        data.Meta.Checksum = savedChecksum;   // 恢复

        string expected = ComputeChecksum(jsonWithoutChecksum);
        if (expected != savedChecksum)
        {
            Debug.LogWarning("[SaveManager] 存档校验失败——存档文件可能被篹改或来自不同设备。");
            return false;
        }
        return true;
    }
}

// ── 存档槽摘要(主菜单 UI 使用,参见 10_UIModule §7.5)──────────────────────────
// 路径: Assets/Scripts/Core/Save/SlotSummary.cs或与 SaveManager 同文件)
/// <summary>存档槽摘要数据,由 SaveManager.GetSlotSummaryAsync 返回。null = 空槽(显示"新局")。</summary>
public class SlotSummary
{
    public int    SlotIndex;
    public float  Playtime;
    public string LastSaved;      // ISO 8601
    public string SceneName;      // 最后存档时的场景名
    public string ActiveFormId;   // 当前形态 ID用于显示图标
}

5. SaveMigrator

// 路径: Assets/Scripts/Core/Save/SaveMigrator.cs
// 处理存档版本升级(旧版 → 新版数据结构)
public static class SaveMigrator
{
    private const string CurrentVersion = "2.0";

    public static SaveData Migrate(SaveData data)
    {
        switch (data.Meta.Version)
        {
            case "1.0": data = MigrateFrom1_0(data); goto case "1.1";
            case "1.1": data = MigrateFrom1_1(data); goto case "2.0";
            case "2.0": break; // 当前版本,无需迁移
            default: Debug.LogWarning($"Unknown save version: {data.Meta.Version}"); break;
        }
        data.Meta.Version = CurrentVersion;
        return data;
    }

    private static SaveData MigrateFrom1_0(SaveData d)
    {
        // 防御性 null-check若旧版本缺少整个子结构体先补全对象
        d.Equipment ??= new EquipmentSaveData();
        d.Player    ??= new PlayerSaveData();
        d.World     ??= new WorldSaveData();
        // 1.0 → 1.1:新增 Equipment.UpgradedCharmIds 字段,旧存档初始化为空列表
        d.Equipment.UpgradedCharmIds ??= new List<string>();
        return d;
    }

    private static SaveData MigrateFrom1_1(SaveData d)
    {
        // 防御性 null-check子结构体若为 null 先补全
        d.Stats ??= new StatsSaveData();
        // 1.1 → 2.0Stats 新增 SkillUseCounts
        d.Stats.SkillUseCounts ??= new Dictionary<string, int>();
        // 1.1 → 2.0Player 新增护盾字段(-1 = 满护盾)
        // ShieldHP 为 int默认 0值类型用 -1 作哨兵值表示"未记录"→恢复满护盾
        if (d.Player.ShieldHP == 0 && !d.Player.ShieldIsBroken)
            d.Player.ShieldHP = -1;   // 旧存档没有护盾字段时恢复为满护盾
        return d;
    }
}

6. 保存流程

[玩家激活存档点]
    ↓ SavePoint.Interact()
    ↓ EVT_SavePointActivated.Raise(savePointId)
        ← GameManager 订阅
            → GameManager 调用 SaveManager.SaveAsync(currentSlot)
                → 遍历 _saveables收集数据
                → 序列化 JSON
                → 计算 Checksum
                → ISaveStorage.WriteAsync(slot, json)
                    → 写入磁盘async不阻塞主线程
                → 更新 LastCheckpointScene / LastCheckpointSpawnId
                → EVT_SaveIndicatorVisible.Raise(true) → HUD 显示保存中图标
                → 完成后EVT_SaveIndicatorVisible.Raise(false)

7. 加载流程

[游戏启动 / 选择存档槽]
    ↓ GameManager.StartGame(slotIndex)
        → SaveManager.LoadAsync(slotIndex)
            → ISaveStorage.ReadAsync(slot)
            → JsonConvert.DeserializeObject<SaveData>(json)
            → ValidateChecksum → 失败则提示损坏
            → SaveMigrator.Migrate(data)
            → 遍历 _saveables调用 OnLoad(data)
                ← PlayerStats.LoadSaveData(data.Player)
                ← EquipmentManager.LoadSaveData(data.Equipment)
                ← WorldStateRegistry.LoadSaveData(data.World)
                ← QuestManager.LoadSaveData(data.Quests)
                ← ...
            → SceneLoader.LoadScene(data.Player.Scene, respawnId)

[重生(死亡后)]
    ↓ DeathScreenController.Respawn()
        → 发送 SceneLoadRequestIsRespawn = true
            → SceneLoader 加载最后存档场景
            → 各系统从缓存的 _current内存中最后存档恢复
            → 无需重新读取磁盘

8. 存档路径与文件规范

平台 存档目录 文件名
Windows / Mac / Linux Application.persistentDataPath/saves/ save_0.jsonsave_1.jsonsave_2.json
Steam 云存档 SteamRemoteStorageISteamRemoteStorage.FileWrite 同文件名

设置文件(独立于存档槽,全局唯一):
Application.persistentDataPath/settings.json
SettingsManager 管理,不经过 SaveManager

自动备份:每次 WriteAsync 前将旧文件重命名为 save_{slot}.bak,提供一份备份。


9. EmergencySaveService 与 CrashReporter

// 路径: Assets/Scripts/Core/Save/EmergencySaveService.cs
// 定期自动保存到独立的紧急存档槽(不覆盖玩家主存档)
// 用途:游戏崩溃后可提示玩家恢复进度
public class EmergencySaveService : MonoBehaviour
{
    private const int EmergencySlot = 99;   // 独立槽,不显示在存档选择界面

    [SerializeField] private float           _intervalSeconds = 120f;  // 每 2 分钟
    [SerializeField] private SaveManager     _saveManager;
    [SerializeField] private BoolEventChannelSO _onGameplayActive;    // 仅 Gameplay 状态下才保存

    private bool  _gameplayActive;
    private float _timer;

    private void OnEnable()  => _onGameplayActive.OnEventRaised += v => _gameplayActive = v;
    private void OnDisable() => _onGameplayActive.OnEventRaised -= v => _gameplayActive = v;

    private void Update()
    {
        if (!_gameplayActive) return;
        _timer += Time.deltaTime;
        if (_timer >= _intervalSeconds)
        {
            _timer = 0f;
            // Fire-and-forget不阻塞主线程
            _ = _saveManager.SaveAsync(EmergencySlot);
        }
    }

    // 判断是否存在未读的紧急存档(启动时检查)
    public bool HasEmergencySave() => _saveManager.SlotExists(EmergencySlot);

    // 将紧急存档提升为指定主存档槽(玩家确认恢复后调用)
    public async Task PromoteToSlot(int targetSlot)
    {
        var json = await ((LocalFileStorage)GetComponent<ISaveStorage>())
                            .ReadAsync(EmergencySlot);
        // 写入目标槽,删除紧急槽
        await ((LocalFileStorage)GetComponent<ISaveStorage>()).WriteAsync(targetSlot, json);
        await ((LocalFileStorage)GetComponent<ISaveStorage>()).DeleteAsync(EmergencySlot);
    }
}

// 路径: Assets/Scripts/Core/Save/CrashReporter.cs
// 监听 Application.quitting 与 Application.logMessageReceived
// 崩溃时触发紧急存档并写入诊断日志
public class CrashReporter : MonoBehaviour
{
    [SerializeField] private SaveManager            _saveManager;
    [SerializeField] private EmergencySaveService   _emergencyService;

    private bool _cleanExit;   // OnApplicationQuit 正常退出时置 true

    private void OnEnable()
    {
        Application.logMessageReceived += OnLogMessage;
        Application.quitting           += OnCleanQuit;
    }

    private void OnDisable()
    {
        Application.logMessageReceived -= OnLogMessage;
        Application.quitting           -= OnCleanQuit;
    }

    private void OnCleanQuit() => _cleanExit = true;

    private void OnLogMessage(string condition, string stackTrace, LogType type)
    {
        if (type == LogType.Exception || type == LogType.Error)
        {
            WriteDiagnosticLog(condition, stackTrace);
        }
    }

    // 写入诊断日志文件(不含存档数据,仅 stacktrace + 时间戳)
    private void WriteDiagnosticLog(string condition, string stackTrace)
    {
        var logPath = Path.Combine(
            Application.persistentDataPath,
            $"crash_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log");

        var content = $"[{DateTime.UtcNow:o}] {condition}\n{stackTrace}";
        File.WriteAllText(logPath, content);    // 同步写,崩溃时 async 不可靠
    }

    // 程序退出前若为非正常退出则触发紧急存档
    private void OnApplicationPause(bool pauseStatus)
    {
        if (pauseStatus && !_cleanExit)
            _ = _saveManager.SaveAsync(99);   // EmergencySlot
    }
}

紧急存档启动流程

[游戏启动]
    ↓ MainMenuController.Start()
        → CrashReporter.HasEmergencySave()
            → true: 显示"检测到上次异常退出,是否恢复进度?"对话框
                → 玩家确认: EmergencySaveService.PromoteToSlot(lastUsedSlot)
                           → 正常 LoadAsync(lastUsedSlot)
                → 玩家拒绝: DeleteAsync(EmergencySlot)
            → false: 正常主菜单流程

10. SaveValidator — 写入前数据校验

Design 来源31_SaveLoadSystem §10
P2 优化:原 Validate() 同步执行 SHA256 + JSON 反序列化,大存档可阻塞主线程 >16ms。拆分为快速同步校验字段范围检查+ 异步完整性校验SHA256 + 反序列化),加载时在后台线程运行。

// 路径: Assets/Scripts/Save/SaveValidator.cs
namespace BaseGames.Save
{
    public static class SaveValidator
    {
        public readonly struct Result
        {
            public readonly bool   IsValid;
            public readonly string Error;

            public Result(bool isValid, string error = null)
            {
                IsValid = isValid;
                Error   = error;
            }
        }

        // ── 同步快速校验SaveAsync 序列化前调用,< 1ms──────────────
        /// <summary>
        /// 轻量字段校验,不涉及 IO 或加密运算,可安全在主线程调用。
        /// SaveManager.SaveAsync() 在序列化前调用此方法。
        /// </summary>
        public static Result ValidateFields(SaveData data)
        {
            if (data.Player.CurrentHP < 0 || data.Player.CurrentHP > data.Player.MaxHP)
                return new Result(false, $"HP 越界: {data.Player.CurrentHP}/{data.Player.MaxHP}");

            if (data.Player.CurrentGeo < 0)
                return new Result(false, $"Geo 为负值: {data.Player.CurrentGeo}");

            if (string.IsNullOrEmpty(data.Player.Scene))
                return new Result(false, "场景名为空");

            if (string.Compare(data.Meta.Version, SaveManager.MinCompatibleVersion,
                    StringComparison.Ordinal) < 0)
                return new Result(false, $"存档版本过旧: {data.Meta.Version}");

            return new Result(true);
        }

        // ── 异步完整性校验LoadAsync 加载后调用,后台线程)─────────────
        /// <summary>
        /// 包含 SHA256 校验和 + JSON 反序列化健壮性验证。
        /// 运行在后台线程Task.Run不阻塞主线程。
        /// SaveManager.LoadAsync() 在反序列化后、应用存档前调用此方法。
        /// </summary>
        public static UniTask<Result> ValidateIntegrityAsync(string rawJson)
            => UniTask.RunOnThreadPool(() => ValidateIntegrityInternal(rawJson));

        private static Result ValidateIntegrityInternal(string rawJson)
        {
            try
            {
                // 1. 反序列化健壮性:确保 JSON 完整可解析
                var data = Newtonsoft.Json.JsonConvert.DeserializeObject<SaveData>(rawJson);
                if (data == null)
                    return new Result(false, "JSON 反序列化结果为 null");

                // 2. SHA256 校验和(保存时写入 data.Meta.Checksum
                if (!string.IsNullOrEmpty(data.Meta.Checksum))
                {
                    string computed = ComputeChecksum(rawJson, data.Meta.Checksum);
                    if (computed != data.Meta.Checksum)
                        return new Result(false, "SHA256 校验失败:存档数据可能被篡改");
                }

                return new Result(true);
            }
            catch (System.Exception ex)
            {
                return new Result(false, $"完整性校验异常: {ex.Message}");
            }
        }

        /// <summary>
        /// 计算 rawJson 去除 checksum 字段后的 SHA256 十六进制字符串。
        /// </summary>
        internal static string ComputeChecksum(string rawJson, string existingChecksum = null)
        {
            // 移除 checksum 字段再计算,避免循环依赖
            var jo = Newtonsoft.Json.Linq.JObject.Parse(rawJson);
            jo["Meta"]?["Checksum"]?.Parent?.Remove();
            var canonical = jo.ToString(Newtonsoft.Json.Formatting.None);

            using var sha  = System.Security.Cryptography.SHA256.Create();
            var hash       = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical));
            return System.BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
        }
    }
}

SaveManager 调用点

// SaveManager.SaveAsync() — 序列化前快速校验
var fieldResult = SaveValidator.ValidateFields(data);
if (!fieldResult.IsValid)
{
    Debug.LogError($"[SaveManager] 存档数据非法,中止写入:{fieldResult.Error}");
    return;
}
// … 序列化 → 写入文件 …

// SaveManager.LoadAsync() — 读取文件后异步完整性校验(不阻塞)
string rawJson = await storage.ReadAsync(slot);
var integrityResult = await SaveValidator.ValidateIntegrityAsync(rawJson);
if (!integrityResult.IsValid)
{
    Debug.LogError($"[SaveManager] 存档完整性校验失败:{integrityResult.Error}");
    // 降级:提示用户存档损坏,加载 BackupSave
    return;
}

11. IDlcSaveExtension — DLC 存档扩展接口

Design 来源31_SaveLoadSystem §13

DLC 内容通过此接口挂载到 SaveData.DLCDictionary<string, JObject>),与核心存档完全解耦。

// 路径: Assets/Scripts/Save/IDlcSaveExtension.cs
public interface IDlcSaveExtension
{
    /// <summary>唯一标识符,对应 SaveData.DLC 字典的键</summary>
    string DlcId { get; }

    /// <summary>将 DLC 数据序列化写入 dlcData</summary>
    void Serialize(ref JObject dlcData, SaveData fullData);

    /// <summary>从 dlcData 读取并应用到游戏状态</summary>
    void Deserialize(JObject dlcData, SaveData fullData);

    /// <summary>版本迁移fromVersion 为旧存档版本号</summary>
    void MigrateIfNeeded(int fromVersion, ref JObject dlcData);

    /// <summary>NG+ 开始时重置 DLC 存档数据</summary>
    void OnNgPlusReset(ref JObject dlcData);
}

SaveManager 集成

// SaveManager 内新增字段与注册方法
private readonly List<IDlcSaveExtension> _dlcExtensions = new();

public void RegisterDlcExtension(IDlcSaveExtension ext)
    => _dlcExtensions.Add(ext);

// SaveAsync() 内调用:
foreach (var ext in _dlcExtensions)
{
    var dlcData = new JObject();
    ext.Serialize(ref dlcData, saveData);
    saveData.DLC[ext.DlcId] = dlcData;
}

// LoadAsync() 内调用:
foreach (var ext in _dlcExtensions)
{
    if (saveData.DLC.TryGetValue(ext.DlcId, out var dlcData))
        ext.Deserialize(dlcData, saveData);
}

10. SaveInspectorWindow — 运行时存档调试工具

痛点:开发调试期间需要频繁查看/修改存档状态(如解锁所有能力、传送到指定场景、修改 HP/Geo。若通过修改 JSON 文件来调试,需要:关游戏 → 找文件 → 编辑 → 重启,效率极低。SaveInspectorWindowPlay Mode 下提供实时存档数据浏览与热修改,减少调试迭代成本。

10.1 功能规格

功能 说明
实时显示 运行时读取 SaveManager._current(反射或接口)展示当前存档数据
分节折叠 按 Player / World / Achievements / EventChains / DLC 分节,各自可折叠
热写入 修改字段后点击 "应用" 按钮立即写入 SaveManager._current 并调用 ISaveable.OnLoad 广播
能力位掩码 AbilityFlags 以复选框列表显示(每个 AbilityType 一个 Toggle一键"解锁全部"
快速存档/读档 一键 "立即存档Slot 0" / "立即读档Slot 0" 调用 SaveManager.SaveAsync/LoadAsync
截图存档 将当前存档数据格式化为 JSON 并复制到剪贴板,方便粘贴到 Bug 报告
存档路径 显示当前平台存档目录路径,双击打开文件夹

10.2 实现规范

// 路径: Assets/Scripts/Editor/Save/SaveInspectorWindow.cs
// 程序集: BaseGames.EditorEditor Only
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using Newtonsoft.Json;

namespace BaseGames.Editor.Save
{
    public class SaveInspectorWindow : EditorWindow
    {
        [MenuItem("BaseGames/Tools/Save Inspector")]
        public static void Open() => GetWindow<SaveInspectorWindow>("存档检视器");

        private Vector2 _scroll;
        private bool    _showPlayer = true;
        private bool    _showWorld  = true;
        private bool    _showAch    = false;
        private bool    _showChains = false;

        private void OnGUI()
        {
            if (!Application.isPlaying)
            {
                EditorGUILayout.HelpBox("仅在 Play Mode 下可用", MessageType.Warning);
                return;
            }

            var mgr = FindObjectOfType<SaveManager>();
            if (mgr == null)
            {
                EditorGUILayout.HelpBox("场景中未找到 SaveManager", MessageType.Error);
                return;
            }

            DrawToolbar(mgr);
            _scroll = EditorGUILayout.BeginScrollView(_scroll);
            DrawPlayerSection(mgr);
            DrawWorldSection(mgr);
            DrawAchievementsSection(mgr);
            DrawEventChainsSection(mgr);
            EditorGUILayout.EndScrollView();
        }

        private void DrawToolbar(SaveManager mgr)
        {
            EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);

            if (GUILayout.Button("💾 立即存档", EditorStyles.toolbarButton, GUILayout.Width(90)))
                _ = mgr.SaveAsync(0);

            if (GUILayout.Button("📂 读取 Slot0", EditorStyles.toolbarButton, GUILayout.Width(90)))
                _ = mgr.LoadAsync(0);

            if (GUILayout.Button("📋 复制 JSON", EditorStyles.toolbarButton, GUILayout.Width(90)))
            {
                var json = JsonConvert.SerializeObject(mgr.CurrentData, Formatting.Indented);
                GUIUtility.systemCopyBuffer = json;
                Debug.Log("[SaveInspector] 存档 JSON 已复制到剪贴板");
            }

            GUILayout.FlexibleSpace();
            if (GUILayout.Button("📁 存档目录", EditorStyles.toolbarButton, GUILayout.Width(80)))
                EditorUtility.RevealInFinder(
                    System.IO.Path.Combine(Application.persistentDataPath, "saves"));

            EditorGUILayout.EndHorizontal();
        }

        private void DrawPlayerSection(SaveManager mgr)
        {
            _showPlayer = EditorGUILayout.Foldout(_showPlayer, "Player", true, EditorStyles.foldoutHeader);
            if (!_showPlayer) return;

            var p = mgr.CurrentData?.Player;
            if (p == null) { EditorGUILayout.LabelField("(无存档数据)"); return; }

            EditorGUI.indentLevel++;
            p.CurrentHP  = EditorGUILayout.IntField("Current HP",  p.CurrentHP);
            p.MaxHP      = EditorGUILayout.IntField("Max HP",      p.MaxHP);
            p.CurrentGeo = EditorGUILayout.IntField("Geo",         p.CurrentGeo);
            p.Scene      = EditorGUILayout.TextField("Scene",      p.Scene);
            p.ActiveFormId = EditorGUILayout.TextField("Form ID",  p.ActiveFormId);

            EditorGUILayout.Space(4);
            EditorGUILayout.LabelField("AbilityFlags", EditorStyles.boldLabel);
            foreach (AbilityType ability in System.Enum.GetValues(typeof(AbilityType)))
            {
                if (ability == AbilityType.None) continue;
                bool has = (p.AbilityFlags & (uint)ability) != 0;
                bool newHas = EditorGUILayout.Toggle(ability.ToString(), has);
                if (newHas != has)
                    p.AbilityFlags = newHas
                        ? p.AbilityFlags | (uint)ability
                        : p.AbilityFlags & ~(uint)ability;
            }
            if (GUILayout.Button("解锁全部能力"))
                p.AbilityFlags = uint.MaxValue;
            if (GUILayout.Button("重置全部能力"))
                p.AbilityFlags = 0;

            EditorGUI.indentLevel--;
        }

        private void DrawWorldSection(SaveManager mgr)
        {
            _showWorld = EditorGUILayout.Foldout(_showWorld, "World", true, EditorStyles.foldoutHeader);
            if (!_showWorld) return;
            var w = mgr.CurrentData?.World;
            if (w == null) return;
            EditorGUI.indentLevel++;
            EditorGUILayout.LabelField($"已解锁地图节点数: {w.UnlockedMapNodes?.Count ?? 0}");
            EditorGUILayout.LabelField($"已开启传送点数: {w.OpenedTeleports?.Count ?? 0}");
            EditorGUILayout.LabelField($"已击败 Boss 数: {w.DefeatedBossIds?.Count ?? 0}");
            EditorGUI.indentLevel--;
        }

        private void DrawAchievementsSection(SaveManager mgr)
        {
            _showAch = EditorGUILayout.Foldout(_showAch, "Achievements", true, EditorStyles.foldoutHeader);
            if (!_showAch) return;
            var ach = mgr.CurrentData?.Achievements;
            if (ach == null) return;
            EditorGUI.indentLevel++;
            EditorGUILayout.LabelField($"已解锁: {ach.Unlocked?.Count ?? 0} 项");
            if (ach.Unlocked != null)
                foreach (var id in ach.Unlocked)
                    EditorGUILayout.LabelField("  ✓ " + id);
            EditorGUI.indentLevel--;
        }

        private void DrawEventChainsSection(SaveManager mgr)
        {
            _showChains = EditorGUILayout.Foldout(_showChains, "EventChains / WorldFlags",
                true, EditorStyles.foldoutHeader);
            if (!_showChains) return;
            var ec = mgr.CurrentData?.EventChains;
            if (ec == null) return;
            EditorGUI.indentLevel++;
            EditorGUILayout.LabelField($"Chain 数: {ec.ChainStates?.Count ?? 0}");
            EditorGUILayout.LabelField($"WorldFlag 数: {ec.WorldFlags?.Count ?? 0}");
            EditorGUI.indentLevel--;
        }

        // 每帧刷新(数据可能在游戏逻辑中被修改)
        private void OnInspectorUpdate() => Repaint();
    }
}
#endif

注意SaveManager 需将 _current 暴露为只读属性 public SaveData CurrentData => _current; 以供 EditorWindow 访问。热修改后如需立即生效,可调用 mgr.BroadcastLoad()(向所有 ISaveable 广播 OnLoad,使游戏状态与修改后的 _current 保持一致)。