From 9c1e70fdeb38725f8a9657ddf3c0b750deed13e8 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 25 May 2026 00:24:20 +0800 Subject: [PATCH] feat: Round 50 narrative systems improvements IQuestManager+QuestManager: add FillQuestsInState/FillFilterQuests buffer overloads (no-alloc hot path); remove R49 duplicate implementations. QuestGiver: cache current quest result (_cachedQuest/_cachedState/_cacheDirty) to avoid per-frame foreach in InteractPrompt; invalidate on OnEnable and Interact_Internal state changes. IDialogueService+DialogueManager: add StartDialogue(..., Action onComplete) overload; callback fires once on ForceEnd (covers both normal end and interrupt); supports chained callbacks via += accumulation. DialogueVariantPreviewWindow: add 'Copy CSV' button in matrix section; exports all 2^N flag combinations with winner column; handles N>10 guard and CSV-safe escaping. WorldStateRegistry: add TryGetCategory(id, out category) reverse lookup for debug tools. NpcSOEditor: new CustomEditor for NpcSO showing live nameKey localization preview in Inspector (green label or warning box if Key not found). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_Game/Scripts/Dialogue/DialogueManager.cs | 20 +++++ .../Scripts/Dialogue/IDialogueService.cs | 8 ++ .../Dialogue/DialogueVariantPreviewWindow.cs | 77 +++++++++++++++++++ .../Scripts/Editor/Dialogue/NpcSOEditor.cs | 73 ++++++++++++++++++ Assets/_Game/Scripts/Quest/IQuestManager.cs | 12 +++ Assets/_Game/Scripts/Quest/QuestGiver.cs | 58 +++++++++----- Assets/_Game/Scripts/Quest/QuestManager.cs | 55 ++++++++----- .../_Game/Scripts/World/WorldStateRegistry.cs | 17 ++++ 8 files changed, 283 insertions(+), 37 deletions(-) create mode 100644 Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs diff --git a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs index 5c23ed1..af61db4 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs @@ -65,6 +65,10 @@ namespace BaseGames.Dialogue /// private int _playbackId; + // ── 一次性对话完成回调 ──────────────────────────────────────────────── + // 通过 StartDialogue(..., onComplete) 注册;OnDialogueEnded 触发后调用一次后清空。 + private System.Action _onCompleteCallback; + // ── 子协程通信字段(避免协程间 ref/out 参数)───────────────────────── /// HandleChoices 子协程写入结果:玩家选中选项后的后续序列(null = 无后续)。 private DialogueSequenceSO _choiceBranchResult; @@ -186,6 +190,17 @@ namespace BaseGames.Dialogue PlayImmediate(sequence, npcId, priority); } + /// + public void StartDialogue(DialogueSequenceSO sequence, string npcId, int priority, System.Action onComplete) + { + if (onComplete != null) + { + // 若已有待回调,链式追加(不覆盖),保证先来先到 + _onCompleteCallback += onComplete; + } + StartDialogue(sequence, npcId, priority); + } + private void PlayImmediate(DialogueSequenceSO sequence, string npcId, int priority = 0) { IsDialogueActive = true; @@ -239,6 +254,11 @@ namespace BaseGames.Dialogue _onDialogueEnded?.Raise(); if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput(); OnDialogueEnded?.Invoke(); + + // 触发一次性完成回调(正常结束和强制中断均触发) + var cb = _onCompleteCallback; + _onCompleteCallback = null; + cb?.Invoke(); } // ── 输入回调 ────────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Dialogue/IDialogueService.cs b/Assets/_Game/Scripts/Dialogue/IDialogueService.cs index 5c06a90..16c3825 100644 --- a/Assets/_Game/Scripts/Dialogue/IDialogueService.cs +++ b/Assets/_Game/Scripts/Dialogue/IDialogueService.cs @@ -23,6 +23,14 @@ namespace BaseGames.Dialogue /// 优先级(默认 0)。数值越大越优先;高优先级可打断低优先级对话。 void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0); + /// + /// 启动对话序列,并在本次对话(含所有排队续播)全部结束时回调 。 + /// 若 为 null,行为与 完全相同。 + /// 回调仅触发一次;若对话被 打断,回调同样会被调用(在 ForceEnd 末尾)。 + /// 适用场景:EventChain 触发对话后等待完成再执行下一动作、CutsceneModule 同步对话与演出。 + /// + void StartDialogue(DialogueSequenceSO sequence, string npcId, int priority, System.Action onComplete); + /// /// 立即强制结束当前对话(含清空等待队列),恢复游戏输入。 /// 适用于:场景切换、演出系统打断、死亡/传送等需要硬中断的场合。 diff --git a/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs b/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs index e08daff..c92b527 100644 --- a/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs +++ b/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs @@ -121,6 +121,10 @@ namespace BaseGames.Editor.Dialogue matrixBtn.style.marginBottom = 4; matrixFoldout.Add(matrixBtn); + var csvBtn = new Button(() => ExportMatrixCsv()) { text = "复制为 CSV" }; + csvBtn.style.marginBottom = 4; + matrixFoldout.Add(csvBtn); + Rebuild(); } @@ -381,6 +385,79 @@ namespace BaseGames.Editor.Dialogue // ── 矩阵分析 ───────────────────────────────────────────────────────── + /// + /// 将矩阵分析结果复制为 CSV 字符串到系统剪贴板。 + /// 格式:首行为标志名称列头(各列)+ "胜出变体",后续每行为一个组合及其结果。 + /// N > 10 时与 一致,提示用户先减少标志数量。 + /// + private void ExportMatrixCsv() + { + if (_target == null || _target.variants == null || _target.variants.Length == 0) + { + EditorUtility.DisplayDialog("矩阵分析 CSV", "当前无可导出的变体数据,请先选择对话序列 SO。", "确定"); + return; + } + + var matrixFlags = _allFlags.Count > 0 ? _allFlags : new List(); + if (matrixFlags.Count == 0) + { + EditorUtility.DisplayDialog("矩阵分析 CSV", "变体未使用任何 requiredFlags,无数据可导出。", "确定"); + return; + } + + const int MaxFlags = 10; + if (matrixFlags.Count > MaxFlags) + { + EditorUtility.DisplayDialog("矩阵分析 CSV", + $"标志数量 ({matrixFlags.Count}) 超过 {MaxFlags},无法导出。\n请先在上方取消勾选不关心的标志,再点击此按钮。", "确定"); + return; + } + + int n = matrixFlags.Count; + int combos = 1 << n; + var sb = new System.Text.StringBuilder(); + + // 表头 + foreach (var f in matrixFlags) + sb.Append(EscapeCsv(f)).Append(','); + sb.AppendLine("胜出变体"); + + // 数据行 + for (int mask = 0; mask < combos; mask++) + { + var combo = new HashSet(System.StringComparer.Ordinal); + for (int bit = 0; bit < n; bit++) + if ((mask & (1 << bit)) != 0) combo.Add(matrixFlags[bit]); + + var mockReader = new MockFlagReader(combo); + int winner = -1; + for (int vi = 0; vi < _target.variants.Length; vi++) + if (_target.CheckVariant(_target.variants[vi], mockReader)) { winner = vi; break; } + + for (int ci = 0; ci < n; ci++) + sb.Append((mask & (1 << ci)) != 0 ? "1" : "0").Append(','); + + string winnerLabel = winner >= 0 + ? $"变体{winner}" + + (_target.variants[winner].sequence != null + ? $"({_target.variants[winner].sequence.name})" : "(无序列)") + : "默认台词"; + sb.AppendLine(EscapeCsv(winnerLabel)); + } + + EditorGUIUtility.systemCopyBuffer = sb.ToString(); + Debug.Log($"[DialogueVariantPreviewWindow] 矩阵 CSV({combos} 行)已复制到剪贴板。"); + ShowNotification(new GUIContent($"✓ 已复制 {combos} 行 CSV 到剪贴板")); + } + + private static string EscapeCsv(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + if (s.Contains(',') || s.Contains('"') || s.Contains('\n')) + return '"' + s.Replace("\"", "\"\"") + '"'; + return s; + } + /// /// 枚举全部 2^N 标志组合(N ≤ 10),以表格形式展示每种组合下胜出的变体索引。 /// N > 10 时显示提示,建议手动筛选标志后分析。 diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs new file mode 100644 index 0000000..e0bc465 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs @@ -0,0 +1,73 @@ +using UnityEditor; +using UnityEngine; +using BaseGames.Dialogue; + +namespace BaseGames.Editor.Dialogue +{ + /// + /// NpcSO 自定义 Inspector。 + /// 在 nameKey 字段下方实时预览本地化管理器解析后的 NPC 名称, + /// 让策划无需打开本地化表即可确认 Key 是否拼写正确。 + /// 仅在编辑器构建中生效;不影响运行时行为。 + /// + [CustomEditor(typeof(NpcSO))] + public class NpcSOEditor : UnityEditor.Editor + { + private static GUIStyle s_previewStyle; + + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + var npc = (NpcSO)target; + if (string.IsNullOrEmpty(npc.nameKey)) return; + + // ── nameKey 本地化预览 ────────────────────────────────────────── + if (s_previewStyle == null) + { + s_previewStyle = new GUIStyle(EditorStyles.helpBox) + { + fontSize = 11, + alignment = TextAnchor.MiddleLeft, + padding = new RectOffset(8, 8, 4, 4), + }; + s_previewStyle.normal.textColor = new Color(0.55f, 0.90f, 0.55f); + } + + string resolved = TryResolveNameKey(npc.nameKey); + EditorGUILayout.Space(4); + + if (string.IsNullOrEmpty(resolved)) + { + EditorGUILayout.HelpBox( + $"nameKey「{npc.nameKey}」在本地化表中未找到对应文本(或 LocalizationManager 未初始化)。\n" + + "请检查本地化表中是否存在此 Key。", + MessageType.Warning); + } + else + { + EditorGUILayout.LabelField( + $"▸ nameKey 解析预览:{resolved}", + s_previewStyle); + } + } + + /// + /// 尝试通过 LocalizationManager(若已加载)解析 nameKey; + /// 如未初始化或找不到 Key,返回 null。 + /// + private static string TryResolveNameKey(string key) + { + try + { + // LocalizationManager.Get 在编辑器下可能返回空字符串(未初始化),视为未找到 + var resolved = BaseGames.Localization.LocalizationManager.Get(key, "UI"); + return string.IsNullOrEmpty(resolved) ? null : resolved; + } + catch + { + return null; + } + } + } +} diff --git a/Assets/_Game/Scripts/Quest/IQuestManager.cs b/Assets/_Game/Scripts/Quest/IQuestManager.cs index 55701ed..4284013 100644 --- a/Assets/_Game/Scripts/Quest/IQuestManager.cs +++ b/Assets/_Game/Scripts/Quest/IQuestManager.cs @@ -131,6 +131,18 @@ namespace BaseGames.Quest /// 适用于自定义筛选(如"活跃且含 NPC 亲密度门槛")等场景。 /// System.Collections.Generic.IReadOnlyList FilterQuests(System.Func predicate); + + /// + /// 将当前处于指定状态的所有任务 ID 填入调用方提供的列表(先 Clear 再添加)。 + /// 相比 不分配新列表,适合高频调用(如每帧刷新 HUD)。 + /// + void FillQuestsInState(QuestStateEnum state, System.Collections.Generic.List result); + + /// + /// 将满足谓词的所有任务 ID 填入调用方提供的列表(先 Clear 再添加)。 + /// 相比 不分配新列表,适合高频调用场景。 + /// + void FillFilterQuests(System.Func predicate, System.Collections.Generic.List result); } /// diff --git a/Assets/_Game/Scripts/Quest/QuestGiver.cs b/Assets/_Game/Scripts/Quest/QuestGiver.cs index 448fe9a..c730266 100644 --- a/Assets/_Game/Scripts/Quest/QuestGiver.cs +++ b/Assets/_Game/Scripts/Quest/QuestGiver.cs @@ -32,14 +32,26 @@ namespace BaseGames.Quest // ── InteractableNPC 覆盖 ────────────────────────────────────────────── + // 缓存上次查找结果,避免 InteractPrompt get(每帧调用)重复遍历 _offeredQuests。 + // 当状态可能变更时(OnEnable、Interact_Internal 后)标记为脏。 + private QuestSO _cachedQuest; + private QuestStateEnum _cachedState; + private bool _cacheDirty = true; + + protected override void OnEnable() + { + base.OnEnable(); + _cacheDirty = true; + } + public override string InteractPrompt { get { var qm = SL.GetOrDefault(); - var quest = GetCurrentOrCompletedQuest(qm); + var quest = GetCachedQuest(qm); if (quest == null || qm == null) return base.InteractPrompt; - return qm.GetState(quest.questId) switch + return _cachedState switch { QuestStateEnum.Available => "接受任务", QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId) ? "提交任务" : "进行中…", @@ -53,32 +65,30 @@ namespace BaseGames.Quest protected override void Interact_Internal(Transform player) { var qm = SL.GetOrDefault(); - var quest = GetCurrentOrCompletedQuest(qm); + var quest = GetCachedQuest(qm); if (quest == null || qm == null) return; - var state = qm.GetState(quest.questId); - - if (state == QuestStateEnum.Available) + if (_cachedState == QuestStateEnum.Available) { qm.AcceptQuest(quest.questId); + _cacheDirty = true; // 状态已变更,下次访问重新查询 } - else if (state == QuestStateEnum.Active && qm.IsReadyToComplete(quest.questId)) + else if (_cachedState == QuestStateEnum.Active && qm.IsReadyToComplete(quest.questId)) { // 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖 var stats = player.GetComponentInParent(); qm.CompleteQuest(quest.questId, stats); + _cacheDirty = true; // 状态已变更,下次访问重新查询 } } protected override DialogueSequenceSO GetCurrentDialogue() { var qm = SL.GetOrDefault(); - var quest = GetCurrentOrCompletedQuest(qm); + var quest = GetCachedQuest(qm); if (quest == null || qm == null) return base.GetCurrentDialogue(); - var state = qm.GetState(quest.questId); - - return state switch + return _cachedState switch { QuestStateEnum.Available => _availableDialogue, QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId) @@ -92,24 +102,36 @@ namespace BaseGames.Quest // ── 私有辅助 ───────────────────────────────────────────────────────── /// - /// 返回当前处于 Available 或 Active 状态的第一个任务; - /// 若全部已完成,返回最后一个已完成任务(用于显示 completedDialogue)。 + /// 返回缓存的当前任务(处于 Available/Active/Paused 的第一个,或最后一个已完成任务)。 + /// 若缓存不脏,直接返回上次结果,避免每帧遍历 _offeredQuests。 + /// 调用 Interact_Internal 后将 _cacheDirty 置 true,确保下次交互状态是最新的。 /// - private QuestSO GetCurrentOrCompletedQuest(IQuestManager qm = null) + private QuestSO GetCachedQuest(IQuestManager qm = null) { - if (_offeredQuests == null) return null; + if (!_cacheDirty && _cachedQuest != null) return _cachedQuest; + qm ??= SL.GetOrDefault(); - if (qm == null) return null; + if (_offeredQuests == null || qm == null) { _cacheDirty = false; 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 || s == QuestStateEnum.Paused) return q; + if (s == QuestStateEnum.Available || s == QuestStateEnum.Active || s == QuestStateEnum.Paused) + { + _cachedQuest = q; + _cachedState = s; + _cacheDirty = false; + return _cachedQuest; + } if (s == QuestStateEnum.Completed) lastCompleted = q; } - return lastCompleted; + + _cachedQuest = lastCompleted; + _cachedState = lastCompleted != null ? QuestStateEnum.Completed : QuestStateEnum.Unavailable; + _cacheDirty = false; + return _cachedQuest; } } } diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index 01fb1e1..87d71b0 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -534,6 +534,42 @@ namespace BaseGames.Quest return CheckQuestDepsAndFlags(quest); } + /// + public IReadOnlyList GetQuestsInState(QuestStateEnum state) + { + var result = new List(); + FillQuestsInState(state, result); + return result; + } + + /// + public IReadOnlyList FilterQuests(Func predicate) + { + if (predicate == null) return Array.Empty(); + var result = new List(); + FillFilterQuests(predicate, result); + return result; + } + + /// + public void FillQuestsInState(QuestStateEnum state, List result) + { + if (result == null) return; + result.Clear(); + foreach (var (id, s) in _questStates) + if (s == state) result.Add(id); + } + + /// + public void FillFilterQuests(Func predicate, List result) + { + if (result == null) return; + result.Clear(); + if (predicate == null) return; + foreach (var (id, s) in _questStates) + if (predicate(id, s)) result.Add(id); + } + #if UNITY_EDITOR || DEVELOPMENT_BUILD // ── IQuestDebugger ──────────────────────────────────────────────────── @@ -797,25 +833,6 @@ namespace BaseGames.Quest return new QuestLockInfo { Reason = QuestLockReason.None }; } - /// - public System.Collections.Generic.IReadOnlyList GetQuestsInState(QuestStateEnum state) - { - var result = new List(); - foreach (var (id, s) in _questStates) - if (s == state) result.Add(id); - return result; - } - - /// - public System.Collections.Generic.IReadOnlyList FilterQuests(System.Func predicate) - { - if (predicate == null) return System.Array.Empty(); - var result = new List(); - foreach (var (id, state) in _questStates) - if (predicate(id, state)) result.Add(id); - return result; - } - /// /// 根据 flags 数组和 logic 评估标志前置条件是否满足。 /// diff --git a/Assets/_Game/Scripts/World/WorldStateRegistry.cs b/Assets/_Game/Scripts/World/WorldStateRegistry.cs index 33be38a..dd5637f 100644 --- a/Assets/_Game/Scripts/World/WorldStateRegistry.cs +++ b/Assets/_Game/Scripts/World/WorldStateRegistry.cs @@ -153,5 +153,22 @@ namespace BaseGames.World /// 重置所有状态(开始新游戏时调用)。 public void Reset() => _states.Clear(); + + /// + /// 反向查询:在所有分类中查找 首次出现的类别。 + /// 适用于调试工具(如 DataHub / WorldState 检视面板)快速定位一个 id 属于哪类。 + /// O(k) 其中 k = 分类数(最多 5)。 + /// + /// 要查找的 ID。 + /// 找到时输出该 ID 所属的类别;未找到时输出 (默认值)。 + /// true = 找到;false = 任何类别中均无此 id。 + public bool TryGetCategory(string id, out WorldObjectCategory category) + { + if (!string.IsNullOrEmpty(id)) + foreach (var (cat, set) in _states) + if (set.Contains(id)) { category = cat; return true; } + category = default; + return false; + } } }