在Unity中实现状态模板

图片

在对游戏中实体进行编程的过程中,会出现以下情况:它们必须在不同条件下以不同方式行动,这表明使用状态

但是,如果您决定使用蛮力,代码将很快变成混乱的混乱状态,其中包含许多嵌套的if-else语句。

为解决此问题,可以使用State设计模式。 我们将把本教程奉献给他!

在本教程中,您:

  • 了解Unity中State模板的基础。
  • 您将了解什么是状态机以及何时使用它。
  • 了解如何使用这些概念来控制角色的动作。

注意 :本教程适用于高级用户; 假设您已经知道如何在Unity中工作,并且具有C#的平均知识水平。 此外,本教程使用Unity 2019.2和C#7。

开始工作


下载项目资料 。 解压缩zip文件,然后在Unity中打开starter项目。

项目中有几个文件夹可以帮助您入门。 Assets / RW文件夹包含AnimationsMaterialsModelsPrefabsResourcesScenesScriptsSounds文件夹,并根据它们包含的资源进行命名。

为了完成本教程,我们将仅使用ScenesScripts

转到RW /场景并打开Main 。 在游戏模式下,您将在中世纪城堡内的引擎盖中看到一个角色。


单击播放 ,注意相机如何移动以适合角色框。 目前,在我们的小游戏中没有交互,我们将在教程中进行处理。


探索角色


层次结构中,选择字符 。 检查检查 。 您将看到一个包含Character控制逻辑的同名组件


打开位于RW /脚本中的 Character.cs

该脚本执行许多操作,但是其中大多数对我们并不重要。 现在,让我们注意以下方法。

  • Move :它移动角色,接收float speed类型的值作为移动速度, rotationSpeed类型的值作为角速度。
  • ResetMoveParams :此方法重置用于动画化角色的运动和角速度的参数。 它仅用于清洁。
  • SetAnimationBool :将Bool类型的param动画参数设置为value。
  • CheckCollisionOverlap :它接收一个Vector3类型的并返回一个bool ,该bool确定从该的指定半径内是否有任何碰撞体。
  • TriggerAnimationTriggerAnimation输入param动画参数。
  • ApplyImpulse :对Character ApplyImpulse脉冲, ApplyImpulse脉冲等于Vector3类型的输入参数force

在下面,您将看到这些方法。 在我们的教程中,它们的内容和内部工作并不重要。

什么是状态机


状态机是一个概念,其中容器在给定的时间存储东西的状态。 基于输入数据,它可以根据当前状态提供结论,并将此过程传递到新状态。 状态机可以表示为状态图 。 准备状态图使您可以考虑系统的所有可能状态以及它们之间的过渡。

状态机


有限状态机FSM(有限状态机)是四个主要的机器家族之一。 自动机是简单机器的抽象模型。 在自动机理论(计算机科学的理论分支)的框架内研究它们。

简而言之:

  • FSM由有限数量的条件组成 。 在任何给定时间这些状态中只有一个处于活动状态
  • 每个状态根据收到的传入信息序列确定它将进入输出的状态。
  • 输出状态变为新的活动状态。 换句话说, 状态之间存在过渡


为了更好地理解这一点,请考虑地面平台游戏的特征。 角色处于站立状态。 这将是他的活动状态,直到玩家按下按钮以使角色跳跃为止。

站立状态将按钮的按下标识为重要输入,并且作为输出 ,切换到跳跃状态。

假设存在一定数量的这种运动状态,并且角色一次只能处于一种状态。 这是FSM的示例。

分层状态机


考虑一个使用FSM的平台程序,其中几个状态共享一个共同的物理逻辑。 例如,您可以在蹲伏站立状态下移动和跳跃。 在这种情况下,几个传入变量导致两种不同状态的相同行为和信息输出。

在这种情况下,将一般行为委派给其他状态是合乎逻辑的。 幸运的是,这可以使用分层状态机来实现。

在分层FSM中,有一些子状态原始的传入信息委派给它们的子状态 。 反过来,这又使您可以在保持FSM逻辑的同时优雅地减小FSM的大小和复杂性。

状态模板


Erich Gamma,Richard Helm,Ralph Johnson和John Vlissidis在《 设计模式:可重用的面向对象软件的元素》一书中将State模板的任务定义如下:

“他必须允许对象在其内部状态更改时更改其行为。 在这种情况下,似乎该对象已更改其类。”

