- QuestSO.HasPrerequisiteCycle/HasBranchCycle: depth>32 改为 LogError+return true 硬中止,防止栈溢出 - DialogueSequenceSO.HasChoiceCycle: 新增 depth 参数及 >32 硬中止,同时更新递归调用传 depth+1 - IQuestEventSource: 新增 OnAfterSaveLoaded 事件接口,供存档加载后统一刷新缓存 - QuestManager.OnLoad: 末尾触发 OnAfterSaveLoaded,确保所有缓存组件收到通知 - QuestGiver: 订阅 OnAfterSaveLoaded 设 _cacheDirty,存档恢复后 NPC 交互提示始终最新 - QuestManager.ApplyAffinity: 新增 giverNpc null 显式 LogWarning、maxAffinity<0 LogError 防护 - DialogueManager: 选项穿透防护改为预创建 WaitForSeconds(0.15f),替代 yield return null - QuestManager.UnlockBranches: 多分支同时满足时只播首个有对话的分支,防止重复播放 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
325 lines
15 KiB
C#
325 lines
15 KiB
C#
using UnityEngine;
|
||
using BaseGames.Core;
|
||
|
||
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>
|
||
/// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。
|
||
/// </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
|
||
{
|
||
[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 都重扫所有同类 SO(O(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
|
||
}
|
||
}
|