Aller au contenu

La fausse bonne idée du
Simple Tic-Tac-Toe1 Game - Learning to code...⚓︎

Introduction⚓︎

Les sites web, chaines youtube et autres supports proposant des tutoriels pour apprendre à programmer sont nombreux. Plus ou moins pédagogiques, abordant des niveaux de programmation plus ou moins difficiles : interfaces graphiques ou pas, programmation orientée objet ou impérative, utilisation de modules externes ou pas etc.

Sur le site steemit.com on trouve une série d'épisodes Learning to code with Python2. Des articles d'un blogueur qui apprenait (les articles datent de 2018 environ) Python lui-même et partageait son avancée et son expérience via son blog.

La démarche est intéressante et on peut saluer l'effort fait par l'auteur.

Si au premier abord on peut se dire que ce type de ressources est une excellente porte d'entrée pour des grands débutants (élèves de \(1^{re}\) NSI ou même de première année de Licence n'ayant jamais programmé en Python) ; en y regardant de plus près on s'aperçoit qu'il s'agit d'une fausse bonne idée.

Dans la suite, nous indiquons les passages de la modélisation qui manquent de généricité et proposons une alternative. Nous mettrons aussi en avant les vraies bonnes idées.

Modélisation du jeu par Abmakko⚓︎

La modélisation s'adresse à des débutant.e.s et ne fait donc pas appel à la programmation orientée objet (même si comme nous le verrons cette modélisation par objet s'avère plus simple).

Le plateau

Souvent un plateau de jeu avec des cases en lignes et en colonnes est modélisé par une matrice (typiquement une liste de listes en Python). Abmakko propose de modéliser le plateau par un dictionnaire. Chacune des neuf cases du Tic-Tac-Toe est représentée par un caractère :

1 2 3
4 5 6
7 8 9

Au départ ces cases sont vides et Abmakko propose le dictionnaire suivant :

theBoard = {'1': ' ', '2': ' ', '3': ' ',
            '4': ' ', '5': ' ', '6': ' ',
            '7': ' ', '8': ' ', '9': ' '}
Bonne idée

Ce choix du dictionnaire est une vraie bonne idée. En effet pour des débutant.e.s la manipulation de listes de listes est difficile. Des indices de lignes et de colonnes entrainent des double boucles pour parcourir les cases.

Il semble plus simple d'écrire :

for numero_case in '123456789':
    # traitement utilisant grille[numero_case]

que

for i in range(3):
    for j in range(3):
        # traitement utilisant grille[i][j]
Les joueurs

Dans l'article, les joueurs sont simplement représentés par leurs marques. On a donc des 'X'et des 'O'dans les cases non vides. Le changement de joueur se fait alors par :

if node == 'X':
    node = 'O'
else:
    node = 'X'
Les affichages

Abmakko définit deux fonctions d'affichage :

La première est une aide qui présente aux joueurs les numéros des cases :

def instruction():
     print("press the numbers to place your X or O in position\n")    
     print('  ', '7', '  |','  ' ,'8', '  |', '  ','9')
     print('-------+--------+--------')
     print('  ', '4', '  |','  ' ,'5', '  |', '  ','6')
     print('-------+--------+--------')
     print('  ', '1', '  |','  ' ,'2', '  |', '  ','3', '\n')
     print('=========================\n')

La deuxième est celle qui affiche la grille de jeu :

def display_board(theBoard):    
    print('  ', theBoard['7'], '  |','  ' ,theBoard['8'], '  |', '  ',theBoard['9'])
    print('-------+--------+--------')
    print('  ', theBoard['4'], '  |','  ' ,theBoard['5'], '  |', '  ',theBoard['6'])
    print('-------+--------+--------')
    print('  ', theBoard['1'], '  |','  ' ,theBoard['2'], '  |', '  ',theBoard['3'], '\n')

Bonne idée

Si les fonctions manquent de généricité (par exemple la fonction instruction affiche toujours tous les numéros, même ceux des cases occupées), l'idée d'avoir une fonction qui affiche une aide aux joueurs est intéressante.

Tester l'existence d'un vainqueur

Après chaque coup, il faut vérifier que le joueur courant (celui qui vient de jouer) n'a pas gagné :

def check_winner(theBoard):
    check = 0    
    if theBoard['7'] == theBoard['8'] == theBoard['9'] != ' ' :
        check = 1
    elif theBoard['7'] == theBoard['4'] == theBoard['1'] != ' ':
        check = 1
    elif theBoard['7'] == theBoard['5'] == theBoard['3'] != ' ':
        check = 1
    elif theBoard['4'] == theBoard['5'] == theBoard['6'] != ' ': 
        check = 1
    elif theBoard['1'] == theBoard['2'] == theBoard['3'] != ' ':
        check = 1
    elif theBoard['1'] == theBoard['5'] == theBoard['9'] != ' ':
        check = 1
    elif theBoard['9'] == theBoard['6'] == theBoard['3'] != ' ':
        check = 1
    elif theBoard['2'] == theBoard['5'] == theBoard['8'] != ' ':
        check = 1
    return check
La fonction principale de jeu
def play(theBoard):
node = "X"
for i in range(9):
    instruction()
    display_board(theBoard)
    print('it is ', node, 'turn to play \n')
    turn = input()
    while True:
        if theBoard[turn] == ' ':
            theBoard[turn] = node
            break
        else:
            print("you've already played that move, place your", node, "elsewhere\n")
            instruction()
            display_board(theBoard)
            turn = input()
    check = check_winner(theBoard)
    if check == 1:
        print('The winner is', node)
        break
    else:
        print("No winner, play again?")
    if node == 'X':
        node = 'O'
    else:
        node = 'X'

Améliorer la généricité⚓︎

Globalement, le principal reproche de ce script est son manque de généricité et de modularité. Un débutant a qui on montre une solution, aura tendance à tenter de la mémoriser pour la reproduire. Ce qui est parfaitement naturel. D'où la vigilance didactique de ne pas trop simplifier les choses.

Mauvaises pratiques

Écrire en dur des lignes de code similaires, sans passer par des boucles est une mauvaise pratique : le débutant voudra reproduire la technique, même sur des problèmes plus gros.

Nous allons privilégier les petites fonctions, les constantes, etc. Tout ce qui rend le code léger, lisible, maintenable et évolutif.

Constantes, modélisation des joueurs et affichages⚓︎

Le plus souvent on modélise les joueurs par les entiers 0 et 1 qu'on nomme (par des constantes) pour plus de lisibilité :

Constantes joueurs
CROIX = 0
ROND = 1
PERSONNE = 2

On peut continuer à introduire quelques constantes modélisant les différents blocs de 3 cases qui vont nous faciliter la vie pour la manipulation de la grille (les fonctions d'affichage mais aussi le test du vainqueur) :

Les blocs
NUMEROS = '123456789'
LIGNES = '123', '456', '789'
COLONNES = '147', '258', '369'
DIAGONALES = '159', '753'                       

Quelques avantages à avoir des entiers pour modéliser les joueurs :

Avantage 1

Le changement de joueur se fait sans test :

joueur = 1 - joueur

Note : certains préfèrent modéliser le joueur 1 par l'entier 1, le joueur 2 par l'entier 2 et une case vide par 0. Alors le changement de joueur se ferait par joueur = 3 - joueur (la formule est \(j = t - j\)\(j\) est le numéro du joueur et \(t\) le total des numéros des deux joueurs).

On pourrait rétorquer que ce test gagné va se retrouver dans le code de la fonction d'affichage ? Nenni ! Grâce aux constantes :

Avantage 2

Les valeurs 0, 1, et 2 peuvent être vues comme des indices, donnant accès, sans faire de test, aux marqueurs relatifs aux joueurs :

MARQUEURS = 'XO.'
PAS_DISPO = '_'

def afficher_grille(grille):
    for ligne in LIGNES:
        for num in ligne:
            joueur = grille[num]
            print(MARQUEURS[joueur], end=' ')
        print()
    print()

L'affichage de l'aide peut évoluer avec la partie, pour ne montrer que le numéro des cases jouables :

def afficher_aide(grille):
for ligne in LIGNES:
    for num in ligne:
        if grille[num] == PERSONNE:
            print(num, end=' ')
        else:
            print(PAS_DISPO, end=' ')
    print()
print()

La création d'une grille vierge⚓︎

Une fonction générant une grille vierge (un dictionnaire) est utile : dans le cas d'un jeu plus gros on pourra réutiliser cette technique.

Générer une nouvelle grille
def nouvelle_grille():
    return {num:PERSONNE for num in NUMEROS}

Tester l'existence d'un vainqueur⚓︎

La fonction proposée par Abmakko retour un entier 0 ou 1. Même si 0 et 1 font de bons booléens (dans certains langages le type bool n'existe pas), en Python on peut utiliser les vrais booléens True et False.

D'autre part, sa fonction constituée d'un if... elif pour tester les 8 possibilités est vraiment à proscrire.

La vérification se fait pourtant en 2 étapes simples :

  1. Il y a un vainqueur si on trouve 3 cases alignées en ligne, en colonne ou en diagonale
  2. Dans une des directions (ligne, colonne ou diagonale), on a 3 symboles alignés si dans l'un des blocs de 3 cases on trouve 3 fois le même joueur et ce n'est pas PERSONNE

Voici les fonctions Python qui découlent :

Tester le vainqueur
def un_gagnant(grille):
    return trois_alignes(grille, LIGNES) or\ 
           trois_alignes(grille, COLONNES) or\
           trois_alignes(grille, DIAGONALES)

def trois_alignes(grille, direction):
    for bloc_de_3 in direction:
        v1 = grille[bloc_de_3[0]]
        v2 = grille[bloc_de_3[1]]
        v3 = grille[bloc_de_3[2]]
        if v1 != PERSONNE and v1 == v2 and v1 == v3:
            return True
    return False

La fonction principale⚓︎

Les jeux à deux joueurs ont souvent le même squelette :

  • Initialisations (lignes 7 à 11 du code ci-dessous)
  • Boucle principale : tant que la partie n'est pas finie, (ligne 15)
    • on affiche la grille (ligne 16)
    • on demande son coup au joueur courant (ligne 17)
    • on met à jour la grille (ligne 18)
    • on test l'existence d'un vainqueur (ligne 20)
    • ou alors si la grille est pleine (ligne 23)
    • sinon on passe au joueur suivant (ligne 26)
  • En sortant de la boucle on affiche les résultats (ligne 28)

La gestion de la fin de partie passe par :

  • une variable booléenne fin qui vaut vrai False au début et qui sera mise à True dès qu'un gagnant est détecté ou qu'il n'y a plus de cases vides ;
  • on stocke le nombre de cases vides dans une variable nb_cases_vides qui, initialisée à 9 va décroitre à chaque coup ;

Voici la traduction en Python :

Fonction principale
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def partie():
    print('TIC-TAC-TOE')
    print('-----------')

    # -- initialisations
    #
    grille = nouvelle_grille(NUMEROS)
    fin = False
    joueur = CROIX
    nb_cases_vides = 9 
    gagnant = PERSONNE 

    # -- boucle de jeu
    #
    while not fin:
        afficher_grille(grille)
        num = coup_joueur(grille, joueur)
        grille[num] = joueur
        nb_cases_vides -= 1
        if un_gagnant(grille):
            fin = True
            gagnant = joueur
        elif nb_cases_vides == 0:
            fin = True
        else:
            joueur = 1 - joueur

    resultat(grille, gagnant)

Remarque : notez comme on ne manipule pas vraiment CROIX et pas du tout ROND ; pourtant définir ces deux constantes augmentent la lisibilité du code par symétrie.

Conclusion⚓︎

L'idée d'un petit jeu simple, ne faisant intervenir aucune interface graphique est une excellente idée pour initier les débutant.e.s. Mais il faut alors profiter de la simplicité pour soigner la méthode. Comme le cerveau humain a tendance à tenter de reproduire ce qui a fonctionné, enseigner une méthodologie qui manque de généricité et de modularité n'est pas une très bonne idée.

La version POO augmente encore la légèreté en supprimant toutes les variables qui alourdissent la fonction principale de jeu :

Tic-Tac-Toe version POO
CROIX = 0
ROND = 1
PERSONNE = 2

NUMEROS = '123456789'
LIGNES = '123', '456', '789'
COLONNES = '147', '258', '369'
DIAGONALES = '159', '753'                       

JETONS = 'XO.'

class TicTacToe:

    def __init__(self):
        self.joueur = CROIX    # joueur courant, X commence ^^
        self.grille = {num:PERSONNE for num in NUMEROS}
        self.gagnant = PERSONNE

    def fini(self):
        """La partie est finie si la grille est pleine ou s'il y a un gagnant"""
        return self.grille_pleine() or self.gagnant != PERSONNE

    def grille_pleine(self):
        """La grille est pleine si toutes les cases sont à quelqu'un"""
        return all(self.grille[num] != PERSONNE for num in self.grille)

    def choix_du_joueur(self):
        self.affiche_aide()
        print(JETONS[self.joueur], 'à vous de jouer...')
        num = input('Quelle case ? ')
        while not self.valide(num):
            print("Ce choix n'est pas valide...")
            num = input('Quelle case ? ')
        print('-----------')
        return num

    def valide(self, numero):
        """coup valide s'il fait parti des numéros et que la case correspondante est à personne"""
        return numero in NUMEROS and self.grille[numero] == PERSONNE

    def affiche_aide(self):
        """affiche les numéros dans l'ordre des lignes 
        et _ si la case correspondante n'est pas libre
        """
        print('Numéros des cases jouables :')
        for ligne in LIGNES:
            for num in ligne:
                if self.grille[num] == VIDE:
                    print(num, end=' ')
                else:
                    print('_', end=' ')
            print()
        print()

    def marquer_case(self, num):
        """La case de numéro num est marquée du numero du joueur courant (0 ou 1)"""
        self.grille[num] = self.joueur

    def suivant(self):
        self.joueur = 1 - self.joueur

    def affiche_grille(self):
        for ligne in LIGNES:
            for num in ligne:
                print(JETONS[self.grille[num]], end=' ')
            print()
        print()

    def trois_pareil_non_vides(self, bloc):
        ref = self.grille[bloc[0]]
        return ref != VIDE and all(self.grille[num] == ref for num in bloc)

    def trois_alignes(self, direction):
        """direction vaudra LIGNES, COLONNES ou DIAGONALES
        la fonction renvoie True si sur un des groupes de 3 représente la même valeur 0 ou 1
        """        
        return any(self.trois_pareil_non_vides(bloc_de_trois) for bloc_de_trois in direction)

    def un_gagnant(self):
        """Renvoie True si on a 3 alignés en LIGNES, en COLONNES ou en DIAGONALES"""
        return self.trois_alignes(LIGNES) or self.trois_alignes(COLONNES) or self.trois_alignes(DIAGONALES)

    def resultat(self):
        self.affiche_grille()
        if self.gagnant != PERSONNE:
            print(JETONS[self.gagnant], 'gagne')
        else:
            print('Partie nulle')

    def partie(self):
        print('TIC-TAC-TOE')
        print('-----------')
        while not self.fini():
            self.affiche_grille()
            num = self.choix_du_joueur()
            self.marquer_case(num)
            if self.un_gagnant():
                self.gagnant = self.joueur
            else:
                self.suivant()
        self.resultat()

  1. Tic-Tac-Toe est le nom anglais pour morpion 

  2. On s'intéresse ici à l'épisode 7 Simple Tic-Tac-Toc Game par abmakko