Skip to content

2. FSM in Animancer

更新: 4/8/2026 字数: 0 字 时长: 0 分钟

Introduction

虽然这一章主要教 Animancer 内置的 FSM,但其中的通用理念几乎可以应用到任何状态机系统中。

新手开发者常犯的一个错误是:创建名叫PlayerEnemy这样的大类,结果里面塞了很多重复的代码。虽然不是所有情况都这样,但在很多游戏中,敌人和玩家的整体结构与规则非常相似。

游戏物体上,逻辑的分离:

  • Root Object:挂载CharacterIdleState等通用 State。在实际项目中,Character 引用的其他组件(如Rigidbody)也会放在这里。
  • Model:挂载AnimatorAnimancerComponent。这样可以方便地将角色的视觉模型换成其他模型。
  • Brain & Actions:把BasicCharacterBrainMoveStateActionState放在同一个物体上,因为大脑正是负责控制这些动作的。

当然,还可以有许多其他组织方式,但最终采用什么方式大多取决于个人偏好。

Character

Character 类指挂载在 Root Object 上的脚本,负责持有对其他系统的引用,以便所有脚本都能方便地通过一个地方访问它。它是任何角色的核心。

它引用的内容包括:

  • 动画系统(使用 Animancer 时是AnimancerComponent
  • 状态机(本示例使用 Animancer 的有限状态机,但你可以使用任意状态机系统)。
  • 其他大多数角色都有的通用功能,例如Rigidbody、角色属性、背包、生命值池等。

我们先 keep every thing simple。角色脚本首先需要的属性:

cs
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>.WithDefaultStateMachine<CharacterState>这个泛型类的嵌套类。


角色状态基类 CharaterState

状态基类可以直接继承自MonoBehaviour并实现状态IState接口,或者更简单的方式是直接继承自StateBehavoiur:

cs
public abstract class CharacterState : StateBehaviour
{ 
    [SerializeField]
    private Character _Character;
    public Character Character => _Character;

为了避免频繁拖拽Character引用,脚本体用了一个OnValidate()方法用于自动查找引用:

cs
#if UNITY_EDITOR
    protected override void OnValidate()
    {
        base.OnValidate();

        gameObject.GetComponentInParentOrChildren(ref _Character);
    }
#endif

此外,还有几个属性用于决定不同状态之间能否相互打断。这些属性会在 Interruptions(打断) 示例中详细解释:

cs
    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 示例:

cs
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 示例:

cs
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的引用,以及对应两个动作的状态引用:

cs
public class BasicCharacterBrain : MonoBehaviour
{
    [SerializeField] private Character _Character;
    [SerializeField] private CharacterState _Move;
    [SerializeField] private CharacterState _Action;
    // ..
}

它有一个Update方法:

cs
    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 等。

用于控制打断行为的属性:

cs
public override CharacterStatePriority Priority => CharacterStatePriority.High;

public override bool CanInterruptSelf => true;

在简单情况下,你可以直接重写CanExitState属性来控制状态是否可被打断:

cs
public class ActionState : CharacterState
{
    public override bool CanExitState => false;

这样,任何TrySetState()TryResetState()的尝试都会失败。但ForceSetState()仍然可以成功,因为它会忽略这个属性。


CharacterStatePriority枚举(优先级枚举)

cs
public enum CharacterStatePriority
{
    // 枚举默认从 0 开始递增。
    Low,    // Low = 0
    Medium, // Medium = 1
    High,   // High = 2
    // 这意味着你可以用数值比较运算符(如 <、>)来比较它们。
}

CharacterStatePriority枚举提供了一组具有明确名称的优先级(Low、Medium、High),用于控制状态之间的打断关系。它本质上就是一个整数,因此支持数值比较,这让模块化状态机逻辑更加清晰和易于维护。


CharacterState 基类中对打断逻辑的核心实现如下:

cs
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;   // 否则只有更高优先级的状态才能打断
        }
    }
}

其他打断策略的对比

  1. 简单布尔值

    • public override bool CanExitState => false;
    • 优点:简单;缺点:无法区分不同的打断者。
  2. 整数优先级

    使用int Priority,数值越高优先级越高。

  3. 枚举优先级(本示例采用)

    使用CharacterStatePriority,既可比较,又有明确语义,最推荐中等复杂度的项目。

  4. 基于类型(Type-Based) ]

    使用另一个枚举CharacterStateType,在CanExitState中通过 switch 判断具体允许哪些类型打断(更精确,但更死板)。

  5. Inspector 中序列化字段

    把优先级做成[SerializeField],可以在编辑器中直接调整,而不用修改代码。

