Plateau de jeu graphique

ChaCuN – étape 9

1. Introduction

Le but de cette étape est de continuer l'interface graphique de ChaCuN en écrivant la classe qui permet d'afficher le plateau de jeu.

2. Concepts

2.1. Plateau de jeu

Le plateau de jeu est situé dans la partie gauche de l'interface graphique de ChaCuN, dont il constitue la plus grosse partie, comme on peut le voir sur l'image ci-dessous.

chacun-gui.jpg
Figure 1 : L'interface graphique de ChaCuN (cliquer pour agrandir)

Le plateau — qui porte le numéro 1 ci-dessus — est représenté par une grille de 25×25 cases carrées. Chacune de ces cases peut être soit vide, soit contenir une tuile et ses éventuels occupants.

Les cases qui contiennent une tuile affichent toujours son image. Lorsque certaines tuiles sont mises en évidence, l'image de celles qui ne le sont pas est assombrie.

2.1.1. Frange

Les cases vides dont au moins une des 4 cases voisines contient une tuile sont celles sur lesquelles la prochaine tuile pourrait être placée. Dans l'étape 5, nous avons nommé les positions de ces cases les positions d'insertion. Nous nommerons l'ensemble des cases dont la position est une position d'insertion la frange (fringe) du plateau.

Il faut noter que la frange n'existe que lorsqu'une tuile doit être placée. Lorsque ce n'est pas le cas, p. ex. car la tuile qui vient d'être placée doit être occupée, la frange est vide.

Les cases de la frange sont colorées avec la couleur du joueur actuel. De plus, si l'une de ces cases est survolée par le curseur de la souris, alors l'image de la tuile à placer apparaît sur elle. Si la tuile à placer, tournée comme elle l'est actuellement, ne peut pas être placée à cet endroit, alors son image est recouverte d'un voile blanc.

Lorsque le curseur de la souris survole une case de la frange, un clic sur le bouton droit de la souris permet de tourner la tuile à placer d'un quart de tour dans le sens antihoraire — ou dans le sens horaire si la touche Alt (Option sur Mac) est pressée simultanément. Un clic sur le bouton gauche de la souris permet de placer effectivement la tuile.

2.1.2. Occupants

Lorsqu'une tuile vient d'être placée, et que son placeur a la possibilité de l'occuper, tous les occupants potentiels de la tuile apparaissent sur elle. Le joueur courant a alors la possibilité de cliquer sur l'un de ces occupants pour le placer.

De manière similaire, lorsqu'un joueur a placé la tuile avec le chaman, il a la possibilité de cliquer sur l'un de ses pions pour le reprendre en main.

2.1.3. Jetons d'annulation

Lorsque des animaux sont annulés, par exemple suite à la pose de la fosse à pieux, leur image est recouverte d'un « jeton d'annulation », dont l'image a été présentée à l'étape précédente.

Les images des jetons d'annulation sont toujours placées entre l'image de la tuile et les occupants, afin de ne pas masquer ces derniers.

3. Mise en œuvre Java

Avant de commencer à programmer cette étape, il vous faut télécharger une archive Zip qui contient la feuille de style du plateau (board.css). Comme les autres feuilles de style, elle doit être placée dans le dossier resources de votre projet.

En ouvrant ce fichier dans IntelliJ, vous constaterez qu'il contient, entre autres, un grand nombre de ligne ayant pour but de placer les occupants et les jetons d'annulation sur leur tuile. Par exemple, les deux lignes suivantes, extraites de ce fichier, ont pour but de placer, sur la tuile 4, le pion occupant la zone 41 ainsi que le jeton d'annulation de l'animal 420 :

#pawn_41 { -fx-translate-x: 48; -fx-translate-y: 14 }
#marker_420 { -fx-translate-x: 52; -fx-translate-y: 3 }

Vous n'avez pas besoin de comprendre la signification exacte de ces lignes, mais il vous faut juste savoir que, grâce à elles, vous n'aurez pas à placer individuellement les occupants et jetons d'annulation.

3.1. Classe BoardUI

La classe BoardUI du sous-paquetage gui, publique et non instanciable, contient le code de création de la partie de l'interface graphique qui affiche le plateau de jeu.

Son unique méthode publique, nommée p. ex. create, prend en arguments la portée du plateau à créer, de type int. En pratique, elle vaudra toujours 12 dans ce projet, mais BoardUI doit néanmoins pouvoir être utilisable avec n'importe quelle portée strictement positive.

