用C#在Unity中构建状态机

嘿,你知道我在制作游戏时使用的一种有趣的编程技术吗?这就是所谓的状态模式,无论我是为游戏开发者构建原型还是修补我的项目,我总是利用这种编程模式来帮助我组织代码。

正如我在学习时了解到的那样,在代码中分离关注点的原则有很多优点。您确切地知道哪些类控制代码中的总体功能,它们无法修改另一个类的数据(这减少了引入新错误的可能性),并且有助于保持代码的可读性。唯一的缺点是您最终会得到很多脚本,但是通过适当的组织和命名约定,它很可能会提高生产力。在本博客中,我将使用 C# 编程语言为 Unity 中的玩家角色编写一个状态机,重点强调关注点分离。
状态模式的这种实现依赖于两个类: PlayerStateMachine 和 PlayerState 。状态类将作为各个状态继承的高级基类。这里的关键字是“继承”,特定的各个状态(空闲、移动、跳跃等)继承了超级状态的函数、变量和逻辑,而超级状态又继承自基状态类。状态机类本身将保存对当前类的引用,允许玩家的脚本访问该类的函数。它还将保存使用起始状态和更改状态来初始化自身的函数。因此,状态机类的想法是保持当前状态并在调用时切换状态。当然,一个角色可以有越来越多的状态,但是借助继承的力量和一些巧妙的预先规划,我们可以避免重复大量代码。
我假设您至少已经具有 C# 和 Unity 的初级经验,以及基础 Player 类。我试图写这篇文章,以便只要您对引擎有一定的经验,就可以很容易地理解。话虽如此,让我们开始吧!
我们首先通过在项目视图中右键单击并转到“创建”->“C# 脚本”来创建两个新脚本。命名一个 PlayerStateMachine ,另一个,你猜对了, PlayerState 。打开它们,然后删除单一行为引用。在这个实现中,我们不想继承monobehaviour,以免使脚本的文件大小太大。另外,继续取出 Unity 在引擎内创建新脚本时生成的 Update() 和 Start() 函数。
我们将从 PlayerStateMachine 类开始,因为它是迄今为止最容易编写的。此类将保存对当前状态的引用,以及初始化状态机的函数和更改状态的函数。通过输入 public PlayerState _CurrentState 创建当前状态的变量。我们将为其提供一个公共 getter 和一个私有 setter,以便其他脚本可以获取当前状态的信息,但不能自行修改该状态。
那么我们如何修改当前状态呢?为什么,当然是使用 ChangeState 函数!我们将此函数公开,以便其他类可以访问它,并且我们将向此函数传递一个 PlayerState 参数。现在,只需使用此函数设置当前玩家状态,如下所示:
public void ChangeState(PlayerState newState){
_CurrentState = newState;
}
现在我们可以在 PlayerState 类中进行操作。为此,我们需要对播放器的引用、对状态机的引用以及一个表示 Animator 中动画名称的字符串。这些变量将构成我们的构造函数,并且每个继承状态类都需要声明它自己的构造函数。
public PlayerState(Player _player, PlayerStateMachine _stateMachine, Animator _animationController string _animationName){
player = _player;
stateMachine = _stateMachine;
animationController = _animationController
animationName = _animationName;
}
为了改变我们的动画,我们需要访问玩家的 Animator 。使用 Animator ,我们可以声明布尔值并打开和关闭它们以控制动画。虽然处理动画器超出了本博客的范围,但为基类 PlayerState 提供一个构造函数将使每个继承类都需要它。我发现这可以很好地提醒您在开发状态时考虑动画布尔名称,而无需完全充实 Animator ,无论项目规模有多大。它还有助于确定您实际需要的动画(尽管实际上,某些状态将使用相同的动画,因此将具有相同的动画布尔名称)。
从那里,我们定义了一些在我们的状态中使用的函数。我们需要 Enter 、 Exit 、 LogicUpdate 、 PhysicsUpdate 、 TransitionChecks 和 AnimationTrigger 功能。
Enter 和 Exit 函数将在您进入或退出状态时触发逻辑。这些被设计为每个状态生命周期触发一次,但对于我们状态的生命和逻辑非常重要。在创建这些函数时,我喜欢包含变量 startTime (float)、 isAnimationFinished (bool) 和 isExitingState (bool)。 Unity 有几个方便的内部时间函数,因此在 Enter 函数中我喜欢使用 startTime = Time.time; 设置开始时间。 isExitingState 是一个简单的布尔检查,用于处理边缘情况。例如,如果我们要退出状态,我们不想检查状态转换。 isAnimationFinished 的用途大致相同,但它不是处理代码中的边缘情况,而是处理 Animator 中的边缘情况,特别是当动画结束应发出退出信号时国家的。我喜欢默认将其放入 AnimationTrigger 函数中,但我见过此实现的变体,将其放入自己的函数中。
正如您所看到的,PlayerState 类已经比 PlayerStateMachine 多了很多工作,但这一切都是值得的。请记住,您在此基本状态中构建的所有逻辑都将默认由您的子状态继承,而无需重写所有这些代码。为了使其工作,您的变量需要设置为 protected 而不是 private 或 public 。这允许您的继承类访问和修改变量的值。此外,您的函数(在构造函数之外)需要声明为 public virtual function name{} 。将函数声明为 virtual 允许继承类使用 override 关键字继承和/或覆盖基函数的逻辑。最后, PlayerState 类应该看起来像这样:
public class PlayerState
{
  protected Player player;
  protected PlayerStateMachine stateMachine;
  protected Animator animationController;
  protected string animationName;

