Welcome to Part 2 of our Metroidvania tutorial series, where we’ll be taking you on a journey through the development process of creating your own Metroidvania game, just like the widely popular Hollow Knight, in Unity!
Update 30 July 2024: We have improved the article with the fixes outlined in these two videos. If you prefer to read what is in these 2 videos, you can also check out this forum post.
Table of Contents:
- Introduction
- Organising the project
- Adding Jump Input Buffer
- Adding Coyote Time
- Adding a Double Jump
- Adding the Dash mechanic
- Conclusion
1. Introduction
In the previous episode, we established the foundations of our game and introduced the basic player movement. Today, we’re going to build on that foundation by adding advanced player movements such as a Double Jump, a Dash, Jump Input Buffer, and Coyote Time. These mechanics are crucial to Metroidvanias as they allow players to have greater control over their movement, resulting in a more engaging and satisfying gameplay experience.
So, without further ado, let’s dive into the details and get started!
2. Organising the project
Let’s first start organising our project into different folders so that it will be easier to navigate and find the assets we need. We’ll create Art, Animation, Scripts, Scenes, and Prefab folders. Then we’ll drag our assets into their respective folders.
3. Adding Jump Input Buffer
Now let’s add a Jump Input Buffer. An input buffer is a feature that allows the player’s inputs to be registered and stored for a brief period of time or a number of frames, even if the inputs were executed slightly before the intended time. This means that the game will remember the player’s inputs and execute them as soon as the current animation or action is complete, giving the player a smoother and more responsive gameplay experience.
a. Creating player states and adding a jumping state
First, we’ll need to create another script called PlayerStateList
in our player game object. This script will help us keep track of what state the player is currently in.
In PlayerStateList
we can delete the Start()
and Update()
functions and add a public
bool
called jumping
.
PlayerStateList.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStateList : MonoBehaviour { public bool jumping; void Start() { } void Update() { } }
Then in our PlayerController
’s Start()
function, we’ll add a PlayerStateList
variable called pState
and set it in the start function by typing pState = GetComponent<PlayerStateList>();
. This will allow us to reference our player states.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); Move(); Jump(); Flip(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); } if (Input.GetButtonDown("Jump") && Grounded()) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); } anim.SetBool("Jumping", !Grounded()); } }
Next, let’s set the jumping
bool
. We’ll set it to false when the cancels their jump (essentially when the player lifts the jump button) by typing pState.jumping = false
. Then we’ll type pState.jumping = true
to set the jumping
bool
to true when the player jumps.
Finally, we’ll want to reset the jumping
bool
when the player is grounded, so what we’ll do is create a function called UpdateJumpVariables()
and set the jumping
bool
to false when the player is grounded. Then we’ll add UpdateJumpVariables()
to the Update()
function above the movement functions. Let’s also move the Flip()
function above the movement functions.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (Input.GetButtonDown("Jump") && Grounded()) { pState.jumping = true; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { pState.jumping = false; } } }
Article continues after the advertisement:
b. Creating Jump Input Buffer
Now let’s add the Jump Input Buffer. First, we’ll create an if statement for when the player is not jumping around the player jumping code.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (Input.GetButtonDown("Jump") && Grounded()) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { pState.jumping = false; } } }
Then, we’ll create two private float
variables, one called jumpBufferCounter
, which will be the variable that stores the input, and the other called jumpBufferFrames
which will be a serializable variable that sets how long the max input buffer will be.
Also, in the UpdateJumpVariables()
function, we’ll add:
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (Input.GetButtonDown("Jump") && Grounded()) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { pState.jumping = false; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
What this does is it sets the jumpBufferCounte
r to our maximum number of jump buffer frames when the player presses the jump button and decreases it over time based on the frame duration.
Next, we’ll go to our jump code and replace the Input.GetButtonDown(“Horizontal”)
with jumpBufferCounter > 0
.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 Input.GetButtonDown("Jump") && Grounded()) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { pState.jumping = false; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
To summarize, the Jump Input Buffer works as follows:
- The player presses the jump button.
- The
jumpBufferCounter
is reset to thejumpBufferFrame
s you have set. (e.g. 8). - The
jumpBufferCounter starts
decreasing over time based on the frame duration. - The player has a limited number of frames to press the jump button again before the
jumpBufferCounter
reaches 0. - If the
jumpBufferCounter
is still greater than 0 when the player lands on the ground, their input will register and they will jump. - If the player hasn’t landed on the ground before the
jumpBufferCounter
reaches 0, the jump input will not register.
Now let’s set our jumpBufferFrames
to exaggerated value like 60 in the inspector and test the buffer out. You should see that if you press the jump button at least 60 frames before touching the ground the player jumps.
Once you’ve successfully tested this let’s set the jumpBufferFrames
in our inspector to something more reasonable like 8 frames.
Article continues after the advertisement:
4. Adding Coyote Time
Next, we’ll add Coyote Time to our game.
In games with a lot of platforming elements like Hollow Knight, coyote time is a popular gameplay mechanic that offers several benefits to players. Here are a few key points to consider:
- Coyote time grants players a brief window of time to jump after leaving a platform or solid ground.
- This mechanic is designed to help players perform jumps more smoothly and easily, especially in fast-paced platformer games where timing is crucial.
- Typically lasting just a few frames or milliseconds, coyote time allows players to perform a jump even if they are no longer technically on solid ground.
- Rather than punishing players for mistiming a jump and falling to their death, coyote time gives them a small margin of error to correct their jump and avoid frustration.
Overall, coyote time is a valuable gameplay mechanic that can enhance the player experience in platforming games. By allowing for smoother and more forgiving jumps, it can help players feel more in control and engaged with the game.
To add coyote time to our game we’ll:
- Create two
private
float
variables- One called
coyoteTimeCounter
, which will be the variable that stores the input - The other called
coyoteTime
which will be a serializable variable that sets how much time coyote time will last.
- One called
- Then we’ll replace the
Grounded()
check in our jump code withcoyoteTimeCounter > 0
. - Finally, we’ll reset the
coyoteTimeCounter
when the player is grounded and starts decreasing it when the player is not grounded in ourUpdateJumpVariables()
function.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0 Grounded()) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
Here’s how a step-by-step walkthrough of how Coyote Time works:
- The player walks off a platform/ledge.
- The
coyoteTimeCounter
starts decreases byTime.deltaTime
every frame (essentially 1 every second). - The player has a limited amount of time defined by the
coyoteTime
variable to jump while not grounded. - If the
coyoteTimeCounter
reaches 0, the player loses the ability to jump while not grounded. - The
coyoteTimeCounter
is reset whenever the player lands on the ground again.
Now let’s set our coyoteTime
to something large like 1 in the inspector window and test the buffer out. You should see that if you press the jump button at least 1 second after leaving the ground, the player jumps.
Once you’ve successfully tested this let’s set the coyoteTime
to something more reasonable like 0.1.
Note that the Coyote Time and Jump Input Buffer mechanics can be tracked using either time or frames so you can interchangeably use the time or frame methods for the above 2 mechanics I have shown.
Article continues after the advertisement:
5. Adding a Double Jump
Next, let’s add a Double Jump. What we’ll want to do is create:
- A
private
int
calledairJumpCounter
- This will keep track of how many times you’ve jumped while in the air
- And a serializable
maxAirJumps
private
int
- This will be the maximum number of air jumps the player will be able to do.
To add our double jump, we’ll add under our jump if statement:
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
By implementing this line of code, the player will only jump while in the air if they press the jump button, are not grounded, and have not exceeded the maximum number of air jumps allowed.
Under our new else if statement, we will:
- Set
pState.jumping
to true. - Increase
airJumpCounter
by 1 to keep track of the number of times the player has jumped in the air. - Set the player’s Rigidbody2D vertical velocity to the
jumpForce
.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
Now let’s set out max air jumps in our inspector to 1 and test it out. We’ll see that our player can now jump once in the air.
Before we move on, let’s also organise our inspector window and move our vertical movement variables under [Header(“Vertical Movement Settings:”)]
. Let’s also add [Space(5)]
beneath each set of variables to give some separation between them in our inspector window.
Article continues after the advertisement:
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; Animator anim; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
6. Adding the Dash mechanic
Next, We’ll add dashing. In Hollow Knight, the dash mechanic is a movement ability that allows the player to quickly move in a chosen direction. When activated, the character will dash forward with a burst of speed, covering a significant distance in a short amount of time. The dash ability can be used to dodge enemy attacks, cross gaps and spikes, and reach previously inaccessible areas.
a. Creating the Dash animation
Here are the steps to create and implement the dash animation:
- Create a new animation clip called Player_Dash.
- Add in the knight strike sprites from the free knight sprite asset pack from the Unity asset store as dash keyframes.
- Go to the player’s Animator Controller.
- Transition from Any State to Player_Dash.
- Set the transition’s Has Exit Time to false and its transition duration to 0.
- Add a trigger parameter called Dashing as the transition condition.
- Set the condition of the transition from any state to Player_Dash to the Dashing trigger.
- Transition out of Player_Dash to Player_Jump if Jumping is true, to Player-Walk if Walking is true, and to Player_Idle if the other 2 are false.
Article continues after the advertisement:
b. Creating the Dash mechanic
To create a dash for our PlayerController script, first, we’ll need to create some variables:
- Create a
private
bool
variable calledcanDash
with a default value of true. - Create
dashSpeed
,dashTime
, anddashCooldown
serializableprivate
float
variables and group them under[Header("Dash Settings:")]
. - Create a
private
float
variable calledgravity
and set it inStart()
to the player’s Rigidbody2D gravity scale. - Add a
public
bool
calleddashing
in our PlayerStateList.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private flaot jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; [SerializeField] private float dashTime; [SerializeField] private float dashCooldown; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; private float gravity; Animator anim; private bool canDash = true; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); gravity = rb.gravityScale; } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
PlayerStateList.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStateList : MonoBehaviour { public bool jumping = false; public bool dashing = false; }
Next, we’ll need to add the code that makes the player Dash
- Create a coroutine called Dash().
- Set
canDash
to false so the coroutine doesn’t run again while the player is dashing. - Set
pState.dashing
to true and trigger the player’s dash animation. - Set the player’s gravity scale to 0 so they will dash forward without falling.
- Dash in the direction the player is facing by setting horizontal velocity to
dashSpeed
multiplied by the player’s x scale. - Wait for
dashTime
in seconds to keep dashing. - Reset
gravity scale
andpState.dashing
. - Wait for
dashCooldown
in seconds before settingcanDash
to true and allowing the player to dash again.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; [SerializeField] private float dashTime; [SerializeField] private float dashCooldown; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; private float gravity; Animator anim; private bool canDash = true; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); gravity = rb.gravityScale; } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } IEnumerator Dash() //the dash action the player performs { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); rb.gravityScale = 0; rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
Then we’ll call the Dash coroutine by adding a function called StartDash()
. In the StartDash()
function we’ll want to start the Dash()
coroutine if the player presses the Dash button (left shift) while canDas
h is true. Let’s then add StartDash()
to our Update()
function.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; [SerializeField] private float dashTime; [SerializeField] private float dashCooldown; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; private float gravity; Animator anim; private bool canDash = true; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); gravity = rb.gravityScale; } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); StartDash(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } void StartDash() { if(Input.GetButtonDown("Dash") && canDash) { StartCoroutine(Dash()); } } IEnumerator Dash() //the dash action the player performs { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); rb.gravityScale = 0; rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
Article continues after the advertisement:
We’ll also want to set our dash button in the input manager. Go to Edit -> Project Settings -> Input Manager, then change one of the buttons’ names in the input manager to Dash then set its binding to left shift.
In Hollow Knight, the player can only dash once while in the air. To recreate the Hollow Knight’s air dash mechanic in our game, we need to modify the PlayerController script by adding a private
bool
called dashed
. Here’s how we can do it:
- Add a
private
bool
calleddashed
to the PlayerController script. - Allow the
Dash()
coroutine to start only ifdashed
is false. - Once the
Dash()
coroutine has started, setdashed
to true so that the player cannot dash again while in the air. - Reset
dashed
to false when theGrounded()
function returns true so that the player can dash again once they land on the ground, just like in Hollow Knight.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; [SerializeField] private float dashTime; [SerializeField] private float dashCooldown; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; private float gravity; Animator anim; private bool canDash = true; private bool dashed; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); gravity = rb.gravityScale; } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables() Flip(); Move(); Jump(); StartDash(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } void StartDash() { if(Input.GetButtonDown("Dash") && canDash && !dashed) { StartCoroutine(Dash()); dashed = true; } if (Grounded()) { dashed = false; } } IEnumerator Dash() //the dash action the player performs { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); rb.gravityScale = 0; rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
Finally, we don’t want our other movement functions to interrupt our Dash. To remedy this, we’ll create an if statement above our movement functions in Update()
to check if the player state is Dashing. We’ll return
this if statement so that the movement Functions don’t get called while the player is Dashing.
From our inspector window, let’s then set our Dash Speed to 70, our Dash Time to 0.2, and our Dash Cooldown to 0.35. Now if we play test our game the player should be able to dash
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; [SerializeField] private float dashTime; [SerializeField] private float dashCooldown; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; private float gravity; Animator anim; private bool canDash = true; private bool dashed; public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); pState = GetComponent<PlayerStateList>(); gravity = rb.gravityScale; } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; //Stops running the Update function short if player is dashing Flip(); Move(); Jump(); StartDash(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } void StartDash() { if(Input.GetButtonDown("Dash") && canDash && !dashed) { StartCoroutine(Dash()); dashed = true; } if (Grounded()) { dashed = false; }< } IEnumerator Dash() //the dash action the player performs { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); rb.gravityScale = 0; rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { pState.jumping = false; rb.velocity = new Vector2(rb.velocity.x, 0); } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if (!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { coyoteTimeCounter = coyoteTime; pState.jumping = false; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
Article continues after the advertisement:
c. Creating a dust kick-up effect
Finally, let’s create a dust effect for when the player dashes on the ground.
To create a dash effect in our game, we can follow these steps:
- Create a game object called Dash Effect and add a Sprite Renderer.
- Create an animation clip for the Dash Effect using the dash wind sprites from the knight sprite sheet as keyframes.
- Make Dash Effect a child of the player and position it to look like dust being kicked up as the player dashes on the ground.
- Increase the scale of the Dash Effect for a better visual impact.
- Make the Dash Effect a prefab so that we can instantiate it whenever the player dashes on the ground.
In Unity, a prefab is a pre-made game object that can be reused throughout a project. It allows developers to create a template or blueprint for an object that can be easily replicated and edited without affecting instances of that object already placed in the scene.
We’ll drag our Dash effect game object to our prefab file to make it a prefab and delete the original. Then we’ll reference the Dash Effect game object by creating a serializable private
game object variable called dashEffect
and set the Dash Effect prefab to the dashEffect
variable in our game object.
We’ll then add if(Grounded()) Instantiate(dashEffect, transform);
under our dash line to instantiate the dash effect as a child of our player game object when the player dashes on the ground.
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; [SerializeField] private float dashTime; [SerializeField] private float dashCooldown; [SerializeField] GameObject dashEffect; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; private float gravity; Animator anim; private bool canDash = true; private bool dashed; //creates a singleton of the PlayerController public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { pState = GetComponent<PlayerStateList>(); rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); gravity = rb.gravityScale; } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } void StartDash() { if(Input.GetButtonDown("Dash") && canDash && !dashed) { StartCoroutine(Dash()); dashed = true; } if (Grounded()) { dashed = false; } } IEnumerator Dash() { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); rb.gravityScale = 0; rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0); if (Grounded()) Instantiate(dashEffect, transform); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if(!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { pState.jumping = false; coyoteTimeCounter = coyoteTime; airJumpCounter = 0; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
We’ll also need to destroy the dash effect once its animation has played. Therefore we’ll create a script called DestroyAfterAnimation in our dash effect prefab. In the script, we’ll delete the Update() function and add in the Start() function:
DestroyAfterAnimation.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class DestroyAfterAnimation : MonoBehaviour { void Start() { Destroy(gameObject, GetComponent<Animator>().GetCurrentAnimatorStateInfo(0).length); } void Update() { } }
This gets the first animation clip length and destroys the game object after the animation plays, which is what we want for our dash effect.
And now after playtesting we should have a player that has a jump input buffer, has coyote time, can double jump, and dash.
Article continues after the advertisement:
7. Conclusion
And that’s a wrap for Part 2 of our Creating a Metroidvania (like Hollow Knight) tutorial series! If you would like to, you can also download the project files of whatever we have done so far.
In the next part, we’re going to dive into one of the most satisfying aspects of Hollow Knight: the melee combat system.
You will learn how you can recreate the fluid swordplay that makes the game so exciting. We’ll be taking you step by step through the process of setting up a melee system that will keep your players on the edge of their seats. We will also be covering how you can recreate some of the enemy types from Hollow Knight, to give you something to test the edge of your newly created blade. See you in the next Part!
If you enjoyed this article and would like to support us, please consider becoming a Patron. As a Patron, you’ll gain access to project files for our tutorials and receive exclusive posts on our blog. Your support means a lot to us, as it helps us to sustain our content creation efforts. Currently, we’re earning only 20 cents on every dollar we invest in creating content, so your patronage would go a long way in helping us to continue making high-quality tutorials.
If you feel that you’ve encountered an issue while following this tutorial you can check if it relates to any of the common issues experienced in this post. If you find that you have a different or unique problem, please create a forum post here for further assistance on the matter.
Here are the final end results of all scripts we have worked with today:
PlayerStateList.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStateList : MonoBehaviour { public bool jumping = false; public bool dashing = false; }
PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; private float jumpBufferCounter = 0; [SerializeField] private float jumpBufferFrames; private float coyoteTimeCounter = 0; [SerializeField] private float coyoteTime; private int airJumpCounter = 0; [SerializeField] private int maxAirJumps; [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; [SerializeField] private float groundCheckY = 0.2f; [SerializeField] private float groundCheckX = 0.5f; [SerializeField] private LayerMask whatIsGround; [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; [SerializeField] private float dashTime; [SerializeField] private float dashCooldown; [SerializeField] GameObject dashEffect; [Space(5)] PlayerStateList pState; private Rigidbody2D rb; private float xAxis; private float gravity; Animator anim; private bool canDash = true; private bool dashed; //creates a singleton of the PlayerController public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } } // Start is called before the first frame update void Start() { pState = GetComponent<PlayerStateList>(); rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); gravity = rb.gravityScale; } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); } } private void Move() { rb.velocity = new Vector2(walkSpeed * xAxis, rb.velocity.y); anim.SetBool("Walking", rb.velocity.x != 0 && Grounded()); } void StartDash() { if(Input.GetButtonDown("Dash") && canDash && !dashed) { StartCoroutine(Dash()); dashed = true; } if (Grounded()) { dashed = false; } } IEnumerator Dash() { canDash = false; pState.dashing = true; anim.SetTrigger("Dashing"); rb.gravityScale = 0; rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0); if (Grounded()) Instantiate(dashEffect, transform); yield return new WaitForSeconds(dashTime); rb.gravityScale = gravity; pState.dashing = false; yield return new WaitForSeconds(dashCooldown); canDash = true; } public bool Grounded() { if (Physics2D.Raycast(groundCheckPoint.position, Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround) || Physics2D.Raycast(groundCheckPoint.position + new Vector3(-groundCheckX, 0, 0), Vector2.down, groundCheckY, whatIsGround)) { return true; } else { return false; } } void Jump() { if(Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } if (!pState.jumping) { if (jumpBufferCounter > 0 && coyoteTimeCounter > 0) { rb.velocity = new Vector3(rb.velocity.x, jumpForce); pState.jumping = true; } else if(!Grounded() && airJumpCounter < maxAirJumps && Input.GetButtonDown("Jump")) { pState.jumping = true; airJumpCounter++; rb.velocity = new Vector3(rb.velocity.x, jumpForce); } } anim.SetBool("Jumping", !Grounded()); } void UpdateJumpVariables() { if (Grounded()) { pState.jumping = false; coyoteTimeCounter = coyoteTime; airJumpCounter = 0; } else { coyoteTimeCounter -= Time.deltaTime; } if (Input.GetButtonDown("Jump")) { jumpBufferCounter = jumpBufferFrames; } else { jumpBufferCounter = jumpBufferCounter - Time.deltaTime * 10; } } }
DestroyAfterAnimation.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class DestroyAfterAnimation : MonoBehaviour { void Start() { Destroy(gameObject, GetComponent<Animator>().GetCurrentAnimatorStateInfo(0).length); } }
Article continues after the advertisement: