KLabGames Tech Blog

KLabは、多くのスマートフォン向けゲームを開発・提供しています。 スマートフォン向けゲームの開発と運用は、Webソーシャルゲームと比べて格段に複雑で、またコンシューマゲーム開発とも異なったノウハウが求められる領域もあります。 このブログでは、KLabのゲーム開発・運用の中で培われた様々な技術や挑戦とそのノウハウについて、広く紹介していきます。

このエントリーは、KLab Advent Calendar 2015 の12/5の記事です。

5番手も緊張しますね。KLab 新卒4年目の @mecha_g3 です。よろしくお願いします。

5日目ですがGoの話ではありません。

はじめに

最近のモバイルオンラインゲームはリアルタイム性の高いマルチプレイができるものが増えてきました。
Unity を使用してゲームを作る場合の通信ライブラリは、Photon Unity Networking が有名です。
Photon を使えばマルチプレイのゲームを比較的簡単に動かすことができますが、便利な反面思い通りに行かない所も出てきます。
ここでは Unity C# を使用して開発するゲームの通信部分を自分で実装したい人のために、protobuf-net の話をします。

protobuf-net とは

protobuf-net は ProtocolBuffers の非公式 C# 実装です。
ProtocolBuffers とは、Google が開発したシリアライズフォーマットとそのライブラリおよび IDL(インターフェース定義言語) とそのコンパイラなどの総称です。

Google 公式の C# 版のソースコードは version3 から提供されるようになりましたが、Status: Alpha と書かれてあるうえに、リポジトリの issue を見る限り Unity ではまだ動かないようです。

protobuf-net というOSSライブラリが Apacheライセンスで使用しやすく、Unityで使用できそうという事で使ってみることにしました。

最初は MessagePack を使うか悩んだのですが、IDL を使って定義を記述し、サーバ用のコードとクライアント用のコードをそれぞれ生成できる点を評価して ProtocolBuffers を使用することにしました。

protobuf-net の入手

コンパイル済みのバイナリは GoogleCode の Downloads のページからダウンロードできます。
ただ執筆時点で最新が2013年9月と古いうえに、GoogleCode は 2016年1月 25日にサービス終了されるのでダウンロードできなくなると思われます。

protobuf-net のビルド

Github からソースを入手します。
最新のmasterブランチでビルドできない場合、issue やコミットログを見ながら適当なバージョンを探してください。

コンパイルには、VisualStudio をインストールした Windows 環境が必要です。
私は VisualStudio 2013 for Desktop を使用しました。
protobuf-net は様々なプラットフォーム用にプロジェクトファイルが存在しますが、すべてのプラットフォーム向けにビルドする環境を整えるのが非常に困難かつそもそもコンパイル通るか怪しいので、今回は Unity 向けビルドのみ着目します。
リポジトリ直下にある all.build を参考にしながら、Unity 用のビルドファイル (ここでは仮に unity.build とします) を作ります。

私の場合以下の様になりました。

MSBuild を使用してビルドします。

MSBuild.exe unity.build

ビルドしてできた protobuf-net.dll を Unity プロジェクトの Plugins ディレクトリにコピーします。

簡単な使い方

protobuf-net は .proto ファイルを使用せずに手動で C# の class に属性を追加する使い方をサポートしているようですが、ここでは .proto ファイルに定義を記述する使い方を紹介します。
私は MacOSX版 の Unity 5.2.1p3 を使用して実験しました。Windows 環境の方は適宜読み替えてください。

C++版の Protocol Buffers をインストール

protobuf-netが version2系なので私は protobuf 2.x 系を使用していますが、最新でも互換性があるようなので動くかもしれません。

mono のインストール

mono をインストールしてPATHを通しておきます。

.proto ファイルを記述

sample.proto ファイルとして以下の内容を保存します。

package sample;
message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
}

protoc

protoc コマンドを使用して sample.proto の descriptor_set を出力します。

protoc --descriptor_set_out=sample.pb sample.proto

protogen

ビルドした protobuf-net の中にある protogen.exe を使用して、C#のソースファイルを生成します。

mono /path/to/protogen.exe -i:sample.pb -o:sample.pb.cs

これで生成される sample.pb.cs は以下のような内容になっています。

//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

// Generated from: sample.proto
namespace sample
{
  [global::System.Serializable, global::ProtoBuf.ProtoContract(Name=@"Person")]
  public partial class Person : global::ProtoBuf.IExtensible
  {
    public Person() {}

    private string _name;
    [global::ProtoBuf.ProtoMember(1, IsRequired = true, Name=@"name", DataFormat = global::ProtoBuf.DataFormat.Default)]
    public string name
    {
      get { return _name; }
      set { _name = value; }
    }
    private int _id;
    [global::ProtoBuf.ProtoMember(2, IsRequired = true, Name=@"id", DataFormat = global::ProtoBuf.DataFormat.TwosComplement)]
    public int id
    {
      get { return _id; }
      set { _id = value; }
    }
    private string _email = "";
    [global::ProtoBuf.ProtoMember(3, IsRequired = false, Name=@"email", DataFormat = global::ProtoBuf.DataFormat.Default)]
    [global::System.ComponentModel.DefaultValue("")]
    public string email
    {
      get { return _email; }
      set { _email = value; }
    }
    private global::ProtoBuf.IExtension extensionObject;
    global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing)
      { return global::ProtoBuf.Extensible.GetExtensionObject(ref extensionObject, createIfMissing); }
  }

}

シリアライズ/デシリアライズ

protobuf-net.dll がうまくインポートできていれば、上記の sample.pb.cs をプロジェクトに入れると、(Unity Editor上で) 以下のようにシリアライズ/デシリアライズができます。

...
var person1 = new sample.Person();
person1.id = 123;
person1.name = "taro";
person1.email = "taro@example.com";

System.IO.MemoryStream ms = new System.IO.MemoryStream();
ProtoBuf.Serializer.Serialize<sample.Person>(ms, person1);

ms.Seek(0, System.IO.SeekOrigin.Begin);
var person2 = ProtoBuf.Serializer.Deserialize<sample.Person>(ms);

UnityEngine.Debug.Log(person2.id);     // => 123
UnityEngine.Debug.Log(person2.name);   // => "taro"
UnityEngine.Debug.Log(person2.email);  // => "taro@example.com"

事前コンパイル

ここまでの手順のままでは、少なくとも iOS 向けビルドではうまくシリアライズ/デシリアライズができません。
動かない理由は、protobuf-net は実行時型情報を使いながら、Reflection.Emit() を使って シリアライズ/デシリアライズするプログラムを動的に生成しているからです。
Reflection.Emit() は Unity iOS 向けビルドではサポートされていません。

.protoファイルで定義したすべての型に対して、そのシリアライザ/デシリアライザを事前にコンパイルし、dll化しておくことによりこれを回避します。
まず、生成した .cs ファイルを一つの dll ファイルにまとめます。

gmcs \
     -target:library \
     -langversion:ISO-2 \
     -r:/path/to/protobuf-net.dll \
     -out:proto.dll \
     $(find . -name "*.pb.cs")

precompile.exe を使用して、シリアライザ/デシリアライザの関数を dll ファイルとして出力します。

mono /path/to/precompile.exe proto.dll -o:proto_serializer.dll -t:ProtoSerializer

proto.dll と proto_serializer.dll を同様に Unity のプロジェクトにインポートし、Protobuf.Serializer の代わりに ProtoSerializer クラスを使うことで、事前コンパイルしたシリアライザ/デシリアライザを使用することができます。

問題点とその回避

protobuf-net を使用していて気づいた問題点とその回避方法を紹介します。これらの問題点はいずれも protobuf-net の issue 等で報告されているものです。
最初は PullRequest を送るつもりで修正を開始しましたが、他の機能の互換性を壊す部分があるので控えています。

Enum型のデフォルト値の問題

protobuf-net では指定がない限り、インスタンスを生成した際に default(T) で初期化されます。
.protoファイルで型を定義する際、フィールドの後ろに [default = 123]; というオプションをつけることで明示的にデフォルト値を指定することができます。

  • C# では Enum型の値のデフォルト値は 0 です。
  • ProtocolBuffers の仕様では、Enum型のデフォルト値は明示的な指定がない場合、定義した最初の定数です。

