Mise en place

ChaCuN – étape 1

1. Introduction

Cette première étape du projet ChaCuN a pour but de définir un certain nombre de classes représentant les principaux éléments du jeu : tuiles, animaux, pions, huttes, etc.

Comme toutes les descriptions d'étapes, celle-ci commence par une introduction aux concepts nécessaires à sa réalisation (§2), suivie d'une présentation de leur mise en œuvre en Java (§3).

Si vous travaillez en groupe, vous êtes toutefois priés de lire, avant de continuer, les guides Travailler en groupe et Synchroniser son travail, qui vous aideront à bien vous organiser.

2. Concepts

2.1. Tuiles

Une tuile (tile en anglais) est une petite carte carrée sur laquelle figurent différentes sortes de terrains : prés, forêts, rivières ou lacs. Comme expliqué dans l'introduction, les tuiles sont progressivement assemblées au cours de la partie pour former un paysage de plus en plus grand.

ChaCuN comporte un total de 95 tuiles, de 3 différentes sortes : une tuile de départ, 78 tuiles « normales » et 16 tuiles « menhir ». Chacune de ces tuiles est identifiée par un nombre compris entre 0 et 94. Par exemple, la tuile de départ est identifiée par le nombre 561.

2.2. Zones

Chaque tuile est composée d'une ou de plusieurs zones (zones), qui sont des régions connexes d'un même type. Chaque zone peut être :

  1. un pré (meadow), qui peut contenir des animaux, ou
  2. une forêt (forest), qui peut contenir des menhirs ou des groupes de champignons, ou
  3. une rivière (river), qui peut contenir des poissons, ou
  4. un lac (lake), qui peut également contenir des poissons.

Les lacs ont la particularité de se trouver à l'intérieur des tuiles, c.-à-d. qu'ils ne sont jamais en contact avec l'un des bords. Les autres types de zones, par contre, sont toujours en contact avec au moins un bord de la tuile.

Même si les lacs ne sont jamais en contact avec un bord de tuile, ils sont toujours connectés à au moins une rivière qui est, elle, en contact avec l'un d'entre eux.

Le concept de zone peut être illustré au moyen de la tuile de départ, qui en comporte cinq au total, dont les limites sont mises en évidence en rouge sur l'image ci-dessous.

zones_56;128.png
Figure 1 : Les cinq zones de la tuile 56 (tuile de départ)

En partant de la zone touchant le bord nord et en se déplaçant ensuite dans le sens des aiguilles d'une montre, ces cinq zones sont :

  1. un pré qui touche le bord nord et constitue également le second pré entourant la rivière du bord ouest,
  2. une forêt, qui touche les bords est et sud,
  3. un petit pré dénué d'animaux, qui constitue le premier pré entourant la rivière du bord ouest,
  4. une rivière qui touche le bord ouest,
  5. un lac connecté à cette rivière.

Les zones d'une tuile sont numérotées à partir de 0 en parcourant les bords de la même manière que ci-dessus — c.-à-d. dans le sens nord, est, sud, ouest. Comme aucune tuile ne comporte plus de 8 zones distinctes en contact avec un bord, ces zones sont numérotées de 0 à 7, et les numéros 8 et 9 sont réservés aux éventuels lacs — ce qui suffit car aucune tuile ne comporte plus de deux lacs. Lorsqu'une tuile comporte deux lacs, le numéro 8 est attribué à celui connecté à la rivière ayant le plus petit numéro.

Ainsi, le pré de la tuile de départ contenant l'animal porte le numéro 0, la forêt le numéro 1, le petit pré le numéro 2, la rivière le numéro 3, et le lac le numéro 8.

Un identifiant unique est attribué à chaque zone de chaque tuile, obtenu en multipliant par 10 l'identifiant de la tuile et en y ajoutant le numéro de la zone. Ainsi, la forêt de la tuile de départ possède l'identifiant 561.

La manière dont cet identifiant unique est construit permet de déterminer — par une simple division entière — le numéro de la tuile à laquelle appartient une zone, ce qui est souvent utile.

2.2.1. Prés

Un pré peut contenir un ou plusieurs animaux, chacun d'entre eux pouvant être :

  • un mammouth (mammoth), ou
  • un auroch (aurochs)2, ou
  • un cerf (deer), ou
  • un smilodon, aussi appelé « tigre à dents de scie » (saber-toothed tiger).

Comme nous le verrons ultérieurement, ces animaux rapportent des points aux joueurs ayant le plus grand nombre de chasseurs dans le pré.

Les quatre types d'animaux existants sont visibles sur les tuiles ci-dessous où se trouvent respectivement, de gauche à droite, un mammouth, un auroch, deux cerfs (un mâle couché, une femelle debout) et un smilodon.

board_01-animals.png
Figure 2 : Animaux des prés : mammouths, aurochs, cerfs et smilodons

Les animaux d'un pré sont numérotés 0 ou 1, car aucun pré ne possède plus de deux animaux. Lorsque deux animaux de type différent sont présents dans un même pré, ils sont numérotés en fonction de leur type, en commençant par les mammouths, puis les aurochs, les cerfs et enfin les smilodons. Si deux animaux d'un même type occupent un pré — comme sur la troisième tuile de la figure 2 — ils sont numérotés arbitrairement.

Un identifiant unique est attribué à chaque animal de chaque pré, obtenu en multipliant par 10 l'identifiant du pré auquel il appartient et en y ajoutant le numéro de l'animal — 0 ou 1. Ainsi, l'auroch de la tuile initiale est identifié par le numéro 5600.

