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
フィールドにドラッグします。