# 22 · 任务与挑战房间模块(Quest & Challenge Module) > **命名空间** `BaseGames.Quest` / `BaseGames.Challenge` > **程序集** `BaseGames.Quest`(`Assets/Scripts/Quest/`) > **依赖** `BaseGames.Core.Events` · `BaseGames.Dialogue`(InteractableNPC)· `BaseGames.World`(SaveManager · SceneLoader)· `BaseGames.Player`(PlayerStats) > **Design 来源** [38_QuestSystem](../Design/38_QuestSystem.md) · [39_ChallengeRoomSystem](../Design/39_ChallengeRoomSystem.md) --- ## 目录 ### Part A — 任务系统 1. [任务系统职责](#1-任务系统职责) 2. [QuestSO](#2-questso) 3. [QuestObjectiveSO](#3-questobjectiveso) 4. [RewardSO](#4-rewardso) 5. [QuestManager](#5-questmanager) 6. [QuestGiver](#6-questgiver) 7. [SaveData 集成(任务)](#7-savedata-集成任务) ### Part B — 挑战房间 8. [挑战房间系统职责](#8-挑战房间系统职责) 9. [ChallengeRoomSO](#9-challengeroomso) 10. [ChallengeEncounterSO](#10-challengeencounterso) 11. [BossRushSequenceSO](#11-bossrushsequenceso) 12. [ChallengeRoomManager](#12-challengeroommanager) 13. [ChallengeRoomTrigger](#13-challengeroomtrigger) 14. [事件频道](#14-事件频道) --- ## Part A — 任务系统 --- ## 1. 任务系统职责 ``` 任务系统职责: ├─ QuestSO → 任务定义(目标链/奖励/分支/前置条件) ├─ QuestObjectiveSO → 单步目标(对话/击败/收集/到达) ├─ RewardSO → 奖励配置(Geo/物品/好感度/能力) ├─ QuestManager → 运行时追踪所有任务状态,事件驱动推进进度 └─ QuestGiver → 扩展 InteractableNPC,发布/完成任务,切换对话 ``` **状态机**:`Unavailable → Available → Active → Completed / Failed`(只进不退) **事件驱动**:`QuestManager` 只订阅事件频道(击败敌人/收集物品/到达地点),不主动轮询。 --- ## 2. QuestSO ```csharp namespace BaseGames.Quest { [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 string[] prerequisiteQuestIds; // 所有前置任务 Completed 后才可接 public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制) [Header("奖励")] public RewardSO reward; [Header("失败条件(可选)")] public bool canFail; public QuestObjectiveSO failCondition; [Header("完成后续任务(分支)")] public QuestBranch[] branches; } [Serializable] public class QuestBranch { public string conditionQuestId; // 若此任务已完成 → 走本分支(空 = 默认) public QuestSO nextQuest; public string npcDialogueKey; // 触发 NPC 对话 key } } ``` --- ## 3. QuestObjectiveSO(多态目标体系) 每种目标类型使用独立的子类 SO,便于策划在 Inspector 中配置且无需修改代码即可扩展: ```csharp namespace BaseGames.Quest { /// /// 任务目标基类(抽象)。所有具体目标类型均继承此类。 /// 策划通过各子类的 CreateAssetMenu 创建具体目标资产。 /// public abstract class QuestObjectiveSO : ScriptableObject { [Header("标识")] public string objectiveId; [TextArea(1, 4)] public string displayText; // 任务日志中显示的文本 public bool IsOptional; // 可选目标(完成加奖励但不阻塞任务) /// 注册监听(由 QuestManager 在任务激活时调用)。 public abstract void RegisterListeners(IQuestObjectiveListener listener); /// 注销监听(由 QuestManager 在任务完成/失败时调用)。 public abstract void UnregisterListeners(IQuestObjectiveListener listener); /// 根据当前进度判断目标是否完成。 public abstract bool EvaluateCompletion(QuestObjectiveState state); } // ── 具体目标类型 ────────────────────────────────────────────── [CreateAssetMenu(menuName = "Quest/Objective/TalkToNPC")] public class TalkToNPCObjective : QuestObjectiveSO { public string targetNpcId; public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this); public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this); public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1; } [CreateAssetMenu(menuName = "Quest/Objective/Defeat")] public class DefeatEnemyObjective : QuestObjectiveSO { public string targetEnemyId; [Min(1)] public int defeatCount = 1; public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this); public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this); public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount; } [CreateAssetMenu(menuName = "Quest/Objective/Collect")] public class CollectItemObjective : QuestObjectiveSO { public string itemId; [Min(1)] public int collectCount = 1; public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this); public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this); public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount; } [CreateAssetMenu(menuName = "Quest/Objective/Reach")] public class ReachAreaObjective : QuestObjectiveSO { public string sceneName; // 需到达的场景 public string markerTag; // 场景内的目标标记 Tag public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this); public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this); public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1; } [CreateAssetMenu(menuName = "Quest/Objective/UseSkill")] public class UseSkillObjective : QuestObjectiveSO { public AbilityType requiredAbility; [Min(1)] public int useCount = 1; public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this); public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this); public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount; } } ``` **扩展方式**:继承 `QuestObjectiveSO`,实现三个抽象方法,标注 `[CreateAssetMenu]`,无需修改 `QuestManager`。 --- ## 4. RewardSO ```csharp namespace BaseGames.Quest { [CreateAssetMenu(menuName = "Quest/Reward")] public class RewardSO : ScriptableObject { public int geo; // Geo 奖励 public int soulBonus; // 灵魂槽扩展(+max) public string[] itemIds; // 物品/护符 ID 列表 public int affinityBonus; // 对发布 NPC 的好感度增量 public string unlockDialogueKey; // 解锁 NPC 新台词集合 key public bool unlocksAbility = false; // ⚠️ AbilityType 无 None 值,用 bool 标识是否解锁能力(架构 09 §1) public AbilityType unlockedAbility; // 仅当 unlocksAbility == true 时有效 [Header("物品发放事件")] [Tooltip("EVT_CollectiblePickup:向 QuestManager/EquipmentManager 广播 itemId")] [SerializeField] private StringEventChannelSO _onCollectiblePickup; /// 将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。 public void Apply(PlayerStats player) { if (geo > 0) player.AddGeo(geo); if (soulBonus > 0) player.AddSoulPower(soulBonus); if (unlocksAbility) // ⚠️ 替代 AbilityType.None 判断 player.UnlockAbility(unlockedAbility); // 物品/护符通过 EVT_CollectiblePickup 事件频道广播(InventoryManager 不存在于本项目) if (itemIds != null && _onCollectiblePickup != null) foreach (var id in itemIds) _onCollectiblePickup.Raise(id); } } } ``` --- ## 5. QuestManager ```csharp namespace BaseGames.Quest { /// /// 运行时任务管理器,挂在 Persistent 场景 [GameManagers] 下。 /// 通过事件频道追踪目标进度,不主动轮询。 /// public class QuestManager : MonoBehaviour { // ── Inspector ──────────────────────────────────────── [SerializeField] QuestSO[] _allQuests; // 所有任务 SO [SerializeField] TransformEventChannelSO _onEnemyDied; // EVT_EnemyDied → 通过 Transform.GetComponent 获取敌人类型 [SerializeField] StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId) [SerializeField] StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName) [SerializeField] StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId) // 分拆为粒度更细的事件频道(替代旧 _onQuestStateChanged 单频道) [SerializeField] StringEventChannelSO _onQuestStarted; // Raise:questId [SerializeField] StringEventChannelSO _onQuestCompleted; // Raise:questId [SerializeField] StringEventChannelSO _onQuestFailed; // Raise:questId [SerializeField] QuestObjectiveEventChannelSO _onObjectiveUpdated; // Raise:objectiveId + progress // ── Runtime State ──────────────────────────────────── readonly Dictionary _questStates = new(); readonly Dictionary _objectiveStates = new(); // objectiveId → 状态 public static QuestManager Instance { get; private set; } /// 向后兼容:保留任务开始事件频道公开属性(供 QuestGiver/QuestLogUI 订阅)。 public StringEventChannelSO OnQuestStarted => _onQuestStarted; public StringEventChannelSO OnQuestCompleted => _onQuestCompleted; void Awake() => Instance = this; void OnEnable() { _onEnemyDied.OnEventRaised += HandleEnemyDefeated; _onCollectiblePickup.OnEventRaised += HandleItemCollected; _onSceneLoaded.OnEventRaised += HandleSceneLoaded; _onNpcDialogueCompleted.OnEventRaised += HandleNpcDialogue; } void OnDisable() { _onEnemyDied.OnEventRaised -= HandleEnemyDefeated; _onCollectiblePickup.OnEventRaised -= HandleItemCollected; _onSceneLoaded.OnEventRaised -= HandleSceneLoaded; _onNpcDialogueCompleted.OnEventRaised -= HandleNpcDialogue; } // ── 公共 API ────────────────────────────────────────── /// NPC 接受任务时调用。 public void AcceptQuest(string questId) { if (!CanAccept(questId)) return; _questStates[questId] = QuestState.Active; _onQuestStarted.Raise(questId); } /// NPC 完成任务时调用。 public void CompleteQuest(string questId, PlayerStats player) { if (!IsReadyToComplete(questId)) return; var quest = GetQuestSO(questId); quest.reward?.Apply(player); _questStates[questId] = QuestState.Completed; _onQuestCompleted.Raise(questId); // 解锁后续任务 foreach (var branch in quest.branches) { if (string.IsNullOrEmpty(branch.conditionQuestId) || GetState(branch.conditionQuestId) == QuestState.Completed) { if (branch.nextQuest != null) _questStates[branch.nextQuest.questId] = QuestState.Available; break; } } } public QuestState GetState(string questId) => _questStates.TryGetValue(questId, out var s) ? s : QuestState.Unavailable; public bool IsReadyToComplete(string questId) { var quest = GetQuestSO(questId); if (quest == null || GetState(questId) != QuestState.Active) return false; foreach (var obj in quest.objectives) { if (!obj.IsOptional && !IsObjectiveComplete(obj)) return false; } return true; } // ── 私有 ───────────────────────────────────────────── bool CanAccept(string questId) { if (GetState(questId) != QuestState.Available) return false; var quest = GetQuestSO(questId); foreach (var pre in quest.prerequisiteQuestIds) if (GetState(pre) != QuestState.Completed) return false; return true; } bool IsObjectiveComplete(QuestObjectiveSO obj) { _objectiveStates.TryGetValue(obj.objectiveId, out var s); s ??= new QuestObjectiveState(); return obj.EvaluateCompletion(s); // 多态:各子类自行判断完成条件 } void HandleEnemyDefeated(Transform enemyTransform) { // 通过 Transform 获取敌人 ID(EnemyBase.EnemyId 属性) var enemyBase = enemyTransform.GetComponent(); if (enemyBase == null) return; string enemyId = enemyBase.EnemyId; foreach (var (qid, state) in _questStates) { if (state != QuestState.Active) continue; var quest = GetQuestSO(qid); foreach (var obj in quest.objectives) { if (obj.type == ObjectiveType.Defeat && obj.targetEnemyId == enemyId) IncrementProgress(obj.objectiveId); } } } void HandleItemCollected(string itemId) { /* 同上,匹配 Collect 目标 */ } void HandleNpcDialogue(string npcId) { /* 同上,匹配 TalkTo 目标 */ } void HandleSceneLoaded(string sceneName) { /* 同上,匹配 Reach 目标 */ } void IncrementProgress(string objectiveId) { if (!_objectiveStates.TryGetValue(objectiveId, out var s)) s = _objectiveStates[objectiveId] = new QuestObjectiveState(); s.progressCount++; _onObjectiveUpdated.Raise(new QuestObjectiveEvent { ObjectiveId = objectiveId, Progress = s.progressCount }); } QuestSO GetQuestSO(string id) => System.Array.Find(_allQuests, q => q.questId == id); } public enum QuestState { Unavailable, Available, Active, Completed, Failed } /// 记录单个目标的运行时进度。 public class QuestObjectiveState { public bool completed = false; public int progressCount = 0; } /// /// 实现此接口的 MonoBehaviour 可自行注册/注销目标监听, /// 由 QuestManager.RegisterObjectiveListeners() 自动调用。 /// public interface IQuestObjectiveListener { void RegisterListeners(QuestManager manager); void UnregisterListeners(QuestManager manager); } } ``` --- ## 6. QuestGiver ```csharp namespace BaseGames.Quest { /// /// 继承 InteractableNPC,负责发布/完成任务并根据任务状态切换对话版本。 /// 不依赖 [RequireComponent],直接通过继承获得 Interact 流程。 /// public class QuestGiver : InteractableNPC { [Header("任务")] [SerializeField] QuestSO[] _offeredQuests; // 该 NPC 可提供的所有任务(按优先级排列) [Header("对话版本(根据任务状态切换)")] [SerializeField] DialogueSequenceSO _availableDialogue; // 任务可接时 [SerializeField] DialogueSequenceSO _activeDialogue; // 任务进行中 [SerializeField] DialogueSequenceSO _readyDialogue; // 完成条件满足时 [SerializeField] DialogueSequenceSO _completedDialogue; // 任务已完成后 // ── Interact_Internal 覆盖(在启动对话前处理任务逻辑)──────── protected override void Interact_Internal(Transform player) { var quest = GetCurrentQuest(); if (quest == null) return; var state = QuestManager.Instance.GetState(quest.questId); if (state == QuestState.Available) QuestManager.Instance.AcceptQuest(quest.questId); else if (QuestManager.Instance.IsReadyToComplete(quest.questId)) QuestManager.Instance.CompleteQuest(quest.questId, player.GetComponent()?.Stats); } // ── 返回与当前最高优先级任务状态匹配的对话 SO ────────────────── protected override DialogueSequenceSO GetCurrentDialogue() { var quest = GetCurrentQuest(); if (quest == null) return base.GetCurrentDialogue(); return QuestManager.Instance.GetState(quest.questId) switch { QuestState.Available => _availableDialogue, QuestState.Active => QuestManager.Instance.IsReadyToComplete(quest.questId) ? _readyDialogue : _activeDialogue, QuestState.Completed => _completedDialogue, _ => base.GetCurrentDialogue(), }; } // 返回当前处于 Available 或 Active 状态的第一个任务 QuestSO GetCurrentQuest() { if (_offeredQuests == null) return null; foreach (var q in _offeredQuests) { var s = QuestManager.Instance.GetState(q.questId); if (s == QuestState.Available || s == QuestState.Active) return q; } return null; } } } ``` --- ## 7. SaveData 集成(任务) `SaveData.Quests.QuestStates`(已在 `12_SaveModule.md` 中定义)读写: ```csharp // 存档时(SaveManager.GatherSaveData) foreach (var (id, state) in QuestManager.Instance.QuestStates) saveData.Quests.QuestStates[id] = (int)state; // 读档时(QuestManager.LoadFromSaveData) public void LoadFromSaveData(QuestSaveData data) { _questStates.Clear(); foreach (var (id, stateInt) in data.QuestStates) _questStates[id] = (QuestState)stateInt; foreach (var (id, progress) in data.ObjectiveProgress) { if (!_objectiveStates.TryGetValue(id, out var s)) s = _objectiveStates[id] = new QuestObjectiveState(); s.progressCount = progress; } } ``` --- ## Part B — 挑战房间 --- ## 8. 挑战房间系统职责 ``` 挑战房间系统职责: ├─ ChallengeRoomSO → 挑战定义(波次/时限/奖励) ├─ ChallengeEncounterSO → 单波敌人配置 ├─ BossRushSequenceSO → Boss Rush 序列数据 ├─ ChallengeRoomManager → 运行时流程管理(开始/推进/成功/失败) └─ ChallengeRoomTrigger → 场景组件,触发进入挑战 ``` --- ## 9. ChallengeRoomSO ```csharp namespace BaseGames.Challenge { [CreateAssetMenu(menuName = "Challenge/ChallengeRoom")] public class ChallengeRoomSO : ScriptableObject { [Header("标识")] public string challengeId; public string displayName; public ChallengeType challengeType; [Header("波次(非 BossRush)")] public ChallengeEncounterSO[] encounters; [Header("Boss Rush(BossRush 类型专用)")] public BossRushSequenceSO bossRushSequence; [Header("限制条件")] public float timeLimit; // 0 = 无时限 public bool requireNoHit; public int minComboRequired; // 0 = 无要求 [Header("奖励")] public RewardSO firstClearReward; // 首次通关奖励 public RewardSO repeatedReward; // 重复通关奖励 [Header("解锁条件")] public string[] prerequisiteBossIds; // 需击败的 Boss ID } public enum ChallengeType { Survival, TimeTrial, BossRush, NoHit } } ``` --- ## 10. ChallengeEncounterSO ```csharp namespace BaseGames.Challenge { [CreateAssetMenu(menuName = "Challenge/Encounter")] public class ChallengeEncounterSO : ScriptableObject { [Serializable] public struct SpawnEntry { public string enemyAddressKey; // Addressables key public Transform spawnPoint; public int count; } public SpawnEntry[] enemies; public float waveDelay; // 上波清空后等待多少秒生成本波 } } ``` --- ## 11. BossRushSequenceSO ```csharp namespace BaseGames.Challenge { [CreateAssetMenu(menuName = "Challenge/BossRushSequence")] public class BossRushSequenceSO : ScriptableObject { [Serializable] public struct BossEntry { public string bossSceneName; // Boss 所在场景(Additive 加载) public string bossId; public float hpRestoreRatio; // 击败本 Boss 后玩家恢复 HP 比例(默认 0.3) } public BossEntry[] bosses; } } ``` --- ## 12. ChallengeRoomManager ```csharp namespace BaseGames.Challenge { /// /// 挑战房间流程管理器,挂在挑战房间场景的 [ChallengeManager] GameObject 上。 /// 场景加载时自动启动挑战。 /// public class ChallengeRoomManager : MonoBehaviour { [SerializeField] ChallengeRoomSO _challengeData; [SerializeField] StringEventChannelSO _onChallengeCompleted; // → EVT_ChallengeCompleted(challengeId) [SerializeField] StringEventChannelSO _onChallengeFailed; // → EVT_ChallengeFailed(challengeId) // ⚠️ PlayerController 无 Instance(Architecture 05 §2);挑战房间场景持有引用 [SerializeField] PlayerController _player; int _currentEncounterIndex; int _remainingEnemies; float _elapsedTime; bool _isRunning; bool _noHitViolated; void Start() => StartChallenge(); void Update() { if (!_isRunning) return; _elapsedTime += Time.deltaTime; // 超时失败 if (_challengeData.timeLimit > 0 && _elapsedTime >= _challengeData.timeLimit) FailChallenge(); } void StartChallenge() { // 自动存档当前位置(挑战失败后读档) SaveManager.Instance.QuickSave(); _isRunning = true; _currentEncounterIndex = 0; SpawnWave(_currentEncounterIndex); } void SpawnWave(int index) { var enc = _challengeData.encounters[index]; _remainingEnemies = 0; foreach (var entry in enc.enemies) { for (int i = 0; i < entry.count; i++) { _remainingEnemies++; // Addressables 加载并生成敌人 Addressables.InstantiateAsync(entry.enemyAddressKey, entry.spawnPoint.position, Quaternion.identity) .Completed += handle => { if (handle.Result.TryGetComponent(out var enemy)) enemy.OnDied += OnEnemyDefeated; }; } } } void OnEnemyDefeated() { _remainingEnemies--; if (_remainingEnemies > 0) return; _currentEncounterIndex++; if (_currentEncounterIndex >= _challengeData.encounters.Length) CompleteChallenge(); else StartCoroutine(DelayedNextWave(_challengeData.encounters[_currentEncounterIndex].waveDelay)); } IEnumerator DelayedNextWave(float delay) { yield return new WaitForSeconds(delay); SpawnWave(_currentEncounterIndex); } void CompleteChallenge() { _isRunning = false; var reward = SaveManager.Instance.IsFirstClear(_challengeData.challengeId) ? _challengeData.firstClearReward : _challengeData.repeatedReward; reward?.Apply(_player.Stats); _onChallengeCompleted.Raise(_challengeData.challengeId); } void FailChallenge() { _isRunning = false; _onChallengeFailed.Raise(_challengeData.challengeId); // 自动读档回挑战入口 SaveManager.Instance.QuickLoad(); } } } ``` --- ## 13. ChallengeRoomTrigger ```csharp namespace BaseGames.Challenge { /// /// 放置在挑战房间入口处,玩家交互后加载挑战场景。 /// [RequireComponent(typeof(Collider2D))] public class ChallengeRoomTrigger : MonoBehaviour, IInteractable { [SerializeField] ChallengeRoomSO _challengeData; [SerializeField] string _challengeSceneName; [SerializeField] SceneLoadRequestEventChannelSO _onSceneLoadRequest; // ⚠️ 通过事件频道触发加载(SceneLoader 无 Instance;架构 03 §3) public string InteractPrompt => $"进入挑战:{_challengeData.displayName}"; public bool CanInteract => IsUnlocked(); public void Interact(Transform player) { if (!IsUnlocked()) return; // ⚠️ 通过 EVT_SceneLoadRequest 频道触发(不直接调用 SceneLoader,架构 03 §3 接口为 RequestLoad) _onSceneLoadRequest.Raise(new SceneLoadRequest { SceneName = _challengeSceneName, EntryTransitionId = string.Empty, ShowLoadingScreen = false, IsRespawn = false, }); } public void OnPlayerEnterRange(Transform player) { } public void OnPlayerExitRange() { } bool IsUnlocked() { foreach (var bossId in _challengeData.prerequisiteBossIds) if (!SaveManager.Instance.IsBossDefeated(bossId)) return false; return true; } } } ``` --- ## 14. 事件频道 | 频道 SO | Payload | 发布者 | 订阅者 | |--------|---------|--------|--------| | `EVT_QuestStateChanged` | `(string questId, QuestState)` | `QuestManager` | `QuestGiver`(刷新对话)、`QuestLogUI`(刷新日志) | | `EVT_ChallengeCompleted` | `string challengeId` | `ChallengeRoomManager` | `HUDController`(结算界面)、`AchievementManager` | | `EVT_ChallengeFailed` | `string challengeId` | `ChallengeRoomManager` | `SaveManager`(触发读档)、`HUDController` |