【Unity】Photon Fusion の Host Migration で ResumeSnapshot を Client の状態まで復元するクラス

# 概要

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

Photon Fusion が用意している Host Migration 機能は、最後の復元用 Snapshot (以下、ResumeSnapshot) まで時間が遡ってしまいます。
私は、Client が持っている状態を反映すれば時間を遡らずに済むと考えました。
そこで、Client の状態を記録し ResumeSnapshot から Client の状態を復元できる **ClientStateRestorer** クラスを作りました。

## 留意点

### Host Migration

いつ再生成されても問題が無いように実装する必要があります。再接続や Host Migration を考慮せずに実装していた場合、全体の見直しが必要です。
Photon Fusion の用意する Host Migration 機能は全オブジェクトの再生成で実現するため、Host Migration 中は動きが止まります。

# コード

https://github.com/yamara-mh/CodeStorage/blob/main/Codes/ClientStateRestorer.cs

[PhotonFusion の開発が捗る拡張メソッドを用意したクラスはこちら](https://github.com/yamara-mh/CodeStorage/blob/main/Codes/PhotonFusionEx.cs)

### ClientStateRestorer
Host/Client モードを前提にしています。
SceneObject には対応していません。**全ての NetworkObject は Runner.Spawn() から生成する必要があります**。

Runner.PushHostMigrationSnapshot() 後の以下の処理には対応していません。
- SimulationBehaviour の追加
- 他にもあるかも

対応していないものに関して、大抵の場合は記録した情報から復元できるかと思います。難しい際は```PushHostMigrationSnapshot()```を呼ぶと、Photon Fusion がサポートしている範囲で復元できます。

### 遅れている Tick への対応

HostMigrationResume() で受け取れる Runner の Snapshot は、前の Host が最後に送ったものなので Tick が過去を指しています。
Runner の Tick が過去のまま Client の状態を復元すると不具合の原因になるので、下記の方法で Runner の Tick を現在の Tick まで進めています。
``` C#
while (runner.Tick != Tick) runner.Simulation.Update(runner.DeltaTime);
```

### ResumeSnapshot より後に生成された NetworkObject への対応

Runner.PushHostMigrationSnapshot() 後に Spawn() された NetworkObject は再生成されるものの、**NetworkId が変わります**。
対応するための関数```ReceiveUpdateNetworkId()```を用意していますが、そもそも生成して欲しくない際はコンストラクタの ```restoreSpawnOnAfterSnapshot```フラグを無効にしてください。

### Client 予測の使用

コンストラクタの```usePredictionsOfPhysicsAndPosRot```では物理と座標の記録に対して、Host から最後に送られた Snapshot を記録するか、Client 予測を記録するかを選択できます。
ゴルフゲームのような物理挙動を見守るゲームであれば、Client 予測の情報を使用しても問題は起きにくいはずです。
レースゲームのような常に移動方向が変化するゲームでは、位置が大きく変わる恐れがあります。

# 使い方

## 前準備

重要な変化が起きたタイミングで```PushHostMigrationSnapshot()```を呼ぶようにします（何かをSpawnした、SimulationBehaviour を追加した、HPが0になった等）。

``` C#
public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
    /* プレイヤー生成等の処理 */

    if (runner.IsServer) await runner.PushHostMigrationSnapshot();
}
```

## 記録と復元の処理

SimulationBehaviour を継承したクラスで以下のように記述すると、値の記録と復元ができます。

``` C#
[Networked] Color Color { get; set; }
[Networked] float Size { get; set; }

public override OnSpawned()
{
    ...
    Runner.StoreAndTryRestore<(Color color, float size)>(
        Id, // 一意の値
        new Lazy(() => (Color, Size)), // 記録する値を登録
        receive => { // 古い Runner の Storer の Id に記録された値がある場合、実行されます
            if (!Runner.IsServer) return;
            Color = receive.color;
            Size = receive.size;
        });
}
```

SimulationBehaviour でなくても一意の値があれば、記録と復元ができます。

## 記録の実行

Shutdown 前に```Store()```を呼ぶと、storer に NetworkObject の状態が記録されます。
また前準備として、新しい Runner を生成したら```SetOldRunner()```で古い Runner を登録しておきます。

``` C#
public async void OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken)
{
    runner.Store(no =>
    {
        // 第二引数で記録する条件を設定できます（下記は、元Host に入力権限があるものを記録しないという条件です）
        return no.InputAuthority != runner.SessionInfo.MaxPlayers - 1;
    });
    await runner.Shutdown(shutdownReason: ShutdownReason.HostMigration).AsUniTask();

    var newRunner = /* Runner を生成する処理 */
    newRunner.SetOldRunner(runner);
    ...
```

## 復元の実行

HostMigrationResume() で```SpawnsAndRestores()```を呼ぶと、ResumeSnapshot を再生成した後、storer に記録した情報が復元されます。

``` C#
void HostMigrationResume(NetworkRunner runner)
{
    // 第一引数で生成順を変更できます
    runner.SpawnsAndRestores(list => list.OrderBy(no => no.Id),
    (R, NO) =>
    {
        // 第二引数で onBeforeSpawnd に処理を追加できます
    }, (R, NO) =>
    {
        // 第三引数で 生成直後 に処理を追加できます
    });
}
```

## 記録した情報の破棄

OnPlayerJoined() で```RemoveOldRunnerAndStorer()```を呼ぶと、古い runner と storer の参照を無くせます。

``` C#
public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
    /* プレイヤー生成等の処理 */

    runner.RemoveOldRunnerAndStorer();
    if (runner.IsServer) await runner.PushHostMigrationSnapshot();
    ...
```

## 再生成直後の処理

復元のため Networked プロパティを変更すると、意図せずイベントを実行する恐れがあります。
そのような時は```Runner.IsRestoring()```を使用すると、イベントの実行を止められます。

``` C#
if (!Runner.IsRestoring()) // イベント実行
```

# 余談

## オブジェクトが一瞬消える現象の解決方法

Camera を無効にしている間は描画の更新が止まるので、それを利用すると消えているように見えなくなります。

``` C#
public async void OnHostMigration(NetworkRunner runner, HostMigrationToken hostMigrationToken)
{
    _camera.enabled = false;
    ...
```

``` C#
public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
    ...
    if (runner.LocalPlayer == player) _camera.enabled = true;
}
```


## Host Migration Snapshot Interval の最大値

```NetworkProjectConfig.fusion```の```Host Migration Snapshot Interval```を2147484秒以上にするとフリーズするようです。21474843秒は約248.5日です。