Animancer v6.0 is currently available for testing.

06 Platformer

Difficulty: Intermediate - Recommended after Interrupt Management and Brains

Location: Assets/Plugins/Animancer/Examples/06 State Machines/06 Platformer

Namespace: Animancer.Examples.StateMachines.Platformer

This example demonstrates how the ideas from Creatures and Brains can be used to make a character controller for a 2D platformer featuring:

  • Using a Timeline Asset to create an introduction sequence to play on startup.
  • Walking and running.
  • Jumping with the height determined by how long you hold the key.
  • Attacking (though there is no damage system or anything to attack).
  • Dying which prevents the player from controlling the character.

Pro-Only Features are used in this example: Timeline Assets. Animancer Lite allows you to try out these features in the Unity Editor, but they are not available in runtime builds unless you purchase Animancer Pro.

Many of the concepts used in this example have already been explained in the earlier State Machines examples so they will not be repeated here.

Knight created by pzUH - CC0 Licence.

Note that this video is a little different from the actual example scene. It has disabled the camera movement because the delta based compression used by GIFs is not effective when the entire image is moving (causing the video file to be far larger than any of the other examples). The sprites have also been significantly reduced in size from their originals to keep the package size of Animancer small.

Introductions

Many games play a special animation for the player when the scene starts which is not used again during regular gameplay. For example, Super Smash Bros. Ultimate:

Those animations could simply be created as a single AnimationClip (per character) so that it can be played like any other, but Unity's Timeline package allows us to create animation sequences from multiple clips within Unity and then play the entire sequence using a PlayableAssetState in Animancer. This example uses a simple sequence of 3 animations: Knight-ExpandFromPoint and Knight-Flip are only used in this sequence, then they are followed by the regular Knight-Attack which is used during gameplay.

We could instead give our script an array of AnimationClips and play them in sequence like the Sequence Coroutine example, but this way the artists have full control over the sequence and the code is very simple:

Code Inspector
[SerializeField]
private PlayableAssetState.Transition _Animation;

[SerializeField]
private bool _DestroyWhenDone;

With those fields, we can play the TimelineAsset (which inherits from PlayableAsset) like any other Transition:

private void OnEnable()
{
    Creature.Animancer.Play(_Animation);
}

Since the sequence is only used once on startup, you might want to destroy it after it finishes. But since destroying it will create Garbage, you might want to just leave it there until the scene is unloaded. So we use a simple _DestroyWhenDone field to decide what to do and register an appropriate callback to the End Event of the sequence:

[SerializeField] private bool _DestroyWhenDone;

private void Awake()
{
    if (_DestroyWhenDone)
    {
        _Animation.Events.OnEnd = () =>
        {
            Creature.Idle.ForceEnterState();

            // Destroy the AnimancerState managing the sequence.
            Creature.Animancer.States.Destroy(_Animation);

            // Destroy this component as well.
            Destroy(this);
        };
    }
    else
    {
        _Animation.Events.OnEnd = Creature.ForceEnterIdleState;
    }
}

The full TimelineState script looks like this:

public sealed class TimelineState : CreatureState
{
    [SerializeField] private PlayableAssetState.Transition _Animation;
    [SerializeField] private bool _DestroyWhenDone;

    private void Awake()
    {
        if (_DestroyWhenDone)
        {
            _Animation.Events.OnEnd = () =>
            {
                Creature.Idle.ForceEnterState();
                Creature.Animancer.States.Destroy(_Animation);
                Destroy(this);
            };
        }
        else
        {
            _Animation.Events.OnEnd = Creature.ForceEnterIdleState;
        }
    }

    private void OnEnable()
    {
        Creature.Animancer.Play(_Animation);
    }
}

Idle

The Brains example merged the Walk and Run animations into a single LocomotionState so that it could synchronise their timings, but kept that separate from the IdleState. This meant that in order to move, the brain had to set the MovementDirection and also attempt to enter the LocomotionState. It also meant that whenever a creature finished an action they returned to the IdleState at least momentarily, even if the brain was trying to move. We could have the ReturnToIdle callback determine which of those states to return to when an action finishes, but in this example we have merged them all into the IdleState so we are using the term "Idle" to mean "able to move freely" rather than specifically "standing still".

This means that the brain can just control the MovementDirection and IsRunning properties then a creature in the IdleState will act accordingly. A creature will never have a non-zero MovementDirection while standing still just because the brain forgot to enter the LocomotionState.

This allows the LocalPlayerBrain script to be quite simple (we will be changing it a bit to implement Jumping later):

using Animancer;
using UnityEngine;

public sealed class LocalPlayerBrain : CreatureBrain
{
    [SerializeField] private CreatureState _Attack;

    private void Update()
    {
        if (Input.GetButtonUp("Fire1"))// Left Click by default.
            Creature.StateMachine.TrySetState(_Attack);

        // GetAxisRaw rather than GetAxis because we do not want any smoothing.
        MovementDirection = Input.GetAxisRaw("Horizontal");// A and D or Arrow Keys by default.

        IsRunning = Input.GetButton("Fire3");// Left Shift by default.
    }
}

Standardised Locomotion

The Brains example only had two states so they could each just implement their own locomotion, but in this example we have more states so we want them all to share the same locomotion system. We do this by giving the base CreatureState class a virtual property which any state can override to allow movement:

// CreatureState does not allow movement by default:
public virtual float MovementSpeed => 0;

// IdleState uses either a run or walk speed:
public override float MovementSpeed => Creature.Brain.IsRunning ? _RunSpeed : _WalkSpeed;

// JumpState uses whatever speed the Idle state recommends:
public override float MovementSpeed => Creature.Idle.MovementSpeed;

// Other states stick with the base 0 speed.

Then in the Creature class we apply that movement speed according to the brain's MovementDirection and the MovementSpeed of the current state:

private void FixedUpdate()
{
    var speed = StateMachine.CurrentState.MovementSpeed * _Brain.MovementDirection;
    _Rigidbody.velocity = new Vector2(speed, _Rigidbody.velocity.y);

    // The sprites face right by default, so flip the X axis when moving left.
    if (speed != 0)
        _Renderer.flipX = _Brain.MovementDirection < 0;
}

This means any state that wants to allow movement can specify its own speed (you might swim or fly at a different speed) and even states that do not allow movement will continue applying the same movement rules to prevent the character from sliding or being pushed around.

Ground Detector

Practically every game that involves jumping needs a way to determine whether a creature is actually on the ground or not at any given time. Many games use raycasts which start just above the creature's feet and travel a short distance downwards to determine if the ground is nearby, but for this example we use the physics contact points instead.

We start with some Serialized Fields for the collider and grip angle, as well as a read-only IsGrounded Auto-Property so this script can set it and anything else can check the value but not change it:

[SerializeField] private Collider2D _Collider;
[SerializeField] private float _GripAngle = 45;

public bool IsGrounded { get; private set; }

Then we pre-allocate an array of ContactPoint2D so that Unity does not need to allocate a new one (and thus create Garbage which is bad for performance) every time we get the contact points.

  • We use a static field so that there is only one array which is shared by every instance of this script because we do not actually want to store the contents. We just get the values into the array, determine what to do based on them, then we do not care about them anymore so the next instance can reuse the same array.
  • Size 8 should be plenty of room for all the contact points a character could have at any given time, but in a real game this number might need to be a bit higher just in case.
private static readonly ContactPoint2D[] _Contacts = new ContactPoint2D[8];

Unity 2019.1 added an Overload of the GetContacts method which takes a List parameter that it can automatically resize as necessary for the current number of contact points, but since Animancer still supports older Unity versions we still need to use the Array based methods.

In FixedUpdate we start by getting the current contact points:

private void FixedUpdate()
{
    // A label for the goto statement to jump to.
    GetContacts:

    // Get the contacts.
    var contactCount = _Collider.GetContacts(_Contacts);

    // If the array is full, double its size, log a message, and go back to get the contacts again.
    if (contactCount >= _Contacts.Length)
    {
        _Contacts = new ContactPoint2D[_Contacts.Length * 2];

        // If you see this message while testing, you should increase the starting size.
        Debug.LogWarning("_Contacts array is full. Increased size to " + _Contacts.Length);

        goto GetContacts;
    }

The goto statement is not needed very often in C#, but it can be useful in situations like this where a standard for, while, or do while loop isn't quite suitable for the job.

Once we have the contacts, we go through them all to see if any have a normal (the direction of the contact) which is close enough to straight up (according to our _GripAngle field):

    for (int i = 0; i < contactCount; i++)
    {
        // As long as one contact point has a normal close to straight up, we are grounded.
        var contact = _Contacts[i];
        if (Vector2.Angle(contact.normal, Vector2.up) < _GripAngle)
        {
            IsGrounded = true;

            // We now know we are grounded so we can skip the rest of the contacts.
            return;
        }
    }
    
    IsGrounded = false;
}

The full GroundDetector script looks like this:

using Animancer;
using UnityEngine;

public sealed class GroundDetector : MonoBehaviour
{
    [SerializeField] private Collider2D _Collider;
    [SerializeField] private float _GripAngle = 45;

    public bool IsGrounded { get; private set; }

    private static ContactPoint2D[] _Contacts = new ContactPoint2D[8];

    private void FixedUpdate()
    {
        GetContacts:
        var contactCount = _Collider.GetContacts(_Contacts);

        if (contactCount >= _Contacts.Length)
        {
            _Contacts = new ContactPoint2D[_Contacts.Length * 2];
            Debug.LogWarning("_Contacts array is full. Increased size to " + _Contacts.Length);
            goto GetContacts;
        }

        for (int i = 0; i < contactCount; i++)
        {
            var contact = _Contacts[i];
            if (Vector2.Angle(contact.normal, Vector2.up) < _GripAngle)
            {
                IsGrounded = true;
                return;
            }
        }

        IsGrounded = false;
    }
}

With that script in place, we can give the Creature script a reference to it with a Property for other scripts to access it:

[SerializeField]
private GroundDetector _GroundDetector;
public GroundDetector GroundDetector => _GroundDetector;

Then anything can check if the creature is on the ground:

// You can only jump while grounded:
public sealed class JumpState : CreatureState
{
    public override bool CanEnterState(CreatureState previousState) => Creature.GroundDetector.IsGrounded;

    ...
}

// You use a different attack animation in the air:
public sealed class AttackState : CreatureState
{
    private void OnEnable()
    {
        var animation = Creature.GroundDetector.IsGrounded ? _GroundAnimation : _AirAnimation;
        var state = Creature.Animancer.Play(animation);
        state.OnEnd = Creature.ForceEnterIdleState;
    }

    ...
}

Jumping

The only part of jumping that we have not covered yet is the physics. A simple implementation would simply use a force value which can be adjusted in the Inspector to give the desired jump height:

// In JumpState:
[SerializeField] private float _Force = 10;

private void OnEnable()
{
    Creature.Rigidbody.velocity += new Vector2(0, _Force);
}

But that is not the most convenient type of number for game designers to work with. Instead, we want to enter a desired jump height and calculate the necessary force to reach that height by taking into account the acceleration due to gravity. You can find many websites that explain the meths behind this sort of calculation so we will not explain it here, but it can be implemented like so:

[SerializeField] private float _Height = 3;

private void OnEnable()
{
    Creature.Rigidbody.velocity += new Vector2(0, CalculateJumpSpeed(_Height));
}

public float CalculateJumpSpeed(float height)
{
    var gravity = Physics2D.gravity.y * Creature.Rigidbody.gravityScale;
    return Mathf.Sqrt(-2 * gravity * height);
}

