Files
zeling_v2/Assets/_Game/Scripts/Quest/QuestManager.cs
Joywayer 446fd5dcd0 feat: Add WorldStateFlagAttribute and custom property drawer for enhanced dialogue management
- Implemented WorldStateFlagAttribute to mark string fields as world state flags.
- Created NarrativeNPCEditor for custom inspector to visualize dialogue version activation states.
- Developed WorldStateFlagDrawer to provide dropdown menu for known flags in the inspector.
- Introduced ActorModule for managing DialogueActorSO assets, including viewing, creating, and deleting actors.
- Added DialogueModule for managing DialogueSequenceSO assets with detailed previews and action bars.
- Established QuestModule for managing QuestSO assets, including objectives and branches.
- Implemented QuestManagerPostprocessor to automatically refresh QuestManager's quest list on asset changes.
2026-05-24 00:36:11 +08:00

407 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using QuestStateEnum = BaseGames.Core.Events.QuestState;
namespace BaseGames.Quest
{
/// <summary>
/// 运行时任务管理器(架构 22_QuestChallengeModule §5
/// 挂在 Persistent 场景 [GameManagers] 下。
/// 事件驱动追踪目标进度,不主动轮询。
/// 实现 ISaveable通过 SaveManager 持久化任务状态。
///
/// _allQuests 由编辑器 OnValidate / "刷新任务列表" 右键菜单自动填充,
/// 无需策划人员手动拖入 ScriptableObject。
/// </summary>
public class QuestManager : MonoBehaviour, ISaveable, IQuestManager
{
// ── Inspector ────────────────────────────────────────────────────────
[Tooltip("所有 QuestSO 资产。编辑器会自动同步,无需手动维护。")]
[SerializeField] private QuestSO[] _allQuests;
[Header("Event Channels监听")]
[SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDiedenemyId
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickupitemId
[SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoadedsceneName
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompletednpcId
[SerializeField] private StringEventChannelSO _onSkillUsed; // EVT_SkillUsedabilityType.ToString()
[Header("Event Channels广播")]
[SerializeField] private StringEventChannelSO _onQuestStarted; // questId
[SerializeField] private StringEventChannelSO _onQuestCompleted; // questId
[SerializeField] private StringEventChannelSO _onQuestFailed; // questId
[SerializeField] private QuestObjectiveEventChannelSO _onObjectiveUpdated;
// ── 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;
private readonly CompositeDisposable _subs = new();
public StringEventChannelSO OnQuestStarted => _onQuestStarted;
public StringEventChannelSO OnQuestCompleted => _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;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
ValidateQuestIds();
#endif
}
#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()
{
_onEnemyDied?.Subscribe(HandleEnemyDefeated).AddTo(_subs);
_onCollectiblePickup?.Subscribe(HandleItemCollected).AddTo(_subs);
_onSceneLoaded?.Subscribe(HandleSceneLoaded).AddTo(_subs);
_onNpcDialogueCompleted?.Subscribe(HandleNpcDialogue).AddTo(_subs);
_onSkillUsed?.Subscribe(HandleSkillUsed).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);
}
// ── 公共 API ──────────────────────────────────────────────────────────
/// <summary>NPC 接受任务时调用。</summary>
public void AcceptQuest(string questId)
{
if (!CanAccept(questId)) return;
_questStates[questId] = QuestStateEnum.Active;
_onQuestStarted?.Raise(questId);
}
/// <summary>NPC 完成任务时调用。</summary>
public void CompleteQuest(string questId, IRewardTarget rewardTarget)
{
if (!IsReadyToComplete(questId)) return;
var quest = GetQuestSO(questId);
quest.reward?.Apply(rewardTarget);
_questStates[questId] = QuestStateEnum.Completed;
_onQuestCompleted?.Raise(questId);
// 解锁后续任务(分支)
// conditionQuest == null 表示默认分支conditionQuest != null 则要求该任务已完成。
// 不 break —— 允许同时解锁多个后续任务(如完成任务后同时开放多条支线)。
if (quest.branches != null)
{
foreach (var branch in quest.branches)
{
if (branch.conditionQuest == null ||
GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed)
{
if (branch.nextQuest != null)
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
}
}
}
}
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.IsOptional && !IsObjectiveComplete(obj)) return false;
}
return true;
}
// ── 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
{
Status = state.ToString(),
ProgressCounts = BuildProgressList(id),
};
}
}
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 && saved.ProgressCounts != null)
{
for (int i = 0; i < quest.objectives.Length && i < saved.ProgressCounts.Count; i++)
{
var obj = quest.objectives[i];
if (obj == null) continue;
if (!_objectiveStates.TryGetValue(obj.objectiveId, out var os))
os = _objectiveStates[obj.objectiveId] = new QuestObjectiveState();
os.progressCount = saved.ProgressCounts[i];
}
}
}
}
// ── 私有辅助 ─────────────────────────────────────────────────────────
private bool CanAccept(string questId)
{
if (GetState(questId) != QuestStateEnum.Available) return false;
var quest = GetQuestSO(questId);
if (quest?.prerequisiteQuests == null) return true;
foreach (var pre in quest.prerequisiteQuests)
{
if (pre == null) continue;
if (GetState(pre.questId) != QuestStateEnum.Completed) return false;
}
return true;
}
private bool IsObjectiveComplete(QuestObjectiveSO obj)
{
if (!_objectiveStates.TryGetValue(obj.objectiveId, out var s))
s = new QuestObjectiveState();
bool result = obj.EvaluateCompletion(s);
// 首次达成时写回 completed 标志,避免 s 是本地临时对象时标志丢失
if (result && !s.completed)
{
s.completed = true;
_objectiveStates[obj.objectiveId] = s;
}
return result;
}
private List<int> BuildProgressList(string questId)
{
var quest = GetQuestSO(questId);
if (quest?.objectives == null) return new List<int>(0);
var list = new List<int>(quest.objectives.Length);
foreach (var obj in quest.objectives)
{
_objectiveStates.TryGetValue(obj?.objectiveId ?? string.Empty, out var os);
list.Add(os?.progressCount ?? 0);
}
return list;
}
// ── 事件处理 ─────────────────────────────────────────────────────────
private void HandleEnemyDefeated(string enemyId)
{
ForEachActiveObjective<DefeatEnemyObjective>(obj =>
{
if (obj.targetEnemyId == enemyId)
IncrementProgress(obj.objectiveId);
});
}
private void HandleItemCollected(string itemId)
{
ForEachActiveObjective<CollectItemObjective>(obj =>
{
if (obj.itemId == itemId)
IncrementProgress(obj.objectiveId);
});
}
private void HandleNpcDialogue(string npcId)
{
ForEachActiveObjective<TalkToNPCObjective>(obj =>
{
if (obj.targetNpcId == npcId)
IncrementProgress(obj.objectiveId);
});
}
private void HandleSceneLoaded(string sceneName)
{
ForEachActiveObjective<ReachAreaObjective>(obj =>
{
if (obj.sceneName == sceneName)
IncrementProgress(obj.objectiveId);
});
}
private void HandleSkillUsed(string abilityTypeName)
{
// 用 Enum.TryParse 避免大小写/ToString 格式差异导致静默失败
if (!System.Enum.TryParse<AbilityType>(abilityTypeName, ignoreCase: true, out var parsed)) return;
ForEachActiveObjective<UseSkillObjective>(obj =>
{
if (obj.requiredAbility == parsed)
IncrementProgress(obj.objectiveId);
});
}
private void ForEachActiveObjective<T>(System.Action<T> action) where T : QuestObjectiveSO
{
foreach (var (qid, state) in _questStates)
{
if (state != QuestStateEnum.Active) continue;
var quest = GetQuestSO(qid);
if (quest?.objectives == null) continue;
foreach (var obj in quest.objectives)
{
if (obj is T typed) action(typed);
}
}
}
private void IncrementProgress(string objectiveId)
{
if (!_objectiveStates.TryGetValue(objectiveId, out var s))
s = _objectiveStates[objectiveId] = new QuestObjectiveState();
s.progressCount++;
_onObjectiveUpdated?.Raise(new QuestObjectiveEvent
{
ObjectiveId = objectiveId,
Progress = s.progressCount,
});
}
private QuestSO GetQuestSO(string id)
=> _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null;
// ── 编辑器自动维护 ────────────────────────────────────────────────────
#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>
[System.Diagnostics.Conditional("UNITY_EDITOR")] // ContextMenu 只在编辑器生效
[UnityEngine.ContextMenu("校验前置任务循环引用")]
private void ValidatePrerequisites()
{
if (_allQuests == null) return;
// 先建立 id → SO 快速查找表
var index = new Dictionary<string, QuestSO>(_allQuests.Length);
foreach (var q in _allQuests)
{
if (q == null || string.IsNullOrEmpty(q.questId)) continue;
index[q.questId] = q;
}
// 三色 DFS0=未访问 1=灰栈中2=黑(已完成)
var color = new Dictionary<string, int>(index.Count);
bool HasCycle(string startId, List<string> path)
{
if (!index.ContainsKey(startId)) return false;
if (color.TryGetValue(startId, out int c))
{
if (c == 1)
{
path.Add(startId);
Debug.LogError(
$"[QuestManager] 前置任务循环引用: {string.Join(" ", path)}",
this);
return true;
}
return false; // 已完成
}
color[startId] = 1;
path.Add(startId);
var prereqs = index[startId].prerequisiteQuests;
if (prereqs != null)
{
foreach (var pre in prereqs)
{
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
if (HasCycle(pre.questId, path)) return true;
}
}
path.RemoveAt(path.Count - 1);
color[startId] = 2;
return false;
}
foreach (var questId in index.Keys)
HasCycle(questId, new List<string>());
}
#endif
}
}