28 KiB
28 KiB
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 · 39_ChallengeRoomSystem
目录
Part A — 任务系统
Part B — 挑战房间
- 挑战房间系统职责
- ChallengeRoomSO
- ChallengeEncounterSO
- BossRushSequenceSO
- ChallengeRoomManager
- ChallengeRoomTrigger
- 事件频道
Part A — 任务系统
1. 任务系统职责
任务系统职责:
├─ QuestSO → 任务定义(目标链/奖励/分支/前置条件)
├─ QuestObjectiveSO → 单步目标(对话/击败/收集/到达)
├─ RewardSO → 奖励配置(Geo/物品/好感度/能力)
├─ QuestManager → 运行时追踪所有任务状态,事件驱动推进进度
└─ QuestGiver → 扩展 InteractableNPC,发布/完成任务,切换对话
状态机:Unavailable → Available → Active → Completed / Failed(只进不退)
事件驱动:QuestManager 只订阅事件频道(击败敌人/收集物品/到达地点),不主动轮询。
2. QuestSO
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 中配置且无需修改代码即可扩展:
namespace BaseGames.Quest
{
/// <summary>
/// 任务目标基类(抽象)。所有具体目标类型均继承此类。
/// 策划通过各子类的 CreateAssetMenu 创建具体目标资产。
/// </summary>
public abstract class QuestObjectiveSO : ScriptableObject
{
[Header("标识")]
public string objectiveId;
[TextArea(1, 4)]
public string displayText; // 任务日志中显示的文本
public bool IsOptional; // 可选目标(完成加奖励但不阻塞任务)
/// <summary>注册监听(由 QuestManager 在任务激活时调用)。</summary>
public abstract void RegisterListeners(IQuestObjectiveListener listener);
/// <summary>注销监听(由 QuestManager 在任务完成/失败时调用)。</summary>
public abstract void UnregisterListeners(IQuestObjectiveListener listener);
/// <summary>根据当前进度判断目标是否完成。</summary>
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
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;
/// <summary>将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。</summary>
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
namespace BaseGames.Quest
{
/// <summary>
/// 运行时任务管理器,挂在 Persistent 场景 [GameManagers] 下。
/// 通过事件频道追踪目标进度,不主动轮询。
/// </summary>
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<string, QuestState> _questStates = new();
readonly Dictionary<string, QuestObjectiveState> _objectiveStates = new(); // objectiveId → 状态
public static QuestManager Instance { get; private set; }
/// <summary>向后兼容:保留任务开始事件频道公开属性(供 QuestGiver/QuestLogUI 订阅)。</summary>
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 ──────────────────────────────────────────
/// <summary>NPC 接受任务时调用。</summary>
public void AcceptQuest(string questId)
{
if (!CanAccept(questId)) return;
_questStates[questId] = QuestState.Active;
_onQuestStarted.Raise(questId);
}
/// <summary>NPC 完成任务时调用。</summary>
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<EnemyBase>();
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 }
/// <summary>记录单个目标的运行时进度。</summary>
public class QuestObjectiveState
{
public bool completed = false;
public int progressCount = 0;
}
/// <summary>
/// 实现此接口的 MonoBehaviour 可自行注册/注销目标监听,
/// 由 QuestManager.RegisterObjectiveListeners() 自动调用。
/// </summary>
public interface IQuestObjectiveListener
{
void RegisterListeners(QuestManager manager);
void UnregisterListeners(QuestManager manager);
}
}
6. QuestGiver
namespace BaseGames.Quest
{
/// <summary>
/// 继承 InteractableNPC,负责发布/完成任务并根据任务状态切换对话版本。
/// 不依赖 [RequireComponent],直接通过继承获得 Interact 流程。
/// </summary>
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<PlayerController>()?.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 中定义)读写:
// 存档时(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
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
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
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
namespace BaseGames.Challenge
{
/// <summary>
/// 挑战房间流程管理器,挂在挑战房间场景的 [ChallengeManager] GameObject 上。
/// 场景加载时自动启动挑战。
/// </summary>
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<EnemyBase>(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
namespace BaseGames.Challenge
{
/// <summary>
/// 放置在挑战房间入口处,玩家交互后加载挑战场景。
/// </summary>
[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 |