2011年11月30日水曜日

「androidで動くゲームプログラミング入門」 その2

「androidで動くゲームプログラミング入門」 その1の続きです。
ここからコードの説明に入ります。blogに載せたコードは説明と合わせて見やすいように骨組みだけにしています。実際のコードはgithubから取ってきてください。

[プログラムの初期化]
androidアプリが起動されるとまず、Activityの初期化でonCreate()が呼ばれます。onCreateではViewを初期化してActivityにセットするだけです。この部分で注意する事はViewの初期化に時間を掛けないようにする事です。やってはいけない代表的な例はネットワーク経由でデータをダウンロードする(ほんの少しでもダメです)。大量のデータをロードする事です。データをダウンロードしようとすると電波状況が悪いと5秒ぐらいすぐ経って、ANRが発生してしまいます。地下鉄に乗っている時にアプリの起動ができないというのも困ります。Viewの初期化や起動時に表示させる画像データをロードする程度に留めます。

public class SlidePuzzle1Activity extends Activity {
    private GameView gameView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        gameView = new GameView(this); //ここは速く
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(gameView);
    }

    @Override
    public void onStart() {
        super.onStart();
    }

    @Override
    public void onPause() {
        super.onPause();
    }

    @Override
    public void onStop() {
        super.onStop();
    }

    @Override
    public void onResume() {
        super.onResume();
    }
}

onCreate()からの流れを説明します。onCreate()が呼ばれるとSurefaceViewから派生したGameViewクラスを作り、setContentView()でGameViewクラスをセットします。ここでなにか画面に表示できそうな気がしますが、まだViewは作られていませので画面には何も表示されません。
この後 onStart(),onResume()が順に呼ばれ、その後にGameViewのsurfaceCreated()が呼ばれます。surfaceCreated()が呼ばれたときに初めて画面に何かを表示される事が出来ます。

[GameViewクラスの初期化]
GameViewクラスはSurfaceViewクラスを継承したものです。ゲームスレッドはこのクラスに置きます。GestureDetector.OnGestureListenerとSurfaceHolder.Callbackの実装も入れます。

public class GameView extends SurfaceView implements
                                          GestureDetector.OnGestureListener,
                                          SurfaceHolder.Callback,
                                          Runnable {
    public GameView(Activity activity) {
        super(activity);
        init();
    }

    private void init() {
        holder = getHolder();
        holder.addCallback(this);

        surfaceCreated = false;
        thread         = null;
        loader         = new DataLoader(this);
        loaded         = false;
        status         = StatusInit;

        Resources res = getContext().getResources(); 
        logoImg = BitmapFactory.decodeResource(res,R.drawable.fjtn);

        thread = new Thread(this);
        thread.start();
    }

説明するほどの事はないのですが、コンストラクタではinit()を呼び出してaddCallback()を呼びSurfaceViewのsurfaceChanged(),surfaceCreated(),surfaceDestroyed()が呼ばれるようにします。次に画像データの読み込みに使うDataLoaderクラスのインスタンスを作り。起動時に表示する画像データを読み込み、最後にスレッドを生成して初期化終了となります。
DataLoaderクラスの説明はしません。ソースコードを見れば何をしているかすぐ解ると思います。

    public void surfaceCreated(SurfaceHolder holder) {
        gestureDetector = new GestureDetector(this);
        surfaceCreated = true;

        if (initializing) {
            //起動時
            repaint();
        }

        if (!loaded) {
            status = StatusLoading;
            wakeup();
        }
    }

    public void surfaceDestroyed(SurfaceHolder holder) {
        surfaceCreated  = false;
        gestureDetector = null;
        loaded = false;
        free();
    }

surfaceCreated()は起動時にViewが作られる時以外にも、ポーズ状態(ホームボタンを押された)後の復帰時にも呼ばれます。その時の流れは Activityの onPause(),onStop()が呼ばれ、復帰時には onRestart(),onStart(),onResume(),SurfaceCreated()の順に呼ばれます。
surfaceCreated()ではGestureDetector()のインスタンスを作った後、描画をします。起動時なら起動時画像が表示され、ポーズ後ならゲーム画面を表示します。
最後にデータがロードされていないならstatusをロード中に書き換え、止めてあったスレッドを動かします。

surfaceDestroyed()はActivityのonPause()の後に呼ばれます。ここで読みこんである画像データを呼んで開放します(free()が画像データ開放メソッド)。Bitmapクラスのインスタンスはrecycle()を呼んで明示的にメモリを開放しないと解放されません。ゲームの多くでは画像データが一番メモリを消費するのではないかと思います。
ここで画像データを開放してしまうと、次にsurfaceCreated()が呼ばれた時に、再度画像をロードしなくてはいけないので再開が遅くなってしまいます。開放しないと他のアプリが動いた時にメモリが足らなくなりアプリが終了させられる可能性も高くなります。どちらを取るかは迷うところです。

ゲームスレッドの骨組みはこのようになっています。
    public void run() {
        while (thread != null) {
            if (status == StatusLoading) {
                sleep(100);
                if (loader.loadImages(getContext())) {
                    if (initializing) {
                        init2();
                        bitmapAlpha = 255;
                        status = StatusOpening;
                        initializing = false;
                    } else {
                        status = prevStatus;
                    }
                    loaded = true;
                    repaint();
                } else {
                    status = StatusError;
                }
            } else if (status == StatusOpening) {
                //オープニング表示
            } else if (status == StatusDisplayAnswer) {
                //答えの表示
            } else if (status == StatusComplete) {
                //パズル完成
            } else if (status == StatusFling) {
                //onFling()を受けて駒を動かす
            } else {
                synchronized (this) {
                    try {
                        wait();
                    } catch (Exception e) {
                    }
                }
            }
        }
    }
}

