趣谈设计模式 | 状态模式(State):如何实现游戏中的状态切换?
文章目錄
- 案例:馬里奧積分競賽
- 有限狀態(tài)機
- 分支邏輯法
- 查表法
- 狀態(tài)模式
- 狀態(tài)模式與策略模式
- 總結
- 完整代碼與文檔
案例:馬里奧積分競賽
喜歡馬里奧的小伙伴們都應該知道,前不久馬里奧為了慶祝35周年,推出了一款以多人對抗大逃殺為核心的超級馬里奧兄弟35
此處僅為舉例,并無此開發(fā)計劃
由于新穎的游戲模式帶來了巨大的熱度,于是任天堂決定趁熱打鐵,推出一款馬里奧競技游戲,在一定時間內獲得積分最多的玩家將獲得勝利。考慮到游戲并非正傳,于是任天堂將游戲的開發(fā)工作外包給了小明所在的游戲公司來進行制作。
游戲的核心玩法就是在一定時間內獲取最高的積分,為了增加游戲的難度,我們設定只有獲取道具才能夠獲得積分,而一旦遭受傷害就會損失積分,而死亡后積分就會清空。同時為了給落后的玩家反擊的機會,以及給領先的玩家造成壓迫感,玩家死亡后并不會退出游戲,而是積分清空后重新挑戰(zhàn)。
在最初的版本中,我們只開放了蘑菇、太陽花兩種道具,以及簡單的設置了造成傷害的陷阱,于是馬里奧的狀態(tài)和行為如下
狀態(tài)具有四種,分別是普通馬里奧、超級馬里奧、火焰馬里奧、死亡馬里奧
由于開放的道具不多,所以行為只有獲得蘑菇、獲得太陽花、受到傷害、復活四種。并且不同的行為都會帶來不同的狀態(tài)/分數變化。
根據狀態(tài)和行為,畫出狀態(tài)轉移圖
從上面可以看出,如果我們要實現這些邏輯的轉換,其實就是去實現一個狀態(tài)機,為了照顧到不了解狀態(tài)機的同學,下面我會簡單的描述一下什么是狀態(tài)機
有限狀態(tài)機
有限狀態(tài)機簡寫為FSM(Finite State Machine),我們通常將其簡稱為狀態(tài)機。狀態(tài)機由以下三個部分組成:狀態(tài)(State)、事件(Event)、動作(Action),其中事件也被叫做轉移條件(Transition Condition)
狀態(tài)機的作用就是根據不同的事件來觸發(fā)狀態(tài)的轉移以及動作的執(zhí)行
例如上面提到的馬里奧中的形態(tài)轉變,就是一個狀態(tài)機。其中馬里奧的不同形態(tài)(如超級馬里奧,火焰馬里奧)就是狀態(tài)機中的狀態(tài)。游戲中觸發(fā)的事件(獲得蘑菇、遭受傷害)就是狀態(tài)機中的事件。觸發(fā)事件后的積分變化就是狀態(tài)機的動作。而其中由事件(吃蘑菇)帶來的狀態(tài)變化(普通馬里奧變?yōu)槌夞R里奧)就是狀態(tài)轉移。
那么我們如何用代碼來實現上面所說的狀態(tài)機呢?下面我會分別介紹三種方法,分別是分支邏輯法、查表法、狀態(tài)模式
首先給出一個通用的自動機骨架,我們使用枚舉來表示四種狀態(tài), 同時為狀態(tài)機提供觸發(fā)事件以及獲取信息的接口,代碼如下
//狀態(tài) enum State {NORMAL, //普通狀態(tài)SUPER, //超級狀態(tài)FIRE, //火焰狀態(tài)DEAD //死亡狀態(tài) };//狀態(tài)機 class MarioStateMachine { public:MarioStateMachine(): _score(0), _state(NORMAL){}void getRevive(); //復活void getMushroom(); //獲得蘑菇void getSunFlower(); //獲得太陽花void getHurt(); //受到傷害int getScore() const; //獲取當前分數State getState() const; //獲取當前狀態(tài)private:int _score; //當前分數State _state; //當前狀態(tài) };分支邏輯法
要想實現狀態(tài)機,最容易的方法就是直接參照狀態(tài)轉移圖,直接將每種事件中每種狀態(tài)變化翻譯成代碼,由于這樣的代碼中存在大量的分支邏輯判斷,所以這種方法又叫做分支邏輯法
代碼實現如下
//獲取當前分數 int MarioStateMachine::getScore() const {return _score; }//獲取當前狀態(tài) State MarioStateMachine::getState() const {return _state; }//復活 void MarioStateMachine::getRevive() {if(_state == FIRE){std::cout << "當前未死亡,不能復活。不存在該邏輯" << std::endl; }else if(_state == SUPER){std::cout << "當前未死亡,不能復活。不存在該邏輯" << std::endl; }else if(_state == NORMAL){std::cout << "當前未死亡,不能復活。不存在該邏輯" << std::endl; }else if(_state == DEAD){_state = NORMAL;_score = 0;std::cout << "復活馬里奧,會到普通狀態(tài),分數重新計算" << std::endl;} }//獲得蘑菇 void MarioStateMachine::getMushroom() {if(_state == FIRE){_score += 100;std::cout << "獲得蘑菇,增加一百分" << std::endl; }else if(_state == SUPER){_score += 100;std::cout << "獲得蘑菇,增加一百分" << std::endl;}else if(_state == NORMAL){_state = SUPER;_score += 100;std::cout << "獲得蘑菇,由普通馬里奧變?yōu)槌夞R里奧,增加一百分" << std::endl;}else if(_state == DEAD){std::cout << "死亡后不能獲取道具,不存在該邏輯" << std::endl;} }; //獲得太陽花 void MarioStateMachine::getSunFlower() {if(_state == FIRE){_score += 200;std::cout << "獲得太陽花,增加兩百分" << std::endl; }else if(_state == SUPER){_state = FIRE;_score += 200;std::cout << "獲得太陽花,由超級馬里奧變?yōu)榛鹧骜R里奧,增加兩百分" << std::endl;}else if(_state == NORMAL){_state = FIRE;_score += 200;std::cout << "獲得太陽花,由普通馬里奧變?yōu)榛鹧骜R里奧,增加兩百分" << std::endl;}else if(_state == DEAD){std::cout << "死亡后不能獲取道具,不存在該邏輯" << std::endl;} }; //遭受傷害 void MarioStateMachine::getHurt() {if(_state == FIRE){_state = SUPER;_score -= 100;std::cout << "受到傷害,由火焰馬里奧變?yōu)槌夞R里奧,扣一百分" << std::endl; }else if(_state == SUPER){_state = NORMAL;_score -= 100;std::cout << "受到傷害,由超級馬里奧變?yōu)槠胀R里奧,扣一百分" << std::endl;}else if(_state == NORMAL){_state = DEAD;_score = 0;std::cout << "受到傷害,馬里奧死亡,分數清空" << std::endl;}else if(_state == DEAD){std::cout << "死亡后不能受到傷害,不存在該邏輯" << std::endl;} };簡單的寫一段代碼測試狀態(tài)機是否正確
int main() {MarioStateMachine Mario;Mario.getMushroom(); //馬里奧獲取蘑菇Mario.getSunFlower(); //馬里奧獲取太陽花Mario.getSunFlower(); //馬里奧獲取太陽花std::cout << Mario.getScore() << std::endl; //查看得分情況Mario.getRevive(); //嘗試復活Mario.getHurt();Mario.getHurt();std::cout << Mario.getScore() << std::endl; //檢查受傷后是否扣分Mario.getHurt();std::cout << Mario.getScore() << std::endl; //死亡分數清空 }
代碼執(zhí)行沒有錯誤。
對于簡單且不需拓展的狀態(tài)機來說,分支邏輯的缺點并不明顯。但是對于復雜的狀態(tài)機來說,隨著狀態(tài)越來越多,代碼中就會充斥著大量的分支判斷邏輯,極易漏寫或錯寫某個狀態(tài)。不僅僅可讀性差,可維護性也差,如果我們需要新增或者修改某個狀態(tài),就需要去修改一系列的代碼來保證邏輯的正常執(zhí)行,不僅麻煩還容易出錯。
為了讓代碼更加可讀且方便拓展,我們可以考慮使用查表法來實現狀態(tài)機。
查表法
查表法并非本章講解的重點,只是因為提到狀態(tài)機后順帶提一提這個知識點,如果不需要了解可以直接跳過到下面的狀態(tài)模式。
除了用狀態(tài)轉移圖外,我們還可以用二維的狀態(tài)轉移表來表示狀態(tài)機。其中第一維為狀態(tài),第二維是事件,值表示發(fā)生的行動以及狀態(tài)的轉移
查表法,就是依賴按照狀態(tài)轉移表,維護一個狀態(tài)轉移數組以及行為數組,根據不同的事件來觸發(fā)數組中的對應元素
如上圖,此時我們需要引入行為表和狀態(tài)轉移表,并且提供一個查表函數,根據當前遭遇的事件以及當前狀態(tài),自動去查表來獲取當前轉移狀態(tài)和行為,所以新的骨架如下
填寫轉移表和行為表
//用INT_MIN表示清空,用0表示不符合邏輯的忽略情況 std::vector<std::vector<int>> MarioStateMachine::_actionTable = {{100, 200, INT_MIN, 0},{100, 200, -100, 0},{200, 200, -100, 0},{0, 0, 0, INT_MIN}, };std::vector<std::vector<State>> MarioStateMachine::_stateTable = {{SUPER, FIRE, DEAD, NORMAL},{SUPER, FIRE, NORMAL, SUPER},{FIRE, FIRE, SUPER, FIRE},{DEAD, DEAD, DEAD, NORMAL}, };接下來用查表法來實現我們的新骨架,我們提供了一個executeEvent接口,當執(zhí)行各種事件函數的時候就會根據事件去查詢表來獲取結果
void MarioStateMachine::executeEvent(Event event) {int score = _actionTable[_state][event]; //查詢表中對應的動作_score = (score == INT_MIN) ? _score = 0 : _score + score; //如果為INT_MIN,則說明需要清空 _state = _stateTable[_state][event]; //查詢表中對應的狀態(tài) }void MarioStateMachine::getRevive() {executeEvent(GET_REVIVE); }void MarioStateMachine::getMushroom() {executeEvent(GET_MUSHROOM); }; void MarioStateMachine::getSunFlower() {executeEvent(GET_SUNFLOWER); }; void MarioStateMachine::getHurt() {executeEvent(GET_HURT); };用上面的測試代碼再次進行測試
int main() {MarioStateMachine Mario;Mario.getMushroom(); //馬里奧獲取蘑菇Mario.getSunFlower(); //馬里奧獲取太陽花Mario.getSunFlower(); //馬里奧獲取太陽花std::cout << Mario.getScore() << std::endl; //查看得分情況Mario.getRevive(); //嘗試復活Mario.getHurt();Mario.getHurt();std::cout << Mario.getScore() << std::endl; //檢查受傷后是否扣分Mario.getHurt();std::cout << Mario.getScore() << std::endl; //死亡分數清空return 0; }
相較于分支邏輯法,查表法的代碼更加簡潔、清晰,可讀性和可維護性更高。當我們需要修改狀態(tài)機的時候,就只需要修改行為表和轉移表。我們甚至可以將這兩個表保存到配置文件中,這樣修改的時候就只需要修改配置文件,而不需要修改任何代碼。
但是從上面的實現我們也可以看到,如果我們的動作只是簡單的積分變化,就可以使用行為表表示,倘若其中是一些復雜的邏輯呢?例如上面代碼中的輸出日志,行為表就沒辦法將其執(zhí)行,因此查表法具有一定的局限性。
雖然分支邏輯不存在這個問題,但是我們前面也提到了它存在大量的邏輯判斷,導致維護性和可讀性不高,既然它們兩者都有一定的缺點,那是否還有第三種方法能夠更好的實現狀態(tài)機呢?
答案是肯定的,鋪墊了這么久,接下來就到狀態(tài)模式大顯身手的時候了
狀態(tài)模式
狀態(tài)模式允許對象在內部狀態(tài)改變的時候改變它的行為,讓對象看起來好像修改了它的類
我們不再使用枚舉來表示狀態(tài),而是將每個狀態(tài)封裝為一個類,并在該類中實現其對應事件的動作及狀態(tài)轉移。由于行為會隨著內部狀態(tài)而改變,所以我們將狀態(tài)機處理事件的行為委托到代表當前狀態(tài)的對象中,這樣我們就通過組合簡單引用不同的狀態(tài)對象來造成類改變的假象
狀態(tài)模式的類圖如下
將其轉換為我們案例的類圖
此時我們的狀態(tài)轉換不再像之前一樣由狀態(tài)機進行,而是由狀態(tài)對象進行,例如下圖
假設我們一開始是普通馬里奧
此時獲得蘑菇
此時內部的狀態(tài)對象變?yōu)槌夞R里奧
講解完了思路,下面開始分別實現這幾部分
馬里奧的狀態(tài)接口如下
class MarioState { public:virtual ~MarioState() = default;virtual MarioState* getState(); //獲取當前狀態(tài)virtual std::string getStateName(); //獲取狀態(tài)名virtual void getRevive(); //復活virtual void getMushroom(); //獲得蘑菇virtual void getSunFlower(); //獲得太陽花virtual void getHurt(); //受到傷害 };接著我們將每個狀態(tài)封裝為一個類,并實現狀態(tài)接口。下面僅實現一個,其他的放在末尾的鏈接中。
每個具體狀態(tài)類中都需要保存一個狀態(tài)機的指針,來進行狀態(tài)的轉移和動作的執(zhí)行。
class NormalMario : public MarioState { public:NormalMario(MarioStateMachine* stateMachine): _stateMachine(stateMachine){}void getRevive() override{std::cout << "當前未死亡,不能復活。不存在該邏輯" << std::endl; } void getMushroom() override{_stateMachine->setScore(_stateMachine->getScore() + 100);_stateMachine->setState(_stateMachine->getSuperMario());std::cout << "獲得蘑菇,由普通馬里奧變?yōu)槌夞R里奧,增加一百分" << std::endl;}void getSunFlower() override{_stateMachine->setScore(_stateMachine->getScore() + 200);_stateMachine->setState(_stateMachine->getFireMario());std::cout << "獲得太陽花,由普通馬里奧變?yōu)榛鹧骜R里奧,增加兩百分" << std::endl;}void getHurt() override{_stateMachine->setScore(0);_stateMachine->setState(_stateMachine->getDeadMario());std::cout << "受到傷害,馬里奧死亡,分數清空" << std::endl;} std::string getStateName() override{return "普通馬里奧";}private:MarioStateMachine* _stateMachine; //狀態(tài)機 };接著我們用策略模式來改寫狀態(tài)機,其中為了避免大量生成狀態(tài)對象,我提前將所有狀態(tài)緩存到狀態(tài)機中,并提供獲取狀態(tài)實例的接口(我們也可以采用將狀態(tài)與單例模式相結合的做法,保證每個狀態(tài)只有一個實例),代碼如下
class MarioStateMachine { public:MarioStateMachine(): _score(0){//提前緩存各種狀態(tài)_normalMario = new NormalMario(this);_superMario = new SuperMario(this);_fireMario = new FireMario(this);_deadMario = new DeadMario(this);_state = _normalMario;}~MarioStateMachine(){delete _normalMario, _superMario, _fireMario, _deadMario;}void getRevive(); //復活void getMushroom(); //獲得蘑菇void getSunFlower(); //獲得太陽花void getHurt(); //受到傷害int getScore() const; //獲取當前分數MarioState* getState() const; //獲取當前狀態(tài)void setScore(int score); //獲取當前分數void setState(MarioState* state); //獲取當前狀態(tài)MarioState* getNormalMario(); //獲取緩存的狀態(tài)MarioState* getSuperMario();MarioState* getFireMario();MarioState* getDeadMario();private:int _score; //當前分數MarioState* _state; //當前狀態(tài)MarioState* _superMario; //緩存所有的狀態(tài)MarioState* _normalMario;MarioState* _fireMario; MarioState* _deadMario; };由于狀態(tài)機會將事件發(fā)生后的行為與狀態(tài)轉移委托給當前的狀態(tài)對象,因此我們只需要調用狀態(tài)對象的方法即可
void MarioStateMachine::getRevive() {_state->getRevive(); }void MarioStateMachine::getMushroom() {_state->getMushroom(); }void MarioStateMachine::getSunFlower() {_state->getSunFlower(); }void MarioStateMachine::getHurt() {_state->getHurt(); } int MarioStateMachine::getScore() const {return _score; }MarioState* MarioStateMachine::getState() const {return _state; }void MarioStateMachine::setScore(int score) {_score = score; }void MarioStateMachine::setState(MarioState* state) {_state = state; }MarioState* MarioStateMachine::getNormalMario() {return _normalMario; } MarioState* MarioStateMachine::getSuperMario() {return _superMario; }MarioState* MarioStateMachine::getFireMario() {return _fireMario; }MarioState* MarioStateMachine::getDeadMario() {return _deadMario; }接著繼續(xù)使用開頭的代碼進行測試,由于我們是在一開始搭建的狀態(tài)機骨架上進行拓展的,因此不需要修改任何代碼
int main() {MarioStateMachine Mario;Mario.getMushroom(); //馬里奧獲取蘑菇Mario.getSunFlower(); //馬里奧獲取太陽花Mario.getSunFlower(); //馬里奧獲取太陽花std::cout << Mario.getScore() << std::endl; //查看得分情況Mario.getRevive(); //嘗試復活Mario.getHurt();Mario.getHurt();std::cout << Mario.getScore() << std::endl; //檢查受傷后是否扣分Mario.getHurt();std::cout << Mario.getScore() << std::endl; //死亡分數清空 }狀態(tài)模式與策略模式
如果不了解策略模式,可以參考我的往期博客
趣談設計模式 | 策略模式(Strategy):你還在使用冗長的if-else嗎?
在上面,我給出了狀態(tài)模式的類圖,我們驚奇的發(fā)現它的類圖竟然和策略模式一模一樣,并且他們的思路也存在相似的地方,它們都可以用來消除大量的條件判斷,并且都可以在允許時動態(tài)切換行為
雖然類圖相同,但是它們的意圖截然不同,策略模式會控制對象使用什么策略,而狀態(tài)模式會自動改變狀態(tài)
以策略模式而言,雖然他也能夠通過組合不同的策略對象來動態(tài)的切換行為,但是這些都需要決策者自己控制使用的策略對象,其中沒有良好的狀態(tài)轉換
而對于狀態(tài)模式,狀態(tài)機則將狀態(tài)轉換的任務委托給了當前的狀態(tài)對象,當前狀態(tài)對象會根據不同的事件自動而切換到其他的狀態(tài)對象,狀態(tài)機本身的行為也會隨著狀態(tài)對象的切換而變化,但是這些都是自動的,并不需要它自己處理
簡單的總結一下就是,策略模式使用策略來主動配置Context而改變行為,狀態(tài)模式則讓Context隨著狀態(tài)的改變自動改變行為。即一個是外部手動切換,一個是內部自動切換
總結
分支邏輯法
特點
- 利用if-else或者switch-case進行邏輯分支邏輯判斷,直接將狀態(tài)轉移圖的每個狀態(tài)轉移翻譯成代碼
應用場景
- 簡單、不考慮拓展的狀態(tài)機
查表法
特點
- 維護了一個動作表和狀態(tài)轉移表,根據具體事件以及當前狀態(tài)進行查表,來實現狀態(tài)轉移和動作
應用場景
- 條件、分支語句的替代
- 狀態(tài)很多、狀態(tài)轉移復雜,但是事件觸發(fā)后執(zhí)行動作簡單的狀態(tài)機
狀態(tài)模式
特點
- 狀態(tài)模式允許一個對象基于內部狀態(tài)而擁有不同行為
- 狀態(tài)機會將行為委托給當前狀態(tài)對象,所以狀態(tài)機會隨著狀態(tài)的改變而改變行為。
- 和其他方法實現的狀態(tài)機不同,狀態(tài)模式用類來表示狀態(tài)。但是也會導致設計中類的數目大量增加,因此最好在狀態(tài)少,狀態(tài)轉移簡單時使用
- 由于狀態(tài)類可以被多個狀態(tài)機共享,所以通常以單例模式實現
- 狀態(tài)的改變局部化,需要增加新狀態(tài)時只需要實現狀態(tài)接口,并完成具體事件的行為即可。實現了具體操作與狀態(tài)轉換之間的解耦
應用場景
- 行為隨著狀態(tài)改變而改變的情景
- 條件、分支語句的替代
- 狀態(tài)不多、狀態(tài)轉移簡單,但是事件觸發(fā)后執(zhí)行動作復雜的狀態(tài)機。如:游戲、電商下單
完整代碼與文檔
如果有需要完整代碼或者markdown文檔的同學可以點擊下面的github鏈接
github
總結
以上是生活随笔為你收集整理的趣谈设计模式 | 状态模式(State):如何实现游戏中的状态切换?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 林纳斯定律
- 下一篇: 在deepin中安装longman英语字