Sponsored By

Featured Blog | This community-written post highlights the best of what the game industry has to offer. Read more like it on the Game Developer Blogs.

2D Animation Methods in Unity

We explore three different methods for 2D character animation in Unity: a simple system that sets sprite frames directly; a hybrid system that uses Unity's Animator component in a minimalistic way; and all-in use of Unity's animation state machine.

Joe Strout, Blogger

August 7, 2015

38 Min Read

0. Introduction

Unity has provided a built-in state machine editor for managing animations since version 4.  This is the officially recommended approach to animating a game character.  However, it often leads to game logic being divided between code and the animator state machine.  I would prefer to have all my game logic in one place, to simplify development and debugging.  Moreover, in some cases — especially simple 2D sprite games — the Animator can seem like more trouble than it's worth.

To help clarify the pros and cons, I built a 2D game character using three different approaches:

  1. A simple home-grown animation system that eschews Unity's built-in animation support completely.

  2. Use of Unity animations, but without using the Animator state machine; instead each animation is invoked directly from code.

  3. Full use of the built-in Unity components, with all game logic in the state machine, and only minimal supporting code.

My test character is an energetic orange rabbit known as Surge, found on OpenGameArt.org.  This particular version of Surge was intended for use with an open-source project called Ultimate Smash Friends, a fighting game inspired by Super Smash Bros.  So for my test project, I implemented a few of the moves Surge would need for such a fighting game.  In particular, the character must be able to:

  1. Run left and right.

  2. Stand with an idle animation, facing either direction.

  3. Jump from the ground, while standing or running.

  4. Jump again while in the air, but only once per regular jump.

  5. Influence his direction while in the air.

Surge

The character is controlled by a digital left/right axis and a Jump button input.

Of course in a real fighting game, a character like this would have several more control inputs, and many more states: various attacks, defenses, combos, etc. (see here for example).  These would depend not only on the current state of the character, but also on the positions of other characters and elements in the game world.

So, we must evaluate each of these approaches with an eye to scalability.  Any approach works well enough on a toy problem, but how well would they handle a much more complex character?  Our set of states here, while small, is big enough to provide a view into this important issue.

1. Simple Approach

The first approach tried doesn't use any of Unity's animation components.  Instead, we have a SimpleAnimator class that does a similar job, but is (for simple cases) easier to use.

A helper class called Anim defines one animation as a name, a series of frames, a frame rate, and whether it should loop.  The SimpleAnimator class then has a public array of Anim.  It provides methods to play an animation by name or index, as well as to stop and resume the current animation.  The code for this class is shown below.

using UnityEngine;

using UnityEngine.Events;
using System.Collections.Generic;
using System.Linq;

public class SimpleAnimator : MonoBehaviour {
    #region Public Properties
    [System.Serializable]
    public class Anim {
        public string name;
        public Sprite[] frames;
        public float framesPerSec = 5;
        public bool loop = true;

        public float duration {
            get {
                return frames.Length * framesPerSec;
            }
            set {
                framesPerSec = value / frames.Length;
            }
        }
    }
    public List animations = new List();

    [HideInInspector]
    public int currentFrame;
    
    [HideInInspector]
    public bool done {
        get { return currentFrame >= current.frames.Length; }
    }
    
    [HideInInspector]
    public bool playing {
        get { return _playing; }
    }

    #endregion
    //--------------------------------------------------------------------------------
    #region Private Properties
    SpriteRenderer spriteRenderer;
    Anim current;
    bool _playing;
    float secsPerFrame;
    float nextFrameTime;
    
    #endregion
    //--------------------------------------------------------------------------------
    #region Editor Support
    [ContextMenu ("Sort All Frames by Name")]
    void DoSort() {
        foreach (Anim anim in animations) {
            System.Array.Sort(anim.frames, (a,b) => a.name.CompareTo(b.name));
        }
        Debug.Log(gameObject.name + " animation frames have been sorted alphabetically.");
    }
    #endregion
    //--------------------------------------------------------------------------------
    #region MonoBehaviour Events
    void Start() {
        spriteRenderer = GetComponentInChildren<SpriteRenderer>();
        if (spriteRenderer == null) {
            Debug.Log(gameObject.name + ": Couldn't find SpriteRenderer");
        }

        if (animations.Count > 0) PlayByIndex(0);
    }
    
    void Update() {
        if (!_playing || Time.time < nextFrameTime || spriteRenderer == null) return;
        currentFrame++;
        if (currentFrame >= current.frames.Length) {
            if (!current.loop) {
                _playing = false;
                return;
            }
            currentFrame = 0;
        }
        spriteRenderer.sprite = current.frames[currentFrame];
        nextFrameTime += secsPerFrame;
    }
    
    #endregion
    //--------------------------------------------------------------------------------
    #region Public Methods
    public void Play(string name) {
        int index = animations.FindIndex(a => a.name == name);
        if (index < 0) {
            Debug.LogError(gameObject + ": No such animation: " + name);
        } else {
            PlayByIndex(index);
        }
    }
    
    public void PlayByIndex(int index) {
        if (index < 0) return;
        Anim anim = animations[index];
        
        current = anim;
        
        secsPerFrame = 1f / anim.framesPerSec;
        currentFrame = -1;
        _playing = true;
        nextFrameTime = Time.time;
    }
    
    public void Stop() {
        _playing = false;
    }
    
    public void Resume() {
        _playing = true;
        nextFrameTime = Time.time + secsPerFrame;
    }
    
    #endregion
}
Listing 1-1: SimpleAnimator.cs

This class is boilerplate code that would not be specific to any particular project; you could drop it in and use it anywhere, just like the built-in Unity animation components.  So, while it's a bit over 100 lines of code, we won't include that when considering the complexity of each solution.

In the editor, this component appears as in the image below.  By selecting the game object (Surge-Simple in this case) and locking the inspector with the small padlock at top right, you can select a number of sprites from the project browser, and drag them all in at once to one of the "Frames" slots in the animations.  If Unity happens to mess up the order, the script provides a contextual menu command on the component to resort the frames by name.  All this makes it extremely quick to define all the animations needed.

Simple animation scene layout

You can also see in the screenshot that the game object contains two sub-objects, HeadCollider and BodyCollider, each with a Collider2D component.  While not necessary for this demo, in a real game these colliders would be crucial to interacting with the environment and detecting hits.  So I wanted to include them to see how well that would work.

Unfortunately, our SimpleAnimator class only changes the sprite images over time; it provides no facility for animating transforms or affecting child objects in any way.  So I had to position the colliders in such a way as to be "close enough" for any image that might be used.  This turned out to be a pretty poor fit in some cases, especially while running, as you can see in the picture below.

Collision boxes on Surge while running

Forging ahead, the next thing needed was a controller class to read the control inputs, and move and animate the sprite appropriately.  I went with a simple code-based state machine approach, combined with a very simple physics model.

The code defines six states: idle, running right, running left, jumping up, jumping (i.e. falling) down, and landing.  A straightforward method handles entering a state, while another handles continuing a state.  This code was all very easy to write and seemed easy to extend and maintain as I built up the behavior.

The only tricky bit was perhaps the UpdateTransform method, which is responsible for moving the sprite based on its state, internal variables such as velocity and whether it's grounded, and the control inputs.  This is the code that makes the sprite accelerate smoothly when you start running, skid to a stop when you stop, and influence its jump height and direction.

The complete SimpleCharController class came to about 200 lines of code, shown below.


using UnityEngine;
using UnityEngine.Events;
using System.Collections.Generic;

public class SimpleCharController : MonoBehaviour {
    #region Public Properties
    public float runSpeed = 5;
    public float acceleration = 20;
    public float jumpSpeed = 5;
    public float gravity = 15;
    public Vector2 influence = new Vector2(5, 5);

    #endregion
    //--------------------------------------------------------------------------------
    #region Private Properties
    SimpleAnimator animator;
    Vector3 defaultScale;
    float groundY;
    bool grounded;
    float stateStartTime;

    float timeInState {
        get { return Time.time - stateStartTime; }
    }

    const string kIdleAnim = "Idle";
    const string kRunAnim = "Run";
    const string kJumpStartAnim = "JumpStart";
    const string kJumpFallAnim = "JumpFall";
    const string kJumpLandAnim = "JumpLand";

    enum State {
        Idle,
        RunningRight,
        RunningLeft,
        JumpingUp,
        JumpingDown,
        Landing
    }
    State state;
    Vector2 velocity;
    float horzInput;
    bool jumpJustPressed;
    bool jumpHeld;
    int airJumpsDone = 0;

    #endregion
    //--------------------------------------------------------------------------------
    #region MonoBehaviour Events
    void Start() {
        animator = GetComponent<Animator>();
        defaultScale = transform.localScale;
        groundY = transform.position.y;
    }
    
    void Update() {
        // Gather inputs
        horzInput = Input.GetAxisRaw("Horizontal");
        jumpJustPressed = Input.GetButtonDown("Jump");
        jumpHeld = Input.GetButton("Jump");

        // Update state
        ContinueState();

        // Update position
        UpdateTransform();
    }

