Ceci est la deuxième partie de l’atelier “Développez une application web qui n’explose pas”. Retrouvez l’introduction ici.
Nous allons construire un clone du “démineur”. Si vous ne connaissez pas, vous pouvez essayer ici ou là.
Lorsque vous aurez fini l’atelier, n’hésitez pas à partager votre travail
Mise en place
Commençons par un objectif simple: afficher une seule case de démineur, selon qu’elle est “révélée” (quand l’utilisateur clique dessus) ou pas, et si elle contient une bombe ou pas.
Notre modèle contient donc uniquement une cellule avec deux attributs booléens:
revealedisMine
Partons du squelette d’application suivant
Rappel: pour accéder aux attributs imbriqués, la syntaxe est identique
à celle de JS. Par exemple pour accéder à l’attribut isMine de la cellule
contenue dans le modèle, on écrit: model.cell.isMine.
>>> À vous de jouer !
- En utilisant un
if/then/else, modifiez la fonctionviewpour afficher une bombe si la cellule est une bombe et la chaîne “0” sinon (on ignore pour l’instant l’attributrevealed). On pourra utiliser l’emoji suivant : 💣 (copiez-collez le dans votre code, c’est du texte ! Remarque: dans un éditeur “hors-ligne” il peut ne pas s’afficher si la police ne supporte pas les emojis; il ne devrait pas y avoir de soucis dans le navigateur 😊).- Testez votre code, en remplaçant le
isMine = FalseparisMine = Truedans leinitpuis en recompilant.Ajoutez la gestion de
revealed: s’il est à faux, on n’affiche rien dans la case, sinon, on fait comme en 1.Rappel astucieux: dans Ellie, en haut à droite de l’éditeur se trouve un bouton pour formatter votre code automatiquement !
- Testez votre code avec différentes combinaisons de
isMinesetrevealeden recompilant à chaque fois.- Extrayez le code que vous venez d’écrire dans une fonction
viewCell : Cell -> Html Msget utilisez cette fonction dansview : Model -> Html Msg.
Plusieurs cellules!
On modifie notre modèle, au lieu d’avoir juste UNE cellule, on a une
liste de cellules (notez au passage que cell devient cells) :
type alias Model =
{ cells : List Cell }
Quelques choses à savoir sur les listes:
- Liste vide : on peut construire la liste vide avec
[]. - Tous les éléments d’une liste on le même type. Par exemple
[1.1, 5.5, 42.5]a pour typeList Floatet["Hello", "world"]a pour typeList String; la liste[5, "hello"]n’est pas valide. - En Elm, il n’y a pas de
forou dewhile. On a mieux : lemap! Cette fonction permet d’application une transformation à tous les éléments de la liste. Par exemple:double x = 2 * x foo = List.map double [1, 2, 3]foovaut alors[2, 4, 6]; essayez dans le REPL !Si on veut afficher une liste “foo, bar, baz” en HTML, on peut utiliser le code suivant (on rappelle que
text : String -> Html Msgtransforme une string “brut” en Html):ul [] [ li [] [text "foo"] , li [] [text "bar"] , li [] [text "baz"] ]On peut alors utiliser la fonction
List.mappour simplifier le code:displayItem : String -> Html Msg displayItem itemDescription = li [] [text itemDescription] ul [] (List.map displayItem ["foo", "bar", "baz"]) -- revient au même qu'avant!On peut encapsuler cela dans une fonction, pour avoir un code très lisible et réutilisable après:
viewListOfWords : List String -> Html Msg viewListOfWords items = ul [] (List.map displayItem items) -- appels de la fonction : viewListOfWords ["Sébastien", "Jean-Baptiste", "Tariq"] viewListOfWords ["Bananes", "Abricots", "Pommes", "Pastèques"]
>>> À vous de jouer !
- Changez le modèle comme indiqué ci-dessus.
- Laissez vous guider par le compilateur pour corriger votre code !
- pour l’instant codez “en dur” 3 cellules dans le
init,- dans la fonction
view : Model -> Html Msg, utilisezList.mappour inclure toutes les celulles dans undiv.- Une fois que le code compile et que vos 3 cellules s’affichent, creez 100 cellules identiques dans le modèle initial ; la fonction
List.repeat(lien cliquable!) peut être utile!- Ajoutez les attributs
style "display" "grid"etstyle "grid-template-columns" "repeat(10, 50px)"audivcontenant toutes les cellules pour les afficher en grille.
Donner des identifiants!
Pour gérer les clicks sur les boutons, nous aurons besoin
que chaque cellule ait un identifiant. Le type Cell devient alors:
type alias Cell =
{ id: Int, isMine : Bool, revealed : Bool }
Dans les sections à venir, il n’y aura pas de rendu “graphique” visible, le compilateur sera notre outil de test principal ! Un petit de patience, à la fin de la partie “Update !”, vous aurez la satisfaction de voir qu’à partir du moment où le code compile, il fonctionne comme on l’attend.
>>> À vous de jouer !
- Changer le type
Cell, et laissez vous guider par le compilateur pour corriger le code. Pour l’instant mettez 1 commeidà toutes les cellules.
Fonctions anonymes
Rappelez-vous que grâce à List.map on peut appliquer une transformation à une liste. L’exemple que j’avais donné était:
double x = 2 * x
foo = List.map double [1, 2, 3]
Cependant, il est fréquent que la transformation (double dans notre cas)
soit très courte et qu’on ne veuille pas lui donner de nom. On peut alors
utiliser une fonction anonyme avec la syntaxe
\<argument> -> <resultat>; notre exemple devient alors:
foo = List.map (\x -> 2 * x) [1, 2, 3]
Remarque : en javascript, on peut déclarer une fonction anonyme de deux façons:
function(x) { return 2 * x; }ou (ES6):
x => 2 * x
>>> À vous de jouer !
- Dans le REPL, en utilisant
List.mapavec une fonction anonyme, transformez la liste[1, 2, 3]en[{id = 1}, {id = 2}, {id = 3}].- Toujours dans le REPL, en utilisant la fonction
List.rangegénérez la liste[{id = 1}, {id = 2}, ..., {id = 100}].- Revenez à l’application et modifiez le
initpour que chacune des 100 cellules ait uniddifférent.
Générer des messages !
Dans cette partie, on commence à gèrer le “clic gauche” permettant de révéler une case.
Pour cela, on va modifier notre type Msg pour indiquer qu’on peut
engendrer le message “La case d’id X doit être révélée”. C’est donc un message qui prend un paramètre entier. On l’indique de cette façon:
type Msg
= Reveal Int
On pourra alors constuire les messages Reveal 1 pour révéler la cellule
d’identifiant 1, Reveal 32 pour celle d’identifiant 32…
>>> À vous de jouer !
- En haut du fichier, ajoutez l’import:
import Html.Events exposing (onClick).- Modifiez le type
Msg.- Modifiez la fonction
viewCellpour qu’un message adapté soit généré à chaque click sur une cellule.En compilant avec l’option
--debug, un débogueur à “voyage dans le temps est incorporé en bas à droite de la page. Sous “Ellie”, il faut aller chercher en haut à droite l’onglet “DEBUG”.Cliquez sur votre grille sur différentes cellules et vérifiez dans le debogueur que les messages ont bien été générés.
Update !
Dans cette partie, on intercepte les messages et on modifie la grille en conséquence.
>>> À vous de jouer !
- Écrivez une fonction
revealIfId : Int -> Cell -> Cellprenant en paramètre un identifiantidet une cellule. Si l’identifiant de la cellule n’est pasid, la fonction renvoie la cellule sans la modifier. Sinon, la fonction renvoie la cellule avec le champrevealedàTrue. Vérifiez que le code compile!
Rappelez-vous que pour l’application “Compteur”, nous avions écrit le code suivant pour réagir aux différents messages :
case msg of
Increment ->
...
Decrement ->
...
Ici, il y a un seul message possible et celui-ci a un argument. Nous pouvons alors effectuer le filtrage par motif (“pattern matching” en anglais) suivant:
case msg of
Reveal 1 ->
<reveal cell of id 1>
Reveal 2 ->
<reveal cell of id 2>
...
Reveal 100 ->
<reveal cell of id 100>
Il serait bien trop long d’écrire cela de cette manière. Nous pouvons capturer l’identifiant en lui donnant un nom:
case msg of
Reveal id ->
<reveal cell of id ... "id">
- Modifiez la fonction update pour intercepter les messages de la forme
Reveal id. Ne cherchez pas à modifier le modèle, faites juste en sorte d’avoir un code qui compile.Modifier le modèle.
Indication: on pourra utiliser
List.mapsurmodel.cells, avec une fonction anonyme faisant appel àrevealIfId(ne cherchez pas à être “efficace” ;) ).- TADIN ! Cliquez sur votre grille, vous devez la “révéler” au fur et à mesure (bon pour l’instant, ce n’est pas très intéressant, il n’y a soit que des bombes, soit aucune bombe!).
Dans l’étape 3. ci-dessus, on peut en fait se passer de la fonction anonyme grâce à:
Application partielle
En Elm, on peut appliquer partiellement les fonctions. Par exemple (essayez dans le REPL!) :
add a b = a + b
addFive = add 5
Observez alors le type de addFive : number -> number. C’est une
fonction qui attend encore 1 argument (add attend 2 arguments, addFive
fourni le premier par défaut!).
On peut ensuite manipuler addFive comme n’importe quelle autre fonction à
1 argument numérique. Par exemple addFive 3 donne 8. “Moui bon, et alors” me direz vous… eh bien on peut faire:
List.map addFive [1, 2, 3] -- résultat: [6, 7, 8]
Mais d’après la définition de addFive, cela revient exactement à:
List.map (add 5) [1, 2, 3] -- résultat: [6, 7, 8]
Plus besoin de définir une fonction auxiliaire!
>>> À vous de jouer !
- Reprenez les exemples précédents dans le REPL.
- Reprenez le code issue de l’étape 3. et ré-écrire la fonction d’update sans utiliser de fonction anonyme.
Placement de bombes
Dans cette partie, on place les bombes de façon aléatoire sur la grille.
>>> À vous de jouer !
- Voici la signature de la fonction
List.member : a -> List a -> Bool. Tentez de comprendre ce qu’elle calcule (combien d’arguments ? de quels types les arguments ? quelle est la valeur de retour ?) et vérifiez le dans le REPL.Écrire une fonction
buildGrid : List Int -> List Cellqui prend en argument la liste des identifiants de cellules qui doivent être des mines. Elle renvoie une liste de 100 cellules d’identifiant de 1 à 100 (le code ne devrait pas être très différent de celui duinitactuel).Indication: utiliser la fonction
List.member.- Réécrire le
initen appelantbuildGrid [2, 3, 25, 35]et testez que vos bombes s’affichent au bon endroit.
Pour générer de l’aléatoire, on a d’abord besoin d’installer un package. Si vous compilez “à la main”, vous devez faire un :
elm install elm/random
Sous Ellie, dans la goutière de gauche, il y a un petit icône en deuxième
position vous permettant d’installer un package directement ; il faut chercher random et sélectionner le premier module.
Effectuer des “effets de bord”
Jusqu’ici, on a uniquement programmé des fonctions dite pures dans le sens où si on a appelle plusieurs fois de suite une fonction avec les mêmes arguments, on aura toujours le même résultat. Et en Elm, les fonctions ne peuvent agir QUE de cette manière là.
Ce n’est pas le cas dans des langages impératifs comme JS, Java ou C. Par exemple, prenons le code suivant:
var lang = "fr"; function greetings(name) { switch(lang) { case "fr": return "Bonjour " + name; case "en": return "Hello " + name; default: // default to Elvish (why not?) return "Suilad " + name; } } greetings("Sébastien"); lang = "en"; greetings("Sébastien");Le premier appel à
greetings("Sébastien")renvoie “Bonjour Sébastien” alors que le second renvoie “Hello Sébastien”… La valeur renvoyée dépend d’une configuration globale qui peut changer au cours de l’exécution. En Elm ce n’est pas possible!
Et générer un nombre aléatoire ne peut pas être une action “pure”: on veut qu’à chaque appel, le résultat soit différent; cela demande de conserver quelque part un état mémorisant le dernier nombre généré.
En Elm, c’est le runtime qui effectue les effets de bords. Le principe est le suivant:
- notre fonction
updatedemande au runtime d’effectuer une commande (dans notre cas “génère moi une donnée aléatoire”). - le runtime effectue ses calculs “impurs” pour effectuer la commande.
- une fois la commande effectuée, le runtime appelle de nouveau la
fonction
updateavec un message de la formeCommandPerformed result.
Le schéma vu précédemment devient alors:

>>> À vous de jouer !
- Comme vous l’avez vu, on a légèrement changé le modèle, on doit donc l’indiquer au runtime. Pour cela, modifiez votre
mainpar :main = Browser.element { view = view , update = update , init = \() -> init , subscriptions = always Sub.none }- La compilation nous indique que nos
initetupdatene sont pas corrects. Dans votre fonctionupdate, transformez :Reveal id -> <nouveau model>en :
Reveal id -> (<nouveau model>, Cmd.none)Cela indique au runtime qu’il n’y a aucune commande à effectuer lorsqu’on révèle une case.
Réalisez une transformation similaire sur le
init.- Rajoutez un message en transformant le type
Msgen:type Msg = Reveal Int | Mine IntCe nouveau message sera produit par le runtime lorsqu’il aura généré un nombre aléatoire. Ce nombre sera l’identifiant de la cellule minée (pour l’instant, on simplifie avec une seule cellule minée).
Mettez à jour la fonction
updatepour reconstruire entièrement la grille à la reception de ce message (vous pouvez utiliserbuildGrid!).- Rajoutez l’import suivant en haut du fichier
import Random. Dans leinit, remplacez leCmd.nonepar:Random.generate Mine (Random.int 1 100)Cela indique au runtime de générer un nombre entier x entre 1 et 100, puis de produire le message
Mine x.Testez plusieurs fois en mettant
revealedàTrue, la bombe devrait se trouver à des endroits différents à chaque fois!Dans le type
Msg, transformezMine IntenMines (List Int).Modifiez le
updateen conséquence.Grâce à la documentation, cherchez comment dire au runtime “génère 20 entiers entre 1 et 100” et modifiez le
initen conséquecne.Remarque: de cette façon, nous n’aurons pas forcément 20 mines car on peut avoir plusieurs fois le même identifiant (voir la partie Finitions pour une idée d’algorithme.
Compter les mines !
On cherche ici à compter les mines autour de chaque case.
>>> À vous de jouer !
- Écrire une fonction
neighbors : Int -> List Intprenant en argument l’identifiant d’une cellule et renvoyant la liste des identifiants des “voisins” de cette cellules.Écrire une fonction
countMinesAround : Int -> List Cell -> Intprenant en argument l’identifiant d’une cellule cible, la liste de toutes les cellules et renvoie le nombre de mines autour de la cible.Les fonctions
List.lengthetList.filterainsi que l’utilisation d’une fonction anonyme peuvent être utile.Modifier la fonction
viewCellpour afficher le nombre de mines au voisinage lorsqu’elle est révélée (et que ce n’est pas une mine!).Vous devrez pour cela rajouter comme argument la liste de toutes les mines. Réfléchissez à l’ordre des arguments pour pouvoir utiliser une application pratielle de cette fonction dans la fonction
view.- Révélez toutes les cases (dans
buildGrid… ou en cliquant sur toutes les cases, c’est vous qui voyez!) et vérifiez que le comptage est correct.
Ajouter les drapeaux !
On gère ici l’ajout des drapeaux (grâce au “clic droit”). On utilise
pour cela l’événement JS contextmenu ; cet évènement n’est pas
supporté “out of the box” en Elm:
- Installer le package
elm/json - Rajoutez l’import
import Json.Decode - Ajoutez cette fonction :
onRightClick : msg -> Attribute msg onRightClick msg = Html.Events.preventDefaultOn "contextmenu" (Decode.succeed ( msg, True )) - Vous pouvez maintenant capturer les “clicks droits” exactement de la
même façon que vous capturez les “clicks gauche” en utilisant
onRightClick myMsgau lieu deonClick myMsg.
>>> À vous de jouer !
- Rajoutez un attribut
withFlag : Boolau typeCell.En suivant les mêmes étapes que pour la gestion du “click gauche”, faites en sorte que lorsque l’utisateur effectue un “click droit” sur une case non révélée, un drapeau s’affiche (on pourra utiliser l’emoji :🚩 ).
Remarque: il faut changer le code à beaucoup d’endroits par rapport aux autres points de cet atelier. Encore une fois, laissez vous guider par le compilateur!
Félicitation! Vous avez un démineur quasi-complet! En revanche, notre
étape introduit la possiblité “d’états impossibles” dans notre modèle.
En effet, que faire avec une cellule qui est “révélée avec un drapeau”
(c’est à dire les attributs revealed et withFlag sont tous deux à
True)?
Même si dans notre code actuel cette situation ne peut pas se produire, il faudra qu’on soit bien attentifs à ne pas introduire cet état lorsqu’on modifiera notre code plus tard. Et comme nous somme humains, il arrivera un moment où nous introdurons cet état vide de sens.
Pour éviter cela, il faut repenser la structure de données : notre cellule
est dans trois états possibles : masquée, révélée ou avec drapeau.
Créons donc un type reflétant cet état et reconstruisons le type
Cell :
type CellStatus
= Revealed
| WithFlag
| Masked
type alias Cell =
{ id : Int, isMine : Bool, cellStatus : CellStatus }
>>> À vous de jouer !
Remplacez le type
Cellpar celui donné ci-dessus. Faites en sorte que votre code compile, tout devrait alors fonctionner !Remarque : comme pour les messages, on peut filtrer par motif sur les valeurs de type
CellStatus:case cell.cellStatus of Revealed -> ... WithFlag -> ... Masked -> ...
Finitions
N’oubliez pas de partager votre travail!
Pour avoir un jeu pleinement fonctionnel :
- Si le joueur révèle une mine, affichez un message de défaite, empêchez le de continuer à jouer et révélez toutes les mines.
- Affichez le nombre de drapeaux / nombre de mines.
- Ajoutez un bouton pour recommencer le jeu.
- Permettre au joueur de retirer un drapeau s’il effectue un “clic droit” sur un drapeau déjà placé.
- Si un utilisateur clique sur une case n’ayant aucun voisin miné, révéler toute la zone sans mines (il faudra programmer une fonction récursive ;) ).
- Faites en sorte d’avoir exactement 20 mines. Pour cela, créez
une liste constituée de 20 éléments
True, puis 80 élémentsFalse; mélanger cette liste grâce à au modulerandom-extra(qu’il faudra installer). Puis, au lieu deList.range, utiliserList.indexedMap. -
Ajoutez un compteur de temps. Nous allons capturer chaque refraîchissement de la page (c’est à dire à chaque “frame”) afin de faire “avancer” un compteur de temps.
Pour cela, ajouter un champ
elapsedTime: Floatdans le typeModel, une variante de messageNewFrame Floatpuis définissez:subscriptions : Model -> Sub Msg subscriptions model = Browser.Events.onAnimationFrameDelta NewFrameModifiez ensuite le
mainen remplaçantsubscriptions = always Sub.noneparsubscriptions = subscriptions.Cela a pour effet de générer un nouveau message
NewFrame deltaTà chaque frame, ledeltaTétant égal au temps écoulé depuis la frame précédente (exprimé en milisecondes). Vous pouvez alors intercepter ce message dans la fonctionupdatepour incrémenter le champelapsedTime. À vous de jouer ensuite pour afficher le temps “seconde par seconde”.
Aller plus loin
Le guide officiel
C’est ici : http://guide.elm-lang.org/.
Il est synthétique et reprend en profondeur les points explicités dans cet atelier. Il est ponctué de petits exercices en fin de chaque section pour s’exercer.
Demander de l’aide !
Deux grandes plateformes :
- le slack Elm très adapté pour de courtes questions, ou un échange avec des programmeurs Elm expérimentés. Rejoignez nous sur le channel #france, posez une question sur #beginners et publiez vos exploits Elmiens sur #news-and-links!
- le Discourse pour des questions plus poussées.
Se retrouver
En france, il y a plusieurs “Meetup” Elm :
Paris héberge le plus gros rassemblement mondial autour de Elm : la conférence Elm Europe.
Envie d’organiser un événement autour de Elm dans votre ville/entreprise/école d’ingé ? Venez en discuter sur Slack sur le channel #france!