3 - 予測
概要
Fusion 103では、予測と、サーバー主導のネットワークゲームでどのようにしてクライアントへ即時のフィードバックを与えるかについて説明します。
このセクションの最後には、プロジェクトでプレイヤーが予測が有効になったKinematicなボールをスポーンできるようになります。
Kinematicなオブジェクト
オブジェクトをスポーンできるようにするために、まずプレハブを作成しましょう。
- Unity Editorで空のGameObjectを新規作成する
- 名称をBallとする
- 2に新規のNetworkTransformコンポーネントを追加する
- FusionからNetworkObjectコンポーネントが不足している旨の警告が表示されるので、Add Network Objectを押す
- Interpolation Data Sourceを- Predictedへ変更し、- World Spaceに設定する
- BallにSphereの子を追加する
- スケールを0.2にサイズダウンする
- 子を、親オブジェクトのNetworkTransformコンポーネントのInterpolationTargetへドラッグする。これによりNetworkTransformが、メインの(ネットワーク上の状態へスナップする)ネットワークオブジェクトと、スムーズなビジュアルの補間(子オブジェクト)を区別できるようになる
- 子Sphereからコライダーを削除する
- 代わりに親オブジェクトにSphereコライダーを新規作成し、半径を0.1にすることで子オブジェクトのビジュアル表現を完全にカバーできるようにする
- ゲームオブジェクトにスクリプトを新規追加し、Ball.csと名前を付ける
- Ballオブジェクト全体をプロジェクトフォルダにドラッグし、プレハブを作成する
- シーンを保存してネットワークオブジェクトをベイクし、シーンからプレハブインスタンスを削除する
 
    予測動作
目標は、全てのピアで同時にBallインスタンスが同様の挙動をとることです。
ここでいう「同時」とは、「同じティックで」ということで、現実世界の時間と同様ではありません。これを実現するために以下の手順を行います。
- サーバーはシミュレーションを特定の等間隔のティックで実行し、ティックごとにFixedUpdateNetwork()を呼び出します。常にティックを先に進める処理を行うのはサーバーのみで、これは一般的なUnityの物理シミュレーションで実行されるFixedUpdate()と全く同様です。各ティックの後、サーバーはネットワーク上の状態の変更を前のティックと比較して計算し、圧縮し、ブロードキャストで送信します。
- このスナップショットを、クライアントは定期的な間隔で受信しますが、当然サーバーより常に遅延しているものとなります。クライアントはスナップショットを受信すると、内部の状態をスナップショットのティックまで戻し、すぐにスナップショットのティックからクライアントの最新ティックまでの再シミュレーションを実行します。
- クライアントの最新ティックは、常にサーバーより十分な余裕を持って先に進んでいるので、ユーザーから収集した入力は、サーバーがそのティックに到達して入力が必要になる前にサーバーに送られます。
これは多くの意味をもちます。
- クライアントは、フレームごとにFixedUpdateNetwork()を何度も実行する、最新のスナップショットを受信するたびに同一ティックのシミュレーションを何度も実行することになります。Fusionは、ネットワーク上の状態をFixedUpdateNetwork()を呼び出す前にその直前のティックにリセットしますが、ローカル上の状態はその限りではないので十分な注意が必要です。
- 各ピアは、あらゆるオブジェクトの位置・速度・加速度やその他の決定論的なプロパティの未来の状態を、予測してシミュレーションすることができます。予測できないものの一つは他のプレイヤーの入力で、その場合には予測は失敗してしまうでしょう。
- ローカルの入力は即時フィードバックのためにクライアントにすぐ適用されますが、クライアントに権限自体はありません。ローカルの入力の適用はあくまで予測であり、最終的にあるティックを定義するスナップショットを生成するのはサーバーになります。
このことを念頭に置いて、Ballスクリプトを開き、ベースクラスをNetworkBehaviourに変更してFusionシミュレーションループに含め、事前生成されたボイラープレートのコードをFusionのFixedUpdateNetwork()のオーバーライドに置き換えます。
この単純な例では、Ballは一定の速度で前方向に5秒間移動した後、自らデスポーンします。次のように、オブジェクトのTransformに単純な直線の動きを追加してみましょう。
C#
using Fusion;
public class Ball : NetworkBehaviour
{
  public override void FixedUpdateNetwork()
  {
    transform.position += 5 * transform.forward * Runner.DeltaTime;
  }
}
これは通常のUnityのオブジェクトを移動するために使用されるコードとほぼ同じですが、タイムステップはTime.deltaTimeではなくティック間の時間に対応するRunner.DeltaTimeになっています。Unityのtransformのようなローカルのプロパティがネットワーク上で機能している秘訣は、もちろんNetworkTransformコンポーネントにあります。NetworkTransformは、transformプロパティをネットワーク上の状態の一部にすることを保証する便利な方法です。
ボールが無限に飛んでいってしまわないように、設定された時間が経過した後にオブジェクトをデスポーンするコードが必要です。Fusionは、TickTimerという名前のタイマー用の便利なヘルパーの型を提供しています。現在の残り時間を保存するかわりに、終了時間をティック単位で保存すると、タイマーは毎ティック同期する必要がなくなり、作成時に一度だけ同期して済むようになります。
ゲームのネットワーク上の状態にTickTimerを追加するには、BallにTickTimer型のlifeという名前のプロパティを追加し、getterとsetterの空スタブと[Networked]属性を追加します。
C#
[Networked] private TickTimer life { get; set; }
タイマーはオブジェクトをスポーンする前に設定してください。Spawned()はローカルでインスタンスが生成された後にのみ呼び出されるので、ネットワーク上の状態を初期化するために使用するべきではありません。
上記のかわりに、プレイヤーから呼び出すことができるInit()メソッドを作成して、lifeプロパティを5秒先に設定する時に使えるようにします。これにはTickTimerのCreateFromSeconds()という静的なヘルパーメソッドを使うのが最善です。
C#
public void Init()
{
  life = TickTimer.CreateFromSeconds(Runner, 5.0f);
}
最後に、FixedUpdateNetwork()の更新中にタイマーが切れたかどうか確認します。
切れていたら、ボールのデスポーンを行います。
C#
if(life.Expired(Runner))
  Runner.Despawn(Object);
