👻 Brancher une machine d'états sur un fantôme

Série noire - l'agent déterministe. Labo Pacman, étape 3 : un cerveau (la machine d'états) branché sur un corps (le fantôme).

Version complète - la solution

Le principe : trois actes, toujours dans cet ordre

Un agent déterministe ne réfléchit pas : il perçoit, son état change, il agit selon son état. Chaque décision se lit dans un tableau, rien n'est laissé au hasard du code.

  1. Naissance (le cerveau) : la machine d'états est créée une seule fois.
  2. Perception (le nerf optique) : à chaque tour, le fantôme mesure la distance à Pacman et la traduit en événement.
  3. Action (le muscle) : l'état actif dicte le comportement.

Où ça se branche dans le Pacman

À chaque tour, le jeu appelle getDistribution(state) de chaque fantôme (une sous-classe de GhostAgent, dans ghostAgents.py). La méthode retourne une distribution (util.Counter) : une note par action permise, et le jeu tire l'action au sort selon ces notes. Une seule action à 1.0 = une décision certaine.

Le nom du fichier doit finir par Agents.py (fantomesIntelligentsAgents.py) : pacman.py ne cherche les classes passées à -g que dans ces fichiers.

La machine : SEUL, VUE, PROCHE

pacmanEnVue pacmanLoin pacmanProche pacmanEnVue pacmanProche pacmanLoin SEUL VUE PROCHE

PROCHE : Pacman à 2 cases ou moins. VUE : de 3 à 5 cases. SEUL : plus de 5 cases (distance de Manhattan).

état actuel ↓ / événement →pacmanLoinpacmanEnVuepacmanProche
SEUL✓VUEPROCHE
VUESEUL✓PROCHE
PROCHESEULVUE✓

✓ = l'état ne change pas.

Notre librairie tient en 15 lignes : le dictionnaire transitions est la matrice état-transition. Une case absente veut dire « l'état ne change pas » : déclencher un événement sans transition n'est pas une erreur.

machineEtats.py à copier tel quel dans le dossier du Pacman
# machineEtats.py - notre machine d'états, sans librairie
# La machine se décrit comme sa matrice état-transition :
#   {(etatActuel, evenement): nouvelEtat}   une case absente = l'état ne change pas


class MachineEtats:

    def __init__(self, nom, etatInitial, transitions):
        self.nom = nom
        self.etat = etatInitial
        self.transitions = transitions

    def declencher(self, evenement):
        nouvelEtat = self.transitions.get((self.etat, evenement), self.etat)
        if nouvelEtat != self.etat:
            print(f"[{self.nom}] {self.etat} --{evenement}--> {nouvelEtat}")
            self.etat = nouvelEtat

1Naissance : le cerveau

La machine se crée dans __init__, une seule fois. Recréée dans getDistribution, elle repartirait à SEUL à chaque tour : le fantôme deviendrait amnésique.

fantomesIntelligentsAgents.py le cerveau naît une seule fois
class FantomeIntelligent(GhostAgent):

    # --- Acte 1 : la naissance du cerveau ---
    def __init__(self, index):
        super().__init__(index)
        self.creerMachines()

    def registerInitialState(self, state):
        # Le jeu l'appelle au début de CHAQUE partie (avec -n 3, le même fantôme joue 3 parties)
        self.creerMachines()

    def creerMachines(self):
        self.machineDistance = MachineEtats(f"fantome-{self.index}", "SEUL", {
            ("SEUL", "pacmanEnVue"): "VUE",
            ("SEUL", "pacmanProche"): "PROCHE",
            ("VUE", "pacmanLoin"): "SEUL",
            ("VUE", "pacmanProche"): "PROCHE",
            ("PROCHE", "pacmanLoin"): "SEUL",
            ("PROCHE", "pacmanEnVue"): "VUE",
        })
        self.machineMode = MachineEtats(f"fantome-{self.index}-mode", "NORMAL", {
            ("NORMAL", "capsuleMangee"): "CHASSE",
            ("CHASSE", "peurTerminee"): "NORMAL",
        })

Piège : avec -n 3, le même objet fantôme joue les 3 parties. Le jeu appelle registerInitialState(state) au début de chaque partie : c'est là qu'on remet la machine à zéro.

2Perception : le nerf optique

La distance se traduit en un événement par tour, et toutes les distances doivent en produire un. Oublier « loin » : le fantôme ne redevient jamais SEUL.

fantomesIntelligentsAgents.py chaque distance produit UN événement
    # --- Acte 2 : la perception ---
    def percevoir(self, state):
        positionFantome = state.getGhostPosition(self.index)
        distancePacman = manhattanDistance(positionFantome, state.getPacmanPosition())
        if distancePacman <= DISTANCE_PROCHE:
            self.machineDistance.declencher("pacmanProche")
        elif distancePacman <= DISTANCE_VUE:
            self.machineDistance.declencher("pacmanEnVue")
        else:
            self.machineDistance.declencher("pacmanLoin")
        if state.getGhostState(self.index).scaredTimer > 0:
            self.machineMode.declencher("capsuleMangee")
        else:
            self.machineMode.declencher("peurTerminee")

Piège de la librairie transitions : déclencher un événement qui n'a pas de transition depuis l'état actuel (par exemple pacman_proche() quand on est déjà PROCHE) lève une MachineError et la partie plante. Notre machine, elle, l'ignore : c'est le ✓ de la matrice.

3Action : le muscle

Pour poursuivre ou fuir : essayer chaque action permise, calculer la distance à Pacman qu'elle donnerait, garder la plus petite (poursuivre) ou la plus grande (fuir).

fantomesIntelligentsAgents.py le tableau COMPORTEMENTS décide
    # --- Acte 3 : l'action ---
    def getDistribution(self, state):
        self.percevoir(state)
        actionsPermises = state.getLegalActions(self.index)
        comportement = COMPORTEMENTS[(self.machineMode.etat, self.machineDistance.etat)]
        distribution = util.Counter()
        if comportement == "auHasard":
            for action in actionsPermises:
                distribution[action] = 1.0
        else:
            distribution[self.choisirAction(state, actionsPermises, comportement)] = 1.0
        distribution.normalize()
        return distribution   # le jeu attend une distribution, pas une action

    def choisirAction(self, state, actionsPermises, comportement):
        positionFantome = state.getGhostPosition(self.index)
        positionPacman = state.getPacmanPosition()
        vitesse = 0.5 if state.getGhostState(self.index).scaredTimer > 0 else 1.0
        distances = {}
        for action in actionsPermises:
            deplacementX, deplacementY = Actions.directionToVector(action, vitesse)
            positionSuivante = (positionFantome[0] + deplacementX, positionFantome[1] + deplacementY)
            distances[action] = manhattanDistance(positionSuivante, positionPacman)
        if comportement == "poursuivre":
            cible = min(distances.values())
        else:
            cible = max(distances.values())
        return random.choice([action for action, distance in distances.items() if distance == cible])

Piège : return action ne marche pas. getDistribution doit retourner une distribution ; le jeu fait lui-même le tirage (util.chooseFromDistribution).

Doubler la machine : la chasse aux fantômes

Quand Pacman mange une capsule, le jeu met scaredTimer à 40 : le fantôme devient blanc et Pacman peut le manger. Une deuxième machine retient ce mode ; l'action dépend des deux états à la fois.

mode ↓ / événement →capsuleMangeepeurTerminee
NORMALCHASSE✓
CHASSE✓NORMAL
action (mode + état)SEULVUEPROCHE
NORMALau hasardpoursuivrefuir
CHASSEau hasardfuirfuir

À discuter : le fantôme qui hésite

La consigne indicative « PROCHE = FUIR » donne un fantôme qui oscille : il poursuit Pacman (VUE), arrive à 2 cases, fuit (PROCHE), repasse VUE et repart... La console le montre : VUE --pacmanProche--> PROCHE puis PROCHE --pacmanEnVue--> VUE. Changez une seule ligne : ("NORMAL", "PROCHE"): "poursuivre". Le fantôme attaque, et ne fuit plus qu'en mode CHASSE. Le comportement se modifie dans le tableau, sans toucher au reste du code : c'est tout l'intérêt d'une machine d'états.

Le fichier complet, prêt à lancer

Les deux fichiers vont dans le dossier du Pacman (à côté de pacman.py), puis : python3 pacman.py -g FantomeIntelligent -l mediumClassic. Ajouter -p GreedyAgent pour qu'un Pacman automatique joue, -n 3 pour 3 parties. La console affiche chaque changement d'état : [fantome-1] SEUL --pacmanEnVue--> VUE.

fantomesIntelligentsAgents.py testé : 4 parties, les 6 transitions et le mode chasse observés
# fantomesIntelligentsAgents.py - un fantôme intelligent, piloté par deux machines d'états
# Le nom du fichier finit par « Agents.py » : sinon pacman.py ne trouve pas la classe.
# Lancer : python3 pacman.py -g FantomeIntelligent -l mediumClassic

import random

import util
from game import Actions
from ghostAgents import GhostAgent
from machineEtats import MachineEtats
from util import manhattanDistance

DISTANCE_PROCHE = 2   # PROCHE : 2 cases ou moins
DISTANCE_VUE = 5      # VUE : de 3 à 5 cases ; au-delà, SEUL

# Ce que fait le fantôme selon (mode, état)
COMPORTEMENTS = {
    ("NORMAL", "SEUL"): "auHasard",
    ("NORMAL", "VUE"): "poursuivre",
    ("NORMAL", "PROCHE"): "fuir",      # consigne de l'énoncé : PROCHE = FUIR
    ("CHASSE", "SEUL"): "auHasard",
    ("CHASSE", "VUE"): "fuir",         # Pacman a mangé une capsule : il peut nous manger
    ("CHASSE", "PROCHE"): "fuir",
}


class FantomeIntelligent(GhostAgent):

    # --- Acte 1 : la naissance du cerveau ---
    def __init__(self, index):
        super().__init__(index)
        self.creerMachines()

    def registerInitialState(self, state):
        # Le jeu l'appelle au début de CHAQUE partie (avec -n 3, le même fantôme joue 3 parties)
        self.creerMachines()

    def creerMachines(self):
        self.machineDistance = MachineEtats(f"fantome-{self.index}", "SEUL", {
            ("SEUL", "pacmanEnVue"): "VUE",
            ("SEUL", "pacmanProche"): "PROCHE",
            ("VUE", "pacmanLoin"): "SEUL",
            ("VUE", "pacmanProche"): "PROCHE",
            ("PROCHE", "pacmanLoin"): "SEUL",
            ("PROCHE", "pacmanEnVue"): "VUE",
        })
        self.machineMode = MachineEtats(f"fantome-{self.index}-mode", "NORMAL", {
            ("NORMAL", "capsuleMangee"): "CHASSE",
            ("CHASSE", "peurTerminee"): "NORMAL",
        })

    # --- Acte 2 : la perception ---
    def percevoir(self, state):
        positionFantome = state.getGhostPosition(self.index)
        distancePacman = manhattanDistance(positionFantome, state.getPacmanPosition())
        if distancePacman <= DISTANCE_PROCHE:
            self.machineDistance.declencher("pacmanProche")
        elif distancePacman <= DISTANCE_VUE:
            self.machineDistance.declencher("pacmanEnVue")
        else:
            self.machineDistance.declencher("pacmanLoin")
        if state.getGhostState(self.index).scaredTimer > 0:
            self.machineMode.declencher("capsuleMangee")
        else:
            self.machineMode.declencher("peurTerminee")

    # --- Acte 3 : l'action ---
    def getDistribution(self, state):
        self.percevoir(state)
        actionsPermises = state.getLegalActions(self.index)
        comportement = COMPORTEMENTS[(self.machineMode.etat, self.machineDistance.etat)]
        distribution = util.Counter()
        if comportement == "auHasard":
            for action in actionsPermises:
                distribution[action] = 1.0
        else:
            distribution[self.choisirAction(state, actionsPermises, comportement)] = 1.0
        distribution.normalize()
        return distribution   # le jeu attend une distribution, pas une action

    def choisirAction(self, state, actionsPermises, comportement):
        positionFantome = state.getGhostPosition(self.index)
        positionPacman = state.getPacmanPosition()
        vitesse = 0.5 if state.getGhostState(self.index).scaredTimer > 0 else 1.0
        distances = {}
        for action in actionsPermises:
            deplacementX, deplacementY = Actions.directionToVector(action, vitesse)
            positionSuivante = (positionFantome[0] + deplacementX, positionFantome[1] + deplacementY)
            distances[action] = manhattanDistance(positionSuivante, positionPacman)
        if comportement == "poursuivre":
            cible = min(distances.values())
        else:
            cible = max(distances.values())
        return random.choice([action for action, distance in distances.items() if distance == cible])

Retour à l'énoncé des laboratoires Agents · voir aussi la version indices