2012年2月17日金曜日

2D迷路データとその解を自動生成する(6):迷路の解を用いてゴール・敵・アイテムの配置を決める

このエントリでは、迷路の解を用いてゴール・敵・アイテムの配置を決める方法のヒントを示します。

これまでに、「2D迷路データとその解を自動生成する(4):「ポテンシャル法」で迷路を解いてみる」「2D迷路データとその解を自動生成する(5):「ポテンシャル法」で迷路を解くための実装」のエントリを通じて、次のようなデータを得られることについて述べてきました。

  • stepMap 迷路上のそれぞれの地点がスタート地点から何歩で到達できる場所なのかを示す2次元配列
  • stepList スタート地点からの同じ距離ごとに、迷路内地点のリストをまとめた2次元配列
  • pathStepTotal この迷路上に「道」のセルがいくつあるかを数える変数

迷路のゴール地点は、スタート地点から到達するのに、なるべく多くの歩数がかかるような場所にあるべきです。 そこで、stepListの配列の一番最後の要素を使うのです。これは、その迷路の中で、スタート地点から一番遠い地点を格納した配列になっています。 つまり、stepListの配列の一番最後の要素の配列のうち、いずれかの要素を選んでゴール地点とすれば良いと考えられます。 …あとは、ゴール地点の座標の周囲について、マップチップの内容を置き換える and/or 別のスプライトを重ねて置くことで、 いかにも「ゴールだ!」と分かるような見た目に演出してやればOKです。

なお、ここまでは説明の便宜上、「スタート地点」と、そこからの「同じ距離ごとの迷路内地点」という言い方をしてきました。 でも、これはようするに、迷路上の任意の地点Aから、任意の地点Bまでについて、最短距離での道のりの歩数を調べる方法を実現しているのです。 そう考えれば、この方法を応用して、いろいろなしくみを実現できることがわかります。

たとえば、ゲームスタート時に、敵キャラが主人公キャラのすぐ近くに居て、いきなりダメージを喰らってしまう!というのはアンフェアです。 そういうことがないように、敵キャラの初期位置は、主人公キャラから「一定以上の距離」になるようにするべきです。 このためには、stepListの配列で最初のいくつかの要素を除外したものから、敵を配置する場所の候補を作り出せば良いということになります。

また、アイテムを配置するときに、「扉」と「その鍵」を一定以上離れた場所に配置したいといったような場合、 ゲームプレイ中に、主人公キャラを追いかける形で、迷路の最短距離を賢く確実に移動する敵キャラを作りたい場合にも、 同じような方法を用いることができるでしょう。

ちなみに、現在のThe Flame Knightでは、ゴール地点の配置、敵キャラ・落ちているアイテムの配置をするときに、スタート地点からの距離を用いています。

2D迷路データとその解を自動生成する(5):「ポテンシャル法」で迷路を解くための実装

先に「2D迷路データとその解を自動生成する(2):「穴掘り法」による迷路データ生成」のエントリで説明したMazeクラスに対して、_initAnswerメソッドを追加します。

このメソッドを実行することで、

  • stepMap 迷路上のそれぞれの地点がスタート地点から何歩で到達できる場所なのかを示す2次元配列
  • stepList スタート地点からの同じ距離ごとに、迷路内地点のリストをまとめた2次元配列
  • pathStepTotal この迷路上に「道」のセルがいくつあるかを数える変数

というようなインスタンス変数が作成され、利用できるようになります。

_initAnswerのコードは以下のとおりです。

_initAnswer: function(){

    var COLLISION = 0;
    var NOT_INITIALIZED = -1;

    // この迷路に「道」のセルがいくつあるかを数える変数
    this.pathStepTotal = 0;

    // stepMap として、迷路上のそれぞれの地点がスタート地点から何歩で到達できる場所なのかを示す2次元配列を作成する。
    // 迷路外周(最上段・最下段・左端・右端)と、迷路上の「壁」の部分に「COLLISON(衝突)」の値、
    // 迷路上の「道」の部分には「未初期化状態」の値を代入しておく。

    this.stepMap = new Array(this.mazeHeight);

    for(var i = 0; i < this.mazeHeight; i++){
        var answerRow = new Array(this.mazeWidth);
        for(var j = 0; j < this.mazeWidth; j++){
            answerRow[j] = this.hitTest(j,i)? COLLISION : NOT_INITIALIZED;
        }
        this.stepMap[i] = answerRow;
    }

    // スタート地点からの同じ距離ごとに、迷路内地点のリストをまとめた2次元配列を作成する。
    this.stepList = [];

    // スタート地点を設定する
    this.stepMap[this.sy][this.sx] = 1;

    var stepCount = 1; //調査する場所の、スタート地点からの歩数。最初は1歩目について調査。
    this.stepList[stepCount - 1] = [new Point(this.sx, this.sy)]; // 0歩目の場所は、スタート地点。

    while(true){

        var nextStepList = new Array(); //現在ステップ数の場所から到達できる、次のステップの地点をすべて格納するためのリスト

        for(var i=0; i < this.stepList[stepCount-1].length; i++){//(stepCount-1)歩目の場所のリストについて繰り返し。
            var p = this.stepList[stepCount-1][i]; // (stepCount-1)歩目の場所を、ここでの注目地点とする。
            var px = p.x;
            var py = p.y;
            //注目地点の「上」のセルが未初期状態かどうか
            if(this.stepMap[py-1][px] == NOT_INITIALIZED){
                this.stepMap[py-1][px] = stepCount+1;
                nextStepList.push(new Point(px, py-1));
            }
            //注目地点の「下」のセルが未初期状態かどうか
            if(this.stepMap[py+1][px] == NOT_INITIALIZED){
                this.stepMap[py+1][px] = stepCount+1;
                nextStepList.push(new Point(px, py+1));
            }
            //注目地点の「左」のセルが未初期状態かどうか
            if(this.stepMap[py][px-1] == NOT_INITIALIZED){
                this.stepMap[py][px-1] = stepCount+1;
                nextStepList.push(new Point(px-1, py));
            }
            //注目地点の「右」のセルが未初期状態かどうか
            if(this.stepMap[py][px+1] == NOT_INITIALIZED){
                this.stepMap[py][px+1] = stepCount+1;
                nextStepList.push(new Point(px+1, py));
            }
        }
        if(nextStepList.length == 0){
            //もうこれ以上遠くへは到達できない場合はループを脱出して終了!
            break;
        }else{
            // 次のステップ地点を格納したリストを、this.stepList配列のステップ数番目に格納。
            this.stepList[stepCount++] = nextStepList;
            // このステップでの道のセルの数を合計に加算
            this.pathStepTotal += nextStepList.length;
        }
    }
}