全体的なBallクラスは以下の様な見た目になります。
C#
using Fusion;
public class Ball : NetworkBehaviour
{
  [Networked] private TickTimer life { get; set; }
  public void Init()
  {
    life = TickTimer.CreateFromSeconds(Runner, 5.0f);
  }
  public override void FixedUpdateNetwork()
  {
    if(life.Expired(Runner))
      Runner.Despawn(Object);
    else
      transform.position += 5 * transform.forward * Runner.DeltaTime;
  }
}
プレハブのスポーン
プレハブのスポーンは、プレイヤーアバターのスポーンと同じ仕組みですが、プレイヤーのスポーンはネットワークのイベント(プレイヤーのゲームセッションへの参加)が起点になるのに対して、ボールはユーザーの入力を起点にしてスポーンされます。
これを実現するには、入力構造体に追加のデータを加える必要があります。以下の三つの手順を行ってください。
- 入力構造体にデータを追加する
- Unityの入力からデータを集める
- 各プレイヤーのFixedUpdateNetwork()中の実装で入力を適用する
NetworkInputDataを開いて、buttonsと呼ばれるバイトフィールドを新しく追加し、一つ目のマウスボタンを指す定数を定義します。
C#
using Fusion;
using UnityEngine;
public struct NetworkInputData : INetworkInput
{
  public const byte MOUSEBUTTON1 = 0x01;
  public byte buttons;
  public Vector3 direction;
}
BasicSpawnerを開いて、OnInput()メソッドに移動し、マウス左ボタン用のチェックを追加して、これが押されているならbuttonsフィールドの最初のビットを設定します。素早いタップを見逃すことのないように、マウスボタンはUpdate()でサンプリングして、入力構造体に記録してからリセットします。
C#
private bool _mouseButton0;
private void Update()
{
  _mouseButton0 = _mouseButton0 | Input.GetMouseButton(0);
}
public void OnInput(NetworkRunner runner, NetworkInput input)
{
  var data = new NetworkInputData();
  if (Input.GetKey(KeyCode.W))
    data.direction += Vector3.forward;
  if (Input.GetKey(KeyCode.S))
    data.direction += Vector3.back;
  if (Input.GetKey(KeyCode.A))
    data.direction += Vector3.left;
  if (Input.GetKey(KeyCode.D))
    data.direction += Vector3.right;
  if (_mouseButton0)
    data.buttons |= NetworkInputData.MOUSEBUTTON1;
  _mouseButton0 = false;
  input.Set(data);
}
Playerクラスを開き、GetInput()の中でボタンのビットを取得し、最初のビットが設定されていたらプレハブをスポーンします。プレハブは、Unityのインスペクター上からアタッチできる[SerializeField]メンバーから渡されます。異なる方向でスポーンできるようにするには、変数で最新の移動方向を保存して、この値をボールの進行方向として使用します。
C#
[SerializeField] private Ball _prefabBall;
private Vector3 _forward;
if (GetInput(out NetworkInputData data))
{
  if (data.direction.sqrMagnitude > 0)
    _forward = data.direction;
  if ((data.buttons & NetworkInputData.MOUSEBUTTON1) != 0)
  {
      Runner.Spawn(_prefabBall,
      transform.position+_forward, Quaternion.LookRotation(_forward),
      Object.InputAuthority);
  } 
}
スポーン頻度を制限するために、スポーンごとに時間を制限するタイマーでスポーン処理をラップします。そして、ボタンの押下を検知した時のみタイマーをリセットします。
C#
[Networked] private TickTimer delay { get; set; }
if (delay.ExpiredOrNotRunning(Runner))
{
  if ((data.buttons & NetworkInputData.MOUSEBUTTON1) != 0)
  {
    delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
    Runner.Spawn(_prefabBall,
    transform.position+_forward, Quaternion.LookRotation(_forward),
    Object.InputAuthority);
  }
}
ボールは、同期される前に追加の初期化処理が必要になるため、実際のSpawnの呼び出しは少し修正する必要があります。具体的には、前に定義していたInit()メソッドを呼び出して、タイマーが適切に設定されることを保証しなければなりません。
この目的のために、FusionはSpawn()の引数で、プレハブがインスタンス化された後かつプレハブが同期される前に実行されるコールバックを指定できます。
まとめると、クラスの実装は次のようになります。
C#
using Fusion;
using UnityEngine;
public class Player : NetworkBehaviour
{
  [SerializeField] private Ball _prefabBall;
  [Networked] private TickTimer delay { get; set; }
  private NetworkCharacterControllerPrototype _cc;
  private Vector3 _forward;
  private void Awake()
  {
    _cc = GetComponent<NetworkCharacterControllerPrototype>();
    _forward = transform.forward;
  }
  public override void FixedUpdateNetwork()
  {
    if (GetInput(out NetworkInputData data))
    {
      data.direction.Normalize();
      _cc.Move(5*data.direction*Runner.DeltaTime);
      if (data.direction.sqrMagnitude > 0)
        _forward = data.direction;
      if (delay.ExpiredOrNotRunning(Runner))
      {
        if ((data.buttons & NetworkInputData.MOUSEBUTTON1) != 0)
        {
          delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
            Runner.Spawn(_prefabBall,
            transform.position+_forward, Quaternion.LookRotation(_forward),
            Object.InputAuthority, (runner, o) =>
            {
              // Initialize the Ball before synchronizing it
              o.GetComponent<Ball>().Init();
            });
        }
      }
    }
  }
}
テスト前の最後の手順は、プレハブをPlayerプレハブの_prefabBallフィールドにアサインすることです。プロジェクトでPlayerPrefabを選択して、Ballプレハブを _prefabBallフィールドにドラッグします。