Breakout Clone 9: Items
2023-04-24When a block is destroyed, I want there to be a small chance of an item dropping and falling towards the paddle. If the player's paddle collides with the item, the item's effect is applied to the game. While playing some other breakout-style games, I've seen three kinds of common items that can appear:
- An item that increases the players score (bonus points)
- An item that makes any balls currently on the screen "sticky". If these balls collide with the paddle, then they'll stick to the paddle, and the player has the option to move the paddle and release the ball at an advantageous position.
- An item that spawns a new ball for every ball currently on the screen.
Let's start with the code for spawning an item, then we can figure out what the items will actually look like and how to implement the logic behind their effects.
First, in Block.cs
we'll add some lines that will call a new DropItem
function randomly when a block is destroyed.
// In Block.cs
// *NEW* add an attribute that represnts how often an item will drop
[SerializeField] int percentageChanceOfItemDrop = 30;
private void DestroyBlockIfDead() {
if (currentHealth > 0) {
return;
}
GameSession gameSession = FindObjectOfType<GameSession>();
gameSession.AddToScore(blockValue);
int numberOfBlocksRemaining = GameObject.FindGameObjectsWithTag("Block").Length - 1;
if(numberOfBlocksRemaining == 0) {
gameSession.LoadNextLevel();
}
// Pick a random number
int randomNumber = Random.Range(1, 100); // *NEW*
// If our random number happens to be in our range (1 to 30)
if (randomNumber <= percentageChanceOfItemDrop) { // *NEW*
DropItem(gameSession); // *NEW*
}
Destroy(gameObject);
}
// *NEW*
private void DropItem(GameSession gameSession) {
GameObject item = Instantiate(gameSession.PickItemToDrop(), transform.position, Quaternion.identity);
item.transform.SetParent(null, true);
}
We'll need to define PickItemToDrop
on GameSession
:
// in GameSession.cs
// This field will contain our list of items
[SerializeField] GameObject[] dropableItems;
public GameObject PickItemToDrop() {
return dropableItems[Random.Range(0, dropableItems.Length)];
}
Now we'll need to define a list of game objects to populate the dropableItems
field with. Each item will have its unique effect that will be applied when they collide with the paddle, but there will be some shared logic too, so I think this is a good case for some inheritance.
First, I'll define a FallingItem
script:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class FallingItem : MonoBehaviour
{
private Rigidbody2D rigidBody2D;
[SerializeField] Vector3 fallingVelocity = new Vector3(0, -1f, 0);
void Awake() {
rigidBody2D = GetComponent<Rigidbody2D>();
}
void Start() {
rigidBody2D.velocity = fallingVelocity;
}
void OnCollisionEnter2D(Collision2D collision) {
string tag = collision.gameObject.tag;
if (tag != "Paddle") {
return;
}
Apply();
}
protected virtual void Apply() {}
}
Each item will fall at the same rate, and they'll have the exact same logic for detecting collisions. When we detect a collision with the paddle, we'll call the Apply
method - that's the method we'll need each of our children objects to define.
The "bonus" item - the item that simply applies an amount to the player's score is pretty easy since we already have a way of adding to the score:
using UnityEngine;
using System;
using System.Collections.Generic;
public class Bonus : FallingItem
{
protected override void Apply() {
GameSession gameSession = FindObjectOfType<GameSession>();
gameSession.AddToScore(1000);
Destroy(gameObject);
}
}
The next two are a bit more tricky. We'll need to define additional logic on our Ball script.
First, the MultiBall
item duplicates every other ball on the screen:
using UnityEngine;
using System;
using System.Collections.Generic;
public class MultiBall : FallingItem
{
protected override void Apply() {
Ball[] balls = FindObjectsOfType<Ball>();
foreach(Ball ball in balls) {
ball.Clone();
}
Destroy(gameObject);
}
}
Then on Ball
, we'll define our Clone
method:
public void Clone() {
// create a new ball
GameObject newBall = Instantiate(ballPrefab, transform.position, Quaternion.identity);
transform.SetParent(null, true);
// keep y direction the same, but make it go in the negative-x direction
newBall.GetComponent<Rigidbody2D>().velocity = (rigidBody2D.velocity * new Vector2(-1f, 1f));
}
This will create a ball going in the opposite x direction at the same position of the ball object we're calling Clone
on.
The Sticky
item is the trickiest. After the item is picked up by the paddle, we want to alter the state of all balls currently on the screen. Whenever those balls collide with the paddle, they'll stick to it instead of bouncing off.
First we'll start with our item:
using UnityEngine;
using System;
using System.Collections.Generic;
public class Sticky : FallingItem
{
protected override void Apply() {
Ball[] balls = FindObjectsOfType<Ball>();
foreach(Ball ball in balls) {
ball.MakeSticky();
}
Destroy(gameObject);
}
}
Then on our Ball
object we'll define MakeSticky
. All this does is alter the state of a private boolean attribute on the object:
public void MakeSticky() {
stickyBall = true;
}
We'll alter our collision detection logic on Ball
to handle the case where we're colliding with the paddle:
void OnCollisionEnter2D(Collision2D collision) {
string tag = collision.gameObject.tag;
if (tag == "Block" || tag == "Wall" || tag == "Paddle") {
Vector2 currentVelocity = rigidBody2D.velocity;
// A vector perpendicular to the surface we're colliding with
Vector2 normal = collision.GetContact(0).normal;
// calculate the new, reflected velocity
Vector2 newVelocity = Vector2.Reflect(currentVelocity, normal);
ballVelocity = newVelocity;
rigidBody2D.velocity = newVelocity;
SoundManager.instance.PlayRandomSFX(collisionClips);
}
// *NEW*
if (tag == "Paddle" && stickyBall) {
PlaceBallOnPaddle();
}
if (tag == "Block") {
collision.gameObject.GetComponent<Block>().TakeHit(1);
}
if (tag == "Bottom Wall") {
BottomHit();
}
}
Implementing the PlaceBallOnPaddle
function was interesting. First, we want the ball to remain at the same position when it collided with the paddle, and remember what it's output vector will be when it is eventually released. Finally, we want the ball to travel with the paddle as the player re-positions it. Thankfully our collision detection is already keeping track of the ball's velocity (in ballVelocity
,) so we can use that when we release it. Keeping the ball stationary and tracking the paddle is as easy as setting the velocity of the RigidBody2D component to some stationary velocity, and setting the parent of the ball to the paddle. We'll also disable collisions on the ball since we don't need to perform those calculations while it's stuck:
private Vector2 stationaryVelocity = new Vector2(0, 0);
private void PlaceBallOnPaddle() {
// disable collisions
circleCollider2D.enabled = false;
// make the ball stationary
rigidBody2D.velocity = stationaryVelocity;
// attach the ball to the paddle
GameObject paddle = GameObject.FindGameObjectWithTag("Paddle");
transform.SetParent(paddle.transform, true);
}
When we release the ball, we'll perform the operations in reverse order:
private void ReleaseBall() {
// detach the ball from the paddle
transform.SetParent(null, true);
// set the velocity of the ball to the saved velocity, making sure it's
// going "up"
rigidBody2D.velocity = new Vector2(ballVelocity.x, Mathf.Abs(ballVelocity.y));
// enable collisions
circleCollider2D.enabled = true;
}
This kinda worked, but I was hitting a bug where the ball would sometimes immediately re-collide with the paddle after being released. I fixed that by waiting half a second before re-enabling collision detection:
private void ReleaseBall() {
// detach the ball from the paddle
transform.SetParent(null, true);
// set the velocity of the ball to the saved velocity
rigidBody2D.velocity = new Vector2(ballVelocity.x, Mathf.Abs(ballVelocity.y));
// enable collisions after a delay
StartCoroutine(AllowCollisions());
}
IEnumerator AllowCollisions() {
yield return new WaitForSeconds(0.5f);
circleCollider2D.enabled = true;
}
Finally we'll call ReleaseBall
whenever the spacebar is pressed. This is the first and only change we'll make to the Ball object's Update
method:
void Update() {
bool spacePressed = Input.GetButtonDown("Jump");
if (spacePressed) {
ReleaseBall();
}
}
Whew, That was a lot! All that's left now is to create some game objects, find some art to represent our items, and fill in the dropableItems
array on GameSession
. This is all fairly standard Unity stuff - right click in the Hierarchy window and create a 2D sprite object, assign a Script component, assign a sprite, etc. Surprisingly the hardest part was actually deciding on sprites that represent our multi-ball, sticky, and bonus points items.
I decided to keep it simple and pick some letters. I found some 8-bit letters on OpenGameArt and chose the M, S, and B letters:
And that's it! Here are the item drops working in all their glory: