807 lines
28 KiB
Markdown
807 lines
28 KiB
Markdown
# 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 Inspector(Play 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 引用。
|