さらに、この_initAnswerで作成した stepMap, stepList の内容を確認するためのdebugメソッドを追加しておくことにします。 このメソッドを実行すると、「2D迷路データとその解を自動生成する(3):「ポテンシャル法」で迷路を解いてみる」のエントリ内での説明図のような内容を、コンソールに対して出力します。

    debug: function(){
     // 地図のコンソールに表示
     for(var i = 0; i < this.mazeData.length; i++){
         var line = "";
         for(var j = 1; j < this.mazeData[i].length - 1; j++){
          line += " "+String.fromCharCode(48+this.stepMap[i][j]);
         }
         console.log(line);
     }

     // 歩数ごとの地点リストをコンソールに表示
     for(var i = 0; i < this.stepList.length; i++){
         var line = ""+i+":\t"+String.fromCharCode(48+i)+"\t";
         for(var j = 0; j < this.stepList[i].length; j++){
          line += " "+this.stepList[i][j].toString();
         }
         console.log(line);
     }
    }

なお、大きな迷路についてdebugを実行すると、ASCII文字として表示できない場合が出てくると思いますが、面倒くさいのでチェックしてません!

2D迷路データとその解を自動生成する(4):「ポテンシャル法」で迷路を解いてみる

ここまでのエントリで、「どんな迷路を、どのようにつくるか」のうち、 (1)大きな構造の生成、(2)実際のMapに与えるdata生成 までを説明しました。 ここでは、(3)細部の構造の生成 について述べる前に、「どのように迷路の解をつくるか」について、具体的な例を示しながら解説をします。

次の図のような16x16の迷路があったとします。0が壁、1がスタート地点です。

この迷路について「ポテンシャル法(等高線法)」と呼ばれるアルゴリズムでの処理をした結果が次の図です。

  • 「0」は、「壁」を表しています。
  • 「0」以外の文字は、「道」を表しています。
  • 「1」が出発点で、出発点から最短で到達可能な歩数によって、ASCIIコード順にそれぞれの文字や記号が示されています。

1の出発点から1歩離れるごとに、数値〜文字のASCIIコードが大きいものになっていく様子が読み取れるでしょうか?

さらに、スタート地点からの距離ごとに、迷路内地点をリスト化してまとめたものを示しておきます。

0:  1 (3,8)
1:  2 (2,8) (4,8)
2:  3 (2,7) (5,8)
3:  4 (2,6) (6,8)
4:  5 (2,5) (6,9) (7,8)
5:  6 (2,4) (6,10) (7,7) (8,8)
6:  7 (2,3) (6,11) (5,10) (7,10) (7,6) (8,7) (8,9)
7:  8 (2,2) (6,12) (5,11) (4,10) (8,10) (7,5) (6,6) (8,6) (9,9)
8:  9 (3,2) (5,12) (7,12) (4,11) (3,10) (9,10) (7,4) (6,5) (5,6) (9,6) (10,9)
9:  : (4,2) (5,13) (4,12) (8,12) (2,10) (9,11) (10,10) (6,4) (8,4) (4,6) (10,6)
10: ; (4,3) (5,14) (3,12) (8,13) (9,12) (10,11) (9,4) (4,5) (11,6)
11: < (4,4) (4,14) (6,14) (2,12) (8,14) (9,13) (11,11) (10,4) (12,6)
12: = (3,14) (7,14) (2,13) (10,13) (12,11) (11,4) (12,5)
13: > (2,14) (11,13) (12,12) (12,4)
14: ? (12,13) (12,3)
15: @ (12,2)
16: A (11,2)
17: B (10,2)
18: C (9,2)
19: D (8,2)
20: E (7,2)
21: F (6,2)

この例では、出発点からいちばん遠いのは、Fの文字が置かれている(6,2)の点で、ここが21歩で到達するゴール地点だということになります。

こうしたデータ構造を作ってしまえば、「迷路を解く」のは、もう簡単です。 このゴールの地点から始めて、ASCIIコードを逆順に文字を辿れるような方向へと歩いていけば、最短距離での経路が求められるということになるわけです。

次のエントリで、こうしたアルゴリズムの実装例を示していきます。

2D迷路データとその解を自動生成する(3):迷路データからのマップデータ生成

ひきつづき、「どんな迷路を、どのようにつくるか」について、述べていきます。

(1)大きな構造の生成、(2)実際のMapに与えるdata生成、(3)細部の構造の生成、という 3段階のうち、「(2)実際のMapに与えるdata生成」についてを解説します。

以下、Worldというクラスを定義し、そのコンストラクタ内で一つ前のエントリで生成した迷路データをもとにして、enchant.jsのMapインスタンスに与える前景の地図データ・背景の地図データを作成するというプログラムを示していきます。

このWorldのインスタンスから、 「主人公キャラクタ開始位置」「地形衝突判定データ」「マップ前景データ」「マップ背景データ」などの それぞれの値が得られるものとします。

ここでは、Worldの実装として、 単純な「壁の最小の厚さと、道の最小の幅とが、基本的には同じ」であるような迷路マップを作ることを考え、 コンストラクタには、次のような引数を与えることにします。

  • mapWidth マップ全体の横幅のセル数
  • mapHeight マップ全体の高さのセル数
  • mapCellUnitSize 1セルがマップチップ何個分であるのかを表す個数
  • block_id 「岩」(通行不可能セル)を表すマップチップの値
  • wall_id 「壁」(通行可能セル)を表すマップチップの値
  • empty_id 「道」(通行可能セル)を表すマップチップの値

