using System.Collections.Generic; using System.Reflection; using Ichni.Story.UI; using UnityEngine; using UnityEngine.Events; using UnityEngine.Localization; using Yarn.Unity; using Yarn.Unity.UnityLocalization; namespace Ichni.Story.Dialogue { /// /// 故事树 block 与 Yarn 之间的桥接控制器。 /// 点击文本块 → 启动对应 Yarn 节点;对话结束 → 持久化剧情变量、标记 block 完成、 /// 重算解锁并存档、依次执行结束回调(例如歌曲解锁提示)。 /// public class StoryDialogueController : MonoBehaviour { public static StoryDialogueController instance; [Header("References")] [Tooltip("驱动 Yarn 对话的 DialogueRunner。")] public DialogueRunner dialogueRunner; [Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")] public StoryUIPage storyUIPage; [Header("Localization")] [Tooltip("所有章节 Yarn 台词共用的 Unity Localization String Table(建议命名 YarnLines)。留空时保留当前 UnityLocalisedLineProvider 的既有表配置。")] public LocalizedStringTable yarnLinesTable; /// /// 对话结束后依次执行并清空的回调队列。供 unlock_song 等 Yarn 命令 /// 把"解锁提示"排队到对话结束后弹出。 /// private readonly List _dialogueEndActions = new List(); // 当前正在播放对话的 block id;对话结束时据此标记完成 private string _activeBlockId; // 当前 TextBlock 开始前的章节快照。它只服务于本次“中途退出对话”的恢复, // 不会被写入 ES3,也不会影响歌曲成绩、解锁 Key、Settings 或其它章节。 private ChapterStorySave _activeDialogueSnapshot; private string _activeDialogueChapterIndex; private bool _isCancellingDialogue; /// /// 当前对话对应的 TextBlock ID。Yarn 选项记忆与变量指令使用它定位所属章节 Block, /// 不应由其它系统直接赋值。 /// public string ActiveBlockId => _activeBlockId; /// /// 当前是否存在尚未提交的 TextBlock 对话事务。 /// Yarn 的全局副作用(例如 grant_unlock、unlock_song、show_message)必须据此决定延迟到正常结束后执行, /// 从而避免玩家按 Back 时留下已解锁内容或孤立弹窗。 /// public bool IsDialogueTransactionActive => !string.IsNullOrEmpty(_activeBlockId) && GameSaveManager.instance?.StorySaveModule?.IsChapterTransactionActive(_activeDialogueChapterIndex) == true; private void Awake() { instance = this; EnsureUnityLocalisedLineProvider(); } private void Start() { // 这些引用不会由 StoryData 自检覆盖,因为它们属于场景运行时装配而非单个章节资产。 // 仅输出可定位 Warning,不阻断编辑器下的部分独立测试流程。 if (dialogueRunner == null) { Debug.LogWarning("[StoryDialogueController] 未配置 DialogueRunner;TextBlock 无法启动对话。", this); return; } if (dialogueRunner.GetComponent() == null) Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 VNDialoguePresenter;台词和选项不会显示。", dialogueRunner); if (dialogueRunner.GetComponent() == null) Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 UnityLocalisedLineProvider;本地化台词无法解析。", dialogueRunner); if (storyUIPage == null) Debug.LogWarning("[StoryDialogueController] 未配置 StoryUIPage;对话开始/结束时不会切换剧情树页面。", this); } private void OnEnable() { if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null) dialogueRunner.onDialogueComplete.AddListener(HandleDialogueComplete); } private void OnDisable() { if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null) dialogueRunner.onDialogueComplete.RemoveListener(HandleDialogueComplete); } private void OnDestroy() { if (instance == this) instance = null; } /// /// 播放某文本块对应的 Yarn 节点。若对话已在进行、引用缺失或节点名为空则忽略。 /// public void PlayBlock(TextBlockView block) { if (block == null) return; if (dialogueRunner == null) { Debug.LogError("[StoryDialogueController] 未配置 DialogueRunner,无法开始对话。"); return; } if (dialogueRunner.IsDialogueRunning) { Debug.LogWarning("[StoryDialogueController] 已有对话在进行中,忽略本次点击。"); return; } if (string.IsNullOrEmpty(block.YarnNodeName)) { Debug.LogWarning($"[StoryDialogueController] block '{block.blockId}' 未配置 yarnNodeName,无法开始对话。"); return; } // 将 DialogueRunner 的 YarnProject 对齐到当前章节(支持多章节共用一个 Runner) EnsureProjectForCurrentChapter(); // StartDialogue 对缺失 Node 只会在内部抛出/记录错误;必须在开启事务前预检, // 否则会留下一个没有机会提交或回滚的半开事务。 if (dialogueRunner.YarnProject?.Program == null || !dialogueRunner.YarnProject.Program.Nodes.ContainsKey(block.YarnNodeName)) { Debug.LogError($"[StoryDialogueController] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'。", dialogueRunner); return; } StoryTreeController treeController = StoryManager.instance?.treeController; StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule; string chapterIndex = treeController?.ActiveChapterIndex; if (treeController == null || saveModule == null || string.IsNullOrEmpty(chapterIndex)) { Debug.LogError("[StoryDialogueController] 当前章节存档上下文未就绪,拒绝开始可回滚的 TextBlock 对话。"); return; } // 必须在任何 Yarn 命令、选项或 Marker 快照写入前完成复制并开启事务。 // 事务期间所有剧情变化仅停留在内存;正常结束才提交,中途 Back 则完整恢复本副本。 _activeDialogueSnapshot = StorySaveCloneUtility.CloneChapter(saveModule.GetChapter(chapterIndex)); _activeDialogueChapterIndex = chapterIndex; if (!saveModule.BeginChapterTransaction(chapterIndex)) { _activeDialogueSnapshot = null; _activeDialogueChapterIndex = null; return; } _activeBlockId = block.blockId; // 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。 StoryProgress.BeginBlockMutation(_activeBlockId); if (storyUIPage != null) storyUIPage.FadeOut(); // 启动异常会回滚本次章节事务;正常结束仍由 onDialogueComplete 统一提交。 StartDialogueSafely(block.YarnNodeName).Forget(); } /// /// 以受保护方式启动 Yarn。缺失 Provider、Presenter 等启动级异常不应让 StorySave 的事务永久处于活动状态, /// 因而会被转换为 Failed 流程并完整恢复进入对话前的章节快照。 /// private async YarnTask StartDialogueSafely(string yarnNodeName) { try { await dialogueRunner.StartDialogue(yarnNodeName); } catch (System.Exception exception) { Debug.LogException(exception, dialogueRunner); // 启动阶段可能已经让部分 Presenter 进入展示状态;先请求停止,再立即清理事务。 // 后续若收到 Runner 的完成事件,会因 activeBlockId 已被清空而被安全忽略。 if (dialogueRunner != null && dialogueRunner.IsDialogueRunning) { dialogueRunner.GetComponent()?.CancelCurrentPresentation(); dialogueRunner.Stop().Forget(); } RestoreInterruptedDialogue("启动 Yarn 对话失败"); } } /// /// 取消当前 TextBlock 对话并恢复进入前的章节剧情快照。 /// Yarn 的 仍会触发 onDialogueComplete; /// 因此此处先标记取消状态,完成回调会走恢复流程而不是误将 Block 标记为 Completed。 /// public bool CancelActiveDialogue() { if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId) || dialogueRunner == null || !dialogueRunner.IsDialogueRunning) { return false; } _isCancellingDialogue = true; _dialogueEndActions.Clear(); dialogueRunner.GetComponent()?.CancelCurrentPresentation(); dialogueRunner.Stop().Forget(); return true; } /// /// 将一个全局副作用排入“本次 TextBlock 成功提交后”执行的队列。 /// 没有活动对话事务时会立即执行,便于未来在非 TextBlock 环境复用同一批 Yarn 命令。 /// public void ExecuteAfterDialogueCommit(UnityAction action) { if (action == null) return; if (IsDialogueTransactionActive) { _dialogueEndActions.Add(action); return; } action.Invoke(); } // 将 DialogueRunner 的 YarnProject 切换为当前已构建章节的 StoryData.yarnProject(若不同且未在运行) private void EnsureProjectForCurrentChapter() { StoryTreeController tree = StoryManager.instance != null ? StoryManager.instance.treeController : null; StoryData data = tree != null ? tree.ActiveStoryData : null; if (data == null || data.yarnProject == null) return; if (dialogueRunner.YarnProject != data.yarnProject && !dialogueRunner.IsDialogueRunning) dialogueRunner.SetProject(data.yarnProject); } /// /// DialogueRunner.lineProvider 为 [SerializeReference] internal 字段。 /// Inspector 的 "Use Unity Localised Line Provider" 按钮有时无法将 MonoBehaviour /// 引用正确持久化到该字段;此方法在运行时用反射补救,确保使用正确的 Provider。 /// private void EnsureUnityLocalisedLineProvider() { if (dialogueRunner == null) return; FieldInfo field = typeof(DialogueRunner).GetField( "lineProvider", BindingFlags.NonPublic | BindingFlags.Instance); if (field == null) return; UnityLocalisedLineProvider provider = field.GetValue(dialogueRunner) as UnityLocalisedLineProvider; provider ??= dialogueRunner.GetComponent(); if (provider != null) { if (field.GetValue(dialogueRunner) != provider) { field.SetValue(dialogueRunner, provider); Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider."); } // Yarn 的 Localized Line Provider 本身只持有一个 String Table 引用;统一使用 YarnLines // 可避免在切换章节时依赖反射替换不同表。未配置时不覆盖旧场景,便于分步迁移现有 Chapter0_Lines。 if (yarnLinesTable != null && !yarnLinesTable.IsEmpty) { FieldInfo tableField = typeof(UnityLocalisedLineProvider).GetField( "stringsTable", BindingFlags.NonPublic | BindingFlags.Instance); tableField?.SetValue(provider, yarnLinesTable); } } else { Debug.LogWarning("[StoryDialogueController] UnityLocalisedLineProvider not found on DialogueRunner's GameObject. Localized line text will be unavailable."); } } private void HandleDialogueComplete() { // Failed 流程已经清理 activeBlockId 后,底层 Runner 仍可能随后送达完成事件;此时绝不能误提交。 if (string.IsNullOrEmpty(_activeBlockId)) return; if (_isCancellingDialogue) { RestoreCancelledDialogue(); return; } // 标记 Block 完成 → 重算解锁 → 保存完整章节容器(含变量、选项与回滚快照)。 if (!string.IsNullOrEmpty(_activeBlockId) && StoryManager.instance != null && StoryManager.instance.treeController != null) { StoryManager.instance.treeController.OnBlockCompleted(_activeBlockId); } StoryProgress.EndBlockMutation(_activeBlockId); _activeBlockId = null; // 所有本次 TextBlock 的变量、选项和完成状态至此才一次性写入 ES3。 GameSaveManager.instance?.StorySaveModule?.CommitChapterTransaction(_activeDialogueChapterIndex); ClearDialogueSessionState(); // 依次执行并清空结束回调(如歌曲解锁提示) if (_dialogueEndActions.Count > 0) { List pending = new List(_dialogueEndActions); _dialogueEndActions.Clear(); foreach (UnityAction action in pending) { action?.Invoke(); } // MessageUIPage 会在收到第一项消息/选项请求时自行开启统一队列与淡入, // 此处不能再次直接 FadeIn,否则会与当前弹窗流程重复播放页面动画。 } if (storyUIPage != null) { storyUIPage.FadeIn(); } DialogueHistory.Clear(); } /// /// 处理 Back 触发的 Yarn 停止回调:放弃运行期间的全部章节剧情改动, /// 并只刷新当前已实例化的 StoryTree,保留玩家拖动剧情树后的视口位置。 /// private void RestoreCancelledDialogue() { RestoreInterruptedDialogue("玩家中途退出对话"); } /// /// 取消和失败共用的事务回滚出口。该方法具有幂等性:无活动 Block 时直接返回, /// 因而底层 Yarn 在 Stop/异常后重复触发完成事件也不会把已回滚的数据再次提交。 /// private void RestoreInterruptedDialogue(string reason) { if (string.IsNullOrEmpty(_activeBlockId)) return; string interruptedBlockId = _activeBlockId; string chapterIndex = _activeDialogueChapterIndex; StoryTreeController treeController = StoryManager.instance?.treeController; Debug.LogWarning($"[StoryDialogueController] {reason},已回滚 TextBlock '{interruptedBlockId}' 的章节剧情事务。"); StoryProgress.EndBlockMutation(interruptedBlockId); GameSaveManager.instance?.StorySaveModule?.RollbackChapterTransaction( chapterIndex, _activeDialogueSnapshot); treeController?.ReloadActiveChapterProgress(); _dialogueEndActions.Clear(); DialogueHistory.Clear(); _activeBlockId = null; ClearDialogueSessionState(); storyUIPage?.FadeIn(); } private void ClearDialogueSessionState() { _activeDialogueSnapshot = null; _activeDialogueChapterIndex = null; _isCancellingDialogue = false; } } }