ホーム < ゲームつくろー! < IKD備忘録

Cocos2d-x
「パネルを置く」をリファクタリング

(2015. 2. 16)


 前章まででパネルを表示して画面をクリック(タップ)したらその位置にパネルを置き、さらに3連以上連なっていたら光らせるという所までを作ってきました。どんどん作っていける流れではありますが、しかし、このまま進めるのは危険です。少しコードが汚れてきていますし、先の仕様(パネルを置いた後に他のパネルが自動的に置かれる、対戦相手がNPCとか)を考えると、現在のコードをリファクタリングした方が賢明です。



@ 「置く」について考える

 現在パネルはタップしたスクリーン座標位置とパネルオブジェクトをBoard::addPanelメソッドに渡す事で指定のマス位置に置かれます。内部でスクリーン座標がマス位置に変換されています。パネルを追加するメソッドはこれだけなので、このままだとNPCなどのAIがパネルを置くのに困ります。また○×Evolutionのルールには「3連以上連なった列の前後に相手のパネルが置かれる」というのがあり、これもスクリーン座標では無くマス位置を直接指定出来た方が便利です。そこで、マス位置を指定してパネルを置くaddPanelToCellメソッドを追加しましょう:

board.cpp
// 盤座標にパネルを追加
bool Board::addPanelToCell( Panel *panel, unsigned cellX, unsigned cellY ) {

    if ( cellX >= cellWidth_ || cellY >= cellHeight_ )
        return false;

    // パネルの相対スクリーン座標を算出
    panel->setPosition( cellWidth_ * (cellX + 0.5f), cellHeight_ * (cellY + 0.5f) );
    this->addChild( panel );

    panels_[getId( cellX, cellY )] = panel;

    // パネルが置けた時のコールバック
    if ( onPanel )
        onPanel( cellX, cellY, panel );

    return true;
}

このように盤座標ベースなパネル追加メソッドがあれば、これまでのスクリーン座標ベースの追加メソッドの一部をこのメソッド呼び出しで置きかえられます:

board.cpp
// 指定スクリーン座標のマスにパネルを追加
bool Board::addPanel( Panel *panel, float x, float y ) {

    unsigned px, py;
    if ( isAdd( x, y, px, py ) == false )
        return false;

    return addPanelToCell( panel, px, py );
}

このように実装の2重化を割けるのはリファクタリングの基本の一つです。



A 「置く」のは誰か?

 現在パネルはタッチ位置にあるマスに置かれます。現在の実装はこちら:

GameLayer::init()
// 画面をタップしたらパネルを置く
auto listener = EventListenerTouchOneByOne::create();
listener->onTouchBegan = [&]( Touch *touch, Event *unused_event ) {

    Vec2 cp = touch->getLocation();

    if ( board_->isAdd(cp.x, cp.y) == true ) {

        // タップ位置にパネルを配置
        Panel *panel_ = Panel::create();
        panel_->setMotion( 0, 0.0f );
        panel_->selectType( rand() % 2 ? Panel::Type_Maru : Panel::Type_Peke );
        board_->addPanel( panel_, cp.x, cp.y );
    }

    return true;
};

 レイヤーの初期化メソッド(init)内で、画面のどこかをタッチしたらその座標に配置可能かをboard_(Boardクラス)に調べてもらい、置ける事がわかったらパネルオブジェクトを作ってボードに渡しています。

 この実装はパネルを置く人を「プレイヤー」に限定しています。しかし、パネルは、

このように人の手だけでなくプログラム上からも直接置く事が予想されます。そこで、上の「置く」という作業をGameLayer::addPanelメソッドとして分離すると使い回しが利きます:

GameLayer::init()
void GameLayer::init() {

    ....

    // 画面をタップしたらパネルを置く
    auto listener = EventListenerTouchOneByOne::create();
    listener->onTouchBegan = [&]( Touch *touch, Event *unused_event ) {

        Vec2 cp = touch->getLocation();

        unsigned px, py;
        if ( board_->isAdd( cp.x, cp.y, px, py ) == true )
            addPanel( px, py, rand() % 2 ? Panel::Type_Maru : Panel::Type_Peke );
    };
}

void GameLayer::addPanel( unsigned px, unsigned py, Panel::Type type ) {

    // タップ位置にパネルを配置
    Panel *panel_ = Panel::create();
    panel_->setMotion( 0, 0.0f );
    panel_->selectType( type );
    board_->addPanel( panel_, cp.x, cp.y );

    return true;
};

addPanelメソッドの引数はマス位置とパネルのタイプです。このようにオブジェクトでは無くて単なる値(数値、文字列)を引数にすると、例えば外部のファイルから読み込んだデータをそのままメソッドの引数に引き渡せるため便利です。これでプログラム上からも直接パネルを配置できるようになりました。



B リファクタリングの本丸「タスク化」

 さて、パネルを置くべくaddPanelメソッドを呼んだ後には次のようなプロセスが待っています:

光らせる所までは出来ていますが、列の前後に相手のパネルを置くというのはまだありません。しかも、列の前後には最大2枚の相手のパネルが「同時」に配置されます。その置かれた相手のパネルが3連以上になれば、それはまた連鎖を生むかもしれません。今の実装だと連鎖をチェックして行く事は出来ますが、すべての演出が1フレームで全部出てしまいます。それだと見栄えとしてはあんまり良く無い訳です。やっぱりこんな感じに数フレームをかけて連鎖して行って欲しいのです:

こういう時間差的な事を実現しようと思ったらタスク処理を入れないと煩雑になってしまいます。



C C++11時代のタスクは「function」ベースでもいけるぜ

 C++でタスクをやるには「関数ポインタ」「タスククラス」等を駆使してきました。関数ポインタを使うタスクは関数ポインタを保持するリストにすべき事(関数)を積み、毎フレームそれを呼び出す事でタスク処理を実現していました。しかし、この方法はオブジェクト的な指向では無いため、ローカルな変数もグローバルな所に置く必要があります。それを解消するのがタスククラスで、クラス内にローカルなメンバ変数を置き、グローバルな変数は外部から参照する事で関数ポインタタスクよりも変数の扱いがカプセル化されます。また仮想更新メソッドを呼び出す事でどのようなタスクも同じ処理で動きます。ただ、タスクというのはとにかく変化に富むものなので、タスククラスの派生形が山ほど出来てしまうのがちょっと煩雑でした。

 C++11では「function」が使えます。functionはSTLの一つで、テンプレートに渡した関数型を抽象的に呼び出す事ができます。それだけだと関数ポインタと大して変わらないのですが、functionは「ラムダ式」の呼び出しも出来るのが大きな違いです。ラムダ式は例えば次のような不思議な事が出来ます:

#include <functional>

std::function< int(int) > getFunctor() {

     int b = 10;

    auto functor = [=]( int a ) {
        return a + b;
    };


    return functor;
}

int main() {

    auto functor = getFunctor();
    printf( "%d", functor(20) );  // "30"と出力!

    return 0;
}

 上の赤太文字がラムダ式です。この不思議な表記は一式で「関数」を表しています。ラムダ式の中では「a + b」と引数の値に対してbを足した値を返しています。main関数の中ではgetFunctor関数でラムダ式を取得しています。で、通常の関数と同様に引数に20を渡すとちゃんと"30"が返って来るんです。これ、凄く不思議です。だって、getFunctor関数の中のbはローカル変数なので、関数を抜けたら破棄されてしまうはずです。でもfunctor(20)の呼び出しの中ではbの値が保存されています。この「ローカルな値を関数内に保存してくれる」というのがラムダ式の最大の特徴です。これは「クロージャー」と呼ばれるレガシーC++には無かった新しい能力なんです。

 この性質を用いれば、関数ポインタとタスククラスの合いの子のようなローカルタスクシステムを構築する事ができます。例えば次のサンプルをご覧ください:

#include <memory>

// 指定回数だけ弾を撃つ
void Enemy::createShotTask( Bullet *buttet, float interval, int num ) {

    auto curTime = std::make_shared<float>( interval );
    auto curNum  = std::make_shared<unsigned>( 0 );

    // 弾打ちタスクを登録
    functors_.push_back( [=]( float deltaSec ) {

        if ( ( *curTime += deltaSec ) >= interval ) {

            buttet->shot();   // 弾を撃つ!
           
            if ( ++*curshotNum >= num )
                return false;   // タスク終了

           *curTime -= interval;
        }
    });
}

