【Unity】Animancer状态机源码学习笔记(一)
官方的轻量免费版也提供了源代码,路径:Assets/Plugins/Animancer/Utilities/FSM
在本文中,标题将严格按照父类——子类的顺序排布
先列代码,后面是讲解。了减少文本量,不让文章看起来臃肿,有些代码不会贴
设计思路
组件布局
Character
组件是任何角色的核心,无论是玩家、敌人、NPC、人、动物、怪物还是机器人。
游戏中的所有角色应共享相同的标准脚本,该脚本包含对其每个其他组件的引用(通常很少有自己的逻辑)。
引用以下内容:
- 动画系统:
AnimancerComponent
或Animator
- 状态机:有限状态机、行为树
- 常见功能:
Rigidbody
,角色 属性、背包、生命值等
拥有这个中心脚本,意味着其他脚本可以简单地拥有对Character
的一个引用,并通过它访问所有其他组件。
动画播放方式
一句话来说就是,将组件当做一个状态,OnEnable
的时候播放动画,在动画播放完的时候退出动画
Animancer
的有限状态机采用在MonoBehaviour
的OnEnable
中播放动画,这样在控制组件的开启与关闭enable
就可以控制这个动画的播放
StateMachine<TState>
泛型类,需要传递StateBehaviour
类,partial
,在多个文件夹中存在
默认状态机
StateMachine<TState>
中有一个默认的状态机StateMachine<TState>.WithDefault
如果没有自定义状态机的需求可以直接使用这个特点:
- 不会跟踪除
CurrentState
以外的任何状态,PreviousState
和NextState
没有作用- 被序列化之后可以引用
CurrentState
和DefaultState
,如果CurrentState
没有被设置值,那么状态机将立即进入DefaultState
。所以也可以理解成,如果设置了CurrentState
,那么状态机会立刻进入这个状态- 还具有
.ForceSetDefaultState(TState)
方法,通常用在动画结束时使用,时动画回到默认状态
这里先简单介绍一下默认状态机,StateMachine<TState>
的详细源码将在下一章节展开
Character
1 | [// Initialize the StateMachine before anything uses it. ] |
启动时机很早,不会再衍生类,能挂载到物体上
序列化:引用AnimancerComponent
、状态机、血量、背包、武器等
1 | private void Awake() |
该类主要是当做所有控制的中心脚本,起到牵线搭桥的作用,本身并没有多少逻辑,只有一个初始化方法
StateBehaviour
1 | public abstract class StateBehaviour : MonoBehaviour, IState |
继承自Monobehaviour
但本身是abstract
类,所以无法被挂载到物体上
1 | public virtual void OnEnterState() |
该类主要的作用是在进入时启动脚本,在退出时关闭脚本:
除了关闭开启脚本外,该脚本还实现了接口的CanEnterState
和CanExitState
属性,默认永远都是true
,后续有需求可自定义
1 | #if UNITY_EDITOR |
挂载脚本的时候将脚本的设置为非启用。这么做是因为要将该组件设置成一个状态,这个状态是否启用就是通过enable
管理并体现的的
CharacterState
1 | public abstract class CharacterState : StateBehaviour |
与父类一样,无法挂载到物体上
1 | [ ] |
定义了一个状态机类,注意:这里只是定义,并没有实例化,在Character
中实例化了
那为什么不直接在
Character
中定义并实例化呢:
StateMachine<>
的泛型参数是Character
,写在这里方便观看Character
里需要定义一个名称为StateMachine
的状态机,如果定义在Character
中的话,就只能改一个名字了,如CharacterStateMachine
- 这样处理能让
Character
的代码更加简洁当然这些原因影响并不大,如果非得写在
Character
中也不是不行,取决于个人喜好
引用Character
(就两行,不贴代码了)
1 | public virtual CharacterStatePriority Priority => CharacterStatePriority.Low; // 设定动画的优先级 |
1 | public override bool CanExitState |
重写了CanExitState
方法,重新设定状态的退出方法,
1 | #if UNITY_EDITOR |
- 因为父类也写在
if UNITY_EDITOR
内,所以同样也得写在里面 - 挂载脚本的时候获取
Character
并赋值给_Character
,这样就不用每次手动拖拽了
IdleState
1 | [private ClipTransition _Animation; // 引用动画 ] |
在OnEnable
的时候播放引用的动画。
注意:
- 在游戏启动的时候
OnEnable
并不会触发,因为该组件的基类会将脚本设置为非启动,所以这里的.Play()
并实际上并不会播放动画 - 将动画设置在
OnEnable
内还有另一个好处:当状态机启动该脚本时,就会播放动画
ActionState
引用ClipTransition
动画
1 | private void Awake() |
1 | // 重新设定优先级,使这个状态为Medium优先级,可被High打断,不可被Low打断。 |
BasicCharacterBrain
用来监控玩家输入
1 | [private Character _Character; // 引用中心脚本 ] |
1 | private void Update() |
ExampleInput
是Animancer.Examples
中的一个数据监测,从命名名称就可以看出作用,源代码也很简单,就不贴了
IState
接口
1 | public interface IState |
bool CanEnterState
:当前状态是否能进入
- 由
StateMachine<TState>
的.CanSetState
、.TrySetState
和.TryResetState
检查 StateMachine<TState>.ForceSetState
不判断,直接进入
bool CanExitState
:当前状态是否能退出
- 判断条件同上
Animancer
还提供了DelegateState
类:
1 | public class DelegateState : IState |
本身并没有实现任何功能,而是简单地为该接口的每个成员提供一个委托,以便在创建状态时分配它们。
CharacterStatePriority
枚举
1 | public enum CharacterStatePriority // 动画优先级 |
小结:
第一次看源码,深刻体会到了一个优秀的架构能让程序更灵活,更有层次能让阅读者更容易理解,但是每个脚本太碎片化了,不容易维护。这个时候,接口的一个作用就体现了,能规定方法。并且充分使用
sealed
,abstract
修饰符,能体现每个类的作用相比之前写的有限状态机有什么不同:
- 最让人眼前一亮的还是通过控制脚本组件的启用来完成进入和退出状态的操作,不仅使逻辑代码融入了
UnityBehaviour
生命周期,还能让开发者直观的看到每个状态的当前情况。有想过如果使用组件开关来实现状态机的进出,会不会影响性能问题。但是不管怎样,每个状态类都需要引用Clip
你不得不序列化这个状态类 - 之前并没有使用接口和父类继承,代码框架不够规范,做了很多重复的工作。每个状态之间太过独立,状态与状态之间的转换变得十分麻烦。如果想要扩展其他状态的话,几乎每个状态都得针对新的状态做出改变,可扩展性不强
- 引用了一个新的
Brain
脚本,用来控制每个状态之间的转换,而不是在状态内部控制跳到哪一个状态。每个状态内部只用管理自己是否能进入和退出,不用管什么时候进入。 - 有三种方式切换状态:
TrySetState
、TryResetState
、ForceSetState
,与CanEnterState
和CanExitState
配合能使动作之间的转变更加灵活。之前写的状态机只有类似ForceSetState
这一种方式
- 关于
[System.Serializable]
:
之前只是简单的知道被这个属性修饰的类可以序列化展示在Inspector窗口上,现在了解到了一些其他的细节:
1 | [ ] |
通常我们实例化一个类需要使用 = new()
关键字。但是当这个类被[System.Serializable]
修饰后,不需要使用new
关键字,只要你在MonoBehaviour
中声明了Human human
,那么你就实例化它了(在Editor Mode的时候就已经被实例化了)