protobuf-net の protogen.exe のテンプレート内では、ProtocolBuffers の仕様に合う様に、最初の定数をデフォルト値にしようとしている部分がありますが、別ファイルで定義されたEnum型の定数一覧を取得することができずコンパイルエラーになる C# のソースコードを出力してしまうという問題があります。

この問題を修正するためには protogen.exe において、依存する他の descriptor_set ファイルも読み込み、Enumの定義を見て適切にデフォルト値を選択する必要があります。
入力を複数ファイルにする必要があり、コマンド自体に大きな変更が必要になってきます。

そもそも C# と Protobuf でデフォルト値の扱いが違うので、.proto ファイルでデフォルト値を必ず明示的に設定する運用で回避することにしました。

フィールドデフォルト値の問題

Enum型のデフォルト値の問題 で紹介した、明示的に指定するデフォルト値ですが、protobuf-net では required フィールドでは無視され、optional フィールドにしか適用されません。
さらに別の問題として、boolint, double などの数値型を値で持っているため、明示的に代入した値なのか、デフォルト値として初期化された値なのかがわからなくなります。

この問題を調査するため、protogen.exe のソースを読んでいくと、-p:detectMissing というオプションがあることに気づきました。このオプションをつけることで、定義した型の各フィールドの値を nullable で持つようになり、「値が設定されていない状態」を持つことができるようになります。
ただし required フィールドでは無視されているので、これを required フィールドにも適用されるように生成されるコードのテンプレートを編集します。
C# のテンプレートは protobuf-net/ProtoGen/csharp.xslt です。
フィールドの default オプションで設定したデフォルト値と、 protogen.exe の -p:detectMissing オプション
を required フィールドに対しても反映するように修正しました。

シリアライズ失敗の検出

本家 Protocol Buffer の実装では、.proto ファイルで、required として定義したフィールドでは、シリアライズ/デシリアライズ時に値が設定されていない場合、例外が投げられます。
しかし protobuf-net では required フィールドに値が入っていなくても、シリアライズ/デシリアライズ時に例外を投げてくれません。
例えばクライアント-サーバ型のゲームで、クライアント側で required フィールドに値を設定し忘れた場合にもシリアライズの失敗が検出できず、サーバ側に壊れたバイナリを送りつけてしまう事になります。

required フィールドへの値の設定漏れはゲーム開発中には頻繁に起こりえるので、なんとかして検出したいです。
シリアライズの直前、デシリアライズの直後に、required フィールドが設定されていなかった場合に例外を投げるように修正します。

挿入する場所は悩みましたが、precompile.exe により生成される各型のシリアライザ/デシリアライザの関数の中に一緒に書いてしまうのが楽そうです。
小さな修正でしたが、IL を生成する部分なので普段から IL で喋っていない私にとっては凄く難しかったです。
生成された dll をデコンパイルして思い通りのプログラムになるまで試行錯誤した結果、なんとか動くようになりました。

これら修正の問題点として

  • Serializer.DeepClone<T>() が内部でシリアライズ/デシリアライズを行っているため例外が出てしまう。
  • precompile.exe を通さない場合にシリアライズ失敗の例外が投げられない。
  • C# 用のテンプレートしか修正していない。vb.xsltxml.xslt も修正する必要がある。

などが挙げられます。上手く修正できれば、protobuf-net リポジトリに PullRequest を送りたいと思っています。

終わりに

この記事が、 protobuf-net を使っている方/使ってみたいと思っている方に少しでも参考になれば幸いです。
私が勘違いして書いてある部分があるかもしれません。指摘していただけると嬉しいです。

明日は kokukuma さんの カーネル空間までコード追っていこう@Linux です。

このエントリーは、KLab Advent Calendar 2015 の12/4の記事です。

KLabとしては久々のAdvent Calendar参戦です。4番手も緊張しますね。@hasi_tです。よろしくお願いします。

はじめに

