イベントとコールバック
イントロ
シミュレーション(Quantum)とビュー(Unity)を分けることで、ゲームステートとビジュアルの開発時にモジュール性を高めることができます。ただし、ビューは自身を更新するためにゲーム状態からの情報を必要とします。Quantumはそのために2つの方法を提供します。
- ゲームステートのポーリング
- イベント/コールバック
どちらも有効なアプローチではありますが、ユースケースは若干異なります。一般的に、UnityからのQuantum情報をポーリングすることは、継続的なビジュアルに適しています。一方、イベントは、ゲームシミュレーションがビューに反応をトリガーするような時間が重要な場合に使用されます。このドキュメントでは、フレームイベントとコールバックに焦点を当てます。
フレームイベント
イベントは、シミュレーションからビューに情報を転送するためのファイア・アンド・フォゲットメカニズムです。ゲームステートの変更や更新に使ってはいけません(その用途にはSignals
を使用します)。イベントには予測やロールバックの際にイベントを管理するのに役立つ、理解すべきいくつかの重要な側面があります。
- イベントはクライアント間で同期を取らず、各クライアント自身のシミュレーションによって発生します。
- 同じFrameを複数回シミュレーションすることができるため(予測、ロールバック)、イベントが複数回トリガーされる可能性があります。重複するイベントを避けるために、Quantum はイベントデータ、イベント ID、tick に対してハッシュコード関数を使用して重複を識別します。詳しくは
nothashed
キーワードを参照してください。 - 通常のイベント(非同期イベント)は、イベントが発生した予測フレームが検証されると、キャンセルされるかまたは確認されます。詳しくは
Canceled And Confirmed Events
を参照してください。 - イベントは、すべてのFrameがシミュレートされた後、
OnUpdateView
コールバックの直後にディスパッチされます。イベントは、起動された順番に呼び出されます。ただし、synced
されていないイベントは例外で、重複していると判断されるとスキップされます。このタイミングにより、対象となるEntityView
はすでに破壊されている可能性があります。
もっとも単純なEventとその使用は、以下のとおりです:
- Quantum DSLを使用してEventを定義
C#
event MyEvent { int Foo; }
- シミュレーションからEventをトリガー
C#
f.Events.MyEvent(2022);
- そしてUnityでイベントを購読して消費し、イベント用のクラスを生成します。接頭辞は
Event
となります。C#
QuantumEvent.Subscribe(listener: this, handler: (EventMyEvent e) => Debug.Log($"MyEvent {e.Foo}"));
DSL構造
イベントとそのデータはqtnファイル内のQuantum DSLを使用して定義されます。プロジェクトをコンパイルし、シミュレーション内のFrame.Events
APIによって、イベントとそのデータを使用できるようにします。
C#
event MyEvent {
FPVector3 Position;
FPVector3 Direction;
FP Length
}
クラス継承は、ベースイベントのクラスとメンバーの共有を許可します。
C#
event MyBaseEvent {}
event SpecializedEventFoo : MyBaseEvent {}
event SpecializedEventBar : MyBaseEvent {}
synced
キーワードは継承できません。
抽象クラスを使用し、ベースイベントが直接トリガーされるのを防止します。
C#
abstract event MyBaseEvent {}
event MyConcreteEvent : MyBaseEvent {}
DSLが生成した構造をEvent内で再利用します。
C#
struct FooEventData {
FP Bar;
FP Par;
FP Rap;
}
event FooEvent {
FooEventData EventData;
}
キーワード
synced
ロールバックによる誤検出を避けるために、イベントにはsynced
キーワードを付けることができます。これは、Frameの入力がサーバーによって確認されたときにのみ、イベントが(Unityに)ディスパッチされることを保証します。
Synced
イベントは、シミュレーションで発行された時(予測されたFrameの間)からビューに表示されるまでの間に遅延を追加し、プレイヤーに通知するために使用することができます。
C#
synced event MyEvent {}
Synced
イベントは、偽陽性や偽陰性を作成しません。
Synced
されないイベントは、Unity上で2回呼び出されることはありません。
nothashed
以前の予測されたフレームでビューによってすでに消費されたイベントが再びディスパッチされるのを防ぐために、ハッシュコードが各Eventインスタンスのために計算されます。Eventをディスパッチする前に、イベントが重複しているかどうかを確認するためにハッシュコードが使用されます。
これにより、以下のような状況が発生する可能性があります。最小のロールバックによって引き起こされた1つのイベントの位置変更が、2つの異なるイベントとして誤って解釈されます。
nothashed
キーワードを使用すると、Eventデータの一部を無視して、Eventの一意性をテストするためにどのキー候補データを使用するかを管理できます。
C#
abstract event MyEvent {
nothashed FPVector2 Position;
Int32 Foo;
}
ローカル、リモート
イベントにplayer_ref
メンバーがある場合、特別なキーワードを使用することができます:remote
とlocal
です。
このキーワードは、Unity でイベントがディスパッチされる前に、player_ref
がlocal
またはremote
のプレイヤーに割り当てられているかどうかをチェックします。すべての条件が一致した場合、このクライアントでイベントがディスパッチされます。
C#
event LocalPlayerOnly {
local player_ref player;
}
C#
event RemotePlayerOnly {
remote player_ref player;
}
まとめると、シミュレーション自体はremote
やlocal
という概念にとらわれません。キーワードは、特定のイベントが各クライアントのビューで発生した場合にのみ変化します。
イベントに複数のplayer_ref
パラメータがある場合、local
とremote
を組み合わせることができます。このイベントはLocalPlayer
をコントロールするクライアントと、RemotePlayer
が別のプレイヤーにアサインされたときのみ発生します。
C#
event MyEvent {
local player_ref LocalPlayer;
remote player_ref RemotePlayer;
player_ref AnyPlayer;
}
クライアントが複数のプレイヤーを管理している場合(例:split-screen)、プレイヤーのplayer_ref
はすべてローカルとみなされます。
クライアント、サーバー
Quantum 2.1以降
イベントは、client
とserver
キーワードを使用して、実行場所を指定することができます。デフォルトでは、すべてのイベントはクライアントとサーバーでディスパッチされます。
C#
server synced event MyServerEvent {}
C#
client event MyClientEvent {}
イベントの使用
イベントをトリガー
イベントのタイプとシグネチャはFrame.FrameEvents
構造にコード生成され、Frame.Events
からアクセスできます。
C#
public override void Update(Frame f) {
f.Events.MyEvent(2022);
}
イベントデータの選択
理想的には、Eventデータは自己完結し、サブスクライバがビュー上で処理するために必要な情報を運ぶべきです。
シミュレーションでEventが発生したFrameは、Eventが実際にビュー上で呼び出されたときに、すでに利用できない可能性があります。つまり、Eventの処理に必要なFrameから取得する情報が失われる可能性があります。
EventのQCollection
やQList
は、実際にはFrameのヒープ上のメモリにPtr
として渡されるだけです。バッファがもう利用できないため、ポインタの解決が失敗する可能性があります。同じことが EntityRefs
にも当てはまります。Eventがディスパッチされた時点で最新のFrameからComponent にアクセスする場合には、Eventが最初に呼び出された時点とデータが同一ではない可能性があります。
イベントデータをarray
やList
でリッチ化する方法は以下のとおりです:
- コレクションデータのペイロードが既知で、かつ妥当な最大サイズである場合、
fixed array
を構造の中にラップしてEventに追加することができます。QCollections
とは異なり、配列はデータをFrameヒープに保存せず、値自体に担わせます。
C#
struct FooEventData {
array<FP>[4] ArrayOfValues;
}
event FooEvent {
FooEventData EventData;
}
- 現時点では、DSLでは通常のC#の
List<T>
型を含むEventを宣言することはできません。しかし、Eventは部分クラスを使って拡張することができます。詳しくはイベント実装の拡張
セクションを参照してください。
Unityでのイベントサブスクリプション
Quantumは、QuantumEvent
によるUnityでの柔軟なEventサブスクリプションAPIをサポートしています。
C#
QuantumEvent.Subscribe(listener: this, handler: (EventPlayerHit e) => Debug.Log($"Player hit in Frame {e.Tick}"));
上記の例では、リスナーは単純に現在のMonoBehaviour
で、ハンドラーは無名関数です。別の方法として、デリゲート関数を渡すこともできます。
C#
QuantumEvent.Subscribe<EventPlayerHit>(listener: this, handler: OnEventPlayerHit);
private void OnEventPlayerHit(EventPlayerHit e){
Debug.Log($"Player hit in Frame {e.Tick}");
}
QuantumEvent.Subscribe
は複数の任意のQoL引数を提供し、様々な方法でサブスクリプションを適格化しています。
C#
// only invoked once, then removed
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, once: true);
// not invoked if the listener is not active
// and enabled (Behaviour.isActiveAndEnabled or GameObject.activeInHierarchy is checked)
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, onlyIfActiveAndEnabled: true);
// only called for runner with specified id
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, runnerId: "SomeRunnerId");
// only called for a specific
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, runner: runnerReference);
// custom filter, invoked only if player 4 is local
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, filter: (QuantumGame game) => game.PlayerIsLocal(4));
// only for replays
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, gameMode: DeterministicGameMode.Replay);
// not for replays (Quantum SDK v2.0)
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, excludeGameMode: DeterministicGameMode.Replay);
// for all types except replays (Quantum SDK 2.1+)
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, gameMode: DeterministicGameMode.Replay, exclude: true);
//=> The gameMode parameter accepts and array of DeterministicGameMode
イベントからサブスクライブを解除
UnityはMonoBehaviours
のライフタイムを管理し、リスナーは自動的にクリーンアップされるため登録解除する必要はありません。
より強力な管理が必要な場合、サブスクライブ解除は手動で処理できます。
C#
var subscription = QuantumEvent.Subscribe();
// cancels this specific subscription
QuantumEvent.Unsubscribe(subscription);
// cancels all subscriptions for this listener
QuantumEvent.UnsubscribeListener(this);
// cancels all listeners to EventPlayerHit for this listener
QuantumEvent.UnsubscribeListener<EventPlayerHit>(this);
CSharpでのイベントサブスクリプション
EventがMonoBehaviour
の外部でサブスクライブされた場合には、サブスクリプションを手動で処理する必要があります。
C#
var disposable = QuantumEvent.SubscribeManual((EventPlayerHit e) => {}); // subscribes to the event
// ...
disposable.Dispose(); // disposes the event subscription
キャンセルされ、確認されたイベント
synced
されていないイベントは、検証されたフレームがシミュレーションされるとキャンセルされるか確認されます。Quantum はそれらに対応するため、CallbackEventCanceled
およびCallbackEventConfirmed
というコールバックを提供します。
C#
QuantumCallback.Subscribe(this, (Quantum.CallbackEventCanceled c) => Debug.Log($"Cancelled event {c.EventKey}"));
QuantumCallback.Subscribe(this, (Quantum.CallbackEventConfirmed c) => Debug.Log($"Confirmed event {c.EventKey}"));
EventインスタンスはEventKey
構造によって識別されます。たとえば、このようにEventKey
を作成することで、以前受信したEventをディクショナリに追加できます。
C#
public void OnEvent(MyEvent e) {
EventKey eventKey = (EventKey)e;
// ...
}
イベント実装を拡張
EventはQList
の使用をサポートしていますが、リストを解決する際、対応するFrameはもう利用できないかもしれません。追加のデータ型は、部分的なクラス宣言を使用して追加することができます。
C#
event ListEvent {
Int32 Foo;
}
C#
public partial class EventListEvent {
public List<Int32> ListOfFoo;
}
カスタマイズされたEventをFrame.Event
APIで発生できるようにするには、FrameEvents
構造を拡張します。
C#
f.Events.ListEvent(f, 1, new List<FP>() {2, 3, 4});.
C#
namespace Quantum {
public partial class Frame {
public partial struct FrameEvents {
public EventListEvent ListEvent(Frame f, Int32 foo, List<Int32> listOfFoo) {
var ev = f.Events.ListEvent(foo);
ev.ListOfFoo = listOfFoo;
return ev;
}
}
}
}
コールバック
コールバックはQuantum Coreによって内部的にトリガーされる、特別な種類のイベントです。ユーザーが利用できるコールバックは以下です:
Callback | Description |
---|---|
CallbackPollInput | Is called when the simulation queries local input. |
CallbackInputConfirmed | Is called when local input was confirmed. |
CallbackGameStarted | Is called when the game has been started. |
CallbackGameResynced | Is called when the game has been re-synchronized from a snapshot. |
CallbackGameDestroyed | Is called when the game was destroyed. |
CallbackUpdateView | Is guaranteed to be called every rendered frame. |
CallbackSimulateFinished | Is called when frame simulation has completed. |
CallbackEventCanceled | Is called when an event raised in a predicted frame was cancelled in a verified frame due to a roll-back / missed prediction. Synchronized events are only raised on verified frames and thus will never be cancelled; this is useful to graciously discard non-synced events in the view. |
CallbackEventConfirmed | Is called when an event was confirmed by a verified frame. |
CallbackChecksumError | Is called on a checksum error. |
CallbackChecksumErrorFrameDump | Is called when due to a checksum error a frame is dumped. |
CallbackChecksumComputed | Is called when a checksum has been computed. |
MonoBehaviour
コールバックは、前述のFrame Eventと同様にサブスクライブ/サブスクライブ解除されます。ただし、QuantumEvent
ではなくQuantumCallback
でおこないます。
C#
var subscription = QuantumCallback.Subscribe(...);
QuantumCallback.Unsubscribe(subscription); // cancels this specific subscription
QuantumCallback.UnsubscribeListener(this); // cancels all subscriptions for this listener
QuantumCallback.UnsubscribeListener<CallbackPollInput>(this); // cancels all listeners to CallbackPollInput for this listener
Unityはオブジェクトのライフタイムを管理します。このため、Quantumはリスナーの生死を判定できます。「死んだ」リスナーは、LateUpdate
や特定のイベントタイプのイベントを呼び出すたびに削除されます。
たとえば、PollInput
をサブスクライブしプレイヤーの入力をセットアップするには、以下の手順が必要です:
C#
public class LocalInput : MonoBehaviour
{
private void Start() {
QuantumCallback.Subscribe<CallbackPollInput>(this, PollInput);
}
private void PollInput(CallbackPollInput pollInput) {
Input i = new Input();
// add input values
pollInput.SetInput(i, DeterministicInputFlags.Repeatable);
}
}
純粋なCSharp
コールバックがMonoBehaviour
の外部でサブスクライブされた場合、サブスクリプションを手動で処理する必要があります。
C#
var disposable = QuantumCallback.SubscribeManual((CallbackPollInput pollInput) => {}); // subscribes to the callback
// ...
disposable.Dispose(); // disposes the callback subscription
エンティティをインスタンス化する順序
Frame.Create()
を使用してエンティティを作成し、またFrameシミュレーションが完了している場合には、以下のコールバックが順番に実行されます:
OnUpdateView
、新たに作成されたエンティティのビューがインスタンス化されます。Monobehaviour.Awake
Monobehaviour.OnEnabled
EntityView.OnEntityInstantiated
Frame.Events
が呼び出されます。
EventおよびCallbackサブスクリプションは、Monobehaviour.OnEnabled
またはEntityView.OnEntityInstantiated
でおこなわれます。
MonoBehaviour.OnEnabled
、ここでのコードでイベントにサブスクライブすることができます。ただし、EntityView
のEntityRefとAsset GUIDはまだ設定されていません。EntityView.OnEntityInstantiated
は、EntityViewコンポーネントのUnityEvent部分です。これは、エディター内のメニューでサブスライブできます。OnEntityInstantiated
が呼び出されると、EntityRefとEntityViewのAsset GUIDは設定されることが保証されます。イベントサブスクリプションまたはカスタムロジックがこれらのパラメータのいずれかを必要とすると、この時点で実行される必要があります。
イベントまたはコールバックからサブスクライブ解除するには、補足機能を使用します:
OnEnabled
で作成されたすべてのサブスクリプションについて、OnDisabled
でサブスクライブ解除します。OnEntityInstantiated
で作成されたすべてのサブスクリプションについて、OnEntityDestroyed
でサブスクライブ解除します。