Photon Fusion for Unityを使った自分のプロダクトをサーバーモードに対応する～実装編

# *はじめ*に

Photon FusionはドイツのExit Games社が開発し、GMOグローバルサイン・ホールディングス株式会社が日本展開を行っているオンラインゲーム開発向けネットワークエンジンです。

サーバーへのデプロイを前提にしたゲームを作りたい、例えば多人数対戦ゲームでセキュリティや安定性を高めたい、MMORPGのような常時稼働させたい、といった場合があるかと思います。

前回、前々回の記事ではそういったゲームを作成する準備段階として、サンプルプロジェクトを動かす手順、サンプルの中でも重要なスクリプトについて解説しました。

https://qiita.com/nimushiki/items/39f1648b81a8cfff65e2

https://qiita.com/nimushiki/items/d837b9cfa2887ab8245f

本記事では、上記記事同様サンプルプログラムを参考に、マルチプレイでよく使われる機能の実装方法について解説します。

### 前提記事

Photon Fusionの基本的な解説は別記事で行っています。詳しく知りたい方は以下のリンクからご参照下さい。

https://qiita.com/nimushiki/items/d6fe4f0cbe0437677fa8

関連記事

[https://qiita.com/tags/photonfusion](https://qiita.com/tags/photonfusion)

### 動作確認環境

Windows 10 Home 21H2

Unity 2021.3.15f1

Fusion Dedicated Server 1.1.2 Build 2

# 実装

以下の画像のような、3Dフィールドを歩き回るMMORPGのようなゲームを想定した実装例を紹介していきます。

![完成イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/318c5664-4f98-d4d2-3321-8f1704bbac67.png)

## プレイヤーキャラクターをスポーン・デスポーンさせる

上記の様なゲームを作る場合、プレイヤーのアバターであるプレイヤーキャラクターをスポーン・デスポーンさせる必要があります。

その実装の考え方はネットワークトポロジーによって違いがあります。今回はサーバーモードを想定しており、クライアントホストモードと同じくスポーンさせる役割をサーバーが担います。

ちなみに共有モードでは自分で自身をスポーンさせる必要があります。

スポーン処理はINetworkRunnerCallbacks.OnPlayerJoinedで実装するとよいでしょう。これは、クライアントが該当シーンに接続した場合に実行されるコールバックです。

```GameManager.cs
public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{

    if (runner.IsServer && _playerPrefab != null)
    {

        var pos = UnityEngine.Random.insideUnitSphere * 3;
        pos.y = 0;

        var character = runner.Spawn(_playerPrefab, pos, Quaternion.identity, inputAuthority: player);

        _playerMap[player] = character;

        //runnerとプレイヤーオブジェクトを紐づける
        runner.SetPlayerObject(player, character);

        Log.Info($"Spawn for Player: {player}");
    }
}
```

![GameManagerの配置位置](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/804826f6-c124-2a89-ddd0-d0f9be695ca9.png)

GameManager.csはServer_NetworkRunnerプレハブにアタッチされており、サーバーサイドで接続した場合に生成されます。

runner.Spawnでプレイヤーキャラクターをスポーンさせます。Photon Fusionでは、同期する必要のあるオブジェクトはUnityのInstantiateではなくこちらを使う必要があります。

NetworkRunnerはクライアントが同期を行うのに必須なコンポーネントであり、常に1つ存在しています。runner.SetPlayerObjectではJoinしてきたプレイヤーのNetworkRunnerとプレイヤーオブジェクトを紐づけています。これは今後別の機能実装時に使うことになります。

また切断時に呼ばれるINetworkRunnerCallbacks.OnPlayerLeftがあります。

```GameManager.cs
public void OnPlayerLeft(NetworkRunner runner, PlayerRef player)
{

    if (_playerMap.TryGetValue(player, out var character))
    {
        runner.Despawn(character);
        _playerMap.Remove(player);
        Log.Info($"Despawn for Player: {player}");
    }

    if (_playerMap.Count == 0)
    {
        Log.Info("Last player left, shutdown...");
        runner.Shutdown();
    }
}
```

OnPlayerJoinedの時に_playerMapに登録したプレイヤーキャラクターをデスポーンさせます。

## 名前を表示する

MMORPGに限らずマルチプレイのゲームを作る場合に欲しくなるのが、プレイヤーの名前を表示する機能です。

### サーバーサイド

前項で説明をしましたが、サーバーサイドではプレイヤーキャラクターをスポーンさせた時に、NetworkRunnerに紐づけておきます。

こうすることによってコールバックを受けた時の処理がしやすくなります。

### クライアントサイド

NameObjはCanvasに配置する名前表示用Prefabで、PlayerNameDisplayによって生成されます。

![NameObjの構造](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/d365baa9-2f6c-c4dc-0427-88a207a7d28e.png)

PlayerNameMoverはそこにアタッチするコンポーネントで、指定したプレイヤーが存在すれば位置を追従・しなければ自己消滅します。

```PlayerNameMover .cs
public class PlayerNameMover : MonoBehaviour
{
    [SerializeField] NetworkObject _playerObj;
    [SerializeField] private Vector3 NameOffset = new Vector3(0, 2.5f, 0);
    [SerializeField] RectTransform playerRectTransform;
    [SerializeField] TextMeshProUGUI _name;
    Camera _camera;

    public void init(string name, NetworkObject playerObj, Camera camera)
    {
        _name.text = name;
        _camera = camera;
        _playerObj = playerObj;
    }

    void Update()
    {
        if(_playerObj == null) Destroy(gameObject);
        playerRectTransform.position = RectTransformUtility.WorldToScreenPoint(_camera, _playerObj.transform.position + NameOffset);
    }
}
```

PlayerNameDisplayはプレイヤー名表示システムを管理しており、INetworkRunnerCallbacksを実装しプレイヤーの接続・切断時のコールバックを受けてNameObjを生成します。

![PlayerNameDisplayの配置と参照先](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/33b08837-fe2f-c198-30c9-cf7aafb4b8ce.png)

```PlayerNameDisplay.cs
public class PlayerNameDisplay : SimulationBehaviour, INetworkRunnerCallbacks
{
    [SerializeField] private Transform playerNameParent;
    [SerializeField] private bool Initialized = false;
    [SerializeField] private Camera _camera;
    [SerializeField] GameObject nameObject;

    private void Init(NetworkObject playerobj)
    {
        _camera = playerobj.GetComponentInChildren<Camera>();
        playerNameParent = playerobj.transform.Find("Canvas").Find("PlayerName");

        var collection = FindObjectsOfType<NetworkRunner>();
        foreach (var item in collection)
        {
            if (item == Runner || item.IsServer) continue;
            string name = "Player " + item.LocalPlayer.PlayerId.ToString();
            GameObject obj = Instantiate(nameObject, playerNameParent);
            obj.GetComponent<PlayerNameMover>().init(name, GetPlayerObject(item.LocalPlayer), _camera);
        }

        Initialized = true;
    }

    private NetworkObject GetPlayerObject(PlayerRef player)
    {
        if (Runner.TryGetPlayerObject(player, out var plObject))
        {
            return plObject;
        }
        else
        {
            Debug.Log("TryGetPlayerObject Failed");
            return null;
        }
    }

    public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
    {
        NetworkObject playerobj = GetPlayerObject(player);
        if (Initialized == false) Init(playerobj);

        if (playerNameParent.IsUnityNull()) return;

        string name = "Player" + player.PlayerId.ToString();
        
        GameObject obj = Instantiate(nameObject, playerNameParent.transform);
        obj.GetComponent<PlayerNameMover>().init(name, playerobj, _camera);

    }
}
```

OnPlayerJoinedは誰かがJoinした時に呼ばれるため、Joinしたキャラクターの情報を利用してNameObjを生成、初期化します。ただし自分がJoinした場合にも呼ばれるため、Initializedフラグを使って初期化処理を分岐しています。

Camera、PlayerNameの生成先であるplayerNameParentはプレイヤーキャラクターの子に装してあるため、初期化処理で取得するようにしています。

Joinした時既にプレイヤーが存在している場合は、その分の名前の生成も同時に行います。他のプレイヤーキャラクターはNetworkRunnerと1対1対応しているため、FindObjectsOfTypeで全てのNetworkRunnerを取得し、そこからNameObjの生成を行います。

現在の実装では、プレイヤーキャラクターに名前をもたせる機能や取得する機能がありません。BaaSなどを利用し、プレイヤーごとの名前を保存しておき、ログイン時に取得、他プレイヤーはそのデータを参照して名前を更新する、といった実装をするとよいでしょう。

# 開発効率を上げるTips

## マルチピアモードで楽にトライアンドエラー

サーバーを利用したゲームとはいえ、プログラムを修正するたびにビルド～サーバーへデプロイをするのは避けたいところです。前回の記事では開発機と同じWindowsPC上でサーバーモードを起動する方法を紹介しました。

今回はさらに開発のイテレーションをあげるために、マルチピアモードを紹介します。

マルチピアモードは、サーバーやクライアントを1つのシーンとして立ち上げることにより、1つのUnityエディタ内でマルチプレイのテストができる仕組みです。

ただし、マルチシーンで動作するため、元々マルチシーンで動作することを想定したゲームの場合は共存させることが困難です。

マルチピアモードについて詳しくは以下の記事で解説しています。

https://qiita.com/nimushiki/items/d683b3a17a6431f12b01

マルチピアモードへの変更はNetworkProjectConfigでPeerModeをMultipleに設定するだけでできます。この設定をすると、runner.StartGameをした時に既存シーンを破棄することなく、指定したシーンを追加で読み込むように（AdditiveでLoadSceneするような状態）になります。
設定は簡単ですが、そこを踏まえた実装をする必要があります。

![マルチピアモードへの変更方法](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/4a32e4f3-e702-4315-5895-793521e784d8.png)


```ServerManager.cs
[SerializeField] private GameObject _individualObject;
async void Start()
    {

#if UNITY_EDITOR
        _individualObject.SetActive(false);
#else
        // Load Menu Scene if not Running in Headless Mode
        // This can be replaced with a check to UNITY_SERVER if running on Unity 2021.2+
        if (CommandLineUtils.IsHeadlessMode() == false)
        {
            return;
        }
#endif

        // Continue with start the Dedicated Server
        Application.targetFrameRate = 30;

//以下サンプルと同等
```

まずサーバーとしてrunner.StartGameをします。マルチピアモードになっていれば、StartSceneはそのままにGameSceneが生成されます。

![サーバーが起動している状態](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/71b8efeb-e719-93b2-b03d-afb9ffb8a9e7.png)

Editorで動かした場合、起動パラメータがなくIsHeadlessModeはfalseになってしまうため、判定はスルーさせます。

同時に、マルチシーン化する関係上重複してしまうオブジェクト（カメラ等）は非アクティブにしておきます。

![サーバーモードで非アクティブにするオブジェクト](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/e4191144-1db9-ea2d-12ef-87cf4b636f20.png)

次にクライアントとしてrunner.StartGameをします。サーバーが起動していれば、これでサーバーに接続することができます。

![サーバーとクライアントが起動している状態](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/c27860a0-d298-ecf0-43a3-4646f09a7af1.png)

```ClientManager.cs
void OnGUI()
{
    if (hideUI) return;

    Rect area = new Rect(10, 10, 600, 200);

    GUILayout.BeginArea(area);
    
    State_SelectMode(); //常にUIを表示

    switch (_currentState)
    {
        //case State.SelectMode: State_SelectMode(); break
        case State.StartClient: State_StartClient(); break;
        case State.JoinLobby: State_JoinLobby(); break;
        case State.LobbyJoined: State_LobbyJoined(); break;
        case State.Started: State_Started(); break;
    }
//略
}

public Task<StartGameResult> StartSimulation(
        NetworkRunner runner,
        GameMode gameMode,
        string sessionName
        )
    {
        
        return runner.StartGame(new StartGameArgs()
        {
            SessionName = sessionName,
            GameMode = gameMode,
            SceneManager = runner.gameObject.AddComponent<NetworkSceneManagerDefault>(),
            Scene = 1,// GameSceneを指定,
            DisableClientSessionCreation = gameMode == GameMode.Client, // クライアントの場合はセッション作成不可
        }); ;
    }
```

ClientManager.csは常時UIを表示させるためState_SelectMode()をcase文から外しています。

## 注意点

前述の通り、マルチピアモードでサーバーモードを起動する時の注意点として、起動パラメータを与えて実行することができない事が挙げられます。

開発初期であれば実装優先度が低いこともあり、あまり問題にはならないかと思います。もし必要に迫られた場合は、エディタで実行した場合用の仮の値やデフォルト値を設定するとよいでしょう。

またマルチシーンで動作するため、既にマルチシーンで実装が進んでいる場合、シーンの管理が難しくなります。これについては開発元でサンプルプロジェクトを準備中とのことです。