Créer un Space Invaders en Python avec Pygame – Guide complet pas à pas

Illustration du tutoriel créer un Space Invaders en Python avec Pygame – 3 types d'ennemis, boucliers destructibles, sons procéduraux, niveau intermédiaire avancé

Ce que vous allez apprendre : créer un clone de Space Invaders complet en Python avec Pygame — 3 types d'ennemis animés, 3 vagues progressives, boucliers destructibles bloc par bloc, 4 bonus, sons procéduraux avec numpy, et une machine à états robuste. 1079 lignes, aucun asset externe.

Le code complet ici.

Introduction

Space Invaders est né en 1978 dans les salles d'arcade japonaises et a littéralement inventé le genre du shoot 'em up. Quarante-cinq ans plus tard, c'est toujours l'un des meilleurs projets pour apprendre la programmation de jeu : flotte d'ennemis coordonnée, physique de tir, collisions, machine à états, et une difficulté qui monte naturellement avec le nombre d'ennemis restants.

Dans ce tutoriel, vous allez construire un clone fidèle et jouable avec tous les mécanismes originaux : la flotte qui s'accélère quand elle se réduit, les boucliers qui s'érodent sous les tirs, le vaisseau UFO mystère qui traverse l'écran, et les sons iconiques générés entièrement en Python.

Prérequis : Python 3.8+, bases de la POO, avoir lu les articles Tetris et Arkanoid de cette série est un plus. Installez les dépendances : pip install pygame numpy

Sommaire

  1. Architecture du projet
  2. Le moteur audio procédural
  3. Les 3 types d'ennemis et leurs animations
  4. La Fleet — mouvement et accélération
  5. Les boucliers destructibles
  6. Le vaisseau joueur et les tirs
  7. Le système de tir ennemi par vague
  8. L'UFO mystère
  9. Les bonus (Powerups)
  10. La machine à états Game
  11. Lancer le jeu
  12. Aller plus loin

1. Architecture du projet

Le projet repose sur 8 classes aux responsabilités bien séparées, tout dans un fichier unique space_invaders.py :

ClasseRôle
SoundEngineGénère tous les sons avec numpy au démarrage. Aucun fichier audio.
Star80 étoiles statiques pour le fond spatial.
EnemyUn ennemi : type (A/B/C), position dans la grille, animation 2 frames, dessin.
FleetLa grille 5×5 d'ennemis, mouvement coordonné, accélération dynamique.
ShieldBouclier composé de blocs individuels, chacun détruit indépendamment.
PlayerShipVaisseau joueur, déplacement, tir, invincibilité, gestion des vies.
UFOVaisseau bonus traversant l'écran aléatoirement.
PowerupCapsule tombante avec effet temporaire ou instantané.
GameMachine à états, boucle principale, collisions, scoring.

Une constante mérite d'être notée : toutes les positions sont calculées dynamiquement à partir d'indices de grille, jamais stockées en absolu. C'est ce qui permet à la flotte entière de se déplacer en ne mettant à jour qu'un seul offset.

2. Le moteur audio procédural

C'est la partie la plus originale du projet. Plutôt que de charger des fichiers .wav, SoundEngine génère tous les sons au démarrage en synthèse additive avec numpy.

2.1 La méthode de base

Tout repose sur _make_tone() qui génère un tableau numpy de samples audio à partir d'une fréquence, d'une durée et d'une forme d'onde :

def _make_tone(self, freq, duration, volume=0.4, wave='sine', fade_out=True):
    n = int(self.sample_rate * duration)   # nombre de samples
    t = np.linspace(0, duration, n, endpoint=False)

    if wave == 'sine':
        samples = np.sin(2 * np.pi * freq * t)
    elif wave == 'square':
        samples = np.sign(np.sin(2 * np.pi * freq * t))
    elif wave == 'sawtooth':
        samples = 2 * (t * freq - np.floor(t * freq + 0.5))

    if fade_out and n > 0:
        fade_len = min(n, int(self.sample_rate * 0.02))
        samples[-fade_len:] *= np.linspace(1.0, 0.0, fade_len)

    samples = (samples * volume * 32767).astype(np.int16)
    return np.column_stack([samples, samples])   # stéréo

Le fade_out de 20ms à la fin évite le "clic" caractéristique d'une coupure abrupte. Les samples sont convertis en int16 (format audio standard) puis dupliqués en stéréo.

2.2 Les 3 formes d'onde et leurs usages

Chaque forme d'onde a un caractère sonore différent, et on les choisit en fonction de l'événement :

Forme d'ondeCaractèreUtilisée pour
SineRonde, pure, musicaleChimes, fanfares, UFO, extra life
SquareÉlectronique, dure, rétroTir joueur, sons de marche, explosion joueur
SawtoothAgressive, riche en harmoniquesTir ennemi

2.3 Les sons de marche de la flotte

Le son le plus iconique de Space Invaders — le boom-boom-boom grave qui s'accélère — est reproduit avec 4 tons en square wave à des fréquences légèrement différentes :

self.sounds['march_0'] = self._to_sound(self._make_tone(120, 0.06, 0.30, 'square'))
self.sounds['march_1'] = self._to_sound(self._make_tone(100, 0.06, 0.30, 'square'))
self.sounds['march_2'] = self._to_sound(self._make_tone(90,  0.06, 0.30, 'square'))
self.sounds['march_3'] = self._to_sound(self._make_tone(80,  0.06, 0.30, 'square'))

Ces 4 tons sont joués en rotation à chaque tick de déplacement de la flotte. Quand la flotte accélère (moins d'ennemis), les ticks sont plus fréquents et le rythme monte naturellement — exactement comme dans l'arcade original.

3. Les 3 types d'ennemis et leurs animations

Chaque ennemi est dessiné entièrement avec des primitives Pygame, sans image. La classe Enemy expose une méthode draw(surf, x, y, frame) qui branche sur le bon dessin selon le type.

Squid (Type A — bas de la flotte, 10 pts)

Corps ovale blanc avec 4 tentacules. L'animation alterne entre tentacules droites et tentacules évasées :

@staticmethod
def _draw_squid(surf, cx, cy, frame):
    pygame.draw.ellipse(surf, WHITE, (cx - 10, cy - 7, 20, 13))
    # yeux
    pygame.draw.circle(surf, BLACK, (cx - 4, cy - 2), 2)
    pygame.draw.circle(surf, BLACK, (cx + 4, cy - 2), 2)
    # tentacules du haut (2 frames)
    if frame == 0:
        pygame.draw.line(surf, WHITE, (cx-5, cy-7), (cx-8, cy-13), 2)
        pygame.draw.line(surf, WHITE, (cx+5, cy-7), (cx+8, cy-13), 2)
    else:
        pygame.draw.line(surf, WHITE, (cx-5, cy-7), (cx-5, cy-13), 2)
        pygame.draw.line(surf, WHITE, (cx+5, cy-7), (cx+5, cy-13), 2)
    # tentacules du bas : position légèrement variable selon frame
    base_y = cy + 6
    xs = [cx-7, cx-2, cx+2, cx+7] if frame == 0 else [cx-8, cx-3, cx+3, cx+8]
    dxs = [0, 0, 0, 0] if frame == 0 else [-2, -1, 1, 2]
    for bx, dx in zip(xs, dxs):
        pygame.draw.line(surf, WHITE, (bx, base_y), (bx + dx, base_y + 7), 2)

Crab (Type B — milieu, 20 pts)

Corps rectangulaire cyan avec pinces latérales. Frame 0 : pinces horizontales ouvertes. Frame 1 : pinces remontées en diagonale.

Jellyfish (Type C — haut de la flotte, 30 pts)

Dôme magenta avec appendices. L'animation joue sur la taille de l'ellipse — plus large en frame 0, plus étroite en frame 1 — pour simuler une pulsation.

Le changement de frame est déclenché par le tick de déplacement de la flotte, pas par un timer indépendant. Résultat : l'animation se synchronise naturellement avec le mouvement — quand la flotte accélère, l'animation accélère aussi.

4. La Fleet — mouvement et accélération

La Fleet gère la grille 5×5 d'ennemis comme un bloc unique. Seuls deux attributs définissent la position de toute la flotte : offset_x et offset_y. La position pixel de chaque ennemi est calculée à la demande :

def enemy_pixel_pos(self, enemy):
    return (self.offset_x + enemy.col * self.SPACING_X,
            self.offset_y + enemy.row * self.SPACING_Y)

4.1 L'accélération dynamique

C'est le mécanisme le plus fidèle à l'original. Le délai entre deux ticks de mouvement est proportionnel au nombre d'ennemis restants :

def _move_delay(self):
    alive = len(self.alive_enemies)
    base  = self.BASE_DELAYS.get(self.wave, 0.60)   # 0.9s, 0.75s ou 0.6s selon la vague
    return max(0.08, base * (alive / self.total))    # minimum 80ms

Avec 25 ennemis au départ et un base_delay de 0.9s, le délai démarre à 900ms. Quand il ne reste qu'un ennemi, il tombe à 36ms — la flotte devient frénétique, exactement comme dans l'arcade. Le minimum de 80ms évite que ça devienne injouable.

4.2 Le déplacement et la descente

À chaque tick, la flotte se décale de 12px dans sa direction. Quand l'ennemi le plus à droite (ou à gauche) touche le bord, la flotte descend de 8px et inverse sa direction. On détecte le bord en cherchant les positions min et max des ennemis vivants :

alive = self.alive_enemies
xs = [self.offset_x + e.col * self.SPACING_X for e in alive]
min_x, max_x = min(xs), max(xs)

if self.direction == 1 and max_x + self.MARGIN >= WIDTH:
    self.direction = -1
    self.offset_y += self.STEP_Y
elif self.direction == -1 and min_x - self.MARGIN <= 0:
    self.direction = 1
    self.offset_y += self.STEP_Y

On vérifie les positions des ennemis vivants (pas tous les ennemis), ce qui fait que quand les colonnes extérieures sont détruites, la flotte peut aller plus loin vers les bords avant de descendre. Fidèle à l'original.

5. Les boucliers destructibles

Les boucliers sont l'une des mécaniques les plus intéressantes techniquement. Chaque bouclier est une grille de 18×10 petits blocs de 4px, chacun représenté par un pygame.Rect individuel.

class Shield:
    BLOCK_SIZE = 4
    COLS       = 18
    ROWS       = 10

    def __init__(self, x, y):
        # Masque de forme : découpe centrale (arch) et coins arrondis
        arch    = {(c, r) for c in range(7, 11) for r in range(4)}
        corners = {(0,0),(1,0),(0,1),(16,0),(17,0),(17,1)}
        empty   = arch | corners
        bs = self.BLOCK_SIZE
        self.blocks = [
            pygame.Rect(x + c*bs, y + r*bs, bs, bs)
            for r in range(self.ROWS) for c in range(self.COLS)
            if (c, r) not in empty
        ]

La découpe centrale (arch) crée l'ouverture caractéristique en haut du bouclier qui permet au joueur de tirer à travers sans se gêner. La détection de collision supprime individuellement chaque bloc touché :

def hit(self, bullet_rect):
    remaining, hit_any = [], False
    for block in self.blocks:
        if block.colliderect(bullet_rect):
            hit_any = True
        else:
            remaining.append(block)
    self.blocks = remaining
    return hit_any

Les boucliers ne sont pas réinitialisés entre les vagues — les dégâts persistent d'une vague à l'autre, ce qui force le joueur à adapter sa stratégie au fil du jeu.

6. Le vaisseau joueur et les tirs

Le vaisseau est dessiné avec un triangle pointant vers le haut et un rectangle à la base. La méthode statique _draw_ship() est réutilisée pour dessiner les petites icônes de vies dans le HUD, avec des dimensions réduites :

@staticmethod
def _draw_ship(surf, cx, cy, w, h, color, highlight=True):
    hw  = w // 2
    tip = (cx,      cy - h // 2)
    bl  = (cx - hw, cy + h // 2)
    br  = (cx + hw, cy + h // 2)
    pygame.draw.polygon(surf, color, [tip, bl, br])
    base_h = h // 5
    pygame.draw.rect(surf, color, (cx - hw, cy + h // 2 - base_h, w, base_h + 2))
    if highlight:
        pygame.draw.line(surf, WHITE, tip, (cx, cy + h // 4), 2)

L'invincibilité du joueur après une mort (1.5s) est gérée par un timer et un effet de clignotement. Le bonus Shield donne également l'invincibilité, mais avec un effet visuel différent — un contour elliptique jaune autour du vaisseau — pour que le joueur sache distinguer les deux états :

def draw(self, surf):
    if self.invincible and not self.shield_pu:
        self._blink_timer += 1 / FPS
        if int(self._blink_timer / 0.1) % 2 == 0:
            return   # clignotement après mort
    self._draw_ship(surf, self.x, self.y, self.W, self.H, CYAN)
    if self.shield_pu and self.invincible:
        pygame.draw.ellipse(surf, YELLOW,
                            (self.x-hw, self.y-hh, hw*2, hh*2), 2)  # contour jaune

7. Le système de tir ennemi par vague

C'est l'une des mécaniques qui différencie le plus les vagues. En vague 1, les tirs sont purement aléatoires parmi les ennemis du bas de chaque colonne. À partir de la vague 2, un tireur ciblé s'ajoute — il choisit l'ennemi dont la position x est la plus proche du joueur :

def _enemy_fire(self):
    bottom      = self.fleet.bottom_enemies_per_column()
    bottom_list = list(bottom.values())
    shooters    = []

    # Vague 2+ : un tireur ciblé
    if self.wave >= 2:
        t1 = min(bottom_list,
                 key=lambda e: abs(self.fleet.enemy_pixel_pos(e)[0] - self.player.x))
        shooters.append(t1)

    # Vague 3 : un deuxième tireur ciblé dans les colonnes restantes
    if self.wave >= 3:
        pool2 = [e for e in bottom_list if e is not shooters[0]]
        if pool2:
            t2 = min(pool2,
                     key=lambda e: abs(self.fleet.enemy_pixel_pos(e)[0] - self.player.x))
            shooters.append(t2)

    # Toujours un tireur aléatoire en plus
    rpool = [e for e in bottom_list if e not in shooters]
    if rpool:
        shooters.append(random.choice(rpool))

    for enemy in shooters:
        ex, ey = self.fleet.enemy_pixel_pos(enemy)
        self.enemy_bullets.append(EnemyBullet(ex, ey + 14))

En vague 3, le joueur fait face à deux tirs simultanément ciblés sur lui plus un tir aléatoire, avec un intervalle réduit à 0.9s. La pression monte significativement.

8. L'UFO mystère

L'UFO apparaît toutes les 20 à 30 secondes et traverse l'écran horizontalement. Sa valeur en points est aléatoire parmi 5 valeurs (50, 100, 150, 200, 300) — le suspense sur sa valeur fait partie du fun. Quand il est détruit, la valeur s'affiche pendant 1 seconde à l'endroit de la destruction :

def kill(self, sfx):
    self.show_points_val   = self.points
    self.show_points_x     = int(self.x)
    self.show_points_timer = 1.0      # affichage pendant 1s
    self.active            = False
    self._spawn_timer      = 0.0
    self._spawn_delay      = random.uniform(20, 30)   # prochain spawn aléatoire
    sfx.play('ufo_dead')

Son bip répétitif (1400Hz, toutes les 0.5s) est géré par un accumulateur interne — il s'arrête automatiquement quand l'UFO quitte l'écran ou est détruit.

9. Les bonus (Powerups)

Les bonus tombent des ennemis détruits avec 12% de probabilité. Ils sont représentés par des capsules arrondies colorées avec une lettre, identiques visuellement à ceux de l'Arkanoid.

CodeNomDuréeEffet
[R]Rapid Fire8 sTir continu en maintenant Espace, pas de cooldown
[B]Big Shot6 sBullet plus large (7px vs 3px)
[S]Shield4 sInvincibilité avec contour jaune
[+]Extra LifeInstantané+1 vie (max 5)

Les bonus à durée limitée sont tracés via pu_timers, un dictionnaire qui décrémente en temps réel. Une barre de progression colorée par bonus s'affiche dans le HUD tant qu'un effet est actif. À l'expiration, _deactivate_powerup() remet les attributs du joueur à leur valeur par défaut.

10. La machine à états Game

La classe Game est le chef d'orchestre. Elle utilise 6 états distincts pour gérer tous les moments du jeu :

STATE_START      = 'start'       # écran titre avec ennemis décoratifs
STATE_PLAY       = 'play'        # jeu en cours
STATE_WAVE_CLEAR = 'wave_clear'  # overlay "Wave X Complete!"
STATE_PAUSE      = 'pause'       # overlay "Paused"
STATE_GAME_OVER  = 'game_over'   # overlay "Game Over"
STATE_WIN        = 'win'         # overlay "You Win!"

Chaque état conditionne les trois méthodes principales : handle_event()update() et draw(). En pratique, update() retourne immédiatement si l'état n'est pas STATE_PLAY, ce qui gèle toute la logique de jeu sans condition supplémentaire dans chaque sous-méthode.

10.1 La transition entre vagues

Quand la flotte est entièrement détruite, on passe en STATE_WAVE_CLEAR. L'appui sur Espace déclenche _load_next_wave() qui recrée une nouvelle Fleet complète tout en conservant le score, les vies et l'état des boucliers :

def _load_next_wave(self):
    self.fleet            = Fleet(self.wave, self.sfx)   # nouvelle flotte complète
    self.bullets          = []
    self.enemy_bullets    = []
    self.powerups         = []
    self.pu_timers        = {}
    self.player.x         = WIDTH // 2       # recentrage
    self.player.invincible       = True
    self.player.invincible_timer = 1.5       # protection au démarrage de vague
    self.state = STATE_PLAY
    # Les shields ne sont PAS réinitialisés

10.2 Le scoring et les vies bonus

Une vie bonus est accordée à 1000 points, puis tous les 1500 points. Un while (et non un if) gère le cas où plusieurs seuils seraient franchis en un seul tick :

while self.player.score >= self.next_extra_life:
    self.player.lives    = min(self.player.lives + 1, 5)
    self.next_extra_life += self.EXTRA_LIFE_STEP
    self.sfx.play('extra_life')

10.3 Les explosions visuelles

Chaque ennemi détruit laisse un flash blanc. Ces explosions sont stockées dans une liste de tuples (x, y, timer) et rendues avec une surface SRCALPHA pour la transparence :

# Dans draw()
for ex, ey, t in self.explosions:
    radius = 15
    alpha  = max(0, int(220 * (t / 0.12)))   # fondu selon le timer restant
    exp_s  = pygame.Surface((radius*2, radius*2), pygame.SRCALPHA)
    pygame.draw.circle(exp_s, (255, 255, 255, alpha), (radius, radius), radius)
    self.screen.blit(exp_s, (int(ex) - radius, int(ey) - radius))

11. Lancer le jeu

pip install pygame numpy
python space_invaders.py

Contrôles :

  • ← → : déplacer le vaisseau
  • Espace : tirer (maintenir avec Rapid Fire actif)
  • P : pause / reprendre
  • R : recommencer (game over ou victoire)

12. Aller plus loin

Le jeu est complet, mais voici des pistes pour continuer :

  • Un 4ème bonus "Bomb" qui détruit tous les ennemis d'une colonne
  • Vagues infinies avec difficulté croissante (au-delà de la vague 3)
  • Ennemis qui plongent vers le joueur à partir de la vague 3 (comme dans Galaga)
  • Meilleur score persistant avec json
  • Publication dans le navigateur avec Pygbag
  • Musique de fond avec pygame.mixer.music et une mélodie générée avec numpy

Le code complet (~1080 lignes) est disponible dans cet article. C'est le projet le plus ambitieux de la série — et le plus fidèle à un classique arcade. Partagez votre meilleur score en commentaire !

Conclusion

Avec Space Invaders, vous avez abordé des techniques avancées de game dev en Python : synthèse audio procédurale avec numpy, flotte coordonnée avec accélération dynamique, boucliers en grille de blocs individuels, machine à états à 6 états, et tir ennemi ciblé progressif. Ce projet clôt dignement la trilogie arcade de python4games.fr — Tetris, Arkanoid, Space Invaders. Que faire ensuite ? Un Pac-Man, peut-être.

Si ce tutoriel vous a aidé, partagez-le et laissez un commentaire avec votre vague préférée !

Sylvain Altmayer

Le Code complet ici !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *