iT邦幫忙

2022 iThome 鐵人賽

DAY 20
3

在早期還是新手程式設計師的我,一直搞不懂事件驅動的程式風格到底有什麼好處,也因此走了一些冤枉路,更糟的是我繞了遠路還不自知。因此希望能藉今天這篇文章,讓同學們能早點體會事件驅動程式設計的好處以及適用時機,幫助同學們的未來能走得更快更遠。

由事件驅動的程式流程

先簡單介紹一下何謂事件驅動(event-driven)?

一般我們處理遊戲的流程,就是按順序一路寫下來,我們可以用類別(class)來幫所有函式分門別類整理好,我們可以用介面(interface)來增加函式參數的彈性與可擴充性,我們還可以用各種設計模式(design pattern)增加解題的效率。雖然有很多方法和工具可資利用,但程式還是得一行一行地跑下來、繞過去、走回來,我們看著程式碼,就可以跟著程式執行的流向,一路看到尾。

事件驅動就不一樣了,這種設計方式從一開始就不打算讓程式照流程跑下來。在事件驅動的設計中,主角是「事件」,程式的走向是圍繞在事件短暫的一生,包括事件的發生,相關資料的收集,事件的發布,以及事件的消亡。是不是感覺就像一則新聞報導的生命週期。

舉個最常見的實例給大伙兒瞧瞧-滑鼠左鍵的點擊事件。

批次程式設計

在撰寫遊戲中的滑鼠類別時,如果放棄使用事件驅動的設計方式,那麼程式的走向就可能變成這樣:

  • 在遊戲每幀更新的函式中:
  • 如果滑鼠左鍵上一幀沒按下去 ➜
  • 如果滑鼠左鍵現在按下去了 ➜
  • 去玩家角色看看左鍵按下去要不要做什麼 ➜
  • 去選單看看左鍵按下去要不要做什麼 ➜
  • 去每個按鈕看看左鍵按下去要不要做什麼 ➜
  • 繼續去遊戲的各個地方看看左鍵到底還要幹什麼...

事件驅動程式設計

在事件驅動的系統中就不是這樣了。事件驅動的流程分成兩個部分,一個是事件自己的流程:

  • 在滑鼠每幀更新的函式中:
  • 如果滑鼠左鍵上一幀沒按下去 ➜
  • 如果滑鼠左鍵現在按下去了 ➜
  • 建立一個滑鼠左鍵按下去的事件 ➜
  • 收集滑鼠目前的各項屬性 ➜
  • 將這個事件的消息發布出去 ➜
  • 事件結束。

事件驅動的第二個部分是遊戲中其他元件的流程,這邊以玩家角色為例說明它的流程:

  • 當玩家角色變成待機模式時 ➜ 向滑鼠訂閱左鍵的事件新聞 ➜ 離開待機模式時向滑鼠取消訂閱。
  • 當收到訂閱的事件後 ➜ 發動攻擊。

同學們能感受到事件驅動帶來的巨大好處了嗎?在事件驅動的流程中,我們完全可以把滑鼠的設計從遊戲中獨立出來。所有遊戲的物件,在需要滑鼠來觸發某些動作時,就去滑鼠那兒訂閱一下事件,更酷的是,在不需要這些事件的時候,比方說角色暈眩時,還能夠取消滑鼠事件的訂閱,這樣的設計是不是就能大幅精簡遊戲中各種滑鼠相關的判斷流程。

採用事件驅動的佳機

哪些東西要採用事件驅動的方式來設計,這個需要經驗才容易判斷,畢竟沒有從這個方法感受到好處過的新手,怎麼會想到要使用它。不過小哈可以先透露一些可能比較適合的時機給同學們參考。

輸入輸出裝置

正如剛剛鍵盤的例子,輸入輸出的裝置如果能儘量從遊戲核心中獨立出來,那麼以後要支援不同的裝置,滑鼠換成搖桿,或是從電腦換到手機,都會簡單多了,因為遊戲核心只關心各種按鈕的事件新聞,實際的輸入裝置怎麼實作都可以。

舉例來說,遊戲核心不直接使用鍵盤上的鍵,而是定義一些遊戲使用的輸入鍵,像是「攻擊鍵」、「跳躍鍵」、「喝水鍵」等比較抽像的按鈕,然後從輸入裝置那兒訂閱這些事件。

接著在實作輸入裝置的類別裏,把系統的輸入包裝起來,改送「攻擊鍵」、「跳躍鍵」等事件出去,那麼以後即使換了輸入裝置,或是玩家自訂按鈕布局,對遊戲核心來說也完全不受影響。

遊戲介面

介面和遊戲邏輯也最好個別獨立,並使用事件來互相溝通。

比如說玩家的血量變動時,就不要直接去找有哪些介面要跟著更新。使用事件驅動的話,只要發一個血量改動的事件出去就好。和血量有關的遊戲介面應該自己去訂閱角色血量變動的事件,並在收到事件的時候去更新介面。

如此一來,未來增加相關的新介面,或是舊介面被廢棄的時候,就不會影響到遊戲核心的邏輯架構。

實作事件驅動物件

瀏覽器內建了EventTarget以及Event這兩個類別,不過我們遊戲中無法借來用,所以我們要自己寫一組類似EventTarget與Event的簡單類別。

EventTarget

EventTarget提供了訂閱、發送等功能,主要由三個函式所構成。

  • addEventListener(type, listener)
    向EventTarget訂閱主題為type的事件新聞,並在事件發生時執行listener這個函式。
    有很多函式庫把這個方法命名為 on(type, listener)
  • removeEventListener(type, listener)
    取消先前的訂閱。
    有很多函式庫把這個方法命名為 off(type, listener)
  • dispatchEvent(event)
    發送事件發生的新聞。
    有很多函式庫把這個方法命名為 emit(event)

