2011年11月30日水曜日

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

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

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

  1. public class SlidePuzzle1Activity extends Activity {  
  2.     private GameView gameView;  
  3.   
  4.     @Override  
  5.     public void onCreate(Bundle savedInstanceState) {  
  6.         super.onCreate(savedInstanceState);  
  7.         gameView = new GameView(this); //ここは速く  
  8.         requestWindowFeature(Window.FEATURE_NO_TITLE);  
  9.         setContentView(gameView);  
  10.     }  
  11.   
  12.     @Override  
  13.     public void onStart() {  
  14.         super.onStart();  
  15.     }  
  16.   
  17.     @Override  
  18.     public void onPause() {  
  19.         super.onPause();  
  20.     }  
  21.   
  22.     @Override  
  23.     public void onStop() {  
  24.         super.onStop();  
  25.     }  
  26.   
  27.     @Override  
  28.     public void onResume() {  
  29.         super.onResume();  
  30.     }  
  31. }  

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

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

  1. public class GameView extends SurfaceView implements  
  2.                                           GestureDetector.OnGestureListener,  
  3.                                           SurfaceHolder.Callback,  
  4.                                           Runnable {  
  5.     public GameView(Activity activity) {  
  6.         super(activity);  
  7.         init();  
  8.     }  
  9.   
  10.     private void init() {  
  11.         holder = getHolder();  
  12.         holder.addCallback(this);  
  13.   
  14.         surfaceCreated = false;  
  15.         thread         = null;  
  16.         loader         = new DataLoader(this);  
  17.         loaded         = false;  
  18.         status         = StatusInit;  
  19.   
  20.         Resources res = getContext().getResources();   
  21.         logoImg = BitmapFactory.decodeResource(res,R.drawable.fjtn);  
  22.   
  23.         thread = new Thread(this);  
  24.         thread.start();  
  25.     }  

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

  1.     public void surfaceCreated(SurfaceHolder holder) {  
  2.         gestureDetector = new GestureDetector(this);  
  3.         surfaceCreated = true;  
  4.   
  5.         if (initializing) {  
  6.             //起動時  
  7.             repaint();  
  8.         }  
  9.   
  10.         if (!loaded) {  
  11.             status = StatusLoading;  
  12.             wakeup();  
  13.         }  
  14.     }  
  15.   
  16.     public void surfaceDestroyed(SurfaceHolder holder) {  
  17.         surfaceCreated  = false;  
  18.         gestureDetector = null;  
  19.         loaded = false;  
  20.         free();  
  21.     }  

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

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

ゲームスレッドの骨組みはこのようになっています。
  1.     public void run() {  
  2.         while (thread != null) {  
  3.             if (status == StatusLoading) {  
  4.                 sleep(100);  
  5.                 if (loader.loadImages(getContext())) {  
  6.                     if (initializing) {  
  7.                         init2();  
  8.                         bitmapAlpha = 255;  
  9.                         status = StatusOpening;  
  10.                         initializing = false;  
  11.                     } else {  
  12.                         status = prevStatus;  
  13.                     }  
  14.                     loaded = true;  
  15.                     repaint();  
  16.                 } else {  
  17.                     status = StatusError;  
  18.                 }  
  19.             } else if (status == StatusOpening) {  
  20.                 //オープニング表示  
  21.             } else if (status == StatusDisplayAnswer) {  
  22.                 //答えの表示  
  23.             } else if (status == StatusComplete) {  
  24.                 //パズル完成  
  25.             } else if (status == StatusFling) {  
  26.                 //onFling()を受けて駒を動かす  
  27.             } else {  
  28.                 synchronized (this) {  
  29.                     try {  
  30.                         wait();  
  31.                     } catch (Exception e) {  
  32.                     }  
  33.                 }  
  34.             }  
  35.         }  
  36.     }  
  37. }  

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

  1.     private void wakeup() {  
  2.         synchronized(this) {  
  3.             notifyAll();  
  4.         }  
  5.     }  


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

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

  1.     public void repaint() {  
  2.         Canvas canvas = null;  
  3.   
  4.         if (!surfaceCreated) {  
  5.             return;  
  6.         }  
  7.   
  8.         try {  
  9.             canvas = holder.lockCanvas();  
  10.             synchronized (holder) {  
  11.                 onDraw(canvas);  
  12.             }  
  13.         } catch (Exception e) {  
  14.         } finally {  
  15.             if (canvas != null) {  
  16.                 holder.unlockCanvasAndPost(canvas);  
  17.             }  
  18.         }  
  19.     }  
  20.   
  21.     public void repaint(int x,int y,int w,int h) {  
  22.         Canvas canvas = null;  
  23.   
  24.         if (!surfaceCreated) {  
  25.             return;  
  26.         }  
  27.   
  28.         try {  
  29.             canvas = holder.lockCanvas();  
  30.             canvas.save(Canvas.CLIP_SAVE_FLAG);  
  31.             canvas.clipRect(x,y,x+w,y+h);  
  32.   
  33.             synchronized (holder) {  
  34.                 onDraw(canvas);  
  35.             }  
  36.   
  37.             canvas.restore();  
  38.         } catch (Exception e) {  
  39.         } finally {  
  40.             if (canvas != null) {  
  41.                 holder.unlockCanvasAndPost(canvas);  
  42.             }  
  43.         }  
  44.     }  

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()を呼びます。

  1.     public boolean onTouchEvent(MotionEvent event) {  
  2.         boolean rc = false;  
  3.   
  4.         if (gestureDetector == null) {  
  5.             return false;  
  6.         }  
  7.   
  8.         if (gestureDetector.onTouchEvent(event)) {  
  9.             rc = true;  
  10.         } else {  
  11.             rc = false;  
  12.         }  
  13.   
  14.         if (event.getAction() == MotionEvent.ACTION_UP) {  
  15.             return onActionUp(event);  
  16.         }  
  17.   
  18.         if (status == StatusReady) {  
  19.             //処理  
  20.             rc = true;  
  21.         } else if (status == StatusPlaying) {  
  22.             if (event.getAction() == MotionEvent.ACTION_DOWN) {  
  23.                 //処理  
  24.             } else if (event.getAction() == MotionEvent.ACTION_MOVE) {  
  25.                 //処理  
  26.             }  
  27.             rc = true;  
  28.         } else if (status == StatusComplete) {  
  29.             //処理  
  30.             rc = true;  
  31.         }  
  32.           
  33.         return rc;  
  34.     }  


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()の実装部分を全部コメントアウトして比べてみると分かると思います。

  1.     public boolean onFling(MotionEvent e1, MotionEvent e2,  
  2.                            float velocityXX, float velocityYY) {  
  3.   
  4.         //移動できる方向と逆にフリックされたら無視  
  5.         if (movableDirection == Answer.DirectionUp && velocityYY > 0) {  
  6.             return false;  
  7.         } else if(movableDirection == Answer.DirectionDown && velocityYY < 0) {  
  8.             return false;  
  9.         } else if (movableDirection == Answer.DirectionLeft && velocityXX > 0) {  
  10.             return false;  
  11.         } else if (movableDirection == Answer.DirectionRight && velocityXX < 0) {  
  12.             return false;  
  13.         }  
  14.   
  15.         velocityX = velocityY = 0;  
  16.         if (movableDirection == Answer.DirectionUp) {  
  17.             velocityY = -movingLength;  
  18.         } else if(movableDirection == Answer.DirectionDown) {  
  19.             velocityY = movingLength;  
  20.         } else if (movableDirection == Answer.DirectionLeft) {  
  21.             velocityX = -movingLength;  
  22.         } else if (movableDirection == Answer.DirectionRight) {  
  23.             velocityX = movingLength;  
  24.         }  
  25.   
  26.         status = StatusFling;  
  27.         wakeup();  
  28.   
  29.         return false;  
  30.     }  


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

  1.     public boolean onSingleTapUp(MotionEvent e) {  
  2.         boolean rc = false;  
  3.   
  4.         if (status == StatusReady) {  
  5.             makeGame();  
  6.             status = StatusDisplayAnswer;  
  7.             wakeup();  
  8.             rc = true;  
  9.         }  
  10.   
  11.         return rc;  
  12.     }  


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

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


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

