Skip to content

JavaScriptで MMOGをつくってみよう その4

こんにちは、中嶋 (@ringo) です。

この連載では、MMOG開発ではまだ実績が少ないJavaScriptを使っていますが、
拙著「オンラインゲームを支える技術」という本では実績のある制作技法を紹介しています。そちらもどうぞ!

さて前回まではクライアント側の実装について、調査を進めました。
今回はサーバーと、通信について調査します。

MMOGのほとんどはCやC++、たまにJavaで書かれます。

JavaScriptでサーバを書く場合の処理系の決定版がnode.jsです。
node.jsはどのサーバにも最初から入ってるということはなく、追加インストールが必要です。

全体の構成をまず考えます。

ほとんどのMMOGで使われている鉄板の構成があるので、そのまま採用します:

MySQL – (TCP接続) – ゲームサーバ – (TCP接続) – Unity3Dクライアント

ゲームサーバは node.jsを使って書きます。
当たり判定とかキャラクターの成長とか敵の動きとかは全部、
ゲームサーバに実装して、Unityクライアントはそれを見るだけ、
MySQLはデータを保存するだけで、ゲームの処理内容をSQLで書くことはしません。
なのでKey-Value ストアでもいいです。
ちなみに minecraftのサーバは、ローカルファイルに保存してます。

通信プロトコルはどうでしょうか?

ゲームサーバとMySQLの間は、MySQLのプロトコルが流れます。
node.jsには sequelizeという、シンプルな非同期ORマッパがあるので、
それを使ってSQLを生成します。

ゲームサーバとクライアントの間は、ゲームの専用プロトコルになります。
だいたい1人あたり1秒間に5〜15回ぐらいの通信を続けます。
この頻度だと、HTTPだとオーバーヘッドが大きすぎるので、socketを使ってTCP接続を維持します。
minecraftも同じぐらいの頻度で通信します。

C++を使ってゲームサーバーを実装する場合は、軽量化と速度のために、
ぎりぎりまでパケットサイズを削ったバイナリプロトコルを実装しますが、
今回はJSON-RPCにします。
バイナリよりはるかに遅くなるけど、デバッグもやりやすいし、既存のライブラリも使えて嬉しいです。
最適化は後で。

通常、JSON-RPCはHTTPを前提としているので、「TCP張りっぱなし」
に対応したバージョンにします。

改行記号を区切り文字として、1行に1個のJSONを送りあうように。

{
"version" : "1.1",
"method"  : "sum",
"params"  : [ 12, 34, "aaa"  ]
}

一般的なフォーマットだとこんな感じです。
TCP接続を維持したままの場合、versionを何度も同じ値で送るのは無駄です。
それに methodとかparamsとかも毎回同じで、無駄です。
なので最適化で数分の1に圧縮できそうですが、それは後で。

ではMMOGの実装に必要な要素を片っ端から検証します。

手元のmacbook proは snow leopardのOSXが入ってます。

手順:
http://d.hatena.ne.jp/tagomoris/20110304/1299209319

brewというコマンドはOSX標準ではないですのでここからとってきます:
http://mxcl.github.com/homebrew/

node.jsもはやく標準的な処理系になればいいのにな。。

自分のマシンにはすんなりと入りました。

TCP接続を張りっぱなしのサーバのスケルトンはこんな感じ:

// TCP Echo サーバ MMOG風

var sys = require('sys');
var net = require('net');

var sockets = new Array();

var server = net.createServer(function (socket) {
socket.setEncoding("utf8");

// 新しい接続を受けいれたとき
socket.addListener("connect", function () {
socket.addrString = socket.remoteAddress + ":" + socket.remotePort;
sockets[socket.addrString] = socket;
socket.counter = 0;       // 接続ごとに、状態を持つ
socket.write("hello: " + socket.addrString + "\r\n");
});

// データが来たとき
socket.addListener("data", function (data) {
socket.counter ++;
socket.write( "message " + socket.counter + ":" + data);
});

// 切れたとき
socket.addListener("end", function () {
delete sockets[ socket.addrString ];

sys.puts( "end. socknum:" + sockets.length);
});
});

server.listen(7000, "localhost"); // サーバー待ち受け開始

このサーバにtelnetしたときのやりとりはこうなります

[@Macintosh node]$ telnet localhost 7000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello: 127.0.0.1:61121
asd
message 1:asd
fas
message 2:fas
df
message 3:df
^]
telnet> quit
Connection closed.

node.jsのechoサーバの例とほぼ同じですが、MMOGでのポイントは、
接続ごとに counterという状態を持って、加算してることです。
MMOGのサーバでは、データベースではなくサーバプロセスのメモリの中に
いろんな状態を保持して多くの処理をします。
接続ごとに情報を持つだけでなく、ほかのプレイヤーにも状態変化を通知したいので、
socketsという連想配列にsocketを入れています。
たとえばチャットするならこの配列に入ってる各socketにRPCを送信します。

ゲームサーバがsocket経由でJSONを受信したときは JSON.parseを、
ネットワークに送信するときは JSON.stringifyを使います。
socketから来る改行記号で区切られたデータは、splitで分割します。

var sys = require( 'sys' );

var t = "{\"aho\":1,\"hoge\":[1,2,3],\"fuga\":\"aaaaaaaa\"}\n{\"aho\":2,\"hoge\":[3,4,5],\"fuga\":\"bbbbb\"}"
var i;

for(i=0;i<100000;i++){
var ary = t.split("\n");
var decoded = JSON.parse(ary[1]);
decoded.aho += 1;
var s = JSON.stringify(decoded);
}

10万回繰り返すと0.6秒かかります。C++にくらべると10分の1ぐらいの速度です。
同時接続が1000のとき、1秒間に5個づつJSONの送受信をしたら、5000回/秒 なので、
CPU時間の5%が文字列処理だけのために食われる計算になります。

次に TCP自体の負荷も一応計測しておきます。
そういうときはApache benchが便利です。
node.jsでHTTPのサーバをつくってApache benchの abコマンドで測定します。

var sys = require('sys');
var http = require('http');

var server = http.createServer(
function (request, response) {

response.writeHead(200, {'Content-Type': 'text/plain'});
response.write('Hello World!!\n');
response.end();
}
).listen(8080);

sys.log('Server running at http://127.0.0.1:8080/');

ab -n 1000 http://127.0.0.1:8080/
で大体 2100接続/秒 の速度が出ます。
libevent等を使って直接書く場合の5分の1程度でしょうか。
特に問題はなさそうです。

以上でサーバの基礎の基礎の調査は終わり。

次はクライアントです。

UnityでSocketを使うためには、現状どうしてもC#で初期化部分を書く必要があります。
ただし、初期化した後の通信はJavaScriptからC#の関数を呼べば
JavaScriptだけで書けます。

初期化部分のC#

using UnityEngine;
using System.Collections;
using System;
using System.IO;
using System.Net.Sockets;

public class ProtocolScript : MonoBehaviour {
internal Boolean socketReady = false;

TcpClient mySocket;
NetworkStream theStream;
StreamWriter theWriter;
StreamReader theReader;
String Host = "127.0.0.1";
Int32 Port = 7000;

void Start () {          // 主人公が登場したときに1回だけ呼ばれる
Debug.Log( "socket start\n" );
Boolean b = Security.PrefetchSocketPolicy ( "127.0.0.1", 7000, 3000);       // ポリシーファイルをサーバから 取ってくる
Debug.Log( "b:"+b);
setupSocket();
writeSocket( "aaaaaaaaa");
}
void Update () {} // 毎フレーム呼ばれる

// ソケットを初期化
public void setupSocket() {
try {
mySocket = new TcpClient(Host, Port);
theStream = mySocket.GetStream();
theWriter = new StreamWriter(theStream);
theReader = new StreamReader(theStream);
socketReady = true;
}
catch (Exception e) {
Debug.Log("Socket error: " + e);
}
}
// ソケットにデータを送信
public void writeSocket(string theLine) {
if (!socketReady)
return;
String foo = theLine + "\n";
theWriter.Write(foo);
theWriter.Flush();
}
// ソケットから読み込み
public String readSocket() {
if (!socketReady)
return "";
if (theStream.DataAvailable)
return theReader.ReadLine();
return "";
}
// ソケット閉じる
public void closeSocket() {
if (!socketReady)
return;
theWriter.Close();
theReader.Close();
mySocket.Close();
socketReady = false;
}
}

ポイントは、Flashとおなじように、接続しようとしてるサーバから
ポリシーファイルを最初に取得し、その後もういちど接続することです。
ポリシーファイルを返すようにするには node.jsでは、
データ受信関数を以下のように修正するだけです。

// データが来たとき
socket.addListener("data", function (data) {
socket.counter ++;

if( data.match( /^<policy-file-request/ ) ){
sys.puts( "policy file requested\n");
socket.write( "\n" );
} else {
socket.write( "message from " + socket.addrString + ":" + socket.counter + " : " + data);
}

sys.puts( "data:" + data + "\n" );
});

受信したデータの冒頭部分が policy-file-request の開始タグだったら、
いつも同じ内容の文字列を返して終わりです。

なんというかものすごく簡単ですね。。

UnityのJavaScriptから C#の関数を呼ぶには次のようにします。

if( GUI.Button( Rect( 200,40,80,20), "Send" )) {
var psend  = GetComponent( "ProtocolScript" );
psend.writeSocket( "fromjs" );
}
if( GUI.Button( Rect( 300,40,80,20), "Recv" )) {
var precv  = GetComponent( "ProtocolScript" );
var s = precv.readSocket();
if( s != "" ){
print( "recv string:" + s );
}
}

GUIのSendボタンが押されたら書きこんで、Recvボタンが押されたら全部読もうとします。
これでデータをJavaScriptに持ってこれるので、C#側で開発する必要はないです。

node.jsとUnityで送受信してる状況:

これで通信自体は問題なくできました。

次回は、リモート関数呼び出し(RPC)をどう実装するかと、ORマッパの使い方を調査します。

# wordpressのcode colorer使ってるけど、どうしてもダブルクォートや不等号がエスケープされる時があって困る。かならずエスケープされるわけではなくて意味がわからん。。
(続く)

このエントリーをはてなブックマークに追加
はてなブックマーク - JavaScriptで MMOGをつくってみよう その4
Post to Google Buzz
Share on GREE

Related posts:

  1. JavaScriptで MMOG をつくってみよう その1
  2. shi3z式ゲームプログラミング #6 覚えておくといいテクニック
  3. JavaScriptで MMOG をつくってみよう その2
  4. shi3z式ゲームプログラミング入門 #4 バナナと配列
  5. shi3z式ゲームプログラミング #5 Twitterと連携だっ!

Facebook comments:

Post a Comment

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