事件驅動的遊戲架構

背景

遊戲開發的過程中會經常面臨代碼的改動或重構,我們如何做到編寫大型遊戲,經常改動又不陷入混亂。能用最少的時間和精力完成相同的特性,又有比較高的重用性,使代碼的有效期更長(更少次數的重構),質量更高

經過長時間的重寫代碼,我想出了這套架構

注意

  • 本篇文章為了方便說明,減少代碼量,全篇使用偽函數。和Kotlin很相似,但並不是Kotlin,並不能運行。只是我寫的和Kotlin很像這樣就有高亮了
  • 我用這個架構時間還不長,差不多一個月的時間,可能會有一些小問題
  • 本篇文章的代碼都比較簡單,便於說明

目標

用最少的代碼實現最多的功能,最大的通用性,組合性,較少的重構

核心概念

在細胞里有4個結構:事件系統,變數,反應器和標記

變數(Variable)

變數里只有變數,和可以方便修改變數的函數

變數不能自發地修改自己的變數的值

當變數被修改時可以發送委託

事件(GameEvent)

細胞在接收到事件之前會先發送一個『邊界事件』,如果返回False可以阻止事件進入細胞。如果沒有接收『邊界事件』的函數事件也會進入細胞內

事件可以被中斷(意思是後面的接收的函數都不會被執行),如果在Begin函數里中斷並發送其他事件,這樣的事件就叫跳轉事件。

事件可以接收函數的返回值,『收集』它們(List<out Any>),並在最後Reduce成一個返回值

如果細胞里沒有接收事件的函數,可以繼續把事件發送到上一級(也就是世界,目前只有一級)

反應器(Reactor)

可以保存Parent細胞的變數,接收事件,修改變數。並擁有一些簡單的函數

可以發送委託

標記(Marker)

可以附著在細胞膜上,用來標識細胞

標記里有變數Parent: Cell, Owner: Cell,分別表示它來自哪個細胞和現在所在的細胞

在標記里有Begin, Finish, Update(delta: Float)函數可以重載

如果你還不是很不明白,可以到結尾處來看實現代碼(注意是偽代碼,並不能運行)

先看一段簡單的代碼

// 變數class PropertyVariable : Variable { class PropertyValueChangedEvent(val property: PropertyVariable, val value: Float) var valueChangedEvent = Delegate<PropertyValueChangedEvent>() var value: Float set (value) { field = value valueChangedEvent(PropertyValueChangedEvent(this, value)) } operator fun +(amount: Float) { value += amount } operator fun -(amount: Float) { value -= amount }}// 標記// class PerishMarker(var amount: Float, var propertyClass: Class<out PropertyVariable>) : Marker { var property: PropertyVariable override fun onStart() { property = owner[propertyClass] } override fun onTick(delta: Float) { property -= amount * delta }}// 事件// 後面Bool::Class的意思是這個事件的返回值是Boolclass DamagePropertyEvent(val amount: Float) : GameEvent(Bool::Class)// 反應器// class PropertyReactor : Reactor { var propertyClass : Class<out PropertyVariable> var property : PropertyVariable override fun beginPlay() { property = owner[propertyClass] registe(DamagePropertyEvent, self.onDamagePropertEvent) } private fun onDamagePropertEvent(event: DamagePropertyEvent) { property -= event.amount return true }}

從上面的代碼可以看出一下幾點

  1. 可以看到變數和函數是分離的,變數寫到Variable里,函數寫到Reactor
  2. 想要給變數添加新的功能,並不需要把函數寫到Variable里。比如上面有一個每秒減少屬性值的功能,就寫到了PerishMarker里,可以隨時給Entity加標記,隨時拿掉標記
  3. Entity和Entity之間的通信通過事件進行,這樣可以做到類型無關,也不需要知道有什麼組件

先加一段代碼方便接下來的操作

class HealthProperty : PropertyVariable()

如果想要對Entity的屬性造成傷害怎麼辦?發送事件

entity.post(DamagePropertyEvent(10.0f))

這個事件會在PropertyReactor:onDamagePropertyEvent(DamagePropertyEvent)函數里執行

這裡並不需要直到Entity的類型,也不需要它有特定的函數,也不需要它有特定的組件。是類型無關的

注: 如果Entity沒有相應的反應器,默認行為是什麼都不發生。

如果想讓Entity每秒減少一定的屬性值呢?添加標記

entity.addMarker(PerishMarker(1.0f, HealthProperty::class))

變數里不需要有函數來做這些操作

添加標記可以動態添加和刪除

高可組合性,可重用性

注: 如果entity沒有HealthProperty,默認行為是什麼都不發生,不會出錯

這裡簡單寫一個傷害系統(非常簡單的那種)

class DamageEvent(val damage: Float) : GameEvent()class DefenseProperty : PropertyVariable()// 跳轉事件class PointDamageEvent(val damage: Float, val hit: HitResult) : GameEvent() { override fun onBegin() { val factor = calc damage factor ... (hit) // 發送』Damage事件 post(DamageEvent(damage * factor)) // 中斷事件 break() }}class DamageSystem : Reactor() { class ReceivedDamageEvent(val entity: Entity, val damage: Float) val receivedDamageEvent = Delegate<ReceivedDamageEvent>() override fun beginPlay() { registe(DamageEvent::Class, self.onApplyDamageEvent) } private fun onApplyDamageEvent(event: DamageEvent) { val health: HealthProperty = event.self[HealthProperty::class] val defense: DefenseProperty = event.self[DefenseProperty::class] val realDamage = event.damage - defense.value health -= realDamage receivedDamageEvent(ReceivedDamageEvent(owner, realDamage)) }}

Entity只需要添加DamageSystem反應器就可以受到傷害了

跳轉事件

你可能注意到了上面的代碼里有一個PointDamageEvent事件,這是一個跳轉事件,它在執行之前(onBegin函數)就中斷(break函數)了,這意味著沒有人響應這個事件。並且它發送了DamageEvent事件,這樣就成功跳轉到DamageEvent上去了

世界事件

如果我們想讓一個全局的Entity來處理傷害,我們可以這樣做

class GlobalDamageSystemEntity : Entity() { init { world.addReactor(DamageSystem::Class) }}

在世界上添加這個組件就可以了

如果事件向上發送,self: Entity變數來獲取原本事件的接收者

完成。把它放到世界裡,這個世界裡的所有的Entity都可以受到傷害了(當然它們需要有HealthProperty變數)。不需要再向Entity里添加DamageSystem組件了

邊界條件

如果想控制在某些條件下才能接收到事件,可以這樣做

class SomeEntity : Entity() { var god: Bool init { // 註冊邊界事件(這是一個特殊的事件,會在真正的事件執行之前發送,返回False會停止發送真正的事件) registeBoundaryEvent(DamageEvent::Class, self.onDamageCondition) } // DamageEvent在發送之前會先調用這個函數,如果返回True則繼續調用,返回False則中斷 fun onDamageCondition(event: BoundaryEvent<DamageEvent>) { // 如果Entity不是上帝(god)則會收到傷害 return !god }}

偽函數

// 這樣看起來就像Cell自身的函數一樣,其實Cell里什麼都沒有// 這個 Cell. 的意思是拓展函數(見Kotlin拓展函數),就是說 applyDamage(cell, damage)和cell.applyDamage(damage)是等價的fun Cell.applyDamage(damage: Float) { return post(DamageEvent(damage)) }

如果函數名和事件名保持一致,則比較少的概率會重名

如果沒有『反應器』接收事件,則什麼都不會發生。並不會調用出錯

事件迭代器

class XXEvent : GameEvent() { // 這裡收到了執行事件函數的返回值,可以在這裡重寫 override fun receivedResult(result: Any?) { return result } // 這裡把所有事件函數的返回值Reduce到一個Object,可以從result: Any?變數中獲取 override fun reduceResult(results: List<Any?>): Any? { return results.isEmpty() ? null : results[0] }}

有時間寫個例子

線程事件

// 線程事件會在線程里執行registeThreadEvent(XXEvent::Class)

延遲事件

// 延遲事件會在幀末執行registeDelayEvent(XXEvent::Class)

總結

  1. 變數和函數完全分離
  2. 高模塊化,低耦合,易於測試
  3. 重用性高
  4. 類型無關,組件無關。不用關心Entity的類型,不用知道Entity有什麼組件,組件有什麼函數。一切都是事件驅動的,只需要發送事件就可以
  5. 修改代碼方便,只需要保證Entity或Reactor的輸入事件和輸出事件一致行為就一致(如果函數內容不一致其實行為還是不一致的)
  6. 不容易導致編譯錯誤

不變和變化

事件和變數是不變的,它們如果變化的話修改成本會很高,因為有很多引用

反應器可以變化,修改成本低

哲學

這裡暫不做討論,以後再說

細胞的基礎代碼

待會放上


推薦閱讀:

GMS2官方教程系列6/8——添加音效
如何打造細緻的2D捏臉系統
GMS2官方教程系列8/8——隨機生成敵人
Redis在遊戲伺服器中的簡單應用
大廠遊戲海外版出現私服用戶遭洗《劍俠情緣》等手游已中招

TAG:遊戲開發 | 軟體架構 | 事件驅動Eventdriven |