Files
ichni_Official/Assets/Scripts/NewStorySystem/Dialogue/VNDialoguePresenter.cs
2026-07-05 16:08:23 -04:00

321 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Threading;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
namespace Ichni.Story.Dialogue
{
/// <summary>
/// 视觉小说VN风格的 Yarn 对话呈现器。
/// <para>
/// 职责:
/// <list type="bullet">
/// <item>对话开始/结束时淡入/淡出 <see cref="panelGroup"/>。</item>
/// <item><see cref="RunLineAsync"/>:逐字打字效果 + 点击推进。</item>
/// <item><see cref="RunOptionsAsync"/>:显示选项按钮 + 等待玩家选择。</item>
/// </list>
/// </para>
/// <para>
/// 挂载位置:与 <see cref="DialogueRunner"/> 同一 GameObjectStoryDialogueRoot
/// UI 引用字段需在 Inspector 中连接到 DialogPage 下的对应节点。
/// </para>
/// </summary>
public class VNDialoguePresenter : DialoguePresenterBase
{
// ── UI 引用 ─────────────────────────────────────────────────────────────
[Header("面板控制")]
[Tooltip("对话面板整体的 CanvasGroupDialogPage/DialogCanvas。用于淡入/淡出及交互开关。")]
[SerializeField] private CanvasGroup panelGroup;
[Header("台词区")]
[Tooltip("台词主体文本 TMPDialogContentFrame/Background/Text。")]
[SerializeField] private TextMeshProUGUI dialogueText;
[Tooltip("说话者名称的父容器 GameObjectDialogContentFrame/Speaker。无说话者时整体隐藏。")]
[SerializeField] private GameObject speakerContainer;
[Tooltip("说话者名称 TMPDialogContentFrame/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;
// ── 私有状态 ─────────────────────────────────────────────────────────────
/// <summary>
/// 当前行的打字效果是否已完成。
/// 用于区分"加速打字(第一次点击)"与"推进到下一行(第二次点击)"两个阶段。
/// </summary>
private bool _typewriterComplete;
/// <summary>
/// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成,
/// 点击按钮时 TrySetResult 触发完成。
/// </summary>
private YarnTaskCompletionSource<DialogueOption?> _optionTcs;
/// <summary>
/// 对应 StoryDialogueRoot 上的 DialogueRunner用于调用 RequestNextLine / RequestHurryUpLine。
/// 在 Awake 中通过 GetComponent 取得。
/// </summary>
private DialogueRunner _runner;
// ── 生命周期 ─────────────────────────────────────────────────────────────
private void Awake()
{
// VNDialoguePresenter 需要与 DialogueRunner 同 GameObject通过 GetComponent 获取引用。
_runner = GetComponent<DialogueRunner>();
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 实现 ───────────────────────────────────────────
/// <summary>
/// 对话开始:将面板淡入到可见状态。
/// 在 DialogueRunner 开始第一行前由 Yarn 调用。
/// </summary>
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);
}
/// <summary>
/// 呈现一行台词。
/// <list type="number">
/// <item>隐藏选项区,显示说话者名称(若有)。</item>
/// <item>逐字打字效果,期间可被 <c>HurryUpToken</c> 中断(显示全文)。</item>
/// <item>等待玩家点击推进(<c>NextContentToken</c> 由 <see cref="DialogueRunner.RequestNextLine"/> 取消)。</item>
/// </list>
/// </summary>
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();
}
/// <summary>
/// 呈现选项列表,等待玩家选择。
/// <list type="number">
/// <item>显示选项容器,配置每个按钮的文本与点击监听。</item>
/// <item>通过 <see cref="YarnTaskCompletionSource{T}"/> 异步等待玩家选择。</item>
/// <item>返回玩家选择的 <see cref="DialogueOption"/>(对话被外部取消则返回 null。</item>
/// </list>
/// </summary>
public override async YarnTask<DialogueOption?> 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<DialogueOption?>();
// 当 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;
}
/// <summary>
/// 对话结束:淡出并隐藏对话面板。
/// 在 DialogueRunner 处理完最后一行/选项后由 Yarn 调用。
/// </summary>
public override async YarnTask OnDialogueCompleteAsync()
{
if (panelGroup != null)
await Effects.FadeAlphaAsync(panelGroup, 1f, 0f, fadeOutDuration, CancellationToken.None);
SetPanelState(visible: false, instant: true);
}
// ── 内部方法 ─────────────────────────────────────────────────────────────
/// <summary>
/// 推进按钮点击回调。
/// <list type="bullet">
/// <item>打字中 → RequestHurryUpLine取消 HurryUpToken立即显示全文。</item>
/// <item>打字完毕 → RequestNextLine取消 NextContentToken进入下一行。</item>
/// </list>
/// </summary>
private void OnAdvanceButtonClicked()
{
if (_runner == null) return;
if (!_typewriterComplete)
_runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果
else
_runner.RequestNextLine(); // 第二次点击:推进到下一行
}
/// <summary>
/// 控制对话面板 <see cref="panelGroup"/> 的显示状态。
/// </summary>
/// <param name="visible">是否可见且可交互。</param>
/// <param name="instant">为 true 时立即设置 alpha为 false 时仅设置 interactable/blocksRaycasts
/// alpha 留给外部的 <c>FadeAlphaAsync</c> 动画处理。</param>
private void SetPanelState(bool visible, bool instant)
{
if (panelGroup == null) return;
panelGroup.interactable = visible;
panelGroup.blocksRaycasts = visible;
if (instant) panelGroup.alpha = visible ? 1f : 0f;
}
/// <summary>
/// 逐字打字效果。
/// 通过 <c>maxVisibleCharacters</c> 逐步显示文本(无 GC 分配)。
/// 被 <paramref name="hurryUpToken"/> 取消时立即返回,调用方负责将 maxVisibleCharacters 设为 MaxValue。
/// </summary>
/// <param name="text">要逐字展示的完整文本(已预先赋值给 dialogueText.text。</param>
/// <param name="hurryUpToken">取消此 token 可跳过打字效果。</param>
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;
}
}
/// <summary>
/// 监听外部取消信号(对话被强制终止),将 <see cref="_optionTcs"/> 设为 null 结果,
/// 确保 <see cref="RunOptionsAsync"/> 的 await 能正常退出而不会死锁。
/// </summary>
private async YarnTask WaitForExternalCancelAsync(CancellationToken externalToken)
{
await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow();
_optionTcs?.TrySetResult(null);
}
}
}