MRS WebSocket サンプルゲーム "ballcatch" の実装

MRS RoomのWebSocket APIを用いたサンプルゲーム "ballcatch" のソースコードを解説します。

ballcatchのゲーム内容

ballcatchは、画面上にランダムに出現する白い玉をクリックで取り合い、
スコアを競うマルチプレイゲームです。

白い玉やプレイヤーの位置は同期されており、相手方が取ると自分は取れません。

ballcatchは MRS Room WebSocket APIで実装されています。

上の写真では、左側の画面がルーム作成者(プレイヤーIDは1)、
右側が参加者(プレイヤーIDが2)になっています。
ルームのIDは1になっています。

ballcatchのファイル構成

ballcatchは mrs_ws/sample/ballcatch/ に配置されています。

ballcatchの動作確認

サーバーを起動してからブラウザでindex.htmlを開くだけで確認できます。
以下、MacOS 10.12で、ローカルマシンで動作するサーバーに接続する場合の動作確認方法を説明します。
サーバー類はLinuxでも同じように動作します。

  1. mrs_room/sample/cpp/mac/10.12/room_server   を引数無しで起動します。これがルームサーバーの本体です。WebSocketはポート33334で待ち受けます(コマンドライン引数で変更可能です)。
  2. ブラウザでindex.htmlを開きます。 Connectを押すとルームサーバーに接続します。 画面に表示されている状態が "Connection:true" になったら成功です。
  3. 次に、PlayerIDとRoomIDがそれぞれ1の状態で、Createボタンを押します。 すると "In-room:true" 表示に変わり、白い玉が出現し始めます。 ここまでで、ルームの所有者側のクライアントのセットアップは完了です。 このクライアントがルームの所有者になっていることを、"Owner:true" という表示で確認できます。
  4. ブラウザで新しいウインドウを開いて index.htmlを開きます。 ここでは、3. で開いたウインドウがタブになってしまわないように注意してください。 タブになっているとループの速度が極端に遅くなってしまい、ボールの同期が不完全になります。ただし、一応の動作はします。
  5. 新しいほうのウインドウで Connectボタンを押して下さい。 "Connection:true"が表示されたらOKです。
  6. PlayerIDを2に設定して Joinを押します。 ここでPlayerIDがデフォルトの1のままだと、 "MRS_ROOM_RESULT_PLAYER_ALREADY_JOINED" エラーが返ります。 成功すると、白い玉の状態をホスト役から受信して、対戦プレイ開始です。 新しいほうのウインドウでは "Owner:false" となっているのが確認できます。
  7. どちらのウインドウでも、Leaveを押すと退出します。その状態でJoinしたりCreateしたりできます。また、CloseSocketを押すと接続を停止します。

index.html の記述

まずヘッダでは、ゲームが依存しているJavaScriptモジュールを読み込みます。
mrs.jsと room.jsは、MRS Room WebSocketAPIを使うために必要なファイルで、
配布パッケージに含まれます。

processing.min.jsはCanvasに円などを描画するために必要な描画ライブラリ
Processing.js のランタイムファイルです。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>BallCatch Game</title>
  <script src="../../mrs.js"></script>
  <script src="../../room.js"></script>    
  <script src="./processing.min.js"></script>
</head>

次にbodyタグの中では、Canvasを初期化します。

  <canvas id="canvas" width="200" height="200"></canvas>

canvasが定義できたので、ゲーム本体のJavaScriptファイルを読み込みます。
Processing.jsを使うにはこの順番で読み込むことが必要です。

<script src="game.js"></script>

次に、Roomサーバーに接続したり、ルームに入室したりするときに必要な情報である、
ルームのIDやプレイヤーのIDを入力ためのテキストエリアを定義します。

MRS Room APIは、ルームやプレイヤーのIDを適切に付与する機能を提供していないため、
これらの番号はアプリケーション側で付与してやる必要があります。

ballcatchサンプルでは、これらの値は手動で定義しますが、
実際のアプリケーションでは、ウェブサーバーなどを利用して実装してください。

  Room ID: <input type="text" value="1" id="room_id"><br>
  Player ID: <input type="text" value="1" id="player_id" >