This assumes gravity is negative (downwards), otherwise any force would give an infinite jump height.

If we wanted to support gravity in any direction, we could replace Physics2D.gravity.y with a Dot Product: Vector2.Dot(Physics2D.gravity, Creature.transform.up). We could have just done that here, but it is a bit less efficient and we do not need the complexity.

The full GroundDetector script looks like this:

public class JumpState : CreatureState
{
    [SerializeField] private AnimationClip _Animation;
    [SerializeField] private float _Height = 3;

    protected AnimancerState AnimancerState { get; private set; }

    public override float MovementSpeed => Creature.Idle.MovementSpeed;

    public override bool CanEnterState(CreatureState previousState) => Creature.GroundDetector.IsGrounded;

    protected virtual void OnEnable()
    {
        Creature.Rigidbody.velocity += new Vector2(0, CalculateJumpSpeed(_Height));

        AnimancerState = Creature.Animancer.Play(_Animation);
    }

    protected virtual void FixedUpdate()
    {
        if (Creature.GroundDetector.IsGrounded && AnimancerState.NormalizedTime > 1)
            Creature.Idle.ForceEnterState();
    }

    public float CalculateJumpSpeed(float height)
    {
        var gravity = Physics2D.gravity.y * Creature.Rigidbody.gravityScale;
        return Mathf.Sqrt(-2 * gravity * height);
    }
}

Gravity

Gravity on Earth applies approximately 9.81 meters per second of acceleration every second (9.81 meters per second per second, or 9.81 m/s^2), but characters in games can often jump far higher than people in real life (can you jump more than twice your own height while wearing armour like the character in this example?). This means that applying the same gravity acceleration often allows characters to float in the air for far longer than we want. The solution is obviously to increase gravity which can be done in the project's Physics settings (or Physics 2D in this case), but you might also want different multipliers for each character and since we do not want to modify your project's settings just for this example we have simply set the Gravity Scale of the Rigidbody2D component to 2 (which we already accounted for in the jump height calculation above).

Variable Height

Many games allow the player to do a small jump by quickly tapping the key or jump higher by holding it down for a bit. Common enemies do not usually make use of this mechanic though, so rather than modifying our JumpState we are going to create another script which Inherits from it so players can use the new script while other creatures continue using the original JumpState script (not that there are any other creatures in this example).

There are generally two ways to vary the jump height:

  • Play a quick crouch animation before the jump and then choose whether to use the short or long jump height when the crouch finishes.
  • Apply some extra upwards acceleration for a short time at the start of the jump until the player releases the button. This is the approach we are using because we do not have a crouch animation for this character.

Unfortunately, applying acceleration over time makes it much harder to calculate the extra force needed to increase the total height by a specific amount, so instead we are simply going to enter the force value directly and let it be adjusted in the Inspector:

public sealed class AdvancedJumpState : JumpState
{
    [SerializeField] private float _HoldForce = 20;
    [SerializeField] private float _HoldDuration = 0.25f;

If this script were to only ever be used for the player, we could directly check whether they are holding the button here. But in the interest of demonstrating good code architecture we will instead expose a public Property for the brain to set:

    public bool IsHolding { get; set; }

    protected override void OnEnable()
    {
        base.OnEnable();
        IsHolding = true;
    }

    private void OnDisable()
    {
        IsHolding = false;
    }

Then we can use that property and the animation time to determine if we still want to apply the extra _HoldForce:

    protected override void FixedUpdate()
    {
        base.FixedUpdate();

        if (IsHolding && AnimancerState.Time <= _HoldDuration)
            Creature.Rigidbody.velocity += new Vector2(0, _HoldForce * Time.deltaTime);
    }
}

Note that where the base JumpState applied its force all at once in OnEnable, here we want to apply the force constantly over time so we multiply it by Time.deltaTime.

The LocalPlayerBrain already has a CreatureState _Attack field, but since we want to access that IsHolding property we will give it an AdvancedJumpState _Jump field:

// In LocalPlayerBrain:

[SerializeField] private AdvancedJumpState _Jump;

private void Update()
{
    ...

    if (Input.GetButtonDown("Jump"))// Space by default.
        Creature.StateMachine.TrySetState(_Jump);

    if (Input.GetButtonUp("Jump"))
        _Jump.IsHolding = false;

    ...
}

The AdvancedJumpState could also support other mechanics which are not used by most creatures. For example, it could allow wall jumping by overriding CanEnterState to check if the character is currently in the air next to a wall.

Life

This example does not actually have any hazards that can harm the player, but it includes a simple Health script for the sake of implementing Death:

We use a Serialized Field to allow the maximum health to be configured in the Inspector:

public sealed class Health : MonoBehaviour
{
    [SerializeField]
    private int _MaxHealth;
    public int MaxHealth => _MaxHealth;

This script does not need to specifically know what will happen when the health value is changed. Something probably happens when it reaches 0 and the player might want to display their health somwhere, but other objects might not need to do anything in response to most health changes. So instead of hard coding a response into this script, we can give it a Delegate which other scripts can assign the appropriate callback method to:

    public event Action OnHealthChanged;

DieState uses that event below.

We need to store the current health in a field, but we do not want other scripts to modify it directly so we make it private and use a public Property to access it:

    private int _CurrentHealth;
    public int CurrentHealth
    {
        get => _CurrentHealth;
        set
        {

When something sets the CurrentHealth, we want to make sure the value is not negative or higher than the maximum value:

            _CurrentHealth = Mathf.Clamp(value, 0, _MaxHealth);

Then we invoke the OnHealthChanged Delegate if we have one. But just in case we have not been given one by another script we will destroy the object this script is attached to when the health reaches 0.

            if (OnHealthChanged != null)
                OnHealthChanged();
            else if (_CurrentHealth == 0)
                Destroy(gameObject);
        }
    }

And finally we need to actually initialise the current health on startup:

    private void Awake()
    {
        CurrentHealth = _MaxHealth;
    }
}

Death

With the Health script in place, we can implement our DieState which will not be activated by the brain like most other states but will instead register its own callback to the OnHealthChanged event to have the creature set it as the active state at 0 health:

public sealed class DieState : CreatureState
{
    [SerializeField] private AnimationClip _Animation;

    private void Awake()
    {
        Creature.Health.OnHealthChanged += () =>
        {
            if (Creature.Health.CurrentHealth <= 0)
            {
                Creature.StateMachine.ForceSetState(this);
            }
        };
    }

When the creature actually enters this state, we play its animation:

    private void OnEnable()
    {
        Creature.Animancer.Play(_Animation);
    }

We also want to prevent the brain or anything else from performing actions now that the creature is dead:

    public override bool CanExitState(CreatureState nextState) => false;
}

This is demonstrated by a UI Button in the scene which sets the CurrentHealth value to 0.

Revival

In order to allow you to continue playing around with this example scene without needing to exit Play Mode after clicking the "Die" button, we also have the ability to revive the character by playing the same animation in reverse.

So if the CurrentHealth is changed to 0, we enter the DieState as above and make sure the animation is playing forwards:

Creature.Health.OnHealthChanged += () =>
{
    if (Creature.Health.CurrentHealth <= 0)
    {
        Creature.StateMachine.ForceSetState(this);
        var state = Creature.Animancer.States[_Animation];
        state.Speed = 1;
    }

Otherwise if the CurrentHealth is not 0 but this script is enabled (because the creature was previously dead) we want to reverse the animation and return to the IdleState when it ends:

    else if (enabled)
    {
        var state = Creature.Animancer.States[_Animation];
        state.Speed = -1;
        if (state.NormalizedTime > 1)
            state.NormalizedTime = 1;
        state.Events.OnEnd = Creature.ForceEnterIdleState;
    }
};

Since we are using ForceEnterIdleState, it will ignore the fact that CanExitState returns false.