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

1124 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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 接口
```csharp
// 路径: 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 接口
```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_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
```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<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.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<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](../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──────────────
/// <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 调用点**
```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<string, JObject>`),与核心存档完全解耦。
```csharp
// 路径: 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 集成**
```csharp
// 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 文件来调试,需要:关游戏 → 找文件 → 编辑 → 重启,效率极低。`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.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` 保持一致)。