This repository has been archived on 2022-03-31. You can view files and clone it, but cannot push or open issues or pull requests.
morpionMinimax/minimax.py

340 lines
18 KiB
Python

import sys
from math import sqrt
from random import choice
class Morpion():
"""Implémentation du Morpion."""
def __init__(self, joueurA: str = 'X', joueurB: str = 'O', taille: tuple = (3, 3)) -> None:
"""
Initalise la classe Morpion :
- Par défaut :
- Le joueur A se nomme `X`.
- Le joueur B se nomme `O`.
- Le plateau à une taille `3x3`.
"""
self.erreur = False
if len(joueurA) != 1 or len(joueurB) != 1: # Gestion erreur nom des joueurs
print("Nom des joueurs invalide.")
self.erreur = True
return
self.plateau: list = self._definitionPlateau(taille[0], taille[1])
if len(self.plateau) == 0: # Gestion erreur du plateau
print("Taille du plateau invalide.")
self.erreur = True
return
self.nbCasesGagnantes = self._recuperationNbCasesGagnantes() # définit combien de cases les joueurs doivent aligner pour gagner
self.joueurA = joueurA # définit le joueur A
self.joueurB = joueurB # définit le joueur B
self.joueurActuel = choice([joueurA, joueurB]) # le joueur qui commence est choisi aléatoirement car le nom 'X' peut varier
self.gagnant = '' # à la fin, montre qui est le gagnant
"""
Permet un bel affichage, la variable `tailleMaxCharactere` augmente en fonction de la taille du tableau et permet
de toujours avoir tout de centrer et tout de la bonne longueur
"""
tailleMaxCharactere = len(str(self.plateau[-1][-1]))
self.tailleMaxCharactere = tailleMaxCharactere if tailleMaxCharactere % 2 != 0 else tailleMaxCharactere + 1
def _definitionPlateau(self, x: int, y: int) -> list:
"""
Renvoie un plateau en fonction de dimensions `x` et `y`.
Les cellules vides sont correspondent à leur "numéro de case".
"""
if x <= 0 or y <= 0:
return []
plateau = []
numCase = 1
for i in range(y):
plateau.append([])
for _ in range(x):
plateau[i].append(numCase)
numCase += 1
return plateau
def _recuperationNbCasesGagnantes(self) -> int:
"""
Renvoie le nombre de cases à aligné pour pouvoir gagner la partie.
Défini en fonction de la taille du plateau.
"""
mini = len(self.plateau) # mini correspond au côté le plus petit
if len(self.plateau[0]) < mini:
mini = len(self.plateau[0])
res = round(sqrt(mini) * 2) # fonction qui grandit lentement, basé sur https://www.mathsisfun.com/sets/functions-common.html
if res > mini: # si res trouvé excède la taille du côté, alors c'est autant que la taille du côté
res = mini
return res
def afficher(self) -> None:
"""Affiche le plateau."""
print("\n" * 5) # petit espace par rapport à ce que le terminale de l'utilisateur à afficher dernièrement
espace = f"\n {'-' * len(self.plateau[0]) * (3 + self.tailleMaxCharactere)}-" # taille des lignes qui séparent les valeurs du plateau
for i in range(len(self.plateau)):
print(f"{espace[1:] if i == 0 else espace}", end = "\n ") # on retire le saut de ligne à la première itération
print("", end = "| ")
for j in range(len(self.plateau[i])):
n = self.plateau[i][j]
if type(n) == int:
print(f"{n:0{self.tailleMaxCharactere}d}", end = " | ") # on rajoute un "0" au chiffre < 10 (comme en C avec `%02d`)
else:
nombreEspaceRequis = (self.tailleMaxCharactere - 1) // 2
print(f"{' ' * nombreEspaceRequis}{n}{' ' * nombreEspaceRequis}", end = " | ") # espace automatique pour pas décaler l'affichage
print(espace)
def _caseOccupee(self, x: int, y: int) -> bool:
"""Vrai si la case en `x` et `y` est déjà occupée par un joueur."""
return type(self.plateau[x][y]) == str
def _terminer(self) -> bool:
"""Vrai si la partie est terminée."""
fini = False
for i in range(len(self.plateau)):
for j in range(len(self.plateau[i])):
if self._caseOccupee(i, j): # si case occupé par un joueur
rechercheLigne = 0
rechercheColonne = 0
# les diagonales vont vers la droite
rechercheDiagonaleVersBas = 0
rechercheDiagonaleVersHaut = 0
joueur = self.plateau[i][j]
while (rechercheLigne != -1) or (rechercheColonne != -1) or (rechercheDiagonaleVersBas != -1) or (rechercheDiagonaleVersHaut != -1):
# -- recherche en ligne --
if len(self.plateau[i]) - j >= self.nbCasesGagnantes: # s'il y a techniquement assez de cases devant pour gagner
if self.plateau[i][j + rechercheLigne] == joueur:
rechercheLigne += 1
else: # si l'élément ne correspond pas au joueur c'est que il n'a pas gagné depuis cette case avec cette direction
rechercheLigne = -1
else: # sinon c'est que ça sert de continuer pour cette ligne
rechercheLigne = -1
if self.nbCasesGagnantes == rechercheLigne: # si on a trouvé autant de cases à la suite du même joueur qu'il en faut pour gagner
fini = True
break # si partie finie ça ne sert a rien de continuer, donc on quitte la boucle while
# -- recherche en colonne --
if len(self.plateau) - i >= self.nbCasesGagnantes: # s'il y a techniquement assez de cases devant pour gagner
if self.plateau[i + rechercheColonne][j] == joueur:
rechercheColonne += 1
else: # si l'élément ne correspond pas au joueur c'est que il n'a pas gagné depuis cette case avec cette direction
rechercheColonne = -1
else: # sinon c'est que ça sert de continuer pour cette ligne
rechercheColonne = -1
if self.nbCasesGagnantes == rechercheColonne: # si on a trouvé autant de cases à la suite du même joueur qu'il en faut pour gagner
fini = True
break # si partie finie ça ne sert a rien de continuer, donc on quitte la boucle while
# -- recherche en diagonale vers le bas --
if (len(self.plateau) - i >= self.nbCasesGagnantes) and (len(self.plateau[i]) - j >= self.nbCasesGagnantes): # s'il y a techniquement assez de cases devant pour gagner
if self.plateau[i + rechercheDiagonaleVersBas][j + rechercheDiagonaleVersBas] == joueur:
rechercheDiagonaleVersBas += 1
else: # si l'élément ne correspond pas au joueur c'est que il n'a pas gagné depuis cette case avec cette direction
rechercheDiagonaleVersBas = -1
else: # sinon c'est que ça sert de continuer pour cette ligne
rechercheDiagonaleVersBas = -1
if self.nbCasesGagnantes == rechercheDiagonaleVersBas: # si on a trouvé autant de cases à la suite du même joueur qu'il en faut pour gagner
fini = True
break # si partie finie ça ne sert a rien de continuer, donc on quitte la boucle while
# -- recherche en diagonale vers le haut --
if (len(self.plateau) - self.nbCasesGagnantes >= 0) and (len(self.plateau[i]) - j >= self.nbCasesGagnantes): # s'il y a techniquement assez de cases devant pour gagner
idxI = i - rechercheDiagonaleVersHaut # i ne peut pas être <= 0
if idxI >= 0 and self.plateau[idxI][j + rechercheDiagonaleVersHaut] == joueur:
rechercheDiagonaleVersHaut += 1
else: # si l'élément ne correspond pas au joueur c'est que il n'a pas gagné depuis cette case avec cette direction
rechercheDiagonaleVersHaut = -1
else: # sinon c'est que ça sert de continuer pour cette ligne
rechercheDiagonaleVersHaut = -1
if self.nbCasesGagnantes == rechercheDiagonaleVersHaut: # si on a trouvé autant de cases à la suite du même joueur qu'il en faut pour gagner
fini = True
break # si partie finie ça ne sert a rien de continuer, donc on quitte la boucle while
if fini: # meme chose, on quitte la boucle for (j) si on a fini
break
if fini: # meme chose, on quitte la boucle for (i) si on a fini
break
if fini:
self.gagnant = joueur
return fini
def _coordonneesCaseDepuisNumero(self, numero: int) -> tuple[int, int]:
"""Renvoie les coordonnées d'une case."""
return ((numero - 1) // len(self.plateau[0]), (numero - 1) % len(self.plateau[0]))
def _placementPiece(self, valeur, n: int) -> None: # valeur est une Union de str et int car il peut être ammené à être modifié avec Minimax
"""Place la pièce d'un joueur dans le plateau."""
x, y = self._coordonneesCaseDepuisNumero(n)
self.plateau[x][y] = valeur
def _demandeCase(self, joueur) -> int:
"""Demande au joueur sur quelle case il veut poser sa pièce."""
prefix = f"({joueur} - {self.nbCasesGagnantes} cases successives pour gagner)"
print(f"{prefix} Entrez le numéro de case que vous voulez jouer : ", end = "")
reponse = -1
while int(reponse) < 0:
erreur = ""
reponse = input()
if not reponse.isnumeric():
erreur = "pas un nombre"
elif int(reponse) > len(self.plateau) * len(self.plateau[0]):
erreur = "valeur trop grande"
elif int(reponse) == 0: # < 0 pas considéré comme un nombre avec la fonction `isnumeric`
erreur = "valeur trop petite"
else:
x, y = self._coordonneesCaseDepuisNumero(int(reponse))
if self._caseOccupee(x, y):
erreur = "case déjà occupée"
if len(erreur) > 0:
print(f"{prefix} ❌ Valeur invalide ({erreur}), réessayez : ", end = "")
reponse = -1
return int(reponse)
def gras(self, texte: str) -> str:
"""Fonction qui renvoie le texte en argument en gras.""" # source: https://stackoverflow.com/a/17303428
return f"\033[1m{texte}\033[0m"
def _demandeCaseA(self) -> int:
"""Demande au joueur A de jouer."""
return self._demandeCase(self.joueurA)
def _demandeCaseB(self) -> int:
"""Demande au joueur B de jouer."""
return self._demandeCase(self.joueurB)
def _egalite(self) -> bool:
"""Renvoie vrai si le plateau est plein."""
for i in range(len(self.plateau)):
for j in range(len(self.plateau[i])):
if not self._caseOccupee(i, j): # si case pas occupée par un joueur
return False
return True
def jouer(self) -> None:
"""Lance la partie de Morpion."""
while not self._terminer() and not self._egalite(): # tant que la partie n'est pas terminé
self.afficher() # affichage du plateau
if self.joueurActuel == self.joueurA:
self._placementPiece(self.joueurA, self._demandeCaseA()) # on place la pièce du joueur là où il veut
self.joueurActuel = self.joueurB
else:
self._placementPiece(self.joueurB, self._demandeCaseB()) # on place la pièce du joueur là où il veut
self.joueurActuel = self.joueurA
self.afficher() # affichage du plateau final
if self._egalite() and len(self.gagnant) == 0:
print(f"😬 Partie terminée, {self.gras(f'égalité parfaite')}.")
else:
print(f"🎉 Partie terminée, le {self.gras(f'joueur {self.gagnant} a gagné')} !")
class Minimax(Morpion):
"""Définition de l'algorithme Minimax."""
def __init__(self, humain: str = 'X', ordinateur: str = 'O', taille: tuple = (3, 3)) -> None:
"""
Initalise la classe Minimax héritant du Morpion.
- Taille par défaut : `3x3`.
- Joueur A est l'humain.
- Joueur B est l'ordinateur.
"""
super().__init__(humain, ordinateur, taille = taille)
if self.erreur: # en cas d'erreur dans la classe parent
return
self.humain = self.joueurA
self.robot = self.joueurB
def _demandeCaseB(self) -> int:
"""Utilise l'algorithme `Minimax` pour jouer le coup du joueur B."""
return self.minimax(self.robot)[1]
def _terminerOuEgaliter(self) -> bool:
"""Retourne vrai si il y a un gagnant ou si le plateau est plein."""
return not len(self.gagnant) == 0 or self._egalite()
def _coupsPossibles(self) -> list:
"""Renvoie la liste des coups possibles dans le plateau."""
coups = []
for i in range(len(self.plateau)):
for j in range(len(self.plateau[i])):
if not self._caseOccupee(i, j):
coups.append(self.plateau[i][j])
return coups
def _ennemi(self, joueur) -> str:
"""Renvoie l'ennemi du joueur en argument."""
if joueur == self.humain:
return self.robot
return self.humain
def minimax(self, joueur: str, profondeur: int = 0):
"""
Fonction Minimax qui décide quel case est la plus intéressante.
Profondeur par défaut à `0` car commence à `0`.
"""
if joueur == self.robot:
meilleursCas = -1
else:
meilleursCas = 1
if self._terminerOuEgaliter():
if self.gagnant == self.humain:
return -1 + profondeur, None
elif self._egalite():
return 0, None
elif self.gagnant == self.robot:
return 1 - profondeur, None
for numero in self._coupsPossibles():
self._placementPiece(joueur, numero) # on essaie de jouer un coup
valeurEvaluation, _ = self.minimax(self._ennemi(joueur), profondeur + 1) # on voit ce que ça donne avec le coup que l'on vient de mettre
self._placementPiece(numero, numero) # on replace l'ancienne valeur
if joueur == self.robot:
if valeurEvaluation > meilleursCas:
meilleursCas, meilleurNumero = valeurEvaluation, numero
else:
if valeurEvaluation < meilleursCas:
meilleursCas, meilleurNumero = valeurEvaluation, numero
return meilleursCas, meilleurNumero
if __name__ == "__main__": # Si on lance directement le fichier et on s'en sert pas comme module
"""
J'ai fait 2 classes :
-> La classe Morpion permet de jouer au morpion entre deux humains.
-> Dans mon morpion :
-> `X` et `O` complètement personnalisable.
-> La taille du plateau peut s'étendre à l'infini.
-> Le joueur qui commence est choisit aléatoirement car `X` n'est pas obligatoirement le joueur A.
-> Pour rendre les parties avec de grands plateaux quand même intéréssant,
j'ai fait en sorte (avec la racine du côté le plus petit multiplié par 2)
de ne pas donner comme condition de victoire toutes une longueur ou une largeur
complété mais seulement un morceau. Ce morceau grossit en fonction de la taille du plateau.
-> La classe Minimax dépend de Morpion (mais elle peut être rataché à d'autre classe ce qui la rend extrêmement flexible)
et permet de remplacer le joueur B et ainsi jouer contre l'algorithme `Minimax`.
Minimax hérite donc des arguments de Morpion :
-> Pour configurer le morpion on peut lui donner :
-> Le nom du joueur A.
-> Le nom du joueur B.
-> Une taille de plateau (qui peut ne pas être identique, exemple : un plateau de 4x6).
-> Les noms de joueurs ne peuvent être que des string de un seule charactère (pour que l'affichage soit jolie).
-> le fichier s'adapte aux arguments données :
-> Si un argument : Précisez la taille du tableau, exemple : "3x3".
-> Si deux arguments : Précisez le nom des deux joueurs qui vont jouer (Rappel: un joueur = un charactère).
-> Si trois arguments : Précisez alors la taille le nom des deux joueurs et la taille du tableau, en suivant les règles précédentes.
"""
sys.argv.pop(0) # on retire le nom du fichier (première valeure de `argv`)
try: # on ne vérifie pas si la taille est bonne, en cas d'erreur on lance le programme avec les paramètres par défaut
if len(sys.argv) == 1:
Minimax(taille = tuple([int(i) for i in sys.argv[0].split('x')])).jouer() # On spécifie la taille du tableau
elif len(sys.argv) == 2:
Minimax(*sys.argv).jouer() # On spécifie les joueurs
else:
Minimax(sys.argv[0], sys.argv[1], tuple([int(i) for i in sys.argv[2].split('x')])).jouer() # On spécifie les joueurs et la taille du tableau
except Exception as e:
if not "list index out of range" in str(e):
print(f"Un argument n'a pas été compris ({e})... Lancement du Morpion avec les paramètres par défaut.")
Minimax().jouer() # On lance la partie à l'instanciation du Morpion