Programmation évènementielle

Nous utiliserons la bibliothèque pyglet pour ce projet. Commencez par l'installer :

python -m pip install pyglet

Une fois la bibliothèque installée, on peut créer notre projet :

  1. créez un dossier nommé essais-pyglet où vous placerez vos fichiers
  2. créez un projet vscode dans ce dossier

La programmation événementielle est un paradigme de programmation très utiliser dans les interfaces graphiques. Cette méthode consiste à réagir à des événements issus du programme comme de cliquer sur un bouton, appuyer sur une touche, etc.

Le principe est le suivant :

  1. on inscrit une fonction $f$ à un type d'événement $e$
  2. lorsque l'événement $e$ arrive, la fonction $f(e)$ est exécutée

Dans ce projet, les événements que nous aurons à manipuler seront les appuies sur les touches du clavier, le dessin de notre interface, la taille des fenêtres, etc, etc.

Cette méthode de programmation est basée sur le patron de conception observateur.

Nous allons utiliser pyglet qui est une bibliothèque permettant de créer des interfaces pour illustrer cela. Nous ne rentrerons pas dans tous les détails de son implémentation (utilisation d'opengl, la gestion du son, etc) mais nous utiliserons assez de ses fonctionnalités pour que vous puissiez aller plus loin de votre côté.

La documentation de pyglet est très bien faite et fourmille d'exemples.

Nous allons placer les différents essais de pyglet dans un sous-dossier de notre projet. Créer un dossier essais-pyglet.

Hello world

Il y a plusieurs moyen me mettre en place une fenêtre pyglet. Nous allons utiliser la méthode la plus proche de la programmation objet qui consiste à faire de l'héritage.

une fenêtre

Fichier : essais-pyglet/fenêtre.py :

import pyglet


class HelloWorldWindow(pyglet.window.Window):
    def __init__(self):
        super().__init__(400, 200, "texte")

        self.label = pyglet.text.Label("Hello, world!")

    def on_draw(self):
        self.clear()
        self.label.draw()


window = HelloWorldWindow()
print(window.get_size())

pyglet.app.run()
print("c'est fini !")

Exécutez le programme précédent.

Vous devriez voir apparaître une fenêtre avec écrit "Hello, world!" en blanc sur fond noir en bas à gauche de la fenêtre. Vous devriez aussi voir dans le terminal le texte : (400, 200) qui est la taille de la fenêtre. En revanche, le texte c'est fini ! ne devrait apparaître dans le terminal que lorsque la fenêtre se ferme.

Comprenez

  • comment fonctionne le programme
  • l'héritage de la classe Window de pyglet
  • la fonction de la ligne pyglet.app.run()

Notre classe HelloWorldWindow commence par appeler le constructeur de sa classe mère.

En pyglet, il est indispensable que ce soit la première ligne, car ce constructeur va gérer tout l'affichage de la fenêtre. Si vous ne faite pas cela, vous risquez d'avoir des erreurs de contexte opengl.

La méthode on_draw sert à dessiner la fenêtre et est exécutée à chaque rafraîchissement de la fenêtre, c'est à dire beaucoup de fois par seconde (60 IPS = 60 images par seconde = 60 rafraîchissement de fenêtre par seconde = 60 FPS). Son code stipule que pour dessiner la fenêtre :

  1. on efface son contenu
  2. on dessine le label

Ajoutez au code de la méthode HelloWorldWindow.on_draw() un print("Coucou du rafraîchissement !") puis exécutez le code et :

  1. repérez où doit s'afficher notre phrase
  2. voyez comment elle est affichée souvent

corrigé

# ...

    def on_draw(self):
        self.clear()
        self.label.draw()

        print("Coucou du rafraîchissement !")

# ...

print affiche du texte à l'endroit où vous avez exécuté le programme. Vous verrez donc l'affichage dans le terminal et non dans le fenêtre.

En utilisant la fonction datetime.now() du module datetime :

>>> from datetime import datetime
>>> print(datetime.now())
2023-03-13 08:44:13.740575
>>> 

Déterminez le nombre d'images par secondes de notre application.

corrigé


from datetime import datetime

# ...

class HelloWorldWindow(pyglet.window.Window):
    # ...
    def on_draw(self):
        self.clear()
        self.label.draw()

        print(datetime.now())

# ...

Le texte s'affiche toutes les 20ms environ soit, $1000/20 = 50$ IPS.

Une fenêtre redimensionnable

La fenêtre que nous venons de créer n'est pas redimensionnable (essayez d'augmenter sa taille, vous verrez qe vous n'y arrivez pas).

Lisez la documentation de la classe fenêtre de pyglet pour trouver comment créer une fenêtre redimensionnable. Puis faites le et testez le tout.

corrigé

C'est le paramètre resizable qu'il faut positionner à True.

# ...
class HelloWorldWindow(pyglet.window.Window):
    def __init__(self):
        super().__init__(400, 200, "texte", resizable=True)

        # ...
    
    # ...

La méthode on_draw est une méthode spéciale. A chaque fois que l'événement draw est activé, cette méthode est exécutée. Pour le voir concrètement, regardons l'effet d'un redimensionnement sur la taille de la fenêtre :

import pyglet


class HelloWorldWindow(pyglet.window.Window):
    def __init__(self):
        super().__init__(400, 200, "texte", resizable=True)

        self.label = pyglet.text.Label("Hello, world!")

    def on_draw(self):
        print("taille de la fenêtre :", self.get_size())
        self.clear()
        self.label.draw()


window = HelloWorldWindow()
print(window.get_size())

pyglet.app.run()
print("c'est fini !")

On récapitule

  • l'ajout d'un paramètre lors de l'appel au construction de Window qui rend la fenêtre redimensionnable
  • l'ajout d'un print dans la méthode on_draw
  • lorsque l'on change la taille de la fenêtre, la méthode on_draw est exécutée

La méthode on_draw étant exécutée à chaque rafraîchissement, le print dans cette fonction va vite devenir pénible. Supprimez le.

Modifiez le code de la méthode on_draw pour qu'il soit identique au code ci-dessous :

# ...

    def on_draw(self):
        self.clear()
        self.label.draw()

# ...

Texte au milieu de la fenêtre

Un label est un objet non modifiable qui peut-être affiché (dans la méthode on_draw).

Remplacez sa création dans le fichier essais-pyglet/fenêtre.py par :

# ...

self.label = pyglet.text.Label(
    "Hello, world!",
    x=self.width // 2,
    y=self.height // 2,
    anchor_x="center",
    anchor_y="center",
)

# ...

En exécutant le code, le texte est placé au milieu de l'écran ! En revanche, lorsque vous modifiez la taille de la fenêtre pendant l'exécution du programme, la position ne change pas.

  • déduire l'origine de la fenêtre en utilisant le redimensionnement de la fenêtre.
  • utiliser l’événement on_resize(width, height) pour replacer le label à la bonne position après chaque redimensionnement en modifiant ses attributs x et y.

La méthode on_resize est utilisée par Window, n’oubliez pas de l’appeler avec un super.

solution

l'origine est en bas à gauche de la fenêtre.

class HelloWorldWindow(pyglet.window.Window):
    #...

    def on_resize(self, width, height):
        super().on_resize(width, height)
        self.label.x = width // 2
        self.label.y = height // 2

    # ...

Gestion du clavier

On utilise deux événements pour gérer le clavier :

Le paramètre symbol est un entier qui correspond au code de la touche et modifiers gère les touches comme shift, control ou encore alt.

Ajoutons une gestion basique du clavier dans le programme :

class HelloWorldWindow(pyglet.window.Window):
    #...

    def on_key_press(self, symbol, modifiers):
        print("press:", symbol, modifiers)

    def on_key_release(self, symbol, modifiers):
        print("release:", symbol, modifiers)

    # ...

Nous n'avons pas utilisé de super pour appeler la méthode de la classe mère, car Window ne gère pas le clavier par défaut.

Exécutez le code précédent et remarquez

  • que chaque touche a bien un code, ainsi que les touches de modification
  • shift gauche et shift droit sont discernables
  • qu'après chaque touche appuyée ou relâchée l'évènement on_draw est lancé
  • que même si on laisse appuyé la touche longtemps, il n'y a qu'un seul événement on_key_press qui est lancé.

Flèches gauche et droite

Les code des différentes touches est disponible dans l'objet pyglet.window.key.

Chaque touche est une constante dont le nom correspond à la la touche et sa valeur au code. Par exemple, la constante pyglet.window.key.SPACE correspond au nombre 32.

Vérifiez que lorsque vous appuyez sur la touche espace de votre clavier, c'est bien le symbole 32 qui et affiché

Nous allons maintenant faire bouger d'un cran notre texte lorsque l'on appuie sur les touches "flèche gauche" et "flèche droite".

En utilisant le fait que les deux attributs x et y contiennent la position du label : faite en sorte que lorsque l'on appuie sur une flèche du clavier (le nom des constantes de pyglet.window.key correspondant aux flèches sont disponible ), le texte se déplace de 10 pixels vers la direction de la flèche.

solution

# ...

from pyglet.window import key

# ...

class HelloWorldWindow(pyglet.window.Window):
    # ...
    
    def on_key_press(self, symbol, modifiers):
        if symbol == key.UP:
            self.label.y += 10
        elif symbol == key.DOWN:
            self.label.y -= 10
        elif symbol == key.LEFT:
            self.label.x -= 10
        elif symbol == key.RIGHT:
            self.label.x += 10

    # ...

Avec cette technique, on ne peut se déplacer que d'un cran par appui sur la touche. Pour gérer les déplacements continus, il faut prendre en compte le temps d'appui sur la touche. C'est le boulot de la partie suivante.

Touches de modifications

Les touches de modifications sont gérées par un bit field : une touche de modification correspond à un bit qui est positionné à 1 si la touche est enfoncée et à 0 sinon. Ceci permet :

Les différents modificateurs sont donnés :

>>> from pyglet.window import key
>>> print(key.MOD_SHIFT)
1
>>> print(bin(key.MOD_SHIFT))
0b1
>>> print(key.MOD_ALT)
4
>>> print(bin(key.MOD_ALT))
0b100
>>>

Savoir si un modificateur particulier est appuyé se fait avec des conditions sur les bits comme & ou | par exemple.

Essayez le code suivant pour savoir si la touche shift est enfoncée ou non :

from pyglet.window import key

# ...

class HelloWorldWindow(pyglet.window.Window):
    # ...
    
    def on_key_press(self, symbol, modifiers):
        if modifiers & key.MOD_SHIFT:
            print("touche shift appuyée")

    # ...

Allons un peu plus loin :

Comment faire pour savoir si la touche shift ou la touche control est enfoncée ?

corrigé

class HelloWorldWindow(pyglet.window.Window):
    # ...
    
    def on_key_press(self, symbol, modifiers):
        if modifiers & (key.MOD_SHIFT | key.MOD_CTRL):
            print("touche shift ou ctrl appuyée")

    # ...

Comment faire pour savoir si la touche shift et la touche control est enfoncée ?

corrigé

class HelloWorldWindow(pyglet.window.Window):
    # ...
    
    def on_key_press(self, symbol, modifiers):
        if (modifiers & (key.MOD_SHIFT | key.MOD_CTRL)) == key.MOD_SHIFT | key.MOD_CTRL:
            print("touche shift ou ctrl appuyée")

    # ...

Gestion du temps

La gestion du temps se fait également par un événement. Sa mise en place est cependant différente des événements que l'on a vu jusqu'à présent :

class HelloWorldWindow(pyglet.window.Window):
    # ...

    def __init__(self):
        super().__init__(400, 200)

        # ...

        pyglet.clock.schedule_interval(self.update, 1)

        # ...

    def update(self, dt):
        print(dt)


    # ...

Le code précédent fait en sorte que la méthode update soit exécutée toute les secondes. Le paramètre dt donne le nombre de secondes exactes depuis le dernier appel de la fonction. Cela permet de gérer le lag s'il existe (remarquez qu'il vaut toujours un peut plus que 1).

