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
GrilleDeJeuqui dérive detkinter.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
LocalisableetActionnable; - 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 desint) ; - cette méthode devra être accessible à travers les
actionsde la classeActionnable
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
cvqui contiendra untkinter.Canvasetnomqui contiendra unstr; - le
Canvasen question sera stocké dans un attribut d'instance propre àInterfaceItemnommécv; - on appellera le constructeur parent (a priori celui de
Deplacable) avec une position aléatoire à l'intérieur duCanvaspassé en argument ; - on attribuera l'argument
nomà l'attributnomhérité de la classeAffichable; - si l'argument nommé
glyphexiste, on l'affectera à l'attributglyphhérité de la classeAffichable, sinon on créera un glyphe avec la fonctionpetitRectangle;
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 :
- Définir une fonction handler qui devra s'exécuter au déclenchement d'un événement.
- Associer un événement (clic de souris, saisie de clavier ...) à un objet graphique et une fonction handler avec
l'une des méthodes
bindfournies partkinter.
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 :

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 :
- l'action (handler) à effectuer au moment de l'appui initial du bouton ;
- l'action (handler) à effectuer pendant le mouvement de la souris, pendant le maintien du bouton ;
- 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
- récupérer les coordonnées de l'événement ;
- modifier la
destinationde l'objetDeplacablepour qu'elle corresponde à ces coordonnées ; - 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.

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
Objetne peuvent pas se déplacer, mais lesPersonnagele peuvent (avec l'action glisser-déposer développée précédemment) - Lorsqu'un
Personnagepasse près d'unObjetil le ramasse et l'Objetdisparaît duCanvas - Lorsqu'un
Personnagecroise un autrePersonnageils par le biais de la méthodechoisir_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
InterfacePersonnagene 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 desObjectparmi ces items il va les ramasser, s'il y a desPersonnageil 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.