21 April 2024: This article has been updated to correct some errors and missing information.
Here’s the list of changes and bugfixes for Part 3 of the Metroidvania Series.
Welcome to part 3 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 this video. If you prefer to read what is in these 2 videos, you can also check out this forum post.
Table of Contents:
1. Introduction
In the previous parts, we created the movement and camera mechanics of the game. Now, we’ll be advancing into creating a melee combat system with recoil and an Enemy base class. Needless to say, a combat system is extremely important for Metroidvanias, creating an engaging play experience for the player.
Alright, let’s get straight into it!
2. Organizing our code
Having settled our core aspects of the PlayerController.cs, we’ll be creating more variables for the various future mechanics. To make sure we understand what our code does and to better sort through the code, we’ll add a few things:
- Set all unassigned variables to
private
to prevent other classes from unintentionally accessing them. - Create comments for the variables to understand their use in the code.
- Move
private float gravity;
to"Vertical Movement Settings"
. - Merge both
canDash
anddashed
bools into one line:private bool canDash = true, dashed
, then move the code to"Dash Settings"
.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis;private float gravity; 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
3. Creating the Attack
Firstly, let’s set up the attack animation so we’ll be able to clearly see if the attack function is being called.
a. Animating the Attack
Here are the steps to create and implement the attack animations:
- Create an animation clip and call it Player_Attack and Player_JumpAttack.
- Drag the sprites for the attack animation into the animator window and adjust it so that it looks good.
- Uncheck Loop time for the animations.
- Link the Player_Attack animation to Player_Idle and Player_Walk, and the Player_JumpAttack to Player_Jump.
- Uncheck Has Exit Time and change Transition Duration to 0 for the arrows leading towards the attack animations.
- Change the exit time to 1 for the arrows leading away from the attack animations.
- Add a trigger parameter and name it Attacking.
- Add the Attacking trigger to the arrows leading towards the attack animations.
b. Activating the animations
With the attack animations set up, we’ll move to the PlayerController
script to call the attack function. Firstly, create a bool attack
to check if we are attacking or not. And create a [SerializeField] private float timeBetweenAttack
and private float timeSinceAttack
. These will limit the rate of attack so the player can’t attack non-stop, it will also prevent a bug that will basically deal infinite damage to the enemy and infinite attack spam.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis; private bool attack = false; //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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
Now, create a method Attack()
then add it in Update()
. In the method, add timeSinceAttack += Time.deltaTime
, then, if attack
and timeSinceAttack >= timeBetweenAttack
, reset the timeSinceAttack
back to 0. If we call the attack input and the time since we last attacked is more than the attack’s cooldown, we can attack again. Add in anim.SetTrigger("Attacking")
in the Attack()
method to play the attack animation.
To call the Attack()
method, we’ll be setting the attack bool
in GetInputs()
.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis; private bool attack = false; //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(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); } } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
Article continues after the advertisement:
c. Creating the Attack Area Detection system
Next up, let’s create the actual attacking function. Add a new section for attacking in your variables section. Then, create 3 separate transforms
, name them SideAttackTransform
, UpAttackTransform
and DownAttackTransform
respectively. Create 3 Vector2
variables and name them SideAttackArea
, UpAttackArea
and DownAttackArea
respectively. The attackAreas will be cast from the attackTransforms to determine the area of attack.
Create an OnDrawGizmos
to make it easier to see in the inspector.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); } } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
To set our attackTransforms, create 3 empty game objects and set them to above, beside and below your player’s sprite respectively and name them to their corresponding attacks. In the Inspector, we’ll determine the size of the attackArea by inputting the X and Y values, the values that I’ll be using are 4.5 x 3.5 for the side and 3.5 x 4.5 for up and down. But you can experiment with whatever values you like.
Now, we’ll begin on the attack function. Firstly, in the Unity editor, create an Attackable layer. Anything assigned to this layer can be attacked by the player.
Then, create a LayerMask attackableLayer
in the PlayerController
so we can assign the Attackable layer to it. Next, we’ll create a float yAxis
which will serve as vertical directional input in our GetInputs()
. Next up, create a method Hit()
which takes two arguments: Transform _attackTransform
and Vector2 _attackArea
. We’ll use this method to handle attacks in all directions, and we’ll specify the appropriate _attackTransform
and _attackArea
when we call it. For instance, when we attack upward, we’ll use the upAttackTransform
and upAttackArea
.
In this method, we want to be able to hit multiple objects and sift out what is attackable and what isn’t, so well create a Collider2D[] objectsToHit
.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); } } void Hit(Transform _attackTransform, Vector2 _attackArea) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); } void Jump() { 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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
In Attack()
, we’ll set the specific _attackArea
to the sideAttackArea
if there is no vertical input or if they are holding down S and are grounded. Set it to the upAttackArea
if the player is holding W and it to downAttackArea
if the player is holding down S and is not grounded. This is because we don’t want the player to attack downward if they are grounded.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //creates a singleton of the PlayerController public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } health = maxHealth; } // Start is called before the first frame update void Start() { pState = GetComponent<PlayerStateList>(); rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); gravity = rb.gravityScale; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea); } } } void Hit(Transform _attackTransform, Vector2 _attackArea) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
Article continues after the advertisement:
4. Creating and Damaging Enemies
Now we’ll create an enemy and the damage enemy function.
a. Creating the Enemy
Here’s how we’ll create the enemy:
- Create a rectangle Game Object, and name it Enemy.
- Make it roughly the same height as the Player.
- Add a BoxCollider2D and a RigidBody2D to it which will give it collision and physics respectively
Create a script called Enemy
. Then, create a float health
and in Update()
, we’ll destroy the enemy GameObject if its health runs out. To determine how much health the enemy has and what happens if it’s attacked, create an EnemyHit()
method and add an argument float _damageDone
. In EnemyHit()
, add health -= _damageDone
.
Enemy.cs
public class Enemy : MonoBehaviour { [SerializeField] float health; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { if(health <= 0) { Destroy(gameObject); } } public void EnemyHit(float _damageDone) { health -= _damageDone; } }
b. Attacking the Enemy
Now in the PlayerController
, create a float damage
. This will be the amount of damage the player will deal to the enemy.
In the Hit()
method, we’ll create a List<Enemy>
called hitEnemies
to store all enemies that have been hit. Then we’ll iterate through the objectsToHit
array to detect if there’s the Enemy
class in the _attackArea
using a for loop. If there is an Enemy
class, deal damage to the enemy. To check if the enemy is already hit we’ll put an Add
function to increase the list count to prevent multiple instances of damage. We use a for loop to avoid throwing a Null Reference Exception error
in case there isn’t an enemy class detected.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea); } } } void Hit(Transform _attackTransform, Vector2 _attackArea) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage); hitEnemies.Add(e); } } } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
Remember to set the damage and the health of the enemy in the inspector before testing it out.
c. Creating a Slash Effect for the Attack
Alright, now let’s add a slash effect to the player attack to make it look nicer. The Sword Slashes Asset Pack we’ll use is from itch.io. We will be using the pure white slash wide, but feel free to use any of the slashes you wish.
Here are the steps to create the Slash Effect prefab:
- Create an animation clip for the slash and drag in the sprites to the keyframes.
- Rename this to Slash Effect and drag it into the prefabs folder.
- In the Slash Effect Prefab, add the
DestroyAfterAnimation
script. - Recentre it’s transform position back to (0, 0, 0).
We add the DestroyAfterAnimation
script so that we will avoid a bug that will instantiate many instances of the Slash Effect, and we recentre it back to (0, 0, 0) to avoid a bug that will cause the Slash Effect to not play at the desired location.
In the PlayerController
, well add a variable GameObject slashEffect
which we’ll use to instantiate the slash effect. Create a method SlashEffectAtAngle()
with three arguments: GameObject _slashEffect
, int _effectAngle
, and Transform _attackTransform
. This will allow us to instantiate it at any angle we choose (up, side and down) and we’ll position it at the _attackTransform
and then rotate it using the _effectAngle
.
In the SlashEffectAtAngle()
method, define _slashEffect
. Then, we’ll modify the angle and stretch the Slash Effect to fit the corresponding attack area.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea); } } } void Hit(Transform _attackTransform, Vector2 _attackArea) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage); hitEnemies.Add(e); } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
In the Attack()
method, we’ll add in the Slash Effect. For the attacking to the side, instantiating it will be fine as there is no change to its angle. However, for attacking upwards and downwards, we will use SlashEffectAtAngle()
to change its angle respectively.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea); Instantiate(slashEffect, SideAttackTransform); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage); hitEnemies.Add(e); } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
Now in the Inspector, assign the Slash Effect prefab and test it out.
Article continues after the advertisement:
5. Recoil
Now, let’s add recoil to the enemy and to the player when we attack. The recoil will serve as additional feedback, giving the attack more weight.
a. Enemy Recoil
In the Enemy
script, add a float recoilLength, recoilTimer, recoilFactor
, add a bool isRecoiling
and default it to false
, then add a RigidBody2D rb
. All these will be used to control and effect the recoil on the enemy.
Next, assign the RigidBody2D
component in Start()
. In the EnemyHit()
method, add these additional arguments: Vector2 _hitDirection
and float _hitForce
. These will determine which direction the enemy will recoil in and how much force is applied to the enemy when it recoils respectively. Then, add force to the enemy if it’s not currently recoiling and set isRecoiling
to true
.
Finally, in void Update()
, if isRecoiling
is true
, if recoilTimer
is less than recoilLength, increase the recoilTimer
. Else, reset isRecoiling
to false and reset the recoilTimer
back to 0. What this does is if the enemy hasn’t recoiled the full length, it will continue to recoil.
Enemy.cs
public class Enemy : MonoBehaviour { [SerializeField] float health; [SerializeField] float recoilLength; [SerializeField] float recoilFactor; [SerializeField] bool isRecoiling = false; float recoilTimer; Rigidbody2D rb; // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); } // Update is called once per frame void Update() { if(health <= 0) { Destroy(gameObject); } if(isRecoiling) { if(recoilTimer < recoilLength) { recoilTimer += Time.deltaTime; } else { isRecoiling = false; recoilTimer = 0; } } } public void EnemyHit(float _damageDone , Vector2 _hitDirection, float _hitForce) { health -= _damageDone; if(!isRecoiling) { rb.AddForce(-_hitForce * recoilFactor * _hitDirection); isRecoiling = True; } } }
At this point, an error should be thrown, if we move back to the PlayerController
, we can see that the EnemyHit()
here isn’t updated to mention the _hitDirection
and _hitForce
, so we’ll need to add that in. We’ll be using 100 as the _hitForce
value for now.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea); Instantiate(slashEffect, SideAttackTransform); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage, (transform.position - objectsToHit[i].transform.position).normalized); hitEnemies.Add(e); } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
b. Player Recoil
Firstly, in the PlayerStateList
, create a public bool recoilingX
and recoilingY
, and a bool lookingRight
.
PlayerStateList.cs
public class PlayerStateList : MonoBehaviour { public bool jumping = false; public bool dashing = false; public bool recoilingX, recoilingY; public bool lookingRight; }
Now in the PlayerController
, add a section for recoil in the namespace
. Then, create 2 int
s recoilXSteps
and recoilYSteps
and default both to 5. These will determine how far the player will recoil. Also, create 2 float
s recoilXSpeed
and recoilYSpeed
to determine the speed and default both to 100. set the status of pState.lookingRight
in Flip()
.
Afterwards, create a method Recoil()
. In the function, set the player to recoil left if they’re facing right and vice versa for recoiling in the X axis. For recoiling in the Y axis, set the player to recoil upwards when they attack downwards and vice versa. Cancel the Y recoil if the player is grounded so that the player won’t cause any glitches like wall clipping. We will also reset the jump counter back to 0 when recoiling in the Y axis.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs [Space(5)] [Header("Recoil Settings:")] [SerializeField] private float recoilXSpeed = 100; //the speed of horizontal recoil [SerializeField] private float recoilYSpeed = 100; //the speed of vertical recoil [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = false; } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = true; } } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea); Instantiate(slashEffect, SideAttackTransform); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage, (transform.position - objectsToHit[i].transform.position).normalized); hitEnemies.Add(e); } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } void Recoil() { if(pState.recoilingX) { if(pState.lookingRight) { rb.velocity = new Vector2(-recoilXSpeed, 0); } else { rb.velocity = new Vector2(recoilXSpeed, 0); } } if(pState.recoilingY) { rb.gravityScale = 0; if (yAxis < 0) { rb.velocity = new Vector2(rb.velocity.x, recoilYSpeed); } else { rb.velocity = new Vector2(rb.velocity.x, -recoilYSpeed); } airJumpCounter = 0; } else { rb.gravityScale = gravity; } } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
Article continues after the advertisement:
To activate the recoil, we’ll add a ref bool _recoilDir
, and a float recoilStrength
in the Hit()
method which will serve as the direction of the recoil and how much force the recoil will be. Then if we hit anything in the attackable layer, we make _recoilDir = true
so that the recoil will be effected in the respective directions.
We’ll need to update Hit()
in the Attack()
method as well. For side attack, add ref pState.recoilingX
and recoilXSpeed
, and for attacking up and down, add ref pState.recoilingY
and recoilYSpeed
. So the respective pState
recoil bool
s will be activated, and thus, the Recoil()
method will run at the respectively set speed.
We’ll also change the enemy’s _hitForce
from 100 to _recoilStrength
so it’s the same as the player.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs [Space(5)] [Header("Recoil Settings:")] [SerializeField] private float recoilXSpeed = 100; //the speed of horizontal recoil [SerializeField] private float recoilYSpeed = 100; //the speed of vertical recoil [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = false; } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = true; } } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea , ref pState.recoilingX, recoilXSpeed ); Instantiate(slashEffect, SideAttackTransform); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea , ref pState.recoilingY, recoilYSpeed ); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea , ref pState.recoilingY, recoilYSpeed ); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea , ref bool _recoilDir, float _recoilStrength ) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); if(objectsToHit.Length > 0) { _recoilDir = true; } for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage, (transform.position - objectsToHit[i].transform.position).normalized, _recoilStrength); hitEnemies.Add(e); } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } void Recoil() { if(pState.recoilingX) { if(pState.lookingRight) { rb.velocity = new Vector2(-recoilXSpeed, 0); } else { rb.velocity = new Vector2(recoilXSpeed, 0); } } if(pState.recoilingY) { rb.gravityScale = 0; if (yAxis < 0) { rb.velocity = new Vector2(rb.velocity.x, recoilYSpeed); } else { rb.velocity = new Vector2(rb.velocity.x, -recoilYSpeed); } airJumpCounter = 0; } else { rb.gravityScale = gravity; } } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
c. Stopping the Recoil
At this point, we have the recoil, but now we need to stop the recoil, otherwise, we’d recoil forever.
Create 4 int
s, stepsXRecoiled
, stepsYRecoiled
, recoilXSteps
and recoilYSteps
. These will control how far the player has and has to recoil. Next up, create a method StopRecoilX()
, set stepsXRecoiled
to 0, resetting how far the player has recoiled back to 0, and pState.recoilingX
to false
. Do the same for stopping recoil in the Y axis. These will stop the recoil in their respective axes.
Next, in the Recoil()
method, we’ll stop the recoil if the stepsRecoiled are more than the recoilSteps, otherwise, continue to recoil and increase the stepsRecoiled value. Also, call StopRecoilY()
when the player is grounded so they won’t clip through the ground. Then add Recoil()
to the Update()
method.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs [Space(5)] [Header("Recoil Settings:")] [SerializeField] private int recoilXSteps = 5; //how many FixedUpdates() the player recoils horizontally for [SerializeField] private int recoilYSteps = 5; //how many FixedUpdates() the player recoils vertically for [SerializeField] private float recoilXSpeed = 100; //the speed of horizontal recoil [SerializeField] private float recoilYSpeed = 100; //the speed of vertical recoil private int stepsXRecoiled, stepsYRecoiled; //the no. of steps recoiled horizontally and verticall [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //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; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); Recoil(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = false; } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = true; } } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea, ref pState.recoilingX, recoilXSpeed); Instantiate(slashEffect, SideAttackTransform); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea, ref pState.recoilingY, recoilYSpeed); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea, ref pState.recoilingY, recoilYSpeed); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea, ref bool _recoilDir, float _recoilStrength) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); if(objectsToHit.Length > 0) { _recoilDir = true; } for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage, (transform.position - objectsToHit[i].transform.position).normalized, _recoilStrength); hitEnemies.Add(e); } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } void Recoil() { if(pState.recoilingX) { if(pState.lookingRight) { rb.velocity = new Vector2(-recoilXSpeed, 0); } else { rb.velocity = new Vector2(recoilXSpeed, 0); } } if(pState.recoilingY) { rb.gravityScale = 0; if (yAxis < 0) { rb.velocity = new Vector2(rb.velocity.x, recoilYSpeed); } else { rb.velocity = new Vector2(rb.velocity.x, -recoilYSpeed); } airJumpCounter = 0; } else { rb.gravityScale = gravity; } //stop recoil if(pState.recoilingX && stepsXRecoiled < recoilXSteps) { stepsXRecoiled++; } else { StopRecoilX(); } if (pState.recoilingY && stepsYRecoiled < recoilYSteps) { stepsYRecoiled++; } else { StopRecoilY(); } if(Grounded()) { StopRecoilY(); } } void StopRecoilX() { stepsXRecoiled = 0; pState.recoilingX = false; } void StopRecoilY() { stepsYRecoiled = 0; pState.recoilingY = false; } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
6. Enemy AI Base Class
We want multiple enemy types in our game, so we’ll make this Enemy
class a base class for all the enemy types to reference, this allows all the enemies to have the same basic building blocks, and we can customize each of them as we see fit.
Firstly, we change all the variables we have to protected
. A protected
variable allows its subclasses to gain access to it whereas a private
variable doesn’t. Let’s also change all the methods to virtual
. virtual void
s can be overridden in the subclasses, allowing them to be more flexible but retaining the ability to use the same variables as the base class. We’ll then add a new protected float speed
as well.
Enemy.cs
public class Enemy : MonoBehaviour { [SerializeField] protected float health; [SerializeField] protected float recoilLength; [SerializeField] protected float recoilFactor; [SerializeField] protected bool isRecoiling = false; [SerializeField] protected float speed; protected float recoilTimer; protected Rigidbody2D rb; // Start is called before the first frame update protected virtual void Start() { rb = GetComponent<Rigidbody2D>(); } // Update is called once per frame protected virtual void Update() { if(health <= 0) { Destroy(gameObject); } if(isRecoiling) { if(recoilTimer < recoilLength) { recoilTimer += Time.deltaTime; } else { isRecoiling = false; recoilTimer = 0; } } } public virtual void EnemyHit(float _damageDone, Vector2 _hitDirection, float _hitForce) { health -= _damageDone; if(!isRecoiling) { rb.AddForce(-_hitForce * recoilFactor * _hitDirection); isRecoiling = True; } } }
a. Chase Player
As we are making multiple types of enemies with different movement characteristics, we’ll leave the Enemy
script as it is now, and create a new script. The first enemy type we’ll be creating is a zombie, so just name it Zombie
.
Firstly, let’s change the MonoBehaviour
to Enemy
so it inherits all the Enemy
methods and variables. We’ll create a protected override void
for all the current methods and add its base in it so it still runs the base method from the Enemy
class. Set its gravity to 12 so that it sticks to the ground as well.
Zombie.cs
public class Zombie :MonoBehaviourEnemy { // Start is called before the first frame update protected override void Start() { base.Start(); rb.gravityScale = 12f; } // Update is called once per frame protected override void Update() { base.Update(); } public override void EnemyHit(float _damageDone, Vector2 _hitDirection, float _hitForce) { base.EnemyHit(_damageDone, _hitDirection, _hitForce); } }
Article continues after the advertisement:
Now, in Update()
, let’s add the chase player function when the enemy is not recoiling. We only want the chase to work when the enemy is not recoiling so that the chase doesn’t affect the recoil.
Zombie.cs
public class Zombie : Enemy { // Start is called before the first frame update protected override void Start() { base.Start(); rb.gravityScale = 12f; } // Update is called once per frame protected override void Update() { base.Update(); if(!isRecoiling) { transform.position = Vector2.MoveTowards (transform.position, new Vector2(PlayerController.Instance.transform.position.x, transform.position.y), speed * Time.deltaTime); } } public override void EnemyHit(float _damageDone, Vector2 _hitDirection, float _hitForce) { base.EnemyHit(_damageDone, _hitDirection, _hitForce); } }
Remove the Enemy
class from the cube and add the Zombie
script instead before testing it out. Set the speed to 10.
b. Attack Player
Ok now let’s add an Attack Player function. First things first, create some health for the player. In the PlayerController
, add a public int health
and maxHealth
. health
is the current health of the player, whereas maxHealth
is the maximum health the player has at the moment. Set the health
to maxHealth
in Start()
.
Then, create a method ClampHealth()
so the health can’t go above the maximum or minimum. Create a void TakeDamage()
that takes an argument: float _damage
. Inside we’ll decrease the health
by _damage
.
PlayerController.cs
public class PlayerController : MonoBehaviour { [Header("Horizontal Movement Settings:")] [SerializeField] private float walkSpeed = 1; //sets the players movement speed on the ground [Space(5)] [Header("Vertical Movement Settings")] [SerializeField] private float jumpForce = 45f; //sets how hight the player can jump private float jumpBufferCounter = 0; //stores the jump button input [SerializeField] private float jumpBufferFrames; //sets the max amount of frames the jump buffer input is stored private float coyoteTimeCounter = 0; //stores the Grounded() bool [SerializeField] private float coyoteTime; //sets the max amount of frames the Grounded() bool is stored private int airJumpCounter = 0; //keeps track of how many times the player has jumped in the air [SerializeField] private int maxAirJumps; //the max no. of air jumps private float gravity; //stores the gravity scale at start [Space(5)] [Header("Ground Check Settings:")] [SerializeField] private Transform groundCheckPoint; //point at which ground check happens [SerializeField] private float groundCheckY = 0.2f; //how far down from ground chekc point is Grounded() checked [SerializeField] private float groundCheckX = 0.5f; //how far horizontally from ground chekc point to the edge of the player is [SerializeField] private LayerMask whatIsGround; //sets the ground layer [Space(5)] [Header("Dash Settings")] [SerializeField] private float dashSpeed; //speed of the dash [SerializeField] private float dashTime; //amount of time spent dashing [SerializeField] private float dashCooldown; //amount of time between dashes [SerializeField] GameObject dashEffect; private bool canDash = true, dashed; [Space(5)] [Header("Attack Settings:")] [SerializeField] private Transform SideAttackTransform; //the middle of the side attack area [SerializeField] private Vector2 SideAttackArea; //how large the area of side attack is [SerializeField] private Transform UpAttackTransform; //the middle of the up attack area [SerializeField] private Vector2 UpAttackArea; //how large the area of side attack is [SerializeField] private Transform DownAttackTransform; //the middle of the down attack area [SerializeField] private Vector2 DownAttackArea; //how large the area of down attack is [SerializeField] private LayerMask attackableLayer; //the layer the player can attack and recoil off of [SerializeField] private float timeBetweenAttack; private float timeSinceAttack; [SerializeField] private float damage; //the damage the player does to an enemy [SerializeField] private GameObject slashEffect; //the effect of the slashs [Space(5)] [Header("Recoil Settings:")] [SerializeField] private int recoilXSteps = 5; //how many FixedUpdates() the player recoils horizontally for [SerializeField] private int recoilYSteps = 5; //how many FixedUpdates() the player recoils vertically for [SerializeField] private float recoilXSpeed = 100; //the speed of horizontal recoil [SerializeField] private float recoilYSpeed = 100; //the speed of vertical recoil private int stepsXRecoiled, stepsYRecoiled; //the no. of steps recoiled horizontally and verticall [Space(5)] [Header("Health Settings")] public int health; public int maxHealth; [Space(5)] private PlayerStateList pState; private Animator anim; private Rigidbody2D rb; //Input Variables private float xAxis, yAxis; private bool attack = false; //creates a singleton of the PlayerController public static PlayerController Instance; private void Awake() { if(Instance != null && Instance != this) { Destroy(gameObject); } else { Instance = this; } health = maxHealth; } // Start is called before the first frame update void Start() { pState = GetComponent<PlayerStateList>(); rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); gravity = rb.gravityScale; } private void OnDrawGizmos() { Gizmos.color = Color.red; Gizmos.DrawWireCube(SideAttackTransform.position, SideAttackArea); Gizmos.DrawWireCube(UpAttackTransform.position, UpAttackArea); Gizmos.DrawWireCube(DownAttackTransform.position, DownAttackArea); } // Update is called once per frame void Update() { GetInputs(); UpdateJumpVariables(); if (pState.dashing) return; Flip(); Move(); Jump(); StartDash(); Attack(); Recoil(); } void GetInputs() { xAxis = Input.GetAxisRaw("Horizontal"); yAxis = Input.GetAxisRaw("Vertical"); attack = Input.GetMouseButtonDown(0); } void Flip() { if (xAxis < 0) { transform.localScale = new Vector2(-Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = false; } else if (xAxis > 0) { transform.localScale = new Vector2(Mathf.Abs(transform.localScale.x), transform.localScale.y); pState.lookingRight = true; } } 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; } void Attack() { timeSinceAttack += Time.deltaTime; if(attack && timeSinceAttack >= timeBetweenAttack) { timeSinceAttack = 0; anim.SetTrigger("Attacking"); if(yAxis == 0 || yAxis < 0 && Grounded()) { Hit(SideAttackTransform, SideAttackArea, ref pState.recoilingX, recoilXSpeed); Instantiate(slashEffect, SideAttackTransform); } else if(yAxis > 0) { Hit(UpAttackTransform, UpAttackArea, ref pState.recoilingY, recoilYSpeed); SlashEffectAtAngle(slashEffect, 80, UpAttackTransform); } else if (yAxis < 0 && !Grounded()) { Hit(DownAttackTransform, DownAttackArea, ref pState.recoilingY, recoilYSpeed); SlashEffectAtAngle(slashEffect, -90, DownAttackTransform); } } } void Hit(Transform _attackTransform, Vector2 _attackArea, ref bool _recoilDir, float _recoilStrength) { Collider2D[] objectsToHit = Physics2D.OverlapBoxAll(_attackTransform.position, _attackArea, 0, attackableLayer); List<Enemy> hitEnemies = new List<Enemy>(); if(objectsToHit.Length > 0) { _recoilDir = true; } for(int i = 0; i < objectsToHit.Length; i++) { Enemy e = objectsToHit[i].GetComponent<Enemy>(); if(e && !hitEnemies.Contains(e)) { e.EnemyHit(damage, (transform.position - objectsToHit[i].transform.position).normalized, _recoilStrength); hitEnemies.Add(e); } } } void SlashEffectAtAngle(GameObject _slashEffect, int _effectAngle, Transform _attackTransform) { _slashEffect = Instantiate(_slashEffect, _attackTransform); _slashEffect.transform.eulerAngles = new Vector3(0, 0, _effectAngle); _slashEffect.transform.localScale = new Vector2(transform.localScale.x, transform.localScale.y); } void Recoil() { if(pState.recoilingX) { if(pState.lookingRight) { rb.velocity = new Vector2(-recoilXSpeed, 0); } else { rb.velocity = new Vector2(recoilXSpeed, 0); } } if(pState.recoilingY) { rb.gravityScale = 0; if (yAxis < 0) { rb.velocity = new Vector2(rb.velocity.x, recoilYSpeed); } else { rb.velocity = new Vector2(rb.velocity.x, -recoilYSpeed); } airJumpCounter = 0; } else { rb.gravityScale = gravity; } //stop recoil if(pState.recoilingX && stepsXRecoiled < recoilXSteps) { stepsXRecoiled++; } else { StopRecoilX(); } if (pState.recoilingY && stepsYRecoiled < recoilYSteps) { stepsYRecoiled++; } else { StopRecoilY(); } if(Grounded()) { StopRecoilY(); } } void StopRecoilX() { stepsXRecoiled = 0; pState.recoilingX = false; } void StopRecoilY() { stepsYRecoiled = 0; pState.recoilingY = false; } public void TakeDamage(float _damage) { health -= Mathf.RoundToInt(_damage); } void ClampHealth() { health = Mathf.Clamp(health, 0, maxHealth); } 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 (!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); } } if (Input.GetButtonUp("Jump") && rb.velocity.y > 0) { rb.velocity = new Vector2(rb.velocity.x, 0); pState.jumping = false; } 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; } } }
Next, go to the Enemy cube and make the current collider slightly shorter and thinner, then well add another BoxCollider2D to it, make this a Trigger. This will detect if the player is touching the enemy and attack the player if it is.
In the Enemy
script, add a protected float damage
. Then a protected virtual void Attack()
. Here we’ll add in the damage player function. To call this Attack()
method, we’ll create a protected virtual void OnTriggerStay2D(Collider2d _other)
which will detect if the player is in the trigger collider, and if they are, attack the player.
Enemy.cs
public class Enemy : MonoBehaviour { [SerializeField] protected float health; [SerializeField] protected float recoilLength; [SerializeField] protected float recoilFactor; [SerializeField] protected bool isRecoiling = false; [SerializeField] protected float speed; [SerializeField] protected float damage; protected float recoilTimer; protected Rigidbody2D rb; // Start is called before the first frame update protected virtual void Start() { rb = GetComponent<Rigidbody2D>(); } // Update is called once per frame protected virtual void Update() { if(health <= 0) { Destroy(gameObject); } if(isRecoiling) { if(recoilTimer < recoilLength) { recoilTimer += Time.deltaTime; } else { isRecoiling = false; recoilTimer = 0; } } } public virtual void EnemyHit(float _damageDone, Vector2 _hitDirection, float _hitForce) { health -= _damageDone; if(!isRecoiling) { rb.AddForce(-_hitForce * recoilFactor * _hitDirection); isRecoiling = True; } } protected void OnTriggerStay2D(Collider2D _other) { if(_other.CompareTag("Player") && !PlayerController.Instance.pState.invincible) { Attack(); } } protected virtual void Attack() { PlayerController.Instance.TakeDamage(damage); } }
Article continues after the advertisement: