Files
zeling_v2/Assets/_Game/Scripts/Quest/QuestManager.cs
Joywayer 3c3ea1ead6 feat: Round 49 narrative systems improvements
QuestManager: extract CheckQuestDepsAndFlags shared method, simplify GetQuestLockInfo/CanAccept/MeetsPrerequisites; add GetQuestsInState+FilterQuests implementations; fix extra brace compile bug; add _pauseTimestamps logging; use actualDelta in ApplyAffinity event.

QuestSO: add depth>32 guard to HasPrerequisiteCycle and HasBranchCycle to prevent editor freeze on deep chains.

EventChainModule: replace FindObjectOfType with ServiceLocator.GetOrDefault in ForceExecute; add self-trigger flag detection (check 6) in ValidateAllChains using reflection.

DialogueVariantPreviewWindow: add matrix analysis section enumerating all 2^N flag combinations (N<=10) with table showing winning variant per combination.

WorldStateRegistry: LoadFromSave null guard on data.World sub-collections (P0 fix).

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

1115 lines
59 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>供需要直接订阅 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;
if (!CanAccept(questId)) return;
var oldState = GetState(questId);
_questStates[questId] = QuestStateEnum.Active;
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;
_notifiedReadyQuests.Remove(questId);
OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Completed);
Chan_QuestCompleted?.Raise(questId);
OnQuestCompleted?.Invoke(questId);
quest.reward?.Apply(rewardTarget);
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 决定分支
if (branch.conditionFlags != null && branch.conditionFlags.Length > 0
&& saveService != null)
{
if (branch.conditionFlagsLogic == BaseGames.Core.WorldStateFlagLogic.Or)
{
conditionMet = false;
foreach (var flag in branch.conditionFlags)
{
if (!string.IsNullOrEmpty(flag) && saveService.GetFlag(flag))
{
conditionMet = true;
break;
}
}
}
else
{
// AND默认全部标志均须满足
foreach (var flag in branch.conditionFlags)
{
if (string.IsNullOrEmpty(flag)) continue;
if (!saveService.GetFlag(flag)) { conditionMet = false; break; }
}
}
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else if (branch.conditionFlags != null && branch.conditionFlags.Length > 0
&& saveService == null)
{
Debug.LogWarning(
$"[QuestManager] 任务 '{questId}' 分支配置了 conditionFlags但 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);
}
#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 = 2,
Status = state.ToString(),
ObjectiveProgress = BuildObjectiveProgress(id),
};
}
// 将本地 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;
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;
}
}
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
}
// 从存档恢复 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 已就绪,会重新评估 prerequisiteFlags
/// 修正 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 均重新评估,确保 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
}
}
/// <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.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>();
// 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<ISaveService>();
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 };
}
/// <inheritdoc cref="IQuestManager.GetQuestsInState"/>
public System.Collections.Generic.IReadOnlyList<string> GetQuestsInState(QuestStateEnum state)
{
var result = new List<string>();
foreach (var (id, s) in _questStates)
if (s == state) result.Add(id);
return result;
}
/// <inheritdoc cref="IQuestManager.FilterQuests"/>
public System.Collections.Generic.IReadOnlyList<string> FilterQuests(System.Func<string, QuestStateEnum, bool> predicate)
{
if (predicate == null) return System.Array.Empty<string>();
var result = new List<string>();
foreach (var (id, state) in _questStates)
if (predicate(id, state)) result.Add(id);
return result;
}
/// <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;
}
// ── 事件路由 ─────────────────────────────────────────────────────────
// 统一分派入口:所有事件频道均路由到此方法,由各目标 SO 自行判断是否匹配。
// 新增目标类型只需在 QuestObjectiveSO 子类中 override TryHandleEvent
// 此处无需任何修改。
private void DispatchEvent(QuestEventType eventType, string 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;
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 && quest.failCondition != null && CheckObjective(qid, quest.failCondition))
{
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;
_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 后序遍历检测 prerequisiteQuests 中是否存在循环引用。
/// 在编辑器 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 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<string>());
}
#endif
}
}