    #endregion
    //--------------------------------------------------------------------------------
    #region Private Methods
    void SetOrKeepState(State state) {
        if (this.state == state) return;
        EnterState(state);
    }

    void ExitState() {
    }

    void EnterState(State state) {
        ExitState();
        switch (state) {
        case State.Idle:
            animator.Play(kIdleAnim);
            break;
        case State.RunningLeft:
            animator.Play(kRunAnim);
            Face(-1);
            break;
        case State.RunningRight:
            animator.Play(kRunAnim);
            Face(1);
            break;
        case State.JumpingUp:
            animator.Play(kJumpStartAnim);
            velocity.y = jumpSpeed;
            break;
        case State.JumpingDown:
            animator.Play(kJumpFallAnim);
            break;
        case State.Landing:
            animator.Play(kJumpLandAnim);
            airJumpsDone = 0;
            break;
        }

        this.state = state;
        stateStartTime = Time.time;
    }

    void ContinueState() {
        switch (state) {
        
        case State.Idle:
            RunOrJump();
            break;
        
        case State.RunningLeft:
        case State.RunningRight:
            if (!RunOrJump()) EnterState(State.Idle);
            break;

        case State.JumpingUp:
            if (velocity.y < 0) EnterState(State.JumpingDown);
            if (jumpJustPressed && airJumpsDone < 1) {
                EnterState(State.JumpingUp);
                airJumpsDone++;
            }
            break;
        
        case State.JumpingDown:
            if (grounded) EnterState(State.Landing);
            if (jumpJustPressed && airJumpsDone < 1) {
                EnterState(State.JumpingUp);
                airJumpsDone++;
            }
            break;

        case State.Landing:
            if (timeInState > 0.2f) EnterState(State.Idle);
            else if (timeInState > 0.1f) RunOrJump();
            break;
        }
    }

    bool RunOrJump() {
        if (jumpJustPressed && grounded) SetOrKeepState(State.JumpingUp);
        else if (horzInput < 0) SetOrKeepState(State.RunningLeft);
        else if (horzInput > 0) SetOrKeepState(State.RunningRight);
        else return false;
        return true;
    }


    void Face(int direction) {
        transform.localScale = new Vector3(defaultScale.x * direction, defaultScale.y, defaultScale.z);
    }

    void UpdateTransform() {

        if (grounded) {
            float targetSpeed = 0;
            switch (state) {
            case State.RunningLeft:
                targetSpeed = -runSpeed;
                break;
            case State.RunningRight:
                targetSpeed = runSpeed;
                break;
            }
            velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed, acceleration * Time.deltaTime);
        } else {
            // vertical influence directly counteracts gravity
            if (jumpHeld) velocity.y += influence.y * Time.deltaTime;

            // horizontal influence is an acceleration towards the target speed
            // (just like when running, but the acceleration should be much lower)
            float targetSpeed = horzInput * runSpeed;
            velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed, influence.x * Time.deltaTime);
        }
        velocity.y -= gravity * Time.deltaTime;

        Vector3 newPos = transform.position + (Vector3)(velocity * Time.deltaTime);
        if (newPos.y < groundY) {
            newPos.y = groundY;
            velocity.y = 0;
            grounded = true;
        } else grounded = false;
        transform.position = newPos;
    }

    #endregion
}
Listing 1-2: SimpleCharController.cs


2. Code-Driven Animator Approach

The second approach made more use of Unity's built-in animation support.  The goal with this one was to leverage Unity's editor for animations, while still keeping all the game logic in code.

Unfortunately, you can't even edit an Animation asset in Unity without also having an AnimationController asset, as well as an Animator component on your game object.  The way it all works together is not entirely obvious at the outset, so here's a quick rundown:

  • An Animation is an asset that lives in your project.  But the Inspector for these is very minimal; it lets you determine whether and how the animation should loop, and preview the animation, but that's about it.  You can't edit the animation frames themselves here.

  • Animator is a component that must live on a game object.  It has a Controller property, of type AnimationController.

  • AnimationController is another asset that lives in your project.  This is what defines the state machine, where each state may reference a particular Animation (as well as other things, as we'll see).

  • The Animation window lets you edit Animation assets.  But it doesn't show all Animation assets in your project.  Instead, you can only view and edit animations which are in use by the states of the AnimationController which is referenced by the Animator component of the currently selected game object.

  • Finally, there is also an Animator window, which lets you edit the AnimationControllers.

So, all that means: to edit an animation, you must have it hooked up to an AnimationController which is in use by the selected game object.

So I created an AnimationController, dragged it into my game object (called Surge-CodeDriven in this case); then created animations, and dragged them into the Animator window, where they became new states.  I didn't bother to create any transitions, or actually use the state machine here in any way; these were just hoops I had to jump through to get my animations defined.  See the image below.

Code-driven animation setup

However, there is another reason why the Animator component, and AnimationController asset, are necessary: they are the only way to play a modern Unity animation.  The Play method of the Animator class lets you specify one of the states by name (or name hash), and it invokes that state immediately, without needing or using any transitions in the state machine.

So, with all this set up, I was able to copy the code from the first approach almost verbatim, simply invoking the Animator component to play animations rather than my own SimpleAnimator class.  I call this the "code-driven animator" approach, because the animator is driven entirely by code, rather than by any transitions or logic in the animation controller itself.  This version of the controller script is shown below.


using UnityEngine;
using UnityEngine.Events;
using System.Collections.Generic;

public class CodeCharController : MonoBehaviour {
    #region Public Properties
    public float runSpeed = 4;
    public float acceleration = 20;
    public float jumpSpeed = 5;
    public float gravity = 15;
    public Vector2 influence = new Vector2(5, 5);
    public AudioClip[] sounds;

    #endregion
    //--------------------------------------------------------------------------------
    #region Private Properties
    Animator animator;
    AudioSource audioSource;
    Vector3 defaultScale;
    float groundY;
    bool grounded;
    float stateStartTime;
    
    float timeInState {
        get { return Time.time - stateStartTime; }
    }

    const string kIdleAnim = "Idle";
    const string kRunAnim = "Run";
    const string kJumpStartAnim = "JumpStart";
    const string kJumpFallAnim = "JumpFall";
    const string kJumpLandAnim = "JumpLand";
    
    enum State {
        Idle,
        RunningRight,
        RunningLeft,
        JumpingUp,
        JumpingDown,
        Landing
    }
    State state;
    Vector2 velocity;
    float horzInput;
    bool jumpJustPressed;
    bool jumpHeld;
    int airJumpsDone = 0;
    
    #endregion
    //--------------------------------------------------------------------------------
    #region MonoBehaviour Events
    void Start() {
        animator = GetComponent<Animator>();
        audioSource = GetComponent<AudioSource>();
        defaultScale = transform.localScale;
        groundY = transform.position.y;
    }
    
    void Update() {
        // Gather inputs
        horzInput = Input.GetAxisRaw("Horizontal");
        jumpJustPressed = Input.GetButtonDown("Jump");
        jumpHeld = Input.GetButton("Jump");
        
        // Update state
        ContinueState();
        
        // Update position
        UpdateTransform();
    }
    
    #endregion
    //--------------------------------------------------------------------------------
    #region Public Methods
    public void PlaySound(string name) {
        if (!audioSource.enabled) return;
        foreach (AudioClip clip in sounds) {
            if (clip.name == name) {
                audioSource.clip = clip;
                audioSource.Play();
                return;
            }
        }
        Debug.LogWarning(gameObject + ": AudioClip not found: " + name);
    }
    #endregion
    //--------------------------------------------------------------------------------
    #region Private Methods
    void SetOrKeepState(State state) {
        if (this.state == state) return;
        EnterState(state);
    }
    
    void ExitState() {
    }
    
    void EnterState(State state) {
        ExitState();
        switch (state) {
        case State.Idle:
            animator.Play(kIdleAnim);
            break;
        case State.RunningLeft:
            animator.Play(kRunAnim);
            Face(-1);
            break;
        case State.RunningRight:
            animator.Play(kRunAnim);
            Face(1);
            break;
        case State.JumpingUp:
            animator.Play(kJumpStartAnim);
            velocity.y = jumpSpeed;
            break;
        case State.JumpingDown:
            animator.Play(kJumpFallAnim);
            break;
        case State.Landing:
            animator.Play(kJumpLandAnim);
            airJumpsDone = 0;
            break;
        }
        
        this.state = state;
        stateStartTime = Time.time;
    }
    
    void ContinueState() {
        switch (state) {
            
        case State.Idle:
            RunOrJump();
            break;
            
        case State.RunningLeft:
        case State.RunningRight:
            if (!RunOrJump()) EnterState(State.Idle);
            break;
            
        case State.JumpingUp:
            if (velocity.y < 0) EnterState(State.JumpingDown);
            if (jumpJustPressed && airJumpsDone < 1) {
                EnterState(State.JumpingUp);
                airJumpsDone++;
            }
            break;
            
        case State.JumpingDown:
            if (grounded) EnterState(State.Landing);
            if (jumpJustPressed && airJumpsDone < 1) {
                EnterState(State.JumpingUp);
                airJumpsDone++;
            }
            break;
            
        case State.Landing:
            if (timeInState > 0.2f) EnterState(State.Idle);
            else if (timeInState > 0.1f) RunOrJump();
            break;
        }
    }
    
    bool RunOrJump() {
        if (jumpJustPressed && grounded) SetOrKeepState(State.JumpingUp);
        else if (horzInput < 0) SetOrKeepState(State.RunningLeft);
        else if (horzInput > 0) SetOrKeepState(State.RunningRight);
        else return false;
        return true;
    }
    
    
    void Face(int direction) {
        transform.localScale = new Vector3(defaultScale.x * direction, defaultScale.y, defaultScale.z);
    }
    
    void UpdateTransform() {
        
        if (grounded) {
            float targetSpeed = 0;
            switch (state) {
            case State.RunningLeft:
                targetSpeed = -runSpeed;
                break;
            case State.RunningRight:
                targetSpeed = runSpeed;
                break;
            }
            velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed, acceleration * Time.deltaTime);
        } else {
            // vertical influence directly counteracts gravity
            if (jumpHeld) velocity.y += influence.y * Time.deltaTime;
            
            // horizontal influence is an acceleration towards the target speed
            // (just like when running, but the acceleration should be much lower)
            float targetSpeed = horzInput * runSpeed;
            velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed, influence.x * Time.deltaTime);
        }
        velocity.y -= gravity * Time.deltaTime;
        
        Vector3 newPos = transform.position + (Vector3)(velocity * Time.deltaTime);
        if (newPos.y < groundY) {
            newPos.y = groundY;
            velocity.y = 0;
            grounded = true;
        } else grounded = false;
        transform.position = newPos;
    }
    
    #endregion
}
Listing 2-1: CodeCharController

So, after all that extra setup, I ended up using nearly identical code.  But the Unity animation editor offers two big advantages.  First, you can animate virtually any property of the target game object, or any of its child objects.  This includes, for example, adjusting the colliders so that they always fit the sprite image, as shown below.

Colliders animated to better fit the sprite image.

Second, you can attach an "animation event" to any frame of an animation.  This is simply a callback invoked on the object hosting the Animator.  It's perfect for things like playing sounds in sync with the animation.  The image below shows how this is used to play a footstep sound.

Invoking a sound via an Animation Event

To support this, the CodeCharController includes an array of AudioClip, and a simple PlaySound method that plays one of them by name.  (I put this function on the same script to keep this article simple; in a real project, we would probably have a separate sound-player script.)

So, while setting up the animations with this approach takes considerably longer than with SimpleAnimator, it's also substantially more powerful.


3. Animator-Driven Code Approach

The final approach tried is the opposite of the last one; as much as possible of the game logic is placed in the animator controller state machine, and code is kept to the bare minimum needed to support this.  This "animation-driven code" approach is going all-in for the Unity way, perhaps even more than most people do.  But remember, this whole journey began with a desire to avoid dividing the game logic between two different places.  So, if we're going to use Unity's state machine editor at all, we want to use it fully.

Initial setup for this approach is very similar to the last one; we need to add an Animator component to our game object, and an AnimationController asset to hold the state diagram.  Then we can define Animation assets for each animation the sprite needs — and in fact, I was able to reuse the animations from the code-driven approach.

Because the state transitions are driven largely by inputs, the first task is to get inputs into the animation controller.  This is pretty easy: you just define a parameter for each input needed (InputH, InputJumpJustPressed, and InputJumpHeld), and then attach a little script like the following to copy values from the input system to the animator component.


using UnityEngine;
using System.Collections.Generic;

public class AnimInputRelay : MonoBehaviour {
    #region Private Properties
    Animator animator;

    #endregion
    //--------------------------------------------------------------------------------
    #region MonoBehaviour Events
    void Start() {
        animator = GetComponent<Animator>();
    }
    
    void Update() {
        animator.SetInteger("InputH", Mathf.RoundToInt(Input.GetAxisRaw("Horizontal")));
        animator.SetBool("InputJumpHeld", Input.GetButton("Jump"));
        animator.SetBool("InputJumpJustPressed", Input.GetButtonDown("Jump"));
    }
    #endregion
}
Listing 3-1: AnimInputRelay.cs

Now we can use these input values to control transitions in the state diagram.  However, some parts of the character that were easy in the two code-driven approaches were a bit thornier (or at least, less obvious) with this animator-driven approach.  These included getting instant transitions, flipping the sprite, handling movement (on the ground), handling influence (in the air), and dealing with double jumps (i.e. a second jump while in the air).

3.1. Instant Transitions

Every transition in Unity has an optional "Exit Time," which is how long the state machine must stay in the source state before it will even look at the exit conditions.  This defaults to the length of the associated animation.  So if you've got, say, a run cycle, then (by default) your transitions will not kick in until at least one full cycle has played.

