État observable

tCHu – étape 9

1. Introduction

Le but de cette étape est de commencer à écrire le code gérant l'interface graphique en réalisant d'une part une classe observable contenant l'état de la partie et d'autre part des classes construisant une partie de cette interface.

2. Concepts

2.1. État de jeu observable

Les classes écrites jusqu'à présent pour représenter l'état d'une partie de tCHu sont toutes immuables. Même si cette immuabilité a de nombreux avantages, elle est malheureusement incompatible avec la notion d'« observabilité » décrite par le patron Observer. En effet, les instances d'une classe immuable ne pouvant par définition pas changer d'état, observer leurs changements d'états n'a aucune utilité.

Cela pose problème lorsqu'on désire réaliser l'interface graphique, puisque la bibliothèque JavaFX que nous utiliserons pour cela se base justement sur le patron Observer. Il nous faut donc définir un objet observable contenant les différents éléments de l'état d'une partie de tCHu — ou en tout cas ceux qui doivent être représentés dans l'interface graphique. Le contenu de cet objet observable changera chaque fois qu'un nouvel état (immuable) sera produit au cours d'une partie.

Par exemple, les cinq cartes disposées face visible sont un élément de l'état d'une partie qui doit être représenté dans l'interface graphique. L'objet observable représentant l'état de la partie doit donc posséder cinq attributs observables — ce que JavaFX nomme des propriétés (properties) — contenant chacun l'une des cinq cartes. Au cours du déroulement d'une partie, chaque fois qu'un nouvel état (immuable) de jeu est produit, les cinq cartes du nouvel état son simplement copiées dans les cinq propriétés de l'objet observable.

Nous appellerons état (de jeu) observable (observable [game] state) l'objet observable contenant l'état d'une partie de tCHu. Il s'agit grosso modo d'une version observable de la classe GameState, mais avec quelques différences ayant pour but de simplifier la réalisation de l'interface graphique.

2.2. Interface graphique

La figure 1 ci-dessous montre l'interface graphique de tCHu, qui peut être découpée en quatre parties principales :

  1. au centre, la carte de la Suisse,
  2. à droite, les pioches de billets et de cartes, et les cartes disposées face visible,
  3. en bas, les billets et les cartes en main du joueur,
  4. à gauche, les statistiques des deux joueurs et les informations au sujet du déroulement de la partie.

Les trois premières de ces parties seront réalisées dans le cadre de cette étape, la dernière dans le cadre de l'étape suivante.

tchu-gui-parts;128.png
Figure 1 : Interface graphique de tCHu (cliquer pour agrandir)

2.2.1. Vue de la carte

La vue de la carte (map view) est la partie de l'interface portant le numéro 1 sur la figure 1 et montrant la carte de la Suisse sur laquelle la partie se déroule. Cette carte est constituée d'un fond, une image fixe présentée ci-dessous, au-dessus duquel les routes sont ensuite dessinées.

map.png
Figure 2 : Fond de carte (cliquer pour agrandir)

Une route est représentée par un nombre de cases égal à sa longueur. Chaque case est un rectangle coloré de la couleur de la route — ou en gris si la route n'a pas de couleur. Ce rectangle est entouré d'un trait plein s'il s'agit d'une route en surface, traitillé s'il s'agit d'un tunnel.

Lorsqu'une route appartient à l'un des joueurs, chacune des cases qui la compose est recouverte d'un rectangle de la couleur du joueur et de deux disques, symbolisant un wagon du propriétaire.

2.2.2. Vue des pioches

Nous appellerons vue des pioches (decks view) la partie de l'interface portant le numéro 2 sur la figure 1 et montrant, de haut en bas, la pioche des billets, les cinq cartes disposées face visible, et la pioche des cartes.

Les deux pioches sont représentées par de simples boutons intitulés Billets et Cartes. Sous chacun de ces intitulés figure une jauge donnant une idée du nombre de cartes restant dans la pioche. Le niveau de cette jauge est toujours relatif au nombre total de cartes dans le jeu : 46 pour les billets, 110 pour les cartes.

La figure ci-dessous montre par exemple le bouton représentant la pioche des billets en début de partie. Étant donné que 10 billets sur 46 ont déjà été distribués à ce stade, la jauge montre un taux de remplissage de 36/46, soit environ 78%.

tickets-gauge;32.png
Figure 3 : La pioche des billets en début de partie

2.2.3. Vue de la main

Nous appellerons vue de la main (hand view) la partie de l'interface portant le numéro 3 sur la figure 1 et montrant les billets et les cartes que le joueur auquel correspond l'interface a en main.

Dans la partie gauche de cette vue se trouve la liste des billets, qui sont chacun représentés textuellement de la manière décrite à la §2.4.5 de l'étape 1.

À droite de la liste des billets se trouvent les cartes wagon/locomotive. Elles apparaissent dans l'ordre utilisé dans l'énumération Card : wagon noir, puis wagon violet, puis wagon bleu, et ainsi de suite jusqu'à locomotive.

L'image représentant un type de carte donné n'est visible que lorsque le joueur en possède au moins une dans sa main. S'il en possède au moins deux, leur nombre apparaît de plus superposé à l'image de la carte. La figure ci-dessous montre un exemple d'une main contenant un total de six cartes.

cards-hand;32.png
Figure 4 : Une main contenant deux wagons noirs, trois violets et un bleu

2.3. Feuilles de style JavaFX

Pour réaliser l'interface graphique, nous utiliserons la bibliothèque JavaFX. Comme toute bibliothèque graphique, elle permet de contrôler l'apparence — taille, couleur, etc. — des différents éléments présents à l'écran. Ce contrôle peut s'effectuer de différentes manières, l'une d'entre elles consistant à utiliser des feuilles de style (style sheets), selon un principe similaire à celui utilisé pour contrôler l'apparence des pages Web.

Nous utiliserons exclusivement de telles feuilles de style pour contrôler l'aspect de l'interface graphique de tCHu. Les feuilles de style à utiliser vous sont distribuées dans le cadre de cette étape, ce qui simplifiera votre travail.

Le fonctionnement détaillé des feuilles de style ne sera pas décrit ici, car cela sort du cadre du cours. Une rapide introduction est toutefois nécessaire afin de pouvoir les utiliser correctement. Les personnes intéressées par plus de détails se rapporteront au document JavaFX CSS Reference Guide.

Une feuille de style JavaFX est composée d'un certain nombre de règles, chacune d'entre elles décrivant l'apparence d'un ou plusieurs éléments graphiques, que JavaFX nomme nœuds (nodes). Les nœuds auxquels s'applique une règle sont déterminés par le sélecteur (selector) attaché à la règle.

Les feuilles de style utilisées dans le cadre de ce projet utilisent principalement trois types de sélecteurs pour désigner les nœuds auxquels une règle s'applique. Chacun de ces sélecteurs utilise un attribut différent du nœud, qui est :

  1. leur identité (id), ou
  2. leurs classes de style (style classes), ou
  3. leur type Java.

L'identité d'un nœud JavaFX est une chaîne de caractères qui lui est attachée au moyen de la méthode setId. L'identité d'un objet doit permettre de l'identifier de manière unique dans un graphe de scène, c-à-d qu'il ne doit pas y avoir plus d'un nœud appartenant à un graphe de scène ayant une identité donnée.

Les classes des style d'un nœud JavaFX sont une liste (!) de chaînes de caractères attachées au nœud et que l'on peut obtenir au moyen de la méthode getStyleClass. La valeur retournée a le type ObservableList, et implémente entre autres le type List de la bibliothèque Java. Il est donc possible de manipuler les classes de style d'un nœud en utilisant les méthodes de List sur cette liste (add, remove, set, etc.).

Le type Java d'un nœud est simplement le type de la classe JavaFX représentant ce nœud, p.ex. Rectangle pour un rectangle.

Par exemple, une des feuilles de style que nous vous fournissons contient la règle suivante :

.track {
    -fx-stroke: dimgray;
    -fx-stroke-width: 1;
    -fx-stroke-type: outside;
}

Cette règle utilise le sélecteur .track signifiant qu'elle s'applique à tous les nœuds ayant track parmi leurs classes de style. Comme nous le verrons plus bas, cette classe de style est attachée aux rectangles représentant les cases des routes du réseau ferroviaire de tCHu.

En raison de cette règle, ces rectangles seront dessinés au moyen d'un trait gris foncé (-fx-stroke: dimgray), d'une largeur d'une unité (-fx-stroke-width: 1), placé à l'extérieur du rectangle (-fr-stroke-type: outside).

3. Mise en œuvre Java

Cette étape est probablement l'une des plus difficiles du projet car elle utilise un grand nombre de concepts nouveaux pour vous, principalement liés à JavaFX. Pour faciliter votre travail, nous vous conseillons donc de la réaliser en trois phases, chacune d'entre elles se concentrant sur un aspect précis :

  1. Au besoin, lisez les §2 et 3 des notes de cours sur JavaFX, décrivant le graphe de scène et les différents types de nœuds graphiques offerts par JavaFX. Ensuite, écrivez une version partielle des classes Map/DecksViewCreator décrites plus bas, en vous préoccupant uniquement de construire les graphes de scène des différentes vues. Après avoir importé les ressources (fichiers CSS, images) dans votre projet, ainsi que le programme de test donné à la §3.6, vérifiez que l'interface a bien l'aspect attendu. Notez que vous devrez pour cela commenter les parties du programme de test utilisant l'état du jeu observable, que vous n'aurez pas encore écrit à ce stade.
  2. Au besoin, lisez la §4 des notes de cours sur JavaFX, décrivant les propriétés et les liens. Ensuite, écrivez la classe ObservableGameState, puis augmentez les classes Map/DeckViewCreator afin d'établir les liens nécessaires entre les propriétés de l'état de jeu observable et celles des nœuds de votre interface graphique. Vérifiez, au moyen du programme de test, que votre interface se met correctement à jour lorsque l'état change.
  3. Au besoin, lisez la §5 des notes de cours sur JavaFX, décrivant la gestion des événements. Ensuite, écrivez l'interface ActionHandlers et terminez vos classes Map/DeckViewCreator afin de leur ajouter la gestion des événements. Vérifiez à nouveau le comportement de votre interface au moyen du programme de test.

Pour faciliter cette séparation en trois phases, les descriptions des mises en œuvre des classes Map/DeckViewCreator ci-dessous sont découpées de la même manière.

Bien entendu, chacune des trois phases susmentionnées mérite d'être réalisée petit à petit, afin de vérifier fréquemment que tout fonctionne. Par exemple, vous pourriez commencer la première phase en ne créant que la partie du graphe de scène permettant d'afficher le fond de carte, vérifier qu'il s'affiche, ajouter les routes, vérifier qu'elles s'affichent également, et ainsi de suite.

Notez que ci-dessous les classes sont décrites dans l'ordre de dépendance, en commençant donc par ObservableGameState que les autres classes utilisent. Nous vous conseillons néanmoins de suivre le découpage en trois phases décrit ci-dessus, et donc de commencer par MapViewCreator et DeckViewCreator.

3.1. Importation des ressources et installation de JavaFX

Avant de commencer cette étape, il vous faut importer dans votre projet le contenu d'une archive Zip que nous vous fournissons et qui contient :

  • cinq fichiers contenant des feuilles de style JavaFX, dont trois sont déjà utiles à cette étape (map.css, colors.css et decks.css), les deux autres n'étant utiles qu'à la suivante,
  • deux images représentant un wagon (train-car.png) et une locomotive (locomotive.png),
  • l'image de la carte de Suisse (map.png).

Ces fichiers constituent ce que l'on nomme les ressources (resources) du projet. Le répertoire resources dans lequel ces fichiers se trouvent doit être ajouté à votre projet comme d'habitude, en suivant les indications données dans nos guides pour IntelliJ (sans oublier le point 7, en marquant le répertoire comme Resources Root) ou pour Eclipse (en faisant bien attention aux points 5 à 7).

Une fois les ressources importées dans votre projet, il vous faut encore installer JavaFX sur votre ordinateur, et l'ajouter à votre projet. Pour ce faire, suivez les instructions de notre guide, qui couvre IntelliJ et Eclipse.

3.2. Classe ObservableGameState

La classe instanciable ObservableGameState du paquetage ch.epfl.tchu.gui représente l'état observable d'une partie de tCHu. Il s'agit d'un état combiné qui inclut :

  1. la partie publique de l'état du jeu, soit les informations contenues dans une instance de PublicGameState,
  2. la totalité de l'état d'un joueur donné, soit les informations contenues dans une instance de PlayerState.

En d'autres termes, une instance de ObservableGameState est spécifique à un joueur, qui est bien entendu celui auquel l'interface graphique utilisant cette instance correspond.

Le constructeur d'ObservableGameState prend en argument l'identité du joueur auquel elle correspond. À la création, la totalité des propriétés de l'état — décrites plus bas — ont leur valeur par défaut : null pour celles contenant un objet, 0 pour celles contenant un entier, false pour celles contenant une valeur booléenne.

Pour mettre à jour l'état qu'elle contient, ObservableGameState offre une méthode publique nommée p.ex. setState et prenant en argument la partie publique du jeu (de type PublicGameState) et l'état complet du joueur auquel elle correspond (de type PlayerState). Cette méthode met à jour la totalité des propriétés décrites ci-dessous en fonction de ces deux états.

Les nombreuses propriétés offertes par ObservableGameState sont toutes en lecture seule, c-à-d qu'il n'est pas possible de les modifier directement, mais seulement indirectement au moyen de setState. Les méthodes d'accès retournant les propriétés ont donc un type de retour désignant une propriété JavaFX en lecture seule, dont le nom commence généralement par ReadOnly — p.ex. ReadOnlyIntegerProperty pour les propriétés contenant un entier.

Les propriétés offertes par ObservableGameState peuvent être séparées en trois groupes : celles qui concernent l'état public de la partie, celle qui concernent l'état public de chacun des deux joueurs, et enfin celles qui concernent l'état complet du joueur auquel l'instance correspond.

Le premier groupe est celui des propriétés concernant l'état public de la partie, qui sont :

  • une propriété contenant le pourcentage de billets restant dans la pioche,
  • une propriété contenant le pourcentage de cartes restant dans la pioche,
  • cinq propriétés contenant, pour chaque emplacement, la carte face visible qu'il contient (voir l'exemple de la section suivante),
  • autant de propriétés qu'il y a de routes dans le réseau de tCHu et contenant, pour chacune d'entre elles, l'identité du joueur la possédant, ou null si elle n'appartient à personne.

Le pourcentage de billets et cartes restant dans les deux pioches est exprimé sous la forme d'un entier compris entre 0 et 100 (inclus).

Le second groupe est celui des propriétés concernant l'état public de chacun des joueurs, qui sont :

  • une propriété par joueur contenant le nombre de billets qu'il a en main,
  • une propriété par joueur contenant le nombre de cartes qu'il a en main,
  • une propriété par joueur contenant le nombre de wagons dont il dispose,
  • une propriété par joueur contenant le nombre de points de construction qu'il a obtenu.

Le troisième et dernier groupe est celui des propriétés concernant l'état privé du joueur auquel l'instance de ObservableGameState correspond, qui sont :

  • une propriété contenant la liste des billets du joueur,
  • neuf propriétés contenant, pour chaque type de carte wagon/locomotive, le nombre de cartes de ce type que le joueur a en main,
  • autant de propriétés qu'il y a de routes dans le réseau de tCHu et contenant, pour chacune d'entre elles, une valeur booléenne qui n'est vraie que si le joueur peut actuellement s'emparer de la route, c-à-d si :
    1. le joueur est le joueur courant,
    2. la route n'appartient à personne et, dans le cas d'une route double, sa voisine non plus,
    3. le joueur a les wagons et les cartes nécessaires pour s'emparer de la route — ou en tout cas tenter de le faire s'il s'agit d'un tunnel.

En plus des méthodes donnant accès à ces propriétés, ObservableGameState offre finalement des méthodes qui correspondent directement à des méthodes de PublicGameState ou PlayerState, et qui ne font rien d'autre que de les appeler sur l'état courant. De telles méthodes existent pour canDrawTickets et canDrawCards de PublicGameState, ainsi que pour possibleClaimCards de PlayerState.

3.2.1. Exemple

Pour clarifier la manière dont les différentes propriétés doivent être créées et gérées par ObservableGameState, cette section présente plus en détail le cas des propriétés contenant les cartes disposées face visible.

Pour commencer, étant donné que nous avons ici affaire à un groupe de (cinq) propriétés, celles-ci ne sont pas stockées comme des attributs individuels. Au lieu de cela, elles sont placées dans une collection, ici une liste, qui est elle stockée dans un attribut. Ce dernier peut être déclaré et initialisé ainsi :

private final List<ObjectProperty<Card>> faceUpCards =
  createFaceUpCards();

createFaceUpCards est une méthode statique privée retournant une liste de cinq propriétés qui sont des instances de SimpleObjectProperty<Card>. Bien entendu, ce code d'initialisation pourrait aussi être placé dans le constructeur.

Dans la méthode setState, le contenu de ces cinq propriétés est mis à jour en fonction du nouvel état reçu, que l'on suppose stocké dans newGameState :

for (int slot : FACE_UP_CARD_SLOTS) {
  Card newCard = newGameState.cardState().faceUpCard(slot);
  faceUpCards.get(slot).set(newCard);
}

Finalement, une méthode permet d'obtenir la propriété correspondant à un emplacement donné :

public ReadOnlyObjectProperty<Card> faceUpCard(int slot) {
  return faceUpCards.get(slot);
}

Notez que cette méthode est déclarée comme retournant une valeur de type ReadOnlyObjectProperty<Card> alors qu'elle retourne en fait une valeur de type ObjectProperty<Card>. Cela est valide car le second type est un sous-type du premier, et a l'avantage d'indiquer clairement à l'utilisateur de la méthode que la propriété n'est pas destinée à être modifiée.

Bien entendu, il est toujours possible de faire un « cast » de la valeur retournée en ObjectProperty<Card> puis de modifier son contenu, mais la probabilité de faire cela par erreur est infime. Sachant que notre but est d'éviter les erreurs involontaires, et pas les attaques d'utilisateurs malintentionnés, celui-ci semble atteint.

3.2.2. Conseils de programmation

Faites bien attention au fait que les propriétés JavaFX sont des objets observables, ce qui implique qu'ils doivent être créés une fois pour toutes au moment de la création de la classe. Ensuite, la valeur contenue dans ces propriétés doit être mise à jour à chaque appel de setState, mais les propriétés elles-mêmes doivent rester identiques.

JavaFX définit plusieurs hiérarchies de classes représentant chacune une propriété contenant une valeur d'un type donné. Par exemple, une propriété contenant un objet de type T est représenté par une hiérarchie de classes qui inclut, entre autres, les classes :

  • ReadOnlyObjectProperty<T>, représentant une propriété (abstraite) en lecture seule,
  • ObjectProperty<T>, représentant une propriété (abstraite) en lecture/écriture,
  • SimpleObjectProperty<T>, représentant une propriété concrète.

Ces classes sont données ici de haut en bas, c-à-d que chaque classe hérite, directement ou indirectement, des classes apparaissant avant elle.

Comme l'exemple de la section précédente l'illustre, il est conseillé d'utiliser :

  • le type en lecture seule (ReadOnly…) comme type de retour de la méthode d'accès, afin que l'appelant ne puisse modifier le contenu de la propriété par erreur,
  • le type « de base » dans le type de l'attribut,
  • la classe concrète (Simple…) pour créer l'instance.

En plus de ObjectProperty, JavaFX définit d'autres hiérarchies de classes représentant des propriétés spécialisées contenant des valeurs de type spécifique. Pour ObservableGameState, les hiérarchies suivantes sont utiles :

  • ReadOnly-, Simple- et BooleanProperty, pour les propriétés contenant une valeur booléenne, et
  • ReadOnly-, Simple- et IntegerProperty, pour les propriétés contenant un entier.

De plus, l'interface ObservableList, qui représente une liste observable, est utile pour stocker la liste des billets du joueur. Il est possible d'en créer une instance en utilisant la méthode observableArrayList de FXCollections.

3.3. Interface ActionHandlers

L'interface ActionHandlers du paquetage ch.epfl.tchu.gui a pour unique but de contenir cinq interfaces fonctionnelles imbriquées représentant différents « gestionnaires d'actions ».

Ce que nous appellerons un gestionnaire d'action (action handler) est un morceau de code à exécuter lorsque le joueur effectue une action. Par exemple, quand c'est à lui de jouer, un joueur peut effectuer trois actions différentes : tirer des billets, tirer des cartes, ou (tenter de) s'emparer d'une route. À chacune de ces trois actions correspond donc un gestionnaire d'action décrit ci-dessous.

Comme nous le verrons ultérieurement, les trois gestionnaires correspondant aux actions que le joueur peut effectuer durant un tour seront chacun stockés dans une propriété. Ces propriétés contiendront soit null, soit un gestionnaire d'action.

L'intérêt principal de cette solution est qu'elle permet de facilement exprimer le fait qu'à un moment donné un joueur peut ou non effectuer une action. Par exemple, s'il peut tirer des billets — car son tour vient de commencer et la pioche n'est pas vide — alors la propriété contenant le gestionnaire d'action correspondant au tirage des billets contient un gestionnaire valide. Par contre, si le joueur ne peut pas tirer de billets, alors cette même propriété contient null. En utilisant des liens JavaFX, il est facile de faire en sorte que le bouton représentant la pioche des billets soit désactivé lorsque cette propriété contient null.

Les deux autres gestionnaires d'action à définir correspondent aux actions à effectuer après que le joueur ait fait un choix durant son tour. Il y a deux situations durant lesquelles le joueur peut être amené à faire un tel choix :

  1. lorsqu'il a décidé de tirer des billets et doit choisir celui ou ceux qu'il garde,
  2. lorsqu'il a décidé de s'emparer d'une route et doit choisir les cartes initiales ou additionnelles à utiliser pour cela.

La raison pour laquelle il est intéressant d'avoir des gestionnaires d'action pour ces choix deviendra claire plus tard.

En résumé, les cinq interfaces fonctionnelles imbriquées dans ActionHandlers sont :

  • DrawTicketsHandler, dont la méthode abstraite, nommée p.ex. onDrawTickets et ne prenant aucun argument, est appelée lorsque le joueur désire tirer des billets,
  • DrawCardHandler, dont la méthode abstraite, nommée p.ex. onDrawCard et prenant un numéro d'emplacement (0 à 4, ou -1 pour la pioche), est appelée lorsque le joueur désire tirer une carte de l'emplacement donné,
  • ClaimRouteHandler, dont la méthode abstraite, nommée p.ex. onClaimRoute et prenant en argument une route et un multiensemble de cartes, est appelée lorsque le joueur désire s'emparer de la route donnée au moyen des cartes (initiales) données,
  • ChooseTicketsHandler, dont la méthode abstraite, nommée p.ex. onChooseTickets et prenant un multiensemble de billets en argument, est appelée lorsque le joueur a choisi de garder les billets donnés suite à un tirage de billets,
  • ChooseCardsHandler, donc la méthode abstraite, nommée p.ex. onChooseCards et prenant en argument un multiensemble de cartes, est appelée lorsque le joueur a choisi d'utiliser les cartes données comme cartes initiales ou additionnelles lors de la prise de possession d'une route ; s'il s'agit de cartes additionnelles, alors le multiensemble peut être vide, ce qui signifie que le joueur renonce à s'emparer du tunnel.

