340 lines
18 KiB
Python
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
|