- 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.
407 lines
17 KiB
C#
407 lines
17 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
|
||
{
|
||
// ── Inspector ────────────────────────────────────────────────────────
|
||
|
||
[Tooltip("所有 QuestSO 资产。编辑器会自动同步,无需手动维护。")]
|
||
[SerializeField] private QuestSO[] _allQuests;
|
||
|
||
[Header("Event Channels(监听)")]
|
||
[SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied(enemyId)
|
||
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId)
|
||
[SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName)
|
||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId)
|
||
[SerializeField] private StringEventChannelSO _onSkillUsed; // EVT_SkillUsed(abilityType.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;
|
||
}
|
||
|
||
// 三色 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
|
||
}
|
||
}
|