Ce que vous allez apprendre

Dans ce chapitre, le champ de briques devient trois fois plus haut que l'ecran. Une camera suit la balle verticalement et fait defiler le monde. On ajoute aussi un mode infini ou de nouvelles rangees de briques apparaissent en continu.

Le concept

Dans un jeu avec scrolling, les objets existent dans un espace monde (world space) plus grand que l'ecran. La camera definit quelle portion du monde est visible. Pour afficher un objet, on soustrait l'offset de la camera a sa position monde.

La formule est :

position_ecran = position_monde - camera_offset

Pour un defilement fluide, on utilise une interpolation lineaire (lerp). Au lieu de sauter directement a la cible, la camera se deplace d'une fraction de la distance restante a chaque frame :

python
class Camera:
    def __init__(self):
        self.offset_y = 0.0
        self.target_y = 0.0

    def update(self, ball_y):
        self.target_y = ball_y - SCREEN_HEIGHT * 0.6
        self.target_y = max(0, self.target_y)
        # Smooth interpolation
        self.offset_y += (self.target_y - self.offset_y) * 0.05

    def apply(self, world_y):
        return world_y - self.offset_y

Etape par etape

1. Un monde plus grand

On augmente BRICK_ROWS a 18 (contre 6 avant). Les briques occupent maintenant un espace vertical bien superieur a la hauteur de l'ecran.

2. La classe Camera

La camera garde deux valeurs : l'offset actuel et la cible. A chaque frame, l'offset se rapproche de la cible avec un facteur de lissage (0.05 = 5 pour cent de la distance restante). La methode apply() convertit une coordonnee monde en coordonnee ecran.

3. Dessiner avec la camera

Chaque brique a une position monde (world_y) qui ne change pas. Au moment du dessin, on calcule sa position ecran avec la camera. On ne dessine que les briques visibles pour economiser des ressources.

python
def draw_at(self, surface, camera):
    screen_y = camera.apply(self.world_y)
    if -BRICK_HEIGHT < screen_y < SCREEN_HEIGHT:
        surface.blit(self.image, (self.rect.x, int(screen_y)))

4. Collisions en espace ecran

La balle et la raquette restent en coordonnees ecran. Pour detecter les collisions balle-brique, on calcule le rectangle ecran de chaque brique a la volee.

5. Mode infini

Toutes les quelques secondes, une nouvelle rangee de briques est ajoutee au-dessus du champ visible. Les briques defilent naturellement vers le bas grace a la camera.

python
if endless_mode:
    endless_timer += dt
    if endless_timer >= ENDLESS_ROW_INTERVAL:
        endless_timer -= ENDLESS_ROW_INTERVAL
        add_endless_row(bricks, endless_next_y)
        endless_next_y -= (BRICK_HEIGHT + BRICK_PADDING)

Points cles a retenir

Astuce : le lerp (interpolation lineaire) est la cle d'un scrolling agreable. Un facteur trop eleve donne un mouvement saccade, un facteur trop faible donne un retard genant.

Piege courant : melanger les coordonnees monde et ecran. Utilisez des noms de variables explicites (world_y vs screen_y) pour eviter la confusion.

Optimisation : ne dessiner que les objets visibles (culling) est une technique essentielle quand le monde est plus grand que l'ecran.

Code complet

python
"""Brick Breaker - Chapter 12: Camera and Scrolling"""
import pygame
import sys
import math
import array
import random

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 = 18
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

# World height: brick field is 3x the screen height
WORLD_HEIGHT = SCREEN_HEIGHT * 3
CAMERA_SMOOTH = 0.05
ENDLESS_ROW_INTERVAL = 5.0


# ── 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 / max(1, total_rows))
    color = pygame.Color(0)
    color.hsva = (hue, 100, 100, 100)
    return (color.r, color.g, color.b)


# ── Camera ──────────────────────────────────────────────────────────

class Camera:
    """Vertical camera that follows the ball with smooth lerp."""

    def __init__(self):
        self.offset_y = 0.0
        self.target_y = 0.0

    def update(self, ball_y):
        """Smoothly follow the ball vertically."""
        # Target: keep ball roughly in the lower third of the screen
        self.target_y = ball_y - SCREEN_HEIGHT * 0.6
        # Clamp so we never show below the paddle area
        self.target_y = max(0, self.target_y)
        # Smooth interpolation (lerp)
        self.offset_y += (self.target_y - self.offset_y) * CAMERA_SMOOTH

    def apply(self, world_y):
        """Convert world Y coordinate to screen Y coordinate."""
        return world_y - self.offset_y

    def reset(self):
        self.offset_y = 0.0
        self.target_y = 0.0


# ── 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()
        # Ball uses world coordinates for Y
        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.world_y = y
        self.row = row
        self.color = color

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

    def draw_at(self, surface, camera):
        """Draw the brick at its camera-adjusted position."""
        screen_y = camera.apply(self.world_y)
        # Only draw if visible
        if -BRICK_HEIGHT < screen_y < SCREEN_HEIGHT:
            surface.blit(self.image, (self.rect.x, int(screen_y)))

    def get_screen_rect(self, camera):
        """Return rect in screen coordinates for collision."""
        screen_y = camera.apply(self.world_y)
        return pygame.Rect(self.rect.x, int(screen_y), BRICK_WIDTH, BRICK_HEIGHT)


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


def add_endless_row(bricks, top_y):
    """Add a new row of bricks at the given world Y position."""
    row_index = random.randint(0, BRICK_ROWS - 1)
    color = rainbow_color(row_index, BRICK_ROWS)
    for col in range(BRICK_COLS):
        x = BRICK_OFFSET_X + col * (BRICK_WIDTH + BRICK_PADDING)
        bricks.add(Brick(x, top_y, color, row_index))


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

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

camera = Camera()
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
endless_timer = 0.0
endless_next_y = -BRICK_HEIGHT - BRICK_PADDING
endless_mode = False


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
    global endless_timer, endless_next_y, endless_mode
    lives = MAX_LIVES
    score = 0
    level = 1
    bricks = create_brick_group()
    ball.reset()
    camera.reset()
    endless_timer = 0.0
    endless_next_y = -BRICK_HEIGHT - BRICK_PADDING
    endless_mode = False
    game_state = STATE_PLAYING


def start_endless():
    global lives, score, level, bricks, game_state
    global endless_timer, endless_next_y, endless_mode
    lives = MAX_LIVES
    score = 0
    level = 1
    bricks = create_brick_group()
    ball.reset()
    camera.reset()
    endless_timer = 0.0
    endless_next_y = -BRICK_HEIGHT - BRICK_PADDING
    endless_mode = True
    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 - 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))

    endless_text = medium_font.render("Press E for Endless mode", True, (200, 200, 255))
    screen.blit(endless_text, (SCREEN_WIDTH // 2 - endless_text.get_width() // 2,
                               SCREEN_HEIGHT // 2 + 60))

    instr_lines = [
        "[LEFT/RIGHT] or [A/D] Move paddle",
        "[M] Mouse  [P] Pause  [F1] Mute  [+/-] Volume",
    ]
    y_off = SCREEN_HEIGHT // 2 + 110
    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_off))
        y_off += 20


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))
    mode_label = "Endless" if endless_mode else "Level: {}".format(level)
    level_surf = hud_font.render(mode_label, 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))
    cam_info = small_font.render(
        "Camera Y: {:.0f}  Bricks: {}".format(camera.offset_y, len(bricks)),
        True, (100, 100, 100))
    screen.blit(cam_info, (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 - 40))
    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 + 20))
    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 + 60))


def draw_game_world():
    """Draw bricks with camera offset, paddle and ball at screen positions."""
    screen.fill(BG_COLOR)
    for brick in bricks:
        brick.draw_at(screen, camera)
    paddle_group.draw(screen)
    ball_group.draw(screen)


# ── 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 event.key == pygame.K_e:
                    start_endless()
            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)

        # Camera follows the ball
        camera.update(ball.float_y)

        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()
                camera.reset()

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

        # Collision: check bricks using screen-space rects
        ball_rect = ball.rect
        hit_list = []
        for brick in bricks:
            screen_rect = brick.get_screen_rect(camera)
            if ball_rect.colliderect(screen_rect):
                hit_list.append(brick)
        if hit_list:
            for brick in hit_list:
                score += brick.score_value()
                brick.kill()
            ball.vy = -ball.vy
            ball.speed += BALL_SPEED_INCREMENT
            play_sound(snd_brick)

        # Endless mode: add new rows periodically
        if endless_mode:
            endless_timer += dt
            if endless_timer >= ENDLESS_ROW_INTERVAL:
                endless_timer -= ENDLESS_ROW_INTERVAL
                add_endless_row(bricks, endless_next_y)
                endless_next_y -= (BRICK_HEIGHT + BRICK_PADDING)

        # Win condition (non-endless only)
        if not endless_mode and 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:
        draw_game_world()
        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:
        draw_game_world()
        draw_hud()
        draw_overlay("PAUSED", (200, 200, 255))
    elif game_state == STATE_GAME_OVER:
        draw_game_world()
        draw_hud()
        draw_overlay("GAME OVER", (255, 60, 60))
    elif game_state == STATE_YOU_WIN:
        draw_game_world()
        draw_hud()
        draw_overlay("YOU WIN!", (100, 255, 100))

    pygame.display.flip()

pygame.quit()
sys.exit()

Telechargement

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