752 lines
28 KiB
Markdown
752 lines
28 KiB
Markdown
# 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
|
||
{
|
||
/// <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
|
||
|
||
```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;
|
||
|
||
/// <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
|
||
|
||
```csharp
|
||
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
|
||
|
||
```csharp
|
||
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` 中定义)读写:
|
||
|
||
```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
|
||
{
|
||
/// <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
|
||
|
||
```csharp
|
||
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` |
|