Building Games With Python 3 and Pygame: Part 4

Overview

This is part four of a five-part series of tutorials about making games with Python 3 and Pygame. In part three, we dove into the heart of Breakout and learned how to handle events, met the main Breakout class, and saw how to move the different game objects.

In this part, we will see how to detect collisions and what happens when the ball hits various objects like the paddle, the bricks, the walls, the ceiling, and the floor. Finally, we will review the important topic of game UI and in particular how to create a menu with our own custom buttons.

Collision Detection

In games, things bump into each other. Breakout is no different. Mostly it’s the ball that bumps into stuff. The handle_ball_collisions() method has a nested function called intersect(), which is used to test if the ball hit an object and where it hit the object. It returns ‘left’, ‘right’, ‘top’, ‘bottom’, or None if the ball didn’t hit the object.

def handle_ball_collisions(self):
    def intersect(obj, ball):
        edges = dict(
            left=Rect(obj.left, obj.top, 1, obj.height),
            right=Rect(obj.right, obj.top, 1, obj.height),
            top=Rect(obj.left, obj.top, obj.width, 1),
            bottom=Rect(obj.left, obj.bottom, obj.width, 1))
        collisions = set(edge for edge, rect in edges.items() if
                         ball.bounds.colliderect(rect))
        if not collisions:
            return None

        if len(collisions) == 1:
            return list(collisions)[0]

        if 'top' in collisions:
            if ball.centery >= obj.top:
                return 'top'
            if ball.centerx < obj.left:
                return 'left'
            else:
                return 'right'

        if 'bottom' in collisions:
            if ball.centery >= obj.bottom:
                return 'bottom'
            if ball.centerx < obj.left:
                return 'left'
            else:
                return 'right'

Hitting the Ball With the Paddle

When the ball hits the paddle, it bounces off. If it hits the top of the paddle, it will bounce back up but keep the same horizontal speed component. 

But if it hits the side of the paddle, it will bounce to the opposite side (left or right) and continue its motion downward until it hits the floor. The code uses the intersect function().

# Hit paddle
s = self.ball.speed
edge = intersect(self.paddle, self.ball)
if edge is not None:
    self.sound_effects['paddle_hit'].play()
if edge == 'top':
	speed_x = s[0]
	speed_y = -s[1]
	if self.paddle.moving_left:
		speed_x -= 1
	elif self.paddle.moving_left:
		speed_x += 1
	self.ball.speed = speed_x, speed_y
elif edge in ('left', 'right'):
	self.ball.speed = (-s[0], s[1])

Hitting the Floor

When the paddle misses the ball on its way down (or if the ball hits the paddle on its side), the ball will keep falling and eventually hit the floor. At this point, the player loses a life, and the ball is recreated so the game can continue. The game is over when the player has run out of lives.

# Hit floor
if self.ball.top > c.screen_height:
    self.lives -= 1
	if self.lives == 0:
		self.game_over = True
	else:
		self.create_ball()

Hitting the Ceiling and Walls

When the ball hits a wall or the ceiling, it simply bounces back. 

# Hit ceiling
if self.ball.top < 0:
    self.ball.speed = (s[0], -s[1])

# Hit wall
if self.ball.left < 0 or self.ball.right > c.screen_width:
	self.ball.speed = (-s[0], s[1])

Hitting Bricks

When a ball hits a brick, it’s a major event in Breakout—the brick disappears, the player gets a point, the ball bounces back, and a few other things happen (sound effect and possibly a special effect too) that I’ll discuss later. 

To determine if a brick was hit, the code checks to see if any of the bricks intersects with the ball:

# Hit brick
for brick in self.bricks:
    edge = intersect(brick, self.ball)
	if not edge:
		continue

	self.bricks.remove(brick)
	self.objects.remove(brick)
	self.score += self.points_per_brick

	if edge in ('top', 'bottom'):
		self.ball.speed = (s[0], -s[1])
	else:
		self.ball.speed = (-s[0], s[1])

Programming the Game Menu

Most games have some UI. Breakout has a simple menu that has two buttons that say ‘PLAY’ and ‘QUIT’. The menu shows up at the beginning of the game and disappears when the player clicks ‘PLAY’. Let’s see how the buttons and menu are implemented and how they integrate with the game.

Making Buttons

Pygame doesn’t have a built-in UI library. There are third-party extensions, but I decided to build my own buttons for the menu. A button is a game object that has three states: normal, hover, and pressed. The normal state is when the mouse isn’t over the button, and the hover state is when the mouse is over the button but the left mouse button isn’t pressed. The pressed state is when the mouse is over the button and the player has pressed the left mouse button. 

The button is implemented as a rectangle with background color and text displayed over it. The button also receives an on_click function (defaults to a noop lambda function) that gets called when the button is clicked.

import pygame

from game_object import GameObject
from text_object import TextObject
import config as c


class Button(GameObject):
    def __init__(self, 
                 x, 
                 y, 
                 w, 
                 h, 
                 text, 
                 on_click=lambda x: None, 
                 padding=0):
        super().__init__(x, y, w, h)
        self.state = 'normal'
        self.on_click = on_click

        self.text = TextObject(x + padding, 
                               y + padding, lambda: text, 
                               c.button_text_color, 
                               c.font_name, 
                               c.font_size)

    def draw(self, surface):
        pygame.draw.rect(surface, 
                         self.back_color, 
                         self.bounds)
        self.text.draw(surface)

The button handles its own mouse events and changes its internal state based on these events. When the button is in pressed state and receives a MOUSEBUTTONUP event, it means the player clicked the button, and the on_click() function is invoked.

def handle_mouse_event(self, type, pos):
    if type == pygame.MOUSEMOTION:
		self.handle_mouse_move(pos)
	elif type == pygame.MOUSEBUTTONDOWN:
		self.handle_mouse_down(pos)
	elif type == pygame.MOUSEBUTTONUP:
		self.handle_mouse_up(pos)

def handle_mouse_move(self, pos):
	if self.bounds.collidepoint(pos):
		if self.state != 'pressed':
			self.state = 'hover'
	else:
		self.state = 'normal'

def handle_mouse_down(self, pos):
	if self.bounds.collidepoint(pos):
		self.state = 'pressed'

def handle_mouse_up(self, pos):
	if self.state == 'pressed':
		self.on_click(self)
		self.state = 'hover'

The back_color property that is used to draw the background rectangle always returns the color that matches the current state of the button, so it’s clear to the player the button is active:

@property
def back_color(self):
    return dict(normal=c.button_normal_back_color,
                hover=c.button_hover_back_color,
                pressed=c.button_pressed_back_color)[self.state]

Creating the Menu

The create_menu() function creates a menu with two buttons with the text ‘PLAY’ and ‘QUIT’. It has two nested functions called on_play() and on_quit() that it provides to the corresponding button. Each button is added to the objects list (to be drawn) and also to the menu_buttons field.

def create_menu(self):
    for i, (text, handler) in enumerate((('PLAY', on_play), 
                                         ('QUIT', on_quit))):
        b = Button(c.menu_offset_x,
                   c.menu_offset_y + (c.menu_button_h + 5) * i,
                   c.menu_button_w,
                   c.menu_button_h,
                   text,
                   handler,
                   padding=5)
        self.objects.append(b)
        self.menu_buttons.append(b)
        self.mouse_handlers.append(b.handle_mouse_event)

When the PLAY button is clicked, on_play() is invoked, which removes the buttons from the objects list so they are not drawn anymore. Also, the boolean fields that trigger the start of the game—is_game_running and start_level—are set to True. 

When the QUIT button is clicked, is_game_running is set to False (effectively pausing the game) and game_over is set to True, triggering the end game sequence.

def on_play(button):
    for b in self.menu_buttons:
		self.objects.remove(b)

	self.is_game_running = True
	self.start_level = True

def on_quit(button):
	self.game_over = True
	self.is_game_running = False

Showing and Hiding the Game Menu

Showing and hiding the menu is implicit. When the buttons are in the objects list, the menu is visible; when they are removed, it is hidden. As simple as that. 

It is possible to create a nested menu with its own surface that renders sub-components like buttons and more, and then just add/remove that menu component, but it’s not needed for this simple menu.

Conclusion

In this part, we covered collision detection and what happens when the ball hits various objects like the paddle, the bricks, the walls, the ceiling, and the floor. Also, we created our own menu with custom buttons that we hide and show on command. 

In the last part of the series, we will look into the end game, keeping tabs on score and lives, sound effects, and music.

Then, we will develop a sophisticated system of special effects that will spice up the game. Finally, we will discuss the future direction and potential improvements.

Leave a Reply

Your email address will not be published. Required fields are marked *