Créer Pac-Man en Python avec Pygame — Architecture complète et explications

Voici le lien vers le programme complet


On va décortiquer un Pac-Man complet en Python/Pygame — pas un prototype à 200 lignes, mais un jeu avec une vraie IA des fantômes, un pathfinding BFS, une machine à états, et un rendu fidèle à l'original. Le projet fait environ 700 lignes réparties en 5 fichiers bien séparés. L'objectif : que tu comprennes chaque choix d'architecture, et que tu puisses réutiliser ces patterns dans tes propres jeux.


Architecture du projet

Avant de plonger dans le code, voici comment les fichiers s'organisent :

pacman.py          ← point d'entrée, game loop, collisions, rendu HUD
├── maze.py        ← grille, pastilles, dessin des murs
├── pacman_player.py  ← mouvement joueur, animation, collecte
└── ghost.py ×4    ← FSM + BFS, rendu des fantômes
constants.py       ← toutes les valeurs numériques et le layout

La règle d'or : chaque fichier a une seule responsabilité. ghost.py ne sait pas que Pac-Man existe, il reçoit juste une position en paramètre. maze.py ne sait pas que des sprites bougent dessus. C'est ce découplage qui rend le code maintenable.


1. constants.py — Centraliser les valeurs

Le fichier constants ne contient aucune logique. Il centralise toutes les valeurs du jeu :

TILE_SIZE = 28      # une tuile = 28×28 pixels
COLS = 20
ROWS = 20
PACMAN_SPEED = 3    # pixels par frame
GHOST_SPEED_NORMAL = 1.5
FRIGHTENED_DURATION = 6 * 60   # 6 secondes à 60 fps

Pourquoi multiplier par 60 ? Parce que le jeu tourne à 60 fps, et qu'on travaille en frames, pas en secondes. Écrire 6 * 60 plutôt que 360 rend le code auto-documenté.

Le labyrinthe lui-même est une simple liste de chaînes :

MAZE_LAYOUT = [
    "####################",
    "#..................#",
    "#.##.###.##.###.##.#",
    "#o##.###.##.###.##o#",
    ...
]

Trois caractères : # pour les murs, . pour les pastilles (10 pts), o pour les power pellets (50 pts). Une assertion vérifie que chaque ligne fait bien 20 caractères au démarrage — c'est une bonne pratique pour détecter les bugs de layout immédiatement.


2. maze.py — La grille et le rendu des murs

Stocker les pastilles dans des sets

class Maze:
    def __init__(self):
        self.dots    = set()   # (col, row)
        self.pellets = set()
        self._parse_grid()

Pourquoi des set plutôt que des listes ? Parce qu'à chaque frame on vérifie si Pac-Man est sur une pastille, et on la supprime si c'est le cas. Ces deux opérations sont en O(1) avec un set, contre O(n) avec une liste.

def _eat(self):
    pos = (self.tile_col, self.tile_row)
    if pos in self.maze.dots:        # O(1)
        self.maze.dots.discard(pos)  # O(1)
        self.score += 10

Le rendu des murs avec coins arrondis

C'est le détail qui donne le look "Pac-Man" au lieu d'une grille de carrés bleus quelconques. L'astuce : pour chaque tuile mur, on dessine d'abord le rectangle bleu, puis on efface les coins avec de petits cercles noirs selon les voisins.

def _draw_walls(self, surface):
    for row in range(ROWS):
        for col in range(COLS):
            if not self.is_wall(col, row):
                continue

            rect = self.tile_rect(col, row)
            pygame.draw.rect(wall_surf, WALL_COLOR, rect)

            # Voisins murs
            n = self.is_wall(col, row - 1)
            s = self.is_wall(col, row + 1)
            e = self.is_wall(col + 1, row)
            w = self.is_wall(col - 1, row)

            # Coin non connecté → cercle noir pour arrondir
            r = 4
            if not n and not w:
                pygame.draw.circle(wall_surf, BLACK, (rect.left, rect.top), r)
            if not n and not e:
                pygame.draw.circle(wall_surf, BLACK, (rect.right, rect.top), r)
            # ... etc pour les coins sud