コンストラクタでstatus=StatusInitとしているのでwait()を呼び出しすぐに休眠状態に入ります。スレッドは用もないのにぐるぐるループしないように必要がない時は止めておきます。
最初のsurfaceCreated()が呼ばれるとstatus==StatusLoadingとなるので、ここでデータをロードします。最初にsleep(100)とあるのは100ミリ秒待つことで起動時画像の描画を邪魔しないようにしています。あまりスマートな方法ではないですが、簡単なやり方でよいかなと..
Activityの起動時は変数initiazilingがtrueになっていてデータをロードした後にオープニングの演出(数字がフェードアウトする)が入りますが、ポーズの後に復帰する時はstatus変数を戻してゲームが再開するようになっています。

    private void wakeup() {
        synchronized(this) {
            notifyAll();
        }
    }


これはwait()で止まっているスレッドを起こすメソッドです。いちいち synchronizedブロックを書くのが面倒なのでメソッドにしています。

[描画について]
描画については前述のように自分で行うのですが、描画をするエリアを絞って必要な部分だけ描くようにします。

    public void repaint() {
        Canvas canvas = null;

        if (!surfaceCreated) {
            return;
        }

        try {
            canvas = holder.lockCanvas();
            synchronized (holder) {
                onDraw(canvas);
            }
        } catch (Exception e) {
        } finally {
            if (canvas != null) {
                holder.unlockCanvasAndPost(canvas);
            }
        }
    }

    public void repaint(int x,int y,int w,int h) {
        Canvas canvas = null;

        if (!surfaceCreated) {
            return;
        }

        try {
            canvas = holder.lockCanvas();
            canvas.save(Canvas.CLIP_SAVE_FLAG);
            canvas.clipRect(x,y,x+w,y+h);

            synchronized (holder) {
                onDraw(canvas);
            }

            canvas.restore();
        } catch (Exception e) {
        } finally {
            if (canvas != null) {
                holder.unlockCanvasAndPost(canvas);
            }
        }
    }

surfaceCreatedという変数をチェックしていますが、GameViewのsurfaceCreated()が呼ばれる前にrepaint()が呼ばれるとholder.lockCanvas()でエラーが起きるためチェックしています。
描画エリアを絞ったrepaint(int x,int y,int w,int h)では、canvas.save()でCanvasの状態をまずセーブしてからクリッピングエリアを変更します。描画の後にcanvas.restore()で元の状態に戻しています。
当たり前ですが、描画エリアを絞ったほうがずっと速く動きます。どの部分だけ再描画が必要か調べるのは面倒な時もありますが効果は大きいのでやったほうがよいでしょう。

[イベント処理について]
イベント処理ではGestureDetector.OnGestureListenerを実装してみます。これでシングルタップやフリックの判定処理を自分で作らずに済みます。オーバーライドするメソッドは下記の6つあります。

onDown() タッチスクリーンに触れられた時に呼ばれる。

onFling() フリックされた時に呼ばれる。

onLongPress() 長押しされた時に呼ばれる。

onScroll() スクロール判定がされた時に呼ばれる。

onShowPress() onDownの後MotionEventのACTION_MOVEやACTION_UPの前に呼ばれる。

onSingleTapUp() シングルタップされた時に呼ばれる。

