layout | permalink |
---|---|
home |
/demineur/ |
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{:target="_blank"} ou là{:target="_blank"}.
Lorsque vous aurez fini l'atelier, n'hésitez pas à partager votre travail
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:
revealed
isMine
Partons du squelette d'application suivant{:target="_blank"}
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 fonctionview
pour 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 = False
parisMine = True
dans leinit
puis 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
isMines
etrevealed
en recompilant à chaque fois.Extrayez le code que vous venez d'écrire dans une fonction
viewCell : Cell -> Html Msg
et utilisez cette fonction dansview : Model -> Html Msg
.
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 Float
et["Hello", "world"]
a pour typeList String
; la liste[5, "hello"]
n'est pas valide. -
En Elm, il n'y a pas de
for
ou 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]
foo
vaut 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 Msg
transforme une string "brut" en Html):ul [] [ li [] [text "foo"] , li [] [text "bar"] , li [] [text "baz"] ]
On peut alors utiliser la fonction
List.map
pour 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.map
pour 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!){:target="_blank"} peut être utile!- Ajoutez les attributs
style "display" "grid"
etstyle "grid-template-columns" "repeat(10, 50px)"
audiv
contenant toutes les cellules pour les afficher en grille.
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 !
{:start="2"} 2. Dans le REPL, en utilisant
List.map
avec une fonction anonyme, transformez la liste[1, 2, 3]
en[{id = 1}, {id = 2}, {id = 3}]
. 3. Toujours dans le REPL, en utilisant la fonctionList.range
{:target="_blank"} générez la liste[{id = 1}, {id = 2}, ..., {id = 100}]
. 4. Revenez à l'application et modifiez leinit
pour que chacune des 100 cellules ait unid
différent.
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
viewCell
pour 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.
Dans cette partie, on intercepte les messages et on modifie la grille en conséquence.
>>> À vous de jouer !
- Écrivez une fonction
revealIfId : Int -> Cell -> Cell
prenant en paramètre un identifiantid
et 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">
{:start="2"} 2. 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. 3. Modifier le modèle.
**Indication:** on pourra utiliser `List.map`
sur
model.cells
, avec une fonction anonyme faisant appel àrevealIfId
(ne cherchez pas à être "efficace" ;) ). 4. 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 !
{:start="5"} 5. Reprenez les exemples précédents dans le REPL. 6. Reprenez le code issue de l'étape 3. et ré-écrire la fonction d'update sans utiliser de fonction anonyme.
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 Cell
qui 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 duinit
actuel).Indication: utiliser la fonction
List.member
.Réécrire le
init
en 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.
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
update
demande 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
update
avec 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
main
par :main = Browser.element { view = view , update = update , init = \() -> init , subscriptions = always Sub.none }2. La compilation nous indique que nos `init` et `update` ne sont pas corrects. Dans votre fonction `update`, transformez : ```elm 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
. 3. Rajoutez un message en transformant le typeMsg
en: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
update
pour reconstruire entièrement la grille à la reception de ce message (vous pouvez utiliserbuildGrid
!). 4. Rajoutez l'import suivant en haut du fichierimport Random
. Dans leinit
, remplacez leCmd.none
par: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! 5. Dans le typeMsg
, transformezMine Int
enMines (List Int)
.Modifiez le
update
en conséquence.Grâce à la documentation{:target="_blank"}, cherchez comment dire au runtime "génère 20 entiers entre 1 et 100" et modifiez le
init
en 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.
On cherche ici à compter les mines autour de chaque case.
>>> À vous de jouer !
Écrire une fonction
neighbors : Int -> List Int
prenant 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 -> Int
prenant 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.length
etList.filter
ainsi que l'utilisation d'une fonction anonyme peuvent être utile.Modifier la fonction
viewCell
pour 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.
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 myMsg
au lieu deonClick myMsg
.
>>> À vous de jouer !
Rajoutez un attribut
withFlag : Bool
au 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 !
{:start="3"} 3. Remplacez le type
Cell
par 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 -> ...
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
{:target="_blank"}. -
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: Float
dans le typeModel
, une variante de messageNewFrame Float
puis définissez:subscriptions : Model -> Sub Msg subscriptions model = Browser.Events.onAnimationFrameDelta NewFrame
Modifiez ensuite le
main
en remplaçantsubscriptions = always Sub.none
parsubscriptions = 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 fonctionupdate
pour incrémenter le champelapsedTime
. À vous de jouer ensuite pour afficher le temps "seconde par seconde".
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.
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.
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!