43 KiB
12 · 存档模块
命名空间
BaseGames.Core.Save
程序集BaseGames.Core.Save
路径Assets/Scripts/Core/Save/
依赖Newtonsoft.Json(com.unity.nuget.newtonsoft-json)
目录
- SaveData C# 数据结构
- ISaveStorage 接口
- ISaveable 接口
- SaveManager
- SaveMigrator
- 保存流程(详细时序)
- 加载流程(详细时序)
- 存档路径与文件规范
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 ID,value = 当前阶段名称(如 "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_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<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:密鑅 = 设备 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));
}
/// <summary>
/// 获取 HMAC 密鑅。密鑅由设备唯一标识符派生,绑定到设备,
/// 防止玩家把存档文件复制到其他设备来绕过 SteelSoul 。
/// </summary>
private string GetHmacKey()
{
// 优先使用 IPlatformService 提供的用户 ID(Steam 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.0:Stats 新增 SkillUseCounts
d.Stats.SkillUseCounts ??= new Dictionary<string, int>();
// 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<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()
→ 发送 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
// 路径: 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.DLC(Dictionary<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 文件来调试,需要:关游戏 → 找文件 → 编辑 → 重启,效率极低。
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 实现规范
// 路径: 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<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保持一致)。