【遊戲設計模式】之三 狀態模式、有限狀態機 & Unity版本實現

遊戲開發過程中,各種遊戲狀態的切換無處不在。但很多時候,簡單粗暴的if else加標誌位的方式並不能很地道地解決狀態複雜變換的問題,這時,就可以運用到狀態模式以及狀態機來高效地完成任務。狀態模式與狀態機,因為他們關聯緊密,常常放在一起討論和運用。而本文將對他們在遊戲開發中的使用,進行一些探討。

PS:

  • 這篇文章起源於《Game Programming Patterns》第二章第六節。
  • 這是一篇略長的文章,約5200餘字,將分析遊戲開發過程中狀態模式與有限狀態機的運用,已經非常了解相關內容的高端選手請略讀。
  • 文中使用C++承載講解內容,文章末尾也提供了Unity&C#版本的代碼實現。

一、文章的短版本與思維導圖

還是國際慣例,先放出這篇文章的短版本——所涉及知識點的一張思維導圖,再開始正文。大家若是疲於閱讀文章正文,直接看這張圖,也是可以Get到本文的主要知識點的大概。

二、引例

假如我們現在正在開發一款橫版遊戲。當前的任務是實現玩家用按鍵操縱女英雄。當按下向上方向鍵的時候,女英雄應該跳躍。那麼我們可以這樣實現:

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

OK,實現是實現了,但是一堆BUG。比如,我們沒有防止主角「在空中跳躍「,當主角跳起來後持續按向上鍵,會導致她一直飄在空中。簡單地修復方法可以是:添加一個 isJumping布爾值變數。當主角跳起來後,就把該變數設置為True.只有當該變數為False時,才讓主角跳躍,代碼如下:

