Files
zeling_v2/Assets/Scripts/Core/Save/SaveManager.cs
2026-05-12 21:50:49 +08:00

263 lines
11 KiB
C#
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.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine;
namespace BaseGames.Core.Save
{
/// <summary>
/// 存档管理器。
/// 完整序列化/反序列化由 Newtonsoft.Json 驱动。
/// </summary>
[DefaultExecutionOrder(-900)]
public class SaveManager : MonoBehaviour, ISaveableRegistry
{
public const int QuickSaveSlot = 98;
[Header("Event Channels - Raise")]
[SerializeField] private BaseGames.Core.Events.BoolEventChannelSO _onSaveIndicatorVisible;
private ISaveStorage _storage;
private readonly HashSet<ISaveable> _saveables = new();
private readonly SemaphoreSlim _saveLock = new SemaphoreSlim(1, 1);
private SaveData _current;
private int _currentSlot = 0;
public int CurrentSlot => _currentSlot;
public SaveData Data => _current;
public string LastCheckpointScene { get; private set; }
public string LastCheckpointSpawnId { get; private set; }
private static SaveManager _instance;
private void Awake()
{
if (_instance != null) { Destroy(gameObject); return; }
_instance = this;
ServiceLocator.Register<ISaveableRegistry>(this);
_storage = new LocalFileStorage();
}
// ── ISaveable 注册 ────────────────────────────────────────────────────
public void Register(ISaveable s) => _saveables.Add(s);
public void Unregister(ISaveable s) => _saveables.Remove(s);
// ── 存档 ──────────────────────────────────────────────────────────────
public async Task SaveAsync(int slot = -1)
{
await _saveLock.WaitAsync();
try
{
int targetSlot = slot < 0 ? _currentSlot : slot;
_current ??= new SaveData();
_onSaveIndicatorVisible?.Raise(true);
var snapshot = _saveables.ToList();
foreach (var s in snapshot) s.OnSave(_current);
_current.Meta.LastSaved = DateTime.UtcNow.ToString("o");
_current.Meta.SlotIndex = targetSlot;
_current.Meta.SaveCount++;
// 先清空 checksum序列化并计算再序列化含 checksum 的最终版本
// 使用 Formatting.None 减少序列化字符串体积和 GC 分配
_current.Meta.Checksum = null;
string jsonForChecksum = JsonConvert.SerializeObject(_current, Formatting.None);
_current.Meta.Checksum = ComputeChecksum(jsonForChecksum);
string finalJson = JsonConvert.SerializeObject(_current, Formatting.None);
await _storage.WriteAsync(targetSlot, finalJson);
LastCheckpointScene = _current.Player?.Scene;
LastCheckpointSpawnId = _current.Meta?.SavePointId;
_onSaveIndicatorVisible?.Raise(false);
}
finally
{
_saveLock.Release();
}
}
// ── 读档 ──────────────────────────────────────────────────────────────
public async Task<bool> LoadAsync(int slot)
{
await _saveLock.WaitAsync();
try
{
if (!_storage.Exists(slot)) return false;
string json = await _storage.ReadAsync(slot);
if (json == null) return false;
SaveData loaded;
try { loaded = JsonConvert.DeserializeObject<SaveData>(json); }
catch (Exception e)
{
Debug.LogError($"[SaveManager] 存档解析失败 slot={slot}: {e.Message}");
return false;
}
if (!ValidateChecksum(loaded, json))
{
Debug.LogWarning("[SaveManager] 存档 checksum 校验失败。");
// 非 SteelSoul 模式仍允许加载(只是警告)
}
loaded = SaveMigrator.Migrate(loaded);
_current = loaded;
_currentSlot = slot;
var snapshot = _saveables.ToList();
foreach (var s in snapshot) s.OnLoad(_current);
LastCheckpointScene = _current.Player?.Scene;
LastCheckpointSpawnId = _current.Meta?.SavePointId;
return true;
}
finally
{
_saveLock.Release();
}
}
public bool SlotExists(int slot) => _storage.Exists(slot);
public IEnumerable<int> GetExistingSlots() => _storage.GetExistingSlots();
// ── 具名数据访问器(替代直接访问 Data 属性) ──────────────────────────────────
/// <summary>判断指定收藏物 ID 是否已拾取。</summary>
public bool IsWorldCollected(string id)
=> _current?.World.CollectedIds.Contains(id) == true;
/// <summary>判断指定门 / 进程锁 ID 是否已开启。</summary>
public bool IsDoorOpened(string id)
=> _current?.World.OpenedDoors.Contains(id) == true;
/// <summary>返回当前存档中玩家最大 HP存档未加载时返回 0。</summary>
public int GetPlayerMaxHP() => _current?.Player.MaxHP ?? 0;
// ── 快速存档(挑战房间用)────────────────────────────────────────────
public void QuickSave() => RunFireAndForget(SaveAsync(QuickSaveSlot), "QuickSave");
public void QuickLoad() => RunFireAndForget(LoadAsync(QuickSaveSlot), "QuickLoad");
/// <summary>
/// 将 async Task 包裹为 async void捕获所有异常并输出到 Log。
/// 用于无法 await 的调用点按钮回调、MonoBehaviour 方法等)。
/// </summary>
private static async void RunFireAndForget(Task task, string context)
{
try { await task; }
catch (Exception e)
{
Debug.LogError($"[SaveManager] {context} 失败: {e.Message}\n{e.StackTrace}");
}
}
// ── 世界标志 / EventChain ─────────────────────────────────────────────
public bool GetFlag(string flagId)
=> _current?.EventChains.WorldFlags.TryGetValue(flagId, out var v) == true && v;
public void SetFlag(string flagId, bool value)
{ _current ??= new SaveData(); _current.EventChains.WorldFlags[flagId] = value; }
public void SetChainCompleted(string chainId)
{ _current ??= new SaveData(); _current.EventChains.ChainStates[chainId] = "Completed"; }
public IEnumerable<string> GetCompletedChains()
=> _current?.EventChains.ChainStates.Keys ?? Enumerable.Empty<string>();
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;
}
public bool IsBossDefeated(string bossId)
=> _current?.World.DefeatedBossIds.Contains(bossId) == true;
// ── 存档槽摘要 ────────────────────────────────────────────────────────
public async Task<SlotSummary> GetSlotSummaryAsync(int slotIndex)
{
if (!_storage.Exists(slotIndex)) return null;
string json = await _storage.ReadAsync(slotIndex);
if (json == null) return null;
try
{
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,
};
}
catch { return null; }
}
public void CreateSlot(int slotIndex)
{
_currentSlot = slotIndex;
_current = new SaveData();
_current.Meta.SlotIndex = slotIndex;
}
public async Task DeleteSlotAsync(int slotIndex)
{
await _storage.DeleteAsync(slotIndex);
if (_currentSlot == slotIndex) _current = null;
}
/// <summary>
/// 将紧急存档槽的数据复制到目标槽,并删除紧急存档。
/// 由 <see cref="EmergencySaveService"/> 调用,确保所有 IO 操作通过统一的 ISaveStorage 进行。
/// </summary>
public async Task PromoteEmergencyToSlotAsync(int targetSlot, int emergencySlot)
{
if (!_storage.Exists(emergencySlot)) return;
string json = await _storage.ReadAsync(emergencySlot);
if (json == null) return;
await _storage.WriteAsync(targetSlot, json);
await _storage.DeleteAsync(emergencySlot);
}
// ── 私有工具 ──────────────────────────────────────────────────────────
private string ComputeChecksum(string json)
{
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));
}
private string GetHmacKey()
{
// ⚠️ 不使用 deviceUniqueIdentifier——设备唯一标识在玩家换设备时会改变
// 导致所有存档 checksum 永久失效。改用游戏固定密钥。
const string gameSecret = "ZelingV2SaveIntegrity_v2_9a3f7c1b";
return gameSecret;
}
private bool ValidateChecksum(SaveData data, string rawJson)
{
if (string.IsNullOrEmpty(data?.Meta?.Checksum)) return true;
var saved = data.Meta.Checksum;
data.Meta.Checksum = null;
string jsonNoChecksum = JsonConvert.SerializeObject(data, Formatting.None);
data.Meta.Checksum = saved;
return ComputeChecksum(jsonNoChecksum) == saved;
}
private void OnDestroy()
{
if (_instance == this) _instance = null;
ServiceLocator.Unregister<ISaveableRegistry>(this);
}
}
}