該文章總結自人民郵電出版社《游戲編程模式》一書
0、開篇
狀態機,全稱有限狀態機,其靈感來源于圖靈機,
將一系列資料輸入輸入圖靈機中,輸出資料會隨著圖靈機內部開關狀態改變,使得同一份資料在不同圖靈機中會獲得不同的結果,
將這種思維抽象成代碼,可以極大程度的提高代碼可讀性,但是會降低你在專案中的不可替代性(doge),
1、沒有狀態機時
試想,我們正在開發一款橫版動作游戲,需要為主角開發一系列的功能,
策劃:角色不應該在防御的時候攻擊(不考慮什么防御反擊技能)
沒有什么是一個if解決不了的,
策劃:在防御時應該是個木樁,不能奔跑
哦,還有攻擊、跳躍時候也是,蹲伏的話就慢慢移動,
策劃:我覺得對于一個動作游戲來說,這些功能屬實太單一了,來點熱武器怎么樣,
void Update()
{
if(GetKey() == Key::Attack)
{
//防御時不可攻擊
if(!bDefense)
{// 執行一次攻擊
Attack();
}
}else if(GetKey() == Key::Run)
{
bool canRun = true;
canRun &= !bDefine;//防御中
canRun &= !bAttacking;//揮刀中
if(canRun))
{
if(bSquating)
{
run(RUN_SPEED);
}else
{
run(SQUAT_SPEED);
}
}
}else if(GetKey() == Key::OpenFire)
{
bool canOpenFire = true;
canOpenFire &= !bDefine;
canOpenFire &= !bAttacking;
canOpenFire &= !bReloading;
canOpenFire &= (iAmmoCount != 0);
if(canOpenFire)
{
openFile();
}
}else if(其他功能)
{
...
}
}
你罵罵咧咧的完成了任務
策劃:唉,再來個可使用道具如何,比如煙霧彈或血藥
你回頭看了看代碼說道:這尼瑪誰寫的
2、簡單狀態機
聰明的你開始思考,如何將這一個個的功能用面向物件的方式封裝起來,
從分析問題開始,大量的標志位以極其抽象的方式表達出這個角色可以做的事與不能做的事,
而這些標志位只有處于某些特定組合時才會有意義,例如:
角色在受擊時,不應該擁有奔跑對應的功能(別問,問就是策劃需求),
角色在奔跑時,應該能夠射擊,但是不能防御,
···
一旦功能代碼中出現了這種情況,就應該考慮
使用一個列舉值來代替大量的標志位,
//255個狀態,夠用了
enum EState : uint8
{
idle,//靜止
walk,//走動
run, //奔跑
define,//防御
...
}
void Update()
{
if(GetKey() == Key::Attack)
{
//防御時不可攻擊
if(State != define)
{// 執行一次攻擊
Attack();
}
}else if(GetKey() == Key::OpenFire)
{
if(state != define
|| state != attack
|| state != reloading)
{
if(iAmmoCount != 0)
{
openFile();
}
}
}else if(...)
{
...
}
}
這樣做的好處有很多:
- 省下了許多記憶體空間,如果狀態多起來,每個狀態標志位都會占用1個bool值空間(可以優化,但依舊不如一個列舉值來的簡單)
- 減少代碼量 == 提高性能
- 免去了出現無意義標志位的情況,減少了出bug的概率
- 降低了代碼閱讀成本,原本5行甚至更多的標志位更換為僅1行的列舉值,理解成本大大降低
省空間省時間提高代碼可讀性的東西,有什么理由不用?
隨著開發的深入,可以發現,各個狀態間有些共同點,這不由得想到了面向物件的三大特性之一 多型
將各個狀態間的共同點抽象出來,組成基類,然后由每個狀態子類去實作自己的功能,
// 狀態基類
class StateBase
{
//獲得這個狀態
virtual EState getState() = 0;
//處理按鍵
virtual void prossesKey() = 0;
}
class RunState : public StateBase
{
virtual EState getState() overried
{
return EState::run;
}
virtual void prossesKey(Key key) overried
{
if(key == Key::Attack)
{
//奔跑狀態下攻擊可以變為特殊攻擊
character->RunAttack();
}
}else if(key == Key::)
{
}else ...//可以按照策劃的腦洞整活
}
void Update()
{
currentState->prossesKey(GetKey());
}
到此,狀態的封裝基本完成了,從判斷狀態實作事件轉發,變為讀取虛函式地址實作轉發,
策劃突然出現,說道:我們需要在站立和攻擊之間添加一個過渡動作,
彳亍
// 狀態基類
class StateBase
{
//獲得這個狀態
virtual EState getState() = 0;
//處理按鍵
virtual void prossesKey() = 0;
//進入狀態事件
virtual void onEnter() = 0;
//退出狀態事件
virtual void onExit() = 0;
}
void switchState(StateBase* newStat)
{
currentState->onExit();
// 偽代碼,不考慮垃圾回收
currentState = newStat;
newStat->onEnter();
}
這樣子類只需要重寫一下進入和退出事件就可以實作事件間過渡,
到最后了,簡單封裝一下
class IStateBase
{
public:
//獲取對應狀態機
StateMechineBase* mechine = nullptr;
//獲得這個狀態
virtual EState getState() = 0;
//更新
virtual void update() = 0;
//進入狀態事件
virtual void onEnter() = 0;
//退出狀態事件
virtual void onExit() = 0;
}
class StateMechineBase
{
public:
virtual void switchState(StateBase* newStat)
{
currentState->onExit();
delete currentState;
currentState = newStat;
currentState->mechine = this;
newStat->onEnter();
}
virtual void update()
{
currentState->update();
}
StateBase* currentState = nullptr;
}
一個簡單的狀態機模式完成了,
2、并發狀態機
策劃又來整活了:我希望玩家角色可以在奔跑或跳躍時射擊,在揮劍時使用閃避,
我們已經有了奔跑和開槍狀態,但是當兩者組合時,我們應該是允許在奔跑時處理設計指令,還是在射擊時處理奔跑指令,亦或者再寫個移動設計狀態,
本質上,這些方法都能實作功能,但是還是需要一個統一處理這些情況的方法,
沒有什么是一個狀態機解決不了的,如果有,那就用兩個,
StateMechineBase* moveState;
StateMechineBase* actionState;
void update()
{
moveState->update();
actionState->update();
}
子狀態機應該受到主狀態機管理,形成組合模式,
其實這種方式需要更多的主次狀態機間協調,是的主狀態機接收到輸入后子狀態機不再處理,
3、層次狀態機
不難發現,雖然我們狀態眾多,但是有一部分仍有相似點,如:
站立,設計,走動,奔跑都可以進行跳躍,此時,輪到了面向物件特性之一:繼承出場了,
class OnGroundState : public StateBase
{
virtual void update()
{
if(GetKey() == Key::Jump)
{
Jump();
}
}
}
class RunState : public OnGroundState
{
virtual void update()
{
if(...)
{
...
}else
{
OnGroundState::update();
}
}
}
通過繼承,實作了代碼復用,繼承了OnGroundState的類都擁有了跳躍能力,在子類能夠處理時,父類會被覆寫掉,以滿足一些特殊情況(如果這種情況過多,則需要重新組織設計結構)
3.5、責任堆疊狀態機
即用堆疊的方式代替繼承,自上而下的遍歷堆疊,當有狀態能夠處理他時,停止遍歷,若遍歷完成后仍找不到則丟棄該事件,代碼相對復雜且使用范圍不多,不多做贅述
4、自動下推狀態機
在寫完角色功能后,策劃表示有一個新的需求,
角色擁有一個背包,按下B鍵后打開背包面板,使用方向鍵在背包中選擇道具,按下J鍵確認選擇,彈出操作串列,選擇合成按鈕后會彈出下一個背包面板,在背包中選擇一個道具與之合成,上述步驟的任意一步中點擊取消時,都會回到上一個選單狀態(如,選擇合成按鈕后,按下K鍵取消選擇,UI會回到道具操作串列,)
試著使用狀態機實作這個功能,
class BagPannelState : public State
{
void update()
{
if(GetKey() == Key::Confirm)
{
stateMechine->switchTo(ActionListState);
}...
}
}
class ActionListState : public State
{
void update()
{
if(GetKey() == Key::Cancel)
{
stateMechine->switchTo(BagPannelState);
}...
}
}
這樣做當然可以完成功能,但是日后維護他時,會非常痛苦,因為你需要手動的去控制它的上一層狀態,當這個狀態有了新的進入方式時,會引發一些奇怪的問題,
而這份痛苦的根源是來自當前的狀態機不會記錄上一個狀態,為了解決這一問題,故引入下推狀態機這一概念,
下推狀態機,是指通過堆疊,記錄狀態的變化,當進入新的狀態時,老的狀態會保留在堆疊中,當新的狀態退出時,只需要將新狀態出堆疊,而每一次呼叫update,只需要對堆疊頂的狀態進行更新即可,
class StateMechine
{
Stack<State*> stack;
void update()
{
//僅需要更新堆疊頂狀態
stack.top().update();
}
void switchToNewState(State* state)
{
stack.top()->onLeave();
stack.push(state);
state->onEnter();
}
void returnToLastState()
{
stack->onLeave();
stack.pop();
stack.top()->onEnter();
}
}
最后
本篇文章從最基本的狀態機開始,為了配合不同的使用場景,進行不同的優化,
相比于學會如何使用狀態機模式,更重要的是學會利用他們的特長,在合適的場景中發光發熱,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/523953.html
標籤:其他
