多轮审查和修复
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
@@ -8,33 +9,35 @@ using UnityEngine;
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档管理器(Phase 0 骨架)。
|
||||
/// 存档管理器。
|
||||
/// 完整序列化/反序列化由 Newtonsoft.Json 驱动。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class SaveManager : MonoBehaviour
|
||||
public class SaveManager : MonoBehaviour, ISaveableRegistry
|
||||
{
|
||||
public static SaveManager Instance { get; private set; }
|
||||
|
||||
public const string MinCompatibleVersion = "1.0";
|
||||
private const int QuickSaveSlot = 98;
|
||||
public const int QuickSaveSlot = 98;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private BaseGames.Core.Events.BoolEventChannelSO _onSaveIndicatorVisible;
|
||||
|
||||
private ISaveStorage _storage;
|
||||
private readonly List<ISaveable> _saveables = new();
|
||||
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 static string LastCheckpointScene { get; private set; }
|
||||
public static string LastCheckpointSpawnId { get; private set; }
|
||||
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;
|
||||
if (_instance != null) { Destroy(gameObject); return; }
|
||||
_instance = this;
|
||||
ServiceLocator.Register<ISaveableRegistry>(this);
|
||||
|
||||
_storage = new LocalFileStorage();
|
||||
}
|
||||
@@ -46,69 +49,113 @@ namespace BaseGames.Core.Save
|
||||
// ── 存档 ──────────────────────────────────────────────────────────────
|
||||
public async Task SaveAsync(int slot = -1)
|
||||
{
|
||||
int targetSlot = slot < 0 ? _currentSlot : slot;
|
||||
_current ??= new SaveData();
|
||||
await _saveLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
int targetSlot = slot < 0 ? _currentSlot : slot;
|
||||
_current ??= new SaveData();
|
||||
|
||||
_onSaveIndicatorVisible?.Raise(true);
|
||||
foreach (var s in _saveables) s.OnSave(_current);
|
||||
_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++;
|
||||
_current.Meta.LastSaved = DateTime.UtcNow.ToString("o");
|
||||
_current.Meta.SlotIndex = targetSlot;
|
||||
_current.Meta.SaveCount++;
|
||||
|
||||
// 先清空 checksum,序列化并计算,再序列化含 checksum 的最终版本
|
||||
_current.Meta.Checksum = null;
|
||||
string jsonForChecksum = JsonConvert.SerializeObject(_current, Formatting.Indented);
|
||||
_current.Meta.Checksum = ComputeChecksum(jsonForChecksum);
|
||||
string finalJson = JsonConvert.SerializeObject(_current, Formatting.Indented);
|
||||
// 先清空 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);
|
||||
await _storage.WriteAsync(targetSlot, finalJson);
|
||||
|
||||
LastCheckpointScene = _current.Player?.Scene;
|
||||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||||
LastCheckpointScene = _current.Player?.Scene;
|
||||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||||
|
||||
_onSaveIndicatorVisible?.Raise(false);
|
||||
_onSaveIndicatorVisible?.Raise(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saveLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 读档 ──────────────────────────────────────────────────────────────
|
||||
public async Task<bool> LoadAsync(int slot)
|
||||
{
|
||||
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)
|
||||
await _saveLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
Debug.LogError($"[SaveManager] 存档解析失败 slot={slot}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
if (!_storage.Exists(slot)) return false;
|
||||
|
||||
if (!ValidateChecksum(loaded, json))
|
||||
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
|
||||
{
|
||||
Debug.LogWarning("[SaveManager] 存档 checksum 校验失败。");
|
||||
// 非 SteelSoul 模式仍允许加载(只是警告)
|
||||
_saveLock.Release();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// ── 具名数据访问器(替代直接访问 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() => _ = SaveAsync(QuickSaveSlot);
|
||||
public void QuickLoad() => _ = LoadAsync(QuickSaveSlot);
|
||||
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)
|
||||
@@ -159,6 +206,12 @@ namespace BaseGames.Core.Save
|
||||
_current.Meta.SlotIndex = slotIndex;
|
||||
}
|
||||
|
||||
public async Task DeleteSlotAsync(int slotIndex)
|
||||
{
|
||||
await _storage.DeleteAsync(slotIndex);
|
||||
if (_currentSlot == slotIndex) _current = null;
|
||||
}
|
||||
|
||||
// ── 私有工具 ──────────────────────────────────────────────────────────
|
||||
private string ComputeChecksum(string json)
|
||||
{
|
||||
@@ -170,9 +223,10 @@ namespace BaseGames.Core.Save
|
||||
|
||||
private string GetHmacKey()
|
||||
{
|
||||
string deviceId = SystemInfo.deviceUniqueIdentifier;
|
||||
const string salt = "ZelingV2SaveIntegrity_v1";
|
||||
return $"{deviceId}:{salt}";
|
||||
// ⚠️ 不使用 deviceUniqueIdentifier——设备唯一标识在玩家换设备时会改变,
|
||||
// 导致所有存档 checksum 永久失效。改用游戏固定密钥。
|
||||
const string gameSecret = "ZelingV2SaveIntegrity_v2_9a3f7c1b";
|
||||
return gameSecret;
|
||||
}
|
||||
|
||||
private bool ValidateChecksum(SaveData data, string rawJson)
|
||||
@@ -180,9 +234,15 @@ namespace BaseGames.Core.Save
|
||||
if (string.IsNullOrEmpty(data?.Meta?.Checksum)) return true;
|
||||
var saved = data.Meta.Checksum;
|
||||
data.Meta.Checksum = null;
|
||||
string jsonNoChecksum = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user