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
= do
randomBall <- randomRIO (V2 xMin yMin, V2 xMax yMax)
p <- randomRIO (V2 (-500) (-500), V2 500 500)
v return (Ball p v)
initialize :: IO Model
= do
initialize <- randomBall
ball0 return (Model ball0)
Et la fonction principale pour lancer l’application (initialisations puis boucle de jeu) :
main :: IO ()
= do
main ...
<- initialize
model 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
EventKey (SpecialKey KeyEnter) Up _ _) model = do
hEvent (<- randomBall
newBall 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 :
"World" [''Ball, ''Position, ''Velocity, ''Camera] makeWorld
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)
= do
randomBall <- randomRIO (V2 xMin yMin, V2 xMax yMax)
p <- randomRIO (V2 (-500) (-500), V2 500 500)
v 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 ()
= do
initialize <- randomBall
ball0 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 ()
= do
main ...
<- initWorld
world $ do
runWith world
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 ()
EventKey (SpecialKey KeyEnter) Up _ _) =
hEvent ($ \(Ball, Position _, Velocity _) -> randomBall
cmapM ...
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 $ execState (updateMotion deltaTime >> updateBounces) cmap
Enfin, foldDraw
permet de construire des Picture
, pour l’affichage.
hDraw :: System World Picture
= do
hDraw <- foldDraw $ \(Ball, Position (V2 x y), Velocity _) ->
ballPicture
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 ()
= do
initialize <- randomBall
ball0
newEntity_ ball0<- randomBall
ball1 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
"World" [''Position, ''Velocity, ''Ball, ''Wall, ''Camera]
makeWorld
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)
= V.map drawWall (scene ^. walls)
ws 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
= do
hDraw <- foldDraw drawBall
balls <- foldDraw drawWall
walls 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 Nothing os1
V.ifoldl' f1 where
= V.ifoldl' (f2 i1 o1) acc os2
f1 acc i1 o1 = ...
f2 i1 o1 acc i2 o2
animateScene :: Double -> State Scene ()
= when (dt > 0) $ do
animateScene dt <- use balls
bs <- use walls
ws let hitsBall = computeHit dt bs bs
= computeHit dt ws bs
hitsWall ...
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 Nothing
cfoldM f1 where
= cfold (f2 o1 e1) acc
f1 acc (o1, e1) = ...
f2 o1 e1 acc (o2, e2)
animateScene :: Double -> System World ()
= when (dt > 0) $ do
animateScene dt 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 :