321 lines
15 KiB
C#
321 lines
15 KiB
C#
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"/> 同一 GameObject(StoryDialogueRoot)。
|
||
/// UI 引用字段需在 Inspector 中连接到 DialogPage 下的对应节点。
|
||
/// </para>
|
||
/// </summary>
|
||
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;
|
||
|
||
// ── 私有状态 ─────────────────────────────────────────────────────────────
|
||
|
||
/// <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);
|
||
}
|
||
}
|
||
}
|