2026-06-02 12:55:39 -04:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
2026-06-05 04:21:00 -04:00
|
|
|
|
using Cielonos.MainGame.Interactions;
|
2026-06-02 12:55:39 -04:00
|
|
|
|
using Sirenix.OdinInspector;
|
|
|
|
|
|
using SLSUtilities.General;
|
|
|
|
|
|
using SLSUtilities.Narrative;
|
|
|
|
|
|
using Cielonos.UI;
|
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
|
|
|
|
namespace Cielonos.MainGame.Narrative
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 全局剧情与对话路由器(单例)。
|
|
|
|
|
|
/// 所有触发源统一调用 StoryDirector.Instance.PlayStory(storyId) 来启动剧情。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class StoryDirector : Singleton<StoryDirector>
|
|
|
|
|
|
{
|
2026-06-05 04:21:00 -04:00
|
|
|
|
#region NPC
|
|
|
|
|
|
|
|
|
|
|
|
public static readonly Dictionary<string, NpcBase> ActiveNpcs = new Dictionary<string, NpcBase>();
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Fields & Properties
|
|
|
|
|
|
|
2026-06-02 12:55:39 -04:00
|
|
|
|
[Title("UI Pages")]
|
|
|
|
|
|
[Required]
|
2026-06-05 04:21:00 -04:00
|
|
|
|
[SerializeField]
|
|
|
|
|
|
private DialogUIPage dialogUIPage;
|
2026-06-02 12:55:39 -04:00
|
|
|
|
|
|
|
|
|
|
private readonly Dictionary<string, NarrativeEntry> _entryLookup = new Dictionary<string, NarrativeEntry>();
|
|
|
|
|
|
|
2026-06-05 04:21:00 -04:00
|
|
|
|
/// <summary>当前是否有对话正在运行。</summary>
|
|
|
|
|
|
public bool IsDialogueActive => dialogUIPage != null && dialogUIPage.IsOpen;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>当前正在播放的对话剧情的启动源 ID(通常是 NPC 的 storyId)</summary>
|
|
|
|
|
|
public string ActiveStoryId { get; private set; }
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Events
|
|
|
|
|
|
|
2026-06-02 12:55:39 -04:00
|
|
|
|
public event Action OnDialogueStarted;
|
|
|
|
|
|
public event Action OnDialogueEnded;
|
|
|
|
|
|
|
2026-06-05 04:21:00 -04:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Unity Lifecycle
|
2026-06-02 12:55:39 -04:00
|
|
|
|
|
|
|
|
|
|
protected override void Awake()
|
|
|
|
|
|
{
|
|
|
|
|
|
base.Awake();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void Start()
|
|
|
|
|
|
{
|
|
|
|
|
|
InitializeEntries();
|
|
|
|
|
|
|
|
|
|
|
|
// 订阅 DialogUIPage 关闭事件以分发 OnDialogueEnded
|
|
|
|
|
|
if (dialogUIPage != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
dialogUIPage.PageClosed += HandleDialogueClosed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 订阅通用剧情触发器事件,实现全局联动
|
|
|
|
|
|
NarrativeTrigger.OnNarrativeTriggerFired += PlayStory;
|
2026-06-05 04:21:00 -04:00
|
|
|
|
|
|
|
|
|
|
// 订阅行内动作标记事件,解耦桥接
|
|
|
|
|
|
SLSUtilities.Narrative.UI.AdvancedLinePresenter.OnPlayAnimationRequested += HandlePlayAnimationRequested;
|
|
|
|
|
|
SLSUtilities.Narrative.UI.AdvancedLinePresenter.OnStopAnimationRequested += HandleStopAnimationRequested;
|
2026-06-02 12:55:39 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void OnDestroy()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (dialogUIPage != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
dialogUIPage.PageClosed -= HandleDialogueClosed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 注销通用剧情触发器事件,防止内存泄漏
|
|
|
|
|
|
NarrativeTrigger.OnNarrativeTriggerFired -= PlayStory;
|
2026-06-05 04:21:00 -04:00
|
|
|
|
|
|
|
|
|
|
// 注销行内动作标记事件
|
|
|
|
|
|
SLSUtilities.Narrative.UI.AdvancedLinePresenter.OnPlayAnimationRequested -= HandlePlayAnimationRequested;
|
|
|
|
|
|
SLSUtilities.Narrative.UI.AdvancedLinePresenter.OnStopAnimationRequested -= HandleStopAnimationRequested;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandlePlayAnimationRequested(string animName, string npcName)
|
|
|
|
|
|
{
|
|
|
|
|
|
Cielonos.Narrative.CustomFunctions.PlayAnimation(animName, npcName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleStopAnimationRequested(string npcName)
|
|
|
|
|
|
{
|
|
|
|
|
|
Cielonos.Narrative.CustomFunctions.StopAnimation(npcName);
|
2026-06-02 12:55:39 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 04:21:00 -04:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Narrative Routing & Playback
|
|
|
|
|
|
|
2026-06-02 12:55:39 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 从全局剧情数据库中读取已注册的路由表,构建快速查找字典。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Button("重新加载路由注册表 (Reload Registry)", ButtonSizes.Small)]
|
|
|
|
|
|
public void InitializeEntries()
|
|
|
|
|
|
{
|
|
|
|
|
|
_entryLookup.Clear();
|
|
|
|
|
|
if (StorySystem.Instance == null || StorySystem.Database == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[StoryDirector] StorySystem 实例或 Database 未初始化,跳过路由加载。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (StorySystem.Database.narrativeEntries == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[StoryDirector] 剧情路由表 (narrativeEntries) 列表为空。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var entry in StorySystem.Database.narrativeEntries)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (entry == null) continue;
|
|
|
|
|
|
if (string.IsNullOrEmpty(entry.storyId))
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[StoryDirector] 存在未配置 Story ID 的 NarrativeEntry: {entry.name}");
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!_entryLookup.TryAdd(entry.storyId, entry))
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[StoryDirector] 重复的 Story ID: '{entry.storyId}',已跳过资产: {entry.name}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Debug.Log($"[StoryDirector] 成功初始化 {_entryLookup.Count} 个剧情路由入口。");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 统一的对话启动入口。NPC、区域触发器、道具等交互源均通过此方法启动剧情。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="storyId">对应 NarrativeEntry 的 storyId 标识</param>
|
|
|
|
|
|
public void PlayStory(string storyId)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrEmpty(storyId))
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[StoryDirector] PlayStory 失败:传入的 storyId 为空。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (IsDialogueActive)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[StoryDirector] 对话正在进行中,忽略新的 PlayStory 请求。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 04:21:00 -04:00
|
|
|
|
ActiveStoryId = storyId; // 记录启动源 ID
|
|
|
|
|
|
|
2026-06-02 12:55:39 -04:00
|
|
|
|
if (!_entryLookup.TryGetValue(storyId, out NarrativeEntry entry))
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[StoryDirector] 未找到 ID 为 '{storyId}' 的剧情路由表,尝试直接作为 Yarn 节点播放。");
|
|
|
|
|
|
PlayNode(storyId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 评估路由匹配(首个满足条件的路由)
|
|
|
|
|
|
string targetNode = string.Empty;
|
|
|
|
|
|
bool routeMatched = false;
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var route in entry.routes)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (route == null) continue;
|
|
|
|
|
|
if (NarrativeConditionEvaluator.Evaluate(route.conditions))
|
|
|
|
|
|
{
|
|
|
|
|
|
targetNode = route.targetNode;
|
|
|
|
|
|
routeMatched = true;
|
|
|
|
|
|
Debug.Log($"[StoryDirector] 剧情路由匹配成功!路由备注: '{route.editorNote}' -> 目标 Yarn 节点: '{targetNode}'");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 如果路由均不满足,则使用 Fallback 兜底
|
|
|
|
|
|
if (!routeMatched)
|
|
|
|
|
|
{
|
|
|
|
|
|
targetNode = entry.fallbackNode;
|
|
|
|
|
|
Debug.Log($"[StoryDirector] 未匹配到任何满足的路由条件,使用 Fallback 兜底节点: '{targetNode}'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 校验并播放目标节点
|
|
|
|
|
|
if (string.IsNullOrEmpty(targetNode))
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[StoryDirector] 剧情评估完毕,但无可播放的节点(未配置 Fallback且条件皆不满足)。StoryId: {storyId}");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
PlayNode(targetNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 绕过条件路由直接播放指定的 Yarn 节点名称(一般用于测试、特异流程或调试)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public void PlayNode(string yarnNode)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (dialogUIPage == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogError("[StoryDirector] PlayNode 失败:DialogUIPage 引用未赋值!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (IsDialogueActive)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[StoryDirector] 已经有对话正在进行中,忽略 PlayNode。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Debug.Log($"[StoryDirector] 开始播放剧情节点: '{yarnNode}'");
|
|
|
|
|
|
OnDialogueStarted?.Invoke();
|
|
|
|
|
|
|
|
|
|
|
|
// 打开 UI 页面并引导其启动特定节点
|
|
|
|
|
|
dialogUIPage.Open(yarnNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleDialogueClosed()
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.Log("[StoryDirector] 对话 UI 已关闭,结束当前剧情。");
|
2026-06-05 04:21:00 -04:00
|
|
|
|
ActiveStoryId = null; // 置空启动源
|
2026-06-02 12:55:39 -04:00
|
|
|
|
OnDialogueEnded?.Invoke();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 04:21:00 -04:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region Editor Debugging
|
|
|
|
|
|
|
2026-06-02 12:55:39 -04:00
|
|
|
|
#if UNITY_EDITOR
|
|
|
|
|
|
[Title("调试与验证 (Debug & Test)", titleAlignment: TitleAlignments.Centered)]
|
|
|
|
|
|
[BoxGroup("调试测试")]
|
|
|
|
|
|
[HorizontalGroup("调试测试/Group")]
|
|
|
|
|
|
[HideLabel]
|
|
|
|
|
|
[Tooltip("输入要测试的 NarrativeEntry 的 storyId")]
|
|
|
|
|
|
[InlineButton("TestPlayStory", "测试播放")]
|
|
|
|
|
|
[SerializeField] private string testStoryId;
|
|
|
|
|
|
|
|
|
|
|
|
[HorizontalGroup("调试测试/Group", Width = 120)]
|
|
|
|
|
|
[GUIColor(0.4f, 0.9f, 0.4f)]
|
|
|
|
|
|
public void TestPlayStory()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrEmpty(testStoryId))
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[StoryDirector] 请在文本框中输入要测试的 storyId!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
PlayStory(testStoryId);
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2026-06-05 04:21:00 -04:00
|
|
|
|
|
|
|
|
|
|
#endregion
|
2026-06-02 12:55:39 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|