- 精通Cocos2d-x游戲開發(進階卷)
- 王永寶
- 3862字
- 2020-11-28 22:37:08
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的引用計數。