# 38 · NPC 任务链系统(Quest System) > **命名空间** `BaseGames.Quest` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Dialogue`(InteractableNPC、RelationshipManager)· `BaseGames.World`(SaveManager)· `BaseGames.Player`(PlayerStats) --- ## 目录 1. [系统总览](#1-系统总览) 2. [QuestSO — 任务数据](#2-questso--任务数据) 3. [QuestObjectiveSO — 目标类型](#3-questobjectiveso--目标类型) 4. [QuestManager — 核心管理器](#4-questmanager--核心管理器) 5. [QuestGiver — 任务 NPC](#5-questgiver--任务-npc) 6. [任务分支与条件](#6-任务分支与条件) 7. [好感度集成](#7-好感度集成) 8. [任务日志 UI](#8-任务日志-ui) 9. [SaveData 集成](#9-savedata-集成) 10. [事件频道](#10-事件频道) 11. [编辑器友好设计](#11-编辑器友好设计) --- ## 1. 系统总览 任务链系统负责追踪 **NPC 委托型任务**(探索交互/委托/支线)的进度与状态,与好感度系统、对话条件系统深度集成,是非线性世界叙事的骨架。 ``` 任务链系统职责: ├─ QuestSO → 任务定义(名称/目标链/奖励/分支条件) ├─ QuestObjectiveSO → 单步目标(与谁对话/击败谁/收集什么/到达哪里) ├─ QuestManager → 运行时追踪所有任务状态,响应游戏事件推进进度 ├─ QuestGiver → 扩展 InteractableNPC,负责发布/完成任务、切换对话 └─ QuestLogUI → 任务日志界面(暂停菜单中的"任务"页) ``` **设计原则**: - 任务状态为只进不退:`Unavailable → Available → Active → Completed / Failed` - 每个目标是独立 SO,可在多个任务之间共享 - 任务进度完全事件驱动,QuestManager 只监听事件频道,不主动轮询 - 任务完成奖励通过 `RewardHandler` 分发,不在 QuestManager 中直接操作 PlayerStats --- ## 2. QuestSO — 任务数据 ```csharp [CreateAssetMenu(menuName = "Quest/Quest")] public class QuestSO : ScriptableObject { [Header("标识")] public string questId; // 唯一 ID,如 "Quest_FindMushroom" public string displayName; [TextArea(2, 6)] public string description; public Sprite icon; // 任务日志图标(可选) [Header("目标链")] public QuestObjectiveSO[] objectives; // 按顺序执行,全部完成 = 任务可交完 [Header("分支(可选)")] public QuestBranch[] branches; // 任务完成后的后续任务(多选一) [Header("前置条件")] public string[] prerequisiteQuestIds; // 所有前置任务必须已完成才可接取 public int minAffinityToAccept; // 发布该任务的 NPC 好感度门槛(0 = 无限制) [Header("奖励")] public RewardSO reward; // 奖励 SO(见下方) [Header("配置")] public bool canFail; // 是否有失败条件(默认 false) public QuestObjectiveSO failCondition; // 若 canFail = true,此目标触发 = 任务失败 } [Serializable] public class QuestBranch { public string conditionQuestId; // 若该任务已完成 → 走此分支(空 = 默认分支) public QuestSO nextQuest; // 解锁的后续任务 public string npcDialogueKey; // 触发的 NPC 对话 key(切换 NPC 台词) } ``` ### RewardSO(奖励数据) ```csharp [CreateAssetMenu(menuName = "Quest/Reward")] public class RewardSO : ScriptableObject { public int geo; // Geo 奖励 public int soulBonus; // 灵魂槽扩展(+max) public ItemSO[] items; // 物品奖励(工具/护符等) public int affinityBonus; // 完成后对发布 NPC 好感度增量 public string unlockDialogueKey;// 解锁 NPC 新台词集合 key public AbilityType unlockedAbility; // 若非 None → 解锁玩家能力 } ``` --- ## 3. QuestObjectiveSO — 目标类型 所有目标继承 `QuestObjectiveSO` 抽象类: ```csharp public abstract class QuestObjectiveSO : ScriptableObject { [Header("目标描述(日志显示)")] public string objectiveText; // e.g. "与旅人 Aldric 对话" public bool isOptional; // 可选目标(完成有额外奖励,不完成不影响主线) /// /// 向 QuestManager 注册需要监听的事件频道。 /// QuestManager 在激活目标时调用此方法。 /// public abstract void RegisterListeners(IQuestObjectiveListener listener); public abstract void UnregisterListeners(IQuestObjectiveListener listener); /// /// 返回是否已完成(数据持久化状态,供存档读取后还原) /// public abstract bool EvaluateCompletion(QuestObjectiveState state); } public interface IQuestObjectiveListener { void OnObjectiveTriggered(QuestObjectiveSO objective); } ``` ### 3.1 内置目标类型 #### TalkToNPCObjective ```csharp [CreateAssetMenu(menuName = "Quest/Objectives/TalkToNPC")] public class TalkToNPCObjective : QuestObjectiveSO { public string targetNpcId; // 目标 NPC 的 npcId public override void RegisterListeners(IQuestObjectiveListener listener) => DialogueManager.Instance.OnNPCDialogueCompleted += id => { if (id == targetNpcId) listener.OnObjectiveTriggered(this); }; public override void UnregisterListeners(IQuestObjectiveListener listener) => DialogueManager.Instance.OnNPCDialogueCompleted -= id => { if (id == targetNpcId) listener.OnObjectiveTriggered(this); }; public override bool EvaluateCompletion(QuestObjectiveState state) => state.completed; } ``` #### DefeatEnemyObjective ```csharp [CreateAssetMenu(menuName = "Quest/Objectives/DefeatEnemy")] public class DefeatEnemyObjective : QuestObjectiveSO { public string enemyTypeId; // 目标敌人类型(空 = 任意敌人) public int requiredCount; // 需击败数量 // 通过 OnEnemyDefeated 事件频道监听 public override void RegisterListeners(IQuestObjectiveListener listener) { /* ... */ } public override void UnregisterListeners(IQuestObjectiveListener listener) { /* ... */ } public override bool EvaluateCompletion(QuestObjectiveState state) => state.progressCount >= requiredCount; } ``` #### CollectItemObjective ```csharp [CreateAssetMenu(menuName = "Quest/Objectives/CollectItem")] public class CollectItemObjective : QuestObjectiveSO { public string itemId; public int requiredAmount; // 通过 OnCollectiblePickedUp 事件频道监听 } ``` #### ReachAreaObjective ```csharp [CreateAssetMenu(menuName = "Quest/Objectives/ReachArea")] public class ReachAreaObjective : QuestObjectiveSO { public string sceneId; // 目标场景名(进入即完成) public string triggerZoneId; // 或场景内特定 TriggerZone 的 ID // 通过 OnRoomEntered / 自定义 TriggerZone 事件监听 } ``` #### UseSkillObjective ```csharp [CreateAssetMenu(menuName = "Quest/Objectives/UseSkill")] public class UseSkillObjective : QuestObjectiveSO { public string skillId; // 如 "Parry" / "Tool_Slingshot" public int requiredCount; // 使用次数(演示用技能的任务) } ``` --- ## 4. QuestManager — 核心管理器 ```csharp namespace BaseGames.Quest { public class QuestManager : MonoBehaviour, IQuestObjectiveListener { [Header("事件频道—订阅")] [SerializeField] StringEventChannelSO _onNPCDialogueCompleted; [SerializeField] StringEventChannelSO _onEnemyDefeated; [SerializeField] StringEventChannelSO _onCollectiblePickedUp; [SerializeField] StringEventChannelSO _onRoomEntered; [SerializeField] StringEventChannelSO _onSkillUsed; [Header("事件频道—发布")] [SerializeField] QuestEventChannelSO _onQuestStarted; [SerializeField] QuestEventChannelSO _onQuestCompleted; [SerializeField] QuestEventChannelSO _onQuestFailed; [SerializeField] VoidEventChannelSO _onObjectiveUpdated; // 运行时数据 readonly Dictionary _quests = new(); // ──────────────── 生命周期 ───────────────────────────────────────── void Awake() { // 从 SaveData 恢复状态 LoadFromSave(); } void OnEnable() { _onNPCDialogueCompleted.OnEventRaised += HandleNPCDialogue; _onEnemyDefeated.OnEventRaised += HandleEnemyDefeated; _onCollectiblePickedUp.OnEventRaised += HandleCollectible; _onRoomEntered.OnEventRaised += HandleRoomEntered; _onSkillUsed.OnEventRaised += HandleSkillUsed; } void OnDisable() { _onNPCDialogueCompleted.OnEventRaised -= HandleNPCDialogue; _onEnemyDefeated.OnEventRaised -= HandleEnemyDefeated; _onCollectiblePickedUp.OnEventRaised -= HandleCollectible; _onRoomEntered.OnEventRaised -= HandleRoomEntered; _onSkillUsed.OnEventRaised -= HandleSkillUsed; } // ──────────────── 核心接口 ───────────────────────────────────────── /// NPC 调用:接受任务 public bool TryAcceptQuest(QuestSO quest, string giverNpcId) { if (_quests.ContainsKey(quest.questId)) return false; // 已接取或已完成 if (!ArePrerequisitesMet(quest)) return false; var state = new QuestRuntimeState(quest, giverNpcId); _quests[quest.questId] = state; ActivateCurrentObjective(state); _onQuestStarted.Raise(quest); SaveToData(); return true; } /// NPC 调用:完成任务(交任务时) public bool TryCompleteQuest(string questId) { if (!_quests.TryGetValue(questId, out var state)) return false; if (!state.IsReadyToComplete) return false; state.Status = QuestStatus.Completed; UnregisterObjectiveListeners(state); _onQuestCompleted.Raise(state.Quest); RewardHandler.Instance.GrantReward(state.Quest.reward, state.GiverNpcId); // 解锁分支后续任务 var branch = SelectBranch(state.Quest); if (branch?.nextQuest != null) MakeQuestAvailable(branch.nextQuest.questId); SaveToData(); return true; } // ──────────────── IQuestObjectiveListener ────────────────────────── public void OnObjectiveTriggered(QuestObjectiveSO objective) { foreach (var (id, state) in _quests) { if (state.Status != QuestStatus.Active) continue; var current = state.CurrentObjective; if (current != objective) continue; // 推进进度 state.AdvanceProgress(); if (state.IsCurrentObjectiveComplete()) state.MoveToNextObjective(); if (state.IsReadyToComplete) _onObjectiveUpdated.Raise(); // 通知 UI 任务可交完 else _onObjectiveUpdated.Raise(); // 通知 UI 进度更新 SaveToData(); break; } } // ──────────────── 内部辅助 ────────────────────────────────────────── bool ArePrerequisitesMet(QuestSO quest) { foreach (var id in quest.prerequisiteQuestIds) if (!IsQuestCompleted(id)) return false; return true; } void ActivateCurrentObjective(QuestRuntimeState state) { state.CurrentObjective?.RegisterListeners(this); } void UnregisterObjectiveListeners(QuestRuntimeState state) { state.CurrentObjective?.UnregisterListeners(this); } QuestBranch SelectBranch(QuestSO quest) { if (quest.branches == null || quest.branches.Length == 0) return null; foreach (var b in quest.branches) if (!string.IsNullOrEmpty(b.conditionQuestId) && IsQuestCompleted(b.conditionQuestId)) return b; // 返回默认分支(conditionQuestId 为空的最后一个) return Array.Find(quest.branches, b => string.IsNullOrEmpty(b.conditionQuestId)); } bool IsQuestCompleted(string questId) => _quests.TryGetValue(questId, out var s) && s.Status == QuestStatus.Completed; void MakeQuestAvailable(string questId) { /* 在 _availableQuestIds 集合中标记,供 QuestGiver 查询 */ } // ──────────────── 事件处理器(转发到各 Objective)────────────────── void HandleNPCDialogue(string npcId) => BroadcastEvent("NPC", npcId); void HandleEnemyDefeated(string typeId) => BroadcastEvent("Enemy", typeId); void HandleCollectible(string itemId) => BroadcastEvent("Item", itemId); void HandleRoomEntered(string sceneId) => BroadcastEvent("Room", sceneId); void HandleSkillUsed(string skillId) => BroadcastEvent("Skill", skillId); void BroadcastEvent(string category, string id) { // 将事件转发给当前所有激活目标,让目标自行判断是否匹配 foreach (var state in _quests.Values) { if (state.Status != QuestStatus.Active) continue; state.CurrentObjective?.OnEvent(category, id, this); } } void LoadFromSave() { /* 从 SaveManager.Instance.GetQuestData() 重建 _quests */ } void SaveToData() { SaveManager.Instance.SetQuestData(_quests.Values); } } } ``` ### QuestRuntimeState(运行时状态容器) ```csharp public class QuestRuntimeState { public QuestSO Quest { get; } public string GiverNpcId { get; } public QuestStatus Status { get; set; } int _objectiveIndex; readonly int[] _progressCounts; // 每个目标的当前计数(用于 Count 类目标) public QuestObjectiveSO CurrentObjective => _objectiveIndex < Quest.objectives.Length ? Quest.objectives[_objectiveIndex] : null; public bool IsReadyToComplete => _objectiveIndex >= Quest.objectives.Length && Status == QuestStatus.Active; public QuestRuntimeState(QuestSO quest, string giverNpcId) { Quest = quest; GiverNpcId = giverNpcId; Status = QuestStatus.Active; _objectiveIndex = 0; _progressCounts = new int[quest.objectives.Length]; } public void AdvanceProgress() => _progressCounts[_objectiveIndex]++; public bool IsCurrentObjectiveComplete() => CurrentObjective?.EvaluateCompletion(GetState(_objectiveIndex)) ?? true; public void MoveToNextObjective() { CurrentObjective?.UnregisterListeners(null); // 注销旧目标 _objectiveIndex++; CurrentObjective?.RegisterListeners(null); // 注册新目标 } QuestObjectiveState GetState(int idx) => new QuestObjectiveState { completed = _progressCounts[idx] > 0, progressCount = _progressCounts[idx], }; } public struct QuestObjectiveState { public bool completed; public int progressCount; } public enum QuestStatus { Unavailable, // 前置条件未满足 Available, // 可接取(NPC 有 ! 标志) Active, // 进行中 Completed, // 已完成 Failed, // 已失败 } ``` --- ## 5. QuestGiver — 任务 NPC `QuestGiver` 继承 `InteractableNPC`,管理该 NPC 可以发布/完成的任务列表: ```csharp namespace BaseGames.Quest { public class QuestGiver : InteractableNPC { [Header("任务")] [SerializeField] QuestSO[] _offeredQuests; // 该 NPC 可发布的任务(按顺序尝试) [SerializeField] string _npcId; // 与 Dialogue/Relationship 系统对齐 protected override void Interact_Internal(Transform player) { var pendingComplete = GetQuestReadyToComplete(); if (pendingComplete != null) { // 优先结算可完成的任务 PlayQuestCompleteDialogue(pendingComplete); QuestManager.Instance.TryCompleteQuest(pendingComplete.questId); return; } var available = GetAvailableQuest(); if (available != null) { // 提供新任务 PlayQuestAcceptDialogue(available); QuestManager.Instance.TryAcceptQuest(available, _npcId); return; } // 无任务 → 播放普通对话(含好感度对应台词) base.Interact_Internal(player); } QuestSO GetQuestReadyToComplete() { foreach (var q in _offeredQuests) if (QuestManager.Instance.IsReadyToComplete(q.questId)) return q; return null; } QuestSO GetAvailableQuest() { foreach (var q in _offeredQuests) if (QuestManager.Instance.CanAccept(q.questId)) return q; return null; } void PlayQuestAcceptDialogue(QuestSO quest) => DialogueManager.Instance.Play(quest.questId + "_Accept"); void PlayQuestCompleteDialogue(QuestSO quest) => DialogueManager.Instance.Play(quest.questId + "_Complete"); } } ``` **NPC 头顶图标规则**(由 `QuestIconController` 驱动): | 状态 | 图标 | 颜色 | |------|------|------| | 可接新任务 | `!`(感叹号)| 黄色 | | 玩家有可交付的任务 | `?`(问号)| 橙色 | | 进行中(追踪中)| `⋯`(省略号)| 灰色 | | 无任务 | 无图标 | — | --- ## 6. 任务分支与条件 ### 分支示例:《寻找蘑菇》 ``` Quest_FindMushroom objectives: [0] CollectItemObjective { itemId = "Item_GlowMushroom", requiredAmount = 3 } [1] TalkToNPCObjective { targetNpcId = "NPC_Herbalist" } branches: [0] conditionQuestId = "Quest_DefeatForestBoss" → nextQuest = Quest_SpecialBrew [1] conditionQuestId = ""(默认) → nextQuest = Quest_PoisonTip ``` `SelectBranch()` 在 `TryCompleteQuest` 时执行,根据当前存档状态选择一个分支解锁后续任务。 ### 失败条件示例:《守护商队》 ``` Quest_EscortCaravan canFail = true failCondition = DefeatEnemyObjective { enemyTypeId = "NPC_Caravan_Merchant", requiredCount = 1 } (商队 NPC 被击败时任务失败) ``` --- ## 7. 好感度集成 任务系统与 §13 `RelationshipManager` 深度整合: | 时机 | 行为 | |------|------| | 接取任务 | `RelationshipManager.AddAffinity(npcId, +5)` | | 完成任务 | `RelationshipManager.AddAffinity(npcId, reward.affinityBonus)` | | 好感度解锁新任务 | `QuestSO.minAffinityToAccept` 门槛检查 | | 任务失败 | `RelationshipManager.AddAffinity(npcId, -10)` | | 完成任务后 NPC 对话 | 切换至 `reward.unlockDialogueKey` 对应的台词集合 | ```csharp // RewardHandler 在发放奖励时同步更新好感度 void GrantReward(RewardSO reward, string npcId) { PlayerStats.Instance.AddGeo(reward.geo); PlayerStats.Instance.AddMaxSoul(reward.soulBonus); foreach (var item in reward.items) InventoryManager.Instance.AddItem(item); if (reward.affinityBonus != 0) RelationshipManager.Instance.AddAffinity(npcId, reward.affinityBonus); if (!string.IsNullOrEmpty(reward.unlockDialogueKey)) DialogueManager.Instance.UnlockDialogueSet(npcId, reward.unlockDialogueKey); if (reward.unlockedAbility != AbilityType.None) PlayerStats.Instance.UnlockAbility(reward.unlockedAbility); } ``` --- ## 8. 任务日志 UI 暂停菜单中的"任务"标签页: ``` ┌─ 任务日志 ─────────────────────────────────────────────┐ │ [进行中] [已完成] │ │ ─────────────────────────────────────────────────── │ │ ► 寻找蘑菇 ⋯ 进行中 │ │ 目标:将 发光蘑菇 交给 草药师 Aldric │ │ 进度:[✓] 收集 3/3 发光蘑菇 │ │ [ ] 与 Aldric 对话 │ │ ─────────────────────────────────────────────────── │ │ ► 守护商队 ✓ 已完成 │ └────────────────────────────────────────────────────────┘ ``` ```csharp public class QuestLogUI : MonoBehaviour { [SerializeField] QuestEventChannelSO _onQuestStarted; [SerializeField] QuestEventChannelSO _onQuestCompleted; [SerializeField] VoidEventChannelSO _onObjectiveUpdated; [SerializeField] QuestEntryWidget _entryPrefab; [SerializeField] Transform _activeContainer; [SerializeField] Transform _completedContainer; void OnEnable() { _onQuestStarted.OnEventRaised += OnQuestStarted; _onQuestCompleted.OnEventRaised += OnQuestCompleted; _onObjectiveUpdated.OnEventRaised += Refresh; } void OnDisable() { _onQuestStarted.OnEventRaised -= OnQuestStarted; _onQuestCompleted.OnEventRaised -= OnQuestCompleted; _onObjectiveUpdated.OnEventRaised -= Refresh; } void Refresh() { // 从 QuestManager 获取当前所有任务状态,重建 UI 条目 ClearContainer(_activeContainer); ClearContainer(_completedContainer); foreach (var state in QuestManager.Instance.GetAllQuests()) { var entry = Instantiate(_entryPrefab, state.Status == QuestStatus.Completed ? _completedContainer : _activeContainer); entry.Bind(state); } } void OnQuestStarted(QuestSO _) => Refresh(); void OnQuestCompleted(QuestSO _) => Refresh(); void ClearContainer(Transform t) { foreach (Transform c in t) Destroy(c.gameObject); } } ``` --- ## 9. SaveData 集成 ```json "quests": { "active": [ { "questId": "Quest_FindMushroom", "giverNpcId": "NPC_Herbalist", "objectiveIndex": 1, "progressCounts": [3, 0] } ], "completed": ["Quest_EscortCaravan", "Quest_OldManLetter"], "failed": [], "availableQuestIds": ["Quest_SpecialBrew"] } ``` ```csharp // SaveManager 扩展 public void SetQuestData(IEnumerable states) { _saveData.quests.active = states .Where(s => s.Status == QuestStatus.Active) .Select(s => s.ToSaveData()).ToList(); _saveData.quests.completed = states .Where(s => s.Status == QuestStatus.Completed) .Select(s => s.Quest.questId).ToList(); WriteDirty(); } ``` --- ## 10. 事件频道 | 频道资产 | 类型 | 发布方 | 主要订阅方 | |---------|------|--------|----------| | `OnQuestStarted.asset` | `QuestEventChannelSO` | `QuestManager` | `QuestLogUI`(添加条目)、`HUD`(屏幕提示)| | `OnQuestCompleted.asset` | `QuestEventChannelSO` | `QuestManager` | `QuestLogUI`(移至完成列)、`AchievementManager` | | `OnQuestFailed.asset` | `QuestEventChannelSO` | `QuestManager` | `QuestLogUI`、`DialogueManager`(触发失败台词)| | `OnObjectiveUpdated.asset` | `VoidEventChannelSO` | `QuestManager` | `QuestLogUI`(刷新进度)、`HUD`(可交付提示)| ```csharp [CreateAssetMenu(menuName = "Events/QuestEventChannel")] public class QuestEventChannelSO : ScriptableObject { public event Action OnEventRaised; public void Raise(QuestSO quest) => OnEventRaised?.Invoke(quest); } ``` --- ## 11. 编辑器友好设计 ### QuestManager Inspector(Play Mode) ``` ┌─ QuestManager ─────────────────────────────────────────────┐ │ 进行中任务: │ │ ├─ Quest_FindMushroom [目标 1/2] 进度: 3/3 │ │ └─ Quest_GuardAldric [目标 0/1] 进度: ─ │ │ 已完成: 3 失败: 0 │ │ [强制完成选中任务] [重置选中任务] [解锁任务 ▼] │ └────────────────────────────────────────────────────────────┘ ``` ### 新增任务 SOP(零代码) 1. `Create → Quest/Quest` → 填写 `questId`、`displayName`、`objectives` 2. 为每个目标 `Create → Quest/Objectives/{Type}` 3. 将任务 SO 拖入目标 NPC 的 `QuestGiver._offeredQuests` 4. 若有奖励:`Create → Quest/Reward`,填写 Geo / Item / Affinity ### 对话文件命名规范 ``` Assets/Data/Dialogue/{QuestId}_Accept.asset ← 接任务台词 Assets/Data/Dialogue/{QuestId}_Complete.asset ← 完成任务台词 Assets/Data/Dialogue/{QuestId}_InProgress.asset ← 进行中每次对话台词 Assets/Data/Dialogue/{QuestId}_Failed.asset ← 失败台词(可选) ```