一、触摸事件
为了处理屏幕触摸事件,Cocos2d-x 提供了非常方便、灵活的支持。在深入研究 Cocos2d-x 的触摸事件分发机制之前,我们利用 CCLayer 已经封装好的触摸接口来实现对简单的触摸事件的响应。
(1)、使用 CCLayer 响应触摸事件
为了处理屏幕输入事件,最简单的解决方案是利用 CCLayer 开启内建的触摸输入支持。在介绍 CCLayer 的时候提到过,它的一个十分重要的作用就是接收输入事件,因此层封装了触摸输入的处理接口。一般情况下,我们可以通过 TouchEnable属性来开启或关闭接收触摸输入。
virtual void ccTouchesBegan(CCSet *pTouches, CCEvent *pEvent); virtual void ccTouchesMoved(CCSet *pTouches, CCEvent *pEvent); virtual void ccTouchesEnded(CCSet *pTouches, CCEvent *pEvent); virtual void ccTouchesCancelled(CCSet *pTouches, CCEvent *pEvent);
以上 4 个方法都声明为虚函数,这意味着我们通过重载的方式来处理用户输入事件。实际上,这 4 个函数来自于CCStandardTouchDelegate 接口,是触摸事件的回调函数,分别对应触摸的开始、移动、结束和取消操作。传入参数 pTouches是一个 CCTouch 对象(表示一个触摸点)的集合,其中包含当前触摸事件中的所有触摸点。传入参数 pEvent 是由Cocos2d-iPhone 引擎遗留下来的形式参数,在 Cocos2d-x 当前版本中没有意义。
触摸事件分为 4 类,其中结束事件和取消事件容易使人困惑。通常,当玩家的触摸动作完成时(例如抬手和手指离开屏幕等),会引起触摸结束事件。而触摸取消的情况较少,仅当触摸过程中程序被调入后台时才会出现。
利用层来实现触摸十分简便,然而只要玩家触摸了屏幕,所有响应触摸事件的层都会被触发。当层的数量很多时,维护多个层的触摸事件就成了一件复杂的事情。因此,在实际开发中,我们通常单独建立一个触摸层。用触摸层来接收用户输入事件,并根据需要通知游戏中的其他部件来响应触摸事件。
(2)两种触摸事件:
我们已经见到了 CCLayer 处理触摸事件的方法。当我们开启 CCLayer 的 TouchEnable 属性后,层中的 4 个回调函数就会在接收到事件时被触发,我们把这类事件称作标准触摸(standard touch)事件,它的特点是任何层都会平等地接收全部触摸事件。
除此以外,Cocos2d-x 还提供了另一种被称作带目标的触摸事件(targeted touch)机制。在带目标的触摸机制中,接收者并不平等,较早处理事件的接收者有权停止事件的分发,使它不再继续传递给其他接收者。换句话说,带目标的触摸事件并不一定会被广播给所有的接收者。通常,游戏的菜单按钮、摇杆按钮等元件常使用目标触摸事件,以保证触摸事件不对其他层产生不良影响。
(2-1)标准触摸事件
经过前面的学习,我们知道利用 CCLayer 可以方便地启用层上的标准触摸事件。实际上,并不是只有层才支持接收触摸事件,任何一个游戏元素都可以接受事件,只不过层中提供了现成的支持。下面我们将以层为例,来探究一下层是如何开启标准触摸事件的。为了开启层的标准触摸事件支持,我们需要启用 TouchEnable 属性。下面是 CCLayer 中与 TouchEnable 属性相关的代码:
/// isTouchEnabled setter void CCLayer::setTouchEnabled(bool enabled) { if (m_bIsTouchEnabled != enabled) { m_bIsTouchEnabled = enabled; if (m_bIsRunning) { if (enabled) { this->registerWithTouchDispatcher(); } else { // have problems? CCDirector::sharedDirector()->getTouchDispatcher()->removeDelegate(this); } } } }
void CCLayer::registerWithTouchDispatcher() { CCTouchDispatcher* pDispatcher = CCDirector::sharedDirector()->getTouchDispatcher(); // Using LuaBindings if (m_pScriptHandlerEntry) { if (m_pScriptHandlerEntry->isMultiTouches()) { pDispatcher->addStandardDelegate(this, 0); LUALOG("[LUA] Add multi-touches event handler: %d", m_pScriptHandlerEntry->getHandler()); } else { pDispatcher->addTargetedDelegate(this, m_pScriptHandlerEntry->getPriority(), m_pScriptHandlerEntry->getSwallowsTouches()); LUALOG("[LUA] Add touch event handler: %d", m_pScriptHandlerEntry->getHandler()); } } else { if( m_bTouchMode == kCCTouchesAllAtOnce ) { pDispatcher->addStandardDelegate(this, 0); } else { pDispatcher->addTargetedDelegate(this, m_bTouchPriority, true); } } }
可以看到,如果设置开启触摸,则会调用 registerWithTouchDispatcher 方法。而在 registerWithTouchDispatcher 方法中,我们调用了 CCTouchDispatcher 的 addStandardDelegate 方法。在引擎中,CCTouchDispatcher 负责触摸事件的分发处理,此处的 addStandardDelegate 方法会把当前对象注册到分发器中。被注册的对象必须实现 CCStandardTouchDelegate 接口,当引擎从系统接收到触摸事件时,就会调用接口中对应的方法,触发触摸事件。
相比之下,关闭触摸则简单得多:只需要调用 CCTouchDispatcher 的 removeDelegate 方法即可。
因此可以总结,为了使一个对象接受标准触摸事件,主要有以下 4 个步骤:
1. 需要此对象实现 CCStandardTouchDelegate 接口。
2. 使用 addStandardDelegate 方法把自己注册给触摸事件分发器。
3. 重载事件回调函数,处理触摸事件;
4. 当不再需要接收触摸事件时,使用 removeDelegate 方法来注销触摸事件的接收。
(2-2)带目标的触摸事件
我们可以为任意对象添加标准触摸事件,然而如同前文所述,标准触摸事件中存在两个较为不便的地方,具体如下所示。
只要事件分发器接收到用户的触摸事件,就会分发给所有的订阅者,因此常常会出现按下按钮时,触摸事件穿透按钮分发给后面的层这种尴尬的情况。当系统存在多个触摸点时,标准触摸事件会把所有触摸点都传递给回调函数,然而在许多情况下每个触摸点之间是独立的,屏幕上是否存在其他触摸点我们并不不关心,因此我们不必为了处理多个触摸事件手动遍历一遍触摸点。
为此,Cocos2d-x 为我们提供了一个简化的解决方案:带目标的触摸事件。与标准触摸事件类似,我们也需要首先使接受事件的对象实现一个接口CCTargetedTouchDelegate,然后把对象注册到触摸分发器中,最后当不再需要接受触摸事件时注销自身。
// default implements are used to call script callback if exist virtual bool ccTouchBegan(CCTouch *pTouch, CCEvent *pEvent); virtual void ccTouchMoved(CCTouch *pTouch, CCEvent *pEvent); virtual void ccTouchEnded(CCTouch *pTouch, CCEvent *pEvent); virtual void ccTouchCancelled(CCTouch *pTouch, CCEvent *pEvent);
细心的读者应该注意到了,这个接口和 CCStandardTouchDelegate 存在两处不同,这两处不同也对应着带目标的触摸事件的两个特点:
1.事件参数不再是集合,而是一次只传入一个触摸点。
2.ccTouchBegin 方法返回一个布尔值,表示声明是否要捕捉这个触摸点,只有在此方法中捕捉到的触摸点才会继续引发其他3个事件,否则此触摸点的其他事件都会被忽略。
3.通过 CCTargetedTouchDelegate 的方式接收触摸事件,就无法直接利用 CCLayer 提供的属性了,因此 我们必须主动把自身注册到引擎的触摸分发器:
CCTouchDispatcher::sharedDispatcher()->addTargetedDelegate(this, 0, true);
在 addTargetedDelegate 方法中,前两个参数分别对应触摸接收对象和优先级,其中优先级是一个整型参数,值越低,则优先级越高,也就越早获得触摸事件。通常,为了获得较高的优先级,可以将其指定为负数。第三个参数较为有趣,表明了是否"吞噬"一个触摸,如果设置为 true,一个触摸一旦被捕捉,那么所有优先级更低的接收对象都无法接收到触摸。CCMenu就是一个会"吞噬"且优先级为-128 的触摸接收器,由于它的优先级很高,所以菜单按钮总能获得触摸响应。
综上所述,带目标的触摸事件的使用步骤如下所示:
1. 实现 CCTargetedTouchDelegate 接口。
2. 使用 addTargetedDelegate 方法注册到触摸事件分发器。
3. 重载事件回调函数。注意,我们必须在触摸开始事件中针对需要接受的事件返回 true 以捕捉事件。
4. 当不再需要接受触摸事件时,使用 removeDelegate 方法来注销触摸事件的接收。与标准触摸事件相比,不同之处主要在于开始触摸事件需要返回一个代表是否捕捉事件的值。
二、触摸分发器原理
前面我们详细介绍了触摸的基本模式。现在,我们已经可以利用 Cocos2d-x 提供的两种方式来处理触摸事件了。然而无论使用哪一种机制,最关键的一步都是把接收事件的对象注册到触摸分发器中,这是因为触摸分发器会利用系统的 API 获取触摸事件,然后把事件分发给游戏中接收触摸事件的对象。
表 7-1 列举了 CCTouchDispatcher 中的主要成员,其中 addStandardDelegate 与 addTargetedDelegate 两个方法接收一系列参数,包括事件的注册者。在这两个方法中,注册者会被对应地存入 m_pStandardHandlers 或 m_pTargetedHandlers容器中。注销事件的方法与注册类似,同样操作上面提到的两个容器。然而在事件分发过程中,为了保证不在循环时改变容器的内容,我们引入了 m_pHandlersToAdd 与 m_ pHandlersToRemove 两个容器,用于暂时保存分发事件时目标对象的增删。此外,DispatchEvents 属性可以用于暂时屏蔽所有的触摸事件,这在一些特殊场合下十分方便。
以上提到的方法是引擎面向开发者而提供的接口。另一方面,事件分发器从系统接收到了触摸事件之后还需要逐一分发。分发事件的相关代码主要集中在 touches 方法之中。由于此方法较为冗长,因此我们对其进行了简化,简化后的版本touches_simplified 基本保持了原来的框架,读者可以作为参考:
void CCTouchDispatcher::touches(CCSet *pTouches, CCEvent *pEvent, unsigned int uIndex) { _prepare_for_dispatch(); // // process the target handlers 1st // 第一步:处理带目标的触摸事件 // if (uTargetedHandlersCount > 0) { // 遍历每一个接收到的触摸事件 for (setIter = pTouches->begin(); setIter != pTouches->end(); ++setIter) { //遍历每一个已注册触摸事件的对象 CCARRAY_FOREACH(m_pTargetedHandlers, pObj) { //分发不同类型的事件 //首先处理触摸开始事件 if (uIndex == CCTOUCHBEGAN) { bClaimed = pHandler->getDelegate()->ccTouchBegan(pTouch, pEvent); if (bClaimed) { pHandler->getClaimedTouches()->addObject(pTouch); } } else if (pHandler->getClaimedTouches()->containsObject(pTouch)) { // moved ended canceled //再处理移动、结束和取消事件 bClaimed = true; switch (sHelper.m_type) { case CCTOUCHMOVED: pHandler->getDelegate()->ccTouchMoved(pTouch, pEvent); break; case CCTOUCHENDED: pHandler->getDelegate()->ccTouchEnded(pTouch, pEvent); pHandler->getClaimedTouches()->removeObject(pTouch); break; case CCTOUCHCANCELLED: pHandler->getDelegate()->ccTouchCancelled(pTouch, pEvent); pHandler->getClaimedTouches()->removeObject(pTouch); break; } } if (bClaimed && pHandler->isSwallowsTouches()) { //此对象被捕捉,而且设置了“吞噬触摸事件”属性 if (bNeedsMutableSet) { pMutableTouches->removeObject(pTouch); } break; } } } } // // process standard handlers 2nd //第二步:处理标准触摸事件 // if (uStandardHandlersCount > 0 && pMutableTouches->count() > 0) { //遍历每一个已注册触摸事件的对象 CCARRAY_FOREACH(m_pStandardHandlers, pObj) { switch (sHelper.m_type) { case CCTOUCHBEGAN: pHandler->getDelegate()->ccTouchesBegan(pMutableTouches, pEvent); break; case CCTOUCHMOVED: pHandler->getDelegate()->ccTouchesMoved(pMutableTouches, pEvent); break; case CCTOUCHENDED: pHandler->getDelegate()->ccTouchesEnded(pMutableTouches, pEvent); break; case CCTOUCHCANCELLED: pHandler->getDelegate()->ccTouchesCancelled(pMutableTouches, pEvent); break; } } } _process_handler_to_remove(); _process_handlers_to_add(); _dispose_unused_resources(); }
可以看到,分发过程遵循以下的规则。
1. 对于触摸集合中的每个触摸点,按照优先级询问每一个注册到分发器的对象。对于同一优先级的对象,访问顺序并不确定。
2. 当接收到开始事件时,如果接受者返回 true,则称对象捕捉了此事件。只有被捕捉,后续事件(如移动、结束等)才会继续分发给目标对象。
3. 如果设置了吞噬属性,则捕捉到的点会被吞噬。被吞噬的点将立即移出触摸集合,不再分发给后续目标(包括注册了标准触摸事件的目标)。
4. 将没有被吞噬的触摸点集按优先级的顺序分发给每一个注册了标准触摸事件的目标对象,同一优先级之间对象的访问顺序并不确定。
5. 为了避免事件分发中事件处理对象被改变,Cocos2d-x 仔细维护了两个临时表,因此开发者无论何时都可以注册或注销触摸事件。
备注:触摸中的陷阱
1. 第一个陷阱是接收触摸的对象并不一定正显示在屏幕上。触摸分发器和引擎中的绘图是相互独立的,所以并不关心触摸代理是否处于屏幕上。因此,在实际使用中,应该在不需要的时候及时从分发器中移除触摸代理,尤其是自定义的触摸对象。而 CCLayer 也仅仅会在切换场景时将自己从分发器中移除,所以同场景内手动切换 CCLayer 的时候,也需要注意禁用触摸来从分发器移除自己。
2. 另一个陷阱出自 CCTargetedTouchDelegate。尽管每次只传入一个触摸点,也只有在开始阶段被声明过的触摸点后续才会传入,但是这并不意味着只会接收一个触摸点:只要被声明过的触摸点都会传入,而且可能是乱序的。因此,一个良好的习惯是,如果使用 CCTargetedTouchDelegate,那么只声明一个触摸,针对一个触摸作处理即可。
三、使用实例
《cocos2d-x高级开发教程(捕鱼实例)》
通过触摸事件控制发炮和炮台的转动。为此,我们新建一个触摸处理层 TouchLayer,它继承自 CCLayer,负责将基本的触摸事件转换为我们感兴趣的事件。TouchLayer 的定义如下:
class TouchLayer : public CCLayer { public: // CC_SYNTHESIZE为cocos2d-x属性宏 CC_SYNTHESIZE(TouchLayerDelegate*,m_pDelegate,Delegate); public: bool init(); virtual void ccTouchesBegan(CCSet *pTouches, CCEvent *pEvent); virtual void ccTouchesMoved(CCSet *pTouches, CCEvent *pEvent); virtual void ccTouchesEnded(CCSet *pTouches, CCEvent *pEvent); virtual void ccTouchesCancelled(CCSet *pTouches, CCEvent *pEvent); void onExit(); };
现在我们添加代码使 TouchLayer 拥有接受触摸事件的能力。和 CCLayer 中开启触摸事件的方法类似,我们在 init 中调用registerWithTouchDispatcher 方法,把 TouchLayer 注册到触摸分发器中,以便接受玩家的触摸事件。与此对应地,当场景退出屏幕,触发了 onExit 回调函数时,我们需要从触摸分发器中移除这个注册。这两个方法的代码如下所示:
bool TouchLayer::init() { bool bRet = false; do { CC_BREAK_IF(!CCLayer::init()); this->registerWithTouchDispatcher(); this->setDelegate(NULL); bRet = true; } while (0); return bRet; } void TouchLayer::onExit() { CCDirector::sharedDirector()->getTouchDispatcher()->removeDelegate(this); }
在这段代码中,我们看到了 Delegate 这个成员变量,它是 TouchLayerDelegate 类型的变量,代表一个处理触摸事件的"代理"。TouchLayer 负责接收触摸事件,并把事件分发给代理,由代理来处理事件。使用代理的设计模式,可以解除触摸层和实际处理事件的精灵层间的耦合。后面会看到,当触摸层渐渐变复杂之后,这样的解耦合能有效分离触摸手势的识别和事件处理两部分,代码结构清晰也不易出错。
在我们的例子中,精灵层需要响应两个触摸事件,分别为发射炮弹与转动炮台。对应地,我们在 TouchLayerDelegate 中定义了两个触摸事件:singleTouchEndsIn 与 singleTouchDirecting,分别表示单点触摸的结束与移动事件。然后在精灵层中继承并实现 TouchLayerDelegate。当单点触摸结束时,向触摸的方向发射一枚炮弹;当单点触摸移动时,改变炮台的方向,使之指向触摸的位置。
// 代理设计模式 class TouchLayerDelegate { public: virtual void singleTouchEndsIn(CCPoint point) = 0; virtual void singleTouchDirecting(CCPoint point) = 0; }; // 精灵层继承并实现TouchLayerDelegate void SpriteLayer::singleTouchEndsIn(CCPoint point) { CCLog("%f %f", point.x, point.y); this->fire(point); } void SpriteLayer::singleTouchDirecting(CCPoint point) { CCPoint origin = cannon->getPosition(); CCPoint direction = ccp(point.x - origin.x, point.y - origin.y); float degree = (float)atan2(direction.y, direction.x); cannon->setRotation(90 - degree * 180 / acos(-1.0)); }
我们已经完成了精灵层对触摸事件的响应。现在我们需要做的是,在 TouchLayer 接收到系统触摸事件之后,把事件分发给代理(也就是精灵层)。在 TouchLayer 的 4 个触摸事件响应函数中,我们首先检测触摸点数量,如果只有一个触摸点,则判断为单点触摸,并调用代理处理对应的事件。相关的代码如下所示:
void TouchLayer::ccTouchesBegan(CCSet *pTouches, CCEvent *pEvent) { if(pTouches->count() == 1) { CCTouch* touch = dynamic_cast<CCTouch*>(pTouches->anyObject()); CCPoint position = touch->locationInView(); position = CCDirector::sharedDirector()->convertToGL(position); if(this->getDelegate()) // 使用代理对象调用其子类的触摸事件处理函数 this->getDelegate()->singleTouchDirecting(position); } } void TouchLayer::ccTouchesMoved(CCSet *pTouches, CCEvent *pEvent) { if(pTouches->count() == 1) { CCTouch* touch=dynamic_cast<CCTouch*>(pTouches->anyObject()); CCPoint position = touch->locationInView(); position = CCDirector::sharedDirector()->convertToGL(position); if(this->getDelegate()) this->getDelegate()->singleTouchDirecting(position); } } void TouchLayer::ccTouchesEnded(CCSet *pTouches, CCEvent *pEvent) { if(pTouches->count() == 1){ CCTouch* touch = dynamic_cast<CCTouch*>(pTouches->anyObject()); CCPoint position = touch->locationInView(); position = CCDirector::sharedDirector()->convertToGL(position); if(this->getDelegate()) this->getDelegate()->singleTouchEndsIn(position); } }
最后,我们把触摸层加入到场景之中,并把精灵层设为它的代理。在 GameScene::init 方法中添加相关的初始化代码,具体如下所示:
TouchLayer* touchLayer = TouchLayer::node(); touchLayer->setDelegate(spriteLayer); this->addChild(touchLayer);
备注:本例中使用了代理的设计模式,这会给程序的扩展和维护提供极大的方便。如果不使用代理模式,可以使精灵层直接继承自CCLayer,然后在精灵层中实现触摸事件处理函数。