MagicOnionを使ってリアルタイム通信ゲームを作る -3. クライアント処理-

MagicOnionやUnitTask、シーンやStateの管理

目次

概要


作ったゲームの技術解説記事3回目です。 今回はMagicOnionなどライブラリを組み込む際に参考にした資料やハマった点、状態遷移に使用しているStateパターンなど、 クライアント処理の詳細を実際のコードを交えて解説していきます。

他の記事へのリンクやソースコードはこちらから。

  1. MagicOnionを使ってリアルタイム通信ゲームを作る -1. 概要-
  2. MagicOnionを使ってリアルタイム通信ゲームを作る -2. アーキテクチャ-
  3. MagicOnionを使ってリアルタイム通信ゲームを作る -3. クライアント処理-
  4. MagicOnionを使ってリアルタイム通信ゲームを作る -4. サーバー処理-
  5. Githubソースコード / LineDeleteGame

MagicOnion(クライアント)


前回の記事で言及済みですが、サーバーとのリアルタイム通信には MagicOnion を用いています。 ここで少し詳しく見ていきましょう。

MagicOnionの概要

MagicOnionで調べてみると、RPC(Remote Procedure Call)通信の特徴であるクライアントからサーバー処理を呼び出す下図のような例をよく見かけます。

この図を成り立たせるためにまず必要なことはクライアント、サーバーで互いに使用する処理をインターフェースとして定義することです。 マッチングの処理を例に取ると下記コードのようにサーバー側でマッチング処理を行うことを想定した “IMatchingHub” と クライアントでレスポンスを受け取るための “IMatchingHubReceiver” をSharedドメインでインターフェースで定義しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public interface IMatchingHub : IStreamingHub<IMatchingHub, IMatchingHubReceiver>
{
    Task JoinAsync(MatchingJoinRequest _request);
    Task LeaveAsync();
}

public interface IMatchingHubReceiver
{
    void OnMatchingSuccess(MatchingRequestSuccessResponse _response);
}

その後クライアントで “IMatchingHubReceiver” を継承した実装用クラスを定義し、

  • Send処理に当たる “JoinAsync”, “LeaveAsync” 関数を実行し、サーバーへパラメータを送信
  • サーバーから送信されるデータを受け取るReceiveにあたる “OnMatchingSuccess” 関数を定義してレスポンスを受け取り

することでクライアント側の通信処理を行います。 サーバーはクライアントからSendされてきたパラメータをもとに処理を行い、実行結果をクライアントがReceiveできるように実行結果を送信します。こちらはまた次回記事で説明します。

特に送受信が1対1で対応している通信をUnary(単方向)通信、クライアントとサーバー互いの都合でリアルタイムに双方向通信し合うStreaming(双方向)通信と2つの通信方法に大別することが可能です。 今回のゲームではリアルタイム通信が主な用途だったためMagicOnionのStreamingHubという機構を利用しています。

環境構築

作りはじめの環境構築は、こちらの記事やスライドを参考にさせていただきました。

今回はストリーミング通信をメインに使用し、クライアント処理に関してはほとんど上記資料の延長で組むことができました。 一方、サーバー処理は初めて見る概念のオンパレードでなかなか苦戦しましたがそれは次回記事にて解説します。

また、今回組んだMagicOnionインターフェースやUtilityは下記になります。Githubは関数名をクリックすると参照コード先へ移動することができます。興味がある方は芋づるで処理をたどってみてください。

MagicOnion、及びgRPCライブラリのハンドリング

上記資料のおかげで概ね順調に処理は組めましたが、管理を誤ると平気でUnityがフリーズしたり、アプリが変な挙動をしたりする点には大分困らされました。 MagicOnionを動かす上で必要になるgRPCライブラリはunsafeな処理、つまりポインタなどの低レベルレイヤーでも動作しているため、適切に管理しないと挙動が不安定になります。 特にサーバーを起動していない状態で「通信 → 通信終了」を繰り返すと、解放処理をしているはずなのにアプリがフリーズしてしまう現象が発生し、とても悩みました。 アプリ運用上はサーバーが落ち疎通できない場合もあるため、問題の回避方法を探す必要がありました。

しばらく現象を観察していると「接続できない状態で解放処理を繰り返すと不安定になる」という挙動をしており、「そもそも疎通できる状態か」を確認すべきではと考えました。 そこでgRPC側の処理で確認処理を挟んだところ、この問題は解消することができました。 以下の「HubConnector.cs」の接続開始処理のハイライト部分がそれにあたります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/// <summary>gRPC channel</summary>
private Grpc.Core.Channel channel = null;

/// <summary>cancel token</summary>
private CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();

public async UniTask ConnectStartAsync()
{
    while (!cancellationTokenSource.IsCancellationRequested)
    {
        try
        {   // Confirm that server connect is established
            await channel.ConnectAsync(DateTime.UtcNow.AddSeconds(5));
            Debug.Log("connecting to the server ... ");
            ServerImpl = await StreamingHubClient.ConnectAsync<TServer, TClient>(channel, ClientImpl, cancellationToken: cancellationTokenSource.Token);
            executeDisconnectEventWaiterAsync(ServerImpl).Forget();
            Debug.Log("established connect");
            break;
        }
        catch (TaskCanceledException)
        {
        }
        catch (OperationCanceledException e)
        {
            Debug.LogWarning(e);
            throw new OperationCanceledException();
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }

        Debug.Log("Retry after 2 seconds");
        await UniTask.Delay(2 * 1000, cancellationToken: cancellationTokenSource.Token);
    }
}

UniTask


趣味、実務ともに使ったことがなかったので、勉強も兼ねUnityで非同期処理を扱う際に活躍する UniTask を使ってみました。 前回の記事でも触れているのでライブラリ概要は割愛します。 概ね思った通りに動作したなと最初は思っていましたが、ところがどっこい調べてみると大量にリークを発生させていました。 UnityのCoroutineはGameObjectの寿命と連動しているのでリークは少ないですが、UniTaskはUnity管理とは無関係に働くので間違えると簡単にリークします。

リークの見つけ方

Unity上部のWindow → UniTaskTrackerを選択すると、専用のウィンドウが表示されます。 更にUniTaskTrackerウィンドウ上部の

  • Enable AutoReload
  • Enable Tracking
  • Enable StackTrace

を全てアクティブにするとUnity実行中に存在しているUniTaskオブジェクトを追跡できます。

最初は「まあ解放できているだろう」と思っていたのですが、一通り確認してみるとゾンビが大量発生していて悲鳴が出ました。

主なリーク原因

discardによるTask完了をしていた

同期関数内で特にawaitする予定のない非同期関数を呼び出す場合、Task機構を使う場合は discard 式の記述を使って下記のように放置することがあります。

1
2
3
4
5
6
async Task AsyncAnything()
{
    await foo();
}

_ = AsyncAnything();

UniTaskでも一部でこの書き方をしていたのですが、Trackerで調べてみたところ軒並みこの部分でリークが発生していました。 UniTaskを使う場合はForget関数が用意されているのでこちらを使用しましょう。

1
2
3
4
5
6
async UniTask AsyncAnything()
{
    await foo();
}

AsyncAnything().Forget();

CancellationTokenが適切に設定できていなかった

UniTask処理を最後まで実行できればいいのですが、シーン遷移やサーバー通信では、非同期処理が中断されることも考慮しなければいけません。 そこでUniTaskでは「CancellationToken」を渡して中断処理を検知できるようになっています。 実は中盤までこのことを全く知らず、UniTask周りが不審な挙動をしてると感じ始めたあたりから調べてみた結果このお約束にたどり着きました。

UnityのGameObjectと非同期処理の寿命が連動する場合はGetCancellationTokenOnDestroyをCancellationTokenとして渡せば大丈夫だと思います。

1
await UniTask.Delay(500, cancellationToken: this.GetCancellationTokenOnDestroy());

しかし通信のDisposeなど、GameObject由来ではなく自前でCancellationTokenを管理する必要がある場合はCancellationTokenSourceを用いて自分で中断処理を管理することも可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();

private async UniTask onDisposing()
{
    cancellationTokenSource.Cancel();

    if (ServerImpl != null)
    {
        await ServerImpl.DisposeAsync();
        ServerImpl = default;
    }
    if (channel != null)
    {
        await channel.ShutdownAsync();
        channel = null;
    }

    ClientImpl = default;
}

Stateを用いたシーンや状態遷移


外部ライブラリ以外の機構としては、Stateパターンを使用しています。 ゲームの状態遷移やシーン遷移をIStateインターフェース[1]で捌き、詳細はインターフェースを継承して記述します。

IStateインターフェース

例えば今回のゲームの場合、

  • ラインがそろった場合、ラインを消す演出を入れたい
  • Pause処理を入れたい

と、同シーン内でも様々な状態遷移が発生します。 ぱっと思いつくのはenumを書いてswitchで分岐させる方法でしょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
enum eState
{
    Start,
    Update,
    Pause,
    LineDeleteEffect,
}

void Update()
{
   switch(currentState)
   {
      case eState.Start:
        ...
      case eState.Update:
        ...
   }
}

もちろん今回のゲーム程度であればこの方法でも管理しきれると思います。 しかし、「メニュー画面を開いてアイテムを使わせたい」「ストーリー仕立てにして会話イベントを挟みたい」となった場合どうなるでしょう。 Update関数に都度eStateを追加し、影響範囲を調べて~、とStateを追加するコストがどんどん増えていきます。

そこでゲーム開発でよくやるのは、各状態毎にIStateを継承したクラスを作成し、必要な処理のみをそのクラスに記述するというものです。 説明用に一部本来のソースコードとは記述を変えていますが、以下コード詳細です。

public abstract class IState
{
    public StateController Ctrl { get; private set; }

    public virtual void Enter() { }

    public virtual bool Update(float _dt) { return false; }

    public virtual IState Exit() { return null; }

    public virtual void CalledOnResume(IState _prevState) { }

    public virtual void CalledOnPause(IState _nextState) { }

    internal void SetController(StateController _ctrl)
    {
        Ctrl = _ctrl;
    }
}

public class StateController
{
    private Stack<IState> stateStack = new Stack<IState>();

    private Queue<IState> reservedStateQueue = new Queue<IState>(RESERVED_MAX);

    public IState Run(float _dt)
    {
        while (reservedStateQueue.Count > 0)
        {
            addState(reservedStateQueue.Dequeue());
        }

        if (stateStack.Count <= 0)
        {
            return null;
        }

        IState st = stateStack.Peek();
        if (st.Update(_dt))
        {
            st.Draw(_dt);
            return st;
        }

        IState prevState = st;
        IState nextState = st.Exit();
        stateStack.Pop();

        if (nextState != null)
        {
            ReserveAddState(nextState);
        }
        else if (stateStack.Count > 0)
        {
            st = stateStack.Peek();
            st.CalledOnResume(prevState);
        }

        return nextState;
    }

    public void ReserveAddState(IState _state)
    {
        reservedStateQueue.Enqueue(_state);
    }

    private void addState(IState _state)
    {
        _state.SetController(this);

        if (stateStack.Count > 0)
        {
            var prev = stateStack.Peek();
            prev.CalledOnPause(_state);
        }

        stateStack.Push(_state);
        _state.Enter();
    }
}

シーンは、StateController上で数珠つなぎ的に遷移させます。 一見こちらのほうが大変に見えますが、State追加時は対象State及び遷移前後のStateのみに意識を集中できること、他のStateへの影響範囲を小さくすることでenumと比較して圧倒的に管理しやすくなります。 このIState処理はピュアなC#で書かれているため、クライアント、サーバー両方で使いまわせるようにSharedな領域に配置しています。

今回IStateを組むにあたりこちらの記事を参考にしました。

Unity x RPG x マルチプラットフォーム | Made with Unity

次の記事


第3回目の記事ではMagicOnionやUniTask、Stateパターンなどクライアント側の技術詳細をメインに紹介しました。 TilemapなどよりUnityに寄った技術も使いましたが、それはまた別記事で紹介できればと思います。

次回の記事ではサーバープログラムに焦点を当てて解説していきます。


  1. 実際には不要な関数は無理してoverrideしなくてよいように、abstractによる純粋仮想関数で定義しています。 ^
y-tomita
y-tomita
ゲームプログラマ
ビルドエンジニア
次へ
前へ

関連項目