logo le blog invivoo blanc

Réaliser un Mastermind avec TKinter (TK Python) – Part 3

28 janvier 2021 | Design & Code, Python | 0 comments

5. Codons le Mastermind !

Nous avons fait le tour des notions les plus essentielles pour commencer à programmer efficacement avec Tkinter grâce à nos articles précédents “Réaliser un Mastermind avec TKinter” Part 1 et Part 2. Il y a encore quelques notions ou widgets dont nous n’avons pas encore parlé et qui vont être utilisés, mais nous les découvrirons au fur et à mesure.

5.a Présentation du jeu

Pour introduire le sujet, rappelons que le Mastermind est un jeu qui consiste à trouver un code secret, ici composé de couleurs, en essayant successivement plusieurs combinaisons, chaque essai entrainant une évaluation qui montre le nombre de couleurs correctement placées (indiquées par des points noirs) et le nombre de couleurs présentes dans le code secret mais mal placées (indiquées par des points blancs).

Voici ce à quoi ressemble le résultat du code que nous allons étudier :

On pourra configurer le nombre d’essais maximum, le nombre de couleurs composant le code secret (avec potentiellement plusieurs fois la même couleur) et la taille de ce dernier. Voici un exemple avec une autre configuration et après quelques coups joués :

Fin de partie (perdue) :

Fin de partie (gagnée) :

Le gameplay est très simple : il faut commencer par sélectionner une couleur dans la barre de gauche en cliquant dessus. Une fois que c’est fait, la couleur est enregistrée et on peut colorer les cases de la zone de jeu principale, en cliquant dessus là aussi. On ne peut modifier que la dernière ligne incomplète, ni les suivantes ni les précédentes. L’évaluation d’une ligne se déclenche automatiquement dès que toutes ses cases sont remplies par une couleur. La ligne n’est plus modifiable par la suite.

5.b Étude du code principal

Le code se décompose en trois parties et autant de fichiers. Nous allons commencer par étudier le fichier principal, mastermind.py, assez court, qui sera l’occasion d’aborder brièvement deux notions que nous n’avons pas encore rencontrées : la création de menus et la définition de réactions à des signaux.

Voici donc le contenu du fichier :

from tkinter import Tk
from tkinter import BOTH, N, S, X
from tkinter import Label, Menu
from tkinter.ttk import Button
from tkinter.messagebox import showinfo

from game_area import GameArea
from preferences import SettingsWindow


def about():
    """
    Display an informative window.
    """
    showinfo('À propos',
             message="Bienvenue dans cette demo de Mastermind avec Tkinter.\n\n"
                     "Ce jeu consiste à trouver un code secret composé de plusieurs couleurs, sachant que "
                     "chaque couleur peut apparaître plusieurs fois.\n\n"
                     "Vous pouvez configurer le nombre de couleurs différentes, la taille du code secret "
                     "et le nombre de tentatives que vous pouvez effectuer dans le menu Préférences.")


# Instantiate the global window:
root = Tk()
root.title('Mastermind')
root.resizable(True, True)


# Create canvas in which to draw the pickable pegs, the playing field and the guess results:
Label(root,
      text='[F1] À propos - [F2] Préférences - [F5] Nouvelle partie - [ESC] Quitter',
      foreground="white",
      background="blue").pack(anchor=N, fill=X)


# Create the game area:
game_area = GameArea(text="Aire de jeu")
game_area.pack(anchor=N, expand=True, fill=BOTH)


# Create and populate the main menu:
root_menu = Menu(root)
root['menu'] = root_menu
main_cascade = Menu(root_menu)
root_menu.add_cascade(label='Mastermind', menu=main_cascade)
main_cascade.add_command(label='Préférences', command=lambda: SettingsWindow(game_area))
main_cascade.add_separator()
main_cascade.add_command(label='À propos', command=about)


# Menu shortcuts:
root.bind("<F1>", lambda _event: about())
root.bind("<F2>", lambda _event: SettingsWindow(game_area))


# Add a last button for quitting the game:
Button(text='Quitter [ESC]', command=root.destroy).pack(anchor=S, fill=X)
root.bind('<Escape>', lambda _event: root.destroy())


# Launch the main loop that catches all user interactions:
root.mainloop()

