Ce que vous allez apprendre

Dans ce chapitre, nous ajoutons un mode auto-play IA. En appuyant sur A, l'ordinateur prend le controle de la raquette. L'IA predit ou la balle va atterrir en simulant sa trajectoire et deplace la raquette pour intercepter.

Le concept

La prediction de trajectoire est une forme simple de pathfinding. Au lieu de chercher un chemin dans un graphe, l'IA simule la physique de la balle en avant (rebonds sur les murs) pour trouver le point X ou la balle croisera la ligne de la raquette.

Pour rendre l'IA realiste et non parfaite, on ajoute :

  • Un delai de reaction : l'IA ne recalcule sa cible que toutes les 0.1 secondes.
  • Une imprecision : un decalage aleatoire de +/- 15 pixels est ajoute a la prediction.

Etape par etape

1. La classe AIController

L'IA est encapsulee dans une classe avec un flag active, une target_x et un timer de reaction. La touche A bascule l'IA.

2. Simulation de trajectoire

La methode predict_landing() simule la balle en micro-pas (1/300e de seconde) : avancer, rebondir sur les murs, jusqu'a croiser la ligne de la raquette.

python
def predict_landing(self, bx, by, bvx, bvy):
    paddle_y = GAME_HEIGHT - 40
    sim_x, sim_y = float(bx), float(by)
    sim_vx, sim_vy = float(bvx), float(bvy)
    for _ in range(2000):
        sim_x += sim_vx * step_dt
        sim_y += sim_vy * step_dt
        # ... wall bounces ...
        if sim_y >= paddle_y:
            return sim_x
    return GAME_WIDTH // 2

3. Visualisation debug

L'IA dessine sa trajectoire predite sous forme de ligne pointillee verte et marque sa cible d'un cercle. Cela aide a comprendre comment elle fonctionne.

4. Controle de la raquette

Quand l'IA est active, la raquette se deplace vers target_x a une vitesse limitee. Le joueur peut reprendre le controle en appuyant a nouveau sur A.

Points cles a retenir

Astuce : limiter le nombre d'iterations de simulation empeche les boucles infinies si la balle se deplace tres lentement.

Piege courant : une IA parfaite est ennuyeuse a regarder. L'imprecision et le delai la rendent plus interessante et faillible.

Code complet

python
"""Brick Breaker - Chapter 19: Pathfinding (AI Auto-play)"""
import pygame
import sys
import math
import array
import random
import json
import os

pygame.init()
pygame.mixer.init(frequency=44100, size=-16, channels=1, buffer=512)

GAME_WIDTH = 800
GAME_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
STATE_DIFFICULTY = 5
STATE_HIGH_SCORES = 6

# Difficulty
DIFFICULTY_EASY = 0
DIFFICULTY_NORMAL = 1
DIFFICULTY_HARD = 2
DIFFICULTY_NAMES = ["Easy", "Normal", "Hard"]
DIFFICULTY_SPEEDS = [200, 300, 420]

# Brick types
BRICK_NORMAL = 0
BRICK_RED = 1
BRICK_BLUE = 2
BRICK_GREEN = 3

# Power-ups
POWERUP_WIDER = 0
POWERUP_LIFE = 1
POWERUP_MULTI = 2
POWERUP_COLORS = {
    POWERUP_WIDER: (255, 200, 0),
    POWERUP_LIFE:  (255, 50, 50),
    POWERUP_MULTI: (50, 150, 255),
}
POWERUP_LABELS = {POWERUP_WIDER: "W", POWERUP_LIFE: "+", POWERUP_MULTI: "M"}
POWERUP_SIZE = 20
POWERUP_FALL_SPEED = 120
POWERUP_DURATION = 8.0

# Animation constants
TRAIL_LENGTH = 8
TRAIL_INTERVAL = 0.016
BRICK_SHATTER_DURATION = 0.3
PADDLE_GLOW_DURATION = 0.2

HIGH_SCORE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                               "highscores.json")


# -- 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_click = generate_beep(frequency=600, duration_ms=40, volume=0.2)
snd_powerup = generate_beep(frequency=900, duration_ms=120, volume=0.3)
snd_speed_up = generate_beep(frequency=1000, duration_ms=60, volume=0.25)
snd_speed_down = generate_beep(frequency=200, duration_ms=100, volume=0.25)


# -- Helpers ----------------------------------------------------------------

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)


def load_high_scores():
    if os.path.exists(HIGH_SCORE_FILE):
        try:
            with open(HIGH_SCORE_FILE, "r") as f:
                return json.load(f)[:10]
        except (json.JSONDecodeError, IOError):
            pass
    return []


def save_high_scores(scores):
    scores = sorted(scores, key=lambda s: s["score"], reverse=True)[:10]
    try:
        with open(HIGH_SCORE_FILE, "w") as f:
            json.dump(scores, f, indent=2)
    except IOError:
        pass


def add_high_score(new_score, difficulty):
    scores = load_high_scores()
    scores.append({"score": new_score,
                   "difficulty": DIFFICULTY_NAMES[difficulty]})
    scores = sorted(scores, key=lambda s: s["score"], reverse=True)[:10]
    save_high_scores(scores)


# -- Screen manager ---------------------------------------------------------

class ScreenManager:
    def __init__(self):
        self.game_surface = pygame.Surface((GAME_WIDTH, GAME_HEIGHT))
        self.fullscreen = False
        self.window_width = GAME_WIDTH
        self.window_height = GAME_HEIGHT
        self.screen = pygame.display.set_mode(
            (self.window_width, self.window_height), pygame.RESIZABLE)
        pygame.display.set_caption("Brick Breaker - Chapter 19")
        self._update_scaling()

    def _update_scaling(self):
        win_w, win_h = self.screen.get_size()
        game_aspect = GAME_WIDTH / GAME_HEIGHT
        win_aspect = win_w / win_h
        if win_aspect > game_aspect:
            scaled_h = win_h
            scaled_w = int(win_h * game_aspect)
        else:
            scaled_w = win_w
            scaled_h = int(win_w / game_aspect)
        self.scaled_rect = pygame.Rect(
            (win_w - scaled_w) // 2, (win_h - scaled_h) // 2,
            scaled_w, scaled_h)

    def toggle_fullscreen(self):
        self.fullscreen = not self.fullscreen
        if self.fullscreen:
            self.screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
        else:
            self.screen = pygame.display.set_mode(
                (self.window_width, self.window_height), pygame.RESIZABLE)
        self._update_scaling()

    def handle_resize(self, event):
        if not self.fullscreen:
            self.window_width = event.w
            self.window_height = event.h
            self.screen = pygame.display.set_mode(
                (event.w, event.h), pygame.RESIZABLE)
            self._update_scaling()

    def present(self):
        self.screen.fill((0, 0, 0))
        scaled = pygame.transform.smoothscale(
            self.game_surface,
            (self.scaled_rect.width, self.scaled_rect.height))
        self.screen.blit(scaled, self.scaled_rect.topleft)
        pygame.display.flip()

    def window_to_game(self, wx, wy):
        gx = (wx - self.scaled_rect.x) * GAME_WIDTH / self.scaled_rect.width
        gy = (wy - self.scaled_rect.y) * GAME_HEIGHT / self.scaled_rect.height
        return (int(gx), int(gy))


# -- Button class -----------------------------------------------------------

class Button:
    def __init__(self, x, y, width, height, label, font,
                 color=(80, 80, 120), hover_color=(120, 120, 180),
                 text_color=(255, 255, 255)):
        self.rect = pygame.Rect(x, y, width, height)
        self.label = label
        self.font = font
        self.color = color
        self.hover_color = hover_color
        self.text_color = text_color
        self.hovered = False
        self.clicked = False

    def update(self, mouse_pos, mouse_click):
        self.hovered = self.rect.collidepoint(mouse_pos)
        self.clicked = self.hovered and mouse_click
        return self.clicked

    def draw(self, surface):
        current_color = self.hover_color if self.hovered else self.color
        shadow_rect = self.rect.move(3, 3)
        pygame.draw.rect(surface, (0, 0, 0, 100), shadow_rect,
                         border_radius=6)
        pygame.draw.rect(surface, current_color, self.rect, border_radius=6)
        border_color = (200, 200, 255) if self.hovered else (100, 100, 150)
        pygame.draw.rect(surface, border_color, self.rect, width=2,
                         border_radius=6)
        text_surf = self.font.render(self.label, True, self.text_color)
        text_rect = text_surf.get_rect(center=self.rect.center)
        surface.blit(text_surf, text_rect)


# -- Particle System --------------------------------------------------------

class Particle:
    def __init__(self, x, y, vx, vy, lifetime, color, size):
        self.x = float(x)
        self.y = float(y)
        self.vx = vx
        self.vy = vy
        self.lifetime = lifetime
        self.max_lifetime = lifetime
        self.color = color
        self.size = size
        self.alive = True
        self.gravity = 120.0

    def update(self, dt):
        self.x += self.vx * dt
        self.y += self.vy * dt
        self.vy += self.gravity * dt
        self.lifetime -= dt
        if self.lifetime <= 0:
            self.alive = False

    def draw(self, surface):
        if not self.alive:
            return
        progress = 1.0 - (self.lifetime / self.max_lifetime)
        alpha = int(255 * (1 - progress))
        current_size = max(1, int(self.size * (1 - progress * 0.5)))
        if alpha <= 0:
            return
        r, g, b = self.color
        surf = pygame.Surface((current_size * 2, current_size * 2),
                              pygame.SRCALPHA)
        pygame.draw.circle(surf, (r, g, b, alpha),
                           (current_size, current_size), current_size)
        surface.blit(surf,
                     (int(self.x) - current_size, int(self.y) - current_size))


