WebGLとWebSocketによる3Dオンラインレースゲーム「JS-Racing」の全て!(後編)
前回に引き続きHTML5 Japan Cup 2014にてWebGL賞と優秀賞をいただいたオンラインレースゲーム、JS-Racingの技術解説をさせていただきます。
サーバサイドの使用技術
サーバサイドの技術としてNode.jsを使用しています。Node.jsはサーバーサイドで動作するJavaScriptで、ノンブロッキングI/Oというモデルを採用しています。非同期処理でデータベースへのアクセスとWebページの表示を別々に行ってくれるので、ストレスなく大量のページの表示が出来ます。また、Socket.ioというライブラリを扱うことで、WebSocketを使用したリアルタイム通信を実現するができます。
Node.jsでWebアプリケーションを構築する場合、実験的にローカルのみの開発の場合は問題ありませんが、公開するとなると、Node.jsをインストールして実行できるサーバが必要になります。そのためには共有サーバのレンタルではなく、管理者権限 (root) が付与される専用サーバや、仮想専用サーバ(VPS)のレンタルが必要になると思います。管理者権限のため自由に環境をセットアップする事ができるために、そのWebアプリケーションに合わせた環境構築が可能になります。
ExpressによるWebアプリケーションフレームワークの利用
Node.jsで作成したWebアプリケーションの場合、リクエストURIの解析からファイルの配信など、HTTPサーバの機能を実装しなくてはいけません。これらの基本機能が備わったWebアプリケーションフレームワークを利用することで、非常に手軽にWebアプリケーションを作成できます。今回は、多数開発されているWebフレームワークのなかでも、有名で多く利用されているExpressを利用しました。
var express = require("express"); var app = express(); // HTMLエンジンとしてjadeを使用 app.set("view engine", "jade"); app.set("views", __dirname + "/views"); app.use(express.static(__dirname + "/public")); // js-racing.knockknock.jp/にアクセスがあった場合、 // /views/index.jadeに「title=JS Racing」と「version=ver1.2」の値を渡して、HTMLを表示 app.get("/", function(req, res) { res.render("index", { title: "JS Racing", version: "ver1.2" }); }); // js-racing.knockknock.jp/controller.htmlにアクセスがあった場合、 // /views/controller.jadeに「title=JS Racing」と「version=ver1.2」と「id=URLパラメータのidの値」の値を渡して、HTMLを表示 app.get("/controller.html", function(req, res) { res.render("controller", { title: "JS Racing", version: "ver1.2", id: req.query.id }); }); app.set("port", process.env.PORT || 3000); var server = require("http").createServer(app); server.listen(app.get("port"), function(){ console.log("Express server listening on port " + app.get("port")); });
22行目のreq.query.id
はhttp://js-racing.knockknock.jp:3000/controller.html?id=000000
として渡された、URLパラメータのidの値が参照できます。この値は、ソケット通信時に発行されたソケットIDの値で、PCから発行されたスマフォ用のURL(QRコードか短縮URL)にURLパラメータとして付加されたもので、この値を元にPCとスマートフォンとのペアリングを行います。
ExpressはHTMLのテンプレートエンジンとしてJadeか、ejsを選択することができます。Jadeはインデントが必須です。閉じタグを省略できる代わりに、DOMの入れ子構造に沿って適切にインデントを使用する必要があります。基本的な記法がHTMLとは違うために、HTMLに親しんだマークアップエンジニアからすれば、慣れるまで違和感があるものだと思います。ejsはJadeとは違い、基本的な記法はHTMLをベースにしていますので、ローカルでくみ上げたHTMLをejsに組み込むことが容易です。今回はより楽にコード量を少なくHTMLを構築したいという点で、閉じたタグなしで記述できるJadeを選択しています。
// オープニングタイトル表示部分 section#sceneOpening.scene-opening(style="display: none;") div.scene-opening__inner div.scene-opening__box5 h1.scene-opening__ttl // expressから受け取ったパラメータ、titleの値を挿入 span.scene-opening__ttl__txt #{title} br // expressから受け取ったパラメータ、versionの値を挿入 span.scene-opening__ttl__txt2 #{version}
Jadeはこのようにインデントによって、要素の入れ子構造を定義しています。そのため閉じタグが不要で、HTMLを非常に簡略化して記述することが可能になります。HTMLを簡略化できるだけがJadeの特徴ではありません。HTMLをコンポーネント化して再利用したり、条件分岐をさせたり、繰り返し処理をさせたり、これまでサーバサイドで行っていたようなことがNode.jsで可能になります。ちなみにCSSはSass(Compass)を使い、記法にはBEMという命名規則を採用しています。
Socket.ioによるリアルタイム通信
車の同時走行と、スマートフォンからの車の操作を実現するためには、ブラウザとサーバ双方から、任意のタイミングでデータを送受信する必要があります。このリアルタイム通信を実現するために、Socket.ioを利用しています。Socket.ioはWebSocketなどのリアルタイム通信技術をラップして、シンプルなAPIを提供しているため、通信部分の煩雑さを意識することなく、リアルタイム通信を簡単に構築することができます。
スマートフォンからの車の操作を実現するために、接続時に発行されたソケットIDを共有することで、スマートフォンとPCをペアリングして相互通信しています。サーバを介しての通信なので、若干のタイムラグが発生する懸念もあったのですが、現状ではタイムラグを感じることはありませんでした。環境に依存することですので、あくまで今回の場合はということですが。
var socketArr = []; io.sockets.on("connection", function(socket){ // Socket.IDをPCに送信 socket.emit("emit_id_form_server", socket.id); // PCから車の状態を受信 socket.on("emit_carcondition_form_client", function(data){ // 受信した車の状態 var info = { id: socket.id, name: data.name, x: data.x, y: data.y, bodyAngle: data.bodyAngle, wheelAngle: data.wheelAngle, speed: data.speed, colorBody: data.colorBody, colorWing: data.colorWing, colorDriver: data.colorDriver }; // 接続しているクライアントごとの情報を更新 var flg = true; var i = 0, max; for (i = 0, max = socketArr.length; i < max; i = i + 1) { if (socketArr[i].id == info.id) { socketArr[i] = info; flg = false; } } if (flg) { socketArr.push(info); } }); // スマートフォンからの操作情報を受信 socket.on("emit_controller_data_form_client", function(data){ var id = data.id; var event = data.event; var value = data.value; var flg = false; var sockets = io.sockets.sockets; var i = 0, max; for (i = 0, max = sockets.length; i < max; i = i + 1) { // ソケットIDが一致したクライアントに操作情報を送信 if (sockets[i].id == id) { sockets[i].emit("emit_controller_data_from_server", { id: id, event: event, value: value }); flg = true; } } // ソケットIDが一致したクライアントが存在しなかった場合はスマートフォンに接続解除イベントを送信 if (!flg) { socket.emit("emit_disconnect_client_from_server"); } }); // 接続しているクライアントの情報を1秒間に3回、接続しているクライアント全てに送信 setInterval(function(){ socket.emit("emit_other_carcondition_from_server", socketArr); }, 1000 / 3); });
接続している全てのクライアントに、接続している全てのクライアントの情報を送信するイベントは、ネットワークの負荷を考えて、1秒間に3回と制限をかけています。つまり3FPSのタイミングで同期をとることになりますが、クライアント側では30FPSでゲームが進行しているために、タイムラグが発生してしまいます。タイムラグによって発生する違和感をなくすために、クライアント側ではサーバ側から配信されるクライアント情報を元に、前回配信された情報との差分を10で割った値を算出して、クライアント側の1FPS毎の値として反映させ、違和感を解消しています。
MongoDBでラップタイムの保存と走行データの保存
ラップタイムを保存したり、走行データを保存するのにデータベースとしてMongoDBを利用しています。MongoDBは、オープンソースのドキュメント指向データベースです。RDBMSのようにレコードをテーブルに格納するのではなく、ドキュメントと呼ばれる構造的データをオブジェクト形式でデータを管理します。 ちなみにNode.jsからMongooseを利用し、MongoDBに接続しています。Mongooseでは、Schemaインスタンスを通してModelを定義する事ができます。以下のようなオブジェクト形式でデータを管理出来るのが、MongoDBの特徴です。
var UserSchema = new mongoose.Schema({ id:String, // ソケットID name:String, // 名前 date:String, // 登録日 time:Number, // ラップタイム comment: String, // コメント color: { body: String, // ボディカラー wing: String, // ウィングカラー driver: String // ドライバーカラー }, runningPath: Array // 走行データ }); var Users = db.model("user", UserSchema);
走行データは1秒間に30回、車の位置と角度を記録した配列ですので、全てのユーザーの分だけ保存すると、データ量が肥大してしまう可能性があります。データ量を押さえるために、ラップタイムでソートして10位圏外の走行データは保持していません。
サーバサイドではNode.jsのおかげで、Webアプリケーションを構築する環境がとても整ってきていると思います。 基本的な言語はJavaScriptですので、サーバサイドもフロントエンドエンジニアが押さえるべき領域になっているのではと思います。
その他の技術的トピック
サーバサイド、クライアントサイドの技術それぞれの紹介を軸として、説明させていただきましたが、これ以外にもいくつか工夫した、技術的なポイントがありますので、紹介します。
コースデータの共有
3D表現を利用するために使用したthree.jsと、ゲームエンジンとして使った2D物理エンジンBox2DJSで、コースデータを共有するために2次元配列を使用しました。各値には数値を格納して、数値によってthree.jsでは壁、道路、芝、タイヤ、木、等の3Dモデルが配置され、Box2DJSでは3Dモデルの形状に沿った障害物を配置します。ちなみに開発当初、コースエディット機能や、SNSでコース共有機能等を考えていましたので、テキストデータとして扱いやすい2次元配列をコースデータとして採用したという経緯があります。
module imjcart.logic.map.value { export class MapConst { // コースデータ用の2次元配列 static MAP:any = [
[1,1,1,1,1,1,1,1,1, 〜省略〜 1,1,1,1,1,1,1,1,1,1,1], [1,4,4,4,4,4,4,4,4, 〜省略〜 4,4,4,4,4,4,4,4,4,4,1], 〜省略〜 [1,1,1,1,1,1,1,1,1, 〜省略〜 1,1,1,1,1,1,1,1,1,1,1] ] // コースデータ配列内の各値が、何を表しているかの定数 static MAP_KEY_NONE:number = 0; // アスファルト static MAP_KEY_WALL:number = 1; // 外壁 static MAP_KEY_BLOCK:number = 2; // ブロック static MAP_KEY_TIRE:number = 3; // タイヤ static MAP_KEY_GRASS:number = 4; // 芝 static MAP_KEY_TREE:number = 5; // 木 static MAP_KEY_CAR_START_POSITION:number = 6; // 車のスタート位置 static MAP_KEY_LAP_MEDIAN_CENTER_02:number = 7; // ゴールライン(逆走制御) static MAP_KEY_LAP_MEDIAN_CENTER_01:number = 8; // ゴールライン(逆走制御) static MAP_KEY_LAP_START_POINT:number = 9; // ゴールライン static MAP_KEY_SAND:number = 10; // 砂地 } }
ただ、このような膨大な量の2次元配列を、テキストエディタで作成、編集するのは非常に非効率だと考えて、途中からExcelによって管理する方法に切り替えました。Excelでは条件付き書式を設定して、セルの値によって、わかりやすいように色づけするように設定しています。データ書き出しの際には、ExcelからCSVデータとしてテキストデータを書き出して、CSVデータをテキストエディタで置換して2次元配列にしています。
コースの装飾
コースは直線と直角だけではなく、カーブ等の曲線も再現できなくてはいけません。2次元配列をコースデータとして利用する場合は、この点を補完する必要があります。また、コースというのは大抵、両脇のシケインとの境目にラインが引いてあります。こういったアスファルトや芝生といった要素以外の装飾を、周りの要素の配置条件に応じて追加することで、よりコースらしい外観を作る事ができます。下のキャプチャが装飾やカーブの補完をかけている物と、コースデータをそのまま表示したものとの違いです。
まとめ
以上が今回作成したJS-Racingの主な技術解説になります。Webアプリケーションを構築するには、今回解説したように多くの技術が必要となります。ご紹介した技術は、たくさんある中から必要なものを取捨選択した結果ですので、当然コンテンツが変われば、必要となる技術も変わります。このコンテンツで使用した主な技術の全体図をまとめました。
オンラインレースゲームというシンプルな内容のコンテンツですが、プラットフォームとしてWebブラウザを使用している点に注目していただけると嬉しいです。今までご紹介した技術は、オンラインゲームを作るだけの技術ではなく、Webコンテンツを作る上で、大きな可能性を持っている技術になります。私はゲームを作る上でこれらの技術を利用していますが、Web上の様々なサービスと連携をとることで、もっと広がりを持つコンテンツを作ることができると思います。ぜひ皆さんも、HTML5とJavaScript(クライアントサイド、サーバサイド含む)を使って、Webアプリケーションを作ってみてください。