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
{