MLAPI v0.1.0 Quick Start サンプルプロジェクトを作成しました

新 MLAPI 取り合えず触れてみよう!という方向けに,MLAPI v0.1.0 の Quick Start 作成しました.
リポジトリはこちらです.

github.com

検証環境

  • Unity 2020.3.1f1
  • MLAPI v0.1.0
  • MLAPI Community Contribution v12

元ネタ

元ネタは Mirror Quick Start です.

深い事を考えずにこちらの Mirror のサンプルスクリプトを MLAPI v0.1.0 に対応させています.
そのため密結合で無駄の多い部分やダーティコードな部分がありますが,そこは大目に見てください.
独自にカスタマイズしている部分もあります.Mirror Quick Start の Part16 ~ Part 19 はここでは省略しています.

今回はHosted (Host-Client) 接続方式のため,Server部分の実装はしていません.
黒河さんが先日新MLAPIに対応したUnityChanサンプルを公開しておりますので,
Serverを介するDGS接続方式の場合はこちらを参考ください.

unity2020.3ブランチの方ですので,cloneの際はご注意ください.

github.com

Part 1|インストール

MLAPI v0.1.0 は現在主に git URL add でインストールするのがメインになっています.

以下で Add package from git URL しましょう.
https://github.com/Unity-Technologies/com.unity.multiplayer.mlapi.git?path=/com.unity.multiplayer.mlapi

f:id:xrdnk:20210324082908p:plain

また,NetworkManagerHudコンポーネントを利用するため, MLAPI Commnuty Contributions の Extensions も Add します.

https://github.com/Unity-Technologies/mlapi-community-contributions.git?path=/com.mlapi.contrib.extensions

f:id:xrdnk:20210323224943p:plain

加えて,ParrelSync もインストールしましょう.
ParrelSync は Multiplayer 補助用のライブラリで,同じプロジェクトのエディタを複数広げることが出来,
エディタ上でのテストプレイの効率を早めることができます.以下で Add package from git URL します.

https://github.com/VeriorPies/ParrelSync.git?path=/ParrelSync

ParrelSync は以下の記事で説明しています.

xrdnk.hateblo.jp

Part 2|NetworkManager の設定

適当なシーンを作成します.
空オブジェクトを作成し,NetworkManagerと命名します.
その後,以下のコンポーネントを追加します.

NetworkTransport が None ということでワーニングが出ているので,
◎ボタンを押すと Transport 一覧がプルダウンで出ます.
今回はUNetTransportを利用します.UNetTransportを選択してください.

f:id:xrdnk:20210324230557p:plain

UNetTrasnport コンポーネントが自動的に追加されます.

Part 3|Static Environment の作成

Plane を作成し,Position (0, -1, 0),Scale (2, 2, 2) とします.
Plane の色を変えたい場合は各自で Matarial を作成してください.

Part 4|Player の作成

Capsule を作成し,NetworkPlayer と名付けます.
以下のコンポーネントを追加します.

f:id:xrdnk:20210324231521p:plain

作成後 Prefab 化し,Hierarchy から消します.

NetworkManager コンポーネントの NetworkPrefabs が現在「List is Empty」なので,
ここに先ほど作成したNetworkPlayerを追加します.
その後,Default Player Prefab にチェックを入れます.

f:id:xrdnk:20210324231834p:plain

Part 5|PlayerScript の作成

以下のように PlayerScript を作成し,NetworkPlayerオブジェクトに追加します.

using UnityEngine;
// NetworkBehaviour 継承に必要
using MLAPI;

namespace MLAPIQuickStart
{
    // MonoBehaviour ではなく NetworkBehaviour を継承する
    public class PlayerScript : NetworkBehaviour
    {
        private Transform _cameraTransform;
        private static readonly string HORIZONTAL = "Horizontal";
        private static readonly string VERTICAL = "Vertical";

        /// <summary>
        /// MLAPI の Setup が完了後に呼ばれるコールバック
        /// </summary>
        public override void NetworkStart()
        {
            _cameraTransform = Camera.main.transform;

            if (IsLocalPlayer)
            {
                transform.position = new Vector3(Random.Range(-5, 5), 0, Random.Range(-5, 5));
                _cameraTransform.transform.SetParent(transform);
                _cameraTransform.localPosition = new Vector3(0, 0, 0);
            }
        }

        private void Update()
        {
            // ローカルプレイヤーではない場合,以下の処理を走らせない
            if (!IsLocalPlayer)
            {
                return;
            }

            var moveX = Input.GetAxis(HORIZONTAL) * Time.deltaTime * 110.0f;
            var moveZ = Input.GetAxis(VERTICAL) * Time.deltaTime * 4f;

            transform.Rotate(0, moveX, 0);
            transform.Translate(0, 0, moveZ);
        }
    }
}

Part 6|Playerの動作確認

実行します.実行後に左上にHUDが出てくるので,Hostボタンを押下します.
一人称視点で動作できるのが確認できるかと思います.

gyazo.com

Part 7|ParrelSync でマルチプレイ確認

ParrelSync タブ > Clone Manager > Create new clone を押します.

f:id:xrdnk:20210324234640p:plain

実行完了後に,Clones Manager の画面は以下のように変わります.Open in New Editor を押します.

f:id:xrdnk:20210324234707p:plain

