Storyline+Dialog初步
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 【临时 / 调试用】将 Yarn 对话行以"本地化后的文本"输出到 Console 的呈现器。
|
||||
/// <para>用途:在没有 VN 对话 UI 的情况下,验证整条 Yarn 管线是否跑通,以及
|
||||
/// Unity Localization 是否正确返回当前语言的文本(点击文本块即可在 Console 看到结果)。</para>
|
||||
/// <para>阶段 3 接入 <c>VNDialoguePresenter</c> 后,可从 DialogueRunner 的呈现器列表移除本组件并删除此脚本。</para>
|
||||
/// </summary>
|
||||
public class ConsoleLinePresenter : DialoguePresenterBase
|
||||
{
|
||||
public override YarnTask OnDialogueStartedAsync()
|
||||
{
|
||||
Debug.Log("[ConsoleLinePresenter] === 对话开始 ===");
|
||||
return YarnTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 输出本地化后的台词文本。
|
||||
/// <para>注意:必须 async + YarnTask.Yield()。Yarn Spinner 3.x 的 RunLocalisedLine 在所有
|
||||
/// presenter 均同步完成时会错误地调用 Dialogue.SignalContentComplete()(该方法仅对
|
||||
/// command dispatch 合法),导致 InvalidOperationException。Yield 确保任务进入异步路径。</para>
|
||||
/// </summary>
|
||||
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
|
||||
{
|
||||
string speaker = string.IsNullOrEmpty(line.CharacterName) ? "(旁白)" : line.CharacterName;
|
||||
string body = line.TextWithoutCharacterName.Text;
|
||||
|
||||
Debug.Log($"[ConsoleLinePresenter] {speaker}: {body}\n" +
|
||||
$" (lineID={line.TextID}, rawText=\"{line.RawText}\")");
|
||||
|
||||
await YarnTask.Yield();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调试用:打印所有选项并自动选择第一个可用选项,以便管线可以跑完整个对话树。
|
||||
/// </summary>
|
||||
public override async YarnTask<DialogueOption?> RunOptionsAsync(
|
||||
DialogueOption[] options,
|
||||
LineCancellationToken token)
|
||||
{
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
{
|
||||
string optText = options[i].Line.TextWithoutCharacterName.Text;
|
||||
string tag = options[i].IsAvailable ? "" : " [不可用]";
|
||||
Debug.Log($"[ConsoleLinePresenter] 选项 {i}{tag}: {optText}");
|
||||
}
|
||||
|
||||
// 自动选择第一个可用选项
|
||||
foreach (DialogueOption opt in options)
|
||||
{
|
||||
if (opt.IsAvailable)
|
||||
{
|
||||
Debug.Log($"[ConsoleLinePresenter] → 自动选择: {opt.Line.TextWithoutCharacterName.Text}");
|
||||
await YarnTask.Yield();
|
||||
return opt;
|
||||
}
|
||||
}
|
||||
|
||||
// 无可用选项时返回 null(触发 DialogueRunner 的 fallthrough 逻辑)
|
||||
await YarnTask.Yield();
|
||||
return null;
|
||||
}
|
||||
|
||||
public override YarnTask OnDialogueCompleteAsync()
|
||||
{
|
||||
Debug.Log("[ConsoleLinePresenter] === 对话结束 ===");
|
||||
return YarnTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f38c46a2f0519249b2b9efb27734c13
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Ichni.Story.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using Yarn.Unity;
|
||||
using Yarn.Unity.UnityLocalization;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 故事树 block 与 Yarn <see cref="DialogueRunner"/> 之间的桥接控制器。
|
||||
/// <para>点击文本块 → 启动对应 Yarn 节点;对话结束 → 持久化剧情变量、标记 block 完成、
|
||||
/// 重算解锁并存档、依次执行结束回调(如歌曲解锁提示,阶段 4 使用)。</para>
|
||||
/// </summary>
|
||||
public class StoryDialogueController : MonoBehaviour
|
||||
{
|
||||
public static StoryDialogueController instance;
|
||||
|
||||
[Header("References")]
|
||||
[Tooltip("驱动 Yarn 对话的 DialogueRunner。")]
|
||||
public DialogueRunner dialogueRunner;
|
||||
|
||||
[Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")]
|
||||
public StoryUIPage storyUIPage;
|
||||
|
||||
/// <summary>
|
||||
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令(阶段 4)
|
||||
/// 把"解锁提示"排队到对话结束后弹出。
|
||||
/// </summary>
|
||||
[System.NonSerialized] public List<UnityAction> dialogueEndActions = new List<UnityAction>();
|
||||
|
||||
// 当前正在播放对话的 block id;对话结束时据此标记完成
|
||||
private string _activeBlockId;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
EnsureUnityLocalisedLineProvider();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null)
|
||||
dialogueRunner.onDialogueComplete.AddListener(HandleDialogueComplete);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (dialogueRunner != null && dialogueRunner.onDialogueComplete != null)
|
||||
dialogueRunner.onDialogueComplete.RemoveListener(HandleDialogueComplete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放某文本块对应的 Yarn 节点。若对话已在进行、引用缺失或节点名为空则忽略。
|
||||
/// </summary>
|
||||
public void PlayBlock(TextBlockView block)
|
||||
{
|
||||
if (block == null) return;
|
||||
|
||||
if (dialogueRunner == null)
|
||||
{
|
||||
Debug.LogError("[StoryDialogueController] 未配置 DialogueRunner,无法开始对话。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogueRunner.IsDialogueRunning)
|
||||
{
|
||||
Debug.LogWarning("[StoryDialogueController] 已有对话在进行中,忽略本次点击。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(block.YarnNodeName))
|
||||
{
|
||||
Debug.LogWarning($"[StoryDialogueController] block '{block.blockId}' 未配置 yarnNodeName,无法开始对话。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 将 DialogueRunner 的 YarnProject 对齐到当前章节(支持多章节共用一个 Runner)
|
||||
EnsureProjectForCurrentChapter();
|
||||
|
||||
_activeBlockId = block.blockId;
|
||||
|
||||
if (storyUIPage != null)
|
||||
storyUIPage.FadeOut();
|
||||
|
||||
// StartDialogue 返回 YarnTask;此处即发即忘,完成由 onDialogueComplete 处理
|
||||
dialogueRunner.StartDialogue(block.YarnNodeName).Forget();
|
||||
}
|
||||
|
||||
// 将 DialogueRunner 的 YarnProject 切换为当前已构建章节的 StoryData.yarnProject(若不同且未在运行)
|
||||
private void EnsureProjectForCurrentChapter()
|
||||
{
|
||||
StoryTreeController tree = StoryManager.instance != null ? StoryManager.instance.treeController : null;
|
||||
StoryData data = tree != null ? tree.ActiveStoryData : null;
|
||||
|
||||
if (data == null || data.yarnProject == null) return;
|
||||
|
||||
if (dialogueRunner.YarnProject != data.yarnProject && !dialogueRunner.IsDialogueRunning)
|
||||
dialogueRunner.SetProject(data.yarnProject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DialogueRunner.lineProvider 为 [SerializeReference] internal 字段。
|
||||
/// Inspector 的 "Use Unity Localised Line Provider" 按钮有时无法将 MonoBehaviour
|
||||
/// 引用正确持久化到该字段;此方法在运行时用反射补救,确保使用正确的 Provider。
|
||||
/// </summary>
|
||||
private void EnsureUnityLocalisedLineProvider()
|
||||
{
|
||||
if (dialogueRunner == null) return;
|
||||
|
||||
FieldInfo field = typeof(DialogueRunner).GetField(
|
||||
"lineProvider",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
|
||||
if (field == null) return;
|
||||
|
||||
// 已经是 UnityLocalisedLineProvider 则无需处理
|
||||
if (field.GetValue(dialogueRunner) is UnityLocalisedLineProvider) return;
|
||||
|
||||
UnityLocalisedLineProvider provider =
|
||||
dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
|
||||
|
||||
if (provider != null)
|
||||
{
|
||||
field.SetValue(dialogueRunner, provider);
|
||||
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[StoryDialogueController] UnityLocalisedLineProvider not found on DialogueRunner's GameObject. Localized line text will be unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDialogueComplete()
|
||||
{
|
||||
// 持久化对话过程中写入的全局剧情变量
|
||||
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
|
||||
GameSaveManager.instance.StorySaveModule.SaveVariables();
|
||||
|
||||
// 标记 block 完成 → 重算解锁 → 存档(OnBlockCompleted 内部已去重并保存章节进度)
|
||||
if (!string.IsNullOrEmpty(_activeBlockId) &&
|
||||
StoryManager.instance != null && StoryManager.instance.treeController != null)
|
||||
{
|
||||
StoryManager.instance.treeController.OnBlockCompleted(_activeBlockId);
|
||||
}
|
||||
|
||||
_activeBlockId = null;
|
||||
|
||||
// 依次执行并清空结束回调(如歌曲解锁提示)
|
||||
if (dialogueEndActions.Count > 0)
|
||||
{
|
||||
List<UnityAction> pending = new List<UnityAction>(dialogueEndActions);
|
||||
dialogueEndActions.Clear();
|
||||
foreach (UnityAction action in pending)
|
||||
action?.Invoke();
|
||||
}
|
||||
|
||||
if (storyUIPage != null)
|
||||
storyUIPage.FadeIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c5da6d59dd1b3b4594e01d045b271c8
|
||||
207
Assets/Scripts/NewStorySystem/Dialogue/StoryVariableStorage.cs
Normal file
207
Assets/Scripts/NewStorySystem/Dialogue/StoryVariableStorage.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace Ichni.Story.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 自定义 Yarn 变量存储。直接读写 <see cref="StorySaveModule.variables"/>(全局剧情变量),
|
||||
/// 让 Yarn 对话中的 <c><<set $var to ...>></c> 与故事树解锁条件共享同一份数据来源,
|
||||
/// 并借助存档模块的 ES3 序列化持久化。
|
||||
/// <para>变量名沿用 Yarn 约定(带 <c>$</c> 前缀);解锁条件中的变量名也应保持一致。</para>
|
||||
/// </summary>
|
||||
public class StoryVariableStorage : VariableStorageBehaviour
|
||||
{
|
||||
// GameSaveManager 尚未就绪(编辑器早期 / 场景未初始化)时的兜底容器
|
||||
private readonly StoryVariablesSave _fallback = new StoryVariablesSave();
|
||||
|
||||
/// <summary>当前生效的变量容器:优先取存档模块,缺失时用本地兜底容器。</summary>
|
||||
private StoryVariablesSave Variables
|
||||
{
|
||||
get
|
||||
{
|
||||
if (GameSaveManager.instance != null && GameSaveManager.instance.StorySaveModule != null)
|
||||
return GameSaveManager.instance.StorySaveModule.variables;
|
||||
return _fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IVariableStorage 实现 ────────────────────────────────────────────────
|
||||
|
||||
public override bool TryGetValue<T>(string variableName, out T result)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
if (vars.floatVariables.TryGetValue(variableName, out float f))
|
||||
return TryConvert(f, out result);
|
||||
|
||||
if (vars.boolVariables.TryGetValue(variableName, out bool b))
|
||||
return TryConvert(b, out result);
|
||||
|
||||
if (vars.stringVariables.TryGetValue(variableName, out string s))
|
||||
return TryConvert(s, out result);
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, string stringValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: false, keepString: true, keepBool: false);
|
||||
Variables.stringVariables[variableName] = stringValue;
|
||||
NotifyVariableChanged(variableName, stringValue);
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, float floatValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: true, keepString: false, keepBool: false);
|
||||
Variables.floatVariables[variableName] = floatValue;
|
||||
NotifyVariableChanged(variableName, floatValue);
|
||||
}
|
||||
|
||||
public override void SetValue(string variableName, bool boolValue)
|
||||
{
|
||||
RemoveStaleEntries(variableName, keepFloat: false, keepString: false, keepBool: true);
|
||||
Variables.boolVariables[variableName] = boolValue;
|
||||
NotifyVariableChanged(variableName, boolValue);
|
||||
}
|
||||
|
||||
public override bool Contains(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
return vars.floatVariables.ContainsKey(variableName)
|
||||
|| vars.boolVariables.ContainsKey(variableName)
|
||||
|| vars.stringVariables.ContainsKey(variableName);
|
||||
}
|
||||
|
||||
public override void Clear()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
vars.floatVariables.Clear();
|
||||
vars.stringVariables.Clear();
|
||||
vars.boolVariables.Clear();
|
||||
}
|
||||
|
||||
public override void SetAllVariables(
|
||||
Dictionary<string, float> floats,
|
||||
Dictionary<string, string> strings,
|
||||
Dictionary<string, bool> bools,
|
||||
bool clear = true)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
if (clear)
|
||||
{
|
||||
vars.floatVariables.Clear();
|
||||
vars.stringVariables.Clear();
|
||||
vars.boolVariables.Clear();
|
||||
}
|
||||
|
||||
if (floats != null)
|
||||
foreach (KeyValuePair<string, float> kv in floats) vars.floatVariables[kv.Key] = kv.Value;
|
||||
if (strings != null)
|
||||
foreach (KeyValuePair<string, string> kv in strings) vars.stringVariables[kv.Key] = kv.Value;
|
||||
if (bools != null)
|
||||
foreach (KeyValuePair<string, bool> kv in bools) vars.boolVariables[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
public override (Dictionary<string, float> FloatVariables,
|
||||
Dictionary<string, string> StringVariables,
|
||||
Dictionary<string, bool> BoolVariables) GetAllVariables()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
return (
|
||||
new Dictionary<string, float>(vars.floatVariables),
|
||||
new Dictionary<string, string>(vars.stringVariables),
|
||||
new Dictionary<string, bool>(vars.boolVariables)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 供故事树解锁条件复用的便捷读取 ────────────────────────────────────────
|
||||
|
||||
/// <summary>读取变量的整数近似值(float 四舍五入、bool 取 0/1);不存在返回 0。</summary>
|
||||
public int GetIntVariable(string variableName)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
if (vars.floatVariables.TryGetValue(variableName, out float f))
|
||||
return Mathf.RoundToInt(f);
|
||||
if (vars.boolVariables.TryGetValue(variableName, out bool b))
|
||||
return b ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>该变量是否存在于任一类型的存储中。</summary>
|
||||
public bool HasVariable(string variableName) => Contains(variableName);
|
||||
|
||||
// ── 内部 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 当变量被重新赋值为其它类型时,从旧类型字典移除,避免同名变量残留多份
|
||||
private void RemoveStaleEntries(string name, bool keepFloat, bool keepString, bool keepBool)
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
if (!keepFloat) vars.floatVariables.Remove(name);
|
||||
if (!keepString) vars.stringVariables.Remove(name);
|
||||
if (!keepBool) vars.boolVariables.Remove(name);
|
||||
}
|
||||
|
||||
// 将存储中的原始值转换为 Yarn 请求的类型(float / bool / string / int 之间互转)
|
||||
private static bool TryConvert<T>(object value, out T result)
|
||||
{
|
||||
if (value is T typed)
|
||||
{
|
||||
result = typed;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (typeof(T) == typeof(float) || typeof(T) == typeof(double) || typeof(T) == typeof(int))
|
||||
{
|
||||
float f = value switch
|
||||
{
|
||||
float fv => fv,
|
||||
bool bv => bv ? 1f : 0f,
|
||||
string sv => float.TryParse(sv, NumberStyles.Any, CultureInfo.InvariantCulture, out float p) ? p : 0f,
|
||||
_ => 0f
|
||||
};
|
||||
result = (T)System.Convert.ChangeType(f, typeof(T), CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(bool))
|
||||
{
|
||||
bool b = value switch
|
||||
{
|
||||
bool bv => bv,
|
||||
float fv => fv != 0f,
|
||||
string sv => bool.TryParse(sv, out bool p) && p,
|
||||
_ => false
|
||||
};
|
||||
result = (T)(object)b;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(string))
|
||||
{
|
||||
string s = value switch
|
||||
{
|
||||
string sv => sv,
|
||||
float fv => fv.ToString(CultureInfo.InvariantCulture),
|
||||
bool bv => bv.ToString(),
|
||||
_ => value != null ? value.ToString() : string.Empty
|
||||
};
|
||||
result = (T)(object)s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 转换失败时落到下方失败分支
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7dc7dc14d9f97c043bf6fb67c3a70877
|
||||
320
Assets/Scripts/NewStorySystem/Dialogue/VNDialoguePresenter.cs
Normal file
320
Assets/Scripts/NewStorySystem/Dialogue/VNDialoguePresenter.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7b08c1a67d3d65478d9d9b4e275393f
|
||||
Reference in New Issue
Block a user