GAEのChannel API で対戦ゲームの開発がどの程度難しいか

かなり前(SDK1.3.4-5)の頃の話題なのですが、Google App Engine でCometを実現するAPIが試験的に導入されました。デモとしてAndroidのロボットが動くやつを見た人も多いでしょう(http://dance-dance-robot.appspot.com/)。
現在、Channel APIは申請し承認されたサーバ(application-id)でのみ利用可能ですが、開発環境では実装して動かしてみることができます。appengine 周辺の人達は出た当初に色々と試しているわけですが、自分も試しにゲームが出来るレベルまで使ってみようと、実装してみました。
題材は囲碁ですが、9路盤という9×9の入門用の盤面としてみました。たまたま囲碁に興味を持ったのでやってみようという流れです。
尚、ソースコードGoogle Codeで公開しています。
http://code.google.com/p/play-go-online/

プラットフォームと開発環境

プラットフォームはappengine。使いたいから、興味があるから、仕事にもなりそうだから。言語はJavaPythonですが、自分は使い慣れたJavaを選択。ライブラリとしては Slim3 のみを使用します。開発環境は勿論、Eclipse
尚、Pythonでも大きく変わることはないはずです。ひがさんが言うように好きな方を使えばいいと思います。

この選択はある意味で一番難しいです。自分はappengineを1年以上いじっているわけで躊躇も何もないわけですが、初めて選択する場合はそれなりの覚悟やリスクは必要でしょう。学習コストは単発では回収できない気がします。

UI

UIはHTMLかFlashなどが選択肢になります。今回はhtml5を使ってみたかった事もあり、html5を選択しました。勿論、JavaScriptも利用するので、jQueryを使います。
html5は軽くサンプルを見た程度で経験はほとんどなしです。とはいえHtml/CSSはこれまで十分な経験があるので大きな問題にはなりません。jQueryも使用してきた経験があるので思い出しながら使うだけです。今回は、簡単なUIや盤面の描画も特に問題はありませんでした。これまでajaxのアプリケーションを組んだことがあればプラスちょっとの学習コストで済みます。ただし、サーバと通信する部分やそのハンドリングはGUI(非Web)やゲーム作成の経験があるとベターですね。
碁盤の描画や初期化、投了、石を打つなど作業は半日程度で済みました。ソースとしては、war/index.html と war/static/js/go.jsです。

datastore設計

今回のアプリケーションは一部Datastoreを使い保存しますが、複雑なデータ構造はほとんどありません。ゲームの場合はデータがあまり複雑にはならないかなと感じました。ゲームのログを記録するGame model, ユーザを保存する User, 対戦相手待ち管理用のLobbyを作り、getで取得してputで保存する事だけしか行いません。
とはいえ、Datastoreを全く使った事がない場合は、どうしたら良いだろうかと言う部分で悩む所です。ランキングやポイント、プロフィール、コミュニティ機能などを作っていくとなるとそれなりのノウハウが必要になるところです。
Datastore関連についてはこれまでの経験とSlim3のおかげでほとんど時間を費やしていません。

ajax / channel

胆となる通信部分です。サーバpushであるchannel apiを使いインタラクティブなゲーム進行を実現します。
UIとサーバは非同期通信を行いますが、ajaxについても今更な技術ですね。ajaxに加えてcomet(channel)を利用しますが、単に非同期にメッセージを受信し、onmessageハンドラが呼ばれるだけです。クライアント側は、イベント駆動型のGUIを作った経験があれば、難しい部分はありません。サーバ側についても、Ajaxリクエストを捌くコントローラを作り処理を記述するだけです。Slim3を使えば、コントローラも簡単に作成できます。

今回、使い方などを調べながら作ったのはchannel api のみです。後はどのようなシーケンスでゲームを進行させるかを設計しました。

ChannelAPIの基本的なシーケンスは次のようになります。

1. クライアントはサーバにリクエストを投げる
2. サーバは ChannelService でプレイヤーに一意な文字列を使いchannelId を発行する
3. クライアントは返却された channelId を使用してコネクションを張る(Google の提供する javascript api
4. サーバからメッセージを来るのを待つ(onmessageハンドラ)

サーバでイベントを送信する場合は、

1. 通知したいクライアント(channelId)にメッセージを送信する

以上です。

ゲームとして使えるように落とし込みます。

1. プレイヤーはクライアントにユーザ名を入力して join する(/join 通常のajax
2. サーバはchannel idを発行し、ユーザIDとchannelIdの紐付けを保存しておく(Datastoe or Memcache)
3. サーバは対戦相手を待っているユーザを保存しておく(ロビー)
4. クライアントは返却されたchannelIdを使い、channelを張る(プレイヤー待ち状態)
5. 2人目のプレイヤーも同様にjoinする(プレイヤー待ち状態)
次に2人を1つのゲームに参加させるために、onopenハンドラを使います
6. 各プレイヤーはchannel を作成したら、ゲームに参加する(/entry 通常のajax / onpen)
7. ロビーに対戦相手がいない場合、ロビーにプレイヤーを登録する
8. ロビーに対戦相手がいた場合、ゲームを開始する準備を行う(先番後番など)
9. サーバは、各プレイヤーのchannelIdを参照し、ゲームの開始メッセージをpushする
10. それぞれのクライアントは、onmessageハンドラでメッセージを受信し、ゲームを開始する

後は各クライアントが石を置くとajax通信が行われメッセージをpushするの繰り返しです。

折角なので実装を少し紹介します。
メイン画面はhtml5で記述して、staticなファイルとしてデプロイ(spin-up対策にもなる)します。
胆となるJavaScriptは /war/static/js/go.js のこの辺りです。

  // join a game
  var join = function(user_name) {
    console.debug("join: " + user_name);
    $.post('/join', {
      user_name : user_name
    }, function(t) {
      token = t;
      $('#msg').text('Connect to server...');
      console.debug("join OK, token: " + token);
      var channel = new goog.appengine.Channel(token);
      socket = channel.open();
      socket.onopen = function() {
        setTimeout(function() {
          $.post('/entry', {
            token : token,
            rank : 1
          }, function(pid) {
            playerId = pid;
            $('#msg').text('Please wait for matching...');
          });
        }, 100);
      };
      socket.onmessage = function(msg) {
        var data = $.parseJSON(msg.data);
        console.debug("data: " + data.black);
        handle(data);
      };
    });
  };

ユーザがユーザ名を入力して「join」ボタンを押した時に実行されます。
最初の $.postはただの非同期通信(ajax)ですので注意してください。

対応するサーバ側のハンドラはSlim3のController

public class JoinController extends AbstructGoController {

    @Override
    public Navigation run() throws Exception {
        String userName = super.asString("user_name");
        if (userName == null || userName.length() == 0) {
            // TODO
        }
        // TODO now guest only
        Player player = new Player();
        player.setName(userName);
        Datastore.put(player);
        String channelId = GoServer.createChannel(player);
        Memcache.put(channelId, player);
        System.out.println("join: " + channelId + ", player=" + player);
        return text(channelId);
    }
}

channelIdを発行している辺りのコードを抽出。クラスとしては、GoServerです。

    public static String createChannel(Player player) {
        return createChannel(player.getKey());
    }
    public static String createChannel(Key key) {
        return getChannelService().createChannel("" + key.getId());
    }
    private static ChannelService getChannelService() {
        return ChannelServiceFactory.getChannelService();
    }

ChannelServiceのcreateChannelで適当なIDを指定してChannelを作るわけです。尚、ざるコードなので細かい部分はご了承ください。

このChannelIdをクライアント側は受け取ります。

    $.post('/join', {
      user_name : user_name
    }, function(t) {
      token = t;
      console.debug("join OK, token: " + token);
      var channel = new goog.appengine.Channel(token);
      socket = channel.open();

GoogleのChannel api用のjsを取り込んでおく必要があります。これでサーバpushの準備はOK、簡単ですね。
尚、サーバが同じ情報を全クライアントに送信する場合、誰がどの情報かを判断するには自分のChannelIdを使うと楽かと思います。

さて、サーバのpushを受けた時に起動されるハンドラはこのように定義されています。

      socket.onmessage = function(msg) {
        var data = $.parseJSON(msg.data);
        console.debug("data: " + data.black);
        handle(data);
      };

pushする方はこんな感じ。

    private static void sendMessages(Game game, String command) {
        String msg = ... // json
        getChannelService().sendMessage(
            new ChannelMessage("" + game.getBlack().getId(), msg));
        getChannelService().sendMessage(
            new ChannelMessage("" + game.getWhite().getId(), msg));
    }

サーバからはJSONでデータを受け取りますが、コマンドパターンで処理をハンドリングしています。JSONは次のようなフォーマットです。

{
"command": "next",
"gameId": 55,
"black": "8",
"white": "7",
"turn": 20,
"player": "8",
"grid":[3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,3,3,0,0,1,0,0,0,0,0,0,3,3,0,1,2,1,0,0,0,0,0,3,3,1,0,1,2,2,2,0,0,0,3,3,0,1,2,2,2,0,0,0,0,3,3,0,0,1,2,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3],
"winner": null}

この辺りは慣れればパターン化できそうです。細かくチート対策する必要はありますが、基本的にはサーバで処理を行い、UIでは表示のみに徹底すれば大きな不正はできないはずです。

というわけで通信部分はそれほど複雑ではなく、channel apiもシンプルです。設計と実装は1日くらい試行錯誤していましたがそのくらいでした。

ゲームロジックの実装

で、問題はゲームロジックの実装です。
というのも囲碁のゲームロジックが結構面倒・・・ルールを再現するだけでもなかなかの骨で、特に終局条件については難しいです。この部分はまだ実装中で、暇なときにちょっとずつ進めていき、簡単な思考アルゴリズムまで実装したい所です。
今回の作った範囲については、このゲームロジック部分が3−4日かかってます(笑)

まとめ

結局の所、囲碁や将棋といった数名で対戦するボードゲームをappengine + channel API で実装するのは非常に簡単だったという事です。勿論、色々なこれまでのスキルにもよりますが、これだけの内容をappengineを使えば、タダ同然で試すことができるのは素晴らしいことです。おまけに、万が一、ゲームがヒットしてもスケールするわけです。
思いついてから、channel apiの調査実装で1−2日、UIの作成で半日、ゲームロジックで3〜4日、その他サーバ処理等で半日。趣味の時間でもそのくらいで作ることができたわけです。残念ながらまだchannel apiは一般使用はできませんが、この機能が無料やかなり安い課金で使えるとしたら素晴らしいことです。

というわけで、appengineを習得するといいですよ。習得してしまえばアイディア次第で様々な事が格安で試すことができます。もう、slim3やdatastoreの設計パターンなど十分に情報は集まる時期です。1人でやっていては情報も知識も偏りますし、効率も悪いですから、先人達のノウハウは最大限に利用して効率良く学びましょう。

おまけ

囲碁の思考ルーチンとか興味ある人がいたら一緒に開発しましょう。Datastoreに格納した何千万という棋譜を参照したり、TQで分散思考ルーチンとか胸が熱くなる方いませんか?w