Multi Player

Unity Starter Asset Third Personを、Netcodeを使用してマルチプレーヤーに変更する。

使用バージョン
Unity EditorUnity 2022LTS2022.3.11f1
Project Template3D(URP)コア
Package (Unity Registry)Netcode for Game Objects1.6.0
Asset StoreStarter Assets - Third Person1.1.5

3D(URP)コア テンプレートで新規プロジェクトを作成.

Netcodeをパッケージマネージャーからインストール。

スターターアセットを、UnityアセットストアからMyAssetに追加してインストール。

フォルダーを作成

プロジェクト>Assetsフォルダにフォルダーを新規作成する。
  • Scripts
  • Prefabs

  • スクリプトの作成

    ScriptsフォルダにC#スクリプトを新規作成して、 ClientNetworkTransform、ClientNetworkAnimatorを定義する。
    これらは、それぞれNetworkTransform、NetworkAnimatorを継承しつつ、 OnIsServerAuthoritative()でfalseを返す。
    マルチプレイにおいて、クライアント側で操作するオブジェクトの動きは、サーバーに依頼を出して サーバー側で動かした結果をクライアント側に同期させることで、クライアント側画面で動く。
    OnIsServerAuthoritative()をオーバーライドしてfalseを返すと、サーバーではなくOwnerに動きの権限が移る。
    クライアント側でモデルをOwnerとして動かした後でその結果をサーバーに送り、 他のプレーヤーのシーンに同期させる。
    メリットは、サーバー依頼のコーディングが不要。自分の操作がローカルで反映されるのでラグが気にならない。
    デメリットは、自分の見ている画面とサーバー側で行っている画面にズレが生じ不可思議な判定結果になりえる。

    ClientNetworkTransform.cs
    
    using System.Collections;
    using System.Collections.Generic;
    using Unity.Netcode.Components;
    using UnityEngine;
    
    public class ClientNetworkTransform : NetworkTransform
    {
        protected override bool OnIsServerAuthoritative()
        {
            return false;
        }
    
    }
    

    ClientNetworkAnimator.cs
    
    using System.Collections;
    using System.Collections.Generic;
    using Unity.Netcode.Components;
    using UnityEngine;
    
    public class ClientNetworkAnimator : NetworkAnimator
    {
        protected override bool OnIsServerAuthoritative()
        {
            return false;
        }
    }
    

    ネットワークマネージャーの作動UI

    マルチプレイでは、プレーヤーの端末として入出力を受けるクライアントプログラムと、 クライアントから依頼を受けワールド全体を動かすサーバーが必要になる。
    クライアントとしてプレイに参加しながら、サーバーの役目も負うのがホストとなる。
    ひとつのプログラムでモードを切り替えて使えるよう、モード切替の追加が必要。

    マルチプレイのため複数アプリ間で通信をするネットワークマネージャーの作動モードを切替える。
    Unity Editor上はインスペクターに切り替えボタンがあるが、これをUIで追加する。
    マルチプレイ役割モード設定作動状態チェック
    ネットワークマネージャー役割IsHostIsClientIsServer
    未起動falsefalsefalse
    Start Clientプレイメンバとして参加falsetruefalse
    Start ServerServerとして参加falsefalsetrue
    Start HostServerかつメンバとして参加truetruetrue


    UIManager.cs
    
    using Unity.Netcode;
    using UnityEngine;
    
    public class UIManager : MonoBehaviour
    {
        private void Start()
        {
    #if UNITY_SERVER
    //ヘッドレスサーバーで動く場合でも起動と同時にネットワークマネージャーをスタート
            NetworkManager.Singleton.StartServer();
    #endif
        }
    
        void OnGUI()
        {
            GUILayout.BeginArea(new Rect(10, 10, 300, 300));
            if (!NetworkManager.Singleton.IsClient && !NetworkManager.Singleton.IsServer)
            {
                StartButtons();
            }
            else
            {
                StatusLabels();
            }
    
            GUILayout.EndArea();
        }
    
        static void StartButtons()
        {
            if (GUILayout.Button("Quit")) QuitApp();
            if (GUILayout.Button("Server")) NetworkManager.Singleton.StartServer();
            if (GUILayout.Button("Host")) NetworkManager.Singleton.StartHost();
            if (GUILayout.Button("Client")) NetworkManager.Singleton.StartClient();
        }
    
        static void StatusLabels()
        {
            if (GUILayout.Button("Quit")) QuitApp();
            var mode = NetworkManager.Singleton.IsHost ?
                "Host" : NetworkManager.Singleton.IsServer ? "Server" : "Client";
    
            GUILayout.Label("Transport: " +
                NetworkManager.Singleton.NetworkConfig.NetworkTransport.GetType().Name);
            GUILayout.Label("Mode: " + mode);
    
        }
        static void QuitApp()
        {
            Application.Quit(); //このアプリを終了させる。
    #if UNITY_EDITOR //Unityエディターで動いているときに限定 ここから
            UnityEditor.EditorApplication.isPlaying = false; //プレビュー再生をOffにする。
    #endif //Unityエディターで動いているときに限定 ここまで
        }
    }
    
    

    PlayGroundシーンの読み込み

    Assets>StarterAssets>ThirdPersonController>Scenes>PlayGround.unity


    PlayArmatureプレファブの複製

    サーバーが同期を取るオブジェクトには、NetworkObjectコンポーネントを備えたプレファブとして登録しておく必要がある。
    プレーヤーとなる、PlayerArmatureプレファブをヒエラルキーからAssets>Prefabsにドラッグして、 オリジナルプレファブを作成する。名前をNetworkPlayerArmatureに変更。


    ヒエラルキーにあるPlayerArmatureプレファブは削除する。


    Assets>PrefabsフォルダーのNetworkPlayerArmatureプレファブを、ダブルクリックしてプレファブウィンドウで開く。


    Player InputコンポーネントをOFF
    Network Objectコンポーネントを追加
    Client Network Transformコンポーネントを追加
    Client Network Animatorコンポーネントを追加
    アニメーターをリストから自分を選んで追加。
    プレファブウィンドウを閉じる。(”<”でシーン編集に戻る)


    Assets>Prefabsフォルダで、Create>Netcode>Network Prefab List


    作成したNetwork Prefab Listをダブルクリックして、NetworkPlayerArmatureをリストに追加


    ヒエラルキーで空のオブジェクトを2つ作成。


    名前をNetworkManager、UIManagerにして、それぞれ同じ名前のコンポーネントを追加。


    オブジェクト名=NetworkManager
    プレイヤープレファブ=NetworkPlayerArmature
    ネットワークプレファブリスト=Network Prefab List
    Select Transport =Unity Transport




    ThirdPersonController.csをマルチプレイ対応に修正

    Assets>PrefabsのNetworkPlayerArmatureプレファブをダブルクリックしてインスペクターで開く。
    ThirdPersonController.csをダブルクリックしてVisualStudioで開く。


    NetworkPlayerArmatureは、ネットワークマネージャーが出現させるので、 シーンの中でのカメラやインプットの対応設定ができていない。
    出現時に、カメラやインプットを接続する。
    ClientNetworkTransformで、クライアント側でモデルを動かして結果をサーバーに送って同期するので、 クライアント側でのキャラクター操作の部分はシングルプレーヤーのまま使える。

    修正箇所
  • using Unity.Netcode;の追加
  • 継承クラスをMonoBehaviourからNetworkBehaviourに変更
  • private CinemachineVirtualCamera _CinemachineVirtualCamera;キャッシュ変数の追加
  • private GameObject _PlayerCameraRoot;キャッシュ用変数の追加
  • Awake()で、シーンにあるCinemachineVirtualCameraを取得
  • Start()で、_playerInputの取得をコメント化
  • OnNetworkSpawn()をオーバーライドして作成、BASE処理を行った後に、 現在クライアントでありオブジェクトのオーナである場合のみ_playerInputを取得
  • 同時に自分のオブジェクトの子供のカメラターゲットを取得しフォローカメラへセット

  • ThirdPersonController.cs 修正後
    
    using Cinemachine;
    using UnityEngine;
    using Unity.Netcode;
    
    #if ENABLE_INPUT_SYSTEM 
    using UnityEngine.InputSystem;
    #endif
    
    /* Note: animations are called via the controller for both the character and capsule using animator null checks
     */
    
    namespace StarterAssets
    {
        [RequireComponent(typeof(CharacterController))]
    #if ENABLE_INPUT_SYSTEM 
        [RequireComponent(typeof(PlayerInput))]
    #endif
        public class ThirdPersonController : NetworkBehaviour
        {
            [Header("Player")]
            [Tooltip("Move speed of the character in m/s")]
            public float MoveSpeed = 2.0f;
    
            [Tooltip("Sprint speed of the character in m/s")]
            public float SprintSpeed = 5.335f;
    
            [Tooltip("How fast the character turns to face movement direction")]
            [Range(0.0f, 0.3f)]
            public float RotationSmoothTime = 0.12f;
    
            [Tooltip("Acceleration and deceleration")]
            public float SpeedChangeRate = 10.0f;
    
            public AudioClip LandingAudioClip;
            public AudioClip[] FootstepAudioClips;
            [Range(0, 1)] public float FootstepAudioVolume = 0.5f;
    
            [Space(10)]
            [Tooltip("The height the player can jump")]
            public float JumpHeight = 1.2f;
    
            [Tooltip("The character uses its own gravity value. The engine default is -9.81f")]
            public float Gravity = -15.0f;
    
            [Space(10)]
            [Tooltip("Time required to pass before being able to jump again. Set to 0f to instantly jump again")]
            public float JumpTimeout = 0.50f;
    
            [Tooltip("Time required to pass before entering the fall state. Useful for walking down stairs")]
            public float FallTimeout = 0.15f;
    
            [Header("Player Grounded")]
            [Tooltip("If the character is grounded or not. Not part of the CharacterController built in grounded check")]
            public bool Grounded = true;
    
            [Tooltip("Useful for rough ground")]
            public float GroundedOffset = -0.14f;
    
            [Tooltip("The radius of the grounded check. Should match the radius of the CharacterController")]
            public float GroundedRadius = 0.28f;
    
            [Tooltip("What layers the character uses as ground")]
            public LayerMask GroundLayers;
    
            [Header("Cinemachine")]
            [Tooltip("The follow target set in the Cinemachine Virtual Camera that the camera will follow")]
            public GameObject CinemachineCameraTarget;
    
            [Tooltip("How far in degrees can you move the camera up")]
            public float TopClamp = 70.0f;
    
            [Tooltip("How far in degrees can you move the camera down")]
            public float BottomClamp = -30.0f;
    
            [Tooltip("Additional degress to override the camera. Useful for fine tuning camera position when locked")]
            public float CameraAngleOverride = 0.0f;
    
            [Tooltip("For locking the camera position on all axis")]
            public bool LockCameraPosition = false;
    
            // cinemachine
            private float _cinemachineTargetYaw;
            private float _cinemachineTargetPitch;
    
            // player
            private float _speed;
            private float _animationBlend;
            private float _targetRotation = 0.0f;
            private float _rotationVelocity;
            private float _verticalVelocity;
            private float _terminalVelocity = 53.0f;
    
            // timeout deltatime
            private float _jumpTimeoutDelta;
            private float _fallTimeoutDelta;
    
            // animation IDs
            private int _animIDSpeed;
            private int _animIDGrounded;
            private int _animIDJump;
            private int _animIDFreeFall;
            private int _animIDMotionSpeed;
    
    #if ENABLE_INPUT_SYSTEM 
            private PlayerInput _playerInput;
    #endif
            private Animator _animator;
            private CharacterController _controller;
            private StarterAssetsInputs _input;
            private GameObject _mainCamera;
            private GameObject _PlayerCameraRoot;
            private CinemachineVirtualCamera _CinemachineVirtualCamera;
    
            private const float _threshold = 0.01f;
    
            private bool _hasAnimator;
    
            private bool IsCurrentDeviceMouse
            {
                get
                {
    #if ENABLE_INPUT_SYSTEM
                    return _playerInput.currentControlScheme == "KeyboardMouse";
    #else
    				return false;
    #endif
                }
            }
    
    
            private void Awake()
            {
                // get a reference to our main camera
                if (_mainCamera == null)
                {
                    _mainCamera = GameObject.FindGameObjectWithTag("MainCamera");
                }
                if(_CinemachineVirtualCamera == null)
                {
                    _CinemachineVirtualCamera = FindObjectOfType();
                }
            }
    
            private void Start()
            {
                _cinemachineTargetYaw = CinemachineCameraTarget.transform.rotation.eulerAngles.y;
                
                _hasAnimator = TryGetComponent(out _animator);
                _controller = GetComponent();
                _input = GetComponent();
    
    //#if ENABLE_INPUT_SYSTEM 
    //            _playerInput = GetComponent();
    //#else
    //			Debug.LogError( "Starter Assets package is missing dependencies. Please use Tools/Starter Assets/Reinstall Dependencies to fix it");
    //#endif
    
                AssignAnimationIDs();
    
                // reset our timeouts on start
                _jumpTimeoutDelta = JumpTimeout;
                _fallTimeoutDelta = FallTimeout;
            }
    
            public override void OnNetworkSpawn()
            {
                base.OnNetworkSpawn();
    
                if(IsClient && IsOwner)
                {
                    _playerInput = GetComponent();
                    _playerInput.enabled = true;
                    _PlayerCameraRoot = transform.Find("PlayerCameraRoot").gameObject;
                    _CinemachineVirtualCamera.Follow = _PlayerCameraRoot.transform;
                }
    
            }
    
    
            private void Update()
            {
                if (IsOwner)
                { 
                    _hasAnimator = TryGetComponent(out _animator);
    
                    JumpAndGravity();
                    GroundedCheck();
                    Move();
                }
            }
    
            private void LateUpdate()
            {
                CameraRotation();
            }
    
            private void AssignAnimationIDs()
            {
                _animIDSpeed = Animator.StringToHash("Speed");
                _animIDGrounded = Animator.StringToHash("Grounded");
                _animIDJump = Animator.StringToHash("Jump");
                _animIDFreeFall = Animator.StringToHash("FreeFall");
                _animIDMotionSpeed = Animator.StringToHash("MotionSpeed");
            }
    
            private void GroundedCheck()
            {
                // set sphere position, with offset
                Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y - GroundedOffset,
                    transform.position.z);
                Grounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers,
                    QueryTriggerInteraction.Ignore);
    
                // update animator if using character
                if (_hasAnimator)
                {
                    _animator.SetBool(_animIDGrounded, Grounded);
                }
            }
    
            private void CameraRotation()
            {
                // if there is an input and camera position is not fixed
                if (_input.look.sqrMagnitude >= _threshold && !LockCameraPosition)
                {
                    //Don't multiply mouse input by Time.deltaTime;
                    float deltaTimeMultiplier = IsCurrentDeviceMouse ? 1.0f : Time.deltaTime;
    
                    _cinemachineTargetYaw += _input.look.x * deltaTimeMultiplier;
                    _cinemachineTargetPitch += _input.look.y * deltaTimeMultiplier;
                }
    
                // clamp our rotations so our values are limited 360 degrees
                _cinemachineTargetYaw = ClampAngle(_cinemachineTargetYaw, float.MinValue, float.MaxValue);
                _cinemachineTargetPitch = ClampAngle(_cinemachineTargetPitch, BottomClamp, TopClamp);
    
                // Cinemachine will follow this target
                CinemachineCameraTarget.transform.rotation = Quaternion.Euler(_cinemachineTargetPitch + CameraAngleOverride,
                    _cinemachineTargetYaw, 0.0f);
            }
    
            private void Move()
            {
                // set target speed based on move speed, sprint speed and if sprint is pressed
                float targetSpeed = _input.sprint ? SprintSpeed : MoveSpeed;
    
                // a simplistic acceleration and deceleration designed to be easy to remove, replace, or iterate upon
    
                // note: Vector2's == operator uses approximation so is not floating point error prone, and is cheaper than magnitude
                // if there is no input, set the target speed to 0
                if (_input.move == Vector2.zero) targetSpeed = 0.0f;
    
                // a reference to the players current horizontal velocity
                float currentHorizontalSpeed = new Vector3(_controller.velocity.x, 0.0f, _controller.velocity.z).magnitude;
    
                float speedOffset = 0.1f;
                float inputMagnitude = _input.analogMovement ? _input.move.magnitude : 1f;
    
                // accelerate or decelerate to target speed
                if (currentHorizontalSpeed < targetSpeed - speedOffset ||
                    currentHorizontalSpeed > targetSpeed + speedOffset)
                {
                    // creates curved result rather than a linear one giving a more organic speed change
                    // note T in Lerp is clamped, so we don't need to clamp our speed
                    _speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * inputMagnitude,
                        Time.deltaTime * SpeedChangeRate);
    
                    // round speed to 3 decimal places
                    _speed = Mathf.Round(_speed * 1000f) / 1000f;
                }
                else
                {
                    _speed = targetSpeed;
                }
    
                _animationBlend = Mathf.Lerp(_animationBlend, targetSpeed, Time.deltaTime * SpeedChangeRate);
                if (_animationBlend < 0.01f) _animationBlend = 0f;
    
                // normalise input direction
                Vector3 inputDirection = new Vector3(_input.move.x, 0.0f, _input.move.y).normalized;
    
                // note: Vector2's != operator uses approximation so is not floating point error prone, and is cheaper than magnitude
                // if there is a move input rotate player when the player is moving
                if (_input.move != Vector2.zero)
                {
                    _targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg +
                                      _mainCamera.transform.eulerAngles.y;
                    float rotation = Mathf.SmoothDampAngle(transform.eulerAngles.y, _targetRotation, ref _rotationVelocity,
                        RotationSmoothTime);
    
                    // rotate to face input direction relative to camera position
                    transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
                }
    
    
                Vector3 targetDirection = Quaternion.Euler(0.0f, _targetRotation, 0.0f) * Vector3.forward;
    
                // move the player
                _controller.Move(targetDirection.normalized * (_speed * Time.deltaTime) +
                                 new Vector3(0.0f, _verticalVelocity, 0.0f) * Time.deltaTime);
    
                // update animator if using character
                if (_hasAnimator)
                {
                    _animator.SetFloat(_animIDSpeed, _animationBlend);
                    _animator.SetFloat(_animIDMotionSpeed, inputMagnitude);
                }
            }
    
            private void JumpAndGravity()
            {
                if (Grounded)
                {
                    // reset the fall timeout timer
                    _fallTimeoutDelta = FallTimeout;
    
                    // update animator if using character
                    if (_hasAnimator)
                    {
                        _animator.SetBool(_animIDJump, false);
                        _animator.SetBool(_animIDFreeFall, false);
                    }
    
                    // stop our velocity dropping infinitely when grounded
                    if (_verticalVelocity < 0.0f)
                    {
                        _verticalVelocity = -2f;
                    }
    
                    // Jump
                    if (_input.jump && _jumpTimeoutDelta <= 0.0f)
                    {
                        // the square root of H * -2 * G = how much velocity needed to reach desired height
                        _verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);
    
                        // update animator if using character
                        if (_hasAnimator)
                        {
                            _animator.SetBool(_animIDJump, true);
                        }
                    }
    
                    // jump timeout
                    if (_jumpTimeoutDelta >= 0.0f)
                    {
                        _jumpTimeoutDelta -= Time.deltaTime;
                    }
                }
                else
                {
                    // reset the jump timeout timer
                    _jumpTimeoutDelta = JumpTimeout;
    
                    // fall timeout
                    if (_fallTimeoutDelta >= 0.0f)
                    {
                        _fallTimeoutDelta -= Time.deltaTime;
                    }
                    else
                    {
                        // update animator if using character
                        if (_hasAnimator)
                        {
                            _animator.SetBool(_animIDFreeFall, true);
                        }
                    }
    
                    // if we are not grounded, do not jump
                    _input.jump = false;
                }
    
                // apply gravity over time if under terminal (multiply by delta time twice to linearly speed up over time)
                if (_verticalVelocity < _terminalVelocity)
                {
                    _verticalVelocity += Gravity * Time.deltaTime;
                }
            }
    
            private static float ClampAngle(float lfAngle, float lfMin, float lfMax)
            {
                if (lfAngle < -360f) lfAngle += 360f;
                if (lfAngle > 360f) lfAngle -= 360f;
                return Mathf.Clamp(lfAngle, lfMin, lfMax);
            }
    
            private void OnDrawGizmosSelected()
            {
                Color transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
                Color transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);
    
                if (Grounded) Gizmos.color = transparentGreen;
                else Gizmos.color = transparentRed;
    
                // when selected, draw a gizmo in the position of, and matching radius of, the grounded collider
                Gizmos.DrawSphere(
                    new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z),
                    GroundedRadius);
            }
    
            private void OnFootstep(AnimationEvent animationEvent)
            {
                if (animationEvent.animatorClipInfo.weight > 0.5f)
                {
                    if (FootstepAudioClips.Length > 0)
                    {
                        var index = Random.Range(0, FootstepAudioClips.Length);
                        AudioSource.PlayClipAtPoint(FootstepAudioClips[index], transform.TransformPoint(_controller.center), FootstepAudioVolume);
                    }
                }
            }
    
            private void OnLand(AnimationEvent animationEvent)
            {
                if (animationEvent.animatorClipInfo.weight > 0.5f)
                {
                    AudioSource.PlayClipAtPoint(LandingAudioClip, transform.TransformPoint(_controller.center), FootstepAudioVolume);
                }
            }
        }
    }
    
    


    ビルドして実行

    BuildSettingsで、スクリーンサイズをWindowed 800x600に変更し、 Buildシーンに編集シーンを追加しBuild and Runを実行。


    まとめ Multi Player動作の完成

    Unity Starter Asset Third Personを、Netcodeを使用してマルチプレーヤーに変更できた。

    自動実行

    ◆◆Server(サーバー機能)
    起動したのがサーバー状態だった場合は、Serverとして開始する。

    メニューで選択

    ◆◆Server(サーバー機能)
    //NetworkManagerで、Serverとしてゲームを開始
    NetworkManager.Singleton.StartServer();

    ◆◆Host(サーバー機能+プレーヤー)
    //NetworkManagerで、Hostとしてゲームを開始
    NetworkManager.Singleton.StartHost();

    ◆◆Client(プレーヤー)
    //NetworkManagerで、Clientとしてゲームを開始
    NetworkManager.Singleton.StartClient();


    Hostで起動すれば、シングルプレイ状態になる。
    同じPCで動くアプリ同士なので、サーバーのアドレスは127.0.0.1(自機)としている。
    他機で起動しているサーバーへ接続するにはサーバーのIPアドレスを指定しないといけない。
    インターネット経由の接続で固定アドレスは珍しいので、都度IPアドレス調べないと繋げない。
    こうした接続を仲介し、インターネットどこでも繋がるようにする。

    インターネットで動くMulti Playerアプリとして機能追加

    RelayとLobbyの追加

    UnityサービスのRelayやLobbyはインターネットのサーバーで行う仲介サービスで、 インターネット越し連絡してきたデバイスと接続できるようにする。
    Unityサービスを使うには、Unityアカウントで組織、プロジェクトのサービスへの登録、セットアップが必要。
    一定使用量の無料枠があり、超えた分は従量課金となる。

    Relayはデバイス間の通信情報を中継する。ホスト・サーバーが開いた回線のIDコードを入力すれば、 そこに接続できる。
    Lobbyは、アプリで指定したサーバー上に開いた回線情報を登録し、それを問合せて参加したり、 自分で開いて接続されるのを待ったりできる。

    Lobbyで開設されたルームのリストを見てClientで参加するか、Hostとして新たにルームを作って待つか 選択する。

    Hostで起動すればシングルプレイ状態になる。
    しばらくすると、他プレーヤーにルームボタンが表示されるので、クライアントとしてジョインできる。




    RelayとLobbyのインストール

    パッケージマネージャーUnityRegistryからRelayとLobbyをインストール

    インストールが終わったら、ProjectSettingsでサービスを設定する。
    UnityサービスのRelayやLobbyを使用するアプリとして、制作アプリのIDを登録する。

    RelayとLobbyの制御

    自動実行

    ◆◆Server(Serverとしてルーム作成)

    メニューで選択

    ◆◆Server(Serverとしてルーム作成)
    ◆◆Host(Serverとしてルーム作成 プレーヤ起動)
    ◆◆Client(すでにあるルームに入室 プレーヤ起動)


    ◆◆Host/Server(ルーム作成)

    //UnityサービスへのAuth認証
    UnityServices.InitializeAsync();
    AuthenticationService.Instance.SignInAnonymouslyAsync();

    //RelayServiceでAllocationを作成
    RelayService.Instance.CreateAllocationAsync(maxConnections);

    //RelayServiceでJoinCode取得
    RelayService.Instance.GetJoinCodeAsync(hostAllocationId);

    //LobbyServiceでロビーを作成
    Lobbies.Instance.CreateLobbyAsync(lobbyName, maxPlayers, createOptions);

    //allocationから入手したRelayサーバー情報をNetworkManagerに設定
    NetworkManager.Singleton.GetComponent().SetRelayServerData(allocation.RelayServer.IpV4, (ushort)allocation.RelayServer.Port, allocation.AllocationIdBytes, allocation.Key, allocation.ConnectionData);

    //NetworkManagerで、Host/Serverとしてゲームを開始
    NetworkManager.Singleton.StartHost();

    //LobbyにHeartBeatを送って切断をしてないと伝える
    Lobbies.Instance.SendHeartbeatPingAsync(lobbyId);


    ◆◆Client(入室)
    ルームリストを習得し、存在しているルームの数だけボタンを表示する
    ルームNoボタンを押すと入室する

    //UnityサービスへのAuth認証
    UnityServices.InitializeAsync();
    AuthenticationService.Instance.SignInAnonymouslyAsync();

    //Lobbyリスト取得
    Lobbies.Instance.QueryLobbiesAsync();

    //Lobbyを選択して参加
    Lobbies.Instance.JoinLobbyByIdAsync(lobbyId, joinOptions);

    //LobbyからJoinCodeを入手して、作成済のRelayServiceへ参加
    RelayService.Instance.JoinAllocationAsync(joinCode);

    //allocationから入手したRelayサーバー情報をNetworkManagerに設定
    NetworkManager.Singleton.GetComponent().SetRelayServerData(allocation.RelayServer.IpV4, (ushort)allocation.RelayServer.Port, allocation.AllocationIdBytes, allocation.Key, allocation.ConnectionData, allocation.HostConnectionData);

    //NetworkManagerで、Clientとしてゲームに参加
    NetworkManager.Singleton.StartClient();


    RelayとLobbyの制御スクリプト

    UIManagerオブジェクトの、UIManagerコンポーネント(script)を削除
    NetPlayManager.csとNetRoomUIManager.csコンポーネント(script)を追加する。
    NetRoomUIManager.cs
    
    using System.Collections;
    using System.Collections.Generic;
    using Unity.Netcode;
    using Unity.Netcode.Transports.UTP;
    using Unity.Services.Lobbies;
    using Unity.Services.Lobbies.Models;
    using UnityEngine;
    using UnityEngine.InputSystem;
    using UnityEngine.XR;
    
    public class NetRoomUIManager : MonoBehaviour
    {
        static NetPlayManager NPMSingleton;
        static private bool findingRoom = false;
        static int rooms = 0;
    
        private void Start()
        {
            NPMSingleton = this.GetComponent();
            NPMSingleton.ServiceAuthentication();
    
    #if UNITY_SERVER
            Singleton.CreateRoom("Server");//起動したのがサーバーの場合は、起動と同時にネットワークマネージャーをスタート
    #endif
            StartCoroutine(ChekRoomCoutine(3));
        }
        private void Update()
        {
            // Exit Sample  
            if (Keyboard.current.escapeKey.isPressed)
            {
                QuitApp();
            }
        }
    
        void OnGUI()
        {
            GUILayout.BeginArea(new Rect(10, 10, 300, 300));
            if (!NetworkManager.Singleton.IsClient && !NetworkManager.Singleton.IsServer)//NetworkManger is not opened 
            {
                StartButtons();
            }
            else //show status of opened NetworkManger
            {
                StatusLabels();
            }
            GUILayout.EndArea();
        }
    
        static void QuitApp()
        {
            Application.Quit(); //このアプリを終了させる。
    #if UNITY_EDITOR //Unityエディターで動いているときに限定 ここから
            UnityEditor.EditorApplication.isPlaying = false; //プレビュー再生をOffにする。
    #endif //Unityエディターで動いているときに限定 ここまで
        }
     
       private IEnumerator ChekRoomCoutine(int sec)
        {
            var delay = new WaitForSecondsRealtime(sec);
            while (true)
            {
                rooms = NPMSingleton.CheckRooms();
                yield return delay;
            }
        }
        static void StatusLabels()
        {
            if (GUILayout.Button("Quit")) QuitApp();
            var mode = NetworkManager.Singleton.IsHost ?
                "Host" : NetworkManager.Singleton.IsServer ? "Server" : "Client";
    
            GUILayout.Label("Transport: " +
                NetworkManager.Singleton.NetworkConfig.NetworkTransport.GetType().Name);
            GUILayout.Label("Mode: " + mode);
        }
    
        static void StartButtons()
        {
            if (GUILayout.Button("Quit")) QuitApp();
            GUILayout.BeginHorizontal("box");
            if (GUILayout.Button("Host")) NPMSingleton.CreateRoom("Host"); 
            if (GUILayout.Button("Server")) NPMSingleton.CreateRoom("Server");
            GUILayout.EndHorizontal();
            GUILayout.BeginHorizontal("box");
            for (int i=0; i < rooms;i++)
           {
                 if (GUILayout.Button("Client:" + i.ToString())) NPMSingleton.JoinRoom(i);
           }
            GUILayout.EndHorizontal();
         }
    }
    

    NetPlayManager.cs
    
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using Unity.Netcode;
    using Unity.Netcode.Transports.UTP;
    using Unity.Services.Authentication;
    using Unity.Services.Core;
    using Unity.Services.Relay.Models;
    using Unity.Services.Relay;
    using Unity.Services.Lobbies.Models;
    using Unity.Services.Lobbies;
    using UnityEngine;
    using UnityEngine.SocialPlatforms;
    
    
    public class NetPlayManager : MonoBehaviour
    {
        public static NetPlayManager Singleton;
        
        [SerializeField] string joinCode;
    
        [Range(10, 99)]
        [SerializeField] private int ServerId = 10;
    
        public static bool IsConnecting = false;
        public static int rooms = 0;
        private float _lastLobbyCheck;
    
        public static List LobbiesList = new();
    
        // Lobby Meta Data
        private const string JoinCodeKey = "jck";
        private const string ServerVersionKey = "svk";
        public const string PlayerNameKey = "nme";
        private string playerName;
    
        private QuickJoinLobbyOptions _quickJoinLobbyOptions;
    
        private void Awake()
        {
            Singleton = this;
            playerName = "Player" + UnityEngine.Random.Range(0, 1000).ToString();
    
            //_quickJoinLobbyOptions = new QuickJoinLobbyOptions
            //{
            //    Filter = new List
            //        {
            //            new (
            //                field: QueryFilter.FieldOptions.S1,
            //                op: QueryFilter.OpOptions.EQ,
            //                value: ServerId.ToString()
            //            ), // this way we can separate servers for testing,
            //            new (
            //                field: QueryFilter.FieldOptions.S3,
            //                op: QueryFilter.OpOptions.EQ,
            //                value: ""
            //            ) // S3 is room key, public rooms with no pin have a value of "";
            //        }
            //};
    
            IsConnecting = false;
        }
    
    
        public async void ServiceAuthentication()
        {
            // already authenticated
            if (UnityServices.State == ServicesInitializationState.Initialized && AuthenticationService.Instance.IsSignedIn) return;
    
            //初期化処理
            InitializationOptions initializationOptions = new InitializationOptions();
            initializationOptions.SetProfile(playerName);
    
            await UnityServices.InitializeAsync(initializationOptions);
    
            // 匿名サインイン処理
            AuthenticationService.Instance.SignedIn += () =>
            {
                Debug.Log("Signed In " + AuthenticationService.Instance.PlayerId);
            };
            await AuthenticationService.Instance.SignInAnonymouslyAsync();
        }
    
        public async void CreateRoom(string lobbyName)
        {
            const int maxPlayers = 8;
     
            try
            {
                //RelayServiceでAllocationを作成
                Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxPlayers);
    
                //RelayServiceでJoinCode取得
                joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);
                Debug.Log(joinCode);
    
                //LobbyServiceでロビーを作成
                var options = new CreateLobbyOptions
                {
                    Data = new Dictionary
                    {
                            { JoinCodeKey,
                                new DataObject(DataObject.VisibilityOptions.Public,
                                    joinCode) },
                            { ServerVersionKey,
                                new DataObject(visibility: DataObject.VisibilityOptions.Public,
                                    value: ServerId.ToString(), index: DataObject.IndexOptions.S1)},
                     }
                };
                var lobby = Lobbies.Instance.CreateLobbyAsync(lobbyName, maxPlayers, options);
    
               //LobbyにHeartBeatを送って切断をしてないと伝える
                StartCoroutine(HeartbeatLobbyCoroutine(lobby.Id.ToString(), 15));
    
                NetworkManager.Singleton.GetComponent().SetHostRelayData(
                    allocation.RelayServer.IpV4,
                    (ushort)allocation.RelayServer.Port,
                    allocation.AllocationIdBytes,
                    allocation.Key,
                    allocation.ConnectionData
                    );
    
                Debug.Log("Start:"+lobbyName);
                if (lobbyName == "Server")
                {
                    NetworkManager.Singleton.StartServer();
                }
                else
                {
                    NetworkManager.Singleton.StartHost();
                }
    
            }
            catch (LobbyServiceException e)
            {
                Debug.Log(e);
            }
        }
        public async void JoinRoom(int id)
        {
            try
            {
                IsConnecting = true;
                //ServiceAuthentication();
                //QueryResponse response = await Lobbies.Instance.QueryLobbiesAsync();
                //LobbiesList = response.Results;
    
                if ((LobbiesList.Count -1) < id)
                {
                    print("No Room");
                    return;
                }
    
                string lobbyId = LobbiesList[id].Id.ToString();
                Debug.Log("JoinTo:"+lobbyId);
    
                var lobby = await Lobbies.Instance.JoinLobbyByIdAsync(lobbyId);
    
                lobby.Data.TryGetValue(JoinCodeKey, out var jCode);
    
                joinCode = jCode.Value;
                Debug.Log("CODE:" + joinCode);
    
                //allocationから入手したRelayサーバー情報をNetworkManagerに設定
                JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode);
                NetworkManager.Singleton.GetComponent().SetClientRelayData(
                    joinAllocation.RelayServer.IpV4,
                    (ushort)joinAllocation.RelayServer.Port,
                    joinAllocation.AllocationIdBytes,
                    joinAllocation.Key,
                    joinAllocation.ConnectionData,
                    joinAllocation.HostConnectionData
                    );
                //NetworkManagerで、Clientとしてゲームを開始
                NetworkManager.Singleton.StartClient();
    
                IsConnecting = false; 
            }
            catch (RelayServiceException e)
            {
                Debug.Log(e);
            }
            catch (LobbyServiceException e)
            {
                Debug.Log(e);
            }
        }
    
        private IEnumerator HeartbeatLobbyCoroutine(string lobbyId, int waitTimeSeconds)
        {
            var delay = new WaitForSecondsRealtime(waitTimeSeconds);
            while (true)
            {
                Lobbies.Instance.SendHeartbeatPingAsync(lobbyId);
                yield return delay;
            }
        }
    
    
        public  int CheckRooms()
        {
            if (!IsConnecting)
            {
                if (Time.unscaledTime > _lastLobbyCheck + 3)// to avoid too many checks, Reject within 3 seconds
                {
                    _lastLobbyCheck = Time.unscaledTime;
                    ListRoom();
                }
            }
            return rooms;
        }
    
        public async void ListRoom()
        {
            ServiceAuthentication();
            
            //QueryLobbiesOptions queryLobbiesOptions = new QueryLobbiesOptions
            //{
            //    Filters = new List()
            //        {
            //            new (
            //                field: QueryFilter.FieldOptions.S1,
            //                op: QueryFilter.OpOptions.EQ,
            //                value: ServerId.ToString()
            //            )
            //        }
            //};
            //QueryResponse response = await Unity.Services.Lobbies.Lobbies.Instance.QueryLobbiesAsync(queryLobbiesOptions);
    
            QueryResponse response = await Lobbies.Instance.QueryLobbiesAsync();
    
            LobbiesList.Clear();
            LobbiesList = response.Results;
            rooms = LobbiesList.Count;
    
            if (rooms < 1)
            {
                Debug.Log("No Room");
                return;
            }
    
            foreach (Lobby _lobby in LobbiesList)
            {
                Debug.Log("Lobby:" + _lobby.Name + "isAvailable");
            }
        }
    }
    
    

    プレーヤー(が所有する)以外のオブジェクトの移動

    サーバーでスポーンさせたオブジェクトにトリガーを設定。
    Playerタグのついたオブジェクトが近接侵入すると、跳ねるようにした。

    近接侵入すると、Player進行方向へ跳ねるように変更。
    他のPlayerが重なったとき、またPlayer内部の他のコライダーと干渉しないようレイヤーを分けた。

    TouchReaction.cs
    
    using System.Collections;
    using System.Collections.Generic;
    using Unity.Netcode;
    using Unity.VisualScripting;
    using UnityEngine;
    using UnityEngine.EventSystems;
    
    public class TouchReaction : Unity.Netcode.NetworkBehaviour
    {
        static private Vector3 DriveDirection = Vector3.zero;
        bool IsContact = false;
        float forcePower = 5f;
    
        private void OnTriggerEnter(Collider other)
        {
            if (IsContact) return;
            Debug.Log(this.gameObject.name.ToString());
    
            if (other.gameObject.tag == "Player")
            {
                if(this.IsOwner)
                {
                    Transform transform = other.GetComponent();
                    Object _parent = transform.root.gameObject;
                    Debug.Log(_parent.name.ToString());
                    DriveDirection = _parent.GetComponent().forward;
                    Vector3 velocity = DriveDirection;
                    Debug.Log("x:" + velocity.x.ToString() + " y:" + velocity.y.ToString() + " z:" + velocity.z.ToString());
    
                    ReactionMoveServerRpc(velocity);
                }
                if (this.IsServer)
                {
                    Move();
                }
            }
        }
        private void Move()
        {
            Rigidbody body = this.GetComponent();
            if (body == null) return;
    
            Vector3 velocity = DriveDirection;
            //方向を正規化
            velocity = velocity.normalized;
            if (DriveDirection.y < -0.4f) return;
            Vector3 pushDir = new Vector3(velocity.x, 0.0f, velocity.z);
            body.AddForce(pushDir * forcePower, ForceMode.Impulse);
        }
    
        [ServerRpc]
        private void ReactionMoveServerRpc(Vector3 velocity)
        {
            DriveDirection = velocity;
        }
    
    
        void OnTriggerStay(Collider other)
        {
            if (IsContact) return;
            if (other.gameObject.tag == "Player")
            {
                IsContact = true;
            }
        }
    
        void OnTriggerExit(Collider other)
        {
            if (other.gameObject.tag == "Player")
            {
               DriveDirection = Vector3.zero;
               IsContact = false;
            }
        }
    }