using System.Collections.Generic; using UnityEngine; using BaseGames.Core.Events; using BaseGames.Core.Save; using QuestStateEnum = BaseGames.Core.Events.QuestState; namespace BaseGames.Quest { /// /// 运行时任务管理器(架构 22_QuestChallengeModule §5)。 /// 挂在 Persistent 场景 [GameManagers] 下。 /// 事件驱动追踪目标进度,不主动轮询。 /// 实现 ISaveable,通过 SaveManager 持久化任务状态。 /// /// _allQuests 由编辑器 OnValidate / "刷新任务列表" 右键菜单自动填充, /// 无需策划人员手动拖入 ScriptableObject。 /// public class QuestManager : MonoBehaviour, ISaveable, IQuestManager, IQuestEventSource #if UNITY_EDITOR || DEVELOPMENT_BUILD , IQuestDebugger #endif { // ── Inspector ──────────────────────────────────────────────────────── [Tooltip("所有 QuestSO 资产。编辑器会自动同步,无需手动维护。")] [SerializeField] private QuestSO[] _allQuests; [Header("事件频道注册表(推荐)")] [Tooltip("将全部事件频道集中到一个 SO 中,方便多场景复用。\n" + "若设置此注册表,下方独立频道字段将被自动忽略(注册表优先)。\n" + "创建方式:右键菜单 → BaseGames/Quest/EventChannelRegistry。")] [SerializeField] private QuestEventChannelRegistry _eventChannelRegistry; [Header("Event Channels(监听)— 未设置注册表时生效")] [Tooltip("EVT_EnemyDied:payload = enemyId(string)。敌人死亡时由战斗系统广播,驱动击败类目标进度。")] [SerializeField] private StringEventChannelSO _onEnemyDied; [Tooltip("EVT_CollectiblePickup:payload = itemId(string)。拾取物品时广播,驱动收集类目标进度,同时也作为 RewardSO 物品发放频道。")] [SerializeField] private StringEventChannelSO _onCollectiblePickup; [Tooltip("EVT_SceneLoaded:payload = sceneName(string)。场景切换完成时广播,驱动到达类目标进度。")] [SerializeField] private StringEventChannelSO _onSceneLoaded; [Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。DialogueManager 播完一段对话后广播,驱动对话类目标进度。")] [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; [Tooltip("EVT_SkillUsed:payload = AbilityType.ToString()(string)。玩家使用技能时广播,驱动技能使用类目标进度。")] [SerializeField] private StringEventChannelSO _onSkillUsed; [Tooltip("EVT_AreaReached:payload = markerTag(string)。TriggerZone 组件在玩家进入碰撞体时广播,驱动精确区域到达类目标进度。")] [SerializeField] private StringEventChannelSO _onAreaReached; [Header("Event Channels(广播)— 未设置注册表时生效")] [Tooltip("EVT_QuestStarted:payload = questId。AcceptQuest 成功后广播,供任务日志 UI 新增条目、任务追踪 HUD 激活等监听。")] [SerializeField] private StringEventChannelSO _onQuestStarted; [Tooltip("EVT_QuestCompleted:payload = questId。CompleteQuest 成功后广播,供成就系统、任务日志、剧情触发器等监听。")] [SerializeField] private StringEventChannelSO _onQuestCompleted; [Tooltip("EVT_QuestFailed:payload = questId。失败条件触发后广播,供失败提示 UI、任务日志、剧情触发器等监听。")] [SerializeField] private StringEventChannelSO _onQuestFailed; [Tooltip("EVT_QuestObjectiveUpdated:payload = QuestObjectiveEvent(questId + progress)。目标进度变化时广播,供任务追踪 HUD 更新进度条等监听。")] [SerializeField] private QuestObjectiveEventChannelSO _onObjectiveUpdated; [Tooltip("EVT_QuestObjectiveBatchUpdated:同帧内同一任务多目标聚合后广播一次(payload = QuestObjectiveBatchEvent)。\n" + "供追踪 HUD 订阅以避免同帧多次重绘;留空则仅使用逐条 EVT_QuestObjectiveUpdated。")] [SerializeField] private QuestObjectiveBatchEventChannelSO _onObjectiveBatchUpdated; [Tooltip("EVT_NpcAffinityChanged:payload = NpcAffinityEvent(npcId + delta + newTotal 强类型,零字符串解析),供 UI/好感度系统监听。")] [SerializeField] private NpcAffinityEventChannelSO _onNpcAffinityChanged; [Tooltip("EVT_DialogueKeyUnlocked:payload = unlockDialogueKey,供 NPC 台词系统监听。")] [SerializeField] private StringEventChannelSO _onDialogueKeyUnlocked; [Tooltip("EVT_QuestReadyToComplete:payload = questId。目标全部达成、可回去交任务时广播一次(去重)。\n" + "供任务日志 UI 高亮、地图标记、提示 HUD 等监听。")] [SerializeField] private StringEventChannelSO _onQuestReadyToComplete; [Tooltip("EVT_QuestAbandoned:payload = questId。玩家主动放弃任务(Active → Available)时广播。\n" + "供任务日志 UI 移除追踪条目、提示 HUD 清空等监听。")] [SerializeField] private StringEventChannelSO _onQuestAbandoned; [Tooltip("EVT_QuestPaused:payload = questId。PauseQuest 成功(Active → Paused)后广播。\n" + "供任务日志 UI 更新状态标记、UI/成就系统监听。")] [SerializeField] private StringEventChannelSO _onQuestPaused; [Tooltip("EVT_QuestResumed:payload = questId。ResumeQuest 成功(Paused → Active)后广播。\n" + "供任务日志 UI 恢复追踪条目、UI/成就系统监听。")] [SerializeField] private StringEventChannelSO _onQuestResumed; [Header("扩展事件频道(自定义目标类型)")] [Tooltip("标准六类事件(敌人/物品/场景/对话/技能/区域)已在上方独立字段配置。\n" + "若新增自定义 QuestEventType 和 StringEventChannelSO,在此数组添加绑定即可,无需修改代码。")] [SerializeField] private QuestEventChannelBinding[] _extraEventChannels; // ── 事件频道访问器(注册表优先,回退到独立字段)──────────────────────── // 支持从 QuestEventChannelRegistry 或独立字段访问,无需修改下游广播/订阅代码。 private StringEventChannelSO Chan_EnemyDied => _eventChannelRegistry?.onEnemyDied ?? _onEnemyDied; private StringEventChannelSO Chan_CollectiblePickup => _eventChannelRegistry?.onCollectiblePickup ?? _onCollectiblePickup; private StringEventChannelSO Chan_SceneLoaded => _eventChannelRegistry?.onSceneLoaded ?? _onSceneLoaded; private StringEventChannelSO Chan_NpcDialogueCompleted => _eventChannelRegistry?.onNpcDialogueCompleted ?? _onNpcDialogueCompleted; private StringEventChannelSO Chan_SkillUsed => _eventChannelRegistry?.onSkillUsed ?? _onSkillUsed; private StringEventChannelSO Chan_AreaReached => _eventChannelRegistry?.onAreaReached ?? _onAreaReached; private StringEventChannelSO Chan_QuestStarted => _eventChannelRegistry?.onQuestStarted ?? _onQuestStarted; private StringEventChannelSO Chan_QuestCompleted => _eventChannelRegistry?.onQuestCompleted ?? _onQuestCompleted; private StringEventChannelSO Chan_QuestFailed => _eventChannelRegistry?.onQuestFailed ?? _onQuestFailed; private StringEventChannelSO Chan_QuestAbandoned => _eventChannelRegistry?.onQuestAbandoned ?? _onQuestAbandoned; private StringEventChannelSO Chan_QuestPaused => _eventChannelRegistry?.onQuestPaused ?? _onQuestPaused; private StringEventChannelSO Chan_QuestResumed => _eventChannelRegistry?.onQuestResumed ?? _onQuestResumed; private StringEventChannelSO Chan_QuestReadyToComplete => _eventChannelRegistry?.onQuestReadyToComplete ?? _onQuestReadyToComplete; private QuestObjectiveEventChannelSO Chan_ObjectiveUpdated => _eventChannelRegistry?.onObjectiveUpdated ?? _onObjectiveUpdated; private QuestObjectiveBatchEventChannelSO Chan_ObjectiveBatch => _eventChannelRegistry?.onObjectiveBatchUpdated ?? _onObjectiveBatchUpdated; private NpcAffinityEventChannelSO Chan_NpcAffinityChanged => _eventChannelRegistry?.onNpcAffinityChanged ?? _onNpcAffinityChanged; private StringEventChannelSO Chan_DialogueKeyUnlocked => _eventChannelRegistry?.onDialogueKeyUnlocked ?? _onDialogueKeyUnlocked; // ── Runtime State ──────────────────────────────────────────────────── private readonly Dictionary _questStates = new(); private readonly Dictionary _objectiveStates = new(); /// questId → QuestSO 快速查找表(由 Awake 构建,将 GetQuestSO O(n) 降为 O(1))。 private Dictionary _questIndex; /// /// (questId, objectiveId) → compositeKey 预缓存表。 /// 由 Awake 与 _questIndex 同步构建,消除 DispatchEvent 高频内循环的字符串拼接分配。 /// key = (questId, objectiveId),value = CompositeKey(questId, objectiveId)。 /// private Dictionary<(string, string), string> _compositeKeyCache; private readonly CompositeDisposable _subs = new(); /// npcId → 好感度数值(从 SaveData.World.NpcRelations 同步,由 CompleteQuest 更新)。 private Dictionary _npcRelations = new(); /// /// OnLoad 完成后置为 true,标记好感度字典已从存档初始化。 /// 防止 CanAccept 在 OnLoad 前被调用时,对空字典产生错误的通过判定。 /// private bool _affinityInitialized; /// 已广播过 EVT_QuestReadyToComplete 的任务 ID 集合(防重复通知)。 /// 任务完成/失败时从集合移除,再次激活后可重新通知。 private readonly HashSet _notifiedReadyQuests = new(); #if UNITY_EDITOR || DEVELOPMENT_BUILD /// 任务暂停时记录的 realtimeSinceStartup(供 ResumeQuest 计算暂停持续时长并日志输出)。 private readonly Dictionary _pauseTimestamps = new(); #endif /// questId → 接取时间(Unix 秒,UTC)。用于存档和统计分析。 private readonly Dictionary _startedAtUtc = new(); /// questId → 完成时间(Unix 秒,UTC)。0 表示未完成或旧存档未记录。 private readonly Dictionary _completedAtUtc = new(); /// 供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。 public StringEventChannelSO QuestStartedChannel => _onQuestStarted; /// 供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。 public StringEventChannelSO QuestCompletedChannel => _onQuestCompleted; /// 供 SaveManager 迭代的任务状态字典(只读视图)。 public IReadOnlyDictionary QuestStates => _questStates; private void Awake() { if (BaseGames.Core.ServiceLocator.GetOrDefault() != null) { Destroy(gameObject); return; } BaseGames.Core.ServiceLocator.Register(this); // 构建任务字典索引,将 GetQuestSO 变为 O(1) _questIndex = new Dictionary(_allQuests?.Length ?? 0); if (_allQuests != null) foreach (var q in _allQuests) if (q != null && !string.IsNullOrEmpty(q.questId)) _questIndex[q.questId] = q; // 预构建 compositeKey 缓存,消除 DispatchEvent 高频内循环的字符串拼接分配 int cacheCapacity = 0; if (_allQuests != null) foreach (var q in _allQuests) if (q?.objectives != null) cacheCapacity += q.objectives.Length; _compositeKeyCache = new Dictionary<(string, string), string>(cacheCapacity); foreach (var q in _questIndex.Values) { if (q.objectives == null) continue; foreach (var obj in q.objectives) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; var cacheKey = (q.questId, obj.objectiveId); if (!_compositeKeyCache.ContainsKey(cacheKey)) _compositeKeyCache[cacheKey] = CompositeKey(q.questId, obj.objectiveId); } } #if UNITY_EDITOR || DEVELOPMENT_BUILD ValidateQuestIds(); ValidatePrerequisites(); #endif // 将无前置条件的任务初始化为 Available,确保冷启动时可接取 InitializeAvailableQuests(); } #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() { Chan_EnemyDied?.Subscribe(p => DispatchEvent(QuestEventType.EnemyDefeated, p)).AddTo(_subs); Chan_CollectiblePickup?.Subscribe(p => DispatchEvent(QuestEventType.ItemCollected, p)).AddTo(_subs); Chan_SceneLoaded?.Subscribe(p => DispatchEvent(QuestEventType.SceneLoaded, p)).AddTo(_subs); Chan_NpcDialogueCompleted?.Subscribe(p => DispatchEvent(QuestEventType.NpcDialogueCompleted, p)).AddTo(_subs); Chan_SkillUsed?.Subscribe(p => DispatchEvent(QuestEventType.SkillUsed, p)).AddTo(_subs); Chan_AreaReached?.Subscribe(p => DispatchEvent(QuestEventType.AreaReached, p)).AddTo(_subs); // 扩展绑定:Inspector 中配置的自定义频道,无需修改代码即可支持新 QuestEventType if (_extraEventChannels != null) { foreach (var binding in _extraEventChannels) { var capturedType = binding.eventType; binding.channel?.Subscribe(p => DispatchEvent(capturedType, p)).AddTo(_subs); } } BaseGames.Core.ServiceLocator.GetOrDefault()?.Register(this); } private void OnDisable() { _subs.Clear(); BaseGames.Core.ServiceLocator.GetOrDefault()?.Unregister(this); } private void OnDestroy() { BaseGames.Core.ServiceLocator.Unregister(this); } // ── IQuestEventSource(代码订阅入口,无需直接持有 SO 频道)────────────── /// public event System.Action OnQuestStarted; /// public event System.Action OnQuestCompleted; /// public event System.Action OnQuestFailed; /// public event System.Action OnQuestAbandoned; /// public event System.Action OnQuestPaused; /// public event System.Action OnQuestResumed; /// public event System.Action OnQuestReadyToComplete; /// public event System.Action OnQuestStateChanged; // ── 公共 API ────────────────────────────────────────────────────────── /// NPC 接受任务时调用。 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; _startedAtUtc[questId] = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds(); OnQuestStateChanged?.Invoke(questId, oldState, QuestStateEnum.Active); Chan_QuestStarted?.Raise(questId); OnQuestStarted?.Invoke(questId); // 触发接取任务对话(NPC 委托台词) var quest = GetQuestSO(questId); if (quest?.acceptDialogueSequence != null) { var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault(); if (dialogueService != null) dialogueService.StartDialogue(quest.acceptDialogueSequence, quest.GiverNpcId ?? ""); #if UNITY_EDITOR || DEVELOPMENT_BUILD else Debug.LogWarning( $"[QuestManager] 任务 '{questId}' 接取时需播放对话 '{quest.acceptDialogueSequence.name}'," + "但 IDialogueService 未注册,对话被跳过。"); #endif } } /// /// 玩家主动放弃进行中的任务(Active → Available/Unavailable)。 /// 清除已积累的目标进度,广播 EVT_QuestAbandoned, /// 任务回到可重新接取状态(前置满足 → Available,否则 → Unavailable)。 /// public void AbandonQuest(string questId) { if (string.IsNullOrEmpty(questId)) return; var curState = GetState(questId); // Paused 状态:自动恢复为 Active 再放弃,调用方无需手动二步操作 if (curState == QuestStateEnum.Paused) _questStates[questId] = QuestStateEnum.Active; else if (curState != QuestStateEnum.Active) return; // 清除该任务的所有目标进度 var quest = GetQuestSO(questId); if (quest?.objectives != null) { foreach (var obj in quest.objectives) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; _objectiveStates.Remove(GetCompositeKey(questId, obj.objectiveId)); } } _notifiedReadyQuests.Remove(questId); // 回到可接取状态(前置满足则 Available,否则 Unavailable) var questSo = GetQuestSO(questId); var newState = MeetsPrerequisites(questSo) ? QuestStateEnum.Available : QuestStateEnum.Unavailable; _questStates[questId] = newState; OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, newState); Chan_QuestAbandoned?.Raise(questId); OnQuestAbandoned?.Invoke(questId); } /// /// 暂停进行中的任务(Active → Paused)。 /// 暂停期间:目标进度事件不推进,失败条件不判定。 /// 通过 ResumeQuest 恢复。非 Active 状态调用无效。 /// public void PauseQuest(string questId) { if (string.IsNullOrEmpty(questId)) return; if (GetState(questId) != QuestStateEnum.Active) return; _questStates[questId] = QuestStateEnum.Paused; OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Paused); #if UNITY_EDITOR || DEVELOPMENT_BUILD _pauseTimestamps[questId] = UnityEngine.Time.realtimeSinceStartup; Debug.Log($"[QuestManager] 任务 '{questId}' 已暂停(realtimeSinceStartup={UnityEngine.Time.realtimeSinceStartup:F2}s)。"); #endif Chan_QuestPaused?.Raise(questId); OnQuestPaused?.Invoke(questId); } /// /// 恢复已暂停的任务(Paused → Active)。 /// 非 Paused 状态调用无效。 /// public void ResumeQuest(string questId) { if (string.IsNullOrEmpty(questId)) return; if (GetState(questId) != QuestStateEnum.Paused) return; _questStates[questId] = QuestStateEnum.Active; OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Paused, QuestStateEnum.Active); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (_pauseTimestamps.TryGetValue(questId, out float pausedAt)) { float duration = UnityEngine.Time.realtimeSinceStartup - pausedAt; _pauseTimestamps.Remove(questId); Debug.Log($"[QuestManager] 任务 '{questId}' 已恢复(暂停持续 {duration:F2}s)。"); } #endif Chan_QuestResumed?.Raise(questId); OnQuestResumed?.Invoke(questId); } /// NPC 完成任务时调用。 public void CompleteQuest(string questId, IRewardTarget rewardTarget) { if (!IsReadyToComplete(questId)) return; var quest = GetQuestSO(questId); if (quest == null) return; // IsReadyToComplete 已通过,此处防御冗余调用竞态 // 先更新状态再发放奖励:确保 Apply 即使抛出异常,任务状态也已正确写入 _questStates[questId] = QuestStateEnum.Completed; _completedAtUtc[questId] = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _notifiedReadyQuests.Remove(questId); OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Completed); Chan_QuestCompleted?.Raise(questId); OnQuestCompleted?.Invoke(questId); // 奖励发放:用 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); } /// 将奖励好感度更新到本地缓存并广播事件。上限由 NpcSO.maxAffinity 控制(>0 时生效)。 private void ApplyAffinity(QuestSO quest) { if (quest.reward == null || quest.reward.affinityBonus == 0) return; if (string.IsNullOrEmpty(quest.GiverNpcId)) return; _npcRelations.TryGetValue(quest.GiverNpcId, out int current); int newTotal = current + quest.reward.affinityBonus; // 上限截断:npcSO.maxAffinity > 0 时好感度不得超过上限 int maxAffinity = quest.giverNpc?.maxAffinity ?? 0; if (maxAffinity > 0 && newTotal > maxAffinity) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning( $"[QuestManager] 任务 '{quest.questId}' 好感度奖励 +{quest.reward.affinityBonus} " + $"将超出 NPC '{quest.GiverNpcId}' 的上限 {maxAffinity}(当前 {current}),已截断至 {maxAffinity},实际增量为 {maxAffinity - current}。"); #endif newTotal = maxAffinity; } // 广播实际写入的 delta(截断后),而非请求值;UI 层显示 "+5" 而非因截断产生误导的 "+20" int actualDelta = newTotal - current; _npcRelations[quest.GiverNpcId] = newTotal; Chan_NpcAffinityChanged?.Raise(new NpcAffinityEvent { npcId = quest.GiverNpcId, delta = actualDelta, newTotal = newTotal }); } /// 广播对话解锁事件,供 NPC 台词管理系统监听并切换新对话集。 private void UnlockDialogueKey(QuestSO quest) { if (quest.reward == null || string.IsNullOrEmpty(quest.reward.unlockDialogueKey)) return; Chan_DialogueKeyUnlocked?.Raise(quest.reward.unlockDialogueKey); } /// /// 解锁满足条件的后续任务分支,并触发相应 NPC 完成反应对话。 /// 允许同时满足多个分支(并行支线解锁)。 /// private void UnlockBranches(string questId, QuestSO quest) { if (quest.branches == null) return; var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault(); var saveService = BaseGames.Core.ServiceLocator.GetOrDefault(); foreach (var branch in quest.branches) { // 任务条件 bool conditionMet = branch.conditionQuest == null || GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed; if (!conditionMet) continue; // 世界状态标志条件(And/Or 由 conditionFlagsLogic 决定) // 优先用新版 conditionFlagEntries(支持 invert/NOT 取反),若为空则回退到旧版 conditionFlags // saveService 未注入时降级:跳过标志检查,仅由 conditionQuest 决定分支 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; } } } } else { // 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; if (!saveService.GetFlag(flag)) { conditionMet = false; break; } } } } } #if UNITY_EDITOR || DEVELOPMENT_BUILD else if (hasFlagConds && saveService == null) { Debug.LogWarning( $"[QuestManager] 任务 '{questId}' 分支配置了标志条件,但 ISaveService 未注册," + "标志条件已跳过(降级为仅 conditionQuest 判断)。"); } #endif if (!conditionMet) continue; if (branch.nextQuest != null) _questStates[branch.nextQuest.questId] = QuestStateEnum.Available; // 触发 NPC 完成反应对话(如 NPC 说"太好了,谢谢你!") if (branch.npcDialogueSequence != null) { if (dialogueService != null) dialogueService.StartDialogue(branch.npcDialogueSequence, quest.GiverNpcId ?? ""); else Debug.LogWarning( $"[QuestManager] 任务 '{questId}' 完成后需播放 NPC 对话 " + $"'{branch.npcDialogueSequence.name}',但 DialogueService 未注册到 ServiceLocator,对话被跳过。"); } } } public QuestStateEnum GetState(string questId) => _questStates.TryGetValue(questId, out var s) ? s : QuestStateEnum.Unavailable; public bool IsReadyToComplete(string questId) { var quest = GetQuestSO(questId); if (quest == null || GetState(questId) != QuestStateEnum.Active) return false; if (quest.objectives == null) return true; foreach (var obj in quest.objectives) { if (obj == null) continue; if (!obj.IsOptional && !IsObjectiveComplete(questId, obj)) return false; } return true; } public int GetNpcAffinity(string npcId) => (!string.IsNullOrEmpty(npcId) && _npcRelations.TryGetValue(npcId, out int v)) ? v : 0; /// /// 返回任务无法被接取的原因(本地化 Key 格式)。 /// 内部委托给 实现,保持向后兼容。 /// public string GetQuestLockReason(string questId) => GetQuestLockInfo(questId).ToLocalizationKey(); /// public QuestLockInfo GetQuestLockInfo(string questId) { if (string.IsNullOrEmpty(questId)) return new QuestLockInfo { Reason = QuestLockReason.NotFound }; var state = GetState(questId); switch (state) { case QuestStateEnum.Active: return new QuestLockInfo { Reason = QuestLockReason.AlreadyActive }; case QuestStateEnum.Completed: return new QuestLockInfo { Reason = QuestLockReason.AlreadyCompleted }; case QuestStateEnum.Failed: return new QuestLockInfo { Reason = QuestLockReason.Failed }; case QuestStateEnum.Paused: return new QuestLockInfo { Reason = QuestLockReason.Paused }; } // Unavailable / Available 都需要进一步细化 var quest = GetQuestSO(questId); if (quest == null) return new QuestLockInfo { Reason = QuestLockReason.NotFound }; // 好感度门槛检查(仅 GetQuestLockInfo 关心,不影响 MeetsPrerequisites) if (quest.minAffinityToAccept > 0 && !string.IsNullOrEmpty(quest.GiverNpcId)) { if (!_affinityInitialized) return new QuestLockInfo { Reason = QuestLockReason.DataNotLoaded }; _npcRelations.TryGetValue(quest.GiverNpcId, out int affinity); if (affinity < quest.minAffinityToAccept) return new QuestLockInfo { Reason = QuestLockReason.InsufficientAffinity, Param = $"{affinity}/{quest.minAffinityToAccept}" }; } // 前置依赖 + 标志检查委托给 CheckQuestDepsAndFlags(单一权威实现) 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 ──────────────────────────────────────────────────── /// /// 将任务重置为 Available(前置满足)或 Unavailable(前置未满足),并清除目标进度。 /// 仅供开发/调试使用(IQuestDebugger),不广播 QuestStarted / QuestCompleted 等运行时事件。 /// 正式发布构建中此方法不存在;调用方通过 (qm as IQuestDebugger)?.ResetQuest(id) 使用。 /// /// 要重置的任务 ID。 /// true = 同步扣回本任务发放的好感度增量,防止反复完成累积。 public void ResetQuest(string questId, bool rollbackAffinity = true) { if (string.IsNullOrEmpty(questId)) return; var quest = GetQuestSO(questId); if (quest == null) return; // 好感度回滚(仅当任务已处于 Completed 状态且配置了好感度奖励时) if (rollbackAffinity && _questStates.TryGetValue(questId, out var curState) && curState == QuestStateEnum.Completed && quest.reward != null && quest.reward.affinityBonus != 0 && !string.IsNullOrEmpty(quest.GiverNpcId)) { _npcRelations.TryGetValue(quest.GiverNpcId, out int current); int rolled = current - quest.reward.affinityBonus; _npcRelations[quest.GiverNpcId] = rolled; Debug.Log($"[QuestManager] 回滚任务 '{questId}' 的好感度增量 " + $"{quest.reward.affinityBonus:+#;-#},'{quest.GiverNpcId}' 好感度:{current} → {rolled}。"); } // 清除目标进度 if (quest.objectives != null) { foreach (var obj in quest.objectives) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; _objectiveStates.Remove(GetCompositeKey(questId, obj.objectiveId)); } } _notifiedReadyQuests.Remove(questId); // 重置状态:前置满足 → Available,否则 Unavailable _questStates[questId] = MeetsPrerequisites(quest) ? QuestStateEnum.Available : QuestStateEnum.Unavailable; Debug.Log($"[QuestManager] 任务 '{questId}' 已重置为 [{_questStates[questId]}](好感度回滚:{rollbackAffinity})。"); } #endif // ── ISaveable ───────────────────────────────────────────────────────── public void OnSave(SaveData data) { data.Quests.QuestStates.Clear(); foreach (var (id, state) in _questStates) { data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState { DataVersion = 3, Status = state.ToString(), ObjectiveProgress = BuildObjectiveProgress(id), ObjectiveCompleted = BuildObjectiveCompleted(id), StartedAtUtc = _startedAtUtc.TryGetValue(id, out var sta) ? sta : 0L, CompletedAtUtc = _completedAtUtc.TryGetValue(id, out var cta) ? cta : 0L, }; } // 将本地 NPC 好感度缓存回写到存档 data.World.NpcRelations.Clear(); foreach (var (npcId, val) in _npcRelations) data.World.NpcRelations[npcId] = val; } public void OnLoad(SaveData data) { _questStates.Clear(); _objectiveStates.Clear(); foreach (var (id, saved) in data.Quests.QuestStates) { if (System.Enum.TryParse(saved.Status, out var parsedState)) _questStates[id] = parsedState; #if UNITY_EDITOR || DEVELOPMENT_BUILD else Debug.LogWarning( $"[QuestManager] OnLoad:任务 '{id}' 的 Status 值 '{saved.Status}' 无法解析为 QuestStateEnum," + "已忽略(该任务将不出现在运行时 _questStates,等同于 Unavailable)。" + "请检查存档文件是否损坏,或任务状态枚举定义是否发生变更。"); #endif var quest = GetQuestSO(id); if (quest?.objectives == null) continue; bool hasNewFormat = saved.ObjectiveProgress != null && saved.ObjectiveProgress.Count > 0; // DataVersion >= 2:新格式(objectiveId 键值对);DataVersion <= 1 或遗留存档:旧格式(按索引) // Count > 0 作为无 DataVersion 字段时的兼容兜底 bool useNewFormat = saved.DataVersion >= 2 || hasNewFormat; if (useNewFormat && saved.ObjectiveProgress != null) { // 新格式:objectiveId → count,重排顺序后仍可正确恢复 foreach (var obj in quest.objectives) { if (obj == null) continue; if (!saved.ObjectiveProgress.TryGetValue(obj.objectiveId, out int count)) continue; string compositeKey = GetCompositeKey(id, obj.objectiveId); if (!_objectiveStates.TryGetValue(compositeKey, out var os)) os = _objectiveStates[compositeKey] = new QuestObjectiveState(); os.progressCount = count; // DataVersion >= 3:从存档恢复 completed 标志(防止 GetRequiredCount 变更后判定漂移) if (saved.ObjectiveCompleted != null && saved.ObjectiveCompleted.TryGetValue(obj.objectiveId, out bool done)) os.completed = done; } } else if (saved.ProgressCounts != null #pragma warning disable CS0618 // ProgressCounts 弃用字段:仅在此处读取用于旧存档迁移,不再写入 && saved.ProgressCounts.Count > 0) { // 旧格式兼容(按数组索引):迁移旧存档用,不再写入新存档 for (int i = 0; i < quest.objectives.Length && i < saved.ProgressCounts.Count; i++) { var obj = quest.objectives[i]; if (obj == null) continue; string compositeKey = GetCompositeKey(id, obj.objectiveId); if (!_objectiveStates.TryGetValue(compositeKey, out var os)) os = _objectiveStates[compositeKey] = new QuestObjectiveState(); os.progressCount = saved.ProgressCounts[i]; } } #pragma warning restore CS0618 // DataVersion >= 3:恢复任务开始 / 完成时间戳(0 = 旧存档未记录,跳过) if (saved.StartedAtUtc != 0) _startedAtUtc[id] = saved.StartedAtUtc; if (saved.CompletedAtUtc != 0) _completedAtUtc[id] = saved.CompletedAtUtc; } // 从存档恢复 NPC 好感度缓存(供 CanAccept 门槛检查使用) _npcRelations = new Dictionary(data.World.NpcRelations); _affinityInitialized = true; #if UNITY_EDITOR || DEVELOPMENT_BUILD // 检测存档中存在但当前版本中已不存在的任务(已删除或重命名的旧任务) if (_questIndex != null) { foreach (var savedQuestId in data.Quests.QuestStates.Keys) { if (!_questIndex.ContainsKey(savedQuestId)) Debug.LogWarning( $"[QuestManager] 存档中任务 '{savedQuestId}' 在当前版本不存在,已自动忽略" + "(可能为已删除或重命名的任务)。如属正常版本迭代,可忽略此警告。"); } } #endif // 存档中未记录的无前置任务,在新周目/首次加载后也保证可接取 InitializeAvailableQuests(); } // ── 私有辅助 ───────────────────────────────────────────────────────── private bool CanAccept(string questId) { // 状态必须为 Available;其余门槛检查委托给 GetQuestLockInfo(单一权威实现)。 if (GetState(questId) != QuestStateEnum.Available) return false; return !GetQuestLockInfo(questId).IsLocked; } /// /// 初始化(或修正)所有任务的 Available/Unavailable 状态。 /// 在 Awake(冷启动)和 OnLoad(存档恢复)后调用。 /// OnLoad 后 ISaveService 已就绪,会重新评估 prerequisiteFlags, /// 修正 Awake 期间因服务未就绪而被跳过的标志检查。 /// Active/Completed/Failed 状态来自存档,不重置。 /// private void InitializeAvailableQuests() { if (_questIndex == null) return; foreach (var q in _questIndex.Values) { var cur = GetState(q.questId); // 运行时终态来自存档,不重新评估 if (cur == QuestStateEnum.Active || cur == QuestStateEnum.Paused || cur == QuestStateEnum.Completed || cur == QuestStateEnum.Failed) continue; #if UNITY_EDITOR || DEVELOPMENT_BUILD // _affinityInitialized 为 true 说明是 OnLoad 后调用,Awake 期间不打此日志 bool isNewToSave = !_questStates.ContainsKey(q.questId) && _affinityInitialized; #endif // Available/Unavailable 均重新评估,确保 prerequisiteFlags 变更后状态正确 _questStates[q.questId] = MeetsPrerequisites(q) ? QuestStateEnum.Available : QuestStateEnum.Unavailable; #if UNITY_EDITOR || DEVELOPMENT_BUILD if (isNewToSave) Debug.Log( $"[QuestManager] 新增任务 '{q.questId}' 在存档中无记录(DLC/补丁新增)," + $"初始化状态 → {_questStates[q.questId]}。"); #endif } } /// /// 检查任务是否满足全部前置条件(不含状态和亲密度检查),用于 InitializeAvailableQuests 初始化。 /// 与 CanAccept 的区别:CanAccept 需要任务已经是 Available 且包含亲密度检查;此方法仅判断前置依赖是否达成。 /// 委托给 实现,不再重复前置逻辑。 /// private bool MeetsPrerequisites(QuestSO quest) { return CheckQuestDepsAndFlags(quest).Reason == QuestLockReason.None; } /// /// 检查任务的前置依赖(任务完成 + 世界标志),不含亲密度和状态检查。 /// 是 (经 GetQuestLockInfo 间接调用)、、 /// 共享的单一权威实现,消除三处重复逻辑。 /// private QuestLockInfo CheckQuestDepsAndFlags(QuestSO quest) { if (quest == null) return new QuestLockInfo { Reason = QuestLockReason.NotFound }; if (quest.prerequisites.HasAny) { if (quest.prerequisites.questDependencies != null) foreach (var dep in quest.prerequisites.questDependencies) { if (dep == null) continue; if (string.IsNullOrEmpty(dep.questId)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisites.questDependencies 含 questId 为空的条目,已跳过。"); #endif continue; } if (GetState(dep.questId) != QuestStateEnum.Completed) return new QuestLockInfo { Reason = QuestLockReason.RequiresQuest, Param = dep.questId }; } var fc = quest.prerequisites.flagCondition; if (fc.flags != null && fc.flags.Length > 0) { var svc = BaseGames.Core.ServiceLocator.GetOrDefault(); // ISaveService 未就绪(Awake 阶段)→ 保守跳过;OnLoad 后重新评估 if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc)) return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet }; } } else { // 旧版字段回退(兼容现有资产) #pragma warning disable CS0618 if (quest.prerequisiteQuests != null) foreach (var pre in quest.prerequisiteQuests) { if (pre == null) continue; if (string.IsNullOrEmpty(pre.questId)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteQuests 含 questId 为空的条目,已跳过该前置条件。"); #endif continue; } if (GetState(pre.questId) != QuestStateEnum.Completed) return new QuestLockInfo { Reason = QuestLockReason.RequiresQuest, Param = pre.questId }; } if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0) { var svc = BaseGames.Core.ServiceLocator.GetOrDefault(); if (svc != null) { if (!EvaluateFlagPrerequisites(quest.prerequisiteFlags, quest.prerequisiteFlagsLogic, svc)) return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet }; } #if UNITY_EDITOR || DEVELOPMENT_BUILD else Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteFlags 需要 ISaveService,但服务未注册,标志检查已跳过。"); #endif } #pragma warning restore CS0618 } return new QuestLockInfo { Reason = QuestLockReason.None }; } /// /// 根据 flags 数组和 logic 评估标志前置条件是否满足。 /// private static bool EvaluateFlagPrerequisites(string[] flags, BaseGames.Core.WorldStateFlagLogic logic, ISaveService svc) { if (logic == BaseGames.Core.WorldStateFlagLogic.Or) { foreach (var flag in flags) if (!string.IsNullOrEmpty(flag) && svc.GetFlag(flag)) return true; return false; } // And 逻辑(默认) foreach (var flag in flags) if (!string.IsNullOrEmpty(flag) && !svc.GetFlag(flag)) return false; return true; } /// /// 只读检查目标是否已完成(不修改任何状态)。 /// 供 DispatchEvent 失败条件评估使用,避免副作用。 /// private bool CheckObjective(string questId, QuestObjectiveSO obj) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning($"[QuestManager] 任务 '{questId}' 含 objectiveId 为空的目标,无法评估完成状态。"); #endif return false; } string compositeKey = GetCompositeKey(questId, obj.objectiveId); _objectiveStates.TryGetValue(compositeKey, out var s); // EvaluateCompletion 读取 s(可为 null/default),不写回 return obj.EvaluateCompletion(s ?? new QuestObjectiveState()); } /// /// 检查目标是否完成,并在首次达成时写回 completed 标志。 /// 仅由 IsReadyToComplete 调用,防止重复计为完成。 /// private bool IsObjectiveComplete(string questId, QuestObjectiveSO obj) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning($"[QuestManager] 任务 '{questId}' 含 objectiveId 为空的目标,无法评估完成状态。"); #endif return false; } string compositeKey = GetCompositeKey(questId, obj.objectiveId); if (!_objectiveStates.TryGetValue(compositeKey, out var s)) s = new QuestObjectiveState(); bool result = obj.EvaluateCompletion(s); // 首次达成时写回 completed 标志,避免 s 是本地临时对象时标志丢失 if (result && !s.completed) { s.completed = true; _objectiveStates[compositeKey] = s; } return result; } /// /// 将当前任务的各目标进度序列化为 objectiveId → count 字典。 /// 按 objectiveId 键存储,策划重排目标顺序后存档数据不会错位。 /// private Dictionary BuildObjectiveProgress(string questId) { var quest = GetQuestSO(questId); if (quest?.objectives == null) return new Dictionary(0); var dict = new Dictionary(quest.objectives.Length, System.StringComparer.Ordinal); foreach (var obj in quest.objectives) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; _objectiveStates.TryGetValue(GetCompositeKey(questId, obj.objectiveId), out var os); dict[obj.objectiveId] = os?.progressCount ?? 0; } return dict; } /// /// 构建当前任务各目标的 completed 标志字典(objectiveId → completed)。 /// 存入 , /// 防止版本迭代中 GetRequiredCount 变更后,进度数值与实际完成状态脱钩。 /// private Dictionary BuildObjectiveCompleted(string questId) { var quest = GetQuestSO(questId); if (quest?.objectives == null) return new Dictionary(0); var dict = new Dictionary(quest.objectives.Length, System.StringComparer.Ordinal); foreach (var obj in quest.objectives) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; _objectiveStates.TryGetValue(GetCompositeKey(questId, obj.objectiveId), out var os); dict[obj.objectiveId] = os?.completed ?? false; } return dict; } // ── 事件路由 ───────────────────────────────────────────────────────── // 统一分派入口:所有事件频道均路由到此方法,由各目标 SO 自行判断是否匹配。 // 新增目标类型只需在 QuestObjectiveSO 子类中 override TryHandleEvent, // 此处无需任何修改。 /// /// 字符串载荷重载(向后兼容)。内部将 string 包装为 后委托给强类型重载。 /// 所有外部调用方(订阅者、频道处理器)保持原有 string 签名,无需修改。 /// private void DispatchEvent(QuestEventType eventType, string payload) => DispatchEvent(eventType, new StringQuestPayload(payload)); /// /// 强类型载荷主实现。子类目标 SO 可 override TryHandleEvent(QuestEventType, IQuestEventPayload, QuestObjectiveState) /// 以直接获取结构化载荷,避免字符串解析开销。 /// private void DispatchEvent(QuestEventType eventType, IQuestEventPayload payload) { // ─ 第1次遍历:更新目标进度 + 同步收集失败候选 ───────────────────── // 将 CheckQuestFailConditions 内联到此处,避免对 _questStates 的独立第2次迭代。 List toFail = null; // 批量事件暂存:questId → 本帧内该任务所有更新过的目标事件(惰性分配,仅在有目标变更时创建) Dictionary> pendingBatchUpdates = null; foreach (var (qid, state) in _questStates) { // Paused 任务跳过所有事件处理(目标进度和失败条件均冻结), // 直到 ResumeQuest() 恢复后才继续推进。 if (state != QuestStateEnum.Active) continue; var quest = GetQuestSO(qid); if (quest == null) continue; // 目标进度处理 if (quest.objectives != null) { foreach (var obj in quest.objectives) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; // P3-B:前置目标顺序依赖检查——前置目标未完成时跳过本目标的事件路由 if (!string.IsNullOrEmpty(obj.prerequisiteObjectiveId)) { string prereqKey = GetCompositeKey(qid, obj.prerequisiteObjectiveId); if (!_objectiveStates.TryGetValue(prereqKey, out var prereqOs) || !prereqOs.completed) continue; } string compositeKey = GetCompositeKey(qid, obj.objectiveId); if (!_objectiveStates.TryGetValue(compositeKey, out var os)) os = _objectiveStates[compositeKey] = new QuestObjectiveState(); if (obj.TryHandleEvent(eventType, payload, os)) { var evt = new QuestObjectiveEvent { QuestId = qid, ObjectiveId = obj.objectiveId, Progress = os.progressCount, Required = obj.GetRequiredCount(), }; // 逐条事件:供向后兼容的逐目标监听者使用 Chan_ObjectiveUpdated?.Raise(evt); // 批量事件积累(同任务多目标聚合为一次广播,减少 UI 重绘) if (Chan_ObjectiveBatch != null) { pendingBatchUpdates ??= new Dictionary>(System.StringComparer.Ordinal); if (!pendingBatchUpdates.TryGetValue(qid, out var list)) pendingBatchUpdates[qid] = list = new List(capacity: 4); list.Add(evt); } } } } // 失败条件检查(同次遍历内完成,惰性分配 toFail 列表) // Paused 任务在此处已被跳过(见上方 state != Active continue), // 设计意图:暂停期间目标冻结,失败条件也不判定,恢复后再继续检查。 if (quest.canFail) { // P3-A:多失败条件支持——failConditions 数组中任意一个达成即失败 bool triggered = false; if (quest.failConditions != null && quest.failConditions.Length > 0) { foreach (var fc in quest.failConditions) { if (fc != null && CheckObjective(qid, fc)) { triggered = true; break; } } } else { // 向后兼容旧版单一 failCondition 字段(Obsolete,将在后续版本移除) #pragma warning disable CS0618 triggered = quest.failCondition != null && CheckObjective(qid, quest.failCondition); #pragma warning restore CS0618 } if (triggered) { toFail ??= new List(); toFail.Add(qid); } } } // 批量目标事件:每个有变更的任务广播一次聚合事件 if (pendingBatchUpdates != null) { foreach (var kv in pendingBatchUpdates) Chan_ObjectiveBatch.Raise(new QuestObjectiveBatchEvent { QuestId = kv.Key, Updates = kv.Value, }); } // 在遍历结束后统一应用失败状态,避免迭代中修改字典 if (toFail != null) { foreach (var qid in toFail) { _questStates[qid] = QuestStateEnum.Failed; _notifiedReadyQuests.Remove(qid); OnQuestStateChanged?.Invoke(qid, QuestStateEnum.Active, QuestStateEnum.Failed); Chan_QuestFailed?.Raise(qid); OnQuestFailed?.Invoke(qid); } } // ─ 第2次遍历:检查就绪通知(必须在失败状态写入后,避免刚失败的任务误报)─ foreach (var (qid, state) in _questStates) { if (state != QuestStateEnum.Active) continue; if (_notifiedReadyQuests.Contains(qid)) continue; if (IsReadyToComplete(qid)) { _notifiedReadyQuests.Add(qid); Chan_QuestReadyToComplete?.Raise(qid); OnQuestReadyToComplete?.Invoke(qid); } } } private QuestSO GetQuestSO(string id) => _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null; /// /// 优先从预缓存表查找 compositeKey(O(1),零字符串分配); /// 缓存未命中时 fallback 到 CompositeKey() 动态构建(运行时新增的目标)。 /// private string GetCompositeKey(string questId, string objectiveId) { if (_compositeKeyCache != null && _compositeKeyCache.TryGetValue((questId, objectiveId), out var cached)) return cached; return CompositeKey(questId, objectiveId); } /// /// 组合任务目标的复合键(格式 "{questId}.{objectiveId}")。 /// 全文统一通过此方法构建,objectiveId 为空时用 "__empty__" 占位保证唯一性。 /// private static string CompositeKey(string questId, string objectiveId) => string.IsNullOrEmpty(objectiveId) ? $"{questId}.__empty__" : $"{questId}.{objectiveId}"; // ── 编辑器自动维护 ──────────────────────────────────────────────────── #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。 /// [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 } }