Files
zeling_v2/Assets/Scripts/Core/Save/SaveManager.cs
2026-05-08 11:04:00 +08:00

189 lines
8.0 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.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;
}
}
}