Today we dive deeper into the Animator Controller, which operates much the same way as our state engine, transitioning us between clips based upon parameters that we will set via scripts. We also look at how to animate properties beyond the Sprites themselves.
Yesterday, we made made animations by selecting sprites that would play in sequence. We also used the created moving platforms by adjusting the values of the position over time. Today, we will combine those methods to create new animations clips using Sprites in the Animation window, and to generate behaviors as we transition between those clips.
Part 1: Animating Properties
Our enemies here are relatively simple, and they only need to have two states – walking, and dying. Our first step is to create our walking animation, which we will do from the sprites included in the sprite sheet.
Since our enemy object already exists, and the prefab has been placed into multiple places, it would be easier for us to add the sprite animation to the existing object. We open the prefab, select the enemy object, and open our Animation Window. You should see a message indicating that there is no Animator or Animation Clip associated yet, and a button to let you create those. Click that button, and give your walking clip a name (and location, if you’re trying to keep your file structure neat).
Now this will create an empty Animation Clip. From here, the sprite animation is easy to create. We can simply grab the sprites from the Asset Folder and drag them directly into the Dopesheet on the Animation Window. This will create a sprite animation.
If you play this, you will notice that it moves very fast. That is because Unity by default creates animation clips to run at a sample rate of 60, meaning it holds 60 timing frames per second. Since we are dealing with sprites, turn this setting down to 12, which is the default setting for sprite animation.
Next, create a new clip that will be our “dying” clip. Here, we are going to use the Animation Clip to manage a few properties for us, saving us the trouble of having to make these happen through programming. Our intended behavior is that when an enemy is hit, it will stop moving, blink rapidly, then disappear. During this time, it’s colliders should also be turned off so that the player cannot be affected by accident.
Inside of our empty dying clip, I add new properties, specifically the “enabled” property for both colliders. This will start them with a keyframe at the first and last frame, which I will set to be unchecked. Next I will add the Sprite Renderer > Enabled property, and set keyframes every few frames to cycle this property on and off quickly to create a blinking movement.
Next, I set up the clips in my Animator to move from Walking to Dying only when a particular trigger is called (I’ve called mine “Dying”).
Lastly, I make some modifications in my script by setting the rigidbody to isKinematic so that it will no longer move, and setting the velocity to 0, calling the SetTrigger( ) function from the Animator, and putting a 1 second delay on the Destroy( ) call so that the death animation has time to run.
Part 2: Player Animation States
The principles behind our player animation are very similar to those of our Enemy object, except now we have many more clips to move between. For this section, I highly recommend watching the video walkthrough, as a writeup will not do the process justice. However, here is a brief summary of the various animation states we attach to the player object.
We set up parameters to assist with the various conditions that we will want to cause a transition to occur. These include:
- speed: a float value that will determine if our character should walk, run, or stand idle.
- isOnGround: a boolean that we will use to determine if our player is mid-air or grounded.
- Death: a trigger that we will call as events to move us between states
Above is our finished controller, with transitions in place. Here is how I have them configured:
- Entry: This step is automatic. Anytime the animation controller is enabled, or if another animation passes through the “Exit” state we will reset to here. Immediately moves to the default state, “player_idle” which holds our idle animation.
- Idle/Walk: uses the “speed” float as a condition to move between states. If we are Idle and speed is > 0.1 we will move to “player_walk”. If the current state is Walk and speed is < 0.1 we will move back to “player_idle”.
- Walk/Run: Similar to Idle/Walk, transition occurs when the speed crosses a value of 0.9
- Jump: All three of these transitions will use the “grounded” boolean, which we will populate from the “isGrounded” property of our character controller. A value of “false” will trigger this transition.
- Exit from Jump: Rather than creating multiple return paths and testing each path for speed values inside a range, we end our jump by exiting the animation when “isOnGround” equals “true”. This sends us back to the “Entry” state, which moves us to Idle and then evaluates based on our current speed to arrive at the correct position.
- Death: Because we may die at any time, we build the transition to the “Death” animation from the “Any State”, and set the condition to the “Death” trigger.
- Exit from Death: We did not use this path this year, but in past years I have also set a trigger “PlayerRevive” to act as a singular event that can move us out of the “Death” state. By going to Exit, we re-enter the flow at the “Entry” point and our animation starts again. However, this only makes sense if we are going to have our player object continue to exist. Since we are likely going to reset by destroying and instantiating, or by simply reloading a scene, this is extraneous and so we did not use it this year.
For all sprite transitions, I recommend turning off the Has Exit Time checkbox and setting the Transition Duration to 0 to assure instant transition from one clip to the next.
Animations can be looped by selecting the individual animation in the Asset Window and checking the Loop Time box in the Inspector. Our “Death” animation does NOT loop, as we do not want to die over and over again.
Part 3: Connecting the Parameters
Now we need to send values into the parameters. Previously, we have only used the SetTrigger( ) command, which is similar to calling a function. And because this was a one-time event, we simply ran the GetComponent<Animator> method in-line.
But now we want to submit regular updates to our Animator, indicating our current speed (for the walk cycle) and our grounded state (for the “jump” clip).
We first define an Animator type variable, and assign our component in the Start( ) command, so that we have a steady reference to it.
private Animator animator; ... void Start() { ... animator = GetComponent<Animator>(); }
Next, we pass in our values – “moveDirection” for the speed and “isGrounded” for the grounding status. Previously we used Input.GetAxisRaw for movement, but because we want to have our character’s speed ramp up, we changed our input to Input.GetAxis. That still returns values between -1 and 1, but our speed check is only looking for numbers between 0 and 1, so we find the absolute value using Mathf.Abs( ).
We place this check inside of the Update( ) command rather than FixedUpdate( ), because we only need to update the values when the frame is preparing to draw.
void Update() { // Movement controls moveDirection = Input.GetAxis("Horizontal"); // set animation controls animator.SetFloat("speed", Mathf.Abs(moveDirection)); animator.SetBool("isOnGround", isGrounded); ... }
We use the SetFloat(string, float) command to say which float parameter, and the value (here an absolute value). We pass the grounded status with SetBool(string, bool), and this will make our character enter the jump animation whenever he leaves the ground.
Finaly, we set our Death animation to occur upon a collision with the Enemy Object, using the SetTrigger() method.
EnemyScript.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemyScript : MonoBehaviour { public float speed; public bool faceLeft = true; public bool _isAlive = true; private Rigidbody2D rb; private SpriteRenderer sprite; // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); sprite = GetComponent<SpriteRenderer>(); } // Update is called once per frame void Update() { /* if (faceLeft && sprite.flipX) { sprite.flipX = false; } else if (!faceLeft && !sprite.flipX) { sprite.flipX = true; } */ if (faceLeft == sprite.flipX) { sprite.flipX = !sprite.flipX; } } private void FixedUpdate() { if (_isAlive) { float direction = faceLeft ? -1 : 1; rb.velocity = new Vector2(direction * speed, rb.velocity.y); } } private void OnTriggerEnter2D(Collider2D collision) { if (collision.gameObject.tag == "TurnAround") { faceLeft = !faceLeft; // invert faceleft } if (collision.gameObject.tag == "Player") { EnemyHasDied(); } } private void EnemyHasDied() { // no longer alive _isAlive = false; // cue the death animation GetComponent<Animator>().SetTrigger("EnemyDied"); // destroy the instance Destroy(this.gameObject, 1.0f); // disable rigidbody rb.velocity = Vector3.zero; rb.isKinematic = true; } }
SideScrollerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] [RequireComponent(typeof(CapsuleCollider2D))] /* * SideScrollerController - created for UGE Summer 23 * Based on Sharp Coder 2D Controller (https://sharpcoderblog.com/blog/2d-platformer-character-controller) */ public class SideScrollerController : MonoBehaviour { // Player Movement Variables public float maxSpeed = 3.4f; public float jumpHeight = 6.5f; public float gravityScale = 1.5f; float moveDirection = 0; public bool isGrounded = true; public bool facingRight = true; public LayerMask groundLayer; public Transform groundCheckObject; public float colliderRadius = 0.5f; private Animator animator; // components Rigidbody2D r2d; CapsuleCollider2D mainCollider; // Transform t; // Start is called before the first frame update void Start() { // set the component variables r2d = GetComponent<Rigidbody2D>(); mainCollider = GetComponent<CapsuleCollider2D>(); // setting some component properties r2d.freezeRotation = true; r2d.collisionDetectionMode = CollisionDetectionMode2D.Continuous; r2d.gravityScale = gravityScale; // set the initial facing right value facingRight = transform.localScale.x > 0; animator = GetComponent<Animator>(); } // Update is called once per frame void Update() { // Movement controls moveDirection = Input.GetAxis("Horizontal"); // set animation controls animator.SetFloat("speed", Mathf.Abs(moveDirection)); animator.SetBool("isOnGround", isGrounded); // TO-DO: Implement Change Facing Direction if (moveDirection > 0 && !facingRight) { facingRight = true; transform.localScale = new Vector3(Mathf.Abs(transform.localScale.x), transform.localScale.y, transform.localScale.z); } if (moveDirection < 0 && facingRight) { facingRight = false; transform.localScale = new Vector3(-Mathf.Abs(transform.localScale.x), transform.localScale.y, transform.localScale.z); } // Jumping if (Input.GetButtonDown("Jump") && isGrounded){ r2d.velocity = new Vector2(r2d.velocity.x, jumpHeight); } } void FixedUpdate() { // Apply movement velocity r2d.velocity = new Vector2((moveDirection) * maxSpeed, r2d.velocity.y); // Check if player is grounded Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheckObject.position, colliderRadius, groundLayer); // check for a grounding collider isGrounded = colliders.Length > 0 ? true : false; } private void OnDrawGizmos() { // Draw a yellow sphere at the ground check position Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(groundCheckObject.position, colliderRadius); } private void OnCollisionEnter2D(Collision2D collision) { if (collision.transform.tag == "Enemy") { // Death happens here // queue the death animation animator.SetTrigger("Death"); } } }