Files
zeling_v2/Docs/Design/39_ChallengeRoomSystem.md
2026-05-08 11:04:00 +08:00

807 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 39 · 挑战房间与 Boss Rush 系统Challenge Room System
> **命名空间** `BaseGames.Challenge`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.World`SceneLoader、SaveManager· `BaseGames.Boss`BossBase· `BaseGames.Player`PlayerStats· `BaseGames.UI`HUD
---
## 目录
1. [系统总览](#1-系统总览)
2. [ChallengeRoomSO — 挑战定义](#2-challengeroomso--挑战定义)
3. [BossRushSequenceSO — Boss Rush 序列](#3-bossrushsequenceso--boss-rush-序列)
4. [ChallengeRoomManager — 核心管理器](#4-challengeroommanager--核心管理器)
5. [挑战类型详解](#5-挑战类型详解)
6. [奖励系统](#6-奖励系统)
7. [排行榜(本地)](#7-排行榜本地)
8. [SaveData 集成](#8-savedata-集成)
9. [事件频道](#9-事件频道)
10. [HUD 扩展](#10-hud-扩展)
11. [编辑器友好设计](#11-编辑器友好设计)
12. [ChallengeRoomTrigger — 场景触发器](#12-challengeroomtrigger--场景触发器)
13. [程序集依赖](#13-程序集依赖)
---
## 1. 系统总览
挑战房间是独立于主线的**高难度可重复内容**:封闭区域内完成限定条件(时限/无伤/连击数获取专属奖励。Boss Rush 是挑战房间的特殊变体,允许玩家按原始顺序重新挑战所有已击败 Boss。
```
挑战房间系统职责:
├─ ChallengeRoomSO → 挑战数据(敌人波次/时限/奖励/解锁条件)
├─ BossRushSequenceSO → Boss Rush 专属数据Boss 序列/难度系数)
├─ ChallengeRoomManager → 运行时管理挑战流程(进入/波次推进/成功/失败)
├─ ChallengeEncounterSO → 单波敌人配置(生成点/敌人类型/数量)
├─ ChallengeLeaderboard → 本地最佳成绩记录(时间/无伤/连击)
└─ ChallengeHUD → 计时器 / 波次进度 / 连击数屏幕显示
```
**核心规则**
- 进入挑战房间时**自动存档当前位置和状态**,退出/失败后原地读档
- 挑战期间**禁止手动存档**(存档点不可用)
- 挑战期间**死亡 = 挑战失败**(不触发正常死亡流程)
- Boss Rush 特殊规则:每场 Boss 战之间恢复 30% HP不恢复 Soul
---
## 2. ChallengeRoomSO — 挑战定义
```csharp
[CreateAssetMenu(menuName = "Challenge/ChallengeRoom")]
public class ChallengeRoomSO : ScriptableObject
{
[Header("标识")]
public string challengeId; // 唯一 ID如 "Challenge_Forest_Elite"
public string displayName;
[TextArea(1, 4)]
public string description;
public ChallengeType challengeType; // Survival / TimeTrial / BossRush / NoHit
[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 ChallengeRewardSO firstClearReward; // 首次完成奖励
public ChallengeRewardSO repeatedReward; // 再次完成奖励(时间/评级奖励)
[Header("解锁条件")]
public string[] prerequisiteBossIds; // 需击败的 Boss ID
public int minGeo; // 最低 Geo可选门槛
}
public enum ChallengeType
{
Survival, // 消灭所有波次敌人(可有时限)
TimeTrial, // 限时完成所有波次
BossRush, // 连续重战 Boss 序列
NoHit, // 无伤通关
Endless, // 无限波次,追求最高波次记录
}
```
### 2.1 ChallengeEncounterSO — 单波次配置
```csharp
/// <summary>单波次敌人配置ChallengeRoomSO.encounters[] 的元素)</summary>
[CreateAssetMenu(menuName = "Challenge/Encounter")]
public class ChallengeEncounterSO : ScriptableObject
{
[Header("敌人生成列表")]
public ChallengeSpawnInfo[] spawnInfos;
[Header("波次延迟(秒)")]
[Tooltip("上一波全歼后等待该时间再生成本波0 = 立即生成")]
[Range(0f, 5f)]
public float waveDelay = 0.5f;
/// <summary>本波总敌人数Editor & Runtime 均可访问)</summary>
public int TotalEnemyCount => spawnInfos.Sum(s => s.count);
}
[Serializable]
public class ChallengeSpawnInfo
{
public EnemyBase enemyPrefab; // 敌人预制件(含 EnemyBase 组件)
public Transform spawnPoint; // 引用场景 SpawnPoints/SP_XX 下的 Transform
[Min(1)]
public int count = 1; // 该类型生成数量
}
```
---
## 3. BossRushSequenceSO — Boss Rush 序列
```csharp
[CreateAssetMenu(menuName = "Challenge/BossRushSequence")]
public class BossRushSequenceSO : ScriptableObject
{
[Header("Boss 序列")]
public BossRushEntry[] entries; // 按顺序排列的 Boss 战
[Header("难度系数")]
[Range(1f, 3f)]
public float difficultyMultiplier = 1.2f; // 基础属性倍率Boss Rush 中 Boss 更强)
[Header("间歇期配置")]
public float interludeHPRestorePercent = 0.3f; // 每场 Boss 战后恢复 30% HP
public float interludeDuration = 5f; // 5 秒过渡动画时间(不可操作)
public bool restoreSoulBetweenBosses = false; // 是否恢复灵魂(通常否)
}
[Serializable]
public class BossRushEntry
{
public string bossId; // 对应主线击败的 Boss ID用于检查是否已解锁
public string bossSceneName; // Boss 场景Additive 加载,独立封闭区域)
public string bossName; // 显示名称
public Sprite bossPortrait; // 过渡画面肖像
}
```
---
## 4. ChallengeRoomManager — 核心管理器
```csharp
namespace BaseGames.Challenge
{
public class ChallengeRoomManager : MonoBehaviour
{
[Header("事件频道—订阅")]
[SerializeField] VoidEventChannelSO _onPlayerDied;
[SerializeField] StringEventChannelSO _onEnemyDefeated;
[SerializeField] VoidEventChannelSO _onPlayerSaveRequested; // 拦截并阻止存档
[SerializeField] VoidEventChannelSO _onPlayerHurt; // 玩家受击:累计受击 & NoHit 判定
[SerializeField] IntEventChannelSO _onComboChanged; // 连击数变化:跟踪最高连击
[SerializeField] BossEventChannelSO _onBossActivated; // BossBase 激活通知BossRush 专用)
[Header("事件频道—发布")]
[SerializeField] ChallengeEventChannelSO _onChallengeStarted;
[SerializeField] ChallengeEventChannelSO _onChallengeCompleted;
[SerializeField] ChallengeEventChannelSO _onChallengeFailed;
[SerializeField] IntEventChannelSO _onWaveChanged; // 当前波次编号
[SerializeField] FloatEventChannelSO _onTimerUpdated; // 计时器
// 运行时状态
ChallengeRoomSO _current;
ChallengeState _state;
int _currentWave;
int _enemiesRemainingInWave;
float _elapsedTime;
int _hitsTaken;
int _maxCombo;
bool _inChallenge;
// 进入挑战(由 ChallengeRoomTrigger 调用)
public void BeginChallenge(ChallengeRoomSO challenge)
{
if (_inChallenge) return;
_current = challenge;
_state = ChallengeState.Running;
_inChallenge = true;
_currentWave = 0;
_elapsedTime = 0f;
_hitsTaken = 0;
_maxCombo = 0;
// 存档当前状态(挑战失败时回档)
SaveManager.Instance.CreateChallengeCheckpoint();
// 封锁出口
LockExits(true);
_onChallengeStarted.Raise(challenge);
if (challenge.challengeType == ChallengeType.BossRush)
StartBossRush();
else
SpawnNextWave();
}
void Update()
{
if (!_inChallenge || _state != ChallengeState.Running) return;
_elapsedTime += Time.deltaTime;
_onTimerUpdated.Raise(_elapsedTime);
// 超时判定
if (_current.timeLimit > 0 && _elapsedTime >= _current.timeLimit)
FailChallenge(ChallengeFailReason.Timeout);
}
void HandleEnemyDefeated(string enemyId)
{
if (!_inChallenge) return;
_enemiesRemainingInWave--;
if (_enemiesRemainingInWave <= 0)
{
_currentWave++;
bool isLastWave = _currentWave >= _current.encounters.Length;
if (isLastWave)
CompleteChallenge();
else
SpawnNextWave();
}
}
void HandlePlayerDied()
{
if (!_inChallenge) return;
FailChallenge(ChallengeFailReason.Death);
}
void HandleSaveRequested()
{
if (_inChallenge)
{
// 静默拦截,阻止存档,可选弹出"挑战中无法存档"提示
UIManager.Instance.ShowNotification("挑战进行中,无法存档。");
}
}
void HandlePlayerHurt()
{
if (!_inChallenge) return;
_hitsTaken++;
// NoHit 类型:受击立即判定失败
if (_current.requireNoHit)
FailChallenge(ChallengeFailReason.ConditionNotMet);
}
void HandleComboChanged(int combo)
{
if (!_inChallenge) return;
if (combo > _maxCombo) _maxCombo = combo;
}
async void SpawnNextWave()
{
var encounter = _current.encounters[_currentWave];
_enemiesRemainingInWave = encounter.TotalEnemyCount;
// 波次延迟(关卡设计师可在 SO 中调整0 = 立即生成)
if (encounter.waveDelay > 0)
await UniTask.Delay(TimeSpan.FromSeconds(encounter.waveDelay));
foreach (var spawnInfo in encounter.spawnInfos)
for (int i = 0; i < spawnInfo.count; i++)
Instantiate(spawnInfo.enemyPrefab, spawnInfo.spawnPoint.position, Quaternion.identity);
_onWaveChanged.Raise(_currentWave + 1);
}
void CompleteChallenge()
{
_state = ChallengeState.Completed;
// 无伤校验
bool noHitSuccess = !_current.requireNoHit || _hitsTaken == 0;
// 连击校验
bool comboSuccess = _current.minComboRequired == 0 || _maxCombo >= _current.minComboRequired;
if (!noHitSuccess || !comboSuccess)
{
FailChallenge(ChallengeFailReason.ConditionNotMet);
return;
}
// 记录成绩
ChallengeLeaderboard.Instance.Submit(
_current.challengeId, _elapsedTime, _hitsTaken, _maxCombo);
// 发放奖励
bool isFirstClear = !SaveManager.Instance.IsChallengeCleared(_current.challengeId);
var reward = isFirstClear ? _current.firstClearReward : _current.repeatedReward;
RewardHandler.Instance.GrantChallengeReward(reward, _elapsedTime);
if (isFirstClear)
SaveManager.Instance.SetChallengeCleared(_current.challengeId);
LockExits(false);
_inChallenge = false;
_onChallengeCompleted.Raise(_current);
}
void FailChallenge(ChallengeFailReason reason)
{
_state = ChallengeState.Failed;
_inChallenge = false;
LockExits(false);
_onChallengeFailed.Raise(_current);
// 回档至挑战检查点
SaveManager.Instance.LoadChallengeCheckpoint();
}
void StartBossRush() { /* Boss Rush 专属流程,见 §4.1 */ }
void LockExits(bool locked) { /* 激活/停用场景内所有 ChallengeExitDoor */ }
void OnEnable()
{
_onPlayerDied.OnEventRaised += HandlePlayerDied;
_onEnemyDefeated.OnEventRaised += HandleEnemyDefeated;
_onPlayerSaveRequested.OnEventRaised += HandleSaveRequested;
_onPlayerHurt.OnEventRaised += HandlePlayerHurt;
_onComboChanged.OnEventRaised += HandleComboChanged;
}
void OnDisable()
{
_onPlayerDied.OnEventRaised -= HandlePlayerDied;
_onEnemyDefeated.OnEventRaised -= HandleEnemyDefeated;
_onPlayerSaveRequested.OnEventRaised -= HandleSaveRequested;
_onPlayerHurt.OnEventRaised -= HandlePlayerHurt;
_onComboChanged.OnEventRaised -= HandleComboChanged;
}
}
public enum ChallengeState
{
Idle, Running, Completed, Failed,
}
public enum ChallengeFailReason
{
Death, Timeout, ConditionNotMet,
}
}
```
### 4.1 Boss Rush 流程
```csharp
async void StartBossRush()
{
var seq = _current.bossRushSequence;
// 过滤出已击败(已解锁)的 Boss
var entries = seq.entries.Where(e => SaveManager.Instance.IsBossDefeated(e.bossId)).ToArray();
if (entries.Length == 0) { FailChallenge(ChallengeFailReason.ConditionNotMet); return; }
for (int i = 0; i < entries.Length; i++)
{
var entry = entries[i];
// 显示过渡画面(肖像 + 名字 + 倒计时)
await UIManager.Instance.ShowBossRushTransition(entry, i + 1, entries.Length);
// Additive 加载 Boss 场景
await SceneLoader.Instance.LoadSceneAdditiveAsync(entry.bossSceneName);
// 等待 BossBase 通过事件频道注册自身(零 FindObjectOfType
// BossBase.OnEnable() 中应调用_onBossActivated.Raise(this)
BossBase activeBoss = null;
void OnBossReg(BossBase b) => activeBoss = b;
_onBossActivated.OnEventRaised += OnBossReg;
await UniTask.WaitUntil(() => activeBoss != null || _state == ChallengeState.Failed);
_onBossActivated.OnEventRaised -= OnBossReg;
if (_state == ChallengeState.Failed) return;
// 将 Boss 属性乘以难度系数
activeBoss.ApplyDifficultyMultiplier(seq.difficultyMultiplier);
// 等待 Boss 被击败
bool bossDefeated = false;
void OnBossEnd() => bossDefeated = true;
activeBoss.OnDefeated += OnBossEnd;
await UniTask.WaitUntil(() => bossDefeated || _state == ChallengeState.Failed);
activeBoss.OnDefeated -= OnBossEnd;
if (_state == ChallengeState.Failed) return;
// 卸载 Boss 场景
await SceneLoader.Instance.UnloadSceneAsync(entry.bossSceneName);
// 间歇期恢复
if (i < entries.Length - 1)
{
PlayerStats.Instance.RestoreHPPercent(seq.interludeHPRestorePercent);
await UniTask.Delay(TimeSpan.FromSeconds(seq.interludeDuration));
}
}
CompleteChallenge();
}
```
---
## 5. 挑战类型详解
### 5.1 Survival — 消灭所有敌人
- 无时限(或宽松时限 = 奖励条件,不影响通关)
- 多波次,每波全歼后自动刷新下一波
- 通关条件:所有波次清空
### 5.2 TimeTrial — 时限挑战
- 严格时限:超时 = 失败
- 根据完成时间评级S/A/B/C不同评级给予不同重复奖励
- 排行榜记录最佳时间
```csharp
// 评级计算
static ChallengeGrade EvaluateGrade(float time, ChallengeRoomSO room)
{
float ratio = time / room.timeLimit;
return ratio switch
{
<= 0.5f => ChallengeGrade.S,
<= 0.7f => ChallengeGrade.A,
<= 0.85f => ChallengeGrade.B,
_ => ChallengeGrade.C,
};
}
```
### 5.3 BossRush — Boss Rush
- 顺序重战所有已击败 Boss
- 每场 Boss 间恢复 30% HP不恢复 Soul
- 完整通关(全 Boss给予专属护符奖励
- 排行榜记录总用时
### 5.4 NoHit — 无伤挑战
- 受任何来自敌人的伤害 = 立即失败
- 通常是已有挑战房间的"无伤变体"(同场景,更严格条件,更好奖励)
### 5.5 Endless — 无限波次
- 波次无上限,随波次增加难度系数(敌人 HP/ATK × 1 + 波次 × 0.1
- 排行榜记录最高到达波次
- 无通关概念,玩家死亡即结束
---
## 6. 奖励系统
```csharp
[CreateAssetMenu(menuName = "Challenge/ChallengeReward")]
public class ChallengeRewardSO : ScriptableObject
{
[Header("固定奖励(首次通关)")]
public ItemSO[] exclusiveItems; // 专属护符/工具(不可从其他途径获得)
public int geoBonus;
[Header("分级奖励(重复挑战)")]
public GradeReward[] gradeRewards; // 不同评级对应不同 Geo 奖励
[Header("排行榜条件奖励(可选)")]
public int rankRewardGeo; // 连续 3 次破纪录时额外奖励
}
[Serializable]
public class GradeReward
{
public ChallengeGrade grade;
public int geo;
public string unlockedTitle; // 解锁标题HUD 显示,如 "快手剑士"
}
public enum ChallengeGrade { S, A, B, C, F }
```
---
## 7. 排行榜(本地)
```csharp
public class ChallengeLeaderboard : MonoBehaviour
{
// 每个挑战的最佳记录(本地,不联网)
readonly Dictionary<string, LeaderboardEntry> _records = new();
public void Submit(string challengeId, float time, int hits, int maxCombo)
{
if (!_records.TryGetValue(challengeId, out var best) || time < best.time)
{
_records[challengeId] = new LeaderboardEntry(challengeId, time, hits, maxCombo);
SaveManager.Instance.SetLeaderboardData(_records);
}
}
public LeaderboardEntry GetBest(string challengeId)
=> _records.TryGetValue(challengeId, out var e) ? e : null;
}
[Serializable]
public class LeaderboardEntry
{
public string challengeId;
public float time; // 最佳时间(秒)
public int hits; // 受击次数(无伤 = 0
public int maxCombo; // 最高连击
public ChallengeGrade grade;
public string timestamp; // ISO 8601 日期字符串
}
```
### SaveData 中排行榜字段
```json
"leaderboard": {
"Challenge_Forest_Elite": {
"time": 87.4,
"hits": 0,
"maxCombo": 34,
"grade": "S",
"timestamp": "2025-06-01T12:00:00"
}
}
```
---
## 8. SaveData 集成
```json
"challenges": {
"cleared": ["Challenge_Forest_Elite", "Challenge_BossRush"],
"inProgress": null
}
```
```csharp
// SaveManager 扩展
public bool IsChallengeCleared(string id)
=> _saveData.challenges.cleared.Contains(id);
public void SetChallengeCleared(string id)
{
if (!_saveData.challenges.cleared.Contains(id))
{
_saveData.challenges.cleared.Add(id);
WriteDirty();
}
}
// 挑战检查点(失败时回档)
public void CreateChallengeCheckpoint()
{
_challengeCheckpoint = JsonConvert.SerializeObject(_saveData);
}
public void LoadChallengeCheckpoint()
{
if (_challengeCheckpoint == null) return;
_saveData = JsonConvert.DeserializeObject<SaveData>(_challengeCheckpoint);
_challengeCheckpoint = null;
ApplySaveDataToGame();
}
```
---
## 9. 事件频道
| 频道资产 | 类型 | 发布方 | 主要订阅方 |
|---------|------|--------|----------|
| `OnChallengeStarted.asset` | `ChallengeEventChannelSO` | `ChallengeRoomManager` | `ChallengeHUD`(显示计时器)、`AudioManager`(播放挑战音乐)|
| `OnChallengeCompleted.asset` | `ChallengeEventChannelSO` | `ChallengeRoomManager` | `ChallengeHUD`(完成演出)、`AchievementManager` |
| `OnChallengeFailed.asset` | `ChallengeEventChannelSO` | `ChallengeRoomManager` | `ChallengeHUD`(失败提示)、`AudioManager` |
| `OnWaveChanged.asset` | `IntEventChannelSO` | `ChallengeRoomManager` | `ChallengeHUD`(波次显示)|
| `OnTimerUpdated.asset` | `FloatEventChannelSO` | `ChallengeRoomManager` | `ChallengeHUD`(计时器刷新)|
```csharp
[CreateAssetMenu(menuName = "Events/ChallengeEventChannel")]
public class ChallengeEventChannelSO : ScriptableObject
{
public event Action<ChallengeRoomSO> OnEventRaised;
public void Raise(ChallengeRoomSO challenge) => OnEventRaised?.Invoke(challenge);
}
```
---
## 10. HUD 扩展
挑战 HUD 在进入挑战时由 `ChallengeHUD` 组件动态覆盖在常规 HUD 之上:
```
┌─ 挑战 HUD锁定在屏幕上方中央────────────────────────┐
│ ► 森林精英挑战 · 波次 2/4 · ⏱ 01:27.4 │
│ 连击×23 │
└────────────────────────────────────────────────────────┘
```
Boss Rush 专用 HUD 显示 Boss 序列进度:
```
┌─ Boss Rush HUD ─────────────────────────────────────────┐
│ [✓小鬼将军] [►毒蜘蛛女王] [ 深渊守卫] [ 破晓术士] │
│ ⏱ 总用时03:14.8 │
└────────────────────────────────────────────────────────┘
```
---
## 11. 编辑器友好设计
### 挑战房间场景搭建规范
```
场景结构Hierarchy:
├─ ChallengeRoomManager ← 挂载 ChallengeRoomManager.cs
├─ ChallengeRoomTrigger ← 触发挑战的入口碰撞体
│ └─ TriggerZone.cs进入时调用 BeginChallenge
├─ ChallengeExitDoors ← 出口碰撞体组(挑战中锁定)
│ ├─ ExitDoor_Left
│ └─ ExitDoor_Right
├─ SpawnPoints ← 敌人生成点(由 Encounter 引用)
│ ├─ SP_01 ~ SP_08
└─ Environment ← 场景装饰(可与主场景共用 Tilemap
```
### 新增挑战 SOP零代码
1. `Create → Challenge/ChallengeRoom` → 填写 ID、类型、奖励
2. 为每波创建 `Create → Challenge/Encounter`,拖入敌人预制件和生成点
3. 在场景中放置 `ChallengeRoomTrigger`,引用该 ChallengeRoomSO
4. 配置 `ChallengeExitDoors`(自动由 Manager 锁定)
### ChallengeRoomManager InspectorPlay Mode
```
┌─ ChallengeRoomManager ─────────────────────────────────┐
│ 挑战: Forest_Elite 状态: Running │
│ 波次: 2/4 剩余敌人: 3 用时: 01:27.4 │
│ 受击: 0 最高连击: 23 │
│ ─────────────────────────────────────────────────────│
│ [强制成功] [强制失败] [跳过当前波次] │
└────────────────────────────────────────────────────────┘
```
### 11.1 ChallengeEncounterSO Inspector
```
┌─ ChallengeEncounterSO ─────────────────────────────────┐
│ 波次延迟: 0.5 秒 │
│ 敌人列表: │
│ [0] 精英骑士 × 2 生成点: SP_01 │
│ [1] 弓箭手 × 3 生成点: SP_03 │
│ 总敌人数: 5只读
└────────────────────────────────────────────────────────┘
```
---
## 12. ChallengeRoomTrigger — 场景触发器
`ChallengeRoomTrigger` 是放置在挑战房间入口处的碰撞触发器,负责在玩家进入时启动挑战流程。
```csharp
namespace BaseGames.Challenge
{
/// <summary>
/// 挂载于挑战房间入口碰撞体Trigger
/// 玩家进入后调用 ChallengeRoomManager.BeginChallenge()。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class ChallengeRoomTrigger : MonoBehaviour
{
[SerializeField] ChallengeRoomSO _challenge; // 此触发器对应的挑战数据
[SerializeField] ChallengeRoomManager _manager; // 同场景的 Manager 引用
[Header("解锁门(可选)")]
[Tooltip("未达到解锁条件时显示的阻挡碰撞体,条件满足后自动禁用")]
[SerializeField] GameObject _lockBarrier;
void Start()
{
// 检查解锁条件,未解锁则显示阻挡门
bool unlocked = IsUnlocked();
if (_lockBarrier != null)
_lockBarrier.SetActive(!unlocked);
// 触发器本体也跟随解锁状态
GetComponent<Collider2D>().enabled = unlocked;
}
void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
_manager.BeginChallenge(_challenge);
}
bool IsUnlocked()
{
// Boss 前置条件
foreach (var bossId in _challenge.prerequisiteBossIds)
if (!SaveManager.Instance.IsBossDefeated(bossId)) return false;
// Geo 门槛
if (_challenge.minGeo > 0 &&
PlayerStats.Instance.CurrentGeo < _challenge.minGeo)
return false;
return true;
}
#if UNITY_EDITOR
void OnDrawGizmos()
{
// 在 Scene 视图中高亮触发区和解锁状态
Gizmos.color = IsUnlocked() ? new Color(0f, 1f, 0.5f, 0.25f)
: new Color(1f, 0.2f, 0.2f, 0.25f);
var col = GetComponent<Collider2D>();
if (col != null) Gizmos.DrawCube(col.bounds.center, col.bounds.size);
GizmosHelper.Label(transform.position + Vector3.up * 1.2f,
$"[挑战] {_challenge?.displayName ?? ""}");
}
#endif
}
}
```
### 场景放置规范
```
ChallengeRoomTrigger 组件设置:
├─ _challenge → 拖入对应 ChallengeRoomSO 资产
├─ _manager → 拖入同场景 ChallengeRoomManager 对象
└─ _lockBarrier → 可选,拖入阻挡玩家的门碰撞体 GameObject
Collider2D 设置:
├─ Is Trigger: ✓
├─ Layer: Interaction仅与 Player Layer 相交)
└─ 尺寸建议: 宽覆盖整个入口通道(防止贴墙穿越)
```
---
## 13. 程序集依赖
```
BaseGames.Challenge
├── BaseGames.Core.Events SO 事件频道基类)
├── BaseGames.World SceneLoader、SaveManager
├── BaseGames.Boss BossBase、BossEventChannelSO
├── BaseGames.Player PlayerStats、PlayerCombat — 受击/连击事件)
└── BaseGames.UI UIManager、HUD 接口)
```
`BaseGames.Challenge` 对上述程序集**只持有接口/事件频道引用**,不直接 `using` 具体实现类型(除 `BossBase` 类型引用用于事件参数)。
### 关键事件频道资产清单
| 资产路径 | SO 类型 | 方向 |
|---------|---------|------|
| `Events/Challenge/OnChallengeStarted.asset` | `ChallengeEventChannelSO` | Manager → HUD / Audio |
| `Events/Challenge/OnChallengeCompleted.asset` | `ChallengeEventChannelSO` | Manager → HUD / Achievement |
| `Events/Challenge/OnChallengeFailed.asset` | `ChallengeEventChannelSO` | Manager → HUD / Audio |
| `Events/Challenge/OnWaveChanged.asset` | `IntEventChannelSO` | Manager → HUD |
| `Events/Challenge/OnTimerUpdated.asset` | `FloatEventChannelSO` | Manager → HUD |
| `Events/Player/OnPlayerHurt.asset` | `VoidEventChannelSO` | PlayerCombat → Manager |
| `Events/Player/OnComboChanged.asset` | `IntEventChannelSO` | PlayerCombat → Manager |
| `Events/Boss/OnBossActivated.asset` | `BossEventChannelSO` | BossBase → Manager |
### BossEventChannelSO 定义
```csharp
[CreateAssetMenu(menuName = "Events/BossEventChannel")]
public class BossEventChannelSO : ScriptableObject
{
public event Action<BossBase> OnEventRaised;
public void Raise(BossBase boss) => OnEventRaised?.Invoke(boss);
}
```
> **BossBase 接入点**`BossBase.OnEnable()` 中调用 `_onBossActivated.Raise(this)`
> 使 `ChallengeRoomManager` 在 Boss Rush 中可零耦合获取 Boss 引用。