Skip to content

node.js + socket.io + enchant.js でつくる、リアルタイム通信ゲーム

みなさんこんにちは! 9leapチームのリョーヘイ (@sidestepism) です。8月27日から28日にかけて開催された「福島ゲームジャム」に参加してきました。当日発表される初対面のチームで、30時間を使って1本のゲームを作るというイベントです。私は東京のNII (国立情報学研究所) のサテライトで、運営のお手伝いをしながらチームの一員として参加してきました。

私たちの作ったゲームは、enchant.js によるシンプルな横スクロールゲーム。流れている祭り囃子に合わせてクリックして御神輿をジャンプさせて障害物をよける、というものです。今回はそこにサーバサイドの node.js と socket.io を組み合わせることで、リアルタイムの通信対戦ゲームを実現しました。

今回の記事では、この通信対戦ゲームができるまでを紹介します。9leapのコンテスト後期では、外部サーバとの通信が解禁されているため、すぐに作って投稿することもできますよ!

WebSocketとは? socket.ioとは?

従来のWebブラウザ上で、JavaScriptアプリケーションがサーバと通信する方法は、JSONP、XMLHttpResponseの2種類に限られていました。どちらも本質的にはほとんど同じ (httpプロトコルによる通信) で、サーバからのデータのプッシュはできず、またチャットやオンラインゲームのような細かい通信を繰り返す通信にも向いていませんでした。「Comet」と呼ばれる、XHRをロングポーリングする (クライアントからのリクエストに対して即座にレスポンスを返さないことで、httpのコネクションをずっと張りっぱなしにして、送信したいタイミングでレスポンスを返す) という、httpプロトコルの上で擬似的なプッシュ通信を実現する技術も存在しますが、そもそもhttpが本来想定していなかった使い方のため、サーバのスレッド数の肥大化、クライアント側のコネクション数制限への配慮などの問題がありました。

WebSocketとは、これらの技術を根本的に解決するために策定された(正確には、策定されることが予告されている)プロトコルです。一度ハンドシェイクするだけで継続的に、双方向に通信を行うことができるもので、W3CとIETFによって仕様書の作成を進められています。Firefox、Chrome、Safariなどのモダンブラウザでは、仕様書のドラフトに沿った実装が進められていますが、ドラフトの改訂も相次いでいたため、サポートしているブラウザでも実装依存が大きいのが現状です。

「Socket.io」とは、このWebSocketをブラウザ互換性を気にせず利用できるライブラリです。WebSocket API、Ajax、Flashから、利用できる通信手段を検出して通信するとのこと。node.js 向けのサーバサイドライブラリも提供されており、node が動いているサーバから jsファイルを読み込むことで通信が始まります。ブラウザ間通信の互換性を気にせず、サーバサイドもクライアントサイドも似た記法で書くことができる便利なスクリプトです。

前置きが長くなりましたが、要は、socket.io は「node.jsで動くWebSocket API の便利なラッパ」と捉えればOKです。これがあれば、頻繁に通信が必要なネットゲームもJavaScript上で動かせます。ゲームエンジン enchant.js と組み合わせれば、クライアントからゲームサーバまで全てJavaScriptで書くことができるんです!しかし、すごい時代になったもんですね…。

ゲーム開発の方針

「福島ゲームジャム」では、30時間という時間制限の中に、事前にいくつかのマイルストーンが設定されていました。

  • スタートから3時間後に企画プレゼン
  • さらに8時間後にα版発表
  • さらに8時間後にβ版発表
  • さらに4時間後にプレイアブルデモ発表
  • 4時間後に最終発表

サーバサイドJavaScriptは初めてだったこともあり、オンライン対戦を実装のにどのくらいの時間がかかるか見積もれなかったため、とにかくαまでに遊べるものを作ってしまい、並行開発できるレベルまでソースコードを分離してから、もう一人のプログラマさんにゲーム自体の改善をお願いして、私が通信対戦部分の実装を進める、という方針を執りました。

