Comment écrirer un jeu de type Match-3, tel que Candy Crush, Bejeweled, FishDom… en Flutter.

Difficulté: Intermédiaire

Introduction

Je me suis demandé s’il serait difficile d’écrire un jeu de plateau en Flutter, impliquant des Animations, Séquences d’animations, Animations déclenchées, Sons, Reconnaissance des gestes

Comme je joue depuis des années avec des jeux Math-3 tels que Candy Crush, Bejeweled et dernièrement FishDom, il me paraissait naturel d’essayer d’imiter ces jeux.

Après seulement 1 semaine, j’avais déjà une version fonctionnelle du jeu que j’ai appelé “Flutter Crush” et cet article explique les étapes que j’ai suivies pour ce développment.

Ceci est une animation qui montre certaines parties du jeu.

Flutter Crush Flutter Crush

Code Source

Le code source complet de cet article se trouve sur GitHub.

Comme mon objectif n’était pas de publier le jeu, il y a encore des choses qui ne sont pas complètement mises en œuvre ou testées. En outre, la structure du code pourrait être très améliorée, mais cela ne faisait pas partie de mes objectifs.

De plus, si le code source peut vous donner des idées pour construire votre propre jeu, je serai plus qu’heureux… faites-le moi simplement savoir… 😃


1. Introduction aux jeux de type Match-3

Avant de commencer à parler de la mise en œuvre, il peut être intéressant de (ré) expliquer le fonctionnement d’un jeu de type Match-3. (Si vous connaissez déjà bien ce sujet, vous pouvez ignorer cette explication et aller directement au Section Implémentation).

Un jeu de type Match-3 est un jeu de puzzle qui consiste à aligner 3 tuiles similaires sur une grille pour les faire disparaître ou être transformées en une autre tuile. Les tuiles qui disparaissent font des “trous” dans la grille. Par gravité (généralement), les carreaux situés au-dessus des trous tomberont pour combler les trous. À la fin d’un déplacement, les colonnes sont remplies avec de nouvelles tuiles.

Un joueur peut faire glisser une tuile d’une cellule à la fois, horizontalement ou verticalement. La tuile “source” (celle qui a été déplacée par l’utilisateur) et la tuile “destination” (la tuile de destination après le déplacement) sont échangées. Si cela entraîne la création d’une chaîne, le déplacement est considéré comme correct et joué. Sinon, le déplacement est considéré comme incorrect et les deux tuiles sont remises à leur emplacement initial.

Les règles dépendent du jeu. Dans mon cas, je n’ai considéré qu’un sous-ensemble de ce qui existe actuellement. Voici les règles que j’ai sélectionnées:

1.1. Types d’alignements

L’image suivante montre les différents types de combinaisons.

Flutter Crush Tiles Flutter Crush Tiles

1.2. Notion de Chaînes

Une chaîne est une série d’au moins 3 tuiles du même type alignés verticalement ou horizontalement:

  • une chaîne de 3 tuiles a pour effet de retirer ces 3 tuiles;
  • une chaîne de 4 tuiles entraîne la suppression de ces 4 tuiles mais la tuile qui a créé la chaîne est remplacée par un autre type (dans mon cas un TNT);
  • une chaîne de 5 (ou plus) tuiles entraîne la suppression de toutes les tuiles de la chaîne mais la tuile qui a créé la chaîne est remplacée par un autre type (dans mon cas une BOMBE);

1.3. Notion de Combos

Un Combo est le résultat de la combinaison de 2 Chaînes ayant une tuile en commun. La somme de toutes les tuiles des deux chaînes donne le type de combo.

  • Un combo “t” ou “l” compte 6 tuiles. Toutes les tuiles des chaînes sont supprimées et la tuile commune est remplacée par un autre type (dans mon cas, une “BOMBE”);
  • Un combo “L” compte 7 tuiles. Toutes les tuiles des chaînes sont supprimées et la tuile commune est remplacée par un autre type (dans mon cas, un “WRAPPED”);
  • Un combo “T” compte 8 tuiles. Toutes les tuiles des chaînes sont supprimées et la tuile commune est remplacée par un autre type (dans mon cas, un “ROCKET”);

1.4. Bombes ou similaires

Lorsqu’elle est touchée, une bombe explose, supprimant toutes les tuiles de l’espace touché par l’explosion. Le nombre de tuiles à enlever dépend du type de bombe:

  • un “TNT” enlève les tuiles juste à côté de la bombe;
  • un “BOMB” enlève les tuiles jusqu’à 2 cellules autour de la bombe;
  • un “WRAPPED” enlève les tuiles jusqu’à 3 cellules autour de la bombe;
  • un “ROCKET” enlève toutes les tuiles

1.5. Autres éléments

Certains autres éléments peuvent être ajoutés au jeu, tels que:

  • un “trou“: un endroit où aucune tuile ne peut aller;
  • un “mur“: un lieu qui ne peut être ni déplacé ni retiré du jeu;
  • un “gel“: qui recouvre une tuile et oblige celle-ci à être impliquée dans au moins 2 événements avant d’être enlevée;

1.6. Objectifs

