P1-A: QuestManager.OnLoad Enum.TryParse failure warning (dev builds)
P1-B: SaveData.QuestState ObjectiveCompleted dict; BuildObjectiveCompleted
helper; OnSave/OnLoad wiring (DataVersion 2→3)
P2-A: Quest start/complete timestamps (_startedAtUtc/_completedAtUtc dicts;
StartedAtUtc/CompletedAtUtc in SaveData; AcceptQuest/CompleteQuest/
OnSave/OnLoad wiring)
P2-B: DialogueManager pending queue Queue→List + priority-eviction on full
(lowest-priority item evicted when higher-priority request arrives)
P2-C: NpcSO.localizationTable field; NpcSOEditor uses npc.localizationTable
in TryResolveNameKey, PingLocalizationFile, and button label
P3-A: QuestSO.failConditions[] multi-fail array; Obsolete failCondition;
DispatchEvent updates fail check to any-of-array logic with fallback
P3-B: QuestObjectiveSO.prerequisiteObjectiveId; DispatchEvent gates objective
event routing behind prerequisite completed check
P3-C: IQuestEventPayload interface + StringQuestPayload struct; QuestObjectiveSO
typed TryHandleEvent(IQuestEventPayload) overload; DispatchEvent string
overload delegates to typed IQuestEventPayload overload
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1243 lines
66 KiB
C#
1243 lines
66 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();
|
||
#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 决定)
|
||
// 优先用新版 conditionFlagEntries(支持 invert/NOT 取反),若为空则回退到旧版 conditionFlags
|
||
// saveService 未注入时降级:跳过标志检查,仅由 conditionQuest 决定分支
|
||
bool hasFlagEntries = branch.conditionFlagEntries != null && branch.conditionFlagEntries.Length > 0;
|
||
bool hasLegacyFlags = branch.conditionFlags != null && branch.conditionFlags.Length > 0;
|
||
bool hasFlagConds = hasFlagEntries || hasLegacyFlags;
|
||
|
||
if (hasFlagConds && saveService != null)
|
||
{
|
||
if (branch.conditionFlagsLogic == BaseGames.Core.WorldStateFlagLogic.Or)
|
||
{
|
||
conditionMet = false;
|
||
if (hasFlagEntries)
|
||
{
|
||
foreach (var entry in branch.conditionFlagEntries)
|
||
{
|
||
if (string.IsNullOrEmpty(entry.flagId)) continue;
|
||
bool raw = saveService.GetFlag(entry.flagId);
|
||
if (entry.invert ? !raw : raw) { conditionMet = true; break; }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (var flag in branch.conditionFlags)
|
||
{
|
||
if (!string.IsNullOrEmpty(flag) && saveService.GetFlag(flag))
|
||
{ conditionMet = true; break; }
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// AND(默认):全部标志均须满足(支持 invert 取反)
|
||
if (hasFlagEntries)
|
||
{
|
||
foreach (var entry in branch.conditionFlagEntries)
|
||
{
|
||
if (string.IsNullOrEmpty(entry.flagId)) continue;
|
||
bool raw = saveService.GetFlag(entry.flagId);
|
||
if (entry.invert ? raw : !raw) { conditionMet = false; break; }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (var flag in branch.conditionFlags)
|
||
{
|
||
if (string.IsNullOrEmpty(flag)) continue;
|
||
if (!saveService.GetFlag(flag)) { conditionMet = false; break; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
else if (hasFlagConds && saveService == null)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[QuestManager] 任务 '{questId}' 分支配置了标志条件,但 ISaveService 未注册," +
|
||
"标志条件已跳过(降级为仅 conditionQuest 判断)。");
|
||
}
|
||
#endif
|
||
if (!conditionMet) continue;
|
||
|
||
if (branch.nextQuest != null)
|
||
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
|
||
|
||
// 触发 NPC 完成反应对话(如 NPC 说"太好了,谢谢你!")
|
||
if (branch.npcDialogueSequence != null)
|
||
{
|
||
if (dialogueService != null)
|
||
dialogueService.StartDialogue(branch.npcDialogueSequence, quest.GiverNpcId ?? "");
|
||
else
|
||
Debug.LogWarning(
|
||
$"[QuestManager] 任务 '{questId}' 完成后需播放 NPC 对话 " +
|
||
$"'{branch.npcDialogueSequence.name}',但 DialogueService 未注册到 ServiceLocator,对话被跳过。");
|
||
}
|
||
}
|
||
}
|
||
|
||
public QuestStateEnum GetState(string questId)
|
||
=> _questStates.TryGetValue(questId, out var s) ? s : QuestStateEnum.Unavailable;
|
||
|
||
public bool IsReadyToComplete(string questId)
|
||
{
|
||
var quest = GetQuestSO(questId);
|
||
if (quest == null || GetState(questId) != QuestStateEnum.Active) return false;
|
||
if (quest.objectives == null) return true;
|
||
foreach (var obj in quest.objectives)
|
||
{
|
||
if (obj == null) continue;
|
||
if (!obj.IsOptional && !IsObjectiveComplete(questId, obj)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public int GetNpcAffinity(string npcId)
|
||
=> (!string.IsNullOrEmpty(npcId) && _npcRelations.TryGetValue(npcId, out int v)) ? v : 0;
|
||
|
||
/// <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;
|
||
|
||
bool hasNewFormat = saved.ObjectiveProgress != null && saved.ObjectiveProgress.Count > 0;
|
||
// DataVersion >= 2:新格式(objectiveId 键值对);DataVersion <= 1 或遗留存档:旧格式(按索引)
|
||
// Count > 0 作为无 DataVersion 字段时的兼容兜底
|
||
bool useNewFormat = saved.DataVersion >= 2 || hasNewFormat;
|
||
|
||
if (useNewFormat && saved.ObjectiveProgress != null)
|
||
{
|
||
// 新格式:objectiveId → count,重排顺序后仍可正确恢复
|
||
foreach (var obj in quest.objectives)
|
||
{
|
||
if (obj == null) continue;
|
||
if (!saved.ObjectiveProgress.TryGetValue(obj.objectiveId, out int count)) continue;
|
||
string compositeKey = GetCompositeKey(id, obj.objectiveId);
|
||
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
|
||
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
|
||
os.progressCount = count;
|
||
// DataVersion >= 3:从存档恢复 completed 标志(防止 GetRequiredCount 变更后判定漂移)
|
||
if (saved.ObjectiveCompleted != null &&
|
||
saved.ObjectiveCompleted.TryGetValue(obj.objectiveId, out bool done))
|
||
os.completed = done;
|
||
}
|
||
}
|
||
else if (saved.ProgressCounts != null
|
||
#pragma warning disable CS0618 // ProgressCounts 弃用字段:仅在此处读取用于旧存档迁移,不再写入
|
||
&& saved.ProgressCounts.Count > 0)
|
||
{
|
||
// 旧格式兼容(按数组索引):迁移旧存档用,不再写入新存档
|
||
for (int i = 0; i < quest.objectives.Length && i < saved.ProgressCounts.Count; i++)
|
||
{
|
||
var obj = quest.objectives[i];
|
||
if (obj == null) continue;
|
||
string compositeKey = GetCompositeKey(id, obj.objectiveId);
|
||
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
|
||
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
|
||
os.progressCount = saved.ProgressCounts[i];
|
||
}
|
||
}
|
||
#pragma warning restore CS0618
|
||
// DataVersion >= 3:恢复任务开始 / 完成时间戳(0 = 旧存档未记录,跳过)
|
||
if (saved.StartedAtUtc != 0) _startedAtUtc[id] = saved.StartedAtUtc;
|
||
if (saved.CompletedAtUtc != 0) _completedAtUtc[id] = saved.CompletedAtUtc;
|
||
}
|
||
// 从存档恢复 NPC 好感度缓存(供 CanAccept 门槛检查使用)
|
||
_npcRelations = new Dictionary<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 };
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
// P3-A:多失败条件支持——failConditions 数组中任意一个达成即失败
|
||
bool triggered = false;
|
||
if (quest.failConditions != null && quest.failConditions.Length > 0)
|
||
{
|
||
foreach (var fc in quest.failConditions)
|
||
{
|
||
if (fc != null && CheckObjective(qid, fc)) { triggered = true; break; }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 向后兼容旧版单一 failCondition 字段(Obsolete,将在后续版本移除)
|
||
#pragma warning disable CS0618
|
||
triggered = quest.failCondition != null && CheckObjective(qid, quest.failCondition);
|
||
#pragma warning restore CS0618
|
||
}
|
||
if (triggered)
|
||
{
|
||
toFail ??= new List<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
|
||
}
|
||
}
|