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

379 lines
17 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.Collections.Generic;
using System.Reflection;
using Ichni.Story.UI;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Localization;
using Yarn.Unity;
using Yarn.Unity.UnityLocalization;
namespace Ichni.Story.Dialogue
{
/// <summary>
/// 故事树 block 与 Yarn <see cref="DialogueRunner"/> 之间的桥接控制器。
/// <para>点击文本块 → 启动对应 Yarn 节点;对话结束 → 持久化剧情变量、标记 block 完成、
/// 重算解锁并存档、依次执行结束回调(例如歌曲解锁提示)。</para>
/// </summary>
public class StoryDialogueController : MonoBehaviour
{
public static StoryDialogueController instance;
[Header("References")]
[Tooltip("驱动 Yarn 对话的 DialogueRunner。")]
public DialogueRunner dialogueRunner;
[Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")]
public StoryUIPage storyUIPage;
[Header("Localization")]
[Tooltip("所有章节 Yarn 台词共用的 Unity Localization String Table建议命名 YarnLines。留空时保留当前 UnityLocalisedLineProvider 的既有表配置。")]
public LocalizedStringTable yarnLinesTable;
/// <summary>
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令
/// 把"解锁提示"排队到对话结束后弹出。
/// </summary>
private readonly List<UnityAction> _dialogueEndActions = new List<UnityAction>();
// 当前正在播放对话的 block id对话结束时据此标记完成
private string _activeBlockId;
// 当前 TextBlock 开始前的章节快照。它只服务于本次“中途退出对话”的恢复,
// 不会被写入 ES3也不会影响歌曲成绩、解锁 Key、Settings 或其它章节。
private ChapterStorySave _activeDialogueSnapshot;
private string _activeDialogueChapterIndex;
private bool _isCancellingDialogue;
/// <summary>
/// 当前对话对应的 TextBlock ID。Yarn 选项记忆与变量指令使用它定位所属章节 Block
/// 不应由其它系统直接赋值。
/// </summary>
public string ActiveBlockId => _activeBlockId;
/// <summary>
/// 当前是否存在尚未提交的 TextBlock 对话事务。
/// Yarn 的全局副作用(例如 grant_unlock、unlock_song、show_message必须据此决定延迟到正常结束后执行
/// 从而避免玩家按 Back 时留下已解锁内容或孤立弹窗。
/// </summary>
public bool IsDialogueTransactionActive =>
!string.IsNullOrEmpty(_activeBlockId) &&
GameSaveManager.instance?.StorySaveModule?.IsChapterTransactionActive(_activeDialogueChapterIndex) == true;
private void Awake()
{
instance = this;
EnsureUnityLocalisedLineProvider();
}
private void Start()
{
// 这些引用不会由 StoryData 自检覆盖,因为它们属于场景运行时装配而非单个章节资产。
// 仅输出可定位 Warning不阻断编辑器下的部分独立测试流程。
if (dialogueRunner == null)
{
Debug.LogWarning("[StoryDialogueController] 未配置 DialogueRunnerTextBlock 无法启动对话。", this);
return;
}
if (dialogueRunner.GetComponent<VNDialoguePresenter>() == null)
Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 VNDialoguePresenter台词和选项不会显示。", dialogueRunner);
if (dialogueRunner.GetComponent<UnityLocalisedLineProvider>() == null)
Debug.LogWarning("[StoryDialogueController] DialogueRunner 缺少 UnityLocalisedLineProvider本地化台词无法解析。", dialogueRunner);
if (storyUIPage == null)
Debug.LogWarning("[StoryDialogueController] 未配置 StoryUIPage对话开始/结束时不会切换剧情树页面。", this);
}
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);
}
private void OnDestroy()
{
if (instance == this)
instance = null;
}
/// <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();
// StartDialogue 对缺失 Node 只会在内部抛出/记录错误;必须在开启事务前预检,
// 否则会留下一个没有机会提交或回滚的半开事务。
if (dialogueRunner.YarnProject?.Program == null ||
!dialogueRunner.YarnProject.Program.Nodes.ContainsKey(block.YarnNodeName))
{
Debug.LogError($"[StoryDialogueController] Yarn Project 中不存在节点 '{block.YarnNodeName}',已取消启动 Block '{block.blockId}'。", dialogueRunner);
return;
}
StoryTreeController treeController = StoryManager.instance?.treeController;
StorySaveModule saveModule = GameSaveManager.instance?.StorySaveModule;
string chapterIndex = treeController?.ActiveChapterIndex;
if (treeController == null || saveModule == null || string.IsNullOrEmpty(chapterIndex))
{
Debug.LogError("[StoryDialogueController] 当前章节存档上下文未就绪,拒绝开始可回滚的 TextBlock 对话。");
return;
}
// 必须在任何 Yarn 命令、选项或 Marker 快照写入前完成复制并开启事务。
// 事务期间所有剧情变化仅停留在内存;正常结束才提交,中途 Back 则完整恢复本副本。
_activeDialogueSnapshot = StorySaveCloneUtility.CloneChapter(saveModule.GetChapter(chapterIndex));
_activeDialogueChapterIndex = chapterIndex;
if (!saveModule.BeginChapterTransaction(chapterIndex))
{
_activeDialogueSnapshot = null;
_activeDialogueChapterIndex = null;
return;
}
_activeBlockId = block.blockId;
// 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。
StoryProgress.BeginBlockMutation(_activeBlockId);
if (storyUIPage != null)
storyUIPage.FadeOut();
// 启动异常会回滚本次章节事务;正常结束仍由 onDialogueComplete 统一提交。
StartDialogueSafely(block.YarnNodeName).Forget();
}
/// <summary>
/// 以受保护方式启动 Yarn。缺失 Provider、Presenter 等启动级异常不应让 StorySave 的事务永久处于活动状态,
/// 因而会被转换为 Failed 流程并完整恢复进入对话前的章节快照。
/// </summary>
private async YarnTask StartDialogueSafely(string yarnNodeName)
{
try
{
await dialogueRunner.StartDialogue(yarnNodeName);
}
catch (System.Exception exception)
{
Debug.LogException(exception, dialogueRunner);
// 启动阶段可能已经让部分 Presenter 进入展示状态;先请求停止,再立即清理事务。
// 后续若收到 Runner 的完成事件,会因 activeBlockId 已被清空而被安全忽略。
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
{
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
dialogueRunner.Stop().Forget();
}
RestoreInterruptedDialogue("启动 Yarn 对话失败");
}
}
/// <summary>
/// 取消当前 TextBlock 对话并恢复进入前的章节剧情快照。
/// <para>Yarn 的 <see cref="DialogueRunner.Stop"/> 仍会触发 onDialogueComplete
/// 因此此处先标记取消状态,完成回调会走恢复流程而不是误将 Block 标记为 Completed。</para>
/// </summary>
public bool CancelActiveDialogue()
{
if (_isCancellingDialogue || string.IsNullOrEmpty(_activeBlockId) ||
dialogueRunner == null || !dialogueRunner.IsDialogueRunning)
{
return false;
}
_isCancellingDialogue = true;
_dialogueEndActions.Clear();
dialogueRunner.GetComponent<VNDialoguePresenter>()?.CancelCurrentPresentation();
dialogueRunner.Stop().Forget();
return true;
}
/// <summary>
/// 将一个全局副作用排入“本次 TextBlock 成功提交后”执行的队列。
/// <para>没有活动对话事务时会立即执行,便于未来在非 TextBlock 环境复用同一批 Yarn 命令。</para>
/// </summary>
public void ExecuteAfterDialogueCommit(UnityAction action)
{
if (action == null)
return;
if (IsDialogueTransactionActive)
{
_dialogueEndActions.Add(action);
return;
}
action.Invoke();
}
// 将 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 provider = field.GetValue(dialogueRunner) as UnityLocalisedLineProvider;
provider ??= dialogueRunner.GetComponent<UnityLocalisedLineProvider>();
if (provider != null)
{
if (field.GetValue(dialogueRunner) != provider)
{
field.SetValue(dialogueRunner, provider);
Debug.Log("[StoryDialogueController] Assigned UnityLocalisedLineProvider to DialogueRunner.lineProvider.");
}
// Yarn 的 Localized Line Provider 本身只持有一个 String Table 引用;统一使用 YarnLines
// 可避免在切换章节时依赖反射替换不同表。未配置时不覆盖旧场景,便于分步迁移现有 Chapter0_Lines。
if (yarnLinesTable != null && !yarnLinesTable.IsEmpty)
{
FieldInfo tableField = typeof(UnityLocalisedLineProvider).GetField(
"stringsTable", BindingFlags.NonPublic | BindingFlags.Instance);
tableField?.SetValue(provider, yarnLinesTable);
}
}
else
{
Debug.LogWarning("[StoryDialogueController] UnityLocalisedLineProvider not found on DialogueRunner's GameObject. Localized line text will be unavailable.");
}
}
private void HandleDialogueComplete()
{
// Failed 流程已经清理 activeBlockId 后,底层 Runner 仍可能随后送达完成事件;此时绝不能误提交。
if (string.IsNullOrEmpty(_activeBlockId))
return;
if (_isCancellingDialogue)
{
RestoreCancelledDialogue();
return;
}
// 标记 Block 完成 → 重算解锁 → 保存完整章节容器(含变量、选项与回滚快照)。
if (!string.IsNullOrEmpty(_activeBlockId) &&
StoryManager.instance != null && StoryManager.instance.treeController != null)
{
StoryManager.instance.treeController.OnBlockCompleted(_activeBlockId);
}
StoryProgress.EndBlockMutation(_activeBlockId);
_activeBlockId = null;
// 所有本次 TextBlock 的变量、选项和完成状态至此才一次性写入 ES3。
GameSaveManager.instance?.StorySaveModule?.CommitChapterTransaction(_activeDialogueChapterIndex);
ClearDialogueSessionState();
// 依次执行并清空结束回调(如歌曲解锁提示)
if (_dialogueEndActions.Count > 0)
{
List<UnityAction> pending = new List<UnityAction>(_dialogueEndActions);
_dialogueEndActions.Clear();
foreach (UnityAction action in pending)
{
action?.Invoke();
}
// MessageUIPage 会在收到第一项消息/选项请求时自行开启统一队列与淡入,
// 此处不能再次直接 FadeIn否则会与当前弹窗流程重复播放页面动画。
}
if (storyUIPage != null)
{
storyUIPage.FadeIn();
}
DialogueHistory.Clear();
}
/// <summary>
/// 处理 Back 触发的 Yarn 停止回调:放弃运行期间的全部章节剧情改动,
/// 并只刷新当前已实例化的 StoryTree保留玩家拖动剧情树后的视口位置。
/// </summary>
private void RestoreCancelledDialogue()
{
RestoreInterruptedDialogue("玩家中途退出对话");
}
/// <summary>
/// 取消和失败共用的事务回滚出口。该方法具有幂等性:无活动 Block 时直接返回,
/// 因而底层 Yarn 在 Stop/异常后重复触发完成事件也不会把已回滚的数据再次提交。
/// </summary>
private void RestoreInterruptedDialogue(string reason)
{
if (string.IsNullOrEmpty(_activeBlockId))
return;
string interruptedBlockId = _activeBlockId;
string chapterIndex = _activeDialogueChapterIndex;
StoryTreeController treeController = StoryManager.instance?.treeController;
Debug.LogWarning($"[StoryDialogueController] {reason},已回滚 TextBlock '{interruptedBlockId}' 的章节剧情事务。");
StoryProgress.EndBlockMutation(interruptedBlockId);
GameSaveManager.instance?.StorySaveModule?.RollbackChapterTransaction(
chapterIndex, _activeDialogueSnapshot);
treeController?.ReloadActiveChapterProgress();
_dialogueEndActions.Clear();
DialogueHistory.Clear();
_activeBlockId = null;
ClearDialogueSessionState();
storyUIPage?.FadeIn();
}
private void ClearDialogueSessionState()
{
_activeDialogueSnapshot = null;
_activeDialogueChapterIndex = null;
_isCancellingDialogue = false;
}
}
}