3 - 予測
概要
ここでは、サーバー主導(Server Authoritative)のネットワークゲームでクライアントサイド予測を使用して、クライアントへ即時のフィードバックを与える方法を説明します。
この章では、予測で動くキネマティック(Kinematic)なボールをスポーンする方法を学びます。
キネマティックオブジェクト
まず、スポーンするオブジェクトのプレハブを作成しましょう。
- 新規で空のゲームオブジェクトを作成する
- 上記の名前を
Ball
にする - 上記に
NetworkTransform
コンポーネントを追加する NetworkObject
が不足している警告が表示されるので、Add Network Object
を押すBall
の子オブジェクトとして、Sphere
を追加する- 上記のスケールを
0.2
にする - 上記から
Collider
を削除する Ball
側にSphereCollider
を追加し、半径を0.1
にすることでSphere
と見た目を合わせるBall
に新規スクリプトを追加し、名前をBall.cs
にする- 上記オブジェクトをプロジェクトフォルダにドラッグし、プレハブを作成する
- シーンを保存してネットワークオブジェクトをベイクし、シーンから
Ball
を削除する
予測動作
目標は、全てのピア上でBall
が同時に同様の挙動を取るようにすることです。
ここでいう「同時」とは「同じティック上で」という意味で、現実世界の時間での同時とは異なります。これを実現する方法は、以下の通りです。
- サーバーは、一定の等間隔でシミュレーションを実行し、そのティックごとに
FixedUpdateNetwork()
を呼び出します。ティックを進める処理は、常にサーバーのみで行われます。これはUnity標準のFixedUpdate()
と同じような挙動です。サーバーは各ティックで計算を終えた後、直前のティックとの状態の差分を圧縮し、ブロードキャストで送信します。 - クライアントは、上記のスナップショットを定期的な間隔で受信しますが、当然サーバーより常に遅延したものになります。クライアントはスナップショットを受信すると、内部の状態をスナップショットのティックまで戻した後、すぐにそのティックからクライアントの現在ティックまでの再シミュレーションを実行します。
- クライアントの現在ティックは、常にサーバーより十分な余裕をとって先行しています。ユーザーから収集した入力は、サーバーが(クライアントの現在ティックと)同じティックになる前にサーバーに到達するため、サーバー側で正しく入力が適用されることになります。
これは、いくつかの意味合いも含まれます。
- クライアントは、フレームごとに何度も
FixedUpdateNetwork()
が実行されます。また、最新のスナップショットを受信するたびに、同一のティックのシミュレーションが何度も実行されます。ネットワーク上の状態はFixedUpdateNetwork()
が呼び出される前にFusionによって直前のティックへ自動的にリセットされますが、ローカル上の状態はその限りではないので十分な注意が必要になります。 - 各ピアは、あらゆるオブジェクトの未来の状態(位置・速度・加速度・その他の決定論的なプロパティなど)を予測できます。他のプレイヤーの入力は予測できないため、その予測は失敗する可能性があります。
- クライアントのローカルの入力は、すぐに適用されて即時のフィードバックを得られますが、状態を変更する権限があるわけではありません。ローカルの入力の適用はあくまで予測であり、最終的なスナップショットを生成するのはサーバーです。
上記を念頭に置いて、作業を進めていきましょう。Ball
スクリプトを開き、MonoBehaviour
をNetworkBehaviour
に変更して、FixedUpdateNetwork()
メソッドを追加してください。
今回のシンプルな例では、Ball
は一定の速度で5秒間前進した後、自らをデスポーンします。まず以下のように、オブジェクトに単純な直線運動を追加しましょう。
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; }
[Networked]
属性が付いたプロパティと{get; set;}
スタブは、Fusionの(自動的な)シリアライゼーションのコード生成に使用されます。必ず上記パターンに従ったコードを記述してください。
タイマーはオブジェクトをスポーンする前に設定したい所ですが、Spawned()
はローカル上でオブジェクトがスポーンした直後に呼び出されるため、ネットワーク上の状態の初期化の使用には適しません。
Spawned()
のかわりに、プレイヤー側から呼び出すことができるInit()
メソッドを作成し、life
プロパティを5秒先に設定する際に使用できるようにしておきます。これにはTickTimer
のCreateFromeSeconds()
という静的メソッドが最適です。
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 MOUSEBUTTON0 = 1;
public NetworkButtons buttons;
public Vector3 direction;
}
NeyworkButtons
型はFusionで定義されている型で、複数のボタン入力状態の追跡に便利で、帯域幅使用量も最適化されています。
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;
data.buttons.Set( NetworkInputData.MOUSEBUTTON0, _mouseButton0);
_mouseButton0 = false;
input.Set(data);
}
Player
クラスを開き、GetInput()
の中でボタンの押下をチェックして、プレハブをスポーンします。プレハブは、Unityのインスペクターからアタッチされる[SerializeField]
で渡されます。プレイヤーアバターの向いている方向にボールをスポーンできるようにするため、変数に記録した直前の移動方向をボールの進行方向に使用します。
C#
[SerializeField] private Ball _prefabBall;
private Vector3 _forward = Vector3.forward;
if (GetInput(out NetworkInputData data))
{
if (data.direction.sqrMagnitude > 0)
_forward = data.direction;
if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON0))
{
Runner.Spawn(_prefabBall,
transform.position+_forward, Quaternion.LookRotation(_forward),
Object.InputAuthority);
}
}
ボールのスポーン頻度を制限するため、スポーンごとに制限タイマーを設定します。タイマーはボタンの押下を検出した時にリセットします。
C#
[Networked] private TickTimer delay { get; set; }
if (HasStateAuthority && delay.ExpiredOrNotRunning(Runner))
{
if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON0))
{
delay = TickTimer.CreateFromSeconds(Runner, 0.5f);
Runner.Spawn(_prefabBall,
transform.position+_forward, Quaternion.LookRotation(_forward),
Object.InputAuthority);
}
}
NetworkObject
をスポーンできるのはStateAuthority
を持つホストのみなので、StateAuthority
のチェックが必要です。プレイヤーアバターの移動とは異なり、ボールのスポーンのクライアントサイド予測は行われません。
まだボールの初期化処理を行っていないため、Spawn
の呼び出しに微調整が必要です。具体的には、先ほど定義したInit()
メソッドを呼び出して、タイマーを適切に初期化しなければなりません。
Spawn()
の引数には、プレハブがインスタンス化された後かつ、プレハブが同期される前に呼び出されるコールバックが提供されています。
まとめると、クラスの実装は以下のようになります。
C#
using Fusion;
using UnityEngine;
public class Player : NetworkBehaviour
{
[SerializeField] private Ball _prefabBall;
[Networked] private TickTimer delay { get; set; }
private NetworkCharacterController _cc;
private Vector3 _forward;
private void Awake()
{
_cc = GetComponent<NetworkCharacterController>();
_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 (HasStateAuthority && delay.ExpiredOrNotRunning(Runner))
{
if (data.buttons.IsSet(NetworkInputData.MOUSEBUTTON0))
{
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
にドラッグしてください。