タッチイベント制御のベタープラクティス

クライアントエンジニアの梅林です。

ドリコムではゲームを中心として複数のスマートフォン向けアプリを開発運用しています。
そこでの経験を元に(主にCocos2d-xをベースとした)ゲームアプリにおける、タッチイベント制御で起こりやすい問題とベタープラクティスを整理してみたいと思います。

最初に結論:初期設計と一貫性が大事

実現方法が複数種類ある場合、場当たり的に作ると後々になって後悔することが多いです。
初期段階でしっかり設計し、設計方針を共有して皆で理解し則ることで、一貫性が保たれたシンプルな構造になって保守もしやすいと思います。

一般的なスマホゲームアプリとタッチ操作

ゲームは、必ず何らかのユーザ入力による操作を伴います。
スマホでは加速度センサーなどによるジェスチャー入力なども可能ですが、一般的には画面タッチによる操作が多くを占めると思います。

操作する対象には、キャラクター・何らかのオブジェクト・ボタンなどのUI部品のような、目に見える物と連動している場合と、画面内の特定エリア・画面内ならどこでもなど、領域だけ決まっていて(目に見える物と直接は)連動していない場合が考えられます。

タッチ操作には、タップ/ダブルタップ/ロングタップ・スライド・フリック・スワイプ・ピンチ(イン/アウト) など、多くの種類があり、なぞる・引っ張って離すなど基本操作を組み合わせたものもあります。
これらは全て、画面のセンサーがタッチ開始/タッチ中/タッチ終了を座標と共に検知して、OSを通じて一定間隔でアプリへ通知される(またはアプリから取得する)ことにより成り立っています。

Cocos2d-xにおけるタッチイベント

EventDispatcher Mechanism | Cocos2d-x
http://www.cocos2d-x.org/wiki/EventDispatcher_Mechanism

詳細は上記の公式サイト解説にお任せするとして、おおまかに

に分かれます。

※この辺りの区分けは、cocos2d-xやスマホに特化したものでは無く、ほとんどのイベント駆動によるプログラムで共通した概念かと思います。

起こりやすい問題

タッチして欲しく無いタイミング/場所で、タッチが有効になってしまう

時間軸に穴があるパターン

(A状態:タッチ可能) → (B状態:タッチ不可) → (C状態:タッチ可能)と切り替えたい際、各状態遷移を非同期で処理し各状態の頭で切り替える場合、A->Bに切り替える間(上記の矢印の部分)はタッチ可能のままだが、実はA状態が終わった際にはタッチ不可になっていて欲しかった場合。

class GameMain {
 
    STATE m_currentState = A;
 
    void onNextState() {
        ...
        switch(m_currentState) {
            case A: m_currentState = B; break;
            case B: m_currentState = C; break;
            case C: ...
        }
    }
 
    void stateA() {
        setTouchEnable();
        ...
        EventManager->sendEvent(CHANGE_NEXT_STATE);
    }
 
    void stateB() {
        setTouchDisable();
        ...
        EventManager->sendEvent(CHANGE_NEXT_STATE);
    }
 
    void stateC() {
        setTouchEnable();
        ...
    }
}

領域に穴があるパターン

タッチ不可のLayerを上から重ねるが、ハミ出している部分がある。

#define WIN_WIDTH  320
#define WIN_HEIGHT 480
...
const int width = 200
const int height = 300
m_layer = ConsumeTouchLayer::create(width, height);
m_layer->setTouchPriority(HIGHEST);

タッチしたいタイミング/場所で、タッチが効かない

戻し忘れパターン

イベント受け取りを解除してから、再登録されていない。
通知優先順序を下げてから、上げていない。
イベントを消費するタッチ不可のLayerが残ったまま。

void setTouchDisable() {
    EventManager->unregisterListener(this);
}
 
void nextProcess() {
    // 何もしていない(再登録を忘れている)
    ...
}
 
...
  
void setTouchDisable() {
    this->setTouchPriority(LOWEST);
}
  
void nextProcess() {
    // 何もしていない(優先度を上げ忘れている)
    ...
}
 
...
  
class GameScene {
  
    ConsumeTouchLayer* m_layer;
  
    void stateA() {
        m_layer = ConsumeTouchLayer::create();
        ...
        // 何もしていない(タッチ不可Layerを消し忘れている)
    }
  
    void stateB() {
        ...
    }
}

これらは、操作する対象とタイミングが多いほど複雑になってきます。

最初に作った時は完璧でも

などなどの変更を、複数人が対応する場面を想像してみて下さい。

ベタープラクティス

要求仕様の見える化

ゲーム開発においては、動くものを素早く作ってブラッシュアップしていくことが多いため、“期待する仕様”を明記する機会がなかなか持てないですが、少し時間が空いた際に(詳しく覚えているうちに)書き残しておくと重宝します。
もちろん書き残す場所は、ローカルマシンのメモでは無く、検索性が高くて必要な人がアクセスしやすい場所へ書き残しましょう。

状態管理に“環境依存で実行タイミングが変動する非同期処理”を使わない

Cocos2d-xにおいては、「Scene遷移時の初期化と終了」や「schedule登録したメソッドと非登録メソッド」のそれぞれでタッチイベント可否を制御すると、環境や状況によって実行順序が前後して意図しない動作となる場合があるので、一連の処理として実行順序が保証されている方法を用いると良いと思います。

下記は悪い例です。

class GameSelectScene {
 
    void nextScene() {
        replaceScene(new GameStageScene()));
    }
     
    void onExit() {
        ...
        setTouchDisable();
        ...
    }
  
    ~GameSelectScene() {
        CC_SAFE_DELETE( ... );
    }
}
 
class GameStageScene {
 
    GameStageScene() { ... }
  
    void onEnter() {
        ...
        setTouchEnable();
        ...
    }
}

上記に対して下記は良い例です。

class GameSelectScene {
 
    void nextScene() {
        setTouchDisable();  // ここならGameStageSceneのsetTouchEnable()よりも後にコールされることは無い
        replaceScene(new GameStageScene()));
    }
 
    void onExit() {
        ...
    }
 
    ~GameSelectScene() {
        CC_SAFE_DELETE( ... );
    }
}
 
class GameStageScene {
 
    GameStageScene() { ... }
  
    void onEnter() {
        ...
        setTouchEnable();
        ...
    }
}

タッチ判定領域とタッチ不可領域に色づけ出来るデバッグ機能を仕込んでおく

目に見えるものと連動している場合は分かりやすいのですが、領域だけの場合は穴に気づきにくいので、1箇所の設定変更で色付けできるデバッグ機能を持った基底クラスを継承して作っておくと簡単に見える化できて便利です。

class BaseSetting : public CCLayerColor {
  
#if defined(DEBUG_TOUCH_VISUALIZATION)
    BaseSetting() {
        schedule(schedule_selector(BaseSetting::onUpdate), 1/60.f);
    }
 
    void onUpdate(float elapsedTime) {
        if(isTouchEnabled()) {
            setColor( ccBLUE );
        }
        else {
            setColor( ccGRAY );
        }
    }
#endif
}
  
class GameMainLayer : public BaseSetting { ... }
class GameMainButton : public BaseSetting { ... }
class ConsumeTouchLayer : public BaseSetting { ... }

いかがでしたでしょうか。

明日からすぐに使えるTipsという訳では無いのですが、タッチ制御はスマホアプリ開発においては避けては通れないものですので、参考にしていただけたら幸いです。