02 Character Controller

Difficulty: Beginner - Recommended after Basic Movement

Location: Assets/Plugins/Animancer/Examples/04 Directional Sprites/02 Character Controller

Namespace: Animancer.Examples.DirectionalSprites

This example demonstrates how you can use Directional Animation Sets in a slightly more complex character than the Basic Movement example and then change to a character that uses DirectionalAnimationSet8s to support diagonal movement as well, without changing the controller script at all. Both characters have animations for Idle, Walk, Run, and Push. The Medical Examiner has 4 directional animations for each of those while the Pirate has 8 of each. This is one of the major advantages Animancer has over Mecanim: you can define data structures to suit your needs and reuse them throughout your projects instead of trying to manually duplicate a particular pattern in an Animator Controller and being unable to easily refactor or extend what you have made.

Medical Examiner Female and Pirate created by Chasersgaming - 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 isn't effective when the entire image is moving (causing the video file to be far larger than any of the other examples).

The SpriteCharacterController script looks like this (with the comments removed since we're about to explain how it works):

using Animancer;
using UnityEngine;

public sealed class SpriteCharacterController : MonoBehaviour
{
    [Header("Physics")]
    [SerializeField] private CapsuleCollider2D _Collider;
    [SerializeField] private Rigidbody2D _Rigidbody;
    [SerializeField] private float _WalkSpeed = 1;
    [SerializeField] private float _RunSpeed = 2;

    [Header("Animations")]
    [SerializeField] private AnimancerComponent _Animancer;
    [SerializeField] private DirectionalAnimationSet _Idle;
    [SerializeField] private DirectionalAnimationSet _Walk;
    [SerializeField] private DirectionalAnimationSet _Run;
    [SerializeField] private DirectionalAnimationSet _Push;
    [SerializeField] private Vector2 _Facing = Vector2.down;

    private Vector2 _Movement;
    private DirectionalAnimationSet _CurrentAnimationSet;
    private TimeSynchronisationGroup _MovementSynchronisation;

    private void Awake()
    {
        _MovementSynchronisation = new TimeSynchronisationGroup(_Animancer)
        { _Walk, _Run, _Push };
        Play(_Idle);
    }

    private void Update()
    {
        _Movement = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
        if (_Movement != Vector2.zero)
        {
            _Facing = _Movement;
            UpdateMovementState();

            _Movement = _CurrentAnimationSet.Snap(_Movement);
            _Movement = Vector2.ClampMagnitude(_Movement, 1);
        }
        else
        {
            Play(_Idle);
        }
    }

    private void Play(DirectionalAnimationSet animations)
    {
        _MovementSynchronisation.StoreTime(_CurrentAnimationSet);

        _CurrentAnimationSet = animations;
        _Animancer.Play(animations.GetClip(_Facing));

        _MovementSynchronisation.SyncTime(_CurrentAnimationSet);
    }

    private static readonly ContactPoint2D[] Contacts = new ContactPoint2D[4];

    private void UpdateMovementState()
    {
        var contactCount = _Collider.GetContacts(Contacts);
        for (int i = 0; i < contactCount; i++)
        {
            if (Vector2.Angle(Contacts[i].normal, _Movement) > 180 - 30)
            {
                Play(_Push);
                return;
            }
        }

        var isRunning = Input.GetButton("Fire3");
        Play(isRunning ? _Run : _Walk);
    }

    private void FixedUpdate()
    {
        var speed = _CurrentAnimationSet == _Run ? _RunSpeed : _WalkSpeed;
        _Rigidbody.velocity = _Movement * speed;
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        if (_Idle == null)
            return;

        AnimancerUtilities.EditModePlay(_Animancer, _Idle.GetClip(_Facing), false);
    }
#endif
}

Fields

[Header("Physics")]
[SerializeField] private CapsuleCollider2D _Collider;
[SerializeField] private Rigidbody2D _Rigidbody;
[SerializeField] private float _WalkSpeed = 1;
[SerializeField] private float _RunSpeed = 2;

[Header("Animations")]
[SerializeField] private AnimancerComponent _Animancer;
[SerializeField] private DirectionalAnimationSet _Idle;
[SerializeField] private DirectionalAnimationSet _Walk;
[SerializeField] private DirectionalAnimationSet _Run;
[SerializeField] private DirectionalAnimationSet _Push;
[SerializeField] private Vector2 _Facing = Vector2.down;
Medical Examiner Pirate

States

As you can see from the fields above, these characters have four states: Idle, Walk, Run, and Push.

Idle

Idle will obviously be used when the player is not trying to move. This is basically the same as in the Basic Movement example, except that this time we are storing the input vector and animation set so that we can determine how to apply the Movement.

private Vector2 _Movement;
private DirectionalAnimationSet _CurrentAnimationSet;

private void Update()
{
    _Movement = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
    if (_Movement != Vector2.zero)
    {
        _Facing = _Movement;
        UpdateMovementState();// See Below.

        _Movement = _CurrentAnimationSet.Snap(_Movement);
        _Movement = Vector2.ClampMagnitude(_Movement, 1);
    }
    else
    {
        Play(_Idle);
    }
}

private void Play(DirectionalAnimationSet animations)
{
    _CurrentAnimationSet = animations;
    _Animancer.Play(animations.GetClip(_Facing));
}

Unlike the Basic Movement example, this time we have several different possible animations to use when moving so the decision of which one to use is handled by a separate method:

  • Update decides whether to move or not and gathers the details that will be necessary to physically move the object.
  • UpdateMovementState decides which movement animation to actually use.

This way we avoid putting too many responsibilities in a single method.

Push

When moving, Push logically takes priority over the other two if you are moving against a solid object so start by detecting if that is currently happening. We need to know what the character is touching so we want to use _Collider.GetContacts to get the current contact details. 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 order to avoid creating Garbage, we want to pre-allocate an array large enough to fit all the contact points we might have. 4 contacts is plenty for this example, but even a large number like 64 would be fine as long as we avoid making a new array every time we need to get the contacts. The Platformer example even implements its own expanding array that works like a list.

private static readonly ContactPoint2D[] Contacts = new ContactPoint2D[4];

private void UpdateMovementState()
{
    var contactCount = _Collider.GetContacts(Contacts);

Note that GetContacts returns the actual number of contact points it filled in the array so when we iterate through them we only want to check that many instead of iterating through the entire Contacts.Length:

    for (int i = 0; i < contactCount; i++)
    {

The ContactPoint2D struct contains various details such as the point of contact and the collider that we are touching, but all we are interested in is the normal which is a Vector2 that tells us which direction the collision is comming from. By comparing the angle between that vector and our movement direction, we can determine if we are actually trying to move towards that object to push it or just brushing past it:

  • If they are 180 degrees apart, we are moving straight towards the object and want to push it.
  • If they are 90 degrees apart, we are moving past the object and do not want to push it.
  • 135 degrees is halfway between those two and could work either way, but since you are moving diagonally across the surface of the object you probably want to move rather than push it.
  • So 150 degrees or more was decided as the threshold for pushing an object. The code is written as 180 - 30 since it is a bit clearer that we want to be within 30 degrees rather than simply writing 150. Note that since both numbers are Constants, the compiler will perform the subtraction at compile-time rather than needing to do it every time the method is executed at runtime (not that this single operation would have any real effect on the performance of your game either way).
        if (Vector2.Angle(Contacts[i].normal, _Movement) > 180 - 30)
        {

So if we determine that the angle is right for pushing, we play the appropriate animation and return immediately so it does not execute the rest of the method.

            Play(_Push);
            return;
        }
    }

    // The UpdateMovementState method continues below.

You might also consider checking whether the colloding object has a Rigidbody2D that you can actually push (or some other component to mark it as "pushable"), but in this example we do not mind playing the Push animation when moving against a wall.

Walk and Run

If the character is not trying to Push anything, we can just choose between Walk and Run based on keyboard Input using a Conditional Operator:

    var isRunning = Input.GetButton("Fire3");
    Play(isRunning ? _Run : _Walk);
}

Synchronisation

With the implementation so far, every time the character changes between Walk and Run, it will re-start from the beginning of the new animation. The problem is not usually too obvious with animations like these which only have a small number of frames, but in smoother animations it can be very jarring when a character snaps from one part of the Walk cycle to a totally different part of the Run cycle. In the worst case, repeatedly swapping between Walk and Run would only let it play the start of each animation before swapping again.

This problem can be avoided by synchronising the time of the previous and next animations:

Not Synchronised Synchronised
Every animation change starts from the beginning. Changing animations continues from the current point in the walk cycle.

A basic implementation only needs two additions to our Play method:

private void Play(DirectionalAnimationSet animations)
{
  1. Store the Time of the current state:
    var time = _Animancer.States.Current.Time;
  1. Play the new animation just like before:
    _CurrentAnimationSet = animations;
    _Animancer.Play(animations.GetClip(_Facing));
  1. Set the Time of the new state:
    _Animancer.States.Current.Time = time;
}

But there are a couple of problems with that implementation:

  • It won't actually work because the first time Play is called the current state will be null so it will just cause an exception. That would be easy to fix.
  • Setting the Time of an animation will actually ensure that it stays at that value after the next animation update (in this case, that will be before the current frame renders). That means if you were to alternate between Walk and Run every single frame, the time would never actually get to advance. That could be solved by adding Time.deltaTime multiplied by the Speed of the state to the value we set.
  • It assumes both animations are the same length. They are in this example, but that won't always be the case. That could be fixed by using NormalizedTime instead, which would also complicate the way we need to add deltaTime a bit more.
  • It synchronises all animations. We want to synchronise Walk and Run, but when we go from Idle to Walk we don't really care what time the Idle was at and if we had another action such as Open Door then we definitely wouldn't want it to skip halfway into that animation based on the Walk cycle. There are plenty of ways that problem could be addressed, but we just keep adding complexity to this system.

These issues could be addressed by using a Blend Tree or Mixer like in the Linear Blending example since they already have Synchronisation built-in, but using them alongside DirectionalAnimationSets isn't particularly convenient.

Instead, we can make a TimeSynchronisationGroup which we initialise in Awake by telling it what we want it to synchronise (Walk, Run, and Push, but not Idle):

private TimeSynchronisationGroup _MovementSynchronisation;

private void Awake()
{
    _MovementSynchronisation = new TimeSynchronisationGroup(_Animancer) { _Walk, _Run, _Push };
    Play(_Idle);
}

Then our Play method can use it in much the same way as the basic implementation:

private void Play(DirectionalAnimationSet animations)
{
    // Store the time if the _CurrentAnimationSet is in the group:
    _MovementSynchronisation.StoreTime(_CurrentAnimationSet);

    _CurrentAnimationSet = animations;
    _Animancer.Play(animations.GetClip(_Facing));

    // Apply the stored time if the newly assigned _CurrentAnimationSet is also in the group:
    _MovementSynchronisation.SyncTime(_CurrentAnimationSet);
}

Movement

Since the character only has animations for a specific set of directions, we want to only allow them to move in those exact directions (this will not always be the case, it is a stylistic choice for you to decide how your game looks and feels):

  • The Medical Examiner only has 4 directions so we do not want them to move diagonally.
  • The Pirate has 8 directions which is all a keyboard can really do, but if the player were to use a controller joystick we could receive input from any angle so we still want to limit them to the available animations.

This script does not actually know how many directions the character supports, but it can simply ask the DirectionalAnimationSet to Snap the movement vector to the closest supported direction:

// in Update when moving:
// Note that Snap returns the modified vector, it does not modify the input directly.
_Movement = _CurrentAnimationSet.Snap(_Movement);

Using the _CurrentAnimationSet to snap the direction has the added benefit of allowing individual actions to have differing numbers of supported directions. For example, your character might have 8 directions for most actions but only 4 for interacting with a specific object. Or you could even make a DirectionalAnimationSet1 if you only have a single animation for an object that always has the same orientation such as pushing open a large boss door which will always be facing straight at the screen.

Holding multiple directions on a keyboard to move diagonally gives you a longer input vector than only holding a single direction ((1, 0) has a Magnitude of 1 while (1, 1) has a Magnitude of about ~1.414... This would cause the character to move faster diagonally than other directions which we do not want, so we can simply use Vector2.ClampMagnitude to shorten the input vector if it is too long:

_Movement = Vector2.ClampMagnitude(_Movement, 1);

Now that we know which direction we want to move we just need to do it. Physical movement is something that should be as consistent as possible, so we want to update it at a fixed rate rather than allowing it to vary based on the rendering frame rate. Using a Rigidbody2D means Unity will already update it at a fixed rate so the following code could simply go at the end of the Update method and would achieve the exact same result. The distinction is much more important if you are applying gradual acceleration which will give slight differences depending on how you apply it.

private void FixedUpdate()
{
    var speed = _CurrentAnimationSet == _Run ? _RunSpeed : _WalkSpeed;
    _Rigidbody.velocity = _Movement * speed;
}

Pixel Perfect Positioning

Pixel art tends to not look very good if the pixels of the characters and other objects do not line up with each other., so this example also makes use of a simple PixelPerfectPositioning script which tweaks the local position of the character's SpriteRenderer to keep it aligned to the pixel grid while the character's actual root object with the Rigidbody on it moves normally.

The PixelPerfectPositioning script looks like this (with the comments removed since we're about to explain how it works):

using Animancer;
using UnityEngine;

public sealed class PixelPerfectPositioning : MonoBehaviour
{
    [SerializeField]
    private SpriteRenderer _Renderer;

    public SpriteRenderer Renderer
    {
        get => _Renderer;
        set => _Renderer = value;
    }

    private void Reset()
    {
        _Renderer = Editor.AnimancerEditorUtilities.GetComponentInHierarchy<SpriteRenderer>(gameObject);
    }

    private void Update()
    {
        var transform = _Renderer.transform;
        var position = transform.position;

        var pixelsPerUnit = _Renderer.sprite.pixelsPerUnit;
        transform.position = new Vector3(
            Mathf.Round(position.x / pixelsPerUnit) * pixelsPerUnit,
            Mathf.Round(position.y / pixelsPerUnit) * pixelsPerUnit,
            Mathf.Round(position.z / pixelsPerUnit) * pixelsPerUnit);

        var maxLocalPosition = 0.5f / pixelsPerUnit;
        position = transform.localPosition;
        WrapValue(ref position.x, maxLocalPosition);
        WrapValue(ref position.y, maxLocalPosition);
        WrapValue(ref position.z, maxLocalPosition);
        transform.localPosition = position;
    }

    private void WrapValue(ref float value, float max)
    {
        value %= max * 2;

        if (value > max) value -= max * 2;
        else if (value < -max) value += max * 2;
    }
}

The snapping process uses some fairly straightforward rounding logic:

  1. Get the world position (transform.position).
  2. Snap it to the grid by rounding its value on each axis.
  3. Get the local position (transform.localPosition).
  4. Wrap its value on each axis to be as close to 0 so the sprite is as close as possible to the actual position its parent Rigidbody thinks it's at while keeping it on the same grid.

Note that other than Editor.AnimancerEditorUtilities.GetComponentInHierarchy, this script does not use anything else from Animancer so you could easily take it out and use it separately (that method just calls GetComponentInParent then if it does not find anything it calls GetComponentInChild).

Edit Mode

In order to ensure that the SpriteRenderer shows the correct Sprite in Edit Mode once we assign the animations to our script, it has basically the same OnValidate method used in the Basic Movement example, except that this time we pass false as the stopImmediately parameter so we can see the Idle animation playing on its own. It is up to you whether you want this to happen in your own scripts; you might find it annoying and prefer to just have it show the first frame by passing true instead.

Sub-Assets

Since the there are lots of animations in this example and they are each only used by a single DirectionalAnimationSet, the Drag and Drop Sub-Assets feature of Inspector Gadgets Pro was used to turn the animations into sub-assets of the set in order to organise them a bit better.