feat: Round 48 narrative systems improvements
- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop - QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip - IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info) - IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it - IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event - QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions - DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix) - DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks - DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match - WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API - DialogueModule: List badge shows warning indicator for unconditional-shadowing variants - DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
@@ -17,14 +18,23 @@ namespace BaseGames.Dialogue
|
||||
|
||||
[Tooltip("说话人本地化 key,留空时使用 actor.nameKey")]
|
||||
public string speakerNameKey;
|
||||
[Tooltip("对话文本本地化 Key,如 \"DLG_Elder_001\"。运行时通过 LocalizationManager.Get(textKey, \"Dialogue\") 获取实际文字。")]
|
||||
[TextArea(2, 5)]
|
||||
public string textKey; // 本地化文本 key(如 "DLG_Elder_001")
|
||||
public string textKey;
|
||||
|
||||
[Tooltip("说话人头像,留空时使用 actor.portrait")]
|
||||
public Sprite portraitSprite;
|
||||
public AudioClip voiceClip; // 可选语音
|
||||
[Tooltip("对应该行对话的语音片段(可选)。由 DialogueUI 通过 AudioSource 播放,打字机阶段同步开始。")]
|
||||
public AudioClip voiceClip;
|
||||
[Tooltip("打字机每字符延迟(秒)。0 = 使用 DialogueUI 默认值(推荐 0.03s)。\n" +
|
||||
"调小 = 打字更快;调大 = 打字更慢。仅影响本行,不影响其他行。")]
|
||||
[Min(0.01f)]
|
||||
public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f)
|
||||
public float typewriterDelay;
|
||||
|
||||
[Tooltip("玩家选项(可选)。有值时,本行打字机效果结束后显示选项列表,等待玩家选择。\n" +
|
||||
"选择后根据 nextSequence 播放续集(或结束对话),并可选地设置 setWorldFlag 标志。\n" +
|
||||
"留空 = 普通对话行,玩家按确认键推进。")]
|
||||
public DialogueChoice[] choices;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。
|
||||
@@ -37,6 +47,31 @@ namespace BaseGames.Dialogue
|
||||
/// </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>
|
||||
@@ -47,23 +82,90 @@ namespace BaseGames.Dialogue
|
||||
[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 均满足时替换整个序列(AND 关系)。
|
||||
/// 与 NarrativeNPC.DialogueVersion 的多条件语义保持一致。
|
||||
/// 条件变体:requiredFlags 按 logic 逻辑满足时替换整个序列。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct ConditionalVariant
|
||||
{
|
||||
[Tooltip("全部满足时激活此变体(AND 关系)。留空表示无条件。")]
|
||||
[Tooltip("条件判断逻辑:And(默认,全部满足)或 Or(任一满足)。\n" +
|
||||
"先选好逻辑再填标志,阅读顺序更自然。")]
|
||||
public BaseGames.Core.WorldStateFlagLogic logic;
|
||||
[Tooltip("条件标志列表。logic=And 时全部满足激活;logic=Or 时任一满足激活。留空表示无条件(总是激活)。")]
|
||||
[WorldStateFlag]
|
||||
public string[] requiredFlags;
|
||||
public DialogueSequenceSO sequence;
|
||||
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) 全量扫描)。
|
||||
@@ -109,6 +211,108 @@ namespace BaseGames.Dialogue
|
||||
$"'{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)
|
||||
{
|
||||
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))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同时遍历条件变体序列,防止变体链形成环路
|
||||
if (seq.variants != null)
|
||||
{
|
||||
foreach (var variant in seq.variants)
|
||||
{
|
||||
if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
visited.Remove(seq.sequenceId);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user