上一章分析了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