Chaque niveau de jeu peut avoir des objectifs différents ou une combinaison de plusieurs objectifs, tels que:

  • enlèvement d’une certaine quantité d’un certain type de carreaux;
  • création d’une certaine quantité de “bombes”;

1.7. Quantité de déménagement limitée

Dans toutes les circonstances, chaque niveau de jeu permet un maximum de coups pour atteindre les objectifs.


2. Préambule à l’implémentation

Avant de commencer avec le code, nous devons avoir une idée des différentes parties du jeu et de la manière dont elles interagiront.

2.1. Les pages

Ce jeu ne contient que 2 pages.

2.1.1. Page d’accueil

La Home Page n’a pour but que d’afficher un joli fond, un titre et une série de boutons, chacun lançant le niveau de jeu correspondant.

Flutter Crush Home Page Elements Flutter Crush Home Page Elements

2.1.2. Game Page

La Game Page contient une série d’éléments comme le montre l’image suivante:

Flutter Crush Game Page Elements Flutter Crush Game Page Elements
2.1.2.1. Splash Banner

Au début de la partie, une bannière Splash apparaît pour montrer à l’utilisateur les informations relatives à:

  • le numéro de niveau
  • la liste des objectifs

Lorsque la partie est terminée, une bannière Splash apparaît pour indiquer si le joueur a gagné ou non la partie.

2.1.2.2. Panneau de mouvements

Ce panneau est affiché dans le coin supérieur gauche de l’écran. Ce panneau indique au joueur combien de coups il reste avant la fin de la partie.

Le nombre de coups restants doit être actualisé chaque fois que le joueur joue un coup.

2.1.2.3. Panneau objectifs

Ce panneau est affiché à différents endroits de l’écran, en fonction de l’orientation du périphérique:

  • en mode portrait, j’ai choisi de l’afficher dans le coin supérieur droit;
  • en mode paysage, j’ai choisi de l’afficher dans le coin inférieur gauche.

Le panneau d’objectifs est constitué d’une série d’ “icônes” (représentant un type de tuile) et du compteur d’objectifs correspondant.

Chaque compteur d’objectifs doit être actualisé chaque fois que le joueur joue un coup qui entraîne la décrémentation d’un compteur d’objectifs.

2.1.2.4. Plateau de Jeu

Ce panneau affiche les tuiles, organisées en fonction de la définition du niveau, avec des dimensions qui dépendent de l’orientation et des dimensions de l’appareil.

En d’autres termes, nous devons déterminer les dimensions d’une tuile en fonction des dimensions de l’appareil et de la position de la “grille”, en fonction de l’orientation de l’appareil.


2.1.3. Interaction entre les parties

A chaque fois que le joueur joue un movement:

  • le “panneau des mouvements” doit être mis à jour (le nombre de mouvements restants doit être décrémenté);
  • le mouvement doit être évalué pour vérifier si un combo ou une chaîne est créé, puis résolu;
  • la “grille” doit être mise à jour (le cas échéant);
  • le “panneau objectifs” doit être mis à jour (sur la base des tuiles supprimées);
  • le “statut du jeu” doit être mis à jour.

2.1.4. Définition des niveaux

Chaque niveau doit être défini en termes de:

  • définition du plateau de jeu      * dimensions (nombre de lignes et de colonnes)      * cellules qui acceptent des tuiles;      * cellules prédéfinies (murs, interdits/trous …);
  • objectifs
  • nombre de mouvements maximal

Ces définitions de niveau seront stockées dans un fichier externe au format JSON.


2.2. Architecture BLoC

Des exigences définies ci-dessus, nous pouvons déduire que de nombreuses parties du jeu devront interagir les unes avec les autres.

Voici un exemple parfait où la notion de Streams pourrait aider:

  • lorsqu’une tuile est retirée du tableau, nous envoyons une notification au BLoC:
    • le BLoC envoie cette information aux Objectifs;
    • lorsque tous les Objectifs sont atteints, un événement est émis pour signaler le gain du jeu.
  • quand un coup est joué, nous envoyons une notification au BLoC:
    • le BLoC envoie cette information au compteur de mouvements;
    • lorsqu’il ne reste plus aucun mouvement, un événement est émis pour signaler que la partie est terminée.
Flutter Crush BLoC Flutter Crush BLoC

Comme on peut le voir sur cette image, le BLoC doit interagir avec les deux pages, il est donc nécessaire de positionner le BLoC comme parent des deux pages.

Dans Flutter, le parent de toutes les pages doit être lui-même le parent du MaterialApp. Ceci est dû au fait qu’une page (ou Route) est encapsulée par OverlayEntry, enfant d’un Stack commun pour toutes les pages.

En d’autres termes, chaque page a un context qui est indépendant de toute autre page. Ceci explique pourquoi, sans faire appel à aucune astuce, il est impossible pour 2 pages (Routes) d’avoir quelque chose en commun.

Flutter Crush Routes Flutter Crush Routes

2.2.1. GameBloc

Le ‘* GameBloc *’ peut être considéré comme une sorte de “Application State” mais est également utilisé:

  • en tant que distributeur d’événements (utilisation de Streams);
  • pour mémoriser la liste de tous les niveaux;
  • pour lancer un nouveau niveau de jeu;
  • pour réinitialiser un niveau de jeu.

2.2.2. Note

Une solution basée sur une classe de type Global Singleton aurait été beaucoup plus simple à mettre en œuvre et aurait très bien fonctionné, mais jouons le jeu et essayons de nous en tenir aux meilleures pratiques d’architecture.

Les meilleures pratiques de BLoC stipulent que toutes les communications à destination / en provenance de BLoC doivent se faire via Streams… Pour cet essai, sachant que ce code ne devait pas être utilisé nulle part ailleurs, et par souci de clarté, j’ai choisi de ne pas me tenir à ce paradigme et utiliser des méthodes pour demander une action au BLoC.

Afin d’exposer le BLoC principal (= GameBloc), j’ai utilisé le Provider Package de Rémi Rousselet. Ce paquet est une version générique de mon BlocProvider que j’ai défini dans un article précédent.


3. Algorithmes

Cette section décrit les algorithmes.

3.1. Début de partie

Le diagramme suivant montre la séquence d’actions lorsqu’un utilisateur lance un nouveau niveau.

Flutter Crush Algorithm Start a Level Flutter Crush Algorithm Start a Level

3.1.1. Mélange des Tiles

Au cours de cette étape, il est très important de mélanger les tuiles afin qu’aucune Chaîne directe ne soit créée.

Pour y parvenir:

  • on remplit la grille en partant de row = 0..max rows -1 et col = 0..max cols -1;
  • pour chaque cellule, nous obtenons un type de tuile aléatoire que nous comparons avec les 2 cellules précédentes sur la gauche [row][col-1] & [row][col-2] ainsi qu’avec les 2 cellules en bas [row-1][col] & [row-2][col];
  • Si le type de tuile aléatoire est le même que pour l’une de ces autres cellules, nous bouclons jusqu’à ce qu’il n’y ait plus de correspondance.

3.1.2. Définition des échanges (=Swap)

Pour identifier tous les échanges de mosaïque possibles, nous devons parcourir toutes les tuiles et, pour chacune d’entre elles:

  • on boucle à travers chaque mouvement possible (haut, gauche, bas, droite)
    • on simule le mouvement (échange de cellules)
    • on vérifie si cela génère des chaînes horizontales ou / et verticales

3.2. Jouer un mouvement

Le diagramme suivant montre la séquence d’actions lorsqu’un utilisateur joue un coup.

Flutter Crush Algorithm Play a Move Flutter Crush Algorithm Play a Move

3.2.1. Animation d’échange négatif

Cette animation consiste à montrer une animation qui échange 2 tuiles, puis inverser l’échange.

3.2.2. Animation d’échange

Cette animation consiste à montrer une animation qui échange 2 tuiles.

3.2.3. Définition de la séquence d’animations

** Avant-propos **

Afin de montrer les tuiles qui tombent et de remplir les cellules vides, ma première tentative a été d’animer chaque tuile, une ligne à la fois. Le résultat n’était pas du tout lisse et l’animation était saccadée.

Je suis finalement arrivé à la solution où je calcule tous les mouvements que chaque tuile va effectuer et les délais entre chaque animation successive dans la séquence.

Les retards sont calculés globalement pour l’ensemble des tuiles.

Je m’explique…

  • delay = 0
  • longest delay = 0
  • Loop until there is no more moves
    • delay = longest delay
    • resolve combos => new delay
    • for each column (0.. cols-1)
      • process the avalanche with delay
      • process vertical fall with delay => new delay
      • calculate longest delay
3.2.3.1. Tuiles qui tombent verticalement
Flutter Crush Vertical Fall Flutter Crush Vertical Fall

Explication:

  • ‘A’ tombera depuis row #3 vers row #1 (= destination). Comme il n’y a plus de tuile en row #2, il débutera directement (delay = 0);
  • ‘B’ tombera depuis row #4 vers row #2 (= destination). Comme il n’y a plus de tuile en row #3 (=A), il débutera après un délai (delay = 1);
  • ‘C’ tombera depuis row #7 vers row #3 (= destination). Comme il n’y a plus de tuile en row #6, il débutera directement (delay = 1).
3.2.3.2. Remplir les cellules vides avec de nouvelles tuiles

Après avoir calculé les déplacements de chaque cellule d’une colonne, s’il y a des cellules vides (en haut), nous devons injecter de nouvelles tuiles.

Flutter Crush New Tiles Flutter Crush New Tiles

Explication:

  • Nouvelle tuile ’D’ tombera depuis row #7 vers row #4, après un délai de 1
  • Nouvelle tuile ‘E’ tombera depuis row #7 vers row #5, après un délai de 2
  • Nouvelle tuile ‘F’ tombera depuis row #7 vers row #6, après un délai de 3
  • Nouvelle tuile ‘G’ tombera depuis row #7 vers row #7, après un délai de 4

Comme vous l’avez remarqué, la tuile ‘G’ se déplacera depuis row #7 vers row #7. Ceci est parfaitement possible car l’animation liée à l’injection d’une nouvelle tuile commencera par une translation verticale afin de simuler son insertion.

3.2.3.3. Notion d’Avalanche

Il existe des cas où les cellules vides ne peuvent pas être remplies par des tuiles tombant verticalement. Cela peut être dû à un obstacle tel qu’un mur, par exemple.

Dans ce cas, nous devons vérifier, pour chaque mosaïque qui tombe, s’il y a une cellule vide dans la colonne précédente (ou suivante), dans la rangée = destination -1 (ou même en dessous). Dans ce cas, nous déplaçons la tuile vers cette nouvelle destination avec un délai de + 1.

Flutter Crush Avalanche Flutter Crush Avalanche

4. Implémentation technique

Maintenant que nous connaissons les règles, les exigences, les algorithmes, il est temps de parler de l’implémentation technique…

En ce qui concerne les explications, je ne vais pas expliquer chaque ligne de code mais surtout me concentrer sur les parties intéressantes de l’implémentation. Veuillez vous reporter au code source, qui est entièrement documenté, pour plus de détails.

4.1. Les ‘assets’

Les icônes de tuiles et la définition des niveaux sont tous assets qui vont être utilisés par le jeu. Tous ces assets sont stockés dans le dossier ‘/assets’ et doivent être référencés dans le fichier ‘pubspec.yaml’.

4.2. 2-dimension array en Dart

L’ensemble du jeu est basé sur une grille à 2 dimensions et Dart ne propose aucune API de type Array2d. J’ai utilisé une version trouvée sur Internet à laquelle j’ai appliqué quelques modifications.

Veuillez consulter le fichier helpers/array_2d.dart pour plus d’informations sur cet Array2d.

Désormais, avec cette nouvelle API, il est facile de définir un tableau à 2 dimensions pour le jeu et d’accéder directement à une cellule spécifique de la grille, comme suit:

Array2d<Tile> grid = Array2d<Tile>(numberOfRows, numberOfColumns);
grid[row][col] = Tile(...);

4.3. Définition des niveaux

Comme expliqué précédemment, j’ai externalisé la définition des différents niveaux dans un fichier nommé “/assets/levels.json”.

La structure du fichier est la suivante:

{
    "levels": [
        {
            "level": 1,                                 // numéro de niveau
            "rows": 10,                                 // nombre de rangées
            "cols": 10,                                 // nombre de colonnes
            "grid": [                                   // template de la grille
                "X,1,1,X,X,X,X,X,1,X",                  // ligne supérieure(row = 9)
                ...
                "X,1,1,W,1,1,W,1,2,X",                  // row #x
                ...
                "X,1,1,X,X,X,X,1,1,X"                   // ligne inférieure (row = 0)
            ],
            "moves": 24,                                // nombre maximum de mouvements
            "objective": ["4;blue","20;red","2;bomb"]   // Objectifs
        },
        ...
    ]
}

Lors de l’initialisation de GameBloc, ce fichier est lu et toutes les définitions de niveau sont mémorisées.

4.4. Début de niveau

Lorsque l’utilisateur lance un niveau, une nouvelle instance de GameController est créée, ce qui génère une grille interne de Tiles, basée sur le modèle de grille de niveau sélectionné. Une fois que cela est fait, la page GamePage est instanciée.

4.5. Affichage des carreaux

Les tuiles sont affichées dans une grille, centrée sur l’écran. Cette grille ne remplit pas toute la largeur. Un peu de marge est utilisé pour rendre le look un peu plus agréable.

4.5.1. Difficulté liée à l’appareil

Les appareils ayant des résolutions différentes, le meilleur moyen de collecter des informations fiables sur la position exacte de la grille centrée et les dimensions de l’une de ses cellules est de la récupérer une fois la grille rendue.

Le widget ‘Board’ est chargé d’afficher la grille et de récupérer la position exacte de son coin supérieur gauche, ainsi que les dimensions de l’une de ses cellules.

Comme il n’est pas possible d’obtenir cette information au niveau de la méthode build(…) (puisque pas encore été affichée), il faut utiliser une astuce.

L’astuce consiste à demander à Flutter d’appeler une méthode dès que le rendu est terminé, de la manière suivante:

Widget build(BuildContext context){
    ...
    WidgetsBinding.instance.addPostFrameCallback((_) => _afterBuild());
    ...
}

La méthode addPostFrameCallback demande à Flutter de faire un appel à une fonction lorsque le pipeline de rendu principal a été vidé. Il est alors possible d’obtenir les informations dont on a besoin mais… comment?

Voici une deuxième astuce qui consiste à “nommer” la grille et une de ses cellules.

Pour donner un nom à un widget, nous utilisons la notion de Key. Une key peut être obtenue de différentes manières, mais la plus simple consiste à utiliser l’API GlobalKey qui renvoie un identifiant unique pour toute l’application. Pour attribuer la key à un widget, utilisez la propriété key comme suit:

GlobalKey _myWidgetKey = GlobalKey();

...
Widget build(BuildContext context){
    return MyWidget(
        key: _myWidgetKey,
        ...
    );
)

Il est maintenant possible de récupérer les dimensions et la position de n’importe quel widget, une fois que nous en connaissons sa key et que le widget a été rendu. L’extrait de code suivant montre une façon de le faire:

void _afterBuild(){
    if (_myWidgetKey.currentContext != null){
        final RenderBox box = _myWidgetKey.currentContext.findRenderObject() as RenderBox;
        final Offset topLeft = box.size.topLeft(box.localToGlobal(Offset.zero));
        final Offset bottomRight = box.size.bottomRight(box.localToGlobal(Offset.zero));
        final Rect widgetDimensions = Rect.fromLTRB(topLeft.dx, topLeft.dy, 
                                                bottomRight.dx, bottomRight.dy);

        ...
    }
}

Veuillez consulter le widget “Board” pour plus de détails sur sa mise en œuvre.

4.5.2. Comment positionner exactement les tuiles dans la grille?

Une complexité supplémentaire réside dans le positionnement exact des tuiles dans la grille car la position de la grille et les dimensions de ses cellules ne sont pas connues avant le rendu. De plus, à cause des animations (voir plus loin), j’ai besoin que les tuiles aient une position absolue et non pas relative (en d’autres termes, chaque tuile devra être positionnée par rapport au coin supérieur gauche de la écran). Cela signifie qu’il est impossible de les afficher avant que la position de la grille et les dimensions de ses cellules n’aient été récupérées.

Voici la première utilisation des Streams

Une fois que le Board Widget aura récupéré les informations relatives à la position de la grille et aux dimensions des cellules, ces données seront enregistrées dans l’objet Level et une valeur ‘true’ sera entrée dans le _readyToDisplayTilesController du GameBloc via son Sink.

Dans la page GamePage, un StreamBuilder écoute les émissions du Stream GameBloc.outReadyToDisplayTiles. Dès qu’une valeur boolean est émise par ce stream, le StreamBuilder sera activé et affichera les Tiles (cfr méthodes ‘_buildTiles()’ dans GamePage et ‘_afterBuild()’ dans Board Widget)

Widget _buildTiles() {
  return StreamBuilder<bool>(
    stream: gameBloc.outReadyToDisplayTiles,
    initialData: null,
    builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
        if (snapshot.data != null && snapshot.hasData) {
            List<Widget> tiles = <Widget>[];
            ...

            return Stack(
                children: tiles,
            );
        }
        // If nothing is ready, simply return an empty container
        return Container();
    },
  );
}

Note intéressante

En Dart, les objets sont passés par référence.

En d’autres termes, si vous passez un objet à une méthode, toute mise à jour appliquée à l’objet dans la méthode sera également visible en dehors de la méthode!

J’ai utilisé cette fonctionnalité. Le Widget ‘Board’ enregistre la position de la grille et les dimensions de ses cellules dans l’instance de l’objet level, qui est transmise en argument au widget ‘Board’.

Comme vous le verrez dans le code, l’objet level est largement utilisé pour transmettre des informations sur la définition de niveau au GameController, aux tuiles, … Par conséquent, il n’est peut-être pas conventionnel (ou une meilleure pratique), mais est un moyen très pratique de propager les informations sur la grille et ses cellules.


4.6. Détection des gestes

Pour jouer un coup, l’utilisateur doit toucher l’écran sur une tuile et déplacer cette tuile horizontalement ou verticalement.

4.6.1. Capture du Geste

Pour capturer les mouvements du doigt, nous devons encapsuler le Board avec un Widget GestureDetector, comme suit:

...
GestureDetector(
    onPanDown: (DragDownDetails details) => _onPanDown(details),
    onPanStart: _onPanStart,
    onPanEnd: _onPanEnd,
    onPanUpdate:(DragUpdateDetails details) => _onPanUpdate(details),
    onTap: _onTap,
    onTapUp: _onPanEnd,
    child: Stack(
        children: <Widget>[
            _buildMovesLeftPanel(orientation),
            _buildObjectivePanel(orientation),
            _buildBoard(),
            _buildTiles(),
        ],
    ),
),
...

Explication:

  • onPanDown

     Est appelé dès que l’utilisateur touche l’écran. DragDownDetails donne la position à l’écran. * onPanStart

     Est appelé dès que l’utilisateur commence à déplacer le doigt sur l’écran. * onPanEnd

     Est appelé dès que l’utilisateur supprime le doigt de l’écran. * onPanUpdate

     Est appelé une fois que le mouvement a commencé, chaque fois que le doigt se déplace sur l’écran. * en fût

     Est appelé lorsque l’utilisateur appuie simplement sur l’écran. * onTapUp

     Est également appelé lorsque l’utilisateur supprime le doigt de l’écran.

4.6.2. Détermination de la tuile qui est jouée

Tout d’abord, nous devons vérifier si l’utilisateur touche un Tile à l’écran. Ceci est réalisé via le rappel onPanDown.

Cette méthode:

  • convertit la position de l’événement tactile en une correspondance (rangée, colonne), en utilisant la position de la grid comme référence;
  • valide que cela correspond à une cellule acceptable (row, col);
  • vérifie si le Tile correspondant peut être déplacé.

Si toutes ces conditions sont remplies, nous enregistrons la référence du Tile touché.

4.6.3. Détermination de la direction du mouvement

Dans le contexte de ce jeu, la seule chose qui compte est de déterminer la direction du mouvement (horizontalement: à gauche ou à droite, verticalement: en haut ou en bas).

Ceci est réalisé par 2 callbacks:

  • onPanStart

    Cela indique que le doigt a commencé à bouger. Lorsque ce rappel est appelé, nous mémorisons la position de départ à l’écran.

  • onPanUpdate

    Chaque fois que le doigt bouge, après que onPanStart ait été appelé, ce callback est appelé avec la nouvelle position du doigt.

    Nous avons simplement besoin de calculer le delta de mouvement, de prendre la plus grande différence entre les positions horizontale et verticale et de calculer la correspondance résultante (row, col), ce qui nous donne la Tile de destination.

    Suivent ensuite la validation du mouvement et le traitement du mouvement lui-même, le cas échéant.


4.7. Notion d’animation, déclenchée par le code.

Dans la plupart des cas, Animation signifie l’utilisation d’un AnimationController, qui, lorsque ce dernier est en cours d’exécution, écoute les événements tick et déclenche la méthode build d’un Widget.

Dans notre cas, cela signifierait à avoir à reconstruire la page GamePage (ou en partie sur nous optimisons), ce qui ne serait pas un gros problème… néanmoins, comment déterminer les animations à jouer, quelles tuiles animer et comment? Cela deviendrait un cauchemar.

La solution que j’ai trouvé est basée sur l’utilisation de l’Overlay (veuillez vous référer à mes articles précédents sur la notion d’Overlay pour plus de détails sur ce sujet).

4.7.1. Avantage à utiliser l’Overlay

Un Overlay est un Stack qui contient déjà la page HomePage et la page GamePage, comme expliqué précédemment. Rien ne nous empêche d’ajouter un nouveau Widget à celui-ci, de manière temporaire.

Ce Widget sera:

  • par défaut, affiché au-dessus (=on top) de n’importe quel autre contenu de l’Overlay;
  • construit (=built) dès qu’il sera ajouté à l’Overlay.

Ça y est, nous avons trouvé un moyen de jouer une animation, déclenchée par le code, en ajoutant simplement un Widget à l’Overlay. Ce nouveau Widget sera seul responsable de l’animation.

4.7.2. Comment faire?

Le code suivant montre comment cela peut être réalisé:

//
// Instantiation of an OverlayEntry which will contain
// our animation reponsible Widget
//
OverlayEntry _overlayEntry = OverlayEntry(
    builder: (BuildContext context){
        return MyAnimationResponsibleWidget(
            ...
        );
    },
);

//
// Add the OverlayEntry to the Overlay
//
Overlay.of(context).insert(_overlayEntry);

4.7.3. Comment savoir quand une animation se termine?

Dans notre jeu, nous devons savoir quand une animation est terminée.

Pour être averti, ajoutons simplement une méthode de rappel qui sera invoquée par le widget responsable de l’animation, une fois l’animation terminée.

Le squelette de code générique d’un tel widget chargé de l’animation deviendrait alors le suivant:

class MyAnimationResponsibleWidget extends StatefulWidget {
    MyAnimationResponsibleWidget({
        Key key,
        @required this.onComplete,
    }): super(key: key);

    final VoidCallback onComplete;

    _MyAnimationResponsibleWidgetState createState() => _MyAnimationResponsibleWidgetState();
}

class _MyAnimationResponsibleWidgetState extends State<_MyAnimationResponsibleWidgetState>
                                        with SingleTickerProviderStateMixin {
   AnimationController _controller;

   @override
   void initState(){
     _controller = AnimationController(vsync: this, 
                                       duration: ...,)
            ..addListener((){
                setState((){});
              })
            ..addStatusListener((AnimationStatus status){
                if (status == AnimationStatus.completed){
                    if (widget.onComplete != null){
                        widget.onComplete();
                    }
                }
              })
            ..forward();
   }

   @override
   void dispose(){
       _controller?.dispose();
       super.dispose();
   }

   @override
   Widget build(BuildContext context){
       ...
   }
}

L’un des avantages supplémentaires à être averti qu’une animation est terminée est que nous pouvons supprimer l’OverlayEntry de l’Overlay au bon moment, comme suit:

//
// Instantiation of an OverlayEntry which will contain
// our animation reponsible Widget
//
OverlayEntry _overlayEntry;

_overlayEntry = OverlayEntry(
    builder: (BuildContext context){
        return MyAnimationResponsibleWidget(
            ...
            onComplete: (){
                _overlayEntry?.remove();
                _overlayEntry = null;
            }
        );
    },
);

//
// Add the OverlayEntry to the Overlay
//
Overlay.of(context).insert(_overlayEntry);

4.7.4. Notion de Future

La notion de **Synchronisation * est également très intéressante pour notre jeu.

La plupart du temps, le jeu devra attendre l’achèvement d’une (ou de plusieurs) animation(s) avant de poursuivre le traitement.

Si nous nous basions uniquement sur la méthode callback à invoquer, cela rendrait notre code assez lourd et très difficile à manipuler, en cas de plusieurs animations. Par conséquent, nous devons trouver un moyen d’attendre la fin de toutes les animations.

C’est ici qu’intervient la notion de Future.

4.7.4.1. Comment créer une Future à partir d’un code source synchrone?

Heureusement pour nous, Dart propose une solution, appeléeCompleter, qui produit un objet de type Future et le “complète” plus tard.

Le code suivant montre comment implémenter ce Completer dans une méthode qui déclenchera une animation:

  Future<dynamic> _playAnimation() async {
    var completer = Completer();

    OverlayEntry overlayEntry;

    overlayEntry = OverlayEntry(
        builder: (BuildContext context){
            return MyAnimationResponsibleWidget(
                onComplete: (){
                    overlayEntry?.remove();
                    overlayEntry = null;

                    // Completes the Future
                    completer.complete(null);
                },
            );
        },
    );
    Overlay.of(context).insert(overlayEntry);

    // Return the Future to the caller
    return completer.future;
  }
4.7.4.2. Comment utiliser cette Future?

L’exemple de code suivant montre comment attendre qu’une ou plusieurs animations se terminent:

  // Wait for 1 animation to complete
  await _playAnimation();

  // Wait for several animations to complete
  await Future.wait([_playAnimation(), _playAnimation()]);

4.8. Comment utiliser les Streams?

Nous avons maintenant presque toutes les pièces pour construire le jeu, mais j’aimerais revenir sur la notion de Streams et sur la manière de les utiliser pour afficher le:

  • nombre de coups restants avant la fin de la partie;
  • compteur lié à chaque objectif;
  • bannière lorsque le jeu est terminé.

4.8.1. Nombre de coups restants

Ceci est réalisé par le Widget ‘StreamMovesLeftCounter’. Son implémentation est très simple:

class StreamMovesLeftCounter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    GameBloc gameBloc = Provider.of<GameBloc>(context, listen: false);

    return Row(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        const Icon(Icons.swap_horiz, color: Colors.black,),
        SizedBox(width: 8.0),
        StreamBuilder<int>(
          initialData: gameBloc.gameController.level.maxMoves,
          stream: gameBloc.movesLeftCount,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot){
            return Text('${snapshot.data}', style: TextStyle(color: Colors.black, fontSize: 16.0,),);
          }
        ),
      ],
    );
  }
}

Nous utilisons un simple StreamBuilder qui écoute le stream “movesLeftCount”, exposé par le GameBloc.

Rien de difficile. Lorsque le nombre de déplacements restants est ajouté au Sink “GameBloc.movesLeftCount”, la valeur est émise par le stream et interceptée par StreamBuilder, qui reconstruit (=build) le Text.

4.8.2. Compteurs d’Objectifs

Pour afficher et actualiser les compteurs d’objectifs, c’est un peu plus “tricky”, car je voulais seulement reconstruire le compteur relatif à UN objectif PARTICULIER, et ne pas à avoir à la modifier tous, systématiquement.

Puisque l’affichage d’un objectif est effectué par un widget dédié et qu’un jeu peut avoir plusieurs objectifs, je devais trouver un moyen de faire savoir au widget qu’il devait se rafraîchir ou non …

Comme vous le verrez dans le code, chaque fois que nous retirons une tuile ou que nous faisons exploser une bombe, nous émettons un ObjectEvent contenant les éléments suivants:

  • type de tuile étant impliqué;
  • le compteur associé à cet objectif particulier (le cas échéant)

Cet ObjectEvent est émis par le GameBloc, via le Sink sendObjectiveEvent / stream outObjectiveEvents.

4.8.2.1. Le ‘Objective BLoC’

La solution que j’ai mise en place repose sur l’utilisation d’un second BLoC, dédié aux objectifs.

Ce BLoC s’écrit comme suit:

class ObjectiveBloc {
  ///
  /// A stream only meant to return whether THIS objective type is part of the Objective events
  ///
  final BehaviorSubject<int> _objectiveCounterController = BehaviorSubject<int>();
  Stream<int> get objectiveCounter => _objectiveCounterController.stream;

  ///
  /// Stream of all the Objective events
  ///
  final StreamController<ObjectiveEvent> _objectivesController = StreamController<ObjectiveEvent>();
  Function get sendObjectives => _objectivesController.sink.add;

  ///
  /// Constructor
  ///
  ObjectiveBloc(TileType tileType){
    //
    // We are listening to all Objective events
    //
    _objectivesController.stream
                        // but, we only consider the ones that matches THIS one
                        .where((e) => e.type == tileType)
                        // if any, we emit the corresponding counter
                        .listen((event) => _objectiveCounterController.add(event.remaining));
  }

  @override
  void dispose() {
    _objectivesController.close();
    _objectiveCounterController.close();
  }
}

Explication:

Le BLoC consiste en l’interaction de 2 streams:

  • le _objectivesController sera alimenté avec tous les ObjectiveEvent

    • chaque fois qu’un ObjectiveEvent est émis, il est comparé au type de Tile qui correspond à l’objectif;
    • si ObjectiveEvent correspond au type de Tile, le compteur est entré dans “_objectiveCounterController”.
  • le _objectiveCounterController est chargé de transmettre le compteur qui sera utilisé par le Widget StreamObjectiveItem pour mettre à jour le compteur à l’écran.

Pour que cela fonctionne, il faut que le * StreamObjectiveItem * indique le type de * Tile * auquel il souhaite être informé des modifications apportées au compteur…

4.8.2.2. Le Widget StreamObjectiveItem

Ce widget est responsable de l’affichage du compteur associé à un objectif spécifique, identifié par son type de Tile.

Voici les parties intéressantes de ce widget, qui méritent une explication:

class StreamObjectiveItem extends StatefulWidget {
  StreamObjectiveItem({
    Key key,
    this.objective,
  }): super(key: key);

  final Objective objective;
 ...
}

class StreamObjectiveItemState extends State<StreamObjectiveItem> {
  ObjectiveBloc _bloc;
  GameBloc gameBloc;
  StreamSubscription _subscription;

  @override
  void didChangeDependencies(){
    super.didChangeDependencies();

    // Now that the context is available, retrieve the gameBloc
    gameBloc = Provider.of<GameBloc>(context, listen: false);
    _createBloc();
  } 
...
  void _createBloc() {
    _bloc = ObjectiveBloc(widget.objective.type);

    // Simple pipe from the stream that lists all the ObjectiveEvents into
    // the BLoC that processes THIS particular Objective type
    _subscription = gameBloc.outObjectiveEvents.listen(_bloc.sendObjectives);
  }
...
  @override
  Widget build(BuildContext context) {
...
    return Container(
...        
          StreamBuilder<int>(
            initialData: widget.objective.count,
            stream: _bloc.objectiveCounter,
            builder: (BuildContext context, AsyncSnapshot<int> snapshot){
              return Text(
                '${snapshot.data}',
...
  }
}

Explication:

  • Lorsque nous instancions le widget, nous devons passer le type d’Objective qu’il devra gérer;
  • Dès que le context est disponible, nous:
    • récupérons l’instance du gameBloc et,
    • créons une nouvelle instance du ObjectiveBloc, en l’initialisant avec le type de tuile;
  • Ensuite nous créons un pipe:
    • nous écoutons toute émission d’un ObjectiveEvent et,
    • en le relayons simplement dans notre instance dédiée de l’ObjectiveBloc.
  • Comme expliqué précédemment, l’instance dédiée de ObjectiveBloc alimentera uniquement son _objectivesController lorsque le type de l’objectif correspondra au type géré par ce Widget spécifique.
  • Un StreamBuilder écoute toutes les valeurs émises pour reconstruire le compteur Text.

4.8.3. La bannière Game Over

C’est beaucoup plus simple à réaliser…

Dans la page GamePage, dès que le context est disponible, nous instancions une StreamSubscription qui écoutera le Stream GameBloC.gameIsOver.

Dès qu’une valeur est émise par ce stream, GamePage appellera la méthode _onGameOver(bool) qui lancera l’animation pour afficher la bannière, comme indiqué dans l’extrait de code suivant:

@override
  void didChangeDependencies(){
    super.didChangeDependencies();

    // Now that the context is available, retrieve the gameBloc
    gameBloc = Provider.of<GameBloc>(context, listen: false);

    // Reset the objectives
    gameBloc.reset();
    
    // Listen to "game over" notification
    _gameOverSubscription = gameBloc.gameIsOver.listen(_onGameOver);
  }

Prochaines étapes

Comme je l’ai mentionné dans l’introduction, le jeu fonctionne, mais ce développement n’était qu’une ébauche, car je voulais expérimenter certains domaines.

Certaines parties du jeu n’ont pas été implémentées telles que:

  • explosion de roquette;
  • avalanche de tuiles qui ne bougent pas;
  • résolution de la glace;
  • explosion d’une bombe lorsque le joueur la déplace;
  • combinaison de bombes;
  • la synchronisation des sons, lorsqu’ils font partie d’une séquence d’actions.

Certaines parties nécessitent des tests supplémentaires, telles que:

  • des chaînes de plus de 5 tuiles;
  • chaînes après plusieurs séquences d’animations.

J’aurais pu faire tout cela, mais mon intention n’est pas de publier le jeu. Par conséquent, n’hésitez pas à considérer tout cela comme un exercice personnel, si vous le souhaitez…


Crédits

Certaines icônes telles que tnt, rocket, mine et wall proviennent de https://pngtree.com/.

Les sons proviennent de https://freesound.org/.

Les images de fond proviennent de https://www.freepik.com.

Les icônes colorées sont sous licence (que j’ai acheté). Alors s’il vous plaît ne les réutilisez pas.


Conclusions

Il y a encore tellement de choses que je pourrais dire à propos de ce code mais je pense que cet article est déjà assez long.

Je vous encourage à consulter le code, que j’ai essayé de documenter autant que possible. Même si le code est loin d’être parfait, il contient quelques fonctionnalités intéressantes.

La plupart du temps que j’ai passé sur ce développmeent (environ 70%) était axé sur les algorithmes et non sur les éléments visuels. Flutter a vraiment été bien pensé. Le ‘Hot Reload’ m’a aidé à peaufiner l’apparence, les algorithmes… sans avoir à redémarrer systématiquement à partir de la page d’accueil. C’était un gain de temps énorme.

Les animations sont fluides, même en utilisant les émulateurs lorsque plusieurs animations doivent être lues en parallèle.

La notion de superposition (Overlay) est fantastique et ouvre la porte à des choses formidables.

J’ai vraiment aimé travailler sur ce développment et je voulais partager quelques résultats et astuces avec vous.

J’espère que vous avez trouvé cet article intéressant.

Restez à l’écoute des articles à venir et, comme d’habitude, bon codage.