Parcours
/
Elm
Elm
/
Exercices
/
Créateur de labyrinthes
Créateur de labyrinthes

Créateur de labyrinthes

Exercice d'apprentissage

Introduction

Random

Dans un langage purement fonctionnel comme Elm, une fonction appelée avec les mêmes arguments doit toujours renvoyer la même valeur. Par conséquent, une fonction dont la signature de type est rand : Int ne peut être implémentée que par rand = 4, ce qui n'augure rien de bon pour la génération d'entiers aléatoires.

Alors, comment générer des valeurs aléatoires en Elm ? On divise le problème en deux : d'abord, on décrit la valeur que l'on veut générer à l'aide d'un Random.Generator a, puis on génère une valeur.

La première façon de générer une valeur consiste à créer un Random.Seed avec initialSeed : Int -> Seed, puis à utiliser step : Generator a -> Seed -> ( a, Seed ), qui renvoie une valeur et une nouvelle graine. À noter que ces deux fonctions sont pures : les appeler deux fois avec les mêmes arguments produira les mêmes valeurs.

La seconde façon de générer une valeur consiste à utiliser generate : (a -> msg) -> Generator a -> Cmd msg, mais cela n'est possible qu'à l'intérieur d'une application Elm. Dans ce cas, l'environnement d'exécution d'Elm peut utiliser step ainsi que des ressources externes non pures pour générer des graines.

À partir de maintenant, on va se concentrer sur les générateurs.

Pour les besoins des exemples, imaginons que l'on ait défini, avec initialSeed et step, une fonction generate : Int -> Generator a -> List a qui génère un certain nombre de valeurs à partir d'un générateur.

Le module Random fournit deux générateurs de base pour générer des entiers et des nombres à virgule flottante dans un intervalle donné :

generate 5 (Random.int -5 5)
    --> [0, 3, -5, 5, 0]

generate 3 (Random.float 0 5)
    --> [1.61803, 3.14159, 2.71828]

Ces valeurs peuvent être combinées en tuples, ou en tableaux de valeurs :

generate 2 (Random.list 3 (Random.int 0 3))
    --> [[0, 3, 3], [1, 3, 2]]

generate 2 (Random.pair (Random.int 0 3) (Random.float 10 10.3))
    --> [(0, 10.23412), (2, 10.17094)]

Le paquet elm-community/random-extra fournit beaucoup d'autres générateurs pour diverses structures de données : des strings, des dates, des dictionnaires, des tableaux, des ensembles, etc.

On peut créer des générateurs qui ne renvoient qu'une seule valeur avec Random.constant :

generate 4 (Random.constant "hello")
    --> ["hello", "hello", "hello", "hello"]

On peut choisir au hasard, avec une probabilité égale, parmi des éléments donnés en utilisant Random.uniform :

generate 5 (Random.uniform Red [Green, Blue])
    --> [Red, Blue, Blue, Green, Red]

Random.uniform prend deux arguments (Red et [Green, Blue]) pour garantir qu'il y a au moins une valeur parmi laquelle choisir, puisqu'un seul tableau pourrait être vide.

On peut aussi ajuster les probabilités avec Random.weighted :

generate 5 (Random.weighted (80, Red) [(15, Green), (5, Blue)])
    --> [Red, Red, Green, Red, Red]

Les valeurs n'ont pas besoin de totaliser 100, elles seront de toute façon renormalisées.

On peut accéder à l'intérieur d'un générateur avec Random.map :

generate 3 (Random.int 1 6 |> Random.map (\n -> n * 10))
    --> [30, 60, 10]

On peut aussi utiliser Random.map2 jusqu'à Random.map5 pour combiner davantage de générateurs :

position =
    Random.map3
      (\x y z -> Position x y z)
      (Random.float -100 100)
      (Random.float -100 100)
      (Random.float -100 100)

generate 1 position
    --> [Position 33.788 -98.321 10.0845]

Pour des usages plus complexes, on dispose de Random.andThen : (a -> Generator b) -> Generator a -> Generator b, qui permet d'utiliser la valeur générée par un générateur pour en créer un autre :

bool = Random.uniform True [False]

failHalfOfTheTime : Generator a -> Generator (Maybe a)
failHalfOfTheTime generator =
    bool
        |> Random.andThen
            (\boolResult ->
                if boolResult then
                    Random.map Just generator

                else
                    Random.constant Nothing
            )

generate 6 (Random.int 0 1 |> failHalfOfTheTime)
    --> [Nothing, Just 1, Just 0, Nothing, Just 1, Nothing]

Il est parfois utile de définir un générateur de façon récursive. Dans ce cas, tu devras peut-être utiliser Random.lazy pour empêcher le compilateur de dérouler le générateur à l'infini.

type Peano
    = Zero
    | Next Peano


peano : Generator Peano
peano =
    Random.uniform (Random.constant Zero)
        [ Random.map Next (Random.lazy (\_ -> peano))
        ]
        |> Random.andThen identity

generate 12 peano
    --> [Zero, Next(Next(Zero)), Zero, Next(Zero), Zero, Zero, Next(Zero), Zero, Zero, Next(Zero), Next(Next(Next(Zero)))]

Cet exemple est un peu dense, alors décomposons-le.

Peano est un type qui représente les entiers positifs : Zero vaut 0, Next(Zero) vaut 1, Next(Next(Zero)) vaut 2, etc. On définit peano pour obtenir un nombre Peano aléatoire.

Tout d'abord, remarque que Random.lazy n'ajoute rien à la logique : il empêche simplement le compilateur d'écrire peano dans peano indéfiniment.

On utilise Random.uniform pour choisir entre zéro (avec une probabilité de 50 %) et un autre nombre Peano plus un (avec une probabilité de 50 %). Cependant, contrairement à l'exemple précédent, Random.uniform ne choisit pas parmi des valeurs (comme Zero) mais parmi des générateurs (comme Random.constant Zero), puisque l'on veut utiliser peano lui-même. Cela signifie que Random.uniform va renvoyer une valeur de type Generator (Generator Peano), ce qui ne correspond pas à ce dont on a besoin.

Pour « aplatir » le générateur, on le fait passer dans Random.andThen : (a -> Generator b) -> Generator a -> Generator b, où l'on veut que b soit Peano. Comme Generator a est Generator (Generator Peano), a doit être Generator Peano, et la fonction (a -> Generator b) doit être de type (Generator Peano -> Generator Peano). On ne veut rien modifier, donc identity est le bon choix.

Enfin, quel genre de nombres peano va-t-il produire ? On sait qu'il produira 0 dans 50 % des cas, ou une autre itération de lui-même plus un dans 50 % des cas. Cela signifie que les nombres seront 0 (50 % du temps), 1 (25 % du temps), 2 (12 % du temps), 3 (6 % du temps), etc. peano peut produire des nombres arbitrairement grands, mais avec une probabilité qui décroît exponentiellement.

Instructions

Tu es un chercheur en labyrinthes, et tu travailles à concevoir une nouvelle génération de donjons et de terrains d'aventure.

Tu es un théoricien : l'agencement physique des labyrinthes ne te préoccupe pas (c'est le travail des ingénieurs en labyrinthes). Ce qui t'intéresse, c'est leur complexité mathématique : facteur de ramification, profondeur maximale et ainsi de suite.

Tu décides d'aborder ton prochain projet avec une approche de génération aléatoire.

1. Commence par la fin

Un bon labyrinthe n'est fait, pour l'essentiel, que de culs-de-sac.

Définis le générateur deadend de sorte qu'il génère toujours la variante DeadEnd.

2. On n'attrape pas les mouches avec du vinaigre

Donnons aux aventuriers une raison de s'aventurer dans tes labyrinthes.

Définis le générateur treasure de sorte qu'il génère Gold, Diamond ou Friendship avec une probabilité égale.

Ensuite, définis le générateur room de sorte qu'il enveloppe les valeurs générées par treasure dans la variante Room.

3. Ramifie

Et maintenant, le cœur et l'âme d'un labyrinthe : les branches.

Le générateur branch prend un argument Generator Maze, qui est un générateur capable de générer un labyrinthe quelconque.

Définis le générateur branch de sorte qu'il génère une variante Branch contenant plusieurs branches menant à des labyrinthes générés par son argument.

Le nombre de branches doit être compris entre 2 et 4 (au-delà, l'ingénieur en labyrinthes se fâcherait), déterminé aléatoirement avec une probabilité égale.

4. Des labyrinthes formidables

Il est temps de passer aux choses sérieuses.

Définis le générateur maze de sorte qu'il génère un cul-de-sac 60 % du temps, une salle au trésor 15 % du temps et une branche 25 % du temps. Les branches doivent générer de nouveaux labyrinthes en utilisant maze de façon récursive. Veille à utiliser deadend, room et branch dans le générateur.

Chaque branche contient en moyenne 3 labyrinthes, mais seuls 25 % d'entre eux se ramifieront à nouveau, ce qui fait moins d'un, donc tes labyrinthes générés finiront probablement par se terminer.

5. Il faut aller plus profond

Ton maze est sympa, mais il a quelques problèmes :

  1. 75 % des valeurs générées n'ont aucune branche, ce qui ressemble difficilement à de vrais labyrinthes
  2. Les aventuriers risquent de tomber sur des salles au trésor tôt dans le labyrinthe et de décider d'arrêter l'exploration
  3. Même si des labyrinthes profonds peuvent être générés, ils sont peu probables : un labyrinthe de profondeur 3 a environ 3 % de chances d'être généré, un labyrinthe de profondeur 10, 0,2 % (la profondeur d'un labyrinthe se calcule avec MazeMakerSupport.mazeDepth)

Définis le générateur mazeOfDepth n de sorte qu'il génère un labyrinthe de profondeur n. Seul le niveau le plus profond 0 doit contenir des culs-de-sac ou des salles au trésor (avec une probabilité égale) ; tous les autres niveaux doivent être une branche contenant des labyrinthes de profondeur n-1 générés de façon récursive avec mazeOfDepth. Veille à utiliser deadend, room et branch dans le générateur.

Modifie via GitHub Le lien s'ouvre dans une nouvelle fenêtre ou un nouvel onglet
Elm Exercism

Prêt à commencer Créateur de labyrinthes ?

Inscris-toi sur Exercism pour apprendre et maîtriser Elm avec 28 concepts110 exercices, et un vrai mentorat humain, le tout gratuitement.