Events & Callbacks
簡介
模擬(Quantum)與視圖(Unity)的分離使得遊戲狀態與視覺效果的開發具有高度的模組化。然而,視圖需要從遊戲狀態中獲取資訊以更新自身。Quantum 提供了兩種方式:
- 輪詢遊戲狀態
- 事件/回調
雖然這兩種方法都有效,但它們的使用場景略有不同。一般來說,從 Unity 輪詢 Quantum 的資訊更適合持續性的視覺效果,而事件則用於遊戲模擬觸發視圖反應的特定時刻。本文將重點介紹 幀事件 和 回調。
幀事件
事件是一種從模擬向視圖傳遞資訊的發送後不管機制。它們不應用於修改或更新遊戲狀態(這部分由Signals
負責)。事件有幾個重要的特性,這些特性有助於在預測和回滾時管理它們:
- 事件不會在客戶端之間同步,它們由每個客戶端自己的模擬觸發。
- 由於同一幀可能會被模擬多次(預測、回滾),事件可能會被多次觸發。為了避免重複事件,Quantum 使用事件資料成員、事件 ID 和刷新的雜湊碼來識別重複事件。詳見
nothashed
關鍵字。 - 非
synced
的常規事件在觸發的預測幀被驗證後,將被取消或確認。詳見Canceled And Confirmed Events
。 - 事件在所有幀模擬完成後、
OnUpdateView
回調之後被分發。事件的調用順序與觸發順序相同,但非synced
事件在識別為重複時可能會被跳過。由於這個時機,目標QuantumEntityView
可能已經被銷毀。
最簡單的事件及其用法如下:
- 使用 Quantum DSL 定義事件
Qtn
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結構
事件及其資料使用 Quantum DSL 在 qtn 檔案中定義。編譯專案後,可以通過模擬中的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
事件永遠不會產生誤報或漏報- 非
synced
事件在 Unity 中永遠不會被調用兩次
nothashed
為了防止已經在早期預測幀中被視圖處理過的事件再次被分發,每個事件實例都會計算一個雜湊碼。在分發事件之前,會使用雜湊碼檢查事件是否重複。
這可能導致以下情況:一個 事件的微小回滾導致的位罝變化被錯誤地解釋為 兩個不同 的事件。
nothashed
關鍵字可以用於控制哪些資料被用於測試事件的唯一性,忽略部分事件資料。
C#
abstract event MyEvent {
nothashed FPVector2 Position;
Int32 Foo;
}
本機,遠端
如果事件有一個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
關鍵字限定其執行範圍。預設情況下,所有事件都會在客戶端和伺服器上分發。
Qtn
server synced event MyServerEvent {}
Qtn
client event MyClientEvent {}
使用事件
觸發事件
事件類型和簽名會程式碼生成到Frame.FrameEvents
結構體中,可以通過Frame.Events
訪問。
C#
public override void Update(Frame frame) {
frame.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>
類型,但可以使用部分類別擴充事件。詳見Extend Event Implementation
部分。
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#
// 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 管理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 中的事件訂閱
如果事件在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 | 在插件因錯誤斷開客戶端連接時調用。reason 參數會填充錯誤描述(例如 "Error #15: Snapshot request timed out")。客戶端狀態在此之後無法恢復,需要重新連接並重啟模擬。當前 QuantumRunner 應立即關閉。 |
Unity端的回調
通過調整SimulationConfig
資源中的Auto Load Scene From Map
值,可以決定是否自動加載遊戲場景,以及預覽場景的卸載是在遊戲場景加載之前還是之後進行。
有四種回調會在場景加載和卸載時調用:CallbackUnitySceneLoadBegin
, CallbackUnitySceneLoadDone
, CallbackUnitySceneUnloadBegin
, CallbackUnitySceneUnloadDone
。
MonoBehaviour
回調的訂閱和取消訂閱方式與幀事件類似,只是通過QuantumCallback
而不是QuantumEvent
。
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);
}
}
純 CSharp
如果回調在MonoBehaviour
之外訂閱,則需要手動處理訂閱。
C#
var disposable = QuantumCallback.SubscribeManual((CallbackPollInput pollInput) => {}); // subscribes to the callback
// ...
disposable.Dispose(); // disposes the callback subscription
實體實例化順序
當使用Frame.Create()
創建實體且幀模擬完成時,以下回調將按順序執行:
OnUpdateView
,新創建實體的視圖被實例化。Monobehaviour.Awake
Monobehaviour.OnEnable
QuantumEntityView.OnEntityInstantiated
Frame.Events
被調用。
可以在Monobehaviour.OnEnabled
或QuantumEntityView.OnEntityInstantiated
中訂閱事件和回調。
MonoBehaviour.OnEnabled
,可以在這裡訂閱事件程式碼,但QuantumEntityView
的 EntityRef 和 Asset GUID 尚未設置。QuantumEntityView.OnEntityInstantiated
是 QuantumEntityView 元件的一個 UnityEvent。可以通過編輯器菜單訂閱。當OnEntityInstantiated
被調用時,QuantumEntityView 的 EntityRef 和 Asset GUID 保證已經設置。如果事件訂閱或自訂邏輯需要這些參數,則應在此處執行。

要取消訂閱事件或回調,只需使用對應的函數:
- 在
OnDisabled
中取消在OnEnabled
中訂閱的內容。 - 在
OnEntityDestroyed
中取消在OnEntityInstantiated
中訂閱的內容。