zhouqijie

事件和消息泵

Cre:本章的”事件”更贴近一般意义的”消息”?

Jason:游戏本质是事件驱动的。游戏通常需要一些方法做两件事,即事件发生时通知关注该事件的对象,以及安排那些对象回应所关注的事件(事件处理(event handling))。

Jason:事件有时候被称为消息(message)。把事件通知对象等于向对象发送消息。

Jason:命令模式中的命令也可以看作事件对象。

Cre:关注事件的对象可以称为Listener或者Subscriber。发送事件的对象可以成为Sender或者Publisher。



1.静态函数类型绑定

通知游戏对象一个事件发生,最简单的方法是调用该对象的成员函数。例如点击事件Click发生时,调用关注该事件的对象的OnClick()虚函数。

Jason:这称为静态函数类型后期绑定。后期绑定是指编译器在编译期不知道虚函数将运行哪个函数实现,只有运行时得知目标类型才能运行适当的实现。虚函数是静态函数类型,因为给定一个对象类型,编译器就确定了要调用哪个实现。

Jason:这种静态绑定方式弹性很低,需要先声明所有可能出现的事件的虚函数。我们需要的是动态函数类型的后期绑定。

Jason:有些语言原生支持这种功能,例如C#的delegate,其他语言中需要自行实现这种绑定。有很多方法解决此问题,但其中部分方案归结到数据驱动方法。换言之就是我们把函数调用封装成对象,并把对象传递至运行时的对象。



2.事件封装成对象

事件包含事件的类型以及事件的参数。可以把这两部分封装成对象。

优点:

  1. 通用的事件处理函数:仅需要单个虚函数处理所有事件类型,例如OnEvent(Event & event)
  2. 持久:事件对象含有持久性,可以存储于队列,也可以复制及广播至多个订阅者。
  3. 转发:一个事件可以被转发至其他对象,而不需要知道事件的内容。



3.事件类型

事件类型的定义可以由多种方式实现。

用枚举定义:

分辨不同事件类型,C/C++最简单的方法是使用一个全局的枚举,把每个事件映射到一个整数。

  1. 硬编码。新增事件类型不方便,不能实现数据驱动。
  2. 枚举次序。枚举仅仅是索引,是次序相关的。

用字符串定义:

使用字符串定义事件类型是一种弹性很高的方法。

  1. 极高的弹性。
  2. 极大地支持数据驱动。
  1. 较大机会产生事件名称冲突。还有可能由于拼写错误造成错误。
  2. 字符串消耗的内存较多。
  3. 比较字符串相对比较慢。
  1. 使用字符串散列标识符,可以提升性能,减少内存占用。
  2. 使用一个全局的名称管理器,在新增事件类型时判断名称冲突。



4.事件参数

事件的参数和函数的参数类似。事件参数可以由多种方式实现。

在Event类的派生类中定义参数:

class ExplosionEvent: public Event
{
    float damage;
    float radius;
}

参数存储为Variant集合:

Variant类示例:

class Variant
{
    enum Type
    {
        INTEGER,
        FLOAT,
        BOOLEAN,
        //...
    }
    Type type;

    union
    {
        int asInt;
        float asFloat;
        bool asBoolean;
        //...
    }
}

参数存储为键值对:

使用键值对实现事件参数,可以避免因参数次序而产生的各种问题。

事件参数取出:

注意类型安全问题。



5.事件处理器(EventHandler)

对象接收到事件后做出回应,称为事件处理(event handling)

事件处理器通常是一个原生函数或者脚本函数:

virtual void OnEvent(Event & event)
{
    switch(event.GetType())
    {
        //...
    }
}

Jason:也可以实现一系列处理器函数,每个函数负责一种事件,例如OnThis()OnThat()等,但是这种增殖的事件处理器函数可能会很麻烦。

Jason:MFC是一个WindowsGUI工具集,它含有一个著名的消息映射,可以在运行时把Windows消息绑定至任何函数。避免了在单个根类中声明所有可能的Windows消息处理器,同时也能避免一个巨大的switch语句。



6.职责链

Jason:游戏对象之间存在依赖关系,对象之间的关系组成一个或者多个关系图(relationship graph)。

Jason:在对象关系图中转发事件,是面向对象、事件驱动编程中常见的设计模式,有时称为职责链(chain of responsibility)。事件首先传递至链的首个对象,然后该对象返回一个布尔或者枚举值,以表示该对象是否识别并处理该事件,如果事件不能处理就再转发至链中下一个接收者。



7.事件订阅

Jason:游戏中大部分对象都不需要接受所有可能的事件,大部分对象都只会关注很少的事件,那么广播就是很低效的事情因为需要迭代所有对象。

Jason:为了提高效率,我们可以容许对象订阅它们所关注的事件。具体实现可以每个事件维护一个对象链表。或者每个对象维护一个位数组,每一位代表该对象是否关注某事件。



8.事件排队和即时传递

Jason:有些引擎容许时间排队,把事件排队有一些好处,但会增加事件系统的复杂度并产生一些独有的问题。

事件排队的好处:

  1. 控制事件处理时机。游戏循环以特定次序更新子系统和游戏对象,有些事件类型对于它应该在游戏循环的哪个时机处理非常敏感。
  2. 往未来投递事件。每个事件进入队列前可以指定一个所需送达时间,队列中的事件按送达时间排序。
  3. 事件的优先次序。为事件设置优先次序(priority),当两个事件送达时间相同,就优先处理优先级高的。

事件排队的问题:

  1. 增加了事件系统复杂度。相对即时处理的事件系统更加复杂。
  2. 涉及深度复制。需要复制整个事件至队列。
  3. 动态内存分配。把事件对象深度复制意味着需要动态内存分配。可以用池分配器。
  4. 调试困难。调用堆栈不能往上追踪事件从何而来。

即时传递事件的问题:

很深的调用堆栈。极端情况下会用尽程序的堆栈空间。



9.数据驱动事件/消息传递系统

Jason:把事件系统变成数据驱动的,就能把权力交给游戏设计师。

可配置事件系统:

Jason:例如在世界编辑器中,我们可以选取一个对象,然后从弹出的事件列表中选择该对象能接收的事件。然后对于每个接收事件,在下拉框中选择一个预定义的反应方式。

Jason:可配置事件系统能做的事较少,有很大限制。

脚本语言:

Jason:另一个做法是提供简单的脚本语言。设计师可以编写代码定义某类对象应该如何回应事件。

Jason:问题是脚本对于一些游戏设计师来说有门槛。

数据路径通信系统和视觉化编程:

Jason:一些引擎会使用折中方案,例如使用流程图风格的编程语言。优点是容易使用,缺点是维护成本高。

Jason:把函数调用形式的事件系统转换为数据驱动,难点之一是不同事件类型造成的兼容问题。解决方法之一是去掉事件类型,仅考虑游戏对象传递数据流至其他对象,这种系统中每个对象有一组输入输出端口,输出端口可以用数据流连接至其他对象的输入端口。程序员制定每种游戏对象有什么端口,设计师再使用图形用户界面自由地连接这些端口来建立游戏所需行为。

(END)