在对游戏中实体进行编程的过程中,会出现以下情况:它们必须在不同条件下以不同方式行动,这表明使用
状态 。
但是,如果您决定使用蛮力,代码将很快变成混乱的混乱状态,其中包含许多嵌套的if-else语句。
为解决此问题,可以使用State设计模式。 我们将把本教程奉献给他!
在本教程中,您:
- 了解Unity中State模板的基础。
- 您将了解什么是状态机以及何时使用它。
- 了解如何使用这些概念来控制角色的动作。
注意 :本教程适用于高级用户; 假设您已经知道如何在Unity中工作,并且具有C#的平均知识水平。 此外,本教程使用Unity 2019.2和C#7。
开始工作
下载
项目资料 。 解压缩
zip文件,然后在Unity中打开
starter项目。
项目中有几个文件夹可以帮助您入门。
Assets / RW文件夹包含
Animations ,
Materials ,
Models ,
Prefabs ,
Resources ,
Scenes ,
Scripts和
Sounds文件夹,并根据它们包含的资源进行命名。
为了完成本教程,我们将仅使用
Scenes和
Scripts 。
转到
RW /场景并打开
Main 。 在游戏模式下,您将在中世纪城堡内的引擎盖中看到一个角色。
单击
播放 ,注意
相机如何移动以适合
角色框。 目前,在我们的小游戏中没有交互,我们将在教程中进行处理。
探索角色
在
层次结构中,选择
字符 。 检查检查
器 。 您将看到一个包含
Character控制逻辑的同名
组件 。
打开位于
RW /脚本中的 Character.cs 。
该脚本执行许多操作,但是其中大多数对我们并不重要。 现在,让我们注意以下方法。
Move
:它移动角色,接收float speed
类型的值作为移动速度, rotationSpeed
类型的值作为角速度。ResetMoveParams
:此方法重置用于动画化角色的运动和角速度的参数。 它仅用于清洁。SetAnimationBool
:将Bool类型的param
动画参数设置为value。CheckCollisionOverlap
:它接收一个Vector3
类型的
并返回一个bool
,该bool
确定从该
的指定半径内是否有任何碰撞体。TriggerAnimation
: TriggerAnimation
输入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
的枚举数作为输入参数,可以是
Left
或
Right
。 状态名称在屏幕左下部或右下部的显示取决于此名称。
此外,还有两个受保护的变量
character
和
stateMachine
。
character
变量引用
Character类的一个实例,
stateMachine
引用与
该状态关联
的状态机的一个实例。
创建状态实例时,构造函数会绑定
character
和
stateMachine
。
场景中
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() { }
这些虚拟方法定义了上述关键状态点。 当
状态机在状态之间进行转换时,我们将上一个状态称为
Exit
并
Enter
新的
活动状态 。
HandleInput
,
LogicUpdate
和
PhysicsUpdate
一起定义
了一个更新循环 。
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,其中
Grounded是
Ducking和
Standing 子状态的子状态 。
返回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
调用HandleInput
和LogicUpdate
。 同样,在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
方法。 这是我们将继续使用的重要模板。 - 下一行
Enter
将horizontalInput
和verticalInput
为其默认值。 - 如上所述,在
Exit
内部Exit
我们调用
ResetMoveParams
方法以在更改为其他状态时进行重置。 - 在
HandleInput
方法中, horizontalInput
和verticalInput
变量HandleInput
水平和垂直输入轴的值。 因此,玩家可以使用键W , A , S和D控制角色。 - 在
PhysicsUpdate
我们进行一次Move
调用,将horizontalInput
和verticalInput
变量乘以相应的速度。 在speed
存储移动speed
在rotationSpeed
,存储角速度。
现在打开
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
继承的变量。 将角色的MovementSpeed
和RotationSpeed
应用于speed
和rotationSpeed
。 然后,它们分别与正常运动速度和角色本质所要达到的角速度有关。
此外,用于存储crouch
和jump
输入的变量将重置为false。 - 在
HandleInput
内部, crouch
和jump
变量存储下蹲和跳跃的玩家输入。 如果在主场景中玩家按下Shift键,则深蹲将设置为true。 同样,玩家可以使用空格键jump
。 - 在
LogicUpdate
我们检查类型为bool
的crouch
和jump
变量。 如果crouch
为true,则movementSM.CurrentState
更改为character.ducking
。 如果jump
为true,则状态更改为character.jumping
。
保存并组装项目,然后单击“
播放” 。 您可以使用
W ,
A ,
S和
D键在场景中四处移动
。 如果您尝试按
Shift或
Space ,则会发生意外行为,因为尚未实现相应的状态。
尝试在表格对象下移动。 您会看到由于角色对撞机的高度,这是不可能的。 为了使角色做到这一点,您需要添加蹲行为。
我们爬到桌子底下
打开
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.CrouchSpeed
和character.CrouchRotationSpeed
属性分配了speed
和rotation
值,当它们下蹲时,它们返回角色的运动和角速度。
下一个character.CrouchColliderHeight
。CrouchColliderHeight设置角色的碰撞体的大小,蹲时返回所需的碰撞体高度。 最后,将belowCeiling
重置为false。 - 在
Exit
内部Exit
将蹲下动画参数设置为false。 这将禁用下蹲动画。 然后设置正常的对撞机高度,由character.NormalColliderHeight
返回。 - 在
HandleInput
内部HandleInput
变量crouchHeld
设置播放器的输入值。 在主场景中,按住Shift键可将crouchHeld
设置为true。 - 在
PhysicsUpdate
内部PhysicsUpdate
通过将Vector3
格式的一个点(带有角色的游戏对象的头部)传递给PhysicsUpdate
belowCeiling
变量分配了一个值。 如果在该点附近发生碰撞,则表示角色处于某种天花板之下。 - 在内部,
LogicUpdate
检查crouchHeld
或belowCeiling
是否为真。 如果它们都不是真实的,则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的书中阅读有关它们的信息。
此外,可以通过检查用于创建更复杂的游戏中实体的
行为树来更深入地探讨该主题。