Aucune des méthodes de ces interfaces fonctionnelles ne retourne de valeur, c-à-d que leur type de retour est toujours void.

3.4. Classe MapViewCreator

La classe MapViewCreator du paquetage ch.epfl.tchu.gui, non instanciable et package private (!), contient une unique méthode publique, nommée p.ex. createMapView et permettant de créer la vue de la carte. Elle prend trois arguments, qui sont :

  1. l'état du jeu observable, de type ObservableGameState,
  2. une propriété contenant le gestionnaire d'action à utiliser lorsque le joueur désire s'emparer d'une route, de type ObjectProperty<ClaimRouteHandler>,
  3. un « sélectionneur de cartes », de type CardChooser, décrit ci-après.

Notez que ces arguments ne sont utiles que pour établir des liens entre propriétés ou gérer les événements, vous pouvez donc les ignorer lors la première phase, quand votre seul but est de construire le graphe de scène.

Le dernier argument a le type CardChooser, une interface fonctionnelle imbriquée dans la classe MapViewCreator et définie de la manière suivante :

@FunctionalInterface
interface CardChooser {
  void chooseCards(List<SortedBag<Card>> options,
		   ChooseCardsHandler handler);
}

La méthode chooseCards de cette interface est destinée à être appelée lorsque le joueur doit choisir les cartes qu'il désire utiliser pour s'emparer d'une route. Les possibilités qui s'offrent à lui sont données par l'argument options, tandis que le gestionnaire d'action handler est destiné à être utilisé lorsqu'il a fait son choix.

Par exemple, un sélectionneur de cartes choisissant systématiquement la première possibilité pourrait être défini ainsi :

CardChooser firstChoice = (options, handler) -> {
  handler.onChooseCards(options.get(0));
};

En pratique, il va de soi qu'un tel sélectionneur ne sera utilisé que pour les tests. Dans la version finale du projet, le sélectionneur — qui sera réalisé dans le cadre de la prochaine étape — présentera au joueur les options qui s'offrent à lui, et n'appellera la méthode onChooseCards que lorsqu'il aura fait son choix.

3.4.1. Graphe de scène

Une partie du graphe de scène de la vue de la carte est représentée sur la figure 5 plus bas. Chaque rectangle blanc représente un nœud JavaFX, et certains d'entre eux sont accompagnés d'annotations colorées qui donnent :

  • en vert, les feuilles de style CSS à attacher au nœud en modifiant la liste retournée par getStylesheets,
  • en rouge, l'identité des nœuds, à définir au moyen de la méthode setId,
  • en bleu, les classes de style à associer au nœud en modifiant la liste retournée par getStyleClass.

Comme cette image l'illustre, la vue de la carte est une instance de Pane.

Son premier fils est une instance de ImageView contenant le fond de carte. L'image que ce nœud affiche est déterminée par une règle du fichier map.css, et createMapView n'a donc rien à faire d'autre que le créer et le placer dans le graphe de scène.

Chacun des fils suivants — seul le premier est montré ici — est un groupe représentant une route. L'identité de ce groupe est celle de la route, p.ex. AT1_STG_1 dans la figure ci-dessous. Cette route, qui relie Saint-Gall à l'Autriche, est la première définie dans la classe ChMap.

Une route est composée à son tour de plusieurs groupes, un par case de la route. L'identité de chacun de ces groupes est constituée de l'identité de la route suivi d'un caractère souligné (_) puis du numéro de la case, qui va de 1 à la longueur de la route. Par exemple, la première case de la route AT1_STG_1, visible dans la figure ci-dessous, porte l'identité AT1_STG_1_1.

Chacune des cases est composée de la « voie » — le rectangle coloré visible lorsque personne ne s'est encore emparé de la route — et du wagon, le rectangle orné de deux disques blancs visible lorsqu'un joueur s'est emparé de la route. Notez que le wagon est toujours présent dans le graphe de scène, mais n'est visible que lorsque l'identité d'un joueur y est attachée, comme décrit à la section suivante.

map-pane-hierarchy;64.png
Figure 5 : Graphe de scène (partiel) de la vue de la carte

Les dimensions et positions des différentes formes géométriques du graphe de scène donné ci-dessus sont les suivantes :

  • les deux rectangles ont une largeur de 36 unités, et une hauteur de 12 unités,
  • les deux cercles ont un rayon de 3 unités, sont centrés verticalement dans le rectangle, et placés horizontalement à 6 unités de chaque côté de son centre.

Notez que lorsque vous créez les instances de Circle représentant les cercles, la position du centre que vous devez spécifier est relative au groupe. Dès lors, pour les placer comme demandé, il faut centrer le premier en (12, 6), le second en (24, 6).

Les groupes représentant les routes sont positionnés par la feuille de style map.css, qui contient par exemple la règle suivante :

#AT1_STG_1_1 {
    -fx-translate-x: 947;
    -fx-translate-y: 204;
    -fx-rotate: 20
}

Cette règle spécifie la position et l'orientation de la case donnée en exemple sur la figure 5. Cela implique que createMapView ne doit pas positionner ces éléments graphique, il lui suffit de les créer et de les placer dans le graphe de scène.

3.4.2. Liens et auditeurs

Lorsqu'un joueur s'empare d'une route, la classe correspondant à l'identité du joueur — PLAYER_1 pour le premier joueur, PLAYER_2 pour le second — doit être ajoutée au groupe représentant la route. Par exemple, si le second joueur s'emparait de la route d'identité AT1_STG_1, la classe PLAYER_2 devrait être ajoutée aux trois que le nœud Group portant la même identité dans la figure 5 possède déjà, à savoir route, UNDERGROUND et NEUTRAL.

Une manière simple de faire cela consiste à attacher un auditeur à la propriété de l'état de jeu observable contenant le propriétaire de la route. Dès que son contenu devient différent de null, la classe correspondant au propriétaire de la route est attachée au nœud. Sachant que le propriétaire d'une route ne peut pas changer, il n'est pas nécessaire de gérer le cas où la route aurait déjà un propriétaire.

Finalement, le groupe représentant une route ne doit être « activé » — selon la terminologie JavaFX — que lorsque le joueur a la possibilité de s'emparer de la route. Le fait qu'un nœud JavaFX soit activé signifie qu'il reçoit les événements comme les clics de la souris, qui sont justement utilisés par les joueurs pour s'emparer d'une route, comme expliqué à la section suivante.

Pour désactiver le groupe représentant une route lorsque le joueur ne peut pas s'en emparer, il suffit de lier sa propriété disableProperty à une propriété qui n'est vraie que lorsque :

  1. la propriété passée en argument à createMapView et contenant le gestionnaire d'action à utiliser lorsque le joueur désire s'emparer d'une route contient null, ou
  2. le joueur ne peut pas s'emparer de la route.

La méthode isNull de ObjectProperty, ainsi que les méthodes not et or de BooleanProperty permettent d'exprimer cela élégamment ainsi :

Group routeGroup = …;
routeGroup.disableProperty().bind(
  claimRouteHP.isNull().or(gameState.claimable(route).not()));

claimRouteHP est la propriété contenant le gestionnaire d'action à utiliser lorsque le joueur désire s'emparer d'une route, gameState est l'état observable du jeu, et la méthode claimable permet d'obtenir la propriété booléenne associée à la route et ne contenant vrai que si le joueur peut actuellement s'en emparer.

3.4.3. Gestionnaires d'événements

Lorsqu'un joueur clique sur un élément quelconque d'un groupe représentant une route, cela signifie qu'il désire s'en emparer — ou tenter de le faire dans le cas d'un tunnel.

Pour gérer cela, un gestionnaire de clic de souris doit être attaché, au moyen de setOnMouseClicked, au groupe représentant une route. Ce gestionnaire utilise la méthode possibleClaimCards de l'état de jeu observable pour déterminer la liste des ensembles de cartes que le joueur a la possibilité d'utiliser pour s'emparer de la route.

Si cette liste contient un seul ensemble de cartes, cela signifie que le joueur n'a pas le choix des cartes à utiliser pour s'emparer de la route, et la méthode onClaimRoute du gestionnaire d'action passé à createMapView peut être appelée avec la route et l'ensemble de cartes.

Par contre, si la liste contient plus d'un ensemble de cartes, alors le sélectionneur de cartes passé en argument à createMapView doit être utilisé pour choisir lequel de ces ensembles utiliser. Ce n'est que lorsque le joueur a fait son choix que la méthode onClaimRoute peut être appelée avec la route et l'ensemble de cartes choisi.

Le code à écrire pour gérer cette seconde situation est court mais non trivial, et il vous est donc donné ici pour faciliter votre travail :

// ci-dessous, cardChooser est le sélectionneur de cartes
Route route = …;
List<SortedBag<Card>> possibleClaimCards = …;
ClaimRouteHandler claimRouteH = …;
ChooseCardsHandler chooseCardsH =
  chosenCards -> claimRouteH.onClaimRoute(route, chosenCards);
cardChooser.chooseCards(possibleClaimCards, chooseCardsH);

Ce code fonctionne ainsi :

  1. la méthode chooseCards du sélectionneur de cartes est appelé par la dernière ligne, ce qui, dans la version finale du programme, provoquera l'apparition d'un dialogue demandant au joueur de choisir l'ensemble de cartes qu'il désire utiliser pour (tenter de) s'emparer de la route,
  2. lorsque le joueur aura fait son choix, le sélectionneur de cartes appellera la méthode onChooseCards du gestionnaire qu'on lui a passé en argument, qui est ici contenu dans la variable chooseCardsH,
  3. cette méthode, dont le code est celui de la lambda de l'avant-dernière ligne, appellera la méthode onClaimRoute du gestionnaire d'action utilisé lorsque le joueur désire s'emparer d'une route, contenu ici dans la variable claimRouteH,
  4. le gestionnaire d'action contenu dans claimRouteH fera le nécessaire pour que le joueur (tente de) s'emparer de la route.

Ne vous en faites pas si cela n'est pas encore clair pour vous, la rédaction de la prochaine étape devrait vous permettre de mieux comprendre le mécanisme utilisé.

3.5. Classe DecksViewCreator

La classe DecksViewCreator du paquetage ch.epfl.tchu.gui, non instanciable et package private (!), contient deux méthodes publiques :

  1. la première, nommée p.ex. createHandView, prend en argument l'état du jeu observable et retourne la vue de la main,
  2. la seconde, nommée p.ex. createCardsView, prend en arguments l'état de jeu observable et deux propriétés contenant chacune un gestionnaire d'action : la première contient celui gérant le tirage de billets, la seconde contient celui gérant le tirage de cartes.

Ces deux méthodes sont regroupées dans une classe car elles doivent toutes deux construire un graphe de scène représentant des cartes, et ont dès lors passablement de code en commun.

3.5.1. Graphe de scène

Le graphe de scène de la vue de la main est présenté dans la figure 6 ci-dessous.

La vue de la main elle-même est une instance de HBox dont le premier fils est l'instance de ListView montrant les billets.

Les fils suivants, au nombre de 9, sont chacun une instance de StackPane montrant les cartes d'une couleur donnée que le joueur a en main. Ces fils sont ordonnés dans le même ordre que l'énumération Card et sont identiques à un détail près : l'une des deux classes attachées à l'instance de StackPane correspond à la couleur de la carte, et varie donc. Le seul fils présenté ci-dessous est celui correspondant au premier type de carte, le wagon noir. Les cartes locomotive n'étant pas colorées, la classe NEUTRAL est attachée au dernier fils.

hand-pane-hierarchy;64.png
Figure 6 : Graphe de scène (partiel) de la vue de la main

Les trois rectangles de la figure ci-dessus représentent les trois composantes du dessin d'une carte wagon/locomotive. De haut en bas il s'agit de :

  1. le rectangle représentant la partie extérieure de la carte — le cadre arrondi —, qui a une largeur de 60 unités et une hauteur de 90 unités,
  2. le rectangle représentant la partie intérieure — colorée — de la carte, qui a une largeur de 40 unités et une hauteur de 70 unités,
  3. le rectangle contenant l'image du wagon ou de la locomotive, qui a les mêmes dimensions que le précédent.

Le graphe de scène de la vue des pioches est présenté dans la figure ci-dessous. Seule la première des cinq cartes face visible y est présentée. Le graphe de scène de ces cartes est identique à celui utilisé pour les cartes de la vue de la main, mais sans le compteur. Là aussi, la classe correspondant à la couleur de la carte – ou NEUTRAL pour les locomotives — est attachée à l'instance de StackPane à la racine de la portion du graphe de scène représentant la carte. Dans la figure ci-dessous, nous avons fait l'hypothèse que la première carte disposée face visible était rouge.

card-pane-hierarchy;64.png
Figure 7 : Graphe de scène (partiel) de la vue des pioches

Les boutons représentant les deux pioches sont accompagnés d'une jauge. Cette dernière est constituée d'un petit graphe de scène, présenté à la figure 8, attaché au bouton au moyen de la méthode setGraphic.

gauge-hierarchy;64.png
Figure 8 : Graphe de scène d'un bouton à jauge

Les deux rectangles représentant la jauge ont initialement une largeur de 50 unités et une hauteur de 5 unités. Bien entendu, la largeur du rectangle représentant l'avant-plan de la jauge — à droite sur la figure — varie en fonction du nombre de cartes présentes dans la pioche correspondante.

3.5.2. Liens et auditeurs

La portion du graphe de scène de la vue de la main correspondant à un type de carte donné ne doit être visible que si le joueur possède au moins une carte de ce type en main. Pour ce faire, il suffit de lier la propriété visibleProperty du nœud à la racine de cette portion du graphe de scène à une propriété qui n'est vraie que si le nombre de cartes de ce type en possession du joueur est supérieur à 0. Une telle propriété s'obtient très facilement au moyen de la méthode greaterThan de Bindings :

ReadOnlyIntegerProperty count = …;
StackPane card = …;
card.visibleProperty().bind(Bindings.greaterThan(count, 0));

L'instance de Text donnant la valeur du compteur doit toujours montrer la représentation textuelle du nombre de cartes du type donné présent dans la main du joueur — count dans l'extrait de programme ci-dessus. Pour ce faire, il suffit de lier la propriété textProperty de Text à une propriété contenant la représentation textuelle du nombre de cartes, qui s'obtient très facilement au moyen de la méthode convert de Bindings.

Finalement, cette instance de Text ne doit être visible que si le compteur est strictement supérieur à 1. Pour cela, il suffit de lier sa propriété visibleProperty à une propriété obtenue une fois encore au moyen de greaterThan.

La classe de style représentant la couleur d'une des cinq cartes face visible doit bien entendu changer lorsque la carte en question change. Cela peut se faire en attachant, au moyen de la méthode addListener, un auditeur à la propriété de l'état du jeu observable correspondant à l'emplacement. Lorsque cet auditeur est informé d'un changement, il modifie la classe de style du nœud afin de refléter le changement du type de carte.

Les boutons représentant les deux pioches, de même que les groupes représentant les cartes disposées face visible, ne doivent être activés que lorsque le joueur peut tirer des billets ou des cartes. Pour cela, il suffit de lier la propriété disableProperty de ces éléments à une propriété qui n'est vraie que si la propriété contenant le gestionnaire d'action correspondant contient null. Une fois encore, cela peut se faire au moyen de la méthode isNull offerte par ObjectProperty.

Finalement, la jauge attachée à chacune des pioches doit toujours avoir une taille proportionnelle au pourcentage des cartes qu'elle contient. La classe ObservableGameState offre des propriétés contenant ce pourcentage, sous la forme d'un entier compris entre 0 et 100, et il suffit donc d'ajuster la largeur de l'avant-plan de la jauge en fonction.

Sachant que la largeur maximale de l'avant-plan de la jauge est de 50 unités, la largeur correspondant à un pourcentage donné s'obtient en multipliant ce pourcentage par 50 puis en divisant le résultat par 100. Les propriétés JavaFX contenant des nombres, comme IntegerProperty, offrent des méthodes permettant d'obtenir des propriétés dérivées en effectuant un calcul sur leur contenu. Grâce à ces méthodes — multiply et divide ci-dessous — il est très facile d'ajuster la taille de la jauge :

Rectangle gaugeForeground = …;
ReadOnlyIntegerProperty pctProperty = …;
gaugeForeground.widthProperty().bind(
  percentageProperty.multiply(50).divide(100));

3.5.3. Gestionnaires d'événements

Lorsque le joueur clique sur le bouton représentant la pioche des billets, cela signifie qu'il désire tirer des billets. Il faut alors appeler la méthode onDrawTickets du gestionnaire d'action à utiliser pour le tirage de billets.

Lorsque le joueur clique sur l'une des cartes dont la face est visible, ou sur le bouton représentant la pioche des cartes, cela signifie qu'il désire tirer une carte de l'emplacement correspondant. Il faut alors appeler la méthode onDrawCards du gestionnaire d'action à utiliser pour le tirage de cartes, en lui passant le numéro de l'emplacement (ou -1 pour la pioche) en argument.

3.6. Tests

Cette étape est presque impossible à tester au moyen de tests unitaires, et nous vous conseillons donc une fois encore d'utiliser un programme de test et de vérifier manuellement qu'il se comporte comme attendu.

Le programme ci-dessous constitue un embryon de programme de test. Il s'agit d'une application JavaFX utilisant les classes de cette étape pour construire la partie de l'interface graphique qu'elles permettent de construire. Une fois l'interface construite, un état de jeu (immuable) est construit et placé dans l'objet représentant l'état de jeu observable.

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

  @Override
  public void start(Stage primaryStage) {
    ObservableGameState gameState = new ObservableGameState(PLAYER_1);

    ObjectProperty<ClaimRouteHandler> claimRoute =
      new SimpleObjectProperty<>(Stage9Test::claimRoute);
    ObjectProperty<DrawTicketsHandler> drawTickets =
      new SimpleObjectProperty<>(Stage9Test::drawTickets);
    ObjectProperty<DrawCardHandler> drawCard =
      new SimpleObjectProperty<>(Stage9Test::drawCard);

    Node mapView = MapViewCreator
      .createMapView(gameState, claimRoute, Stage9Test::chooseCards);
    Node cardsView = DecksViewCreator
      .createCardsView(gameState, drawTickets, drawCard);
    Node handView = DecksViewCreator
      .createHandView(gameState);

    BorderPane mainPane =
      new BorderPane(mapView, null, cardsView, handView, null);
    primaryStage.setScene(new Scene(mainPane));
    primaryStage.show();

    setState(gameState);
  }

  private void setState(ObservableGameState gameState) {
    PlayerState p1State =
      new PlayerState(SortedBag.of(ChMap.tickets().subList(0, 3)),
		      SortedBag.of(1, Card.WHITE, 3, Card.RED),
		      ChMap.routes().subList(0, 3));

    PublicPlayerState p2State =
      new PublicPlayerState(0, 0, ChMap.routes().subList(3, 6));

    Map<PlayerId, PublicPlayerState> pubPlayerStates =
      Map.of(PLAYER_1, p1State, PLAYER_2, p2State);
    PublicCardState cardState =
      new PublicCardState(Card.ALL.subList(0, 5), 110 - 2 * 4 - 5, 0);
    PublicGameState publicGameState =
      new PublicGameState(36, cardState, PLAYER_1, pubPlayerStates, null);
    gameState.setState(publicGameState, p1State);
  }

  private static void claimRoute(Route route, SortedBag<Card> cards) {
    System.out.printf("Prise de possession d'une route : %s - %s %s%n",
		      route.station1(), route.station2(), cards);
  }

  private static void chooseCards(List<SortedBag<Card>> options,
				  ChooseCardsHandler chooser) {
    chooser.onChooseCards(options.get(0));
  }

  private static void drawTickets() {
    System.out.println("Tirage de billets !");
  }

  private static void drawCard(int slot) {
    System.out.printf("Tirage de cartes (emplacement %s)!\n", slot);
  }
}

Après avoir terminé la phase de construction du graphe de scène, vous pouvez modifier ce programme pour en supprimer tous les aspects liés à la gestion de l'état observable, et le lancer. Vous devriez alors obtenir une interface ressemblant à celle ci-dessous.

stage9-gui-1;128.png
Figure 9 : Interface après construction du graphe de scène

Notez que les cartes disposées face visible sont en noir car la classe donnant leur couleur n'a pas été ajoutée à ce stade.

La vidéo ci-dessous montre la manière dont le programme de test devrait se comporter une fois la totalité de l'étape terminée. Notez que la fenêtre au-dessous de celle de tCHu est la console d'IntelliJ, et montre les messages affichés par le programme. De plus, des cercles rouges apparaissent lors des clics de la souris, mais ils ont été ajoutés après coup pour faciliter la compréhension, et ne seront donc pas visibles chez vous.

En plus de ce programme de test, nous vous encourageons à écrire du code de déboguage vous permettant de vérifier certains aspects de votre mise en œuvre. Par exemple, la méthode dumpTree ci-dessous prend en argument un nœud JavaFX et affiche des informations au sujet de ce nœud et de tous ses descendants :

public static void dumpTree(Node root) {
  dumpTree(0, root);
}

public static void dumpTree(int indent, Node root) {
  System.out.printf("%s%s (id: %s, classes: [%s])%n",
		    " ".repeat(indent),
		    root.getTypeSelector(),
		    root.getId(),
		    String.join(", ", root.getStyleClass()));
  if (root instanceof Parent) {
    Parent parent = ((Parent) root);
    for (Node child : parent.getChildrenUnmodifiable())
      dumpTree(indent + 2, child);
  }
}

Par exemple, appliquée au nœud représentant la route reliant Saint-Gall à l'Autriche, dont l'identité est AT1_STG_1, cette méthode affiche :

Group (id: AT1_STG_1, classes: [route, UNDERGROUND, NEUTRAL])
  Group (id: AT1_STG_1_1, classes: [])
    Rectangle (id: null, classes: [track, filled])
    Group (id: null, classes: [car])
      Rectangle (id: null, classes: [filled])
      Circle (id: null, classes: [])
      Circle (id: null, classes: [])

qui est une forme textuelle de la hiérarchie présentée à la figure 5.

4. Résumé

Pour cette étape, vous devez :

  • écrire les classes et interfaces ObservableGameState, ActionHandlers, MapViewCreator et DecksViewCreator selon les indications données ci-dessus,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.

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