Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

MemoryPackでゲームのリプレイデータを作った話

こんにちは、Unityエンジニアのいも(@adarapata)です。 今回は、ミラティブのライブゲーム「スラポンコロシアム」で活用しているリプレイデータについてMemoryPackを使って作成した話をします。

スラポンコロシアムとは

スラポンコロシアム(スラコロ)はMirrativアプリ上で動作するライブゲームです。 他のライブゲームにも登場するスラポンなどのモンスターたちが戦う闘技場で、誰が最後まで生き残るかを予想するゲームです。 視聴者も配信者も一緒になって遊べるのが特徴です。

スラコロのバトルは、ターン制の自動進行型です。 各モンスターが特性に応じて技を選択して、最後の一人になるまでターンの進行が繰り返されます。 今回お話しするのは、このバトルの結果を保存、再現できるリプレイデータについてです。

なぜリプレイデータが欲しいのか

ゲームにおいてリプレイデータは、開発効率の向上に大きく貢献します。 完全に再現可能な状態をいつでも作り出せるようにすることで、特にQA担当者によるバグ発見時の再現がとても簡単です。 さらに、リプレイデータはユーザーにも提供可能であり、クラッシュした際の復旧や他人のリプレイを見るなど新たな体験を提供することもできます。 このようなメリットがあるので、開発初期からリプレイデータは作成できるような構造にしておくのがよいでしょう。

リプレイデータを作成するにあたって、バトルの内容をシリアライズして外部に書き出す必要があります。Unityでシリアライザを採用する場合はデフォルトでjson形式をサポートするJsonUtilityがあります。しかし、今回はシリアライザとしてOSSのMemoryPackを採用しました。

MemoryPack

MemoryPackはCySharp社が提供するシリアライザーです。C#に特化したバイナリフォーマットを採用することで不要なエンコーディングを回避しパフォーマンスが最適化されています。 今回は以下の理由からMemoryPackを採用しました。

  • 導入が非常に簡単
  • シリアライズ、デシリアライズが非常に高速
  • ペイロードがjsonより軽量
  • Unity側でしか使用しないため汎用的なバイナリにする必要がない

特にスラコロはプラットフォームがモバイルのWebGL且つ、Mirrativアプリ上で配信をしながらWebViewで起動するという少し特殊な環境ということもあり、できることならパフォーマンスに優れたものを使いたいという事情もありました。 その点を踏まえて、MemoryPackの高速な処理速度は非常に魅力的でした。

導入は非常に簡単です。 例えば以下のようなIDと名前を持つUserクラスを例に考えてみます。

public class User
{
    public string Id; // ユーザーID
    public string Name; // ユーザ名
}

これをMemoryPackに対応させる場合、[MemoryPackable]属性をつけてpartialクラスにするだけです。

[MemoryPackable]
public partial class User
{
    public string Id; // ユーザーID
    public string Name; // ユーザ名
}

シリアライズは MemoryPackSerializer.Serialize を呼び出します。

byte[] binary = MemoryPackSerializer.Serialize(user);      // シリアライズ

デシリアライズは MemoryPackSerializer.Deserialize を呼び出します。

User u = MemoryPackSerializer.Deserialize<User>(binary); // デシリアライズ

リプレイを作るための構造

リプレイを取り巻く設計として、スラコロではざっくりと以下の3つの構造に分かれています。

  • BattleSystem
  • BattleLog
  • BattleScene

BattleSystem

バトルの根幹を成す部分です。ターンごとのモンスターの行動、現在の状況、バトルの終了など全てのロジックを持っています。 BattleSystemを実行させバトルが行われ、その結果が後述のBattleLogとして出力されます。

public class BattleSystem
{
    public BattleLog Run(BattleParameter parameter)
    {
        // パラメーターを元にバトルを実行
        var result = DoBattle(parameter);
        
        var battleLog = new BattleLog(/* resultをごにょごにょ */);
        return battleLog;
    }
}

BattleLog

BattleLogはバトルの結果を全て保持した構造体です。例えば以下のデータを持っています。

  • 登場モンスター
  • BETしているユーザー
  • ターンごとの行動 etc...

これがリプレイデータに相当し、理論上は同じバトル内容をどこでも完全に再現できるという単位でまとめています。

BattleLogを簡略化したものを以下に示します。

