ECS框架-EnTT信号系统
EnTT信号系统
问题是如何产生的?我打死了一个敌人,可能会牵扯到很多东西,比如需要播放敌人的死亡动画,需要播放音效,需要更新得分,需要掉落道具,那么这么多事件难道都写在敌人击杀的那块代码中吗?这将会形成一个高度耦合的函数,这不是一种好的处理方法。
这里我们学习EnTT的信号/事件机制,用"发布事件 ---> 多处订阅响应"的方式,把这些后续逻辑拆解。
目标:
- 学习EnTT信号系统的三个重要概念:
entt::delegate/entt::sigh + entt::sink/entt::dispatcher - 理解
dispatcher的工作方式:连接监听器 → 发布事件(enqueue/trigger)→ update 统一处理 - 学会用
registry.ctx()存放“非组件”的全局数据(本节示例:分数game_state) - 了解事件对象应该携带哪些信息,避免“实体销毁后取不到数据”的坑
设计思路
- 思路转变,我们会从直接调用的方式走向发布-订阅
事件驱动的思想是:发生了什么事件与要做什么响应解耦,所以游戏中,一个系统经常只是负责产生事件,但不关心谁处理它,比如战斗系统只负责发布enemy_destroyed_event的事件;分数系统监听它并加分;掉落系统监听并生成掉落。这种方式带来的收益就是:如果新加了一个系统,只是再连接一个监听器,而不需要更改其他系统,它是职责分离的,如:
敌人死亡事件流程:
战斗系统
↓
【发现敌人死亡】
↓
创建 EnemyDeathEvent 事件
↓
通过 dispatcher.trigger(event) 发出
↓
├─→ 分数系统收到通知 → 加分数
├─→ 特效系统收到通知 → 播放死亡特效
├─→ 音效系统收到通知 → 播放死亡音效
├─→ 任务系统收到通知 → 更新击杀任务
└─→ 统计系统收到通知 → 记录击杀数据
- delegate / sigh / dispatcher:三层工具的关系
entt::delegate:单播回调(只绑定一个函数),可以理解为轻量级的 std::function 替代品
entt::sigh:多播信号(一个信号绑定多个回调)
entt::sink:信号的“入口”,提供安全的 connect/disconnect 接口(一般通过 sink 来连回调)
entt::dispatcher 则可以理解为“更上层的事件中心”:它按事件类型管理一组 sigh,并额外提供 事件队列,让你能把事件在主循环中“集中入队、统一出队处理”。
delegate
先来讲讲用法,其实和std::function极其类似,区别可能就是不是采用赋值,而是connect传函数指针的方式。
entt::delegate<int(int, int)> my_delegate;
// 定义函数
int add(int a, int b) {
return a + b;
}
if(!my_delegate){
// 委托空的话,调用会导致未定义行为
}
// 连接一个函数
my_delegate.connect<&add>(); // 方法传的是函数指针
// 开始调用
if(my_delegate){
// 调用委托,就是调用一个普通函数
int res = my_delegate(1,2); // 就当一个函数名调用即可
}
// 当然也可以连接类的成员函数
class MyClass {
public:
int multiply(int a, int b) { return a * b; }
};
MyClass obj;
entt::delegate<int(int, int)> delegate1;
delegate1.connect<&MyClass::multiply>(obj); // 连接成员函数,区别就在于需要指明是哪个对象,也就是实例,obj是刚定义的一个实例
int res1 = delegate1(2, 3); // 返回 6
// 当然有了委托不想要了也可以重置,
delegate1.reset(); // 委托被置空
// 使用构造函数直接连接(便捷方式)
MyClass obj1;
// 一步到位,语法虽然复杂了一点,忘了就忘了,没关系的,可以马上查到的
entt::delegate<int(int,int)> delegate2{entt::connect_arg<&MyClass::multiply>,obj1};
delegate某种程度上可以替代std::function,但是有点缺陷,lambda函数支持不完整,这点要注意,语法上相对较为复杂,没必要为了一点提升耗费太大资源,收益不高。
这块是理解后面信号和分发的基石...
信号(entt::sigh,entt::sink)
一个"多播"的std::function,一个信号可以连接多个监听者(回调函数)。
其实理解起来还是容易的,就是可以绑定多个回调函数,里面有个数组,存放一堆的delegate嘛,每个delegate绑定一个函数,这样就实现了,一个触发,调用sigh,依次去调用函数
讲讲sigh、sink的用法
// 定义一个事件类型(任意可传递的对象)
struct MyEvent {
int value;
std::string message;
};
// 1. 连接普通函数
void Listener_func(const MyEvent& event) {
spdlog::info("普通函数处理事件: {}, {}", event.value, event.message);
}
// 2. 连接lambda表达式, 注意限制了不可捕获参数
auto lambda_listener = [](const MyEvent& event) {
spdlog::info("Lambda处理事件: {}, {}", event.value, event.message);
};
// 3. 连接成员函数
class Listener {
public:
void onEvent(const MyEvent& event) {
spdlog::info("成员函数处理事件: {}, {}", event.value, event.message);
}
};
int main() {
// 创建信号
entt::sigh<void(const MyEvent&)> my_signal;
// 创建sink 管理连接
entt::sink sink{my_signal};
// 创建监听器实例
Listener listener_instance;
// 连接回调函数
sink.connect<&Listener_func>(); // 普通函数
sink.connect(lambda_listener); // Lambda
sink.connect<&Listener::onEvent>(listener_instance); // 成员函数
// 触发信号 - 所有连接的回调都会被调用
my_signal.publish(MyEvent{42, "测试事件"});
// 断开监听器
sink.disconnect<&Listener::onEvent>(listener_instance);
spdlog::info("当前连接了{}个监听器",my_sigal.size());
// 再次发布
my_signal.publish(MyEvent{666, "测试事件"});
return 0;
}
这里简单讲讲sink,为什么sigh要用sink包起来,sink主要是提供安全订阅/取消订阅事件的接口,避免直接操作sigh带来风险,这是一种更为安全的操作方式。
方法都容易理解,说话又好听,但是有个问题,函数签名都是固定的,如果多种不同类型的签名怎么办?
分发器(entt::dispatcher)
事件中心,集成了sigh和sink,集中管理各种类型的事件信号与回调,支持事件队列
dispatcher中类似的有一个map,键就是事件类型,值就是信号sigh,sigh里可以绑定某种事件类型的多个回调函数。
struct position { float x; float y; };
struct velocity { float dx; float dy; };
struct tag { entt::id_type id; std::string value; };
// 上下文变量
struct game_state {
int score = 0;
};
// 1. 定义事件
// 事件通常是简单的结构体,用于携带数据。
// 这个事件携带了被消灭的敌人的信息。 事件一定要是一个结构体
struct enemy_destroyed_event {
entt::entity enemy_entity;
// 可以在此添加更多信息,比如敌人类型、掉落物品等
};
// 2. 创建一个监听器,监听器可以是一个独立的类或一个函数。
// 这里使用类来组织逻辑。
class ScoreSystem {
entt::registry& registry; // 需要 registry 的引用,以便获取上下文
public:
ScoreSystem(entt::registry& reg) : registry(reg) {}
// 这是事件处理函数。当接收到 enemy_destroyed_event 时,这个函数会被调用。
void on_enemy_destroyed(const enemy_destroyed_event& event) {
// 从上下文中获取游戏状态并增加分数
auto& state = registry.ctx().get<game_state>();
state.score += 10;
spdlog::info("杀死敌人 {},分数增加!当前分数: {}", entt::to_integral(event.enemy_entity), state.score);
/* entt::entity 的底层是 entt::id_type,即 uint32_t,但不可直接当成整数用(保证类型安全),
可以用 entt::to_integral 显式转换为 uint32_t */
}
};
// 另一种监听器,直接使用函数
void dummy_listener(const enemy_destroyed_event& event) {
spdlog::info("DummyListener 收到事件:敌人 {} 被摧毁!", entt::to_integral(event.enemy_entity));
}
int main() {
using namespace entt::literals;
entt::registry registry;
// 初始化上下文变量
registry.ctx().emplace<game_state>();
// 3. 创建事件分发器 (dispatcher) 和监听器实例
entt::dispatcher dispatcher{};
ScoreSystem score_system(registry);
// 4. 连接监听器到分发器
// dispatcher.sink<EventType>().connect<&Class::MemberFunction>(instance)
// 这行代码告诉分发器:当有 enemy_destroyed_event 类型的事件时,
// 调用 score_system 实例的 on_enemy_destroyed 方法。
dispatcher.sink<enemy_destroyed_event>().connect<&ScoreSystem::on_enemy_destroyed>(score_system);
// 同一个事件可以连接多个函数(注意调用的顺序,后进先调)
dispatcher.sink<enemy_destroyed_event>().connect<&dummy_listener>();
// 创建实体
entt::entity enemy_to_destroy = registry.create();
registry.emplace<tag>(enemy_to_destroy, "enemy"_hs, "enemy");
spdlog::info("=== 游戏进行中 ===");
spdlog::info("初始分数: {}", registry.ctx().get<game_state>().score);
// ... 战斗发生 ...
spdlog::info("玩家摧毁了敌人 {}!", static_cast<uint32_t>(enemy_to_destroy));
registry.destroy(enemy_to_destroy); // 从 registry 中移除实体
// 5. 发布事件
// 使用 enqueue 来将事件放入队列,通常在游戏循环的末尾统一处理。 (trigger 会立即触发)
dispatcher.enqueue(enemy_destroyed_event{enemy_to_destroy});
// 6. 更新分发器
// 在游戏循环的某个固定点(比如末尾),调用 update() 来处理队列中的所有事件。
// 这时,所有连接的监听器函数才会被调用。
dispatcher.update();
spdlog::info("=== 游戏循环结束 ===");
spdlog::info("最终分数: {}", registry.ctx().get<game_state>().score);
return 0;
}
1.registry.ctx()存放"非组件"全局状态
struct game_state {
int score = 0;
};
// 初始化上下文变量
registry.ctx().emplace<game_state>();
之后任何拿到 registry 的系统,都可以通过 registry.ctx().get<game_state>() 取到并修改它
2.事件类型
在 EnTT 的 dispatcher 里,事件类型通常是一个结构体。结构体里放“监听者处理事件所需的信息”:
struct enemy_destroyed_event {
entt::entity enemy_entity;
// 可以在此添加更多信息,比如敌人类型、掉落物品等
};
事件处理,满足其结构要和订阅该事件的回调函数签名相匹配。
3.监听器:可以是普通函数,也可以是类成员函数
class ScoreSystem {
entt::registry& registry; // 需要 registry 的引用,以便获取上下文
public:
ScoreSystem(entt::registry& reg) : registry(reg) {}
void on_enemy_destroyed(const enemy_destroyed_event& event) {
auto& state = registry.ctx().get<game_state>();
state.score += 10;
spdlog::info("杀死敌人 {},分数增加!当前分数: {}", entt::to_integral(event.enemy_entity), state.score);
/* entt::entity 的底层是 entt::id_type,即 uint32_t,但不可直接当成整数用(保证类型安全),
可以用 entt::to_integral 显式转换为 uint32_t */
}
};
void dummy_listener(const enemy_destroyed_event& event) {
spdlog::info("DummyListener 收到事件:敌人 {} 被摧毁!", entt::to_integral(event.enemy_entity));
}
4.创建 dispatcher 并连接监听器
dispatcher.sink<Event>() 会返回该事件类型对应的 sink,通过它连接监听器:
复制entt::dispatcher dispatcher{};
ScoreSystem score_system(registry);
dispatcher.sink<enemy_destroyed_event>()
.connect<&ScoreSystem::on_enemy_destroyed>(score_system);
dispatcher.sink<enemy_destroyed_event>()
.connect<&dummy_listener>();
5.发布事件:enqueue 入队,update 统一处理
演示流程是:
- 创建一个敌人实体
- “战斗发生”后摧毁敌人
- 发布事件
update()触发监听器
entt::entity enemy_to_destroy = registry.create();
registry.emplace<tag>(enemy_to_destroy, "enemy"_hs, "enemy");
spdlog::info("初始分数: {}", registry.ctx().get<game_state>().score);
// ... 战斗发生 ...
registry.destroy(enemy_to_destroy);
dispatcher.enqueue(enemy_destroyed_event{enemy_to_destroy});
dispatcher.update();
这里有两个常用选择:
enqueue + update:事件先入队,等到你选定的“主循环某一阶段”再统一处理(更适合游戏循环)trigger:立即触发(即时响应,简单但更容易造成“中途触发导致状态不一致”)
优先采用入队,后统一处理的方式。

浙公网安备 33010602011771号