In addition, each transition has a Transition Duration which controls the speed at which the animation from the previous state is blended with the animation from the next state.  This is great when you're dealing with bone-based mesh deformation, but when your animation is a series of sprite frames, this blending is fruitless, and merely makes the transition slower than it needs to be.

Put together, this means that every transition you create in a sprite-based game like this one will feel sluggish until you uncheck the "Has Exit Time" checkbox, and set the Transition Duration (under the Settings group) to zero, as shown in the image here.

Proper transition settings when using sprite animation

Unfortunately, forgetting to change these settings is an easy mistake to make, and can be hard to catch.  Several times I found that my animation-driven character was just not reacting the same way as the other two, and I had to go through all my transitions, checking each one until I found where I had failed to change these values.

3.2. Flipping the Sprite

The Surge sprite sheet has only right-facing images; when the character needs to face to the left, we need to scale it by -1 on the X axis.  The two previous approaches did this in code at the same time they entered the RunLeft or RunRight states.  With the animator-driven approach, I needed a different method.

This is where I made the biggest mistake in this project.  At first, I handled making the sprite face left or right by making two versions of each animation: IdleLeft and IdleRight, for example, and including the transform scale in the animation itself, so that I could scale X by -1 to flip the images to face left.  This doubled the number of animations, as well as the number of states in the state machine, and more than doubled the number of transitions.  While it worked, it was uncomfortably complex even when only handling idle and run, and by the time I started working on jumps, I was ready to look for better options.

The better option was to create a state behavior, which is a little script you can attach to an AnimationController state, to be invoked when the state is entered, updates each frame, or exits.  I made a very general ScaleSetter script (listing below), and eliminated all the left/right duplicates except for the Run state.  To RunLeft, I attached a ScaleSetter that scaled X by -1; to RunRight, I attached one that scaled X by 1.  See the image below the listing.


using UnityEngine;
using System.Collections;

public class ScaleSetter : StateMachineBehaviour {

    public Vector3 scale = Vector3.one;

    override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        animator.transform.localScale = scale;
    }
    
}
Listing 3-2: ScaleSetter.cs

Use of a custom state behavior to flip the sprite

That took care of flipping the sprites in a nice general way.

3.3. Character Movement

The alert reader may have noticed in the previous screen shot that there are two behaviors attached to the RunLeft state.  In addition to ScaleSetter, which was discussed above, there's also something called AnimCharSpeedSetter.  What's that all about?

This was my solution to making the character move, while still being under the control of the state machine as much as possible.

The basic idea here is to still have a character controller script, but instead of containing any state logic, this mainly handles character physics.  It tells the animator state machine things like how fast the character is moving and whether it is grounded, and the animator in turn tells the script what its speed goal should be, whether it should jump, and so on.

The character script is about half as long as it was in the previous approaches, as shown below.

using UnityEngine;
using UnityEngine.Events;
using System.Collections.Generic;

public class AnimCharController : MonoBehaviour {
    #region Public Properties

    public float runSpeed = 5;
    public float acceleration = 50;
    public float jumpSpeed = 5;
    public float gravity = 10;
    public Vector2 influence = new Vector2(5, 5);
    public AudioClip[] sounds;

    #endregion
    //--------------------------------------------------------------------------------
    #region Private Properties
    Animator animator;
    AudioSource audioSource;

    float targetSpeed = 0;
    Vector2 velocity;
    float groundY;
    bool grounded;
    Vector2 applyInfluence;

    #endregion
    //--------------------------------------------------------------------------------
    #region MonoBehaviour Events
    void Start() {
        animator = GetComponent<Animator>();
        audioSource = GetComponent<AudioSource>();
        groundY = transform.position.y;
    }

    void Update() {
        // Update position
        UpdateTransform();

        // Update the animator with things likely to change almost every frame
        animator.SetFloat("VelocityY", velocity.y);
    }

    #endregion
    //--------------------------------------------------------------------------------
    #region Public Methods
    public void SetSpeedTarget(float fraction) {
        targetSpeed = runSpeed * fraction;
    }

    public void StartJump() {
        velocity.y = jumpSpeed;
    }

    public void ApplyInfluenceH(float fraction) {
        applyInfluence.x = fraction;
    }

    public void ApplyInfluenceV(float fraction) {
        applyInfluence.y = fraction;
    }

    public void SetGrounded(bool grounded) {
        this.grounded = grounded;
        animator.SetBool("Grounded", grounded);
    }

