Tuiles

ChaCuN – étape 2

1. Introduction

Le but de cette seconde étape est d'écrire les classes permettant de représenter les tuiles du jeu.

Avant de la commencer, lisez le guide Sauvegarder son travail, qui vous donnera des conseils importants concernant la sauvegarde de votre projet au cours du semestre.

2. Concepts

La majorité des concepts nécessaires à cette étape ont été introduits dans la précédente, mais il convient encore de préciser quelques détails.

2.1. Bords de tuiles

Une tuile comporte 4 bords (sides), chacun d'entre eux pouvant être d'une des trois sortes suivantes :

  • un bord pré, constitué d'une seule zone pré,
  • un bord forêt, constitué d'une seule zone forêt,
  • un bord rivière, constitué d'une zone rivière centrale, entourée de deux zones pré, généralement différentes.

Comme nous l'avons dit, deux tuiles ne peuvent être voisines sur le plateau de jeu que si leurs deux bords qui se touchent sont de la même sorte.

Par exemple, la tuile de départ, présentée ci-dessous avec les limites des zones en rouge pour faciliter la compréhension, a les bords suivants :

  • son bord nord est un bord pré (zone 0),
  • ses bords est et sud sont des bords forêt (zone 1),
  • son bord ouest est un bord rivière, le premier pré étant constitué de la zone 2, la rivière de la zone 3, et le second pré de la zone 0.
zones_56;128.png
Figure 1 : La tuile 56 découpée en zones

Notez bien que les deux prés entourant une rivière sont toujours donnés dans l'ordre dans lequel on les rencontre en faisant le tour de la tuile dans le sens des aiguilles d'une montre, comme ci-dessus. Ainsi, la rivière du bord ouest de la tuile de départ est entourée des prés correspondant aux zones 2 et 0 dans cet ordre, et il serait faux d'inverser leur ordre.

2.2. Placement d'une tuile

À l'exception de la tuile de départ, toute tuile placée sur le plateau de jeu l'est par un joueur, que nous nommerons le placeur (placer) de la tuile en question.

Le placeur a le droit de tourner la tuile qu'il doit placer d'un nombre quelconque de quarts de tour pour garantir que ses bords sont compatibles avec ceux des tuiles voisines.

2.3. Occupants potentiels d'une tuile

Après avoir placé une tuile, le placeur a la possibilité de déposer un occupant — pion ou hutte — sur l'une de ses zones.

Pour cela, le placeur doit choisir lequel des occupants potentiels (potential occupants) de la tuile il désire effectivement déposer. Ces occupants potentiels sont donnés par les règles suivantes :

  • chaque zone de bordure (side zone), c.-à-d. qui touche au moins un bord, peut potentiellement être occupée par un pion,
  • chaque rivière qui n'est pas connectée à un lac peut potentiellement être occupée par une hutte,
  • chaque lac peut potentiellement être occupé par une hutte.

Pour mémoire, les lacs ne touchent jamais un bord, par contre toutes les autres zones d'une tuile touchent au moins l'un de ses bords.

Par exemple, les occupants potentiels de la tuile de départ — qui, en pratique, ne peut pas être occupée car elle n'est pas placée par un joueur — sont :

  1. un pion (chasseur) occupant la zone 0 (le pré contenant l'auroch),
  2. un pion (cueilleur) occupant la zone 1 (la forêt),
  3. un pion (chasseur) occupant la zone 2 (le pré vide),
  4. un pion (pêcheur) occupant la zone 3 (la rivière),
  5. une hutte (de pêcheur) occupant la zone 8 (le lac).

Notez que dans le cas général, le nombre d'occupants potentiels d'une tuile est toujours au moins égal au nombre de zones de la tuile, car chaque zone peut être occupée par au moins un occupant. Mais le nombre d'occupants potentiels peut être plus grand que le nombre de zones, car une rivière qui n'est pas connectée à un lac peut être occupée soit par un pion, soit par une hutte.

3. Mise en œuvre Java

3.1. Interface TileSide

L'interface TileSide du paquetage principal, publique et scellée (!), représente un bord de tuile. Elle est destinée à être implémentée par les classes représentant les trois sortes de bords qui existent — forêt, pré ou rivière — décrites dans les sections suivantes.

TileSide offre les méthodes publiques et abstraites suivantes :

  • List<Zone> zones(), qui retourne les zones qui touchent le bord représenté par le récepteur (this),
  • boolean isSameKindAs(TileSide that), qui retourne vrai si et seulement si le bord donné (that) est de la même sorte que le récepteur (this).

3.2. Enregistrement TileSide.Forest

L'enregistrement Forest, public et imbriqué dans TileSide qu'il implémente, représente un bord de tuile forêt. Il possède l'unique attribut suivant :

  • Zone.Forest forest, la forêt qui touche le bord.

De plus, Forest fournit des définitions concrètes des méthodes zones et isSameKindAs.

3.3. Enregistrement TileSide.Meadow

L'enregistrement Meadow, public et imbriqué dans TileSide qu'il implémente, représente un bord de tuile pré. Il possède l'unique attribut suivant :

  • Zone.Meadow meadow, le pré qui touche le bord.

De plus, Meadow fournit des définitions concrètes des méthodes zones et isSameKindAs.

3.4. Enregistrement TileSide.River

L'enregistrement River, public et imbriqué dans TileSide qu'il implémente, représente un bord de tuile rivière. Il possède les attributs suivants :

  • Zone.Meadow meadow1, le premier pré qui entoure la rivière et touche le bord,
  • Zone.River river, la rivière qui touche le bord,
  • Zone.Meadow meadow2, le second pré qui entoure la rivière et touche le bord.

De plus, River fournit des définitions concrètes des méthodes zones et isSameKindAs.

Notez une fois encore que l'ordre des deux prés est significatif, puisqu'ils sont donnés dans l'ordre dans lequel ils sont rencontrés lors d'un parcours de la tuile dans le sens des aiguilles d'une montre. La méthode zones les fournit dans le même ordre.

3.5. Enregistrement Tile

L'enregistrement Tile du paquetage principal, public, représente une tuile qui n'a pas encore été placée. Il possède les attributs suivants :

  • int id, l'identifiant de la tuile,
  • Kind kind, la sorte de la tuile (voir plus bas),
  • TileSide n, le côté nord (north) de la tuile,
  • TileSide e, le côté est (east) de la tuile,
  • TileSide s, le côté sud (south) de la tuile,
  • TileSide w, le côté ouest (west) de la tuile.

Comme d'habitude, le type Kind, imbriqué dans l'enregistrement Tile, énumère les sortes de tuile qui existent et qui sont, dans l'ordre :

  • START, qui identifie l'unique tuile de départ,
  • NORMAL, qui identifie les tuiles normales,
  • MENHIR, qui identifie les tuiles menhir.

En plus de ces attributs et des méthodes automatiquement générées par Java, Tile possède les méthodes publiques suivantes :

  • List<TileSide> sides(), qui retourne la liste des quatre côtés de la tuile, dans l'ordre n, e, s, w,
  • Set<Zone> sideZones(), qui retourne l'ensemble des zones de bordure de la tuile, c.-à-d. celles qui touchent au moins un bord — toutes les zones, sauf les lacs,
  • Set<Zone> zones(), qui retourne l'ensemble de toutes les zones de la tuile, lacs compris.

Lisez les conseils de programmation qui suivent pour savoir ce que sont des ensembles, et comment écrire succinctement la méthode zones.

3.5.1. Conseils de programmation

  1. Ensembles

    Les méthodes sideZones() et zones() retournent toutes deux un ensemble de type Set<Zone>. Les ensembles seront examinés prochainement au cours, mais pour l'instant vous pouvez les voir comme quelque chose de similaire aux listes (List / ArrayList), à la différence près qu'ils n'admettent pas de doublons. Cela signifie que, lorsqu'on tente d'ajouter un élément à un ensemble au moyen de la méthode add, il n'y est effectivement ajouté que s'il ne s'y trouve pas déjà.

    Cette différence peut être illustrée au moyen de l'extrait de programme suivant :

    List<Integer> l = new ArrayList<>();
    l.add(1); l.add(2); l.add(1); l.add(3);
    
    Set<Integer> s = new HashSet<>();
    s.add(1); s.add(2); s.add(1); s.add(3);
    
    System.out.println("l = " + l);
    System.out.println("s = " + s);
    

    qui affiche les deux lignes suivantes :

    l = [1, 2, 1, 3]
    s = [1, 2, 3]
    

    qui illustrent bien le fait que l'élément 1 est présent deux fois dans la liste, mais une seule fois dans l'ensemble.

    Il y a encore beaucoup à dire concernant les différences entre les listes et les ensembles, mais pour cette étape, vous pouvez simplement voir les ensembles comme des listes sans doublons, et utiliser les équivalences de la table ci-dessous pour écrire votre code.

    Liste Ensemble
    List<…> Set<…>
    new ArrayList<>() new HashSet<>()
    l.add(…) s.add(…)
    l.addAll(…) s.addAll(…)

    Notez que la méthode addAll des ensembles — tout comme celle des listes — accepte aussi bien un ensemble qu'une liste en argument. Comme nous le verrons au cours, il s'agit en fait de la même méthode, définie dans l'interface Collection qui est implémentée aussi bien par les listes que par les ensembles.

    La raison pour laquelle les méthodes sideZones et zones retournent des ensembles et pas des listes est que chaque zone de la tuile ne doit apparaître qu'une fois dans le résultat, et les ensembles garantissent cela. Par exemple, la zone forêt de la tuile de départ touche les côtés est et sud de la tuile, mais elle ne doit néanmoins apparaître qu'une seule fois dans le résultat de sideZones et zones.

  2. Filtrage de motifs pour instanceof

    Une manière simple d'écrire la méthode zones consiste à parcourir l'ensemble des zones de bordures retourné par sideZones et, pour chaque rivière de cet ensemble, regarder si elle est connectée à un lac.

    Le parcours de l'ensemble retourné par sideZones peut se faire au moyen de la boucle for-each que vous connaissez déjà et qui fonctionne également pour les ensembles.

    Pour déterminer si une zone est une rivière, vous pourriez bien entendu utiliser instanceof puis un transtypage (cast), pour écrire quelque chose comme :

    Zone zone = …;
    if (zone instanceof Zone.River) {
      Zone.River river = (Zone.River) zone;
      // … utilisation de river
    }
    

    Toutefois, un nouveau concept a été ajouté à Java 17, qui se nomme le filtrage de motif pour instanceof (pattern matching for instanceof) et qui permet de récrire le code ci-dessus ainsi :

    Zone zone = …;
    if (zone instanceof Zone.River river) {
      // … utilisation de river
    }
    

    Comme on le voit, l'idée est de déclarer la variable river directement dans le contexte du instanceof, sans devoir faire de transtypage, ce qui simplifie le code.

    Depuis Java 21, le filtrage de motif est aussi disponible dans le contexte d'un switch, comme nous le verrons à l'étape suivante. De manière générale, le filtrage de motif est un concept très puissant, et nous vous recommandons donc vivement de vous entraîner à l'utiliser dès maintenant.

3.6. Enregistrement PlacedTile

L'enregistrement PlacedTile du paquetage principal, public, représente une tuile qui a été placée. Il possède les attributs suivants :

  • Tile tile, la tuile qui a été placée,
  • PlayerColor placer, le placeur de la tuile, ou null pour la tuile de départ,
  • Rotation rotation, la rotation appliquée à la tuile lors de son placement,
  • Pos pos, la position à laquelle la tuile a été placée,
  • Occupant occupant, l'occupant de la tuile, ou null si elle n'est pas occupée.

Le constructeur compact de PlacedTile valide les arguments en vérifiant que ni tile, ni rotation, ni pos ne sont égaux à null. (Attention : placer et occupant peuvent très bien l'être !) Il lève NullPointerException si ce n'est pas le cas.

De plus, PlacedTile possède un constructeur secondaire qui prend les mêmes arguments que le principal, dans le même ordre, sauf le dernier (occupant). Ce constructeur secondaire appelle le principal en lui passant les arguments reçus, et null comme dernier argument (occupant). Ce constructeur a pour but de faciliter la création de tuiles placées sans occupant.

En plus de ces attributs et des méthodes automatiquement générées par Java, PlacedTile possède les méthodes publiques suivantes :

  • int id(), qui retourne l'identifiant de la tuile placée,
  • Tile.Kind kind(), qui retourne la sorte de la tuile placée,
  • TileSide side(Direction direction), qui retourne le côté de la tuile dans la direction donnée, en tenant compte de la rotation appliquée à la tuile,
  • Zone zoneWithId(int id), qui retourne la zone de la tuile dont l'identifiant est celui donné, ou lève IllegalArgumentException si la tuile ne possède pas de zone avec cet identifiant,
  • Zone specialPowerZone(), qui retourne la zone de la tuile ayant un pouvoir spécial — il y en a au plus une par tuile — ou null s'il n'y en a aucune,
  • Set<Zone.Forest> forestZones(), qui retourne l'ensemble, éventuellement vide, des zones forêt de la tuile,
  • Set<Zone.Meadow> meadowZones(), qui retourne l'ensemble, éventuellement vide, des zones pré de la tuile,
  • Set<Zone.River> riverZones(), qui retourne l'ensemble, éventuellement vide, des zones rivière de la tuile,
  • Set<Occupant> potentialOccupants(), qui retourne l'ensemble de tous les occupants potentiels de la tuile, ou un ensemble vide si la tuile est celle de départ — qui se reconnaît au fait que son placeur est null,
  • PlacedTile withOccupant(Occupant occupant), qui retourne une tuile placée identique au récepteur (this), mais occupée par l'occupant donné, ou lève IllegalArgumentException si le récepteur est déjà occupé,
  • PlacedTile withNoOccupant(), qui retourne une tuile placée identique au récepteur, mais sans occupant,
  • int idOfZoneOccupiedBy(Occupant.Kind occupantKind), qui retourne l'identifiant de la zone occupée par un occupant de la sorte donnée (pion ou hutte), ou -1 si la tuile n'est pas occupée, ou si l'occupant n'est pas de la bonne sorte.

3.7. Enregistrement TileDecks

L'enregistrement TileDecks du paquetage principal, public et immuable, représente les tas des trois sortes de tuile qui existent — départ, normale, menhir. Il peut sembler étrange de considérer que l'unique tuile de départ fait partie d'un tas, mais cela permet de traiter toutes les tuiles de manière uniforme et simplifie le code de certaines étapes ultérieures.

TileDecks possède les attributs suivants :

  • List<Tile> startTiles, qui contient la tuile de départ (ou rien du tout),
  • List<Tile> normalTiles, qui contient les tuiles normales restantes,
  • List<Tile> menhirTiles, qui contient les tuiles menhir restantes.

La première tuile de chacune des listes est celle qui se trouve au sommet du tas correspondant, la seconde est celle se trouvant juste au-dessous, et ainsi de suite.

Le constructeur compact de TileDecks se charge de garantir l'immuabilité de la classe en copiant — au moyen de la méthode copyOf de List — chacune des trois listes reçues.

De plus, TileDecks offre les méthodes publiques suivantes :

  • int deckSize(Tile.Kind kind), qui retourne le nombre de tuiles disponibles dans le tas contenant les tuiles de la sorte donnée,
  • Tile topTile(Tile.Kind kind) qui retourne la tuile au sommet du tas contenant les tuiles de la sorte donnée, ou null si le tas est vide,
  • TileDecks withTopTileDrawn(Tile.Kind kind), qui retourne un nouveau triplet de tas égal au récepteur (this) si ce n'est que la tuile du sommet du tas contenant les tuiles de la sorte donnée en a été supprimée ; lève IllegalArgumentException si ce tas est vide,
  • TileDecks withTopTileDrawnUntil(Tile.Kind kind, Predicate<Tile> predicate), qui retourne un nouveau triplet de tas égal au récepteur sans les tuiles au sommet du tas contenant celles de la sorte donnée pour lesquelles la méthode test de predicate retourne faux (voir les conseils de programmation).

3.7.1. Conseils de programmation

La méthode subList peut être utile pour écrire la méthode withTopTileDrawn.

La méthode withTopTileDrawnUntil peut être utilisée pour supprimer du sommet d'un tas toutes les tuiles ne satisfaisant pas une condition donnée — le prédicat passé en second argument. Dans le contexte de ce projet, cette méthode est destinée à être utilisée pour éliminer les tuiles qui ne peuvent pas être placées. En effet, lorsqu'un joueur tire la prochaine tuile et qu'il est impossible de la placer, les règles précisent qu'elle doit être éliminée du jeu.

La condition que doit satisfaire la tuile au sommet du tas est passée sous la forme d'une valeur de type Predicate<Tile>. Predicate est une interface de la bibliothèque standard Java qui représente un prédicat, c.-à-d. une expression booléenne (vraie ou fausse). Elle ne possède qu'une seule méthode abstraite, nommée test, et sa déclaration (simplifiée) ressemble donc à ceci :

public interface Predicate<T> {
  boolean test(T t);
}

Dès lors, pour utiliser withTopTileDrawnUntil, il est possible de déclarer une classe implémentant cette interface, puis de lui en passer une instance en second argument. Par exemple, si on désirait supprimer du sommet d'un tas toutes les tuiles contenant plus d'une zone de bordure, on pourrait définir la classe suivante :

public final class TileHasOneZone implements Predicate<Tile> {
  @Override
  public boolean test(Tile tile) {
    return tile.zones().size() == 1;
  }
}

dont la méthode test retourne vrai ssi la tuile qu'on lui passe possède exactement une zone de bordure. On pourrait utiliser une instance de cette classe pour supprimer du tas des tuiles normales toutes celles du sommet ayant plus d'une zone (ou que le tas soit vide) ainsi :

TileDecks decks = …;
TileDecks decks1 =
  decks.withTopTileDrawnUntil(NORMAL, new TileHasOneZone());

Nous verrons plus tard au cours qu'une notation bien plus concise, nommée lambda, existe pour écrire ce genre de code. Ainsi, plutôt que de devoir définir la classe TileHasOneZone, on pourrait utiliser une lambda pour spécifier le second argument de withTopTileDrawnUntil, de la manière suivante :

TileDecks decks1 =
  decks.withTopTileDrawnUntil(NORMAL,
                              t -> t.zones().size() == 1);

Comme nous n'avons pas encore vu les lambdas au cours, vous pouvez pour l'instant définir des classes similaires à TileHasOneZone pour effectuer vos tests.

3.8. Vérification des signatures

Pour faciliter votre travail, nous mettons à votre disposition un fichier de vérification de signatures, nommé SignatureChecks_2.java, à importer dans votre projet dans le même dossier que celui contenant le fichier SignatureChecks_1.java. La classe qu'il contient fait référence à la totalité des classes et méthodes de cette étape, ce qui vous permet de vérifier que leurs noms et types sont corrects. Cela est capital, car la moindre faute à ce niveau empêcherait l'exécution de nos tests unitaires.

Nous vous fournirons de tels fichiers pour toutes les étapes jusqu'à la sixième (incluse), et il vous faudra penser à vérifier systématiquement qu'aucune erreur n'est signalée à leur sujet. Faute de cela, votre rendu pourrait se voir refusé par notre système.

3.9. Tests

À partir de cette étape, nous ne vous fournissons plus de tests unitaires, et il vous faut donc les écrire vous-même.

Notez que, pour les étapes 2 à 6, nous mettrons à disposition nos tests le lundi suivant le jour de rendu de chaque étape. Vous aurez alors tout intérêt à les incorporer à votre projet, ce qui peut poser un problème de nommage.

En effet, si vous nommez vos tests selon la convention standard, en ajoutant simplement le suffixe Test au nom de la classe testée, vos tests auront le même nom que les nôtres, et il ne vous sera pas possible d'avoir vos tests et les nôtres dans un même projet. Pour cette raison, nous vous recommandons d'adopter une autre convention de nommage pour vos tests, par exemple en entourant le nom de la classe testée au moyen du préfixe My et du suffixe Test. Ainsi, votre test pour la classe Tile pourrait être nommé MyTileTest.

4. Résumé

Pour cette étape, vous devez :

  • écrire les classes TileSide, Tile, PlacedTile et TileDecks selon les indications plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • rendre votre code au plus tard le 1er mars 2024 à 18h00, au moyen du programme Submit.java fourni et des jetons disponibles sur votre page privée.

Ce rendu est un rendu testé, auquel 18 points sont attribués, au prorata des tests unitaires passés avec succès.

N'attendez surtout pas le dernier moment pour effectuer votre rendu, car vous n'êtes pas à l'abri d'imprévus. Souvenez-vous qu'aucun retard, aussi insignifiant soit-il, ne sera toléré !