Event

Event是被發送的事件,會帶有事件主題這個屬性。一般在設計事件時,都會繼承這個類別,並在事件內夾帶更多的資料,待會兒我們會實作一個遊戲內發生的事件來說明。

程式實作

/** 寫一個類似Event的基礎類別 */
class MyEvent {
    // 建構子要給事件的主題
    constructor(public type: string) {

    }
}

/** 寫一個類似EventTarget的類別 */
class MyEventTarget {
    /** 儲存和主題對應的訂閱者列表
     * {[key: string]: Function[]}是一般物件型別,字串為鍵,函式陣列為值
     */
    listenerMap: { [key: string]: Function[] } = {};

    /** 訂閱功能(也可以叫addEventListener) */
    on(type: string, listener: Function) {
        // 取出type對應的訂閱者列表, 找不到就用空陣列
        let listenerList = this.listenerMap[type] || [];
        // 增加訂閱者
        listenerList.push(listener);
        // 放回去
        this.listenerMap[type] = listenerList;
    }
    /** 取消訂閱功能(也可以叫removeEventListener) */
    off(type: string, listener: Function) {
        // 取出type對應的訂閱者列表, 找不到就用空陣列
        let listenerList = this.listenerMap[type] || [];
        // 找訂閱者
        let index = listenerList.indexOf(listener);
        // 找到就移除
        if (index != -1) {
            // 從index的位置移除一個元素
            listenerList.splice(index, 1);
        }
    }
    /** 發布事件消息(也可以叫dispatchEvent) */
    emit(event: MyEvent) {
        // 取出type對應的訂閱者列表, 找不到就用空陣列
        let listenerList = this.listenerMap[event.type] || [];
        // 把event送給所有的訂閱函式
        listenerList.forEach(listener => listener(event));
    }
}

事件驅動有很多現成的函式庫可以用,小哈常用的事件驅動類別函式庫在這裏: EventEmitter3

下面我們試用剛剛寫好的工具來完成一個非常簡單的事件驅動流程,給大家體會一下事件驅動是什麼感覺。

/** 先寫一個繼承MyEvent的血量變更事件 */
class HpChangeEvent extends MyEvent {
    // 定義這個事件的主題
    static TYPE = "hpChange";
    // 建構子
    constructor(
        public actor: Actor, // 發生血量變化的角色
        public hp: number    // 發生血量變化後的血量值
    ) {
        // Event的建構子需要給主題
        super(HpChangeEvent.TYPE);
    }
}

/** 寫一個繼承MyEventTarget的角色類別 */
class Actor extends MyEventTarget {
    // 用一個非公開的變數儲存血量
    // private是關鍵字,代表隱私
    private _hp = 100;

    // 用getter函式來取血量值
    get hp(): number {
        return this._hp;
    }
    // 用setter函式來改變血量值
    set hp(value: number) {
        // 血量不能低於0
        value = Math.max(0, value);
        // 檢查血量有無變化
        if (this._hp != value) {
            // 設為新值
            this._hp = value;
            // 建立事件,並把相關資料放進去
            let event = new HpChangeEvent(this, this._hp);
            // 公告天下
            this.emit(event);
        }
    }
}

有了事件和可發布事件的物件,我們就可以來進行實驗了。

/**
 * 遊戲核心
 */
// 建立一位角色
let actor = new Actor();
// 寫個函式讓角色在n秒後改鑾deltaHp的血量
function changeHpInSeconds(seconds: number, deltaHp: number) {
    // 使用setTimeout延遲動作
    setTimeout(
        function () {
            // 下面這行會用hp的getter取值,加上變動值
            // 再用hp的setter存回去
            actor.hp += deltaHp;
        },
        seconds * 1000 // 延遲毫秒數
    );
}
// 一秒後減50
changeHpInSeconds(1, -50);
// 兩秒後加10
changeHpInSeconds(2, 10);
// 三秒後減30
changeHpInSeconds(3, -30);

/**
 * 遊戲介面:訂閱角色血量改變的事件
 */
actor.on(
    HpChangeEvent.TYPE, // 訂閱主題
    function (event: HpChangeEvent) { // 收到消息要做的事
        console.log("角色的血量變成了 " + event.hp);
    }
);

在示範程式中,角色血量的變化獨立於用來顯示血量變化的介面。未來我們把剛剛遊戲介面的程式碼都刪除,改用漂亮的圖案動畫來取代,對遊戲中實際增減角色血量的邏輯也不會有一絲影響。

CG示範專案

事件驅動的缺點

事件驅動設計法的缺點很明顯的就是無法一眼看清程式的運作流程。如果同樣是遊戲核心的一部分,但為了實現事件驅動而切成小小塊的話,那麼切割時以及設計上就要非常小心地全盤考量,不然未來在除錯上會比較傷腦筋。不過也正因如此,事件驅動的架構會驅使設計師在寫程式時考量得更全面,更能看清每個獨立系統的核心價值。又因為系統被分割成獨立小塊,讓整個專案較容易和同伴分工並進。

事件驅動的另一個小缺點,就是只靠事件來溝通的物件之間,比較無法針對流程的細節最佳化。雖然這聽起來有點令人沮喪,不過被強迫無法最佳化,有時也是一種幸福,因為最佳化的另一個暱稱就叫Bug彩蛋,不知道哪天一個小改動就會破殼而出,咬你一口。


明天我們會繼續在事件驅動的基礎上,實作拖曳物件的介面邏輯,敬請期待吧。


上一篇
Trick 18: 收下我的承諾,遲早給你個交待-I Promise
下一篇
Trick 20: 把鎧甲拉到身上裝備的拖曳控制器
系列文
30個遊戲程設的錦囊妙計32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言