Ce que vous allez apprendre

Dans ce chapitre, nous ajoutons une machine a etats au jeu. Au lieu d'un seul mode de fonctionnement, le programme connait maintenant cinq etats distincts : MENU, PLAYING, PAUSED, GAME_OVER et YOU_WIN. Chaque etat a son propre comportement d'affichage et de gestion des touches.

Le concept

Une machine a etats (state machine) est un patron de conception tres courant dans les jeux video. L'idee est simple : le jeu se trouve toujours dans exactement un etat. Les entrees de l'utilisateur et les evenements du jeu provoquent des transitions d'un etat a un autre.

Par exemple :

  • MENU : le joueur voit le titre et appuie sur ESPACE pour passer a PLAYING.
  • PLAYING : le jeu tourne normalement. Appuyer sur P passe a PAUSED. Perdre toutes ses vies passe a GAME_OVER. Detruire toutes les briques passe a YOU_WIN.
  • PAUSED : le jeu est fige. Appuyer sur P reprend le jeu (retour a PLAYING).
  • GAME_OVER et YOU_WIN : ecrans de fin. ESPACE retourne au MENU.

L'implementation la plus simple utilise des constantes entieres et une variable game_state :

python
STATE_MENU = 0
STATE_PLAYING = 1
STATE_PAUSED = 2
STATE_GAME_OVER = 3
STATE_YOU_WIN = 4

game_state = STATE_MENU

Etape par etape

1. Definir les etats

On definit cinq constantes au debut du programme. La variable game_state commence a STATE_MENU pour que le joueur voie le menu au lancement.

2. Gestion des touches par etat

La gestion des evenements clavier depend de l'etat courant. On utilise des blocs if/elif pour separer la logique de chaque etat :

python
if game_state == STATE_MENU:
    if event.key == pygame.K_SPACE:
        start_game()

elif game_state == STATE_PLAYING:
    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

3. L'ecran du menu

Le menu affiche le titre du jeu et un texte clignotant invitant le joueur a appuyer sur ESPACE. Le clignotement est obtenu en alternant l'affichage toutes les demi-secondes.

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

    # Blinking text
    if int(menu_blink_timer * 2) % 2 == 0:
        start_text = medium_font.render(
            "Press SPACE to start", True, (255, 255, 255))
        screen.blit(start_text, ...)

4. La pause

Quand l'etat est PAUSED, on dessine le jeu normalement puis on superpose un voile semi-transparent avec le texte «PAUSED». Le jeu ne se met plus a jour (pas d'appel a update ni de physique).

5. Dessiner selon l'etat

La section de dessin utilise la meme structure if/elif :

python
if game_state == STATE_MENU:
    draw_menu()
elif game_state == STATE_PLAYING:
    screen.fill(BG_COLOR)
    bricks.draw(screen)
    paddle_group.draw(screen)
    ball_group.draw(screen)
    draw_hud()
elif game_state == STATE_PAUSED:
    # ... draw game then overlay
    draw_paused()
elif game_state == STATE_GAME_OVER:
    # ... draw game then overlay
    draw_game_over()

Points cles a retenir

Astuce : en separant chaque etat dans sa propre fonction de dessin, le code reste lisible meme avec beaucoup d'etats.

Piege courant : oublier de bloquer les mises a jour pendant la pause. Si on oublie le if game_state == STATE_PLAYING autour du code de physique, le jeu continuera de tourner en arriere-plan.

Patron utile : la machine a etats peut evoluer vers un dictionnaire de fonctions ou meme des classes d'etat, mais pour un casse-briques, de simples constantes suffisent.

Code complet

python
"""Brick Breaker - Chapter 11: Game States"""
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

# ── Game states ────────────────────────────────────────────────────
STATE_MENU = 0
STATE_PLAYING = 1
STATE_PAUSED = 2
STATE_GAME_OVER = 3
STATE_YOU_WIN = 4


# ── 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)


# ── 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)


# ── 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
        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


# ── Game setup ──────────────────────────────────────────────────────

screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 11")
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
game_state = STATE_MENU
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0


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


def start_game():
    """Reset everything and switch to PLAYING state."""
    global lives, score, level, bricks, game_state
    lives = MAX_LIVES
    score = 0
    level = 1
    bricks = create_brick_group()
    ball.reset()
    game_state = STATE_PLAYING


# ── Draw helpers for each state ─────────────────────────────────────

def draw_menu():
    """Draw the title menu screen."""
    global menu_blink_timer
    menu_blink_timer += clock.get_time() / 1000.0

    screen.fill(BG_COLOR)

    # Title
    title = big_font.render("BRICK BREAKER", True, (255, 200, 50))
    screen.blit(title, (SCREEN_WIDTH // 2 - title.get_width() // 2,
                        SCREEN_HEIGHT // 3 - 30))

    # Blinking "Press SPACE to start"
    if int(menu_blink_timer * 2) % 2 == 0:
        start_text = medium_font.render("Press SPACE to start", True, (255, 255, 255))
        screen.blit(start_text, (SCREEN_WIDTH // 2 - start_text.get_width() // 2,
                                 SCREEN_HEIGHT // 2 + 20))

    # Instructions
    instr_lines = [
        "[LEFT/RIGHT] or [A/D] Move paddle",
        "[M] Toggle mouse control",
        "[P] Pause game",
        "[F1] Mute  [+/-] Volume",
        "[ESC] Quit",
    ]
    y_offset = SCREEN_HEIGHT // 2 + 80
    for line in instr_lines:
        surf = small_font.render(line, True, (150, 150, 150))
        screen.blit(surf, (SCREEN_WIDTH // 2 - surf.get_width() // 2, y_offset))
        y_offset += 20


def draw_paused():
    """Draw the pause overlay on top of the game."""
    overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA)
    overlay.fill((0, 0, 0, 150))
    screen.blit(overlay, (0, 0))

    pause_text = big_font.render("PAUSED", True, (200, 200, 255))
    screen.blit(pause_text, (SCREEN_WIDTH // 2 - pause_text.get_width() // 2,
                             SCREEN_HEIGHT // 2 - 40))

    resume_text = medium_font.render("Press P to resume", True, (200, 200, 200))
    screen.blit(resume_text, (SCREEN_WIDTH // 2 - resume_text.get_width() // 2,
                              SCREEN_HEIGHT // 2 + 20))


def draw_game_over():
    """Draw the game over overlay."""
    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("Final score: {}".format(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 SPACE for menu", True, (200, 200, 200))
    screen.blit(restart, (SCREEN_WIDTH // 2 - restart.get_width() // 2,
                          SCREEN_HEIGHT // 2 + 60))


def draw_you_win():
    """Draw the victory overlay."""
    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("Final score: {}".format(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 SPACE for menu", True, (200, 200, 200))
    screen.blit(restart, (SCREEN_WIDTH // 2 - restart.get_width() // 2,
                          SCREEN_HEIGHT // 2 + 60))


def draw_hud():
    """Draw the heads-up display."""
    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))


# ── Main loop ───────────────────────────────────────────────────────

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

            # Volume controls (always active)
            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)

            # State-specific key handling
            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

    # ── Update ──────────────────────────────────────────────────
    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)

    # ── Draw ────────────────────────────────────────────────────
    if game_state == STATE_MENU:
        draw_menu()

    elif game_state == STATE_PLAYING:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        info = small_font.render(
            "[M] Mouse/Kbd  [P] Pause  [F1] Mute  [+/-] Volume  [ESC] Quit",
            True, (150, 150, 150))
        screen.blit(info, (10, SCREEN_HEIGHT - 25))

    elif game_state == STATE_PAUSED:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_paused()

    elif game_state == STATE_GAME_OVER:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_game_over()

    elif game_state == STATE_YOU_WIN:
        screen.fill(BG_COLOR)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_you_win()

    pygame.display.flip()

pygame.quit()
sys.exit()

Telechargement

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