void update( float delta ) {

    // タスク更新
    std::list< std::function< bool(float) > >::iterator it = functors_.begin();

    for ( ; it != functors_.end(); ) {
        if ( (*it)( delta ) == false )
            it = functors_.erase( it );
        else
            it++;
    }
}

 createShotTaskメソッド内で指定の弾を指定間隔で指定回数だけ撃つラムダ式を作成しています。現在のinterval時間や撃った弾数などタスク内だけで管理して欲しい値はローカル変数に定義するのですが、それを「shared_ptr」として宣言します。なぜそうするかと言うと、ラムダ式内で値を変更する目的で使用するからです。ラムダキャプチャを[&](スコープ内の変数を参照する)にするとローカル変数も変更できるようにはなりますが、これは御法度です。createShotTaskメソッドを抜けるとローカル変数は破棄されてしまうからです。その参照を使うともちろんメモリを破壊してしまいます。だからラムダキャプチャを[=]にしてshared_ptrをコピーしてラムダ式内で使用しています。

 updateメソッドで登録されているタスクラムダ式の更新を行っています。



D 「パネルを置く」をタスク化する

 ではパネルを置く作業をfunctionベース(ラムダ式ベース)なタスクとして投げてみます。

 addPanelメソッドは、今呼ばれたらパネルを指定の座標に直ちに置くメソッドになっていますが、この実装の中身をそっくりラムダ式内に移し、メソッド内でタスクへ登録するようにします:

GameLayer::init()
// 時間を置いてFunctorを始動
void GameLayer::wait( float startTime, Functor functor ) {

    auto st = std::make_shared<float>( startTime );

    auto waitFunctor = [=]( float delta ) mutable {

        if ( (*st -= delta) <= 0.0f ) {
            functors_.push_back( functor );
            return false;
        }
        return true;
    };

    functors_.push_back( waitFunctor );
}


// パネルを追加してチェックを走らせる
void GameLayer::addPanel( float startTime, unsigned px, unsigned py, Panel::Type type ) {

    auto functor = [=]( float delta ) {

    // 置ける?
    if ( board_->isAddToCell( px, py ) == false )
        return false;

        // 置かれた位置をMatchN判定機に登録
        if ( matchN_->setVal( px, py, type ) == false ) {
            log( "not place: %d, %d", px, py );
            return false; // 終了
        }

        log( " place: %d, %d", px, py );

        // 指定位置にパネルを配置
        Panel *panel_ = Panel::create();
        panel_->setMotion( 0, 0.0f );
        panel_->selectType( type );
        board_->addPanelToCell( panel_, px, py );

        // パネルが置けたので、3連以上出来ていたら連パネルを反応させる
        std::vector< MatchN::MatchIDInfo > idArys;
        if ( matchN_->check( idArys, MatchN::All, 3, 5, px, py ) > 0 ) {
            // 列の処理
            ....
        }
        return false;
    };

    if ( startTime <= 0.0f )
        functors_.push_back( functor );
    else
        wait( startTime, functor );
}

 addPanelメソッドの第1引数に「発動までの時間」を指定するstartTimeを追加しました。発動する内容はaddPanel内のラムダ式です。もしstartTimeが0より大きな値を指定されていたらwaitメソッドにfunctorを渡して、「時間を待つ」ラムダ式にタスクをラップしてしまいます。waitメソッド内でfunctors_というラムダ式タスクのリストに登録しているのに注目です。

 後はラムダ式タスクリストを毎フレーム更新してラムダ式がfalseを返したらリストから外すようにすればOK。GameLayer内でタスクが動きだします。


 タスク化のリファクタリングによって、何かタスクを投げたら勝手に動く仕組みを手に入れました。これで時間制御による「手触り感」を細かく作り込んで行けます。ゲームは手触り感が取っても大切。そのためにタスクのような仕組みはどうしても必要なのです。