戰鬥系統:事件系統設計

    稍有點項目經驗的讀者,想必對事件都不陌生,本篇就和大家分享一下游戲中的事件處理。

閱讀提醒:

  1. 本篇不會講觀察者模式和發佈訂閱模式的基本概念,若尚不瞭解,建議先簡單瞭解一下。

  2. 本文字數較多,若想有所得,還需仔細閱讀。

遊戲事件的分類

    遊戲中的事件,作者將它們劃爲三類:

  1. UI 事件(輸入事件)

  2. 養成系統事件(數據變化事件)

  3. 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# 到處都在表達:我是一門客戶端語言。

事件帶來的問題

    引入觀察者模式或訂閱模式的主要優點是解耦,這一點想必大家都已很熟悉;但大規模應用事件,也並非好事。作者在項目中常被以下問題煩惱:

  1. 監聽器的執行順序控制

  2. 事件遞歸問題

  3. 核心流程模糊

時序問題

    觀察者模式的指導之一是:儘量保持監聽器之間無依賴(無時序要求)。但在實際的項目中,通常無法完全避免監聽器之間的時序要求,比如跨天事件(凌晨 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;
}

這應該大家最常見到的方式,它的優點是:

  1. 可讀性好:事件包含什麼數據一目瞭然

  2. 可維護性和可擴展性好

它的缺點是:

  1. 需要定義大量類型

  2. 不利於池化,每個類型需要獨立的對象池

    我們通常會將養成系統的事件設計爲如此,因爲養成系統拋出事件的頻率並不算高;因此即使不池化,對 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 壓力。

它的主要缺點:

  1. 擴展性較差,因爲不能擴展數據結構

  2. 不能使用通用的 EventSystem 拋出,否則會產生裝箱

  3. 如果事件會被頻繁地拷貝,則會產生較多的 CPU 開銷

    值類型的事件設計並不常見,通常都代表着一些激進的優化,一般也只用於內部系統。不過,在我們的業務層的 MVC 框架中,View 傳遞給 Controller 的事件對象,通常都是隻讀的貧血對象,因此可以採用值類型。

    注意:如果項目的事件處理存在 AOP 邏輯,那麼不能使用值類型。

黑板類型

public class GameEvent {
    public int eventType;
    public int childType;
    public readonly Dictionay<int, Value> values = new ();
}

    黑板類型是指:所有的事件都使用同一個 Class 表達,然後通過 int 或 string 字段表達事件的類型;事件的數據通常採用字典存儲。它的優點:

  1. 易於池化,對象複用率高

  2. 可以提供統一取值和設值接口

它的缺點:

  1. 事件的數據不直觀

  2. 實現不好會產生大量拆裝箱 

  3. 有一定的額外開銷

    大家在靜態語言中可能較少見到黑板類型事件,但大家在腳本語言中使用的事件對象其實都是黑板類型。

    我們通常會將戰鬥系統的事件設計爲黑板類型,原因見事件測試部分。

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 的複雜度會大幅增加,而且時序的處理也會變得麻煩。簡單說就是:得不償失。

事件的類型表達

    事件的類型表達通常有以下選擇:

    除了第四種可通過 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,也比字符串的拼接開銷小。

事件測試

    在戰鬥系統中,條件觸發效果的佔比非常高,而條件觸發的主要邏輯就是事件測試,那麼如何實現事件的測試接口呢?

有兩種方式:

  1. 面向對象式:每個條件一個 Class

  2. 面向過程式:取值函數 + 關係運算符

面向對象式

    面向對象式是指我們爲每一個條件定義一個 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
        }
    }
}

優點:貼近本質,靈活性和擴展性更好。

缺點:沒有具體的條件類型,缺少對業務邏輯信息的表達,有一定的維護難度。

怎麼選?

    看起來是不是面向過程式更優?那就選面向過程式?

    在實際的項目中,兩種方式都是需要的。原因包括:

  1. 並非所有的值都能轉換爲 long 或 double,比如座標

  2. 部分邏輯不能簡單轉換爲運算符和取值函數,比如座標測試

    但整體架構上講,推薦以面向過程爲主,面向對象爲輔的方式。

黑板事件的優點

    作者在第一個項目的時候,還正在學習怎麼編碼:重構、設計模式、整潔代碼...

    那是我最癡迷面向對象的時候,對於重構一書中的 “用數據類代替記錄(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