189 lines
8.0 KiB
C#
189 lines
8.0 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Threading.Tasks;
|
||
using Newtonsoft.Json;
|
||
using UnityEngine;
|
||
|
||
namespace BaseGames.Core.Save
|
||
{
|
||
/// <summary>
|
||
/// 存档管理器(Phase 0 骨架)。
|
||
/// 完整序列化/反序列化由 Newtonsoft.Json 驱动。
|
||
/// </summary>
|
||
[DefaultExecutionOrder(-900)]
|
||
public class SaveManager : MonoBehaviour
|
||
{
|
||
public static SaveManager Instance { get; private set; }
|
||
|
||
public const string MinCompatibleVersion = "1.0";
|
||
private 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 SaveData _current;
|
||
private int _currentSlot = 0;
|
||
|
||
public static string LastCheckpointScene { get; private set; }
|
||
public static string LastCheckpointSpawnId { get; private set; }
|
||
|
||
private void Awake()
|
||
{
|
||
if (Instance != null) { Destroy(gameObject); return; }
|
||
Instance = 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)
|
||
{
|
||
int targetSlot = slot < 0 ? _currentSlot : slot;
|
||
_current ??= new SaveData();
|
||
|
||
_onSaveIndicatorVisible?.Raise(true);
|
||
foreach (var s in _saveables) s.OnSave(_current);
|
||
|
||
_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);
|
||
|
||
await _storage.WriteAsync(targetSlot, finalJson);
|
||
|
||
LastCheckpointScene = _current.Player?.Scene;
|
||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||
|
||
_onSaveIndicatorVisible?.Raise(false);
|
||
}
|
||
|
||
// ── 读档 ──────────────────────────────────────────────────────────────
|
||
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)
|
||
{
|
||
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;
|
||
|
||
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();
|
||
|
||
// ── 快速存档(挑战房间用)────────────────────────────────────────────
|
||
public void QuickSave() => _ = SaveAsync(QuickSaveSlot);
|
||
public void QuickLoad() => _ = LoadAsync(QuickSaveSlot);
|
||
|
||
// ── 世界标志 / 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;
|
||
}
|
||
|
||
// ── 私有工具 ──────────────────────────────────────────────────────────
|
||
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()
|
||
{
|
||
string deviceId = SystemInfo.deviceUniqueIdentifier;
|
||
const string salt = "ZelingV2SaveIntegrity_v1";
|
||
return $"{deviceId}:{salt}";
|
||
}
|
||
|
||
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.Indented);
|
||
data.Meta.Checksum = saved;
|
||
return ComputeChecksum(jsonNoChecksum) == saved;
|
||
}
|
||
}
|
||
}
|