Unity状态机详解

状态机(State Machine)全称为有限状态自动机,是一种计算机科学和工程领域中的建模工具,也是一种用于描述对象的不同状态以及状态之间转换的数学模型。状态机在各种应用中都有广泛的应用,包括软件开发、游戏设计、自动控制系统等。在实际编程中,我们将这种数学模型的运用称为状态机设计模式,简称状态模式。在Unity引擎中运用最明显的例子就是Animator动画器。今天,我们就来详细介绍一下状态机。

什么是状态机

状态机是一种抽象模型,通常用于描述对象、系统或程序的行为方式。它包括一组可能的状态,每个状态代表对象或系统的一种特定情况或条件,以及规定了在给定条件下如何从一个状态切换到另一个状态的转换规则。这些规则通常依赖于外部输入事件或信号,即状态机会响应这些事件触发状态之间的转换。此外,状态机还可以定义在状态转换时执行的动作或任务,例如状态改变时发送消息、执行计算或触发其他操作。
简单来说状态机就是用于描述事物不同状态之间如何互相转换的模型。

为什么要使用状态机

在Unity游戏开发中,使用状态机是一种强大的方法,用于管理游戏对象的行为和状态。接下来让我们举一个简单的例子来说明为什么要使用状态机。
当你想制作一个敌人角色,可能有如下一些状态,例如:

  • 徘徊状态:敌人在一个区域内徘徊,不主动攻击玩家。
  • 追击状态:敌人发现玩家后,开始主动追击玩家。
  • 攻击状态:敌人接近玩家一定距离后,开始攻击玩家。
  • 死亡状态:敌人被击败后,进行死亡动画并禁用。

如果没有状态机,会产生什么问题呢?

  • 大量的if-else语句
    我们可能需要在 update 中编写大量的 if-else 语句编写大量的 if-else 语句或开关语句来检查敌人的状态并执行相应的行为。例如,为了处理敌人的不同状态(徘徊、追击、攻击、死亡),往往需要创建大量的条件判断。这会导致代码的可读性下降,难以理解和维护。
  • 状态之间的影响和纠缠
    在没有状态机的情况下,不同状态之间的切换和影响可能会变得混乱。例如,当敌人从追击状态切换到攻击状态时,需要确保适当地停止追击、开始攻击,并且不会触发错误的行为。这种状态之间的相互作用可能会导致难以调试的问题,因为需要在多个位置管理状态切换和影响。
  • 功能难以拓展
    如果我们希望添加新的状态或更多的行为,而没有使用状态机,那么每次增加新功能都会变得繁琐。我们需要修改和添加更多的 if-else 语句,同时需要小心确保不会影响现有的行为,这会导致代码的膨胀和难以维护。
  • 可读性差,不利于团队合作
    在缺少状态机时,代码将变得难以理解,因为所有的状态判断和行为都集中在一起,没有明确的结构。这降低了代码的可读性,使团队合作和维护变得更加复杂。

总的来说,没有状态机的情况下,我们将面临编写冗长、复杂、难以扩展和难以维护的代码。状态机的使用可以有效地解决这些问题,提高代码的可读性、可维护性,同时使添加新状态和行为变得更加容易。状态机提供了一种清晰的结构,使游戏对象的状态管理更加直观和可控。

状态机的组成部分

状态机通常由以下几个基本组成部分构成:

  • 状态:状态是状态机的核心组成部分,代表了对象、系统或程序可能处于的不同情况或条件。每个状态通常有一个唯一的标识符,并与特定的行为或属性相关联,描述对象或系统的不同情况。
  • 转换:转换是描述状态之间的切换规则的一组规则。规定了状态之间的切换条件和规则。转换描述了当特定事件发生时,从一个状态切换到另一个状态的方式。转换规则通常包括定义触发条件以及指定目标状态,转换是状态之间的过渡。
  • 事件:事件是触发状态之间切换的信号或条件。事件可以是外部输入,如玩家的操作,也可以是内部触发条件,如定时事件或特定的系统条件。
  • 动作:在状态机的特定状态切换中,可以定义执行的动作或任务。这些动作可以包括更新状态、发送消息、执行计算、启动动画或触发其他操作。动作通常与状态转换相关联,以确保状态之间的平稳过渡。
  • 初始状态:初始状态定义了状态机的初始状态,即对象、系统或程序在启动时所处的状态。状态机通常从初始状态开始,然后根据触发条件逐渐切换到其他状态。
  • 终止状态:终止状态表示状态机的结束状态,当状态机达到终止状态时,它可能会执行最终的清理操作或结束自身。终止状态并不是每个状态机都必须包含的元素,但在某些情况下它们是有用的。
  • 上下文数据:状态机可能需要访问和维护一些上下文数据,以在不同状态之间共享信息。这些数据可以用于存储对象属性、计数、计时器等,以影响状态机的行为。

让我们以操控一个游戏角色进行跑和跳的情境来解释状态机的各个关键组成部分:

  • 状态:在这个例子中,可能存在以下两个状态。①”跑”状态:当玩家控制角色按下移动键时,角色处于跑步状态;②”跳”状态:当玩家按下跳跃键时,角色处于跳跃状态。
  • 转换:①从”跑”状态到”跳”状态的转换:当玩家按下跳跃键(事件)时,发生状态从”跑”到”跳”的转换;②从”跳”状态到”跑”状态的转换:当角色着陆(事件)后,状态从”跳”转回到”跑”。
  • 事件:在这个情境中,事件可以是①”跳跃键按下”:触发从”跑”到”跳”的状态转换;②”角色着陆”:触发从”跳”到”跑”的状态转换。
  • 动作:①在”跳”状态中,动作可能包括播放跳跃动画、应用重力、移动角色位置;②在”跑”状态中,动作可能包括播放跑步动画、移动角色位置。
  • 初始状态:初始状态指定了状态机的启动状态。在这个例子中,可能的初始状态是”跑”状态。当游戏开始时,角色默认处于跑状态。
  • 终止状态:在某些情境下,可能有终止状态,但在这个特定情境中,终止状态不是必需的。
  • 上下文数据:例如,角色的速度、跳跃高度等属性可以被多个状态访问和修改,以影响角色的行为。

这些组成部分共同构成了状态机,用于管理游戏角色的不同行为状态和状态之间的切换。在这个示例中,状态机使游戏开发人员能够清晰地定义和控制角色的行为,确保跑和跳的行为按预期进行。

一个基于状态机的demo示例

接下来我们使用的示例同样来自于B站阿严老师,B站主页指路https://space.bilibili.com/27164588。demo内容是基于状态机模式,控制 unity 酱进行一些简单的跑跳行为。

不使用状态机的普通版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerController : MonoBehaviour
{
Animator animator;
void Awake()
{
animator = GetComponentInChildren<Animator>();
}

void Update()
{
// 如果按住A键或D键
if(Keyboard.current.aKey.isPressed || Keyboard.current.dKey.isPressed)
{
animator.Play("Run");
}
else // 松开按键时
{
animator.Play("Idle");
}
}
}

随着状态的增多,我们需要在 Update 里写越来越多的if-else,而且某些状态还可能会相互制约,例如只有在地面上时,我们才能跳跃或者移动;只有在跑步状态时,我们松开按键才能回到空闲状态。于是我们只能声明一个个的 bool 值,来判断当前属于什么状态,很容易被一大堆互相限制的逻辑搞晕。

使用状态机的版本

Base

IState接口

通过这个接口,我们定义一个状态必有的几种方法,即状态的进入,退出,逻辑更新,物理更新(我们通过刚体模拟物体运动)。

1
2
3
4
5
6
7
8
9
10
11
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public interface IState
{
void Enter();
void Exit();
void LogicUpdate();
void PhysicUpdate();
}
StateMachineBase

定义状态机基类。状态机类的功能主要有两点:①持有所有的状态类,并对它们进行管理和切换;②负责进行当前状态的更新。我们使用字典来映射到具体的状态上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class StateMachineBase : MonoBehaviour
{
//当前状态
IState currentState;
//设置一个状态表,可以根据state的Type获取到对应的state
protected Dictionary<System.Type, IState> stateTable;

void Update()
{
//进行当前状态的逻辑更新;
currentState.LogicUpdate();
}

void FixedUpdate()
{
//进行当前状态的物理更新;
currentState.PhysicUpdate();
}

protected void SwitchOn(IState newState)
{
//当前状态的启动,我们将在这个类的子类中调用这个方法,因此添加protected修饰符
currentState = newState;
currentState.Enter();
}

public void SwitchState(IState newState)
{
//当前状态的切换,和启动函数不同的是,我们进行状态切换时,需要先退出当前状态
currentState.Exit();
SwitchOn(newState);
}

//传入字典key值的重载
public void SwitchState(System.Type newState)
{
SwitchState(stateTable[newState]);
}
}

Player

PlayerState玩家状态类

定义玩家状态类,继承 IState 接口,同时继承 ScriptableObject,让我们更方便的管理玩家的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.PlayerLoop;

public class PlayerState : ScriptableObject, IState
{
protected Animator animator; //执行动画切换
protected PlayerStateMachine stateMachine; //执行状态切换

public void Initialize(Animator animator, PlayerStateMachine stateMachine)
{
//定义一个用来接收这两个变量的公有方法
this.animator = animator;
this.stateMachine = stateMachine;
}
// 方法可以先留空,在子类中重写
public virtual void Enter()
{

}

public virtual void Exit()
{

}

public virtual void LogicUpdate()
{

}

public virtual void PhysicUpdate()
{

}
}
玩家状态机类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerStateMachine : StateMachineBase
{
//为了避免每次都要通过代码添加新状态,我们使用一个数组来存储所有的状态类,可以在Inspector面板直接管理
[SerializeField] PlayerState[] playerStates;
Animator animator;

void Awake()
{
animator = GetComponentInChildren<Animator>();
stateTable = new Dictionary<System.Type, IState>(playerStates.Length);
//状态的初始化
foreach(PlayerState playerState in playerStates)
{
playerState.Initialize(animator, this);
stateTable.Add(playerState.GetType(), playerState);
}
}

void Start()
{
//以idle作为初始状态
SwitchOn(stateTable[typeof(PlayerState_Idle)]);
}
}
Idle状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

[CreateAssetMenu(menuName = "Data/StateMachine/PlayerState/Idle", fileName = "PlayerState_Idle")]
public class PlayerState_Idle : PlayerState
{
public override void Enter()
{
animator.Play("Idle");
}

public override void LogicUpdate()
{
if(Keyboard.current.aKey.isPressed || Keyboard.current.dKey.isPressed)
{
stateMachine.SwitchState(typeof(PlayerState_Run));
}
}
}
Run状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

[CreateAssetMenu(menuName = "Data/StateMachine/PlayerState/Run", fileName = "PlayerState_Run")]
public class PlayerState_Run : PlayerState
{
public override void Enter()
{
animator.Play("Run");
}

public override void LogicUpdate()
{
if(!(Keyboard.current.aKey.isPressed || Keyboard.current.dKey.isPressed))
{
stateMachine.SwitchState(typeof(PlayerState_Idle));
}
}
}

状态机的优缺点

使用状态机在游戏开发和软件设计中具有多个优点:

  • 结构清晰:状态机提供了清晰的结构,使代码易于组织和理解。每个状态和状态之间的转换都可以明确定义,使代码更易于阅读和维护。
  • 易于扩展:添加新状态或行为时,状态机使修改代码更加简单和可控。你只需创建新状态并定义状态之间的转换规则,而不必修改现有的大量代码。
  • 降低复杂性和耦合性:状态机将复杂性分解为更小的、可管理的部分。每个状态只关注自己的行为,这减少了状态之间的相互干扰和错误,我们不需要考虑状态间的相互制约,只需要去思考当前状态如何切换到下一个状态,降低了耦合性。
  • 可重用性:状态机中的状态和转换规则通常可以在不同对象或角色之间重复使用。这提高了代码的可重用性,减少了冗余。
  • 容易维护:当需要调整游戏对象的行为时,只需关注状态机的定义,而不必搜索整个代码库来查找和修改相关代码。
  • 增加可读性:状态机提供了一种自然的方式来描述对象的行为。通过查看状态机,开发人员和团队可以快速了解对象可能的状态和行为。
  • 容易调试:由于状态机的状态切换和行为是明确定义的,因此在调试和查找问题时更容易定位和解决错误。
  • 可视化工具:许多游戏引擎,包括Unity,提供了可视化工具来创建和管理状态机,使开发人员能够直观地定义状态和转换,而无需编写大量代码。

同时,使用状态机也有一些缺点:

  • 针对每一个状态,我们都需要创建一个类来存储数据,造成了文件数量增多,所以使用状态机对文件规范整理的要求很高。
  • 代码重复。

总之,使用状态机有助于简化代码、提高可维护性、降低复杂性,同时提供更清晰的结构,这对于游戏开发和软件设计都是非常有益的。状态机是一种强大的工具,可用于管理对象、角色或系统的不同状态和行为,帮助开发人员更好地组织和控制复杂性。