Mirrorを使ってWebGLなアプリからサーバに接続してみる

May 29, 2022

Unityでオンラインゲームを作成するためのオープンソースなライブラリであるMirrorを使って、WebGLでビルドしたアプリを複数ブラウザから立ち上げてサーバに同時接続できるかやってみました。

クライアントはブラウザで動かすのでWebGLでビルドしてWEBサーバ(apache)でホスティングし、サーバはLinuxで動かすためLinux向けにビルドして常時起動する想定です。サーバとクライアント間の通信はWebSocketで行います。

作成するアプリはudemyの「Unity Multiplayer」の講座の1つ(Multiplayer Basics)の教材であるサンプルアプリをベースにしています。内容としては、サーバにクライアントが接続すると玉が出現し、マウスを画面上の地面をクリックするとその場所に向かって玉が転がっていく、という単純なものです。

シーンに配置するコンポーネントは以下のようになっています。入れ子になっているコンポーネントはリストの階層で表現しています。

  • MyNetworkManager:MirrorのNetworkManagerクラスを継承したスクリプト。サーバ、クライアント間の接続を管理しています。例えば、クライアントがサーバに接続したらPlayerのプレファブからPlayerを生成してシーンに追加したり、Playerの色や名前を設定します。
    • Simple Web Transport:Websocketによる通信モジュールです。WebGLの場合はこちらを使用します。
    • NetworkManagerHud:サーバ、クライアントの起動UIです。WebGLでビルドした場合はサーバとして起動できませんので、今回はクライアントとしてのみ起動可能です。サーバのホスト名を指定することができます。
  • Player:3Dオブジェクト(玉)です。クライアントがサーバに接続すると、MyNetworkManagerがシーンにPlayerを配置します。
    • NetworkIdentity:各Playerを識別するために必要です。
    • MyNetworkPlayer:MirrorのNetworkBehaviorを継承したスクリプトです。Playerの色や名前を管理しています。SyncVarとして変数を定義することにより各クライアントでその変数の値を同期できます。MyNetworkPlayerではPlayerの色、名前をSyncVarとして定義しています。
    • NetworkTransform:Playerの位置、向きなどを各クライアントで同期するために必要です。
    • NavMeshAgent:Playerをナビメッシュで動かすために必要です。今回使用したエージェントはHumanoid型で縦長なのでPlayerの高さにあわせてエージェントの高さを調整しました。
    • PlayerMovement:MirrorのNetworkBehaviorを継承したスクリプトです。Playerの移動を管理しています。ナビメッシュの機能を使って、Playerをマウスで右クリックした位置に自動的に移動させます。ナビメッシュの機能により障害物も勝手に回避してくれます。
  • Box、Wall:staticな3Dオブジェクト。NavigationタブのBakeボタンを押してナビメッシュを作成できます。なお、配置を変えたら毎回Bakeボタンを押す必要があるようです。
  • SpawnPoint:Playerの出現位置を定義するための空オブジェクトです。
    • NetworkStartPosition:このコンポーネントを持たせることにより、Playerの出現位置としてNetworkManagerが認識してくれます。複数ある場合はランダムまたはラウンドロビンで選択されます(NetworkManagerコンポーネントで設定可能)。

ほぼunityの講座のものをそのまま流用したのですが、今回はWebGLを使用するため、ネットワークのトランスポートについてはSimple Web Transportに変更しています。設定の内容としては以下のようになります。ほぼデフォルトなのですが、サーバはSSLでしか接続できないため、Ssl EnabledとClient Use Wssはいずれも有効にしました。

次に各スクリプトの内容をのせておきます。

MyNetworkManagerは以下です。OnServerAddPlayer(クライアントによりPlayerが追加される際に呼ばれる)でPlayerの名前と色をセットしていますが、実際の処理(値の変更とPlayerコンポーネントへの反映)はMyNetworkPlayerで実装されています。

using System.Collections;
using System.Collections.Generic;
using Mirror;
using UnityEngine;

public class MyNetworkManager : NetworkManager
{
    public override void OnClientConnect()
    {
        base.OnClientConnect();
        Debug.Log("Client connected!");
    }

    public override void OnServerAddPlayer(NetworkConnectionToClient conn)
    {
        base.OnServerAddPlayer(conn);

        MyNetworkPlayer player = conn.identity.GetComponent<MyNetworkPlayer>();
        player.SetDisplayName($"Player {numPlayers}");
        player.SetDisplayColor(new Color(Random.Range(0f,1f),Random.Range(0f,1f),Random.Range(0f,1f)));
        Debug.Log($"There are now {numPlayers} players!");
    }
}

MyNetworkPlayerは以下です。[Server]はサーバしか実行できないメソッド、[Command]はクライアントから呼び出されてサーバで実行するメソッドです。また、SyncVarのhookで値が更新されたときに呼ばれるメソッドを指定しています。これらによって、Playerが追加されたときの色、名前の設定が実行されています。

using System.Collections;
using System.Collections.Generic;
using Mirror;
using TMPro;
using UnityEngine;

public class MyNetworkPlayer : NetworkBehaviour
{
    [SerializeField] private TMP_Text displayNameText = null;
    [SerializeField] private Renderer displayColorRenderer = null;

    [SyncVar(hook =nameof(HandleDisplayNameTextUpdated))]
    [SerializeField]
    private string displayName = "Missing Name";

    [SyncVar(hook=nameof(HandleDisplayColorUpdated))]
    [SerializeField]
    private Color displayColor = Color.black;

    #region Server

    [Server]
    public void SetDisplayName(string newDisplayName)
    {
        displayName = newDisplayName;
    }

    [Server]
    public void SetDisplayColor(Color newDisplayColor)
    {
        displayColor = newDisplayColor;
    }

    // クライアントから呼び出されてサーバで実行される
    [Command]
    private void CmdSetDisplayName(string newDisplayName)
    {
        RpcWriteLog(newDisplayName);
        if(newDisplayName.Length < 2 || newDisplayName.Length > 20) {return;}
        SetDisplayName(newDisplayName);
    }

    #endregion

    #region Client
    private void HandleDisplayColorUpdated(Color oldColor, Color newColor)
    {
        displayColorRenderer.material.SetColor("_Color", newColor);
    }

    private void HandleDisplayNameTextUpdated(string oldString, string newString)
    {
        displayNameText.text = newString;
    }

    // MyNetworkPlayerを右クリックするとメニューにこの文字列が出る
    [ContextMenu("Set My Name")]
    private void SetMyName()
    {
        CmdSetDisplayName("My New Name");
    }

    // サーバから呼び出されると全てのクライアントで実行される
    [ClientRpc]
    private void RpcWriteLog(string msg)
    {
        Debug.Log(msg);
    }
    
    #endregion
}

PlayerMovementは以下です。agentには前述のNavMeshAgentコンポーネントをUnity上でアタッチします。[ClientCallback]は[Client]と同じで、クライアントしか呼べないメソッドです。サーバから呼ばれても前者はWarningを出さないという違いがあるようです。クライアントでUpdateが呼ばれたら右クリックされているかをチェックし、クリックされていればその位置に移動するようにサーバにCmdMoveを実行させてます。

using System.Collections;
using System.Collections.Generic;
using Mirror;
using UnityEngine;
using UnityEngine.AI;

public class PlayerMovement : NetworkBehaviour
{
    [SerializeField] private NavMeshAgent agent = null;

    private Camera mainCamera;

    #region Server

    [Command]
    private void CmdMove(Vector3 position)
    {
        if(!NavMesh.SamplePosition(position, out NavMeshHit hit, 1f, NavMesh.AllAreas)) {return;}
        agent.SetDestination(hit.position);
    }

    #endregion

    #region Client

    public override void OnStartAuthority()
    {
        mainCamera = Camera.main;

    }

    [ClientCallback]
    private void Update() 
    {
        if(!hasAuthority) { return;}
        if(!Input.GetMouseButtonDown(1)){return;}

        Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);

        if(!Physics.Raycast(ray, out RaycastHit hit, Mathf.Infinity)) {return;}

        CmdMove(hit.point);
    }

    #endregion
}

次はビルド時の設定です。

サーバビルドは以下のように設定してビルドしました。

サーバのビルド設定

WebGL(クライアント)は以下のように設定してビルドしました。

クライアントのビルド設定

クライアントの方はapacheでホスティングしました。こちらのサイトを参考にさせていただきましたが、特に躓くことはありませんでした。ビルドして生成されたフォルダ一式をコピーしてそのルートにこのページのサンプルの.htaccessをコピーするだけです。

サーバは基本、上でビルドして生成されたフォルダーをサーバにコピーして、server.x86_64を実行すればよいですが、SSLを有効にしていますので、pfx形式の証明書も必要になります。今回は、Let’s Encryptで生成したファイルからこちらのページにある手順でpfxに変換して使用しました。

実際にブラウザで実行している様子が以下の画像です。3つのタブから同時接続していますが、問題なく3つともプレイヤーが表示されていますし、マウスをクリックするとスムーズに動いてくれました。レスポンスも特に悪くはなかったのでWebSocketでも今回のように同期の頻度が少ないアプリなら問題なさそうです。

WebGLのクライアントの画面