クローンプロジェクトを開いたら,オリジナルとクローン両方実行してみます.
片方がHostで入った後,片方でClientで入ります.二人現れれば成功です!

gyazo.com

Part 8|PlayerName の表示

NetworkPlayerプレハブの子として空オブジェクトを作成し,FloatingInfoと命名します.
Transform設定は以下のようにします.
f:id:xrdnk:20210325161701p:plain

FloatingInfoの子として3D Textを作成し,Text_PlayerNameと命名します.
Transformや3D Textの設定は以下のようにします.
f:id:xrdnk:20210325161708p:plain

Part 9|NetworkVariable と ServerRpc の利用

プレイヤーの名前と色を同期するために,同期変数 NetworkVariable を使います.
PlayerScript.cs にコードを追加します.

using UnityEngine;
// NetworkBehaviour 継承に必要
using MLAPI;
// RPCに必要
using MLAPI.Messaging;
// NetworkVariable 利用に必要
using MLAPI.NetworkVariable;
using Random = UnityEngine.Random;

namespace MLAPIQuickStart
{
    // MonoBehaviour ではなく NetworkBehaviour を継承する
    public class PlayerScript : NetworkBehaviour
    {
        [SerializeField, Tooltip("プレイヤー名のテキスト")]
        private TextMesh textPlayerName;

        [SerializeField,Tooltip("プレイヤー情報")]
        private GameObject floatingInfo;

        /// <summary>
        /// プレイヤーの色設定に利用するマテリアルのクローン
        /// </summary>
        private Material _playerMaterialClone;
        /// <summary>
        /// カメラのTransform
        /// </summary>
        private Transform _cameraTransform;

        #region Constants
        private static readonly string HORIZONTAL = "Horizontal";
        private static readonly string VERTICAL = "Vertical";
        private static readonly float MOVEX_COEFFICIENT = 110.0f;
        private static readonly float MOVEZ_COEFFICIENT = 4.0f;
        #endregion

        /// <summary>
        /// プレイヤー名の同期変数
        /// </summary>
        private readonly NetworkVariable<string> _networkPlayerName = new NetworkVariable<string>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});
        /// <summary>
        /// プレイヤー色の同期変数
        /// </summary>
        private readonly NetworkVariable<Color> _networkPlayerColor = new NetworkVariable<Color>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly}, Color.white);

        private void Awake()
        {
            // フック関数の設定
            _networkPlayerName.OnValueChanged += OnNameChanged;
            _networkPlayerColor.OnValueChanged += OnColorChanged;

            // カメラのTransformのキャッシュ
            _cameraTransform = Camera.main.transform;
        }

        private void Start()
        {
            // ローカルプレイヤーの場合,カメラを一人称視点に設定し,プレイヤー名のテキストを画面下に表示する
            if (IsOwner)
            {
                var thisTransform = transform;
                thisTransform.position = new Vector3(Random.Range(-5, 5), 0, Random.Range(-5, 5));
                _cameraTransform.transform.SetParent(thisTransform);
                _cameraTransform.localPosition = new Vector3(0, 0, 0);

                floatingInfo.transform.localPosition = new Vector3(0, -0.3f, 0.6f);
                floatingInfo.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f);

                var playerName = "Player" + Random.Range(100, 999);
                var color = new Color(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f));

                SubmitPlayerNameServerRpc(playerName);
                SubmitPlayerColorServerRpc(color);
            }
        }

        private void Update()
        {
            // ローカルプレイヤーではない場合,プレイヤー名のテキストをカメラに向けさせる
            if (!IsLocalPlayer)
            {
                floatingInfo.transform.LookAt(_cameraTransform);
                return;
            }

            // ローカルプレイヤーの場合,移動・回転処理を実行する
            Move();
        }

        private void OnDestroy()
        {
            _networkPlayerName.OnValueChanged -= OnNameChanged;
            _networkPlayerColor.OnValueChanged -= OnColorChanged;
        }

        /// <summary>
        /// プレイヤー名を設定する
        /// </summary>
        /// <param name="playerName">playerName</param>
        /// <param name="serverRpcParams"></param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerNameServerRpc(string playerName, ServerRpcParams serverRpcParams = default)
            => _networkPlayerName.Value = playerName;

        /// <summary>
        /// プレイヤー色を設定する
        /// </summary>
        /// <param name="playerColor">playerColor</param>
        /// <param name="serverRpcParams"></param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerColorServerRpc(Color playerColor, ServerRpcParams serverRpcParams = default)
            => _networkPlayerColor.Value = playerColor;

        /// <summary>
        /// プレイヤー名が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldName">前のプレイヤー名</param>
        /// <param name="newName">現在のプレイヤー名</param>
        private void OnNameChanged(string oldName, string newName)
            => textPlayerName.text = newName;

        /// <summary>
        /// プレイヤー色が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldColor">前のプレイヤー色</param>
        /// <param name="newColor">現在のプレイヤー色</param>
        private void OnColorChanged(Color oldColor, Color newColor)
        {
            textPlayerName.color = newColor;
            _playerMaterialClone = new Material(GetComponent<Renderer>().material) {color = newColor};
            GetComponent<Renderer>().material = _playerMaterialClone;
        }

        /// <summary>
        /// プレイヤーの移動・回転処理
        /// </summary>
        private void Move()
        {
            var moveX = Input.GetAxis(HORIZONTAL) * Time.deltaTime * MOVEX_COEFFICIENT;
            var moveZ = Input.GetAxis(VERTICAL) * Time.deltaTime * MOVEZ_COEFFICIENT;

            transform.Rotate(0, moveX, 0);
            transform.Translate(0, 0, moveZ);
        }
    }
}

Part 10|PlayerName/PlayerColor の同期確認

NetworkPlayer プレハブを開き,Inspector に FloatingInfo,Text_PlayerNameを当てはめます.
ParrelSync でプレイヤーの名前と色が同期されていることを確認しましょう.

gyazo.com

お互い同期できていればOKです!

Part 11|簡単なテキストチャットの表示

空オブジェクトを作成し,SceneScriptと命名します.
NetworkObjectコンポーネントをアタッチしてください.
(忘れるとNetworkBehaviourを継承したスクリプトコンポーネントのネットワーク同期処理がうまく行きません)

SceneScriptスクリプトコンポーネントを作成します.

using MLAPI;
using MLAPI.NetworkVariable;
using UnityEngine;
using UnityEngine.UI;

namespace MLAPIQuickStart
{
    public class SceneScript : NetworkBehaviour
    {
        [SerializeField, Tooltip("メッセージ表示用のテキスト")]
        private Text textMessage;
        [SerializeField, Tooltip("メッセージ送信用のボタン")]
        private Button buttonSendMessage;

        public PlayerScript PlayerScript { set => _playerScript = value; }
        private PlayerScript _playerScript;

        /// <summary>
        /// メッセージの同期変数
        /// </summary>
        private readonly NetworkVariable<string> _networkMessage = new NetworkVariable<string>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});

        private void Awake()
        {
            // フック関数の設定
            _networkMessage.OnValueChanged += OnMessageChanged;

            buttonSendMessage.onClick.AddListener(SendMessage);
        }

        private void OnDestroy()
        {
            buttonSendMessage.onClick.RemoveListener(SendMessage);
        }

        /// <summary>
        /// メッセージの設定
        /// </summary>
        /// <param name="message">テキストメッセージ</param>
        public void SetMessage(string message) => _networkMessage.Value = message;

        /// <summary>
        /// メッセージ内容が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldMessage">旧メッセージ</param>
        /// <param name="newMessage">新メッセージ</param>
        private void OnMessageChanged(string oldMessage, string newMessage) => textMessage.text = newMessage;

        /// <summary>
        /// メッセージの送信
        /// </summary>
        private void SendMessage()
        {
            if (_playerScript != null)
            {
                _playerScript.SubmitMessageServerRpc();
            }
        }
    }
}

PlayerScript スクリプトコンポーネントを更新します.

using UnityEngine;
// NetworkBehaviour 継承に必要
using MLAPI;
// RPCに必要
using MLAPI.Messaging;
// NetworkVariable 利用に必要
using MLAPI.NetworkVariable;
using Random = UnityEngine.Random;

namespace MLAPIQuickStart
{
    // MonoBehaviour ではなく NetworkBehaviour を継承する
    public class PlayerScript : NetworkBehaviour
    {
        [SerializeField, Tooltip("プレイヤー名のテキスト")]
        private TextMesh textPlayerName;

        [SerializeField,Tooltip("プレイヤー情報")]
        private GameObject floatingInfo;

        /// <summary>
        /// プレイヤーの色設定に利用するマテリアルのクローン
        /// </summary>
        private Material _playerMaterialClone;
        /// <summary>
        /// カメラのTransform
        /// </summary>
        private Transform _cameraTransform;

        private SceneScript _sceneScript;

        #region Constants
        private static readonly string HORIZONTAL = "Horizontal";
        private static readonly string VERTICAL = "Vertical";
        private static readonly float MOVEX_COEFFICIENT = 110.0f;
        private static readonly float MOVEZ_COEFFICIENT = 4.0f;
        #endregion

        /// <summary>
        /// プレイヤー名の同期変数
        /// </summary>
        private readonly NetworkVariable<string> _networkPlayerName = new NetworkVariable<string>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});
        /// <summary>
        /// プレイヤー色の同期変数
        /// </summary>
        private readonly NetworkVariable<Color> _networkPlayerColor = new NetworkVariable<Color>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly}, Color.white);

        private void Awake()
        {
            // フック関数の設定
            _networkPlayerName.OnValueChanged += OnNameChanged;
            _networkPlayerColor.OnValueChanged += OnColorChanged;

            // カメラのTransformのキャッシュ
            _cameraTransform = Camera.main.transform;

            _sceneScript = FindObjectOfType<SceneScript>();
        }

        private void Start()
        {
            // ローカルプレイヤーの場合,カメラを一人称視点に設定し,プレイヤー名のテキストを画面下に表示する
            if (IsOwner)
            {
                _sceneScript.PlayerScript = this;

                var thisTransform = transform;
                thisTransform.position = new Vector3(Random.Range(-5, 5), 0, Random.Range(-5, 5));
                _cameraTransform.transform.SetParent(thisTransform);
                _cameraTransform.localPosition = new Vector3(0, 0, 0);

                floatingInfo.transform.localPosition = new Vector3(0, -0.3f, 0.6f);
                floatingInfo.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f);

                var playerName = "Player" + Random.Range(100, 999);
                var color = new Color(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f));

                SubmitPlayerNameServerRpc(playerName);
                SubmitPlayerColorServerRpc(color);
                SubmitJoinedMessageServerRpc();
            }
        }

        private void Update()
        {
            // ローカルプレイヤーではない場合,プレイヤー名のテキストをカメラに向けさせる
            if (!IsLocalPlayer)
            {
                floatingInfo.transform.LookAt(_cameraTransform);
                return;
            }

            // ローカルプレイヤーの場合,移動・回転処理を実行する
            Move();
        }

        private void OnDestroy()
        {
            _networkPlayerName.OnValueChanged -= OnNameChanged;
            _networkPlayerColor.OnValueChanged -= OnColorChanged;
        }

        /// <summary>
        /// テキストチャットを送信する
        /// </summary>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        public void SubmitMessageServerRpc(ServerRpcParams serverRpcParams = default)
        {
            if (_sceneScript != null)
            {
                _sceneScript.SetMessage($"{_networkPlayerName.Value} says hello {Random.Range(10, 99)}");
            }
        }

        /// <summary>
        /// プレイヤー名を設定する
        /// </summary>
        /// <param name="playerName">playerName</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerNameServerRpc(string playerName, ServerRpcParams serverRpcParams = default)
            => _networkPlayerName.Value = playerName;

        /// <summary>
        /// プレイヤー色を設定する
        /// </summary>
        /// <param name="playerColor">playerColor</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerColorServerRpc(Color playerColor, ServerRpcParams serverRpcParams = default)
            => _networkPlayerColor.Value = playerColor;

        /// <summary>
        /// 入室時メッセージを表示する
        /// </summary>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitJoinedMessageServerRpc(ServerRpcParams serverRpcParams = default)
        {
            if (_sceneScript != null)
            {
                _sceneScript.SetMessage($"{_networkPlayerName.Value} joined");
            }
        }

        /// <summary>
        /// プレイヤー名が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldName">前のプレイヤー名</param>
        /// <param name="newName">現在のプレイヤー名</param>
        private void OnNameChanged(string oldName, string newName)
            => textPlayerName.text = newName;

        /// <summary>
        /// プレイヤー色が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldColor">前のプレイヤー色</param>
        /// <param name="newColor">現在のプレイヤー色</param>
        private void OnColorChanged(Color oldColor, Color newColor)
        {
            textPlayerName.color = newColor;
            _playerMaterialClone = new Material(GetComponent<Renderer>().material) {color = newColor};
            GetComponent<Renderer>().material = _playerMaterialClone;
        }

        /// <summary>
        /// プレイヤーの移動・回転処理
        /// </summary>
        private void Move()
        {
            var moveX = Input.GetAxis(HORIZONTAL) * Time.deltaTime * MOVEX_COEFFICIENT;
            var moveZ = Input.GetAxis(VERTICAL) * Time.deltaTime * MOVEZ_COEFFICIENT;

            transform.Rotate(0, moveX, 0);
            transform.Translate(0, 0, moveZ);
        }
    }
}

メッセージ表示用のTextオブジェクト,送信用のButtonオブジェクトを作成します.以下,参考.

f:id:xrdnk:20210326161650p:plain

f:id:xrdnk:20210326161723p:plain

作成後,InspectorでSceneScriptのTextとButtonを埋めます.

以下の動画のように,入室時に「PlayerXXX joined」が,
Send Message ボタン押下時に「Player XXX says hello XX」が同期表示されればOKです!

gyazo.com

Part 12|武器のスイッチ実装

武器のスイッチを実装してみます.
PlayerScriptコンポーネントを更新します.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
// NetworkBehaviour 継承に必要
using MLAPI;
// RPCに必要
using MLAPI.Messaging;
// NetworkVariable 利用に必要
using MLAPI.NetworkVariable;
using Random = UnityEngine.Random;

namespace MLAPIQuickStart
{
    // MonoBehaviour ではなく NetworkBehaviour を継承する
    public class PlayerScript : NetworkBehaviour
    {
        [SerializeField, Tooltip("プレイヤー名のテキスト")]
        private TextMesh textPlayerName;

        [SerializeField,Tooltip("プレイヤー情報")]
        private GameObject floatingInfo;

        [SerializeField, Tooltip("プレイヤーの武器リスト")]
        private List<GameObject> playerWeapons;

        /// <summary>
        /// プレイヤーの色設定に利用するマテリアルのクローン
        /// </summary>
        private Material _playerMaterialClone;
        /// <summary>
        /// カメラのTransform
        /// </summary>
        private Transform _cameraTransform;

        private SceneScript _sceneScript;

        private int _currentWeaponIndex;

        #region Constants
        private static readonly string HORIZONTAL = "Horizontal";
        private static readonly string VERTICAL = "Vertical";
        private static readonly float MOVEX_COEFFICIENT = 110.0f;
        private static readonly float MOVEZ_COEFFICIENT = 4.0f;
        #endregion

        /// <summary>
        /// プレイヤー名の同期変数
        /// </summary>
        private readonly NetworkVariable<string> _networkPlayerName = new NetworkVariable<string>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});
        /// <summary>
        /// プレイヤー色の同期変数
        /// </summary>
        private readonly NetworkVariable<Color> _networkPlayerColor = new NetworkVariable<Color>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly}, Color.white);
        /// <summary>
        /// 武器番号の同期変数
        /// </summary>
        private readonly NetworkVariable<int> _networkWeaponIndex = new NetworkVariable<int>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});

        private void Awake()
        {
            // フック関数の設定
            _networkPlayerName.OnValueChanged += OnNameChanged;
            _networkPlayerColor.OnValueChanged += OnColorChanged;
            _networkWeaponIndex.OnValueChanged += OnWeaponChanged;

            // カメラのTransformのキャッシュ
            _cameraTransform = Camera.main.transform;

            _sceneScript = FindObjectOfType<SceneScript>();

            // 武器は最初全て非表示にする
            foreach (var weapon in playerWeapons.Where(weapon => weapon != null))
            {
                weapon.SetActive(false);
            }
        }

        private void Start()
        {
            // ローカルプレイヤーの場合,カメラを一人称視点に設定し,プレイヤー名のテキストを画面下に表示する
            if (IsOwner)
            {
                _sceneScript.PlayerScript = this;

                var thisTransform = transform;
                thisTransform.position = new Vector3(Random.Range(-5, 5), 0, Random.Range(-5, 5));
                _cameraTransform.transform.SetParent(thisTransform);
                _cameraTransform.localPosition = new Vector3(0, 0, 0);

                floatingInfo.transform.localPosition = new Vector3(0, -0.3f, 0.6f);
                floatingInfo.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f);

                var playerName = "Player" + Random.Range(100, 999);
                var color = new Color(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f));

                SubmitPlayerNameServerRpc(playerName);
                SubmitPlayerColorServerRpc(color);
                SubmitJoinedMessageServerRpc();
            }
        }

        private void Update()
        {
            // ローカルプレイヤーではない場合,プレイヤー名のテキストをカメラに向けさせる
            if (!IsLocalPlayer)
            {
                floatingInfo.transform.LookAt(_cameraTransform);
                return;
            }

            // ローカルプレイヤーの場合,移動・回転処理を実行する
            Move();

            // 右クリックで武器変更処理を走らせる
            if (Input.GetButtonDown("Fire2"))
            {
                _currentWeaponIndex++;
                if (_currentWeaponIndex > playerWeapons.Count)
                {
                    _currentWeaponIndex = 0;
                }

                ChangeActiveWeaponServerRpc(_currentWeaponIndex);
            }
        }

        private void OnDestroy()
        {
            _networkPlayerName.OnValueChanged -= OnNameChanged;
            _networkPlayerColor.OnValueChanged -= OnColorChanged;
        }

        /// <summary>
        /// テキストチャットを送信する
        /// </summary>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        public void SubmitMessageServerRpc(ServerRpcParams serverRpcParams = default)
        {
            if (_sceneScript != null)
            {
                _sceneScript.SetMessage($"{_networkPlayerName.Value} says hello {Random.Range(10, 99)}");
            }
        }

        /// <summary>
        /// プレイヤー名を設定する
        /// </summary>
        /// <param name="playerName">playerName</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerNameServerRpc(string playerName, ServerRpcParams serverRpcParams = default)
            => _networkPlayerName.Value = playerName;

        /// <summary>
        /// プレイヤー色を設定する
        /// </summary>
        /// <param name="playerColor">playerColor</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerColorServerRpc(Color playerColor, ServerRpcParams serverRpcParams = default)
            => _networkPlayerColor.Value = playerColor;

        /// <summary>
        /// 入室時メッセージを表示する
        /// </summary>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitJoinedMessageServerRpc(ServerRpcParams serverRpcParams = default)
        {
            if (_sceneScript != null)
            {
                _sceneScript.SetMessage($"{_networkPlayerName.Value} joined");
            }
        }

        /// <summary>
        /// 武器インデックスを設定する
        /// </summary>
        /// <param name="index">武器インデックス</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void ChangeActiveWeaponServerRpc(int index, ServerRpcParams serverRpcParams = default) => _networkWeaponIndex.Value = index;

        /// <summary>
        /// プレイヤー名が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldName">前のプレイヤー名</param>
        /// <param name="newName">現在のプレイヤー名</param>
        private void OnNameChanged(string oldName, string newName)
            => textPlayerName.text = newName;

        /// <summary>
        /// プレイヤー色が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldColor">前のプレイヤー色</param>
        /// <param name="newColor">現在のプレイヤー色</param>
        private void OnColorChanged(Color oldColor, Color newColor)
        {
            textPlayerName.color = newColor;
            _playerMaterialClone = new Material(GetComponent<Renderer>().material) {color = newColor};
            GetComponent<Renderer>().material = _playerMaterialClone;
        }

        /// <summary>
        /// 武器インデックスが変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldIndex">前のインデックス</param>
        /// <param name="newIndex">現在のインデックス</param>
        private void OnWeaponChanged(int oldIndex, int newIndex)
        {
            if (0 < oldIndex && oldIndex < playerWeapons.Count && playerWeapons[oldIndex] != null)
            {
                playerWeapons[oldIndex].SetActive(false);
            }

            if (0 < newIndex && newIndex < playerWeapons.Count && playerWeapons[newIndex] != null)
            {
                playerWeapons[newIndex].SetActive(true);
            }
        }

        /// <summary>
        /// プレイヤーの移動・回転処理
        /// </summary>
        private void Move()
        {
            var moveX = Input.GetAxis(HORIZONTAL) * Time.deltaTime * MOVEX_COEFFICIENT;
            var moveZ = Input.GetAxis(VERTICAL) * Time.deltaTime * MOVEZ_COEFFICIENT;

            transform.Rotate(0, moveX, 0);
            transform.Translate(0, 0, moveZ);
        }
    }
}

Part 13|簡易的な武器オブジェクトの実装

NetworkPlayerプレハブを開きます.
NetworkPlayerの子として空オブジェクトを生成し,WeaponHolderと命名します.
WeaponHolderの子として,Cubeを生成し,Weapon1と命名します.
Weapon1を複製して,Weapon2と命名します.

Weapon1とWeapon2のTransformは以下を参考にしてください.

f:id:xrdnk:20210326171142p:plain

f:id:xrdnk:20210326171153p:plain

Part 14|武器オブジェクト変更処理の確認

Inspector で PlayerWeapons を設定します.
注意なのはここでは,Element 0 は None に設定してください.

f:id:xrdnk:20210326171320p:plain

動作確認をしてみます.

gyazo.com

武器の変更の同期が出来ていればOKです!

Part 15|SceneReference作成(微調整)

現在のコードのままでは,SceneScriptオブジェクトが非活性の際,Find処理に失敗します.
ここで少し微調整を行います.空オブジェクトを作成し,SceneReferenceと命名します.
以下,SceneReference スクリプトコンポーネントを作成します.

using UnityEngine;

namespace MLAPIQuickStart
{
    public class SceneReference : MonoBehaviour
    {
        [SerializeField] private SceneScript sceneScript;
        public SceneScript SceneScript => sceneScript;
    }
}

また,SceneScriptスクリプトコンポーネントで以下のコードを追加します.

        public SceneReference SceneReference { set => _sceneReference = value; }
        private SceneReference _sceneReference;

PlayerScriptスクリプトコンポーネントで以下のコードを変更します.

変更前

            _sceneScript = FindObjectOfType<SceneScript>();

変更後

            _sceneScript = GameObject.Find("SceneReference").GetComponent<SceneReference>().SceneScript;

デグレの確認をし,今までと同じ動作をしているかどうか確認してください.問題なければ続きます.

Part 16 ~ 19|シーン遷移

今回ここは省略します.

Part 20|武器の中身の作成

Weapon1の子として,WeaponFirePositionを作成します.Weapon2も同様にします.
WeaponFirePosition の Position.z は 0.5 くらいが丁度よいです.

Weapon スクリプトコンポーネントを作成し,Weapon1,Weapon2にアタッチします.

using UnityEngine;

namespace MLAPIQuickStart
{
    public class Weapon : MonoBehaviour
    {
        public float weaponSpeed = 15.0f;
        public float weaponLife = 3.0f;
        public float weaponCooldown = 1.0f;
        public int weaponAmmo = 15;

        public GameObject weaponBullet;
        public Transform weaponFirePosition;
    }
}

Part 21|銃弾の作成

銃弾を作成します.SphereとCubeを作成し,Scale全てを0.2にします.
それぞれにRigidbodyも忘れずに追加します.命名はWeaponBullet1,WeaponBullet2としました.
f:id:xrdnk:20210326180614p:plain

NetworkPlayerプレハブを開き,InspectorでWeaponの中身を埋めます.
f:id:xrdnk:20210326180507p:plain

Part 22|銃弾処理の確認

SceneScriptスクリプトコンポーネントを更新します.
弾数表示用のテキストと表示用のメソッドを追加します.

using MLAPI;
using MLAPI.NetworkVariable;
using UnityEngine;
using UnityEngine.UI;

namespace MLAPIQuickStart
{
    public class SceneScript : NetworkBehaviour
    {
        [SerializeField, Tooltip("メッセージ表示用のテキスト")]
        private Text textMessage;
        [SerializeField, Tooltip("メッセージ送信用のボタン")]
        private Button buttonSendMessage;
        [SerializeField, Tooltip("銃弾の弾数表示用のテキスト")]
        private Text textAmmo;

        public PlayerScript PlayerScript { set => _playerScript = value; }
        private PlayerScript _playerScript;

        public SceneReference SceneReference { set => _sceneReference = value; }
        private SceneReference _sceneReference;

        /// <summary>
        /// メッセージの同期変数
        /// </summary>
        private readonly NetworkVariable<string> _networkMessage = new NetworkVariable<string>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});

        private void Awake()
        {
            // フック関数の設定
            _networkMessage.OnValueChanged += OnMessageChanged;

            buttonSendMessage.onClick.AddListener(SendMessage);
        }

        private void OnDestroy()
        {
            buttonSendMessage.onClick.RemoveListener(SendMessage);
        }

        /// <summary>
        /// メッセージの設定
        /// </summary>
        /// <param name="message">テキストメッセージ</param>
        public void SetMessage(string message) => _networkMessage.Value = message;

        /// <summary>
        /// 銃弾の弾数を表示する
        /// </summary>
        /// <param name="value"></param>
        public void DisplayAmmo(int value) => textAmmo.text = $"Ammo: {value}";

        /// <summary>
        /// メッセージ内容が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldMessage">旧メッセージ</param>
        /// <param name="newMessage">新メッセージ</param>
        private void OnMessageChanged(string oldMessage, string newMessage) => textMessage.text = newMessage;

        /// <summary>
        /// メッセージの送信
        /// </summary>
        private void SendMessage()
        {
            if (_playerScript != null)
            {
                _playerScript.SubmitMessageServerRpc();
            }
        }
    }
}

適当にTextオブジェクトを作成し,Inspectorで textAmmo を埋めます.

次にPlayerScriptスクリプトコンポーネントを更新します.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
// NetworkBehaviour 継承に必要
using MLAPI;
// RPCに必要
using MLAPI.Messaging;
// NetworkVariable 利用に必要
using MLAPI.NetworkVariable;
using Random = UnityEngine.Random;

namespace MLAPIQuickStart
{
    // MonoBehaviour ではなく NetworkBehaviour を継承する
    public class PlayerScript : NetworkBehaviour
    {
        [SerializeField, Tooltip("プレイヤー名のテキスト")]
        private TextMesh textPlayerName;

        [SerializeField,Tooltip("プレイヤー情報")]
        private GameObject floatingInfo;

        [SerializeField, Tooltip("プレイヤーの武器リスト")]
        private List<GameObject> playerWeapons;

        /// <summary>
        /// プレイヤーの色設定に利用するマテリアルのクローン
        /// </summary>
        private Material _playerMaterialClone;
        /// <summary>
        /// カメラのTransform
        /// </summary>
        private Transform _cameraTransform;

        private SceneScript _sceneScript;

        /// <summary>
        /// 現在の武器インデックス
        /// </summary>
        private int _currentWeaponIndex;

        /// <summary>
        /// 現在の武器
        /// </summary>
        private Weapon _activeWeapon;

        /// <summary>
        /// 武器の攻撃間隔
        /// </summary>
        private float _weaponCooldownTime;

        #region Constants
        private static readonly string HORIZONTAL = "Horizontal";
        private static readonly string VERTICAL = "Vertical";
        private static readonly float MOVEX_COEFFICIENT = 110.0f;
        private static readonly float MOVEZ_COEFFICIENT = 4.0f;
        #endregion

        /// <summary>
        /// プレイヤー名の同期変数
        /// </summary>
        private readonly NetworkVariable<string> _networkPlayerName = new NetworkVariable<string>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});
        /// <summary>
        /// プレイヤー色の同期変数
        /// </summary>
        private readonly NetworkVariable<Color> _networkPlayerColor = new NetworkVariable<Color>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly}, Color.white);
        /// <summary>
        /// 武器番号の同期変数
        /// </summary>
        private readonly NetworkVariable<int> _networkWeaponIndex = new NetworkVariable<int>
            (new NetworkVariableSettings {WritePermission = NetworkVariablePermission.OwnerOnly});

        private void Awake()
        {
            // フック関数の設定
            _networkPlayerName.OnValueChanged += OnNameChanged;
            _networkPlayerColor.OnValueChanged += OnColorChanged;
            _networkWeaponIndex.OnValueChanged += OnWeaponChanged;

            // カメラのTransformのキャッシュ
            _cameraTransform = Camera.main.transform;

            _sceneScript = GameObject.Find("SceneReference").GetComponent<SceneReference>().SceneScript;

            // 武器は最初全て非表示にする
            foreach (var weapon in playerWeapons.Where(weapon => weapon != null))
            {
                weapon.SetActive(false);
            }

            // 現在の武器の設定と銃弾の弾数を表示する
            if (_currentWeaponIndex < playerWeapons.Count && playerWeapons[_currentWeaponIndex] != null)
            {
                _activeWeapon = playerWeapons[_currentWeaponIndex].GetComponent<Weapon>();
                _sceneScript.DisplayAmmo(_activeWeapon.weaponAmmo);
            }
        }

        private void Start()
        {
            // ローカルプレイヤーの場合,カメラを一人称視点に設定し,プレイヤー名のテキストを画面下に表示する
            if (IsOwner)
            {
                _sceneScript.PlayerScript = this;

                var thisTransform = transform;
                thisTransform.position = new Vector3(Random.Range(-5, 5), 0, Random.Range(-5, 5));
                _cameraTransform.transform.SetParent(thisTransform);
                _cameraTransform.localPosition = new Vector3(0, 0, 0);

                floatingInfo.transform.localPosition = new Vector3(0, -0.3f, 0.6f);
                floatingInfo.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f);

                var playerName = "Player" + Random.Range(100, 999);
                var color = new Color(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f));

                SubmitPlayerNameServerRpc(playerName);
                SubmitPlayerColorServerRpc(color);
                SubmitJoinedMessageServerRpc();
            }
        }

        private void Update()
        {
            // ローカルプレイヤーではない場合,プレイヤー名のテキストをカメラに向けさせる
            if (!IsLocalPlayer)
            {
                floatingInfo.transform.LookAt(_cameraTransform);
                return;
            }

            // ローカルプレイヤーの場合,移動・回転処理を実行する
            Move();

            // 右クリックで武器変更処理を走らせる
            if (Input.GetButtonDown("Fire2"))
            {
                _currentWeaponIndex++;
                if (_currentWeaponIndex > playerWeapons.Count)
                {
                    _currentWeaponIndex = 0;
                }

                ChangeActiveWeaponServerRpc(_currentWeaponIndex);
            }

            // 左クリックで発射処理を走らせる
            if (Input.GetButtonDown("Fire1"))
            {
                if (_activeWeapon && Time.time > _weaponCooldownTime && _activeWeapon.weaponAmmo > 0)
                {
                    _weaponCooldownTime = Time.time + _activeWeapon.weaponCooldown;
                    _activeWeapon.weaponAmmo--;
                    _sceneScript.DisplayAmmo(_activeWeapon.weaponAmmo);
                    FireWeaponServerRpc();
                }
            }
        }

        private void OnDestroy()
        {
            _networkPlayerName.OnValueChanged -= OnNameChanged;
            _networkPlayerColor.OnValueChanged -= OnColorChanged;
        }

        /// <summary>
        /// テキストチャットを送信する
        /// </summary>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        public void SubmitMessageServerRpc(ServerRpcParams serverRpcParams = default)
        {
            if (_sceneScript != null)
            {
                _sceneScript.SetMessage($"{_networkPlayerName.Value} says hello {Random.Range(10, 99)}");
            }
        }

        /// <summary>
        /// プレイヤー名を設定する
        /// </summary>
        /// <param name="playerName">playerName</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerNameServerRpc(string playerName, ServerRpcParams serverRpcParams = default)
            => _networkPlayerName.Value = playerName;

        /// <summary>
        /// プレイヤー色を設定する
        /// </summary>
        /// <param name="playerColor">playerColor</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitPlayerColorServerRpc(Color playerColor, ServerRpcParams serverRpcParams = default)
            => _networkPlayerColor.Value = playerColor;

        /// <summary>
        /// 入室時メッセージを表示する
        /// </summary>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void SubmitJoinedMessageServerRpc(ServerRpcParams serverRpcParams = default)
        {
            if (_sceneScript != null)
            {
                _sceneScript.SetMessage($"{_networkPlayerName.Value} joined");
            }
        }

        /// <summary>
        /// 武器インデックスを設定する
        /// </summary>
        /// <param name="index">武器インデックス</param>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void ChangeActiveWeaponServerRpc(int index, ServerRpcParams serverRpcParams = default) => _networkWeaponIndex.Value = index;

        /// <summary>
        /// サーバーへ発射処理を走らせる
        /// </summary>
        /// <param name="serverRpcParams">ServerRpcParams</param>
        [ServerRpc(RequireOwnership = true)]
        private void FireWeaponServerRpc(ServerRpcParams serverRpcParams = default) => FireWeaponClientRpc();

        /// <summary>
        /// 全クライアントへ発射処理を送る
        /// </summary>
        /// <param name="clientRpcParams">ClientRpcParams</param>
        [ClientRpc]
        private void FireWeaponClientRpc(ClientRpcParams clientRpcParams = default)
        {
            var bullet = Instantiate(_activeWeapon.weaponBullet, _activeWeapon.weaponFirePosition.position,
                _activeWeapon.weaponFirePosition.rotation);
            bullet.GetComponent<Rigidbody>().velocity = bullet.transform.forward * _activeWeapon.weaponSpeed;
            if (bullet)
            {
                Destroy(bullet, _activeWeapon.weaponLife);
            }
        }

        /// <summary>
        /// プレイヤー名が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldName">前のプレイヤー名</param>
        /// <param name="newName">現在のプレイヤー名</param>
        private void OnNameChanged(string oldName, string newName)
            => textPlayerName.text = newName;

        /// <summary>
        /// プレイヤー色が変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldColor">前のプレイヤー色</param>
        /// <param name="newColor">現在のプレイヤー色</param>
        private void OnColorChanged(Color oldColor, Color newColor)
        {
            textPlayerName.color = newColor;
            _playerMaterialClone = new Material(GetComponent<Renderer>().material) {color = newColor};
            GetComponent<Renderer>().material = _playerMaterialClone;
        }

        /// <summary>
        /// 武器インデックスが変更された時に呼ばれるフック関数
        /// </summary>
        /// <param name="oldIndex">前のインデックス</param>
        /// <param name="newIndex">現在のインデックス</param>
        private void OnWeaponChanged(int oldIndex, int newIndex)
        {
            if (0 < oldIndex && oldIndex < playerWeapons.Count && playerWeapons[oldIndex] != null)
            {
                playerWeapons[oldIndex].SetActive(false);
            }

            if (0 < newIndex && newIndex < playerWeapons.Count && playerWeapons[newIndex] != null)
            {
                playerWeapons[newIndex].SetActive(true);
                _activeWeapon = playerWeapons[_networkWeaponIndex.Value].GetComponent<Weapon>();
                if (IsLocalPlayer)
                {
                    _sceneScript.DisplayAmmo(_activeWeapon.weaponAmmo);
                }
            }
        }

        /// <summary>
        /// プレイヤーの移動・回転処理
        /// </summary>
        private void Move()
        {
            var moveX = Input.GetAxis(HORIZONTAL) * Time.deltaTime * MOVEX_COEFFICIENT;
            var moveZ = Input.GetAxis(VERTICAL) * Time.deltaTime * MOVEZ_COEFFICIENT;

            transform.Rotate(0, moveX, 0);
            transform.Translate(0, 0, moveZ);
        }
    }
}

gyazo.com

銃弾発射が同期できていればOKです!

終わりに

ここで終わりです.

何か漏れや誤りがあれば,ご連絡ください.
一部バグが残っていますが,ここでは軽微なので(ry