为了更好地理解这一点,请考虑以下示例:

  • 接收用于运动逻辑的传入信息的脚本附加到游戏内实体。
  • 此类存储当前状态变量,该状态变量仅引用状态类的实例。
  • 传入信息被委派给该当前状态,该状态对其进行处理并在其内部创建行为。 它还处理所需的状态转换。

因此,由于以下事实: 当前状态变量在不同的时间引用了不同的状态,因此同一脚本类的行为似乎有所不同。 这是“状态”模板的本质。

在我们的项目中,前述的Character类将根据不同的状态表现不同。 但是我们需要他表现自己!


在一般情况下,每个状态类都有三个关键点,这些关键点允许整个状态运行:

  • 进入 :这是实体进入状态并执行仅在进入状态时只需执行一次的操作的时刻。
  • 退出 :类似于输入-此处将执行所有重置操作,必须仅在状态更改之前执行此操作。
  • 更新循环 :这是在每个帧中运行的基本更新逻辑 。 它可以分为几个部分,例如,用于更新物理的循环和用于处理玩家输入的循环。


定义状态和状态机


转到RW / Scripts并打开StateMachine.cs

您可能会猜到,状态机为状态机提供了一种抽象。 请注意, CurrentState正确位于此类内。 它将存储到当前活动状态机状态的链接。

现在,要定义状态的概念,让我们转到RW / Scripts并在IDE中打开State.cs脚本。

状态是一个抽象类,我们将使用它作为模型 ,从中派生所有项目状态类 。 项目资料中的部分代码已准备就绪。

DisplayOnUI仅在屏幕UI中显示当前状态的名称。 您无需了解其内部结构,只需了解它接收到类型为UIManager.Alignment的枚举数作为输入参数,可以是LeftRight 。 状态名称在屏幕左下部或右下部的显示取决于此名称。

此外,还有两个受保护的变量characterstateMachinecharacter变量引用Character类的一个实例, stateMachine引用与该状态关联的状态机的一个实例。

创建状态实例时,构造函数会绑定characterstateMachine

场景中Character的许多实例中的每个实例都可以具有自己的一组状态和状态机。

现在,将以下方法添加到State.cs并保存文件:

 public virtual void Enter() { DisplayOnUI(UIManager.Alignment.Left); } public virtual void HandleInput() { } public virtual void LogicUpdate() { } public virtual void PhysicsUpdate() { } public virtual void Exit() { } 

这些虚拟方法定义了上述关键状态点。 当状态机在状态之间进行转换时,我们将上一个状态称为ExitEnter新的活动状态

HandleInputLogicUpdatePhysicsUpdate一起定义了一个更新循环HandleInput处理玩家输入。 LogicUpdate处理基本逻辑,而PhyiscsUpdate处理逻辑和物理计算。

现在再次打开StateMachine.cs ,添加以下方法并保存文件:

 public void Initialize(State startingState) { CurrentState = startingState; startingState.Enter(); } public void ChangeState(State newState) { CurrentState.Exit(); CurrentState = newState; newState.Enter(); } 

Initialize通过将CurrentState设置为startingState并为其调用Enter来配置状态机。 这将初始化状态机,这是第一次设置活动状态。

ChangeState处理状态转换。 在将其引用替换为newState之前,它将为旧的CurrentState调用Exit 。 最后,它为Enter调用newState

因此,我们设置状态状态机

创建运动状态


请看下面的状态图,其中显示玩家游戏中本质的不同运动状态 。 在本节中,我们为FSM图中所示的移动实现“状态”模板:


注意运动状态,即站立躲避跳跃 ,以及输入数据如何引起状态之间的转换。 这是分层FSM,其中GroundedDuckingStanding 子状态的子状态

返回Unity并转到RW /脚本/状态 。 在这里,您将找到几个名称以State结尾的C#文件。

这些文件中的每个文件都定义一个类,每个类都从State继承。 因此,这些类定义了我们将在项目中使用的状态。

现在从RW / Scripts文件夹中打开Character.cs

滚动到#region Variables文件上方,并添加以下代码:

 public StateMachine movementSM; public StandingState standing; public DuckingState ducking; public JumpingState jumping; 

这个movementSM是指状态机,它处理Character实例的运动逻辑。 我们还添加了针对每种运动类型实现的三个状态的链接。

