WonderPlanet DEVELOPER BLOG

ワンダープラネットの開発者ブログです。モバイルゲーム開発情報を発信。

Cocos2d-x でカルーセルを作る

おつかれさまです。藤澤です。

前回のブログのときには事前登録中だったクラッシュフィーバーですが、おかげさまで多くの皆さまにプレイしていただいております。皆さまありがとうございます。また、いろいろと不具合でご迷惑をおかけしており誠に恐れ入ります。不具合修正に加えて機能追加も鋭意行なっておりますので、いましばらくお待ちいただければ幸いです。

さて今回は、そのクラッシュフィーバーでも使用しておりますカルーセルの作り方をご紹介したいと思います。

考え方は簡単です。

  1. Node を円形に配置する。
  2. y 軸方向の半径を縮めて楕円にする。
  3. 手前の Node を大きくし、奥の Node を小さく、半透明にするとそれっぽく見えるはず。

では実際にやってみましょう。

  1. まず適当に Node を作り、円周上に等間隔に配置します。
    Vector<Node *> items;  
    items.pushBack(Label::createWithSystemFont("1", "Arial", 72));  
    items.pushBack(Label::createWithSystemFont("2", "Arial", 72));  
    items.pushBack(Label::createWithSystemFont("3", "Arial", 72));  
    items.pushBack(Label::createWithSystemFont("4", "Arial", 72));  
    items.pushBack(Label::createWithSystemFont("5", "Arial", 72));  
      
    for (auto item : items)  
        this->addChild(item);  
      
    float theta = 360.0f / items.size();  
    for (int i = 0; i < items.size(); i++)  
    {  
        // 270 度の位置が正面にくるように  
        float angle = theta * i + 270.0f;  
        float radians = angle * PI / 180.0f;  
        float x = RADIUS * cos(radians);  
        float y = RADIUS * sin(radians);  
        items.at(i)->setPosition(Vec2(x, y));  
    }  

  1. y 軸方向を潰してみます。
        float y = RADIUS * sin(radians) * FLATTEN_RATE;  

  1. y 座標に応じて scale と opacity を調整します。ついでに手前の Node の Z オーダが大きくなるようにしておきます。
    float theta = 360.0f / items.size();  
    for (int i = 0; i < items.size(); i++)  
    {  
        // 270 度の位置が正面にくるように  
        float angle = theta * i + 270.0f;  
        float radians = angle * PI / 180.0f;  
        float x = RADIUS * cos(radians);  
        float y = RADIUS * sin(radians) * FLATTEN_RATE;  
        float radiusY = RADIUS * FLATTEN_RATE;  
        float diameterY = radiusY * 2;  
        float scale = (diameterY - y) / diameterY;  
        GLubyte opacity = 255 - (y + radiusY);  
        items.at(i)->setPosition(Vec2(x, y));  
        items.at(i)->setScale(scale);  
        items.at(i)->setOpacity(opacity);  
        items.at(i)->setZOrder(diameterY - y);  
    }  

それっぽくなりました。
次にこれを動かしてみたいと思います。
これも考え方はシンプルで、スワイプにあわせて回転角度を変更するだけです。

bool Carousel::init()  
{  
    if (!Node::init())  
        return false;  
      
    this->items.clear();  
    this->items.pushBack(Label::createWithSystemFont("1", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("2", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("3", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("4", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("5", "Arial", 72));  
      
    for (auto item : items)  
        this->addChild(item);  
      
    this->angle = 0.0f;  
    this->arrange();  
      
    EventListenerTouchOneByOne *listener = EventListenerTouchOneByOne::create();  
    listener->onTouchBegan = [](Touch *touch, Event *event){ return true; };  
    listener->onTouchMoved = [&](Touch *touch, Event *event){  
        float delta = touch->getLocation().x - touch->getPreviousLocation().x;  
        this->angle += delta;  
        this->arrange();  
    };  
    this->getEventDispatcher()->addEventListenerWithSceneGraphPriority(listener, this);  
      
    return true;  
}  
  
void Carousel::arrange()  
{  
    float theta = 360.0f / items.size();  
    float baseAngle = this->angle + 270.0f;  
    for (int i = 0; i < items.size(); i++)  
    {  
        // 270 度の位置が正面にくるように  
        float angle = theta * i + baseAngle;  
        float radians = angle * PI / 180.0f;  
        float x = RADIUS * cos(radians);  
        float y = RADIUS * sin(radians) * FLATTEN_RATE;  
        float radiusY = RADIUS * FLATTEN_RATE;  
        float diameterY = radiusY * 2;  
        float scale = (diameterY - y) / diameterY;  
        GLubyte opacity = 255 - (y + radiusY);  
        this->items.at(i)->setPosition(Vec2(x, y));  
        this->items.at(i)->setScale(scale);  
        this->items.at(i)->setOpacity(opacity);  
        this->items.at(i)->setZOrder(diameterY - y);  
    }  
}  

最後に、指を離したら勝手にホームポジションに戻るようにしてみたいと思います。
ScrollView の実装を参考に、所定の位置にくるまで再帰的に移動させます。

    EventListenerTouchOneByOne *listener = EventListenerTouchOneByOne::create();  
    listener->onTouchBegan = [](Touch *touch, Event *event){ return true; };  
    listener->onTouchMoved = [&](Touch *touch, Event *event){  
        float delta = touch->getLocation().x - touch->getPreviousLocation().x;  
        this->angle += delta;  
        this->arrange();  
    };  
    listener->onTouchEnded = [&](Touch *touch, Event *event){  
        // Z オーダが一番大きい(= 一番手前の)オブジェクトを探す  
        int maxZ = 0;  
        ssize_t frontIndex = 0;  
        for (int i = 0; i < this->items.size(); i++)  
        {  
            if (this->items.at(i)->getZOrder() > maxZ)  
            {  
                maxZ = this->items.at(i)->getZOrder();  
                frontIndex = i;  
            }  
        }  
          
        float theta = 360.0f / this->items.size();  
        float angleToMove = 360.0f - theta * frontIndex;  
          
        // 逆回転防止  
        if (this->angle > 180.0f) this->angle -= 360.0f;  
        if (this->angle < -180.0f) this->angle += 360.0f;  
        if (angleToMove - this->angle > 180.0f) angleToMove -= 360.0f;  
        if (angleToMove - this->angle < -180.0f) angleToMove += 360.0f;  
          
        auto moveToHome = [=](float){  
            if (fabs(angleToMove - this->angle) <= 0.1f)  
            {  
                this->unschedule("moveToHome");  
                this->angle = angleToMove;  
                this->arrange();  
                return;  
            }  
              
            float delta = (angleToMove - this->angle) * 0.1f;  
            this->angle += delta;  
            this->arrange();  
        };  
        this->schedule(moveToHome, 0.0f, "moveToHome");  
    };  
    this->getEventDispatcher()->addEventListenerWithSceneGraphPriority(listener, this);  

それっぽく動くようになりました。
実際のプロダクトではスムーズに動くようにいろいろと調整をしておりますが、基本的な実装としてはこれだけです。意外と簡単ですよね。

今回のコードの全体を載せておきます。

#include "cocos2d.h"  
  
class Carousel : public cocos2d::Node  
{  
private:  
    cocos2d::Vector<cocos2d::Node *> items;  
    float angle;  
      
    bool init();  
      
    void arrange();  
      
public:  
    static Carousel* create();  
      
};  
#include "Carousel.h"  
  
using namespace cocos2d;  
  
#define PI 3.14159265359f  
#define RADIUS 100  
#define FLATTEN_RATE 0.4f  
  
Carousel* Carousel::create()  
{  
    Carousel *instance = new Carousel();  
    instance->init();  
    instance->autorelease();  
    return instance;  
}  
  
bool Carousel::init()  
{  
    if (!Node::init())  
        return false;  
      
    this->items.clear();  
    this->items.pushBack(Label::createWithSystemFont("1", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("2", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("3", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("4", "Arial", 72));  
    this->items.pushBack(Label::createWithSystemFont("5", "Arial", 72));  
      
    for (auto item : items)  
        this->addChild(item);  
      
    this->angle = 0.0f;  
    this->arrange();  
      
    EventListenerTouchOneByOne *listener = EventListenerTouchOneByOne::create();  
    listener->onTouchBegan = [](Touch *touch, Event *event){ return true; };  
    listener->onTouchMoved = [&](Touch *touch, Event *event){  
        float delta = touch->getLocation().x - touch->getPreviousLocation().x;  
        this->angle += delta;  
        this->arrange();  
    };  
    listener->onTouchEnded = [&](Touch *touch, Event *event){  
        // Z オーダが一番大きい(= 一番手前の)オブジェクトを探す  
        int maxZ = 0;  
        ssize_t frontIndex = 0;  
        for (int i = 0; i < this->items.size(); i++)  
        {  
            if (this->items.at(i)->getZOrder() > maxZ)  
            {  
                maxZ = this->items.at(i)->getZOrder();  
                frontIndex = i;  
            }  
        }  
          
        float theta = 360.0f / this->items.size();  
        float angleToMove = 360.0f - theta * frontIndex;  
          
        // 逆回転防止  
        if (this->angle > 180.0f) this->angle -= 360.0f;  
        if (this->angle < -180.0f) this->angle += 360.0f;  
        if (angleToMove - this->angle > 180.0f) angleToMove -= 360.0f;  
        if (angleToMove - this->angle < -180.0f) angleToMove += 360.0f;  
          
        auto moveToHome = [=](float){  
            if (fabs(angleToMove - this->angle) <= 0.1f)  
            {  
                this->unschedule("moveToHome");  
                this->angle = angleToMove;  
                this->arrange();  
                return;  
            }  
              
            float delta = (angleToMove - this->angle) * 0.1f;  
            this->angle += delta;  
            this->arrange();  
        };  
        this->schedule(moveToHome, 0.0f, "moveToHome");  
    };  
    this->getEventDispatcher()->addEventListenerWithSceneGraphPriority(listener, this);  
      
    return true;  
}  
  
void Carousel::arrange()  
{  
    float theta = 360.0f / items.size();  
    float baseAngle = this->angle + 270.0f;  
    for (int i = 0; i < items.size(); i++)  
    {  
        // 270 度の位置が正面にくるように  
        float angle = theta * i + baseAngle;  
        float radians = angle * PI / 180.0f;  
        float x = RADIUS * cos(radians);  
        float y = RADIUS * sin(radians) * FLATTEN_RATE;  
        float radiusY = RADIUS * FLATTEN_RATE;  
        float diameterY = radiusY * 2;  
        float scale = (diameterY - y) / diameterY;  
        GLubyte opacity = 255 - (y + radiusY);  
        this->items.at(i)->setPosition(Vec2(x, y));  
        this->items.at(i)->setScale(scale);  
        this->items.at(i)->setOpacity(opacity);  
        this->items.at(i)->setZOrder(diameterY - y);  
    }  
}