void Heroine::handleInput(Input input){ if (input == PRESS_UP) { if (!isJumping_) { isJumping_ = true; // Jump... } }}

接下來,我們想實現主角的閃避動作。當主角站在地面上的時候,如果玩家按下向下方向鍵,則下蹲躲避,如果鬆開此鍵,則起立。代碼如下:

void Heroine::handleInput(Input input){ if (input == PRESS_UP) { // Jump if not jumping... } else if (input == PRESS_DOWN) { if (!isJumping_) { setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { setGraphics(IMAGE_STAND); }}

找找看, 這次bug又在哪裡?

使用這段代碼,玩家可以:按向下鍵下蹲,按向上鍵則從下蹲狀態跳起,英雄會在跳躍的半路上變成站立圖片…….是時候增加另一個標識了……

void Heroine::handleInput(Input input){ if (input == PRESS_UP) { if (!isJumping_ && !isDucking_) { // Jump... } } 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_UP) { if (!isJumping_ && !isDucking_) { // Jump... } } 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_) { // Stand... } }}

BUG又出現了,這次發現了沒?

目前在下斬的時候,按跳躍鍵居然可以繼續向上跳, OK,要解決它又是另一個欄位……

很明顯,我們採用的這種if else加標誌位的做法並不好用。每次我們添加一些功能的時候,都會不經意地破壞已有代碼的功能。而且,我們還沒有添加「行走」的狀態,加了之後問題恐怕更多。

這一幕是不是有些似曾相識?我想各位同學在踏入遊戲開發領域的早期,多少會碰到過一些類似的情況,反正我是碰到過。其實,在這種情況下,狀態機是可以幫上我們忙的。

三、使用有限狀態機

讓我們畫一個流程圖。目前的狀態有,站立,跳躍,下蹲,下斬。得到的狀態圖示大致如下:

OK,我們成功創建了一個有限狀態機(Finite-state machine, FSM)。它來自計算機科學的分支自動理論,那裡有很多著名的數據結構,包括著名的圖靈機。狀態機是表示有限個狀態以及在這些狀態之間的轉移和動作等行為的數學模型。有限狀態機是其中最簡單的成員。(本文限於篇幅,更多狀態機暫不討論,在文章末尾進階閱讀中,列舉了分層狀態機Hierarchical State Machines與下推自動機Push down Automata的參考資料,有需要的朋友們可以閱讀)

有限狀態機FSM的要點是:

  • 擁有一組狀態,並且可以在這組狀態之間進行切換。在我們的例子中,是站立,跳躍,蹲下和跳斬。
  • 狀態機同時只能在一個狀態。英雄不可能同時處於跳躍和站立。事實上,防止這點是使用FSM的理由之一。
  • 一連串的輸入或事件被發送給機器。在我們的例子中,就是按鍵按下和鬆開。
  • 每個狀態都有一系列的轉換,轉換與輸入和另一狀態相關。當輸入進來,如果它與當前狀態的某個轉換匹配,機器轉為轉換所指的狀態。

舉個例子,在站立狀態時,按下下鍵轉換為俯卧狀態。在跳躍時按下下鍵轉換為跳斬。如果輸入在當前狀態沒有定義轉換,輸入就被忽視。

目前而言,遊戲編程中狀態機的實現方式,有兩種可以選擇:

  • 用枚舉配合switch case語句。
  • 用多態與虛函數(也就是狀態模式)。

下面讓我們用代碼來實現。不妨先從簡單的方式開始,用枚舉與switch case語句實現。

四、用枚舉配合switch case實現狀態機

我們知道,上文中實現的女英雄類Heroine有一些布爾類型的成員變數:isJumping_和isDucking,但是這兩個變數永遠不可能同時為True。

OK,這邊可以提供一個小經驗:當你有一系列的標記成員變數,而它們只能有且僅有一個為True時,定義成枚舉(enum)其實更加適合。

在這個例子當中,我們的FSM的每一個狀態可以用一個枚舉來表示,所以,讓我們定義以下枚舉:

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

好了,無需一堆flags了, Heroine類只需一個state成員就可以勝任。在前面的代碼中,我們先判斷輸入事件,然後才是狀態。那種風格的代碼可以讓我們集中處理與按鍵相關的邏輯,但是,它也讓每一種狀態的處理代碼變得很亂。我們想把它們放在一起來處理,因此,我們先對狀態做分支switch處理。代碼如下:

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; }}

現在的代碼看起來比之前的代碼更加地道。我們簡化了狀態的處理,將所有處理單個狀態的代碼都集中在了一起。這樣做是實現狀態機的最簡單方式,而且在特定情況下,這就是最佳的解決方案。

我們的問題可能也會超過此方案能解決的範圍。比如,我們想在主角下蹲躲避的時候「蓄能」,然後等蓄滿能量之後可以釋放出一個特殊的技能。那麼,當主角處理躲避狀態的時候,我們需要添加一個變數來記錄蓄能時間。

我們可以添加一個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); } // Handle other inputs... break; // Other states... }}

總之,為了添加蓄能攻擊,我們不得不修改兩個方法,並且添加一個 chargeTime成員給主角,儘管這個成員變數只有在主角處於躲避狀態的時候才有效。我們其實真正想要的是把所有這些與狀態相關的數據和代碼封裝起來。

接下來,我們正式介紹四人幫設計模式中的狀態模式來解決這個問題。

五、用狀態模式實現狀態機

5.1 狀態模式概述

對於沉浸於面向對象思維方式的同學來說,每一個條件分支都可以用動態調度來解決(也就是虛函數和多態來解決)。但是,如果你不分青紅皂白每次都這樣做,可能就會簡單的問題複雜化。其實有時候,一個簡單的if語句就足夠了。

四人幫對於狀態模式是這麼描述的:

「Allow an object to alter its behavior whenits internal state changes. The object will appear to change its class.

允許對象在當內部狀態改變時改變其行為,就好像此對象改變了自己的類一樣。」

其實,狀態模式主要解決的就是當控制一個對象狀態轉換的條件表達式過於複雜的情況,它把狀態的判斷邏輯轉移到表示不同的一系列類當中,可以把複雜的邏輯判斷簡單化。

狀態模式的實現要點,主要有三點:

  • 為狀態定義一個介面。
  • 為每個狀態定義一個類。
  • 恰當地進行狀態委託。

下面將分別進行概述。

5.2 步驟一、為狀態定義一個介面

首先,我們為狀態定義一個介面。每一個與狀態相關的行為都定義成虛函數。對於上文的例子而言,就是handleInput和update函數。

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

5.3 步驟二、為每個狀態定義一個類

對於每一個狀態,我們定義了一個類並繼承至此狀態介面。它覆蓋的方法定義主角對應此狀態的行為。換句話說,把之前的switch語句裡面的每個case語句中的內容放置到它們對應的狀態類裡面去。比如:

class DuckingState : public HeroineState{public: DuckingState( ) :chargeTime_(0) { } virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Change to standing state... heroine.setGraphics(IMAGE_STAND); } } virtual void update(Heroine& heroine) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { heroine.superBomb( ); } }private: int chargeTime_;};

注意我們也將chargeTime_移出了Heroine,放到了DuckingState(下蹲狀態)類中。 因為這部分數據只在這個狀態有用,這樣符合設計的原則。

5.4 步驟三、恰當地進行狀態委託

接下來,我們在主角類Heroine中定義一個指針變數,讓它指向當前的狀態。放棄之前巨大的switch,然後讓它去調用狀態介面的虛函數,最終這些虛方法就會動態地調用具體子狀態的相應函數了:

class Heroine{public: virtual void handleInput(Input input) { state_->handleInput(*this, input); } virtual void update( ) { state_->update(*this); } // Other methods...private: HeroineState* state_;};

而為了「改變狀態」,我們只需要將state_聲明指向不同的HeroineState對象。至此,經過為狀態定義一個介面,為每個狀態定義一個類以及進行狀態委託,經歷這三步,就是的狀態模式的實現思路了。

六、狀態對象的存放位置探討

這裡忽略了一些細節。為了修改一個狀態,我們需要給state指針賦值為一個新的狀態,但是這個新的狀態對象要從哪裡來呢?我們的之前的枚舉方法是一些數字定義。但是,現在我們的狀態是類,我們需要獲取這些類的實例。

通常來說,有兩種實現存放的思路:

  • 靜態狀態。初始化時把所有可能的狀態都new好,狀態切換時通過賦值改變當前的狀態。
  • 實例化狀態。每次切換狀態時動態new出新的狀態。

下面分別進行介紹。

6.1 方法一:靜態狀態

如果一個狀態對象沒有任何數據成員,那麼它的惟一數據成員便是虛表指針了。那樣的話,我們就沒有必要創建此狀態的多個實例了,因為它們的每一個實例都是相等的。

如果你的狀態類沒有任何數據成員,並且它只有一個函數方法在裡面。那麼我們還可以進一步簡化此模式。我們可以通過一個狀態函數來替換狀態類。這樣的話,我們的state變數只需要變成一個狀態函數指針就可以了。在此情況下,我們可以定義一個靜態實例。即使你有一系列的FSM在同時運轉,所有的狀態機都同時指向這一個惟一的實例。

在哪裡放置靜態實例取決於你的喜好。如果沒有任何特殊原因的話,我們可以把它放置到基類狀態類中:

class HeroineState{public: static StandingState standing;//站立狀態 static DuckingState ducking;//下蹲狀態 static JumpingState jumping;//跳躍狀態 static DivingState diving;//下斬狀態 // Other code...};

每一個靜態成員變數都是對應狀態類的一個實例。如果我們想讓主角跳躍,那麼站立狀態的可以這樣實現:

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

6.2 方式二:實例化狀態

剛剛講到的基於靜態狀態的方式有一定的局限性。比如說,一個靜態狀態就不能勝任上面例子中我們提到的躲避狀態的實現。因為躲避狀態有一個 chargeTime成員變數,而chargeTime成員變數是專屬於每個主角類的躲避狀態的。

也就是說,如果我們的遊戲裡面只有一個主角,那麼定義一個靜態類沒啥問題。但是,如果我們想加入多個玩家,那麼此方法就行不通了。

所以,當有多個玩家時,我們不得不在狀態切換的時候動態地創建一個躲避狀態實例。

這樣,我們的FSM就擁有了它自己的實例。當然,如果我們又動態分配了一個新的狀態實例,我們需要負責清理老的狀態實例。我們這裡必須要相當小心,因為當前狀態修改的函數是處在當前狀態裡面,我們需要小心地處理刪除的順序。

另外,我們也會在handleInput方法裡面可選地返回一個新的狀態。當這個狀態返回的時候,主角將會刪除老的狀態並切換到這個新的狀態,如下所示:

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) { // Other code... return new DuckingState( ); } //Stay in this state. return NULL;}

如果可以,推薦使用靜態狀態,因為它們不會在狀態轉換時消耗太多的內存和CPU。但是,對於更多的狀態,實例化狀態的方式將是不錯的選擇。

七、實踐:Unity中C#版本狀態模式實現

除了文章中講解用到的C++版本的代碼,我也使用C#在Unity中實現了幾個版本的狀態模式,如下圖,包含一個Stucture(狀態模式的框架)和四個Example(四種不同使用狀態模式的實例)。

上文引例中的示例,也就是女英雄在站立,跳躍,俯卧,下斬幾個狀態之間切換的問題,在example4中進行了Unity版本的實現。鏈接:

Unity-Design-Pattern/Assets/Behavioral Patterns/State Pattern at master · QianMo/Unity-Design-Pattern · GitHub

運行場景,按鍵盤上的上下方向鍵,可以在console窗口中看到對應的狀態,有點遊戲發展史早期文字冒險遊戲的感覺。運行截圖:

由於篇幅原因,代碼就不貼在這邊了。具體可以到我的Github的repo "Unity-Design-Pattern」中看到。這個repo目前已經在Unity中實現了《設計模式:可復用面向對象軟體的基礎》一書中提出的23種設計模式。且每種模式都包含對應的結構實現、應用示例以及圖示介紹。有感興趣的朋友可以關注一下。鏈接:

GitHub - QianMo/Unity-Design-Pattern

八、本文知識點總結

本文涉及知識點總結如下:

  • 在遊戲開發過程中,涉及到複雜的狀態切換時,可以運用狀態模式以及狀態機來高效地完成任務。

  • 有限狀態機的實現方式,有兩種可以選擇:

    1. 用枚舉配合switch case語句。

    2. 用多態與虛函數(即狀態模式)。

  • 狀態模式的經典定義:允許對象在當內部狀態改變時改變其行為,就好像此對象改變了自己的類一樣。

  • 對狀態模式的理解:狀態模式用來解決當控制一個對象狀態轉換的條件表達式過於複雜的情況,它把狀態的判斷邏輯轉移到表示不同的一系列類當中,可以把複雜的邏輯判斷簡單化。

  • 狀態模式的實現分為三個要點:為狀態定義一個介面。

    1. 為每個狀態定義一個類。

    2. 恰當地進行狀態委託。

  • 通常來說,狀態模式中狀態對象的存放有兩種實現存放的思路:
    1. 靜態狀態。初始化時把所有可能的狀態都new好,狀態切換時通過賦值改變當前的狀態。
    2. 實例化狀態。每次切換狀態時動態new出新的狀態。

九、參考文獻與進階閱讀

[1] State · Design Patterns Revisited · Game Programming Patterns

[2] Gamma E. Design patterns: elements of reusableobject-oriented software[M]. Pearson Education India, 1995.

[3] https://www.youtube.com/watch?v=MGEx35FjBuo&list

[4] UML state machine :UML state machine

[5] Hierarchical State Machines分層狀態機:

Hierarchical State Machine Design Pattern

[6] Pushdown Automata下推自動機:en.wikipedia.org/wiki/P


推薦閱讀:

「小白DAY4」這樣你就懂了,談CSS設計模式
自己實現的觀察者模式、BroadcastReceiver和EventBus三者的優缺點是什麼?
MVC 架構與 Observer 模式有什麼異同點?
c++ 單例模式的一點疑問,求解答?
在替考的代理模式中到底誰是Proxy?

TAG:设计模式 | Unity游戏引擎 | 游戏开发 |