JavaScriptテトリスを移植してみた

<2008/08/20:追記>SDK0.9がリリースされたので対応しました。m5では動きません。


id:amachangさんがプログラミングキャンプの講義資料を公開されていたのですが、それがとても面白かったのでAndroidに移植してみました。
元記事
http://d.hatena.ne.jp/amachang/20080814
http://svn.coderepos.org/share/docs/amachang/20080813-procamp2008/index.html


参加された受講生の方達は非常にレベルが高かったようですので、Javaもある程度知っている人が多いかもしれません。
でも、もし知らない方がいらしたらぜひ比べてみて何がどう違うのか考えてみてください。
きっと面白いと思います。


Javaは型(Class)を用いるオブジェクト指向言語ですので、追記で拡張されたJavaScriptオブジェクト指向版を理解されてからこちらのプログラムを読まれることをお勧めします。


移植したのは追記前の最初の完成版、「17 ランダムなブロックを出す」の版です。

ソース

以下がソースコードになります。元記事に習いPublic Domainとします。
リソースは一切使っておりませんのでEclipseにてAndroidプロジェクトをDroidrisという名前にて作成し、自動的に作成されたDroidris.javaを以下のコードに置き換えれば動くはずです。package文だけは御自分の指定したpackageに直してください。わからない場合にはプロジェクト作成時のpackage指定において著者と同じ"minghai.practice4"を指定すればOKです。

Androidの開発環境の構築に関しては以下の記事を参考にしてみてください。
http://allabout.co.jp/internet/java/subject/msubsub_cate18.htm

package minghai.practice4;

import java.util.Random;

import android.app.Activity;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RectShape;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.MessageQueue;
import android.util.Log;
import android.view.KeyEvent;
import android.view.SurfaceView;

public class Droidris extends Activity {
    private class FieldView extends SurfaceView {
        
        Random mRand = new Random(System.currentTimeMillis());
        
        int[][][] blocks = {
                {
                    {1,1},
                    {0,1},
                    {0,1}
                },
                {
                    {1,1},
                    {1,0},
                    {1,0}
                },
                {
                    {1,1},
                    {1,1}
                },
                {
                    {1,0},
                    {1,1},
                    {1,0}
                },
                {
                    {1,0},
                    {1,1},
                    {0,1}
                },
                {
                    {0,1},
                    {1,1},
                    {1,0}
                },
                {
                    {1},
                    {1},
                    {1},
                    {1}
                }
        };

        int[][] block = blocks[mRand.nextInt(blocks.length)];
        int posx, posy;
        int mapWidth  = 10;
        int mapHeight = 20;
        int[][] map = new int[mapHeight][];
        
        public FieldView(Context context) {
            super(context);
            
            setBackgroundColor(0xFFFFFFFF);
            setFocusable(true);
            setFocusableInTouchMode(true);
            requestFocus();
        }
        
        public void initGame() {
            for (int y = 0; y < mapHeight; y++) {
                map[y] = new int[mapWidth];
                for (int x = 0; x < mapWidth; x++) {
                    map[y][x] = 0;
                }
            }
        }

        private void paintMatrix(Canvas canvas, int[][] matrix, int offsetx, int offsety, int color) {
            ShapeDrawable rect = new ShapeDrawable(new RectShape());
            rect.getPaint().setColor(color);
            int h = matrix.length;
            int w = matrix[0].length;

            for (int y = 0; y < h; y ++) {
                for (int x = 0; x < w; x ++) {
                    if (matrix[y][x] != 0) {
                        int px = (x + offsetx) * 20;
                        int py = (y + offsety) * 20;
                        rect.setBounds(px, py, px + 20, py + 20);
                        rect.draw(canvas);
                    }
                }
            }
        }

        
        boolean check(int[][] block, int offsetx, int offsety) {
            if (offsetx < 0 || offsety < 0 ||
                mapHeight < offsety + block.length ||
                mapWidth < offsetx + block[0].length) {
                return false;
            }
            for (int y = 0; y < block.length; y ++) {
                for (int x = 0; x < block[y].length; x ++) {
                    if (block[y][x] != 0 && map[y + offsety][x + offsetx] != 0) { 
                        return false;
                    }
                }
            }
            return true;
        }

        void mergeMatrix(int[][] block, int offsetx, int offsety) {
            for (int y = 0; y < block.length; y ++) {
                for (int x = 0; x < block[0].length; x ++) {
                    if (block[y][x] != 0) {
                        map[offsety + y][offsetx + x] = block[y][x];
                    }
                }
            }
        }

        void clearRows() {
            // 埋まった行は消す。nullで一旦マーキング
            for (int y = 0; y < mapHeight; y ++) {
                boolean full = true;
                for (int x = 0; x < mapWidth; x ++) {
                    if (map[y][x] == 0) {
                        full = false;
                        break;
                    }
                }
                
                if (full) map[y] = null;
            }
            
            // 新しいmapにnull以外の行を詰めてコピーする
            int[][] newMap = new int[mapHeight][];
            int y2 = mapHeight - 1;
            for (int y = mapHeight - 1; y >= 0; y--) {
                if (map[y] == null) {
                    continue;
                } else {
                    newMap[y2--] = map[y];
                }
            }
            
            // 消えた行数分新しい行を追加する
            for (int i = 0; i <= y2; i++) {
                int[] newRow = new int[mapWidth];
                for (int j = 0; j < mapWidth; j ++) {
                    newRow[j] = 0;
                }
                newMap[i] = newRow;
            }
            map = newMap;
        }

        /**
         * Draws the 2D layer.
         */
        @Override
        protected void onDraw(Canvas canvas) {
            ShapeDrawable rect = new ShapeDrawable(new RectShape());
            rect.setBounds(0, 0, 210, 410);
            rect.getPaint().setColor(0xFF000000);
            rect.draw(canvas);
            canvas.translate(5, 5);
            rect.setBounds(0, 0, 200, 400);
            rect.getPaint().setColor(0xFFFFFFFF);
            rect.draw(canvas);
            
            paintMatrix(canvas, block, posx, posy, 0xFFFF0000);
            paintMatrix(canvas, map, 0, 0, 0xFF808080);
        }
        
        int[][] rotate(final int[][] block) {
            int[][] rotated = new int[block[0].length][];
            for (int x = 0; x < block[0].length; x ++) {
                rotated[x] = new int[block.length];
                for (int y = 0; y < block.length; y ++) {
                    rotated[x][block.length - y - 1] = block[y][x];
                }
            }
            return rotated;
        }

        @Override
        public boolean onKeyDown(int keyCode, KeyEvent event) {
            switch (keyCode) {
            case KeyEvent.KEYCODE_DPAD_CENTER:
                int[][] newBlock = rotate(block);
                if (check(newBlock, posx, posy)) {
                    block = newBlock;
                }
                break;
            case KeyEvent.KEYCODE_DPAD_RIGHT:
                if (check(block, posx + 1, posy)) {
                    posx = posx + 1;
                }
                break;
            case KeyEvent.KEYCODE_DPAD_LEFT:
                if (check(block, posx - 1, posy)) {
                    posx = posx - 1;
                }
                break;
            case KeyEvent.KEYCODE_DPAD_UP:
            case KeyEvent.KEYCODE_DPAD_DOWN:
                int y = posy;
                while (check(block, posx, y)) { y++; }
                if (y > 0) posy = y - 1;

                break;
            }
            mHandler.sendEmptyMessage(INVALIDATE);
            return true;
        }
        
        public void startAnime() {
            mHandler.sendEmptyMessage(INVALIDATE);
            mHandler.sendEmptyMessage(DROPBLOCK);
        }
        
        public void stopAnime() {
            mHandler.removeMessages(INVALIDATE);
            mHandler.removeMessages(DROPBLOCK);
        }

        private static final int INVALIDATE = 1;
        private static final int DROPBLOCK = 2;

        /**
         * Controls the animation using the message queue. Every time we receive an
         * INVALIDATE message, we redraw and place another message in the queue.
         */
        private final Handler mHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                switch (msg.what) {
                case INVALIDATE:
                    invalidate();
                    break;
                case DROPBLOCK:
                    if (check(block, posx, posy + 1)) {
                        posy++;
                    } else {
                        mergeMatrix(block, posx, posy);
                        clearRows();
                        posx = 0; posy = 0;
                        block = blocks[mRand.nextInt(blocks.length)];
                    }
                    
                    invalidate();
                    Message massage = new Message();
                    massage.what = DROPBLOCK;
                    sendMessageDelayed(massage, 500);
                    break;
                }
            }
        };
    }
    
    FieldView mFieldView;

    private void setFieldView() {
        if (mFieldView == null) {
            mFieldView = new FieldView(getApplication());
            setContentView(mFieldView);
        }
    }

    @Override
    protected void onCreate(Bundle icicle) {
        super.onCreate(icicle);
    }

    @Override
    protected void onResume() {
        super.onResume();
        setFieldView();
        mFieldView.initGame();
        mFieldView.startAnime();
        Looper.myQueue().addIdleHandler(new Idler());
    }
    

    @Override
    protected void onPause() {
        super.onPause();
        mFieldView.stopAnime();
    }

    @Override
    protected void onStop() {
        super.onStop();
        mFieldView.stopAnime();
    }


    // Allow the activity to go idle before its animation starts
    class Idler implements MessageQueue.IdleHandler {
        public Idler() {
            super();
        }

        public final boolean queueIdle() {
            return false;
        }
    }
}

簡単な解説

Javaに移植する上で変更が必要なのは型が必要だということです。
Javaで扱われるデータには全てこの型が必要になります。
例えばblockはintという整数の2次元配列の型になります。
型の使用のメリットはプログラミング中に型チェックがIDEにより行なわれるため、コーディングミスを実行前に減らすことができます。
また実行時に型が確認済みのためセキュリティ上安全で、パフォーマンスにも良い影響があると言われています。
デメリットとして、プログラムを書くのに少し苦労します。


またJavaScriptでは配列が自由自在に伸びたり、縮んだりします。またサイズを指定せずとも使えるため、データの存在しないインデックスを指定することができます。
Javaではこれは配列では行えません。Javaでは配列を使用する時、そのサイズを指定しなければなりません。また指定したサイズの範囲のインデックスしか利用できません。
変わりにArrayListのようなCollectionと呼ばれるデータ構造が標準ライブラリに用意されています。これを用いると自在に増やしたり消したりすることが可能です。
今回は元のプログラムとできるだけ同じにするため配列をそのまま使っています。
そのために行が揃った場合に消すclearRows関数は全面的に変更されています。またmergeMatrixもIndexOutOfBoundsにて例外終了するために変更してあります。


AndroidではアプリケーションはActivityというクラスで作成し、このActivityのonCreateというメソッドから実行が開始されます。
このプログラムではonCreateにて初期設定の一部を行い、別のonResumeという関数にてさらにゲームの初期化を行います。二つある理由はActivityのライフサイクルに関係しますので興味のある方は調べてみてください。どちらも自動的にシステムから呼び出されます。


onResumeにてstartAnime()を呼び出すことによりゲームを開始します。startAnimeではHandlerに対して最初のメッセージを投げています。このHandlerのメッセージ処理の部分にて自分自身にmessageを再度投げることによりゲームのループがずっと行われる訳です。

Androidの画面更新

Androidにおいてゲームの様な自由な画面記述を行い、画面を更新するプログラムの作成方法は幾通りもあるようです。
今回は公式ブログにて公開されたデモのプログラムを見様見真似にて書いてみました。


Android独特の特徴として、画面変更を行なうには画面変更を行う専用のThreadにリクエストを行う必要があります。
JavaScript版ではblockが落下した時とキー入力を受け付けた後に別に画面更新を行っています。
Androidにてblockを落下させた時に同時に画面更新を行うと、キー入力を行い画面更新をリクエストした時にもブロックが落下するという症状に見舞われました。
このためblockを落とすプログラムと画面更新のプログラムは別にしてあります。
JavaScriptではブロックの落下をsetIntervalにて指定していましたが、AndroidではHandler.sendMessageDelayedを用いています。
携帯のインターフェイスはあまりよくないので落下時間は500msec毎に延長してあります。
このHandlerにmessageを投げると、指定時間後にHandlerにてmessageが処理されます。この時にView.invalidateを実行することで画面の更新がリクエストされます。実際の更新時にはView.onDrawが呼び出されます。この中でJavaScriptと同じくcanvasに描画を行うことになります。

追記拡張による関数型への対応について

id:amachangさんはhttp://d.hatena.ne.jp/amachang/20080815にて上級者向けと称して元のプログラムを関数型のプログラムへと変更しています。Javaでこれを行うのは結構難しいのです。


JavaScriptでは名前の無い無名関数が扱えます。しかしJavaは型を重視するために無名関数の採用には反対する立場を長く取ってきました。
このため無名関数は使えないのですが、既存のクラスやインターフェイスに対して(無名)内部クラスというものを記述することができます。
AndroidでもListnerインターフェイスの実装には非常に良く使う手段です。
(無名)内部クラスには型が存在しますので安全です。
しかしこれは非常に記述が長くなるデメリットがあります。


SunはJavaSE5という版からEase of Developmentを標語に据え、プログラマの苦労を取り除く方向に方針を変えました。
そしてJavaSE7ではついにClosureと呼ばれる無名関数の実装が可能となります。
Androidでは現在、JavaSE5を利用します。希望的観測ですが、将来にはJavaSE7が使えるようになると思います。
Listnerを数多く使うAndroidではClosureの採用の恩恵は計り知れないと思います。