using System.Collections; using System.Collections.Generic; using BaseGames.Core; using BaseGames.Core.Events; using BaseGames.Input; using BaseGames.World; using UnityEngine; namespace BaseGames.Dialogue { /// /// 对话管理器(架构 14_NarrativeModule §4)。 /// 驱动 DialogueUI 打字机效果;管理 Action Map 切换;向 QuestManager 广播对话完成事件。 /// 在 Awake 中注册到 ServiceLocator。 /// public class DialogueManager : MonoBehaviour, IDialogueService { [Header("依赖")] [Tooltip("对话 UI 组件。负责打字机效果、头像/说话人渲染、显示隐藏。")] [SerializeField] private DialogueUI _dialogueBox; [Tooltip("输入读取器 SO。监听 SubmitEvent(确认/跳过)推进对话行。")] [SerializeField] private InputReaderSO _inputReader; [Tooltip("世界状态注册表 SO。对话序列 variants 条件分支据此读取标志位。")] [SerializeField] private WorldStateRegistry _worldState; [Header("事件频道")] [Tooltip("EVT_DialogueStarted:对话序列开始时广播(无 payload)。供输入系统切换 Action Map 至 UI 模式等监听。")] [SerializeField] private VoidEventChannelSO _onDialogueStarted; [Tooltip("EVT_DialogueEnded:对话序列全部行播完后广播(无 payload)。\n" + "【重要】输入系统应监听此事件切回 Gameplay Action Map;\n" + "若未连接此频道,DialogueManager 会直接调用 InputReader.EnableGameplayInput() 作为兜底。")] [SerializeField] private VoidEventChannelSO _onDialogueEnded; [Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。每段对话结束时广播,驱动 QuestManager 中对话类目标进度。")] [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; [Tooltip("EVT_LineStarted:每行对话开始打字时广播(无 payload)。供音效/震动/打字音效系统监听。")] [SerializeField] private VoidEventChannelSO _onLineStarted; [Tooltip("EVT_LineEnded:玩家按确认键推进到下一行时广播(无 payload)。供音效/震动系统监听。")] [SerializeField] private VoidEventChannelSO _onLineEnded; [Tooltip("EVT_DialogueForceEnded:对话序列因超时被强制终止时广播(payload = npcId)。\n" + "供埋点/异常追踪系统监听,以区分正常结束和超时强制中断。\n" + "可选字段,留空则不广播此专用事件(ForceEnd 仍正常执行)。")] [SerializeField] private StringEventChannelSO _onDialogueForceEnded; [Tooltip("EVT_DialogueChoiceSelected:玩家选择对话选项时广播(payload = \"sequenceId/choiceIndex\")。\n" + "供 QA 埋点、成就系统、或数据分析监听,以还原玩家的对话选择路径。\n" + "可选字段,留空则不广播。")] [SerializeField] private StringEventChannelSO _onDialogueChoiceSelected; [Header("运行时限制")] [Tooltip("分支选项最大嵌套深度。超过此深度触发循环引用保护,跳过当前分支继续播放。\n" + "普通对话通常不超过 6 层;极端场景可调高,但推荐保持默认值 16。")] [Min(1)] [SerializeField] private int _maxChoiceDepth = 16; [Tooltip("待播对话序列队列容量上限。超过后新请求将被丢弃并记录警告。\n" + "用于防止事件链或脚本误触导致无限排队。")] [Min(1)] [SerializeField] private int _pendingQueueCapacity = 8; [Tooltip("单段对话序列的最长播放时间(秒)。超时后强制结束当前序列,防止异常卡死。\n" + "0 = 不限时(不推荐用于正式发布)。推荐 300s(5 分钟)覆盖最长剧情段落。")] [Min(0)] [SerializeField] private float _sequenceTimeoutSeconds = 300f; private bool _skipRequested; private int _selectedChoiceIndex = -1; private int _choiceDepth; /// /// 每次 PlayImmediate 递增。HandleChoices 的选项回调在写入 _selectedChoiceIndex 前 /// 比对此值,确保打断后遗留的回调不会污染新序列的状态。 /// private int _playbackId; // ── 子协程通信字段(避免协程间 ref/out 参数)───────────────────────── /// HandleChoices 子协程写入结果:玩家选中选项后的后续序列(null = 无后续)。 private DialogueSequenceSO _choiceBranchResult; /// HandleChoices 子协程写入结果:true = 分支深度超限,优雅降级(继续播放后续行)。 private bool _branchDepthExceeded; // ── 复用 Yield 指令,避免协程每行 new WaitUntil 闭包 ─────────────── private sealed class WaitTypingOrSkip : CustomYieldInstruction { private readonly DialogueManager _m; public WaitTypingOrSkip(DialogueManager m) => _m = m; public override bool keepWaiting => _m._dialogueBox.IsTyping && !_m._skipRequested; } private sealed class WaitSkip : CustomYieldInstruction { private readonly DialogueManager _m; public WaitSkip(DialogueManager m) => _m = m; public override bool keepWaiting => !_m._skipRequested; } // 等待玩家从分支选项中做出选择(_selectedChoiceIndex >= 0 时解除阻塞) private sealed class WaitForChoice : CustomYieldInstruction { private readonly DialogueManager _m; public WaitForChoice(DialogueManager m) => _m = m; public override bool keepWaiting => _m._selectedChoiceIndex < 0; } private WaitTypingOrSkip _waitTypingOrSkip; private WaitSkip _waitSkip; private WaitForChoice _waitForChoice; /// /// 当 IsDialogueActive 时排队等待的对话请求。 /// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话), /// 但容量上限为 8,避免因误触导致无限排队。 /// private readonly Queue<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new(); /// 当前是否有对话正在播放。 public bool IsDialogueActive { get; private set; } /// 当前正在播放的对话优先级(0 = 默认)。高优先级请求可打断低优先级。 private int _currentPriority; /// public event System.Action OnDialogueEnded; // ── 生命周期 ────────────────────────────────────────────────────── private void Awake() { if (ServiceLocator.GetOrDefault() != null) { Destroy(gameObject); return; } ServiceLocator.Register(this); _waitTypingOrSkip = new WaitTypingOrSkip(this); _waitSkip = new WaitSkip(this); _waitForChoice = new WaitForChoice(this); } private void OnDestroy() { ServiceLocator.Unregister(this); } private void OnEnable() { if (_inputReader != null) _inputReader.SubmitEvent += OnSubmit; } private void OnDisable() { if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit; // 若对话协程在组件禁用或场景切换时仍在运行,Unity 会强制杀死协程但不调用 // EndDialogue(),导致 Action Map 永久停留在 UI 模式。复用 ForceEnd() 统一处理。 ForceEnd(); } // ── 公开 API ────────────────────────────────────────────────────── /// /// 启动对话序列。 /// 若已有对话在播放: /// - 当 高于当前对话优先级时,立即打断并播放新序列; /// - 否则进入等待队列(上限 ),超出上限的请求被丢弃。 /// 由 InteractableNPC.Interact() 调用。 /// /// 要播放的对话序列 SO。 /// NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。 /// 优先级。数值越大越优先;相同或更低优先级不会打断当前对话。 public void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0) { if (sequence == null) return; if (IsDialogueActive) { // 高优先级:打断当前对话,立即播放 if (priority > _currentPriority) { StopAllCoroutines(); _dialogueBox?.HideChoices(); IsDialogueActive = false; _skipRequested = false; _selectedChoiceIndex = -1; _choiceDepth = 0; // 不清空队列,被打断的对话之后仍可继续播放 PlayImmediate(sequence, npcId, priority); return; } if (_pending.Count < _pendingQueueCapacity) _pending.Enqueue((sequence, npcId, priority)); #if UNITY_EDITOR || DEVELOPMENT_BUILD else Debug.LogWarning( $"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," + $"序列 '{sequence.sequenceId}' 被丢弃。可在 Inspector 中调大 _pendingQueueCapacity。"); #endif return; } PlayImmediate(sequence, npcId, priority); } private void PlayImmediate(DialogueSequenceSO sequence, string npcId, int priority = 0) { IsDialogueActive = true; _currentPriority = priority; _skipRequested = false; _selectedChoiceIndex = -1; _choiceDepth = 0; _playbackId++; if (_inputReader != null) _inputReader.EnableUIInput(); _onDialogueStarted?.Raise(); StartCoroutine(PlaySequence(sequence, npcId)); // 启动超时守卫(0 = 不限时) if (_sequenceTimeoutSeconds > 0f) StartCoroutine(SequenceTimeoutGuard(npcId)); } /// /// 超时守卫协程:若对话在 内未正常结束, /// 强制终止并记录错误,防止游戏卡死在对话状态。 /// private IEnumerator SequenceTimeoutGuard(string npcId) { yield return new WaitForSeconds(_sequenceTimeoutSeconds); if (!IsDialogueActive) yield break; Debug.LogError( $"[DialogueManager] 对话序列 (npcId='{npcId}') 超时 {_sequenceTimeoutSeconds}s 未结束," + "强制终止。请检查是否存在无法退出的等待分支。"); _onDialogueForceEnded?.Raise(npcId); ForceEnd(); } /// /// 强制立即终止当前对话,清空等待队列,恢复游戏输入。 /// 场景切换/演出打断时调用;若无对话活跃则无操作。 /// public void ForceEnd() { if (!IsDialogueActive) return; StopAllCoroutines(); _playbackId++; // 使所有残余的选项回调失效,防止下一帧写入新序列状态 _pending.Clear(); _dialogueBox?.HideChoices(); _dialogueBox?.Hide(); IsDialogueActive = false; _currentPriority = 0; _skipRequested = false; _selectedChoiceIndex = -1; _choiceDepth = 0; // 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入; // 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。 _onDialogueEnded?.Raise(); if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput(); OnDialogueEnded?.Invoke(); } // ── 输入回调 ────────────────────────────────────────────────────── private void OnSubmit() => _skipRequested = true; // ── 内部协程 ────────────────────────────────────────────────────── private IEnumerator PlaySequence(DialogueSequenceSO startSequence, string npcId) { if (_dialogueBox == null) { Debug.LogError("[DialogueManager] _dialogueBox 未配置,对话无法显示。请在 Inspector 中指定 DialogueUI 组件。", this); EndDialogue(npcId); yield break; } // 使用显式序列栈替代递归,防止深链(100+ 序列)时 C# 调用栈溢出 var sequenceStack = new System.Collections.Generic.Stack(); sequenceStack.Push(startSequence); while (sequenceStack.Count > 0) { var sequence = sequenceStack.Pop(); var resolved = ResolveVariant(sequence); if (resolved.lines == null || resolved.lines.Length == 0) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning( $"[DialogueManager] 序列 '{resolved.sequenceId}' 没有对话行(lines 为空)。" + "对话将静默跳过此序列,可能是未完成配置。"); #endif continue; } bool branchChosen = false; foreach (var line in resolved.lines) { yield return StartCoroutine(PlayOneLine(line)); if (line.choices != null && line.choices.Length > 0) { yield return StartCoroutine(HandleChoices(line, resolved.sequenceId)); if (!_branchDepthExceeded) { if (_choiceBranchResult != null) sequenceStack.Push(_choiceBranchResult); branchChosen = true; break; } // 深度超限:优雅降级,继续播放当前序列后续行 continue; } // 普通行:等待玩家按 Submit 推进 _skipRequested = false; yield return _waitSkip; _onLineEnded?.Raise(); } _ = branchChosen; } EndDialogue(npcId); } /// /// 显示一行对话并等待打字机效果完成(期间允许跳过)。 /// 广播 EVT_LineStarted。不广播 EVT_LineEnded(由调用方在推进后广播)。 /// private IEnumerator PlayOneLine(DialogueLine line) { _skipRequested = false; _dialogueBox.ShowLine(line); _onLineStarted?.Raise(); yield return _waitTypingOrSkip; if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping(); } /// /// 显示分支选项,等待玩家选择,并将结果写入 。 /// 若选项嵌套深度超过 ,将 /// 置为 true 并立即返回,调用方应优雅降级继续播放后续行而不是终止对话。 /// private IEnumerator HandleChoices(DialogueLine line, string sequenceId) { _choiceBranchResult = null; _branchDepthExceeded = false; _choiceDepth++; if (_choiceDepth >= _maxChoiceDepth) { Debug.LogError( $"[DialogueManager] 分支对话深度超过 {_maxChoiceDepth}," + $"序列 \"{sequenceId}\" 可能存在循环引用。" + "已跳过当前选项分支,继续播放后续内容。"); _dialogueBox?.HideChoices(); _skipRequested = false; _selectedChoiceIndex = -1; _branchDepthExceeded = true; yield break; } // 清除打字机阶段积压的输入,防止选项显示后被立即误触发 _skipRequested = false; _selectedChoiceIndex = -1; // 延迟一帧:确保此前积压的"确认键"输入在下一帧开始前已被消耗, // 防止快速连击(先跳过打字机→再误触发选项0)的穿透问题。 yield return null; // 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引 int capturedId = _playbackId; _dialogueBox.ShowChoices(line.choices, idx => { if (_playbackId == capturedId) _selectedChoiceIndex = idx; }); yield return _waitForChoice; _dialogueBox.HideChoices(); _skipRequested = false; _onLineEnded?.Raise(); var chosen = line.choices[_selectedChoiceIndex]; // 广播选择事件(供 QA 埋点、成就系统、数据分析使用) _onDialogueChoiceSelected?.Raise($"{sequenceId}/{_selectedChoiceIndex}"); // 可选:将世界状态标志写入 WorldStateRegistry if (!string.IsNullOrEmpty(chosen.setWorldFlag) && _worldState != null) _worldState.SetFlag(chosen.setWorldFlag); _choiceBranchResult = chosen.nextSequence; } private void EndDialogue(string npcId) { _dialogueBox?.Hide(); IsDialogueActive = false; _currentPriority = 0; // 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入; // 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。 _onDialogueEnded?.Raise(); if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput(); OnDialogueEnded?.Invoke(); if (!string.IsNullOrEmpty(npcId)) _onNpcDialogueCompleted?.Raise(npcId); // 自动播放队首等待中的对话(脚本触发的连续序列) if (_pending.Count > 0) { var (seq, id, pri) = _pending.Dequeue(); PlayImmediate(seq, id, pri); } } /// /// 根据 WorldState 标志选择正确的序列版本。 /// 委托给 统一处理,消除重复逻辑。 /// private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence) { var resolved = sequence.TryGetActiveVariant(_worldState); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (resolved == sequence && sequence.variants != null && sequence.variants.Length > 0 && _worldState == null) Debug.LogWarning( $"[DialogueManager] 序列 '{sequence.sequenceId}' 有 {sequence.variants.Length} 个条件变体," + "但 WorldStateRegistry 未注入,将使用默认序列。请检查 Inspector 中的 _worldState 字段。", this); #endif return resolved; } } }