🧬

【Photon Fusion】ホストマイグレーションの話

2023/01/09に公開約8,100字

はじめに

Photon FusionのHostModeでは、ホストがセッションから退出した時点でセッションは終了(残されたクライアントは全員解散)してしまいます。
その際に、ホストマイグレーション(Host Migration)が有効になっていれば、セッション内の別クライアントの1つを新しいホストとして、旧セッションからのスナップショット(ネットワークの状態)を引き継いだ新しいセッションでゲームを継続できるようになります。

詳細は公式ドキュメントとサンプルをご確認ください!と言いたい所ですが、結構わかりづらい所や落とし穴があるので、その辺りの補足をしつつ基本的な手順をまとめ直してみました。

https://doc.photonengine.com/ja-jp/fusion/current/manual/host-migration
https://doc.photonengine.com/ja-jp/fusion/current/technical-samples/fusion-host-migration

開発環境

  • Unity 2022.2.1f1
  • Fusion SDK 1.1.5 F2 Build 643

準備

ホストマイグレーションは「開発終盤にちょっと設定するだけで、Fusionが裏側で全部いい感じに引き継ぎ処理を実行してくれるようになる」ものではないことに注意です。便利なAPIは用意されていますが、旧セッションから新セッションへどのデータをどう引き継いでゲームの状態を復元するか?などは、ゲーム開発者側が実装するものになっています。そのため、まずゲーム自体の仕様や実装がホストマイグレーションの実行が可能な形になっているか確認しましょう。

NetworkRunnerはPrefabから生成できるようにする

旧セッションを切断して新セッションへと接続する際に、旧セッションのNetworkRunner再利用できません。ホストマイグレーションが実行されるたびに、旧セッションのNetworkRunnerを停止した後、新セッションへと接続するためのNetworkRunnerを生成することになります。
ゲームの接続回りの実装は、柔軟にNetworkRunnerのインスタンスを生成して利用できるような仕組みにしておくと、ホストマイグレーションの実装も少し楽になるでしょう。

プレイヤーはConnectionTokenで識別できるようにする

プレイヤーのIDとして利用できるplayer.PlayerIdはあくまでセッション内でユニークな値です。同じプレイヤーでも、旧セッションと新セッションでは(偶然一致するケースを除き)別のIDが割り当てられるので、ConnectionTokenを使ってプレイヤーを識別できるようにしましょう。

セッション接続時に、プレイヤーのConnectionTokenを生成して送信する例
  private NetworkRunner runner;
+ private byte[] connectionToken;

  public async UniTask StartGame() {
+     // ConnectionTokenを一意な識別子として使うならGUIDで生成するのが簡単
+     connectionToken = Guid.NewGuid().ToByteArray();

      runner = Instantiate(networkRunnerPrefab);
      runner.ProvideInput = true;
      var result = await runner.StartGame(new StartGameArgs {
          GameMode = GameMode.AutoHostOrClient,
          SessionName = "GameRoom",
          Scene = SceneManager.GetActiveScene().buildIndex,
          SceneManager = runner.GetComponent<NetworkSceneManagerDefault>(),
+         ConnectionToken = connectionToken
      });
  }
プレイヤーのネットワークオブジェクトに、ConnectionToken(のハッシュ値)を紐づける例
  public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) {
      if (!runner.IsServer) { return; }

+     int token = new Guid(runner.GetPlayerConnectionToken(player)).GetHashCode();
      runner.Spawn(playerAvatarPrefab, Vector3.zero, Quaternion.identity, player,
+         (_, obj) => { obj.GetComponent<PlayerAvatar>().Token = token; }
      );
  }
入力権限を持つプレイヤーのConnectionToken(のハッシュ値)を同期するネットワークプロパティの例
  public class PlayerAvatar : NetworkBehaviour
  {
+     [Networked] public int Token { get; set; }
  }

ネットワークオブジェクト復元時のSpawned()を考慮する

NetworkBehaviourSpawned()は、新セッションでネットワークオブジェクトが復元された時も実行されます。ネットワークオブジェクトがホストマイグレーションで復元されたものかどうかはObject.IsResumeで判定できるので、誤ったタイミングで初期化処理などが実行されないようにしましょう。

  public override void Spawned() {
+     if (!Object.IsResume) {
          // 初期化処理
      }
  }

ゲームの状態はスナップショットから復元できるようにする

旧セッションからのスナップショットを引き継いだ新セッションを開始してゲームを継続できるようにするには、ゲーム途中のどんなタイミングのスナップショットからでも、ゲームの状態を正しく復元できるようにしておく必要があります。これは少し言い換えると、(実際にゲーム中の途中参加を許可するかどうかに関わらず)ゲームに途中参加しても同期がずれないような状態を常に維持しておく必要があるということでもあります。

例えば、(通信量の削減などのために)同期に重要な情報をネットワークプロパティを使わずにRPCのみでやり取りしていると、ホストマイグレーション実行時に重要な情報が復元されない可能性があるので要注意です。

Runner.Spawn()からネットワークオブジェクトを生成する

https://forum.photonengine.com/discussion/21500/anyone-working-host-migrating-photon-fusion-advance-asteroids-in-unity-2022-2-1f1

設定

ホストマイグレーションを有効にするには、NetworkProjectConfigEnable Host Migrationにチェックを入れます。Host Migration Snapshot Intervalは、セッションが開始されてからホストマイグレーションが有効になるまでの時間になります。この時間が経過する前にホストがセッションから退出した場合は、ホストマイグレーションを有効にしていてもホストマイグレーションは実行されません。デフォルトは60秒、公式ドキュメントでは(多くの場合は)30秒が推奨されていますが、個人的には5~15秒くらいに短く設定する方が良さそうかなと思っています。

実装

OnHostMigration

ホストがセッションを退出すると、そのセッション内に残された全てのクライアントINetworkRunnerCallbacks.OnHostMigration()コールバックが呼ばれます。ここで旧セッションの切断と新セッションへの接続処理を行います。旧セッションからの引き継ぎが必要な情報は全てHostMigrationTokenに含まれているので、これをrunner.StartGame()の引数に渡します。

public async void OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken) {
    // 旧セッションのNetworkRunnerを停止する
    await runner.Shutdown(true, ShutdownReason.HostMigration);

    // 新セッションのNetworkRunnerを生成する
    runner = Instantiate(networkRunnerPrefab);
    runner.ProvideInput = true;
    runner.AddCallbacks(this);

    // 旧セッションの情報を渡して、新セッションに接続する
    await runner.StartGame(new StartGameArgs {
        SceneManager = runner.GetComponent<NetworkSceneManagerDefault>(),
        HostMigrationToken = hostMigrationToken,
        HostMigrationResume = HostMigrationResume,
        ConnectionToken = connectionToken
    });
}

INetworkRunnerCallbacks.OnShutdown()でセッション終了時の処理を実装している場合は、旧セッションの切断時に誤った処理が実行されてしまう可能性があるので気を付けておきましょう。

  public void OnShutdown(NetworkRunner runner, ShutdownReason shutdownReason) {
+     if (shutdownReason != ShutdownReason.HostMigration) {
          // セッション通常終了時の処理
      }
  }

HostMigrationResume

新セッションに接続すると、ホストのみHostMigrationResumeに指定したコールバックが呼ばれます。ホストはrunner.GetResumeSnapshotNetworkObjects()から、旧セッションのスナップショット(全てのネットワークオブジェクトの状態)が取得できるので、これを元にしてネットワークオブジェクトを復元する処理を行います。

旧セッションのスナップショットには、旧セッションのホストが入力権限を持っているネットワークオブジェクト(例えばホストのアバター)なども含まれます。Fusionの仕様で、ホストのプレイヤーのIDは常に「セッションの最大接続人数 - 1」になっているので、これを利用して不要なネットワークオブジェクトが復元されないようにすると良いでしょう。

private static void HostMigrationResume(NetworkRunner runner) {
    foreach (var resumeObj in runner.GetResumeSnapshotNetworkObjects()) {
        // 旧セッションのホストのアバターは復元しない
        if (resumeObj.TryGetBehaviour<PlayerAvatar>(out _)) {
            int hostId = runner.SessionInfo.MaxPlayers - 1;
            if (resumeObj.InputAuthority.PlayerId == hostId) { continue; }
        }

        // NetworkTransformやNetworkRigidbodyなどを利用している場合は、
        // 親クラスのNetworkPositionRotationからpositionとrotationを取得できる
        bool hasNetworkTransform = resumeObj.TryGetBehaviour<NetworkPositionRotation>(out var networkTransform);
        var position = (hasNetworkTransform) ? networkTransform.ReadPosition() : Vector3.zero;
        var rotation = (hasNetworkTransform) ? networkTransform.ReadRotation() : Quaternion.identity;

        // 新セッションのネットワークオブジェクトを生成する
        runner.Spawn(resumeObj, position, rotation, onBeforeSpawned: (_, newObj) => {
            newObj.CopyStateFrom(resumeObj);
        });
    }
}

HostMigrationResumeのコールバックを実行している段階では、まだ新ホストには誰も接続していない状況なので、ネットワークオブジェクトの入力権限は(ホスト以外)復元できません

クライアントの入力権限を復元するには、プレイヤーが参加した際のOnPlayerJoined()コールバックで、参加したプレイヤーが入力権限を持つネットワークオブジェクトが存在していないか?を調べて、該当するネットワークオブジェクトの入力権限を更新する処理を追加しましょう。

  public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) {
      if (!runner.IsServer) { return; }

      int token = new Guid(runner.GetPlayerConnectionToken(player)).GetHashCode();
+     // アバターのリストを取得する
+     var avatarList = FindObjectsOfType<PlayerAvatar>();
+     // ConnectionTokenを比較して、参加したプレイヤーのアバターが生成済みか調べる
+     var newAvatar = avatarList.FirstOrDefault(avatar => avatar.Token == token);
+
+     if (newAvatar != null) {
+         // 既にアバターが生成されていたら、そのアバターの入力権限を更新する
+         newAvatar.Object.AssignInputAuthority(player);
      } else {
          // 新規のプレイヤーなら、通常通りアバターを生成する
          runner.Spawn(playerAvatarPrefab, Vector3.zero, Quaternion.identity, player,
              (_, obj) => { obj.GetComponent<PlayerAvatar>().Token = token; }
          );
      }
}

おわりに

いかがでしたか? ホストマイグレーションは、FusionのHostModeでオンラインゲームを開発するなら是非おさえておきたい機能の一つですが、仕組みを理解してちゃんと動作させるまで中々苦労することになる機能だと思うので、この記事が何かしらの参考になれば幸いです。

ホストマイグレーションが有効ならばゲームのユーザー体験が向上する大きなメリットがあることは間違いありませんが、ホストマイグレーションのためにホストが必ずPhoton Cloudと通信を行うようになる分だけ通信コストや費用が増えてしまう可能性があるなどのデメリットも0ではないので、その辺りも考慮してホストマイグレーションの導入を決めていきましょう。

Discussion

ログインするとコメントできます