Ce que vous allez apprendre

Ce chapitre est une etape majeure : on refactorise tout le code en utilisant le systeme de Sprites de Pygame. Au lieu de gerer des variables globales et des dictionnaires, chaque element du jeu devient un objet avec ses propres donnees et comportements. C'est la base de la programmation orientee objet appliquee au jeu video.

Le concept

Un Sprite dans Pygame est un objet qui possede deux attributs essentiels :

  • self.image : la Surface (image) a dessiner
  • self.rect : le rectangle qui definit la position et la taille (pour le dessin et les collisions)

Pour creer un Sprite, on herite de pygame.sprite.Sprite et on definit self.image et self.rect dans le constructeur __init__.

Les Groups (groupes) sont des conteneurs de sprites. Ils offrent des avantages puissants :

  • group.draw(screen) : dessine tous les sprites du groupe en un seul appel
  • group.update() : appelle la methode update() de chaque sprite
  • pygame.sprite.spritecollide(sprite, group, dokill) : detecte les collisions entre un sprite et un groupe entier

GroupSingle est un groupe special qui ne contient qu'un seul sprite. Il est parfait pour la raquette et la balle.

Etape par etape

1. La classe Paddle

On encapsule toute la logique de la raquette dans une classe. Le constructeur cree l'image et positionne le rectangle. La methode update() gere le deplacement.

python
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))
        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)

super().__init__() appelle le constructeur de pygame.sprite.Sprite. C'est obligatoire. Sans cet appel, les groupes ne fonctionneront pas correctement.

2. La classe Ball

La balle encapsule son mouvement, ses rebonds et sa remise a zero. On garde des coordonnees float separees pour la precision.

python
class Ball(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        # ... (creation de l'image)
        self.float_x = float(SCREEN_WIDTH // 2)
        self.float_y = float(SCREEN_HEIGHT // 2)
        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):
        """Handle wall bounces. Returns True if bounced."""
        # ... (logique de rebond)

    def bounce_off_paddle(self, paddle):
        """Bounce off paddle. Returns True if bounced."""
        # ... (logique de rebond)

3. La classe Brick

Chaque brique est un Sprite independant avec sa propre image et sa propre position. La methode score_value() retourne les points gagnes quand cette brique est detruite.

python
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 and shadow)
        self.rect = self.image.get_rect(topleft=(x, y))
        self.row = row

    def score_value(self):
        return (BRICK_ROWS - self.row) * 10

4. Creer les groupes

On utilise GroupSingle pour la raquette et la balle (un seul sprite chacun) et Group pour les briques.

python
paddle = Paddle()
ball = Ball()
bricks = create_brick_group()

paddle_group = pygame.sprite.GroupSingle(paddle)
ball_group = pygame.sprite.GroupSingle(ball)

5. Dessiner et detecter les collisions

Le dessin se fait en une seule ligne par groupe. La detection de collisions utilise spritecollide qui retourne la liste des sprites touches. Le parametre True supprime automatiquement les sprites touches du groupe.

python
    # Draw all sprites
    bricks.draw(screen)
    paddle_group.draw(screen)
    ball_group.draw(screen)

    # Collision detection
    hit_bricks = pygame.sprite.spritecollide(
        ball, bricks, True)
    if hit_bricks:
        for brick in hit_bricks:
            score += brick.score_value()
        ball.vy = -ball.vy

Points cles a retenir

Astuce : le troisieme parametre de spritecollide (dokill) est tres pratique. Quand il vaut True, les sprites en collision sont automatiquement retires de tous leurs groupes. Plus besoin de gerer la suppression manuellement.

Piege courant : oublier super().__init__() dans le constructeur du Sprite causera une erreur quand vous essayerez d'ajouter le sprite a un groupe.

Avantage des Sprites : le code est mieux organise, plus facile a lire et a maintenir. Chaque objet du jeu est autonome : il sait se dessiner, se deplacer et reagir.

Fonctions Pygame utilisees :

  • pygame.sprite.Sprite : classe de base pour les sprites
  • pygame.sprite.Group : conteneur de sprites
  • pygame.sprite.GroupSingle : groupe a un seul sprite
  • group.draw(surface) : dessine tous les sprites
  • group.update() : met a jour tous les sprites
  • pygame.sprite.spritecollide() : collision sprite vs groupe

Code complet

python
"""Brick Breaker - Chapter 10: Sprites and Groups"""
import pygame
import sys
import math
import array

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


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)


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)


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
        if self.float_x - BALL_RADIUS <= 0:
            self.float_x = BALL_RADIUS
            self.vx = abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        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
        if self.float_y - BALL_RADIUS <= 0:
            self.float_y = BALL_RADIUS
            self.vy = abs(self.vy)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        if bounced:
            self.rect.center = (int(self.float_x), int(self.float_y))
        return bounced

    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


screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 10")
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)

paddle = Paddle()
ball = Ball()
bricks = create_brick_group()

paddle_group = pygame.sprite.GroupSingle(paddle)
ball_group = pygame.sprite.GroupSingle(ball)

lives = MAX_LIVES
score = 0
level = 1
STATE_PLAYING = 0
STATE_GAME_OVER = 1
STATE_YOU_WIN = 2
game_state = STATE_PLAYING
master_volume = 0.5
sound_muted = False


def play_sound(sound):
    if not sound_muted:
        sound.set_volume(master_volume)
        sound.play()


running = True
while running:
    dt = clock.tick(FPS) / 1000.0

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
            if event.key == pygame.K_m:
                if game_state == STATE_PLAYING:
                    paddle.use_mouse = not paddle.use_mouse
            if event.key == pygame.K_F1:
                sound_muted = not sound_muted
            if (event.key == pygame.K_PLUS
                    or event.key == pygame.K_KP_PLUS
                    or event.key == pygame.K_EQUALS):
                master_volume = min(1.0, master_volume + 0.1)
            if (event.key == pygame.K_MINUS
                    or event.key == pygame.K_KP_MINUS):
                master_volume = max(0.0, master_volume - 0.1)
            if event.key == pygame.K_r and game_state != STATE_PLAYING:
                lives = MAX_LIVES
                score = 0
                level = 1
                bricks = create_brick_group()
                ball.reset()
                game_state = STATE_PLAYING

    if game_state == STATE_PLAYING:
        paddle_group.update()
        ball.update(dt)

        if ball.bounce_wall():
            play_sound(snd_wall)

        if ball.fell_off_bottom():
            lives -= 1
            if lives <= 0:
                game_state = STATE_GAME_OVER
                play_sound(snd_game_over)
            else:
                play_sound(snd_lose_life)
                ball.reset()

        if ball.bounce_off_paddle(paddle):
            play_sound(snd_paddle)

        hit_bricks = pygame.sprite.spritecollide(
            ball, bricks, True)
        if hit_bricks:
            for brick in hit_bricks:
                score += brick.score_value()
            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)

    screen.fill(BG_COLOR)

    bricks.draw(screen)
    paddle_group.draw(screen)
    ball_group.draw(screen)

    score_surf = hud_font.render(f"Score: {score}", True, (255, 255, 100))
    screen.blit(score_surf, (10, 10))

    lives_surf = hud_font.render(f"Lives: {lives}", True, (255, 100, 100))
    screen.blit(lives_surf,
                (SCREEN_WIDTH // 2 - lives_surf.get_width() // 2, 10))

    level_surf = hud_font.render(f"Level: {level}", True, (100, 200, 255))
    screen.blit(level_surf,
                (SCREEN_WIDTH - level_surf.get_width() - 10, 10))

    vol_label = "MUTED" if sound_muted else f"Vol: {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))

    if game_state == STATE_GAME_OVER:
        overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT),
                                 pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 150))
        screen.blit(overlay, (0, 0))
        go_text = big_font.render("GAME OVER", True, (255, 60, 60))
        screen.blit(go_text,
                    (SCREEN_WIDTH // 2 - go_text.get_width() // 2,
                     SCREEN_HEIGHT // 2 - 40))
        final_score = medium_font.render(f"Final score: {score}",
                                         True, (255, 255, 255))
        screen.blit(final_score,
                    (SCREEN_WIDTH // 2 - final_score.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 20))
        restart = medium_font.render("Press R to restart",
                                     True, (200, 200, 200))
        screen.blit(restart,
                    (SCREEN_WIDTH // 2 - restart.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 60))

    elif game_state == STATE_YOU_WIN:
        overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT),
                                 pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 150))
        screen.blit(overlay, (0, 0))
        win_text = big_font.render("YOU WIN!", True, (100, 255, 100))
        screen.blit(win_text,
                    (SCREEN_WIDTH // 2 - win_text.get_width() // 2,
                     SCREEN_HEIGHT // 2 - 40))
        final_score = medium_font.render(f"Final score: {score}",
                                         True, (255, 255, 255))
        screen.blit(final_score,
                    (SCREEN_WIDTH // 2 - final_score.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 20))
        restart = medium_font.render("Press R to restart",
                                     True, (200, 200, 200))
        screen.blit(restart,
                    (SCREEN_WIDTH // 2 - restart.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 60))

    info = small_font.render(
        "[M] Mouse/Kbd  [F1] Mute  [+/-] Volume  [R] Restart  [ESC] Quit",
        True, (150, 150, 150))
    screen.blit(info, (10, SCREEN_HEIGHT - 25))

    pygame.display.flip()

pygame.quit()
sys.exit()

Telechargement

Telecharger le code source de ce chapitre : brick-breaker-ch10.zip