本网站基于Next.js开发

有限状态机

Cover Image for 有限状态机
QinMo
QinMo

什么是状态机

有限状态机这个称呼听起来就像存在一个“亲兄弟”——无限状态机,所以它们应当有个共同的“父亲”——状态机。

定义上状态机是一种抽象的计算模型,用于设计具有离散状态的系统。状态机通过状态和状态之间的转换规则来描述系统的行为。

离散状态说明模型中存在能够可数的区分形态标识,代码中通常使用字段标识所处状态,例如角色的运动模型存在 "idle, running, walking..."等等的动画状态,不同的状态下角色执行不同的模型动画。idle 状态下的角色根据玩家的前进操作转变到 walking 状态并执行行走动画,这便是状态直接的转换过程。此过程切换中玩家是否按下“前进键”的条件判断便是状态切换过程中的转换规则。

根据这样的方式我们使用大量条件判断来处理不同的状态转换,出现类似如下的代码

class Person
{
	public string state;

	public void Update()
	{
		if (Input.GetKeyDown(KeyCode.W))
		{
			state = "Walking";
		}//....
	}
	public void Execut()
	{
		if (state == "Idel")
		{
			// 空闲状态逻辑
		}else if (state == "Walking")
		{
			// 行走状态逻辑
		}else if (state == "Running")
		{
			//跑步状态逻辑
		}
	}
}

一旦我们开始向该类添加越来越多的状态和状态相关的行为,添加例如“按下空格,玩家跳跃,条约后不能立即奔跑,跳跃时不能攻击”,你发现基于条件判断的状态机最大弱点就凸显出来了。对的,你没看错这就是最简易的状态机,为了不易混淆我们将其叫做条件状态机,更精确点叫做条件有限状态机(😁本人简化叫法,或许根本搜索不到😁)。大量的文章将这种代码划分到状态机的对立面,但仔细观察它完全符合状态机的定义,而市面上那种用于解决顽疾的代码救星,实际上是我们下面要讲的基于状态模式的状态机。

状态模式

首先我们先熟悉一个常见的设计模式——状态模式(State Patterns),虽然它们并不一样,但是概念上密切相关。

状态是一种行为设计模式,允许对象在其内部状态发生变化时改变其行为。看起来好像对象改变了它的类。

状态模式建议您为对象的所有可能状态创建新类,并将所有特定于状态的行为提取到这些类中。也就是说状态将是一个类,状态机存储这些状态对象引用,原始的执行逻辑不在状态机中执行,执行逻辑代码也交由状态类管理。

要将上下文转换到另一个状态,请将活动状态对象替换为表示该新状态的另一个对象。仅当所有状态类都遵循相同的接口并且上下文本身通过该接口与这些对象一起工作时,这才是可能的。

定义状态需要实现的接口,包含进入状态、退出状态和更新状态的方法。State 接口声明特定于状态的方法。这些方法应该对所有具体状态都有意义。

public interface IFsmState  
{  
    void OnInt();  
    void OnEnter();  
    void OnUpdate();  
    void OnLeave();  
    void ChangeState();  
}

状态机理解为状态管理类,管理有限的状态集合。内部构建程序循环时的状态更新 Update,以及供状态类使用的状态变更方法。没有抽象的 Fsm 类意味着状态的改变规则逻辑不在管理器内部,而是交由状态类 OnUpdate 实现。虽然实现 Fsm 编写了不少代码,但从长远角度看状态切换逻辑变得精简,减少的杂糅的逻辑代码有助于项目的维护。

public class Fsm
{
	private IFsmState[] fsmStates; //有限状态集合
	private IFsmState currentState; //当前执行状态

	public Fsm()
	{
		fsmStates = Array.Empty<IFsmState>();
	}
	
	public Fsm(params IFsmState[] states)
	{
		fsmStates = states;
	}

	public void AddOrCreateState(IFsmState state)
	{
		if (state == null)
		{
			throw new Exception("new status unknown");
		}
		IFsmState[] newStats = new IFsmState[this.fsmStates.Length + 1];
		Array.Copy(this.fsmStates,newStats,this.fsmStates.Length);
		newStats[newStats.Length] = state;
		state.OnInt();
		this.fsmStates = newStats;
	}
	
	public bool IsRunning
	{
		get => this.currentState != null;
	}

	public IFsmState CurrentState
	{
		get => this.currentState;
	}

	public void Update()
	{
		if (this.CurrentState == null && this.fsmStates is { Length: > 0 })
		{
			this.currentState = this.fsmStates[0];
			this.currentState?.OnEnter();
		}

		this.currentState?.OnUpdate();
	}

