🔰

【Photon Fusion】ホストモードのチュートリアル

2023/08/16に公開

セッションに参加しよう

まずプレイヤーがネットワーク上の他のプレイヤーと通信するためには、セッション(Session)に参加する必要があります。セッションは部屋のようなもので、同じセッションに参加しているプレイヤー同士でのみ、データを送受信してゲームを同期させることができます。

NetworkRunnerのプレハブの作成

NetworkRunnerは、Fusionのネットワークを管理するための最重要コンポーネントになります。以下の画像を参考にNetworkRunnerのプレハブを作成してください。


NetworkRunnerインスタンスの生成

シーン上に配置した空のゲームオブジェクトに新規のスクリプト(GameLauncher)を追加して、NetworkRunnerのプレハブをアタッチしましょう。このスクリプトからNetworkRunnerインスタンスを生成して、StartGame()を呼び出すことでセッションに参加できます。


GameLauncher.cs
using Fusion;
using UnityEngine;

public class GameLauncher : MonoBehaviour
{
    [SerializeField]
    private NetworkRunner networkRunnerPrefab;

    private NetworkRunner networkRunner;

    private void Start() {
        // NetworkRunnerを生成する
        networkRunner = Instantiate(networkRunnerPrefab);
        // StartGameArgsに渡した設定で、セッションに参加する
        networkRunner.StartGame(new StartGameArgs {
            GameMode = GameMode.AutoHostOrClient,
            SceneManager = networkRunner.GetComponent<NetworkSceneManagerDefault>()
        });
    }
}

正常にセッションに参加できているか確認するために、コンソールにログを出してみましょう。StartGame()は非同期メソッドになっていて、タスク完了後に戻り値(StartGameResult型)から実行結果を取得して、成功時と失敗時の処理を分岐させることができます。

GameLauncher.cs
+ private async void Start() {
      networkRunner = Instantiate(networkRunnerPrefab);
+     var result = await networkRunner.StartGame(new StartGameArgs {
          GameMode = GameMode.AutoHostOrClient,
          SceneManager = networkRunner.GetComponent<NetworkSceneManagerDefault>()
      });
  
+     if (result.Ok) {
+         Debug.Log("成功!");
+     } else {
+         Debug.Log("失敗!");
+     }
  }


セッションへの参加が成功した時のログ

プレイヤーのアバターを表示しよう

プレイヤーのアバターをネットワークオブジェクトとして生成して、セッションへ参加しているプレイヤーの人数の分だけ、ゲーム画面上にアバターが表示されるようにしてみましょう。

ホストモードでは、ネットワークオブジェクトを生成できるのはホストのみであるということに気を付けてください。セッションへプレイヤーが参加した時には、ホストがその参加したプレイヤーのアバターを生成する必要があります。

アバターのプレハブの作成

以下の画像を参考にアバターのプレハブを作成してください。アバターをネットワークオブジェクトにするために、NetworkObjectコンポーネントを追加するのを忘れないようにしてください。





アバターのプレハブが作成できたら、プレハブの参照をGameLauncherにアタッチしましょう。

GameLauncher.cs
    [SerializeField]
    private NetworkRunner networkRunnerPrefab;
+   [SerializeField]
+   private NetworkPrefabRef playerAvatarPrefab;


GameLauncherにアバターのプレハブをアタッチする

NetworkRunnerからコールバックを受け取れるようにする

セッションへプレイヤーが参加した時などの様々なイベントに対応した処理を実行できるようにするために、NetworkRunnerからコールバックを受け取れるようにしましょう。GameLauncherINetworkRunnerCallbacksインターフェースを実装して、NetworkRunnerのコールバック対象に登録してください。

GameLauncher.cs
+ using System;
+ using System.Collections.Generic;
  using Fusion;
+ using Fusion.Sockets;
  using UnityEngine;

+ // INetworkRunnerCallbacksを実装して、NetworkRunnerのコールバック処理を実行できるようにする
+ public class GameLauncher : MonoBehaviour, INetworkRunnerCallbacks
  {
      [SerializeField]
      private NetworkRunner networkRunnerPrefab;
  
      private NetworkRunner networkRunner;
  
      private async void Start() {
          networkRunner = Instantiate(networkRunnerPrefab);
+         // NetworkRunnerのコールバック対象に、このスクリプト(GameLauncher)を登録する
+         networkRunner.AddCallbacks(this);
          var result = await networkRunner.StartGame(new StartGameArgs {
              GameMode = GameMode.AutoHostOrClient,
              SceneManager = networkRunner.GetComponent<NetworkSceneManagerDefault>()
          });
      }
        
+     // INetworkRunnerCallbacksインターフェースの空実装
+     public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) {}
+     public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) {}
+     public void OnInput(NetworkRunner runner, NetworkInput input) {}
+     public void OnInputMissing(NetworkRunner runner, PlayerRef player, NetworkInput input) {}
+     public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) {}
+     public void OnConnectedToServer(NetworkRunner runner) {}
+     public void OnDisconnectedFromServer(NetworkRunner runner) {}
+     public void OnConnectRequest(NetworkRunner runner, NetworkRunnerCallbackArgs.   ConnectRequest request, byte[] token) {}
+     public void OnConnectFailed(NetworkRunner runner, NetAddress remoteAddress,    NetConnectFailedReason reason) {}
+     public void OnUserSimulationMessage(NetworkRunner runner, SimulationMessagePtr message) {}
+     public void OnSessionListUpdated(NetworkRunner runner, List<SessionInfo> sessionList) {}
+     public void OnCustomAuthenticationResponse(NetworkRunner runner, Dictionary<string,    object> data) {}
+     public void OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken)    {}
+     public void OnReliableDataReceived(NetworkRunner runner, PlayerRef player,    ArraySegment<byte> data) {}
+     public void OnSceneLoadDone(NetworkRunner runner) {}
+     public void OnSceneLoadStart(NetworkRunner runner) {}
  }

アバターの生成と破棄

ネットワークオブジェクトは、Unity標準のInstantiate()ではなくNetworkRunner.Spawn()から生成します。セッションへプレイヤーが参加した時に、INetworkRunnerCallbacks.OnPlayerJoined()コールバックが呼ばれるので、そこで参加したプレイヤーのアバターをホストが生成します。

GameLauncher.cs
+   // セッションへプレイヤーが参加した時に呼ばれるコールバック
+   public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) {
+       // ホスト(サーバー兼クライアント)かどうかはIsServerで判定できる
+       if (!runner.IsServer) { return; }
+       // ランダムな生成位置(半径5の円の内部)を取得する
+       var randomValue = UnityEngine.Random.insideUnitCircle * 5f;
+       var spawnPosition = new Vector3(randomValue.x, 5f, randomValue.y);
+       // 参加したプレイヤーのアバターを生成する
+       var avatar = runner.Spawn(playerAvatarPrefab, spawnPosition, Quaternion.identity, player);
+       // プレイヤー(PlayerRef)とアバター(NetworkObject)を関連付ける
+       runner.SetPlayerObject(player, avatar);
+   }

ネットワークオブジェクトの破棄は、Unity標準のDestroy()ではなくNetworkRunner.Despawn()で行います。セッションからプレイヤーが退出した時に、INetworkRunnerCallbacks.OnPlayerLeft()コールバックが呼ばれるので、そこでホストが退出したプレイヤーのアバターを破棄します。

GameLauncher.cs
+   // セッションからプレイヤーが退出した時に呼ばれるコールバック
+   public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) {
+       if (!runner.IsServer) { return; }
+       // 退出したプレイヤーのアバターを破棄する
+       if (runner.TryGetPlayerObject(player, out var avatar)) {
+           runner.Despawn(avatar);
+       }
+   }

NetworkRunner.SetPlayerObject()であらかじめプレイヤーとアバターを関連付けていると、NetworkRunner.TryGetPlayerObject()から退出したプレイヤーのアバターのネットワークオブジェクトが簡単に取得できるようになり便利なので、うまく活用していきましょう。

アバターの位置の同期

ここでUnityのプロジェクトをビルドして、動作確認してみましょう。生成されたアバターが画面に表示されるようにはなりましたが、アバターの位置は同期されていないことがわかります。

