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>()查看是否有相应事件的监听器。

实践

详见我