Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

MagicOnionでリアルタイム通信を実装してみる

はじめに

モバイルファクトリー Advent Calendar 2019の16日目担当のshioiyanです。

モバイルファクトリーには部活動制度があり、自分はゲームジャム部という不定期にゲームジャムを行ったりする部活に所属しています。

最近ゲームジャム部で、.NET CoreおよびUnity用のリアルタイム/APIエンジンのMagicOnionを使ってUnityでリアルタイム通信を実装してみたのでその方法を紹介してみます。

この記事を読んでわかること

  • MagicOnionのStreamingHubを使ったリアルタイム通信の実装方法がわかる

MagicOnionを利用するメリット

  • クライアントとサーバでAPIの定義を共有できる
    • 共有したインターフェイスを介して通信できるため, 直接クライアントからサーバ, サーバからクライアントのコードを呼び出すことができる
  • C#のオブジェクトをMessgePackによって直接やりとりできる
    • クライアントとサーバで通信内容の齟齬が発生しない, 通信したデータをそのまま利用可能なため効率的
    • 通信のためにデータを加工することをあまり考えなくて良い

クライアントとサーバを共通の言語(C#)で開発でき, 入力補完の恩恵を受けられます。

インターフェイスの実装漏れやメソッド名, 引数のミスなどが発生するとコンパイルエラーになるためエラーに気がつくことが容易です。

APIの定義や通信に利用するデータを共有できるため, クライアントとサーバで通信しているという感覚をあまり持たずに実装することが可能です。

今回作るもの

  • プレイヤーの位置や向きが複数のクライアントで同期されたゲーム
    • 名前入力, キャラクター選択画面
    • プレイヤーが同期されるゲーム画面

開発バージョン

  • Unity v2019.1.14f1
  • MagicOnion 2.6.2
  • MessagePack 1.7.3.7

実装

MagicOnionをUnityプロジェクトに導入するにはMagicOnion, MessagePack for C#, gRPC packageの導入が必要です。

今回導入手順については割愛します。

処理の流れ

今回は大きく分けて, 「ルームに参加」「キャラクター移動の同期」を実装します。

まずはじめに処理の流れを紹介します。

ルームに参加し, 参加者をクライアントに表示

  1. クライアント(SceneA): 名前入力とキャラクター選択をする
  2. クライアント: シーン切り替え(SceneA -> SceneB) ※以下SceneB
  3. クライアント: サーバに接続し, 入力した名前とキャラクターをサーバに送信
  4. サーバ: Playerクラスを生成し, ルームに参加してルームを保持
  5. サーバ: ルームの参加者情報を返却
  6. クライアント: ルームの参加者情報を元にプレイヤー(GameObject)を生成

移動した自キャラクターを他のクライアントに同期

  1. クライアント: キャラクターを移動
  2. クライアント: n秒に一度サーバにプレイヤーの位置と向きを送信
  3. サーバ: 保持しているPlayerを更新し、更新したPlayerを返却
  4. クライアント: 返却されたPlayerの位置や向きを使ってプレイヤー(GameObject)を移動させる

自分がクライアントで操作するプレイヤーのGameObjectの情報をサーバに送信し, 他のプレイヤーの情報を受け取り, クライアントで他プレイヤーのGameObjectを描画します。

ポイントはクライアント<->サーバでPlayer情報をやりとりし, 各クライアントでの同期を行うことです。

クライアントとサーバ共通のコード

クライアント<->サーバで送信するPlayerクラスを作成

using MessagePack;
using UnityEngine;

namespace Sample.Shared.MessagePackObjects
{
    [MessagePackObject]
    public class Player
    {
        [Key(0)] public string Name { get; set; }
        [Key(1)] public Vector3 Position { get; set; }
        [Key(2)] public Quaternion Rotation { get; set; }
        [Key(3)] public string UUID { get; set; }
        [Key(4)] public PlayerConfig.PrefabName PrefabName { get; set; }
    }
}
public class PlayerConfig
{
    public enum PrefabName
    {
        Prefab1,
        Prefeb2
    }
}

上記のような独自な型やVector3などもMessagePackで送信でき, クライアントとサーバでそのまま使うことができます。

クライアントとサーバと共有するインターフェイスを定義

クライアント -> サーバの通信を行うHubと, サーバ -> クライアントの通信を行うReceiverのインターフェイスを定義します。 Hubはサーバ, Receiverはクライアントで実装し, それぞれクライアント, サーバから呼ばれることになります。

using MagicOnion;
using Sample.Shared.MessagePackObjects;
using System.Threading.Tasks;
using UnityEngine;

namespace Sample.Shared.Hubs
{
    /// <summary>
    /// クライアント -> サーバ
    /// </summary>
    public interface IGameHub : IStreamingHub<IGameHub, IGameHubReceiver>
    {
        /// <summary>
        /// ゲームに接続することをサーバに伝える
        /// </summary>
        Task<Player[]> JoinAsync(string name, PlayerConfig.PrefabName prefabName);

        /// <summary>
        /// ゲームから切断することをサーバに伝える
        /// </summary>
        Task LeaveAsync();

        /// <summary>
        /// 移動したことをサーバに伝える
        /// </summary>
        Task MoveAsync(Vector3 position, Quaternion rotation);
    }

    /// <summary>
    /// サーバ -> クライアント
    /// </summary>
    public interface IGameHubReceiver
    {
        /// <summary>
        /// 誰かがゲームに接続したことをクライアントに伝える
        /// </summary>
        void OnJoin(Player player);

        /// <summary>
        /// 誰かがゲームから切断したことをクライアントに伝える
        /// </summary>
        void OnLeave(string uuid);

        /// <summary>
        /// 誰かが移動した事をクライアントに伝える
        /// </summary>
        void OnMove(Player player);
    }
}

クライアントの実装

クライアントでは, サーバから呼ばれるReceiverの実装を行います。

クライアントからはサーバとの接続とサーバで実装されているHubの呼び出しを行います。

サーバから受け取った情報を元にクライアントを描画します。

using Grpc.Core;
using MagicOnion.Client;
...

namespace Sample
{
    public class GameController : MonoBehaviour, IGameHubReceiver
    {
        private Channel channel;
        private IGameHub gameHub;

        private List<ClientPlayer> clientPlayers = new List<ClientPlayer>(); // ルームのプレイヤー情報とクライアントで利用するGameObjectを保持
        private ClientPlayer _myClientPlayer = null; // 操作するプレイヤー

        public float intervalSeconds;
        public string Name = "";  // 入力されてプレイヤー名
        public PlayerConfig.PrefabName PrefabName; // プレイヤーが選んだキャラクター

        async void Start()
        {
            // サーバに接続
            this.channel = new Channel("localhost:12345", ChannelCredentials.Insecure);
            this.gameHub = StreamingHubClient.Connect<IGameHub, IGameHubReceiver>(this.channel, this);

            // ルームに参加してルームの参加者一覧を受け取る
            Player[] players = await this.gameHub.JoinAsync(this.Name, this.PrefabName);
            // ルーム参加者情報を元にプレイヤー(GameObject)を生成
            this.AddClientPlayers(players);

            // 一定時間ごとにサーバに位置と向きを送る
            this.UpdateAsObservable()
                .ThrottleFirst(TimeSpan.FromSeconds(this.intervalSeconds))
                .Subscribe(_ => this.Move())
                .AddTo(this);
        }

        private void AddClientPlayers(Player[] players)
        {
            foreach (Player player in players)
            {
                // PlayerのPrefabName, Position, Rotationに応じてInstantiateを行う
                ClientPlayer clientPlayer = new ClientPlayer
                {
                    GameObject = Instantiate(...),
                    Player = player
                };
                this.clientPlayers.Add(clientPlayer);
                ...
            }
        }

        async void Move()
        {
            // 自分の操作プレイヤーの位置と向きをサーバに送信
            await this.gameHub.MoveAsync(this._myClientPlayer.GameObject.transform.localPosition,
                this._myClientPlayer.GameObject.transform.localRotation);
        }
        ...

        #region リアルタイム通信でサーバーから呼ばれるメソッド(IGameHubReceiverの実装)

        public void OnJoin(Player player)
        {
            Debug.Log($"{player.Name}さんが入室しました");
            // 入室後, 他の人が参加したらクライアントで表示するプレイヤーを追加
            AddClientPlayers(new Player[]
            {
                player
            });
        }

        public void OnLeave(string uuid)
        {
            Debug.Log($"{name}さんが退室しました");
            this.clientPlayers.RemoveAll(_ => _.Player.UUID == uuid);
        }

        public void OnMove(Player player)
        {
            ClientPlayer clientPlayer = this.GetPlayerByUUID(player.UUID);
            clientPlayer.Player = player;
            // 位置を直接上書きするとワープして見えるので滑らかに移動するためにDOTweenを使用
            clientPlayer.GameObject.transform.DOLocalMove(player.Position, this.intervalSeconds);
            clientPlayer.GameObject.transform.DORotate(player.Rotation.eulerAngles, this.intervalSeconds);
        }
    }
}
using Sample.Shared.MessagePackObjects;
using UnityEngine;

namespace Sample
{
    // ルームのPlayerクラスとクライアントで描画しているプレイヤーのGameObjectをまとめて管理するクラス
    public class ClientPlayer
    {
        public GameObject GameObject;
        public Player Player;
    }
}

サーバの実装

サーバでは, クライアントから呼ばれるHubの実装を行います。

クライアントが接続するルームの情報を保持し, クライアントのReceiverを呼び出します。

Receiverの呼び出しはルームに参加している全員のReceiverを呼び出すBroadcast()や自分以外のルーム参加者を対象にしたBroadcastExceptSelf()などを通じて行います。(参考)

using MagicOnion.Server.Hubs;
using Sample.Shared.Hubs;
using Sample.Shared.MessagePackObjects;
using System;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;

public class GameHub : StreamingHubBase<IGameHub, IGameHubReceiver>, IGameHub
{
    IGroup room;
    Player me;
    // ルームに入室しているプレイヤー全員の情報を保持
    IInMemoryStorage<Player> storage;

    public async Task<Player[]> JoinAsync(string name, PlayerConfig.PrefabName prefabName)
    {
        const string roomName = "Room";
        //自分の情報を作成し, 保持
        me = new Player
        {
            Name = name,
            Position = new Vector3(0, 0, 0),
            Rotation = new Quaternion(0, 0, 0, 0),
            UUID = Guid.NewGuid().ToString("N"),
            PrefabName = prefabName
        };
        //ルームに参加し, ルームを保持
        (this.room, this.storage) = await this.Group.AddAsync(roomName, me);

        //参加したことを自分以外のルームに参加しているメンバーに通知
        this.BroadcastExceptSelf(room).OnJoin(me);

        // ルームに入室している他ユーザ全員の情報を配列で取得する
        return this.storage.AllValues.ToArray();
    }

    public async Task LeaveAsync()
    {
        //ルーム内のメンバーから自分を削除
        await room.RemoveAsync(this.Context);
        //退室したことを全メンバーに通知
        this.Broadcast(room).OnLeave(me.UUID);
    }

    public async Task MoveAsync(Vector3 position, Quaternion rotation)
    {
        // サーバー上の情報を更新
        me.Position = position;
        me.Rotation = rotation;

        // 更新したプレイヤーの情報を自分以外のメンバーに通知
        this.BroadcastExceptSelf(room).OnMove(me);
    }
}

まとめ

MagicOnionを利用して, ルームに接続したクライアント同士でプレイヤーの同期を行いました。

C#でサーバのコードを書くことができ, しかもクライアントとコードを共有できるというのは新しい体験でした。

まだ色々検証しているところですが, 引き続きMagicOnionを触っていきたいと思います。