using System.Threading; using TMPro; using UnityEngine; using UnityEngine.UI; using Yarn.Unity; namespace Ichni.Story.Dialogue { /// /// 视觉小说(VN)风格的 Yarn 对话呈现器。 /// /// 职责: /// /// 对话开始/结束时淡入/淡出 /// :逐字打字效果 + 点击推进。 /// :显示选项按钮 + 等待玩家选择。 /// /// /// /// 挂载位置:与 同一 GameObject(StoryDialogueRoot)。 /// UI 引用字段需在 Inspector 中连接到 DialogPage 下的对应节点。 /// /// public class VNDialoguePresenter : DialoguePresenterBase { // ── UI 引用 ───────────────────────────────────────────────────────────── [Header("面板控制")] [Tooltip("对话面板整体的 CanvasGroup(DialogPage/DialogCanvas)。用于淡入/淡出及交互开关。")] [SerializeField] private CanvasGroup panelGroup; [Header("台词区")] [Tooltip("台词主体文本 TMP(DialogContentFrame/Background/Text)。")] [SerializeField] private TextMeshProUGUI dialogueText; [Tooltip("说话者名称的父容器 GameObject(DialogContentFrame/Speaker)。无说话者时整体隐藏。")] [SerializeField] private GameObject speakerContainer; [Tooltip("说话者名称 TMP(DialogContentFrame/Speaker/Text)。")] [SerializeField] private TextMeshProUGUI speakerText; [Header("推进按钮")] [Tooltip("点击此按钮推进对话。\n" + " • 打字效果进行中 → 加速(RequestHurryUpLine)显示全文。\n" + " • 打字已完成 → 进入下一行(RequestNextLine)。\n" + "推荐:将场景中 DialogContentFrame/Background 或 BackButton 的 Button 拖入此处。")] [SerializeField] private Button advanceButton; [Header("选项区")] [Tooltip("选项按钮容器根节点(ChoiceFrame)。有选项时显示,无选项时隐藏。")] [SerializeField] private GameObject choiceFrame; [Tooltip("选项按钮数组(最多 4 个,对应 ChoiceFrame 下的四个 GameObject)。")] [SerializeField] private Button[] choiceButtons = new Button[4]; [Tooltip("每个选项按钮上的文本 TMP,与 choiceButtons 按索引一一对应。")] [SerializeField] private TextMeshProUGUI[] choiceTexts = new TextMeshProUGUI[4]; // ── 打字 / 淡入淡出设置 ────────────────────────────────────────────────── [Header("打字效果")] [Tooltip("每秒显示的字符数。设为 0 则瞬间显示全文。")] [SerializeField] private float lettersPerSecond = 40f; [Header("淡入淡出")] [SerializeField] private float fadeInDuration = 0.2f; [SerializeField] private float fadeOutDuration = 0.15f; // ── 私有状态 ───────────────────────────────────────────────────────────── /// /// 当前行的打字效果是否已完成。 /// 用于区分"加速打字(第一次点击)"与"推进到下一行(第二次点击)"两个阶段。 /// private bool _typewriterComplete; /// /// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成, /// 点击按钮时 TrySetResult 触发完成。 /// private YarnTaskCompletionSource _optionTcs; /// /// 对应 StoryDialogueRoot 上的 DialogueRunner,用于调用 RequestNextLine / RequestHurryUpLine。 /// 在 Awake 中通过 GetComponent 取得。 /// private DialogueRunner _runner; // ── 生命周期 ───────────────────────────────────────────────────────────── private void Awake() { // VNDialoguePresenter 需要与 DialogueRunner 同 GameObject,通过 GetComponent 获取引用。 _runner = GetComponent(); if (_runner == null) Debug.LogWarning("[VNDialoguePresenter] 未在同 GameObject 上找到 DialogueRunner。" + "请确认 VNDialoguePresenter 挂载在 StoryDialogueRoot 上。"); // 启动时隐藏面板(alpha=0, 不响应交互) SetPanelState(visible: false, instant: true); // 启动时隐藏选项容器 if (choiceFrame != null) choiceFrame.SetActive(false); // 注册推进按钮回调 if (advanceButton != null) advanceButton.onClick.AddListener(OnAdvanceButtonClicked); } private void OnDestroy() { if (advanceButton != null) advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked); } // ── DialoguePresenterBase 实现 ─────────────────────────────────────────── /// /// 对话开始:将面板淡入到可见状态。 /// 在 DialogueRunner 开始第一行前由 Yarn 调用。 /// public override async YarnTask OnDialogueStartedAsync() { // 先令 CanvasGroup 可交互但 alpha=0(为淡入动画做准备) SetPanelState(visible: true, instant: false); if (panelGroup != null) await Effects.FadeAlphaAsync(panelGroup, 0f, 1f, fadeInDuration, CancellationToken.None); } /// /// 呈现一行台词。 /// /// 隐藏选项区,显示说话者名称(若有)。 /// 逐字打字效果,期间可被 HurryUpToken 中断(显示全文)。 /// 等待玩家点击推进(NextContentToken 取消)。 /// /// public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token) { // 确保选项区不可见;台词区始终可见 if (choiceFrame != null) choiceFrame.SetActive(false); // ── 说话者名称 ── bool hasSpeaker = !string.IsNullOrEmpty(line.CharacterName); if (speakerContainer != null) speakerContainer.SetActive(hasSpeaker); if (speakerText != null) speakerText.text = hasSpeaker ? line.CharacterName : string.Empty; // ── 打字效果 ── string fullText = line.TextWithoutCharacterName.Text; _typewriterComplete = false; if (dialogueText != null) { // 先将全文赋给 TMP,再通过 maxVisibleCharacters 逐步显示 // (避免每帧 Substring 产生大量 GC 分配) dialogueText.text = fullText; dialogueText.maxVisibleCharacters = 0; } // 逐字播放;若 HurryUpToken 被取消(玩家第一次点击)则立即跳出循环 await TypewriterAsync(fullText, token.HurryUpToken); // 确保全文可见(无论正常完成还是被加速中断) if (dialogueText != null) dialogueText.maxVisibleCharacters = int.MaxValue; _typewriterComplete = true; // ── 等待玩家推进 ── // token.NextContentToken 由外部 DialogueRunner.RequestNextLine() 取消, // SuppressCancellationThrow 确保不抛出 OperationCanceledException。 await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow(); } /// /// 呈现选项列表,等待玩家选择。 /// /// 显示选项容器,配置每个按钮的文本与点击监听。 /// 通过 异步等待玩家选择。 /// 返回玩家选择的 (对话被外部取消则返回 null)。 /// /// public override async YarnTask RunOptionsAsync( DialogueOption[] options, LineCancellationToken token) { // 清空台词区,准备显示选项 if (speakerContainer != null) speakerContainer.SetActive(false); if (dialogueText != null) dialogueText.text = string.Empty; // 显示选项容器 if (choiceFrame != null) choiceFrame.SetActive(true); // 创建选项选择的异步结果来源 _optionTcs = new YarnTaskCompletionSource(); // 当 NextContentToken 被外部取消(对话被强制结束),提前将 TCS 置为 null 结果, // 使 await _optionTcs.Task 能正常退出而不死锁。 WaitForExternalCancelAsync(token.NextContentToken).Forget(); // ── 配置每个选项按钮 ── for (int i = 0; i < choiceButtons.Length; i++) { // 只显示当前实际有效且可用的选项 bool active = i < options.Length && options[i].IsAvailable; if (choiceButtons[i] != null) choiceButtons[i].gameObject.SetActive(active); if (!active) continue; // 设置按钮文本 if (i < choiceTexts.Length && choiceTexts[i] != null) choiceTexts[i].text = options[i].Line.TextWithoutCharacterName.Text; // 捕获循环变量,避免闭包捕获错误索引 DialogueOption captured = options[i]; choiceButtons[i].onClick.RemoveAllListeners(); choiceButtons[i].onClick.AddListener(() => _optionTcs.TrySetResult(captured)); } // ── 等待玩家选择 ── DialogueOption? selected = await _optionTcs.Task; // 清理按钮监听,隐藏选项容器 foreach (Button btn in choiceButtons) if (btn != null) btn.onClick.RemoveAllListeners(); if (choiceFrame != null) choiceFrame.SetActive(false); return selected; } /// /// 对话结束:淡出并隐藏对话面板。 /// 在 DialogueRunner 处理完最后一行/选项后由 Yarn 调用。 /// public override async YarnTask OnDialogueCompleteAsync() { if (panelGroup != null) await Effects.FadeAlphaAsync(panelGroup, 1f, 0f, fadeOutDuration, CancellationToken.None); SetPanelState(visible: false, instant: true); } // ── 内部方法 ───────────────────────────────────────────────────────────── /// /// 推进按钮点击回调。 /// /// 打字中 → RequestHurryUpLine(取消 HurryUpToken,立即显示全文)。 /// 打字完毕 → RequestNextLine(取消 NextContentToken,进入下一行)。 /// /// private void OnAdvanceButtonClicked() { if (_runner == null) return; if (!_typewriterComplete) _runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果 else _runner.RequestNextLine(); // 第二次点击:推进到下一行 } /// /// 控制对话面板 的显示状态。 /// /// 是否可见且可交互。 /// 为 true 时立即设置 alpha;为 false 时仅设置 interactable/blocksRaycasts, /// alpha 留给外部的 FadeAlphaAsync 动画处理。 private void SetPanelState(bool visible, bool instant) { if (panelGroup == null) return; panelGroup.interactable = visible; panelGroup.blocksRaycasts = visible; if (instant) panelGroup.alpha = visible ? 1f : 0f; } /// /// 逐字打字效果。 /// 通过 maxVisibleCharacters 逐步显示文本(无 GC 分配)。 /// 被 取消时立即返回,调用方负责将 maxVisibleCharacters 设为 MaxValue。 /// /// 要逐字展示的完整文本(已预先赋值给 dialogueText.text)。 /// 取消此 token 可跳过打字效果。 private async YarnTask TypewriterAsync(string text, CancellationToken hurryUpToken) { if (dialogueText == null) return; // lettersPerSecond <= 0 时瞬间显示全文 if (lettersPerSecond <= 0f) { dialogueText.maxVisibleCharacters = int.MaxValue; return; } int delayMs = Mathf.Max(1, Mathf.RoundToInt(1000f / lettersPerSecond)); // TMP 的 maxVisibleCharacters 控制可见字符数(不含 rich-text 标签) // 逐帧递增直到全文显示或被 hurryUpToken 中断 for (int i = 1; i <= text.Length; i++) { if (hurryUpToken.IsCancellationRequested) return; dialogueText.maxVisibleCharacters = i; await YarnTask.Delay(delayMs, hurryUpToken).SuppressCancellationThrow(); if (hurryUpToken.IsCancellationRequested) return; } } /// /// 监听外部取消信号(对话被强制终止),将 设为 null 结果, /// 确保 的 await 能正常退出而不会死锁。 /// private async YarnTask WaitForExternalCancelAsync(CancellationToken externalToken) { await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow(); _optionTcs?.TrySetResult(null); } } }