多轮审查和修复
This commit is contained in:
65
Assets/Scripts/Core/Save/CrashReporter.cs
Normal file
65
Assets/Scripts/Core/Save/CrashReporter.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 崩溃检测与诊断日志写入。
|
||||
/// 监听 Unity 异常日志并在 OnApplicationPause 非正常退出时触发紧急存档(槽 99)。
|
||||
/// </summary>
|
||||
public class CrashReporter : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
[SerializeField] private EmergencySaveService _emergencyService;
|
||||
|
||||
private bool _cleanExit;
|
||||
|
||||
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 OnApplicationPause(bool pauseStatus)
|
||||
{
|
||||
// 移动端:App 被切出且未完成正常退出流程时触发紧急存档
|
||||
if (pauseStatus && !_cleanExit && _saveManager != null)
|
||||
_ = _saveManager.SaveAsync(EmergencySaveService.EmergencySlot);
|
||||
}
|
||||
|
||||
private void OnLogMessage(string condition, string stackTrace, LogType type)
|
||||
{
|
||||
if (type == LogType.Exception || type == LogType.Error)
|
||||
WriteDiagnosticLog(condition, stackTrace);
|
||||
}
|
||||
|
||||
/// <summary>检查是否存在上次崩溃或意外退出留下的紧急存档。</summary>
|
||||
public bool HasEmergencySave()
|
||||
=> _emergencyService != null && _emergencyService.HasEmergencySave();
|
||||
|
||||
/// <summary>同步写入崩溃诊断日志——崩溃场景下 async 不可靠,改用同步 IO。</summary>
|
||||
private void WriteDiagnosticLog(string condition, string stackTrace)
|
||||
{
|
||||
try
|
||||
{
|
||||
string timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
string logPath = Path.Combine(Application.persistentDataPath, $"crash_{timestamp}.log");
|
||||
string content = $"[{DateTime.UtcNow:o}]\n{condition}\n\n{stackTrace}";
|
||||
File.WriteAllText(logPath, content);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 日志写入失败不能再抛异常,否则会造成无限递归
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/CrashReporter.cs.meta
Normal file
11
Assets/Scripts/Core/Save/CrashReporter.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18aff6d8d08f33f43a5980c8c4f94c2b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -6,7 +6,7 @@ namespace BaseGames.Core.Save
|
||||
{
|
||||
public class EmergencySaveService : MonoBehaviour
|
||||
{
|
||||
private const int EmergencySlot = 99;
|
||||
public const int EmergencySlot = 99;
|
||||
|
||||
[SerializeField] private float _intervalSeconds = 120f;
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
@@ -14,16 +14,14 @@ namespace BaseGames.Core.Save
|
||||
|
||||
private bool _gameplayActive;
|
||||
private float _timer;
|
||||
private readonly CompositeDisposable _subscriptions = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onGameplayActive != null) _onGameplayActive.OnEventRaised += OnGameplayActiveChanged;
|
||||
_onGameplayActive?.Subscribe(OnGameplayActiveChanged).AddTo(_subscriptions);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onGameplayActive != null) _onGameplayActive.OnEventRaised -= OnGameplayActiveChanged;
|
||||
}
|
||||
private void OnDisable() => _subscriptions.Clear();
|
||||
|
||||
private void OnGameplayActiveChanged(bool value) => _gameplayActive = value;
|
||||
|
||||
@@ -44,8 +42,12 @@ namespace BaseGames.Core.Save
|
||||
public async Task PromoteToSlot(int targetSlot)
|
||||
{
|
||||
if (_saveManager == null) return;
|
||||
// Phase 1 stub:完整实现在 Phase 2 LocalFileStorage API
|
||||
await Task.CompletedTask;
|
||||
var storage = new LocalFileStorage();
|
||||
if (!storage.Exists(EmergencySlot)) return;
|
||||
string json = await storage.ReadAsync(EmergencySlot);
|
||||
if (json == null) return;
|
||||
await storage.WriteAsync(targetSlot, json);
|
||||
await storage.DeleteAsync(EmergencySlot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
Assets/Scripts/Core/Save/ISaveableRegistry.cs
Normal file
16
Assets/Scripts/Core/Save/ISaveableRegistry.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// ISaveable 注册表接口。
|
||||
/// 将 ISaveable 对象的注册/注销责任与 SaveManager 的具体实现类解耦,
|
||||
/// 使 SaveableMonoBehaviour 等组件无需直接依赖 <see cref="SaveManager"/>。
|
||||
/// </summary>
|
||||
public interface ISaveableRegistry
|
||||
{
|
||||
/// <summary>将 <paramref name="saveable"/> 加入存档系统管理。</summary>
|
||||
void Register(ISaveable saveable);
|
||||
|
||||
/// <summary>将 <paramref name="saveable"/> 从存档系统移除。</summary>
|
||||
void Unregister(ISaveable saveable);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/ISaveableRegistry.cs.meta
Normal file
11
Assets/Scripts/Core/Save/ISaveableRegistry.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b59605354f1e174593553d2d09fb624
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -11,6 +11,9 @@ namespace BaseGames.Core.Save
|
||||
/// </summary>
|
||||
public class LocalFileStorage : ISaveStorage
|
||||
{
|
||||
/// <summary>存档槽位总数。调整此值时需同步更新 UI 存档槽列表。</summary>
|
||||
public const int MaxSlots = 3;
|
||||
|
||||
private readonly string _saveDir;
|
||||
|
||||
public LocalFileStorage()
|
||||
@@ -54,7 +57,7 @@ namespace BaseGames.Core.Save
|
||||
|
||||
public IEnumerable<int> GetExistingSlots()
|
||||
{
|
||||
for (int i = 0; i < 3; i++)
|
||||
for (int i = 0; i < MaxSlots; i++)
|
||||
if (Exists(i)) yield return i;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace BaseGames.Core.Save
|
||||
public ShopsSaveData Shops = new();
|
||||
public StatsSaveData Stats = new();
|
||||
public NGPlusSaveData NGPlus = null; // null = 非 NG+ 模式
|
||||
public TutorialSaveData Tutorial = new();
|
||||
public SettingsSaveData Settings = new();
|
||||
public Dictionary<string, JObject> DLC = new();
|
||||
}
|
||||
|
||||
@@ -40,6 +42,8 @@ namespace BaseGames.Core.Save
|
||||
public int NGPlusCount;
|
||||
public int SaveCount;
|
||||
public string Checksum; // HMAC-SHA256
|
||||
/// <summary>此存档是否以钢铁之魂(一命)模式开始;加载后 DifficultyManager 会锁定到 SteelSoul。</summary>
|
||||
public bool IsSteelSoul;
|
||||
}
|
||||
|
||||
// ─── Player ───────────────────────────────────────────────────────────────
|
||||
@@ -92,6 +96,7 @@ namespace BaseGames.Core.Save
|
||||
public List<string> OpenedDoors = new();
|
||||
public List<string> DefeatedBossIds = new();
|
||||
public List<string> CollectedIds = new();
|
||||
public List<string> DestroyedObjectIds = new();
|
||||
public Dictionary<string, bool> Switches = new();
|
||||
public Dictionary<string, int> NpcRelations = new();
|
||||
public HashSet<string> ChallengeFirstClears = new();
|
||||
@@ -101,8 +106,29 @@ namespace BaseGames.Core.Save
|
||||
[Serializable]
|
||||
public class MapSaveData
|
||||
{
|
||||
public Dictionary<string, List<int>> DiscoveredRooms = new();
|
||||
public Dictionary<string, bool> MapPurchased = new();
|
||||
public List<string> ExploredRooms = new(); // 踏入过的房间 ID
|
||||
public List<string> MappedRooms = new(); // 完整地图信息(购买/存档点揭示)
|
||||
public List<MapPin> Pins = new(); // 玩家自定义地图标记
|
||||
}
|
||||
|
||||
/// <summary>玩家在地图上放置的自定义标记(架构 15_MapShopModule §1.5)。</summary>
|
||||
[Serializable]
|
||||
public class MapPin
|
||||
{
|
||||
public string RoomId;
|
||||
public float NormalizedPosX;
|
||||
public float NormalizedPosY;
|
||||
public int PinTypeInt; // PinType 枚举整数值,避免 SaveData 依赖 Map 程序集
|
||||
public string Note; // 玩家备注(最多 64 字符)
|
||||
}
|
||||
|
||||
public enum PinType
|
||||
{
|
||||
Marker = 0,
|
||||
Chest = 1,
|
||||
Enemy = 2,
|
||||
Path = 3,
|
||||
Note = 4,
|
||||
}
|
||||
|
||||
// ─── Quests ───────────────────────────────────────────────────────────────
|
||||
@@ -144,6 +170,7 @@ namespace BaseGames.Core.Save
|
||||
public int EnemyKills, Deaths, ParrySuccess, ParryFail;
|
||||
public int GeoEarned, GeoLost;
|
||||
public float DistanceTraveled;
|
||||
public float SpeedrunTime;
|
||||
public int SaveCount;
|
||||
public Dictionary<string, int> SkillUseCounts = new();
|
||||
public Dictionary<string, int> DeathsByBoss = new();
|
||||
@@ -215,4 +242,20 @@ namespace BaseGames.Core.Save
|
||||
public string SceneName;
|
||||
public string ActiveFormId;
|
||||
}
|
||||
|
||||
// ─── Tutorial ─────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class TutorialSaveData
|
||||
{
|
||||
/// <summary>已完成(不再弹出)的提示 ID 列表。</summary>
|
||||
public List<string> CompletedHintIds = new();
|
||||
}
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class SettingsSaveData
|
||||
{
|
||||
/// <summary>玩家选择的语言。空字符串 = 使用系统默认。</summary>
|
||||
public string Language = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,68 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理存档版本升级(旧版 → 新版数据结构)。
|
||||
/// 处理存档版本迁移。
|
||||
/// 迁移链:旧版本 → "2.0" → "2.1"(CurrentVersion),每个分支按顺序落下执行(fall-through)。
|
||||
/// </summary>
|
||||
public static class SaveMigrator
|
||||
{
|
||||
private const string CurrentVersion = "2.1";
|
||||
public const string CurrentVersion = "2.1";
|
||||
|
||||
public static SaveData Migrate(SaveData data)
|
||||
{
|
||||
if (data?.Meta == null) return data;
|
||||
switch (data.Meta.Version)
|
||||
|
||||
string v = data.Meta.Version ?? "";
|
||||
|
||||
// ── 旧版本(< 2.0)→ 2.0 ───────────────────────────────────────────
|
||||
if (string.IsNullOrEmpty(v) || IsOlderThan(v, "2.0"))
|
||||
{
|
||||
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": data = MigrateFrom2_0(data); goto case "2.1";
|
||||
case "2.1": break;
|
||||
default:
|
||||
Debug.LogWarning($"[SaveMigrator] 未知存档版本 '{data.Meta.Version}',跳过迁移。");
|
||||
break;
|
||||
// 2.0 引入的新存档节:若为 null 则补充空白对象
|
||||
data.Tutorial ??= new TutorialSaveData();
|
||||
data.Settings ??= new SettingsSaveData();
|
||||
data.EventChains ??= new EventChainsSaveData();
|
||||
data.ChallengeRooms ??= new ChallengeRoomsSaveData();
|
||||
data.NGPlus = null; // 非 NG+ 模式
|
||||
|
||||
// Player 子字段兼容(旧版本可能未记录 DeathShade)
|
||||
if (data.Player != null)
|
||||
data.Player.DeathShade ??= new DeathShadeSaveData();
|
||||
|
||||
Debug.Log($"[SaveMigrator] 从 '{v}' 迁移至 '2.0'。");
|
||||
v = "2.0";
|
||||
}
|
||||
|
||||
// ── 2.0 → 2.1 ───────────────────────────────────────────────────────
|
||||
if (v == "2.0")
|
||||
{
|
||||
// 2.1 在 SaveMeta 中增加 IsSteelSoul;JSON 反序列化已默认 false,无需额外处理。
|
||||
// Stats 新增 SpeedrunTime;默认 0f,无需额外处理。
|
||||
// Map.Pins 列表:旧版可能为 null
|
||||
if (data.Map != null)
|
||||
data.Map.Pins ??= new System.Collections.Generic.List<MapPin>();
|
||||
|
||||
Debug.Log("[SaveMigrator] 从 '2.0' 迁移至 '2.1'。");
|
||||
v = "2.1";
|
||||
}
|
||||
|
||||
// ── 未识别的未来版本 ─────────────────────────────────────────────────
|
||||
if (v != CurrentVersion)
|
||||
Debug.LogWarning($"[SaveMigrator] 未知版本 '{v}',将直接使用(可能存在兼容性问题)。");
|
||||
|
||||
data.Meta.Version = CurrentVersion;
|
||||
return data;
|
||||
}
|
||||
|
||||
private static SaveData MigrateFrom1_0(SaveData d)
|
||||
{
|
||||
d.Equipment ??= new EquipmentSaveData();
|
||||
d.Player ??= new PlayerSaveData();
|
||||
d.World ??= new WorldSaveData();
|
||||
d.Equipment.UpgradedCharmIds ??= new List<string>();
|
||||
return d;
|
||||
}
|
||||
// ── 工具 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static SaveData MigrateFrom1_1(SaveData d)
|
||||
/// <summary>简单语义版本比较:返回 <paramref name="v"/> 是否严格小于 <paramref name="target"/>。</summary>
|
||||
private static bool IsOlderThan(string v, string target)
|
||||
{
|
||||
d.Stats ??= new StatsSaveData();
|
||||
d.Stats.SkillUseCounts ??= new Dictionary<string, int>();
|
||||
if (d.Player.ShieldHP == 0 && !d.Player.ShieldIsBroken)
|
||||
d.Player.ShieldHP = -1;
|
||||
return d;
|
||||
}
|
||||
|
||||
private static SaveData MigrateFrom2_0(SaveData d)
|
||||
{
|
||||
// 2.0 → 2.1:Player.AbilityFlags (uint bitmask) 替换旧版 Dictionary<string,bool> Abilities
|
||||
// 旧版若 AbilityFlags 为 0 且 ExtensionData 含 "Abilities" 字段,则转换
|
||||
if (d.Player.AbilityFlags == 0 && d.ExtensionData.ContainsKey("Abilities"))
|
||||
{
|
||||
// 此处仅清除旧字段,具体位掩码由 AbilitySystem 在 OnLoad 时根据 ExtrensionData 转换
|
||||
d.ExtensionData.Remove("Abilities");
|
||||
}
|
||||
return d;
|
||||
return System.Version.TryParse(v, out var sv) &&
|
||||
System.Version.TryParse(target, out var tv) &&
|
||||
sv < tv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs
Normal file
25
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 带自动注册/注销的 ISaveable MonoBehaviour 基类。
|
||||
/// 继承此类可消除每个存档对象手动调用 ServiceLocator.GetOrDefault<SaveManager>()?.Register/Unregister 的样板代码。
|
||||
///
|
||||
/// 生命周期:
|
||||
/// OnEnable → ServiceLocator.GetOrDefault<SaveManager>()?.Register(this)
|
||||
/// OnDisable → ServiceLocator.GetOrDefault<SaveManager>()?.Unregister(this)
|
||||
///
|
||||
/// 子类只需实现 <see cref="OnSave"/> 和 <see cref="OnLoad"/>。
|
||||
/// 若子类需要自定义 OnEnable/OnDisable,请先调用 base.OnEnable() / base.OnDisable()。
|
||||
/// </summary>
|
||||
public abstract class SaveableMonoBehaviour : MonoBehaviour, ISaveable
|
||||
{
|
||||
protected virtual void OnEnable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
protected virtual void OnDisable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
|
||||
public abstract void OnSave(SaveData saveData);
|
||||
public abstract void OnLoad(SaveData saveData);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs.meta
Normal file
11
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8716b07a7bca5c43bb372f78884cc13
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user