今年は、PlaygroundとLuaによる大規模モバイルオンラインゲーム開発のレベルアップという題でCEDEC2015で発表することができました。
スライド原稿を公開したので、よければご覧ください。

この記事では、「Modelとサーバを統合したテスト」を支えるLunatic Pythonを紹介しようと思います。

使用した言語のバージョンは、Python 3.4とLua 5.2です。

Lunatic Pythonとは

Lunatic PythonはPythonとLuaの双方向ブリッジで、PythonからLuaを呼び出して、さらにその中からPythonを呼び出し、さらにその中からLuaを呼び出し、…することができます。

https://github.com/bastibe/lunatic-python にあるものを用います。
"Sadly, Lunatic Python is very much outdated and won't work with either a current Python or Lua." とあるように、
オリジナルのほうは古くなっているので注意が必要です。

クライアント・サーバ統合テスト

Playgroundゲームエンジンでは、ゲームを記述するプログラミング言語としてLuaを用います。
そして、ここではPythonで書かれたHTTPサーバと通信するゲームを考えます。

サーバに閉じたテストは比較的自動化しやすいのですが、クライアント・サーバを統合したテストは、サーバを立てて実際にクライアントアプリを実行してテストするのが従来の方法でした。
従来の方法だと自動化が難しいのが問題です。

そこでまず、クライアントサイドのLuaコードを、Playgroundゲームエンジンに依存する部分と依存しない部分に分けます。
更に、Playgroundゲームエンジンに依存しない部分のうち、通信に関わる部分をクライアント・サーバ統合テストの対象とします。(下図)

クライアント・サーバ統合テストの対象

environmentを使おう

Lunatic Pythonが提供する機能はとても単純なので、ラッパーオブジェクトを作って書きやすくしようと思います。
Lua5.2で導入されたenvironmentを使って、以下のようにLuaObjectというクラスを定義します。

class LuaObject:
    def __init__(self):
        self.env = lua.eval('{}')

    def run_lua_code(self, ld, src='_', **kw):
        return lua.eval('''
            load(python.locals().ld, python.locals().src, 't', setmetatable({}, {
                __index = function(_, key)
                    local w = python.locals().kw[key]
                    if w ~= nil then return w end
                    local v = python.locals().self.env[key]
                    if v ~= nil then return v end
                    return _G[key]
                end,
                __newindex = function(_, key, value)
                    if python.locals().kw[key] ~= nil then error('kw is read only') end
                    python.locals().self.env[key] = value
                end,
            }))()
        ''')

例えば、下のコードを実行すると4が出力されます。

L = LuaObject()
L.run_lua_code('x = 2 + 2')
print(L.run_lua_code('return x'))  # 4が出力される

environmentはインスタンスごとになっているので、グローバル変数への書き込みはそのインスタンスだけに影響します。

L1 = LuaObject()
L2 = LuaObject()
L1.run_lua_code('x = 1')
L2.run_lua_code('x = 2')
print(L1.run_lua_code('return x'))  # 1が出力される
print(L2.run_lua_code('return x'))  # 2が出力される

当然、普通にluaのライブラリ関数を使うこともできます。

print(L1.run_lua_code('return string.rep("hoge", 2)'))  # hogehogeが出力される

また、キーワード引数を使って簡単に値を渡すことができるようにしています。

L = LuaObject()
print(L.run_lua_code('return y', y=10))  # 10が出力される
print(L.run_lua_code('return y'))  # Noneが出力される

こうすることで、複数のクライアントを並行して動作させるテストも簡単に書けるようになりました。

サーバと通信しよう

次に、Luaからサーバと通信する部分を作ります。
といっても、Pythonの中でLuaスクリプトが動いているので、実際に通信することはなく通信に関するテストを実現できます。

例として、以下のようなJSONでデータをやりとりするFlaskを使ったAPIサーバを考えます。

def get_app():
    app = flask.Flask(__name__)

    @app.route('/incr', methods=['POST'])
    def incr():
        request_data = json.loads(flask.request.data.decode('utf-8'))
        return flask.jsonify(n=request_data['n'] + 1)

    return app

pytestとWebTestを使って、以下のようにテストを書くことができますが、これはサーバに閉じたテストです。

def test_api():
    app = get_app()
    test_app = webtest.TestApp(app)
    response = test_app.post(
        url='/incr',
        params='{"n":1}',
        headers=[('Content-Type', 'application/json')],
    )
    assert response.json['n'] == 2

ここで、以下のようにLuaClientというクラスを定義します。

class LuaClient(LuaObject):
    def __init__(self, test_app):
        super().__init__()
        self.test_app = test_app
        self.run_lua_code('''
            function json_encode(data)
                if type(data) == 'table' then
                    local a = {}
                    local r = {}
                    for i, v in ipairs(data) do
                        table.insert(a, json_encode(v))
                    end
                    for k, v in pairs(data) do
                        table.insert(r, json_encode(tostring(k))..':'..json_encode(v))
                    end
                    if #a == #r then
                        return '['..table.concat(a, ',')..']'
                    else
                        return '{'..table.concat(r, ',')..'}'
                    end
                elseif type(data) == 'string' then
                    return python.import('json').dumps(data, false, false)
                else
                    return tostring(data)
                end
            end

            function call_api(path, data, callback)
                local res = python.locals().self.call_api(path, json_encode(data))
                if res then
                    callback(res)
                end
            end
        ''')

    def call_api(self, path, data):
        response = self.test_app.post(
            url=path,
            params=data,
            headers=[('Content-Type', 'application/json')],
        )

        def json_to_lua(x):
            if isinstance(x, dict):
                res = lua.eval('{}')
                for k, v in x.items():
                    res[k] = json_to_lua(v)
                return res
            elif isinstance(x, list):
                res = lua.eval('{}')
                for i, v in enumerate(x, start=1):
                    res[i] = json_to_lua(v)
                return res
            else:
                return x

        return json_to_lua(response.json)

LuaClientを使うことで、Luaからサーバを呼び出すテストを以下のように書くことができます。

def test_incr():
    app = get_app()
    test_app = webtest.TestApp(app)
    client = LuaClient(test_app)
    client.run_lua_code('''
        call_api('/incr', {n = 1}, function(data)
            response = data
        end)
    ''')
    assert client.run_lua_code('return response.n') == 2

ここでは簡単のためにプレイヤー作成や認証に関する処理を省いていますが、
プレイヤーを作成しデータベースを書き換えてログインさせるといったテストも簡単に書くことができます。

データベース書き換えは、クライアントテストを拡張してクライアント・サーバ統合テストを実現しようとしたときに面倒だったところで、
これはサーバテストを拡張したクライアント・サーバ統合テストの長所といえます。

まとめ

  • Lunatic PythonまじLunatic
  • Luaのenvironmentは便利
  • サーバテストにクライアントを組み込むほうが捗る
  • クライアント・サーバ統合テストを書こう

最後に

明日はmecha_g3氏が何か書くそうです。お楽しみに。

このエントリーは、KLab Advent Calendar 2015 の12/2の記事です。
KLabとしては久々のAdvent Calendar参戦です。2番手も緊張しますね。KLabGamesの基盤エンジニアのkenseiです。よろしくお願いします。

はじめに

ソーシャルゲームは体力の概念があるゲームが結構あります。
ユーザが体力を全快した時間を知る方法があれば、よりスムーズにゲームを行う事ができます。
体力全快をユーザが知る手段の一つ、「体力全快予定時間にローカルプッシュを用いてユーザに全快を通知する方法」をご紹介したいと思います。

ローカルプッシュとは

Appleのドキュメントによれば、「ローカル通知は、アプリケーション自身がスケジューリングし、送信する」とあります。
指定した時間になると、アプリがフォアグラウンドで動作していない場合は、アラートでの通知 / バッジアイコン、サウンドの形でユーザに伝えます。
アプリがフォアグラウンドで動作している場合は、アラートでの通知を行います。
AndroidはAlarmManagerとNotificationBuilderの組み合わせでローカルプッシュを実装できます。

序章

