Animancer v8.0 is coming

3. Changing States

Once you have Initialized your StateMachine, you can start telling it to change its state. There are two Try... methods which respect the CanEnterState and CanExitState rules defined by the states and a Force... method which ignores them.

Method Description
TrySetState

Tries to enter a new state.

If the target state is already the CurrentState, this method returns true without doing anything. Otherwise it calls TryResetState.
TryResetState

Tries to enter a new state or re-enter the current state.

  1. If the current state's CanExitState property returns false, this method returns false without doing anything.
  2. Same for the next state's CanEnterState property.
  3. If they both returned true, call ForceSetState and return true.
ForceSetState

Enters a state without checking CanExitState or CanEnterState.

  1. Call the current state's OnExitState method.
  2. Set the next state as the current state.
  3. Call the next state's OnEnterState method.

Details

While a StateMachine is changing its state (throughout all of the Can... and On... methods in the IState interface), references to the PreviousState and NextState can be accessed in several ways.

The following examples are based on the Priority Enum demonstrated in the Interruptions example.

  • TState refers to the base State Type you are using.
  • Accessing the previous or next state will throw an exception if no state change is currently occurring (which can be checked using StateChange<TState>.IsActive).

State Change

StateChange<TState>.PreviousState
StateChange<TState>.NextState

The static StateChange properties can be accessed from anywhere:

public override bool CanExitState
    => StateChange<TState>.NextState.Priority > Priority;

State Machine

StateMachine.PreviousState
StateMachine.NextState

If you have a reference to the StateMachine you can use its properties to avoid needing to specify the TState generic argument:

public override bool CanExitState
    => StateMachine.NextState.Priority > Priority;

Extension Methods

this.GetPreviousState<TState>()
this.GetNextState<TState>()

Inside a class that implements IState (or if you have a reference to one) you can use those Extension Methods which are defined in the StateExtensions class:

public override bool CanExitState
    => this.GetNextState<TState>().Priority > Priority;

Using those Extension Methods without specifying the generic TState parameter relies on the compiler to infer the parameter which can sometimes lead to it accessing the wrong type and therefore not working properly.

  • For example, if you have a StateMachine<CharacterState> then you need to check StateChange<CharacterState>, but if an IdleState calls this.GetPreviousState() the compiler will infer the generic type as IdleState and try to access StateChange<IdleState> which would be empty and cause it to throw an exception:
    • InvalidOperationException: Attempted to access StateChange<IdleState> but no StateMachine of that type is currently changing its State.
  • This can be avoided by specifying the generic parameter yourself: this.GetPreviousState<CharacterState>().

Examples

  • You cannot Jump if you are not on the ground so Jump.CanEnterState checks whether the character is on the ground (or asks whatever system is responsible for checking that).
  • You cannot perform other actions during an Attack so Attack.CanExitState returns false.
  • Attacks can be interrupted if you get hit or killed though, so instead of always returning false, Attack.CanExitState returns true if the StateChange<TState>.NextState is Flinch or Die. This is often implemented by using an enum to indicate the priority of each state or type of each state so you might have multiple different Attack scripts (perhaps for melee vs. ranged or to implement attack combos for some characters but not others) yet they all use the same CharacterAction.Attack as their type. The Interruptions example goes into more detail about this.
  • Once the Attack animation finishes, it needs to be able to return to Idle. This could be done by having Attack.CanExitState check the current animation time as well, but since the Attack state already knows it is ending and Idle is unlikely to impose any constraints on when it can be entered you could simply use ForceSetState to skip the check.

The State Machines examples go into more detail and demonstrate these ideas in action.

Design Rationale

The state change system might seem a bit complicated compared to simply passing the change details into the IState methods as parameters, but there are several main advantages to this design in terms of minimising Boilerplate Code and maximising the flexibility of the system:

  • It allows the IState interface to avoid needing the state type as a generic parameter, which simplifies its usage throughout the system.
  • It avoids the need to declare the parameters in every state because most of the time state changes are dependent on other factors so the parameters would be unused. In particular, while CanEnterState will sometimes base its decision on the previous state, it's much less likely to be relevant in OnEnterState but the parameter would still need to be included in every implementation of that method just in case it's needed.
  • It allows states in Keyed State Machines to access the key change as well as the state change without requiring them to be based on an entirely different IState interface (which would need to pass both the key and state into every method, meaning even more useless code when they aren't used).

Here's a comparison of what various implementations would look like with either approach:

The Current Implementation If It Used Parameters
public interface IState
{
    bool CanEnterState { get; }
    bool CanExitState { get; }
    void OnEnterState();
    void OnExitState();
}
public interface IState<TState>
{
    bool CanEnterState(TState previousState);
    bool CanExitState(TState nextState);
    void OnEnterState(TState previousState);
    void OnExitState(TState nextState);
}
public abstract class StateBehaviour : MonoBehaviour, IState
{
    public virtual bool CanEnterState ...
    public virtual bool CanExitState ...
    public virtual void OnEnterState() ...
    public virtual void OnExitState() ...
}
public abstract class StateBehaviour<TState> : MonoBehaviour, IState<TState>
{
    public virtual bool CanEnterState(TState previousState) ...
    public virtual bool CanExitState(TState nextState) ...
    public virtual void OnEnterState(TState previousState) ...
    public virtual void OnExitState(TState nextState) ...
}
public abstract class CharacterState : StateBehaviour ...
public abstract class CharacterState : StateBehaviour<CharacterState> ...
public class JumpState : CharacterState
{
    public override bool CanEnterState => Character.Body.IsGrounded;
    
    public override void OnEnterState()
    {
        Character.Rigidbody.AddForce(...);
        Character.Animancer.Play(...);
    }
}
public class JumpState : CharacterState
{
    public override bool CanEnterState(TState previousState) => Character.Body.IsGrounded;
    
    public override void OnEnterState(TState previousState)
    {
        Character.Rigidbody.AddForce(...);
        Character.Animancer.Play(...);
    }
}