Brain

思考让角色执行一个动作通常需要哪些步骤:

  1. 检测鼠标左键被按下。
  2. 检查角色当前是否正在做其他事情,导致无法执行这个动作。
  3. 播放动作动画。

只有第1步与角色想要做什么相关,因此这一步才属于 brain 的职责。当大脑检测到按键后,它只会告诉角色尝试执行这个动作,剩下的检查和执行工作交给其他系统处理。

这种关注点分离意味着每一种状态只需要实现一次,然后就可以被任何类型的角色复用——无论是玩家还是 NPC。这也使得你可以在不影响动作实际执行逻辑的情况下,轻松修改角色的控制方式(修改大脑脚本,或给角色换一个不同的大脑即可)。

之所以使用 Brain 这个名称,是因为它准确描述了它的作用:控制Character的行为。你也可以根据自己的喜好使用其他术语,例如 “Character Input”。

Weapons

在先前的示例中,攻击动画是写死在ActionState里的。现在升级为:每个武器自己定义自己的动画(攻击、装备、卸下)。角色只需要根据当前装备了什么武器,然后从武器那里拿动画来播放。

这样做的好处非常大:

  • 以后添加新武器时,只需要做一个武器 Prefab,配好它的动画,不需要改角色脚本。
  • 支持不同武器有不同持握姿势、不同攻击连招、不同装备动作。
  • 非常适合实际游戏开发(刀、剑、枪、弓等)。

Weapon 类

每个武器都是一个 Prefab,上面挂 Weapon 脚本。

cs
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组件的引用:

cs
class Character
{
    // ..
    [SerializeField]
    private Equipment _Equipment;
    public Equipment Equipment => _Equipment;
}

Equipment脚本持有右手骨骼的 Transform 引用,以便在装备武器时将武器重新挂载到该骨骼下:

cs
public class Equipment : MonoBehaviour
{
    [SerializeField] private Transform _WeaponHolder;
    
    // ..
}

它还持有一个当前装备的Weapon引用。当Weapon被更改时,它会先分离旧武器,再将新武器挂载到_WeaponHolder上:

cs
    [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:

cs
    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:

cs
    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脚本中获取当前装备的武器。
cs
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,以便下次尝试进入该状态时检查该动画是否仍在活跃。
cs
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;
}

以下两种情况会导致连击重置:

  • 索引已经到达最后一个动画;
  • 上一个动画已经完全淡出(或尚未初始化)。
cs
private bool ShouldRestartCombo
    => _AttackIndex >= Weapon.AttackAnimations.Length - 1 
    || _CurrentState == null
    || _CurrentState.Weight == 0;

EquipState一样,该状态的优先级也为 Medium:

cs
public override CharacterStatePriority Priority
    => CharacterStatePriority.Medium;

Weapons Character Brain

WeaponsCharacterBrain脚本保留了与MovingCharacterBrain相同的移动代码,但增加了一些内容:

  • 它使用 InputBuffer(输入缓冲),即使按钮在状态切换不允许时按下,也会在短时间内持续尝试进入该状态。
  • 它引用了EquipState来切换装备的武器。
  • 它有一个 Weapon 数组,用于循环切换武器。在实际游戏中,这通常由库存系统管理。

输入缓冲是一种技术:当状态切换最初失败时,会在每一帧继续尝试,直到成功或超时结束。这能给玩家更大的操作窗口,让游戏操作感觉更灵敏。

在 Animancer 的有限状态机系统中,使用时需先用要控制的StateMachine初始化InputBuffer

cs
private StateMachine<CharacterState>.InputBuffer _InputBuffer;

protected virtual void Awake()
{
    _InputBuffer = new StateMachine<CharacterState>.InputBuffer(_Character.StateMachine);
}

执行动作时,调用 Buffer 方法,传入目标状态和超时时间,决定缓冲持续多久:

cs
[SerializeField, Seconds] private float _InputTimeOut = 0.5f;

private void UpdateAction()
{
    if (SampleInput.LeftMouseDown)
    {
        _InputBuffer.Buffer(_Attack, _InputTimeOut);
    }
}

最后,在 Update 中调用缓冲的 Update 方法,让它继续尝试进入状态:

cs
protected virtual void Update()
{
    UpdateMovement();
    UpdateEquip();  // 这个方法用来检测玩家输入并切换武器
    UpdateAction();

    _InputBuffer.Update();
}