Imprimer
Catégorie : SAÉ - Développement IHM
Affichages : 4187

Préambule :

 


 

L'objectif de de tutoriel est de présenter le développement d'un jeu de plateau grâce à Boardifier. C'est un framework de développement Java basé sur la bibliothèque graphique JavaFx. C'est le "grand frère" de boardifier-console, car il en reprend les principes essentiels, notamment l'utilisation du paradigme MVC strict, mais avec des classes basées sur Javafx pour la mise en place des parties vue et contrôle. Malgré cela, la partie modèle est identique, ainsi qu'environ 80% du code des classes de contrôle et de vue est identique. C'est pourquoi il est relativement aisé de passer d'une version console à une version graphique, puisque tout le côté fonctionnel peut être repris.

 

Pour suivre ce tutoriel, il faut donc d'abord suivre celui qui implémente le jeu en mode console : Tutoriel 1 : créer un jeu de plateau en mode texte avec boardifier-console.

Dans la suite de cet article, seules les différences dans la modélisation et l'implémentation des classes des parties modèle, vue et contrôleur sont abordées. Comme on va le constater, ces différences ne sont pas très importantes, et elles concernent principalement :

A côté de cela, des modifications mineures doivent être faites sur les classes du modèle, notamment pour tenir compte de la sélection d'un pion avec la souris.

 


 

Avant de commencer le développement, il convient d'aborder les principales différences entre les deux versions du framework boardifier.

Pour la partie modèle, les classes sont identiques à l'exception de la méthode update() de GameElement dont les paramètres changent : update(double width, double height, GridGeometry gridGeometry).  La valeur de ces paramètres est directement fournit par le contrôleur général, lorsque le modèle est mis à jour à chaque frame. Ces 3 paramètres correspondent à la taille en pixel de l'élément, et un objet donnant la géométrie visuelle de la grille (taille des cellules en pixel notamment) si l'élément est dans une grille. Normalement, en MVC le modèle n'a pas accès aux informations relatives au visuel mais il y a des exceptions. Par exemple, s'il faut déplacer un élément dans l'espace, il est parfois nécessaire de connaître sa forme et/ou la forme de la grille dans laquelle il se trouve pour savoir si le déplacement est possible. Dans "The Hole", ce genre de cas n'existe pas, mais comme boardifier est fait pour être général, ces exceptions sont prévues.

 

Pour la partie vue, presque toutes les classes sont différentes puisque c'est Javafx qui est utilisé pour faire le rendu visuel. En revanche, les principes généraux de création du visuel ne changent pas beaucoup par rapport à boardifier-console :

 Pour la partie contrôle, il y a également des différences notables avec le mode console, notamment afin de gérer les mouvements des éléments graphiques et les interactions utilisateur grâce au clavier/souris. De ce fait, on a :

 

La dernière différence concerne les animations et la classe ActionPlayer. Elle est différente du mode console, puisqu'elle ajoute la possibilité de "jouer" des animations à l'écran, pour représenter une action de jeu. 

La super classe (dans le modèle) représentant les animations est Animation mais ce sont ses sous-classes qui sont réellement utilisables.

ATTENTION : Animation et ses sous-classes ne contiennent aucune instruction permettant de modifier directement la vue. Ces classes servent uniquement à stocker des informations sous la forme d'objets AnimationStep, qui seront utilisés pour modifier l'état d'un élément, par exemple sa position dans l'espace. Comme dit auparavant, la modification de l'état de l'élément entraînera une mise à jour automatique de son visuel, ce qui produira un effet visuel d'animation, par exemple de mouvement.

 

Animation ne contient que ce qui est nécessaire à toutes les animations, c.a.d des attributs représentant la durée de l'animation, une liste d'objets AnimationStep contenant les "pas" de l'animation, et un callback qui sera appelé à la fin de l'animation (par défaut ne fait rien).

AnimationStep est une simple "enveloppe" pour une liste de nombres à virgule. Les valeurs que l'on met dans cette liste dépend entièrement de ce dont on a besoin comme information pour représenter l'animation. Par exemple, pour une animation du type mouvement, cette liste va contenir les coordonnées x,y de l’élément à déplacer à un pas donné le l'animation. Si on veut faire changer l'apparence d'un élément, la liste va plutôt contenir l'indice d'une des apparences possible de l'élément (cf. DiceElement).

A l'heure actuelle, gamifier ne propose qu'un nombre limité d'animation, basées sur un changement soit de position, soit d'apparence. Il y a ainsi :

 

ATTENTION : on ne crée généralement pas directement une instance de ces classes. On utilise plutôt un objet Action qui va contenir une Animation représentant l'action. Ce sera ActionPlayer qui lancera automatiquement l'animation. Par exemple, lorsqu'un pion doit se déplacer d'une case à une autre, on peut associer à une action de jeu instance de MoveAction une animation de type mouvement linéaire LinearMoveAnimation

 

 


 

Du fait de la présence d'une interface graphique, il y a plus de besoins que dans le jeu en mode console. Après discussion avec nous-même, on établit le scénario suivant :

Parallèlement, on choisit de maquetter le jeu comme suivant :

 nom du joueur courant liste pions noirs 
 grille 3x3 liste pions rouge

 

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

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 :

 

Nous allons voir que grâce à Boardifier, l'essentiel de ces fonctionnalités sont écrites, soit totalement, soit presque entièrement. Qui plus est, si on réutilise le code du jeu en mode console, les trois dernières sont également déjà écrites.

 


 

Exemple (en supposant que les sources du jeu en mode console se trouvant dans ~/code/Java/HoleConsole) :

cd ~/code/Java
mkdir Hole
cd Hole
cp -r ../HoleConsole/src .
cd src
rm -rf boardifier
// télécharger l'archive boardifier.tgz et la placer dans le rep. courant.
tar zxf boardifier.tgz

 

 


 

Les modifications des classes du modèle sont relativement limitées et concernent seulement 3 classes :

Remarque : pour fournir à boardifier le callback utilisé lors de la sélection d'un pion, il suffit d'appeler la méthode onSelectionChange() dans setupCallbacks() (même principe qu'en mode console avec onPutInGrid() ). Cette méthode prend en paramètre le callback, sous la forme d'une fonction lambda. Dans le cas présent, il suffit de vérifier si un pion est sélectionné. Si oui, on calcule les case atteignables en fonction du pion sélectionné, et sinon on remet à zéro les cases atteignables :

       onSelectionChange( () -> {
            // get the selected pawn if any
            if (selected.size() == 0) {
                board.resetReachableCells(false);
                return;
            }
            Pawn pawn = (Pawn) selected.get(0);
            board.setValidCells(pawn.getNumber());
        });

Remarque : dans un jeu où les seules animations consistent à bouger des éléments (pions, cubes, ...), on peut utiliser le code "universel" suivant pour jouer une animation de déplacement linéaire :

    public void update(double width, double height, GridGeometry gridGeometry) {
        // if must be animated, move the pawn
        if (animation != null) {
            AnimationStep step = animation.next();
            if (step != null) {
                setLocation(step.getInt(0), step.getInt(1));
            }
            else {
                animation = null;
            }
        }
    }

 

Toutes ces modifications sont relativement simples à mettre en place et se retrouvent dans la solution téléchargeable en fin d'article.

  


 

La prise de décision se fait comme en mode console, dans la classe HoleDecider. La seule différence est que l'on peut cette fois utiliser le constructeur des actions avec une animation en paramètre. Le seul problème est de déterminer les coordonnées d'arrivée du pion dans le plan. Pour cela, il suffit d'utiliser la méthode "helper" getRootPaneLocationForCellCenter(), accessible via le GridLook de la grille dans laquelle le pion va être placé, comme dans l'exemple suivant :

 

// 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
...
// create action list. After the last action, it is next player's turn.
ActionList actions = new ActionList(true);
// get the dest. cell center in space.
GridLook look = (GridLook) control.getElementLook(board);
Coord2D center = look.getRootPaneLocationForCellCenter(rowDest, colDest);
// create the move action
GameAction move = new MoveAction(model, pawn, "holeboard", rowDest, colDest, AnimationTypes.MOVE_LINEARPROP, center.getX(), center.getY(), 10);
actions.addSingleAction(move);

   


 

En JavaFx, lors du lancement d'un application, une instance de la classe Stage est créée. Attention : cette classe n'a rien à voir avec la notion de stage dans boardifier. Elle représente en fait la fenêtre primaire de l'application. Si l'on veut ouvrir d'autres fenêtres, il faut donc créer d'autres instance de Stage. Ensuite, pour afficher quelque chose au sein de cette fenêtre, il faut créer une instance de Scene et lui ajouter des éléments graphiques JavaFx (Button, Shape, ...). On peut alors assigner la Scene au Stage et la machinerie interne de JavaFx se débrouille pour effectivement peindre les éléments graphiques à l'écran, et si besoin les repeindre quand leurs caractéristiques (position, dimension ,...) ont changé.

En conclusion, contrairement à Swing, il n'y a pas besoin d'utiliser des instructions pour "peindre" des formes, comme par exemple drawRect(), puisqu'il y a des classes qui représentent ces formes. De plus, il est inutile de demander explicitement à repeindre un élément avec une méthode du style repaint(). JavaFx est donc plus souple à utiliser ... une fois qu'on connaît bien ses classes et leurs interactions !


 Pour créer le visuel des éléments, donc une sous-classe de ElementLook, boardifier repose sur les principes suivants (certains étant communs avec boardifier-console):

  • une sous-classe d'ElementLook prend forcément en paramètre un GameElement (= principe MVC où la vue accède au modèle)
  • le visuel est compris dans une boîte englobante, dont le coin haut-gauche se trouve aux coordonnées virtuelles 0,0.
  • les dimensions de la boîte englobante ne sont pas forcément fixes et peuvent dépendre des paramètres passés au constructeur, ou bien venant du GameElement associé.
  • on crée les différentes parties du visuel grâce à des instances de sous-classes de Shape (Circle, Rectangle, Line, ...) et/ou ImageView (pour afficher une image).
  • on positionne ces instances dans la boîte englobante, en utilisant leurs méthodes propres (par ex, setCenterX() pour Circle)
  • on "ajoute" les instances au look grâce à
    • addShape() pour les sous-classes de Shape,
    • addNode() pour une ImageView.
  • si besoin, on redéfinit les méthodes :
    • onSelectionChange(), car le visuel doit changer lorsqu'on l'élément associé est (dé)sélectionné,
    • onChange(), car le visuel doit changer lors d'un changement d'état (autre que position, visibilité, sélection) de l'élément associé.

Attention :

  • si vous n'ajoutez pas l'instance au look avec addShape() ou addNode(), elle ne sera jamais visible
  • si la position d'une instance n'est pas correcte, ce qui dépasse de la boîte englobante ne sera pas coupé. En revanche, il deviendra difficile de positionner correctement à l'écran l'élément.

 

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

...
public class PawnLook extends ElementLook {
    private Circle circle;
    public PawnLook(int radius, GameElement element) {
        super(element);
        Pawn pawn = (Pawn)element; // unless you did somthing stupid in HoleStageView, it IS really a Pawn instance !
        circle = new Circle();
        circle.setRadius(radius);
        if (pawn.getColor() == Pawn.PAWN_BLACK) {
            circle.setFill(Color.BLACK);
        }
        else {
            circle.setFill(Color.RED);
        }
        circle.setCenterX(radius);
        circle.setCenterY(radius);
        // add the circle to the look
        addShape(circle);
        // to fulfill ...
    }
    @Override
    public void onSelectionChange() {
        // to fulfill ...
    } 
    public void onChange() { }
}
 

Remarque : il n'y a pas besoin définir du code pour onChange() car il n'y a aucun changement d'état dans Pawn (à part la position et la sélection). Ce ne serait pas le cas dans un jeu de dames où un pion peut devenir une dame. Il faudrait alors redéfinir onChange() pour modifier son visuel quand le pion devient un reine.


Comme en mode console, tous les looks doivent être instanciés dans la fonction createLooks() d'une sous-classe de GameStageView. Comme dit plus haut, la différence est que les dimensions des looks doivent être données en pixels. Si le jeu comporte plusieurs stages, il faut créer une sous-classe de GameStageView par stage. Dans le cas de "The Hole", il n'y a qu'une seule classe HoleStageView, qui ressemble fortement à celle du mode console :

...
public class HoleStageView extends GameStageView {
    public HoleStageView(String name, GameStageModel gameStageModel) {
        super(name, gameStageModel);
        width = 650;
        height = 450;
    }

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

        addLook(new HoleBoardLook(320, model.getBoard()));
        addLook(new PawnPotLook(120,420,model.getBlackPot()));
        ... // to fulfill        
}

 


Tous les looks stockés dans les différentes instances de GameStageView vont être affichés dans un Pane, classe JavaFx servant d'espace d'affichage pour les éléments javafx (Shape, ImageView, ...). Ce Pane est représenté par la classe RootPane de boardifier (qui hérite de Pane). Au moment de son instanciation, RootPane contient seulement un rectangle gris et un texte basique. Si on veut changer cet état par défaut, il faut hérité de RootPane, ce qui est la cas dans "The Hole", avec la classe HoleRootPane, qui redéfinit la méthode createDefaultGroup() comme suivant :

...
public class HoleRootPane extends RootPane {
    ...
    @Override
    public void createDefaultGroup() {
        Rectangle frame = new Rectangle(600, 100, Color.LIGHTGREY);
        Text text = new Text("Playing to The Hole");
        text.setFont(new Font(15));
        text.setFill(Color.BLACK);
        text.setX(10);
        text.setY(50);
        // put shapes in the group
        group.getChildren().clear();
        group.getChildren().addAll(frame, text);
    }
}

 


Enfin, la vue globale est représentée par la classe View, dont il faut hériter si l'on a besoin d'une barre de menu. Dans ce cas, il faut redéfinir la méthode createMenuBar() :

...
public class HoleView extends View {

    private MenuItem menuStart;
    private MenuItem menuIntro;
    private MenuItem menuQuit;

    public HoleView(Model model, Stage stage, RootPane rootPane) {
        super(model, stage, rootPane);
    }

    @Override
    protected void createMenuBar() {
        menuBar = new MenuBar();
        Menu menu1 = new Menu("Game");
        menuStart = new MenuItem("New game");
        menuIntro = new MenuItem("Intro");
        menuQuit = new MenuItem("Quit");
        menu1.getItems().add(menuStart);
        menu1.getItems().add(menuIntro);
        menu1.getItems().add(menuQuit);
        menuBar.getMenus().add(menu1);
    }
    ...
}

  

Cette partie est celle qui nécessite le plus de modifications, et globalement les plus compliquées à écrire.Pour un jeu en général, il faut créer :

  • une sous-classe de ControllerKey, pour gérer les interactions clavier,
  • une sous-classe de ControllerMouse, pour gérer les interactions souris,
  • une sous-classe de ControllerAction, pour gérer la barre de menu
  • une sous-classe de Controller, pour créer le contrôleur global, et notamment implémenter l'alternance entre joueurs.

 

NB : Dans "The Hole",  il faut donc créer :

  • HoleControllerKey, qui ne sert à rien dans le jeu mais permet d'illustrer les événements clavier. 
  • HoleControllerMouse, qui va s'occuper de détecter le clic souris et de voir si un pion a été sélectionné et si ou, si une case d'arrivée est cliquée.
  • HoleControllerAction, pour gérer le clic sur les items du menu.
  • HoleController.

 

Pour HoleCntroller, à part le constructeur qui doit instancier les sous-contrôleurs, il n'existe qu'une seul méthode à redéfinir : nextPlayer(), qui contient le code a exécuter lorsque le tour d'un joueur vient de se terminer. Dans le cas présent, que ce soit le joueur humain ou l'ordinateur, les actions de jeu vont être encodées dans une ActionList jouée par un ActionPlayer. Si on crée l'ActionList avec son attribut doNextPlayer à true, l'ActionPlayer appellera automatiquement nextPlayer() une fois que toutes les actions auront été jouées. Cela permettra donc d'alterner automatiquement entre les joueurs.

A noter qu'il est également possible d'appeler cette méthode dans le contrôleur de souris ou clavier, par exemple lorsque l'on clique sur un élément précis, ou bien lors de l'appui sur une touche fixée. 

Pour "The Hole", le code de nextPlayer() est très simple :

  • modifier le joueur courant dans le modèle, ce qui peut être fait grâce à model.setNextPlayer() qui va par défaut boucler sur tous les joueurs,
  • changer le texte du TextElement qui contient le nom du joueur courant,
  • si le joueur courant est l'ordinateur, créer un HoleDecider, le passer en paramètre de construction à un ActionPlayer et enfin démarrer l'ActionPlayer.

Cela donne :

public void nextPlayer() {
    // use the default method to compute next player
    model.setNextPlayer();
    // get the new player
    Player p = model.getCurrentPlayer();
    // change the text of the TextElement
    HoleStageModel stageModel = (HoleStageModel) model.getGameStage();
    stageModel.getPlayerName().setText(p.getName());
    if (p.getType() == Player.COMPUTER) {
        System.out.println("COMPUTER PLAYS");
        HoleDecider decider = new HoleDecider(model,this);
        ActionPlayer play = new ActionPlayer(model, this, decider, null);
        play.start();
    }
}

 

Pour HoleControllerMouse, il faut compléter la méthode handle() qui sert de gestionnaire des événements souris. C'est une des parties du jeu les plus complexes à écrire du jeu car elle demande de bien connaître boardifier. En revanche, d'un point de vue algorithmique, c'est assez simple car le scénario d'utilisation du jeu est lui-même simple. Cela donne :

  • récupérer les coordonnées du clic souris dans la scène,
  • obtenir la liste des GameElement contenant ces coordonnées,
  • si aucun pion n'est sélectionné :
    • parcourir la liste pour trouver un élément du type pion. Si aucun, retour.
    • sinon, si la couleur du pion correspond au joueur courant, sélectionner le pion et retour.
  • sinon :
    • parcourir la liste pour trouver un élément du type pion. Si trouvé et qu'il s'agit du pion déjà sélectionné, désélectionner le pion puis retour.
    • parcourir la liste pour trouver le panneau 3x3. Si pas trouvé, retour.
    • sinon 
      • trouver la case cliquée
      • si case non valide, retour
      • déterminer le centre de la case cliquée.
      • créer une ActionList.
      • insérer une MoveAction dans l'ActionList, avec comme coordonnées finale le centre de la case cliquée.
      • créer une ActionPlayer et jouer l'ActionList.
      • désélectionner le pion.

Le code complet est donnée dans la solution complète en fin d'article.

 

Dans HoleControllerAction, il faut mettre en place la détection des clics sur un item de menu, ce qui génère un ActionEvent, et en fonction de l'item cliqué, exécuter certains instructions. Il existe plusieurs façon de mettre en place cela. Dans la solution proposée, on n'utilise pas la méthode handle() comme dans le cas du clavier ou de la souris. En effet, comme les instructions à exécuter en cas de clic sur un menu sont relativement simples et concises, il est possible d'attacher un callback à un item de menu, grâce à la méthode setOnAction(). Le paramètre de cette méthode est une fonction lambda contenant les instructions a exécuter en cas de clic. Cette lambda prend en paramètre l'ActionEvent généré lors du clic, si besoin.

HoleControllerAction étant un contrôleur, il a accès à la vue, donc peut accéder aux items de menus via les getters définis dans HoleView, et ainsi définir les callback à appeler avec setOnAction(). Exemple partiel, tiré de la solution proposée ;

...
public class HoleControllerAction extends ControllerAction implements EventHandler<ActionEvent> {
    // to avoid lots of casts, create an attribute that matches the instance type.
    private HoleView holeView;

    public HoleControllerAction(Model model, View view, Controller control) {
        super(model, view, control);
        // take the view parameter to define a local view attribute with the real instance type, i.e. HoleView.
        holeView = (HoleView) view;
        // set handlers dedicated to menu items
        setMenuHandlers();
        ...
    }

    private void setMenuHandlers() {
        ...
        // set event handler on the MenuQuit item
        holeView.getMenuQuit().setOnAction(e -> {
            System.exit(0);
        });
    }
    ...
}

 

Remarque : HoleControllerAction prend en paramètre un objet View, mais lors de l'exécution, on sait que cet objet sera en réalité une instance de HoleView. Pour accéder aux getters de HoleView, il faut donc transtyper le paramètre view. Pour éviter de faire ce transtypage à chaque utilisation, il est fait une seule fois pour un attribut du bon type, c.a.d. HoleView.

 


 

La classe Hole ressemble fortement à celle du mode console. Cette classe doit forcément hériter de Application. Dans main(), il suffit d'analyser les paramètres pour vérifier s'ils sont corrects puis appeler la méthode launch() qui lance le "moteur" javafx. Ce dernier va automatiquement appeler la méthode start(), qu'il faut implémenter avec les étapes suivantes :

  • créer le modèle global,
  • ajouter des joueurs au modèle,
  • enregistrer des noms des classes associées aux stages dans le StageFactory,
  • créer le panneau principal, dans le cas présent, une instance de HoleRootPane.
  • créer la vue globale,
  • créer le contrôleur global,
  • définir le premier stage,
  • afficher la fenêtre principale.

 


 

 L'archive de la solution complète est téléchargeable [ ici ].