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画面がクライアントとして接続を試みます。
同一LAN内が前提なのでIPアドレスの入力などは省略できます。
コード(CustomNetworkDiscovery )
まずはサーバーを検索し、接続するための処理を担うコードです。
usingSystem;usingSystem.Threading;usingCysharp.Threading.Tasks;usingMirror;usingMirror.Discovery;usingUnityEngine;usingUnityEngine.UI;/// <summary>/// サーバー検索、接続/// </summary>publicclassCustomNetworkDiscovery:NetworkDiscovery{[SerializeField]privateButton_multiPlayButton;[SerializeField]privateButton_backButton;[SerializeField]privateButton_playButton;[SerializeField]privateText_playerCountText;[SerializeField]privateText_connectionStateText;//SceneのアトリビュートはMirrorに用意されている便利機能//Inspectorでシーンを参照してコード内で文字列として使用できる[SerializeField,Scene]privatestring_gameSceneName;privateServerResponse_discoveredServer;privateCancellationTokenSource_cancellationTokenSource;privateconstintCONNECT_INTERVAL_TIME=2;privateconstintWAIT_TIME=2;privateconstintCONNECT_TRY_COUNT=1;privateconststringCONNECTION_STATUS_CLIENT_WAITING="Waiting start...";privateconststringCONNECTION_STATUS_HOST_WAITING="Waiting other player...";privateconststringCONNECTION_STATUS_SUCCESS="Success!";privatebool_isHostReady;privateNetworkManager_networkManager;privatevoidOnDestroy(){//シーン遷移などで破棄されたタイミングで検索をやめるStopDiscovery();}privatevoidAwake(){//データ受信の準備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=newCancellationTokenSource();CancellationTokentoken=_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");//各クライアントにフラグデータを送るSendHostReadyDatasendData=newSendHostReadyData(){IsHostReady=true};NetworkServer.SendToAll(sendData);_playButton.transform.gameObject.SetActive(false);});}/// <summary>/// サーバーから受け取ったデータを各クライアントで使う/// </summary>/// <param name="conn">コネクション情報 関数内で使ってないけど必要みたい</param>/// <param name="receivedData">受け取ったデータ</param>privatevoidReceivedReadyInfo(NetworkConnectionconn,SendHostReadyDatareceivedData){//ローカルのフラグに反映_isHostReady=receivedData.IsHostReady;}/// <summary>/// サーバーから受け取ったデータを各クライアントで使う/// </summary>/// <param name="conn">コネクション情報 関数内で使ってないけど必要みたい</param>/// <param name="receivedData">受け取ったデータ</param>privatevoidReceivedPlayerCountInfo(NetworkConnectionconn,SendPlayerCountDatareceivedData){if(_playButton==null)return;_playerCountText.text=receivedData.PlayerCount+"/"+_networkManager.maxConnections;}/// <summary>/// 接続を試みる/// 非同期/// </summary>privateasyncUniTaskVoidTryConnectAsync(CancellationTokentoken){_networkManager=NetworkManager.singleton;inttryCount=0;//サーバーの検索開始StartDiscovery();//サーバーに接続するまでループwhile(!_networkManager.isNetworkActive){//n秒間隔で実行awaitUniTask.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();//ここでホストの開始フラグを待つawaitUniTask.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);//ここでホストの開始フラグを待つawaitUniTask.WaitUntil(()=>_isHostReady,cancellationToken:token);//接続ステータスの文言変更_connectionStateText.text=CONNECTION_STATUS_SUCCESS;//n秒待つawaitUniTask.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");//各クライアントにフラグデータを送るSendHostReadyDatasendData=newSendHostReadyData(){IsHostReady=true};NetworkServer.SendToAll(sendData);_playButton.transform.gameObject.SetActive(false);});NetworkClient.RegisterHandler
先ほどのSendToAllでデータが送られてきたことを検知し、
各クライアントでデータの受信時に行いたい処理を登録できます。
(引数のNetworkConnectionは別になくても動きます。)
privatevoidStart(){//データ受信の準備NetworkClient.RegisterHandler<SendHostReadyData>(ReceivedReadyInfo);}/// <summary>/// サーバーから受け取ったデータを各クライアントで使う/// </summary>/// <param name="conn">コネクション情報 関数内で使ってないけど必要みたい</param>/// <param name="receivedData">受け取ったデータ</param>privatevoidReceivedReadyInfo(NetworkConnectionconn,SendHostReadyDatareceivedData){//ローカルのフラグに反映_isHostReady=receivedData.IsHostReady;}やり取りするデータも別途定義が必要となります。NetworkMessageというインターフェースを実装することで
やり取りが可能なデータとなります。
usingSystem;usingMirror;/// <summary>/// 送信するデータ/// </summary>[Serializable]publicstructSendHostReadyData:NetworkMessage{/// <summary>/// ホストが準備できたかどうか/// </summary>publicboolIsHostReady;}コード(CustomNetworkManager)
次に接続にまつわるコードです。
usingMirror;usingUnityEngine;usingUnityEngine.SceneManagement;/// <summary>/// 接続にまつわるいろいろ/// </summary>publicclassCustomNetworkManager:NetworkManager{[SerializeField,Scene]privatestring_titleScene;[SerializeField,Scene]privatestring_mainScene;privateTransform_playerTransform;privateMaterial_playerMaterial;/// <summary>/// プレイヤー入室時にサーバー側が実行/// </summary>/// <param name="conn">接続されたプレイヤーのコネクション</param>publicoverridevoidOnServerAddPlayer(NetworkConnectionconn){Debug.Log("Add Player");//タイトルシーンでのみ実行if(_titleScene.Contains(SceneManager.GetActiveScene().name)){//接続中の人数表記を変えるSendPlayerCountDatasendData=newSendPlayerCountData(){PlayerCount=NetworkServer.connections.Count};NetworkServer.SendToAll(sendData);}//メインシーンでのみ実行if(_mainScene.Contains(SceneManager.GetActiveScene().name)){Debug.Log("Spawn Player");//プレイヤー生成GameObjectplayer=Instantiate(playerPrefab);//今立ち上げているサーバーにプレイヤーを追加登録NetworkServer.AddPlayerForConnection(conn,player);}}/// <summary>/// 各プレイヤー退室時にサーバー側が実行/// </summary>/// <param name="conn">切れたコネクション</param>publicoverridevoidOnServerDisconnect(NetworkConnectionconn){//接続中の人数表記を変えるSendPlayerCountDatasendData=newSendPlayerCountData(){PlayerCount=NetworkServer.connections.Count};NetworkServer.SendToAll(sendData);Debug.Log("Anyone Disconnect");base.OnServerDisconnect(conn);}/// <summary>/// サーバーとの接続が切れた時にクライアント側で呼ばれる/// </summary>publicoverridevoidOnStopClient(){SceneManager.LoadScene(_titleScene);Debug.Log("Disconnect");base.OnStopClient();}}NetworkManager
文字通りネットワークにまつわるいろいろを担います。
The Network Manager is a component for managing the networking aspects of a multiplayer game.
これもあまりそのまま使う想定のものではないので、
継承してメソッドをオーバーライドしてカスタムします。
コールバック含め、大量に機能があるので今回使ったものだけ解説します。
StartHost, StartClient, StopHost
接続にまつわる関数です。StartHostを実行した場合、サーバーとクライアントの両方の役割を持つことになります。
StartClientは引数に指定したアドレスのサーバーにクライアントとして接続します。
StopHostは自身がサーバーならサーバーの接続を中断し、
クライアントならサーバーから抜けます。
NetworkManagerはシングルトンとなっており、
インスタンスをどこからでも呼び出せます。
StartHost, StartClient, StopHostは全てPublicな関数なので、
これらもどこからでも呼び出せるってことです。
今回はサーバーの検索を担う、CustomNetworkDiscoveryで接続にまつわる関数を呼び出しています。
そうすることで、
・LAN内にサーバーが見つかったら→StartClient
・LAN内にサーバーが見つからなかったら→StartHost
のように同一LAN内で自動でマッチングする仕組みを作れます。
OnStopClient
クライアントがサーバーから切断された場合に各クライアントで呼び出されます。
このコールバックの中でシーン遷移を呼び出すことで
切断→シーン遷移 という処理が可能となります。
すなわち、接続状態にあるクライアントでStopHostを呼び出せば
下記処理が呼ばれるということです。
/// <summary>/// サーバーとの接続が切れた時にクライアント側で呼ばれる/// </summary>publicoverridevoidOnStopClient(){SceneManager.LoadScene(_titleScene);Debug.Log("Disconnect");base.OnStopClient();}OnServerAddPlayer
Mirrorにはプレイヤーという概念があります。
誤解を恐れずに簡単にまとめると
サーバーに接続したクライアントのことをプレイヤーと呼び、接続時にサーバーに追加されます。
このOnServerAddPlayerはプレイヤーが追加された際に呼び出される処理です。
デモにおける接続された人数の表記の変更の通知(プレイヤー増加時)はOnServerAddPlayerで行っています。
/// <summary>/// プレイヤー入室時にサーバー側が実行/// </summary>/// <param name="conn">接続されたプレイヤーのコネクション</param>publicoverridevoidOnServerAddPlayer(NetworkConnectionconn){Debug.Log("Add Player");//タイトルシーンでのみ実行if(_titleScene.Contains(SceneManager.GetActiveScene().name)){//接続中の人数表記を変えるSendPlayerCountDatasendData=newSendPlayerCountData(){PlayerCount=NetworkServer.connections.Count};NetworkServer.SendToAll(sendData);}//メインシーンでのみ実行if(_mainScene.Contains(SceneManager.GetActiveScene().name)){Debug.Log("Spawn Player");//プレイヤー生成GameObjectplayer=Instantiate(playerPrefab);//今立ち上げているサーバーにプレイヤーを追加登録NetworkServer.AddPlayerForConnection(conn,player);}}また、プレイヤーを概念ではなく、実体として生成する場合もあるかと思います。
その場合、OnServerAddPlayerでInstantiateしてあげれば
各クライアントにプレイヤーが生成されます。
ただし、この機能を利用するには
InspectorのPlayerPrefabにNetworkIdentityが付与されたPrefabを
事前に登録しておく必要があります。
最後に
詳しくは知りませんがUNETという機能?がひと昔前にあったそうで、
それを改良したのがMirrorのようです。
結構なビッグタイトルに採用されているようですが、
ドキュメント以外の情報がなかなか無いので苦労しました。
私の今の力では及びませんがサーバー側の実装とかもいずれできるようになりたいです。
(UniTaskの実装は見よう見真似でやったので間違ってたら教えてください。)
