Files
zeling_v2/Docs/Architecture/22_QuestChallengeModule.md
2026-05-12 15:34:08 +08:00

28 KiB
Raw Permalink Blame History

22 · 任务与挑战房间模块Quest & Challenge Module

命名空间 BaseGames.Quest / BaseGames.Challenge
程序集 BaseGames.QuestAssets/Scripts/Quest/
依赖 BaseGames.Core.Events · BaseGames.DialogueInteractableNPC· BaseGames.WorldSaveManager · SceneLoader· BaseGames.PlayerPlayerStats
Design 来源 38_QuestSystem · 39_ChallengeRoomSystem


目录

Part A — 任务系统

  1. 任务系统职责
  2. QuestSO
  3. QuestObjectiveSO
  4. RewardSO
  5. QuestManager
  6. QuestGiver
  7. SaveData 集成(任务)

Part B — 挑战房间

  1. 挑战房间系统职责
  2. ChallengeRoomSO
  3. ChallengeEncounterSO
  4. BossRushSequenceSO
  5. ChallengeRoomManager
  6. ChallengeRoomTrigger
  7. 事件频道

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_CollectiblePickupitemId
        [SerializeField] StringEventChannelSO           _onSceneLoaded;          // EVT_SceneLoadedsceneName
        [SerializeField] StringEventChannelSO           _onNpcDialogueCompleted; // EVT_NpcDialogueCompletednpcId

        // 分拆为粒度更细的事件频道(替代旧 _onQuestStateChanged 单频道)
        [SerializeField] StringEventChannelSO           _onQuestStarted;         // RaisequestId
        [SerializeField] StringEventChannelSO           _onQuestCompleted;       // RaisequestId
        [SerializeField] StringEventChannelSO           _onQuestFailed;          // RaisequestId
        [SerializeField] QuestObjectiveEventChannelSO   _onObjectiveUpdated;     // RaiseobjectiveId + 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 获取敌人 IDEnemyBase.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 RushBossRush 类型专用)")]
        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_ChallengeCompletedchallengeId
        [SerializeField] StringEventChannelSO      _onChallengeFailed;      // → EVT_ChallengeFailedchallengeId
        // ⚠️ PlayerController 无 InstanceArchitecture 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