事件系统,是一个软件的核心组成部分。从小处讲它是应用程序内部各模块交互的设计模式,从大处讲,它是软件架构的组成模块。在现代软件开发中,操作系统通常通过一些预定义的事件,告知应用程序发生的一些事情如用户输入,内存不足等。
然而,通常我们并不直接使用系统事件,例如一个界面可能不同区域的元素对触摸事件的理解都不一样,在某些情况下需要优先处理某些逻辑,这就需要对系统事件再包装,以应对界面复杂的元素和逻辑。另一方面,我们需要一个事件系统用来在应用程序内部分发消息,例如当敌人进入攻击范围时通知英雄射击,当敌人血量低于0时播放死亡动画等等。这些都需要游戏引擎提供一个灵活的事件系统,它既能管理分发系统事件,还能借助其简单管理自定义事件。
Cocos2d-x 3.0将所有的事件统一集中到EventDispatcher中处理,它不光改进了触摸等系统事件的管理和使用方式,还使得我们可以借助其处理程序自定义的事件。本章将学习相关的内容。
5.1 事件类型
要处理一个事件,首先得定义一个事件类型。事件系统总是按类型而不是实例来处理事件的订阅和分发,这样使得同一个事件可以有多个订阅者。Event是所有事件的基类,它用一个字符串来表示该事件的类型。我们不应该直接使用Event,而应该从它继承实现自定义事件。事件类型通常不是一个变量,以保证相同类型的事件实例拥有相同的类型,但EventCustom除外,它可以在初始化的时候指定不同的类型,这是为了简化编写事件类型。以下是Event类的定义:
class Event
{
protected:
Event(const std::string& type);
public:
virtual ~Event();
inline const std::string& getType() const { return _type; };
inline void stopPropagation() { _isStopped = true; };
inline bool isStopped() const { return _isStopped; };
inline Node* getCurrentTarget() { return _currentTarget; };
protected:
inline void setCurrentTarget(Node* target) { _currentTarget = target; };std::string _type;
bool _isStopped;
Node* _currentTarget;
friend class EventDispatcher;
};
实际上Event的成员应该仅包含一个表示类型的字符串,然而在Cocos2d-x中有些事件的分发如触摸可能和Node的层级相关,所以它还包含一个获取关联元素的方法:getCurrentTarget();另外它还是EventDispatcher的友元,这是为了方便处理触摸等事件分发,这些都会在本章后面分析。
一个Event实例实际上是事件传递过程中的数据,它由事件触发者构造,并传递给事件分发器,事件分发器根据其类型分别通知所有订阅该类型事件的订阅者,并将其作为参数传递给订阅者。因为事件是一种异步通信机制,它通常没有回调,甚至一个类型的事件可能不包含任何订阅者,这就需要事件的触发者向接受者传递相关的上下文数据,接受者才能正确处理,例如EventTouch对象中会包含触摸点的信息,以便于订阅者处理逻辑。
Cocos2d-x引擎自带的事件类型包括:EventTouch,EventKeyboard,EventAcceleration,以及便于开发者自定义事件的EventCustom。
5.2 事件的订阅者
订阅者负责处理事件,它的成员包含一个订阅事件的类型(这个类型应该和对应的Event的类型一致),以及一个回调方法用来处理事件。这两个成员都应该只被事件分发器(EventDispatcher)使用,所以它们被定义为受保护的成员,同时EventListener被定义为EventDispatcher的友元:
class EventListener : public Object
{
protected:
EventListener();
bool init(const std::string& t, std::function<void(Event*)>callback);
public:
virtual ~EventListener();
virtual bool checkAvaiable() = 0;
virtual EventListener* clone() = 0;
protected:
std::function<void(Event*)> _onEvent;
std::string _type;
bool _isRegistered;friend class EventDispatcher;
friend class Node;
};
在Cocos2d-x以前的版本中,订阅者以继承的方式定义,订阅者和处理逻辑的对象是同一个实体,例如CCLayer实现了CCTouchDelegate。而在3.0中EventListener被定义为一个变量,其好处是可以将处理方法定义为lambda表达式,这是3.0支持C++11的一个重要方面,它改变了使用事件的编程习惯,但是带来了lambda表达式的好处,编程更加灵活,你甚至可以在一个EventListener的处理程序中再定义一个EventListener变量。
与事件类型相对应,Cocos2d-x中自带的订阅者包括:EventListenerTouch,EventListenerKeyboard,EventListenerAcceleration以及EventListenerCustom。
5.3 事件的工作流程
在定义了事件和订阅者之后,应用程序只需要向事件分发器注册一个订阅者实例,即可在事件发生的时候得到通知。在Cocosd-x中负责事件的订阅,注销,分发的是EventDispatcher,它是一个单例,应用程序可通过EventDispatcher::getInstance()方法获取其实例。
下面通过一个示例来演示事件的工作方式,在这个示例中当CollisionSystem检测到两个Node之间发生碰撞时,将触发碰撞事件,而HitSystem是碰撞事件的其中一个订阅者,它会响应碰撞事件并修改敌人的生命值:
class CollisionEvent:public Event
{
public:
static const char* COLLISION_EVENT_TYPE;
CollisionEvent(Entity* l,Entity* r);
Entity* getLeft(){return _left;}
Entity* getRight(){return _right;}
private:
Entity* _left;
Entity* _right;
};
上述代码首先添加一个碰撞事件类CollisionEvent,它继承自Event,并用一个常量COLLISION_EVENT_TYPE定义其类型。
CollisionEvent作为事件传递的数据,应该向订阅者传递相关的上下文,这里需要传递的是发生碰撞的两个Entity实例,关于Entity Component System会在本书后面的章节讲述。
class CollisionListener : public EventListener
{
public:
static CollisionListener* create(std::function<void(CollisionEvent*)> callback);
virtual bool checkAvaiable() override;
virtual CollisionListener* clone() override;
protected:
CollisionListener();
bool init(std::function<void(CollisionEvent*)> callback);
std::function<void(CollisionEvent*)> _onCollisionEvent;
};
接下来,我们需要定义订阅者,在CollisionListener的init()方法中,声明了它订阅事件的类型,通过查看CollisionListener的实现部分代码,可以看到它引用的是上面CollisionEvent定义的COLLISION_EVENT_TYPE。
void HitSystem::configure()
{
auto listener=CollisionListener::create(
[this](CollisionEvent* event){
this->hit(event);
});
EventDispatcher::getInstance()->addEventListenerWithFixedPriority(listener, 1);
}
然后,我们需要向EventDispatcher注册订阅者。HitSystem会响应碰撞事件,所以我们在HitSystem初始化的时候向EventDispatcher注册,并传递一个lambda表达式作为处理程序。
void CollisionSystem::update(float dt)
{
if (collide()) {
CollisionEvent* event=new CollisionEvent(entity,collisionEntity);
EventDispatcher::getInstance()->dispatchEvent(event);
}
}
最后,是触发事件的程序。由于CollisionSystem负责碰撞检测,所以它会在检测到两个Node之间发生碰撞时,通知EventDispatcher分发此碰撞事件,并将发生碰撞的两个Entity作为数据保存在Event参数中。EventDispatcher在接受到事件通知的时候,首先根据Event参数的类型,查找与此类型相符的订阅者,在本示例程序中CollisionListener的类型与CollisionEvent的类型一致,所以将会执行CollisionListener中的回调方法。
所以,通过EventDispatcher我们就能自定义各种事件,在应用程序的各个模块之间灵活通信,大大简化了事件的处理,同时降低了模块间的耦合。
当然一般情况下并不需要像这样定义每一个事件,可以直接使用EventCustom,它的构造函数接受一个类型参数,使得同样的EventCustom实例可以分发不同类型的事件。同理,EventListenerCustom也接受一个类型参数,使得其可以处理不同的事件类型。
5.4 深入分析EventDispatcher
通过前面的学习,我们应该初步学会了在Cocos2d-x中怎样使用一般的事件。然而更灵活熟练地使用事件,还需要深入学习更多的知识,在进一步分析EventDispatcher的机制之前,我们来总结一下一般在游戏中使用事件还有哪些特殊的需求:
- 设置订阅者的优先级,一个类型的事件可能拥有多个订阅者,因此有必要设置处理顺序,例如当碰撞事件完成之后,其中一个订阅者负责处理伤害计算,而另一个订阅者可能做一些UI的操作,例如播放声音或者粒子效果。前者的优先级肯定需要更高,因为后者的处理可能需要依赖于生命值的计算。
- 修改订阅者的优先级。
- 停止事件的继续分发,使后续的订阅者不用再处理该事件。
- 根据屏幕上元素的层级,而不是手动设定的优先级来处理事件分发,这在触摸事件的分发中尤其重要。
带着这些目标,我们来分析EventDispatcher是怎样实现它们,以及我们在应用程序中应该怎样使用它们。
首先,EventDispatcher提供了两种注册订阅者的方法:
void addEventListenerWithSceneGraphPriority(EventListener* listener, Node* node);
void addEventListenerWithFixedPriority(EventListener* listener, int fixedPriority);
第一种提供一个相关联的Node,这样事件的处理将会依据该Node的绘制顺序来决定分发的优先级。第二种则是手动设定一个优先级,这样EventDispatcher将根据该优先级直接决定分发顺序。同时,通过第二种方法注册的订阅者还可以通过调用setPriority()方法修改优先级。
其次,EventDispatcher是怎样做到根据元素的绘制顺序来计算订阅者的优先级的呢?在Cocos2d-x引擎内部,每个EventListener都被封装为一个EventListenerItem的结构体:
struct EventListenerItem
{
int fixedPriority;
Node* node;
EventListener* listener;
~EventListenerItem();
};
如果订阅者与某个Node相关联,则node成员将被赋值,同时fixedPriority被设置为0。并且该listener变量会被添加到该Node的关联订阅者列表中。这样的设置会影响订阅者的排序,找到sortAllEventListenerItemsForType()方法,可以总结排序规则如下:
- 分发fixedPriority小于0的订阅者,fixedPriority越小则优先分发。
- 分发所有fixedPriority值为0的订阅者,并且没有与Node相关联的。
- 分发所有与Node相关联的订阅者,其关联Node的eventPriority越高(越处于屏幕最上层)则优先级越高。
- 分发所有fixedPriority大于0的订阅者,同样fixedPriority越小则优先分发。
这里有两个地方需要注意:首先,两种订阅方式的优先级进行比较并没有太大意义,通常我们应该避免同一个事件类型的订阅者混用两种注册方式;其次,Node的eventPriority变量不需要我们操心,只要设置为根据Node计算优先级,则引擎会保证其与绘制顺序一致。
如果读者对此感兴趣,可以看到Node类有一个静态变量_globalEventPriorityIndex,在每一帧开始的时候,Director会将其重置为0,然后在Node的visit()方法中,每此调用draw()方法之后会调用updateEventPriorityIndex()方法,该方法如下:
inline void updateEventPriorityIndex() {
_oldEventPriority = _eventPriority;
_eventPriority = ++_globalEventPriorityIndex;
if (_oldEventPriority != _eventPriority)
{
setDirtyForAllEventListeners();
}
};
由此计算,第一个绘制的Node其_eventPriority变量值为1,第二个Node其_eventPriority值为2…,一旦有新的Node被添加或者旧的Node被移除,这些值会重新计算,但始终能保证其和绘制顺序一致。而一旦某个Node的绘制顺序号发生了改变,则重新计算该Node关联的所有订阅者在EventDispatcher中的排列顺序,从而保证其处理顺序始终和绘制顺序一致。
每个Event在被处理的时候,如果订阅者是通过与Node关联注册的,EventDispatcher还会将当前订阅者关联的Node临时保存在Event中,这样我们还可以在事件处理程序中获取该Node,这就是前面看到的getCurrentTarget()方法。这在某些时候比较有用,因为EventListener可以和任何Node实例关联,事件处理代码所在的对象可能并没有保留其引用。
最后是关于事件的禁止分发,由于一个事件会被多个订阅者处理,因此同一个Event实例会被传递给多个处理程序,这样每个处理程序就可以修改这个共同的Event实例。如果其中一个处理程序调用Event的stopPropagation()方法,将其_isStopped设置为true,EventDispatcher就会停止对该事件的分发,后续的订阅者将接受不到事件通知。
此外,EventDispatcher还包含一些例如保证安全的代码,以及在需要的时候对订阅者从新排序等方法,dispatchEvent()方法还包含一个forceSortListeners的默认为false的参数它可以在分发事件之前对订阅者重新排序,但似乎用处不大,因为一般影响优先级的因素如添加,移除节点,直接修改fixedPriority等都会导致订阅者重新排序,也许可能还存在一些特殊情况。
5.5 Cocos2d-x中的系统事件
对于系统事件,有的Cocos2d-x采取了特殊的处理,而有的我们可以从中学习到使用事件的一些高级技巧,当然在实际使用过程中最重要的还是知道怎么熟练使用它们,以及明白它们在什么时候被触发。
5.5.1 EventTouch
触摸是Coco2d-x中最复杂的事件,为了简化最常用的单点操作,它要区分多点和单点触摸,对于单点的情况它还要在整个触摸操作过程中记录一些状态。这些复杂的情况使得上面学习的分发机制不能直接处理,所以Cocos2d-x对于触摸事件做了特殊处理,我们可以在EventDispatcher中找到一个私有的dispatchTouchEvent()方法,它专门用来处理触摸事件。
EventTouch事件分为两种,一种是PC或者Mac上的鼠标点击,另一种是移动设备上面的触摸,其中前者只有一个触摸点。但是Cocos2d-x并不处理右键点击,而是直接交给父窗口或者系统处理。这些事件都由比较底层的EGLViewProtocol的实现者从系统捕获,然后封装成Cocos2d-x中的信息格式传递给EventDispatcher。值得注意的是,在这个封装的过程中EGLViewProtocol会根据程序设置的分辨率方案对触摸点的位置进行调整。同时在ios平台默认情况下Cocos2d-x并不开启多点支持,读者需要在AppController.cpp中设置:
[__glView setMultipleTouchEnabled:YES];
首先找到EventTouch的定义,它仅包含两个成员,_eventCode用来表面当前触摸事件的状态,它在EventTouch:EventCode中定义;_touches则保存者当前触摸状态下的触摸点信息:
class EventTouch : public Event
{
public:
enum class EventCode{
BEGAN,
MOVED,
ENDED,
CANCELLED
};
EventCode getEventCode() { return _eventCode; };
std::vector<Touch*> getTouches() { return _touches; };
#if TOUCH_PERF_DEBUG
void setEventCode(EventCode eventCode) { _eventCode = eventCode; };
void setTouches(const std::vector<Touch*>& touches) { _touches = touches; };
#endif
};
从定义可以看出,我们还可以直接设置触摸信息模拟系统事件用于测试。Touch封装了一个触摸点的信息,应用程序可以从这里获取很多有用的信息:
class CC_DLL Touch : public Object
{
public:
/** 触摸点在OpenGL坐标系中的位置 */
Point getLocation() const;
/** 触摸点在OpenGL坐标系中的上一个位置 */
Point getPreviousLocation() const;
/** 触摸点在OpenGL坐标系的起点位置 */
Point getStartLocation() const;
/** 在OpenGL坐标系中当前位置与上一个位置的差 */
Point getDelta() const;
/** 触摸点在屏幕坐标系中的位置 */
Point getLocationInView() const;
/** 触摸点在屏幕坐标系中的上一个位置 */
Point getPreviousLocationInView() const;
/** 触摸点在屏幕坐标系的起点位置 */
Point getStartLocationInView() const;
int getID() const{ return _id; }
};
有了这些信息,我们就可以在程序中进行精准的触摸判断,例如判定是否点中某个区域,以及是否在触摸事件结束的时候离开了某个区域,还可以在cancelled事件发生时根据触摸点发生的位移还原一些元素的位置等等,后面将分析一些实际例子。
在dispatchTouchEvent()方法中我们再也不用为订阅者的优先级操心了,因为在这方面,触摸事件和其他事件的处理是一致的。这里需要特殊处理的是触摸事件要根据不同的触摸状态调用订阅者的不同响应方法,和其他订阅者只有一个处理方法不同,触摸事件的订阅者需要提供每个触摸状态下的方法:
class EventListenerTouch : public EventListener
{
public:
std::function<bool(Touch*, Event*)> onTouchBegan;
std::function<void(Touch*, Event*)> onTouchMoved;
std::function<void(Touch*, Event*)> onTouchEnded;
std::function<void(Touch*, Event*)> onTouchCancelled;
std::function<void(const std::vector<Touch*>&, Event*)> onTouchesBegan;
std::function<void(const std::vector<Touch*>&, Event*)> onTouchesMoved;
std::function<void(const std::vector<Touch*>&, Event*)> onTouchesEnded;
std::function<void(const std::vector<Touch*>&, Event*)> onTouchesCancelled;
void setSwallowTouches(bool needSwallow);
private:
bool _needSwallow;
Touch::DispatchMode _dispatchMode;
};
首先通过Touch::DispatchMode将订阅者分为单点和多点触摸的订阅者,而对于单点的情况,还可以通过设置setSwallowTouches来决定是否需要禁止后续的订阅者继续处理某个触摸点。EventListener与Node的关联只是导致了事件的分发与绘制的顺序相反,而对于触摸事件来说,一般情况下它可能只需要被处理一次,这个时候EventDispatcher就要根据_needSwallow属性来决定是否需要继续分发。
根据这些触摸事件处理的一些需求,dispatchTouchEvent()方法的逻辑就比较清晰了:
首先,找到所有单点触摸的订阅者,然后分别用每一个触摸点分别询问onTouchBegan是否需要处理,如果需要则将该触摸点保存到该订阅者中以供后续的move,end,cencelled等方法处理。同时,如果该订阅者的_needSwallow设置为true,则该触摸点将不再被任何订阅者处理。
其次,对上述执行后剩下的所有触摸点,找到所有多点触摸的订阅者,分别调用各个多点触摸的方法。
值得注意的是,如果同时有大于1个的触摸点,则单点触摸的订阅者将会执行多次,所以如果玩家同时将两个手指点击在一个按钮上,则按钮将被触发两次点击事件,EventDispatcher并不保证单点事件的订阅者只被点击一次,程序逻辑需要实现状态记录,我们可以参看后面的Menu分析对此的处理,它用Menu::State来记录按钮当前状态。
最后,我们来分析两个引擎中的使用触摸的例子,让读者了解触摸的常用处理方法。
5.5.1.1 Layer对系统事件的支持
Layer经常被用来根据UI的层级组织元素,正如它的名字一样。实际上它的主要目的是方便我们使用系统事件,触摸,按键,重力加速等事件,它通过提供构造这些事件的订阅者,并向EventDispatcher注册和注销这些订阅者来简化我们使用系统事件。此外,在3.0中它还新加入了对物理引擎集成的支持,在后面的章节我们会学习。
Layer对所有事件的均采取与自身相关联的方式向EventDispatcher注册订阅者,即是说被处理的优先级取决于自身的UI层级。要使用某个系统事件基本上只需要调用setXXXEnabled()方法,然后重写相关事件的处理方法即可。当然默认所有的系统事件均没有开启,并且对于触摸事件默认设置为多点触摸。
一下我们以使用触摸事件为例,在Layer中使用触摸事件:
bool HelloWorld::init()
{
if ( !CCLayer::init()){
return false;
}
setTouchMode(Touch::DispatchMode::ONE_BY_ONE);
setTouchEnabled(true);
return true;
}
class HelloWorld : public cocos2d::Layer
{
public:
virtual bool onTouchBegan(Touch *touch, Event *event);
virtual void onTouchMoved(Touch *touch, Event *event);
virtual void onTouchEnded(Touch *touch, Event *event);
virtual void onTouchCancelled(Touch *touch, Event *event);
}
上述示例中,我们首先在Layer的init初始化后调用setTouchEnabled()方法声明需要处理单点触摸事件,然后重写了单点触摸需要实现的4个处理方法。
实际上,在开启每个系统事件处理之后,Layer向EventDispatcher注册订阅者,并将处理方法声明为虚方法以供子类重写。并在关闭事件,或者元素被移除的时候向EventDispatcher注销订阅者。这样大大简化了我们使用系统事件。
5.5.1.2 Menu中的触摸处理
触摸更常见的是被用在一些控件中,比如按钮点击,表格拖拽等。其开发中最重要的部分是点击判定,此外对于按钮还需要注意前面提到的状态判定,以避免多次点击。Cocos2d-x为我们提供了一个常用的GUI控件-Menu,用于显式一组按钮。通过分析Menu我们就可以完全掌握触摸相关的处理了。
Menu继承自LayerRGB,所有可以很方便的使用触摸事件。通过查看Menu的源代码可以知道Menu注册了单点触摸事件。当然Menu还实现了多个MenuItem的管理,我们这里关心的是怎么使用触摸点的位置信息。
bool Menu::onTouchBegan(Touch* touch, Event* event)
{
if (_state != Menu::State::WAITING || ! _visible || !_enabled){
return false;
}
for (Node *c = this->_parent; c != NULL; c = c->getParent()){
if (c->isVisible() == false){
return false;
}
}
_selectedItem = this->itemForTouch(touch);
if (_selectedItem){
_state = Menu::State::TRACKING_TOUCH;
_selectedItem->selected();
return true;
}
return false;
}
从这个示例我们可以看到三点有趣的信息:
www.sumdaboke.com
首先,Menu通过一个Menu::State变量,来防止同时多次点击,如果Menu开始处理一个触摸点,则会将_state设置为Menu::State::TRACKING_TOUCH。
其次,它在UI树上向上查找直到根节点,检查节点是否正在被绘制。这里是因为虽然可以通过设置visible来设置节点的可见性,但其子结点并不能直接知道自己是否被隐藏或者显式了,需要向上遍历至根节点才能做出判断。同时,EventDispatcher虽然可以根据节点UI层级来决定分发顺序,但它并不负责检查节点的可见性,因为这里元素仅用来计算分发顺序,而且并不是所有的事件都依据元素的层级来计算优先级。所以,这里在开发中经常会遇到的一个问题就是,某个节点通过父级被隐藏了,但是其仍然能够收到触摸事件。
最后,Menu通过itemForTouch()方法来做点击判定:
MenuItem* Menu::itemForTouch(Touch *touch)
{
Point touchLocation = touch->getLocation();
if (_children && _children->count() > 0)
{
Object* pObject = NULL;
CCARRAY_FOREACH_REVERSE(_children, pObject)
{
MenuItem* child = dynamic_cast<MenuItem*>(pObject);
if (child && child->isVisible() && child->isEnabled())
{
Point local = child->convertToNodeSpace(touchLocation);
Rect r = child->rect();
r.origin = Point::ZERO;
if (r.containsPoint(local)){
return child;
}
}
}
}
return NULL;
}
这里则告诉我们做点击判定的一般方法,首先我们通过getLocation()方法取出触摸点在OpenGL坐标系中的世界坐标,然后将其转化到节点的本地坐标系,最后根据节点的尺寸检测其是否落于该区域内。至此,我们就了解了关于触摸的所有知识。
5.5.2 EventKeyboard
www.sumdaboke.com
键盘输入事件比较简单,它捕捉一个按键动作,它的参数包括按下的键_keyCode,以及表示按键的两个状态_isPressed:
class EventKeyboard : public Event
{
EventKeyboard(KeyCode keyCode, bool isPressed)
: Event(EVENT_TYPE)
, _keyCode(keyCode)
, _isPressed(isPressed)
{};
private:
KeyCode _keyCode;
bool _isPressed;
friend class EventListenerKeyboard;
};
class EventListenerKeyboard : public EventListener
{
public:
std::function<void(EventKeyboard::KeyCode, Event* event)> onKeyPressed;
std::function<void(EventKeyboard::KeyCode, Event* event)> onKeyReleased;
};
有趣的是这里对两个处理方法的调用方式,一般事件只有一个处理方法,我们还记得EventListener定义的_onEvent变量,它被EventDispatcher直接调用,而这里EventListenerKeyboard做了特殊处理:
bool EventListenerKeyboard::init()
{
auto listener = [this](Event* event){
auto keyboardEvent = static_cast<EventKeyboard*>(event);
if (keyboardEvent->_isPressed){
if (onKeyPressed != nullptr)
onKeyPressed(keyboardEvent->_keyCode, event);
}
else {
if (onKeyReleased != nullptr)
onKeyReleased(keyboardEvent->_keyCode, event);
}
};
www.sumdaboke.com
if (EventListener::init(EventKeyboard::EVENT_TYPE, listener)){
return true;
}
return false;
}
我们看到,EventListenerKeyboard重新包装了listener,由此可见,我们程序中定义的订阅者实例并不一定是最终EventDispatcher中引用的实例,而这里更有趣的是订阅者中包含了订阅者。这就是事件分发使用方法指针,而不是继承实现某个Delegate的好处。
请转载的朋友们 加上www.sundaboke.com
cocos2dx3.x的所有事件总结