Files
zeling_v2/Assets/_Game/Scripts/Dialogue/DialogueManager.cs
Joywayer 6eaa83dc71 feat: Round 48 narrative systems improvements
- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop
- QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip
- IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info)
- IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it
- IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event
- QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions
- DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix)
- DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks
- DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match
- WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API
- DialogueModule: List badge shows warning indicator for unconditional-shadowing variants
- DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 00:05:15 +08:00

418 lines
20 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;
using System.Collections.Generic;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Input;
using BaseGames.World;
using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话管理器(架构 14_NarrativeModule §4
/// 驱动 DialogueUI 打字机效果;管理 Action Map 切换;向 QuestManager 广播对话完成事件。
/// 在 Awake 中注册到 ServiceLocator。
/// </summary>
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_NpcDialogueCompletedpayload = npcIdstring。每段对话结束时广播驱动 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 = 不限时(不推荐用于正式发布)。推荐 300s5 分钟)覆盖最长剧情段落。")]
[Min(0)] [SerializeField] private float _sequenceTimeoutSeconds = 300f;
private bool _skipRequested;
private int _selectedChoiceIndex = -1;
private int _choiceDepth;
/// <summary>
/// 每次 PlayImmediate 递增。HandleChoices 的选项回调在写入 _selectedChoiceIndex 前
/// 比对此值,确保打断后遗留的回调不会污染新序列的状态。
/// </summary>
private int _playbackId;
// ── 子协程通信字段(避免协程间 ref/out 参数)─────────────────────────
/// <summary>HandleChoices 子协程写入结果玩家选中选项后的后续序列null = 无后续)。</summary>
private DialogueSequenceSO _choiceBranchResult;
/// <summary>HandleChoices 子协程写入结果true = 分支深度超限,优雅降级(继续播放后续行)。</summary>
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;
/// <summary>
/// 当 IsDialogueActive 时排队等待的对话请求。
/// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话),
/// 但容量上限为 8避免因误触导致无限排队。
/// </summary>
private readonly Queue<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new();
/// <summary>当前是否有对话正在播放。</summary>
public bool IsDialogueActive { get; private set; }
/// <summary>当前正在播放的对话优先级0 = 默认)。高优先级请求可打断低优先级。</summary>
private int _currentPriority;
/// <inheritdoc/>
public event System.Action OnDialogueEnded;
// ── 生命周期 ──────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<IDialogueService>(this);
_waitTypingOrSkip = new WaitTypingOrSkip(this);
_waitSkip = new WaitSkip(this);
_waitForChoice = new WaitForChoice(this);
}
private void OnDestroy()
{
ServiceLocator.Unregister<IDialogueService>(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 ──────────────────────────────────────────────────────
/// <summary>
/// 启动对话序列。
/// 若已有对话在播放:
/// - 当 <paramref name="priority"/> 高于当前对话优先级时,立即打断并播放新序列;
/// - 否则进入等待队列(上限 <see cref="_pendingQueueCapacity"/>),超出上限的请求被丢弃。
/// 由 InteractableNPC.Interact() 调用。
/// </summary>
/// <param name="sequence">要播放的对话序列 SO。</param>
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
/// <param name="priority">优先级。数值越大越优先;相同或更低优先级不会打断当前对话。</param>
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));
}
/// <summary>
/// 超时守卫协程:若对话在 <see cref="_sequenceTimeoutSeconds"/> 内未正常结束,
/// 强制终止并记录错误,防止游戏卡死在对话状态。
/// </summary>
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();
}
/// <summary>
/// 强制立即终止当前对话,清空等待队列,恢复游戏输入。
/// 场景切换/演出打断时调用;若无对话活跃则无操作。
/// </summary>
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<DialogueSequenceSO>();
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);
}
/// <summary>
/// 显示一行对话并等待打字机效果完成(期间允许跳过)。
/// 广播 EVT_LineStarted。不广播 EVT_LineEnded由调用方在推进后广播
/// </summary>
private IEnumerator PlayOneLine(DialogueLine line)
{
_skipRequested = false;
_dialogueBox.ShowLine(line);
_onLineStarted?.Raise();
yield return _waitTypingOrSkip;
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
}
/// <summary>
/// 显示分支选项,等待玩家选择,并将结果写入 <see cref="_choiceBranchResult"/>。
/// <para>若选项嵌套深度超过 <see cref="_maxChoiceDepth"/>,将 <see cref="_branchDepthExceeded"/>
/// 置为 true 并立即返回,调用方应优雅降级继续播放后续行而不是终止对话。</para>
/// </summary>
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);
}
}
/// <summary>
/// 根据 WorldState 标志选择正确的序列版本。
/// 委托给 <see cref="DialogueSequenceSO.TryGetActiveVariant"/> 统一处理,消除重复逻辑。
/// </summary>
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;
}
}
}