# 12 · 存档模块 > **命名空间** `BaseGames.Core.Save` > **程序集** `BaseGames.Core.Save` > **路径** `Assets/Scripts/Core/Save/` > **依赖** `Newtonsoft.Json`(`com.unity.nuget.newtonsoft-json`) --- ## 目录 1. [SaveData C# 数据结构](#1-savedata-c-数据结构) 2. [ISaveStorage 接口](#2-isavestorage-接口) 3. [ISaveable 接口](#3-isaveable-接口) 4. [SaveManager](#4-savemanager) 5. [SaveMigrator](#5-savemigrator) 6. [保存流程(详细时序)](#6-保存流程) 7. [加载流程(详细时序)](#7-加载流程) 8. [存档路径与文件规范](#8-存档路径与文件规范) --- ## 1. SaveData C# 数据结构 所有数据类使用 `[Serializable]`,由 Newtonsoft.Json 序列化为 JSON。 ```csharp namespace BaseGames.Core.Save { // ─── 顶层 ────────────────────────────────────────────────── [Serializable] public class SaveData { [JsonExtensionData] public Dictionary 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 DLC = new(); } // ─── Meta ─────────────────────────────────────────────────── [Serializable] public class SaveMeta { public string Version = "2.1"; // 2.1: AbilityFlags uint 替换 Dictionary 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 Abilities 由 SaveMigrator.MigrateV2ToV21 自动转换 public uint AbilityFlags = 0; // 形态 public string ActiveFormId; public List 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 EquippedCharmIds = new(); public int NotchesUsed; public int MaxNotches; public List OwnedCharmIds = new(); public List UpgradedCharmIds = new(); // 注:工具槽数据在独立的 ToolsSaveData(顶层 SaveData.Tools),不在此处重复 } // ─── World ────────────────────────────────────────────────── [Serializable] public class WorldSaveData { public List VisitedScenes = new(); public List ActivatedSavePoints = new(); public List OpenedDoors = new(); public List DefeatedBossIds = new(); public List CollectedIds = new(); // Collectibles public Dictionary Switches = new(); // Levers/Buttons public Dictionary NpcRelations = new(); public HashSet ChallengeFirstClears = new(); // 已首次通关的挑战 ID } // ─── Map ──────────────────────────────────────────────────── [Serializable] public class MapSaveData { // key = 场景名,value = 已探索房间 index 列表 public Dictionary> DiscoveredRooms = new(); public Dictionary MapPurchased = new(); } // ─── Quests ───────────────────────────────────────────────── [Serializable] public class QuestSaveData { public Dictionary QuestStates = new(); public List AvailableQuestIds = new(); } [Serializable] public class QuestState { public string Status; // "NotStarted"|"Active"|"Completed"|"Failed" public int ObjectiveIndex; public List ProgressCounts = new(); public string GiverNpcId; } // ─── Achievements ─────────────────────────────────────────── [Serializable] public class AchievementSaveData { public List Unlocked = new(); public Dictionary 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 SkillUseCounts = new(); public Dictionary DeathsByBoss = new(); } // ─── ToolsSaveData ─────────────────────────────────────────── [Serializable] public class ToolsSaveData { public string ToolSlot0; public string ToolSlot1; public List OwnedToolIds = new(); public Dictionary ToolStates = new(); } // ─── ChallengeRoomsSaveData ────────────────────────────────── [Serializable] public class ChallengeRoomsSaveData { // key = 挑战房间 ID public Dictionary 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 ID,value = 当前阶段名称(如 "Step2" / "Completed") public Dictionary ChainStates = new(); // 世界状态标志(跨系统布尔标志,如 "ForestBossDefeated") public Dictionary WorldFlags = new(); } // ─── ShopsSaveData ─────────────────────────────────────────── [Serializable] public class ShopsSaveData { // key = 商店 ID(如 "Shop_Forest_Merchant") public Dictionary ShopRecords = new(); } [Serializable] public class ShopRecord { public List SoldUniqueItems = new(); // 已售出的唯一物品 ID public Dictionary PurchaseCounts = new(); // 消耗品购买次数 } // ─── NGPlusSaveData ────────────────────────────────────────── [Serializable] public class NGPlusSaveData { public int NGPlusCount; // NG+ 轮次(1 = 第一次 NG+) public bool SteelSoulMode; // 钢铁之魂(死亡即删档) public Dictionary NGPlusFlags = new(); // 专属标志 } } ``` --- ## 2. ISaveStorage 接口 ```csharp // 路径: Assets/Scripts/Core/Save/ISaveStorage.cs public interface ISaveStorage { Task WriteAsync(int slotIndex, string json); Task ReadAsync(int slotIndex); Task DeleteAsync(int slotIndex); bool Exists(int slotIndex); IEnumerable 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 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 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 接口 ```csharp // 路径: 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 ```csharp // 路径: Assets/Scripts/Core/Save/SaveManager.cs [DefaultExecutionOrder(-900)] public class SaveManager : MonoBehaviour { [SerializeField] private ISaveStorage _storage; // Inspector 绑定实现类型 [SerializeField] private BoolEventChannelSO _onSaveIndicatorVisible; // → EVT_SaveIndicatorVisible(HUDController 订阅) // ── 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 _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 LoadAsync(int slot) { if (!_storage.Exists(slot)) return false; string json = await _storage.ReadAsync(slot); var loaded = JsonConvert.DeserializeObject(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 GetExistingSlots() => _storage.GetExistingSlots(); // ── 挑战房间快速存读档 ──────────────────────────────────────────── private const int QuickSaveSlot = 98; // 专用快速存档槽(不覆盖普通存档) /// 挑战房间开始时调用,保存挑战入口状态(不阻塞)。 public void QuickSave() => _ = SaveAsync(QuickSaveSlot); /// 挑战失败时调用,读取快速存档回挑战入口(不阻塞)。 public void QuickLoad() => _ = LoadAsync(QuickSaveSlot); /// /// 判断指定挑战是否为首次通关,并将其标记为已通关(幂等:重复调用返回 false)。 /// 首次调用返回 true(触发首通奖励),后续调用返回 false(触发重复奖励)。 /// 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; } /// /// 判断指定 Boss 是否已被击败(查询 World.DefeatedBossIds 列表)。 /// 由 ChallengeRoomTrigger 解锁条件校验使用。 /// public bool IsBossDefeated(string bossId) { if (_current == null) return false; return _current.World.DefeatedBossIds.Contains(bossId); } // ── EventChain / WorldFlag 集成(参见 14_NarrativeModule §6.1)──────────────────── /// 返回所有已完成的 EventChain ID。 public IEnumerable GetCompletedChains() => _current?.EventChains.ChainStates.Keys ?? Enumerable.Empty(); /// 将指定 EventChain 标记为 "Completed"。 public void SetChainCompleted(string chainId) { _current ??= new SaveData(); _current.EventChains.ChainStates[chainId] = "Completed"; } /// 读取世界状态布尔标志(EventChains.WorldFlags)。 public bool GetFlag(string flagId) => _current?.EventChains.WorldFlags.TryGetValue(flagId, out var v) == true && v; /// 写入世界状态布尔标志(EventChains.WorldFlags)。 public void SetFlag(string flagId, bool value) { _current ??= new SaveData(); _current.EventChains.WorldFlags[flagId] = value; } // ── 存档槽摘要(参见 10_UIModule §7.5)────────────────────────────────────── /// 异步读取指定槽的摘要信息,用于主菜单存档槽 UI 显示。槽不存在时返回 null。 public async Task GetSlotSummaryAsync(int slotIndex) { if (!_storage.Exists(slotIndex)) return null; string json = await _storage.ReadAsync(slotIndex); var data = JsonConvert.DeserializeObject(json); return new SlotSummary { SlotIndex = slotIndex, Playtime = data.Meta.Playtime, LastSaved = data.Meta.LastSaved, SceneName = data.Player.Scene, ActiveFormId = data.Player.ActiveFormId, }; } /// 创建新存档槽:重置运行时缓存并绑定槽索引。由主菜单"新建存档"调用。 public void CreateSlot(int slotIndex) { _currentSlot = slotIndex; _current = new SaveData(); _current.Meta.SlotIndex = slotIndex; } private string ComputeChecksum(string json) { // HMAC-SHA256:密鑅 = 设备 GUID(PC)或 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)); } /// /// 获取 HMAC 密鑅。密鑅由设备唯一标识符派生,绑定到设备, /// 防止玩家把存档文件复制到其他设备来绕过 SteelSoul 。 /// private string GetHmacKey() { // 优先使用 IPlatformService 提供的用户 ID(Steam UserId等)作为密酅的一部分 string platformId = ServiceLocator .GetOrDefault(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 同文件) /// 存档槽摘要数据,由 SaveManager.GetSlotSummaryAsync 返回。null = 空槽(显示"新局")。 public class SlotSummary { public int SlotIndex; public float Playtime; public string LastSaved; // ISO 8601 public string SceneName; // 最后存档时的场景名 public string ActiveFormId; // 当前形态 ID(用于显示图标) } ``` --- ## 5. SaveMigrator ```csharp // 路径: 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(); return d; } private static SaveData MigrateFrom1_1(SaveData d) { // 防御性 null-check:子结构体若为 null 先补全 d.Stats ??= new StatsSaveData(); // 1.1 → 2.0:Stats 新增 SkillUseCounts d.Stats.SkillUseCounts ??= new Dictionary(); // 1.1 → 2.0:Player 新增护盾字段(-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(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() → 发送 SceneLoadRequest(IsRespawn = true) → SceneLoader 加载最后存档场景 → 各系统从缓存的 _current(内存中最后存档)恢复 → 无需重新读取磁盘 ``` --- ## 8. 存档路径与文件规范 | 平台 | 存档目录 | 文件名 | |-----|---------|-------| | Windows / Mac / Linux | `Application.persistentDataPath/saves/` | `save_0.json`、`save_1.json`、`save_2.json` | | Steam 云存档 | SteamRemoteStorage(`ISteamRemoteStorage.FileWrite`) | 同文件名 | **设置文件**(独立于存档槽,全局唯一): `Application.persistentDataPath/settings.json` 由 `SettingsManager` 管理,不经过 `SaveManager`。 **自动备份**:每次 `WriteAsync` 前将旧文件重命名为 `save_{slot}.bak`,提供一份备份。 --- ## 9. EmergencySaveService 与 CrashReporter ```csharp // 路径: 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()) .ReadAsync(EmergencySlot); // 写入目标槽,删除紧急槽 await ((LocalFileStorage)GetComponent()).WriteAsync(targetSlot, json); await ((LocalFileStorage)GetComponent()).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](../Design/31_SaveLoadSystem.md) §10 > **P2 优化**:原 `Validate()` 同步执行 SHA256 + JSON 反序列化,大存档可阻塞主线程 >16ms。拆分为快速同步校验(字段范围检查)+ 异步完整性校验(SHA256 + 反序列化),加载时在后台线程运行。 ```csharp // 路径: 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)────────────── /// /// 轻量字段校验,不涉及 IO 或加密运算,可安全在主线程调用。 /// SaveManager.SaveAsync() 在序列化前调用此方法。 /// 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 加载后调用,后台线程)───────────── /// /// 包含 SHA256 校验和 + JSON 反序列化健壮性验证。 /// 运行在后台线程(Task.Run),不阻塞主线程。 /// SaveManager.LoadAsync() 在反序列化后、应用存档前调用此方法。 /// public static UniTask ValidateIntegrityAsync(string rawJson) => UniTask.RunOnThreadPool(() => ValidateIntegrityInternal(rawJson)); private static Result ValidateIntegrityInternal(string rawJson) { try { // 1. 反序列化健壮性:确保 JSON 完整可解析 var data = Newtonsoft.Json.JsonConvert.DeserializeObject(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}"); } } /// /// 计算 rawJson 去除 checksum 字段后的 SHA256 十六进制字符串。 /// 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 调用点**: ```csharp // 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](../Design/31_SaveLoadSystem.md) §13 DLC 内容通过此接口挂载到 `SaveData.DLC`(`Dictionary`),与核心存档完全解耦。 ```csharp // 路径: Assets/Scripts/Save/IDlcSaveExtension.cs public interface IDlcSaveExtension { /// 唯一标识符,对应 SaveData.DLC 字典的键 string DlcId { get; } /// 将 DLC 数据序列化写入 dlcData void Serialize(ref JObject dlcData, SaveData fullData); /// 从 dlcData 读取并应用到游戏状态 void Deserialize(JObject dlcData, SaveData fullData); /// 版本迁移,fromVersion 为旧存档版本号 void MigrateIfNeeded(int fromVersion, ref JObject dlcData); /// NG+ 开始时重置 DLC 存档数据 void OnNgPlusReset(ref JObject dlcData); } ``` **SaveManager 集成**: ```csharp // SaveManager 内新增字段与注册方法 private readonly List _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 文件来调试,需要:关游戏 → 找文件 → 编辑 → 重启,效率极低。`SaveInspectorWindow` 在 **Play 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 实现规范 ```csharp // 路径: Assets/Scripts/Editor/Save/SaveInspectorWindow.cs // 程序集: BaseGames.Editor(Editor 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("存档检视器"); 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(); 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` 保持一致)。