実際には、環境構築でいくつかのポイントでつまずき、4時間ほど時間がかかってしまいましたが、その後のゲームサーバ・クライアントの実装はあまり時間がかからず、βまでに原形部分の実装を終えることができました。

初期設定

それでは、実際にゲームサーバを構築する手順を簡単にご紹介します。環境は「さくらのVPS 512」に初期インストールされている CentOS 5.5 x86_64 を利用しました。nodeはv0.4.11、npmはv1.0.27、socket.ioのバージョンはv0.7.11です。

1. git, wget が必要なので yum からインストール

$ sudo yum install wget git

2. nvmのソースコードをcheckout

nvm (node version manager) のソースコードをgithubからダウンロード。実態はシェルスクリプトです。

$ git clone git://github.com/creationix/nvm.git ~/.nvm

3. nvmを使う

$ source ~/.nvm/nvm.sh

nvmを使うたびにこの .sh を読み込まなければいけないので、忘れないうちに .bashrc に書いておきましょう。

4. install node and use

$ nvm install v0.4.11
$ nvm use v0.4.11
$ node --version
v0.4.11

5. npm install

$ curl http://npmjs.org/install.sh | sh

6. ejs, express をインストール

$ npm install ejs express

socket.ioもまとめて入れちゃいたいところですが、後述の理由でエラーになるのでまだ入れません。

7. GNU Tar のバージョン確認

$ tar --version
tar (GNU tar) 1.14

8. GNU Tar が最新版 (v1.26) じゃなかったらアップデート

さくらVPSの初期OSであるCentOS5.5 にくっついてくるtarのバージョンはかなり古いので、Socket.io の最新版 (v0.7.8以降) を入れようとすると、tarが解凍できずエラーとなってしまいます。

$ mkdir local
$ cd local
$ wget http://ftp.gnu.org/gnu/tar/tar-1.26.tar.gz
$ tar xfz tar-1.26.tar.gz
$ cd tar-1.26.tar.gz
$ configure
$ make
$ make install

9. socket.io をインストール

$ npm install socket.io

10. サーバ起動

$ node server.js
info  - socket.io started

11. このままだと ssh の窓を閉じると切れちゃうので

$ node server.js &>/dev/null

ログ取りたければ取りましょう。

サーバ実装

ゲームサーバ上に置いておく jsファイルです。上の設定手順では「server.js」という名前で保存しています。

socket.io は現在 v0.8で、v0.7以降は大幅にインターフェイスが変更となっているようです。公式 ( http://socket.io/
) のサンプルが一番シンプルで分かりやすいのですが、すべての機能を網羅しているわけではありません。今年7月に書かれた Socket.IO v0.7 の新機能解説 – Block Rockin’ Codes という記事がいちばん役に立ちました。

var app = require('http').createServer(handler)
, io = require('socket.io').listen(app)
, fs = require('fs')

app.listen(3000);

function handler (req, res) {
// index.html を返す。今回は特に必要ありませんが残しています

fs.readFile(__dirname + '/index.html',
function (err, data) {
if (err) {
res.writeHead(500);
return res.end('Error loading index.html');
}

res.writeHead(200);
res.end(data);
});
}

// ゲームサーバ用変数
 
// ロビーに居るユーザの配列 (id : nickname)
var robby = {};
// battleId のカウンタ
var _battleId = 0;
 
// ゲームサーバ処理
// ゲームサーバへの接続があったときのイベントリスナ
io.sockets.on('connection', function (socket) {
// 接続が成立したことをクライアントに通知
socket.emit('connected');
// 接続が途切れたときのイベントリスナを定義
socket.on('disconnect', function () {
// ロビーの配列から削除
delete robby[socket.id];
// 接続が途切れたことを通知
socket.emit('user disconnected');
});
// ユーザがロビーを離れたときのイベントリスナを定義
socket.on('leave robby', function (id) {
delete robby[socket.id];
socket.emit('user left');
});
 
// ロビーへ入るとき・戻ってきたときのイベントリスナを定義
socket.on('enter robby', function (data){
if(data.nickname){
// ロビーのユーザ情報配列にデータを追加
robby[socket.id] = data.nickname;
 
// クライアントにロビーに接続できたことと、クライアントのidを通知
socket.emit('robby entered', socket.id);
 
// クライアントにロビーにいるユーザを通知
socket.emit('robby info', robby);
 
// 他のユーザに、接続があったことを通知
socket.broadcast.emit('user joined', { id: socket.id, nickname: data.nickname });
}
})
 
// バトルの申し込みがあったときのイベントリスナを定義
socket.on('battle proposal', function(data, fn){
if(data.to){
// 新しいバトルに対してバトルIDを割り振る
var battleId = _battleId ++;
 
// 申し込みがあったことを通知
socket.broadcast.emit('battle proposal', {from: socket.id, to: data.to, battleId: battleId});
 
// バトルを始める
startBattle(battleId);

// 割り振られたバトルIDをクライアントに返答
fn({battleId : battleId});
}
});
 
// ロビーにいるユーザの情報を求められたときのイベントリスナ
socket.on('robby info', function (data) {
// ロビーのユーザ情報配列を返す
socket.emit('robby info', robby);
});
});
 
// 通信対戦を始める
function startBattle(battleId){
// /battle/:battleId に対する接続を待ち受けるイベントリスナ
var battle = io.of('/battle/'+ battleId).on('connection', function(socket){
 
 
// ゲームを始めた旨の通知をbroadcast
socket.on('game start', function(){
console.log('started');
socket.broadcast.emit('game start',{});
});
// ジャンプした通知をbroadcast
socket.on('jump', function(data){
console.log('jumped');
socket.broadcast.emit('jump',{frame: data.frame, score: data.score, voltage: data.voltage});
});
// ゲームの状況をbroadcast
socket.on('game info', function(data){
socket.broadcast.emit('game info',{frame: data.frame, score: data.score, voltage: data.voltage});
});
});
}

クライアント実装

オンライン対戦用のコードを読み込む前に、socket.ioを通じた通信に必要なjsファイル「socket.io.js」を、通信先のサーバから読み込む必要があります。

        <script type="text/javascript" src="http://182.48.46.45:3000/socket.io/socket.io.js"></script>
// 接続するサーバのアドレスを指定
// var server = 'http://localhost:3000';
var server = 'http://182.48.46.45:3000';
 
// ロビーに接続する関数
function enterRobby(){
var socket = io.connect(server);
 
// 接続できたというメッセージを受け取ったら
socket.on('connect', function() {
robby.emit('enter robby',{'nickname': prompt("enter your nickname")});
waiting = true;
matchingScene();
});

// ロビーに入ったというメッセージを受け取ったら
socket.on('robby entered', function(id){
socket.id = id;
})

// 他のユーザが接続を解除したら
socket.on('user disconected', function(data) {
console.log('user disconnected:', data.id);
});

// ロビーのユーザ一覧を受け取ったら
socket.on('robby info', function(robbyInfo) {
console.log('robby info', robbyInfo);
for(var id in robbyInfo){
// 誰かいたら無条件でバトル申し込み
if(id != robbyInfo.id){
var enemyId = id;
var enemyNick = robby[id];
break;
}
}
// バトルの相手が居たら…
if(enemyId){
// バトル申し込みのメッセージを送信
robby.emit('battle proposal', {to: enemyId}, function(data){
// 返信がきたときのコールバック
waiting = false;
var that = data;
// マッチング完了のというシーンを表示
matchedScene(function(){
game.popScene();
battleStart(robby.id, enemyId, that.battleId);
});
})
}

});

// バトル申し込みを受け取ったら
socket.on('battle proposal', function(data) {
// 自分宛のものかどうか確かめる
if(data.to == robby.id){
// マッチング完了のシーンを表示して、バトルスタート
matchedScene(function(){
game.popScene();
battleStart(robby.id, data.from, data.battleId);
});
}
});
}

// バトルスタート
function battleStart(myId, enemyId, battleId){
// まずロビーを離脱
robby.emit('leave robby', {id: robby.id});

game.started = false;
game.emittedStartSignal = false;

// ゲームスタート待機
game.begin();
game.ready();

// 新しく、http://localhost:3000/battle/:battleID と接続
battle = io.connect(server + '/battle/' + battleId);

// バトルルームとの接続が確立されたら
battle.on('connect', function() {
waiting = true;
});

// ゲームが始まった通知を受け取ったら
battle.on('game start', function() {
console.log('game start');
game.popScene();
});

// 相手がジャンプした通知を受け取ったら
battle.on('jump', function(data) {
// 相手の神輿をジャンプさせる
mikoshi2.jump();
// データを更新
enemy.voltage = data.voltage;
enemy.score = data.score;
});

// ゲームが始めた通知を送る
sendStart = function(){
battle.emit('game start', {});
}

// ジャンプしたという通知を送る
sendJump = function(frame){
battle.emit('jump', {frame: frame, voltage: game.voltage, score: game.score});
}
}
enemy = {voltage: 0, score: 0};

// マッチング中の画面を表示
function matchingScene(){
var matchingScene = new SplashScene();
matchingScene.image = game.assets['image/matching.png'];
game.pushScene(matchingScene);
}

// マッチング成立の画面を表示し、2秒後に引数の関数を呼ぶ
function matchedScene(func){
game.popScene();

var matchingScene = new SplashScene();
matchingScene.image = game.assets['image/matching.png'];
game.pushScene(matchingScene);
setTimeout(func, 2000);
}

ご覧の通り、サーバサイド・クライアントサイドの処理をほとんど同じ感覚で書くことが出来ます。

改善点

技量不足も、当日の時間不足もあり、改善点は多いですが、特に重要なものを。

  • 相手の神輿の動きにレイテンシを考慮できていない(スコアは正しく伝わる)
  • ロビー機能の実装が適当

特にレイテンシがあっても対戦ゲームとしての整合性を保つ処理はとても難しい。障害物の出てくるタイミングをレイテンシ分遅らせて、といった処理も考えたのですが、レイテンシにもバラつきがあるため、ジャンプの指令を遅れて受け取ったあと、いったんコケた神輿を戻してジャンプさせたり、と煩雑になってしまうため実装は見送りました。

同じ対戦型ゲームでも、オセロのようなレイテンシが問題にならないものはカンタンに作ることが出来ます。

ハマりやすいポイント

  • さくらの標準OSでは、socket.io v0.7.8以降のインストールはtarのアップデートが必要
  • socket.io の記法が v0.7以降大きく変わっているので、Webで検索できるサンプルのうち古いものは役に立たない
  • node.js もバージョンアップが激しいので、nvmは必須
  • ポートに穴を空けるのを忘れないように

以上、enchant.js と node.js + socket.io でリアルタイム対戦ゲームを作ってみたレポートでした!

このエントリーをはてなブックマークに追加
はてなブックマーク - node.js + socket.io + enchant.js でつくる、リアルタイム通信ゲーム
Post to Google Buzz
Share on GREE

Related posts:

  1. enchant.jsが進化!新機能でプチ・ネットゲームを作っちゃおう!
  2. JavaScriptで MMOGをつくってみよう その4
  3. [enchant.js]shi3z式ゲームプログラミング #7 タイムアタックは神!
  4. enchant.jsで3Dゲームを作ってみる!
  5. DBで作るHTML5時代のネットゲームの作り方/どきどき☆ダンジョンの作り方

Facebook comments:

Post a Comment

Your email is never published nor shared. Required fields are marked *
*
*