Files
ichni_Official/Assets/Scripts/NewStorySystem/Dialogue/VNDialoguePresenter.cs
SoulliesOfficial 810d019619 剧情+对话完善
2026-07-21 15:24:42 -04:00

354 lines
14 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;
using Ichni.Story.UI;
namespace Ichni.Story.Dialogue
{
/// <summary>
/// 视觉小说VN风格的 Yarn 对话呈现器。
/// <para>
/// 职责:
/// <list type="bullet">
/// <item>对话流控制:打字机效果、点击推进、等待选项。</item>
/// <item>将所有的 UI 引用委托给 <see cref="DialogUIPage"/> 进行管理。</item>
/// </list>
/// </para>
/// </summary>
public class VNDialoguePresenter : DialoguePresenterBase
{
[Header("UI 管理")]
[Tooltip("负责当前对话界面的 UI Page 容器。如果为空,将尝试获取全局单例。")]
[SerializeField] private DialogUIPage uiPage;
[Header("打字效果")]
[Tooltip("每秒显示的字符数。设为 0 则瞬间显示全文。")]
[SerializeField] private float lettersPerSecond = 40f;
// ── 私有状态 ─────────────────────────────────────────────────────────────
/// <summary>
/// 当前行的打字效果是否已完成。
/// 用于区分"加速打字(第一次点击)"与"推进到下一行(第二次点击)"两个阶段。
/// </summary>
private bool _typewriterComplete;
/// <summary>
/// 是否处于快进状态(遇到选项或玩家再次点击时会打断)。
/// </summary>
private bool _isFastForwarding;
/// <summary>
/// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成,
/// 点击按钮时 TrySetResult 触发完成。
/// </summary>
private YarnTaskCompletionSource<DialogueOption?> _optionTcs;
/// <summary>
/// 对应 StoryDialogueRoot 上的 DialogueRunner。
/// </summary>
private DialogueRunner _runner;
private DialogUIPage Page => uiPage != null ? uiPage : DialogUIPage.instance;
// ── 生命周期 ─────────────────────────────────────────────────────────────
private void Awake()
{
_runner = GetComponent<DialogueRunner>();
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<bool>();
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<DialogueOption?> 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<DialogueOption?> optionTcs = new YarnTaskCompletionSource<DialogueOption?>();
_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<bool>();
Page.PlayFadeOut(() => tcs.TrySetResult(true));
await tcs.Task;
}
}
/// <summary>
/// 打开对话记录前的 UI 收束操作。
/// 停止自动快进,并补全当前正在打字的一句,但绝不请求下一句,
/// 因而 History 覆盖层打开期间不会改变 Yarn 的剧情进度。
/// </summary>
public void PrepareForHistory()
{
_isFastForwarding = false;
if (!_typewriterComplete && _runner != null)
_runner.RequestHurryUpLine();
}
/// <summary>
/// 为 DialogUIPage 的 Back 取消流程收起所有临时交互。
/// 真正终止 Yarn 和恢复章节快照由 <see cref="StoryDialogueController"/> 负责;
/// 本方法不写入任何剧情变量或选项。
/// </summary>
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<DialogueOption?> optionTcs)
{
await YarnTask.WaitUntilCanceled(externalToken).SuppressCancellationThrow();
optionTcs.TrySetResult(null);
}
}
}