最後に、ルームサーバーに入室をするなどの操作を指示するための
各種のボタンを定義します。
ConnectはWebSocket接続を開始、Createはルームを作成、
Joinはルームに入室、 Leaveは退出、 CloseSocketはWebSocketを閉じます。

  <input type="button" value="Connect" onclick="connectButtonClicked();">
  <input type="button" value="Create" onclick="createButtonClicked();">
  <input type="button" value="Join" onclick="joinButtonClicked();">
  <input type="button" value="Leave" onclick="leaveButtonClicked();">
  <input type="button" value="CloseSocket" onclick="closeSocketButtonClicked();">  

それぞれに対応した関数は game.js に記述されています。

game.js の記述

game.jsでは大きく分けて以下の処理を行います。

それぞれ、順を追って説明していきます。

ボタンをおした時の動作

Connectボタンは、内部でWebSocketを初期化し、通信に必要なコールバック関数をすべて定義します。
Connect以外のボタンは、関数を1個呼び出すだけの非常に簡単な内容になっています。

Createボタンを押すとsendCreateRoom(room_id,player_id)関数を呼び、ルームを作成します。
Joinボタンを押すと sendJoinRoom( room_id, player_id )関数を呼び、ルームに参加します。
CloseSocketボタンを押すと、 close()関数を呼び、WebSocketを切断します。
Leaveボタンを押すと、sendLeaveRoom()関数を呼び、ルームから退出します(切断はしません)。

Roomサーバーから受信したイベントやレコードへの反応

Connectボタンの処理関数であるconnectButtonClicked関数では、ルームサーバーに接続します。
以下の2行で接続します。これらの関数が成功するためには、mrs.jsとroom.jsが必要です。

var cli = createMRSClient("ws://localhost:8888/");
g_room = createMRSRoomClient(cli);

次に、ルームサーバーから、ルームの状態が変化したイベントを受信するための
コールバック関数 "onRoomEvent" を設定します。

g_room.onRoomEvent = function(evt,room_id,player_id,result,members) {

前から順番に、 evtはイベントの種別を示す定数(MRS_ROOM_EVENT_),
room_idはルームのID番号、 player_idは操作を行ったプレイヤーのID番号,
resultは操作の結果コード(MRS_ROOM_RESULT_
), membersは現在参加しているルームの参加メンバーのリスト、
が渡されます。

この関数の中では、evtで渡されるイベントの種類ごとに処理を分けて実装します。
本サンプルで利用しているイベントでは、以下のことを行っています。

        for(var i in g_sprites) {
            if( g_sprites[i].name == "ball" ) {
                g_room.unicastString( 1, g_sprites[i].toJSON("createBall"),player_id);
            }
    deleteSprite("player",player_id);
    g_in_room = false;
    clearAllSprites("ball");
    clearAllSpritesExcept(g_local_player);

イベントのコールバック関数以外に、ゲームの同期を行うためのレコードを受信したときどうするかを、
onReadRecord関数で定義します。
ゲームの同期とは、ballcatchでは、ボールを出現させる、ボールを消す、プレイヤーの位置を伝える、の3種類です。

g_room.onReadRecord = function(room_id,owner_player_id,member_ids,sender_player_id, payload_type, payload ) {

引数で渡される情報は前から順番に、 room_idはルームのID、owner_player_idはルームの所有者のプレイヤーID、
member_idsはルームの参加者のプレイヤーID番号の配列、sender_player_idはデータを送信したプレイヤーのID、
payload_typeは、ゲームが自由に定義するレコードの種類(ballcatchではつねに1), payloadはバイト列です。
ballcatchではすべてのレコードはJSON文字列です。

簡易スプライト描画

game.jsでは、Processing.jsのfill関数で画面をクリアし、
ellipse関数でボールの円を描画し、rect関数でプレイヤーの四角形を描画します。
これらを配列で管理し、スプライトとしてupdate()関数で更新するための、
極めて簡単なスプライトシステムを実装しています。
createSprite()でスプライトを作成し、deleteSprite()で削除、countSprites()で数え、clearAllSprites()で全てを削除、
clearAllSpritesExcept()でひとつ以外を削除します。
このシステムを使い、createBall()でボールを作成し、createPlayer()でプレイヤーを作成します。
g_spritesという配列にすべてのスプライトが格納されます。

ホスト役の動作

Connectした後、Createボタンでルームを作成するとルームの所有者になります。
ルームの所有者は、updateGame()関数の中で、次の処理を行います。

{ "func": "createBall,"id":88,"x":232.115,"y":441.988125,"vx":-100.101,"vy":201.5590 }

他のクライアントがレコードを受信したときは、上記のJSONに含まれるidを使ってスプライトを特定します。
ballcatchサンプルでは、ボールは厳密ではありませんが、基本的には決定論的な動作をするので、
ボールが最初に出現するときに一度だけ同期し、それ以降二度と同期しません。
なお、クライアントがルームにJoinしたとき、 ホスト役がMRS_ROOM_EVENT_JOIN_ROOM_OTHER_PLAYERイベントを受信しますが、
そのイベントの処理関数の中でも、全ボールについて createBallのレコードを新規参加クライアントに向けて unicastしています。

{"func":"playerSync", "id":5,"x":232.115,"y":441.9881 }

上記JSONを受信したクライアントは、プレイヤースプライトをidから検索し、位置を更新します。
ballcatchサンプルでは毎秒60回画面を更新しますが、100ミリ秒に1回の同期をする場合は、だいたい6回に1回の更新になります。
そのため、同期情報を受信したクライアントでは、ほかのプレイヤーの位置がなめらかに移動しません。
ほかのプレイヤーの位置をなめらかに動かすためには、典型的には線形補完を用います。つまり100ミリ秒かけて次の位置まで動くようにプログラムします。
ballcatchでは、レコードの受信頻度がわかりやすいように、あえて線形補完の処理を実装していません。

ボールを取ったときの処理

ボールを取ったら、スコアをインクリメントし、
deleteSprite()でボールのスプライトを削除し、以下のJSONをothercastします。

{ "func":"ballGet", "id":88}

これを受信したクライアントは、ボールのID番号からボールのスプライトを検索して削除します。

通信量の見積もり

ひとつのルームにおけるballcatchによる通信は、
参加者数のに比例して増えるものと、そうでないものがあります。

まずルームをCreateしたりJoinする処理は、参加者が1人増えるたびに1回だけしか行われないため、
人数に比例して増えず、実際に発生するトラフィック量もごくわずかで、無視できます。

同様に、ゲーム中に unicast 関数を使って個別のクライアントに送信するデータも、
参加者が1人増えるたびに1回なので、参加人数に比例して増えません。
ballcatchでは、これは参加したときにすべてのボールの位置と速度をcreateBallコマンドで送るときに行います。
このトラフィックも、全体に占める割合は小さく、通常問題にはなりません。

othercast関数は、自分以外の全員への送信を行うため、1回あたりの通信量が人数に比例して増えていきます。
ルームに参加している人数が増えたら、othercastを実行するクライアントの数自体が増えていくため、
参加人数の2乗に比例して増大します。 厳密にいうと、2乗ではなく、参加者数をnとすると、 n(n-1)に比例します。
たとえば、参加者数が4人のときは、100ミリ秒に1回 4(4-1)=12回のplayerSyncのJSONデータが送信されます。
参加者数が10人になると、10(10-9)=90回となり、大幅に増えます。100人では、100(100-1)=9900回です。
これが1秒に10回あるので、毎秒9万9000回のJSON送信が発生します。
10人の場合のバイト数を試算するには、JSONデータが約50バイト、TCPヘッダが40バイトちょっとあるため、
1回あたり合計約100バイトの送信が必要と想定します。 1回の送信が90個なので、1秒あたり900回、
900回x100バイト=90KBの通信が毎秒サーバーに発生します。

othercastの呼び出し頻度をいかに削減するかが、同期通信のコスト削減の鍵となります。

ballcatchでは、以下のような工夫をすることが考えられます。


Copyright © 2019 MONOBIT ENGINE Inc.