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>
This commit is contained in:
2026-05-25 00:05:15 +08:00
parent 446fd5dcd0
commit 6eaa83dc71
72 changed files with 7080 additions and 373 deletions

View File

@@ -15,18 +15,61 @@ namespace BaseGames.Dialogue
/// </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;
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId)
[SerializeField] private VoidEventChannelSO _onLineStarted; // 每行开始打字时广播
[SerializeField] private VoidEventChannelSO _onLineEnded; // 每行玩家确认后广播
[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
@@ -41,21 +84,34 @@ namespace BaseGames.Dialogue
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)> _pending = new();
private const int PendingCapacity = 8;
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()
@@ -64,6 +120,7 @@ namespace BaseGames.Dialogue
ServiceLocator.Register<IDialogueService>(this);
_waitTypingOrSkip = new WaitTypingOrSkip(this);
_waitSkip = new WaitSkip(this);
_waitForChoice = new WaitForChoice(this);
}
private void OnDestroy()
@@ -79,48 +136,109 @@ namespace BaseGames.Dialogue
private void OnDisable()
{
if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit;
_pending.Clear();
// 若对话协程在组件禁用或场景切换时仍在运行Unity 会强制杀死协程但不调用
// EndDialogue(),导致 Action Map 永久停留在 UI 模式。此处主动恢复。
if (IsDialogueActive)
{
StopAllCoroutines();
_dialogueBox?.Hide();
IsDialogueActive = false;
_inputReader?.EnableGameplayInput();
}
// EndDialogue(),导致 Action Map 永久停留在 UI 模式。复用 ForceEnd() 统一处理
ForceEnd();
}
// ── 公开 API ──────────────────────────────────────────────────────
/// <summary>
/// 启动对话序列。
/// 若已有对话在播放,请求会进入等待队列(上限 <see cref="PendingCapacity"/>
/// 待当前对话结束后依序自动播放;超出上限的请求被丢弃。
/// 若已有对话在播放
/// - 当 <paramref name="priority"/> 高于当前对话优先级时,立即打断并播放新序列;
/// - 否则进入等待队列(上限 <see cref="_pendingQueueCapacity"/>),超出上限的请求被丢弃。
/// 由 InteractableNPC.Interact() 调用。
/// </summary>
/// <param name="sequence">要播放的对话序列 SO。</param>
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "")
/// <param name="priority">优先级。数值越大越优先;相同或更低优先级不会打断当前对话。</param>
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0)
{
if (sequence == null) return;
if (IsDialogueActive)
{
if (_pending.Count < PendingCapacity)
_pending.Enqueue((sequence, npcId));
// 高优先级:打断当前对话,立即播放
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);
PlayImmediate(sequence, npcId, priority);
}
private void PlayImmediate(DialogueSequenceSO sequence, string npcId)
private void PlayImmediate(DialogueSequenceSO sequence, string npcId, int priority = 0)
{
IsDialogueActive = true;
_skipRequested = false;
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();
}
// ── 输入回调 ──────────────────────────────────────────────────────
@@ -129,43 +247,144 @@ namespace BaseGames.Dialogue
// ── 内部协程 ──────────────────────────────────────────────────────
private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId)
private IEnumerator PlaySequence(DialogueSequenceSO startSequence, string npcId)
{
var resolved = ResolveVariant(sequence);
if (resolved.lines == null || resolved.lines.Length == 0)
if (_dialogueBox == null)
{
Debug.LogError("[DialogueManager] _dialogueBox 未配置,对话无法显示。请在 Inspector 中指定 DialogueUI 组件。", this);
EndDialogue(npcId);
yield break;
}
foreach (var line in resolved.lines)
// 使用显式序列栈替代递归防止深链100+ 序列)时 C# 调用栈溢出
var sequenceStack = new System.Collections.Generic.Stack<DialogueSequenceSO>();
sequenceStack.Push(startSequence);
while (sequenceStack.Count > 0)
{
_skipRequested = false;
_dialogueBox.ShowLine(line);
_onLineStarted?.Raise();
var sequence = sequenceStack.Pop();
var resolved = ResolveVariant(sequence);
// 等待打字完成,期间允许跳过
yield return _waitTypingOrSkip;
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
if (resolved.lines == null || resolved.lines.Length == 0)
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning(
$"[DialogueManager] 序列 '{resolved.sequenceId}' 没有对话行lines 为空)。" +
"对话将静默跳过此序列,可能是未完成配置。");
#endif
continue;
}
// 等待玩家按 Submit 推进下一行
_skipRequested = false;
yield return _waitSkip;
_onLineEnded?.Raise();
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();
_dialogueBox?.Hide();
IsDialogueActive = false;
_currentPriority = 0;
if (_inputReader != null) _inputReader.EnableGameplayInput();
// 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入;
// 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。
_onDialogueEnded?.Raise();
if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput();
OnDialogueEnded?.Invoke();
if (!string.IsNullOrEmpty(npcId))
_onNpcDialogueCompleted?.Raise(npcId);
@@ -173,52 +392,26 @@ namespace BaseGames.Dialogue
// 自动播放队首等待中的对话(脚本触发的连续序列)
if (_pending.Count > 0)
{
var (seq, id) = _pending.Dequeue();
PlayImmediate(seq, id);
var (seq, id, pri) = _pending.Dequeue();
PlayImmediate(seq, id, pri);
}
}
/// <summary>
/// 根据 ConditionalVariant 选择正确的序列版本。
/// 按顺序检查 variants所有 requiredFlags 均满足的第一个变体胜出AND 关系);
/// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。
/// 根据 WorldState 标志选择正确的序列版本。
/// 委托给 <see cref="DialogueSequenceSO.TryGetActiveVariant"/> 统一处理,消除重复逻辑。
/// </summary>
private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence)
{
if (sequence.variants == null || sequence.variants.Length == 0)
return sequence;
if (_worldState != null)
{
foreach (var variant in sequence.variants)
{
if (variant.sequence == null) continue;
if (variant.requiredFlags == null || variant.requiredFlags.Length == 0)
return variant.sequence; // 无条件变体:直接采用
bool allMet = true;
foreach (var flag in variant.requiredFlags)
{
if (!string.IsNullOrEmpty(flag) && !_worldState.HasFlag(flag))
{
allMet = false;
break;
}
}
if (allMet) return variant.sequence;
}
}
var resolved = sequence.TryGetActiveVariant(_worldState);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else if (sequence.variants.Length > 0)
{
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 sequence;
return resolved;
}
}
}