Interfaces utilisateur avec Arrow et Yampa, en Haskell

Voir aussi : video youtube - code source

Dans les deux articles précédents, on a vu le concept d’Arrow et quelques applications, notamment via le type Circuit. On va maintenant voir comment implémenter des interfaces utilisateur (animations, jeux…) avec Yampa, une bibliothèque basée sur la programmation fonctionnelle réactive et les arrows.

Yampa et yampa-gloss

Yampa est une bibliothèque permettant d’implémenter des systèmes hybrides. Typiquement, il s’agit d’interfaces utilisateur, qui peuvent avoir, à la fois, des événements à temps continu (par exemple, la position de la souris) et des événements à temps discret (par exemple, un clic de souris).

Yampa est basé sur la notion de signal, c’est-à-dire des fonctions (DeltaTime -> a) qui produisent une valeur au cours du temps. Il définit notamment le type SF (“signal function”) qui permet de manipuler des signaux :

newtype SF a b = SF (DeltaTime -> a -> (SF a b, b))
-- (définition simplifiée)

On notera que ce type ressemble au type Circuit de l’article précédent (mais avec le paramètre de temps en plus). Comme pour le type Circuit, le type SF instancie la classe Arrow, ce qui permet d’utiliser toutes les fonctionnalités correspondantes.

Pour gérer les événements ponctuels, Yampa définit également le type Event suivant :

data Event a = NoEvent | Event a

Yampa implémente la définition et l’exécution d’un système mais pas le backend (accès aux périphériques d’entrée et de sortie). Cependant, il peut être utilisé avec différents backends existants comme SDL, Gloss, Canvas HTML5…

La bibliothèque yampa-gloss permet d’utiliser Yampa avec le backend Gloss. Elle fournit essentiellement la fonction playYampa suivante :

-- | Play the game in a window, updating when the value of the provided
playYampa :: Display                       -- ^ The display method
          -> Color                         -- ^ The background color
          -> Int                           -- ^ The refresh rate, in Hertz
          -> SF (Event InputEvent) Picture -- ^ Signal function
          -> IO ()

Cette fonction permet d’exécuter une application en spécifiant les fonctions d’entrée/sortie (via Gloss) et le système (via Yampa). C’est donc l’équivalent de la fonction reactimate (cf. l’article précédent) mais adaptée à Gloss.

Le type InputEvent est définit par :

type InputEvent = Gloss.Event

Ainsi le système doit être de type SF (Event InputEvent) Picture, c’est-à-dire prendre des événements Gloss en entrée et produire des images Gloss en sortie.

Exemple 1

On veut implémenter une animation simple, où un disque tourne en rond au milieu de la fenètre.

Typiquement, l’application se résume à régler la taille de la fenêtre, couleur de fond, etc, et à spécifier le système à exécuter.

main :: IO ()
main = 
    let window = InWindow "AnimTest1" (400, 300) (0, 0)
        bgcolor = makeColor 0.4 0.6 1.0 1.0
        fps = 30
    in playYampa window bgcolor fps animSF

Ici, le système est implémenté par l’arrow animSF :

animSF :: SF (Event InputEvent) Picture
animSF = proc _ -> do
    t <- time -< ()
    let angle = 2 * pi * realToFrac t
    returnA -< rotate (radToDeg angle)
                $ translate 100 0
                $ circleSolid 20

Pour cet exemple, il suffit de récupérer le temps courant, d’utiliser ce temps pour calculer un angle de rotation et de retourner une image contenant le disque sur lequel on a appliqué les transformations géométriques.

Ainsi, le système peut accéder au temps (continu) via la définition de SF et la fonction time :: SF a Time. Il peut également accéder aux événements (ponctuels) via l’entrée de type Event InputEvent. Enfin, il produit son résultat via la sortie de type Picture.

Exemple 2

On veut maintenant implémenter une balle qui se déplace en ligne droite et rebondit contre les bords de la fenêtre. Quand on appuie sur la touche Entrée, la balle doit être réinitialisée aléatoirement (position et vitesse).

On doit donc gérer deux événements : le déplacement de la balle (continu) et sa réinitialisation (discret).