La logique : si une tuile mur n'a pas de voisin mur au nord ET à l'ouest, son coin supérieur gauche est un coin "externe" — on l'arrondit. Simple et efficace.

Faire clignoter les power pellets

def _draw_pellets(self, surface, elapsed_ms):
    period_ms = 1000 / PELLET_BLINK_HZ   # 500 ms
    visible = (elapsed_ms % 500) < 250
    if not visible:
        return
    # ... dessiner les pellets

pygame.time.get_ticks() renvoie le temps en millisecondes depuis le démarrage. Le modulo permet de créer n'importe quel cycle de clignotement sans variable d'état supplémentaire.


3. pacman_player.py — Le mouvement et l'animation

Le problème de l'alignement sur grille

Dans un jeu tile-based, le joueur se déplace en pixels mais doit "snapper" sur la grille pour tourner. Si on autorise le changement de direction n'importe quand, le sprite traverse les murs en diagonale.

La solution : bufferiser la direction demandée, et ne l'appliquer que quand Pac-Man est aligné sur une tuile.

def _aligned(self, tolerance=2) -> bool:
    """Vrai si le centre est à ≤ tolerance px d'un centre de tuile."""
    cx, cy = self._tile_center(self.tile_col, self.tile_row)
    return abs(self.x - cx) <= tolerance and abs(self.y - cy) <= tolerance

def update(self):
    # 1. Appliquer next_dir uniquement quand on est aligné
    if self._aligned():
        if self._can_enter(self.next_dir):
            self.direction = self.next_dir
            # Snap précis sur le centre pour éviter la dérive
            cx, cy = self._tile_center(self.tile_col, self.tile_row)
            self.x, self.y = float(cx), float(cy)

    # 2. Avancer dans la direction courante si la voie est libre
    if self._leading_edge_free(self.direction):
        self.x += self.direction[0] * PACMAN_SPEED
        self.y += self.direction[1] * PACMAN_SPEED

La tolérance de 2 px évite que le snap ne soit trop strict : si Pac-Man rate le centre de 1 pixel (ce qui arrive avec SPEED = 3), il peut quand même tourner.

Animer la bouche

L'animation de la bouche est un cycle de 8 frames : ouverture de 40° → fermeture → ouverture. Le dessin utilise pygame.draw.polygon pour tracer un triangle/éventail noir par-dessus le cercle jaune.

_DIR_ANGLE = {RIGHT: 0, DOWN: 90, LEFT: 180, UP: 270}
_MAX_MOUTH_DEG = 40

def draw(self, surface, offset_y=0):
    half = PACMAN_ANIM_FRAMES // 2   # 4
    f = self._anim_frame
    if f < half:
        mouth_deg = _MAX_MOUTH_DEG * (1.0 - f / half)   # fermeture
    else:
        mouth_deg = _MAX_MOUTH_DEG * ((f - half) / half)  # ouverture

    self._draw_sprite(surface, int(self.x), int(self.y), mouth_deg)

def _draw_sprite(self, surface, cx, cy, mouth_deg):
    pygame.draw.circle(surface, PACMAN_YELLOW, (cx, cy), PACMAN_RADIUS)
    if mouth_deg < 1.0:
        return   # bouche fermée

    base_rad = math.radians(_DIR_ANGLE[self.direction])
    half_rad = math.radians(mouth_deg)
    pts = [(cx, cy)]
    for i in range(15):
        angle = (base_rad - half_rad) + 2 * half_rad * i / 14
        pts.append((cx + PACMAN_RADIUS * math.cos(angle),
                    cy + PACMAN_RADIUS * math.sin(angle)))
    pygame.draw.polygon(surface, BLACK, pts)

L'éventail noir est orienté selon la direction de déplacement grâce au dictionnaire _DIR_ANGLE.


4. ghost.py — La partie la plus intéressante

Machine à états (FSM)

Les fantômes ont 4 états :

class GhostState(Enum):
    SCATTER    = auto()   # retraite vers un coin
    CHASE      = auto()   # poursuite active
    FRIGHTENED = auto()   # power pellet mangé, ils fuient
    EATEN      = auto()   # mangé par Pac-Man, retour à la maison

La séquence SCATTER/CHASE alterne automatiquement selon une timeline fixe :

_MODE_SEQ = [
    (GhostState.SCATTER,  7 * 60),   # 7 secondes de dispersion
    (GhostState.CHASE,   20 * 60),   # 20 secondes de chasse
    (GhostState.SCATTER,  5 * 60),
    (GhostState.CHASE,   20 * 60),
    # ensuite CHASE permanent
]

Le timer de mode est pausé pendant FRIGHTENED et EATEN — le jeu reprend exactement là où il s'était arrêté dans la séquence.

4 personnalités différentes

Chaque fantôme calcule sa tuile cible différemment en mode CHASE :

def _target_tile(self, pac_x, pac_y, pac_dir, blinky_pos):
    pc = int(pac_x) // TILE_SIZE
    pr = int(pac_y) // TILE_SIZE

    if self.name == 'blinky':
        return (pc, pr)   # cible directement Pac-Man

    if self.name == 'pinky':
        return (pc + 4 * pac_dir[0], pr + 4 * pac_dir[1])  # 4 tuiles devant

    if self.name == 'inky':
        # Point de référence 2 tuiles devant Pac-Man,
        # puis symétrie par rapport à Blinky → comportement imprévisible
        ref_c = pc + 2 * pac_dir[0]
        ref_r = pr + 2 * pac_dir[1]
        bc = int(blinky_pos[0]) // TILE_SIZE
        br = int(blinky_pos[1]) // TILE_SIZE
        return (2 * ref_c - bc, 2 * ref_r - br)

    # clyde : chasse si loin, fuit vers son coin si proche
    if math.hypot(self.tile_col - pc, self.tile_row - pr) > 8:
        return (pc, pr)
    return _SCATTER['clyde']

Ce sont les algorithmes originaux de Pac-Man 1980. Blinky est agressif, Pinky tente d'intercepter, Inky est chaotique (son comportement dépend de Blinky), Clyde est timide.

Pathfinding BFS

Pour aller de leur position à la tuile cible, les fantômes utilisent un BFS (Breadth-First Search) tile par tile. Le BFS garantit le chemin le plus court, et c'est suffisamment rapide pour 4 fantômes à 60 fps.

def _bfs_first_step(maze, fc, fr, tc, tr):
    """Retourne la première direction du chemin le plus court vers (tc, tr)."""
    start = (fc, fr)
    goal  = (tc, tr)

    visited = {start: None}
    queue = deque([start])

    while queue:
        col, row = queue.popleft()
        for d in _DIRS:   # UP, DOWN, LEFT, RIGHT
            nc, nr = col + d[0], row + d[1]
            nxt = (nc, nr)
            if nxt in visited or maze.is_wall(nc, nr):
                continue
            visited[nxt] = (col, row)
            if nxt == goal:
                # Remonter le chemin jusqu'au premier pas
                node = nxt
                while visited[node] != start:
                    node = visited[node]
                return (node[0] - fc, node[1] - fr)
            queue.append(nxt)

    # But inaccessible : prendre la direction qui réduit la distance
    best, best_d = None, float('inf')
    for d in _DIRS:
        nc, nr = fc + d[0], fr + d[1]
        if not maze.is_wall(nc, nr):
            dist = (nc - tc)**2 + (nr - tr)**2
            if dist < best_d:
                best_d, best = dist, d
    return best or DOWN

Le truc clé : le BFS ne retourne pas tout le chemin, juste le premier pas. Inutile de calculer 50 étapes à l'avance — à chaque nouvelle tuile atteinte, on recalcule.

La règle no-reverse

Dans le Pac-Man original, les fantômes ne peuvent pas faire demi-tour (sauf à certains moments précis). C'est implémenté après le BFS :

def _pick_next_tile(self, global_target):
    reverse = (-self.direction[0], -self.direction[1])
    d = _bfs_first_step(self.maze, self.tile_col, self.tile_row, *global_target)

    # Hors FRIGHTENED : si le BFS suggère demi-tour, chercher une alternative
    if self.state != GhostState.FRIGHTENED and d == reverse:
        best, best_dist = None, float('inf')
        for alt in _DIRS:
            if alt == reverse:
                continue
            nc, nr = self.tile_col + alt[0], self.tile_row + alt[1]
            if not self.maze.is_wall(nc, nr):
                dist = (nc - tc)**2 + (nr - tr)**2
                if dist < best_dist:
                    best_dist, best = dist, alt
        if best:
            d = best


5. pacman.py — La game loop

Détecter la collecte d'un power pellet

Plutôt que d'écouter un événement, on compare le nombre de pellets avant et après la mise à jour de Pac-Man :

prev_pellet_count = len(maze.pellets)
player.update()

if len(maze.pellets) < prev_pellet_count:
    for g in ghosts:
        g.frighten()
    ghost_eat_combo = 0

Simple et robuste.

Collisions avec les fantômes

On calcule la distance euclidienne entre les centres des sprites. La soustraction de 6 pixels donne une hitbox un peu plus permissive — le jeu est jouable sans être frustrant.

for g in ghosts:
    dist = math.hypot(player.x - g.x, player.y - g.y)
    if dist >= PACMAN_RADIUS + GHOST_RADIUS - 6:
        continue

    if g.state == GhostState.FRIGHTENED:
        g.eat()
        player.score += _GHOST_SCORES[min(ghost_eat_combo, 3)]
        ghost_eat_combo += 1
    elif g.state in (GhostState.SCATTER, GhostState.CHASE):
        player.lives -= 1
        death_timer = DEATH_FREEZE
        break

Le combo de score pour les fantômes : 200, 400, 800, 1600 — fidèle à l'original.

Geler le jeu après une mort

DEATH_FREEZE = 90   # 1,5 secondes

elif death_timer > 0:
    death_timer -= 1
    if death_timer == 0:
        if player.lives <= 0:
            game_over = True
        else:
            _reset_positions(player, ghosts)

Un simple compteur de frames qui gèle toute la logique. Pendant ce temps le rendu continue — on affiche "MORT !" à l'écran.

Le double buffer de surface

maze_surface = pygame.Surface((COLS * TILE_SIZE, ROWS * TILE_SIZE))

# Chaque frame :
maze_surface.fill(BLACK)
maze.draw(maze_surface, elapsed_ms)
for g in ghosts: g.draw(maze_surface, elapsed_ms)
player.draw(maze_surface)
screen.blit(maze_surface, (0, HUD_TOP))

On dessine d'abord tout le jeu sur une surface intermédiaire (maze_surface), puis on la colle sur l'écran principal avec un décalage de HUD_TOP = 28 px pour laisser de la place au HUD du score. Cela évite de gérer des offsets dans tous les sprites.


Pour aller plus loin

Le projet est déjà complet, mais voici quelques idées d'améliorations pour pratiquer :

Facile :

  • Ajouter un niveau suivant quand toutes les pastilles sont mangées
  • Afficher le score combo quand on mange un fantôme (texte flottant)
  • Ajouter des sons avec pygame.mixer

Intermédiaire :

  • Accélérer progressivement les fantômes selon le niveau
  • Implémenter le tunnel de téléportation gauche/droite (tuiles 0 et 19 de la même ligne)
  • Ajouter des fruits bonus qui apparaissent périodiquement

Avancé :

  • Remplacer le BFS par A* pour voir la différence de performance
  • Ajouter un mode deux joueurs (Pac-Man et un chasseur humain)
  • Générer des labyrinthes procéduralement

Récapitulatif

Ce projet illustre plusieurs patterns fondamentaux du développement de jeux en Python :

  • Séparation des responsabilités : chaque fichier a un rôle unique
  • Machine à états (FSM) : pour gérer les comportements complexes des fantômes
  • BFS tile-based : pathfinding simple et suffisant pour ce type de jeu
  • Sets Python : pour les lookups O(1) dans les collections fréquemment mises à jour
  • Coordonnées pixel vs tuile : gérer les deux systèmes et savoir quand convertir
  • Alignement sur grille : technique essentielle pour les jeux tile-based

Le code source complet est disponible en téléchargement — lance python pacman.py après avoir installé Pygame (pip install pygame).

Voici le lien vers le programme complet

Lucan

Laisser un commentaire

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