Files
ichni_Official/Assets/Scripts/NewStorySystem/Dialogue/VNDialoguePresenter.cs

273 lines
10 KiB
C#
Raw Normal View History

2026-07-05 16:08:23 -04:00
using System.Threading;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
2026-07-07 01:28:27 -04:00
using Ichni.Story.UI;
2026-07-05 16:08:23 -04:00
namespace Ichni.Story.Dialogue
{
/// <summary>
/// 视觉小说VN风格的 Yarn 对话呈现器。
/// <para>
/// 职责:
/// <list type="bullet">
2026-07-07 01:28:27 -04:00
/// <item>对话流控制:打字机效果、点击推进、等待选项。</item>
/// <item>将所有的 UI 引用委托给 <see cref="DialogUIPage"/> 进行管理。</item>
2026-07-05 16:08:23 -04:00
/// </list>
/// </para>
/// </summary>
public class VNDialoguePresenter : DialoguePresenterBase
{
2026-07-07 01:28:27 -04:00
[Header("UI 管理")]
[Tooltip("负责当前对话界面的 UI Page 容器。如果为空,将尝试获取全局单例。")]
[SerializeField] private DialogUIPage uiPage;
2026-07-05 16:08:23 -04:00
[Header("打字效果")]
[Tooltip("每秒显示的字符数。设为 0 则瞬间显示全文。")]
[SerializeField] private float lettersPerSecond = 40f;
// ── 私有状态 ─────────────────────────────────────────────────────────────
/// <summary>
/// 当前行的打字效果是否已完成。
/// 用于区分"加速打字(第一次点击)"与"推进到下一行(第二次点击)"两个阶段。
/// </summary>
private bool _typewriterComplete;
2026-07-07 01:28:27 -04:00
/// <summary>
/// 是否处于快进状态(遇到选项或玩家再次点击时会打断)。
/// </summary>
private bool _isFastForwarding;
2026-07-05 16:08:23 -04:00
/// <summary>
/// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成,
/// 点击按钮时 TrySetResult 触发完成。
/// </summary>
private YarnTaskCompletionSource<DialogueOption?> _optionTcs;
/// <summary>
2026-07-07 01:28:27 -04:00
/// 对应 StoryDialogueRoot 上的 DialogueRunner。
2026-07-05 16:08:23 -04:00
/// </summary>
private DialogueRunner _runner;
2026-07-07 01:28:27 -04:00
private DialogUIPage Page => uiPage != null ? uiPage : DialogUIPage.instance;
2026-07-05 16:08:23 -04:00
// ── 生命周期 ─────────────────────────────────────────────────────────────
private void Awake()
{
_runner = GetComponent<DialogueRunner>();
if (_runner == null)
Debug.LogWarning("[VNDialoguePresenter] 未在同 GameObject 上找到 DialogueRunner。" +
"请确认 VNDialoguePresenter 挂载在 StoryDialogueRoot 上。");
2026-07-07 01:28:27 -04:00
}
2026-07-05 16:08:23 -04:00
2026-07-07 01:28:27 -04:00
private void Start()
{
if (Page == null) return;
2026-07-05 16:08:23 -04:00
// 启动时隐藏选项容器
2026-07-07 01:28:27 -04:00
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
2026-07-05 16:08:23 -04:00
// 注册推进按钮回调
2026-07-07 01:28:27 -04:00
if (Page.advanceButton != null)
Page.advanceButton.onClick.AddListener(OnAdvanceButtonClicked);
// 注册快进按钮回调
if (Page.fastForwardButton != null)
Page.fastForwardButton.onClick.AddListener(OnFastForwardButtonClicked);
2026-07-05 16:08:23 -04:00
}
private void OnDestroy()
{
2026-07-07 01:28:27 -04:00
if (Page != null)
{
if (Page.advanceButton != null)
Page.advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked);
if (Page.fastForwardButton != null)
Page.fastForwardButton.onClick.RemoveListener(OnFastForwardButtonClicked);
}
2026-07-05 16:08:23 -04:00
}
// ── DialoguePresenterBase 实现 ───────────────────────────────────────────
public override async YarnTask OnDialogueStartedAsync()
{
2026-07-07 01:28:27 -04:00
if (Page != null)
{
var tcs = new YarnTaskCompletionSource<bool>();
Page.PlayFadeIn(() => tcs.TrySetResult(true));
await tcs.Task;
}
2026-07-05 16:08:23 -04:00
}
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
{
2026-07-07 01:28:27 -04:00
if (Page == null) return;
2026-07-05 16:08:23 -04:00
// 确保选项区不可见;台词区始终可见
2026-07-07 01:28:27 -04:00
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
2026-07-05 16:08:23 -04:00
// ── 说话者名称 ──
bool hasSpeaker = !string.IsNullOrEmpty(line.CharacterName);
2026-07-07 01:28:27 -04:00
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);
2026-07-05 16:08:23 -04:00
// ── 打字效果 ──
string fullText = line.TextWithoutCharacterName.Text;
_typewriterComplete = false;
2026-07-07 01:28:27 -04:00
if (Page.dialogueText != null)
2026-07-05 16:08:23 -04:00
{
2026-07-07 01:28:27 -04:00
Page.dialogueText.text = fullText;
Page.dialogueText.maxVisibleCharacters = 0;
2026-07-05 16:08:23 -04:00
}
2026-07-07 01:28:27 -04:00
if (_isFastForwarding)
{
// 瞬间显示全文
if (Page.dialogueText != null) Page.dialogueText.maxVisibleCharacters = int.MaxValue;
_typewriterComplete = true;
2026-07-05 16:08:23 -04:00
2026-07-07 01:28:27 -04:00
// 极短的延迟,防止瞬间刷过几百句话造成卡顿,也给玩家微小的视觉感知
await YarnTask.Delay(50).SuppressCancellationThrow();
2026-07-05 16:08:23 -04:00
2026-07-07 01:28:27 -04:00
if (_isFastForwarding && _runner != null)
{
_runner.RequestNextLine();
}
}
else
{
// 正常逐字播放
await TypewriterAsync(fullText, token.HurryUpToken);
// 确保全文可见
if (Page.dialogueText != null)
Page.dialogueText.maxVisibleCharacters = int.MaxValue;
_typewriterComplete = true;
}
2026-07-05 16:08:23 -04:00
// ── 等待玩家推进 ──
await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow();
}
public override async YarnTask<DialogueOption?> RunOptionsAsync(
DialogueOption[] options,
LineCancellationToken token)
{
2026-07-07 01:28:27 -04:00
if (Page == null) return null;
// 遇到选项强制打断快进
_isFastForwarding = false;
2026-07-05 16:08:23 -04:00
// 清空台词区,准备显示选项
2026-07-07 01:28:27 -04:00
if (Page.speakerContainer != null) Page.speakerContainer.SetActive(false);
if (Page.dialogueText != null) Page.dialogueText.text = string.Empty;
2026-07-05 16:08:23 -04:00
// 显示选项容器
2026-07-07 01:28:27 -04:00
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(true);
2026-07-05 16:08:23 -04:00
_optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
WaitForExternalCancelAsync(token.NextContentToken).Forget();
// ── 配置每个选项按钮 ──
2026-07-07 01:28:27 -04:00
for (int i = 0; i < Page.choiceButtons.Length; i++)
2026-07-05 16:08:23 -04:00
{
2026-07-07 01:28:27 -04:00
if (Page.choiceButtons[i] == null) continue;
2026-07-05 16:08:23 -04:00
2026-07-07 01:28:27 -04:00
if (i < options.Length)
Page.choiceButtons[i].Setup(options[i], opt => _optionTcs.TrySetResult(opt));
else
Page.choiceButtons[i].Cleanup();
2026-07-05 16:08:23 -04:00
}
// ── 等待玩家选择 ──
DialogueOption? selected = await _optionTcs.Task;
2026-07-07 01:28:27 -04:00
// 清理所有按钮并隐藏选项容器
foreach (ChoiceButton btn in Page.choiceButtons)
btn?.Cleanup();
2026-07-05 16:08:23 -04:00
2026-07-07 01:28:27 -04:00
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
2026-07-05 16:08:23 -04:00
return selected;
}
public override async YarnTask OnDialogueCompleteAsync()
{
2026-07-07 01:28:27 -04:00
if (Page != null)
{
var tcs = new YarnTaskCompletionSource<bool>();
Page.PlayFadeOut(() => tcs.TrySetResult(true));
await tcs.Task;
}
2026-07-05 16:08:23 -04:00
}
// ── 内部方法 ─────────────────────────────────────────────────────────────
private void OnAdvanceButtonClicked()
{
2026-07-07 01:28:27 -04:00
// 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作
if (_isFastForwarding)
{
_isFastForwarding = false;
return;
}
2026-07-05 16:08:23 -04:00
if (_runner == null) return;
if (!_typewriterComplete)
_runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果
else
_runner.RequestNextLine(); // 第二次点击:推进到下一行
}
2026-07-07 01:28:27 -04:00
private void OnFastForwardButtonClicked()
2026-07-05 16:08:23 -04:00
{
2026-07-07 01:28:27 -04:00
_isFastForwarding = true;
if (_runner == null) return;
// 立即跳过当前的等待状态
if (!_typewriterComplete)
_runner.RequestHurryUpLine();
else
_runner.RequestNextLine();
2026-07-05 16:08:23 -04:00
}
private async YarnTask TypewriterAsync(string text, CancellationToken hurryUpToken)
{
2026-07-07 01:28:27 -04:00
if (Page.dialogueText == null) return;
2026-07-05 16:08:23 -04:00
if (lettersPerSecond <= 0f)
{
2026-07-07 01:28:27 -04:00
Page.dialogueText.maxVisibleCharacters = int.MaxValue;
2026-07-05 16:08:23 -04:00
return;
}
int delayMs = Mathf.Max(1, Mathf.RoundToInt(1000f / lettersPerSecond));
for (int i = 1; i <= text.Length; i++)
{
if (hurryUpToken.IsCancellationRequested) return;
2026-07-07 01:28:27 -04:00
Page.dialogueText.maxVisibleCharacters = i;
2026-07-05 16:08:23 -04:00
await YarnTask.Delay(delayMs, hurryUpToken).SuppressCancellationThrow();
if (hurryUpToken.IsCancellationRequested) return;
}
}
private async YarnTask WaitForExternalCancelAsync(CancellationToken externalToken)
{
await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow();
_optionTcs?.TrySetResult(null);
}
}
}