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 统一处理

演示流程是:

  1. 创建一个敌人实体
  2. “战斗发生”后摧毁敌人
  3. 发布事件
  4. 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:立即触发(即时响应,简单但更容易造成“中途触发导致状态不一致”)

优先采用入队,后统一处理的方式。

posted @ 2026-03-18 19:25  wenyiGamecpp  阅读(266)  评论(0)    收藏  举报