株式会社アドベンチャーに所属してます。
航空券予約サイトskyticket(スカイチケット)を運営してますので、どうぞよろしくお願いします。
今回、初めて記事を書くことになり、せっかくの機会なので、ゲーム開発に挑戦してみようと思います。
とはいえ、ゲーム開発の知識ゼロ。。。さくっと作りたい。。。
そんな私でも簡単に作れるライブラリを探してみたところ、phina.jsを発見。これを使ってみます。
phina.jsとは
ざっくり説明すると、
- ゲームやツールを簡単に作る事ができる国産JavaScriptゲームライブラリ
- PC・スマホどちらでも動く
- phina.jsを読み込むだけでOK!
なんだかお手軽に作れそうな気がします!
タイトルシーンを作る
最低限のHTML書いて、phina.jsを読み込んで、JavaScript数行書く。
それだけで簡単にタイトルシーンが作れます。
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>title</title>
<script src='https://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/build/phina.js'></script>
<script>
phina.globalize();
phina.main(function() {
var app = GameApp({
title: 'ゲームタイトル',
startLabel: 'tltle',
});
app.run();
});
</script>
</head>
<body></body>
</html>
startLabelで開始シーンを指定するのですが、ここではデフォルトで用意されているタイトルシーンを利用しています。startLabelを書かない場合は、自動的にtitleが適用されます。
これはこれで楽なのですが、見た目はしょぼいですね。
慣れてきたらオリジナルのタイトルシーンにしたいところです。
なお、デフォルトのタイトルシーンは、タッチするとメインシーンに推移します。
メインシーンを作る
今回は簡単なシューティングゲームを作ってみます。
音声なし! ステージ数は1つのみ! 敵も1機のみ! 自機、敵機、ともに、左右の動きのみ!
定数いじれば難易度が変わりますが、クソゲーなのは変わりません。
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Shooting Game</title>
<script src='https://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/build/phina.js'></script>
<script>
// グローバルに展開
phina.globalize();
var ASSETS = {
image: {
bg: 'https://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/assets/images/shooting/bg.png',
player: 'https://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/assets/images/shooting/player.png',
enemy: 'https://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/assets/images/shooting/enemy.png',
player_bullet: 'https://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/assets/images/shooting/bullet.png',
enemy_bullet: 'https://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/assets/images/shooting/enemy_bullet.png',
},
};
var SCREEN_WIDTH = 640; // 画面幅
var SCREEN_HEIGHT = 960; // 画面高さ
var PLAYER_HP; // 自機HP
var PLAYER_SPEED = 10; // 自機速度
var PLAYER_BULLET; // 自弾数
var PLAYER_BULLET_SPEED = 15; // 自弾速度
var ENEMY_HP; // 敵機HP
var ENEMY_SPEED = 15; // 敵機速度
var ENEMY_BULLET_SPEED = 25; // 敵弾速度
var ENEMY_BULLET_DENCITY = 25; // 敵弾密度(1~100)
var TIME_LIMIT = 30; // 制限時間(秒)
var TIME; // 経過時間(秒)
// メインシーン
phina.define('MainScene', {
superClass: 'DisplayScene',
// コンストラクタ
init: function() {
this.superInit();
PLAYER_HP = 1; // 自機HP
PLAYER_BULLET = 100; // 自弾数
ENEMY_HP = 5; // 敵機HP
TIME = 0; // 経過時間(秒)
// 背景
Sprite('bg', SCREEN_WIDTH, SCREEN_HEIGHT).addChildTo(this).setPosition(this.gridX.center(), this.gridY.center());
// 自機
this.player = Player().addChildTo(this).setPosition(this.gridX.center(), this.gridY.span(15));
// 敵機
this.enemy = Enemy().addChildTo(this).setPosition(this.gridX.center(), this.gridY.span(1));
// 自弾グループ
this.playerBulletGroup = DisplayElement().addChildTo(this);
// 敵弾グループ
this.enemyBulletGroup = DisplayElement().addChildTo(this);
// 自機HP
this.label_player_hp = Label({
text: '',
fill: 'white',
}).addChildTo(this).setPosition(this.gridX.span(15), this.gridY.span(15) - 20);
// 敵機HP
this.label_enemy_hp = Label({
text: '',
fill: 'white',
}).addChildTo(this).setPosition(this.gridX.span(1), this.gridY.span(1) - 20);
// 残り時間
this.label_time = Label({
text: '',
fill: 'white',
}).addChildTo(this).setPosition(this.gridX.span(3), this.gridY.center());
// 残弾
this.label_bullet = Label({
text: '',
fill: 'white',
}).addChildTo(this).setPosition(this.gridX.span(13), this.gridY.center());
// ポーズボタン
var self = this;
Button({
text: 'Pause',
}).addChildTo(this).setPosition(this.gridX.center(), this.gridY.center()).onpush = function() {
self.app.pushScene(PauseScene());
};
},
// 自弾
onpointstart: function(e) {
if (PLAYER_BULLET >= 1){
PlayerBullet().addChildTo(this.playerBulletGroup).setPosition(this.player.x, this.player.y);
PLAYER_BULLET--;
}
},
// 敵機当たり判定
hitTestEnemy: function() {
var self = this;
self.playerBulletGroup.children.each(function(bullet) {
// 円判定
var a = Circle(self.enemy.x, self.enemy.y, 20);
var b = Circle(bullet.x, bullet.y, 10);
if (Collision.testCircleCircle(a, b)) {
--ENEMY_HP;
if (ENEMY_HP > 0) {
self.Impact(bullet.x, bullet.y);
bullet.remove();
} else {
self.exit('result', {
score: 100,
message: 'Beat'
});
}
}
});
},
// 自機当たり判定
hitTestPlayer: function() {
var self = this;
self.enemyBulletGroup.children.each(function(bullet) {
// 円判定
var a = Circle(self.player.x, self.player.y, 20);
var b = Circle(bullet.x, bullet.y, 20);
if (Collision.testCircleCircle(a, b)) {
--PLAYER_HP;
if (PLAYER_HP > 0) {
self.Impact(bullet.x, bullet.y);
bullet.remove();
} else {
self.exit('result', {
score: 0,
message: 'Game Over'
});
}
}
});
},
// 着弾エフェクト
Impact: function(x, y) {
// 着弾時エフェクト
const circle = CircleShape({
fill: null,
stroke: 'red',
strokeWidth: 4,
}).addChildTo(this).setPosition(x, y);
circle.count = 0;
// エフェクト更新
circle.update = function() {
circle.count++;
circle.alpha += 0.2;
circle.radius += circle.count * 2;
if (circle.count == 5) {
circle.remove();
}
};
},
// 毎フレーム更新処理
update: function(app) {
if (TIME) {
// 当たり判定
this.hitTestEnemy();
this.hitTestPlayer();
// 敵弾
if (Random.randint(1, 100) <= ENEMY_BULLET_DENCITY) {
EnemyBullet().addChildTo(this.enemyBulletGroup).setPosition(this.enemy.x, this.enemy.y);
}
} else {
// カウントダウン
this.app.pushScene(CountdownScene());
}
// 時間・残弾・HP表示
TIME += app.deltaTime;
var t = TIME_LIMIT - Math.floor(TIME / 1000);
this.label_time.text = '残り時間:' + t;
this.label_bullet.text = '残弾数:' + PLAYER_BULLET;
this.label_player_hp.text = 'HP:' + PLAYER_HP;
this.label_enemy_hp.text = 'HP:' + ENEMY_HP;
// タイムオーバー
if (t <= 0) {
this.exit('result', {
score: 0,
message: 'Time Over'
});
}
}
});
// Playerクラス
phina.define('Player', {
superClass: 'Sprite',
// コンストラクタ
init: function() {
this.superInit('player', 64, 64);
this.frameIndex = 0;
},
// 毎フレーム更新処理
update: function(app) {
var p = app.pointer;
var diff = this.x - p.x;
if (Math.abs(diff) > PLAYER_SPEED) {
// 右移動
if (diff < 0) {
this.x += PLAYER_SPEED;
this.frameIndex = 2;
}
// 左移動
else {
this.x -= PLAYER_SPEED;
this.frameIndex = 1;
}
}
else {
// 待機
this.frameIndex = 0;
}
},
});
// PlayerBulletクラス
phina.define('PlayerBullet',{
superClass: 'Sprite',
// コンストラクタ
init: function() {
this.superInit('player_bullet');
this.physical.velocity.y = -PLAYER_BULLET_SPEED; //弾速
},
// 毎フレーム更新処理
update: function() {
// 画面上到達で削除
if (this.top < 0) {
this.remove();
}
}
});
// Enemyクラス
phina.define('Enemy', {
superClass: 'Sprite',
// コンストラクタ
init: function() {
this.superInit('enemy');
this.physical.velocity.x = ENEMY_SPEED;
},
// 毎フレーム更新処理
update: function() {
// 左右画面端で折り返し
if (this.left < 0) {
this.left = 0;
this.physical.velocity.x *= -1;
} else if (this.right > SCREEN_WIDTH) {
this.right = SCREEN_WIDTH;
this.physical.velocity.x *= -1;
}
}
});
// EnemyBulletクラス
phina.define('EnemyBullet',{
superClass: 'Sprite',
// コンストラクタ
init: function() {
this.superInit('enemy_bullet');
this.physical.velocity.y = ENEMY_BULLET_SPEED; //弾速
},
// 毎フレーム更新処理
update: function() {
// 画面下到達で削除
if (this.bottom > SCREEN_HEIGHT) {
this.remove();
}
}
});
// ポーズシーン
phina.define('PauseScene', {
superClass: 'DisplayScene',
// コンストラクタ
init: function() {
this.superInit();
this.backgroundColor = 'rgba(0, 0, 0, 0.7)';
var self = this;
Button({
text: 'Resume'
}).addChildTo(this).setPosition(this.gridX.center(), this.gridY.center()).onpush = function() {
self.exit();
};
}
});
// カウントダウンシーン
phina.define('CountdownScene', {
superClass: 'DisplayScene',
// コンストラクタ
init: function() {
this.superInit();
this.backgroundColor = 'rgba(0, 0, 0, 0.7)';
this.label = Label({
text: '',
fill: 'white',
fontSize: 200,
}).addChildTo(this).setPosition(this.gridX.center(), this.gridY.center());
this.time = 0;
},
// 毎フレーム更新処理
update: function(app) {
this.time -= app.deltaTime;
var t = Math.ceil(this.time / 1000) + 3;
if (t > 0) {
this.label.text = t;
} else {
this.exit();
}
}
});
// メイン処理
phina.main(function() {
var app = GameApp({
title: 'Shooting Game', // ゲームタイトル
width: SCREEN_WIDTH, // 画面幅
height: SCREEN_HEIGHT,// 画面高さ
assets: ASSETS, // アセット読み込み
});
app.run();
});
</script>
</head>
<body></body>
</html>
ASSETSでゲームで使用する画像URLを設定してます。そしてGameApp生成時にASSETSを読み込ませてます。
Playerクラスで自機の左右移動を扱ってます。
app.pointerで取得できるマウス位置と自機位置のx軸差分を求めることで、自機よりマウスが右にある時は右移動、自機よりマウスが左にある時は左移動、としてます。
Enemyクラスで敵機の左右移動を扱ってます。
開始時は右に移動。以降は左右画面端に到達したら反転、という単純な動きです。
敵機の速度はphysicalクラスのvelocityプロパティで指定してます。
自弾はクリック時に発射するように、onpointstartに処理を書いてます。
残弾がある場合に、PlayerBulletクラスを呼び出し、残弾数を減らします。
PlayerBulletクラスで、physicalクラスのvelocityプロパティで弾速と方向を指定、画面上に自弾が到達したらremoveで自弾を消してます。
敵弾はフレーム毎に一定の確率でEnemyBulletクラスを呼び出す事で発射する処理になっています。
EnemyBulletクラスで、physicalクラスのvelocityプロパティで弾速と方向を指定、画面下に自弾が到達したらremoveで敵弾を消してます。
当たり判定は円判定にしてます。
まず、Circleクラスで、キャラクターと弾に、判定用の円を作成してます。
次に、CollisionクラスのtestCircleCircleメソッドで、上記の2つの円の当たり判定を行います。
当たりの場合、まずキャラクターのライフを減らしてます。
ライフが残ってる場合は、着弾エフェクトを表示させ、弾をremoveしてます。
ライフ0の場合は、リザルトシーンに推移させます。
着弾エフェクトの部分では、
CircleShapeで円形の波紋を作り、フレーム毎に波紋の大きさと透過度を変更、最終的にremoveで波紋を消してます。
メインシーンの他に、カウントダウンシーンとポーズシーンを用意してます。
pushSceneで、メインシーンに上乗せします。その際、メインシーンの更新処理は自動的に止まります。
app.deltadTimeプロパティは、前の1フレームにかかった時間を取得することができます。時間管理用の変数に、更新処理の中でapp.deltadTimeを加算、減算することで、経過秒数に応じて各処理を開始・終了するなどの使い方ができます。
タイムオーバー、自機ライフ0、敵機ライフ0で、デフォルトで用意されているリザルトシーンに推移させてます。
これまた見た目がしょぼいので、慣れてきたらオリジナルのエンディングシーンにしたいところです。
最後に
タイトルシーン、プロローグ、エンディングを追加してアップしました。1分あれば終るので、お暇な方は遊んでみてください。
JavaScriptの知識が多少あれば、phina.jsなどのライブラリを利用することで、簡単にゲームを作ることができます。興味を持たれた方は、他にもライブラリがいろいろありますので、ぜひ試してみてください。