chore: initial commit
This commit is contained in:
743
Docs/Design/38_QuestSystem.md
Normal file
743
Docs/Design/38_QuestSystem.md
Normal 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 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 ← 失败台词(可选)
|
||||
```
|
||||
Reference in New Issue
Block a user