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:
11
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4ef7fae4d515f649bc8e5f51ad9510b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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_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;
|
||||
|
||||
// ── 子协程通信字段(避免协程间 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
@@ -17,14 +18,23 @@ namespace BaseGames.Dialogue
|
||||
|
||||
[Tooltip("说话人本地化 key,留空时使用 actor.nameKey")]
|
||||
public string speakerNameKey;
|
||||
[Tooltip("对话文本本地化 Key,如 \"DLG_Elder_001\"。运行时通过 LocalizationManager.Get(textKey, \"Dialogue\") 获取实际文字。")]
|
||||
[TextArea(2, 5)]
|
||||
public string textKey; // 本地化文本 key(如 "DLG_Elder_001")
|
||||
public string textKey;
|
||||
|
||||
[Tooltip("说话人头像,留空时使用 actor.portrait")]
|
||||
public Sprite portraitSprite;
|
||||
public AudioClip voiceClip; // 可选语音
|
||||
[Tooltip("对应该行对话的语音片段(可选)。由 DialogueUI 通过 AudioSource 播放,打字机阶段同步开始。")]
|
||||
public AudioClip voiceClip;
|
||||
[Tooltip("打字机每字符延迟(秒)。0 = 使用 DialogueUI 默认值(推荐 0.03s)。\n" +
|
||||
"调小 = 打字更快;调大 = 打字更慢。仅影响本行,不影响其他行。")]
|
||||
[Min(0.01f)]
|
||||
public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f)
|
||||
public float typewriterDelay;
|
||||
|
||||
[Tooltip("玩家选项(可选)。有值时,本行打字机效果结束后显示选项列表,等待玩家选择。\n" +
|
||||
"选择后根据 nextSequence 播放续集(或结束对话),并可选地设置 setWorldFlag 标志。\n" +
|
||||
"留空 = 普通对话行,玩家按确认键推进。")]
|
||||
public DialogueChoice[] choices;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。
|
||||
@@ -37,6 +47,31 @@ namespace BaseGames.Dialogue
|
||||
/// </summary>
|
||||
public Sprite ResolvedPortrait => actor != null && actor.portrait != null
|
||||
? actor.portrait : portraitSprite;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的主题颜色:actor 有值时取 actor.accentColor,否则返回 white。
|
||||
/// </summary>
|
||||
public Color ResolvedAccentColor => actor != null ? actor.accentColor : Color.white;
|
||||
|
||||
/// <summary>
|
||||
/// 当前行是否由玩家角色说话(影响 UI 排版方向)。
|
||||
/// </summary>
|
||||
public bool ResolvedIsPlayer => actor != null && actor.isPlayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 玩家可选的对话分支选项。
|
||||
/// 在对话行打字机效果结束后呈现给玩家,选择后播放对应续集序列或结束对话。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct DialogueChoice
|
||||
{
|
||||
[Tooltip("选项文字本地化 Key,如 \"DLG_Choice_AcceptQuest\"。\n运行时由 LocalizationManager 解析为实际文字。")]
|
||||
public string textKey;
|
||||
[Tooltip("选择此选项后继续播放的对话序列(留空 = 对话立即结束)。")]
|
||||
public DialogueSequenceSO nextSequence;
|
||||
[Tooltip("选择此选项后设置的世界状态标志(留空 = 不修改任何标志)。\n与 nextSequence 同时生效。")]
|
||||
public string setWorldFlag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,23 +82,90 @@ namespace BaseGames.Dialogue
|
||||
[CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueSequence")]
|
||||
public class DialogueSequenceSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("序列唯一 ID,如 \"DLG_Elder_Quest_Available\"。OnValidate 会自动以资产名填充,也可手动指定。")]
|
||||
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
|
||||
|
||||
[Header("对话行")]
|
||||
[Tooltip("按顺序播放的对话行列表。每行包含说话人(actor 优先)、文本本地化 Key、可选头像与语音。")]
|
||||
public DialogueLine[] lines;
|
||||
|
||||
/// <summary>
|
||||
/// 条件变体:所有 requiredFlags 均满足时替换整个序列(AND 关系)。
|
||||
/// 与 NarrativeNPC.DialogueVersion 的多条件语义保持一致。
|
||||
/// 条件变体:requiredFlags 按 logic 逻辑满足时替换整个序列。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct ConditionalVariant
|
||||
{
|
||||
[Tooltip("全部满足时激活此变体(AND 关系)。留空表示无条件。")]
|
||||
[Tooltip("条件判断逻辑:And(默认,全部满足)或 Or(任一满足)。\n" +
|
||||
"先选好逻辑再填标志,阅读顺序更自然。")]
|
||||
public BaseGames.Core.WorldStateFlagLogic logic;
|
||||
[Tooltip("条件标志列表。logic=And 时全部满足激活;logic=Or 时任一满足激活。留空表示无条件(总是激活)。")]
|
||||
[WorldStateFlag]
|
||||
public string[] requiredFlags;
|
||||
public DialogueSequenceSO sequence;
|
||||
public string[] requiredFlags;
|
||||
public DialogueSequenceSO sequence;
|
||||
}
|
||||
|
||||
[Header("条件变体(可选)")]
|
||||
[Tooltip("运行时根据 WorldState 标志动态替换整个序列。按优先级从高到低排列:满足条件的第一个变体胜出。\n" +
|
||||
"每个变体支持 And(全部满足)或 Or(任一满足)两种判断逻辑。\n" +
|
||||
"留空表示无变体,始终使用本序列默认台词。")]
|
||||
public ConditionalVariant[] variants;
|
||||
|
||||
// ── 运行时变体解析 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 检查单个条件变体的 requiredFlags 在给定 reader 下是否满足。
|
||||
/// 无条件(requiredFlags 为空)的变体始终返回 true。
|
||||
/// </summary>
|
||||
public bool CheckVariant(ConditionalVariant variant, BaseGames.Core.IWorldStateReader reader)
|
||||
{
|
||||
if (variant.sequence == null) return false;
|
||||
if (variant.requiredFlags == null || variant.requiredFlags.Length == 0) return true;
|
||||
if (reader == null) return false;
|
||||
|
||||
if (variant.logic == BaseGames.Core.WorldStateFlagLogic.Or)
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(flag) && reader.HasFlag(flag)) return true;
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(flag) && !reader.HasFlag(flag)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 <paramref name="reader"/> 提供的世界状态,返回第一个满足条件的变体序列;
|
||||
/// 无满足变体或 reader 为 null 时返回 this(默认序列)。
|
||||
/// 开发构建下会在控制台输出命中的变体索引和标志,方便调试。
|
||||
/// </summary>
|
||||
public DialogueSequenceSO TryGetActiveVariant(BaseGames.Core.IWorldStateReader reader)
|
||||
{
|
||||
if (variants == null || variants.Length == 0) return this;
|
||||
if (reader != null)
|
||||
{
|
||||
for (int i = 0; i < variants.Length; i++)
|
||||
{
|
||||
var variant = variants[i];
|
||||
if (!CheckVariant(variant, reader)) continue;
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
string matchedFlags = variant.requiredFlags != null && variant.requiredFlags.Length > 0
|
||||
? string.Join(", ", variant.requiredFlags)
|
||||
: "(无条件)";
|
||||
string targetId = variant.sequence != null ? variant.sequence.sequenceId : "null";
|
||||
Debug.Log(
|
||||
$"[DialogueSequenceSO] '{sequenceId}' 选中变体[{i}]({matchedFlags})→ '{targetId}'",
|
||||
this);
|
||||
#endif
|
||||
return variant.sequence;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// sequenceId → 资产路径,5 秒 TTL,跨所有 DialogueSequenceSO.OnValidate 共用,
|
||||
// 避免每次 Save 都重扫所有同类 SO(O(1) 路径比对代替 O(n) 全量扫描)。
|
||||
@@ -109,6 +211,108 @@ namespace BaseGames.Dialogue
|
||||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||
s_seqIdsCacheTime = -10.0;
|
||||
}
|
||||
|
||||
ValidateChoiceCycles();
|
||||
ValidateVariantOrder();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查 variants 数组中是否存在"无条件变体遮蔽后续变体"的配置错误:
|
||||
/// 若某变体 requiredFlags 为空(无条件)且不在数组末尾,则其后所有变体永远不会被匹配。
|
||||
/// </summary>
|
||||
private void ValidateVariantOrder()
|
||||
{
|
||||
if (variants == null || variants.Length <= 1) return;
|
||||
|
||||
for (int i = 0; i < variants.Length - 1; i++)
|
||||
{
|
||||
var v = variants[i];
|
||||
bool isUnconditional = v.requiredFlags == null || v.requiredFlags.Length == 0;
|
||||
if (!isUnconditional) continue;
|
||||
|
||||
if (v.sequence == null) continue; // 无效变体,忽略
|
||||
|
||||
Debug.LogWarning(
|
||||
$"[DialogueSequenceSO] '{name}' 的 variants[{i}] 没有设置任何条件(requiredFlags 为空)," +
|
||||
$"该变体将始终优先匹配,其后的 {variants.Length - 1 - i} 个变体永远不会生效。\n" +
|
||||
"请将无条件变体移到数组末尾作为兜底,或为此变体添加具体条件。", this);
|
||||
return; // 一次只报第一个问题
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateChoiceCycles()
|
||||
{
|
||||
if (lines == null && (variants == null || variants.Length == 0)) return;
|
||||
var visited = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||||
visited.Add(sequenceId);
|
||||
|
||||
// 检查选项链循环
|
||||
if (lines != null)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.choices == null) continue;
|
||||
foreach (var choice in line.choices)
|
||||
{
|
||||
if (choice.nextSequence == null) continue;
|
||||
if (HasChoiceCycle(choice.nextSequence, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueSequenceSO] '{name}' 的选项链存在循环引用!" +
|
||||
$"序列 '{choice.nextSequence.name}' 最终指回自身或已访问序列," +
|
||||
"运行时将触发递归保护(强制终止对话)。请检查 nextSequence 配置。", this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查条件变体链循环(variant.sequence 也可能引用形成环路)
|
||||
if (variants != null)
|
||||
{
|
||||
foreach (var variant in variants)
|
||||
{
|
||||
if (variant.sequence == null) continue;
|
||||
if (HasChoiceCycle(variant.sequence, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueSequenceSO] '{name}' 的条件变体链存在循环引用!" +
|
||||
$"变体序列 '{variant.sequence.name}' 最终指回自身或已访问序列," +
|
||||
"运行时将触发递归保护(强制终止对话)。请检查 variants 配置。", this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasChoiceCycle(DialogueSequenceSO seq,
|
||||
System.Collections.Generic.HashSet<string> visited)
|
||||
{
|
||||
if (string.IsNullOrEmpty(seq.sequenceId)) return false;
|
||||
if (!visited.Add(seq.sequenceId)) return true;
|
||||
if (seq.lines != null)
|
||||
{
|
||||
foreach (var line in seq.lines)
|
||||
{
|
||||
if (line.choices == null) continue;
|
||||
foreach (var choice in line.choices)
|
||||
{
|
||||
if (choice.nextSequence != null && HasChoiceCycle(choice.nextSequence, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同时遍历条件变体序列,防止变体链形成环路
|
||||
if (seq.variants != null)
|
||||
{
|
||||
foreach (var variant in seq.variants)
|
||||
{
|
||||
if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
visited.Remove(seq.sequenceId);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
@@ -20,20 +21,61 @@ namespace BaseGames.Dialogue
|
||||
[SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框
|
||||
[SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示
|
||||
[SerializeField] private Image _speakerPortrait; // 角色头像框
|
||||
[SerializeField] private Image _speakerNameBackground; // 说话人名称框背景,用于应用 accentColor(可选)
|
||||
[SerializeField] private AudioSource _voiceSource; // 语音播放源(可不配置)
|
||||
|
||||
private Coroutine _typingCoroutine;
|
||||
[Header("选项系统(可选)")]
|
||||
[Tooltip("选项按钮的父节点容器。ShowChoices 通过对象池激活/停用按钮,HideChoices 停用全部。\n留空则分支选项功能静默禁用。")]
|
||||
[SerializeField] private Transform _choicesContainer;
|
||||
[Tooltip("选项按钮预制体(需包含 Button 组件和 TMP_Text 子组件)。\n首次使用时预热 PoolInitialSize 个到对象池,后续零 GC。")]
|
||||
[SerializeField] private GameObject _choiceButtonPrefab;
|
||||
[Tooltip("选项按钮池初始大小。设为预期最大选项数,默认 8 覆盖绝大多数情况。")]
|
||||
[SerializeField] [Range(2, 16)] private int _choicePoolSize = 8;
|
||||
|
||||
// 说话人名称框背景的默认色(Awake 时记录,切换角色后可还原)
|
||||
private Color _defaultNameBgColor = Color.white;
|
||||
// 缓存名称框 RectTransform,避免 ShowLine 每次调用 GetComponent(零堆分配)
|
||||
private RectTransform _speakerNamePanelRT;
|
||||
|
||||
// 选项按钮对象池:Awake 时按 _choicePoolSize 预热,ShowChoices/HideChoices 零 GC
|
||||
private readonly List<(GameObject go, Button btn, TMP_Text lbl)> _choicePool = new();
|
||||
|
||||
private Coroutine _typingCoroutine;
|
||||
private DialogueLine _currentLine;
|
||||
private const float DefaultTypewriterDelay = 0.03f;
|
||||
|
||||
// 缓存单个 WaitForSecondsRealtime,避免 TypeLine 每字符 new 分配。
|
||||
// 当 delay 值改变时才重新创建(不同行可能有不同打字速度)。
|
||||
// 缓存 WaitForSecondsRealtime:delay 值不变时直接复用,避免每行 new 分配。
|
||||
private WaitForSecondsRealtime _cachedTypeDelay;
|
||||
private float _cachedTypeDelayValue = -1f;
|
||||
|
||||
// 缓存 StringBuilder:每行 Clear() 复用,避免每行 new StringBuilder(n) 的堆分配。
|
||||
// 初始容量 256,足以容纳绝大多数对话行,超长时会自动扩容(扩容极少发生)。
|
||||
private readonly StringBuilder _typingSB = new(256);
|
||||
|
||||
/// <summary>当前是否仍在执行打字机效果。</summary>
|
||||
public bool IsTyping { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_speakerNameBackground != null)
|
||||
_defaultNameBgColor = _speakerNameBackground.color;
|
||||
if (_speakerNamePanel != null)
|
||||
_speakerNamePanelRT = _speakerNamePanel.GetComponent<RectTransform>();
|
||||
|
||||
// 预热选项按钮对象池:在此时创建可避免首次对话时的 Instantiate 停顿
|
||||
if (_choicesContainer != null && _choiceButtonPrefab != null)
|
||||
{
|
||||
for (int i = 0; i < _choicePoolSize; i++)
|
||||
{
|
||||
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
|
||||
var btn = go.GetComponent<Button>();
|
||||
var lbl = go.GetComponentInChildren<TMP_Text>();
|
||||
go.SetActive(false);
|
||||
_choicePool.Add((go, btn, lbl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>显示一行对话并开始打字机效果。</summary>
|
||||
@@ -50,6 +92,13 @@ namespace BaseGames.Dialogue
|
||||
if (hasSpeaker && _speakerNameText != null)
|
||||
_speakerNameText.text = LocalizationManager.Get(resolvedNameKey, "Dialogue");
|
||||
|
||||
// 说话人名称框背景颜色(accentColor):有 actor 时着色,无 actor 时还原默认色
|
||||
if (_speakerNameBackground != null)
|
||||
_speakerNameBackground.color = hasSpeaker ? line.ResolvedAccentColor : _defaultNameBgColor;
|
||||
|
||||
// 排版方向:玩家角色说话时名称框靠右,NPC 靠左
|
||||
SetLayoutSide(line.ResolvedIsPlayer);
|
||||
|
||||
// 头像(actor 优先,回退到直接字段)
|
||||
var resolvedPortrait = line.ResolvedPortrait;
|
||||
if (_speakerPortrait != null)
|
||||
@@ -85,7 +134,20 @@ namespace BaseGames.Dialogue
|
||||
}
|
||||
_voiceSource?.Stop();
|
||||
if (_dialogueText != null)
|
||||
_dialogueText.text = LocalizationManager.Get(_currentLine.textKey ?? "", "Dialogue");
|
||||
{
|
||||
string key = _currentLine.textKey;
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning("[DialogueUI] 当前对话行 textKey 为空,跳过打字机后显示空文本。");
|
||||
#endif
|
||||
_dialogueText.text = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
_dialogueText.text = LocalizationManager.Get(key, "Dialogue");
|
||||
}
|
||||
}
|
||||
IsTyping = false;
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(true);
|
||||
}
|
||||
@@ -93,9 +155,80 @@ namespace BaseGames.Dialogue
|
||||
/// <summary>隐藏对话框面板。</summary>
|
||||
public void Hide()
|
||||
{
|
||||
_voiceSource?.Stop();
|
||||
if (_rootPanel != null) _rootPanel.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 布局辅助 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 切换名称框的横向对齐方向:玩家说话时靠右,NPC 靠左。
|
||||
/// 修改 RectTransform 的 anchorMin.x / anchorMax.x / pivot.x,保持纵向不变。
|
||||
/// </summary>
|
||||
private void SetLayoutSide(bool isPlayer)
|
||||
{
|
||||
if (_speakerNamePanelRT == null) return;
|
||||
float x = isPlayer ? 1f : 0f;
|
||||
_speakerNamePanelRT.anchorMin = new Vector2(x, _speakerNamePanelRT.anchorMin.y);
|
||||
_speakerNamePanelRT.anchorMax = new Vector2(x, _speakerNamePanelRT.anchorMax.y);
|
||||
_speakerNamePanelRT.pivot = new Vector2(x, _speakerNamePanelRT.pivot.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示玩家可选的分支选项列表。打字机效果结束后由 DialogueManager 调用。
|
||||
/// 使用对象池(零 GC):池不足时动态扩容;点击后回调 onSelected(index)。
|
||||
/// 若 _choicesContainer 或 _choiceButtonPrefab 未配置,则静默跳过(不影响流程)。
|
||||
/// </summary>
|
||||
public void ShowChoices(DialogueChoice[] choices, System.Action<int> onSelected)
|
||||
{
|
||||
if (_choicesContainer == null || _choiceButtonPrefab == null) return;
|
||||
|
||||
// 确保池中有足够按钮
|
||||
while (_choicePool.Count < choices.Length)
|
||||
{
|
||||
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
|
||||
var btn = go.GetComponent<Button>();
|
||||
var lbl = go.GetComponentInChildren<TMP_Text>();
|
||||
go.SetActive(false);
|
||||
_choicePool.Add((go, btn, lbl));
|
||||
}
|
||||
|
||||
// 激活前 N 个并绑定数据
|
||||
for (int i = 0; i < choices.Length; i++)
|
||||
{
|
||||
int captured = i;
|
||||
var (go, btn, lbl) = _choicePool[i];
|
||||
go.SetActive(true);
|
||||
if (lbl != null)
|
||||
lbl.text = LocalizationManager.Get(choices[i].textKey ?? "", "Dialogue");
|
||||
if (btn != null)
|
||||
{
|
||||
btn.onClick.RemoveAllListeners();
|
||||
btn.onClick.AddListener(() => onSelected?.Invoke(captured));
|
||||
}
|
||||
}
|
||||
|
||||
// 多余的池对象保持隐藏
|
||||
for (int i = choices.Length; i < _choicePool.Count; i++)
|
||||
_choicePool[i].go.SetActive(false);
|
||||
|
||||
// 有选项时隐藏继续提示,避免与选项按钮视觉重叠
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(false);
|
||||
_choicesContainer.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>隐藏选项列表,将池中按钮全部停用(零 GC)。</summary>
|
||||
public void HideChoices()
|
||||
{
|
||||
if (_choicesContainer == null) return;
|
||||
foreach (var (go, btn, _) in _choicePool)
|
||||
{
|
||||
if (btn != null) btn.onClick.RemoveAllListeners();
|
||||
go.SetActive(false);
|
||||
}
|
||||
_choicesContainer.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 内部协程 ──────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator TypeLine(DialogueLine line)
|
||||
@@ -110,16 +243,27 @@ namespace BaseGames.Dialogue
|
||||
_cachedTypeDelayValue = delay;
|
||||
}
|
||||
|
||||
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
|
||||
string text;
|
||||
if (string.IsNullOrEmpty(line.textKey))
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning("[DialogueUI] 对话行 textKey 为空,打字机将显示空文本。请检查 DialogueSequenceSO 配置。");
|
||||
#endif
|
||||
text = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
text = LocalizationManager.Get(line.textKey, "Dialogue");
|
||||
}
|
||||
|
||||
// 性能:StringBuilder 避免每帧字符串分配(O(n²) → O(n))
|
||||
var sb = new StringBuilder(text.Length);
|
||||
// 复用缓存 StringBuilder,避免每行 new 分配;TMP SetText(StringBuilder) 零分配
|
||||
_typingSB.Clear();
|
||||
if (_dialogueText != null) _dialogueText.text = "";
|
||||
|
||||
foreach (char c in text)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
|
||||
_typingSB.Append(c);
|
||||
if (_dialogueText != null) _dialogueText.SetText(_typingSB);
|
||||
yield return _cachedTypeDelay;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,25 @@ namespace BaseGames.Dialogue
|
||||
bool IsDialogueActive { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。若已有对话在播放则忽略新请求。
|
||||
/// 每次对话序列(含分支链)全部播完后触发。
|
||||
/// 测试代码可订阅此事件等待对话结束,无需依赖 VoidEventChannelSO 资产。
|
||||
/// </summary>
|
||||
event System.Action OnDialogueEnded;
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。
|
||||
/// 若已有对话在播放:priority 高于当前对话时立即打断;否则进入队列(上限 8),超出丢弃。
|
||||
/// </summary>
|
||||
/// <param name="sequence">要播放的对话序列 SO。</param>
|
||||
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
|
||||
void StartDialogue(DialogueSequenceSO sequence, string npcId = "");
|
||||
/// <param name="priority">优先级(默认 0)。数值越大越优先;高优先级可打断低优先级对话。</param>
|
||||
void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0);
|
||||
|
||||
/// <summary>
|
||||
/// 立即强制结束当前对话(含清空等待队列),恢复游戏输入。
|
||||
/// 适用于:场景切换、演出系统打断、死亡/传送等需要硬中断的场合。
|
||||
/// 若当前没有活跃对话,则无操作。
|
||||
/// </summary>
|
||||
void ForceEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.World;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
@@ -10,13 +11,46 @@ namespace BaseGames.Dialogue
|
||||
/// </summary>
|
||||
public class InteractableNPC : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("NPC 基础")]
|
||||
[Tooltip("NPC 唯一 ID(如 \"NPC_Elder\")。对话结束时随 EVT_NpcDialogueCompleted 广播,用于驱动对话类任务目标进度。\n" +
|
||||
"需与 QuestSO 目标中 targetNpcId 保持一致。")]
|
||||
[SerializeField] protected string _npcId;
|
||||
[Tooltip("默认对话序列。无其他逻辑覆盖时播放此序列。NarrativeNPC/QuestGiver 子类通过 GetCurrentDialogue() 返回更精确的版本。")]
|
||||
[SerializeField] protected DialogueSequenceSO _defaultDialogue;
|
||||
[Tooltip("玩家进入此半径(单位:Unity 单位)后显示交互提示。建议 1.0–2.0。\n编辑器下在场景视图中以黄色圆圈可视化。")]
|
||||
[SerializeField] protected float _interactRadius = 1.5f;
|
||||
[Tooltip("交互提示本地化 Key(如 \"INTERACT_Talk\")。运行时通过 LocalizationManager 解析为实际文字。\n" +
|
||||
"留空时回退到内置字符串 \"对话\"。")]
|
||||
[SerializeField] protected string _interactPromptKey = "INTERACT_Talk";
|
||||
|
||||
[Header("范围检测")]
|
||||
[Tooltip("玩家所在的物理层。OnTriggerEnter2D / OnTriggerExit2D 仅响应属于此层的碰撞体,\n" +
|
||||
"实现 NPC 自包含的交互范围检测,无需外部 PlayerInteractionDetector 组件。\n" +
|
||||
"将玩家 GameObject 的 Layer 与此 Mask 对齐即可(推荐专用 \"Player\" 层)。\n" +
|
||||
"若留空(值为 0),则跳过层级过滤,任意碰撞体均可触发(调试用,不推荐上线)。")]
|
||||
[SerializeField] protected LayerMask _playerLayer;
|
||||
|
||||
// ── IInteractable ──────────────────────────────────────────────────
|
||||
public virtual bool CanInteract => true;
|
||||
public virtual string InteractPrompt => "对话";
|
||||
public virtual bool CanInteract => true;
|
||||
|
||||
public virtual string InteractPrompt
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_interactPromptKey))
|
||||
{
|
||||
var resolved = LocalizationManager.Get(_interactPromptKey, "UI");
|
||||
if (!string.IsNullOrEmpty(resolved)) return resolved;
|
||||
}
|
||||
return "对话";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 范围进出通知(供子组件订阅,如 InteractionPromptController)──────
|
||||
/// <summary>玩家进入交互范围时触发。参数为玩家 Transform。</summary>
|
||||
public event System.Action<Transform> PlayerEnteredRange;
|
||||
/// <summary>玩家离开交互范围时触发。</summary>
|
||||
public event System.Action PlayerExitedRange;
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
@@ -27,8 +61,24 @@ namespace BaseGames.Dialogue
|
||||
PlayDialogue(dialogue, player);
|
||||
}
|
||||
|
||||
public virtual void OnPlayerEnterRange(Transform player) { }
|
||||
public virtual void OnPlayerExitRange() { }
|
||||
public virtual void OnPlayerEnterRange(Transform player) { PlayerEnteredRange?.Invoke(player); }
|
||||
public virtual void OnPlayerExitRange() { PlayerExitedRange?.Invoke(); }
|
||||
|
||||
// ── 自包含物理范围检测 ─────────────────────────────────────────────
|
||||
// 需在 NPC Prefab 上挂载 Collider2D(设为 IsTrigger),并将 Collider2D.size/radius
|
||||
// 配置为期望的交互半径。OnTriggerEnter2D / Exit2D 会自动过滤非玩家碰撞体。
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
|
||||
OnPlayerEnterRange(other.transform);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
|
||||
OnPlayerExitRange();
|
||||
}
|
||||
|
||||
// ── 子类覆盖点 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,5 +101,42 @@ namespace BaseGames.Dialogue
|
||||
}
|
||||
manager.StartDialogue(sequence, _npcId);
|
||||
}
|
||||
|
||||
// ── 编辑器辅助 ────────────────────────────────────────────────────
|
||||
// 注意:OnValidate 声明在 #if 外,确保子类在非编辑器构建中调用 base.OnValidate() 不会编译失败。
|
||||
|
||||
protected virtual void OnValidate()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (_playerLayer.value == 0)
|
||||
Debug.LogWarning(
|
||||
$"[InteractableNPC:{name}] _playerLayer 未设置(value=0)。" +
|
||||
"OnTriggerEnter2D 将响应所有层,建议在 Inspector 中指定玩家所在层。", this);
|
||||
|
||||
// 检测 _interactRadius 与 CircleCollider2D.radius 是否同步(仅输出一次,非逐帧)
|
||||
var circle = GetComponent<UnityEngine.CircleCollider2D>();
|
||||
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
|
||||
Debug.LogWarning(
|
||||
$"[InteractableNPC:{name}] _interactRadius({_interactRadius:F2}) 与 " +
|
||||
$"CircleCollider2D.radius({circle.radius:F2}) 不一致," +
|
||||
"交互范围视觉(Gizmos)与物理碰撞可能不匹配,请手动对齐。", this);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
protected virtual void OnDrawGizmosSelected()
|
||||
{
|
||||
// Collider2D 不一致时改绘红色(警告已在 OnValidate 中输出,此处不重复 LogWarning)
|
||||
bool mismatch = false;
|
||||
var circle = GetComponent<UnityEngine.CircleCollider2D>();
|
||||
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
|
||||
mismatch = true;
|
||||
|
||||
UnityEditor.Handles.color = mismatch
|
||||
? new Color(1f, 0.2f, 0.2f, 0.8f)
|
||||
: new Color(1f, 0.92f, 0.016f, 0.6f);
|
||||
UnityEditor.Handles.DrawWireDisc(transform.position, Vector3.forward, _interactRadius);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
@@ -5,34 +6,144 @@ using UnityEngine.UI;
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 交互提示 UI 控制器(架构 14_NarrativeModule §2)。
|
||||
/// 挂载在每个 IInteractable GameObject 的子节点(Prefab 实例),默认隐藏。
|
||||
/// 根据当前活跃输入设备自动切换图标(键盘/手柄)。
|
||||
/// 世界空间交互提示控制器(架构 14_NarrativeModule §2 升级版)。
|
||||
/// 挂在每个 InteractableNPC 子节点(Prefab 实例),默认隐藏。
|
||||
///
|
||||
/// 功能:
|
||||
/// • 自动订阅父级 InteractableNPC 的进/出范围事件,免手动调用 Show/Hide
|
||||
/// • TMP_Text 实时显示 InteractPrompt(如"接受任务"/"提交任务"),随任务状态动态刷新
|
||||
/// • 根据当前活跃输入设备自动切换按键图标(键盘/手柄)
|
||||
/// • 支持淡入/淡出动画
|
||||
/// </summary>
|
||||
public class InteractionPromptController : MonoBehaviour
|
||||
{
|
||||
[Header("UI 引用")]
|
||||
[Tooltip("整个提示根节点(包含图标和文字),控制显示/隐藏。")]
|
||||
[SerializeField] private GameObject _promptRoot;
|
||||
[Tooltip("按键图标 Image 组件(可选)。有输入设备时显示对应图标。")]
|
||||
[SerializeField] private Image _icon;
|
||||
[SerializeField] private Sprite _keyboardIcon;
|
||||
[SerializeField] private Sprite _gamepadIcon;
|
||||
[Tooltip("提示文字 TMP_Text 组件(可选)。自动显示 InteractableNPC.InteractPrompt 的当前值。")]
|
||||
[SerializeField] private TMP_Text _label;
|
||||
|
||||
[Header("按键图标")]
|
||||
[Tooltip("键盘/鼠标设备激活时使用的按键图标 Sprite。")]
|
||||
[SerializeField] private Sprite _keyboardIcon;
|
||||
[Tooltip("手柄设备激活时使用的按键图标 Sprite。")]
|
||||
[SerializeField] private Sprite _gamepadIcon;
|
||||
|
||||
[Header("位置与动画")]
|
||||
[Tooltip("相对于本组件 transform 的世界空间偏移。调整此值可控制气泡与 NPC 的相对位置。")]
|
||||
[SerializeField] private Vector3 _offset = new Vector3(0f, 1.8f, 0f);
|
||||
[Tooltip("是否随相机方向 Billboard 朝向(世界空间 Canvas 推荐开启)。")]
|
||||
[SerializeField] private bool _billboard = true;
|
||||
[Tooltip("淡入持续时间(秒)。0 = 立即显示,无动画。")]
|
||||
[SerializeField] [Min(0f)] private float _fadeInDuration = 0.12f;
|
||||
[Tooltip("淡出持续时间(秒)。0 = 立即隐藏,无动画。")]
|
||||
[SerializeField] [Min(0f)] private float _fadeOutDuration = 0.08f;
|
||||
|
||||
// ── 运行时状态 ────────────────────────────────────────────────────────
|
||||
private InteractableNPC _npc;
|
||||
private bool _visible;
|
||||
private float _alpha;
|
||||
private Camera _cam;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_promptRoot != null) _promptRoot.SetActive(false);
|
||||
// 自动连接父级 InteractableNPC 事件(无需手动调用 Show/Hide)
|
||||
_npc = GetComponentInParent<InteractableNPC>();
|
||||
if (_npc != null)
|
||||
{
|
||||
_npc.PlayerEnteredRange += OnPlayerEntered;
|
||||
_npc.PlayerExitedRange += OnPlayerExited;
|
||||
}
|
||||
|
||||
SetVisible(false, immediate: true);
|
||||
}
|
||||
|
||||
/// <summary>显示交互提示,根据输入设备选择图标。</summary>
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_npc != null)
|
||||
{
|
||||
_npc.PlayerEnteredRange -= OnPlayerEntered;
|
||||
_npc.PlayerExitedRange -= OnPlayerExited;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 位置偏移(世界空间气泡)
|
||||
if (_offset != Vector3.zero)
|
||||
transform.position = (_npc != null ? _npc.transform.position : transform.parent.position) + _offset;
|
||||
|
||||
// Billboard
|
||||
if (_billboard && _visible)
|
||||
{
|
||||
if (_cam == null) _cam = Camera.main;
|
||||
if (_cam != null)
|
||||
transform.forward = _cam.transform.forward;
|
||||
}
|
||||
|
||||
// 淡入/淡出
|
||||
if (_promptRoot == null) return;
|
||||
if (_visible && _alpha < 1f)
|
||||
{
|
||||
float speed = _fadeInDuration > 0f ? Time.deltaTime / _fadeInDuration : 1f;
|
||||
_alpha = Mathf.MoveTowards(_alpha, 1f, speed);
|
||||
ApplyAlpha(_alpha);
|
||||
}
|
||||
else if (!_visible && _alpha > 0f)
|
||||
{
|
||||
float speed = _fadeOutDuration > 0f ? Time.deltaTime / _fadeOutDuration : 1f;
|
||||
_alpha = Mathf.MoveTowards(_alpha, 0f, speed);
|
||||
ApplyAlpha(_alpha);
|
||||
if (_alpha <= 0f) _promptRoot.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API(兼容旧调用 / 脚本手动控制)────────────────────────────
|
||||
|
||||
/// <summary>手动显示提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
|
||||
public void Show()
|
||||
{
|
||||
if (_promptRoot == null) return;
|
||||
_promptRoot.SetActive(true);
|
||||
if (_npc != null) _label.text = _npc.InteractPrompt;
|
||||
SetVisible(true, immediate: false);
|
||||
UpdateIcon();
|
||||
}
|
||||
|
||||
/// <summary>隐藏交互提示。</summary>
|
||||
public void Hide()
|
||||
/// <summary>手动隐藏提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
|
||||
public void Hide() => SetVisible(false, immediate: false);
|
||||
|
||||
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void OnPlayerEntered(Transform player)
|
||||
{
|
||||
if (_promptRoot != null) _promptRoot.SetActive(false);
|
||||
// 刷新文字(每次进入都读取最新 InteractPrompt,确保任务状态变化后文字正确)
|
||||
if (_label != null && _npc != null)
|
||||
_label.text = _npc.InteractPrompt;
|
||||
SetVisible(true, immediate: false);
|
||||
UpdateIcon();
|
||||
}
|
||||
|
||||
private void OnPlayerExited() => SetVisible(false, immediate: false);
|
||||
|
||||
private void SetVisible(bool show, bool immediate)
|
||||
{
|
||||
_visible = show;
|
||||
if (immediate)
|
||||
{
|
||||
_alpha = show ? 1f : 0f;
|
||||
if (_promptRoot != null)
|
||||
{
|
||||
_promptRoot.SetActive(show);
|
||||
ApplyAlpha(_alpha);
|
||||
}
|
||||
}
|
||||
else if (show && _promptRoot != null)
|
||||
{
|
||||
_promptRoot.SetActive(true); // 淡出由 Update 结束时 SetActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateIcon()
|
||||
@@ -41,5 +152,12 @@ namespace BaseGames.Dialogue
|
||||
bool isGamepad = Gamepad.current != null && Gamepad.current.enabled;
|
||||
_icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
|
||||
}
|
||||
|
||||
private void ApplyAlpha(float a)
|
||||
{
|
||||
if (_icon != null) { var c = _icon.color; c.a = a; _icon.color = c; }
|
||||
if (_label != null) { var c = _label.color; c.a = a; _label.color = c; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.World;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -6,23 +7,35 @@ namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 条件对话 NPC(架构 14_NarrativeModule §7)。
|
||||
/// 扩展 InteractableNPC,根据 WorldStateRegistry 标志动态选择对话版本。
|
||||
/// 扩展 InteractableNPC,根据世界状态标志动态选择对话版本。
|
||||
/// 版本列表从高到低优先级排列;第一个满足条件的版本生效。
|
||||
///
|
||||
/// _worldState 可留空:留空时自动从 ServiceLocator 获取全局注册的 IWorldStateReader,
|
||||
/// 便于无需在每个 NPC Prefab 上手动拖入 WorldStateRegistry 资产。
|
||||
/// </summary>
|
||||
public class NarrativeNPC : InteractableNPC
|
||||
{
|
||||
[Header("台词版本集(从高到低优先级排列)")]
|
||||
[Tooltip("条件对话版本列表。运行时从上到下检查,第一个满足条件的版本被播放。\n" +
|
||||
"版本之间的优先级由列表顺序决定——请将最具体的条件放在最上方。")]
|
||||
[SerializeField] private DialogueVersion[] _dialogueVersions;
|
||||
[SerializeField] private DialogueSequenceSO _fallbackDialogue; // 无条件满足时的兜底台词
|
||||
[SerializeField] private WorldStateRegistry _worldState; // SO 注入
|
||||
[Tooltip("所有版本均不满足条件时的兜底对话。务必配置,否则运行时会输出 LogWarning 且 NPC 无对话。")]
|
||||
[SerializeField] private DialogueSequenceSO _fallbackDialogue;
|
||||
[Tooltip("世界状态 SO(可选)。留空时自动从 ServiceLocator 获取全局 IWorldStateReader。\n" +
|
||||
"通常同场景下多个 NPC 共用同一个 WorldStateRegistry;\n" +
|
||||
"若全局已通过 ServiceLocator 注册,可不在此处手动指定。")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
protected override DialogueSequenceSO GetCurrentDialogue()
|
||||
{
|
||||
IWorldStateReader reader = _worldState
|
||||
?? ServiceLocator.GetOrDefault<IWorldStateReader>();
|
||||
|
||||
if (_dialogueVersions == null) return _fallbackDialogue;
|
||||
|
||||
foreach (var version in _dialogueVersions)
|
||||
{
|
||||
if (version != null && version.CheckConditions(_worldState))
|
||||
if (version != null && version.CheckConditions(reader))
|
||||
return version.dialogue;
|
||||
}
|
||||
|
||||
@@ -43,6 +56,7 @@ namespace BaseGames.Dialogue
|
||||
{
|
||||
[Tooltip("编辑器显示名,如'森林 Boss 击败后'")]
|
||||
public string versionLabel;
|
||||
[Tooltip("此版本对应的对话序列 SO。条件满足时播放。留空时等同于跳过此版本。")]
|
||||
public DialogueSequenceSO dialogue;
|
||||
|
||||
[Tooltip("全部满足才激活此版本(AND 关系)")]
|
||||
@@ -53,18 +67,21 @@ namespace BaseGames.Dialogue
|
||||
[WorldStateFlag]
|
||||
public string[] blockedByFlags;
|
||||
|
||||
/// <summary>检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。</summary>
|
||||
public bool CheckConditions(WorldStateRegistry registry)
|
||||
/// <summary>
|
||||
/// 检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。
|
||||
/// reader 为 null 时直接返回 false(无法判断,视为条件不满足)。
|
||||
/// </summary>
|
||||
public bool CheckConditions(IWorldStateReader reader)
|
||||
{
|
||||
if (registry == null) return false;
|
||||
if (reader == null) return false;
|
||||
|
||||
if (requiredFlags != null)
|
||||
foreach (var f in requiredFlags)
|
||||
if (!registry.HasFlag(f)) return false;
|
||||
if (!reader.HasFlag(f)) return false;
|
||||
|
||||
if (blockedByFlags != null)
|
||||
foreach (var f in blockedByFlags)
|
||||
if (registry.HasFlag(f)) return false;
|
||||
if (reader.HasFlag(f)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
86
Assets/_Game/Scripts/Dialogue/NpcSO.cs
Normal file
86
Assets/_Game/Scripts/Dialogue/NpcSO.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// NPC 元数据资产(架构 14_NarrativeModule §2)。
|
||||
/// 将 NPC 的唯一 ID、本地化名称 Key、头像、好感度上限集中在一处管理。
|
||||
///
|
||||
/// 关联:
|
||||
/// • <see cref="InteractableNPC"/> 通过 _npcId 字段与此 SO 对应。
|
||||
/// • <see cref="DialogueActorSO"/> 管理对话 UI 侧头像/颜色(二者可共享同一 Sprite,或独立维护)。
|
||||
/// • <see cref="BaseGames.Quest.QuestSO"/> 的 <c>giverNpc</c> 字段直接引用此 SO,避免手填字符串。
|
||||
///
|
||||
/// 资产路径:Assets/_Game/Data/NPC/NPC_{npcId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/NPC/NPC")]
|
||||
public class NpcSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("NPC 唯一 ID,如 \"NPC_Elder\"。需与 InteractableNPC._npcId、QuestSO.giverNpcId 保持一致。")]
|
||||
public string npcId;
|
||||
|
||||
[Header("显示")]
|
||||
[Tooltip("本地化 Key,如 \"NPC_Elder_Name\"。通过 LocalizationManager 解析为实际名称。")]
|
||||
public string nameKey;
|
||||
[Tooltip("NPC 头像,用于地图、任务日志、DataHub 等 UI。")]
|
||||
public Sprite portrait;
|
||||
|
||||
[Header("好感度")]
|
||||
[Tooltip("该 NPC 的好感度上限(0 = 无上限)。\n" +
|
||||
"QuestManager.CompleteQuest 发放 affinityBonus 时,不超过此数值。\n" +
|
||||
"UI 侧可用此值绘制好感度进度条满格。")]
|
||||
[Min(0)] public int maxAffinity = 0;
|
||||
|
||||
[Header("交互提示")]
|
||||
[Tooltip("与此 NPC 交互时显示的提示本地化 Key(如 \"INTERACT_Talk\")。\n" +
|
||||
"留空时 InteractableNPC 回退到内置字符串 \"对话\"。")]
|
||||
public string interactPromptKey = "INTERACT_Talk";
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// npcId → 资产路径,5 秒 TTL,跨所有 NpcSO.OnValidate 共用,O(1) 重复检测。
|
||||
private static System.Collections.Generic.Dictionary<string, string> s_npcIdToPath;
|
||||
private static double s_npcIdsCacheTime = -10.0;
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, string> GetNpcIdCache()
|
||||
{
|
||||
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||
if (s_npcIdToPath != null && now - s_npcIdsCacheTime < 5.0)
|
||||
return s_npcIdToPath;
|
||||
|
||||
s_npcIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:NpcSO");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||
var npc = UnityEditor.AssetDatabase.LoadAssetAtPath<NpcSO>(path);
|
||||
if (npc != null && !string.IsNullOrEmpty(npc.npcId) && !s_npcIdToPath.ContainsKey(npc.npcId))
|
||||
s_npcIdToPath[npc.npcId] = path;
|
||||
}
|
||||
s_npcIdsCacheTime = now;
|
||||
return s_npcIdToPath;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(npcId))
|
||||
{
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
npcId = name;
|
||||
}
|
||||
|
||||
var cache = GetNpcIdCache();
|
||||
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||
if (!string.IsNullOrEmpty(myPath) &&
|
||||
cache.TryGetValue(npcId, out var existingPath) &&
|
||||
existingPath != myPath)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[NpcSO] npcId '{npcId}' 与 " +
|
||||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||
s_npcIdsCacheTime = -10.0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a534ec2815a6bd4ebd50cf4b7bccf3e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,10 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记一个 string 或 string[] 字段为世界状态标志 Key。
|
||||
/// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。
|
||||
/// </summary>
|
||||
public sealed class WorldStateFlagAttribute : PropertyAttribute { }
|
||||
}
|
||||
Reference in New Issue
Block a user