進化遊戲的階層 - 用組件來重構你的遊戲實體
直到最近幾年,遊戲程式員一直使用深階層的類表示遊戲實體。現在的潮流開始逐漸從深層次的結構,到僅僅是把遊戲實體物件作為彙總組件的方向轉變。這篇文章解釋了這些轉變意味著什麼,並且探討了用這種方式帶來的的好處和實際情況中的使用。我將會描述我個人的一些經驗,怎樣在大項目中實現這個系統,當然也包括怎樣去賣你的方案給別的程式員和經理。
遊戲實體
不同的遊戲有不同的需求,就像遊戲實體應該需要什麼一樣。但是在大多數的遊戲裡,實體的概念是十分的相似的。一個遊戲實體就是一個在遊戲世界裡的對象,通常這個對象對於玩家來說是可見的,並且通常它還能四處移動。
一些實體的例子:
l 子彈
l 小轎車
l 坦克
l 手榴彈
l 槍
l 英雄
l 行人
l 外星人
l 噴氣式飛行器
l 醫學包
l 石頭
實體通常可以做很多事情。下面是一些事情你也許想要實體去做的:
l 運行一個指令碼
l 移動
l 表現的像個死板的東西
l 發射粒子
l 播放特定的聲音
l 能被玩家放在背包裡
l 能被玩家穿上
l 爆炸
l 表現的有磁性的
l 被玩家瞄準
l 沿著一條路徑走
l 動畫
傳統的深階層
傳統的表示一組實體集的方式就像是在分解我們想要去表的實體集。這樣做通常開始的意圖是好的,但是隨著遊戲的開發進度這些東西經常要變動—尤其是當一個遊戲引擎被不同的遊戲重新使用時。我們通常最後的設計出如 圖B-1那樣,但是實際上的類階層比圖中節點還要多。
圖B-1
隨著開發的進行,我們通常需要增加很多不同的功能到實體上。對象必須要麼封裝自己封裝功能,要麼從有那個功能的別的對象那裡繼承過來。經常性的功能被載入接近類階層的根節點上,比如說CEntity類。這樣做有一個好處,就是所有衍生類別都能有那些功能。但是不好的地方是會被這些類帶來相關的開銷。
即使是非常簡單的對象比如石頭或者是手榴彈,到最後會有大量的額外功能(和相關的成員變數,或者是不必要執行的成員函數)。傳統的遊戲對象階層經常到最後要建立一個被稱作”團跡”(胖球)(the blob)的東西。胖球是經典的反模式之一,表現為一個巨大的單類(或者是有大量的分支在類的階層上),擁有大量的複雜的互相交織的功能。
當胖球反模式經常在對象階層的根節點附近出現,它也就顯現在葉子節點上了(譯註:因為葉子節點是繼承自根的)。最有可能的候選者因該是表示玩家的類。由於遊戲通常是針對單一角色而編寫的程式,因此表示角色的對象經常有大量的功能。這經常是實現為在一個類裡比如CPlayer類,有大量的成員函數。
實現這麼多功能在階層的根節點附近的結果就是給葉對象大量不需要功能的過重包袱。不管怎麼樣,用相反的實現方法,在葉子節點上實現大量的功能,同樣是不幸的結果。功能現在被分解了,所以只有專門為那個對象編程的特定功能才能使用它。程式員經常複製一樣的代碼到已經被不同的對象實現的鏡子函數裡。最終,需要重新組織類的階層這種骯髒的重構來移動和組合功能。
先來一個例子吧,有一個對象在在物理作用下表現為剛體的功能。不是所有的對象需要做到這樣。你可以在圖B-1裡看到的那樣,我們僅僅讓Crock和CGrenade類從CRigid類派生。如果我們想要將此功能應用到車子上會發生什麼呢?你不的不把CRigid類移到階層的上面去,讓它變得更像我們以前看到的根部的重型胖子模式,所有的的功能都被串成一條類的窄鏈子從其他最先開始繼承的類開始起。
彙總組件
組件方式,現在越來越得到現在的遊戲開發的認可,是一種把不同的功能分開放到不同的獨立於其他組件的組件上的方法。傳統的對象階層被免除了,並且一個對象現在被建立為為一個獨立的組件的彙總(積聚物)。
每個對象現在只有它需要的功能了。任何不同的新共嫩被實現為增加一個組件。
一個由彙總組件組成的對象系統能有3種方式實現,可以被看成將胖球對象階層轉移到一個組合對象上去的不同階段。下面將介紹一下這3個階段。
對象作為組織胖球
一種通常重構胖球對象的方法是將它的功能分散到不同的子物件上去,然後被第一個對象所引用。最終,父系的胖球對象被一系列的指向其他對象的指標代替,最終胖球對象的成員函數編程了這些子物件上函數的介面函數。
這也許事實上是一個合理的解決方案,如果你的遊戲對象裡的功能在一個合適小的範圍內,或者如果時間是有限的。你可以簡單實現任意的對象聚集,通過允許一些子物件為空白(通給一個NULL指標給它們)。假設沒有太多的子物件,那麼這仍然允許你有一個輕型的沒有實現一個管理此對象的組合架構的偽組合對象的優勢。
不足之處是,這仍然在本質上是一個胖球。所有的功能人然被封裝在一個大對象裡。這不像是你完全的分解胖球對象到純的子物件那樣,所以你仍然遺留了一些重要的開銷,仍然會讓你輕型的對象變重。你仍然有不斷檢查所有null 指標,以便看看是否需要更新的開銷。
對象作為組件容器
下一個階段是分解每個組件(上一節例子裡的“子物件”)成共用一個公用的基類的對象,因此我們可以儲存一個對象的列表在對象裡。
這是一個過度的解決方案,我們仍然有表示遊戲實體的根“對象”。不管怎樣,它應該是一個合理的解決方案,或者確實是在實踐中是可行的方案,如果一大部分的程式碼程式庫中需要這種概念的遊戲對象作為具體對象的話。
你的遊戲對象然後變成了一個介面對象,充當了在你遊戲裡的遺留代碼之間橋的作用,並且還是新系統的組合對象。如果時間允許,你最終將會把遊戲實體物件作為整體式對象的概念消除掉。相反,訪問對象越來越直接的通過它所在的組件了。最終,你能夠將其轉換到純彙總了。
對象作為純彙總
在最終的布置圖裡,一個對象簡單是各個部分的和。圖B-2顯示了一個方案,每個對象都是由許多不同的組件組成的。這裡沒有所謂的“遊戲實體物件”。每一列在表徵圖中都表示同一組件,每一行因此都能表示一個對象。組件自己也可以看成是和組成它們的對象是獨立的。
圖B-2
實踐經驗
我第一個用組件實現的對象的組合系統是我在Neversoft公司做Tony Hawk系列遊戲的時候做的。我們的遊戲對象系統一直伴隨著三個連續發布的遊戲而發展,指導我們有了一個遊戲對象的階層來重組我先前提到的胖球反模式。它遭受著所有同樣的問題:對象傾向於重量級的。對象有不必要的資料和功能。有時不必要的功能讓遊戲變慢。功能有時在不同樹的分支上重複。
我在sweng-gamedev的郵件清單裡聽說過這個關於“基於對象的組件”系統的新式發明。我覺得那聽起來是一個好主意。我於是開始重新組織代碼,兩年以後把它完成了。
為什這麼長的時間?因為,首先我們在以每年一個的速度艱苦的做出Tony Hawk遊戲,所以只有很少的時間讓我們投入到重構上。第二,我錯誤的計算了問題的規模。一個三年時間長的代碼群已經包含大量的代碼。大量的代碼一年一年的逐漸層成了某種不靈活的代碼。由於代碼依賴於遊戲對象成為遊戲對象,尤其是的某些遊戲對象。那說明了有大量的工作要去做,才能使得所有的東西都已組件方式工作。
預期的阻力
我第一遇到的問題就是怎樣試著解釋這個系統給其他的程式員。如果你不是特別的熟悉對象組合和彙總的事情,那麼會被認為是無意的,不必要的複雜,不必要的多餘工作,讓你受備受打擊。程式員已經在傳統系統的對象階層上工作了很多年,已經非常習慣那種工作方式了。它們甚至變得擅長於那種方式來,能解決那些出現的問題了。
把這個方案賣給經理也是一個困難。你需要能夠用平實的語言準確的描述,這個方案怎樣能夠讓遊戲完成的更快。下面是一段的因該說的話:
“當我們加入新的特性到遊戲裡時,那會花費很長的時間去完成,將會導致很BUGS。如果我們採用這種新的組件對象的東西,它能讓我們加入新的特性更快,會有更少的BUGS”
而我採用是一種悄悄的方式。我首先和一些程式員單獨討論這個主意,最後說服他們這是一個好主意。我然後實現了萬用群組件的基本架構,並且還實現了遊戲對象功能的很小的一部分作為組件。
我然後把這些成果呈現給剩下的程式員。他們有一些疑惑和抵觸,但是由於它已經實現了並且它在那裡工作不是一個大的爭議。
緩慢的進展
當架構被連結上了,從靜態階層到對象組合的方便性顯現的很緩慢。那是一個吃力不討好的工作,即使你花了很多小時,很多天將代碼重構成一些看起來像樣的東西,但其和被替換的代碼沒有什麼兩樣。而且,我們還在做這個事情的時候,我們仍然在為下一個遊戲實現新的功能。
在早些時候,我們撞上了重構我們最大的類—滑雪者類的問題。由於它包含有大量的功能,它甚至在一段時間幾乎無法重構一小點。事實上,它也無法被重構除非其他在遊戲裡的對象系統已經服從了組件方式了。話又說回來,其他那些對象系統也不容易被組件化,除非滑雪者已經是一個組件了。
這裡的解決方案是建立一個“胖球組件”。這是一個單獨的巨型組件,封裝了大量滑雪者類的功能。少量的其他胖球組件也需要被用在別的地方。我們最終硬是將對象系統塞進了組件裡。當這個事情到位了,胖球組件能被逐級的重構成更多的原子組件。
結果
一開始重構的結果不是那麼明顯。但是隨著時間的推移,代碼變得越來越清晰並且變得更容易維護,功能都被封裝到分散的組件裡了。程式員開始用更少的時間建立新類型的對象,僅僅簡單的組合一些組件然後再加一個新的。
我們建立了一個資料驅動的對象建立系統,因此整個新類型的對象都能被設計人員創造。這被證明對於快速建立和配置新類型的對象是非常有價值的。
最終程式員開始(以不同的速度)接受組件化系統了。並且他們變得非常熟練的擅長通過組件來增加新的功能了。通用的介面和嚴格的封裝使得BUGS減少了,代碼也更容易閱讀,更容易維護和重用。
實現細節
給每一個組件一個通用的介面意味著繼承自同一個帶虛函數的基類。這會帶來額外的開銷。但不要因為這一點而使你反對這種方法,節約的開銷和對象的簡單性相比是不不重要的。
由於每個組件都有一個公用的介面,非常容易的就可以增加額外的調試成員函數給每個組件。這使得增加一個能匯出組件的組合對象的可讀資訊的診斷器對象更容易了。然後,這可以被進化成一個複雜功能的遠端偵錯工具,總能夠得到幾乎所有類型的遊戲對象的最新資訊。這也許在傳統的階層的系統裡去實現和維護是十分的令人厭惡的。
理想情況下,組件應該互相不知道到對方。不管怎麼樣,在現實世界裡,總是有特定組件間的依賴關係。效能問題,也決定了組件應該能夠快速的訪問其他組件。開始的時候,我們讓所有組件的引用都是通過組件管理器的,但是當開始時只用了5%的CPU時間,我們允許組件存貯指向其他對象的指標,並且直接調用在其他組件裡的成員函數。
在組件裡,怎樣組合對象的順序是非常重要的。在我們一開始的系統裡,我們把組件作為鏈表格儲存體在一個容器物件裡。每個組件有一個更新函數,每個對象每次迭代組件列表時被調用。
由於對象建立是資料驅動的,那樣會造成麻煩的,如果在鏈表裡的組件不是期望的順序的話。如果一個對象更新物理相關內容在動畫相關內容之前,但是另外一個對象更新動畫相關內容在物理相關內容之前,這樣他們就會互相失去同步。互相依賴關係像這樣的必須找出來,然後在代碼裡定義強制規則。
結尾
用組件把從胖球風格的對象階層轉變成組合對象結構是我所做的最好的決定之一。開始的結果是讓人失望的,它花費了太多時間去重構現有的代碼。不管怎麼樣,最後的結果是非常值得,輕型的,靈活的,健壯,和可重用的代碼。