36 KiB
02 · SO 事件系统
命名空间
BaseGames.Core.Events
程序集BaseGames.Core.Events(无任何运行时依赖,最底层)
路径Assets/Scripts/Core/Events/
目录
- 设计原则
- 泛型事件频道基类
- 所有事件频道类型
- 全局事件频道 SO 资产清单
- 发布 / 订阅模式
- MMEventManager(备用广播)
- EventChannelRegistry
- 事件订阅生命周期管理 — 自动注销机制
- 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 §1(enum 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(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. 发布 / 订阅模式
标准订阅写法
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()构建EquipmentContext时Instance已就绪。
普通 MonoBehaviour 仍优先使用[SerializeField]直接引用 SO,Registry 仅供动态 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 调用 Subscribe,OnDisable 调用 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();
// ── 只写 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();
}
动态订阅(运行时按需订阅,对象销毁时自动取消):
// 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 包裹,零运行时开销 |