ようするに、迷路データのそれぞれのセルを、mapCellUnitSizeの分だけ単純に整数倍して引き伸ばした内容を迷路マップとして作っていこうというものです。 マップ全体の横幅・高さのセル数であるmapWidth, mapHeightを引数とし、迷路データの幅・高さのセル数については、内部的に算出するものとします。 また、地形の基本マップチップである「岩」「壁」「道」を表す値として、block_id, wall_id, empty_idの3種類の値を与えることにします。

この部分のプログラムは、特に複雑なアルゴリズムを用いているわけではなく、とってもかんたんです。

// 世界クラスの定義
var World = enchant.Class.create({
    /**
      @param {Number} mapWidth マップ全体の横幅のセル数
      @param {Number} mapHeight マップ全体の高さのセル数
      @param {Number} mapCellUnitSize 1セルがマップチップ何個分であるのかを表す個数
      @param {Number} block_id 「岩」(通行不可能セル)を表すマップチップの値
      @param {Number} wall_id 「壁」(通行可能セル)を表すマップチップの値
      @param {Number} empty_id 「道」(通行可能セル)を表すマップチップの値
    */
    initialize: function(mapWidth, mapHeight, mapCellUnitSize, mazeComplexity,
                                    block_id, wall_id, empty_id){

            //mazeの幅・高さを算出
     var mazeWidth = Math.floor(mapWidth/mapCellUnitSize);
     var mazeHeight = Math.floor(mapHeight/mapCellUnitSize);

            //mazeの生成
     var maze = new Maze(mazeWidth, mazeHeight,
                                        mazeComplexity,
                   startPoint.x, startPoint.y);

            //背景、前景、衝突判定データの初期化
     var bgMapData = new Array(mapHeight);
     var fgMapData = new Array(mapHeight);
     var collisionData = new Array(mapHeight);
     for(var i=0; i < mapHeight; i++){
         bgMapData[i] = new Array(mapWidth);
         fgMapData[i] = new Array(mapWidth);
         collisionData[i] = new Array(mapWidth);
     }

            // 背景・前景・衝突判定データの内容設定
     for(var i = 0; i < mapHeight; i++){
         for(var j = 0; j < mapWidth; j++){
                        // 迷路上の該当位置が「侵入不可能」セルであるかどうか
          var isCollid = maze.hitTest(Math.floor(j/mapCellUnitSize), Math.floor(i/mapCellUnitSize));

          if(isCollid){
              //該当位置が「侵入不可能」の場合は、壁ブロックを表示
              fgMapData[i][j] = block_id;
              bgMapData[i][j] = block_id;
              collisionData[i][j] = 1;
          }else{
              //該当位置が「侵入不可能」ではない場合は、「道」(床)を表示
              fgMapData[i][j] = empty_id;
              bgMapData[i][j] = empty_id;
              collisionData[i][j] = 0;
          }

          if(i % mapCellUnitSize == 0 && mapCellUnitSize <= i){
              var northIsCollid = maze.hitTest(Math.floor(j/mapCellUnitSize), Math.floor(i/mapCellUnitSize) - 1);
              if(! isCollid && northIsCollid){
               //上側が「侵入不可能」、該当位置が「侵入可能」の場合は、背景を「壁」に変更
               bgMapData[i][j] = wall_id;
              }
          }

          if(i % mapCellUnitSize == mapCellUnitSize - 1 && i < mapHeight - mapCellUnitSize){
              var southIsCollid = maze.hitTest(Math.floor(j/mapCellUnitSize), Math.floor(i/mapCellUnitSize) + 1);
              if(! isCollid && southIsCollid){
               //下側が「侵入不可能」、該当位置が「侵入可能」の場合は、前景を「岩」に変更
               fgMapData[i][j] = block_id;
              }
          }
         }
     }

     //プレイヤー開始位置
     this.startPoint = new Point(maze.sx * mapCellUnitSize,
                                maze.sy * mapCellUnitSize);

     //迷路データ
     this.maze = maze;
     //地形衝突判定データ
     this.collisionData = collisionData;
     //マップ前景データ
     this.fgMapData = fgMapData;
     //マップ背景データ
     this.bgMapData = bgMapData;
    },
    ....略....
});

なおここでは、「斜め上からの見下ろし」を実現するために、 キャラクタの上半身が奥の「壁」に重なって表示されたり、 下半身が手前の「岩」に隠れたりするために、

  • 上側が「侵入不可能」、該当位置が「侵入可能」の場合は、マップ背景の該当位置セルの値を「壁」に変更する
  • 下側が「侵入不可能」、該当位置が「侵入可能」の場合は、マップ前景の該当位置セルの値を「岩」に変更する

という後処理を加えています。こうした内容の「マップ背景」「マップ前景」を、 次のような順番でステージに追加してやれば、「斜め上からの見下ろし」のような表現を実現できることになります。

  • マップ背景
  • キャラクタ、アイテム
  • 爆発などの効果の表示
  • マップ前景
  • スコアなどの表示

2D迷路データとその解を自動生成する(2):「穴掘り法」による迷路データ生成

「どんな迷路を、どのようにつくるか」について、述べていきます。

ここでは、(1)大きな構造の生成、(2)実際のMapに与えるdata生成、(3)細部の構造の生成、という 3段階のうち、まずは「(1)大きな構造の生成」についてを解説します。

準備のために、2D座標空間上の点を表すPointクラスを用意します。

//点を表すクラス
Point = enchant.Class.create({
    initialize: function(){
     this.x = 0;
     this.y = 0;
     switch(arguments.length){
     case 1:
         this.x = arguments[0].x;
         this.y = arguments[0].y;
         break;
     case 2:
         this.x = arguments[0];
         this.y = arguments[1];
         break;
     }
    },

    toString: function(){
     return "("+this.x+","+this.y+")";
    }
});

