It’s not always “one jump, one kill” in the land of side-scrolling platformers. Today we looked at some alternate approaches to handling player/enemy interactions. (These are not required for your final project… you already have everything you need there. This week is all about how to do other cool things with Unity Engine now that you know the basics! )
Part 1: Simplifying our Controller
One aspect of our initial controller continues to bother me, and that is the Ground Transform object that we ended up using. On the one hand, it is convenient because when we flip our character, that child object also inherits that flip. On the other hand, we have to select the object that is NOT our controller in order to adjust the transform position.
In the first part of today’s lesson, we rewrite aspects of the SideScrollerController that handle the ground-check position. Along the way we encountered some issues, because Vector3 math can be selective at times. Our process was to replace reference to that GroundCheck child object with a simple offset value (recorded as a Vector2) that we would add to the transform of our character object.
This created issues when we attempted to add a Vector2 to a Vector3, but this was resolved by simply converting the Vector2 into a Vector3 by declaration. Then we added that offset to the object position. Also, to account for conditions where we face opposite directions, we multiply the Vector2 value by transform.localScale (which is how we are flipping our character)
void FixedUpdate() { Vector3 groundCheckOffset = groundCheckPosition * transform.localScale; // Check if player is grounded Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position + groundCheckOffset, colliderRadius, groundLayer); // check for a grounding collider isGrounded = colliders.Length > 0 ? true : false; }
There was also an unfortunate bug introduced by the “conveyer” aspects that we added last time. This prevented us from initiating movement from a standstill. Removing the “conveyer” components of our move commands resolved this issue.
Part 2: Other Enemy Death Options
Next we adjusted our enemy object to die differently. We created a simple cylindrical object that would serve as our enemy. We adjusted our EnemyScript to get these objects out of the way by placing them on a different layer (one that would interact with the “Ground” layer, but nothing else). This prevents the player from colliding with them once the death trigger has been hit. We also used the SpriteRenderer’s “Color” property to change the sprite to gray, and turned off the “freeze rotation” option on the Rigidbody2D. Finally, a slight velocity assured that our character would “fall over”.
private void EnemyHasDied() { // no longer alive _isAlive = false; // destroy the instance Destroy(this.gameObject, 3.0f); // change the color sprite.color = Color.gray; // roll over rb.freezeRotation = false; // put this on the dead layer this.gameObject.layer = 13; rb.velocity = Vector3.right; }
Part 3: Pushback
Up to this point, our enemy has been capable of one-shotting our Player character, but many times these games feature a “health” system, which allows for multiple hits before player death. One issue commonly encountered when first coding these health-based mechanics is accounting for multiple successive hits. Our enemy characters keep pushing ahead, often times this can cause multiple hits before your player has time to react. To address this, we introduce two mechanics – The first is a “stunned” state, where the player is incapable of controlling movement, but is also invulnerable for a period of time.
We accomplish this by introducing a “stunned” boolean to the character controller, and testing that when we use our input or process collisions with enemies. We only process the collision if stunned == false, otherwise we ignore it. If we DO get hit, then we set “stunned” to true, and run an animation (here we keyframe the color value of the sprite to give a red tint that fades out over one second). We use Animation Events to call another function that ends our stunned status, setting the value back to false.
The second mechanic is a “pushback” force… if we are hurt by an enemy, we should be propelled back in the opposite direction. here we accomplish this by drawing a line between the two characters and applying a force in the opposite direction.
private void OnCollisionEnter2D(Collision2D collision) { if (collision.transform.tag == "Enemy") { if (controller.stunned == false) { // take damage controller.stunned = true; // run the hurt animation animator.SetTrigger("Stunned"); Vector3 pushback = collision.transform.position - transform.position; GetComponent<Rigidbody2D>().velocity = pushback * -pushbackForce; } } }
Part 4: Melee Attacks (Hitbox and Hurtbox)
Often times our characters have attack animations, such as the one in the Ninja pack that we used for today’s class. These attack areas often fall outside of the original sprite dimensions, meaning that we must define a volume that will be the area of the attack’s effect. This is know as a hitbox and conversely, the area that is vulnerable to damage is the hurtbox. In the case of our Enemy object, the Box Collider trigger is our hurtbox. (Our player, on the other hand, is one giant hurtbox.)
To define this hitbox, we created a similar system to our “Ground Check” – defining an point relative to the player object, and a radius of effect. Once we attached this to an “attack” animation, we noticed that the death was instant, before the sword even reached the enemy objects. To counteract this, we created another Animation Event that calls the hitbox check midway through the swing, which improves the timing to match the movement.
Player.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Player : MonoBehaviour { public int score = 0; public bool isAlive = true; private SideScrollerController controller; private Animator animator; public float pushbackForce; [Header("Attack Check Values")] public float attackXOffset = 0; public float attackYOffset = 0; public float attackRadius = 0.5f; public LayerMask attackLayer; private void Start() { controller = GetComponent<SideScrollerController>(); animator = GetComponent<Animator>(); } private void Update() { if (Input.GetButtonDown("Fire1")) { if (!controller.stunned) { // call the attack PlayerAttack(); } } } private void PlayerAttack() { // queue the animation animator.SetTrigger("Attack"); // make the attack happen } public void ProcessAttack() { // offset vector Vector3 offset = new Vector3(attackXOffset, attackYOffset); offset.x *= transform.localScale.x; // run physics Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position + offset, attackRadius, attackLayer); foreach (Collider2D thisCollider in colliders) { // yell "hit" at the object thisCollider.gameObject.SendMessage("SuccessfulHit"); } } private void OnTriggerEnter2D(Collider2D collision) { if (collision.transform.tag == "EndOfLevel") { LevelManager.S.NextLevel(); } if (collision.transform.tag == "EndOfGame") { LevelManager.S.GoToStartMenu(); } } private void OnCollisionEnter2D(Collision2D collision) { if (collision.transform.tag == "Enemy") { if (controller.stunned == false) { // take damage controller.stunned = true; // run the hurt animation animator.SetTrigger("Stunned"); Vector3 pushback = collision.transform.position - transform.position; GetComponent<Rigidbody2D>().velocity = pushback * -pushbackForce; } } } public void StunExpires() { controller.stunned = false; } public void PlayerHasDied() { LevelManager.S.RestartLevel(); } private void OnDrawGizmos() { // offset vector Vector3 offset = new Vector3(attackXOffset, attackYOffset); offset.x *= transform.localScale.x; // Draw a yellow sphere at the ground check position Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(transform.position + offset, attackRadius); } }
EnemyScript.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class EnemyScript : MonoBehaviour { public float speed; public bool faceLeft = true; public bool _isAlive = true; private Rigidbody2D rb; private SpriteRenderer sprite; // Start is called before the first frame update void Start() { rb = GetComponent<Rigidbody2D>(); sprite = GetComponent<SpriteRenderer>(); } // Update is called once per frame void Update() { /* if (faceLeft && sprite.flipX) { sprite.flipX = false; } else if (!faceLeft && !sprite.flipX) { sprite.flipX = true; } */ if (faceLeft == sprite.flipX) { sprite.flipX = !sprite.flipX; } } private void FixedUpdate() { if (_isAlive) { float direction = faceLeft ? -1 : 1; rb.velocity = new Vector2(direction * speed, rb.velocity.y); } } private void OnTriggerEnter2D(Collider2D collision) { if (collision.gameObject.tag == "TurnAround") { faceLeft = !faceLeft; // invert faceleft } if (collision.gameObject.tag == "Player") { EnemyHasDied(); } } public void SuccessfulHit() { if(_isAlive) { EnemyHasDied(); } } private void EnemyHasDied() { // no longer alive _isAlive = false; // cue the death animation // GetComponent<Animator>().SetTrigger("EnemyDied"); // increase the score // GameManager.S.IncreaseScore(50); // destroy the instance Destroy(this.gameObject, 3.0f); // change the color sprite.color = Color.gray; // roll over rb.freezeRotation = false; // put this on the dead layer this.gameObject.layer = 13; rb.velocity = Vector3.right; } }
SideScrollerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] [RequireComponent(typeof(CapsuleCollider2D))] /* * SideScrollerController - created for UGE Summer 23 * Based on Sharp Coder 2D Controller (https://sharpcoderblog.com/blog/2d-platformer-character-controller) */ public class SideScrollerController : MonoBehaviour { // Player Movement Variables public float maxSpeed = 3.4f; public float jumpHeight = 6.5f; public float gravityScale = 1.5f; float moveDirection = 0; public bool isGrounded = true; public bool isConveyer = false; public bool facingRight = true; public LayerMask conveyerLayer; // public Transform groundCheckObject; [Header("Ground Check Values")] public Vector2 groundCheckPosition; public float colliderRadius = 0.5f; public LayerMask groundLayer; private Animator animator; public bool stunned = false; // components Rigidbody2D r2d; CapsuleCollider2D mainCollider; // Transform t; // Start is called before the first frame update void Start() { // set the component variables r2d = GetComponent<Rigidbody2D>(); mainCollider = GetComponent<CapsuleCollider2D>(); // setting some component properties r2d.freezeRotation = true; r2d.collisionDetectionMode = CollisionDetectionMode2D.Continuous; r2d.gravityScale = gravityScale; // set the initial facing right value facingRight = transform.localScale.x > 0; animator = GetComponent<Animator>(); } // Update is called once per frame void Update() { // Movement controls moveDirection = Input.GetAxis("Horizontal"); // set animation controls // animator.SetFloat("speed", Mathf.Abs(moveDirection)); // animator.SetBool("isOnGround", isGrounded); // TO-DO: Implement Change Facing Direction if (moveDirection > 0 && !facingRight) { facingRight = true; transform.localScale = new Vector3(Mathf.Abs(transform.localScale.x), transform.localScale.y, transform.localScale.z); } if (moveDirection < 0 && facingRight) { facingRight = false; transform.localScale = new Vector3(-Mathf.Abs(transform.localScale.x), transform.localScale.y, transform.localScale.z); } // Jumping if (Input.GetButtonDown("Jump") && isGrounded){ r2d.velocity = new Vector2(r2d.velocity.x, jumpHeight); } } void FixedUpdate() { if (!stunned) { r2d.velocity = new Vector2((moveDirection) * maxSpeed, r2d.velocity.y); } Vector3 groundCheckOffset = groundCheckPosition * transform.localScale; // Check if player is grounded Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position + groundCheckOffset, colliderRadius, groundLayer); // check for a grounding collider isGrounded = colliders.Length > 0 ? true : false; } private void OnDrawGizmos() { Vector3 groundCheckOffset = groundCheckPosition * transform.localScale; // Draw a yellow sphere at the ground check position Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(transform.position + groundCheckOffset, colliderRadius); } /* private void OnCollisionEnter2D(Collision2D collision) { if (collision.transform.tag == "Enemy") { // Death happens here // queue the death animation animator.SetTrigger("Death"); } } */ }