Skip to content

enchant.jsで3Dゲームを作ってみる!

こんにちは。
先日、3Dプログラミングの簡単なやり方をここで紹介した。

そこで今日は、先日のサンプルをもとにゲームを作ってみた。
まずは遊んでみてほしい

さて、ではどうやってゲームにしているのか?
それを解説していこう。


このゲームは非常にシンプルなものになっている。
画面の奥から手前に向かってやってくる立方体をひたすらタップやクリックで消していくだけだ。

時間が経つと立方体が増えたり、スピードが上がったりして非常に難しくなる。

前回のサンプルとの違いは、Shapeクラスが追加されたことと、変換行列の掛けかたを変えたこと。
また、iPhoneやAndroidでもちゃんとスピードが出るようにプログラム的な高速化(最適化)を行っている。

実際のコードは以下のjsdo.itにアップしてある。

飛んでくる3D立方体をひたすら消すシンプルなゲームサンプル – jsdo.it – share JavaScript, HTML5 and CSS

まずは新しく追加したShapeクラスについて見てみよう

Shape = new enchant.Class.create({
    initialize:function(){
        this.vt =new VertexTable();
        var vt =this.vt;
        vt.push(new Vector( -10, -10, -10) );
        vt.push(new Vector( -10,  10, -10) );
        vt.push(new Vector(  10,  10, -10) );
        vt.push(new Vector(  10, -10, -10) );
        vt.push(new Vector( -10, -10, -10) );//hoge
        vt.push(new Vector( -10, -10,  10) );
        vt.push(new Vector(  10, -10,  10) );
        vt.push(new Vector(  10,  10,  10) );
        vt.push(new Vector( -10,  10,  10) );
        vt.push(new Vector( -10, -10,  10) );
        vt.push(new Vector(  10, -10,  10) );
        vt.push(new Vector(  10, -10, -10) );//hoge
        vt.push(new Vector(  10,  10, -10) );
        vt.push(new Vector(  10,  10,  10) );//hoge
        vt.push(new Vector( -10,  10,  10) );
        vt.push(new Vector( -10,  10, -10) );
        this. movMat =new Matrix([  [ 1, 0, 0, 0],
                                    [ 0, 1, 0, 0],
                                    [ 0, 0, 1, 50],
                                    [ 0, 0, 0, 1]]);
        this.rvt =new VertexTable(this.vt);
        this.x=0;
        this.y=0;
        this.z=0;
        this.color="#0f0";
    },

Shapeクラスは、その名の通り形状を保存するためのクラスだ。
メンバーとしてVertexTableとmovMat(移動行列)を持ち、draw関数で実際の描画を行う。

Shapeクラスのdraw関数はこんな感じに鳴っている

    draw:function(){
        this.movMat.matrix[0][3]=this.x; //A 平行移動量を移動行列に代入
        this.movMat.matrix[1][3]=this.y;
        this.movMat.matrix[2][3]=this.z;
       
        var rotMat =rotMatX.composition(rotMatY); //B 回転させる
        rotMat.compositionMe(this.movMat); //C 合成する
        rotMat.compositionMe(convMat); //D 変換行列を合成する
        this.z -= speed;
        var rvt =this.rvt;
        rvt.applyFast(this.vt,rotMat); //E 変換行列に頂点配列を乗じる
       
                //F 描画する
        context.beginPath();
        context.strokeStyle=this.color;
        rvt[0].x=rvt[0].x/rvt[0].w;
        rvt[0].y=rvt[0].y/rvt[0].w;
        context.moveTo(rvt[0].x,rvt[0].y);
        for(var i=1;i<rvt.length;i++){
            rvt[i].x=rvt[i].x/rvt[i].w;
            rvt[i].y=rvt[i].y/rvt[i].w;
            context.lineTo(rvt[i].x,rvt[i].y);
        }
        context.stroke();
       
        if(this.z<200){
            this.color="#f00";
        }
        if(this.z<10){
            this.setPosition(Math.random()*400-200,Math.random()*400-200,1000);
            this.color="#0f0";
            context.fillStyle  = '#fff';
            context.fillRect(0,0,320,320);
            shield-=10;
            shieldLabel.text="<font color=white>SHIELD:"+shield+"</font>";
            if(shield<=0)
                game.end(score);
        }
    }

冒頭でmovMatの[0][3]と[1][3]、[2][3]にそれぞれx,y,zを代入している(Aの行)けど、これで平行移動を実現している。
その後、rotMatXとrotMatYの二つの回転行列を乗じている(Bの行)。この二つの行列は、今回は高速化のために総ての立方体が同じように回転するようになっているのでグローバル変数になっている。

今回は高速化のため、Matrixクラスにcompsitionというメソッドとは別に、自分自身の演算結果を変えるcompositionMeというメソッドを用意した。

通常のcompositionメソッドは、合成する度に新しいMatrixクラスのインスタンスを返していたが、compositionMeはインスタンス生成を行わないため、そのぶんのオーバーヘッド(これがバカにならない)が短縮される。

C行で示すように、rotMatにmovMatを乗じると、rotMatで回転してからmovMatで移動する、という動きになる。
行列は乗じる順番によって動きが変わる(これが普通の乗算と違う)ので、順番には注意が必要だ。

さらに次のD行ではconvMatというグローバル変数を乗じたあと、E行でVertexTableに変換行列を適用してF行以降で描画している。

D行で乗じられているconvMatは、このdrawメソッドの外で作られていて、内容は透視変換とスクリーン変換を予め乗じたものだ。
行列の乗算も計算コストが高いので、できるだけ外でまとめてやっておくのがいい。

       var m = new Matrix();
        speed=5;
        var minZ =1,maxZ=10;
        var S = 1/Math.tan(45*3.14159/180);
        var Sz = maxZ/(maxZ-minZ);
        var projMat = new Matrix([  [  S, 0, 0, 0],
                                    [  0, S, 0, 0],
                                    [  0, 0, Sz, 1],
                                    [  0, 0,-Sz*minZ, 0] ]);
                                   
        var screenMat = new Matrix([[160,   0,0,160],
                                    [  0,-160,0,160],
                                    [  0,   0,1,  0],
                                    [  0,   0,0,  1] ]);
        convMat =screenMat.composition(projMat);

一度合成しておけば、この行列はずっと使えるわけだ。

プログラミングにおいては、しばしばこの”最適化(Optimize)”という考え方が重要になる。
最近のトレンドは関数型言語を多用した「トリッキーだけど確実に動作する」か、「あとから誰が読んでも間違わないように書く可読性と保守性を重視する」という考え方だけれども、ゲームプログラミングの世界や、最先端のユーザーインターフェースを実現するためには、しばしばその確実性や可読性は犠牲になる。

書いた本人以外誰も理解できない。けれども誰のコードよりも最も高速かつ”最適”に動作する。
そういう世界もあるのだ。

このコードの場合、最適化とは、1フレーム毎に発生する計算量をトータルでなるべく減らすことだ。
4×4行列の乗算には4の3重ループが必要だから64回の乗算が必要になる。

コンピュータが苦手な計算の筆頭は除算、とりわけ剰余算(余りを求める計算)だと言われているが、最近のコンピュータは浮動小数点の計算に特化してコンピュータ自身が最適化されているため、どの計算が一概に遅いとは言いにくくなっている。

とはいえ、計算の絶対量が減れば確実に全体を高速化できるし、”最近のコンピュータ”と言っても、電力を無尽蔵に使えるPentum世代以降の話なので、未だにモバイル用途においてはこうした細かい最適化が有効なのだ。

また、インスタンス生成のオーバーヘッド(余分にかかる時間)が掛かる場合は多い。
少なくともインスタンスを生成するために、メモリ確保とコンストラクタの実行という処理は走るし、下手をするとこの時点でメモリが一杯になってがベージコレクションが走るわけだから、これが省けるにこしたことはない。

ガベージコレクションのあるコンピュータ言語でガベージコレクションの時間を最も少なくするには?
当然、ガベージを作らない、ということが重要になる。ガベージが出なければガベージコレクションは不要になり、最小限で済む。

このガベージコレクションがある環境でもガベージを作らないようにプログラミングする、という考え方は、OSやシェルのように常時動作することが前提となっている場合は非常に重要だ。OSやシェルが気持ちよくガベージコレクションを連発していたらコンピュータ全体の動作が恐ろしく重くなってしまう。

ガベージコレクションというのはあくまでも「メモリ管理についてそれほど難しく考えなくてもコンピュータが破壊されない」ために存在するのだ。

ガベージを出さないようにするにはどうすればいいか?
基本的にはインスタンスを使い回す、ということになる。リサイクルだね。

たとえば静的メソッドでインスタンスを生成して返すようなものがあったら、インスタンスは生成せず自分自身のデータを書き換えるような破壊的メソッドを用意するということだ。

今回作ったMatrixクラスのcompsotinMeやVertexTableクラスのapplyMeといったメソッドは、そういう破壊的なメソッドである。

特にメインループの中からnewを無くしていくとガベージコレクションの回数は極端に減る。

さて、では実際のメインループを見てみよう

        game.rootScene.addEventListener('enterframe', function(e) {
            if(t%100==0){
                if(box.length<20){
                    var nBox = new Shape(); //A 新しい立方体を作る
                    nBox.setPosition(Math.random()*400-200,Math.random()*400-200,Math.random()*100+1000);
                    box.push(nBox);
                }
            }
            if(t%350==0){
                if(speed<20)speed++;
            }
            context.fillStyle  = '#000';
            context.fillRect(0,0,320,320);
            t++;
            rotMatY.rotateY(t*3.14159/180);
            rotMatX.rotateX(t*3.14159/180);
            for(var i=0;i<box.length;i++) //B 総ての立方体を描画する
                box[i].draw();
        });

って、いきなりメインループの中でnew使ってるじゃん!(A行)と思うかもしれないが、これはt%100==0のとき、つまり100フレームに一回だけしか呼ばれない部分だからいいのだ。

しかもその上の行のif分でbox.lengthが20未満の時しかnewを呼ばないのでShapeのnewが呼ばれる数はゲーム内トータルで20回だけだ。

そしてB行で総ての立方体を描画している。
非常にシンプル。

ここでさっきのdrawメソッドを順番に呼んでいるわけだ。

さて、これだけだとゲームにならない。
少なくともタッチに対する反応くらいは欲しいところだ。

そこでtouchendイベントに対するイベントハンドラを書こう。

    game.rootScene.addEventListener('touchend', function(e) {
            for(var i=0;i<box.length;i++){
                if(box[i].z<10000){
                    if(box[i].intersect(e.x,e.y)){ // A当たり判定をとる
                        box[i].color="#0f0";
                        score++;
                        scoreLabel.text="<font color=white>SCORE:"+score+"</font>";
                        box[i].setPosition(Math.random()*400-200,Math.random()*400-200,Math.random()*100+1000);
                    }
                }
            }
        });

touchendイベントは、タッチが終了したとき、またはマウスボタンが離れたときに呼び出される。
タッチする度に総ての立方体について、intersectというメソッドを呼び出し(A行)て、当たったかどうか判定するのだ。

このintersectというメソッドはShapeクラスの中で定義されている。
見てみよう

    intersect:function(x,y){
        this.movMat.matrix[0][3]=this.x;
        this.movMat.matrix[1][3]=this.y;
        this.movMat.matrix[2][3]=this.z;
        var rotMat =rotMatX.composition(rotMatY);
        rotMat.compositionMe(this.movMat);
        rotMat.compositionMe(convMat);
        var rvt =this.rvt;
        rvt.applyFast(this.vt,rotMat);
        var minX=320,minY=320,maxX=0,maxY=0; //A 最小値と最大値をx,yそれぞれに求める
        for(var i=0;i<rvt.length;i++){
            if(minX>rvt[i].x/rvt[i].w)minX=rvt[i].x/rvt[i].w;
            if(minY>rvt[i].y/rvt[i].w)minY=rvt[i].y/rvt[i].w;
            if(maxX<rvt[i].x/rvt[i].w)maxX=rvt[i].x/rvt[i].w;
            if(maxY<rvt[i].y/rvt[i].w)maxY=rvt[i].y/rvt[i].w;
        }
        if( (minX<x) && (x<maxX) && (minY<y) && (y<maxY))return true; // B 当たり判定
        return false;
    },

これ、前半部分はdrawメソッドとほとんど同じだということに気づいただろうか?
また、Shapeクラスでは、メインループ内でのnewを減らすため、rvtという変換結果を格納するインスタンスを予め確保してそれを再利用するようにしている。

これで実際の画面上の座標を計算して、A行以降で画面上のx,yの最小値と最大値をそれぞれ求めていてる。

これを行うと、下図のように、この立方体を囲む矩形(くけい)が求まるわけだ。

この矩形にタッチ座標が含まれているかどうか判定して、当たっていたらtrueを返す(B行)というわけなのである。

この計算だとちょっとズレても当たったと判定されてしまうが、それでもかなり難しいゲームになっているので良しとしよう。

さて、次はもっと本格的なものを作ってみよう。
ではまた次回

このエントリーをはてなブックマークに追加
はてなブックマーク - enchant.jsで3Dゲームを作ってみる!
Post to Google Buzz
Share on GREE

Related posts:

  1. Canvasで3Dワイヤーフレームに挑戦!
  2. shi3z式ゲームプログラミング入門 #3 “ゲームらしく”する
  3. かんたんプログラミング #1:亀の子グラフィック
  4. shi3z式ゲームプログラミング入門 #2 あと48時間でゲームプログラマになる方法
  5. shi3z式ゲームプログラミング入門 #4 バナナと配列

Facebook comments:

Post a Comment

Your email is never published nor shared. Required fields are marked *
*
*