From 446fd5dcd005345883758b969132333e9f3621d8 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sun, 24 May 2026 00:36:11 +0800 Subject: [PATCH] feat: Add WorldStateFlagAttribute and custom property drawer for enhanced dialogue management - Implemented WorldStateFlagAttribute to mark string fields as world state flags. - Created NarrativeNPCEditor for custom inspector to visualize dialogue version activation states. - Developed WorldStateFlagDrawer to provide dropdown menu for known flags in the inspector. - Introduced ActorModule for managing DialogueActorSO assets, including viewing, creating, and deleting actors. - Added DialogueModule for managing DialogueSequenceSO assets with detailed previews and action bars. - Established QuestModule for managing QuestSO assets, including objectives and branches. - Implemented QuestManagerPostprocessor to automatically refresh QuestManager's quest list on asset changes. --- .../_Game/Scripts/Dialogue/DialogueActorSO.cs | 81 +++++ .../_Game/Scripts/Dialogue/DialogueManager.cs | 117 +++++- .../Scripts/Dialogue/DialogueSequenceSO.cs | 82 ++++- Assets/_Game/Scripts/Dialogue/DialogueUI.cs | 33 +- Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs | 5 + .../Dialogue/WorldStateFlagAttribute.cs | 10 + .../Editor/Dialogue/NarrativeNPCEditor.cs | 209 +++++++++++ .../Editor/Dialogue/WorldStateFlagDrawer.cs | 195 ++++++++++ .../_Game/Scripts/Editor/Hub/DataHubWindow.cs | 3 + .../Scripts/Editor/Modules/ActorModule.cs | 128 +++++++ .../Scripts/Editor/Modules/DialogueModule.cs | 230 ++++++++++++ .../Scripts/Editor/Modules/QuestModule.cs | 342 ++++++++++++++++++ .../Scripts/Editor/Modules/SkillModule.cs | 73 +++- .../Editor/Quest/QuestManagerPostprocessor.cs | 64 ++++ .../Scripts/Editor/Shared/AssetOperations.cs | 16 +- .../_Game/Scripts/EventChain/EventChainSO.cs | 22 +- .../Localization/LocalizationManager.cs | 59 ++- Assets/_Game/Scripts/Quest/QuestGiver.cs | 18 +- Assets/_Game/Scripts/Quest/QuestManager.cs | 175 ++++++++- .../_Game/Scripts/Quest/QuestObjectiveSO.cs | 46 ++- Assets/_Game/Scripts/Quest/QuestSO.cs | 96 ++++- .../_Game/Scripts/World/WorldStateRegistry.cs | 5 +- 22 files changed, 1908 insertions(+), 101 deletions(-) create mode 100644 Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs create mode 100644 Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs create mode 100644 Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs create mode 100644 Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/ActorModule.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs create mode 100644 Assets/_Game/Scripts/Editor/Modules/QuestModule.cs create mode 100644 Assets/_Game/Scripts/Editor/Quest/QuestManagerPostprocessor.cs diff --git a/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs b/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs new file mode 100644 index 0000000..54c8398 --- /dev/null +++ b/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs @@ -0,0 +1,81 @@ +using UnityEngine; + +namespace BaseGames.Dialogue +{ + /// + /// 对话角色定义 SO(架构 14_NarrativeModule §3)。 + /// 将 NPC 的显示名、头像、对话气泡颜色集中在一处管理。 + /// DialogueLine.actor 引用此 SO,修改头像/名称只需改一个资产, + /// 无需批量编辑所有对话行。 + /// + /// 资产路径:Assets/_Game/Data/Dialogue/Actors/Actor_{actorId}.asset + /// + [CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueActor")] + public class DialogueActorSO : ScriptableObject + { + [Header("标识")] + [Tooltip("唯一 ID,如 \"NPC_Elder\",供 DialogueLine 引用")] + public string actorId; + + [Header("显示")] + [Tooltip("本地化 Key,格式如 \"NPC_Elder_Name\"")] + public string nameKey; + + [Tooltip("对话 UI 中显示的头像")] + public Sprite portrait; + + [Tooltip("对话气泡/说话人名称的主题颜色(可选)")] + public Color accentColor = Color.white; + + [Tooltip("是否为玩家角色(影响对话 UI 排版方向)")] + public bool isPlayer; + +#if UNITY_EDITOR + // actorId → 资产路径,5 秒 TTL,跨所有 DialogueActorSO.OnValidate 共用。 + // 与 QuestSO / DialogueSequenceSO 保持一致的 O(1) 重复检测策略。 + private static System.Collections.Generic.Dictionary s_actorIdToPath; + private static double s_actorIdsCacheTime = -10.0; + + private static System.Collections.Generic.Dictionary GetActorIdCache() + { + double now = UnityEditor.EditorApplication.timeSinceStartup; + if (s_actorIdToPath != null && now - s_actorIdsCacheTime < 5.0) + return s_actorIdToPath; + + s_actorIdToPath = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + string[] guids = UnityEditor.AssetDatabase.FindAssets("t:DialogueActorSO"); + foreach (var guid in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var actor = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (actor != null && !string.IsNullOrEmpty(actor.actorId) && !s_actorIdToPath.ContainsKey(actor.actorId)) + s_actorIdToPath[actor.actorId] = path; + } + s_actorIdsCacheTime = now; + return s_actorIdToPath; + } + + private void OnValidate() + { + if (string.IsNullOrWhiteSpace(actorId)) + { + Debug.LogWarning($"[DialogueActorSO] '{name}' 缺少 actorId,保存前请填写。", this); + return; + } + + // 检测重复 actorId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。 + var cache = GetActorIdCache(); + string myPath = UnityEditor.AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(myPath) && + cache.TryGetValue(actorId, out var existingPath) && + existingPath != myPath) + { + Debug.LogError( + $"[DialogueActorSO] actorId '{actorId}' 与 " + + $"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this); + s_actorIdsCacheTime = -10.0; + } + } +#endif + } +} diff --git a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs index 37f450f..d63a6ca 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Collections.Generic; using BaseGames.Core; using BaseGames.Core.Events; using BaseGames.Input; @@ -22,9 +23,36 @@ namespace BaseGames.Dialogue [SerializeField] private VoidEventChannelSO _onDialogueStarted; [SerializeField] private VoidEventChannelSO _onDialogueEnded; [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId) + [SerializeField] private VoidEventChannelSO _onLineStarted; // 每行开始打字时广播 + [SerializeField] private VoidEventChannelSO _onLineEnded; // 每行玩家确认后广播 private bool _skipRequested; + // ── 复用 Yield 指令,避免协程每行 new WaitUntil 闭包 ─────────────── + private sealed class WaitTypingOrSkip : CustomYieldInstruction + { + private readonly DialogueManager _m; + public WaitTypingOrSkip(DialogueManager m) => _m = m; + public override bool keepWaiting => _m._dialogueBox.IsTyping && !_m._skipRequested; + } + private sealed class WaitSkip : CustomYieldInstruction + { + private readonly DialogueManager _m; + public WaitSkip(DialogueManager m) => _m = m; + public override bool keepWaiting => !_m._skipRequested; + } + + private WaitTypingOrSkip _waitTypingOrSkip; + private WaitSkip _waitSkip; + + /// + /// 当 IsDialogueActive 时排队等待的对话请求。 + /// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话), + /// 但容量上限为 8,避免因误触导致无限排队。 + /// + private readonly Queue<(DialogueSequenceSO seq, string npcId)> _pending = new(); + private const int PendingCapacity = 8; + /// 当前是否有对话正在播放。 public bool IsDialogueActive { get; private set; } @@ -34,6 +62,8 @@ namespace BaseGames.Dialogue { if (ServiceLocator.GetOrDefault() != null) { Destroy(gameObject); return; } ServiceLocator.Register(this); + _waitTypingOrSkip = new WaitTypingOrSkip(this); + _waitSkip = new WaitSkip(this); } private void OnDestroy() @@ -49,25 +79,46 @@ namespace BaseGames.Dialogue private void OnDisable() { if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit; + _pending.Clear(); + + // 若对话协程在组件禁用或场景切换时仍在运行,Unity 会强制杀死协程但不调用 + // EndDialogue(),导致 Action Map 永久停留在 UI 模式。此处主动恢复。 + if (IsDialogueActive) + { + StopAllCoroutines(); + _dialogueBox?.Hide(); + IsDialogueActive = false; + _inputReader?.EnableGameplayInput(); + } } // ── 公开 API ────────────────────────────────────────────────────── /// - /// 启动对话序列。若已有对话在播放则忽略新请求。 + /// 启动对话序列。 + /// 若已有对话在播放,请求会进入等待队列(上限 ), + /// 待当前对话结束后依序自动播放;超出上限的请求被丢弃。 /// 由 InteractableNPC.Interact() 调用。 /// /// 要播放的对话序列 SO。 /// NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。 public void StartDialogue(DialogueSequenceSO sequence, string npcId = "") { - if (IsDialogueActive || sequence == null) return; + if (sequence == null) return; + if (IsDialogueActive) + { + if (_pending.Count < PendingCapacity) + _pending.Enqueue((sequence, npcId)); + return; + } + PlayImmediate(sequence, npcId); + } + + private void PlayImmediate(DialogueSequenceSO sequence, string npcId) + { IsDialogueActive = true; - _skipRequested = false; - - // 切换到 UI Action Map(禁用玩家移动输入) - _inputReader.EnableUIInput(); - + _skipRequested = false; + if (_inputReader != null) _inputReader.EnableUIInput(); _onDialogueStarted?.Raise(); StartCoroutine(PlaySequence(sequence, npcId)); } @@ -80,21 +131,28 @@ namespace BaseGames.Dialogue private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId) { - // 选择条件变体(查询 ConditionalVariant.conditionFlag — 未实现 WorldStateRegistry 查询时直接使用默认序列) var resolved = ResolveVariant(sequence); + if (resolved.lines == null || resolved.lines.Length == 0) + { + EndDialogue(npcId); + yield break; + } + foreach (var line in resolved.lines) { _skipRequested = false; _dialogueBox.ShowLine(line); + _onLineStarted?.Raise(); // 等待打字完成,期间允许跳过 - yield return new WaitUntil(() => !_dialogueBox.IsTyping || _skipRequested); + yield return _waitTypingOrSkip; if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping(); // 等待玩家按 Submit 推进下一行 _skipRequested = false; - yield return new WaitUntil(() => _skipRequested); + yield return _waitSkip; + _onLineEnded?.Raise(); } EndDialogue(npcId); @@ -105,18 +163,24 @@ namespace BaseGames.Dialogue _dialogueBox.Hide(); IsDialogueActive = false; - // 恢复 Gameplay Action Map - _inputReader.EnableGameplayInput(); + if (_inputReader != null) _inputReader.EnableGameplayInput(); _onDialogueEnded?.Raise(); if (!string.IsNullOrEmpty(npcId)) _onNpcDialogueCompleted?.Raise(npcId); + + // 自动播放队首等待中的对话(脚本触发的连续序列) + if (_pending.Count > 0) + { + var (seq, id) = _pending.Dequeue(); + PlayImmediate(seq, id); + } } /// /// 根据 ConditionalVariant 选择正确的序列版本。 - /// 按顺序检查 variants:第一个满足 WorldStateRegistry 标志的变体胜出; + /// 按顺序检查 variants:所有 requiredFlags 均满足的第一个变体胜出(AND 关系); /// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。 /// private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence) @@ -128,12 +192,31 @@ namespace BaseGames.Dialogue { foreach (var variant in sequence.variants) { - if (!string.IsNullOrEmpty(variant.conditionFlag) - && variant.sequence != null - && _worldState.HasFlag(variant.conditionFlag)) - return variant.sequence; + if (variant.sequence == null) continue; + if (variant.requiredFlags == null || variant.requiredFlags.Length == 0) + return variant.sequence; // 无条件变体:直接采用 + + bool allMet = true; + foreach (var flag in variant.requiredFlags) + { + if (!string.IsNullOrEmpty(flag) && !_worldState.HasFlag(flag)) + { + allMet = false; + break; + } + } + if (allMet) return variant.sequence; } } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else if (sequence.variants.Length > 0) + { + Debug.LogWarning( + $"[DialogueManager] 序列 '{sequence.sequenceId}' 有 {sequence.variants.Length} 个条件变体," + + "但 WorldStateRegistry 未注入,将使用默认序列。请检查 Inspector 中的 _worldState 字段。", + this); + } +#endif return sequence; } diff --git a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs index ac36131..56c830c 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs @@ -5,17 +5,38 @@ namespace BaseGames.Dialogue /// /// 对话行结构(架构 14_NarrativeModule §3)。 /// 每行包含说话人、文本(本地化 Key)和可选的语音片段。 + /// + /// 推荐通过 actor 引用 DialogueActorSO 统一管理头像/名称; + /// speakerNameKey / portraitSprite 作为无 SO 时的直接覆盖(保持向后兼容)。 /// [System.Serializable] public struct DialogueLine { - public string speakerNameKey; // 本地化 key(如 "NPC_Elder_Name") + [Tooltip("说话人角色(推荐)。actor 优先;留空时回退到 speakerNameKey / portraitSprite")] + public DialogueActorSO actor; + + [Tooltip("说话人本地化 key,留空时使用 actor.nameKey")] + public string speakerNameKey; [TextArea(2, 5)] public string textKey; // 本地化文本 key(如 "DLG_Elder_001") - public Sprite portraitSprite; // 可选说话人头像 + + [Tooltip("说话人头像,留空时使用 actor.portrait")] + public Sprite portraitSprite; public AudioClip voiceClip; // 可选语音 [Min(0.01f)] public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f) + + /// + /// 获取最终使用的说话人名称 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; } /// @@ -29,13 +50,66 @@ namespace BaseGames.Dialogue public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available" public DialogueLine[] lines; - /// 条件变体:满足特定世界标志时替换整个序列。 + /// + /// 条件变体:所有 requiredFlags 均满足时替换整个序列(AND 关系)。 + /// 与 NarrativeNPC.DialogueVersion 的多条件语义保持一致。 + /// [System.Serializable] public struct ConditionalVariant { - public string conditionFlag; // WorldState flag key + [Tooltip("全部满足时激活此变体(AND 关系)。留空表示无条件。")] + [WorldStateFlag] + public string[] requiredFlags; public DialogueSequenceSO sequence; } public ConditionalVariant[] variants; + +#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; + } + } +#endif } } diff --git a/Assets/_Game/Scripts/Dialogue/DialogueUI.cs b/Assets/_Game/Scripts/Dialogue/DialogueUI.cs index f30f79c..9d47853 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueUI.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueUI.cs @@ -26,6 +26,11 @@ namespace BaseGames.Dialogue private DialogueLine _currentLine; private const float DefaultTypewriterDelay = 0.03f; + // 缓存单个 WaitForSecondsRealtime,避免 TypeLine 每字符 new 分配。 + // 当 delay 值改变时才重新创建(不同行可能有不同打字速度)。 + private WaitForSecondsRealtime _cachedTypeDelay; + private float _cachedTypeDelayValue = -1f; + /// 当前是否仍在执行打字机效果。 public bool IsTyping { get; private set; } @@ -35,20 +40,22 @@ namespace BaseGames.Dialogue public void ShowLine(DialogueLine line) { _currentLine = line; - _rootPanel.SetActive(true); - _continuePrompt.SetActive(false); + if (_rootPanel != null) _rootPanel.SetActive(true); + if (_continuePrompt != null) _continuePrompt.SetActive(false); - // 说话人名称 - bool hasSpeaker = !string.IsNullOrEmpty(line.speakerNameKey); + // 说话人名称(actor 优先,回退到直接字段) + string resolvedNameKey = line.ResolvedNameKey; + bool hasSpeaker = !string.IsNullOrEmpty(resolvedNameKey); if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker); if (hasSpeaker && _speakerNameText != null) - _speakerNameText.text = LocalizationManager.Get(line.speakerNameKey, "Dialogue"); + _speakerNameText.text = LocalizationManager.Get(resolvedNameKey, "Dialogue"); - // 头像 + // 头像(actor 优先,回退到直接字段) + var resolvedPortrait = line.ResolvedPortrait; if (_speakerPortrait != null) { - _speakerPortrait.gameObject.SetActive(line.portraitSprite != null); - if (line.portraitSprite != null) _speakerPortrait.sprite = line.portraitSprite; + _speakerPortrait.gameObject.SetActive(resolvedPortrait != null); + if (resolvedPortrait != null) _speakerPortrait.sprite = resolvedPortrait; } // 语音播放 @@ -95,6 +102,14 @@ namespace BaseGames.Dialogue { IsTyping = true; float delay = line.typewriterDelay > 0f ? line.typewriterDelay : DefaultTypewriterDelay; + + // 复用缓存的 WaitForSecondsRealtime;仅当 delay 值变化时才重新 new + if (_cachedTypeDelay == null || !Mathf.Approximately(_cachedTypeDelayValue, delay)) + { + _cachedTypeDelay = new WaitForSecondsRealtime(delay); + _cachedTypeDelayValue = delay; + } + string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue"); // 性能:StringBuilder 避免每帧字符串分配(O(n²) → O(n)) @@ -105,7 +120,7 @@ namespace BaseGames.Dialogue { sb.Append(c); if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配 - yield return new WaitForSecondsRealtime(delay); + yield return _cachedTypeDelay; } IsTyping = false; diff --git a/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs b/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs index 3238c19..0bf4984 100644 --- a/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs +++ b/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs @@ -26,6 +26,9 @@ namespace BaseGames.Dialogue return version.dialogue; } + if (_fallbackDialogue == null) + Debug.LogWarning($"[NarrativeNPC] '{name}' 没有版本满足当前条件,且未配置兜底对话 (_fallbackDialogue)。", gameObject); + return _fallbackDialogue; } } @@ -43,9 +46,11 @@ namespace BaseGames.Dialogue public DialogueSequenceSO dialogue; [Tooltip("全部满足才激活此版本(AND 关系)")] + [WorldStateFlag] public string[] requiredFlags; [Tooltip("有任意一个 = 此版本不激活(NOT 关系)")] + [WorldStateFlag] public string[] blockedByFlags; /// 检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。 diff --git a/Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs b/Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs new file mode 100644 index 0000000..5b63591 --- /dev/null +++ b/Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs @@ -0,0 +1,10 @@ +using UnityEngine; + +namespace BaseGames.Dialogue +{ + /// + /// 标记一个 string 或 string[] 字段为世界状态标志 Key。 + /// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。 + /// + public sealed class WorldStateFlagAttribute : PropertyAttribute { } +} diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs b/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs new file mode 100644 index 0000000..bf1cb1f --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs @@ -0,0 +1,209 @@ +using UnityEditor; +using UnityEngine; +using BaseGames.Dialogue; +using BaseGames.World; + +namespace BaseGames.Editor.Dialogue +{ + /// + /// NarrativeNPC 自定义 Inspector。 + /// 在默认字段之下显示"对话版本激活状态"面板, + /// 以色彩提示每个 DialogueVersion 在当前 WorldStateRegistry 中是否激活, + /// 方便策划人员在编辑模式下即时核查对话版本切换逻辑。 + /// + [CustomEditor(typeof(NarrativeNPC))] + public class NarrativeNPCEditor : UnityEditor.Editor + { + private static bool s_foldout = true; + + private static readonly Color ColorActive = new(0.25f, 0.75f, 0.40f, 1f); + private static readonly Color ColorInactive = new(0.55f, 0.55f, 0.55f, 1f); + private static readonly Color ColorBlocked = new(0.85f, 0.40f, 0.35f, 1f); + + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + EditorGUILayout.Space(6); + + if (Application.isPlaying) + { + EditorGUILayout.HelpBox( + "PlayMode 中显示的是 WorldStateRegistry SO 的初始序列化值,非运行时动态 flags。\n" + + "如需查看实际激活版本,请在运行时检查 NPC 日志或在 SO 上断点调试。", + MessageType.Info); + } + + s_foldout = EditorGUILayout.BeginFoldoutHeaderGroup(s_foldout, "对话版本激活状态"); + + if (s_foldout) + { + EditorGUI.indentLevel++; + DrawVersionStatus(); + EditorGUI.indentLevel--; + } + + EditorGUILayout.EndFoldoutHeaderGroup(); + } + + // ── 版本状态绘制 ────────────────────────────────────────────────────── + + private void DrawVersionStatus() + { + // 获取 WorldStateRegistry(运行时或编辑器 SO 引用均可) + var worldStateProp = serializedObject.FindProperty("_worldState"); + var registry = worldStateProp?.objectReferenceValue as WorldStateRegistry; + + var versionsProp = serializedObject.FindProperty("_dialogueVersions"); + if (versionsProp == null || !versionsProp.isArray || versionsProp.arraySize == 0) + { + EditorGUILayout.HelpBox("未设置任何对话版本。", MessageType.None); + return; + } + + if (registry == null) + { + EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n请在 Inspector 中设置 World State 字段。", MessageType.Warning); + DrawVersionLabelsOnly(versionsProp); + return; + } + + bool higherActive = false; + + for (int i = 0; i < versionsProp.arraySize; i++) + { + var element = versionsProp.GetArrayElementAtIndex(i); + + string label = GetStringProp(element, "versionLabel"); + string displayName = string.IsNullOrEmpty(label) ? $"版本 {i}" : label; + string dialogueName = GetObjectPropName(element, "dialogue"); + + // 计算激活状态(直接迭代 SerializedProperty,不分配中间 string[]) + bool missingRequired = false; + string missingFlag = null; + var reqProp = element.FindPropertyRelative("requiredFlags"); + if (reqProp != null && reqProp.isArray) + { + for (int j = 0; j < reqProp.arraySize; j++) + { + var f = reqProp.GetArrayElementAtIndex(j).stringValue; + if (!string.IsNullOrEmpty(f) && !registry.HasFlag(f)) + { + missingRequired = true; + missingFlag = f; + break; + } + } + } + + bool hasBlocker = false; + string blockerKey = null; + if (!missingRequired) + { + var blkProp = element.FindPropertyRelative("blockedByFlags"); + if (blkProp != null && blkProp.isArray) + { + for (int j = 0; j < blkProp.arraySize; j++) + { + var f = blkProp.GetArrayElementAtIndex(j).stringValue; + if (!string.IsNullOrEmpty(f) && registry.HasFlag(f)) + { + hasBlocker = true; + blockerKey = f; + break; + } + } + } + } + + bool conditionsMet = !missingRequired && !hasBlocker; + bool isActive = conditionsMet && !higherActive; + + Color statusColor; + string statusText; + + if (isActive) + { + statusColor = ColorActive; + statusText = "✓ 激活中"; + } + else if (conditionsMet && higherActive) + { + statusColor = ColorInactive; + statusText = "⏩ 被更高优先级覆盖"; + } + else if (hasBlocker) + { + statusColor = ColorBlocked; + statusText = $"✗ blockedByFlag 阻断 [{blockerKey}]"; + } + else + { + statusColor = ColorInactive; + statusText = $"✗ 缺少 requiredFlag [{missingFlag}]"; + } + + DrawVersionRow(i, displayName, dialogueName, statusText, statusColor); + + if (conditionsMet) higherActive = true; + } + + // 兜底说明 + EditorGUILayout.Space(2); + var fallbackProp = serializedObject.FindProperty("_fallbackDialogue"); + if (fallbackProp?.objectReferenceValue != null) + { + using (new EditorGUI.DisabledScope(true)) + EditorGUILayout.LabelField("兜底台词", fallbackProp.objectReferenceValue.name); + } + } + + private static void DrawVersionLabelsOnly(SerializedProperty versionsProp) + { + for (int i = 0; i < versionsProp.arraySize; i++) + { + var element = versionsProp.GetArrayElementAtIndex(i); + string label = GetStringProp(element, "versionLabel"); + string display = string.IsNullOrEmpty(label) ? $"版本 {i}" : label; + EditorGUILayout.LabelField($" {i}. {display}", EditorStyles.miniLabel); + } + } + + private static void DrawVersionRow(int index, string versionLabel, string dialogueName, string statusText, Color statusColor) + { + using (new EditorGUILayout.HorizontalScope()) + { + // 序号+名称 + EditorGUILayout.LabelField($"{index}. {versionLabel}", GUILayout.Width(160)); + + // 对话 SO 名称 + if (!string.IsNullOrEmpty(dialogueName)) + { + using (new EditorGUI.DisabledScope(true)) + EditorGUILayout.LabelField(dialogueName, EditorStyles.miniLabel, GUILayout.Width(140)); + } + + // 状态徽章 + var prevColor = GUI.contentColor; + GUI.contentColor = statusColor; + EditorGUILayout.LabelField(statusText, EditorStyles.boldLabel); + GUI.contentColor = prevColor; + } + } + + // ── 辅助:从 SerializedProperty 读取字段 ───────────────────────────── + + private static string GetStringProp(SerializedProperty parent, string name) + { + var p = parent.FindPropertyRelative(name); + return p != null ? p.stringValue : string.Empty; + } + + private static string GetObjectPropName(SerializedProperty parent, string name) + { + var p = parent.FindPropertyRelative(name); + if (p == null || p.objectReferenceValue == null) return string.Empty; + return p.objectReferenceValue.name; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs b/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs new file mode 100644 index 0000000..25dc579 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs @@ -0,0 +1,195 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using BaseGames.Dialogue; +using BaseGames.EventChain; +using BaseGames.Editor; + +namespace BaseGames.Editor.Dialogue +{ + /// + /// 在 Inspector 中为 [WorldStateFlag] 标记的 string 字段提供已知标志下拉菜单。 + /// 扫描项目内所有 SetFlagAction / FlagSetCondition / ConditionalVariant.requiredFlags 收集已使用的标志名, + /// 供策划选取;同时保留直接输入新标志的能力。 + /// + [CustomPropertyDrawer(typeof(WorldStateFlagAttribute))] + internal sealed class WorldStateFlagDrawer : PropertyDrawer + { + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { + if (property.propertyType != SerializedPropertyType.String) + { + EditorGUI.PropertyField(position, property, label); + return; + } + + EditorGUI.BeginProperty(position, label, property); + + // 文本输入区 + 下拉箭头按钮 + const float btnW = 22f; + var textRect = new Rect(position.x, position.y, position.width - btnW - 2f, position.height); + var btnRect = new Rect(position.xMax - btnW, position.y, btnW, position.height); + + property.stringValue = EditorGUI.TextField(textRect, label, property.stringValue); + + if (GUI.Button(btnRect, EditorGUIUtility.IconContent("d_icon dropdown"), EditorStyles.iconButton)) + { + var flags = WorldStateFlagCollector.CollectKnownFlags(); + var menu = new GenericMenu(); + var current = property.stringValue; + var propPath = property.propertyPath; + var so = property.serializedObject; + + foreach (var flag in flags) + { + var captured = flag; + menu.AddItem( + new GUIContent(captured), + current == captured, + () => + { + var prop = so.FindProperty(propPath); + if (prop != null) + { + prop.stringValue = captured; + so.ApplyModifiedProperties(); + } + }); + } + + if (flags.Count == 0) + menu.AddDisabledItem(new GUIContent("(项目中尚未定义任何标志)")); + + menu.AddSeparator(string.Empty); + menu.AddItem(new GUIContent("刷新列表"), false, WorldStateFlagCollector.Invalidate); + + menu.ShowAsContext(); + } + + EditorGUI.EndProperty(); + } + } + + // ── 标志扫描器 ──────────────────────────────────────────────────────────── + // SO 来源(SetFlagAction / FlagSetCondition / DialogueSequenceSO):5 秒 TTL 自动刷新。 + // NarrativeNPC prefab 来源:仅在用户点击"刷新列表"时执行(开销高,手动触发)。 + + internal static class WorldStateFlagCollector + { + private static double _lastSoCollect = -10.0; + private static double _lastPrefabCollect = double.MinValue; // 默认不自动触发 + private static List _cache = new(); + private static SortedSet _prefabFlags = new(System.StringComparer.OrdinalIgnoreCase); + + /// + /// 强制下次 CollectKnownFlags() 时重新扫描 SO 和 prefab(含 NarrativeNPC)。 + /// 由下拉菜单的"刷新列表"触发。 + /// + public static void Invalidate() + { + _lastSoCollect = -10.0; + _lastPrefabCollect = -10.0; // 重置 prefab 缓存使其在下次调用时立即扫描 + } + + /// + /// 收集项目内所有已使用的世界状态标志名。 + /// - SO 来源(SetFlagAction / FlagSetCondition / ConditionalVariant):5 秒缓存。 + /// - NarrativeNPC prefab 来源:仅在调用 Invalidate() 后才重新扫描。 + /// + public static List CollectKnownFlags() + { + double now = EditorApplication.timeSinceStartup; + + bool soStale = now - _lastSoCollect >= 5.0; + bool prefabStale = now - _lastPrefabCollect >= 5.0 && _lastPrefabCollect < 0; // 仅在 Invalidate 后为负 + + if (!soStale && !prefabStale) + return _cache; + + var found = new SortedSet(System.StringComparer.OrdinalIgnoreCase); + + if (soStale) + { + CollectSoFlags(found); + _lastSoCollect = now; + } + else + { + // SO 缓存仍有效,直接把已有 SO 标志加入合并集 + foreach (var f in _cache) + { + // _cache 里混有 prefab 标志,先全加;下面再合并 prefab + found.Add(f); + } + } + + if (prefabStale) + { + _prefabFlags.Clear(); + CollectNarrativeNpcFlags(_prefabFlags); + _lastPrefabCollect = now; + } + + foreach (var f in _prefabFlags) + found.Add(f); + + _cache = new List(found); + return _cache; + } + + private static void CollectSoFlags(SortedSet found) + { + foreach (var a in AssetOperations.FindAll()) + if (!string.IsNullOrWhiteSpace(a.flagId)) found.Add(a.flagId.Trim()); + + foreach (var c in AssetOperations.FindAll()) + if (!string.IsNullOrWhiteSpace(c.flagId)) found.Add(c.flagId.Trim()); + + foreach (var seq in AssetOperations.FindAll()) + { + if (seq.variants == null) continue; + foreach (var v in seq.variants) + { + if (v.requiredFlags == null) continue; + foreach (var f in v.requiredFlags) + if (!string.IsNullOrWhiteSpace(f)) found.Add(f.Trim()); + } + } + } + + private static void CollectNarrativeNpcFlags(SortedSet found) + { + var prefabGuids = AssetDatabase.FindAssets("t:Prefab"); + foreach (var guid in prefabGuids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var prefab = AssetDatabase.LoadAssetAtPath(path); + if (prefab == null) continue; + + var npc = prefab.GetComponent(); + if (npc == null) continue; + + var so = new SerializedObject(npc); + var versProp = so.FindProperty("_dialogueVersions"); + if (versProp == null || !versProp.isArray) continue; + + for (int i = 0; i < versProp.arraySize; i++) + { + var elem = versProp.GetArrayElementAtIndex(i); + ExtractStringArrayProp(elem.FindPropertyRelative("requiredFlags"), found); + ExtractStringArrayProp(elem.FindPropertyRelative("blockedByFlags"), found); + } + } + } + + private static void ExtractStringArrayProp(SerializedProperty arr, SortedSet found) + { + if (arr == null || !arr.isArray) return; + for (int i = 0; i < arr.arraySize; i++) + { + var val = arr.GetArrayElementAtIndex(i).stringValue; + if (!string.IsNullOrWhiteSpace(val)) found.Add(val.Trim()); + } + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs b/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs index 13892a4..f1af19e 100644 --- a/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs +++ b/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs @@ -77,6 +77,9 @@ namespace BaseGames.Editor _modules.Add(new BossSkillModule()); _modules.Add(new CharmModule()); _modules.Add(new StreamingModule()); + _modules.Add(new DialogueModule()); + _modules.Add(new QuestModule()); + _modules.Add(new ActorModule()); } // ── 布局 ───────────────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs new file mode 100644 index 0000000..07985f0 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs @@ -0,0 +1,128 @@ +using System; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Dialogue; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub 对话角色模块 —— 管理 DialogueActorSO 资产。 + /// 统一查看、创建、重命名、删除 NPC/玩家角色定义(头像、名称 Key、强调色)。 + /// + public class ActorModule : IDataModule + { + private const string Folder = "Assets/_Game/Data/Dialogue/Actors"; + private const string Prefix = "Actor_"; + + public string ModuleId => "actor"; + public string DisplayName => "角色"; + public string IconName => "d_Prefab Icon"; + + private SoListPane _listPane; + private DetailHeader _header; + private DialogueActorSO _selected; + + public void Initialize() + { + _listPane = new SoListPane( + Folder, Prefix, + a => a.isPlayer ? "[玩家]" : null); + } + + public void BuildListPane(VisualElement container, Action onSelected) + { + _listPane.SelectionChanged = sel => + { + _selected = sel; + onSelected?.Invoke(sel); + }; + container.Add(_listPane); + _listPane.Refresh(); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _selected = selected as DialogueActorSO; + + _header = new DetailHeader(); + _header.SetAsset(_selected); + _header.RenameRequested += OnRenameRequested; + container.Add(_header); + + if (_selected == null) return; + + container.Add(BuildInfoCard(_selected)); + container.Add(BuildActionBar(_selected)); + container.Add(SkillModule.MakeDivider()); + container.Add(new InspectorElement(_selected)); + } + + public void OnActivated() => _listPane?.Refresh(); + + // ── 内部 ───────────────────────────────────────────────────────────── + + private void OnRenameRequested(string newName) + { + if (_selected == null) return; + var (ok, err) = AssetOperations.Rename(_selected, newName); + if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定"); + else { _header.SetAsset(_selected); _listPane.Invalidate(); } + } + + private static VisualElement BuildInfoCard(DialogueActorSO a) + { + var card = SkillModule.MakeCard(); + + SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(a.actorId) ? "(未设置)" : a.actorId); + SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(a.nameKey) ? "(未设置)" : a.nameKey); + if (a.isPlayer) + SkillModule.AddChip(card, "类型", "玩家"); + + // 头像预览 + if (a.portrait != null) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.paddingLeft = 8; + row.style.paddingTop = 4; + + var img = new Image { image = a.portrait.texture }; + img.style.width = 40; + img.style.height = 40; + img.style.borderTopLeftRadius = 4; + img.style.borderTopRightRadius = 4; + img.style.borderBottomLeftRadius = 4; + img.style.borderBottomRightRadius = 4; + row.Add(img); + + // 强调色色块 + var swatch = new VisualElement(); + swatch.style.width = 14; + swatch.style.height = 14; + swatch.style.marginLeft = 8; + swatch.style.backgroundColor = new StyleColor(a.accentColor); + swatch.style.borderTopLeftRadius = 3; + swatch.style.borderTopRightRadius = 3; + swatch.style.borderBottomLeftRadius = 3; + swatch.style.borderBottomRightRadius = 3; + row.Add(swatch); + + card.Add(row); + } + + return card; + } + + private VisualElement BuildActionBar(DialogueActorSO a) + { + return SkillModule.BuildStandardActionBar( + a, Folder, Prefix, + onCreated: c => _listPane.Refresh(c), + onCloned: c => _listPane.Refresh(c), + onDeleted: () => _listPane.Refresh(null)); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs new file mode 100644 index 0000000..1f35bd2 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs @@ -0,0 +1,230 @@ +using System; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Dialogue; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。 + /// + public class DialogueModule : IDataModule + { + private const string Folder = "Assets/_Game/Data/Dialogue"; + private const string Prefix = "DLG_"; + + public string ModuleId => "dialogue"; + public string DisplayName => "对话"; + public string IconName => "d_UnityEditor.ConsoleWindow"; + + private SoListPane _listPane; + private DetailHeader _header; + private DialogueSequenceSO _selected; + + public void Initialize() + { + _listPane = new SoListPane( + Folder, Prefix, + s => + { + int v = s.variants != null ? s.variants.Length : 0; + return v > 0 ? $"{v}变体" : null; + }); + } + + public void BuildListPane(VisualElement container, Action onSelected) + { + _listPane.SelectionChanged = sel => + { + _selected = sel; + onSelected?.Invoke(sel); + }; + container.Add(_listPane); + _listPane.Refresh(); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _selected = selected as DialogueSequenceSO; + + _header = new DetailHeader(); + _header.SetAsset(_selected); + _header.RenameRequested += OnRenameRequested; + container.Add(_header); + + if (_selected == null) return; + + container.Add(BuildInfoCard(_selected)); + container.Add(BuildLinesPreview(_selected)); + if (_selected.variants != null && _selected.variants.Length > 0) + container.Add(BuildVariantsCard(_selected)); + container.Add(BuildActionBar(_selected)); + container.Add(SkillModule.MakeDivider()); + container.Add(new InspectorElement(_selected)); + } + + public void OnActivated() => _listPane?.Refresh(); + + // ── 内部 ───────────────────────────────────────────────────────────── + + private void OnRenameRequested(string newName) + { + if (_selected == null) return; + var (ok, err) = AssetOperations.Rename(_selected, newName); + if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定"); + else { _header.SetAsset(_selected); _listPane.Invalidate(); } + } + + private static VisualElement BuildInfoCard(DialogueSequenceSO s) + { + var card = SkillModule.MakeCard(); + int lineCount = s.lines != null ? s.lines.Length : 0; + int variantCount = s.variants != null ? s.variants.Length : 0; + + SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.sequenceId) ? "(未设置)" : s.sequenceId); + SkillModule.AddChip(card, "行数", lineCount.ToString()); + if (variantCount > 0) + SkillModule.AddChip(card, "变体数", variantCount.ToString()); + return card; + } + + private static VisualElement BuildLinesPreview(DialogueSequenceSO s) + { + var section = new VisualElement(); + section.style.paddingLeft = 12; + section.style.paddingRight = 12; + section.style.paddingTop = 6; + section.style.paddingBottom = 6; + + var title = new Label("对话预览(前 5 行)"); + title.style.fontSize = 11; + title.style.opacity = 0.55f; + title.style.marginBottom = 4; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + section.Add(title); + + if (s.lines == null || s.lines.Length == 0) + { + var empty = new Label("(无对话行)"); + empty.style.opacity = 0.4f; + empty.style.fontSize = 11; + section.Add(empty); + return section; + } + + int preview = Mathf.Min(5, s.lines.Length); + for (int i = 0; i < preview; i++) + { + var line = s.lines[i]; + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.marginBottom = 3; + + // 头像图标(actor 优先,回退到直接字段) + var portrait = line.ResolvedPortrait; + if (portrait != null) + { + var img = new Image { image = portrait.texture }; + img.style.width = 18; + img.style.height = 18; + img.style.marginRight = 4; + img.style.borderTopLeftRadius = 2; + img.style.borderTopRightRadius = 2; + img.style.borderBottomLeftRadius = 2; + img.style.borderBottomRightRadius = 2; + row.Add(img); + } + + // 语音图标 + if (line.voiceClip != null) + { + var ico = EditorGUIUtility.IconContent("d_AudioClip Icon"); + if (ico?.image != null) + { + var img = new Image { image = ico.image }; + img.style.width = 14; + img.style.height = 14; + img.style.marginRight = 4; + row.Add(img); + } + } + + // 说话人(actor 优先,回退到直接字段) + string speakerKey = line.ResolvedNameKey; + if (!string.IsNullOrEmpty(speakerKey)) + { + var spk = new Label(speakerKey + ":"); + spk.style.fontSize = 11; + spk.style.opacity = 0.55f; + spk.style.unityFontStyleAndWeight = FontStyle.Bold; + spk.style.marginRight = 4; + spk.style.flexShrink = 0; + row.Add(spk); + } + + // 文本 key(尝试显示本地化实际内容,回退到 key 本身) + string rawText = string.IsNullOrEmpty(line.textKey) ? "(空)" : line.textKey; + string preview = string.IsNullOrEmpty(line.textKey) + ? "(空)" + : (BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue") ?? rawText); + if (preview.Length > 48) preview = preview[..48] + "…"; + var lbl = new Label(preview); + lbl.style.fontSize = 11; + lbl.style.overflow = Overflow.Hidden; + row.Add(lbl); + + section.Add(row); + } + + if (s.lines.Length > preview) + { + var more = new Label($"… 还有 {s.lines.Length - preview} 行"); + more.style.opacity = 0.4f; + more.style.fontSize = 10; + section.Add(more); + } + + return section; + } + + private static VisualElement BuildVariantsCard(DialogueSequenceSO s) + { + var card = SkillModule.MakeCard(); + card.style.flexDirection = FlexDirection.Column; + + var title = new Label("条件变体"); + title.style.fontSize = 11; + title.style.opacity = 0.55f; + title.style.marginBottom = 4; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + card.Add(title); + + foreach (var v in s.variants) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.marginBottom = 2; + + string flags = v.requiredFlags != null && v.requiredFlags.Length > 0 + ? string.Join(", ", v.requiredFlags) + : "(无条件)"; + SkillModule.AddChip(row, "条件", flags); + SkillModule.AddChip(row, "替换序列", v.sequence != null ? v.sequence.name : "(未设置)"); + card.Add(row); + } + return card; + } + + private VisualElement BuildActionBar(DialogueSequenceSO s) + { + return SkillModule.BuildStandardActionBar( + s, Folder, Prefix, + onCreated: c => _listPane.Refresh(c), + onCloned: c => _listPane.Refresh(c), + onDeleted: () => _listPane.Refresh(null)); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs new file mode 100644 index 0000000..b7625a0 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Quest; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub 任务模块 —— 管理 QuestSO 资产。 + /// + public class QuestModule : IDataModule + { + private const string Folder = "Assets/_Game/Data/Quest"; + private const string Prefix = "Quest_"; + + public string ModuleId => "quest"; + public string DisplayName => "任务"; + public string IconName => "d_UnityEditor.InspectorWindow"; + + private SoListPane _listPane; + private DetailHeader _header; + private QuestSO _selected; + + public void Initialize() + { + _listPane = new SoListPane( + Folder, Prefix, + s => + { + bool hasPre = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0; + return hasPre ? "有前置" : null; + }); + } + + public void BuildListPane(VisualElement container, Action onSelected) + { + _listPane.SelectionChanged = sel => + { + _selected = sel; + onSelected?.Invoke(sel); + }; + container.Add(_listPane); + _listPane.Refresh(); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _selected = selected as QuestSO; + + _header = new DetailHeader(); + _header.SetAsset(_selected); + _header.RenameRequested += OnRenameRequested; + container.Add(_header); + + if (_selected == null) return; + + container.Add(BuildInfoCard(_selected)); + container.Add(BuildObjectivesList(_selected)); + if (_selected.branches != null && _selected.branches.Length > 0) + container.Add(BuildBranchesCard(_selected)); + container.Add(BuildActionBar(_selected)); + container.Add(SkillModule.MakeDivider()); + container.Add(new InspectorElement(_selected)); + } + + public void OnActivated() => _listPane?.Refresh(); + + // ── 内部 ───────────────────────────────────────────────────────────── + + private void OnRenameRequested(string newName) + { + if (_selected == null) return; + var (ok, err) = AssetOperations.Rename(_selected, newName); + if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定"); + else { _header.SetAsset(_selected); _listPane.Invalidate(); } + } + + private static VisualElement BuildInfoCard(QuestSO s) + { + var card = SkillModule.MakeCard(); + int objCount = s.objectives != null ? s.objectives.Length : 0; + + SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.questId) ? "(未设置)" : s.questId); + SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(s.displayNameKey) ? "(未设置)" : s.displayNameKey); + if (!string.IsNullOrEmpty(s.descriptionKey)) + SkillModule.AddChip(card, "描述 Key", s.descriptionKey); + SkillModule.AddChip(card, "目标数", objCount.ToString()); + + if (s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0) + { + // 显示每个前置任务的 questId,方便策划一眼看清依赖链 + var preIds = new System.Text.StringBuilder(); + foreach (var pre in s.prerequisiteQuests) + { + if (pre == null) continue; + if (preIds.Length > 0) preIds.Append(", "); + preIds.Append(string.IsNullOrEmpty(pre.questId) ? pre.name : pre.questId); + } + if (preIds.Length > 0) + SkillModule.AddChip(card, "前置任务", preIds.ToString()); + } + + if (s.minAffinityToAccept > 0) + SkillModule.AddChip(card, "好感门槛", s.minAffinityToAccept.ToString()); + if (s.canFail) + SkillModule.AddChip(card, "可失败", "✓"); + if (s.reward != null) + SkillModule.AddChip(card, "奖励", s.reward.name); + return card; + } + + private static VisualElement BuildObjectivesList(QuestSO s) + { + var section = new VisualElement(); + section.style.paddingLeft = 12; + section.style.paddingRight = 12; + section.style.paddingTop = 6; + section.style.paddingBottom = 6; + + var title = new Label("目标列表"); + title.style.fontSize = 11; + title.style.opacity = 0.55f; + title.style.marginBottom = 4; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + section.Add(title); + + if (s.objectives == null || s.objectives.Length == 0) + { + var empty = new Label("(无目标)"); + empty.style.opacity = 0.4f; + empty.style.fontSize = 11; + section.Add(empty); + return section; + } + + for (int i = 0; i < s.objectives.Length; i++) + { + var obj = s.objectives[i]; + if (obj == null) continue; + + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.marginBottom = 3; + + // 序号 + var idx = new Label($"{i + 1}."); + idx.style.fontSize = 11; + idx.style.opacity = 0.5f; + idx.style.marginRight = 4; + idx.style.width = 16; + idx.style.flexShrink = 0; + row.Add(idx); + + // 类型徽章 + string badge = obj.BadgeLabel; + var badgeLbl = new Label(badge); + badgeLbl.style.fontSize = 10; + badgeLbl.style.opacity = 0.7f; + badgeLbl.style.marginRight = 6; + badgeLbl.style.flexShrink = 0; + badgeLbl.style.unityFontStyleAndWeight = FontStyle.Bold; + row.Add(badgeLbl); + + // ID + string id = string.IsNullOrEmpty(obj.objectiveId) ? obj.name : obj.objectiveId; + var idLbl = new Label(id); + idLbl.style.fontSize = 11; + idLbl.style.flexGrow = 1; + row.Add(idLbl); + + // 可选标记 + if (obj.IsOptional) + { + var opt = new Label("[可选]"); + opt.style.fontSize = 10; + opt.style.opacity = 0.5f; + opt.style.marginLeft = 4; + row.Add(opt); + } + + section.Add(row); + } + + return section; + } + + private static VisualElement BuildBranchesCard(QuestSO s) + { + var card = SkillModule.MakeCard(); + card.style.flexDirection = FlexDirection.Column; + + var title = new Label("完成后分支"); + title.style.fontSize = 11; + title.style.opacity = 0.55f; + title.style.marginBottom = 4; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + card.Add(title); + + foreach (var branch in s.branches) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.marginBottom = 2; + + string condition = branch.conditionQuest != null ? branch.conditionQuest.questId : "(默认)"; + string next = branch.nextQuest != null ? branch.nextQuest.name : "(无)"; + SkillModule.AddChip(row, "条件", condition); + SkillModule.AddChip(row, "后续任务", next); + + // 优先显示新 SO 引用;回退到旧字段(Obsolete) + string seqName = branch.npcDialogueSequence != null + ? branch.npcDialogueSequence.name +#pragma warning disable CS0618 + : branch.npcDialogueKey; +#pragma warning restore CS0618 + if (!string.IsNullOrEmpty(seqName)) + SkillModule.AddChip(row, "对话序列", seqName); + card.Add(row); + } + return card; + } + + private VisualElement BuildActionBar(QuestSO s) + { + var bar = SkillModule.BuildStandardActionBar( + s, Folder, Prefix, + onCreated: c => _listPane.Refresh(c), + onCloned: c => _listPane.Refresh(c), + onDeleted: () => _listPane.Refresh(null)); + + // 任务模块额外:代码常量生成 + new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar); + + return bar; + } + + // ── QuestKeys.cs 常量生成器 ────────────────────────────────────────── + + private const string GeneratedFolder = "Assets/_Game/Scripts/Generated"; + private const string QuestKeysPath = GeneratedFolder + "/QuestKeys.cs"; + + private static void GenerateQuestKeys() + { + // 收集所有 questId + var questIds = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var q in AssetOperations.FindAll()) + if (!string.IsNullOrWhiteSpace(q.questId)) + questIds.Add(q.questId.Trim()); + + // 收集所有 targetNpcId(来自 TalkToNPC 目标 SO) + var npcIds = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var obj in AssetOperations.FindAll()) + if (!string.IsNullOrWhiteSpace(obj.targetNpcId)) + npcIds.Add(obj.targetNpcId.Trim()); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// 由 QuestModule 工具自动生成,请勿手动编辑。"); + sb.AppendLine("// 重新生成:DataHub → 任务 → 任意任务 → 生成常量"); + sb.AppendLine("// "); + sb.AppendLine("namespace BaseGames.Quest"); + sb.AppendLine("{"); + sb.AppendLine(" /// 任务 ID 常量(从 QuestSO 自动生成)。"); + sb.AppendLine(" public static class QuestKeys"); + sb.AppendLine(" {"); + + sb.AppendLine(" /// 任务唯一 ID 常量。"); + sb.AppendLine(" public static class Quest"); + sb.AppendLine(" {"); + if (questIds.Count == 0) + sb.AppendLine(" // (未发现任何 QuestSO)"); + foreach (var id in questIds) + sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";"); + sb.AppendLine(" }"); + + sb.AppendLine(); + sb.AppendLine(" /// TalkToNPC 目标中使用的 NPC ID 常量。"); + sb.AppendLine(" public static class NpcId"); + sb.AppendLine(" {"); + if (npcIds.Count == 0) + sb.AppendLine(" // (未发现任何 TalkToNPCObjective)"); + foreach (var id in npcIds) + sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";"); + sb.AppendLine(" }"); + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + if (!Directory.Exists(GeneratedFolder)) + Directory.CreateDirectory(GeneratedFolder); + + File.WriteAllText(QuestKeysPath, sb.ToString(), Encoding.UTF8); + AssetDatabase.Refresh(); + Debug.Log($"[QuestModule] QuestKeys.cs 已生成:{questIds.Count} 个任务 ID,{npcIds.Count} 个 NPC ID。"); + EditorUtility.DisplayDialog("生成成功", + $"QuestKeys.cs 已写入 {QuestKeysPath}\n任务 ID: {questIds.Count} NPC ID: {npcIds.Count}", + "确定"); + } + + /// 将任意字符串转换为合法的 C# 标识符(PascalCase)。C# 保留关键字加 @ 前缀。 + private static string ToIdentifier(string raw) + { + if (string.IsNullOrEmpty(raw)) return "_Empty"; + var parts = raw.Split('_', '-', ' ', '.', '/'); + var sb = new StringBuilder(); + foreach (var part in parts) + { + if (part.Length == 0) continue; + sb.Append(char.ToUpperInvariant(part[0])); + if (part.Length > 1) sb.Append(part.Substring(1)); + } + string result = sb.ToString(); + if (result.Length > 0 && char.IsDigit(result[0])) + result = "_" + result; + if (string.IsNullOrEmpty(result)) return "_Empty"; + // C# 保留关键字加 @ 前缀,避免生成无法编译的代码 + if (s_CSharpKeywords.Contains(result)) + result = "@" + result; + return result; + } + + private static readonly HashSet s_CSharpKeywords = new HashSet( + System.StringComparer.Ordinal) + { + "abstract","as","base","bool","break","byte","case","catch","char","checked", + "class","const","continue","decimal","default","delegate","do","double","else", + "enum","event","explicit","extern","false","finally","fixed","float","for", + "foreach","goto","if","implicit","in","int","interface","internal","is","lock", + "long","namespace","new","null","object","operator","out","override","params", + "private","protected","public","readonly","ref","return","sbyte","sealed", + "short","sizeof","stackalloc","static","string","struct","switch","this", + "throw","true","try","typeof","uint","ulong","unchecked","unsafe","ushort", + "using","virtual","void","volatile","while" + }; + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs b/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs index 8a9a1fe..592db2a 100644 --- a/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs @@ -99,15 +99,7 @@ namespace BaseGames.Editor.Modules { text = "克隆..." }.AlsoAddTo(bar); var del = new Button(() => { if (AssetOperations.Delete(s)) _listPane.Refresh(null); }) { text = "删除" }; - del.style.borderLeftColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - del.style.borderRightColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - del.style.borderTopColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - del.style.borderBottomColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); - del.style.borderLeftWidth = 1; - del.style.borderRightWidth = 1; - del.style.borderTopWidth = 1; - del.style.borderBottomWidth = 1; - del.style.marginLeft = 8; + ApplyDeleteStyle(del); del.AlsoAddTo(bar); return bar; @@ -171,6 +163,69 @@ namespace BaseGames.Editor.Modules d.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.2f)); return d; } + + /// 将删除按钮染成红色边框,统一各模块样式。 + internal static void ApplyDeleteStyle(Button btn) + { + var red = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f)); + btn.style.borderLeftColor = red; + btn.style.borderRightColor = red; + btn.style.borderTopColor = red; + btn.style.borderBottomColor = red; + btn.style.borderLeftWidth = 1; + btn.style.borderRightWidth = 1; + btn.style.borderTopWidth = 1; + btn.style.borderBottomWidth = 1; + btn.style.marginLeft = 8; + } + + /// + /// 为任意 ScriptableObject 模块生成标准 ActionBar(新建 / 定位 / 克隆 / 删除)。 + /// 各模块可在返回后向 bar 追加额外按钮。 + /// + /// 当前选中资产。 + /// 资产所在文件夹(用于新建 / 克隆)。 + /// 新建资产的文件名前缀。 + /// 新建完成回调(传入新资产)。 + /// 克隆完成回调(传入克隆资产)。 + /// 删除完成回调。 + internal static VisualElement BuildStandardActionBar( + T asset, + string folder, + string prefix, + Action onCreated, + Action onCloned, + Action onDeleted) where T : UnityEngine.ScriptableObject + { + var bar = MakeActionBar(); + + new Button(() => + { + var c = AssetOperations.Create(folder, prefix + "New"); + if (c != null) onCreated?.Invoke(c); + }) { text = "新建" }.AlsoAddTo(bar); + + new Button(() => + { + EditorGUIUtility.PingObject(asset); + Selection.activeObject = asset; + }) { text = "定位" }.AlsoAddTo(bar); + + new Button(() => + { + var c = AssetOperations.Clone(asset, folder); + if (c != null) onCloned?.Invoke(c); + }) { text = "克隆..." }.AlsoAddTo(bar); + + var del = new Button(() => + { + if (AssetOperations.Delete(asset)) onDeleted?.Invoke(); + }) { text = "删除" }; + ApplyDeleteStyle(del); + del.AlsoAddTo(bar); + + return bar; + } } // ── Button 扩展(模块内共用)───────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Editor/Quest/QuestManagerPostprocessor.cs b/Assets/_Game/Scripts/Editor/Quest/QuestManagerPostprocessor.cs new file mode 100644 index 0000000..8266961 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Quest/QuestManagerPostprocessor.cs @@ -0,0 +1,64 @@ +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace BaseGames.Quest.Editor +{ + /// + /// 监听 QuestSO 资产的增删/移动事件,自动刷新场景中 QuestManager 的 _allQuests 列表。 + /// 保证策划通过 DataHub 创建新任务后无需手动触发 OnValidate。 + /// + internal sealed class QuestManagerPostprocessor : AssetPostprocessor + { + private static void OnPostprocessAllAssets( + string[] imported, string[] deleted, string[] moved, string[] movedFrom) + { + if (!NeedsRefresh(imported) && !NeedsRefresh(deleted) && !NeedsRefresh(moved)) + return; + + RefreshAllQuestManagers(); + } + + private static bool NeedsRefresh(string[] paths) + { + if (paths == null) return false; + foreach (var p in paths) + { + if (p.EndsWith(".asset", System.StringComparison.OrdinalIgnoreCase) + && AssetDatabase.GetMainAssetTypeAtPath(p) == typeof(QuestSO)) + return true; + } + return false; + } + + /// + /// 在所有已加载场景中查找 QuestManager,通过反射调用其 + /// EditorRefreshQuestList 方法(#if UNITY_EDITOR 区块内定义)。 + /// + private static void RefreshAllQuestManagers() + { + var managers = Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None); + + if (managers == null || managers.Length == 0) return; + + var method = typeof(QuestManager).GetMethod( + "EditorRefreshQuestList", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (method == null) + { + Debug.LogWarning("[QuestManagerPostprocessor] 未找到 EditorRefreshQuestList 方法,请确认 QuestManager 包含该方法。"); + return; + } + + foreach (var mgr in managers) + { + method.Invoke(mgr, null); + EditorUtility.SetDirty(mgr); + } + + Debug.Log($"[QuestManagerPostprocessor] 已刷新 {managers.Length} 个 QuestManager 的任务列表。"); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs b/Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs index 590291c..8b1769a 100644 --- a/Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs +++ b/Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs @@ -55,20 +55,14 @@ namespace BaseGames.Editor string path = AssetDatabase.GetAssetPath(asset); if (string.IsNullOrEmpty(path)) return (false, "资产不在 AssetDatabase 中"); - // 先更新序列化内部名称 - string oldName = asset.name; - Undo.RecordObject(asset, "Rename " + oldName); - asset.name = newName; - EditorUtility.SetDirty(asset); - - // 再重命名磁盘文件 + // 先重命名磁盘文件,成功后再修改内部名称,避免 Undo 状态被失败操作污染 string err = AssetDatabase.RenameAsset(path, newName); if (!string.IsNullOrEmpty(err)) - { - asset.name = oldName; - EditorUtility.SetDirty(asset); return (false, err); - } + + Undo.RecordObject(asset, "Rename " + asset.name); + asset.name = newName; + EditorUtility.SetDirty(asset); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); diff --git a/Assets/_Game/Scripts/EventChain/EventChainSO.cs b/Assets/_Game/Scripts/EventChain/EventChainSO.cs index 6ec9988..9233053 100644 --- a/Assets/_Game/Scripts/EventChain/EventChainSO.cs +++ b/Assets/_Game/Scripts/EventChain/EventChainSO.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using BaseGames.Core; using BaseGames.Core.Events; +using BaseGames.Dialogue; using UnityEngine; namespace BaseGames.EventChain @@ -59,7 +60,7 @@ namespace BaseGames.EventChain public class BossDefeatedCondition : ChainCondition { public string bossId; - private bool _met; + [System.NonSerialized] private bool _met; public override void Register(EventChainManager m) => m.OnBossDefeated += Check; public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check; public override bool IsMet() => _met; @@ -80,7 +81,7 @@ namespace BaseGames.EventChain public class AbilityUnlockedCondition : ChainCondition { public string abilityId; - private bool _met; + [System.NonSerialized] private bool _met; public override void Register(EventChainManager m) => m.OnAbilityUnlocked += Check; public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked -= Check; public override bool IsMet() => _met; @@ -92,7 +93,7 @@ namespace BaseGames.EventChain public class CollectibleCollectedCondition : ChainCondition { public string itemId; - private bool _met; + [System.NonSerialized] private bool _met; public override void Register(EventChainManager m) => m.OnCollectiblePickedUp += Check; public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check; public override bool IsMet() => _met; @@ -104,7 +105,7 @@ namespace BaseGames.EventChain public class RoomEnteredCondition : ChainCondition { public string sceneName; - private bool _met; + [System.NonSerialized] private bool _met; public override void Register(EventChainManager m) => m.OnRoomEntered += Check; public override void Unregister(EventChainManager m) => m.OnRoomEntered -= Check; public override bool IsMet() => _met; @@ -116,7 +117,7 @@ namespace BaseGames.EventChain public class DialogueCompletedCondition : ChainCondition { public string npcId; - private bool _met; + [System.NonSerialized] private bool _met; public override void Register(EventChainManager m) => m.OnDialogueCompleted += Check; public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check; public override bool IsMet() => _met; @@ -128,7 +129,7 @@ namespace BaseGames.EventChain public class ChainCompletedCondition : ChainCondition { public string chainId; - private bool _met; + [System.NonSerialized] private bool _met; public override void Register(EventChainManager m) => m.OnChainCompleted += Check; public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check; public override bool IsMet() => _met; @@ -167,11 +168,18 @@ namespace BaseGames.EventChain [CreateAssetMenu(menuName = "BaseGames/EventChain/Action/SetFlag")] public class SetFlagAction : ChainAction { + [WorldStateFlag] public string flagId; public bool value = true; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { - ServiceLocator.GetOrDefault()?.SetFlag(flagId, value); + var saveService = ServiceLocator.GetOrDefault(); + if (saveService == null) + { + Debug.LogError($"[SetFlagAction] ISaveService 未注册,标志 '{flagId}' 无法持久化。", this); + yield break; + } + saveService.SetFlag(flagId, value); yield break; } } diff --git a/Assets/_Game/Scripts/Localization/LocalizationManager.cs b/Assets/_Game/Scripts/Localization/LocalizationManager.cs index fabc704..e805698 100644 --- a/Assets/_Game/Scripts/Localization/LocalizationManager.cs +++ b/Assets/_Game/Scripts/Localization/LocalizationManager.cs @@ -122,7 +122,64 @@ namespace BaseGames.Localization public static string Get(string key, string table = "UI") => ServiceLocator.GetOrDefault()?.Get(key, table) ?? key; - // ── 内部 ───────────────────────────────────────────────────────────── + // ── 编辑器预览(不依赖 ServiceLocator 实例)──────────────────────────── +#if UNITY_EDITOR + // 编辑器预览缓存:"{language}/{table}" → (key → value) + // 生命周期与编辑器进程相同;域重载时自动清空(static 字段随域重载重置)。 + private static readonly System.Collections.Generic.Dictionary< + string, + System.Collections.Generic.Dictionary> s_editorPreviewCache = new(); + + /// + /// 编辑器工具专用:不依赖运行时服务实例,直接从 Resources 读取本地化文本。 + /// 结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。 + /// 找不到时返回 null(区别于运行时的 key 回退,便于调用方判断是否显示 key)。 + /// + public static string GetEditorPreview(string key, string table = "UI", + Language language = Language.ChineseSimplified) + { + if (string.IsNullOrEmpty(key)) return null; + + var dict = GetEditorTable(language, table) + ?? GetEditorTable(Language.English, table); // 中文缺失时英文回退 + if (dict == null) return null; + + dict.TryGetValue(key, out var value); + return value; // 找不到 key 时返回 null + } + + private static System.Collections.Generic.Dictionary GetEditorTable( + Language language, string table) + { + string cacheKey = $"{language}/{table}"; + if (s_editorPreviewCache.TryGetValue(cacheKey, out var cached)) + return cached; // 已缓存(可能是 null 占位,表示文件不存在) + + string path = $"Localization/{language}/{table}"; + var asset = Resources.Load(path); + if (asset == null) + { + s_editorPreviewCache[cacheKey] = null; // 记录"不存在",避免重复尝试 + return null; + } + + var parsed = JsonUtility.FromJson(asset.text); + if (parsed?.entries == null) + { + s_editorPreviewCache[cacheKey] = null; + return null; + } + + var dict = new System.Collections.Generic.Dictionary( + parsed.entries.Count, System.StringComparer.Ordinal); + foreach (var entry in parsed.entries) + if (!string.IsNullOrEmpty(entry.key)) + dict[entry.key] = entry.value ?? string.Empty; + + s_editorPreviewCache[cacheKey] = dict; + return dict; + } +#endif private bool TryGetFromTable(Language language, string table, string key, out string value) { var cacheKey = $"{language}/{table}"; diff --git a/Assets/_Game/Scripts/Quest/QuestGiver.cs b/Assets/_Game/Scripts/Quest/QuestGiver.cs index 3fdcddd..7c0b1c8 100644 --- a/Assets/_Game/Scripts/Quest/QuestGiver.cs +++ b/Assets/_Game/Scripts/Quest/QuestGiver.cs @@ -27,7 +27,7 @@ namespace BaseGames.Quest protected override void Interact_Internal(Transform player) { var qm = SL.GetOrDefault(); - var quest = GetCurrentQuest(qm); + var quest = GetCurrentOrCompletedQuest(qm); if (quest == null || qm == null) return; var state = qm.GetState(quest.questId); @@ -36,7 +36,7 @@ namespace BaseGames.Quest { qm.AcceptQuest(quest.questId); } - else if (qm.IsReadyToComplete(quest.questId)) + else if (state == QuestStateEnum.Active && qm.IsReadyToComplete(quest.questId)) { // 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖 var stats = player.GetComponentInParent(); @@ -47,7 +47,7 @@ namespace BaseGames.Quest protected override DialogueSequenceSO GetCurrentDialogue() { var qm = SL.GetOrDefault(); - var quest = GetCurrentQuest(qm); + var quest = GetCurrentOrCompletedQuest(qm); if (quest == null || qm == null) return base.GetCurrentDialogue(); var state = qm.GetState(quest.questId); @@ -64,19 +64,25 @@ namespace BaseGames.Quest // ── 私有辅助 ───────────────────────────────────────────────────────── - /// 返回当前处于 Available 或 Active 状态的第一个任务。 - private QuestSO GetCurrentQuest(IQuestManager qm = null) + /// + /// 返回当前处于 Available 或 Active 状态的第一个任务; + /// 若全部已完成,返回最后一个已完成任务(用于显示 completedDialogue)。 + /// + private QuestSO GetCurrentOrCompletedQuest(IQuestManager qm = null) { if (_offeredQuests == null) return null; qm ??= SL.GetOrDefault(); if (qm == null) return null; + + QuestSO lastCompleted = null; foreach (var q in _offeredQuests) { if (q == null) continue; var s = qm.GetState(q.questId); if (s == QuestStateEnum.Available || s == QuestStateEnum.Active) return q; + if (s == QuestStateEnum.Completed) lastCompleted = q; } - return null; + return lastCompleted; } } } diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index c465e3d..c4e3050 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -11,17 +11,23 @@ namespace BaseGames.Quest /// 挂在 Persistent 场景 [GameManagers] 下。 /// 事件驱动追踪目标进度,不主动轮询。 /// 实现 ISaveable,通过 SaveManager 持久化任务状态。 + /// + /// _allQuests 由编辑器 OnValidate / "刷新任务列表" 右键菜单自动填充, + /// 无需策划人员手动拖入 ScriptableObject。 /// public class QuestManager : MonoBehaviour, ISaveable, IQuestManager { // ── Inspector ──────────────────────────────────────────────────────── - [SerializeField] private QuestSO[] _allQuests; + + [Tooltip("所有 QuestSO 资产。编辑器会自动同步,无需手动维护。")] + [SerializeField] private QuestSO[] _allQuests; [Header("Event Channels(监听)")] [SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied(enemyId) - [SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId) - [SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName) - [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId) + [SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId) + [SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName) + [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId) + [SerializeField] private StringEventChannelSO _onSkillUsed; // EVT_SkillUsed(abilityType.ToString()) [Header("Event Channels(广播)")] [SerializeField] private StringEventChannelSO _onQuestStarted; // questId @@ -52,14 +58,35 @@ namespace BaseGames.Quest foreach (var q in _allQuests) if (q != null && !string.IsNullOrEmpty(q.questId)) _questIndex[q.questId] = q; + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + ValidateQuestIds(); +#endif } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + private void ValidateQuestIds() + { + if (_allQuests == null) return; + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var q in _allQuests) + { + if (q == null) continue; + if (string.IsNullOrEmpty(q.questId)) + Debug.LogError($"[QuestManager] QuestSO '{q.name}' 缺少 questId,此任务将无法被引用。", q); + else if (!seen.Add(q.questId)) + Debug.LogError($"[QuestManager] 重复的 questId '{q.questId}'(资产:{q.name}),将导致任务系统异常。", q); + } + } +#endif + private void OnEnable() { _onEnemyDied?.Subscribe(HandleEnemyDefeated).AddTo(_subs); _onCollectiblePickup?.Subscribe(HandleItemCollected).AddTo(_subs); _onSceneLoaded?.Subscribe(HandleSceneLoaded).AddTo(_subs); _onNpcDialogueCompleted?.Subscribe(HandleNpcDialogue).AddTo(_subs); + _onSkillUsed?.Subscribe(HandleSkillUsed).AddTo(_subs); BaseGames.Core.ServiceLocator.GetOrDefault()?.Register(this); } @@ -94,16 +121,17 @@ namespace BaseGames.Quest _onQuestCompleted?.Raise(questId); // 解锁后续任务(分支) + // conditionQuest == null 表示默认分支,conditionQuest != null 则要求该任务已完成。 + // 不 break —— 允许同时解锁多个后续任务(如完成任务后同时开放多条支线)。 if (quest.branches != null) { foreach (var branch in quest.branches) { - if (string.IsNullOrEmpty(branch.conditionQuestId) || - GetState(branch.conditionQuestId) == QuestStateEnum.Completed) + if (branch.conditionQuest == null || + GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed) { if (branch.nextQuest != null) _questStates[branch.nextQuest.questId] = QuestStateEnum.Available; - break; } } } @@ -133,9 +161,8 @@ namespace BaseGames.Quest { data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState { - Status = state.ToString(), - ObjectiveIndex = 0, - ProgressCounts = BuildProgressList(id), + Status = state.ToString(), + ProgressCounts = BuildProgressList(id), }; } } @@ -171,24 +198,36 @@ namespace BaseGames.Quest { if (GetState(questId) != QuestStateEnum.Available) return false; var quest = GetQuestSO(questId); - if (quest?.prerequisiteQuestIds == null) return true; - foreach (var pre in quest.prerequisiteQuestIds) - if (GetState(pre) != QuestStateEnum.Completed) return false; + if (quest?.prerequisiteQuests == null) return true; + foreach (var pre in quest.prerequisiteQuests) + { + if (pre == null) continue; + if (GetState(pre.questId) != QuestStateEnum.Completed) return false; + } return true; } private bool IsObjectiveComplete(QuestObjectiveSO obj) { - _objectiveStates.TryGetValue(obj.objectiveId, out var s); - s ??= new QuestObjectiveState(); - return obj.EvaluateCompletion(s); + if (!_objectiveStates.TryGetValue(obj.objectiveId, out var s)) + s = new QuestObjectiveState(); + + bool result = obj.EvaluateCompletion(s); + + // 首次达成时写回 completed 标志,避免 s 是本地临时对象时标志丢失 + if (result && !s.completed) + { + s.completed = true; + _objectiveStates[obj.objectiveId] = s; + } + return result; } private List BuildProgressList(string questId) { - var list = new List(); var quest = GetQuestSO(questId); - if (quest?.objectives == null) return list; + if (quest?.objectives == null) return new List(0); + var list = new List(quest.objectives.Length); foreach (var obj in quest.objectives) { _objectiveStates.TryGetValue(obj?.objectiveId ?? string.Empty, out var os); @@ -235,6 +274,17 @@ namespace BaseGames.Quest }); } + private void HandleSkillUsed(string abilityTypeName) + { + // 用 Enum.TryParse 避免大小写/ToString 格式差异导致静默失败 + if (!System.Enum.TryParse(abilityTypeName, ignoreCase: true, out var parsed)) return; + ForEachActiveObjective(obj => + { + if (obj.requiredAbility == parsed) + IncrementProgress(obj.objectiveId); + }); + } + private void ForEachActiveObjective(System.Action action) where T : QuestObjectiveSO { foreach (var (qid, state) in _questStates) @@ -263,5 +313,94 @@ namespace BaseGames.Quest private QuestSO GetQuestSO(string id) => _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null; + + // ── 编辑器自动维护 ──────────────────────────────────────────────────── + +#if UNITY_EDITOR + /// + /// 编辑器中每次属性变更时自动同步项目内所有 QuestSO,并校验前置任务是否存在循环引用。 + /// + private void OnValidate() + { + EditorRefreshQuestList(); + ValidatePrerequisites(); + } + + [ContextMenu("刷新任务列表")] + private void EditorRefreshQuestList() + { + string[] guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO"); + var list = new List(guids.Length); + foreach (var guid in guids) + { + string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var q = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (q != null) list.Add(q); + } + _allQuests = list.ToArray(); + UnityEditor.EditorUtility.SetDirty(this); + } +#endif + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + /// + /// 通过 DFS 后序遍历检测 prerequisiteQuests 中是否存在循环引用。 + /// 在编辑器 OnValidate 及开发构建 Awake 时调用,发现问题立即打 LogError。 + /// + [System.Diagnostics.Conditional("UNITY_EDITOR")] // ContextMenu 只在编辑器生效 + [UnityEngine.ContextMenu("校验前置任务循环引用")] + private void ValidatePrerequisites() + { + if (_allQuests == null) return; + + // 先建立 id → SO 快速查找表 + var index = new Dictionary(_allQuests.Length); + foreach (var q in _allQuests) + { + if (q == null || string.IsNullOrEmpty(q.questId)) continue; + index[q.questId] = q; + } + + // 三色 DFS:0=未访问 1=灰(栈中)2=黑(已完成) + var color = new Dictionary(index.Count); + + bool HasCycle(string startId, List path) + { + if (!index.ContainsKey(startId)) return false; + if (color.TryGetValue(startId, out int c)) + { + if (c == 1) + { + path.Add(startId); + Debug.LogError( + $"[QuestManager] 前置任务循环引用: {string.Join(" → ", path)}", + this); + return true; + } + return false; // 已完成 + } + + color[startId] = 1; + path.Add(startId); + + var prereqs = index[startId].prerequisiteQuests; + if (prereqs != null) + { + foreach (var pre in prereqs) + { + if (pre == null || string.IsNullOrEmpty(pre.questId)) continue; + if (HasCycle(pre.questId, path)) return true; + } + } + + path.RemoveAt(path.Count - 1); + color[startId] = 2; + return false; + } + + foreach (var questId in index.Keys) + HasCycle(questId, new List()); + } +#endif } } diff --git a/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs b/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs index 1609c23..b3905d0 100644 --- a/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs @@ -11,13 +11,32 @@ namespace BaseGames.Quest public abstract class QuestObjectiveSO : ScriptableObject { [Header("标识")] + [Tooltip("目标唯一 ID,如 \"OBJ_TalkElder\"。空时由 OnValidate 自动以资产名填充。")] public string objectiveId; + [Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Obj1\"。通过 LocalizationManager.Get(displayTextKey, \"Quest\") 显示给玩家。")] [TextArea(1, 4)] - public string displayTextKey; // 本地化 key(通过 LocalizationManager.Get(displayTextKey, "Quest") 显示) - public bool IsOptional; // 可选目标(完成加奖励但不阻塞任务) + public string displayTextKey; + [Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")] + public bool IsOptional; /// 根据当前进度判断目标是否完成。 public abstract bool EvaluateCompletion(QuestObjectiveState state); + + /// + /// 在 DataHub / 编辑器工具中显示的类型徽章文字。 + /// 子类应 override 返回简洁中文标签(如 "[对话]")。 + /// 避免工具代码中使用 type-switch 维护列表。 + /// + public virtual string BadgeLabel => "[目标]"; + +#if UNITY_EDITOR + private void OnValidate() + { + if (!string.IsNullOrEmpty(objectiveId)) return; + objectiveId = name; + UnityEditor.EditorUtility.SetDirty(this); + } +#endif } // ── 运行时目标进度状态(由 QuestManager 管理,不继承 SO)──────────────── @@ -34,8 +53,9 @@ namespace BaseGames.Quest [CreateAssetMenu(menuName = "BaseGames/Quest/Objective/TalkToNPC")] public class TalkToNPCObjective : QuestObjectiveSO { + [Tooltip("目标 NPC 的唯一 ID,需与 NPC 组件上的 npcId / InteractableNPC.npcId 保持一致。")] public string targetNpcId; - + public override string BadgeLabel => "[对话]"; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1; } @@ -43,9 +63,11 @@ namespace BaseGames.Quest [CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Defeat")] public class DefeatEnemyObjective : QuestObjectiveSO { + [Tooltip("目标敌人的唯一 ID,需与敌人 SO 或敌人组件上的 enemyId 保持一致。")] public string targetEnemyId; + [Tooltip("需击败的次数,默认 1。")] [Min(1)] public int defeatCount = 1; - + public override string BadgeLabel => "[击败]"; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount; } @@ -53,9 +75,11 @@ namespace BaseGames.Quest [CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Collect")] public class CollectItemObjective : QuestObjectiveSO { + [Tooltip("目标物品的唯一 ID,需与拾取事件广播的 itemId 保持一致。")] public string itemId; + [Tooltip("需收集的数量,默认 1。")] [Min(1)] public int collectCount = 1; - + public override string BadgeLabel => "[收集]"; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount; } @@ -63,9 +87,11 @@ namespace BaseGames.Quest [CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Reach")] public class ReachAreaObjective : QuestObjectiveSO { - public string sceneName; // 需到达的场景 - public string markerTag; // 场景内的目标标记 Tag(预留) - + [Tooltip("需到达的场景名(Unity Build Settings 中的场景名称)。")] + public string sceneName; + [Tooltip("场景内的目标标记 Tag(预留字段,当前未启用)。")] + public string markerTag; + public override string BadgeLabel => "[到达]"; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1; } @@ -73,9 +99,11 @@ namespace BaseGames.Quest [CreateAssetMenu(menuName = "BaseGames/Quest/Objective/UseSkill")] public class UseSkillObjective : QuestObjectiveSO { + [Tooltip("目标能力类型。事件频道 EVT_SkillUsed 广播 AbilityType.ToString(),与此值匹配时计数。")] public AbilityType requiredAbility; + [Tooltip("需使用的次数,默认 1。")] [Min(1)] public int useCount = 1; - + public override string BadgeLabel => "[使用]"; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount; } } diff --git a/Assets/_Game/Scripts/Quest/QuestSO.cs b/Assets/_Game/Scripts/Quest/QuestSO.cs index f5e0588..7bfc451 100644 --- a/Assets/_Game/Scripts/Quest/QuestSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestSO.cs @@ -13,17 +13,19 @@ namespace BaseGames.Quest { [Header("标识")] public string questId; // 唯一 ID,如 "Quest_FindMushroom" - public string displayName; - [TextArea(2, 6)] - public string description; + + [Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Name\"。通过 LocalizationManager.Get(displayNameKey, \"Quest\") 显示。")] + public string displayNameKey; + [Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Desc\"。通过 LocalizationManager.Get(descriptionKey, \"Quest\") 显示。")] + public string descriptionKey; public Sprite icon; [Header("目标链")] public QuestObjectiveSO[] objectives; // 按顺序完成,全部完成 = 可交完 [Header("前置条件")] - public string[] prerequisiteQuestIds; // 所有前置任务 Completed 后才可接 - public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制) + public QuestSO[] prerequisiteQuests; // 所有前置任务 Completed 后才可接 + public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制) [Header("奖励")] public RewardSO reward; @@ -34,13 +36,91 @@ namespace BaseGames.Quest [Header("完成后续任务(分支)")] public QuestBranch[] branches; + + // ── 编辑器校验 ──────────────────────────────────────────────────────── +#if UNITY_EDITOR + // questId → 资产路径,5 秒 TTL,跨所有 QuestSO.OnValidate 共用。 + // 重复检测时只需将缓存路径与自身路径比对(O(1)),无需全量扫描。 + private static System.Collections.Generic.Dictionary s_questIdToPath; + private static double s_questIdsCacheTime = -10.0; + + private static System.Collections.Generic.Dictionary GetQuestIdCache() + { + double now = UnityEditor.EditorApplication.timeSinceStartup; + if (s_questIdToPath != null && now - s_questIdsCacheTime < 5.0) + return s_questIdToPath; + + s_questIdToPath = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + string[] guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO"); + foreach (var guid in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var q = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (q != null && !string.IsNullOrEmpty(q.questId) && !s_questIdToPath.ContainsKey(q.questId)) + s_questIdToPath[q.questId] = path; + } + s_questIdsCacheTime = now; + return s_questIdToPath; + } + + private void OnValidate() + { + if (string.IsNullOrWhiteSpace(questId)) + { + Debug.LogWarning($"[QuestSO] '{name}' 缺少 questId,保存前请填写。", this); + return; + } + + // 检测重复 questId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。 + var cache = GetQuestIdCache(); + string myPath = UnityEditor.AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(myPath) && + cache.TryGetValue(questId, out var existingPath) && + existingPath != myPath) + { + Debug.LogError( + $"[QuestSO] questId '{questId}' 与 " + + $"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this); + s_questIdsCacheTime = -10.0; + } + + ValidateBranchDialogueKeys(); + } + + private void ValidateBranchDialogueKeys() + { + if (branches == null || branches.Length == 0) return; + + foreach (var branch in branches) + { + if (branch == null) continue; + + // npcDialogueSequence 是 SO 直接引用,无需字符串校验。 + // 旧字段 npcDialogueKey(Obsolete)有值时提示迁移。 +#pragma warning disable CS0618 + if (!string.IsNullOrEmpty(branch.npcDialogueKey) && branch.npcDialogueSequence == null) + { + Debug.LogWarning( + $"[QuestSO] '{name}' 分支仍使用旧字段 npcDialogueKey='{branch.npcDialogueKey}'," + + "请迁移至 npcDialogueSequence(直接拖入 DialogueSequenceSO)。", this); + } +#pragma warning restore CS0618 + } + } +#endif } [Serializable] public class QuestBranch { - public string conditionQuestId; // 若此任务已完成 → 走本分支(空 = 默认) - public QuestSO nextQuest; - public string npcDialogueKey; // 触发 NPC 对话 key + /// 若此前置任务已完成 → 走本分支(null = 默认分支)。 + public QuestSO conditionQuest; + public QuestSO nextQuest; + /// 完成后触发的 NPC 对话序列(直接引用,避免手写 sequenceId 字符串出错)。 + public DialogueSequenceSO npcDialogueSequence; + + [System.Obsolete("已废弃,请改用 npcDialogueSequence(直接 SO 引用)。保留字段以兼容现有资产序列化。")] + [HideInInspector] + public string npcDialogueKey; } } diff --git a/Assets/_Game/Scripts/World/WorldStateRegistry.cs b/Assets/_Game/Scripts/World/WorldStateRegistry.cs index ef64c1b..1892a53 100644 --- a/Assets/_Game/Scripts/World/WorldStateRegistry.cs +++ b/Assets/_Game/Scripts/World/WorldStateRegistry.cs @@ -98,13 +98,14 @@ namespace BaseGames.World } /// - /// 返回指定分类中所有已标记的 ID(快照副本)。 + /// 返回指定分类中所有已标记的 ID(只读视图,非副本)。 /// 由 WorldStateRegistrySaver.OnSave 调用,将运行时状态写入 SaveData。 + /// 注意:不保证跨帧稳定性,调用方若需持久快照请自行 ToArray()。 /// public IReadOnlyCollection GetAllIds(WorldObjectCategory category) { if (_states.TryGetValue(category, out var set)) - return new HashSet(set); + return set; return System.Array.Empty(); }