Custom Messaging Manager の Named Message を利用してテキストチャットのやり取りを実装する【MLAPI】

Environment

  • Unity 2020.3.1f1 (using declaration を利用しているため,C#8 が利用できる 2020.2 以上が推奨)
  • MLAPI v 0.1.0
  • UniRx 7.1.0
  • UniTask 2.2.4

Custom Messaging Manager

平たく言うと,Photon の RaiseEvent と似たような機能です.

ServerRPC / ClientRPC は NetworkBehaviour 継承クラスでしか使えないのですが,
Custom Messaging Handler を利用すれば,NetworkBehaviour を継承しなくても他プレイヤーとデータのやり取りができます.
こちらの仕様は Photon の RPC と RaiseEvent との対応が似ています.

doc.photonengine.com

How To Use

サンプルスクリプト

ひとまずサンプルスクリプトです.

ChatMessageService.cs [Serviceクラス]

using System;
using System.IO;
using System.Linq;
using Cysharp.Threading.Tasks;
using MLAPI;
using MLAPI.Messaging;
using MLAPI.Serialization.Pooled;
using UniRx;
using UnityEngine;

namespace MLAPIPractice.ChatMessage
{
    public static class CMMConst
    {
        public static readonly string CLIENT_TO_SERVER = "CLIENT_TO_SERVER";
        public static readonly string SERVER_TO_ALL_CLIENTS = "SERVER_TO_ALL_CLIENTS";
    }

    public class ChatMessageService : MonoBehaviour
    {
        public IObservable<(ulong senderId, string message)> OnReceivedMessageAsObservable() => _onReceivedMessageSubject;
        private readonly Subject<(ulong senderId, string message)> _onReceivedMessageSubject = new Subject<(ulong senderId, string message)>();

        private async void Awake()
        {
            // NetworkManager がリスナー状態になるまで待つ
            await UniTask.WaitUntil(() => NetworkManager.Singleton.IsListening);

            if (NetworkManager.Singleton.IsServer)
            {
                // 事前にクライアント側からのストリームを受け取る処理を登録する
                CustomMessagingManager.RegisterNamedMessageHandler(CMMConst.CLIENT_TO_SERVER,
                    MessageHandler_Server_ReceiveAndSendMessageToAllClients);
                Debug.Log("U R SERVER. <color=red>YOU CANNOT RECEIVE MESSAGES.</color>");
            }

            if (NetworkManager.Singleton.IsClient)
            {
                // 事前にサーバー側からのストリームを受け取る処理を登録する
                CustomMessagingManager.RegisterNamedMessageHandler(CMMConst.SERVER_TO_ALL_CLIENTS,
                    MessageHandler_Client_ReceiveMessage);
                Debug.Log("U R CLIENT.");
            }
        }

        private void OnDestroy()
        {
            CustomMessagingManager.UnregisterNamedMessageHandler(CMMConst.CLIENT_TO_SERVER);
            CustomMessagingManager.UnregisterNamedMessageHandler(CMMConst.SERVER_TO_ALL_CLIENTS);
        }

        /// <summary>
        /// チャットメッセージ送信処理
        /// </summary>
        /// <param name="message">テキストメッセージの内容</param>
        public void SendMessageToServerToAllClients(string message)
        {
            // ① まず最初に,クライアント側がサーバー側に向けて送る
            // PooledNetworkBuffer Stream の生成(送る側なので生成する)
            using var outputStream = PooledNetworkBuffer.Get();
            // PooledNetworkBuffer Stream から Writer を生成
            using var writer = PooledNetworkWriter.Get(outputStream);
            // テキストメッセージを PooledNetworkBuffer Streamに書きだすイメージ
            writer.WriteStringPacked(message);

            if (NetworkManager.Singleton.IsListening)
            {
                // サーバーのクライアントIDを取得し,ストリームにデータを乗せて送る
                var serverClientId = NetworkManager.Singleton.ServerClientId;
                CustomMessagingManager.SendNamedMessage(CMMConst.CLIENT_TO_SERVER, serverClientId, outputStream);
            }
            else
            {
                Debug.LogError("Cannot send message because network manager is not listening.");
            }
        }

        /// <summary>
        /// クライアント側から送られてきたテキストメッセージ入りのストリームを読み込み,
        /// さらに,そのストリームを全クライアントへ送り返す.
        /// </summary>
        /// <param name="senderClientId">送信者側のクライアントID(ここでは SendMessageToServerToAllClients を実行したクライアントIDになる)</param>
        /// <param name="inputStream">PooledNetworkBuffer Stream</param>
        private void MessageHandler_Server_ReceiveAndSendMessageToAllClients(ulong senderClientId, Stream inputStream)
        {
            // ② 次に,サーバー側がクライアント側から送られてきたメッセージを受け取って読み込む
            using var reader = PooledNetworkReader.Get(inputStream);
            var message = reader.ReadStringPacked();

            // ③ 今度は,送られてきたメッセージをサーバー側が全クライアントに向けて送り返す
            using var outputStream = PooledNetworkBuffer.Get();
            using var writer = PooledNetworkWriter.Get(outputStream);
            // 送信したクライアントのIDも乗せる
            writer.WriteUInt64Packed(senderClientId);
            writer.WriteStringPacked(message);

            // 全クライアントのIDリストを取得し,ストリームにデータを乗せて送る
            var targetClientIds =
                NetworkManager.Singleton.ConnectedClientsList.Select(client => client.ClientId).ToList();
            CustomMessagingManager.SendNamedMessage(CMMConst.SERVER_TO_ALL_CLIENTS, targetClientIds, outputStream);
        }

        /// <summary>
        /// サーバー側から送られてきたテキストメッセージ入りのストリームを読み込む.
        /// </summary>
        /// <param name="senderServerId">送信者側のクライアントID(このまま利用するとサーバーのIDになるのでここでは使わない)</param>
        /// <param name="inputStream">PooledNetworkBuffer Stream</param>
        private void MessageHandler_Client_ReceiveMessage(ulong senderServerId, Stream inputStream)
        {
            // ④ 最後に,サーバー側から送られてきたストリームから送り手側のIDとメッセージ内容を読み取る
            using var reader = PooledNetworkReader.Get(inputStream);
            var senderClientId = reader.ReadUInt64Packed();
            var message = reader.ReadStringPacked();
            _onReceivedMessageSubject.OnNext((senderClientId, message));
        }
    }
}

ChatMessageView.cs [Viewクラス]

using System;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace MLAPIPractice.ChatMessage
{
    public class ChatMessageView : MonoBehaviour
    {
        [SerializeField]
        private InputField inputFieldTextMessage;

        [SerializeField]
        private Button buttonTextMessage;

        public IObservable<string> OnSendTextMessageAsObservable() => _OnSendTextMessageSubject;
        private readonly Subject<string> _OnSendTextMessageSubject = new Subject<string>();

        private void Awake()
        {
            // テキストメッセージが入力されてない限り,ボタンを非活性にする
            inputFieldTextMessage.ObserveEveryValueChanged(field => field.text)
                .Subscribe(message => buttonTextMessage.interactable = !string.IsNullOrEmpty(message))
                .AddTo(this);

            buttonTextMessage.OnClickAsObservable()
                .Subscribe(_ => SendTextMessage(inputFieldTextMessage.text))
                .AddTo(this);
        }

        private void SendTextMessage(string message)
        {
            _OnSendTextMessageSubject.OnNext(message);
            inputFieldTextMessage.text = string.Empty;
        }

        public void DisplayTextMessage((ulong, string) messageTuple)
        {
            var (senderClientId, message) = messageTuple;
            Debug.Log($"SenderClientId: {senderClientId}, Message:[{message}]");
        }
    }
}

ChatMessagePresenter.cs [Presenter クラス]

using UniRx;
using UnityEngine;

namespace MLAPIPractice.ChatMessage
{
    public class ChatMessagePresenter : MonoBehaviour
    {
        [SerializeField]
        private ChatMessageProvider _Provider = default;
        [SerializeField]
        private ChatMessageView _View = default;

        private void Awake()
        {
            _Provider.OnReceivedMessageAsObservable()
                .Subscribe(_View.DisplayTextMessage)
                .AddTo(this);

            _View.OnSendTextMessageAsObservable()
                .Subscribe(_Provider.SendMessageToServerToAllClients)
                .AddTo(this);
        }
    }
}

DebugLogDisplay.cs [Debug.Log表示用]

using UnityEngine;
using System.Collections.Generic;
using System.Text;

namespace MLAPIPractice.ChatMessage
{
    public class DebugLogDisplay : MonoBehaviour
    {
        [SerializeField]
        private int maxLogCount = 20;
        [SerializeField]
        private Rect logArea = new Rect(300, 10, 400, 400);

        private readonly Queue<string> _logMessages = new Queue<string>();
        private readonly StringBuilder _stringBuilder = new StringBuilder();

        private void Start()
        {
            Application.logMessageReceived += LogReceived;
        }

        private void LogReceived(string text, string stackTrace, LogType type)
        {
            _logMessages.Enqueue(text);

            while(_logMessages.Count > maxLogCount)
            {
                _logMessages.Dequeue();
            }
        }

        private void OnGUI()
        {
            _stringBuilder.Length = 0;
            GUI.skin.label.fontSize = 30;

            foreach (var message in _logMessages)
            {
                _stringBuilder.Append(message).Append(System.Environment.NewLine);
            }

            GUI.Label(logArea, _stringBuilder.ToString());
        }
    }
}

処理の流れ

下手な画像ですみませんが,処理の流れのイメージは以下のようになります.

f:id:xrdnk:20210403220549j:plain

  1. PooledNetworkBufferストリームを生成し,特定のクライアントがメッセージをストリームに書き込んでサーバーへ送る
  2. サーバーは特定のクライアントから送られてきたストリームを読み込んでメッセージを受け取る
  3. 新しくストリームを生成し,受け取ったメッセージの内容とその送信者IDの情報をストリームに書き込んで全クライアントに送る
  4. 各々のクライアントはサーバーから送られてきたストリームを読み込んで,メッセージとメッセージ送信者IDの情報を受け取る

ストリームを作ってデータを送る処理を作る

メッセージ送信ボタンを押した時に走る処理です.

        /// <summary>
        /// チャットメッセージ送信処理
        /// </summary>
        /// <param name="message">テキストメッセージの内容</param>
        public void SendMessageToServerToAllClients(string message)
        {
            // ① まず最初に,クライアント側がサーバー側に向けて送る
            // PooledNetworkBuffer Stream の生成(送る側なので生成する)
            using var outputStream = PooledNetworkBuffer.Get();
            // PooledNetworkBuffer Stream から Writer を生成
            using var writer = PooledNetworkWriter.Get(outputStream);
            // テキストメッセージを PooledNetworkBuffer Streamに書きだすイメージ
            writer.WriteStringPacked(message);

            if (NetworkManager.Singleton.IsListening)
            {
                // サーバーのクライアントIDを取得し,ストリームにデータを乗せて送る
                var serverClientId = NetworkManager.Singleton.ServerClientId;
                CustomMessagingManager.SendNamedMessage(CMMConst.CLIENT_TO_SERVER, serverClientId, outputStream);
            }
            else
            {
                Debug.LogError("Cannot send message because network manager is not listening.");
            }
        }

PooledNetworkBuffer.Get() を通して,PooledNetworkBuffer のストリームを生成します.
そのストリームから PooledNetworkWriter を生成し,送りたいメッセージの内容を書きだします.
ここでは writer.WriteStringPacked(message) で書きだしています.
最後に,CustomMessagingManager.SendNamedMessage()で,ストリームを送ります.

CustomMessagingManager.SendNamedMessage() の第一引数は識別用のIDのようなものです.
個人的にはストリームの識別用にも連動して考えるとイメージしやすいかも.なので,IDは CLIENT_TO_SERVER にしています.
第二引数はストリームの送り先です.ここではサーバーのIDを指定しています.第三引数は送るストリームです.
一応,NetworkChannel という第四引数もあるんですがここでは割愛します.

ストリームを受け取ってデータを読み込む処理を作る

まずはサーバ側がストリームを受け取る処理を作ります.
ここでは受け取った後,新たにストリームを生成して,全クライアントに送り返す処理も行っています.

        /// <summary>
        /// クライアント側から送られてきたテキストメッセージ入りのストリームを読み込み,
        /// さらに,そのストリームを全クライアントへ送り返す.
        /// </summary>
        /// <param name="senderClientId">送信者側のクライアントID(ここでは SendMessageToServerToAllClients を実行したクライアントIDになる)</param>
        /// <param name="inputStream">PooledNetworkBuffer Stream</param>
        private void MessageHandler_Server_ReceiveAndSendMessageToAllClients(ulong senderClientId, Stream inputStream)
        {
            // ② 次に,サーバー側がクライアント側から送られてきたメッセージを受け取って読み込む
            using var reader = PooledNetworkReader.Get(inputStream);
            var message = reader.ReadStringPacked();

            // ③ 今度は,送られてきたメッセージをサーバー側が全クライアントに向けて送り返す
            using var outputStream = PooledNetworkBuffer.Get();
            using var writer = PooledNetworkWriter.Get(outputStream);
            // 送信したクライアントのIDも乗せる
            writer.WriteUInt64Packed(senderClientId);
            writer.WriteStringPacked(message);

            // 全クライアントのIDリストを取得し,ストリームにデータを乗せて送る
            var targetClientIds =
                NetworkManager.Singleton.ConnectedClientsList.Select(client => client.ClientId).ToList();
            CustomMessagingManager.SendNamedMessage(CMMConst.SERVER_TO_ALL_CLIENTS, targetClientIds, outputStream);
        }

受け取る側なのでストリーム生成は不要です.PooledNetworkReader.Get (inputStream) で流れてきたストリームを受け取ります.
次に,ストリームから reader.ReadStringPacked() でメッセージの内容を受け取ります.
受け取った後,新たにストリームを生成し,メッセージの内容と送信者のIDをストリームに書き込みまし.
そして,サーバから全クライアントに向けて,ストリームを送ります.識別IDは SERVER_TO_ALL_CLIENTS にしました.

        /// <summary>
        /// サーバー側から送られてきたテキストメッセージ入りのストリームを読み込む.
        /// </summary>
        /// <param name="senderServerId">送信者側のクライアントID(このまま利用するとサーバーのIDになるのでここでは使わない)</param>
        /// <param name="inputStream">PooledNetworkBuffer Stream</param>
        private void MessageHandler_Client_ReceiveMessage(ulong senderServerId, Stream inputStream)
        {
            // ④ 最後に,サーバー側から送られてきたストリームから送り手側のIDとメッセージ内容を読み取る
            using var reader = PooledNetworkReader.Get(inputStream);
            var senderClientId = reader.ReadUInt64Packed();
            var message = reader.ReadStringPacked();
            _onReceivedMessageSubject.OnNext((senderClientId, message));
        }

最後に,各クライアントがサーバから送られてくるストリームを受け取って内容を読み込む処理を作ります.
処理の流れはこれまでと同じ感じです.

ストリームを受け取ってデータを読み込む処理を事前登録する

        private async void Awake()
        {
            // NetworkManager がリスナー状態になるまで待つ
            await UniTask.WaitUntil(() => NetworkManager.Singleton.IsListening);

            if (NetworkManager.Singleton.IsServer)
            {
                // 事前にクライアント側からのストリームを受け取る処理を登録する
                CustomMessagingManager.RegisterNamedMessageHandler(CMMConst.CLIENT_TO_SERVER,
                    MessageHandler_Server_ReceiveAndSendMessageToAllClients);
                Debug.Log("U R SERVER. <color=red>YOU CANNOT RECEIVE MESSAGES.</color>");
            }

            if (NetworkManager.Singleton.IsClient)
            {
                // 事前にサーバー側からのストリームを受け取る処理を登録する
                CustomMessagingManager.RegisterNamedMessageHandler(CMMConst.SERVER_TO_ALL_CLIENTS,
                    MessageHandler_Client_ReceiveMessage);
                Debug.Log("U R CLIENT.");
            }
        }

        private void OnDestroy()
        {
            CustomMessagingManager.UnregisterNamedMessageHandler(CMMConst.CLIENT_TO_SERVER);
            CustomMessagingManager.UnregisterNamedMessageHandler(CMMConst.SERVER_TO_ALL_CLIENTS);
        }

ストリームを受け取る処理は初期化時に事前に登録する必要があります.
登録方法は CustomMessagingManager.RegisterNamedMessageHandler で,
解除方法は CustomMessagingManager.UnregisterNamedMessageHandler で行います.

注意点

テキストメッセージ送信処理の際に,以下のようにサーバーに向けて送信を行っています.

 CustomMessagingManager.SendNamedMessage(CMMConst.CLIENT_TO_SERVER, serverClientId, outputStream);

この処理はMLAPI v0.1.0 段階では自分自身がサーバー(これは自身がホストであることも内包)である場合,動きません.

MLAPI.Messaging.InternalMessageSender.cs のソースコードの中身を見てみると,送信処理はこのようになってます.

        internal static void Send(ulong clientId, byte messageType, NetworkChannel networkChannel, NetworkBuffer messageBuffer)
        {
            messageBuffer.PadBuffer();

            // 自身がサーバーである かつ 送られてきたクライアントIDがサーバークライアントID だった場合,以下の処理を走らせない
            if (NetworkManager.Singleton.IsServer && clientId == NetworkManager.Singleton.ServerClientId)
                return;

            using (var buffer = MessagePacker.WrapMessage(messageType, messageBuffer))
            {
                NetworkProfiler.StartEvent(TickType.Send, (uint)buffer.Length, networkChannel, NetworkConstants.MESSAGE_NAMES[messageType]);

                NetworkManager.Singleton.NetworkConfig.NetworkTransport.Send(clientId, new ArraySegment<byte>(buffer.GetBuffer(), 0, (int)buffer.Length), networkChannel);
                ProfilerStatManager.BytesSent.Record((int)buffer.Length);
                PerformanceDataManager.Increment(ProfilerConstants.ByteSent, (int)buffer.Length);

#if !UNITY_2020_2_OR_NEWER
                NetworkProfiler.EndEvent();
#endif
            }
        }

ガード節の部分がまんま該当していますね.
なのでホストの場合はこの処理は走らないことにご注意ください.
サーバー(ホスト)役はクライアントから受け取ったメッセージをバケツリレーのように全クライアントへ渡しているイメージで.

動作確認

gyazo.com

左上がホスト,それ以外はクライアントとして入っています.
2回目にホスト側でメッセージを送っていますが,送信処理がそもそも走れていないためにテキストメッセージが出ないですね.
1,3,4回目で送信処理を行うと,クライアント全員にはテキストメッセージが表示されています.(ホストは出ません.)