一熊经、前言
本文是一篇關于游戲設計模式之狀態(tài)模式的文章內容翻譯明未,我在上一篇文章 Godot3游戲引擎入門之十四:剛體RidigBody2D節(jié)點的使用以及簡單的FSM狀態(tài)機介紹中簡單地介紹了 FSM 有限狀態(tài)機的含義以及游戲中的簡單實現吮旅,講述的很淺顯,如果你對游戲設計模式感興趣县昂,我相信本篇文章會適合你,如有翻譯不當之處請諒解陷舅,哈哈倒彰。 :smiley:
作者簡介:
Robert Nystrom ,《 Game Programming Patterns 》的作者
原文鏈接: http://www.gameprogrammingpatterns.com/state.html
二莱睁、正文
懺悔時間:我對這一章節(jié)的內容有點夸大其詞狸驳。表面上是關于狀態(tài)設計模式的探討,但我不得不談及游戲中關于有限狀態(tài)機制(或稱為 "FSM" )的基本概念缩赛。不過我一旦提及到這個耙箍,那么我想我也不妨介紹下分層狀態(tài)機和下推自動機的概念以及相關原理。
這會涵蓋多方面的知識點酥馍,為了盡可能地縮短文章篇幅辩昆,文中使用的代碼示例省略了一些細節(jié),這些是您必須自己填寫的旨袒。不管怎樣汁针,我還是希望這些知識點仍然能夠清晰以便能讓你了解整個理念。
如果你從未聽說過狀態(tài)機砚尽,也請不要感到難過施无。狀態(tài)機不像 AI 和編譯器、黑客那樣必孤,它在編程圈子里沒有那么耳熟能詳猾骡。不過我認為它們更應該廣為人知,所以我在這里會把它們拋到一個不同層次的問題上去看待敷搪。
我們曾經都見識過
假設我們正在研究一個往一邊滾動的平臺游戲兴想。我們的工作是實現游戲中的女主角,即玩家在游戲世界中的化身赡勘。這意味著要讓她響應用戶的輸入嫂便。比如按下 <key>B</key> 鍵,她應該跳躍闸与。實現起來非常簡單:
void Heroine::handleInput(Input input)
{
if (input == PRESS_B)
{
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
}
有什么問題嗎毙替?
目前還不能阻止“在空氣中跳躍”的發(fā)生——當她在空中時繼續(xù)點擊 <key>B</key> 鍵,她將永遠漂浮下去践樱。這里最簡單的解決方法是給 Heroine
添加一個 isJumping_
的布爾字段厂画,用于跟蹤判斷她是否已經跳躍,然后再執(zhí)行操作:
void Heroine::handleInput(Input input)
{
if (input == PRESS_B)
{
if (!isJumping_)
{
isJumping_ = true;
// 起跳...
}
}
}
接下來映胁,我們希望實現:如果女主角在地面上木羹,玩家按下<key>下方向鍵</key>按鈕她就能進行躲閃,而松開按鈕的時候,她又會重新站起來:
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);
}
}
這次有沒有發(fā)現問題所在坑填?
通過以上代碼玩家可以實現:
- 按下按鍵躲閃抛人。
- 按 <key>B</key> 鍵從閃避位置開始跳躍。
- 在空中松開按鈕也能站立脐瑰。
女主角跳躍在空中就能切換到她的站立姿勢妖枚。是時候再添加另一個判斷標記了……
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_)
{
// 站立...
}
}
}
又是尋找 Bug 的時候了。找到問題了嗎寂恬?
我們已經確定玩家在跳躍的過程中是不能繼續(xù)在空中二次跳躍了续誉,但這對于俯沖效果并不適用〕跞猓看來我們又開辟了一個新的問題領域……
我們的方法顯然存在一些問題酷鸦。每當我們修改這些代碼,我們都會破壞某些邏輯牙咏。我們還需要添加更多的動作——我們還沒有添加行走行為呢——但是按照目前這個進度臼隔,它會在我們完成之前就已經崩潰成一堆的 Bug 了。
有限狀態(tài)機救場
有點沮喪妄壶,不過至少你可以掃除桌面上除了紙和筆之外的所有其他東西摔握,并開始來繪制一個流程圖。你把女主角可以做的每個動作都畫成一個長方形框:站立丁寄,跳躍氨淌,閃避和俯沖。當她處于其中的某一個狀態(tài)并按下某個按鈕時狡逢,您就可以從該狀態(tài)框中畫出來一個箭頭宁舰,箭頭上用這個按鈕做標記,然后將其連接到她應該切換到的另一個狀態(tài)上奢浑。
恭喜,您剛剛創(chuàng)建了一個有限狀態(tài)機腋腮。這來自計算機科學的一個分支雀彼,被稱為自動機理論,其數據結構所在家族還包括著名的圖靈機即寡。 FSM 是該家族中最簡單的一位成員徊哑。
幾個要點是:
- 你有一套固定的機械狀態(tài)。在我們的例子中聪富,那就是站立莺丑,跳躍,閃避和俯沖。
- 機器一次只能處于一種狀態(tài)中梢莽。我們的女主角不能同時既跳躍又站立萧豆。事實上,防止這種情況的發(fā)生正是我們將要采用 FSM 機制的原因之一昏名。
- 一系列輸入或者事件會被發(fā)送到機器涮雷。在我們的示例中,也就是原始的按鍵按下與釋放動作轻局。
- 每個狀態(tài)都有一系列轉換機制洪鸭,每個轉換與某個輸入相關聯(lián)并指向另一個狀態(tài)。當有輸入進入時仑扑,如果輸入與當前狀態(tài)的轉換相匹配览爵,則機器的狀態(tài)將切換為轉換所指向的新狀態(tài)。
例如镇饮,在站立狀態(tài)時按下下方向鍵就可以過渡到閃避狀態(tài)拾枣。在跳躍狀態(tài)下按下下方向鍵可以過渡到俯沖狀態(tài)。如果沒有給當前狀態(tài)的輸入定義轉換盒让,那么這個輸入會被忽略梅肤。
說的純粹點,它的整個組成就是:狀態(tài)邑茄,輸入和轉換姨蝴。您可以把它繪制成一個小流程圖。不幸的是肺缕,編譯器沒法識別我們的涂鴉左医,那么我們如何才能實現一個呢?四人幫 Gang of Four 的狀態(tài)模式就是其中的一種方案——我們可以做到———不過先讓我們從簡單點開始吧同木。
枚舉和 Switch 語句
在我們的 Heroine
類中一個問題就是一些布爾字段的某些組合是無效的:比如浮梢, isJumping_
和 isDucking_
不能全部為 true
。如果你的一些標記中彤路,符合一次只有一個是 true
秕硝,那就意味著你所需要的是一個 enum
枚舉類。
在這種情況下洲尊,枚舉 enum
的內容恰好是我們 FSM 的狀態(tài)集远豺,所以我們給出如下定義:
enum State
{
STATE_STANDING,
STATE_JUMPING,
STATE_DUCKING,
STATE_DIVING
};
取代了一堆布爾標志, Heroine
類中將只有一個 state_
的字段坞嘀。同時我們將選擇分支的對象進行了反轉躯护。在之前的代碼中,我們先判斷輸入丽涩,然后再根據狀態(tài)進行判斷棺滞。這會把同一個按鈕的輸入事件全寫在了一起,導致某一個狀態(tài)的代碼的混亂。我們希望對同一個狀態(tài)的處理保持在一塊继准,因此我們以狀態(tài)進行分支處理枉证。代碼如下:
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;
}
}
這看起來有點繁瑣,但它確實在之前的代碼上有了真正的改進锰瘸。我們還缺少一些條件分支刽严,不過我們將可變狀態(tài)簡化成了單個的字段。現在所有處理單個狀態(tài)的代碼都很好地集中在一塊了避凝。這是實現狀態(tài)機的最簡單方式舞萄,適用于某些用途。
但是管削,你的問題很可能會超出這個方案倒脓。假設我們想要添加一個新動作,我們的女主角可以閃避一段時間以補充能量含思,然后發(fā)動某個特殊攻擊崎弃。當她進行閃避動作時,我們需要跟蹤其能量補充的時間含潘。
我們向 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;
// 其他狀態(tài)...
}
}
總而言之盆均,為了增加這種特殊的大招攻擊狀態(tài),我們必須修改兩個方法并在 Heroine
類上添加一個 chargeTime_
字段漱逸,即使這個字段只有在女主角處于閃避的狀態(tài)下才有意義泪姨。我們傾向于將所有的代碼和數據完美地整合在一起。這方面四人幫的設計模式已經涵蓋了饰抒。
設計模式之狀態(tài)模式
對于對面向對象思想有深入了解的人來說肮砾,每個條件分支都是一個使用動態(tài)分派的機會(換句話說,也就是在 C++ 中使用虛擬方法)袋坑。我估計你可能會太深入而掉進了那個兔子打的洞里仗处。有時候你需要的僅僅是一個 if
語句而已。
不過在我們的例子中咒彤,我們已經達到了一個轉折點疆柔,即使用面向對象的思想更合適。這讓我們順理成章地使用狀態(tài)模式镶柱。引用四人幫的話來說:
允許對象在其內部狀態(tài)發(fā)生變化時更改其行為。這個對象貌似會更改它所在的類模叙。
其實這并沒有告訴我們多少東西歇拆。不過,我們的 switch
已經搞定了。他們所描述的具體模式故觅,在我們的女主角類中實現起來像下面這樣:
一個狀態(tài)接口
首先厂庇,我們?yōu)闋顟B(tài)定義一個接口。每個行為都依賴于狀態(tài)——就是我們之前每個 switch
分支的地方——都轉變成該接口中的虛擬方法输吏。對我們來說权旷,這里的方法就是 handleInput()
和 update()
:
class HeroineState
{
public:
virtual ~HeroineState() {}
virtual void handleInput(Heroine& heroine, Input input) {}
virtual void update(Heroine& heroine) {}
};
每個狀態(tài)封裝成類
對于每個狀態(tài),我們定義一個實現接口的類贯溅。它的方法定義了女主角在該狀態(tài)下的一些行為拄氯。換句話說,從之前的 switch
語句中獲取每個 case
情形并將它們移動到其對應的 state
類中它浅。例如:
class DuckingState : public HeroineState
{
public:
DuckingState() : chargeTime_(0) {}
virtual void handleInput(Heroine& heroine, Input input) {
if (input == RELEASE_DOWN)
{
// 轉換為站立狀態(tài)...
heroine.setGraphics(IMAGE_STAND);
}
}
virtual void update(Heroine& heroine) {
chargeTime_++;
if (chargeTime_ > MAX_CHARGE)
{
heroine.superBomb();
}
}
private:
int chargeTime_;
};
注意我們還會將 chargeTime_
字段從 Heroine
類中移出并移入到 DuckingState
類中译柏。這真是太好了——這個數據段只有在該狀態(tài)下才會有意義,現在的對象模型很明顯地反映出了這一點姐霍。
狀態(tài)委托
接下來鄙麦,我們給 Heroine
類一個指向她當前狀態(tài)的指針,拋棄每段長長的 switch
語句镊折,然后將其委托給狀態(tài):
class Heroine
{
public:
virtual void handleInput(Input input)
{
state_->handleInput(*this, input);
}
virtual void update()
{
state_->update(*this);
}
// 其他方法...
private:
HeroineState* state_;
};
為了“改變狀態(tài)”胯府,我們只需要賦值 state_
變量以指向不同的 HeroineState
對象即可。整個就是狀態(tài)模式的全部了恨胚。
狀態(tài)對象實例在哪里骂因?
在這里我掩飾了一些東西。為了改變狀態(tài)与纽,我們需要給 state_
字段賦值所要指向的新狀態(tài)侣签,但是這個新狀態(tài)對象從哪里來呢?如果是通過我們的枚舉類實現急迂,這是一個欠缺思考的方式—— enum
枚舉類型的值都是一些原始的基本數據類型影所,比如數字。但現在我們的狀態(tài)的類型確是類僚碎,這意味著我們需要一個真實的實例來指向它猴娩。通常這有兩種常見的方案:
靜態(tài)類的狀態(tài)
如果狀態(tài)對象沒有任何其他字段,則它存儲的唯一數據是一個指向內部虛擬方法表的指針勺阐,這樣就可以實現其他方法的調用卷中。在這種情況下,我們并沒有什么理由讓其擁有多個實例渊抽。無論如何蟆豫,實例化的每個對象都是完全一樣的。
所以基于這種情形懒闷,你可以創(chuàng)建一個靜態(tài)的類型實例十减。即使你有一堆的 FSM 狀態(tài)機都是同時運行在同一個狀態(tài)下栈幸,它們都是可以指向同一個實例的,因為它沒有任何特定于某個機器的實例帮辟。
把靜態(tài)實例放在哪里這取決于你速址。最好是找一個有意義的地方吧。沒有什么特別的原因的話由驹,讓我們把靜態(tài)對象放在狀態(tài)的基類中吧:
class HeroineState
{
public:
static StandingState standing;
static DuckingState ducking;
static JumpingState jumping;
static DivingState diving;
// 其他代碼...
};
每個靜態(tài)字段都是游戲中使用的對應狀態(tài)的一個實例芍锚。為了能讓女主角正常跳躍,站立狀態(tài)下應該是這樣編寫的:
if (input == PRESS_B)
{
heroine.state_ = &HeroineState::jumping;
heroine.setGraphics(IMAGE_JUMP);
}
實例化的狀態(tài)
但是有時候這并不管用蔓榄。靜態(tài)狀態(tài)類不適用于閃避狀態(tài)并炮。它有一個 chargeTime_
字段,這個字段是特定于女主角的閃避狀態(tài)的润樱。如果碰巧只有一個女主角渣触,這在我們的游戲中是沒問題的,但如果我們假設添加多人玩家進行合作壹若,同時在屏幕上出現兩個女主角嗅钻,那我們就會遇到問題了。
在這種情況下店展,我們必須在進行狀態(tài)轉換的時候創(chuàng)建一個新的狀態(tài)對象實例养篓。這樣每個 FSM 都有自己的狀態(tài)實例。當然赂蕴,如果我們分配了一個新的狀態(tài)對象柳弄,那意味著我們需要釋放當前的舊狀態(tài)對象內存。我們得小心翼翼概说,因為觸發(fā)狀態(tài)改變的代碼是位于當前的舊狀態(tài)的方法內碧注。我們不想從自己本身當中刪除 this
引用。
相反糖赔,我們將允許 HeroineState
中的 handleInput()
方法可選地返回一個新的狀態(tài)萍丐。如果這樣做, Heroine
將可以刪除舊的狀態(tài)然后轉換為新的放典,代碼如下所示:
void Heroine::handleInput(Input input)
{
HeroineState* state = state_->handleInput(*this, input);
if (state != NULL)
{
delete state_;
state_ = state;
}
}
這樣的話逝变,在方法的返回值之前,我們不會刪除先前的舊狀態(tài)》芄梗現在壳影,站立狀態(tài)對象就可以通過創(chuàng)建新實例來轉換為閃避狀態(tài)了:
HeroineState* StandingState::handleInput(Heroine& heroine, Input input)
{
if (input == PRESS_DOWN)
{
// 其他代碼...
return new DuckingState();
}
// 停留在當前狀態(tài)。
return NULL;
}
如果給我選擇的話弥臼,我更傾向于使用靜態(tài)狀態(tài)模式宴咧,因為它們不會在每次狀態(tài)更改的時候因為分配對象空間而消耗內存和 CPU 調用周期。當然径缅,對于狀態(tài)機悠汽,呃箱吕,這是一種思路芥驳。
動作的進入和退出
狀態(tài)模式的目的是將一個狀態(tài)的所有行為和數據都封裝在同一個類中柿冲。一般我們已經差不多實現,但仍然還有一些東西要完成兆旬。
當女主角狀態(tài)改變時假抄,我們同時會切換她的精靈( sprite )圖片顯示。目前這個代碼由她所要發(fā)生轉換的舊狀態(tài)持有丽猬。當她從閃避狀態(tài)轉為站立狀態(tài)時宿饱,閃避狀態(tài)就會設定其顯示圖形:
HeroineState* DuckingState::handleInput(Heroine& heroine, Input input)
{
if (input == RELEASE_DOWN)
{
heroine.setGraphics(IMAGE_STAND);
return new StandingState();
}
// 其他代碼...
}
我們真正想要的是每個狀態(tài)能控制其自己的圖形顯示。我們可以通過向狀態(tài)類提供一個進入( enter )的行為來解決這個問題:
class StandingState : public HeroineState
{
public:
virtual void enter(Heroine& heroine)
{
heroine.setGraphics(IMAGE_STAND);
}
// 其他代碼...
};
回到 Heroine
類脚祟,我們修改一下處理狀態(tài)更改的代碼谬以,以便在新狀態(tài)下調用這個方法:
void Heroine::handleInput(Input input)
{
HeroineState* state = state_->handleInput(*this, input);
if (state != NULL)
{
delete state_;
state_ = state;
// 在新的狀態(tài)上調用 enter 行為。
state_->enter(*this);
}
}
這使我們可以簡化閃避狀態(tài)類的代碼如下:
HeroineState* DuckingState::handleInput(Heroine& heroine, Input input)
{
if (input == RELEASE_DOWN)
{
return new StandingState();
}
// 其他代碼...
}
現在這段代碼僅僅只用來處理切換到站立的狀態(tài)而已由桌,而圖形顯示由站立狀態(tài)自行處理了为黎。嗯,現在我們的狀態(tài)類是真的被封裝起來了行您。關于進入動作的一個特別好的效果就是它們一定是在進入該狀態(tài)時才調用铭乾,而且不管你是從哪個狀態(tài)轉換而來。
大多數真實的游戲中娃循,狀態(tài)圖是會存在從多個狀態(tài)轉換到同一個狀態(tài)的情況炕檩。例如,我們的女主角在她跳躍或俯沖后最終都呈現站立狀態(tài)捌斧。這意味著我們最后還是會在狀態(tài)轉換所發(fā)生的每一個地方編寫一些重復的代碼笛质。進入( Entry )狀態(tài)的方法為我們提供了處理這一點的地方。
當然捞蚂,同樣我們也可以擴展它以支持退出( exit )行為妇押。這只是我們在切換到新狀態(tài)之前所調用要離開的舊狀態(tài)中的一個方法。
有何收獲洞难?
我已經花了這么多時間安利你 FSM 有限狀態(tài)機舆吮,不過現在我要把你從飄飄然狀態(tài)拉回原地了。到目前為止我所說的一切都是沒有什么問題队贱, FSM 非常適合解決某些問題色冀。但是他們最大的優(yōu)點也即他們最大的缺陷。
狀態(tài)機通過強制使用固定死的結構來幫助您解開那些亂成一團的代碼柱嫌。你所擁有的全部僅為一組固定的狀態(tài)锋恬,一個單一的當前狀態(tài)和一些用于進行狀態(tài)轉換的硬編碼。
如果您嘗試使用狀態(tài)機來處理游戲中更復雜的事情编丘,例如游戲 AI 与学,那么你首先得弄清楚這個模型的局限性彤悔。值得慶幸的是,我們的先人已經為我們找到了避開這些疑難雜癥的方法索守。我將通過向你介紹其中幾個解決方案來結束本篇文章的主要內容晕窑。
并發(fā)狀態(tài)機
我們決定讓我們的女主角擁有攜帶槍支的能力。當她正在射擊的時候卵佛,她仍然可以做之前所能做的一切動作:跑步杨赤,跳躍,閃避等等截汪。而且她也能夠在做這些動作的同時發(fā)射她的武器疾牲。
如果我們堅持使用 FSM 的范疇,那么我們必須將擁有的狀態(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)機隶债。 Heroine
類將擁有兩個“狀態(tài)”引用腾它,對應我們定義的兩個狀態(tài)機,如下代碼:
class Heroine
{
// 其他代碼...
private:
HeroineState* state_;
HeroineState* equipment_;
};
當女主角向各狀態(tài)委托處理輸入時死讹,她將輸入交給兩個相應的函數分別進行處理:
void Heroine::handleInput(Input input)
{
state_->handleInput(*this, input);
equipment_->handleInput(*this, input);
}
然后瞒滴,每個狀態(tài)機可以響應輸入,生成相應的行為赞警,并獨立于其他狀態(tài)機而各自更改其狀態(tài)妓忍。這里兩組狀態(tài)機大多不會相關聯(lián)虏两,這樣處理很有效。
在項目實踐中世剖,你確實會發(fā)現某些情形下狀態(tài)機之間會發(fā)生一些交互定罢。例如,或者她并不能邊跳躍邊開火搁廓,或者如果她有武裝引颈,那么她就不能進行俯沖攻擊。為了解決此類問題境蜕,在一個狀態(tài)的代碼中,你可能會簡單地使用 if
語句測試其他機器的狀態(tài)來協(xié)調它們之間的交互凌停。這當然不是最優(yōu)雅的解決方案粱年,但它至少可以搞定這個目標。
分層狀態(tài)機
在完善了我們的女主角的一些行為后罚拟,她可能會有一堆相似的狀態(tài)台诗。例如,她可能有站立赐俗,行走拉队,跑步和滑動狀態(tài)。在其中任何一個狀態(tài)下阻逮,按下 <key>B</key> 鍵會跳躍再按下方向鍵則俯沖粱快。
如果通過一個簡單的狀態(tài)機實現,那么我們必須在每個狀態(tài)中復制這段代碼叔扼。如果我們能夠只實現一次事哭,然后在所有的狀態(tài)中重用它那就更好了。
如果把這當做面向對象中的代碼而不是狀態(tài)機瓜富,那么這些狀態(tài)共享代碼的一種方式就是使用繼承鳍咱。我們可以定義一個“在地面上”的類來處理跳躍和閃避。然后与柑,站立谤辜,行走,跑步和滑動將繼承于它并添加他們各自應有的附加行為价捧。
事實證明丑念,這是一種被稱為分層狀態(tài)機的常見結構。一個狀態(tài)可以有一個狀態(tài)超類(使自己成為一個子狀態(tài))干旧。當一個事件發(fā)生時渠欺,如果子狀態(tài)沒有處理它,它會順著繼承鏈到達狀態(tài)的超類然后進行處理椎眯。換句話說挠将,它就像繼承中方法的重寫一樣胳岂。
實際上,如果我們使用 State 狀態(tài)模式來實現我們的 FSM 有限狀態(tài)機舔稀,我們可以使用類繼承來實現層次結構乳丰。為狀態(tài)超類定義一個基類:
class OnGroundState : public HeroineState
{
public:
virtual void handleInput(Heroine& heroine, Input input)
{
if (input == PRESS_B)
{
// 跳躍...
}
else if (input == PRESS_DOWN)
{
// 俯沖...
}
}
};
然后每個子狀態(tài)都繼承于它:
class DuckingState : public OnGroundState
{
public:
virtual void handleInput(Heroine& heroine, Input input)
{
if (input == RELEASE_DOWN)
{
// 站立...
}
else
{
// 不處理輸入,順著繼承鏈往上走内贮。
OnGroundState::handleInput(heroine, input);
}
}
};
當然产园,這并不是實現層次結構的唯一方式。如果你沒有使用 Gang of Four 四人幫的狀態(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)來播放射擊的動畫并不斷生成子彈和對應的視覺效果。因此瓷马,我們弄了一個 FiringState
拼到一起拴还,同時還要弄出來所有那些當射擊按鈕按下時可以過渡到這個新狀態(tài)的其他狀態(tài)。
而棘手的部分就是她在射擊后所要過渡到的狀態(tài)欧聘。她可以在站立片林,跑步,跳躍和閃避時彈出一些特效。當射擊相關的一系列動作完成后费封,她應該回到她之前正在做的動作狀態(tài)焕妙。
如果我們堅持使用這香噴噴的 FSM ,那么我們早已經忘記了她之前所處的是什么狀態(tài)了弓摘。為了跟蹤之前的狀態(tài)焚鹊,我們必須又定義一系列幾乎完全一樣的狀態(tài)——站立時射擊,邊跑邊射擊韧献,射擊時跳躍等等——這樣每個人都有一套可以正確地回到之前狀態(tài)的硬編碼轉換代碼了末患。
其實我們真正喜歡的方式是先存儲她在射擊之前所處的狀態(tài),之后再返回去調用它锤窑。同理璧针,這就是自動機理論發(fā)揮作用的地方。相關數據結構被稱為下推自動機果复。
在有限狀態(tài)機只有一個指向狀態(tài)的指針的情況下塌计,下推自動機則擁有一個狀態(tài)堆棧琴昆。在 FSM 中啦膜,當轉換到新狀態(tài)后將覆蓋前一個狀態(tài)重归。下推自動機也可以讓你這樣處理灸拍,但它同時還為你提供了兩個額外的操作:
- 您可以將新的狀態(tài)推入堆棧中茵乱。 “當前”的狀態(tài)始終處于堆棧的頂部睁枕,這樣實現轉換為新的狀態(tài)挎塌。同時它將先前的狀態(tài)壓在了新狀態(tài)的下面忌栅,而不是直接丟棄它车酣。
- 您可以將最頂層的狀態(tài)彈出堆棧。該狀態(tài)被丟棄索绪,而它下面的狀態(tài)則成為新的當前狀態(tài)湖员。
這正是我們解決射擊狀態(tài)所需要的。我們創(chuàng)建了一個單一的射擊狀態(tài)瑞驱。當處于任何其他狀態(tài)情況下娘摔,按下射擊按鈕時,我們將射擊狀態(tài)推到堆棧頂層唤反。當射擊動畫完成后凳寺,我們將該狀態(tài)彈出,同時下推自動機自動將我們的狀態(tài)轉回之前的狀態(tài)彤侍。
那么肠缨,這些東西有用嗎?
即使對狀態(tài)機的發(fā)展有這些常見的擴充盏阶,但是它們仍然非常有限晒奕。如今,人工智能游戲領域中的趨勢更傾向于使用行為樹和規(guī)劃系統(tǒng)等令人興奮的事物。如果您感興趣的是那些復雜的 AI 脑慧,那么本章內容一定能夠刺激到您的胃口魄眉。你會想要閱讀其他更多相關的書籍以滿足自己的興趣。
這并不意味著有限狀態(tài)機漾橙,下推自動機以及其他簡單的系統(tǒng)都是毫無用處的杆融。對于某類問題,它們確實是一個非常不錯的模型工具霜运。有限狀態(tài)機在以下情況非常有用:
- 你有一個實體脾歇,其行為根據某個內部狀態(tài)變化而變化。
- 該狀態(tài)可以嚴格地劃分為相對比較小的不同選項淘捡。
- 隨著時間推移藕各,實體會對一系列的輸入或者事件進行響應。
在游戲中焦除,它們最常用于 AI 激况,但它們在用戶輸入處理,菜單導航切換膘魄,文本解析乌逐,網絡協(xié)議以及其他異步行為的實現中也很常見。
三创葡、其他
以上就是主要內容浙踢,有任何建議請給我留言吧,謝謝灿渴! :sunglasses:
我的博客地址: http://liuqingwen.me 洛波,我的博客即將同步至騰訊云+社區(qū),邀請大家一同入駐: https://cloud.tencent.com/developer/support-plan?invite_code=3sg12o13bvwgc 骚露,歡迎關注我的微信公眾號: