MRS RoomのWebSocket APIを用いたサンプルゲーム "ballcatch" のソースコードを解説します。
ballcatchは、画面上にランダムに出現する白い玉をクリックで取り合い、
スコアを競うマルチプレイゲームです。
白い玉やプレイヤーの位置は同期されており、相手方が取ると自分は取れません。
ballcatchは MRS Room WebSocket APIで実装されています。
上の写真では、左側の画面がルーム作成者(プレイヤーIDは1)、
右側が参加者(プレイヤーIDが2)になっています。
ルームのIDは1になっています。
ballcatchは mrs_ws/sample/ballcatch/ に配置されています。
サーバーを起動してからブラウザでindex.htmlを開くだけで確認できます。
以下、MacOS 10.12で、ローカルマシンで動作するサーバーに接続する場合の動作確認方法を説明します。
サーバー類はLinuxでも同じように動作します。
まずヘッダでは、ゲームが依存している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では大きく分けて以下の処理を行います。
それぞれ、順を追って説明していきます。
Connectボタンは、内部でWebSocketを初期化し、通信に必要なコールバック関数をすべて定義します。
Connect以外のボタンは、関数を1個呼び出すだけの非常に簡単な内容になっています。
Createボタンを押すとsendCreateRoom(room_id,player_id)関数を呼び、ルームを作成します。
Joinボタンを押すと sendJoinRoom( room_id, player_id )関数を呼び、ルームに参加します。
CloseSocketボタンを押すと、 close()関数を呼び、WebSocketを切断します。
Leaveボタンを押すと、sendLeaveRoom()関数を呼び、ルームから退出します(切断はしません)。
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では、以下のような工夫をすることが考えられます。