using System.Threading; using TMPro; using UnityEngine; using UnityEngine.UI; using Yarn.Unity; using Ichni.Story.UI; namespace Ichni.Story.Dialogue { /// /// 视觉小说(VN)风格的 Yarn 对话呈现器。 /// /// 职责: /// /// 对话流控制:打字机效果、点击推进、等待选项。 /// 将所有的 UI 引用委托给 进行管理。 /// /// /// public class VNDialoguePresenter : DialoguePresenterBase { [Header("UI 管理")] [Tooltip("负责当前对话界面的 UI Page 容器。如果为空,将尝试获取全局单例。")] [SerializeField] private DialogUIPage uiPage; [Header("打字效果")] [Tooltip("每秒显示的字符数。设为 0 则瞬间显示全文。")] [SerializeField] private float lettersPerSecond = 40f; // ── 私有状态 ───────────────────────────────────────────────────────────── /// /// 当前行的打字效果是否已完成。 /// 用于区分"加速打字(第一次点击)"与"推进到下一行(第二次点击)"两个阶段。 /// private bool _typewriterComplete; /// /// 是否处于快进状态(遇到选项或玩家再次点击时会打断)。 /// private bool _isFastForwarding; /// /// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成, /// 点击按钮时 TrySetResult 触发完成。 /// private YarnTaskCompletionSource _optionTcs; /// /// 对应 StoryDialogueRoot 上的 DialogueRunner。 /// private DialogueRunner _runner; private DialogUIPage Page => uiPage != null ? uiPage : DialogUIPage.instance; // ── 生命周期 ───────────────────────────────────────────────────────────── private void Awake() { _runner = GetComponent(); if (_runner == null) Debug.LogWarning("[VNDialoguePresenter] 未在同 GameObject 上找到 DialogueRunner。" + "请确认 VNDialoguePresenter 挂载在 StoryDialogueRoot 上。"); } private void Start() { if (Page == null) return; // 启动时隐藏选项容器 if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false); // 注册推进按钮回调 if (Page.advanceButton != null) Page.advanceButton.onClick.AddListener(OnAdvanceButtonClicked); // 注册快进按钮回调 if (Page.fastForwardButton != null) Page.fastForwardButton.onClick.AddListener(OnFastForwardButtonClicked); } private void OnDestroy() { if (Page != null) { if (Page.advanceButton != null) Page.advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked); if (Page.fastForwardButton != null) Page.fastForwardButton.onClick.RemoveListener(OnFastForwardButtonClicked); } } // ── DialoguePresenterBase 实现 ─────────────────────────────────────────── public override async YarnTask OnDialogueStartedAsync() { // 快进是一次 Yarn 对话会话的临时操作,绝不能遗留到下一次进入 TextBlock。 _isFastForwarding = false; _typewriterComplete = false; // 对话记录只保留当前一次 TextBlock;每次 Yarn 对话真正开始时清空上一段残留内容。 DialogueHistory.BeginSession(); if (Page != null) { var tcs = new YarnTaskCompletionSource(); Page.PlayFadeIn(() => tcs.TrySetResult(true)); await tcs.Task; } } public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token) { if (Page == null) return; // 确保选项区不可见;台词区始终可见 if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false); // ── 说话者名称 ── bool hasSpeaker = !string.IsNullOrEmpty(line.CharacterName); if (Page.speakerContainer != null) Page.speakerContainer.SetActive(hasSpeaker); if (Page.speakerText != null) Page.speakerText.text = hasSpeaker ? line.CharacterName : string.Empty; // ── 立绘高亮 ── Page.portraitStage?.HighlightSpeaker(line.CharacterName); // ── 打字效果 ── string fullText = line.TextWithoutCharacterName.Text; // 记录的是本次实际展示时已经解析完成的本地化文本,而非 Yarn Key。 DialogueHistory.AddLine(line.CharacterName, fullText); _typewriterComplete = false; if (Page.dialogueText != null) { Page.dialogueText.text = fullText; Page.dialogueText.maxVisibleCharacters = 0; } if (_isFastForwarding) { // 瞬间显示全文 if (Page.dialogueText != null) Page.dialogueText.maxVisibleCharacters = int.MaxValue; _typewriterComplete = true; // 极短的延迟,防止瞬间刷过几百句话造成卡顿,也给玩家微小的视觉感知 await YarnTask.Delay(50).SuppressCancellationThrow(); if (_isFastForwarding && _runner != null) { _runner.RequestNextLine(); } } else { // 正常逐字播放 await TypewriterAsync(fullText, token.HurryUpToken); // 确保全文可见 if (Page.dialogueText != null) Page.dialogueText.maxVisibleCharacters = int.MaxValue; _typewriterComplete = true; } // ── 等待玩家推进 ── await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow(); } public override async YarnTask RunOptionsAsync( DialogueOption[] options, LineCancellationToken token) { if (Page == null) return null; // 同一 TextBlock 的选项已被玩家选择过时,直接把原 Option 返回给 Yarn, // 让后续指令和分支仍由 Yarn 正常执行,而不是在 C# 中复制分支逻辑。 string sourceBlockId = StoryDialogueController.instance?.ActiveBlockId; if (StoryChoiceMemory.TryGetRememberedOption(sourceBlockId, options, out DialogueOption rememberedOption)) { // 已记忆选项在普通回顾与快进时都会自动采用;记录页仍应如实显示本次经过的分支。 DialogueHistory.AddChoice(rememberedOption.Line.TextWithoutCharacterName.Text); return rememberedOption; } // 遇到选项强制打断快进 _isFastForwarding = false; // 清空台词区,准备显示选项 if (Page.speakerContainer != null) Page.speakerContainer.SetActive(false); if (Page.dialogueText != null) Page.dialogueText.text = string.Empty; // 显示选项容器 if (Page.choiceFrame != null) Page.choiceFrame.SetActive(true); YarnTaskCompletionSource optionTcs = new YarnTaskCompletionSource(); _optionTcs = optionTcs; // 将本组选项自己的 TCS 传入取消监听,避免上一组选项的 Token 在稍后取消时 // 错误结束下一组选项。_optionTcs 只用于外部 Back 流程定位当前正在等待的选择。 WaitForExternalCancelAsync(token.NextContentToken, optionTcs).Forget(); // ── 配置每个选项按钮 ── for (int i = 0; i < Page.choiceButtons.Length; i++) { if (Page.choiceButtons[i] == null) continue; if (i < options.Length) { Page.choiceButtons[i].Setup(options[i], opt => { // 先写入选项记忆,再结束异步等待;这样即使紧接着 Yarn 修改路线变量, // Timeline 快照也仍然代表“本组选择之前”的状态。 StoryChoiceMemory.RememberChoice(sourceBlockId, options, opt); DialogueHistory.AddChoice(opt.Line.TextWithoutCharacterName.Text); optionTcs.TrySetResult(opt); }); } else Page.choiceButtons[i].Cleanup(); } // ── 等待玩家选择 ── DialogueOption? selected = await optionTcs.Task; if (_optionTcs == optionTcs) _optionTcs = null; // 清理所有按钮并隐藏选项容器 foreach (ChoiceButton btn in Page.choiceButtons) btn?.Cleanup(); if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false); return selected; } public override async YarnTask OnDialogueCompleteAsync() { // 无论正常完成还是被 StoryDialogueController 通过 Stop 取消,都要结束本次快进状态。 _isFastForwarding = false; _typewriterComplete = true; if (Page != null) { var tcs = new YarnTaskCompletionSource(); Page.PlayFadeOut(() => tcs.TrySetResult(true)); await tcs.Task; } } /// /// 打开对话记录前的 UI 收束操作。 /// 停止自动快进,并补全当前正在打字的一句,但绝不请求下一句, /// 因而 History 覆盖层打开期间不会改变 Yarn 的剧情进度。 /// public void PrepareForHistory() { _isFastForwarding = false; if (!_typewriterComplete && _runner != null) _runner.RequestHurryUpLine(); } /// /// 为 DialogUIPage 的 Back 取消流程收起所有临时交互。 /// 真正终止 Yarn 和恢复章节快照由 负责; /// 本方法不写入任何剧情变量或选项。 /// public void CancelCurrentPresentation() { _isFastForwarding = false; _typewriterComplete = true; _optionTcs?.TrySetResult(null); if (Page == null) return; foreach (ChoiceButton button in Page.choiceButtons) button?.Cleanup(); if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false); } // ── 内部方法 ───────────────────────────────────────────────────────────── private void OnAdvanceButtonClicked() { // 未记忆选项必须由 ChoiceButton 明确选择;推进按钮不能越过当前选择。 if (_optionTcs != null) return; // 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作 if (_isFastForwarding) { _isFastForwarding = false; return; } if (_runner == null) return; if (!_typewriterComplete) _runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果 else _runner.RequestNextLine(); // 第二次点击:推进到下一行 } private void OnFastForwardButtonClicked() { // 快进可以跨过已经记忆的选项,但不能跳过正在等待玩家首次决定的选项。 if (_optionTcs != null) return; _isFastForwarding = true; if (_runner == null) return; // 立即跳过当前的等待状态 if (!_typewriterComplete) _runner.RequestHurryUpLine(); else _runner.RequestNextLine(); } private async YarnTask TypewriterAsync(string text, CancellationToken hurryUpToken) { if (Page.dialogueText == null) return; if (lettersPerSecond <= 0f) { Page.dialogueText.maxVisibleCharacters = int.MaxValue; return; } int delayMs = Mathf.Max(1, Mathf.RoundToInt(1000f / lettersPerSecond)); for (int i = 1; i <= text.Length; i++) { if (hurryUpToken.IsCancellationRequested) return; Page.dialogueText.maxVisibleCharacters = i; await YarnTask.Delay(delayMs, hurryUpToken).SuppressCancellationThrow(); if (hurryUpToken.IsCancellationRequested) return; } } private static async YarnTask WaitForExternalCancelAsync( CancellationToken externalToken, YarnTaskCompletionSource optionTcs) { await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow(); optionTcs.TrySetResult(null); } } }