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

798 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
{
/// <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. 所有事件频道类型
### 基础类型频道
```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<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> { }
```
### 游戏专用负载频道
```csharp
// 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` | `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`01 进度) | `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<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 实例需要在运行时订阅事件频道)。
```csharp
// 路径: 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()` 构建 `EquipmentContext` 时 `Instance` 已就绪。
> 普通 MonoBehaviour 仍优先使用 `[SerializeField]` 直接引用 SORegistry 仅供动态 SO 对象使用。
---
## 8. 事件订阅生命周期管理 — 自动注销机制
> **P1 优化**:手动在 `OnDisable` 配对 `OnEnable` 的取消注册,在多场景 Additive 加载时易遗漏导致重复注册或 NullRef。引入 `EventSubscription` / `CompositeDisposable` 模式统一管理。
### 8.1 EventSubscription — 单订阅 Disposable
```csharp
// 路径: 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 — 批量注销容器
```csharp
// 路径: 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 扩展重载
```csharp
// 追加到 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 手动配对):
```csharp
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();
}
```
**动态订阅**(运行时按需订阅,对象销毁时自动取消):
```csharp
// 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 修改
```csharp
// 修改: 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 — 静态记录缓冲
```csharp
// 路径: 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
```csharp
// 路径: 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` 包裹,零运行时开销 |