# 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` 包裹,零运行时开销 |