Files
zeling_v2/Docs/Architecture/02_EventSystem.md
2026-05-08 11:04:00 +08:00

36 KiB
Raw Permalink Blame History

02 · SO 事件系统

命名空间 BaseGames.Core.Events
程序集 BaseGames.Core.Events(无任何运行时依赖,最底层)
路径 Assets/Scripts/Core/Events/


目录

  1. 设计原则
  2. 泛型事件频道基类
  3. 所有事件频道类型
  4. 全局事件频道 SO 资产清单
  5. 发布 / 订阅模式
  6. MMEventManager备用广播
  7. EventChannelRegistry
  8. 事件订阅生命周期管理 — 自动注销机制
  9. EventBusMonitorWindow — 运行时事件监控面板

1. 设计原则

  • 每个事件频道是一个 ScriptableObject,在 Assets/Data/Events/ 下预建资产Inspector 拖拽引用
  • 发布者和订阅者只持有频道 SO 的引用,彼此完全不知道对方的存在
  • 事件频道不存储状态;仅在 Raise() 调用时执行 C# Action 委托链
  • 禁止在代码中 new 出事件频道;必须使用预建的 .asset 资产
  • 所有频道订阅在 OnEnable 注册,在 OnDisable 取消注册

2. 泛型事件频道基类

文件:Assets/Scripts/Core/Events/BaseEventChannelSO.cs

using System;
using UnityEngine;

namespace BaseGames.Core.Events
{
    /// <summary>
    /// 泛型 SO 事件频道基类。T 为负载类型。
    /// </summary>
    public abstract class BaseEventChannelSO<T> : ScriptableObject
    {
        // Editor 备注Inspector 可见)
        [Multiline] public string description;

        public event Action<T> OnEventRaised;

        /// <summary>
        /// 发布事件,执行所有订阅委托。
        /// </summary>
        public void Raise(T value)
        {
            OnEventRaised?.Invoke(value);
        }
    }

    /// <summary>
    /// 无负载事件频道基类。
    /// </summary>
    public abstract class VoidBaseEventChannelSO : ScriptableObject
    {
        [Multiline] public string description;

        public event Action OnEventRaised;

        public void Raise()
        {
            OnEventRaised?.Invoke();
#if UNITY_EDITOR
            EventBusMonitor.Record(name, "<void>",
                OnEventRaised?.GetInvocationList().Length ?? 0);
#endif
        }
    }
}

3. 所有事件频道类型

基础类型频道

// 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<bool> { }

// Assets/Scripts/Core/Events/IntEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/Int")]
public class IntEventChannelSO : BaseEventChannelSO<int> { }

// Assets/Scripts/Core/Events/FloatEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/Float")]
public class FloatEventChannelSO : BaseEventChannelSO<float> { }

// Assets/Scripts/Core/Events/StringEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/String")]
public class StringEventChannelSO : BaseEventChannelSO<string> { }

// Assets/Scripts/Core/Events/Vector2EventChannelSO.cs
[CreateAssetMenu(menuName = "Events/Vector2")]
public class Vector2EventChannelSO : BaseEventChannelSO<Vector2> { }

// Assets/Scripts/Core/Events/TransformEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/Transform")]
public class TransformEventChannelSO : BaseEventChannelSO<Transform> { }

游戏专用负载频道

// Assets/Scripts/Core/Events/GameStateEventChannelSO.cs
// ⚠️ payload 类型为 GameStateId值类型 struct非旧 GameState 枚举
[CreateAssetMenu(menuName = "Events/GameState")]
public class GameStateEventChannelSO : BaseEventChannelSO<GameStateId> { }

// Assets/Scripts/Core/Events/SceneLoadRequestEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/SceneLoadRequest")]
public class SceneLoadRequestEventChannelSO : BaseEventChannelSO<SceneLoadRequest> { }

// Assets/Scripts/Core/Events/DamageInfoEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/DamageInfo")]
public class DamageInfoEventChannelSO : BaseEventChannelSO<DamageInfo> { }

