天天看點

在cocos2d-x中實踐資料驅動的遊戲開發

from: http://elvisco.de/2013/09/entity-component-system/

在cocos2d-x中實踐資料驅動的遊戲開發 2013/09/29

(本文已發表在《程式員》雜志2013年10月刊,轉載需經《程式員》雜志許可)

在2002年的GDC大會上,Scott Bilas做了一個題目叫做《A Data-Driven Game Object System》的演講,他在自己的部落格上說懷疑自己是第一個提出這個概念的人,但肯定是第一個對公衆解說這個概念的人。

這個概念引起了遊戲圈内廣泛的讨論,至今已形成比較完整的理論,維基百科上稱作Entity-component-system(ECS),或者更簡便的稱作Entity System。到今天,著名的遊戲引擎Unity甚至完全基于ECS來建構。

然而國内社群似乎很少讨論ECS,我們常使用的Cocos2d遊戲引擎預設也不支援(最近的2.1.4版本加入了簡單支援,後面分析)ECS。本文 将詳細探讨ECS的工作原理,優點及不足,以及它和遊戲引擎的關系,還會簡單分析Unity中ECS實作的方法,最後提供一個基于Cocos2d-x實作 ECS的完整的源代碼執行個體。

  • 面向對象在遊戲設計中遇到的問題

ECS的出現起源于面向對象的架構風格在遊戲設計中遇到了一些問題。在現代軟體設計中,通常使用面向對象的思想來抽象各種實體,資料模型,業務邏 輯,它工作的很好,我們用易于了解的名詞例如Car,People等類來描述軟體中的各種概念,通過繼承,多态來表述同類事物的不同特征和不同行為,如下 圖:

在cocos2d-x中實踐資料驅動的遊戲開發

 圖1. 《Ghost in the Sea》中部分類結構

然而這樣的設計方式在遊戲開發中卻遇到了困難,例如在上面結構圖中,船是不同于英雄的類,但是其實船也是可以産生資源的,它是否應該繼承于“資源” 類呢?如果他們是互相獨立的,意味着有部分重複代碼;其次,如果設計需求中新加入一種敵人,它可以“遠端”攻擊輪船,用什麼攻擊呢,我們給它配一把 “槍”,我們有一些解決方法:

  • 使用C++中的多繼承,讓新加的敵人同時繼承于“槍”和“遠端”。
  • 從“遠端”繼承,複制“槍”的代碼。
  • 為了清晰表達一個新的類型,幹脆建立一個類,複制兩個類中的代碼。
  • 把代碼移至父類中,不同的子類執行不同的方法。

我們看到,不管使用哪一種方法,都需要對程式進行不同程度的修改,修改的原因是為了代碼重用,修改的結果卻使得類的職責不再清晰。這就是面向對象在 處理交叉關系時遇到的問題,這不僅涉及到每次變更都要重構類的結構,更加重了了解和處理這些關系的負擔。并且這會占據很多時間:在項目開始的時候,絞盡腦 汁把簡單的資料庫轉化為面向對象世界複雜的關系,在每次需求變更的時候重構這種關系。

而在整個遊戲開發過程中這樣的變更非常頻繁。每一次變更可能會影響到很多地方,對遊戲的穩定性也是非常不利的。而應對這種頻繁複雜的需求變更正是基于元件的架構最擅長的。

  • 基于元件的架構風格

基于元件的架構設計風格核心思想是組合優于繼承(prefer composition over inheritance),即是通過将各個相對獨立的資料和邏輯組織成一個元件(而不是通過繼承)來實作代碼重用。這樣我們可以通過不同的元件組合形成不 同特征的對象,這樣形成一個扁平而不是樹形的結構,如下圖:

在cocos2d-x中實踐資料驅動的遊戲開發

圖2 基于元件的扁平的架構設計風格

從上圖可以看到,一個GameObject本身包含很少的資訊,它僅僅是由一些元件組成,這些元件的組合決定一個特定GameObject的特征和 行為。這樣就可以應對不同的需求同時保持比較穩固的架構。如果新的需求加入一種新的資料和行為,我們就定義一個新的元件,如果新的需求具有不同的行為組 合,我們就為這類對象添加不同的元件組合即可,這對已有程式不會有什麼影響。

基于元件的設計還使我們将精力集中在邏輯及資料本身,而不是絞盡腦汁去抽象各種類層次結構,繼承關系。軟體設計關心的是資料和行為,面向對象是為了 重用代碼,簡化設計的一種方法,它不是程式設計的規則和原則,它也有它的局限性。是以我們可以說基于元件的設計風格和面向對象的設計風格是兩種比較獨立的 方法,讀者有必要區分這兩個概念。

  • Entity Component System中的概念

ECS是一種架構風格,是以它可以有不同的實作方式,限于篇幅,這裡隻介紹社群讨論和使用比較多的方法。傳統的ECS架構分3部分,分别是 Entity,Component和System,以及一個用來管理Entity和Components對應關系的EntityManager,以下分别 介紹:

  • Entity:Entity泛指一個遊戲對象,它僅用來辨別一個對象,除此之外不包含任何資訊。它可以包含或者不包含UI,這些都由它所擁有的Components來決定,不同的元件組合構成了一個特定的遊戲對象:
在cocos2d-x中實踐資料驅動的遊戲開發

System:System表示Entity的一個行為,它用Component提供的資料,根據一定的邏輯更新Entity的狀态,例如HealthSystem計算英雄的血量并繪制血條。在一個冒險遊戲中,MoveSystem則會移動人物不斷向指定方向前進:

在cocos2d-x中實踐資料驅動的遊戲開發

 EntityManager:EntityManager是Entity的資料庫,它存儲所有的Entity及每個Entity擁有的Component集合,System将從這裡檢索所有Entity及擷取需要的Component

在cocos2d-x中實踐資料驅動的遊戲開發
  • 怎樣使用Entity Component System

有了ECS的一些基本概念,那麼首先來看一下在cocos2d-x中怎樣使用它,然後再詳述ECS的工作機制。現在假設需求是要為一個遊戲對象添加 一個Sprite,并繪制血條。現在我們還不太清楚ECS的工作機制,那麼我們試着根據Entity-Component-System的定義來思考我們 該怎樣設計。

首先,我們要用一個特定的Entity來表示一個特定的遊戲對象,這裡假設我們現在要處理的是輪船,我們這樣寫:

Entity* ship = _entityManager->createEntity();

我想我們已經完成輪船的定義了,不是嗎?還記得前面說過Entity僅用來代表一個遊戲對象嗎?除了一個用于差別自己的Id,再沒有什麼其他資訊。

接下來,應該定義Component。因為Component代表資料,分析需求,我們需要什麼資料呢,我覺得應該需要一個UI元素,以及表示血條 相關的資訊。那麼我們将定義兩個Component:RenderComponent和HealthComponent,這裡僅分析 HealthComponent,讀者自行檢視源代碼關于RenderComponent:

我們定義了HP,Alive等Health相關的屬性,然後重寫父類的虛函數getComponentType(),它用來表示一個 Component的類型。在有些設計中使用RTTI中的typeId來表示Component的類型,這裡用std::string來表示類型,因為會 頻繁檢索。

最後就是System了,這是處理Component資料并更新遊戲對象狀态的地方,也就是遊戲中的邏輯,這裡不再貼healthSystem.cpp的代碼,讀者自己參照源代碼:

System是遊戲的循環,它應該被mainLoop調用。從HealthSystem的源代碼中可以看出,在每次update的時候,它首先從 Entity中查詢所有具有HealthComponent資料的Entity對象,然後周遊每個Entity,如果目前血量小于0,則将alive設定 為false,最後它還檢查該Entity是否包含RenderComponent資料,如果包含則将UI元素從螢幕移除,因為該對象已經死亡。

現在我們已經準備好所有需要的資料(Component),以及處理邏輯的算法(System)了,接下來我們讓ECS系統工作起來,打開HelloWorldScene.cpp檔案,找到以下部分:

分析上面的代碼,我們建立了一個Entity對象,用來表示船。ship對象擁有UI表現,血量等相關資訊,是以我們添加RenderComponent和HealthComponent到ship對象,這樣ship對象就有了UI和血量相關的資料。

然後,我們在update中調用每個System的update方法,每個System在update中将根據自身行為對資料的需求,從 EntityManager中周遊所有Entity,滿足條件的Entity将對其進行處理,處理什麼呢,其實就是修改Component中的資料,這樣 就實作了遊戲循環。

也許你現在還是很迷惑,System到底應該怎樣處理Entity呢,我們再用一個比喻來看一下ECS的工作原理。

  • ECS是怎樣做到資料驅動的

在StackExchange上有個叫Byte56的開發者将ECS比作鎖系統,他把Entity比作一把鑰匙,而每個Component是一把鑰匙上的齒槽,擁有不同Component組合的Entity就形成一把不一樣的鑰匙:

在cocos2d-x中實踐資料驅動的遊戲開發

圖3 将Entity比作一把鑰匙

而将System比作一把鎖,如下圖的MoveSystem對鑰匙的定義,隻要是同時擁有Position和Velocity齒槽的Entity就将會被MoveSystem處理。

在cocos2d-x中實踐資料驅動的遊戲開發

圖4 将System比作鎖

與真實鎖系統不同的是,這裡System定義的鎖和Entity定義的鑰匙不是絕對比對的,Entity隻要包含System需要的齒槽即可,如圖3表示的Entity也會被圖4表示的System處理。

通過這樣的機制,我們可以将遊戲中的每一個行為抽象成一個獨立的System,一個System封裝了某一個方面的遊戲邏輯單元,這個邏輯單元基本 上不可再拆分成更小的邏輯單元。而每個System需要特定的一些資料,這些資料由Component提供,滿足某個System所需要的資料集合,即被 認為需要執行該System中的邏輯處理。

反過來,通過給Entity組合不同的Component,構成不同的“條件”,就自動給Entity附加上了一個不同的行為,這樣就實作了資料驅動。

  • 關于資料和邏輯的分離

ECS是一個資料和邏輯高度分離的很好的例子,Component僅定義純資料,而System中不存儲任何Entity的資料,最多包含一個循環 内的臨時資料,由于系統中每個System隻存在一個執行個體,它也限制着我們不能在System中存儲遊戲對象的資料,是以完全成為一個邏輯的封裝。

資料和邏輯的分離從一定程度上降低了耦合,因為耦合的原因一般都是一個對象希望通路另一個對象中的資料,如果僅是通路一個純算法,這是完全可以排除 這部分耦合的,就是因為包含了特定的資料,才使得兩個類之間有直接的關聯,是以如果将資料存儲至一個公共的地方,不同的算法都從這裡讀取資料,這樣就能排 除耦合,在ECS中每個System都是純算法,互相之間沒有什麼耦合的部分。

在cocos2d-x中實踐資料驅動的遊戲開發

圖5 ECS中資料和邏輯完全分離

  • ECS和遊戲引擎的關系

通過對ECS的學習,我們明白它是一種架構風格,更具體一點,它指導我們應該怎樣設計算法和使用資料,是以它是和繪制無關的,它需要和遊戲引擎的繪制系統一起工作。

那麼它到底需不需要引擎支援?确實有引擎如Unity(我們後面會分析)基于ECS系統來設計引擎,然而從ECS的概念來看,它基本上可以和遊戲引 擎一起工作,而不需要引擎提供專門的支援,因為它僅包含行為和資料,與繪制無關。就像Box2D實體引擎,它包含的也僅僅是算法,它可以和大多數遊戲引擎 例如Cocos2d一起工作。

值得注意的是,在實踐ECS的時候,問的比較多的問題是關于輸入(觸摸,滑鼠,重力感應等)應該怎樣處理,是應該在System還是在 Component中擷取系統事件。其實輸入屬于遊戲引擎的基礎設施,就像UI元素的樹形結構,這些和遊戲行為無關的事情不應該放入到ECS中去,這些都 應該轉化成資料存儲在Component中。

是以,不是所有代碼都應該ECS化,ECS應該是和遊戲引擎結合起來使用,例如元素的層級結構,深度,觸摸響應,動畫,地圖等等都應該在引擎層面來 處理,然後将之轉化為Component資料供System使用。記住,ECS隻包含遊戲行為和遊戲資料,ECS幫助我們将這部分邏輯從引擎中抽取出來。

當然,ECS也可以被內建進遊戲引擎中,它跟引擎之間就能形成了一個比較好的協作,它甚至能幫助引擎實作某些功能,我們來分析一下Unity引擎中的元件系統。

  • Unity中的ECS分析

我們一直強調ECS是一種架構風格,它可以有不同的實作方式,Unity在元件系統的實作上較标準的ECS系統有幾點不一樣:

  • 将System轉化為執行個體,附加到每個GameObject中,而不是集中一個System處理所有GameObject。這樣做的好處 是:System中的邏輯處理可以和UI結構相對應,标準的ECS系統是忽略UI結構的;其次是支援可視化設計,通過引擎本身提供一些标準的繪制相關的 Component就可以很好的支援設計器,開發者自定義Perfab的時候隻是組合元素之間的父子關系,并修改這些标準的UI元件的屬性,設計器可以應 付任何可視化設計。
  • 因為System成為了GameObject的集合,而Component也是GameObject的集合,它們之間的差別就僅僅是一個處理邏 輯,一個存儲資料,是以Unity就将這兩者簡化成一個東西:Component。這給引擎設計帶來一緻,簡便,然而卻給開發者留下一些模糊的概念,如果 學習Unity之初不熟悉ECS系統,則很難實作資料和邏輯的分離,資料和邏輯混在一個Component就帶來元件之間頻繁通信,因為一個元件需要通路 另一個元件中的資料。這個時候的表現往往是大量使用Message之類的,Unity支援向GameObject發送消息,這些消息會被 Component中對應的方法處理。
  • 由于将System集合化,這也使得尋找Component的任務落到GameObject上,System的update需要由 GameObject自身來驅動,是以整個ECS也必須由引擎支援,相應地GameObject就需要在全局範圍内根據name任意查找,否則整個系統就 工作不了,因為你無法找到Entity,無法和其他Entity之間互動。
在cocos2d-x中實踐資料驅動的遊戲開發

圖6 Unity中的元件系統

  • Cocos2d-x引擎提供的元件支援

說Cocos2d-x引擎不支援元件系統其實是不準确的,在2.1.4版本中給CCNode加入了元件支援:

在cocos2d-x中實踐資料驅動的遊戲開發

每個CCNode新包含一個m_pComponentContainer的容器,它存儲每個CCNode所擁有的所有Component集合,然後 在CCNode的update循環中調用每個Component的update方法。這和Unity的設計思路是類似的,然而因為cocos2d-x缺乏 全局範圍内檢索遊戲對象,使得引擎提供的元件系統使用起來有點困難。

通過前面的分析,我們知道标準的ECS不需要了解遊戲元素的UI結構,Entity之間的互動全是通過Component資料來确定的,例如判斷兩 個Entity是否發生碰撞,首先ColliderSystem會從EntityManager檢查所有包含ColliderComponent的 Entity,然後判斷和修改transform相關的屬性,它不需要和UI樹打交道。

單純一個Component融合了資料和行為,這不是資料驅動的方式。是以也就不存在EntityManager,是以Unity提供全局查找 GameObject的方法,而Cocos2d-x目前是不能全局查找CCNode,是以我們就隻能通過parent去搜尋UI樹,這會在 Component中引入大量getParent,getChild之類的跟UI樹關系緊密的代碼,而一旦UI結構發生變化,則會影響這部分代碼。同時數 據和行為混在一起對元件間消息傳遞的需求比較大,目前cocos2d-x中的元件也沒有提供這樣的機制。

是以cocos2d-x目前的元件系統适合做一些簡單的算法方面的使用,不适宜用來建構整個系統,或者你也可以按ECS的方式,将資料抽取出來,建 立自己的EntityManager,同樣可以做到資料驅動,讀者可以自己去嘗試。然而cocos2d正在朝着元件做一些努力,我們期待cocos2d- x 3.0能有高效簡便的元件系統支援。

  • Entity Component System總結

至此,我們應該對ECS有一定的認識了,它是一種軟體架構風格,與面向對象中繼承的方式相反,ECS通過組合的方式重用代碼,它能實作行為和資料的 完全分離,進而降低耦合,并通過定義一個System需要滿足的條件(Component構成的資料組合)來達到資料驅動。并且它幾乎是可以和任何繪制引 擎一起工作的。

ECS有很多優點,比如資料驅動,遊戲設計師在不太多依賴程式員的情況下就可以通過資料變更遊戲行為(當然需要将Component的組合轉化為讀取外部配置檔案),最重要的是,它可以非常靈活地滿足頻繁變更的需求。

當然ECS也有一些缺點,社群讨論最多的是每一幀頻繁查詢大量Entity,如果這裡用到了一些RTTI的方法可能會比較明顯的影響性能,而且 System的每次處理都需要判斷太多的變量,因為System本身并不儲存任何資料,它的算法的資料完全需要Component來提供,并且為了減少 System之間的耦合,會大量增加一些變量在Component中。

社群也讨論了很多優化方案,感興趣的讀者可以繼續閱讀相關資訊。總之,Entity Component System作為一種優秀的架構思想,它能為你帶來愉悅的開發體驗,減少你大量的痛苦。