class ParticleSystem:
    def __init__(self):
        self.particles = []

    def update(self, dt):
        for p in self.particles:
            p.update(dt)
        self.particles = [p for p in self.particles if p.alive]

    def draw(self, surface):
        for p in self.particles:
            p.draw(surface)

    def clear(self):
        self.particles.clear()

    def emit_brick_explosion(self, rect, color):
        cx, cy = rect.centerx, rect.centery
        for _ in range(15):
            angle = random.uniform(0, 2 * math.pi)
            speed = random.uniform(60, 200)
            vx = speed * math.cos(angle)
            vy = speed * math.sin(angle) - 50
            lt = random.uniform(0.3, 0.8)
            r = min(255, max(0, color[0] + random.randint(-30, 30)))
            g = min(255, max(0, color[1] + random.randint(-30, 30)))
            b = min(255, max(0, color[2] + random.randint(-30, 30)))
            self.particles.append(
                Particle(cx, cy, vx, vy, lt, (r, g, b), random.randint(2, 5)))

    def emit_paddle_sparks(self, paddle_rect, ball_x):
        cx, cy = ball_x, paddle_rect.top
        for _ in range(10):
            angle = random.uniform(-math.pi, 0)
            speed = random.uniform(40, 150)
            self.particles.append(
                Particle(cx, cy, speed * math.cos(angle),
                         speed * math.sin(angle),
                         random.uniform(0.2, 0.5),
                         (random.randint(220, 255), random.randint(200, 255),
                          random.randint(100, 200)),
                         random.randint(1, 3)))

    def emit_wall_dust(self, x, y, direction):
        for _ in range(5):
            if direction == "left":
                vx, vy = random.uniform(20, 80), random.uniform(-50, 50)
            elif direction == "right":
                vx, vy = random.uniform(-80, -20), random.uniform(-50, 50)
            else:
                vx, vy = random.uniform(-50, 50), random.uniform(20, 80)
            gray = random.randint(150, 220)
            self.particles.append(
                Particle(x, y, vx, vy, random.uniform(0.2, 0.4),
                         (gray, gray, gray), random.randint(1, 3)))


# -- Animation classes ------------------------------------------------------

class BrickShatter:
    def __init__(self, rect, color):
        self.rect = pygame.Rect(rect)
        self.color = color
        self.timer = 0.0
        self.duration = BRICK_SHATTER_DURATION
        self.alive = True

    def update(self, dt):
        self.timer += dt
        if self.timer >= self.duration:
            self.alive = False

    def draw(self, surface):
        progress = self.timer / self.duration
        if progress < 0.3:
            flash = int(255 * (1 - progress / 0.3))
            r = min(255, self.color[0] + flash)
            g = min(255, self.color[1] + flash)
            b = min(255, self.color[2] + flash)
            alpha = 255
        else:
            fade = (progress - 0.3) / 0.7
            r, g, b = self.color
            alpha = int(255 * (1 - fade))
        if alpha <= 0:
            return
        shrink = int(progress * 10)
        draw_rect = self.rect.inflate(-shrink * 2, -shrink * 2)
        if draw_rect.width <= 0 or draw_rect.height <= 0:
            return
        surf = pygame.Surface((draw_rect.width, draw_rect.height),
                              pygame.SRCALPHA)
        surf.fill((r, g, b, alpha))
        surface.blit(surf, draw_rect.topleft)


class BallTrail:
    def __init__(self):
        self.positions = []
        self.timer = 0.0

    def update(self, dt, ball_x, ball_y):
        self.timer += dt
        if self.timer >= TRAIL_INTERVAL:
            self.timer -= TRAIL_INTERVAL
            self.positions.append((ball_x, ball_y))
            if len(self.positions) > TRAIL_LENGTH:
                self.positions.pop(0)

    def draw(self, surface):
        count = len(self.positions)
        for i, (x, y) in enumerate(self.positions):
            ratio = (i + 1) / count if count > 0 else 0
            alpha = int(80 * ratio)
            radius = max(1, int(BALL_RADIUS * ratio * 0.8))
            trail_surf = pygame.Surface((radius * 2, radius * 2),
                                        pygame.SRCALPHA)
            pygame.draw.circle(trail_surf, (100, 100, 255, alpha),
                               (radius, radius), radius)
            surface.blit(trail_surf, (int(x) - radius, int(y) - radius))

    def reset(self):
        self.positions.clear()
        self.timer = 0.0


class PaddleGlow:
    def __init__(self):
        self.timer = 0.0
        self.active = False
        self.intensity = 0.0

    def trigger(self):
        self.active = True
        self.timer = 0.0
        self.intensity = 1.0

    def update(self, dt):
        if self.active:
            self.timer += dt
            self.intensity = max(0.0,
                                 1.0 - self.timer / PADDLE_GLOW_DURATION)
            if self.timer >= PADDLE_GLOW_DURATION:
                self.active = False

    def draw(self, surface, paddle_rect):
        if not self.active or self.intensity <= 0:
            return
        glow_alpha = int(150 * self.intensity)
        glow_expand = int(6 * self.intensity)
        glow_rect = paddle_rect.inflate(glow_expand * 2, glow_expand * 2)
        glow_surf = pygame.Surface(
            (glow_rect.width, glow_rect.height), pygame.SRCALPHA)
        glow_surf.fill((200, 200, 255, glow_alpha))
        surface.blit(glow_surf, glow_rect.topleft)


