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);
}
}
}