Pages

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