There are 3 different methods for changing the CurrentState
of a StateMachine
: 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 theCurrentState , this method returns true without doing anything. Otherwise it calls TryResetState . |
TryResetState |
Tries to enter a new state or re-enter the current state.
|
ForceSetState |
Enters a state without checking
|
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 static
StateChange<TState>.PreviousState
andNextState
properties can be accessed from anywhere. - If you have a reference to the
StateMachine
you can use its properties of the same name (i.e.stateMachine.PreviousState
) to avoid needing to specify theTState
generic argument. - Inside a class that implements
IState
you can usethis.GetPreviousState()
andthis.GetNextState()
which are Extension Methods defined in theStateExtensions
class.- 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 obviously need to checkStateChange<CharacterState>
, but if anIdleState
callsthis.GetPreviousState()
the compiler will infer the generic type asIdleState
and try to accessStateChange<IdleState>
which will be empty and cause it to throw anInvalidOperationException: Attempted to access StateChange<...> but no StateMachine of that type is currently changing its State
. - This can be avoided by specifying the generic parameter yourself:
this.GetPreviousState<CharacterState>()
.
- Using those Extension Methods without specifying the generic
All of those properties will throw an exception if no state change is currently occurring (which can be checked using StateChange<TState>.IsActive
).
Examples
- You cannot
Jump
if you are not on the ground soJump.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
soAttack.CanExitState
returns false. Attack
s can be interrupted if you get hit or killed though, so instead of always returning false,Attack.CanExitState
returns true if theStateChange<TState>.NextState
isFlinch
orDie
. This is often implemented by using anenum
to indicate the priority of each state or type of each state so you might have multiple differentAttack
scripts (perhaps for melee vs. ranged or to implement attack combos for some characters but not others) yet they all use the sameCharacterAction.Attack
as their type. The Interrupt Management example goes into more detail about this.- Once the
Attack
animation finishes, it needs to be able to return toIdle
. This could be done by havingAttack.CanExitState
check the current animation time as well, but since theAttack
state already knows it is ending andIdle
is unlikely to impose any constraints on when it can be entered you could simply useForceSetState
to skip the check.
The State Machines examples go into more detail and demonstrate these ideas in action.
Initialisation
A StateMachine
can be created and have its state changed at any time so the system is very flexible, but when multiple scripts are all interacting with the same StateMachine
it can be easy to cause bugs if you don't pay close attention to their execution order. The following initialisation pattern is often useful to avoid those problems:
[SerializeField]
private State _Idle;
public readonly StateMachine<State>
StateMachine = new StateMachine<State>();
private void Awake()
{
if (StateMachine.CurrentState == null)
StateMachine.ForceSetState(_Idle);
}
- The
StateMachine
ispublic
so other scripts can access it butreadonly
so they can't assign a differentStateMachine
or set it tonull
. - The
StateMachine
is created using a Field Initializer so that it is always accessible, even if another script executes before this one. - The
Awake
method makes sure there is noCurrentState
before entering the first state in case another script executed before this one and already set the state.- For example, an Introduction state could set itself as the active state on startup and it wouldn't matter whether that happens before or after this
Awake
method is called. That way the rest of the system wouldn't have to know or care whether there is an Introduction state or not. If there is an Introduction script attached to the character it will enter itself, but otherwise the character will start in the Idle state.
- For example, an Introduction state could set itself as the active state on startup and it wouldn't matter whether that happens before or after this
Here are some of the other approaches that could be used and a summary of their problems:
Create in Awake
[SerializeField]
private State _Idle;
public StateMachine<State> StateMachine { get; private set; }
private void Awake()
{
StateMachine = new StateMachine<State>(_Idle);
}
If another script executes before this Awake
method gets called, the StateMachine
will still be null.
Initialize in Field Initializer
[SerializeField]
private State _Idle;
public readonly StateMachine<State>
StateMachine = new StateMachine<State>(_Idle);
This gives a compiler error because Field Initializers can't access instance fields (_Idle
is an instance field).
Initialize in Constructor
[SerializeField]
private State _Idle;
public readonly StateMachine<State> StateMachine;
public MyClass()
{
StateMachine = new StateMachine<State>(_Idle);
}
This will compile and run fine, but Constructors are executed immediately when the object is created which is before Unity's Serialization system can deserialize its fields, meaning that the _Idle
field will still be null
.
Default States
A regular StateMachine<TState>
doesn't keep track of any states other than the CurrentState
, but it is often useful to have a particular state that it can return to by default if nothing else is active so the StateMachine<TState>.WithDefault
class allows you to do that:
- It inherits from the base
StateMachine<TState>
, meaning it has the usualCurrentState
property and methods for Changing States. - It has a
DefaultState
property. If there is noCurrentState
when you set the default, the state machine will immediately enter that state. - It also has a
ForceSetDefaultState
callback which is useful for End Events if you want to return to the default state when an animation ends.
The recommended initialisation pattern is basically the same as the regular one explained above, but instead of manually checking the CurrentState
and calling ForceSetState
, you can just set its DefaultState
:
public sealed class Character : MonoBehaviour
{
[SerializeField]
private AnimancerComponent _Animancer;
public AnimancerComponent Animancer => _Animancer;
[SerializeField]
private CharacterState _Idle;
public readonly StateMachine<CharacterState>.WithDefault
StateMachine = new StateMachine<CharacterState>.WithDefault();
private void Awake()
{
StateMachine.DefaultState = _Idle;
}
}
Then other states can use its ForceSetDefaultState
callback for their End Events:
public abstract class CharacterState : StateBehaviour
{
[SerializeField]
private Character _Character;
public Character Character => _Character;
}
public sealed class AttackState : CharacterState
{
[SerializeField]
private AnimationClip _Animation;
private void OnEnable()
{
var animancerState = Character.Animancer.Play(_Animation);
animancerState.Events.OnEnd = Character.StateMachine.ForceSetDefaultState;
}
}
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 inOnEnterState
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:
This System | Using Parameters |
---|---|
|
|
|
|
|
|
|
|