using System; using System.Collections.Generic; using Ichni.Menu; using Ichni.Story.UI; using Sirenix.OdinInspector; using UniRx; using UnityEngine; namespace Ichni.Story { /// /// 剧情系统协调器(单例)。持有故事树控制器、剧情页面与章节数据引用, /// 是外部系统(章节选择、存档等)与剧情系统交互的入口。 /// public class StoryManager : SerializedMonoBehaviour { public static StoryManager instance; [Header("References")] public StoryTreeController treeController; public StoryUIPage storyUIPage; public CharacterRegistry characterRegistry; [Tooltip("全章节共用的 Helper 对话数据。它独立于各章节 StoryData,避免重复配置相同的 Helper。")] public StoryHelperData helperData; /// /// 当前运行时实际使用的 Helper 数据。第一版始终由 初始化; /// 未来的 Helper 更换界面应通过 切换,而不要让各个 UI /// 直接保存自己的 Helper 引用。 /// private StoryHelperData _activeHelperData; /// 当前运行时生效的 Helper 数据。未被运行时覆盖时回退到 Inspector 默认数据。 public StoryHelperData ActiveHelperData => _activeHelperData != null ? _activeHelperData : helperData; /// /// 当前 Helper 被切换时触发。静态 Image、Spine 或 Live2D 的表现层均可订阅该事件刷新视觉, /// 而无需了解具体的更换 UI 实现。 /// public event Action ActiveHelperChanged; [Header("Chapter Data")] [Tooltip("章节索引 (chapterIndex) -> 该章节的 StoryData 资产")] public Dictionary storyDatas = new Dictionary(); private void Awake() { instance = this; _activeHelperData = helperData; } private void OnDestroy() { if (instance == this) instance = null; } /// /// 设置本次运行期间的当前 Helper。 /// 本方法暂不写入存档:当前没有 Helper 更换功能;后续选择界面完成后, /// 只需在其外层决定何时持久化 helperId,再调用本方法即可。 /// public void SetActiveHelper(StoryHelperData data) { if (data == null) { Debug.LogWarning("[StoryManager] 尝试切换到空的 StoryHelperData,已忽略。"); return; } if (_activeHelperData == data) return; _activeHelperData = data; ActiveHelperChanged?.Invoke(_activeHelperData); } /// /// 打开指定章节,构建其故事树。 /// public void OpenChapter(string chapterIndex) { treeController.BuildChapter(chapterIndex); } /// /// 根据章节索引获取对应的 StoryData,未找到返回 null。 /// public StoryData GetStoryData(string chapterIndex) { return storyDatas.TryGetValue(chapterIndex, out StoryData data) ? data : null; } /// /// 从剧情 SongBlock 打开选曲页,并把“来源 Block、目标歌曲、剧情树视口位置”写入 /// 运行时上下文。此处不完成 Block;只有玩家确认进入目标歌曲时才会写入剧情进度。 /// public bool TryOpenSongBlock(SongBlockView sourceBlock, ChapterSelectionUnit chapter, SongItemData song) { if (sourceBlock == null || chapter == null || song == null || treeController == null || treeController.content == null || storyUIPage == null || MenuManager.instance == null || MenuManager.instance.songSelectionUIPage == null || MenuManager.instance.songSelectionUIPage.songListController == null || InformationTransistor.instance == null) { Debug.LogWarning("[StoryManager] SongBlock 缺少打开选曲所需引用,已取消本次跳转。"); return false; } InformationTransistor.instance.storySongEntryContext.Begin( chapter.chapterIndex, sourceBlock.blockId, song.songName, treeController.content.anchoredPosition); // 强制歌曲只影响本次打开选曲页的初始焦点;原有章节歌曲/难度缓存仍然保留。 ChapterSelectionManager.instance.currentChapter = chapter; MenuManager.instance.songSelectionUIPage.songListController.RequestInitialSongSelection(song); storyUIPage.FadeOut(); MenuManager.instance.songSelectionUIPage.FadeIn(); return true; } /// /// 选曲页确认启动游戏时调用。只有章节和稳定歌曲 ID 都命中剧情入口目标时, /// 才将对应 SongBlock 标记为完成,并要求 GameScene 返回剧情页。 /// public void HandleSongSelectionConfirmed(ChapterSelectionUnit chapter, SongItemData song) { StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext; if (context == null || !context.IsTargetSong(chapter, song)) return; // isTried 已在 PrepareGameLaunch 中落盘;此处不再直接写 StorySave,避免 SongBlock // 同时拥有一次性选曲上下文与歌曲存档两套完成来源。 context.returnToStoryAfterGame = true; } /// /// 处理剧情入口进入选曲后的“返回”操作。返回时不重建故事树,只恢复离开前的 /// Content 位置与来源 Block 焦点;普通选曲返回则由调用方继续回到章节选择页。 /// public bool TryReturnFromSongSelection() { StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext; if (context == null || !context.isActive) return false; // SongSaves.isTried 在进入 GameScene 前已落盘。返回时立刻同步,确保后续 Block // 的解锁条件和 Timeline 状态基于持久化的“已尝试歌曲”记录重新计算。 treeController?.SynchronizeSongBlockAttempts(); RestoreStoryTreeViewport(context.sourceBlockId, context.storyTreeContentPosition); context.Clear(); return true; } /// /// GameScene 返回 MenuScene 后调用。此时 MenuManager 已依据 InformationTransistor /// 重建来源章节;下一帧恢复 Content 位置,确保 Canvas 布局和连接线已稳定。 /// public void RestoreStoryAfterGameReturn() { StorySongEntryContext context = InformationTransistor.instance?.storySongEntryContext; if (context == null || !context.isActive || !context.returnToStoryAfterGame) return; // SongSaves.isTried 在进入 GameScene 前已落盘。返回时立刻同步,确保后续 Block // 的解锁条件和 Timeline 状态基于持久化的“已尝试歌曲”记录重新计算。 treeController?.SynchronizeSongBlockAttempts(); RestoreStoryTreeViewport(context.sourceBlockId, context.storyTreeContentPosition); context.Clear(); } /// /// 在下一帧还原剧情树浏览位置,并使来源 Block 重新成为当前 Block。 /// 参数在调用前即被复制,因此即使运行时上下文随后清理,也不会丢失恢复数据。 /// private void RestoreStoryTreeViewport(string sourceBlockId, Vector2 contentPosition) { Observable.NextFrame().Subscribe(_ => { if (treeController == null || treeController.content == null) return; treeController.content.anchoredPosition = contentPosition; StoryBlockView sourceBlock = treeController.GetBlockView(sourceBlockId); if (sourceBlock != null) treeController.currentBlock = sourceBlock; }).AddTo(this); } /// /// 清除全部剧情存档(所有章节故事树、章节变量与由剧情授予的内容解锁 Key)。 /// [Button] public void ClearAllStorySave() { GameSaveManager.instance.StorySaveModule.ClearAllStoryline(); GameSaveManager.instance.UnlockSaveModule.ClearAllKeys(); } } }