Pygame 4: Breakout – Step 4

Pygame Tutorial 4 – Breakout: Step 4
Written by Collin Green — Version 1.0.0 — 2010-12-31

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.

205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
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.

236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
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.

327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
        # 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.

343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
    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.

466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
    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.

599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
    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.

626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
    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.

658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
    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.

730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
    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 and Download

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.

Tutorial Download:
Pygame Tutorial 4 - Breakout (243)