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<QuestSO>() 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>
This commit is contained in:
2026-05-25 00:34:59 +08:00
parent 9c1e70fdeb
commit 48f018f4b8
6 changed files with 181 additions and 43 deletions

View File

@@ -141,7 +141,7 @@ namespace BaseGames.Core.Save
/// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。
/// </summary>
public int DataVersion = 2;
public string Status; // "NotStarted"|"Active"|"Completed"|"Failed"
public string Status; // "Unavailable"|"Available"|"Active"|"Paused"|"Completed"|"Failed"
public int ObjectiveIndex;
/// <summary>新格式Round 24+DataVersion=2objectiveId → progressCount重排目标顺序后存档不会错位。</summary>
public Dictionary<string, int> ObjectiveProgress = new();

View File

@@ -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();
}
/// <summary>
@@ -69,5 +79,32 @@ namespace BaseGames.Editor.Dialogue
return null;
}
}
/// <summary>
/// 在 Project 窗口中 Ping 指定表名对应的本地化 JSON 文件Resources/Localization/…/{tableName}.json
/// 遍历所有语言目录,以第一个找到的文件为准。
/// </summary>
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<TextAsset>(path);
if (asset == null) continue;
EditorGUIUtility.PingObject(asset);
Selection.activeObject = asset;
return;
}
Debug.LogWarning($"[NpcSOEditor] 未找到本地化表文件Resources/Localization/…/{tableName}.json");
}
}
}

View File

@@ -31,6 +31,11 @@ namespace BaseGames.Editor.Modules
// playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏
private System.Action<UnityEditor.PlayModeStateChange> _playModeHandler;
// 依赖关系图中 FindAll<QuestSO>() 的静态缓存,同一编辑器会话内复用,避免重复扫描磁盘
private static QuestSO[] s_allQuestCache;
private static double s_allQuestCacheTime;
private const double k_AllQuestCacheTtl = 5.0; // 秒;超时后下次打开 foldout 时刷新
public void Initialize()
{
_listPane = new SoListPane<QuestSO>(
@@ -421,7 +426,14 @@ namespace BaseGames.Editor.Modules
private static void PopulateDependencyGraph(VisualElement container, QuestSO s)
{
var allQuests = AssetOperations.FindAll<QuestSO>();
// 静态 TTL 缓存5 秒内复用上次 FindAll 结果,避免每次展开 foldout 都扫描全量资产
if (s_allQuestCache == null ||
EditorApplication.timeSinceStartup - s_allQuestCacheTime > k_AllQuestCacheTtl)
{
s_allQuestCache = AssetOperations.FindAll<QuestSO>();
s_allQuestCacheTime = EditorApplication.timeSinceStartup;
}
var allQuests = s_allQuestCache;
// ── 前置任务(上游)────────────────────────────────────────────────
bool hasPrereqs = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;

View File

@@ -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 本地化表中的条目;
// 如本地化表未配置该 KeyGetPrompt 会降级为内联的中文默认文本。
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<IQuestManager>();
_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<IQuestManager>();
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<IQuestManager>();
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<PlayerStats>();
qm.CompleteQuest(quest.questId, stats);
_cacheDirty = true; // 状态已变更,下次访问重新查询
_questManager.CompleteQuest(quest.questId, stats);
// OnQuestStateChanged 事件会自动触发 HandleQuestStateChanged → _cacheDirty = true
}
}
protected override DialogueSequenceSO GetCurrentDialogue()
{
var qm = SL.GetOrDefault<IQuestManager>();
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
/// <summary>
/// 返回缓存的当前任务(处于 Available/Active/Paused 的第一个,或最后一个已完成任务)。
/// 若缓存不脏,直接返回上次结果,避免每帧遍历 _offeredQuests。
/// 调用 Interact_Internal 后将 _cacheDirty 置 true确保下次交互状态是最新的。
/// 任务状态事件HandleQuestStateChanged或 OnEnable 会自动将 _cacheDirty 置 true
/// 确保下次访问状态是最新的。
/// </summary>
private QuestSO GetCachedQuest(IQuestManager qm = null)
private QuestSO GetCachedQuest()
{
if (!_cacheDirty && _cachedQuest != null) return _cachedQuest;
qm ??= SL.GetOrDefault<IQuestManager>();
var qm = _questManager;
if (_offeredQuests == null || qm == null) { _cacheDirty = false; return null; }
QuestSO lastCompleted = null;

View File

@@ -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,25 +432,49 @@ 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;
if (hasFlagEntries)
{
foreach (var entry in branch.conditionFlagEntries)
{
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;
{ conditionMet = true; break; }
}
}
}
else
{
// AND默认全部标志均须满足
// AND默认全部标志均须满足(支持 invert 取反)
if (hasFlagEntries)
{
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;
@@ -451,12 +482,12 @@ namespace BaseGames.Quest
}
}
}
}
#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

View File

@@ -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;
}
/// <summary>
/// 任务分支中单个世界状态标志条件支持取反NOT逻辑。
/// </summary>
[Serializable]
public struct BranchFlagEntry
{
[Tooltip("世界状态标志 ID由 ISaveService.GetFlag 查询)。")]
[BaseGames.Core.WorldStateFlag]
public string flagId;
[Tooltip("若勾选,则该标志为 false 时才满足条件NOT 取反逻辑)。")]
public bool invert;
}
/// <summary>任务分类,供日志 UI 分区和 DataHub 过滤使用。</summary>
public enum QuestCategory
{