Intelligence artificielle

Ajul – étape 8

1. Introduction

Le but principal de cette étape est d'écrire l'« intelligence artificielle », qui utilise l'algorithme MCTS pour jouer à Ajul. Son but secondaire est de commencer la rédaction des classes liées à l'interface graphique, en écrivant celles représentant les tuiles et les emplacements qu'elles peuvent occuper.

2. Concepts

2.1. Algorithme MCTS

Comme nous l'avons vu à l'étape précédente, notre « intelligence artificielle » pour Ajul est basée sur l'algorithme MCTS (Monte Carlo Tree Search). Cet algorithme détermine quel coup jouer à un instant de la partie en construisant un arbre de jeu partiel, puis en l'utilisant pour choisir le coup qui semble le meilleur pour le joueur courant.

Initialement, l'arbre de jeu partiel consiste en un unique nœud, la racine de l'arbre, qui correspond à l'état courant de la partie. Cet arbre de jeu est ensuite progressivement augmenté en effectuant en boucle les trois opérations suivantes :

  1. le prochain nœud à évaluer est déterminé en partant de la racine et en recherchant un nœud pour lequel aucune partie n'a encore été simulée,
  2. la fin de la partie est simulée aléatoirement à partir de ce nœud,
  3. les points obtenus par les joueurs à la fin de cette partie sont ajoutés aux nœuds se trouvant sur le chemin allant de la racine au nœud évalué.

Une fois que ces opérations ont été répétées un grand nombre de fois, le coup correspondant au fils de la racine ayant le meilleur score moyen est choisi comme étant celui à jouer. Cela est intuitivement sensé, car ce coup est celui qui a permis au joueur courant de remporter, en moyenne, le plus grand nombre de points au cours de parties simulées.

Les trois opérations énumérées ci-dessus, qui constituent le cœur de l'algorithme MCTS, sont détaillées dans les sections qui suivent.

2.1.1. Recherche du nœud à évaluer

La recherche du prochain nœud à évaluer commence toujours à la racine, et consiste à descendre dans l'arbre de jeu jusqu'à trouver un nœud qui :

  • n'a pas encore été évalué — c.-à-d. à partir duquel aucune partie n'a encore été simulée, ou
  • correspond à une partie terminée.

Si le nœud actuellement examiné ne satisfait aucune de ces deux conditions, la recherche se poursuit avec son enfant de plus haute priorité — la priorité des enfants étant calculée au moyen de la formule donnée à l'étape précédente. Si le nœud ne possède encore pas ses enfants à ce moment-là, alors ils sont immédiatement créés pour que la recherche puisse se poursuivre. C'est ainsi que l'arbre grandit petit à petit.

Pour mémoire, un nœud possède un enfant par coup unique valide qu'il est possible de jouer dans l'état de la partie auquel il correspond. Dès lors, pour pouvoir créer les enfants du nœud actuellement examiné, il est nécessaire de connaître l'état de la partie qui lui correspond. Comme cet état n'est pas stocké dans les nœuds, il doit être gardé séparément.

À première vue, cela n'est pas très difficile, sachant que l'état correspondant à la racine de l'arbre est connu, et qu'il peut être mis à jour lors de la descente dans l'arbre, en rejouant le coup associé à l'enfant sélectionné. Un petit problème se pose toutefois à la fin d'une manche, car pour continuer à construire l'arbre, il faut savoir quels sont les coups valides au début de la manche suivante, ce qui implique de remplir aléatoirement les fabriques. Or il n'est bien entendu pas possible de deviner, à ce stade, comment les fabriques seront remplies !

Il existe différentes manières de résoudre ce problème, mais nous choisirons la plus simple d'entre elles pour ne pas compliquer le projet. Elle consiste à simplement choisir un remplissage au hasard, avant de poursuivre l'évaluation comme si de rien n'était. Il faut toutefois bien faire attention à choisir le même remplissage à chaque visite du nœud, en utilisant à chaque fois un générateur aléatoire produisant les mêmes valeurs.

2.1.2. Simulation de la fin de la partie

Une fois le nœud à évaluer déterminé, une fin de partie est simulée aléatoirement à partir de l'état qui lui correspond. Lors de cette simulation, on fait l'hypothèse que tous les joueurs jouent un coup valide choisi selon l'heuristique décrite à l'étape précédente. Pour mémoire, elle consiste à choisir aléatoirement :

  • un des coups permettant de remplir totalement une ligne de motif, s'il y en a au moins un, sinon
  • un des coups permettant de remplir partiellement une ligne de motif, s'il y en a au moins un, sinon
  • un autre coup.

Lors de ces simulations de fin de partie, si une nouvelle manche doit commencer, les fabriques sont simplement remplies aléatoirement.

2.1.3. Calcul et propagation des points

Lorsque la partie a été simulée jusqu'à sa fin, les points à attribuer aux différents joueurs sont déterminés et ajoutés aux nœuds de l'arbre se trouvant sur le chemin allant de la racine de l'arbre au nœud depuis lequel la simulation a débuté — c.-à-d. les nœuds parcourus lors de la recherche du nœud à évaluer.

Il faut toutefois noter que les points que nous attribuerons aux différents joueurs ne seront pas simplement les points obtenus à la fin de la partie. En effet, comme nous l'avons dit à l'étape précédente, le but premier d'un joueur d'Ajul n'est pas de maximiser ses points, mais bien de minimiser son rang dans le classement final. Dès lors, pour évaluer le résultat d'une partie, nous utiliserons une notion plus générale de points, qui combine le rang et les points effectivement obtenus. Nous noterons \(P_j\) les « points généralisés » du joueur \(j\), et nous les calculerons ainsi :

\[ P_j = \overline{r_j} × 256 + p_j \]

où \(\overline{r_j}\) est le complément du rang et \(p_j\) les points obtenus par le joueur \(j\). Le complément du rang est simplement le rang maximum — qui vaut 1 pour une partie à 2 joueurs, 2 pour une partie à 3, et 3 pour une partie à 4 — moins le rang effectif.

Pour mémoire, le nombre maximal de points qu'il est possible d'obtenir à Ajul est 240, mais nous avons choisi ci-dessus de multiplier le complément du rang par 256 car cela peut être fait efficacement au moyen d'un décalage à gauche de 8 bits.

La table ci-dessous montre les valeurs produites par cette formule pour les différents joueurs de la partie donnée en exemple dans l'étape précédente. La colonne intitulée \(l_j\) rappelle le nombre de lignes horizontales complètes se trouvant dans les murs des différents joueurs, car cette valeur influence le calcul du rang.

Joueur \(j\) \(p_j\) \(l_j\) \(r_j\) \(\overline{r_j}\) \(P_j\)
1 45 3 3 0 45
2 55 2 0 3 823
3 55 1 2 1 311
4 55 2 0 3 823

Une fois les points généralisés de chaque joueur calculés, ils sont ajoutés à tous les nœuds se trouvant sur le chemin allant de la racine au nœud évalué, et le nombre de parties simulées associé à chacun de ces nœuds est incrémenté. La racine est traitée séparément, car si son nombre de parties simulées doit bien être incrémenté, aucun point ne doit lui être ajouté. En effet, les points associés à la racine ne jouent aucun rôle dans l'algorithme.

Lors de cette propagation des points, il faut faire attention à ajouter à un nœud donné les points correspondants au joueur associé au nœud — c.-à-d. le joueur qui a joué le coup stocké dans le nœud. Avec les informations que nous avons décidé de stocker dans les nœuds, il n'est en général pas possible de déterminer l'identité de ce joueur, et elle doit donc être stockée séparément, comme expliqué ci-après.

2.1.4. Mémorisation du chemin

Lors de la descente dans l'arbre à la recherche du nœud à évaluer, il faut se souvenir d'une manière ou d'une autre des nœuds qui ont été parcourus et de l'identité du joueur courant de l'état de partie correspondant à chacun de ces nœuds. Ces deux informations sont nécessaires pour pouvoir correctement mettre à jour les points associés à ces nœuds une fois la partie simulée jusqu'à sa fin, comme nous venons de le voir.

Bien entendu, ces informations pourraient être recalculées en effectuant une seconde fois le parcours de l'arbre à la recherche du nœud à évaluer, mais cela serait coûteux car il faudrait une nouvelle fois garder un état de partie à jour. Il est donc nettement préférable de les stocker lors du premier parcours de l'arbre, par exemple dans une liste.

2.2. Tuiles

Les tuiles — qu'il s'agisse des tuiles colorées ou du marqueur de premier joueur — sont l'élément central d'Ajul, puisqu'une partie consiste essentiellement à les déplacer sur le plateau de jeu. Les sections qui suivent décrivent la manière dont elles seront représentées et positionnées à l'écran.

2.2.1. Représentation graphique

Dans l'interface graphique, les tuiles sont représentées par de simples carrés colorés. À chacune des cinq couleurs de tuiles (A à E) correspond une couleur « réelle », avec laquelle le carré est rempli. Le marqueur de premier joueur est rempli avec une couleur différente des cinq autres, et un chiffre « 1 » apparaît en son centre, afin qu'il soit facile à distinguer des autres tuiles.

2.2.2. Positionnement

Les carrés représentant les tuiles doivent être placés à l'écran en fonction de l'endroit où se trouve la tuile à laquelle ils correspondent dans l'état de la partie. Par exemple, si le joueur P1 a une tuile de couleur A dans l'unique case de sa première ligne de motif, alors un carré de la bonne couleur doit être placé à l'écran à l'emplacement correspondant à cette case.

Pour décrire la position d'un carré représentant une tuile, il serait bien entendu possible de donner ses coordonnées à l'écran, en disant par exemple que son coin haut-gauche se trouve à la position (37, 59). Toutefois, il est souvent plus simple de décrire cette position de manière « logique », en disant par exemple qu'il se trouve dans la case d'index 2 de la troisième fabrique. En effet, une tuile ne peut occuper qu'un nombre fini d'emplacements différents sur le plateau de jeu, et il est souvent préférable de travailler avec ces emplacements plutôt qu'avec les positions à l'écran.

Pour une partie à \(n\) joueurs, le nombre d'emplacements distincts que peut occuper une tuile à l'écran est de \(61n + 8\), puisqu'il y a :

  • sur chacune des \(2n+1\) fabriques, 4 emplacements,
  • sur la zone centrale, \(1 + 3(2n+1) = 6n+4\) emplacements (voir l'étape 3),
  • sur chacun des \(n\) plateaux individuels des joueurs, 47 emplacements :
    • sur les lignes de motif, \(1+2+3+4+5=15\) emplacements,
    • sur le mur, \(5×5=25\) emplacements,
    • sur la ligne plancher, \(7\) emplacements.

Ainsi, il y a un total de 130 emplacements pour une partie à 2 joueurs, qui sont représentés sur la figure 1 ci-dessous par des carrés bordés de noir.

tile-locations;32.png
Figure 1 : Emplacements pour une partie à 2 joueurs

Les emplacements présentés dans la figure ci-dessus sont ceux que peut occuper une tuile qui se trouve sur le plateau. Or comme nous l'avons vu, un certain nombre de tuiles ne se trouve en général pas sur le plateau, mais dans le sac ou dans le couvercle de la boîte.

Afin de garantir que toutes les tuiles, y compris celles qui ne sont pas sur le plateau, occupent un emplacement distinct, nous considérerons qu'il existe également des emplacements hors du plateau. Plus précisément, nous admettrons qu'il en existe 20 pour chaque couleur de tuile, et 1 pour le marqueur de premier joueur.

2.2.3. Ancrage

Spécifier la position des carrés représentant les tuiles au moyen d'emplacements logiques, comme décrit ci-dessus, simplifie généralement les choses. Toutefois, il est clair qu'à un moment ou à un autre, ces positions logiques doivent être transformées en positions « physiques » afin de positionner les carrés à l'écran.

Pour ce faire, nous pourrions utiliser des formules nous permettant de calculer la position à l'écran correspondant à un emplacement donné, mais ces formules sont fastidieuses à déterminer. Il serait préférable d'avoir un moyen de laisser JavaFX — la bibliothèque que nous utiliserons pour gérer l'interface graphique — déterminer la position correspondant aux différents emplacements. La question est de savoir comment faire cela.

La solution que nous utiliserons consiste à placer, à chaque emplacement sur lequel une tuile doit pouvoir apparaître, une « fausse tuile », souvent invisible, dont la position est déterminée par JavaFX. Cela fait, pour placer une « vraie tuile » à un emplacement donné, il nous suffira de regarder à quelle position se trouve la fausse tuile correspondant à cet emplacement, et de placer la vraie juste au-dessus !

L'avantage de cette solution est qu'il nous suffit de construire un plateau de jeu dans lequel tous les emplacements sont occupés par des fausses tuiles et de laisser JavaFX calculer leur position. Et comme nous le verrons à l'étape suivante, cela est relativement aisé.

Nous appellerons ces fausses tuiles des ancres (anchors), car leur but est de servir de point d'ancrage aux vraies tuiles. Comme nous l'avons dit, ces ancres seront généralement invisible, leur seul but étant de « réserver de la place » sur le plateau pour les véritables tuiles. Cela est bien visible dans la figure 1 plus haut, qui ne montre en fait rien d'autre que la totalité des 130 ancres présentes sur le plateau d'une partie à 2 joueurs. Il est toutefois intéressant d'intégrer certaines ancres à la représentation du plateau de jeu en les rendant visible. C'est le cas des ancres qui se trouvent :

  • sur les murs, qui sont rendues visibles pour représenter les cases encore inoccupées du mur et indiquer du même coup la couleur de la tuile pouvant occuper chacune d'entre elles,
  • sur les lignes de motif, qui sont rendues visibles pour représenter les cases encore inoccupées,
  • sur les lignes plancher, qui sont rendues visibles dans le même but.

En d'autres termes, seules les ancres se trouvant sur les sources sont invisibles, les autres étant toutes visibles d'une manière ou d'une autre. Cela est illustré par la figure 2 ci-dessous, qui montre le plateau d'une partie à 2 joueurs sans aucune tuile.

visible-anchors;64.png
Figure 2 : Plateau de jeu dénué de tuiles

3. Mise en œuvre Java

La principale difficulté de cette étape est de rédiger la classe MctsPlayer mettant en œuvre l'intelligence artificielle. Cette classe n'est pas très longue — celle du corrigé comporte 85 lignes non vides — mais sa rédaction est loin d'être aisée. Avant de la commencer, assurez-vous de bien comprendre l'algorithme MCTS en faisant des dessins montrant la manière dont il explore et construit l'arbre de jeu.

3.1. Classe MctsPlayer

La classe MctsPlayer du sous-paquetage mcts, publique et finale, représente un joueur utilisant l'algorithme MCTS pour jouer à Ajul. Elle implémente donc l'interface Player.

MctsPlayer offre un unique constructeur public qui prend en arguments :

  • la « fabrique de générateur aléatoire » à utiliser pour créer les générateurs aléatoires nécessaires à MCTS, de type RandomGeneratorFactory,
  • le nombre d'itérations de la boucle décrite à la §2.1 à effectuer pour choisir le coup, de type int.

En dehors de ce constructeur, la classe MctsPlayer n'offre rien d'autre qu'une mise en œuvre concrète de la méthode nextMove de l'interface Player. Cette méthode utilise bien entendu l'algorithme MCTS pour déterminer quel coup jouer.

3.1.1. Conseils de programmation

Pour que l'exécution de l'algorithme MCTS soit aussi rapide que possible, il est important de créer une fois pour toutes, avant d'entrer dans la boucle principale de l'algorithme, tous les objets qui peuvent l'être, à savoir :

  • le tableau destiné à être passé à la méthode uniqueValidMoves pour déterminer les coups valides,
  • le tableau destiné à être passé à la méthode playersRank pour déterminer les rangs des joueurs,
  • le tableau destiné à contenir les nœuds parcourus lors de la recherche de celui à évaluer, et le joueur courant correspondant à chacun d'entre eux.

Le dernier de ces tableaux est un peu problématique à créer, car le type de ses éléments n'est pas évident, et sa taille maximale n'est pas connue d'avance.

Pour le type de ses éléments, nous vous suggérons d'utiliser un type entier et de représenter le nœud choisi par son index dans le tableau des enfants, et le joueur courant par son index — 0 pour P1, 1 pour P2, etc. Sachant que le nombre maximum de coups, et donc d'enfants, est de 240, le type byte suffit mais il faut prendre garde à interpréter toutes les valeurs de manière non signée, en utilisant au besoin la méthode Byte.toUnsignedInt !

Le fait que la taille de ce tableau ne soit pas connue d'avance suggère l'utilisation d'un « tableau dynamique » de type ArrayList<Byte>. Dans un premier temps, pour simplifier les choses, nous vous suggérons d'ailleurs de faire cela. Vous pourrez par la suite essayer d'utiliser à la place un tableau de type byte[] d'une taille initiale quelconque, disons 32, que vous « redimensionnerez » au besoin en copiant son contenu dans un tableau deux fois plus grand. Vous pourrez vous inspirer pour cela de la mise en œuvre de ArrayList, que nous examinerons vers la fin du semestre.

Pour créer les générateurs aléatoires nécessaires au remplissage des fabriques et au jeu aléatoire, utilisez la variante de la méthode create qui accepte en argument une graine (seed) qui détermine les valeurs générées. Prenez bien garde à utiliser :

  • toujours la même graine pour créer le générateur utilisé pour remplir les fabriques lors de la recherche du nœud à évaluer — par exemple en passant à create le coup empaqueté du nœud courant, qui ne change pas,
  • une graine quelconque pour créer le générateur utilisé pour simuler aléatoirement la fin de la partie, y compris pour remplir les fabriques lors de cette étape — par exemple en passant le nombre total de points du nœud à évaluer à create, qui varie au cours du temps mais est déterministe, ce qui peut faciliter le test et le débogage de votre programme.

3.2. Installation de JavaFX

Les classes restantes de cette étape sont les premières du projet à avoir un lien avec son interface graphique, ce qui a deux conséquences.

La première est que nous placerons ces classes dans un nouveau sous-paquetage du paquetage principal, nommé gui pour Graphical User Interface.

La seconde est qu'il vous faut installer la version 26 de JavaFX avant de commencer à rédiger ces classes. Pour cela, suivez tout d'abord les instructions données dans notre guide. Ensuite, dans le panneau Project d'IntelliJ, effectuez un clic droit sur le dossier src, puis choisissez New puis module-info.java afin d'ajouter un fichier nommé module-info.java au dossier src de votre projet.

Une fois ce fichier créé et ouvert par IntelliJ, cliquez sur le texte Fill in module dependencies qui apparaît dans un avertissement à son sommet. Votre fichier module-info.java devrait alors avoir le contenu suivant :

module Ajul {
  requires javafx.base;
  requires javafx.graphics;
}

L'existence de ce fichier fait de votre projet ce que l'on nomme une application modulaire (modular application), c.-à-d. une application utilisant le système de modules de Java. Ce système de modules a été introduit dans la version 9 du langage, est généralement peu utilisé, et ne sera pas examiné en détail dans le cadre de ce cours. Il permet toutefois de simplifier l'utilisation de JavaFX, raison pour laquelle nous avons choisi d'en faire usage1.

Au cours des étapes suivantes, vous devrez progressivement rajouter des lignes à votre fichier module-info.java, mais dans la plupart des cas IntelliJ se chargera de le faire, ou au moins de vous proposer de le faire. Vous pouvez néanmoins déjà ajouter ces lignes pour que votre fichier module-info.java ait le contenu suivant :

module Ajul {
  requires javafx.graphics;
  requires javafx.controls;
  requires java.net.http;
  exports ch.epfl.ajul.gui;
  exports ch.epfl.ajul.intarray;
  exports ch.epfl.ajul;
  exports ch.epfl.ajul.gamestate;
  exports ch.epfl.ajul.gamestate.packed;
  exports ch.epfl.ajul.mcts;
}

Notez que la ligne exports ch.epfl.ajul.gui provoquera une erreur tant et aussi longtemps que vous n'aurez pas créé une première classe dans le sous-paquetage gui, mais elle disparaîtra ensuite.

3.3. Interface TileLocation

L'interface TileLocation du sous-paquetage gui, publique et scellée, représente un emplacement qu'une tuile peut occuper. Elle ne contient rien d'autres que cinq enregistrements imbriqués, qui implémentent tous TileLocation et qui représentent les différents types d'emplacements qui existent, à savoir :

  1. un emplacement hors du plateau (dans le sac ou dans le couvercle de la boîte), représenté par un enregistrement nommé p. ex. OffBoard dont les attributs sont :
    • une sorte de tuile (de type TileKind) et
    • un index (de type int),
  2. un emplacement sur une source, représenté par un enregistrement nommé p. ex. OnSource dont les attributs sont :
    • la source en question (de type TileSource) et
    • l'index de l'emplacement dans la source (de type int),
  3. un emplacement sur une ligne de motif d'un joueur, représenté par un enregistrement nommé p. ex. OnPattern dont les attributs sont :
    • l'identité du joueur, de type PlayerId,
    • l'identité de la ligne de motif, de type TileDestination.Pattern,
    • l'index sur la ligne, de type int,
  4. un emplacement sur le mur d'un joueur, représenté par un enregistrement nommé p. ex. OnWall dont les attributs sont :
    • l'identité du joueur, de type PlayerId,
    • l'identité de la ligne de mur, de type TileDestination.Pattern,
    • la couleur de la tuile pouvant occuper l'emplacement, de type TileKind.Colored,
  5. un emplacement sur la ligne plancher d'un joueur, représenté par un enregistrement nommé p. ex. OnFloor dont les attributs sont :
    • l'identité du joueur, de type PlayerId,
    • l'index sur la ligne, de type int et compris entre 0 et 6.

Les index utilisés par certains de ces enregistrements permettent de distinguer les différentes positions que les tuiles peuvent occuper dans un conteneur donné. Ces index vont de 0 (inclus) à la taille du conteneur (exclu), et dans la plupart des cas, plus un index est grand et plus l'emplacement qu'il représente sera à droite de l'écran dans l'interface graphique.

Par exemple, l'extrait de programme ci-dessous crée une liste contenant les 4 emplacements de la première fabrique, dans l'ordre dans lequel ils apparaîtront à l'écran, de gauche à droite :

List<TileLocation> firstFactoryLocations = List.of(
  new TileLocation.OnSource(TileSource.FACTORY_1, 0),
  new TileLocation.OnSource(TileSource.FACTORY_1, 1),
  new TileLocation.OnSource(TileSource.FACTORY_1, 2),
  new TileLocation.OnSource(TileSource.FACTORY_1, 3));

3.4. Enregistrement Tiles

L'enregistrement Tiles du sous-paquetage gui, public, contient les nœuds JavaFX2 représentant la totalité des ancres et des tuiles. Il possède donc :

  • un attribut nommé p. ex. anchors contenant la totalité des ancres, stockées dans une table associant les nœuds JavaFX des ancres avec leur emplacement — donc de type Map<TileLocation, Node>,
  • un attribut nommé p. ex. tiles contenant la totalité des tuiles, stockées dans une table associant la liste des nœuds correspondant aux tuiles d'une certaine sorte à cette sorte — donc de type Map<TileKind, List<Node>>.

Tiles possède deux constantes — c.-à-d. deux attributs marqués public, static et final — nommées p. ex. TILE_WIDTH et TILE_HEIGHT et qui contiennent respectivement la largeur et la hauteur de la représentation graphique d'une tuile, en unités JavaFX, qui valent toutes les deux 30.

De plus, Tiles offre une méthode fabrique — donc statique — nommée p. ex. create, qui prend en argument une configuration de partie de type Game et retourne une instance de Tiles contenant les emplacements et tuiles correspondants.

Finalement, Tiles offre deux méthodes publiques et statiques permettant de manipuler l'emplacement associé à une ancre ou une tuile, à savoir :

  • une méthode, nommée p. ex. location, qui prend en argument le nœud JavaFX représentant une tuile ou une ancre, de type Node, et retourne son emplacement, de type TileLocation,
  • une méthode, nommée p. ex. setLocation, qui prend en argument le nœud JavaFX représentant une tuile ou une ancre, de type Node, ainsi qu'un emplacement de type TileLocation, et associe cet emplacement à la tuile.

Les conseils de programmation plus bas expliquent comment stocker l'emplacement associé à une tuile ou une ancre. Notez que la méthode create doit faire en sorte que l'emplacement associé à chaque ancre soit celui qui lui correspond, mais elle ne doit par contre associer aucun emplacement aux tuiles.

Les ancres et les tuiles colorées sont représentées par des instances de Rectangle. Le marqueur de premier joueur est quant à lui représenté par une instance de StackPane qui superpose une instance de Rectangle (l'arrière-plan) et une instance de Text (l'avant-plan) ayant le chiffre 1 comme texte. Tous les rectangles ont les mêmes dimensions, qui sont celles données par TILE_WIDTH et TILE_HEIGHT.

Comme nous le verrons par la suite, la présentation des différentes parties de l'interface graphique — les couleurs des différents éléments, la taille de leurs marges, etc. — sera déterminé au moyen de ce que l'on nomme une feuille de style (style sheet), que nous vous fournirons. Dès lors, ce n'est pas à vous d'assigner la bonne couleur de remplissage aux rectangles représentant les tuiles. Par contre, vous devez leur attacher différentes classes de style (style classes) qui seront utilisées par la feuille de style, à savoir :

  • toutes les instances de Rectangle représentant une tuile — marqueur de premier joueur inclus — ou une ancre doivent avoir la classe tile,
  • les ancres doivent de plus avoir la classe anchor,
  • les tuiles colorées doivent de plus avoir la classe ayant le même nom que leur couleur (A, B, C, D ou E).

Ces classes peuvent être attachées aux nœuds en modifiant (!) la liste retournée par leur méthode getStyleClass.

Finalement, l'instance de StackPane représentant le marqueur de premier joueur doit avoir l'identité (!) first-player-marker. L'identité d'un nœud JavaFX peut être définie au moyen de la méthode setId.

3.4.1. Conseils de programmation

Pour créer les ancres, vous devrez créer la totalité des emplacements existant sur le plateau, sous la forme d'instances de classes implémentant TileLocation. La programmation par flot convient particulièrement bien pour cela, et nous vous conseillons donc d'essayer de l'utiliser.

Pour mémoriser l'emplacement sur lequel se trouve une tuile ou une ancre, il serait bien entendu possible d'utiliser une table associative, mais JavaFX offre une solution plus simple. En effet, il est possible d'attacher un objet quelconque à un nœud au moyen de la méthode setUserData, et de l'obtenir plus tard au moyen de la méthode getUserData. C'est la technique que nous vous conseillons d'utiliser pour mettre en œuvre setLocation et location, même si cela implique l'utilisation d'un transtypage peu élégant.

3.5. Classe RelocationTransition

La classe RelocationTransition du sous-paquetage gui, publique et finale, représente une animation qui déplace un nœud JavaFX entre sa position actuelle et une autre position. Elle est destinée à être utilisée pour animer le déplacement des tuiles à l'écran.

Comme toutes les animations JavaFX, RelocationTransition hérite de Transition. Son constructeur prend en arguments :

  • le nœud JavaFX à déplacer, de type Node,
  • la position à laquelle le nœud doit se trouver à la fin de l'animation, de type Point2D,
  • la durée de l'animation, de type Duration.

En dehors de ce constructeur, RelocationTransition ne possède rien d'autre qu'une redéfinition de la méthode interpolate qui calcule la position du point se trouvant à la fraction donnée de la ligne droite allant de la position de départ à celle d'arrivée, puis y déplace le nœud au moyen de la méthode relocate.

3.5.1. Conseils de programmation

Comme expliqué dans la documentation de Transition, le constructeur doit appeler la méthode setCycleDuration avec la durée reçue.

De plus, le constructeur doit déterminer la position actuelle du nœud dans le système de coordonnées de son parent, afin de connaître sa position de départ. Une manière simple de l'obtenir est d'appeler la méthode localToParent avec Point2D.ZERO en argument.

Finalement, la redéfinition de la méthode interpolate doit calculer les coordonnées du point se trouvant à une fraction donnée entre cette position de départ et la position d'arrivée désirée, ce qui peut se faire facilement au moyen de la méthode interpolate de Point2D.

3.6. Tests

Pour tester votre intelligence artificielle, vous pouvez augmenter la version textuelle de votre jeu (AjulTUI), si vous l'avez écrite dans le cadre des étapes précédentes. En lui ajoutant la possibilité de jouer contre une instance de MctsPlayer, vous pourrez très facilement voir si celui-ci joue bien, et en un temps raisonnable.

Pour donner une idée, sur un ordinateur datant de 2020 et avec 100 000 itérations, l'intelligence artificielle prend environ 7 secondes pour jouer le tout premier coup d'une partie, et devient beaucoup plus rapide en fin de partie. Elle est de plus extrêmement difficile à battre.

4. Résumé

Pour cette étape, vous devez :

  • écrire les classes et interfaces MctsPlayer, TileLocation, Tiles et RelocationTransition selon les indications données plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.

Vous n'avez pas l'obligation de rendre cette étape avant le rendu final, mais nous vous conseillons néanmoins de le faire afin de sauvegarder votre travail.

Notes de bas de page

1

Il faut savoir qu'IntelliJ possède aussi une notion de module qui n'a malheureusement rien à voir avec celle de Java. Par exemple, lorsqu'on examine la structure d'un projet (project structure), les modules qui y figurent sont des modules au sens d'IntelliJ, pas de Java, et ils sont donc présents même en l'absence d'un fichier module-info.java.

2

Comme nous le verrons au cours, un nœud (node) JavaFX est un élément graphique qui peut apparaître à l'écran, comme un rectangle, une ligne, un bouton, etc. Tous les nœuds JavaFX héritent de la classe Node.