| Safe Haskell | None |
|---|---|
| Language | GHC2024 |
Mischief.ECS.Tutorial.Guides.MonadicQueries
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|] &qrefocusplayer &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|]qpureplayer &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 _ ->despawne)
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 _ ->despawne) 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 _ ->despawne) 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 _ ->despawne) &qcollectentity &qinsert(\collectedCoins -> Coins $foldr(\(From_ (Coin x)) -> (+ x)) coins collectedCoins) let totalCoins =foldr(\(From_ (Coin x)) -> (+ x)) coins collectedCoinsqpureentity & 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@SomeRespure()
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.