Fusionには、ネットワークオブジェクトの位置を同期するコンポーネントがいくつか用意されているので、今回はNetworkCharacterControllerPrototypeコンポーネントを使ってみましょう。アバターのプレハブにこのコンポーネントを追加すれば、アバターの位置が自動的に同期されるようになります。


NetworkCharacterControllerPrototypeを追加する(CharacterControllerは自動で追加される)

コンポーネントのInterpolation Targetプロパティを設定すると、位置をスムーズに補間して表示できるようになるので、子要素のゲームオブジェクトをアタッチしておきましょう。


NetworkCharacterControllerPrototypeInterpolation Targetを設定する

Unityのプロジェクトをビルドして、複数のアバターの位置が正しく同期されて表示されるかを確認してみましょう。

プレイヤー自身のアバターを操作できるようにしよう

アバターが表示できたので、ここから自分自身のアバターを操作できるようにしてみましょう。

ホストモードでは、ホストが全てのネットワークオブジェクトの状態権限を持つため、クライアントは自分自身のアバターを直接操作することはできません。クライアントはホストに入力を送信して、自分自身のアバター(入力権限を持つネットワークオブジェクト)を更新してもらうことになります。ホストモードで入力を反映させるための流れは以下の通りです。

  1. 入力構造体(入力のデータをまとめたもの)を定義する
  2. Fusionがクライアントの入力を収集して、ホストに送信する
  3. ホストがクライアントの入力を取得して、ネットワークオブジェクトの状態を更新する

アバターの移動

新規スクリプトで入力構造体(NetworkInputData)を作成します。アバターを移動させるための入力として、方向キーの入力データを定義しましょう。

NetworkInputData.cs
using Fusion;
using UnityEngine;

public struct NetworkInputData : INetworkInput
{
    public Vector3 Direction;
}

Fusionが入力を収集するタイミングでINetworkRunnerCallbacks.OnInput()コールバックが呼ばれるようになっているので、そこで入力の値を入力構造体に格納して渡します。

GameLauncher.cs
+   // 入力を収集する時に呼ばれるコールバック
+   public void OnInput(NetworkRunner runner, NetworkInput input) {
+       var data = new NetworkInputData();
+
+       data.Direction = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical"));
+
+       input.Set(data);
+   }

ネットワーク上で同期する処理は、UnityのUpdate()内ではなくFixedUpdateNetwork()内で実行する必要があります。新規スクリプトでPlayerAvatarを追加して、FixedUpdateNetwork()の中でGetInput()から取得した入力に応じて、アバターを移動させる処理を実行しましょう。


アバターのプレハブにPlayerAvatarを追加する

PlayerAvatar.cs
using Fusion;

public class PlayerAvatar : NetworkBehaviour
{
    private NetworkCharacterControllerPrototype characterController;

    private void Awake() {
        characterController = GetComponent<NetworkCharacterControllerPrototype>();
    }

    public override void FixedUpdateNetwork() {
        if (GetInput(out NetworkInputData data)) {
            // 入力方向のベクトルを正規化する
            data.Direction.Normalize();
            // 入力方向を移動方向としてそのまま渡す
            characterController.Move(data.Direction);
        }
    }
}

アバターのジャンプ

アバターをジャンプさせるために、入力構造体にジャンプボタンの定義を追加します。Fusionにはボタンの入力を格納するのに便利なNetworkButtons型が用意されているのでそれを使います。

NetworkInputData.cs
  public struct NetworkInputData : INetworkInput
  {
      public Vector3 Direction;
+     public NetworkButtons Buttons;
  }

+ // 入力のボタンの種類は、列挙型(enum)で定義しておく
+ public enum NetworkInputButtons
+ {
+     Jump
+ }
GameLauncher.cs
    public void OnInput(NetworkRunner runner, NetworkInput input) {
        var data = new NetworkInputData();

        data.Direction = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical"));
+       data.Buttons.Set(NetworkInputButtons.Jump, Input.GetKey(KeyCode.Space));

        input.Set(data);
    }

NetworkButtons.IsSet()でジャンプボタンが押されているかどうかを判定できるので、ジャンプボタンが押されていたら、アバターのジャンプ処理を実行しましょう。

PlayerAvatar.cs
    public override void FixedUpdateNetwork() {
        if (GetInput(out NetworkInputData data)) {
            data.Direction.Normalize();
            characterController.Move(data.Direction);
+           if (data.Buttons.IsSet(NetworkInputButtons.Jump)) {
+               characterController.Jump();
+           }
        }
    }

カメラの制御

今回はカメラの制御に、Unity公式パッケージのCinemachineを利用します。以下の画像を参考にして、Cinemachine Free Look Cameraのセットアップを行ってください。


カメラにCinemachineBrainを追加する


ヒエラルキーのメニューから「Cinemachine > FreeLook Camera」を選択する



CinemachineFreeLookの設定を調整する

CinemachineFreeLookのプロパティ設定は自由に変えてもらっても構いません。オススメとして、Lens Vertical FOVTopRigMiddleRigBottomRigあたりは、デフォルトの値から変更すると画面が見やすくなると思います。

次に、PlayerAvatarViewスクリプトを新規で作成して、アバターにカメラを追従させる処理を実装しましょう。スクリプトは、アバターの3Dモデル部分のゲームオブジェクトに追加します。

PlayerAvatarView.cs
using Cinemachine;
using UnityEngine;

public class PlayerAvatarView : MonoBehaviour
{
    public void SetCameraTarget() {
        var freeLookCamera = FindObjectOfType<CinemachineFreeLook>();
        freeLookCamera.LookAt = transform;
        freeLookCamera.Follow = transform;
    }
}


アバターの3Dモデル部分にPlayerAvatarViewを追加する

NetworkBehaviourを継承しているスクリプト(PlayerAvatar)は、ネットワークオブジェクトが生成された時にSpawned()が呼ばれます。そこで自分自身のアバターかどうか(自分自身が入力権限を持つネットワークオブジェクトかどうか)を調べて、自分自身のアバターならカメラを追従させる処理を実行しましょう。


PlayerAvatarPlayerAvatarViewをアタッチする

PlayerAvatar.cs
  using Fusion;
+ using UnityEngine;

  public class PlayerAvatar : NetworkBehaviour
  {
+     [SerializeField]
+     private PlayerAvatarView view;
  
      private NetworkCharacterControllerPrototype characterController;
  
      private void Awake() {
          characterController = GetComponent<NetworkCharacterControllerPrototype>();
      }
  
+     public override void Spawned() {
+         // 自分自身のアバターにカメラを追従させる
+         if (Object.HasInputAuthority) {
+             view.SetCameraTarget();
+         }
+     }
  
      public override void FixedUpdateNetwork() {
          if (GetInput(out NetworkInputData data)) {
              data.Direction.Normalize();
              characterController.Move(data.Direction);
              if (data.Buttons.IsSet(NetworkInputButtons.Jump)) {
                  characterController.Jump();
              }
          }
      }
  }

ここでひとまずUnityのエディター上で再生してみて、自分自身のアバターにカメラが追従しているか?を確認してみましょう。

カメラが操作できるようになると、入力の方向をそのまま(ワールド座標系の方向として)渡すだけでは操作性に問題が発生することがわかるかと思います。そのため、入力の方向をカメラの正面向きを基準とした方向(ビュー座標系の方向)として、ビュー座標系からワールド座標系に変換する処理を追加する必要があります。

GameLauncher.cs
    public void OnInput(NetworkRunner runner, NetworkInput input) {
        var data = new NetworkInputData();

-       data.Direction = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical"));
+       // 入力の方向を、ビュー座標系からワールド座標系に変換する
+       var cameraRotation = Quaternion.Euler(0f, Camera.main.transform.rotation.eulerAngles.y, 0f);
+       var inputDirection = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical"));
+       data.Direction = cameraRotation * inputDirection;
        data.Buttons.Set(NetworkInputButtons.Jump, Input.GetKey(KeyCode.Space));

        input.Set(data);
    }

プレイヤー名を表示しよう

アバターを自由に動かせるようになりましたが、このままではどれが誰のアバターなのか判別がつかないので、アバターの頭上にプレイヤーの名前を表示させてみましょう。

プレイヤー名の保存

事前にプレイヤー名を文字列で保存・取得できるようにしておきましょう。新規スクリプトで、以下のような静的クラスのPlayerDataを作成してください。

