phina.js でブロック崩しを作ろう

phi phi on phina.js, game

phina.js Advent Calendar 2015 - Qiita 15日目のエントリーです.

前回 に引き続きゲームを作ったので紹介したいと思います. 今回紹介するのは, phina.js の examples にあるブロック崩しです.

ブロック崩しは, シューティングゲームと並んでプログラミング入門でよく扱われるゲームです. ゲームプログラミングの基礎を学ぶのに良い題材なのでぜひコードリーディングしてみてください♪

入門用として作ったので極力シンプルになるよう実装したのですが, ここ, こうしたほうがわかりやすいとか, ここ何やってるかよくわからないなどありましたら 気軽にコメントください.

※ 本エントリーは, 詳しい解説というより phina.js を使えばこういった感じでゲーム作れますよーって のを知ってもらうのが目的です. 1から自分でブロック崩しを作りたいという方は @alkn203 さんが書かれた 『【phina.js】ゲーム作成チュートリアル(ブロック崩し)第0回 | Keep Coding』 を参照してください.

About phina.js

phina.js って何?って方はこちらを御覧ください.

本日 JavaScript ゲームライブラリ『phina.js』をリリースしました! | phiary

Runstant Demo

phina.js で作ったブロック崩しです.
タッチするとゲームが開始します.

runstant

Code

JavaScript の実装コードです.
コメント合わせて 350 行程度です.

/*
 * runstant
 */

phina.globalize();

var SHARE_URL = 'http://phiary.me/phina-js-breakout/';  
var SHARE_MESSAGE = 'phina.js でブロック崩しを作ろう!!\nSCORE:{score}';  
var SHARE_HASH_TAGS = 'breakout,phina_js';

var SCREEN_WIDTH    = 640;  
var SCREEN_HEIGHT   = 960;  
var MAX_PER_LINE    = 8;  
var BLOCK_NUM       = MAX_PER_LINE*5;  
var BLOCK_SIZE      = 64;  
var BOARD_PADDING   = 50;  
var PADDLE_WIDTH    = 150;  
var PADDLE_HEIGHT   = 32;  
var BALL_RADIUS     = 16;  
var BALL_SPEED      = 16;

var BOARD_SIZE      = SCREEN_WIDTH - BOARD_PADDING*2;  
var BOARD_OFFSET_X  = BOARD_PADDING+BLOCK_SIZE/2;  
var BOARD_OFFSET_Y  = 150;

phina.define("MainScene", {  
  superClass: 'CanvasScene',

  init: function(options) {
    this.superInit(options);

    // スコアラベル
    this.scoreLabel = Label('0').addChildTo(this);
    this.scoreLabel.x = this.gridX.center();
    this.scoreLabel.y = this.gridY.span(1);
    this.scoreLabel.fill = 'white';

    // グループ
    this.group = CanvasElement().addChildTo(this);

    var gridX = Grid(BOARD_SIZE, MAX_PER_LINE);
    var gridY = Grid(BOARD_SIZE, MAX_PER_LINE);

    var self = this;

    (BLOCK_NUM).times(function(i) {
      // グリッド上でのインデックス
      var xIndex = i%MAX_PER_LINE;
      var yIndex = Math.floor(i/MAX_PER_LINE);
      var angle = (360)/BLOCK_NUM*i;
      var block = Block(angle).addChildTo(this.group).setPosition(100, 100);

      block.x = gridX.span(xIndex) + BOARD_OFFSET_X;
      block.y = gridY.span(yIndex)+BOARD_OFFSET_Y;
    }, this);

    // ボール
    this.ball = Ball().addChildTo(this);

    // パドル
    this.paddle = Paddle().addChildTo(this);
    this.paddle.setPosition(this.gridX.center(), this.gridY.span(15));
    this.paddle.hold(this.ball);

    // タッチでゲーム開始
    this.ballSpeed = 0;
    this.one('pointend', function() {
      this.paddle.release();
      this.ballSpeed = BALL_SPEED;
    });

    // スコア
    this.score = 0;
    // 時間
    this.time = 0;
    // コンボ
    this.combo = 0;
  },

  update: function(app) {
    // タイムを加算
    this.time += app.deltaTime;

    // パドル移動
    this.paddle.x = app.pointer.x;
    if (this.paddle.left < 0) {
      this.paddle.left = 0;
    }
    if (this.paddle.right > this.gridX.width) {
      this.paddle.right = this.gridX.width;
    }

    // スピードの数分, 移動と衝突判定を繰り返す
    (this.ballSpeed).times(function() {
      this.ball.move();
      this.checkHit();
    }, this);

    // ブロックがすべてなくなったらクリア
    if (this.group.children.length <= 0) {
      this.gameclear();
    }
  },

  checkHit: function() {
    //
    var ball = this.ball;

    // 画面外対応
    if (ball.left < 0) {
      ball.left = 0;
      ball.reflectX();
    }
    if (ball.right > this.gridX.width) {
      ball.right = this.gridX.width
      ball.reflectX();
    }
    if (ball.top < 0) {
      ball.top = 0;
      ball.reflectY();
    }
    if (ball.bottom > this.gridY.width) {
      ball.bottom = this.gridY.width
      ball.reflectY();
      this.gameover();
    }

    // ボールとパドル
    if (ball.hitTestElement(this.paddle)) {
      ball.bottom = this.paddle.top;

      var dx = ball.x - this.paddle.x;
      ball.direction.x = dx;
      ball.direction.y = -80;
      ball.direction.normalize();

      // speed up
      this.ballSpeed += 1;

      // コンボ数をリセット
      this.combo = 0;
    }

    this.group.children.some(function(block) {
      // ヒット
      if (ball.hitTestElement(block)) {
        var dq = Vector2.sub(ball, block);

        if (Math.abs(dq.x) < Math.abs(dq.y)) {
          ball.reflectY();
          if (dq.y >= 0) {
            ball.top = block.bottom;
          }
          else {
            ball.bottom = block.top;
          }
        }
        else {
          ball.reflectX();
          if (dq.x >= 0) {
            ball.left = block.right;
          }
          else {
            ball.right = block.left;
          }
        }

        block.remove();

        this.combo += 1;
        this.score += this.combo*100;

        var c = ComboLabel(this.combo).addChildTo(this);
        c.x = this.gridX.span(12) + Math.randint(-50, 50);
        c.y = this.gridY.span(12) + Math.randint(-50, 50);

        return true;
      }
    }, this);
  },

  gameclear: function() {
    // add clear bonus
    var bonus = 2000;
    this.score += bonus;

    // add time bonus
    var seconds = (this.time/1000).floor();
    var bonusTime = Math.max(60*10-seconds, 0);
    this.score += (bonusTime*10);

    this.gameover();
  },

  gameover: function() {
    this.exit({
      score: this.score,
      message: SHARE_MESSAGE,
      url: SHARE_URL,
      hashtags: SHARE_HASH_TAGS
    });
  },

  _accessor: {
    score: {
      get: function() {
        return this._score;
      },
      set: function(v) {
        this._score = v;
        this.scoreLabel.text = v;
      },
    },
  }

});

/*
 * ブロック
 */
phina.define('Block', {  
  superClass: 'RectangleShape',

  init: function(angle) {
    this.superInit({
      width: BLOCK_SIZE,
      height: BLOCK_SIZE,
      fill: 'hsl({0}, 80%, 60%)'.format(angle || 0),
      stroke: null,
      cornerRadius: 8,
    });
  },
});

/*
 * ボール
 */
phina.define('Ball', {  
  superClass: 'CircleShape',

  init: function() {
    this.superInit({
      radius: BALL_RADIUS,
      fill: '#eee',
      stroke: null,
      cornerRadius: 8,
    });

    this.speed = 0;
    this.direction = Vector2(1, -1).normalize();
  },

  move: function() {
    this.x += this.direction.x;
    this.y += this.direction.y;
  },

  reflectX: function() {
    this.direction.x *= -1;
  },
  reflectY: function() {
    this.direction.y *= -1;
  },
});

/*
 * パドル
 */
phina.define('Paddle', {  
  superClass: 'RectangleShape',
  init: function() {
    this.superInit({
      width: PADDLE_WIDTH,
      height: PADDLE_HEIGHT,
      fill: '#eee',
      stroke: null,
      cornerRadius: 8,
    });
  },

  hold: function(ball) {
    this.ball = ball;
  },

  release: function() {
    this.ball = null;
  },

  update: function() {
    if (this.ball) {
      this.ball.x = this.x;
      this.ball.y = this.top-this.ball.radius;
    }
  }
});

/*
 * コンボラベル
 */
phina.define('ComboLabel', {  
  superClass: 'Label',
  init: function(num) {
    this.superInit(num + ' combo!');

    this.stroke = 'white';
    this.strokeWidth = 8;

    // 数によって色とサイズを分岐
    if (num < 5) {
      this.fill = 'hsl(40, 60%, 60%)';
      this.fontSize = 16;
    }
    else if (num < 10) {
      this.fill = 'hsl(120, 60%, 60%)';
      this.fontSize = 32;
    }
    else {
      this.fill = 'hsl(220, 60%, 60%)';
      this.fontSize = 48;
    }

    // フェードアウトして削除
    this.tweener
      .by({
        alpha: -1,
        y: -50,
      })
      .call(function() {
        this.remove();
      }, this)
      ;
  },
});


phina.main(function() {  
  var app = GameApp({
    title: 'Breakout',
    startLabel: location.search.substr(1).toObject().scene || 'title',
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
    backgroundColor: '#444',
  });

  app.enableStats();

  app.run();
});

Tips

いくつか抜粋して解説したいと思います.

ゲームの要素をそれぞれクラス化しよう

オブジェクト指向はゲームプログラミングにおいて基本ですよね. ゲームの要素をそれぞれ物のように捉えてクラスとして定義することで, 汎用性の高いコードを実現できます.

phina.js では, C++ ライクなクラス定義の仕組みを用意しています.
これを使えば簡単にクラスを作ることができます.

phina.define('クラス名', {  
  superClass: '継承元クラス名',
  // コンストラクタ
  init: function() {
    this.superInit(); // 親の初期化
    // TODO: 初期化処理
  },
  // 更新(メンバ関数)
  update: {
    // TODO: 更新処理
  },
});

今回は下記のようなクラスを定義しています.

  • Block クラス ... ブロック
  • Paddle クラス ... パドル
  • Ball クラス ... ボール
  • ComboLabel クラス ... コンボラベル

簡易アクセッサ定義

クラス定義時に _accessor というオブジェクトに各アクセッサを渡すことで 簡単にそのクラスに対するアクセッサを定義することができます.

値をセットした際に, 他のものも追従して変化させたいときなどに便利です. 今回のサンプルでは, score 部分で使用しています.

  _accessor: {
    score: {
      get: function() {
        return this._score;
      },
      set: function(v) {
        this._score = v;
        this.scoreLabel.text = v;
      },
    },
  }

こうすることで score の値を変更すると, 一緒にラベルの表示も変わってくれるようになります.

Grid による要素の配置

Grid を使うことで, 要素をグリッド状に並べることができます.

var grid = Grid(200, 10); // 幅, グリッド数  
grid.span(1); // 20  
grid.center(); // 100;  
grid.center(-1); // 80  
grid.center(1); // 120  

本サンプルでは下記のようにブロックの配置に使用しています.

// グループ
this.group = CanvasElement().addChildTo(this);

var gridX = Grid(BOARD_SIZE, MAX_PER_LINE);  
var gridY = Grid(BOARD_SIZE, MAX_PER_LINE);

var self = this;

(BLOCK_NUM).times(function(i) {
  // グリッド上でのインデックス
  var xIndex = i%MAX_PER_LINE;
  var yIndex = Math.floor(i/MAX_PER_LINE);
  var angle = (360)/BLOCK_NUM*i;
  var block = Block(angle).addChildTo(this.group).setPosition(100, 100);

  block.x = gridX.span(xIndex) + BOARD_OFFSET_X;
  block.y = gridY.span(yIndex)+BOARD_OFFSET_Y;
}, this);

Grid については, @alkn203 さんが別エントリーで詳しくまとめてくれています. よかったら参考にしてください.
【phina.js】Gridクラスを使いこなそう - Qiita

ブロック崩さぬ

明日, 奇才 @utyo さんが, あの名作『ブロック崩さぬ』の解説エントリーを公開してくれるみたいです.

実は『ブロック崩さぬ』って phina.js の前身のライブラリ tmlib.js で作られていたりします. それを phina.js で作り直してくれたみたいです!!

楽しみですね♪