Breakout - Step 5 - Part 1

Goal:
This is our last step. At the end of the last tutorial we had a working game. Now we are going to add two last features to really finish it off: powerups and a persistent high score table. This actually requires quite a bit of code, so I’m breaking this into two different pages. For clarity, I strongly recommend you download the package and use a diff program to compare this to the last tutorial as you read instead of trying to piece together the code I add one step at a time.
Planning the Powerups
For the powerups, we need to make changes in quite a few places. We need a powerup class to render the powerups on the screen for the player to collect. We need timers for dropping them, applying them, and turning them back off. We need to check for collisions with the powerups and collisions with their effects. First things first, we need to decide what we are adding. I threw together some powerup images and decided I was going to add 4 different powerups: 1up, big paddle, slow ball, and a devastating laser. Let’s get started.
The Powerup Class
This is our sprite class for rendering the powerups on screen as they fall toward the player. Not much different than the other sprites we already have. When we create it we are going to tell it what type of powerup to be and it will pick its image and starting location and drop itself as the frames go by.
class Powerup(pygame.sprite.Sprite):
"""A powerup."""
def __init__(self, type='bigpaddle'):
pygame.sprite.Sprite.__init__(self)
# some variables we need
self.type = type # which powerup is it
self.collected = False # has it been collected yet?
self.countdown = 1 # duration of the effect
# set individual countdowns for the powerups with actual durations
if type == 'bigpaddle':
self.countdown = 60 * 25
elif type == 'slowball':
self.countdown = 60 * 10
self.imagepaths = {
'bigpaddle': os.path.join('images', 'powerup_paddle.png'),
'laser': os.path.join('images', 'powerup_laser.png'),
'1up': os.path.join('images', 'powerup_lightning.png'),
'slowball': os.path.join('images', 'powerup_ball.png'),
}
# set image and rect so we can be rendered
self.image = pygame.image.load(self.imagepaths[type])
self.rect = self.image.get_rect()
# set initial position somewhere near the top but below the blocks
self.rect.center = random.randint(20, 500), 125
def update(self):
"""Called every frame. Move the powerup down a bit so it 'falls' down
the screen. Return false if below the screen because the player
missed it."""
self.rect.y += 2
if self.rect.y > 600:
return False
return True
Game.init
Next we add a couple variables to the Game.init for managing our powerup system. We are going to track our current powerup (falling or applied) and have a countdown until the next drop (starting at roughly 10 seconds).
# variables for powerups
self.currentpowerup = None
self.powerupdrop = 60 * 10
Game.run
Next we are going to tweak our sprite rendering code so the update methods return false when the sprites need to be removed. Change the sprite.update() section to look like below. This will allow us to correctly delete our laser sprite when it is finished. We are also going to call our managePowerups method each frame.
# update our sprites
for sprite in self.sprites:
alive = sprite.update()
if alive is False:
self.sprites.remove(sprite)
# manage powerups
self.managePowerups()
Game.managePowerups
This is a hefty method for handling all the new powerup functionality. I should have split it up into multiple methods for clarity but I didn’t so you’ll just have to suffer through it. Take it as another ‘what not to do’ lesson. :) The first case we are going to handle in this function is when there is no active powerup and there isn’t one currently dropping. We are going to decrement our countdown until dropping and, if it hits zero, we create a new powerup. Our powerup class handles its location and dropping so all we have to do is decide which powerup to make. I made a little array of tuples with the ‘chances in 100’ for that powerup to drop, then created a random number between 1 and 100 and used the corresponding powerup.
def managePowerups(self):
"""Called each frame. Drops new powerups as necessary. Checks if the
paddle hits a powerup and applies the powerup. Manages the powerups
timing out."""
# no powerup, update countdown and drop one if necessary
if self.currentpowerup is None:
# decrement powerup drop countdown
if not self.isReset: # dont continue countddown if waiting to serve
self.powerupdrop -= 1
# drop a powerup if time
if self.powerupdrop <= 0:
# drop chances to use with random
droppercentages = [
(10, '1up'), # 10% chance
(30, 'laser'), # 20% chance
(55, 'slowball'), # 25% chance
(100, 'bigpaddle') # 45% chance
]
# decide which powerup to drop
choice = random.uniform(0,100)
for chance, type in droppercentages:
if choice <= chance:
# create new powerup and add it to render group
self.currentpowerup = Powerup(type)
self.sprites.add(self.currentpowerup)
break
return
Next we are going to handle the case we just created above – the user has no powerup but one has spawned and is falling down the screen. In this case we need to check if the paddle hits the powerup and, if so, apply the effect. We call some methods we haven’t written yet but the names should make them pretty obvious. The effects are all pretty easy to apply - the only one that really does anything to the game is the laser, which we will make a sprite for and check the collisions. Note that we don’t care if the Block thinks it should be deleted, we just delete it anyway - this way the laser destroys the solid, formerly indestructible blocks too. Here we also check if the powerup is ‘dead’ because the user missed it. If so, we reset the countdown to shorter than normal - in this case between 10 and 20 seconds.
# if powerup hasn't been collected yet, check for collision
if not self.currentpowerup.collected:
# collision, ie: the player collected the powerup
if self.paddle.rect.colliderect(self.currentpowerup.rect):
# apply the powerup
if self.currentpowerup.type == 'bigpaddle':
# increase paddle size
self.paddle.grow()
elif self.currentpowerup.type == 'laser':
# create laser sprite for rendering
laser = Laser(self.paddle.rect.centerx)
self.sprites.add(laser)
# destroy all blocks it touches
touchedblocks = pygame.sprite.spritecollide(laser, self.blocks, False)
for b in touchedblocks:
# add score for those blocks
alive,points = b.hit(1000)
self.score.add(points)
# remove blocks from render group
self.blocks.remove(b)
elif self.currentpowerup.type == '1up':
# increment player lives
self.lives.setLives( self.lives.getLives() + 1)
elif self.currentpowerup.type == 'slowball':
# decrease ball max speed
self.ball.slowDown()
# set collected so timer starts
self.currentpowerup.collected = True
self.sprites.remove(self.currentpowerup)
# not colliding - keep moving and check if we missed it
else:
# update powerup and delete if necessary
alive = self.currentpowerup.update()
if not alive:
self.sprites.remove(self.currentpowerup)
self.currentpowerup = None
# reset drop counter
self.powerupdrop = random.randint(60 * 10, 60 * 20)
Lastly, we need to countdown until the end of the powerup effect (only for big paddle and slow ball) and when we hit zero, turn them off and restart the countdown until a new powerup is dropped (30-60 seconds this time).
# if powerup is currently active, continue countdown
elif self.currentpowerup.countdown > 0:
# decrement countdown
self.currentpowerup.countdown -= 1
# powerup is over -- has been collected and countdown <= 0
else:
# if we haven't turned off the current powerup yet, do so
if self.currentpowerup is not None:
if self.currentpowerup.type == 'bigpaddle':
self.paddle.shrink()
elif self.currentpowerup.type == 'slowball':
self.ball.speedUp()
# set current to none
self.currentpowerup = None
# set new powerupdrop countdown
self.powerupdrop = random.randint(60 * 30, 60 * 60)
Applying the Big Paddle Powerup
Now we add two methods to the Paddle class - grow and shrink - to handle when we apply and remove the big paddle powerup. It is fairly straightforward - we save the current location, give it a new, expanded image, and place the center back where it was. I also check here to see if we are over one of the walls after growing and move us back in if necessary. Shrink is copy pasted with the regular sized paddle image. I can’t think of a way the paddle would be over a wall when shrinking, but I left that code there just in case.
def grow(self):
"""Increases the size of the paddle."""
# get current position
xy = self.rect.center
# set image and rect
self.image = pygame.image.load(os.path.join('images','paddle.gif'))
self.image = pygame.transform.rotate(self.image, 90)
# double image size
self.image = pygame.transform.scale2x(self.image)
# get new rect
self.rect = self.image.get_rect()
# reset position
self.rect.centerx, self.rect.centery = xy
# if paddle is now over a wall, fix it
if self.rect.right > 510:
self.rect.right = 509
elif self.rect.left < 10:
self.rect.left = 11
def shrink(self):
"""Returns the size of the paddle to normal"""
# get current position
xy = self.rect.center
# set image and rect
self.image = pygame.image.load(os.path.join('images','paddle.gif'))
self.image = pygame.transform.rotate(self.image, 90)
# get new rect
self.rect = self.image.get_rect()
# reset position
self.rect.centerx, self.rect.centery = xy
# if paddle is now over a wall, fix it
if self.rect.right > 510:
self.rect.right = 509
elif self.rect.left < 10:
self.rect.left = 11
Applying the Slow Ball powerup
Instead of doing anything directly here, I just decided to change the Ball’s maxspeed variable. If the ball is already going slow it won’t change to super slow and boring, but if it is going fast it will automatically cap the speed at the new lower number. Undoing it is as simple as resetting the maxspeed back up to 10.
def slowDown(self):
"""Called for the slowball powerup"""
self.maxspeed = 5
def speedUp(self):
"""Called for the slowball powerup"""
self.maxspeed = 10
Applying the 1-up Powerup
We already added a life to the counter in the managePowerups method, so we could be finished already, but there is a tiny tweak we need to make. When I wrote the Lives code the first time there was no way to have more than the initial 3 lives so I set the left side of the lives sprite and was finished. Now the player can possibly have unlimited lives, so the balls representing extra lives will quickly go off screen. To fix this, I’m going to change just one word and set the center of the lives sprite instead of the left. Now the lives can accommodate a much bigger number before going offscreen or overlapping the score.
# move rect to the proper location
self.rect.left, self.rect.centery = self.xy
becomes
# move rect to the proper location
self.rect.centerx, self.rect.centery = self.xy
Applying the Laser Powerup
We are going to create another sprite class just like any of our others. The only real difference here is that instead of using an image I just used the pygame draw methods to make an ugly little multicolor laser design. The Laser moves itself up and offscreen and returns False in its update method when it is safe to delete it, which is the code we added way up at the start of this tutorial.
class Laser(pygame.sprite.Sprite):
"""A laser sprite for use with the laser powerup."""
def __init__(self, x):
pygame.sprite.Sprite.__init__(self)
# create an image
image = pygame.Surface((50, 550))
image.fill( (255,0,0) ) # fill it with red
pygame.draw.rect(image, (255,255,0), pygame.Rect(10,0,30,550)) # yellow rect
pygame.draw.rect(image, (0,255,250), pygame.Rect(20,0,10,550)) # cyan rect
# set image and rect so we can be rendered
self.image = image
self.rect = self.image.get_rect()
# set initial position somewhere near the top but below the blocks
self.rect.centerx = x
self.rect.bottom = 550
def update(self):
"""Called every frame. Moves the laser up and off the screen. Returns
false when the laser is completely gone and can be deleted."""
self.rect.y -= 20
if self.rect.bottom < 0:
return False
return True
Bug Fix! - Block.hit()
When I wrote the hit method in the previous tutorials I planned for the ability to cause more than one point of damage per hit, but I wrote it totally wrong. I didn’t notice the bug because there wasn’t a way to actually do it yet, but now that the laser destroys everything (by doing 1000 points of damage) we have to fix it. The old code just returned the current level of the block * 100. The new, correct code adds up the points for each level of the block that gets destroyed by the hit. The first couple lines of the hit method now look like this.
points = 0
while damage > 0 and self.level > 0:
# points earned for hitting the block
points += 100 * self.level
# decrement the block level
self.level -= damage
# decrement the damage remaining to apply
damage -= 1