では、大きな構造としての(見た目以前の)迷路を生成・表現するMazeクラスを実装していきます。

ここでは、迷路ゲームを作る場合に良く用いられている、「穴掘り法」と呼ばれるアルゴリズムを基本にしています。 この開発にあたっては、Ishida So氏の「迷路自動生成アルゴリズム」のページを参考にさせていただきました。併せて参照されることをおすすめします。

Maze = enchant.Class.create({
    /**
      @param {Number} mazeWidth 迷路全体の横幅
      @param {Number} mazeHeight 迷路全体の高さ
     @param {Number} complexity 迷路内の分岐の多さ
   */
    initialize: function(mazeWidth, mazeHeight, complexity){
     this.mazeWidth = mazeWidth;
     this.mazeHeight = mazeHeight;
     this.sx = Math.floor(rand(mazeWidth-4))+2;//スタート地点のx位置、迷路の左右端にならないように調整
     this.sy = Math.floor(rand(mazeHeight-4))+2;//スタート地点のy位置、迷路の上下端にならないように調整

     this.mazeData = new Array(mazeHeight); //迷路データを格納する配列
     this.route = []; // 迷路上のすべての「道」を格納する配列

     this.empty = 0;
     this.block = 1;

     this._createMaze(complexity); //迷路データの生成
    },

   /** 
     指定されたx,y位置に「道」を掘る
   */
    _dig: function(x, y){
     this.route.push(new Point(x, y));
     this.mazeData[y][x] = this.empty;
    },

    _createMaze: function(complexity){

           // 迷路の配列を、迷路外周(最上段・最下段・左端・右端)は「道」とし、
           // それ以外はすべて「壁」として初期化する。
           // 基本的に、「壁」を掘ることで迷路を作るので、
           // 迷路外周を「道」にしておけば、迷路外まで掘らずに済むのでアルゴリズムを単純化できる。

     for(var i = 0; i < this.mazeHeight; i++){
         var data = (i == 0 || i == this.mazeHeight - 1)? this.empty : this.block;//迷路の最上段と最下段は常に「道」とする
         var mazeRow = new Array(this.mazeWidth);
         mazeRow[0] = this.empty; //迷路の左端は常に「道」とする
         mazeRow[this.mazeWidth-1] = this.empty; //迷路の右端は常に「道」とする
         for(var j = 1; j < this.mazeWidth - 1; j++){
          mazeRow[j] = data;
         }
         this.mazeData[i] = mazeRow;
     }
     
     var direction = new Array(4);

     //「道」を掘るスタート位置
     var sx = this.sx;
     var sy = this.sy;

     //はじめに、スタート位置を掘る
     this._dig(sx, sy);

     for(var j = 0; j < complexity; j++){ //迷路内の分岐の回数だけ繰り返し
         var start = this.route[rand(this.route.length)]; //すでに掘ってある場所から任意の場所を選んで分岐の開始点を設定。
         sx = start.x;
         sy = start.y;

         while(true){
                        //「道」がループしないような「掘れる方向」をチェック。
                       // 堀った道が別の道に突き抜けないように、2つ先まで調べる。
          direction[0] =// 「上」方向には掘れるか?
          this.mazeData[sy-1][sx] != this.empty && amp;
              this.mazeData[sy-2][sx] != this.empty ;
          direction[1] =// 「右」方向には掘れるか?
          this.mazeData[sy][sx+1] != this.empty &&
              this.mazeData[sy][sx+2] != this.empty ;
          direction[2] =// 「下」方向には掘れるか?
          this.mazeData[sy+1][sx] != this.empty &&
              this.mazeData[sy+2][sx] != this.empty;
          direction[3] =// 「左」方向には掘れるか?
          this.mazeData[sy][sx-1] != this.empty &&
              this.mazeData[sy][sx-2] != this.empty ;

                        //穴掘り可能な方向の候補を配列に格納
          var candidate = [];
          for(var i = 0; i < 4; i++){
              if(direction[i]){
               candidate.push(i);
              }
          }
          if(candidate.length == 0){
                           //これ以上掘れない場所なので、穴掘り作業を中断。今回の開始点を穴掘り開始点の候補から削除。
              delete this.route[start];
              break;
          }

                        // 穴掘り可能な方向からひとつの方向をランダムに選ぶ
          switch(candidate[rand(candidate.length)]){
          case 0://「上」に掘る
              this._dig(sx, sy-1);
              sy -= 1;
              break;
          case 1://「右」に掘る
              this._dig(sx+1, sy);
              sx += 1;
              break;
          case 2://「下」に掘る
              this._dig(sx, sy+1);
              sy += 1;
              break;
          case 3:// 「左」に掘る
              this._dig(sx-1, sy);
              sx -= 1;
              break;
          }
         }
     }

     // 迷路の外周を壁で埋め直す
     for(var i = 0; i < this.mazeHeight; i++){
         this.mazeData[i][0] = this.block;
         this.mazeData[i][this.mazeHeight - 1] = this.block;
     }
     for(var j = 1; j < this.mazeWidth - 1; j++){
         this.mazeData[0][j] = this.block;
         this.mazeData[this.mazeWidth-1][j] = this.block;
     }
    },

    /** そこに壁があるかどうかを調べる
      @param {Number} x 調べる場所のx位置
      @param {Number} y 調べる場所のy位置
    */
    hitTest: function(x, y){
     return (0 <= y && y < this.mazeData.length &&
                0 <= x && x < this.mazeData[y].length &&
                this.mazeData[y][x] != this.empty);
    },

    ....略.....
});

2D迷路データとその解を自動生成する(1):仕様策定

ひさびさの更新です。予告していた「2D迷路データを作成する」の件についてのエントリを連続投稿します。

ここでの論点は次の2つです。

  1. どんな迷路を、どのようにつくるか
  2. どのように迷路の解をつくるか

