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 :
- l'état du jeu à afficher, de type
GameState
(version client), et - 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 :
- en premier lieu, les joueurs sont triés en fonction de leur coordonnée y (de la plus petite à la plus grande),
- 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 :
- une table associative associant des actions de joueur (de type
PlayerAction
) à des codes de touche (de typeInteger
), - 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
) pourMOVE_N
, - flèche du curseur droite (
KeyEvent.VK_RIGHT
) pourMOVE_E
, - flèche du curseur bas (
KeyEvent.VK_DOWN
) pourMOVE_S
, - flèche du curseur gauche (
KeyEvent.VK_LEFT
) pourMOVE_W
, - barre d'espace (
KeyEvent.VK_SPACE
) pourDROP_BOMB
, - shift (
KeyEvent.VK_SHIFT
) pourSTOP
.
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
etKeyboardEventHandler
ainsi que l'énumérationPlayerAction
en fonction des spécifications données plus haut, - tester votre code,
- documenter la totalité des entités publiques que vous avez définies.