Cet exemple est inspiré des tutoriel 92 et tutoriel 94. On peut reprendre les mêmes types et fonctions, pour la logique de jeu et le dessin avec Gloss.

data Ball = Ball 
  { _pos :: V2 Float
  , _vel :: V2 Float
  }

data GameState = GameState 
  { _ball :: Ball
  , _gen :: StdGen
  }

drawGame :: GameState -> Picture
...

initGame :: StdGen -> GameState
...

updateGame :: Float -> GameState -> GameState
...

Comme pour l’exemple 1, la boucle de jeu est lancée via la fonction playYampa de la bibliothèque yampa-gloss :

main :: IO ()
main = do
  g <- newStdGen
  let window = InWindow "AnimYampa4" (winWidth, winHeight) (0, 0)
      bgcolor = makeColor 0.4 0.6 1.0 1.0
      fps = 30
  playYampa window bgcolor fps (mainSF g)

On peut décomposer l’arrow principale mainSF en plusieurs arrows, par exemple la gestion du jeu puis son affichage :

mainSF :: StdGen -> SF (Event InputEvent) Picture
mainSF g = gameSF g >>^ drawGame

On aurait pu aussi définir explicitement un type décrivant les événements du jeu puis “insérer” une arrow avant gameSF pour traduire les événements Gloss en événements de notre type, et ainsi rendre l’arrow de gestion de jeu indépendante de Gloss.

On peut maintenant définir différentes arrows, pour gérer les différents aspects de l’application. L’arrow keySF permet de mettre à jour le jeu selon les événements de l’utilisateur. Si celui-ci a appuyé sur la touche Entrée, on retourne un jeu réinitialisé, sinon on retourne le jeu inchangé :

keySF :: SF (Event InputEvent, GameState) GameState
keySF = proc (gi, gs0) -> case gi of
    Event (G.EventKey (G.SpecialKey G.KeyEnter) G.Up _ _) -> 
        returnA -< initGame (gs0 ^. gen)
    _ -> returnA -< gs0

L’arrow updateSF suivante implémente l’animation de la balle. Pour cela, il suffit de récupérer le temps courant, calculer le temps passé depuis la dernière exécution de updateSF puis mettre à jour le jeu. Ainsi, on a le temps/jeu courants en entrée de l’arrow et le temps/jeu actualisés en sortie.

updateSF :: SF (Time, GameState) (Time, GameState)
updateSF = proc (t0, gs0) -> do
  t1 <- time -< ()
  let gs1 = updateGame (realToFrac $ t1 - t0) gs0
  returnA -< (t1, gs1)

L’arrow stepSF suivante permet de gérer un pas d’exécution de l’application, c’est-à-dire les événements utilisateur (via keySF) et l’animation (via updateSF) :

stepSF :: SF (Event InputEvent, (Time, GameState)) (GameState, (Time, GameState))
stepSF = proc (gi, (t0, gs0)) -> do
    gs1 <- keySF -< (gi, gs0)
    (t2, gs2) <- updateSF -< (t0, gs1)
    returnA -< (gs2, (t2, gs2))

On peut voir stepSF comme une arrow qui prend un Event InputEvent et retourne un GameState, tout en actualisant un état courant, de type (Time, GameState).

Pour implémenter l’arrow de gestion de jeu, il suffit d’exécuter en boucle l’arrow stepSF, grâce à la fonction loopPre fournie par Yampa et en spécifiant l’état initial (du temps et du jeu courants) :

gameSF :: StdGen -> SF (Event InputEvent) GameState
gameSF g = loopPre (0, initGame g) stepSF

-- loopPre :: c -> SF (a, c) (b, c) -> SF a b

Conclusion

La bibliothèque Yampa permet de définir des fonctions sur des signaux, pour implémenter des systèmes hybrides, à événements continus ou discrets, comme par exemple, des interfaces utilisateur complexes. Elle utilise le concept d’Arrow, ce qui a l’avantage d’être un concept relativement connu et de profiter des fonctionnalités sur les arrows déjà existantes en Haskell.

À noter qu’il existe aussi la bibliothèque Dunai, basée sur les “monadic stream functions” mais utilisable également via les arrows.

Voir aussi :