Façade Bibliothèque Graphique AWT (5): Souris et Boucle de Jeu

Faisant suite aux articles sur la façade graphique, je propose à présent d’ajouter un peu d’interaction avec l’utilisation d’une souris. Cela va être l’occasion de voir deux autres patrons: l’Observateur (Observater Pattern) pour gérer les événements de la souris, et la Boucle de Jeu (Game Loop Pattern) pour la synchronisation entre contrôles, mises à jour et affichage.

Façade souris

Pour ajouter la gestion de la souris à la façade, je propose d’introduire une nouvelle interface Mouse qui contient toutes les fonctions liées à la souris:

Séparer l’interface pour la souris de l’interface générale de la façade a deux avantages: alléger l’interface générale et permettre la gestion simultanée de plusieurs souris.

En ce qui concerne les méthodes, j’ai choisi l’API la plus simple possible:

  • Méthode isButtonPressed(): renvoie true si le bouton button est pressée;
  • Méthodes getX() et getY(): renvoient les coordonnées (x,y) du pointeur dans la fenêtre.

Cette API est également bas niveau, afin de correspondre à ce que propose la plupart des bibliothèques graphiques. En outre, cela permet de disposer à tout moment de l’état complet de la souris, ce qui est souvent nécessaire pour les jeux vidéo.

Pour la façade générale GUIFacade, une nouvelle méthode getMouse() est ajoutée pour renvoyer une implantation de l’interface Mouse.

Gestion de la souris avec AWT

Pour implanter cette interface, je reste sur la bibliothèque AWT incluse dans la bibliothèque standard Java. Celle-ci propose une API haut niveau, qui correspond aux besoins des applications de bureautique. Elle est basé sur le patron Observateur (Observater Pattern), qui permet à un élément d’observer (ou écouter) un autre élément, et d’être averti (ou notifié) lorsqu’un événement se produit. Dans le cas de la souris, ces événements sont par exemple la pression d’un bouton ou le déplacement du curseur.

Cette API est divisée en plusieurs interfaces, par exemple l’interface MouseListener permet de gérer les événements liés aux boutons:

L’interface MouseListener contient les méthodes implantées par l’élément observateur: par exemple, lorsqu’un bouton est pressé, la méthode mousePressed() est appelée et l’observateur peut alors agir en conséquence. La classe Component, mère de nombreux éléments graphiques au sein d’AWT, peut être observée par quiconque le demande via la méthode addMouseListener(). Pour l’exemple de cet article, c’est le Canvas utilisé pour rendre le niveau du jeu qui est observé: chaque action de la souris dans sa zone d’affichage provoque des appels aux méthodes de l’interface MouseListener.

Implantation AWT

Je propose que l’implantation de l’interface Mouse de la façade prenne la forme d’une classe AWTMouse:

La classe implante les interfaces Mouse, MouseListener et MouseMotionListener. Dans le premier cas, elle fournit des informations sur la souris (contenues dans ses attributs):

public class AWTMouse implements Mouse, MouseListener, MouseMotionListener {

    private final boolean[] buttons;

    private int x;

    private int y;
    
    public AWTMouse() {
        buttons = new boolean[4];
    }

    @Override
    public boolean isButtonPressed(int button) {
        if (button >= buttons.length) {
            return false;
        }
        return buttons[button];
    }

    @Override
    public int getX() {
        return x;
    }

    @Override
    public int getY() {
        return y;
    }

    ...

Dans les cas suivants, elle réagit aux événements de la souris pour mettre à jour les informations de la souris:

    @Override
    public void mouseClicked(MouseEvent e) {
    }

    @Override
    public void mousePressed(MouseEvent e) {
        if (e.getButton() <= 3) {
            buttons[e.getButton()] = true;
        }
    }

    @Override
    public void mouseReleased(MouseEvent e) {
        if (e.getButton() <= 3) {
            buttons[e.getButton()] = false;
        }
    }

    @Override
    public void mouseEntered(MouseEvent e) {
    }

    @Override
    public void mouseExited(MouseEvent e) {
    }

    @Override
    public void mouseDragged(MouseEvent e) {
        x = e.getX();
        y = e.getY();
    }

    @Override
    public void mouseMoved(MouseEvent e) {
        x = e.getX();
        y = e.getY();
    }
}

Enfin, la classe AWTWindow contient comme précédemment le canvas, et celui-ci est utilisé pour “écouter” les événements de la souris:

public void init(String title, int width, int height) {

    ...

    mouse = new AWTMouse();
    canvas.addMouseListener(mouse);
    canvas.addMouseMotionListener(mouse);
}

Il est possible d’effectuer un travail similaire pour le clavier: pour l’interface, l’important et de permettre à l’utilisateur de disposer à tout moment de l’état du clavier, et pour l’implantation AWT, le clavier est également géré avec le parton Observateur.

Boucle de Jeu

Il ne reste plus qu’à exploiter cette nouvelle interface dans un exemple. Pour ce faire, je propose d’introduire le patron Boucle de Jeu (Game Loop Pattern) dans sa version la plus simple (sans considérations multi-thread). Il repose tout d’abord sur un ensemble de méthodes que l’on peut regrouper au sein d’une même classe:

  • La méthode init() est appelée au démarrage pour initialiser le jeu et ses données;
  • La méthode processInput() est appelée à chaque itération du jeu pour gérer les contrôles (clavier, souris, …). En deux mots, son principal rôle consiste à transformer des “instructions” de l’utilisateur en des “ordres” ou “commandes” plus abstraites que le moteur de jeu sait interpréter. Sur le plan temporel, ces opérations vont au rythme de l’utilisateur.
  • La méthode update() applique les changements sur les données du jeu en fonction de diverses sources, comme les commandes produites par les contrôles utilisateurs ou des opérations qui doivent être appliquées à chaque mise à jour. Sur le plan temporel, ces opérations vont à la vitesse du moteur de jeu.
  • La méthode render() fait le nécessaire pour que l’affichage soit correct. En pratique, cette méthode va le plus souvent commander ou transférer des données sur la carte graphique, cette dernière s’occupant des tâches très bas niveau, au niveau des pixels. Sur le plan temporel, ces opérations vont à la vitesse de rafraîchissement de l’écran (par défaut 60Hz).
  • La méthode run() appelle les précédentes et contient la boucle de jeu à proprement parlé.

Pour notre illustration, je n’ai pas encore mis en place tous les éléments qui permettent de respecter tous ces principes. Je propose donc ici une exploitation de ce patron avec une forme pas totalement orthodoxe, mais suffisante, enfin je l’espère, pour commencer à comprendre toutes ces notions.

Pour la méthode init(), on fabrique et paramètre les deux calques (layers) et initialise la fenêtre:

public void init(Level level) {
    this.level = level;

    backgroundLayer = gui.createLayer();
    backgroundLayer.setTileSize(level.getTileWidth(),level.getTileHeight());
    backgroundLayer.setTexture(level.getTilesetImage(0));
    backgroundLayer.setSpriteCount(level.getWidth()*level.getHeight());

    groundLayer = gui.createLayer();
    groundLayer.setTileSize(level.getTileWidth(),level.getTileHeight());
    groundLayer.setTexture(level.getTilesetImage(1));
    groundLayer.setSpriteCount(level.getWidth()*level.getHeight());

    gui.createWindow("Exemple de contrôle avec la souris",
        scale*level.getTileWidth()*level.getWidth(),
        scale*level.getTileHeight()*level.getHeight());
}

Pour la méthode processInput(), si le bouton gauche est pressé, qu’il y a une cellule du niveau sous le curseur, et que sa tuile est dans le deuxième jeu de tuiles (celui du second calque), alors on y place une tuile avec de l’herbe:

public void processInput() {
    Mouse mouse = gui.getMouse();
    if (mouse.isButtonPressed(MouseEvent.BUTTON1)) {
        int x = mouse.getX() / (scale*level.getTileWidth());
        int y = mouse.getY() / (scale*level.getTileHeight());
        if (x >= 0 && x < level.getWidth() && y >= 0 && y < level.getHeight()) {
            if (level.getTileset(x,y) == 1) {
                level.setTileset(x,y,0);
                level.setTile(x,y,new Point(7,0));
            }
        }
    }
}

Pour la méthode update(), le contenu du niveau est utilisé pour définir les textures à utiliser pour rendre le niveau. C’est la même chose que dans l’article précédent, sinon le fait que cette opération est répétée régulièrement:

public void update() {
    for(int y=0;y<level.getHeight();y++) {
        for(int x=0;x<level.getWidth();x++) {
            int index = x + y * level.getWidth();
            backgroundLayer.setSpriteLocation(index, new Rectangle(scale*x*level.getTileWidth(), scale*y*level.getTileHeight(), scale*level.getTileWidth(), scale*level.getTileHeight()));
            if (level.getTileset(x, y) == 0) {
                Rectangle tile = new Rectangle(level.getTile(x, y), new Dimension(1,1));
                backgroundLayer.setSpriteTexture(index, tile);
            }
            else {
                backgroundLayer.setSpriteTexture(index, null);
            }
        }
    }

    for(int y=0;y<level.getHeight();y++) {
        for(int x=0;x<level.getWidth();x++) {
            int index = x + y * level.getWidth();
            groundLayer.setSpriteLocation(index, new Rectangle(scale*x*level.getTileWidth(), scale*(y-1)*level.getTileHeight(), scale*level.getTileWidth(), scale*2*level.getTileHeight()));
            if (level.getTileset(x, y) == 1) {
                Rectangle tile = new Rectangle(level.getTile(x, y), new Dimension(1,2));
                groundLayer.setSpriteTexture(index, tile);
            }
            else {
                groundLayer.setSpriteTexture(index, null);
            }
        }
    }
}

La méthode render() se contente d’effectuer l’affichage, à savoir dessiner les deux calques:

public void render() {
    if (gui.beginPaint()) {
        gui.drawLayer(backgroundLayer);
        gui.drawLayer(groundLayer);
        gui.endPaint();
    }
}

Enfin, la méthode run() contient la boucle de jeu qui appelle les autres méthodes au maximum 60 fois par seconde:

public void run() {

    int fps = 60;
    long nanoPerFrame = (long) (1000000000.0 / fps);
    long lastTime = 0;

    while(!gui.isClosingRequested()) {
        long nowTime = System.nanoTime();
        if ((nowTime-lastTime) < nanoPerFrame) {
            continue;
        }
        lastTime = nowTime;            

        processInput();
        update();
        render();

        long elapsed = System.nanoTime() - lastTime;
        long milliSleep = (nanoPerFrame - elapsed) / 1000000;
        if (milliSleep > 0) {
            try {
                Thread.sleep (milliSleep);
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }
        }               
    }      
    gui.dispose();        
}

Cette implantation simple du patron Boucle de Jeu ne respecte pas tous ses principes. Il n’y a pas de véritable notion de commande, et il sera difficile de faire évoluer chaque partie à des rythmes différents. En outre, en terme de conception, il y a aussi des choix discutables, comme la classe Level sert à la fois de chargeur de niveau, stockage pour le niveau, et d’une forme de tampon. Par l’intermédiaire de prochains articles, je vous présenterai petit à petit tout ce qu’il faut pour parvenir à un résultat satisfaisant avec toutes les propriétés qui en découlent.

Le code de cet article peut être téléchargé ici.
Pour compiler, saisissez: javac fr/phtools/awtfacade05/Main.java
Pour lancer, saisissez: java fr.phtools.awtfacade05.Main

Ce contenu a été publié dans Tutoriel, avec comme mot(s)-clé(s) , , , , . Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire