Interface graphique II
Javass – étape 10

1 Introduction

Le but de cette étape est de terminer l'interface graphique commencée à l'étape précédente et d'écrire une classe permettant de la connecter au reste du programme.

1.1 Interface graphique

L'interface graphique réalisée lors de l'étape précédente n'est pas complète car il lui manque encore la section montrant la main du joueur. Pour mémoire, une fois terminée, cette interface devrait avoir l'apparence ci-dessous :

javass-screenshot.png

Figure 1 : L'interface graphique de Javass

La section montrant la main du joueur se trouve tout en bas de l'écran. Elle affiche les cartes de la main du joueur, triées de gauche à droite dans l'ordre usuel : d'abord par couleur (♠, ♥, ♦, ♣) puis par rang croissant.

Les cartes de la main sont plus ou moins bien visibles, en fonction de si elles sont jouables à l'instant actuel ou non : les cartes jouables sont visibles normalement, tandis que les autres ne le sont que partiellement. Cela signifie, entre autres, que toutes les cartes de la main ne sont que partiellement visibles lorsque ce n'est pas au joueur auquel correspond l'interface graphique de jouer.

Ainsi, en consultant l'image ci-dessus, on conclut que c'est au joueur auquel l'interface correspond — Ola — de jouer, et que les deux seules cartes qu'il peut jouer sont, selon les règles, le dix et la dame de pique.

Pour jouer une carte, le joueur clique simplement sur l'image de celle-ci.

1.2 Fils d'exécution

Comme cela est expliqué à la §5 des notes de cours sur les interfaces graphiques avec JavaFX, lorsqu'un programme est doté d'une interface graphique, il possède forcément une boucle événementielle. Cette boucle est une boucle infinie qui ne fait rien d'autre qu'attendre le prochain événement — une interaction entre l'utilisateur et l'interface —, le traite, et recommence.

Avec JavaFX, cette boucle événementielle est fournie par la bibliothèque et n'est donc pas directement visible au programmeur. Elle n'en existe pas moins, et son existence a des conséquences sur les programmes JavaFX, dont le nôtre, qu'il est important de comprendre.

Pour simplifier les explications qui suivent, focalisons nous sur un exemple concret : admettons qu'un joueur humain désire participer, depuis son ordinateur, à une partie se déroulant sur un autre ordinateur.

Pour ce faire, il faut qu'il exécute sur son ordinateur une version du serveur (RemotePlayerServer de l'étape 7). Ce serveur contient, comme nous l'avons vu, une seule méthode (run), qui consiste en une boucle infinie réagissant aux commandes envoyées par le client à travers le réseau.

En plus du serveur, il est probable que le joueur ait envie d'exécuter l'interface graphique pour jouer. Et celle-ci contient, nous l'avons vu, une autre boucle infinie : la boucle événementielle JavaFX réagissant aux interactions du joueur avec l'interface.

On semble ici être face à un problème insoluble : on désire à la fois exécuter le serveur et l'interface graphique dans le cadre d'un seul programme, or il est bien clair qu'un seul programme ne peut exécuter en même temps deux boucles infinies !

Comme d'autres langages, Java offre un concept permettant de résoudre ce genre de problèmes : le fil d'exécution (execution thread). Il s'agit d'un concept avancé, qui introduit une grande quantité de problèmes délicats et qui ne sera pas présenté dans le cadre du cours. Il est néanmoins nécessaire de comprendre les principales idées pour pouvoir utiliser les fils d'exécution dans ce projet.

Jusqu'à présent, les programmes Java écrits dans le cadre de ce cours, y compris dans le projet, ne possédaient qu'un seul fil d'exécution. Cela signifie que leur exécution se déroulait de manière séquentielle, selon les règles du langage.

En introduisant un second fil d'exécution, il est possible d'exécuter en même temps deux parties distinctes du programme. Cela est similaire à exécuter deux programmes séparés sur un seul ordinateur, mais avec une différence importante : deux fils d'exécution qui font partie du même programme ont accès aux mêmes données. Par exemple, un objet créé par le premier fil peut être utilisé par le second fil.

L'utilisation de données partagées par plusieurs fils d'exécution offre des possibilités intéressantes, mais introduit aussi énormément de problèmes délicats qui font de la programmation concurrente (c-à-d la programmation avec plusieurs fils d'exécution) un sujet fort complexe. Il convient donc d'être prudent lors du partage de données entre fils, et dans le cadre de ce projet nous vous donnerons des indications à ce sujet qu'il vous faudra respecter scrupuleusement.

Quoi qu'il en soit, le problème mentionné plus haut peut être résolu au moyen de fils d'exécution : il suffit qu'un premier fil exécute la boucle événementielle JavaFX et que, en même temps, un second fil exécute la boucle du serveur.

Le fil exécutant la boucle événementielle est créé automatiquement par JavaFX au démarrage de l'application, et est généralement appelé le fil d'application JavaFX (JavaFX application thread). A quelques exceptions près, la totalité du code interagissant avec l'interface graphique doit être exécutée par ce fil. De plus, c'est le fil sur lequel tous les gestionnaires d'événements sont exécutés par JavaFX.

Le fil exécutant la boucle du serveur sera quant à lui créé par vos soins, à l'étape suivante. Notez au passage que même lorsqu'un joueur humain joue à une partie uniquement sur son ordinateur, en compagnie de 3 joueurs simulés, deux fils d'exécution sont nécessaires : le premier exécutant la boucle événementielle JavaFX, le second appelant en boucle la méthode advanceToEndOfNextTrick de JassGame afin de faire progresser la partie.

Ces deux fils doivent communiquer entre eux pour deux raisons :

  1. le fil exécutant la boucle du serveur (ou de la partie elle-même) doit communiquer, à travers les propriétés des trois beans de l'étape 8, les changements à l'état de la partie au fil d'application JavaFX, afin que l'interface se mette à jour,
  2. le fil d'application JavaFX doit communiquer, d'une manière ou d'une autre, la carte sélectionnée par le joueur humain au fil du serveur (ou de la partie elle-même) lorsqu'il a pris sa décision.

Le premier type de communication est le plus simple à effectuer car, comme nous le verrons plus bas, JavaFX met à disposition la méthode runLater permettant de demander à ce qu'un morceau de code soit exécuté sur le fil d'application JavaFX. Cette méthode peut être utilisée pour effectuer toutes les modifications des propriétés des beans sur le fil d'application JavaFX, et c'est ce que nous ferons.

Le second type de communication est un (tout petit) peu plus problématique, car JavaFX ne met rien à disposition pour envoyer de l'information depuis le fil d'application JavaFX vers un autre fil. Toutefois, la bibliothèque Java offre un concept de queue bornée et bloquante, représenté par l'interface BlockingQueue.

Ce type de queue est conçu pour faciliter la communication entre deux fils d'exécution. Le premier fil, nommé le producteur (producer), produit des données et les place dans la queue au fur et à mesure. Le second fil, nommé le consommateur (consumer), consomme les données produites par le producteur en les extrayant de la queue.

Comme la queue a une capacité bornée, il est possible qu'elle soit pleine au moment où le producteur essaie d'y insérer une donnée, ou vide au moment où le consommateur essaie d'en extraire une donnée. Dans le premier cas, le producteur est bloqué automatiquement jusqu'à ce que le consommateur retire une donnée de la queue, libérant ainsi la place nécessaire à la nouvelle donnée. Dans le second cas, le consommateur reste bloqué tant et aussi longtemps que le producteur n'a pas placé dans la queue une donnée qu'il puisse consommer.

Au moyen d'une telle queue de capacité 1, il est très simple de faire en sorte que le fil d'application JavaFX (le producteur) communique la carte jouée par le joueur au fil du serveur (le consommateur).

2 Mise en œuvre Java

2.1 Classe GraphicalPlayerAdapter

La classe GraphicalPlayerAdapter, du paquetage ch.epfl.javass.gui, est un adaptateur permettant d'adapter l'interface graphique (c-à-d la classe GraphicalPlayer) pour en faire un joueur, c-à-d une valeur de type Player. Dans ce but, la classe GraphicalPlayerAdapter implémente l'interface Player.

En plus de servir d'adaptateur, la classe GraphicalPlayerAdapter s'assure que la communication entre le fil d'application JavaFX et l'autre fil — celui du serveur, ou celui de la partie — est faite correctement. Toutes les méthodes de cette classe ont pour but d'être appelées sur le second fil, et elles doivent donc s'assurer de communiquer correctement avec l'interface graphique, dont le code s'exécute lui sur le fil d'application JavaFX.

Pour cela, la classe GraphicalPlayerAdapter contient les attributs suivants :

  • les trois beans écrits lors de l'étape 8,
  • l'interface graphique, une instance de GraphicalPlayer,
  • la queue de communication entre les deux fils, une instance de ArrayBlockingQueue<Card> de capacité 1.

Elle redéfinit de plus toutes les méthodes de Player afin de communiquer correctement avec l'interface graphique. Dans la plupart des cas, cette communication se fait implicitement au travers des propriétés des beans, sauf dans le cas de la méthode cardToPlay qui utilise la queue de communication pour obtenir la carte jouée de l'interface graphique.

2.1.1 Conseils de programmation

Tous les attributs de GraphicalPlayerAdapter peuvent être créés dans le constructeur, à l'exception de l'interface graphique, qui ne peut être créée que lorsque la méthode setPlayers est appelée, étant donné qu'elle a besoin de l'identité du joueur auquel elle correspond, et du nom de tous les joueurs.

Notez que JavaFX autorise la création de la plupart des nœuds graphiques dans n'importe quel fil d'exécution, mais exige que les nœuds de type Stage soient créés sur le fil d'application JavaFX. Dès lors, l'appel à la méthode createStage de GraphicalPlayer doit obligatoirement être fait depuis le fil d'application JavaFX. Comme la méthode setPlayers ne sera justement pas appelée depuis ce fil-là, il faut qu'elle utilise la méthode runLater de JavaFX pour s'assurer que l'appel à createStage soit fait sur le bon fil. Cela peut se faire ainsi :

@Override
public void setPlayers(PlayerId ownId,
		       Map<PlayerId, String> playerNames) {
  graphicalPlayer = /* … */;
  runLater(() -> { graphicalPlayer.createStage().show(); });
}

puisque runLater garantit que le code de la lambda qu'on lui passe sera exécuté (plus tard, comme son nom l'indique, mais généralement très rapidement) sur le fil d'application JavaFX.

De manière similaire, JavaFX exige que les modifications apportées à l'interface graphique soient faites depuis le fil d'application JavaFX. Ces modifications sont faites de manière indirecte, via des liaisons aux propriétés des beans. Dès lors, les modifications à ces propriétés doivent être faites depuis le fil d'application JavaFX. En d'autres termes, les autres méthodes de Player redéfinies par GraphicalPlayerAdapter doivent elles aussi utiliser runLater afin de n'appeler les méthodes de modification des propriétés des beans que depuis le fil d'application JavaFX.

2.2 Classe GraphicalPlayer

La classe GraphicalPlayer commencée à l'étape précédente doit être augmentée ainsi :

  1. le constructeur doit être modifié pour prendre deux arguments de plus : le bean de la main et la queue de communication avec le fil secondaire,
  2. le code créant la section de la main doit y être ajouté.

2.2.1 Conseils de programmation

Tout comme pour l'étape précédente, nous vous conseillons vivement de placer la totalité du code créant la section de la main dans une méthode privée séparée, nommée p.ex. createHandPane. Cette méthode retourne un panneau JavaFX contenant la main du joueur, qui est placé dans la zone inférieure du panneau BorderPane constituant le panneau principal.

Le panneau de la main est de type HBox et son style est :

-fx-background-color: lightgray;
-fx-spacing: 5px;
-fx-padding: 5px;

Il possède exactement 9 fils, un par carte de la main, et chacun d'entre eux est une ImageView montrant l'image de la carte correspondante, ou rien du tout si la carte en question a été jouée.

Les images des cartes à utiliser pour la main sont celles de 160×240 pixels fournies à l'étape précédente. Toutefois, pour les raisons déjà évoquées, la taille à laquelle elles doivent être affichées est la moitié de leur taille réelle, à savoir 80×120 pixels.

Le choix de l'image à afficher dans chacune de ces 9 instances de ImageView se fait simplement au moyen d'une liaison à la propriété hand du bean de la main, selon une technique similaire à celle utilisée pour l'affichage des cartes du pli à l'étape précédente.

Afin que le joueur puisse choisir une carte en cliquant dessus, il suffit d'attacher un gestionnaire d'événement à chacune des ImageView représentant les cartes, au moyen de la méthode setOnMouseClicked. Ce gestionnaire ne fait rien d'autre que placer la carte correspondante dans la queue utilisée pour communiquer avec l'adaptateur.

Pour faire en sorte que l'image d'une carte ne soit que partiellement visible lorsqu'elle n'est pas jouable, il faut dans un premier temps créer une propriété booléenne locale, nommée p.ex. isPlayable, qui ne soit vraie que si la carte est jouable. Cela peut se faire facilement au moyen de la méthode createBooleanBinding de Bindings et des propriétés playableCards et hand du bean de la main.

Une fois la propriété isPlayable définie, il faut l'utiliser pour définir l'opacité de l'instance de ImageView représentant la carte, afin qu'elle vaille 1 si la carte est jouable, 0.2 sinon. Cela peut se faire en liant sa propriété opacityProperty à une expression valant 1 si la carte est jouable, 0.2 sinon. Celle-ci s'obtient de manière fort simple et élégante grâce à la méthode when de Bindings.

La propriété isPlayable peut de plus être utilisée pour activer et désactiver automatiquement l'instance de ImageView, via sa propriété disableProperty. Lorsqu'un nœud JavaFX est désactivé, il est insensible aux clics de souris, ce qui garantit que le gestionnaire attaché à chacune des images de cartes n'est appelé que si la carte correspondante est jouable. Son code en est ainsi simplifié !

2.3 Tests

Le programme principal ci-dessous, très basique, permet à un joueur humain de jouer contre 3 joueurs simulés. Si vous avez correctement mis en œuvre cette étape et les précédentes, tout devrait fonctionner !

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

  @Override
  public void start(Stage primaryStage) throws Exception {
    Map<PlayerId, Player> ps = new EnumMap<>(PlayerId.class);
    ps.put(PLAYER_1, new GraphicalPlayerAdapter());
    ps.put(PLAYER_2, new MctsPlayer(PLAYER_2, 123, 10_000));
    ps.put(PLAYER_3, new MctsPlayer(PLAYER_3, 456, 10_000));
    ps.put(PLAYER_4, new MctsPlayer(PLAYER_4, 789, 10_000));

    Map<PlayerId, String> ns = new EnumMap<>(PlayerId.class);
    PlayerId.ALL.forEach(i -> ns.put(i, i.name()));

    new Thread(() -> {
	JassGame g = new JassGame(0, ps, ns);
	while (! g.isGameOver()) {
	  g.advanceToEndOfNextTrick();
	  try { Thread.sleep(1000); } catch (Exception e) {}
	}
    }).start();
  }
}

Notez, à la fin de la méthode start, l'énoncé new Thread(…).start() qui permet de créer (au moyen du constructeur prenant un objet de type Runnable) puis de démarrer (au moyen de la méthode start) un nouveau fil d'exécution, qui est celui qui contient la boucle appelant advanceToEndOfNextTrick. L'argument au constructeur de Thread est passé sous la forme d'une lambda, Runnable étant une interface fonctionnelle.

3 Résumé

Pour cette étape, vous devez :

  • terminer la classe GraphicalPlayer et écrire la classe GraphicalPlayerAdapter (ou équivalent) en fonction des indications données plus haut,
  • 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.