イベントとコールバック
はじめに
シミュレーション(Quantum)とビュー(Unity)を分割することで、ゲーム状態とビジュアルの開発において高いモジュール性を得られます。ビューを更新するには、ゲーム状態の情報を必要としますが、Quantumでは以下の2つの方法を選べます。
- ゲーム状態のポーリング
- イベント/コールバック
どちらも有効なアプローチですが、ユースケースは若干異なります。一般的に、UnityからのQuantumの情報のポーリングは継続的なビジュアル反映に好ましく、イベントはゲームシミュレーションがビューへの即時の反応をトリガーする際に使用されます。このドキュメントは、フレームイベントとコールバックに焦点を当てます。
フレームイベント
イベントは、シミュレーションからビューへ情報を転送するための「撃ち放し」メカニズムです。イベントをゲーム状態の一部を変更/更新するために使用してはいけません(その用途ではSignals
が使用されます)。イベントを理解するために重要な点はいくつかあり、予測/ロールバックの際の管理に役立ちます。
- イベントはクライアント間では何も同期せず、各クライアント自身のシミュレーションで発生します。
- 同一フレームは複数回シミュレーションすること(予測/ロールバック)ができるため、イベントは何度もトリガーされる可能性があります。望まないイベントの重複を避けるため、Quantumはイベントデータ・ID・ティックに基づくハッシュコードを使用して重複を判別します。詳細は
nothashed
キーワードをご覧ください。 - 通常の非
synced
イベントは、イベントが発火した予測フレームで一度だけキャンセル/確認されます。詳細は「イベントのキャンセルと確認」をご覧ください。 - イベントは、すべてのフレームがシミュレーションされた後、
OnUpdateView
コールバック直後にディスパッチされます。イベントはトリガーされた順に呼び出されますが、重複した非synced
イベントはスキップされる可能性があります。このタイミングのため、対象のQuantumEntityView
は既に破棄されている場合があります。
最も単純なイベントとその使用法は次のようになります。
- QuantumのDSLを使用してイベントを定義する
Qtn
event MyEvent { int Foo; }
- シミュレーションからイベントをトリガーする
C#
f.Events.MyEvent(2023);
- 接頭辞
Event
のイベントクラスを生成したら、Unityでイベントを購読/消費するC#
QuantumEvent.Subscribe(listener: this, handler: (EventMyEvent e) => Debug.Log($"MyEvent {e.Foo}"));
DSL構造
イベントとそのデータは、QTNファイル内のQuantum DSLを使用して定義されます。プロジェクトをコンパイルすると、シミュレーションのFrame.Events
APIを通して、イベントが利用可能になります。
Qtn
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で生成された構造体は、イベントで再利用できます。
Qtn
struct FooEventData {
FP Bar;
FP Par;
FP Rap;
}
event FooEvent {
FooEventData EventData;
}
キーワード
synced
ロールバックによるイベント誤判定を回避するために、synced
キーワードを付けることができます。これにより、フレームの入力がサーバーから確認された後にのみ、イベントが(Unityに)送信されることが保証されます。
Synced
イベントでは、シミュレーションで発行された時点(予測フレーム中)から、プレイヤーへの通知に使用されるビューに表示されるまでに遅延が発生します。
Qtn
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
かをチェックし、すべての条件が一致すれば、クライアントでイベントが送信されます。
Qtn
event LocalPlayerOnly {
local player_ref player;
}
Qtn
event RemotePlayerOnly {
remote player_ref player;
}
要約:シミュレーション自体はremote
/local
の概念を持ちません。このキーワードは、特定のイベントが各クライアントのビューに送信されるかどうかのみを変更します。
イベントが複数のplayer_ref
パラメーターを含む場合、local
とremote
を組み合わせることができます。以下のイベントは、LocalPlayer
を制御するクライアント上で、RemotePlayer
に別のプレイヤーが割り当てられた時のみトリガーされます。
Qtn
event MyEvent {
local player_ref LocalPlayer;
remote player_ref RemotePlayer;
player_ref AnyPlayer;
}
クライアントが複数のプレイヤーを制御する場合(例:画面分割)、すべてのplayer_ref
がローカルとしてあつかわれます。
client, server
イベントはclient
/server
キーワードを使用して、どこで実行されるかのスコープを制限できます。デフォルトでは、すべてのイベントはクライアントとサーバー両方に送信されます。
Qtn
server synced event MyServerEvent {}
Qtn
client event MyClientEvent {}
イベントの使用
イベントのトリガー
イベント型とシグネチャは、Frame.FrameEvents
構造体にコード生成され、Frame.Events
からアクセス可能です。
C#
public override void Update(Frame f) {
f.Events.MyEvent(2023);
}
イベントデータの選択
理想的には、イベントデータは自己完結していて、購読者のビュー処理に必要な情報すべてを持っているべきです。
シミュレーション中のイベントが発生したフレームは、実際にビューでイベントが呼び出された時にはもう存在しない可能性があります。つまり、イベントを処理するためにフレームから取得する情報は失われている可能性があります。
イベントのQCollection
/QList
は、フレームのヒープメモリのPtr
のみが渡されます。バッファが既に利用不可能な場合は、ポインタの解決に失敗する可能性があります。EntityRef
も同様で、イベントが送信された時点の最新フレームからコンポーネントにアクセスすると、元々イベントが発生した際のデータとは異なる可能性があります。
イベントデータにarray
/List
を含める方法について、
コレクションデータのペイロードの最大サイズが既知で妥当なサイズの場合は、
fixed array
を構造体内にラップしてイベントに追加できます。QCollections
とは異なり、配列はフレームヒープのデータを保持せず、値自体を保持します。コレクションデータのペイロードが既知の妥当な最大サイズである場合、
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}"));
上記の例では、listener
は単にMonoBehaviour
自身で、handler
は匿名関数かデリゲート関数を渡すことができます。
C#
QuantumEvent.Subscribe<EventPlayerHit>(listener: this, handler: OnEventPlayerHit);
private void OnEventPlayerHit(EventPlayerHit e) {
Debug.Log($"Player hit in Frame {e.Tick}");
}
QuantumEvent.Subscribe
は、様々な方法で購読を行うための、便利なオプション引数を提供しています。
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);
// for all types except replays
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, gameMode: DeterministicGameMode.Replay, exclude: true);
//=> The gameMode parameter accepts and array of DeterministicGameMode
イベントの購読解除
UnityはMonoBehaviour
のライフタイムを管理し、リスナーは自動的にクリーンアップされるため、登録を解除する必要はありません。
より厳密な正業が必要な場合は、手動で購読解除することも可能です。
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);
C#でのイベントの購読
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}"));
イベントインスタンスは、EventKey
構造体によって識別されます。例えば以下のようにEventKey
を作成して、過去に受信したイベントをディクショナリーに追加できます。
C#
public void OnEvent(MyEvent e) {
EventKey eventKey = (EventKey)e;
// ...
}
イベント実装の拡張
イベントはQList
の使用をサポートしていますが、リスト解決時に、対応するフレームが既に利用できない場合があります。ここで、partial
クラス宣言を使用して、データ型を追加することができます(以下EventListEvent
を参照)。
C#
// Define the event in Quantum DSL.
event ListEvent {
EntityRef Entity;
}
Frame.Event
APIから独自のイベントを発生させるために、partial FrameEvents
構造体を拡張します。
C#
namespace Quantum
{
using System;
using System.Collections.Generic;
partial class EventListEvent {
// Add the C# list field to the event object using partial.
public List<Int32> ListOfFoo;
}
partial class Frame {
partial struct FrameEvents {
public EventListEvent ListEvent(EntityRef entity, List<Int32> listOfFoo) {
var ev = ListEvent(entity);
if (ev == null) {
// Synced or local events can be null for example during predicted frame.
return null;
}
// Reuse the list object of the pooled event.
if (ev.ListOfFoo == null) {
ev.ListOfFoo = new List<Int32>(listOfFoo.Count);
}
ev.ListOfFoo.Clear();
// Copy the content into the event, to be independent from the input list object which can be cached.
ev.ListOfFoo.AddRange(listOfFoo);
return ev;
}
}
}
}
そして、シミュレーションコードからイベントを呼び出してください。
C#
// The list object can be cached and reused, its content is copied inside the ListEvent() call (see above).
f.Events.ListEvent(f, 0, new List<FP> {2, 3, 4});.
コールバック
コールバックは、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
コールバックの購読/購読解除は、先に説明したフレームイベントと同様の方法で、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 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);
}
}
ピュアC#
MonoBehaviour
外でコールバックを購読する場合は、手動で購読を管理する必要があります。
C#
var disposable = QuantumCallback.SubscribeManual((CallbackPollInput pollInput) => {}); // subscribes to the callback
// ...
disposable.Dispose(); // disposes the callback subscription
エンティティのインスタンス生成順序
Frame.Create()
を使用してエンティティを作成し、フレームシミュレーションが完了すると、次のコールバックが順番に実行されます。
OnUpdateView
で、新しく作成されたエンティティのビューがインスタンス化されますMonobehaviour.Awake
Monobehaviour.OnEnabled
QuantumEntityView.OnEntityInstantiated
Frame.Events
が呼び出されます
イベントとコールバックの購読は、Monobehaviour.OnEnabled
/QuantumEntityView.OnEntityInstantiated
で行うことができます。
MonoBehaviour.OnEnabled
のコード内でイベントを購読することが可能ですが、QuantumEntityView
のEntityRef
やAsset GUID
はまだ設定されていません。QuantumEntityView.OnEntityInstantiated
は、QuantumEntityView
コンポーネントのUnityEvent
で、エディタメニューから購読できます。OnEntityInstantiated
が呼び出された時に、QuantumEntityView
のEntityRef
とAsset GUID
が設定されていることは保証されます。イベント購読や独自ロジックでこれらのパラメーターが必要な場合は、ここで実行しましょう。

イベントやコールバックを購読解除するには、単純に対応する関数を使用してください。
OnEnabled
で購読したら、OnDisabled
で解除しますOnEntityInstantiated
で購読したら、OnEntityDestroyed
で解除します