feat: Add WorldStateFlagAttribute and custom property drawer for enhanced dialogue management

- Implemented WorldStateFlagAttribute to mark string fields as world state flags.
- Created NarrativeNPCEditor for custom inspector to visualize dialogue version activation states.
- Developed WorldStateFlagDrawer to provide dropdown menu for known flags in the inspector.
- Introduced ActorModule for managing DialogueActorSO assets, including viewing, creating, and deleting actors.
- Added DialogueModule for managing DialogueSequenceSO assets with detailed previews and action bars.
- Established QuestModule for managing QuestSO assets, including objectives and branches.
- Implemented QuestManagerPostprocessor to automatically refresh QuestManager's quest list on asset changes.
This commit is contained in:
2026-05-24 00:36:11 +08:00
parent 520f84999b
commit 446fd5dcd0
22 changed files with 1908 additions and 101 deletions

View File

@@ -0,0 +1,81 @@
using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话角色定义 SO架构 14_NarrativeModule §3
/// 将 NPC 的显示名、头像、对话气泡颜色集中在一处管理。
/// DialogueLine.actor 引用此 SO修改头像/名称只需改一个资产,
/// 无需批量编辑所有对话行。
///
/// 资产路径Assets/_Game/Data/Dialogue/Actors/Actor_{actorId}.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueActor")]
public class DialogueActorSO : ScriptableObject
{
[Header("标识")]
[Tooltip("唯一 ID如 \"NPC_Elder\",供 DialogueLine 引用")]
public string actorId;
[Header("显示")]
[Tooltip("本地化 Key格式如 \"NPC_Elder_Name\"")]
public string nameKey;
[Tooltip("对话 UI 中显示的头像")]
public Sprite portrait;
[Tooltip("对话气泡/说话人名称的主题颜色(可选)")]
public Color accentColor = Color.white;
[Tooltip("是否为玩家角色(影响对话 UI 排版方向)")]
public bool isPlayer;
#if UNITY_EDITOR
// actorId → 资产路径5 秒 TTL跨所有 DialogueActorSO.OnValidate 共用。
// 与 QuestSO / DialogueSequenceSO 保持一致的 O(1) 重复检测策略。
private static System.Collections.Generic.Dictionary<string, string> s_actorIdToPath;
private static double s_actorIdsCacheTime = -10.0;
private static System.Collections.Generic.Dictionary<string, string> GetActorIdCache()
{
double now = UnityEditor.EditorApplication.timeSinceStartup;
if (s_actorIdToPath != null && now - s_actorIdsCacheTime < 5.0)
return s_actorIdToPath;
s_actorIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:DialogueActorSO");
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var actor = UnityEditor.AssetDatabase.LoadAssetAtPath<DialogueActorSO>(path);
if (actor != null && !string.IsNullOrEmpty(actor.actorId) && !s_actorIdToPath.ContainsKey(actor.actorId))
s_actorIdToPath[actor.actorId] = path;
}
s_actorIdsCacheTime = now;
return s_actorIdToPath;
}
private void OnValidate()
{
if (string.IsNullOrWhiteSpace(actorId))
{
Debug.LogWarning($"[DialogueActorSO] '{name}' 缺少 actorId保存前请填写。", this);
return;
}
// 检测重复 actorId缓存路径 vs 自身路径比对O(1)5 秒内无需重扫。
var cache = GetActorIdCache();
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
if (!string.IsNullOrEmpty(myPath) &&
cache.TryGetValue(actorId, out var existingPath) &&
existingPath != myPath)
{
Debug.LogError(
$"[DialogueActorSO] actorId '{actorId}' 与 " +
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
s_actorIdsCacheTime = -10.0;
}
}
#endif
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections;
using System.Collections.Generic;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Input;
@@ -22,9 +23,36 @@ namespace BaseGames.Dialogue
[SerializeField] private VoidEventChannelSO _onDialogueStarted;
[SerializeField] private VoidEventChannelSO _onDialogueEnded;
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId)
[SerializeField] private VoidEventChannelSO _onLineStarted; // 每行开始打字时广播
[SerializeField] private VoidEventChannelSO _onLineEnded; // 每行玩家确认后广播
private bool _skipRequested;
// ── 复用 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;
}
private WaitTypingOrSkip _waitTypingOrSkip;
private WaitSkip _waitSkip;
/// <summary>
/// 当 IsDialogueActive 时排队等待的对话请求。
/// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话),
/// 但容量上限为 8避免因误触导致无限排队。
/// </summary>
private readonly Queue<(DialogueSequenceSO seq, string npcId)> _pending = new();
private const int PendingCapacity = 8;
/// <summary>当前是否有对话正在播放。</summary>
public bool IsDialogueActive { get; private set; }
@@ -34,6 +62,8 @@ namespace BaseGames.Dialogue
{
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<IDialogueService>(this);
_waitTypingOrSkip = new WaitTypingOrSkip(this);
_waitSkip = new WaitSkip(this);
}
private void OnDestroy()
@@ -49,25 +79,46 @@ 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();
}
}
// ── 公开 API ──────────────────────────────────────────────────────
/// <summary>
/// 启动对话序列。若已有对话在播放则忽略新请求。
/// 启动对话序列。
/// 若已有对话在播放,请求会进入等待队列(上限 <see cref="PendingCapacity"/>
/// 待当前对话结束后依序自动播放;超出上限的请求被丢弃。
/// 由 InteractableNPC.Interact() 调用。
/// </summary>
/// <param name="sequence">要播放的对话序列 SO。</param>
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "")
{
if (IsDialogueActive || sequence == null) return;
if (sequence == null) return;
if (IsDialogueActive)
{
if (_pending.Count < PendingCapacity)
_pending.Enqueue((sequence, npcId));
return;
}
PlayImmediate(sequence, npcId);
}
private void PlayImmediate(DialogueSequenceSO sequence, string npcId)
{
IsDialogueActive = true;
_skipRequested = false;
// 切换到 UI Action Map禁用玩家移动输入
_inputReader.EnableUIInput();
_skipRequested = false;
if (_inputReader != null) _inputReader.EnableUIInput();
_onDialogueStarted?.Raise();
StartCoroutine(PlaySequence(sequence, npcId));
}
@@ -80,21 +131,28 @@ namespace BaseGames.Dialogue
private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId)
{
// 选择条件变体(查询 ConditionalVariant.conditionFlag — 未实现 WorldStateRegistry 查询时直接使用默认序列)
var resolved = ResolveVariant(sequence);
if (resolved.lines == null || resolved.lines.Length == 0)
{
EndDialogue(npcId);
yield break;
}
foreach (var line in resolved.lines)
{
_skipRequested = false;
_dialogueBox.ShowLine(line);
_onLineStarted?.Raise();
// 等待打字完成,期间允许跳过
yield return new WaitUntil(() => !_dialogueBox.IsTyping || _skipRequested);
yield return _waitTypingOrSkip;
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
// 等待玩家按 Submit 推进下一行
_skipRequested = false;
yield return new WaitUntil(() => _skipRequested);
yield return _waitSkip;
_onLineEnded?.Raise();
}
EndDialogue(npcId);
@@ -105,18 +163,24 @@ namespace BaseGames.Dialogue
_dialogueBox.Hide();
IsDialogueActive = false;
// 恢复 Gameplay Action Map
_inputReader.EnableGameplayInput();
if (_inputReader != null) _inputReader.EnableGameplayInput();
_onDialogueEnded?.Raise();
if (!string.IsNullOrEmpty(npcId))
_onNpcDialogueCompleted?.Raise(npcId);
// 自动播放队首等待中的对话(脚本触发的连续序列)
if (_pending.Count > 0)
{
var (seq, id) = _pending.Dequeue();
PlayImmediate(seq, id);
}
}
/// <summary>
/// 根据 ConditionalVariant 选择正确的序列版本。
/// 按顺序检查 variants第一个满足 WorldStateRegistry 标志的变体胜出
/// 按顺序检查 variants所有 requiredFlags 均满足的第一个变体胜出AND 关系)
/// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。
/// </summary>
private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence)
@@ -128,12 +192,31 @@ namespace BaseGames.Dialogue
{
foreach (var variant in sequence.variants)
{
if (!string.IsNullOrEmpty(variant.conditionFlag)
&& variant.sequence != null
&& _worldState.HasFlag(variant.conditionFlag))
return variant.sequence;
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;
}
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else if (sequence.variants.Length > 0)
{
Debug.LogWarning(
$"[DialogueManager] 序列 '{sequence.sequenceId}' 有 {sequence.variants.Length} 个条件变体," +
"但 WorldStateRegistry 未注入,将使用默认序列。请检查 Inspector 中的 _worldState 字段。",
this);
}
#endif
return sequence;
}

View File

@@ -5,17 +5,38 @@ namespace BaseGames.Dialogue
/// <summary>
/// 对话行结构(架构 14_NarrativeModule §3
/// 每行包含说话人、文本(本地化 Key和可选的语音片段。
///
/// 推荐通过 actor 引用 DialogueActorSO 统一管理头像/名称;
/// speakerNameKey / portraitSprite 作为无 SO 时的直接覆盖(保持向后兼容)。
/// </summary>
[System.Serializable]
public struct DialogueLine
{
public string speakerNameKey; // 本地化 key如 "NPC_Elder_Name"
[Tooltip("说话人角色推荐。actor 优先;留空时回退到 speakerNameKey / portraitSprite")]
public DialogueActorSO actor;
[Tooltip("说话人本地化 key留空时使用 actor.nameKey")]
public string speakerNameKey;
[TextArea(2, 5)]
public string textKey; // 本地化文本 key如 "DLG_Elder_001"
public Sprite portraitSprite; // 可选说话人头像
[Tooltip("说话人头像,留空时使用 actor.portrait")]
public Sprite portraitSprite;
public AudioClip voiceClip; // 可选语音
[Min(0.01f)]
public float typewriterDelay; // 每字符延迟0 = 使用默认 0.03f
/// <summary>
/// 获取最终使用的说话人名称 Keyactor 优先,回退到直接字段。
/// </summary>
public string ResolvedNameKey => actor != null && !string.IsNullOrEmpty(actor.nameKey)
? actor.nameKey : speakerNameKey;
/// <summary>
/// 获取最终使用的头像actor 优先,回退到直接字段。
/// </summary>
public Sprite ResolvedPortrait => actor != null && actor.portrait != null
? actor.portrait : portraitSprite;
}
/// <summary>
@@ -29,13 +50,66 @@ namespace BaseGames.Dialogue
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
public DialogueLine[] lines;
/// <summary>条件变体:满足特定世界标志时替换整个序列。</summary>
/// <summary>
/// 条件变体:所有 requiredFlags 均满足时替换整个序列AND 关系)。
/// 与 NarrativeNPC.DialogueVersion 的多条件语义保持一致。
/// </summary>
[System.Serializable]
public struct ConditionalVariant
{
public string conditionFlag; // WorldState flag key
[Tooltip("全部满足时激活此变体AND 关系)。留空表示无条件。")]
[WorldStateFlag]
public string[] requiredFlags;
public DialogueSequenceSO sequence;
}
public ConditionalVariant[] variants;
#if UNITY_EDITOR
// sequenceId → 资产路径5 秒 TTL跨所有 DialogueSequenceSO.OnValidate 共用,
// 避免每次 Save 都重扫所有同类 SOO(1) 路径比对代替 O(n) 全量扫描)。
private static System.Collections.Generic.Dictionary<string, string> s_seqIdToPath;
private static double s_seqIdsCacheTime = -10.0;
private static System.Collections.Generic.Dictionary<string, string> GetSequenceIdCache()
{
double now = UnityEditor.EditorApplication.timeSinceStartup;
if (s_seqIdToPath != null && now - s_seqIdsCacheTime < 5.0)
return s_seqIdToPath;
s_seqIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:DialogueSequenceSO");
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var seq = UnityEditor.AssetDatabase.LoadAssetAtPath<DialogueSequenceSO>(path);
if (seq != null && !string.IsNullOrEmpty(seq.sequenceId) && !s_seqIdToPath.ContainsKey(seq.sequenceId))
s_seqIdToPath[seq.sequenceId] = path;
}
s_seqIdsCacheTime = now;
return s_seqIdToPath;
}
private void OnValidate()
{
if (string.IsNullOrEmpty(sequenceId))
{
sequenceId = name;
UnityEditor.EditorUtility.SetDirty(this);
}
// 检测重复 sequenceId缓存路径 vs 自身路径比对O(1)5 秒内无需重扫。
var cache = GetSequenceIdCache();
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
if (!string.IsNullOrEmpty(myPath) &&
cache.TryGetValue(sequenceId, out var existingPath) &&
existingPath != myPath)
{
Debug.LogError(
$"[DialogueSequenceSO] sequenceId '{sequenceId}' 与 " +
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
s_seqIdsCacheTime = -10.0;
}
}
#endif
}
}

