diff --git a/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs b/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs index 902ecaae7..f2f5eb7d7 100644 --- a/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs +++ b/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs @@ -567,7 +567,7 @@ namespace AibisDream.SaveSystem.Editor return; } - GameManager.Instance.StartWithSlot(slotIndex); + GameManager.Instance.TryRestoreSlot(slotIndex); Log($"已启动读档 slot_{slotIndex}。"); } @@ -585,7 +585,9 @@ namespace AibisDream.SaveSystem.Editor return; } - GameManager.Instance.StartWithSaveFile(entry.SnapshotPath); + GameManager.Instance.TryRestoreFile( + entry.SnapshotPath, + RestoreOptions.DevJump); Log($"已启动读档: {entry.EntryId}"); } diff --git a/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs b/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs index 25c021ef7..b635dd98d 100644 --- a/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs +++ b/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs @@ -332,7 +332,7 @@ namespace AibisDream.SaveSystem.Editor { EditorGUILayout.LabelField("读档验证 (P4)", EditorStyles.boldLabel); EditorGUILayout.HelpBox( - "触发 SaveRestoreOrchestrator.RestoreFromSlot,验证 Phase + Barrier 编排、自动存档抑制与 Provider 还原日志。\n" + + "触发 GameManager.TryRestoreSlot,验证预检、Phase + Barrier 编排、自动存档抑制与 Provider 还原日志。\n" + "需要在 Play Mode 下执行;不代表正式玩家 UI。", MessageType.None); @@ -559,7 +559,7 @@ namespace AibisDream.SaveSystem.Editor return; } - if (GameManager.Instance != null && GameManager.Instance.state.isInPause) + if (GameManager.Instance != null && GameManager.Session.IsPaused) { Log("全局门控: GamePaused"); return; @@ -631,7 +631,7 @@ namespace AibisDream.SaveSystem.Editor return; } - GameManager.Instance.StartWithSlot(slotIndex); + GameManager.Instance.TryRestoreSlot(slotIndex); Log($"已启动 Restore slot_{slotIndex}。请观察 Restore Pipeline Log 与场景状态。"); } diff --git a/Assets/Scenes/Persistence.unity b/Assets/Scenes/Persistence.unity index f6a8e81dc..6e8778551 100644 --- a/Assets/Scenes/Persistence.unity +++ b/Assets/Scenes/Persistence.unity @@ -3253,6 +3253,8 @@ GameObject: - component: {fileID: 719228339} - component: {fileID: 719228337} - component: {fileID: 719228340} + - component: {fileID: 719228341} + - component: {fileID: 719228342} m_Layer: 0 m_Name: Game Manager m_TagString: Untagged @@ -3301,9 +3303,30 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: firstTalkSo: {fileID: 11400000, guid: 2f763a2c04ecafb4981379b7363f9791, type: 2} - additionalRestoreSceneSos: - - {fileID: 11400000, guid: aaef579ad8bdda542b503b40ce6773a8, type: 2} - - {fileID: 11400000, guid: 0bdbd8e46fa688e49a5a670b78b6f956, type: 2} +--- !u!114 &719228341 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 719228336} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 15e947a337c0448ca777e71852e5bb3c, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!114 &719228342 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 719228336} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2a2aa6c1f5b143acb0fc561b2af40e36, type: 3} + m_Name: + m_EditorClassIdentifier: --- !u!1 &749156594 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/Dialog System/BaseYarnCommand.cs b/Assets/Scripts/Dialog System/BaseYarnCommand.cs index ba9fca2ba..d9108b877 100644 --- a/Assets/Scripts/Dialog System/BaseYarnCommand.cs +++ b/Assets/Scripts/Dialog System/BaseYarnCommand.cs @@ -14,9 +14,13 @@ namespace AibisDream [YarnCommand("NextYarn")] public static void NextYarn(string exitName = null) { + if (!GameManager.Instance.TryAdvanceChapterFromYarn(exitName)) + { + return; + } + EnumEventSystem.Global.Send(EventEnum.NextYarn); YarnVariableStorage.Instance.ClearLocal(); - DialogController.Instance.StartCoroutine(GameManager.Instance.NextSceneSo(exitName)); } [YarnCommand("OpenEndMenu")] @@ -28,14 +32,13 @@ namespace AibisDream [YarnCommand("load_scene")] public static IEnumerator LoadScene(string sceneName, float _unusedFadeDuration = 0f) { - HideDialog(); - yield return SceneLoader.Instance.LoadSceneAsync(sceneName); + yield return GameManager.Instance.LoadSceneFromYarn(sceneName); } [YarnCommand("unload_scene")] public static IEnumerator UnloadScene() { - yield return SceneLoader.Instance.UnloadSceneAsync(); + yield return GameManager.Instance.UnloadSceneFromYarn(); } [YarnCommand("hide_dialog")] diff --git a/Assets/Scripts/Dialog System/DialogController.cs b/Assets/Scripts/Dialog System/DialogController.cs index 945090d2b..827ce4e14 100644 --- a/Assets/Scripts/Dialog System/DialogController.cs +++ b/Assets/Scripts/Dialog System/DialogController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using AibisDream.Kit; using AibisDream.SaveSystem; @@ -11,6 +12,8 @@ namespace AibisDream { public class DialogController : Singleton { + public event Action DialogueActivityChanged; + private DialogueRunner _dialogueRunner; public DialogueRunner DialogueRunner => _dialogueRunner; @@ -30,11 +33,13 @@ namespace AibisDream _dialogueRunner.onDialogueStart.AddListener(() => { + DialogueActivityChanged?.Invoke(true); EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart); }); _dialogueRunner.onDialogueComplete.AddListener(() => { + DialogueActivityChanged?.Invoke(false); EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd); ClearCurrentNodeContext(); }); @@ -47,8 +52,8 @@ namespace AibisDream { SingleCastEventSystem.Global.Register(DialogEventEnum.StartNode, StartDialogNode); - EnumEventSystem.Global.Register(GameLoopEnum.GameStart, ResetAdvanceMode); - EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, ResetAdvanceMode); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionStarted, ResetAdvanceMode); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, ResetAdvanceMode); EnumEventSystem.Global.Register(DialogEventEnum.OptionShow, OnOptionShow); EnumEventSystem.Global.Register(DialogEventEnum.OptionSelected, OnOptionSelected); @@ -58,8 +63,8 @@ namespace AibisDream { SingleCastEventSystem.Global.Unregister(DialogEventEnum.StartNode); - EnumEventSystem.Global.UnRegister(GameLoopEnum.GameStart, ResetAdvanceMode); - EnumEventSystem.Global.UnRegister(GameLoopEnum.GameQuit, ResetAdvanceMode); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionStarted, ResetAdvanceMode); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, ResetAdvanceMode); EnumEventSystem.Global.UnRegister(DialogEventEnum.OptionShow, OnOptionShow); EnumEventSystem.Global.UnRegister(DialogEventEnum.OptionSelected, OnOptionSelected); @@ -94,10 +99,20 @@ namespace AibisDream public void StopDialog() { - _ = _dialogueRunner.Stop(); + StartCoroutine(StopDialogRoutine()); + } + + public IEnumerator StopDialogRoutine() + { + if (_dialogueRunner != null && _dialogueRunner.IsDialogueRunning) + { + yield return _dialogueRunner.Stop(); + } + SavePointEvaluator.ResetForProject(null); YarnVariableStorage.Instance.Clear(); DialogUIManager.Instance.HideDialog(); + DialogueActivityChanged?.Invoke(false); } public void LoadDialog(YarnProject yarnProject) @@ -314,4 +329,4 @@ namespace AibisDream Auto, Quick } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Dialog System/LineAdvanceInput.cs b/Assets/Scripts/Dialog System/LineAdvanceInput.cs index a0af9ee8d..2e8a9a084 100644 --- a/Assets/Scripts/Dialog System/LineAdvanceInput.cs +++ b/Assets/Scripts/Dialog System/LineAdvanceInput.cs @@ -72,7 +72,7 @@ namespace AibisDream private bool IsInputAvailable() { - return GameManager.Instance.state.IsInputAvailable() + return GameManager.Session.CanAdvanceDialogue && _curSyncToken != null && _curSyncToken.state != LineSyncState.Advanced; } diff --git a/Assets/Scripts/Dialog System/OptionExploreTracker.cs b/Assets/Scripts/Dialog System/OptionExploreTracker.cs index bd54d0ee7..5ac86e81b 100644 --- a/Assets/Scripts/Dialog System/OptionExploreTracker.cs +++ b/Assets/Scripts/Dialog System/OptionExploreTracker.cs @@ -27,8 +27,8 @@ namespace AibisDream EnumEventSystem.Global.Register(StorageEvent.VariableSet, OnVariableSet); EnumEventSystem.Global.Register(DialogEventEnum.OptionSelected, OnOptionSelected); EnumEventSystem.Global.Register(DialogEventEnum.OptionShow, OnOptionShow); - EnumEventSystem.Global.Register(GameLoopEnum.GameStart, Clear); - EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, Clear); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionStarted, Clear); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, Clear); EnumEventSystem.Global.Register(EventEnum.NextYarn, Clear); } @@ -37,8 +37,8 @@ namespace AibisDream EnumEventSystem.Global.UnRegister(StorageEvent.VariableSet, OnVariableSet); EnumEventSystem.Global.UnRegister(DialogEventEnum.OptionSelected, OnOptionSelected); EnumEventSystem.Global.UnRegister(DialogEventEnum.OptionShow, OnOptionShow); - EnumEventSystem.Global.UnRegister(GameLoopEnum.GameStart, Clear); - EnumEventSystem.Global.UnRegister(GameLoopEnum.GameQuit, Clear); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionStarted, Clear); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, Clear); EnumEventSystem.Global.UnRegister(EventEnum.NextYarn, Clear); } diff --git a/Assets/Scripts/Framework/AudioKit/AudioManager.cs b/Assets/Scripts/Framework/AudioKit/AudioManager.cs index 9bfca83ed..ea8e4cacb 100644 --- a/Assets/Scripts/Framework/AudioKit/AudioManager.cs +++ b/Assets/Scripts/Framework/AudioKit/AudioManager.cs @@ -74,9 +74,11 @@ namespace AibisDream.Kit _removeTrigger.Add(EnumEventSystem.Global.Register(EventEnum.SceneLoad, OnSceneLoad)); _removeTrigger.Add(EnumEventSystem.Global.Register(EventEnum.FixSceneModeChanged, OnFixSceneModeChange)); // 主菜单 / 退出局内 - _removeTrigger.Add(EnumEventSystem.Global.Register(GameLoopEnum.AppStart, OnAppStart)); - _removeTrigger.Add(EnumEventSystem.Global.Register(GameLoopEnum.GameStart, OnGameStart)); - _removeTrigger.Add(EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, OnGameQuit)); + _removeTrigger.Add(EnumEventSystem.Global.Register(GameLifecycleEvent.ApplicationReady, OnAppStart)); + _removeTrigger.Add(EnumEventSystem.Global.Register(GameLifecycleEvent.SessionStarted, OnGameStart)); + _removeTrigger.Add(EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, OnGameQuit)); + _removeTrigger.Add(EnumEventSystem.Global.Register(GameLifecycleEvent.SessionPaused, PauseAll)); + _removeTrigger.Add(EnumEventSystem.Global.Register(GameLifecycleEvent.SessionResumed, UnPauseAll)); _removeTrigger.Add(EnumEventSystem.Global.Register( TerminalUIEvent.MainMenuPhase, OnMainMenuPhase)); _removeTrigger.Add(EnumEventSystem.Global.Register(TerminalUIEvent.PauseMenuAmbEnter, OnPauseMenuAmbEnter)); diff --git a/Assets/Scripts/Framework/EventSystemKit/EventSystemEx.cs b/Assets/Scripts/Framework/EventSystemKit/EventSystemEx.cs index c965ccb2b..baf2de0a5 100644 --- a/Assets/Scripts/Framework/EventSystemKit/EventSystemEx.cs +++ b/Assets/Scripts/Framework/EventSystemKit/EventSystemEx.cs @@ -108,8 +108,8 @@ namespace AibisDream.Framework draggingObjList = new List(); holdingObjList = new List(); - EnumEventSystem.Global.Register(GameLoopEnum.GameStart, ClearAll); - EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, ClearAll); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionStarted, ClearAll); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, ClearAll); EnumEventSystem.Global.Register(InteractionEventEnum.DialogEnd, HandleDialogueComplete); EnumEventSystem.Global.Register(InteractionEventEnum.DialogStart, HandleDialogueStart); @@ -124,8 +124,8 @@ namespace AibisDream.Framework public override void OnSingletonDestroy() { - EnumEventSystem.Global.UnRegister(GameLoopEnum.GameStart, ClearAll); - EnumEventSystem.Global.UnRegister(GameLoopEnum.GameQuit, ClearAll); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionStarted, ClearAll); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, ClearAll); EnumEventSystem.Global.UnRegister(InteractionEventEnum.DialogEnd, HandleDialogueComplete); EnumEventSystem.Global.UnRegister(InteractionEventEnum.DialogStart, HandleDialogueStart); @@ -140,4 +140,4 @@ namespace AibisDream.Framework Dragging, PointerOver } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Framework/OtherKit/CameraAspectAdapter.cs b/Assets/Scripts/Framework/OtherKit/CameraAspectAdapter.cs index cbd85841d..fbf57bfee 100644 --- a/Assets/Scripts/Framework/OtherKit/CameraAspectAdapter.cs +++ b/Assets/Scripts/Framework/OtherKit/CameraAspectAdapter.cs @@ -29,7 +29,7 @@ namespace AibisDream.Framework private void OnEnable() { _resolutionChangeUnRegister = EnumEventSystem.Global.Register( - GameLoopEnum.ScreenResolutionChanged, OnResolutionChanged); + SettingChangeEvent.DisplayChanged, OnResolutionChanged); } private void OnDisable() diff --git a/Assets/Scripts/Game Loop/ApplicationBootstrapper.cs b/Assets/Scripts/Game Loop/ApplicationBootstrapper.cs new file mode 100644 index 000000000..c04c5848a --- /dev/null +++ b/Assets/Scripts/Game Loop/ApplicationBootstrapper.cs @@ -0,0 +1,35 @@ +using System.Collections; +using AibisDream.Framework; +using AibisDream.SaveSystem; +using AibisDream.UI; +using UnityEngine; + +namespace AibisDream +{ + /// 负责 Persistence 场景中的应用级初始化,不参与游戏会话流程。 + [DefaultExecutionOrder(-100)] + public sealed class ApplicationBootstrapper : MonoBehaviour + { + private const float MainMenuFadeDuration = 0.5f; + + private IEnumerator Start() + { + SettingLoader.Init(); + SnapshotRegistry.EnsureInitialized(); + + var transitionPanel = UIManager.Instance.GetPanel(); + UIManager.Instance.ShowPanel(); + transitionPanel?.ApplyScreenCovered(true); + + yield return LocalizationKit.WaitUntilReady(); + + EnumEventSystem.Global.Send(GameLifecycleEvent.ApplicationReady); + + yield return LocalizationKit.WaitForUIRefresh(); + if (transitionPanel != null) + { + yield return transitionPanel.FadeOutAsync(MainMenuFadeDuration); + } + } + } +} diff --git a/Assets/Scripts/Game Loop/ApplicationBootstrapper.cs.meta b/Assets/Scripts/Game Loop/ApplicationBootstrapper.cs.meta new file mode 100644 index 000000000..d218207a8 --- /dev/null +++ b/Assets/Scripts/Game Loop/ApplicationBootstrapper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 15e947a337c0448ca777e71852e5bb3c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: -100 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Game Loop/ChapterController.cs b/Assets/Scripts/Game Loop/ChapterController.cs index b63355af8..f776385a9 100644 --- a/Assets/Scripts/Game Loop/ChapterController.cs +++ b/Assets/Scripts/Game Loop/ChapterController.cs @@ -25,7 +25,7 @@ namespace AibisDream cacheChapter = sceneSoName; } - var sourceList = GameManager.Instance.GetAllSceneSos(); + var sourceList = GameManager.Instance.RuntimeChapters; // 拼接章节列表 var chapterList = sourceList @@ -47,10 +47,10 @@ namespace AibisDream public void StartWithChapter(ChapterVo chapterVo) { - GameManager.Instance.StartWithLevel(chapterVo.chapterSo); + GameManager.Instance.TryStartChapter(chapterVo.chapterSo); } - public void StartWithSaveFile() + public void StartWithLatestSave() { var slotIndex = SlotManager.GetLatestSlotIndex() ?? SlotIndex.Auto; if (!SlotDirectory.Exists(slotIndex)) @@ -59,7 +59,7 @@ namespace AibisDream return; } - GameManager.Instance.StartWithSlot(slotIndex); + GameManager.Instance.TryRestoreSlot(slotIndex); } public void UnlockChapter(TalkSceneSO chapterSo) @@ -90,9 +90,16 @@ namespace AibisDream private void LoadUnlockChapters() { - unlockedChapterList = File.Exists(ConstRef.ChapterProgressPath) - ? JsonUtil.ReadBeanArray(ConstRef.ChapterProgressPath).ToList() - : new List { GameManager.Instance.firstTalkSo.name }; + if (File.Exists(ConstRef.ChapterProgressPath)) + { + unlockedChapterList = JsonUtil.ReadBeanArray(ConstRef.ChapterProgressPath).ToList(); + return; + } + + var firstChapter = GameManager.Instance.RuntimeChapters.FirstOrDefault(); + unlockedChapterList = firstChapter != null + ? new List { firstChapter.name } + : new List(); } public void SaveUnloadChapters() @@ -130,4 +137,4 @@ namespace AibisDream public bool hasSaveFile; public TalkSceneSO chapterSo; } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Game Loop/DemoTimer.cs b/Assets/Scripts/Game Loop/DemoTimer.cs index fd769cfc5..088d38195 100644 --- a/Assets/Scripts/Game Loop/DemoTimer.cs +++ b/Assets/Scripts/Game Loop/DemoTimer.cs @@ -16,8 +16,8 @@ namespace AibisDream { _timerText = GetComponent(); // 注册事件 - EnumEventSystem.Global.Register(GameLoopEnum.GameStart, TimerStart); - EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, TimerStop); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionStarted, TimerStart); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, TimerStop); } private void TimerStart() @@ -71,4 +71,4 @@ namespace AibisDream UIManager.Instance.ShowPanel(); } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Game Loop/DisplaySettingsController.cs b/Assets/Scripts/Game Loop/DisplaySettingsController.cs new file mode 100644 index 000000000..33c055802 --- /dev/null +++ b/Assets/Scripts/Game Loop/DisplaySettingsController.cs @@ -0,0 +1,71 @@ +using System.Collections; +using AibisDream.Framework; +using AibisDream.Kit; +using UnityEngine; + +namespace AibisDream +{ + /// 应用窗口模式并在实际分辨率稳定后发送显示变化通知。 + public sealed class DisplaySettingsController : Singleton + { + private Coroutine _applyRoutine; + + public void ApplyWindowMode(string windowMode) + { + if (!TryResolveMode(windowMode, out var width, out var height, out var mode)) + { + Debug.LogWarning($"[DisplaySettingsController] Unknown window mode: {windowMode}"); + return; + } + + Screen.SetResolution(width, height, mode); + + if (_applyRoutine != null) + { + StopCoroutine(_applyRoutine); + } + + _applyRoutine = StartCoroutine(WaitForResolution(width, height)); + } + + private IEnumerator WaitForResolution(int targetWidth, int targetHeight) + { + var deadline = Time.unscaledTime + 1f; + while ((Screen.width != targetWidth || Screen.height != targetHeight) + && Time.unscaledTime < deadline) + { + yield return null; + } + + _applyRoutine = null; + EnumEventSystem.Global.Send(SettingChangeEvent.DisplayChanged); + } + + private static bool TryResolveMode( + string windowMode, + out int width, + out int height, + out FullScreenMode mode) + { + switch (windowMode) + { + case "FullScreen": + case "MaximizedWindow": + width = Screen.currentResolution.width; + height = Screen.currentResolution.height; + mode = FullScreenMode.FullScreenWindow; + return true; + case "Windowed": + width = 1920; + height = 1080; + mode = FullScreenMode.Windowed; + return true; + default: + width = 0; + height = 0; + mode = FullScreenMode.Windowed; + return false; + } + } + } +} diff --git a/Assets/Scripts/Game Loop/DisplaySettingsController.cs.meta b/Assets/Scripts/Game Loop/DisplaySettingsController.cs.meta new file mode 100644 index 000000000..bce6ecbf9 --- /dev/null +++ b/Assets/Scripts/Game Loop/DisplaySettingsController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a2aa6c1f5b143acb0fc561b2af40e36 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Game Loop/EventEnums.cs b/Assets/Scripts/Game Loop/EventEnums.cs index 2946a9a8d..1ac1a1883 100644 --- a/Assets/Scripts/Game Loop/EventEnums.cs +++ b/Assets/Scripts/Game Loop/EventEnums.cs @@ -15,14 +15,16 @@ namespace AibisDream CutEnd } - public enum GameLoopEnum + public enum GameLifecycleEvent { - AppStart, - GameStart, - GameQuit, - PauseGame, - UnPauseGame, - ScreenResolutionChanged + ApplicationReady, + SessionStarted, + SessionReady, + SessionPaused, + SessionResumed, + SessionEnding, + SessionEnded, + SessionFailed } public enum DialogEventEnum @@ -83,7 +85,8 @@ namespace AibisDream public enum SettingChangeEvent { TextSpeed, - LocaleChanged + LocaleChanged, + DisplayChanged } public enum TerminalUIAudioEnum diff --git a/Assets/Scripts/Game Loop/GameManager.cs b/Assets/Scripts/Game Loop/GameManager.cs index e0c66dbfa..9127e1369 100644 --- a/Assets/Scripts/Game Loop/GameManager.cs +++ b/Assets/Scripts/Game Loop/GameManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using AibisDream.FixSystem; @@ -11,468 +11,751 @@ using UnityEngine.EventSystems; namespace AibisDream { - public class GameManager : Singleton, IGameManager + public sealed class SessionFailure { - #region 运行状态与章节数据 + public string Command { get; internal set; } + public string Stage { get; internal set; } + public IReadOnlyList Errors { get; internal set; } = Array.Empty(); + } - public GameLoopState state; + public class GameManager : Singleton + { + private enum SessionCommandKind + { + None, + Start, + Transition, + Restore, + ReturnToMenu + } - private List _totalSceneSos; + private const float TransitionDuration = 0.5f; + private const float DialogueHandoffTimeout = 5f; - [Header("生产环境数据(一般不动)")] public TalkSceneSO firstTalkSo; + [Header("生产环境数据(一般不动)")] + [SerializeField] private TalkSceneSO firstTalkSo; - [Header("存档恢复补充章节")] - [SerializeField] private List additionalRestoreSceneSos = new(); + private GameSession _session; + private TalkSceneGraphIndex _talkSceneIndex; + private SessionCommandKind _activeCommand; + private bool _returnToMenuRequested; - #endregion + public static GameSession Session => + Instance != null && Instance._session != null + ? Instance._session + : throw new InvalidOperationException( + "GameManager.Session accessed before GameManager initialization."); - private const float MainMenuFadeDuration = 0.5f; + public IReadOnlyList RuntimeChapters => _talkSceneIndex.RuntimeChapters; - private BindProperty _currentTalkSceneSo; public override void OnSingletonInit() { - LoadAllSceneSos(); + _session = new GameSession(); + _talkSceneIndex = new TalkSceneGraphIndex(firstTalkSo); + + foreach (var error in _talkSceneIndex.ValidationErrors) + { + Debug.LogError($"[GameManager] {error}"); + } } private void Start() { - // 管理游戏开始时各部分的初始化 - SettingLoader.Init(); - SnapshotRegistry.EnsureInitialized(); - // 场景So事件注册 - _currentTalkSceneSo = new BindProperty(); - - StartCoroutine(AppBootstrapCoroutine()); - } - - /// - /// 启动主界面:先黑屏,待本地化与 UI 就绪后再淡出,避免玩家看到初始化过程。 - /// - private IEnumerator AppBootstrapCoroutine() - { - var transitionPanel = UIManager.Instance.GetPanel(); - UIManager.Instance.ShowPanel(); - transitionPanel?.ApplyScreenCovered(true); - - yield return LocalizationKit.WaitUntilReady(); - - EnumEventSystem.Global.Send(GameLoopEnum.AppStart); - - yield return LocalizationKit.WaitForUIRefresh(); - - if (transitionPanel != null) + if (DialogController.Instance != null) { - yield return transitionPanel.FadeOutAsync(MainMenuFadeDuration); + DialogController.Instance.DialogueActivityChanged += OnDialogueActivityChanged; } } - private void LoadAllSceneSos() + public override void OnSingletonDestroy() { - _totalSceneSos = new List(); - var visited = new HashSet(); - var current = firstTalkSo; - - while (current != null && !visited.Contains(current)) + if (DialogController.Instance != null) { - _totalSceneSos.Add(current); - visited.Add(current); - current = current.GetNextScene(); + DialogController.Instance.DialogueActivityChanged -= OnDialogueActivityChanged; } } - #region 游戏循环 - - public void StartNewGame() + public bool TryStartNewGame() { - // 游戏开始 - EnumEventSystem.Global.Send(GameLoopEnum.GameStart); - // 重置屏幕效果 - ScreenEffectManager.Instance?.ResetSaturation(); - // 序列 - _currentTalkSceneSo.Value = firstTalkSo; - StartCoroutine(LoadFirstScene()); + return TryStartChapter(firstTalkSo); } - public void StartWithLevel(TalkSceneSO sceneSo) + public bool TryStartChapter(TalkSceneSO chapter) { - // 游戏开始 - EnumEventSystem.Global.Send(GameLoopEnum.GameStart); - // 重置屏幕效果 - ScreenEffectManager.Instance?.ResetSaturation(); - - _currentTalkSceneSo.Value = sceneSo; - StartCoroutine(LoadFirstScene()); - } - - public void StartWithSaveFile(string fileName) - { - // 游戏开始 - EnumEventSystem.Global.Send(GameLoopEnum.GameStart); - - StartCoroutine(LoadFromSaveFile(fileName)); - } - - public void StartWithSlot(int slotIndex) - { - // 游戏开始 - EnumEventSystem.Global.Send(GameLoopEnum.GameStart); - - StartCoroutine(LoadFromSlot(slotIndex)); - } - - private IEnumerator LoadFromSaveFile(string fileName) - { - yield return SaveRestoreOrchestrator.RestoreFromFile(fileName); - } - - private IEnumerator LoadFromSlot(int slotIndex) - { - yield return SaveRestoreOrchestrator.RestoreFromSlot(slotIndex); - } - - private IEnumerator LoadFirstScene() - { - ChapterController.Instance.UnlockChapter(_currentTalkSceneSo.Value); - yield return UIManager.Instance.GetPanel().FadeInAsync(0.5f); - yield return SceneLoader.Instance.LoadSceneAsync(_currentTalkSceneSo.Value.firstSceneName); - yield return WaitForSceneDialogueReady(); - DialogController.Instance.StartDialog(_currentTalkSceneSo.Value.yarnProject); - } - - /// - /// 等待新场景 Awake/Start 完成,并等待场景协调器(Fix / Subway / Outside 等)就绪后再开对话。 - /// - private static IEnumerator WaitForSceneDialogueReady() - { - yield return null; - - if (SceneHasDialogueGate()) + if (!ValidateChapter(chapter, out var error)) { - while (HasPendingDialogueGate()) - { - yield return null; - } + Debug.LogError($"[GameManager] Cannot start chapter: {error}"); + return false; } - else + + if (_session.Phase != GameSessionPhase.MainMenu + || !TryAcquireCommand(SessionCommandKind.Start)) { - yield return null; + return false; } + + StartCoroutine(StartChapterRoutine(chapter)); + return true; } - private static bool SceneHasDialogueGate() + public bool TryRestoreSlot(int slotIndex, Action completed = null) { - return FixSystemCenter.Instance != null - || SubwayCenter.Instance != null - || OutsideCenter.Instance != null; - } - - private static bool HasPendingDialogueGate() - { - if (FixSystemCenter.Instance is ISceneDialogueGate { IsDialogueReady: false }) + if (!CanBeginRestore() || !TryAcquireCommand(SessionCommandKind.Restore)) { + return false; + } + + StartCoroutine(RestoreRoutine( + () => SaveRestoreOrchestrator.PrepareRestoreFromSlot( + slotIndex, + _talkSceneIndex, + RestoreOptions.Default), + completed)); + return true; + } + + public bool TryRestoreFile( + string path, + RestoreOptions options, + Action completed = null) + { + if (!CanBeginRestore() || !TryAcquireCommand(SessionCommandKind.Restore)) + { + return false; + } + + StartCoroutine(RestoreRoutine( + () => SaveRestoreOrchestrator.PrepareRestoreFromFile( + path, + _talkSceneIndex, + options ?? RestoreOptions.Default), + completed)); + return true; + } + + public bool TryPause() + { + if (!_session.CanPause) + { + return false; + } + + _session.SetPaused(true); + Time.timeScale = 0f; + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionPaused); + return true; + } + + public bool TryResume() + { + if (!_session.CanResume) + { + return false; + } + + ResumeSessionInternal(sendEvent: true); + return true; + } + + public bool TryReturnToMainMenu() + { + if (_session.Phase is GameSessionPhase.MainMenu or GameSessionPhase.ReturningToMenu) + { + return false; + } + + if (_activeCommand != SessionCommandKind.None) + { + _returnToMenuRequested = true; return true; } - if (SubwayCenter.Instance is ISceneDialogueGate { IsDialogueReady: false }) + if (!TryAcquireCommand(SessionCommandKind.ReturnToMenu)) { - return true; + return false; } - if (OutsideCenter.Instance is ISceneDialogueGate { IsDialogueReady: false }) - { - return true; - } - - return false; + StartCoroutine(ReturnToMainMenuRoutine()); + return true; } - public void PauseGame() - { - // 系统暂停 - Time.timeScale = 0; - // 交互继续 - EnumEventSystem.Global.Send(GameLoopEnum.PauseGame); - // 音频全部暂停 - AudioManager.Instance.PauseAll(); - // 关闭Dialog - DialogUIManager.Instance.CloseCanvas(); - } - - public void ContinueGame() - { - // 系统继续 - Time.timeScale = 1; - // 交互继续 - EnumEventSystem.Global.Send(GameLoopEnum.UnPauseGame); - // 音频继续 - AudioManager.Instance.UnPauseAll(); - // 打开Dialog - DialogUIManager.Instance.OpenCanvas(); - } - - public void QuitGame() - { - // 系统继续 - Time.timeScale = 1; - // 重置屏幕效果 - ScreenEffectManager.Instance?.ResetSaturation(); - // 卸载当前场景 - StartCoroutine(RestartCoroutine()); - } - - /// - /// 可等待的完整游戏会话清理。开发跳转等编排流程必须等待它结束,避免场景卸载与读档并发。 - /// - public IEnumerator ResetGameSessionRoutine() - { - Time.timeScale = 1; - ScreenEffectManager.Instance?.ResetSaturation(); - yield return RestartCoroutine(); - } - - public void QuitApp() + public void QuitApplication() { Application.Quit(); } - private IEnumerator RestartCoroutine() + internal bool TryAdvanceChapterFromYarn(string exitName) { - var playTool = UIManager.Instance.GetPanel(); - var transitionPanel = UIManager.Instance.GetPanel(); - UIManager.Instance.ShowPanel(); - UIManager.Instance.ShowPanel(); - if (transitionPanel != null) + var target = _session.CurrentTalkScene?.GetNextScene(exitName); + if (!ValidateChapter(target, out var error)) { - yield return transitionPanel.FadeInAsync(MainMenuFadeDuration); - } - - // 清理所有系统注册 - if (FixSystemCenter.SystemDic != null) - { - FixSystemCenter.SystemDic.Clear(); - } - - playTool?.ClearPresentationEffects(); - - // 执行场景卸载等功能 - CameraKit.Instance.ResetCamera(); - yield return SceneLoader.Instance.UnloadSceneAsync(); - DialogController.Instance.StopDialog(); - - yield return LocalizationKit.WaitUntilReady(); - - EnumEventSystem.Global.Send(GameLoopEnum.GameQuit); - // 音频继续 - AudioManager.Instance.UnPauseAll(); - // 打开Dialog - DialogUIManager.Instance.OpenCanvas(); - // 关闭结束面板 - UIManager.Instance.HidePanel(); - - yield return LocalizationKit.WaitForUIRefresh(); - - if (transitionPanel != null) - { - yield return transitionPanel.FadeOutAsync(MainMenuFadeDuration); - } - - EventSystem.current?.SetSelectedGameObject(null); - } - - #endregion - - #region 分辨率与屏幕模式 - - public void SetScreenMode(string windowMode) - { - int targetWidth, targetHeight; - switch (windowMode) - { - case "FullScreen": - targetWidth = Screen.currentResolution.width; - targetHeight = Screen.currentResolution.height; - Screen.SetResolution(targetWidth, targetHeight, FullScreenMode.FullScreenWindow); - break; - case "Windowed": - targetWidth = 1920; - targetHeight = 1080; - Screen.SetResolution(targetWidth, targetHeight, FullScreenMode.Windowed); - break; - case "MaximizedWindow": - targetWidth = Screen.currentResolution.width; - targetHeight = Screen.currentResolution.height; - // Windows上没有MaximizedWindow模式,所以用FullScreenWindow代替 - Screen.SetResolution(targetWidth, targetHeight, FullScreenMode.FullScreenWindow); - break; - default: - return; - } - - StartCoroutine(WaitForResolutionAndUpdateViewport( - targetWidth, targetHeight)); - } - - private IEnumerator WaitForResolutionAndUpdateViewport( - int targetWidth, int targetHeight) - { - float timeout = Time.unscaledTime + 1f; - while ((Screen.width != targetWidth || Screen.height != targetHeight) - && Time.unscaledTime < timeout) - { - yield return null; - } - - EnumEventSystem.Global.Send(GameLoopEnum.ScreenResolutionChanged); - } - - #endregion - - public IEnumerator NextSceneSo(string exitName = null) - { - var next = _currentTalkSceneSo.Value?.GetNextScene(exitName); - if (next != null) - { - _currentTalkSceneSo.Value = next; - ChapterController.Instance.UnlockChapter(_currentTalkSceneSo.Value); - yield return UIManager.Instance.GetPanel().FadeInAsync(); - yield return SceneLoader.Instance.LoadSceneAsync(_currentTalkSceneSo.Value.firstSceneName); - yield return WaitForSceneDialogueReady(); - DialogController.Instance.StartDialog(_currentTalkSceneSo.Value.yarnProject); - } - else - { - Debug.Log($"没有下个场景了 (exitName: {exitName})"); - yield return null; - } - } - - public List GetAllSceneSos() - { - return _totalSceneSos; - } - - public bool SetSceneSoByName(string soName) - { - var sceneSo = FindSceneSoByName(soName); - - if (sceneSo == null) - { - Debug.LogError($"[GameManager] 找不到章节 SO:{soName}"); + Debug.LogError($"[GameManager] Invalid chapter exit '{exitName ?? "default"}': {error}"); return false; } - _currentTalkSceneSo.Value = sceneSo; + if (_session.Phase != GameSessionPhase.Playing + || !TryAcquireCommand(SessionCommandKind.Transition)) + { + Debug.LogWarning("[GameManager] Chapter transition rejected because another command is active."); + return false; + } + + StartCoroutine(AdvanceChapterCommandRoutine(target)); return true; } - /// 只查询章节资产,不改变当前章节;供严格读档预检使用。 - public TalkSceneSO FindSceneSoByName(string soName) + private IEnumerator AdvanceChapterCommandRoutine(TalkSceneSO target) { - var sceneSo = _totalSceneSos?.Find(so => so != null && so.name == soName); - return sceneSo ?? additionalRestoreSceneSos?.Find(so => so != null && so.name == soName); - } - - public void StartDialog() - { - DialogController.Instance.LoadDialog(_currentTalkSceneSo.Value.yarnProject); - } - - public TalkSceneSO GetCurrentTalkSceneSo() - { - return _currentTalkSceneSo?.Value; - } - - public string GetSceneSoName() - { - return _currentTalkSceneSo.Value.name; - } - - #region 事件处理 - - private IUnRegister[] _unRegisters; - - private void OnEnable() - { - RegisterEvents(); - } - - private void OnDisable() - { - UnRegisterEvents(); - } - - private void RegisterEvents() - { - _unRegisters = new IUnRegister[6]; - - _unRegisters[0] = EnumEventSystem.Global.Register(GameLoopEnum.GameStart, () => state.OnGame(true)); - _unRegisters[1] = EnumEventSystem.Global.Register(GameLoopEnum.PauseGame, () => state.OnPause(true)); - - _unRegisters[2] = EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, () => state.OnGame(false)); - _unRegisters[3] = EnumEventSystem.Global.Register(GameLoopEnum.UnPauseGame, () => state.OnPause(false)); - - _unRegisters[4] = EnumEventSystem.Global.Register(InteractionEventEnum.DialogStart, () => state.OnDialog(true)); - _unRegisters[5] = EnumEventSystem.Global.Register(InteractionEventEnum.DialogEnd, () => state.OnDialog(false)); - } - - private void UnRegisterEvents() - { - foreach (var unRegister in _unRegisters) + try { - unRegister.UnRegister(); + yield return TransitionChapterRoutine(target); + } + finally + { + FinishCommand(SessionCommandKind.Transition); } } - #endregion - } - - /// - /// GameManager应该包含什么功能? - /// 1. 游戏状态管理。开始/暂停/结束 - /// 2. 全局设置 即SettingLoader - /// 3. 场景管理 有SceneLoader类,暂无必要 - /// 4. 资源管理 似无必要 - /// 5. 时间管理 主要供暂停使用 - /// 6. 日志和调试 日志已有 - /// - public interface IGameManager - { - #region 游戏循环相关 - - public void StartNewGame(); - public void PauseGame(); - public void ContinueGame(); - public void QuitGame(); - public void QuitApp(); - - #endregion - } - - public struct GameLoopState - { - public bool isInGame; - public bool isInDialog; - public bool isInPause; - - public void OnGame(bool isInGame) + internal IEnumerator LoadSceneFromYarn(string sceneName) { - this.isInGame = isInGame; + if (_session.Phase != GameSessionPhase.Playing + || !TryAcquireCommand(SessionCommandKind.Transition)) + { + Debug.LogWarning("[GameManager] Yarn scene load rejected because another command is active."); + yield break; + } + + try + { + DialogUIManager.Instance?.HideDialog(); + + if (!TrySetPhase(GameSessionPhase.Transitioning)) + { + yield break; + } + + SceneOperationResult sceneResult = null; + yield return SceneLoader.Instance.LoadSceneAsync(sceneName, value => sceneResult = value); + if (!EnsureSceneSucceeded(sceneResult, "Yarn scene load")) + { + yield break; + } + + SceneReadinessResult readiness = null; + yield return SceneReadiness.WaitUntilReady( + SceneLoader.Instance.CurrentScene, + IsReturnRequested, + value => readiness = value); + if (!EnsureReadinessSucceeded(readiness, "Yarn scene readiness")) + { + yield break; + } + + if (_returnToMenuRequested) + { + yield break; + } + + TrySetPhase(GameSessionPhase.Playing); + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionReady); + } + finally + { + FinishCommand(SessionCommandKind.Transition); + } } - public void OnPause(bool isInPause) + internal IEnumerator UnloadSceneFromYarn() { - this.isInPause = isInPause; + if (_session.Phase != GameSessionPhase.Playing + || !TryAcquireCommand(SessionCommandKind.Transition)) + { + Debug.LogWarning("[GameManager] Yarn scene unload rejected because another command is active."); + yield break; + } + + try + { + if (!TrySetPhase(GameSessionPhase.Transitioning)) + { + yield break; + } + + SceneOperationResult result = null; + yield return SceneLoader.Instance.UnloadSceneAsync(value => result = value); + if (!EnsureSceneSucceeded(result, "Yarn scene unload")) + { + yield break; + } + + if (_returnToMenuRequested) + { + yield break; + } + + TrySetPhase(GameSessionPhase.Playing); + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionReady); + } + finally + { + FinishCommand(SessionCommandKind.Transition); + } } - public void OnDialog(bool isInDialog) + private IEnumerator StartChapterRoutine(TalkSceneSO chapter) { - this.isInDialog = isInDialog; + try + { + _session.ResetRuntimeState(); + if (!TrySetPhase(GameSessionPhase.Starting)) + { + yield break; + } + + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionStarted); + yield return FadeIn(); + + if (_returnToMenuRequested) + { + yield break; + } + + SceneOperationResult sceneResult = null; + yield return SceneLoader.Instance.LoadSceneAsync( + chapter.firstSceneName, + value => sceneResult = value); + if (!EnsureSceneSucceeded(sceneResult, "Start scene")) + { + yield break; + } + + SceneReadinessResult readiness = null; + yield return SceneReadiness.WaitUntilReady( + SceneLoader.Instance.CurrentScene, + IsReturnRequested, + value => readiness = value); + if (!EnsureReadinessSucceeded(readiness, "Start readiness")) + { + yield break; + } + + if (_returnToMenuRequested) + { + yield break; + } + + CommitChapter(chapter); + DialogController.Instance.StartDialog(chapter.yarnProject); + TrySetPhase(GameSessionPhase.Playing); + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionReady); + yield return FadeOut(); + } + finally + { + FinishCommand(SessionCommandKind.Start); + } } - public bool IsInputAvailable() + private IEnumerator TransitionChapterRoutine(TalkSceneSO target) { - return isInGame && isInDialog && !isInPause; + if (!TrySetPhase(GameSessionPhase.Transitioning)) + { + yield break; + } + + yield return FadeIn(); + if (_returnToMenuRequested) + { + yield break; + } + + var sourceDialogueCompleted = false; + yield return WaitForSourceDialogueCompletion( + value => sourceDialogueCompleted = value); + if (_returnToMenuRequested) + { + yield break; + } + + if (!sourceDialogueCompleted) + { + SendFailure( + "Transition", + "SourceDialogueCompletion", + new[] + { + $"Source dialogue did not complete within {DialogueHandoffTimeout:0.#} seconds. " + + "NextYarn must be the final executable command on its branch." + }); + _returnToMenuRequested = true; + yield break; + } + + SceneOperationResult sceneResult = null; + yield return SceneLoader.Instance.LoadSceneAsync( + target.firstSceneName, + value => sceneResult = value); + if (!EnsureSceneSucceeded(sceneResult, "Chapter scene")) + { + yield break; + } + + SceneReadinessResult readiness = null; + yield return SceneReadiness.WaitUntilReady( + SceneLoader.Instance.CurrentScene, + IsReturnRequested, + value => readiness = value); + if (!EnsureReadinessSucceeded(readiness, "Chapter readiness")) + { + yield break; + } + + if (_returnToMenuRequested) + { + yield break; + } + + CommitChapter(target); + DialogController.Instance.StartDialog(target.yarnProject); + TrySetPhase(GameSessionPhase.Playing); + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionReady); + yield return FadeOut(); + } + + private IEnumerator WaitForSourceDialogueCompletion(Action completed) + { + var runner = DialogController.Instance?.DialogueRunner; + if (runner == null) + { + completed?.Invoke(false); + yield break; + } + + var deadline = Time.unscaledTime + DialogueHandoffTimeout; + while (runner.IsDialogueRunning && Time.unscaledTime < deadline) + { + if (_returnToMenuRequested) + { + completed?.Invoke(false); + yield break; + } + + yield return null; + } + + completed?.Invoke(!runner.IsDialogueRunning); + } + + private IEnumerator RestoreRoutine( + Func prepare, + Action completed) + { + var originPhase = _session.Phase; + RestoreResult result = null; + + try + { + if (!TrySetPhase(GameSessionPhase.Restoring)) + { + yield break; + } + + var preparation = prepare(); + result = preparation.Result; + if (!preparation.Success) + { + SendFailure("Restore", result.FailedPhase, result.Errors); + TrySetPhase(originPhase); + yield break; + } + + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionStarted); + ResumeSessionInternal(sendEvent: _session.IsPaused); + yield return FadeIn(); + + if (_returnToMenuRequested) + { + result.Cancelled = true; + yield break; + } + + if (DialogController.Instance != null) + { + yield return DialogController.Instance.StopDialogRoutine(); + } + + if (_returnToMenuRequested) + { + result.Cancelled = true; + yield break; + } + + ResetPresentationEffects(); + yield return SaveRestoreOrchestrator.ExecutePreparedRestore( + preparation, + IsReturnRequested); + + result = preparation.Result; + if (result.Cancelled || _returnToMenuRequested) + { + _returnToMenuRequested = true; + yield break; + } + + if (!result.Success) + { + SendFailure("Restore", result.FailedPhase, result.Errors); + _returnToMenuRequested = true; + yield break; + } + + CommitChapter(result.RestoredTalkScene); + TrySetPhase(GameSessionPhase.Playing); + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionReady); + ScreenSnapshotHelper.ApplyDeferredFadeScreenIfNeeded(); + yield return FadeOut(); + } + finally + { + completed?.Invoke(result ?? RestoreResult.CreateFailure( + "Restore", + "Restore did not produce a result.")); + FinishCommand(SessionCommandKind.Restore); + } + } + + private IEnumerator ReturnToMainMenuRoutine() + { + try + { + _returnToMenuRequested = false; + if (!TrySetPhase(GameSessionPhase.ReturningToMenu)) + { + yield break; + } + + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionEnding); + ResumeSessionInternal(sendEvent: _session.IsPaused); + yield return FadeIn(); + + if (DialogController.Instance != null) + { + yield return DialogController.Instance.StopDialogRoutine(); + } + + SceneOperationResult unloadResult = null; + const int maxUnloadAttempts = 2; + for (var attempt = 1; attempt <= maxUnloadAttempts; attempt++) + { + yield return SceneLoader.Instance.UnloadSceneAsync(value => unloadResult = value); + if (unloadResult?.Success == true) + { + break; + } + + if (attempt < maxUnloadAttempts) + { + yield return null; + } + } + + if (unloadResult?.Success != true) + { + SendFailure( + "ReturnToMenu", + "UnloadScene", + new[] { unloadResult?.Error ?? "Scene unload did not return a result." }); + yield break; + } + + ResetPresentationEffects(); + UIManager.Instance.HidePanel(); + yield return LocalizationKit.WaitUntilReady(); + + _session.ResetRuntimeState(); + TrySetPhase(GameSessionPhase.MainMenu); + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionEnded); + + yield return LocalizationKit.WaitForUIRefresh(); + yield return FadeOut(); + EventSystem.current?.SetSelectedGameObject(null); + } + finally + { + FinishCommand(SessionCommandKind.ReturnToMenu); + } + } + + private bool CanBeginRestore() + { + return _activeCommand == SessionCommandKind.None + && _session.Phase is GameSessionPhase.MainMenu or GameSessionPhase.Playing; + } + + private bool TryAcquireCommand(SessionCommandKind command) + { + if (_activeCommand != SessionCommandKind.None) + { + return false; + } + + _activeCommand = command; + return true; + } + + private void FinishCommand(SessionCommandKind command) + { + if (_activeCommand == command) + { + _activeCommand = SessionCommandKind.None; + } + + if (!_returnToMenuRequested + || _activeCommand != SessionCommandKind.None + || _session.Phase is GameSessionPhase.MainMenu or GameSessionPhase.ReturningToMenu) + { + return; + } + + if (TryAcquireCommand(SessionCommandKind.ReturnToMenu)) + { + StartCoroutine(ReturnToMainMenuRoutine()); + } + } + + private bool TrySetPhase(GameSessionPhase phase) + { + if (_session.TryTransitionTo(phase, out var error)) + { + return true; + } + + Debug.LogError($"[GameManager] {error}"); + return false; + } + + private void CommitChapter(TalkSceneSO chapter) + { + _session.SetCurrentTalkScene(chapter); + ChapterController.Instance?.UnlockChapter(chapter); + } + + private bool EnsureSceneSucceeded(SceneOperationResult result, string stage) + { + if (result?.Success == true) + { + return true; + } + + SendFailure("Scene", stage, new[] { result?.Error ?? "Scene operation did not return a result." }); + _returnToMenuRequested = true; + return false; + } + + private bool EnsureReadinessSucceeded(SceneReadinessResult result, string stage) + { + if (result?.Success == true) + { + return true; + } + + if (result?.Cancelled == true) + { + _returnToMenuRequested = true; + return false; + } + + SendFailure("SceneReadiness", stage, new[] + { + result?.Error ?? "Scene readiness did not return a result." + }); + _returnToMenuRequested = true; + return false; + } + + private void SendFailure(string command, string stage, IReadOnlyList errors) + { + var failure = new SessionFailure + { + Command = command, + Stage = stage, + Errors = errors ?? Array.Empty() + }; + + Debug.LogError($"[GameManager] {command} failed at {stage}: {string.Join("; ", failure.Errors)}"); + EnumEventSystem.Global.Send( + GameLifecycleEvent.SessionFailed, + failure); + } + + private void ResumeSessionInternal(bool sendEvent) + { + var wasPaused = _session.IsPaused; + Time.timeScale = 1f; + _session.SetPaused(false); + if (sendEvent && wasPaused) + { + EnumEventSystem.Global.Send(GameLifecycleEvent.SessionResumed); + } + } + + private void ResetPresentationEffects() + { + ScreenEffectManager.Instance?.ResetSaturation(); + UIManager.Instance.GetPanel()?.ClearPresentationEffects(); + CameraKit.Instance?.ResetCamera(); + } + + private IEnumerator FadeIn() + { + var transitionPanel = UIManager.Instance.GetPanel(); + if (transitionPanel != null) + { + yield return transitionPanel.FadeInAsync(TransitionDuration, useUnscaledTime: true); + } + } + + private IEnumerator FadeOut() + { + var transitionPanel = UIManager.Instance.GetPanel(); + if (transitionPanel != null) + { + yield return transitionPanel.FadeOutAsync(TransitionDuration, useUnscaledTime: true); + } + } + + private void OnDialogueActivityChanged(bool active) + { + _session.SetDialogueActive(active); + } + + private bool IsReturnRequested() + { + return _returnToMenuRequested; + } + + private static bool ValidateChapter(TalkSceneSO chapter, out string error) + { + if (chapter == null) + { + error = "TalkSceneSO is null."; + return false; + } + + if (string.IsNullOrWhiteSpace(chapter.firstSceneName)) + { + error = $"TalkSceneSO {chapter.name} has an empty scene key."; + return false; + } + + if (chapter.yarnProject == null) + { + error = $"TalkSceneSO {chapter.name} has no YarnProject."; + return false; + } + + error = null; + return true; } } - } diff --git a/Assets/Scripts/Game Loop/GameSession.cs b/Assets/Scripts/Game Loop/GameSession.cs new file mode 100644 index 000000000..3ed716b6a --- /dev/null +++ b/Assets/Scripts/Game Loop/GameSession.cs @@ -0,0 +1,95 @@ +using System; + +namespace AibisDream +{ + public enum GameSessionPhase + { + MainMenu, + Starting, + Transitioning, + Restoring, + Playing, + ReturningToMenu + } + + /// + /// 单次游戏会话的纯状态模型。它不执行流程,也不发送全局事件。 + /// + public sealed class GameSession + { + public GameSessionPhase Phase { get; private set; } = GameSessionPhase.MainMenu; + public TalkSceneSO CurrentTalkScene { get; private set; } + public bool IsPaused { get; private set; } + public bool IsDialogueActive { get; private set; } + + public bool IsActive => Phase != GameSessionPhase.MainMenu; + + public bool IsBusy => Phase is GameSessionPhase.Starting + or GameSessionPhase.Transitioning + or GameSessionPhase.Restoring + or GameSessionPhase.ReturningToMenu; + + public bool CanPause => Phase == GameSessionPhase.Playing && !IsPaused; + public bool CanResume => Phase == GameSessionPhase.Playing && IsPaused; + public bool CanAdvanceDialogue => Phase == GameSessionPhase.Playing && IsDialogueActive && !IsPaused; + + internal bool TryTransitionTo(GameSessionPhase next, out string error) + { + if (Phase == next) + { + error = null; + return true; + } + + if (!IsTransitionAllowed(Phase, next)) + { + error = $"Illegal game session transition: {Phase} -> {next}"; + return false; + } + + Phase = next; + error = null; + return true; + } + + internal void SetCurrentTalkScene(TalkSceneSO scene) + { + CurrentTalkScene = scene; + } + + internal void SetPaused(bool paused) + { + IsPaused = paused; + } + + internal void SetDialogueActive(bool active) + { + IsDialogueActive = active; + } + + internal void ResetRuntimeState() + { + CurrentTalkScene = null; + IsPaused = false; + IsDialogueActive = false; + } + + private static bool IsTransitionAllowed(GameSessionPhase current, GameSessionPhase next) + { + return current switch + { + GameSessionPhase.MainMenu => next is GameSessionPhase.Starting or GameSessionPhase.Restoring, + GameSessionPhase.Starting => next is GameSessionPhase.Playing or GameSessionPhase.ReturningToMenu, + GameSessionPhase.Transitioning => next is GameSessionPhase.Playing or GameSessionPhase.ReturningToMenu, + GameSessionPhase.Restoring => next is GameSessionPhase.Playing + or GameSessionPhase.MainMenu + or GameSessionPhase.ReturningToMenu, + GameSessionPhase.Playing => next is GameSessionPhase.Transitioning + or GameSessionPhase.Restoring + or GameSessionPhase.ReturningToMenu, + GameSessionPhase.ReturningToMenu => next == GameSessionPhase.MainMenu, + _ => throw new ArgumentOutOfRangeException(nameof(current), current, null) + }; + } + } +} diff --git a/Assets/Scripts/Game Loop/GameSession.cs.meta b/Assets/Scripts/Game Loop/GameSession.cs.meta new file mode 100644 index 000000000..4f797199e --- /dev/null +++ b/Assets/Scripts/Game Loop/GameSession.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b05e66a9fd114f1c9c91ee4020123893 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Game Loop/SceneLoader.cs b/Assets/Scripts/Game Loop/SceneLoader.cs index b70253c2d..7351758ea 100644 --- a/Assets/Scripts/Game Loop/SceneLoader.cs +++ b/Assets/Scripts/Game Loop/SceneLoader.cs @@ -1,118 +1,211 @@ -using System.Collections; +using System; +using System.Collections; +using AibisDream.FixSystem; using AibisDream.Framework; using AibisDream.Kit; -using AibisDream.FixSystem; using AibisDream.UI; +using DG.Tweening; +using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; +using UnityEngine.ResourceManagement.ResourceProviders; using UnityEngine.SceneManagement; -using DG.Tweening; namespace AibisDream { - public class SceneLoader : Singleton + public sealed class SceneOperationResult { - #region 场景转换参数 - - private AsyncOperationHandle _loadHandle; - private string _sceneToLoad; - private bool _isLoading; - - private string _currentSceneName; - - public string CurrentSceneName => _currentSceneName; - public bool IsLoading => _isLoading; - - public override void OnSingletonInit() - { - } - - #endregion - - /// - /// 同步场景转换 - /// - /// 目标场景 - public void LoadScene(string targetScene) - { - StartCoroutine(LoadSceneAsync(targetScene)); - } - - /// - /// 异步场景转换 - /// - /// 目标场景 - /// - public IEnumerator LoadSceneAsync(string targetScene) - { - if (_isLoading) - { - yield return null; - } - else - { - // 场景转换参数 - _isLoading = true; - UIManager.Instance.ShowPreferredLoading(); - _sceneToLoad = targetScene; - - // 在卸载场景前清理所有系统 - if (!string.IsNullOrEmpty(_currentSceneName)) - { - DOTween.KillAll(); - - if (FixSystemCenter.SystemDic != null) - { - FixSystemCenter.SystemDic.Clear(); - } - - // 释放场景级资源(在卸载场景之前) - ResourceSystem.ReleaseSceneLoader(); - - yield return Addressables.UnloadSceneAsync(_loadHandle); - } - - // 显式创建 Scene Loader(在所有业务组件 Start 之前) - ResourceSystem.CreateSceneLoader(); - - _loadHandle = Addressables.LoadSceneAsync(_sceneToLoad, LoadSceneMode.Additive); - while (!_loadHandle.IsDone) - { - UIManager.Instance.SetPreferredLoadingProgress(_loadHandle.PercentComplete); - yield return null; - } - UIManager.Instance.SetPreferredLoadingProgress(1f); - - // 场景加载完成,通知各系统 - EnumEventSystem.Global.Send(EventEnum.SceneLoad, _sceneToLoad); - _currentSceneName = _sceneToLoad; - UIManager.Instance.HidePreferredLoading(); - _isLoading = false; - } - } - - public IEnumerator UnloadSceneAsync() - { - if (_isLoading) - { - yield return null; - } - else - { - _isLoading = true; - - if (_loadHandle.IsValid()) - { - DOTween.KillAll(); - ResourceSystem.ReleaseSceneLoader(); - - yield return Addressables.UnloadSceneAsync(_loadHandle); - _currentSceneName = null; - } - - _isLoading = false; - } - } + public bool Success { get; internal set; } + public string SceneName { get; internal set; } + public string Error { get; internal set; } } + public class SceneLoader : Singleton + { + private AsyncOperationHandle _loadHandle; + private string _currentSceneName; + private Scene _currentScene; + private bool _isLoading; + + public string CurrentSceneName => _currentSceneName; + public Scene CurrentScene => _currentScene; + public bool IsLoading => _isLoading; + + public IEnumerator LoadSceneAsync( + string targetScene, + Action completed = null) + { + if (_isLoading) + { + completed?.Invoke(Failure(targetScene, "SceneLoader is busy.")); + yield break; + } + + if (string.IsNullOrWhiteSpace(targetScene)) + { + completed?.Invoke(Failure(targetScene, "Scene key is empty.")); + yield break; + } + + _isLoading = true; + SceneOperationResult result = null; + UIManager.Instance?.ShowPreferredLoading(); + + try + { + if (_loadHandle.IsValid()) + { + SceneOperationResult unloadResult = null; + yield return UnloadCurrentSceneCore(value => unloadResult = value); + if (unloadResult?.Success != true) + { + result = unloadResult ?? Failure(_currentSceneName, "Current scene unload did not return a result."); + yield break; + } + } + + ResourceSystem.CreateSceneLoader(); + + AsyncOperationHandle pendingHandle; + try + { + pendingHandle = Addressables.LoadSceneAsync(targetScene, LoadSceneMode.Additive); + } + catch (Exception ex) + { + ResourceSystem.ReleaseSceneLoader(); + result = Failure(targetScene, ex.Message); + yield break; + } + + while (!pendingHandle.IsDone) + { + UIManager.Instance?.SetPreferredLoadingProgress(pendingHandle.PercentComplete); + yield return null; + } + + UIManager.Instance?.SetPreferredLoadingProgress(1f); + + if (pendingHandle.Status != AsyncOperationStatus.Succeeded) + { + var error = pendingHandle.OperationException?.Message ?? "Addressables scene load failed."; + if (pendingHandle.IsValid()) + { + Addressables.Release(pendingHandle); + } + + ResourceSystem.ReleaseSceneLoader(); + result = Failure(targetScene, error); + yield break; + } + + var loadedScene = pendingHandle.Result.Scene; + if (!loadedScene.IsValid() || !loadedScene.isLoaded) + { + if (pendingHandle.IsValid()) + { + yield return Addressables.UnloadSceneAsync(pendingHandle); + } + + ResourceSystem.ReleaseSceneLoader(); + result = Failure(targetScene, "Addressables returned an invalid SceneInstance."); + yield break; + } + + _loadHandle = pendingHandle; + _currentSceneName = targetScene; + _currentScene = loadedScene; + + EnumEventSystem.Global.Send(EventEnum.SceneLoad, targetScene); + result = Success(targetScene); + } + finally + { + _isLoading = false; + UIManager.Instance?.HidePreferredLoading(); + completed?.Invoke(result ?? Failure(targetScene, "Scene load was interrupted.")); + } + } + + public IEnumerator UnloadSceneAsync(Action completed = null) + { + if (_isLoading) + { + completed?.Invoke(Failure(_currentSceneName, "SceneLoader is busy.")); + yield break; + } + + _isLoading = true; + SceneOperationResult result = null; + try + { + yield return UnloadCurrentSceneCore(value => result = value); + } + finally + { + _isLoading = false; + completed?.Invoke(result ?? Failure(_currentSceneName, "Scene unload was interrupted.")); + } + } + + private IEnumerator UnloadCurrentSceneCore(Action completed) + { + if (!_loadHandle.IsValid()) + { + _currentSceneName = null; + _currentScene = default; + completed?.Invoke(Success(null)); + yield break; + } + + DOTween.KillAll(); + FixSystemCenter.SystemDic?.Clear(); + ResourceSystem.ReleaseSceneLoader(); + + AsyncOperationHandle unloadHandle; + try + { + unloadHandle = Addressables.UnloadSceneAsync(_loadHandle); + } + catch (Exception ex) + { + completed?.Invoke(Failure(_currentSceneName, ex.Message)); + yield break; + } + + yield return unloadHandle; + if (unloadHandle.Status != AsyncOperationStatus.Succeeded) + { + completed?.Invoke(Failure( + _currentSceneName, + unloadHandle.OperationException?.Message ?? "Addressables scene unload failed.")); + yield break; + } + + var unloadedSceneName = _currentSceneName; + _loadHandle = default; + _currentSceneName = null; + _currentScene = default; + completed?.Invoke(Success(unloadedSceneName)); + } + + private static SceneOperationResult Success(string sceneName) + { + return new SceneOperationResult + { + Success = true, + SceneName = sceneName + }; + } + + private static SceneOperationResult Failure(string sceneName, string error) + { + return new SceneOperationResult + { + Success = false, + SceneName = sceneName, + Error = error + }; + } + } } diff --git a/Assets/Scripts/Game Loop/SettingLoader.cs b/Assets/Scripts/Game Loop/SettingLoader.cs index 06b3ff6c5..d46a15b2e 100644 --- a/Assets/Scripts/Game Loop/SettingLoader.cs +++ b/Assets/Scripts/Game Loop/SettingLoader.cs @@ -74,7 +74,7 @@ namespace AibisDream case "WindowMode": // 屏幕模式 #if !UNITY_ANDROID && !UNITY_IOS - GameManager.Instance.SetScreenMode(value); + DisplaySettingsController.Instance.ApplyWindowMode(value); #endif break; case "Language": @@ -125,4 +125,4 @@ namespace AibisDream #endregion } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Game Loop/TalkSceneGraphIndex.cs b/Assets/Scripts/Game Loop/TalkSceneGraphIndex.cs new file mode 100644 index 000000000..8eef40fed --- /dev/null +++ b/Assets/Scripts/Game Loop/TalkSceneGraphIndex.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; + +namespace AibisDream +{ + /// + /// 从现有 TalkSceneSO 图派生的运行时索引,不引入第二份章节创作数据。 + /// + public sealed class TalkSceneGraphIndex + { + private readonly List _runtimeChapters = new(); + private readonly Dictionary _byName = new(StringComparer.Ordinal); + private readonly HashSet _ambiguousNames = new(StringComparer.Ordinal); + private readonly List _validationErrors = new(); + + public IReadOnlyList RuntimeChapters => _runtimeChapters; + public IReadOnlyList ValidationErrors => _validationErrors; + + public TalkSceneGraphIndex(TalkSceneSO firstTalkScene) + { + var visited = new HashSet(); + VisitRuntime(firstTalkScene, visited); + } + + public bool TryFindByName(string sceneName, out TalkSceneSO scene, out string error) + { + scene = null; + if (string.IsNullOrWhiteSpace(sceneName)) + { + error = "TalkSceneSO name is empty."; + return false; + } + + if (_ambiguousNames.Contains(sceneName)) + { + error = $"TalkSceneSO name is ambiguous: {sceneName}."; + return false; + } + + if (!_byName.TryGetValue(sceneName, out scene) || scene == null) + { + error = $"TalkSceneSO does not exist: {sceneName}."; + return false; + } + + error = null; + return true; + } + + private void VisitRuntime(TalkSceneSO scene, ISet visited) + { + if (scene == null || !visited.Add(scene)) + { + return; + } + + _runtimeChapters.Add(scene); + IndexByName(scene); + + if (scene.exits == null) + { + return; + } + + foreach (var exit in scene.exits) + { + VisitRuntime(exit?.targetScene, visited); + } + } + + private void IndexByName(TalkSceneSO scene) + { + if (scene == null) + { + return; + } + + var sceneName = scene.name; + if (string.IsNullOrWhiteSpace(sceneName)) + { + _validationErrors.Add("TalkSceneSO has an empty asset name."); + return; + } + + if (_byName.TryGetValue(sceneName, out var existing)) + { + if (existing != scene && _ambiguousNames.Add(sceneName)) + { + _validationErrors.Add($"Duplicate TalkSceneSO name: {sceneName}."); + } + + return; + } + + _byName.Add(sceneName, scene); + } + } +} diff --git a/Assets/Scripts/Game Loop/TalkSceneGraphIndex.cs.meta b/Assets/Scripts/Game Loop/TalkSceneGraphIndex.cs.meta new file mode 100644 index 000000000..97098f46f --- /dev/null +++ b/Assets/Scripts/Game Loop/TalkSceneGraphIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a755050ce5e4196a5caaaebeb23448e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs index 7ae656cc6..a60bb2afb 100644 --- a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs +++ b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs @@ -8,13 +8,15 @@ namespace AibisDream.SaveSystem [Serializable] public sealed class RestoreOptions { - public bool CleanSessionFirst; - public bool StrictValidation; + public bool StrictValidation = true; + + public static RestoreOptions Default => new() + { + StrictValidation = true + }; - public static RestoreOptions Default => new(); public static RestoreOptions DevJump => new() { - CleanSessionFirst = true, StrictValidation = true }; } @@ -22,10 +24,32 @@ namespace AibisDream.SaveSystem public sealed class RestoreResult { public bool Success { get; internal set; } + public bool Cancelled { get; internal set; } public string FailedPhase { get; internal set; } public IReadOnlyList Errors { get; internal set; } = Array.Empty(); public IReadOnlyList Warnings { get; internal set; } = Array.Empty(); public IReadOnlyList Log { get; internal set; } = Array.Empty(); + public TalkSceneSO RestoredTalkScene { get; internal set; } + + public static RestoreResult CreateFailure(string phase, string error) + { + return new RestoreResult + { + Success = false, + FailedPhase = phase, + Errors = new[] { error } + }; + } + } + + internal sealed class RestorePreparation + { + internal bool Success => Result?.Success == true; + internal SaveSnapshot Snapshot { get; set; } + internal TalkSceneSO TargetTalkScene { get; set; } + internal string SourceLabel { get; set; } + internal RestoreOptions Options { get; set; } + internal RestoreResult Result { get; set; } = new(); } /// @@ -70,14 +94,20 @@ namespace AibisDream.SaveSystem /// public sealed class SnapshotRestoreContext { - public SnapshotRestoreContext(SaveSnapshot snapshot, bool strictMode = false, Action logStep = null) + public SnapshotRestoreContext( + SaveSnapshot snapshot, + TalkSceneSO targetTalkScene = null, + bool strictMode = false, + Action logStep = null) { Snapshot = snapshot; + TargetTalkScene = targetTalkScene; StrictMode = strictMode; LogStep = logStep; } public SaveSnapshot Snapshot { get; } + public TalkSceneSO TargetTalkScene { get; } public bool StrictMode { get; } public Action LogStep { get; } public string CurrentPhase { get; private set; } diff --git a/Assets/Scripts/SaveSystem/README.md b/Assets/Scripts/SaveSystem/README.md index 00f6bf76c..78f8e62e5 100644 --- a/Assets/Scripts/SaveSystem/README.md +++ b/Assets/Scripts/SaveSystem/README.md @@ -24,22 +24,17 @@ yield return SaveRestoreOrchestrator.ExplicitSaveRoutine(); // 手动存档:复制最近落盘档(含 OnNodeStart 与 <>) SaveRestoreOrchestrator.CreateManualSlot(slotIndex); -// 槽位读档 -yield return SaveRestoreOrchestrator.RestoreFromSlot(slotIndex); +// 槽位读档:会话命令统一从 GameManager 进入 +GameManager.Instance.TryRestoreSlot(slotIndex, result => { /* 处理结果 */ }); -// 文件读档(旧档 / 调试) -yield return SaveRestoreOrchestrator.RestoreFromFile(path); - -// 严格开发跳转:完整清理旧会话,并取得实际成功/失败结果 -RestoreResult result = null; -yield return SaveRestoreOrchestrator.RestoreFromFile( +// 严格文件读档 / 开发跳转 +GameManager.Instance.TryRestoreFile( path, RestoreOptions.DevJump, - value => result = value); + result => { /* 处理结果 */ }); -// 仅内存快照 +// 仅捕获内存快照;恢复仍须通过 GameManager,以保证会话互斥和失败清理 var snap = SnapshotService.Capture(); -yield return SnapshotService.Restore(snap); // Yarn 变量(与存档无关) YarnVariableStorage.Instance.SetValue("$foo", 1f); @@ -118,17 +113,18 @@ SaveSystem/ → SnapshotPersistence.Save() 读盘: - SaveRestoreOrchestrator - → SnapshotPersistence.Load() (或 legacy 分支) - → SnapshotService.Restore() - → SnapshotRestore(见「读档还原编排」) + GameManager + → SaveRestoreOrchestrator.PrepareRestore(读盘 + 预检,不修改运行时) + → SaveRestoreOrchestrator.ExecutePreparedRestore + → SnapshotRestore(变量 + Scene + Providers + Anchor) + → GameManager 提交章节和会话状态 ``` -## 读档还原编排(终态 vs P1 代码) +## 读档还原编排 -> **勿将当前代码当作终态。** P1 中 `ISnapshotProvider.Restore` 统一返回 `IEnumerator`,`SnapshotRestore` 对每个 Provider 做 `yield return`——这是受旧 `IData.Load()` 影响的**临时 scaffolding**。终态见设计文档 **§4.1 / D6**;P4 实施前应 refactor。 +读档已经采用 Prepare / Execute 边界与 Phase + Barrier 模型。所有文件统一反序列化,并由 schema 与必填字段预检判断是否兼容;不存在旧格式专用识别、恢复或转写分支。 -### 终态:Phase + Barrier +### Phase + Barrier 读档分阶段推进,仅在**必须等待**的边界上 `yield`: @@ -137,35 +133,23 @@ Phase 0 Yarn 变量(sync) ↓ Phase 1 场景加载(Barrier:LoadSceneAsync)← 框架直管,不走 Provider ↓ -Phase 1.5 设置章节 SO(sync)← 框架直管,从 anchor.sceneSoName 读取 - ↓ Phase 2 env / actor / audio / timeline / fix / screen(Provider 按 RestoreOrder) └─ timeline:`Stopped` = untouched(从未 Evaluate),读档仅还原 `isActive`,不 Reset/Evaluate ↓ Phase 2′ 可选 Barrier(如 Timeline Addressable 须显式等待) ↓ +Phase 2.5 SceneReadiness(Barrier) + ↓ Phase 3 加载对话工程 + RestoreAnchor(Barrier:StartDialogue)← 框架直管 ↓ -Phase 4 P4:淡入淡出等演出时序 +Postflight 校验 ``` - **核心层**(`scene`、`anchor`、`yarnVariables`)由 `SnapshotRestore` 框架直管,不通过 Provider 注册。 - **表现层**(`sections` 内各子系统)通过 `ISnapshotProvider` 扩展;`RestoreOrder` 表示同 Phase 内的建议顺序或软依赖。 - **逐项还原(D1)**指各子系统各自写回状态,**不是** Provider 之间逐步 `yield return`。 -### P1 临时形态 vs P4 目标 - -| | P1(当前) | P4 目标 | -| --- | --- | --- | -| 核心层还原 | 硬编码在 `SnapshotRestore` | 保持框架直管 | -| Provider 还原 | 全部 `IEnumerator Restore` | 默认 `void Restore`;仅需 async 加载的实现显式协程 | -| 编排 | `foreach` 逐步 `yield return` | Phase 编排,仅 Barrier 步骤 `yield` | -| Manager | 部分 `IEnumerator RestoreSnapshot` 仅 `yield break` | 默认 `void`;真有 async 才保留协程 | - -### 已知偏离(tech debt) - -- `DirectorHandler` 在 Addressable Timeline 路径下内部 `StartCoroutine`,Provider 已返回,编排层无法感知——P4 应改为可等待路径。 -- P2/P3 新增代码**不要**再复制「全 Provider 协程链」模式。 +Provider 使用 `ISyncSnapshotProvider` 或 `IAsyncSnapshotProvider` 显式声明同步/异步恢复;异步 Provider 必须把等待过程返回给编排层,禁止内部 fire-and-forget。 ## 快照 JSON 结构(schemaVersion = 1) diff --git a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs index edd87fe07..3513e457a 100644 --- a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs +++ b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs @@ -230,7 +230,7 @@ namespace AibisDream.SaveSystem return false; } - if (GameManager.Instance != null && GameManager.Instance.state.isInPause) + if (GameManager.Instance != null && GameManager.Session.IsPaused) { reason = SavePointRejectReason.GamePaused; return false; diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs index f181215a4..55589c262 100644 --- a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs @@ -25,8 +25,6 @@ namespace AibisDream.SaveSystem private static int _autoSaveSuppressDepth; private static readonly List _lastRestoreLog = new(); - private const float RestoreFadeDuration = 0.2f; - /// 启动自动存档协程(手动/调试入口)。 public static void TryAutoSave() { @@ -138,86 +136,145 @@ namespace AibisDream.SaveSystem SlotManager.CopyAutoToManual(slotIndex); } - /// 从指定槽位读档并还原。 - public static IEnumerator RestoreFromSlot(int slotIndex) + internal static RestorePreparation PrepareRestoreFromSlot( + int slotIndex, + TalkSceneGraphIndex talkSceneIndex, + RestoreOptions options) { - var snapshot = SlotManager.LoadSnapshot(slotIndex); - if (snapshot == null) - { - Debug.LogError($"[SaveRestoreOrchestrator] 槽位 {slotIndex} 不存在或读取失败"); - yield break; - } - - yield return RestoreSnapshot(snapshot, $"slot_{slotIndex}"); - } - - /// 从文件读档并还原;自动区分新快照格式与 legacy 格式。 - public static IEnumerator RestoreFromFile(string savePath) - { - yield return RestoreFromFile(savePath, RestoreOptions.Default, null); - } - - /// 从文件恢复并返回结构化结果;开发跳转使用严格校验和完整会话清理。 - public static IEnumerator RestoreFromFile( - string savePath, - RestoreOptions options, - Action completed) - { - options ??= RestoreOptions.Default; - var result = new RestoreResult(); - - bool isLegacy; + var sourceLabel = $"slot_{slotIndex}"; + SaveSnapshot snapshot; try { - isLegacy = SnapshotPersistence.IsLegacyFormat(savePath); + snapshot = SlotManager.LoadSnapshot(slotIndex); } catch (Exception ex) { - result.Success = false; - result.FailedPhase = "LoadSnapshot"; - result.Errors = new[] { ex.Message }; - completed?.Invoke(result); - yield break; + return FailedPreparation(sourceLabel, "LoadSnapshot", ex.Message, options); } - if (isLegacy) + if (snapshot == null) { - if (options.StrictValidation) - { - result.Success = false; - result.FailedPhase = "Preflight"; - result.Errors = new[] { "legacy format is not supported by strict restore" }; - completed?.Invoke(result); - yield break; - } - - Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。"); - yield return RestoreLegacyWithFlow(savePath); - result.Success = true; - result.Log = new List(_lastRestoreLog); - completed?.Invoke(result); - yield break; + return FailedPreparation( + sourceLabel, + "LoadSnapshot", + $"Slot {slotIndex} does not exist or could not be read.", + options); } - SaveSnapshot snapshot = null; + return PrepareSnapshot(snapshot, sourceLabel, talkSceneIndex, options); + } + + internal static RestorePreparation PrepareRestoreFromFile( + string savePath, + TalkSceneGraphIndex talkSceneIndex, + RestoreOptions options) + { + options ??= RestoreOptions.Default; try { + SaveSnapshot snapshot; using (new CodeTimer("LoadSnapshot")) { snapshot = SnapshotPersistence.Load(savePath); } + + return PrepareSnapshot(snapshot, savePath, talkSceneIndex, options); } catch (Exception ex) { - result.Success = false; - result.FailedPhase = "LoadSnapshot"; - result.Errors = new[] { ex.Message }; - completed?.Invoke(result); + return FailedPreparation(savePath, "LoadSnapshot", ex.Message, options); + } + } + + internal static IEnumerator ExecutePreparedRestore( + RestorePreparation preparation, + Func isCancellationRequested) + { + if (preparation?.Success != true) + { yield break; } - yield return RestoreSnapshot(snapshot, savePath, options, result); - completed?.Invoke(result); + var result = preparation.Result; + result.Success = false; + + using (SuppressAutoSaveScope($"RestoreSnapshot:{preparation.SourceLabel}")) + { + IsRestoring = true; + ResetRestoreLog(preparation.SourceLabel); + var context = new SnapshotRestoreContext( + preparation.Snapshot, + preparation.TargetTalkScene, + strictMode: preparation.Options?.StrictValidation ?? true, + logStep: AddRestoreLog); + + try + { + SnapshotRegistry.EnsureInitialized(); + yield return SnapshotRestore.RestoreState( + YarnVariableStorage.Instance, + preparation.Snapshot, + context, + isCancellationRequested); + + if (ShouldCancel(isCancellationRequested, result, context)) + { + yield break; + } + + if (context.StrictMode && context.HasErrors) + { + yield break; + } + + context.SetPhase("Phase 2.5: Scene readiness"); + SceneReadinessResult readiness = null; + yield return SceneReadiness.WaitUntilReady( + SceneLoader.Instance.CurrentScene, + isCancellationRequested, + value => readiness = value); + + if (readiness?.Cancelled == true) + { + result.Cancelled = true; + yield break; + } + + if (readiness?.Success != true) + { + context.Error(readiness?.Error ?? "Scene readiness did not return a result."); + yield break; + } + + if (ShouldCancel(isCancellationRequested, result, context)) + { + yield break; + } + + yield return SnapshotRestore.RestoreAnchor(preparation.Snapshot, context); + if (!context.StrictMode || !context.HasErrors) + { + context.SetPhase("Postflight validation"); + ValidateRestoredRuntime(preparation, context); + } + + result.Success = !context.HasErrors; + if (result.Success) + { + result.RestoredTalkScene = preparation.TargetTalkScene; + } + } + finally + { + IsRestoring = false; + AddRestoreLog("Restore finished"); + result.Success = result.Success && !result.Cancelled && !context.HasErrors; + result.FailedPhase = result.Success || result.Cancelled ? null : context.CurrentPhase; + result.Errors = new List(context.Errors); + result.Warnings = new List(context.Warnings); + result.Log = new List(_lastRestoreLog); + } + } } public static IDisposable SuppressAutoSaveScope(string reason) @@ -227,180 +284,122 @@ namespace AibisDream.SaveSystem return new AutoSaveSuppressScope(reason); } - /// 旧档:仅恢复 Yarn 变量,再 Capture 写入 slot_0 完成格式迁移。 - private static IEnumerator RestoreLegacy(string savePath) - { - var saveData = SnapshotPersistence.ReadLegacySaveRoot(savePath); - var floatDict = saveData["floatDict"]?.ToObject>(); - var stringDict = saveData["stringDict"]?.ToObject>(); - var boolDict = saveData["boolDict"]?.ToObject>(); - YarnVariableStorage.Instance.SetAllVariables(floatDict, stringDict, boolDict); - - if (saveData["systemData"] != null) - { - Debug.LogWarning( - "[SaveRestoreOrchestrator] 旧档 systemData 段已无法还原(IData 已移除);" + - "仅恢复 Yarn 变量并迁移为新快照格式。"); - } - - var snapshot = SnapshotService.Capture(); - SlotManager.SaveToAutoSlot(snapshot, null); - yield break; - } - - private static IEnumerator RestoreLegacyWithFlow(string savePath) - { - using (SuppressAutoSaveScope("RestoreLegacy")) - { - IsRestoring = true; - ResetRestoreLog("legacy"); - - try - { - PrepareGameSessionForRestore(); - yield return FadeInForRestore(); - yield return RestoreLegacy(savePath); - yield return null; - FinalizeGameSessionAfterRestore(); - yield return FadeOutForRestore(); - ScreenSnapshotHelper.ApplyDeferredFadeScreenIfNeeded(); - } - finally - { - IsRestoring = false; - AddRestoreLog("Legacy restore finished"); - } - } - } - - private static IEnumerator RestoreSnapshot(SaveSnapshot snapshot, string sourceLabel) - { - yield return RestoreSnapshot(snapshot, sourceLabel, RestoreOptions.Default, new RestoreResult()); - } - - private static IEnumerator RestoreSnapshot( + private static RestorePreparation PrepareSnapshot( SaveSnapshot snapshot, string sourceLabel, - RestoreOptions options, - RestoreResult result) + TalkSceneGraphIndex talkSceneIndex, + RestoreOptions options) { + options ??= RestoreOptions.Default; + var preparation = new RestorePreparation + { + Snapshot = snapshot, + SourceLabel = sourceLabel, + Options = options, + Result = new RestoreResult() + }; + + var errors = new List(); if (snapshot == null) + errors.Add("Snapshot is null."); + else { - Debug.LogError("[SaveRestoreOrchestrator] snapshot 为 null,无法读档。"); - result.Success = false; - result.FailedPhase = "Preflight"; - result.Errors = new[] { "snapshot is null" }; - yield break; + if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) + errors.Add($"Unsupported schema {snapshot.schemaVersion}; expected {SaveSnapshotSchema.CurrentVersion}."); + if (string.IsNullOrWhiteSpace(snapshot.scene?.sceneName)) + errors.Add("Snapshot scene is missing."); + if (string.IsNullOrWhiteSpace(snapshot.anchor?.sceneSoName)) + errors.Add("Snapshot TalkSceneSO is missing."); + if (string.IsNullOrWhiteSpace(snapshot.anchor?.yarnProjectId)) + errors.Add("Snapshot YarnProject is missing."); } - using (SuppressAutoSaveScope($"RestoreSnapshot:{sourceLabel}")) + TalkSceneSO targetScene = null; + if (errors.Count == 0 + && !talkSceneIndex.TryFindByName(snapshot.anchor.sceneSoName, out targetScene, out var lookupError)) { - IsRestoring = true; - ResetRestoreLog(sourceLabel); - var context = new SnapshotRestoreContext( - snapshot, - strictMode: options.StrictValidation, - logStep: AddRestoreLog); - - try - { - context.SetPhase("Preflight"); - ValidateSnapshotForRestore(snapshot, context); - if (context.StrictMode && context.HasErrors) - { - yield break; - } - - if (options.CleanSessionFirst && GameManager.Instance != null) - { - context.SetPhase("Clean game session"); - yield return GameManager.Instance.ResetGameSessionRoutine(); - } - - PrepareGameSessionForRestore(); - - yield return FadeInForRestore(); - - SnapshotRegistry.EnsureInitialized(); - yield return SnapshotRestore.RestoreState(YarnVariableStorage.Instance, snapshot, context); - if (!context.StrictMode || !context.HasErrors) - { - yield return SnapshotRestore.RestoreAnchor(snapshot, context); - } - - if (!context.StrictMode || !context.HasErrors) - { - context.SetPhase("Phase 3.5: settle one frame"); - yield return null; - ValidateRestoredRuntime(snapshot, context); - } - - if (!context.StrictMode || !context.HasErrors) - { - FinalizeGameSessionAfterRestore(); - } - - yield return FadeOutForRestore(); - ScreenSnapshotHelper.ApplyDeferredFadeScreenIfNeeded(); - } - finally - { - IsRestoring = false; - AddRestoreLog("Restore finished"); - result.Success = !context.HasErrors; - result.FailedPhase = context.HasErrors ? context.CurrentPhase : null; - result.Errors = new List(context.Errors); - result.Warnings = new List(context.Warnings); - result.Log = new List(_lastRestoreLog); - } + errors.Add(lookupError); } - } - private static void ValidateSnapshotForRestore(SaveSnapshot snapshot, SnapshotRestoreContext context) - { - if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) - context.Error($"Unsupported schema {snapshot.schemaVersion}; expected {SaveSnapshotSchema.CurrentVersion}."); - if (string.IsNullOrWhiteSpace(snapshot.scene?.sceneName)) - context.Error("Snapshot scene is missing."); - if (string.IsNullOrWhiteSpace(snapshot.anchor?.sceneSoName)) - context.Error("Snapshot TalkSceneSO is missing."); - if (string.IsNullOrWhiteSpace(snapshot.anchor?.yarnProjectId)) - context.Error("Snapshot YarnProject is missing."); - if (string.IsNullOrWhiteSpace(snapshot.anchor?.nodeName)) - context.Error("Snapshot Yarn node is missing."); - - if (context.HasErrors) return; - var sceneSo = GameManager.Instance?.FindSceneSoByName(snapshot.anchor.sceneSoName); - if (sceneSo == null) + if (errors.Count == 0 + && (targetScene.yarnProject == null + || !string.Equals( + targetScene.yarnProject.name, + snapshot.anchor.yarnProjectId, + StringComparison.Ordinal))) { - context.Error($"TalkSceneSO does not exist: {snapshot.anchor.sceneSoName}."); - return; + errors.Add($"TalkSceneSO YarnProject mismatch: {snapshot.anchor.yarnProjectId}."); } - if (sceneSo.yarnProject == null - || !string.Equals(sceneSo.yarnProject.name, snapshot.anchor.yarnProjectId, StringComparison.Ordinal)) - { - context.Error($"TalkSceneSO YarnProject mismatch: {snapshot.anchor.yarnProjectId}."); - return; - } - if (!Array.Exists(sceneSo.yarnProject.NodeNames, + + if (errors.Count == 0 + && !string.IsNullOrWhiteSpace(snapshot.anchor.nodeName) + && !Array.Exists( + targetScene.yarnProject.NodeNames, node => string.Equals(node, snapshot.anchor.nodeName, StringComparison.Ordinal))) { - context.Error($"Yarn node does not exist: {snapshot.anchor.nodeName}."); + errors.Add($"Yarn node does not exist: {snapshot.anchor.nodeName}."); } + + if (errors.Count > 0) + { + preparation.Result = new RestoreResult + { + Success = false, + FailedPhase = "Preflight", + Errors = errors + }; + return preparation; + } + + preparation.TargetTalkScene = targetScene; + preparation.Result.Success = true; + preparation.Result.RestoredTalkScene = targetScene; + return preparation; } - private static void ValidateRestoredRuntime(SaveSnapshot snapshot, SnapshotRestoreContext context) + private static RestorePreparation FailedPreparation( + string sourceLabel, + string phase, + string error, + RestoreOptions options) { - context.SetPhase("Postflight validation"); + return new RestorePreparation + { + SourceLabel = sourceLabel, + Options = options ?? RestoreOptions.Default, + Result = RestoreResult.CreateFailure(phase, error) + }; + } + + private static bool ShouldCancel( + Func isCancellationRequested, + RestoreResult result, + SnapshotRestoreContext context) + { + if (isCancellationRequested?.Invoke() != true) + { + return false; + } + + result.Cancelled = true; + context.Log("Restore cancelled between phases."); + return true; + } + + private static void ValidateRestoredRuntime( + RestorePreparation preparation, + SnapshotRestoreContext context) + { + var snapshot = preparation.Snapshot; var sceneLoader = SceneLoader.Instance; if (sceneLoader == null || sceneLoader.IsLoading) context.Error("SceneLoader is not ready."); else if (!string.Equals(sceneLoader.CurrentSceneName, snapshot.scene.sceneName, StringComparison.Ordinal)) context.Error($"Scene mismatch: {sceneLoader.CurrentSceneName ?? "none"}."); - var sceneSo = GameManager.Instance?.GetCurrentTalkSceneSo(); - if (!string.Equals(sceneSo?.name, snapshot.anchor.sceneSoName, StringComparison.Ordinal)) - context.Error($"TalkSceneSO mismatch: {sceneSo?.name ?? "none"}."); + var targetScene = preparation.TargetTalkScene; + if (!string.Equals(targetScene?.name, snapshot.anchor.sceneSoName, StringComparison.Ordinal)) + context.Error($"TalkSceneSO mismatch: {targetScene?.name ?? "none"}."); var runner = DialogController.Instance?.DialogueRunner; if (!string.Equals(runner?.YarnProject?.name, snapshot.anchor.yarnProjectId, StringComparison.Ordinal)) @@ -417,78 +416,6 @@ namespace AibisDream.SaveSystem } } - /// - /// 读档前对齐「已进入游戏」的 UI 与输入状态。 - /// 编辑器测试工具等路径可能跳过 ,导致 TerminalPanel 未关闭、 - /// 为 false,从而无法推进对话。 - /// - private static void PrepareGameSessionForRestore() - { - AddRestoreLog("Prepare game session for restore"); - - // 读档淡入使用 DOTween(受 timeScale 影响);若处于暂停态须先恢复,否则 FadeIn 永不结束、画面卡在诊室/终端。 - Time.timeScale = 1f; - - var gameManager = GameManager.Instance; - if (gameManager != null && !gameManager.state.isInGame) - { - EnumEventSystem.Global.Send(GameLoopEnum.GameStart); - } - - var ui = UIManager.Instance; - if (ui != null) - { - ui.HidePanel(); - ui.HidePanel(); - ui.ShowPanel(); - ui.HidePanel(); - ui.HidePanel(); - } - - if (gameManager != null && gameManager.state.isInPause) - { - gameManager.ContinueGame(); - } - else - { - DialogUIManager.Instance?.OpenCanvas(); - } - } - - /// 读档完成后确保对话 Canvas 可用、退出暂停态。 - private static void FinalizeGameSessionAfterRestore() - { - AddRestoreLog("Finalize game session after restore"); - - DialogUIManager.Instance?.OpenCanvas(); - - var gameManager = GameManager.Instance; - if (gameManager != null && gameManager.state.isInPause) - { - gameManager.ContinueGame(); - } - } - - private static IEnumerator FadeInForRestore() - { - AddRestoreLog("Begin restore fade in"); - var panel = UIManager.Instance?.GetPanel(); - if (panel != null) - { - yield return panel.FadeInAsync(RestoreFadeDuration, useUnscaledTime: true); - } - } - - private static IEnumerator FadeOutForRestore() - { - AddRestoreLog("End restore fade out"); - var panel = UIManager.Instance?.GetPanel(); - if (panel != null) - { - yield return panel.FadeOutAsync(RestoreFadeDuration, useUnscaledTime: true); - } - } - private static void ResetRestoreLog(string sourceLabel) { _lastRestoreLog.Clear(); diff --git a/Assets/Scripts/SaveSystem/SaveSnapshot.cs b/Assets/Scripts/SaveSystem/SaveSnapshot.cs index 6d86c8830..d72792805 100644 --- a/Assets/Scripts/SaveSystem/SaveSnapshot.cs +++ b/Assets/Scripts/SaveSystem/SaveSnapshot.cs @@ -57,7 +57,7 @@ namespace AibisDream.SaveSystem [Serializable] public class AnchorSnapshot { - /// 当前章节 TalkSceneSO 名称,用于 + /// 当前章节 TalkSceneSO 名称,由读档预检解析为目标章节。 public string sceneSoName; public string yarnProjectId; public string nodeName; diff --git a/Assets/Scripts/SaveSystem/SnapshotCapture.cs b/Assets/Scripts/SaveSystem/SnapshotCapture.cs index 2abde22dc..65931e0dd 100644 --- a/Assets/Scripts/SaveSystem/SnapshotCapture.cs +++ b/Assets/Scripts/SaveSystem/SnapshotCapture.cs @@ -88,8 +88,9 @@ namespace AibisDream.SaveSystem var yarnProject = dialog.DialogueRunner?.YarnProject; var projectId = yarnProject != null ? yarnProject.name : string.Empty; - var gameManager = GameManager.Instance; - var sceneSoName = gameManager != null ? gameManager.GetSceneSoName() : null; + var sceneSoName = GameManager.Instance != null + ? GameManager.Session.CurrentTalkScene?.name + : null; snapshot.anchor = new AnchorSnapshot { diff --git a/Assets/Scripts/SaveSystem/SnapshotPersistence.cs b/Assets/Scripts/SaveSystem/SnapshotPersistence.cs index 102d1e227..402facf6f 100644 --- a/Assets/Scripts/SaveSystem/SnapshotPersistence.cs +++ b/Assets/Scripts/SaveSystem/SnapshotPersistence.cs @@ -1,16 +1,15 @@ using System.IO; using AibisDream.Utility; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using UnityEngine; namespace AibisDream.SaveSystem { /// - /// 快照与磁盘的边界:序列化、读写 JSON 文件、检测旧档格式。 + /// 快照与磁盘的边界:序列化和读写 JSON 文件。 /// /// P2 起,业务存档统一通过 写入槽位目录; - /// 本类保留按路径读写的低级接口,供旧档兼容、迁移和外部调试使用。 + /// 本类保留按路径读写的低级接口,供迁移和外部调试使用。 /// /// public static class SnapshotPersistence @@ -80,21 +79,5 @@ namespace AibisDream.SaveSystem return Deserialize(json); } - /// - /// 是否为旧 StorageSystem 格式(含 systemData、无 schemaVersion)。 - /// 此类文件应走 的 legacy 分支。 - /// - public static bool IsLegacyFormat(string savePath) - { - var json = File.ReadAllText(JsonUtil.FormatAsJsonPath(savePath)); - var jobj = JObject.Parse(json); - return jobj.Value("schemaVersion") == null && jobj["systemData"] != null; - } - - /// 读取旧格式存档根 JObject,供 legacy 还原使用。 - public static JObject ReadLegacySaveRoot(string savePath) - { - return JsonUtil.ReadJObject(savePath); - } } } diff --git a/Assets/Scripts/SaveSystem/SnapshotRestore.cs b/Assets/Scripts/SaveSystem/SnapshotRestore.cs index a66fcb175..b101239eb 100644 --- a/Assets/Scripts/SaveSystem/SnapshotRestore.cs +++ b/Assets/Scripts/SaveSystem/SnapshotRestore.cs @@ -15,7 +15,8 @@ namespace AibisDream.SaveSystem public static IEnumerator RestoreState( YarnVariableStorage storage, SaveSnapshot snapshot, - SnapshotRestoreContext context) + SnapshotRestoreContext context, + System.Func isCancellationRequested = null) { if (snapshot == null) { @@ -27,17 +28,15 @@ namespace AibisDream.SaveSystem context.SetPhase("Phase 0: Restore Yarn variables"); RestoreYarnVariables(storage, snapshot); + if (IsCancellationRequested(isCancellationRequested, context)) yield break; context.SetPhase("Phase 1: Load scene"); yield return RestoreScene(snapshot, context); - if (context.StrictMode && context.HasErrors) yield break; - - context.SetPhase("Phase 1.5: Restore scene SO"); - RestoreSceneSo(snapshot, context); + if (IsCancellationRequested(isCancellationRequested, context)) yield break; if (context.StrictMode && context.HasErrors) yield break; context.SetPhase("Phase 2: Restore providers (ordered by RestoreOrder)"); - yield return RestoreProvidersInOrder(snapshot, context); + yield return RestoreProvidersInOrder(snapshot, context, isCancellationRequested); } /// @@ -74,7 +73,7 @@ namespace AibisDream.SaveSystem if (!string.IsNullOrEmpty(snapshot.anchor.yarnProjectId)) { - var sceneSo = GameManager.Instance?.GetCurrentTalkSceneSo(); + var sceneSo = context.TargetTalkScene; if (sceneSo?.yarnProject == null) { context.Error($"章节 {snapshot.anchor.sceneSoName} 没有可用的 YarnProject。"); @@ -138,41 +137,37 @@ namespace AibisDream.SaveSystem yield break; } - yield return sceneLoader.LoadSceneAsync(snapshot.scene.sceneName); + SceneOperationResult result = null; + yield return sceneLoader.LoadSceneAsync( + snapshot.scene.sceneName, + value => result = value); + + if (result?.Success != true) + { + context.Error(result?.Error ?? "Scene load did not return a result."); + yield break; + } // 再等一帧,等Awake执行完 yield return null; } - 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) - { - context.Warn("GameManager 未初始化,无法设置章节 SO。"); - return; - } - - if (!gameManager.SetSceneSoByName(snapshot.anchor.sceneSoName)) - { - context.Error($"找不到章节 SO:{snapshot.anchor.sceneSoName},无法恢复 YarnProject。"); - } - } - /// /// 按 依次还原; /// 同步 Provider 连续调用,异步 Provider 作为 Barrier 挂起,二者混排而非分两批。 /// - private static IEnumerator RestoreProvidersInOrder(SaveSnapshot snapshot, SnapshotRestoreContext context) + private static IEnumerator RestoreProvidersInOrder( + SaveSnapshot snapshot, + SnapshotRestoreContext context, + System.Func isCancellationRequested) { foreach (var provider in SnapshotRegistry.GetOrderedProviders()) { + if (IsCancellationRequested(isCancellationRequested, context)) + { + yield break; + } + if (!TryGetSectionDto(snapshot, provider, context, out var dto)) { continue; @@ -199,9 +194,27 @@ namespace AibisDream.SaveSystem { yield break; } + + if (IsCancellationRequested(isCancellationRequested, context)) + { + yield break; + } } } + private static bool IsCancellationRequested( + System.Func isCancellationRequested, + SnapshotRestoreContext context) + { + if (isCancellationRequested?.Invoke() != true) + { + return false; + } + + context.Log("Restore cancellation requested between phases."); + return true; + } + private static bool TryGetSectionDto( SaveSnapshot snapshot, ISnapshotProvider provider, diff --git a/Assets/Scripts/SaveSystem/SnapshotService.cs b/Assets/Scripts/SaveSystem/SnapshotService.cs index 7bab9c7c9..3c45a03ea 100644 --- a/Assets/Scripts/SaveSystem/SnapshotService.cs +++ b/Assets/Scripts/SaveSystem/SnapshotService.cs @@ -1,9 +1,7 @@ -using System.Collections; - namespace AibisDream.SaveSystem { /// - /// 快照层对外 API:Capture / Restore,不涉及文件与 UI。 + /// 快照层捕获 API。恢复必须通过 GameManager 会话命令进入。 /// 游戏逻辑应优先使用 完成存读档流程。 /// public static class SnapshotService @@ -17,25 +15,5 @@ namespace AibisDream.SaveSystem return SnapshotCapture.Capture(YarnVariableStorage.Instance, triggerNodeName, omitAnchor); } - /// - /// 还原快照状态;默认继续执行 。 - /// 业务读档流程应优先使用 ,以获得黑屏与自动存档抑制。 - /// - public static IEnumerator Restore(SaveSnapshot snapshot, bool restoreAnchor = true) - { - if (snapshot == null) - { - yield break; - } - - SnapshotRegistry.EnsureInitialized(); - var context = new SnapshotRestoreContext(snapshot); - yield return SnapshotRestore.RestoreState(YarnVariableStorage.Instance, snapshot, context); - - if (restoreAnchor) - { - yield return SnapshotRestore.RestoreAnchor(snapshot, context); - } - } } } diff --git a/Assets/Scripts/SceneManagement/SceneReadiness.cs b/Assets/Scripts/SceneManagement/SceneReadiness.cs new file mode 100644 index 000000000..83cf86676 --- /dev/null +++ b/Assets/Scripts/SceneManagement/SceneReadiness.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace AibisDream +{ + public sealed class SceneReadinessResult + { + public bool Success { get; internal set; } + public bool Cancelled { get; internal set; } + public string Error { get; internal set; } + } + + public static class SceneReadiness + { + public const float DefaultTimeoutSeconds = 15f; + + public static IEnumerator WaitUntilReady( + Scene scene, + Func isCancellationRequested, + Action completed, + float timeoutSeconds = DefaultTimeoutSeconds) + { + var result = new SceneReadinessResult(); + + // 等待场景内 Awake/Start 完成,再收集当前场景自己的 Gate。 + yield return null; + + if (isCancellationRequested?.Invoke() == true) + { + result.Cancelled = true; + result.Error = "Scene readiness was cancelled."; + completed?.Invoke(result); + yield break; + } + + if (!scene.IsValid() || !scene.isLoaded) + { + result.Error = "Loaded scene is invalid or not loaded."; + completed?.Invoke(result); + yield break; + } + + var gates = CollectGates(scene); + if (gates.Count == 0) + { + result.Success = true; + completed?.Invoke(result); + yield break; + } + + var deadline = Time.unscaledTime + Mathf.Max(0f, timeoutSeconds); + while (true) + { + if (isCancellationRequested?.Invoke() == true) + { + result.Cancelled = true; + result.Error = "Scene readiness was cancelled."; + break; + } + + var allReady = true; + foreach (var gate in gates) + { + if (gate is UnityEngine.Object unityObject && unityObject == null) + { + result.Error = "A scene dialogue gate was destroyed while waiting."; + completed?.Invoke(result); + yield break; + } + + if (!gate.IsDialogueReady) + { + allReady = false; + } + } + + if (allReady) + { + result.Success = true; + break; + } + + if (Time.unscaledTime >= deadline) + { + result.Error = $"Scene readiness timed out after {timeoutSeconds:0.#} seconds."; + break; + } + + yield return null; + } + + completed?.Invoke(result); + } + + private static List CollectGates(Scene scene) + { + var gates = new List(); + foreach (var root in scene.GetRootGameObjects()) + { + foreach (var behaviour in root.GetComponentsInChildren(true)) + { + if (behaviour is ISceneDialogueGate gate) + { + gates.Add(gate); + } + } + } + + return gates; + } + } +} diff --git a/Assets/Scripts/SceneManagement/SceneReadiness.cs.meta b/Assets/Scripts/SceneManagement/SceneReadiness.cs.meta new file mode 100644 index 000000000..257fea5f5 --- /dev/null +++ b/Assets/Scripts/SceneManagement/SceneReadiness.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa605d25316143c690f4e165e48ed832 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/Cursor/CursorManager.cs b/Assets/Scripts/UI/Cursor/CursorManager.cs index 44b241fa2..efa69d366 100644 --- a/Assets/Scripts/UI/Cursor/CursorManager.cs +++ b/Assets/Scripts/UI/Cursor/CursorManager.cs @@ -76,9 +76,9 @@ namespace AibisDream private CursorStateEnum CheckCursorState() { - var gameState = GameManager.Instance.state; + var gameState = GameManager.Session; - if (!gameState.isInGame || gameState.isInPause) + if (gameState.Phase != GameSessionPhase.Playing || gameState.IsPaused) { // 非游戏中显示默认指针 return CursorStateEnum.Default; @@ -88,7 +88,7 @@ namespace AibisDream // 游戏中鼠标移至右上角Main Panel时显示默认指针 return CursorStateEnum.Default; } - else if (gameState.isInDialog) + else if (gameState.IsDialogueActive) { // 处理对话中的指针 var dialogState = DialogController.Instance.GetDialogState(); diff --git a/Assets/Scripts/UI/DialogUI/DialogUIManager.cs b/Assets/Scripts/UI/DialogUI/DialogUIManager.cs index 635eeb17d..09c3d387a 100644 --- a/Assets/Scripts/UI/DialogUI/DialogUIManager.cs +++ b/Assets/Scripts/UI/DialogUI/DialogUIManager.cs @@ -25,7 +25,9 @@ namespace AibisDream EnumEventSystem.Global.Register(DialogEventEnum.LineStart, AddDialogueLine); EnumEventSystem.Global.Register(DialogEventEnum.OptionSelected, AddDialogueLine); EnumEventSystem.Global.Register(EventEnum.NextYarn, CleanDialogHistory); - EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, CleanDialogHistory); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, CleanDialogHistory); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionPaused, CloseCanvas); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionResumed, OpenCanvas); } public override void OnSingletonDestroy() @@ -33,7 +35,9 @@ namespace AibisDream EnumEventSystem.Global.UnRegister(DialogEventEnum.LineStart, AddDialogueLine); EnumEventSystem.Global.UnRegister(DialogEventEnum.OptionSelected, AddDialogueLine); EnumEventSystem.Global.UnRegister(EventEnum.NextYarn, CleanDialogHistory); - EnumEventSystem.Global.UnRegister(GameLoopEnum.GameQuit, CleanDialogHistory); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, CleanDialogHistory); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionPaused, CloseCanvas); + EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionResumed, OpenCanvas); } private void AddDialogueLine(LineInfo line) diff --git a/Assets/Scripts/UI/Panel/EndPanel.cs b/Assets/Scripts/UI/Panel/EndPanel.cs index c23e94feb..9eb68ad6c 100644 --- a/Assets/Scripts/UI/Panel/EndPanel.cs +++ b/Assets/Scripts/UI/Panel/EndPanel.cs @@ -39,7 +39,7 @@ namespace AibisDream.UI private void BackToMain() { UIManager.Instance.HidePanel(); - GameManager.Instance.QuitGame(); + GameManager.Instance.TryReturnToMainMenu(); } // private void OpenFeedbackLink() @@ -52,4 +52,4 @@ namespace AibisDream.UI // Application.OpenURL(ConstRef.BugSurveyURL); // } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/UI/Terminal/InGameTerminalPanel.cs b/Assets/Scripts/UI/Terminal/InGameTerminalPanel.cs index e872cc656..2ff2c3bfb 100644 --- a/Assets/Scripts/UI/Terminal/InGameTerminalPanel.cs +++ b/Assets/Scripts/UI/Terminal/InGameTerminalPanel.cs @@ -145,7 +145,7 @@ namespace AibisDream.UI public void CloseToMainMenu() { Close(restoreAmb: false); - GameManager.Instance.QuitGame(); + GameManager.Instance.TryReturnToMainMenu(); } public void SetHeader(string title, string status = "") @@ -165,12 +165,12 @@ namespace AibisDream.UI private void PauseIfNeeded() { - if (GameManager.Instance == null || !GameManager.Instance.state.isInGame || _pausedByPanel) + if (GameManager.Instance == null || !GameManager.Session.CanPause || _pausedByPanel) { return; } - GameManager.Instance.PauseGame(); + GameManager.Instance.TryPause(); TerminalUIAudio.NotifyPauseMenuAmbEnter(); _pausedByPanel = true; } @@ -182,7 +182,7 @@ namespace AibisDream.UI return; } - GameManager.Instance.ContinueGame(); + GameManager.Instance.TryResume(); TerminalUIAudio.NotifyPauseMenuAmbExit(restoreAmb); _pausedByPanel = false; } diff --git a/Assets/Scripts/UI/Terminal/TerminalPanel.cs b/Assets/Scripts/UI/Terminal/TerminalPanel.cs index 3536ad280..66d0dbadf 100644 --- a/Assets/Scripts/UI/Terminal/TerminalPanel.cs +++ b/Assets/Scripts/UI/Terminal/TerminalPanel.cs @@ -259,7 +259,7 @@ namespace AibisDream.UI public void CloseToMainMenu() { Close(); - GameManager.Instance.QuitGame(); + GameManager.Instance.TryReturnToMainMenu(); } public void SetHeader(string title, string status = "") diff --git a/Assets/Scripts/UI/Terminal/TerminalStartPanel.cs b/Assets/Scripts/UI/Terminal/TerminalStartPanel.cs index 84358440e..a75160948 100644 --- a/Assets/Scripts/UI/Terminal/TerminalStartPanel.cs +++ b/Assets/Scripts/UI/Terminal/TerminalStartPanel.cs @@ -166,11 +166,11 @@ namespace AibisDream.UI var terminal = UIManager.Instance.GetPanel(); if (terminal != null && terminal.IsOpen) { - terminal.CloseThen(() => GameManager.Instance.StartWithSlot(SlotIndex.Auto)); + terminal.CloseThen(() => GameManager.Instance.TryRestoreSlot(SlotIndex.Auto)); return; } - GameManager.Instance.StartWithSlot(SlotIndex.Auto); + GameManager.Instance.TryRestoreSlot(SlotIndex.Auto); } private static void StartNewGame() @@ -178,11 +178,11 @@ namespace AibisDream.UI var terminal = UIManager.Instance.GetPanel(); if (terminal != null && terminal.IsOpen) { - terminal.CloseThen(() => GameManager.Instance.StartNewGame()); + terminal.CloseThen(() => GameManager.Instance.TryStartNewGame()); return; } - GameManager.Instance.StartNewGame(); + GameManager.Instance.TryStartNewGame(); } private void OpenChapter() @@ -197,7 +197,7 @@ namespace AibisDream.UI private static void QuitApp() { - GameManager.Instance.QuitApp(); + GameManager.Instance.QuitApplication(); } } } diff --git a/Assets/Scripts/UI/UIManager.cs b/Assets/Scripts/UI/UIManager.cs index 62f05d44b..c6142ed5e 100644 --- a/Assets/Scripts/UI/UIManager.cs +++ b/Assets/Scripts/UI/UIManager.cs @@ -46,7 +46,7 @@ namespace AibisDream return; } - if (GameManager.Instance != null && GameManager.Instance.state.isInGame) + if (GameManager.Instance != null && GameManager.Session.CanPause) { GetPanel()?.Open(InGameTerminalPage.Setting); } @@ -85,9 +85,9 @@ namespace AibisDream panel.Initialize(); } - EnumEventSystem.Global.Register(GameLoopEnum.AppStart, OnAppStart); - EnumEventSystem.Global.Register(GameLoopEnum.GameStart, OnGameStart); - EnumEventSystem.Global.Register(GameLoopEnum.GameQuit, OnGameQuit); + EnumEventSystem.Global.Register(GameLifecycleEvent.ApplicationReady, OnAppStart); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionStarted, OnGameStart); + EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, OnGameQuit); } private void OnAppStart() diff --git a/Assets/Scripts/Utility/DevSaveJumpTool.cs b/Assets/Scripts/Utility/DevSaveJumpTool.cs index 4c10c3bf4..bf2115166 100644 --- a/Assets/Scripts/Utility/DevSaveJumpTool.cs +++ b/Assets/Scripts/Utility/DevSaveJumpTool.cs @@ -142,10 +142,27 @@ namespace AibisDream details = string.Empty; RestoreResult result = null; - yield return SaveRestoreOrchestrator.RestoreFromFile( + var completed = false; + var accepted = GameManager.Instance.TryRestoreFile( entry.SnapshotPathWithoutExtension, RestoreOptions.DevJump, - value => result = value); + value => + { + result = value; + completed = true; + }); + + if (!accepted) + { + status = $"跳转失败:{entry.Label}(当前存在其他会话命令)"; + isJumping = false; + yield break; + } + + while (!completed) + { + yield return null; + } if (result?.Success == true) { @@ -181,14 +198,20 @@ namespace AibisDream status = $"冒烟测试 {i + 1}/{entries.Count}:{entry.Label}"; RestoreResult result = null; var completed = false; - var routine = StartCoroutine(SaveRestoreOrchestrator.RestoreFromFile( + var accepted = GameManager.Instance.TryRestoreFile( entry.SnapshotPathWithoutExtension, RestoreOptions.DevJump, value => { result = value; completed = true; - })); + }); + + if (!accepted) + { + failures.Add($"{entry.Label}: 当前存在其他会话命令"); + break; + } const float timeoutSeconds = 60f; var elapsed = 0f; @@ -200,7 +223,7 @@ namespace AibisDream if (!completed) { - StopCoroutine(routine); + GameManager.Instance.TryReturnToMainMenu(); failures.Add($"{entry.Label}: 超过 {timeoutSeconds:0} 秒"); break; } @@ -377,11 +400,6 @@ namespace AibisDream try { - if (SnapshotPersistence.IsLegacyFormat(SnapshotPathWithoutExtension)) - { - Error = "不支持旧格式存档"; - return; - } Snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension); } catch (Exception ex) diff --git a/Assets/Yarn/Fiction/Fiction_Day1_mid/Fiction_Day1_mid.yarn b/Assets/Yarn/Fiction/Fiction_Day1_mid/Fiction_Day1_mid.yarn index 87b89eccc..4dc83fb2a 100644 --- a/Assets/Yarn/Fiction/Fiction_Day1_mid/Fiction_Day1_mid.yarn +++ b/Assets/Yarn/Fiction/Fiction_Day1_mid/Fiction_Day1_mid.yarn @@ -441,22 +441,7 @@ position: 250,200 <> <> ->回到诊室 #line:0ffae52 - <> - <> - <> - <> - <> - // <> - <> - <> - <> - <> - // <> - <> - <> - //感谢游玩 爱与机器人维修技术 ,本次demo的体验流程已结束。 #line:0cd82d0 - //<> - <> + <> <> ->自动售货机 #line:016adaa 【诊室门口平平无奇的自动售货机】 #line:0ca665d @@ -466,7 +451,29 @@ position: 250,200 <> ->四处逛逛 #line:00b2a01 <> -<> +=== + +title: 返回诊室并进入下一章 +tags: no_save +colorID: 8 +position: 500,200 +--- +<> +<> +<> +<> +<> +// <> +<> +<> +<> +<> +// <> +<> +<> +//感谢游玩 爱与机器人维修技术 ,本次demo的体验流程已结束。 #line:0cd82d0 +//<> +<> === @@ -593,4 +600,4 @@ position: 250,200 <> <> <> -=== \ No newline at end of file +=== diff --git a/Assets/Yarn/Fiction/Fiction_Day2_mid/Day2_mid.yarn b/Assets/Yarn/Fiction/Fiction_Day2_mid/Day2_mid.yarn index ef5f90025..fc121a844 100644 --- a/Assets/Yarn/Fiction/Fiction_Day2_mid/Day2_mid.yarn +++ b/Assets/Yarn/Fiction/Fiction_Day2_mid/Day2_mid.yarn @@ -79,7 +79,6 @@ position: 250,200 <> <> <> -<> === diff --git a/Docs/GameManagerRefactorPlan.md b/Docs/GameManagerRefactorPlan.md new file mode 100644 index 000000000..cc9a58914 --- /dev/null +++ b/Docs/GameManagerRefactorPlan.md @@ -0,0 +1,204 @@ +# GameManager 收敛式重构说明 + +> 状态:已按修订方案实现 +> +> 更新日期:2026-07-17 + +## 1. 最终职责边界 + +| 组件 | 职责 | +| --- | --- | +| `GameManager` | 唯一会话命令入口;编排开始、章节跳转、读档、暂停、返回主界面和退出应用 | +| `GameSession` | GameManager 持有的纯状态对象;保存状态、校验迁移并计算能力 | +| `TalkSceneGraphIndex` | 从首章图派生运行时章节列表和恢复名称索引 | +| `SceneLoader` | 串行执行 Addressables 场景加载、卸载和场景级资源清理 | +| `SceneReadiness` | 在当前加载 Scene 内收集 `ISceneDialogueGate` 并等待就绪 | +| `SaveRestoreOrchestrator` | 存档预检和 Snapshot 内部恢复阶段 | +| `ChapterController` | 章节解锁数据及章节面板投影 | +| `ApplicationBootstrapper` | 应用设置、SnapshotRegistry、本地化及主界面启动流程 | +| `DisplaySettingsController` | 窗口模式和分辨率应用,并发送显示变化通知 | + +没有引入 `NarrativeFlowController`、人工维护的 `TalkSceneCatalog` 或独立全局 Session 服务。`TalkSceneSO`、`SceneExit`、章节资产和 Chapter Graph Editor 数据结构保持不变。 + +## 2. GameSession + +`GameSessionPhase`: + +```text +MainMenu +Starting +Transitioning +Restoring +Playing +ReturningToMenu +``` + +只读状态: + +- `Phase` +- `CurrentTalkScene` +- `IsPaused` +- `IsDialogueActive` +- `IsActive` +- `IsBusy` +- `CanPause` +- `CanResume` +- `CanAdvanceDialogue` + +外部通过 `GameManager.Session` 读取;GameManager 内部直接使用 `_session`。所有写入方法均为 `internal`,GameSession 不执行协程、不操作 UI、不加载场景,也不发送全局事件。 + +合法迁移: + +```text +MainMenu → Starting / Restoring +Playing → Transitioning / Restoring / ReturningToMenu +Starting / Transitioning → Playing / ReturningToMenu +Restoring → Playing / MainMenu / ReturningToMenu +ReturningToMenu → MainMenu +``` + +## 3. 生命周期事件 + +`GameLoopEnum` 已删除,统一使用: + +```csharp +public enum GameLifecycleEvent +{ + ApplicationReady, + SessionStarted, + SessionReady, + SessionPaused, + SessionResumed, + SessionEnding, + SessionEnded, + SessionFailed +} +``` + +显示设置通知使用 `SettingChangeEvent.DisplayChanged`。 + +事件顺序固定为:GameManager 执行命令 → GameSession 完成状态迁移 → GameManager 发送生命周期通知。监听方不能反向修改 Session。 + +`DialogController.DialogueActivityChanged(bool)` 是 GameManager 更新对话活动状态的直接 C# 事件;原有对话表现事件继续供其他系统使用。 + +## 4. GameManager API + +```csharp +public bool TryStartNewGame(); +public bool TryStartChapter(TalkSceneSO chapter); +public bool TryRestoreSlot(int slotIndex, Action completed = null); +public bool TryRestoreFile(string path, RestoreOptions options, + Action completed = null); +public bool TryPause(); +public bool TryResume(); +public bool TryReturnToMainMenu(); +public void QuitApplication(); +public IReadOnlyList RuntimeChapters { get; } +``` + +开始、转场和恢复共用一个活动命令锁,重复请求直接返回 `false`。返回主界面请求可以在活动命令期间排队;当前不可中断的 Addressables 或 Provider 单步结束后,流程会在阶段边界停止,并进入唯一的返回主界面协程。 + +Yarn 场景命令通过以下内部可等待入口执行;章节交接是例外,由源 Runner 同步提交后交给 GameManager 独立执行: + +```csharp +internal bool TryAdvanceChapterFromYarn(string exitName); +internal IEnumerator LoadSceneFromYarn(string sceneName); +internal IEnumerator UnloadSceneFromYarn(); +``` + +`NextYarn` 是同步交接命令:它只向 GameManager 提交章节跳转并立即返回,不等待转场,也不在自身命令回调中停止 DialogueRunner。`NextYarn` 必须是当前分支最后一条可执行命令;GameManager 只等待源节点自然结束,超时视为内容错误并终止转场,不兼容后续选项、`jump` 或其他命令。`load_scene` / `unload_scene` 不替换 YarnProject,因此继续由 Yarn 等待完成。 + +旧 GameManager 状态字段、接口和命令 API 已删除,没有保留兼容转发层。 + +## 5. 章节索引 + +`TalkSceneGraphIndex` 从 `firstTalkSo` 开始,按 `exits` 顺序稳定深度优先遍历,并用 visited 集合处理环: + +- 可达节点进入 `RuntimeChapters` 和名称索引。 +- 同名不同资产会被标记为歧义,恢复预检失败。 +- `ChapterController` 直接投影 `RuntimeChapters`。 + +这份索引完全从现有创作数据派生,不引入第二份章节目录。 + +## 6. 场景加载与 Readiness + +`SceneLoader` 使用 `AsyncOperationHandle`,加载和卸载均返回结构化 `SceneOperationResult`: + +- Busy、空 Key、Addressables 失败和无效 SceneInstance 均明确失败。 +- 仅成功后提交当前场景名、Scene 和句柄。 +- 卸载失败保留当前场景句柄与名称。 +- Loading UI 与 `IsLoading` 在 `finally` 中复位。 +- DOTween、Fix 注册表和 `ResourceSystem` 场景级生命周期统一由 SceneLoader 清理。 + +`SceneReadiness` 在刚加载的 Scene 根对象内查找 Gate,使用 unscaled time,默认超时 15 秒。无 Gate 时等待稳定一帧后成功;Gate 销毁、取消或超时返回失败。普通章节加载在 SceneLoader 后等待;读档在 Provider 后、Anchor 前等待。 + +## 7. 恢复边界 + +```text +PrepareRestore +→ 读槽位或文件 +→ schema、场景、章节、YarnProject 和可选节点预检 +→ 解析目标 TalkSceneSO +→ 不修改运行时 + +ExecutePreparedRestore +→ Yarn variables +→ Scene +→ Providers +→ SceneReadiness +→ Anchor +→ Postflight +``` + +说明:显式 `<>` 可以生成无节点 anchor 的新格式快照。这类快照仍预检章节和 YarnProject,但跳过节点存在性校验,并在恢复时只加载 YarnProject、不重进节点。 + +GameManager 管理命令锁、Session Phase、遮罩、旧会话表现清理、成功提交和失败决策。Orchestrator 不再查找或设置 GameManager 的当前章节。 + +恢复成功后,GameManager 提交 `RestoreResult.RestoredTalkScene`、解锁章节、进入 `Playing` 并发送 `SessionReady`。预检失败恢复发起前的稳定 Phase;执行失败停止后续 Anchor 并排队安全返回主界面。已接受请求的回调只调用一次。 + +存档文件统一经过当前快照反序列化和 schema 预检,不保留旧 StorageSystem 格式的专用识别、恢复或转写分支。 + +## 8. 核心流程 + +新游戏 / 选章: + +```text +校验章节 → 获取命令锁 → Starting → SessionStarted → 遮黑 +→ SceneLoader → SceneReadiness → 提交并解锁章节 → 启动 Yarn +→ Playing → SessionReady → 揭示画面 +``` + +章节跳转: + +```text +解析出口 → Transitioning → 遮黑 → SceneLoader → SceneReadiness +→ 提交并解锁章节 → 启动 Yarn → Playing → SessionReady → 揭示画面 +``` + +读档: + +```text +获取命令锁 → Restoring → PrepareRestore → SessionStarted → 遮黑 +→ 清理旧会话表现 → ExecutePreparedRestore → 提交目标章节 +→ Playing → SessionReady → 揭示画面 +``` + +返回主界面: + +```text +ReturningToMenu → SessionEnding → 恢复 timeScale / 清除暂停 → 遮黑 +→ 等待对话停止 → 卸载场景 → 清理表现和相机 +→ 清除章节与对话状态 → MainMenu → SessionEnded → 揭示主界面 +``` + +## 9. 验证基线 + +静态验收应确认: + +- 不存在 `GameLoopEnum`、`GameManager.state`、旧 GameManager API 或 `CleanSessionFirst`。 +- 输入只读取 `CanAdvanceDialogue`;暂停入口只读取 `CanPause`。 +- 开始、转场、恢复和 Yarn 场景命令都经过 GameManager 互斥。 +- Snapshot 恢复不再通过 GameManager 查找或设置章节。 +- 章节面板和存档恢复都只使用从首章可达的正式章节。 + +运行时冒烟仍需覆盖新游戏、选章、每类新格式存档、Gate 成功/超时、活动命令期间返回主界面,以及退出后场景、对话、暂停、音频、相机和表现 UI 清理。 diff --git a/Docs/存档系统设计方案.md b/Docs/存档系统设计方案.md index a9ad32683..c52b788d1 100644 --- a/Docs/存档系统设计方案.md +++ b/Docs/存档系统设计方案.md @@ -8,7 +8,7 @@ - 需求来源:`Docs/存档系统需求.md` - 当前阶段:**P1 快照层、P2 槽位/落盘基础版、P3 可存点判定基础版、P4 读档编排基础版已落地**。原 `StorageSystem` 已重命名为 `YarnVariableStorage` 并完成职责拆分;FixStateMachine 接口与 DTO 已接入;自动档 / 手动档槽位、sidecar、缩略图、原子写、latest_slot 与 P1 测试档迁移已接入;Yarn `onNodeStart` 已触发自动存档判定与写盘;读档已切换为 Provider sync/async 契约 + Phase/Barrier 编排。 -- 最近更新:2026-06-24(废弃 `deepRepair` 段与 `IData`/`DataContainer`;维修子模块统一经 sections Provider;Legacy 旧档仅迁移 Yarn 变量)。 +- 最近更新:2026-07-17(读档入口收敛到 `GameManager`;恢复拆分为预检与执行;Legacy 旧档停止运行时恢复)。 --- @@ -98,10 +98,10 @@ | 类 | 路径 | 职责 | | --- | --- | --- | | `YarnVariableStorage` | `Game Loop/YarnVariableStorage.cs` | 仅 Yarn `$` / `$global_` 变量读写 | -| `SnapshotService` | `SaveSystem/SnapshotService.cs` | `Capture()` / `Restore()`,不涉及文件 | +| `SnapshotService` | `SaveSystem/SnapshotService.cs` | `Capture()`,不涉及文件 | | `SnapshotPersistence` | `SaveSystem/SnapshotPersistence.cs` | JSON 序列化 / 反序列化;P1 测试路径兼容;旧档格式检测 | | `SlotManager` / `SlotDirectory` | `SaveSystem/SlotManager.cs` / `SaveSystem/SlotFileSystem.cs` | P2 槽位业务、sidecar、缩略图、latest_slot、原子写、P1 测试档迁移 | -| `SaveRestoreOrchestrator` | `SaveSystem/SaveRestoreOrchestrator.cs` | `AutoSaveRoutine()` / `CreateManualSlot()` / `RestoreFromSlot()` / `RestoreFromFile()`;保存 UI;legacy 分支 | +| `SaveRestoreOrchestrator` | `SaveSystem/SaveRestoreOrchestrator.cs` | 保存入口;读档预检与 Snapshot 内部执行阶段;不拥有会话状态 | | `SnapshotCapture` / `SnapshotRestore` | `SaveSystem/` | 快照组装与逐项 provider 还原 | | `SnapshotSerializer` | `SaveSystem/` | schemaVersion、稳定 SaveId key | | `SnapshotRegistry` + `Providers/*` | `SaveSystem/` | 表现层 + 维修子模块 Provider(env / actor / fix / bodyModule …) | @@ -112,8 +112,8 @@ | --- | --- | | Yarn `onNodeStart` 自动存档 | `SavePointEvaluator.CanAutoSave()` → `SaveRestoreOrchestrator.AutoSaveRoutine(nodeName)` | | 手动存档 | `SaveRestoreOrchestrator.CreateManualSlot(slotIndex)`(复制最近自动档) | -| 槽位读档 | `SaveRestoreOrchestrator.RestoreFromSlot(slotIndex)` | -| 文件读档(旧档 / 调试) | `SaveRestoreOrchestrator.RestoreFromFile(path)` | +| 槽位读档 | `GameManager.Instance.TryRestoreSlot(slotIndex, completed)` | +| 文件读档(调试) | `GameManager.Instance.TryRestoreFile(path, options, completed)` | | 仅捕获内存快照 | `SnapshotService.Capture()` | | Yarn 变量 | `YarnVariableStorage.Instance.SetValue / TryGetValue` | @@ -123,7 +123,7 @@ ### 4.1 读档还原编排与 Provider 契约(重要) -> **勿将 P1 代码形态当作终态架构。** 当前 `ISnapshotProvider.Restore` 统一返回 `IEnumerator`,且 `SnapshotRestore` 对每个 Provider 做 `yield return`,这是受旧 `IData.Load()` 影响的**临时 scaffolding**,容易误导后续 P4/P5 规划。本节描述终态模型;P4 实施前应据此 refactor 编排层与 Provider 契约。 +当前实现已经采用同步 / 异步 Provider 显式契约,并由 `GameManager` 在会话命令外层调用 `PrepareRestore` 与 `ExecutePreparedRestore`。 #### 终态模型:Phase + Barrier,而非协程链 @@ -170,28 +170,22 @@ Phase 4 Barrier(P4:淡入淡出等演出时序) - 表示**同 Phase 内的建议顺序或软依赖**(如 scene 必须先于 env/actor),**不是**「每步之间必须 `yield return`」。 - 硬依赖应通过 **Phase 划分 + Barrier** 表达,而不是无限细化 RestoreOrder 数字。 -- scene 加载(Phase 1 Barrier)必须在 Phase 2 之前完成;设置章节 SO(Phase 1.5)必须在 RestoreAnchor 之前完成;env / actor / audio / timeline / fix / screen 之间目前无硬依赖,Phase 2 内一批执行即可。 +- scene 加载(Phase 1 Barrier)必须在 Phase 2 之前完成;目标章节已在 PrepareRestore 中解析并放入 `SnapshotRestoreContext`;env / actor / audio / timeline / fix / screen 之间目前无硬依赖,Phase 2 内一批执行即可。 -#### Provider 契约:终态 vs P1 临时形态 +#### Provider 契约(当前实现) -| | P1 临时形态(当前代码) | 终态(P4 前 refactor 目标) | +| 类型 | 契约 | 使用规则 | | --- | --- | --- | -| 同步还原 | `IEnumerator Restore` + `yield break` | `void Restore(object dto)` | -| 异步还原 | 同上(仅 scene 真正 yield) | `IAsyncSnapshotRestore.RestoreAsync(object dto)` 或等价显式接口 | -| Manager 层 | 部分 `IEnumerator RestoreSnapshot` 仅 `yield break` | 默认 `void RestoreSnapshot`;仅真有 async 处保留 `IEnumerator` | -| 编排层 | `foreach` 逐步 `yield return provider.Restore` | Phase 编排 + 仅对 Barrier 步骤 `yield` | +| 同步还原 | `ISyncSnapshotProvider.Restore(object dto, context)` | 同一 Phase 内按 RestoreOrder 连续调用 | +| 异步还原 | `IAsyncSnapshotProvider.RestoreAsync(object dto, context)` | 必须把完整等待过程交给编排层 | +| 编排层 | `SnapshotRestore` + `SaveRestoreOrchestrator` | Phase 编排,仅对异步 Provider、Scene、Readiness、Anchor 等 Barrier `yield` | 新增可存子系统时:**默认实现 sync `Restore`**;仅当存在必须等待的加载/状态切换时,才实现 async 接口并向编排层**上报**可等待句柄,不得在 Provider 内私自 `StartCoroutine` 而不纳入 Barrier。 -#### 已知偏离(tech debt,非推荐 pattern) - -- `DirectorHandler.RestoreSnapshotEntry` 在 Addressable 路径下内部 `StartCoroutine(RestoreAtEndFromAddressable)`,Provider 已返回,编排层无法感知完成——与 D6 冲突。P4 编排时应改为**可等待**的还原路径,或纳入 Phase 2′ Barrier。 -- 各 Manager 的 `RestoreSnapshot` 声明为 `IEnumerator` 但内部仅 `yield break`——属 `IData.Load()` 惯性,终态应收回到 `void`。 - #### 对后续阶段的影响 -- **P2 / P3**:可不改动 `SnapshotRestore`;但新增代码**不应**再复制「全 Provider 协程链」模式。 -- **P4**:在 `SaveRestoreOrchestrator` / `SnapshotRestore` 上按本节 Phase 模型重写编排;顺带 refactor Provider 契约。P4 是「加 fade UI」+「修正编排模型」,而非在现有 foreach 链首尾叠 UI。 +- **P2 / P3**:新增代码不得复制「全 Provider 协程链」模式。 +- **P4**:当前已完成 Prepare / Execute 边界、Provider 契约、SceneReadiness 与 GameManager 会话编排;后续主要补 Play Mode 存档样本验证。 - **P5**:深度维修子模块与其他子系统一样经 sections Provider 扩展,**不应**再假设独立 Phase 或 `deepRepair` 根字段;是否独立 Phase 仅由具体 Provider 的 async 需求决定。 --- @@ -233,10 +227,10 @@ P7 横切 - `CloudSaveManager` / `ICloudSaveBackend`:仅保留云存档扩展点,未接 Steam 后端与冲突处理。 - 手动档 = 拷贝最近自动快照,符合需求约定。 -仍待补齐: +当前接入: -- 玩家 UI 尚未接入 `SlotManager.GetSlotViewModels()`;旧 `SavesPanel` / `SaveFileUIController` 仍按 `StreamingAssets/SaveFiles/*.json` 或旧文件列表工作。 -- 「继续游戏」尚未统一改为 `SlotManager.GetLatestSlotIndex()` → `GameManager.StartWithSlot()`;`ChapterController` 仍按旧扁平 JSON 文件查找。 +- `SavesPanel` / `SaveFileUIController` 使用 `SlotManager.GetSlotViewModels()`,并通过 `GameManager.TryRestoreSlot()` 发起恢复。 +- 「继续游戏」使用 `SlotManager.GetLatestSlotIndex()`,由 `ChapterController` 转交 `GameManager.TryRestoreSlot()`。 - 缩略图规格暂按 640×360 实装,是否满足正式 UI 仍需 P6 验证。 ### P3 可存点判定层(基础版已落地) @@ -246,7 +240,7 @@ P7 横切 - **tag 规则**:`hub` / `linear` / `content` 默认允许;`start` / `init` / `function` / `detour` / `center` / `performance` / `event` / `end` 默认禁止;`no_save` 覆盖一切并禁止 **OnNodeStart 自动存**。 - 自动档边界 = **Fresh OnNodeStart** 且 tag 判定通过且非 `DetourResume`(不区分 hub / content / linear 的额外终身去重)。 - Yarn `<>`(`SaveYarnCommand`)走 `CanExplicitSave`:仅全局门控,默认 omit anchor;用于节点末尾进入无对话 / Fix 交互等边界。 -- `SaveRestoreOrchestrator.IsRestoring` 与 `GameManager.state.isInPause` 会阻止自动保存与显式 save,避免读档中覆盖自动档。 +- `SaveRestoreOrchestrator.IsRestoring` 与 `GameManager.Session.IsPaused` 会阻止自动保存与显式 save,避免读档中覆盖自动档。 - `CreateManualSlot` 复用 `CanManualSave`:`slot_0` 存在且通过全局门控即可复制(含显式 `<>` 写盘后的手动档)。 仍待补齐: @@ -257,7 +251,7 @@ P7 横切 ### P4~P7 -- P4:基础版已落地。`ISnapshotProvider` 已拆为捕获契约 + `ISyncSnapshotProvider` / `IAsyncSnapshotProvider`;`SnapshotRestore` 已改为 Phase + Barrier;`SaveRestoreOrchestrator` 已接入读档黑屏、自动存档抑制与 restore 日志;Timeline Addressable 恢复已改为可等待路径。 +- P4:已落地。`ISnapshotProvider` 已拆为捕获契约 + `ISyncSnapshotProvider` / `IAsyncSnapshotProvider`;读档由 `GameManager` 管理会话、遮罩和失败决策,`SaveRestoreOrchestrator` 负责预检与 Snapshot 内部 Phase + Barrier;Provider 后增加 SceneReadiness;所有文件统一按当前 schema 预检,不保留旧格式专用分支。 - P5(部分已落地):`punchTape`/`fix`/`bodyModule`/`eye` 已迁入 sections;BlockPuzzle / Memory / Cutting 等待新增 Provider;`isSystemOn`/`currentRepairSystemType` Restore 仍延后。 - P6:接入正式存 / 读档 UI、继续游戏、新游戏覆盖自动档、游戏中读档确认等玩家流程。 - P7:Steam 云存档真实后端、版本兼容策略、全局 / 跨周目数据边界等横切项。