Files
zeling_v2/Docs/Design/38_QuestSystem.md
2026-05-08 11:04:00 +08:00

26 KiB
Raw Permalink Blame History

38 · NPC 任务链系统Quest System

命名空间 BaseGames.Quest
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.DialogueInteractableNPC、RelationshipManager· BaseGames.WorldSaveManager· BaseGames.PlayerPlayerStats


目录

  1. 系统总览
  2. QuestSO — 任务数据
  3. QuestObjectiveSO — 目标类型
  4. QuestManager — 核心管理器
  5. QuestGiver — 任务 NPC
  6. 任务分支与条件
  7. 好感度集成
  8. 任务日志 UI
  9. SaveData 集成
  10. 事件频道
  11. 编辑器友好设计

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 QuestLogUIDialogueManager(触发失败台词)
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 InspectorPlay Mode

┌─ QuestManager ─────────────────────────────────────────────┐
│  进行中任务:                                               │
│  ├─ Quest_FindMushroom  [目标 1/2]  进度: 3/3             │
│  └─ Quest_GuardAldric   [目标 0/1]  进度: ─              │
│  已完成: 3  失败: 0                                       │
│  [强制完成选中任务] [重置选中任务] [解锁任务 ▼]           │
└────────────────────────────────────────────────────────────┘

新增任务 SOP零代码

  1. Create → Quest/Quest → 填写 questIddisplayNameobjectives
  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      ← 失败台词(可选)