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.
This commit is contained in:
2026-05-24 00:36:11 +08:00
parent 520f84999b
commit 446fd5dcd0
22 changed files with 1908 additions and 101 deletions

View File

@@ -11,17 +11,23 @@ namespace BaseGames.Quest
/// 挂在 Persistent 场景 [GameManagers] 下。
/// 事件驱动追踪目标进度,不主动轮询。
/// 实现 ISaveable通过 SaveManager 持久化任务状态。
///
/// _allQuests 由编辑器 OnValidate / "刷新任务列表" 右键菜单自动填充,
/// 无需策划人员手动拖入 ScriptableObject。
/// </summary>
public class QuestManager : MonoBehaviour, ISaveable, IQuestManager
{
// ── Inspector ────────────────────────────────────────────────────────
[SerializeField] private QuestSO[] _allQuests;
[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 _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
@@ -52,14 +58,35 @@ namespace BaseGames.Quest
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);
}
@@ -94,16 +121,17 @@ namespace BaseGames.Quest
_onQuestCompleted?.Raise(questId);
// 解锁后续任务(分支)
// conditionQuest == null 表示默认分支conditionQuest != null 则要求该任务已完成。
// 不 break —— 允许同时解锁多个后续任务(如完成任务后同时开放多条支线)。
if (quest.branches != null)
{
foreach (var branch in quest.branches)
{
if (string.IsNullOrEmpty(branch.conditionQuestId) ||
GetState(branch.conditionQuestId) == QuestStateEnum.Completed)
if (branch.conditionQuest == null ||
GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed)
{
if (branch.nextQuest != null)
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
break;
}
}
}
@@ -133,9 +161,8 @@ namespace BaseGames.Quest
{
data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState
{
Status = state.ToString(),
ObjectiveIndex = 0,
ProgressCounts = BuildProgressList(id),
Status = state.ToString(),
ProgressCounts = BuildProgressList(id),
};
}
}
@@ -171,24 +198,36 @@ namespace BaseGames.Quest
{
if (GetState(questId) != QuestStateEnum.Available) return false;
var quest = GetQuestSO(questId);
if (quest?.prerequisiteQuestIds == null) return true;
foreach (var pre in quest.prerequisiteQuestIds)
if (GetState(pre) != QuestStateEnum.Completed) return false;
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)
{
_objectiveStates.TryGetValue(obj.objectiveId, out var s);
s ??= new QuestObjectiveState();
return obj.EvaluateCompletion(s);
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 list = new List<int>();
var quest = GetQuestSO(questId);
if (quest?.objectives == null) return list;
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);
@@ -235,6 +274,17 @@ namespace BaseGames.Quest
});
}
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)
@@ -263,5 +313,94 @@ namespace BaseGames.Quest
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
}
}