Ce que vous allez apprendre

Dans ce chapitre, nous chargeons les niveaux depuis des fichiers CSV. Au lieu d'un champ de briques genere par code, chaque niveau est dessine dans un fichier texte. Le joueur progresse a travers plusieurs niveaux, et la victoire arrive quand tous les niveaux sont termines.

Le concept

Un tile map (carte de tuiles) est une grille ou chaque cellule represente un element du monde. Dans notre cas, chaque cellule est soit vide (0), soit une brique de couleur (1 a 6). Le fichier CSV est facile a editer avec un editeur de texte ou un tableur.

Voici un exemple de fichier level1.csv :

text
1,1,1,1,1,1,1,1,1,1
2,2,2,2,2,2,2,2,2,2
3,3,3,3,3,3,3,3,3,3
4,4,4,4,4,4,4,4,4,4
5,5,5,5,5,5,5,5,5,5
6,6,6,6,6,6,6,6,6,6

Les codes couleur sont :

  • 1 = Rouge
  • 2 = Orange
  • 3 = Jaune
  • 4 = Vert
  • 5 = Bleu
  • 6 = Violet

Etape par etape

1. Le dictionnaire de couleurs

On definit un dictionnaire qui associe chaque numero a une couleur RGB :

python
BRICK_COLORS = {
    1: (255, 50, 50),     # Rouge
    2: (255, 165, 0),     # Orange
    3: (255, 255, 50),    # Jaune
    4: (50, 255, 50),     # Vert
    5: (50, 150, 255),    # Bleu
    6: (180, 50, 255),    # Violet
}

2. Charger un fichier CSV

La fonction load_level_from_csv() lit le fichier ligne par ligne. Chaque cellule non vide et non nulle cree une brique a la bonne position dans la grille.

python
def load_level_from_csv(filepath):
    group = pygame.sprite.Group()
    with open(filepath, newline="") as f:
        reader = csv.reader(f)
        for row_idx, row_data in enumerate(reader):
            for col_idx, cell in enumerate(row_data):
                cell = cell.strip()
                if not cell or cell == "0":
                    continue
                brick_type = int(cell)
                color = BRICK_COLORS[brick_type]
                x = BRICK_OFFSET_X + col_idx * (...)
                y = BRICK_OFFSET_Y + row_idx * (...)
                group.add(Brick(x, y, color, brick_type))
    return group

3. Progression de niveaux

Quand toutes les briques sont detruites, la fonction advance_level() charge le niveau suivant. Si tous les niveaux sont termines, on passe a l'etat YOU_WIN.

python
def advance_level():
    global current_level, bricks, game_state
    current_level += 1
    if current_level < len(LEVEL_FILES):
        bricks = load_level(current_level)
        ball.reset()
        game_state = STATE_LEVEL_CLEAR
    else:
        game_state = STATE_YOU_WIN

4. Creer ses propres niveaux

Pour creer un nouveau niveau, il suffit de creer un fichier CSV et de l'ajouter a la liste LEVEL_FILES. Chaque ligne du CSV represente une rangee de briques, et chaque nombre une brique (ou 0 pour un espace vide).

Points cles a retenir

Astuce : le module csv de Python gere tous les cas particuliers (guillemets, virgules dans les valeurs). Utilisez-le au lieu de faire un simple split(',').

Piege courant : oublier os.path.abspath pour construire le chemin vers les fichiers. Si le script est lance depuis un autre repertoire, les chemins relatifs ne fonctionneront pas.

Evolution possible : remplacer les fichiers CSV par le format TMX (Tiled Map Editor) pour des niveaux plus complexes avec plusieurs couches.

Code complet

python
"""Brick Breaker - Chapter 13: Tile Maps"""
import pygame
import sys
import math
import array
import csv
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_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_LEVEL_CLEAR = 5

# Brick color map: number -> (R, G, B)
BRICK_COLORS = {
    1: (255, 50, 50),     # Red
    2: (255, 165, 0),     # Orange
    3: (255, 255, 50),    # Yellow
    4: (50, 255, 50),     # Green
    5: (50, 150, 255),    # Blue
    6: (180, 50, 255),    # Purple
}

# Level files (relative to this script)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LEVEL_FILES = [
    os.path.join(SCRIPT_DIR, "level1.csv"),
    os.path.join(SCRIPT_DIR, "level2.csv"),
    os.path.join(SCRIPT_DIR, "level3.csv"),
]


# ── 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_level_clear = generate_beep(frequency=660, duration_ms=300, volume=0.4)


# ── 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 (7 - min(self.row, 6)) * 10


# ── Level loading from CSV ──────────────────────────────────────────

def load_level_from_csv(filepath):
    """Load a brick layout from a CSV file.

    Each cell contains a number:
      0 = empty
      1-6 = brick color
    Returns a pygame.sprite.Group.
    """
    group = pygame.sprite.Group()
    if not os.path.exists(filepath):
        return group

    with open(filepath, newline="") as f:
        reader = csv.reader(f)
        for row_idx, row_data in enumerate(reader):
            for col_idx, cell in enumerate(row_data):
                cell = cell.strip()
                if not cell or cell == "0":
                    continue
                brick_type = int(cell)
                if brick_type not in BRICK_COLORS:
                    continue
                color = BRICK_COLORS[brick_type]
                x = BRICK_OFFSET_X + col_idx * (BRICK_WIDTH + BRICK_PADDING)
                y = BRICK_OFFSET_Y + row_idx * (BRICK_HEIGHT + BRICK_PADDING)
                group.add(Brick(x, y, color, brick_type))
    return group


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

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

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

lives = MAX_LIVES
score = 0
current_level = 0
game_state = STATE_MENU
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0
level_clear_timer = 0.0
LEVEL_CLEAR_DELAY = 2.0


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


def load_level(level_index):
    """Load a level by index. Returns the brick group."""
    if level_index < len(LEVEL_FILES):
        return load_level_from_csv(LEVEL_FILES[level_index])
    else:
        return pygame.sprite.Group()


def start_game():
    global lives, score, current_level, bricks, game_state
    lives = MAX_LIVES
    score = 0
    current_level = 0
    bricks = load_level(current_level)
    ball.reset()
    game_state = STATE_PLAYING


def advance_level():
    """Move to next level or win the game."""
    global current_level, bricks, game_state, level_clear_timer
    current_level += 1
    if current_level < len(LEVEL_FILES):
        bricks = load_level(current_level)
        ball.reset()
        game_state = STATE_LEVEL_CLEAR
        level_clear_timer = 0.0
        play_sound(snd_level_clear)
    else:
        game_state = STATE_YOU_WIN
        play_sound(snd_win)


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

    subtitle = medium_font.render("Tile Map Edition", True, (200, 200, 255))
    screen.blit(subtitle, (SCREEN_WIDTH // 2 - subtitle.get_width() // 2,
                           SCREEN_HEIGHT // 3 + 30))

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

    levels_info = small_font.render(
        "{} levels loaded from CSV files".format(len(LEVEL_FILES)),
        True, (150, 200, 150))
    screen.blit(levels_info, (SCREEN_WIDTH // 2 - levels_info.get_width() // 2,
                              SCREEN_HEIGHT // 2 + 60))


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(current_level + 1, len(LEVEL_FILES)),
        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))


def draw_overlay(title_text, title_color, sub_text="Press SPACE for menu"):
    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 - 40))
    fs = medium_font.render("Score: {}".format(score), True, (255, 255, 255))
    screen.blit(fs, (SCREEN_WIDTH // 2 - fs.get_width() // 2, SCREEN_HEIGHT // 2 + 20))
    r = medium_font.render(sub_text, True, (200, 200, 200))
    screen.blit(r, (SCREEN_WIDTH // 2 - r.get_width() // 2, SCREEN_HEIGHT // 2 + 60))


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

    # ── 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:
            advance_level()

    elif game_state == STATE_LEVEL_CLEAR:
        level_clear_timer += dt
        if level_clear_timer >= LEVEL_CLEAR_DELAY:
            game_state = STATE_PLAYING

    # ── 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  [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_overlay("PAUSED", (200, 200, 255), "Press P to resume")

    elif game_state == STATE_LEVEL_CLEAR:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay(
            "LEVEL {} CLEAR!".format(current_level),
            (255, 255, 100),
            "Next level starting..."
        )

    elif game_state == STATE_GAME_OVER:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("GAME OVER", (255, 60, 60))

    elif game_state == STATE_YOU_WIN:
        screen.fill(BG_COLOR)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("ALL LEVELS CLEAR!", (100, 255, 100))

    pygame.display.flip()

pygame.quit()
sys.exit()

Telechargement

Telecharger le code source de ce chapitre (inclut les 3 fichiers de niveaux CSV) : brick-breaker-ch13.zip