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)
= makeColor 0.4 0.6 1.0 1.0
bgcolor = 30
fps in playYampa window bgcolor fps animSF
Ici, le système est implémenté par l’arrow animSF
:
animSF :: SF (Event InputEvent) Picture
= proc _ -> do
animSF <- time -< ()
t let angle = 2 * pi * realToFrac t
-< rotate (radToDeg angle)
returnA $ 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 ()
= do
main <- newStdGen
g let window = InWindow "AnimYampa4" (winWidth, winHeight) (0, 0)
= makeColor 0.4 0.6 1.0 1.0
bgcolor = 30
fps 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
= gameSF g >>^ drawGame mainSF g
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
= proc (gi, gs0) -> case gi of
keySF Event (G.EventKey (G.SpecialKey G.KeyEnter) G.Up _ _) ->
-< initGame (gs0 ^. gen)
returnA -> 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)
= proc (t0, gs0) -> do
updateSF <- time -< ()
t1 let gs1 = updateGame (realToFrac $ t1 - t0) gs0
-< (t1, gs1) returnA
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))
= proc (gi, (t0, gs0)) -> do
stepSF <- keySF -< (gi, gs0)
gs1 <- updateSF -< (t0, gs1)
(t2, gs2) -< (gs2, (t2, gs2)) returnA
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
= loopPre (0, initGame g) stepSF
gameSF g
-- 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 :
- Yampa: Elegant Functional Reactive Programming Language for Hybrid Systems
- yampa-gloss: A GLOSS backend for Yampa
- Book of Yampa
- A Brief Introduction to Functional Reactive Programming and Yampa
- Functional Reactive Programming, Continued
- Arrows, Robots, and Functional Reactive Programming
- 2048 game clone using Yampa FRP library
- A free and open source breakout clone in Haskell using SDL and FRP