精通Cocos2d-x游戏开发(进阶卷)
上QQ阅读APP看书,第一时间看更新

10.4 在Cocos2d-x中使用Box2d

在Cocos2d-x中使用Box2d有两个要点,第一点是在Cocos2d-x中完成物理引擎的初始化和更新,第二点是将Cocos2d-x的渲染和Box2D的物理模拟结合起来。本节将由浅入深地介绍Box2d在Cocos2d-x中的应用,将物理引擎嵌入Cocos2d-x框架中,从对象物理模拟和Cocos2d-x渲染,到碰撞监听的回调,再到安全地删除刚体,释放物理世界。

10.4.1 物理世界

首先需要有一个场景,将这个场景作为我们的物理世界,所以这个场景需要完成第一个任务,即在onEnter或init的时候,初始化物理世界,设置好重力。

        b2Vec2 gravity;
        gravity.Set(0.0f, -10.0f);
        m_World = new b2World(gravity);
        m_World->SetAllowSleeping(true);
        m_World->SetContinuousPhysics(true);

在每一帧的update中,都要执行物理世界的更新,这只是很简单的几行代码。

        int velocityIterations = 8;
        int positionIterations = 1;
        m_World->Step(dt, velocityIterations, positionIterations);

创建World,更新World,放在场景中是没有问题的,但是假设把这些物理相关的东西,封装到一个物理管理器里面作为一个单例,在Cocos2d-x中使用会更好一些。这样做的好处是,把物理框架从场景分离开,使物理框架的功能更独立一些,清晰一些,整体的耦合性小一些。并且在很多地方,可能需要使用物理引擎来做一些东西。假如需要先找到场景节点,然后获取它的World,那么这样做会使整体的耦合度变得很高,而如果封装到单例里面,这些操作就会变得“优雅”很多。

        class CPhysicsManager
        {
        private:
            CPhysicsManager(void);
            virtual ~CPhysicsManager(void);

        public:
            static CPhysicsManager* getInstance();

            //在一场游戏结束之后应该调用
            static void destory();

            //更新物理世界
            void update(float dt);

            inline bool isLocked()
            {
              if (NULL == m_World)
              {
                  return false;
              }

              return m_World->IsLocked();
            }

            //获取世界
            inline b2World* getWorld()
            {
                return m_World;
            }

        private:
            static CPhysicsManager* m_Instance;
            b2World* m_World;
            CPhysicsListener* m_ContactListener;
        };

笔者是这样设计这个单例的,这个单例会负责维护两项,一个是我们的物理世界,在构造函数中初始化物理世界,另外一个是我们自定义的碰撞监听器,在绝大部分情况下总是需要它的。在构造函数中会初始化物理世界,而update()函数将会驱动物理世界的Step进行模拟。

        CPhysicsManager::CPhysicsManager(void)
        {
            //创建碰撞监听器
            m_ContactListener = new CPhysicsListener();

            //初始化物理世界
            b2Vec2 gravity;
            gravity.Set(0.0f, -10.0f);
            m_World = new b2World(gravity);

            //设置碰撞监听器
            m_World->SetContactListener(m_ContactListener);

            //允许刚体睡眠
            m_World->SetAllowSleeping(true);
            //激活连续碰撞检测
            m_World->SetContinuousPhysics(true);
        }

最后在游戏场景初始化的时候,调用单例的初始化函数,在游戏场景退出的时候,调用单例的销毁函数,因为当玩家退回到主界面的时候,物理世界不应该继续模拟了,而当玩家进入游戏的时候,物理世界必须是一个新的世界,不能使用上一个关卡所遗留的数据来模拟。当然,需要在游戏场景节点的update()函数中调用CPhysicsManager的update,这里没有说释放,但应在析构函数中,把new出来的CPhysicsListener和b2World释放掉。

接下来还需要创建场景的边界,用一个包围盒把场景框住,在设置和Box2d相关的大小变量时,一般都要除以一个PTM_RATIO常量,这个常量一般是32,表示像素和物理单位米的比例,因为在设置物体大小的时候,按照现实世界的比例来设置是比较好的,假设没有这个参数,那就会变成1像素=1米,在大多数情况下,32像素=1米的比例能够更好地工作。当然这只是一个比例问题。让显示对象缩小到1/32或其他比例以适应物理对象,或者让物理对象放大32倍来适应显示对象,关键的地方在于物理对象的大小和显示对象的大小是否相等。

        //定义包围盒
        b2BodyDef groundBodyDef;
        groundBodyDef.position.Set(0, 0); //bottom-left corner
        //调用世界工厂的方法创建刚体
        b2Body* groundBody = m_World->CreateBody(&groundBodyDef);

        //定义包围盒的形状
        b2EdgeShape groundBox;

        //设置包围盒的底部
        groundBox.Set(b2Vec2(VisibleRect::leftBottom().x/PTM_RATIO,
            VisibleRect::leftBottom().y/PTM_RATIO),
        b2Vec2(VisibleRect::rightBottom().x/PTM_RATIO,
            VisibleRect::rightBottom().y/PTM_RATIO));
        groundBody->CreateFixture(&groundBox,0);

        //设置包围盒的顶部
        groundBox.Set(b2Vec2(VisibleRect::leftTop().x/PTM_RATIO,
            VisibleRect::leftTop().y/PTM_RATIO),
        b2Vec2(VisibleRect::rightTop().x/PTM_RATIO,
            VisibleRect::rightTop().y/PTM_RATIO));
        groundBody->CreateFixture(&groundBox,0);

        //设置包围盒的左边
        groundBox.Set(b2Vec2(VisibleRect::leftTop().x/PTM_RATIO,
            VisibleRect::leftTop().y/PTM_RATIO),
        b2Vec2(VisibleRect::leftBottom().x/PTM_RATIO,
            VisibleRect::leftBottom().y/PTM_RATIO));
        groundBody->CreateFixture(&groundBox,0);

        //设置包围盒的右边
        groundBox.Set(b2Vec2(VisibleRect::rightBottom().x/PTM_RATIO,
            VisibleRect::rightBottom().y/PTM_RATIO),
        b2Vec2(VisibleRect::rightTop().x/PTM_RATIO,
            VisibleRect::rightTop().y/PTM_RATIO));
        groundBody->CreateFixture(&groundBox,0);

我们能且只能通过World的create()方法来创建刚体,如果直接用new或者malloc来创建刚体,那么创建的刚体将不在这个世界之内,也不会和物理世界有任何交集。上面创建包围盒的代码,应该写在场景中,因为这并不属于物理框架的内容,物理框架不会知道,创建的这个场景的地形长什么样的,有多大。

10.4.2 物理Sprite

接下来要添加场景内的东西了,主要是把显示对象Sprite和b2Body结合起来,双继承也许会是一个好主意,但可能存在比较多的争议,将b2Body作为Sprite的一个成员变量已经可以比较好地工作了,为什么是b2Body作为Sprite的成员变量,而不是反过来呢?首先,编码的时候可能会频繁用到Sprite里面的东西,但是b2Body可能很少问津。另外,当Body被销毁的时候,Sprite可能需要继续存在于场景中,对于Body,只是需要使用其物理特性而已。

首先需要有一个继承于Sprite的类,因为需要为其添加一些成员变量,一个b2Body指针,在onEnter的时候初始化这个指针,可以在onExit或者析构函数中释放它,继承于Sprite的类先管其叫CPhysicsObject,可以在init中初始化物理刚体,这块在描述不同的CPhysicsObject时,可以根据需要重写这部分的代码。下面的一小段代码只是用来介绍,在Cocos2d-x中创建刚体的过程,实际上CPhysicsObject应该作为一个纯粹的物理对象基类来使用,不应该在这里添加创建刚体的代码,应该由子类来完成这个任务。

        b2BodyDef bodyDef;
        bodyDef.type = b2_dynamicBody;
        bodyDef.position.Set(getPositionX() / PTM_RATIO, getPositionY() / PTM_
        RATIO);

        m_Body = world->CreateBody(&bodyDef);

        b2PolygonShape dynamicBox;
        Size sz = getContentSize();
        //根据精灵图片的大小以及像素和米的比例,来设置包围盒
        dynamicBox.SetAsBox(sz.width * 0.5f / PTM_RATIO, sz.height * 0.5f / PTM_
        RATIO);

        //设置好动态刚体的属性,然后配置给Body
        b2FixtureDef fixtureDef;
        fixtureDef.shape = &dynamicBox;
        fixtureDef.density = 1.0f;
        fixtureDef.friction = 0.3f;
        m_Body->CreateFixture(&fixtureDef);

在onExit()函数中,析构或者任何想要删除刚体的时候,需要调用下面的代码来释放。

        void CPhysicsObject::onExit()
        {
            if (NULL ! = m_Body)
            {
              CPhysicsManager::getInstance()->getWorld()->DestroyBody(m_Body);
              m_Body = NULL;
            }

            Sprite::onExit();
        }

有了刚体之后,需要注意一件事情,就是不要再调用这个刚体的setPosition()方法来改变刚体的位置,如果要设置,需要同时更新m_Body的位置属性,强制设置位置会导致物理模拟出错。

另外还应该做一个事情,就是把m_Body的位置,旋转等属性同步到Sprite中。在Node中有一个nodeToParentTransform()函数,用于返回一个描述节点当前的旋转和位置的矩阵,在Node中是根据当前节点的位置、锚点,以及旋转来计算这个矩阵的,在这里用m_pBody的位置和旋转来计算。

        AffineTransform CPhysicsObject::nodeToParentTransform(void)
        {
            if (NULL == m_Body)
            {
              return Sprite::nodeToParentTransform();
            }

            b2Vec2 pos  = m_Body->GetPosition();

            float x = pos.x * PTM_RATIO;
            float y = pos.y * PTM_RATIO;

            if ( isIgnoreAnchorPointForPosition() ) {
              x += m_tAnchorPointInPoints.x;
              y += m_tAnchorPointInPoints.y;
          }

          //Make matrix
          float radians = m_Body->GetAngle();
          float c = cosf(radians);
          float s = sinf(radians);

          if( ! m_tAnchorPointInPoints.equals(CCPointZero) ){
              x += ((c * -m_tAnchorPointInPoints.x * m_fScaleX) + (-s * -m_
              tAnchorPointInPoints.y * m_fScaleY));
              y += ((s * -m_tAnchorPointInPoints.x * m_fScaleX) + (c * -m_
              tAnchorPointInPoints.y * m_fScaleY));
          }

          //Rot, Translate Matrix
          m_tTransform = AffineTransformMake( c * m_fScaleX,   s * m_fScaleX,
              -s * m_fScaleY,   c * m_fScaleY,
              x,    y );

          return m_tTransform;
        }

nodeToParentTransform()函数在对象需要被重绘的时候调用,Cocos2d-x根据isDirty虚函数的返回值,来决定是否重绘。正常情况下,当玩家的位置、大小、旋转发生改变的时候,nodeToParentTransform()函数就会返回true,而在使用了Box2d的情况下,CPhysicsObject应该在物体的运动状态下,返回true,而在静止状态下,返回false,可以直接返回body的IsAwake()函数,当刚体醒着的时候更新,当刚体静止下来的时候,停止更新。

        bool CPhysicsObject::isDirty()
        {
            if (NULL ! = m_Body)
            {
              return m_Body->IsAwake();
            }

            return CCSprite::isDirty();
        }

CPhysicsObject还需要重写一些接口,用于设置位置和旋转,因为在设置旋转和位置的时候,需要同步到物理世界,而在获取位置和旋转的时候,也需要从物理世界中获取。

        const CCPoint& CPhysicsObject::getPosition()
        {
            if (NULL == m_Body)
            {
              return CCSprite::getPosition();
            }

            b2Vec2 pos = m_Body->GetPosition();

            float x = pos.x * PTM_RATIO;
            float y = pos.y * PTM_RATIO;
            m_tPosition = ccp(x, y);
            return m_tPosition;
        }
        void CPhysicsObject::setPosition(const CCPoint &pos)
        {
            if (NULL == m_Body)
            {
              return Sprite::setPosition(pos);
            }
            float angle = m_Body->GetAngle();
            m_Body->SetTransform(b2Vec2(pos.x / PTM_RATIO, pos.y / PTM_RATIO),
            angle);
        }
        float CPhysicsObject::getRotation()
        {
            if (NULL == m_Body)
            {
              return Sprite::getRotation();
            }
            return CC_RADIANS_TO_DEGREES(m_Body->GetAngle());
        }
        void CPhysicsObject::setRotation(float fRotation)
        {
            if (NULL == m_Body)
            {
              return Sprite::setRotation(fRotation);
            }
            else
            {
              b2Vec2 p = m_Body->GetPosition();
              float radians = CC_DEGREES_TO_RADIANS(fRotation);
              m_Body->SetTransform(p, radians);
            }
        }

10.4.3 碰撞处理

现在我们有了一个物理场景,以及物理节点,这个带有物理属性的节点可以正常显示,那么接下来还需要一个碰撞监听器,虽然这不是必须的,但在每次碰撞发生的时候,告诉节点,被碰了一下或者说跟谁碰到一起了是非常有用的。例如,愤怒的小鸟游戏,玩家发射出去的小鸟,不同的小鸟碰到不同的障碍,效果是不一样的,普通小鸟碰到冰块时穿透力很低,而蓝色小鸟碰到冰块时会有非常强的穿透力。黑色小鸟碰到障碍时会直接爆炸。这些都是由碰撞触发的,从而根据碰撞信息进行相对应的处理。

      class CPhysicsListener :
          public b2ContactListener
      {
      public:
          CPhysicsListener(void);
          virtual ~CPhysicsListener(void);

          //当两个对象互相碰撞
          virtual void BeginContact(b2Contact* contact);
          //当两个对象碰撞结束
          virtual void EndContact(b2Contact* contact);

          //当两个对象准备进行物理模拟之前调用
          virtual void PreSolve(b2Contact* contact, const b2Manifold*
          oldManifold);

          //当两个对象完成了物理模拟之后调用
          virtual void PostSolve(b2Contact* contact, const b2ContactImpulse*
          impulse);

          //处理每一帧的物理碰撞事件
          void Execute();

      private:
          std::set<CPhysicsObject*> m_PhysicsObjets;
      };

