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