Faites en sorte que qu'un texte avance de 10 pixels toutes les 0.5s si une touche est appuyée.

Pour cela on ne va pas modifier la position du label dans on_key_press mais dans update :

  • créez deux attributs dx et dy à notre objet HelloWorldWindow. Par défaut ces deux attributs vaudront 0
  • à chaque appelle de update, bougez la position du label de self.dx et self.dy
  • gérez les valeurs de self.dx et self.dy dans on_key_press et on_key_release (par exemple self.dx = -10 lorsque l'on appuie sur la flèche gauche et self.dx = 0 lorsque la flèche gauche est relâchée)

solution

# ...

class HelloWorldWindow(pyglet.window.Window):
    def __init__(self):
        self.label = pyglet.text.Label(
            "Hello, world!",
            x=self.width // 2,
            y=self.height // 2,
            anchor_x="center",
            anchor_y="center",
        )

        pyglet.clock.schedule_interval(self.update, .5)

        self.dx = 0
        self.dy = 0

        # ...

    def update(self, dt):
        self.label.x += self.dx
        self.label.y += self.dy

    # ...

    def on_key_press(self, symbol, modifiers):
        if symbol == key.UP:
            self.dy = 10
        elif symbol == key.DOWN:
            self.dy = -10
        elif symbol == key.LEFT:
            self.dx = -10
        elif symbol == key.RIGHT:
            self.dx = +10

    def on_key_release(self, symbol, modifiers):
        if symbol == key.UP:
            self.dy = 0
        elif symbol == key.DOWN:
            self.dy = 0
        elif symbol == key.LEFT:
            self.dx = 0
        elif symbol == key.RIGHT:
            self.dx = 0
    
    # ...

# ...

Avec cette méthode le texte va continuer de se déplacer tant qu'une flèche reste appuyée. On peut même taper plusieurs flèches en même temps pour se déplacer en diagonale (cool, non ?).

Il reste un problème : le texte va sortir de la fenêtre si on reste appuyé trop longtemps. Corrigez ça :

Ajoutez à votre code une sentinelle qui empêche les coordonnées x et y du label de sortir hors de la fenêtre.

Les dimensions de la fenêtres sont données par ses attributs width et height.

solution


class HelloWorldWindow(pyglet.window.Window):
    # ...

    def update(self, dt):
        self.label.x += self.dx
        if self.label.x < 0:
            self.label.x = 0
        elif self.label.x > self.width:
            self.label.x = self.width

        self.label.y += self.dy
        if self.label.y < 0:
            self.label.y = 0
        elif self.label.y > self.height:
            self.label.y = self.height
    
    # ...

Gestion de la souris

Pour gérer la souris, comme vous pouvez vous en douter, il s'agit de s'abonner à des événements. Il en existe plusieurs. Commençons par voir ce que ça donne avec les événements on_mouse_press(x, y, button, modifiers) et on_mouse_release(x, y, button, modifiers) :


class HelloWorldWindow(pyglet.window.Window):
    # ...

    def on_mouse_press(self, x, y, button, modifiers):
        print("press:", x, y, button)

        if (abs(self.label.x - x) <= self.label.content_width / 2) and (
            abs(self.label.y - y) <= self.label.content_height / 2
        ):
            print("clique dans le label")

    def on_mouse_release(self, x, y, button, modifiers):
        print("release:", x, y, button)

        if (abs(self.label.x - x) <= self.label.content_width / 2) and (
            abs(self.label.y - y) <= self.label.content_height / 2
        ):
            print("relâcher le bouton dans le label")
    
    # ...

Lorsque vous cliquez sur un bouton de la souris puis que vous le relâchez, vous devriez voir affiché à l'écran la position du curseur ainsi que le numéro du bouton de la souris qui a servi à cliquer.

Cerise sur le gâteau, lorsque vous cliquez ou relâchez le bouton de la souris sur le label, cela devrait vous l'indiquer.

Boutons

Se fait comme pour les modificateurs de touches, avec un bit field. Les valeurs des bits concernés sont définis par les constantes suivantes, accessibles après l'import from pyglet.window import mouse :

Modifiez le code précédent pour que l'on ne prenne en compte l'appuie dans le label que si l'on a cliqué avec le bouton de gauche.

solution

from pyglet.window import mouse

class HelloWorldWindow(pyglet.window.Window):
    # ...

    def on_mouse_press(self, x, y, button, modifiers):
        print("press:", x, y, button)

        if not (button & mouse.LEFT):
            return

        if (abs(self.label.x - x) <= self.label.content_width / 2) and (
            abs(self.label.y - y) <= self.label.content_height / 2
        ):
            print("clique dans le label")

    # ...

Mouvements

En utilisant l'événement on_mouse_motion(self, x, y, dx, dy) repérez quand la souris rentre et sort du label. N'hésitez pas à regarder la documentation de l'événement pour comprendre la définition de chaque paramètre.

solution


class HelloWorldWindow(pyglet.window.Window):
    # ...

    def on_mouse_motion(self, x, y, dx, dy):
        if (abs(self.label.x - x) <= self.label.content_width / 2) and (
            abs(self.label.y - y) <= self.label.content_height / 2
        ):
            if (abs(self.label.x - (x - dx)) > self.label.content_width / 2) or (
                abs(self.label.y - (y - dy)) > self.label.content_height / 2
            ):
                print("entre dans label")
        else:
            if (abs(self.label.x - (x - dx)) <= self.label.content_width / 2) and (
                abs(self.label.y - (y - dy)) <= self.label.content_height / 2
            ):
                print("sort du label")
    
    # ...

Dessiner des formes

La documentation permet de voir que l'on peut facilement dessiner des cercle ou des rectangles en pyglet.

De façon générale, la gestion des graphique en pyglet se fait directement en opengl, ce qui dépasse de loin le cadre de ce cours (même si c'est chouette de parler directement à la carte graphique). Nous allons donc uniquement nous restreindre au dessin d'un cercle et d'un rectangle ce qui sera suffisant pour notre projet.

Fichier : essais-pyglet/forme.py :

import pyglet
from pyglet import shapes
from pyglet.window import mouse


class Formes(pyglet.window.Window):
    def __init__(self):
        super().__init__(640, 480, "formes")

        self.rectangle = shapes.Rectangle(200, 200, 200, 200, color=(55, 55, 255))
        self.circle = shapes.Circle(0, 0, 50, color=(255, 0, 0))
        self.circle.opacity = 128

    def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
        if buttons & mouse.LEFT and (
            (self.circle.x - x) ** 2 + (self.circle.y - y) ** 2
            <= self.circle.radius ** 2
        ):
            self.circle.x += dx
            self.circle.y += dy

    def on_draw(self):
        self.clear()

        self.rectangle.draw()
        self.circle.draw()


forme = Formes()

pyglet.app.run()
print("c'est fini !")

Testez l'exemple ci-dessus et comprenez ce qu'il fait.

Les couleurs sont décrites au format RGB sous la forme de 3 entiers allant de 0 à 255 en base 10 :

On a souvent coutume (dans le monde du web par exemple) de représenter ces 3 nombres par un nombre hexadécimal de 6 chiffres (2 par composante, chaque composante étant codée par un nombre allant de 00 à FF). Par exemple, le nombre #F58318 correspond à la couleur ayant F5 en rouge, 83 en vert et 18 en bleu, les 3 nombres étant en codage hexadécimal. Ce qui en python donne avec un tuple de 3 coordonnées : (0xF5, 0x83, 0x18), ou (245, 131, 24) en base 10 (un nombre écrit en hexadécimal en python commence par 0x).

Pour gérer et trouver des couleurs sympathiques, utilisez une roue des couleurs, comme celle d'adobe par exemple.

Code final