Files
zeling_v2/Assets/_Game/Scripts/Dialogue/DialogueManager.cs
Joywayer c7057db27d fix: Round 56 亲密度门槛UI、空npcId警告、任务总览窗口、超时缓存、本地化Key检查、放弃任务交互、CurrentNpcId属性
- QuestManager.ApplyAffinity: giverNpc.npcId 为空时改为 LogWarning+return,不再静默丢弃好感度奖励
- QuestManager.UnlockBranches: 分支对话 npcId 为空时输出 LogWarning,提示开发者可能误推进对话类目标
- QuestGiver.InteractPrompt: Available 状态调用 GetQuestLockInfo,亲密度/前置未满足时显示锁定原因而非'接受任务'
- QuestGiver.Interact_Internal: Available 状态加锁定检查防卫,锁定时提前返回;新增 _allowAbandon 字段(默认 false)
- QuestGiver: Active+未完成+_allowAbandon=true 时显示'放弃任务'并触发 AbandonQuest,接入已有 AbandonQuest 接口
- DialogueManager: 新增 _waitSequenceTimeout 缓存字段,Awake 预创建避免每次 PlayImmediate 分配 WaitForSeconds
- DialogueManager: 新增 _currentNpcId 字段,PlayImmediate 写入、EndDialogue/ForceEnd 清空
- IDialogueService + DialogueManager: 暴露 CurrentNpcId 只读属性,供外部系统主动查询当前对话 NPC
- QuestSO.OnValidate: 对空 displayNameKey/descriptionKey 输出 LogWarning,防止 UI 显示空文本
- 新增 QuestOverviewEditorWindow: BaseGames/Quest/Quest Overview,列出全部 QuestSO,支持搜索/分类过滤;
  Play Mode 下读取 IQuestManager 运行时状态并着色显示;Edit Mode 高亮配置错误行;单击 Ping、双击 Select

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

489 lines
24 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;
// ── 一次性对话完成回调 ────────────────────────────────────────────────
// 通过 StartDialogue(..., onComplete) 注册OnDialogueEnded 触发后调用一次后清空。
private System.Action _onCompleteCallback;
// ── 子协程通信字段(避免协程间 ref/out 参数)─────────────────────────
/// <summary>HandleChoices 子协程写入结果玩家选中选项后的后续序列null = 无后续)。</summary>
private DialogueSequenceSO _choiceBranchResult;
/// <summary>HandleChoices 子协程写入结果true = 分支深度超限,优雅降级(继续播放后续行)。</summary>
private bool _branchDepthExceeded;
/// <summary>当前正在播放对话的 NPC ID无对话时为 null。供外部系统主动查询"谁在说话"。</summary>
private string _currentNpcId;
// ── 复用 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;
// 延迟 0.15s 防止玩家快速连击穿透跳过打字机后立即触发选项0
private WaitForSeconds _waitChoiceInputGuard;
// 超时守卫等待指令(与 _sequenceTimeoutSeconds 同步,在 Awake 初始化,避免每次 PlayImmediate 分配)
private WaitForSeconds _waitSequenceTimeout;
/// <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>
/// 当前正在播放对话的 NPC ID。无对话活跃时为 <see langword="null"/>。
/// 供地图标记、HUD、分析埋点等外部系统主动查询"当前谁在说话",无需订阅事件。
/// </summary>
public string CurrentNpcId => _currentNpcId;
/// <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);
_waitChoiceInputGuard = new WaitForSeconds(0.15f);
if (_sequenceTimeoutSeconds > 0f)
_waitSequenceTimeout = new WaitForSeconds(_sequenceTimeoutSeconds);
}
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;
_currentNpcId = npcId;
_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 _waitSequenceTimeout ?? 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;
_currentNpcId = null;
_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.15s:确保此前积压的"确认键"输入已被彻底消耗,
// 防止快速连击跳过打字机→立即误选选项0的穿透问题。
// 使用预创建的缓存实例,避免每次分配 WaitForSeconds 对象。
yield return _waitChoiceInputGuard;
// 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引
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;
_currentNpcId = null;
_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;
}
}
}