  protected bool isExitingState;
  protected bool isAnimationFinished;
  protected float startTime;

  /* Put the constructor from above right here */

  public virtual void Enter(){
    isAnimationFinished = false;
    isExitingState = false;
    startTime = Time.time;
    animationController.SetBool(animationName, true);
  }
  public virtual void Exit(){
    isExitingState = true;
    if (!isAnimationFinished) isAnimationFinished = true;
    animationController.SetBool(animationName, false);
  }
  public virtual void LogicUpdate(){
    TransitionChecks();
  }
  public virtual void PhysicsUpdate(){
  }
  public virtual void TransitionChecks(){
  }
  public virtual void AnimationTrigger(){
    isAnimationFinished = true;
  }
}
回到PlayerStateMachine类,我们可以完成我们的 ChangeState 函数。在将 _CurrentState 变量设置为 newState 之前,我们将触发 _CurrentState.ExitState() 。这样我们就可以确保我们的状态执行它可能需要的任何清理逻辑。设置 _CurrentState 后,我们将使用 _CurrentState.Enter() 触发新的 Enter() 函数。这样我们就可以确保当我们更改状态时,我们立即进行所需的任何设置。最终的 ChangeState 函数应如下所示:
public void ChangeState(PlayerState newState){
_CurrentState.Exit();
_CurrentState = newState;
_CurrentState.Enter();
}
最后,对于 PlayerStateMachine 类,我们需要一个函数在启动时对其进行初始化。通常,我们可以为此使用构造函数。然而,对于我们的实现,由于每个单独状态的构造函数都依赖于初始化的状态机,因此最好创建一个函数来手动处理此逻辑。该函数很简单,采用 PlayerState 参数,将 _CurrentState 设置为参数,然后触发 _CurrentState.Enter() 函数。
public void InitializeStateMachine(PlayerState initialState){
  _CurrentState = initialState;
  _CurrentState.Enter();
}
这对于基本的状态机类来说就差不多了。然而,在我们继续之前,先对创作状态、类继承和关注点分离等主题做一个题外话。
您看,您选择如何编写状态最终取决于您。当我编写状态时,我喜欢创建总体的“超级状态”类来包含我的很多逻辑。然后,我将编写“子状态”来规定实际的子状态到子状态的流程,并具有与其相关的更详细的逻辑。正如您可以想象的,在我个人的实现中,子状态将从超级状态继承,而超级状态将从基类 PlayerState 继承。但是,根据您想要分离类的粒度、项目的规模以及您实际构建其余代码的方式,您可以变得更简单或更复杂。
对于本文的示例实现,我将尝试描述一个超级状态和两个子状态,以解释两种状态类型之间的差异以及如何正确利用每种状态。我将描述的超级状态是 Grounded 超级状态,而子状态是 Idle 和 Move 。然而,在我们深入研究之前,让我们先设定一些规则并定义我们的期望。子状态将包含运行角色的所有逻辑,以及决定何时更改状态以及更改为什么状态。国家之间自然会有很多共享的逻辑,这就是我们拥有超级国家的原因。超级状态是检测输入、检查地面、施加重力等的好地方。这些是某些子状态将共享的东西,因此我们可以简单地将这些数据保存在超级状态中,以便在需要时随时随地进行访问。
好的,这总结了我们如何看待我们的 Grounded 超级状态。我们知道,对于 Idle 和 Move 子状态,我们需要检查运动输入,并且我们知道我们需要检查玩家是否接地。我们将创建从 PlayerState 类继承的类。
在我的实现中,我非常字面地理解关注点分离,并且我总是有一个 PlayerControls 类来处理我的输入,还有一个 PlayerMovement 类来控制我的所有移动。详细说明实际代码……超出了本文的范围,因此您需要知道的是我使用公共 getter(但私有 setter,因为我从不希望外部类设置我的 Player 类中的这些类)变量)。通过 Player 类可以轻松访问这些类,我只需调用 Grounded 超级状态的 LogicUpdate() 函数中所需的函数即可。我们将使用公共 getter 和受保护的 setter 创建局部变量(以防继承类需要设置变量),并将变量命名为 moveInput 和 isGrounded 。
public class GroundedSuperState : PlayerState{
  public PlayerGroundedSuperState(PlayerCharacter pc, string animationName, PlayerStateMachine stateMachine) : base(pc, animationName, stateMachine){}

