2026-07-05 16:08:23 -04:00
using System.Collections.Generic ;
using System.Reflection ;
using Ichni.Story.UI ;
using UnityEngine ;
using UnityEngine.Events ;
2026-07-21 15:24:42 -04:00
using UnityEngine.Localization ;
2026-07-05 16:08:23 -04:00
using Yarn.Unity ;
using Yarn.Unity.UnityLocalization ;
namespace Ichni.Story.Dialogue
{
/// <summary>
/// 故事树 block 与 Yarn <see cref="DialogueRunner"/> 之间的桥接控制器。
/// <para>点击文本块 → 启动对应 Yarn 节点;对话结束 → 持久化剧情变量、标记 block 完成、
2026-07-21 15:24:42 -04:00
/// 重算解锁并存档、依次执行结束回调(例如歌曲解锁提示)。</para>
2026-07-05 16:08:23 -04:00
/// </summary>
public class StoryDialogueController : MonoBehaviour
{
public static StoryDialogueController instance ;
[Header("References")]
[Tooltip("驱动 Yarn 对话的 DialogueRunner。")]
public DialogueRunner dialogueRunner ;
[Tooltip("故事树页面:对话进行时淡出、结束后淡入(可选)。")]
public StoryUIPage storyUIPage ;
2026-07-21 15:24:42 -04:00
[Header("Localization")]
[Tooltip("所有章节 Yarn 台词共用的 Unity Localization String Table( 建议命名 YarnLines) 。留空时保留当前 UnityLocalisedLineProvider 的既有表配置。")]
public LocalizedStringTable yarnLinesTable ;
2026-07-05 16:08:23 -04:00
/// <summary>
2026-07-21 15:24:42 -04:00
/// 对话结束后依次执行并清空的回调队列。供 <c>unlock_song</c> 等 Yarn 命令
2026-07-05 16:08:23 -04:00
/// 把"解锁提示"排队到对话结束后弹出。
/// </summary>
2026-07-21 15:24:42 -04:00
private readonly List < UnityAction > _dialogueEndActions = new List < UnityAction > ( ) ;
2026-07-05 16:08:23 -04:00
// 当前正在播放对话的 block id; 对话结束时据此标记完成
private string _activeBlockId ;
2026-07-21 15:24:42 -04:00
// 当前 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 ;
2026-07-05 16:08:23 -04:00
private void Awake ( )
{
instance = this ;
EnsureUnityLocalisedLineProvider ( ) ;
}
2026-07-21 15:24:42 -04:00
private void Start ( )
{
// 这些引用不会由 StoryData 自检覆盖,因为它们属于场景运行时装配而非单个章节资产。
// 仅输出可定位 Warning, 不阻断编辑器下的部分独立测试流程。
if ( dialogueRunner = = null )
{
Debug . LogWarning ( "[StoryDialogueController] 未配置 DialogueRunner; TextBlock 无法启动对话。" , 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 ) ;
}
2026-07-05 16:08:23 -04:00
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 ) ;
}
2026-07-21 15:24:42 -04:00
private void OnDestroy ( )
{
if ( instance = = this )
instance = null ;
}
2026-07-05 16:08:23 -04:00
/// <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 ( ) ;
2026-07-21 15:24:42 -04:00
// 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 ;
}
2026-07-05 16:08:23 -04:00
_activeBlockId = block . blockId ;
2026-07-21 15:24:42 -04:00
// 在 Yarn 命令或选项改变章节变量前先登记来源,并为 Marker 所在列创建快照。
StoryProgress . BeginBlockMutation ( _activeBlockId ) ;
2026-07-05 16:08:23 -04:00
if ( storyUIPage ! = null )
storyUIPage . FadeOut ( ) ;
2026-07-21 15:24:42 -04:00
// 启动异常会回滚本次章节事务;正常结束仍由 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 ( ) ;
2026-07-05 16:08:23 -04:00
}
// 将 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 ;
2026-07-21 15:24:42 -04:00
UnityLocalisedLineProvider provider = field . GetValue ( dialogueRunner ) as UnityLocalisedLineProvider ;
provider ? ? = dialogueRunner . GetComponent < UnityLocalisedLineProvider > ( ) ;
2026-07-05 16:08:23 -04:00
if ( provider ! = null )
{
2026-07-21 15:24:42 -04:00
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 ) ;
}
2026-07-05 16:08:23 -04:00
}
else
{
Debug . LogWarning ( "[StoryDialogueController] UnityLocalisedLineProvider not found on DialogueRunner's GameObject. Localized line text will be unavailable." ) ;
}
}
private void HandleDialogueComplete ( )
{
2026-07-21 15:24:42 -04:00
// Failed 流程已经清理 activeBlockId 后,底层 Runner 仍可能随后送达完成事件;此时绝不能误提交。
if ( string . IsNullOrEmpty ( _activeBlockId ) )
return ;
2026-07-05 16:08:23 -04:00
2026-07-21 15:24:42 -04:00
if ( _isCancellingDialogue )
{
RestoreCancelledDialogue ( ) ;
return ;
}
// 标记 Block 完成 → 重算解锁 → 保存完整章节容器(含变量、选项与回滚快照)。
2026-07-05 16:08:23 -04:00
if ( ! string . IsNullOrEmpty ( _activeBlockId ) & &
StoryManager . instance ! = null & & StoryManager . instance . treeController ! = null )
{
StoryManager . instance . treeController . OnBlockCompleted ( _activeBlockId ) ;
}
2026-07-21 15:24:42 -04:00
StoryProgress . EndBlockMutation ( _activeBlockId ) ;
2026-07-05 16:08:23 -04:00
_activeBlockId = null ;
2026-07-21 15:24:42 -04:00
// 所有本次 TextBlock 的变量、选项和完成状态至此才一次性写入 ES3。
GameSaveManager . instance ? . StorySaveModule ? . CommitChapterTransaction ( _activeDialogueChapterIndex ) ;
ClearDialogueSessionState ( ) ;
2026-07-05 16:08:23 -04:00
// 依次执行并清空结束回调(如歌曲解锁提示)
2026-07-21 15:24:42 -04:00
if ( _dialogueEndActions . Count > 0 )
2026-07-05 16:08:23 -04:00
{
2026-07-21 15:24:42 -04:00
List < UnityAction > pending = new List < UnityAction > ( _dialogueEndActions ) ;
_dialogueEndActions . Clear ( ) ;
2026-07-05 16:08:23 -04:00
foreach ( UnityAction action in pending )
2026-07-07 01:28:27 -04:00
{
2026-07-05 16:08:23 -04:00
action ? . Invoke ( ) ;
2026-07-07 01:28:27 -04:00
}
2026-07-19 16:44:58 -04:00
// MessageUIPage 会在收到第一项消息/选项请求时自行开启统一队列与淡入,
// 此处不能再次直接 FadeIn, 否则会与当前弹窗流程重复播放页面动画。
2026-07-05 16:08:23 -04:00
}
if ( storyUIPage ! = null )
2026-07-07 01:28:27 -04:00
{
2026-07-05 16:08:23 -04:00
storyUIPage . FadeIn ( ) ;
2026-07-07 01:28:27 -04:00
}
2026-07-21 15:24:42 -04:00
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 ;
2026-07-05 16:08:23 -04:00
}
}
}