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 :
- un pré (meadow), qui peut contenir des animaux, ou
- une forêt (forest), qui peut contenir des menhirs ou des groupes de champignons, ou
- une rivière (river), qui peut contenir des poissons, ou
- 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.
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 :
- un pré qui touche le bord nord et constitue également le second pré entourant la rivière du bord ouest,
- une forêt, qui touche les bords est et sud,
- un petit pré dénué d'animaux, qui constitue le premier pré entourant la rivière du bord ouest,
- une rivière qui touche le bord ouest,
- 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.
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.
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.
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.
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.
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).
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.
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'exceptionIllegalArgumentException
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 :
- 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, - la méthode
of
de l'interfaceList
, 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 listeALL
, 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 deadd
, 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 suffixeCW
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)
retourneE
,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 detileCount
tuiles et comportantmushroomGroupCount
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 detileCount
tuiles et dans laquelle nagentfishCount
poissons,int forMeadow(int mammothCount, int aurochsCount, int deerCount)
, qui retourne le nombre de points obtenus par les chasseurs majoritaires d'un pré comportantmammothCount
mammouths,aurochsCount
aurochs etdeerCount
cerfs — les cerfs dévorés par des smilodons n'étant pas inclus dansdeerCount
,int forRiverSystem(int fishCount)
, qui retourne le nombre de points obtenus par les pêcheurs majoritaires d'un réseau hydrographique dans lequel nagentfishCount
poissons,int forLogboat(int lakeCount)
, qui retourne le nombre de points obtenus par le joueur déposant la pirogue dans un réseau hydrographique comportantlakeCount
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 comportantlakeCount
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
etim
, - un constructeur prenant en argument la valeur de ces attributs et les initialisant,
- des méthodes d'accès (getters) publics nommés
re()
etim()
pour ces attributs, - une méthode
equals
retournant vrai si et seulement si l'objet qu'on lui passe est aussi une instance deComplex
et que ses attributs sont égaux à ceux dethis
, - une méthode
hashCode
compatible avec la méthodeequals
— le but de cette méthode et la signification de sa compatibilité avecequals
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
sikind
estnull
,IllegalArgumentException
sizoneId
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 dedX
unités, et sa coordonnée y dedY
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 estzoneId
,int localId(int zoneId)
, qui retourne l'identifiant « local » de la zone dont l'identifiant estzoneId
, 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, ounull
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, ounull
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 attributlake
n'est pasnull
.
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
etZone
(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 variablesTOKEN_1
etTOKEN_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
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.
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.