イベントとコールバック
はじめに
シミュレーション (Quantum) とビュー (Unity) の分割は、ゲームの状態とビジュアルの開発において高いモジュール性を提供します。しかし、ビューは自分自身を更新するためにゲームの状態から情報を必要とします。Quantum では、以下の2つの方法があります。
- ゲーム状態のポーリング
- イベント / コールバック
どちらも有効なアプローチですが、その使用ケースは若干異なります。一般的に、Unity から Quantum 情報をポーリングすることは継続的なビジュアルに対して好ましく、イベントはゲームシミュレーションがビューに反応を引き起こす際の一時的な発生に使用されます。このドキュメントでは フレームイベント と コールバック に焦点を当てます。
フレームイベント
イベントはシミュレーションからビューに情報を転送するためのファイア・アンド・フォゲットメカニズムです。イベントはゲーム状態の一部を変更または更新するために使用されるべきではありません(そのためには Signals
が使用されます)。イベントを管理する上で重要な側面がいくつかあり、これにより予測やロールバックの際に役立ちます。
- イベントはクライアント間で何も同期せず、各クライアントの独自のシミュレーションによって発火します。
- 同じフレームは複数回シミュレーションを行うことができるため(予測、ロールバック)、イベントが何度もトリガーされる可能性があります。予期しない重複イベントを避けるために、Quantum はイベントデータメンバー、イベントID、およびティックに基づくハッシュコード関数を使用して重複を特定します。詳細については
nothashed
キーワードを参照してください。 - 通常の非
synced
イベントは、発火したフレームの予測が確認されるとキャンセルまたは確認されます。詳細についてはキャンセルおよび確認イベント
を参照してください。 - イベントはすべてのフレームがシミュレーションされた後、
OnUpdateView
コールバックの直後にディスパッチされます。イベントは、トリガーされた順序で呼び出されますが、重複していると識別された非synced
イベントはスキップされる可能性があります。このタイミングのため、対象となるQuantumEntityView
はすでに破棄されている場合があります。
最も単純なイベントとその使用法は次のようになります。
- Quantum DSL を使用してイベントを定義します
C#
event MyEvent { int Foo; }
- シミュレーションからイベントをトリガーします
C#
f.Events.MyEvent(2023);
- Unity でイベントを購読し、消費します。イベントのためのクラスを生成し、接頭辞
Event
を付けますC#
QuantumEvent.Subscribe(listener: this, handler: (EventMyEvent e) => Debug.Log($"MyEvent {e.Foo}"));
DSL 構造
イベントとそのデータは、qtn-file 内の 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生成構造体を再利用します。
C#
struct FooEventData {
FP Bar;
FP Par;
FP Rap;
}
event FooEvent {
FooEventData EventData;
}
キーワード
synced
ロールバックによって引き起こされる誤検出イベントを回避するために、イベントに synced
キーワードを付けることができます。これにより、フレームの入力がサーバーによって確認されるまで、イベントが Unity に送信されないことが保証されます。
Synced
イベントは、シミュレーション内で発行された時点(予測フレーム中)と、そのイベントがビューに現れるまでに遅延が生じるため、プレイヤーに通知するのに利用できます。
C#
synced event MyEvent {}
Synced
イベントは、誤検出(false positives)や誤否定(false negatives)を引き起こしません。- 非
synced
イベントは、Unity 上で二度呼び出されることはありません。
nothashed
以前に予測されたフレームでビューによってすでに消費されたイベントが再度送信されるのを防ぐために、各イベントインスタンスに対してハッシュコードが計算されます。イベントを送信する前に、このハッシュコードを使用してイベントが重複しているかどうかをチェックします。
これにより、次のような状況が発生する可能性があります:1つのイベントの最小ロールバックによる位置変更が、2つの異なるイベントとして誤って解釈されることがあります。
nothashed
キーワードは、イベントデータの一部を無視することで、イベントの一意性をテストするために使用されるキー候補データを制御することができます。
C#
abstract event MyEvent {
nothashed FPVector2 Position;
Int32 Foo;
}
local, remote
イベントに 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;
}
クライアントが複数のプレイヤーを制御する場合(例:分割画面)、すべての player_ref
はローカルとして扱われます。
client, server
イベントは 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(2023);
}
イベントデータの選択
理想的には、イベントデータは自己完結しており、サブスクライバーがビューで処理するために必要なすべての情報を持っているべきです。
イベントがシミュレーションで発生したフレームは、実際にビューでイベントが呼ばれたときにはもう存在しない可能性があります。つまり、イベントを処理するためにフレームから取得される情報が失われてしまう可能性があるのです。
イベント上の QCollection
または QList
は、実際にはフレームヒープのメモリへの Ptr
のみが渡されます。このポインタの解決は、バッファがもはや利用できないために失敗する可能性があります。EntityRefs
に関しても同様で、イベントが送信される時点で最新のフレームからコンポーネントにアクセスすると、データはもともとイベントが発生した際のものとは異なる可能性があります。
イベントデータを array
または List
で充実させる方法:
- コレクションデータのペイロードが既知の妥当な最大サイズである場合、
fixed array
を構造体の内部にラップし、イベントに追加することができます。QCollections
とは異なり、配列はデータをフレームヒープに保存せず、値自体に保持します。C#
struct FooEventData { array<FP>[4] ArrayOfValues; } event FooEvent { FooEventData EventData; }
- 現在、DSL では通常の C#
List<T>
タイプを使用してイベントを宣言することはできませんが、部分クラスを使用してイベントを拡張することができます。詳細はイベントの実装の拡張
セクションを参照してください。
Unity におけるイベントの購読
Quantum は、QuantumEvent
を介して Unity に柔軟なイベント購読 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#
// 一度だけ呼び出され、その後削除される
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, once: true);
// リスナーがアクティブではない場合や有効でない場合は呼び出されない
// (Behaviour.isActiveAndEnabled または GameObject.activeInHierarchy がチェックされる)
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, onlyIfActiveAndEnabled: true);
// 指定された ID を持つランナーに対してのみ呼ばれる
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, runnerId: "SomeRunnerId");
// 特定のランナーに対してのみ呼ばれる
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, runner: runnerReference);
// カスタムフィルタ、プレイヤー4がローカルである場合のみ呼び出される
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, filter: (QuantumGame game) => game.PlayerIsLocal(4));
// リプレイのためのみ
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, gameMode: DeterministicGameMode.Replay);
// リプレイを除くすべてのタイプのため
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, gameMode: DeterministicGameMode.Replay, exclude: true);
// => gameMode パラメータは DeterministicGameMode の配列を受け入れます
イベントの購読解除
Unity は MonoBehaviours
のライフタイムを管理するため、リスナーが自動的にクリーンアップされるため、登録解除の必要はありません。
より厳密な制御が必要な場合には、手動で購読解除することも可能です。
C#
var subscription = QuantumEvent.Subscribe();
// この特定の購読をキャンセルする
QuantumEvent.Unsubscribe(subscription);
// このリスナーに対するすべての購読をキャンセルする
QuantumEvent.UnsubscribeListener(this);
// このリスナーに対する EventPlayerHit のすべてのリスナーをキャンセルする
QuantumEvent.UnsubscribeListener<EventPlayerHit>(this);
CSharp におけるイベントの購読
MonoBehaviour
の外部でイベントに購読する場合は、手動で購読を管理する必要があります。
C#
var disposable = QuantumEvent.SubscribeManual((EventPlayerHit e) => {}); // イベントに購読する
// ...
disposable.Dispose(); // イベント購読を破棄する
キャンセルおよび確認イベント
非 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}"));
イベントインスタンスは EventKey
構造体によって識別されます。以前に受信したイベントは、たとえば EventKey
を作成することで辞書に追加できます。
C#
public void OnEvent(MyEvent e) {
EventKey eventKey = (EventKey)e;
// ...
}
イベント実装の拡張
イベントは QList
を使用することをサポートしていますが、リストを解決するときに対応するフレームがもはや利用できない場合があります。部分クラス声明を使用することで、追加のデータ型を追加することができます。
C#
event ListEvent {
Int32 Foo;
}
C#
public partial class EventListEvent {
public List<Int32> ListOfFoo;
}
カスタマイズされたイベントを 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 によって内部的にトリガーされる特別なタイプのイベントです。ユーザーに提供されるコールバックは次のとおりです:
コールバック | 説明 |
---|---|
CallbackPollInput | シミュレーションがローカル入力をクエリする際に呼ばれます。 |
CallbackInputConfirmed | ローカル入力が確認されたときに呼ばれます。 |
CallbackGameStarted | ゲームが開始されたときに呼ばれます。 |
CallbackGameResynced | スナップショットからゲームが再同期されたときに呼ばれます。 |
CallbackGameDestroyed | ゲームが破棄されたときに呼ばれます。 |
CallbackUpdateView | 毎フレーム描画時に呼ばれることが保証されています。 |
CallbackSimulateFinished | フレームシミュレーションが完了したときに呼ばれます。 |
CallbackEventCanceled | 予測フレームで発生したイベントがロールバックまたはミスした予測によって確認されたフレームでキャンセルされたときに呼ばれます。同期イベントは確認されたフレームでのみ発生するため、キャンセルされることはありません。これは、ビュー内で非同期イベントを優雅に破棄するのに役立ちます。 |
CallbackEventConfirmed | イベントが確認されたときに呼ばれます。 |
CallbackChecksumError | チェックサムエラーが発生したときに呼ばれます。 |
CallbackChecksumErrorFrameDump | チェックサムエラーによりフレームがダンプされたときに呼ばれます。 |
CallbackChecksumComputed | チェックサムが計算されたときに呼ばれます。 |
CallbackPluginDisconnect | プラグインがエラーによりクライアントを切断したときに呼ばれます。理由パラメータにはエラーの説明(例:"Error #15: Snapshot request timed out")が設定されます。この後、クライアント状態は回復不可能であり、再接続およびシミュレーションの再起動が必要です。現在の QuantumRunner はすぐにシャットダウンされるべきです。 |
Unity 側のコールバック
SimulationConfig
アセット内の Auto Load Scene From Map
の値を調整することで、ゲームシーンが自動的にロードされるかどうかを決定でき、また、プレビューシーンのアンロードがゲームシーンのロードの前または後に行われるかどうかを決定できます。
シーンがロードおよびアンロードされる際に呼ばれる 4 つのコールバックがあります:CallbackUnitySceneLoadBegin
, CallbackUnitySceneLoadDone
, CallbackUnitySceneUnloadBegin
, CallbackUnitySceneUnloadDone
。
MonoBehaviour
コールバックは、先に説明したフレームイベントと同様に QuantumCallback
を介して購読および購読解除されます。
C#
var subscription = QuantumCallback.Subscribe(...);
QuantumCallback.Unsubscribe(subscription); // この特定の購読をキャンセルします
QuantumCallback.UnsubscribeListener(this); // このリスナーに対するすべての購読をキャンセルします
QuantumCallback.UnsubscribeListener<CallbackPollInput>(this); // このリスナーに対する CallbackPollInput のすべてのリスナーをキャンセルします
Unity はオブジェクトのライフタイムを管理します。したがって、Quantum はリスナーが生存しているかどうかを検出できます。「死んでいる」リスナーは、各 LateUpdate
と特定のイベントタイプのイベント呼び出しごとに削除されます。
たとえば、PollInput
メソッドに購読し、プレイヤー入力をセットアップするには、次の手順が必要です:
C#
public class LocalInput : MonoBehaviour {
private DispatcherSubscription _pollInputDispatcher;
private void OnEnable() {
_pollInputDispatcher = QuantumCallback.Subscribe(this, (CallbackPollInput callback) => PollInput(callback));
}
public void PollInput(CallbackPollInput callback) {
Quantum.Input i = new Quantum.Input();
callback.SetInput(i, DeterministicInputFlags.Repeatable);
}
private void OnDisable() {
QuantumCallback.Unsubscribe(_pollInputDispatcher);
}
}
ピュア CSharp
MonoBehaviour
の外部でコールバックに購読する場合は、手動で購読を管理する必要があります。
C#
var disposable = QuantumCallback.SubscribeManual((CallbackPollInput pollInput) => {}); // コールバックに購読する
// ...
disposable.Dispose(); // コールバック購読を破棄する
エンティティのインスタンス生成順序
Frame.Create()
を使用してエンティティを作成し、フレームシミュレーションが完了したときに、次のコールバックが順番に実行されます:
OnUpdateView
、新しく作成されたエンティティのビューがインスタンス化されます。Monobehaviour.Awake
Monobehaviour.OnEnabled
QuantumEntityView.OnEntityInstantiated
Frame.Events
が呼び出されます。
イベントおよびコールバックの購読は、Monobehaviour.OnEnabled
または QuantumEntityView.OnEntityInstantiated
で行うことができます。
MonoBehaviour.OnEnabled
では、ここでコード内でイベントに購読することが可能ですが、QuantumEntityView
の EntityRef および Asset GUID はまだ設定されていません。QuantumEntityView.OnEntityInstantiated
は、QuantumEntityView コンポーネントのユニティイベントです。エディタメニューを介して購読できます。OnEntityInstantiated
が呼び出されると、QuantumEntityView の EntityRef および Asset GUID が設定されていることが保証されます。イベントの購読やカスタムロジックでこれらのパラメータが必要な場合、ここで実行すべきです。

イベントやコールバックから購読解除するには、単に対応する関数を使用します:
OnEnabled
で行った購読は、OnDisabled
でキャンセルします。OnEntityInstantiated
で行った購読は、OnEntityDestroyed
でキャンセルします。