1つめの「どんな迷路を、どのようにつくるか」については、次のような仕様を考えました。

  • 今回作成しているゲーム The Flame Kinght は、単なる迷路脱出ゲームではなく、 迷路上で敵と遭遇した場合に、炎で攻撃して戦ったり、すり抜けて避けたりするような、 アクション要素を持ったゲームです。そのため、フィールド内には 大きな広間のような場所があったり、廊下のような場所があったりといったように、 大きな構造としては変化のあるマップが作られることが望ましいです。
  • 今回使用するマップチップはenchant.js組み込みで提供されている限られた種類の画像です。 そのため、ゲーム世界は基本的には「人工的に作られたダンジョン」とし、 小さな構造としては変化の少ないリアルっぽくないマップでも構わないものとします。
  • 斜め上から見下ろすような視界とし、キャラクタの上半身が奥の壁に重なって表示されたり、 下半身が手前の壁に隠れたりするようにしたいです。

そういうわけで、これを、「穴掘り法」と呼ばれるアルゴリズムを基本に手を加えつつ、 (1)大きな構造の生成(2)実際のMapに与えるdata生成、(3)細部の構造の生成、という 3段階に分けて、いろいろ作りながら試した結果を説明していくことにします。

次に、2つめの「どのように迷路の解をつくるか」です。

ここでは、単に複雑な迷路データを作るだけでなく、それと同時に、 この迷路上をスタート地点からゴール地点まで通り抜けるような経路データを作成する必要があるのです。 そうすれば、マップの自動生成時に、スタート地点から一番離れた場所にボスキャラやゴール地点を配置するなどの、 さまざまなニーズを満たすことができるはずです。このしくみを、 「ポテンシャル法」と呼ばれるアルゴリズムで実現する方法について、基本的な考え方具体的な実装に分けて説明していきます。

2012年2月5日日曜日

The Flame Knight 公開

このブログ上での報告が遅くなりましたが、 9leapにて「The Flame Knight」を公開しました。

The Flame Knight

もともとの「ARPGフレームワークのサンプル」と、そんなに変わっていないように見えるかもしれませんが...

本業の仕事と、週末は家族サービスで忙殺されており、ゲーム開発のための、まとまった時間がなかなか取れずにおります。まぁ、ぼちぼちとやっていくしか。

2012年1月21日土曜日

ちょっと一段落・このあとの予定

というわけで、ここまでいくつかのエントリを費やして、作ってみたもの・考えたことを、書き留めておきました。

このあと、予定しているエントリは、

  • 穴掘り法での迷路自動生成アルゴリズム
  • 最短での迷路脱出経路を求めるアルゴリズム
  • 迷路の最深部にゴールを配置するアルゴリズム
  • 敵移動:迷路内でランダムに動き回るアルゴリズム
  • 敵移動:迷路内でプレイヤーを追跡するアルゴリズム

くらいかなー、と。

ここからは、OpenLeapへの投稿のための作業と並行して、少しづつ書いていきます。

斜め上から見下ろしたときの前後関係に応じたスプライト表示

「斜め上」から見下ろしたようなマップ上では、手前側にいるキャラクタのスプライトの上半身が、 奥側のキャラクタのスプライトの下半身を隠すような感じに、前後関係に応じて重ね合わせて表示をしてやると、 ちょっとリアルでイイ感じになります。 

こうした描画の際の前後関係を判断する基準として、

  • 地形の標高は均一である
  • スプライトの画像上でキャラクタの足が画像の下端に接するように描かれている

という条件のもとならば、「スプライトの左上y座標+スプライトの高さ」の値の大小関係を用いることができます。

そういうわけで、スプライトの y座標の値を更新したときに、自動的に z-index の値のほうも更新されるようなSpriteのサブクラス、 ZSpriteクラス を作ってみることにしました。

ZSprite = new enchant.Class.create(enchant.Sprite,{
    initialize: function(w, h){
     enchant.util.ExSprite.call(this, w, h);
    },

    moveTo: function(x, y){
        this._element.style.zIndex = y + this._height;
     enchant.Sprite.prototype.moveTo.call(this, x, y);
    },

    moveBy: function(dx, dy){
        this._element.style.zIndex = this.y + this._height + dy ;
     enchant.Sprite.prototype.moveBy.call(this, dx, dy);
    },

    zindex: {
     get: function(){
         return this._element.style.zIndex;
     },
     set: function(zindex){
         this._element.style.zIndex = zindex;
     }
    },

    y:{
     get: function(){
         return this._y;
     },
     set: function(y){
            this._element.style.zIndex = y + this._height;
         enchant.Sprite.prototype.y.set.call(this, y);
     }
    }
});

なお、GitHub上でのIssue、「#3 z-indexのプロパティが欲しい」では、 開発者の方が「z-indexによる表示順序の管理は確かに柔軟でその場対応のようなことをやるのには便利なのですが、 様々なプラグイン等を利用した場合に整合性を保つのがやや難しく、enchant.jsでのサポートはしない考えです。」と 述べているので、今後、何か問題が生じる可能性があるのかもしれません(よくわかりません><)。

nineleap.enchant.jsの拡張(2)

前のエントリで紹介したNodeのフェードイン・アウト機能を利用しつつ、 enchant.js に標準添付されているプラグイン nineleap.enchant.js を拡張して、次の機能を実現するプラグインを作成します。

  • 「START」「GAME OVER」をフェードイン・アウトによるトランジション効果を利用して表示する
  • ローカル環境での実行時に、「GAME OVER」画面をタッチ・キーダウンすると「もう一度プレイ」できるようにする

これを、nineleap_fade.enchant.js というファイル名で作成するものとします。 なお、この開発にあたっては、次の点に注意しました。

  • nineleap.enchant.jsは修正しない。
  • ゲーム本体のプログラム(game.js)の修正は、最小限にとどめる。

というわけで、以下、これを実現するコードです。

/**
* ローカル環境でのデバッグ時に、ゲームオーバー画面のクリックまたはキーダウンで
* 「もう一度プレイする」を可能にするためには、この値をtrueにする。
* nineleapに投稿する際にはtrueにしておく。
*/
var NINELEAP_RELEASE_MODE = false;
var START_IMAGE = 'start.png';
var END_IMAGE = 'end.png';

enchant.nineleap_fade = {};

enchant.nineleap_fade.Game = enchant.Class.create(enchant.nineleap.Game, {
    initialize: function(width, height) {
        enchant.nineleap.Game.call(this, width, height);
        this.sceneTransition = false;
        this.overwriteLoadEventListener();
    },

    // enchant.nineleap.Gameにおけるloadイベントリスナを解除・上書き
    overwriteLoadEventListener: function(){
        this._listeners['load'] = [];
        this.addEventListener('load', function() {
            this.startScene = new SplashScene();
            this.startScene.id = 'startScene';
            this.startScene.image = this.assets[START_IMAGE];

            this.pushScene(this.startScene);
            fadeIn(this.startScene, 10);

            this.endScene = new SplashScene();
            this.endScene.id = 'endScene';
            this.endScene.image = this.assets[END_IMAGE];

            this.addTransitionEventListeners();
        });
    },

    // トランジションのためのイベントリスナを登録
    addTransitionEventListeners: function(){
        var game = this;
        this.startScene.addEventListener('touchend', function() {
            game.startScene.removeEventListener('touchend', arguments.callee);
            if (game.started == false && game.sceneTransition == false) {
                game.sceneTransition = true;
                if (game.onstart != null) game.onstart();
                game.onGameStartTouched();
            }
        });

        this.endScene.addEventListener('touchend', function(){
            if(game.sceneTransition == false){
                game.sceneTransition = true;
                game.endScene.removeEventListener('touched', arguments.callee);
                game.onGameEndTouched();//fadeout endScnene and popScene
            }
        });

        this.addEventListener('keydown', function() {
            if (game.currentScene == game.startScene && game.sceneTransition == false){
                game.sceneTransition = true;
                game.removeEventListener('keydown', arguments.callee);
                if (game.started == false) {
                    if(game.onstart != null) game.onstart();
                    game.onGameStartTouched();//fadeout startScnene and popScene
                }
            }
        });

        this.addEventListener('keydown', function() {
            if (game.currentScene == game.endScene && game.sceneTransition == false){
                game.sceneTransition = true;
                game.removeEventListener('keydown', arguments.callee);
                game.onGameEndTouched();//fadeout endScnene and popScene
            }
        });
    },

    //ユーザによるゲーム開始画面のタッチ後に実行される関数,
    onGameStartTouched: function(callback){
        var game = this;
        game.started = true;
        gameStart = true;   // deprecated
        fadeOut(game.startScene, 10, function(){
            if(game.currentScene == game.startScene){
                game.popScene();
            }
            if(callback){
                callback();
            }
            game.sceneTransition = false;          
        });
    },

    //ゲームオーバーのときの終了処理を実行する
    end: function(score, message){
        this.started = false;
        enchant.nineleap.Game.prototype.end.call(this, score, message);
        fadeIn(this.endScene, 10);
    },

    //ユーザによるゲーム終了画面のタッチ後に実行される関数
    onGameEndTouched: function(callback){
        var game = this;

        gameStart = false;   // deprecated

        fadeOut(game.endScene, 10, function(){
            if(game.currentScene == game.endScene){
                game.popScene();
            }
        });

        if(NINELEAP_RELEASE_MODE){
            return;
        }

        fadeOut(game.getGameNode(), 10, function(){

            if(game.reset){
                game.reset();
            }

            fadeIn(game.getGameNode(), 10, function(){

                game.pushScene(game.startScene);

                fadeIn(game.startScene, 10, function(){
                    game.addTransitionEventListeners();
                    if(callback){
                        callback();
                    }
                    game.sceneTransition = false;
                });
            });
        });
    },

    /**
     * ゲーム終了・リセット再開時に、
     * フェイドイン・フェイドアウトされるゲーム画面のnodeを返す関数。
     * デフォルトではgame.rootSceneを返すので、開発者側で必要に応じて
     * game.getGameNode = function(){ return fooScene };のように定義して
     * おくことで内部的に呼び出される。
     */
    getGameNode: function(){
        return this.rootScene;
    },

    /**
     * NINELEAP_RELEASE_MODE==falseのときに、自前でゲームを再開するための、
     * 各種ゲーム状態(スコア・自機位置・敵位置など)の初期化処理を記述するための関数。
     * game.reset = function(){....}; のように定義しておくことで内部的に呼び出される。
     */
    reset: function(){
        alert("reset関数を実装してください");
    }

});

以下、使い方です。

  • 開発者は、この冒頭の NINELEAP_RELEASE_MODE 定数を、 9leapに投稿時には true に、ローカルでデバッグしたり遊んだりするときには false に しておく必要があります。
  • Gameをインスタンス化した後に、そのメンバとして getGameNode関数と、reset関数を上書き再定義しておく必要があります。
  • ゲームオーバー時なには、game.started変数をfalseにセットする必要があります。
  • ゲーム終了時・再開時の「ゲーム画面は見えているが、プレイすることができない瞬間」を実現するために、 enterframeのイベントリスナの関数内の処理の冒頭で、game.started変数をチェックして、以降の処理をキャンセルする コードを必要に応じて追加する必要があります。
  • ゲームの実行時に登録したイベントリスナは、不要になった時点で削除してください。たとえば、 this.removeEventListener('enterframe', arguments.callee); のようなコードを 適切な箇所で実行してください。余計なイベントリスナが残っていると、ゲームを「もう一度プレイ」する際、 イベントリスナの登録が多重的に実行されてしまい、ゲームが正常に動作しなくなることがあります。

たとえば、enchant.jsのサンプルである、examples/action/game.jsにおいて、 この nineleap_fade.enchant.js を適用するならば、次のような修正をすることになります。