    public void PlaySound(string name) {
        if (!audioSource.enabled) return;
        foreach (AudioClip clip in sounds) {
            if (clip.name == name) {
                audioSource.clip = clip;
                audioSource.Play();
                return;
            }
        }
        Debug.LogWarning(gameObject + ": AudioClip not found: " + name);
    }

    #endregion
    //--------------------------------------------------------------------------------
    #region Private Methods

    void UpdateTransform() {
        if (grounded) {
            velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed, acceleration * Time.deltaTime);
        } else {
            // vertical influence directly counteracts gravity
            velocity.y += applyInfluence.y * influence.y * Time.deltaTime;
            
            // horizontal influence is an acceleration towards the target speed
            // (just like when running, but the acceleration should be much lower)
            float targetSpeed = applyInfluence.x * runSpeed;
            velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed, influence.x * Time.deltaTime);
        }
        velocity.y -= gravity * Time.deltaTime;
        
        Vector3 newPos = transform.position + (Vector3)(velocity * Time.deltaTime);

        if (newPos.y < groundY) {
            newPos.y = groundY;
            velocity.y = 0;
            if (!grounded) SetGrounded(true);
        } else if (grounded) SetGrounded(false);

        transform.position = newPos;

    }

    #endregion
}
Listing 3-3: AnimCharController.cs

However, the state machine can't directly invoke methods on this script.  There are two ways to do that.  One is to use an animation event, just like we did for playing sounds before (and also do for this version).  In the jump animation, I added an animation event that invokes a "StartJump" method, which must be found on some component attached to the same GameObject as the animator.  Our AnimCharController implements this method to initiate a jump, by instantaneously setting the Y velocity to a the jump speed value.

The second way is to attach a behavior script to a state.  This is the AnimCharSpeedSetter that was pointed out before.  Its job is to find the AnimCharController component, and call its SetSpeedTarget method to let that script know how fast (and in which direction) the character should be running.  This state behavior is shown below.


using UnityEngine;
using System.Collections;

public class AnimCharSpeedSetter : StateMachineBehaviour {

    [Range(-1, 1)]
    public float speedTarget;

    override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        AnimCharController acc = animator.gameObject.GetComponent<AnimCharController>();
        acc.SetSpeedTarget(speedTarget);
    }
    
}
Listing 3-4: AnimCharSpeedSetter.cs

This behavior was attached to the RunLeft and RunRight states with a speed target of -1 and 1 respectively, as well as to the Idle state with a speed target of 0.

Note that a custom state behavior was necessary here because an animation event can't send a different value depending on where (in which state) the animation is used.  We have only one "run" animation, which is used by both RunLeft and RunRight.  So an animation event couldn't work for this.  State behaviors, on the other hand, are attached to particular states, so we can send a different speed target in each case.

3.4. Influence

Influence is the ability of a player to have some control over which way a character moves while in the air.  The character still more or less traces out a parabola, but the player can influence the shape of this parabola with their jump and directional inputs.

In the first two approaches, influence was just a couple extra lines of code in the UpdateTransform method that made direct use of the control inputs.  With the animator-driven approach, we faced the same problem as with running.  Moreover, I couldn't just call the same SetSpeedTarget method, because influence is handled differently from running, and so needs a separate API.  In addition, while target speed is something we only need to set when entering a running state, influence needs to be updated on every frame, so the player can influence their direction throughout the arc.

So, I made another state behavior script whose job it was to get the inputs from the animator parameters, and pass them on to the character script on every frame:

using UnityEngine;
using System.Collections;

public class AnimCharInfluenceUpdater : StateMachineBehaviour {

    public bool applyInputsToInfluence = true;

    override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        AnimCharController acc = animator.gameObject.GetComponent<AnimCharController>();
        if (applyInputsToInfluence) {
            acc.ApplyInfluenceH(animator.GetInteger("InputH"));
            acc.ApplyInfluenceV(animator.GetBool("InputJumpHeld") ? 1 : 0);
        } else {
            acc.ApplyInfluenceH(0);
            acc.ApplyInfluenceV(0);
        }
    }
}
Listing 3-6: AnimCharInfluenceUpdater.cs

This script was attached to all four jump states, so that influence would be applied while in any of those.

3.5. Air Jumps

Finally, we want Surge to be able to do a double jump, that is, a second jump (but no more than that!) while already in the air.  The first two approaches managed this with a simple "airJumpsDone" counter that is incremented on each jump, and checking of that counter when deciding whether to allow another one.

I was able to do something very similar in the animation controller state machine, but it required an extra state, and yet another state behavior.  This new state behavior is at least general; it allows you to set any integer parameter to any value, upon entering the state to which it is attached.  See the listing below.

using UnityEngine;
using System.Collections;

public class AnimatorSetIntParam : StateMachineBehaviour {

    public string parameterName;
    public int value;

    override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        animator.SetInteger(parameterName, value);
    }
    
}
Listing 3-7: AnimatorSetIntParam.cs

So using this, I was able to set an "AirJumps" parameter in the state machine to 0 whenever we enter the JumpLand state, which gets things back to normal.  Now, incrementing this parameter would have required another script; the above can only set a parameter to a specific value.  If we needed to support, say, two air jumps in a row, then this would have been necessary.  But since we only support one, I was able to cheat by making a second state exactly like JumpStart (which covers from the start of the jump to the peak, before you start falling).  This new state is called AirJump, and the logic works like this:

  1. When entering the AirJump state, set AirJumps to 1.

  2. Transition from JumpStart or JumpFall to AirJump is allowed only if AirJumps=0.

  3. When entering the JumpLand state, set AirJumps to 0.

So the net effect is, you can enter the AirJump state (which replays the jump animation, invoking StartJump as an animation event) only if you haven't already been there since your last landing.

The figure below shows the state machine, with the transition from JumpFall to AirJump selected.

State machine transition from JumpFall to AirJump

4. Discussion

Coding the same behavior with three different approaches was a very enlightening project.  All three approaches are capable of doing the job, but there are clear pros and cons.  Here are some general observations.

First, the simple animator class was great for quickly snapping together animations out of a large collection of frames, with no fuss or fiddling around.  However, it doesn't easily allow for animating transforms or colliders, nor does it make it especially easy to sync sound effects with the animation (although you could do this in code, since you have access to the current frame number).  For something like a simple 2D space shooter, where the game objects are fairly simple, it might be a great approach.  But it doesn't hold up very well to the demands of something more sophisticated, like a fighting game character.

In the second approach, I used Unity's animation editor but completely ignored the state machine editor, instead triggering states (that wrap animations) directly from code.  This worked really well.  The code was easy to write, extend, and maintain, but we still get all the power of the animation editor, including transforming colliders and triggering sound effects.  I feel confident that either of these first two approaches would scale comfortably to more states and more animations, as would be needed in a real brawler.

The third approach, embracing the state machine editor as much as possible, turned out to be disappointing for several reasons.  First, it didn't end up actually reducing the code much; when you add up just the project-specific scripts (AnimCharController, AnimInputRelay, AnimCharInfluenceUpdater, and AnimCharSpeedSetter), it comes to about 165 lines, compared to 216 lines for the CodeCharController.  That's about a 25% reduction.

The cost of that reduction is significantly more time spent fiddling with the editor: creating states, dragging transitions, remembering to zero out the exit and transition times, looking for ways to arrange the states neatly in the window, creating parameters and scripts to use those parameters, and so on.  While I didn't actually time myself (as in reality, I developed all three approaches in parallel), my impression is that the animator-driven approach took at least twice as long as the other two.  This is due in part to the fact that it is very difficult to inspect the logic; you have to click on each transition in turn to see its details.  The game logic is much easier and faster to read (and thus extend or debug) in code.

Moreover, if you run the demo, you will find that while the first two characters (using the simple and code-driven approaches) stay in perfect synchrony, the animator-driven character does not.  Despite having all the same acceleration and speed parameters, and all transitions set to mimic the code as closely as possible, the animation-driven character reacts differently.  I suspect this may be because of the extra step of a script that copies the input values to the state machine, which then acts on those values, perhaps as much as a frame later.

Also, very occasionally, the anim-driven character will completely miss an air jump that the other two catch.  I don't know why and can't reproduce it at will, but it definitely happens.

Finally, the state machine for even this simple run/jump demo is seven states and 18 transitions, which I was just barely able to arrange in a reasonably readable way.  For a full game, with many more states and animations, I fear the state machine would become a tangled mess.  While Unity does provide some tools for taming such a jungle, such as sub-state machines, those can only help so much.

I should emphasize here that I have very gladly made use of the state machine editor in projects with 3D characters, where animation blending and blend trees (which combine two simultaneous animations) are crucial and very difficult to accomplish in any other way.

But for a 2D game based on hand-drawn sprite frames, the animator controller seems like an ill fit.  For very simple games, I might use the SimpleAnimator class, and for anything more sophisticated, I would choose the middle approach: animations thrown into a controller and triggered directly from code, exactly when you want them.

If you have the Unity web browser plug-in, you can play around with the demo here.  And you can download the project source, including all three approaches, here.

Read more about:

Featured Blogs

About the Author(s)

Daily news, dev blogs, and stories from Game Developer straight to your inbox

You May Also Like