《遊戲設計模式》(遊戲編程模式)全書筆記+Unity實現
來自專欄技術美術 Shading Artist 小屋93 人贊了文章
Unity實現(Github地址):
1. 命令模式 2. 享元模式 3. 觀察者模式 4. 原型模式 5. 單例模式
6. 狀態模式 7. 序列模式 8. 行為模式 9. 解耦模式 10. 優化模式
筆記部分
- 以下部分只包含筆記,具體實現及項目說明可查看Github
- 筆記中很多都是個人理解,目的是盡量讓原本抽象的概念更易懂一些
- 關於書名——書名直譯是《遊戲編程模式》,但在中文版致謝頁中,被翻譯成了《遊戲設計模式》,「設計模式」一詞來源於GOF。此書(GPP)雖與編程有關,但更本質上是設計思想,所以更傾向「設計模式」這個翻譯
- 鏈接
- 英文原版
- 中文版
命令模式
是什麼(個人理解)
將命令封裝,與目標行為解耦,使命令由流程概念變為對象數據
為什麼
既然命令變成了數據,就是可以被傳遞、存儲、重複利用的:
- 通過命令數據隊列或棧可以輕易實現撤銷、重做、時光倒流
- 命令數據還可以形成日誌,用於復現用戶行為,便於重複測試同樣序列命令對各種目標的影響
- 這些命令數據可以發送給不同的目標,比如同樣的「出發,5分鐘後,停止」,發送給飛機就可以變成「起飛,5分鐘後,降落」,發送給輪船就成了「離港,5分鐘後,拋錨」
怎麼做(U3D示例)
類圖如下:
具體實現:
TYJia/GameDesignPattern_U3D_Version
缺陷
可能會導致大量的實例化,從而浪費內存
拓展
可用享元模式代替大量的實例化
享元模式
是什麼(個人理解)
不同的實例共享相同的特性(共性),同時保留自己的特性部分
為什麼
- 傳遞的信息享元化,可以節約計算時間
- 存儲的信息享元化,可以節約佔用的空間
- 所以享元模式可以減少時間與空間上的代價
怎麼做
類圖如下:
左半部分為享元模式下,只有一個CubeBase,通過ObjInstancing(int num)將共享的網格、材質及一個Transform信息表傳遞給GPU,只有一個Draw Call,所以效率極高
右半部分為關閉享元模式後的做法,每生成一個Cube都會重新實例化一個立方體,並向GPU發送一次網格、材質和位置信息,所以1000個立方體就需要1000個Draw Call,效率極低
具體實現:
TYJia/GameDesignPattern_U3D_Version
拓展
可與對象池聯動,進一步減少內存的開銷
觀察者模式
是什麼(個人理解)
事件與其他對象行為的解耦——例如一個代碼描述了日本核電站爆炸的事件,世界人民買鹽這種行為顯然不應該由核電站爆炸直接調用,而是通過衛星電視告訴廣大群眾,群眾想買鹽還是想買仙人掌就由他們自己決定了~
為什麼
- 解耦,物價局改了糧價不需要挨家挨戶通知公民,只需要讓電視台播個新聞就好
- 如果要挨家挨戶通知,物價局必須有每個公民的地址,這顯然不合理,也會浪費很多資源
- 擴展困難——如果公民改了地址或者有新公民出生了,那還需要告訴物價局,這也很荒唐
怎麼做
類圖如下:
射手(Shooter,觀察者,這裡是聽眾)告訴廣播電台(Radio)自己要聽發射氣球的廣播
吹氣球的人(Emitter) 向上發出氣球,並告訴廣播電台自己發射了氣球
廣播電台廣播發射了氣球的消息,所有射手向氣球射擊
這個例子中吹氣球的人不會關心誰是射手,射手也不用在意誰是吹氣球的人
具體實現:
TYJia/GameDesignPattern_U3D_Version
原型模式
是什麼(個人理解)
將一個或多個對象當做原型,通過統一的生成器克隆出很多類似原型的對象,同時可以通過配置表更改克隆體屬性,製造出很多具有自身個性的對象。
為什麼
- 復用生成器,而非針對每一個不同的對象做一個生成器
- 與享元模式結合,通過配置表來實現對象的個性,將不同配置與代碼解耦
怎麼做
類圖如下:
- Unity中Prefab本質就是此模式里的原型,而Spawner要做的只是調用Instantiate方法
- 新的Prefab被生成以後,通過讀取Dragons.txt里配置的信息來設置克隆體的名稱和尺寸
註:這裡為了快速實現使用txt記錄配置表(我在偷懶),但實際項目里,往往使用SQL、Json、csv等方式進行配置
具體實現:
TYJia/GameDesignPattern_U3D_Version
單例模式
是什麼(個人理解)
使用單例意味著這個對象只有一個實例,這個實例是此對象自行構造的,並且可向全局提供
為什麼
- 減少代碼復用,讓專門的類處理專門的事情——例如讓TimeLog類來記錄日誌,而不是把StreamWriter的代碼寫到每一個類里
- 快速訪問,任何其他類都可以通過ClassName.Instance來訪問單例,使用它的公開變數和方法
缺陷
- 因為實現簡單,而且使用方便,所以有被濫用的趨勢
- 濫用單例會促進耦合的發生,因為單例是全局可訪問的,如果不該訪問者訪問了單例,就會造成過耦合——例如如果播放器允許單例,那石頭碰撞地面後就可以直接調用播放器來播放聲音,這在程序世界並不合理,而且會破壞架構
- 如果很多很多類和對象調用了某個單例並做了一系列修改,那想理解具體發生了什麼就困難了
- 對多線程不太友好——每個線程都可以訪問這個單例,會產生初始化、死鎖等一系列問題
怎麼做
U3D中利用MonoBehaviour初始化單例非常簡單,只要在Awake中加入Instance = this,不過要注意的是,別的類不能在Awake里使用這個單例
單例在普通C#中還有其他做法,甚至有些泛型、線程安全的擴展,也都不複雜,可以自行查詢
類圖如下:
具體實現:
TYJia/GameDesignPattern_U3D_Version
狀態模式
是什麼(個人理解)
現在狀態和條件決定對象的新狀態,狀態決定行為(Unity內AnimationController就是狀態機)
為什麼
- 使流程清晰化、結構化
- 簡化判斷邏輯,比如嘴的狀態是洗牙,那就不應該做出咀嚼的行為;必須是在憋氣,那就不應該做出呼吸的行為
註解
- 狀態機(自動機)是我最喜歡的一種設計模式,因為這樣設計的程序邏輯清晰,穩定性也很強
- 作者對switch case下的狀態機理解並不深刻,一般情況下,狀態機需要兩個switch case,一個用於處理狀態變化,另一個用來處理狀態行為
- 相比狀態類,個人更喜歡switch case的方法,雖然狀態類有其有點,但缺點也非常明顯——當狀態量較大時,代碼量激增,可讀性也很差,狀態變化和狀態行為都需要大量的信息傳遞,十分不便
怎麼做
這次我實現了兩個版本:
- SwitchCase版本,用按鍵控制一盞冷暖燈,關燈狀態下,按一次打開暖光,再按切換為白光,再按變為暖白光,再按關閉
- 狀態類版本,交通燈 停止、通行、閃爍、等待的切換
另外自動機用類圖描述不是好方法,應該用自動機專門的圖來說明才對
SwitchCase版本類圖及自動機如下:
StateClass版本類圖及自動機如下:
具體實現:
TYJia/GameDesignPattern_U3D_Version
序列模式
是什麼、為什麼(個人理解)
包含了
- 雙緩衝模式
- 當一個緩衝準備好後才會被使用——就像一個集裝箱裝滿才會發貨一樣;當一個緩衝被使用時另一個處於準備狀態,就形成了雙緩衝
- 在渲染中廣泛使用,一幀準備好後才會被渲染到屏幕上——所以準備時間太長就會導致幀率下降
- 遊戲循環
- 可參考腳本生命周期
- 更新方法
- 同上,實際上是Unity通過反射在生命周期不同時刻調用MonoBehaviour中的相關方法
這三者一定程度上是相輔相成的,在Unity中都已在底層實現,雙緩衝可以通過FrameDebugger體會,而遊戲循環、更新方法則與腳本生命周期和MonoBehaviour相關
行為模式
是什麼、為什麼(個人理解)
包含了
- 位元組碼
- 享元模式、原型模式中,不同的屬性被存儲在資料庫中,而位元組碼是將行為存在資料庫中
- 可用於實現可視化腳本編輯工具
- 子類沙箱
- 子類使用基類方法,或在基類方法上擴展
- 類型對象
- 其實是享元模式、原型模式的一種應用,以不同數據(而不是不同類)區分對象類型
解耦模式
是什麼、為什麼(個人理解)
包含了
- 組件模式
- 本質上是功能的模塊化,延伸了面向對象的解耦思想
- U3D的編程思想就是面向組件的,MonoBehaviour的子類都可作為組件掛在GameObject上
- 事件序列
- 就像銀行辦事需要排號一樣——每個顧客要處理的事都是一個事件,編號後就形成了天然的事件序列,銀行會按一定規則來依次處理隊列中的事件
- 一般在底層實現,但宏觀上依然存在,例如RTS遊戲中通過Shift對一些單位下達前往不同位置的命令
- Unity中協程可以用來做消息隊列,防止同幀產生大量的計算
- 服務定位器
- 類似單例模式,在運行時尋找組件(而不是運行前賦值)
- Unity中GetComponent,FindObjectOfType,Find等方法都可幫助實現相關服務的查找,但此類反射方法要避免在運行時高頻循環調用
- 拓展——還可以建立一個運行前賦值的服務註冊中心(當然也可運行中賦值),其他需要服務的對象在運行時去註冊中心查找相關服務,這樣做一方面可以避免全局反射的惡果,一方面可以保留服務定位器帶來的解耦優勢——單例模式也可使用這樣的方法來替換(對象註冊中心)
怎麼做(事件隊列)
點擊滑鼠時在Queue中添加一個紅點,當目標點為空時從Queue中取出第一位位作為目標點,讓Player移向目標點,到達目標點時刪除目標點
具體實現:
https://github.com/TYJia/GameDesignPattern_U3D_Version/tree/master/Assets/009DecouplingPatterns
優化模式
是什麼、為什麼(個人理解)
包含了
- 數據局部性
- CPU緩存讀寫速度大於內存讀寫速度,所以要盡量減少緩存不命中(CPU從內存讀取信息)的次數
- 用連續隊列代替指針的不斷跳轉
- 不過此模式會讓代碼更複雜,並傷害其靈活性
- 臟標識模式
- 需要結果時才去執行工作——避免不必要的計算或傳輸開銷
- 一種是被動狀態變化時才計算,否則使用緩存;另一種是主動變化標識,否則不執行(例如存檔)
- 對象池模式
- 對象池就像一包不同顏色的水彩筆,當我們使用時就拿出來,不用時就放回去——而不是使用時就買一隻,不用時就扔進垃圾桶
- 可以減少內存碎片,減少實例化與回收對象所面臨的開銷
- 空間分區
- 建立細分空間用於存儲數據(對象),可以幫助告訴定位對象,降低演算法複雜度
- 例如郵局寄信,如果只按身份證號郵寄,那就麻煩了,每封信平均要拿給幾億人確認是否是ta的;但是按空間分區後,就簡單了——省份、城市、街道、小區、樓棟、單元、房號,於是很快就能定位到個人。
怎麼做(對象池)
用對象池對之前實現的例子做了優化:
- 之前每次點擊滑鼠會生成一個目標點,Player到達目標點後會將目標點回收(Destroy)
- 優化後點擊滑鼠,先會嘗試從對象池「未激活列表」獲取對象,無法獲取才會生成新對象並放入對象池中的「已激活列表」;Player到達目標點後,會把對象從已激活列表放入未激活列表,並執行SetActive(false)方法
怎麼做(空間分區)
- 這裡我實現了一個八叉樹簡單示例,用來尋找最近的點
- 建立
- 先尋找空間邊界,建立父節點長方體
- 若父節點中點數超過閾值,則分割成八個子節點長方體
- 尋找最近的點
- 在點所在的和臨近的立方體中尋找最近的點
因為只是示例,所以並未完善臨近立方體的查找,目前只用了八叉樹結構臨近的立方體,而非空間臨近,有興趣的同學可以進一步優化
- 更新點
- 先看點是否在之前的長方體里,如果不在,則從當前節點移除,並查詢是否在父節點裡
- 如果在父節點裡,則向下查詢在哪一個子節點裡此示例只能更新點的位置,也就是八叉樹中的內容,不能更新八叉樹的結構,大家可以自行思考如何更新結構
此示例只能更新點的位置,也就是八叉樹中的內容,不能更新八叉樹的結構,大家可以自行思考如何更新結構
具體實現:
TYJia/GameDesignPattern_U3D_Version
前幾天有親人離世,願安息
望生者多體驗些人間的美好
推薦閱讀: