Files
zeling_v2/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs
2026-05-25 11:54:37 +08:00

344 lines
16 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.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Localization;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话行结构(架构 14_NarrativeModule §3
/// 每行包含说话人、文本(本地化 Key和可选的语音片段。
///
/// 推荐通过 actor 引用 DialogueActorSO 统一管理头像/名称;
/// speakerNameKey / portraitSprite 作为无 SO 时的直接覆盖(保持向后兼容)。
/// </summary>
[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;
/// <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>
/// 获取最终使用的主题颜色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>
/// 对话序列 SO架构 14_NarrativeModule §3
/// 一个 NPC 对话场合对应一个序列,由若干 DialogueLine 组成。
/// 资产路径: Assets/ScriptableObjects/Dialogue/DLG_{NpcId}_{Context}.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueSequence")]
public class DialogueSequenceSO : ScriptableObject, ILocalizableAsset
{
[Header("标识")]
[Tooltip("序列唯一 ID如 \"DLG_Elder_Quest_Available\"。OnValidate 会自动以资产名填充,也可手动指定。")]
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
[Header("对话行")]
[Tooltip("按顺序播放的对话行列表。每行包含说话人actor 优先)、文本本地化 Key、可选头像与语音。")]
public DialogueLine[] lines;
/// <summary>
/// 条件变体requiredFlags 按 logic 逻辑满足时替换整个序列。
/// </summary>
[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;
// ── 运行时变体解析 ─────────────────────────────────────────────────────
/// <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 都重扫所有同类 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;
}
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, 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
public IEnumerable<LocalizationKeyRef> GetLocalizationKeys()
{
if (lines == null) yield break;
foreach (var line in lines)
{
if (!string.IsNullOrEmpty(line.textKey))
yield return new LocalizationKeyRef(line.textKey, "Dialogue", "lines.textKey");
// speakerNameKey only relevant when actor is absent (override path)
if (line.actor == null && !string.IsNullOrEmpty(line.speakerNameKey))
yield return new LocalizationKeyRef(line.speakerNameKey, "Dialogue", "lines.speakerNameKey");
if (line.choices != null)
foreach (var choice in line.choices)
if (!string.IsNullOrEmpty(choice.textKey))
yield return new LocalizationKeyRef(choice.textKey, "Dialogue", "lines.choices.textKey");
}
}
}
}