Skip to content

JavaScriptでMMOGをつくってみよう その9 世界が現れた

こんにちは。中嶋( @ringo ) です。今回は、前回実装したマルチプレイの基本部分を拡張して、世界をつくりだしました。ゲームの仕組みはまだですが、世界を探検することができます。 ゲーム完成まで、あと少し。

(拙著「オンラインゲームを支える技術」ではJavaScriptよりも実績の多い制作技法を紹介しています。そちらもどうぞ!)

いつもどおり、ソースなどすべてがgithubに上げてあります: https://github.com/kengonakajima/wise9mmo

さて、今回のプレイ動画はこれです(720p,全画面でどうぞ):

今回やっていることは、

  • node.jsサーバを起動して、地形データを作り出す
  • 地形データを動的にソケット経由で読み込んで、ポリゴンデータを生成
  • その中を自由に移動
  • controlキーで地面を掘る、tabキーで花を置く

以上です。今回でサーバは500行、クライアントは1500行ぐらいになっています。「陣取りゲーム」実装のためのベースとなる世界ができたといえます。

上から順番に見ていきましょう。

まず地形生成は、nodeサーバを立ち上げた直後におこないます。世界のサイズは128x128x128です。起動時に生成処理で10秒以上かかりますが辛抱してください。

サーバでは、Fieldというnodeのモジュールを定義して、それを使っています。実際に生成をする部分は以下のようなかんじです。

exports.generate = function( hsize, vsize ) {
    var fld = new Field( hsize, vsize );

    fld.fill( 0,0,0, hsize,vsize,hsize, Enums.BlockType.AIR ); // 世界を空気で満たす
    fld.fill( 0,0,0, hsize,1,hsize, Enums.BlockType.STONE ); // 地盤を置く
    fld.fill( 4,0,4, 100,1,100, Enums.BlockType.WATER );   //水面

    var d = 20;
    fld.fill( 4,1,4, 8+d,2,8+d, Enums.BlockType.STONE );   // 高台を置く
    fld.fill( 5,2,5, 7+d,3,7+d, Enums.BlockType.SOIL );   // その上に水を置く
    fld.fill( 6,3,6, 6+d,4,6+d, Enums.BlockType.GRASS );   // その上に水を置く    
    fld.fill( 7,4,7, 5+d,5,5+d, Enums.BlockType.GRASS );   //
   
    fld.set( 8,5,8, Enums.ItemType.REDFLOWER );   //
    fld.set( 8,5,10, Enums.ItemType.BLUEFLOWER );   //

    // 木を4本
    fld.putTree(12,12);
    fld.putTree(17,12);
    fld.putTree(17,17);        
    fld.putTree(12,17);
   
   
   
    fld.fill( 53,1,53, 54,20,54, Enums.BlockType.LADDER );   //

    // 山をいっぱいおく
    for(var i=0;i=hsize||mz+msz>=hsize)continue;
        var t;
        if( Math.random() < 0.5 ){
            t = Enums.BlockType.SOIL;
        } else {
            t = Enums.BlockType.STONE;
        }
        fld.putMountain( mx,0,mz, msz, t);
    }

    fld.fill( 9,15,9, 28,17,28, Enums.BlockType.SOIL ); // 土の天井

    // 後処理
    fld.growGrass();
    fld.recalcSunlight(0,0,hsize,hsize);
    fld.stats(30);
   
    return fld;
};

prototypeを使ってFieldクラスに各種関数を追加しています。fill関数は直方体の塗りつぶし、setは1マス塗り、 putTreeは木を置く、putMountainは山を置く(単なる球体ですが) などを駆使して世界をつくっています。 本来ならば、perlinノイズというアルゴリズムを使って、いい感じに生成しますが、それは後でなんとかしましょう。
地形データを生成した後は、growGrassで、日の当たる面に草をはやし、 recalcSunlightで、奥まったところを暗くする処理をしています。「地下は暗くて見えない」ということは必須だとおもうのでそうしてみました。

ここでJavaScriptの処理の遅さに苦労しました。128x128x128=だいたい100万要素の配列を数回、3次元的にスキャンする処理に10秒以上かかってしまうのです。C言語だったら気づかない程度の時間で終わるところです。こうした単純なスキャン処理でいちいちV8エンジンの特性を調べたり、かしこいアルゴリズムを考えたり、処理を省略できないか考えたりといったことをするのは骨が折れます。(当然C言語ではメモリ管理を自分でやる必要があるのもかなり骨が折れるのですが)

次に、サーバで生成したデータは、動画をみてもわかるとおり、1フレームあたり1チャンク(8x8x8=512マス)づつ取っています。これを取っている間は、だいたい20fpsに落ちます(動画は10fpsになっていますが、これはビデオキャプチャーソフトのせいです)。 地形を取得しおわると、60fpsに戻ります。
地形ロード処理をしてるときに処理が重い原因は、2~3kbあるJSONの処理が重いのか、ポリゴンの生成処理が重いのか、詳しく調べようとしたのですが、簡単にはわかりませんでした。
こういうときに必須のツールであるプロファイラは、Unity3Dのプロ版にしかないようです。商売がうまいですね。。

ところで見える範囲はいまは128×128で、かなり遠くの地形まで見えることがわかります。minecraftの世界は無限ですが、今回の陣取りゲームは、有限のほうがゲームが面白くなりそうなので、まずは世界のサイズも128×128に固定にしています。ゲーム実装の本番では、多分もっと広く、1024とかにするかも。

またひとつ、動画を見ていると気づくとおもいますが、壁を掘ったときに、一瞬、後ろの世界が見えてしまうことがあります。
これは、完全に壁で埋まっているチャンクについてはポリゴンを生成しないというロジックを組みこんでいるので、8x8x8のチャンク境界を越えて掘り進むと、ポリゴンが生成されていない部分が一瞬見えてしまい、その後地形データがサーバから送信されてきて埋まるという動きになっているためです。 
向こうが見えるのは不自然なので、掘ったときにサーバに複数チャンクの分のデータを送信するとき、それが全部結果が返ってくるまで待ってから描画内容を更新するといった改善をすると、より美しくなるでしょう。

地形からとってきたデータを画面に描画するときは、すべてプログラムでポリゴンの情報(頂点、法線ベクトル、UV値、三角形情報)を生成しています。それが ChunkMaker.jsです。 テクスチャアトラスは、512×512の1枚だけで、単位は16×16ピクセルです:

地形の立方体を描画するときは半透明なし、花を描画するときは透明ありのマテリアルにしています。
Unity3Dではぽちぽちとクリックするだけでどんな画像でもインポートできるので極端に楽です。これは一度慣れると離れられないですね。。

次は世界の中の移動です。

なめらかに移動できるようにするために、フレームレートをできるだけ上げることはもちろんですが「ひっかからないようにする」という処理をいれています。業界用語では「ぬるぬる移動」といいます。

    var blkn = cs.getBlock( nextpos.x, nextpos.y, nextpos.z);

    var x_ok=false;
    var y_ok=false;
    var z_ok=false;
   
    if( blkn == null || blkn == cs.AIR ){
        // 進む先が空気の場合
        x_ok = y_ok = z_ok = true;
    } else {
        // 進む先が壁などの場合
        // y
        var nextpos2 = Vector3( transform.position.x, nextpos.y, transform.position.z );
        var blkcur2 = cs.getBlock( nextpos2.x, nextpos2.y, nextpos2.z );
        if( blkcur2 != null && blkcur2 == cs.AIR ) y_ok = true;
        // z
        var nextpos3 = Vector3( transform.position.x, transform.position.y, nextpos.z );
        var blkcur3 = cs.getBlock( nextpos3.x, nextpos3.y, nextpos3.z );
        if( blkcur3 != null && blkcur3 == cs.AIR ) z_ok = true;
        // x
        var nextpos4 = Vector3( nextpos.x, transform.position.y, transform.position.z );
        var blkcur4 = cs.getBlock( nextpos4.x, nextpos4.y, nextpos4.z );
        if( blkcur4 != null && blkcur4 == cs.AIR ) x_ok = true;
    }

    var finalnextpos = transform.position;
    if( x_ok ) finalnextpos.x = nextpos.x;
    if( y_ok ) finalnextpos.y = nextpos.y;
    if( z_ok ) finalnextpos.z = nextpos.z;


    transform.position = finalnextpos;

これは、ブロックが完全にXYZ各軸に平行に並んでいるので、
「次に進もうとしてる位置にブロックが無い(AIR)ならそのまますすむが、もし存在するなら、X軸だけ、Y軸だけ、Z軸だけを試しに動かしてみて、もし行ける方向が存在するならそっちの方向に動く」というロジックです。
世界が複雑なポリゴンや曲面で構成されてると、こんなに単純なコードでは「ぬるぬる移動」は実現できませんが、世界データが単純なのでこの程度で済みました。

最後に「花を置く」という動作ですが、この動作を入れた理由は陣取りゲームのためです。

陣取りゲームの内容は、最初の案では「空間の占拠率を高める」だったけど、「赤い花チームと青い花チームに分けて、花をたくさん増やして多いほうが勝ち」というのはどうかと考えています。 一定以上日が当たる場所や明かりのある場所では花がひとりでに増えていくという寸法です。

ところで、作ってみて驚いたのですが、だいぶんminecraftの雰囲気に似てしまいました。 地面の表面の草の見た目と、ブロックの陰影が、似てしまう原因かもしれません。

ブロックの微妙な陰影とは、たとえば、「へこんだくぼみになっているところはすこし暗くする」という処理です。

最初はこの処理は不要かと思っていたのですが、実際に作ってみると、すべてがのっぺりして、段差の形状を認識できず、移動に支障を来すほどだったので、移動のしやすさのためには必須だと考えました。これは単なる演出ではなかったのです。
minecraftの仕様には無駄がないということを思い知らされます。

この処理はやっつけで書いたら「ひどいif文の羅列」になりました。またデータみたいなコードの登場です。。

                   // z=0の面
                    lts[0]=lts[1]=lts[2]=lts[3]=lights[ toLightIndex(lx,ly,lz-1,sz+2) ];
                    if( lts[0]!=-1 )drawflags[0]=1; else drawflags[0]=0;
                    // 0:z=0,0の角
                    if( lights[toLightIndex(lx,ly-1,lz-1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz-1,sz+2)]==-1) lts[0]-=2;
                    if( lights[toLightIndex(lx-1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz-1,sz+2)]==-1) lts[0]-=2;
                    // z=0,1の角
                    if( lights[toLightIndex(lx,ly-1,lz-1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz-1,sz+2)]==-1) lts[1]-=2;
                    if( lights[toLightIndex(lx+1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz-1,sz+2)]==-1) lts[1]-=2;
                    // z=0,2の角
                    if( lights[toLightIndex(lx+1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz-1,sz+2)]==-1) lts[2]-=2;
                    if( lights[toLightIndex(lx,ly+1,lz-1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz-1,sz+2)]==-1) lts[2]-=2;
                    // z=0,3の角
                    if( lights[toLightIndex(lx-1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz-1,sz+2)]==-1) lts[3]-=2;
                    if( lights[toLightIndex(lx,ly+1,lz-1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz-1,sz+2)]==-1) lts[3]-=2;
                   
                    // z=1の面
                    lts[4]=lts[5]=lts[6]=lts[7]=lights[ toLightIndex(lx,ly,lz+1,sz+2) ];
                    if( lts[4]!=-1 )drawflags[1]=1; else drawflags[1]=0;                    
                    // z=1,4の角
                    if( lights[toLightIndex(lx-1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz+1,sz+2)]==-1) lts[4]-=2;
                    if( lights[toLightIndex(lx,ly-1,lz+1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz+1,sz+2)]==-1) lts[4]-=2;
                    // z=1,5の角
                    if( lights[toLightIndex(lx+1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz+1,sz+2)]==-1) lts[5]-=2;
                    if( lights[toLightIndex(lx,ly-1,lz+1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz+1,sz+2)]==-1) lts[5]-=2;
                    // z=1,6の角
                    if( lights[toLightIndex(lx+1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz+1,sz+2)]==-1) lts[6]-=2;
                    if( lights[toLightIndex(lx,ly+1,lz+1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz+1,sz+2)]==-1) lts[6]-=2;
                    // z=1,7の角
                    if( lights[toLightIndex(lx-1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz+1,sz+2)]==-1) lts[7]-=2;
                    if( lights[toLightIndex(lx,ly+1,lz+1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz+1,sz+2)]==-1) lts[7]-=2;

                    // x=0の面
                    lts[8]=lts[9]=lts[10]=lts[11]=lights[toLightIndex(lx-1,ly,lz,sz+2)];
                    if( lts[8]!=-1 )drawflags[2]=1; else drawflags[2]=0;
                    // x=0,0の角
                    if( lights[toLightIndex(lx-1,ly-1,lz,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz-1,sz+2)]==-1) lts[8]-=2;
                    if( lights[toLightIndex(lx-1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz-1,sz+2)]==-1) lts[8]-=2;
                    // x=0,4の角
                    if( lights[toLightIndex(lx-1,ly-1,lz,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz+1,sz+2)]==-1) lts[9]-=2;
                    if( lights[toLightIndex(lx-1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly-1,lz+1,sz+2)]==-1) lts[9]-=2;
                    // x=0,7の角
                    if( lights[toLightIndex(lx-1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz+1,sz+2)]==-1) lts[10]-=2;
                    if( lights[toLightIndex(lx-1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz+1,sz+2)]==-1) lts[10]-=2;
                    // x=0,3の角
                    if( lights[toLightIndex(lx-1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz-1,sz+2)]==-1) lts[11]-=2;
                    if( lights[toLightIndex(lx-1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz-1,sz+2)]==-1) lts[11]-=2;

                    // x=1の面
                    lts[12]=lts[13]=lts[14]=lts[15]=lights[toLightIndex(lx+1,ly,lz,sz+2)];
                    if( lts[12]!=-1 )drawflags[3]=1; else drawflags[3]=0;
                    // x=1,1の角
                    if( lights[toLightIndex(lx+1,ly-1,lz,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz-1,sz+2)]==-1) lts[12]-=2;
                    if( lights[toLightIndex(lx+1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz-1,sz+2)]==-1) lts[12]-=2;
                    // x=1,5の角
                    if( lights[toLightIndex(lx+1,ly-1,lz,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz+1,sz+2)]==-1) lts[13]-=2;
                    if( lights[toLightIndex(lx+1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly-1,lz+1,sz+2)]==-1) lts[13]-=2;
                    // x=1,6の角
                    if( lights[toLightIndex(lx+1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz+1,sz+2)]==-1) lts[14]-=2;
                    if( lights[toLightIndex(lx+1,ly,lz+1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz+1,sz+2)]==-1) lts[14]-=2;
                    // x=1,2の角
                    if( lights[toLightIndex(lx+1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz-1,sz+2)]==-1) lts[15]-=2;
                    if( lights[toLightIndex(lx+1,ly,lz-1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz-1,sz+2)]==-1) lts[15]-=2;

                    // y=0の面
                    lts[16]=lts[17]=lts[18]=lts[19]=lights[toLightIndex(lx,ly-1,lz,sz+2)];
                    if( lts[16]!=-1 )drawflags[4]=1; else drawflags[4]=0;                    
                    // y=0,0の角
                    if( lights[toLightIndex(lx-1,ly-1,lz,sz+2)]==-1) lts[16]-=2;
                    if( lights[toLightIndex(lx,ly-1,lz-1,sz+2)]==-1) lts[16]-=2;
                    // y=0,1の角
                    if( lights[toLightIndex(lx+1,ly-1,lz,sz+2)]==-1) lts[17]-=2;
                    if( lights[toLightIndex(lx,ly-1,lz-1,sz+2)]==-1) lts[17]-=2;
                    // y=0,5の角
                    if( lights[toLightIndex(lx+1,ly-1,lz,sz+2)]==-1) lts[18]-=2;
                    if( lights[toLightIndex(lx,ly-1,lz+1,sz+2)]==-1) lts[18]-=2;
                    // y=0,4の角
                    if( lights[toLightIndex(lx,ly-1,lz+1,sz+2)]==-1) lts[19]-=2;
                    if( lights[toLightIndex(lx-1,ly-1,lz,sz+2)]==-1) lts[19]-=2;

                    // y=1の面
                    lts[20]=lts[21]=lts[22]=lts[23]=lights[toLightIndex(lx,ly+1,lz,sz+2)];
                    if( lts[20]!=-1 )drawflags[5]=1; else drawflags[5]=0;
                    // y=1,3の角
                    if( lights[toLightIndex(lx-1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz-1,sz+2)]==-1) lts[20]-=2;
                    if( lights[toLightIndex(lx,ly+1,lz-1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz-1,sz+2)]==-1) lts[20]-=2;
                    // y=1,2の角
                    if( lights[toLightIndex(lx,ly+1,lz-1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz-1,sz+2)]==-1) lts[21]-=2;
                    if( lights[toLightIndex(lx+1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz-1,sz+2)]==-1) lts[21]-=2;
                    // y=1,6の角
                    if( lights[toLightIndex(lx+1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz+1,sz+2)]==-1) lts[22]-=2;
                    if( lights[toLightIndex(lx,ly+1,lz+1,sz+2)]==-1 && lights[toLightIndex(lx+1,ly+1,lz+1,sz+2)]==-1) lts[22]-=2;
                    // y=1,7の角
                    if( lights[toLightIndex(lx,ly+1,lz+1,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz+1,sz+2)]==-1) lts[23]-=2;
                    if( lights[toLightIndex(lx-1,ly+1,lz,sz+2)]==-1 && lights[toLightIndex(lx-1,ly+1,lz+1,sz+2)]==-1) lts[23]-=2;

頂点が24個で、それぞれが2個〜4個の周囲のボクセルの情報に影響を受けて明るさが決まるのでこんなコードになっています。
このコードは最終的には1個のデータ配列と3重ループ程度にリファクタリングできるコードです。が、まあ動いてるのと、サブルーチンに閉じ込めてあるので、よしとします。

こんな風にだーっとif文で書いて、遠くから眺めて、えいやっとリファクタしてループにするという作業は、誰もがよくやるのではないでしょうか。

動いてる状態がかなりminecraftに似てきたので、一応確認をしておきます。

お手本のminecraftの利用規約を見ていると「Notch(作者)が作ったものを再配布するな。以上!」と書いてあります。この連載でgithubに上げてあるものはドット絵も含めて全部、原作とは関係なく自分が作ったものなので、問題はないとおもいます。

次回は、陣取りゲームの内容を実装してみましょう。

(続く)

このエントリーをはてなブックマークに追加
はてなブックマーク - JavaScriptでMMOGをつくってみよう その9 世界が現れた
Post to Google Buzz
Share on GREE

Related posts:

  1. JavaScriptでMMOGをつくってみよう その8 マルチプレイ、協力して地面を掘る
  2. JavaScriptで MMOG をつくってみよう その2
  3. JavaScriptでMMOGをつくってみよう その7 JSON-RPC風通信

Facebook comments:

Post a Comment

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