using System.Collections; using System.Collections.Generic; using Ichni.Story.UI; using UnityEngine; using UnityEngine.Events; using UnityEngine.Localization.Tables; using UnityEngine.ResourceManagement.AsyncOperations; 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; /// /// 对话结束后依次执行并清空的回调队列。供 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; private Coroutine _dialogueStartupCoroutine; private const float DialogueTableLoadTimeoutSeconds = 30f; /// /// 当前对话对应的 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; } 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; Debug.Log($"[YarnDiag] StoryDialogueController.PlayBlock: block={block.blockId}, node={block.YarnNodeName}"); if (dialogueRunner == null) { Debug.LogError("[YarnDiag] [ERROR] 未配置 DialogueRunner,无法开始对话!"); return; } if (dialogueRunner.IsDialogueRunning || _dialogueStartupCoroutine != null) { Debug.LogWarning("[YarnDiag] [WARNING] 已有对话正在运行或启动,忽略本次点击!"); return; } if (string.IsNullOrEmpty(block.YarnNodeName)) { Debug.LogWarning($"[YarnDiag] [WARNING] block '{block.blockId}' 未配置 yarnNodeName,无法开始对话!"); return; } // 将 DialogueRunner 的 YarnProject 对齐到当前章节,并验证场景中持久化的 LineProvider 配置。 EnsureProjectForCurrentChapter(); if (!ValidateUnityLocalisedLineProvider()) { return; } Debug.Log($"[YarnDiag] YarnProject={dialogueRunner.YarnProject?.name}, NodesCount={(dialogueRunner.YarnProject?.Program != null ? dialogueRunner.YarnProject.Program.Nodes.Count : 0)}, LineProviderType={dialogueRunner.LineProvider.GetType().FullName}"); // StartDialogue 对缺失 Node 只会在内部抛出/记录错误;必须在开启事务前预检, // 否则会留下一个没有机会提交或回滚的半开事务。 if (dialogueRunner.YarnProject?.Program == null || !dialogueRunner.YarnProject.Program.Nodes.ContainsKey(block.YarnNodeName)) { Debug.LogError($"[YarnDiag] [ERROR] 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); // Unity Localization 的 AsyncOperationHandle 原生支持协程等待。 // 先在项目侧显式加载当前章节台词表,再启动 Yarn;不修改 Yarn Spinner Package, // 也不把 Unity Addressables Handle 转换为 YarnTask。 _dialogueStartupCoroutine = StartCoroutine(StartDialogueSafely(block.YarnNodeName)); } /// /// 使用 Unity Localization 的公开 API 加载当前章节台词表。 /// /// 在 Yarn Spinner 3.2.1 中是 no-op, /// 不能用来预载 String Table;因此这里直接等待 。 /// /// private IEnumerator StartDialogueSafely(string yarnNodeName) { LocalizationBootstrap.BeginInitialization(); while (!LocalizationBootstrap.IsReady && !LocalizationBootstrap.HasFailed) { LocalizationBootstrap.RefreshState(); yield return null; } if (!LocalizationBootstrap.IsReady) { FailDialogueStartup($"Localization 初始化失败:{LocalizationBootstrap.FailureReason}"); yield break; } StoryData storyData = StoryManager.instance?.treeController?.ActiveStoryData; if (storyData?.yarnLineStringTable == null || storyData.yarnLineStringTable.IsEmpty) { FailDialogueStartup( $"章节 '{storyData?.chapterIndex ?? ""}' 未配置 Yarn Line String Table。"); yield break; } AsyncOperationHandle tableOperation; //Debug.Log(storyData.yarnLineStringTable.GetTable()); try { // LocalizedStringTable 会自动使用 LocalizationSettings.SelectedLocale。 // 这与 UnityLocalisedLineProvider 后续读取台词时使用的 Locale 保持一致。 Debug.Log($"[YarnDiag] 开始加载 Yarn 台词表。Table={storyData.yarnLineStringTable.TableReference}, Locale={UnityEngine.Localization.Settings.LocalizationSettings.SelectedLocale?.Identifier.Code ?? ""}"); tableOperation = storyData.yarnLineStringTable.GetTableAsync(); } catch (System.Exception exception) { FailDialogueStartup("创建 Yarn 台词表加载操作失败。", exception); yield break; } Debug.Log( $"[YarnDiag] 开始加载章节台词表。Chapter={storyData.chapterIndex}, " + $"Table={storyData.yarnLineStringTable.TableReference}, " + $"Locale={UnityEngine.Localization.Settings.LocalizationSettings.SelectedLocale?.Identifier.Code ?? ""}"); float loadStartedAt = Time.realtimeSinceStartup; float nextProgressLogAt = loadStartedAt + 5f; while (!tableOperation.IsDone) { float elapsed = Time.realtimeSinceStartup - loadStartedAt; if (elapsed >= DialogueTableLoadTimeoutSeconds) { FailDialogueStartup( $"加载 Yarn 台词表超时。Table={storyData.yarnLineStringTable.TableReference}, " + $"Elapsed={elapsed:F1}s, Percent={tableOperation.PercentComplete:P0}"); yield break; } if (Time.realtimeSinceStartup >= nextProgressLogAt) { Debug.LogWarning( $"[YarnDiag] Yarn 台词表仍在加载。Elapsed={elapsed:F1}s, " + $"Percent={tableOperation.PercentComplete:P0}"); nextProgressLogAt += 5f; } yield return null; } if (tableOperation.Status != AsyncOperationStatus.Succeeded || tableOperation.Result == null) { FailDialogueStartup( $"Yarn 台词表加载失败。Table={storyData.yarnLineStringTable.TableReference}", tableOperation.OperationException); yield break; } Debug.Log( $"[YarnDiag] 章节台词表加载完成。Locale={tableOperation.Result.LocaleIdentifier.Code}, " + $"Entries={tableOperation.Result.Count}"); _dialogueStartupCoroutine = null; storyUIPage?.FadeOut(); StartDialogueRunnerSafely(yarnNodeName).Forget(); } /// /// 调用 Yarn Spinner 的公开 API。 /// 返回时表示所有 Presenter 已完成 OnDialogueStartedAsync,且 Runner 已调用 Dialogue.Continue。 /// private async YarnTask StartDialogueRunnerSafely(string yarnNodeName) { try { Debug.Log($"[YarnDiag] 调用 DialogueRunner.StartDialogue。Node={yarnNodeName}"); await dialogueRunner.StartDialogue(yarnNodeName); Debug.Log( $"[YarnDiag] DialogueRunner 初始启动完成。Node={yarnNodeName}, " + $"IsDialogueRunning={dialogueRunner.IsDialogueRunning}"); } catch (System.Exception exception) { FailDialogueStartup("启动 Yarn 对话失败。", exception); } } private void FailDialogueStartup(string reason, System.Exception exception = null) { _dialogueStartupCoroutine = null; if (exception != null) Debug.LogException(exception, dialogueRunner); Debug.LogError($"[YarnDiag] {reason}", dialogueRunner); if (dialogueRunner != null && dialogueRunner.IsDialogueRunning) { dialogueRunner.GetComponent()?.CancelCurrentPresentation(); dialogueRunner.Stop().Forget(); } RestoreInterruptedDialogue(reason); } /// /// 取消当前 TextBlock 对话并恢复进入前的章节剧情快照。 /// Yarn 的 仍会触发 onDialogueComplete; /// 因此此处先标记取消状态,完成回调会走恢复流程而不是误将 Block 标记为 Completed。 /// public bool CancelActiveDialogue() { if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId)) { return false; } if (_dialogueStartupCoroutine != null) { StopCoroutine(_dialogueStartupCoroutine); _dialogueStartupCoroutine = null; RestoreInterruptedDialogue("玩家在对话启动阶段中途退出"); return true; } if (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 是否为 UnityLocalisedLineProvider。 /// /// LineProvider 应通过 Yarn Spinner Inspector 持久化配置;运行时不使用反射修改 Package 内部字段, /// 这样 Editor、Mono 与 IL2CPP Build 使用完全相同的装配结果。 /// /// private bool ValidateUnityLocalisedLineProvider() { if (dialogueRunner == null) { return false; } if (dialogueRunner.LineProvider is UnityLocalisedLineProvider) { return true; } Debug.LogError( "[StoryDialogueController] DialogueRunner.lineProvider 未配置为 UnityLocalisedLineProvider。" + "请在 Yarn Spinner Inspector 中使用 Unity Localization Line Provider,并配置台词 String Table。", dialogueRunner); return false; } 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; } } }