Dans l’ordre, on trouve :

  • Une fenêtre principale redimensionnable appelée root et intitulée Mastermind
  • Un Label bleu à texte blanc récapitulant les différents raccourcis créés (on y reviendra), étiré sur toute la largeur via pack pour donner l’aspect d’un bandeau d’en-tête. Il est ancré au bord Nord avec le paramètre anchor, une alternative à side dont nous n’avions pas parlé.
  • L’aire de jeu principale (en marron) qu’on verra dans un fichier séparé, mais dont on peut déjà dire qu’il s’agit d’un gros LabelFrame étirable dans les deux directions
  • La création d’un menu composé de deux entrées (« Préférences » et « À propos », on y revient plus bas)
  • La définition de raccourcis clavier pour certaines actions
  • L’ajout d’un bouton pour quitter le jeu en fermant la fenêtre
  • La boucle infinie mainloop(), comme toujours, qui permet à l’application de rester ouverte et de recevoir toutes les interactions de l’utilisateur

Le bouton « Quitter » reçoit en argument la command root.destroy. Cela signifie que dès que nous appuierons dessus, la fonction sera appelée sans paramètre (root.destroy()). Cette méthode destroy appliquée à tout widget permet de le supprimer définitivement. Appliquée à la fenêtre principale sans laquelle rien n’existe, elle a donc pour effet de fermer l’application.

On peut également utiliser sur tous les widgets la méthode bind pour associer une action utilisateur à une fonction. Le premier argument doit être le code identifiant l’action, le second la fonction à appeler. Cette dernière sera automatiquement appelée avec comme argument unique un objet de type Event qui contiendra plusieurs informations sur l’évènement qui s’est produit. Par exemple, si my_event est cet objet de type Event, il contiendra entre autres :

  • my_event.x et my_event.y qui sont les coordonnées du pointeur de la souris
  • my_event.widget qui est une référence vers l’objet sur lequel bind a été appliqué (on peut donc l’utiliser pour lui appliquer de nouvelles méthodes)
  • et encore d’autres informations…

Dans le cas présent, on associe à la clé ‘<Escape>’, qui représente la touche « esc » ou « Echap » du clavier, une fonction lambda qui ne fait pas usage des informations de l’évènement qu’elle reçoit mais appelle plutôt root.destroy(). On a ainsi défini un raccourci clavier qui permet d’obtenir via cette touche le même résultat que quand on appuie sur le bouton.

Nous avons réutilisé ce principe pour définir deux autres raccourcis clavier à partir des touches F1 et F2. Elles permettent d’accéder à des contenus qui sont normalement accessibles via le menu (que nous allons présenter ensuite). La touche F1 appelle la fonction about() qui fait apparaître une boite de dialogue contenant du texte :

La touche F2 quant à elle, permet d’accéder à une fenêtre contenant toutes les options de configuration du jeu, que nous présenterons par la suite.

Enfin, nous avons créé un menu, ce dont nous n’avions pas encore parlé dans ce tutoriel. Il existe plusieurs types de menus mais nous ne présenterons ici que celui que nous utilisons, à savoir une instance de la classe Menu. Cette instance root_menu est rattachée, comme tous les widgets, à un conteneur ou une fenêtre (ici la principale) qui est le premier argument passé lors de son instanciation. Cette première instance sert à créer une « barre » de menu.

Nous créons ensuite un sous-menu, appelé main_cascade, intitulé Mastermind et créé à partir de la méthode add_cascade appliquée au menu principal. « cascade » est le nom du type de l’entrée créée dans le menu, ici un sous-menu (on peut rajouter plusieurs types d’entrée dans un menu). Dès ce moment, nous pouvons voir ceci :

Le rendu étant spécifique à un Macbook Pro bien sûr. C’est donc la première entrée du menu, et nous pourrions en rajouter d’autres horizontalement mais nous n’en aurons pas besoin. Le type « cascade » de ce sous-menu signifie ce que nous appelons aussi un « menu déroulant ». Nous allons le voir tout de suite en rajoutant deux entrées de type « commande » via la méthode add_command appliquée à main_cascade. Ces deux entrées reçoivent un titre et une action. La première, « Préférences », fera apparaître la fenêtre de configuration du jeu (comme la touche F2), tandis que la seconde appellera la fonction about() (comme F1). Résultat :

Parlons maintenant de cette fenêtre de configuration accessible via ce menu ou via F2.

5.c Le code de la fenêtre de configuration

Voici d’abord à quoi elle ressemble :

Cet agencement est obtenu cette fois via le gestionnaire de positionnement grid. Voici le code qui est contenu dans le fichier preferences.py :

from tkinter import Button, IntVar, Label, Radiobutton, Spinbox, Toplevel
from tkinter.ttk import Combobox


ALL_COLORS = ['red', 'blue', 'yellow', 'black', 'green', 'purple', 'orange', 'cyan']


