Etape 10 – Interface graphique

1 Introduction

Le but principal de cette étape est de réaliser l'interface graphique du jeu, qui affiche l'état d'une partie à l'écran et gère la pression des touches du clavier par le joueur humain dans le but de contrôler son joueur simulé.

2 Mise en œuvre Java

A l'exception de l'énumération PlayerAction, placée dans le paquetage ch.epfl.xblast car utilisée également par le serveur, le code de cette étape est à usage exclusif du client et donc placé dans le paquetage qui lui est réservé, ch.epfl.xblast.client.

2.1 Classe XBlastComponent

La classe XBlastComponent, publique et finale, est un composant Swing affichant l'état d'une partie de XBlast. Comme tout composant Swing, elle hérite de JComponent. Elle redéfinit deux méthodes de sa super-classe, à savoir :

  • getPreferredSize, qui retourne la taille idéale du composant, valant ici 960×688,
  • paintComponent, appelée par Swing pour (re)dessiner le contenu du composant, et qui doit ici dessiner l'état.

En plus de ces redéfinitions, elle ajoute une méthode nommée p.ex. setGameState et permettant de changer l'état du jeu affiché par le composant, qui est initialement inexistant (null). Cette méthode prend deux arguments, qui sont :

  1. l'état du jeu à afficher, de type GameState (version client), et
  2. l'identité du joueur pour lequel cet état doit être affiché, de type PlayerID.

L'identité du joueur est utilisée pour décider de l'ordre dans lequel afficher les joueurs, comme expliqué plus bas.

Pour garantir que le composant soit bien redessiné après chaque changement de l'état qu'il affiche, il faut bien entendu que la méthode setGameState appelle la méthode repaint.

2.1.1 Dessin de l'état

Le dessin de l'état est très simple pour peu que l'on s'y prenne correctement. En effet, à l'exception des joueurs et du nombre de vies, dont le dessin est décrit plus bas, le dessin de l'état consiste simplement à placer côte à côte les images correspondant aux différents éléments de l'état.

Ainsi, la toute première image à dessiner, en haut à gauche, est celle du premier bloc du plateau, par dessus laquelle on dessine ensuite la première image des bombes et explosions, s'il y en a une. Cela fait, juste à droite de cette paire d'images superposées, on dessine l'image du second bloc du plateau, par dessus laquelle on dessine ensuite la seconde image des bombes et explosions, s'il y en a une. Et ainsi de suite jusqu'à ce que la totalité des images du plateau et des bombes et explosions aient été dessinées. Ensuite, selon le même principe, on dessine la ligne des scores, puis celle du temps.

Etant donné que les images sont placées côte-à-côte, le calcul de leur position est très simple : la première image est aux coordonnées (0, 0), la seconde aux coordonnées (w1, 0) où w1 est la largeur de la première image, et ainsi de suite.

Le dessin d'une image dans le composant se fait au moyen de la méthode drawImage appliquée au contexte graphique du composant (en passant null en dernier paramètre). Pour mémoire, ce contexte est passé à la méthode paintComponent. Pour des raisons historiques, le paramètre qui lui correspond a le type Graphics, mais il s'agit en réalité toujours d'une valeur de type Graphics2D. La méthode paintComponent peut donc se permettre de le transtyper de manière inconditionnelle, comme illustré dans cet exemple :

public final class XBlastComponent extends JComponent {
  // …

  @Override
  protected void paintComponent(Graphics g0) {
    Graphics2D g = (Graphics2D)g0;
    // … seul g est utilisé ensuite, pas g0
  }
}

2.1.2 Dessin du nombre de vies

Comme cela a été expliqué précédemment, le nombre de vies n'est pas représenté par une image mais par du texte. Ce texte doit être superposé à la ligne des scores, dessiné en blanc et à l'aide de la police Arial grasse, d'une taille de 25 points.

La méthode setFont du contexte graphique permet de choisir la police à utiliser pour le dessin de texte. La méthode setColor permet quant à elle de choisir sa couleur. Finalement, le dessin lui-même peut se faire au moyen de la méthode drawString. L'extrait de programme ci-dessous illustre l'utilisation de ces méthodes pour dessiner le texte Bonjour aux coordonnées (x, y) du contexte graphique g, avec la police susmentionnée :

Graphics2D g = …;               // contexte graphique
int x = …, y = …;               // coordonnées du texte
Font font = new Font("Arial", Font.BOLD, 25);
g.setColor(Color.WHITE);
g.setFont(font);
g.drawString("Bonjour", x, y);

Les coordonnées à utiliser pour le texte donnant le nombre de vie de chaque joueur sont :

  • (96, 659) pour le joueur 1,
  • (240, 659) pour le joueur 2,
  • (768, 659) pour le joueur 3,
  • (912, 659) pour le joueur 4.

2.1.3 Dessin des joueurs

Pour être correctement positionnées, les images des joueurs doivent être dessinées aux coordonnées \((x_s, y_s)\) données par les formules suivantes, qui dépendent de la sous-case \((x, y)\) occupée par le joueur :

\begin{align*} x_s &= 4x - 24\\ y_s &= 3y - 52 \end{align*}

2.1.4 Ordre d'affichage des joueurs

Etant donné que les images des joueurs peuvent se superposer, il est important de décider dans quel ordre les afficher, car cela détermine quel joueur cache l'autre.

Lorsque deux joueurs occupent une position verticale différente, ce choix est facile : le joueur situé le plus au sud du plateau est affiché en dernier afin qu'il apparaisse au dessus de l'autre (ou des autres). Cet ordre est le seul cohérent d'un point de vue visuel.

Lorsque deux joueurs occupent la même position verticale, par contre, le choix est plus difficile car aucune solution ne s'impose pour des raisons visuelles. Dans ce cas, on peut profiter du fait que chaque joueur (humain) dispose de son propre affichage pour dessiner son joueur (simulé) au dessus des autres.

En résumé, l'ordre d'affichage des joueurs s'obtient donc en combinant deux ordres de tri :

  1. en premier lieu, les joueurs sont triés en fonction de leur coordonnée y (de la plus petite à la plus grande),
  2. en second lieu, lorsque deux joueurs ont la même coordonnée y, ils sont triés dans l'ordre de la rotation des joueurs qui place en dernier le joueur pour lequel l'affichage est fait.

Par exemple, lors de l'affichage des joueurs pour le joueur 3, les joueurs sont triés en fonction de leur coordonnée y puis, en cas d'égalité, en fonction de leur position dans la rotation [joueur 4, joueur 1, joueur 2, joueur 3].

Notez que ce tri nécessite la définition d'un comparateur approprié, et que celui-ci est facile à définir en définissant deux comparateurs séparés pour chacun des critères mentionnés ci-dessus puis en les composant au moyen de la méthode par défaut thenComparing de Comparator.

2.2 Enumération PlayerAction

L'énumération PlayerAction énumère les différentes actions qu'un joueur humain peut effectuer, à savoir (dans l'ordre) :

  • JOIN_GAME, qui exprime sa volonté de se joindre à une partie,
  • MOVE_N, qui demande à son joueur simulé de se déplacer vers le nord,
  • MOVE_E, qui demande à son joueur simulé de se déplacer vers l'est,
  • MOVE_S, qui demande à son joueur simulé de se déplacer vers le sud,
  • MOVE_W, qui demande à son joueur simulé de se déplacer vers l'ouest,
  • STOP, qui demande à son joueur simulé de s'arrêter,
  • DROP_BOMB, qui demande à son joueur simulé de déposer une bombe.

A l'exception de la première action, utilisée uniquement avant le début de la partie pour réunir les joueurs, les autres actions correspondent de manière évidente aux événements décrits lors des étapes 5 et 6.

Notez qu'il est important de respecter cet ordre, afin que vos différents clients et serveurs soient compatibles entre eux et avec les nôtres. En effet, ces actions sont transmises des clients au serveur, représentées par leur position dans l'énumération (p.ex. JOIN_GAME est représentée par 0, et ainsi de suite).

2.3 Classe KeyboardEventHandler

La classe KeyboardEventHandler représente un auditeur d'événements clavier. Elle a pour but de simplifier l'écriture de l'auditeur à l'écoute des touches pressées par le joueur humain pour contrôler son joueur simulé.

Comme tout auditeur d'événements clavier Swing, cette classe implémente l'interface KeyListener du paquetage java.awt.event. Pour simplifier sa mise en œuvre, elle hérite simplement de la classe KeyAdapter. Attention, malgré son nom KeyAdapter n'est pas un adaptateur au sens du patron Adapter. Il s'agit en fait d'une classe héritable implémentant l'interface KeyListener et dont toutes les méthodes de gestion d'événements (keyPressed, keyReleased et keyTyped) sont vides. La seule de ces trois méthodes réimplémentée par KeyboardEventHandler est, comme nous le verrons, keyPressed.

La classe KeyboardEventHandler offre un unique constructeur qui prend les deux arguments suivants :

  1. une table associative associant des actions de joueur (de type PlayerAction) à des codes de touche (de type Integer),
  2. un consommateur d'action de joueur, de type Consumer<PlayerAction>.

Les codes des touches permettent d'identifier les touches pressées. Les différentes méthodes de l'auditeur (keyPressed et autres) reçoivent en argument un objet de type KeyEvent donnant des informations concernant l'événement clavier qui s'est produit, et la méthode getKeyCode de cet objet permet d'obtenir le code de la touche pressée. Les valeurs entières correspondant aux différentes touches d'un clavier sont données par des champs statiques de la classe KeyEvent. Par exemple, le champ KeyEvent.VK_UP contient le code correspondant à la touche permettant de déplacer le curseur vers le haut.

La classe KeyboardEventHandler redéfinit la méthode keyPressed de l'interface KeyListener afin que la pression de l'une des touches dont le code fait partie des clefs de la table passée au constructeur provoque l'appel de la méthode accept du consommateur, avec l'action correspondant à la touche pressée en argument.

Comme tous les auditeurs d'événements clavier, les instances de la classe KeyboardEventHandler ont pour but d'être attachées à un composant afin de gérer les événements clavier qu'il reçoit. Dans le cadre de ce projet, le composant auquel l'auditeur doit être attaché est bien entendu l'instance de XBlastComponent affichant l'état du jeu.

L'extrait de code ci-dessous montre comment définir un auditeur d'événements clavier au moyen de la classe KeyboardEventHandler et comment l'attacher à un composant de type XBlastComponent. L'appel à requestFocusInWindow permet de garantir que les événements clavier sont bien envoyés au composant auquel l'auditeur est attaché.

XBlastComponent xbc = …;
Map<Integer, PlayerAction> kb = new HashMap<>();
kb.put(KeyEvent.VK_UP, PlayerAction.MOVE_N);
// … autres correspondances touches / actions
Consumer<PlayerAction> c = System.out::println;
xbc.addKeyListener(new KeyboardEventHandler(kb, c));
xbc.requestFocusInWindow();

Le consommateur d'actions ci-dessus ne fait ici rien d'autre qu'afficher à l'écran l'action correspondant à la touche pressée. Dès lors, si l'utilisateur presse la touche permettant de déplacer le curseur vers le haut, la chaîne MOVE_N s'affiche à l'écran.

2.4 Tests

Pour vérifier visuellement que l'affichage de la partie de XBlast est fait correctement, il est conseillé d'écrire un petit programme principal simulant une partie aléatoire et l'affichant à l'écran. En réutilisant le même principe que celui utilisé à l'étape 6 mais en affichant l'état au moyen du composant XBlastComponent, vous devriez obtenir un affichage identique à celui de la séquence ci-dessous.

Notez que pour obtenir l'état à afficher, vous n'avez pas d'autre choix que de sérialiser l'état actuel, puis de le désérialiser pour obtenir l'état du point de vue du client, qui est le seul que votre composant accepte.

Pour vérifier que votre classe KeyboardEventHandler fonctionne, il est conseillé de l'utiliser pour attacher un auditeur d'événements clavier très simple au composant affichant l'état du jeu. Cet auditeur peut établir la correspondance suivante entre les touches du clavier et les actions (qui est celle qui sera utilisée dans la version finale du jeu) :

  • flèche du curseur haut (KeyEvent.VK_UP) pour MOVE_N,
  • flèche du curseur droite (KeyEvent.VK_RIGHT) pour MOVE_E,
  • flèche du curseur bas (KeyEvent.VK_DOWN) pour MOVE_S,
  • flèche du curseur gauche (KeyEvent.VK_LEFT) pour MOVE_W,
  • barre d'espace (KeyEvent.VK_SPACE) pour DROP_BOMB,
  • shift (KeyEvent.VK_SHIFT) pour STOP.

Lorsque l'une de ces touches est pressée, l'auditeur peut simplement afficher l'action reçue à l'écran, comme celui donné en exemple plus haut.

3 Résumé

Pour cette étape, vous devez :

  • écrire les classes XBlastComponent et KeyboardEventHandler ainsi que l'énumération PlayerAction en fonction des spécifications données plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.