chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,743 @@
# 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; // 可选目标(完成有额外奖励,不完成不影响主线)
/// <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
```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<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运行时状态容器
```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<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`(可交付提示)|
```csharp
[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` → 填写 `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 ← 失败台词(可选)
```