  protected Vector2 moveInput;
  protected bool isGrounded;
  
  public override void LogicUpdate(){
    base.LogicUpdate();
  
    moveInput = player.playerControls._MoveInput; /* we check for movement controls here*/
    isGrounded = player.playerMovement._IsGrounded; /* we check to see if the player is grounded from playerMovement*/
  }
}
由于 Idle 和 Move 将继承此逻辑,因此我们已经拥有从一种状态转移到另一种状态所需的设置。到达这里是一个漫长的旅程,但在我们的子状态类中,从一个状态移动到另一个状态就像在覆盖的类中添加一行一样简单:
// for Idle, use this
public override void TransitionChecks(){
  base.TransitionChecks();

  if(moveInput != Vector2.zero) stateMachine.ChangeState(player.moveState);
  }

// for Move use this
public override void TransitionChecks(){
  base.TransitionChecks();
  
  if(moveInput == Vector2.zero) stateMachine.ChangeState(player.idleState);
  }
通过此添加,我们的播放器将运行从一个状态移动到另一个状态的清理逻辑,包括为 Animator 设置正确的布尔值,当我们添加下降状态时,我们只需要添加条件到 Grounded 超级状态,以便它在子子状态中触发。
再次强调,这需要大量的前期设置,但我希望您能看到长期的好处。我们不必重写大量代码,调试最终更容易(因为您知道哪些函数导致问题,因为它们只能在您编写它们的子状态类中触发),并且操作总体上组织得更好。为了扩展这个系统,我强烈建议遵循可靠的编码原则,并记住分离代码的关注点。虽然 Move 状态不会移动玩家本身,但它会调用 PlayerMove 类中的 MoveCharacter(Vector3) 函数。无法强调此方法使调试变得多么容易:如果玩家没有移动,请检查 Move 状态是否正在调用 MoveCharacter 函数并发送 moveInput 变量。如果失败,那么您就知道问题根本不在于状态,而在于 PlayerMove 类。进步!
所以我想就这样结束了。我希望这对使用 Unity3D 在 C# 中编程状态机有所帮助并提供良好的基础。
祝你好运,玩得开心,编码愉快!