想要处理好物理碰撞,那么就需要一个碰撞监听器,碰撞监听器的实现很简单,先写一个空的碰撞监听器,这个碰撞监听器的关键在于,如何把碰撞消息传递给物理节点,这需要通过一个Body获得一个PhysicsObject,那么最好的方法就是,将这个PhysicsObject放到Body的UserData中。因此在碰撞监听器中,需要重写4个碰撞回调函数,而在我们的物理节点基类中,也需要对应4个接口,来接收这4种碰撞消息。例如下面的代码。

      void  CPhysicsListener::PreSolve(b2Contact*  contact,  const  b2Manifold*
      oldManifold)
      {
          //碰撞的第一个刚体如果是一个CPhysicObject(用userData判断),那么调用它的回调
          CPhysicsObject* objA = reinterpret_cast<CPhysicsObject*>
          (contact->GetFixtureA()
            ->GetBody()->GetUserData());
          if (NULL ! = objA)
          {
            objA->beforeSimulate(contact, oldManifold);
          }

          //接下来判断第二个刚体
          CPhysicsObject* objB = reinterpret_cast<CPhysicsObject*>
          (contact->GetFixtureB()
            ->GetBody()->GetUserData());
          if (NULL ! = objB)
          {
            objB->beforeSimulate(contact, oldManifold);
          }
      }

其他几个接口的实现与其类似,都是简单地转发消息,但需要注意的一点是,不要在这些回调函数中改变刚体,因为这会对物理模拟造成影响,特别是不要删除刚体,否则可能导致程序崩溃。假设需要在碰撞发生的时候改变刚体,那么可以在碰撞发生的时候记录状态,在物理模拟完成之后,再进行改变。同样,删除刚体,也是需要在物理模拟完成之后再进行删除。

假设需要在碰撞的时候改变刚体的属性,例如,让一个物体在被碰到的时候破碎,如玻璃杯,或者是碰到某个物体之后变重,如海绵碰到水,这种情况下可以在监听器中增加一个Execute()方法,在World的Step执行之前或之后来执行该方法,在该方法中,将调用这一帧,所有触发碰撞的对象的一个方法,来执行这些操作,包括删除刚体。

        void CPhysicsManager::update(float dt)
        {
            //触发碰撞事件,交给监听者处理
            m_ContactListener->Execute();

            int velocityIterations = 8;
            int positionIterations = 1;
            m_World->Step(dt, velocityIterations, positionIterations);
        }

在每次事件触发的时候,对上面的代码小小改动一下,将PhysicsObject添加到一个容器中,缓存起来。

      CPhysicsObject* objA = reinterpret_cast<CPhysicsObject*>
      (contact->GetFixtureA()
          ->GetBody()->GetUserData());
      if (NULL ! = objA)
      {
          //添加到一个Set容器中
          m_PhysicsObjets.insert(objA);
          objA->beforeSimulate(contact, oldManifold);
      }

然后在每一帧都会执行的Execute()方法中,遍历这一帧所有触发事件的对象,并调用它们的processOver()函数,在所有物理对象的processOver()函数中,可以根据当前的状态改变刚体或者销毁刚体。

      for (set<CPhysicsObject*>::iterator iter = m_PhysicsObjets.begin(); iter ! =
      m_PhysicsObjets.end(); ++iter)
      {
          CPhysicsObject* obj = *iter;
          obj->processOver();
      }

      m_PhysicsObjets.clear();

这里面有一个陷阱,可能导致一个对象被销毁之后,仍然触发其碰撞监听。这是非常可怕的一件事情,意味着程序很可能因此而崩溃!那就是在销毁一个刚体的时候,假设这个刚体正和其他对象发生了接触,那么这个时候,会有一个在Step之外的EndContact回调被触发,这是合理的,但很容易被忽视,并且产生BUG。正常的流程如图10-2所示,在一个Step中完成所有的触发,而图10-3演示了需要多个Step才能处理完一次接触的情况。

图10-2 一次Step完成

图10-3 需要多次Step

要解决这个BUG其实很简单,就是在释放刚体之前,先把刚体的UserData设置为NULL,这样回调流程就无法触发到PhysicsObject里面了。假设你的代码期望收到这个EndContact回调,那么需要在processOver()函数里面把好关,防止因为重复的processOver()函数调用,导致重复释放的问题,并且需要管理好UserData的引用计数。