Utiliser un ECS pour développer des jeux, en Haskell

Voir aussi : video youtube - code source

Haskell a quelques fonctionnalités intéressantes pour développer des jeux. On a déjà vu comment implémenter des animations avec la bibliothèque gloss (voir le tutoriel : Animations 2D interactives, en Haskell/Gloss) et comment utiliser la bibliothèque lens et la monade State (voir le tutoriel : Exemple “d’Haskellisation” de code).

Il existe également une implémentation de l’architecture ECS (Entity Component System). Celle-ci est très classique dans le domaine du jeu vidéo et permet de gérer efficacement les données de jeu.

L’architecture ECS et la bibliothèque Apecs

L’idée est de décrire/manipuler les éléments selon leurs composants. Un composant est une caractéristique particulière (par exemple, une position ou une vitesse). Une entité est un élément du jeu auquel on peut attribuer certains de ces composants (par exemple, une balle avec une position et une vitesse données).

La bibliothèque Apecs propose une implémentation de l’architecture ECS en Haskell. Concrètement, elle permet de définir des composants et des entités, d’accéder aux entités, de manipuler les composants via des mapping/réduction… Apecs implémente un état courant, qui permet d’ajouter/modifier/supprimer des données ou d’avoir des données globales (par exemple, un score de jeu).

La documentation d’Apecs contient des ressources/tutoriels pour apprendre à utiliser cette bibliothèque. Ici, on va plutôt voir comment modifier un code existant de façon à utiliser Apecs et quels sont les avantages/inconvénients que cela apporte.

Exemple 1 (anim)

Dans ce premier exemple (inspiré du tutoriel 92), une balle se déplace dans la fenêtre et rebondit contre les bords.

À noter qu’Apecs intègre aussi un petit moteur physique mais on ne l’utilisera pas ici (le code fourni avec ce tutoriel contient des exemples qui l’utilisent).

Sans ECS

Définir les types

On commence par définir les types de données pour représenter la balle (position et vitesse) et le modèle de scène (ici, juste une balle).

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

newtype Model = Model
  { _ball :: Ball
  }

Initialiser la scène et l’application

On écrit une fonction pour initialiser le modèle (avec une balle aléatoire) :

randomBall :: IO Ball
randomBall = do
  p <- randomRIO (V2 xMin yMin, V2 xMax yMax)
  v <- randomRIO (V2 (-500) (-500), V2 500 500)
  return (Ball p v)

initialize :: IO Model
initialize = do
  ball0 <- randomBall
  return (Model ball0)

Et la fonction principale pour lancer l’application (initialisations puis boucle de jeu) :

main :: IO ()
main = do
  ...
  model <- initialize
  playIO window bgcolor fps model hDisplay hEvent hTime

Manipuler les données de la scène

Enfin, on implémente les fonctions de rappel, en utilisant le modèle de scène. Par exemple, pour réinitialiser une balle aléatoire quand l’utilisateur appuie sur la touche Enter :

hEvent :: Event -> Model -> IO Model
hEvent (EventKey (SpecialKey KeyEnter) Up _ _) model = do
  newBall <- randomBall
  return $ model & ball .~ newBall
...

Pour animer la balle :

hTime :: Float -> Model -> IO Model
hTime deltaTime model =
  return $ model & ball %~ execState (updateMotion deltaTime >> updateBounces)

Et pour afficher la scène :

hDisplay :: Model -> IO Picture
hDisplay model = 
  let (V2 x y) = model ^. ball . pos
  in return $ Pictures
      [ translate x y (circleSolid ballRadius)
      , rectangleWire winWidthF winHeightF ]

Avec ECS

Définir les types

Il y a différentes façons possibles d’implémenter cet exemple avec Apecs. Cependant, l’idée de l’ECS est de généraliser les composants, plutôt que de les inclure dans les types. Par exemple, on peut écrire un type/composant représentant une position :

newtype Position = Position { _unPos :: V2 Float }

instance Component Position where type Storage Position = Map Position

Ici, on a instancié la classe Component pour notre type Position, ce qui va nous permettre d’utiliser les différentes fonctionnalités d’Apecs ensuite. Lors de l’instanciation, on doit préciser comment est stocké le composant (Map permet de stocker plusieurs valeurs de ce composant, par exemple pour plusieurs entités).

De même, on définit un composant pour réprésenter une vitesse :

newtype Velocity = Velocity { _unVel :: V2 Float }

instance Component Velocity where type Storage Velocity = Map Velocity

Enfin, on peut définir un type/composant représentant une balle :

data Ball = Ball

instance Component Ball where type Storage Ball = Map Ball

Ainsi, on pourra représenter une balle par un tuple (Ball, Position, Velocity). L’intérêt de séparer en ces trois composants est de pouvoir à la fois animer toutes les entitès qui ont des composants Position et Velocity, mais également de pouvoir sélectionner ceux qui sont vraiment des Ball (même si pour l’instant, l’exemple anime seulement une balle).

La dernière étape consiste à créer un type World permettant de manipuler les composants désirés :

makeWorld "World" [''Ball, ''Position, ''Velocity, ''Camera]

Initialiser la scène et l’application

Maintenant qu’on a défini nos composants et notre type World, on peut gérer les données de scène, avec la monade System. Par exemple, la fonction suivante permet de créer une balle aléatoire, sous la forme de trois composants (Ball, Position, Velocity) :

randomBall :: System World (Ball, Position, Velocity)
randomBall = do
  p <- randomRIO (V2 xMin yMin, V2 xMax yMax)
  v <- randomRIO (V2 (-500) (-500), V2 500 500)
  return (Ball, Position p, Velocity v)

Pour créer vraiment des données de jeu, il faut créer des entités, avec les composants voulues. Par exemple, la fonction suivante ajoute une entité correspondant à une balle aléatoire :

initialize :: System World ()
initialize = do
  ball0 <- randomBall
  newEntity_ ball0

Ainsi les données sont gérées via la monade System et on n’a plus besoin de manipuler explicitement un modèle de scène, comme c’était le cas dans l’implémentation sans ECS.

main :: IO ()
main = do
  ...
  world <- initWorld 
  runWith world $ do
    initialize 
    play window bgcolor fps hDraw hEvent hTime

Manipuler les données de la scène

De même, les fonctions de rappels utilisent désormais la monade System au lieu de manipuler explicitement un modèle de scène. Par exemple, pour réinitialiser la balle aléatoirement quand on appuie sur la touche Enter :

hEvent :: Event -> System World ()
hEvent (EventKey (SpecialKey KeyEnter) Up _ _) = 
  cmapM $ \(Ball, Position _, Velocity _) -> randomBall
...

On aurait pu récupérer et modifier l’entité de la balle mais l’avantage d’un ECS est de pouvoir modifier la scène plus généralement, notamment via les composants. Ici, la fonction cmapM permet de sélectionner toutes les entités qui possèdent les trois composants (Ball, Position, Velocity) et de leur appliquer une fonction.

La fonction cmap est équivalente à cmapM mais pour appliquer une fonction pure, par exemple pour déplacer la balle au cours du temps :

hTime :: Float -> System World ()
hTime deltaTime =
  cmap $ execState (updateMotion deltaTime >> updateBounces)

Enfin, foldDraw permet de construire des Picture, pour l’affichage.

hDraw :: System World Picture
hDraw = do
  ballPicture <- foldDraw $ \(Ball, Position (V2 x y), Velocity _) -> 
    translate x y  (circleSolid ballRadius)
  return $ Pictures
      [ ballPicture
      , rectangleWire winWidthF winHeightF ]

Gérer des données plus complexes

Pour cet exemple très simple, le gain apporté par l’ECS n’est pas forcément justifié mais cela permet par exemple d’ajouter très simplement une deuxième balle (sans collision entre balles, toutefois) :

initialize :: System World ()
initialize = do
  ball0 <- randomBall
  newEntity_ ball0
  ball1 <- randomBall
  newEntity_ ball1

Exemple 2 (boules2d)

On veut maintenant implémenter une animation plus complexe, qui gère explicitement les murs ainsi que plusieurs balles aléatoires.

Une version sans ECS et une version avec ECS sont disponibles dans le dossier le code associé à ce tutoriel. On ne va pas détailler tout le code mais seulement comparer quelques extraits sans/avec ECS.

Données de scène

Sans ECS, on modélise la scène par des types Ball, Wall et Scene :

data Ball = Ball
    { _col :: (Float, Float, Float)
    , _rad :: Double
    , _mass :: Double
    , _pos :: V2 Double
    , _vel :: V2 Double
    }

data Wall 
    = WallLeft
    | WallRight
    | WallTop
    | WallBottom

data Scene = Scene
    { _balls :: V.Vector Ball
    , _walls :: V.Vector Wall
    }

Avec ECS, on peut définir les composants et types suivants :

newtype Position = ...

newtype Velocity = ...

data Ball = Ball
    { _col :: (Float, Float, Float)
    , _rad :: Double
    , _mass :: Double
    }
instance Component Ball where type Storage Ball = Map Ball

data Wall 
    = WallLeft
    | WallRight
    | WallTop
    | WallBottom
instance Component Wall where type Storage Wall = Map Wall

makeWorld "World" [''Position, ''Velocity, ''Ball, ''Wall, ''Camera]

type Ball' = (Ball, Position, Velocity)

Comme pour l’exemple 1, on a “sorti” les composants Position et Velocity de Ball mais on aurait pu les y laisser car seules les balles sont animées et utilisent donc une position et une vitesse. On notera également qu’on n’a pas besoin de type Scene avec l’ECS; la scène sera géré via le type World et la monade System.

Affichage de la scène

Sans ECS, il faut gérer explicitement les données de la scène (balles, murs) et leur implémentation (par exemple avec des Vector) :

hDraw :: Scene -> IO Picture
hDraw scene = 
    let bs = V.map drawBall (scene ^. balls)
        ws = V.map drawWall (scene ^. walls)
    in return $ Pictures $ V.toList (bs <> ws)

Avec ECS, il suffit de “travailler” dans System pour accéder aux données via leurs composants :

hDraw :: System World Picture
hDraw = do
    balls <- foldDraw drawBall
    walls <- foldDraw drawWall
    return $ Pictures [ balls, walls ]

L’ECS détermine automatiquement comment foldDraw selectionne les composants, grâce au système de types. Par exemple, la fonction drawBall prend un paramètre de type (Ball, Position) donc l’ECS va sélectionner automatiquement les entités qui possèdent ces composants, c’est-à-dire ici, les balles.

Gestion des collisions

Pour gérer les collisions (balle-balle et mur-balle), on doit prendre chaque objet et le comparer avec tous les autres objets qu’il peut rencontrer. Sans ECS, on doit gérer explicitement le modèle de scène et la structure de données qui contient les objets (ici des Vector que l’on parcourt avec la fonction ifold') :

computeHit :: 
    TryHit a b => 
    Double -> V.Vector a -> V.Vector b -> Maybe ((Int, a), (Int, b), Double)
computeHit dt os1 os2 = 
    V.ifoldl' f1 Nothing os1
        where
            f1 acc i1 o1 = V.ifoldl' (f2 i1 o1) acc os2
            f2 i1 o1 acc i2 o2 = ...

animateScene :: Double -> State Scene ()
animateScene dt = when (dt > 0) $ do
    bs <- use balls
    ws <- use walls
    let hitsBall = computeHit dt bs bs 
        hitsWall = computeHit dt ws bs
    ...

Avec ECS, tout est déterminé lorsqu’on définit les composants et le type World. On n’a donc pas besoin de gérer explicitement les structures de données qui stocke les composants. Il suffit d’utiliser les fonctions de parcourt générique, comme cfold et les composants/entités sont sélectionnés automatiquement via le système de types :

computeHit ::
    ( TryHit a b, ... =>
    Double -> System World (Maybe ((Entity, a), (Entity, b), Double))
computeHit dt = 
    cfoldM f1 Nothing
        where
            f1 acc (o1, e1) = cfold (f2 o1 e1) acc
            f2 o1 e1 acc (o2, e2) = ...

animateScene :: Double -> System World ()
animateScene dt = when (dt > 0) $ do
    (hitsBall :: Maybe ((Entity, Ball'), (Entity, Ball'), Double)) <- computeHit dt
    (hitsWall :: Maybe ((Entity, Wall), (Entity, Ball'), Double)) <- computeHit dt
    ...

Conclusion

L’ECS (Entity Component System) est une architecture classique pour développer des jeux vidéo. Elle permet de modéliser les données du jeu par des composants. On peut ensuite créer un élément du jeu via une entité, en spécifiant ses composants et leur valeur. Enfin, le système permet de manipuler toutes ces données efficacement, soit via les entités, soit de façon plus générique via les composants. Utiliser un ECS nécessite un peu de mise en place mais devient avantageux dès que les données du jeu ne sont plus triviales.

La bibliothèque Apecs implémente l’architecture ECS en Haskell et s’intègre très bien avec son système de type et avec des bibliothèques classiques comme Gloss et Lens.

Voir aussi :