diff --git a/Assets/_Game/Scripts/Core/AutoSaveService.cs b/Assets/_Game/Scripts/Core/AutoSaveService.cs index bf26d9d..e7b98cd 100644 --- a/Assets/_Game/Scripts/Core/AutoSaveService.cs +++ b/Assets/_Game/Scripts/Core/AutoSaveService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections; using UnityEngine; using BaseGames.Core.Events; @@ -105,12 +106,20 @@ namespace BaseGames.Core #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.Log($"[AutoSave] 触发:{reason}"); #endif - // 与手动存档使用相同槽位,fire-and-forget - _ = svc.SaveAsync(svc.ActiveSlot); + RunFireAndForget(svc.SaveAsync(svc.ActiveSlot), reason); } yield return new WaitForSecondsRealtime(_cooldownSeconds); _onCooldown = false; } + + private static async void RunFireAndForget(System.Threading.Tasks.Task task, string context) + { + try { await task; } + catch (Exception e) + { + Debug.LogError($"[AutoSave] {context} 存档失败: {e.Message}\n{e.StackTrace}"); + } + } } } diff --git a/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs b/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs index c1272b0..15076a1 100644 --- a/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs +++ b/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs @@ -232,5 +232,10 @@ namespace BaseGames.Core public System.Collections.Generic.IEnumerable GetCompletedChains() => _save.GetCompletedChains(); public void SetChainCompleted(string chainId) => _save.SetChainCompleted(chainId); public void MarkBossDefeated(string bossId) => _save.MarkBossDefeated(bossId); + + // ── 存档槽管理(主菜单 UI 用)───────────────────────────────────────── + public System.Threading.Tasks.Task GetSlotSummaryAsync(int slotIndex) + => _save.GetSlotSummaryAsync(slotIndex); + public void CreateSlot(int slotIndex) => _save.CreateSlot(slotIndex); } } diff --git a/Assets/_Game/Scripts/Core/ISaveService.cs b/Assets/_Game/Scripts/Core/ISaveService.cs index 228b078..031b43b 100644 --- a/Assets/_Game/Scripts/Core/ISaveService.cs +++ b/Assets/_Game/Scripts/Core/ISaveService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using BaseGames.Core.Save; namespace BaseGames.Core { @@ -76,5 +77,13 @@ namespace BaseGames.Core /// 标记某个 Boss 已被击败,立即写入当前 SaveData(无需等待下次存档)。 void MarkBossDefeated(string bossId); + + // ── 存档槽管理(主菜单 UI 用)──────────────────────────────────────── + + /// 读取指定存档槽摘要数据用于 UI 展示;槽不存在时返回 null。 + Task GetSlotSummaryAsync(int slotIndex); + + /// 新建存档槽(不写盘,仅初始化内存 SaveData 并设定活跃槽索引)。 + void CreateSlot(int slotIndex); } } diff --git a/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs b/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs index 70cf6c5..a135abd 100644 --- a/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs +++ b/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs @@ -127,7 +127,7 @@ namespace BaseGames.Core.Save return false; } - if (!ValidateChecksum(loaded, json)) + if (!ValidateChecksum(loaded)) { if (loaded.Meta.IsSteelSoul) { @@ -278,7 +278,7 @@ namespace BaseGames.Core.Save return gameSecret; } - private bool ValidateChecksum(SaveData data, string rawJson) + private bool ValidateChecksum(SaveData data) { if (string.IsNullOrEmpty(data?.Meta?.Checksum)) return true; var saved = data.Meta.Checksum; diff --git a/Assets/_Game/Scripts/Support/Speedrun/SpeedrunTimer.cs b/Assets/_Game/Scripts/Support/Speedrun/SpeedrunTimer.cs index 2ecfdd1..123d41a 100644 --- a/Assets/_Game/Scripts/Support/Speedrun/SpeedrunTimer.cs +++ b/Assets/_Game/Scripts/Support/Speedrun/SpeedrunTimer.cs @@ -28,6 +28,16 @@ public class SpeedrunTimer : MonoBehaviour, ISaveable /// 上一帧已显示的整秒值,展示内容未变化时跳过字符串重建。 private int _lastDisplayedSecond = -1; + private void OnEnable() + { + ServiceLocator.GetOrDefault()?.Register(this); + } + + private void OnDisable() + { + ServiceLocator.GetOrDefault()?.Unregister(this); + } + private void Start() { bool show = _settings != null && _settings.ShowSpeedrunTimer; diff --git a/Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs b/Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs index b7d33d8..370a355 100644 --- a/Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs +++ b/Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using UnityEngine; using UnityEngine.UI; using TMPro; +using BaseGames.Core; using BaseGames.Core.Events; using BaseGames.Core.Save; @@ -14,7 +15,6 @@ namespace BaseGames.UI.Menus public class SaveSlotController : MonoBehaviour { [SerializeField] private SaveSlotUI[] _slotUIs; // 存档槽 UI(数量由 Inspector 决定) - [SerializeField] private GameSaveManager _saveManager; [Header("Event Channels")] [SerializeField] private IntEventChannelSO _onSlotConfirmed; // 携带槽索引,供 GameManager 监听 @@ -22,7 +22,6 @@ namespace BaseGames.UI.Menus private void OnEnable() { var task = RefreshAsync(); - // 捕获 async Task 异常,避免 async void 吞掉未处理异常 task.ContinueWith(t => { if (t.IsFaulted) @@ -32,11 +31,12 @@ namespace BaseGames.UI.Menus private async Task RefreshAsync() { - if (_saveManager == null) return; + var svc = ServiceLocator.GetOrDefault(); + if (svc == null) return; for (int i = 0; i < _slotUIs.Length; i++) { if (_slotUIs[i] == null) continue; - var summary = await _saveManager.GetSlotSummaryAsync(i); + var summary = await svc.GetSlotSummaryAsync(i); _slotUIs[i].Refresh(summary); } } @@ -44,16 +44,19 @@ namespace BaseGames.UI.Menus /// 选中指定槽位(新局或继续)。由 SaveSlotUI 内部按钮调用。 public void OnSlotSelected(int slotIndex) { - if (slotIndex < 0 || slotIndex >= _slotUIs.Length || _saveManager == null) return; + if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return; _ = SelectSlotAsync(slotIndex); } private async Task SelectSlotAsync(int slotIndex) { - if (_saveManager.SlotExists(slotIndex)) - await _saveManager.LoadAsync(slotIndex); + var svc = ServiceLocator.GetOrDefault(); + if (svc == null) return; + + if (svc.HasSave(slotIndex)) + await svc.LoadAsync(slotIndex); else - _saveManager.CreateSlot(slotIndex); + svc.CreateSlot(slotIndex); _onSlotConfirmed?.Raise(slotIndex); } @@ -61,13 +64,15 @@ namespace BaseGames.UI.Menus /// 删除指定槽位存档并刷新 UI。由 SaveSlotUI 内部按钮调用。 public void OnSlotDeleteRequested(int slotIndex) { - if (slotIndex < 0 || slotIndex >= _slotUIs.Length || _saveManager == null) return; + if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return; _ = DeleteAndRefreshAsync(slotIndex); } private async Task DeleteAndRefreshAsync(int slotIndex) { - await _saveManager.DeleteSlotAsync(slotIndex); + var svc = ServiceLocator.GetOrDefault(); + if (svc == null) return; + await svc.DeleteSlotAsync(slotIndex); await RefreshAsync(); } } diff --git a/Assets/_Game/Scripts/World/WorldStateRegistrySaver.cs b/Assets/_Game/Scripts/World/WorldStateRegistrySaver.cs index 1abfb83..d20cd0e 100644 --- a/Assets/_Game/Scripts/World/WorldStateRegistrySaver.cs +++ b/Assets/_Game/Scripts/World/WorldStateRegistrySaver.cs @@ -8,8 +8,8 @@ namespace BaseGames.World /// 挂载位置:Persistent 场景的根 GameObject(与 GameServiceRegistrar 同级)。 /// 需在 Inspector 中绑定场景中唯一的 WorldStateRegistry ScriptableObject 资源。 /// - /// OnSave:从 Registry 读取 Collectible / Destroyed / Flag 三个分类,写入 SaveData。 - /// SavePoint 和 Door 两个分类由各自的 SaveableMonoBehaviour 独立写入,此处跳过。 + /// OnSave:从 Registry 读取 Collectible / Destroyed / Door / Flag 四个分类,写入 SaveData。 + /// 每个分类在写入前先 Clear,确保运行时删除的状态不会残留在存档里。 /// OnLoad:将完整的 SaveData 传递给 Registry,恢复所有分类的运行时缓存。 /// 这是 Registry 从空白变为"知道世界状态"的唯一时机。 /// @@ -29,6 +29,12 @@ namespace BaseGames.World foreach (var id in _registry.GetAllIds(WorldObjectCategory.Destroyed)) data.World.DestroyedObjectIds.Add(id); + data.World.OpenedDoors.Clear(); + foreach (var id in _registry.GetAllIds(WorldObjectCategory.Door)) + data.World.OpenedDoors.Add(id); + + // 清除所有 Flag 后重写,确保运行时 ClearFlag() 的变更能正确落盘 + data.EventChains.WorldFlags.Clear(); foreach (var id in _registry.GetAllIds(WorldObjectCategory.Flag)) data.EventChains.WorldFlags[id] = true; }