Index de l'article

Préambule :

  • Tout ce tutoriel est basé sur un ordinateur avec une distribution type debian/ubuntu, un SDK Java du type openjdk 11 ou 17, et l'IDE Intellij IDEA.
  • Ces hypothèses sont valables notamment pour les machines du département.
  • Si vous utilisez votre propre ordinateur sous windows/mac, les procédures d'installation logicielle seront relativement semblables mais avec des différences notables, par exemple dans les chemins d'accès aux éléments installés.

 

Le jeu qui sert de base à ce tutoriel a été inventé pour l'occasion mais s'inspire d'autres jeux similaires. C'est un jeu de placement où chaque joueur dispose de quatre pions rouge ou noir, numérotés de 1 à 4. Les pions doivent être posés dans les cases d'une grille de taille 3x3. Quand tous les pions sont posés, il y a donc une case libre.

Les règles de placement sont :

  • au début de la partie, le premier joueur pose un de ses pions sur n'importe quelle case,
  • ensuite, à chaque tour de jeu, le joueur courant doit poser un pion sur une case adjacente, par les côtés et/ou par les coins, à une (ou plusieurs) cases déjà occupées, avec comme règle :
    • si le ou les pions dans les cases adjacentes par les côtés ont une valeur pair, le joueur ne peut poser qu'un pion de valeur impaire.
    • si le ou les pions dans les cases adjacentes par les côtés ont une valeur impair, le joueur ne peut poser qu'un pion de valeur paire.
    • si le ou les pions dans les cases adjacentes par les coins ont une valeur pair, le joueur ne peut poser qu'un pion de valeur paire.
    • si le ou les pions dans les cases adjacentes par les coins ont une valeur impair, le joueur ne peut poser qu'un pion de valeur impaire.

 

Ce principe de placement assure que tous les pions puissent être posés et que le trou sera soit dans la case centrale, soit dans un des 4 coins.

En fin de partie, le gagnant est :

  • soit celui qui a le moins de pions, au sens strict, autour du trou, en prenant UNIQUEMENT les cases adjacentes par les côtés.
  • soit en cas d'égalité, celui dont la somme des pions autour du trou est minimale.
  • s'il y a encore égalité, il n'y a pas de gagnant

Exemples :

  2/4  
 2/4 1 2/4
  2/4  

Le premier joueur à posé son premier pion (valeur 1) au centre : le deuxième joueur peut seulement poser son pion de valeur 2 ou 4 dans les cases marquées 2/4.

 

1/3        1/3 
  1  
 1/3   1/3

Le premier joueur à posé son premier pion (valeur 1) au centre : le deuxième joueur peut seulement poser son pion de valeur 1 ou 3 dans les cases marquées 1/3.

 

1   
    X
 X X

Le premier joueur à posé son premier pion (valeur 1) en haut à gauche : le deuxième joueur ne peut poser aucun pion dans les cases marquées d'un X => pas d'adjacence avec une case occupée.

 

1 2  
 4 1 4 
3 2 3

Il y a 2 pions rouges autour du trou et 0 noirs (case adjacentes par les côtés). Le joueur noir gagne.

 

1 2  
 4 1 4
3 2 3

Il y a 1 pion rouge et 1 pion noir autour du trou. Le joueur rouge gagne car son pion est plus petit.

 

1 2  
 4 1 2
3 4 3

Il y a 1 pion rouge et 1 pion noir autour du trou. Pas de gagnant car les 2 pions ont la même valeur.


 

L'objectif de de tutoriel est de reproduire les étapes essentielles dans le développement d'un jeu de plateau grâce à boardifier-console, pour ensuite les appliquer à un projet de plus grande envergure.

boardifier-console est un framework de développement Java. Il permet de faciliter le développement des jeux de plateau en mode texte. Il peut être considéré comme un spin-off du framework boardifier, qui lui est basé sur la bibliothèque graphique JavaFx pour créer des jeux de plateau en mode graphique. La caractéristique principale de ces 2 framework est de forcer l'utilisation du paradigme MVC strict pour développer un jeu, tout en assurant la possibilité de mettre en place des tests unitaires.

Ces contraintes ont l'inconvénient de multiplier le nombre de classes à écrire. Cependant, le code obtenu est plus facile à déboguer, tester, faire évoluer. De plus, cela permet de suivre un développement incrémental, en ajoutant petit à petit des fonctionnalités, voire dans certains cas, de développer plusieurs de ces fonctionnalités en parallèle sans que leur intégration soit complexe. Enfin, boardifier-console contient déjà la plupart des classes nécessaires pour faire tourner la "machinerie" interne de tout jeu basé sur une visualisation en mode texte, ce qui accélère notablement le développement.

 

Pour tout jeu, dont ce tutoriel, l'organisation du développement va suivre les étapes :

  • analyse des besoins par rapport aux règles du jeu et à la façon d'y jouer, ce qui permet d'obtenir :
    • une scénarisation et maquettage sommaire des interfaces du jeu,
    • une liste des fonctionnalités à implémenter.
  • mise en place logicielle (= environnement de dev.),
  • conception et implémentation des classes associées à la partie modèle,
  • conception et implémentation d'un algorithme de décision pour un ordinateur joueur,
  • maquettage précis des interface du jeu,
  • conception et implémentation des classes associées à la partie vue et contrôle.
  • implémentation de la classe principale, contenant main().

 

Pour ce tutoriel, tout le code à créer/ajouter est fourni. En revanche, les explications concernent uniquement pourquoi et comment fait-on telle chose avec boardifier-console. Les aspects plus algorithmiques liés au respect des règles du jeu ne seront pas expliqués (par ex, le code qui calcul qui gagne la partie).

 


 

 L'analyse se fait généralement à partir de l'expression des besoins d'un client. Dans le cas de ce tutoriel, vous êtes votre propre client qui demande simplement de pouvoir joueur au jeu "The Hole". Cette analyse permet de répertorier des fonctionnalités, qui sont indépendantes de la façon de les coder et avec quel langage/environnement. En revanche, certaines fonctionnalités vont apparaître à cause de la façon d'utiliser l'application = scénario d'utilisation. C'est pourquoi l'analyse doit aussi fixer ces scénario, qui sont généralement illustrés par des storyboards et des maquettes lorsqu'il y a des interfaces graphiques pour "naviguer" dans l'application.

Cependant, le fait que le jeu soit en mode texte implique des façons d'interagir avec le jeu complètement différentes de celles en mode graphique. Par exemple, en mode texte, la choix du type des joueurs et le lancement d'une partie, se fera sans via des questions affichées à l'écran et des réponses au clavier. Alors qu'en mode graphique, il y aurait une une fenêtre de dialogue, avec des cases à cocher, des boutons, etc.

La phase d'analyse doit donc absolument prendre en compte cette différence afin de ne pas produire un cahier des charges irréalisable.

Pour ce tutoriel, on suppose qu'après discussion avec nous-même, on établit un seul scénario global :

  • lancement de l'application => le premier paramètre de l'application (c.a.d. args[0] )  permet de sélectionner le type de partie, à savoir 2 humains, 2 ordinateurs, ou bien humain contre ordinateur.
  • la partie commence directement.
  • fin de partie => affichage d'un message indiquant quel est le gagnant puis le programme d'arrête.

Parallèlement, on choisit d'avoir une maquette dans le style suivant :

Le scénario d'une partie est le suivant :

  • le joueur humain est le premier joueur et reçoit les pions noirs, l'ordinateur les rouges.
  • lorsque c'est le tour de l'humain, il doit taper après le prompt player > un ordre de mouvement.
  • cet ordre est constitué de 3 caractères : le premier désigne la valeur du pion à jouer, le deuxième est la colonne de destination, et le troisième la ligne.
  • par exemple, 1A1 désigne le fait de placer le pion de vlaeur 1 dans la case A1.
  • si le joueur tape un ordre invalide, un message d'erreur est affiché, puis de nouveau le prompt.

A partir de ces élément, on peut établir une liste synthétique des fonctionnalités et de sous-fonctionnalités dont elles dépendent :

  • analyser args[0] pour créer les 2 joueurs,
  • construire le visuel du panneau de jeu,
  • demander à un joueur humain de taper un ordre,
  • déterminer quelles cases sont valide pour poser un pion de valeur n,
  • déplacer un pion,
  • calculer un déplacement de pion (pour l'ordinateur)
  • détecter la fin de partie,
  • calculer le résultat de la partie.

Nous allons voir que grâce à boardifier-console, l'essentiel de ces fonctionnalités sont déjà partiellement écrites.

 


 

  • Pour faciliter la création
  • Télécharger l'archive contenant un canevas des sources [ ici ]. On suppose dans la suite que l'archive s'appelle holeconsole-canvas.tgz et qu'elle a été téléchargée dans ~/Téléchargements.
  • Créer un répertoire pour le projet.
  • Déplacer l'archive dans le répertoire et la décompacter.

Exemple :

cd ~
mkdir -p code/Java/HoleConsole
cd code/Java/HoleConsole
mv ~/Téléchargements/holeconsole-canvas.tgz .
tar zxf holeconsole-canvas.tgz

 

  • Ouvrez IDEA et sélectionnez "Nouveau projet", sélectionnez "Java" dans la liste à gauche, puis dans la fenêtre de sélection du répertoire racine du projet, prendre code/Java/HoleConsole
  • Une fois la création terminer, cliquez sur le bouton entre le marteau et la flèche verte, afin de créer une configuration de compilation/exécution.
  • Cliquez sur le + pour ajouter une configuration et choisir "Application" comme type.
  • Dans la partie droite, choisissez un nom pour la configuration, le nom de la classe principale et éventuellement une valeur pour args[0].
  • Vous devriez avoir une fenêtre avec apparence similaire à ci-dessous :

tuto holeconsole 002

  • Cliquez sur la flèche verte pour vérifier que tout compile bien et que le programme commence son exécution correctement, bien qu'il ne fasse rien pour l'instant ! 

Avant de continuer dans le développement de "The Hole - console", il faut en connaître un peu plus sur boardifier-console, notamment sur la façon de structurer le code et qu'est-ce qu'il faut créer par soi-même.

Le paradigme d'architecture logicielle étant MVC (Modèle, Vue, Contrôleur), cela se traduit directement dans l'arborescence du projet. On retrouve donc :

  • boardifier : le répertoire contenant toutes les classes du framework boardifier-console. Le contenu de ce répertoire n'est pas censé être modifié, juste être parcouru pour mieux comprendre les rouages internes de boardifier-console (pour les curieux!). 

Remarque : la plupart des classes de boardifier-console sont exactement les mêmes dans son "grand frère" boardifier. Cela permet de plus facilement migrer vers une version totalement graphique d'un jeu.

  • HoleConsole : la classe contenant le main() de l'application et qui crée tous les objets nécessaires à son exécution. Cette classe doit être complété pour initialiser et lancer une partie.
  • model : le répertoire où seront mis toutes les classes représentant des éléments du jeu qui ne sont pas déjà existant dans boardifier-console (principalement des sous-classes de GameElement et de GameStageModel)
  • view : le répertoire où seront mis toutes les classes en rapport avec le visuel du jeu qui ne sont pas déjà existantes dans boardifier-console (principalement des sous-classes de ElementLook et de GameStageView)
  • control : le répertoire où sont mis les classes permettant de gérer le déroulement du jeu, ainsi que la prise de décision de l'ordinateur.

Partie modèle

  • Ce répertoire contient les squelettes (plus ou moins complets) des 5 classes nécessaires pour représenter la partie modèle :
    • pour représenter un pion : classe Pawn, qui hérite de GameElement,
    • pour représenter le plateau de 3x3 cases : classe HoleBoard, qui hérite de ContainerElement,
    • pour représenter les pool de pions : classe HolePawnPot, qui hérite de ContainerElement, (NB: déjà complète)
    • pour représenter l'unique stage (c.a.d. un niveau) d'une partie : classe HoleStageModel, qui hérite de GameStageModel,
    • pour créer les éléments de ce stage : classe HoleStageFactory, qui hérite de StageElementsFactory.

Partie vue

  • Ce répertoire ne contient que 2 classes complètes, à savoir BlackPawnPotLook et RedPawnPotLook. Elles permettent de définir l'aspect visuel des zones des pions à jouer.
  • Le répertoire contient également les squelettes de 2 classes  :
    • pour définir l'aspect des pions : classe PawnLook qui hérite de ElementLook,
    • pour définir le visuel global du stage : classe HoleStageView, qui hérité de GameStageView.

 

Partie contrôle

  • boardifier-console repose sur le fait qu'une classe représente le "contrôleur global" (cf. classe Controller) qui gère le déroulement du jeu, avec notamment la possibilité d'avoir différents stages (c.a.d. des niveaux) pour une même partie, créer des joueurs, lancer/arrêter une partie, etc. Cette classe contient également une bonne partie des mécanismes qui permettent de mettre à jour le modèle et la vue en fonction des coups des joueurs.
  • En revanche, il faut obligatoirement créer une sous-classe de Controller afin d'implémenter :
    • la gestion d'un tour de jeu du joueur courant en fonction des règles du jeu,
    • la gestion du passage d'un joueur à un autre en fonction des règles du jeu,
    • si besoin la gestion du passage d'un stage à un autre.
  • boardifier-console repose également sur le fait qu'un ordinateur joueur doit décider de lui-même ses coups. Pour cela, on doit définir une sous-classe de Decider, afin de construire une liste d'actions de jeu, représentant la décision de l'ordinateur (cf. section sur les actions & animations)
  • dans le répertoire control de l'archive se trouvent déjà 2 classes totalement écrites :
    • HoleController : le contrôleur global, avec la méthode stageLoop() qui définit le déroulement global de la partie, playTurn() qui fait joueur le joueur courant et endOfTurn() permet de passer au joueur suivant. Il y a également une fonction annexe analyseAndPlay() qui permet de vérifier la saisie d'un joueur humain et ensuite joueur son coup.
    • HoleDecider : permet de créer la liste d'actions de jeu pour le tour courant d'un joueur ordinateur. En l'occurrence, il n'y a pas de stratégie intelligente utilisée dans cette classe puisque la case choisie est tirée au hasard parmi celles qui sont valides.

 


 

D'après l'analyse, il faut créer au moins 4 classes spécifiques au jeu :

  • pour représenter un pion
  • pour représenter le plateau de 3x3 cases
  • pour représenter le pool de pions de chaque joueur.
  • pour représenter l'état du jeu loirs d'une partie.

Utiliser boardifier-console ne change rien à ce constat, excepté le fait que ces 4 classes vont devoir hériter des classes définies dans boardifier-console, et que d'autres classes vont être nécessaires. Pour comprendre pourquoi et comment faire, il faut connaître la logique utilisée pour la partie modèle de boardifier-console. Pour synthétiser :

  • Chaque élément de jeu (les pions, les plateaux, textes, boutons, ...) doit être représenté par une sous-classe de GameElement.
  • GameElement contient la plupart des attributs nécessaires à la gestion d'un grand nombre de type d'éléments (position dans l'espace, visible, sélectionnable, ...), ainsi que les méthodes permettant de lire/écrire ces attributs.
  • boardifier-console propose déjà plusieurs sous-classes à GameElement, mais aucune qui corresponde à un pion.
  • C'est pourquoi on crée la sous-classe Pawn.

 

  • En revanche, boardifier-console propose déjà une sous-classe qui permet de représenter une "zone" multi-cases, où placer des éléments : ContainerElement.
  • ContainerElement modélise une grille 2D de cases, dans lesquelles on peut assigner un ou plusieurs éléments du jeu. Cela permet de représenter les zones où l'on va placer des pions/cartes/dés/..., comme par exemple le plateau d'un jeu d'échec. A noter que des cases voisines peuvent être (dé)jointes comme on le ferait dans un tableur.
  • Il est bien entendu possible d'utiliser ContainerElement pour créer une zone d'une seul case, ou bien juste une ligne/colonne de cases.
  • ContainerElement contient tous les attributs et méthodes permettant de gérer la grille, notamment pour insérer/déplacer/enlever un élément de la grille, pour dire si une case est utilisable ou non lors d'une action de jeu.
  • Pour implémenter une zone pour un jeu particulier, on crée généralement une sous-classe de ContainerElement, afin de profiter des fonctionnalités héritées et en lui ajoutant celles qui sont propres au jeu.
  • Par exemple, pour "The Hole", on crée :
    • une sous-classe HoleBoard représentant la zone de 3x3 cases, avec une méthode computeValidCells() qui permet de calculer et mettre à jour quelles cases sont utilisables pour placer le pion actuellement sélectionné (NB : ce calcul est dépendant du jeu et ne peut donc pas être directement fait dans ContainerElement)
    • une sous-classe HolePawnPot pour représenter les zones de 4x1 cases où sont stockés les pions à jouer.

 

  • Pour gérer l'état du jeu, boardifier-console se base sur une classe Model contenant ce qui est commun à tous les jeux et une classe GameStageModel, contenant ce qu'il faut pour gérer un stage dans le jeu.
  • La plupart des jeux de plateaux n'ont donc qu'un seul stage.
  • GameStageModel contient essentiellement 3 listes et les méthodes associées pour les manipuler :
    • celle de tous les GameElement du stage,
    • celle de tous les ContainerElement du stage,
    • celle des éléments actuellement sélectionnés.
  • GameStageModel permet également de spécifier des méthodes dites "callbacks", qui seront appelées automatiquement lorsque la sélection change, ou lorsqu'un élément est inséré/déplacer/supprimé d'un conteneur. Par défaut, ces callbacks ne font rien, mais il est possible d'en définir d'autres, spécifiques au jeu.
  • Comme les éléments d'un stage varient d'un jeu à l'autre, il faut obligatoirement créer des sous-classes de GameStageModel et leur ajouter tous les attributs et méthodes nécessaires pour gérer l'état du jeu en fonction des règles, notamment les callbacks mentionnés ci-dessus.
  • C'est l'objectif de la sous-classe HoleStageModel.

 

  • ATTENTION : pour pouvoir mettre en place des test unitaires, l'instanciation des GameElement d'un stage et leur insertion dans les listes NE SE FAIT PAS directement dans les sous-classes de GameStageModel.
  • Pour effectuer ces instanciations, il faut obligatoirement passer par une sous-classe de StageElementsFactory.
  • Cette sous-classe doit définir une méthode setup(), qui va faire les instanciation voulues.
  • Pour résumer, il faut donc créer une sous-classe de GameStageModel et une sous-classe de StageElementsFactory pour chaque stage du jeu.
  • C'est ce qui est fait avec la sous-classe HoleStageFactory.

 

Reste à définir les attributs et méthodes de ces classes, ce qui doit normalement se faire AVANT de coder. Mais pour cela, il faudrait connaître un peu mieux boardifier-console. Nous allons donc sortir du cadre conseillé et finir la conception parallèlement au développement.

 


 

Bouger un pion est une action de jeu. Pour exécuter réellement cette action de jeu, il faut appeler les méthodes de ContainerElement, par exemple addElement() ou moveElement(). La partie contrôle ayant accès au modèle, le développeur peut très bien appeler lui-même ces méthodes. Cependant, ce n'est pas la façon de procéder dans boardifier-console.

La procédure consiste à :

  • représenter l'action de jeu par une instance d'un des sous-classes de GameAction.

ATTENTION :  vu que les classes d'action & animation sont exactement les mêmes dans boardifier-console et son grand frère boardifier, il existe 2 constructeurs possibles dans les classes d'action, dont un qui prend en paramètre un nom d'animation. En mode console, il va de soi qu'il n'y a pas d'animation possible donc il faut uniquement utiliser le constructeur sans animation.

  • insérer cette action dans un objet ActionList.
  • créer une instance d'ActionPlayer qui va "jouer" la suite d'actions de l'ActionList.

En fait, l'ActionPlayer va appeler des méthodes précises des objets GameAction, qui elles-mêmes vont appeler les méthodes pour modifier le modèle (par ex, moveElement()).

 

Le problème de cette procédure est qu'elle nécessite de créer soi-même les actions nécessaires et l'ActionList. Mais pour que le résultat de ces actions soit correct, il faut respecter des contraintes imposées par boardifier-console. Par exemple, il est impossible de bouger directement un pion d'un conteneur à un autre. Il faut d'abord l'enlever du conteneur source pour ensuite l'ajouter au conteneur destination.

Pour éviter au programmeur de gérer ces contraintes, boardifier-console propose une classe ActionFactory, qui permet de générer directement des instances d'ActionList pour les 4 opérations courantes dans un jeu. Cette classe contient ainsi les méthodes :

  • generatePutInContainer() : pour créer une ActionList permettant de déplacer un élément d'un conteneur à un autre.
  • generateMoveWithinContainer() : pour créer une ActionList permettant de déplacer un élément d'une case à une autre dans un même conteneur.
  • generateRemoveFromContainer() : pour créer une ActionList permettant d'enlever un élément d'un conteneur.
  • generateRemoveFromStage() : pour créer une ActionList permettant d'enlever un élément du stage.

A noter que la classe ActionList contient une méthode addAll() afin de concaténer deux ActionList, si nécessaire. C'est la cas par exemple dans les Dames, lorsqu'un pion prend un autre pion : il faut générer une ActionList pour le déplacement du pion et la concaténer avec une ActionList pour enlever le pion pris du stage.

L'avantage d'utiliser ces méthodes est encore plus marquant avec la version graphique de boardifier quand on utilise des animations pour représenter les déplacements des éléments. Dans ce cas, il faut calculer la position de destination des éléments et ces méthodes le font automatiquement.

 

Cette façon de procéder peut paraître alambiquée mais en pratique, elle évite beaucoup de copier/coller de code et propose une façon universelle d'exécuter des actions de jeu, quel que soit le jeu et que le joueur soit humain ou ordinateur.

Malgré l'existence de ActionFactory, voici quelques explications sur les classes d'action, au cas où vous décidiez d'en créer de nouvelles.


La super classe (dans le modèle) représentant les actions est GameAction mais ce sont ses sous-classes qui sont réellement utilisables. Dans chaque sous-classe on retrouve 2 méthodes essentielles :

  • start() : contient le code qui va modifier le modèle,
  • createAnimation() : permet de créer un objet Animation qui va contenir les pas d'animation nécessaires pour représenter visuellement l'action de jeu. Cette méthode n'est utile que pour la version graphique de boardifier

boardifier-console et boardifier ne proposent que des d'actions centrées sur les jeux de plateaux à pion+dés. Il y a donc :

  • PutInContainerAction : pour représenter le fait de bouger un élément non affecté à un conteneur vers une case d'un conteneur.
  • RemoveFromContainerAction : pour représenter le fait d'enlever un élément d'un conteneur.
  • MoveWithinContainerAction : pour représenter le fait de bouger un élément dans un conteneur d'une case à une autre (dans le même conteneur).
  • RemoveFromStageAction : pour représenter le fait qu'un élément soit "sorti" de la partie. Aucune animation n'est possible : l'élément devient invisible et il est positionné en dehors de la scène.
  • DrawDiceAction : pour représenter un jet de dés (donc ne fonctionne qu'avec les élément de type "dice"). 

Si vous voulez créer d'autres types d'action, il faudra donc écrire une sous-classe de GameAction qui définit les 2 méthodes mentionnées.


Un objet Action n'est jamais directement utilisé par le développeur. Comme mentionné ci-dessus, une Action doit être mise dans une ActionList qui est ensuite utilisée par un ActionPlayer pour exécuter réellement les actions de jeu.

La classe ActionList est donc un simple conteneur d'objets Action. Sans trop rentrer dans les détails, la classe ActionList contient des méthodes permettant d'ajouter des Action, soit individuellement, soit en pack. Un pack n'est utile que lorsque plusieurs actions de jeu doivent avoir une animation simultanée, par exemple bouger 2 pions en même temps. Sinon, on peut se contenter d'utiliser la méthode addSingleAction().

ATTENTION : l'ordre dans lequel on ajoute les Action sera celui qui sera ensuite joué par l'ActionPlayer

ActionList contient également une méthode  setDoEndOfTurn. Si cet attribut vaut true, l'ActionPlayer qui joue l'ActionList donnera l'ordre à la partie contrôle de déclencher le passage au joueur suivant une fois que toutes les actions auront été jouées.


La classe ActionPlayer et sa méthode playActions() permet de "jouer" les actions d'une ActionList. Le comportement central de playActions() est :

  • pour chaque Action de l'ActionList devant se faire avant l'animation :
    • appelle la méthode start() de l'Action (ce qui va modifier le modèle).
  • pour chaque Action de l'ActionList :
    • récupère l'animation,
    • lance l'animation,
    • attend la fin de l'animation.
  • pour chaque Action de l'ActionList devant se faire après l'animation:
    • appelle la méthode start() de l'Action (ce qui va modifier le modèle).

 Remarque : dans le framework boardifier, la classe ActionPlayer n'est pas implémentée tout à fait de la même façon, mais s'utilise à l'identique.


  

Remarque : Il est conseillé de coder soi-même les modifications indiquées ci-dessous. Cependant, vous pouvez vous rendre à la fin de cet article pour trouver le lien de téléchargement de la solution complète.

 

Quelques commentaires sur la façon de remplir les squelettes :

  • La classe GameElement contient un attribut int type, qui permet de catégoriser les éléments. Cette catégorisation va permettre de différencier plus facilement les éléments lorsqu'une méthode retourne une liste de GameElement.
  • Attribuer un type à un élément se fait automatiquement pour les classes existantes de boardifier-console.
  • Pour celles créés par le développeur, le constructeur doit obligatoirement enregistrer un nouveau type dans ElementTypes, grâce à la méthode register().
  • Cette méthode prend en paramètre un nom et une valeur entière. Par convention (et pour ne pas écraser celles qui sont déjà définies par boardifier-console), les valeurs doivent être >=50.
  • On peut ensuite utiliser ce nom et la méthode ElementTypes.getType() pour fixer le type de l'élément. 
  • Quand on crée soit même des sous-classes de GameElement, il faut qu'elles contiennent tous les attributs nécessaires à la gestion de l'état de l'élément.
  • Par exemple, aux dames, un pion peut devenir une reine. Il faut donc définir un attribut booléen qui indique si le pion est normal ou bien une reine et des méthodes permettant de manipuler cet attribut.
  • Dans le cas de "The Hole", un pion a un numéro, qui doit donc être définit comme attribut.
  • Pour certains jeux, un changement des valeurs de ces attributs d'état doit provoquer un changement de rendu visuel de l' élément (= son "look" dans la suite). Par exemple, un pion qui devient reine doit changer d'aspect visuel.
  • Dans ce cas, les méthodes qui manipulent les attributs d'état DOIVENT appeler la méthode addChangeFaceEvent() afin de signaler à la partie vue qu'il faudra mettre à jour le look de l'élément. Sans cet appel explicite, on ne constaterait aucun changement visuel à l'écran.

 

  • Quand un stage commence, la "machinerie" de boardifier-console va automatiquement créer une instance du stage (donc de HoleStageModel) grâce à son nom.
  • Ensuite, elle appelle la méthode getDefaultFactory() de cette instance. Le rôle de cette fonction est de retourner une instance de la classe qui crée les éléments du stage.
  • C'est pourquoi dans le cas présent, le code de getDefaultFactory() retourne une instance de HoleStageFactory.
  • Enfin, la machinerie appelle la fonction setup() de cette instance.

 

  • la classe HoleStageFactory contient un attribut stageModel, qui a la même valeur que la paramètre gameStageModel, mais dont on force le type comme étant celui de HoleStageModel.
  • Cette manipulation est juste un tour de passe-passe pour éviter plein de transtypages dans la méthode setup(), lorsque l'on voudra accéder aux attributs/méthodes de la classe HoleStageModel.

 Pour la classe Pawn :

  • Comme dit plus haut, il suffit d'enregistrer un type pour les pions, par exemple avec la valeur 50, puis d'initialiser les attributs int color et int number.
  • A noter qu'il n'y a pas besoin de méthode pour changer la valeurs de ces 2 attributs puisque les pions ne changent jamais d'état. Dans "The Hole", la classe Pawn n'a donc jamais besoin d'appeler addChangeEvent().

 

Pour la classe HoleBoard :

  • La méthode setValidCells(int number) qui va mettre à jour le tableau 2D de booléens reachableCells (hérité de ContainerElement).
  • reachableCells est automatiquement créé avec la même taille que la grille, donc 3x3 pour "The Hole".
  • L'objectif de la méthode est de mettre true dans les cases de reachableCells qui correspondent à une case valide si on voulait poser un pion dont la valeur est number.
  • Pour éviter d'avoir un code trop long, cette méthode appelle computeValidCells() pour obtenir la liste de ce cases.
 

 

Pour la classe HolePawnPot :

  • Il n'y a aucune fonctionnalité particulière. Le code du squelette est donc complet.

 

Pour la classe HoleStageModel :

  • Pour les méthodes, il faut créer des setters pour chacun des attributs afin que HoleStageFactory puisse les manipuler.
  • Cependant, il ne faut pas oublier que chaque élément du stage doit être ajouté dans les listes mentionnées auparavant, grâce aux méthodes addElement() et addGrid(). C'est pourquoi les setters doivent suivre un schéma bien particulier.
  • Par exemple, pour les setters de HoleBoard et blackPawns, cela donne :
    public void setBoard(HoleBoard board) {
        this.board = board;
        addContainer(board);
    }
    public void setBlackPawns(Pawn[] blackPawns) {
        this.blackPawns = blackPawns;
        for(int i=0;i<blackPawns.length;i++) {
            addElement(blackPawns[i]);
        }
    }

 

  • Il faut ensuite définir un ou plusieurs des 4 callbacks qui sont automatiquement appelés lors de certaines opérations et qui permettent d'influer sur l'état du jeu.
  • Par défaut, ces 4 callbacks ne font rien mais ils peuvent être changés grâce aux méthodes onSelectionChange(), onPutInContainer(), onRemoveFromContainer() et onMoveInContainer().
  • Pour faire simple, les 4 méthodes mentionnées ci-dessus prennent en paramètre une fonction lambda (ou fonction fléchée) qui définit les instructions a exécuter lorsque, respectivement, un élément a été sélectionné, un élément a été inséré/supprimé/déplacer dans un conteneur.
  • Pour "The Hole - console", il n'y a qu'une seule situation qui nécessite de changer le comportement nul par défaut : un pion est supprimé de son pool et mis dans la grille 3x3, ce qui implique de vérifier si tous les pions ont été placés et que la partie est terminée.
  • Il faut donc utiliser onPutInGrid() pour changer le callback associé.
  • On peut par exemple le faire dans une méthode setupCallbacks() que l'on appelle à la fin du constructeur :
    private void setupCallbacks() { 
        onPutInContainer( (element, containerDest, rowDest, colDest) -> {
            if (containerDest != board) return; // if not put in board, do nothing
            Pawn p = (Pawn) element;
            if (p.getColor() == 0) {
                blackPawnsToPlay--;
            }
            else {
                redPawnsToPlay--;
            }
            if ((blackPawnsToPlay == 0) && (redPawnsToPlay == 0)) {
                computePartyResult();
            }
        });
    }
  •  Reste à définir la méthode computePartyResult() qui va calculer qui gagne et mettre fin à la partie :
    private void computePartyResult() {
        int idWinner = -1;
        // compute winner
        // ... to fulfill
        
        // set the winner
        model.setIdWinner(idWinner);
        // stop de the stage
        model.stopStage();
    }

 

Pour la classe HoleStageFactory :

  •  Il suffit de compléter la méthode setup() pour qu'elle crée tous les éléments déclarés dans HoleStageModel et les assigne grâce aux différents setters.
  • Par exemple :
    public void setup() {
        // create the text , top-left char is in (0,0)
        TextElement text = new TextElement(stageModel.getCurrentPlayerName(), stageModel);
        text.setLocation(0,0);
        stageModel.setPlayerName(text);
        // create the board, top-left char is in (0,1)
        stageModel.setBoard(new HoleBoard(0, 1, stageModel));
        //create the black pot, top-left char is in (18,0)
        HolePawnPot blackPot = new HolePawnPot(18,0, stageModel);
        stageModel.setBlackPot(blackPot);
        // create the black pawns
        Pawn[] blackPawns = new Pawn[4];
        for(int i=0;i<4;i++) {
            blackPawns[i] = new Pawn(i + 1, Pawn.PAWN_BLACK, stageModel);
        }
        stageModel.setBlackPawns(blackPawns);
        // assign black pawns to their pot
        for (int i=0;i<4;i++) {
            blackPot.addElement(blackPawns[i], i,0);
        }

        // to fulfill for the red pot in (25,0), and red pawns 
        // ...
    }

 

Remarques :

  • Dans l'exemple ci-dessus, on positionne le texte, le panneau 3x3, les pots, ... comme si on avait une zone de pixels, mais avec des caractères au lieu de pixel. Par exemple, les coordonnées (18,0) représentent la colonne 18 et ligne 0 dans cette zone.
  • A noter que les coordonnées des éléments qui sont (dé)placés dans un conteneur n'ont par défaut pas besoin d'être spécifiées. En effet, quand on utilise une action PutInContainerAction sur un élément, ou bien que l'on utilise addElement() pour mettre cet élément dans une conteneur, celui-ci va être mis par défaut dans un état particulier. Quand la partie contrôle doit mettre à jour le visuel, elle vérifie si des éléments sont dans cet état particulier, et si c'est le cas, elle calcule automatiquement leur position dans la case du conteneur. C'est ce qui se passe pour les pions de "The Hole - console".

 


 

Contrairement aux classes créées précédemment, la prise de décision fait partie du contrôle. Cependant, dans boardifier-console, elle peut être écrite sans que le reste de l’application soit écrit, notamment le contrôle global et la vue. On peut même l'implémenter en parallèle des classes du modèle, à partir du moment où l'on a bien défini toutes les zones du jeu, donc tous les objets ContainerElement nécessaires.

Pour implémenter la prise de décision, il suffit de créer une sous-classe de Decider et de définir sa méthode decide(). Le fichier de la classe doit aller dans le répertoire control.

Pour ce tutoriel, on doit donc compléter decide() dans le fichier HoleDecider.java

 

L'objectif de cette méthode est simplement de choisir un pion et sa destination, puis de représenter son déplacement par une instance de MoveAction, qui sera placée dans l'ActionList. Le choix du pion et de sa destination représente le plus gros challenge algorithmique si l'on veut le faire intelligemment. Dans ce tutoriel, pour simplifier, on prend une solution "au hasard" : on prend le premier pion disponible qui peut être placé et on choisit au hasard une des cases valides.

Cela conduit au code suivant (à compléter) :

    public ActionList decide() {
        ActionList actions = null;
        // do a cast get a variable of the real type to get access to the attributes of HoleStageModel
        HoleStageModel stage = (HoleStageModel)model.getGameStage();
        HoleBoard board = stage.getBoard(); // get the board
        HolePawnPot pot = null; // the pot where to take a pawn
        GameElement pawn = null; // the pawn that is moved
        int rowDest = 0; // the dest. row in board
        int colDest = 0; // the dest. col in board

        // fulfill below to set pot, pawn, rowDest, colDest
        // NB : the pot (black or red) depends on the player's id
        // recall: board.computeValidCells() allows to compute ... valid cells :-)
        // ...

        // create action list, with ActionFactory. After the last action, it is next player's turn.
        ActionList actions = ActionFactory.generatePutInContainer( model, pawn, "holeboard", rowDest, colDest);
        actions.setDoEndOfTurn(true); // after playing this action list, it will be the end of turn for current player.
        return actions;      
    }

 


 

Afin de respecter le paradigme MVC, boardifier-console repose sur les principes suivants  :

  • Chaque GameElement est associé à un ElementLook qui définit son aspect visuel (c.a.d. son "look"). Quand on définit une sous-classe de GameElement, il faut donc définir une sous-classe de ElementLook pour créer son look.
  • Comme on est en mode texte, le look est représenté par un tableau 2D de String, ce qui permet d'afficher des caractères spéciaux en couleur.
  • Le contenu de ce tableau 2D doit être définit dans une méthode render(), que chaque sous classe de ElementLook DOIT définir pour créer le rendu visuel de l'élément en fonction de son état.

Remarques :

  • render() est automatiquement appelé si l'état de l'élément a changé et que l'on a appelé addChangeFaceEvent()(cf. § sur GameElement). C'est pourquoi il est important d'écrire le code de render() pour qu'il tienne compte des états possibles de l'élément ... sauf pour certains états.
  • En effet, la gestion du changement de la visibilité de l'élément est automatisée. Il n'y a pas besoin de code spécial dans render() pour "effacer" son rendu visuel lorsque l'élément est invisible. C'est fait automatiquement.

 

Par exemple, pour créer le look des pions de "The Hole", PawnLook contient :

public class PawnLook extends ElementLook {

    public PawnLook(GameElement element) {
        super(element, 1, 1);
  }

  public protected render() {
        Pawn pawn = (Pawn)element; // just for convenience to avoid in the following lots of casts like: (Pawn)element
        if (pawn.getColor() == Pawn.PAWN_BLACK) {
            shape[0][0] = ConsoleColor.WHITE + ConsoleColor.BLACK_BACKGROUND + pawn.getNumber() + ConsoleColor.RESET;
        }
        else {
            shape[0][0] = ConsoleColor.BLACK + ConsoleColor.RED_BACKGROUND + pawn.getNumber() + ConsoleColor.RESET;
        }
    } 
}

Remarque : pour cet exemple, il y a juste besoin de définir le rendu visuel quand le pion est visible. Ce rendu ne changera jamais, contrairement aux dames, où il faudrait tester dans render() si le pion est une dame ou non afin de définir son visuel.

 

  • Pour le look des éléments "conteneurs" (c.a.d. de type ContainerElement, boardifier-console contient déjà une sous-classes de ElementLook, à savoir ContainerLook. Cette classe représente une table sans bordure, avec la possibilité d'avoir des cases jointes si le ContainerElement associé en a ou pas. La taille des cases peut être fixée ou bien être variable selon la taille des looks à l'intérieur. boardifier-console propose également 3 sous-classes plus spécialisée/optimisées  : 
    • GridLook, 
    • ClassicBoardLook
    • TableLook.
  • Les 2 premières servent uniquement lorsque le conteneur est vraiment une grille 2D, sans cases jointes. Le rendu visuel est une grille avec des cases de taille fixe, et avec ou sans bordure. La deuxième est juste une sous-classe de la première, afin d'avoir en plus une numérotation des lignes et colonnes.
  • La troisième classe permet de gérer une grille aussi bien avec que sans cases jointes, sans numérotation, avec une taille de case variable ou fixe.
  • A noter que s'il n'y a pas de cases jointes, il vaut mieux utiliser les 2 premières classes, plus performantes.
  • Ces classes gèrent elles-mêmes les looks des éléments se trouvant dans le conteneur, notamment leur position "locale".
  • Cela implique que dès qu'un élément est placé/déplacé/supprimé d'un conteneur, boardifer-console reproduit automatiquement la même action pour le look de l'élément au sein du look du conteneur. Le rendu visuel du conteneur est alors mis à jour, via l'appel de sa méthode render().
  • Pour résumer, grâce aux layouts, le développeur a juste besoin de gérer les modifications de la partie modèle, en déplaçant des éléments dans/hors de conteneurs, et boardifier-console gère automatiquement la mise à jour de la partie vue, à partir des modifications sur le modèle.

 

  • Puisque ces classes héritent de ElementLook, elles doivent définir la méthode render(). Pour les 3 sous-classes, on sépare le rendu des bordures et coordonnées du contenu, grâce aux méthodes :
    • renderInners() : recopie le tableau 2D de chaque look interne à la bonne position dans la tableau shape du look du conteneur.
    • renderBorders() : "dessine" les bordures de chaque case grâce à des caractères spéciaux, dans le tableau shape du look du conteneur.
    • renderCoords() : "dessine" les coordonnées de chaque ligne/colonne, dans le tableau shape du look du conteneur.
  • Si on veut créer une sous-classe, par exemple de GridLook, pour modifier la façon de faire le rendu visuel, il n'y a normalement aucune raison de redéfinir renderInners(). En revanche, on peut redéfinir render() et/ou renderBorders(). C'est ce qui est fait dans ce tutoriel avec la classe PawnPotLook.

 

 

  • Pour gérer tous les visuels d'un même stage, chaque sous-classe de GameStageModel doit avoir son équivalent pour la vue, sous la forme d'une sous-classe de GameStageView.
  • Cette classe n'a par elle-même aucun rôle d'affichage. C'est juste un conteneur pour tous les looks des éléments utilisés dans un même stage.
  • Toutes les sous-classes de GameStageView doivent redéfinir la méthode createLooks() afin de créer les looks des différents éléments du modèle.
  • Pour afficher réellement les looks stockés dans un GameStageView, boardifier-console utilise la classe RootPane qui représente une sorte de panneau qui peut être affiché à l'écran. RootPane contient une méthode update() qui parcourt tous les éléments à afficher afin de remplir ce panneau aux bonnes coordonnées texte.
  • Ce RootPane est lui-même inclut dans une View, qui gère le contenu de la fenêtre du jeu et notamment sa mise à jour lorsque le contrôleur le demande.
  • Dans boardifer-console, View est quasi une coquille vide. Cependant, elle permet d'avoir la même structuration de classe que dans boardifier, ce qui facilite le passage d'un jeu du mode texte à graphique.

 

Pour PawnPotLook, la redéfinition de la méthode renderBorders() permet de changer le visuel par défaut de la grille.

Enfin, pour HoleStageView, il suffit d'instancier les classes de look, avec les dimensions prévues par le maquettage, et d'utiliser addLook() pour ajouter ces instances au visuel du stage. Ce qui donne :

package view;
// imports
// ...
public class HoleStageView extends GameStageView {
    public HoleStageView(String name, GameStageModel gameStageModel) {
        super(name, gameStageModel);
    }

    @Override
    public void createLooks() {
        HoleStageModel model = (HoleStageModel)gameStageModel;

        // create a TextLook for the text element
        addLook(new TextLook(model.getPlayerName()));
        // create a ClassicBoardLook (with borders and coordinates) for the main board.
        addLook(new ClassicBoardLook(2, 4, model.getBoard(), 1, 1, true));
        // create looks for both pots
        addLook(new BlackPawnPotLook(model.getBlackPot()));
        addLook(new RedPawnPotLook(2, 4, model.getRedPot()));
        // create looks for all pawns
        for(int i=0;i<4;i++) {
            addLook(new PawnLook(model.getBlackPawns()[i]));
            addLook(new PawnLook(model.getRedPawns()[i]));
        }
    }
}

 


 

HoleController étant déjà complètement écrite, seuls quelques commentaires sont nécessaires.

  • La méthode stageLoop()  se contente de boucler jusqu'à la fin de partie, en appelant playTurn() et endOfTurn() pour alterner entre les joueurs, puis update() afin de mettre à jour le modèle et la vue, et ainsi afficher celle-ci.
  • playTurn() récupère le type du joueur courant et en fonction, crée un HoleDecider pour deviner le coup à jouer, soit demande des instructions au clavier. Dans ce dernier cas, analyseAndPlay() permet de vérifier et jouer le coup choisi par le joueur.
  • endOfTurn() utilise le modèle pour mettre à jour le joueur courant, grâce à la méthode par défaut qui produit simplement une alternance entre joueurs. 

 


 

La classe HoleConsole est en fait quasi complète. En effet, quel que soit le jeu, les étapes de création et initialisation des objets sont presque identiques. Il n'y a globalement que 3 étapes qui dépendent du jeu :

  • l'ajout des joueurs au modèle,
  • l'enregistrement des noms des classes associées aux stages dans le StageFactory,
  • quel stage doit être utilisé lorsqu'une partie commence.

Ensuite, il suffit de lancer la partie et entrer dans la boucle de gestion du stage.

Cela donne :

    public static void main(String[] args) {
        ...
        if (mode == 0) {
            model.addHumanPlayer("player1");
            model.addHumanPlayer("player2");
        }
        else if (mode == 1) {
            model.addHumanPlayer("player");
            model.addComputerPlayer("computer");
        }
        else if (mode == 2) {
            model.addComputerPlayer("computer1");
            model.addComputerPlayer("computer2");
        }

        StageFactory.registerModelAndView("hole", "model.HoleStageModel", "view.HoleStageView");
        View holeView = new View(model);
        ControllerHole control = new ControllerHole(model,holeView);
        control.setFirstStageName("hole");
        try {
            control.startGame();
            control.stageLoop();
        }
        catch(GameException e) {
            System.out.println("Cannot start the game. Abort");
        }
    }

 


  

 L'archive de la solution complète est téléchargeable [ ici ]. Elle contient un projet IDEA complet que vous devrez reconfigurer pour l'adapter à votre ordinateur.

En enlevant toute la partie boardifier-console, cela représente en gros 550 lignes de code, lignes vides/commentaires exclus, ce qui est vraiment peu pour un petit jeu parfaitement fonctionnel. S'il fallait l'écrire complètement, il faudrait multiplier ce nombre par environ 3, sachant que cela donnerait sans doute un code bien moins évolutif et peu réutilisable pour créer d'autre jeux.