StorySystem
This commit is contained in:
@@ -3,6 +3,7 @@ using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using Yarn.Unity;
|
||||
using Ichni.Story.UI;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
@@ -11,61 +12,21 @@ namespace Ichni.Story.Dialogue
|
||||
/// <para>
|
||||
/// 职责:
|
||||
/// <list type="bullet">
|
||||
/// <item>对话开始/结束时淡入/淡出 <see cref="panelGroup"/>。</item>
|
||||
/// <item><see cref="RunLineAsync"/>:逐字打字效果 + 点击推进。</item>
|
||||
/// <item><see cref="RunOptionsAsync"/>:显示选项按钮 + 等待玩家选择。</item>
|
||||
/// <item>对话流控制:打字机效果、点击推进、等待选项。</item>
|
||||
/// <item>将所有的 UI 引用委托给 <see cref="DialogUIPage"/> 进行管理。</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("UI 管理")]
|
||||
[Tooltip("负责当前对话界面的 UI Page 容器。如果为空,将尝试获取全局单例。")]
|
||||
[SerializeField] private DialogUIPage uiPage;
|
||||
|
||||
[Header("打字效果")]
|
||||
[Tooltip("每秒显示的字符数。设为 0 则瞬间显示全文。")]
|
||||
[SerializeField] private float lettersPerSecond = 40f;
|
||||
|
||||
[Header("淡入淡出")]
|
||||
[SerializeField] private float fadeInDuration = 0.2f;
|
||||
[SerializeField] private float fadeOutDuration = 0.15f;
|
||||
|
||||
// ── 私有状态 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -74,6 +35,11 @@ namespace Ichni.Story.Dialogue
|
||||
/// </summary>
|
||||
private bool _typewriterComplete;
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于快进状态(遇到选项或玩家再次点击时会打断)。
|
||||
/// </summary>
|
||||
private bool _isFastForwarding;
|
||||
|
||||
/// <summary>
|
||||
/// 选项选择的异步结果来源。RunOptionsAsync 等待其 Task 完成,
|
||||
/// 点击按钮时 TrySetResult 触发完成。
|
||||
@@ -81,179 +47,180 @@ namespace Ichni.Story.Dialogue
|
||||
private YarnTaskCompletionSource<DialogueOption?> _optionTcs;
|
||||
|
||||
/// <summary>
|
||||
/// 对应 StoryDialogueRoot 上的 DialogueRunner,用于调用 RequestNextLine / RequestHurryUpLine。
|
||||
/// 在 Awake 中通过 GetComponent 取得。
|
||||
/// 对应 StoryDialogueRoot 上的 DialogueRunner。
|
||||
/// </summary>
|
||||
private DialogueRunner _runner;
|
||||
|
||||
private DialogUIPage Page => uiPage != null ? uiPage : DialogUIPage.instance;
|
||||
|
||||
// ── 生命周期 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
private void Start()
|
||||
{
|
||||
if (Page == null) return;
|
||||
|
||||
// 启动时隐藏选项容器
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
if (Page.choiceFrame != null) Page.choiceFrame.SetActive(false);
|
||||
|
||||
// 注册推进按钮回调
|
||||
if (advanceButton != null)
|
||||
advanceButton.onClick.AddListener(OnAdvanceButtonClicked);
|
||||
if (Page.advanceButton != null)
|
||||
Page.advanceButton.onClick.AddListener(OnAdvanceButtonClicked);
|
||||
|
||||
// 注册快进按钮回调
|
||||
if (Page.fastForwardButton != null)
|
||||
Page.fastForwardButton.onClick.AddListener(OnFastForwardButtonClicked);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (advanceButton != null)
|
||||
advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked);
|
||||
if (Page != null)
|
||||
{
|
||||
if (Page.advanceButton != null)
|
||||
Page.advanceButton.onClick.RemoveListener(OnAdvanceButtonClicked);
|
||||
if (Page.fastForwardButton != null)
|
||||
Page.fastForwardButton.onClick.RemoveListener(OnFastForwardButtonClicked);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
if (Page != null)
|
||||
{
|
||||
var tcs = new YarnTaskCompletionSource<bool>();
|
||||
Page.PlayFadeIn(() => tcs.TrySetResult(true));
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 (Page == null) return;
|
||||
|
||||
// 确保选项区不可见;台词区始终可见
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
if (Page.choiceFrame != null) Page.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;
|
||||
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;
|
||||
_typewriterComplete = false;
|
||||
|
||||
if (dialogueText != null)
|
||||
if (Page.dialogueText != null)
|
||||
{
|
||||
// 先将全文赋给 TMP,再通过 maxVisibleCharacters 逐步显示
|
||||
// (避免每帧 Substring 产生大量 GC 分配)
|
||||
dialogueText.text = fullText;
|
||||
dialogueText.maxVisibleCharacters = 0;
|
||||
Page.dialogueText.text = fullText;
|
||||
Page.dialogueText.maxVisibleCharacters = 0;
|
||||
}
|
||||
|
||||
// 逐字播放;若 HurryUpToken 被取消(玩家第一次点击)则立即跳出循环
|
||||
await TypewriterAsync(fullText, token.HurryUpToken);
|
||||
if (_isFastForwarding)
|
||||
{
|
||||
// 瞬间显示全文
|
||||
if (Page.dialogueText != null) Page.dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
_typewriterComplete = true;
|
||||
|
||||
// 确保全文可见(无论正常完成还是被加速中断)
|
||||
if (dialogueText != null)
|
||||
dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
// 极短的延迟,防止瞬间刷过几百句话造成卡顿,也给玩家微小的视觉感知
|
||||
await YarnTask.Delay(50).SuppressCancellationThrow();
|
||||
|
||||
_typewriterComplete = true;
|
||||
if (_isFastForwarding && _runner != null)
|
||||
{
|
||||
_runner.RequestNextLine();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 正常逐字播放
|
||||
await TypewriterAsync(fullText, token.HurryUpToken);
|
||||
|
||||
// 确保全文可见
|
||||
if (Page.dialogueText != null)
|
||||
Page.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 (Page == null) return null;
|
||||
|
||||
// 遇到选项强制打断快进
|
||||
_isFastForwarding = false;
|
||||
|
||||
// 清空台词区,准备显示选项
|
||||
if (speakerContainer != null) speakerContainer.SetActive(false);
|
||||
if (dialogueText != null) dialogueText.text = string.Empty;
|
||||
if (Page.speakerContainer != null) Page.speakerContainer.SetActive(false);
|
||||
if (Page.dialogueText != null) Page.dialogueText.text = string.Empty;
|
||||
|
||||
// 显示选项容器
|
||||
if (choiceFrame != null) choiceFrame.SetActive(true);
|
||||
if (Page.choiceFrame != null) Page.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++)
|
||||
for (int i = 0; i < Page.choiceButtons.Length; i++)
|
||||
{
|
||||
// 只显示当前实际有效且可用的选项
|
||||
bool active = i < options.Length && options[i].IsAvailable;
|
||||
if (choiceButtons[i] != null)
|
||||
choiceButtons[i].gameObject.SetActive(active);
|
||||
if (Page.choiceButtons[i] == null) continue;
|
||||
|
||||
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));
|
||||
if (i < options.Length)
|
||||
Page.choiceButtons[i].Setup(options[i], opt => _optionTcs.TrySetResult(opt));
|
||||
else
|
||||
Page.choiceButtons[i].Cleanup();
|
||||
}
|
||||
|
||||
// ── 等待玩家选择 ──
|
||||
DialogueOption? selected = await _optionTcs.Task;
|
||||
|
||||
// 清理按钮监听,隐藏选项容器
|
||||
foreach (Button btn in choiceButtons)
|
||||
if (btn != null) btn.onClick.RemoveAllListeners();
|
||||
// 清理所有按钮并隐藏选项容器
|
||||
foreach (ChoiceButton btn in Page.choiceButtons)
|
||||
btn?.Cleanup();
|
||||
|
||||
if (choiceFrame != null) choiceFrame.SetActive(false);
|
||||
if (Page.choiceFrame != null) Page.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);
|
||||
if (Page != null)
|
||||
{
|
||||
var tcs = new YarnTaskCompletionSource<bool>();
|
||||
Page.PlayFadeOut(() => tcs.TrySetResult(true));
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部方法 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 推进按钮点击回调。
|
||||
/// <list type="bullet">
|
||||
/// <item>打字中 → RequestHurryUpLine(取消 HurryUpToken,立即显示全文)。</item>
|
||||
/// <item>打字完毕 → RequestNextLine(取消 NextContentToken,进入下一行)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private void OnAdvanceButtonClicked()
|
||||
{
|
||||
// 如果玩家在快进时点击屏幕/推进键,则打断快进状态并停止当前动作
|
||||
if (_isFastForwarding)
|
||||
{
|
||||
_isFastForwarding = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_runner == null) return;
|
||||
|
||||
if (!_typewriterComplete)
|
||||
@@ -262,55 +229,40 @@ namespace Ichni.Story.Dialogue
|
||||
_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)
|
||||
private void OnFastForwardButtonClicked()
|
||||
{
|
||||
if (panelGroup == null) return;
|
||||
panelGroup.interactable = visible;
|
||||
panelGroup.blocksRaycasts = visible;
|
||||
if (instant) panelGroup.alpha = visible ? 1f : 0f;
|
||||
_isFastForwarding = true;
|
||||
|
||||
if (_runner == null) return;
|
||||
|
||||
// 立即跳过当前的等待状态
|
||||
if (!_typewriterComplete)
|
||||
_runner.RequestHurryUpLine();
|
||||
else
|
||||
_runner.RequestNextLine();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
if (Page.dialogueText == null) return;
|
||||
|
||||
// lettersPerSecond <= 0 时瞬间显示全文
|
||||
if (lettersPerSecond <= 0f)
|
||||
{
|
||||
dialogueText.maxVisibleCharacters = int.MaxValue;
|
||||
Page.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;
|
||||
Page.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();
|
||||
|
||||
Reference in New Issue
Block a user