using UnityEngine; using BaseGames.Core; namespace BaseGames.Dialogue { /// /// 对话行结构(架构 14_NarrativeModule §3)。 /// 每行包含说话人、文本(本地化 Key)和可选的语音片段。 /// /// 推荐通过 actor 引用 DialogueActorSO 统一管理头像/名称; /// speakerNameKey / portraitSprite 作为无 SO 时的直接覆盖(保持向后兼容)。 /// [System.Serializable] public struct DialogueLine { [Tooltip("说话人角色(推荐)。actor 优先;留空时回退到 speakerNameKey / portraitSprite")] public DialogueActorSO actor; [Tooltip("说话人本地化 key,留空时使用 actor.nameKey")] public string speakerNameKey; [Tooltip("对话文本本地化 Key,如 \"DLG_Elder_001\"。运行时通过 LocalizationManager.Get(textKey, \"Dialogue\") 获取实际文字。")] [TextArea(2, 5)] public string textKey; [Tooltip("说话人头像,留空时使用 actor.portrait")] public Sprite portraitSprite; [Tooltip("对应该行对话的语音片段(可选)。由 DialogueUI 通过 AudioSource 播放,打字机阶段同步开始。")] public AudioClip voiceClip; [Tooltip("打字机每字符延迟(秒)。0 = 使用 DialogueUI 默认值(推荐 0.03s)。\n" + "调小 = 打字更快;调大 = 打字更慢。仅影响本行,不影响其他行。")] [Min(0.01f)] public float typewriterDelay; [Tooltip("玩家选项(可选)。有值时,本行打字机效果结束后显示选项列表,等待玩家选择。\n" + "选择后根据 nextSequence 播放续集(或结束对话),并可选地设置 setWorldFlag 标志。\n" + "留空 = 普通对话行,玩家按确认键推进。")] public DialogueChoice[] choices; /// /// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。 /// public string ResolvedNameKey => actor != null && !string.IsNullOrEmpty(actor.nameKey) ? actor.nameKey : speakerNameKey; /// /// 获取最终使用的头像:actor 优先,回退到直接字段。 /// public Sprite ResolvedPortrait => actor != null && actor.portrait != null ? actor.portrait : portraitSprite; /// /// 获取最终使用的主题颜色:actor 有值时取 actor.accentColor,否则返回 white。 /// public Color ResolvedAccentColor => actor != null ? actor.accentColor : Color.white; /// /// 当前行是否由玩家角色说话(影响 UI 排版方向)。 /// public bool ResolvedIsPlayer => actor != null && actor.isPlayer; } /// /// 玩家可选的对话分支选项。 /// 在对话行打字机效果结束后呈现给玩家,选择后播放对应续集序列或结束对话。 /// [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; } /// /// 对话序列 SO(架构 14_NarrativeModule §3)。 /// 一个 NPC 对话场合对应一个序列,由若干 DialogueLine 组成。 /// 资产路径: Assets/ScriptableObjects/Dialogue/DLG_{NpcId}_{Context}.asset /// [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; /// /// 条件变体:requiredFlags 按 logic 逻辑满足时替换整个序列。 /// [System.Serializable] public struct ConditionalVariant { [Tooltip("条件判断逻辑:And(默认,全部满足)或 Or(任一满足)。\n" + "先选好逻辑再填标志,阅读顺序更自然。")] public BaseGames.Core.WorldStateFlagLogic logic; [Tooltip("条件标志列表。logic=And 时全部满足激活;logic=Or 时任一满足激活。留空表示无条件(总是激活)。")] [WorldStateFlag] public string[] requiredFlags; public DialogueSequenceSO sequence; } [Header("条件变体(可选)")] [Tooltip("运行时根据 WorldState 标志动态替换整个序列。按优先级从高到低排列:满足条件的第一个变体胜出。\n" + "每个变体支持 And(全部满足)或 Or(任一满足)两种判断逻辑。\n" + "留空表示无变体,始终使用本序列默认台词。")] public ConditionalVariant[] variants; // ── 运行时变体解析 ───────────────────────────────────────────────────── /// /// 检查单个条件变体的 requiredFlags 在给定 reader 下是否满足。 /// 无条件(requiredFlags 为空)的变体始终返回 true。 /// 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; } } /// /// 根据 提供的世界状态,返回第一个满足条件的变体序列; /// 无满足变体或 reader 为 null 时返回 this(默认序列)。 /// 开发构建下会在控制台输出命中的变体索引和标志,方便调试。 /// 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) 全量扫描)。 private static System.Collections.Generic.Dictionary s_seqIdToPath; private static double s_seqIdsCacheTime = -10.0; private static System.Collections.Generic.Dictionary 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(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(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; } ValidateChoiceCycles(); ValidateVariantOrder(); } /// /// 检查 variants 数组中是否存在"无条件变体遮蔽后续变体"的配置错误: /// 若某变体 requiredFlags 为空(无条件)且不在数组末尾,则其后所有变体永远不会被匹配。 /// 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(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 visited, int depth = 0) { if (depth > 32) { Debug.LogError($"[DialogueSequenceSO] 选项链深度超过 32 层(路径末端:'{seq.name}'),已视为存在循环引用并中止检测。请减少 nextSequence 嵌套层数。"); return true; } 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, depth + 1)) return true; } } } // 同时遍历条件变体序列,防止变体链形成环路 if (seq.variants != null) { foreach (var variant in seq.variants) { if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited, depth + 1)) return true; } } visited.Remove(seq.sequenceId); return false; } #endif } }