From 48f018f4b8d7249c704e93193b58d88b82e5c846 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 25 May 2026 00:34:59 +0800 Subject: [PATCH] feat: Round 51 narrative systems improvements - SaveData: update QuestState.Status comment to include Paused state - QuestManager: add inline comment on AcceptQuest duplicate-accept guard - QuestManager: wrap reward.Apply() in try-catch so exceptions don't corrupt already-committed Completed state - QuestManager.UnlockBranches: support new conditionFlagEntries (invert/ NOT logic) with graceful fallback to legacy conditionFlags - QuestGiver: cache IQuestManager field in OnEnable; subscribe to OnQuestStateChanged for automatic cache invalidation instead of manual _cacheDirty = true after each Interact; remove per-call SL.GetOrDefault - QuestGiver: replace hardcoded Chinese prompt strings with LocalizationManager.Get(key, 'UI') + inline fallback via GetPrompt() - QuestSO: add BranchFlagEntry struct (flagId + invert) for NOT-logic branch conditions; add conditionFlagEntries to QuestBranch with HideInInspector on legacy conditionFlags for backward compat - QuestModule: add static TTL cache (5 s) for FindAll() in PopulateDependencyGraph to avoid re-scanning disk on every foldout open - NpcSOEditor: add 'jump to localization file' button that pings and selects the UI table JSON in the Project window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Assets/_Game/Scripts/Core/Save/SaveData.cs | 2 +- .../Scripts/Editor/Dialogue/NpcSOEditor.cs | 37 ++++++++ .../Scripts/Editor/Modules/QuestModule.cs | 14 ++- Assets/_Game/Scripts/Quest/QuestGiver.cs | 87 ++++++++++++++----- Assets/_Game/Scripts/Quest/QuestManager.cs | 59 ++++++++++--- Assets/_Game/Scripts/Quest/QuestSO.cs | 25 ++++-- 6 files changed, 181 insertions(+), 43 deletions(-) diff --git a/Assets/_Game/Scripts/Core/Save/SaveData.cs b/Assets/_Game/Scripts/Core/Save/SaveData.cs index 4bd7d0c..b5e53fc 100644 --- a/Assets/_Game/Scripts/Core/Save/SaveData.cs +++ b/Assets/_Game/Scripts/Core/Save/SaveData.cs @@ -141,7 +141,7 @@ namespace BaseGames.Core.Save /// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。 /// public int DataVersion = 2; - public string Status; // "NotStarted"|"Active"|"Completed"|"Failed" + public string Status; // "Unavailable"|"Available"|"Active"|"Paused"|"Completed"|"Failed" public int ObjectiveIndex; /// 新格式(Round 24+,DataVersion=2):objectiveId → progressCount,重排目标顺序后存档不会错位。 public Dictionary ObjectiveProgress = new(); diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs index e0bc465..d6da0db 100644 --- a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs +++ b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs @@ -50,6 +50,16 @@ namespace BaseGames.Editor.Dialogue $"▸ nameKey 解析预览:{resolved}", s_previewStyle); } + + // ── 跳转到本地化文件 ──────────────────────────────────────────── + EditorGUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("跳转到本地化文件(UI 表)", GUILayout.Width(200))) + { + PingLocalizationFile("UI"); + } + EditorGUILayout.EndHorizontal(); } /// @@ -69,5 +79,32 @@ namespace BaseGames.Editor.Dialogue return null; } } + + /// + /// 在 Project 窗口中 Ping 指定表名对应的本地化 JSON 文件(Resources/Localization/…/{tableName}.json)。 + /// 遍历所有语言目录,以第一个找到的文件为准。 + /// + private static void PingLocalizationFile(string tableName) + { + string[] guids = AssetDatabase.FindAssets( + $"t:TextAsset {tableName}", + new[] { "Assets/Resources/Localization" }); + + foreach (var guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + // 文件名(不含扩展名)必须完全匹配 tableName + if (!path.EndsWith($"/{tableName}.json", System.StringComparison.OrdinalIgnoreCase)) continue; + + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset == null) continue; + + EditorGUIUtility.PingObject(asset); + Selection.activeObject = asset; + return; + } + + Debug.LogWarning($"[NpcSOEditor] 未找到本地化表文件:Resources/Localization/…/{tableName}.json"); + } } } diff --git a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs index db6248f..3da3b87 100644 --- a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs @@ -31,6 +31,11 @@ namespace BaseGames.Editor.Modules // playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏 private System.Action _playModeHandler; + // 依赖关系图中 FindAll() 的静态缓存,同一编辑器会话内复用,避免重复扫描磁盘 + private static QuestSO[] s_allQuestCache; + private static double s_allQuestCacheTime; + private const double k_AllQuestCacheTtl = 5.0; // 秒;超时后下次打开 foldout 时刷新 + public void Initialize() { _listPane = new SoListPane( @@ -421,7 +426,14 @@ namespace BaseGames.Editor.Modules private static void PopulateDependencyGraph(VisualElement container, QuestSO s) { - var allQuests = AssetOperations.FindAll(); + // 静态 TTL 缓存:5 秒内复用上次 FindAll 结果,避免每次展开 foldout 都扫描全量资产 + if (s_allQuestCache == null || + EditorApplication.timeSinceStartup - s_allQuestCacheTime > k_AllQuestCacheTtl) + { + s_allQuestCache = AssetOperations.FindAll(); + s_allQuestCacheTime = EditorApplication.timeSinceStartup; + } + var allQuests = s_allQuestCache; // ── 前置任务(上游)──────────────────────────────────────────────── bool hasPrereqs = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0; diff --git a/Assets/_Game/Scripts/Quest/QuestGiver.cs b/Assets/_Game/Scripts/Quest/QuestGiver.cs index c730266..904bd15 100644 --- a/Assets/_Game/Scripts/Quest/QuestGiver.cs +++ b/Assets/_Game/Scripts/Quest/QuestGiver.cs @@ -4,6 +4,7 @@ using BaseGames.Dialogue; using BaseGames.Player; using QuestStateEnum = BaseGames.Core.Events.QuestState; using SL = BaseGames.Core.ServiceLocator; +using L10n = BaseGames.Localization.LocalizationManager; namespace BaseGames.Quest { @@ -38,25 +39,68 @@ namespace BaseGames.Quest private QuestStateEnum _cachedState; private bool _cacheDirty = true; + // 本地化 Key 常量 — 对应 UI 本地化表中的条目; + // 如本地化表未配置该 Key,GetPrompt 会降级为内联的中文默认文本。 + private const string K_Accept = "QUEST_PROMPT_ACCEPT"; + private const string K_Submit = "QUEST_PROMPT_SUBMIT"; + private const string K_InProgress = "QUEST_PROMPT_IN_PROGRESS"; + private const string K_Paused = "QUEST_PROMPT_PAUSED"; + private const string K_Talk = "QUEST_PROMPT_TALK"; + + // 缓存 IQuestManager + IQuestEventSource 引用,避免每次访问 InteractPrompt 调用 SL.GetOrDefault + private IQuestManager _questManager; + private IQuestEventSource _questEvents; + protected override void OnEnable() { base.OnEnable(); _cacheDirty = true; + _questManager = SL.GetOrDefault(); + _questEvents = _questManager as IQuestEventSource; + if (_questEvents != null) + _questEvents.OnQuestStateChanged += HandleQuestStateChanged; + } + + protected override void OnDisable() + { + base.OnDisable(); + if (_questEvents != null) + { + _questEvents.OnQuestStateChanged -= HandleQuestStateChanged; + _questEvents = null; + } + _questManager = null; + } + + // 任务状态变化时自动标记缓存失效(无需再手动设 _cacheDirty) + private void HandleQuestStateChanged(string questId, QuestStateEnum from, QuestStateEnum to) + { + if (_offeredQuests == null) return; + foreach (var q in _offeredQuests) + if (q != null && q.questId == questId) { _cacheDirty = true; return; } + } + + // 本地化提示词辅助:如 Key 在表中找不到(返回值等于 Key 自身),回退到内联默认文本 + private static string GetPrompt(string key, string fallback) + { + var v = L10n.Get(key, "UI"); + return v != key ? v : fallback; } public override string InteractPrompt { get { - var qm = SL.GetOrDefault(); - var quest = GetCachedQuest(qm); - if (quest == null || qm == null) return base.InteractPrompt; + var quest = GetCachedQuest(); + if (quest == null || _questManager == null) return base.InteractPrompt; return _cachedState switch { - QuestStateEnum.Available => "接受任务", - QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId) ? "提交任务" : "进行中…", - QuestStateEnum.Paused => "暂停中…", - QuestStateEnum.Completed => "对话", + QuestStateEnum.Available => GetPrompt(K_Accept, "接受任务"), + QuestStateEnum.Active => _questManager.IsReadyToComplete(quest.questId) + ? GetPrompt(K_Submit, "提交任务") + : GetPrompt(K_InProgress, "进行中…"), + QuestStateEnum.Paused => GetPrompt(K_Paused, "暂停中…"), + QuestStateEnum.Completed => GetPrompt(K_Talk, "对话"), _ => base.InteractPrompt, }; } @@ -64,34 +108,32 @@ namespace BaseGames.Quest protected override void Interact_Internal(Transform player) { - var qm = SL.GetOrDefault(); - var quest = GetCachedQuest(qm); - if (quest == null || qm == null) return; + var quest = GetCachedQuest(); + if (quest == null || _questManager == null) return; if (_cachedState == QuestStateEnum.Available) { - qm.AcceptQuest(quest.questId); - _cacheDirty = true; // 状态已变更,下次访问重新查询 + _questManager.AcceptQuest(quest.questId); + // OnQuestStateChanged 事件会自动触发 HandleQuestStateChanged → _cacheDirty = true } - else if (_cachedState == QuestStateEnum.Active && qm.IsReadyToComplete(quest.questId)) + else if (_cachedState == QuestStateEnum.Active && _questManager.IsReadyToComplete(quest.questId)) { // 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖 var stats = player.GetComponentInParent(); - qm.CompleteQuest(quest.questId, stats); - _cacheDirty = true; // 状态已变更,下次访问重新查询 + _questManager.CompleteQuest(quest.questId, stats); + // OnQuestStateChanged 事件会自动触发 HandleQuestStateChanged → _cacheDirty = true } } protected override DialogueSequenceSO GetCurrentDialogue() { - var qm = SL.GetOrDefault(); - var quest = GetCachedQuest(qm); - if (quest == null || qm == null) return base.GetCurrentDialogue(); + var quest = GetCachedQuest(); + if (quest == null || _questManager == null) return base.GetCurrentDialogue(); return _cachedState switch { QuestStateEnum.Available => _availableDialogue, - QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId) + QuestStateEnum.Active => _questManager.IsReadyToComplete(quest.questId) ? _readyDialogue : _activeDialogue, QuestStateEnum.Paused => _activeDialogue, // 暂停中显示"催促"对话,不触发任何状态推进 QuestStateEnum.Completed => _completedDialogue, @@ -104,13 +146,14 @@ namespace BaseGames.Quest /// /// 返回缓存的当前任务(处于 Available/Active/Paused 的第一个,或最后一个已完成任务)。 /// 若缓存不脏,直接返回上次结果,避免每帧遍历 _offeredQuests。 - /// 调用 Interact_Internal 后将 _cacheDirty 置 true,确保下次交互状态是最新的。 + /// 任务状态事件(HandleQuestStateChanged)或 OnEnable 会自动将 _cacheDirty 置 true, + /// 确保下次访问状态是最新的。 /// - private QuestSO GetCachedQuest(IQuestManager qm = null) + private QuestSO GetCachedQuest() { if (!_cacheDirty && _cachedQuest != null) return _cachedQuest; - qm ??= SL.GetOrDefault(); + var qm = _questManager; if (_offeredQuests == null || qm == null) { _cacheDirty = false; return null; } QuestSO lastCompleted = null; diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index 87d71b0..340d5e5 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -244,6 +244,7 @@ namespace BaseGames.Quest public void AcceptQuest(string questId) { if (string.IsNullOrEmpty(questId)) return; + // CanAccept 内部已通过 GetState() != Available 检查,防止重复接取 Active/Completed 任务产生重复事件 if (!CanAccept(questId)) return; var oldState = GetState(questId); _questStates[questId] = QuestStateEnum.Active; @@ -361,7 +362,13 @@ namespace BaseGames.Quest Chan_QuestCompleted?.Raise(questId); OnQuestCompleted?.Invoke(questId); - quest.reward?.Apply(rewardTarget); + // 奖励发放:用 try-catch 包裹,防止 Apply 异常导致好感度/对话解锁等后续逻辑中断 + try { quest.reward?.Apply(rewardTarget); } + catch (System.Exception ex) + { + Debug.LogError( + $"[QuestManager] 任务 '{questId}' 奖励发放时抛出异常(任务状态已提交为 Completed):{ex.Message}\n{ex.StackTrace}"); + } ApplyAffinity(quest); UnlockDialogueKey(quest); UnlockBranches(questId, quest); @@ -425,38 +432,62 @@ namespace BaseGames.Quest if (!conditionMet) continue; // 世界状态标志条件(And/Or 由 conditionFlagsLogic 决定) + // 优先用新版 conditionFlagEntries(支持 invert/NOT 取反),若为空则回退到旧版 conditionFlags // saveService 未注入时降级:跳过标志检查,仅由 conditionQuest 决定分支 - if (branch.conditionFlags != null && branch.conditionFlags.Length > 0 - && saveService != null) + bool hasFlagEntries = branch.conditionFlagEntries != null && branch.conditionFlagEntries.Length > 0; + bool hasLegacyFlags = branch.conditionFlags != null && branch.conditionFlags.Length > 0; + bool hasFlagConds = hasFlagEntries || hasLegacyFlags; + + if (hasFlagConds && saveService != null) { if (branch.conditionFlagsLogic == BaseGames.Core.WorldStateFlagLogic.Or) { conditionMet = false; - foreach (var flag in branch.conditionFlags) + if (hasFlagEntries) { - if (!string.IsNullOrEmpty(flag) && saveService.GetFlag(flag)) + foreach (var entry in branch.conditionFlagEntries) { - conditionMet = true; - break; + if (string.IsNullOrEmpty(entry.flagId)) continue; + bool raw = saveService.GetFlag(entry.flagId); + if (entry.invert ? !raw : raw) { conditionMet = true; break; } + } + } + else + { + foreach (var flag in branch.conditionFlags) + { + if (!string.IsNullOrEmpty(flag) && saveService.GetFlag(flag)) + { conditionMet = true; break; } } } } else { - // AND(默认):全部标志均须满足 - foreach (var flag in branch.conditionFlags) + // AND(默认):全部标志均须满足(支持 invert 取反) + if (hasFlagEntries) { - if (string.IsNullOrEmpty(flag)) continue; - if (!saveService.GetFlag(flag)) { conditionMet = false; break; } + foreach (var entry in branch.conditionFlagEntries) + { + if (string.IsNullOrEmpty(entry.flagId)) continue; + bool raw = saveService.GetFlag(entry.flagId); + if (entry.invert ? raw : !raw) { conditionMet = false; break; } + } + } + else + { + foreach (var flag in branch.conditionFlags) + { + if (string.IsNullOrEmpty(flag)) continue; + if (!saveService.GetFlag(flag)) { conditionMet = false; break; } + } } } } #if UNITY_EDITOR || DEVELOPMENT_BUILD - else if (branch.conditionFlags != null && branch.conditionFlags.Length > 0 - && saveService == null) + else if (hasFlagConds && saveService == null) { Debug.LogWarning( - $"[QuestManager] 任务 '{questId}' 分支配置了 conditionFlags,但 ISaveService 未注册," + + $"[QuestManager] 任务 '{questId}' 分支配置了标志条件,但 ISaveService 未注册," + "标志条件已跳过(降级为仅 conditionQuest 判断)。"); } #endif diff --git a/Assets/_Game/Scripts/Quest/QuestSO.cs b/Assets/_Game/Scripts/Quest/QuestSO.cs index 280d05f..f5040a4 100644 --- a/Assets/_Game/Scripts/Quest/QuestSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestSO.cs @@ -310,12 +310,14 @@ namespace BaseGames.Quest [Tooltip("条件任务:该任务已 Completed 时走本分支(留空 = 默认分支,总是满足)。")] public QuestSO conditionQuest; [Tooltip("世界状态标志条件判断逻辑:\n" + - " And(默认)= 全部 conditionFlags 均满足才走本分支\n" + - " Or = 任意一个 conditionFlag 满足即可走本分支")] + " And(默认)= 全部 conditionFlagEntries 均满足才走本分支\n" + + " Or = 任意一个 conditionFlagEntry 满足即可走本分支")] public BaseGames.Core.WorldStateFlagLogic conditionFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And; - [Tooltip("世界状态标志条件(可选)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。\n" + - "标志由 ISaveService.GetFlag(flagId) 查询,通常由 SetFlagAction 或其他系统写入。")] - [WorldStateFlag] + [Tooltip("世界状态标志条件(支持 invert 取反)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。\n" + + "优先使用此字段;若为空则自动回退到旧版 conditionFlags 以保证兼容性。")] + public BranchFlagEntry[] conditionFlagEntries; + [Tooltip("(旧版兼容字段,已被 conditionFlagEntries 取代。如 conditionFlagEntries 不为空则本字段被忽略。)")] + [HideInInspector] public string[] conditionFlags; [Tooltip("本分支解锁的后续任务。满足所有条件后,此任务将被设为 Available。")] public QuestSO nextQuest; @@ -327,6 +329,19 @@ namespace BaseGames.Quest public string npcDialogueKey; } + /// + /// 任务分支中单个世界状态标志条件,支持取反(NOT)逻辑。 + /// + [Serializable] + public struct BranchFlagEntry + { + [Tooltip("世界状态标志 ID(由 ISaveService.GetFlag 查询)。")] + [BaseGames.Core.WorldStateFlag] + public string flagId; + [Tooltip("若勾选,则该标志为 false 时才满足条件(NOT 取反逻辑)。")] + public bool invert; + } + /// 任务分类,供日志 UI 分区和 DataHub 过滤使用。 public enum QuestCategory {