上一章分析了Live場景中各個元素的作用,這一章開始來分析最關鍵的部分——打擊物件的實現。
上一章放出的視頻很模糊,先來看一個清晰版的復習一下:http://www.bilibili.com/video/av1087466/
然后說一下我使用的環境:Win8.1 + VS2013 + Cocos2d-x3.2
接下來為后文作點准備工作:
1、 創建一個空的cocos2d-x項目;
2、 把HelloWorldScene類和它的兩個源碼文件改名。我使用的名稱是LiveScene;
3、 刪掉LiveScene類中多余的代碼,比如添加一個“Hello World”的Label這種的(說實話cocos2d-x創建空項目每次都要帶個這個類挺蛋疼,因為99.99999%的情況我們的項目中根本不需要它,直接創建一個干凈的空項目多好);
4、 刪掉Resource文件夾中的所有文件;
5、 在AppDelegate類中修改設計分辨率為960×640,像下面這樣:
bool AppDelegate::applicationDidFinishLaunching() { // ... if(!glview) { glview = GLView::create("My Game"); glview->setFrameSize(960, 640); director->setOpenGLView(glview); glview->setDesignResolutionSize(960, 640, ResolutionPolicy::SHOW_ALL); } // ... }
准備工作做完后,可以先編譯運行一下。如果跑起來一片黑,那么就OK可以繼續了。
准備工作完成,開始分析打擊物件。從視頻看,打擊物件有個3D透視效果:近大遠小。Cocos2d-x 3.x的版本已經支持3D模型,可以制作3D游戲了。但是對於這個比較簡單的效果,直接上3D有點大炮打蚊子的感覺。可以運用3D透視公式,根據物件的Z軸距離計算出在屏幕上的X和Y坐標以及縮放。
等等,仔細看看那個視頻,感覺物件飛過來的過程,和真正的3D比還是有點違和啊……於是,LL(《Love Live!學院偶像祭》,以后都簡稱LL)中真的是用的3D嗎?
此時需要檢測一下了。先說一下我的思路:對於下圖所示的三個三連打物件,他們的時間間隔是一樣的。如果使用了3D透視,那么后兩個的屏幕坐標間距一定小於前兩個,近大遠小嘛。
還是不大能理解?來看看這么一張圖(圖片源自網絡):
不要吐槽遠景那些莫名其妙的東西……我們來看鐵軌,是不是從畫面上看,越遠處的枕木越密集?但是實際上枕木和枕木之間的間距是一樣的。這個現象用計算機圖形學的術語講,其實就是世界坐標系到攝像機坐標系的變換形成的。因為屏幕上沒有Z軸,只有X和Y,物體的Z軸變化只能通過移動位置和縮放來表現。
扯遠了,用這一點來驗證LL是否使用了3D透視,就是因為物件飛過來的時候,它們的Z軸速度肯定是一定的(從一個音樂游戲來講,如果是變速運動這游戲基本上沒法玩了),而三個物件的時間間隔相同,那么經過坐標變換必然形成后兩個的屏幕坐標間距一定小於前兩個。
那么打開神奇PS,將三個圓的中心標出來,連上線。不會PS沒關系,我把圖做好了。背景有點花?蓋個黑色圖層:
能明顯看出,三個圓之間的屏幕距離是相等的。於是乎,這個效果根本沒有使用3D透視變換。也就是說,物件在飛向屏幕的過程中,在屏幕上移動的速度也是不變的。
於是事情變得簡單了,不需要去計算透視變換了。對於單個的一列,物件飛過來就是X軸不變,Y軸勻速運動。再從飛出來的點拉兩條射線出來:
艾瑪,縮放也是勻變化的,取值就是一個f(x) = kx + b的一元線性方程嘛。接下來我們來求這個方程的參數k和b。
為了方便測量,以最中間的按鈕為准。從視頻看這個游戲的長寬比是3:2的,所以文章開始說的的設計分辨率要取960×640。測量值寫在圖片上了(怎么測量屬於PS的應用,與這一系列文章無關,這里就不做介紹了):
所以就可以知道:物件在0px高度的時候,縮放是0,在-400px高度的時候,縮放是1(這里定義列的錨點是物件出現的點,以方便后續做旋轉操作,所以物件的y是從0開始逐漸變小的)。所以可以求得縮放方程式是
scale = -0.0025 * y
可以看出對於圓圈物件,就是一個Sprite對象。對於長條物件,是一個Sprite做頭,一個Sprite做中間,一個Sprite做尾。
對於圓圈物件來說,我只要處理縮放和坐標就行。而對於Sprite的中間,則是一個隨時在變化的梯形。
Cocos2d-x提供了setSkew方法對Sprite進行扭曲,但是這個扭曲只是一個平行四邊形變換,並不是梯形。我們知道OpenGL渲染圖形是先渲染頂點,再渲染像素的。所以修改Sprite的四個頂點可以達到想要的效果。說到頂點,自然就想到了頂點着色器,想到了GLSL。不過,這個效果怎么說也不復雜,殺雞焉用牛刀呢。其實,在Sprite類中有一個成員(CCSprite.h 563行):
// vertex coords, texture coords and color info V3F_C4B_T2F_Quad _quad;
注釋說,這個成員就是Sprite的四個頂點。V3F_C4B_T2F_Quad又是個啥玩意?看看結構定義(ccTypes.h 291行):
//! 4 Vertex3FTex2FColor4B struct V3F_C4B_T2F_Quad { //! top left V3F_C4B_T2F tl; //! bottom left V3F_C4B_T2F bl; //! top right V3F_C4B_T2F tr; //! bottom right V3F_C4B_T2F br; };
里面果然是四個成員,分別表示左上,左下,右上,右下四個頂點。而頂點的結構V3F_C4B_T2F是這樣的(ccTypes.h 245行):
//! a Vec2 with a vertex point, a tex coord point and a color 4B struct V3F_C4B_T2F { //! vertices (3F) Vec3 vertices; // 12 bytes //! colors (4B) Color4B colors; // 4 bytes // tex coords (2F) Tex2F texCoords; // 8 bytes };
這三個成員分別表示:頂點坐標,頂點顏色和UV紋理貼圖坐標。我們只需要處理位置即可。然而,_quad成員是一個protected對象,Sprite類也沒有對外提供訪問接口。因為對象時protected的,我們可以派生一個Sprite類的子類,提供訪問_quad的接口。
在項目中添加VertexSprite.h和VertexSprite.cpp,將接口暴露出來(憋了這么久,終於可以敲代碼了)。首先是頭文件:
#ifndef __VERTEX_SPRITE_H__ #define __VERTEX_SPRITE_H__ #include "cocos2d.h" USING_NS_CC; class VertexSprite : public Sprite { public: static VertexSprite* create(const std::string& filename); bool initWithFile(const std::string& filename); /* * 設置四個頂點的坐標 * @param pTL 左上角頂點坐標 * @param pBL 左下角頂點坐標 * @param pTR 右上角頂點坐標 * @param pBR 右下角頂點坐標 */ void SetVertex(const Vec2& pTL, const Vec2& pBL, const Vec2& pTR, const Vec2& pBR); private: VertexSprite(){} }; #endif // __VERTEX_SPRITE_H__
然后是實現文件:
#include "VertexSprite.h" VertexSprite* VertexSprite::create(const std::string& filename) { auto ret = new VertexSprite(); if (ret->initWithFile(filename)) { ret->autorelease(); } else { CC_SAFE_DELETE(ret); } return ret; } bool VertexSprite::initWithFile(const std::string& filename) { return Sprite::initWithFile(filename); } void VertexSprite::SetVertex(const Vec2& pTL, const Vec2& pBL, const Vec2& pTR, const Vec2& pBR) { // Top Left // this->_quad.tl.vertices.x = pTL.x; this->_quad.tl.vertices.y = pTL.y; // Bottom Left // this->_quad.bl.vertices.x = pBL.x; this->_quad.bl.vertices.y = pBL.y; // Top Right // this->_quad.tr.vertices.x = pTR.x; this->_quad.tr.vertices.y = pTR.y; // Bottom Right // this->_quad.br.vertices.x = pBR.x; this->_quad.br.vertices.y = pBR.y; this->setContentSize(Size(0, pTL.y - pBL.y)); }
可以看到SetVertex方法的最后做了一下setContentSize的操作。為什么呢?因為Sprite繪制的時候,會判斷自己是否在顯示區域內,如果不在,則不繪制(CCSprite.cpp 586行):
// draw void Sprite::draw(Renderer *renderer, const Mat4 &transform, uint32_t flags) { // Don't do calculate the culling if the transform was not updated _insideBounds = (flags & FLAGS_TRANSFORM_DIRTY) ? renderer->checkVisibility(transform, _contentSize) : _insideBounds; if(_insideBounds) { _quadCommand.init(_globalZOrder, _texture->getName(), getGLProgramState(), _blendFunc, &_quad, 1, transform); renderer->addCommand(&_quadCommand); #if CC_SPRITE_DEBUG_DRAW _customDebugDrawCommand.init(_globalZOrder); _customDebugDrawCommand.func = CC_CALLBACK_0(Sprite::drawDebugData, this); renderer->addCommand(&_customDebugDrawCommand); #endif //CC_SPRITE_DEBUG_DRAW } }
所以設置頂點坐標后,還需要手動設置它的_contentSize。如果不設置,這個值默認就是我們使用的貼圖的大小,即1px × 1px。所以在沒設置的情況下,這個Sprite稍微移出顯示區域一點,整個Sprite就不會顯示了。所以我們需要在設定頂點后,手動去修改它的_contentSize。為了節約運算資源,以及考慮到可能出現的情況(只會是梯形,不會出現凹四邊形等情況),這里直接設置_contentSize的高度就行了,可以減少一定的運算量。
目前只寫了一個從文件創建對象的create方法。當然,今后為了各種需求可以也可以加入其他的比如createWithSpriteFrame等等。
有了一個可設定頂點坐標的類,接下來就可以編寫物件類:BeatObject類了。根據框架設計,畫面表現部分和數據部分是分開的,所以BeatObject類不能存放這個BeatObject出現的時間等數據,外部僅能改變BeatObject的位置。
當外部調用BeatObject的setPositionY方法(物件只會縱向移動,不應當修改x坐標)時,應當會做如下操作:
1、 計算出頭的縮放值;
2、 如果這個BeatObject的類型是Strip,則根據BeatObject的長度計算出尾部的坐標和縮放值;
3、 如果這個BeatObject的類型是Strip,再計算出中間部分的四個頂點坐標。
四個頂點的坐標和尾部的縮放怎么計算呢?請看圖:
如圖所示是一個Strip物件的示意圖。下面的圓是頭部,上面的圓是尾部,中間紅色的梯形就是我們要進設置頂點的中間部。TL, BL, TR, BR則是四個頂點,直接對應_quad成員中的四個成員。
Length值是由外部設置進去的,表示頭部圓心到尾部圓心的長度,也就是梯形的高。頭部圓的縮放的公式上文推導出了,尾部圓的縮放公式則是
-0.0025 * (y + length)
y值就是這個坐標系中的原點相對於Colume節點的高度。
我使用的圖中,頭部尾部的圖像雖然是128px×128px,但是圓圈本身有個外發光,導致圓圈實際沒有128px那么大。這里我取124px作為中間部一倍縮放寬度。實際制作的時候,這個寬度值應當根據使用的圖像作出適當調整。於是,四個頂點的坐標取值如下:
TL: x = -尾部縮放 × 124 / 2, y = length
BL: x = -頭部縮放 × 124 / 2, y = 0
TR: x = 尾部縮放 × 124 / 2, y = length
BR: x = 頭部縮放 × 124 / 2, y = 0
我使用一個enum來區分BeatObject的類型。這個enum存放在Common.h中,因為在使用物件數據的時候還會用上。圈叫Block因為在傳統下落式音樂游戲中那玩意叫“塊”。Common.h的內容如下:
#ifndef __COMMON_H__ #define __COMMON_H__ enum BeatObjectType : int { Invalid = 0x0000, Block = 0x0001, Strip = 0x0002, SameTime = 0x0004, Star = 0x0008 }; #define WASSERT(__COND__) if(!(__COND__)){ DebugBreak(); } #endif // __COMMON_H__
WASSERT宏用於在斷言符合時產生一個斷點,而不是生成報錯對話框,這樣可以方便調試。
如下是BeatObject類的代碼,首先是頭文件:
#ifndef __BEAT_OBJECT_H__ #define __BEAT_OBJECT_H__ #include "cocos2d.h" #include "Common.h" #include "VertexSprite.h" USING_NS_CC; class BeatObject : public Node { public: /* * 創建一個BeatObject實例 * @param pType BeatObject類型,參考BeatObjectType * @param pLength BeatObject的長度,僅當該實例為Strip類型時有效 */ static BeatObject* create(int pType, float pLength = 0); ~BeatObject(){} public: // Getter bool IsBlock(); bool IsStrip(); public: // Setter void setPositionY(float y) override; void setRotation(float rotation) override; private: BeatObject(); bool init(int pType, float pLength = 0); // 不允許外部修改BeatObj的坐標 void setPosition(const Vec2& position){ Node::setPosition(position); } void setPositionX(float x){ Node::setPositionX(x); } private: int m_nType; Sprite* m_pHead; Sprite* m_pTail; VertexSprite* m_pBody; float m_fLength; float m_fCurLength; }; #endif // __BEAT_OBJECT_H__
實現:
#include "BeatObject.h" namespace { inline float GetMoveScale(float pY) { if (pY >= 0) { return 0; } return -0.0025f * pY; } inline bool TypeContains(int pType, const BeatObjectType& pTarType) { return (pType & pTarType) == pTarType; } } ////////////////////////////////////////////////////////////////////////// // BeatObject BeatObject::BeatObject() : m_nType(BeatObjectType::Invalid) , m_pHead(nullptr) , m_pTail(nullptr) , m_pBody(nullptr) , m_fLength(0) , m_fCurLength(0) { } BeatObject* BeatObject::create(int pType, float pLength /* = 0 */) { auto ret = new BeatObject(); if (ret->init(pType, pLength)) { ret->autorelease(); } else { CC_SAFE_DELETE(ret); } return ret; } bool BeatObject::init(int pType, float pLength /* = 0 */) { if (!Node::init()) { return false; } this->m_nType = pType; WASSERT(TypeContains(this->m_nType, BeatObjectType::Invalid)); // 不允許對Block類型設置Length // 以及不允許設置Strip類型的Length小於等於0 // if (pLength > 0) { WASSERT(this->IsStrip()); } else if (pLength < 0) { WASSERT(false); } this->m_fLength = pLength; if (this->IsStrip()) { this->m_pBody = VertexSprite::create("Strip_Body.png"); this->m_pTail = Sprite::create("Strip_Tail.png"); this->m_pBody->setAnchorPoint(Vec2(0.5f, 0)); this->addChild(this->m_pBody); this->addChild(this->m_pTail); this->m_pTail->setVisible(false); } this->m_pHead = Sprite::create("Block.png"); this->addChild(this->m_pHead); if (TypeContains(this->m_nType, BeatObjectType::Star)) { auto s = Sprite::create("Star.png"); s->setPosition(this->m_pHead->getContentSize() / 2); this->m_pHead->addChild(s); } if (TypeContains(this->m_nType, BeatObjectType::SameTime)) { auto st = Sprite::create("SameTime.png"); st->setPosition(this->m_pHead->getContentSize() / 2); this->m_pHead->addChild(st); } return true; } bool BeatObject::IsBlock() { return TypeContains(this->m_nType, BeatObjectType::Block); } bool BeatObject::IsStrip() { return TypeContains(this->m_nType, BeatObjectType::Strip); } void BeatObject::setPositionY(float y) { Node::setPositionY(y); // 設置圓圈的縮放。若縮放太小直接不顯示 // auto headScale = GetMoveScale(y); this->m_pHead->setScale(headScale); this->m_pHead->setVisible(headScale > 0.05f); // 如果該物件是一個Strip,則需要處理其身體和尾部 // if (this->IsStrip()) { // 模擬無限遠處飛來的效果,保證尾部的y坐標小於0 // if (y + this->m_fLength > 0) { this->m_fCurLength = -y; } else if (this->m_fCurLength != this->m_fLength) { this->m_fCurLength = this->m_fLength; this->m_pTail->setPositionY(this->m_fLength); } auto tailScale = GetMoveScale(this->getPositionY() + this->m_fCurLength); this->m_pTail->setScale(tailScale); this->m_pTail->setVisible(tailScale > 0.05f); auto harfHeadWidth = headScale * 124 / 2.0f; auto harfTailWidth = tailScale * 124 / 2.0f; this->m_pBody->SetVertex( Vec2(-harfTailWidth, this->m_fCurLength), Vec2(-harfHeadWidth, 0), Vec2(harfTailWidth, this->m_fCurLength), Vec2(harfHeadWidth, 0)); } } void BeatObject::setRotation(float rotation) { this->m_pHead->setRotation(rotation); }
BeatObject類開放並重寫setPositionY方法,屏蔽setPosition和setPositionX,不允許外部直接修改BeatObject的坐標。這里還重寫了setRotation方法,但是並沒有調用基類的setRotation,原因后面來講。
BeatObject類目前就是這樣了,今后需要其他功能再逐漸添加。接下來我們創建BeatObject的父節點類:BeatObjectColume。
每個物件在運動的時候,都不會離開它們所在的列。換句話說,就是一個在第一列的物件,在任何時候都不會跑到第二列去。BeatObjectColume類就表示一個列。這個類目前比較簡單,我就直接放代碼了,頭文件:
#ifndef __BEAT_OBJECT_COLUME_H__ #define __BEAT_OBJECT_COLUME_H__ #include "cocos2d.h" #include "BeatObject.h" USING_NS_CC; class BeatObjectColume : public Node { public: CREATE_FUNC(BeatObjectColume); ~BeatObjectColume(); public: void AddBeatObject(BeatObject* pObj); void ClearObjects(); void SetObjectPositionY(int pIndex, float pY); private: void addChild(Node *child){ Node::addChild(child); } private: BeatObjectColume(); bool init(); private: std::vector<BeatObject*> m_BeatObjList; }; #endif // __BEAT_OBJECT_COLUME_H__
實現:
#include "BeatObjectColume.h" BeatObjectColume::BeatObjectColume() { } bool BeatObjectColume::init() { if (!Node::init()) { return false; } return true; } void BeatObjectColume::AddBeatObject(BeatObject* pObj) { pObj->setRotation(-this->getRotation()); this->addChild(pObj); this->m_BeatObjList.push_back(pObj); } void BeatObjectColume::ClearObjects() { for (auto it : this->m_BeatObjList) { it->removeFromParent(); } this->m_BeatObjList.clear(); } void BeatObjectColume::SetObjectPositionY(int pIndex, float pY) { WASSERT(pIndex >= 0 && pIndex < this->m_BeatObjList.size()); this->m_BeatObjList.at(pIndex)->setPositionY(pY); } BeatObjectColume::~BeatObjectColume() { this->m_BeatObjList.clear(); }
注意在AddBeatObject這個方法中,對添加進去的BeatObject進行了一個旋轉處理。為什么呢?因為列是呈扇形分開的,除開最中間的列,其他列都進行過旋轉處理。而我們看視頻截圖:
可以看出在列旋轉后,對於Block物件,它相對於屏幕其實是沒有旋轉的,對於Strip物件(圖中沒有),它的頭對於屏幕也是沒有旋轉的。所以在添加BeatObject的時候,要對它進行一個和Colume方向相反,大小相同的旋轉。而因為Strip物件只有頭部進行了旋轉,所以上文說的不必調用基類的setRotation。
然后我們在LiveScene類中加入一些東西,看看我們實現的成果(因為是臨時使用的代碼,變量使用不太規范)。頭文件:
#ifndef __LIVE_SCENE_H__ #define __LIVE_SCENE_H__ #include "cocos2d.h" #include "BeatObject.h" #include "BeatObjectColume.h" USING_NS_CC; class LiveScene : public cocos2d::Layer { public: // there's no 'id' in cpp, so we recommend returning the class instance pointer static cocos2d::Scene* createScene(); // Here's a difference. Method 'init' in cocos2d-x returns bool, instead of returning 'id' in cocos2d-iphone virtual bool init(); // implement the "static create()" method manually CREATE_FUNC(LiveScene); private: void update(float dt); private: BeatObjectColume* m_pColume; }; #endif // __LIVE_SCENE_H__
實現:
#include "LiveScene.h" Scene* LiveScene::createScene() { // 'scene' is an autorelease object auto scene = Scene::create(); // 'layer' is an autorelease object auto layer = MainScene::create(); // add layer as a child to scene scene->addChild(layer); // return the scene return scene; } // on "init" you need to initialize your instance bool LiveScene::init() { if (!Layer::init()) { return false; } // 加入背景圖 // auto bg = Sprite::create("bg.jpg"); bg->setPosition(480, 320); this->addChild(bg); // 加上黑色半透明蒙層 // auto colorLayer = LayerColor::create(Color4B(0, 0, 0, 192)); this->addChild(colorLayer); // 加上一個列 // this->m_pColume = BeatObjectColume::create(); this->m_pColume->setPosition(480, 480); this->addChild(this->m_pColume); // 添加一個BeatObject // 如果要添加Block類的Object,則 // auto obj = BeatObject::create(BeatObjectType::Block); auto obj = BeatObject::create(BeatObjectType::Strip | BeatObjectType::SameTime, 256); this->m_pColume->AddBeatObject(obj); this->scheduleUpdate(); return true; } float moveY = 0; void LiveScene::update(float dt) { this->m_pColume->SetObjectPositionY(0, moveY); moveY -= 4; if (moveY < -960) { moveY = 0; } }
編譯運行,如果沒有問題的話,看到這樣的效果:
這一章就結束了。下一章我們來編寫一個BeatObjectManager,將所有的Object管理起來,並通過數據顯示我們想要的譜面。
本章所用到的資源:http://pan.baidu.com/s/1dDk0TXZ