2. FSM in Animancer
更新: 4/8/2026 字数: 0 字 时长: 0 分钟
Introduction
虽然这一章主要教 Animancer 内置的 FSM,但其中的通用理念几乎可以应用到任何状态机系统中。
新手开发者常犯的一个错误是:创建名叫Player和Enemy这样的大类,结果里面塞了很多重复的代码。虽然不是所有情况都这样,但在很多游戏中,敌人和玩家的整体结构与规则非常相似。
游戏物体上,逻辑的分离:
- Root Object:挂载
Character和IdleState等通用 State。在实际项目中,Character 引用的其他组件(如Rigidbody)也会放在这里。 - Model:挂载
Animator和AnimancerComponent。这样可以方便地将角色的视觉模型换成其他模型。 - Brain & Actions:把
BasicCharacterBrain、MoveState和ActionState放在同一个物体上,因为大脑正是负责控制这些动作的。
当然,还可以有许多其他组织方式,但最终采用什么方式大多取决于个人偏好。
Character
Character 类指挂载在 Root Object 上的脚本,负责持有对其他系统的引用,以便所有脚本都能方便地通过一个地方访问它。它是任何角色的核心。
它引用的内容包括:
- 动画系统(使用 Animancer 时是
AnimancerComponent) - 状态机(本示例使用 Animancer 的有限状态机,但你可以使用任意状态机系统)。
- 其他大多数角色都有的通用功能,例如
Rigidbody、角色属性、背包、生命值池等。
我们先 keep every thing simple。角色脚本首先需要的属性:
public class Character : MonoBehaviour
{
[SerializeField]
private AnimancerComponent _Animancer;
public AnimancerComponent Animancer => _Animancer;
[SerializeField]
private StateMachine<CharacterState>.WithDefault _StateMachine;
public StateMachine<CharacterState>.WithDefault StateMachine => _StateMachine;
// ..
protected virtual void Awake()
{
_StateMachine.InitializeAfterDeserialize();
}
// ..
}NOTE
StateMachine<CharacterState>.WithDefault是StateMachine<CharacterState>这个泛型类的嵌套类。
角色状态基类 CharaterState
状态基类可以直接继承自MonoBehaviour并实现状态IState接口,或者更简单的方式是直接继承自StateBehavoiur:
public abstract class CharacterState : StateBehaviour
{
[SerializeField]
private Character _Character;
public Character Character => _Character;为了避免频繁拖拽Character引用,脚本体用了一个OnValidate()方法用于自动查找引用:
#if UNITY_EDITOR
protected override void OnValidate()
{
base.OnValidate();
gameObject.GetComponentInParentOrChildren(ref _Character);
}
#endif此外,还有几个属性用于决定不同状态之间能否相互打断。这些属性会在 Interruptions(打断) 示例中详细解释:
public virtual CharacterStatePriority Priority => CharacterStatePriority.Low;
public virtual bool CanInterruptSelf => false;
public override bool CanExitState
{
get
{
// 有几种不同的方式可以访问状态切换的详细信息:
// CharacterState nextState = StateChange<CharacterState>.NextState;
// CharacterState nextState = this.GetNextState();
CharacterState nextState = _Character.StateMachine.NextState;
if (nextState == this)
return !CanInterruptSelf;
else if (Priority == CharacterStatePriority.Low)
return true;
else
return nextState.Priority > Priority;
}
}
}继承自StateBehaviour的脚本默认会在状态机进入该状态时自动启用自身,并在退出时禁用。
Idle State 示例:
public class IdleState : CharacterState
{
[SerializeField] private TransitionAsset _Animation;
protected virtual void OnEnable()
{
Character.Animancer.Play(_Animation);
}
}TIP
由于CharacterState继承自StateBehaviour,它默认会在状态机进入该状态时自动启用自身(并在退出时禁用)。因此OnEnable方法的作用等同于重写public override void OnEnterState()。
Action State 示例:
public class ActionState : CharacterState
{
[SerializeField] private TransitionAsset _Animation;
protected virtual void OnEnable()
{
AnimancerState state = Character.Animancer.Play(_Animation);
state.Events(this).OnEnd ??= Character.StateMachine.ForceSetDefaultState;
}
public override CharacterStatePriority Priority => CharacterStatePriority.Medium;
public override bool CanInterruptSelf => true;
}与 Idle 和 Move 不同,这个动画不是循环播放的,所以我们希望它播放结束后自动返回 Idle 状态。因此我们为其设置了 End Event,让它强制切换到 Default State。
它还重写了控制打断行为的属性(这些会在 Interruptions 示例中详细说明):
Priority返回Medium,这样它可以打断IdleState,但不会被IdleState打断(因为IdleState使用基类默认的Low优先级)。CanInterruptSelf返回true,这样角色仍然可以实现快速触发 Action。
Brain
角色的 Brain 负责决定它想要做什么。本节重点说明这个具体脚本的作用,Brains 示例会进一步解释整个系统为什么这样设计。
这个角色可以选择做两件事:Move 和执行 Action,因此大脑脚本持有对Character的引用,以及对应两个动作的状态引用:
public class BasicCharacterBrain : MonoBehaviour
{
[SerializeField] private Character _Character;
[SerializeField] private CharacterState _Move;
[SerializeField] private CharacterState _Action;
// ..
}它有一个Update方法:
protected virtual void Update()
{
UpdateMovement();
UpdateAction();
}
private void UpdateMovement()
{
float forward = SampleInput.WASD.y;
if (forward > 0)
{
_Character.StateMachine.TrySetState(_Move);
}
else
{
_Character.StateMachine.TrySetDefaultState();
}
}
private void UpdateAction()
{
if (SampleInput.LeftMouseUp)
_Character.StateMachine.TryResetState(_Action);
}这里使用了几种不同的 Change States 方法:
TIP
Animancer 提供了用于将根运动转发给另一个物体的简单组件RedirectRootMotion<T>。Model 需要将自身的根运动 redirect 给 root object。
Interruptions
本示例展示了管理这种逻辑的几种方式:
- 使用
bool表示某个状态是否可被打断 —— 简单,但如果需要“能被某些状态打断,但不能被另一些打断”,就无法满足。 - 使用
int表示优先级 —— 更灵活,但具体数值没有明确含义。 - 使用
enum—— 既可以像int一样比较,又能给每个值赋予具体名称(Low、Medium、High 等)。 - 也可以不使用单纯的优先级,而是让枚举代表不同类型的状态,例如 Idle, Attack, Die 等。
用于控制打断行为的属性:
public override CharacterStatePriority Priority => CharacterStatePriority.High;
public override bool CanInterruptSelf => true;在简单情况下,你可以直接重写CanExitState属性来控制状态是否可被打断:
public class ActionState : CharacterState
{
public override bool CanExitState => false;这样,任何TrySetState()或TryResetState()的尝试都会失败。但ForceSetState()仍然可以成功,因为它会忽略这个属性。
CharacterStatePriority枚举(优先级枚举)
public enum CharacterStatePriority
{
// 枚举默认从 0 开始递增。
Low, // Low = 0
Medium, // Medium = 1
High, // High = 2
// 这意味着你可以用数值比较运算符(如 <、>)来比较它们。
}CharacterStatePriority枚举提供了一组具有明确名称的优先级(Low、Medium、High),用于控制状态之间的打断关系。它本质上就是一个整数,因此支持数值比较,这让模块化状态机逻辑更加清晰和易于维护。
CharacterState 基类中对打断逻辑的核心实现如下:
public abstract class CharacterState : StateBehaviour
{
public virtual CharacterStatePriority Priority => CharacterStatePriority.Low;
public virtual bool CanInterruptSelf => false;
public override bool CanExitState
{
get
{
CharacterState nextState = _Character.StateMachine.NextState;
if (nextState == this)
return CanInterruptSelf; // 尝试进入自身状态时,取决于 CanInterruptSelf
else if (Priority == CharacterStatePriority.Low)
return true; // Low 优先级可以被任何状态打断
else
return nextState.Priority > Priority; // 否则只有更高优先级的状态才能打断
}
}
}其他打断策略的对比
简单布尔值
public override bool CanExitState => false;- 优点:简单;缺点:无法区分不同的打断者。
整数优先级
使用
int Priority,数值越高优先级越高。枚举优先级(本示例采用)
使用
CharacterStatePriority,既可比较,又有明确语义,最推荐中等复杂度的项目。基于类型(Type-Based) ]
使用另一个枚举
CharacterStateType,在CanExitState中通过 switch 判断具体允许哪些类型打断(更精确,但更死板)。Inspector 中序列化字段
把优先级做成
[SerializeField],可以在编辑器中直接调整,而不用修改代码。
Brain
思考让角色执行一个动作通常需要哪些步骤:
- 检测鼠标左键被按下。
- 检查角色当前是否正在做其他事情,导致无法执行这个动作。
- 播放动作动画。
只有第1步与角色想要做什么相关,因此这一步才属于 brain 的职责。当大脑检测到按键后,它只会告诉角色尝试执行这个动作,剩下的检查和执行工作交给其他系统处理。
这种关注点分离意味着每一种状态只需要实现一次,然后就可以被任何类型的角色复用——无论是玩家还是 NPC。这也使得你可以在不影响动作实际执行逻辑的情况下,轻松修改角色的控制方式(修改大脑脚本,或给角色换一个不同的大脑即可)。
之所以使用 Brain 这个名称,是因为它准确描述了它的作用:控制Character的行为。你也可以根据自己的喜好使用其他术语,例如 “Character Input”。
Weapons
在先前的示例中,攻击动画是写死在ActionState里的。现在升级为:每个武器自己定义自己的动画(攻击、装备、卸下)。角色只需要根据当前装备了什么武器,然后从武器那里拿动画来播放。
这样做的好处非常大:
- 以后添加新武器时,只需要做一个武器 Prefab,配好它的动画,不需要改角色脚本。
- 支持不同武器有不同持握姿势、不同攻击连招、不同装备动作。
- 非常适合实际游戏开发(刀、剑、枪、弓等)。
Weapon 类
每个武器都是一个 Prefab,上面挂 Weapon 脚本。
public class Weapon : MonoBehaviour
{
[SerializeField] private TransitionAsset[] _AttackAnimations; // 攻击连招动画数组
public TransitionAsset[] AttackAnimations => _AttackAnimations;
[SerializeField] private TransitionAsset _EquipAnimation; // 装备动画
public TransitionAsset EquipAnimation => _EquipAnimation;
[SerializeField] private TransitionAsset _UnequipAnimation; // 卸下动画
public TransitionAsset UnequipAnimation => _UnequipAnimation;
// ..
}实际游戏中可能还包含战斗数据等内容。
装备系统
管理当前装备的武器,并负责把武器模型挂到角色右手骨骼上。
Character类持有一个对Equipment组件的引用:
class Character
{
// ..
[SerializeField]
private Equipment _Equipment;
public Equipment Equipment => _Equipment;
}Equipment脚本持有右手骨骼的 Transform 引用,以便在装备武器时将武器重新挂载到该骨骼下:
public class Equipment : MonoBehaviour
{
[SerializeField] private Transform _WeaponHolder;
// ..
}它还持有一个当前装备的Weapon引用。当Weapon被更改时,它会先分离旧武器,再将新武器挂载到_WeaponHolder上:
[SerializeField] private Weapon _Weapon;
public Weapon Weapon
{
get => _Weapon;
set
{
DetachWeapon();
_Weapon = value;
AttachWeapon();
}
}
protected virtual void Awake()
{
AttachWeapon();
}本示例中所有武器都作为 Equipment 物体的子物体并默认处于禁用状态。挂载武器(AttachWeapon)的操作包括:将武器的 parent 设置为 _WeaponHolder,清除其本地 Transform 值,并激活其 GameObject:
private void AttachWeapon()
{
if (_Weapon == null) return;
Transform transform = _Weapon.transform;
transform.parent = _WeaponHolder;
transform.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity);
transform.localScale = Vector3.one;
_Weapon.gameObject.SetActive(true);
}分离武器(DetachWeapon)则更简单:将武器的父级设为该Equipment脚本所在的 Transform,并禁用其 GameObject:
private void DetachWeapon()
{
if (_Weapon == null) return;
_Weapon.transform.parent = transform;
_Weapon.gameObject.SetActive(false);
}
}在实际游戏中,挂载和分离武器可能涉及实例化/销毁预制体,或者让武器一直存在于场景中但仅控制激活状态。
EquipState
负责卸下旧武器和装备新武器的流程。
- 有
NextWeapon属性,用于告诉它要换成哪把武器。 - 先播放当前武器的
UnequipAnimation,结束后再切换武器并播放 EquipAnimation。 - 最后强制回到默认状态(Idle)。
它包含两个 Weapon 属性:
NextWeapon:需要由尝试进入该状态的脚本设置(在本示例中由 Brain 设置)。CurrentWeapon:只是一个快捷方式,用于从Equipment脚本中获取当前装备的武器。
public class EquipState : CharacterState
{
public Weapon NextWeapon { get; set; }
public Weapon CurrentWeapon
=> Character.Equipment.Weapon;
public override CharacterStatePriority Priority
=> CharacterStatePriority.Medium;
protected virtual void Awake()
{
NextWeapon = CurrentWeapon;
}
public override bool CanEnterState
=> !enabled
&& NextWeapon != CurrentWeapon;
protected virtual void OnEnable()
{
if (CurrentWeapon.UnequipAnimation.IsValid())
{
AnimancerState state = Character.Animancer.Play(CurrentWeapon.UnequipAnimation);
state.Events(this).OnEnd ??= OnUnequipEnd;
}
else
{
OnUnequipEnd();
}
}
private void OnUnequipEnd()
{
Character.Equipment.Weapon = NextWeapon;
if (CurrentWeapon.EquipAnimation.IsValid())
{
AnimancerState state = Character.Animancer.Play(CurrentWeapon.EquipAnimation);
state.Events(this).OnEnd = Character.StateMachine.ForceSetDefaultState;
}
else
{
Character.StateMachine.ForceSetDefaultState();
}
}
}Attack State
该状态使用一个简单的int来跟踪当前进行到数组中的第几个攻击。
- 每次进入状态时,要么从0开始重置,要么递增索引。
- 它还会记住上一次播放的 AnimancerState,以便下次尝试进入该状态时检查该动画是否仍在活跃。
private int _AttackIndex = int.MaxValue;
private AnimancerState _CurrentState;
protected virtual void OnEnable()
{
if (ShouldRestartCombo)
_AttackIndex = 0;
else
_AttackIndex++;
TransitionAsset animation = Weapon.AttackAnimations[_AttackIndex];
_CurrentState = Character.Animancer.Play(animation);
_CurrentState.Events(this).OnEnd ??= Character.StateMachine.ForceSetDefaultState;
}以下两种情况会导致连击重置:
- 索引已经到达最后一个动画;
- 上一个动画已经完全淡出(或尚未初始化)。
private bool ShouldRestartCombo
=> _AttackIndex >= Weapon.AttackAnimations.Length - 1
|| _CurrentState == null
|| _CurrentState.Weight == 0;与EquipState一样,该状态的优先级也为 Medium:
public override CharacterStatePriority Priority
=> CharacterStatePriority.Medium;Weapons Character Brain
WeaponsCharacterBrain脚本保留了与MovingCharacterBrain相同的移动代码,但增加了一些内容:
- 它使用 InputBuffer(输入缓冲),即使按钮在状态切换不允许时按下,也会在短时间内持续尝试进入该状态。
- 它引用了
EquipState来切换装备的武器。 - 它有一个 Weapon 数组,用于循环切换武器。在实际游戏中,这通常由库存系统管理。
输入缓冲是一种技术:当状态切换最初失败时,会在每一帧继续尝试,直到成功或超时结束。这能给玩家更大的操作窗口,让游戏操作感觉更灵敏。
在 Animancer 的有限状态机系统中,使用时需先用要控制的StateMachine初始化InputBuffer:
private StateMachine<CharacterState>.InputBuffer _InputBuffer;
protected virtual void Awake()
{
_InputBuffer = new StateMachine<CharacterState>.InputBuffer(_Character.StateMachine);
}执行动作时,调用 Buffer 方法,传入目标状态和超时时间,决定缓冲持续多久:
[SerializeField, Seconds] private float _InputTimeOut = 0.5f;
private void UpdateAction()
{
if (SampleInput.LeftMouseDown)
{
_InputBuffer.Buffer(_Attack, _InputTimeOut);
}
}最后,在 Update 中调用缓冲的 Update 方法,让它继续尝试进入状态:
protected virtual void Update()
{
UpdateMovement();
UpdateEquip(); // 这个方法用来检测玩家输入并切换武器
UpdateAction();
_InputBuffer.Update();
}