Pages

Breakout - Step 4




Goal:

This time we will be taking what we made in the previous tutorials and making it into an actual game. Before we could bounce the ball around and break blocks, but to be complete we need multiple levels, score keeping, and a way to win/lose. We made the level editor last time, so all we have to do to get levels is write a way to load them. We are going to add a score at the bottom and give the player a limited number of ‘lives’. If the player beats every level, we will tell them they win. If they don’t, they lose!

Score Object

The first thing we are going to add to our code from tutorial 2 is a sprite to show the score. This is extremely similar to the TextSprite from the level builder tutorial; we create an image of some text using pygame.font. There also a couple methods to make it easier to change the score and re-generate the image.

class Score(pygame.sprite.Sprite):
    """A sprite for the score."""

    def __init__(self, xy):
        pygame.sprite.Sprite.__init__(self)
        self.xy = xy    # save xy -- will center our rect on it when we change the score
        self.font = pygame.font.Font(None, 50)  # load the default font, size 50
        self.color = (255, 165, 0)         # our font color in rgb
        self.score = 0  # start at zero
        self.reRender() # generate the image

    def update(self):
        pass

    def add(self, points):
        """Adds the given number of points to the score."""
        self.score += points
        self.reRender()

    def reset(self):
        """Resets the scores to zero."""
        self.score = 0
        self.reRender()

    def reRender(self):
        """Updates the score. Renders a new image and re-centers at the initial coordinates."""
        self.image = self.font.render("%d"%(self.score), True, self.color)
        self.rect = self.image.get_rect()
        self.rect.center = self.xy

Lives Object

Similar to the score object, we are going to create a Lives object to show how many lives the player has left. We are going to use the pygame Sprite class as usual, but generate our image by copying the ball image for however many lives are left. Every time we change how many lives remain we just regenerate the image.

class Lives(pygame.sprite.Sprite):
    """An object to represent the player's remaining lives."""
    def __init__(self, xy, startinglives=3):
        pygame.sprite.Sprite.__init__(self)
        self.xy = xy        # our rendering position
        self.ballimage = pygame.image.load(os.path.join('images','ball.gif'))  # path to the ball image
        self.setLives(startinglives)        # sets lives and generates the lives image

    def getLives(self):
        return self.lives

    def setLives(self, lives):
        self.lives = lives
        self.generateLivesImage()

    def generateLivesImage(self):
        """Generates a new image for this sprite by repeating the ball image
        for each life the player still has."""
        # get a new surface that is the width of the ball image * the lives
        ballrect = self.ballimage.get_rect()
        padding = 5
        newwidth = (ballrect.width + padding) * self.lives

        # create the surface
        surface = pygame.Surface( (newwidth, ballrect.height) )
        surface.set_colorkey((0,0,0))   # set the color key to black so we
                                        # have a transparent background

        # draw the ball on it repeatedly
        for l in range(self.lives):
            surface.blit(self.ballimage, ((ballrect.width + padding) * l, 0))

        # set as image and rect so it can be rendered
        self.image = surface
        self.rect = surface.get_rect()

        # move rect to the proper location
        self.rect.left, self.rect.centery = self.xy

Game.init Changes

We are going to change the end of our Game.init method. First of all we need a variable to track which level we are on since we have more than one now. We are also going to change the loadLevel method to accept the level to load. Next we need a variable to check if we are still playing - we will use this to stop the gameplay when the player has won or lost. Finally, we add our score and lives sprites and add them to the sprites render group.

    # load the first level
    self.currentlevel = 1
    self.loadLevel(self.currentlevel)

    # track the state of the game
    self.isReset = True
    self.playing = True

    # create our score object
    self.score = Score((75, 575))
    self.sprites.add(self.score)

    # create our lives object
    self.lives = Lives((450, 575), 3)
    self.sprites.add(self.lives)

Game.run

Next we are going to make some changes to the run method. We aren’t replacing all of it, but I’m posting the entire run method here for clarity. We are going to move most of the gameplay (rendering, collisions, movement) into an if statement so it only happens if self.playing is True. Also, each frame we are going to check if the player beat the level (ie, all the blocks are gone) and change to the next level if necessary.

def run(self):
    """Runs the game. Contains the game loop that computes and renders
    each frame."""

    print 'Starting Event Loop'

    running = True
    # run until something tells us to stop
    while running:

        # tick pygame clock
        # you can limit the fps by passing the desired frames per seccond to tick()
        self.clock.tick(60)

        # handle pygame events -- if user closes game, stop running
        running = self.handleEvents()

        # update the title bar with our frames per second
        pygame.display.set_caption('Pygame Tutorial 4 - Breakout   %d fps' % self.clock.get_fps())

        # if we haven't lost yet
        if self.playing:

            # update our sprites
            for sprite in self.sprites:
                sprite.update()

            # handle ball -- all our ball management here
            self.manageBall()

            # manageCollisions
            self.manageCollisions()

            # check if we beat the level
            if len(self.blocks) == 0:
                self.newLevel()

            # render our sprites
            self.sprites.clear(self.window, self.background)    # clears the window where the sprites currently are, using the background
            dirty = self.sprites.draw(self.window)              # calculates the 'dirty' rectangles that need to be redrawn

            # render blocks
            self.blocks.clear(self.window, self.background)
            dirty += self.blocks.draw(self.window)

            # blit the dirty areas of the screen
            pygame.display.update(dirty)                        # updates just the 'dirty' areas

    print 'Quitting. Thanks for playing'

Game.manageCollisions

Remember when we made the Block.hit method return both if the Block was still around and the score? Now we are going to use that by adding that returned score to our score object. We are only changing two lines here, but I’m posting the entire method so there is no confusing on where they go.

def manageCollisions(self):
    """Called every frame. Manages collisions between the ball and
    the paddle and the ball and the blocks"""
    # lets do the paddle and the ball first
    if pygame.sprite.collide_rect(self.ball, self.paddle):
        # need to get WHERE on the paddle the ball hit so we can apply
        # the proper rebound
        self.collisionHelper(self.ball, self.paddle)

    # ball and blocks
    collisions = pygame.sprite.spritecollide(self.ball, self.blocks, dokill=False)

    # if we hit two blocks at once we need to bounce differently
    if len(collisions) >= 2:    # going to just take the first 2
        # if between them horizontally, bounce like a flat horizontal wall
        if collisions[0].rect.y == collisions[1].rect.y:
            self.ball.vely *= -1            # bounce in y direction
            self.ball.rect.top = collisions[0].rect.bottom + 1  # move out of collision

        # if between them vertically, bounce like a flat vertical wall
        else:
            # we were moving right
            if self.ball.velx > 0:
                self.ball.rect.right = collisions[0].rect.left - 1  # move out of collision
            # we were moving left
            else:
                self.ball.rect.left = collisions[0].rect.right + 1  # move out of collision
            # bounce x direction
            self.ball.velx *= -1

        # apply damage to blocks
        for block in collisions:
            destroyed, points = block.hit(self.ball.damage)
            self.score.add(points)      # add the points to the score
            if destroyed:
                self.blocks.remove(block)

    # if we hit one block, use the collisionHelper function
    if len(collisions) == 1:
        item = collisions[0]
        self.collisionHelper(self.ball, item)
        # collided with a block, call the block hit method
        if hasattr(item, 'hit'):
            destroyed, points = item.hit(self.ball.damage)
            self.score.add(points)      # add the points to the score
            # remove from render group if block is destroyed
            if destroyed:
                self.blocks.remove(item)

Game.newLevel

Now we are going to add a function to handle when the player beats the current level. First we check if there is another level available. If so, we load it and reset the paddle and ball. If not, the player has beaten every level we have, so we are going to create some text and put it on screen, then turn off the gameplay with our self.playing variable we put in our run method. Notice how easy this makes adding new levels – if we create new ones or take some away, the game will automatically handle the change without us having to edit anything in the code.

def newLevel(self):
    """Called when the user completes the level. Loads the next level
    if possible and resets the paddle and ball. If no more levels are
    available, shows the win message."""
    self.currentlevel += 1
    # if there is a file for the next level, load it
    if os.path.isfile(os.path.join('levels', 'level%d.level'%self.currentlevel)):
        self.loadLevel(self.currentlevel)
        self.paddle.reset()
        self.ball.reset()
        self.isReset = True

    # no file, show win message
    else:
        # game over! render a game over message and stop the game
        font = pygame.font.Font(None, 50)  # load the default font, size 50
        endmessage = font.render("You Win!", True, (255,150,80))
        endmessagerect  = endmessage.get_rect()
        endmessagerect.center = (260, 250)

        # blit it on the background and flip (render) the display one last time
        self.window.blit(endmessage, endmessagerect)
        pygame.display.flip()

        # turn off all the gameplay
        self.playing = False

Game.reset

Now we have to rewrite our reset method. Since we are keeping track of lives now we have to take away a life when the ball hits the bottom. If the player is out of lives, we are going to stop the gameplay and display a message just like in the newLevel method.

def reset(self):
    """Called when the ball hits the bottom wall. The player loses
    a life and the ball is placed on the paddle, ready to be served."""

    # handle the lives
    lives = self.lives.getLives()
    # if we have lives to spare
    if lives > 0:
        self.lives.setLives(lives-1)
        self.paddle.reset()
        self.ball.reset()
        self.isReset = True
    else:
        # game over! render a game over message and stop the game
        font = pygame.font.Font(None, 50)  # load the default font, size 50
        endmessage = font.render("Game Over!", True, (255,100,50))
        endmessagerect  = endmessage.get_rect()
        endmessagerect.center = (260, 250)

        font = pygame.font.Font(None, 40)  # load the default font, size 40
        endmessage2 = font.render("Press Escape to Quit", True, (255,100,50))
        endmessagerect2  = endmessage2.get_rect()
        endmessagerect2.center = (260, endmessagerect.bottom + 20)

        # blit it on the background and flip (render) the display one last time
        self.window.blit(endmessage, endmessagerect)
        self.window.blit(endmessage2, endmessagerect2)
        pygame.display.flip()

        # turn off all the gameplay
        self.playing = False

Game.parseLevelFile

Since we have multiple levels now we need a way to load them from the files we create with the level editor into the game. This method takes the path to the next level file, reads it into an array, converts the strings to integers, and returns the array as the new level. I also added a bit of code to return the default level we made in the previous tutorials in case we don’t find the level we are looking for. This also stops the game from crashing if we don’t have any levels when the game starts.

def parseLevelFile(self, filepath):
    """Parses a level file and returns a 2D array representing it.
    If it fails to find the level it returns a default level"""
    defaultlevel = [
        [0, 1, 1, 2, 1, 1, 2, 1, 1, 0],
        [0, 0, 2, 3, 4, 4, 3, 2, 0, 0],
        [0, 0, 0, 4, 5, 5, 4, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    ]

    # verify the file exists
    if os.path.isfile(filepath):
        # open it for reading
        f = open(filepath, 'r')
        # read all the lines from it into an array
        rows = f.readlines()

        level = []
        # for all the rows in the array we read from the file
        for r in rows:
            # strip any /n from the row, then split on spaces so we get
            # an array of our block levels
            blocks = r.strip().split(' ')
            newrow = []
            # convert our strings to integers and place in the row
            for b in blocks:
                newrow.append(int(b))
            level.append(newrow)

        # return the level
        return level

    else:
        return defaultlevel

Game.loadLevel

The last thing we need to do is change the loadLevel method to accept the levelnumber to load as a parameter and to get the level from the parseLevelFile method instead of just using the default one we created before.

def loadLevel(self, levelnumber):
    """Loads a level. Places blocks on the board and adds them to the
    blocks render group"""
    # parse the desired level file
    level = self.parseLevelFile( os.path.join('levels', 'level%d.level' % levelnumber))

    # levels are a 2d array with 5 rows and 10 columns
    # each space represents a block
    for i in range(5):              # for every row
        for j in range(10):         # for every column
            if level[i][j] != 0:    # if the space isnt empty
                blocklevel = level[i][j]    # get the level of the block
                x = 35 + (50*j) # x = 10 (for the wall) + 25 (to center of first block)
                                # + 50 (width of a block) * j (number of blocks over we are)
                y = 20 + (20*i) # y = 10 (for the wall ) + 10 (to center of first block)
                                # + 20 (height of a block) * i (number of blocks down)

                # if greater than 0 and less than 6, ie not a gray block
                if blocklevel > 0 and blocklevel < 6:
                    # create a block and add it
                    self.blocks.add(self.blockfactory.getBlock((x,y), blocklevel))

                # if block level == 6, solid block
                elif blocklevel == 6:
                    # create solid block and add it
                    self.blocks.add(self.blockfactory.getSolidBlock((x,y)))

Result

Now we have a game! We added multiple levels, keeping score, and ways to win and lose. Go make some levels of your own in the level editor and you can actually have some fun.