phina.js でブロック崩しを作ろう
phina.js Advent Calendar 2015 - Qiita 15日目のエントリーです.
- 1日目 本日 JavaScript ゲームライブラリ『phina.js』をリリースしました! | phiary
- 2日目 ManagerSceneでゲームの流れを管理しよう | daishi blog
- 3日目 ゲーム開発 - 気鋭の新JSゲームライブラリ「phina.js」の概要を自分なりにまとめてみた - Qiita
- 4日目 【phina.js】Gridクラスを使いこなそう - Qiita
- 5日目 phina.js で Webフォント を使ってみるよ! - Qiita
- 6日目 [phina.js] Tweenerを使いこなそう! [Tweener 基本編] - Qiita
- 7日目 [phina.js] サンプルから覚えるphina.js - Qiita
- 8日目 とりあえず試してみたいって方のための phina.js 入門 | phiary
- 9日目 【phina.js】グループ管理の基本テクニック - Qiita
- 10日目 phina.jsでレイヤーと仲良くなろう | daishi blog
- 11日目 【phina.js】Physicalクラスを使ってみよう - Qiita
- 12日目 [phina.js]SpriteSheetを使ってみよう! - Qiita
- 14日目 phina.js を使って 100 行縛りでゲーム作ってみた | phiary
前回 に引き続きゲームを作ったので紹介したいと思います. 今回紹介するのは, phina.js の examples にあるブロック崩しです.
ブロック崩しは, シューティングゲームと並んでプログラミング入門でよく扱われるゲームです. ゲームプログラミングの基礎を学ぶのに良い題材なのでぜひコードリーディングしてみてください♪
入門用として作ったので極力シンプルになるよう実装したのですが, ここ, こうしたほうがわかりやすいとか, ここ何やってるかよくわからないなどありましたら 気軽にコメントください.
※ 本エントリーは, 詳しい解説というより phina.js を使えばこういった感じでゲーム作れますよーって のを知ってもらうのが目的です. 1から自分でブロック崩しを作りたいという方は @alkn203 さんが書かれた 『【phina.js】ゲーム作成チュートリアル(ブロック崩し)第0回 | Keep Coding』 を参照してください.
About phina.js
phina.js って何?って方はこちらを御覧ください.
『本日 JavaScript ゲームライブラリ『phina.js』をリリースしました! | phiary』
- Official ... http://phinajs.com
- Github ... https://github.com/phi-jp/phina.js
- Gitter ... https://gitter.im/phi-jp/phina.js
Runstant Demo
phina.js で作ったブロック崩しです.
タッチするとゲームが開始します.
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 で作り直してくれたみたいです!!
楽しみですね♪
