- DialogueManager.EndDialogue: dequeue by max-priority index instead of FIFO index-0 - DialogueManager.EndDialogue: fire _onCompleteCallback on normal end (was only in ForceEnd) - NpcSO.OnValidate: auto-restore localizationTable to 'UI' if cleared - QuestSO.ValidateObjectiveIds: validate prerequisiteObjectiveId references exist in same quest - QuestSO.OnValidate: remove call to empty ValidateBranchDialogueKeys stub + remove the stub itself - QuestManager.DispatchEvent toFail loop: write _completedAtUtc timestamp on quest failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
470 lines
23 KiB
C#
470 lines
23 KiB
C#
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_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;
|
||
/// <summary>
|
||
/// 每次 PlayImmediate 递增。HandleChoices 的选项回调在写入 _selectedChoiceIndex 前
|
||
/// 比对此值,确保打断后遗留的回调不会污染新序列的状态。
|
||
/// </summary>
|
||
private int _playbackId;
|
||
|
||
// ── 一次性对话完成回调 ────────────────────────────────────────────────
|
||
// 通过 StartDialogue(..., onComplete) 注册;OnDialogueEnded 触发后调用一次后清空。
|
||
private System.Action _onCompleteCallback;
|
||
|
||
// ── 子协程通信字段(避免协程间 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,避免因误触导致无限排队。
|
||
/// 使用 List 而非 Queue,以支持基于优先级的抢占式淘汰(队满时丢弃最低优先级项目)。
|
||
/// </summary>
|
||
private readonly List<(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.Add((sequence, npcId, priority));
|
||
}
|
||
else
|
||
{
|
||
// 队满时:查找优先级最低的项目,若新请求优先级更高则淘汰之,否则丢弃新请求
|
||
int minIdx = 0;
|
||
for (int i = 1; i < _pending.Count; i++)
|
||
{
|
||
if (_pending[i].priority < _pending[minIdx].priority) minIdx = i;
|
||
}
|
||
if (priority > _pending[minIdx].priority)
|
||
{
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
Debug.LogWarning(
|
||
$"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," +
|
||
$"序列 '{_pending[minIdx].seq.sequenceId}'(优先级 {_pending[minIdx].priority})" +
|
||
$"被优先级更高的 '{sequence.sequenceId}'(优先级 {priority})淘汰。");
|
||
#endif
|
||
_pending.RemoveAt(minIdx);
|
||
_pending.Add((sequence, npcId, priority));
|
||
}
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
else
|
||
Debug.LogWarning(
|
||
$"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," +
|
||
$"序列 '{sequence.sequenceId}'(优先级 {priority})被丢弃,队列中最低优先级为 {_pending[minIdx].priority}。");
|
||
#endif
|
||
}
|
||
return;
|
||
}
|
||
PlayImmediate(sequence, npcId, priority);
|
||
}
|
||
|
||
/// <inheritdoc cref="IDialogueService.StartDialogue(DialogueSequenceSO,string,int,System.Action)"/>
|
||
public void StartDialogue(DialogueSequenceSO sequence, string npcId, int priority, System.Action onComplete)
|
||
{
|
||
if (onComplete != null)
|
||
{
|
||
// 若已有待回调,链式追加(不覆盖),保证先来先到
|
||
_onCompleteCallback += onComplete;
|
||
}
|
||
StartDialogue(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();
|
||
|
||
// 触发一次性完成回调(正常结束和强制中断均触发)
|
||
var cb = _onCompleteCallback;
|
||
_onCompleteCallback = null;
|
||
cb?.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);
|
||
|
||
// 触发一次性完成回调(正常结束和强制中断均触发)
|
||
var cb = _onCompleteCallback;
|
||
_onCompleteCallback = null;
|
||
cb?.Invoke();
|
||
|
||
// 自动播放优先级最高的等待中对话(保证高优先级对话不被低优先级插队)
|
||
if (_pending.Count > 0)
|
||
{
|
||
int best = 0;
|
||
for (int i = 1; i < _pending.Count; i++)
|
||
if (_pending[i].priority > _pending[best].priority) best = i;
|
||
var item = _pending[best];
|
||
_pending.RemoveAt(best);
|
||
PlayImmediate(item.seq, item.npcId, item.priority);
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
}
|