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 :
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.
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.
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
"""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
