遊戲程序常規設計模式

 

 

 

 

 

 

遊戲程序常規設計模式javascript

 

 

 

https://gpp.tkchu.me/spatial-partition.htmlhtml

 

 

                   

 

 

 

 

 

 

 

 

 

 

 

O一 十二 月於上海浦東新區前端

 

第一章 序html5

 

遊戲設計模式java

在五年級時,我和個人朋友被准許使用一間存放有幾臺很是破舊的TRS-80s的房間。 爲了鼓舞咱們,一位老師給咱們找了一些簡單的BASIC程序打印文檔。git

電腦的磁帶驅動器已經壞掉了,因此每當咱們想要運行代碼,就得當心地從頭開始輸入它們。 所以,咱們更喜歡那些只有幾行長的程序:程序員

10 PRINT "BOBBY IS RADICAL!!!"angularjs

20 GOTO 10github

若是電腦打印的次數足夠多,也許這句話就會魔法成真。golang

哪怕這樣,過程也充滿了困難。咱們不知道如何編程,因此小小的語法錯誤對咱們來講也是天險。 若是程序沒有工做,咱們就得從頭再來一遍——這常常發生。

文檔的最後幾頁是個真正的怪物:一個佔據了幾頁篇幅的程序。 咱們得花些時間才能鼓起勇氣去試一試,但它實在太誘人——它的標題是地道與巨魔 咱們不知道它能作什麼,但聽起來像是個遊戲,還有什麼比本身編個電腦遊戲更酷的嗎?

咱們歷來沒能讓它運行起來,一年之後,咱們離開了那間教室。 (好久之後,當我真的學會了點BASIC,我意識到那只是個桌面遊戲角色生成器,而不是遊戲。) 可是命運的車輪已經開始轉動——自那時起,我就想要成爲一名遊戲程序員。

青少年時,我家有了一臺能運行QuickBASICMacintosh,以後THINK C也能在其上運行。 幾乎整個暑假我都在用它編遊戲。 自學緩慢而痛苦。 我能輕鬆地編寫並運行某些部分——地圖或者小謎題——但隨着程序代碼量的增加,這愈來愈難。

暑假中的很多時間我都花在在路易斯安那州南部的沼澤裏逮蛇和烏龜上了。 若是外面不是那麼酷熱,頗有可能這就會是一本講爬蟲而不是編程的書了。

起初,挑戰之處僅僅在於讓程序成功運行。而後,是搞明白怎樣寫出內容超出我大腦容量的代碼。 我再也不只閱讀關於如何用C++編程的書籍,而開始嘗試找那些講如何組織程序的書。

幾年事後,一位朋友給我一本書:《設計模式:可複用面向對象軟件的基礎》。 終於!這正是我從青年時期就在尋找的書。 我一口氣從頭讀到尾。雖然我仍然掙扎於本身的程序中,但看到別人也在掙扎並提出瞭解決方案是一種解脫。 我意識到手無寸鐵的我終於有件像樣的工具了。

那是我首次見到這位朋友,相互介紹五分鐘後,我坐在他的沙發上,在接下來的幾個小時中無視他並全神貫注地閱讀。 我想自那之後個人社交技能仍是有所提升的。

2001年,我得到了夢想中的工做:EA的軟件工程師。 我等不及要看看真正的遊戲,還有專業人士是如何組織一切的。 像實況足球這樣的大型遊戲使用了什麼樣的架構?不一樣的系統是如何交互的?一套代碼庫是如何在多個平臺上運行的?

分析理解源代碼是種震顫的體驗。圖形,AI,動畫,視覺效果皆有傑出代碼。 有專家知道如何榨乾CPU的最後一個循環並好好使用。 那些我都不知道是否可行的事情,這些人在午餐前就能完成。

可是這些傑出代碼依賴的架構一般是過後設計。 他們太注重功能而忽視了架構。耦合充斥在模塊間。 新功能被塞到任何能塞進去的地方。 在夢想幻滅的我看來,這和其餘程序員沒什麼不一樣, 若是他們閱讀過《設計模式》,最多也就用用單例

固然,沒那麼糟。我曾幻想遊戲程序員坐在白板包圍的象牙塔裏,爲架構冷靜地討論上幾周。 而實際狀況是,我看到的代碼是努力應對緊張截止期限的人趕工完成的。 他們已經不遺餘力,並且就像我慢慢意識到的那樣,他們盡心盡力的結果一般很好。 我花在遊戲代碼上的時間越多,我越能發現藏在表面下的天才之處。

不幸的是,是廣泛現象。 寶石埋在代碼中,但人們從未意識到它們的存在。 我看到同事重複尋找解決方案,而須要的示例代碼就埋在他們所用的代碼庫中。

這個問題正是這本書要解決的。 我挖出了遊戲代碼庫中能找到的設計模式,打磨而後在這裏展現它們,這樣能夠節約時間用在發明新事物上,而非從新發明它們。

書店裏已有的書籍

書店裏已經有不少遊戲編程書籍了。爲何要再寫一本呢?

我看到的不少編程書籍能夠歸爲這兩類:

  • 特定領域的書籍。 這些關於細分領域的書籍帶你深刻理解遊戲開發的某一特定層面。 它們會教授你3D圖形,實時渲染,物理模擬,人工智能,或者音頻播放。 那些不少程序員窮其一輩子研究的細分領域。
  • 完整引擎的書籍。 另外一個方向,還有書籍試圖包含遊戲引擎的各個部分。 它們傾向於構建特定種類遊戲的完整引擎,一般是3D FPS遊戲。

這兩種書我都喜歡,但我認爲它們並未覆蓋所有空間。 特定領域的書籍不多告訴你這些代碼如何與遊戲的其餘部分打交道。 你擅長物理或者渲染,可是你知道怎麼將二者優雅地組合嗎?

第二類書包含這些,可是我發現完整引擎的書籍一般過於總體,過於專一某類遊戲了。 特別是,隨着手遊和休閒遊戲的興起,咱們正處於衆多遊戲類型欣欣向榮的時刻。 咱們再也不只是複製Quake了。若是你的遊戲與該類遊戲不一樣,那些介紹單一引擎的書就不那麼有用了。

相反,我在這裏作的更à la carte  每一章都是獨立的、可應用到代碼上的思路。 這樣,你能夠用認爲最好的方式組合這些思路,用到你的遊戲上去。

另外一個普遍使用這種à la carte風格的例子是Game Programming Gems系列。

和設計模式的關聯

任何名字中有模式的編程書 都與Erich GammaRichard HelmRalph Johnson,和John Vlissides(一般被稱爲GoF)合著的經典書籍: 《設計模式:可複用面向對象軟件要素》相關。

《設計模式》也受到以前的書籍的啓發。 建立一種模式語言來描述問題的開放式解法, 這思路來自 A Pattern Language, 做者是Christopher Alexander (還有Sarah Ishikawa和Murray Silverstein).

他們的書是關於架構的(建築和牆那樣的真正的框架結構), 但他們但願其餘人能使用相同的方法描述其餘領域的解決方案。 《設計模式》正是是GoF用這一方法在軟件業作出的努力。

稱這本書爲遊戲編程模式,我不是暗示GoF的模式不適用於遊戲編程。 相反:本書的重返設計模式一節包含了《設計模式》中的不少模式, 但強調了這些模式在遊戲編程中的特定使用。

一樣地,我認爲本書也適用於非遊戲軟件。 我能夠依樣畫瓢稱本書爲《更多設計模式》,可是我認爲舉遊戲編程爲例子更爲契合。 你真的想要另外一本介紹員工記錄和銀行帳戶的書嗎?

也就是說,雖然這裏介紹的模式在其餘軟件上也頗有用,但它們更合適於處理遊戲中常見的工程挑戰:

  • 時間和順序一般是遊戲架構的核心部分。事物必須在正確的時間按正確的順序發生。
  • 高度壓縮的開發週期,大量程序員須要能快速構建和迭代一系列不一樣的行爲,同時保證不煩擾他人,也不污染代碼庫。
  • 在定義全部的行爲後,遊戲開始互動。怪物攻擊英雄,藥物相互混合,炸彈炸飛敵人或者友軍。 實現這些互動不能把代碼庫搞成一團亂麻。
  • 最後,遊戲中性能很重要。 遊戲開發者處於一場榨乾平臺性能的競賽中。 節約CPU循環的技巧區分了A級百萬銷量遊戲和掉幀差評遊戲。

如何閱讀這本書

《遊戲設計模式》分爲三大塊。 第一部分介紹並劃分本書的框架。包含你如今閱讀的這章和下一章

第二部分,重訪設計模式,複習了GoF書籍裏的不少模式。 在每一章中,我給出我對這個模式的見解,以及我認爲它和遊戲編程有什麼關係。

最後一部分是這本書最肥美的部分。 它展現了十三種我發現有用的模式。它們被分爲四類: 序列模式行爲模式解耦模式,優化模式

每種模式都使用固定的格式表述,這樣你能夠將這本書當成引用,快速找到你須要的:

  • 意圖 部分提供這個模式想要解決什麼問題的簡短介紹。 將它放在首位,這樣你能夠快速翻閱,找到你如今須要的模式。
  • 動機 部分描述了模式處理的問題示例。 不一樣於具體的算法,模式一般不針對某個特定問題。 不用示例教授模式,就像不用麪糰教授烘烤。動機部分提供了麪糰,而下部分會教你烘烤。
  • 模式 部分將模式從示例中剝離出來。 若是你想要一段對模式的教科書式簡短介紹,那就是這部分了。 若是你已經熟悉了這種模式,想要確保你沒有拉下什麼,這部分也是很好的提示。
  • 到目前爲止,模式只是用一兩個示例解釋。可是如何知道模式對你的問題有沒有用呢?什麼時候使用 部分提供了這個模式在什麼時候使用什麼時候不用的指導。 記住 部分指出了使用模式的結果和風險。
  • 若是你像我同樣須要具體的例子來真正地理解某物,那麼示例代碼部分能讓你趁心如意。 它描述模式的一步步具體實現,來展示模式是如何工做的。
  • 模式與算法不一樣的是它們是開放的。 每次你使用模式,能夠用不一樣的方式實現。 下一部分設計決策,討論這些方式,告訴你應用模式時可供考慮的不一樣選項。
  • 做爲結尾,這裏有參見部分展現了這一模式與其餘模式的關聯,以及那些使用它的真實代碼。

關於示例代碼

這本書的示例代碼使用C++寫就,但這並不意味着這些模式只在C++中有用,或C++比其餘語言更適合使用這些模式。 這些模式適用於幾乎每種編程語言,雖然有的模式假設編程語言有對象和類。

我選擇C++有幾個緣由。首先,這是在遊戲製做中最流行的語言,是業界的通用語 一般,C++基於的C語法也是JavaC#JavaScript和其餘不少語言的基礎。 哪怕你不懂C++,你也只需一點點努力就能理解這裏的示例代碼。

這本書的目標不是教會你C++ 示例代碼儘量地簡單,不必定符合好的C++風格或規範。 示例代碼展現的是意圖,而不是代碼。

特別地,代碼沒用現代的」——C++11或者更新的——標準。 沒有使用標準庫,不多使用模板。 它們是糟糕」C++代碼,但我但願保持這樣,這樣那些使用CObjective-CJava和其餘語言的人更容易理解它們。

爲了不花費時間在你已經看過或者是與模式無關的代碼上,示例中省略了部分代碼。 若是是那樣,示例代碼中的省略號代表這裏隱藏了一些代碼。

假設有個函數,作了些工做而後返回值。 而用它做示例的模式只關心返回的值,而不是完成了什麼工做。那樣的話,示例代碼長得像這樣:

bool update()

{

  // 作點工做……

  return isDone();

}

接下來呢

設計模式在軟件開發過程當中不斷地改變和擴展。 這本書繼續了GoF記錄分享設計模式的旅程,而這旅程也不會終於本書。

你是這段旅程的關鍵部分。改良(或者否決)了這本書中的模式,你就是爲軟件開發社區作貢獻。 若是你有任何建議,更正,或者任何反饋,保持聯絡!

1.1架構,性能和遊戲

遊戲設計模式Introduction

在一頭扎進一堆設計模式以前,我想先講一些我對軟件架構及如何將其應用到遊戲之中的理解, 這也許能幫你更好地理解這本書的其他部分。 至少,在你被捲入一場關於設計模式和軟件架構有多麼糟糕(或多麼優秀)的辯論時, 這能夠給你一些火力支援。

注意我沒有建議你在戰鬥中選哪一邊。就像任何軍火販子同樣,我願意向做戰雙方出售武器。

什麼是軟件架構?

若是把本書從頭至尾讀一遍, 你不會學會3D圖形背後的線性代數或者遊戲物理背後的微積分。 本書不會告訴你如何用α-β修剪你的AI樹,也不會告訴你如何在音頻播放中模擬房間中的混響。

Wow,這段給這本書打了個糟糕的廣告啊。

相反,這本書告訴你在這些之間的代碼的事情。 與其說這本書是關於如何寫代碼,不如說是關於如何架構代碼的。 每一個程序都有必定架構,哪怕這架構是將全部東西都塞到main()中看看如何 因此我認爲講講什麼形成了架構是頗有意思的。咱們如何區分好架構和壞架構呢?

我思考這個問題五年了。固然,像你同樣,我有對好的設計有一種直覺。 咱們都被糟糕的代碼折磨得不輕,你惟一能作的好事就是刪掉它們,結束它們的痛苦。

不得不認可,咱們中大多數人都該對一些糟糕代碼負責

少數幸運兒有相反的經驗,有機會在好好設計的代碼庫上工做。 那種代碼庫看上去是間豪華酒店,裏面的門房隨時準備知足你心血來潮的需求。 這二者之間的區別是什麼呢?

什麼是好的軟件架構?

對我而言,好的設計意味着當我做出改動,整個程序就好像正等着這種改動。 我能夠僅調用幾個函數就完成任務,而代碼庫自己無需改動。

這聽起來很棒,但實際上不可行。把代碼寫成改動不會影響其表面上的和諧。就好。

讓咱們通俗些。第一個關鍵點是架構是關於改動的 總會有人改動代碼。若是沒人碰代碼,那麼它的架構設計就可有可無——不管是由於代碼至善至美,仍是由於代碼糟糕透頂以致於沒人會爲了修改它而玷污本身的文本編輯器。 評價架構設計的好壞就是評價它應對改動有多麼輕鬆。 沒有了改動,架構好似永遠不會離開起跑線的運動員。

你如何處理改動?

在你改動代碼去添加新特性,去修復漏洞,或者隨便用文本編輯器乾點什麼的時候, 你須要理解代碼正在作什麼。固然,你不須要理解整個程序, 但你須要將全部相關的東西裝進你的大腦。

有點詭異,這字面上是一個OCR過程。

咱們一般無視了這步,但這每每是編程中最耗時的部分。 若是你認爲將數據從磁盤上分頁到RAM上很慢, 那麼經過一對神經纖維將數據分頁到大腦中無疑更慢。

一旦把全部正確的上下文都記到了你的大腦裏, 想一會,你就能找到解決方案。 可能有時也須要反覆斟酌,但一般比較簡單。 一旦理解了問題和須要改動的代碼,實際的編碼工做有時是微不足道的。

用手指在鍵盤上敲打一陣,直到屏幕上閃着正確的光芒, 搞定了,對吧?還沒呢! 在你爲之寫測試併發送到代碼評審以前,一般有些清理工做要作。

我是否是說了「測試」?噢,是的。爲有些遊戲代碼寫單元測試很難,但代碼庫的大部分是徹底能夠測試的。

我不會在這裏發表演說,可是我建議你,若是尚未作自動測試,請考慮一下。 除了手動驗證之外你就沒更重要的事要作了嗎?

你將一些代碼加入了遊戲,但確定不想下一我的被留下來的小問題絆倒。 除非改動很小,不然就還須要一些微調新代碼的工做,使之無縫對接到程序的其餘部分。 若是你作對了,那麼下個編寫代碼的人沒法察覺到哪些代碼是新加入的。

簡而言之,編程的流程圖看起來是這樣的:

使人震驚的死循環,我看到了。

解耦幫了什麼忙?

雖然並不明顯,但我認爲不少軟件架構都是關於研究代碼的階段。 將代碼載入到神經元太過緩慢,找些策略減小載入的總量是件很值得作的事。 這本書有整整一章是關於解耦模式 還有不少設計模式是關於一樣的主題。

能夠用多種方式定義解耦,但我認爲若是有兩塊代碼是耦合的, 那就意味着沒法只理解其中一個。 若是耦了它們倆,就能夠單獨地理解某一塊。 這固然很好,由於只有一塊與問題相關, 只需將這一塊加載到你的大腦中而不須要加載另一塊。

對我來講,這是軟件架構的關鍵目標: 最小化在編寫代碼前須要瞭解的信息

固然,也能夠從後期階段來看。 解耦的另外一種定義是:當一塊代碼有改動時,不須要修改另外一塊代碼。 確定也得修改一些東西,但耦合程度越小,改動會波及的範圍就越小。

代價呢?

聽起來很棒,對吧?解耦任何東西,而後就能夠像風同樣編碼。 每一個改動都只需修改一兩個特定方法,你能夠在代碼庫上行雲流水地編寫代碼。

這就是抽象、模塊化、設計模式和軟件架構令人們激動不已的緣由。 在架構優良的程序上工做是極佳的體驗,每一個人都但願能更有效率地工做。 好架構能形成生產力上巨大的不一樣。它的影響大得無以復加。

可是,天下沒有免費的午飯。好的設計須要汗水和紀律。 每次作出改動或是實現特性,你都須要將它優雅的集成到程序的其餘部分。 須要花費大量的努力去管理代碼, 使得程序在開發過程當中面對千百次變化仍能保持它的結構。

第二部分——管理代碼——須要特別關注。 我看到無數程序有優雅的開始,而後死於程序員一遍又一遍添加的「微小黑魔法」。

就像園藝,僅僅種植是不夠的,還須要除草和修剪。

你得考慮程序的哪部分須要解耦,而後再引入抽象。 一樣,你須要決定哪部分能支持擴展來應對將來的改動。

人們對這點變得狂熱。 他們設想,將來的開發者(或者他們本身)進入代碼庫, 發現它極爲開放,功能強大,只需擴展。 他們想要有至尊代碼應衆求。(譯著:這裏是至尊魔戒御衆戒的梗,很遺憾翻譯不出來)

可是,事情從這裏開始變得棘手。 每當你添加了抽象或者擴展支持,你就是在之後這裏須要靈活性。 你向遊戲中添加的代碼和複雜性是須要時間來開發、調試和維護的。

若是你賭對了,後來使用了這些代碼,那麼功夫不負有心人。 但預測將來很難,模塊化若是最終無益,那就有害。 畢竟,你得處理更多的代碼。

有些人喜歡使用術語「YAGNI」——You aren’t gonna need it(你不須要那個)——來對抗這種預測未來需求的強烈衝動。

當你過度關注這點時,代碼庫就失控了。 接口和抽象無處不在。插件系統,抽象基類,虛方法,還有各類各樣的擴展點,它們遍地都是。

你要消耗無盡的時間回溯全部的腳手架,去找真正作事的代碼。 當須要做出改動時,固然,有可能某個接口能幫上忙,但能不能找到就只能聽天由命了。 理論上,解耦意味着在修改代碼以前須要瞭解更少的代碼, 但抽象層自己也會填滿大腦。

像這樣的代碼庫會使得人們反對軟件架構,特別是設計模式。 人們很容易沉浸在代碼中,忽略了目標是要發佈遊戲 對可擴展性的過度強調使得無數的開發者花費多年時間製做引擎 卻沒有搞清楚作引擎是爲了什麼

性能和速度

軟件架構和抽象有時因損傷性能而被批評,而遊戲開發尤甚。 讓代碼更靈活的許多模式依靠虛擬調度、 接口、 指針、 消息和其餘機制, 它們都會加大運行時開銷。

一個有趣的反面例子是C++中的模板。模板編程有時能夠帶來沒有運行時開銷的抽象接口。

這是靈活性的兩極。 當寫代碼調用類中的具體方法時,你就是在的時候指定類——硬編碼了調用的是哪一個類。 當使用虛方法或接口時,直到運行時才知道調用的類。這更加靈活但增長了運行時開銷。

模板編程是在兩極之間。在編譯時初始化模板,決定調用哪些類。

還有一個緣由。不少軟件架構的目的是使程序更加靈活,做出改動須要更少的付出,編碼時對程序有更少的假設。 使用接口能夠讓代碼可與任何實現了接口的類交互,而不只僅是如今寫的類。 今天,你能夠使用觀察者消息讓遊戲的兩部分相互交流, 之後能夠很容易地擴展爲三個或四個部分相互交流。

但性能與假設相關。實現優化須要基於肯定的限制。 敵人永遠不會超過256個?好,能夠將敵人ID編碼爲一個字節。 只在這種類型上調用方法嗎?好,能夠作靜態調度或內聯。 全部實體都是同一類?太好了,能夠使用 連續數組存儲它們。

但這並不意味着靈活性很差!它能夠讓咱們快速改進遊戲, 開發速度對創造更好的遊戲體驗來講是很重要的。 沒有人能在紙面上構建一個平衡的遊戲,哪怕是Will Wright。這須要迭代和實驗。

嘗試想法並查看效果的速度越快,能嘗試的東西就越多,也就越可能找到有價值的東西。 就算找到正確的機制,你也須要足夠的時間調試。 一個微小的不平衡就有可能破壞整個遊戲的樂趣。

這裏沒有普適的答案。 要麼在損失一點點性能的前提下,讓你的程序更加靈活以便更快地作出原型; 要麼就優化性能,損失一些靈活性。

就我我的經驗而言,讓有趣的遊戲變得高效比讓高效的遊戲變有趣簡單得多。 一種折中的辦法是保持代碼靈活直到肯定設計,再去除抽象層來提升性能。

糟糕代碼的優點

下一觀點:不一樣的代碼風格各有千秋。 這本書的大部分是關於保持乾淨可控的代碼,因此我堅持應該用正確方式寫代碼,但糟糕的代碼也有必定的優點。

編寫架構良好的代碼須要仔細地思考,這會消耗時間。 在項目的整個週期中保持良好的架構須要花費大量的努力。 你須要像露營者處理營地同樣當心處理代碼庫:老是讓它比以前更好些。

當你要在項目上花費好久時間的時這是很好的。 但就像早先提到的,遊戲設計須要不少實驗和探索。 特別是在早期,寫一些你知道將會扔掉的代碼是很廣泛的事情。

若是隻想試試遊戲的某些點子是否可行, 良好的架構就意味着在屏幕上看到和獲取反饋以前要消耗很長時間。 若是最後證實這點子不對,那麼刪除代碼時,那些讓代碼更優雅的工夫就付諸東流了。

原型——一坨勉強拼湊在一塊兒,只能完成某個點子的簡單代碼——是個徹底合理的編程實踐。 雖然當你寫一次性代碼時,必須 保證未來能夠扔掉它。 我見過不少次糟糕的經理人在玩這種把戲:

老闆:嗨,我有些想試試的點子。只要原型,不須要作得很好。你能多快搞定?

開發者:額,若是刪掉這些部分,不測試,不寫文檔,容許不少的漏洞,那麼幾天能給你臨時的代碼文件。

老闆:太好了。

幾天後

老闆:嘿,原型很棒,你能花上幾個小時清理一下而後變爲成品嗎?

你得讓人們清楚,可拋棄的代碼即便看上去能工做,也不能被維護必須 重寫。 若是有可能要維護這段代碼,就得防護性地好好編寫它。

一個小技巧能保證原型代碼不會變成真正用的代碼:使用和遊戲實現不一樣的編程語言。 這樣,在將其實際應用於遊戲中以前必須重寫。

保持平衡

有些因素在相互角力:

1. 爲了在項目的整個生命週期保持其可讀性,須要好的架構。 2. 須要更好的運行時性能。 3. 須要讓如今想要的特性更快地實現。

有趣的是,這些都是速度:長期開發的速度,遊戲運行的速度,和短時間開發的速度。

這些目標至少是部分對立的。 好的架構長期來看提升了生產力, 也意味着每一個改動都須要消耗更多努力保持代碼整潔。

草就的代碼不多是運行時最快的。 相反,提高性能須要不少的開發時間。 一旦完成,它就會污染代碼庫:高度優化的代碼不靈活,很難改動。

總有今日事今日畢的壓力。可是若是儘量快地實現特性, 代碼庫就會充滿黑魔法,漏洞和混亂,阻礙將來的產出。

沒有簡單的答案,只有權衡。 從我收到的郵件看,這傷了不少人的心,特別是那些只是想作個遊戲的人。 這彷佛是在恐嚇,沒有正確的答案,只有不一樣的錯誤。

但對我而言,這讓人興奮!看看任何人們從事的領域, 你總能發現某些相互抵觸的限制。不管如何,若是有簡單的答案,每一個人都會那麼作。 一週就能掌握的領域是很無聊的。你歷來沒有據說過有人討論挖坑。

也許你會討論挖坑;我沒有深究這個類比。 可能有挖坑熱愛者,挖坑規範,以及一整套亞文化。 我算什麼人,能在此大放厥詞?

對我來講,這和遊戲有不少類似之處。 國際象棋之類的遊戲永遠不能被掌握,由於每一個棋子都很完美地與其餘棋子相平衡。 這意味你能夠花費一輩子探索廣闊的可選策略。糟糕的遊戲就像井字棋,玩上幾遍就會厭倦地退出。

簡單

最近,我感受若是有什麼能簡化這些限制,那就是簡單 在我如今的代碼中,我努力去寫最簡單,最直接的解決方案。 你讀過這種代碼後,徹底理解了它在作什麼,想不到其餘完成的方法。

個人目標是正確得到數據結構和算法(大體是這樣的前後),而後再從那裏開始。 我發現若是能讓事物變得簡單,最終的代碼就更少, 就意味着改動時有更少的代碼載入腦海。

它一般跑的很快,由於沒什麼開銷,也沒什麼代碼須要執行。 (雖然大部分時候事實並不是如此。你能夠在一小段代碼里加入大量的循環和遞歸。)

可是,注意我並無說簡單的代碼須要更少的時間編寫 你會這麼以爲是由於最終獲得了更少的代碼,可是好的解決方案不是往代碼中注水,而是蒸乾代碼。

Blaise Pascal有句著名的信件結尾,「我沒時間寫得更短。」

另外一句名言來自Antoine de Saint-Exupery:「臻於完美之時,不是加無可加,而是減無可減。」

言歸正傳,我發現每次重寫本書,它就變得更短。有些章節比剛完成時短了20%。

咱們不多遇到優雅表達的問題,通常反而是一堆用況。 你想要XZ狀況下作Y,在A狀況下作W,諸如此類。換言之,一長列不一樣行爲。

最節約心血的方法是爲每段用況編寫一段代碼。 看看新手程序員,他們常常這麼幹:爲每種狀況編寫條件邏輯。

但這一點也不優雅,那種風格的代碼遇到一點點沒想到的輸入就會崩潰。 當咱們想象優雅的代碼時,想的是通用的那一個: 只須要不多的邏輯就能夠覆蓋整個用況。

找到這樣的方法有點像模式識別或者解決謎題。 須要努力去識別散亂的用例下隱藏的規律。 完成時你會感受好得不能再好。

就快完了

幾乎每一個人都會跳過介紹章節,因此祝賀你看到這裏。 我沒有太多東西回報你的耐心,但還有些建議給你,但願對你有用:

  • 抽象和解耦讓擴展代碼更快更容易,但除非確信須要靈活性,不然不要在這上面浪費時間。
  • 在整個開發週期中爲性能考慮並作好設計,可是儘量推遲那些底層的,基於假設的優化,那會鎖死代碼。

相信我,發佈前兩個月不是開始思考「遊戲運行只有1FPS」這種問題的時候。

  • 快速地探索遊戲的設計空間,但不要跑得太快,在身後留下爛攤子。畢竟你總得回來打掃。
  • 若是打算拋棄這段代碼,就不要嘗試將其寫完美。搖滾明星將旅店房間弄得一團糟,由於他們知道明天就走人了。
  • 但最重要的是,若是你想要作出讓人享受的東西,那就享受作它的過程。

 

第二章 重訪設計模式

遊戲設計模式

《設計模式:可複用面向對象軟件的基礎》出版已經二十年了。 除非你比我從業還久,不然《設計模式》已經醞釀成一罈足以飲用的老酒了。 對於像軟件行業這樣快速發展的行業,它已是老古董了。 這本書的持久流行證實了設計方法比框架和方法論更經久不衰。

雖然我認爲設計模式仍然有意義,但在過去幾十年咱們學到了不少。 在這一部分,咱們會遇到GoF記載的一些模式。 對於每一個模式,我但願能講些有用有趣的東西。

我認爲有些模式被過分使用了(單例模式), 而另外一些被冷落了(命令模式)。 有些模式在這裏是由於我想探索其在遊戲上的特殊應用(享元模式觀察者模式)。 最後,我認爲看看有些模式在更廣的編程領域是如何運用的是頗有趣的(原型模式狀態模式)。

模式

2.1命令模式

遊戲設計模式Design Patterns Revisited

命令模式是我最喜歡的模式之一。 大多數我寫的遊戲或者別的什麼之類的大型程序,都會在某處用到它。 當在正確的地方使用時,它能夠將複雜的代碼清理乾淨。 對於這樣一個了不得的模式,不出所料地,GoF有個深奧的定義:

將一個請求封裝爲一個對象,從而使你可用不一樣的請求對客戶進行參數化; 對請求排隊或記錄請求日誌,以及支持可撤銷的操做。

我想你也會以爲這個句子晦澀難懂。 第一,它的比喻難以理解。 在詞語能夠指代任何事物的狂野軟件世界以外,客戶是一個——那些和你作生意的人。 據我查證,人類不能被參數化

而後,句子餘下的部分介紹了可能會使用這個模式的場景。 若是你的場景不在這個列表中,那麼這對你就沒什麼用處。 個人命令模式精簡定義爲:

命令是具現化的方法調用

「Reify(具現化)」來自於拉丁語「res」,意爲「thing」(事物),加上英語後綴「–fy」。 因此它意爲「thingify」,沒準用「thingify」更合適。

固然,精簡每每意味着着缺乏必要信息,因此這可能沒有太大的改善。 讓我擴展一下。若是你沒有據說過具現化的話,它的意思是實例化,對象化 具現化的另一種解釋方式是將某事物做爲第一公民對待。

在某些語言中的反射容許你在程序運行時命令式地和類型交互。 你能夠得到類的類型對象,能夠與其交互看看這個類型能作什麼。換言之,反射是具現化類型的系統

兩種術語都意味着將概念變成數據 ——一個對象——能夠存儲在變量中,傳給函數。 因此稱命令模式爲具現化方法調用,意思是方法調用被存儲在對象中。

這聽起來有些像回調第一公民函數函數指針閉包偏函數 取決於你在學哪一種語言,事實上大體上是同一個東西。GoF隨後說:

命令模式是一種回調的面向對象實現。

這是一種對命令模式更好的解釋。

但這些都既抽象又模糊。我喜歡用實際的東西做爲章節的開始,很差意思,搞砸了。 做爲彌補,從這裏開始都是命令模式能出色應用的例子。

配置輸入

在每一個遊戲中都有一塊代碼讀取用戶的輸入——按鈕按下,鍵盤敲擊,鼠標點擊,諸如此類。 這塊代碼會獲取用戶的輸入,而後將其變爲遊戲中有意義的行爲:

下面是一種簡單的實現:

void InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) jump();
  else if (isPressed(BUTTON_Y)) fireGun();
  else if (isPressed(BUTTON_A)) swapWeapon();
  else if (isPressed(BUTTON_B)) lurchIneffectively();
}

專家建議:不要太常常地按B。

這個函數一般在遊戲循環中每幀調用一次,我確信你能夠理解它作了什麼。 在咱們想將用戶的輸入和程序行爲硬編碼在一塊兒時,這段代碼能夠正常工做,可是許多遊戲容許玩家配置按鍵的功能。

爲了支持這點,須要將這些對jump()fireGun()的直接調用轉化爲能夠變換的東西。變換聽起來有點像變量乾的事,所以咱們須要表示遊戲行爲的對象。進入:命令模式。

咱們定義了一個基類表明可觸發的遊戲行爲:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
};

當你有接口只包含一個沒有返回值的方法時,極可能你能夠使用命令模式。

而後咱們爲不一樣的遊戲行爲定義相應的子類:

class JumpCommand : public Command
{
public:
  virtual void execute() { jump(); }
};
 
class FireCommand : public Command
{
public:
  virtual void execute() { fireGun(); }
};
 
// 你知道思路了吧

在代碼的輸入處理部分,爲每一個按鍵存儲一個指向命令的指針。

class InputHandler
{
public:
  void handleInput();
 
  // 綁定命令的方法……
 
private:
  Command* buttonX_;
  Command* buttonY_;
  Command* buttonA_;
  Command* buttonB_;
};

如今輸入處理部分這樣處理:

void InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) buttonX_->execute();
  else if (isPressed(BUTTON_Y)) buttonY_->execute();
  else if (isPressed(BUTTON_A)) buttonA_->execute();
  else if (isPressed(BUTTON_B)) buttonB_->execute();
}

注意在這裏沒有檢測NULL了嗎?這假設每一個按鍵都與某些命令相連。

若是想支持不作任何事情的按鍵又不想顯式檢測NULL,咱們能夠定義一個命令類,它的execute()什麼也不作。 這樣,某些按鍵處理器沒必要設爲NULL,只需指向這個類。這種模式被稱爲空對象

之前每一個輸入直接調用函數,如今會有一層間接尋址:

這是命令模式的簡短介紹。若是你可以看出它的好處,就把這章剩下的部分做爲獎勵吧。

角色說明

咱們剛纔定義的類能夠在以前的例子上正常工做,但有很大的侷限。 問題在於假設了頂層的jump()fireGun()之類的函數能夠找到玩家角色,而後像木偶同樣操縱它。

這些假定的耦合限制了這些命令的用處。JumpCommand只能 讓玩家的角色跳躍。讓咱們放鬆這個限制。 不讓函數去找它們控制的角色,咱們將函數控制的角色對象傳進去

class Command
{
public:
  virtual ~Command() {}
  virtual void execute(GameActor& actor) = 0;
};

這裏的GameActor是表明遊戲世界中角色的遊戲對象類。 咱們將其傳給execute(),這樣命令類的子類就能夠調用所選遊戲對象上的方法,就像這樣:

class JumpCommand : public Command
{
public:
  virtual void execute(GameActor& actor)
  {
    actor.jump();
  }
};

如今,咱們能夠使用這個類讓遊戲中的任何角色跳來跳去了。 在輸入控制部分和在對象上調用命令部分之間,咱們還缺了一塊代碼。 第一,咱們修改handleInput(),讓它能夠返回命令:

Command* InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) return buttonX_;
  if (isPressed(BUTTON_Y)) return buttonY_;
  if (isPressed(BUTTON_A)) return buttonA_;
  if (isPressed(BUTTON_B)) return buttonB_;
 
  // 沒有按下任何按鍵,就什麼也不作
  return NULL;
}

這裏不能當即執行,由於還不知道哪一個角色會傳進來。 這裏咱們享受了命令是具體調用的好處——延遲到調用執行時再知道。

而後,須要一些接受命令的代碼,做用在玩家角色上。像這樣:

Command* command = inputHandler.handleInput();
if (command)
{
  command->execute(actor);
}

actor視爲玩家角色的引用,它會正確地按着玩家的輸入移動, 因此咱們賦予了角色和前面例子中相同的行爲。 經過在命令和角色間增長了一層重定向, 咱們得到了一個靈巧的功能:咱們能夠讓玩家控制遊戲中的任何角色,只需向命令傳入不一樣的角色。

在實踐中,這個特性並不常用,可是常常會有相似的用例跳出來。 到目前爲止,咱們只考慮了玩家控制的角色,可是遊戲中的其餘角色呢? 它們被遊戲AI控制。咱們能夠在AI和角色之間使用相同的命令模式;AI代碼只需生成Command對象。

在選擇命令的AI和展示命令的遊戲角色間解耦給了咱們很大的靈活度。 咱們能夠對不一樣的角色使用不一樣的AI,或者爲了避免同的行爲而混合AI 想要一個更加有攻擊性的對手?插入一個更加有攻擊性的AI爲其生成命令。 事實上,咱們甚至能夠爲玩家角色加上AI 在展現階段,遊戲須要自動演示時,這是頗有用的。

把控制角色的命令變爲第一公民對象,去除直接方法調用中嚴厲的束縛。 將其視爲命令隊列,或者是命令流:

隊列能爲你作的更多事情,請看事件隊列

爲何我以爲須要爲你畫一幅「流」的圖像?又是爲何它看上去像是管道?

一些代碼(輸入控制器或者AI)產生一系列命令放入流中。 另外一些代碼(調度器或者角色自身)調用並消耗命令。 經過在中間加入隊列,咱們解耦了消費者和生產者。

若是將這些指令序列化,咱們能夠經過網絡流傳輸它們。 咱們能夠接受玩家的輸入,將其經過網絡發送到另一臺機器上,而後重現之。這是網絡多人遊戲的基礎。

撤銷和重作

最後的這個例子是這種模式最廣爲人知的使用狀況。 若是一個命令對象能夠一件事,那麼它亦能夠撤銷這件事。 在一些策略遊戲中使用撤銷,這樣你就能夠回滾那些你不喜歡的操做。 它是創造遊戲時必不可少的工具。 一個不能撤銷誤操做致使的錯誤的編輯器,確定會讓遊戲設計師恨你。

這是經驗之談。

沒有了命令模式,實現撤銷很是困難,有了它,就是小菜一碟。 假設咱們在製做單人回合制遊戲,想讓玩家能撤銷移動,這樣他們就能夠集中注意力在策略上而不是猜想上。

咱們已經使用了命令來抽象輸入控制,因此每一個玩家的舉動都已經被封裝其中。 舉個例子,移動一個單位的代碼可能以下:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    x_(x),
    y_(y)
  {}
 
  virtual void execute()
  {
    unit_->moveTo(x_, y_);
  }
 
private:
  Unit* unit_;
  int x_, y_;
};

注意這和前面的命令有些許不一樣。 在前面的例子中,咱們須要從修改的角色那裏抽象命令。 在這個例子中,咱們將命令綁定到要移動的單位上。 這條命令的實例不是通用的移動某物命令;而是遊戲回合中特殊的一次移動。

這展示了命令模式應用時的一種情形。 就像以前的例子,指令在某些情形中是可重用的對象,表明了可執行的事件 咱們早期的輸入控制器將其實現爲一個命令對象,而後在按鍵按下時調用其execute()方法。

這裏的命令更加特殊。它們表明了特定時間點能作的特定事件。 這意味着輸入控制代碼能夠在玩家下決定時創造一個實例。就像這樣:

Command* handleInput()
{
  Unit* unit = getSelectedUnit();
 
  if (isPressed(BUTTON_UP)) {
    // 向上移動單位
    int destY = unit->y() - 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }
 
  if (isPressed(BUTTON_DOWN)) {
    // 向下移動單位
    int destY = unit->y() + 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }
 
  // 其餘的移動……
 
  return NULL;
}

固然,在像C++這樣沒有垃圾回收的語言中,這意味着執行命令的代碼也要負責釋放內存。

命令的一次性爲咱們很快地贏得了一個優勢。 爲了讓指令可被取消,咱們爲每一個類定義另外一個須要實現的方法:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
  virtual void undo() = 0;
};

undo()方法回滾了execute()方法形成的遊戲狀態改變。 這裏是添加了撤銷功能後的移動命令:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    xBefore_(0),
    yBefore_(0),
    x_(x),
    y_(y)
  {}
 
  virtual void execute()
  {
    // 保存移動以前的位置
    // 這樣以後能夠復原。
 
    xBefore_ = unit_->x();
    yBefore_ = unit_->y();
 
    unit_->moveTo(x_, y_);
  }
 
  virtual void undo()
  {
    unit_->moveTo(xBefore_, yBefore_);
  }
 
private:
  Unit* unit_;
  int xBefore_, yBefore_;
  int x_, y_;
};

注意咱們爲類添加了更多的狀態。 當單位移動時,它忘記了它以前是什麼樣的。 若是咱們想要撤銷這個移動,咱們須要記得單位以前的狀態,也就是xBefore_yBefore_的做用。

這看上去是備忘錄模式使用的地方,它歷來沒有有效地工做過。 因爲命令趨向於修改對象狀態的一小部分,對數據其餘部分的快照就是浪費內存。手動內存管理的消耗更小。

持久化數據結構是另外一個選項。 使用它,每次修改對象都返回一個新對象,保持原來的對象不變。巧妙的實現下,這些新對象與以前的對象共享數據,因此比克隆整個對象開銷更小。

使用持久化數據結構,每條命令都存儲了命令執行以前對象的引用,而撤銷只是切換回以前的對象。

爲了讓玩家撤銷移動,咱們記錄了執行的最後命令。當他們按下control+z時,咱們調用命令的undo()方法。 (若是他們已經撤銷了,那麼就變成了重作,咱們會再一次執行命令。)

支持多重的撤銷也不太難。 咱們不僅僅記錄最後一條指令,還要記錄指令列表,而後用一個引用指向當前的那個。 當玩家執行一條命令,咱們將其添加到列表,而後將表明當前的指針指向它。

當玩家選擇撤銷,咱們撤銷如今的命令,將表明當前的指針日後退。 當他們選擇重作,咱們將表明當前的指針往前進,執行該指令。 若是在撤銷後選擇了新命令,那麼清除命令列表中當前的指針所指命令以後的所有命令。

第一次在關卡編輯器中實現這點時,我以爲本身簡直就是個天才。 我驚訝於它如此的簡明有效。 你須要約束本身,保證每一個數據修改都經過命令完成,一旦你作到了,餘下的都很簡單。

重作在遊戲中並不常見,但重常見。 一種簡單的重放實現是記錄遊戲每幀的狀態,這樣它能夠回放,但那會消耗太多的內存。

相反,不少遊戲記錄每一個實體每幀運行的命令。 爲了重放遊戲,引擎只須要正常運行遊戲,執行以前存儲的命令。

用類仍是用函數?

早些時候,我說過命令與第一公民函數或者閉包相似, 可是在這裏展示的每一個例子都是經過類完成的。 若是你更熟悉函數式編程,你也許會疑惑函數都在哪裏。

我用這種方式寫例子是由於C++對第一公民函數支持很是有限。 函數指針沒有狀態,函子很奇怪並且仍然須要定義類, C++11中的lambda演算須要大量的人工記憶輔助才能使用。

這並不是說你在其餘語言中不能夠用函數來完成命令模式。 若是你使用的語言支持閉包,無論怎樣,快去用它! 在某種程度上說,命令模式是爲一些沒有閉包的語言模擬閉包。

(我說某種程度上是由於,即便是那些支持閉包的語言, 爲命令創建真正的類或者結構也是頗有用的。 若是你的命令擁有多重操做(好比可撤銷的命令), 將其所有映射到同一函數中並不優雅。)

定義一個有字段的真實類能幫助讀者理解命令包含了什麼數據。 閉包是自動包裝狀態的完美解決方案,但它們過於自動化而很難看清包裝的真正狀態有哪些。

舉個例子,若是咱們使用javascript來寫遊戲,那麼咱們能夠用這種方式來寫讓單位移動的命令:

function makeMoveUnitCommand(unit, x, y) {
  // 這個函數就是命令對象:
  return function() {
    unit.moveTo(x, y);
  }
}

咱們能夠經過一對閉包來爲撤銷提供支持:

function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function() {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function() {
      unit.moveTo(xBefore, yBefore);
    }
  };
}

若是你習慣了函數式編程風格,這種作法是很天然的。 若是你沒有,我但願這章能夠幫你瞭解一些。 對於我而言,命令模式展示了函數式範式在不少問題上的高效性。

參見

  • 你最終可能會獲得不少不一樣的命令類。 爲了更容易實現這些類,定義一個具體的基類,包含一些能定義行爲的高層方法,每每會有幫助。 這將命令的主體execute()轉到子類沙箱中。
  • 在上面的例子中,咱們明確地指定哪一個角色會處理命令。 在某些狀況下,特別是當對象模型分層時,也能夠不這麼簡單粗暴。 對象能夠響應命令,或者將命令交給它的從屬對象。 若是你這樣作,你就完成了一個職責鏈模式
  • 有些命令是無狀態的純粹行爲,好比第一個例子中的JumpCommand 在這種狀況下,有多個實例是在浪費內存,由於全部的實例是等價的。 能夠用享元模式解決。

2.2享元模式

遊戲設計模式Design Patterns Revisited

迷霧散盡,露出了古樸莊嚴的森林。古老的鐵杉,在頭頂編成綠色穹頂。 陽光在樹葉間破碎成金色頂棚。從樹幹間遠眺,遠處的森林漸漸隱去。

這是咱們遊戲開發者夢想的超凡場景,這樣的場景一般由一個模式支撐着,它的名字低調至極:享元模式。

森林

用幾句話就能描述一片巨大的森林,可是在實時遊戲中作這件事就徹底是另一件事了。 當屏幕上須要顯示一整個森林時,圖形程序員看到的是每秒須要送到GPU六十次的百萬多邊形。

咱們討論的是成千上萬的樹,每棵都由上千的多邊形組成。 就算有足夠的內存描述森林,渲染的過程當中,CPUGPU的部分也太過繁忙了。

每棵樹都有一系列與之相關的位:

  • 定義樹幹,樹枝和樹葉形狀的多邊形網格。
  • 樹皮和樹葉的紋理。
  • 在森林中樹的位置和朝向。
  • 大小和色彩之類的調節參數,讓每棵樹都看起來不同凡響。

若是用代碼表示,那麼會獲得這樣的東西:

class Tree
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

這是一大堆數據,多邊形網格和紋理體積很是大。 描述整個森林的對象在一幀的時間就交給GPU實在是太過了。 幸運的是,有一種老辦法來處理它。

關鍵點在於,哪怕森林裏有千千萬萬的樹,它們大多數長得如出一轍。 它們使用了相同的網格和紋理。 這意味着這些樹的實例的大部分字段是同樣的

你要麼是瘋了,要麼是億萬富翁,才能讓美術給森林裏每棵樹創建獨立模型。

注意每一棵樹的小盒子中的東西都是同樣的。

咱們能夠經過顯式地將對象切爲兩部分來更加明確地模擬。 第一,將樹共有的數據拿出來分離到另外一個類中:

class TreeModel
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
};

遊戲只須要一個這種類, 由於沒有必要在內存中把相同的網格和紋理重複一千遍。 遊戲世界中每一個樹的實例只需有一個對這個共享TreeModel引用 留在Tree中的是那些實例相關的數據:

class Tree
{
private:
  TreeModel* model_;
 
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

你能夠將其想象成這樣:

這有點像類型對象模式。 二者都涉及將一個類中的狀態委託給另外的類,來達到在不一樣實例間分享狀態的目的。 可是,這兩種模式背後的意圖不一樣。

使用類型對象,目標是經過將類型引入對象模型,減小須要定義的類。 伴隨而來的內容分享是額外的好處。享元模式則是純粹的爲了效率。

把全部的東西都存在主存裏沒什麼問題,可是這對渲染也毫無幫助。 在森林到屏幕上以前,它得先到GPU。咱們須要用顯卡能夠識別的方式共享數據。

一千個實例

爲了減小須要推送到GPU的數據量,咱們想把共享的數據——TreeModel——只發送一次 而後,咱們分別發送每一個樹獨特的數據——位置,顏色,大小。 最後,咱們告訴GPU使用同一模型渲染每一個實例

幸運的是,今日的圖形接口和顯卡正好支持這一點。 這些細節很繁瑣且超出了這部書的範圍,可是Direct3DOpenGL均可以作實例渲染

在這些API中,你須要提供兩部分數據流。 第一部分是一塊須要渲染屢次的共同數據——在例子中是樹的網格和紋理。 第二部分是實例的列表以及繪製第一部分時須要使用的參數。 而後調用一次渲染,繪製整個森林。

這個API是由顯卡直接實現的,意味着享元模式也許是惟一的有硬件支持的GoF設計模式。

享元模式

好了,咱們已經看了一個具體的例子,下面我介紹模式的通用部分。 享元,就像它的名字暗示的那樣, 當你須要共享類時使用,一般是由於你有太多這種類了。

實例渲染時,每棵樹經過總線送到GPU消耗的更可能是時間而非內存,可是基本要點是同樣的。

這個模式經過將對象的數據分爲兩種來解決這個問題。 第一種數據沒有特定指明是哪一個對象的實例,所以能夠在它們間分享。 Gof稱之爲固有狀態,可是我更喜歡將其視爲上下文無關部分。 在這裏的例子中,是樹的網格和紋理。

數據的剩餘部分是變化狀態,那些每一個實例獨一無二的東西。 在這個例子中,是每棵樹的位置,拉伸和顏色。 就像這裏的示例代碼塊同樣,這種模式經過在每一個對象出現時共享一份固有狀態來節約內存。

就目前而言,這看上去像是基礎的資源共享,很難被稱爲一種模式。 部分緣由是在這個例子中,咱們能夠爲共享狀態劃出一個清晰的身份TreeModel

我發現,當共享對象沒有有效定義的實體時,使用這種模式就不那麼明顯(使用它也就愈加顯得精明)。 在那些狀況下,這看上去是一個對象被魔術般地同時分配到了多個地方。 讓我展現給你另一個例子。

紮根之所

這些樹長出來的地方也須要在遊戲中表示。 這裏可能有草,泥土,丘陵,湖泊,河流,以及其它任何你能夠想到的地形。 咱們基於區塊創建地表:世界的表面被劃分爲由微小區塊組成的巨大網格。 每一個區塊都由一種地形覆蓋。

每種地形類型都有一系列特性會影響遊戲玩法:

  • 決定了玩家可以多快地穿過它的移動開銷。
  • 代表可否用船穿過的水域標識。
  • 用來渲染它的紋理。

由於咱們遊戲程序員偏執於效率,咱們不會在每一個區塊中保存這些狀態。 相反,一個通用的方式是爲每種地形使用一個枚舉。

再怎麼樣,咱們也已經從樹的例子吸收教訓了。

enum Terrain
{
  TERRAIN_GRASS,
  TERRAIN_HILL,
  TERRAIN_RIVER
  // 其餘地形
};

而後,世界管理巨大的網格:

class World
{
private:
  Terrain tiles_[WIDTH][HEIGHT];
};

這裏我使用嵌套數組存儲2D網格。 在C/C++中這樣是頗有效率的,由於它會將全部元素打包在一塊兒。 在Java或者其餘內存管理語言中,那樣作會實際給你一個數組,其中每一個元素都是對數組的列的引用,那就不像你想要的那樣內存友好了。

反正,隱藏2D網格數據結構背後的實現細節,能使代碼更好地工做。 我這裏這樣作只是爲了讓其保持簡單。

爲了得到區塊的實際有用的數據,咱們作了一些這樣的事情:

int World::getMovementCost(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return 1;
    case TERRAIN_HILL:  return 3;
    case TERRAIN_RIVER: return 2;
      // 其餘地形……
  }
}
 
bool World::isWater(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return false;
    case TERRAIN_HILL:  return false;
    case TERRAIN_RIVER: return true;
      // 其餘地形……
  }
}

你知道個人意思了。這可行,可是我以爲很醜。 移動開銷和水域標識是區塊的數據,但在這裏它們散佈在代碼中。 更糟的是,簡單地形的數據被衆多方法拆開了。 若是可以將這些包裹起來就行了。畢竟,那是咱們設計對象的目的。

若是咱們有實際的地形就行了,像這樣:

class Terrain
{
public:
  Terrain(int movementCost,
          bool isWater,
          Texture texture)
  : movementCost_(movementCost),
    isWater_(isWater),
    texture_(texture)
  {}
 
  int getMovementCost() const { return movementCost_; }
  bool isWater() const { return isWater_; }
  const Texture& getTexture() const { return texture_; }
 
private:
  int movementCost_;
  bool isWater_;
  Texture texture_;
};

你會注意這裏全部的方法都是const。這不是巧合。 因爲同一對象在多處引用,若是你修改了它, 改變會同時在多個地方出現。

這也許不是你想要的。 經過分享對象來節約內存的這種優化,不該該影響到應用的顯性行爲。 所以,享元對象幾乎老是不可變的。

可是咱們不想爲每一個區塊都保存一個實例。 若是你看看這個類內部,你會發現裏面實際上什麼也沒有 惟一特別的是區塊在哪裏 用享元的術語講,區塊的全部狀態都是固有的或者說上下文無關的

鑑於此,咱們沒有必要保存多個同種地形類型。 地面上的草區塊兩兩無異。 咱們不用地形區塊對象枚舉構成世界網格,而是用Terrain對象指針組成網格:

class World
{
private:
  Terrain* tiles_[WIDTH][HEIGHT];
 
  // 其餘代碼……
};

每一個相同地形的區塊會指向相同的地形實例。

因爲地形實例在不少地方使用,若是你想要動態分配,它們的生命週期會有點複雜。 所以,咱們直接在遊戲世界中存儲它們。

class World
{
public:
  World()
  : grassTerrain_(1, false, GRASS_TEXTURE),
    hillTerrain_(3, false, HILL_TEXTURE),
    riverTerrain_(2, true, RIVER_TEXTURE)
  {}
 
private:
  Terrain grassTerrain_;
  Terrain hillTerrain_;
  Terrain riverTerrain_;
 
  // 其餘代碼……
};

而後咱們能夠像這樣來描繪地面:

void World::generateTerrain()
{
  // 將地面填滿草皮.
  for (int x = 0; x < WIDTH; x++)
  {
    for (int y = 0; y < HEIGHT; y++)
    {
      // 加入一些丘陵
      if (random(10) == 0)
      {
        tiles_[x][y] = &hillTerrain_;
      }
      else
      {
        tiles_[x][y] = &grassTerrain_;
      }
    }
  }
 
  // 放置河流
  int x = random(WIDTH);
  for (int y = 0; y < HEIGHT; y++) {
    tiles_[x][y] = &riverTerrain_;
  }
}

我認可這不是世界上最好的地形生成算法。

如今不須要World中的方法來接觸地形屬性,咱們能夠直接暴露出Terrain對象。

const Terrain& World::getTile(int x, int y) const
{
  return *tiles_[x][y];
}

用這種方式,World再也不與各類地形的細節耦合。 若是你想要某一區塊的屬性,可直接從那個對象得到:

int cost = world.getTile(2, 3).getMovementCost();

咱們回到了操做實體對象的API,幾乎沒有額外開銷——指針一般不比枚舉大。

性能如何?

我在這裏說幾乎,是由於性能偏執狂確定會想要知道它和枚舉比起來如何。 經過解引用指針獲取地形須要一次間接跳轉。 爲了得到移動開銷這樣的地形數據,你首先須要跟着網格中的指針找到地形對象, 而後再找到移動開銷。跟蹤這樣的指針會致使緩存不命中,下降運行速度。

須要更多指針追逐和緩存不命中的相關信息,看看數據局部性這章。

就像往常同樣,優化的金科玉律是需求優先 現代計算機硬件過於複雜,性能只是遊戲的一個考慮方面。 在我這章作的測試中,享元較枚舉沒有什麼性能上的損失。 享元實際上明顯更快。可是這徹底取決於內存中的事物是如何排列的。

能夠自信地說使用享元對象不會搞到不可收拾。 它給了你面向對象的優點,並且沒有產生一堆對象。 若是你建立了一個枚舉,又在它上面作了不少分支跳轉,考慮一下這個模式吧。 若是你擔憂性能,那麼至少在把代碼編程爲難以維護的風格以前先作些性能分析。

參見

  • 在區塊的例子中,咱們只是爲每種地形建立一個實例而後存儲在World中。 這也許能更好找到和重用這些實例。 可是在多數狀況下,你不會在一開始就建立全部享元。

若是你不能預料哪些是實際上須要的,最好在須要時才建立。 爲了保持共享的優點,當你須要一個時,首先看看是否已經建立了一個相同的實例。 若是確實如此,那麼只需返回那個實例。

這一般意味須要將構造函數封裝在查詢對象是否存在的接口以後。 像這樣隱藏構造指令是工廠方法的一個例子。

  • 爲了返回一個早先建立的享元,須要追蹤那些已經實例化的對象池。 正如其名,這意味着對象池是存儲它們的好地方。
  • 當使用狀態模式時, 常常會出現一些沒有任何特定字段的狀態對象 這個狀態的標識和方法都頗有用。 在這種狀況下,你能夠使用這個模式,而後在不一樣的狀態機上使用相同的對象實例。

2.3觀察者模式

遊戲設計模式Design Patterns Revisited

隨便打開電腦中的一個應用,頗有可能它就使用了MVC架構 而究其根本,是由於觀察者模式。 觀察者模式應用普遍,Java甚至將其放到了核心庫之中(java.util.Observer),而C#直接將其嵌入了語法event關鍵字)。

就像軟件中的不少東西,MVC是Smalltalkers在七十年代創造的。 Lisp程序員也許會說實際上是他們在六十年代發明的,可是他們懶得記下來。

觀察者模式是應用最普遍和最廣爲人知的GoF模式,可是遊戲開發世界與世隔絕, 因此對你來講,它也許是全新的。 假設你與世隔絕,讓我給你舉個形象的例子。

成就解鎖

假設咱們向遊戲中添加了成就係統。 它存儲了玩家能夠完成的各類各樣的成就,好比殺死1000只猴子惡魔從橋上掉下去,或者一命通關

我發誓畫的這個沒有第二個意思,笑。

要實現這樣一個包含各類行爲來解鎖成就的系統是頗有技巧的。 若是咱們不夠當心,成就係統會纏繞在代碼庫的每一個黑暗角落。 固然,從橋上掉落和物理引擎相關, 但咱們並不想看到在處理撞擊代碼的線性代數時, 有個對unlockFallOffBridge()的調用是不?

這只是隨口一說。 有自尊的物理程序員毫不會容許像遊戲玩法這樣的平凡之物玷污他們優美的算式。

咱們喜歡的是,照舊,讓關注遊戲一部分的全部代碼集成到一塊。 挑戰在於,成就在遊戲的不一樣層面被觸發。怎麼解耦成就係統和其餘部分呢?

這就是觀察者模式出現的緣由。 這讓代碼宣稱有趣的事情發生了,而沒必要關心究竟是誰接受了通知。

舉個例子,有物理代碼處理重力,追蹤哪些物體待在地表,哪些墜入深淵。 爲了實現橋上掉落的徽章,咱們能夠直接把成就代碼放在那裏,但那就會一團糟。 相反,能夠這樣作:

void Physics::updateEntity(Entity& entity)
{
  bool wasOnSurface = entity.isOnSurface();
  entity.accelerate(GRAVITY);
  entity.update();
  if (wasOnSurface && !entity.isOnSurface())
  {
    notify(entity, EVENT_START_FALL);
  }
}

它作的就是聲稱,額,我不知道有誰感興趣,可是這個東西剛剛掉下去了。作你想作的事吧。

物理引擎確實決定了要發送什麼通知,因此這並無徹底解耦。但在架構這個領域,一般只能讓系統變得更好,而不是完美

成就係統註冊它本身爲觀察者,這樣不管什麼時候物理代碼發送通知,成就係統都能收到。 它能夠檢查掉落的物體是否是咱們的失足英雄, 他以前有沒有作過這種不愉快的與橋的經典力學遭遇。 若是知足條件,就伴着禮花和炫光解鎖合適的成就,而這些都無需牽扯到物理代碼。

事實上,咱們能夠改變成就的集合或者刪除整個成就係統,而沒必要修改物理引擎。 它仍然會發送它的通知,哪怕實際沒有東西接收。

固然,若是咱們永久移除成就,沒有任何東西須要物理引擎的通知, 咱們也一樣能夠移除通知代碼。可是在遊戲的演進中,最好保持這裏的靈活性。

它如何運做

若是你還不知道如何實現這個模式,你可能能夠從以前的描述中猜到,可是爲了減輕你的負擔,我仍是過一遍代碼吧。

觀察者

咱們從那個須要知作別的對象作了什麼事的類開始。 這些好打聽的對象用以下接口定義:

class Observer
{
public:
  virtual ~Observer() {}
  virtual void onNotify(const Entity& entity, Event event) = 0;
};

onNotify()的參數取決於你。這就是爲何是觀察者模式, 而不是「能夠粘貼到遊戲中的真實代碼」。 典型的參數是發送通知的對象和一個裝入其餘細節的「數據」參數。

若是你用泛型或者模板編程,你可能會在這裏使用它們,可是根據你的特殊用況裁剪它們也很好。 這裏,我將其硬編碼爲接受一個遊戲實體和一個描述發生了什麼的枚舉。

任何實現了這個的具體類就成爲了觀察者。 在咱們的例子中,是成就係統,因此咱們能夠像這樣實現:

class Achievements : public Observer
{
public:
  virtual void onNotify(const Entity& entity, Event event)
  {
    switch (event)
    {
    case EVENT_ENTITY_FELL:
      if (entity.isHero() && heroIsOnBridge_)
      {
        unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
      }
      break;
 
      // 處理其餘事件,更新heroIsOnBridge_變量……
    }
  }
 
private:
  void unlock(Achievement achievement)
  {
    // 若是尚未解鎖,那就解鎖成就……
  }
 
  bool heroIsOnBridge_;
};

被觀察者

被觀察的對象擁有通知的方法函數,用GoF的說法,那些對象被稱爲主題 它有兩個任務。首先,它有一個列表,保存默默等它通知的觀察者:

class Subject
{
private:
  Observer* observers_[MAX_OBSERVERS];
  int numObservers_;
};

在真實代碼中,你會使用動態大小的集合而不是一個定長數組。 在這裏,我使用這種最基礎的形式是爲了那些不瞭解C++標準庫的人們。

重點是被觀察者暴露了公開的API來修改這個列表:

class Subject
{
public:
  void addObserver(Observer* observer)
  {
    // 添加到數組中……
  }
 
  void removeObserver(Observer* observer)
  {
    // 從數組中移除……
  }
 
  // 其餘代碼……
};

這就容許了外界代碼控制誰接收通知。 被觀察者與觀察者交流,可是不與它們耦合 在咱們的例子中,沒有一行物理代碼會說起成就。 但它仍然能夠與成就係統交流。這就是這個模式的聰慧之處。

被觀察者有一列表觀察者而不是單個觀察者也是很重要的。 這保證了觀察者不會相互干擾。 舉個例子,假設音頻引擎也須要觀察墜落事件來播放合適的音樂。 若是客體只支持單個觀察者,當音頻引擎註冊時,就會取消成就係統的註冊。

這意味着這兩個系統須要相互交互——並且是用一種極其糟糕的方式, 第二個註冊時會使第一個的註冊失效。 支持一列表的觀察者保證了每一個觀察者都是被獨立處理的。 就它們各自的視角來看,本身是這世界上惟一看着被觀察者的。

被觀察者的剩餘任務就是發送通知:

class Subject
{
protected:
  void notify(const Entity& entity, Event event)
  {
    for (int i = 0; i < numObservers_; i++)
    {
      observers_[i]->onNotify(entity, event);
    }
  }
 
  // 其餘代碼…………
};

注意,代碼假設了觀察者不會在它們的onNotify()方法中修改觀察者列表。 更加可靠的實現方法會阻止或優雅地處理這樣的併發修改。

可被觀察的物理系統

如今,咱們只須要給物理引擎和這些掛鉤,這樣它能夠發送消息, 成就係統能夠和引擎連線來接受消息。 咱們按照傳統的設計模式方法實現,繼承Subject

class Physics : public Subject
{
public:
  void updateEntity(Entity& entity);
};

這讓咱們將notify()實現爲了Subject內的保護方法。 這樣派生的物理引擎類能夠調用併發送通知,可是外部的代碼不行。 同時,addObserver()removeObserver()是公開的, 因此任何能夠接觸物理引擎的東西均可以觀察它。

在真實代碼中,我會避免使用這裏的繼承。 相反,我會讓Physics  一個Subject的實例。 再也不是觀察物理引擎自己,被觀察的會是獨立的「下落事件」對象。 觀察者能夠用像這樣註冊它們本身:

physics.entityFell()
  .addObserver(this);

對我而言,這是「觀察者」系統與「事件」系統的不一樣之處。 使用前者,你觀察作了有趣事情的事物。 使用後者,你觀察的對象表明了發生的有趣事情

如今,當物理引擎作了些值得關注的事情,它調用notify(),就像以前的例子。 它遍歷了觀察者列表,通知全部觀察者。

很簡單,對吧?只要一個類管理一列表指向接口實例的指針。 難以置信的是,如此直觀的東西是無數程序和應用框架交流的主心骨。

觀察者模式不是天衣無縫的。當我問其餘程序員怎麼看,他們提出了一些抱怨。 讓咱們看看能夠作些什麼來處理這些抱怨。

太慢了

我常常聽到這點,一般是從那些不知道模式具體細節的程序員那裏。 他們有一種假設,任何東西只要沾到了設計模式,那麼必定包含了一堆類,跳轉和浪費CPU循環其餘行爲。

觀察者模式的名聲特別壞,一些壞名聲的事物與它如影隨形, 好比事件消息,甚至數據綁定 其中的一些系統確實會慢。(一般是故意的,出於好的意圖)。 他們使用隊列,或者爲每一個通知動態分配內存。

這就是爲何我認爲設計模式文檔化很重要。 當咱們沒有統一的術語,咱們就失去了簡潔明確表達的能力。 你說「觀察者」,我覺得是「事件」,他覺得是「消息」, 由於沒人花時間記下差別,也沒人閱讀。

而那就是在這本書中我要作的。 本書中也有一章關於事件和消息:事件隊列.

如今你看到了模式是如何真正被實現的, 你知道事實並不如他們所想的這樣。 發送通知只需簡單地遍歷列表,調用一些虛方法。 是的,這比靜態調用慢一點,除非是性能攸關的代碼,不然這點消耗都是微不足道的。

我發現這個模式在代碼性能瓶頸之外的地方能有很好的應用, 那些你能夠承擔動態分配消耗的地方。 除那之外,使用它幾乎毫無限制。 咱們沒必要爲消息分配對象,也無需使用隊列。這裏只多了一個用在同步方法調用上的額外跳轉。

事實上,你得當心,觀察者模式同步的。 被觀察者直接調用了觀察者,這意味着直到全部觀察者的通知方法返回後, 被觀察者纔會繼續本身的工做。觀察者會阻塞被觀察者的運行。

這聽起來很瘋狂,但在實踐中,這可不是世界末日。 這只是值得注意的事情。 UI程序員——那些使用基於事件的編程的程序員已經這麼幹了不少年了——有句經典名言:遠離UI線程

若是要對事件同步響應,你須要完成響應,儘量快地返回,這樣UI就不會鎖死。 當你有耗時的操做要執行時,將這些操做推到另外一個線程或工做隊列中去。

你須要當心地在觀察者中混合線程和鎖。 若是觀察者試圖得到被觀察者擁有的鎖,遊戲就進入死鎖了。 在多線程引擎中,你最好使用事件隊列來作異步通訊。

它作了太多動態分配

整個程序員社區——包括不少遊戲開發者——轉向了擁有垃圾回收機制的語言, 動態分配今昔非比。 但在像遊戲這樣性能攸關的軟件中,哪怕是在有垃圾回收機制的語言,內存分配也依然重要。 動態分配須要時間,回收內存也須要時間,哪怕是自動運行的。

不少遊戲開發者不怎麼擔憂分配,但很擔憂分頁。 當遊戲須要不崩潰地連續運行多日來得到發售資格,不斷增長的分頁堆會影響遊戲的發售。

對象池模式一章介紹了避免這點的經常使用技術,以及更多其餘細節。

在上面的示例代碼中,我使用的是定長數組,由於我想盡量保證簡單。 在真實的項目中中,觀察者列表隨着觀察者的添加和刪除而動態地增加和縮短。 這種內存的分配嚇壞了一些人。

固然,第一件須要注意的事情是隻在觀察者加入時分配內存。 發送通知無需內存分配——只需一個方法調用。 若是你在遊戲一開始就加入觀察者而不亂動它們,分配的總量是很小的。

若是這仍然困擾你,我會介紹一種無需任何動態分配的方式來增長和刪除觀察者。

鏈式觀察者

咱們如今看到的全部代碼中,Subject擁有一列指針指向觀察它的Observer Observer類自己沒有對這個列表的引用。 它是純粹的虛接口。優先使用接口,而不是有狀態的具體類,這大致上是一件好事。

可是若是咱們確實願意在Observer中放一些狀態, 咱們能夠將觀察者的列表分佈到觀察者本身中來解決動態分配問題。 不是被觀察者保留一列表分散的指針,觀察者對象自己成爲了鏈表中的一部分:

爲了實現這一點,咱們首先要擺脫Subject中的數組,而後用鏈表頭部的指針取而代之:

class Subject
{
  Subject()
  : head_(NULL)
  {}
 
  // 方法……
private:
  Observer* head_;
};

而後,咱們在Observer中添加指向鏈表中下一觀察者的指針。

class Observer
{
  friend class Subject;
 
public:
  Observer()
  : next_(NULL)
  {}
 
  // 其餘代碼……
private:
  Observer* next_;
};

這裏咱們也讓Subject成爲了友類。 被觀察者擁有增刪觀察者的API,可是如今鏈表在Observer內部管理。 最簡單的實現辦法就是讓被觀察者類成爲友類。

註冊一個新觀察者就是將其連到鏈表中。咱們用更簡單的實現方法,將其插到開頭:

void Subject::addObserver(Observer* observer)
{
  observer->next_ = head_;
  head_ = observer;
}

另外一個選項是將其添加到鏈表的末尾。這麼作增長了必定的複雜性。 Subject要麼遍歷整個鏈表來找到尾部,要麼保留一個單獨tail_指針指向最後一個節點。

加在在列表的頭部很簡單,但也有另外一反作用。 當咱們遍歷列表給每一個觀察者發送一個通知, 註冊的觀察者最接到通知。 因此若是以ABC的順序來註冊觀察者,它們會以CBA的順序接到通知。

理論上,這種仍是那種方式沒什麼差異。 在好的觀察者設計中,觀察同一被觀察者的兩個觀察者互相之間不應有任何順序相關。 若是順序確實有影響,這意味着這兩個觀察者有一些微妙的耦合,最終會害了你。

讓咱們完成刪除操做:

void Subject::removeObserver(Observer* observer)
{
  if (head_ == observer)
  {
    head_ = observer->next_;
    observer->next_ = NULL;
    return;
  }
 
  Observer* current = head_;
  while (current != NULL)
  {
    if (current->next_ == observer)
    {
      current->next_ = observer->next_;
      observer->next_ = NULL;
      return;
    }
 
    current = current->next_;
  }
}

如你所見,從鏈表移除一個節點一般須要處理一些醜陋的特殊狀況,應對頭節點。 還能夠使用指針的指針,實現一個更優雅的方案。

我在這裏沒有那麼作,是由於半數看到這個方案的人都迷糊了。 但這是一個很值得作的練習:它能幫助你深刻思考指針。

由於使用的是鏈表,因此咱們得遍歷它才能找到要刪除的觀察者。 若是咱們使用普通的數組,也得作相同的事。 若是咱們使用雙向鏈表,每一個觀察者都有指向前面和後面的指針, 就能夠用常量時間移除觀察者。在實際項目中,我會這樣作。

剩下的事情只有發送通知了,這和遍歷列表一樣簡單;

void Subject::notify(const Entity& entity, Event event)
{
  Observer* observer = head_;
  while (observer != NULL)
  {
    observer->onNotify(entity, event);
    observer = observer->next_;
  }
}

這裏,咱們遍歷了整個鏈表,通知了其中每個觀察者。 這保證了全部的觀察者相互獨立並有一樣的優先級。

咱們能夠這樣實現,當觀察者接到通知,它返回了一個標識,代表被觀察者是否應該繼續遍歷列表。 若是這樣作,你就接近了職責鏈模式。

不差嘛,對吧?被觀察者如今想有多少觀察者就有多少觀察者,無需動態內存。 註冊和取消註冊就像使用簡單數組同樣快。 可是,咱們犧牲了一些小小的功能特性。

因爲咱們使用觀察者對象做爲鏈表節點,這暗示它只能存在於一個觀察者鏈表中。 換言之,一個觀察者一次只能觀察一個被觀察者。 在傳統的實現中,每一個被觀察者有獨立的列表,一個觀察者同時能夠存在於多個列表中。

你也許能夠接受這一限制。 一般是一個被觀察者有多個觀察者,反過來就不多見了。 若是這真是一個問題,這裏還有一種沒必要使用動態分配的解決方案。 詳細介紹的話,這章就太長了,但我會大體描述一下,其他的你能夠自行填補……

鏈表節點池

就像以前,每一個被觀察者有一鏈表的觀察者。 可是,這些鏈表節點不是觀察者自己。 相反,它們是分散的小鏈表節點對象, 包含了指向觀察者的指針和指向鏈表下一節點的指針。

因爲多個節點能夠指向同一觀察者,這就意味着觀察者能夠同時在超過多個被觀察者的列表中。 咱們能夠同時觀察多個對象了。

鏈表有兩種風格。學校教授的那種,節點對象包含數據。 在咱們以前的觀察者鏈表的例子中,是另外一種: 數據(這個例子中是觀察者)包含了節點next_指針)。

後者的風格被稱爲「侵入式」鏈表,由於在對象內部使用鏈表侵入了對象自己的定義。 侵入式鏈表靈活性更小,但如咱們所見,也更有效率。 在Linux核心這樣的地方這種風格很流行。

避免動態分配的方法很簡單:因爲這些節點都是一樣大小和類型, 能夠預先在對象池中分配它們。 這樣你只需處理固定大小的列表節點,能夠隨你所需使用和重用, 而無需牽扯到真正的內存分配器。

剩餘的問題

我認爲該模式將人們嚇阻的三個主要問題已經被搞定了。 它簡單,快速,對內存管理友好。 可是這意味着你總該使用觀察者嗎?

如今,這是另外一個的問題。 就像全部的設計模式,觀察者模式不是萬能藥。 哪怕能夠正確高效地的實現,它也不必定是好的解決方案。 設計模式聲名狼藉的緣由之一就是人們將好模式用在錯誤的問題上,獲得了糟糕的結果。

還有兩個挑戰,一個是關於技術,另外一個更偏向於可維護性。 咱們先處理關於技術的挑戰,由於關於技術的問題老是更容易處理。

銷燬被觀察者和觀察者

咱們看到的樣例代碼健壯可用,但有一個嚴重的反作用: 當刪除一個被觀察者或觀察者時會發生什麼? 若是你不當心在某些觀察者上面調用了delete,被觀察者也許仍然持有指向它的指針。 那是一個指向一片已釋放區域的懸空指針。 當被觀察者試圖發送一個通知,額……就說發生的事情會出乎你的意料以外吧。

不是譴責,但我注意到設計模式徹底沒提這個問題。

刪除被觀察者更容易些,由於在大多數實現中,觀察者沒有對它的引用。 可是即便這樣,將被觀察者所佔的字節直接回收可能仍是會形成一些問題。 這些觀察者也許仍然期待在之後收到通知,而這是不可能的了。 它們無法繼續觀察了,真的,它們只是認爲它們能夠。

你能夠用好幾種方式處理這點。 最簡單的就是像我作的那樣,之後一腳踩空。 在被刪除時取消註冊是觀察者的職責。 多數狀況下,觀察者確實知道它在觀察哪一個被觀察者, 因此一般須要作的只是給它的析構器添加一個removeObserver()

一般在這種狀況下,難點不在如何作,而在記得作。

若是在刪除被觀察者時,你不想讓觀察者處理問題,這也很好解決。 只須要讓被觀察者在它被刪除前發送一個最終的死亡通知 這樣,任何觀察者均可以接收到,而後作些合適的行爲。

默哀,獻花,輓歌……

——哪怕是那些花費在大量時間在機器前,擁有讓咱們黯然失色的才能的人——也是絕對不可靠的。 這就是爲何咱們發明了電腦:它們不像咱們那樣常常犯錯誤。

更安全的方案是在每一個被觀察者銷燬時,讓觀察者自動取消註冊。 若是你在觀察者基類中實現了這個邏輯,每一個人沒必要記住就能夠使用它。 這確實增長了必定的複雜度。 這意味着每一個觀察者都須要有它在觀察的被觀察者的列表。 最終維護一個雙向指針。

別擔憂,我有垃圾回收器

大家那些裝備有垃圾回收系統的孩子如今必定很洋洋自得。 以爲你沒必要擔憂這個,由於你歷來沒必要顯式刪除任何東西?再仔細想一想!

想象一下:你有UI顯示玩家角色狀況的狀態,好比健康和道具。 當玩家在屏幕上時,你爲其初始化了一個對象。 UI退出時,你直接忘掉這個對象,交給GC清理。

每當角色臉上(或者其餘什麼地方)捱了一拳,就發送一個通知。 UI觀察到了,而後更新健康槽。很好。 當玩家離開場景,但你沒有取消觀察者的註冊,會發生什麼?

UI界面再也不可見,但也不會進入垃圾回收系統,由於角色的觀察者列表還保存着對它的引用。 每一次場景加載後,咱們給那個不斷增加的觀察者列表添加一個新實例。

玩家玩遊戲時,來回跑動,打架,角色的通知發送給全部的界面。 它們不在屏幕上,但它們接受通知,這樣就浪費CPU循環在不可見的UI元素上了。 若是它們會播放聲音之類的,這樣的錯誤就會被人察覺。

這在通知系統中很是常見,甚至專門有個名字:失效監聽者問題 因爲被觀察者保留了對觀察者的引用,最終有UI界面對象僵死在內存中。 這裏的教訓是要及時刪除觀察者。

它甚至有專門的維基條目

而後呢?

觀察者的另外一個深層次問題是它的意圖直接致使的。 咱們使用它是由於它幫助咱們放鬆了兩塊代碼之間的耦合。 它讓被觀察者與沒有靜態綁定的觀察者間接交流。

當你要理解被觀察者的行爲時,這頗有價值,任何不相關的事情都是在分散注意力。 若是你在處理物理引擎,你根本不想要編輯器——或者你的大腦——被一堆成就係統的東西而搞糊塗。

另外一方面,若是你的程序沒能運行,漏洞散佈在多個觀察者之間,理清信息流變得更加困難。 顯式耦合中更易於查看哪個方法被調用了。 這是由於耦合是靜態的,IDE分析它垂手可得。

可是若是耦合發生在觀察者列表中,想要知道哪一個觀察者被通知到了,惟一的辦法是看看哪一個觀察者在列表中,並且處於運行中 你得理清它的命令式,動態行爲而非理清程序的靜態交流結構。

處理這個的指導原則很簡單。 若是爲了理解程序的一部分,兩個交流的模塊須要考慮, 那就不要使用觀察者模式,使用其餘更加顯式的東西。

當你在某些大型程序上用黑魔法時,你會感受這樣處理很笨拙。 咱們有不少術語用來描述,好比關注點分離一致性和內聚性模塊化 總歸就是這些東西待在一塊兒,而不是與那些東西待在一塊兒。

觀察者模式是一個讓這些不相關的代碼塊互相交流,而沒必要打包成更大的塊的好方法。 這在專一於一個特性或層面的單一代碼塊不會太有用。

這就是爲何它能很好地適應咱們的例子: 成就和物理是幾乎徹底不相干的領域,一般被不一樣的人實現。 咱們想要它們之間的交流最小化, 這樣不管在哪個上工做都不須要另外一個的太多信息。

今日觀察者

設計模式源於1994 那時候,面嚮對象語言正是熱門的編程範式。每一個程序員都想要「30天學會面向對象編程,中層管理員根據程序員建立類的數量爲他們支付工資。工程師經過繼承層次的深度評價代碼質量。

同一年,Ace of Base的暢銷單曲發行了三首而不是一首,這也許能讓你瞭解一些咱們那時的品味和洞察力。

觀察者模式在那個時代中很流行,因此構建它須要不少類就不奇怪了。 可是現代的主流程序員更加適應函數式語言。 實現一整套接口只是爲了接受一個通知再也不符合今日的美學了。

它看上去是又沉重又死板。它確實又沉重又死板。 舉個例子,在觀察者類中,你不能爲不一樣的被觀察者調用不一樣的通知方法。

這就是爲何被觀察者常常將自身傳給觀察者。 觀察者只有單一的onNotify()方法, 若是它觀察多個被觀察者,它須要知道哪一個被觀察者在調用它的方法。

現代的解決辦法是讓觀察者只是對方法或者函數的引用。 在函數做爲第一公民的語言中,特別是那些有閉包的, 這種實現觀察者的方式更爲廣泛。

今日,幾乎每種語言都有閉包。C++克服了在沒有垃圾回收的語言中構建閉包的挑戰, 甚至Java都在JDK8中引入了閉包。

舉個例子,C#事件嵌在語言中。 經過這樣,觀察者是一個委託 委託是方法的引用在C#中的術語)。在JavaScript事件系統中,觀察者能夠是支持了特定EventListener協議的類, 可是它們也能夠是函數。 後者是人們經常使用的方式。

若是設計今日的觀察者模式,我會讓它基於函數而不是基於類。 哪怕是在C++中,我傾向於讓你註冊一個成員函數指針做爲觀察者,而不是Observer接口的實例。

這裏的一篇有趣博文以某種方式在C++上實現了這一點。

明日觀察者

事件系統和其餘相似觀察者的模式現在遍地都是。 它們都是成熟的方案。 可是若是你用它們寫一個稍微大一些的應用,你會發現一件事情。 在觀察者中不少代碼最後都長得同樣。一般是這樣:

1. 獲知有狀態改變了。
2. 下命令改變一些UI來反映新的狀態。

就是這樣,哦,英雄的健康如今是7了?讓咱們把血條的寬度設爲70像素。 過上一段時間,這會變得很沉悶。 計算機科學學術界和軟件工程師已經用了很長時間嘗試結束這種情況了。 這些方式被賦予了不一樣的名字:數據流編程函數反射編程等等。

即便有所突破,通常也侷限在特定的領域中,好比音頻處理或芯片設計,咱們尚未找到萬能鑰匙。與此同時,一個更腳踏實地的方式開始得到成效。那就是如今的不少應用框架使用的數據綁定

不像激進的方式,數據綁定再也不期望徹底終結命令式代碼,也不嘗試基於巨大的聲明式數據圖表架構整個應用。它作的只是自動改變UI元素或計算某些數值來反映一些值的變化。

就像其餘聲明式系統,數據綁定也許太慢,嵌入遊戲引擎的核心也太複雜。 可是若是說它不會侵入遊戲不那麼性能攸關的部分,好比UI,那我會很驚訝。

與此同時,經典觀察者模式仍然在那裏等着咱們。是的,它不像其餘的新熱門技術同樣在名字中填滿了函數」「反射,可是它超簡單並且能正常工做。對我而言,這一般是解決方案最重要的條件。

2.4原型模式

遊戲設計模式Design Patterns Revisited

我第一次聽到原型這個詞是在設計模式中。 現在,彷佛每一個人都在用這個詞,但他們討論的實際上不是設計模式 咱們會討論他們所說的原型,也會討論術語原型的有趣之處,和其背後的理念。 但首先,讓咱們重訪傳統的設計模式。

「傳統的」一詞可不是隨便用的。 設計模式引自1963 Ivan Sutherland的Sketchpad傳奇項目,那是這個模式首次出現。 當其餘人在聽迪倫和甲殼蟲樂隊時,Sutherland正忙於,你知道的,發明CAD,交互圖形和麪向對象編程的基本概念。

看看這個demo,跪服吧。

原型設計模式

假設咱們要用《聖鎧傳說》的風格作款遊戲。 野獸和惡魔圍繞着英雄,爭着要吃他的血肉。 這些可怖的同行者經過生產者進入這片區域,每種敵人有不一樣的生產者。

在這個例子中,假設咱們遊戲中每種怪物都有不一樣的類——GhostDemonSorcerer等等,像這樣:

class Monster
{
  // 代碼……
};
 
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

生產者構造特定種類怪物的實例。 爲了在遊戲中支持每種怪物,咱們能夠用一種暴力的實現方法, 讓每一個怪物類都有生產者類,獲得平行的類結構:

我得翻出落滿灰塵的UML書來畫這個圖表。表明「繼承」。

實現後看起來像是這樣:

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};
 
class GhostSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Ghost();
  }
};
 
class DemonSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Demon();
  }
};
 
// 你知道思路了……

除非你會根據代碼量來得到工資, 不然將這些焊在一塊兒很明顯不是好方法。 衆多類,衆多引用,衆多冗餘,衆多副本,衆多重複自我……

原型模式提供了一個解決方案。 關鍵思路是一個對象能夠產出與它本身相近的對象。 若是你有一個惡靈,你能夠製造更多惡靈。 若是你有一個惡魔,你能夠製造其餘惡魔。 任何怪物均可以被視爲原型怪物,產出其餘版本的本身。

爲了實現這個功能,咱們給基類Monster添加一個抽象方法clone()

class Monster
{
public:
  virtual ~Monster() {}
  virtual Monster* clone() = 0;
 
  // 其餘代碼……
};

每一個怪獸子類提供一個特定實現,返回與它本身的類和狀態都徹底同樣的新對象。舉個例子:

class Ghost : public Monster {
public:
  Ghost(int health, int speed)
  : health_(health),
    speed_(speed)
  {}
 
  virtual Monster* clone()
  {
    return new Ghost(health_, speed_);
  }
 
private:
  int health_;
  int speed_;
};

一旦咱們全部的怪物都支持這個, 咱們再也不須要爲每一個怪物類建立生產者類。咱們只需定義一個類:

class Spawner
{
public:
  Spawner(Monster* prototype)
  : prototype_(prototype)
  {}
 
  Monster* spawnMonster()
  {
    return prototype_->clone();
  }
 
private:
  Monster* prototype_;
};

它內部存有一個怪物,一個隱藏的怪物, 它惟一的任務就是被生產者當作模板,去產生更多同樣的怪物, 有點像一個歷來不離開巢穴的蜂后。

爲了獲得惡靈生產者,咱們建立一個惡靈的原型實例,而後建立擁有這個實例的生產者:

Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

這個模式的靈巧之處在於它不但拷貝原型的,也拷貝它的狀態 這就意味着咱們能夠建立一個生產者,生產快速鬼魂,虛弱鬼魂,慢速鬼魂,而只需建立一個合適的原型鬼魂。

我在這個模式中找到了一些既優雅又使人驚歎的東西。 我沒法想象本身是如何創造出它們的,但我更沒法想象不知道這些東西的本身該如何是好。

效果如何?

好吧,咱們不須要爲每一個怪物建立單獨的生產者類,那很好。 但咱們確實須要在每一個怪物類中實現clone() 這和使用生產者方法比起來也沒節約多少代碼量。

當你坐下來試着寫一個正確的clone(),會碰見使人不快的語義漏洞。 作深層拷貝仍是淺層拷貝呢?換言之,若是惡魔拿着叉子,克隆惡魔也要克隆叉子嗎?

同時,這看上去沒減小已存問題上的代碼, 事實上還增添了些人爲的問題 咱們須要將每一個怪物有獨立的類做爲前提條件。 這絕對不是當今大多數遊戲引擎運做的方法。

咱們中大部分痛苦地學到,這樣龐雜的類層次管理起來很痛苦, 那就是咱們爲何用組件模式類型對象爲不一樣的實體建模,這樣無需一一建構本身的類。

生產函數

哪怕咱們確實須要爲每一個怪物構建不一樣的類,這裏還有其餘的實現方法。 不是使用爲每一個怪物創建分離的生產者,咱們能夠建立生產函數,就像這樣:

Monster* spawnGhost()
{
  return new Ghost();
}

這比構建怪獸生產者類更簡潔。生產者類只需簡單地存儲一個函數指針:

typedef Monster* (*SpawnCallback)();
 
class Spawner
{
public:
  Spawner(SpawnCallback spawn)
  : spawn_(spawn)
  {}
 
  Monster* spawnMonster()
  {
    return spawn_();
  }
 
private:
  SpawnCallback spawn_;
};

爲了給惡靈構建生產者,你須要作:

Spawner* ghostSpawner = new Spawner(spawnGhost);

模板

現在,大多數C++開發者已然熟悉模板了。 生產者類須要爲某類怪物構建實例,可是咱們不想硬編碼是哪類怪物。 天然的解決方案是將它做爲模板中的類型參數

我不太肯定程序員是學着喜歡C++模板仍是徹底畏懼並遠離了C++。 無論怎樣,今日我見到的程序員中,使用C++的也都會使用模板。

這裏的Spawner類沒必要考慮將生產什麼樣的怪物, 它總與指向Monster的指針打交道。

若是咱們只有SpawnerFor<T>類,模板類型沒有辦法共享父模板, 這樣的話,若是一段代碼須要與產生多種怪物類型的生產者打交道,就都得接受模板參數。

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};
 
template <class T>
class SpawnerFor : public Spawner
{
public:
  virtual Monster* spawnMonster() { return new T(); }
};

像這樣使用它:

Spawner* ghostSpawner = new SpawnerFor<Ghost>();

第一公民類型

前面的兩個解決方案使用類完成了需求,Spawner使用類型進行參數化。 C++中,類型不是第一公民,因此須要一些改動。 若是你使用JavaScriptPython,或者Ruby這樣的動態類型語言, 它們的類能夠傳遞的對象,你能夠用更直接的辦法解決這個問題。

某種程度上, 類型對象也是爲了彌補第一公民類型的缺失。 但那個模式在擁有第一公民類型的語言中也有用,由於它讓決定什麼是「類型」。 你也許想要與語言內建的類不一樣的語義。

當你完成一個生產者,直接向它傳遞要構建的怪物類——那個表明了怪物類的運行時對象。超容易的,對吧。

綜上所述,老實說,我不能說找到了一種情景,而在這個情景下,原型設計模式是最好的方案。 也許你的體驗有所不一樣,但如今把它擱到一邊,咱們討論點別的:將原型做爲一種語言範式

原型語言範式

不少人認爲面向對象編程是同義詞。 OOP的定義卻讓人感受正好相反, 毫無疑問,OOP讓你定義對象,將數據和代碼綁定在一塊兒。 C這樣的結構化語言相比,與Scheme這樣的函數語言相比, OOP的特性是它將狀態和行爲牢牢地綁在一塊兒。

你也許認爲類是完成這個的惟一方式方法, 可是包括Dave UngarRandall Smith的一大堆傢伙一直在拼命區分OOP和類。 他們在80年代建立了一種叫作Self的語言。它不用類實現了OOP

Self語言

就單純意義而言,Self比基於類的語言更加面向對象。 咱們認爲OOP將狀態和行爲綁在一塊兒,可是基於類的語言實際將狀態和行爲割裂開來。

拿你最喜歡的基於類的語言的語法來講。 爲了接觸對象中的一些狀態,你須要在實例的內存中查詢。狀態包含在實例中。

可是,爲了調用方法,你須要找到實例的類, 而後在那裏調用方法。行爲包含在中。 得到方法總須要經過中間層,這意味着字段和方法是不一樣的。

舉個例子,爲了調用C++中的虛方法,你須要在實例中找指向虛方法表的指針,而後再在那裏找方法。

Self結束了這種分歧。不管你要找啥,都只需在對象中找。 實例同時包含狀態和行爲。你能夠構建擁有徹底獨特方法的對象。

沒有人能與世隔絕,但這個對象是。

若是這就是Self語言的所有,那它將很難使用。 基於類的語言中的繼承,無論有多少缺陷,總歸提供了有用的機制來重用代碼,避免重複。 爲了避免使用類而實現一些相似的功能,Self語言加入了委託

若是要在對象中尋找字段或者調用方法,首先在對象內部查找。 若是能找到,那就成了。若是找不到,在對象的父對象中尋找。 這裏的父類僅僅是一個對其餘對象的引用。 當咱們沒能在第一個對象中找到屬性,咱們嘗試它的父對象,而後父類的父對象,繼續下去直到找到或者沒有父對象爲止。 換言之,失敗的查找被委託給對象的父對象。

我在這裏簡化了。Self實際上支持多個父對象。 父對象只是特別標明的字段,意味着你能夠繼承它們或者在運行時改變他們, 你最終獲得了「動態繼承」。

父對象讓咱們在不一樣對象間重用行爲(還有狀態!),這樣就完成了類的公用功能。 類作的另外一個關鍵事情就是給出了建立實例的方法。 當你須要新的某物,你能夠直接new Thingamabob(),或者隨便什麼你喜歡的表達法。 類是實例的生產工廠。

不用類,咱們怎樣建立新的實例? 特別地,咱們如何建立一堆有共同點的新東西? 就像這個設計模式,在Self中,達到這點的方式是使用克隆

Self語言中,就好像每一個對象都自動支持原型設計模式。 任何對象都能被克隆。爲了得到一堆類似的對象,你:

  1. 將對象塑形成你想要的狀態。你能夠直接克隆系統內建的基本Object,而後向其中添加字段和方法。
  2. 克隆它來產出…………隨你想要多少就克隆多少個對象。

無需煩擾本身實現clone();咱們就實現了優雅的原型模式,原型被內建在系統中。

這個系統美妙,靈巧,並且小巧, 一據說它,我就開始建立一個基於原型的語言來進一步學習。

我知道從頭開始構建一種編程語言語言不是學習它最有效率的辦法,但我能說什麼呢?我可算是個怪人。 若是你很好奇,我構建的語言叫Finch.

它的實際效果如何?

能使用純粹基於原型的語言讓我很興奮,可是當我真正上手時, 我發現了一個使人不快的事實:用它編程沒那麼有趣。

從小道消息中,我據說不少Self程序員得出了相同的結論。 但這項目並非一無可取。 Self很是的靈活,爲此創造了不少虛擬機的機制來保持高速運行。

他們發明了JIT編譯,垃圾回收,以及優化方法分配——這都是由同一批人實現的—— 這些新玩意讓動態類型語言能快速運行,構建了不少大受歡迎的應用。

是的,語言自己很容易實現,那是由於它把複雜度甩給了用戶。 一旦開始試着使用這語言,我發現我想念基於類語言中的層次結構。 最終,在構建語言缺失的庫概念時,我放棄了。

鑑於我以前的經驗都來自基於類的語言,所以個人頭腦可能已經固定在它的範式上了。 可是直覺上,我認爲大部分人仍是喜歡有清晰定義的事物

除去基於類的語言自身的成功之外,看看有多少遊戲用類建模描述玩家角色,以及不一樣的敵人、物品、技能。 不是遊戲中的每一個怪物都不同凡響,你不會看到洞穴人和哥布林還有雪混合在一塊兒這樣的怪物。

原型是很是酷的範式,我但願有更多人瞭解它, 但我很慶幸沒必要每天用它編程。 徹底皈依原型的代碼是一團漿糊,難以閱讀和使用。

這同時證實,不多 有人使用原型風格的代碼。我查過了。

JavaScript又怎麼樣呢?

好吧,若是基於原型的語言不那麼友好,怎麼解釋JavaScript呢? 這是一個有原型的語言,天天被數百萬人使用。運行JavaScript的機器數量超過了地球上其餘全部的語言。

Brendan EichJavaScript的締造者, Self語言中直接汲取靈感,不少JavaScript的語義都是基於原型的。 每一個對象都有屬性的集合,包含字段和方法(事實上只是存儲爲字段的函數)。 A對象能夠擁有B對象,B對象被稱爲A對象的原型 若是A對象的字段獲取失敗就會委託給B對象。

做爲語言設計者,原型的誘人之處是它們比類更易於實現。 Eich充分利用了這一點,他在十天內建立了JavaScript的第一個版本。

但除那之外,我相信在實踐中,JavaScript更像是基於類的而不是基於原型的語言。 JavaScriptSelf有所偏離,其中一個要點是除去了基於原型語言的核心操做克隆

JavaScript中沒有方法來克隆一個對象。 最接近的方法是Object.create(),容許你建立新對象做爲現有對象的委託。 這個方法在ECMAScript5中才添加,而那已經是JavaScript出現後的第十四年了。 相對於克隆,讓我帶你參觀一下JavaScript中定義類和建立對象的經典方法。 咱們從構造器函數開始:

function Weapon(range, damage) {
  this.range = range;
  this.damage = damage;
}

這建立了一個新對象,初始化了它的字段。你像這樣引入它:

var sword = new Weapon(10, 16);

這裏的new調用Weapon()函數,而this綁定在新的空對象上。 函數爲新對象添加了一系列字段,而後返回填滿的對象。

new也爲你作了另一件事。 當它建立那個新的空對象時,它將空對象的委託和一個原型對象鏈接起來。 你能夠用Weapon.prototype來得到原型對象。

屬性是添加到構造器中的,而定義行爲一般是經過向原型對象添加方法。就像這樣:

Weapon.prototype.attack = function(target) {
  if (distanceTo(target) > this.range) {
    console.log("Out of range!");
  } else {
    target.health -= this.damage;
  }
}

這給武器原型添加了attack屬性,其值是一個函數。 因爲new Weapon()返回的每個對象都有給Weapon.prototype的委託, 你如今能夠經過調用sword.attack() 來調用那個函數。 看上去像是這樣:

讓咱們複習一下:

  • 經過「new」操做建立對象,該操做引入表明類型的對象——構造器函數。
  • 狀態存儲在實例中。
  • 行爲經過間接層——原型的委託——被存儲在獨立的對象中,表明了一系列特定類型對象的共享方法。

說我瘋了吧,但這聽起來很像是我以前描述的類。 能夠JavaScript中寫原型風格的代碼(不用 克隆), 可是語言的語法和慣用法更鼓勵基於類的實現。

我的而言,我認爲這是好事。 就像我說的,我發現若是一切都使用原型,就很難編寫代碼, 因此我喜歡JavaScript,它將整個核心語義包上了一層糖衣。

爲數據模型構建原型

好吧,我以前不斷地討論我不喜歡原型的緣由,這讓這一章讀起來使人沮喪。 我認爲這本書應該更歡樂些,因此在最後,讓咱們討論討論原型確實有用,或者更加精確,委託 有用的地方。

隨着編程的進行,若是你比較程序與數據的字節數, 那麼你會發現數據的佔比穩定地增加。 早期的遊戲在程序中生成幾乎全部東西,這樣程序能夠塞進磁盤和老式遊戲卡帶。 在今日的遊戲中,代碼只是驅動遊戲的引擎,遊戲是徹底由數據定義的。

這很好,可是將內容推到數據文件中並不能魔術般地解決組織大項目的挑戰。 它只能把這挑戰變得更難。 咱們使用編程語言就由於它們有辦法管理複雜性。

再也不是將一堆代碼拷來拷去,咱們將其移入函數中,經過名字調用。 再也不是在一堆類之間複製方法,咱們將其放入單獨的類中,讓其餘類能夠繼承或者組合。

當遊戲數據達到必定規模時,你真的須要考慮一些類似的方案。 我不期望在這裏能說清數據模式這個問題, 但我確實但願提出個思路,讓你在遊戲中考慮考慮:使用原型和委託來重用數據。

假設咱們爲早先提到的山寨版《聖鎧傳說》定義數據模型。 遊戲設計者須要在不少文件中設定怪物和物品的屬性。

這標題是我原創的,沒有受到任何已存的多人地下城遊戲的影響。 請不要起訴我。

一個經常使用的方法是使用JSON 數據實體通常是字典,或者屬性集合,或者其餘什麼術語, 由於程序員就喜歡爲舊事物發明新名字。

咱們從新發明了太屢次,Steve Yegge稱之爲通用設計模式.

因此遊戲中的哥布林也許被定義爲像這樣的東西:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

這看上去很易懂,哪怕是最討厭文本的設計者也能使用它。 因此,你能夠給哥布林你們族添加幾個兄弟分支:

{
  "name": "goblin wizard",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "spells": ["fire ball", "lightning bolt"]
}
 
{
  "name": "goblin archer",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "attacks": ["short bow"]
}

如今,若是這是代碼,咱們會聞到了臭味。 在實體間有不少的重複,訓練優良的程序員討厭重複。 它浪費了空間,消耗了做者更多時間。 你須要仔細閱讀代碼才知道這些數據是否是相同的。 這難以維護。 若是咱們決定讓全部哥布林變強,須要記得將三個哥布林都更新一遍。糟糕糟糕糟糕。

若是這是代碼,咱們會爲哥布林構建抽象,並在三個哥布林類型中重用。 可是無能的JSON無法這麼作。因此讓咱們把它作得更加巧妙些。

咱們能夠爲對象添加"prototype"字段,記錄委託對象的名字。 若是在此對象內沒找到一個字段,那就去委託對象中查找。

這讓"prototype"再也不是數據,而成爲了數據。 哥布林有綠色疣皮和黃色牙齒。 它們沒有原型。 原型是表示哥布林的數據模型的屬性,而不是哥布林自己的屬性。

這樣,咱們能夠簡化咱們的哥布林JSON內容:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}
 
{
  "name": "goblin wizard",
  "prototype": "goblin grunt",
  "spells": ["fire ball", "lightning bolt"]
}
 
{
  "name": "goblin archer",
  "prototype": "goblin grunt",
  "attacks": ["short bow"]
}

因爲弓箭手和術士都將grunt做爲原型,咱們就不須要在它們中重複血量,防護和弱點。 咱們爲數據模型增長的邏輯超級簡單——基本的單一委託——但已經成功擺脫了一堆冗餘。

有趣的事情是,咱們沒有更進一步,把哥布林委託的抽象原型設置成基本哥布林 相反,咱們選擇了最簡單的哥布林,而後委託給它。

在基於原型的系統中,對象能夠克隆產生新對象是很天然的, 我認爲在這裏也同樣天然。這特別適合記錄那些只有一處不一樣的實體的數據。

想一想Boss和其餘獨特的事物,它們一般是更加常見事物的從新定義, 原型委託是定義它們的好方法。 斷頭魔劍,就是一把擁有加成的長劍,能夠像下面這樣表示:

{
  "name": "Sword of Head-Detaching",
  "prototype": "longsword",
  "damageBonus": "20"
}

只需在遊戲引擎上多花點時間,你就能讓設計者更加方便地添加不一樣的武器和怪物,而增長的這些豐富度可以取悅玩家。

2.5單例模式

遊戲設計模式Design Patterns Revisited

這個章節不一樣尋常。 其餘章節展現如何使用某個設計模式。 這個章節展現如何避免使用某個設計模式。

儘管它的意圖是好的,GoF描述的單例模式一般弊大於利。 他們強調應該謹慎使用這個模式,但在遊戲業界的口口相傳中,這一提示常常被無視了。

就像其餘模式同樣,在不合適的地方使用單例模式就好像用夾板處理子彈傷口。 因爲它被濫用得太嚴重了,這章的大部分都在講如何迴避單例模式, 但首先,讓咱們看看模式自己。

當業界從C語言遷移到面向對象的語言,他們遇到的首個問題是「如何訪問實例?」 他們知道有要調用的方法,可是找不到實例提供這個方法。 單例(換言之,全局化)是一條簡單的解決方案。

單例模式

設計模式 像這樣描述單例模式:

保證一個類只有一個實例,而且提供了訪問該實例的全局訪問點。

咱們從而且那裏將句子分爲兩部分,分別進行考慮。

保證一個類只有一個實例

有時候,若是類存在多個實例就不能正確的運行。 一般發生在類與保存全局狀態的外部系統互動時。

考慮封裝文件系統的API類。 由於文件操做須要一段時間完成,因此類使用異步操做。 這就意味着能夠同時運行多個操做,必須讓它們相互協調。 若是一個操做建立文件,另外一個操做刪除同一文件,封裝器類須要同時考慮,保證它們沒有相互妨礙。

爲了實現這點,對咱們封裝器類的調用必須接觸以前的每一個操做。 若是用戶能夠自由地建立類的實例,這個實例就沒法知道另外一實例以前的操做。 而單例模式提供的構建類的方式,在編譯時保證類只有單一實例。

提供了訪問該實例的全局訪問點

遊戲中的不一樣系統都會使用文件系統封裝類:日誌,內容加載,遊戲狀態保存,等等。 若是這些系統不能建立文件系統封裝類的實例,它們如何訪問該實例呢?

單例爲這點也提供瞭解決方案。 除了建立單一實例之外,它也提供了一種得到它的全局方法。 使用這種範式,不管何處何人均可以訪問實例。 綜合起來,經典的實現方案以下:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    // 惰性初始化
    if (instance_ == NULL) instance_ = new FileSystem();
    return *instance_;
  }
 
private:
  FileSystem() {}
 
  static FileSystem* instance_;
};

靜態的instance_成員保存了一個類的實例, 私有的構造器保證了它是惟一的。 公開的靜態方法instance()讓任何地方的代碼都能訪問實例。 在首次被請求時,它一樣負責惰性實例化該單例。

現代的實現方案看起來是這樣的:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    static FileSystem *instance = new FileSystem();
    return *instance;
  }
 
private:
  FileSystem() {}
};

哪怕是在多線程狀況下,C++11標準也保證了本地靜態變量只會初始化一次, 所以,假設你有一個現代C++編譯器,這段代碼是線程安全的,而前面的那個例子不是。

固然,單例類自己的線程安全是個不一樣的問題!這裏只保證了它的初始化沒問題。

爲何咱們使用它

看起來已有成效。 文件系統封裝類在任何須要的地方均可用,而無需笨重地處處傳遞。 類自己巧妙地保證了咱們不會實例化多個實例而搞砸。它還具備不少其餘的優良性質:

  • 若是沒人用,就沒必要建立實例。 節約內存和CPU循環老是好的。 因爲單例只在第一次被請求時實例化,若是遊戲永遠不請求,那麼它不會被實例化。
  • 它在運行時實例化。 一般的替代方案是使用含有靜態成員變量的類。 我喜歡簡單的解決方案,所以我儘量使用靜態類而不是單例,可是靜態成員有個限制:自動初始化。 編譯器在main()運行前初始化靜態變量。 這就意味着不能使用在程序加載時才獲取的信息(舉個例子,從文件加載的配置)。 這也意味着它們的相互依賴是不可靠的——編譯器可不保證以什麼樣的順序初始化靜態變量。

惰性初始化解決了以上兩個問題。 單例會盡量晚地初始化,因此那時它須要的全部信息都應該可用了。 只要沒有環狀依賴,一個單例在初始化它本身的時甚至能夠引用另外一個單例。

  • 可繼承單例。 這是個頗有用但一般被忽視的能力。 假設咱們須要跨平臺的文件系統封裝類。 爲了達到這一點,咱們須要它變成文件系統抽象出來的接口,而子類爲每一個平臺實現接口。 這是基類:
  • class FileSystem
  • {
  • public:
  •   virtual ~FileSystem() {}
  •   virtual char* readFile(char* path) = 0;
  •   virtual void  writeFile(char* path, char* contents) = 0;
  • };

而後爲一堆平臺定義子類:

class PS3FileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用索尼的文件讀寫API……
  }
 
  virtual void writeFile(char* path, char* contents)
  {
    // 使用索尼的文件讀寫API……
  }
};
 
class WiiFileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用任天堂的文件讀寫API……
  }
 
  virtual void writeFile(char* path, char* contents)
  {
    // 使用任天堂的文件讀寫API……
  }
};

下一步,咱們把FileSystem變成單例:

class FileSystem
{
public:
  static FileSystem& instance();
 
  virtual ~FileSystem() {}
  virtual char* readFile(char* path) = 0;
  virtual void  writeFile(char* path, char* contents) = 0;
 
protected:
  FileSystem() {}
};

靈巧之處在於如何建立實例:

FileSystem& FileSystem::instance()
{
  #if PLATFORM == PLAYSTATION3
    static FileSystem *instance = new PS3FileSystem();
  #elif PLATFORM == WII
    static FileSystem *instance = new WiiFileSystem();
  #endif
 
  return *instance;
}

經過一個簡單的編譯器轉換,咱們把文件系統包裝類綁定到合適的具體類型上。 整個代碼庫均可以使用FileSystem::instance()接觸到文件系統,而無需和任何平臺相關的代碼耦合。耦合發生在爲特定平臺寫的FileSystem類實現文件中。

大多數人解決問題到這個程度就已經夠了。 咱們獲得了一個文件系統封裝類。 它工做可靠,它全局有效,只要請求就能獲取。 是時候提交代碼,開懷暢飲了。

爲何咱們後悔使用它

短時間來看,單例模式是相對良性的。 就像其餘設計決策同樣,咱們須要從長期考慮。 這裏是一旦咱們將一些沒必要要的單例寫進代碼,會給本身帶來的麻煩:

它是一個全局變量

當遊戲仍是由幾個傢伙在車庫中完成時,榨乾硬件性能比象牙塔裏的軟件工程原則更重要。 C語言和彙編程序員前輩能毫無問題地使用全局變量和靜態變量,發佈好遊戲。 但隨着遊戲變得愈來愈大,愈來愈複雜,架構和管理開始變成瓶頸, 阻礙咱們發佈遊戲的,除了硬件限制,還有生產力限制。

因此咱們遷移到了像C++這樣的語言, 開始將一些從軟件工程師前輩那裏學到的智慧應用於實際。 其中一課是全局變量有害的諸多緣由:

  • 理解代碼更加困難。 假設咱們在查找其餘人所寫函數中的漏洞。 若是函數沒有碰到任何全局狀態,腦子只需圍着函數轉, 只需搞懂函數和傳給函數的變量。

計算機科學家稱不接觸不修改全局狀態的函數爲函數。 純函數易於理解,易於編譯器優化, 易於完成優雅的任務,好比記住緩存的狀況並繼續上次調用。

徹底使用純函數是有難度的,但其好處足以引誘科學家創造像Haskell這樣使用純函數的語言。

如今考慮函數中間是個對SomeClass::getSomeGlobalData()的調用。爲了查明發生了什麼,得追蹤整個代碼庫來看看什麼修改了全局變量。你真的不須要討厭全局變量,直到你在凌晨三點使用grep搜索數百萬行代碼,搞清楚哪個錯誤的調用將一個靜態變量設爲了錯誤的值。

  • 促進了耦合的發生。 新加入團隊的程序員也許不熟悉大家完美、可維護、鬆散耦合的遊戲架構, 但仍是剛剛得到了第一個任務:在岩石撞擊地面時播放聲音。 你我都知道這不須要將物理和音頻代碼耦合,可是他只想着把任務完成。 不幸的是,咱們的AudioPlayer是全局可見的。 因此以後一個小小的#include,新隊員就打亂了整個精心設計的架構。

若是不用全局實例實現音頻播放器,那麼哪怕他確實#include包含了頭文件,他仍是啥也作不了。 這種阻礙給他發送了一個明確的信號,這兩個模塊不應接觸,他須要另闢蹊徑。經過控制對實例的訪問,你控制了耦合。

  • 對並行不友好。 那些在單核CPU上運行遊戲的日子已經遠去。 哪怕徹底不須要並行的優點,現代的代碼至少也應考慮在多線程環境下工做 當咱們將某些東西轉爲全局變量時,咱們建立了一塊每一個線程都能看到並訪問的內存, 殊不知道其餘線程是否正在使用那塊內存。 這種方式帶來了死鎖,競爭狀態,以及其餘很難解決的線程同步問題。

像這樣的問題足夠嚇阻咱們聲明全局變量了, 同理單例模式也是同樣,可是那尚未告訴咱們應該如何設計遊戲。 怎樣不使用全局變量構建遊戲?

有幾個對這個問題的答案(這本書的大部分都由答案構成), 可是它們並不是顯而易見。 與此同時,咱們得發佈遊戲。 單例模式看起來是萬能藥。 它被寫進了一本關於面向對象設計模式的書中,所以它確定是個好的設計模式,對吧? 何況咱們已經藉助它作了不少年軟件設計了。

不幸的是,它不是解藥,它是安慰劑。 若是瀏覽全局變量形成的問題列表,你會注意到單例模式解決不了其中任何一個。 由於單例確實是全局狀態——它只是被封裝在一個類中。

它能在你只有一個問題的時候解決兩個

GoF對單例模式的描述中,而且這個詞有點奇怪。 這個模式解決了一個問題仍是兩個問題呢?若是咱們只有其中一個問題呢? 保證明例是惟一存在的是頗有用的,可是誰告訴咱們要讓每一個人都能接觸到它? 一樣,全局接觸很方便,可是必須禁止存在多個實例嗎?

這兩個問題中的後者,便利的訪問,幾乎是使用單例模式的所有緣由。 想一想日誌類。大部分模塊都能從記錄診斷日誌中獲益。 可是,若是將Log類的實例傳給每一個須要這個方法的函數,那就混雜了產生的數據,模糊了代碼的意圖。

明顯的解決方案是讓Log類成爲單例。 每一個函數都能從類那裏得到一個實例。 但當咱們這樣作時,咱們無心地製造了一個奇怪的小約束。 忽然之間,咱們再也不能建立多個日誌記錄者了。

起初,這不是一個問題。 咱們記錄單獨的日誌文件,因此只須要一個實例。 而後,隨着開發週期的逐次循環,咱們遇到了麻煩。 每一個團隊的成員都使用日誌記錄各自的診斷信息,大量的日誌傾瀉在文件裏。 程序員須要翻過不少頁代碼來找到他關心的記錄。

咱們想將日誌分散到多個文件中來解決這點。 爲了達到這點,咱們得爲遊戲的不一樣領域創造單獨的日誌記錄者: 網絡,UI,聲音,遊戲,玩法。 可是咱們作不到。 Log類再也不容許咱們建立多個實例,並且調用的方式也保證了這一點:

Log::instance().write("Some event.");

爲了讓Log類支持多個實例(就像它原來的那樣), 咱們須要修改類和說起它的每一行代碼。 以前便利的訪問就再也不那麼便利了。

這可能更糟。想象一下你的Log類是在多個遊戲間共享的庫中。 如今,爲了改變設計,須要在多組人之間協調改變, 他們中的大多數既沒有時間,也沒有動機修復它。

惰性初始化從你那裏剝奪了控制權

在擁有虛擬內存和軟性性能需求的PC裏,惰性初始化是一個小技巧。 遊戲則是另外一種情況。初始化系統須要消耗時間:分配內存,加載資源,等等。 若是初始化音頻系統消耗了幾百個毫秒,咱們須要控制它什麼時候發生。 若是在第一次聲音播放時惰性初始化它本身,這可能發生在遊戲的高潮部分,致使可見的掉幀和斷續的遊戲體驗。

一樣,遊戲一般須要嚴格管理在堆上分配的內存來避免碎片。 若是音頻系統在初始化時分配到了堆上,咱們須要知道初始化在什麼時候發生, 這樣咱們能夠控制內存待在堆的哪裏

對象池模式一節中有內存碎片的其餘細節。

由於這兩個緣由,我見到的大多數遊戲都不使用惰性初始化。 相反,它們像這樣實現單例模式:

class FileSystem
{
public:
  static FileSystem& instance() { return instance_; }
 
private:
  FileSystem() {}
 
  static FileSystem instance_;
};

這解決了惰性初始化問題,可是損失了幾個單例確實比原生的全局變量優良的特性。 靜態實例中,咱們不能使用多態,在靜態初始化時,類也必須是可構建的。 咱們也不能在不須要這個實例的時候,釋放實例所佔的內存。

與建立一個單例不一樣,這裏其實是一個簡單的靜態類。 這並不是壞事,可是若是你須要的是靜態類,爲何不徹底擺脫instance()方法, 直接使用靜態函數呢?調用Foo::bar()Foo::instance().bar()更簡單, 也更明確地代表你在處理靜態內存。

一般使用單例而不是靜態類的理由是, 若是你後來決定將靜態類改成非靜態的,你須要修改每個調用點。 理論上,用單例就沒必要那麼作,由於你能夠將實例傳來傳去,像普通的實例方法同樣使用。

實踐中,我從未見過這種狀況。 每一個人都在使用Foo::instance().bar()。 若是咱們將Foo改爲非單例,咱們仍是得修改每個調用點。 鑑於此,我更喜歡簡單的類和簡單的調用語法。

那該如何是好

若是我如今達到了目標,你在下次遇到問題使用單例模式以前就會三思然後行。 可是你仍是有問題須要解決。你應該使用什麼工具呢? 這取決於你試圖作什麼,我有一些你能夠考慮的選項,可是首先……

看看你是否是真正地須要類

我在遊戲中看到的不少單例類都是管理器」——那些類存在的意義就是照顧其餘對象。 我曾看到一些代碼庫中,幾乎全部類都有管理器: 怪物,怪物管理器,粒子,粒子管理器,聲音,聲音管理器,管理管理器的管理器。 有時候,它們被叫作系統引擎,可是思路仍是同樣的。

管理器類有時是有用的,但一般它們只是反映出做者對OOP的不熟悉。思考這兩個特製的類:

class Bullet
{
public:
  int getX() const { return x_; }
  int getY() const { return y_; }
 
  void setX(int x) { x_ = x; }
  void setY(int y) { y_ = y; }
 
private:
  int x_, y_;
};
 
class BulletManager
{
public:
  Bullet* create(int x, int y)
  {
    Bullet* bullet = new Bullet();
    bullet->setX(x);
    bullet->setY(y);
 
    return bullet;
  }
 
  bool isOnScreen(Bullet& bullet)
  {
    return bullet.getX() >= 0 &&
           bullet.getX() < SCREEN_WIDTH &&
           bullet.getY() >= 0 &&
           bullet.getY() < SCREEN_HEIGHT;
  }
 
  void move(Bullet& bullet)
  {
    bullet.setX(bullet.getX() + 5);
  }
};

也許這個例子有些蠢,可是我見過不少代碼,在剝離了外部的細節後是同樣的設計。 若是你看看這個代碼,BulletManager很天然應是一個單例。 不管如何,任何有Bullet的對象都須要管理,而你又須要多少個BulletManager實例呢?

事實上,這裏的答案是 這裏是咱們如何爲管理類解決單例問題:

class Bullet
{
public:
  Bullet(int x, int y) : x_(x), y_(y) {}
 
  bool isOnScreen()
  {
    return x_ >= 0 && x_ < SCREEN_WIDTH &&
           y_ >= 0 && y_ < SCREEN_HEIGHT;
  }
 
  void move() { x_ += 5; }
 
private:
  int x_, y_;
};

好了。沒有管理器,也沒有問題。 糟糕設計的單例一般會幫助另外一個類增長代碼。 若是能夠,把全部的行爲都移到單例幫助的類中。 畢竟,OOP就是讓對象管理好本身。

可是在管理器以外,還有其餘問題咱們須要尋求單例模式幫助。 對於每種問題,都有一些後續方案可供參考。

將類限制爲單一的實例

這是單例模式幫你解決的一個問題。 就像在文件系統的例子中那樣,保證類只有一個實例是很重要的。 可是,這不意味着咱們須要提供對實例的公衆全局訪問。 咱們想要減小某部分代碼的公衆部分,甚至讓它在類中是私有的。 在這些狀況下,提供一個全局接觸點消弱了總體架構。

舉個例子,咱們也許想把文件系統包在另外一層抽象中。

咱們但願有種方式能保證同事只有一個實例而無需提供全局接觸點。 有好幾種方法能作到。這是其中之一:

class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }
 
  ~FileSystem() { instantiated_ = false; }
 
private:
  static bool instantiated_;
};
 
bool FileSystem::instantiated_ = false;

這個類容許任何人構建它,若是你試圖構建超過一個實例,它會斷言並失敗。 只要正確的代碼首先建立了實例,那麼就保證了沒有其餘代碼能夠接觸實例或者建立本身的實例。 這個類保證知足了它關注的單一實例,可是它沒有指定類該如何被使用。

斷言 函數是一種向你的代碼中添加限制的方法。 當assert()被調用時,它計算傳入的表達式。 若是結果爲true,那麼什麼都不作,遊戲繼續。 若是結果爲false,它馬上中止遊戲。 在debug build時,這一般會啓動調試器,或至少打印失敗斷言所在的文件和行號。

assert()表示, 「我斷言這個總該是真的。若是不是,那就是漏洞,我想馬上中止並處理它。」 這使得你能夠在代碼區域之間定義約束。 若是函數斷言它的某個參數不能爲NULL,那就是說,「我和調用者定下了協議:傳入的參數不會NULL。」

斷言幫助咱們在遊戲發生預期之外的事時馬上追蹤漏洞, 而不是等到錯誤最終顯如今用戶可見的某些事物上。 它們是代碼中的柵欄,圍住漏洞,這樣漏洞就不能從製造它的代碼邊逃開。

這個實現的缺點是隻在運行時檢查並阻止多重實例化。 單例模式正相反,經過類的天然結構,在編譯時就能肯定實例是單一的。

爲了給實例提供方便的訪問方法

便利的訪問是咱們使用單例的一個主要緣由。 這讓咱們在不一樣地方獲取須要的對象更加容易。 這種便利是須要付出代價的——在咱們不想要對象的地方,也能輕易地使用。

通用原則是在能完成工做的同時,將變量寫得儘量局部。 對象影響的範圍越小,在處理它時,咱們須要放在腦子裏的東西就越少。 在咱們拿起有全局範圍影響的單例對象前,先考慮考慮代碼中其餘獲取對象的方式:

  • 傳進來。 最簡單的解決辦法,一般也是最好的,把你須要的對象簡單地做爲參數傳給須要它的函數。 在用其餘更加繁雜的方法前,考慮一下這個解決方案。

有些人使用術語依賴注入來指代它。不是代碼出來調用某些全局量來確認依賴, 而是依賴經過參數被傳進到須要它的代碼中去。 其餘人將依賴注入保留爲對代碼提供更復雜依賴的方法。

考慮渲染對象的函數。爲了渲染,它須要接觸一個表明圖形設備的對象,管理渲染狀態。 將其傳給全部渲染函數是很天然的,一般是用一個名字像context之類的參數。

另外一方面,有些對象不應在方法的參數列表中出現。 舉個例子,處理AI的函數可能也須要寫日誌文件,可是日誌不是它的核心關注點。 看到Log出如今它的參數列表中是很奇怪的事情,像這樣的狀況,咱們須要考慮其餘的選項。

像日誌這樣散佈在代碼庫各處的是橫切關注點」(cross-cutting concern) 當心地處理橫切關注點是架構中的持久挑戰,特別是在靜態類型語言中。

面向切面編程被設計出來應對它們。

  • 從基類中得到。 不少遊戲架構有淺層可是寬泛的繼承層次,一般只有一層深。 舉個例子,你也許有GameObject基類,每一個遊戲中的敵人或者對象都繼承它。 使用這樣的架構,很大一部分遊戲代碼會存在於這些推導類中。 這就意味着這些類已經有了對一樣事物的相同獲取方法:它們的GameObject基類。 咱們能夠利用這點:
  • class GameObject
  • {
  • protected:
  •   Log& getLog() { return log_; }
  •  
  • private:
  •   static Log& log_;
  • };
  •  
  • class Enemy : public GameObject
  • {
  •   void doSomething()
  •   {
  •     getLog().write("I can log!");
  •   }
  • };

這保證任何GameObject以外的代碼都不能接觸Log對象,可是每一個派生的實體都確實能使用getLog() 這種使用protected函數,讓派生對象使用的模式, 被涵蓋在子類沙箱這章中。

這也引出了一個新問題,GameObject是怎樣得到Log實例的?一個簡單的方案是,讓基類建立並擁有靜態實例。

若是你不想要基類承擔這些,你能夠提供一個初始化函數傳入Log實例, 或使用服務定位器模式找到它。

  • 從已是全局的東西中獲取。 移除全部全局狀態的目標使人欽佩,但並不實際。 大多數代碼庫仍有一些全局可用對象,好比一個表明了整個遊戲狀態的GameWorld對象。

咱們能夠讓現有的全局對象捎帶須要的東西,來減小全局變量類的數目。 不讓LogFileSystemAudioPlayer都變成單例,而是這樣作:

class Game
{
public:
  static Game& instance() { return instance_; }
 
  // 設置log_, et. al. ……
 
  Log&         getLog()         { return *log_; }
  FileSystem&  getFileSystem()  { return *fileSystem_; }
  AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
 
private:
  static Game instance_;
 
  Log         *log_;
  FileSystem  *fileSystem_;
  AudioPlayer *audioPlayer_;
};

這樣,只有Game是全局可見的。 函數能夠經過它訪問其餘系統。

Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);

純粹主義者會聲稱這違反了Demeter法則。我則聲稱這比一大坨單例要好。

若是,稍後,架構被改成支持多個Game實例(多是爲了流處理或者測試),LogFileSystem,和AudioPlayer都不會被影響到——它們甚至不知道有什麼區別。 缺陷是,固然,更多的代碼耦合到了Game中。 若是一個類簡單地須要播放聲音,爲了訪問音頻播放器,上例中仍然須要它知道遊戲世界。

咱們經過混合方案解決這點。 知道Game的代碼能夠直接從它那裏訪問AudioPlayer 而不知道的代碼,咱們用上面描述的其餘選項來提供AudioPlayer

  • 從服務定位器中得到。 目前爲止,咱們假設全局類是具體的類,好比Game 另外一種選項是定義一個類,存在的惟一目標就是爲對象提供全局訪問。 這種常見的模式被稱爲服務定位器模式,有單獨講它的章節。

單例中還剩下什麼

剩下的問題,何處咱們應該使用真實的單例模式? 說實話,我歷來沒有在遊戲中使用所有的GoF模式。 爲了保證明例是單一的,我一般簡單地使用靜態類。 若是這無效,我使用靜態標識位,在運行時檢測是否是隻有一個實例被建立了。

書中還有一些其餘章節也許能有所幫助。 子類沙箱模式經過分享狀態, 給實例以類的訪問權限而無需讓其全局可用。 服務定位器模式確實讓一個對象全局可用, 但它給了你如何設置對象的靈活性。

2.6狀態模式

遊戲設計模式Design Patterns Revisited

懺悔時間:我有些越界,將太多的東西打包到了這章中。 它表面上關於狀態模式 但我沒法只討論它和遊戲,而不涉及更加基礎的有限狀態機FSMs)。 可是一旦講了那個,我發現也想要介紹層次狀態機下推自動機

有不少要講,我會盡量簡短,這裏的示例代碼留下了一些你須要本身填補的細節。 我但願它們仍然足夠清晰,能讓你獲取一份全景圖。

若是你歷來沒有據說過狀態機,不要難過。 雖然在AI和編譯器程序方面很出名,但它在其餘編程圈就沒那麼知名了。 我認爲應該有更多人知道它,因此在這裏我將其運用在不一樣的問題上。

這些狀態機術語來自人工智能的早期時代。 在五十年代到六十年代,不少AI研究關注於語言處理。 不少如今用於分析程序語言的技術在當時是發明出來分析人類語言的。

感同身受

假設咱們在完成一個卷軸平臺遊戲。 如今的工做是實現玩家在遊戲世界中操做的女英雄。 這就意味着她須要對玩家的輸入作出響應。按B鍵她應該跳躍。簡單實現以下:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    yVelocity_ = JUMP_VELOCITY;
    setGraphics(IMAGE_JUMP);
  }
}

看到漏洞了嗎?

沒有東西阻止空中跳躍」——當角色在空中時狂按B,她就會浮空。 簡單的修復方法是給Heroine增長isJumping_布爾字段,追蹤它跳躍的狀態。而後這樣作:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_)
    {
      isJumping_ = true;
      // 跳躍……
    }
  }
}

這裏也應該有在英雄接觸到地面時將isJumping_設回false的代碼。 我在這裏爲了簡明沒有寫。

接下來,當玩家按下下方向鍵時,若是角色在地上,咱們想要她臥倒,而鬆開按鍵時站起來:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    // 若是沒在跳躍,就跳起來……
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    setGraphics(IMAGE_STAND);
  }
}

此次看到漏洞了嗎?

經過這個代碼,玩家能夠:

  1. 按下鍵臥倒。
  2. B從臥倒狀態跳起。
  3. 在空中放開下鍵。

英雄跳一半貼圖變成了站立時的貼圖。是時候增長另外一個標識了……

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // 跳躍……
    }
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    if (isDucking_)
    {
      isDucking_ = false;
      setGraphics(IMAGE_STAND);
    }
  }
}

下面,若是玩家在跳躍途中按下下方向鍵,英雄可以作跳斬攻擊就太酷了:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // 跳躍……
    }
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
    else
    {
      isJumping_ = false;
      setGraphics(IMAGE_DIVE);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    if (isDucking_)
    {
      // 站立……
    }
  }
}

又是檢查漏洞的時間了。找到了嗎?

跳躍時咱們檢查了字段,防止了空氣跳,可是速降時沒有。又是另外一個字段……

咱們的實現方法很明顯有錯。 每次咱們改動代碼時,就破壞些東西。 咱們須要增長更多動做——行走 都尚未加入呢——但以這種作法,完成以前就會形成一堆漏洞。

那些你崇拜的、看上去永遠能寫出完美代碼的程序員並非超人。 相反,他們有哪一種代碼易於出錯的直覺,而後避開。

複雜分支和可變狀態——隨時間改變的字段——是兩種易錯代碼,上面的例子覆蓋了二者。

有限狀態機前來救援

在經歷了上面的挫敗以後,把桌子掃空,只留下紙筆,咱們開始畫流程圖。 你給英雄每件能作的事情都畫了一個盒子:站立,跳躍,俯臥,跳斬。 當角色在能響應按鍵的狀態時,你從那個盒子畫出一個箭頭,標記上按鍵,而後鏈接到她變到的狀態。

祝賀,你剛剛建好了一個有限狀態機 它來自計算機科學的分支自動理論,那裏有不少著名的數據結構,包括著名的圖靈機。 FSMs是其中最簡單的成員。

要點是:

  • 你擁有狀態機全部可能狀態的集合。 在咱們的例子中,是站立,跳躍,俯臥和速降。
  • 狀態機同時只能在一個狀態。 英雄不可能同時處於跳躍和站立狀態。事實上,防止這點是使用FSM的理由之一。
  • 一連串的輸入事件被髮送給狀態機。 在咱們的例子中,就是按鍵按下和鬆開。
  • 每一個狀態都有一系列的轉移,每一個轉移與輸入和另外一狀態相關。 當輸入進來,若是它與當前狀態的某個轉移相匹配,機器轉換爲所指的狀態。

舉個例子,在站立狀態時,按下下方向鍵轉換爲俯臥狀態。 在跳躍時按下下方向鍵轉換爲速降。 若是輸入在當前狀態沒有定義轉移,輸入就被忽視。

這就是核心部分的所有了:狀態,輸入,和轉移。 你能夠用一張流程圖把它畫出來。不幸的是,編譯器不認識流程圖, 因此咱們如何實現一個? GoF的狀態模式是一個方法——咱們會談到的——但先從簡單的開始。

對FSMs我最喜歡的類比是那種老式文字冒險遊戲,好比Zork。 你有個由屋子組成的世界,屋子彼此經過出口相連。你輸入像「去北方」的導航指令探索屋子。

這其實就是狀態機:每一個屋子都是一個狀態。 你如今在的屋子是當前狀態。每一個屋子的出口是它的轉移。 導航指令是輸入。

枚舉和分支

Heroine類的問題在於它不合法地捆綁了一堆布爾量: isJumping_isDucking_不會同時爲真。 但有些標識同時只能有一個是true,這提示你真正須要的實際上是enum(枚舉)。

在這個例子中的enum就是FSM的狀態的集合,因此讓咱們這樣定義它:

enum State
{
  STATE_STANDING,
  STATE_JUMPING,
  STATE_DUCKING,
  STATE_DIVING
};

不須要一堆標識,Heroine只有一個state_狀態。 這裏咱們同時改變了分支順序。在前面的代碼中,咱們先判斷輸入,而後 判斷狀態。 這讓處理某個按鍵的代碼集中到了一處,但處理某個狀態的代碼分散到了各處。 咱們想讓處理狀態的代碼聚在一塊兒,因此先對狀態作分支。這樣的話:

void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_B)
      {
        state_ = STATE_JUMPING;
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
      }
      else if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        setGraphics(IMAGE_DUCK);
      }
      break;
 
    case STATE_JUMPING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DIVING;
        setGraphics(IMAGE_DIVE);
      }
      break;
 
    case STATE_DUCKING:
      if (input == RELEASE_DOWN)
      {
        state_ = STATE_STANDING;
        setGraphics(IMAGE_STAND);
      }
      break;
  }
}

這看起來很普通,可是比起前面的代碼是個很大的進步。 咱們仍有條件分支,但簡化了狀態變化,將它變成了字段。 處理同一狀態的全部代碼都聚到了一塊兒。 這是實現狀態機最簡單的方法,在某些狀況下,這也不錯。

重要的是,英雄再也不會處於不合法狀態。 使用布爾標識,不少可能存在的值的組合是不合法的。 經過enum,每一個值都是合法的。

可是,你的問題也許超過了這個解法的能力範圍。 假設咱們想增長一個動做動做,英雄能夠俯臥一段時間充能,以後釋放一次特殊攻擊。 當她俯臥時,咱們須要追蹤充能的持續時間。

咱們爲Heroine添加了chargeTime_字段,記錄充能的時間長度。 假設咱們已經有一個每幀都會調用的update()方法。在那裏,咱們添加:

void Heroine::update()
{
  if (state_ == STATE_DUCKING)
  {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      superBomb();
    }
  }
}

若是你猜這就是更新方法模式,恭喜你答對了!

咱們須要在她開始俯臥的時候重置計時器,因此咱們修改handleInput()

void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        chargeTime_ = 0;
        setGraphics(IMAGE_DUCK);
      }
      // 處理其餘輸入……
      break;
 
      // 其餘狀態……
  }
}

總而言之,爲了增長這個充能攻擊,咱們須要修改兩個方法, 添加一個chargeTime_字段到Heroine,哪怕它只在俯臥時有意義。 咱們更喜歡的是讓全部相關的代碼和數據都待在同一個地方。GoF完成了這個。

狀態模式

對於那些思惟模式深深沉浸在面向對象的人,每一個條件分支都是使用動態分配的機會(在C++中叫作虛方法調用)。 我以爲那就太過於複雜化了。有時候一個if就能知足你的須要了。

這裏有個歷史遺留問題。 原先的面向對象傳教徒,好比寫《設計模式》的GoF和寫《重構》的Martin Fowler都使用Smalltalk。 那裏,ifThen:只是個由你在必定狀況下使用的方法,該方法在truefalse對象中以不一樣的方式實現。

可是在咱們的例子中,面向對象確實是一個更好的方案。 這帶領咱們走向狀態模式。GoF這樣描述狀態模式:

容許一個對象在其內部狀態發生變化時改變本身的行爲,該對象看起來好像修改了它的類型

這可沒太多幫助。咱們的switch也完成了這一點。 它們描述的東西應用在英雄的身上實際是:

一個狀態接口

首先,咱們爲狀態定義接口。 狀態相關的行爲——以前用switch的每一處——都成爲了接口中的虛方法。 在咱們的例子中,那是handleInput()update()

class HeroineState
{
public:
  virtual ~HeroineState() {}
  virtual void handleInput(Heroine& heroine, Input input) {}
  virtual void update(Heroine& heroine) {}
};

爲每一個狀態寫個類

對於每一個狀態,咱們定義一個類實現接口。它的方法定義了英雄在狀態的行爲。 換言之,從以前的switch中取出每一個case,將它們移動到狀態類中。舉個例子:

class DuckingState : public HeroineState
{
public:
  DuckingState()
  : chargeTime_(0)
  {}
 
  virtual void handleInput(Heroine& heroine, Input input) {
    if (input == RELEASE_DOWN)
    {
      // 改回站立狀態……
      heroine.setGraphics(IMAGE_STAND);
    }
  }
 
  virtual void update(Heroine& heroine) {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      heroine.superBomb();
    }
  }
 
private:
  int chargeTime_;
};

注意咱們也將chargeTime_移出了Heroine,放到了DuckingState類中。 這很好——那部分數據只在這個狀態有用,如今咱們的對象模型顯式反映了這一點。

狀態委託

接下來,向Heroine添加指向當前狀態的指針,放棄龐大的switch,轉向狀態委託:

class Heroine
{
public:
  virtual void handleInput(Input input)
  {
    state_->handleInput(*this, input);
  }
 
  virtual void update()
  {
    state_->update(*this);
  }
 
  // 其餘方法……
private:
  HeroineState* state_;
};

爲了改變狀態,咱們只須要將state_聲明指向不一樣的HeroineState對象。 這就是狀態模式的所有了。

這看上去有些像策略模式和類型對象模式。 在三者中,你都有一個主對象委託給下屬。區別在於意圖

  • 在策略模式中,目標是解耦主類和它的部分行爲。
  • 在類型對象中,目標是經過共享一個對相同類型對象的引用,讓一系列對象行爲相近。
  • 在狀態模式中,目標是讓主對象經過改變委託的對象,來改變它的行爲。

狀態對象在哪裏?

我這裏掩掩藏了一些細節。爲了改變狀態,咱們須要聲明state_指向新的狀態, 但那個新狀態又是從哪裏來呢? enum實現中,這都不用過腦子——enum實際上就像數字同樣。 可是如今狀態是類了,意味着咱們須要指向實例。一般這有兩種方案:

靜態狀態

若是狀態對象沒有其餘數據字段, 那麼它存儲的惟一數據就是指向虛方法表的指針,用來調用它的方法。 在這種狀況下,沒理由產生多個實例。畢竟每一個實例都徹底同樣。

若是你的狀態沒有字段,只有一個虛方法,你能夠再簡化這個模式。 將每一個狀態替換成狀態函數——只是一個普通的頂層函數。 而後,主類中的state_字段變成一個簡單的函數指針。

在那種狀況下,你能夠用一個靜態實例。 哪怕你有一堆FSM同時在同一狀態上運行,它們也能指向同一實例,由於狀態沒有與狀態機相關的部分。

這是享元模式。

哪裏放置靜態實例取決於你。找一個合理的地方。 沒什麼特殊的理由,在這裏我將它放在狀態基類中。

class HeroineState
{
public:
  static StandingState standing;
  static DuckingState ducking;
  static JumpingState jumping;
  static DivingState diving;
 
  // 其餘代碼……
};

每一個靜態字段都是遊戲狀態類的一個實例。爲了讓英雄跳躍,站立狀態會這樣作:

if (input == PRESS_B)
{
  heroine.state_ = &HeroineState::jumping;
  heroine.setGraphics(IMAGE_JUMP);
}

實例化狀態

有時沒那麼容易。靜態狀態對俯臥狀態不起做用。 它有一個chargeTime_字段,與正在俯臥的英雄特定相關。 在遊戲中,若是隻有一個英雄,那也行,可是若是要添加雙人合做,同時在屏幕上有兩個英雄,就有麻煩了。

在那種狀況下,轉換時須要建立狀態對象。 這須要每一個FSM擁有本身的狀態實例。若是咱們分配狀態, 那意味着咱們須要釋放當前的狀態。 在這裏要當心,因爲觸發變化的代碼是當前狀態中的方法,須要刪除this,所以須要當心從事。

相反,咱們容許HeroineState中的handleInput()返回一個新狀態。 若是它那麼作了,Heroine會刪除舊的,而後換成新的,就像這樣:

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
  }
}

這樣,直到從以前的狀態返回,咱們才須要刪除它。 如今,站立狀態能夠經過建立新實例轉換爲俯臥狀態:

HeroineState* StandingState::handleInput(Heroine& heroine,
                                         Input input)
{
  if (input == PRESS_DOWN)
  {
    // 其餘代碼……
    return new DuckingState();
  }
 
  // 保持這個狀態
  return NULL;
}

若是能夠,我傾向於使用靜態狀態,由於它們不會在狀態轉換時消耗太多的內存和CPU 可是,對於更多狀態的事物,須要耗費一些精力來實現。

當你爲狀態動態分配內存時,你也許會擔憂碎片。 對象池模式能夠幫上忙。

入口行爲和出口行爲

狀態模式的目標是將狀態的行爲和數據封裝到單一類中。 咱們完成了一部分,可是還有一些未了之事。

當英雄改變狀態時,咱們也改變她的貼圖。 如今,那部分代碼在她轉換的狀態中。 當她從俯臥轉爲站立,俯臥狀態修改了她的貼圖:

HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    heroine.setGraphics(IMAGE_STAND);
    return new StandingState();
  }
 
  // 其餘代碼……
}

咱們想作的是,每一個狀態控制本身的貼圖。這能夠經過給狀態一個入口行爲來實現:

class StandingState : public HeroineState
{
public:
  virtual void enter(Heroine& heroine)
  {
    heroine.setGraphics(IMAGE_STAND);
  }
 
  // 其餘代碼……
};

Heroine中,咱們將處理狀態改變的代碼移動到新狀態上調用:

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
 
    // 調用新狀態的入口行爲
    state_->enter(*this);
  }
}

這讓咱們將俯臥代碼簡化爲:

HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    return new StandingState();
  }
 
  // 其餘代碼……
}

它作的全部事情就是轉換到站立狀態,站立狀態控制貼圖。 如今咱們的狀態真正地封裝了。 關於入口行爲的好事就是,當你進入狀態時,沒必要關心你是從哪一個狀態轉換來的

大多數真正的狀態圖都有轉爲同一狀態的多個轉移。 舉個例子,英雄在跳躍或跳斬後進入站立狀態。 這意味着咱們在轉換髮生的最後重複相同的代碼。 入口行爲很好地解決了這一點。

咱們能,固然,擴展並支持出口行爲 這是在咱們離開現有狀態,轉換到新狀態以前調用的方法。

有什麼收穫?

我花了這麼長時間向您推銷FSMs,如今咱們來捋一捋。 我到如今講的都是真的,FSM能很好地解決一些問題。但它們最大的優勢也是它們最大的缺點。

狀態機經過使用有約束的結構來理清雜亂的代碼。 你只需一個固定狀態的集合,單一的當前狀態,和一些硬編碼的轉換。

一個有限狀態機甚至不是圖靈徹底的。 自動理論用一系列抽象模型描述計算,每種都比以前的複雜。 圖靈機 是其中最具備表現力的模型之一。

「圖靈徹底」意味着一個系統(一般是編程語言)足以在內部實現一個圖靈機, 也就意味着,在某種程度上,全部的圖靈徹底具備一樣的表現力。 FSMs不夠靈活,並不在其中。

若是你須要爲更復雜的東西使用狀態機,好比遊戲AI,你會撞到這個模型的限制上。 感謝上天,咱們的前輩找到了一些方法來避免這些限制。我會在這一章的最後簡單地瀏覽一下它們。

併發狀態機

咱們決定賦予英雄拿槍的能力。 當她拿着槍的時候,她仍是能作她以前的任何事情:跑動,跳躍,跳斬,等等。 可是她在作這些的同時也要能開火。

若是咱們執着於FSM,咱們須要翻倍現有狀態。 對於每一個現有狀態,咱們須要另外一個她持槍狀態:站立,持槍站立,跳躍,持槍跳躍, 你知道個人意思了吧。

多加幾種武器,狀態就會指數爆炸。 不但增長了大量的狀態,也增長了大量的冗餘: 持槍和不持槍的狀態是徹底同樣的,只是多了一點負責射擊的代碼。

問題在於咱們將兩種狀態綁定到了一個狀態機上——作的和她攜帶的 爲了處理全部可能的組合,咱們須要爲每一組合寫一個狀態。 修復方法很明顯:使用兩個單獨的狀態機。

若是她在作什麼有n個狀態,而她攜帶了什麼有m個狀態,要塞到一個狀態機中, 咱們須要n × m個狀態。使用兩個狀態機,就只有n + m個。

咱們保留以前記錄她在作什麼的狀態機,不用管它。 而後定義她攜帶了什麼的單獨狀態機。 Heroine將會有兩個狀態引用,每一個對應一個狀態機,就像這樣:

class Heroine
{
  // 其餘代碼……
 
private:
  HeroineState* state_;
  HeroineState* equipment_;
};

爲了便於說明,她的裝備也使用了狀態模式。 在實踐中,因爲裝備只有兩個狀態,一個布爾標識就夠了。

當英雄把輸入委託給了狀態,兩個狀態都須要委託:

void Heroine::handleInput(Input input)
{
  state_->handleInput(*this, input);
  equipment_->handleInput(*this, input);
}

功能更完備的系統也許能讓狀態機銷燬輸入,這樣其餘狀態機就不會收到了。 這能阻止兩個狀態機響應同一輸入。

每一個狀態機以後都能響應輸入,發生行爲,獨立於其它機器改變狀態。 當兩個狀態集合幾乎沒有聯繫的時候,它工做得不錯。

在實踐中,你會發現狀態有時須要交互。 舉個例子,也許她在跳躍時不能開火,或者她在持槍時不能跳斬攻擊。 爲了完成這個,你也許會在狀態的代碼中作一些粗糙的if測試其餘狀態來協同, 這不是最優雅的解決方案,但這能夠搞定工做。

分層狀態機

再充實一下英雄的行爲,她可能會有更多類似的狀態。 舉個例子,她也許有站立、行走、奔跑和滑鏟狀態。在這些狀態中,按B跳,按下蹲。

若是使用簡單的狀態機實現,咱們在每一個狀態中的都重複了代碼。 若是咱們可以實現一次,在多個狀態間重用就行了。

若是這是面向對象的代碼而不是狀態機的,在狀態間分享代碼的方式是經過繼承。 咱們能夠爲在地面上定義一個類處理跳躍和速降。 站立、行走、奔跑和滑鏟都從它繼承,而後增長各自的附加行爲。

它的影響有好有壞。 繼承是一種有力的代碼重用工具,但也在兩塊代碼間創建了很是強的耦合。 這是重錘,因此請當心使用。

你會發現,這是個被稱爲分層狀態機的通用結構。 狀態能夠有父狀態(這讓它變爲子狀態)。 當一個事件進來,若是子狀態沒有處理,它就會交給鏈上的父狀態。 換言之,它像重載的繼承方法那樣運做。

事實上,若是咱們使用狀態模式實現FSM,咱們能夠使用繼承來實現層次。 定義一個基類做爲父狀態:

class OnGroundState : public HeroineState
{
public:
  virtual void handleInput(Heroine& heroine, Input input)
  {
    if (input == PRESS_B)
    {
      // 跳躍……
    }
    else if (input == PRESS_DOWN)
    {
      // 俯臥……
    }
  }
};

每一個子狀態繼承它:

class DuckingState : public OnGroundState
{
public:
  virtual void handleInput(Heroine& heroine, Input input)
  {
    if (input == RELEASE_DOWN)
    {
      // 站起……
    }
    else
    {
      // 沒有處理輸入,返回上一層
      OnGroundState::handleInput(heroine, input);
    }
  }
};

這固然不是惟一的實現層次的方法。 若是你沒有使用GoF的狀態模式,這可能不會有用。 相反,你能夠顯式的使用狀態而不是單一狀態來表示當前狀態的父狀態鏈。

棧頂的狀態是當前狀態,在他下面是它的直接父狀態, 而後是那個父狀態的父狀態,以此類推。 當你須要狀態的特定行爲,你從棧的頂端開始, 而後向下尋找,直到某一個狀態處理了它。(若是到底也沒找到,就無視它。)

下推自動機

還有一種有限狀態機的擴展也用了狀態棧。 容易混淆的是,這裏的棧表示的是徹底不一樣的事物,被用於解決不一樣的問題。

要解決的問題是有限狀態機沒有任何歷史的概念。 你記得正在什麼狀態中,可是不記得曾在什麼狀態。 沒有簡單的辦法重回上一狀態。

舉個例子:早先,咱們讓無畏英雄武裝到了牙齒。 當她開火時,咱們須要新狀態播放開火動畫,發射子彈,產生視覺效果。 因此咱們拼湊了一個FiringState,無論如今是什麼狀態,都能在按下開火按鈕時跳轉爲這個狀態。

這個行爲在多個狀態間重複,也許是用層次狀態機重用代碼的好地方。

問題在於她射擊轉換到的狀態。 她能夠在站立、奔跑、跳躍、跳斬時射擊。 當射擊結束,應該轉換爲她以前的狀態。

若是咱們執拗於純粹的FSM,咱們就已經忘了她以前所處的狀態。 爲了追蹤以前的狀態,咱們定義了不少幾乎徹底同樣的類——站立開火,跑步開火,跳躍開火,諸如此類—— 每一個都有硬編碼的轉換,用來回到以前的狀態。

咱們真正想要的是,它會存儲開火前所處的狀態,以後能回想起來。 自動理論又一次能幫上忙了,相關的數據結構被稱爲下推自動機

有限狀態機有一個指向狀態的指針,下推自動機有一棧指針。 FSM中,新狀態代替了以前的那個狀態。 下推自動機不只能完成那個,還能給你兩個額外操做:

  1. 你能夠將新狀態壓入棧中。當前的狀態老是在棧頂,因此你能轉到新狀態。 但它讓以前的狀態待在棧中而不是銷燬它。
  2. 你能夠彈出最上面的狀態。這個狀態會被銷燬,它下面的狀態成爲新狀態。

這正是咱們開火時須要的。咱們建立單一的開火狀態。 當開火按鈕在其餘狀態按下時,咱們壓入開火狀態。 當開火動畫結束,咱們彈出開火狀態,而後下推自動機自動轉回以前的狀態。

因此它們有多有用呢?

即便狀態機有這些常見的擴展,它們仍是很受限制。 這讓今日遊戲AI移向了更加激動人心的領域,好比行爲樹規劃系統  若是你關注複雜AI,這一整章只是爲了勾起你的食慾。 你須要閱讀其餘書來知足你的慾望。

這不意味着有限狀態機,下推自動機,和其餘簡單的系統沒有用。 它們是特定問題的好工具。有限狀態機在如下狀況有用:

  • 你有個實體,它的行爲基於一些內在狀態。
  • 狀態能夠被嚴格地分割爲相對較少的不相干項目。
  • 實體響應一系列輸入或事件。

在遊戲中,狀態機因在AI中使用而聞名,可是它也經常使用於其餘領域, 好比處理玩家輸入,導航菜單界面,分析文字,網絡協議以及其餘異步行爲。

第三章 序列模式

遊戲設計模式

電子遊戲之因此有趣,很大程度上歸功於它們會將咱們帶到別的地方。 幾分鐘後(或者,誠實點,可能會更長),咱們活在一個虛擬的世界。 創造那樣的世界是遊戲程序員至上的歡愉。

大多數遊戲世界都有的特性是時間——虛構世界以其特定的節奏運行。 做爲世界的架構師,咱們必須發明時間,製造推進遊戲時間運做的齒輪。

本篇的模式是建構這些的工具。 遊戲循環是時鐘的中心軸。 對象經過更新方法來聆聽時鐘的滴答聲。 咱們能夠用雙緩衝模式存儲快照來隱藏計算機的順序執行,這樣看起來世界能夠進行同步更新。

模式

3.1雙緩衝模式

遊戲設計模式Sequencing Patterns

意圖

用序列的操做模擬瞬間或者同時發生的事情。

動機

電腦具備強大的序列化處理能力。 它的力量來自於將大的任務分解爲小的步驟,這樣能夠一步接一步的完成。 可是,一般用戶須要看到事情發生在瞬間或者讓多個任務同時進行。

使用線程和多核架構讓這種說法不那麼正確了,但哪怕使用多核,也只有一些操做能夠同步運行。

一個典型的例子,也是每一個遊戲引擎都得掌控的問題,渲染。 當遊戲渲染玩家所見的世界時,它同時須要處理一堆東西——遠處的山,起伏的丘陵,樹木,每一個都在各自的循環中處理。 若是在用戶觀察時增量作這些,連續世界的幻覺就會被打破。 場景必須快速流暢地更新,顯示一系列完整的幀,每幀都是當即出現的。

雙緩衝解決了這個問題,可是爲了理解其原理,讓咱們首先的複習下計算機是如何顯示圖形的。

計算機圖形系統是如何工做的(概述)

在電腦屏幕上顯示圖像是一次繪製一個像素點。 它從左到右掃描每行像素點,而後移動至下一行。 當抵達了右下角,它退回左上角從新開始。 它作得飛快——每秒六十次——所以咱們的眼睛沒法察覺。 對咱們來講,這是一整張靜態的彩色像素——一張圖像。

這個解釋是「簡化過的」。 若是你是底層軟件開發人員,跳過下一節吧。 你對這章的其他部分已經瞭解得夠多了。 若是你不是,這部分的目標是給你足夠的背景知識,理解等下要討論的設計模式。

你能夠將整個過程想象爲軟管向屏幕噴灑像素。 獨特的像素從軟管的後面流入,而後在屏幕上噴灑,每次對一個像素塗一點顏色。 因此軟管怎麼知道哪一種顏色要噴到哪裏?

在大多數電腦上,答案是從幀緩衝中獲知這些信息。 幀緩衝是內存中的色素數組,RAM中每兩個字節表明表示一個像素點的顏色。 當軟管向屏幕噴灑時,它從這個數組中讀取顏色值,每次一個字節。

在字節值和顏色之間的映射一般由系統的像素格式色深來指定。 在今日多數遊戲主機上,每一個像素都有32位,紅綠藍三個各佔八位,剩下的八位保留做其餘用途。

最終,爲了讓遊戲顯示在屏幕中,咱們須要作的就是寫入這個數組。 咱們瘋狂擺弄的圖形算法最終都到了這裏:設置幀緩衝中的字節值。 但這裏有個小問題。

早先,我說過計算機是順序處理的。 若是機器在運行一塊渲染代碼,咱們不期望它同時還能作些別的什麼事。 這一般是沒啥問題,可是有些事確實在程序運行時發生。 其中一件是,當遊戲運行時,視頻輸出正在不斷從幀緩衝中讀取數據。 這可能會爲咱們帶來問題。

假設咱們要在屏幕上顯示一張笑臉。 程序在幀緩衝上開始循環,爲像素點塗色。 咱們沒有意識到的是,在寫入的同時,視頻驅動正在讀取它。 當它掃描過已寫的像素時,笑臉開始浮現,可是以後它進入了未寫的部分,就將沒有寫的像素繪製到了屏幕上。結果就是撕裂,你在屏幕上看到了繪製到一半的圖像,這是可怕的視覺漏洞。

顯卡設備讀取的緩衝幀正是咱們繪製像素的那塊(Fig. 1)。 顯卡最終追上了渲染器,而後越過它,讀取了尚未寫入的像素(Fig. 2)。 咱們完成了繪製,但驅動沒有收到那些新像素。

結果(Fig. 4)是用戶只看到了一半的繪製結果。 我稱它爲「哭臉」,笑臉看上去下半部是撕裂的。

這就是咱們須要這個設計模式的緣由。 程序一次渲染一個像素,可是顯示須要一次所有看到——在這幀中啥也沒有,下一幀笑臉所有出現。 雙緩衝解決了這個問題。我會用類比來解釋。

表演1,場景1

想象玩家正在觀看咱們的表演。 在場景一結束而場景二開始時,咱們須要改變舞臺設置。 若是讓場務在場景結束後進去拖動東西,場景的連貫性就被打破了。 咱們能夠減弱燈光(這是劇院實際上的作法),可是觀衆仍是知道有什麼在進行,而咱們想在場景間毫無跳躍地轉換。

經過消耗一些地皮,咱們想到了一個聰明的解決方案:建兩個舞臺,觀衆兩個都能看到。 每一個有它本身的一組燈光。咱們稱這些舞臺爲舞臺A和舞臺B 場景一在舞臺A上。同時場務在處於黑暗之中的舞臺B佈置場景二。 當場景一完成後,將切斷場景A的燈光,打開場景B的燈光。觀衆看向新舞臺,場景二當即開始。

同時,場務到了黑咕隆咚的舞臺A,收拾了場景一而後佈置場景 一旦場景二結束,將燈光轉回舞臺A 咱們在整場表演中進行這樣的活動,使用黑暗的舞臺做爲佈置下一場景的工做區域。 每一次場景轉換,只是在兩個舞臺間切換燈光。 觀衆得到了連續的體驗,場景轉換時沒有感到任何中斷。他們歷來沒有見到場務。

使用單面鏡以及其餘的巧妙佈置,你能夠真正地在同一位置佈置兩個舞臺。 隨着燈光切換,觀衆看到了不一樣的舞臺,無需看向不一樣的地方。 如何這樣佈置舞臺就留給讀者作練習吧。

從新回到圖形

這就是雙緩衝的工做原理, 這就是你看到的幾乎每一個遊戲背後的渲染系統。 不僅用一個幀緩衝,咱們用兩個。其中一個表明如今的幀,即類比中的舞臺A,也就是說是顯卡讀取的那一個。 GPU能夠想何時掃就何時掃。

但不是全部的遊戲主機都是這麼作的。 更老的簡單主機中,內存有限,須要當心地同步繪製和渲染。那很須要技巧。

同時,咱們的渲染代碼正在寫入另外一個幀緩衝。 即黑暗中的舞臺B。當渲染代碼完成了場景的繪製,它將經過交換緩存來切換燈光。 這告訴圖形硬件開始從第二塊緩存中讀取而不是第一塊。 只要在刷新以前交換,就不會有任何撕裂出現,整個場景都會一會兒出現。

這時能夠使用之前的幀緩衝了。咱們能夠將下一幀渲染在它上面了。超棒!

模式

定義緩衝類封裝了緩衝:一段可改變的狀態。 這個緩衝被增量地修改,但咱們想要外部的代碼將修改視爲單一的原子操做。 爲了實現這點,類保存了兩個緩衝的實例:下一緩衝當前緩衝

當信息緩衝區中讀取,它老是讀取當前的緩衝區。 當信息須要寫緩存,它老是在下一緩衝區上操做。 當改變完成後,一個交換操做會馬上將當前緩衝區和下一緩衝區交換, 這樣新緩衝區就是公共可見的了。舊的緩衝區成爲下一個重用的緩衝區。

什麼時候使用

這是那種你須要它時天然會想起的模式。 若是你有一個系統須要雙緩衝,它可能有可見的錯誤(撕裂之類的)或者行爲不正確。 可是,當你須要時天然會想起沒提提供太多有效信息。 更加特殊地,如下狀況都知足時,使用這個模式就很恰當:

  • 咱們須要維護一些被增量修改的狀態。
  • 在修改到一半的時候,狀態可能會被外部請求。
  • 咱們想要防止請求狀態的外部代碼知道內部的工做方式。
  • 咱們想要讀取狀態,並且不想等着修改完成。

記住

不像其餘較大的架構模式,雙緩衝模式位於底層。 正因如此,它對代碼庫的其餘部分影響較小——大多數遊戲甚至不會感到有區別。 儘管這裏仍是有幾個警告。

交換自己須要時間

在狀態被修改後,雙緩衝須要一個swap步驟。 這個操做必須是原子的——在交換時,沒有代碼能夠接觸到任何一個狀態。 一般,這就是修改一個指針那麼快,可是若是交換消耗的時間長於修改狀態的時間,那但是毫無助益。

咱們得保存兩個緩衝區

這個模式的另外一個結果是增長了內存的使用。 正如其名,這個模式須要你在內存中一直保留兩個狀態的拷貝。 在內存受限的設備上,你可能要付出慘痛的代價。 若是你不能接受使用兩分內存,你須要使用別的方法保證狀態在修改時不會被請求。

示例代碼

咱們知道了理論,如今看看它在實踐中如何應用。 咱們編寫了一個很是基礎的圖形系統,容許咱們在緩衝幀上描繪像素。 在大多數主機和電腦上,顯卡驅動提供了這種底層的圖形系統, 可是在這裏手動實現有助於理解發生了什麼。首先是緩衝區自己:

class Framebuffer
{
public:
  Framebuffer() { clear(); }
 
  void clear()
  {
    for (int i = 0; i < WIDTH * HEIGHT; i++)
    {
      pixels_[i] = WHITE;
    }
  }
 
  void draw(int x, int y)
  {
    pixels_[(WIDTH * y) + x] = BLACK;
  }
 
  const char* getPixels()
  {
    return pixels_;
  }
 
private:
  static const int WIDTH = 160;
  static const int HEIGHT = 120;
 
  char pixels_[WIDTH * HEIGHT];
};

它有將整個緩存設置成默認的顏色的操做,也將其中一個像素設置爲特定顏色的操做。 它也有函數getPixels(),讀取保存像素數據的數組。 雖然在這個例子中沒有出現,但在實際中,顯卡驅動會頻繁調用這個函數,將緩存中的數據輸送到屏幕上。

咱們將整個緩衝區封裝在Scene類中。渲染某物須要作的是在這塊緩衝區上調用一系列draw()

class Scene
{
public:
  void draw()
  {
    buffer_.clear();
 
    buffer_.draw(1, 1);
    buffer_.draw(4, 1);
    buffer_.draw(1, 3);
    buffer_.draw(2, 4);
    buffer_.draw(3, 4);
    buffer_.draw(4, 3);
  }
 
  Framebuffer& getBuffer() { return buffer_; }
 
private:
  Framebuffer buffer_;
};

特別地,它畫出來這幅曠世傑做:

每一幀,遊戲告訴場景去繪製。場景清空緩衝區而後一個接一個繪製一大堆像素。 它也提供了getBuffer()得到緩衝區,這樣顯卡能夠接觸到它。

這看起來直截了當,可是若是就這樣作,咱們會遇到麻煩。 顯卡驅動能夠在任何時間調用getBuffer(),甚至在這個時候:

buffer_.draw(1, 1);
buffer_.draw(4, 1);
// <- 圖形驅動從這裏讀取像素!
buffer_.draw(1, 3);
buffer_.draw(2, 4);
buffer_.draw(3, 4);
buffer_.draw(4, 3);

當上面的狀況發生時,用戶就會看到臉的眼睛,可是這一幀中嘴卻消失了。 下一幀,又可能在某些別的地方發生衝突。最終結果是糟糕的閃爍圖形。咱們會用雙緩衝修復這點:

class Scene
{
public:
  Scene()
  : current_(&buffers_[0]),
    next_(&buffers_[1])
  {}
 
  void draw()
  {
    next_->clear();
 
    next_->draw(1, 1);
    // ...
    next_->draw(4, 3);
 
    swap();
  }
 
  Framebuffer& getBuffer() { return *current_; }
 
private:
  void swap()
  {
    // 只需交換指針
    Framebuffer* temp = current_;
    current_ = next_;
    next_ = temp;
  }
 
  Framebuffer  buffers_[2];
  Framebuffer* current_;
  Framebuffer* next_;
};

如今Scene有存儲在buffers_數組中的兩個緩衝區,。 咱們並不從數組中直接引用它們。而是經過兩個成員,next_current_,指向這個數組。 當繪製時,咱們繪製在next_指向的緩衝區上。 當顯卡驅動須要得到像素信息時,它老是經過current_獲取另外一個緩衝區。

經過這種方式,顯卡驅動永遠看不到咱們正在施工的緩衝區。 解決方案的的最後一部分就是在場景完成繪製一幀的時候調用swap() 它經過交換next_current_的引用完成這一點。 下一次顯卡驅動調用getBuffer(),它會得到咱們剛剛完成渲染的新緩衝區, 而後將剛剛描繪好的緩衝區放在屏幕上。沒有撕裂,也沒有不美觀的問題。

不只是圖形

雙緩衝解決的核心問題是狀態有可能在被修改的同時被請求。 這一般有兩種緣由。圖形的例子覆蓋了第一種緣由——另外一線程的代碼或者另外一箇中斷的代碼直接訪問了狀態。

可是,還有一個一樣常見的緣由:負責修改的 代碼試圖訪問一樣正在修改狀態。 這可能發生在不少地方,特別是實體的物理部分和AI部分,實體在相互交互。 雙緩衝在那裏也十分有用。

人工不智能

假設咱們正在構建一個關於趣味喜劇的遊戲的行爲系統。 這個遊戲包括一堆跑來跑去尋歡做樂的角色。這裏是咱們的基礎角色:

class Actor
{
public:
  Actor() : slapped_(false) {}
 
  virtual ~Actor() {}
  virtual void update() = 0;
 
  void reset()      { slapped_ = false; }
  void slap()       { slapped_ = true; }
  bool wasSlapped() { return slapped_; }
 
private:
  bool slapped_;
};

每一幀,遊戲要在角色身上調用update(),讓角色作些事情。 特別地,從玩家的角度,全部的角色都應該看上去同時更新

這是更新方法模式的例子。

角色也能夠相互交互,這裏的交互,我指能夠互相扇對方巴掌 當更新時,角色能夠在另外一個角色身上調用slap()來扇它一巴掌,而後調用wasSlapped()看看本身是否是被扇了。

角色須要一個能夠交互的舞臺,讓咱們來佈置一下:

class Stage
{
public:
  void add(Actor* actor, int index)
  {
    actors_[index] = actor;
  }
 
  void update()
  {
    for (int i = 0; i < NUM_ACTORS; i++)
    {
      actors_[i]->update();
      actors_[i]->reset();
    }
  }
 
private:
  static const int NUM_ACTORS = 3;
 
  Actor* actors_[NUM_ACTORS];
};

Stage容許咱們向其中增長角色, 而後使用簡單的update()調用來更新每一個角色。 在用戶看來,角色是同時移動的,可是實際上,它們是依次更新的。

這裏須要注意的另外一點是,每一個角色的被扇狀態在更新後就馬上被清除。 這樣才能保證一個角色對一巴掌只反應一次。

做爲一切的開始,讓咱們定義一個具體的角色子類。 這裏的喜劇演員很簡單。 他只面向一個角色。當他被扇時——不管是誰扇的他——他的反應是扇他面前的人一巴掌。

class Comedian : public Actor
{
public:
  void face(Actor* actor) { facing_ = actor; }
 
  virtual void update()
  {
    if (wasSlapped()) facing_->slap();
  }
 
private:
  Actor* facing_;
};

如今咱們把一些喜劇演員丟到舞臺上看看發生了什麼。 咱們設置三個演員,第一個面朝第二個,第二個面朝第三個,第三個面對第一個,造成一個環:

Stage stage;
 
Comedian* harry = new Comedian();
Comedian* baldy = new Comedian();
Comedian* chump = new Comedian();
 
harry->face(baldy);
baldy->face(chump);
chump->face(harry);
 
stage.add(harry, 0);
stage.add(baldy, 1);
stage.add(chump, 2);

最終舞臺佈置以下圖。箭頭表明角色的朝向,而後數字表明角色在舞臺數組中的索引。

咱們扇哈利一巴掌,爲表演拉開序幕,看看以後會發生什麼:

harry->slap();
 
stage.update();

記住Stage中的update()函數輪流更新每一個角色, 所以若是檢視整個代碼,咱們會發現事件這樣發生:

Stage updates actor 0 (Harry)
  Harry was slapped, so he slaps Baldy
Stage updates actor 1 (Baldy)
  Baldy was slapped, so he slaps Chump
Stage updates actor 2 (Chump)
  Chump was slapped, so he slaps Harry
Stage update ends

在單獨的一幀中,初始給哈利的一巴掌傳給了全部的喜劇演員。 如今,讓事物複雜起來,讓咱們從新排列舞臺數組中角色的排序, 可是繼續保持面向對方的方式。

咱們不動舞臺的其他部分,只是將添加角色到舞臺的代碼塊改成以下:

stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);

讓咱們看看再次運行時會發生什麼:

Stage updates actor 0 (Chump)
  Chump was not slapped, so he does nothing
Stage updates actor 1 (Baldy)
  Baldy was not slapped, so he does nothing
Stage updates actor 2 (Harry)
  Harry was slapped, so he slaps Baldy
Stage update ends

哦不。徹底不同了。問題很明顯。 更新角色時,咱們修改了他們的被扇狀態,這也是咱們在更新時讀取的狀態。 所以,在更新中早先的狀態修改會影響以後同一狀態的修改的步驟。

若是你繼續更新舞臺,你會看到巴掌在角色間逐漸傳遞,每幀傳遞一個。 在第一幀 Harry扇了Baldy。下一幀,Baldy扇了Chump,如此類推。

而最終的結果是,一個角色對被扇做出反應多是在被扇的同一幀或者下一幀, 這徹底取決於兩個角色在舞臺上是如何排序的。 這沒能知足我讓角色同時反應的需求——它們在同一幀中更新的順序不應對結果有影響。

緩存巴掌

幸運的是,雙緩衝模式能夠幫忙。 此次,不是保存兩大塊緩衝,咱們緩衝更小粒度的事物:每一個角色的被扇狀態。

class Actor
{
public:
  Actor() : currentSlapped_(false) {}
 
  virtual ~Actor() {}
  virtual void update() = 0;
 
  void swap()
  {
    // 交換緩衝區
    currentSlapped_ = nextSlapped_;
 
    // 清空新的下一個緩衝區。.
    nextSlapped_ = false;
  }
 
  void slap()       { nextSlapped_ = true; }
  bool wasSlapped() { return currentSlapped_; }
 
private:
  bool currentSlapped_;
  bool nextSlapped_;
};

再也不使用一個slapped_狀態,每一個演員如今使用兩個。 就像咱們以前圖形的例子同樣,當前狀態爲讀準備,下一狀態爲寫準備。

reset()函數被替換爲swap() 如今,就在清除交換狀態前,它將下一狀態拷貝到當前狀態上, 使其成爲新的當前狀態,這還須要在Stage中進行小小的改變:

void Stage::update()
{
  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->update();
  }
 
  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->swap();
  }
}

update()函數如今更新全部的角色,而後 交換它們的狀態。 最終結果是,角色在實際被扇以後的那幀才能看到巴掌。 這樣一來,角色不管在舞臺數組中如何排列,都會保持相同的行爲。 不管外部的代碼如何調用,全部的角色在一幀內同時更新。

設計決策

雙緩衝很直觀,咱們上面看到的例子也覆蓋了大多數你須要的場景。 使用這個模式以前,還須要作兩個主要的設計決策。

緩衝區是如何被交換的?

交換操做是整個過程的最重要的一步, 由於在其發生時,咱們必須鎖住兩個緩衝區上的讀取和修改。 爲了讓性能最優,咱們須要它進行得越快越好。

  • 交換緩衝區的指針或者引用: 這是咱們圖形例子中的作法,這也是大多數雙緩衝圖形通用的解決方法。
    • 速度快。 無論緩衝區有多大,交換都只需賦值一對指針。很難在速度和簡易性上超越它。
    • 外部代碼不能存儲對緩存的永久指針。 這是主要限制。 因爲咱們沒有真正地移動數據,本質上作的是週期性地通知代碼庫的其餘部分到別處去尋找緩存, 就像前面的舞臺類比同樣。這就意味着代碼庫的其餘部分不能存儲指向緩衝區中數據的指針—— 它一段時間後可能就指向了錯誤的部分。

這會嚴重誤導那些期待緩衝幀永遠在內存中的固定地址的顯卡驅動。在這種狀況下,咱們不能這麼作。

    • 緩衝區中的數據是兩幀以前的數據,而不是上一幀的數據。 接下來的那幀繪製在幀緩衝區上,而不是在它們之間拷貝數據,就像這樣:
    • Frame 1 drawn n buffer A
    • Frame 2 drawn n buffer B
    • Frame 3 drawn n buffer A
    • ...

你會注意到,當咱們繪製第三幀時,緩衝區上的數據是第一幀的,而不是第二幀的。大多數狀況下,這不是什麼問題——咱們一般在繪製以前清空整個幀。但若是想沿用某些緩存中已有的數據,就須要考慮數據其實比指望的更舊。

舊幀中緩存數據的經典用法是模擬動態模糊。 當前的幀混合一點以前的幀,看起來更像真實的相機捕獲的圖景。

  • 在緩衝區之間拷貝數據: 若是咱們不能重定向到其餘緩存,惟一的選項就是將下幀的數據實實在在的拷貝到如今這幀上。 這是咱們的扇巴掌喜劇的工做方法。 這種狀況下,使用這種方法是由於拷貝狀態——一個簡單的布爾標識——不比修改指向緩存的指針開銷大。
    • 下一幀的數據和以前的數據相差一幀。 拷貝數據與在兩塊緩衝區間跳來跳去正相反。 若是咱們須要前一幀的數據,這樣咱們能夠處理更新的數據。
    • 交換也許更花時間。 這個固然是最大的缺點。交換操做如今意味着在內存中拷貝整個緩衝區。 若是緩衝區很大,好比一整個緩衝幀,這須要花費可觀的時間。 因爲交換時沒有東西能夠讀取或者寫入任何一個緩衝區,這是一個巨大的限制。

緩衝的粒度如何?

這裏的另外一個問題是緩衝區自己是如何組織的——是單個數據塊仍是散佈在對象集合中? 圖形例子是前一種,而角色例子是後一種。

大多數狀況下,你緩存的方式天然而然會引導你找到答案,可是這裏也有些靈活度。 好比,角色總能將消息存在獨立的消息塊中,使用索引來引用。

  • 若是緩存是一整塊:
    • 交換操做更簡單。 因爲只有一對緩存,一個簡單的交換就完成了。 若是能夠改變指針來交換,那麼沒必要在乎緩衝區大小,只需幾部操做就能夠交換整個緩衝區。
  • 若是不少對象都持有一塊數據:
    • 交換操做更慢。 爲了交換,須要遍歷整個對象集合,通知每一個對象交換。

在喜劇的例子中,這沒問題,由於反正須要清除被扇狀態 ——每塊緩存的數據每幀都須要接觸。 若是不須要接觸較舊的幀,能夠用經過在多個對象間分散狀態來優化,得到使用整塊緩存同樣的性能。

思路是將當前下一指針概念,將它們改成對象相關的偏移量。就像這樣:

class Actor
{
public:
  static void init() { current_ = 0; }
  static void swap() { current_ = next(); }
 
  void slap()        { slapped_[next()] = true; }
  bool wasSlapped()  { return slapped_[current_]; }
 
private:
  static int current_;
  static int next()  { return 1 - current_; }
 
  bool slapped_[2];
};

角色使用current_在狀態數組中查詢,得到當前的被扇狀態, 下一狀態老是數組中的另外一索引,這樣能夠用next()來計算。 交換狀態只需改動current_索引。 聰明之處在於swap()如今是靜態函數,它只需被調用一次,每一個 角色的狀態都會被交換。

參見

  • 你能夠在幾乎每一個圖形API中找到雙緩衝模式。舉個例子,OpenGLswapBuffers()Direct3D」swap chains」, MicrosoftXNA框架有endDraw()方法。

3.2遊戲循環

遊戲設計模式Sequencing Patterns

意圖

將遊戲的進行和玩家的輸入解耦,和處理器速度解耦。

動機

若是本書中有一個模式不可或缺,那非這個模式莫屬了。 遊戲循環是遊戲編程模式的精髓。 幾乎每一個遊戲都有,兩兩不一樣,而在非遊戲的程序幾乎沒有使用。

爲了看看它多有用,讓咱們快速緬懷一遍往事。 在每一個編寫計算機程序的人都留着鬍子的時代,程序像洗碗機同樣工做。 你輸入一堆代碼,按個按鈕,等待,而後得到結果,完成。 程序全都是批處理模式——一旦工做完成,程序就中止了。

Ada Lovelace和Rear Admiral Grace Hopper是女程序員,並無鬍子。

你在今日仍然能看到這些程序,雖然感謝上天,咱們沒必要在打孔紙上面編寫它們了。 終端腳本,命令行程序,甚至將Markdown翻譯成這本書的Python腳本都是批處理程序。

採訪CPU

最終,程序員意識到將批處理代碼留在計算辦公室,等幾個小時後拿到結果才能開始找程序漏洞的方式實在低效。 他們想要當即的反饋。交互式 程序誕生了。 第一批交互式程序中就有遊戲:

YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK
BUILDING . AROUND YOU IS A FOREST. A SMALL
STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY.
 
> GO IN
YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING.

這是Colossal Cave Adventure,史上首個冒險遊戲。

你能夠和這個程序進行實時交互。 它等待你的輸入,而後進行響應。 你再輸入,這樣一唱一和,就像相聲同樣。 當輪到你時,它停在那裏啥也不作。像這樣:

while (true)
{
  char* command = readCommand();
  handleCommand(command);
}

這程序會永久循環,因此無法退出遊戲。 真實的遊戲會作些while (!done)進行檢查,而後經過設置done爲真來退出遊戲。 我省去了那些內容,保持簡明。

事件循環

若是你剝開現代的圖形UI的外皮,會驚訝地發現它們與老舊的冒險遊戲差很少。 文本處理器一般呆在那裏什麼也不作,直到你按了個鍵或者點了什麼東西:

while (true)
{
  Event* event = waitForEvent();
  dispatchEvent(event);
}

這與冒險遊戲主要的不一樣是,程序不是等待文本指令,而是等待用戶輸入事件——鼠標點擊、按鍵按下之類的。 其餘部分仍是和之前的老式文本冒險遊戲同樣,程序阻塞等待用戶的輸入,這是個問題。

不像其餘大多數軟件,遊戲即便在沒有玩家輸入時也繼續運行。 若是你站在那裏看着屏幕,遊戲不會凍結。動畫繼續動着。視覺效果繼續閃爍。 若是運氣很差的話,怪物會繼續吞噬英雄。

事件循環有「空轉」事件,這樣你能夠無需用戶輸入間歇地作些事情。 這對於閃爍的光標或者進度條已經足夠了,但對於遊戲就太原始了。

這是真實遊戲循環的第一個關鍵部分:它處理用戶輸入,可是不等待它。循環老是繼續旋轉:

while (true)
{
  processInput();
  update();
  render();
}

咱們以後會改善它,可是基本的部分都在這裏了。 processInput()處理上次調用到如今的任何輸入。 而後update()讓遊戲模擬一步。 運行AI和物理(一般是這種順序)。 最終,render()繪製遊戲,這樣玩家能夠看到發生了什麼。

就像你能夠從名字中猜到的,update()是使用更新方法模式的好地方。

時間以外的世界

若是這個循環沒有由於輸入而阻塞,這就帶來了明顯的問題,要運轉多快呢? 每次進行遊戲循環都會推進必定的遊戲狀態的發展。 在遊戲世界的居民看來,他們手上的表就會滴答一下。

運行遊戲循環一次的經常使用術語就是「滴答」(tick)和「幀」(frame)。

同時,玩家的真實手錶也在滴答着。 若是咱們用實際時間來測算遊戲循環運行的速度,就獲得了遊戲的幀率」(FPS) 若是遊戲循環的更快,FPS就更高,遊戲運行得更流暢、更快。 若是循環得過慢,遊戲看上去就像是慢動做電影。

咱們如今寫的這個循環是能轉多快轉多快,兩個因素決定了幀率。 一個是每幀要作多少工做。複雜的物理,衆多遊戲對象,圖形細節都讓CPUGPU繁忙,這決定了須要多久能完成一幀。

另外一個是底層平臺的速度。 更快的芯片能夠在一樣的時間裏執行更多的代碼。 多核,GPU組,獨立聲卡,以及系統的調度都影響了在一次滴答中可以作多少東西。

每秒的幀數

在早期的視頻遊戲中,第二個因素是固定的。 若是你爲NES或者Apple IIe寫遊戲,你明確知道遊戲運行在什麼CPU上。 你能夠(也必須)爲它特製代碼。 你只需擔心第一個因素:每次滴答要作多少工做。

早期的遊戲被仔細地編碼,一幀只作必定的工做,開發者能夠讓遊戲以想要的速率運行。 可是若是你想要在快些或者慢些的機器上運行同一遊戲,遊戲自己就會加速或減速。

這就是爲何老式計算機一般有「turbo」按鈕。 新的計算機運行得太快了,沒法玩老遊戲,由於遊戲也會運行得過快。 關閉 turbo按鈕,會減慢計算機的運行速度,就能夠運行老遊戲了。

如今,不多有開發者能夠奢侈地知道遊戲運行的硬件條件。遊戲必須自動適應多種設備。

這就是遊戲循環的另外一個關鍵任務:無論潛在的硬件條件,以固定速度運行遊戲。

模式

一個遊戲循環在遊玩中不斷運行。 每一次循環,它無阻塞地處理玩家輸入更新遊戲狀態渲染遊戲 它追蹤時間的消耗並控制遊戲的速度。

什麼時候使用

使用錯誤的模式比不使用模式更糟,因此這節一般告誡你不要過於熱衷設計模式。 設計模式的目標不是往代碼庫裏儘量的塞東西。

可是這個模式有所不一樣。我能夠很自信的說你使用這個模式。 若是你使用遊戲引擎,你不須要本身編寫,可是它還在那裏。

對於我而言,這是「引擎」與「庫」的不一樣之處。 使用庫時,你擁有遊戲循環,調用庫代碼。 使用引擎時,引擎擁有遊戲循環,調用你的代碼。

你可能認爲在作回合制遊戲時不須要它。 可是哪怕是那裏,就算遊戲狀態到玩家回合才改變,視覺聽覺 狀態仍會改變。 哪怕遊戲在等待你進行你的回合,動畫和音樂也會繼續運行。

記住

咱們這裏談到的循環是遊戲代碼中最重要的部分。 有人說程序會花費90%的時間在10%的代碼上。 遊戲循環代碼確定在這10%中。 你必須當心謹慎,時時注意效率。

「真正的」工程師,好比機械或電子工程師,不把咱們當回事,大概就是由於咱們像這樣使用統計學。

你也許須要與平臺的事件循環相協調

若是你在操做系統的頂層或者有圖形UI和內建事件循環的平臺上構建遊戲, 那你就有了兩個應用循環在同時運做。 它們須要很好地協調。

有時候,你能夠進行控制,只運行你的遊戲循環。 舉個例子,若是捨棄了Windows的珍貴APImain()能夠只用遊戲循環。 其中你能夠調用PeekMessage()來處理和分發系統的事件。 不像GetMessage()PeekMessage()不會阻塞等待用戶輸入, 所以你的遊戲循環會保持運做。

其餘的平臺不會讓你這麼輕鬆地擺脫事件循環。 若是你使用網頁瀏覽器做爲平臺,事件循環已被內建在瀏覽器的執行模型深處。 這樣,你得用事件循環做爲遊戲循環。 你會調用requestAnimationFrame()之類的函數,它會回調你的代碼,保持遊戲繼續運行。

示例代碼

在如此長的介紹以後,遊戲循環的代碼實際上很直觀。 咱們會瀏覽一堆變種,比較它們的好處和壞處。

遊戲循環驅動了AI,渲染和其餘遊戲系統,但這些不是模式的要點, 因此咱們會調用虛構的方法。在實現了render()update()以後, 剩下的做爲給讀者的練習(挑戰!)。

跑,能跑多快跑多快

咱們已經見過了多是最簡單的遊戲循環:

while (true)
{
  processInput();
  update();
  render();
}

它的問題是你不能控制遊戲運行得有多快。 在快速機器上,循環會運行得太快,玩家看不清發生了什麼。 在慢速機器上,遊戲慢的跟在爬同樣。 若是遊戲的一部分有大量內容或者作了不少AI或物理運算,遊戲就會慢一些。

休息一下

咱們看看增長一個簡單的小修正如何。 假設你想要你的遊戲以60FPS運行。這樣每幀大約16毫秒。 只要你用少於這個的時長進行遊戲全部的處理和渲染,就能夠以穩定的幀率運行。 你須要作的就是處理這一幀而後等待,直處處理下一幀的時候,就像這樣:

代碼看上去像這樣:

1000 毫秒 / 幀率 = 毫秒每幀.

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();
 
  sleep(start + MS_PER_FRAME - getCurrentTime());
}

若是它很快地處理完一幀,這裏的sleep()保證了遊戲不會運行太 若是你的遊戲運行太,這無濟於事。 若是須要超過16ms來更新並渲染一幀,休眠的時間就變成了負的 若是計算機能回退時間,不少事情就很容易了,可是它不能。

相反,遊戲變慢了。 能夠經過每幀少作些工做來解決這個問題——減小物理效果和絢麗光影,或者把AI變笨。 可是這影響了那些有快速機器的玩家的遊玩體驗。

一小步,一大步

讓咱們嘗試一些更加複雜的東西。咱們擁有的問題基本上是:

  1. 每次更新將遊戲時間推進一個固定量。
  2. 這消耗必定量的真實時間來處理它。

若是第二步消耗的時間超過第一步,遊戲就變慢了。 若是它須要超過16ms來推進遊戲時間16ms,那它永遠也跟不上。 可是若是一步中推進遊戲時間超過16ms,那咱們能夠減小更新頻率,就能夠跟得上了。

接着的思路是基於上幀到如今有多少真實時間流逝來選擇前進的時間。 這一幀花費的時間越長,遊戲的間隔越大。 它總能跟上真實時間,由於它走的步子愈來愈大。 有人稱之爲變化的或者流動的時間間隔。它看上去像是:

double lastTime = getCurrentTime();
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - lastTime;
  processInput();
  update(elapsed);
  render();
  lastTime = current;
}

每一幀,咱們計算上次遊戲更新到如今有多少真實時間過去了(即變量elapsed)。 當咱們更新遊戲狀態時將其傳入。 而後遊戲引擎讓遊戲世界推動必定的時間量。

假設有一顆子彈跨過屏幕。 使用固定的時間間隔,在每一幀中,你根據它的速度移動它。 使用變化的時間間隔,你根據過去的時間拉伸速度 隨着時間間隔增長,子彈在每幀間移動得更遠。 不管是二十個快的小間隔仍是四個慢的大間隔,子彈在真實時間裏移動一樣多的距離。 這看上去成功了:

  • 遊戲在不一樣的硬件上以固定的速度運行。
  • 使用高端機器的玩家得到了更流暢的遊戲體驗。

但悲劇的是,這裏有一個嚴重的問題: 遊戲再也不是肯定的了,也再也不穩定。 這是咱們給本身挖的一個坑:

「肯定的」表明每次你運行程序,若是給了它一樣的輸入,就得到一樣的輸出。 能夠想獲得,在肯定的程序中追蹤漏洞更容易——一旦找到形成漏洞的輸入,每次你都能重現之。

計算機自己是肯定的;它們機械地執行程序。 在紛亂的真實世界攙合進來,非肯定性就出現了。 例如,網絡,系統時鐘,線程調度都依賴於超出程序控制的外部世界。

假設咱們有個雙人聯網遊戲,Fred的遊戲機是臺性能猛獸,而George正在使用他祖母的老爺機。 前面提到的子彈在他們的屏幕上飛行。 Fred的機器上,遊戲跑得超級快,每一個時間間隔都很小。 好比,咱們塞了50幀在子彈穿過屏幕的那一秒。 可憐的George的機器只能塞進大約5幀。

這就意味着在Fred的機器上,物理引擎每秒更新50次位置,可是George的只更新5次。 大多數遊戲使用浮點數,它們有舍入偏差 每次你將兩個浮點數加在一塊兒,得到的結果就會有點誤差。 Fred的機器作了10倍的操做,因此他的偏差要比George的更大。 一樣 的子彈最終在他們的機器上到了不一樣的位置

這是使用變化時間可引發的問題之一,還有更多問題呢。 爲了實時運行,遊戲物理引擎作的是實際機制法則的近似。 爲了不飛天遁地,物理引擎添加了阻尼。 這個阻尼運算被當心地安排成以固定的時間間隔運行。 改變了它,物理就再也不穩定。

「飛天遁地」在這裏使用的是它的字面意思。當物理引擎卡住,對象得到了徹底錯誤的速度,就會飛到天上或者掉入地底。

這種不穩定性太糟了,這個例子在這裏的惟一緣由是做爲警示寓言,引領咱們到更好的東西……

追逐時間

遊戲中渲染一般不會被動態時間間隔影響到。 因爲渲染引擎表現的是時間上的一瞬間,它不會計算上次到如今過了多久。 它只是將當前事物渲染在所在的地方。

這或多或少是成立的。像動態模糊的東西會被時間間隔影響,但若是有一點延遲,玩家一般也不會注意到。

咱們能夠利用這點。 以固定的時間間隔更新遊戲,由於這讓全部事情變得簡單,物理和AI也更加穩定。 可是咱們容許靈活調整渲染的時刻,釋放一些處理器時間。

它像這樣運做:自上一次遊戲循環過去了必定量的真實時間。 須要爲遊戲的當前時間模擬推動相同長度的時間,以追上玩家的時間。 咱們使用一系列固定時間步長。 代碼大體以下:

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;
 
  processInput();
 
  while (lag >= MS_PER_UPDATE)
  {
    update();
    lag -= MS_PER_UPDATE;
  }
 
  render();
}

這裏有幾個部分。 在每幀的開始,根據過去了多少真實的時間,更新lag 這個變量代表了遊戲世界時鐘比真實世界落後了多少,而後咱們使用一個固定時間步長的內部循環進行追趕。 一旦咱們追上真實時間,咱們就渲染而後開始新一輪循環。 你能夠將其畫成這樣:

注意這裏的時間步長不是視覺上的幀率了。 MS_PER_UPDATE只是咱們更新遊戲的間隔 這個間隔越短,就須要越多的處理次數來追上真實時間。 它越長,遊戲抖動得越厲害。 理想上,你想要它足夠短,一般快過60FPS,這樣遊戲在高速機器上會有高效的表現。

可是當心不要把它整得短了。 你須要保證即便在最慢的機器上,這個時間步長也超過處理一次update()的時間。 不然,你的遊戲就跟不上現實時間了。

我不會詳談這個,但你能夠經過限定內層循環的最大次數來保證這一點。 遊戲會變慢,可是比徹底卡死要好。

幸運的是,咱們給本身了一些喘息的空間。 技巧在於咱們將渲染拉出了更新循環 這釋放了一大塊CPU時間。 最終結果是遊戲以固定時間步長模擬,該時間步長與硬件不相關。 只是使用低端硬件的玩家看到的內容會有抖動。

卡在中間

咱們還剩一個問題,就是剩下的延遲。 以固定的時間步長更新遊戲,在任意時刻渲染。 這就意味着從玩家的角度看,遊戲常常在兩次更新之間時顯示。

這是時間線:

就像你看到的那樣,咱們以緊湊固定的時間步長進行更新。 同時,咱們在任何可能的時候渲染。 它比更新發生得要少,並且也不穩定。 二者都沒問題。糟糕的是,咱們不總能在正確的時間點渲染。 看看第三次渲染時間。它發生在兩次更新之間。

想象一顆子彈飛過屏幕。第一次更新時,它在左邊。 第二次更新將它移到了右邊。 這個遊戲在兩次更新之間的時間點渲染,因此玩家指望看到子彈在屏幕的中間。 而如今的實現中,它還在左邊。這意味着看上去移動發生了卡頓。

方便的是,咱們實際知道渲染時距離兩次更新的時間:它被存儲在lag中。 咱們在lag比更新時間間隔小時,而不是lag時,跳出循環進行渲染。 lag的剩餘量?那就是到下一幀的時間。

當咱們要渲染時,咱們將它傳入:

render(lag / MS_PER_UPDATE);

咱們在這裏除以MS_PER_UPDATE歸一化值。 無論更新的時間步長是多少,傳給render()的值總在0(恰巧在前一幀)到1.0(恰巧在下一幀)之間。 這樣,渲染引擎沒必要擔憂幀率。它只需處理0到1的值。

渲染器知道每一個遊戲對象以及它當前的速度 假設子彈在屏幕左邊20像素的地方,正在以400像素每幀的速度向右移動。 若是在兩幀正中渲染,咱們會給render()0.5 它繪製了半幀以前的圖形,在220像素,啊哈,平滑的移動。

固然,也許這種推斷是錯誤的。 在咱們計算下一幀時,也許會發現子彈碰撞到另外一障礙,或者減速,又或者別的什麼。 咱們只是在上一幀位置和咱們認爲的下一幀位置之間插值。 但只有在完成物理和AI更新後,咱們才能知道真正的位置。

因此推斷有猜想的成分,有時候結果是錯誤的。 可是,幸運地,這種修正一般不可感知。 最起碼,比你不使用推斷致使的卡頓更不明顯。

設計決策

雖然這章我講了不少,可是有更多的東西我沒講。 一旦你考慮顯示刷新頻率的同步,多線程,多GPU,真正的遊戲循環會變得更加複雜。 即便在高層,這裏還有一些問題須要你回答:

擁有遊戲循環的是你,仍是平臺?

這個選擇一般是已經由平臺決定的。 若是你在作瀏覽器中的遊戲,極可能你不能編寫本身的經典遊戲循環。 瀏覽器自己的事件驅動機制阻礙了這一點。 相似地,若是你使用現存的遊戲引擎,你極可能依賴於它的遊戲循環而不是本身寫一個。

  • 使用平臺的事件循環:
    • 簡單。你沒必要擔憂編寫和優化本身的遊戲核心循環。
    • 平臺友好。 你沒必要明確地給平臺一段時間讓它處理它本身的事件,沒必要緩存事件,沒必要管理任何平臺輸入模型和你的不匹配之處。
    • 你失去了對時間的控制。 平臺會在它方便時調用代碼。 若是這不如你想要的那樣平滑或者頻繁,太糟了。 更糟的是,大多數應用的事件循環並未爲遊戲設計,一般又慢又卡頓。
  • 使用遊戲引擎的循環:
    • 沒必要本身編寫。 編寫遊戲循環很是須要技巧。 因爲是每幀都要執行的核心代碼,小小的漏洞或者性能問題就對遊戲有巨大的影響。 穩固的遊戲循環是使用現有引擎的緣由之一。
    • 沒必要本身編寫。 固然,硬幣的另外一面是,若是引擎沒法知足你真正的需求,你也無法得到控制權。
  • 本身寫:
    • 徹底的控制。 你能夠作任何想作的事情。你能夠爲遊戲的需求訂製開發。
    • 你須要與平臺交互。 應用框架和操做系統一般須要時間片去處理本身的事件和其餘工做。 若是你擁有應用的核心循環,平臺就沒有這些時間片了。 你得顯式按期檢查,保證框架沒有掛起或者混亂。

如何管理能量消耗?

在五年前這還不是問題。 遊戲運行在插到插座上的機器上或者專用的手持設備上。 可是隨着智能手機,筆記本以及移動遊戲的發展,如今須要關注這個問題了。 畫面絢麗,但會耗幹三十分鐘前充的電,並將手機變成空間加熱器的遊戲,可不能讓人開心。

如今,你須要考慮的不只僅是讓遊戲看上去很棒,同時也要儘量少地使用CPU 你須要設置一個性能的上限:完成一幀以內所需的工做後,讓CPU休眠。

  • 儘量快地運行:

這是PC遊戲的常態(即便愈來愈多的人在筆記本上運行遊戲)。 遊戲循環永遠不會顯式告訴系統休眠。相反,空閒的循環被劃在提高FPS或者圖像顯示效果上了。

這會給你最好的遊戲體驗。 可是,也會盡量多地使用電量。若是玩家在筆記本電腦上游玩,他們就獲得了一個很好的加熱器。

  • 固定幀率

移動遊戲更加註意遊戲的體驗質量,而不是最大化圖像畫質。 不少這種遊戲都會設置最大幀率(一般是3060FPS)。 若是遊戲循環在分配的時間片消耗完以前完成,剩餘的時間它會休眠。

這給了玩家足夠好的遊戲體驗,也讓電池輕鬆了一點。

你如何控制遊戲速度?

遊戲循環有兩個關鍵部分:不阻塞用戶輸入和自適應的幀時間步長。 輸入部分很直觀。關鍵在於你如何處理時間。 這裏有數不盡的遊戲可運行的平臺, 每一個遊戲都須要在其中一些平臺上運行。 如何適應平臺的變化就是關鍵。

創做遊戲看來是人類的天性,由於每當咱們建構能夠計算的機器,首先作的就是在上面編遊戲。 PDP-1是一個僅有4096字內存的2kHz機器,可是Steve Russell和他的朋友仍是在上面建立了Spacewar!。

  • 固定時間步長,沒有同步:

見咱們第一個樣例中的代碼。你只需儘量快地運行遊戲。

    • 簡單。這是主要的(好吧,惟一的)好處。
    • 遊戲速度直接受到硬件和遊戲複雜度影響。 主要的缺點是,若是有所變化,會直接影響遊戲速度。遊戲速度與遊戲循環緊密相關。
  • 固定時間步長,有同步:

對複雜度控制的下一步是使用固定的時間間隔,但在循環的末尾增長同步點,保證遊戲不會運行得過快。

    • 仍是很簡單。 這比過於簡單以致於不可行的例子只多了一行代碼。 在多數遊戲循環中,你可能須要作一些同步。 你可能須要雙緩衝圖形並將緩衝塊與更新顯示的頻率同步。
    • 電量友好。 這對移動遊戲相當重要。你不想消耗沒必要要的電量。 經過簡單地休眠幾個毫秒而不是試圖每幀塞入更多的處理,你就節約了電量。
    • 遊戲不會運行得太快。 這解決了固定循環速度的一半問題。
    • 遊戲可能運行的太慢。 若是花了太多時間更新和渲染一幀,播放也會減緩。 由於這種方案沒有分離更新和渲染,它比更高級的方案更容易遇到這點。 無法扔掉渲染幀來追上真實時間,遊戲自己會變慢。
  • 動態時間步長:

我把這個方案放在這裏做爲問題的解決辦法之一,附加警告:大多數我認識的遊戲開發者反對它。 不過記住爲何反對它是頗有價值的。

    • 能適應並調整,避免運行得太快或者太慢。 若是遊戲不能追上真實時間,它用愈來愈長的時間步長更新,直到追上。
    • 讓遊戲不肯定並且不穩定。 這是真正的問題,固然。在物理和網絡部分使用動態時間步長會碰見更多的困難。
  • 固定更新時間步長,動態渲染:

在示例代碼中提到的最後一個選項是最複雜的,可是也是最有適應性的。 它以固定時間步長更新,可是若是須要遇上玩家的時間,能夠扔掉一些渲染幀。

    • 能適應並調整,避免運行得太快或者太慢。 只要能實時更新,遊戲狀態就不會落後於真實時間。若是玩家用高端的機器,它會回以更平滑的遊戲體驗。
    • 更復雜。 主要負面問題是須要在實現中寫更多東西。 你須要將更新的時間步長調整得儘量小來適應高端機,同時不至於在低端機上太慢。

參見

  • 關於遊戲循環的經典文章是Glenn FiedlerFix Your Timestep。若是沒有這篇文章,這章就不會是這個樣子。
  • Witters關於game loops的文章也值得閱讀。
  • Unity框架有一個複雜的遊戲循環,細節在這裏有詳盡的解釋。

3.3更新方法

遊戲設計模式Sequencing Patterns

意圖

經過每次處理一幀的行爲模擬一系列獨立對象。

動機

玩家操做強大的女武神完成考驗:從死亡巫王的棲骨之處偷走華麗的珠寶。 她嘗試接近巫王華麗的地宮門口,而後遇到了……啥也沒遇到 沒有詛咒雕像向她發射閃電,沒有不死戰士巡邏入口。 她直搗黃龍,拿走了珠寶。遊戲結束。你贏了。

好吧,這可不行。

地宮須要守衛——一些英雄能夠殺死的敵人。 首先,咱們須要一個骷髏戰士在門口先後移動巡邏。 若是無視任何關於遊戲編程的知識, 讓骷髏蹣跚着來回移動的最簡單的代碼大概是這樣的:

若是巫王想表現得更加智慧,它應創造一些仍有腦子的東西。

while (true)
{
  // 向右巡邏
  for (double x = 0; x < 100; x++)
  {
    skeleton.setX(x);
  }
 
  // 向左巡邏
  for (double x = 100; x > 0; x--)
  {
    skeleton.setX(x);
  }
}

這裏的問題,固然,是骷髏來回打轉,可玩家永遠看不到。 程序鎖死在一個無限循環,那可不是有趣的遊戲體驗。 咱們事實上想要的是骷髏每幀移動一步。

咱們得移除這些循環,依賴外層遊戲循環來迭代。 這保證了在衛士來回巡邏時,遊戲能響應玩家的輸入並進行渲染。以下:

固然,遊戲循環是本書的另外一個章節。

Entity skeleton;
bool patrollingLeft = false;
double x = 0;
 
// 遊戲主循環
while (true)
{
  if (patrollingLeft)
  {
    x--;
    if (x == 0) patrollingLeft = false;
  }
  else
  {
    x++;
    if (x == 100) patrollingLeft = true;
  }
 
  skeleton.setX(x);
 
  // 處理用戶輸入並渲染遊戲……
}

在這裏先後兩個版本展現了代碼是如何變得複雜的。 左右巡邏須要兩個簡單的for循環。 經過指定哪一個循環在執行,咱們追蹤了骷髏在移向哪一個方向。 如今咱們每幀跳出到外層的遊戲循環,而後再跳回繼續咱們以前所作的,咱們使用patrollingLeft顯式地追蹤了方向。

但或多或少這能行,因此咱們繼續。 一堆無腦的骨頭不會對你的女武神提出太多挑戰, 咱們下一個添加的是魔法雕像。它們一直會向她發射閃電球,這樣可以讓她保持移動。

繼續咱們的用最簡單的方式編碼的風格,咱們獲得了:

// 骷髏的變量……
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;
 
// 遊戲主循環:
while (true)
{
  // 骷髏的代碼……
 
  if (++leftStatueFrames == 90)
  {
    leftStatueFrames = 0;
    leftStatue.shootLightning();
  }
 
  if (++rightStatueFrames == 80)
  {
    rightStatueFrames = 0;
    rightStatue.shootLightning();
  }
 
  // 處理用戶輸入,渲染遊戲
}

你會發現這代碼漸漸滑向失控。 變量數目不斷增加,代碼都在遊戲循環中,每段代碼處理一個特殊的遊戲實體。 爲了同時訪問並運行它們,咱們將它們的代碼混雜在了一塊兒。

一旦能用「混雜」一詞描述你的架構,你就有麻煩了。

你也許已經猜到了修復這個所用的簡單模式了: 每一個遊戲實體應該封裝它本身的行爲。這保持了遊戲循環的整潔,便於添加和移除實體。

爲了作到這點須要抽象層,咱們經過定義抽象的update()方法來完成。 遊戲循環管理對象的集合,可是不知道對象的具體類型。 它只知道這些對象能夠被更新。 這樣,每一個對象的行爲與遊戲循環分離,與其餘對象分離。

每一幀,遊戲循環遍歷集合,在每一個對象上調用update() 這給了咱們在每幀上更新一次行爲的機會。 在全部對象上每幀調用它,對象就能同時行動。

死摳細節的人會在這點上揪着我不放,是的,它們沒有真的同步。 當一個對象更新時,其餘的都不在更新中。 咱們等會兒再說這點。

遊戲循環維護動態的對象集合,因此從關卡添加和移除對象是很容易的——只須要將它們從集合中添加和移除。 沒必要再用硬編碼,咱們甚至能夠用數據文件構成這個關卡,那正是咱們的關卡設計者須要的。

模式

遊戲世界管理對象集合 每一個對象實現一個更新方法模擬對象在一幀內的行爲。每一幀,遊戲循環更新集合中的每個對象。

什麼時候使用

若是遊戲循環模式是切片面包, 那麼更新方法模式就是它的奶油。 不少玩家交互的遊戲實體都以這樣或那樣的方式實現了這個模式。 若是遊戲有太空陸戰隊,火龍,火星人,鬼魂或者運動員,頗有可能它使用了這個模式。

可是若是遊戲更加抽象,移動部分不太像活動的角色而更加像棋盤上的棋子, 這個模式一般就不適用了。 在棋類遊戲中,你不須要同時模擬全部的部分, 你可能也不須要告訴棋子每幀都更新它們本身。

你也許不須要每幀更新它們的行爲,但即便是棋類遊戲, 你可能也須要每幀更新動畫。 這個設計模式也能夠幫到你。

更新方法適應如下狀況:

  • 你的遊戲有不少對象或系統須要同時運行。
  • 每一個對象的行爲都與其餘的大部分獨立。
  • 對象須要跟着時間進行模擬。

記住

這個模式很簡單,因此沒有太多值得發現的驚喜。固然,每行代碼仍是有利有弊。

將代碼劃分到一幀幀中會讓它更復雜

當你比較前面兩塊代碼時,第二塊看上去更加複雜。 二者都只是讓骷髏守衛來回移動,但與此同時,第二塊代碼將控制權交給了遊戲循環的一幀幀中。

幾乎 這個改變是遊戲循環處理用戶輸入,渲染等幾乎必需要注意的事項,因此第一個例子不大實用。 可是頗有必要記住,將你的行爲切片會增長很高的複雜性。

我在這裏說幾乎是由於有時候魚和熊掌能夠兼得。 你能夠直接爲對象編碼而不進行返回, 保持不少對象同時運行並與遊戲循環保持協調。

你須要的是容許你同時擁有多個「線程」執行的系統。 若是對象的代碼能夠在執行中暫停和繼續,而不是總得返回, 你能夠用更加命令式的方式編碼。

真實的線程太太重量級而不能這麼作, 但若是你的語言支持輕量協同架構好比generators,coroutines或者fibers,那你也許能夠使用它們。

字節碼模式是另外一個在應用層建立多個線程執行的方法。

當離開每幀時,你須要存儲狀態,以備未來繼續。

在第一個示例代碼中,咱們不須要用任何變量代表守衛在向左仍是向右移動。 這顯式的依賴於哪塊代碼正在運行。

當咱們將其變爲一次一幀的形式,咱們須要建立patrollingLeft變量來追蹤行走的方向。 當從代碼中返回時,就丟失了行走的方向,因此爲了下幀繼續,咱們須要顯式存儲足夠的信息。

狀態模式一般能夠在這裏幫忙。 狀態機在遊戲中頻繁出現的部分緣由是(就像名字暗示的),它能在你離開時爲你存儲各類你須要的狀態。

對象逐幀模擬,但並不是真的同步

在這個模式中,遊戲遍歷對象集合,更新每個對象。 update()調用中,大多數對象都可以接觸到遊戲世界的其餘部分, 包括如今正在更新的其餘對象。這就意味着你更新對象的順序相當重要。

若是對象更新列表中,AB以前,當A更新時,它會看到B以前的狀態。 可是當B更新時,因爲A已經在這幀更新了,它會看見A狀態。 哪怕按照玩家的視角,全部對象都是同時運轉的,遊戲的核心仍是回合制的。 只是完整的回合只有一幀那麼長。

若是,因爲某些緣由,你決定讓遊戲按這樣的順序更新,你須要雙緩衝模式。 那麼AB更新的順序就沒有關係了,由於雙方都會看對方以前那幀的狀態。

當關注遊戲邏輯時,這一般是件好事。 同時更新全部對象將把你帶到一些不愉快的語義角落。 想象若是國際象棋中,黑白雙方同時移動會發生什麼。 雙方都試圖同時往同一個空格子中放置棋子。這怎麼解決?

序列更新解決了這點——每次更新都讓遊戲世界從一個合法狀態增量更新到下一個,不會出現引起歧義而須要協調的部分。

這對在線遊戲也有用,由於你有了能夠在網上發送的行動指令序列。

在更新時修改對象列表需當心

當你使用這個模式時,不少遊戲行爲在更新方法中糾纏在一塊兒。 這些行爲一般包括增長和刪除可更新對象。

舉個例子,假設骷髏守衛被殺死時掉落物品。 使用新對象,你一般能夠將其增長到列表尾部,而不引發任何問題。 你會繼續遍歷這張鏈表,最終找到新的那個,而後也更新了它。

但這確實代表新對象在它產生的那幀就有機會活動,甚至有可能在玩家看到它以前。 若是你不想發生那種狀況,簡單的修復方法就是在遊戲循環中緩存列表對象的數目,而後只更新那麼多數目的對象就中止:

int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
  objects_[i]->update();
}

這裏,objects_是可更新遊戲對象的數組,而numObjects_是數組的長度。 當添加新對象時,這個數組長度變量就增長。 在循環的一開始,咱們在numObjectsThisTurn中存儲數組的長度, 這樣這幀的遍歷循環會停在新添加的對象以前。

一個更麻煩的問題是在遍歷時移除對象。 你擊敗了邪惡的野獸,如今它須要被移出對象列表。 若是它正好位於你當前更新對象以前,你會意外地跳過一個對象:

for (int i = 0; i < numObjects_; i++)
{
  objects_[i]->update();
}

這個簡單的循環經過增長索引值來遍歷每一個對象。 下圖的左側展現了在咱們更新英雄時,數組看上去是什麼樣的:

咱們在更新她時,索引值i1 邪惡野獸被她殺了,所以須要從數組移除。 英雄移到了位置0,倒黴的鄉下人移到了位置1 在更新英雄以後,i增長到了2 就像你在右圖看到的,倒黴的鄉下人被跳過了,沒有更新。

一種簡單的解決方案是在更新時從後往前遍歷列表。 這種方式只會移動已經被更新的對象。

一種解決方案是當心地移除對象,任何對象被移除時,更新索引。 另外一種是在遍歷完列表後再移除對象。 將對象標爲死亡,可是把它放在那裏。 在更新時跳過任何死亡的對象。而後,在完成遍歷後,遍歷列表並刪除屍體。

若是在更新循環中有多個線程處理對象, 那麼你可能更喜歡推遲任何修改,避免更新時同步線程的開銷。

示例代碼

這個模式太直觀了,代碼幾乎只是在重複說明要點。 這不意味着這個模式沒有用。它由於簡單而有用:這是一個無需裝飾的乾淨解決方案。

可是爲了讓事情更具體些,讓咱們看看一個基礎的實現。 咱們會從表明骷髏和雕像的Entity類開始:

class Entity
{
public:
  Entity()
  : x_(0), y_(0)
  {}
 
  virtual ~Entity() {}
  virtual void update() = 0;
 
  double x() const { return x_; }
  double y() const { return y_; }
 
  void setX(double x) { x_ = x; }
  void setY(double y) { y_ = y; }
 
private:
  double x_;
  double y_;
};

我在這裏只呈現了咱們後面所需東西的最小集合。 能夠推斷在真實代碼中,會有不少圖形和物理這樣的其餘東西。 上面這部分代碼最重要的部分是它有抽象的update()方法。

遊戲管理實體的集合。在咱們的示例中,我會把它放在一個表明遊戲世界的類中。

class World
{
public:
  World()
  : numEntities_(0)
  {}
 
  void gameLoop();
 
private:
  Entity* entities_[MAX_ENTITIES];
  int numEntities_;
};

在真實的世界程序中,你可能真的要使用集合類,我在這裏使用數組來保持簡單

如今,萬事俱備,遊戲經過每幀更新每一個實體來實現模式:

void World::gameLoop()
{
  while (true)
  {
    // 處理用戶輸入……
 
    // 更新每一個實體
    for (int i = 0; i < numEntities_; i++)
    {
      entities_[i]->update();
    }
 
    // 物理和渲染……
  }
}

正如其名,這是遊戲循環模式的一個例子。

子類化實體?!

有不少讀者剛剛起了雞皮疙瘩,由於我在Entity主類中使用繼承來定義不一樣的行爲。 若是你在這裏尚未看出問題,我會提供一些線索。

當遊戲業界從6502彙編代碼和VBLANKs轉向面向對象的語言時, 開發者陷入了對軟件架構的狂熱之中。 其中之一就是使用繼承。他們創建了遮天蔽日的高聳的拜占庭式對象層次。

最終證實這是個糟點子,沒人能夠不拆解它們來管理龐雜的對象層次。 哪怕在1994年的GoF都知道這點,並寫道:

多用對象組合,而非類繼承

只在你我間聊聊,我認爲這已是一朝被蛇咬十年怕井繩了。 我一般避免使用它,但教條地不用和教條地使用同樣糟。 你能夠適度使用,沒必要徹底禁用。

當遊戲業界都明白了這一點,解決方案是使用組件模式。 使用它,update()是實體的組件而不是在Entity中。 這讓你避開了爲了定義和重用行爲而建立實體所需的複雜類繼承層次。相反,你只需混合和組裝組件。

若是我真正在作遊戲,我也許也會那麼作。 可是這章不是關於組件的, 而是關於update()方法,最簡單,最少牽連其餘部分的介紹方法, 就是把更新方法放在Entity中而後建立一些子類。

組件模式在這裏

定義實體

好了,回到任務中。 咱們原先的動機是定義巡邏的骷髏守衛和釋放閃電的魔法雕像。 讓咱們從咱們的骷髏朋友開始吧。 爲了定義它的巡邏行爲,咱們定義恰當地實現了update()的新實體:

class Skeleton : public Entity
{
public:
  Skeleton()
  : patrollingLeft_(false)
  {}
 
  virtual void update()
  {
    if (patrollingLeft_)
    {
      setX(x() - 1);
      if (x() == 0) patrollingLeft_ = false;
    }
    else
    {
      setX(x() + 1);
      if (x() == 100) patrollingLeft_ = true;
    }
  }
 
private:
  bool patrollingLeft_;
};

如你所見,幾乎就是從早先的遊戲循環中剪切代碼,而後粘貼到Skeletonupdate()方法中。 惟一的小小不一樣是patrollingLeft_被定義爲字段而不是本地變量。 經過這種方式,它的值在update()兩次調用間保持不變。

讓咱們對雕像如法炮製:

class Statue : public Entity
{
public:
  Statue(int delay)
  : frames_(0),
    delay_(delay)
  {}
 
  virtual void update()
  {
    if (++frames_ == delay_)
    {
      shootLightning();
 
      // 重置計時器
      frames_ = 0;
    }
  }
 
private:
  int frames_;
  int delay_;
 
  void shootLightning()
  {
    // 火光效果……
  }
};

又一次,大部分改動是將代碼從遊戲循環中移動到類中,而後重命名一些東西。 可是,在這個例子中,咱們真的讓代碼庫變簡單了。 先前討厭的命令式代碼中,存在存儲每一個雕像的幀計數器和開火的速率的分散的本地變量。

如今那些都被移動到了Statue類中,你能夠想建立多少就建立多少實例了, 每一個實例都有它本身的小計時器。 這是這章背後的真實動機——如今爲遊戲世界增長新實體會更加簡單, 由於每一個實體都帶來了它須要的所有東西。

這個模式讓咱們分離了遊戲世界的構建實現 這一樣能讓咱們靈活地使用分散的數據文件或關卡編輯器來構建遊戲世界。

還有人關心UML嗎?若是還有,那就是咱們剛剛建的。

傳遞時間

這是模式的關鍵,可是我只對經常使用的部分進行了細化。 到目前爲止,咱們假設每次對update()的調用都推進遊戲世界前進一個固定的時間。

我更喜歡那樣,可是不少遊戲使用可變時間步長 在那種狀況下,每次遊戲循環推動的時間長度或長或短, 具體取決於它須要多長時間處理和渲染前一幀。

遊戲循環一章討論了更多關於固定和可變時間步長的優劣。

這意味着每次update()調用都須要知道虛擬的時鐘轉動了多少, 因此你常常能夠看到傳入消逝的時間。 舉個例子,咱們能夠讓骷髏衛士像這樣處理變化的時間步長:

void Skeleton::update(double elapsed)
{
  if (patrollingLeft_)
  {
    x -= elapsed;
    if (x <= 0)
    {
      patrollingLeft_ = false;
      x = -x;
    }
  }
  else
  {
    x += elapsed;
    if (x >= 100)
    {
      patrollingLeft_ = true;
      x = 100 - (x - 100);
    }
  }
}

如今,骷髏衛士移動的距離隨着消逝時間的增加而增加。 也能夠看出,處理變化時間步長鬚要的額外複雜度。 若是一次須要更新的時間步長過長,骷髏衛士也許就超過了其巡邏的範圍,所以須要當心的處理。

設計決策

在這樣簡單的模式中,沒有太多的調控之處,可是這裏仍有兩個你須要決策的地方:

更新方法在哪一個類中?

最明顯和最重要的決策就是決定將update()放在哪一個類中。

  • 實體類中:

若是你已經有實體類了,這是最簡單的選項, 由於這不會帶來額外的類。若是你須要的實體種類很少,這也許可行,可是業界已經逐漸遠離這種作法了。

當類的種類不少時,一有新行爲就建Entity子類來實現是痛苦的。 當你最終發現你想要用單一繼承的方法重用代碼時,你就卡住了。

  • 組件類:

若是你已經使用了組件模式,你知道這個該怎麼作。 這讓每一個組件獨立更新它本身。 更新方法用了一樣的方法解耦遊戲中的實體,組件讓你進一步解耦了單一實體中的各部分 渲染,物理,AI均可以自顧自了。

  • 委託類:

還可將類的部分行爲委託給其餘的對象。 狀態模式能夠這樣作,你能夠經過改變它委託的對象來改變它的行爲。 類型對象模式也這樣作了,這樣你能夠在同實體間分享行爲。

若是你使用了這些模式,將update()放在委託類中是很天然的。 在那種狀況下,也許主類中仍有update()方法,可是它不是虛方法,能夠簡單地委託給委託對象。就像這樣:

void Entity::update()
{
  // 轉發給狀態對象
  state_->update();
}

這樣作容許你改變委託對象來定義新行爲。就像使用組件,這給了你無須定義全新的子類就能改變行爲的靈活性。

如何處理隱藏對象?

遊戲中的對象,無論什麼緣由,可能暫時無需更新。 它們多是停用了,或者超出了屏幕,或者尚未解鎖。 若是狀態中的這種對象不少,每幀遍歷它們卻什麼都不作是在浪費CPU循環。

一種方法是管理單獨的活動對象集合,它存儲真正須要更新的對象。 當一個對象停用時,從那個集合中移除它。當它啓用時,再把它添加回來。 用這種方式,你只須要迭代那些真正須要更新的東西:

  • 若是你使用單個包括了全部不活躍對象的集合:
    • 浪費時間。對於不活躍對象,你要麼檢查一些是否啓用的標識,要麼調用一些啥都不作的方法。

檢查對象啓用與否而後跳過它,不但消耗了CPU循環,還報銷了你的數據緩存。 CPU經過從RAM上讀取數據到緩存上來優化讀取。 這樣作是基於剛剛讀取內存以後的內存部分極可能等會兒也會被讀取到這個假設。

當你跳過對象,你可能越過了緩存的尾部,強迫它從緩慢的主存中再取一塊。

  • 若是你使用單獨的集合保存活動對象:
    • 使用了額外的內存管理第二個集合。 當你須要全部實體時,一般又須要一個巨大的集合。在那種狀況下,這集合是多餘的。 在速度比內存要求更高的時候(一般如此),這取捨還是值得的。

另外一個權衡後的選擇是使用兩個集合,除了活動對象集合的另外一個集合只包含不活躍實體而不是所有實體。

    • 得保持集合同步。 當對象建立或徹底銷燬時(不是暫時停用),你得修改所有對象集合和活躍對象集合。

方法選擇的度量標準是不活躍對象的可能數量。 數量越多,用分離的集合避免在覈心遊戲循環中用到它們就更有用。

參見

  • 這個模式,以及遊戲循環模式和組件模式,是構建遊戲引擎核心的三位一體。
  • 當你關注在每幀中更新實體或組件的緩存性能時,數據局部性模式能夠讓它跑到更快。
  • Unity框架在多個類中使用了這個模式,包括 MonoBehaviour
  • 微軟的XNA平臺在 Game  GameComponent 類中使用了這個模式。
  • Quintus,一個JavaScript遊戲引擎在它的主Sprite類中使用了這個模式。

 

第四章 行爲模式

遊戲設計模式

一旦作好遊戲設定,在裏面裝滿了角色和道具,剩下的就是啓動場景。 爲了完成這點,你須要行爲——告訴遊戲中每一個實體作什麼的劇本。

固然,全部代碼都是行爲,而且全部軟件都是定義行爲的, 但在遊戲中有所不一樣的是,行爲一般很 文字處理器也許有很長的特性清單, 但特性的數量與角色扮演遊戲中的居民,物品和任務數量相比,那就相形見絀了。

本章的模式有助於快速定義和完善大量的行爲。 類型對象定義行爲的類別而無需完成真正的類。 子類沙盒定義各類行爲的安全原語。 最早進的是字節碼,將行爲從代碼中分離,放入數據文件中。

模式

4.1字節碼

遊戲設計模式Behavioral Patterns

意圖

將行爲編碼爲虛擬機器上的指令,賦予其數據的靈活性。

動機

製做遊戲也許頗有趣,但毫不容易。 現代遊戲的代碼庫非常龐雜。 主機廠商和應用市場有嚴格的質量要求, 小小的崩潰漏洞就能阻止遊戲發售。

我曾參與製做有六百萬行C++代碼的遊戲。做爲對比,控制好奇號火星探測器的軟件尚未其一半大小。

與此同時,咱們但願榨乾平臺的每一點性能。 遊戲對硬件發展的推進數一數二,只有堅持不懈地優化才能跟上競爭。

爲了保證穩定和性能的需求,咱們使用如C++這樣的重量級的編程語言,它既有能兼容多數硬件的底層表達能力,又擁有防止漏洞的強類型系統。

咱們對本身的專業技能充滿自信,但其亦有代價。 專業程序員須要多年的訓練,以後又要對抗代碼規模的增加。 構建大型遊戲的時間長度能夠在喝杯咖啡烤咖啡豆,手磨咖啡豆,弄杯espresso,打奶泡,在拿鐵咖啡里拉花。之間變更。

除開這些挑戰,遊戲還多了個苛刻的限制:樂趣 玩家須要仔細權衡過的新奇體驗。 這須要不斷的迭代,可是若是每一個調整都須要讓工程師修改底層代碼,而後等待漫長的編譯結束,那就毀掉了創做流程。

法術戰鬥!

假設咱們在完成一個基於法術的格鬥遊戲。 兩個敵對的巫師互相丟法術,直到分出勝負。 咱們能夠將這些法術都定義在代碼中,但這就意味着每次修改法術都會牽扯到工程師。 當設計者想修改幾個數字感受一下效果,就要從新編譯整個工程,重啓,而後進入戰鬥。

像如今的許多遊戲同樣,咱們也須要在發售以後更新遊戲,修復漏洞或是添加新內容。 若是全部法術都是硬編碼的,那麼每次修改都意味着要給遊戲的可執行文件打補丁。

再扯遠一點,假設咱們還想支持模組。咱們想讓玩家創造本身的法術。 若是這些法術都是硬編碼的,那就意味着每一個模組製造者都得擁有編譯遊戲的整套工具鏈, 咱們也就不得不開放源代碼,若是他們的自創法術上有個漏洞,那麼就會把其餘人的遊戲也搞崩潰。

數據 > 代碼

很明顯實現引擎的編程語言不是個好選擇。 咱們須要將法術放在與遊戲核心隔絕的沙箱中。 咱們想要它們易於修改,易於加載,並與其餘可執行部分相隔離。

我不知道你怎麼想,但這聽上去讓我以爲有點像是數據 若是能在分離的數據文件中定義行爲,遊戲引擎還能加載並執行它們,就能夠實現全部目標。

這裏須要指出執行對於數據的意思。如何讓文件中的數據表示爲行爲呢?這裏有幾種方式。 解釋器模式對比着看會好理解些。

解釋器模式

關於這個模式我就能寫整整一章,可是有四個傢伙的工做早涵蓋了這一切, 因此,這裏給一些簡短的介紹。

它源於一種你想要執行的語言——想一想編程語言。

好比,它支持這樣的算術表達式

(1 + 2) * (3 - 4)

而後,把每塊表達式,每條語言規則,都裝到對象中去。數字字面量都變成對象:

簡單地說,它們在原始值上作了個小封裝。 運算符也是對象,它們擁有操做數的引用。 若是你考慮了括號和優先級,那麼表達式就魔術般變成這樣的小樹:

這裏的「魔術」是什麼?很簡單——語法分析。 語法分析器接受一串字符做爲輸入,將其轉爲抽象語法樹,即一個包含了表示文本語法結構的對象集合。

完成這個你就獲得了半個編譯器。

解釋器模式與建立這棵樹無關,它只關於執行這棵樹。 它工做的方式很是巧妙。樹中的每一個對象都是表達式或子表達式。 用真正面向對象的方式描述,咱們會讓表達式本身對本身求值。

首先,咱們定義全部表達式都實現的基本接口:

class Expression
{
public:
  virtual ~Expression() {}
  virtual double evaluate() = 0;
};

而後,爲咱們語言中的每種語法定義一個實現這個接口的類。最簡單的是數字:

class NumberExpression : public Expression
{
public:
  NumberExpression(double value)
  : value_(value)
  {}
 
  virtual double evaluate()
  {
    return value_;
  }
 
private:
  double value_;
};

一個數字表達式就等於它的值。加法和乘法有點複雜,由於它們包含子表達式。在一個表達式計算本身的值以前,必須先遞歸地計算其子表達式的值。像這樣:

class AdditionExpression : public Expression
{
public:
  AdditionExpression(Expression* left, Expression* right)
  : left_(left),
    right_(right)
  {}
 
  virtual double evaluate()
  {
    // 計算操做數
    double left = left_->evaluate();
    double right = right_->evaluate();
 
    // 把它們加起來
    return left + right;
  }
 
private:
  Expression* left_;
  Expression* right_;
};

你確定能想明白乘法的實現是什麼樣的。

很優雅對吧?只需幾個簡單的類,如今咱們能夠表示和計算任意複雜的算術表達式。 只須要建立正確的對象,並正確地連起來。

Ruby用了這種實現方法差很少15年。在1.9版本,他們轉換到了本章所介紹的字節碼。看看我給你節省了多少時間!

這是個優美、簡單的模式,但它有一些問題。 看看插圖,看到了什麼?大量的小盒子,以及它們之間大量的箭頭。 代碼被表示爲小物體組成的巨大分形樹。這會帶來些使人不快的後果:

  • 從磁盤上加載它須要實例化並鏈接成噸的小對象。
  • 這些對象和它們之間的指針會佔據大量的內存。在32位機上,那個小的算術表達式至少要佔據68字節,這還沒考慮內存對其呢。

若是你想本身算算,別忘了算上虛函數表指針。

  • 順着那些指針遍歷子表達式是對數據緩存的謀殺。同時,虛函數調用是對指令緩存的屠戮。

參見數據局部性一章以瞭解什麼是緩存以及它是如何影響遊戲性能的。

將這些拼到一塊兒,怎麼念?S-L-O-W 這就是爲何大多數普遍應用的編程語言不基於解釋器模式: 太慢了,也太消耗內存了。

虛擬的機器碼

想一想咱們的遊戲。玩家電腦在運行遊戲時並不會遍歷一堆C++語法結構樹。 咱們提早將其編譯成了機器碼,CPU基於機器碼運行。機器碼有什麼好處呢?

  • 密集。 它是一塊堅實連續的二進制數據塊,沒有一位被浪費。
  • 線性。 指令被打成包,一條接一條地執行。不會在內存裏處處亂跳(除非你的控制流代碼真真這麼幹了)。
  • 底層。 每條指令都作一件小事,有趣的行爲從組合中誕生。
  • 速度快。 綜合全部這些條件(固然,也包括它直接由硬件實現這一事實),機器碼跑得跟風同樣快。

這聽起來很好,但咱們不但願真的用機器代碼來寫咒語。 讓玩家提供遊戲運行時的機器碼簡直是在自找麻煩。咱們須要的是機器代碼的性能和解釋器模式的安全的折中。

若是不是加載機器碼並直接執行,而是定義本身的虛擬機器碼呢? 而後,在遊戲中寫個小模擬器。 這與機器碼相似——密集,線性,相對底層——但也由遊戲直接掌控,因此能夠放心地將其放入沙箱。

這就是爲何不少遊戲主機和iOS不容許程序在運行時生成並加載機器碼。 這是一種拖累,由於最快的編程語言實現就是那麼作的。 它們包含了「即時(just-in-time)」編譯器,或者JIT,在運行時將語言翻譯成優化的機器碼。

咱們將小模擬器稱爲虛擬機(或簡稱「VM」),它運行的二進制機器碼叫作字節碼 它有數據的靈活性和易用性,但比解釋器模式性能更好。

在程序語言編程圈,「虛擬機」和「解釋器」是同義詞,我在這裏交替使用。 當指代GoF的解釋器模式,我會加上「模式」來代表區別。

這聽起來有點嚇人。 這章其他部分的目標是爲了展現一下,若是把功能列表縮減下來,它實際上至關通俗易懂。 即便最終沒有使用這個模式,你也至少能夠對Lua和其餘使用了這一模式的語言有個更好的理解。

模式

指令集 定義了可執行的底層操做。 一系列的指令被編碼爲字節序列 虛擬機 使用 中間值棧 依次執行這些指令。 經過組合指令,能夠定義複雜的高層行爲。

什麼時候使用

這是本書中最複雜的模式,沒法輕易地加入遊戲中。這個模式應當用在你有許多行爲須要定義,而遊戲實現語言由於以下緣由不適用時:

  • 過於底層,繁瑣易錯。
  • 編譯慢或者其餘工具因素致使迭代緩慢。
  • 安全性依賴編程者。若是想保證行爲不會破壞遊戲,你須要將其與代碼的其餘部分隔開。

固然,該列表描述了一堆特性。誰不但願有更快的迭代循環和更多的安全性? 然而,世上沒有免費的午飯。字節碼比本地代碼慢,因此不適合引擎的性能攸關的部分。

記住

建立本身的語言或者創建系統中的系統是頗有趣的。 我在這裏作的是小演示,但在現實項目中,這些東西會像藤蔓同樣蔓延。

對我來講,遊戲開發也正所以而有趣。 無論哪一種狀況,我都建立了虛擬空間讓他人遊玩。

每當我看到有人定義小語言或腳本系統,他們都說,別擔憂,它很小。因而,不可避免地,他們增長更多小功能,直到完成了一個完整的語言。 除了,和其它語言不一樣,它是定製的並擁有棚戶區的建築風格。

例如每一種模板語言。

固然,完成完整的語言並無什麼。只是要肯定你作得慎重。 不然,你就要當心地控制你的字節碼所能表達的範圍。在野馬脫繮以前把它拴住。

你須要一個前端

底層的字節碼指令性能優越,可是二進制的字節碼格式不是用戶能寫的。 咱們將行爲移出代碼的一個緣由是想要以更高層的形式表示它。 若是說寫C++太過底層,那麼讓用戶寫彙編可不是一個改進方案——就算是你設計的!

一個反例的是使人尊敬的遊戲RoboWar。 在遊戲中,玩家 編寫相似彙編的語言控制機器人,咱們這裏也會討論這種指令集。

這是我介紹相似彙編的語言的首選。

就像GoF的解釋器模式,它假設你有某些方法來生成字節碼。 一般狀況下,用戶在更高層編寫行爲,再用工具將其翻譯爲虛擬機能理解的字節碼。 這裏的工具就是編譯器。

我知道,這聽起來很嚇人。醜話說在前頭, 若是沒有資源製做編輯器,那麼字節碼不適合你。 可是,接下來你會看到,也可能沒你想的那麼糟。

你會想念調試器

編程很難。咱們知道想要機器作什麼,但並不總能正確地傳達——因此咱們會寫出漏洞。 爲了查找和修復漏洞,咱們已經積累了一堆工具來了解代碼作錯了什麼,以及如何修正。 咱們有調試器,靜態分析器,反編譯工具等。 全部這些工具都是爲現有的語言設計的:不管是機器碼仍是某些更高層次的東西。

當你定義本身的字節碼虛擬機時,你就得把這些工具拋在腦後了。 固然,能夠經過調試器調試虛擬機,但它告訴你虛擬機自己在作什麼,而不是正在被翻譯的字節碼是幹什麼的。

它固然也不會把字節碼映射回編譯前的高層次的形式。

若是你定義的行爲很簡單,可能無需太多工具幫忙調試就能勉強堅持下來。 但隨着內容規模增加,仍是應該花些時間完成些功能,讓用戶看到字節碼在作什麼。 這些功能也許不隨遊戲發佈,但它們相當重要,它們能確保你的遊戲被髮布。

固然,若是你想要讓遊戲支持模組,那你發佈這些特性,它們就更加劇要了。

示例代碼

經歷了前面幾個章節後,你也許會驚訝於它的實現是多麼直接。 首先須要爲虛擬機設定一套指令集。 在開始考慮字節碼之類的東西前,先像思考API同樣思考它。

法術的API

若是直接使用C++代碼定義法術,代碼須要調用何種API呢? 在遊戲引擎中,構成法術的基本操做是什麼樣的?

大多數法術最終改變一個巫師的狀態,所以先從這樣的代碼開始。

void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);

第一個參數指定哪一個巫師被影響,0表明玩家而1表明對手。 以這種方式,治癒法術能夠治療玩家的巫師,而傷害法術傷害他的敵人。 這三個小方法能覆蓋的法術出人意料地多。

若是法術只是默默地調整數據,遊戲邏輯就已經完成了, 但玩這樣的遊戲會讓玩家無聊得要哭。讓咱們修復這點:

void playSound(int soundId);
void spawnParticles(int particleType);

這並不影響遊戲玩法,但它們加強了遊戲的體驗 咱們能夠增長一些鏡頭晃動,動畫之類的,但這足夠咱們開始了。

法術指令集

如今讓咱們把這種程序化API轉化爲可被數據控制的東西。 從小處開始,而後慢慢拓展到總體。 如今,要去除方法的全部參數。 假設set__()方法總影響玩家的巫師,總直接將狀態設爲最大值。 一樣,FX操做老是播放一個硬編碼的聲音和粒子效果。

這樣,一個法術就只是一系列指令了。 每條指令都表明了想要呈現的操做。咱們能夠枚舉以下:

enum Instruction
{
  INST_SET_HEALTH      = 0x00,
  INST_SET_WISDOM      = 0x01,
  INST_SET_AGILITY     = 0x02,
  INST_PLAY_SOUND      = 0x03,
  INST_SPAWN_PARTICLES = 0x04
};

爲了將法術編碼進數據,咱們存儲了一數組enum值。 只有幾個不一樣的基本操做原語,所以enum值的範圍能夠存儲到一個字節中。 這就意味着法術的代碼就是一系列字節——也就是字節碼

有些字節碼虛擬機爲每條指令使用多個字節,解碼規則也更復雜。 事實上,在x86這樣的常見芯片上的機器碼更加複雜。

但單字節對於Java虛擬機和支撐了.NET平臺的Common Language Runtime已經足夠了,對咱們來講也同樣。

爲了執行一條指令,咱們看看它的基本操做原語是什麼,而後調用正確的API方法。

switch (instruction)
{
  case INST_SET_HEALTH:
    setHealth(0, 100);
    break;
 
  case INST_SET_WISDOM:
    setWisdom(0, 100);
    break;
 
  case INST_SET_AGILITY:
    setAgility(0, 100);
    break;
 
  case INST_PLAY_SOUND:
    playSound(SOUND_BANG);
    break;
 
  case INST_SPAWN_PARTICLES:
    spawnParticles(PARTICLE_FLAME);
    break;
}

用這種方式,解釋器創建了溝通代碼世界和數據世界的橋樑。咱們能夠像這樣將其放進執行法術的虛擬機:

class VM
{
public:
  void interpret(char bytecode[], int size)
  {
    for (int i = 0; i < size; i++)
    {
      char instruction = bytecode[i];
      switch (instruction)
      {
        // 每條指令的跳轉分支……
      }
    }
  }
};

輸入這些,你就完成了你的首個虛擬機。 不幸的是,它並不靈活。 咱們不能設定攻擊對手的法術,也不能減小狀態上限。咱們只能播放聲音!

爲了得到像一個真正的語言那樣的表達能力,咱們須要在這裏引入參數。

棧式機器

要執行復雜的嵌套表達式,得先從最裏面的子表達式開始。 計算完裏面的,將結果做爲參數向外流向包含它們的表達式, 直到得出最終結果,整個表達式就算完了。

解釋器模式將其明確地表現爲嵌套對象組成的樹,但咱們須要指令速度達到列表的速度。咱們仍然須要確保子表達式的結果正確地向外傳遞給包括它的表達式。

但因爲數據是扁平的,咱們得使用指令的順序來控制這一點。咱們的作法和CPU同樣——使用棧。

這種架構不出所料地被稱爲棧式計算機。像ForthPostScript,和Factor這些語言直接將這點暴露給用戶。

class VM
{
public:
  VM()
  : stackSize_(0)
  {}
 
  // 其餘代碼……
 
private:
  static const int MAX_STACK = 128;
  int stackSize_;
  int stack_[MAX_STACK];
};

虛擬機用內部棧保存值。在例子中,指令交互的值只有一種,那就是數字, 因此能夠使用簡單的int數組。 每當數據須要從一條指令傳到另外一條,它就得經過棧。

顧名思義,值能夠壓入棧或者從棧彈出,因此讓咱們添加一對方法。

class VM
{
private:
  void push(int value)
  {
    // 檢查棧溢出
    assert(stackSize_ < MAX_STACK);
    stack_[stackSize_++] = value;
  }
 
  int pop()
  {
    // 保證棧不是空的
    assert(stackSize_ > 0);
    return stack_[--stackSize_];
  }
 
  // 其他的代碼
};

當一條指令須要接受參數,就將參數從棧彈出,以下所示:

switch (instruction)
{
  case INST_SET_HEALTH:
  {
    int amount = pop();
    int wizard = pop();
    setHealth(wizard, amount);
    break;
  }
 
  case INST_SET_WISDOM:
  case INST_SET_AGILITY:
    // 像上面同樣……
 
  case INST_PLAY_SOUND:
    playSound(pop());
    break;
 
  case INST_SPAWN_PARTICLES:
    spawnParticles(pop());
    break;
}

爲了將一些值存入棧中,須要另外一條指令:字面量。 它表明了原始的整數值。可是的值又是從哪裏來的呢? 咱們怎麼樣避免這樣追根溯源到無窮無盡呢?

技巧是利用指令是字節序列這一事實——咱們能夠直接將數值存儲在字節數組中。 以下,咱們爲數值字面量定義了另外一條指令類型:

case INST_LITERAL:
{
  // 從字節碼中讀取下一個字節
  int value = bytecode[++i];
  push(value);
  break;
}

這裏,從單個字節中讀取值,從而避免瞭解碼多字節整數須要的代碼, 但在真實實現中,你會須要支持整個數域的字面量。

它讀取字節碼流中的字節做爲數值並將其壓入棧。

讓咱們把一些這樣的指令串起來看看解釋器的執行,感覺下棧是如何工做的。 從空棧開始,解釋器指向第一個指令:

首先,它執行第一條INST_LITERAL,讀取字節碼流的下一個字節(0)並壓入棧中。

而後,它執行第二條INST_LITERAL,讀取10而後壓入。

最後,執行INST_SET_HEALTH。這會彈出10存進amount,彈出0存進wizard。而後用這兩個參數調用setHealth()

完成!咱們得到了將玩家巫師血量設爲10點的法術。 如今咱們擁有了足夠的靈活度,來定義修改任一巫師的狀態到任意值的法術。 咱們還能夠放出不一樣的聲音和粒子效果。

可是……這感受仍是像數據格式。好比,不能將巫師的血量提高爲他智力的一半。 設計師但願法術能表達規則,而不只僅是數值

行爲 = 組合

若是咱們視小虛擬機爲編程語言,如今它能支持的只有一些內置函數,以及常量參數。 爲了讓字節碼感受像行爲,咱們缺乏的是組合

設計師須要能以有趣的方式組合不一樣的值,來建立表達式。 舉個簡單的例子,他們想讓法術變化一個數值而不是變到一個數值。

這須要考慮到狀態的當前值。 咱們有指令來修改狀態,如今須要添加方法讀取狀態:

case INST_GET_HEALTH:
{
  int wizard = pop();
  push(getHealth(wizard));
  break;
}
 
case INST_GET_WISDOM:
case INST_GET_AGILITY:
  // 你知道思路了吧……

正如你所看到的,這要與棧雙向交互。 彈出一個參數來肯定獲取哪一個巫師的狀態,而後查找狀態的值並壓入棧中。

這容許咱們創造複製狀態的法術。 咱們能夠建立一個法術,根據巫師的智慧設定敏捷度,或者讓巫師的血量等於對方的血量。

有所改善,但仍很受限制。接下來,咱們須要算術。 是時候讓小虛擬機學習如何計算1 + 1了,咱們將添加更多的指令。 如今,你可能已經知道如何去作,猜到了大概的模樣。我只展現加法:

case INST_ADD:
{
  int b = pop();
  int a = pop();
  push(a + b);
  break;
}

像其餘指令同樣,它彈出數值,作點工做,而後壓入結果。 直到如今,每一個新指令彷佛都只是有所改善而已,但其實咱們已完成大飛躍。 這並不顯而易見,但如今咱們能夠處理各類複雜的,深層嵌套的算術表達式了。

來看個稍微複雜點的例子。 假設咱們但願有個法術,能讓巫師的血量增長敏捷和智慧的平均值。 用代碼表示以下:

setHealth(0, getHealth(0) +
    (getAgility(0) + getWisdom(0)) / 2);

你可能會認爲咱們須要指令來處理括號形成的分組,但棧隱式支持了這一點。能夠手算以下:

  1. 獲取巫師當前的血量並記錄。
  2. 獲取巫師敏捷並記錄。
  3. 對智慧執行一樣的操做。
  4. 獲取最後兩個值,加起來並記錄。
  5. 除以二並記錄。
  6. 回想巫師的血量,將它和這結果相加並記錄。
  7. 取出結果,設置巫師的血量爲這一結果。

你看到這些記錄回想了嗎?每一個記錄對應一個壓入,回想對應彈出。 這意味着能夠很容易將其轉化爲字節碼。例如,第一行得到巫師的當前血量:

LITERAL 0
GET_HEALTH

這些字節碼將巫師的血量壓入堆棧。 若是咱們機械地將每行都這樣轉化,最終獲得一大塊等價於原來表達式的字節碼。 爲了讓你感受這些指令是如何組合的,我在下面給你作個示範。

爲了展現堆棧如何隨着時間推移而變化,咱們舉個代碼執行的例子。 巫師目前有45點血量,7點敏捷,和11點智慧。 每條指令的右邊是棧在執行指令以後的模樣,再右邊是解釋指令意圖的註釋:

LITERAL 0    [0]            # 巫師索引
LITERAL 0    [0, 0]         # 巫師索引
GET_HEALTH   [0, 45]        # 獲取血量()
LITERAL 0    [0, 45, 0]     # 巫師索引
GET_AGILITY  [0, 45, 7]     # 獲取敏捷()
LITERAL 0    [0, 45, 7, 0]  # 巫師索引
GET_WISDOM   [0, 45, 7, 11] # 獲取智慧()
ADD          [0, 45, 18]    # 將敏捷和智慧加起來
LITERAL 2    [0, 45, 18, 2] # 被除數:2
DIVIDE       [0, 45, 9]     # 計算敏捷和智慧的平均值
ADD          [0, 54]        # 將平均值加到現有血量上。
SET_HEALTH   []             # 將結果設爲血量

若是你注意每步的棧,你能夠看到數據如何魔法通常地在其中流動。 咱們最開始壓入0來查找巫師,而後它一直掛在棧的底部,直到最終的SET_HEALTH纔用到它。

也許「魔法」在這裏的門檻過低了。

一臺虛擬機

我能夠繼續下去,添加愈來愈多的指令,可是時候適可而止了。 如上所述,咱們已經有了一個可愛的小虛擬機,能夠使用簡單,緊湊的數據格式,定義開放的行爲。 雖然字節碼虛擬機的聽起來很嚇人,但你能夠看到它們每每簡單到只需棧,循環,和switch語句。

還記得咱們最初的讓行爲呆在沙盒中的目標嗎? 如今,你已經看到虛擬機是如何實現的,很明顯,那個目標已經完成。 字節碼不能把惡意觸角伸到遊戲引擎的其餘部分,由於咱們只定義了幾個與其餘部分接觸的指令。

咱們經過控制棧的大小來控制內存使用量,並很當心地確保它不會溢出。 咱們甚至能夠控制它使用多少時間 在指令循環裏,能夠追蹤已經執行了多少指令,若是遇到了問題也能夠擺脫困境。

控制運行時間在例子中沒有必要,由於沒有任何循環的指令。 能夠限制字節碼的整體大小來限制運行時間。 這也意味着咱們的字節碼不是圖靈完備的。

如今就剩一個問題了:建立字節碼。 到目前爲止,咱們使用僞代碼,再手工編寫爲字節碼。 除非你有不少的空閒時間,不然這種方式並不實用。

語法轉換工具

咱們最初的目標是創造更高層的方式來控制行爲,可是,咱們卻創造了比C++底層的東西。 它具備咱們想要的運行性能和安全性,但絕對沒有對設計師友好的可用性。

爲了填補這一空白,咱們須要一些工具。 咱們須要一個程序,讓用戶定義法術的高層次行爲,而後生成對應的低層棧式機字節碼。

這可能聽起來比虛擬機更難。 許多程序員都在大學參加編譯器課程,除了被龍書或者lexyacc引起了PTSD外,什麼也沒真正學到。

我指的,固然,是經典教材Compilers: Principles, Techniques, and Tools

事實上,編譯一個基於文本的語言並不那麼糟糕,儘管把這個話題放進這裏來要牽扯的東西有多。可是,你不是非得那麼作。 我說,咱們須要的是工具——它並不必定是個輸入格式是文本文件編譯器

相反,我建議你考慮構建圖形界面讓用戶定義本身的行爲, 尤爲是在使用它的人沒有很高的技術水平時。 沒有花幾年時間習慣編譯器怒吼的人很難寫出沒有語法錯誤的文本。

你能夠創建一個應用程序,用戶經過單擊拖動小盒子,下拉菜單項,或任何有意義的行爲建立腳本,從而建立行爲。

我爲Henry Hatsworth in the Puzzling Adventure編寫的腳本系統就是這麼工做的。

這樣作的好處是,你的UI能夠保證用戶沒法建立無效的程序。 與其向他們吐一大堆錯誤警告,不如主動禁用按鈕或提供默認值, 以確保他們創造的東西在任什麼時候間點上都有效。

我想要強調錯誤處理是多麼重要。做爲程序員,咱們趨向於將人爲錯誤視爲應當極力避免的的我的恥辱。

爲了製做用戶喜歡的系統,你須要接受人性,包括他們的失敗。是人都會犯錯誤,但錯誤同時也是創做的固有基礎。 用撤銷這樣的特性優雅地處理它們,這能讓用戶更有創意,創做出更好的成果。

這免去了設計語法和編寫解析器的工做。 可是我知道,你可能會發現UI設計一樣使人不快。 好吧,若是這樣,我就沒啥辦法啦。

畢竟,這種模式是關於使用對用戶友好的高層方式表達行爲。 你必須精心設計用戶體驗。 要有效地執行行爲,又須要將其轉換成底層形式。這是必作的,但若是你準備好迎接挑戰,這終會有所回報。

設計決策

我想盡量讓本章簡短,但咱們所作的事情實際上但是創造語言啊。 那但是個寬泛的設計領域,你能夠從中得到不少樂趣,因此別沉迷於此反而忘了完成你的遊戲。

這是本書中最長的章節,看來我失敗了。

指令如何訪問堆棧?

字節碼虛擬機主要有兩種:基於棧的和基於寄存器的。 棧式虛擬機中,指令老是操做棧頂,如同咱們的示例代碼所示。 例如,INST_ADD彈出兩個值,將它們相加,將結果壓入。

基於寄存器的虛擬機也有棧。惟一不一樣的是指令能夠從棧的深處讀取值。 不像INST_ADD始終彈出其操做數, 它在字節碼中存儲兩個索引,指示了從棧的何處讀取操做數。

  • 基於棧的虛擬機:
    • 指令短小。 因爲每一個指令隱式認定在棧頂尋找參數,不須要爲任何數據編碼。 這意味着每條指令可能會很是短,通常只需一個字節。
    • 易於生成代碼。 當你須要爲生成字節碼編寫編譯器或工具時,你會發現基於棧的字節碼更容易生成。 因爲每一個指令隱式地在棧頂工做,你只須要以正確的順序輸出指令就能夠在它們之間傳遞參數。
    • 會生成更多的指令。 每條指令只能看到棧頂。這意味着,產生像a = b + c這樣的代碼, 你須要單獨的指令將bc壓入棧頂,執行操做,再將結果壓入a
  • 基於寄存器的虛擬機:
    • 指令較長。 因爲指令須要參數記錄棧偏移量,單個指令須要更多的位。 例如,一個Lua指令佔用完整的32——它多是最著名的基於寄存器的虛擬機了。 它採用6位作指令類型,其他的是參數。

Lua做者沒有指定Lua的字節碼格式,它每一個版本都會改變。如今描述的是Lua 5.1 要深究Lua的內部構造, 讀讀這個

    • 指令較少。 因爲每一個指令能夠作更多的工做,你不須要那麼多的指令。 有人說,性能會得以提高,由於不須要將值在棧中移來移去了。

因此,應該選一種?個人建議是堅持使用基於棧的虛擬機。 它們更容易實現,也更容易生成代碼。 Lua轉換爲基於寄存器的虛擬機從而變得更快,這爲寄存器虛擬機博得了聲譽, 可是這強烈依賴於實際的指令和虛擬機的其餘大量細節。

你有什麼指令?

指令集定義了在字節碼中能夠幹什麼,不能幹什麼,對虛擬機性能也有很大的影響。 這裏有個清單,記錄了你可能須要的不一樣種類的指令:

  • 外部基本操做原語。 這是虛擬機與引擎其餘部分交互,影響玩家所見的部分。 它們控制了字節碼能夠表達的真實行爲。 若是沒有這些,你的虛擬機除了消耗CPU循環之外一無所獲。
  • 內部基本操做原語 這些語句在虛擬機內操做數值——文字,算術,比較操做,以及操縱棧的指令。
  • 控制流。 咱們的例子沒有包含這些,但當你須要有條件執行或循環執行,你就會須要控制流。 在字節碼這樣底層的語言中,它們出奇地簡單:跳轉。

在咱們的指令循環中,須要索引來跟蹤執行到了字節碼的哪裏。 跳轉指令作的是修改這個索引並改變將要執行的指令。 換言之,這就是goto。你能夠基於它制定各類更高級別的控制流。

  • 抽象。 若是用戶開始在數據中定義不少的東西,最終要重用字節碼的部分位,而不是複製和粘貼。 你也許會須要可調用過程這樣的東西。

最簡單的形式中,過程並不比跳轉複雜。 惟一不一樣的是,虛擬機須要管理另外一個返回棧。 當執行「call」指令時,將當前指令索引壓入棧中,而後跳轉到被調用的字節碼。 當它到了「return」,虛擬機從堆棧彈出索引,而後跳回索引指示的位置。

數值是如何表示的?

咱們的虛擬機示例只與一種數值打交道:整數。 回答這個問題很簡單——棧只是一棧的int 更加完整的虛擬機支持不一樣的數據類型:字符串,對象,列表等。 你必須決定在內部如何存儲這些值。

  • 單一數據類型:
    • 簡單易用 你沒必要擔憂標記,轉換,或類型檢查。
    • 沒法使用不一樣的數據類型。 這是明顯的缺點。將不一樣類型成塞進單一的表示方式——好比將數字存儲爲字符串——這是自找麻煩。
  • 帶標記的類型:

這是動態類型語言中常見的表示法。 全部的值有兩部分。 第一部分是類型標識——一個存儲了數據的類型的enum。其他部分會被解釋爲這種類型:

enum ValueType
{
  TYPE_INT,
  TYPE_DOUBLE,
  TYPE_STRING
};
 
struct Value
{
  ValueType type;
  union
  {
    int    intValue;
    double doubleValue;
    char*  stringValue;
  };
};
    • 數值知道其類型。 這個表示法的好處是可在運行時檢查值的類型。 這對動態調用很重要,能夠確保沒有在類型上面執行其不支持的操做。
    • 消耗更多內存。 每一個值都要帶一些額外的位來標識類型。在像虛擬機這樣的底層,這裏幾位,那裏幾位,總量就會快速增長。
  • 無標識的union

像前面同樣使用union,可是沒有類型標識。 你能夠將這些位表示爲不一樣的類型,由你確保沒有搞錯值的類型。

這是靜態類型語言在內存中表示事物的方式。 因爲類型系統在編譯時保證沒弄錯值的類型,不須要在運行時對其進行驗證。

這也是無類型語言,像彙編和Forth存儲值的方式。 這些語言讓用戶保證不會寫出誤認值的類型的代碼。毫無服務態度!

    • 結構緊湊。 找不到比只存儲須要的值更加有效率的存儲方式。
    • 速度快。 沒有類型標識意味着在運行時無需消耗週期檢查它們的類型。這是靜態類型語言每每比動態類型語言快的緣由之一。
    • 不安全。 這是真正的代價。一塊錯誤的字節碼,會讓你誤解一個值,把數字誤解爲指針,會破壞遊戲安全性從而致使崩潰。

若是你的字節碼是由靜態類型語言編譯而來,你也許認爲它是安全的,由於編譯不會生成不安全的字節碼。 那也許是真的,但記住惡意用戶也許會手寫惡意代碼而不通過你的編譯器。

舉個例子,這就是爲何Java虛擬機在加載程序時要作字節碼驗證

  • 接口:

多種類型值的面向對象解決方案是經過多態。接口爲不一樣的類型的測試和轉換提供虛方法,以下:

class Value
{
public:
  virtual ~Value() {}
 
  virtual ValueType type() = 0;
 
  virtual int asInt() {
    // 只能在int上調用
    assert(false);
    return 0;
  }
 
  // 其餘轉換方法……
};

而後你爲每一個特定的數據類型設計特定的類,如:

class IntValue : public Value
{
public:
  IntValue(int value)
  : value_(value)
  {}
 
  virtual ValueType type() { return TYPE_INT; }
  virtual int asInt() { return value_; }
 
private:
  int value_;
};
    • 開放。 可在虛擬機的核心以外定義新的值類型,只要它們實現了基本接口就行。
    • 面向對象。 若是你堅持OOP原則,這是正確的作法,爲特定類型使用多態分配行爲,而不是在標籤上作switch之類的。
    • 冗長。 必須定義單獨的類,包含了每一個數據類型的相關行爲。 注意在前面的例子中,這樣的類定義了全部的類型。在這裏,只包含了一個!
    • 低效。 爲了使用多態,必須使用指針,這意味着即便是短小的值,如布爾和數字,也得裹在堆中分配的對象裏。 每使用一個值,你就得作一次虛方法調用。

在虛擬機核心之類的地方,像這樣的性能影響會迅速疊加。 事實上,這引發了許多咱們試圖在解釋器模式中避免的問題。 只是如今的問題不在代碼中,而是在中。

個人建議是:若是能夠,只用單一數據類型。 除此之外,使用帶標識的union。這是世界上幾乎每一個語言解釋器的選擇。

如何生成字節碼?

我將最重要的問題留到最後。咱們已經完成了消耗解釋字節碼的部分, 但需你要寫製造字節碼的工具。 典型的解決方案是寫個編譯器,但它不是惟一的選擇。

  • 若是你定義了基於文本的語言:
    • 必須定義語法。 業餘和專業的語言設計師小看這件事情的難度。讓解析器高興很簡單,讓用戶快樂很

語法設計是用戶界面設計,當你將用戶界面限制到字符構成的字符串,這可沒把事情變簡單。

    • 必須實現解析器。 無論名聲如何,這部分其實很是簡單。不管使用ANTLRBison,仍是——像我同樣——手寫遞歸降低,均可以完成。
    • 必須處理語法錯誤。 這是最重要和最困難的部分。 當用戶製造了語法和語義錯誤——他們總會這麼幹——引導他們返回到正確的道路是你的任務。 解析器只知道接到了意外的符號,給予有用的的反饋並不容易。
    • 可能會對非技術用戶關上大門。 咱們程序員喜歡文本文件。結合強大的命令行工具,咱們把它們看成計算機的樂高積木——簡單,有百萬種方式組合。

大部分非程序員不這樣想。 對他們來講,輸入文本文件就像爲憤怒機器人審覈員填寫稅表,若是忘記了一個分號就會遭到痛斥。

  • 若是你定義了一個圖形化創做工具:
    • 必須實現用戶界面。 按鈕,點擊,拖動,諸如此類。 有些人畏懼它,但我喜歡它。 若是沿着這條路走下去,設計用戶界面和工做核心部分同等重要——而不是硬着頭皮完成的亂七八糟工做。

每點額外工做都會讓工具更容易更溫馨地使用,並直接致使了遊戲中更好的內容。 若是你看看不少遊戲製做過程的內部解密,常常會發現製做有趣的創造工具是祕訣之一。

    • 有較少的錯誤狀況。 因爲用戶經過交互式一步一步地設計行爲,應用程序能夠儘快引導他們走出錯誤。

而使用基於文本的語言時,直到用戶輸完整個文件才能看到用戶的內容,預防和處理錯誤更加困難。

    • 更難移植。 文本編譯器的好處是,文本文件是通用的。編譯器簡單地讀入文件並寫出。跨平臺移植的工做實在微不足道。

除了換行符。還有編碼。

當你構建用戶界面,你必須選擇要使用的架構,其中不少是基於某個操做系統。 也有跨平臺的用戶界面工具包,但他們每每要爲對全部平臺一樣適用付出代價——它們在不一樣的平臺上一樣差別很大。

參見

  • 這一章節的近親是GoF解釋器模式。兩種方式都能讓你用數據組合行爲。

事實上,最終你兩種模式會使用。你用來構造字節碼的工具會有內部的對象樹。這也是解釋器模式所能作的。

爲了編譯到字節碼,你須要遞歸回溯整棵樹,就像用解釋器模式去解釋它同樣。 惟一的 不一樣在於,不是當即執行一段行爲,而是生成整個字節碼再執行。

  • Lua是遊戲中最普遍應用的腳本語言。 它的內部被實現爲一個很是緊湊的,基於寄存器的字節碼虛擬機。
  • Kismet是個可視化腳本編輯工具,應用於Unreal引擎的編輯器UnrealEd
  • 個人腳本語言Wren,是一個簡單的,基於棧的字節碼解釋器。

4.2子類沙箱

遊戲設計模式Behavioral Patterns

意圖

用一系列由基類提供的操做定義子類中的行爲。

動機

每一個孩子都夢想過變成超級英雄,可是不幸的是,高能射線在地球上很短缺。 遊戲是讓你扮演超級英雄最簡單的方法。 由於咱們的遊戲設計者歷來沒有學會說咱們的超級英雄遊戲中有成百上千種不一樣的超級能力可供選擇。

咱們的計劃是建立一個Superpower基類。而後由它派生出各類超級能力的實現類。 咱們在程序員隊伍中分發設計文檔,而後開始編程。 當咱們完成時,咱們就會有上百種超級能力類。

當你發現像這個例子同樣有不少子類時,那一般意味着數據驅動的方式更好。 再也不用代碼定義不一樣的能力,用數據吧。

類型對象字節碼,和解釋器模式都能幫忙。

咱們想讓玩家處於擁有無限可能的世界中。不管他們在孩童時想象過什麼能力,咱們都要在遊戲中展示。 這就意味着這些超能力子類須要作任何事情: 播放聲音,產生視覺刺激,與AI交互,建立和銷燬其餘遊戲實體,與物理打交道。沒有哪處代碼是它們不會接觸的。

假設咱們讓團隊信馬由繮地寫超能力類。會發生什麼?

  • 會有不少冗餘代碼。 當超能力種類繁多,咱們能夠預期有不少重疊。 不少超能力都會用相同的方式產生視覺效果並播放聲音。 當你坐下來看看,冷凍光線,熱能光線,芥末醬光線都很類似。 若是人們實現這些的時候沒有協同,那就會有不少冗餘的代碼和重複勞動。
  • 遊戲引擎中的每一部分都會與這些類耦合。 沒有深刻了解的話,任何人都能寫出直接調用子系統的代碼,但子系統歷來沒打算直接與超能力類綁定。 就算渲染系統被好好組織成多個層次,只有一個能被外部的圖形引擎使用, 咱們能夠打賭,最終超能力代碼會與每個接觸。
  • 當外部代碼須要改變時,一些隨機超能力代碼有很大概率會損壞。 一旦咱們有了不一樣的超能力類綁定到遊戲引擎的多個部分,改變那些部分必然影響超能力類。 這可不合理,由於圖形,音頻,UI程序員極可能不想成爲玩法程序員。
  • 很難定義全部超能力遵照的不變量。 假設咱們想保證超能力播放的全部音頻都有正確的順序和優先級。 若是咱們的幾百個類都直接調用音頻引擎,就沒什麼好辦法來完成這點。

咱們要的是給每一個實現超能力的玩法程序員一系列可以使用的基本單元。 你想要播放聲音?這是你的playSound()函數。 你想要粒子效果?這是你的spawnParticles()函數。 咱們保證了這些操做覆蓋了你要作的事情,因此你不須要#include隨機的頭文件,干擾到代碼庫的其餘部分。

咱們實現的方法是經過定義這些操做爲Superpower基類protected方法 將它們放在基類給了每一個子類直接便捷的途徑獲取方法。 讓它們成爲protected(極可能不是虛方法)方法暗示了它們存在就是爲了被子類調用

一旦有了這些東西來使用,咱們須要一個地方使用他們。 爲了作到這點,咱們定義沙箱方法,這是子類必須實現的抽象的protected方法。 有了這些,要實現一種新的能力,你須要:

  1. 建立從Superpower繼承的新類。
  2. 重載沙箱方法activate()
  3. 經過調用Superpower提供的protected方法實現主體。

咱們如今能夠使用這些高層次的操做來解決冗餘代碼問題了。 當咱們看到代碼在多個子類間重複,咱們總能夠將其打包到Superpower中,做爲它們均可以使用的新操做。

咱們經過將耦合約束到一個地方解決了耦合問題。 Superpower最終與不一樣的系統耦合,可是繼承它的幾百個類不會。 相反,它們耦合基類。 當遊戲系統的某部分改變時,修改Superpower也許是必須的,可是衆多的子類不須要修改。

這個模式帶來淺層可是普遍的類層次。 你的繼承鏈不,可是有不少類與Superpower掛鉤。 經過使用有不少直接子類的基類,咱們在代碼庫中創造了一個支撐點。 咱們投入到Superpower的時間和愛能夠讓遊戲中衆多類獲益。

最近,你會發現不少人批評面嚮對象語言中的繼承。 繼承有問題——在代碼庫中沒有比父類子類之間的耦合更深的了——但我發現扁平的繼承樹比起深的繼承樹更好處理。

模式

基類定義抽象的沙箱方法和幾個提供的操做 將操做標爲protected,代表它們只爲子類所使用。 每一個推導出的沙箱子類用提供的操做實現了沙箱函數。

什麼時候使用

子類沙箱模式是潛伏在代碼庫中簡單經常使用的模式,哪怕是在遊戲以外的地方亦有應用。 若是你有一個非虛的protected方法,你可能已經在用相似的東西了。 沙箱方法在如下狀況適用:

  • 你有一個能推導不少子類的基類。
  • 基類能夠提供子類須要的全部操做。
  • 在子類中有行爲重複,你想要更容易地在它們間分享代碼。
  • 你想要最小化子類和程序的其餘部分的耦合。

記住

繼承近來在不少編程圈子爲人詬病,緣由之一是基類趨向於增長愈來愈多的代碼 這個模式特別容易染上這個毛病。

因爲子類經過基類接觸遊戲的剩餘部分,基類最後和子類須要的每一個系統耦合。 固然,子類也緊密地與基類相綁定。這種蛛網耦合讓你很難在不破壞什麼的狀況下改變基類——你獲得了(脆弱的基類問題)brittle base class problem

硬幣的另外一面是因爲你耦合的大部分都被推到了基類,子類如今與世界的其餘部分分離。 理想的狀況下,你大多數的行爲都在子類中。這意味着你的代碼庫大部分是孤立的,很容易管理。

若是你發現這個模式正把你的基類變成一鍋代碼糊糊, 考慮將它提供的一些操做放入分離的類中, 這樣基類能夠分散它的責任。組件模式能夠在這裏幫上忙。

示例代碼

由於這個模式太簡單了,示例代碼中沒有太多東西。 這不是說它沒用——這個模式關鍵在於意圖,而不是它實現的複雜度。

咱們從Superpower基類開始:

class Superpower
{
public:
  virtual ~Superpower() {}
 
protected:
  virtual void activate() = 0;
 
  void move(double x, double y, double z)
  {
    // 實現代碼……
  }
 
  void playSound(SoundId sound, double volume)
  {
    // 實現代碼……
  }
 
  void spawnParticles(ParticleType type, int count)
  {
    // 實現代碼……
  }
};

activate()方法是沙箱方法。因爲它是抽象虛函數,子類必須重載它。 這讓那些須要建立子類的人知道要作哪些工做。

其餘的protected函數move()playSound(),和spawnParticles()都是提供的操做。 它們是子類在實現activate()時要調用的。

在這個例子中,咱們沒有實現提供的操做,但真正的遊戲在那裏有真正的代碼。 那些代碼中,Superpower與遊戲中其餘部分的耦合——move()也許調用物理代碼,playSound()會與音頻引擎交互,等等。 因爲這都在基類的實現中,保證了耦合封閉在Superpower中。

好了,拿出咱們的放射蜘蛛,建立個能力。像這樣:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate()
  {
    // 空中滑行
    playSound(SOUND_SPROING, 1.0f);
    spawnParticles(PARTICLE_DUST, 10);
    move(0, 0, 20);
  }
};

好吧,也許跳躍不是超級能力,但我在這裏講的是基礎知識。

這種能力將超級英雄射向天空,播放合適的聲音,揚起塵土。 若是全部的超能力都這樣簡單——只是聲音,粒子效果,動做的組合——那麼就根本不須要這個模式了。 相反,Superpower有內置的activate()能獲取聲音ID,粒子類型和運動的字段。 可是這隻在全部能力運行方式相同,只在數據上不一樣時纔可行。讓咱們精細一些:

class Superpower
{
protected:
  double getHeroX()
  {
    // 實現代碼……
  }
 
  double getHeroY()
  {
    // 實現代碼……
  }
 
  double getHeroZ()
  {
    // 實現代碼……
  }
 
  // 退出之類的……
};

這裏咱們增長了些方法獲取英雄的位置。咱們的SkyLaunch如今能夠使用它們了:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate()
  {
    if (getHeroZ() == 0)
    {
      // 在地面上,衝向空中
      playSound(SOUND_SPROING, 1.0f);
      spawnParticles(PARTICLE_DUST, 10);
      move(0, 0, 20);
    }
    else if (getHeroZ() < 10.0f)
    {
      // 接近地面,再跳一次
      playSound(SOUND_SWOOP, 1.0f);
      move(0, 0, getHeroZ() + 20);
    }
    else
    {
      // 正在空中,跳劈攻擊
      playSound(SOUND_DIVE, 0.7f);
      spawnParticles(PARTICLE_SPARKLES, 1);
      move(0, 0, -getHeroZ());
    }
  }
};

因爲咱們如今能夠訪問狀態,沙箱方法能夠作有用有趣的控制流了。 這還須要幾個簡單的if聲明, 但你能夠作任何你想作的東西。 使用包含任意代碼的成熟沙箱方法,天高任鳥飛了。

早先,我建議以數據驅動的方式創建超能力。 這裏是你可能想那麼作的緣由之一。 若是你的行爲複雜而使用命令式風格,它更難在數據中定義。

設計決策

如你所見,子類沙箱是一個模式。它表述了一個基本思路,可是沒有不少細節機制。 這意味着每次使用都面臨着一些有趣的選擇。這裏是一些須要思考的問題。

應該提供什麼操做?

這是最大的問題。這深深影響了模式感受上和實際上有多好。 在一個極端,基類幾乎不提供任何操做。只有一個沙箱方法。 爲了實現功能,老是須要調用基類外部的系統。若是你這樣作,很難說你在使用這個模式。

另外一個極端,基類提供了全部子類也許須要的操做。 子類與基類耦合,不調用任何外部系統的東西。

具體來講,這意味着每一個子類的源文件只須要#include它的基類頭文件。

在這兩個極端之間,操做由基類提供仍是向外部直接調用有很大的操做餘地。 你提供的操做越多,外部系統與子類耦合越少,可是與基類耦合越多 從子類中移除了耦合是經過將耦合推給基類完成的。

若是你有一堆與外部系統耦合的子類的話,這很好。 經過將耦合移到提供的操做中,你將其移動到了一個地方:基類。可是你越這麼作,基類就越大越難管理。

因此分界線在哪裏?這裏是一些首要原則:

  • 若是提供的操做只被一個或幾個子類使用,將操做加入基類獲益不會太多。 你向基類添加了會影響全部事物的複雜性,可是隻有少數幾個類受益。

讓該操做與其餘提供的操做保持一致或許有價值,但讓使用操做的子類直接調用外部系統也許更簡單明瞭。

  • 當你調用遊戲中其餘地方的方法,若是方法沒有修改狀態就有更少的干擾。 它仍然製造耦合,可是這是安全的耦合,由於它沒有破壞遊戲中的任何東西。

安全的在這裏打了引號是由於嚴格來講,接觸數據也能形成問題。 若是你的遊戲是多線程的,讀取的數據可能正在被修改。若是你不當心,就會讀入錯誤的數據。

另外一個不愉快的狀況是,若是你的遊戲狀態是嚴格肯定性的(不少在線遊戲爲了保持玩家同步都是這樣的)。 接觸了遊戲同步狀態以外的東西會形成極糟的不肯定性漏洞。

另外一方面,修改狀態的調用會和代碼庫的其餘方面緊密綁定,你須要三思。打包他們成基類提供的操做是個好的候選項。

  • 若是操做只是增長了向外部系統的轉發調用,那它就沒增長太多價值。那種狀況下,也許直接調用外部系統的方法更簡單。

可是,簡單的轉發也是有用的——那些方法接觸了基類不想直接暴露給子類的狀態。 舉個例子,假設Superpower提供這個:

void playSound(SoundId sound, double volume)
{
  soundEngine_.play(sound, volume);
}

它只是轉發調用給SuperpowersoundEngine_字段。 可是,好處是將字段封裝在Superpower中,避免子類接觸。

方法應該直接提供,仍是包在對象中提供?

這個模式的挑戰是基類中最終加入了不少方法。 你能夠將一些方法移到其餘類中來緩和。基類經過返回對象提供方法。

舉個例子,爲了讓超能力播放聲音,咱們能夠直接將它們加到Superpower中:

class Superpower
{
protected:
  void playSound(SoundId sound, double volume)
  {
    // 實現代碼……
  }
 
  void stopSound(SoundId sound)
  {
    // 實現代碼……
  }
 
  void setVolume(SoundId sound)
  {
    // 實現代碼……
  }
 
  // 沙盒方法和其餘操做……
};

可是若是Superpower已經很龐雜了,咱們也許想要避免這樣。 取而代之的是建立SoundPlayer類暴露該函數:

class SoundPlayer
{
  void playSound(SoundId sound, double volume)
  {
    // 實現代碼……
  }
 
  void stopSound(SoundId sound)
  {
    // 實現代碼……
  }
 
  void setVolume(SoundId sound)
  {
    // 實現代碼……
  }
};

Superpower提供了對其的接觸:

class Superpower
{
protected:
  SoundPlayer& getSoundPlayer()
  {
    return soundPlayer_;
  }
 
  // 沙箱方法和其餘操做……
 
private:
  SoundPlayer soundPlayer_;
};

將提供的操做分流到輔助類能夠爲你作一些事情:

  • 減小了基類中的方法。 在這裏的例子中,將三個方法變成了一個簡單的獲取函數。
  • 在輔助類中的代碼一般更好管理。 Superpower的核心基類,無論意圖如何好,它被太多的類依賴而很難改變。 經過將函數移到耦合較少的次要類,代碼變得更容易被使用而不破壞任何東西。
  • 減小了基類和其餘系統的耦合度。 playSound()方法直接在Superpower時,基類與SoundId以及其餘涉及的音頻代碼直接綁定。 將它移動到SoundPlayer中,減小了SuperpowerSoundPlayer類的耦合,這就封裝了它其餘的依賴。

基類如何得到它須要的狀態?

你的基類常常須要將對子類隱藏的數據封裝起來。 在第一個例子中,Superpower類提供了spawnParticles()方法。 若是方法的實現須要一些粒子系統對象,怎麼得到呢?

  • 將它傳給基類構造器:

最簡單的解決方案是讓基類將其做爲構造器變量:

class Superpower
{
public:
  Superpower(ParticleSystem* particles)
  : particles_(particles)
  {}
 
  // 沙箱方法和其餘操做……
 
private:
  ParticleSystem* particles_;
};

這安全地保證了每一個超能力在構造時能獲得粒子系統。但讓咱們看看子類:

class SkyLaunch : public Superpower
{
public:
  SkyLaunch(ParticleSystem* particles)
  : Superpower(particles)
  {}
};

咱們在這兒看到了問題。每一個子類都須要構造器調用基類構造器並傳遞變量。這讓子類接觸了咱們不想要它知道的狀態。

這也形成了維護的負擔。若是咱們後續向基類添加了狀態,每一個子類都須要修改並傳遞這個狀態。

  • 使用兩階初始化:

爲了不經過構造器傳遞全部東西,咱們能夠將初始化劃分爲兩個部分。 構造器不接受任何參數,只是建立對象。而後,咱們調用定義在基類的分離方法傳入必要的數據:

Superpower* power = new SkyLaunch();
power->init(particles);

注意咱們沒有爲SkyLaunch的構造器傳入任何東西,它與Superpower中想要保持私有的任何東西都不耦合。 這種方法的問題在於,你要保證永遠記得調用init(),若是忘了,你會得到處於半完成的,沒法運行的超能力。

你能夠將整個過程封裝到一個函數中來修復這一點,就像這樣:

Superpower* createSkyLaunch(ParticleSystem* particles)
{
  Superpower* power = new SkyLaunch();
  power->init(particles);
  return power;
}

使用一點像私有構造器和友類的技巧,你能夠保證createSkylaunch()函數是惟一可以建立能力的函數。 這樣,你不會忘記任何初始化步驟。

  • 讓狀態靜態化:

在先前的例子中,咱們用粒子系統初始化每個Superpower實例 在每一個能力都須要本身獨特的狀態時這是有意義的。可是若是粒子系統是單例,那麼每一個能力都會分享相同的狀態。

若是是這樣,咱們能夠讓狀態是基類私有而靜態的。 遊戲仍然要保證初始化狀態,可是它只須要爲整個遊戲初始化Superpower一遍,而不是爲每一個實例初始化一遍。

記住單例仍然有不少問題。你在不少對象中分享了狀態(全部的Superpower實例)。 粒子系統被封裝了,所以它不是全局可見的,這很好,但它們都訪問同一對象,這讓分析更加困難了。

class Superpower
{
public:
  static void init(ParticleSystem* particles)
  {
    particles_ = particles;
  }
 
  // 沙箱方法和其餘操做……
 
private:
  static ParticleSystem* particles_;
};

注意這裏的init()particles_都是靜態的。 只要遊戲早先調用過一次Superpower::init(),每種能力都能接觸粒子系統。 同時,能夠調用正確的推導類構造器來自由建立Superpower實例。

更棒的是,如今particles_靜態變量, 咱們不須要在每一個Superpower中存儲它,這樣咱們的類佔據的內存更少了。

  • 使用服務定位器:

前一選項中,外部代碼要在基類請求前壓入基類須要的所有狀態。 初始化的責任交給了周圍的代碼。另外一選項是讓基類拉取它須要的狀態。 而作到這點的一種實現方法是使用服務定位器模式:

class Superpower
{
protected:
  void spawnParticles(ParticleType type, int count)
  {
    ParticleSystem& particles = Locator::getParticles();
    particles.spawn(type, count);
  }
 
  // 沙箱方法和其餘操做……
};

這兒,spawnParticles()須要粒子系統,不是外部系統它,而是它本身從服務定位器中拿了一個。

參見

  • 當你使用更新模式時,你的更新函數一般也是沙箱方法。
  • 這個模式與模板方法正相反。 兩種模式中,都使用一系列受限操做實現方法。 使用子類沙箱時,方法在推導類中,受限操做在基類中。 使用模板方法時,基類 有方法,而受限操做在推導類中。
  • 你也能夠認爲這個模式是外觀模式的變形。 外觀模式將一系列不一樣系統藏在簡化的API後。使用子類沙箱,基類起到了在子類前隱藏整個遊戲引擎的做用。

4.3類型對象

遊戲設計模式Behavioral Patterns

意圖

創造一個類A來容許靈活地創造新類型,類A的每一個實例都表明了不一樣的對象類型。

動機

想象咱們在製做一個奇幻RPG遊戲。 咱們的任務是爲一羣想要殺死英雄的惡毒怪物編寫代碼。 怪物有多個的屬性:生命值,攻擊力,圖形效果,聲音表現,等等。 可是爲了說明介紹的目的咱們先只考慮前面兩個。

遊戲中的每一個怪物都有當前血值。 開始時是滿的,每次怪物受傷,它就降低。 怪物也有一個攻擊字符串。 當怪物攻擊咱們的英雄,那個文本就會以某種方式展現給用戶。 (咱們不在意這裏怎樣實現。)

設計者告訴咱們怪物有不一樣品種,像或者巨魔 每一個品種都描述了一存在於遊戲中的怪物,同時可能有多個同種怪物在地牢裏遊蕩。

品種決定了怪物的初始健康——龍開始的血量比巨魔多,它們更難被殺死。 這也決定了攻擊字符——同種的全部怪物都以相同的方式進行攻擊。

傳統的面向對象方案

想着這樣的設計方案,咱們啓動了文本編輯器開始編程。 根據設計,龍是一種怪物,巨魔是另外一種,其餘品種的也同樣。 用面向對象的方式思考,這引導咱們建立Monster基類。

這是一種「是某物」的關係。 在傳統OOP思路中,因爲龍「是」怪物,咱們用DragonMonster的子類來描述這點。 如咱們將看到的,繼承是一種將這種關係表示爲代碼的方法。

class Monster
{
public:
  virtual ~Monster() {}
  virtual const char* getAttack() = 0;
 
protected:
  Monster(int startingHealth)
  : health_(startingHealth)
  {}
 
private:
  int health_; // 當前血值
};

在怪物攻擊英雄時,公開的getAttack()函數讓戰鬥代碼能得到須要顯示的文字。 每一個子類都須要重載它來提供不一樣的消息。

構造器是protected的,須要傳入怪物的初始血量。 每一個品種的子類的公共構造器調用這個構造器,傳入對於該品種適合的起始血量。

如今讓咱們看看兩個品種子類:

class Dragon : public Monster
{
public:
  Dragon() : Monster(230) {}
 
  virtual const char* getAttack()
  {
    return "The dragon breathes fire!";
  }
};
 
class Troll : public Monster
{
public:
  Troll() : Monster(48) {}
 
  virtual const char* getAttack()
  {
    return "The troll clubs you!";
  }
};

感嘆號讓全部事情都更刺激!

每一個從Monster派生出來的類都傳入起始血量,重載getAttack()返回那個品種的攻擊字符串。 全部事情都一如所料地運行,不久之後,咱們的英雄就能夠跑來跑去殺死各類野獸了。 咱們繼續編程,在乎識到以前,咱們就有了從酸泥怪到殭屍羊的衆多怪物子類。

而後,很奇怪,事情陷入了困境。 設計者最終想要幾百個品種,可是咱們發現全部的時間都花費在寫這些只有七行長的子類和從新編譯上。 這會繼續變糟——設計者想要協調已經編碼的品種。咱們以前富有產出的工做日退化成了:

  1. 收到設計者將巨魔的血量從48改到52的郵件。
  2. 簽出並修改Troll.h
  3. 從新編譯遊戲。
  4. 簽入修改。
  5. 回覆郵件。
  6. 重複。

咱們度過了失意的一天,由於咱們變成了填數據的猴子。 設計者也感到挫敗,由於修改一個數據就要老久。 咱們須要的是一種無需每次從新編譯遊戲就能修改品種的狀態。 若是設計者建立和修改品種時無需任何程序員的介入那就更好了。

爲類型建類

從較高的層次看來,咱們試圖解決的問題很是簡單。 遊戲中有不少不一樣的怪物,咱們想要在它們之間分享屬性。 一大羣怪物在攻擊英雄,咱們想要它們中的一些使用相同的攻擊文本。 咱們聲明這些怪物是相同的品種,而品種決定了攻擊字符串。

這種狀況下咱們很容易想到類,那就試試吧。 龍是怪物,每條龍都是龍的實例。 定義每一個品種爲抽象基類Monster 的子類,讓遊戲中每一個怪物都是子類的實例反映了那點。最終的類層次是這樣的:

這裏的意爲「從……繼承」。

每一個怪物的實例屬於某個繼承怪物類的類型。 咱們有的品種越多,類層次越高。 這固然是問題:添加新品種就須要添加新代碼,而每一個品種都須要被編譯爲它本身的類型。

這可行,但不是惟一的選項。 咱們也能夠重構代碼讓每一個怪物品種。 不是讓每一個品種繼承Monster,咱們如今有單一的Monster類和Breed類。

這裏意爲「被……引用」。

這就成了,就兩個類。注意這裏徹底沒有繼承。 經過這個系統,遊戲中的每一個怪物都是Monster的實例。 Breed類包含了在不一樣品種怪物間分享的信息:開始血量和攻擊字符串。

爲了將怪物與品種相關聯,咱們給了每一個Monster實例對包含品種信息的Breed對象的引用。 爲了得到攻擊字符串,一個怪獸能夠調用它品種的方法。 Breed類本質上定義了一個怪物的類型,這就是爲啥這個模式叫作類型對象。

這個模式特別有用的一點是,咱們如今能夠定義全新的類型而無需攪亂代碼庫。 咱們本質上將部分的類型系統從硬編碼的繼承結構中拉出,放到能夠在運行時定義的數據中去。

咱們能夠經過用不一樣值實例化Monster來建立成百上千的新品種。 若是從配置文件讀取不一樣的數據初始化品種,咱們就有能力徹底靠數據定義新怪物品種。 這麼容易,設計者也能夠作到!

模式

定義類型對象類和有類型的對象類。每一個類型對象實例表明一種不一樣的邏輯類型。 每種有類型的對象保存對描述它類型的類型對象的引用

實例相關的數據被存儲在有類型對象的實例中,被同種類分享的數據或者行爲存儲在類型對象中。 引用同一類型對象的對象將會像同一類型同樣運做。 這讓咱們在一組相同的對象間分享行爲和數據,就像子類讓咱們作的那樣,但沒有固定的硬編碼子類集合。

什麼時候使用

在任何你須要定義不一樣事物,可是語言自身的類型系統過於僵硬的時候使用該模式。尤爲是下面二者之一成立時:

  • 你不知道你後面還須要什麼類型。(舉個例子,若是你的遊戲須要支持資料包,而資料包有新的怪物品種呢?)
  • 想不改變代碼或者從新編譯就能修改或添加新類型。

記住

這個模型是關於將類型的定義從命令式僵硬的語言世界移到靈活可是缺乏行爲的對象內存世界。 靈活性很好,可是將類型提到數據喪失了一些東西。

須要手動追蹤類型對象

使用像C++類型系統這種東西的好處之一就是編譯器自動記錄類的註冊。 定義類的數據自動編譯到可執行的靜態內存段而後就運做起來了。

使用類型對象模式,咱們如今不但要負責管理內存中的怪物,同時要管理它們的類型 ——咱們要保證,只要個人怪物須要,全部的品種對象都能實例化並保存在內存中。 不管什麼時候建立新的怪物,由咱們來保證能初始化爲含有品種的引用。

咱們從編譯器的限制中解放了本身,可是代價是須要從新實現一些它之前爲咱們作的事情。

C++內部使用了「虛函數表」(「vtable」)實現虛方法。 虛函數表是個簡單的struct,包含了一集合函數指針,每一個對應一個類中的虛方法。 在內存中每一個類有一個虛函數表。每一個類的實例有一個指針指向它的類的虛函數表。

當你調用一個虛函數,代碼首先在虛函數表中查找對象,而後調用表中函數指針指向的函數。

聽起來很熟悉?虛函數表就是個品種對象,而指向虛函數表的指針是怪物保留的、指向品種的引用。 C++的類是C中的類型對象,由編譯器自動處理。

更難爲每種類型定義行爲

使用子類派生,你能夠重載方法,而後作你想作的事——用程序計算值,調用其餘代碼,等等。 天高任鳥飛。若是咱們想的話,能夠定義一個怪物子類,根據月亮的階段改變它的攻擊字符串。(我以爲就像狼人。)

當咱們使用類型對象模式時,咱們將重載的方法替換成了成員變量。 再也不讓怪物的子類重載方法,用不一樣的代碼計算攻擊字符串,而是讓咱們的品種對象在不一樣的變量存儲攻擊字符串。

這讓使用類型對象定義類型相關的數據變得容易,可是定義類型相關的行爲變得困難。 若是,舉個例子,不一樣品種的怪物須要使用不一樣的AI算法,使用這個模式就面臨着挑戰。

有不少方式能夠讓咱們跨越這個限制。 一個簡單的方式是使用預先定義的固定行爲, 而後類型對象中的數據簡單地選擇它們中的一個。 舉例,假設咱們的怪物AI老是處於站着不動追逐英雄或者恐懼地嗚咽顫抖(嘿,他們不可能都是強勢的龍)狀態。 咱們能夠定義函數來實現每種行爲。 而後,咱們在方法中存儲合適函數的引用,將AI算法與品種相關聯。

聽起來很熟悉?這是在咱們的類型對象中實現虛函數表。

另外一個更加完全的解決方案是真正地在數據中支持定義行爲。 解釋器模式和字節碼模式讓咱們定義有行爲的對象。 若是咱們讀取數據文件並用上面兩種模式之一構建數據結構,咱們就將行爲徹底從代碼中移出,放入了數據之中。

時過境遷,遊戲愈來愈多地由數據驅動。 硬件變得更爲強大,咱們發現比起能榨乾多少硬件的性能,瓶頸更多於在能完成多少內容。 使用64K軟盤的時代,挑戰是將遊戲塞入其中。 而在使用雙面DVD的時代,挑戰是用遊戲填滿它。

腳本語言和其餘定義遊戲行爲的高層方式能給咱們提供必要的生產力,同時只消耗可預期的運行時性能。 因爲硬件愈來愈好,而大腦並不是如此,這種交換愈來愈有意義。

示例代碼

在第一遍實現中,讓咱們從簡單的開始,只構建動機那節提到的基礎系統。 咱們從Breed類開始:

class Breed
{
public:
  Breed(int health, const char* attack)
  : health_(health),
    attack_(attack)
  {}
 
  int getHealth() { return health_; }
  const char* getAttack() { return attack_; }
 
private:
  int health_; // 初始血值
  const char* attack_;
};

很簡單。它基本上只是兩個數據字段的容器:起始血量和攻擊字符串。 讓咱們看看怪物怎麼使用它:

class Monster
{
public:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}
 
  const char* getAttack()
  {
    return breed_.getAttack();
  }
 
private:
  int    health_; // 當前血值
  Breed& breed_;
};

當咱們建構怪物時,咱們給它一個品種對象的引用。 它定義了怪物的品種,取代了以前的子類。 在構造函數中,Monster使用的品種決定了起始血量。 爲了得到攻擊字符串,怪物簡單地將調用轉發給它的品種。

這段很是簡單的代碼是這章的核心思路。剩下的任何東西都是紅利。

讓類型對象更像類型:構造器

如今,咱們能夠直接構造怪物並負責傳入它的品種。 和經常使用的OOP語言實現的對象相比這有些退步——咱們一般不會分配一塊空白內存,而後賦予它類型。 相反,咱們根據類調用構造器,它負責建立一個新實例。

咱們能夠在類型對象上應用一樣的模式。

class Breed
{
public:
  Monster* newMonster() { return new Monster(*this); }
 
  // Previous Breed code...
};

「模式」一詞用在這裏正合適。咱們討論的是設計模式中經典的模式:工廠方法

在一些語言中,這個模式被用來構造全部的對象。 在Ruby,Smalltalk,Objective-C以及其餘類是對象的語言中,你經過在類對象自己上調用方法來構建實例。

以及那個使用它們的類:

class Monster
{
  friend class Breed;
 
public:
  const char* getAttack() { return breed_.getAttack(); }
 
private:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}
 
  int health_; // 當前血值
  Breed& breed_;
};

不一樣的關鍵點在於Breed中的newMonster() 這是咱們的構造器工廠方法。使用咱們原先的實現,就像這樣建立怪物:

這裏還有一個小小的不一樣。 由於樣例代碼由C++寫就,咱們能夠使用一個小小的特性:友類

咱們讓Monster的構造器成爲私有,防止了任何人直接調用它。 友類放鬆了這個限制,Breed仍可接觸它。 這意味着構造怪物的惟一方法是經過newMonster()

Monster* monster = new Monster(someBreed);

在咱們改動後,它看上去是這樣:

Monster* monster = someBreed.newMonster();

因此,爲何這麼作?建立一個對象分爲兩步:內存分配和初始化。 Monster的構造器讓咱們作完了全部須要的初始化。 在例子中,那隻存儲了類型;可是在完整的遊戲中,那須要加載圖形,初始化怪物AI以及作其餘的設置工做。

可是,那都發生在內存分配以後 在構造器調用前,咱們已經找到了內存放置怪物。 在遊戲中,咱們一般也想控制對象創造這一環節: 咱們一般使用自定義的分配器或者對象池模式來控制對象最終在內存中的位置。

Breed中定義構造器函數給了咱們地方實現這些邏輯。 不是簡單地調用new,newMonster()函數能夠在將控制權傳遞給Monster初始化以前,從池中或堆中獲取內存。 經過在惟一有能力建立怪物的Breed函數中放置這些邏輯, 咱們保證了全部怪物變量遵照了內存管理規範。

經過繼承分享數據

咱們如今已經實現了能完美服務的類型對象系統,可是它很是基礎。 咱們的遊戲最終有上百種不一樣品種,每種都有成打的特性。 若是設計者想要協調30種不一樣的巨魔,讓它們變得強壯一點,他會得處理不少數據。

能幫上忙的是在不一樣品種間分享屬性的能力,一如品種在不一樣的怪物間分享屬性的能力。 就像咱們在以前OOP方案中作的那樣,咱們能夠使用派生完成這點。 只是,此次,不使用語言的繼承機制,咱們用類型對象實現它。

簡單起見,咱們只支持單繼承。 就像類能夠有一個父類,咱們容許品種有一個父品種:

class Breed
{
public:
  Breed(Breed* parent, int health, const char* attack)
  : parent_(parent),
    health_(health),
    attack_(attack)
  {}
 
  int         getHealth();
  const char* getAttack();
 
private:
  Breed*      parent_;
  int         health_; // 初始血值
  const char* attack_;
};

當咱們構建一個品種,咱們先傳入它繼承的父品種。 咱們能夠爲基礎品種傳入NULL代表它沒有祖先。

爲了讓這有用,子品種須要控制它從父品種繼承了哪些屬性,以及哪些屬性須要重載並由本身指定。 在咱們的示例系統中,咱們能夠說品種用非零值重載了怪物的健康,用非空字符串重載了攻擊字符串。 不然,這些屬性要從它的父品種裏繼承。

實現方式有兩種。 一種是每次屬性被請求時動態處理委託,就像這樣:

int Breed::getHealth()
{
  // 重載
  if (health_ != 0 || parent_ == NULL) return health_;
 
  // 繼承
  return parent_->getHealth();
}
 
const char* Breed::getAttack()
{
  // 重載
  if (attack_ != NULL || parent_ == NULL) return attack_;
 
  // 繼承
  return parent_->getAttack();
}

若是品種在運行時修改種類,再也不重載,或者再也不繼承某些屬性時,這能保證作正確的事。 另外一方面,這要更多的內存(它須要保存指向它的父品種的指針)並且更慢。 每次你查找屬性都須要回溯繼承鏈。

若是咱們能夠保證品種的屬性不變,一個更快的解決方案是在構造時使用繼承。 這被稱爲複製委託,由於在建立對象時,咱們複製繼承的屬性推導的類型。它看上去是這樣的:

Breed(Breed* parent, int health, const char* attack)
: health_(health),
  attack_(attack)
{
  // 繼承沒有重載的屬性
  if (parent != NULL)
  {
    if (health == 0) health_ = parent->getHealth();
    if (attack == NULL) attack_ = parent->getAttack();
  }
}

注意如今咱們再也不須要給父品種的字段了。 一旦構造器完成,咱們能夠忘了父品種,由於咱們已經拷貝了它的全部屬性。 爲了得到品種的屬性,咱們如今直接返回字段:

int         getHealth() { return health_; }
const char* getAttack() { return attack_; }

又好又快!

假設遊戲引擎從品種的JSON文件加載設置而後建立類型。它看上去是這樣的:

{
  "Troll": {
    "health": 25,
    "attack": "The troll hits you!"
  },
  "Troll Archer": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll archer fires an arrow!"
  },
  "Troll Wizard": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll wizard casts a spell on you!"
  }
}
 
:::json
{
  "Troll": {
    "health": 25,
    "attack": "The troll hits you!"
  },
  "Troll Archer": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll archer fires an arrow!"
  },
  "Troll Wizard": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll wizard casts a spell on you!"
  }
}

咱們有一段代碼讀取每一個品種,用新數據實例化品種實例。 就像你從"parent": "Troll"字段看到的, Troll ArcherTroll Wizard品種都由基礎Troll品種繼承而來。

因爲派生類的初始血量都是0,因此該值從基礎Troll品種繼承。 這意味着不管怎麼調整Troll的血量,三個品種的血量都會被更新。 隨着品種的數量和屬性的數量增長,這節約了不少時間。 如今,經過一小塊代碼,系統給了設計者控制權,讓他們能好好利用時間。 與此同時,咱們能夠回去編碼其餘特性了。

設計決策

類型對象模式讓咱們創建類型系統,就好像在設計本身的編程語言。 設計空間是開放的,咱們能夠作不少有趣的事情。

在實踐中,有些東西打破了咱們的幻想。 時間和可維護性阻止咱們建立特別複雜的東西。 更重要的是,不管如何設計類型系統,用戶(一般不是程序員)要能輕鬆地理解它。 咱們將其作得越簡單,它就越有用。 因此咱們在這裏談到的是已經反覆探索的領域,開闢新路就留給學者和探索者吧。

類型對象是封裝的仍是暴露的?

在咱們的簡單實現中,Monster有一個對品種的引用,可是它沒有顯式暴露這個引用。 外部代碼不能直接獲取怪物的品種。 從代碼庫的角度看來,怪物事實上是沒有類型的,事實上它們擁有品種只是個實現細節。

咱們能夠很容易地改變這點,讓Monster返回它的Breed

class Monster
{
public:
  Breed& getBreed() { return breed_; }
 
  // 當前的代碼……
};

在本書的另外一個例子中,咱們遵照了慣例,返回對象的引用而不是對象的指針,保證了永遠不會返回NULL

這樣作改變了Monster的設計。 事實是全部怪物都擁有品種是API的可見部分了,下面是這二者各自的好處:

  • 若是類型對象是封裝的:
    • 類型對象模式的複雜性對代碼庫的其餘部分是隱藏的。 它成爲了只有有類型的對象才須要考慮的實現細節。
    • 有類型的對象能夠選擇性地修改類型對象的重載行爲 假設咱們想要怪物在它接近死亡時改變它的攻擊字符串。 因爲攻擊字符串老是經過Monster獲取的,咱們有一個方便的地方放置代碼:
    • cnst char* Mnster::getAttack()
    • {
    •   if (health_ < LOW_HEALTH)
    •   {
    •     return "The mnster flails weakly.";
    •   }
    •  
    •   return breed_.getAttack();
    • }

若是外部代碼直接調用品種的getAttack(),咱們就沒有機會能插入邏輯。

    • 咱們得爲每一個類型對象暴露的方法寫轉發。 這是這個設計的冗長之處。若是類型對象有不少方法,對象類也得爲每個方法創建屬於本身的公共可見方法。
  • 若是類型對象是暴露的:
    • 外部代碼能夠與類型對象直接交互,無需擁有類型對象的實例。 若是類型對象是封裝的,那麼沒有一個擁有它的對象就無法使用它。 這阻止咱們使用構造器模式這樣的方法,在品種上調用方法來建立新怪物。 若是用戶不能直接得到品種,他們就沒辦法調用它。
    • 類型對象如今是對象公共API的一部分了。 大致上,窄接口比寬接口更容易掌控——你暴露給代碼庫其餘部分的越少,你須要處理的複雜度和維護工做就越少。 經過暴露類型對象,咱們擴寬了對象的API,包含了全部類型對象提供的東西。

有類型的對象是如何建立的?

使用這個模式,每一個對象如今都是一對對象:主對象和它的類型對象。 因此咱們怎樣建立並綁定二者呢?

  • 構造對象而後傳入類型對象:
    • 外部代碼能夠控制分配。 因爲調用代碼也是構建對象的代碼,它能夠控制其內存位置。 若是咱們想要UI在多種內存場景中使用(不一樣的分配器,在棧中,等等),這給了完成它的靈活性。
  • 在類型對象上調用構造器函數:
    • 類型對象控制了內存分配。 這是硬幣的另外一面。若是咱們不想讓用戶選擇在內存中何處建立對象, 在類型對象上調用工廠方法能夠達到這一點。 若是咱們想保證全部的對象都來自具體的對象池或者其餘的內存分配器時也有用。

能改變類型嗎?

到目前爲止,咱們假設一旦對象建立並綁定到類型對象上,這永遠不會改變。 對象建立時的類型就是它銷燬時的類型。這其實沒有必要。 咱們能夠容許對象隨着時間改變它的類型。

讓咱們回想下咱們的例子。 當怪物死去時,設計者告訴咱們,有時它的屍體會復活成殭屍。 咱們能夠經過在怪物死亡時產生殭屍類型的新怪獸,但另外一個選項是拿到現有的怪物,而後將它的品種改成殭屍。

  • 若是類型不改變:
    • 編碼和理解都更容易。 在概念上,大多數人不指望類型會改變。這符合大多數人的理解。
    • 更容易查找漏洞。 若是咱們試圖追蹤怪物進入奇怪狀態時的漏洞,如今看到的品種就是怪物始終保持的品種能夠大大簡化工做。
  • 若是類型能夠改變:
    • 須要建立的對象更少。 在咱們的例子中,若是類型不能改變,咱們須要消耗CPU循環建立新的殭屍怪物對象, 把原先對象中須要保留的屬性都拷貝過來,而後刪除它。 若是咱們能夠改變類型,全部的工做都被一個簡單的聲明取代。
    • 咱們須要當心地作約束。 在對象和它的類型間有強耦合是很天然的事情。 舉個例子,一個品種也許假設怪物當前的血量永遠高於品種中的初始血量。

若是咱們容許品種改變,咱們須要確保已存對象知足新品種的需求。 當咱們改變類型時,咱們也許須要執行一些驗證代碼保證對象如今的狀態對新類型是有意義的。

它支持何種繼承?

  • 沒有繼承:
    • 簡單。 最簡單的一般是最好的。若是你在類型對象間沒有大量數據共享,爲何要爲難本身呢?
    • 這會帶來重複的工做。 我從未見過哪一個編碼系統中設計者想要繼承的。 當你有十五種不一樣的精靈時,協調血量就要修改十五處一樣的數字真是糟透了。
  • 單繼承:
    • 仍是相對簡單。 它易於實現,可是,更重要的是,也易於理解。若是非技術用戶正在使用這個系統,要操做的部分越少越好。 這就是不少編程語言只支持單繼承的緣由。這看起來是能力和簡潔之間的平衡點。
    • 查詢屬性更慢。 爲了在類型對象中獲取一塊數據,咱們也許須要回溯繼承鏈尋找是哪個類型最終決定了值。 在性能攸關的代碼上,咱們也許不想花時間在這上面。
  • 多重繼承:
    • 能夠避免絕大多數代碼重複。 使用優良的多繼承系統,用戶能夠爲類型對象創建幾乎沒有冗餘的層次。 改變數值時,咱們能夠避免不少複製和粘貼。
    • 複雜。 不幸的是,它的好處更多地是理論上的而非實際上的。多重繼承很難理解。

若是殭屍龍繼承殭屍和龍,哪些屬性來自殭屍,哪些來自於龍? 爲了使用系統,用戶須要理解如何遍歷繼承圖,還須要有設計優秀層次的遠見。

我看到的大多數C++編碼標準趨向于禁止多重繼承,JavaC#徹底移除了它。 這認可了一個悲傷的事實:它太難掌握了,最好根本不要用。 儘管值得考慮,但你不多想要在類型對象上實現多重繼承。就像往常同樣,簡單的老是最好的。

參見

  • 這個模式處理的高層問題是在多個對象間分享數據和行爲。 另外一個用另外一種方式解決了相同問題的模式是原型模式。
  • 類型對象是享元模式的近親。 二者都讓你在實例間分享代碼。使用享元,意圖是節約內存,而分享的數據也許不表明任何概念上對象的類型 使用類型對象模式,焦點在組織性和靈活性。
  • 這個模式和狀態模式有不少類似之處。 二者都委託對象的部分定義給另一個對象。 經過類型對象,咱們一般委託了對象什麼:不變的數據歸納描述對象。 經過狀態,咱們委託了對象如今是什麼:暫時描述對象當前狀態的數據。

當咱們討論對象改變它的類型時,你能夠認爲類型對象起到了和狀態類似的職責。

第五章 解耦模式

遊戲設計模式

一旦你掌握了編程語言,編寫想要寫的東西就會變得至關容易。 困難的是編寫適應需求變化的代碼,在咱們用文本編輯器開火以前,一般沒有完美的特性表供咱們使用。

能讓咱們更好地適應變化的工具是解耦 當咱們說兩塊代碼解耦時,是指修改一塊代碼通常不會須要修改另外一塊代碼。 當咱們修改遊戲中的特性時,須要修改的代碼越少,就越容易。

組件模式將一個實體拆成多個,解耦不一樣的領域。 事件序列解耦了兩個互相通訊的事物,穩定並且及時 服務定位器讓代碼使用服務而無需綁定到提供服務的代碼。

模式

5.1組件模式

遊戲設計模式Decoupling Patterns

意圖

容許單一的實體跨越多個領域而不會致使這些領域彼此耦合。

動機

讓咱們假設咱們正在製做平臺跳躍遊戲。 意大利水管工已經有人作了,所以咱們將出動丹麥麪包師,Bjorn 照理說,會有一個類來表示友好的糕點廚師,包含他在遊戲中作的一切。

像這樣的遊戲創意致使了我是程序員而不是設計師。

因爲玩家控制着他,這意味着須要讀取控制器的輸入而後轉化爲動做。 並且他固然須要與關卡進行互動,因此要引入物理和碰撞。 一旦這樣作了,他就必須在屏幕上出現,因此要引入動畫和渲染。 他可能還會播放一些聲音。

等一下,這一切正在失控。軟件體系結構101課程告訴咱們,程序的不一樣領域應保持分離。 若是咱們作一個文字處理器,處理打印的代碼不該該受加載和保存文件的代碼影響。 遊戲和企業應用程序的領域不盡相同,但該規則仍然適用。

咱們但願AI,物理,渲染,聲音和其餘領域域儘量相互不瞭解, 但如今咱們將全部這一切擠在一個類中。 咱們已經看到了這條路通往何處:5000行的巨大代碼文件,哪怕是大家團隊中最勇敢的程序員也不敢打開。

這工做對能馴服他的少數人來講是有趣的,但對其餘人而言是地獄。 這麼大的類意味着,即便是看似微不足道的變化亦可有深遠的影響。 很快,爲類添加錯誤的速度會明顯快於添加功能的速度。

一團亂麻

比起單純的規模問題,更糟糕的是耦合。 在遊戲中,全部不一樣的系統被綁成了一個巨大的代碼球:

if (collidingWithFloor() && (getRenderState() != INVISIBLE))
{
  playSound(HIT_FLOOR);
}

任何試圖改變上面代碼的程序員,都須要物理,圖形和聲音的相關知識,以確保沒破壞什麼。

這樣的耦合在任何遊戲中出現都是個問題,可是在使用併發的現代遊戲中尤爲糟糕。 在多核硬件上,讓代碼同時在多個線程上運行是相當重要的。 將遊戲分割爲多線程的一種通用方法是經過領域劃分——在一個核上運行AI代碼,在另外一個上播放聲音,在第三個上渲染,等等。

一旦你這麼作了,在領域間保持解耦就是相當重要的,這是爲了不死鎖或者其餘噩夢般的併發問題。 若是某個函數從一個線程上調用UpdateSounds()方法,從另外一個線程上調用RenderGraphics()方法,那它是在自找麻煩。

這兩個問題互相混合;這個類涉及太多的域,每一個程序員都得接觸它, 但它又太過巨大,這就變成了一場噩夢。 若是變得夠糟糕,程序員會黑入代碼庫的其餘部分,僅僅爲了躲開這個像毛球同樣的Bjorn類。

快刀斬亂麻

咱們能夠像亞歷山大大帝同樣解決這個問題——快刀斬亂麻。 按領域將Bjorn類割成相互獨立的部分。 例如,抽出全部處理用戶輸入的代碼,將其移動到一個單獨的InputComponent類。 Bjorn擁有這個部件的一個實例。咱們將對Bjorn接觸的每一個領域重複這一過程。

當完成後,咱們就將Bjorn大多數的東西都抽走了。 剩下的是一個薄殼包着全部的組件。 經過將類劃分爲多個小類,咱們已經解決了這個問題。但咱們所完成的遠不止如此。

寬鬆的結果

咱們的組件類如今解耦了。 儘管BjornPhysicsComponentGraphicsComponent 但這兩部分都不知道對方的存在。 這意味着處理物理的人能夠修改組件而不須要了解圖形,反之亦然。

在實踐中,這些部件之間須要有一些相互做用。 例如,AI組件可能須要告訴物理組件Bjorn試圖去哪裏。 然而,咱們能夠將這種交互限制在確實須要交互的組件之間, 而不是把它們圍在同一個圍欄裏。

綁到一塊兒

這種設計的另外一特性是,組件如今是可複用的包。 到目前爲止,咱們專一於麪包師,可是讓咱們考慮幾個遊戲世界中其餘類型的對象。 裝飾 是玩家看到但不能交互的事物:灌木,雜物等視覺細節。 道具 裝飾,但能夠交互:箱,巨石,樹木。 區域 與裝飾相反——無形但可互動。 它們是很好的觸發器,好比在Bjorn進入區域時觸發過場動畫。

當面向對象語言第一次接觸這個場景時,繼承是它箱子裏最閃耀的工具。 它被認爲是代碼無限重用之錘,編程者經常揮舞着它。 然而咱們痛苦地學到,事實上它是一把重錘。 繼承有它的用處,但對簡單的代碼重用來講太過複雜。

相反,在今日軟件設計的趨勢是儘量使用組件代替繼承。 不是讓兩個類繼承同一類來分享代碼,而是讓它們擁有同一個類的實例

如今,考慮若是不用組件,咱們將如何創建這些類的繼承層次。第一遍多是這樣的:

咱們有GameObject基類,包含位置和方向之類的通用部分。 Zone繼承它,增長了碰撞檢測。 一樣,Decoration繼承GameObject,並增長了渲染。 Prop繼承Zone,所以它能夠重用碰撞代碼。 然而,Prop不能同時繼承Decoration來重用渲染 不然就會形成致命菱形結構。

「致命菱形」發生在類繼承了多個類,而這多個類中有兩個繼承同一基類時。 介紹它形成的痛苦超過了本書的範圍,但它被說成「致命」是有緣由的。

咱們能夠反過來讓Prop繼承Decoration,但隨後不得不重複碰撞檢測代碼。 不管哪一種方式,沒有乾淨的辦法重用碰撞和渲染代碼而不訴諸多重繼承。 惟一的其餘選擇是一切都繼承GameObject 但隨後Zone會浪費內存在並不須要的渲染數據上, Decoration在物理效果上有一樣的浪費。

如今,讓咱們嘗試用組件。子類將完全消失。 取而代之的是一個GameObject類和兩個組件類:PhysicsComponentGraphicsComponent 裝飾是個簡單的GameObject,包含GraphicsComponent但沒有PhysicsComponent 區域與其剛好相反,而道具包含兩種組件。 沒有代碼重複,沒有多重繼承,只有三個類,而不是四個。

能夠拿飯店菜單打比方。若是每一個實體是一個類,那就只能訂套餐。 咱們須要爲每種可能的組合定義各自的類。 爲了知足每位用戶,咱們須要十幾種套餐。

組件是照單點菜——每位顧客均可以選他們想要的,菜單記錄可選的菜式。

對對象而言,組件是即插即用的。 將不一樣的可重用部件插入對象,咱們就能構建複雜且具備豐富行爲的實體。 就像軟件中的戰神金剛。

模式

單一實體跨越了多個領域。爲了保持領域之間相互分離,將每部分代碼放入各自的組件類中。 實體被簡化爲組件的容器

「組件」,就像「對象」,在編程中意味任何東西也不意味任何東西。 正因如此,它被用來描述一些概念。 在商業軟件中,「組件」設計模式描述經過網絡解耦的服務。

我試圖從遊戲中找到無關這個設計模式的另外一個名字,但「組件」看來是最經常使用的術語。 因爲設計模式是記錄已存的實踐,我沒有建立新術語的餘地。 因此,跟着XNA,Delta3D和其餘人的腳步,我稱之爲「組件」。

什麼時候使用

組件一般在定義遊戲實體的核心部分中使用,但它們在其餘地方也有用。 這個模式應用在在以下狀況中:

  • 有一個涉及了多個領域的類,而你想保持這些領域互相隔離。
  • 一個類正在變大並且愈來愈難以使用。
  • 想要能定義一系列分享不一樣能力的類,可是使用繼承沒法讓你精確選取要重用的部分。

記住

組件模式比簡單地向類中添加代碼增長了一點點複雜性。 每一個概念上的對象要組成真正的對象須要實例化,初始化,而後正確地鏈接。 不一樣組件間溝通會有些困難,而控制它們如何使用內存就更加複雜。

對於大型代碼庫,爲了解耦和重用而付出這樣的複雜度是值得的。 可是在使用這種模式以前,保證你沒有爲了避免存在的問題而過分設計

使用組件的另外一後果是,須要多一層跳轉才能作要作的事。 拿到容器對象,得到相應的組件,而後 你才能作想作的事情。 在性能攸關的內部循環中,這種跳轉也許會致使糟糕的性能。

這是硬幣的兩面。組件模式一般能夠增進性能和緩存一致性。 組件讓使用數據局部性模式的CPU更容易組織數據。

示例代碼

我寫這本書的最大挑戰之一就是搞明白如何隔離各個模式。 許多設計模式包含了不屬於這種模式的代碼。 爲了將提取模式的本質,我儘量地消減代碼, 可是在某種程度上,這就像是沒有衣服還要說明如何整理衣櫃。

說明組件模式尤爲困難。 若是看不到它解耦的各個領域的代碼,你就不能得到正確的體會, 所以我會多寫一些有關於Bjorn的代碼。 這個模式事實上只關於將組件變爲,但類中的代碼能夠幫助代表類是作什麼用的。 它是僞代碼——它調用了其餘不存在的類——但這應該能夠讓你理解咱們正在作什麼。

單塊類

爲了清晰的看到這個模式是如何應用的, 咱們先展現一個Bjorn類, 它包含了全部咱們須要的事物,可是沒有使用這個模式:

我應指出在代碼中使用角色的名字老是個壞主意。市場部有在發售以前更名字的壞習慣。 「焦點測試代表,在11歲到15歲之間的男性不喜歡‘Bjorn’,請改成‘Sven‘」。

這就是爲何不少軟件項目使用內部代碼名。 並且比起告訴人們你在完成「Photoshop的下一版本」,告訴他們你在完成「大電貓」更有趣。

class Bjorn
{
public:
  Bjorn()
  : velocity_(0),
    x_(0), y_(0)
  {}
 
  void update(World& world, Graphics& graphics);
 
private:
  static const int WALK_ACCELERATION = 1;
 
  int velocity_;
  int x_, y_;
 
  Volume volume_;
 
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

Bjorn有個每幀調用的update()方法。

void Bjorn::update(World& world, Graphics& graphics)
{
  // 根據用戶輸入修改英雄的速度
  switch (Controller::getJoystickDirection())
  {
    case DIR_LEFT:
      velocity_ -= WALK_ACCELERATION;
      break;
 
    case DIR_RIGHT:
      velocity_ += WALK_ACCELERATION;
      break;
  }
 
  // 根據速度修改位置
  x_ += velocity_;
  world.resolveCollision(volume_, x_, y_, velocity_);
 
  // 繪製合適的圖形
  Sprite* sprite = &spriteStand_;
  if (velocity_ < 0)
  {
    sprite = &spriteWalkLeft_;
  }
  else if (velocity_ > 0)
  {
    sprite = &spriteWalkRight_;
  }
 
  graphics.draw(*sprite, x_, y_);
}

它讀取操縱桿以肯定如何加速麪包師。 而後,用物理引擎解析新位置。 最後,將Bjorn渲染至屏幕。

這裏的示例實現平凡而簡單。 沒有重力,動畫,或任何讓人物有趣的其餘細節。 即使如此,咱們能夠看到,已經出現了同時消耗多個程序員時間的函數,而它開始變得有點混亂。 想象增長到一千行,你就知道這會有多難受了。

分離領域

從一個領域開始,將Bjorn的代碼去除一部分,納入分離的組件類。 咱們從首個執行的領域開始:輸入。 Bjorn作的頭件事就是讀取玩家的輸入,而後基於此調整它的速度。 讓咱們將這部分邏輯移入一個分離的類:

class InputComponent
{
public:
  void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;
 
      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }
 
private:
  static const int WALK_ACCELERATION = 1;
};

很簡單吧。咱們將Bjornupdate()的第一部分取出,放入這個類中。 Bjorn的改變也很直接:

class Bjorn
{
public:
  int velocity;
  int x, y;
 
  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);
 
    // 根據速度修改位置
    x += velocity;
    world.resolveCollision(volume_, x, y, velocity);
 
    // 繪製合適的圖形
    Sprite* sprite = &spriteStand_;
    if (velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }
 
    graphics.draw(*sprite, x, y);
  }
 
private:
  InputComponent input_;
 
  Volume volume_;
 
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

Bjorn如今擁有了一個InputComponent對象。 以前它在update()方法中直接處理用戶輸入,如今委託給組件:

input_.update(*this);

咱們纔剛開始,但已經擺脫了一些耦合——Bjorn主體如今已經與Controller無關了。這會派上用場的。

將剩下的分割出來

如今讓咱們對物理和圖像代碼繼續這種剪切粘貼的工做。 這是咱們新的 PhysicsComponent

class PhysicsComponent
{
public:
  void update(Bjorn& bjorn, World& world)
  {
    bjorn.x += bjorn.velocity;
    world.resolveCollision(volume_,
        bjorn.x, bjorn.y, bjorn.velocity);
  }
 
private:
  Volume volume_;
};

爲了將物理行爲移出Bjorn類,你能夠看到咱們也移出了數據Volume對象已是組件的一部分了。

最後,這是如今的渲染代碼:

class GraphicsComponent
{
public:
  void update(Bjorn& bjorn, Graphics& graphics)
  {
    Sprite* sprite = &spriteStand_;
    if (bjorn.velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (bjorn.velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }
 
    graphics.draw(*sprite, bjorn.x, bjorn.y);
  }
 
private:
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

咱們幾乎將全部的東西都移出來了,因此麪包師還剩下什麼?沒什麼了:

class Bjorn
{
public:
  int velocity;
  int x, y;
 
  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }
 
private:
  InputComponent input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};

Bjorn類如今基本上就作兩件事:擁有定義它的組件,以及在不一樣域間分享的數據。 有兩個緣由致使位置和速度仍然在Bjorn的核心類中: 首先,它們是泛領域狀態——幾乎每一個組件都須要使用它們, 因此咱們想要提取它出來時,哪一個組件應該擁有它們並不明確。

第二,也是更重要的一點,它給了咱們無需讓組件耦合就能溝通的簡易方法。 讓咱們看看能不能利用這一點。

機器人Bjorn

到目前爲止,咱們將行爲納入了不一樣的組件類,但還沒將行爲抽象出來。 Bjorn仍知道每一個類的具體定義的行爲。讓咱們改變這一點。

取出處理輸入的部件,將其藏在接口以後,將InputComponent變爲抽象基類。

class InputComponent
{
public:
  virtual ~InputComponent() {}
  virtual void update(Bjorn& bjorn) = 0;
};

而後,將現有的處理輸入的代碼取出,放進一個實現接口的類中。

class PlayerInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;
 
      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }
 
private:
  static const int WALK_ACCELERATION = 1;
};

咱們將Bjorn改成只擁有一個指向輸入組件的指針,而不是擁有一個內聯的實例。

class Bjorn
{
public:
  int velocity;
  int x, y;
 
  Bjorn(InputComponent* input)
  : input_(input)
  {}
 
  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }
 
private:
  InputComponent* input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};

如今當咱們實例化Bjorn,咱們能夠傳入輸入組件使用,就像下面這樣:

Bjorn* bjorn = new Bjorn(new PlayerInputComponent());

這個實例能夠是任何實現了抽象InputComponent接口的類型。 咱們爲此付出了代價——update()如今是虛方法調用了,這會慢一些。這一代價的回報是什麼?

大多數的主機須要遊戲支持演示模式 若是玩家停在主菜單沒有作任何事情,遊戲就會自動開始運行,直到接入一個玩家。 這讓屏幕上的主菜單看上去更有生機,同時也是銷售商店裏很好的展現。

隱藏在輸入組件後的類幫咱們實現了這點, 咱們已經有了具體的PlayerInputComponent供玩遊戲時使用。 如今讓咱們完成另外一個:

class DemoInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    // 自動控制BjornAI……
  }
};

當遊戲進入演示模式,咱們將Bjorn和一個新組件鏈接起來,而不像以前演示的那樣構造它:

Bjorn* bjorn = new Bjorn(new DemoInputComponent());

如今,只須要更改組件,咱們就有了爲演示模式而設計的電腦控制的玩家。 咱們能夠重用全部Bjorn的代碼——物理和圖像都不知道這裏有了變化。 也許我有些奇怪,但這就是天天能讓我起牀的事物。

那個,還有咖啡。熱氣騰騰的咖啡。

刪掉Bjorn

若是你看看如今的Bjorn類,你會意識到那裏徹底沒有「Bjorn」——那只是個組件包。 事實上,它是個好候選人,可以做爲每一個遊戲中的對象都能繼承的遊戲對象基類。 咱們能夠像弗蘭肯斯坦同樣,經過挑選拼裝部件構建任何對象。

讓咱們將剩下的兩個具體組件——物理和圖像——像輸入那樣藏到接口以後。

class PhysicsComponent
{
public:
  virtual ~PhysicsComponent() {}
  virtual void update(GameObject& obj, World& world) = 0;
};
 
class GraphicsComponent
{
public:
  virtual ~GraphicsComponent() {}
  virtual void update(GameObject& obj, Graphics& graphics) = 0;
};

而後將Bjorn改成使用這些接口的通用GameObject類。

class GameObject
{
public:
  int velocity;
  int x, y;
 
  GameObject(InputComponent* input,
             PhysicsComponent* physics,
             GraphicsComponent* graphics)
  : input_(input),
    physics_(physics),
    graphics_(graphics)
  {}
 
  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_->update(*this, world);
    graphics_->update(*this, graphics);
  }
 
private:
  InputComponent* input_;
  PhysicsComponent* physics_;
  GraphicsComponent* graphics_;
};

有些人走的更遠。 不使用包含組件的GameObject,遊戲實體只是一個ID,一個數字。 每一個組件都知道它們鏈接的實體ID,而後管理分離的組件。

這些實體組件系統將組件發揮到了極致,讓你向實體添加組件而無需通知實體。 數據局部性一章有更多細節。

咱們現有的具體類被重命名並實現這些接口:

class BjornPhysicsComponent : public PhysicsComponent
{
public:
  virtual void update(GameObject& obj, World& world)
  {
    // 物理代碼……
  }
};
 
class BjornGraphicsComponent : public GraphicsComponent
{
public:
  virtual void update(GameObject& obj, Graphics& graphics)
  {
    // 圖形代碼……
  }
};

如今咱們無需爲Bjorn創建具體類,就能構建擁有全部Bjorn行爲的對象。

GameObject* createBjorn()
{
  return new GameObject(new PlayerInputComponent(),
                        new BjornPhysicsComponent(),
                        new BjornGraphicsComponent());
}

這個createBjorn()函數固然就是經典的GoF工廠模式的例子。

經過用不一樣組件實例化GameObject,咱們能夠構建遊戲須要的任何對象。

設計決策

這章中你最須要回答的設計問題是我須要什麼樣的組件?回答取決於你遊戲的需求和風格。 引擎越大越複雜,你就越想將組件劃分得更細。

除此以外,還有幾個更具體的選項要回答:

對象如何獲取組件?

一旦將單塊對象分割爲多個分離的組件,就須要決定誰將它們拼到一塊兒。

  • 若是對象建立組件:
    • 這保證了對象老是能拿到須要的組件。 你永遠沒必要擔憂某人忘記鏈接正確的組件而後破壞了整個遊戲。容器類本身會處理這個問題。
    • 從新設置對象比較困難。 這個模式的強力特性之一就是隻需從新組合組件就能夠建立新的對象。 若是對象老是用硬編碼的組件組裝本身,咱們就沒法利用這個特性。
  • 若是外部代碼提供組件:
    • 對象更加靈活。 咱們能夠提供不一樣的組件,這樣就能改變對象的行爲。 經過共用組件,對象變成了組件容器,咱們能夠爲不一樣目的一遍又一遍地重用它。
    • 對象能夠與具體的組件類型解耦。

若是咱們容許外部代碼提供組件,好處是也能夠傳遞派生的組件類型。 這樣,對象只知道組件接口而不知道組件的具體類型。這是一個很好的封裝結構。

組件之間如何通訊?

完美解耦的組件不須要考慮這個問題,但在真正的實踐中行不通。 事實上組件屬於同一對象暗示它們屬於須要相互協同的更大總體的一部分。 這就意味着通訊。

因此組件如何相互通訊呢? 這裏有不少選項,但不像這本書中其餘的選項,它們相互並不衝突——你能夠在一個設計中支持多種方案。

  • 經過修改容器對象的狀態:
    • 保持了組件解耦。 當咱們的InputComponent設置了Bjorn的速度,然後PhysicsComponent使用它, 這兩個組件都不知道對方的存在。在它們的理解中,Bjorn的速度是被黑魔法改變的。
    • 須要將組件分享的任何數據存儲在容器類中。 一般狀態只在幾個組件間共享。好比,動畫組件和渲染組件須要共享圖形專用的信息。 將信息存入容器類會讓全部組件都得到這樣的信息。

更糟的是,若是咱們爲不一樣組件配置使用相同的容器類,最終會浪費內存存儲不被任何對象組件須要的狀態。 若是咱們將渲染專用的數據放入容器對象中,任何隱形對象都會無益地消耗內存。

    • 這讓組件的通訊基於組件運行的順序。 在一樣的代碼中,原先一整塊的update()代碼當心地排列這些操做。 玩家的輸入修改了速度,速度被物理代碼使用並修改位置,位置被渲染代碼使用將Bjorn繪製到所在之處。 當咱們將這些代碼劃入組件時,仍是得當心翼翼地保持這種操做順序。

若是咱們不那麼作,就引入了微妙而難以追蹤的漏洞。 好比,咱們更新圖形組件,就錯誤地將Bjorn渲染在他上一幀而不是這一幀所處的位置上。 若是你考慮更多的組件和更多的代碼,那你能夠想象要避免這樣的錯誤有多麼困難了。

這樣被大量代碼讀寫相同數據的共享狀態很難保持正確。 這就是爲何學術界花時間研究徹底函數式語言,好比Haskell,那裏根本沒有可變狀態。

  • 經過它們之間相互引用:

這裏的思路是組件有要交流的組件的引用,這樣它們直接交流,無需經過容器類。

假設咱們想讓Bjorn跳躍。圖形代碼想知道它須要用跳躍圖像仍是不用。 這能夠經過詢問物理引擎它當前是否在地上來肯定。一種簡單的方式是圖形組件直接知道物理組件的存在:

class BjornGraphicsComponent
{
public:
  BjornGraphicsComponent(BjornPhysicsComponent* physics)
  : physics_(physics)
  {}
 
  void Update(GameObject& obj, Graphics& graphics)
  {
    Sprite* sprite;
    if (!physics_->isOnGround())
    {
      sprite = &spriteJump_;
    }
    else
    {
      // 現存的圖形代碼……
    }
 
    graphics.draw(*sprite, obj.x, obj.y);
  }
 
private:
  BjornPhysicsComponent* physics_;
 
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
  Sprite spriteJump_;
};

當構建BjornGraphicsComponent時,咱們給它相應的PhysicsComponent引用。

    • 簡單快捷。 通訊是一個對象到另外一個的直接方法調用。組件能夠調用任一引用對象的方法。作什麼均可以。
    • 兩個組件緊綁在一塊兒。 這是作什麼均可以帶來的壞處。咱們向使用整塊類又退回了一步。 這比只用單一類好一點,至少咱們如今只是把須要通訊的類綁在一塊兒。
  • 經過發送消息:
    • 這是最複雜的選項。咱們能夠在容器類中建小小的消息系統,容許組件相互發送消息。

下面是一種可能的實現。咱們從每一個組件都會實現的Component接口開始:

class Component
{
public:
  virtual ~Component() {}
  virtual void receive(int message) = 0;
};

它有一個簡單的receive()方法,每一個須要接受消息的組件類都要實現它。 這裏,咱們使用一個int來定義消息,但更完整的消息實現應該能夠附加數據。

而後,向容器類添加發送消息的方法。

class ContainerObject
{
public:
  void send(int message)
  {
    for (int i = 0; i < MAX_COMPONENTS; i++)
    {
      if (components_[i] != NULL)
      {
        components_[i]->receive(message);
      }
    }
  }
 
private:
  static const int MAX_COMPONENTS = 10;
  Component* components_[MAX_COMPONENTS];
};

如今,若是組件可以接觸容器,它就能向容器發送消息,直接向全部的組件廣播。 (包括了原先發送消息的組件,當心別陷入無限的消息循環中!)這會形成一些結果:

若是你真的樂意,甚至能夠將消息存儲在隊列中,晚些發送。 要知道更多,看看事件隊列

    • 同級組件解耦。 經過父級容器對象,就像共享狀態的方案同樣,咱們保證了組件之間仍然是解耦的。 使用了這套系統,組件之間惟一的耦合是它們發送的消息。

GoF稱之爲中介模式——兩個或更多的對象經過中介對象通訊。 如今這種狀況下,容器對象自己就是中介。

    • 容器類很簡單。 不像使用共享狀態那樣,容器類無需知道組件使用了什麼數據,它只是將消息發送出去。 這能夠讓組件發送領域特有的數據而無需打擾容器對象。

不出意料的,這裏沒有最好的答案。這些方法你最終可能都會使用一些。 共享狀態對於每一個對象都有的數據是很好用的——好比位置和大小。

有些不一樣領域仍然緊密相關。想一想動畫和渲染,輸入和AI,或物理和粒子。 若是你有這樣一對分離的組件,你會發現直接相互引用也許更加容易。

消息對於不那麼重要的通訊頗有用。對物理組件發現事物碰撞後發送消息讓音樂組件播放聲音這種事情來講,發送後無論的特性是頗有效的。

就像之前同樣,我建議你從簡單的開始,而後若是須要的話,加入其餘的通訊路徑。

參見

  • Unity核心架構中GameObject類徹底根據這樣的原則設計components
  • 開源的Delta3D引擎有GameActor基類經過ActorComponent實現了這種模式。
  • 微軟的XNA遊戲框架有一個核心的Game類。它擁有一系列GameComponent對象。咱們在遊戲實體層使用組件,XNA在遊戲主對象上實現了這種模式,但意圖是同樣的。
  • 這種模式與GoF策略模式相似。 兩種模式都是將對象的行爲取出,劃入單獨的重述對象。 與對象模式不一樣的是,分離的策略模式一般是無狀態的——它封裝了算法,而沒有數據。 它定義了對象如何行動,但沒有定義對象什麼。

組件更加劇要。它們常常保存了對象的狀態,這有助於肯定其真正的身份。 可是,這條界限很模糊。有一些組件也許根本沒有任何狀態。 在這種狀況下,你能夠在不一樣的容器對象中使用相同的組件實例。這樣看來,它的行爲確實更像一種策略。

5.2事件隊列

遊戲設計模式Decoupling Patterns

意圖

解耦發出消息或事件的時間和處理它的時間。

動機

除非還呆在一兩個沒有互聯網接入的犄角旮旯,不然你極可能已經據說過事件序列了。 若是沒有,也許消息隊列事件循環消息泵能夠讓你想起些什麼。 爲了喚醒你的記憶,讓咱們瞭解幾個此模式的常見應用吧。

這章的大部分裏,我交替使用「事件」和「消息」。 在二者的意義有區別時,我會代表的。

GUI事件循環

若是你曾作過任何用戶界面編程,你就會很熟悉事件 每當用戶與你的程序交互——點擊按鈕,拉出菜單,或者按個鍵——操做系統就會生成一個事件。 它會將這個對象扔給你的應用程序,你的工做就是獲取它而後將其與有趣的行爲相掛鉤。

這個程序風格很是廣泛,被認爲是一種編程範式:事件驅動編程

爲了獲取這些事件,代碼底層是事件循環。它大致上是這樣的:

while (running)
{
  Event event = getNextEvent();
  // 處理事件……
}

調用getNextEvent()將一堆未處理的用戶輸入傳到應用程序中。 你將它導向事件處理器,以後應用魔術般得到了生命。 有趣的部分是應用在想要的時候獲取事件。 操做系統在用戶操做時不是直接跳轉到你應用的某處代碼。

相反,操做系統的中斷確實是直接跳轉的。 當中斷髮生時,操做系統中斷應用在作的事,強制它跳到中斷處理。 這種唐突的作法是中斷很難使用的緣由。

這就意味着當用戶輸入進來時,它須要到某處去, 這樣操做系統在設備驅動報告輸入和應用去調用getNextEvent()之間不會漏掉它。 這個某處是一個隊列

當用戶輸入抵達時,操做系統將其添加到未處理事件的隊列中。 當你調用getNextEvent()時,它從隊列中獲取最舊的事件而後交給應用程序。

中心事件總線

大多數遊戲不是像這樣事件驅動的,可是在遊戲中使用事件循環來支撐中樞系統是很常見的。 你一般聽到用中心」「全局」「主體描述它。 它一般被用於想要相互保持解耦的高層模塊間通訊。

若是你想知道爲何它們不是事件驅動的,看看遊戲循環一章。

假設遊戲有新手教程系統,在某些特定遊戲事件後顯示幫助框。 舉個例子,當玩家第一次擊敗了邪惡野獸,你想要一個顯示着X拿起戰利品!的小氣泡。

新手教程系統很難優雅地實現,大多數玩家不多使用遊戲內的幫助,因此這感受上吃力不討好。 但對那些使用教程的玩家,這是無價之寶。

遊戲玩法和戰鬥代碼也許像上面同樣複雜。 你最不想作的就是檢查一堆教程的觸發器。 相反,你能夠使用中心事件隊列。 任何遊戲系統均可以發事件給隊列,這樣戰鬥代碼能夠在砍倒敵人時發出敵人死亡事件。

相似地,任何遊戲系統都能從隊列接受事件。 教程引擎在隊列中註冊本身,而後代表它想要收到敵人死亡事件。 用這種方式,敵人死了的消息從戰鬥系統傳到了教程引擎,而不須要這兩個系統直接知道對方的存在。

實體能夠發送和收到消息的模型很像AI界的blackboard systems

我本想將這個做爲這章其餘部分的例子,可是我真的不喜歡這樣巨大的全局系統。 事件隊列不須要在整個遊戲引擎中溝通。在一個類或者領域中溝通就足夠有用了。

你說什麼?

因此說點別的,讓咱們給遊戲添加一些聲音。 人類是視覺動物,可是聽覺強烈影響到情感系統和空間感受。 正確模擬的回聲能夠讓漆黑的屏幕感受上是巨大的洞穴,而適時的小提琴慢板能夠讓心絃拉響一樣的旋律。

爲了得到優秀的音效表現,咱們從最簡單的解決方法開始,看看結果如何。 添加一個聲音引擎,其中有使用標識符和音量就能夠播放音樂的API

我老是離單例模式遠遠的。 這是少數它能夠使用的領域,由於機器一般只有一個聲源系統。 我使用更簡單的方法,直接將方法定爲靜態。

class Audio
{
public:
  static void playSound(SoundId id, int volume);
};

它負責加載合適的聲音資源,找到可靠的播放頻道,而後啓動它。 這章不是關於某個平臺真實的音頻API,因此我會假設在其餘某處魔術般實現了一個。 使用它,咱們像這樣寫方法:

void Audio::playSound(SoundId id, int volume)
{
  ResourceId resource = loadSound(id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, volume);
}

咱們簽入以上代碼,建立一些聲音文件,而後在代碼中加入一些對playSound()的調用。 舉個例子,在UI代碼中,咱們在選擇菜單項變化時播放一點小音效:

class Menu
{
public:
  void onSelect(int index)
  {
    Audio::playSound(SOUND_BLOOP, VOL_MAX);
    // 其餘代碼……
  }
};

這樣作了以後,咱們注意到有時候你改變菜單項目,整個屏幕就會凍住幾幀。 咱們遇到了第一個問題:

  • 問題一:API在音頻引擎完成對請求的處理前阻塞了調用者。

咱們的playSound()方法是同步——它在從播放器放出聲音前不會返回調用者。 若是聲音文件要從光盤上加載,那就得花費必定時間。 與此同時,遊戲的其餘部分被卡住了。

如今忽視這一點,咱們繼續。 AI代碼中,咱們增長了一個調用,在敵人承受玩家傷害時發出痛苦的低號。 沒有什麼比在虛擬的生物身上施加痛苦更能溫暖玩家心靈的了。

這能行,可是有時玩家打出暴擊,他在同一幀能夠打到兩個敵人。 這讓遊戲同時要播放兩遍哀嚎。 若是你瞭解一些音頻的知識,那麼就知道要把兩個不一樣的聲音混合在一塊兒,就要加和它們的波形。 當這兩個是同一波形時,它與一個聲音播放兩倍響是同樣的。那會很刺耳。

我在完成Henry Hatsworth in the Puzzling Adventure時遇到了一樣的問題。解決方法和這裏的很類似。

Boss戰中有個相關的問題,當有一堆小怪跑動並製造傷害時。 硬件只能同時播放必定數量的音頻。當數量超過限度時,聲音就被忽視或者切斷了。

爲了處理這些問題,咱們須要得到音頻調用的整個集合,用來整合和排序。 不幸的是,音頻API獨立處理每個playSound()調用。 看起來這些請求像是從針眼穿過同樣,一次只能有一個。

  • 問題二:請求沒法合併處理。

這個問題與下面的問題相比只是小煩惱。 如今,咱們在不少不一樣的遊戲系統中散佈了playSound()調用。 可是遊戲引擎是在現代多核機器上運行的。 爲了使用多核帶來的優點,咱們將系統分散在不一樣線程上——渲染在一個,AI在另外一個,諸如此類。

因爲咱們的API是同步的,它在調用者的線程上運行。 當從不一樣的遊戲系統調用時,咱們從多個線程同時使用API 看看示例代碼,看到任何線程同步性嗎?我也沒看到。

當咱們想要分配一個單獨的線程給音頻,這個問題就更加嚴重。 當其餘線程都忙於互相跟隨和製造事物,它只是傻傻待在那裏。

  • 問題三:請求在錯誤的線程上執行。

音頻引擎調用playSound()意味着,放下任何東西,如今就播放聲音!當即就是問題。 遊戲系統在它們方便時調用playSound(),可是音頻引擎不必定能方便去處理這個請求。 爲了解決這點,咱們須要將接受請求和處理請求解耦。

模式

事件隊列在隊列中按先入先出的順序存儲一系列通知或請求 發送通知時,將請求放入隊列並返回 處理請求的系統以後稍晚從隊列中獲取請求並處理。 解耦了發送者和接收者,既靜態及時

什麼時候使用

若是你只是想解耦接收者和發送者,像觀察者模式 命令模式均可以用較小的複雜度進行處理。 在解耦某些須要及時處理的東西時使用隊列。

我在以前的幾乎每章都提到了,但這值得反覆提。 複雜度會拖慢你,因此要將簡單視爲珍貴的財寶。

用推和拉來考慮。 有一塊代碼A須要另外一塊代碼B去作些事情。 A天然的處理方式是將請求B

同時,對B天然的處理方式是在B方便時將請求入。 當一端有推模型另外一端有拉模型,你須要在它們之間設置緩存。 這就是隊列比簡單的解耦模式多提供的部分。

隊列給了代碼對拉取的控制權——接收者能夠延遲處理,合併或者忽視請求。 但隊列作這些事是經過將控制權從發送者那裏拿走完成的。 發送者能作的就是向隊列發送請求而後祈禱。 當發送者須要回覆時,隊列不是好的選擇。

記住

不像本書中的其餘模式,事件隊列很複雜,會對遊戲架構產生普遍影響。 這就意味着你得仔細考慮如何——或者要不要——使用它。

中心事件隊列是一個全局變量

這個模式的經常使用方法是一個大的交換站,遊戲中的每一個部分都能將消息送到這裏。 這是頗有用的基礎架構,可是有用並不表明好用

可能要走一些彎路,可是咱們中的大多數最終學到了全局變量是很差的。 當有一小片狀態,程序的每部分都能接觸到,會產生各類微妙的相關性。 這個模式將狀態封裝在協議中,可是它仍是全局的,仍然有全局變量引起的所有危險。

世界的狀態能夠因你改變

假設在虛擬的小怪結束它一輩子時,一些AI代碼將實體死亡事件發送到隊列中。 這個事件在隊列中等待了誰知有多少幀後才排到了前面,得以處理。

同時,經驗系統想要追蹤英雄的殺敵數,並對他的效率加以獎勵。 它接受每一個實體死亡事件,而後決定英雄擊殺了何種怪物,以及擊殺的難易程度,最終計算出合適的獎勵。

這須要遊戲世界的多種不一樣狀態。 咱們須要死亡的實體以獲取擊殺它的難度。 咱們也許要看看英雄的周圍有什麼其餘的障礙物或者怪物。 可是若是事件沒有及時處理,這些東西都會消失。 實體可能被清除,周圍的東西也有可能移開。

當你接到事件時,得當心,不能假設如今的狀態反映了事件發生時的世界。 這就意味着隊列中的事件比同步系統中的事件須要存儲更多數據。 在後者中,通知只需說某事發生了而後接收者能夠找到細節。 使用隊列時,這些短暫的細節必須在事件發送時就被捕獲,以方便以後使用。

會陷於反饋系統環路中

任何事件系統和消息系統都得擔憂環路:

  1. A發送了一個事件
  2. B接收而後發送事件做爲迴應。
  3. 這個事件剛好是A關注的,因此它收到了。爲了迴應,它發送了一個事件。
  4. 回到2.

當消息系統是同步的,你很快就能找到環路——它們形成了棧溢出並讓遊戲崩潰。 使用隊列,它會異步地使用棧,即便虛假事件晃來晃去,遊戲仍然能夠繼續運行。 避免這個的通用方法就是避免在處理事件的代碼中發送事件。

在你的事件系統中加一個小小的漏洞日誌也是一個好主意。

示例代碼

咱們已經看到一些代碼了。它不完美,可是有基本的正確功能——公用的API和正確的底層音頻調用。 剩下須要作的就是修復它的問題。

第一個問題是咱們的API阻塞的 當代碼播放聲音時,它不能作任何其餘事情,直到playSound()加載完音頻而後真正地開始播放。

咱們想要推遲這項工做,這樣 playSound() 能夠很快地返回。 爲了達到這一點,咱們須要具體化播放聲音的請求。 咱們須要一個小結構存儲發送請求時的細節,這樣咱們晚些時候能夠使用:

struct PlayMessage
{
  SoundId id;
  int volume;
};

下面咱們須要給Audio一些存儲空間來追蹤正在播放的聲音。 如今,你的算法專家也許會告訴你使用激動人心的數據結構, 好比Fibonacci heap或者skip list或者最起碼鏈表 可是在實踐中,存儲一堆同類事物最好的辦法是使用一個平凡無奇的經典數組:

算法研究者經過發表對新奇數據結構的研究得到收入。 他們不鼓勵使用基本的結構。

  • 沒有動態分配。
  • 沒有爲記錄信息形成的額外的開銷或者多餘的指針。
  • 對緩存友好的連續存儲空間。

更多「緩存友好」的內容,見數據局部性一章。

因此讓咱們開幹吧:

class Audio
{
public:
  static void init()
<
相關文章
相關標籤/搜索