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

28 KiB
Raw Permalink Blame History

39 · 挑战房间与 Boss Rush 系统Challenge Room System

命名空间 BaseGames.Challenge
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.WorldSceneLoader、SaveManager· BaseGames.BossBossBase· BaseGames.PlayerPlayerStats· BaseGames.UIHUD


目录

  1. 系统总览
  2. ChallengeRoomSO — 挑战定义
  3. BossRushSequenceSO — Boss Rush 序列
  4. ChallengeRoomManager — 核心管理器
  5. 挑战类型详解
  6. 奖励系统
  7. 排行榜(本地)
  8. SaveData 集成
  9. 事件频道
  10. HUD 扩展
  11. 编辑器友好设计
  12. ChallengeRoomTrigger — 场景触发器
  13. 程序集依赖

1. 系统总览

挑战房间是独立于主线的高难度可重复内容:封闭区域内完成限定条件(时限/无伤/连击数获取专属奖励。Boss Rush 是挑战房间的特殊变体,允许玩家按原始顺序重新挑战所有已击败 Boss。

挑战房间系统职责:
  ├─ ChallengeRoomSO          → 挑战数据(敌人波次/时限/奖励/解锁条件)
  ├─ BossRushSequenceSO       → Boss Rush 专属数据Boss 序列/难度系数)
  ├─ ChallengeRoomManager     → 运行时管理挑战流程(进入/波次推进/成功/失败)
  ├─ ChallengeEncounterSO     → 单波敌人配置(生成点/敌人类型/数量)
  ├─ ChallengeLeaderboard     → 本地最佳成绩记录(时间/无伤/连击)
  └─ ChallengeHUD             → 计时器 / 波次进度 / 连击数屏幕显示

核心规则

  • 进入挑战房间时自动存档当前位置和状态,退出/失败后原地读档
  • 挑战期间禁止手动存档(存档点不可用)
  • 挑战期间死亡 = 挑战失败(不触发正常死亡流程)
  • Boss Rush 特殊规则:每场 Boss 战之间恢复 30% HP不恢复 Soul

2. ChallengeRoomSO — 挑战定义

[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 — 单波次配置

/// <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 序列

[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 — 核心管理器

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 流程

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不同评级给予不同重复奖励
  • 排行榜记录最佳时间
// 评级计算
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. 奖励系统

[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. 排行榜(本地)

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 中排行榜字段

"leaderboard": {
  "Challenge_Forest_Elite": {
    "time": 87.4,
    "hits": 0,
    "maxCombo": 34,
    "grade": "S",
    "timestamp": "2025-06-01T12:00:00"
  }
}

8. SaveData 集成

"challenges": {
  "cleared": ["Challenge_Forest_Elite", "Challenge_BossRush"],
  "inProgress": null
}
// 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(计时器刷新)
[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 是放置在挑战房间入口处的碰撞触发器,负责在玩家进入时启动挑战流程。

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 定义

[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 引用。