Day 17: 2D Character Controller

Unity has a bunch of great components that make game development much easier, but one thing they are still missing is a 2D version of a Character Controller. So today we found a 2D controller that uses 2D Physics and modified it to make it our own. Let’s dive in!

Part 1: 2D Character Controller Setup

At their core, Character Controllers are rather simple, but incredibly important. They manage the important task of moving our character through our levels based upon our input, running, jumping, and not falling through the floor. And by providing situational information such as the answer to “are we on the ground?” which is a critical question when figuring out whether or not we can “jump”.

While Unity provides a 3D Character Controller component for their games, they have yet to release their own 2D controller As such, there are a significant number of controllers that others have created and published. Today, we looked at a lovely little controller script from the Sharp Coder blog that I prefer – it’s simple, straightforward, and uses 2D Physics to handle gravity and collisions. We incorporated much of their code, with some modifications that simplify our process.

You can find their blog post and code here: Sharp Code – Unity 2D Character Controller

The first neat feature about this script is the RequireComponent instruction at the head of the file. This tells the Unity Editor that this script requires these components to be attached to the same object (in this case, Rigidbody2D and CapsuleCollider2D). If you attach this script to an object without these components, they will automatically be added.

[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(CapsuleCollider2D))]

The next feature that I appreciate about this particular controller is that they modify the rigidbody properties during the Start( ) function, to ensure that the right settings are in place. In this case, they make sure that Rigidbody2D.freezeRotation is set to true (so our character doesn’t roll over) and that the collision detection mode is set to “Continuous” rather than the default setting of “Discrete”. Continuous detection is going to be the preferred method for your player object and probably your enemy objects – you want those collisions running all the time. Finally, it set the rigidbody’s gravityScale to a value defined by this script. This is important because it will affect the speed of your vertical movement.

        r2d.freezeRotation = true;
        r2d.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
        r2d.gravityScale = gravityScale;

We left out all references to camera control from the script, as we will be making our own custom camera controller later in this lesson.

Part 2: Character Controller Inputs

The Update( ) method is used to fetch our user Inputs, which currently consist of moving horizontally, and jumping (which is a vertical motion). We modify the ridigbody’s velocity, which is expressed as a Vector2, in order to move the controller in that direction.

Jumping can only occur when we are “grounded” (more on that in a minute) and simply replaces the velocity’s y-value with whatever we set out “jumpHeight” value to be.

Horizontal movement is a little more complicated. Sharp Coder’s features a lot of logic to determine whether how to set our “moveDirection”.

        // Movement controls
        if ((Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.D)) && (isGrounded || Mathf.Abs(r2d.velocity.x) > 0.01f))
        {
            moveDirection = Input.GetKey(KeyCode.A) ? -1 : 1;
        }
        else
        {
            if (isGrounded || r2d.velocity.magnitude < 0.01f)
            {
                moveDirection = 0;
            }
        }

This version looks to see if we are pressing the A or D key, AND if we are either grounded, or already moving horizontally (by testing the absolute of the velocity’s x-value). This means that we can move while on the ground, or can adjust our movement in air if we were already moving horizontally in air. Whether or not you can control your character’s horizontal movement while in-air makes for a heated debate amongst designers. Also, what’s up with this line?

moveDirection = Input.GetKey(KeyCode.A) ? -1 : 1;

This is what is known as a “ternary operator”. It’s a conditional that works like a simplified “if” conditional. It’s structure is something = statement ? <true value> : <false value> Here, moveDirection can be set to one of two values, based on “is the A key is pressed”. If true, a value of -1 is returned, if false, 1 is returned.

The else on the original statement says if we are not pressing A or D, then our move direction should be 0. Doesn’t this sound like Input.GetAxisRaw? It sure does, so that’s what we use instead.

// Movement controls
moveDirection = Input.GetAxisRaw("Horizontal");

Part 3: Actual Making things Move

We used the Update( ) loop to take our settings, but for actually *moving* our object, we want to take advantage of FixedUpdate( ). FixedUpdate is called at a steady, regular interval (usually 50 times per second) and is the preferred method for working with Rigidbodies / Physics. Update is frame dependent – and that framerate will fluctuate, which is why we have to include a “deltaTime” measurement when creating motion. With FixedUpdate( ), the interval is the same every time.

Towards the end of Sharp Coder’s FixedUpdate( ) function, we see the code that sets the velocity value of our rigid body.

// Apply movement velocity
r2d.velocity = new Vector2((moveDirection) * maxSpeed, r2d.velocity.y);

This is super-simple – take the horizontal movement direction we have designated, multiply it by the max speed and place it in the x-value. We preserve the y-value of the velocity, as this will be set elsewhere through a “jump”, react to the -y forces of gravity, or return to 0 in the event that we are on a surface.

Part 4: Are we on the ground?

The beginning of the original FixedUpdate( ) function contains a considerable amount of logic to identify a point in space that exists inside of our player collider, and the width of the collider itself. The reason this information is important is that it will be used to create a custom collision check to see if our collider is indeed touching something. The logic goes something like this:

  1. Find a point just about the radius point of the lower arc on the capsule.
  2. Create a circle that will reach just a little lower than that arc, and check within that circle for any colliders.
  3. Take the list of returned colliders and check to see if one of those is NOT the player collider.
  4. If you find a non-player collider, you’re on the ground! If not, isGrounded remains false.

This method certainly does work, but I have a few issues with it. First, it just assumes that ANY collider that it detects is automatically the ground – but later we will use colliders with the “trigger” function that our player object will pass through, but this Physics2D.OverlapCircleAll will definitely detect. We saw before how we could use the layer matrix in Physics settings to “mask” layers from one another (like objects in Enemy layers ignoring one another). Since we only care if we hit colliders that represent the ground, why don’t we just use layers to check specifically for that?

In order to do this, I can use the third parameter of Physics2D.OverlapCircleAll( ) – a integer named layerMask. The layer mask is what is known as a “bitmask” – a way of using and manipulating the bits in a byte of information, otherwise known as a bitwise operation. These bits are set on or off, and used as a way to store information as a number. If you look at the Layer panel, you’ll notice that we only have slots in Layer 0-31, or 32 total possible values that can be set to “on” or “off”. We have a data type that is made of 32 bits – the integer. And so there is a single numeric value that represents the over 4 million possible combinations of on-off in those 32 slots. Sound complicated? It is!

Thankfully we don’t have to worry about the math for this, because Unity has already built this into our editor, with the LayerMask type. You’ve seen these variables before, in the Camera Mask settings, which determine whether a camera can view a layer or not. We declare a variable to set in the editor like so:

public LayerMask groundLayer;

This gives us a dropdown that we can use to set the layers we are interested in looking at. I’ve created a layer named “Ground”, and put all of our environment objects on that layer.

Now in FixedUpdate( ) we modify that custom physics call to include the layerMask so that it only returns colliders on the layers that we defined in the editor, which in this case is “Ground” and “Enemy”.

// Check if player is grounded
Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheckPos, colliderRadius, groundLayer);
        

Now rather than sort through all of the colliders, I can simply check to see if there ARE any colliders returned! I set my grounded variable equal to false, then if there is length, I turn it to true.

//Check if any of the overlapping colliders are not player collider, if so, set isGrounded to true
isGrounded = false;

if (colliders.Length > 0) { isGrounded = true; }

Of course, if I wanted to be fancy, I could use our ternary operator to express these in one line.

isGrounded = colliders.Length > 0 ? true : false;

Tomorrow we will finish our controller by simplifying the physics check, and generating some helpful gizmos to visualize their range of effect.


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;

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;


    // 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;
    }

    // Update is called once per frame
    void Update()
    {

        // Movement controls
        moveDirection = Input.GetAxisRaw("Horizontal");

        // 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()
    {
        Bounds colliderBounds = mainCollider.bounds;
        float colliderRadius = mainCollider.size.x * 0.4f * Mathf.Abs(transform.localScale.x);
        Vector3 groundCheckPos = colliderBounds.min + new Vector3(colliderBounds.size.x * 0.5f, colliderRadius * 0.9f, 0);
        
        
        
        // Check if player is grounded
        Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheckPos, colliderRadius, groundLayer);
        
        
        
        //Check if any of the overlapping colliders are not player collider, if so, set isGrounded to true
        isGrounded = false;

        if (colliders.Length > 0) { isGrounded = true; }

        // Apply movement velocity
        r2d.velocity = new Vector2((moveDirection) * maxSpeed, r2d.velocity.y);

        // Simple debug
        Debug.DrawLine(groundCheckPos, groundCheckPos - new Vector3(0, colliderRadius, 0), isGrounded ? Color.green : Color.red);
        Debug.DrawLine(groundCheckPos, groundCheckPos - new Vector3(colliderRadius, 0, 0), isGrounded ? Color.green : Color.red);
    }

}

}