| Safe Haskell | None |
|---|---|
| Language | GHC2024 |
Mischief.ECS.Tutorial.Dungeon
Contents
- Learn You an ECS for Great Mischief! - 2. Coding a Dungeon Game
- Introduction
- Creating an App
- Spawning the Grid
- Traversing the Grid
- Spawning the Player
- Adding Walls
- Moving the Player
- Displaying the Grid
- Generating Random Positions
- Adding Enemies
- Moving Enemies
- Enemy Collision
- Health
- Quitting
- Taking Damage
- Spawning Coins
- Collecting Coins
- Next Steps
- Next Chapter: App and Plugins
Description
This module walks the user through creating a small game in the terminal.
Synopsis
Learn You an ECS for Great Mischief! - 2. Coding a Dungeon Game
Introduction
This module will walk you through creating a simple terminal-based dungeon crawler in Mischief. The goal is to have a player which we can freely move on a 2D grid, as well as various objects placed on tiles, such as enemies, weapons, obstacles, etc.
Creating an App
Let's start by creating our App and a Main Plugin which will serve as the starting point of all our logic.
import Mischief.ECS.Prelude main ::IO() main = do app <-newAppMainPluginrunAppapp data MainPlugin = MainPlugin deriving (Eq) instancePluginMainPlugin whereinit_ =info"Hello World!"
If you run this program you should see "Hello World!" logged to the terminal.
Spawning the Grid
The game will play out on a small 2D grid. There are many ways of representing this Mischief, the way I've chosen to do it is by having each tile of the grid be an entity, and the full list of entities stored in a global resource.
The Tile component will be used to mark which entities are tiles:
data Tile = Tile deriving (Component)
Each tile will also have a Pos component containing it's position on the grid:
data Pos = Pos (Int,Int) deriving (Component)
Grid will be the resource containing the (bidimensional) list of all tile entities:
data Grid = Grid [[Entity]] deriving (Component)
I've written two functions returning the width and height of the grid:
gridH ::IntgridH = 10 gridW ::IntgridW = 20
Let's write a system that spawns the tiles and initializes the grid resource:
spawnGrid ::System() spawnGrid = do tiles <-for[0 .. gridH - 1] $ \i ->for[0 .. gridW - 1] $ \j ->spawn(Tile, Pos (i, j))insertRes$ Grid tiles
insertRes inserts the component as a singleton resource into the ECS. We can grab its value at any time by using res @Grid.
Now we just need to modify MainPlugin so that it schedules spawnGrid to happen when the app starts.
import Mischief.ECS.Systems qualified as Systems instancePluginMainPlugin whereinit_ = Systems.addStartupspawnGrid
Traversing the Grid
Next, we need to code a way for traversing between adjacent tiles. Having a tile entity, we should have easy access to the entities found above, below, to the left and right of it.
First, I've written this function which gets an entity by position:
getTile :: (Int,Int) ->System(MaybeEntity) getTile (x, y) = do grid <-res@Gridpure$ do Grid tiles <- grid line <- tiles!?x line!?y
Now we can write a function that gets the position of a given tile and finds tiles offset by a certain amount:
moveBy :: (Int,Int) ->Entity->System(MaybeEntity) moveBy (x, y) entity = doJustpos <-get(C@Pos) entity let (Pos (x', y')) =valuepos getTile (x' + x, y' + y)
Queries will be explain in-depth later, but what happens essentially is that, our query returns a Result Pos for the given entity, and
we get and unwrap the inner Pos value using the function.value
We can completely bypass this by just using the Val query transformer which unwraps the Result for us:
moveBy (x, y) entity = doJust(Pos (x', y')) <-get(Val(C@Pos)) entity getTile (x' + x, y' + y)
We can compress the code even further by using quasi-queries. They are macros which allow us to write queries in faster, easier ways.
They will be explained in a further chapter, but should be pretty easy to understand at an intuitive level. For instance, we can replace
the above get with the g quasi-query:
moveBy (x, y) entity = doJust(Pos (x', y')) <- [g|*Pos|] entity getTile (x' + x, y' + y)
The * is the quasi equivalent of Val, although it can also just be written as Val or val if you prefer.
Spawning the Player
Our game needs a player, so we should have a component that uniquely identifies it:
data Player = Player deriving (Component)
We should also have a relationship to associate an entity to a tile, teling us that it's currently placed on that tile.
data OnTile = OnTile deriving (Component)
Additionally, an entity should only be able to be on a single tile at a time. Mischief has a convenient way of doing this hidden in the Component class:
instanceComponentOnTile whereisExclusiveRel= True
This means that if we insert a new OnTile relationship to the player, the old one will be automatically removed.
Anyway, it's finally time to spawn our player:
spawnPlayer ::System() spawnPlayer = doJusttile <- getTile (5, 5) _ <-spawn(Player,RelOnTile tile)pure()
Rel OnTile tile inserts the (OnTile, tile) relationship on the player.
We can avoid the pure by just using void to consume the value of spawn:
spawnPlayer = doJusttile <- getTile (5, 5)void$spawn(Player,RelOnTile tile)
Now we can add the player spawning logic to Startup:
instancePluginMainPlugin whereinit_ = do Systems.addStartupspawnPlayer Systems.addStartupspawnGrid
Except there's something really wrong in the logic above!
If we run the app, we will get an error pointing us to the Just tile <- in spawnPlayer.
That system assumes getTile will produce a valid result, but that will only happen if the grid is already initialized. Which means we want
to guarantee that spawnPlayer happens after spawnGrid.
We can do this by providing an explicit order when scheduling:
instancePluginMainPlugin whereinit_ = do Systems.addStartup$ spawnPlayer`after`spawnGrid Systems.addStartupspawnGrid
The app should now run without issues!
Adding Walls
Our game will also have Walls which can block the player's movement.
data Wall = Wall deriving (Component)
I've written a system which spawns walls and places them on the tiles around the edge of the grid:
spawnWall :: (Int,Int) ->SystemEntityspawnWall pos = doJusttile <- getTile posspawn(Wall,RelOnTile tile) spawnWalls ::System() spawnWalls = dofor_[0 .. gridW - 1] $ i -> spawnWall (0, i)for_[0 .. gridW - 1] $ i -> spawnWall (gridH - 1, i)for_[1 .. gridH - 2] $ i -> spawnWall (i, 0)for_[1 .. gridH - 2] $ i -> spawnWall (i, gridW - 1)
The system also needs to be scheduled to run, so MainPlugin now looks like this:
instancePluginMainPlugin whereinit_ = do Systems.addStartupspawnPlayer`after`spawnGrid Systems.addStartup(spawnGrid, spawnWalls)
Additionally, I wrote a system which checks if a given tile has a wall on it:
hasWall ::Entity->SystemBoolhasWall tile = do walls <-query'E(With(C@Wall,R@OnTile tile))pure$not$nullwalls
E just grabs the Entity of all queried entities. With is a query filter that makes it so the query only iterates over entities which have those components (in this case,
they must have Wall and must have a OnTile relationship to this precise tile).
Note that query' is the filtered version of query.
Here's the same system but in quasi-notation:
hasWall tile = do walls <- [q|Entity / With (Wall, OnTile -> tile)|]pure$not$nullwalls
We can compress it even further using <$>:
hasWall tile =not.null<$>[q|Entity / With (Wall, OnTile -> tile)|]
Moving the Player
Next, we should write a system which moves the player from one tile to another.
First, let's write the actual logic for moving in a certain direction:
movePlayerBy :: (Int,Int) ->System() movePlayerBy dir = doJustplayer <-single'E (With(C@Player))Justrel <-get(R@OnTileAny) player let tile = rel.target newTile <- moveBy dir tilefor_newTile $ t -> do wall <- hasWall tunlesswall $insert(RelOnTile t) player
Let's break it down line-by-line.
First, we get the entity of the player:
Justplayer <-single'E (With(C@Player))
single is a variant of query which returns a Maybe based on whether there is exactly one entity matching the query or not. We know there is exactly one player, and
we know by this point it should be spawned, so doing the Just player <- unwrapping is fine.
The query could also be writtten as:
[s|Entity / With Player|]
Next, we get the entity of the current tile the player is on:
Justrel <-get(R@OnTileAny) player let tile = rel.target
Querying for R OnTile Any will give us a list of all OnTile relationships of the player. However, because earlier we set OnTile
to be Exclusive, Mischief knows to only return a single relationship.
We then use rel.target to get the target entity of the relationship, which, in this case, is the tile we are looking for.
The get could also be written as:
[g|OnTile -> *|] player
After, we use the earlier moveDir system to get the new tile the player will be on:
newTile <- moveBy dir tile
And finally, we unwrap it (moveBy returns a Maybe Entity), check if there is a Wall on it, and if there isn't, move the player to it.
for_newTile $ t -> do wall <- hasWall tunlesswall $insert(RelOnTile t) player
This could also be written as:
for_newTile $ t -> hasWall t>>=flipunless(insert(RelOnTile t) player)
We also need to somehow get input from the user. Mischief exposes some useful functions for this in the following module:
import Mischief.ECS.Stdin qualified as Stdin
These functions are useful for the purpose of this tutorial but should probably never be used in a released game. Instead, you should import a dedicated
haskell input library, or use mischief-input which is based on SDL.
In order to read input we'll need to add Stdin.init to a plugin, which you'll see a bit later.
For now, we can just use the Stdin.readLast to empty the input buffer and get the last character typed by the user, if any.
movePlayer ::System() movePlayer = do c <- Stdin.readLastfor_c $ case 'w' -> movePlayerBy (-1, 0) 's' -> movePlayerBy (1, 0) 'a' -> movePlayerBy (0, -1) 'd' -> movePlayerBy (0, 1) _ ->pure()
The for_ just 'iterates' over the Maybe, applying the function if it has a value, and doing nothing otherwise.
At this point the codebase is starting to grow, so I have decided to create a new PlayerPlugin which handles all the player logic (including the new movement system),
and make it a dependency of MainPlugin:
data MainPlugin = MainPlugin deriving (Eq) instancePluginMainPlugin whereinit_ = do Stdin.initSystems.addStartup(spawnGrid, spawnWalls)plugins_ =plugPlayerPlugin data PlayerPlugin = PlayerPlugin deriving (Eq) instancePluginPlayerPlugin whereinit_ = do Systems.addStartup$ spawnPlayer`after`spawnGrid Systems.addUpdatemovePlayer
Displaying the Grid
It's finally time to actually display our game's grid in the terminal!
You may remember earlier we wrote a system which checks if there is a wall on a given tile.
hasWall ::Entity->SystemBoolhasWall tile =not.null<$>[q|Entity / With (Wall, OnTile -> tile)|]
It'd be useful to have something similar but for any arbitrary type of entity. We can write a generic variant of it like this:
tileHas :: forall c. (QueryTypec) =>Entity->SystemBooltileHas tile =not.null$ [q|Entity / With (c, OnTile -> tile)|]
QueryType is a sort of catch-all constraint that makes sure c is a component and can be queried (so nothing weird like it being a tuple).
Make sure to add {-# LANGUAGE AllowAmbiguousTypes #-} at the top of your .hs file, otherwise the type system will not like that c can not be inferred from the
function's signature (alternatively you can just pass a Proxy c as a workaround but I personally prefer the other way).
We can now write hasWall as just:
hasWall = tileHas @Wall
Now it should be easy to write a system that receives a tile and returns a character to represent it:
showTile ::Entity->SystemCharshowTile tile = do player <- tileHas @Player tile wall <- tileHas @Wall tilepure$ if | player -> '@' | wall -> '#' | otherwise -> '.'
This requires the MultiWayIf language extension, but there are many ways to write it without it, it's just a personal preference.
Next, I've written a system which produces a String for the whole grid by calling the previous function on each tile:
showGrid ::SystemStringshowGrid = doJust(Grid tiles) <-res@Grid lines <-fortiles $traverseshowTilepure$unlineslines
All that's left is to write a system that prints the string, and schedule it to happen each frame. We'll do the actual
printing via the printClear function of Mischief.ECS.Stdout, which automatically clears the terminal.
printGrid ::System() printGrid =printClear=<<showGrid
instancePluginMainPlugin whereinit_ = do Stdin.initSystems.addStartup(spawnGrid, spawnWalls) Systems.addUpdateprintGrid
If you run the app now, you should see the game's grid and we able to use wasd to move the player around!
#################### #..................# #..................# #..................# #..................# #....@.............# #..................# #..................# #..................# ####################
Generating Random Positions
For some of the next sections, an ability to choose random tiles would be very useful. So let's work on that.
I've chosen to use the random package, so just add it as a dependency to your project and import it:
import System.Random import System.Random.Stateful
We need some sort of mutable generator, so I'll create a resource to hold it:
data Rand = Rand (IOGenM StdGen) deriving (Component) newGen ::SystemRand newGen = Rand <$> (newIOGenM =<< initStdGen)
Don't forget to insert the resource!
instancePluginMainPlugin whereinit_ = do Stdin.initSystems.addStartup(spawnGrid, spawnWalls) Systems.addUpdateprintGridinsertRes=<<newGen
Now it's possible to write a system that generates a random position on the grid:
randomPos ::System(Int,Int) randomPos = doJust(Rand gen) <- res @Rand i <- applyIOGen (uniformR (0, gridH - 1)) gen j <- applyIOGen (uniformR (0, gridW - 1)) gen return (i, j)
And a system that uses it to get the Entity of a random tile:
randomTile ::SystemEntityrandomTile =unwrap<$> getTile randomPos
unwrap is a utility function provided by Mischief that just grabs the value out of a Maybe, or panics if there is no value. But in this case,
we know there will be a value since the provided position is valid.
Adding Enemies
It would be a pretty boring game if there were no obstacles. For that reason, we're going to add some enemies.
Here's the marker component that will be used to identify them:
data Enemy = Enemy deriving (Component)
I'll use this system to spawn an enemy, using the randomTile function defined earlier:
spawnEnemy ::SystemEntity spawnEnemy = do tile <- randomTilespawn(Enemy,RelOnTile tile)
And this as a driver to handle all enemy spawning (it just spawns 5 enemies):
spawnEnemies ::System() spawnEnemies =for_[0 .. 4] $constspawnEnemy
I've also modified the showTile system to take enemies into account:
showTile ::Entity->SystemCharshowTile tile = do player <- tileHas @Player tile enemy <- tileHas @Enemy tile wall <- tileHas @Wall tilepure$ if | player -> '@' | wall -> '#' | enemy -> '!' | otherwise -> '.'
Finally, we need a to schedule the enemy spawning, so I've created a new EnemyPlugin:
data EnemyPlugin = EnemyPlugin deriving (Eq) instancePluginEnemyPlugin whereinit_ = Systems.addStartupspawnEnemies
And added it to the list of plugins added by MainPlugin:
instancePluginMainPlugin whereinit_ = ...plugins=plug(PlayerPlugin, EnemyPlugin)
You should now see something like this when running the app:
#################### #..................# #..!...............# #....!.!...........# #..................# #....@.............# #..................# #..................# #........!.!.......# ####################
Moving Enemies
Right now the enemies just sit there. Let's make them move!
But first, I'd like to present you a new concept: transitive queries.
Up until now, if we wanted the position of the tile of the player we'd do somehing like:
Justtile <-single'(R@OnTileAny) (With(C@Player))Justpos <-get(Val(C@Pos)) tile.target
We'd do a query to get the player's relationship to the tile, then do another query on the actual tile to get its position.
But this could also be written as:
Justpos <-single'(R@OnTile (Q(Val(C@Pos)))) (With(C@Player))
Instead of getting all relationship, we use the Q marker to run the given query on each target of the relationship. So we transitively
get the position of the tile through its relationship to the player.
If you think this is uglier than just the two queries earlier, don't worry, the quasi notation looks much better:
Just pos ['s'|OnTile - (*Pos) / With Player|]
Don't forget to put the () around *Pos!
You should now be able to understand this system that handles the movement of the enemies:
moveEnemies ::System() moveEnemies = doJustpos ['s'|OnTile - (*Pos) / With Player|] enemies ['q'|Entity, OnTile - (Entity, *Pos) / With Enemy|]for_enemies $ (enemy, (enemyTarget, enemyPos)) -> do diff <- decideEnemyDir enemyPos pos newTile <- moveBy diff enemyTilefor_newTile $ t -> doinsert(RelOnTile t) enemy
With this helper function for deciding which direction to move on, based on the player's position.
decideEnemyDir :: Pos -> Pos ->System(Int,Int) decideEnemyDir (Pos (ex, ey)) (Pos (px, py)) = dopure$ if | ex > px -> (-1, 0) | ey > py -> (0, -1) | ex - (1, 0) | ey - (0, 1) | otherwise -> (0, 0)
And don't forget to schedule it:
instancePluginEnemyPlugin whereinit_ = do Systems.addStartupspawnEnemies Systems.addUpdatemoveEnemies
Except there's a small problem. If you run the app now, you may notice you don't see any enemies!
That's because they all already got to the player and are hiding behind it! We've set moveEnemies
to happen every frame, and our frames are happening almost instantly. So we need to add some sort of timing to the enemy's movement.
There are many cleaner high-level solutions to fix this, some of them even using async systems, but instead, I'll take the opportunity to introduce you to an important notion, Time!
In order to use Time utilities, you need to add the to your app, so I'll add it to our TimePluginMainPlugin:
plugins=plug(PlayerPlugin, EnemyPlugin, TimePlugin)
In any system you can use the deltaTime function to get the number of seconds passed since the last frame.
Mischief also provides a hnady way of keeping track of time via the Timer.
import Mischief.ECS.Timer (Timer)
import Mischief.ECS.Timer qualified as Timer
We can now create a Cooldown component which stores a Timer.
data Cooldown = Cooldown {timer :: Timer} deriving (Component)
We want this to always be on every Enemy, so we can make it a required component of the Enemy component.
instanceComponentEnemy whererequired=require@Cooldown
In order to have that compile, we also need to provide a Default instance for Cooldown:
instanceDefaultCooldown wheredef= Timer.new1 Timer.Repeat
Timer.new takes a Float (the duration of the timer), and a Mode which is either Repeat or Once.
Cooldown should now automatically be on every enemy.
We can use the Timer.tick function to advance the state of a timer. It returns the new state, along with a Bool that tells us whether the timer has just finished or not.
All that's left is to put all of this together:
moveEnemies ::System() moveEnemies = doJustpos ['s'|OnTile - (*Pos) / With Player|] delta <-deltaTimeenemies ['q'|Entity, OnTile - (Entity, *Pos), Cooldown / With Enemy|]for_enemies $ (enemy, (enemyTile, enemyPos), cooldown) -> do let (timer, finished) = Timer.tickdelta cooldown.timersetcooldown $ Cooldown timerwhenfinished $ do diff <- decideEnemyDir enemyPos pos newTile <- moveBy diff enemyTile 'for_ newTile $ t -> doinsert(RelOnTile t) enemy
Each frame, we tick the cooldown timer of each enemy, and only move them if that timer has just finished. This means every enemy will now move only once per 0.5 seconds.
Don't forget to use the set to pass the new timer back into the ECS! In Mischief, all variables you use are immutable, so you need to explicitly order mutations.
You can now run your app and see the enemies chasing you!
Enemy Collision
Right now the enemies just kinda overlap each other and go under the player. We can fix that by preventing them to move.
Now, let's write a function that tells us whether a certain tile is free to move on or not:
tileIsFree ::Entity->SystemBool tileIsFree tile = do wall <- tileHas @Wall tile enemy <- tileHas @Enemy tile player <- tileHas @Player tile pure $ not (wall || enemy || player)
Plus, an extra one which takes the tile's position directly rather than the entity:
tileAtPosIsFree :: (Int,Int) ->SystemBooltileAtPosIsFree pos = do tile <- getTile posmaybe(pureFalse) tileIsFree tile
And let's integrate it into the system which decides the enemy's movement direction:
decideEnemyDir :: Pos -> Pos ->System(Int,Int) decideEnemyDir (Pos (ex, ey)) (Pos (px, py)) = do left <- tileAtPosIsFree (ex - 1, ey) up <- tileAtPosIsFree (ex, ey - 1) right <- tileAtPosIsFree (ex + 1, ey) down <- tileAtPosIsFree (ex, ey + 1)pure$ if | ex > px && left -> (-1, 0) | ey > py && up -> (0, -1) | ex && right - (1, 0) | ey && down - (0, 1) | otherwise -> (0, 0)
(There are definitely much better ways to write this but I can't really be bothered, feel free to make it cleaner at home)
I've also replaced the hasWall in the movePlayerBy function with tileIsFree, so the player can collide with enemies as well
(make sure to also replace the with unless!).when
The game should now have fullly working collision and feel much more solid!
Health
Here's a simple one: let's add a Health component to the player and have it be displayed under the grid each frame.
I'll also give it a Default instance so it can be required by the Player component.
data Health = Health {hp :: Int} deriving (Component)
instance Default Health where
def = Health 100
instanceComponentPlayer whererequired=require@Health
I've written a system that returns a string for the health:
showHealth ::SystemStringshowHealth = doJusthealth <- [s|Health / With Player|]pure$ "Health: " ++showhealth.hp
And I added it to the printing system:
printGrid ::System() printGrid = do grid <- showGrid health <- showHealthprintClear$ health ++ "\n" ++ grid
In case you're thinking about it, yes, Health could just be a resource, I just decided to make it a component.
Your game should now print the health at the top:
Health: 100 #################### #............!.....# #.....!............# #..................# #..................# #....@!......!..!..# #..................# #..................# #..................# ####################
Quitting
It feels weird that the player can reach 0 health but the game just keeps running. So let's add some logic for quitting:
onDamage :: Damage ->System() onDamage dmg = do player <- [s|(Entity, Health) / With Player, Without Invincible|]for_player $ (entity, health) -> domodifyhealth $ (Health x) -> Health $ max (x - dmg.amount) 0insertInvincible entitydelay1000000 $remove(C@Invincible) entitywhen(health.hp == 0) $liftIOexitSuccess
But you may notice, the player actually takes an extra hit before that condition is triggered. That's because the health variable is immutable.
When we call modify, we update the live value of the component, but our local variable stays as it is.
We can use update to get the live value:
Justhealth <-updatehealthwhen(health.hp == 0) $liftIOexitSuccess
The logic should now work as expected.
Taking Damage
Let's add behavior for enemies damaging the player. I'll also take this opportunity to introduce you to Events.
We can create a damage event like this:
data Damage = Damage deriving (Event)
We also need an observer system for it:
onDamage :: Damage ->System() onDamage dmg = doJusthealth <- [s|Health / With Player|]modifyhealth $ (Health x) -> Health $max(x - dmg.amount) 0
In order to activate the system when the event is triggered, we need to spawn an observer for it. I'll do it in the PlayerPlugin:
import Mischief.ECS.Observers as Observers
instancePluginPlayerPlugin whereinit_ = do Systems.addStartup$ spawnPlayer`after`spawnGrid Systems.addUpdatemovePlayervoid$ Observers.spawnonDamage
The last thing we need is a way for enemies to trigger the event. I made a system which checks if an enemy is adjacent to the player and triggers the event:
tryDamage ::System() tryDamage = doJustplayer <- [s|OnTile -> (*Pos) / With Player|] enemies <- [q|OnTile -> (*Pos) / With Enemy|]for_enemies $ pos -> dowhen(isAdjacent pos player) $ dotrigger(Damage 5)
With this helper function:
isAdjacent :: Pos -> Pos -> 'Bool
isAdjacent (Pos (x1, y1)) (Pos (x2, y2)) =
let dx = abs (x1 - x2)
dy = abs (y1 - y2)
in (dx == 1 && dy == 0) || (dx == 0 && dy == 1)
I've scheduled tryDamage to happen every frame, after both the player and enemies have moved:
instancePluginEnemyPlugin whereinit_ = do Systems.addStartupspawnEnemies Systems.addUpdatemoveEnemies Systems.addUpdate$ tryDamage`after`moveEnemies`after`movePlayer
This now technically works, except that the enemies almost instantly kill the player on contact. That's because they deal damage every frame, the same issue we had when they were moving each frame.
I'll show you a different way to solve this problem. We can add an Invincible component on the player after being hit once,
which causes it to not receive damage, and which is removed after a delay.
data Invincible = Invincible deriving (Component)
I'll modify the onDamage observer like so:
onDamage :: Damage ->System() onDamage dmg = do player <- [s|(Entity, Health) / With Player, Without Invincible|]for_player $ (entity, health) -> domodifyhealth $ (Health x) -> Health $ max (x - dmg.amount) 0insertInvincible entitydelay1000000 $remove(C@Invincible) entity
Let's analyze it.
This line queries the player's Entity and Health, but only if they don't have the Invincible component.
player <- [s|(Entity, Health) / With Player, Without Invincible|]
If the query returned something (if the player isn't invincible), it will take damage.
modify health $ (Health x) -> Health $ max (x - dmg.amount) 0
Then the Invincible component will be inserted on the player.
insert Invincible entity
And finally, we use the delay async function to tell Mischief to run remove the component after a delay (in miliseconds):
delay1000000 $remove(C@Invincible) entity
Now, the player will only be able to take damage once per second!
Spawning Coins
Now, for our last bit of logic, we should add a reason for the player to not die. Let's spawn a bunch of coins!
First, we need a marker component for the coins:
data Coin = Coin deriving (Component)
Second, here's a system that spawns a coin on a random free tile:
spawnCoin ::System() spawnCoin = do tile <- randomTile free <- tileIsFree tile if free thenvoid$spawn(Coin,RelOnTile tile) else spawnCoin
(If the tile is not free, it will just keep looping and generating tiles until it finds one that is).
Third, we can use intervals to make the system repeat every two seconds.
import Mischief.ECS.Interval qualified as Interval
instancePluginMainPlugin whereinit_ = do Stdin.initSystems.addStartup(spawnGrid, spawnWalls) Systems.addUpdateprintGrid interval <- Interval.start2000000 spawnCoininsertRes=<<newGen
You can also use Interval.stop on the returned value to stop the interval at any point, but I won't be doing that here.
Finally, we should display the coins:
showTile ::Entity->SystemCharshowTile tile = do player <- tileHas @Player tile enemy <- tileHas @Enemy tile wall <- tileHas @Wall tile coin <- tileHas @Coin tilepure$ if | player -> '@' | wall -> '#' | enemy -> '!' | coin -> '$' | otherwise -> '.'
Collecting Coins
All that's left is letting the player collect coins and keeping track of how many they got.
I'll do this via a resource this time.
data Coins = CoinsIntderiving (Component)
I'll insert the resource in MainPlugin:
instancePluginMainPlugin whereinit_ = do Stdin.initSystems.addStartup(spawnGrid, spawnWalls) Systems.addUpdateprintGrid interval <- Interval.start2000000 spawnCoininsertRes=<<newGeninsertRes$ Coins 0
And I'll update the display to also show the number of coins:
showCoins ::SystemStringshowCoins = doJust(Coins c) <-res@Coinspure$ "Coins: " ++ show c
printGrid :: System ()
printGrid = do
grid <- showGrid
health <- showHealth
coins <- showCoins
printClear $ health ++ "\n" ++ grid ++ "\n" ++ coins ++ "\n"
And now finally, a system that checks if there are any coins on the same tile as the player, despawns them, and increments the resource:
collectCoins ::System() collectCoins = doJustplayerTile ['s'|OnTile - (Entity) / With Player|] coins ['q'|Entity / With OnTile - playerTile, With Coin|]Just(Coins c) <-res@Coins 'insertRes $ Coins $ c +lengthcoinsfor_coins despawn
And I'll schedule it:
instancePluginPlayerPlugin whereinit_ = do Systems.addStartup$ spawnPlayer`after`spawnGrid Systems.addUpdatemovePlayer Systems.addUpdate$ collectCoins`after`movePlayervoid$ Observers.spawnonDamage
And that's it! Out player should now be able to collect coins!
Health: 5 #################### #..................# #..................# #$....!!!!!@...$...# #$.................# #..................# #......$...........# #.$................# #..................# #################### Coins: 16
Next Steps
Don't worry if there are different details you haven't understood yet. The next chapters will go into details over the many aspects of the ECS. This chapter was just meant to give you a general idea of working with Mischief.