状态机杂谈
计算机控制系统的控制程序具有有限状态自动机(FA)的特征,可以用有限状态机理论来描述。有限自动机(Finite Automata Machine)是计算机科学的重要基石,它在软件开发领域内通常被称作有限状态机(Finite State Machine),是一种应用非常广泛的软件设计模式。
状态机这个名字听上去似乎是个很不得了的东西,然而实际上它普遍应用于计算机行业,毕竟现代编程语言都离不开形式语言理论研究的成果,否则编译器都做不出来还哪来的软件开发呢。
那么,状态机究竟是什么,有什么神奇的地方?
自动机与状态机
严格来说这俩是一个东西,计算机科学的知识表示计算机控制程序具有“有限状态自动机”的特征,一般称之为“有限状态机”;而“有限自动机”是一种数学模型,因此大概可以这么说
- 有限状态机是有限自动机这种数学模型在计算机软件开发领域的实践
至于有限自动机的相关信息在数学领域应该有大量的文献资料可供参考,因此不再赘述,仅引用部分文字如下
自动机是有限状态机(FSM)的数学模型。
FSM 是给定符号输入,依据(可表达为一个表格的)转移函数“跳转”过一系列状态的一种机器。在常见的 FSM 的“Mealy”变体中,这个转移函数告诉自动机给定当前状态和当前字符的时候下一个状态是什么。
逐个读取输入中的符号,直到被完全耗尽(把它当作有一个字写在其上的磁带,通过自动机的读磁头来读取它;磁头在磁带上前行移动,一次读一个符号)。一旦输入被耗尽,自动机被称为“停止”了。
依赖自动机停止时的状态,称呼这个自动机要么是“接受”要么“拒绝”这个输入。如果停止于“接受状态”,则自动机“接受”了这个字。在另一方面,如果它停止于“拒绝状态”,则这个字被“拒绝”。自动机接受的所有字的集合被称为“这个自动机接受的语言”。
那么现在回到状态机,从自动机的定义可以看得出来,状态机的重点就在于“状态”的维护和转移。
来看一个例子,比如工厂流水线制造汽车,需要许多名工人在流水线上依次工作,现在要求设计一个工人类来完成整个汽车的装配该如何设计呢?
直截了当一点的话,我们需要的就是一个能根据当前流水线位置来执行方法的工作对象而已,那么先分析一下汽车流水线装配有哪些步骤吧。
简单一点,分成三个步骤
- 装配
- 喷漆
- 测试
那么工人类可以如下设计
class Worker {
private CarInfo car;
public CarInfo Car {
set {
car = value;
}
get {
return car;
}
}
private bool isWorking = false;
public bool IsWorking {
get {
return isWorking;
}
}
public void workOnPipeline() {
if(!car.isComAssembleDone && !isWorking) {
isWorking = true;
PipelineTool.doComAssemble(car);
return;
}
if (!car.isPaintDone && !isWorking) {
isWorking = true;
PipelineTool.doPaint(car);
return;
}
if (!car.isTestDone && !isWorking) {
isWorking = true;
PipelineTool.doTest(car);
return;
}
isWorking = false;
}
}
使用CarInfo来说明当前汽车的状况,工人根据汽车状况来进行工作。
这样的设计当然可以工作得很好,但是别忘了这三个步骤只是最简单的抽象,如果要详细划分的话,流水线上的汽车可能会有很多很多情况,某些工作要先做完才能继续装配某些零件,电路的调试要放在内部装饰之前等等。如果要将这些情况全部考虑进去的话,工人类的主方法可能会被if-else分支变成无法维护的怪物。
除此之外,对工人职责的变更也非常困难,尤其是当步骤增加的时候,每新增一个步骤就要修改与这个步骤相关的所有代码,还必须一遍又一遍地测试,其开发效率可想而知。
想要解决这种问题,状态机就是一个足够优秀的方案。那么为了设计一个合适的状态机,首先要分析工人需要的状态,很显然的三个状态是
- 离开
- 空闲
- 工作
然后对第三种状态进行细化拆分,这样能较为详细地描述整个装配生产过程,将工作状态拆分为以下的细分状态
- 装配内部零件
- 装配外壳
- 装配内饰
- 喷漆
- 测试
有了这样的状态细分,那么可以设计一个状态枚举来维护所有的状态。
public enum WorkerState {
OFFDUTY, // 离开岗位
IDLE, // 空闲
INSIDE_COM, // 装配内部零件
OUTLAYER, // 装配外壳
INSIDE_DEC, // 装配内饰
PAINT, // 喷漆
TEST // 测试
}
接着为工厂流水线设计一个抽象类,所有在流水线上工作的对象都会由该抽象类派生。
abstract class FactoryPipeline {
private WorkerState _state; // 当前状态
public WorkerState state {
get {
return _state;
}
}
// 状态转移
public void changeState(WorkerState targetState) {
_state = targetState;
// 依照状态执行分支
switch(_state) {
case WorkerState.OFFDUTY:
doOffduty();
break;
case WorkerState.IDLE:
doIdle();
break;
case WorkerState.INSIDE_COM:
doInside_com();
break;
case WorkerState.OUTLAYER:
doOutlayer();
break;
case WorkerState.INSIDE_DEC:
doInside_dec();
break;
case WorkerState.PAINT:
doPaint();
break;
case WorkerState.TEST:
doTest();
break;
default:
Console.WriteLine("Error! No Such a State!");
break;
}
}
// 所有状态分支的抽象方法
protected abstract void doOffduty();
protected abstract void doIdle();
protected abstract void doInside_com();
protected abstract void doOutlayer();
protected abstract void doInside_dec();
protected abstract void doPaint();
protected abstract void doTest();
}
接着从流水线抽象基类派生一个工人类
class NormalWorker : FactoryPipeline {
protected override void doIdle() {
Console.WriteLine("Normal Worker idle state");
}
protected override void doInside_com() {
Console.WriteLine("Normal Worker inside_com state");
}
protected override void doInside_dec() {
Console.WriteLine("Normal Worker inside_dec state");
}
protected override void doOffduty() {
Console.WriteLine("Normal Worker offduty state");
}
protected override void doOutlayer() {
Console.WriteLine("Normal Worker outlayer state");
}
protected override void doPaint() {
Console.WriteLine("Normal Worker paint state");
}
protected override void doTest() {
Console.WriteLine("Normal Worker test state");
}
}
派生类唯一的作用就是实现所有状态相关的执行方法,而不必关心这些方法是怎么调用的。
此后可以在需要的地方直接使用NormalWorker类
FactoryPipeline worker = new NormalWorker();
worker.changeState(FactoryPipeline.WorkerState.IDLE);
worker.changeState(FactoryPipeline.WorkerState.PAINT);
以上就是一个简单的状态机在编程中的实现,那么这种机制的优势何在?很显然的一个好处就是控制起来方便,工人的工作只跟自身所处的状态有关,因此根本不存在前一个设计中那么多判断条件,在某个状态之下的工人只需要执行当前状态对应的方法就可以完成工作了。
可以想见如果不使用状态机的话,那么为了判定当前情况该调用怎样的方法将会是一件非常困难的事情,前文的例子已经很能说明问题了。
在实际工作中,使用Switch-case分支来实现状态机是个比较低效的选择,大部分编程工具都会有各自的优秀机制来实现高效且便于理解和使用的状态机。
既然状态机适合于大批量的状态转移情景,那么显而易见的,游戏场景是它能大显神威的一个典型例子。
游戏本身的运行时间往往都比较长,而且内部状态在整个运行过程中是不停变化的,状态变化的速度快,频率高,次数多;尤其是对玩家操作的角色而言,玩家的操作对角色状态的影响也具有速度快和数量大的特点,因此将状态机应用到游戏角色控制中是现在很常见的一种做法。
那么可以尝试将前面模拟工厂流水线工人的状态机改为一个游戏角色响应操作的状态机,代码如下
abstract class PlayerStateMachine {
public enum PlayerState {
IDLE, // 空闲
WALK, // 行走
RUN, // 跑动
JUMP, // 跳起
FALL, // 下落
ATTACK, // 攻击
DEFENCE // 防御
}
protected PlayerState prevState; // 记录上一个状态方便返回
private PlayerState _state = PlayerState.IDLE;
public PlayerState state {
get {
return _state;
}
}
public void changeState(PlayerState targetState) {
_state = targetState;
switch(_state) {
case PlayerState.IDLE:
doIdle();
break;
case PlayerState.WALK:
doWalk();
break;
case PlayerState.RUN:
doRun();
break;
case PlayerState.JUMP:
doJump();
break;
case PlayerState.FALL:
doFall();
break;
case PlayerState.ATTACK:
doAttack();
break;
case PlayerState.DEFENCE:
doDefence();
break;
default:
Console.WriteLine("Error! No such a state!");
break;
}
prevState = _state;
}
protected abstract void doIdle();
protected abstract void doWalk();
protected abstract void doRun();
protected abstract void doJump();
protected abstract void doFall();
protected abstract void doAttack();
protected abstract void doDefence();
}
// 派生的玩家类
class Player : PlayerStateMachine {
protected override void doAttack() {
Console.WriteLine("Player attack once");
// Play animation
changeState(prevState);
}
protected override void doDefence() {
Console.WriteLine("Player defence");
// Play animation
}
protected override void doFall() {
Console.WriteLine("Player falling");
// Play animation
if(Controller.hitTheGround()) {
changeState(PlayerState.IDLE);
}
}
protected override void doIdle() {
Console.WriteLine("Player idle");
}
protected override void doJump() {
Console.WriteLine("Player jump");
// Play animation
if(!Controller.isMovingUp()) {
changeState(PlayerState.FALL);
}
}
protected override void doRun() {
Console.WriteLine("Player running");
}
protected override void doWalk() {
Console.WriteLine("Player walking");
}
}
其中的Controller表示玩家角色的控制器,用于接收操作和控制角色动画之类的功能。
然后在控制器里可以进行如下操作
PlayerStateMachine player = new Player();
public void playerControl() {
if(Input.triggerMove()) {
if(player.state == PlayerStateMachine.PlayerState.IDLE) {
if (Input.triggerRun()) {
player.changeState(PlayerStateMachine.PlayerState.RUN);
} else {
player.changeState(PlayerStateMachine.PlayerState.WALK);
}
} else if(player.state == PlayerStateMachine.PlayerState.WALK) {
if(Input.triggerRun()) {
player.changeState(PlayerStateMachine.PlayerState.RUN);
}
}
} else {
player.changeState(PlayerStateMachine.PlayerState.IDLE);
}
if(Input.triggerJump()) {
if(player.state != PlayerStateMachine.PlayerState.ATTACK && player.state != PlayerStateMachine.PlayerState.DEFENCE && player.state != PlayerStateMachine.PlayerState.FALL && player.state != PlayerStateMachine.PlayerState.JUMP) {
player.changeState(PlayerStateMachine.PlayerState.JUMP);
}
}
if(Input.triggerAttack()) {
if(player.state != PlayerStateMachine.PlayerState.ATTACK) {
player.changeState(PlayerStateMachine.PlayerState.ATTACK);
}
}
if(Input.triggerDefence()) {
if(player.state == PlayerStateMachine.PlayerState.IDLE) {
player.changeState(PlayerStateMachine.PlayerState.DEFENCE);
}
}
}
从代码中可以看得出来,状态机利用状态枚举在控制器和角色实际运行方法之间架起了桥梁,想让角色的行为根据操作的变化而变化只要相对应地修改角色的状态即可。
当然以上只是个简单的示例,并没有很大的实用性,在实际使用中的状态机往往都具有通用性,而且对状态的控制精度也更高,比如会有进入状态和退出状态的回调,方便使用者进行预处理以及给上一个操作收尾等。
更复杂的状态——状态模式
前文设计的玩家角色状态机是个很简单的实践示例,它有着一些无法回避的缺点,比如它的状态是单纯的枚举,并没有储存任何与状态相关的数据,这在一般情况下没有太大问题,但是如果玩家角色的状态变化涉及到数据操作时就可能无法适应了。
要解决这样的问题,就需要考虑一种能把数据和状态结合到一起的方案了;幸运的是,设计模式中恰好就有这么一种,就是状态模式,一种行为型模式,主要的特点是类的行为根据其所处的状态变化而变化。
状态模式:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。
在很多情况下,一个对象的行为取决于一个或多个动态变化的属性,这样的属性叫做状态,这样的对象叫做有状态的(stateful)对象,这样的对象状态是从事先定义好的一系列值中取出的。当一个这样的对象与外部事件产生互动时,其内部状态就会改变,从而使得系统的行为也随之发生变化。
这种设计模式就能解决状态机使用枚举作为状态时造成的无法关联数据的问题,对于一般的情况,状态模式的示例代码如下
// 状态接口
interface State {
void doAction(Context context); // 根据状态不同执行不同功能
}
// 加载状态
class LoadState : State {
public void doAction(Context context) {
Console.WriteLine("Loading in progress...");
context.state = this;
}
public override String ToString() {
return "Load State";
}
}
// 编译状态
class CompileState : State {
public void doAction(Context context) {
Console.WriteLine("Compiling in progress...");
context.state = this;
}
public override String ToString() {
return "Compile State";
}
}
// 连接状态
class LinkState : State {
public void doAction(Context context) {
Console.WriteLine("Linking in progress...");
context.state = this;
}
public override String ToString() {
return "Link State";
}
}
// 运行状态
class ExecuteState : State {
public void doAction(Context context) {
Console.WriteLine("Executing in progress...");
context.state = this;
}
public override String ToString() {
return "Execute State";
}
}
// 状态上下文
class Context {
private State _state; // 当前上下文状态
public State state { // 属性暴露
set {
_state = value;
}
get {
return _state;
}
}
public Context() {
_state = null;
}
}
// 测试主程序
class Program {
static void Main(string[] args) {
Context context = new Context();
State currentState = new LoadState();
currentState.doAction(context);
Console.WriteLine(context.state.ToString());
currentState = new CompileState();
currentState.doAction(context);
Console.WriteLine(context.state.ToString());
currentState = new LinkState();
currentState.doAction(context);
Console.WriteLine(context.state.ToString());
currentState = new ExecuteState();
currentState.doAction(context);
Console.WriteLine(context.state.ToString());
// --- 等待任意键
Console.ReadKey();
}
}
以上的代码模拟了一个程序从源文件加载,编译,连接到运行的过程,期间总计三次状态变化,四种状态,可以看出状态模式的优势在于上下文本身并不需要了解当前处于何种状态,只要状态改变了,同样的调用doAction所得到的结果也不一样。
这样的设计方式,状态直接管理着行为,上下文只维护状态而不关心行为实现,因此可以随意拓展行为库,只要状态枚举增加即可。
有了关于状态模式的认识便可以尝试修改此前的玩家角色状态机,让它可以将状态与数据关联起来,获得更强的功能。
// 设计一个玩家角色状态接口
interface IPlayerState {
void enterState(PlayerStateMachine player);
void updateState(PlayerStateMachine player);
void exitState(PlayerStateMachine player);
}
// 根据所需的状态设计不同的状态类
class PlayerState_IDLE : IPlayerState {
public void enterState(PlayerStateMachine player) {
Console.WriteLine("Player enter IDLE State");
}
public void exitState(PlayerStateMachine player) {
Console.WriteLine("Player exit IDLE State");
}
public void updateState(PlayerStateMachine player) {
Console.WriteLine("Player stay in IDLE State");
}
}
class PlayerState_WALK : IPlayerState {
public void enterState(PlayerStateMachine player) {
Console.WriteLine("Player enter WALK State");
}
public void exitState(PlayerStateMachine player) {
Console.WriteLine("Player exit WALK State");
}
public void updateState(PlayerStateMachine player) {
Console.WriteLine("Player stay in WALK State");
}
}
class PlayerState_RUN : IPlayerState {
private int runEnergy = 0; // 跑动中蓄力
public int RunEnergy {
get {
return runEnergy;
}
}
public int strength = 100; // 体力
public void enterState(PlayerStateMachine player) {
Console.WriteLine("Player enter RUN State");
}
public void exitState(PlayerStateMachine player) {
Console.WriteLine("Player exit RUN State");
}
public void updateState(PlayerStateMachine player) {
Console.WriteLine("Player stay in RUN State");
runEnergy++; // 跑动中按时间蓄力
strength--;
if (strength == 0) { // 体力归零,切换回走路状态
player.changeState(PlayerStateMachine.walkState);
}
}
}
// 修改玩家角色状态机抽象基类
abstract class PlayerStateMachine {
public static PlayerState_IDLE idleState = new PlayerState_IDLE();
public static PlayerState_WALK walkState = new PlayerState_WALK();
public static PlayerState_RUN runState = new PlayerState_RUN();
protected IPlayerState prevState;
private IPlayerState _state;
public IPlayerState state {
get {
return _state;
}
}
public void changeState(IPlayerState targetState) {
_state = targetState;
if(prevState != null) {
prevState.exitState(this);
}
_state.enterState(this);
prevState = _state;
}
public void gameUpdate() {
_state.updateState(this);
update();
}
protected abstract void update();
}
// 设计玩家角色实现类
class Player : PlayerStateMachine {
protected override void update() {
Console.WriteLine("Player update");
if(Input.triggerMove()) {
if(Input.triggerRun()) {
changeState(runState);
} else {
changeState(walkState);
}
} else {
changeState(idleState);
}
// 攻击操作时可以取出跑动状态对象中的蓄力数值参与计算
}
}
有了这一套设计,状态机就摆脱了自身状态无法与数据联系起来的缺陷,看到PlayerState_RUN这个状态类中存在两个字段,它们表示的就是在RUN这个状态中玩家角色所需要的数据。
注意到三个状态类都在抽象基类里有静态实例,这是静态状态的实现方式,编码简单,状态和数据的维护也很容易,只是灵活性有限,如果玩家角色的某些状态有动态数据需求,或者希望将状态机用于多个不同的角色则需要使用动态状态对象(也就是将static关键字去掉并且进行适当的初始化与维护)。
状态机与自动化
状态机看起来不过也就是一种对操作和实际运行方法的解耦,就玩家角色控制这个需求而言似乎并没有特别的优势,不使用状态机也能控制得很好,那么使用状态机的好处究竟在哪里?
最直接的好处当然是方便,只需要切换状态就能让玩家角色的行为发生改变,这样的分离使得行为不需要考虑太多额外的信息。除此之外还有另外一个好处,那就是自动化。
状态机就是自动机,因此状态机天生就有自动化的能力,而且从前面两个状态机的设计方式可以看得出来,要让状态机自动化运行起来,最重要的是让状态自动切换。这个需求放到不使用状态机而直接编写控制代码的情况下几乎不可能做到,除非模拟玩家输入。
而使用的状态机的前提下,自动化运行不过就是定义一系列的状态切换,每个切换有什么条件只要编写出来进行判断就可以了,这就是实现自动化控制的重要条件,可编程性。
举个例子,以前文提到的玩家角色状态机来说,如果要实现一个自动控制程序,大概可以如下进行编码。
class DummyState_IDLE : IPlayerState {
private AIEnvSensor sensor = AIEnvSensor.getInstance();
public DummyState_IDLE(AIEnvSensor env) {
sensor = env;
}
public void enterState(PlayerStateMachine player) {
Console.WriteLine("Player enter IDLE State");
}
public void exitState(PlayerStateMachine player) {
Console.WriteLine("Player exit IDLE State");
}
public void updateState(PlayerStateMachine player) {
if (sensor.seeTarget()) {
if(sensor.getNearestTargetDistance() < 2) {
if(sensor.senseAttack()) {
Controller.runAway();
changeState(PlayerStateMachine.dummyRunState);
}
return;
}
if (sensor.getNearestTargetDistance() > 25) {
Controller.runTo(sensor.getNearestTarget());
changeState(PlayerStateMachine.dummyRunState);
} else {
Controller.moveTo(sensor.getNearestTarget());
changeState(PlayerStateMachine.dummyWalkState);
}
}
}
}
class DummyState_WALK : IPlayerState {
private AIEnvSensor sensor = AIEnvSensor.getInstance();
public DummyState_WALK(AIEnvSensor env) {
sensor = env;
}
public void enterState(PlayerStateMachine player) {
Console.WriteLine("Player enter WALK State");
}
public void exitState(PlayerStateMachine player) {
Console.WriteLine("Player exit WALK State");
}
public void updateState(PlayerStateMachine player) {
if (sensor.seeTarget()) {
if(sensor.getNearestTargetDistance() < 2) {
if(sensor.senseAttack()) {
Controller.runAway();
changeState(PlayerStateMachine.dummyRunState);
}
return;
}
if (sensor.getNearestTargetDistance() < 2) {
Controller.stopMoving();
changeState(PlayerStateMachine.dummyIdleState);
}
}
}
}
class DummyState_RUN : IPlayerState {
private AIEnvSensor sensor = AIEnvSensor.getInstance();
private int runEnergy = 0; // 跑动中蓄力
public int RunEnergy {
get {
return runEnergy;
}
}
public int strength = 100; // 体力
public DummyState_RUN(AIEnvSensor env) {
sensor = env;
}
public void enterState(PlayerStateMachine player) {
Console.WriteLine("Player enter RUN State");
}
public void exitState(PlayerStateMachine player) {
Console.WriteLine("Player exit RUN State");
}
public void updateState(PlayerStateMachine player) {
runEnergy++; // 跑动中按时间蓄力
strength--;
if (strength == 0) { // 体力归零,切换回走路状态
changeState(PlayerStateMachine.dummyWalkState);
}
if (sensor.seeTarget() && sensor.getNearestTargetDistance() < 25) {
Controller.moveTo(sensor.getNearestTarget());
changeState(PlayerStateMachine.dummyWalkState);
}
}
}
class DummyPlayer : PlayerStateMachine {
protected override void update() {
//
}
}
就如代码所示,同样从PlayerStateMachine派生,使用Controller控制行动,添加了AIEnvSensor作为对周边环境的感应设备。在IDLE状态里集中了多种行为判断,比如根据是否看到目标和计算与目标之间的距离来决定是走到目标附近还是跑过去,走动和跑动过程中一旦发现敌人并且距离小于阈值就会改变行动。
虽然很简陋,但这样的DummyPlayer就是一个拥有自动运行能力的角色,只要将各种状态的处理以及转移条件完善起来,角色也会具有更加灵活和好用的智能。
有了这个例子,现在可以反过来尝试着从理论上分析一下状态机与自动运行之间的关系。就如前文提到的,状态机本质上就是自动机,因此它有着和自动机类似的特性和定义,对于一个有限自动机而言,其组成部分有五个,分别是
- 非空有穷状态集合(就是状态机里的状态枚举或者状态类)
- 符号的有限集合(对于玩家角色而言,这个指的就是一连串的操作输入,而对于Dummy而言,这个集合是一系列的环境变量判定结果)
- 状态转移函数(在示例代码中就是所有changeState调用时的条件与转移的目标状态之间的映射集合)
- 开始状态(在示例代码中就是IDLE状态)
- 终止状态(就角色而言会是在IDLE状态结束,因为停止操作之后都会回到IDLE状态)
根据这个定义,可以推断出所谓的自动运行实际上是自动生成一连串的输入符号。对于玩家而言,只要他使用控制器进行操作,那么对于玩家角色的状态机来说就是一个输入符号,状态机就会根据这个符号去找到状态转移函数并执行状态转移,玩家输入不停止,状态转移就不会停止。
而对于Dummy来说,输入符号是从AIEnvSensor获得的数据,比如是否看见目标,与目标之间的距离,是否存在障碍等等;若能初始数据和所有数据变化的过程都完全一致则两个Dummy将会有相同的行为表现。
如果希望能最大限度地控制Dummy的行动,那么可以设计另外一种类型的Dummy,让它不再把环境数据当成输入,而是以某种指令集或者脚本语言作为输入,状态机根据这样的输入来进行状态转移,这也就是所谓的“脚本编程AI”。
更多的状态机
简单的状态机或许能满足许多需求,但针对有些特别的需求,简单的状态机不足以完全而且优雅地实现功能,这种时候就需要求助于一些特别的状态机了。
从数学角度上来说,状态机,或者说有限自动机,本质上就是一种图灵机,后者是理论中存在的机器,能解决一切计算领域的问题。而同样作为图灵机的子集,包括并发状态机,层次状态机,下推自动机在内的数种解决方案都适合于解决一般状态机无能为力的问题。
并发状态机
层次状态机
下推自动机
状态机相关的东西有很多很多值得关注和学习的地方,无论是想更好地对玩家角色进行控制还是希望开发更智能的AI,状态机都会是相当不错的选择。