- Home /
Unity 2D: Properly Implementing Player Movement with a dash, jump, and knockback?
Hi. So I'm very new to Unity and coding as a whole. I've been fiddling around a lot with player controllers and trying to make one for a 2D platformer. I have read a lot about the advantages and disadvantages of using Unity physics for platformers. I have decided that although there may be better systems for platformers, that as a beginner the physics system is easiest for me to get started and learn with.
I've put together a controller below which seems to work just fine. The player can move, jump, double jump, dash, and get knocked back. I had a ton of issues with knockback not working as I was setting the velocity directly so any AddForce for a knockback was being overridden instantly. I have opted to have the player movement be overridden extremely briefly by AddForce when the player is knocked back so that it simulates being pushed away. Right now it only works in one direction but it works!
Onto my question. Is there anything in this code that should be improved or changed? I am not happy that my jump movement is being handled in Update() while all my other movement is being handled in FixedUpdate(). I also feel like I am missing Time.fixedDeltaTime in my movement? My code just seems sloppy and not optimal.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MoveDashJump : MonoBehaviour
{
// Standard Movement
[SerializeField] float currentMoveSpeed;
[SerializeField] float baseMoveSpeed;
private float moveInput;
// Jumping
[SerializeField] float jumpForce;
private bool isGrounded;
[SerializeField] Transform groundCheck;
[SerializeField] float checkRadius;
[SerializeField] LayerMask whatIsGround;
private int extraJumps;
[SerializeField] int extraJumpValue;
// Rigidbody2D reference
private Rigidbody2D playerRB;
// Dashing
[SerializeField] float dashSpeed;
[SerializeField] float dashDuration;
bool isDashing;
// Knockback
[SerializeField] float knockbackForce;
bool beingKnockedBack;
[SerializeField] float knockbackDuration;
void Start()
{
// Setting values to their defaults and grabbing a rigidbody reference
beingKnockedBack = false;
currentMoveSpeed = baseMoveSpeed;
isDashing = false;
extraJumps = extraJumpValue;
playerRB = GetComponent<Rigidbody2D>();
}
// Update is called once per frame
void Update()
{
// Gathering input for movement and handling jump
moveInput = Input.GetAxisRaw("Horizontal");
if (isGrounded == true)
{
extraJumps = extraJumpValue;
}
if (Input.GetKeyDown(KeyCode.B) && extraJumps > 0)
{
playerRB.velocity = Vector2.up * jumpForce;
extraJumps--;
}
if (Input.GetKeyDown(KeyCode.Space))
{
isDashing = true;
}
if (Input.GetKeyDown(KeyCode.K))
{
beingKnockedBack = true;
}
}
private void FixedUpdate()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, checkRadius, whatIsGround);
if (isDashing)
{
StartCoroutine(Dash());
}
if (beingKnockedBack)
{
StartCoroutine(KnockBack());
}
if (beingKnockedBack)
{
//playerRB.velocity = new Vector2(knockbackForce, playerRB.velocity.y);
playerRB.AddForce(new Vector2(knockbackForce, 0f), ForceMode2D.Impulse);
}
else
{
playerRB.velocity = new Vector2(moveInput * currentMoveSpeed, playerRB.velocity.y);
}
}
IEnumerator Dash()
{
currentMoveSpeed = dashSpeed;
yield return new WaitForSeconds(dashDuration);
currentMoveSpeed = baseMoveSpeed;
isDashing = false;
}
IEnumerator KnockBack()
{
yield return new WaitForSeconds(knockbackDuration);
beingKnockedBack = false;
}
To clarify how I want things to work:
dashing gives the player a short movement speed increase but they can still control movement
jumping simply sends them straight up and back down
knockback abruptly jerks them in a direction based (getting hit by an enemy or something)
Answer by OutramJamie · Aug 27, 2020 at 11:39 AM
There's no 'correct' way of doing things but typically when using Rigidbodies its best to stick to just using AddForce OR Adding to the current velocity vector. Mixing the two can make balancing resistances for the desired motion much harder. (Velocity Ignores the mass of the object, whilst drag and AddForce take mass into account.) In my experience velocity is mostly useful if you want to stop an objects motion completely.
If you want a constant move speed on the ground and in the air I recommend making the physics material with 0 friction applied to either your player or ground collider. You can then balance the linear drag, mass and forces to get your desired behavior.
Because you're setting the velocity every update rather than incrementing it there is no need to scale with Time.deltaTime as it is already independent from the frame-rate.
Below is my attempt at solving your problem using only AddForce: Note: It is indeed beneficial to perform physics updates within FixedUpdate() but as down input detection is badly behaved I've just left it all in Update()
Edit: Added Snappy movement with Add Force
PlayerMovement.cs
using UnityEngine;
public class PlayerMovementB : MonoBehaviour
{
InputManager inputs;
Rigidbody2D rb;
// Standard Movement
[SerializeField] float currentMoveSpeed;
private float CurrentMoveSpeed
{
get { return currentMoveSpeed; }
set
{
currentMoveSpeed = value;
isMoveDown = true;
}
}
[SerializeField] float baseMoveSpeed = 500f;
private int moveInput;
// Jumping
[SerializeField] float jumpForce = 10f;
[SerializeField] int jumps = 3;
private int remainingJumps;
// Dashing
[SerializeField] float dashSpeed = 800f;
[SerializeField] float dashDuration = 4;
// Knockback
bool beingKnockedBack;
[SerializeField] float knockbackForce = 10;
[SerializeField] float knockbackDuration = 1;
//adjust this to accelerate faster or slower
[SerializeField] float accelBoost = 10;
//Flag for direction faced
int facing;
bool isMoveDown;
bool isMoveUp;
void Awake()
{
//Find components attached to this game object, returns null if not found
inputs = GetComponent<InputManager>();
rb = GetComponent<Rigidbody2D>();
facing = 1;
remainingJumps = jumps;
CurrentMoveSpeed = baseMoveSpeed;
isMoveDown = false;
isMoveUp = false;
}
// Update is called once per frame
void Update()
{
if (inputs.GetKeyDown(Action.dash))
{
Debug.Log("Dash");
Dash();
}
if (inputs.GetKeyDown(Action.jump))
{
Debug.Log("Jump");
Jump();
}
//Calculate Horizontal motion
moveInput = 0;
if (inputs.GetKey(Action.moveLeft))
{
//Debug.Log("Left");
moveInput--;
facing = -1;
}
if (inputs.GetKey(Action.moveRight))
{
moveInput++;
facing = 1;
}
if (!isMoveDown) isMoveDown = inputs.GetKeyDown(Action.moveLeft) || inputs.GetKeyDown(Action.moveRight);
if(!isMoveUp) isMoveUp = inputs.GetKeyUp(Action.moveLeft) || inputs.GetKeyUp(Action.moveRight);
if (inputs.GetKeyDown(Action.knockback))
{
Debug.Log("Knockback");
Knockback();
}
}
int previousMoveInput = 0;
private void FixedUpdate()
{
if (!beingKnockedBack)
{
if ((moveInput != 0) && !(isMoveUp && isMoveDown))
{
Vector2 addedForce = Vector2.right * CurrentMoveSpeed * Time.fixedDeltaTime; // The force added each FixedUpdate
//If the first frame when pressed add large impulse;
if (isMoveDown)
{
isMoveDown = false;
rb.AddForce(Vector2.right * moveInput * GetTopSpeed(rb, addedForce) * rb.mass, ForceMode2D.Impulse);
}
else
{
rb.AddForce(addedForce * moveInput);
}
}
else if (isMoveUp || isMoveDown)
{
//Stop the object;
rb.AddForce(Vector2.right * -rb.velocity.x * rb.mass, ForceMode2D.Impulse);
isMoveUp = false;
}
}
else if (moveInput != 0) isMoveDown = true;
previousMoveInput = moveInput;
}
//This Estimates the terminal velocity of the object.
private float GetTopSpeed(Rigidbody2D rb, Vector2 repeatedForce)
{
float topSpeed = ((repeatedForce.magnitude / rb.drag) - Time.fixedDeltaTime * repeatedForce.magnitude) / rb.mass;
return topSpeed;
}
private void Knockback()
{
if (IsInvoking("CancelKnockback")) return;
beingKnockedBack = true;
rb.AddForce(Vector2.left*knockbackForce*facing, ForceMode2D.Impulse);
Invoke("CancelKnockback", knockbackDuration);
}
void CancelKnockback()
{
beingKnockedBack = false;
}
void Jump()
{
if (remainingJumps > 0) {
rb.AddForce(Vector2.up*jumpForce, ForceMode2D.Impulse);
remainingJumps--;
}
}
void OnCollisionEnter2D(Collision2D collision)
{
//Check if collided with ground only when collision detected
if (collision.gameObject.CompareTag("Ground"))
{
//reset jumps
remainingJumps = jumps;
}
}
void Dash()
{
if (!IsInvoking("CancelDash"))
{
CurrentMoveSpeed = dashSpeed;
Invoke("CancelDash", dashDuration);
}
}
void CancelDash()
{
CurrentMoveSpeed = baseMoveSpeed;
}
}
InputManager.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum Action
{
moveLeft,
moveRight,
jump,
dash,
knockback,
}
public class InputManager : MonoBehaviour
{
//Keyboard inputs indexable by InputCode enumeration
private KeyCode[] InputMap;
public void Awake()
{
InputMap = new KeyCode[] {
KeyCode.LeftArrow,
KeyCode.RightArrow,
KeyCode.B,
KeyCode.Space,
KeyCode.K,
};
}
public bool GetKey(Action inputCode)
{
return Input.GetKey(InputMap[(int)inputCode]);
}
public bool GetKeyDown(Action inputCode)
{
return Input.GetKeyDown(InputMap[(int)inputCode]);
}
public bool GetKeyUp(Action inputCode)
{
return Input.GetKeyUp(InputMap[(int)inputCode]);
}
}
Does this bit of code for moving horizontally make it so the player slowly increases speed in a direction? I am trying to get some snappy movement which is why I was setting velocity directly but I really like the idea of using AddForce instead.
if (!beingKnockedBack) { //move horizontally rb.AddForce(Vector2.right current$$anonymous$$oveSpeed moveInput * Time.deltaTime); }
You can adjust how snappy the movement is to some degree by tweaking forces and resistances but it will never be instant unless you set the velocity at the start. There is code available for predicting the ter$$anonymous$$al velocity from a given force; so the most 'sensible' thing is likely to set the horizontal velocity to this ter$$anonymous$$al velocity OnKeyDown to get the initial speed then continue with add force (make sure to preserve the vertical y motion or jumps will be cut off)
However I have a very over engineered solution here; Instead of setting the velocity it multiplies the force when stationary or moving slowly and reduces the multiplier to 1 using Linear interpolation dependent on the current velocity. The benefit here is you can still have external forces effect the player and can keep a very slight ramping up and down of their speed with easy control. Downside is all the overhead :/.
//Replacing if (!beingKnockedBack) condition in update() from previous code
private void FixedUpdate()
{
if (!beingKnockedBack)
{
//This really needs to be in fixed update now
Vector2 addedForce = Vector2.right * current$$anonymous$$oveSpeed * Time.fixedDeltaTime; // The force added each FixedUpdate
//The following calculation could be cashed and updated when movment speed is changed
//This Estimates the ter$$anonymous$$al velocity of the object and uses it to scale the acceleration forces to be larger.
float topVelocity = ((addedForce.magnitude / rb.drag) - Time.fixedDeltaTime * addedForce.magnitude) / rb.mass;
if (moveInput != 0)
{
topVelocity *= moveInput; //Convert speed to velocity
float t = $$anonymous$$athf.Clamp(rb.velocity.x / topVelocity, 0, 1);
float impulseScale = $$anonymous$$athf.Lerp(accelBoost, 1, t);
Vector2 force = addedForce * moveInput * impulseScale;
//move horizontally
rb.AddForce(force);
}
else if($$anonymous$$ath.Abs(rb.velocity.x) > 0.01f)
{
//Oppose $$anonymous$$otion
if (rb.velocity.x > 0)
{
topVelocity *= -1;
addedForce *= -1;
}
//Arrest motion just as quickly by adding extra drag force
float t = $$anonymous$$athf.Clamp(1 - ($$anonymous$$ath.Abs(rb.velocity.x / topVelocity)), 0, 1);
//AccelBoost can be swapped here for a seperate deceleration variable for more control.
float impulseScale = $$anonymous$$athf.Lerp(accelBoost, 1, t);
Vector2 dragForce = addedForce * impulseScale;
//Debug.Log(dragForce);
rb.AddForce(dragForce);
}
}
}
After some research I've found it is actually possible to use Rigidbody2D.Impulse to apply a single force to get your character up to speed in one frame. Doing this means you wont mess with any collisions by setting the velocity directly. This is likely better than my previous solution as it's not trying to reach the top speed every frame but only on the first. (e.g some crates with weight and friction could slow or stop the player still)
rb.AddForce(Vector2.right* moveInput * GetTopSpeed(rb, addedForce) * rb.mass, Force$$anonymous$$ode2D.Impulse);
//This Estimates the ter$$anonymous$$al velocity of the object.
private float GetTopSpeed(Rigidbody2D rb, Vector2 repeatedForce)
{
float topSpeed = ((repeatedForce.magnitude / rb.drag) - Time.fixedDeltaTime * repeatedForce.magnitude) / rb.mass;
return topSpeed;
}
Answer by xxmariofer · Aug 27, 2020 at 06:42 AM
going first into the specific quesstions:
"I am not happy that my jump movement is being handled in Update() while all my other movement is being handled in FixedUpdate()" the code is fine, inputs need to be called in the Update event or you will miss some of the input clicks but all the physics need to be in the fixedupdate
"I also feel like I am missing Time.fixedDeltaTime in my movement?" no you are not, it is completly optional since fixedDeltaTime is a constant, dont mix Time.deltaTime in movements with fixedDeltaTime in physicis they are not the same, fixedDeltaTime is a constant and there is no need to use it.
As a personal advice i would separate input handling from the movement itself, but the code is fine i dont see any specific issues