phina.jsで始めるJavaScriptゲーム開発

株式会社アドベンチャーに所属してます。
航空券予約サイト[skyticket（スカイチケット）](https://skyticket.jp/)を運営してますので、どうぞよろしくお願いします。

今回、初めて記事を書くことになり、せっかくの機会なので、ゲーム開発に挑戦してみようと思います。

とはいえ、ゲーム開発の知識ゼロ。。。さくっと作りたい。。。

そんな私でも簡単に作れるライブラリを探してみたところ、[phina.js](http://phinajs.com/)を発見。これを使ってみます。

# phina.jsとは

ざっくり説明すると、

- ゲームやツールを簡単に作る事ができる国産JavaScriptゲームライブラリ
- PC・スマホどちらでも動く
- phina.jsを読み込むだけでＯＫ！

なんだかお手軽に作れそうな気がします！

# タイトルシーンを作る
最低限のHTML書いて、phina.jsを読み込んで、JavaScript数行書く。
それだけで簡単にタイトルシーンが作れます。

```sample.html
<!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>
```
![Screenshot.png](https://qiita-image-store.s3.amazonaws.com/0/313387/aa229580-964a-d908-ebcd-2fa33c11c23b.png)

startLabelで開始シーンを指定するのですが、ここではデフォルトで用意されているタイトルシーンを利用しています。startLabelを書かない場合は、自動的にtitleが適用されます。

これはこれで楽なのですが、見た目はしょぼいですね。
慣れてきたらオリジナルのタイトルシーンにしたいところです。

なお、デフォルトのタイトルシーンは、タッチするとメインシーンに推移します。

# メインシーンを作る
今回は簡単なシューティングゲームを作ってみます。
音声なし！　ステージ数は１つのみ！　敵も１機のみ！　自機、敵機、ともに、左右の動きのみ！
定数いじれば難易度が変わりますが、クソゲーなのは変わりません。

```sample.html
<!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メソッドで、上記の２つの円の当たり判定を行います。
当たりの場合、まずキャラクターのライフを減らしてます。
ライフが残ってる場合は、着弾エフェクトを表示させ、弾をremoveしてます。
ライフ0の場合は、リザルトシーンに推移させます。

着弾エフェクトの部分では、
CircleShapeで円形の波紋を作り、フレーム毎に波紋の大きさと透過度を変更、最終的にremoveで波紋を消してます。

メインシーンの他に、カウントダウンシーンとポーズシーンを用意してます。
pushSceneで、メインシーンに上乗せします。その際、メインシーンの更新処理は自動的に止まります。

app.deltadTimeプロパティは、前の１フレームにかかった時間を取得することができます。時間管理用の変数に、更新処理の中でapp.deltadTimeを加算、減算することで、経過秒数に応じて各処理を開始・終了するなどの使い方ができます。

タイムオーバー、自機ライフ0、敵機ライフ0で、デフォルトで用意されているリザルトシーンに推移させてます。
これまた見た目がしょぼいので、慣れてきたらオリジナルのエンディングシーンにしたいところです。

# 最後に

タイトルシーン、プロローグ、エンディングを追加してアップしました。１分あれば終るので、お暇な方は遊んでみてください。

[シューティングゲーム](http://runstant.com/shigaki/projects/373ad5cd)

JavaScriptの知識が多少あれば、phina.jsなどのライブラリを利用することで、簡単にゲームを作ることができます。興味を持たれた方は、他にもライブラリがいろいろありますので、ぜひ試してみてください。