Notifications
Article
Making a 2D movement script
Updated 6 months ago
51
0
How to write a simple 2D player controller

The objective of this article

The objective of this article is to teach people that are learning Unity the proper way to make a simple 2D player controller that can make the player move left/right and jump. It is very simple and it's just an introduction to character controllers.

The movement basics

The naive way to approach this would be using transform.Translate or even rigidbody.MovePosition, but both of these have problems (like not being able to stand on moving platforms and interacting with physics). So the way we are going to do this is using rigidbody.velocity.

The code

So let's start by creating our main variables.
[Header("Movement")] [SerializeField] float movSpeed; //The movement speed when grounded [SerializeField] float airMovSpeed; //The movement speed when in the air [SerializeField] float movAccel; //The maximum change in velocity the player can do on the ground. This determines how responsive the character will be when on the ground. [SerializeField] float airMovAccel; //The maximum change in velocity the player can do in the air. This determines how responsive the character will be in the air. [Header("Ground detection")] [SerializeField] float groundCastRadius; [SerializeField] float groundCastDist; [SerializeField] ContactFilter2D groundFilter; //Rigidbody cache, just so we don't have to call GetComponent<Rigidbody2D>() all the time new Rigidbody2D rigidbody; //True when the character is on the ground bool isGrounded;
Now we need a function to check if the character is grounded (touching the ground) or not. To do that we will use a function called CircleCast. The CircleCast is the same as a raycast but instead of checking for obstacles with a ray, we check that with the area of a circle and we can get everything that touches it. Here's our ground checking function.
bool DoGroundCheck() { //If DoGroundCast returns Vector2.zero (it's the same as Vector2(0, 0)) it means it didn't hit the ground and therefore we are not grounded. return DoGroundCast() != Vector2.zero; } Vector2 DoGroundCast() { //We will use this array to get what the CircleCast returns. The size of this array determines how many results we will get. //Note that we have a size of 2, that's because we are always going to get the player as the first element, since the cast //has its origin inside the player's collider. RaycastHit2D[] hits = new RaycastHit2D[2]; if (Physics2D.CircleCast(transform.position, groundCastRadius, Vector3.down, new ContactFilter2D(), hits, groundCastDist) > 1) { return hits[1].normal; } return Vector2.zero; }
The reason it is separated in two is that later we will want to get the normal of the ground for the movement.
And now, the core of the movement script, the Move function.
void Move(Vector2 _dir) { Vector2 velocity = rigidbody.velocity; //calculate the ground direction based on the ground normal Vector2 groundDir = Vector2.Perpendicular(DoGroundCast()).normalized; groundDir.x *= -1; //Vector2.Perpendicular rotates the vector 90 degrees counter clockwise, inverting X. So here we invert X back to normal //The velocity we want our character to have. We get the movement direction, the ground direction and the speed we want (ground speed or air speed) Vector2 targetVelocity = groundDir * _dir * (isGrounded ? movSpeed : airMovSpeed); //The change in velocity we need to perform to achieve our target velocity Vector2 velocityDelta = targetVelocity - velocity; //The maximum change in velocity we can do float maxDelta = isGrounded ? movAccel : airMovAccel; //Clamp the velocity delta to our maximum velocity change velocityDelta.x = Mathf.Clamp(velocityDelta.x, -maxDelta, maxDelta); //We don't want to move the character vertically velocityDelta.y = 0; //Apply the velocity change to the character rigidbody.AddForce(velocityDelta * rigidbody.mass, ForceMode2D.Impulse); }
Basically what it does is: calculate the change in velocity we have to do, clamp it to a maximum value and then apply the clamped velocity change to the rigidbody. The reason we multiply the velocityDelta by the mass of the rigidbody is because we only want a velocity change. The formula for force is F = m × a where F is the force, m is the mass and a is the acceleration (which is a change in velocity, like our velocityDelta).
To make it all work together we just need the Start and Update functions.
void Start() { //Setup our rigidbody cache variable rigidbody = GetComponent<Rigidbody2D>(); } void Update() { isGrounded = DoGroundCheck(); Move(new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"))); }
I made a simple test scene, created the player, attached a rigidbody, a collider to it and our movement script to it. Here you can see a print of the player object configuration and in the video below you can see the movement so far.

The jump

To make the character jump we will need a few more variables and a couple of functions. Here they are:
[Header("Jump")] [SerializeField] float initialJumpForce; //The force applied to the player when starting to jump [SerializeField] float holdJumpForce; //The force applied to the character when holding the jump button [SerializeField] float maxJumpTime; //The maximum amount of time the player can hold the jump button
And
void Jump() { if (!isGrounded) { return; } rigidbody.AddForce(Vector3.up * initialJumpForce * rigidbody.mass, ForceMode2D.Impulse); StartCoroutine(JumpCoroutine()); } IEnumerator JumpCoroutine() { //true if the player is holding the Jump button down bool wantsToJump = true; //Counts for how long we've been jumping float jumpTimeCounter = 0; while (wantsToJump && jumpTimeCounter <= maxJumpTime) { jumpTimeCounter += Time.deltaTime; //check if the player still wants to jump wantsToJump = Input.GetButton("Jump"); rigidbody.AddForce(Vector3.up * holdJumpForce * rigidbody.mass * maxJumpTime / jumpTimeCounter); yield return null; } }
The way it works is when the player presses the Jump button Jump() is called and a change in velocity is applied to the player. Then a coroutine (JumpCoroutine) that keeps pushing the player upwards is started. The reason for * maxJumpTime / jumpTimeCounter is to make the jump look a bit less floaty, applying the full force of holdJumpForce only at the end of the jump. Experiment removing this part of the force calculation and you will notice the difference.
The last thing you have to do is add this piece of code to Update():
if(Input.GetButtonDown("Jump")) { Jump(); }
Here are my results. I set initialJumpForce to 3, holdJumpForce to 17 and maxJumpTime to 0.15. Look at the video below to see the results:
It looks floaty, doesn't it? To fix that we have to multiply the gravity of the player. We don't want to change the gravity in the physics settings because we don't want this to affect the whole game. Here's how to do it:
Add this variable
[Header("Misc")] [SerializeField] float gravityMultiplier = 2.7f;
And this piece of code to Update()
rigidbody.AddForce(gravityMultiplier * Physics2D.gravity * rigidbody.mass, ForceMode2D.Force);
Look at the results now (I changed holdJumpForce to 30), much better:
I think we reached the end of this long-but-not-so-long article/tutorial on how to make a character controller for your 2D game. You should tweak the settings of the controller and play around with the code to get the results you want. I might do another article on dashing and wall jumping someday, but for now this is it. I hope to have helped someone.
Here's the full code for the lazy people XD
public class PlayerMovement : MonoBehaviour { [Header("Movement")] [SerializeField] float movSpeed; //The movement speed when grounded [SerializeField] float airMovSpeed; //The movement speed when in the air [SerializeField] float movAccel; //The maximum change in velocity the player can do on the ground. This determines how responsive the character will be when on the ground. [SerializeField] float airMovAccel; //The maximum change in velocity the player can do in the air. This determines how responsive the character will be in the air. [Header("Jump")] [SerializeField] float initialJumpForce; //The force applied to the player when starting to jump [SerializeField] float holdJumpForce; //The force applied to the character when holding the jump button [SerializeField] float maxJumpTime; //The maximum amount of time the player can hold the jump button [Header("Ground detection")] [SerializeField] float groundCastRadius; //Radius of the circle when doing the circle cast to check for the ground [SerializeField] float groundCastDist; //Distance of the circle cast [Header("Misc")] [SerializeField] float gravityMultiplier = 2.7f; //Rigidbody cache new Rigidbody2D rigidbody; bool isGrounded; void Start() { //Setup our rigidbody cache variable rigidbody = GetComponent<Rigidbody2D>(); } void Update() { rigidbody.AddForce(gravityMultiplier * Physics2D.gravity * rigidbody.mass, ForceMode2D.Force); isGrounded = DoGroundCheck(); Move(new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"))); if(Input.GetButtonDown("Jump")) { Jump(); } } void Move(Vector2 _dir) { Vector2 velocity = rigidbody.velocity; //calculate the ground direction based on the ground normal Vector2 groundDir = Vector2.Perpendicular(DoGroundCast()).normalized; groundDir.x *= -1; //Vector2.Perpendicular rotates the vector 90 degrees counter clockwise, inverting X. So here we invert X back to normal //The velocity we want our character to have. We get the movement direction, the ground direction and the speed we want (ground speed or air speed) Vector2 targetVelocity = groundDir * _dir * (isGrounded ? movSpeed : airMovSpeed); //The change in velocity we need to perform to achieve our target velocity Vector2 velocityDelta = targetVelocity - velocity; //The maximum change in velocity we can do float maxDelta = isGrounded ? movAccel : airMovAccel; //Clamp the velocity delta to our maximum velocity change velocityDelta.x = Mathf.Clamp(velocityDelta.x, -maxDelta, maxDelta); //We don't want to move the character vertically velocityDelta.y = 0; //Apply the velocity change to the character rigidbody.AddForce(velocityDelta * rigidbody.mass, ForceMode2D.Impulse); } bool DoGroundCheck() { //If DoGroundCast returns Vector2.zero (it's the same as Vector2(0, 0)) it means it didn't hit the ground and therefore we are not grounded. return DoGroundCast() != Vector2.zero; } Vector2 DoGroundCast() { //We will use this array to get what the CircleCast returns. The size of this array determines how many results we will get. //Note that we have a size of 2, that's because we are always going to get the player as the first element, since the cast //has its origin inside the player's collider. RaycastHit2D[] hits = new RaycastHit2D[2]; if (Physics2D.CircleCast(transform.position, groundCastRadius, Vector3.down, new ContactFilter2D(), hits, groundCastDist) > 1) { return hits[1].normal; } return Vector2.zero; } void Jump() { if (!isGrounded) { return; } rigidbody.AddForce(Vector3.up * initialJumpForce * rigidbody.mass, ForceMode2D.Impulse); StartCoroutine(JumpCoroutine()); } IEnumerator JumpCoroutine() { //true if the player is holding the Jump button down bool wantsToJump = true; //Counts for how long we've been jumping float jumpTimeCounter = 0; while (wantsToJump && jumpTimeCounter <= maxJumpTime) { jumpTimeCounter += Time.deltaTime; //check if the player still wants to jump wantsToJump = Input.GetButton("Jump"); rigidbody.AddForce(Vector3.up * holdJumpForce * rigidbody.mass * maxJumpTime / jumpTimeCounter); yield return null; } } }

Lucas
Self taught hobbyist - Programmer
3
Comments