体力全快通知を送る際に一番重要なのは「キャンセル処理」です。
スマートホンはマルチタスクなので、途中でゲームをサスペンドして別の作業を行った後にゲームを再開するかもしれません。
この時にローカルプッシュをタイマー送信していたらどうなるでしょうか?
ユーザがゲームを再開した時に、キャンセル処理をしない場合、ゲーム中に通知が飛んでしまいます。
また、ユーザが体力を消費したりアイテムなどで全快した場合も、キャンセル処理がないと
体力が全快でなかったり、すでに全快なのに、場違いな回復通知が飛んでしまう事態になってしまいます。

キャンセル処理のタイミング

ではキャンセル処理はどのタイミングで送ればいいでしょうか?
KlabGamesのあるタイトルでは

  • 起動時
  • サーバと通信を行った結果に格納されている、体力全快にかかる時間が0秒だった場合
  • 体力全快にかかる時間が0秒より大きい場合、ローカルプッシュのタイマーを設定する直前に

の3箇所でキャンセル処理を行っています。

  • 起動時は受け取る必要がないので、キャンセルを行います.
  • 2つめのサーバで通信を行う場合、複数のApiに体力全快にかかる時間をサーバ側で計算して返してもらっています.
    体力回復アイテムを使用して全快した場合や、レベルアップで体力が全快した場合などにキャンセルできるようになります.
  • 3つめのローカルプッシュのタイマーを設定する直前にキャンセル処理を行うのは、タイマーを常に最新に保つためです.
    設定されているタイマーを一つにして、常に最新にしておけば誤通知を防ぐ事ができます.

体力全快通知を送る際に一番重要なのは「設定されているタイマーを一つにして、常に最新にしておく事」です。
あれ、一番重要な事が増えた。。

Unity側のプログラム

iOSとAndroidで動くサンプルを作ってみました。
https://github.com/kensei/klab_advent_calendar_2015

体力を消費&回復し続けるだけのゲーム?です。
急ごしらえなので、バグってたら申し訳ありません。

簡単な処理の概要

実際のローカルプッシュの処理

クライアントプラグインを作成して、プラットフォーム別の処理をカプセル化しています。
unityでいうネイティブコードの初期化・ローカルプッシュの設定・ローカルプッシュのキャンセルをブリッジしています。

実はiOSはUnity標準でローカルプッシュの処理が用意されているのですが、

LocalNotification l = new LocalNotification();
l.applicationIconBadgeNumber = 1;
l.fireDate = System.DateTime.Now.AddSeconds(10);
l.alertBody = "test";
NotificationServices.ScheduleLocalNotification(l);

繰り返しを伴うローカルプッシュのような複雑な事はできません。
以上の理由から、今回は最初からネイティブコードを使用してローカルプッシュの実装を行っています。
また、iOS8ではローカルプッシュ通知にもユーザ許可が必要になりました。
ユーザ許可のためにUnityAppControllerを拡張しています。
code

AndroidはAndroidManifestに、ローカルプッシュで使用する権限の設定や、タイマーを受け取るレシーバの設定が必要になります。

初期化

起動時に各プラットフォームのネイティブコードを初期化しています。

ローカルプッシュ設定

  • C#
    • ネイティブコードの呼び出し.
  • iOS
    • UILocalNotificationを作成して、UIApplicationに渡しています.
  • Android
    • レシーバに渡すintentを生成します. IntentはLocalNotificationReceiverが受け取るように設定します.
    • Calendarインスタンスに終了予定時間を設定します. サンプルでは秒で設定しています.
    • AlarmManagerに生成したintentとCalendarを設定します.
  • AndroidReceiver
    • intentから情報を受け取ります.
    • Notificationを生成し、通知を行います.

ローカルプッシュのキャンセル処理

  • C#
    • ネイティブコードの呼び出し.
  • iOS
    • UIApplicationからUILocalNotificationを全て受け取ります.
    • notificationIdが一致する物をキャンセルします.
  • Android
    • Actionが同じPendingIntentを取得します.
    • AlarmManagerにキャンセルを依頼します.

最後に

明日は、僕が入社式に遅刻した時にメッチャ睨んできたpandax381さんのラズパイの話です。 お楽しみに。

↑このページのトップヘ