# 02 · SO 事件系统 > **命名空间** `BaseGames.Core.Events` > **程序集** `BaseGames.Core.Events`(无任何运行时依赖,最底层) > **路径** `Assets/Scripts/Core/Events/` --- ## 目录 1. [设计原则](#1-设计原则) 2. [泛型事件频道基类](#2-泛型事件频道基类) 3. [所有事件频道类型](#3-所有事件频道类型) 4. [全局事件频道 SO 资产清单](#4-全局事件频道-so-资产清单) 5. [发布 / 订阅模式](#5-发布--订阅模式) 6. [MMEventManager(备用广播)](#6-mmeventmanager) 7. [EventChannelRegistry](#7-eventchannelregistry) 8. [事件订阅生命周期管理 — 自动注销机制](#8-事件订阅生命周期管理--自动注销机制) 9. [EventBusMonitorWindow — 运行时事件监控面板](#9-eventbusmonitorwindow--运行时事件监控面板) --- ## 1. 设计原则 - 每个事件频道是一个 **ScriptableObject**,在 `Assets/Data/Events/` 下预建资产,Inspector 拖拽引用 - 发布者和订阅者**只持有频道 SO 的引用**,彼此完全不知道对方的存在 - 事件频道**不存储状态**;仅在 `Raise()` 调用时执行 C# Action 委托链 - **禁止在代码中 `new` 出事件频道**;必须使用预建的 `.asset` 资产 - 所有频道订阅在 `OnEnable` 注册,在 `OnDisable` 取消注册 --- ## 2. 泛型事件频道基类 ### 文件:`Assets/Scripts/Core/Events/BaseEventChannelSO.cs` ```csharp using System; using UnityEngine; namespace BaseGames.Core.Events { /// /// 泛型 SO 事件频道基类。T 为负载类型。 /// public abstract class BaseEventChannelSO : ScriptableObject { // Editor 备注(Inspector 可见) [Multiline] public string description; public event Action OnEventRaised; /// /// 发布事件,执行所有订阅委托。 /// public void Raise(T value) { OnEventRaised?.Invoke(value); } } /// /// 无负载事件频道基类。 /// public abstract class VoidBaseEventChannelSO : ScriptableObject { [Multiline] public string description; public event Action OnEventRaised; public void Raise() { OnEventRaised?.Invoke(); #if UNITY_EDITOR EventBusMonitor.Record(name, "", OnEventRaised?.GetInvocationList().Length ?? 0); #endif } } } ``` --- ## 3. 所有事件频道类型 ### 基础类型频道 ```csharp // Assets/Scripts/Core/Events/VoidEventChannelSO.cs [CreateAssetMenu(menuName = "Events/Void")] public class VoidEventChannelSO : VoidBaseEventChannelSO { } // Assets/Scripts/Core/Events/BoolEventChannelSO.cs [CreateAssetMenu(menuName = "Events/Bool")] public class BoolEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/IntEventChannelSO.cs [CreateAssetMenu(menuName = "Events/Int")] public class IntEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/FloatEventChannelSO.cs [CreateAssetMenu(menuName = "Events/Float")] public class FloatEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/StringEventChannelSO.cs [CreateAssetMenu(menuName = "Events/String")] public class StringEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/Vector2EventChannelSO.cs [CreateAssetMenu(menuName = "Events/Vector2")] public class Vector2EventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/TransformEventChannelSO.cs [CreateAssetMenu(menuName = "Events/Transform")] public class TransformEventChannelSO : BaseEventChannelSO { } ``` ### 游戏专用负载频道 ```csharp // Assets/Scripts/Core/Events/GameStateEventChannelSO.cs // ⚠️ payload 类型为 GameStateId(值类型 struct),非旧 GameState 枚举 [CreateAssetMenu(menuName = "Events/GameState")] public class GameStateEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/SceneLoadRequestEventChannelSO.cs [CreateAssetMenu(menuName = "Events/SceneLoadRequest")] public class SceneLoadRequestEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/DamageInfoEventChannelSO.cs [CreateAssetMenu(menuName = "Events/DamageInfo")] public class DamageInfoEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/ShopPurchaseEventChannelSO.cs [CreateAssetMenu(menuName = "Events/ShopPurchase")] public class ShopPurchaseEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/DialogueEventChannelSO.cs [CreateAssetMenu(menuName = "Events/DialogueRequest")] public class DialogueEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/BossSkillEventChannelSO.cs [System.Serializable] public struct BossSkillEvent { public string BossId; public string SkillId; } [CreateAssetMenu(menuName = "Events/BossSkill")] public class BossSkillEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/BossPhaseEventChannelSO.cs [System.Serializable] public struct BossPhaseEvent { public string BossId; public int Phase; } [CreateAssetMenu(menuName = "Events/BossPhase")] public class BossPhaseEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/AbilityTypeEventChannelSO.cs // AbilityType 定义于 09_ProgressionModule §1(enum AbilityType) [CreateAssetMenu(menuName = "Events/AbilityType")] public class AbilityTypeEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/HitConfirmedEventChannelSO.cs [System.Serializable] public struct HitInfo { public DamageInfo DamageInfo; public Vector3 HitPoint; } [CreateAssetMenu(menuName = "Events/HitConfirmed")] public class HitConfirmedEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/ColorblindModeEventChannelSO.cs // ColorblindMode 枚举定义于 16_SupportingModules §AccessibilityManager [CreateAssetMenu(menuName = "Events/ColorblindMode")] public class ColorblindModeEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/DifficultyChangedEventChannel.cs // ⚠️ 命名按 Architecture 19 约定:不加 "SO" 后缀(与其他频道类有意区分以标识自定义 payload) // DifficultyScalerSO 定义于 19_DifficultyModule §3 [CreateAssetMenu(menuName = "Events/DifficultyChanged")] public class DifficultyChangedEventChannel : BaseEventChannelSO { } // Assets/Scripts/Core/Events/LiquidEventChannelSO.cs // LiquidZone 定义于 21_LiquidPuzzleModule §4 [CreateAssetMenu(menuName = "Events/LiquidZone")] public class LiquidEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/WorldMarkerEventChannelSO.cs // WorldMarker 定义于 21_LiquidPuzzleModule §14 [CreateAssetMenu(menuName = "Events/WorldMarker")] public class WorldMarkerEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/QuestStateChangedEventChannel.cs // QuestState 枚举定义于 22_QuestChallengeModule §QuestSO [System.Serializable] public struct QuestStateChangedEvent { public string QuestId; public QuestState State; } [CreateAssetMenu(menuName = "Events/QuestStateChanged")] public class QuestStateChangedEventChannel : BaseEventChannelSO { } // Assets/Scripts/Core/Events/QuestObjectiveEventChannelSO.cs // 任务目标进度频道(用于 QuestManager 逐目标通知订阅者) [System.Serializable] public struct QuestObjectiveEvent { public string QuestId; public string ObjectiveId; public int Progress; public int Required; } [CreateAssetMenu(menuName = "Events/QuestObjective")] public class QuestObjectiveEventChannelSO : BaseEventChannelSO { } // Assets/Scripts/Core/Events/StatusEffectEventChannelSO.cs // 状态效果频道(StatusEffectManager 施加/过期时广播;StatusEffectType 定义于 06_CombatModule §11) [CreateAssetMenu(menuName = "Events/StatusEffect")] public class StatusEffectEventChannelSO : BaseEventChannelSO { } ``` --- ## 4. 全局事件频道 SO 资产清单 所有频道资产预建于 `Assets/Data/Events/`,资产文件名格式:`EVT_{描述}.asset` ### Core 系统 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_GameStateChanged` | `GameStateEventChannelSO` | `GameManager` | `UIManager`、`AudioManager`、`InputManager` | | `EVT_SceneLoadRequest` | `SceneLoadRequestEventChannelSO` | `RoomTransition`、`GameManager` | `SceneLoader` | | `EVT_SceneLoaded` | `StringEventChannelSO` | `SceneLoader` | `GameManager`、`MapManager` | | `EVT_PauseRequested` | `VoidEventChannelSO` | `InputReader`(Pause 键) | `GameManager` | | `EVT_DifficultyChanged` | `DifficultyChangedEventChannel` | `DifficultyManager.Apply()` | `PlayerStats`、`EnemyStats`(缩放属性) | ### 玩家系统 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_PlayerDied` | `VoidEventChannelSO` | `PlayerStats`(HP ≤ 0) | `GameManager`、`UIManager`、`AudioManager` | | `EVT_DeathScreenConfirmed` | `VoidEventChannelSO` | `DeathScreenController`(Respawn 按钮) | `GameManager`(启动 RespawnCoroutine) | | `EVT_PlayerRespawned` | `VoidEventChannelSO` | `GameManager`(复活流程末) | `UIManager`、`AudioManager` | | `EVT_HPChanged` | `IntEventChannelSO` | `PlayerStats.TakeDamage/HealHP` | `HUDController`(血量 UI) | | `EVT_MaxHPChanged` | `IntEventChannelSO` | `PlayerStats.UnlockHP` | `HUDController` | | `EVT_SoulPowerChanged` | `IntEventChannelSO` | `PlayerStats.AddSoulPower` | `HUDController` | | `EVT_SpiritPowerChanged` | `IntEventChannelSO` | `PlayerStats.AddSpiritPower` | `HUDController` | | `EVT_SpringChargesChanged` | `IntEventChannelSO` | `PlayerStats.UseSpring/RestoreSpring` | `HUDController` | | `EVT_GeoChanged` | `IntEventChannelSO` | `PlayerStats.AddGeo` | `HUDController` | | `EVT_AbilityUnlocked` | `StringEventChannelSO`(abilityId) | `PlayerStats.UnlockAbility` | `AbilityGate`、`HUDController`、`TutorialManager` | | `EVT_PlayerFormChanged` | `IntEventChannelSO`(FormType) | `FormController` | `HUDController`、`AudioManager` | | `EVT_SkillSetChanged` | `VoidEventChannelSO` | `FormController` | `SkillHUD`(刷新技能栏 UI) | | `EVT_ParrySuccess` | `VoidEventChannelSO` | `ParrySystem` | `PlayerStats`(+20 SoulPower)、`Feedback`、`AudioManager` | | `EVT_ShieldHPChanged` | `IntEventChannelSO` | `ShieldComponent`(盾牌受击/修复) | `HUDController`(盾槽 UI) | ### 战斗系统 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_EnemyDied` | `TransformEventChannelSO` | `EnemyBase`(HP ≤ 0) | `PlayerStats`(击杀点)、`QuestManager` | | `EVT_HitConfirmed` | `HitConfirmedEventChannelSO`(HitInfo) | `HurtBox.ReceiveDamage` | `HitFXSpawner`、`AudioManager`、`PlayerFeedback`(受击方) | | `EVT_DamageDealt` | `DamageInfoEventChannelSO` | `HurtBox.ReceiveDamage` | `AnalyticsManager` | | `EVT_BossFightStarted` | `StringEventChannelSO`(bossId) | `BossOrchestrator` | `GameManager`(切换 BossFight 状态)、`AudioManager` | | `EVT_BossFightEnded` | `BoolEventChannelSO`(胜利/失败) | `BossBase.Die()` | `GameManager`、`AudioManager`、`UIManager` | | `EVT_BossFightToggled` | `BoolEventChannelSO`(true=开始,false=结束) | `BossOrchestrator`(开始)、`BossBase.Die()`(结束) | `BossHPBar`(显示/隐藏 Boss 血条) | | `EVT_BossHPChanged` | `IntEventChannelSO` | `BossBase`(受击/回血) | `BossHPBar`(HP 进度条更新) | | `EVT_BossNameSet` | `StringEventChannelSO`(bossName) | `BossOrchestrator`(战斗开始时) | `BossHPBar`(显示 Boss 名称标签) | | `EVT_BossHPMaxSet` | `IntEventChannelSO` | `BossOrchestrator`(战斗开始时) | `BossHPBar`(初始化满血值) | | `EVT_BossPhaseChanged` | `BossPhaseEventChannelSO`(bossId, phase) | `BossBase.EnterPhase()` | `BossHUD`(相机切换通过 `CameraStateController.Instance` 直接调用,不订阅事件)| | `EVT_BossSkillStarted` | `BossSkillEventChannelSO`(bossId, skillId) | `BossSkillExecutor` | `BossHUD`(显示技能名)(震动通过 `CameraStateController.Instance.TriggerImpulse()` 直接调用)| | `EVT_BossSkillEnded` | `BossSkillEventChannelSO`(bossId, skillId) | `BossSkillExecutor` | `BossOrchestrator`(BD 决策推进) | | `EVT_BossVulnerabilityWindowOpened` | `StringEventChannelSO`(bossId) | `WeakPointSystem` | `PlayerFeedback`(提示音效)、`HUDController` | | `EVT_NailClash` | `VoidEventChannelSO` | `ClashResolver`(玩家与敌人 HitBox 对碰) | `VFXSpawner`(拼刀特效)、`AudioManager`(拼刀音效)、`CameraStateController`(轻振动) | | `EVT_StatusEffectApplied` | `StatusEffectEventChannelSO` | `StatusEffectManager.ApplyEffect()` | `HUDController`(状态图标)、`PlayerFeedback`(特效) | | `EVT_StatusEffectExpired` | `StatusEffectEventChannelSO` | `StatusEffectManager.Tick()` | `HUDController`(移除状态图标) | ### 世界系统 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_RoomTransitionRequest` | `SceneLoadRequestEventChannelSO` | `RoomTransition` | `SceneLoader` | | `EVT_SavePointActivated` | `StringEventChannelSO`(saveId) | `SavePoint` | `GameManager`(触发存档)、`HUDController` | | `EVT_FastTravelOpen` | `VoidEventChannelSO` | `SavePoint`(快速旅行已解锁) | `UIManager`(显示快速旅行面板) | | `EVT_CollectiblePickup` | `StringEventChannelSO`(itemId) | `Collectible` | `PlayerStats`、`QuestManager`、`AnalyticsManager` | | `EVT_GeoRecovered` | `StringEventChannelSO`(sceneId) | `DeathShade.Interact()` | `SaveManager`(标记该场景遗骸已回收) | | `EVT_ShowInteractPrompt` | `StringEventChannelSO`(promptText) | `InteractableDetector` | `HUDController`(显示交互提示 UI) | | `EVT_HideInteractPrompt` | `VoidEventChannelSO` | `InteractableDetector` | `HUDController`(隐藏交互提示 UI) | ### UI / 对话 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_DialogueStartRequest` | `DialogueEventChannelSO` | `InteractableNPC` | `DialogueManager` | | `EVT_DialogueStarted` | `VoidEventChannelSO` | `DialogueManager`(对话正式开始) | `InputReaderSO`(切 UI 输入)、`PlayerController`(锁定输入) | | `EVT_DialogueEnded` | `VoidEventChannelSO` | `DialogueManager` | `GameManager`(恢复 Gameplay)、`InputReaderSO`(切回 Gameplay) | | `EVT_NpcDialogueCompleted` | `StringEventChannelSO`(npcId) | `DialogueManager`(与特定 NPC 对话结束) | `QuestManager`(任务目标进度追踪) | | `EVT_CutsceneStarted` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(隐藏 HUD)、`InputReaderSO`(禁用输入) | | `EVT_CutsceneEnded` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(恢复 HUD) | | `EVT_ShowPanel` | `StringEventChannelSO`(panelId) | 各触发源 | `UIManager` | | `EVT_HidePanel` | `StringEventChannelSO`(panelId) | 各触发源 | `UIManager` | ### 商店系统 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_ShopOpened` | `StringEventChannelSO`(shopId) | `ShopController.Open()` | `UIManager`(显示 ShopPanel) | | `EVT_ShopClosed` | `VoidEventChannelSO` | `ShopPanel` 关闭按钮 | `UIManager`(隐藏 ShopPanel) | | `EVT_ItemPurchased` | `ShopPurchaseEventChannelSO` | `ShopController` | `PlayerStats`(扣 Geo)、`AchievementManager`(购买成就) | ### 存档系统 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_SaveIndicatorVisible` | `BoolEventChannelSO`(true=显示,false=隐藏) | `SaveManager.SaveAsync()` | `HUDController`(显示/隐藏保存中图标) | ### 音频系统 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_RegionEntered` | `StringEventChannelSO`(zoneId) | `AudioZone` | `BGMController`(按 zoneId 查 AudioConfigSO 切换 BGM) | | `EVT_PlayBGM` | `StringEventChannelSO`(bgmKey) | `GameManager` 等非区域触发源 | `BGMController` | | `EVT_StopBGM` | `VoidEventChannelSO` | `GameManager` | `BGMController` | | `EVT_PlaySFX` | `StringEventChannelSO`(sfxKey) | 各触发源 | `AudioManager` | ### 可访问性 / 成就 / 防软锁 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_AchievementUnlocked` | `StringEventChannelSO`(achievementId) | `AchievementManager` | `ToastManager`(显示成就 Toast) | | `EVT_SoftlockDetected` | `VoidEventChannelSO` | `AntiSoftlockSystem` | `UIManager`(显示确认对话框) | | `EVT_ColorblindModeChanged` | `ColorblindModeEventChannelSO` | `AccessibilityManager` | URP Feature(色觉 LUT 切换) | | `EVT_SubtitlesToggled` | `BoolEventChannelSO` | `AccessibilityManager` | `DialogueBox`(字幕显隐) | | `EVT_HighContrastToggled` | `BoolEventChannelSO` | `AccessibilityManager` | `UIManager`(高对比度 UI Theme 切换) | ### 液体 / 导航标记 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_LiquidEntered` | `LiquidEventChannelSO`(LiquidZone) | `LiquidZone.OnTriggerEnter2D()` | `PlayerController`(切换 SwimState) | | `EVT_LiquidExited` | `LiquidEventChannelSO`(LiquidZone) | `LiquidZone.OnTriggerExit2D()` | `PlayerController`(退出 SwimState) | | `EVT_DrownProgress` | `FloatEventChannelSO`(0–1 进度) | `LiquidZone`(窒息计时器每帧) | `HUDController`(窒息条 UI) | | `EVT_PlayerDrowned` | `VoidEventChannelSO` | `LiquidZone`(窒息计时器归零) | `GameManager`(触发死亡流程) | | `EVT_WorldMarkerActivated` | `WorldMarkerEventChannelSO`(WorldMarker) | `WorldMarker.Activate()` | `HUDController`(指引箭头)、`MapManager`(地图图标) | | `EVT_WorldMarkerDeactivated` | `WorldMarkerEventChannelSO`(WorldMarker) | `WorldMarker.Deactivate()` | `HUDController`、`MapManager` | ### 任务 / 挑战 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_QuestStarted` | `StringEventChannelSO`(questId) | `QuestManager.StartQuest()` | `QuestLogUI`(新增条目)、`HUDController`(Toast 提示) | | `EVT_QuestCompleted` | `StringEventChannelSO`(questId) | `QuestManager.CompleteQuest()` | `QuestGiver`(刷新对话)、`QuestLogUI`、`AchievementManager` | | `EVT_QuestFailed` | `StringEventChannelSO`(questId) | `QuestManager.FailQuest()` | `QuestLogUI`(标记失败)、`HUDController`(失败提示) | | `EVT_ObjectiveUpdated` | `QuestObjectiveEventChannelSO`(QuestObjectiveEvent) | `QuestManager.UpdateObjective()` | `QuestLogUI`(进度刷新)、`HUDController`(目标追踪 UI) | | `EVT_ChallengeCompleted` | `StringEventChannelSO`(challengeId) | `ChallengeRoomManager` | `HUDController`(结算界面)、`AchievementManager` | | `EVT_ChallengeFailed` | `StringEventChannelSO`(challengeId) | `ChallengeRoomManager` | `SaveManager`(触发读档)、`HUDController` | ### 事件链 / EventChain | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_ChainCompleted` | `StringEventChannelSO`(chainId) | `EventChainManager` | `ChainCompletedCondition`(链间依赖) | | `EVT_DoorOpened` | `StringEventChannelSO`(doorId) | `OpenDoorAction` | `DoorController`(物理开门动画) | | `EVT_FlagChanged` | `StringEventChannelSO`(flagId) | `SetFlagAction` | `InteractableNPC`(条件对话刷新) | --- ## 5. 发布 / 订阅模式 ### 标准订阅写法 ```csharp public class HUDController : MonoBehaviour { [SerializeField] private IntEventChannelSO _onHPChanged; [SerializeField] private IntEventChannelSO _onSoulPowerChanged; private void OnEnable() { _onHPChanged.OnEventRaised += UpdateHPBar; _onSoulPowerChanged.OnEventRaised += UpdateSoulBar; } private void OnDisable() { _onHPChanged.OnEventRaised -= UpdateHPBar; _onSoulPowerChanged.OnEventRaised -= UpdateSoulBar; } private void UpdateHPBar(int newHP) { /* ... */ } private void UpdateSoulBar(int newSoul) { /* ... */ } } ``` ### 标准发布写法 ```csharp public class PlayerStats : MonoBehaviour { [SerializeField] private IntEventChannelSO _onHPChanged; public void TakeDamage(int amount) { _currentHP = Mathf.Max(0, _currentHP - amount); _onHPChanged.Raise(_currentHP); if (_currentHP == 0) _onPlayerDied.Raise(); } } ``` --- ## 6. MMEventManager(备用广播) 用于**无需预建 SO 资产的临时广播**,或 Feel 框架自带事件(如 `MMTopDownEngineEvent`)。 ```csharp // 发布(任意位置) MMEventManager.TriggerEvent(new PlayerDiedEvent()); // 订阅(实现 MMEventListener) public class AudioManager : MonoBehaviour, MMEventListener { private void OnEnable() => this.MMEventStartListening(); private void OnDisable() => this.MMEventStopListening(); public void OnMMEvent(PlayerDiedEvent eventType) { // 响应 } } ``` **仅在 Feel 框架集成场景下使用**;其他跨系统通信统一使用 SO 事件频道。 --- ## 7. EventChannelRegistry `EventChannelRegistry` 是运行时频道查找辅助单例,专为**无法在 Inspector 中序列化 SO 引用的动态场景**设计(典型用例:`ICharmEffect` SO 实例需要在运行时订阅事件频道)。 ```csharp // 路径: Assets/Scripts/Core/Events/EventChannelRegistry.cs namespace BaseGames.Core.Events { /// /// 运行时事件频道注册表。 /// 在 Persistent 场景 Awake 时由 EventChannelRegistrar 注册所有频道 SO, /// 供不能持有 [SerializeField] 的动态对象(CharmEffect SO 等)按类型名查找频道。 /// public class EventChannelRegistry : MonoBehaviour { public static EventChannelRegistry Instance { get; private set; } private readonly Dictionary _channels = new(); private void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } /// 由 EventChannelRegistrar 在场景初始化时批量注册频道 SO。 public void Register(string key, ScriptableObject channel) => _channels[key] = channel; /// /// 按 key 查找频道。key 约定 = SO 资产文件名(不含扩展名),如 "EVT_OnHitConfirmed"。 /// 找不到时输出 Error 并返回 null。 /// public T Get(string key) where T : ScriptableObject { if (_channels.TryGetValue(key, out var ch) && ch is T typed) return typed; Debug.LogError($"[EventChannelRegistry] Key '{key}' not found or wrong type."); return null; } } } ``` > **注册时机**:Persistent 场景中的 `EventChannelRegistrar` 组件在 `Awake`(最早执行)时调用 > `EventChannelRegistry.Instance.Register(key, channelSO)` 完成全部频道注册, > 确保 `EquipmentManager.Awake()` 构建 `EquipmentContext` 时 `Instance` 已就绪。 > 普通 MonoBehaviour 仍优先使用 `[SerializeField]` 直接引用 SO,Registry 仅供动态 SO 对象使用。 --- ## 8. 事件订阅生命周期管理 — 自动注销机制 > **P1 优化**:手动在 `OnDisable` 配对 `OnEnable` 的取消注册,在多场景 Additive 加载时易遗漏导致重复注册或 NullRef。引入 `EventSubscription` / `CompositeDisposable` 模式统一管理。 ### 8.1 EventSubscription — 单订阅 Disposable ```csharp // 路径: Assets/Scripts/Core/Events/EventSubscription.cs namespace BaseGames.Core.Events { /// /// 单条订阅的 Disposable 句柄。Dispose() 自动取消注册。 /// 配合 using 块或 CompositeDisposable 批量释放。 /// public readonly struct EventSubscription : System.IDisposable { private readonly System.Action _unsubscribe; public EventSubscription(System.Action unsubscribe) => _unsubscribe = unsubscribe; public void Dispose() => _unsubscribe?.Invoke(); } } ``` ### 8.2 CompositeDisposable — 批量注销容器 ```csharp // 路径: Assets/Scripts/Core/Events/CompositeDisposable.cs namespace BaseGames.Core.Events { /// /// 批量管理多条订阅,统一在 Dispose / Clear 时取消所有注册。 /// MonoBehaviour 生命周期:OnEnable 调用 Subscribe,OnDisable 调用 Clear。 /// public sealed class CompositeDisposable : System.IDisposable { private readonly List _items = new(); public void Add(System.IDisposable item) => _items.Add(item); public void Clear() { foreach (var item in _items) item.Dispose(); _items.Clear(); } public void Dispose() => Clear(); } } ``` ### 8.3 BaseEventChannelSO — Subscribe 扩展重载 ```csharp // 追加到 BaseEventChannelSO(路径: Assets/Scripts/Core/Events/BaseEventChannelSO.cs) namespace BaseGames.Core.Events { public abstract class BaseEventChannelSO : ScriptableObject { public event Action OnEventRaised; public void Raise(T value) => OnEventRaised?.Invoke(value); /// /// 订阅并返回可 Dispose 的订阅句柄。 /// 推荐与 CompositeDisposable 配合使用,代替手动 OnDisable 取消注册。 /// public EventSubscription Subscribe(Action callback) { OnEventRaised += callback; return new EventSubscription(() => OnEventRaised -= callback); } } } ``` ### 8.4 使用模式 **标准用法**(替代 OnEnable/OnDisable 手动配对): ```csharp public class HUDController : MonoBehaviour { [SerializeField] VoidEventChannelSO _onCutsceneStarted; [SerializeField] VoidEventChannelSO _onCutsceneEnded; [SerializeField] IntEventChannelSO _onHealthChanged; private readonly CompositeDisposable _subs = new(); // ── 只写 OnEnable,OnDisable 统一 Clear ────────────────────────── private void OnEnable() { _subs.Add(_onCutsceneStarted.Subscribe(_ => HideHUD())); _subs.Add(_onCutsceneEnded.Subscribe(_ => ShowHUD())); _subs.Add(_onHealthChanged.Subscribe(UpdateHealthBar)); } private void OnDisable() => _subs.Clear(); } ``` **动态订阅**(运行时按需订阅,对象销毁时自动取消): ```csharp // CharmEffect SO 动态订阅(不持有 MonoBehaviour 生命周期) public class CharmEffect : StatusEffectSO { private EventSubscription _sub; public override void OnApply(GameObject target) { var channel = EventChannelRegistry.Instance .Get("EVT_CharmExpired"); _sub = channel.Subscribe(_ => OnCharmExpired()); } public override void OnRemove(GameObject target) { _sub.Dispose(); // 精确注销,无需持有 channel 引用 } } ``` ### 8.5 迁移规范 | 旧模式 | 新模式 | 备注 | |--------|--------|------| | `OnEnable` + `OnDisable` 手动 `+=` / `-=` | `CompositeDisposable` + `Subscribe()` | 一对多订阅的 MonoBehaviour | | 单条订阅 + 手动 `-=` | `EventSubscription` (`using` 或字段) | 精确生命周期控制 | | SO 内部动态订阅 | `EventSubscription` 字段 + `OnRemove Dispose` | CharmEffect 等运行时 SO | > **注意**:`VoidEventChannelSO`(无参版)不继承 `BaseEventChannelSO`,需补充等价 `Subscribe(Action callback)` 方法。原有 `OnEnable/OnDisable` 写法仍合法,迁移以新增代码为主,不强制改旧代码。 --- ## 9. EventBusMonitorWindow — 运行时事件监控面板 > **P0 优化**:生产级调试能力。调试"玩家死了但死亡画面没有弹出"、"Boss HP 归零但胜利 > 事件未触发"等问题时,依靠 `Debug.Log` 逐行追踪效率极低。 > `EventBusMonitorWindow` 在 Play Mode 中实时显示所有 SO 事件频道的最近触发记录, > 一眼定位"谁 Raise 了什么事件、何时触发、订阅者当前有几个"。 ### 9.1 运行时埋点 — BaseEventChannelSO 修改 ```csharp // 修改: Assets/Scripts/Core/Events/BaseEventChannelSO.cs public abstract class BaseEventChannelSO : ScriptableObject { [Multiline] public string description; public event Action OnEventRaised; public void Raise(T value) { OnEventRaised?.Invoke(value); #if UNITY_EDITOR EventBusMonitor.Record(name, value?.ToString() ?? "", OnEventRaised?.GetInvocationList().Length ?? 0); #endif } // Subscribe() 见 §8.3 } // ✅ VoidBaseEventChannelSO.Raise() 已同步添加 EventBusMonitor.Record(见 §2 VoidBaseEventChannelSO) ``` ### 9.2 EventBusMonitor — 静态记录缓冲 ```csharp // 路径: Assets/Scripts/Core/Events/Editor/EventBusMonitor.cs #if UNITY_EDITOR namespace BaseGames.Core.Events.Editor { /// /// 运行时事件触发记录(仅 Editor)。 /// BaseEventChannelSO.Raise() 内部调用,CircularBuffer 避免无限增长。 /// public static class EventBusMonitor { public const int MaxRecords = 200; public struct EventRecord { public double Timestamp; // Time.realtimeSinceStartupAsDouble public string ChannelName; // SO 资产名称(如 "EVT_PlayerDied") public string PayloadText; // value.ToString() public int SubscriberCount; // 触发时订阅者数量 public int Frame; // Time.frameCount } private static readonly Queue _records = new(MaxRecords + 1); public static IReadOnlyCollection Records => _records; // 供 EditorWindow 订阅"有新记录"通知 public static event System.Action OnRecordAdded; public static void Record(string channelName, string payload, int subCount) { if (_records.Count >= MaxRecords) _records.Dequeue(); _records.Enqueue(new EventRecord { Timestamp = UnityEngine.Time.realtimeSinceStartupAsDouble, ChannelName = channelName, PayloadText = payload, SubscriberCount = subCount, Frame = UnityEngine.Time.frameCount, }); OnRecordAdded?.Invoke(); } public static void Clear() => _records.Clear(); // 搜索过滤(大小写不敏感) public static IEnumerable Filter(string keyword) => string.IsNullOrEmpty(keyword) ? _records : _records.Where(r => r.ChannelName.IndexOf(keyword, System.StringComparison.OrdinalIgnoreCase) >= 0); } } #endif ``` ### 9.3 EventBusMonitorWindow — EditorWindow ```csharp // 路径: Assets/Scripts/Editor/EventBusMonitorWindow.cs #if UNITY_EDITOR namespace BaseGames.Editor { /// /// 菜单路径: BaseGames/Tools/Event Bus Monitor /// 快捷键: Ctrl+Shift+E /// public class EventBusMonitorWindow : EditorWindow { [MenuItem("BaseGames/Tools/Event Bus Monitor %#e")] public static void Open() => GetWindow("Event Bus Monitor"); // ── UI 状态 ──────────────────────────────────────────────────────── private string _filterText = ""; private bool _autoScroll = true; private bool _pauseCapture = false; private Vector2 _scrollPos; private static readonly Color _zeroSubColor = new Color(1f, 0.4f, 0.4f); // 红:零订阅者触发 private static readonly Color _normalColor = Color.white; private void OnEnable() => EventBusMonitor.OnRecordAdded += Repaint; private void OnDisable() => EventBusMonitor.OnRecordAdded -= Repaint; private void OnGUI() { // ── 工具栏 ────────────────────────────────────────────────── using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("Filter:", GUILayout.Width(40)); _filterText = EditorGUILayout.TextField(_filterText, EditorStyles.toolbarSearchField, GUILayout.Width(200)); GUILayout.FlexibleSpace(); _pauseCapture = GUILayout.Toggle(_pauseCapture, "Pause", EditorStyles.toolbarButton, GUILayout.Width(50)); _autoScroll = GUILayout.Toggle(_autoScroll, "Auto Scroll", EditorStyles.toolbarButton, GUILayout.Width(80)); if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45))) EventBusMonitor.Clear(); } // ── 列标题 ────────────────────────────────────────────────── using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField("Time", GUILayout.Width(70)); EditorGUILayout.LabelField("Frame", GUILayout.Width(55)); EditorGUILayout.LabelField("Channel", GUILayout.Width(220)); EditorGUILayout.LabelField("Payload", GUILayout.MinWidth(120)); EditorGUILayout.LabelField("Subs", GUILayout.Width(40)); } EditorGUILayout.LabelField("", GUI.skin.horizontalSlider); // ── 记录列表 ───────────────────────────────────────────────── using var scroll = new EditorGUILayout.ScrollViewScope(_scrollPos); _scrollPos = scroll.scrollPosition; var records = EventBusMonitor.Filter(_filterText).ToArray(); foreach (var r in records) { var prevColor = GUI.color; GUI.color = r.SubscriberCount == 0 ? _zeroSubColor : _normalColor; using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField($"{r.Timestamp:F2}s", GUILayout.Width(70)); EditorGUILayout.LabelField($"#{r.Frame}", GUILayout.Width(55)); EditorGUILayout.LabelField(r.ChannelName, GUILayout.Width(220)); EditorGUILayout.LabelField(r.PayloadText, GUILayout.MinWidth(120)); EditorGUILayout.LabelField(r.SubscriberCount.ToString(), GUILayout.Width(40)); } GUI.color = prevColor; } if (_autoScroll && Application.isPlaying) _scrollPos.y = float.MaxValue; } } } #endif ``` ### 9.4 使用指南 | 场景 | 操作 | |------|------| | 调试事件未触发 | Filter 输入频道名,观察 Records;空 = Raise 从未调用 | | 调试无响应(Subs=0) | 行显示红色 = 有 Raise 但零订阅者,检查 OnEnable 注册 | | 调试重复触发 | 观察同一频道在同一 Frame 内多次出现 | | 性能分析 | 高频帧内 Raise 次数过多时考虑节流 | | 生产构建 | `#if UNITY_EDITOR` 包裹,零运行时开销 |