SETTINGS = {
    'n_colors': 4,
    'n_tries': 8,
    'code_size': 4
}


class SettingsWindow(Toplevel):

    def __init__(self, game_area):
        super().__init__()
        self.bg_color = 'wheat'
        self.config(bg=self.bg_color)
        self.title("Préférences")
        self.resizable(False, False)
        self.game_area = game_area

        paddings = {'padx': (12, 12), 'pady': (12, 12)}

        Label(self, text="Nombre de couleurs en jeu :", bg=self.bg_color) \
            .grid(row=0, column=0, columnspan=2, sticky='nws', **paddings)
        self.n_colors_box = Spinbox(self, from_=4, to=8)
        self.n_colors_box.grid(row=0, column=2, columnspan=2, sticky='nes', **paddings)

        Label(self, text="Nombre maximum d'essais :", bg=self.bg_color) \
            .grid(row=1, column=0, sticky='nws', **paddings)
        self.n_tries_box = Combobox(self, values=[8, 10, 12, 14])
        self.n_tries_box.grid(row=1, column=2, columnspan=2, sticky='nes', **paddings)
        self.n_tries_box.current(0)

        Label(self, text="Taille du code à trouver :", bg=self.bg_color) \
            .grid(row=2, column=0, columnspan=2, sticky='nws', **paddings)
        self.code_size_var = IntVar(value=SETTINGS['code_size'])
        Radiobutton(self, text="Facile (4)", variable=self.code_size_var, value=4, bg=self.bg_color) \
            .grid(row=2, column=1, **paddings)
        Radiobutton(self, text="Moyen (6)", variable=self.code_size_var, value=6, bg=self.bg_color) \
            .grid(row=2, column=2, **paddings)
        Radiobutton(self, text="Difficile (8)", variable=self.code_size_var, value=8, bg=self.bg_color) \
            .grid(row=2, column=3, **paddings)

        Button(self, text="Annuler", command=self.destroy, bg=self.bg_color) \
            .grid(row=3, column=0, columnspan=2, sticky='nesw', **paddings)
        Button(self, text="Appliquer", command=self.apply, bg=self.bg_color) \
            .grid(row=3, column=2, columnspan=2, sticky='nesw', **paddings)

        self.bind("<Escape>", lambda _event: self.destroy())

    def apply(self):
        global SETTINGS
        SETTINGS['n_colors'] = int(self.n_colors_box.get())
        SETTINGS['n_tries'] = int(self.n_tries_box.get())
        SETTINGS['code_size'] = int(self.code_size_var.get())
        self.game_area.new_game()
        self.destroy()

Ce code définit des variables globales qui seront importables lorsque nous coderons le cœur du jeu : ALL_COLORS et SETTINGS. Nous pourrons ainsi à tout moment manipuler les préférences et les couleurs en tenant compte des modifications qui auront été sélectionnées. Mais le plus intéressant est la fenêtre elle-même.

Cette fenêtre est une sous-classe de TopLevel dont nous avons déjà parlé. Autrement dit, nous définissions notre propre type de fenêtre autonome. Sa particularité sera de contenir déjà, à la création, tous les widgets dont nous avons besoin. Pour ce faire, code qui aurait pu être écrit en-dehors de la classe est écrit dans la méthode __init__ de celle-ci, et le premier argument de chaque widget n’est pas une instance de TopLevel mais self. C’est une façon de programmer en objet tout à fait classique avec Tkinter.