*** examples/action/game.js 2012-01-11 03:47:36.000000000 +0900
--- examples/action/game_fade.js 2012-01-20 21:47:17.000000000 +0900
***************
*** 62,68 ****
--- 62,85 ----
          bear.jumping = true;
          bear.jumpBoost = 0;
          bear.image = game.assets['chara1.gif'];
+ 
+  game.reset = function(){
+         bear.x = 8;
+         bear.y = -32;
+         bear.vx = 0;
+         bear.vy = 0;
+         bear.ax = 0;
+         bear.ay = 0;
+         bear.pose = 0;
+         bear.jumping = true;
+         bear.jumpBoost = 0;
+         stage.x = 0;
+  };
+ 
          bear.addEventListener('enterframe', function(e) {
+             if(game.started == false){
+                 return;
+             }
              var friction = 0; 
              if (this.vx > 0.3) {
                  friction = -0.3;
***************
*** 162,167 ****
--- 179,185 ----
              this.y = dest.y-2;
  
              if (this.y > 320) {
+                 game.started = false;
                  game.assets['gameover.wav'].play();
                  var score = Math.round(bear.x);
                  this.frame = 3;
***************
*** 171,185 ****
                      this.y += Math.min(Math.max(this.vy, -10), 10);
                      if (this.y > 320) {
                          game.end(score, score + 'mで死にました');
                      }
                  });
-                 this.removeEventListener('enterframe', arguments.callee);
              }
          });
!         var stage = new Group();
          stage.addChild(map);
          stage.addChild(bear);
          stage.addEventListener('enterframe', function(e) {
              if (this.x > 64 - bear.x) { 
                  this.x = 64 - bear.x;
              }
--- 189,206 ----
                      this.y += Math.min(Math.max(this.vy, -10), 10);
                      if (this.y > 320) {
                          game.end(score, score + 'mで死にました');
+                 this.removeEventListener('enterframe', arguments.callee);
                      }
                  });
              }
          });
