TP - Interfaces Graphiques

Dans ce TP nous allons développer une interface graphique pour le jeu des personnages en utilisant tkinter.

Tkinter nécessiterait un cours à lui tout seul. Nous utiliserons seulement quelques fonctionnalités élémentaires de la bibliothèque.

1. Créer sa première fenêtre.

import tkinter

class GrilleDeJeu(tkinter.Canvas):

    def __init__(self, root, **kwargs):
        super().__init__(root, **kwargs)

if __name__ == '__main__':
    # Initialisation de Tk
    root = tkinter.Tk()

    # Création d'un _Canvas_
    myCanvas = GrilleDeJeu(root, bg="white", height=300, width=300)

    myCanvas.pack()
    root.mainloop()

Le fonctionnement de base de tkinter est relativement simple :

  • on crée une fenêtre principale (ici root) à laquelle on peut ensuite rattacher des objets graphiques divers (boutons, labels, ascenseurs, ...);
  • dans notre exemple, ce sera un objet de type GrilleDeJeu qui dérive de tkinter.Canvas (cf. Canvas, par la suite, nous créerons d'autres fenêtres avec d'autres composants);
  • on recalcule la géométrie de la fenêtre et la visibilité des composants (méthode pack());
  • on lance la boucle de gestion des évènements (méthode mainloop()).

Pour faire très vite, le principe de fonctionnement principal de tkinter est de recueillir des évènements dans une file d'attente. La boucle de gestion d'événements est une boucle infinie et qui traite les évènements dans l'ordre d'arrivée de façon asynchrone.

2. Mise en place de classes de base

Nous allons définir deux classes de base, nécessaires pour la suite du travail : Localisable (objets ayant des coordonnées et pouvant être localisés dans la grille de jeu), Actionnable (objets avec lesquels il est possible d'interagir) et Affichable (des objets ayant des attributs pouvant être affichés)

class Localisable(object):
    def __init__(self, x=0, y=0, **kwargs):
        super().__init__(**kwargs)
        self.__pos_x = x
        self.__pos_y = y

    @property
    def x(self):
        return self.__pos_x

    @property
    def y(self):
        return self.__pos_y

    @property
    def lieu(self):
        return self.__pos_x, self.__pos_y

    @lieu.setter
    def lieu(self, loc: tuple):
        self.__pos_x, self.__pos_y = loc
class Actionnable(object):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._actions = {}

    @property
    def actions(self):
        return self._actions

if __name__ == '__main__':
    a = Actionnable()

    def f():
        print('Action effectuée')

    a.actions['demo'] = f

    print(a.actions)
    a.actions['demo']()
class Affichable(object):

    def __init__(self, n : str = None, **kwargs):
        super().__init__(**kwargs)
        self.__name = n
        if 'glyph' in kwargs.keys():
            self.__glyph = kwargs['glyph']
            del kwargs['glyph']
        else:
            self.__glyph = None

        self.__attributs = kwargs.copy()

    @property
    def nom(self):
        return self.__name

    @nom.setter
    def nom(self, n: str):
        self.__name = n

    @property
    def glyph(self):
        return self.__glyph

    @glyph.setter
    def glyph(self, g):
        self.__glyph = g

    @property
    def attributs(self):
        return self.__attributs

3. Déplacer des objets

La classe Deplacable

Vous allez créer une classe Deplacable permettant de déplacer des objets dans une fenêtre.

Cette classe a les propriétés suivantes :

  • elle hérite à la fois de Localisable et Actionnable ;
  • elle aura un attribut supplémentaire sous forme de tuple (x,y) représentant sa destination. Cet attribut aura les getter et setter adéquats ;
  • elle aura une méthode move() qui ne prend pas d'argument et qui, lorsqu'elle est exécutée, rapproche l'objet d'une unité de distance vers sa destination (les coordonnées de sa position restent des int) ;
  • cette méthode devra être accessible à travers les actions de la classe Actionnable

Lorsque vous aurez implanté la classe Deplacable, le code ci-dessous devrait fonctionner.

if __name__ == '__main__':

    d = Deplacable()
    d.destination = (6, 12)

    while d.destination != d.lieu:
        d.move()
        print(d.lieu)

    d.destination = (0,0)
    while d.destination != d.lieu:
        d.actions['déplacer']()
        print(d.lieu)

Afficher des objets Deplacable

La classe Affichable introduite précédemment contient trois attributs d'instance : nom, glyph et attributs. L'attribut glyph sert à stocker la représentation graphique de l'objet dans l'interface (son avatar en quelque sorte). Dans ce qui suit, nous présenterons les objets par de petits carrés colorés. Le code pour construire ces petits glyphes se trouve ci-dessous.

Travail préalable

def randomRGBString():
    return "#" + ("%06x" % randint(0, 16777215))

def petitRectangle(cv : tkinter.Canvas, nom : str):
    return cv.create_rectangle(0, 0, 3, 3, fill=randomRGBString(), tags=nom)

Nous allons créer une classe InterfaceItem qui permet de représenter des objets dans l'interface graphique que l'on pourra afficher, déplacer et avec lesquels il sera possible d'interagir.

De fait, elle suivra le canevas suivant :

class InterfaceItem(Deplacable, Affichable):

    def __init__(self, **kwargs):
        pass

Construction d'objets affichés

Écrire le constructeur de la classe InterfaceItem de sorte que

  • dans les arguments nommés il y aura cv qui contiendra un tkinter.Canvas et nom qui contiendra un str ;
  • le Canvas en question sera stocké dans un attribut d'instance propre à InterfaceItem nommé cv ;
  • on appellera le constructeur parent (a priori celui de Deplacable) avec une position aléatoire à l'intérieur du Canvas passé en argument ;
  • on attribuera l'argument nom à l'attribut nom hérité de la classe Affichable ;
  • si l'argument nommé glyph existe, on l'affectera à l'attribut glyph hérité de la classe Affichable, sinon on créera un glyphe avec la fonction petitRectangle;

Une fois les attributs initialisés il est nécessaire de bien positionner le glyph dans la zone d'affichage (fenêtre) gérée par tkinter. On pourra utiliser la méthode moveto de la classe Canvas.

canvas.moveto(self.glyph, self.lieu[0], self.lieu[1])

Validation

Le code ci-dessous devrait vous permettre d'afficher une fenêtre avec deux points positionnés aléatoirement.

if __name__ == '__main__':
    root = tkinter.Tk()
    myCanvas = GrilleDeJeu(root, bg="white", height=300, width=300)

    obj1 = InterfaceItem(name="Obj1", cv=myCanvas)
    obj2 = InterfaceItem(name="Obj2", cv=myCanvas)

    myCanvas.pack()
    root.mainloop()

Interagir avec un InterfaceItem

Tkinter permet l'interaction avec les objets graphiques. Le principe de la gestion des interactions est d'associer des événements (comme les clics de souris ou des actions au clavier) à des objets graphiques via des fonctions handler. En tkinter, un handler est une fonction qui prend comme argument un objet de type tkinter.Event. Vous pouvez obtenir la description de cette classe avec la commande help dans la console Python.

>>> import tkinter
>>> help(tkinter.Event)

Dans ce qui suit nous utiliserons seulement les coordonnées occurrence d'un événement :Event.x et Event.y.

Principe de base

Le principe de base pour associer événements et utiliser des interactions avec des objets de l'interface, le principe est toujours le même :

  1. Définir une fonction handler qui devra s'exécuter au déclenchement d'un événement.
  2. Associer un événement (clic de souris, saisie de clavier ...) à un objet graphique et une fonction handler avec l'une des méthodes bind fournies par tkinter.

bind a pour rôle d'associer de relier un objet ou une classe d'objets à une fonction de gestion d'événement (le handler) Il existe plusieurs fonctions et de façons de faire cette association : à un objet graphique précis, à l'ensemble des objets appartenant à une classe, à tous les objets ayant une étiquette particulière, ... Dans ce TP nous utiliserons principalement le tag_bind qui associe un handler aux objets possédant une étiquette (tag) précise.

Vous remarquerez que la fonction petitRectangle() associe un tag au glyph créé. Dans le TP, ce sera le tag qui permettra d'identifier les objets que l'on manipulera. On veillera donc que les noms des objets créés (et donc leurs tags) soient uniques.

Afficher des objets Affichable

Les objets de la classe Affichable ont un glyph qui permet de les afficher sur la grille de jeu, mais également d'une liste d'attributs. Dans cette partie nous allons afficher les attributs dans une fenêtre lorsqu'on clique sur le glyph.

Le code suivant permet de créer une méthode dans la classe InterfaceItem (qui dérive de la classe Affichable) qui affiche une fenêtre supplémentaire.

class InterfaceItem(Deplacable, Affichable):
    def attributsWindow(self):
        w = tkinter.Toplevel(self.__canvas)
        w.title(f'Attributs pour {self.nom}')
        # à compléter

Utilisez la méthode grid() de tkinter en combinaison avec la classe Label pour afficher l'ensemble des attributs et leur valeur dans la fenêtre. Vous pouvez tester votre code avec l'exemple ci-dessous.

if __name__ == '__main__':
    root = tkinter.Tk()
    myCanvas = GrilleDeJeu(root, bg="white", height=300, width=300)

    obj = InterfaceItem(name="Obj1", cv=myCanvas)
    obj.attributs['Attr'] = 'Un attribut'
    obj.attributsWindow()

    myCanvas.pack()
    root.mainloop()

Le résultat devrait ressembler à ceci :

type:image

Rendre l'objet interactif

Maintenant que nous sommes capables d'afficher les attributs d'un objet Affichable dans une fenêtre, nous allons provoquer cet affichage lorsqu'on clique dessus. Pour cela nous allons associer (avec la méthode tag_bind) le handler afficher à un objet de type InterfaceItem et un clic de souris.

Le code pour le handler est très simple :

def afficher(e=None):
    self.attributsWindow()

Le code pour associer le handler à un objet via un clic du bouton du milieu de la souris est le suivant :

cv.tag_bind(self.nom, "<Button-2>", afficher)

Trouvez les endroits pertinents pour ajouter ce code et testez qu'un clic milieu sur un objet graphique affiche bien la fenêtre des attributs créée précédemment.

Déplacer les objets Deplacable

Tous les outils sont maintenant en place pour déplacer des objets graphiques. Dans ce TP nous allons le réaliser avec action de glisser-déposer. Le principe sera le suivant : l'action de glisser-déposer identifiera l'objet cliqué et enregistrera les coordonnées de l'évènement déposer. Comme il s'agit d'un objet de type Deplacable on peut associer ces coordonnées à son attribut destination. Il suffira ensuite d'enclencher une boucle de move() pour rapprocher l'objet vers sa destination.

En tkinter l'opération glisser-déposer se décompose en trois parties :

  1. l'action (handler) à effectuer au moment de l'appui initial du bouton ;
  2. l'action (handler) à effectuer pendant le mouvement de la souris, pendant le maintien du bouton ;
  3. l'action (handler) à effectuer au moment de relâcher le bouton, en fin de mouvement.

Ceci se traduit donc par le traitement de 3 événements par des fonctions action1(), action2() et action3() ad hoc que vous aurez à définir.

cv.tag_bind(self.nom, "<ButtonPress-1>", action1)
cv.tag_bind(self.nom, "<B1-Motion>", action2)
cv.tag_bind(self.nom, "<ButtonRelease-1>", action3)

L'événement <B1-Motion>

Dans notre exemple, il n'y a rien à faire pendant le mouvement de la souris. Le handler action2 peut donc être substitué par la fonction qui se contente de ne rien faire.

L'événement <ButtonRelease-1>

L'événement <ButtonRelease-1> correspond au lâcher de souris à la fin du glisser-déposer. Le handler associé doit

  1. récupérer les coordonnées de l'événement ;
  2. modifier la destination de l'objet Deplacable pour qu'elle corresponde à ces coordonnées ;
  3. initier une boucle de déplacements progressifs, pixel par pixel, de la position actuelle de l'objet vers sa destination via la méthode move().

Pour réussir à correctement coder le dernier point, il est nécessaire de comprendre comment fonctionne la boucle d'événements principale mainloop() de tkinter. Comme ce TP n'a pas pour but d'être un tutoriel exhaustif de tkinter on fournit ici la solution pour le dernier point.

# On présuppose que dans la portée de la fonction `deplacer` la grille de dessin `canvas` et 
# l'objet `self` de type `InterfaceItem` sont définis
def deplacer(e):
    self.move()
    if self.lieu != self.destination:
        canvas.after(100, deplacer, e)

La fonction deplacer est un handler qui déplace un objet pendant une itération avec move et qui demande ensuite au gestionnaire d'événements de provoquer à nouveau l'événement e au bout de 100ms avec le handler deplacer, si l'objet n'a pas atteint sa destination. Elle utilise pour cela la méthode particulière after qui place un événement dans la file d'attente.

En d'autres termes, on exécute deplacer() et si la destination n'est pas atteinte, on demande à tkinter d'exécuter la même fonction dans 100ms.

Utilisez ces connaissances et la fonction deplacer() pour écrire le handler start() qui sera associé à l'événement <ButtonRelease-1>.

Testez votre code et observez que vous pouvez bien déplacer des objets avec des glisser-déposer.

Essayez ensuite de prendre un objet en mouvement avec un glisser-déposer pour l'amener à un autre endroit. Normalement vous devriez observer un changement de vitesse. Pouvez-vous l'expliquer ?

L'événement <ButtonPress-1>

Expliquez comment le handler reset ci-dessous, associé à l'événement <ButtonPress-1> résout le problème de l'augmentation de la vitesse observé précédemment.

def reset(e):
    self.destination = self.lieu

Testez votre code

Si tout est bien mis en place, vous devriez obtenir quelque chose qui ressemble à l'animation ci-dessous.

4. Connecter l'interface au jeu des personnages

L'objet de cette partie est de connecter le travail fait dans les TPs sur le jeu des personnages et d'en étendre les fonctionnalités pour le rendre jouable et interactif.

L'une des choses importantes à observer est qu'il ne sera (quasiment) pas nécessaire de modifier le code des Objet et les Personnage pour les intégrer dans l'interface graphique et vice-versa grace à l'héritage multiple.

Modifier (quand-même un peu) les classes Personnage et Objet ainsi que leurs classes dérivées.

Modifez les constructeurs des classes Personnage et dérivées de la façon à ce qu'ils puissent recevoir, en plus des arguments d'appel déjà définis, des arguments nommés **kwargs dont ils ne feront rien d'autre que de les passer au constructeur de super(). Veillez bien à faire la même chose dans les classes dérivées aussi (Brute, Charmeur, RandomObjet ...)

class Personnage(object):
    def __init__(self, n: str, o: List[Objet] = None, **kwargs):
        # Code existant à ne pas modifier ...
        super().__init__(**kwargs)

    # Suite de la classe, à ne pas modifier
class Objet:
    def __init__(self, n: str = None, v: int = 10, **kwargs):
        super().__init__(**kwargs)
        # Code existant à ne pas modifier ...

    # Suite de la classe, à ne pas modifier

Définir les versions graphiques des Personage et Objet.

Pour définir une version graphique d'un Personnage il suffit de créer une nouvelle classe qui dérive à la fois de Personnage et de InterfaceItem.

class InterfacePersonnage(Personnage, InterfaceItem):
    def __init__(self, nom: str, cv: tkinter.Canvas):
        # à compléter
        pass

1. Appeler super().__init__ correctement

Réflichissez à comment correctement appeler le constructeur des classes parentes et complétez le code du constructeur InterfaceItem.

2. Relier les caractéristiques des deux classes

Efin de permettre l'affichage des caractéristiques d'un personnage avec un clic du bouton milieu, il est nécessaire que l'attribut Affichable.__attributs soit bien renseigné.

Dans le constructeur d'InterfacePersonnage affectez les attributs force, charisme ... de la classe Personnage au dictionnaire Affichable.__attributs.

Testez votre code avec l'exemple ci-dessous.

if __name__ == '__main__':
    # init tk

    root = tkinter.Tk()

    # create canvas
    myCanvas = GrilleDeJeu(root, bg="white", height=300, width=300)

    ipPers = InterfacePersonnage("Perso", myCanvas)
    print(ipPers.force)
    print(ipPers.charisme)

    myCanvas.pack()
    root.mainloop()

Ce code devrait produire dans la console l'affichage des attributs force et charisme et générer un pop_up lors d'un clic milieu qui reproduit les mêmes valeurs, en plus des autres caractéristiques du Personnage. Comme montré dans l'image ci-dessous.

type:image

3. Définir les classes dérivées

Vous pouvez maintenant définir toutes les autres classes dérivées de Personnage sans autre problème sur le modèle ci-dessous.

class InterfaceBrute(InterfacePersonnage, Brute):
    def __init__(self, nom: str, cv: tkinter.Canvas):
        super().__init__(nom, cv)

4. Bis repetitum ...

Faites maintenant la même chose avec la classe Objet et ses classes dérivées en créant la classe InterfaceObjet.

5. Faire un "vrai" jeu

Le jeu que nous allons réaliser est relativement basique. Le Canvas comportera des Personnage et des Objet

  • Les Objet ne peuvent pas se déplacer, mais les Personnage le peuvent (avec l'action glisser-déposer développée précédemment)
  • Lorsqu'un Personnage passe près d'un Objet il le ramasse et l'Objet disparaît du Canvas
  • Lorsqu'un Personnage croise un autre Personnage ils par le biais de la méthode choisir_action() du TP2.

Travail préalable

Pour développer les fonctionnalités requises, on vous propose une nouvelle version de la classe GrilleDeJeu, enrichie des méthodes getItems, ajoutItem et enleveItem. Au-delà de gérer l'affichage, cette nouvelle version gère la position des items qu'elle gère.

class GrilleDeJeu(tkinter.Canvas):

    def __init__(self, root, **kwargs):
        super().__init__(root, **kwargs)
        self._objets = set()
        self._lieux = {}

    @property
    def lieux(self):
        return self._lieux

    def getItems(self, loc: tuple) -> set:
        if loc in self._lieux.keys():
            return self._lieux[loc]
        else:
            return set()

    def ajoutItem(self, o: Localisable):
        self._objets.add(o)
        if o.lieu not in self._lieux.keys():
            self._lieux[o.lieu] = {o}
        else:
            self._lieux[o.lieu].add(o)

    def enleveItem(self, o: Localisable):
        self._objets.remove(o)
        self._lieux[o.lieu].remove(o)

Modifiez la classe InterfaceItem de sorte à bien utiliser les nouvelles fonctionnalités de GrilleDeJeu:

  • dans le constructeur, faire un appel judicieux à self.canvas.ajoutItem(self) de sorte à bien enregistrer l'item ;
  • dans la méthode move()ajouter un appel à enleveItem(self) avant l'appel à super().move() et un appel à ajouteItem(self) après.

Testez votre code et vérifier que vous obtenez strictement le même comportement qu'avant lors du déplacement des items.

Modification de la classe InterfaceObjet

Modifiez la calsse IterfaceObjet de sorte à ce que

  • le glyphe des objets soit un triangle, plutôt qu'un rectangle ;
  • les objets ne puissent pas être déplacés.

Pour ce faire, vous aurez à modifier uniquement la classe InterfaceObject.

Modification de la classe InterfacePersonnage

Dans la clase InterfacePersonnage surchargez la méthode move() telle que :

  • un InterfacePersonnage ne pourra pas bouger si ses points de vie sont inférieurs à zéro ;
  • à chaque itération de déplacement il vérifie la présence d'autres items à proximité (avec la méthode GrilleDeJeu.itemsProches()) s'il y a des Object parmi ces items il va les ramasser, s'il y a des Personnage il va en choisir un au hasard parmi ceux à proximité, et déclencher un appel à choisir_action

Le ramassage d'objets a pour conséquence que l'Objet ramassé soit enlevé de GrilleDeJeu, qu'il soit mis dans les objets du Personnage qui le ramasse (avec déclenchement éventuel de son effet) et qu'il disparaisse de l'affichage. On peut utiliser la méthode itemconfigure avec l'attribut state='hidden' de tkinter pour ne plus afficher un glyph.

Testez votre code

Le code suivant devra fonctionner et donner une sortie similaire à l'animation ci-dessous.

if __name__ == '__main__':
    root = tkinter.Tk()
    myCanvas = GrilleDeJeu(root, bg="white", height=300, width=300)

    obj1 = InterfaceObjet(name="Obj1", cv=myCanvas)
    obj2 = InterfaceObjet(name="Obj2", cv=myCanvas)
    ipPers1 = InterfacePersonnage("Alice", myCanvas)
    ipPers2 = InterfacePersonnage("Bob", myCanvas)

    myCanvas.pack()
    root.mainloop()

6. Améliorez et étendez les fonctionnalités.

À partir d'ici on peut imaginer quantité de nouvelles fonctionnalités et d'extensions comme moduler la vitesse en fonction de l'attribut force ou obstination d'un Personnage, des Objet qui rendent invisible, ou qui accélèrent leur propriétaire ... ou encore des affichages plus parlants et plus dynamiques, etc.

Il y a également un bug à corriger. Vous remarquerez que les caractéristiques affichées avec un clic-milieu n'évoluent pas avec les caractéristiques du Personnage au gré des interactions, mais restent figées à leurs valeurs de début. Il y a un moyen très élégant pour résoudre le problème en ne modifiant que très légèrement la classe InterfacePersonnage ... on vous laisse découvrir comment.