32
26

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.

phina.jsAdvent Calendar 2017

Day 8

phina.jsとWebSocketで多人数対戦ゲームを作ったよ

Last updated at Posted at 2017-12-07

はじめに

初めまして!真符です。phina.jsのアドベントカレンダーに参加させていただきました。
プログラミングやJavaScriptの勉強も兼ねphina.jsでのゲーム制作を始めてから2か月ぐらいが経ちました。

つい先日、phina.jsとWebSocketを使った多人数対戦ゲームを作ったので、
躓いたポイントなど交えつつ制作過程を紹介していきます。

サーバーサイド(WebSocket)はLaineusがコードを書いてくれました。
なので今回は主にphina.jsまわりの紹介記事になります。

実際に作ったもの

こちらです
キーボード+マウス操作なのでパソコンからご覧ください。
敵を倒してコインを奪い、先に20コイン集めた人が勝ち!の単純なルールです。
戦士、魔術師、忍者、猫の4種類のキャラが居ます。

ゲームの流れ

setumei1.png

  1. タイトル:TitleScene
  2. メインシーン:MainScene
  3. 結果画面:ResultScene

の3つのシーンで構成されています。
ゲームオーバー用のシーンも別途用意するか悩んだのですが、死んでいる最中もフィールドの様子を見れた方が楽しそうだったので、メインシーンの中にゲームオーバーの処理も含みました。

// メイン処理
phina.main(function() {
  // アプリケーション生成
  var app = GameApp({
    title: 'ピコファイターズ',
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
    assets: ASSETS,
    fit: false,
    startLabel: 'TitleScene',
    // シーンのリストを引数で渡す
    scenes: [
      {
        className: 'TitleScene',
        label: 'TitleScene',
        nextLabel: 'MainScene',
      },
      {
        className: 'MainScene',
        label: 'MainScene',
        nextLabel: 'ResultScene',
      },
      {
        className: 'ResultScene',
        label: 'ResultScene',
        nextLabel: 'TitleScene',
      }
    ]
  });

Scene管理についてはこちらの記事を参考にさせていただきました!
参考:[phina.js-Tips] 独自のSceneを作って遷移させる

以下でシーンごとに順を追って説明していきます。

タイトルシーン

タイトル.png
タイトル画面兼キャラ選択画面です。
マウスオーバーでキャラがアニメーションします。

Buttonクラス以外でも、shape.interactive = true;を追加すればマウスイベントを設定できます。
ゲームスタートボタンはButtonクラスですが、キャラクターのボタンはShapeクラスで作りました。

スタートボタンの拡縮アニメはtweenerで設定しました。
サイズはscaleXscaleYで変更できます。(widthとheightでサイズが変更出来るとしばらく勘違いしてました…)
小数点以下の値も設定できるので、さりげないアニメーションをつけたい時は0.05~0.2ぐらいの変化がちょうどよさそう。
setloop(true)でループです。

    if(selected_chara) {
      button.tweener.to({
        scaleX: 1.05,
        scaleY: 1.05,
      },300)
      .to({
        scaleX: 1,
        scaleY: 1,
      },300).setLoop(true);
    }

タイトル画面にアクセスした時点ではまだサーバーとの通信は行っておらず、「ゲームスタート」ボタンをクリックしたタイミングで初めてサーバーと通信します。

// ボタンクリックでスタート
button.onpointend = function(){
  SoundManager.playMusic('bgm');
  SoundManager.play('pyo');
  var connect = new WebSocket(WEBSOCKET_URL);
  var text_label = Label({
    text: '接続中…',
    fontSize: 16,
    fill: '#fff',
    stroke: '#1e3308',
    strokeWidth: 4,
    fontFamily: 'mplus_normal',
  }).addChildTo(self).setPosition(self.gridX.center(), 446);
  connect.onopen = function() {
    self.exit({
      chara: selected_chara,
      connect: connect
    });
  };
  connect.onclose = function() {
    text_label.text = '接続できませんでした。人が多すぎるかサーバーがダウンしています。';
  };

接続に成功したら、引数で選択したキャラクターの番号を渡して次のシーンに遷移します。
定員がいっぱいだったり、サーバーが落ちている等で接続が上手くいかなかった場合はエラー文を表示します。

メインシーン

前のシーンから受け取ったキャラ番号を元にキャラクターを生成します。
キャラクターの初期位置は通行可能なタイルからランダムで選ばれます。

マップデータはこんな感じです。
0が通行可能、1が通行不可、2は通行できないけど弾はすり抜けるタイルです。キャラチップのサイズと同じ32px×32pxのサイズで判定しています。

var mapdata = [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,0,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,0,0,1,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1],
[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[2,2,2,2,2,2,2,2,2,2,2,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,2,2,2,2,2,2,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1],
[1,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1],
[1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]];

更新処理について

hoee.png

player.update関数の中でプレイヤーの情報を常にサーバーへと送ってます。
同時に、ほかのプレイヤーの情報もサーバーから受け取っています。

何のデータを渡すかわかるように、サーバーと情報をやり取りするときは以下の形で渡すように取り決めました。

method:'なんか適当な名前',
data:{hoge:hoge,hoge,hoge}

x,y座標やHP、弾の位置といった頻繁に変更があるものはstatusという名前で常に飛ばし、

data.connect.send(JSON.stringify({
  method: 'status',
  data: {x: player.x, y: player.y, hp: player.children[0].value, frameIndex: player.frameIndex, speed: player.speed, chara_id: player.chara_id, coin: player.coin , bullets: bullets}
}));

上記とは別に、

  • 宝箱を開けた時
  • アイテムを拾った時
  • 敵に攻撃を当てた時

はそれぞれ別の名前をつけ、その都度サーバーに情報を送っています。
以下は敵に攻撃を当てた時の処理です。

if(otherPlayer.hitTestElement(bullet)) {
  data.connect.send(JSON.stringify({
    method: 'hit',
    data: {
      id: otherPlayer.id,
      power: CHARACTERS[player.chara_id].POWER
    }
  }));
}

プレイヤーが敵に攻撃を当てたとき、自分の攻撃力と相手のIDを一緒にサーバーへ送ります。
すると、サーバーから攻撃を喰らった人へメッセージが送られ、相手の攻撃力分のHPを減らすという流れです。

「自分が撃った弾」と「相手が撃った弾」両方の衝突判定をクライアントで行うとズレが生じてしまうので自分が発射した弾のみを見張っています。
サーバー.png

ゲームオーバーの処理

プレイヤーが死んだら所持コインの半分を落とし3秒間墓状態になります。この間はキー操作が無効です。

// 死んだ時
if(player.children[0].value <= 0 && !gameover_flag) {
  gameover_flag = true;
  player.frameIndex = 12;
  share_button.interactive = true;
  label_count.text = '3秒後に復活';
  overlay.tweener.to({
    alpha: 1,
  }, 100)
  .play()
  .wait(1000)
  .call(function(){
    label_count.text = '2秒後に復活';
  })
  .wait(1000)
  .call(function(){
    label_count.text = '1秒後に復活';
  })
  .wait(1000)
  .call(function(){
    label_count.text = '0秒後に復活';
  })
  .to({
    alpha: 0,
  }, 600)
  .play()
  .call(function() {
    player.frameIndex = 0;
    gameover_flag = false;
    share_button.interactive = false;
  })
  .play();
}

こちらのアニメーションにもtweenerをモリモリ使っています。
2回目以降のゲームオーバーでアニメーションがまったく動かず困っていたところ、こちらの記事を発見しました。

clear()で前回のアニメーションをクリアするか、play()で明示的に実行するといいみたいです。
引用元:【phina.js】2回目以降のTweenerアニメーションが動かないとき
とのことなので今回はplay()を入れて解決しました。

また、要素のalphaが0で画面上では見えない状態になっていてもボタンは反応します。
その為、ゲームオーバー処理の最後でshare_button.interactive = false;を入れてボタンが反応しないようにしています。

結果画面

プレイヤーの誰かが20コイン貯めると、プレイヤー全員の接続が切断されリザルトシーンに遷移します。
ツイートボタンから戦績を呟けます。勝った時と負けたときで文章をちょっと変えてみました。
hoge.png
参考:phina.js twitterシェアボタンの実装例

まとめ・感想など

phina.jsを使ってゲームを作るのは今回が3度目になります。
個人的には、前回前々回作ったゲームよりも複雑な処理を組み込めたり、何度も使う処理は関数化するなど、小さいながらもレベルアップを実感出来ていい経験になりました。

jQueryで要素をfadeInさせるのが精一杯…!( ˘ω˘ )ぐらいの初心者の自分でも、phina.jsなら楽しくゲーム制作が出来るので、**「JavaScriptでゲームを作りたいけど何を使えばいいんだろう…」**ともし悩んでいる方がいたら、ぜひおすすめです!

32
26
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
32
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?