From 67668f18e040b5b30420230ffac926d23d1f6c2f Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Mon, 22 Jun 2026 14:48:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(SaveSystem):=20=E8=AF=BB=E6=A1=A3=E7=BC=96?= =?UTF-8?q?=E6=8E=92=20Phase+Barrier=20=E4=B8=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=AD=98=E6=A1=A3=E6=8A=91=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaveSystemValidationWindow.cs | 80 ++++++++ .../Scripts/SaveSystem/SavePointEvaluator.cs | 7 + .../SaveSystem/SaveRestoreOrchestrator.cs | 191 +++++++++++++----- Assets/Scripts/SaveSystem/SnapshotRestore.cs | 127 +++++++++--- Assets/Scripts/SaveSystem/SnapshotService.cs | 9 +- 5 files changed, 331 insertions(+), 83 deletions(-) diff --git a/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs b/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs index 9c591c438..968e37814 100644 --- a/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs +++ b/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs @@ -28,6 +28,7 @@ namespace AibisDream.SaveSystem.Editor private int _deleteSlotIndex = 1; private string _testNodeName = "SomeNode"; private string _testTags = ""; + private int _restoreSlotIndex; private readonly List _saveHistory = new(); private string _lastObservedSavedAt; @@ -149,6 +150,8 @@ namespace AibisDream.SaveSystem.Editor GUILayout.Space(12); DrawManualSlotSection(); GUILayout.Space(12); + DrawRestoreSection(); + GUILayout.Space(12); DrawSavePointEvaluationSection(); GUILayout.Space(12); DrawUtilitySection(); @@ -182,6 +185,9 @@ namespace AibisDream.SaveSystem.Editor var latest = SlotManager.GetLatestSlotIndex(); EditorGUILayout.LabelField($"最近槽位: {(latest.HasValue ? latest.Value.ToString() : "无")}"); EditorGUILayout.LabelField($"存档根目录: {ConstRef.SaveFilePath}"); + EditorGUILayout.LabelField( + $"读档状态: IsRestoring={SaveRestoreOrchestrator.IsRestoring}, " + + $"SuppressAutoSave={SaveRestoreOrchestrator.IsAutoSaveSuppressed}"); var viewModels = SlotManager.GetSlotViewModels(); foreach (var vm in viewModels) @@ -322,6 +328,44 @@ namespace AibisDream.SaveSystem.Editor } } + private void DrawRestoreSection() + { + EditorGUILayout.LabelField("读档验证 (P4)", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "触发 SaveRestoreOrchestrator.RestoreFromSlot,验证 Phase + Barrier 编排、自动存档抑制与 Provider 还原日志。\n" + + "需要在 Play Mode 下执行;不代表正式玩家 UI。", + MessageType.None); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Restore Latest")) + { + RestoreLatestSlot(); + } + + if (GUILayout.Button("Restore slot_0")) + { + RestoreSlot(SlotIndex.Auto); + } + } + + _restoreSlotIndex = EditorGUILayout.IntSlider("Restore 槽位", _restoreSlotIndex, 0, SlotIndex.ManualEnd); + if (GUILayout.Button($"Restore slot_{_restoreSlotIndex}")) + { + RestoreSlot(_restoreSlotIndex); + } + + var restoreLog = SaveRestoreOrchestrator.LastRestoreLog; + if (restoreLog.Count > 0) + { + EditorGUILayout.LabelField("Restore Pipeline Log", EditorStyles.boldLabel); + foreach (var line in restoreLog) + { + EditorGUILayout.LabelField(line, EditorStyles.wordWrappedMiniLabel); + } + } + } + private void DrawUtilitySection() { EditorGUILayout.LabelField("工具", EditorStyles.boldLabel); @@ -510,6 +554,42 @@ namespace AibisDream.SaveSystem.Editor } } + private void RestoreLatestSlot() + { + var latest = SlotManager.GetLatestSlotIndex(); + if (!latest.HasValue) + { + Log("没有 latest_slot,无法读档。"); + return; + } + + RestoreSlot(latest.Value); + } + + private void RestoreSlot(int slotIndex) + { + if (!Application.isPlaying) + { + Log("Restore 需要在 Play Mode 下执行。"); + return; + } + + if (GameManager.Instance == null) + { + Log("GameManager 未初始化,无法启动读档协程。"); + return; + } + + if (!SlotDirectory.Exists(slotIndex)) + { + Log($"slot_{slotIndex} 不存在,无法读档。"); + return; + } + + GameManager.Instance.StartCoroutine(SaveRestoreOrchestrator.RestoreFromSlot(slotIndex)); + Log($"已启动 Restore slot_{slotIndex}。请观察 Restore Pipeline Log 与场景状态。"); + } + private void OpenSaveDirectory() { var path = ConstRef.SaveFilePath; diff --git a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs index 735e449c7..563986f9c 100644 --- a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs +++ b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs @@ -10,6 +10,7 @@ namespace AibisDream.SaveSystem public enum SavePointRejectReason { None, + AutoSaveSuppressed, Restoring, GamePaused, @@ -65,6 +66,12 @@ namespace AibisDream.SaveSystem /// public static bool CanAutoSave(out SavePointRejectReason reason) { + if (SaveRestoreOrchestrator.IsAutoSaveSuppressed) + { + reason = SavePointRejectReason.AutoSaveSuppressed; + return false; + } + if (SaveRestoreOrchestrator.IsRestoring) { reason = SavePointRejectReason.Restoring; diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs index 0fd8107ae..8fc5ceb94 100644 --- a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs @@ -1,4 +1,5 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; using AibisDream.Framework; using AibisDream.UI; @@ -9,20 +10,22 @@ namespace AibisDream.SaveSystem { /// /// 存读档流程编排层:连接 UI、 与槽位系统。 - /// - /// 对外入口:(手动/调试触发)、 - /// (节点进入事件触发)、 - /// (手动存档)、 - /// (读档)、 - /// (旧档/调试)。 - /// /// public static class SaveRestoreOrchestrator { - /// 是否正在读档还原中;为 true 时禁止自动存档覆盖 slot_0。 + /// 是否正在读档还原中;供 UI / 日志 / 外部系统观察。 public static bool IsRestoring { get; private set; } + /// 是否处于自动存档抑制作用域;真正用于防止读档期间覆盖 slot_0。 + public static bool IsAutoSaveSuppressed => _autoSaveSuppressDepth > 0; + + public static IReadOnlyList LastRestoreLog => _lastRestoreLog; + private static bool _isAutoSaving; + private static int _autoSaveSuppressDepth; + private static readonly List _lastRestoreLog = new(); + + private const float RestoreFadeDuration = 0.2f; /// 启动自动存档协程(手动/调试入口)。 public static void TryAutoSave() @@ -44,7 +47,6 @@ namespace AibisDream.SaveSystem } /// 自动存档协程:settle 一帧 → 主线程 Capture → 异步写盘。 - /// OnNodeStart 触发存档时的节点名,作为 anchor 写入快照。 public static IEnumerator AutoSaveRoutine(string triggerNodeName = null) { if (_isAutoSaving) @@ -54,7 +56,6 @@ namespace AibisDream.SaveSystem yield return null; - // 可能在 settle 帧内已有另一条协程开始写盘,须二次检查避免并发覆盖 slot_0。 if (_isAutoSaving) { yield break; @@ -115,49 +116,40 @@ namespace AibisDream.SaveSystem /// 从指定槽位读档并还原。 public static IEnumerator RestoreFromSlot(int slotIndex) { - IsRestoring = true; - try + var snapshot = SlotManager.LoadSnapshot(slotIndex); + if (snapshot == null) { - var snapshot = SlotManager.LoadSnapshot(slotIndex); - if (snapshot == null) - { - Debug.LogError($"[SaveRestoreOrchestrator] 槽位 {slotIndex} 不存在或读取失败"); - yield break; - } + Debug.LogError($"[SaveRestoreOrchestrator] 槽位 {slotIndex} 不存在或读取失败"); + yield break; + } - yield return RestoreSnapshot(snapshot); - } - finally - { - IsRestoring = false; - } + yield return RestoreSnapshot(snapshot, $"slot_{slotIndex}"); } /// 从文件读档并还原;自动区分新快照格式与 legacy 格式。 public static IEnumerator RestoreFromFile(string savePath) { - IsRestoring = true; - try + if (SnapshotPersistence.IsLegacyFormat(savePath)) { - if (SnapshotPersistence.IsLegacyFormat(savePath)) - { - Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。"); - yield return RestoreLegacy(savePath); - yield break; - } - - SaveSnapshot snapshot; - using (new CodeTimer("LoadSnapshot")) - { - snapshot = SnapshotPersistence.Load(savePath); - } - - yield return RestoreSnapshot(snapshot); + Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。"); + yield return RestoreLegacyWithFlow(savePath); + yield break; } - finally + + SaveSnapshot snapshot; + using (new CodeTimer("LoadSnapshot")) { - IsRestoring = false; + snapshot = SnapshotPersistence.Load(savePath); } + + yield return RestoreSnapshot(snapshot, savePath); + } + + public static IDisposable SuppressAutoSaveScope(string reason) + { + _autoSaveSuppressDepth++; + Debug.Log($"[SaveRestoreOrchestrator] SuppressAutoSave begin: {reason}, depth={_autoSaveSuppressDepth}"); + return new AutoSaveSuppressScope(reason); } /// 旧档:Yarn 变量 + DeepRepairDataRegistry systemData。 @@ -174,14 +166,121 @@ namespace AibisDream.SaveSystem yield return DeepRepairDataRegistry.LoadByJson(systemDataJson); } - // 旧档还原后自动固化为新格式,方便后续使用槽位系统 var snapshot = SnapshotService.Capture(); SlotManager.SaveToAutoSlot(snapshot, null); } - private static IEnumerator RestoreSnapshot(SaveSnapshot snapshot) + private static IEnumerator RestoreLegacyWithFlow(string savePath) { - yield return SnapshotService.Restore(snapshot); + using (SuppressAutoSaveScope("RestoreLegacy")) + { + IsRestoring = true; + ResetRestoreLog("legacy"); + + try + { + yield return FadeInForRestore(); + yield return RestoreLegacy(savePath); + yield return null; + yield return FadeOutForRestore(); + } + finally + { + IsRestoring = false; + AddRestoreLog("Legacy restore finished"); + } + } + } + + private static IEnumerator RestoreSnapshot(SaveSnapshot snapshot, string sourceLabel) + { + if (snapshot == null) + { + Debug.LogError("[SaveRestoreOrchestrator] snapshot 为 null,无法读档。"); + yield break; + } + + using (SuppressAutoSaveScope($"RestoreSnapshot:{sourceLabel}")) + { + IsRestoring = true; + ResetRestoreLog(sourceLabel); + var context = new SnapshotRestoreContext(snapshot, logStep: AddRestoreLog); + + try + { + yield return FadeInForRestore(); + + SnapshotRegistry.EnsureInitialized(); + yield return SnapshotRestore.RestoreState(YarnVariableStorage.Instance, snapshot, context); + yield return SnapshotRestore.RestoreAnchor(snapshot, context); + + AddRestoreLog("Phase 3.5: settle one frame"); + yield return null; + + yield return FadeOutForRestore(); + } + finally + { + IsRestoring = false; + AddRestoreLog("Restore finished"); + } + } + } + + private static IEnumerator FadeInForRestore() + { + AddRestoreLog("Begin restore fade in"); + var panel = UIManager.Instance?.GetPanel(); + if (panel != null) + { + yield return panel.FadeInAsync(RestoreFadeDuration); + } + } + + private static IEnumerator FadeOutForRestore() + { + AddRestoreLog("End restore fade out"); + var panel = UIManager.Instance?.GetPanel(); + if (panel != null) + { + yield return panel.FadeOutAsync(RestoreFadeDuration); + } + } + + private static void ResetRestoreLog(string sourceLabel) + { + _lastRestoreLog.Clear(); + AddRestoreLog($"Restore source: {sourceLabel}"); + } + + private static void AddRestoreLog(string message) + { + _lastRestoreLog.Add($"[{DateTime.Now:HH:mm:ss}] {message}"); + Debug.Log($"[SaveRestoreOrchestrator] {message}"); + } + + private sealed class AutoSaveSuppressScope : IDisposable + { + private readonly string _reason; + private bool _disposed; + + public AutoSaveSuppressScope(string reason) + { + _reason = reason; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _autoSaveSuppressDepth = Math.Max(0, _autoSaveSuppressDepth - 1); + Debug.Log( + $"[SaveRestoreOrchestrator] SuppressAutoSave end: {_reason}, depth={_autoSaveSuppressDepth}"); + } } } } diff --git a/Assets/Scripts/SaveSystem/SnapshotRestore.cs b/Assets/Scripts/SaveSystem/SnapshotRestore.cs index 40c83d92c..d1e012328 100644 --- a/Assets/Scripts/SaveSystem/SnapshotRestore.cs +++ b/Assets/Scripts/SaveSystem/SnapshotRestore.cs @@ -5,65 +5,73 @@ using UnityEngine; namespace AibisDream.SaveSystem { /// - /// 将 逐项写回运行时(纯内存,不读盘)。 - /// - /// 顺序:Yarn 变量 → 场景加载 → 设置章节 → 各 Provider(按 RestoreOrder)→ 锚点加载对话并重进节点。 - /// 完整淡入淡出时序由 P4 在 扩展。 - /// + /// 将 按 Phase + Barrier 写回运行时(纯内存,不读盘)。 /// public static class SnapshotRestore { /// - /// 还原 Yarn 变量、场景、章节与各 section;不包含锚点重进(由 或上层编排)。 + /// Phase 0~2:Yarn 变量、场景、章节与所有 Provider;不包含锚点重进。 /// - public static IEnumerator Restore(YarnVariableStorage storage, SaveSnapshot snapshot) + public static IEnumerator RestoreState( + YarnVariableStorage storage, + SaveSnapshot snapshot, + SnapshotRestoreContext context) { if (snapshot == null) { - Debug.LogError("[SnapshotRestore] snapshot 为 null"); + context?.Error("snapshot 为 null"); yield break; } + context ??= new SnapshotRestoreContext(snapshot); + + context.Log("Phase 0: Restore Yarn variables"); RestoreYarnVariables(storage, snapshot); - yield return RestoreScene(snapshot); - RestoreSceneSo(snapshot); - foreach (var provider in SnapshotRegistry.GetOrderedProviders()) - { - if (!snapshot.sections.TryGetValue(provider.SaveId, out var dto) || dto == null) - { - continue; - } + context.Log("Phase 1: Load scene"); + yield return RestoreScene(snapshot, context); - dto = CoerceSectionDto(provider.SaveId, dto); - yield return provider.Restore(dto); - } + context.Log("Phase 1.5: Restore scene SO"); + RestoreSceneSo(snapshot, context); + + context.Log("Phase 2: Restore providers (ordered by RestoreOrder)"); + yield return RestoreProvidersInOrder(snapshot, context); if (snapshot.deepRepair != null && snapshot.deepRepair.sections != null && snapshot.deepRepair.sections.Count > 0) { - Debug.LogWarning("[SnapshotRestore] DeepRepair 段尚未实现,已跳过(P5)。"); + context.Warn("DeepRepair 段尚未实现,已跳过(P5)。"); } } /// - /// 按 加载对话工程并重新进入 Yarn 节点,实现「所见即所存」。 - /// 调用前应已完成场景加载与各 provider 还原。 + /// Phase 3:按 加载对话工程并重新进入 Yarn 节点。 /// - public static IEnumerator RestoreAnchor(SaveSnapshot snapshot) + public static IEnumerator RestoreAnchor(SaveSnapshot snapshot, SnapshotRestoreContext context = null) { if (snapshot?.anchor == null || string.IsNullOrEmpty(snapshot.anchor.nodeName)) { + context?.Log("Phase 3: No anchor node, skip RestoreAnchor"); yield break; } + context ??= new SnapshotRestoreContext(snapshot); + context.Log($"Phase 3: Restore anchor {snapshot.anchor.nodeName}"); + var dialog = DialogController.Instance; - if (dialog == null) yield break; + if (dialog == null) + { + context.Warn("DialogController 未初始化,无法重进 Yarn 节点。"); + yield break; + } var runner = dialog.DialogueRunner; - if (runner == null) yield break; + if (runner == null) + { + context.Warn("DialogueRunner 未初始化,无法重进 Yarn 节点。"); + yield break; + } - // 若当前 Runner 未加载对应 YarnProject,则先加载 if (!string.IsNullOrEmpty(snapshot.anchor.yarnProjectId)) { var sceneSo = GameManager.Instance?.GetCurrentTalkSceneSo(); @@ -76,8 +84,8 @@ namespace AibisDream.SaveSystem } else if (runner.YarnProject != null && runner.YarnProject.name != snapshot.anchor.yarnProjectId) { - Debug.LogWarning( - $"[SnapshotRestore] YarnProject 不匹配:当前 {runner.YarnProject.name},存档 {snapshot.anchor.yarnProjectId}"); + context.Warn( + $"YarnProject 不匹配:当前 {runner.YarnProject.name},存档 {snapshot.anchor.yarnProjectId}"); } } @@ -92,45 +100,98 @@ namespace AibisDream.SaveSystem private static void RestoreYarnVariables(YarnVariableStorage storage, SaveSnapshot snapshot) { var vars = snapshot.yarnVariables; - if (vars == null) return; + if (vars == null || storage == null) return; storage.SetAllVariables(vars.floats, vars.strings, vars.bools); } - private static IEnumerator RestoreScene(SaveSnapshot snapshot) + private static IEnumerator RestoreScene(SaveSnapshot snapshot, SnapshotRestoreContext context) { if (snapshot.scene == null || string.IsNullOrEmpty(snapshot.scene.sceneName)) { + context.Warn("快照缺少 scene.sceneName,跳过场景加载。"); yield break; } var sceneLoader = SceneLoader.Instance; if (sceneLoader == null) { - Debug.LogError("[SnapshotRestore] SceneLoader 未初始化,无法加载场景"); + context.Error("SceneLoader 未初始化,无法加载场景。"); yield break; } yield return sceneLoader.LoadSceneAsync(snapshot.scene.sceneName); } - private static void RestoreSceneSo(SaveSnapshot snapshot) + private static void RestoreSceneSo(SaveSnapshot snapshot, SnapshotRestoreContext context) { if (snapshot.anchor == null || string.IsNullOrEmpty(snapshot.anchor.sceneSoName)) { + context.Warn("快照缺少 anchor.sceneSoName,跳过章节 SO 设置。"); return; } var gameManager = GameManager.Instance; if (gameManager == null) { - Debug.LogWarning("[SnapshotRestore] GameManager 未初始化,无法设置章节 SO"); + context.Warn("GameManager 未初始化,无法设置章节 SO。"); return; } gameManager.SetSceneSoByName(snapshot.anchor.sceneSoName); } + /// + /// 按 依次还原; + /// 同步 Provider 连续调用,异步 Provider 作为 Barrier 挂起,二者混排而非分两批。 + /// + private static IEnumerator RestoreProvidersInOrder(SaveSnapshot snapshot, SnapshotRestoreContext context) + { + foreach (var provider in SnapshotRegistry.GetOrderedProviders()) + { + if (!TryGetSectionDto(snapshot, provider, context, out var dto)) + { + continue; + } + + switch (provider) + { + case ISyncSnapshotProvider syncProvider: + context.Log($"Sync provider [{provider.RestoreOrder}]: {provider.SaveId}"); + syncProvider.Restore(dto, context); + break; + + case IAsyncSnapshotProvider asyncProvider: + context.Log($"Async provider [{provider.RestoreOrder}]: {provider.SaveId}"); + yield return asyncProvider.RestoreAsync(dto, context); + break; + + default: + context.Warn($"Provider {provider.SaveId} 未实现同步或异步还原契约,已跳过。"); + break; + } + } + } + + private static bool TryGetSectionDto( + SaveSnapshot snapshot, + ISnapshotProvider provider, + SnapshotRestoreContext context, + out object dto) + { + dto = null; + if (snapshot.sections == null + || !snapshot.sections.TryGetValue(provider.SaveId, out dto) + || dto == null) + { + context.Log($"Provider {provider.SaveId}: no section, skip"); + return false; + } + + dto = CoerceSectionDto(provider.SaveId, dto); + return true; + } + /// /// 读盘后 中的值常为 ; /// 按 SaveId 转回各 Provider 所需的强类型 DTO。 diff --git a/Assets/Scripts/SaveSystem/SnapshotService.cs b/Assets/Scripts/SaveSystem/SnapshotService.cs index 84f3f68da..ac2481616 100644 --- a/Assets/Scripts/SaveSystem/SnapshotService.cs +++ b/Assets/Scripts/SaveSystem/SnapshotService.cs @@ -17,9 +17,9 @@ namespace AibisDream.SaveSystem } /// - /// 还原快照。默认在 provider 还原后继续执行 。 + /// 还原快照状态;默认继续执行 。 + /// 业务读档流程应优先使用 ,以获得黑屏与自动存档抑制。 /// - /// 为 false 时仅还原状态,不重进 Yarn 节点(供 P4 编排使用)。 public static IEnumerator Restore(SaveSnapshot snapshot, bool restoreAnchor = true) { if (snapshot == null) @@ -28,11 +28,12 @@ namespace AibisDream.SaveSystem } SnapshotRegistry.EnsureInitialized(); - yield return SnapshotRestore.Restore(YarnVariableStorage.Instance, snapshot); + var context = new SnapshotRestoreContext(snapshot); + yield return SnapshotRestore.RestoreState(YarnVariableStorage.Instance, snapshot, context); if (restoreAnchor) { - yield return SnapshotRestore.RestoreAnchor(snapshot); + yield return SnapshotRestore.RestoreAnchor(snapshot, context); } } }