26 KiB
26 KiB
38 · NPC 任务链系统(Quest System)
命名空间
BaseGames.Quest
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Dialogue(InteractableNPC、RelationshipManager)·BaseGames.World(SaveManager)·BaseGames.Player(PlayerStats)
目录
- 系统总览
- QuestSO — 任务数据
- QuestObjectiveSO — 目标类型
- QuestManager — 核心管理器
- QuestGiver — 任务 NPC
- 任务分支与条件
- 好感度集成
- 任务日志 UI
- SaveData 集成
- 事件频道
- 编辑器友好设计
1. 系统总览
任务链系统负责追踪 NPC 委托型任务(探索交互/委托/支线)的进度与状态,与好感度系统、对话条件系统深度集成,是非线性世界叙事的骨架。
任务链系统职责:
├─ QuestSO → 任务定义(名称/目标链/奖励/分支条件)
├─ QuestObjectiveSO → 单步目标(与谁对话/击败谁/收集什么/到达哪里)
├─ QuestManager → 运行时追踪所有任务状态,响应游戏事件推进进度
├─ QuestGiver → 扩展 InteractableNPC,负责发布/完成任务、切换对话
└─ QuestLogUI → 任务日志界面(暂停菜单中的"任务"页)
设计原则:
- 任务状态为只进不退:
Unavailable → Available → Active → Completed / Failed - 每个目标是独立 SO,可在多个任务之间共享
- 任务进度完全事件驱动,QuestManager 只监听事件频道,不主动轮询
- 任务完成奖励通过
RewardHandler分发,不在 QuestManager 中直接操作 PlayerStats
2. QuestSO — 任务数据
[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(奖励数据)
[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 抽象类:
public abstract class QuestObjectiveSO : ScriptableObject
{
[Header("目标描述(日志显示)")]
public string objectiveText; // e.g. "与旅人 Aldric 对话"
public bool isOptional; // 可选目标(完成有额外奖励,不完成不影响主线)
/// <summary>
/// 向 QuestManager 注册需要监听的事件频道。
/// QuestManager 在激活目标时调用此方法。
/// </summary>
public abstract void RegisterListeners(IQuestObjectiveListener listener);
public abstract void UnregisterListeners(IQuestObjectiveListener listener);
/// <summary>
/// 返回是否已完成(数据持久化状态,供存档读取后还原)
/// </summary>
public abstract bool EvaluateCompletion(QuestObjectiveState state);
}
public interface IQuestObjectiveListener
{
void OnObjectiveTriggered(QuestObjectiveSO objective);
}
3.1 内置目标类型
TalkToNPCObjective
[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
[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
[CreateAssetMenu(menuName = "Quest/Objectives/CollectItem")]
public class CollectItemObjective : QuestObjectiveSO
{
public string itemId;
public int requiredAmount;
// 通过 OnCollectiblePickedUp 事件频道监听
}
ReachAreaObjective
[CreateAssetMenu(menuName = "Quest/Objectives/ReachArea")]
public class ReachAreaObjective : QuestObjectiveSO
{
public string sceneId; // 目标场景名(进入即完成)
public string triggerZoneId; // 或场景内特定 TriggerZone 的 ID
// 通过 OnRoomEntered / 自定义 TriggerZone 事件监听
}
UseSkillObjective
[CreateAssetMenu(menuName = "Quest/Objectives/UseSkill")]
public class UseSkillObjective : QuestObjectiveSO
{
public string skillId; // 如 "Parry" / "Tool_Slingshot"
public int requiredCount; // 使用次数(演示用技能的任务)
}
4. QuestManager — 核心管理器
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<string, QuestRuntimeState> _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;
}
// ──────────────── 核心接口 ─────────────────────────────────────────
/// <summary>NPC 调用:接受任务</summary>
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;
}
/// <summary>NPC 调用:完成任务(交任务时)</summary>
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(运行时状态容器)
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 可以发布/完成的任务列表:
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 对应的台词集合 |
// 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 对话 │
│ ─────────────────────────────────────────────────── │
│ ► 守护商队 ✓ 已完成 │
└────────────────────────────────────────────────────────┘
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 集成
"quests": {
"active": [
{
"questId": "Quest_FindMushroom",
"giverNpcId": "NPC_Herbalist",
"objectiveIndex": 1,
"progressCounts": [3, 0]
}
],
"completed": ["Quest_EscortCaravan", "Quest_OldManLetter"],
"failed": [],
"availableQuestIds": ["Quest_SpecialBrew"]
}
// SaveManager 扩展
public void SetQuestData(IEnumerable<QuestRuntimeState> 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(可交付提示) |
[CreateAssetMenu(menuName = "Events/QuestEventChannel")]
public class QuestEventChannelSO : ScriptableObject
{
public event Action<QuestSO> 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(零代码)
Create → Quest/Quest→ 填写questId、displayName、objectives- 为每个目标
Create → Quest/Objectives/{Type} - 将任务 SO 拖入目标 NPC 的
QuestGiver._offeredQuests - 若有奖励:
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 ← 失败台词(可选)