[MemoryPackable]
public readonly partial struct BattleLog
{
    public readonly string Version;
    public readonly BattleMonsterLog[] BattleMonsters;
    public readonly TurnLog[] TurnLogs;

    public BattleLog(BattleMonsterLog[] battleMonsters,
            TurnLog[] turnLogs
            string version)
    {
        BattleMonsters = battleMonsters;
        TurnLogs = turnLogs;
        Version = version;
    }
}

今回はこのBattleLogをMemoryPackでシリアライズしてファイルとして保存することでリプレイデータを作成しています。1つのリプレイデータは概ね10kb程度です。

BattleScene

BattleSceneは実際にユーザーに見せるバトルを再生する役割を持つUnityのシーンです。 上記のBattleLogを入力として受け取り、実際の演出を再生してくれます。

通常のゲームフローは以下の通りです。

  • 操作者(User)がパラメータを作成してBattleSystemを実行
  • BattleSystemがBattleLogを生成
  • BattleSceneがBattleLogを受け取って実際の演出を再生する

ここでいう操作者は、ゲームを遊んでいるユーザーだけではなく開発者やQA担当者も含まれます。なので通常のゲーム画面のフローに縛られないようにデバッグ画面だったりUnityEditor上だったりとどこからでも呼び出せるように設計しています。

Logの利用方法

作成したBattleLogは様々な場面で利用できます。以下にその一部を紹介します。

バトルシミュレータでの利用

スラコロでは開発用の機能として、UnityEditor上で動作するバトルシミュレーターを用意しています。 出現するモンスターや調子、BETユーザーなどを設定して自由にバトルできます。バトルを数万回繰り返して統計を取ることもできます。

このシミュレーターではBattleLogをファイルとして保存することができます。保存したファイルはシミュレーターで開いて再生できるのでチーム間で受け渡しができます。

主に以下の用途で使われています。

  • ユーザーからの不具合報告を手元で再現できたら保存する
  • 演出周りなどでデザイナーにファイルを送って確認してもらう
  • 面白い試合のスクリーンショットを撮る

ゲームクラッシュ時の復帰に利用

バトルはゲーム中も負荷が大きい場面なので長時間配信している端末などは時々クラッシュが発生します。最後までバトルを見ることができなかったユーザーに対して、再起動時にバトルを確認する機能を実装しています。この機能の裏側もBattleLogを使って再現しています。

バトル開始時にBattleLogを保存しておき、バトルの結果を再生し終えたタイミングで削除します。 クラッシュなどで終了した場合は削除処理が行われていないので、再起動時に保存されたBattleLogを再生することでバトルの結果を確認できます。

バイナリを直接保存するのではなくMemoryPackをBase64エンコードしてローカルストレージに保存しています。

var binary = MemoryPackSerializer.Serialize(battleLog);
var base64 = System.Convert.ToBase64String(binary);
saveData.Save(base64);

再起動時には、ローカルストレージからBase64を取り出してデシリアライズしています。

var base64 = saveData.Load();
if (string.IsNullOrEmpty(base64))
{
    return null;
}

var binary = System.Convert.FromBase64String(base64);
return MemoryPackSerializer.Deserialize<BattleLog>(binary);

当初は想定していなかった機能でしたが、最初からBattleLogを保存できるようにしていたおかげでスムーズに追加できました。

QA時の不具合調査時の利用

QA担当者からの不具合報告を受けた際、再現環境を作るために該当のバトルをログを取得できるようにしています。 実機のデバッグコマンドから、直近のバトルのログを取得できるような機能を追加しています。

実行すると、クラッシュ時の復帰と同じくBattleLogをBase64エンコードした上で、その文字列をクリップボードにコピーします。

private void CopyBattleLog(BattleLog battleLog)
{
    var serializedLog = MemoryPackSerializer.Serialize(battleLog);
    var base64String = Convert.ToBase64String(serializedLog);
    CopyWebGL(base64String);         // クリップボードにコピーする自作メソッド
}

社内チャットに貼り付けて共有してもらうことで、開発者が確認できるようにしています。 もっと充実させるのであれば、バトルのログをサーバーに送信して保存するなどの機能も追加できるでしょう。

まとめ

リプレイデータはあまり派手な機能ではありませんが、不具合をすぐに確認できるスナップショットとして考えると非常に便利です。 今回はMemoryPackを使ってバイナリ化することで、簡単に軽量で高速なデータを扱うことができました。 後から実装するのは少し大変ですが、できればリプレイデータを設計時点から考慮しておくと開発効率が向上するのでお勧めします。

We are hiring!

ミラティブでは面白いゲームを共に作っていくエンジニアを募集しています!!