- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop - QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip - IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info) - IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it - IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event - QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions - DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix) - DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks - DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match - WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API - DialogueModule: List badge shows warning indicator for unconditional-shadowing variants - DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1149 lines
60 KiB
C#
1149 lines
60 KiB
C#
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_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<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();
|
||
/// <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);
|
||
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);
|
||
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}。");
|
||
#endif
|
||
newTotal = maxAffinity;
|
||
}
|
||
|
||
_npcRelations[quest.GiverNpcId] = newTotal;
|
||
Chan_NpcAffinityChanged?.Raise(new NpcAffinityEvent
|
||
{
|
||
npcId = quest.GiverNpcId,
|
||
delta = quest.reward.affinityBonus,
|
||
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 };
|
||
|
||
// 好感度门槛检查
|
||
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}" };
|
||
}
|
||
|
||
// 前置任务依赖检查(新版优先,回退旧版)
|
||
#pragma warning disable CS0618
|
||
var deps = quest.prerequisites.HasAny ? quest.prerequisites.questDependencies : quest.prerequisiteQuests;
|
||
#pragma warning restore CS0618
|
||
if (deps != null)
|
||
{
|
||
foreach (var dep in deps)
|
||
{
|
||
if (dep == null || string.IsNullOrEmpty(dep.questId)) continue;
|
||
if (GetState(dep.questId) != QuestStateEnum.Completed)
|
||
return new QuestLockInfo { Reason = QuestLockReason.RequiresQuest, Param = dep.questId };
|
||
}
|
||
}
|
||
|
||
// 世界标志条件检查
|
||
var fc = quest.prerequisites.HasAny ? quest.prerequisites.flagCondition : default;
|
||
#pragma warning disable CS0618
|
||
if (!quest.prerequisites.HasAny && quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0)
|
||
fc = new QuestPrerequisite.FlagCondition
|
||
{ flags = quest.prerequisiteFlags, logic = quest.prerequisiteFlagsLogic };
|
||
#pragma warning restore CS0618
|
||
|
||
if (fc.flags != null && fc.flags.Length > 0)
|
||
{
|
||
var svc = BaseGames.Core.ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc))
|
||
return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet };
|
||
}
|
||
|
||
return new QuestLockInfo { Reason = QuestLockReason.None }; // 无锁定,任务可接取
|
||
}
|
||
|
||
#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)
|
||
{
|
||
if (GetState(questId) != QuestStateEnum.Available) return false;
|
||
var quest = GetQuestSO(questId);
|
||
if (quest == null) return false;
|
||
|
||
// 好感度门槛检查:_npcRelations 仅在 OnLoad 后有效
|
||
if (quest.minAffinityToAccept > 0 && !string.IsNullOrEmpty(quest.GiverNpcId))
|
||
{
|
||
if (!_affinityInitialized)
|
||
{
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
Debug.LogWarning(
|
||
$"[QuestManager] CanAccept: 好感度数据尚未从存档加载(OnLoad 未完成)," +
|
||
$"任务 '{questId}' 的好感度门槛检查暂时拒绝接取。");
|
||
#endif
|
||
return false;
|
||
}
|
||
_npcRelations.TryGetValue(quest.GiverNpcId, out int affinity);
|
||
if (affinity < quest.minAffinityToAccept) return false;
|
||
}
|
||
|
||
// 前置条件检查:优先使用新版 prerequisites 结构,回退到旧版字段
|
||
if (quest.prerequisites.HasAny)
|
||
{
|
||
if (quest.prerequisites.questDependencies != null)
|
||
foreach (var dep in quest.prerequisites.questDependencies)
|
||
{
|
||
if (dep == null) continue;
|
||
if (GetState(dep.questId) != QuestStateEnum.Completed) return false;
|
||
}
|
||
var fc = quest.prerequisites.flagCondition;
|
||
if (fc.flags != null && fc.flags.Length > 0)
|
||
{
|
||
var svc = BaseGames.Core.ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc)) return false;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 旧版字段回退(兼容现有资产)
|
||
if (quest.prerequisiteQuests != null)
|
||
foreach (var pre in quest.prerequisiteQuests)
|
||
{
|
||
if (pre == null) continue;
|
||
if (GetState(pre.questId) != QuestStateEnum.Completed) return false;
|
||
}
|
||
|
||
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 false;
|
||
}
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
else Debug.LogWarning(
|
||
$"[QuestManager] CanAccept: 任务 '{questId}' 的 prerequisiteFlags 需要 ISaveService,但服务未注册,标志检查已跳过。");
|
||
#endif
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <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="QuestPrerequisite"/> 结构;若未配置则回退到旧版字段。
|
||
/// </summary>
|
||
private bool MeetsPrerequisites(QuestSO quest)
|
||
{
|
||
if (quest == null) return false;
|
||
|
||
if (quest.prerequisites.HasAny)
|
||
{
|
||
// 新版前置结构:questDependencies + flagCondition
|
||
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 false;
|
||
}
|
||
|
||
var fc = quest.prerequisites.flagCondition;
|
||
if (fc.flags != null && fc.flags.Length > 0)
|
||
{
|
||
var svc = BaseGames.Core.ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc)) return false;
|
||
// ISaveService 未就绪(Awake 阶段)→ 保守跳过;OnLoad 后重新评估
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 旧版字段回退(兼容现有资产)
|
||
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 false;
|
||
}
|
||
|
||
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 false;
|
||
}
|
||
// ISaveService 未就绪(Awake 阶段)→ 保守跳过;OnLoad 后 InitializeAvailableQuests 重新评估
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <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>
|
||
/// 优先从预缓存表查找 compositeKey(O(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;
|
||
}
|
||
|
||
// 三色 DFS:0=未访问 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
|
||
}
|
||
}
|