informa
/
Programming
Featured Blog

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.

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;
           &a

Latest Jobs

Sucker Punch Productions

Bellevue, Washington
08.27.21
Combat Designer

Xbox Graphics

Redmond, Washington
08.27.21
Senior Software Engineer: GPU Compilers

Insomniac Games

Burbank, California
08.27.21
Systems Designer

Deep Silver Volition

Champaign, Illinois
08.27.21
Senior Environment Artist
More Jobs   

CONNECT WITH US

Register for a
Subscribe to
Follow us

Game Developer Account

Game Developer Newsletter

@gamedevdotcom

Register for a

Game Developer Account

Gain full access to resources (events, white paper, webinars, reports, etc)
Single sign-on to all Informa products

Register
Subscribe to

Game Developer Newsletter

Get daily Game Developer top stories every morning straight into your inbox

Subscribe
Follow us

@gamedevdotcom

Follow us @gamedevdotcom to stay up-to-date with the latest news & insider information about events & more