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
}
}