376 lines
15 KiB
C#
376 lines
15 KiB
C#
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 YarnTask OnDialogueStartedAsync()
|
||
{
|
||
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 开始。");
|
||
|
||
// 快进是一次 Yarn 对话会话的临时操作,绝不能遗留到下一次进入 TextBlock。
|
||
_isFastForwarding = false;
|
||
_typewriterComplete = false;
|
||
|
||
// 对话记录只保留当前一次 TextBlock;每次 Yarn 对话真正开始时清空上一段残留内容。
|
||
DialogueHistory.BeginSession();
|
||
|
||
Page.PlayFadeIn();
|
||
Debug.Log("[YarnDiag] VNDialoguePresenter.OnDialogueStartedAsync 完成。");
|
||
return YarnTask.CompletedTask;
|
||
}
|
||
|
||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||
{
|
||
if (Page == null) return;
|
||
|
||
Debug.Log($"[YarnDiag] VNDialoguePresenter.RunLineAsync: LineID='{line.TextID}', Speaker='{line.CharacterName}', Text='{line.TextWithoutCharacterName.Text}', RawText='{line.RawText}'");
|
||
|
||
// 确保选项区不可见;台词区始终可见
|
||
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;
|
||
if (string.IsNullOrEmpty(fullText) && !string.IsNullOrEmpty(line.RawText))
|
||
{
|
||
fullText = line.RawText;
|
||
Debug.LogWarning($"[YarnDiag] [WARNING] 台词 '{line.TextID}' 本地化解析结果为空,回退使用 RawText: '{fullText}'");
|
||
}
|
||
// 记录的是本次实际展示时已经解析完成的本地化文本,而非 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()
|
||
{
|
||
Debug.Log($"[YarnDiag] OnAdvanceButtonClicked 推进按钮被点击!_runner={(_runner != null ? _runner.name : "null")}, _typewriterComplete={_typewriterComplete}, _isFastForwarding={_isFastForwarding}, _optionTcs={(_optionTcs != null)}");
|
||
|
||
// 未记忆选项必须由 ChoiceButton 明确选择;推进按钮不能越过当前选择。
|
||
if (_optionTcs != null)
|
||
{
|
||
Debug.LogWarning("[YarnDiag] OnAdvanceButtonClicked 被拦截:正在等待选项选择。");
|
||
return;
|
||
}
|
||
|
||
// 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作
|
||
if (_isFastForwarding)
|
||
{
|
||
Debug.Log("[YarnDiag] OnAdvanceButtonClicked 打断快进模式。");
|
||
_isFastForwarding = false;
|
||
return;
|
||
}
|
||
|
||
if (_runner == null)
|
||
{
|
||
Debug.LogError("[YarnDiag] [ERROR] OnAdvanceButtonClicked 拦截:_runner 为 null!");
|
||
return;
|
||
}
|
||
|
||
if (!_typewriterComplete)
|
||
{
|
||
Debug.Log("[YarnDiag] RequestHurryUpLine (跳过打字效果)");
|
||
_runner.RequestHurryUpLine(); // 第一次点击:跳过打字效果
|
||
}
|
||
else
|
||
{
|
||
Debug.Log("[YarnDiag] RequestNextLine (推进下一行)");
|
||
_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);
|
||
}
|
||
}
|
||
}
|