14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

AdventureAdvent Calendar 2018

Day 14

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

Last updated at Posted at 2018-12-13

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

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

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

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

phina.jsとは

ざっくり説明すると、

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

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

タイトルシーンを作る

最低限の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

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

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

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

メインシーンを作る

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

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

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

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

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

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

最後に

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

シューティングゲーム

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

14
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?