第4章 事件系统
事件系统是游戏框架的核心组件之一,它负责管理游戏中各种状态变化的通知机制。一个设计良好的事件系统能够降低模块间的耦合度,提高代码的可维护性和可扩展性。本章将详细介绍如何在 Godot C# 环境中构建一个类型安全、高效且易于使用的事件系统。
1. 设计原理与思路
事件驱动架构是一种以事件为核心的软件架构模式,它改变了传统游戏开发中直接调用的编程范式。
1.1 事件驱动架构的核心概念
事件驱动架构(Event-Driven Architecture,EDA)建立在发布-订阅模式(Publish-Subscribe Pattern)之上。在这种架构中,系统中的组件不直接调用彼此的方法,而是通过事件总线进行间接通信。这种设计带来了以下核心概念:
发布者(Publisher):生成事件的组件,它不知道谁会接收事件 订阅者(Subscriber):对特定事件感兴趣的组件,它不知道事件由谁发布 事件总线(Event Bus):负责事件路由的中心枢纽,解耦发布者和订阅者
┌─────────────────────────────────────────────────────────────────┐
│ 事件驱动架构示意图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 发布者A │────────▶│ │────────▶│ 订阅者X │ │
│ └───────────┘ │ │ └───────────┘ │
│ │ 事件总线 │ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ 发布者B │────────▶│ │────────▶│ 订阅者Y │ │
│ └───────────┘ │ │ └───────────┘ │
│ │ │ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ 发布者C │────────▶│ │────────▶│ 订阅者Z │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ 优势: │
│ - 发布者和订阅者完全解耦 │
│ - 可以轻松添加新的订阅者 │
│ - 一个事件可以被多个订阅者接收 │
└─────────────────────────────────────────────────────────────────┘
1.2 为什么要解耦组件通信
在传统的游戏开发中,组件间的通信往往通过直接引用来实现:
// 传统方式:直接引用
public class PlayerController : Node
{
private UIManager _uiManager; // 直接依赖
private AchievementSystem _achievementSystem; // 直接依赖
private SaveManager _saveManager; // 直接依赖
public override void _Ready()
{
_uiManager = GetNode<UIManager>("/root/UIManager");
_achievementSystem = GetNode<AchievementSystem>("/root/AchievementSystem");
_saveManager = GetNode<SaveManager>("/root/SaveManager");
}
public void TakeDamage(int damage)
{
_currentHealth -= damage;
// 直接调用UI更新
_uiManager.UpdateHealthBar(_currentHealth);
// 直接调用成就检查
_achievementSystem.CheckDamageTaken(damage);
// 直接调用存档
_saveManager.AutoSave();
}
}
这种方式的问题在于:
- 耦合度高:PlayerController需要知道所有相关系统的存在
- 可测试性差:难以单独测试PlayerController,需要构造所有依赖
- 扩展困难:添加新系统需要修改PlayerController代码
- 灵活性低:无法在运行时动态添加或移除功能
通过事件系统解耦后,代码变得更加灵活:
// 事件驱动方式:通过事件总线通信
public class PlayerController : EventBusNode
{
public void TakeDamage(int damage)
{
_currentHealth -= damage;
// 发布事件,不关心谁接收
Publish(new PlayerHealthChangedEvent(this, new PlayerHealthChangeData
{
CurrentHealth = _currentHealth,
Delta = -damage
}));
}
}
1.3 事件总线与直接引用的对比
| 特性 | 直接引用 | 事件总线 |
|---|---|---|
| 耦合度 | 高(编译时依赖) | 低(运行时依赖) |
| 可测试性 | 差(需要构造所有依赖) | 好(可以单独测试) |
| 扩展性 | 差(需要修改现有代码) | 好(添加订阅者即可) |
| 性能 | 高(直接调用) | 中等(委托调用开销) |
| 代码清晰度 | 直观 | 需要理解事件流向 |
| 调试难度 | 简单(调用栈清晰) | 较难(需要追踪事件) |
为什么这样设计?
- 泛型委托:使用泛型而非object类型,避免装箱拆箱,确保类型安全
- 生命周期管理:通过EventBusNode自动处理订阅和取消订阅,防止内存泄漏
- 线程安全:使用lock关键字保护事件字典,支持多线程环境
- 延迟移除:在事件发布期间取消订阅时延迟处理,避免遍历时的集合修改异常
2. 使用场景分析
2.1 适用场景
模块间通信:当两个模块位于不同的命名空间或层级,且不需要强耦合时
// 场景:成就系统监听多个模块的事件
public class AchievementSystem : Node
{
public override void _Ready()
{
// 订阅各种游戏事件
EventBus.Instance.Subscribe<PlayerDeathEvent>(OnPlayerDeath);
EventBus.Instance.Subscribe<QuestCompletedEvent>(OnQuestCompleted);
EventBus.Instance.Subscribe<InventoryChangedEvent>(OnInventoryChanged);
}
}
UI更新:当游戏状态变化需要更新多个UI元素时
游戏状态变更:关卡开始/结束、暂停/恢复等全局状态变化
跨系统通知:存档、音效、统计等辅助系统需要响应游戏事件
2.2 不适用场景
高频实时通信:如每帧更新的物理计算、动画插值等
// 不推荐:高频事件
public override void _PhysicsProcess(double delta)
{
// 每帧都发布事件会造成性能问题
EventBus.Instance.Publish(new PositionChangedEvent(this, transform.Position));
}
// 推荐:直接调用
public override void _PhysicsProcess(double delta)
{
// 直接更新位置
_sprite.Position = transform.Position;
}
需要同步返回值的场景:事件系统是异步的,无法直接获取返回值
// 不推荐:尝试通过事件获取返回值
public int GetPlayerLevel()
{
int level = 0;
// 事件处理器无法返回数据给发布者
EventBus.Instance.Publish(new QueryPlayerLevelEvent(this, (l) => level = l));
return level; // 总是0,因为事件是异步处理的
}
2.3 典型案例
成就系统:当玩家完成特定条件时解锁成就 任务系统:任务进度更新时通知UI和奖励系统 UI事件:生命值变化时更新血条、技能冷却时更新图标
成就系统事件流:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 玩家击杀敌人 │────▶│ 事件总线 │────▶│ 成就系统 │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ 统计系统 │
└──────────────┘
│
▼
┌──────────────┐
│ UI显示弹窗 │
└──────────────┘
3. 实现细节讲解
3.1 为什么使用泛型委托
我们使用Action<TEvent>而非Action<object>或Action有两个主要原因:
类型安全:编译时检查事件类型匹配,避免运行时错误
// 使用泛型:编译时类型检查
public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
// 如果传入的handler参数类型不匹配,编译器会报错
}
// 使用非泛型:运行时可能出错
public void Subscribe(Type eventType, Action<object> handler)
{
// 运行时可能接收到错误类型的事件
handler.Invoke(wrongTypeEvent); // 运行时异常
}
性能优化:避免装箱拆箱操作
// 值类型事件数据
public struct PlayerHealthChangeData
{
public int CurrentHealth;
public int MaxHealth;
public int Delta;
}
// 使用泛型委托:无装箱
Action<PlayerHealthChangedEvent> handler = (evt) => { /* ... */ };
// 使用非泛型委托:需要装箱
Action<object> handler = (obj) =>
{
var evt = (PlayerHealthChangedEvent)obj; // 拆箱
/* ... */
};
3.2 事件过滤机制设计思路
事件过滤允许订阅者只接收感兴趣的事件子集:
// 实现事件过滤
public class EventFilter<TEvent> where TEvent : IGameEvent
{
private readonly Func<TEvent, bool> _filter;
private readonly Action<TEvent> _handler;
public EventFilter(Func<TEvent, bool> filter, Action<TEvent> handler)
{
_filter = filter;
_handler = handler;
}
public void Handle(TEvent evt)
{
if (_filter(evt))
{
_handler(evt);
}
}
}
// 使用示例:只接收特定来源的伤害事件
EventBus.Instance.Subscribe<PlayerHealthChangedEvent>(
new EventFilter<PlayerHealthChangedEvent>(
evt => evt.Data.DamageSource?.Name == "BossEnemy",
OnBossDamage
).Handle
);
3.3 内存泄漏防范策略
事件系统最常见的内存泄漏原因是忘记取消订阅:
// 错误示例:内存泄漏
public class UIController : Node
{
public override void _Ready()
{
// 订阅事件
EventBus.Instance.Subscribe<PlayerHealthChangedEvent>(OnHealthChanged);
}
// 忘记在_ExitTree中取消订阅!
// 当UIController被销毁时,事件总线仍然持有对它的引用
}
防范策略:
1. 使用EventBusNode基类:自动处理取消订阅
2. 使用弱引用:
public class WeakEventHandler<TEvent> where TEvent : IGameEvent
{
private readonly WeakReference _target;
private readonly Action<TEvent> _handler;
public void Invoke(TEvent evt)
{
if (_target.IsAlive)
{
_handler(evt);
}
else
{
// 目标已被GC,自动取消订阅
EventBus.Instance.Unsubscribe(evt.GetType(), this);
}
}
}
3. 定期清理:
public void CleanupDeadHandlers()
{
foreach (var kvp in _handlers.ToList())
{
var handlers = kvp.Value.GetInvocationList();
foreach (var handler in handlers)
{
var target = handler.Target;
if (target is Node node && !IsInstanceValid(node))
{
// 节点已被销毁,移除处理器
Delegate.Remove(kvp.Value, handler);
}
}
}
}
4. 性能与权衡
4.1 事件分发的时间复杂度
事件系统的主要操作时间复杂度:
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 订阅事件 | O(1) | 字典查找 + 委托组合 |
| 取消订阅 | O(n) | 需要在委托链中查找 |
| 发布事件 | O(1) | 字典查找 + 委托调用 |
| 获取订阅者数量 | O(n) | 遍历委托链 |
事件发布流程:
1. 根据事件类型查找处理器字典: O(1)
_handlers.TryGetValue(typeof(TEvent), out Delegate handlers)
2. 调用处理器委托: O(1) ~ O(n)
handlers.Invoke(eventData) // 委托内部遍历所有订阅者
总复杂度:O(1) 平均,O(n) 最坏情况(n为订阅者数量)
4.2 订阅者过多时的性能影响
当订阅者数量过多时,需要考虑:
委托链遍历开销:每个订阅者都会增加调用开销
// 如果有100个订阅者
// 每次发布事件需要遍历100个处理器
// 即使99个处理器什么都不做,遍历开销仍然存在
内存占用:每个订阅都存储一个委托引用
// 假设每个委托引用约32字节
// 1000个订阅者 = 32KB内存
// 10000个订阅者 = 320KB内存
优化策略:
- 按优先级分组,只在必要时调用低优先级处理器
- 实现批量发布,一帧内多次相同事件合并处理
- 定期清理无效的订阅者
4.3 与信号系统的性能对比
| 方面 | C# 事件/委托 | Godot 信号 |
|---|---|---|
| 调用开销 | 较低(直接委托调用) | 较高(需要遍历信号连接) |
| 内存占用 | 较小(每个委托) | 较大(信号对象+连接列表) |
| 编辑器支持 | 无 | 完整(可视化连接) |
| 动态连接 | 完全支持 | 支持但开销大 |
| 跨语言 | C# only | C# + GDScript |
| 类型安全 | 完全类型安全 | 需要运行时类型检查 |
5. 实战指南
5.1 逐步实施步骤
步骤1:定义事件类型
// 为游戏中的主要状态变化定义事件
public class PlayerLevelUpEvent : GameEvent<PlayerLevelUpData>
{
public PlayerLevelUpEvent(object sender, PlayerLevelUpData data) : base(sender, data) { }
}
public struct PlayerLevelUpData
{
public int OldLevel;
public int NewLevel;
public int ExperienceGained;
}
步骤2:创建订阅者基类
// 创建特定功能的订阅者基类
public abstract class AchievementSubscriber : EventBusNode
{
protected abstract void OnPlayerLevelUp(PlayerLevelUpEvent evt);
public override void _Ready()
{
base._Ready();
Subscribe<PlayerLevelUpEvent>(OnPlayerLevelUp);
}
}
步骤3:配置全局事件总线
// 在Autoload中初始化
public partial class GameBootstrap : Node
{
public override void _Ready()
{
// 确保GlobalEventBus存在
if (GlobalEventBus.Instance == null)
{
var bus = new GlobalEventBus();
Engine.RegisterSingleton("GlobalEventBus", bus);
GetTree().Root.AddChild(bus);
}
}
}
步骤4:迁移现有代码
// 原代码
public void LevelUp()
{
_level++;
_uiManager.UpdateLevel(_level);
_achievementManager.CheckLevelAchievement(_level);
_audioManager.PlaySound("levelup");
}
// 新代码
public void LevelUp()
{
int oldLevel = _level;
_level++;
EventBus.Instance.Publish(new PlayerLevelUpEvent(this, new PlayerLevelUpData
{
OldLevel = oldLevel,
NewLevel = _level,
ExperienceGained = _currentExp
}));
}
5.2 常见错误:忘记取消订阅
这是事件系统最常见的错误,会导致内存泄漏和崩溃。
问题代码:
public class TemporaryPopup : Node
{
public override void _Ready()
{
// 订阅全局事件
EventBus.Instance.Subscribe<PlayerHealthChangedEvent>(OnHealthChanged);
// 5秒后自动关闭
GetTree().CreateTimer(5.0).Timeout += QueueFree;
}
private void OnHealthChanged(PlayerHealthChangedEvent evt)
{
// 更新UI
}
// 忘记在销毁时取消订阅!
}
解决方案:
public class TemporaryPopup : EventBusNode // 使用EventBusNode
{
public override void _Ready()
{
base._Ready();
Subscribe<PlayerHealthChangedEvent>(OnHealthChanged);
GetTree().CreateTimer(5.0).Timeout += QueueFree;
}
// EventBusNode会自动处理取消订阅
}
// 或者手动处理
public class TemporaryPopup : Node
{
public override void _Ready()
{
EventBus.Instance.Subscribe<PlayerHealthChangedEvent>(OnHealthChanged);
GetTree().CreateTimer(5.0).Timeout += QueueFree;
}
public override void _ExitTree()
{
// 手动取消订阅
EventBus.Instance.Unsubscribe<PlayerHealthChangedEvent>(OnHealthChanged);
base._ExitTree();
}
}
5.3 测试建议:如何测试事件系统
单元测试事件处理器:
[Test]
public void TestHealthChangedEvent()
{
// Arrange
var player = new PlayerController();
var healthUI = new HealthUIComponent();
bool eventReceived = false;
EventBus.Instance.Subscribe<PlayerHealthChangedEvent>(evt =>
{
eventReceived = true;
Assert.AreEqual(80, evt.Data.CurrentHealth);
});
// Act
player.TakeDamage(20);
// Assert
Assert.IsTrue(eventReceived);
}
测试订阅/取消订阅:
[Test]
public void TestUnsubscribe()
{
int callCount = 0;
Action<TestEvent> handler = evt => callCount++;
// Subscribe and publish
EventBus.Instance.Subscribe<TestEvent>(handler);
EventBus.Instance.Publish(new TestEvent(this));
Assert.AreEqual(1, callCount);
// Unsubscribe and publish again
EventBus.Instance.Unsubscribe<TestEvent>(handler);
EventBus.Instance.Publish(new TestEvent(this));
Assert.AreEqual(1, callCount); // Should not increase
}
集成测试:
[Test]
public void TestEventFlow_PlayerDamageToUI()
{
// Setup complete scene
var player = new PlayerController();
var healthUI = new HealthUI();
AddChild(player);
AddChild(healthUI);
// Act
player.TakeDamage(30);
// Wait for event propagation
yield return new WaitForSeconds(0.1f);
// Assert
Assert.AreEqual("70/100", healthUI.HealthLabel.Text);
}
6. 类型安全的事件总线实现
事件总线(Event Bus)是一种发布-订阅模式(Publish-Subscribe Pattern)的实现,它允许系统中的各个组件通过统一的事件通道进行通信,而无需直接引用彼此。
1.1 EventBus 设计
我们的事件总线设计基于以下核心原则:
- 类型安全:利用 C# 的泛型机制确保事件类型和处理器类型匹配
- 生命周期管理:自动处理订阅者的订阅和取消订阅,防止内存泄漏
- 线程安全:在必要时提供线程安全的事件分发机制
- 性能优化:避免装箱拆箱,使用强类型委托
首先,我们定义事件接口和基础实现:
// 文件路径: Scripts/Core/Events/IGameEvent.cs
using Godot;
namespace GameFramework.Core.Events
{
/// <summary>
/// 游戏事件基础接口
/// 所有自定义事件都必须实现此接口
/// </summary>
public interface IGameEvent
{
/// <summary>
/// 事件发送者,用于追踪事件来源
/// </summary>
object Sender { get; }
/// <summary>
/// 事件创建时间戳
/// </summary>
double Timestamp { get; }
/// <summary>
/// 是否阻止事件继续传播
/// </summary>
bool IsHandled { get; set; }
}
/// <summary>
/// 带泛型参数的事件接口
/// 用于传递具体的数据类型
/// </summary>
/// <typeparam name="T">事件数据类型</typeparam>
public interface IGameEvent<T> : IGameEvent
{
/// <summary>
/// 事件携带的数据
/// </summary>
T Data { get; }
}
}
// 文件路径: Scripts/Core/Events/GameEvent.cs
using Godot;
namespace GameFramework.Core.Events
{
/// <summary>
/// 游戏事件基础实现
/// 提供通用的事件属性和构造方法
/// </summary>
public abstract class GameEvent : IGameEvent
{
/// <summary>
/// 事件发送者
/// </summary>
public object Sender { get; }
/// <summary>
/// 事件创建时间戳(使用 Godot 的时间系统)
/// </summary>
public double Timestamp { get; }
/// <summary>
/// 是否已处理(阻止冒泡)
/// </summary>
public bool IsHandled { get; set; }
/// <summary>
/// 构造函数
/// </summary>
/// <param name="sender">事件发送者</param>
protected GameEvent(object sender)
{
Sender = sender;
// 使用 Godot 引擎的时间获取当前时间戳
Timestamp = Time.GetTicksMsec() / 1000.0;
}
}
/// <summary>
/// 泛型游戏事件实现
/// 可以携带具体的数据类型
/// </summary>
/// <typeparam name="T">事件数据类型</typeparam>
public class GameEvent<T> : GameEvent, IGameEvent<T>
{
/// <summary>
/// 事件数据
/// </summary>
public T Data { get; }
/// <summary>
/// 构造函数
/// </summary>
/// <param name="sender">事件发送者</param>
/// <param name="data">事件数据</param>
public GameEvent(object sender, T data) : base(sender)
{
Data = data;
}
}
}
现在实现核心的事件总线类:
// 文件路径: Scripts/Core/Events/EventBus.cs
using System;
using System.Collections.Generic;
using Godot;
namespace GameFramework.Core.Events
{
/// <summary>
/// 类型安全的事件总线
/// 管理所有事件的订阅、取消订阅和发布
/// </summary>
public class EventBus
{
// 单例实例,用于全局事件总线
private static EventBus _instance;
public static EventBus Instance => _instance ??= new EventBus();
// 存储事件处理器,使用字典实现 O(1) 查找
// 键:事件类型,值:该事件类型的处理器委托列表
private readonly Dictionary<Type, Delegate> _handlers = new();
// 用于线程安全的锁对象
private readonly object _lock = new();
// 标记是否正在发布事件,用于延迟移除
private bool _isPublishing;
// 待移除的处理器队列(在发布期间使用)
private readonly Queue<(Type eventType, Delegate handler)> _pendingRemovals = new();
/// <summary>
/// 订阅指定类型的事件
/// </summary>
/// <typeparam name="TEvent">事件类型</typeparam>
/// <param name="handler">事件处理器</param>
public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
// 参数检查
if (handler == null)
{
GD.PushError("[EventBus] 处理器不能为空");
return;
}
lock (_lock)
{
Type eventType = typeof(TEvent);
// 如果字典中已存在该事件类型的处理器,组合新的处理器
if (_handlers.TryGetValue(eventType, out Delegate existingHandler))
{
_handlers[eventType] = Delegate.Combine(existingHandler, handler);
}
else
{
// 否则添加新的处理器
_handlers[eventType] = handler;
}
GD.Print($"[EventBus] 已订阅事件: {eventType.Name}");
}
}
/// <summary>
/// 取消订阅指定类型的事件
/// </summary>
/// <typeparam name="TEvent">事件类型</typeparam>
/// <param name="handler">要移除的事件处理器</param>
public void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
if (handler == null)
{
return;
}
lock (_lock)
{
Type eventType = typeof(TEvent);
// 如果正在发布事件,延迟移除
if (_isPublishing)
{
_pendingRemovals.Enqueue((eventType, handler));
return;
}
PerformUnsubscribe(eventType, handler);
}
}
/// <summary>
/// 执行实际的取消订阅操作
/// </summary>
private void PerformUnsubscribe<TEvent>(Type eventType, Action<TEvent> handler) where TEvent : IGameEvent
{
if (_handlers.TryGetValue(eventType, out Delegate existingHandler))
{
// 移除指定处理器
var newHandler = Delegate.Remove(existingHandler, handler);
if (newHandler == null)
{
// 如果没有处理器了,从字典中移除该事件类型
_handlers.Remove(eventType);
}
else
{
_handlers[eventType] = newHandler;
}
GD.Print($"[EventBus] 已取消订阅事件: {eventType.Name}");
}
}
/// <summary>
/// 发布事件到所有订阅者
/// </summary>
/// <typeparam name="TEvent">事件类型</typeparam>
/// <param name="eventData">事件数据</param>
public void Publish<TEvent>(TEvent eventData) where TEvent : IGameEvent
{
if (eventData == null)
{
GD.PushError("[EventBus] 事件数据不能为空");
return;
}
lock (_lock)
{
Type eventType = typeof(TEvent);
// 检查是否有处理器订阅了该事件
if (!_handlers.TryGetValue(eventType, out Delegate handlers))
{
return; // 没有订阅者,直接返回
}
// 标记正在发布
_isPublishing = true;
try
{
// 转换为具体类型并调用
if (handlers is Action<TEvent> typedHandler)
{
typedHandler.Invoke(eventData);
}
else
{
// 如果不是预期的类型,尝试动态调用
foreach (Delegate handler in handlers.GetInvocationList())
{
try
{
handler.DynamicInvoke(eventData);
}
catch (Exception ex)
{
GD.PushError($"[EventBus] 调用处理器时出错: {ex.Message}");
}
}
}
}
finally
{
_isPublishing = false;
// 处理延迟移除的订阅
ProcessPendingRemovals();
}
}
}
/// <summary>
/// 处理延迟移除的订阅
/// </summary>
private void ProcessPendingRemovals()
{
while (_pendingRemovals.Count > 0)
{
var (eventType, handler) = _pendingRemovals.Dequeue();
// 使用反射调用泛型方法
var method = typeof(EventBus).GetMethod(nameof(PerformUnsubscribe),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var genericMethod = method.MakeGenericMethod(eventType);
genericMethod.Invoke(this, new object[] { eventType, handler });
}
}
/// <summary>
/// 清除所有订阅
/// </summary>
public void ClearAllSubscriptions()
{
lock (_lock)
{
_handlers.Clear();
_pendingRemovals.Clear();
GD.Print("[EventBus] 已清除所有订阅");
}
}
/// <summary>
/// 获取指定事件的订阅者数量
/// </summary>
public int GetSubscriberCount<TEvent>() where TEvent : IGameEvent
{
lock (_lock)
{
if (_handlers.TryGetValue(typeof(TEvent), out Delegate handler))
{
return handler.GetInvocationList().Length;
}
return 0;
}
}
}
}
1.2 强类型事件参数
为了确保类型安全,我们应该为游戏中的不同系统定义具体的事件类型:
// 文件路径: Scripts/Events/GameplayEvents.cs
using GameFramework.Core.Events;
using Godot;
namespace GameFramework.Events
{
// ==================== 玩家相关事件 ====================
/// <summary>
/// 玩家生命值变化数据
/// </summary>
public struct PlayerHealthChangeData
{
/// <summary>
/// 当前生命值
/// </summary>
public int CurrentHealth;
/// <summary>
/// 最大生命值
/// </summary>
public int MaxHealth;
/// <summary>
/// 变化量(正数为治疗,负数为伤害)
/// </summary>
public int Delta;
/// <summary>
/// 伤害来源(如果是伤害)
/// </summary>
public Node DamageSource;
}
/// <summary>
/// 玩家生命值变化事件
/// </summary>
public class PlayerHealthChangedEvent : GameEvent<PlayerHealthChangeData>
{
public PlayerHealthChangedEvent(object sender, PlayerHealthChangeData data)
: base(sender, data) { }
}
/// <summary>
/// 玩家死亡事件数据
/// </summary>
public struct PlayerDeathData
{
/// <summary>
/// 死亡位置
/// </summary>
public Vector3 DeathPosition;
/// <summary>
/// 击杀者
/// </summary>
public Node Killer;
/// <summary>
/// 死亡原因
/// </summary>
public string DeathReason;
}
/// <summary>
/// 玩家死亡事件
/// </summary>
public class PlayerDeathEvent : GameEvent<PlayerDeathData>
{
public PlayerDeathEvent(object sender, PlayerDeathData data)
: base(sender, data) { }
}
// ==================== 游戏状态事件 ====================
/// <summary>
/// 游戏状态变化类型
/// </summary>
public enum GameStateChangeType
{
Started, // 游戏开始
Paused, // 游戏暂停
Resumed, // 游戏恢复
Ended, // 游戏结束
Restarted // 游戏重新开始
}
/// <summary>
/// 游戏状态变化数据
/// </summary>
public struct GameStateChangeData
{
public GameStateChangeType ChangeType;
public string PreviousState;
public string NewState;
}
/// <summary>
/// 游戏状态变化事件
/// </summary>
public class GameStateChangedEvent : GameEvent<GameStateChangeData>
{
public GameStateChangedEvent(object sender, GameStateChangeData data)
: base(sender, data) { }
}
// ==================== 物品/库存事件 ====================
/// <summary>
/// 物品变化类型
/// </summary>
public enum InventoryChangeType
{
Added, // 添加物品
Removed, // 移除物品
Used, // 使用物品
Equipped, // 装备物品
Unequipped // 卸下物品
}
/// <summary>
/// 库存变化数据
/// </summary>
public struct InventoryChangeData
{
public string ItemId;
public int Quantity;
public int SlotIndex;
public InventoryChangeType ChangeType;
}
/// <summary>
/// 库存变化事件
/// </summary>
public class InventoryChangedEvent : GameEvent<InventoryChangeData>
{
public InventoryChangedEvent(object sender, InventoryChangeData data)
: base(sender, data) { }
}
// ==================== 任务/成就事件 ====================
/// <summary>
/// 任务进度数据
/// </summary>
public struct QuestProgressData
{
public string QuestId;
public string ObjectiveId;
public int CurrentProgress;
public int TargetProgress;
public bool IsCompleted;
}
/// <summary>
/// 任务进度更新事件
/// </summary>
public class QuestProgressEvent : GameEvent<QuestProgressData>
{
public QuestProgressEvent(object sender, QuestProgressData data)
: base(sender, data) { }
}
/// <summary>
/// 成就解锁数据
/// </summary>
public struct AchievementData
{
public string AchievementId;
public string AchievementName;
public string Description;
public Texture2D Icon;
}
/// <summary>
/// 成就解锁事件
/// </summary>
public class AchievementUnlockedEvent : GameEvent<AchievementData>
{
public AchievementUnlockedEvent(object sender, AchievementData data)
: base(sender, data) { }
}
}
1.3 订阅者生命周期管理
在 Godot 中,节点的生命周期管理非常重要。我们需要确保当节点被销毁时,自动取消订阅事件,防止内存泄漏。
// 文件路径: Scripts/Core/Events/EventBusNode.cs
using Godot;
using System;
using System.Collections.Generic;
namespace GameFramework.Core.Events
{
/// <summary>
/// 提供事件总线功能的节点基类
/// 继承此类的节点会自动管理事件订阅的生命周期
/// </summary>
public abstract partial class EventBusNode : Node
{
// 存储该节点的所有订阅,用于自动取消订阅
private readonly List<SubscriptionInfo> _subscriptions = new();
/// <summary>
/// 订阅事件
/// </summary>
/// <typeparam name="TEvent">事件类型</typeparam>
/// <param name="handler">处理器</param>
protected void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
_subscriptions.Add(new SubscriptionInfo
{
EventType = typeof(TEvent),
Handler = handler
});
EventBus.Instance.Subscribe(handler);
}
/// <summary>
/// 取消订阅事件
/// </summary>
/// <typeparam name="TEvent">事件类型</typeparam>
/// <param name="handler">处理器</param>
protected void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
EventBus.Instance.Unsubscribe(handler);
// 从本地列表中移除
for (int i = _subscriptions.Count - 1; i >= 0; i--)
{
if (_subscriptions[i].Handler.Equals(handler))
{
_subscriptions.RemoveAt(i);
}
}
}
/// <summary>
/// 发布事件
/// </summary>
/// <typeparam name="TEvent">事件类型</typeparam>
/// <param name="eventData">事件数据</param>
protected void Publish<TEvent>(TEvent eventData) where TEvent : IGameEvent
{
EventBus.Instance.Publish(eventData);
}
/// <summary>
/// 节点退出场景树时自动取消所有订阅
/// </summary>
public override void _ExitTree()
{
// 自动取消所有订阅
foreach (var subscription in _subscriptions)
{
// 使用反射调用 Unsubscribe 方法
var method = typeof(EventBus).GetMethod("Unsubscribe");
var genericMethod = method.MakeGenericMethod(subscription.EventType);
genericMethod.Invoke(EventBus.Instance, new object[] { subscription.Handler });
}
_subscriptions.Clear();
GD.Print($"[{Name}] 已清理所有事件订阅");
base._ExitTree();
}
/// <summary>
/// 订阅信息存储结构
/// </summary>
private struct SubscriptionInfo
{
public Type EventType;
public Delegate Handler;
}
}
}
使用示例:
// 文件路径: Scripts/Player/PlayerController.cs
using GameFramework.Core.Events;
using GameFramework.Events;
using Godot;
namespace GameFramework.Player
{
/// <summary>
/// 玩家控制器示例
/// 展示如何使用 EventBusNode 基类
/// </summary>
public partial class PlayerController : EventBusNode
{
[Export] private int _maxHealth = 100;
private int _currentHealth;
public override void _Ready()
{
_currentHealth = _maxHealth;
// 步骤1:订阅玩家受伤事件
Subscribe<PlayerHealthChangedEvent>(OnHealthChanged);
// 步骤2:订阅其他系统的事件
Subscribe<GameStateChangedEvent>(OnGameStateChanged);
GD.Print($"[{Name}] 玩家控制器初始化完成");
}
/// <summary>
/// 处理生命值变化
/// </summary>
private void OnHealthChanged(PlayerHealthChangedEvent evt)
{
GD.Print($"[{Name}] 生命值变化: {evt.Data.Delta}, 当前: {evt.Data.CurrentHealth}");
// 如果生命值为0,触发死亡事件
if (evt.Data.CurrentHealth <= 0)
{
Publish(new PlayerDeathEvent(this, new PlayerDeathData
{
DeathPosition = GlobalPosition,
DeathReason = "Health Depleted"
}));
}
}
/// <summary>
/// 处理游戏状态变化
/// </summary>
private void OnGameStateChanged(GameStateChangedEvent evt)
{
// 游戏暂停时禁用输入处理
if (evt.Data.ChangeType == GameStateChangeType.Paused)
{
SetProcessInput(false);
}
else if (evt.Data.ChangeType == GameStateChangeType.Resumed)
{
SetProcessInput(true);
}
}
/// <summary>
/// 受到伤害
/// </summary>
public void TakeDamage(int damage, Node source)
{
int previousHealth = _currentHealth;
_currentHealth = Mathf.Max(0, _currentHealth - damage);
// 发布生命值变化事件
Publish(new PlayerHealthChangedEvent(this, new PlayerHealthChangeData
{
CurrentHealth = _currentHealth,
MaxHealth = _maxHealth,
Delta = _currentHealth - previousHealth,
DamageSource = source
}));
}
/// <summary>
/// 手动取消订阅示例(通常不需要,_ExitTree 会自动处理)
/// </summary>
public void StopListeningToHealthChanges()
{
// 注意:如果手动取消订阅,_ExitTree 中不会再次尝试取消
Unsubscribe<PlayerHealthChangedEvent>(OnHealthChanged);
}
}
}
2. Godot 信号与 C# 事件的选择策略
在 Godot 中,我们有两种主要的事件机制可用:Godot 信号(Signals)和 C# 事件/委托。理解何时使用哪种机制对于构建清晰的游戏架构至关重要。
2.1 何时使用 Godot 信号
Godot 信号适用于以下场景:
- 编辑器配置:需要在 Godot 编辑器中通过可视化界面连接信号
- 节点间直接通信:父子节点或兄弟节点之间的直接交互
- 内置节点事件:响应 Godot 内置节点的事件(如按钮点击、区域进入等)
- 场景独立事件:事件处理逻辑与特定场景紧密耦合
// 文件路径: Scripts/UI/MainMenuButton.cs
using Godot;
namespace GameFramework.UI
{
/// <summary>
/// 主菜单按钮示例
/// 展示何时使用 Godot 信号
/// </summary>
public partial class MainMenuButton : Button
{
[Signal]
public delegate void MenuSelectedEventHandler(string menuName);
[Export] private string _menuName = "Main";
public override void _Ready()
{
// 步骤1:连接内置 Pressed 信号
// 这是 Godot 节点的事件,应该使用 Godot 信号
Pressed += OnButtonPressed;
}
private void OnButtonPressed()
{
// 步骤2:发射自定义信号
// 这个信号可以在编辑器中连接到其他节点
EmitSignal(SignalName.MenuSelected, _menuName);
GD.Print($"[{Name}] 菜单被选中: {_menuName}");
}
/// <summary>
/// 鼠标悬停效果
/// 使用 Godot 内置信号
/// </summary>
private void OnMouseEntered()
{
// 修改按钮样式
Modulate = new Color(1.2f, 1.2f, 1.2f);
}
private void OnMouseExited()
{
Modulate = Colors.White;
}
}
}
// 文件路径: Scripts/Gameplay/TriggerZone.cs
using Godot;
namespace GameFramework.Gameplay
{
/// <summary>
/// 触发区域示例
/// 展示物理/区域检测相关的 Godot 信号使用
/// </summary>
public partial class TriggerZone : Area3D
{
[Signal]
public delegate void PlayerEnteredEventHandler(Node3D player);
[Signal]
public delegate void PlayerExitedEventHandler(Node3D player);
public override void _Ready()
{
// 步骤1:连接 Area3D 的内置信号
// 这些是 Godot 引擎提供的物理检测信号
BodyEntered += OnBodyEntered;
BodyExited += OnBodyExited;
}
private void OnBodyEntered(Node body)
{
// 步骤2:检查是否是玩家
if (body is CharacterBody3D player && player.IsInGroup("Player"))
{
GD.Print($"[{Name}] 玩家进入触发区域");
// 步骤3:发射自定义信号
// 可以在编辑器中连接此信号到关卡设计相关逻辑
EmitSignal(SignalName.PlayerEntered, player);
}
}
private void OnBodyExited(Node body)
{
if (body is CharacterBody3D player && player.IsInGroup("Player"))
{
GD.Print($"[{Name}] 玩家离开触发区域");
EmitSignal(SignalName.PlayerExited, player);
}
}
}
}
2.2 何时使用 C# 事件
C# 事件/委托适用于以下场景:
- 全局/系统级事件:跨多个场景或系统的通信
- 数据变化通知:需要传递复杂数据的状态变化
- 解耦系统间通信:不相关的系统需要相互通知
- 动态订阅:需要在运行时动态添加/移除监听者
- 类型安全要求高:需要编译时类型检查
// 文件路径: Scripts/Systems/AchievementSystem.cs
using GameFramework.Core.Events;
using GameFramework.Events;
using Godot;
using System.Collections.Generic;
namespace GameFramework.Systems
{
/// <summary>
/// 成就系统示例
/// 展示何时使用 C# 事件系统
/// </summary>
public partial class AchievementSystem : Node
{
// 成就数据库
private Dictionary<string, AchievementDefinition> _achievements = new();
// 解锁的成就列表
private HashSet<string> _unlockedAchievements = new();
public override void _Ready()
{
// 步骤1:订阅全局事件
// 成就系统需要监听多个不相关系统的事件
EventBus.Instance.Subscribe<PlayerDeathEvent>(OnPlayerDeath);
EventBus.Instance.Subscribe<QuestProgressEvent>(OnQuestProgress);
EventBus.Instance.Subscribe<InventoryChangedEvent>(OnInventoryChanged);
InitializeAchievements();
}
/// <summary>
/// 初始化成就定义
/// </summary>
private void InitializeAchievements()
{
_achievements["first_death"] = new AchievementDefinition
{
Id = "first_death",
Name = "初次阵亡",
Description = "在游戏中第一次死亡",
Condition = (data) => true
};
_achievements["collector"] = new AchievementDefinition
{
Id = "collector",
Name = "收藏家",
Description = "收集100个物品",
Condition = (data) => GetTotalItemsCollected() >= 100
};
// 更多成就定义...
}
/// <summary>
/// 处理玩家死亡事件
/// 这是一个全局事件,来自玩家系统
/// </summary>
private void OnPlayerDeath(PlayerDeathEvent evt)
{
TryUnlockAchievement("first_death");
}
/// <summary>
/// 处理任务进度事件
/// 来自任务系统
/// </summary>
private void OnQuestProgress(QuestProgressEvent evt)
{
if (evt.Data.IsCompleted)
{
TryUnlockAchievement($"quest_{evt.Data.QuestId}");
}
}
/// <summary>
/// 处理库存变化事件
/// 来自物品系统
/// </summary>
private void OnInventoryChanged(InventoryChangedEvent evt)
{
if (evt.Data.ChangeType == InventoryChangeType.Added)
{
TryUnlockAchievement("collector");
}
}
/// <summary>
/// 尝试解锁成就
/// </summary>
private void TryUnlockAchievement(string achievementId)
{
// 检查是否已解锁
if (_unlockedAchievements.Contains(achievementId))
{
return;
}
// 检查是否满足条件
if (_achievements.TryGetValue(achievementId, out var definition))
{
if (definition.Condition(null))
{
UnlockAchievement(definition);
}
}
}
/// <summary>
/// 解锁成就并发布事件
/// </summary>
private void UnlockAchievement(AchievementDefinition definition)
{
_unlockedAchievements.Add(definition.Id);
GD.Print($"[AchievementSystem] 解锁成就: {definition.Name}");
// 发布成就解锁事件
// 这将通知 UI 系统显示成就弹窗
// 通知统计系统记录成就
// 通知存档系统保存进度
EventBus.Instance.Publish(new AchievementUnlockedEvent(this, new AchievementData
{
AchievementId = definition.Id,
AchievementName = definition.Name,
Description = definition.Description
}));
}
private int GetTotalItemsCollected() => 0; // 实际实现从存档读取
/// <summary>
/// 成就定义结构
/// </summary>
private class AchievementDefinition
{
public string Id;
public string Name;
public string Description;
public System.Func<object, bool> Condition;
}
}
}
2.3 混合使用策略
在实际项目中,我们需要同时使用两种机制。以下是推荐的分层策略:
// 文件路径: Scripts/Architecture/EventStrategyGuide.md
/*
事件使用策略指南:
┌─────────────────────────────────────────────────────────────┐
│ 系统分层架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ UI 层 │ ← 使用 Godot 信号 │
│ │ (按钮、界面) │ - 按钮点击 │
│ └──────┬───────┘ - 界面切换 │
│ │ │
│ ┌──────▼───────┐ │
│ │ 游戏逻辑层 │ ← 使用 C# 事件总线 │
│ │(玩家、敌人) │ - 状态变化 │
│ └──────┬───────┘ - 数据更新 │
│ │ │
│ ┌──────▼───────┐ │
│ │ 系统层 │ ← 混合使用 │
│ │(存档、音效) │ - Godot 信号用于引擎交互 │
│ └──────────────┘ - C# 事件用于跨系统通信 │
│ │
└─────────────────────────────────────────────────────────────┘
具体规则:
1. 同一节点树内的通信 → Godot 信号
2. 跨系统的全局通信 → C# 事件总线
3. 与 Godot 引擎内置节点交互 → Godot 信号
4. 需要传递复杂数据的事件 → C# 事件
5. 需要在编辑器中配置的事件 → Godot 信号
*/
// 文件路径: Scripts/Player/PlayerHealthUI.cs
using GameFramework.Core.Events;
using GameFramework.Events;
using Godot;
namespace GameFramework.Player
{
/// <summary>
/// 玩家生命值 UI 组件
/// 展示混合使用策略:
/// - 使用 C# 事件监听全局生命值变化
/// - 使用 Godot 信号更新 UI 节点
/// </summary>
public partial class PlayerHealthUI : Control
{
[Signal]
public delegate void HealthCriticalEventHandler();
[Export] private ProgressBar _healthBar;
[Export] private Label _healthLabel;
[Export] private AnimationPlayer _animationPlayer;
// 生命值警告阈值
private const float CriticalHealthThreshold = 0.2f;
public override void _Ready()
{
// 步骤1:订阅全局 C# 事件
// 这是跨系统通信,使用 EventBus
EventBus.Instance.Subscribe<PlayerHealthChangedEvent>(OnHealthChanged);
// 步骤2:初始化 UI
UpdateHealthDisplay(100, 100);
}
/// <summary>
/// 处理生命值变化事件
/// 来自 EventBus 的全局事件
/// </summary>
private void OnHealthChanged(PlayerHealthChangedEvent evt)
{
var data = evt.Data;
// 步骤3:更新 UI
UpdateHealthDisplay(data.CurrentHealth, data.MaxHealth);
// 步骤4:检查是否处于危险状态
float healthPercent = (float)data.CurrentHealth / data.MaxHealth;
if (healthPercent <= CriticalHealthThreshold)
{
// 步骤5:发射 Godot 信号
// 这个信号可以在编辑器中连接到其他 UI 效果
EmitSignal(SignalName.HealthCritical);
// 播放危险状态动画
_animationPlayer?.Play("health_critical");
}
}
/// <summary>
/// 更新生命值显示
/// </summary>
private void UpdateHealthDisplay(int current, int max)
{
if (_healthBar != null)
{
_healthBar.MaxValue = max;
_healthBar.Value = current;
}
if (_healthLabel != null)
{
_healthLabel.Text = $"{current} / {max}";
}
}
/// <summary>
/// Godot 信号回调示例
/// 可以在编辑器中连接此函数
/// </summary>
public void OnHealthCriticalEffect()
{
// 屏幕红色闪烁效果
GD.Print("[{Name}] 触发生命值危急效果");
}
public override void _ExitTree()
{
// 取消订阅 C# 事件
EventBus.Instance.Unsubscribe<PlayerHealthChangedEvent>(OnHealthChanged);
base._ExitTree();
}
}
}
3. 全局事件与局部事件分离
为了构建可维护的大型项目,我们需要明确区分全局事件和局部事件的作用域。
3.1 GlobalEventBus
全局事件总线用于跨场景、跨系统的通信。
// 文件路径: Scripts/Core/Events/GlobalEventBus.cs
using Godot;
using System;
using System.Collections.Generic;
namespace GameFramework.Core.Events
{
/// <summary>
/// 全局事件总线
/// 用于跨场景、跨系统的全局事件通信
/// 在游戏整个生命周期内保持存在
/// </summary>
public partial class GlobalEventBus : Node
{
// 单例实例
private static GlobalEventBus _instance;
public static GlobalEventBus Instance
{
get
{
if (_instance == null)
{
GD.PushError("[GlobalEventBus] 实例尚未初始化。请确保在场景树中添加了 GlobalEventBus 节点");
}
return _instance;
}
}
// 内部使用 EventBus 实现
private readonly EventBus _eventBus = new();
// 场景加载完成时重置标记
private bool _shouldResetOnSceneChange = false;
public override void _EnterTree()
{
// 步骤1:确保只有一个实例
if (_instance != null)
{
GD.PushWarning("[GlobalEventBus] 存在多个实例,销毁重复实例");
QueueFree();
return;
}
_instance = this;
// 步骤2:设置为持久节点(切换场景时不销毁)
ProcessMode = ProcessModeEnum.Always;
GD.Print("[GlobalEventBus] 全局事件总线已初始化");
}
/// <summary>
/// 订阅全局事件
/// </summary>
public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
_eventBus.Subscribe(handler);
}
/// <summary>
/// 取消订阅全局事件
/// </summary>
public void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
_eventBus.Unsubscribe(handler);
}
/// <summary>
/// 发布全局事件
/// </summary>
public void Publish<TEvent>(TEvent eventData) where TEvent : IGameEvent
{
_eventBus.Publish(eventData);
}
/// <summary>
/// 设置是否在场景切换时重置
/// </summary>
public void SetResetOnSceneChange(bool shouldReset)
{
_shouldResetOnSceneChange = shouldReset;
}
/// <summary>
/// 清除所有订阅
/// </summary>
public void ClearAllSubscriptions()
{
_eventBus.ClearAllSubscriptions();
GD.Print("[GlobalEventBus] 已清除所有订阅");
}
public override void _ExitTree()
{
if (_instance == this)
{
_instance = null;
}
base._ExitTree();
}
}
}
3.2 LocalEventBus
局部事件总线用于场景内部的模块间通信,随场景销毁而清理。
// 文件路径: Scripts/Core/Events/LocalEventBus.cs
using Godot;
using System;
using System.Collections.Generic;
namespace GameFramework.Core.Events
{
/// <summary>
/// 局部事件总线
/// 用于单个场景内部的模块间通信
/// 场景切换时自动清理
/// </summary>
public partial class LocalEventBus : Node
{
// 存储当前场景中所有的局部事件总线
private static readonly Dictionary<string, LocalEventBus> _instances = new();
/// <summary>
/// 获取或创建指定作用域的局部事件总线
/// </summary>
/// <param name="scope">作用域名称</param>
public static LocalEventBus GetOrCreate(string scope = "default")
{
if (_instances.TryGetValue(scope, out var existing))
{
return existing;
}
// 创建新的实例
var instance = new LocalEventBus(scope);
// 添加到场景树根节点
var tree = Engine.GetMainLoop() as SceneTree;
tree?.Root.CallDeferred("add_child", instance);
return instance;
}
// 内部使用 EventBus 实现
private readonly EventBus _eventBus = new();
// 此总线的作用域
public string Scope { get; private set; }
// 构造函数
private LocalEventBus(string scope)
{
Scope = scope;
_instances[scope] = this;
GD.Print($"[LocalEventBus] 创建新的局部事件总线: {scope}");
}
public override void _Ready()
{
Name = $"LocalEventBus_{Scope}";
}
/// <summary>
/// 订阅局部事件
/// </summary>
public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
_eventBus.Subscribe(handler);
}
/// <summary>
/// 取消订阅局部事件
/// </summary>
public void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : IGameEvent
{
_eventBus.Unsubscribe(handler);
}
/// <summary>
/// 发布局部事件
/// </summary>
public void Publish<TEvent>(TEvent eventData) where TEvent : IGameEvent
{
_eventBus.Publish(eventData);
}
/// <summary>
/// 获取订阅者数量
/// </summary>
public int GetSubscriberCount<TEvent>() where TEvent : IGameEvent
{
return _eventBus.GetSubscriberCount<TEvent>();
}
public override void _ExitTree()
{
// 从字典中移除
_instances.Remove(Scope);
// 清理所有订阅
_eventBus.ClearAllSubscriptions();
GD.Print($"[LocalEventBus] 销毁局部事件总线: {Scope}");
base._ExitTree();
}
}
}
3.3 事件作用域管理
为了更好地管理事件作用域,我们提供一个管理器类:
// 文件路径: Scripts/Core/Events/EventScopeManager.cs
using Godot;
using System.Collections.Generic;
namespace GameFramework.Core.Events
{
/// <summary>
/// 事件作用域管理器
/// 提供统一的事件作用域管理接口
/// </summary>
public static class EventScopeManager
{
// 记录当前激活的作用域
private static readonly HashSet<string> _activeScopes = new();
/// <summary>
/// 创建新的局部事件作用域
/// </summary>
/// <param name="scopeName">作用域名称</param>
public static LocalEventBus CreateScope(string scopeName)
{
if (_activeScopes.Contains(scopeName))
{
GD.PushWarning($"[EventScopeManager] 作用域 '{scopeName}' 已存在,返回现有实例");
return LocalEventBus.GetOrCreate(scopeName);
}
_activeScopes.Add(scopeName);
return LocalEventBus.GetOrCreate(scopeName);
}
/// <summary>
/// 销毁指定作用域
/// </summary>
public static void DestroyScope(string scopeName)
{
if (!_activeScopes.Contains(scopeName))
{
return;
}
// 获取并销毁
var bus = LocalEventBus.GetOrCreate(scopeName);
bus.QueueFree();
_activeScopes.Remove(scopeName);
}
/// <summary>
/// 销毁所有局部作用域
/// </summary>
public static void DestroyAllScopes()
{
foreach (var scope in _activeScopes)
{
var bus = LocalEventBus.GetOrCreate(scope);
bus.QueueFree();
}
_activeScopes.Clear();
GD.Print("[EventScopeManager] 已销毁所有局部作用域");
}
/// <summary>
/// 检查作用域是否存在
/// </summary>
public static bool ScopeExists(string scopeName)
{
return _activeScopes.Contains(scopeName);
}
/// <summary>
/// 获取所有激活的作用域名称
/// </summary>
public static IEnumerable<string> GetActiveScopes()
{
return _activeScopes;
}
}
}
使用示例:
// 文件路径: Scripts/Levels/LevelManager.cs
using GameFramework.Core.Events;
using GameFramework.Events;
using Godot;
namespace GameFramework.Levels
{
/// <summary>
/// 关卡管理器示例
/// 展示全局事件与局部事件的分离使用
/// </summary>
public partial class LevelManager : Node
{
// 局部事件总线引用
private LocalEventBus _levelEventBus;
[Export] private string _levelName = "Level_1";
public override void _Ready()
{
// 步骤1:创建局部事件总线
// 此总线只在本关卡内有效
_levelEventBus = EventScopeManager.CreateScope($"Level_{_levelName}");
// 步骤2:订阅局部事件
// 这些事件只在当前关卡内传播
_levelEventBus.Subscribe<PlayerHealthChangedEvent>(OnLocalPlayerHealthChanged);
// 步骤3:订阅全局事件
// 这些事件跨所有关卡传播
GlobalEventBus.Instance.Subscribe<GameStateChangedEvent>(OnGlobalGameStateChanged);
GD.Print($"[{Name}] 关卡管理器初始化,作用域: Level_{_levelName}");
}
/// <summary>
/// 局部事件处理器
/// 只响应当前关卡内的事件
/// </summary>
private void OnLocalPlayerHealthChanged(PlayerHealthChangedEvent evt)
{
// 只在当前关卡内处理生命值变化
// 例如:触发关卡特定的陷阱、检查关卡完成条件等
GD.Print($"[{Name}] 局部处理生命值变化: {evt.Data.CurrentHealth}");
// 发布局部事件通知关卡内的其他系统
_levelEventBus.Publish(new GameStateChangedEvent(this, new GameStateChangeData
{
ChangeType = GameStateChangeType.Started,
NewState = "LevelSpecificState"
}));
}
/// <summary>
/// 全局事件处理器
/// 响应跨关卡的全局事件
/// </summary>
private void OnGlobalGameStateChanged(GameStateChangedEvent evt)
{
// 处理全局游戏状态变化
if (evt.Data.ChangeType == GameStateChangeType.Paused)
{
// 暂停关卡逻辑
PauseLevel();
}
else if (evt.Data.ChangeType == GameStateChangeType.Resumed)
{
// 恢复关卡逻辑
ResumeLevel();
}
}
private void PauseLevel()
{
GetTree().Paused = true;
GD.Print($"[{Name}] 关卡已暂停");
}
private void ResumeLevel()
{
GetTree().Paused = false;
GD.Print($"[{Name}] 关卡已恢复");
}
/// <summary>
/// 关卡完成时调用
/// </summary>
public void CompleteLevel()
{
// 发布全局事件通知游戏管理器
GlobalEventBus.Instance.Publish(new GameStateChangedEvent(this, new GameStateChangeData
{
ChangeType = GameStateChangeType.Ended,
PreviousState = _levelName,
NewState = "LevelComplete"
}));
}
public override void _ExitTree()
{
// 取消订阅全局事件
GlobalEventBus.Instance.Unsubscribe<GameStateChangedEvent>(OnGlobalGameStateChanged);
// 局部事件总线会在场景切换时自动销毁
// 不需要手动处理
base._ExitTree();
}
}
}
最佳实践总结
1. 事件命名规范
// 文件路径: Scripts/Core/Events/NamingConventions.md
/*
事件命名规范:
1. 事件类名:使用过去时态,以 "Event" 结尾
- PlayerHealthChangedEvent (玩家生命值已改变)
- ItemCollectedEvent (物品已收集)
- GameStateChangedEvent (游戏状态已改变)
2. 事件数据类名:描述数据内容,以 "Data" 结尾
- PlayerHealthChangeData
- ItemCollectionData
- GameStateChangeData
3. 处理器方法名:以 "On" 开头,描述事件
- OnPlayerHealthChanged
- OnItemCollected
- OnGameStateChanged
4. 事件类型枚举:使用动作描述
- Changed (已改变)
- Started (已开始)
- Ended (已结束)
- Collected (已收集)
*/
2. 性能优化建议
// 文件路径: Scripts/Core/Events/PerformanceTips.md
/*
性能优化建议:
1. 避免频繁创建事件对象
- 对于高频事件(如每帧更新),考虑使用直接调用而非事件系统
- 重用事件对象或使用结构体作为事件数据
2. 及时取消订阅
- 使用 EventBusNode 基类自动管理生命周期
- 在 _ExitTree 中手动取消订阅
3. 使用对象池
- 对于频繁触发的事件,实现事件对象池
4. 延迟处理
- 对于非紧急事件,考虑在下一帧处理
- 使用 Godot 的 CallDeferred 方法
5. 事件过滤
- 在处理器中尽早检查条件,避免不必要处理
- 使用 IsHandled 属性阻止事件传播
*/
3. 调试和监控
// 文件路径: Scripts/Core/Events/EventDebugger.cs
using Godot;
using System;
using System.Collections.Generic;
namespace GameFramework.Core.Events
{
/// <summary>
/// 事件调试器
/// 用于监控事件系统的运行状态
/// </summary>
public partial class EventDebugger : Node
{
[Export] private bool _enableLogging = true;
[Export] private bool _enableWarningOnUnsubscribedEvents = true;
// 事件统计
private Dictionary<string, int> _eventPublishCounts = new();
private Dictionary<string, int> _eventHandlerCounts = new();
public override void _Ready()
{
if (_enableLogging)
{
GD.Print("[EventDebugger] 事件调试器已启用");
}
}
/// <summary>
/// 记录事件发布
/// </summary>
public void LogEventPublish(string eventType)
{
if (!_enableLogging) return;
if (!_eventPublishCounts.ContainsKey(eventType))
{
_eventPublishCounts[eventType] = 0;
}
_eventPublishCounts[eventType]++;
// 如果某事件发布次数过多,发出警告
if (_enableWarningOnUnsubscribedEvents && _eventPublishCounts[eventType] > 100)
{
if (!_eventHandlerCounts.ContainsKey(eventType) || _eventHandlerCounts[eventType] == 0)
{
GD.PushWarning($"[EventDebugger] 事件 '{eventType}' 已发布 {_eventPublishCounts[eventType]} 次,但没有订阅者");
}
}
}
/// <summary>
/// 记录处理器注册
/// </summary>
public void LogHandlerRegistered(string eventType)
{
if (!_eventHandlerCounts.ContainsKey(eventType))
{
_eventHandlerCounts[eventType] = 0;
}
_eventHandlerCounts[eventType]++;
}
/// <summary>
/// 打印统计信息
/// </summary>
public void PrintStatistics()
{
GD.Print("========== 事件系统统计 ==========");
GD.Print("事件发布统计:");
foreach (var kvp in _eventPublishCounts)
{
GD.Print($" {kvp.Key}: {kvp.Value} 次");
}
GD.Print("处理器注册统计:");
foreach (var kvp in _eventHandlerCounts)
{
GD.Print($" {kvp.Key}: {kvp.Value} 个处理器");
}
GD.Print("==================================");
}
}
}
本章介绍了事件系统的核心设计理念和实现方案。通过类型安全的事件总线、合理的事件作用域分离,以及 Godot 信号与 C# 事件的混合使用策略,我们可以构建出既灵活又易于维护的游戏架构。在实际项目中,请根据具体需求选择合适的通信机制,并始终关注事件订阅的生命周期管理,以避免内存泄漏问题。