Files
zeling_v2/Assets/_Game/Scripts/Quest/QuestManager.cs
Joywayer 943178cbc1 fix: Round 54 priority dequeue, onComplete callback, prerequisiteObjectiveId validation, localizationTable guard, FailQuest timestamp, remove empty ValidateBranchDialogueKeys
- DialogueManager.EndDialogue: dequeue by max-priority index instead of FIFO index-0
- DialogueManager.EndDialogue: fire _onCompleteCallback on normal end (was only in ForceEnd)
- NpcSO.OnValidate: auto-restore localizationTable to 'UI' if cleared
- QuestSO.ValidateObjectiveIds: validate prerequisiteObjectiveId references exist in same quest
- QuestSO.OnValidate: remove call to empty ValidateBranchDialogueKeys stub + remove the stub itself
- QuestManager.DispatchEvent toFail loop: write _completedAtUtc timestamp on quest failure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 07:20:55 +08:00

1152 lines
61 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using QuestStateEnum = BaseGames.Core.Events.QuestState;
namespace BaseGames.Quest
{
/// <summary>
/// 运行时任务管理器(架构 22_QuestChallengeModule §5
/// 挂在 Persistent 场景 [GameManagers] 下。
/// 事件驱动追踪目标进度,不主动轮询。
/// 实现 ISaveable通过 SaveManager 持久化任务状态。
///
/// _allQuests 由编辑器 OnValidate / "刷新任务列表" 右键菜单自动填充,
/// 无需策划人员手动拖入 ScriptableObject。
/// </summary>
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_EnemyDiedpayload = enemyIdstring。敌人死亡时由战斗系统广播驱动击败类目标进度。")]
[SerializeField] private StringEventChannelSO _onEnemyDied;
[Tooltip("EVT_CollectiblePickuppayload = itemIdstring。拾取物品时广播驱动收集类目标进度同时也作为 RewardSO 物品发放频道。")]
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
[Tooltip("EVT_SceneLoadedpayload = sceneNamestring。场景切换完成时广播驱动到达类目标进度。")]
[SerializeField] private StringEventChannelSO _onSceneLoaded;
[Tooltip("EVT_NpcDialogueCompletedpayload = npcIdstring。DialogueManager 播完一段对话后广播,驱动对话类目标进度。")]
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted;
[Tooltip("EVT_SkillUsedpayload = AbilityType.ToString()string。玩家使用技能时广播驱动技能使用类目标进度。")]
[SerializeField] private StringEventChannelSO _onSkillUsed;
[Tooltip("EVT_AreaReachedpayload = markerTagstring。TriggerZone 组件在玩家进入碰撞体时广播,驱动精确区域到达类目标进度。")]
[SerializeField] private StringEventChannelSO _onAreaReached;
[Header("Event Channels广播— 未设置注册表时生效")]
[Tooltip("EVT_QuestStartedpayload = questId。AcceptQuest 成功后广播,供任务日志 UI 新增条目、任务追踪 HUD 激活等监听。")]
[SerializeField] private StringEventChannelSO _onQuestStarted;
[Tooltip("EVT_QuestCompletedpayload = questId。CompleteQuest 成功后广播,供成就系统、任务日志、剧情触发器等监听。")]
[SerializeField] private StringEventChannelSO _onQuestCompleted;
[Tooltip("EVT_QuestFailedpayload = questId。失败条件触发后广播供失败提示 UI、任务日志、剧情触发器等监听。")]
[SerializeField] private StringEventChannelSO _onQuestFailed;
[Tooltip("EVT_QuestObjectiveUpdatedpayload = QuestObjectiveEventquestId + progress。目标进度变化时广播供任务追踪 HUD 更新进度条等监听。")]
[SerializeField] private QuestObjectiveEventChannelSO _onObjectiveUpdated;
[Tooltip("EVT_QuestObjectiveBatchUpdated同帧内同一任务多目标聚合后广播一次payload = QuestObjectiveBatchEvent。\n" +
"供追踪 HUD 订阅以避免同帧多次重绘;留空则仅使用逐条 EVT_QuestObjectiveUpdated。")]
[SerializeField] private QuestObjectiveBatchEventChannelSO _onObjectiveBatchUpdated;
[Tooltip("EVT_NpcAffinityChangedpayload = NpcAffinityEventnpcId + delta + newTotal 强类型,零字符串解析),供 UI/好感度系统监听。")]
[SerializeField] private NpcAffinityEventChannelSO _onNpcAffinityChanged;
[Tooltip("EVT_DialogueKeyUnlockedpayload = unlockDialogueKey供 NPC 台词系统监听。")]
[SerializeField] private StringEventChannelSO _onDialogueKeyUnlocked;
[Tooltip("EVT_QuestReadyToCompletepayload = questId。目标全部达成、可回去交任务时广播一次去重。\n" +
"供任务日志 UI 高亮、地图标记、提示 HUD 等监听。")]
[SerializeField] private StringEventChannelSO _onQuestReadyToComplete;
[Tooltip("EVT_QuestAbandonedpayload = questId。玩家主动放弃任务Active → Available时广播。\n" +
"供任务日志 UI 移除追踪条目、提示 HUD 清空等监听。")]
[SerializeField] private StringEventChannelSO _onQuestAbandoned;
[Tooltip("EVT_QuestPausedpayload = questId。PauseQuest 成功Active → Paused后广播。\n" +
"供任务日志 UI 更新状态标记、UI/成就系统监听。")]
[SerializeField] private StringEventChannelSO _onQuestPaused;
[Tooltip("EVT_QuestResumedpayload = 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<string, QuestStateEnum> _questStates = new();
private readonly Dictionary<string, QuestObjectiveState> _objectiveStates = new();
/// <summary>questId → QuestSO 快速查找表(由 Awake 构建,将 GetQuestSO O(n) 降为 O(1))。</summary>
private Dictionary<string, QuestSO> _questIndex;
/// <summary>
/// (questId, objectiveId) → compositeKey 预缓存表。
/// 由 Awake 与 _questIndex 同步构建,消除 DispatchEvent 高频内循环的字符串拼接分配。
/// key = (questId, objectiveId)value = CompositeKey(questId, objectiveId)。
/// </summary>
private Dictionary<(string, string), string> _compositeKeyCache;
private readonly CompositeDisposable _subs = new();
/// <summary>npcId → 好感度数值(从 SaveData.World.NpcRelations 同步,由 CompleteQuest 更新)。</summary>
private Dictionary<string, int> _npcRelations = new();
/// <summary>
/// OnLoad 完成后置为 true标记好感度字典已从存档初始化。
/// 防止 CanAccept 在 OnLoad 前被调用时,对空字典产生错误的通过判定。
/// </summary>
private bool _affinityInitialized;
/// <summary>已广播过 EVT_QuestReadyToComplete 的任务 ID 集合(防重复通知)。
/// 任务完成/失败时从集合移除,再次激活后可重新通知。</summary>
private readonly HashSet<string> _notifiedReadyQuests = new();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
/// <summary>任务暂停时记录的 realtimeSinceStartup供 ResumeQuest 计算暂停持续时长并日志输出)。</summary>
private readonly Dictionary<string, float> _pauseTimestamps = new();
#endif
/// <summary>questId → 接取时间Unix 秒UTC。用于存档和统计分析。</summary>
private readonly Dictionary<string, long> _startedAtUtc = new();
/// <summary>questId → 完成时间Unix 秒UTC。0 表示未完成或旧存档未记录。</summary>
private readonly Dictionary<string, long> _completedAtUtc = new();
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
public StringEventChannelSO QuestStartedChannel => _onQuestStarted;
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
public StringEventChannelSO QuestCompletedChannel => _onQuestCompleted;
/// <summary>供 SaveManager 迭代的任务状态字典(只读视图)。</summary>
public IReadOnlyDictionary<string, QuestStateEnum> QuestStates => _questStates;
private void Awake()
{
if (BaseGames.Core.ServiceLocator.GetOrDefault<IQuestManager>() != null) { Destroy(gameObject); return; }
BaseGames.Core.ServiceLocator.Register<IQuestManager>(this);
// 构建任务字典索引,将 GetQuestSO 变为 O(1)
_questIndex = new Dictionary<string, QuestSO>(_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<string>(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<BaseGames.Core.Save.ISaveableRegistry>()?.Register(this);
}
private void OnDisable()
{
_subs.Clear();
BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Save.ISaveableRegistry>()?.Unregister(this);
}
private void OnDestroy()
{
BaseGames.Core.ServiceLocator.Unregister<IQuestManager>(this);
}
// ── IQuestEventSource代码订阅入口无需直接持有 SO 频道)──────────────
/// <inheritdoc/>
public event System.Action<string> OnQuestStarted;
/// <inheritdoc/>
public event System.Action<string> OnQuestCompleted;
/// <inheritdoc/>
public event System.Action<string> OnQuestFailed;
/// <inheritdoc/>
public event System.Action<string> OnQuestAbandoned;
/// <inheritdoc/>
public event System.Action<string> OnQuestPaused;
/// <inheritdoc/>
public event System.Action<string> OnQuestResumed;
/// <inheritdoc/>
public event System.Action<string> OnQuestReadyToComplete;
/// <inheritdoc/>
public event System.Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
// ── 公共 API ──────────────────────────────────────────────────────────
/// <summary>NPC 接受任务时调用。</summary>
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<BaseGames.Dialogue.IDialogueService>();
if (dialogueService != null)
dialogueService.StartDialogue(quest.acceptDialogueSequence, quest.GiverNpcId ?? "");
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else
Debug.LogWarning(
$"[QuestManager] 任务 '{questId}' 接取时需播放对话 '{quest.acceptDialogueSequence.name}'" +
"但 IDialogueService 未注册,对话被跳过。");
#endif
}
}
/// <summary>
/// 玩家主动放弃进行中的任务Active → Available/Unavailable
/// 清除已积累的目标进度,广播 EVT_QuestAbandoned
/// 任务回到可重新接取状态(前置满足 → Available否则 → Unavailable
/// </summary>
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);
}
/// <summary>
/// 暂停进行中的任务Active → Paused
/// 暂停期间:目标进度事件不推进,失败条件不判定。
/// 通过 ResumeQuest 恢复。非 Active 状态调用无效。
/// </summary>
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);
}
/// <summary>
/// 恢复已暂停的任务Paused → Active
/// 非 Paused 状态调用无效。
/// </summary>
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);
}
/// <summary>NPC 完成任务时调用。</summary>
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);
}
/// <summary>将奖励好感度更新到本地缓存并广播事件。上限由 NpcSO.maxAffinity 控制(>0 时生效)。</summary>
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
});
}
/// <summary>广播对话解锁事件,供 NPC 台词管理系统监听并切换新对话集。</summary>
private void UnlockDialogueKey(QuestSO quest)
{
if (quest.reward == null || string.IsNullOrEmpty(quest.reward.unlockDialogueKey)) return;
Chan_DialogueKeyUnlocked?.Raise(quest.reward.unlockDialogueKey);
}
/// <summary>
/// 解锁满足条件的后续任务分支,并触发相应 NPC 完成反应对话。
/// 允许同时满足多个分支(并行支线解锁)。
/// </summary>
private void UnlockBranches(string questId, QuestSO quest)
{
if (quest.branches == null) return;
var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Dialogue.IDialogueService>();
var saveService = BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.ISaveService>();
foreach (var branch in quest.branches)
{
// 任务条件
bool conditionMet = branch.conditionQuest == null ||
GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed;
if (!conditionMet) continue;
// 世界状态标志条件And/Or 由 conditionFlagsLogic 决定)
// saveService 未注入时降级:跳过标志检查,仅由 conditionQuest 决定分支
bool hasFlagEntries = branch.conditionFlagEntries != null && branch.conditionFlagEntries.Length > 0;
if (hasFlagEntries && saveService != null)
{
if (branch.conditionFlagsLogic == BaseGames.Core.WorldStateFlagLogic.Or)
{
conditionMet = false;
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
{
// AND默认全部标志均须满足支持 invert 取反)
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; }
}
}
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else if (hasFlagEntries && 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;
/// <summary>
/// 返回任务无法被接取的原因(本地化 Key 格式)。
/// 内部委托给 <see cref="GetQuestLockInfo"/> 实现,保持向后兼容。
/// </summary>
public string GetQuestLockReason(string questId) => GetQuestLockInfo(questId).ToLocalizationKey();
/// <inheritdoc cref="IQuestManager.GetQuestLockInfo"/>
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);
}
/// <inheritdoc cref="IQuestManager.GetQuestsInState"/>
public IReadOnlyList<string> GetQuestsInState(QuestStateEnum state)
{
var result = new List<string>();
FillQuestsInState(state, result);
return result;
}
/// <inheritdoc cref="IQuestManager.FilterQuests"/>
public IReadOnlyList<string> FilterQuests(Func<string, QuestStateEnum, bool> predicate)
{
if (predicate == null) return Array.Empty<string>();
var result = new List<string>();
FillFilterQuests(predicate, result);
return result;
}
/// <inheritdoc cref="IQuestManager.FillQuestsInState"/>
public void FillQuestsInState(QuestStateEnum state, List<string> result)
{
if (result == null) return;
result.Clear();
foreach (var (id, s) in _questStates)
if (s == state) result.Add(id);
}
/// <inheritdoc cref="IQuestManager.FillFilterQuests"/>
public void FillFilterQuests(Func<string, QuestStateEnum, bool> predicate, List<string> 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 ────────────────────────────────────────────────────
/// <summary>
/// 将任务重置为 Available前置满足或 Unavailable前置未满足并清除目标进度。
/// 仅供开发/调试使用IQuestDebugger不广播 QuestStarted / QuestCompleted 等运行时事件。
/// 正式发布构建中此方法不存在;调用方通过 <c>(qm as IQuestDebugger)?.ResetQuest(id)</c> 使用。
/// </summary>
/// <param name="questId">要重置的任务 ID。</param>
/// <param name="rollbackAffinity">true = 同步扣回本任务发放的好感度增量,防止反复完成累积。</param>
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<QuestStateEnum>(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;
if (saved.ObjectiveProgress != null)
{
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;
if (saved.ObjectiveCompleted != null &&
saved.ObjectiveCompleted.TryGetValue(obj.objectiveId, out bool done))
os.completed = done;
}
}
if (saved.StartedAtUtc != 0) _startedAtUtc[id] = saved.StartedAtUtc;
if (saved.CompletedAtUtc != 0) _completedAtUtc[id] = saved.CompletedAtUtc;
}
// 从存档恢复 NPC 好感度缓存(供 CanAccept 门槛检查使用)
_npcRelations = new Dictionary<string, int>(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;
}
/// <summary>
/// 初始化(或修正)所有任务的 Available/Unavailable 状态。
/// 在 Awake冷启动和 OnLoad存档恢复后调用。
/// OnLoad 后 ISaveService 已就绪,会重新评估 prerequisites.flagCondition.flags
/// 修正 Awake 期间因服务未就绪而被跳过的标志检查。
/// Active/Completed/Failed 状态来自存档,不重置。
/// </summary>
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 均重新评估,确保 prerequisites.flagCondition.flags 变更后状态正确
_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
}
}
/// <summary>
/// 检查任务是否满足全部前置条件(不含状态和亲密度检查),用于 InitializeAvailableQuests 初始化。
/// 与 CanAccept 的区别CanAccept 需要任务已经是 Available 且包含亲密度检查;此方法仅判断前置依赖是否达成。
/// 委托给 <see cref="CheckQuestDepsAndFlags"/> 实现,不再重复前置逻辑。
/// </summary>
private bool MeetsPrerequisites(QuestSO quest)
{
return CheckQuestDepsAndFlags(quest).Reason == QuestLockReason.None;
}
/// <summary>
/// 检查任务的前置依赖(任务完成 + 世界标志),不含亲密度和状态检查。
/// 是 <see cref="CanAccept"/>(经 GetQuestLockInfo 间接调用)、<see cref="MeetsPrerequisites"/>、
/// <see cref="GetQuestLockInfo"/> 共享的单一权威实现,消除三处重复逻辑。
/// </summary>
private QuestLockInfo CheckQuestDepsAndFlags(QuestSO quest)
{
if (quest == null) return new QuestLockInfo { Reason = QuestLockReason.NotFound };
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>();
// ISaveService 未就绪Awake 阶段)→ 保守跳过OnLoad 后重新评估
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc))
return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet };
}
return new QuestLockInfo { Reason = QuestLockReason.None };
}
/// <summary>
/// 根据 flags 数组和 logic 评估标志前置条件是否满足。
/// </summary>
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;
}
/// <summary>
/// 只读检查目标是否已完成(不修改任何状态)。
/// 供 DispatchEvent 失败条件评估使用,避免副作用。
/// </summary>
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());
}
/// <summary>
/// 检查目标是否完成,并在首次达成时写回 completed 标志。
/// 仅由 IsReadyToComplete 调用,防止重复计为完成。
/// </summary>
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;
}
/// <summary>
/// 将当前任务的各目标进度序列化为 objectiveId → count 字典。
/// 按 objectiveId 键存储,策划重排目标顺序后存档数据不会错位。
/// </summary>
private Dictionary<string, int> BuildObjectiveProgress(string questId)
{
var quest = GetQuestSO(questId);
if (quest?.objectives == null) return new Dictionary<string, int>(0);
var dict = new Dictionary<string, int>(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;
}
/// <summary>
/// 构建当前任务各目标的 completed 标志字典objectiveId → completed
/// 存入 <see cref="BaseGames.Core.Save.QuestState.ObjectiveCompleted"/>
/// 防止版本迭代中 <c>GetRequiredCount</c> 变更后,进度数值与实际完成状态脱钩。
/// </summary>
private Dictionary<string, bool> BuildObjectiveCompleted(string questId)
{
var quest = GetQuestSO(questId);
if (quest?.objectives == null) return new Dictionary<string, bool>(0);
var dict = new Dictionary<string, bool>(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
// 此处无需任何修改。
/// <summary>
/// 字符串载荷重载(向后兼容)。内部将 string 包装为 <see cref="StringQuestPayload"/> 后委托给强类型重载。
/// 所有外部调用方(订阅者、频道处理器)保持原有 string 签名,无需修改。
/// </summary>
private void DispatchEvent(QuestEventType eventType, string payload)
=> DispatchEvent(eventType, new StringQuestPayload(payload));
/// <summary>
/// 强类型载荷主实现。子类目标 SO 可 override <c>TryHandleEvent(QuestEventType, IQuestEventPayload, QuestObjectiveState)</c>
/// 以直接获取结构化载荷,避免字符串解析开销。
/// </summary>
private void DispatchEvent(QuestEventType eventType, IQuestEventPayload payload)
{
// ─ 第1次遍历更新目标进度 + 同步收集失败候选 ─────────────────────
// 将 CheckQuestFailConditions 内联到此处,避免对 _questStates 的独立第2次迭代。
List<string> toFail = null;
// 批量事件暂存questId → 本帧内该任务所有更新过的目标事件(惰性分配,仅在有目标变更时创建)
Dictionary<string, List<QuestObjectiveEvent>> 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<string, List<QuestObjectiveEvent>>(System.StringComparer.Ordinal);
if (!pendingBatchUpdates.TryGetValue(qid, out var list))
pendingBatchUpdates[qid] = list = new List<QuestObjectiveEvent>(capacity: 4);
list.Add(evt);
}
}
}
}
// 失败条件检查(同次遍历内完成,惰性分配 toFail 列表)
// Paused 任务在此处已被跳过(见上方 state != Active continue
// 设计意图:暂停期间目标冻结,失败条件也不判定,恢复后再继续检查。
if (quest.canFail)
{
bool triggered = false;
if (quest.failConditions != null)
{
foreach (var fc in quest.failConditions)
{
if (fc != null && CheckObjective(qid, fc)) { triggered = true; break; }
}
}
if (triggered)
{
toFail ??= new List<string>();
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;
_completedAtUtc[qid] = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
_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;
/// <summary>
/// 优先从预缓存表查找 compositeKeyO(1),零字符串分配);
/// 缓存未命中时 fallback 到 CompositeKey() 动态构建(运行时新增的目标)。
/// </summary>
private string GetCompositeKey(string questId, string objectiveId)
{
if (_compositeKeyCache != null &&
_compositeKeyCache.TryGetValue((questId, objectiveId), out var cached))
return cached;
return CompositeKey(questId, objectiveId);
}
/// <summary>
/// 组合任务目标的复合键(格式 "{questId}.{objectiveId}")。
/// 全文统一通过此方法构建objectiveId 为空时用 "__empty__" 占位保证唯一性。
/// </summary>
private static string CompositeKey(string questId, string objectiveId)
=> string.IsNullOrEmpty(objectiveId)
? $"{questId}.__empty__"
: $"{questId}.{objectiveId}";
// ── 编辑器自动维护 ────────────────────────────────────────────────────
#if UNITY_EDITOR
/// <summary>
/// 编辑器中每次属性变更时自动同步项目内所有 QuestSO并校验前置任务是否存在循环引用。
/// </summary>
private void OnValidate()
{
EditorRefreshQuestList();
ValidatePrerequisites();
}
[ContextMenu("刷新任务列表")]
private void EditorRefreshQuestList()
{
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO");
var list = new List<QuestSO>(guids.Length);
foreach (var guid in guids)
{
string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var q = UnityEditor.AssetDatabase.LoadAssetAtPath<QuestSO>(path);
if (q != null) list.Add(q);
}
_allQuests = list.ToArray();
UnityEditor.EditorUtility.SetDirty(this);
}
#endif
#if UNITY_EDITOR || DEVELOPMENT_BUILD
/// <summary>
/// 通过 DFS 后序遍历检测 prerequisites.questDependencies 中是否存在循环引用。
/// 在编辑器 OnValidate 及开发构建 Awake 时调用,发现问题立即打 LogError。
/// </summary>
[UnityEngine.ContextMenu("校验前置任务循环引用")]
private void ValidatePrerequisites()
{
if (_allQuests == null) return;
// 先建立 id → SO 快速查找表
var index = new Dictionary<string, QuestSO>(_allQuests.Length);
foreach (var q in _allQuests)
{
if (q == null || string.IsNullOrEmpty(q.questId)) continue;
index[q.questId] = q;
}
// 三色 DFS0=未访问 1=灰栈中2=黑(已完成)
var color = new Dictionary<string, int>(index.Count);
bool HasCycle(string startId, List<string> 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 prereqDeps = index[startId].prerequisites.questDependencies;
if (prereqDeps != null)
{
foreach (var pre in prereqDeps)
{
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<string>());
}
#endif
}
}