イベントの起こり方は、例えばonLongPress()だと、onDown(),onShowPress(),onLongPress()の順に呼ばれます。今回使うのはonSingleTapUp()(onDown() -> onSingleTapUp())と
onFling()(onDown() -> onScroll() -> onFling())です。
GestureDetectorを使うにはonTouchEvent()内で下記のようにgestureDetector.onTouchEvent()を呼びます。

    public boolean onTouchEvent(MotionEvent event) {
        boolean rc = false;

        if (gestureDetector == null) {
            return false;
        }

        if (gestureDetector.onTouchEvent(event)) {
            rc = true;
        } else {
            rc = false;
        }

        if (event.getAction() == MotionEvent.ACTION_UP) {
            return onActionUp(event);
        }

        if (status == StatusReady) {
            //処理
            rc = true;
        } else if (status == StatusPlaying) {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {
                //処理
            } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
                //処理
            }
            rc = true;
        } else if (status == StatusComplete) {
            //処理
            rc = true;
        }
        
        return rc;
    }


GestureDetectorを使う上で困るなと思う点があります。拾ったイベントを観察すると分かるのですが、速めに指を動かすとonTouchEventのMotionEvent.ACTION_DOWNの後MotionEvent.ACTION_MOVEが来て、次にonScroll()が呼ばれ、さらに速く動かすとonFling()が呼ばれます。これは理解しやすいです。
ところが指をゆっくり動かし始めるとonTouchEventのMotionEvent.ACTION_DOWNの後MotionEvent.ACTION_MOVEまでは一緒ですが、その後に指の動きを加速させてもonScroll()もonFling()も呼ばれません。これでは最初にタッチして指をゆっくりした動きから速い動きに変えた時にはスクロールやフリック動作をさせられません。実際に作るとわかりますが、結構ぎこちない動きになってしまいます。自分は結局GestureDetectorに頼らないで自作のスクロールやフリックの実装を使っています。作るアプリによってはGestureDetectorは使わない方が却って楽だと思います。

今回はサンプルアプリという事で GestureDetectorを使いました。駒の移動量が少ないのでonTouchEvent()でMotionEvent.ACTION_MOVEだけ拾って作ってもあまりぎこちない動きにはなりませんでした。実装例ですので onFling()も実装してみました。結果はonFling()を使った方が動きが気持よくなりました。ソースのonFling()の実装部分を全部コメントアウトして比べてみると分かると思います。

    public boolean onFling(MotionEvent e1, MotionEvent e2,
                           float velocityXX, float velocityYY) {

        //移動できる方向と逆にフリックされたら無視
        if (movableDirection == Answer.DirectionUp && velocityYY > 0) {
            return false;
        } else if(movableDirection == Answer.DirectionDown && velocityYY < 0) {
            return false;
        } else if (movableDirection == Answer.DirectionLeft && velocityXX > 0) {
            return false;
        } else if (movableDirection == Answer.DirectionRight && velocityXX < 0) {
            return false;
        }

        velocityX = velocityY = 0;
        if (movableDirection == Answer.DirectionUp) {
            velocityY = -movingLength;
        } else if(movableDirection == Answer.DirectionDown) {
            velocityY = movingLength;
        } else if (movableDirection == Answer.DirectionLeft) {
            velocityX = -movingLength;
        } else if (movableDirection == Answer.DirectionRight) {
            velocityX = movingLength;
        }

        status = StatusFling;
        wakeup();

        return false;
    }


onFling()では移動できる方向以外にフリックされたら無視するようにして、それ以外なら速度を与えてstatus変数をStatusFlingにしてwait()しているスレッドを起こします。
駒の移動は長い時間の掛かる処理の部類に入りますので、ここでは行わずゲームスレッドに任せます。動きがよく見えるように駒が次の位置まで移動するのに10フレーム(numMoveToNext=10としています)掛けてゆっくり移動しています。実際のゲームはもっと速く動かすでしょう。

    public boolean onSingleTapUp(MotionEvent e) {
        boolean rc = false;

        if (status == StatusReady) {
            makeGame();
            status = StatusDisplayAnswer;
            wakeup();
            rc = true;
        }

        return rc;
    }


onSingleTapUp()は見た通りなのですが、ゲームを開始できる時(StatusがStatusReady)に問題を作り(makeGame()) statusをStatusDisplayAnswerにしてwait()しているスレッドを起こします。ゲームスレッドは問題を作る過程(コマを1つ空けて、1駒づつ動かしていく)を表示します。解答を見せてしまうためとても簡単にパズルが解けます(笑)。 実際のゲームを作るときは難易度の設定ができて、「簡単モード」だとこのように解答が見られるようにするのかなと思っています。

コードの説明は androidに関連する部分だけにしてゲーム部分の説明はしませんでした。たぶん読んでも眠くなるだけですし。
次回は色々な解像度に対応するデータの持ち方の説明をして終了します。


「androidで動くゲームプログラミング入門」 その1
「androidで動くゲームプログラミング入門」 その2
「androidで動くゲームプログラミング入門」 その3

0 件のコメント:

コメントを投稿