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的引用计数。