有限状态机是游戏开发中最流行的模式之一,原因很充分。它们可以通过鼓励模块化、可重用的状态来降低复杂性并创建更易读的程序。虽然 FSM(有限状态机)无法扩展到更复杂的情况,但它们是大多数常见情况(如处理应用程序状态或制作简单的 AI)的绝佳解决方案。
我不会解释 FSM 的基本概念和用例,因为我认为网上已经有很多关于 FSM 的好文章,比如这篇文章*
(http://gameprogrammingpatterns.com/state.html) 来自Bob Nystrom的关于游戏编程模式的书。*
创建和使用 FSM 主要有两种方法:编写一个快速而简陋的实现,或者从asset store购买一个插件来使用。但还有第三种选择:使用 Unity 内置的动画系统(Mecanim)。
为什么你会使用 Unity 的动画系统?
将动画系统用于创建有限状态机似乎有点奇怪,但Animator本身就是一个有限状态机。虽然它有一些动画特有的功能可能会妨碍使用,但动画系统几乎可以满足你所有的需求。它附带了一些工具,可以在运行时编辑和可视化状态机。此外,它还受 Unity 支持,经过数百万开发人员的测试,你之前在 Unity 中的任何动画经验都是可以应用的。
为什么不直接构建一个快速而简陋的实现?
快速而简陋的实现对于其设计任务来说效果很好,但一旦你需要在其他地方使用这种模式,你就会发现自己一遍又一遍地重写相同的代码。动画系统可以轻松地创建不同的状态机,并使用可重用的状态行为。
那么使用第三方插件怎么样?
有一些第三方插件在复杂性和功能方面可以与动画系统相媲美(比如 NodeCanvas(https://www.assetstore.unity3d.com/en/#!/content/14914))。它们还有一个更简洁的 API 的优势,因为它们不处理动画。我甚至自己实现(https://github.com/DarrenTsung/finite-graph-machine),但我最终停止使用它,转而重新利用动画器。因为你将使用动画器来,嗯,动画,将它重新用于状态机意味着你不需要在两个类似的系统之间切换上下文。
好吧,我该怎么开始?
首先,我将介绍 Unity 动画系统(Mecanim)的基本概念。
-
在 Unity 中,你可以创建一个名为Animator Controller的资产。这是一个状态机模板。
-
你的状态机中包含状态。你可能习惯将状态与动画相关联,但如果你想创建一个纯粹的逻辑状态机,它们实际上是可选的。
-
要运行你的状态机,请向一个 GameObject 添加一个名为Animator的组件,并将其设置为任何你创建的动画控制器。现在它就成了你的状态机的一个实例。
-
要在每个状态上运行逻辑,我们需要创建一个从类StateMachineBehaviour派生的脚本。一旦我们有了这个新的行为,我们就可以将其添加到状态机中的任何状态。
你可以在这里(https://docs.unity3d.com/Manual/Animator.html)阅读更多关于动画器的深入信息,或者观看这里(https://unity3d.com/learn/tutorials/topics/animation/animator-controller)的教程。
请注意,这与 MonoBehaviours 的工作方式类似:脚本接收预定义的消息,比如 OnEnter、OnExit 等。
如何使用 StateMachineBehaviours?
我通常使用状态来管理对象的生命周期及其自身行为,在继承自 StateMachineBehaviour 时使用OnStateEnter和OnStateExit消息。例如,如果我想让我的状态监听任何游戏事件,我通常会在进入时添加监听器,并在退出时移除它们。通过在退出时清理对象,你可以避免难以追踪的后续影响,比如遗留的游戏对象或僵尸监听器。你不会希望你的状态在不活动时影响游戏!
我发现的一个问题是,当动画器被禁用或销毁时,OnStateExit 不会被调用。因此,为了正确地清理当前状态,你需要确保也处理 OnDisable***。
***为了节省时间,我创建了一个仓库,我将其导入到我的游戏中,仓库地址为 *这里(https://github.com/DarrenTsung/DTAnimatorStateMachine)。欢迎你在自己的项目中使用它!
让我们一起看一下我在自己的游戏中为敌人创建的一个简单的状态机,Jellyquest。
这是愤怒的河豚。它会缓慢地旋转,直到它发现玩家,然后它会快速地向玩家的方向推进。
河豚是一个预制体,配置了一个名为 AngryPufferfish 的动画控制器。它有 3 个状态:瞄准、准备射击和射击。河豚从瞄准状态开始,该状态由两个状态行为组成:RotateFacingDirection和AimInFacingDirection。
RotateFacingDirection根据可配置的速度旋转河豚。AimInFacingDirection根据射线检测确定河豚是否面对目标。
class RotateFacingDirection : LogicalStateMachineBehaviour {
[SerializeField] private float _rotationSpeed = 10.0f;
...
}
class AimInFacingDirection : LogicalStateMachineBehaviour {
[SerializeField] private LayerMask _targetLayerMask;
void OnStateUpdate() {
this.Animator.SetBool("FacingTarget", this.FacingTarget());
}
...
}
在准备射击状态中,我重新使用了一个名为TriggerContinueAfterDelay的状态行为,它带有一个参数,用于指定延迟时间。
class TriggerContinueAfterDelay : LogicalStateMachineBehaviour {
[SerializeField] private float _delay = 1.0f;
void OnStateEntered() {
this.DoAfterDelay(this._delay, () => {
this.Animator.SetTrigger("Continue");
}
}
}
在射击状态中,我使用MoveInFacingDirection状态行为来移动河豚,使其朝向玩家。
class MoveInFacingDirection : LogicalStateMachineBehaviour {
[SerializeField] private float _speed = 5.0f;
void OnStateUpdate() {
this.MoveInFacingDirection();
}
...
}
通过使这些行为中的每一个都通用且单一目的,我可以调整它们并在其他状态机中重复使用它们,以创建各种不同的敌人类型。
太棒了,还有其他我应该知道的吗?
有一些常见的陷阱,你应该小心。当你对动画系统越熟悉,这些陷阱就越有意义。
-
警惕编写可能在同一帧中多次设置触发器的代码。因为触发器只能被一个转换消耗一次,所以触发器可能会在被消耗后被设置,并且会持续到当前状态之后。
-
子状态机上的 OnStateEnter 和 OnStateExit 可能不会像你预期的那样工作。它们只在命中子状态机的入口和出口节点时被调用。但这些节点可能会被意外地绕过,方法是进行直接进入特定子状态或退出到外部状态的转换。
-
转换持续时间可能应该始终设置为零。如果转换持续时间不为零,则下一个状态的 OnEnter 将在当前状态的 OnExit 之前被调用。
-
使用触发器的转换将在同一帧内前进到下一个状态。基于其他参数类型的转换需要额外的帧。
简短总结
-
有限状态机可用于管理对象的生命周期
-
人们要么在代码中编写自己的状态机,要么从资产商店购买第三方插件。但你也可以重新利用 Unity Animator。
-
动画器附带内置的可视化、工具、转换和熟悉的 API。
-
想了解更多游戏开发知识,可以扫描下方二维码,免费领取游戏开发4天训练营课程