2011年11月24日木曜日

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

[はじめに]
今回は「androidで動くゲームプログラミング入門」というタイトルで書いてみます。少し長くなりそうなので何回かに分けて書く予定です。
ちょっと普通のアプリと作り方が違うかと思いますのでSurfaceViewを使ったパズルゲームを作って、そのコードを追いながらandroidの基本的なゲーム・プログラミングのやり方を解説していきたいと思います。作るのはスライドパズルです。なぜスライドパズルかというと作るのが簡単だからという理由です。(笑) ゲームといえばOpenGLを使うケースが多いかと思いますが、今回はCanvasを使います。Canvasでもスライドパズルなら充分な速度が出ます。
ソースコードはgithubに置いておきます。
linuxとMacOSな人は
git clone git@github.com:michiro/SlidePuzzle.git でソースコード一式取得できます。
windowsな他人はリポジトリをgit@github.com:michiro/SlidePuzzle.gitとしてください。
質問、コメントや間違いの指摘を寄せていただいたら感謝します。

[ゲームの処理とマルチスレッド]
ゲームの処理をどこで行うか考えてみます。ゲームですので画面の表示はユーザが何かをした時以外にも変わってきます。例えばアクションゲームではユーザが何もしなくてもゲームはどんどん進んでいきます、それに伴い画面もどんどん描き変えてきます。こういった長い処理はメインスレッドでは行うことが出来ません。androidの場合はメインスレッドを5秒以上止めてしまいますとANR(Application Not Responding)エラーが出てプログラムが終了させられます。android以外のシステムだとしても、例えばwindowシステム(WindowsやX-Window Sysetmなど)のメインループを止めてしますような事をしますと画面が書き換えられなかったり、プログラムが反応しなくなったりします。
そこでゲームの主な処理はメインスレッドを専有しないようにしなくてはいけません。方法としてはタイマーを使って一定間隔でゲーム処理部分を呼び出す方法や別のスレッドを作ってそこで処理を行う方法があります。今回のプログラムは別のスレッドを作る方法で書いて行きます。以後の説明では、この別のスレッドをゲームスレッドと呼ぶことにします。

[Viewについて]
今回作るゲームではSurfaceViewを使います。理由はViewクラスを使うより速いからです。もう一つ、androidのUI操作はスレッドセーフに作られていません。したがって描画はメインスレッドで行わなければならないのです。具体的にはViewクラスでは描画はview.invalidate()を呼ぶのですが、これはメインスレッドから呼ばなくてはなりません。メインスレッドから呼ぶには、下記のようなコードになります。
  1. public void repaint() {  
  2.     new Thread(new Runnable() {  
  3.             @Override public void run() {  
  4.                 handler.post(new Runnable() {  
  5.                         @Override public void run() {  
  6.                             view.invalidate()  
  7.                         }  
  8.                     });  
  9.             }  
  10.         }).start();  
  11. }  
  12.   
  13. protected onDraw(Canvas canvas) {  
  14.     //ここで描画をする  
  15. }  

この repaint()メソッド(Java2MEと同じメソッド名にしました)はメインスレッドの待ち行列に処理を登録するだけで、すぐに実行されるわけではありません。
repaint()を呼んだ後にonDraw()が呼び出されるので、そこで描画を行います。下記のコードではrepaint()で描画しているのですが、おそらく描画が完了する前にnextProcess()が呼び出されます。遅くなりますし、描画の終了を知るのにひと工夫要ります。
  1. public void myFunction() {  
  2.     repaint();  
  3.     nextProcess(); //描画が完了する前に、ここに来てしまう  
  4. }  

androidアプリのUIがシングルスレッドという事に関してはadamrockerさんがblogに詳しく書かれています。

これに対してSurfaceViewを使った場合は、自分でonDraw()を呼び出し描画をします。コードは下記のようになります。
  1. public void repaint() {  
  2.     Canvas canvas = null;  
  3.     try {  
  4.         canvas = holder.lockCanvas();  
  5.         synchronized (holder) {  
  6.             onDraw(canvas);  
  7.         }  
  8.     } catch (Exception e) {  
  9.     } finally {  
  10.         if (canvas != null) {  
  11.             holder.unlockCanvasAndPost(canvas);  
  12.         }  
  13.     }  
  14. }  

この場合はrepaint()を呼び出せばすぐに描画が始まり。repaint()が終了したときは描画も終わっています。こちらの方が速く、repaint()が帰ってきた時には描画は終わっています。
注意する点はメインスレッドとゲームスレッド両方で色々な処理をする事になると思いますので競合が起きないようにしないように注意深くプログラミングする必要があります。


次回はコードの説明に入ります。
「androidで動くゲームプログラミング入門」 その1
「androidで動くゲームプログラミング入門」 その2
「androidで動くゲームプログラミング入門」 その3