View File

@@ -26,6 +26,11 @@ namespace BaseGames.Dialogue
private DialogueLine _currentLine;
private const float DefaultTypewriterDelay = 0.03f;
// 缓存单个 WaitForSecondsRealtime避免 TypeLine 每字符 new 分配。
// 当 delay 值改变时才重新创建(不同行可能有不同打字速度)。
private WaitForSecondsRealtime _cachedTypeDelay;
private float _cachedTypeDelayValue = -1f;
/// <summary>当前是否仍在执行打字机效果。</summary>
public bool IsTyping { get; private set; }
@@ -35,20 +40,22 @@ namespace BaseGames.Dialogue
public void ShowLine(DialogueLine line)
{
_currentLine = line;
_rootPanel.SetActive(true);
_continuePrompt.SetActive(false);
if (_rootPanel != null) _rootPanel.SetActive(true);
if (_continuePrompt != null) _continuePrompt.SetActive(false);
// 说话人名称
bool hasSpeaker = !string.IsNullOrEmpty(line.speakerNameKey);
// 说话人名称actor 优先,回退到直接字段)
string resolvedNameKey = line.ResolvedNameKey;
bool hasSpeaker = !string.IsNullOrEmpty(resolvedNameKey);
if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker);
if (hasSpeaker && _speakerNameText != null)
_speakerNameText.text = LocalizationManager.Get(line.speakerNameKey, "Dialogue");
_speakerNameText.text = LocalizationManager.Get(resolvedNameKey, "Dialogue");
// 头像
// 头像actor 优先,回退到直接字段)
var resolvedPortrait = line.ResolvedPortrait;
if (_speakerPortrait != null)
{
_speakerPortrait.gameObject.SetActive(line.portraitSprite != null);
if (line.portraitSprite != null) _speakerPortrait.sprite = line.portraitSprite;
_speakerPortrait.gameObject.SetActive(resolvedPortrait != null);
if (resolvedPortrait != null) _speakerPortrait.sprite = resolvedPortrait;
}
// 语音播放
@@ -95,6 +102,14 @@ namespace BaseGames.Dialogue
{
IsTyping = true;
float delay = line.typewriterDelay > 0f ? line.typewriterDelay : DefaultTypewriterDelay;
// 复用缓存的 WaitForSecondsRealtime仅当 delay 值变化时才重新 new
if (_cachedTypeDelay == null || !Mathf.Approximately(_cachedTypeDelayValue, delay))
{
_cachedTypeDelay = new WaitForSecondsRealtime(delay);
_cachedTypeDelayValue = delay;
}
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
// 性能StringBuilder 避免每帧字符串分配O(n²) → O(n)
@@ -105,7 +120,7 @@ namespace BaseGames.Dialogue
{
sb.Append(c);
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
yield return new WaitForSecondsRealtime(delay);
yield return _cachedTypeDelay;
}
IsTyping = false;

View File

@@ -26,6 +26,9 @@ namespace BaseGames.Dialogue
return version.dialogue;
}
if (_fallbackDialogue == null)
Debug.LogWarning($"[NarrativeNPC] '{name}' 没有版本满足当前条件,且未配置兜底对话 (_fallbackDialogue)。", gameObject);
return _fallbackDialogue;
}
}
@@ -43,9 +46,11 @@ namespace BaseGames.Dialogue
public DialogueSequenceSO dialogue;
[Tooltip("全部满足才激活此版本AND 关系)")]
[WorldStateFlag]
public string[] requiredFlags;
[Tooltip("有任意一个 = 此版本不激活NOT 关系)")]
[WorldStateFlag]
public string[] blockedByFlags;
/// <summary>检查此版本的激活条件AND requiredFlags / NOT blockedByFlags。</summary>

View File

@@ -0,0 +1,10 @@
using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// 标记一个 string 或 string[] 字段为世界状态标志 Key。
/// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。
/// </summary>
public sealed class WorldStateFlagAttribute : PropertyAttribute { }
}