Une fois encore, la manière dont cet identifiant est construit permet d'obtenir facilement l'identifiant du pré auquel appartient un animal dont on connaît l'identifiant. Bien entendu, il est ensuite possible d'obtenir l'identifiant de la tuile sur laquelle se trouve l'animal au moyen de l'identifiant de son pré.

2.2.2. Forêts

Une forêt peut contenir un menhir (menhir) ou un groupe de champignons (mushroom group), mais jamais les deux à la fois. La présence d'un menhir dans une forêt permet au joueur qui la termine de jouer une seconde tuile durant son tour, tandis que la présence d'un groupe de champignons rapporte des points supplémentaires aux cueilleurs majoritaires de la forêt.

Les trois types de forêts existants sont visibles sur les tuiles ci-dessous qui, de gauche à droite, comportent respectivement une forêt vide, une forêt contenant des menhirs, et une forêt contenant un groupe de champignons.

board_01-forests.png
Figure 3 : Forêt vide, avec menhir et avec un groupe de champignons

2.2.3. Zones aquatiques

Les zones aquatiques — rivières et lacs — peuvent contenir un certain nombre de poissons, qui permettent aux joueurs d'obtenir des points. Les deux tuiles ci-dessous montrent respectivement une rivière contenant un poisson et un lac en contenant deux.

board_01-fishes.png
Figure 4 : Un poisson dans une rivière, et deux dans un lac

2.2.4. Pouvoirs spéciaux

Six zones, toutes situées sur des tuiles menhir, possèdent un pouvoir spécial (special power). Leur fonctionnement détaillé sera décrit dans une étape ultérieure, mais un bref résumé — qui utilise parfois des concepts qui n'ont pas encore été introduits — est donné ci-dessous.

Trois pouvoirs spéciaux ont un effet immédiat, c.-à-d. que le joueur posant une tuile contenant une zone dotée d'un tel pouvoir spécial peut immédiatement effectuer une action, qui lui rapporte éventuellement des points. Ces pouvoirs sont :

  • le chaman (shaman), qui permet au joueur qui le pose de récupérer, s'il le désire, l'un de ses pions,
  • la pirogue (logboat), qui rapporte au joueur qui la pose un nombre de points dépendant du nombre de lacs accessibles à la pirogue,
  • la fosse à pieux (hunting trap), qui rapporte au joueur qui la pose un nombre de points dépendant des animaux présents sur les tuiles voisines.

Les tuiles contenant ces trois pouvoirs spéciaux sont visibles ci-dessous.

board_01-chaman-logboat-trap.png
Figure 5 : Le chaman, la pirogue et la fosse à pieux

Les trois autres pouvoirs spéciaux n'ont pas d'effet immédiat mais influencent le décompte final des points. Il s'agit de :

  • la grande fosse à pieux (pit trap), qui rapporte aux chasseurs majoritaires du pré la contenant des points supplémentaires pour les animaux présents sur les tuiles voisines de la fosse,
  • le feu (wild fire), qui fait fuir tous les smilodons du pré qui le contient, évitant ainsi qu'ils ne dévorent des cerfs,
  • le radeau (raft), qui rapporte aux pêcheurs majoritaires du réseau hydrographique le contenant des points additionnels dépendant du nombre de lacs qu'il contient.

Les tuiles contenant ces trois pouvoirs spéciaux sont visibles ci-dessous.

board_01-trap-fire-raft.png
Figure 6 : La grande fosse à pieux, le feu et le radeau

2.3. Occupants

Lorsqu'un joueur pose une tuile, il a également la possibilité de déposer un occupant (occupant) sur l'une des zones de la tuile.

Il y a deux sortes d'occupants, les pions (pawns) et les huttes (huts), qui jouent différents rôles en fonction du type de la zone sur laquelle ils sont placés :

  • un pion placé dans un pré est un chasseur (hunter),
  • un pion placé dans une forêt est un cueilleur (gatherer),
  • un pion placé dans une rivière est un pêcheur (fisher),
  • une hutte placée dans une zone aquatique est une hutte de pêcheur (fisher hut).

Ces occupants sont les seuls qu'il est valide de placer. Ainsi, il n'est pas autorisé de placer une hutte dans une forêt, ou un pion dans un lac.

Visuellement, les pions — indépendamment du rôle qu'ils jouent — sont représentés par des personnages portant une lance, tandis que les huttes sont représentées par des maisonnettes. Les occupants sont représentés de profil et coloriés en fonction du joueur auquel ils appartiennent (rouge, bleu, vert, jaune ou pourpre).

pawns-huts.svg
Figure 7 : Pions et huttes des différents joueurs

Les occupants permettent aux joueurs d'obtenir des points à différents moments de la partie, comme nous le verrons lors d'une étape ultérieure.

2.4. Plateau de jeu

Le jeu se joue sur un plateau carré comportant 25×25 cases, chacune d'entre elles pouvant accueillir une tuile. La case centrale du plateau est occupée par la tuile de départ.

Les cases du plateau sont identifiées par deux coordonnées, la première (x) identifiant la colonne, la seconde (y) identifiant la ligne. La case centrale, qui contient la tuile de départ, a les coordonnées (0, 0), tandis que ses quatre voisines ont les coordonnées suivantes :

  • celle du nord : (0, -1),
  • celle de l'est : (1, 0),
  • celle du sud : (0, 1), et
  • celle de l'ouest : (-1, 0).

En d'autres termes, les deux coordonnées augmentent dans le sens de lecture — de gauche à droite et de haut en bas.

2.5. Calcul des points

La manière exacte dont les points sont attribués aux joueurs sera décrite ultérieurement. Toutefois, la mise en œuvre de cette étape nécessite de déjà connaître le nombre de points obtenus dans différentes situations.

Souvent, les points obtenus le sont par les occupants majoritaires (majority occupants) d'un élément du paysage — pré, forêt, etc. Il s'agit du ou des joueurs possédant le plus grand nombre d'occupants dans cet élément du paysage, p. ex. le plus grand nombre de cueilleurs dans une forêt.

Ces points peuvent être obtenus en cours de partie, ou à la fin, comme décrit dans les deux sections suivantes. Certains termes utilisés, comme celui de « réseau hydrographique », ne seront expliqués en détail que dans une étape ultérieure, mais cela ne pose pas de problème pour la mise en œuvre de celle-ci.

2.5.1. En cours de partie

Lorsqu'une forêt est fermée, les cueilleurs majoritaires remportent 2 points par tuile composant la forêt, et 3 points par groupe de champignons qu'elle contient.

Lorsqu'une rivière est fermée, les pêcheurs majoritaires remportent 1 point par tuile composant la rivière, et 1 point par poisson nageant dans la rivière elle-même ou dans l'un des éventuels lacs aux extrémités.

Lorsqu'un joueur pose la tuile contenant la pirogue, il obtient 2 points par lac du réseau hydrographique dont elle fait partie.

2.5.2. En fin de partie

Les chasseurs majoritaires d'un pré remportent 3 points par mammouth, 2 par auroch et 1 par cerf qu'il contient. Toutefois, avant que les animaux ne soient comptés, chaque smilodon présent dans le pré dévore l'un de ses cerfs, qui ne rapporte alors aucun point.

Les propriétaires des huttes majoritaires d'un réseau hydrographique remportent 1 point par poisson présent dans ce réseau. De plus, si ce réseau contient le radeau, ils remportent également 1 point par lac qu'il contient.

3. Mise en œuvre Java

Les concepts importants pour cette étape ayant été introduits, il est temps de décrire leur mise en œuvre en Java. En plus des classes spécifiques à cette étape, une classe « utilitaire » est à réaliser : Preconditions, qui offre une méthode de validation d'argument.

Toutes les classes et interfaces de ce projet appartiendront au paquetage ch.epfl.chacun — qui sera désigné par le terme paquetage principal dans les descriptions d'étapes — ou à l'un de ses sous-paquetages.

Attention : jusqu'à l'étape 6 du projet (incluse), vous devez suivre à la lettre les instructions qui vous sont données dans l'énoncé, et vous n'avez pas le droit d'apporter la moindre modification à l'interface publique des classes, interfaces et types énumérés décrits.

En d'autres termes, vous ne pouvez pas ajouter des classes, interfaces ou types énumérés publics à votre projet, ni ajouter des attributs ou méthodes publics aux classes, interfaces et types énumérés décrits dans l'énoncé. Vous pouvez par contre définir des méthodes et attributs privés si cela vous semble judicieux.

Cette restriction sera levée dès l'étape 7.

3.1. Installation de Java et d'IntelliJ

Avant de commencer à programmer, il faut vous assurer que la version 21 de Java est bien installée sur votre ordinateur, car c'est celle qui sera utilisée pour ce projet.

Si vous ne l'avez pas encore installée, rendez-vous sur le site Web du projet Adoptium, téléchargez le programme d'installation correspondant à votre système d'exploitation (macOS, Linux ou Windows), et exécutez-le.

Cela fait, si vous n'avez pas encore installé IntelliJ, téléchargez la version Community Edition — et pas la version Ultimate qui apparaît au sommet de la page — depuis le site de JetBrains et installez-la.

3.2. Importation du squelette

Une fois Java et IntelliJ installés, vous pouvez télécharger le squelette de projet que nous mettons à votre disposition. Il s'agit d'une archive Zip dont vous devrez tout d'abord extraire le contenu à un emplacement de votre choix sur votre ordinateur — notez que certains navigateurs comme Safari extraient automatiquement le contenu de telles archives.

Une fois le contenu de l'archive extrait, vous constaterez qu'il se trouve en totalité dans un dossier nommé ChaCuN. Lancez IntelliJ, choisissez Open, sélectionnez ce dossier, et finalement cliquez Ok.

Le dossier ChaCuN contient les sous-dossiers suivants :

  • resources, destiné à contenir les « ressources » utiles au projet (images, etc.), et qui ne contient pour l'instant que les images des tuiles,
  • src, destiné à contenir le code source de votre projet, ainsi que divers fichiers que nous mettrons à votre disposition, et qui contient pour l'instant :
    • SignatureChecks_1.java, un fichier de vérification de signatures pour l'étape 1, qui ne devrait plus contenir d'erreur lorsque vous aurez terminé la rédaction de cette étape,
    • Submit.java, un programme qui vous permettra de rendre votre projet à la fin de chaque semaine,
  • test, destiné à contenir le code des tests unitaires de votre projet que nous vous fournirons ou que vous écrirez vous-même, et qui contient pour l'instant les tests de l'étape 1 — fournis exceptionnellement pour faciliter votre démarrage.

Le dossier resources doit être marqué comme « dossier de ressources » pour qu'il soit correctement géré par IntelliJ. Pour cela, faites un clic droit sur lui puis sélectionnez Mark Directory As puis Resources Root. Vérifiez ensuite que le panneau Project d'IntelliJ ressemble à l'image ci-dessous.

intellij-project-skeleton;32.png
Figure 8 : Projet IntelliJ après importation du squelette

3.3. Classe Preconditions

Fréquemment, les méthodes d'un programme exigent que leurs arguments satisfassent certaines conditions. Par exemple, une méthode déterminant la valeur maximale d'un tableau d'entiers exige que ce tableau contienne au moins un élément.

De telles conditions sont souvent appelées préconditions (preconditions) car elles doivent être satisfaites avant l'appel d'une méthode : c'est à l'appelant de s'assurer qu'il n'appelle la méthode qu'avec des arguments valides.

En Java, la convention veut que chaque méthode vérifie, autant que possible, ses préconditions et lève une exception — souvent IllegalArgumentException — si l'une d'entre elles n'est pas satisfaite. Par exemple, une méthode max calculant la valeur maximale d'un tableau d'entiers, et exigeant logiquement que celui-ci contienne au moins un élément, pourrait commencer ainsi :

int max(int[] array) {
  if (! (array.length > 0))
    throw new IllegalArgumentException();
  // … reste du code
}

(Notez au passage que la méthode max ne déclare pas qu'elle lève potentiellement IllegalArgumentException au moyen d'une clause throws, car cette exception est de type unchecked.)

La première classe à réaliser dans le cadre de ce projet a pour but de faciliter l'écriture de telles préconditions. En l'utilisant, la méthode ci-dessus pourrait être simplifiée ainsi :

int max(int[] array) {
  Preconditions.checkArgument(array.length > 0);
  // … reste du code
}

Cette classe est nommée Preconditions et appartient au paquetage principal. Elle est publique et finale et n'offre rien d'autre que la méthode checkArgument décrite plus bas. Elle a toutefois la particularité d'avoir un constructeur par défaut privé :

public final class Preconditions {
  private Preconditions() {}
  // … méthodes
}

Le but de ce constructeur privé est de rendre impossible la création d'instances de la classe, puisque cela n'a clairement aucun sens — elle ne sert que de conteneur à une méthode statique. Dans la suite du projet, nous définirons plusieurs autres classes du même type, que nous appellerons dès maintenant classes non instanciables.

La méthode publique (et statique) offerte par la classe Preconditions est :

  • void checkArgument(boolean shouldBeTrue), qui lève l'exception IllegalArgumentException si son argument est faux, et ne fait rien sinon.

3.4. Type énuméré PlayerColor

Le type énuméré PlayerColor du paquetage principal, public, énumère les couleurs associées aux joueurs et qui sont, dans l'ordre :

  • RED, pour le joueur de couleur rouge,
  • BLUE, pour le joueur de couleur bleue,
  • GREEN, pour le joueur de couleur verte,
  • YELLOW, pour le joueur de couleur jaune,
  • PURPLE, pour le joueur de couleur pourpre.

En plus de ces valeurs, le type énuméré PlayerColor offre l'attribut public, statique et final suivant :

  • List<PlayerColor> ALL, une liste immuable contenant la totalité des valeurs du type énuméré, dans leur ordre de définition (voir les conseils de programmation ci-dessous).

3.4.1. Conseils de programmation

La définition de l'attribut ALL peut vous poser quelques problèmes, car le concept de liste, représenté en Java par l'interface List, n'a pas encore été vu au cours. Toutefois, vous avez vu au premier semestre le concept de tableau dynamique (ArrayList), qui est une sorte de liste. Pour l'instant, vous pouvez donc admettre que le type List est synonyme du type ArrayList, et que les méthodes offertes par une valeur de type ArrayList le sont aussi par une valeur de type List.

Sachant cela, il n'est pas très difficile de définir l'attribut ALL en s'aidant des deux méthodes statiques suivantes :

  1. la méthode values définie pour tous les types énumérés et retournant un tableau contenant les éléments du type énuméré dans leur ordre de définition,
  2. la méthode of de l'interface List, qui retourne une liste immuable ayant les mêmes éléments que le tableau qu'on lui passe en argument.

L'extrait de programme ci-dessous, dont vous pouvez vous inspirer, montre comment la méthode of de List peut être utilisée pour obtenir une liste (seasons) des noms de saisons stockés dans un tableau (seasonsArray) :

String[] seasonsArray = new String[] {
  "printemps", "été", "automne", "hiver"
};
List<String> seasons = List.of(seasonsArray);

3.5. Type énuméré Rotation

Le type énuméré Rotation du paquetage principal, public, énumère les quatre rotations qu'il est possible d'appliquer à une tuile avant de la poser sur le plateau. Ces rotations sont, dans l'ordre :

  • NONE, qui correspond à une rotation nulle,
  • RIGHT, qui correspond à une rotation d'un quart de tour vers la droite (sens horaire),
  • HALF_TURN, qui correspond à une rotation d'un demi-tour,
  • LEFT, qui correspond à une rotation d'un quart de tour vers la gauche (sens antihoraire).

Tout comme PlayerColor, Rotation offre un attribut public, statique et final nommé ALL contenant la liste (immuable) de toutes les valeurs du type énuméré, dans l'ordre de définition. Elle offre de plus l'attribut public, statique et final suivant :

  • COUNT, qui contient le nombre d'éléments du type énuméré, qui est égal à la longueur de la liste ALL, c.-à-d. ALL.size().

Finalement, Rotation offre également les méthodes publiques suivantes :

  • Rotation add(Rotation that), qui retourne la somme de la rotation représentée par le récepteur (this) et l'argument (that),
  • Rotation negated(), qui retourne la négation de la rotation représentée par le récepteur — c.-à-d. la rotation qui, ajoutée au récepteur au moyen de add, produit la rotation nulle (NONE),
  • int quarterTurnsCW(), qui retourne le nombre de quarts de tours correspondant au récepteur (0, 1, 2 ou 3), dans le sens horaire — le suffixe CW signifie clockwise,
  • int degreesCW(), qui retourne l'angle correspondant au récepteur, en degrés, dans le sens horaire (0°, 90°, 180° ou 270°).

Notez bien que les méthodes quarterTurnsCW et degreesCW doivent retourner l'une des quatre valeurs mentionnées dans leur description, même si d'autres valeurs seraient mathématiquement équivalentes — comme –90° au lieu de 270°.

3.5.1. Conseils de programmation

Souvenez-vous qu'en Java, tous les types énumérés possèdent une méthode ordinal qui retourne la position de l'élément auquel on l'applique dans son type énuméré. Par exemple, appliquée à NONE, cette méthode retourne 0, appliquée à RIGHT elle retourne 1, et ainsi de suite.

L'existence de cette méthode, et l'ordre choisi pour définir les éléments de Rotation, rendent triviale la définition des méthodes quarterTurnsCW et degreesCW.

De plus, combinée aux constantes ALL et COUNT, ainsi qu'à l'opérateur de reste de la division entière de Java (%), ordinal simplifie aussi grandement la définition des méthodes add et negated.

3.6. Type énuméré Direction

Le type énuméré Direction du paquetage principal, public, énumère les directions correspondant aux quatre points cardinaux. Dans l'ordre, il s'agit de :

  • N, qui correspond au nord (haut de l'écran),
  • E, qui correspond à l'est (droite de l'écran),
  • S, qui correspond au sud (bas de l'écran),
  • W, qui correspond à l'ouest (gauche de l'écran).

De plus, Direction possède des attributs ALL et COUNT similaires à ceux de Rotation, ainsi que les méthodes publiques suivantes :

  • Direction rotated(Rotation rotation), qui retourne la direction correspondant à l'application de la rotation donnée au récepteur — p. ex. N.rotated(RIGHT) retourne E,
  • Direction opposite(), qui retourne la direction opposée à celle du récepteur.

3.7. Classe Points

La classe Points du paquetage principal, finale et non instanciable, offre des méthodes statiques permettant de calculer les points obtenus dans différentes situations. Il s'agit de :

  • int forClosedForest(int tileCount, int mushroomGroupCount), qui retourne le nombre de points obtenus par les cueilleurs majoritaires d'une forêt fermée constituée de tileCount tuiles et comportant mushroomGroupCount groupes de champignons,
  • int forClosedRiver(int tileCount, int fishCount), qui retourne le nombre de points obtenus par les pêcheurs majoritaires d'une rivière fermée constituée de tileCount tuiles et dans laquelle nagent fishCount poissons,
  • int forMeadow(int mammothCount, int aurochsCount, int deerCount), qui retourne le nombre de points obtenus par les chasseurs majoritaires d'un pré comportant mammothCount mammouths, aurochsCount aurochs et deerCount cerfs — les cerfs dévorés par des smilodons n'étant pas inclus dans deerCount,
  • int forRiverSystem(int fishCount), qui retourne le nombre de points obtenus par les pêcheurs majoritaires d'un réseau hydrographique dans lequel nagent fishCount poissons,
  • int forLogboat(int lakeCount), qui retourne le nombre de points obtenus par le joueur déposant la pirogue dans un réseau hydrographique comportant lakeCount lacs,
  • int forRaft(int lakeCount), qui retourne le nombre de points supplémentaires obtenus par les pêcheurs majoritaires du réseau hydrographique contenant le radeau et comportant lakeCount lacs.

Toutes ces méthodes lèvent IllegalArgumentException si leurs arguments ne satisfont pas les conditions suivantes :

  • un nombre de tuiles (tileCount) est toujours strictement supérieur à 1,
  • un nombre de lacs (lakeCount) est toujours strictement supérieur à 0,
  • un nombre de poissons (fishCount), d'animaux des prés (mammothCount, etc.) ou de groupes de champignons est toujours supérieur ou égal à 0.

Bien entendu, cette validation des arguments se fait au moyen de la méthode checkArgument de Preconditions.

3.8. Enregistrements Java

Avant de décrire Occupant, la prochaine classe à mettre en œuvre pour cette étape, il convient de décrire le concept de classe enregistrement (record class), ou simplement enregistrement (record), introduit dans la version 17 de Java.

Un enregistrement est un type particulier de classe qui peut se définir au moyen d'une syntaxe plus concise qu'une classe normale. Dans cette syntaxe, le mot-clef class est remplacé par record et les attributs de la classe sont donnés entre parenthèses, après son nom.

Par exemple, un enregistrement nommé Complex représentant un nombre complexe dont les attributs sont sa partie réelle re et sa partie imaginaire im peut se définir ainsi :

public record Complex(double re, double im) { }

Cette définition est équivalente à celle d'une classe finale (!) dotée de :

  • deux attributs privés et finaux nommés re et im,
  • un constructeur prenant en argument la valeur de ces attributs et les initialisant,
  • des méthodes d'accès (getters) publics nommés re() et im() pour ces attributs,
  • une méthode equals retournant vrai si et seulement si l'objet qu'on lui passe est aussi une instance de Complex et que ses attributs sont égaux à ceux de this,
  • une méthode hashCode compatible avec la méthode equals — le but de cette méthode et la signification de sa compatibilité avec equals seront examinés ultérieurement dans le cours,
  • une méthode toString retournant une chaîne composée du nom de la classe et du nom et de la valeur des attributs de l'instance, p. ex. Complex[re=1.0, im=2.0].

En d'autres termes, la définition plus haut, qui tient sur une ligne, est équivalente à la définition suivante, qui n'utilise que des concepts que vous connaissez déjà :

public final class Complex {
  private final double re;
  private final double im;

  public Complex(double re, double im) {
    this.re = re;
    this.im = im;
  }

  public double re() { return re; }
  public double im() { return im; }

  @Override
  public boolean equals(Object that) {
    // … vrai ssi that :
    // 1. est aussi une instance de Complex, et
    // 2. ses attributs re et im sont identiques.
  }

  @Override
  public int hashCode() {
    // … code omis car peu important
  }

  @Override
  public String toString() {
    return "Complex[re=" + re + ", im=" + im + "]";
  }
}

Comme cet exemple l'illustre, les enregistrements permettent d'éviter d'écrire beaucoup de code répétitif, ce que les anglophones appellent du boilerplate code. Il faut toutefois bien comprendre qu'en dehors d'une syntaxe très concise, les enregistrements n'apportent — pour l'instant en tout cas — rien de nouveau à Java, dans le sens où il est toujours possible de récrire un enregistrement en une classe Java équivalente, comme ci-dessus. En cela, les enregistrements sont similaires aux types énumérés.

Il est bien entendu possible de définir des méthodes dans un enregistrement, qui viennent s'ajouter à celles définies automatiquement. Par exemple, pour doter l'enregistrement Complex d'une méthode modulus retournant son module, il suffit de l'ajouter entre les accolades, ainsi :

public record Complex(double re, double im) {
  public double modulus() { return Math.hypot(re, im); }
}

(La méthode Math.hypot(x,y) retourne \(\sqrt{x^2 + y^2}\)).

Finalement, il est aussi possible de définir ce que l'on nomme un constructeur compact (compact constructor), qui augmente le constructeur que Java ajoute par défaut aux enregistrements. Un constructeur compact doit son nom au fait qu'il semble ne prendre aucun argument, et n'initialise pas explicitement les attributs. En réalité, il prend des arguments qui sont les mêmes que ceux de l'enregistrement (re et im dans notre exemple), et Java lui ajoute automatiquement des affectations de ces arguments aux attributs correspondants.

Par exemple, on pourrait vouloir ajouter un constructeur compact à la classe Complex pour lever une exception si l'un des arguments passés au constructeur était une valeur NaN (not a number, une valeur invalide). On pourrait le faire ainsi :

public record Complex(double re, double im) {
  public Complex {  // constructeur compact
    if (Double.isNaN(re) || Double.isNaN(im))
      throw new IllegalArgumentException();
  }
  // … méthode modulus
}

Ce constructeur compact serait automatiquement traduit en :

public final class Complex {
  // … attributs re et im

  public Complex(double re, double im) {
    if (Double.isNaN(re) || Double.isNaN(im))
      throw new IllegalArgumentException();
    this.re = re;  // ajouté automatiquement
    this.im = im;  // ajouté automatiquement
  }

  // … méthodes modulus, re, im, hashCode, etc.
}

Les enregistrements ne seront pas décrits en détail dans le cours, mais seront introduits au moyen d'exemples similaires à ceux ci-dessus dans la suite du projet. Les personnes intéressées par les détails de leur fonctionnement pourront se rapporter à la §8.10 (Record Classes) de la spécification du langage.

3.9. Enregistrement Occupant

L'enregistrement Occupant du paquetage principal, public, représente un occupant — pion ou hutte — d'une zone. Ses attributs sont :

  • Kind kind, la sorte d'occupant dont il s'agit (voir ci-dessous),
  • int zoneId, l'identifiant de la zone dans laquelle se trouve l'occupant.

Le type Kind est un type énuméré imbriqué à l'intérieur de l'enregistrement Occupant, qui a donc la structure suivante :

public record Occupant( /* attributs */ ) {
  public enum Kind { /* sortes d'occupants */ }
  // … constructeur, méthodes
}

Kind possède les éléments suivants :

  • PAWN, qui représente un pion,
  • HUT, qui représente une hutte.

Notez que la couleur du joueur auquel appartient l'occupant ne fait pas partie des attributs de Occupant, car elle peut se déduire d'autres informations, comme nous le verrons ultérieurement.

Occupant possède un constructeur compact dont le seul but est de valider les arguments qui lui sont passés, et qui lève :

  • NullPointerException si kind est null,
  • IllegalArgumentException si zoneId est strictement négatif.

Occupant offre également la méthode publique et statique suivante :

  • int occupantsCount(Kind kind), qui retourne le nombre d'occupants de la sorte donnée que possède un joueur — 5 pour les pions, 3 pour les huttes.

3.9.1. Conseils de programmation

Dans le constructeur compact, utilisez la méthode requireNonNull de Objects pour lever une NullPointerException lorsque kind est null.

3.10. Enregistrement Animal

L'enregistrement Animal du paquetage principal, public, représente un animal situé dans un pré. Il possède les attributs suivants :

  • int id, l'identifiant de l'animal,
  • Kind kind, la sorte d'animal dont il s'agit (voir ci-après).

Une fois encore, Kind est un type énuméré imbriqué à l'intérieur de l'enregistrement Animal, et qui possède les éléments suivants, dans l'ordre :

  • MAMMOTH, qui représente un mammouth,
  • AUROCHS, qui représente un auroch,
  • DEER, qui représente un cerf,
  • TIGER, qui représente un smilodon.

On pourrait imaginer doter Animal d'un constructeur compact chargé de valider les arguments, similaire à celui de Occupant. Afin de ne pas alourdir le code, nous avons toutefois décidé de ne pas le faire pour certaines classes comme Animal, ou les classes représentant les zones, dont toutes les instances sont créées au démarrage du programme — qui plus est par du code généré automatiquement, comme nous le verrons.

Animal possède une unique méthode publique :

  • int tileId(), qui retourne l'identifiant de la tuile sur laquelle se trouve l'animal.

3.10.1. Conseils de programmation

Pour écrire la méthode tileId, vous pouvez vous aider de la méthode statique tileId de Zone, décrite plus loin à la §3.12.

3.11. Enregistrement Pos

L'enregistrement Pos du paquetage principal, public, représente la position d'une case du plateau de jeu. Il possède les attributs suivants :

  • int x, la coordonnée x de la position,
  • int y, la coordonnée y de la position.

Pos possède un attribut public, final et statique :

  • Pos ORIGIN, qui contient la position de l'origine (0, 0), c.-à-d. la case centrale du plateau de jeu, qui contient la tuile de départ.

Elle offre de plus les méthodes publiques suivantes :

  • Pos translated(int dX, int dY), qui retourne la position obtenue en translatant la coordonnée x du récepteur de dX unités, et sa coordonnée y de dY unités,
  • Pos neighbor(Direction direction), qui retourne la position voisine du récepteur dans la direction donnée.

3.11.1. Conseils de programmation

Pour écrire la méthode neighbor, souvenez-vous qu'en Java un switch peut être utilisé pour distinguer les différents cas d'un type énuméré, et que ce switch peut avoir une valeur. Dès lors, il est possible d'écrire neighbor de manière concise et claire ainsi :

public Pos neighbor(Direction direction) {
  return switch (direction) {
    case N -> // … position du voisin nord
    // … autres cas
  }
}

3.12. Interface Zone

L'interface Zone du paquetage principal, publique, représente une zone d'une tuile. Elle est destinée à être implémentée par les classes représentant les différents types de zones — forêts, prés, etc. — décrites dans les sections suivantes.

Zone possède un type énuméré imbriqué nommé SpecialPower et représentant les six pouvoirs spéciaux qu'une zone peut posséder. Les éléments de ce type énuméré sont, dans l'ordre :

  • SHAMAN, qui représente le chaman,
  • LOGBOAT, qui représente la pirogue,
  • HUNTING_TRAP, qui représente la fosse à pieux,
  • PIT_TRAP, qui représente la grande fosse à pieux,
  • WILD_FIRE, qui représente le feu,
  • RAFT, qui représente le radeau.

Zone offre deux méthodes publiques et statiques :

  • int tileId(int zoneId), qui retourne l'identifiant de la tuile contenant la zone dont l'identifiant est zoneId,
  • int localId(int zoneId), qui retourne l'identifiant « local » de la zone dont l'identifiant est zoneId, c.-à-d. son numéro dans la tuile qui la contient, compris entre 0 et 9,

ainsi que la méthode (publique) abstraite suivante :

  • int id(), qui retourne l'identifiant de la zone,

et enfin les méthodes (publiques) par défaut suivantes :

  • int tileId(), qui retourne l'identifiant de la tuile contenant la zone,
  • int localId(), qui retourne l'identifiant « local » de la zone,
  • SpecialPower specialPower(), qui retourne le pouvoir spécial de la zone, ou null si elle n'en possède aucun — ce qui est ce que retourne cette méthode par défaut.

La raison pour laquelle specialPower est définie comme une méthode par défaut retournant null est que seuls les prés et les lacs peuvent avoir un pouvoir spécial. Les classes représentant les autres types de zones — forêts et rivières — peuvent donc hériter de la méthode par défaut qui retourne null.

3.12.1. Conseils de programmation

Pour écrire la version statique de tileId, ainsi que la méthode localId, souvenez-vous que l'identifiant d'une zone est obtenu en multipliant par 10 l'identifiant de la tuile qui la contient, et en y ajoutant le numéro de la zone — que nous appelons ici son identifiant local.

3.13. Enregistrement Zone.Forest

L'enregistrement Forest, imbriqué dans l'interface Zone, représente une zone de type forêt. Il implémente l'interface Zone et possède les attributs suivants :

  • int id, l'identifiant de la zone,
  • Kind kind, la sorte de forêt dont il s'agit (voir plus bas).

Notez bien que le fait que cet enregistrement — qui implémente l'interface Zone — possède un attribut id implique que Java fournit automatiquement une redéfinition de la méthode abstraite id() de Zone ! Soyez sûrs de bien comprendre cela, car nous utiliserons ce petit « truc » à de nombreuses reprises dans le projet.

Le type énuméré Kind, imbriqué dans Forest, énumère les trois types de forêts qui existent et qui sont, dans l'ordre :

  • PLAIN, une forêt vide (plain signifiant « simple » en anglais),
  • WITH_MENHIR, une forêt contenant au moins un menhir,
  • WITH_MUSHROOMS, une forêt contenant un groupe de champignons.

3.14. Enregistrement Zone.Meadow

L'enregistrement Meadow, imbriqué dans l'interface Zone, représente une zone de type pré. Il implémente l'interface Zone et possède les attributs suivants :

  • int id, l'identifiant de la zone,
  • List<Animal> animals, les animaux contenus dans le pré,
  • SpecialPower specialPower, l'éventuel pouvoir spécial du pré, qui peut bien entendu être inexistant (null).

Comme la plupart des classes de ce projet, Meadow doit être immuable. Nous verrons très prochainement au cours ce que cela signifie exactement, mais pour l'instant il vous suffit de savoir que cela implique de doter Meadow d'un constructeur compact qui copie, au moyen de la méthode copyOf de List, la liste des animaux reçue, ainsi :

animals = List.copyOf(animals);

Cette copie — souvent appelée copie défensive — garantit que les animaux d'un pré ne changent pas même si la liste d'animaux passée à son constructeur change.

3.15. Interface Zone.Water

L'interface Zone.Water, imbriquée dans l'interface Zone, représente une zone aquatique, c.-à-d. une rivière ou un lac. Elle étend l'interface Zone et lui ajoute la méthode abstraite (et publique) suivante :

  • int fishCount(), qui retourne le nombre de poissons nageant dans la zone.

Cette interface est destinée à être implémentée par les classes représentant des zones aquatiques, qui sont elles aussi des enregistrements, décrits ci-après.

3.16. Enregistrement Zone.Lake

L'enregistrement Lake, imbriqué dans l'interface Zone, représente une zone de type lac. Il implémente l'interface Zone.Water et possède les attributs suivants :

  • int id, l'identifiant de la zone,
  • int fishCount, le nombre de poissons nageant dans le lac,
  • SpecialPower specialPower, l'éventuel pouvoir spécial du lac, qui peut bien entendu être inexistant (null).

3.17. Enregistrement Zone.River

L'enregistrement River, imbriqué dans l'interface Zone, représente une zone de type rivière. Il implémente l'interface Zone.Water et possède les attributs suivants :

  • int id, l'identifiant de la zone,
  • int fishCount, le nombre de poissons nageant dans la rivière,
  • Lake lake, le lac auquel la rivière est connectée, ou null s'il n'y en a aucun.

En plus des méthodes automatiquement définies par Java pour les enregistrements, River possède la méthode publique suivante :

  • boolean hasLake(), qui retourne vrai si et seulement si la rivière est connectée à un lac — c.-à-d. si son attribut lake n'est pas null.

3.18. Interfaces scellées en Java

En Java, une interface peut normalement être implémentée par n'importe quelle classe. Par exemple, l'interface Zone déclarée ci-dessus pourrait très bien être implémentée par d'autres classes que celles déclarées à l'intérieur de Zone (Forest, Meadow, etc.).

Dans ce cas particulier, cela n'est toutefois pas souhaitable, car nous savons que les seuls types de zones qui existent dans le jeu sont ceux susmentionnés. Il serait donc bien de pouvoir communiquer cela à Java, afin d'interdire la définition d'autres classes implémentant l'interface Zone.

Cela peut se faire en scellant (seal) l'interface Zone, simplement en ajoutant le mot-clef sealed à l'interface, ainsi :

public sealed interface Zone { … }

Lorsqu'une interface est ainsi scellée, les seules classes qui ont le droit de l'implémenter sont celles se trouvant dans la même « unité de compilation », c.-à-d. le même fichier Java.

Nous verrons plus tard que le fait de déclarer les interfaces Zone et Zone.Water comme scellées sera très utile lorsque nous utiliserons du filtrage de motifs (pattern matching) pour manipuler les zones. Dès lors, pensez à sceller ces deux interfaces dès maintenant !

3.19. Tests

Pour vous aider à démarrer ce projet, des tests unitaires JUnit vous sont exceptionnellement fournis pour cette étape, et se trouvent dans le dossier test du squelette. Une fois que vous aurez terminé la rédaction des classes de cette étape, vous pourrez les exécuter en :

  • ajoutant JUnit à votre projet, comme expliqué dans notre guide à ce sujet,
  • effectuant un clic droit sur le dossier test du projet et sélectionnant Run 'All Tests'.

3.20. Documentation

Une fois les tests exécutés avec succès, il vous reste à documenter la totalité des entités publiques (classes, attributs et méthodes) définies dans cette étape, au moyen de commentaires Javadoc, comme décrit dans le guide consacré à ce sujet. Vous pouvez écrire ces commentaires en français ou en anglais, en fonction de votre préférence, mais vous ne devez utiliser qu'une seule langue pour tout le projet.

4. Résumé

Pour cette étape, vous devez :

  • installer la version 21 (et ni une plus récente, ni une plus ancienne !) de Java sur votre ordinateur, ainsi que la dernière version d'IntelliJ IDEA Community Edition (attention : n'installez pas la version Ultimate) ,
  • écrire les classes et interfaces Preconditions, PlayerColor, Rotation, Direction, Points, Occupant, Animal, Pos et Zone (et les éventuelles classes imbriquées qu'elles contiennent) selon les indications ci-dessus,
  • vérifier que les tests que nous vous fournissons s'exécutent sans erreur, et dans le cas contraire, corriger votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • (optionnel mais fortement recommandé) rendre votre code au plus tard le 23 février 2024 à 18h00, en exécutant le programme Submit fourni dans le squelette, après avoir modifié les définitions des variables TOKEN_1 et TOKEN_2 pour qu'elles contiennent les jetons individuels des deux membres du groupe, disponibles sur la page privée de chacun d'eux. (Les personnes travaillant seules doivent mettre leur jeton individuel dans les deux variables.)

Ce premier rendu n'est pas noté, mais celui de la prochaine étape le sera. Dès lors, il est vivement conseillé de faire un rendu de test cette semaine afin de se familiariser avec la procédure à suivre.

Notes de bas de page

1

Il aurait pu sembler plus logique d'attribuer l'identifiant 0 à la tuile de départ, plutôt que 56, mais ces identifiants ont été déterminés automatiquement en fonction de la position des tuiles dans les pages cartonnées fournies avec le jeu physique, et la tuile de départ y figure au milieu d'une page.

2

En anglais, auroch s'écrit aurochs avec un s à la fin, même au singulier. C'était aussi le cas en français jusqu'aux rectifications orthographiques du français de 1990, qui recommandent d'écrire simplement « auroch » au singulier.