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

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

株式会社アドベンチャーに所属してます。

航空券予約サイト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などのライブラリを利用することで、簡単にゲームを作ることができます。興味を持たれた方は、他にもライブラリがいろいろありますので、ぜひ試してみてください。

コメント

このブログの人気の投稿

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2021-04-30 23:37:32 RSSフィード2021-04-30 23:00 分まとめ(42件)

投稿時間:2023-02-05 02:09:04 RSSフィード2023-02-05 02:00 分まとめ(9件)