イベントとコールバック
はじめに
シミュレーション(Quantum)とビュー(Unity)を分割することで、ゲームステートとビジュアルの開発において高いモジュール性を得られます。ビューを更新するには、ゲームステートの情報を必要としますが、Quantumでは以下の2つの方法を選べます。
- ゲームステートのポーリング
- イベント/コールバック
どちらも有効なアプローチですが、ユースケースは若干異なります。一般的に、UnityからのQuantumの情報のポーリングは継続的なビジュアル反映に好ましく、イベントはゲームシミュレーションがビューへの即時の反応をトリガーする際に使用されます。このドキュメントは、フレームイベントとコールバックに焦点を当てます。
フレームイベント
イベントは、シミュレーションからビューへ情報を転送するための「撃ちっ放し(fire-and-forget)」メカニズムです。イベントをゲームステートの一部を変更/更新するために使用してはいけません(その用途では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.EventsAPIを通して、イベントが利用可能になります。
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#
// 一度実行されたら、削除される
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, once: true);
// リスナーがアクティブかつ有効(Behaviour.isActiveAndEnabled/GameObject.activeInHierarchyがチェック済み)
// でなければ実行されない
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, onlyIfActiveAndEnabled: true);
// 特定IDのRunnerでのみ呼び出される
QuantumEvent.Subscribe(this, (EventPlayerHit e) => {}, runnerId: "SomeRunnerId");
// 特定のRunnerでのみ呼び出される
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はMonoBehaviourのライフタイムを管理し、リスナーは自動的にクリーンアップされるため、登録を解除する必要はありません。
より厳密な正業が必要な場合は、手動で購読解除することも可能です。
C#
var subscription = QuantumEvent.Subscribe();
// 特定の購読をキャンセルする
QuantumEvent.Unsubscribe(subscription);
// リスナーのすべての購読をキャンセルする
QuantumEvent.UnsubscribeListener(this);
// リスナーのすべてのEventPlayerHitリスナーをキャンセルする
QuantumEvent.UnsubscribeListener<EventPlayerHit>(this);
C#でのイベントの購読
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の使用をサポートしていますが、対応するフレームでリストを解決した際には、既に利用できない場合があります。ここで、partialクラス宣言を使用して、データ型を追加することができます(以下EventListEventを参照)。
C#
// Quantum DSLでのイベントの定義
event ListEvent {
EntityRef Entity;
}
Frame.EventAPIから独自のイベントを発生させるために、partial FrameEvents構造体を拡張します。
C#
namespace Quantum
{
using System;
using System.Collections.Generic;
partial class EventListEvent {
// partialを使用して、イベントオブジェクトにC#リストのフィールドを追加する
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) {
// 例:同期/ローカルイベントは、予測フレーム中にnullにすることができる
return null;
}
// プールされたイベントのリストを再利用する
if (ev.ListOfFoo == null) {
ev.ListOfFoo = new List<Int32>(listOfFoo.Count);
}
ev.ListOfFoo.Clear();
// イベントに内容をコピーして、キャッシュされる入力リストとは分ける
ev.ListOfFoo.AddRange(listOfFoo);
return ev;
}
}
}
}
そして、シミュレーションコードからイベントを呼び出してください。
C#
// リストオブジェクトはキャッシュされて再利用される、その内容はListEvent()呼び出し内でコピーされる(上述)
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); // 特定の購読をキャンセルする
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);
}
}
ピュアC#
MonoBehaviour外でコールバックを購読する場合は、手動で購読を管理する必要があります。
C#
var disposable = QuantumCallback.SubscribeManual((CallbackPollInput pollInput) => {}); // コールバックを購読する
// ...
disposable.Dispose(); // コールバックの購読を解除する
エンティティのインスタンス生成順序
Frame.Create()を使用してエンティティを作成し、フレームシミュレーションが完了すると、次のコールバックが順番に実行されます。
OnUpdateViewで、新しく作成されたエンティティのビューがインスタンス化されますMonobehaviour.AwakeMonobehaviour.OnEnabledQuantumEntityView.OnEntityInstantiatedFrame.Eventsが呼び出されます
イベントとコールバックの購読は、Monobehaviour.OnEnabled/QuantumEntityView.OnEntityInstantiatedで行うことができます。
MonoBehaviour.OnEnabledのコード内でイベントを購読することが可能ですが、QuantumEntityViewのEntityRefやAsset GUIDはまだ設定されていません。QuantumEntityView.OnEntityInstantiatedは、QuantumEntityViewコンポーネントのUnityEventで、エディタメニューから購読できます。OnEntityInstantiatedが呼び出された時に、QuantumEntityViewのEntityRefとAsset GUIDが設定されていることは保証されます。イベント購読や独自ロジックでこれらのパラメーターが必要な場合は、ここで実行しましょう。
イベントやコールバックを購読解除するには、単純に対応する関数を使用してください。
OnEnabledで購読したら、OnDisabledで解除しますOnEntityInstantiatedで購読したら、OnEntityDestroyedで解除します