LoginSignup
17
12

More than 3 years have passed since last update.

【Unity(C#)】Mirrorで同期通信① マッチング機能

Last updated at Posted at 2020-12-05

Unity #2 Advent Calendar 2020

こちらは Unity #2 Advent Calendar 2020 の 6日目の記事です。

Mirror

オープンソースのネットワークライブラリ(アセット)です。

プレイヤーのマッチングに公式サーバーが必要ないので
同一LAN内が担保されていれば接続が可能です。

当然、サーバーをゴリゴリ頑張れば自前運用も可能です。

【参考リンク】:無料で使えるネットライブラリMirrorのざっくり紹介

公式Discordに参加してみましたが、
アップデートが頻繁に行われているのもあってか、
実装上の質問も飛び交って賑やかでした。(全部英語です)

今回やること

①同一LAN内のサーバー(ホスト)を検索
②サーバーが見つかればクライアントとして接続、なければ自身がサーバー(ホスト)になる
③サーバー(ホスト)がマッチングを確認し、ゲームを開始する
④サーバー(ホスト)、各クライアント、共にシーン遷移する

一言でまとめるとオートマッチングシステムを作ります。

バージョン

Unity 2019.4.8f1
Mirror 26.2.2
UniTask.2.0.18

デモ

左上が自動でホストになり、残りの3画面がクライアントとして接続を試みます。
MirrorForQiita1.gif

同一LAN内が前提なのでIPアドレスの入力などは省略できます。

コード(CustomNetworkDiscovery )

まずはサーバーを検索し、接続するための処理を担うコードです。

using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Mirror;
using Mirror.Discovery;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// サーバー検索、接続
/// </summary>
public class CustomNetworkDiscovery : NetworkDiscovery
{
    [SerializeField] private Button _multiPlayButton;
    [SerializeField] private Button _backButton;
    [SerializeField] private Button _playButton;
    [SerializeField] private Text _playerCountText;
    [SerializeField] private Text _connectionStateText;
    //SceneのアトリビュートはMirrorに用意されている便利機能
    //Inspectorでシーンを参照してコード内で文字列として使用できる
    [SerializeField,Scene] private string _gameSceneName;

    private ServerResponse _discoveredServer;
    private CancellationTokenSource _cancellationTokenSource;

    private const int CONNECT_INTERVAL_TIME = 2;
    private const int WAIT_TIME = 2;
    private const int CONNECT_TRY_COUNT = 1;

    private const string CONNECTION_STATUS_CLIENT_WAITING = "Waiting start...";
    private const string CONNECTION_STATUS_HOST_WAITING = "Waiting other player...";
    private const string CONNECTION_STATUS_SUCCESS = "Success!";

    private bool _isHostReady;
    private NetworkManager _networkManager;

    private void OnDestroy()
    {
        //シーン遷移などで破棄されたタイミングで検索をやめる
        StopDiscovery();
    }

    private void Awake()
    {
        //データ受信の準備
        NetworkClient.RegisterHandler<SendHostReadyData>(ReceivedReadyInfo);
        NetworkClient.RegisterHandler<SendPlayerCountData>(ReceivedPlayerCountInfo);

        //サーバー見つけたらこれが呼ばれる
        OnServerFound.AddListener(serverResponse =>
        {
            //見つけたサーバーを辞書に登録
            _discoveredServer = serverResponse;
            Debug.Log("ServerFound");
        });

        //サーバーの検索&接続開始
        _multiPlayButton.onClick.AddListener(() =>
        {
            Debug.Log("Search Connection");
            _backButton.transform.gameObject.SetActive(true);
            _multiPlayButton.transform.gameObject.SetActive(false);

            //接続を試みる
            _cancellationTokenSource = new CancellationTokenSource();
            CancellationToken token = _cancellationTokenSource.Token;
            TryConnectAsync(token).Forget();
        });

        //最初の画面に戻る
        _backButton.onClick.AddListener(() =>
        {
            Debug.Log("Cancel");

            //サーバーから抜ける
            //サーバーの検索停止
            StopDiscovery();
            NetworkManager.singleton.StopHost();

            //非同期処理止める
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();

        });

        //ホスト側にのみ表示されるボタン プレイボタン押下で準備完了とする
        _playButton.onClick.AddListener(() =>
        {
            Debug.Log("Ready Ok");
            //各クライアントにフラグデータを送る
            SendHostReadyData sendData = new SendHostReadyData() {IsHostReady = true};
            NetworkServer.SendToAll(sendData);

            _playButton.transform.gameObject.SetActive(false);
        });
    }

    /// <summary>
    /// サーバーから受け取ったデータを各クライアントで使う
    /// </summary>
    /// <param name="conn">コネクション情報 関数内で使ってないけど必要みたい</param>
    /// <param name="receivedData">受け取ったデータ</param>
    private void ReceivedReadyInfo(NetworkConnection conn, SendHostReadyData receivedData)
    {
        //ローカルのフラグに反映
        _isHostReady = receivedData.IsHostReady;
    }

    /// <summary>
    /// サーバーから受け取ったデータを各クライアントで使う
    /// </summary>
    /// <param name="conn">コネクション情報 関数内で使ってないけど必要みたい</param>
    /// <param name="receivedData">受け取ったデータ</param>
    private void ReceivedPlayerCountInfo(NetworkConnection conn, SendPlayerCountData receivedData)
    {
        if (_playButton == null) return;

        _playerCountText.text = receivedData.PlayerCount + "/" + _networkManager.maxConnections;
    }


    /// <summary>
    /// 接続を試みる
    /// 非同期
    /// </summary>
    private async UniTaskVoid TryConnectAsync(CancellationToken token)
    {
        _networkManager = NetworkManager.singleton;

        int tryCount = 0;

        //サーバーの検索開始
        StartDiscovery();

        //サーバーに接続するまでループ
        while (!_networkManager.isNetworkActive)
        {
            //n秒間隔で実行
            await UniTask.Delay(TimeSpan.FromSeconds(CONNECT_INTERVAL_TIME), cancellationToken: token);

            //サーバー発見した場合
            if (_discoveredServer.uri != null)
            {
                Debug.Log("Start Client");
                //クライアントとして接続開始
                _networkManager.StartClient(_discoveredServer.uri);
                //接続ステータスの文言変更
                _connectionStateText.text =CONNECTION_STATUS_CLIENT_WAITING;
                //サーバーの検索停止
                StopDiscovery();
                //ここでホストの開始フラグを待つ
                await UniTask.WaitUntil(() => _isHostReady, cancellationToken: token);
                //接続ステータスの文言変更
                _connectionStateText.text = CONNECTION_STATUS_SUCCESS;
            }
            //サーバー見つからない場合
            else
            {
                Debug.Log("Try Connect...");

                //接続を試みた回数をカウントアップ
                tryCount++;

                //任意の回数以上接続に試みて失敗した場合は自身がホストになる
                if (tryCount > CONNECT_TRY_COUNT)
                {
                    Debug.Log("Start Host");

                    //ホストになる(サーバー)
                    _networkManager.StartHost();
                    //サーバーあるよーってお知らせする
                    AdvertiseServer();

                    //接続ステータスの文言変更
                    _connectionStateText.text = CONNECTION_STATUS_HOST_WAITING;

                    //プレイボタン表示
                    _playButton.gameObject.SetActive(true);

                    //ここでホストの開始フラグを待つ
                    await UniTask.WaitUntil(() => _isHostReady, cancellationToken: token);
                    //接続ステータスの文言変更
                    _connectionStateText.text = CONNECTION_STATUS_SUCCESS;
                    //n秒待つ
                    await UniTask.Delay(TimeSpan.FromSeconds(WAIT_TIME), cancellationToken: token);
                    //シーン遷移
                    _networkManager.ServerChangeScene(_gameSceneName);
                }
            }
        }
    }
}

NetworkDiscovery

サーバーを検索、もしくはサーバーが自身の存在を通知する機能を持ちます。

NetworkDiscoveryはそのまま使用することもできますが、
UIをカスタマイズしたかったり、
シーン遷移時のフェードアニメーションなどを追加したかったりする場合には
カスタムしないと難しいです。

そのために継承して利用しています。

StartDiscovery,StopDiscovery,AdvertiseServerなどは
NetworkDiscoveryの機能に当たります。

これらの機能は名前のまんまです。
ただし、シーン遷移時にしっかりとサーバーの検索、通知を停止させないと
サーバーは停止しているのにレスポンスだけは返ってくるという謎の減少が起きるので
OnDestroyで確実にStopDiscoveryするのが安全だと思います。


NetworkServer.SendToAll

サーバー内のすべてのクライアント(ホスト含む)に引数で指定したデータを送信します。

CustomNetworkDiscovery内ではホストがプレイボタンを押したことを各クライアントに通知しています。

   //ホスト側にのみ表示されるボタン プレイボタン押下で準備完了とする
   _playButton.onClick.AddListener(() =>
   {
        Debug.Log("Ready Ok");
        //各クライアントにフラグデータを送る
        SendHostReadyData sendData = new SendHostReadyData() {IsHostReady = true};
        NetworkServer.SendToAll(sendData);

        _playButton.transform.gameObject.SetActive(false);
   });

NetworkClient.RegisterHandler

先ほどのSendToAllでデータが送られてきたことを検知し、
各クライアントでデータの受信時に行いたい処理を登録できます。

(引数のNetworkConnectionは別になくても動きます。)


private void Start()
{
    //データ受信の準備
    NetworkClient.RegisterHandler<SendHostReadyData>(ReceivedReadyInfo);
}


/// <summary>
/// サーバーから受け取ったデータを各クライアントで使う
/// </summary>
/// <param name="conn">コネクション情報 関数内で使ってないけど必要みたい</param>
/// <param name="receivedData">受け取ったデータ</param>
private void ReceivedReadyInfo(NetworkConnection conn, SendHostReadyData receivedData)
{
    //ローカルのフラグに反映
    _isHostReady = receivedData.IsHostReady;
}

やり取りするデータも別途定義が必要となります。
NetworkMessageというインターフェースを実装することで
やり取りが可能なデータとなります。

using System;
using Mirror;

/// <summary>
/// 送信するデータ
/// </summary>
[Serializable]
public struct SendHostReadyData : NetworkMessage
{
    /// <summary>
    /// ホストが準備できたかどうか
    /// </summary>
    public bool IsHostReady;
}

コード(CustomNetworkManager)

次に接続にまつわるコードです。

using Mirror;
using UnityEngine;
using UnityEngine.SceneManagement;

/// <summary>
/// 接続にまつわるいろいろ
/// </summary>
public class CustomNetworkManager : NetworkManager
{
    [SerializeField,Scene] private string _titleScene;
    [SerializeField,Scene] private string _mainScene;

    private Transform _playerTransform;
    private Material _playerMaterial;

    /// <summary>
    /// プレイヤー入室時にサーバー側が実行
    /// </summary>
    /// <param name="conn">接続されたプレイヤーのコネクション</param>
    public override void OnServerAddPlayer(NetworkConnection conn)
    {
        Debug.Log("Add Player");

        //タイトルシーンでのみ実行
        if (_titleScene.Contains(SceneManager.GetActiveScene().name))
        {
            //接続中の人数表記を変える
            SendPlayerCountData sendData = new SendPlayerCountData() {PlayerCount = NetworkServer.connections.Count};
            NetworkServer.SendToAll(sendData);
        }

        //メインシーンでのみ実行
        if (_mainScene.Contains(SceneManager.GetActiveScene().name))
        {
            Debug.Log("Spawn Player");
            //プレイヤー生成
            GameObject player = Instantiate(playerPrefab);
            //今立ち上げているサーバーにプレイヤーを追加登録
            NetworkServer.AddPlayerForConnection(conn, player);
        }
    }

    /// <summary>
    /// 各プレイヤー退室時にサーバー側が実行
    /// </summary>
    /// <param name="conn">切れたコネクション</param>
    public override void OnServerDisconnect(NetworkConnection conn)
    {
        //接続中の人数表記を変える
        SendPlayerCountData sendData = new SendPlayerCountData() {PlayerCount = NetworkServer.connections.Count};
        NetworkServer.SendToAll(sendData);
        Debug.Log("Anyone Disconnect");
        base.OnServerDisconnect(conn);
    }

    /// <summary>
    /// サーバーとの接続が切れた時にクライアント側で呼ばれる
    /// </summary>
    public override void OnStopClient()
    {
        SceneManager.LoadScene(_titleScene);
        Debug.Log("Disconnect");
        base.OnStopClient();
    }
}

NetworkManager

文字通りネットワークにまつわるいろいろを担います。

The Network Manager is a component for managing the networking aspects of a multiplayer game.

引用:Network Manager

これもあまりそのまま使う想定のものではないので、
継承してメソッドをオーバーライドしてカスタムします。

コールバック含め、大量に機能があるので今回使ったものだけ解説します。


StartHost, StartClient, StopHost

接続にまつわる関数です。
StartHostを実行した場合、サーバーとクライアントの両方の役割を持つことになります。

StartClientは引数に指定したアドレスのサーバーにクライアントとして接続します。

StopHostは自身がサーバーならサーバーの接続を中断し、
クライアントならサーバーから抜けます。

NetworkManagerはシングルトンとなっており、
インスタンスをどこからでも呼び出せます。

StartHost, StartClient, StopHostは全てPublicな関数なので、
これらもどこからでも呼び出せるってことです。

今回はサーバーの検索を担う、CustomNetworkDiscoveryで接続にまつわる関数を呼び出しています。

そうすることで、
・LAN内にサーバーが見つかったら→StartClient
・LAN内にサーバーが見つからなかったら→StartHost

のように同一LAN内で自動でマッチングする仕組みを作れます。


OnStopClient

クライアントがサーバーから切断された場合に各クライアントで呼び出されます。

このコールバックの中でシーン遷移を呼び出すことで
切断→シーン遷移 という処理が可能となります。

すなわち、接続状態にあるクライアントでStopHostを呼び出せば
下記処理が呼ばれるということです。

 /// <summary>
 /// サーバーとの接続が切れた時にクライアント側で呼ばれる
 /// </summary>
 public override void OnStopClient()
 {
     SceneManager.LoadScene(_titleScene);
     Debug.Log("Disconnect");
     base.OnStopClient();
 }

OnServerAddPlayer

Mirrorにはプレイヤーという概念があります。
誤解を恐れずに簡単にまとめると
サーバーに接続したクライアントのことをプレイヤーと呼び、接続時にサーバーに追加されます。

このOnServerAddPlayerはプレイヤーが追加された際に呼び出される処理です。

デモにおける接続された人数の表記の変更の通知(プレイヤー増加時)はOnServerAddPlayerで行っています。

    /// <summary>
    /// プレイヤー入室時にサーバー側が実行
    /// </summary>
    /// <param name="conn">接続されたプレイヤーのコネクション</param>
    public override void OnServerAddPlayer(NetworkConnection conn)
    {
        Debug.Log("Add Player");

        //タイトルシーンでのみ実行
        if (_titleScene.Contains(SceneManager.GetActiveScene().name))
        {
            //接続中の人数表記を変える
            SendPlayerCountData sendData = new SendPlayerCountData() {PlayerCount = NetworkServer.connections.Count};
            NetworkServer.SendToAll(sendData);
        }

        //メインシーンでのみ実行
        if (_mainScene.Contains(SceneManager.GetActiveScene().name))
        {
            Debug.Log("Spawn Player");
            //プレイヤー生成
            GameObject player = Instantiate(playerPrefab);
            //今立ち上げているサーバーにプレイヤーを追加登録
            NetworkServer.AddPlayerForConnection(conn, player);
        }
    }

また、プレイヤーを概念ではなく、実体として生成する場合もあるかと思います。

その場合、OnServerAddPlayerでInstantiateしてあげれば
各クライアントにプレイヤーが生成されます。

ただし、この機能を利用するには
InspectorのPlayerPrefabNetworkIdentityが付与されたPrefabを
事前に登録しておく必要があります。

PlayerPrefabSS.PNG

最後に

詳しくは知りませんがUNETという機能?がひと昔前にあったそうで、
それを改良したのがMirrorのようです。

結構なビッグタイトルに採用されているようですが、
ドキュメント以外の情報がなかなか無いので苦労しました。

私の今の力では及びませんがサーバー側の実装とかもいずれできるようになりたいです。

(UniTaskの実装は見よう見真似でやったので間違ってたら教えてください。)

17
12
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
12