26 août 2016

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

Design & Code

Robin

Huart

Image ordinateur.

26 août 2016

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

Design & Code

Robin

Huart

Image ordinateur.

26 août 2016

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

Design & Code

Robin

Huart

Image ordinateur.

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>

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']

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__

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.


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