该文章总结自人民邮电出版社《游戏编程模式》一书
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();
}
}
最后
本篇文章从最基本的状态机开始,为了配合不同的使用场景,进行不同的优化。
相比于学会如何使用状态机模式,更重要的是学会利用他们的特长,在合适的场景中发光发热。