// Assets/Scripts/Core/Events/ShopPurchaseEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/ShopPurchase")]
public class ShopPurchaseEventChannelSO : BaseEventChannelSO<ShopPurchaseEvent> { }

// Assets/Scripts/Core/Events/DialogueEventChannelSO.cs
[CreateAssetMenu(menuName = "Events/DialogueRequest")]
public class DialogueEventChannelSO : BaseEventChannelSO<DialogueDataSO> { }

// 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<BossSkillEvent> { }

// 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<BossPhaseEvent> { }

// Assets/Scripts/Core/Events/AbilityTypeEventChannelSO.cs
// AbilityType 定义于 09_ProgressionModule §1enum AbilityType
[CreateAssetMenu(menuName = "Events/AbilityType")]
public class AbilityTypeEventChannelSO : BaseEventChannelSO<AbilityType> { }

// 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<HitInfo> { }

// Assets/Scripts/Core/Events/ColorblindModeEventChannelSO.cs
// ColorblindMode 枚举定义于 16_SupportingModules §AccessibilityManager
[CreateAssetMenu(menuName = "Events/ColorblindMode")]
public class ColorblindModeEventChannelSO : BaseEventChannelSO<ColorblindMode> { }

// Assets/Scripts/Core/Events/DifficultyChangedEventChannel.cs
// ⚠️ 命名按 Architecture 19 约定:不加 "SO" 后缀(与其他频道类有意区分以标识自定义 payload
// DifficultyScalerSO 定义于 19_DifficultyModule §3
[CreateAssetMenu(menuName = "Events/DifficultyChanged")]
public class DifficultyChangedEventChannel : BaseEventChannelSO<DifficultyScalerSO> { }

// Assets/Scripts/Core/Events/LiquidEventChannelSO.cs
// LiquidZone 定义于 21_LiquidPuzzleModule §4
[CreateAssetMenu(menuName = "Events/LiquidZone")]
public class LiquidEventChannelSO : BaseEventChannelSO<LiquidZone> { }

// Assets/Scripts/Core/Events/WorldMarkerEventChannelSO.cs
// WorldMarker 定义于 21_LiquidPuzzleModule §14
[CreateAssetMenu(menuName = "Events/WorldMarker")]
public class WorldMarkerEventChannelSO : BaseEventChannelSO<WorldMarker> { }

// 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<QuestStateChangedEvent> { }

// 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<QuestObjectiveEvent> { }

// Assets/Scripts/Core/Events/StatusEffectEventChannelSO.cs
// 状态效果频道StatusEffectManager 施加/过期时广播StatusEffectType 定义于 06_CombatModule §11
[CreateAssetMenu(menuName = "Events/StatusEffect")]
public class StatusEffectEventChannelSO : BaseEventChannelSO<StatusEffectType> { }

4. 全局事件频道 SO 资产清单

所有频道资产预建于 Assets/Data/Events/,资产文件名格式:EVT_{描述}.asset

Core 系统

资产名 类型 Raise 方 Subscribe 方
EVT_GameStateChanged GameStateEventChannelSO GameManager UIManagerAudioManagerInputManager
EVT_SceneLoadRequest SceneLoadRequestEventChannelSO RoomTransitionGameManager SceneLoader
EVT_SceneLoaded StringEventChannelSO SceneLoader GameManagerMapManager
EVT_PauseRequested VoidEventChannelSO InputReaderPause 键) GameManager
EVT_DifficultyChanged DifficultyChangedEventChannel DifficultyManager.Apply() PlayerStatsEnemyStats(缩放属性)

玩家系统

资产名 类型 Raise 方 Subscribe 方
EVT_PlayerDied VoidEventChannelSO PlayerStatsHP ≤ 0 GameManagerUIManagerAudioManager
EVT_DeathScreenConfirmed VoidEventChannelSO DeathScreenControllerRespawn 按钮) GameManager(启动 RespawnCoroutine
EVT_PlayerRespawned VoidEventChannelSO GameManager(复活流程末) UIManagerAudioManager
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 StringEventChannelSOabilityId PlayerStats.UnlockAbility AbilityGateHUDControllerTutorialManager
EVT_PlayerFormChanged IntEventChannelSOFormType FormController HUDControllerAudioManager
EVT_SkillSetChanged VoidEventChannelSO FormController SkillHUD(刷新技能栏 UI
EVT_ParrySuccess VoidEventChannelSO ParrySystem PlayerStats+20 SoulPowerFeedbackAudioManager
EVT_ShieldHPChanged IntEventChannelSO ShieldComponent(盾牌受击/修复) HUDController(盾槽 UI

战斗系统

资产名 类型 Raise 方 Subscribe 方
EVT_EnemyDied TransformEventChannelSO EnemyBaseHP ≤ 0 PlayerStats(击杀点)、QuestManager
EVT_HitConfirmed HitConfirmedEventChannelSOHitInfo HurtBox.ReceiveDamage HitFXSpawnerAudioManagerPlayerFeedback(受击方)
EVT_DamageDealt DamageInfoEventChannelSO HurtBox.ReceiveDamage AnalyticsManager
EVT_BossFightStarted StringEventChannelSObossId BossOrchestrator GameManager(切换 BossFight 状态)、AudioManager
EVT_BossFightEnded BoolEventChannelSO(胜利/失败) BossBase.Die() GameManagerAudioManagerUIManager
EVT_BossFightToggled BoolEventChannelSOtrue=开始false=结束) BossOrchestrator(开始)、BossBase.Die()(结束) BossHPBar(显示/隐藏 Boss 血条)
EVT_BossHPChanged IntEventChannelSO BossBase(受击/回血) BossHPBarHP 进度条更新)
EVT_BossNameSet StringEventChannelSObossName BossOrchestrator(战斗开始时) BossHPBar(显示 Boss 名称标签)
EVT_BossHPMaxSet IntEventChannelSO BossOrchestrator(战斗开始时) BossHPBar(初始化满血值)
EVT_BossPhaseChanged BossPhaseEventChannelSObossId, phase BossBase.EnterPhase() BossHUD(相机切换通过 CameraStateController.Instance 直接调用,不订阅事件)
EVT_BossSkillStarted BossSkillEventChannelSObossId, skillId BossSkillExecutor BossHUD(显示技能名)(震动通过 CameraStateController.Instance.TriggerImpulse() 直接调用)
EVT_BossSkillEnded BossSkillEventChannelSObossId, skillId BossSkillExecutor BossOrchestratorBD 决策推进)
EVT_BossVulnerabilityWindowOpened StringEventChannelSObossId 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 StringEventChannelSOsaveId SavePoint GameManager(触发存档)、HUDController
EVT_FastTravelOpen VoidEventChannelSO SavePoint(快速旅行已解锁) UIManager(显示快速旅行面板)
EVT_CollectiblePickup StringEventChannelSOitemId Collectible PlayerStatsQuestManagerAnalyticsManager
EVT_GeoRecovered StringEventChannelSOsceneId DeathShade.Interact() SaveManager(标记该场景遗骸已回收)
EVT_ShowInteractPrompt StringEventChannelSOpromptText 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(恢复 GameplayInputReaderSO(切回 Gameplay
EVT_NpcDialogueCompleted StringEventChannelSOnpcId DialogueManager(与特定 NPC 对话结束) QuestManager(任务目标进度追踪)
EVT_CutsceneStarted VoidEventChannelSO CutsceneManager HUDController(隐藏 HUDInputReaderSO(禁用输入)
EVT_CutsceneEnded VoidEventChannelSO CutsceneManager HUDController(恢复 HUD
EVT_ShowPanel StringEventChannelSOpanelId 各触发源 UIManager
EVT_HidePanel StringEventChannelSOpanelId 各触发源 UIManager

商店系统

资产名 类型 Raise 方 Subscribe 方
EVT_ShopOpened StringEventChannelSOshopId ShopController.Open() UIManager(显示 ShopPanel
EVT_ShopClosed VoidEventChannelSO ShopPanel 关闭按钮 UIManager(隐藏 ShopPanel
EVT_ItemPurchased ShopPurchaseEventChannelSO ShopController PlayerStats(扣 GeoAchievementManager(购买成就)

存档系统

资产名 类型 Raise 方 Subscribe 方
EVT_SaveIndicatorVisible BoolEventChannelSOtrue=显示false=隐藏) SaveManager.SaveAsync() HUDController(显示/隐藏保存中图标)

音频系统

资产名 类型 Raise 方 Subscribe 方
EVT_RegionEntered StringEventChannelSOzoneId AudioZone BGMController(按 zoneId 查 AudioConfigSO 切换 BGM
EVT_PlayBGM StringEventChannelSObgmKey GameManager 等非区域触发源 BGMController
EVT_StopBGM VoidEventChannelSO GameManager BGMController
EVT_PlaySFX StringEventChannelSOsfxKey 各触发源 AudioManager

可访问性 / 成就 / 防软锁

资产名 类型 Raise 方 Subscribe 方
EVT_AchievementUnlocked StringEventChannelSOachievementId 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 LiquidEventChannelSOLiquidZone LiquidZone.OnTriggerEnter2D() PlayerController(切换 SwimState
EVT_LiquidExited LiquidEventChannelSOLiquidZone LiquidZone.OnTriggerExit2D() PlayerController(退出 SwimState
EVT_DrownProgress FloatEventChannelSO01 进度) LiquidZone(窒息计时器每帧) HUDController(窒息条 UI
EVT_PlayerDrowned VoidEventChannelSO LiquidZone(窒息计时器归零) GameManager(触发死亡流程)
EVT_WorldMarkerActivated WorldMarkerEventChannelSOWorldMarker WorldMarker.Activate() HUDController(指引箭头)、MapManager(地图图标)
EVT_WorldMarkerDeactivated WorldMarkerEventChannelSOWorldMarker WorldMarker.Deactivate() HUDControllerMapManager

任务 / 挑战

资产名 类型 Raise 方 Subscribe 方
EVT_QuestStarted StringEventChannelSOquestId QuestManager.StartQuest() QuestLogUI(新增条目)、HUDControllerToast 提示)
EVT_QuestCompleted StringEventChannelSOquestId QuestManager.CompleteQuest() QuestGiver(刷新对话)、QuestLogUIAchievementManager
EVT_QuestFailed StringEventChannelSOquestId QuestManager.FailQuest() QuestLogUI(标记失败)、HUDController(失败提示)
EVT_ObjectiveUpdated QuestObjectiveEventChannelSOQuestObjectiveEvent QuestManager.UpdateObjective() QuestLogUI(进度刷新)、HUDController(目标追踪 UI
EVT_ChallengeCompleted StringEventChannelSOchallengeId ChallengeRoomManager HUDController(结算界面)、AchievementManager
EVT_ChallengeFailed StringEventChannelSOchallengeId ChallengeRoomManager SaveManager(触发读档)、HUDController

事件链 / EventChain

资产名 类型 Raise 方 Subscribe 方
EVT_ChainCompleted StringEventChannelSOchainId EventChainManager ChainCompletedCondition(链间依赖)
EVT_DoorOpened StringEventChannelSOdoorId OpenDoorAction DoorController(物理开门动画)
EVT_FlagChanged StringEventChannelSOflagId SetFlagAction InteractableNPC(条件对话刷新)

5. 发布 / 订阅模式

标准订阅写法

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) { /* ... */ }
}

标准发布写法

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)。

// 发布(任意位置)
MMEventManager.TriggerEvent(new PlayerDiedEvent());

// 订阅(实现 MMEventListener<T>
public class AudioManager : MonoBehaviour, MMEventListener<PlayerDiedEvent>
{
    private void OnEnable()  => this.MMEventStartListening<PlayerDiedEvent>();
    private void OnDisable() => this.MMEventStopListening<PlayerDiedEvent>();

    public void OnMMEvent(PlayerDiedEvent eventType)
    {
        // 响应
    }
}

仅在 Feel 框架集成场景下使用;其他跨系统通信统一使用 SO 事件频道。


7. EventChannelRegistry

EventChannelRegistry 是运行时频道查找辅助单例,专为无法在 Inspector 中序列化 SO 引用的动态场景设计(典型用例:ICharmEffect SO 实例需要在运行时订阅事件频道)。

// 路径: Assets/Scripts/Core/Events/EventChannelRegistry.cs
namespace BaseGames.Core.Events
{
    /// <summary>
    /// 运行时事件频道注册表。
    /// 在 Persistent 场景 Awake 时由 EventChannelRegistrar 注册所有频道 SO
    /// 供不能持有 [SerializeField] 的动态对象CharmEffect SO 等)按类型名查找频道。
    /// </summary>
    public class EventChannelRegistry : MonoBehaviour
    {
        public static EventChannelRegistry Instance { get; private set; }

        private readonly Dictionary<string, ScriptableObject> _channels = new();

        private void Awake()
        {
            if (Instance != null) { Destroy(gameObject); return; }
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }

        /// <summary>由 EventChannelRegistrar 在场景初始化时批量注册频道 SO。</summary>
        public void Register(string key, ScriptableObject channel)
            => _channels[key] = channel;

        /// <summary>
        /// 按 key 查找频道。key 约定 = SO 资产文件名(不含扩展名),如 "EVT_OnHitConfirmed"。
        /// 找不到时输出 Error 并返回 null。
        /// </summary>
        public T Get<T>(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() 构建 EquipmentContextInstance 已就绪。
普通 MonoBehaviour 仍优先使用 [SerializeField] 直接引用 SORegistry 仅供动态 SO 对象使用。


8. 事件订阅生命周期管理 — 自动注销机制

P1 优化:手动在 OnDisable 配对 OnEnable 的取消注册,在多场景 Additive 加载时易遗漏导致重复注册或 NullRef。引入 EventSubscription / CompositeDisposable 模式统一管理。

8.1 EventSubscription — 单订阅 Disposable

// 路径: Assets/Scripts/Core/Events/EventSubscription.cs
namespace BaseGames.Core.Events
{
    /// <summary>
    /// 单条订阅的 Disposable 句柄。Dispose() 自动取消注册。
    /// 配合 using 块或 CompositeDisposable 批量释放。
    /// </summary>
    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 — 批量注销容器

// 路径: Assets/Scripts/Core/Events/CompositeDisposable.cs
namespace BaseGames.Core.Events
{
    /// <summary>
    /// 批量管理多条订阅,统一在 Dispose / Clear 时取消所有注册。
    /// MonoBehaviour 生命周期OnEnable 调用 SubscribeOnDisable 调用 Clear。
    /// </summary>
    public sealed class CompositeDisposable : System.IDisposable
    {
        private readonly List<System.IDisposable> _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 扩展重载

// 追加到 BaseEventChannelSO<T>(路径: Assets/Scripts/Core/Events/BaseEventChannelSO.cs
namespace BaseGames.Core.Events
{
    public abstract class BaseEventChannelSO<T> : ScriptableObject
    {
        public event Action<T> OnEventRaised;

        public void Raise(T value) => OnEventRaised?.Invoke(value);

        /// <summary>
        /// 订阅并返回可 Dispose 的订阅句柄。
        /// 推荐与 CompositeDisposable 配合使用,代替手动 OnDisable 取消注册。
        /// </summary>
        public EventSubscription Subscribe(Action<T> callback)
        {
            OnEventRaised += callback;
            return new EventSubscription(() => OnEventRaised -= callback);
        }
    }
}

8.4 使用模式

标准用法(替代 OnEnable/OnDisable 手动配对):

public class HUDController : MonoBehaviour
{
    [SerializeField] VoidEventChannelSO   _onCutsceneStarted;
    [SerializeField] VoidEventChannelSO   _onCutsceneEnded;
    [SerializeField] IntEventChannelSO    _onHealthChanged;

    private readonly CompositeDisposable _subs = new();

    // ── 只写 OnEnableOnDisable 统一 Clear ──────────────────────────
    private void OnEnable()
    {
        _subs.Add(_onCutsceneStarted.Subscribe(_ => HideHUD()));
        _subs.Add(_onCutsceneEnded.Subscribe(_ => ShowHUD()));
        _subs.Add(_onHealthChanged.Subscribe(UpdateHealthBar));
    }

    private void OnDisable() => _subs.Clear();
}

动态订阅(运行时按需订阅,对象销毁时自动取消):

// CharmEffect SO 动态订阅(不持有 MonoBehaviour 生命周期)
public class CharmEffect : StatusEffectSO
{
    private EventSubscription _sub;

    public override void OnApply(GameObject target)
    {
        var channel = EventChannelRegistry.Instance
            .Get<VoidEventChannelSO>("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<T>,需补充等价 Subscribe(Action callback) 方法。原有 OnEnable/OnDisable 写法仍合法,迁移以新增代码为主,不强制改旧代码。


9. EventBusMonitorWindow — 运行时事件监控面板

P0 优化:生产级调试能力。调试"玩家死了但死亡画面没有弹出"、"Boss HP 归零但胜利
事件未触发"等问题时,依靠 Debug.Log 逐行追踪效率极低。
EventBusMonitorWindow 在 Play Mode 中实时显示所有 SO 事件频道的最近触发记录,
一眼定位"谁 Raise 了什么事件、何时触发、订阅者当前有几个"。

9.1 运行时埋点 — BaseEventChannelSO 修改

// 修改: Assets/Scripts/Core/Events/BaseEventChannelSO.cs
public abstract class BaseEventChannelSO<T> : ScriptableObject
{
    [Multiline] public string description;
    public event Action<T> OnEventRaised;

    public void Raise(T value)
    {
        OnEventRaised?.Invoke(value);
#if UNITY_EDITOR
        EventBusMonitor.Record(name, value?.ToString() ?? "<void>",
            OnEventRaised?.GetInvocationList().Length ?? 0);
#endif
    }
    // Subscribe() 见 §8.3
}

// ✅ VoidBaseEventChannelSO.Raise() 已同步添加 EventBusMonitor.Record见 §2 VoidBaseEventChannelSO

9.2 EventBusMonitor — 静态记录缓冲

// 路径: Assets/Scripts/Core/Events/Editor/EventBusMonitor.cs
#if UNITY_EDITOR
namespace BaseGames.Core.Events.Editor
{
    /// <summary>
    /// 运行时事件触发记录(仅 Editor
    /// BaseEventChannelSO.Raise() 内部调用CircularBuffer 避免无限增长。
    /// </summary>
    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<EventRecord> _records = new(MaxRecords + 1);
        public static IReadOnlyCollection<EventRecord> 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<EventRecord> Filter(string keyword)
            => string.IsNullOrEmpty(keyword)
               ? _records
               : _records.Where(r => r.ChannelName.IndexOf(keyword,
                   System.StringComparison.OrdinalIgnoreCase) >= 0);
    }
}
#endif

9.3 EventBusMonitorWindow — EditorWindow

// 路径: Assets/Scripts/Editor/EventBusMonitorWindow.cs
#if UNITY_EDITOR
namespace BaseGames.Editor
{
    /// <summary>
    /// 菜单路径: BaseGames/Tools/Event Bus Monitor
    /// 快捷键:  Ctrl+Shift+E
    /// </summary>
    public class EventBusMonitorWindow : EditorWindow
    {
        [MenuItem("BaseGames/Tools/Event Bus Monitor %#e")]
        public static void Open()
            => GetWindow<EventBusMonitorWindow>("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 包裹,零运行时开销