!         stage = new Group();
          stage.addChild(map);
          stage.addChild(bear);
          stage.addEventListener('enterframe', function(e) {
+                 if(game.started == false){
+                     return;
+                 }
              if (this.x > 64 - bear.x) { 
                  this.x = 64 - bear.x;
              }

2012年1月20日金曜日

Nodeのフェードイン/アウト処理の実現方法

「Nodeをフェードイン・フェードアウトさせるしくみ」を開発するにあたって、 まずは、「ある対象ノードに対してフレームごとに何らかの処理を実行させるしくみ」を作ってみます。

対象となるノードとして、Spriteではなく、GroupやSceneを与えても、ちゃんと動作させることが目的です。 現在のenchant.jsでのGroupやSceneのオブジェクトは、階層的なひとまとまりのHTML要素ではないので、 単なるjQueryによるアニメーション指定などでは、うまく動かないのです。fpsがうまく合わないと、 画面がちらつく原因にもなります。

なお、ひとつのNodeに対して、たとえばフェードインとフェードアウトが同時に実行されるのを許してしまうと、 画面がちらついたり、悪くすれば無限ループに陥ってしまうようなことがあるかもしれません。 そこで、たとえばフェードイン処理が最後まで完了してから、 次のフェードアウト処理が開始されるというように、 処理をキューに格納して順次実行するような仕組みを作ることにします。

具体的には、以下のようになります。

enchant.effect = {  };

/**
 * ある対象ノードに対してフレームごとの処理をするしくみ
 */
enchant.effect.Action = enchant.Class.create({
    /**
     * フレームごとの処理
     * @param {Node} taregtNode 対象となるノード
     * @param {Function} tickFunc フレームごとに実行される関数
     */
    initialize: function(targetNode, tickFunc){
     this.targetNode = targetNode;
     this.tickFunc = tickFunc;
     this.frame = 0;

     if(! targetNode.queue){
         //対象となるノードにキューがなければ作る
         targetNode.queue = [];
         targetNode.addEventListener('enterframe', function(){
          //フレームごとの処理
          if(targetNode.queue &&0 < targetNode.queue.length){
              //キューが空でなければ最初のアクションを実行
              targetNode.queue[0].tick();
          }else{
              //キューが空ならフレームごとの処理を終了
              targetNode.removeEventListener('enterframe', arguments.callee);
              delete targetNode.queue;
          }
         });
     }
     // キューに自身を登録する
     targetNode.queue.push(this);
    },

    /**
     * フレームごとの処理
     */
    tick: function(){
     this.tickFunc(this);
     this.frame++;
    }
});

次に、上記のコードを利用しつつ、指定したノードについて フェードイン・フェードアウトをするしくみを実現します。


/**
 * ノードとその子ノードに対して、透明度を設定する
 */
enchant.effect.setOpacity = function(targetNode, opacity){
    if(targetNode instanceof Entity){
     targetNode.opacity = opacity;
    }
    if(targetNode.childNodes){
        for (var i = 0, len = targetNode.childNodes.length; i < len; i++) {
         var node = targetNode.childNodes[i];
         setOpacity(node, opacity);
     }
    }
};

/**
 * ノードとその子ノードに対して、徐々に透明度を変化させる
 * @param {Node} taregtNode 対象となるノード
 * @param {Number} from 透明度の初期値
 * @param {Number} to 透明度の最終値
 * @param {Number} time 透明度変化の所要フレーム数
 * @param {Function} onEndCallback 終了時に実行される関数
 */
enchant.effect.fade = function(targetNode, from, to, time, onEndCallback){

    setOpacity(targetNode, from);

    new Action(targetNode, function(action){
     if(action.frame < time){
         var opacity = from + (action.frame / time) * (to - from) ;
         setOpacity(targetNode, opacity);
     }else{
         setOpacity(targetNode, to);
         if(onEndCallback){
          onEndCallback();
         }
         targetNode.queue.shift();
     }
    });
};

/**
 * フェードイン
 * @param {Node} taregtNode 対象となるノード
 * @param {Number} time 透明度変化の所要フレーム数
 * @param {Function} onEndCallback 終了時に実行される関数
 */
enchant.effect.fadeIn = function(targetNode, time, onEndCallback){
    fade(targetNode, 0, 1, time, onEndCallback);
};

/**
 * フェードアウト
 * @param {Node} taregtNode 対象となるノード
 * @param {Number} time 透明度変化の所要フレーム数
 * @param {Function} onEndCallback 終了時に実行される関数
 */
enchant.effect.fadeOut = function(targetNode, time, onEndCallback){
    fade(targetNode, 1, 0, time, onEndCallback);
};

jQueryによるアニメーションのプログラミングように、メソッドチェインを用いていろいろな効果を重ね合わせたり、 thenメソッドの呼び出し時の引数で、フェード処理が終了した時のコールバック関数を指定するといった書き方を できるようにするというのも、面白いかもしれませんが、ひとまずこのくらいの内容でとどめておくことにします。

animation.enchant.jsの問題点:Groupに対して適用できない!

ゲームで面クリアをしたとき、前の面の内容をフェードアウトし、いったん暗転してから、次の面の内容をフェードインさせる... というような画面のトランジション効果を付与するしくみを、なるべく簡単なプログラムで書けるようにしたいものです。

そこでまずは、最新のプラグイン animation.enchant.js が使えないか検討することにしました。animation.enchant.js は、GitHub から開発版のenchant.jsをダウンロードすると、plugins/animation.enchant.js というファイルとして入っています。このプラグインでは、Robert Penner 氏のEasing Equationsを内部的に利用しつつ、jQuery風のAPIでfadeIn,fadeOutなどのアニメーション効果を「スプライトに対して」指定できるようになっています。

しかし残念ながら、この animation.enchant.js は、今回の用途には使えませんでした。ぼくは画面のトランジション効果を実現したいので、単一の「スプライトに対して」効果を適用するのではなく、複数のスプライトによる「グループに対して」効果を適用したいのです。フェードイン・フェードアウトをさせるなら、enchant.Entityクラスには透明度をコントロールするためのopacityへのset関数が用意されているので、基本的にはこれを使えば良いというように一瞬思われますが、複数のスプライトを束ねて扱うための enchant.Group クラスとそのサブクラスであるenchant.Sceneが、 enchant.Entity のサブクラスではない!ので、enchant.Groupの子ノードに対して再帰的に透明度を適用させるといった使いかたをするには、いろいろと animation.enchant.js の書き直しが必要になることがわかります。

本来は、上記のような問題点を解消する、animation.enchant.js へのパッチを書くべきところですが、今回は時間が無いので、パス。すみません...。

というわけで、自前でお手軽に作ってみることにしました。次のエントリで具体的なコードを示します。

nineleap.enchant.jsの拡張(1)

OpenLeapに投稿するためにいろいろと実験して、何から手をつければいいか考えました。

まず気になったのは、次の2点です。

  • nineleap.enchant.js を利用したときに表示される、「START」や「GAME OVER」のメッセージについて、それぞれの表示内容はstart.png, end.pngの画像を差し替えることで自由に変更できるが、フェードイン・フェードアウトのようなトランジション効果を指定する仕組みがないので、なんだか味気ない。
  • 「GAME OVER」表示後にゲームを「もう一度プレイする」機能は、9leapのサイト側で実装されており、ローカル環境で遊んでいるときに「GAME OVER」表示後にもう一度プレイしたい場合には、ブラウザ上でリロードをするしかない。
nineleap.enchant.jsのコードを読んでみると、9leap対応化を実現しているのは、enchant.nineleap.Gameクラスであるということがわかります。そこで、このクラスの継承による機能の上書きにより、上記の2点に改善を施してみることにしました。

(つづく)

enchant.jsの基本的なクラスの階層

enchant.jsのしくみを理解するため、クラス図を書きながらコードを読んでみることにしました。


  • enchant.EventTarget: DOM Event風味の独自イベント実装を行ったクラス.
    • enchant.Game: ゲームのメインループ, シーンを管理するクラス.
    • enchant.Node: Sceneをルートとした表示オブジェクトツリーに属するオブジェクトの基底クラス.
      • enchant.Entity: DOM上で表示する実体を持ったクラス.
        • enchant.Sprite: 画像表示機能を持ったクラス.
      • enchant.Group: 複数のNodeを子に持つことができるクラス.
        • enchant.Scene:  表示オブジェクトツリーのルートになるクラス.
このうち、HTMLのdiv要素に対応するのが enchant.Entity クラスで、このインスタンスを通じて、対応するdiv要素のstyle属性をいろいろ書き換えることで、実際に画面を変化させていく仕組みであるということがわかりました。

Groupのインスタンスに対してSpriteのインスタンスをaddChildすることで、複数のSpriteのインスタンスを論理的に階層化して取り扱うことができます。なおこのとき、それぞれのSpriteのインスタンスに対応したdiv要素群は、論理的な構造とは無関係に、フラットな兄弟の関係にあるような要素として作成される実装になっているという点、興味深いです。同じくJavaScriptによるゲーム開発用ライブラリであるgameQueryでは、スプライトをグループ化したものについてもdiv要素を割り当てているのと対照的です。

2012年1月18日水曜日

はじめまして。

ぼくは、今年で39歳になるおっさんです。

ぼくは、子どものころ、FM-7上でのオリジナルのゲーム作りを通じて、プログラミングのイロハを学びました。この経験は、自分にとってかけがえのない宝物です。でも、振り返れば、これはもう30年も前のことになってしまいました。


あのころの自分とあのころの環境では作りきることができなかったゲームを、今なら作れる、かもしれない。あのころのように、もういちど、「自分がやりたいゲームを自分で作りたい」!

いろいろ考えた末、ぼくは、子どものころの趣味「ゲームプログラミング」を再開することにしました。このブログを通じて、そうした作業の足跡を記していくことにします。

通勤・帰宅時の総武線各停の車内で、enchant.jsのシールが貼られたMacBookAirを開いている人を見かけたら、それはたぶん、ぼくだと思います。