戰鬥系統:事件系統設計
稍有點項目經驗的讀者,想必對事件都不陌生,本篇就和大家分享一下游戲中的事件處理。
閱讀提醒:
-
本篇不會講觀察者模式和發佈訂閱模式的基本概念,若尚不瞭解,建議先簡單瞭解一下。
-
本文字數較多,若想有所得,還需仔細閱讀。
遊戲事件的分類
遊戲中的事件,作者將它們劃爲三類:
-
UI 事件(輸入事件)
-
養成系統事件(數據變化事件)
-
World 模擬事件(以戰鬥和 AI 爲主)
每個領域對事件和事件處理的設計都有所不同,本篇主要講它們的公共邏輯和 World 模擬事件。
觀察者模式與發佈訂閱模式
觀察者模式,客戶端程序應當比服務器程序更熟悉,因爲一個好的客戶端程序,離不開良好的 MVC 框架,而 MVC 的核心就是觀察者模式。
而發佈訂閱模式,雖然客戶端和服務器都會用到 —— 我們的 EventSystem 就是發佈訂閱模式,但服務器應當比客戶端更熟悉,因爲除了遊戲業務外,MQ 在服務器開發中越來越常見,而 MQ 的核心之一便是發佈訂閱模式。
那麼,觀察者模式和發佈訂閱模式有什麼區別呢?
我這裏畫了個圖:
觀察者模式下,監聽者(UI)是直接依賴主題(Data);而在發佈訂閱模式下,監聽器不直接依賴主題,而是依賴 EventBus,而主題對象也不直接持有監聽器對象的引用。
那麼,引入 EventBus 這個中間對象有什麼優點呢?
對於主題對象而言,抽取 EventBus 並未帶來太大的優點 —— 主要是避免了維護監聽器列表,而這個其實可以通過封裝一個監聽器容器實現,就像這樣:
public class Subject {
public readonly ListenerContainer listeners = new ();
}
public class ListenerContainer {
public void AddListener(Action<object> listener) {}
public void AddListener(Action<object> listener, IThreadPool threadPool) {}
public void RemoveListener(Action<object> listener) {}
public void Post(object tempData) {}
}
引入 EventBus 的真正受益方是監聽器 —— 也就是事件處理方,那麼主要優點是什麼呢?
有些人可能會想到線程切換功能,即指切換到後臺線程處理事件;但這個功能,上面的 Container 照樣可以實現 —— 所以我在示例代碼刻意加了該邏輯。
引入 EventBus 的真正優點是:允許監聽器監聽一個尚不存在的主題。
在傳統觀察者模式下,監聽器是強依賴主題對象的;如果主題對象不存在,監聽器就無法正常工作 —— 比如我們的 UI 對象;但在引入 EventBus 中間對象後,監聽器就可以通過 ID 監聽一個尚不存在的主題對象。
public class EventBus {
// id爲string或long
public void AddListener(long id, int eventType, Action<object> action);
}
那這個功能有什麼用呢?
以遊戲的核心業務爲例,我們會使用這種方式來監聽周圍對象(GameObject)的事件。我知道很多人爲了方便,是直接將監聽器添加到目標對象上;但這麼做很容易導致內存泄漏等錯誤,而且通常隱藏得很深,難以被發現。
爲避免內存泄漏等問題,我在項目中通常會要求:GameObject 之間禁止直接引用,必須通過 ID 引用。
// 負責場景級別的事件廣播
public class SceneEventSystem {
// gobjId爲0表示監聽非gameobject事件
public void AddListener(long gobjId, int eventType, Action<object> action);
public void RemListener(long gobjId, int eventType, Action<object> action);
}
那什麼時候選擇觀察者模式,什麼時候選擇發佈訂閱模式呢?
通過生命週期判斷。如果監聽器必須依賴於主題對象纔可以正常工作,比如 UI 對象,那麼就應當選擇觀察者模式;如果監聽器和主題對象的生命週期是獨立的,比如戰鬥和養成系統,那麼就選擇發佈訂閱模式。
C# 的事件 + 委託
C# 在語言層面對觀察者模式進行了支持,event 其實定義了一個監聽器列表,編譯器會爲其生成 add 和 remove 方法。
監聽器的合併是通過 Delegate 類中的 Combine 實現的,而監聽器的刪除是通過 Delegate 類中的 Remove 方法實現的。
public static event Action<object> OnLoaded;
public abstract class Deleagate {
public static Delegate? Combine(Delegate? a, Delegate? b) {}
public static Delegate? Remove(Delegate? source, Delegate? value) {}
}
在語言層面增加觀察者模式的支持,是一種愚蠢的行爲;這不僅增加了語言的複雜度,也缺乏靈活性,比如:異常處理,刪除處理。
雖然現在用 C# 來做服務器也挺不錯的,但 C# 到處都在表達:我是一門客戶端語言。
事件帶來的問題
引入觀察者模式或訂閱模式的主要優點是解耦,這一點想必大家都已很熟悉;但大規模應用事件,也並非好事。作者在項目中常被以下問題煩惱:
-
監聽器的執行順序控制
-
事件遞歸問題
-
核心流程模糊
時序問題
觀察者模式的指導之一是:儘量保持監聽器之間無依賴(無時序要求)。但在實際的項目中,通常無法完全避免監聽器之間的時序要求,比如跨天事件(凌晨 X 點),哪些邏輯先執行,哪些後執行就是有要求的。
不過,這個問題其實是最容易處理的;我們只需要將分散註冊的監聽器改爲集中註冊即可,即在一個方法中註冊事件關聯的所有監聽器。
public void RegisterDaybreakListeners() {
daybreakMgr.AddListener(moduleA.OnDaybreak);
daybreakMgr.AddListener(moduleB.OnDaybreak);
// ...
}
遞歸問題
遞歸是指在派發某類事件時又觸發了該類事件,最容易寫出來該類邏輯的,是遊戲中的揹包系統。
玩家在獲得道具時,我們通常會派發一個獲得物品事件,而這個事件可能會觸發任務完成等邏輯;這些監聽器在處理事件時,又可能向玩家發放道具,然後又觸發獲得物品事件;由於揹包數據已發生變化,即前一個物品事件的上下文其實已失效,因此可能引發 Bug。
public void AddItems(Player player, List<Items> items) {
AddItemsImpl(player, items);
FireAddItemEvent(player, items);
}
此外,向玩家發放道具的代碼可能也假設了揹包新增物品後的狀態;如果獲得物品事件導致了額外的揹包數據變化,可能會導致邏輯失敗。如果要求不能對揹包進行假設,那麼部分邏輯非常難編寫。
這類問題沒有統一的處理方案,只能具體情況具體分析;像任務系統,就可以延遲到下一幀再自動提交、發放獎勵。
事件用於線性流程的話是非常好的,不僅有良好的擴展性,邏輯也足夠清晰,事件流架構是很好的案例;但一旦產生迴路,複雜度就會暴增,事件就可能成爲一切問題的根源。
核心流程模糊
大規模應用事件的另一個問題是:核心流程模糊。
我們常聽到的一個編程指導是:高內聚低耦合。事件系統確實降低了耦合,但本應該直接寫在函數里的代碼通過事件去觸發的話,就會導致內聚性不足,業務流程不清晰。
這在策劃需求變更,或是代碼需要重構時影響較大,因爲我們看不見業務的全貌,就容易出現 Bug。
要解決這個問題,我們就需要能夠識別核心業務和次要業務,核心業務的代碼不可通過事件調用,只有次要業務的代碼纔可以通過事件擴展。
監聽器的刪除問題
我們在實現觀察者模式或發佈訂閱模式時,最頭疼的問題就是:通知監聽器時,如何處理刪除監聽器請求。
在多線程下,通常採用的是 CopyOnWrite 機制,不論是增加還是刪除監聽器,都會先創建副本,然後修改副本,再將監聽器列表的引用替換爲副本的引用 —— C# 的 event 就是這種機制。
這就有個問題:那就是刪除對當前通知(迭代)無效。
多線程下這麼設計,主要還是因爲性能問題:否則廣播監聽器需要加鎖,那性能問題太嚴重;多線程下要想避免被刪除後還被通知,還需要額外的機制,如取消令牌。
不過我們的遊戲業務主要還是單線程的,而且通常期望刪除是對當前通知有效的,所以一般不使用 C#的 event。
作者常用的方案是:標記法。即在迭代的過程中收到刪除請求時,只是將對應元素的槽位設置爲 Null,在迭代結束後,再檢查是否需要壓縮空間。示例代碼如下:
public class ListenerContainer {
private readonly List<Action<object>> list = new ();
private int deep;
private bool containsNull;
//
public void NotifyListeners(object? tempData) {
deep++;
try {
for (int idx = 0,len = list.Count; idx < len; idx ++) {
var listener = list[idx];
if (listener == null) continue; // 已被刪除
listener.Invoke(tempData);
}
} finally {
if (--deep == 0 && containsNull) {
RemoveNullElements(list);
}
}
}
//
public void RemListener(Action<object> listener) {
int idx = list.IndexOf(listener);
if (idx < 0) return;
if (deep == 0) {
list.RemoveAt(idx);
} else {
list[idx] = null; // 當前正在迭代,則僅僅標記爲null
containsNull = true;
}
}
}
PS:更優質的實現,可查看作者 commons 倉庫中的 DynamicArray 類。
事件對象實現
每個事件一個 Class
public class OnPlayerGetItem {
public readonly Player player;
public readonly List<Item> items;
public readonly int reason;
}
這應該大家最常見到的方式,它的優點是:
-
可讀性好:事件包含什麼數據一目瞭然
-
可維護性和可擴展性好
它的缺點是:
-
需要定義大量類型
-
不利於池化,每個類型需要獨立的對象池
我們通常會將養成系統的事件設計爲如此,因爲養成系統拋出事件的頻率並不算高;因此即使不池化,對 gc 的影響也不大;而每個事件一個 Class 的可讀性和可維護性都很好,這對保證項目的代碼質量是很關鍵的。
值類型 (Struct)
public struct UIEvent {
public readonly int type;
public readonly UINode container;
public readonly object uiObj;
public readonly int uiIdex;
public readonly int dataIndex;
// ...
}
它的主要優點:可以減輕 GC 壓力。
它的主要缺點:
-
擴展性較差,因爲不能擴展數據結構
-
不能使用通用的 EventSystem 拋出,否則會產生裝箱
-
如果事件會被頻繁地拷貝,則會產生較多的 CPU 開銷
值類型的事件設計並不常見,通常都代表着一些激進的優化,一般也只用於內部系統。不過,在我們的業務層的 MVC 框架中,View 傳遞給 Controller 的事件對象,通常都是隻讀的貧血對象,因此可以採用值類型。
注意:如果項目的事件處理存在 AOP 邏輯,那麼不能使用值類型。
黑板類型
public class GameEvent {
public int eventType;
public int childType;
public readonly Dictionay<int, Value> values = new ();
}
黑板類型是指:所有的事件都使用同一個 Class 表達,然後通過 int 或 string 字段表達事件的類型;事件的數據通常採用字典存儲。它的優點:
-
易於池化,對象複用率高
-
可以提供統一取值和設值接口
它的缺點:
-
事件的數據不直觀
-
實現不好會產生大量拆裝箱
-
有一定的額外開銷
大家在靜態語言中可能較少見到黑板類型事件,但大家在腳本語言中使用的事件對象其實都是黑板類型。
我們通常會將戰鬥系統的事件設計爲黑板類型,原因見事件測試部分。
PS:關於如何避免裝箱,請移步《行爲樹:黑板實現》。
爲事件增加子類型
雖然監聽器是按照類型監聽事件,但這不意味着該類型的所有事件都是有效的輸入;爲解決這個問題,部分項目允許用戶在註冊監聽器時同時綁定一個 Filter,事件系統在通知監聽器前執行測試。
public void AddListener(int type, Action<object> action,
Predicate<object> filter = null);
這種方案確實能避免監聽器收到不關注的事件,但並沒有減少廣播(迭代)範圍,所以並沒有提升系統的吞吐量。
在實際的項目中,可以發現:事件測試的第一步往往是 ID 這類屬性,比如技能事件中的技能 ID,Buff 事件中的 BuffId;也就是說,監聽器監聽的其實是該類事件的一個子分支。
基於這個原理:我們可以爲事件增加子類型,並允許監聽器在註冊時指定子類型。示例代碼如下:
public class EventSystem {
public Dictionary<long, ListenerContainer> listenerDic = new ();
public void AddListener(int eventType, Action<object> action){}
public void AddListener(int eventType, int childType, Action<object> action){}
public void Post(GameEvent evt) {
PostImpl(MakeKey(evt.eventType, 0) , evt);
}
public void Post(GameEvent evt, int childType) {
PostImpl(MakeKey(evt.eventType, 0) , evt);
PostImpl(MakeKey(evt.eventType, childType), evt);
}
private static long MakeKey(int eventType, int childType) {
return ((long)eventType << 32) | childType;
}
}
通過引入子類型,可以大幅減少廣播範圍,提升性能。
Q:爲什麼 childType 不直接定義在事件接口上?
A:可以定義在事件對象的接口上,但不必要 —— 依賴注入。
子類型帶來的問題(時序)
我一直都認爲:在引入一個方案時,知道其缺點往往比知道其優點更重要。引入子類型確實大大減少了廣播範圍,但也不是沒代價的,大家能想到是什麼問題嗎?
打亂了執行時序。當引入子類型時,監聽器被存儲到了不同的列表中 —— 它們的廣播順序就和原始的廣播順序不同,這有時可能會引發錯誤。
eventSystem.AddListener(EventTypes.OnCastSkill, 101, moduleA.OnCastSkill);
eventSystem.AddListener(EventTypes.OnCastSkill, moduleB.OnCastSkill);
以上面這段代碼爲例,雖然 ModuleA 先註冊技能施展事件,但 ModuleB 會先觸發。
一般而言,打亂執行順序是很危險的;不過,根據作者的經驗,像戰鬥系統、AI 系統、任務系統,使用子類型一般不會引發問題;而且,這個問題是可以修復的,當某個監聽器與其它的監聽器存在時序要求時,我們將這些監聽器都轉移到主列表上。
爲減少改動,我們可以對事件系統進行一下小小的改造:允許用戶在註冊監聽器時指定是否註冊到主類型上。
// 主類型監聽器列表:listener需要被封裝
public Dictionary<int, ListenerContainer> listenerDic = new ();
// 子類型監聽器列表
public Dictionary<long, ListenerContainer> listenerDic = new ();
// 可以指定是否註冊到主類型
public void AddListener(int eventType, int childType, Action<object> action,
bool registerToMainType = false){}
public readonly struct ListenerInfo {
readonly Action<object> action;
readonly bool hasChildType; // 通常也可以約定特殊的childType值實現
readonly int childType;
}
避免多級子類型
引入一級子類型就已經減少了大量的無效廣播,引入多級子類型是否會變得更好呢?
不知道。但可以確定的是:EventSystem 的複雜度會大幅增加,而且時序的處理也會變得麻煩。簡單說就是:得不償失。
事件的類型表達
事件的類型表達通常有以下選擇:
-
int32
-
enum
-
string
-
Type
除了第四種可通過 GetType(Java 爲 getClass)獲取外,另外三種都需要一個事件接口。
public interface IEvent {
int EventType { get; }
}
那麼我們怎麼選呢?
若非必要,不建議使用 string,因爲 string 的 equals 效率較差;至於 enum 和 int32 之間的選擇,取決於對擴展性的要求;如果允許外部程序集擴展事件類型,那就不能使用 enum,只能使用 int32;而如果沒有這方面需求,那麼使用枚舉的可讀性更好。
至於使用對象的類型作爲事件的類型,也是挺常見的實現,養成系統的事件通常會這麼做;因爲這麼做簡單,而且對性能影響不大。
避免 String 拼接和 ToString
有部分項目是使用 string 來表達事件類型的,如果僅僅是這樣,那也頂多就是 equals 效率差點,不需要多說;但我發現,他們在派發事件的時候存在字符串拼接和 ToString 調用,這就很要命了。
public void Post(GameEvent evt) {
PostImpl(evt.EventType, evt);
if (evt.ChildType != null) {
PostImpl(evt.EventType + "#" + evt.ChildType, evt);
}
}
頻繁的字符串拼接對性能的影響極大 —— CPU 和內存開銷都很大,應當儘量避免。一種簡單的解決方案是:爲聯合鍵定義一個結構體。
public readonly struct Key {
public readonly string first;
public readonly string second;
// ...equals hashcode
}
PS:就算是定義成 Class,也比字符串的拼接開銷小。
事件測試
在戰鬥系統中,條件觸發效果的佔比非常高,而條件觸發的主要邏輯就是事件測試,那麼如何實現事件的測試接口呢?
有兩種方式:
-
面向對象式:每個條件一個 Class
-
面向過程式:取值函數 + 關係運算符
面向對象式
面向對象式是指我們爲每一個條件定義一個 Class,每個條件都會從事件中提取自己需要的數據,然後進行測試。
public class SKillIdCondition : ICondition {
private HashSet<int> skillIdSet;
public bool Test(object evt) {
return skillIdSet.Contains(GetSkillId(evt));
}
}
優點:可讀性很好。
缺點:需要實現大量的類型。
面向過程式
其實可以發現,絕大多數條件測試最終都會演化爲:左值和右值的比較 —— 就像我們日常的編碼一樣。
因此我們的條件測試,也可以抽象爲取值函數和關係運算符。
// cfg包括操作的實體,以及值類型信息等
public interface IValueGetter {
// Long可以合併到Double
long GetLongValue(Blackboard ctx, object evt, GetterCfg cfg);
double GetDouleValue(Blackboard ctx, object evt, GetterCfg cfg);
// 用於contains測試
HashSet<int> GetSet(Blackboard ctx, object evt, GetterCfg cfg);
}
public class Condition : ICondtion {
private GetterCfg lhsCfg;
private GetterCfg rhsCfg;
private RelationOp op; // 關係運算符
// 假設這裏比較的是int值
public bool Test(object evt) {
long lhs = GetValue(balckboard, evt, lhsCfg);
long rhs = GetValue(balckboard, evt, lhsCfg);
// 根據關係運算符比較計算結果
return op switch {
RelationOp.Eq => lhs == rhs,
RelationOp.NotEq => lhs != rhs,
RelationOp.Gt => lhs > rhs,
// ...
_ => false
}
}
}
優點:貼近本質,靈活性和擴展性更好。
缺點:沒有具體的條件類型,缺少對業務邏輯信息的表達,有一定的維護難度。
怎麼選?
看起來是不是面向過程式更優?那就選面向過程式?
在實際的項目中,兩種方式都是需要的。原因包括:
-
並非所有的值都能轉換爲 long 或 double,比如座標
-
部分邏輯不能簡單轉換爲運算符和取值函數,比如座標測試
但整體架構上講,推薦以面向過程爲主,面向對象爲輔的方式。
黑板事件的優點
作者在第一個項目的時候,還正在學習怎麼編碼:重構、設計模式、整潔代碼...
那是我最癡迷面向對象的時候,對於重構一書中的 “用數據類代替記錄(Record)” 遵循的很徹底,總喜歡用具體的 Class 來描述對應的數據。
在表格模塊和戰鬥模塊,我都引入了大量的類型,但兩者給我的感覺卻不相同;在表格模塊,提前將表格中的數組數據解析爲 Class,對項目的代碼質量起到了很積極的作用;但在戰鬥模塊,具體類型的事件帶來的優點就並不明顯,因爲事件的處理存在大量的 instanceof (is);而且由於類型的不同,許多代碼無法簡單複用。
但在第三個項目,我開始大規模使用黑板和行爲樹(TaskTree);我就發現,在戰鬥系統這種對靈活度要求極高的地方,必須使用黑板代替具體類型。
回到事件測試上,當我們使用黑板表達事件時,可以大幅簡化事件測試(取值函數)的實現 ;而且也便於策劃在編輯器中配置事件測試。
public double GetDoubleValue(Blackboard ctx, Blackboard evt, GetterCfg cfg) {
// 部分情況下需要先確定GameObeject,然後從gobj取屬性...
return evt.GetDouble(cfg.eventKey);
}
結語
關於事件系統的設計就分享到這裏,現在留個思考題:通過 Id 監聽周圍 GameObject 的事件,是否也可能導致時序問題?
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/GaJWmXXunhQI7L73IGL1mQ