mischief-ecs
Safe HaskellNone
LanguageGHC2024

Mischief.ECS.Tutorial.Guides.MonadicQueries

Description

Synopsis

    Learn You an ECS for Great Mischief! - 2.6. Monadic Queries

    Finally we get to the most powerful and complete ability of Queries: monadic operations!

    Binding

    Monadic queries are usually written by using one of the query runners (query_, query, single), followed by a do:

    query_ $ do
      playerName <- [q|Name / With Player|]
      pure ()
    

    When you bind a value out of a query (the <- above), you are immediately iterating over all elements of a query and applying operations to them. It is in a way similar to the List monad if you are familiar with it (if you are not, don't worry!).

    Essentially, in pseudo-imperative, python-eseque code, the above is equivalent to:

    for playerName in [q|Name / With Player|]:
      pure ()
    

    There are many great uses to this, which you'll see in this chapter. The most naive one is that it naturally allows you to write sql-like joins:

    query_ $ do
      playerName <- [q|Name / With Player|]
      [q|Name / With Enemy|]
        & qinfo (\enemyName -> [i|#{playerName}, #{enemyName}|])
    

    Which is, in essence:

    for playerName in [q|Name / With Player|]:
      for enemyName in [q|Name / With Enemy|]:
        print (playerName + ", " + enemyName)
    

    Sources

    There is one detail about the section above that may not make much sense at first. Essentially, you may be wondering what happens if you do this:

    query_ $ do
      Health health <- [q|Health / With Player|]
      [q|Name / With Enemy|]
        & qinsert (_ -> (Health $ health - 1))
    

    It does, in fact, insert the player's health (minus 1) to each enemy! But why, and how can we do an insertion on the player?

    The answer are Sources. Each time you start a new query (e.g. the [q|Name / With Enemy|] above), you turn your "attention" to the new entiites. The source of the query are the enemies.

    You can change the source of a query using qrefocus; this code will iterate over all enemies but insert the Health component on the player:

    query_ $ do
      (player, Health health) <- [q|Entity, Health / With Player|]
      [q|Name / With Enemy|]
        & qrefocus player
        & qinsert (_ -> (Health $ health - 1))
    

    Keeping track of sources can be a significant footgun and one of the main disadvantages to writing monadic queries.

    One other way to bring the "attention" back to the players is by using qpure (or pure () followed by qrefocus):

    query_ $ do
      (player, Health health) <- [q|Entity, Health / With Player|]
      enemyName <- [q|Name / With Enemy|]
      qpure player
        & qinsert (_ -> (Health $ health - 1))
    

    qpure essentially creates a one-element query with the current player as the source, you can think of this as:

    for (player, Health health) in [q|Entity, Health / With Player|]:
      for enemyName in [q|Name / With Enemy|]
        for _ in [()]:
          insert (Health $ health - 1) player
    

    Note that using just pure () without refocusing is completely valid. But keep in mind that the source of the computation you're writing will be the "null entity". If you want to perform any sourced operations such as qinsert, you'll need to refocus first.

    Accumulating and Aggregating Queries

    To re-use the context from the previous chapter, consider that you have players marked by a Player component, each with a Coins Int component storing how many coins they have collected. You have coins in the world, marked by Coin Int, and want to iterate over all players, then over all coins, and, if the coin is in range of the player, despawn the entity and add its value to the total collected by the player.

    This "nested query" is a problem you may encounter in a lot of places. You've already seen how it's solved imperatively, by actually nesting the queries, but that solution isn't great. It's highly imperative and mutable, and the indentation gets bad as you need to nest more and more queries.

    This is where the monadic part of the queries comes in. The goal is to perform all those operations inside the same query, smartly chaining them using do.

    As a first step it's often helpful to start with a code like this:

    query_ $ do
      (entity, From _ tile, Coins coins) <- [q|Entity, OnTile -> (Entity), Coins / With Player|]
      pure ()
    

    Just copy the main query that you're iterating over. And bind the data out of it using <-. Wrap it in a do folllwing a query_ or query.

    At this point, you've put your hand on a slice of the data flowing through the query. What you do with this data dictates what will happen with all data.

    Next, try replacing the pure with another query, typically the second query that you're iterating over:

    query_ $ do
      (entity, From _ tile, Coins coins) <- [q|Entity, OnTile -> (Entity), Coins / With Player|]
    
      [q|Coin / With OnTile -> tile|]
        & qtap (\e _ -> despawn e)
    

    This is already a valid query. You're iterating over all the coins that are on each player's tile. You've changed the source, or subject, from being the players to being the coins. But in this case that's not really what you want. You want to somehow collect or aggregate the coins to use them in the larger query.

    Let's try collecting them first:

    query_ $ do
      (entity, From _ tile, Coins coins) <- [q|Entity, OnTile -> (Entity), Coins / With Player|]
    
      collectedCoins <- qcollect $ do
        [q|Coin / With OnTile -> tile|]
          & qtap (\e _ -> despawn e)
    
      pure ()
    

    The qcollect function is, in a way, the opposite of the bind operation. It returns from a loop by aggregating all its results into a list. You may see that collectedCoins has type [From Coin].

    Now we need to calculate the actual sum of all coins. We can use a fold for that:

    let totalCoins = foldr (\(From _ (Coin x)) -> (+ x)) coins collectedCoins
    

    In order to update the Coins component you have a few options.

    Firstly, you can use qpure entity (or pure () & qrefocus entity) to bring the attention back to the player and run a qinsert on it:

    query_ $ do
      (entity, From _ tile, Coins coins) <- [q|Entity, OnTile -> (Entity), Coins / With Player|]
    
      collectedCoins <- qcollect $ do
        [q|Coin / With OnTile -> tile|]
          & qtap (\e _ -> despawn e)
    
      pure ()
    

    So this is what you want to be doing instead:

    query_ $ do
      (entity, From _ tile, Coins coins) <- [q|Entity, OnTile -> (Entity), Coins / With Player|]
    
      [q|Coin / With OnTile -> tile|]
        & qtap (\e _ -> despawn e)
        & qcollect entity
        & qinsert (\collectedCoins -> Coins $ foldr (\(From _ (Coin x)) -> (+ x)) coins collectedCoins)
    
      let totalCoins = foldr (\(From _ (Coin x)) -> (+ x)) coins collectedCoins
    
      qpure entity
        & qinsert (const $ Coins totalCoins)
    

    That's it! The coins are now folded and added to the total held by the player.

    You can also iterate over the "null entity" and perform an insertion manually:

    pure ()
      & qtap (\_ _ -> insert (Coins totalCoins) entity)
    

    Or you can use qlift which does exactly this!

    qlift $ insert (Coins totalCoins) entity
    

    Grabbing New Components and Resources

    One powerup monadic queries give you is that they let you seamlessly read components and resources without unwrapping Maybes.

    query_ $ do
      (entity, health) <- [q|Entity, Health|]
      (names, player) 'qget' entity ['qd'|Likes - (Names), Player|]
      someRes <- qres @SomeRes
      pure ()
    

    qget will grab the respective Query Data from the given entity. Due to the nature of the monad, your query will be nullified if any of these componetns don't exist. You would be iterating over an empty list!

    qres works the same way but grabs a resource.

    Lifting

    You can lift systems into the query monad by using qlift:

    query_ $ do
      (player, Health x) <- [q|Entity, Health / With Player|]
      qlift $ insert (Health $ x + 1) player
    

    This, essentially, allows you to switch between the two access modes. One where you can easily query and grab new data, and one where you can perform fine-grained changes on said data.

    Next Chapter: Scheduling and Running Systems