在同一文件中转到#region MonoBehaviour Callbacks 。 添加以下MonoBehaviour方法,然后保存

 private void Start() { movementSM = new StateMachine(); standing = new StandingState(this, movementSM); ducking = new DuckingState(this, movementSM); jumping = new JumpingState(this, movementSM); movementSM.Initialize(standing); } private void Update() { movementSM.CurrentState.HandleInput(); movementSM.CurrentState.LogicUpdate(); } private void FixedUpdate() { movementSM.CurrentState.PhysicsUpdate(); } 

  • 在“ Start代码创建状态机的实例并将其分配给movementSM ,还实例化各种运动状态。 在创建每个运动状态时,我们使用this以及Character实例将对Character实例的引用传递给Character实例。 最后,我们为movementSM Initialize调用Initialize ,并将Standing作为初始状态。
  • Update方法中,我们为HandleInput机器的CurrentState调用HandleInputLogicUpdate 。 同样,在FixedUpdate我们将PhysicsUpdate机器的CurrentState称为PhysicsUpdate 。 从本质上讲,这将任务委派为活动状态。 这就是“状态”模板的含义。

现在我们需要在每个运动状态内设置行为。 振作起来,会有很多代码!

常设公司


在“项目”窗口中返回到RW /脚本/状态

打开Grounded.cs ,注意该类的构造函数与State构造函数匹配。 这是合乎逻辑的,因为此类从其继承。 您将在所有其他状态类中看到相同的内容。

添加以下代码:

 public override void Enter() { base.Enter(); horizontalInput = verticalInput = 0.0f; } public override void Exit() { base.Exit(); character.ResetMoveParams(); } public override void HandleInput() { base.HandleInput(); verticalInput = Input.GetAxis("Vertical"); horizontalInput = Input.GetAxis("Horizontal"); } public override void PhysicsUpdate() { base.PhysicsUpdate(); character.Move(verticalInput * speed, horizontalInput * rotationSpeed); } 

这是这里发生的情况:

  • 我们重新定义在父类中定义的虚拟方法之一。 为了保留父级中可能存在的所有功能,我们从每个重写的方法中调用具有相同名称的base方法。 这是我们将继续使用的重要模板。
  • 下一行EnterhorizontalInputverticalInput为其默认值。
  • 如上所述,在Exit内部Exit我们调用 ResetMoveParams方法以在更改为其他状态时进行重置。
  • HandleInput方法中, horizontalInputverticalInput变量HandleInput水平和垂直输入轴的值。 因此,玩家可以使用键WASD控制角色
  • PhysicsUpdate我们进行一次Move调用,将horizontalInputverticalInput变量乘以相应的速度。 在speed存储移动speedrotationSpeed ,存储角速度。

现在打开Standing.cs,并注意它继承自Grounded的事实。 之所以发生这种情况,是因为如上所述, 站立Grounded的一个子状态。 有多种方法可以实现这种关系,但是在本教程中,我们使用继承。

添加以下override方法并保存脚本:

 public override void Enter() { base.Enter(); speed = character.MovementSpeed; rotationSpeed = character.RotationSpeed; crouch = false; jump = false; } public override void HandleInput() { base.HandleInput(); crouch = Input.GetButtonDown("Fire3"); jump = Input.GetButtonDown("Jump"); } public override void LogicUpdate() { base.LogicUpdate(); if (crouch) { stateMachine.ChangeState(character.ducking); } else if (jump) { stateMachine.ChangeState(character.jumping); } } 

  • Enter我们配置从Grounded继承的变量。 将角色的MovementSpeedRotationSpeed应用于speedrotationSpeed 。 然后,它们分别与正常运动速度和角色本质所要达到的角速度有关。

    此外,用于存储crouchjump输入的变量将重置为false。
  • HandleInput内部, crouchjump变量存储下蹲和跳跃的玩家输入。 如果在主场景中玩家按下Shift键,则深蹲将设置为true。 同样,玩家可以使用空格jump
  • LogicUpdate我们检查类型为boolcrouchjump变量。 如果crouch为true,则movementSM.CurrentState更改为character.ducking 。 如果jump为true,则状态更改为character.jumping

保存并组装项目,然后单击“ 播放” 。 您可以使用WASD键在场景中四处移动 如果您尝试按ShiftSpace ,则会发生意外行为,因为尚未实现相应的状态。


尝试在表格对象下移动。 您会看到由于角色对撞机的高度,这是不可能的。 为了使角色做到这一点,您需要添加蹲行为。