Le reste est très classique par rapport à tout ce que nous avons déjà vu :

  • La fenêtre (self) est déclarée non redimensionable (resizable), sa couleur de fond est ‘wheat’ (couleur « blé ») et son titre « Préférences »
  • À la création, on reçoit une référence à l’objet contenant l’aire de jeu (game_area) et on la sauvegarde (on verra plus bas pourquoi)
  • Le nombre de colonnes total est contraint par la ligne 2, avec le nombre de Radiobuttons (3) et le Label, ce qui fait que la grille a 4 colonnes
  • Les deux premières lignes sont très classiques : un Label sur les 2 premières colonnes et un widget étendu sur les 2 suivantes, une Spinbox et une Combobox respectivement
  • La Spinbox permet de sélectionner les 4 à 8 premières couleurs de la liste ALL_COLORS avec lesquelles nous pourrons jouer, tandis que la Combobox permet de choisir 8, 10, 12 ou 14 tentatives maximum
  • La troisième ligne suit le même principe mais le Label n’occupe qu’une colonne puisque les 3 suivantes sont réservées aux Radiobuttons, qui permettent de choisir le nombre de couleurs qui composeront le code secret à trouver (4, 6 ou 8) via une variable partagée de type IntVar, initialisée à la valeur courante de SETTINGS[‘code_size’] (celle utilisée pour la dernière partie jouée, et qui vaut 4 au démarrage de l’application)
  • Au niveau esthétique, le paramètre sticky nous permet d’aligner tous les Labels à gauche de leurs cellules avec le ‘w’ de ‘nws’ et les autres widgets à droite via le ‘e’ de ‘nes’. Les nouveaux paramètres de padding padx et pady n’apportent pas grand-chose visuellement (définitions de marges) mais améliorent un tout petit peu le rendu. Les Labels ont pour couleur de fond la même que la fenêtre elle-même, de telle façon qu’on ne distingue pas leurs bords.
  • La quatrième et dernière ligne contient deux boutons, « Annuler » et « Appliquer ». Le premier appelle self.destroy() et détruit donc la fenêtre Préférences, tandis que le second enregistre les choix effectués par l’utilisateur et modifie en conséquence les entrées du dictionnaire global SETTINGS (avant de détruire la fenêtre à son tour). Cette dernière action est codée dans une méthode spécifique de la fenêtre, apply. Néanmoins cette action ne doit pas influencer une éventuelle partie en cours, raison pour laquelle on considère que quiconque modifie les règles souhaite démarrer une nouvelle partie. Cette action est effectuée via une méthode spécifique au widget contrôlant l’aire de jeu, game_area. C’est pour cette unique raison que nous avions besoin de recevoir cette variable à la création de la fenêtre.
  • Enfin, mais c’est un détail, nous créons comme pour la fenêtre principale un raccourci clavier pour quitter la fenêtre (identique au bouton « Annuler ») quand on appuie sur la touche « esc ».

Passons à présent au plus complexe, l’aire de jeu.

5.d Le code de l’aire de jeu

Ce code est plus long et complexe, mais nous connaissons l’essentiel des concepts qui y sont manipulés hormis un nouveau widget central : le Canvas. C’est un conteneur qui nous permet principalement de dessiner des formes, un peu en mode « bac à sable » mais avec beaucoup de fonctionnalités très pratiques.

Nous allons procéder par morceaux plutôt que donner tout le code en un seul bloc. Voici le début du fichier game_area.py :

from random import randrange

from tkinter import BOTH, S, X
from tkinter import Canvas, Label, LabelFrame, PhotoImage
from tkinter.ttk import Button

from preferences import ALL_COLORS, SETTINGS


class GameArea(LabelFrame):

    EXTERNAL_OFFSET = 30
    OFFSET_X = 20
    OFFSET_Y = 20
    DIAMETER = 20
    SMALL_DIAMETER = 10

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.secret_code = None
        self.main_cv = None
        self.new_game_button = None
        self.active_row = 0
        self.selected_color = None
        self.victory_image = None
        self.failure_image = None
        self.new_game()

    def new_game(self):
        if self.main_cv is not None:
            self.main_cv.destroy()
        if self.new_game_button is not None:
            self.new_game_button.destroy()
        self.active_row = 0
        self.generate_fields()
        self.secret_code = self.make_secret()
        self.set_gameplay()

Plusieurs petites choses à dire sur ce début de code :

  • L’aire de jeu est un conteneur de type LabelFrame inséré dans la fenêtre principale. Comme pour la fenêtre Préférences, cet objet est défini comme un nouveau type de conteneur à part entière héritant du conteneur LabelFrame. Tout le code qu’il contient est appeléen dernière analyse depuis sa méthode __init__.
  • On définit comme constantes de classe des variables qui définissent les espacements entre les différents composants/dessins de notre aire de jeu.
  • Dans la méthode __init__, on ne fait qu’initialiser des attributs qui seront utilisés par la suite et déléguer les actions à mener initialement à une autre méthode, self.new_game().
  • Cette méthode new_game, qui est celle appelée depuis la fenêtre Préférences quand on applique des changements de règles (pour rappel), commence par détruire les widgets de la partie précédente s’il y en a eu une (c’est-à-dire si les variables qui les référencent n’ont pas leur valeur initiale, None). On remet à zéro le compteur qui indique quelle est la ligne courante que nous avons le droit de remplir. On génère ensuite les champs qui composent visuellement l’aire de jeu (generate_fields), on génère un code secret aléatoirement (make_secret) et on définit les actions à mener en réaction aux clics de l’utilisateur (set_gameplay).

Nous allons présenter ces différentes méthodes appelées par new_game dans leur ordre d’appel, qui est aussi l’ordre du fichier. generate_fields est la plus difficile à concevoir.

    def generate_fields(self):
        n_colors = SETTINGS['n_colors']
        code_size = SETTINGS['code_size']
        n_tries = SETTINGS['n_tries']
        colors = ALL_COLORS[:n_colors]

        # Create the canvas in which the game takes place:
        self.main_cv = Canvas(self, bg="sienna", cursor="hand")
        self.main_cv.pack(expand=True, fill=BOTH)

        # Draw the field of choices, a white rectangle with the pegs of all colors that can be picked:
        band_width = self.OFFSET_X+self.DIAMETER
        self.main_cv.create_rectangle(self.EXTERNAL_OFFSET, self.EXTERNAL_OFFSET,
                                      self.EXTERNAL_OFFSET+self.OFFSET_X+band_width,
                                      self.EXTERNAL_OFFSET+self.OFFSET_Y+n_colors*(self.DIAMETER+self.OFFSET_Y),
                                      fill="white")
        offsets = (self.EXTERNAL_OFFSET+self.OFFSET_X, self.EXTERNAL_OFFSET+self.OFFSET_Y,
                   self.EXTERNAL_OFFSET+self.OFFSET_X+self.DIAMETER, self.EXTERNAL_OFFSET+self.OFFSET_Y+self.DIAMETER)
        for color in colors:
            self.main_cv.create_oval(*offsets, fill=color, tags=color+'_choice')
            offsets = (offsets[0], offsets[1]+self.OFFSET_Y+self.DIAMETER,
                       offsets[2], offsets[3]+self.OFFSET_Y+self.DIAMETER)

        # Draw the field of guesses, a white rectangle with initially empty (white) slots:
        left_offset = 2*self.EXTERNAL_OFFSET + band_width + self.OFFSET_X
        self.main_cv.create_rectangle(left_offset, self.EXTERNAL_OFFSET,
                                      left_offset+code_size*band_width+self.OFFSET_X,
                                      self.EXTERNAL_OFFSET+self.OFFSET_Y+n_tries*(self.DIAMETER+self.OFFSET_Y),
                                      fill="white")
        for j in range(code_size):
            offsets = (left_offset + self.OFFSET_X + j*(self.DIAMETER+self.OFFSET_X),
                       self.EXTERNAL_OFFSET + self.OFFSET_Y,
                       left_offset + (j+1)*(self.DIAMETER+self.OFFSET_X),
                       self.EXTERNAL_OFFSET + self.OFFSET_Y + self.DIAMETER)
            for i in range(n_tries):
                self.main_cv.create_oval(*offsets, fill='white', tags='_'.join([str(i), str(j), 'guess']))
                offsets = (offsets[0], offsets[1] + self.OFFSET_Y + self.DIAMETER, offsets[2],
                           offsets[3] + self.OFFSET_Y + self.DIAMETER)

        # Restart:
        self.new_game_button = Button(self, text='Nouvelle partie [F5]', command=self.new_game)
        self.new_game_button.pack(anchor=S, fill=X)
        self.master.bind("<F5>", lambda _x: self.new_game())

Afin de simplifier les explications, nous ne nous attarderons pas sur les calculs qui permettent d’obtenir les bons espacements réguliers horizontalement et verticalement. C’est la partie la plus fastidieuse de la conception et le but de ce tutoriel est avant tout de comprendre le fonctionnement de Tkinter.

La méthode commence par instancier un objet de type Canvas sauvegardé dans l’attribut self.main_cv. Sa couleur de fond est une déclinaison du marron (« sienna »), le curseur de la souris prend la forme d’une main (cursor=’hand’) quand la souris passe dessus et il s’étend dans les deux directions pour occuper toute la place disponible.

Ensuite on dessine un premier rectangle via la méthode create_rectangle du Canvas. Ce qu’il faut bien comprendre, c’est le sens des 4 premiers arguments pour positionner le rectangle. Toutes les autres formes dessinées prendront les mêmes 4 premiers arguments et le sens sera le même. Ils forment ce qu’on appelle la « boundary box » (bbox dans la documentation de Tkinter). Les 2 premiers sont les coordonnées (x, y) du coin supérieur gauche de la bbox, tandis que les 2 suivants sont celles de son coin inférieur droit, de sorte à former une diagonale si on les relie. Même quand on dessine des formes circulaires ou quelconques, on raisonne à partir de la diagonale du plus petit rectangle qui puisse contenir le dessin. Le paramètre fill permet de préciser la couleur de fond du dessin effectué, ici blanc (fill=‘white’). Les calculs de coordonnées se basent nécessairement sur les entrées du dictionnaire SETTINGS définies dans preferences.py pour s’adapter automatiquement à toutes les configurations.

Une fois ce premier rectangle correctement positionné on crée, avec des coordonnées régulièrement espacées et incluses dans le rectangle, n_colors cercles de diamètre self.DIAMETER (largeur et hauteur de leurs propres bbox), via la méthode self.main_cv.create_oval. On remplit les nouveaux cercles avec chacune des n_colors premières couleurs de ALL_COLORS.

IMPORTANT : on assigne à chaque cercle un tag (fonctionnalité permise par le canvas) qui nous permettra plus tard de l’identifier. Les tags des cercles de cet espace sont de la forme : tag=color+’_choice’.

On répète l’opération pour le second rectangle, dont les dimensions sont un peu plus complexes à gérer, et que l’on remplit de code_size x n_tries cercles initialement blancs (le blanc est interprété comme la couleur du vide), tous de la même taille (self.DIAMETER) que ceux de la palette de couleurs. Il nous sera nécessaire pour la suite d’avoir là encore des tags pour identifier chaque cercle. Ils sont ici de la forme : « i_j_guess » où i et j désignent les indices de ligne et de colonne de chaque cercle.

Tout en bas du canvas, on rajoute un bouton lié à l’aire de jeu (self) qui permet de recommencer une nouvelle partie en appelant la méthode new_game de self. On crée aussi un binding avec la touche F5 pour réaliser la même opération, qu’on lie non pas à l’aire de jeu cette fois, mais à la fenêtre parente (autrement dit la fenêtre principale ici). Il est d’ailleurs intéressant de noter qu’on peut toujours atteindre le conteneur parent d’un widget via son attribut master. Cette liaison à la fenêtre principale permet de commencer une nouvelle partie même lorsque le focus n’est pas sur l’aire de jeu.

Maintenant que les zones de jeu sont dessinées, passons à la génération du code secret. Cette partie est extrêmement simple en Python :

@staticmethod
    def make_secret():
        return [ALL_COLORS[randrange(0, SETTINGS['n_colors'])] for _ in range(SETTINGS['code_size'])]

Pour chaque élément du code secret, on génère via la fonction randrange (importée du module random de Python) un nombre aléatoire compris entre 0 et SETTINGS[‘n_colors’] qui sert d’indice pour sélectionner une des couleurs de la liste ALL_COLORS.

Vient enfin la dernière méthode, set_gameplay, mais qui en appellera de nouvelles. Le tout forme la partie algorithmique du jeu.

    def set_gameplay(self):

        def interpret_click(event):
            selected_item = self.main_cv.find_closest(event.x, event.y)
            try:
                selected_tag, _ = self.main_cv.gettags(selected_item)
            except ValueError:
                return

            # The tags of the pickable colors are of the form "<color>_choice":
            if 'choice' in selected_tag:
                self.selected_color = selected_tag.split('_')[0]

            # The tags of the settable slots are of the form "<row_index>_<column_index>_guess":
            elif 'guess' in selected_tag:
                selected_row = int(selected_tag.split('_')[0])
                if selected_row == self.active_row and self.selected_color is not None:
                    self.main_cv.itemconfig(selected_item, fill=self.selected_color)
                    # Detect if the row is fully filled:
                    all_row_items = [self.main_cv.find_withtag('_'.join([str(selected_row), str(j), 'guess']))
                                     for j in range(SETTINGS['code_size'])]
                    all_row_colors = [self.main_cv.itemcget(item, 'fill') for item in all_row_items]
                    if 'white' not in all_row_colors:
                        all_scores = self.compute_scores(all_row_colors)
                        self.draw_scores(*all_scores)
                        self.active_row += 1
                        if all_scores[0] == SETTINGS['code_size']:
                            self.make_victory()
                        elif self.active_row == SETTINGS['n_tries']:
                            self.make_failure()

        self.main_cv.bind('<Button-1>', interpret_click)

Le gameplay pourrait se résumer à cette dernière ligne : appeler la fonction interpret_click pour chaque clic de l’utilisateur. Sauf que cette fonction est complexe et peut appeler elle-même d’autres méthodes. Au passage, la clé identifiant un clic de souris est ‘<Button-1>’.

interpret_click est une fonction liée à un évènement, donc elle reçoit automatiquement un objet event de type Event comme seul argument. Cet objet possèdent en attributs les coordonnées (x, y) où le clic a eu lieu. À partir de là l’objet Canvas permet, grâce à sa méthode find_closest, de trouver le dessin le plus proche (celui sur lequel on a donc cliqué ou voulu cliquer). La méthode renvoie un « item », une sorte de numérotation interne au canvas, à partir duquel on peut retrouver le tag associé via la méthode gettags (qui renvoie une paire dont le premier élément est le tag que nous avons assigné). On connait alors selected_tag, le tag du cercle sur lequel l’utilisateur a cliqué.

Si ce tag contient la chaine de caractères ‘choice’, on sait qu’il correspond à un cercle de choix de couleur (cercles du rectangle à la gauche du canvas). Or d’après notre convention de nommage des tags, on sait que la couleur est dans la première partie du tag (par exemple red_choice correspond au choix de la couleur rouge). On enregistre donc cette couleur dans l’attribut self.selected_color.

En revanche, si le tag contient la chaine de caractères ‘guess’, cela signifie que l’utilisateur a cliqué sur un cercle de la zone de jeu et souhaite donc « remplir » ce cercle avec la couleur de son choix. Cette couleur de son choix doit être préalablement renseignée dans self.selected_color (autrement dit il a dû cliquer auparavant au moins une fois sur un des cercles de choix de couleur). On commence donc par détecter, sachant que le tag est de la forme « i_j_guess », l’indice i de la ligne courante. Si cet indice n’est pas égal à celui de la ligne que l’utilisateur a le droit de remplir (pour rappel, self.active_row), on ne fait rien. Autrement, on modifie la configuration du cercle pour que sa couleur de remplissage soit celle qui est enregistrée (fill=self.selected_color). Pour cela, le canvas nous met à disposition une méthode itemconfig qui prend en premier argument l’ « item » à partir duquel nous avions récupéré le tag de l’objet cliqué.

Après cela, nous souhaitons déterminer si cette dernière action termine le remplissage de toute la ligne self.active_row. Pour cela, on procède en trois temps :

  1. Grâce à la méthode find_withtag du canvas, on récupère tous les « items » des cercles qui ont un tag de la forme « i_j_guess » où i == self.active_row
  2. Grâce à la méthode itemcget du canvas, on récupère pour chacun de ces items la valeur du paramètre fill du cercle correspondant
  3. Si aucune de ces valeurs ne vaut ‘white’ (le blanc représentant le vide pour nous), cela signifie que la ligne a été complètement remplie

Si tel est le cas, une autre phase s’enclenche : il faut calculer les scores, les représenter, et en fonction des résultats, décréter la victoire ou, si la ligne active était la dernière ligne autorisée, décréter la défaite. On n’oubliera pas non plus d’incrémenter l‘indice de la ligne active (autrement dit d’activer la possibilité de remplir la ligne suivante). Ces quatre actions (calcul, représentation, victoire, défaite) font l’objet de méthodes séparées.

Commençons par le calcul des résultats avec la méthode compute_scores :

    def compute_scores(self, row_colors):
        """
        Compute the scores.

        The "badly placed" score should take into account duplicates and reflect their occurences in the secrect code.
        """
        badly_placed_colors = {color: 0 for color in row_colors}
        for color in row_colors:
            n_occurences_in_secret = self.secret_code.count(color)
            if badly_placed_colors[color] < n_occurences_in_secret:
                badly_placed_colors[color] += 1

        exact_matches = {color: 0 for color in row_colors}
        for color, secret_color in zip(row_colors, self.secret_code):
            if color == secret_color:
                exact_matches[color] += 1
                badly_placed_colors[color] -= 1

        n_badly_placed = sum(badly_placed_colors.values())
        n_exact_matches = sum(exact_matches.values())

        return n_exact_matches, n_badly_placed

Il est nécessaire de commencer par évaluer indistinctement le nombre de couleurs bien ou mal placées mais en tout cas présentes dans le code secret. On comptera les résultats par couleur dans le compteur des couleurs mal placées. Pour chacune de celles de la réponse de l’utilisateur, on incrémente le compteur si la couleur est dans le code secret et si elle n’est pas déjà apparue autant de fois que dans le code secret.

Ensuite, pour chacun des scores par couleur, on retranche 1 point au compteur des couleurs mal placées et on augmente d’1 point le compteur des couleurs bien placées. Au final, on obtient bien des scores « exact_match » et « badly_placed » reflétant conjointement le nombre d’occurrences des couleurs dans le code secret.

Il faut ensuite représenter ces scores. C’est le rôle de la méthode draw_scores :

    def draw_scores(self, n_exact, n_badly_placed):
        code_size = SETTINGS['code_size']
        diameters_delta = self.DIAMETER - self.SMALL_DIAMETER
        left_offset = 3*self.EXTERNAL_OFFSET + 2*self.OFFSET_X + (code_size+1)*(self.OFFSET_X+self.DIAMETER)
        row_offset = self.EXTERNAL_OFFSET + self.OFFSET_Y + self.active_row*(self.OFFSET_Y+self.DIAMETER)
        offsets = (left_offset+0.5*diameters_delta, row_offset+0.5*diameters_delta,
                   left_offset+0.5*diameters_delta+self.SMALL_DIAMETER,
                   row_offset+0.5*diameters_delta+self.SMALL_DIAMETER)
        for _ in range(n_exact):
            self.main_cv.create_oval(offsets[0], offsets[1],
                                     offsets[2], offsets[3],
                                     fill='black')
            offsets = (offsets[0]+self.SMALL_DIAMETER+self.OFFSET_X, offsets[1],
                       offsets[2]+self.SMALL_DIAMETER+self.OFFSET_X, offsets[3])
        for _ in range(n_badly_placed):
            self.main_cv.create_oval(offsets[0], offsets[1],
                                     offsets[2], offsets[3],
                                     fill='white')
            offsets = (offsets[0]+self.SMALL_DIAMETER+self.OFFSET_X, offsets[1],
                       offsets[2]+self.SMALL_DIAMETER+self.OFFSET_X, offsets[3])

Comme pour generate_fields, toute la complexité réside dans le calcul des décalages entre les cercles, mais d’un point de vue technique il n’y a rien de nouveau. On crée des cercles à l’aide de la méthode create_oval du canvas, d’un diamètre plus petit contrôlé par la constante self.SMALL_DIAMETER. On commence par créer autant de cercles que de couleurs « bien placées », en les remplissant en noir, puis on crée autant de cercles remplis de blanc que de couleurs « trouvées mais mal placées ».

Il ne reste plus qu’à définir les actions en cas de victoire et de défaite, que nous allons présenter en même temps :

    def make_victory(self):
        self.active_row = SETTINGS['n_tries']
        self.victory_image = PhotoImage(file='winner_cup.gif').subsample(3)
        left_offset = 20 * self.EXTERNAL_OFFSET
        row_offset = 10 * self.EXTERNAL_OFFSET
        self.main_cv.create_image(left_offset, row_offset, image=self.victory_image)

    def make_failure(self):
        self.failure_image = PhotoImage(file='fail.gif').subsample(3)
        left_offset = 20 * self.EXTERNAL_OFFSET
        row_offset = 10 * self.EXTERNAL_OFFSET
        self.main_cv.create_image(left_offset, row_offset, image=self.failure_image)

Dans les deux cas, les actions menées sont similaires :

  • En cas de victoire seulement, placer la ligne active juste après la dernière ligne dessinée (ce qui donne l’impression de figer la zone de jeu, puisqu’on ne peut plus remplir aucun cercle), cela étant déjà le cas lors d’une défaite
  • Charger l’image appropriée grâce à la classe PhotoImage de Tkinter, qui prend en argument un chemin vers un fichier et n’accepte que les images au format GIF ou PPM/PGM
  • Éventuellement, rétrécir l’image via la méthode subsample de la classe PhotoImage ou l’agrandir via sa méthode zoom
  • L’afficher par-dessus la zone de jeu à une position (x, y) arbitraire grâce à la méthode du canvas create_image qui prend comme arguments x, y et l’image à afficher

Et voilà qui termine notre implémentation du Mastermind !

6. Conclusion

Tkinter est une bibliothèque d’interfaces graphiques assez riche pour nous permettre de réaliser des applications bien plus évoluées que ce Mastermind. Elle est facile à prendre en main, on peut y faire de la programmation orientée objet comme on en a l’habitude en Python (son design est très « pythonique ») et elle est incluse par défaut dans l’installation standard de Python. Le rendu visuel n’est cependant pas à la hauteur de ce qui se fait de mieux ces dernières années (PyQt, wxPython, Kivy…), mais le développement est souvent plus rapide. Son adoption dans un projet tiendra donc comme souvent en un arbitrage entre ces deux paramètres : haute qualité esthétique et rapidité de développement.

Cette article vous est proposé par l’expertise Design & Code et Python d’Invivoo.