PlayerData.cs
using UnityEngine;

public static class PlayerData
{
    public static string NickName {
        get => PlayerPrefs.GetString("NickName", "No Name");
        set => PlayerPrefs.SetString("NickName", value);
    }
}

このチュートリアルでは、ランダムなプレイヤー名を表示することにします。セッションに参加する前に、プレイヤー名を設定する処理を追加しておきましょう。

GameLauncher.cs
    private async void Start() {
+       // ランダムなプレイヤー名を設定する
+       PlayerData.NickName = $"Player{UnityEngine.Random.Range(0, 10000)}";

        networkRunner = Instantiate(networkRunnerPrefab);
        networkRunner.AddCallbacks(this);
        await networkRunner.StartGame(new StartGameArgs {
            GameMode = GameMode.AutoHostOrClient,
            SceneManager = networkRunner.GetComponent<NetworkSceneManagerDefault>()
        });
    }

プレイヤー名のテキスト作成

以下の画像を参考にして、アバターの頭上にテキストを追加してください。テキストは、Unity公式パッケージのTextMesh Proを使用します。


ヒエラルキーのメニューから「3D Object > Text - TextMeshPro」を選択する



PlayerAvatarViewにテキストの参照(TextMeshPro)をアタッチして、プレイヤー名をテキストに設定する処理を追加しましょう。また、テキストは常にカメラの正面向きで表示させたいので、LateUpdate()のタイミングでビルボードの処理を実行しましょう。

PlayerAvatarView.cs
  using Cinemachine;
+ using TMPro;
  using UnityEngine;
  
  public class PlayerAvatarView : MonoBehaviour
  {
+     [SerializeField]
+     private TextMeshPro nameLabel;
  
      public void SetCameraTarget() {
          var freeLookCamera = FindObjectOfType<CinemachineFreeLook>();
          freeLookCamera.LookAt = transform;
          freeLookCamera.Follow = transform;
      }

+     // プレイヤー名をテキストに設定する
+     public void SetNickName(string nickName) {
+         nameLabel.text = nickName;
+     }
  
+     private void LateUpdate() {
+         // プレイヤー名のテキストを、常にカメラの正面向きにする
+         nameLabel.transform.rotation = Camera.main.transform.rotation;
+     }
  }

プレイヤー名をネットワークプロパティで定義します。ただしホストモードでは、クライアントは直接ネットワークプロパティを更新することはできないので、RPCでプレイヤー名を設定する処理をホストに実行してもらいます。

PlayerAvatar.cs
  public class PlayerAvatar : NetworkBehaviour
  {
+     // プレイヤー名のネットワークプロパティを定義する
+     [Networked(OnChanged = nameof(OnNickNameChanged))]
+     private NetworkString<_16> NickName { get; set; }
  
      [SerializeField]
      private PlayerAvatarView view;
  
      private NetworkCharacterControllerPrototype characterController;
  
      private void Awake() {
          characterController = GetComponent<NetworkCharacterControllerPrototype>();
      }
  
      public override void Spawned() {
          if (Object.HasInputAuthority) {
              view.SetCameraTarget();
+             // RPCでプレイヤー名を設定する処理をホストに実行してもらう
+             Rpc_SetNickName(PlayerData.NickName);
          }
      }
  
+     [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
+     private void Rpc_SetNickName(string nickName) {
+         NickName = nickName;
+     }

+     // ネットワークプロパティ(NickName)が更新された時に呼ばれるコールバック 
+     public static void OnNickNameChanged(Changed<PlayerAvatar> changed) {
+         // 更新されたプレイヤー名をテキストに反映する
+         changed.Behaviour.view.SetNickName(NickName.Value);
+     }
  
      public override void FixedUpdateNetwork() {
          if (GetInput(out NetworkInputData data)) {
              data.Direction.Normalize();
              characterController.Move(data.Direction);
              if (data.Buttons.IsSet(NetworkInputButtons.Jump)) {
                  characterController.Jump();
              }
          }
      }
  }

Unityのプロジェクトをビルドして、プレイヤー名が表示されることを確認してみましょう。

Photon運営事務局TechBlog

Discussion