mischief-ecs
Safe HaskellNone
LanguageGHC2024

Mischief.ECS.Tutorial.Components

Description

This module contains a more in-depth tutorial on Mischief Components.

Previous Chapter: App and Plugins

Next Chapter: Relationships

Main Page

Synopsis

    Learn You an ECS for Great Mischief! - 4. Components

    A component is any type which derives the Component typeclass. They can be both carriers of data or marker components used for querying (Tags from Flecs):

    Component that carries data.

    data Health = Health Int deriving (Component)
    

    Marker components.

    data Player = Player deriving (Component)
    data Enemy = Enemy deriving (Component)
    

    The Name Component

    Name is a special component provided by Mischief that is internally added to every spawned entity, based on its Entity index, if none is provided on spawn. It can be, of course, changed at any time.

    newtype Name = Name String deriving (Component)
    

    Consider this system that prints the name of a given Entity:

    printName :: Entity -> System ()
    printName e = info . T.show =<< get e [q|Name|]
    

    Notice how the Names behave here:

    foo <- spawn ()
    printName foo
    
    insert (Name "Foo") foo
    printName foo
    
    bar <- spawn (Name "Bar")
    printName bar
    
    >> [INFO] Just "Entity 15v1"
    >> [INFO] Just "Foo"
    >> [INFO] Just "Bar"
    

    Operations

    Mischief offers various operations for inserting and manipulating data into the World:

    • You can spawn entities as bundles of components:
    player <- spawn (Name "Player", Player)
    
    • You can insert components on existing entities:
    insert (Name "New Player Name", Health 100)
    
    • You can remove components:
    remove (C @Health, C @Player) player
    
    • You can despawn entities:
    despawn player
    

    Additionally, insert has a couple of variants:

    • insertNew only inserts components that aren't already on the entity.
    • insertIfNeq only insert components if they aren't on the entity of if their value differs from the current one.

    Note that, unlike other ECS's, Mischief exposes an immutable API to the user. This means that component values cannot be mutated in any other way, other than re-inserting them to change their previous value.

    Querying

    Components can be queried using the C, M, and Has markers.

    C simply returns the component, and makes the query ignore all entities that don't have it:

    x <- query $ mkQuery (C @Name, C @Health)
    
    x <- [q|Name, Health|]
    
    x :: [(Name, Health)]
    

    M is short for Maybe and makes the component optional. It may return Nothing, and the query will also include entities that don't have it.

    x <- query $ mkQuery (C @Name, M @Health)
    
    x <- [q|Name, Maybe Health|]
    
    x :: [(Name, Maybe Health)]
    

    Has is similar to M but it will return a bool saying whether the component is present or not.

    x <- query $ mkQuery (C @Name, Has @Health)
    
    x <- [q|Name, Has Health]
    
    x :: [(Name, Bool)]
    

    Additionally, the mkQuery' function (or q quasi-quoter, with a separating /) accepts an expression of With / Without filters that limit the entities looked at by the query.

    Getting the name of all entities that are Player and either don't have Enemy or have Health:

    x <- query $ mkQuery' (C @Name) (With (C @Player) `And` (Without (C @Enemy) `Or` With (C @Health)))
    

    Or

    x <- query [q|Name / With Player, (Without Enemy || With Health)|]
    

    Note that these filters limit the archetypes that the query will look at, rather than filtering the entities themselves, making them significantly faster than other filters.

    Change Detection

    Change detection can be done in two ways: Observers and Filters.

    Observers

    Observers can listen to the OnAdd, OnSet, and OnRemove event:

    • OnAdd c is triggered when the component c is added to an entity that didn't previously have it.
    onNameAdd :: OnAdd Name -> System ()
    
    • OnSet c is triggered each time c is inserted on an entity, whether it's for the first time or it's a re-insertion to change its value.
    onNameSet :: OnInsert Name -> System ()
    
    • OnRemove is triggered when a component is removed from an entity.
    onNameRemove :: OnRemove Name -> System ()
    

    All of these events have a .entity field you can use to obtain the entity which the event was triggered on.

    onNameRemove :: OnRemove Name -> System ()
    onNameRemove event = info $ T.show event.entity <> " had their name removed!"
    

    Don't forget to spawn an Observer to listen for each event.

    void $ spawn (Observer onNameAdd)
    void $ spawn (Observer onNameRemove)
    

    The order these events are triggered in is also an important detail:

    • After a component has been added, OnAdd is triggered, followed by OnSet.
    • After a component has been re-inserted, OnSet is triggered.
    • Before a component is removed, OnRemove is triggered.

    You can find more details on Observers and Events in the corresponding Chapter.

    Filters

    Now for the other way of reacting to changes: the Changed and Added filters.

    With the following query we can query the name of all entities that have had the Player component added in the last frame:

    mkQuery (C @Name)
      & qcheck (Added (C @Player))
      & query
    

    Or

    [q|Name|]
      & qcheck [f|Added Player|]
      & query
    

    Added c will catch entities that just had c added to them, while Changed c will catch any insertion. If you wish to query for entities that have had a component changed but it wasn't just added, you can do:

    mkQuery (C @Name)
      & qcheck (Changed (C @Player) `And` (Not (Added (C @Player))))
      & query
    

    Or

    [q|Name|]
      & qcheck [f|Changed Player, !Added Player|]
      & query
    

    Note on listening to changes

    One essential detail to be aware of here is that insertion (OnSet or Changed) doesn't necessarily mean a component's value has been changed!

    The following insert will trigger change detection:

    p <- spawn (Health 100)
    insert (Health 100) p
    

    To avoid this, you can derive Eq on your components and use insertIfNeq, which will only perform insertion if the value of the component is different from the current one.

    Meta Components

    Each component has a corresponding entity in the World. The components on that entity store information about the component itself. Such as which archetypes it is part of.

    A component's entity can be accessed by using meta.

    Getting the entities of the Name and Player components:

    x <- meta @Name
    y <- meta @Player
    

    Most users should avoid tinkering with Meta Components unless they have a good reason to, and should absolutely never remove or change any components added to them by the ECS.

    Resources

    Resources are singleton components that can be easily accessed and modified from any system.

    Any component can be used as a resource.

    data MyRes = MyRes Int deriving (Component)
    

    You can insert a resource into the World using insertRes.

    insertRes $ MyRes 5
    

    And you can query for the value of a resource using res:

    Just myRes <- res @MyRes
    

    res r returns a Maybe r because it's possible for the resource to not have been inserted yet.

    Resources are implemented by inserting a component's value on its own meta entity.

    res @MyRes
    

    Is equivalent to:

    m <- meta @MyRes
    get m [q|MyRes|]
    

    Besides using insertRes, resources can be inserted as part of a bundle through the Res type.

    The following will spawn an entity, insert a Name on it, and additioanlly insert a resource into the world.

    a <- spawn ()
    insert (Name A, Res $ SomeRes 5) a
    

    It's equivalent to:

    a <- spawn ()
    insert (Name "A") a
    insertRes (SomeRes 5)
    

    Res can also be used in queries to grab a resource:

    mkQuery (C @Name, Res @SomeRes)
    

    Or

    [q|Name, Res SomeRes|]
    

    This will query the name of all entities, and attach Res SomeRes to all of them.

    From

    From is a special type in Mischief:

    data From c = From {entity :: Entity, comp :: c}
    

    It symbolizes the idea of a foreign component. A component belonging to an external entity that we store alongside it.

    From can be inserted in any bundle, inserting the component c on the entity stored inside it. For instance, the following code will insert the name "B2" on b and the name "A2" on a:

    a <- spawn (Name "A")
    b <- spawn (Name "B")
    
    insert (Name "B2", From a (Name "A2")) b
    

    Equivalent to:

    a <- spawn (Name "A")
    b <- spawn (Name "B")
    
    insert (Name "B2") b
    insert (Name "A2") a
    

    From is generally returned from traversal queries such as:

    x <- query [q|Name, ChildOf -> (Name)|]
    
    x :: [Name, From Name]
    

    Where Name will be the name of each entity and From Name will be the name of their parent.

    Required Components

    Each component can require a bundle of other components.

    data Player = Player
    
    instance Component Player where
      required = require @(Position, Health)
    

    This means that each time Player is added to an entity, a default Position and Health will also be inserted, if they aren't already present.

    In order for a component to be required by another, it must instance the Default typeclass, either through a Generic derive or a custom instance.

    data Position = Position Int Int deriving (Component, Generic, Default)
    
    data Health = Health Int deriving (Component)
    
    instance Default Health where
      def = Health 100
    

    Requirements are transitive (if A requires B and B requires C, then A requires C) and can contain cycles.

    A requirement is added to the ECS as a RequiredBy / Requires relationship between the components' meta entities.

    Registering Components

    Registering a component involves spawning its meta entity and adding the corresponding data.

    Each component is registered automatically the first time it is inserted on an Entity, so you don't usually have to worry about registration.

    Queries are also smart about components; if you query or filter for a component that hasn't been registered yet, they will just assume that component can't be be on any Entity. Queries can't perform registration themselves, because they're not allowed to mutate the world in any way.

    However, there may be extremely niche situations where you want to register components earlier than normal, which is where manual registration comes in:

    register @(Player, Health, Position)
    

    One such situation could be wanting to check the requirements between multiple components on runtime. If a component hasn't been registered yet, it won't show up when you query for components that require a specific component.

    Next Chapter: Relationships