This document is about: QUANTUM 3
SWITCH TO

イベントとコールバック

はじめに

シミュレーション(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.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メンバーが含まれている場合は、特別なキーワードのremotelocalが利用可能です。

クライアント上でイベントがUnityに送信される前に、player_refに割り当てられているプレイヤーがlocalremoteかをチェックし、すべての条件が一致すれば、クライアントでイベントが送信されます。

Qtn

event LocalPlayerOnly {
  local player_ref player;
}

Qtn

event RemotePlayerOnly {
  remote player_ref player;
}

要約:シミュレーション自体はremote/localの概念を持ちません。このキーワードは、特定のイベントが各クライアントのビューに送信されるかどうかのみを変更します。

イベントが複数のplayer_refパラメーターを含む場合、localremoteを組み合わせることができます。以下のイベントは、LocalPlayerを制御するクライアント上で、RemotePlayerに別のプレイヤーが割り当てられた時のみトリガーされます。

Qtn

event MyEvent {
  local player_ref LocalPlayer;
  remote player_ref RemotePlayer;
  player_ref AnyPlayer;
}

クライアントが複数のプレイヤーを制御する場合(例:画面分割)、すべてのplayer_refがローカルとしてあつかわれます。

client, server

これはカスタムQuantumプラグインで、サーバーサイドのシミュレーションを実行している場合にのみ関連します。

イベントは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.EventAPIから独自のイベントを発生させるために、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()を使用してエンティティを作成し、フレームシミュレーションが完了すると、次のコールバックが順番に実行されます。

  1. OnUpdateViewで、新しく作成されたエンティティのビューがインスタンス化されます
  2. Monobehaviour.Awake
  3. Monobehaviour.OnEnabled
  4. QuantumEntityView.OnEntityInstantiated
  5. Frame.Eventsが呼び出されます

イベントとコールバックの購読は、Monobehaviour.OnEnabled/QuantumEntityView.OnEntityInstantiatedで行うことができます。

  • MonoBehaviour.OnEnabledのコード内でイベントを購読することが可能ですが、QuantumEntityViewEntityRefAsset GUIDはまだ設定されていません。
  • QuantumEntityView.OnEntityInstantiatedは、QuantumEntityViewコンポーネントのUnityEventで、エディタメニューから購読できます。OnEntityInstantiatedが呼び出された時に、QuantumEntityViewEntityRefAsset GUIDが設定されていることは保証されます。イベント購読や独自ロジックでこれらのパラメーターが必要な場合は、ここで実行しましょう。
OnEntityInstantiated subscription menu in Editor
エディタ内のOnEntityInstantiated購読メニュー

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

  • OnEnabledで購読したら、OnDisabledで解除します
  • OnEntityInstantiatedで購読したら、OnEntityDestroyedで解除します
Back to top