Photon Fusion for UnityのサンプルSocial HubとProjectilesをあわせてシューターを作る

# はじめに

Photon Fusionに限らず、ゲームを作る際にまずデモとして単一シーン構成で作り始め、その後複数シーンに拡張していくといったパターンは多いと思います。しかし、Photon Fusionにおいてもシーン構成の単複は実装に対して小さくない影響があります。

またPhoton Fusionでは様々なサンプルが公開されていますが、単一の機能やテーマを扱ったものが多く、必然的にシーン構成については単一のものが多くあります。その中でSocial Hubは複数シーンでの構成を前提としたサンプルです。

本記事ではそれにProjectilesというサンプルをあわせたデモをベースに、単一と複数のシーン構成における違いやその合わせ方について解説します。

### 前提記事

Photon Fusionの基本的な解説は以下の記事で行っていますので、そちらを参照下さい。

[Photon Fusion for Unityの導入手順とPUN2との機能比較](https://qiita.com/nimushiki/items/d6fe4f0cbe0437677fa8)

### 動作確認環境

Windows 11 Home 22H2
Unity 2022.3.2f1
Fusion SDK 1.1.6 F Build 696

# Social Hubサンプルについて

Social Hubは複数のネットワークトポロジーを切り替える事のできるシーン遷移のサンプルになります。前回の記事にて紹介していますので、詳しくは「[Photon Fusion for Unityでネットワークトポロジーを複数採用する](https://qiita.com/nimushiki/items/5cd0ddfb49993afce365)」をご覧ください

https://doc.photonengine.com/ja-jp/fusion/current/technical-samples/fusion-social-hub

特徴はシーン毎に異なるネットワークトポロジーを使い分けができる部分で、ロビーは共有モード、ゲームはクライアントホストモードを利用できるようになっています。

## 大まかな動作

startシーンはローカルで動作します。lobbyへの遷移処理を行います。ここでConnectionManagerがDontDestroyOnLoadオブジェクトになり、シーン遷移やその準備の処理を行います。

lobbyシーンは共有モードで動作します。FirstDungeon用の設定を元にしたマッチング、Runnerオブジェクトの生成や設定、遷移処理を行います。

FirstDungeonシーンはクライアントホストモードで動作します。今回はこのシーンをProjectilesに置き換えることを目指します。

## 実装について

lobbyシーンのように任意のタイミングでRunner生成、設定→シーン読み込みとできるようにすることによるメリットは大きく２つあります。

１つは通信を司るRunnerのコールバック等の設定をシーンをまたいで持ち越せること、もう一つは初期化処理の一部を先に行えるため次シーンの読み込みが早くなることです。

# Projectilesサンプルについて

Projectilesは発射体の様々な同期方式を紹介するサンプルです。EssentialsとAdvancedの2つに分かれており、それぞれ基本と応用のサンプルになっています。

サンプルでは、例えば射手よりも長生きする誘導弾や、ゴムボールのようなバウンドする弾について、それぞれに向いた同期方法とその実装例が紹介されています。詳しくは公式ページを御覧ください。

https://doc.photonengine.com/ja-jp/fusion/current/technical-samples/fusion-projectiles-essentials

https://doc.photonengine.com/ja-jp/fusion/current/technical-samples/fusion-projectiles-advanced

## その他の特徴

一つのシーンで完結しているため簡潔な作りになっており、起動後すぐに接続関係の準備が行われます。

またダメージの処理やリスポーン処理なども実装されており、簡単なシューター系のゲームにそのまま使うことも出来ます。

マルチピアモードに対応しており、ビルドをしなくても一つのUnityクライアントでマルチプレイのテストが出来るようになっています。マルチピアモードの詳細は過去記事の「[Photon Fusion for Unityでマルチピアモードを利用したデバッグの始め方](https://qiita.com/nimushiki/items/d683b3a17a6431f12b01)」にて紹介しています。

## 大まかな動作

Gameシーン読み込むと、Network Debug StartがUIを表示し、ゲームモードを選択することができます。

Network Debug Startが初期化処理を開始してRunnerを生成、そのGameManagerがGamePlayとPlayerを生成、PlayerがAgent（キャラクター）を生成という流れになります。

# 改修ポイント

本記事の目標は、Social Hubの仕組みからProjectilesのシーンを呼び出せるようにする事です。

Social Hubではマッチング処理の一環としてRunnerを生成・設定した後、任意のタイミングでシーンを遷移し、キャラクターのスポーンを行います。一方Projectilesは同一シーン内で完結するため、Runnerの生成・設定～キャラクターのスポーンが一括で行われます。

そのためProjectilesの初期化処理のタイミングを調整し、Social Hubから生成できるようにする必要があります。

## Projectiles側の変更

### RunnerプレハブのGameManagerを修正

前述の通り、SocialHubではProjectilesのシーンに必要なRunnerを予め生成して準備を行う流れのため、GameManagerの初期化処理をシーン遷移後に行う必要があります。

IPlayerJoined.PlayerJoinedはProjectilesシーンがそのまま動作するように残しておきますが、SocialHubで生成した時に実行しないよう条件分岐を追加します。

新たに追加したSceneLoadDoneはSocialHubで生成した後、Projectilesシーンを読み込んだ際に実行されます。こちらも同様に、Projectilesで実行しないよう条件分岐を追加します。

```csharp
GameManager.cs

void IPlayerJoined.PlayerJoined(PlayerRef playerRef)
        {
            // SocialHubでは何もしない
            if (Runner.SessionInfo.Properties.TryGetValue("Target", out var sessionInfo) && sessionInfo == ConnectionTarget.Dungeon.ToString()) return;

            if (Runner.IsServer == false)
                return;

            if (_gameplaySpawned == false)
            {
                Runner.Spawn(_gameplayPrefab);

                _gameplaySpawned = true;
            }

            var player = Runner.Spawn(_playerPrefab, inputAuthority: playerRef);
            _players.Add(playerRef, player);

            Runner.SetPlayerObject(playerRef, player.Object);
        }

        // ISceneLoadDoneを継承しておく
        public void SceneLoadDone()
        {
            // Projectilesでは何もしない
            if (Runner.SessionInfo.Properties.TryGetValue("Target", out var sessionInfo) == false || sessionInfo != ConnectionTarget.Dungeon.ToString()) return;

            if (Runner.IsServer == false)
                return;

            if (_gameplaySpawned == false)
            {
                Runner.Spawn(_gameplayPrefab);

                _gameplaySpawned = true;
            }

            foreach (var playerRef in Runner.ActivePlayers)
            {
                var player = Runner.Spawn(_playerPrefab, inputAuthority: playerRef);
                _players.Add(playerRef, player);

                Runner.SetPlayerObject(playerRef, player.Object);
            }
        }
```
### CustomNetworkDebugStartオブジェクトを無効化

Network Debug Start（CustomNetworkDebugStart.cs）はProjectilesの初期化処理の開始点ですが、Runnerが既に存在している（SocialHubから呼び出した）場合は自動で無効化される機能があるためそのままで問題ありません。

ただし、StartGameの際にNetworkObjectPoolの設定をしているため、この処理はSocialHubのConnectionManagerに追加する必要があります。

NetworkObjectPoolはPhoton FusionのObjectPooling機能で、Despawnしたオブジェクトの再利用だけではなく、Staticなプレイヤー情報の格納にも使われています。

## SocialHub側Startシーンの変更点

### ConnectionManager.csを修正

ProjectilesのRunnerをそのまま生成できるように以下の修正を行います。

- GameManagerでの条件分岐に使うため、SessionPropertyに遷移先の情報を追加
- ProjectilesのRunnerを生成する分岐を追加
- 遷移先のシーンによってsceneManagerを切り替える処理を追加
- RunnerについているNetworkObjectPoolを設定

```csharp
ConnectionManager.cs
public async Task ConnectToRunner(ConnectionData connectionData, Action<NetworkRunner> onInitialized = default, Action<ShutdownReason> onFailed = default)
        {
            //Get correct connection reference.
            var connection = connectionData.Target == Lobby ? _lobbyConnection : _dungeonConnection;
            connection.ActiveConnection = connectionData;

            var gameMode = connectionData.Target == Lobby ? GameMode.Shared : GameMode.AutoHostOrClient;
            SceneRef scene = connectionData.Target == Lobby ? (SceneRef)connectionData.SceneIndex : SceneRef.None;
            var sessionProperties = new Dictionary<string, SessionProperty>()
            { { "ID", (int)connectionData.ID },
                {"Target", connectionData.Target.ToString() } };

            if (connection.Runner == default)
            {
                if (connectionData.Target == ConnectionData.ConnectionTarget.Dungeon)
                {
                    var child = Instantiate(_runner);
                    child.name = connection.ActiveConnection.ID.ToString() + " Projectile";
                    child.transform.SetParent(transform);
                    connection.Runner = child;
                }
                else
                {
                    var child = new GameObject(connection.ActiveConnection.ID.ToString());
                    child.transform.SetParent(transform);
                    connection.Runner = child.AddComponent<NetworkRunner>();
                }
            }

            if (connection.Callback == default)
                connection.Callback = new ConnectionCallbacks();

            if (connectionData.Target == Dungeon)
                connection.Callback.ActionOnShutdown += OnDungeonShutdown;

            if (connection.IsRunning)
            {
                Debug.Log("Shutdown");
                await connection.Runner.Shutdown();
            }

            if (connectionData.Target == Lobby && _dungeonConnection.IsRunning) // Shutdown Dungeon runner if going back to a lobby
                await _dungeonConnection.Runner.Shutdown();

            connection.Runner.AddCallbacks(connection.Callback);
            connection.Runner.AddCallbacks(GetComponent<InputBehaviourPrototype>());

            onInitialized += runner =>
            {
                if (runner.IsServer || runner.IsSharedModeMasterClient)
                    connection.App = runner.Spawn(_app);
            };

            INetworkSceneManager sceneManager;
            if (connectionData.Target == ConnectionData.ConnectionTarget.Dungeon)
            {
                sceneManager = connection.Runner.gameObject.GetComponent<NetworkSceneManager>();
            }
            else
            {
                sceneManager = connection.Runner.gameObject.AddComponent<NetworkSceneManagerDefault>();
            }

            var startResult = await connection.Runner.StartGame(new StartGameArgs()
            {
                GameMode = gameMode,
                SessionProperties = sessionProperties,
                DisableClientSessionCreation = false,
                Scene = scene,
                PlayerCount = connectionData.MaxClients,
                Initialized = onInitialized,
                SceneManager = sceneManager,
                ObjectPool = connection.Runner.GetComponent<INetworkObjectPool>(),
            });

            if (!startResult.Ok)
                onFailed?.Invoke(startResult.ShutdownReason);

        }
```
### Photon向け入力管理スクリプトのコントロールを追加

SocialHubとProjectilesはそれぞれ別の入力管理スクリプトを利用しているため、それぞれのサンプルに付属しているものをそのまま利用するように修正します。

InputBehaviourPrototypeについて、Projectilesシーンでは自身を無効化する処理を追加します。

```csharp
InputBehaviourPrototype.cs

private bool ignoreInput;

  public void OnInput(NetworkRunner runner, NetworkInput input) {
    if (ignoreInput) return;

    var frameworkInput = new NetworkInputPrototype();

// 中略


public void OnSceneLoadDone(NetworkRunner runner) {
        if (runner.SessionInfo.Properties.TryGetValue("Target", out var sessionInfo) && sessionInfo == ConnectionTarget.Dungeon.ToString()) ignoreInput = true;
  }
```
## SocialHub側Lobbyシーンの変更点

ConnectionData（Scriptableobject）は次に呼び出すシーンの設定が保存されています。

Projectilesシーン向けのものを新たに作成して設定を行います。

![作成するConnectionDataの例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/fc77f3d8-fa4c-f4f3-fafd-a6ba095b3bfc.png)

# まとめ

全体のシーン構成が単一と複数の場合の主な実装の違いや、既存のシーンを合わせる時に考慮する部分について紹介しました。

変更後も、SocialHubからでもProjectilesからでも元と同じ手順で動作するようになっています。

ただし、現段階では2つのシーンをくっつけただけなので、操作システムやプレイヤーオブジェクトの統一ができていません。プレイをする上で問題はないですが、似たような機能が複数あるというのは今後実装を拡張していくにあたって障害になる可能性が高いため、ゆくゆくは統一する必要があるでしょう。