En plus de la portée, create prend un certain nombre de valeurs observables en arguments. Pour alléger la présentation, leur type est donné ci-dessous sans le ObservableValue<…> qui l'entoure. Il s'agit de :

  • l'état du jeu, de type GameState,
  • la rotation à appliquer à la tuile à placer, de type Rotation,
  • l'ensemble des occupants visibles, de type Set<Occupant>,
  • l'ensemble des identifiants des tuiles mises en évidence, de type Set<Integer>.

Lorsque l'ensemble des identifiants des tuiles mises en évidence est vide, cela signifie qu'aucune tuile ne doit être mise en évidence, et donc que toutes les tuiles posées ont leur apparence normale.

Finalement, create prend encore trois arguments qui sont des gestionnaires d'événements à appeler dans certaines situations. Comme précédemment, nous représenterons ces gestionnaires d'événements au moyen de l'interface Consumer. Pour alléger la présentation, leur type est donné ci-dessous sans le Consumer<…> qui les entoure. Il s'agit de :

  • un gestionnaire prenant une valeur de type Rotation, à appeler lorsque le joueur courant désire effectuer une rotation de la tuile à placer, c.-à-d. qu'il effectue un clic droit sur une case de la frange,
  • un gestionnaire prenant une valeur de type Pos, à appeler lorsque le joueur courant désire poser la tuile à placer, c.-à-d. qu'il effectue un clic gauche sur une case de la frange,
  • un gestionnaire prenant une valeur de type Occupant, à appeler lorsque le joueur courant sélectionne un occupant, c.-à-d. qu'il clique sur l'un d'entre eux.

Le premier de ces gestionnaires doit être appelé soit avec Rotation.RIGHT si la touche Alt est appuyée au moment du clic, soit avec Rotation.LEFT sinon.

Lisez bien les conseils de programmation plus bas avant de commencer à écrire la classe BoardUI, car ils contiennent de nombreuses indications quant à la manière d'utiliser les arguments passés à create.

3.1.1. Graphe de scène

Le graphe de scène construit par la méthode create — et par les auditeurs qu'elle installe — est visible dans la figure ci-dessous.

board-sg;32.png
Figure 2 : Graphe de scène du plateau de jeu

Comme les occupants et les marqueurs d'une case dépendent de la tuile qui s'y trouve, seules les cases contenant une tuile possèdent des occupants et des jetons d'annulation.

3.1.2. Conseils de programmation

  1. Construction du plateau

    Comme nous l'avons vu plus haut, le plateau de jeu est représenté par une instance de GridPane qui possède un enfant par case du plateau, donc 625 enfants si la portée de ce dernier est de 12.

    La construction du graphe de scène se fait donc assez facilement au moyen de deux boucles imbriquées, l'une parcourant les index x des cases, l'autre les index y. À chaque itération de cette boucle, le petit graphe de scène correspondant à la case à la position (x, y) est construit, et ajouté à l'instance de GridPane au moyen de sa méthode add.

    Initialement, le graphe de scène d'une case consiste en une instance de Group contenant une unique instance de ImageView, qui montre une image « vide ». Un auditeur attaché à une valeur observable dont le contenu est la tuile du plateau à la position (x, y) augmente ce graphe de scène initial avec les nœuds correspondants aux jetons d'annulation et aux occupants au moment où une tuile est ajoutée au plateau à cette position.

  2. Image de fond

    L'image de fond d'une case — celle qui est affichée par l'instance de ImageView — est soit « vide », soit l'image d'une tuile.

    Il faut toutefois faire attention à ce que l'on entend par « image vide ». On pourrait penser que cela signifie que la propriété imageProperty de l'instance de ImageView contient simplement null. Malheureusement, lorsque c'est le cas, l'instance de ImageView est considérée comme vide par JavaFX, de même que le groupe qui la contient. Cela implique, entre autres, qu'il est impossible de savoir quand le curseur de la souris survole la case, ce qui est problématique pour nous.

    Dès lors, quand une case du plateau est vide, l'instance de ImageView qui lui correspond affiche une image de couleur uniforme, presque blanche. Cette image, qui peut être créée une seule fois et ensuite utilisée dans toutes les cases vides, s'obtient ainsi :

    WritableImage emptyTileImage = new WritableImage(1, 1);
    emptyTileImage
      .getPixelWriter()
      .setColor(0, 0, Color.gray(0.98));
    

    Notez que cette image a une taille de 1×1 unités, mais cela n'est pas problématique car les instances de ImageView que nous utilisons sont configurées pour redimensionner leur image à une taille de 128×128 unités. Cette minuscule image occupera donc la totalité de la case, comme attendu.

    Lorsqu'une case contient une tuile, son image de fond est simplement celle de la tuile, qui s'obtient facilement de la classe ImageLoader écrite à l'étape 8. Pour des raisons d'efficacité, il est toutefois nécessaire de stocker toutes les images des tuiles obtenues de ImageLoader dans une table les associant à l'identifiant de leur tuile. Cette table, que l'on nomme un cache en informatique, permet de garantir que l'image d'une tuile donnée sera chargée au plus une fois durant l'exécution du programme. Sachant que le chargement est une opération relativement lente, le cache permet d'améliorer les performances du programme.

  3. Occupants et jetons d'annulation

    Une fois ajoutés au graphe de scène, les nœuds JavaFX correspondant aux jetons d'annulation et aux occupants y restent ensuite définitivement, dans un soucis de simplicité. Toutefois, ils sont rendus visibles ou non en fonction de l'état du jeu.

    Un jeton donné est visible lorsque l'identifiant de l'animal auquel il correspond fait partie des animaux annulés de l'état du jeu courant. Un occupant donné est quant à lui visible lorsqu'il fait partie de l'ensemble des occupants visibles passés à create. Cet ensemble contiendra tous les occupants effectivement posés sur le plateau ainsi que tous les occupants potentiels de la dernière tuile lorsque celle-ci doit être occupée.

    Comme illustré à la figure 2, l'identité d'un nœud JavaFX représentant un jeton d'annulation est la concaténation de la chaîne marker_ avec l'identifiant de l'animal auquel le jeton correspond. De même, l'identité d'un nœud JavaFX représentant un occupant est la concaténation de la chaîne pawn_ (pour un pion), ou de la chaîne hut_ (pour une hutte), avec l'identifiant de la zone qu'il occupe.

    Les nœuds JavaFX représentant les occupants — mais pas ceux représentant les jetons — subissent une rotation de manière à apparaître toujours verticaux à l'écran, même si la tuile qu'ils occupent est tournée.

    Finalement, un clic sur un nœud JavaFX représentant un occupant provoque toujours l'appel de la méthode accept du troisième gestionnaire d'événements passé à create, avec l'occupant lui-même en argument.

  4. Rotation

    Les tuiles posées sur le plateau peuvent être tournées d'un nombre quelconque de quarts de tour. Bien entendu, cela implique que leur image doit également l'être.

    Toutefois, tourner uniquement l'image de la tuile, par exemple en appliquant une rotation à l'instance de ImageView qui la contient, ne suffit pas. En effet, les éventuels jetons d'annulation et les occupants doivent également être tournés, faute de quoi ils ne sont pas positionnés correctement.

    Dès lors, lorsqu'une tuile a été tournée avant d'être posée sur le plateau, la rotation doit être appliquée à l'instance de Group qui représente la case qui la contient. C'est pour cette raison que les occupants doivent subir la rotation inverse de celle appliquée au groupe les contenant.

    Appliquer une rotation à un nœud est très simple avec JavaFX, puisqu'il suffit de modifier le contenu de sa propriété rotateProperty.

  5. Voile

    Comme nous l'avons dit dans l'introduction, les cases sont parfois recouvertes d'un voile coloré, c.-à-d. d'une image semi-transparente de couleur uniforme. Il y a trois situations dans lesquelles c'est le cas :

    1. si la case contient une tuile, et que certaines tuiles sont mises en évidence mais pas celle de la case, alors elle est recouverte d'un voile noir qui l'assombrit,
    2. si la case fait partie de la frange mais quelle n'est pas survolée par le curseur de la souris, alors elle est recouverte d'un voile de la couleur du joueur courant,
    3. si la case fait partie de la frange, que le curseur de la souris la survole et que la tuile courante, avec sa rotation actuelle, ne peut pas y être placée, alors elle est recouverte d'un voile blanc.

    Avec JavaFX, le voile peut être représenté par un effet graphique attaché à l'instance de Group qui représente la case, au moyen de sa propriété effectProperty. L'effet qui convient ici est un mélange (Blend) de type SRC_OVER entre l'image du voile, à l'avant plan, et l'image de la tuile, à l'arrière-plan. L'image du voile peut être créée au moyen d'une instance de ColorInput de la taille d'une tuile, dont l'opacité est de 0.5 et la couleur est celle du voile.

  6. Représentation d'une case

    Comme expliqué dans les sections qui précèdent, la représentation graphique d'une case est paramétrée par trois éléments :

    1. l'image de fond de la case,
    2. l'éventuelle rotation appliquée à la case,
    3. la couleur de l'éventuel voile recouvrant la case.

    Ces trois éléments dépendent d'un assez grand nombre de valeurs qui changent au cours du temps, comme le contenu du plateau, la frange, la rotation appliquée à la tuile à poser, le fait que le curseur de la souris survole la case, etc.

    Pour représenter ces trois éléments, nous vous conseillons vivement de définir un enregistrement privé, nommé p. ex. CellData, les regroupant. Cela fait, vous pouvez définir, pour chaque case du plateau, une valeur observable contenant l'instance de cet enregistrement correspondant à la case. Comme cette valeur observable dépend de plusieurs autres valeurs observables, et pas d'une seule, il n'est pour une fois pas possible d'utiliser map pour la définir. Au lieu de cela, il vous faut utiliser la méthode createObjectBinding de la classe Bindings, que l'on peut voir comme une variante de map généralisée à plusieurs arguments.

    Le premier argument passé à createObjectBinding est une lambda sans argument dont le but est de calculer la valeur observable à créer, généralement en fonction de plusieurs autres valeurs observables. Les autres arguments de createObjectBinding sont toutes les valeurs observables utilisées dans la lambda.

    Par exemple, admettons que l'on dispose de deux valeurs observables, string1O et string2O, contenant des chaînes de caractères, et que l'on désire créer une autre valeur observable contenant la concaténation de ces chaînes. Cela peut se faire ainsi :

    ObservableValue<String> string1O = …;
    ObservableValue<String> string2O = …;
    
    ObservableValue<String> string12O = Bindings
      .createObjectBinding(() -> {
          return string1O.getValue() + string2O.getValue();
        },
        string1O,
        string2O);
    

    Comme cet exemple l'illustre, createObjectBinding permet de faire quelque chose de similaire à ce que l'on fait dans un tableur lorsqu'on place une formule dans une cellule, la formule en question étant le corps de la lambda. Une différence importante est qu'un tableur peut lui-même déterminer les dépendances de la formule (ici string1O et string2O), alors qu'elles doivent être passées explicitement à createObjectBinding.

    Notez que, comme dit plus haut, l'une des choses dont dépend la représentation d'une case est le fait que le curseur de la souris la survole ou non. Cette information est disponible sous la forme d'une propriété nommée hoverProperty que tout nœud JavaFX possède.

3.2. Tests

Le programme de test ci-dessous, dont vous pouvez vous inspirer, est similaire à celui de l'étape 8 mais construit l'interface d'un plateau de jeu d'une portée de 1.

public final class BoardUITest extends Application {
  public static void main(String[] args) {launch(args);}

  @Override
  public void start(Stage primaryStage) throws Exception {
    var playerNames = /* comme à l'étape 8 */;
    var playerColors = /* comme à l'étape 8 */;
    var tileDecks = /* comme à l'étape 8 */;
    var textMaker = /* comme à l'étape 8 */;
    var gameState = /* comme à l'étape 8 */;

    var tileToPlaceRotationP =
      new SimpleObjectProperty<>(Rotation.NONE);
    var visibleOccupantsP =
      new SimpleObjectProperty<>(Set.<Occupant>of());
    var highlightedTilesP =
      new SimpleObjectProperty<>(Set.<Integer>of());

    var gameStateO = new SimpleObjectProperty<>(gameState);
    var boardNode = BoardUI
      .create(1,
              gameStateO,
              tileToPlaceRotationP,
              visibleOccupantsP,
              highlightedTilesP,
              r -> System.out.println("Rotate: " + r),
              t -> System.out.println("Place: " + t),
              o -> System.out.println("Select: " + o));

    gameStateO.set(gameStateO.get().withStartingTilePlaced());

    var rootNode = new BorderPane(boardNode);
    primaryStage.setScene(new Scene(rootNode));

    primaryStage.setTitle("ChaCuN test");
    primaryStage.show();
  }
}

En exécutant ce programme, vous devriez voir une fenêtre similaire à celle ci-dessous s'afficher à l'écran. Certains aspects pourraient bien entendu différer si vous n'avez pas encore terminé la programmation de votre classe.

board-ui-test;64.png

La vidéo ci-dessous montre le comportement de ce programme lorsque la souris survole les cases de la frange, et lorsqu'on clique sur elles au moyen de l'un des deux boutons, éventuellement en maintenant la touche Alt pressée en même temps. Les cercles rouges qui apparaissent lors des clics ont été ajoutés lors de la création de la vidéo, et ne doivent bien entendu pas être visibles dans votre version.

N'hésitez pas à modifier ce programme de test pour, par exemple, ajouter des tuiles au plateau, vérifier que l'affichage des occupants fonctionne, que les animaux peuvent être annulés, etc.

4. Résumé

Pour cette étape, vous devez :

  • écrire la classe BoardUI selon les indications données ci-dessus,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.

Aucun rendu n'est à faire pour cette étape avant le rendu final. N'oubliez pas de faire régulièrement des copies de sauvegarde de votre travail en suivant nos indications à ce sujet.