# -- PowerUp class ----------------------------------------------------------

class PowerUp:
    def __init__(self, x, y, ptype):
        self.x = float(x)
        self.y = float(y)
        self.ptype = ptype
        self.color = POWERUP_COLORS[ptype]
        self.label = POWERUP_LABELS[ptype]
        self.alive = True
        self.vy = POWERUP_FALL_SPEED
        self.gravity = 60.0
        self.bob_timer = random.uniform(0, math.pi * 2)
        self.size = POWERUP_SIZE

    def update(self, dt):
        self.vy += self.gravity * dt
        self.y += self.vy * dt
        self.bob_timer += dt * 4
        if self.y > GAME_HEIGHT + self.size:
            self.alive = False

    def get_rect(self):
        return pygame.Rect(int(self.x) - self.size // 2,
                           int(self.y) - self.size // 2,
                           self.size, self.size)

    def draw(self, surface):
        if not self.alive:
            return
        bob = math.sin(self.bob_timer) * 3
        cx, cy = int(self.x), int(self.y + bob)
        glow_s = pygame.Surface((self.size * 2, self.size * 2),
                                pygame.SRCALPHA)
        pygame.draw.circle(glow_s,
                           (*self.color, 60),
                           (self.size, self.size), self.size)
        surface.blit(glow_s, (cx - self.size, cy - self.size))
        pygame.draw.circle(surface, self.color, (cx, cy), self.size // 2)
        pygame.draw.circle(surface, (255, 255, 255), (cx, cy),
                           self.size // 2, 2)
        f = pygame.font.SysFont("monospace", 14, bold=True)
        lbl = f.render(self.label, True, (255, 255, 255))
        surface.blit(lbl, (cx - lbl.get_width() // 2,
                           cy - lbl.get_height() // 2))


# -- Sprite classes ---------------------------------------------------------

class Paddle(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.base_width = PADDLE_WIDTH
        self.current_width = PADDLE_WIDTH
        self.wide_timer = 0.0
        self._build_image()
        self.rect.centerx = GAME_WIDTH // 2
        self.rect.y = GAME_HEIGHT - 40
        self.use_mouse = False

    def _build_image(self):
        w = self.current_width
        self.image = pygame.Surface((w, 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), (w, y))
        pygame.draw.rect(self.image, (0, 0, 0, 0), (0, 0, 3, 3))
        pygame.draw.rect(self.image, (0, 0, 0, 0), (w - 3, 0, 3, 3))
        if self.current_width > self.base_width:
            tint = pygame.Surface((w, PADDLE_HEIGHT), pygame.SRCALPHA)
            tint.fill((255, 200, 0, 40))
            self.image.blit(tint, (0, 0))
        self.image = self.image.convert_alpha()
        old_center = self.rect.center if hasattr(self, 'rect') else (
            GAME_WIDTH // 2, GAME_HEIGHT - 40)
        self.rect = self.image.get_rect(center=old_center)

    def make_wider(self, duration=POWERUP_DURATION):
        self.current_width = int(self.base_width * 1.5)
        self.wide_timer = duration
        self._build_image()

    def update(self, dt=0, mouse_game_x=None, ai_target_x=None):
        if self.wide_timer > 0:
            self.wide_timer -= dt
            if self.wide_timer <= 0:
                self.wide_timer = 0
                self.current_width = self.base_width
                self._build_image()

        if ai_target_x is not None:
            # AI controls the paddle
            diff = ai_target_x - self.rect.centerx
            move = min(abs(diff), PADDLE_SPEED + 2)
            if diff > 0:
                self.rect.x += move
            elif diff < 0:
                self.rect.x -= move
        elif self.use_mouse and mouse_game_x is not None:
            self.rect.centerx = mouse_game_x
        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(GAME_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(GAME_WIDTH // 2)
        self.float_y = float(GAME_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)
        self.heavy = False
        self.heavy_timer = 0.0
        self.heavy_gravity = 40.0

    def reset(self):
        self.float_x = float(GAME_WIDTH // 2)
        self.float_y = float(GAME_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)
        self.heavy = False
        self.heavy_timer = 0.0

    def make_heavy(self, duration=5.0):
        self.heavy = True
        self.heavy_timer = duration

    def update(self, dt):
        if self.heavy:
            self.vy += self.heavy_gravity * dt
            self.heavy_timer -= dt
            if self.heavy_timer <= 0:
                self.heavy = False
        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
        wall_direction = None
        if self.float_x - BALL_RADIUS <= 0:
            self.float_x = BALL_RADIUS
            self.vx = abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "left"
        if self.float_x + BALL_RADIUS >= GAME_WIDTH:
            self.float_x = GAME_WIDTH - BALL_RADIUS
            self.vx = -abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "right"
        if self.float_y - BALL_RADIUS <= 0:
            self.float_y = BALL_RADIUS
            self.vy = abs(self.vy)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "top"
        if bounced:
            self.rect.center = (int(self.float_x), int(self.float_y))
        return bounced, wall_direction

    def fell_off_bottom(self):
        return self.float_y - BALL_RADIUS > GAME_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.current_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

    def accelerate(self, amount=60):
        self.speed += amount
        a = math.atan2(self.vy, self.vx)
        self.vx = self.speed * math.cos(a)
        self.vy = self.speed * math.sin(a)

    def decelerate(self, amount=40):
        self.speed = max(150, self.speed - amount)
        a = math.atan2(self.vy, self.vx)
        self.vx = self.speed * math.cos(a)
        self.vy = self.speed * math.sin(a)


class Brick(pygame.sprite.Sprite):
    def __init__(self, x, y, color, row, brick_type=BRICK_NORMAL):
        super().__init__()
        self.brick_type = brick_type
        self.color = color
        self.row = row
        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))
        if brick_type != BRICK_NORMAL:
            mc = {BRICK_RED: (255, 60, 60), BRICK_BLUE: (60, 60, 255),
                  BRICK_GREEN: (60, 255, 60)}.get(brick_type, (255, 255, 255))
            pygame.draw.circle(self.image, mc,
                               (BRICK_WIDTH // 2, BRICK_HEIGHT // 2), 4)
            pygame.draw.circle(self.image, (255, 255, 255),
                               (BRICK_WIDTH // 2, BRICK_HEIGHT // 2), 4, 1)
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect(topleft=(x, y))

    def score_value(self):
        base = (BRICK_ROWS - self.row) * 10
        if self.brick_type != BRICK_NORMAL:
            base += 20
        return base


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)
            roll = random.random()
            if roll < 0.07:
                btype, bcolor = BRICK_RED, (220, 60, 60)
            elif roll < 0.14:
                btype, bcolor = BRICK_BLUE, (60, 60, 220)
            elif roll < 0.20:
                btype, bcolor = BRICK_GREEN, (60, 200, 60)
            else:
                btype, bcolor = BRICK_NORMAL, color
            group.add(Brick(x, y, bcolor, row, btype))
    return group


# -- AI Controller ----------------------------------------------------------

class AIController:
    """Predicts where the ball will reach the paddle's Y level.

    The AI simulates the ball trajectory forward, bouncing off walls,
    to find the X position where the ball crosses the paddle line.
    It adds a configurable reaction delay and inaccuracy.
    """

    def __init__(self):
        self.active = False
        self.target_x = GAME_WIDTH // 2
        self.reaction_timer = 0.0
        self.reaction_delay = 0.1   # seconds before AI reacts to new info
        self.inaccuracy = 15        # pixels of random offset
        self.predicted_path = []    # for debug visualisation

    def toggle(self):
        self.active = not self.active

    def predict_landing(self, bx, by, bvx, bvy):
        """Simulate ball trajectory to find where it crosses paddle Y.

        Returns the predicted X position, or the centre if the ball is
        going upward and far from the paddle line.
        """
        paddle_y = GAME_HEIGHT - 40
        sim_x = float(bx)
        sim_y = float(by)
        sim_vx = float(bvx)
        sim_vy = float(bvy)
        path = [(sim_x, sim_y)]

        # If ball going upward, simulate anyway (it will come back)
        max_steps = 2000
        step_dt = 1.0 / 300.0  # fine-grained simulation

        for _ in range(max_steps):
            sim_x += sim_vx * step_dt
            sim_y += sim_vy * step_dt

            # Wall bounces
            if sim_x - BALL_RADIUS <= 0:
                sim_x = BALL_RADIUS
                sim_vx = abs(sim_vx)
            if sim_x + BALL_RADIUS >= GAME_WIDTH:
                sim_x = GAME_WIDTH - BALL_RADIUS
                sim_vx = -abs(sim_vx)
            if sim_y - BALL_RADIUS <= 0:
                sim_y = BALL_RADIUS
                sim_vy = abs(sim_vy)

            if len(path) < 50:
                path.append((sim_x, sim_y))

            # Check if we crossed the paddle line
            if sim_y >= paddle_y:
                self.predicted_path = path
                return sim_x

        self.predicted_path = path
        return GAME_WIDTH // 2

    def update(self, dt, ball):
        if not self.active:
            self.predicted_path = []
            return None

        self.reaction_timer -= dt
        if self.reaction_timer <= 0:
            self.reaction_timer = self.reaction_delay
            predicted = self.predict_landing(
                ball.float_x, ball.float_y, ball.vx, ball.vy)
            # Add inaccuracy
            offset = random.uniform(-self.inaccuracy, self.inaccuracy)
            self.target_x = predicted + offset

        return self.target_x

    def draw_debug(self, surface):
        """Draw the predicted trajectory as a dotted line."""
        if not self.active or len(self.predicted_path) < 2:
            return
        for i in range(0, len(self.predicted_path) - 1, 2):
            x1, y1 = int(self.predicted_path[i][0]), int(
                self.predicted_path[i][1])
            x2, y2 = int(self.predicted_path[i + 1][0]), int(
                self.predicted_path[i + 1][1])
            pygame.draw.line(surface, (50, 200, 50, 100),
                             (x1, y1), (x2, y2), 1)
        # Target marker
        tx = int(self.target_x)
        ty = GAME_HEIGHT - 40
        pygame.draw.circle(surface, (50, 255, 50), (tx, ty), 6, 2)


# -- Game setup -------------------------------------------------------------

scr_mgr = ScreenManager()
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)
button_font = pygame.font.SysFont("monospace", 22, bold=True)

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

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

ball_trail = BallTrail()
paddle_glow = PaddleGlow()
shatter_animations = []
particles = ParticleSystem()
powerups = []

lives = MAX_LIVES
score = 0
level = 1
game_state = STATE_MENU
current_difficulty = DIFFICULTY_NORMAL
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0

# Buttons
btn_start = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 10,
                   200, 45, "Start", button_font,
                   color=(40, 100, 60), hover_color=(60, 150, 90))
btn_difficulty = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 65,
                        200, 45, "Difficulty", button_font)
btn_high_scores = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 120,
                         200, 45, "High Scores", button_font)
btn_quit = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 175,
                  200, 45, "Quit", button_font,
                  color=(120, 40, 40), hover_color=(180, 60, 60))
menu_buttons = [btn_start, btn_difficulty, btn_high_scores, btn_quit]

btn_easy = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 - 30,
                  200, 45, "Easy", button_font,
                  color=(40, 120, 40), hover_color=(60, 180, 60))
btn_normal = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 30,
                    200, 45, "Normal", button_font,
                    color=(120, 120, 40), hover_color=(180, 180, 60))
btn_hard = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 90,
                  200, 45, "Hard", button_font,
                  color=(120, 40, 40), hover_color=(180, 60, 60))
btn_diff_back = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 160,
                       200, 45, "Back", button_font)
difficulty_buttons = [btn_easy, btn_normal, btn_hard, btn_diff_back]

btn_resume = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 - 20,
                    200, 45, "Resume", button_font,
                    color=(40, 100, 60), hover_color=(60, 150, 90))
btn_restart = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 40,
                     200, 45, "Restart", button_font,
                     color=(120, 120, 40), hover_color=(180, 180, 60))
btn_pause_quit = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 100,
                        200, 45, "Quit to Menu", button_font,
                        color=(120, 40, 40), hover_color=(180, 60, 60))
pause_buttons = [btn_resume, btn_restart, btn_pause_quit]

btn_hs_back = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT - 80,
                     200, 45, "Back", button_font)


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


def spawn_powerup(brick):
    if brick.brick_type == BRICK_NORMAL:
        return
    if random.random() > 0.5:
        return
    ptype = random.choice([POWERUP_WIDER, POWERUP_LIFE, POWERUP_MULTI])
    powerups.append(PowerUp(brick.rect.centerx, brick.rect.centery, ptype))


def apply_powerup(pu):
    global lives
    if pu.ptype == POWERUP_WIDER:
        paddle.make_wider()
    elif pu.ptype == POWERUP_LIFE:
        lives = min(lives + 1, MAX_LIVES + 2)


def start_game():
    global lives, score, level, bricks, game_state, shatter_animations
    global powerups
    lives = MAX_LIVES
    score = 0
    level = 1
    bricks = create_brick_group()
    ball.reset()
    ball.speed = DIFFICULTY_SPEEDS[current_difficulty]
    angle = math.radians(-60)
    ball.vx = ball.speed * math.cos(angle)
    ball.vy = ball.speed * math.sin(angle)
    ball_trail.reset()
    shatter_animations = []
    particles.clear()
    powerups = []
    paddle.current_width = paddle.base_width
    paddle.wide_timer = 0.0
    paddle._build_image()
    game_state = STATE_PLAYING


# -- Draw helpers -----------------------------------------------------------

def draw_menu(surface):
    global menu_blink_timer
    menu_blink_timer += clock.get_time() / 1000.0
    surface.fill(BG_COLOR)
    title = big_font.render("BRICK BREAKER", True, (255, 200, 50))
    surface.blit(title, (GAME_WIDTH // 2 - title.get_width() // 2,
                         GAME_HEIGHT // 3 - 60))
    subtitle = medium_font.render("AI Edition", True, (200, 200, 255))
    surface.blit(subtitle, (GAME_WIDTH // 2 - subtitle.get_width() // 2,
                            GAME_HEIGHT // 3 - 10))


def draw_difficulty_screen(surface):
    surface.fill(BG_COLOR)
    title = big_font.render("DIFFICULTY", True, (255, 200, 50))
    surface.blit(title, (GAME_WIDTH // 2 - title.get_width() // 2,
                         GAME_HEIGHT // 4 - 30))


def draw_high_scores_screen(surface):
    surface.fill(BG_COLOR)
    title = big_font.render("HIGH SCORES", True, (255, 200, 50))
    surface.blit(title, (GAME_WIDTH // 2 - title.get_width() // 2, 40))
    scores = load_high_scores()
    if not scores:
        ns = medium_font.render("No scores yet!", True, (150, 150, 150))
        surface.blit(ns, (GAME_WIDTH // 2 - ns.get_width() // 2,
                          GAME_HEIGHT // 2 - 20))
    else:
        for i, entry in enumerate(scores[:10]):
            color = (255, 215, 0) if i == 0 else (
                (200, 200, 200) if i < 3 else (150, 150, 150))
            line = "{:>3}.  {:>6}    {}".format(
                i + 1, entry["score"], entry.get("difficulty", "---"))
            ts = hud_font.render(line, True, color)
            surface.blit(ts, (GAME_WIDTH // 2 - 140, 110 + i * 35))


def draw_hud(surface):
    score_surf = hud_font.render("Score: {}".format(score),
                                 True, (255, 255, 100))
    surface.blit(score_surf, (10, 10))
    lives_surf = hud_font.render("Lives: {}".format(lives),
                                 True, (255, 100, 100))
    surface.blit(lives_surf,
                 (GAME_WIDTH // 2 - lives_surf.get_width() // 2, 10))
    level_surf = hud_font.render("Level: {}".format(level),
                                 True, (100, 200, 255))
    surface.blit(level_surf,
                 (GAME_WIDTH - level_surf.get_width() - 10, 10))
    # AI indicator
    if ai.active:
        ai_text = hud_font.render("AI: ON  [A] toggle", True, (50, 255, 50))
    else:
        ai_text = hud_font.render("AI: OFF  [A] toggle", True,
                                  (150, 150, 150))
    surface.blit(ai_text, (GAME_WIDTH - ai_text.get_width() - 10, 30))
    # Status
    status_parts = []
    if ball.heavy:
        status_parts.append("HEAVY {:.1f}s".format(ball.heavy_timer))
    if paddle.wide_timer > 0:
        status_parts.append("WIDE {:.1f}s".format(paddle.wide_timer))
    if status_parts:
        st = small_font.render(" | ".join(status_parts), True,
                               (255, 200, 100))
        surface.blit(st, (10, 30))


def draw_overlay(surface, title_text, title_color):
    overlay = pygame.Surface((GAME_WIDTH, GAME_HEIGHT), pygame.SRCALPHA)
    overlay.fill((0, 0, 0, 150))
    surface.blit(overlay, (0, 0))
    t = big_font.render(title_text, True, title_color)
    surface.blit(t, (GAME_WIDTH // 2 - t.get_width() // 2,
                     GAME_HEIGHT // 2 - 100))
    fs = medium_font.render("Final score: {}".format(score),
                            True, (255, 255, 255))
    surface.blit(fs, (GAME_WIDTH // 2 - fs.get_width() // 2,
                      GAME_HEIGHT // 2 - 50))
    r = medium_font.render("Press SPACE for menu", True, (200, 200, 200))
    surface.blit(r, (GAME_WIDTH // 2 - r.get_width() // 2,
                     GAME_HEIGHT // 2 - 10))


def draw_game_scene(surface):
    surface.fill(BG_COLOR)
    bricks.draw(surface)
    for anim in shatter_animations:
        anim.draw(surface)
    ball_trail.draw(surface)
    paddle_glow.draw(surface, paddle.rect)
    paddle_group.draw(surface)
    ball_group.draw(surface)
    for pu in powerups:
        pu.draw(surface)
    particles.draw(surface)
    # AI debug visualisation
    ai.draw_debug(surface)
    draw_hud(surface)


# -- Main loop --------------------------------------------------------------

running = True
while running:
    dt = clock.tick(FPS) / 1000.0
    raw_mx, raw_my = pygame.mouse.get_pos()
    mouse_pos = scr_mgr.window_to_game(raw_mx, raw_my)
    mouse_click = False
    gs = scr_mgr.game_surface

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.VIDEORESIZE:
            scr_mgr.handle_resize(event)
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_click = True
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_F11:
                scr_mgr.toggle_fullscreen()
            if event.key == pygame.K_ESCAPE:
                if game_state == STATE_PLAYING:
                    game_state = STATE_PAUSED
                elif game_state == STATE_PAUSED:
                    game_state = STATE_PLAYING
                else:
                    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
                if event.key == pygame.K_a:
                    ai.toggle()
            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

    # -- Button updates --
    if game_state == STATE_MENU:
        for btn in menu_buttons:
            btn.update(mouse_pos, mouse_click)
        if btn_start.clicked:
            play_sound(snd_click)
            start_game()
        elif btn_difficulty.clicked:
            play_sound(snd_click)
            game_state = STATE_DIFFICULTY
        elif btn_high_scores.clicked:
            play_sound(snd_click)
            game_state = STATE_HIGH_SCORES
        elif btn_quit.clicked:
            running = False
    elif game_state == STATE_DIFFICULTY:
        for btn in difficulty_buttons:
            btn.update(mouse_pos, mouse_click)
        if btn_easy.clicked:
            play_sound(snd_click)
            current_difficulty = DIFFICULTY_EASY
            game_state = STATE_MENU
        elif btn_normal.clicked:
            play_sound(snd_click)
            current_difficulty = DIFFICULTY_NORMAL
            game_state = STATE_MENU
        elif btn_hard.clicked:
            play_sound(snd_click)
            current_difficulty = DIFFICULTY_HARD
            game_state = STATE_MENU
        elif btn_diff_back.clicked:
            play_sound(snd_click)
            game_state = STATE_MENU
    elif game_state == STATE_HIGH_SCORES:
        btn_hs_back.update(mouse_pos, mouse_click)
        if btn_hs_back.clicked:
            play_sound(snd_click)
            game_state = STATE_MENU
    elif game_state == STATE_PAUSED:
        for btn in pause_buttons:
            btn.update(mouse_pos, mouse_click)
        if btn_resume.clicked:
            play_sound(snd_click)
            game_state = STATE_PLAYING
        elif btn_restart.clicked:
            play_sound(snd_click)
            start_game()
        elif btn_pause_quit.clicked:
            play_sound(snd_click)
            game_state = STATE_MENU

    # -- Update --
    if game_state == STATE_PLAYING:
        # AI prediction
        ai_target = ai.update(dt, ball)

        paddle.update(dt=dt, mouse_game_x=mouse_pos[0],
                      ai_target_x=ai_target)
        ball.update(dt)

        ball_trail.update(dt, ball.float_x, ball.float_y)
        paddle_glow.update(dt)
        particles.update(dt)
        for anim in shatter_animations:
            anim.update(dt)
        shatter_animations = [a for a in shatter_animations if a.alive]

        for pu in powerups:
            pu.update(dt)
        powerups = [pu for pu in powerups if pu.alive]
        for pu in powerups[:]:
            if pu.alive and pu.get_rect().colliderect(paddle.rect):
                pu.alive = False
                apply_powerup(pu)
                play_sound(snd_powerup)

        bounced, wall_dir = ball.bounce_wall()
        if bounced:
            play_sound(snd_wall)
            if wall_dir == "left":
                particles.emit_wall_dust(BALL_RADIUS, ball.float_y, "left")
            elif wall_dir == "right":
                particles.emit_wall_dust(
                    GAME_WIDTH - BALL_RADIUS, ball.float_y, "right")
            elif wall_dir == "top":
                particles.emit_wall_dust(ball.float_x, BALL_RADIUS, "top")

        if ball.fell_off_bottom():
            lives -= 1
            if lives <= 0:
                game_state = STATE_GAME_OVER
                play_sound(snd_game_over)
                add_high_score(score, current_difficulty)
            else:
                play_sound(snd_lose_life)
                ball.reset()
                ball.speed = DIFFICULTY_SPEEDS[current_difficulty]
                a = math.radians(-60)
                ball.vx = ball.speed * math.cos(a)
                ball.vy = ball.speed * math.sin(a)
                ball_trail.reset()

        if ball.bounce_off_paddle(paddle):
            play_sound(snd_paddle)
            paddle_glow.trigger()
            particles.emit_paddle_sparks(paddle.rect, ball.float_x)

        hit_bricks = pygame.sprite.spritecollide(ball, bricks, True)
        if hit_bricks:
            for brick in hit_bricks:
                score += brick.score_value()
                shatter_animations.append(
                    BrickShatter(brick.rect, brick.color))
                particles.emit_brick_explosion(brick.rect, brick.color)
                if brick.brick_type == BRICK_RED:
                    ball.accelerate(60)
                    play_sound(snd_speed_up)
                elif brick.brick_type == BRICK_BLUE:
                    ball.decelerate(40)
                    play_sound(snd_speed_down)
                elif brick.brick_type == BRICK_GREEN:
                    ball.make_heavy(5.0)
                spawn_powerup(brick)
            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)
            add_high_score(score, current_difficulty)

    # -- Draw --
    if game_state == STATE_MENU:
        draw_menu(gs)
        for btn in menu_buttons:
            btn.draw(gs)
    elif game_state == STATE_DIFFICULTY:
        draw_difficulty_screen(gs)
        for btn in difficulty_buttons:
            btn.draw(gs)
    elif game_state == STATE_HIGH_SCORES:
        draw_high_scores_screen(gs)
        btn_hs_back.draw(gs)
    elif game_state == STATE_PLAYING:
        draw_game_scene(gs)
        info = small_font.render(
            "[M] Mouse  [P] Pause  [A] AI  [F1] Mute  [F11] Fullscreen",
            True, (150, 150, 150))
        gs.blit(info, (10, GAME_HEIGHT - 25))
    elif game_state == STATE_PAUSED:
        draw_game_scene(gs)
        overlay = pygame.Surface((GAME_WIDTH, GAME_HEIGHT), pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 150))
        gs.blit(overlay, (0, 0))
        t = big_font.render("PAUSED", True, (200, 200, 255))
        gs.blit(t, (GAME_WIDTH // 2 - t.get_width() // 2,
                     GAME_HEIGHT // 2 - 120))
        for btn in pause_buttons:
            btn.draw(gs)
    elif game_state == STATE_GAME_OVER:
        draw_game_scene(gs)
        draw_overlay(gs, "GAME OVER", (255, 60, 60))
    elif game_state == STATE_YOU_WIN:
        draw_game_scene(gs)
        draw_overlay(gs, "YOU WIN!", (100, 255, 100))

    scr_mgr.present()

pygame.quit()
sys.exit()

Telechargement

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