What you will learn
In this chapter we add UI elements to the game. The main menu now features clickable buttons with hover effects, a difficulty selector, a high-score table saved to JSON, and a full pause menu with resume, restart and quit options.
The concept
A good game is more than its gameplay loop: menus and interfaces enrich the player experience. We build a reusable Button class that detects mouse hover and clicks.
High scores are stored in a JSON file next to the program, enabling persistence between play sessions.
Step by step
1. The Button class
A button is a rectangle with a centered label. The update() method detects hover and click. The draw() method changes colour based on state.
class Button:
def __init__(self, x, y, width, height, label, font,
color=(80,80,120), hover_color=(120,120,180)):
self.rect = pygame.Rect(x, y, width, height)
self.hovered = False
self.clicked = False
def update(self, mouse_pos, mouse_click):
self.hovered = self.rect.collidepoint(mouse_pos)
self.clicked = self.hovered and mouse_click
return self.clicked
2. The main menu
The menu contains four buttons: Start, Difficulty, High Scores and Quit. Each button is instantiated with a specific position and colour.
3. The difficulty selector
Three difficulty levels change the initial ball speed: Easy (200), Normal (300), Hard (420). The choice is kept in memory.
4. High score table
Scores are loaded and saved using the json module. The file highscores.json stores the top 10 scores with their difficulty level.
def load_high_scores():
if os.path.exists(HIGH_SCORE_FILE):
with open(HIGH_SCORE_FILE, "r") as f:
return json.load(f)[:10]
return []
5. Pause menu
During gameplay, pressing P or Escape opens the pause menu with three options: Resume, Restart and Quit to Menu. The game is suspended in the background.
Key takeaways
Tip: click detection is done by checking MOUSEBUTTONDOWN in the event loop, then passing a boolean to the buttons.
Common pitfall: forgetting to convert mouse coordinates if the window is resized (this will be addressed in the next chapter).
Complete code
"""Brick Breaker - Chapter 16: UI Elements"""
import pygame
import sys
import math
import array
import random
import json
import os
pygame.init()
pygame.mixer.init(frequency=44100, size=-16, channels=1, buffer=512)
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
FPS = 60
BG_COLOR = (20, 20, 40)
PADDLE_WIDTH = 100
PADDLE_HEIGHT = 15
PADDLE_SPEED = 8
BALL_RADIUS = 8
BALL_BASE_SPEED = 300
BALL_SPEED_INCREMENT = 10
BRICK_ROWS = 6
BRICK_COLS = 10
BRICK_WIDTH = 70
BRICK_HEIGHT = 20
BRICK_PADDING = 5
BRICK_OFFSET_X = 30
BRICK_OFFSET_Y = 50
MAX_LIVES = 3
# Game states
STATE_MENU = 0
STATE_PLAYING = 1
STATE_PAUSED = 2
STATE_GAME_OVER = 3
STATE_YOU_WIN = 4
STATE_DIFFICULTY = 5
STATE_HIGH_SCORES = 6
# Difficulty settings
DIFFICULTY_EASY = 0
DIFFICULTY_NORMAL = 1
DIFFICULTY_HARD = 2
DIFFICULTY_NAMES = ["Easy", "Normal", "Hard"]
DIFFICULTY_SPEEDS = [200, 300, 420]
# Animation constants
TRAIL_LENGTH = 8
TRAIL_INTERVAL = 0.016
BRICK_SHATTER_DURATION = 0.3
PADDLE_GLOW_DURATION = 0.2
# High score file
HIGH_SCORE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"highscores.json")
# -- Sound generation -------------------------------------------------------
def generate_beep(frequency=440, duration_ms=100, volume=0.3):
sample_rate = 44100
n_samples = int(sample_rate * duration_ms / 1000)
buf = array.array("h", [0] * n_samples)
max_amp = int(32767 * volume)
for i in range(n_samples):
t = i / sample_rate
value = int(max_amp * math.sin(2 * math.pi * frequency * t))
fade_samples = min(500, n_samples // 4)
if i >= n_samples - fade_samples:
value = int(value * (n_samples - i) / fade_samples)
buf[i] = value
return pygame.mixer.Sound(buffer=buf)
snd_paddle = generate_beep(frequency=500, duration_ms=60, volume=0.3)
snd_wall = generate_beep(frequency=300, duration_ms=50, volume=0.2)
snd_brick = generate_beep(frequency=700, duration_ms=80, volume=0.3)
snd_lose_life = generate_beep(frequency=150, duration_ms=300, volume=0.4)
snd_game_over = generate_beep(frequency=100, duration_ms=500, volume=0.5)
snd_win = generate_beep(frequency=880, duration_ms=400, volume=0.4)
snd_click = generate_beep(frequency=600, duration_ms=40, volume=0.2)
# -- Helper functions -------------------------------------------------------
def rainbow_color(row, total_rows):
hue = int(360 * row / total_rows)
color = pygame.Color(0)
color.hsva = (hue, 100, 100, 100)
return (color.r, color.g, color.b)
# -- High Score persistence -------------------------------------------------
def load_high_scores():
"""Load high scores from JSON file. Returns a list of dicts."""
if os.path.exists(HIGH_SCORE_FILE):
try:
with open(HIGH_SCORE_FILE, "r") as f:
data = json.load(f)
return data[:10]
except (json.JSONDecodeError, IOError):
pass
return []
def save_high_scores(scores):
"""Save high scores to JSON file."""
scores = sorted(scores, key=lambda s: s["score"], reverse=True)[:10]
try:
with open(HIGH_SCORE_FILE, "w") as f:
json.dump(scores, f, indent=2)
except IOError:
pass
def add_high_score(score, difficulty):
"""Add a score entry and save."""
scores = load_high_scores()
scores.append({
"score": score,
"difficulty": DIFFICULTY_NAMES[difficulty],
})
scores = sorted(scores, key=lambda s: s["score"], reverse=True)[:10]
save_high_scores(scores)
return scores
# -- Button class -----------------------------------------------------------
class Button:
"""A clickable button with hover effect."""
def __init__(self, x, y, width, height, label, font,
color=(80, 80, 120), hover_color=(120, 120, 180),
text_color=(255, 255, 255)):
self.rect = pygame.Rect(x, y, width, height)
self.label = label
self.font = font
self.color = color
self.hover_color = hover_color
self.text_color = text_color
self.hovered = False
self.clicked = False
def update(self, mouse_pos, mouse_click):
"""Update hover state and check for click. Returns True if clicked."""
self.hovered = self.rect.collidepoint(mouse_pos)
self.clicked = False
if self.hovered and mouse_click:
self.clicked = True
return self.clicked
def draw(self, surface):
"""Draw the button with hover feedback."""
current_color = self.hover_color if self.hovered else self.color
# Shadow
shadow_rect = self.rect.move(3, 3)
pygame.draw.rect(surface, (0, 0, 0, 100), shadow_rect,
border_radius=6)
# Main body
pygame.draw.rect(surface, current_color, self.rect, border_radius=6)
# Border highlight
border_color = (200, 200, 255) if self.hovered else (100, 100, 150)
pygame.draw.rect(surface, border_color, self.rect, width=2,
border_radius=6)
# Label text
text_surf = self.font.render(self.label, True, self.text_color)
text_rect = text_surf.get_rect(center=self.rect.center)
surface.blit(text_surf, text_rect)
# -- Particle System --------------------------------------------------------
class Particle:
def __init__(self, x, y, vx, vy, lifetime, color, size):
self.x = float(x)
self.y = float(y)
self.vx = vx
self.vy = vy
self.lifetime = lifetime
self.max_lifetime = lifetime
self.color = color
self.size = size
self.alive = True
self.gravity = 120.0
def update(self, dt):
self.x += self.vx * dt
self.y += self.vy * dt
self.vy += self.gravity * dt
self.lifetime -= dt
if self.lifetime <= 0:
self.alive = False
def draw(self, surface):
if not self.alive:
return
progress = 1.0 - (self.lifetime / self.max_lifetime)
alpha = int(255 * (1 - progress))
current_size = max(1, int(self.size * (1 - progress * 0.5)))
if alpha <= 0:
return
r, g, b = self.color
surf = pygame.Surface((current_size * 2, current_size * 2),
pygame.SRCALPHA)
pygame.draw.circle(surf, (r, g, b, alpha),
(current_size, current_size), current_size)
surface.blit(surf,
(int(self.x) - current_size, int(self.y) - current_size))
class ParticleSystem:
def __init__(self):
self.particles = []
def update(self, dt):
for p in self.particles:
p.update(dt)
self.particles = [p for p in self.particles if p.alive]
def draw(self, surface):
for p in self.particles:
p.draw(surface)
def clear(self):
self.particles.clear()
def emit_brick_explosion(self, rect, color):
cx = rect.centerx
cy = rect.centery
for _ in range(15):
angle = random.uniform(0, 2 * math.pi)
speed = random.uniform(60, 200)
vx = speed * math.cos(angle)
vy = speed * math.sin(angle) - 50
lifetime = random.uniform(0.3, 0.8)
r = min(255, max(0, color[0] + random.randint(-30, 30)))
g = min(255, max(0, color[1] + random.randint(-30, 30)))
b = min(255, max(0, color[2] + random.randint(-30, 30)))
size = random.randint(2, 5)
self.particles.append(
Particle(cx, cy, vx, vy, lifetime, (r, g, b), size))
def emit_paddle_sparks(self, paddle_rect, ball_x):
cx = ball_x
cy = paddle_rect.top
for _ in range(10):
angle = random.uniform(-math.pi, 0)
speed = random.uniform(40, 150)
vx = speed * math.cos(angle)
vy = speed * math.sin(angle)
lifetime = random.uniform(0.2, 0.5)
r = random.randint(220, 255)
g = random.randint(200, 255)
b = random.randint(100, 200)
size = random.randint(1, 3)
self.particles.append(
Particle(cx, cy, vx, vy, lifetime, (r, g, b), size))
def emit_wall_dust(self, x, y, direction):
for _ in range(5):
if direction == "left":
vx = random.uniform(20, 80)
vy = random.uniform(-50, 50)
elif direction == "right":
vx = random.uniform(-80, -20)
vy = random.uniform(-50, 50)
else:
vx = random.uniform(-50, 50)
vy = random.uniform(20, 80)
lifetime = random.uniform(0.2, 0.4)
gray = random.randint(150, 220)
size = random.randint(1, 3)
self.particles.append(
Particle(x, y, vx, vy, lifetime, (gray, gray, gray), size))
# -- Animation classes ------------------------------------------------------
class BrickShatter:
def __init__(self, rect, color):
self.rect = pygame.Rect(rect)
self.color = color
self.timer = 0.0
self.duration = BRICK_SHATTER_DURATION
self.alive = True
def update(self, dt):
self.timer += dt
if self.timer >= self.duration:
self.alive = False
def draw(self, surface):
progress = self.timer / self.duration
if progress < 0.3:
flash = int(255 * (1 - progress / 0.3))
r = min(255, self.color[0] + flash)
g = min(255, self.color[1] + flash)
b = min(255, self.color[2] + flash)
alpha = 255
else:
fade = (progress - 0.3) / 0.7
r, g, b = self.color
alpha = int(255 * (1 - fade))
if alpha <= 0:
return
shrink = int(progress * 10)
draw_rect = self.rect.inflate(-shrink * 2, -shrink * 2)
if draw_rect.width <= 0 or draw_rect.height <= 0:
return
surf = pygame.Surface((draw_rect.width, draw_rect.height),
pygame.SRCALPHA)
surf.fill((r, g, b, alpha))
surface.blit(surf, draw_rect.topleft)
class BallTrail:
def __init__(self):
self.positions = []
self.timer = 0.0
def update(self, dt, ball_x, ball_y):
self.timer += dt
if self.timer >= TRAIL_INTERVAL:
self.timer -= TRAIL_INTERVAL
self.positions.append((ball_x, ball_y))
if len(self.positions) > TRAIL_LENGTH:
self.positions.pop(0)
def draw(self, surface):
count = len(self.positions)
for i, (x, y) in enumerate(self.positions):
ratio = (i + 1) / count if count > 0 else 0
alpha = int(80 * ratio)
radius = max(1, int(BALL_RADIUS * ratio * 0.8))
trail_surf = pygame.Surface((radius * 2, radius * 2),
pygame.SRCALPHA)
pygame.draw.circle(trail_surf, (100, 100, 255, alpha),
(radius, radius), radius)
surface.blit(trail_surf, (int(x) - radius, int(y) - radius))
def reset(self):
self.positions.clear()
self.timer = 0.0
class PaddleGlow:
def __init__(self):
self.timer = 0.0
self.active = False
self.intensity = 0.0
def trigger(self):
self.active = True
self.timer = 0.0
self.intensity = 1.0
def update(self, dt):
if self.active:
self.timer += dt
self.intensity = max(0.0,
1.0 - self.timer / PADDLE_GLOW_DURATION)
if self.timer >= PADDLE_GLOW_DURATION:
self.active = False
def draw(self, surface, paddle_rect):
if not self.active or self.intensity <= 0:
return
glow_alpha = int(150 * self.intensity)
glow_expand = int(6 * self.intensity)
glow_rect = paddle_rect.inflate(glow_expand * 2, glow_expand * 2)
glow_surf = pygame.Surface(
(glow_rect.width, glow_rect.height), pygame.SRCALPHA)
glow_surf.fill((200, 200, 255, glow_alpha))
surface.blit(glow_surf, glow_rect.topleft)
# -- Sprite classes ---------------------------------------------------------
class Paddle(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.Surface((PADDLE_WIDTH, PADDLE_HEIGHT),
pygame.SRCALPHA)
for y in range(PADDLE_HEIGHT):
brightness = 200 + int(55 * (1 - y / PADDLE_HEIGHT))
pygame.draw.line(self.image,
(brightness, brightness, brightness),
(0, y), (PADDLE_WIDTH, y))
pygame.draw.rect(self.image, (0, 0, 0, 0), (0, 0, 3, 3))
pygame.draw.rect(self.image, (0, 0, 0, 0),
(PADDLE_WIDTH - 3, 0, 3, 3))
self.image = self.image.convert_alpha()
self.rect = self.image.get_rect()
self.rect.centerx = SCREEN_WIDTH // 2
self.rect.y = SCREEN_HEIGHT - 40
self.use_mouse = False
def update(self):
if self.use_mouse:
mx, _ = pygame.mouse.get_pos()
self.rect.centerx = mx
else:
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT] or keys[pygame.K_a]:
self.rect.x -= PADDLE_SPEED
if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
self.rect.x += PADDLE_SPEED
self.rect.left = max(0, self.rect.left)
self.rect.right = min(SCREEN_WIDTH, self.rect.right)
class Ball(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
size = BALL_RADIUS * 4
self.image = pygame.Surface((size, size), pygame.SRCALPHA)
center = size // 2
for r in range(BALL_RADIUS * 2, BALL_RADIUS, -1):
alpha = int(100 * (1 - (r - BALL_RADIUS) / BALL_RADIUS))
pygame.draw.circle(self.image, (100, 100, 255, alpha),
(center, center), r)
pygame.draw.circle(self.image, (255, 255, 255),
(center, center), BALL_RADIUS)
pygame.draw.circle(self.image, (255, 255, 255, 200),
(center - 2, center - 2), BALL_RADIUS // 3)
self.image = self.image.convert_alpha()
self.rect = self.image.get_rect()
self.float_x = float(SCREEN_WIDTH // 2)
self.float_y = float(SCREEN_HEIGHT // 2)
self.rect.center = (int(self.float_x), int(self.float_y))
self.speed = BALL_BASE_SPEED
angle = math.radians(-60)
self.vx = self.speed * math.cos(angle)
self.vy = self.speed * math.sin(angle)
def reset(self):
self.float_x = float(SCREEN_WIDTH // 2)
self.float_y = float(SCREEN_HEIGHT // 2)
self.rect.center = (int(self.float_x), int(self.float_y))
self.speed = BALL_BASE_SPEED
angle = math.radians(-60)
self.vx = self.speed * math.cos(angle)
self.vy = self.speed * math.sin(angle)
def update(self, dt):
self.float_x += self.vx * dt
self.float_y += self.vy * dt
self.rect.center = (int(self.float_x), int(self.float_y))
def bounce_wall(self):
bounced = False
wall_direction = None
if self.float_x - BALL_RADIUS <= 0:
self.float_x = BALL_RADIUS
self.vx = abs(self.vx)
self.speed += BALL_SPEED_INCREMENT
bounced = True
wall_direction = "left"
if self.float_x + BALL_RADIUS >= SCREEN_WIDTH:
self.float_x = SCREEN_WIDTH - BALL_RADIUS
self.vx = -abs(self.vx)
self.speed += BALL_SPEED_INCREMENT
bounced = True
wall_direction = "right"
if self.float_y - BALL_RADIUS <= 0:
self.float_y = BALL_RADIUS
self.vy = abs(self.vy)
self.speed += BALL_SPEED_INCREMENT
bounced = True
wall_direction = "top"
if bounced:
self.rect.center = (int(self.float_x), int(self.float_y))
return bounced, wall_direction
def fell_off_bottom(self):
return self.float_y - BALL_RADIUS > SCREEN_HEIGHT
def bounce_off_paddle(self, paddle):
if self.rect.colliderect(paddle.rect) and self.vy > 0:
self.float_y = paddle.rect.top - BALL_RADIUS
hit_pos = (self.float_x - paddle.rect.left) / PADDLE_WIDTH
angle = 150 - hit_pos * 120
rad = math.radians(angle)
self.speed += BALL_SPEED_INCREMENT
self.vx = self.speed * math.cos(rad)
self.vy = -abs(self.speed * math.sin(rad))
self.rect.center = (int(self.float_x), int(self.float_y))
return True
return False
class Brick(pygame.sprite.Sprite):
def __init__(self, x, y, color, row):
super().__init__()
self.image = pygame.Surface((BRICK_WIDTH, BRICK_HEIGHT),
pygame.SRCALPHA)
pygame.draw.rect(self.image, color,
(0, 0, BRICK_WIDTH, BRICK_HEIGHT))
highlight = tuple(min(255, c + 50) for c in color)
pygame.draw.line(self.image, highlight,
(1, 1), (BRICK_WIDTH - 2, 1))
pygame.draw.line(self.image, highlight,
(1, 1), (1, BRICK_HEIGHT - 2))
shadow = tuple(max(0, c - 50) for c in color)
pygame.draw.line(self.image, shadow,
(1, BRICK_HEIGHT - 1),
(BRICK_WIDTH - 1, BRICK_HEIGHT - 1))
pygame.draw.line(self.image, shadow,
(BRICK_WIDTH - 1, 1),
(BRICK_WIDTH - 1, BRICK_HEIGHT - 1))
self.image = self.image.convert_alpha()
self.rect = self.image.get_rect(topleft=(x, y))
self.row = row
self.color = color
def score_value(self):
return (BRICK_ROWS - self.row) * 10
def create_brick_group():
group = pygame.sprite.Group()
for row in range(BRICK_ROWS):
color = rainbow_color(row, BRICK_ROWS)
for col in range(BRICK_COLS):
x = BRICK_OFFSET_X + col * (BRICK_WIDTH + BRICK_PADDING)
y = BRICK_OFFSET_Y + row * (BRICK_HEIGHT + BRICK_PADDING)
group.add(Brick(x, y, color, row))
return group
# -- Game setup -------------------------------------------------------------
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 16")
clock = pygame.time.Clock()
hud_font = pygame.font.SysFont("monospace", 18)
big_font = pygame.font.SysFont("monospace", 48, bold=True)
medium_font = pygame.font.SysFont("monospace", 24)
small_font = pygame.font.SysFont("monospace", 14)
button_font = pygame.font.SysFont("monospace", 22, bold=True)
paddle = Paddle()
ball = Ball()
bricks = create_brick_group()
paddle_group = pygame.sprite.GroupSingle(paddle)
ball_group = pygame.sprite.GroupSingle(ball)
ball_trail = BallTrail()
paddle_glow = PaddleGlow()
shatter_animations = []
particles = ParticleSystem()
lives = MAX_LIVES
score = 0
level = 1
game_state = STATE_MENU
current_difficulty = DIFFICULTY_NORMAL
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0
# -- Menu buttons -----------------------------------------------------------
btn_start = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 10,
200, 45, "Start", button_font,
color=(40, 100, 60), hover_color=(60, 150, 90))
btn_difficulty = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 65,
200, 45, "Difficulty", button_font,
color=(80, 80, 120), hover_color=(120, 120, 180))
btn_high_scores = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 120,
200, 45, "High Scores", button_font,
color=(80, 80, 120), hover_color=(120, 120, 180))
btn_quit = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 175,
200, 45, "Quit", button_font,
color=(120, 40, 40), hover_color=(180, 60, 60))
menu_buttons = [btn_start, btn_difficulty, btn_high_scores, btn_quit]
# Difficulty buttons
btn_easy = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 - 30,
200, 45, "Easy", button_font,
color=(40, 120, 40), hover_color=(60, 180, 60))
btn_normal = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 30,
200, 45, "Normal", button_font,
color=(120, 120, 40), hover_color=(180, 180, 60))
btn_hard = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 90,
200, 45, "Hard", button_font,
color=(120, 40, 40), hover_color=(180, 60, 60))
btn_diff_back = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 160,
200, 45, "Back", button_font)
difficulty_buttons = [btn_easy, btn_normal, btn_hard, btn_diff_back]
# Pause menu buttons
btn_resume = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 - 20,
200, 45, "Resume", button_font,
color=(40, 100, 60), hover_color=(60, 150, 90))
btn_restart = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 40,
200, 45, "Restart", button_font,
color=(120, 120, 40), hover_color=(180, 180, 60))
btn_pause_quit = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 100,
200, 45, "Quit to Menu", button_font,
color=(120, 40, 40), hover_color=(180, 60, 60))
pause_buttons = [btn_resume, btn_restart, btn_pause_quit]
# High scores back button
btn_hs_back = Button(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT - 80,
200, 45, "Back", button_font)
def play_sound(sound):
if not sound_muted:
sound.set_volume(master_volume)
sound.play()
def start_game():
global lives, score, level, bricks, game_state, shatter_animations
lives = MAX_LIVES
score = 0
level = 1
bricks = create_brick_group()
ball.reset()
ball.speed = DIFFICULTY_SPEEDS[current_difficulty]
angle = math.radians(-60)
ball.vx = ball.speed * math.cos(angle)
ball.vy = ball.speed * math.sin(angle)
ball_trail.reset()
shatter_animations = []
particles.clear()
game_state = STATE_PLAYING
# -- Draw helpers -----------------------------------------------------------
def draw_menu():
global menu_blink_timer
menu_blink_timer += clock.get_time() / 1000.0
screen.fill(BG_COLOR)
title = big_font.render("BRICK BREAKER", True, (255, 200, 50))
screen.blit(title, (SCREEN_WIDTH // 2 - title.get_width() // 2,
SCREEN_HEIGHT // 3 - 60))
subtitle = medium_font.render("UI Edition", True, (200, 200, 255))
screen.blit(subtitle, (SCREEN_WIDTH // 2 - subtitle.get_width() // 2,
SCREEN_HEIGHT // 3 - 10))
diff_text = small_font.render(
"Difficulty: {}".format(DIFFICULTY_NAMES[current_difficulty]),
True, (180, 180, 180))
screen.blit(diff_text, (SCREEN_WIDTH // 2 - diff_text.get_width() // 2,
SCREEN_HEIGHT // 3 + 20))
def draw_difficulty_screen():
screen.fill(BG_COLOR)
title = big_font.render("DIFFICULTY", True, (255, 200, 50))
screen.blit(title, (SCREEN_WIDTH // 2 - title.get_width() // 2,
SCREEN_HEIGHT // 4 - 30))
# Show current selection
current = small_font.render(
"Current: {}".format(DIFFICULTY_NAMES[current_difficulty]),
True, (200, 200, 200))
screen.blit(current, (SCREEN_WIDTH // 2 - current.get_width() // 2,
SCREEN_HEIGHT // 4 + 20))
def draw_high_scores_screen():
screen.fill(BG_COLOR)
title = big_font.render("HIGH SCORES", True, (255, 200, 50))
screen.blit(title, (SCREEN_WIDTH // 2 - title.get_width() // 2, 40))
scores = load_high_scores()
if not scores:
no_scores = medium_font.render("No scores yet!", True, (150, 150, 150))
screen.blit(no_scores,
(SCREEN_WIDTH // 2 - no_scores.get_width() // 2,
SCREEN_HEIGHT // 2 - 20))
else:
header = hud_font.render(" # Score Difficulty", True,
(200, 200, 200))
screen.blit(header, (SCREEN_WIDTH // 2 - 140, 110))
pygame.draw.line(screen, (100, 100, 100),
(SCREEN_WIDTH // 2 - 140, 135),
(SCREEN_WIDTH // 2 + 140, 135))
for i, entry in enumerate(scores[:10]):
color = (255, 215, 0) if i == 0 else (
(200, 200, 200) if i < 3 else (150, 150, 150))
line = "{:>3}. {:>6} {}".format(
i + 1, entry["score"], entry.get("difficulty", "---"))
text_surf = hud_font.render(line, True, color)
screen.blit(text_surf, (SCREEN_WIDTH // 2 - 140, 145 + i * 35))
def draw_hud():
score_surf = hud_font.render("Score: {}".format(score),
True, (255, 255, 100))
screen.blit(score_surf, (10, 10))
lives_surf = hud_font.render("Lives: {}".format(lives),
True, (255, 100, 100))
screen.blit(lives_surf,
(SCREEN_WIDTH // 2 - lives_surf.get_width() // 2, 10))
level_surf = hud_font.render("Level: {}".format(level),
True, (100, 200, 255))
screen.blit(level_surf,
(SCREEN_WIDTH - level_surf.get_width() - 10, 10))
vol_label = "MUTED" if sound_muted else "Vol: {}".format(
int(master_volume * 100))
vol_color = (255, 80, 80) if sound_muted else (150, 150, 150)
vol_surf = small_font.render(vol_label, True, vol_color)
screen.blit(vol_surf,
(SCREEN_WIDTH - vol_surf.get_width() - 10, 30))
count_surf = small_font.render(
"Particles: {}".format(len(particles.particles)),
True, (100, 100, 100))
screen.blit(count_surf, (10, 30))
def draw_overlay(title_text, title_color):
overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 150))
screen.blit(overlay, (0, 0))
t = big_font.render(title_text, True, title_color)
screen.blit(t, (SCREEN_WIDTH // 2 - t.get_width() // 2,
SCREEN_HEIGHT // 2 - 100))
fs = medium_font.render("Final score: {}".format(score),
True, (255, 255, 255))
screen.blit(fs, (SCREEN_WIDTH // 2 - fs.get_width() // 2,
SCREEN_HEIGHT // 2 - 50))
r = medium_font.render("Press SPACE for menu", True, (200, 200, 200))
screen.blit(r, (SCREEN_WIDTH // 2 - r.get_width() // 2,
SCREEN_HEIGHT // 2 - 10))
def draw_game_scene():
"""Draw the in-game scene (bricks, ball, paddle, effects)."""
screen.fill(BG_COLOR)
bricks.draw(screen)
for anim in shatter_animations:
anim.draw(screen)
ball_trail.draw(screen)
paddle_glow.draw(screen, paddle.rect)
paddle_group.draw(screen)
ball_group.draw(screen)
particles.draw(screen)
draw_hud()
# -- Main loop --------------------------------------------------------------
running = True
while running:
dt = clock.tick(FPS) / 1000.0
mouse_pos = pygame.mouse.get_pos()
mouse_click = False
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_click = True
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
if game_state == STATE_PLAYING:
game_state = STATE_PAUSED
elif game_state == STATE_PAUSED:
game_state = STATE_PLAYING
elif game_state in (STATE_MENU, STATE_DIFFICULTY,
STATE_HIGH_SCORES):
running = False
if event.key == pygame.K_F1:
sound_muted = not sound_muted
if event.key in (pygame.K_PLUS, pygame.K_KP_PLUS,
pygame.K_EQUALS):
master_volume = min(1.0, master_volume + 0.1)
if event.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
master_volume = max(0.0, master_volume - 0.1)
if game_state == STATE_MENU:
if event.key == pygame.K_SPACE:
start_game()
elif game_state == STATE_PLAYING:
if event.key == pygame.K_m:
paddle.use_mouse = not paddle.use_mouse
if event.key == pygame.K_p:
game_state = STATE_PAUSED
elif game_state == STATE_PAUSED:
if event.key == pygame.K_p:
game_state = STATE_PLAYING
elif game_state in (STATE_GAME_OVER, STATE_YOU_WIN):
if event.key == pygame.K_SPACE:
game_state = STATE_MENU
# -- Button updates per state --
if game_state == STATE_MENU:
for btn in menu_buttons:
btn.update(mouse_pos, mouse_click)
if btn_start.clicked:
play_sound(snd_click)
start_game()
elif btn_difficulty.clicked:
play_sound(snd_click)
game_state = STATE_DIFFICULTY
elif btn_high_scores.clicked:
play_sound(snd_click)
game_state = STATE_HIGH_SCORES
elif btn_quit.clicked:
running = False
elif game_state == STATE_DIFFICULTY:
for btn in difficulty_buttons:
btn.update(mouse_pos, mouse_click)
if btn_easy.clicked:
play_sound(snd_click)
current_difficulty = DIFFICULTY_EASY
game_state = STATE_MENU
elif btn_normal.clicked:
play_sound(snd_click)
current_difficulty = DIFFICULTY_NORMAL
game_state = STATE_MENU
elif btn_hard.clicked:
play_sound(snd_click)
current_difficulty = DIFFICULTY_HARD
game_state = STATE_MENU
elif btn_diff_back.clicked:
play_sound(snd_click)
game_state = STATE_MENU
elif game_state == STATE_HIGH_SCORES:
btn_hs_back.update(mouse_pos, mouse_click)
if btn_hs_back.clicked:
play_sound(snd_click)
game_state = STATE_MENU
elif game_state == STATE_PAUSED:
for btn in pause_buttons:
btn.update(mouse_pos, mouse_click)
if btn_resume.clicked:
play_sound(snd_click)
game_state = STATE_PLAYING
elif btn_restart.clicked:
play_sound(snd_click)
start_game()
elif btn_pause_quit.clicked:
play_sound(snd_click)
game_state = STATE_MENU
# -- Update --
if game_state == STATE_PLAYING:
paddle_group.update()
ball.update(dt)
ball_trail.update(dt, ball.float_x, ball.float_y)
paddle_glow.update(dt)
particles.update(dt)
for anim in shatter_animations:
anim.update(dt)
shatter_animations = [a for a in shatter_animations if a.alive]
bounced, wall_dir = ball.bounce_wall()
if bounced:
play_sound(snd_wall)
if wall_dir == "left":
particles.emit_wall_dust(
BALL_RADIUS, ball.float_y, "left")
elif wall_dir == "right":
particles.emit_wall_dust(
SCREEN_WIDTH - BALL_RADIUS, ball.float_y, "right")
elif wall_dir == "top":
particles.emit_wall_dust(
ball.float_x, BALL_RADIUS, "top")
if ball.fell_off_bottom():
lives -= 1
if lives <= 0:
game_state = STATE_GAME_OVER
play_sound(snd_game_over)
add_high_score(score, current_difficulty)
else:
play_sound(snd_lose_life)
ball.reset()
ball.speed = DIFFICULTY_SPEEDS[current_difficulty]
angle = math.radians(-60)
ball.vx = ball.speed * math.cos(angle)
ball.vy = ball.speed * math.sin(angle)
ball_trail.reset()
if ball.bounce_off_paddle(paddle):
play_sound(snd_paddle)
paddle_glow.trigger()
particles.emit_paddle_sparks(paddle.rect, ball.float_x)
hit_bricks = pygame.sprite.spritecollide(ball, bricks, True)
if hit_bricks:
for brick in hit_bricks:
score += brick.score_value()
shatter_animations.append(
BrickShatter(brick.rect, brick.color))
particles.emit_brick_explosion(brick.rect, brick.color)
ball.vy = -ball.vy
ball.speed += BALL_SPEED_INCREMENT
play_sound(snd_brick)
if len(bricks) == 0:
game_state = STATE_YOU_WIN
play_sound(snd_win)
add_high_score(score, current_difficulty)
# -- Draw --
if game_state == STATE_MENU:
draw_menu()
for btn in menu_buttons:
btn.draw(screen)
elif game_state == STATE_DIFFICULTY:
draw_difficulty_screen()
for btn in difficulty_buttons:
btn.draw(screen)
elif game_state == STATE_HIGH_SCORES:
draw_high_scores_screen()
btn_hs_back.draw(screen)
elif game_state == STATE_PLAYING:
draw_game_scene()
info = small_font.render(
"[M] Mouse [P] Pause [F1] Mute [+/-] Volume [ESC] Pause",
True, (150, 150, 150))
screen.blit(info, (10, SCREEN_HEIGHT - 25))
elif game_state == STATE_PAUSED:
draw_game_scene()
overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT),
pygame.SRCALPHA)
overlay.fill((0, 0, 0, 150))
screen.blit(overlay, (0, 0))
t = big_font.render("PAUSED", True, (200, 200, 255))
screen.blit(t, (SCREEN_WIDTH // 2 - t.get_width() // 2,
SCREEN_HEIGHT // 2 - 120))
for btn in pause_buttons:
btn.draw(screen)
elif game_state == STATE_GAME_OVER:
draw_game_scene()
draw_overlay("GAME OVER", (255, 60, 60))
elif game_state == STATE_YOU_WIN:
draw_game_scene()
draw_overlay("YOU WIN!", (100, 255, 100))
pygame.display.flip()
pygame.quit()
sys.exit()
Download
Download the source code for this chapter: brick-breaker-ch16.zip