我们爬到桌子底下


打开Ducking.cs脚本。 请注意, Ducking也出于与Standing相同的原因而继承自Grounded类。 添加以下override方法并保存脚本:

 public override void Enter() { base.Enter(); character.SetAnimationBool(character.crouchParam, true); speed = character.CrouchSpeed; rotationSpeed = character.CrouchRotationSpeed; character.ColliderSize = character.CrouchColliderHeight; belowCeiling = false; } public override void Exit() { base.Exit(); character.SetAnimationBool(character.crouchParam, false); character.ColliderSize = character.NormalColliderHeight; } public override void HandleInput() { base.HandleInput(); crouchHeld = Input.GetButton("Fire3"); } public override void LogicUpdate() { base.LogicUpdate(); if (!(crouchHeld || belowCeiling)) { stateMachine.ChangeState(character.standing); } } public override void PhysicsUpdate() { base.PhysicsUpdate(); belowCeiling = character.CheckCollisionOverlap(character.transform.position + Vector3.up * character.NormalColliderHeight); } 

  • Enter内部Enter导致下蹲动画切换Enter参数设置为“蹲下”,这将启用下蹲动画。 为character.CrouchSpeedcharacter.CrouchRotationSpeed属性分配了speedrotation值,当它们下蹲时,它们返回角色的运动和角速度。

    下一个character.CrouchColliderHeight 。CrouchColliderHeight设置角色的碰撞体的大小,蹲时返回所需的碰撞体高度。 最后,将belowCeiling重置为false。
  • Exit内部Exit将蹲下动画参数设置为false。 这将禁用下蹲动画。 然后设置正常的对撞机高度,由character.NormalColliderHeight返回。
  • HandleInput内部HandleInput变量crouchHeld设置播放器的输入值。 在场景中,按住Shift键可crouchHeld设置为true。
  • PhysicsUpdate内部PhysicsUpdate通过将Vector3格式的一个点(带有角色的游戏对象的头部)传递给PhysicsUpdate belowCeiling变量分配了一个值。 如果在该点附近发生碰撞,则表示角色处于某种天花板之下。
  • 在内部, LogicUpdate检查crouchHeldbelowCeiling是否为真。 如果它们都不是真实的,则movementSM.CurrentState更改为character.standing

生成项目,然后单击“ 播放” 。 现在您可以在场景中四处移动。 如果按Shift键 ,角色将坐下,然后可以下蹲。

您也可以在平台下攀爬。 如果您在平台下释放Shift ,角色将一直处于下蹲状态,直到他离开庇护所。


快起来!


打开Jumping.cs 。 您将看到一个名为Jump的方法。 不用担心它是如何工作的。 足以理解它的用法,以便角色可以在考虑物理和动画的情况下跳跃。

现在添加常用的override方法并保存脚本

 public override void Enter() { base.Enter(); SoundManager.Instance.PlaySound(SoundManager.Instance.jumpSounds); grounded = false; Jump(); } public override void LogicUpdate() { base.LogicUpdate(); if (grounded) { character.TriggerAnimation(landParam); SoundManager.Instance.PlaySound(SoundManager.Instance.landing); stateMachine.ChangeState(character.standing); } } public override void PhysicsUpdate() { base.PhysicsUpdate(); grounded = character.CheckCollisionOverlap(character.transform.position); } 

  • Enter内部Enter单例SoundManager播放跳跃声。 然后将grounded重置为其默认值。 最后,将调用Jump
  • PhysicsUpdate内部PhysicsUpdate角色腿部旁边PhysicsUpdate点发送到CheckCollisionOverlap ,这意味着当角色在地面上时, grounded将设置为true。
  • LogicUpdate ,如果LogicUpdate为true,我们将调用TriggerAnimation启用触地动画,播放触地声音,并将LogicUpdate更改为character.standing

因此,在此我们已使用“状态”模板完成了FSM位移的完整实现。 生成项目并运行它。 按空格键使角色跳转。


接下来要去哪里?


项目材料有一个项目草案和一个完成的项目。

尽管有用,状态机还是有局限性的。 并发状态机和下推自动机可以处理其中一些限制。 您可以在Robert Nystrom Game Programming Patterns的书中阅读有关它们的信息。

此外,可以通过检查用于创建更复杂的游戏中实体的行为树来更深入地探讨该主题。

Source: https://habr.com/ru/post/zh-CN484176/


All Articles