01 - 事件模块

想往游戏引擎中添加事件系统,刚好 EnTT 库里有相关内容,康康能不能应用上。

EnTT 事件模块

委托 Delegate

委托可以作为一个通用的没有内存开销的调用者,用于触发一般函数,Lambda 函数,实例提供的成员函数等。

创建一个空委托的代码如下:

entt::delegate<int(int)> delegate{};

需要提供让委托触发的函数签名。如果直接让空委托触发函数,可能会造成如崩溃等的未定义行为。因此有必要检查要使用的委托是否为空:

   
if(delegate) { // ... }

为了初始化一个委托,需要使用它的.connect() 成员函数:

   
int f(int i) { return i; } // 向委托绑定一个一般函数 delegate.connect<&f>(); struct my_struct { int f(const int &i) const { return i; } }; // 向委托绑定一个成员函数 my_struct instance; delegate.connect<&my_struct::f>(instance);

如果想要同时创建并初始化一个委托,需要使用 entt::connect_arg

entt::delegate<int(int)> func{entt::connect_arg<&f>};

接下来看看如何调用委托绑定的函数。对于一般函数,像平常调用函数那样即可。对于成员函数,如果它与类实例有关联,就需要显式提供一个实例作为第一参数:

   
entt::delegate<void(my_struct &, int)> delegate; delegate.connect<&my_struct::f>(); my_struct instance; delegate(instance, 42);

需要注意的是,没有形如.disconnect() 的成员函数,因此不能注销单个绑定的函数。但可以通过.reset() 来清除委托的所有绑定。

此外,对于形如 void(T&, args...) 的一般函数,第一个参数 T& 是该函数的 “载荷”,函数在每次被触发时都会引用该参数:

   
void g(const char &c, int i) { /* ... */ } const char c = 'c'; delegate.connect<&g>(c); delegate(42);

其中,函数 g 随着引用 c 和参数 42 被触发。但委托的函数签名实际上是 void(int)。并且,在委托触发其绑定的函数时会忽略掉过多的参数。

Lambda 支持

entt::delegate 只支持没有捕获的 Lambda 函数:

   
my_struct instance; // use the constructor ... entt::delegate delegate{+[](const void *ptr, int value) { return static_cast<const my_struct *>(ptr)->f(value); }, &instance}; // ... or the connect member function delegate.connect([](const void *ptr, int value) { return static_cast<const my_struct *>(ptr)->f(value); }, &instance);

委托接收的函数签名仍是 int(int),这里的第一参数 const void* 只是用于传递用户定义的数据结构。

原始数据访问

尽管不推荐该操作,但 entt::delegate 支持对委托存储可调用对象的直接访问操作。这使得对特定实例强制调用一些委托函数成为可能:

my_struct other;
delegate.target(&other, 42);

在不知道可调用对象是什么类别(普通函数,成员函数,Lambda)时,谨慎使用该操作。 常规操作则是利用此特性来辨别特定的委托,通过 traits

信号 Signals

信号一般和类引用,函数指针,成员指针配合使用。监听器可以是任何类型,并且用户可以对一个信号在不同生命周期进行连接和断连操作。信号在内部使用委托,因此它可以提供和委托类似的功能。

一个信号句柄(Signal handler)可被用作一个私有数据成员,并且不会向类的客户端暴露任何功能。信号句柄的 API 很直白。如果该信号句柄支持收集功能,那么就能在接收事件的时候获取相关参数,并在之后使用;如果该信号句柄不支持收集功能,那么可用于周期发射事件。

要想新建一个信号句柄实例,需要为它指定接收的函数类型:

entt::sigh<void(int, char)> signal;

常用到的成员函数如下:

  • .size():获取当前信号句柄内监听器的个数;
  • .empty():当前信号句柄内监听器是否为 0;
  • .swap():交换两个信号句柄;

信号的相关操作(如连接和断连)需要 sink 类帮忙:

   
void foo(int, char) { /* ... */ } struct listener { void bar(const int &, char) { /* ... */ } }; // ... entt::sink sink{signal}; listener instance; sink.connect<&foo>(); sink.connect<&listener::bar>(instance); // ... // disconnects a free function sink.disconnect<&foo>(); // disconnect a member function of an instance sink.disconnect<&listener::bar>(instance); // disconnect all member functions of an instance, if any sink.disconnect(&instance); // discards all listeners at once sink.disconnect();

如上所述,监听器的函数签名并不和信号的严格匹配。只需要监听器可以被所给参数正确触发,并返回一个可被转化为目标返回类型的值即可。

.connect() 成员函数会默认返回一个 connection 对象,该对象可被用作断连操作的替代方法,需要使用它的.release() 成员函数。此外,也能通过 connection 对象创建一个 scoped_connection 对象,当脱离它的作用域后,会自动断连。

发布与收集

当信号被附加监听器(或根本没有)时,就可以通过它的.publish() 成员函数来发布事件和数据:

signal.publish(42, 'c');

要想收集数据,需要使用.collect() 成员函数:

   
int f() { return 0; } int g() { return 1; } // ... entt::sigh<int()> signal; entt::sink sink{signal}; sink.connect<&f>(); sink.connect<&g>(); std::vector<int> vec{}; signal.collect([&vec](int value) { vec.push_back(value); }); assert(vec[0] == 0); assert(vec[1] == 1);

收集器必须支持 operator(),并且要接收一个能被监听器返回值转换的类型参数。此外,还能通过返回一个布尔值标识是否该停止收集数据,这可以避免在非必要条件下调用所有监听器。

收集器不仅可以是 Lambda,还能是仿函数:

   
struct my_collector { std::vector<int> vec{}; bool operator()(int v) { vec.push_back(v); return true; } }; // ... my_collector collector; signal.collect(std::ref(collector));

事件分发器

事件分发器允许用户触发立即事件,或将其放到队列中延迟一起发布。创建事件分发器的代码如下:

// define a general purpose dispatcher
entt::dispatcher dispatcher{};

与事件分发器注册的监听器,它的类型可以提供一或多个成员函数,接收 Event& 类型的参数,不管返回值。

   
struct an_event { int value; }; struct another_event {}; struct listener { void receive(const an_event &) { /* ... */ } void method(const another_event &) { /* ... */ } }; // ... listener listener; dispatcher.sink<an_event>().connect<&listener::receive>(listener); dispatcher.sink<another_event>().connect<&listener::method>(listener);

可以通过.disconnect() 来解绑事件对应的监听器(一个或所有):

dispatcher.sink<an_event>().disconnect<&listener::receive>(listener);
dispatcher.sink<another_event>().disconnect(&listener);

可以通过.trigger() 来向所有注册的监听器发送立即事件:

dispatcher.trigger(an_event{42});
dispatcher.trigger<another_event>();

此时相关监听器被马上调用,不保证执行顺序。

可以通过.enqueue() 让延迟发送的事件进入消息队列:

dispatcher.enqueue<an_event>(42);
dispatcher.enqueue(another_event{});

在合适的事件通过.update() 发送队列中的所有事件:

   
// emits all the events of the given type at once dispatcher.update<an_event>(); // emits all the events queued so far at once dispatcher.update();

命名队列

默认是按事件类型进行队列区分,也可以通过不同的名字来创建队列(可以造成同类型事件但多个队列):

   
dispatcher.sink<an_event>("custom"_hs).connect<&listener::receive>(listener); dispatcher.enqueue_hint<an_event>("custom"_hs, 42);

事件发射器

这个通用事件发射器被用于异步环境下。一般来说,当派生类封装异步操作时,它是一个方便的工具,但并不局限于这种用途。

创建的事件发射器类型必须基于 enitter 类:

   
struct my_emitter: emitter<my_emitter> { // ... }

创建一个新的发射器实例不需要参数:

my_emitter emitter{};

监听器是可移动且可调用的对象(一般函数,Lambda 函数,仿函数,std::function),并且它们的签名为:

void(Type &, my_emitter &)

其中 Type 就是它们要接收的事件类型。

可通过.on() 将监听器绑定到发射器上:

   
emitter.on<my_event>([](const my_event &event, my_emitter &emitter) { // ... });

可以通过.erase() 移除特定事件的监听器,也能通过.clear() 移除所有监听器:

   
// resets the listener for my_event emitter.erase<my_event>(); // resets all listeners emitter.clear()

可通过 publish() 发送事件:

   
struct my_event { int i; }; // ... emitter.publish(my_event{42});

可通过.empty() 查看注册的监听器是否为空;可通过.contains<T>() 查看是否有相应事件的监听器。

实践

详见我