	public void ChangeState<T>() where T : class, IFsmState
	{
		this.currentState?.OnLeave();
		foreach (var fsmState in this.fsmStates)
		{
			if (fsmState.GetType() == typeof(T))
			{
				this.currentState = fsmState;
				this.currentState?.OnEnter();
			}
		}

		if (this.currentState == null)
		{
			this.Close();
		}
	}

	public void Close()
	{
		this.currentState = null;
		this.fsmStates = Array.Empty<IFsmState>();
	}
}

是的,将之前角色动画的案例用现在的状态模式状态机实现将会变得简单,但是我们忽略了上下文关系,无法让所有状态知晓外界信息。所以一个更加完善的接口应该为状态赋予设置上下文的方法。

public interface IFsmState  
{  
    void OnInt();  
    void OnEnter();  
    void OnUpdate();  
    void OnLeave();  
    void ChangeState();  
    void SetContext<T>(T context);
}

这里的上下文内容是抽象的表示,不确定用户类型,常见的方式是传递状态管理器 FsmHandle,并通过替换链接到上下文的状态对象来执行实际的状态转换。

进阶

文本主谈游戏开发中的状态机应用。状态机因其具备的优势在开发中应用广泛。例如行为控制、流程控制、对象管理等。所以在一个项目中普遍会和多个不同应用于不同模块的状态机打交道,为此我们希望构造抽象且完备的状态机基类以供其他模块使用。灵活性和可扩展性必不可少。

❓好吧,我们明确了一个大胆目标,但该如何着手完成这项工作呢?或者设计思路是什么?

面向接口设计

既然状态机要被多数模块使用,模块之间遵守独立原则,那么各自所属的状态机之间也应当是独立的。基于状态机构建的功能模块只维护独立特定的状态机对象。显而易见的首要工作是将状态机的定义和实现与具体的游戏模块解耦, 使状态机成为一个独立的可复用组件。特定意味着状态机类型具备可扩展性。所以状态机的定义应该尽可能通用和抽象,不应该与特定的游戏模块耦合。 于是,让我们将 Fsm 重写为 FsmBase 抽象基类,将外部使用的通用功能归于 IFsm 之下。就当做是将这状态机打造成智慧之树,根深叶茂,万物皆可托其荫而存。

完善后的状态接口定义为如下

public interface IFsmState
{
	public void OnInit();
	public void OnEnter(IFsm content);
	public void OnUpdate(float elapseSeconds, float realElapseSeconds);
	public void OnLeave();
	public void OnDestroy();
	public void ChangeState<TState>() where TState:IFsmState;
	public void ChangeState(Type stateType);
}

其中包含全新的生存周期函数,状态进入时传递上下文,以及更好更多的状态改变方法定义。但这样的方式违背了封装原则,现在这些方法变得全局可见,甚至可供公开调用。状态类的方法调用只会存在于其运行周期,应当合理使用访问修饰符隐藏对象的内部细节。所以 IFsmState 被遗弃,转而取代它的是 FsmStateBase

public abstract class FsmStateBase
{
	protected IFsm m_fsmHandle;
	protected internal FsmStateBase(IFsm handle)
	{
		this.m_fsmHandle = handle;
	}

	protected abstract void OnInit();

	protected internal abstract void OnEnter();

	protected internal abstract void OnUpdate(float elapseSeconds, float realElapseSeconds);

	protected internal abstract void OnLeave();

	protected internal virtual void OnDestroy()
	{
		this.m_fsmHandle = null;
	}

	protected void ChangeState<TState>() where TState : FsmStateBase
	{
		if (this.m_fsmHandle == null)
		{
			throw new Exception("Fsm is invalid");
		}
		this.m_fsmHandle.ChangeState<TState>();
	}

	protected void ChangeState(Type stateType)
	{
		if (this.m_fsmHandle == null)
		{
			throw new Exception("Fsm is invalid");
		}
		this.m_fsmHandle.ChangeState(stateType);
	}
}

protected internal 修饰符意味着:被修饰的成员可以在当前类、子类,以及同一程序集内的任何其他类型中访问。但是在程序集之外的代码中,该成员是完全不可见的。结合我们当前情况,其作用保证程序集内部 FsmBase 和程序集外部 FsmStateBase 的派生类访问。

Fsm 内的公共方法仍由 IFsm 接口声明,接口定义了一个功能完整的有限状态机,包括状态机的启动、停止、状态切换、状态获取等核心功能,同时也考虑了状态机更新和关闭的需求。

  • 状态机基本信息:提供了获取状态机名称、状态数量、运行状态的属性,用于获取状态机的基本信息。
  • 状态机启动和停止:定义了StartStop方法,用于启动和停止状态机的运行。
  • 状态检查和获取:提供了HasStateGetState方法,用于检查是否存在某个状态,以及获取指定状态的实例。还有一个GetAllStates方法可以获取状态机中所有状态。
  • 状态切换:定义了ChangeState方法,用于在状态机运行时切换到指定的新状态。
  • 状态机更新:有一个Update方法,用于在每个逻辑周期更新状态机的执行,传入逻辑时间和真实时间参数。
  • 状态机关闭:提供了Shutdown方法,用于关闭和清理状态机。
  • 状态类型约束:许多方法使用了泛型类型参数TState,它被约束为必须派生自FsmStateBase类,这可能是状态的基类。
  • 内部方法:有几个被标记为internal的方法,可能是供状态机内部使用,而不是对外公开的接口。
public interface IFsm  
{  
  public string Name { get; }  
  public int Count { get; }  
  public bool IsRunning { get; }  
  public FsmStateBase CurrentState { get; }  
  public void Start<TState>() where TState : FsmStateBase;  
  public void Start(Type stateType);  
  public void Stop();  
  public bool HasState<TState>() where TState : FsmStateBase;  
  public FsmStateBase GetState<TState>() where TState : FsmStateBase;  
  public FsmStateBase GetState(Type stateType);  
  public FsmStateBase[] GetAllStates();  
  internal void Update(float elapseSeconds, float realElapseSeconds);  
  internal void Shutdown();  
  internal void ChangeState<TState>() where TState : FsmStateBase;  
  internal void ChangeState(Type stateType);  
}

把接口当作一个精心设计的俱乐部,它为会员提供了一系列优质的状态机服务。首先,它允许会员获取俱乐部的基本信息,比如名称、会员人数以及是否正在营业中。同时作为一个尊重隐私的高端俱乐部,它只为内部人员提供了一些特殊服务,比如更新俱乐部运行状态、关闭整个俱乐部以及更换当前服务生。它用非常优雅的方式设计贯彻了"会员第一、服务至上"的理念,通过规范的接口约定,为会员提供了一流的状态机体验。

现在我们为该接口添加基础实现。内部存储状态类型和状态的映射关系,状态切换时非常容易的找到它们。接口 internal void 声明的"内部人员服务",在实现时有点特别 void IFsm.ChangeState(Type stateType) ——指定了接口,并且不能够在被继承。就像一个顶级的杀手俱乐部,身份绑定本人不可转借,甚至不可告知他人。

public abstract class FsmBase:IFsm
{
  private readonly Dictionary<Type, FsmStateBase> m_States;
  private FsmStateBase m_CurrentState;
  
  private string m_Name;
  public string Name
  {
	get
	{
	  return m_Name;
	}
	protected set
	{
	  m_Name = value ?? string.Empty;
	}
  }
  
  
  public int Count => m_States.Count;
  public bool IsRunning => m_CurrentState != null;
  public FsmStateBase CurrentState => m_CurrentState;

  public FsmBase()
  {
	m_States = new Dictionary<Type, FsmStateBase>();
	m_CurrentState = null;
  }
  
  
  public void Start<TState>() where TState : FsmStateBase
  {
	if (IsRunning)
	{
	  throw new Exception("Fsm is Running, can not start again.");
	}

	FsmStateBase state = GetState<TState>();
	if (state == null)
	{
	  throw new Exception($"Fsm {m_Name} can not start state '{typeof(TState).FullName} which is not exist");
	}

	m_CurrentState = state;
	m_CurrentState.OnEnter();
  }

  public void Start(Type stateType)
  {
	if (IsRunning)
	{
	  throw new Exception("Fsm is Running, can not start again.");
	}

	FsmStateBase state = GetState(stateType);
	if (state == null)
	{
	  throw new Exception($"Fsm {m_Name} can not start state '{stateType.FullName} which is not exist");
	}

	m_CurrentState = state;
	m_CurrentState.OnEnter();
  }

  public void Stop()
  {
	m_CurrentState.OnLeave();
	m_CurrentState = null;
  }

  public bool HasState<TState>() where TState : FsmStateBase
  {
	return HasState(typeof(TState));
  }

  public bool HasState(Type stateType)
  {
	if (m_States.Count == 0)
	{
	  return false;
	}

	if (!m_States.ContainsKey(stateType))
	{
	  return false;
	}
	return true;
  }

  public FsmStateBase GetState<TState>() where TState : FsmStateBase
  {
	return GetState(typeof(TState));
  }

  public FsmStateBase GetState(Type stateType)
  {
	if (m_States.TryGetValue(stateType, out FsmStateBase state))
	{
	  return state;
	}
	return null;
  }

  public FsmStateBase[] GetAllStates()
  {
	return m_States.Values.ToArray();
  }

  void IFsm.Update(float elapseSeconds, float realElapseSeconds)
  {
	if (m_CurrentState == null)
	{
	  return;
	}
	m_CurrentState.OnUpdate(elapseSeconds, realElapseSeconds);
  }

  void IFsm.Shutdown()
  {
	Stop();
	//TODO:回收状态机
  }

  void IFsm.ChangeState<TState>()
  {
	(this as IFsm).ChangeState(typeof(TState));
  }

  void IFsm.ChangeState(Type stateType)
  {
	if (m_CurrentState == null)
	{
	  throw new Exception("Current state is invalid.");
	}

	FsmStateBase state = null;
	if (!m_States.TryGetValue(stateType, out state))
	{
	  throw new Exception($"Fsm '{m_Name} can not change state to '{stateType.FullName}' which is not exist");
	}
	
	m_CurrentState.OnLeave();
	m_CurrentState = state;
	m_CurrentState.OnEnter();
  }
}

以上的的代码完成了一个状态机启动、运行和结束的必要编码,每个状态运行时的生存周期函数同样保证被正确调用... 稍等, 我们可能忽略掉了一个函数 OnInit ,这是运行前的初始状态初始化,这非常重要。当你坐在吧台即将点上那杯让你魂牵梦绕的莫吉托,却发现钱包根本没带,酒保似乎看出了你的窘迫,但他此刻打量的眼神让你知道赊账恐怕没门。你可不想这样的事情在状态机中反复发生把?与 OnEnter 相比它是在状态类创建的首次调用💵。那我们何时去做这件事情,当然是“计划好今晚的活动后,出门前的准备工作”。这段描述当中一两个关键点"活动计划"和“准备工作”。

FsmBase 内部我们创建一个静态方法,使用典型的工程模式将状态和状态初始化完成,返回准备好的状态机对象。好似一个优秀的秘书,只用告诉她今天的安排,出门的时候文件和司机便以准备妥当,剩下的出发就好了。

public static FsmBase Create<T>(string name, params Type[] stateTypes) where T:FsmBase
{
	if (stateTypes == null || stateTypes.Length<1)
	{
	  throw new Exception("Fsm states is invalid");
	}

	FsmBase fsm = Activator.CreateInstance<T>();
	fsm.Name = name;
	foreach (var stateType in stateTypes)
	{
	  if (stateType == null)
	  {
		throw new Exception("FSM states is invalid.");
	  }

	  if (!typeof(FsmStateBase).IsAssignableFrom(stateType))
	  {
		throw new Exception("Fsm state type is invalid.");
	  }

	  if (fsm.m_States.ContainsKey(stateType))
	  {
		throw new Exception($"Fsm '{name}' state '{stateType.FullName}' is already exist.");
	  }

	  FsmStateBase state = Activator.CreateInstance(stateType, fsm) as FsmStateBase;
	  fsm.m_States.Add(stateType, state);
	  state.OnInit();
	}

	return fsm;
}

模块化统一管理

前面展示了如何使用面向接口的方式设计状态机。还有一点值得仔细思考——多个状态机如何共生。这意味着我们从如何管理状态的思维层次提高到了如何管理状态机⚙️。所以这里要做的是对状态机的统一管理。我们逐渐向上收缩的层级结构模仿了企业的层级管理模式,称作状态机的“小组长”管理着一批状态“员工”,状态机“部门经理”指导着所有“小组长”。当然还可以在向上发掘,不同的“部门经理”由“CEO”统一调度,只不过这不是本文的要点,在这不做叙述。

public sealed partial class FsmManager
{

	private readonly Dictionary<string, FsmBase> m_Fsms;
	private readonly List<FsmBase> m_FsmsCache;

	public FsmManager()
	{
		m_Fsms = new Dictionary<string, FsmBase>();
		m_FsmsCache = new List<FsmBase>();
	}

	public int Count
	{
		get => m_Fsms.Count;
	}

	public void Update(float elapseSeconds, float realElapseSeconds)
	{
		
		foreach (var fsm in m_FsmsCache)
		{
			if (!fsm.IsRunning)
			{
				continue;
			}
			(fsm as IFsm).Update(elapseSeconds,realElapseSeconds);
		}
	}

	public void Shutdown()
	{
		foreach (var kp in m_Fsms)
		{
			(kp.Value as IFsm).Shutdown();
		}
		
		m_Fsms.Clear();
		m_FsmsCache.Clear();
	}

	public bool HasFsm(string name)
	{
		if (string.IsNullOrEmpty(name))
		{
			throw new Exception("Fsm name is invalid.");
		}
		
		return m_Fsms.ContainsKey(name);
	}
	

	/// <summary>
	/// 获取所有状态机数组
	/// </summary>
	/// <returns></returns>
	public FsmBase[] GetAllFsms()
	{
		return m_FsmsCache.ToArray();
	}

	public IFsm CreateFsm<T>(string name, params Type[] states) where T : FsmBase
	{
		if (string.IsNullOrEmpty(name))
		{
			throw new Exception("Fsm name is invalid.");
		}
		
		if (m_Fsms.ContainsKey(name))
		{
			throw new Exception($"Fsm '{name}' already exists.");
		}

		FsmBase fsm = FsmBase.Create<T>(name, states);
		m_Fsms.Add(name, fsm);
		m_FsmsCache.Add(fsm);
		return fsm;
	}

	public bool RemoveFsm(string name)
	{
		if (string.IsNullOrEmpty(name))
		{
			throw new Exception("Fsm name is invalid.");
		}

		FsmBase fsm = null;
		if (!m_Fsms.TryGetValue(name,out fsm))
		{
			return true;
		}

		(fsm as IFsm).Shutdown();
		m_Fsms.Remove(name);
		m_FsmsCache.Remove(fsm);
		return true;
	}
}

我们从以下几点分析,以上代码是如何达到目的

  1. 集中管理: 使用 Dictionary 存储所有已创建的状态机, 以状态机名称作为键, 确保了状态机名称的唯一性。同时, 它还使用一个 List 作为缓存, 用于存储当前处于运行状态的状态机实例, 以便进行高效的状态更新。
  2. 生命周期控制:CreateFsm 方法用于创建新的状态机实例, 并在内部进行必要的有效性检查, 防止重复创建。在状态机运行期间, 它通过 Update 方法定期更新运行中的状态机。当状态机不再需要时, 可以调用 RemoveFsm 方法将其从管理器中移除并进行必要的清理工作。
  3. 封装和安全性:对外部代码隐藏了状态机管理的内部细节, 只提供了有限的公共接口。这些接口都包含了必要的参数验证和异常处理, 确保了管理器的健壮性和安全性。同时, 通过将 FsmManager 设计为密封类, 防止了外部代码对其进行继承和修改, 从而保证了管理器的封装性和一致性。

总结

本文参考GameFramework框架的状态机实现,提炼并精简了相关代码,向您呈现了一个优秀的状态机示例,展现了作者对该框架状态机代码的深入理解和精心构建。在此,我谨向框架作者表达由衷的敬意。然而,尽管您对本文内容持肯定态度,但还请留意以下几点:

  • 代码即思想 当前所编写代码并非与环境匹配,例如多程序集的设计仍需要开发者针对设计,文章中并未清晰指出代码文件存放结构。文中所用编译环境为 NetFramework 4.7.1, CSharp 9.0 的语法规则并非受到所有环境支持,并且代码中故意保留了少许细微错误。使用时请务必慎重。
  • 线程安全 本文较多的谈论状态机在游戏开发中的应用场景,因此其实现没有考虑多线程访问的情况。如果在多个线程中同时执行,可能会导致数据竞争和不一致的状态。为了确保线程安全,需要在关键区域使用同步机制,如锁或其他线程同步原语。
  • 缓存管理 模块缓存实现使用 List 实现,如果状态机数量很大,这可能会影响性能。可以考虑使用其他数据结构来优化缓存管理,例如使用HashSet或者自定义的缓存管理器,以提高访问效率。
  • 状态机复用 当前的实现在每次 CreateFsm 时都会创建一个新的状态机实例,但没有提供重用已创建实例的机制。如果需要频繁创建和销毁相同类型的状态机实例,可能会导致性能开销。可以考虑实现一个对象池机制,以允许状态机实例在需要时被重用,从而提高资源利用率和性能。(GameFramework 框架包含优秀的复用机制) 如有疏漏或不当之处,还请不吝赐教。

参考

Jiang, Ellan. “EllanJiang/GameFramework.” GitHub, 14 June 2024, github. Com/EllanJiang/GameFramework/. Accessed 14 June 2024.

State (refactoring.guru) 《深入设计模式》