多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View 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
{
// 日志写入失败不能再抛异常,否则会造成无限递归
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 18aff6d8d08f33f43a5980c8c4f94c2b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b59605354f1e174593553d2d09fb624
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 中增加 IsSteelSoulJSON 反序列化已默认 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.1Player.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;
}
}
}

View File

@@ -0,0 +1,25 @@
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Core.Save
{
/// <summary>
/// 带自动注册/注销的 ISaveable MonoBehaviour 基类。
/// 继承此类可消除每个存档对象手动调用 ServiceLocator.GetOrDefault&lt;SaveManager&gt;()?.Register/Unregister 的样板代码。
///
/// 生命周期:
/// OnEnable → ServiceLocator.GetOrDefault&lt;SaveManager&gt;()?.Register(this)
/// OnDisable → ServiceLocator.GetOrDefault&lt;SaveManager&gt;()?.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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d8716b07a7bca5c43bb372f78884cc13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: