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

    Introduction

    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 . text =<< get (C @Name) e
    

    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 ECS:

    • 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.

    Query Results

    A Result c is a wrapper around the component c that's produced by a query. We will discuss querying itself more in the Query Chapter.

    health <- get (C @Health) player
    
    health :: Result Health
    

    There are a number of useful operations that can be done on a Result:

    • Set a new value for this component.
    set health (Health 100)
    
    • Modify the value of this component.
    modify health ((Health x) -> Health (x + 1))
    
    • Remove the component from the entity.
    delete health
    

    Note that these functions just call the insert, remove, etc. operations.

    So these two are equivalent, performance-wise:

    health <- query (C @Health)
    
    for_ health $ (health) -> do
      modify health $ (Health x) -> Health (x + 1)
    
    health <- query (C @Health, E)
    
    for_ health $ ((Health x), entity) -> do
      insert (Health (x + 1)) entity
    

    Or, if you prefer compact code:

    query (C @Health) >>= traverse_ (`modify` ((Health x) -> Health (x + 1)))
    

    You can use the value function to obtain the inner value of a Result.

    value :: Result c -> c
    

    For instance:

    Just name <- get (C @Name) e
    let name' = value name
    
    name  :: Result Name
    name' :: Name
    

    If the component has record fields, every field will be inherited by the Result (via a HasField instance)

    data Pos = Pos {x :: Float, y :: Float}
    
    Just pos <- get @Pos e
    let x = pos.x
    let y = pos.y
    
    pos :: Result Pos
    x   :: Float
    y   :: Float
    

    Some typeclasses, namely Show, Eq, Ord are also implemented for a Result c if they are for the underlying c.

    Note that the value of a Result is the value gotten at the time of querying. It could be outdated, in case the live value was changed after querying.

    Change Detection

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

    Observers

    Observers can listen to the OnInsert and OnRemove event:

    • OnInsert c is triggered each time c is inserted on an entity. This event is also triggered when a component is re-inserted / changed, meaning this isn't a reliable way to determine if a component was just added.
    onNameInsert :: OnInsert Name -> System ()
    
    • OnRemove is triggered when a component is removed from an entity.
    onNameRemove :: OnRemove Name -> System ()
    

    Both events have a .entity field you can use to obtain the entity which it happened on.

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

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

    void $ spawn (Observer onNameInsert)
    void $ spawn (Observer onNameRemove)
    

    OnInsert is always triggered after a component has been inserted, while OnRemove is triggered before. This allows you to query for the component and get its value.

    Filters

    Now for the other way of doing change detection: the Changed and Added query filters.

    With the following query:

    query (C @Name) (Added (C @Player))
    

    You will only obtain the name of entities which had the Player component added to them since the current (scheduled) system last ran.

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

    query (C @Name) (Changed (C @Player), Not (Added (C @Player)))
    

    Note on listening to changes

    One essential detail to be aware of here is that insertion (OnInsert 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 and setIfNeq, 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 into its own meta entity.

    res @MyRes is the same as doing:

    m <- meta @MyRes
    get (C @MyRes) m
    

    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' 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 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 in-between multiple components. If a component hasn't been registered yet, it won't show up when you query for components that require a specific component.

    Examples

    This section contains a few chunkier examples that combine the notions from this entire chapter.

    Example 1

    Expand

    An example showing different operations that spawn and alter entities.

    import Mischief.ECS.Prelude
    
    data CompA = CompA Int Int deriving (Component, Show)
    
    data CompB = CompB String deriving (Component, Show)
    
    data CompC = CompC deriving (Component, Show)
    
    main :: IO ()
    main = do
      app <- newApp MainPlugin
      runApp app
    
    data MainPlugin = MainPlugin deriving (Eq)
    
    instance Plugin MainPlugin where
      init _ = do
        foo <- spawn (Name "Foo", CompA 10 10, CompB "Component B on Foo", CompC)
        bar <- spawn (Name "Bar", CompA 15 3,  CompB "Component B on Bar")
        baz <- spawn (Name "Baz", CompA 0 0,   CompB "Component B on Baz", CompC)
    
        info . text =<< query (C @Name, C @CompA, M @CompB, M @CompC)
    
        insert (Name "Foo2", CompA 100 100, CompC) foo
        remove (C @CompC, C @CompB) baz
        despawn bar
    
        info . text =<< query (C @Name, C @CompA, M @CompB, M @CompC)
    
    >> [Info] [
      ("Foo", CompA 10 10, Just CompB "Component B on Foo", Just CompC),
      ("Baz", CompA 0 0,   Just CompB "Component B on Baz", Just CompC),
      ("Bar", CompA 15 3,  Just CompB "Component B on Bar", Nothing)
    ]
    
    >> [Info] [
      ("Foo2", CompA 100 100, Just CompB "Component B on Foo", Just CompC),
      ("Baz",  CompA 0 0,     Nothing, Nothing)
    ]
    

    Example 2

    Expand

    This example shows you how to make a resource that independently tracks the number of Players in the World.

    data PlayerCount = PlayerCount Int deriving (Component, Show)
    
    changeCount :: Int -> PlayerCount -> PlayerCount
    changeCount n (PlayerCount x) = PlayerCount (x + n)
    

    We need to write a system that queries all entities that have had a Player component added to them and updates PlayerCount accordingly:

    updateCount :: System ()
    updateCount = do
      x <- query' E (Added @Player)
    
      count <- res @PlayerCount
      modify count $ changeCount (length x)
    

    We couldn't have used an observer for this since OnInsert also catches re-insertions.

    We'll also make an Observer that listens to the OnRemove event to update PlayerCount:

    handlePlayerRemove :: OnRemove Player -> System ()
    handlePlayerRemove _ = do
      count <- res @PlayerCount
      modify count $ changecount (-1)
    

    I also wrote this system that spawns 3 Players and despawns one of them each frame, to make sure both the previous sytems works fine.

    spawnPlayers :: System ()
    spawnPlayers = do
      (info . text) =<< res @PlayerCount
    
      p <- spawn Player
      void $ spawn Player
      void $ spawn Player
    
      despawn p
    

    Now let's write a a simple app that makes use of these systems:

    import Mischief.ECS.Systems qualified as Systems
    
    data Player = Player deriving (Component)
    
    main :: IO ()
    main = do
      app <- newApp MainPlugin
      runApp app
    
    data MainPlugin = MainPlugin deriving (Eq)
    
    instance Plugin MainPlugin where
      init _ = do
        insertRes $ PlayerCount 0
        Systems.add Update (updateCount, spawnPlayers)
        void . spawn $ Observer handlePlayerRemove
    

    Running it will result in:

    [INFO] Just (PlayerCount 0)
    [INFO] Just (PlayerCount 2)
    [INFO] Just (PlayerCount 4)
    ...
    

    Next Chapter: Relationship