Tape le 🐇
Règles du jeu 📐
Vous avez peut-être déjà entendu parler de "Whack-a-mole" ou "Tape-Taupe". C'est essentiellement ce que nous tenterons de réaliser aujourd'hui.
Quelques règles :
- 4 boutons affichent 3x 🐹 et 1x 🐇, placés aléatoirement
- Quand on appuie sur 🐇, un compteur vert "Bonk" est augmenté de 1
- Quand on appuie sur 🐹, un compteur rouge "Zloop" est augmenté de 1
- À chaque fois qu'on appuie sur un bouton, l'emplacement des 3 🐹 et du 🐇 est mélangé parmi les boutons
Créer le projet
- Dans Visual Studio Code, assurez-vous d'avoir sélectionné le profil Mobile dans
> Profile > Mobile.
- Tapez le raccourci
Ctrl+Shift+P, tapez Flutter et sélectionnez Flutter : New Project. - Sélectionnez Application.
- Dans l'explorateur, sélectionnez le dossier où vous stockez vos exercices.
- Tapez tape_le_lapin comme nom du projet.
- Cochez seulement android.
- Une nouvelle fenêtre va s'ouvrir. Vous pouvez resélectionner le profil Mobile au besoin.
- La création des fichiers et dossiers de départ peut prendre quelques secondes.
- Lancez le projet pour vous assurer que tout fonctionne.
- Commit + push
En Flutter, tous les noms de dossier sont en snake_case 🐍.
Flutter est très capricieux sur les chemins où sont les projets. Il n'accepte pas d'avoir des caractères spéciaux comme é ou à, et pas d'espace.
- Exemple invalide :
C:\Users\123456\Desktop\Jean-Mathéo Premier\tape_le_lapin - Exemple valide :
C:\Users\123456\Desktop\Jean-Matheo_Premier\tape_le_lapin
L'interface graphique
Nous allons placer les éléments graphiques avant de leur donner un comportement.
Préparation
Le fichier main.dart qui est dans lib/ est celui que nous allons modifier. Beaucoup de code a été généré, mais nous allons complètement l'enlever pour le moment. Nous vous recommandons tout de même d'y jeter un coup d'œil à un autre moment.
Tout ce que nous voulons garder est cette fonction d'entrée main, et la classe MyApp. On peut supprimer le reste qui est en dessous. Vous pouvez aussi enlever tous les commentaires, et retirer title: 'Flutter Demo Home Page' à la ligne TODO.
Voici ce qui devrait vous rester :
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(colorScheme: .fromSeed(seedColor: Colors.deepPurple)),
home: const MyHomePage(),
);
}
}
À ce point, MyHomePage devrait être souligné en rouge. C'est normal puisque nous venons d'enlever la classe à laquelle il faisait référence. Nous allons rajouter la classe manquante.
Créez un nouveau dossier nommé pages dans le dossier lib. Dans ce dossier, créez un fichier nommé lapin.dart.
Dans le fichier créé, commencez à taper stfu, puis ouvrez l'IntelliSense en appuyant sur Ctrl+Espace. Vous devriez pouvoir sélectionner une entrée nommée Flutter Stateful Widget. Nommez votre nouveau widget MyHomePage.

On remplace const Placeholder() par Scaffold comme suit :
import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(body: const Center(child: Text('Tape le 🐇')));
}
}
De retour dans main.dart, positionnez-vous sur MyHomePage, qui devrait être encore rouge. Appuyez sur Ctrl+. (point). Vous aurez l'option d'importer le widget que nous venons de créer.

Relancez l'application. Vous devriez maintenant voir "Tape le 🐇" centré.
Si tout fonctionne comme prévu, COMMIT + PUSH.
Titre
Retour dans lapin.dart.
Le titre est bien, mais rendons-le un peu plus gros :
Text('Tape le 🐇')
devient
Text('Tape le 🐇', style: TextStyle(fontSize: 40))
Découpage
Pour arriver à placer les prochains éléments, il va falloir séparer l'interface en 3 sections empilées les unes sur les autres :
- Titre (Déjà fait)
- Résultats (nombre de Bonk et de Zloop)
- Boutons pour jouer
Nous allons enrober le widget Text par un autre widget Column.
Positionnez votre curseur sur le widget Text, et appuyez sur Ctrl+. (point).
Cela fera apparaître un menu contextuel. Sélectionnez l'option pour enrober avec une Column.

On se retrouve avec le résultat suivant :
Center(
child: Column(
children: [
Text('Tape le 🐇', style: TextStyle(fontSize: 40))
],
),
),
Le widget Text est maintenant le premier élément dans une liste de widgets qui va du haut vers le bas.
Vous remarquerez que la plupart des Widgets ont un attribut :
- child : qui accepte un seul Widget enfant, ou
- children : qui accepte un tableau d'enfants
Bonk et Zloop
Maintenant les résultats pour compter combien de fois nous avons réussi à taper le 🐇 (Bonk), et combien de fois on s'est trompé en appuyant sur le 🐹 (Zloop). On veut afficher Bonk : x et Zloop : y côte à côte, sous le titre créé précédemment.
On ajoute donc une Row sous le Text.
Column(
children: [
Text('Tape le 🐇', style: TextStyle(fontSize: 40)), // <- Ne pas oublier la virgule ici
Row(
children: [
Text("Bonk : x"),
Text("Zloop : y")
]
),
],
),
Nos widgets sont bien alignés de gauche à droite, mais ils pourraient être mieux positionnés.
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text("Bonk : x"),
Text("Zloop : y")
],
),
Et un peu de style :
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
"Bonk : x",
style: TextStyle(color: Colors.green, fontSize: 30),
),
Text(
"Zloop : y",
style: TextStyle(color: Colors.red, fontSize: 30),
),
],
),
Boutons
Pour les boutons, nous allons afficher une grille de 2x2 boutons, qui vont tous afficher 🐹 pour le moment.
Column(
children: [
Text('Tape le 🐇', style: TextStyle(fontSize: 40)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
"Bonk : x",
style: TextStyle(color: Colors.green, fontSize: 30),
),
Text(
"Zloop : y",
style: TextStyle(color: Colors.red, fontSize: 30),
),
],
),
GridView.count(
shrinkWrap: true, // Dimensionner selon le contenu
physics: NeverScrollableScrollPhysics(), // Empêcher de scroller4
crossAxisCount: 2, // Nombre de colonnes
children: [
],
),
],
),
Une erreur surgit! Essayez de corriger l'erreur par vous-même. Pour y arriver, au besoin :
- Lis le message d'erreur surligné en rouge dans Visual Studio Code
- Lis le message d'erreur dans la console d'exécution Flutter
- Fais une recherche sur internet
- Regarde ce que l'IntelliSense de Visual Studio Code te propose
Si tu bloque pendant 5 minutes, appelle ton enseignant.
Ajoutons maintenant les boutons :
GridView.count(
shrinkWrap: true, // Dimensionner selon le contenu
physics: NeverScrollableScrollPhysics(), // Empêcher de scroller
crossAxisCount: 2, // Nombre de colonnes
children: [
ElevatedButton(onPressed: null, child: Text("🐹")),
ElevatedButton(onPressed: null, child: Text("🐹")),
ElevatedButton(onPressed: null, child: Text("🐹")),
ElevatedButton(onPressed: null, child: Text("🐹")),
],
),
On verra dans l'étape suivante ce que la propriété onPressed fait. C'est d'ailleurs parce qu'elle est à null que les boutons semblent grisés.
On peut aussi grossir les boutons en leur assignant une taille un peu plus élevée, tel que vu précédemment :
Text("🐹", style: TextStyle(fontSize: 100))
On peut aussi ajouter un peu d'espacement entre et autour des boutons :
GridView.count(
shrinkWrap: true, // Dimensionner selon le contenu
physics: NeverScrollableScrollPhysics(), // Empêcher de scroller
crossAxisCount: 2, // Nombre de colonnes
mainAxisSpacing: 20, // Espacement vertical
crossAxisSpacing: 20, // Espacement horizontal
padding: EdgeInsets.all(20), // Espacement autour
children: [
ElevatedButton(
onPressed: null,
child: Text("🐹", style: TextStyle(fontSize: 100)),
),
ElevatedButton(
onPressed: null,
child: Text("🐹", style: TextStyle(fontSize: 100)),
),
ElevatedButton(
onPressed: null,
child: Text("🐹", style: TextStyle(fontSize: 100)),
),
ElevatedButton(
onPressed: null,
child: Text("🐹", style: TextStyle(fontSize: 100)),
),
],
),
Si vous êtes habiles, vous avez remarqué que les boutons commencent à se répéter, et que chaque modification devra être appliquée 4 fois. Imaginez si c'était 10 boutons, il faudrait modifier le code 10 fois. Nous allons donc tenter une autre approche pour mieux gérer nos boutons :
GridView.count(
shrinkWrap: true, // Dimensionner selon le contenu
physics: NeverScrollableScrollPhysics(), // Empêcher de scroller
crossAxisCount: 2, // Nombre de colonnes
mainAxisSpacing: 20, // Espacement vertical
crossAxisSpacing: 20, // Espacement horizontal
padding: EdgeInsets.all(20), // Espacement autour
children: List.generate(4, (index) {
return ElevatedButton(
onPressed: null,
child: Text("🐹", style: TextStyle(fontSize: 100)),
);
}),
),
Fignoler l'interface
Tout est à peu près beau. Il ne nous reste qu'à aérer les 3 sections qui sont dans la Column. On peut ajouter l'attribut mainAxisAlignment: MainAxisAlignment.spaceEvenly dans la première Column que nous avons créée.
Avant de continuer, validez que votre interface ressemble à ça :
Comportement
Maintenant que nous avons l'interface graphique, nous allons implémenter le comportement.
Score
Le score des Bonk et des Zloop est appelé à changer. Pour garder le compte, il faut déclarer des variables :
class _MyHomePageState extends State<MyHomePage> {
int _scoreBonk = 0;
// Reste du code
}
Pour afficher la valeur de ces variables dans le texte affiché, remplacer les x et y qui avaient été ajoutés :
Text(
"Bonk : $_scoreBonk",
style: TextStyle(color: Colors.green, fontSize: 30),
),
Faites pareil pour le score de Zloop.
Position du 🐇
À chaque fois qu'on appuie sur un bouton, il faut déterminer la nouvelle position du 🐇. On veut tout de même le faire une première fois au lancement de l'application.
Pour y arriver, on doit garder en mémoire l'index de la position du 🐇 dans une variable, et utiliser l'objet Random pour déterminer au hasard cette position.
class _MyHomePageState extends State<MyHomePage> {
final Random _random = Random();
int _positionLapin = 0;
// Autres variables
// Reste du code
}
Au démarrage, on veut trouver la première position du 🐇.
class _MyHomePageState extends State<MyHomePage> {
// Variables
void initState() {
super.initState();
// Choisit un entier entre 0 et 4
_positionLapin = _random.nextInt(4);
}
// Reste du code
}
Pour afficher le 🐇, il faut modifier le code du générateur de liste de la grille.
GridView.count(
shrinkWrap: true, // Dimensionner selon le contenu
physics: NeverScrollableScrollPhysics(), // Empêcher de scroller
crossAxisCount: 2, // Nombre de colonnes
mainAxisSpacing: 20, // Espacement vertical
crossAxisSpacing: 20, // Espacement horizontal
padding: EdgeInsets.all(20), // Espacement autour
children: List.generate(4, (index) {
String emoji = _positionLapin == index ? "🐇" : "🐹";
return ElevatedButton(
onPressed: null,
child: Text(emoji, style: TextStyle(fontSize: 100)),
);
}),
),
On devrait maintenant voir 1x🐇 et 3x🐹. La position du 🐇 devrait changer à chaque redémarrage.
Réagir aux clics
Commençons par ajouter une fonction anonyme vide. Vous pouvez vous rafraîchir la mémoire sur les Fonctions Lambda 🥸.
ElevatedButton(
onPressed: () {},
child: Text(emoji, style: TextStyle(fontSize: 100)),
);
Lorsqu'on tape sur un bouton, on veut :
- Vérifier si on a tapé sur le bon bouton, et mettre à jour le compteur qui correspond à ce qui a été tapé.
- Trouver un nouvel emplacement pour le 🐇.
ElevatedButton(
onPressed: () {
setState(() {
if (index == _positionLapin) {
_scoreBonk++;
} else {
_scoreZloop++;
}
_positionLapin = _random.nextInt(4);
});
},
child: Text(emoji, style: TextStyle(fontSize: 100)),
);
Le setState indique à Flutter qu'on souhaite mettre à jour l'interface graphique. Flutter va donc cibler quelles zones de l'application il doit redessiner. Vous remarquerez que tout comme onPressed, setState utilise aussi une Fonction Lambda 🥸.
Bravo 🎉! Vous avez réussi à compléter votre première application en Flutter 🐦! Nous avons vu tout ce qui devait être fait pour le cours d'aujourd'hui. Si cela vous intéresse, vous pouvez consulter la suite pour voir comment ajouter une fonctionnalité plus avancée, qui nous donnera un vrai Tape-taupe!
Temps limité
Afin de stresser un peu notre joueur, ajoutons cette règle : Le jeu est mélangé à toutes les secondes.
Minuterie
Nous allons ajouter une minuterie responsable de gérer le temps.
class _MyHomePageState extends State<MyHomePage> {
Timer? _minuterie;
}
Pour éviter des problèmes pendant le développement, faisons en sorte que le minuteur soit annulé lorsque la page se ferme.
// À mettre sous le initState
void dispose() {
_minuterie?.cancel();
super.dispose();
}
Mélanger et gérer le temps
Trouver une nouvelle position pour le 🐇 va être un peu plus complexe qu'avant. Nous allons donc créer une fonction pour faire cette gestion.
// Au-dessus de la fonction build
void _nouvellePosition() {
setState(() {
_positionLapin = _random.nextInt(4);
});
}
On peut appeler la fonction où on faisait _positionLapin = _random.nextInt(4); avant, soit dans la fonction initState et dans la fonction anonyme de l'attribut onPressed.
void initState() {
super.initState();
_nouvellePosition();
}
ElevatedButton(
onPressed: () {
setState(() {
if (index == _positionLapin) {
_scoreBonk++;
} else {
_scoreZloop++;
}
});
_nouvellePosition(); // On sort aussi l'appel de fonction du setState, pour qu'il soit juste en dessous
},
child: Text(emoji, style: TextStyle(fontSize: 100)),
);
Premièrement, dans notre nouvelle fonction, on veut re-mélanger le 🐇 à toutes les secondes :
void _nouvellePosition() {
// Arrêter la minuterie si elle est déjà démarrée
_minuterie?.cancel();
setState(() {
_positionLapin = _random.nextInt(4);
});
// La fonction anonyme est appelée à toutes les secondes
_minuterie = Timer(const Duration(seconds: 1), () {
_nouvellePosition();
});
}