Unity+MagicOnionでメタバース空間を作ってみる（第一回）

はじめに
------------------------------------------------------
本記事は、複数回に分けてUnityとMagicOnionを用いてメタバース空間を構築する内容（備忘録）となっています。
第一回目は、UnityとMagicOnionで簡単なチャット機能とアバターの位置同期を実装します。
MagicOnionの環境構築については、様々な記事で解説されているため、割愛させていただきます。
なお、筆者はUnityやサーバーサイドの経験は浅く現在も学習中であるため、間違っている部分がある場合は教えて下さると幸いです。

------------------------------------------------------

動作環境や使用したアセットなど
--------------
* Windows 10
* Unity 2021.3.5f1
* Visual Studio 2019 16.11
* MagicOnion 4.5.1
* MessagePack 2.3.85
* gRPC 2.47.0
* Unity-Chan! Model 1.2.2


# 1. クライアント側を作成
## 1.1. MessagePackObjectの実装
MagicOnionは、[MessagePack](https://msgpack.org/ja.html)を用いてクライアントとサーバー間を一つのオブジェクトにまとめて送受信できるデータの構造を定義することができます。
そのため、まずはそのデータ構造を書いていきます。
```cs:Player.cs
using MessagePack;
using UnityEngine;

namespace AppServer.MessagePackObjects
{
    [MessagePackObject]
    public class Player
    {
        [Key(0)]
        public string userName { get; set; }

        [Key(1)]
        public Vector3 Position { get; set; }

        [Key(2)]
        public Quaternion Rotation { get; set; }

        [Key(3)]
        public string UUID { get; set; }

    }
}
```
クライアントとサーバー間を送受信する際に用いるクラスは、上記のように定義を行います。
勿論、クラスを定義しなくてもデータを送受信することはできますが、例えば今回のようなプレイヤー情報はクラスにまとめてあげると便利です。

## 1.2. サーバーへデータを送信するAPIの実装
クライアントとサーバー間を送受信するデータの構造はできましたが、これではまだ送受信ができません。
そのため、次はサーバーへデータを送信するためのインターフェイスを書いていきます。
```cs:IChatAppHub.cs
using MagicOnion;
using System.Threading.Tasks;
using UnityEngine;
using AppServer.MessagePackObjects;

namespace AppServer.Hubs
{
    public interface IChatAppHub : IStreamingHub<IChatAppHub, IChatAppHubReceiver>
    {
        ///<summary>
        ///入室通知
        /// </summary>
        Task<JoinerInfo> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation);

        ///<summary>
        ///退室通知
        /// </summary>
        Task LeaveAsync();

        ///<summary>
        ///メッセージ通知
        /// </summary>
        Task SendMessageAsync(string userName, string message);

        ///<summary>
        ///移動通知
        /// </summary>
        Task MoveAsync(Vector3 position, Quaternion rotation);
    }
}
```
JoinAsyncで渡すデータにVector3とQuaternionが含まれているのは、先に入室しているプレイヤーの位置を同期させるためです。

## 1.3. クライアントでデータを受信するAPIの実装
続いて、サーバーから送信されたデータを受信するためのインターフェイスを書いていきます。
```cs:IChatAppHubReceiver.cs
using AppServer.MessagePackObjects;
using UnityEngine;

namespace AppServer.Hubs
{
    public interface IChatAppHubReceiver
    {
        /// <summary>
        /// 入室通知
        /// </summary>
        void OnJoin(Player player);

        ///<summary>
        ///退出通知
        /// </summary>
        void OnLeave(Player player);

        ///<summary>
        ///メッセージ通知
        /// </summary>
        void OnSendMessage(Player player, string message);

        ///<summary>
        ///移動通知
        /// </summary>
        void OnMove(Player player);
    }
}

```
今回はPlayerの中にチャットのメッセージデータを含めていないためOnSendMessageでは二つに分けて受け取っていますが、Playerの中に含めても良いかもしれません。

## 1.4. クライアント側のプログラムを実装
少し長いので、所々解説していきます。
```cs:ChatApp.cs
using System.Collections.Generic;
using Grpc.Core;
using MagicOnion;
using MagicOnion.Client;
using System.Threading;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using AppServer.MessagePackObjects;
using UniRx;
using System;
using System.Collections;
using UniRx.Triggers;

namespace AppServer.Hubs
{
    public class ChatApp : MonoBehaviour, IChatAppHubReceiver
    {
        #region 入室ページ
        ///<summary>
        ///入室ページ
        /// </summary>
        [SerializeField]
        private GameObject joinPage;

        ///<summary>
        ///ユーザ名を入力
        /// </summary>
        private TMP_InputField nameInput;

        ///<summary>
        ///入室ボタン
        /// </summary>
        [SerializeField]
        private Button joinButton;
        #endregion

        #region チャットページ
        ///<summary>
        ///チャットページ
        /// </summary>
        [SerializeField]
        private GameObject chatPage;

        ///<summary>
        ///チャット表示
        /// </summary>
        private TextMeshProUGUI chatComment;

        ///<summary>
        ///メッセージ入力
        /// </summary>
        private TMP_InputField messageInput;
        #endregion

        #region MagicOnion用
        ///<summary>
        ///通信キャンセル用トークン
        /// </summary>
        private CancellationTokenSource shutdownCancellation = new CancellationTokenSource();

        ///<summary>
        ///接続チャンネル
        /// </summary>
        private ChannelBase channel;

        ///<summary>
        ///サーバ呼び出し用
        /// </summary>
        private IChatAppHub streamingClient;
        #endregion



        ///<summary>
        ///ルーム名
        /// </summary>
        private string roomName = "ChatAPP";

        ///<summary>
        ///ユーザ名
        /// </summary>
        private string userName = "名無し";

        /// <summary>
        /// プレイヤーリスト
        /// </summary>
        List<ClientPlayer> clientPlayers = new List<ClientPlayer>();

        /// <summary>
        /// 自分のアバター用
        /// </summary>
        private ClientPlayer clientPlayer;

        /// <summary>
        /// 自分のアバターがスポーンされたか
        /// </summary>
        private bool isSpawn;

        /// <summary>
        /// 同期のタイミング
        /// </summary>
        private float syncTimer = 0.2f;
        private float interpolationPeriod = 0.2f;

        /// <summary>
        /// 自分のアバター本体
        /// </summary>
        [SerializeField]
        private GameObject avatar;

        /// <summary>
        /// 初期スポーンポイント
        /// </summary>
        [SerializeField]
        private GameObject spawnPoint;

        /// <summary>
        /// アプリの初期設定
        /// </summary>
        private void Start()
        {
            #region ページの初期化
            joinPage.SetActive(false);
            chatPage.SetActive(false);
            nameInput = joinPage.GetComponentInChildren<TMP_InputField>(true);
            joinButton.onClick.AddListener(OnClick_JoinButton);

            chatComment = chatPage.GetComponentInChildren<TextMeshProUGUI>(true);
            chatComment.text = "";
            messageInput = chatPage.GetComponentInChildren<TMP_InputField>(true);

            var sendButton = chatPage.GetComponentInChildren<Button>(true);
            sendButton.onClick.AddListener(OnClick_SendButton);

            joinPage.SetActive(true);
            isSpawn = false;
            #endregion

            #region 定期実行
            Observable.Interval(TimeSpan.FromSeconds(syncTimer)).TakeUntilDestroy(this).Subscribe(async _ =>
            {
                if (isSpawn)
                {
                    await streamingClient.MoveAsync(clientPlayer.avatarObject.transform.position, clientPlayer.avatarObject.transform.rotation);
                }
            });
            
            #endregion
        }

        private async void OnDestroy()
        {
            shutdownCancellation.Cancel();

            if (streamingClient != null) await streamingClient.DisposeAsync();
            if (channel != null) await channel.ShutdownAsync();
        }



        #region ボタン処理
        ///<summary>
        ///入室
        /// </summary>
        private async void OnClick_JoinButton()
        {
            //if (instance == null) return;

            if (!string.IsNullOrEmpty(nameInput.text))
            {
                userName = nameInput.text;
            }

            channel = GrpcChannelx.ForAddress("IPアドレス");
            streamingClient = await StreamingHubClient.ConnectAsync<IChatAppHub, IChatAppHubReceiver>(channel, this, cancellationToken: shutdownCancellation.Token);

            //既に参加しているユーザ情報を取得
            JoinerInfo joinerInfo = await streamingClient.JoinAsync(roomName, userName, Vector3.zero, Quaternion.identity);
            
            //自分のアバターの設定
            MyAvatarSetting(joinerInfo);

            //既に参加しているユーザをスポーン
            AddClientPlayers(joinerInfo.players);

            //スポーン
            chatPage.SetActive(true);
            isSpawn = true;
        }

        ///<summary>
        ///メッセージ送信
        /// </summary>
        private async void OnClick_SendButton()
        {
            await streamingClient.SendMessageAsync(userName, messageInput.text);
            messageInput.text = "";
        }
        #endregion

        #region MagicOnion サーバ→クライアントの受信

        ///<summary>
        ///入室通知
        /// </summary>
        public void OnJoin(Player player)
        {
            chatComment.text = $"{chatComment.text}{player.userName}さんが入室しました。\n";

            //入室後に入ってくるユーザ情報を追加
            AddClientPlayers(new Player[]
            {
                player
            });
        }

        ///<summary>
        ///退室通知
        /// </summary>
        public void OnLeave(Player player)
        {
            chatComment.text = $"{chatComment.text}{player.userName}さんが退室しました。\n";

            var leavePlayer = clientPlayers.Find(n => n.player.UUID == player.UUID);
            Destroy(leavePlayer.avatarObject);

            clientPlayers.Remove(leavePlayer);

        }

        ///<summary>
        ///メッセージ通知
        /// </summary>
        public void OnSendMessage(Player player, string message)
        {
            chatComment.text = $"{chatComment.text}{player.userName}:{message}\n";
        }
        #endregion

        ///<summary>
        ///移動同期
        /// </summary>
        public void OnMove(Player player)
        {
            ClientPlayer _clientPlayer = GetPlayerByUUID(player.UUID);

            //MoveInterpolation(_clientPlayer, player);
            Observable.FromCoroutine(() => MoveInterpolation(_clientPlayer, player)).Subscribe();

        }

        private async void AddClientPlayers(Player[] players)
        {
            foreach(Player _player in players)
            {
                if(_player.UUID != clientPlayer.player.UUID)
                {
                    var _clientPlayer = new ClientPlayer();
                    _clientPlayer.player = _player;
                    _clientPlayer.avatarObject = Instantiate(avatar, spawnPoint.transform.position, spawnPoint.transform.rotation);
                    _clientPlayer.avatarObject.name = _clientPlayer.player.userName;
                    this.clientPlayers.Add(_clientPlayer);
                    _clientPlayer.avatarObject.GetComponent<UnityChan.UnityChanControlScriptWithRgidBody>().enabled = false;

                    _clientPlayer.anim = _clientPlayer.avatarObject.GetComponent<Animator>();
                }

            }
        }

        private ClientPlayer GetPlayerByUUID(string uuid)
        {
            ClientPlayer _clientPlayer = new ClientPlayer();

            foreach(ClientPlayer clientPlayer in clientPlayers)
            {
                if(clientPlayer.player.UUID == uuid)
                {
                    _clientPlayer = clientPlayer;
                }
            }
            return _clientPlayer;
        }

        private void MyAvatarSetting(JoinerInfo joinerInfo)
        {
            //自分のアバター設定
            clientPlayer = new ClientPlayer();
            Player _player = new Player();
            _player.userName = userName;
            _player.UUID = joinerInfo.UUID;
            clientPlayer.player = _player;

            joinPage.SetActive(false);
            GameObject _avatarObject = Instantiate(avatar, spawnPoint.transform.position, spawnPoint.transform.rotation);

            clientPlayer.avatarObject = _avatarObject;
            clientPlayer.avatarObject.name = clientPlayer.player.userName;
            clientPlayer.avatarObject.GetComponent<UnityChan.UnityChanControlScriptWithRgidBody>().enabled = true;

            clientPlayer.anim = clientPlayer.avatarObject.GetComponent<Animator>();
        }


        private IEnumerator MoveInterpolation(ClientPlayer _clientPlayer, Player _player)
        {

            //補間の開始座標
            Vector3 p1 = _clientPlayer.avatarObject.transform.position;
            
            //補間の終了座標
            Vector3 p2 = _player.Position;

            //補間の開始角度
            Quaternion r1 = _clientPlayer.avatarObject.transform.rotation;

            //補間の終了角度
            Quaternion r2 = _player.Rotation;

            float elapsedTime = 0f;
            while(elapsedTime < syncTimer)
            {
                elapsedTime += Time.deltaTime;
                _clientPlayer.avatarObject.transform.position = Vector3.Lerp(p1, p2, elapsedTime / interpolationPeriod);
                _clientPlayer.anim.SetFloat("Speed", Vector3.Distance(p1, p2));
                _clientPlayer.avatarObject.transform.rotation = Quaternion.Lerp(r1, r2, elapsedTime / interpolationPeriod);
                _clientPlayer.anim.SetFloat("Direction", Quaternion.Angle(r1, r2));
                yield return null;
            }
            _clientPlayer.avatarObject.transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / interpolationPeriod);

        }



    }

    /// <summary>
    /// アバターのオブジェクトとPlayerを保持するためのクラス
    /// </summary>
    public class ClientPlayer
    {
        /// <summary>
        /// アバターのオブジェクト
        /// </summary>
        public GameObject avatarObject;

        /// <summary>
        /// Player
        /// </summary>
        public Player player;

        /// <summary>
        /// キャッシュ用のAnimator
        /// </summary>
        public Animator anim;
    }

}
```
まずサーバーから送信されたPlayerとアバターのオブジェクトを紐づけるために、ClientPlayerクラスを定義します。
また、各Playerが使用する移動用のAnimatorをキャッシュとして保存できるようにします。
クライアントとサーバー間はPlayerクラスで送受信できますが、GameObject等は別で管理する必要があります（MessagePackで送受信できないため）。
```cs
    public class ClientPlayer
    {
        /// <summary>
        /// アバターのオブジェクト
        /// </summary>
        public GameObject avatarObject;

        /// <summary>
        /// Player
        /// </summary>
        public Player player;

        /// <summary>
        /// キャッシュ用のAnimator
        /// </summary>
        public Animator anim;
    }
```

OnClick_JoinButton関数で、uGUIで作成したボタンを押すとサーバーとの接続を開始するようになっています。
サーバーとの接続は、StreamingHubClient.ConnectAsync()へチャンネル等の引数を渡してあげると接続できるようになります。
接続に成功した後にJoinAsync()で既に参加しているPlayerの情報（自分自身も含め）を取得します。
```cs
        private async void OnClick_JoinButton()
        {
            //if (instance == null) return;

            if (!string.IsNullOrEmpty(nameInput.text))
            {
                userName = nameInput.text;
            }

            channel = GrpcChannelx.ForAddress("IPアドレス");
            streamingClient = await StreamingHubClient.ConnectAsync<IChatAppHub, IChatAppHubReceiver>(channel, this, cancellationToken: shutdownCancellation.Token);

            //既に参加しているユーザ情報を取得
            JoinerInfo joinerInfo = await streamingClient.JoinAsync(roomName, userName, Vector3.zero, Quaternion.identity);
            
            //自分のアバターの設定
            MyAvatarSetting(joinerInfo);

            //既に参加しているユーザをスポーン
            AddClientPlayers(joinerInfo.players);

            //スポーン
            chatPage.SetActive(true);
            isSpawn = true;
        }
```
なお、ここでサーバーからユーザ情報を持ってくる際には、新たにMessagePackObjectアトリビュートを追加したJoinerInfoクラスを作成しました（ここら辺のコード管理は整理したい）。
```cs:JoinerInfo.cs
using MessagePack;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace AppServer.MessagePackObjects
{
    [MessagePackObject]
    public class JoinerInfo
    {
        [Key(0)]
        public Player[] players { get; set; }

        [Key(1)]
        public string UUID { get; set; }
    }
}
```

位置同期は、MoveInterpolation()内で補間と予測を行っています。
位置同期の方法については、こちらの[サイト](https://zenn.dev/o8que/books/bdcb9af27bdd7d/viewer/1ea062)を参考にしました。
```cs
        private IEnumerator MoveInterpolation(ClientPlayer _clientPlayer, Player _player)
        {

            //補間の開始座標
            Vector3 p1 = _clientPlayer.avatarObject.transform.position;
            
            //補間の終了座標
            Vector3 p2 = _player.Position;

            //補間の開始角度
            Quaternion r1 = _clientPlayer.avatarObject.transform.rotation;

            //補間の終了角度
            Quaternion r2 = _player.Rotation;

            float elapsedTime = 0f;
            while(elapsedTime < syncTimer)
            {
                elapsedTime += Time.deltaTime;
                _clientPlayer.avatarObject.transform.position = Vector3.Lerp(p1, p2, elapsedTime / interpolationPeriod);
                _clientPlayer.anim.SetFloat("Speed", Vector3.Distance(p1, p2));
                _clientPlayer.avatarObject.transform.rotation = Quaternion.Lerp(r1, r2, elapsedTime / interpolationPeriod);
                _clientPlayer.anim.SetFloat("Direction", Quaternion.Angle(r1, r2));
                yield return null;
            }
            _clientPlayer.avatarObject.transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / interpolationPeriod);

        }
```
OnDestroyでは、サーバーとの切断処理を記述することで、正常にサーバーとの接続を切断します。
```cs
        private async void OnDestroy()
        {
            if (streamingClient != null) await streamingClient.DisposeAsync();
            if (channel != null) await channel.ShutdownAsync();
        }
```

## 1.5. Scene上に必要なオブジェクトを設置
以下のように設置しました。
今回はあくまで簡易的なものを作成するため、アプリ起動時にユーザー名を記入→入室で一つのルームに入室するようにしています。
![スクリーンショット 2022-07-25 215524.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290975/01754d88-2c33-0550-d241-c8fabc5a0fe6.png)
クライアント側はひとまずこれで完成です。
続いて、サーバー側を実装していきます。

# 2. サーバー側を作成
クライアントから受け取ったデータを処理するプログラムを書いていきます。
```cs:ChatAppHub.cs
using MagicOnion.Server.Hubs;
using System.Threading.Tasks;
using AppServer.MessagePackObjects;
using UnityEngine;
using System;
using System.Linq;

namespace AppServer.Hubs
{
    public class ChatAppHub : StreamingHubBase<IChatAppHub, IChatAppHubReceiver>, IChatAppHub
    {
        ///<summary>
        ///ルーム
        /// </summary>
        private IGroup room;

        ///<summary>
        ///ユーザ名
        /// </summary>
        private string userName;

        ///<summary>
        ///通知先
        /// </summary>
        private Player _self;

        ///<summary>
        ///ストレージ
        /// </summary>
        IInMemoryStorage<Player> _strage;

        ///<summary>
        ///入室通知
        /// </summary>
        public async Task<JoinerInfo> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation)
        {
            _self = new Player { userName = userName, Position = position, Rotation = rotation, UUID = Guid.NewGuid().ToString("N") };

            (room,_strage) = await Group.AddAsync(roomName,_self);

            this.userName = userName;
            BroadcastExceptSelf(room).OnJoin(_self);

            JoinerInfo joinerInfo = new JoinerInfo { players = _strage.AllValues.ToArray(), UUID = _self.UUID };

            return joinerInfo;
        }

        ///<summary>
        ///退室通知
        /// </summary>
        public async Task LeaveAsync()
        {
            await room.RemoveAsync(this.Context);
            Broadcast(room).OnLeave(_self);
        }

        ///<summary>
        ///メッセージ通知
        /// </summary>
        public async Task SendMessageAsync(string userName, string message)
        {
            Broadcast(room).OnSendMessage(_self, message);
            await Task.CompletedTask;
        }

        ///<summary>
        ///移動通知
        /// </summary>
        public async Task MoveAsync(Vector3 position, Quaternion rotation)
        {
            _self.Position = position;
            _self.Rotation = rotation;
            BroadcastExceptSelf(room).OnMove(_self);

            await Task.CompletedTask;
        }

        ///<summary>
        ///切断通知
        /// </summary>
        protected override ValueTask OnDisconnected()
        {
            BroadcastExceptSelf(room).OnLeave(_self);
            return CompletedTask;
        }
    }
}
```
JoinAsync()内では、ルーム名に該当するルームに参加者をサーバー上で追加するようになっています。
また、PlayerのUUIDを生成した上で現在のルーム内プレイヤーをクライアント側へ返しています。

今回は書いていませんが、OnDisconnected()内で切断した際のサーバー側の処理も記述できます。
また、接続時の処理はOnConnecting()内で記述できます。


# 3. 動作確認
クライアント側のアプリをビルドして複数立ち上げてみると、このようにキャラクターの位置が同期されているのが確認できます。
※現段階ではlocalhost上でしか動作確認ができないため、クライアント側のチャンネルはlocalhostを指定します。
また、チャットの方も入室する度にログが出ています。
![73t0z-w53gj.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290975/87bb421b-5cc5-bb11-97cb-3d1f59a939a9.gif)

# 4. まとめ
今回は、UnityとMagicOnionで簡単なチャット機能とアバターの位置同期を実装しました。
クライアントもサーバーも、C#で書けるというのはとても便利だと感じました。
しかし現状はlocalhost上で構築されているため、実際は外部のクラウドやサーバー上にプログラムを置く必要があります。
なので、次回はAWS等のホスティングサービスを用いてネットワーク通信ができるのかを検証・確認していきたいと思います。