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を開いている人を見かけたら、それはたぶん、ぼくだと思います。