{-# OPTIONS_GHC -Wno-unused-imports #-} -- | -- Module: Components Tutorial -- Description: Introductory Tutorial -- -- This module walks the user through creating a small game in the terminal. -- -- [Previous Chapter: Startup Guide]("Mischief.ECS.Tutorial.Startup") -- -- [Next Chapter: App and Plugins]("Mischief.ECS.Tutorial.App") -- -- [Main Page]("Mischief.ECS") module Mischief.ECS.Tutorial.Dungeon ( -- * Learn You an ECS for Great Mischief! - 2. Coding a Dungeon Game -- * Introduction -- $intro -- * Creating an App -- $creation -- * Spawning the Grid -- $grid -- * Traversing the Grid -- $traversing -- * Spawning the Player -- $player -- * Adding Walls -- $walls -- * Moving the Player -- $move -- * Displaying the Grid -- $display -- * Generating Random Positions -- $rand -- * Adding Enemies -- $enemies -- * Moving Enemies -- $moveE -- * Enemy Collision -- $collision -- * Health -- $health -- * Quitting -- $quit -- * Taking Damage -- $dmg -- * Spawning Coins -- $coins -- * Collecting Coins -- $collect -- * Next Steps -- $next -- * [Next Chapter: App and Plugins]("Mischief.ECS.Tutorial.App") ) where import Control.Monad (unless, void, when) import Control.Monad.IO.Class (MonadIO (liftIO)) import Data.Default (Default) import Data.Foldable (for_) import Data.List ((!?)) import Data.Traversable (for) import Mischief.ECS import System.Exit (exitSuccess) -- $intro -- 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. -- $creation -- 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 <- 'newApp' MainPlugin -- 'runApp' app -- -- data MainPlugin = MainPlugin deriving ('Eq') -- -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = 'info' \"Hello World!\" -- @ -- -- If you run this program you should see \"Hello World!\" logged to the terminal. -- $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 :: 'Int' -- gridH = 10 -- -- gridW :: 'Int' -- gridW = 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]("Mischief.ECS.Systems") -- -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnGrid -- @ -- $traversing -- 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' ('Maybe' 'Entity') -- getTile (x, y) = do -- grid <- 'res' @Grid -- 'pure' $ 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' ('Maybe' 'Entity') -- moveBy (x, y) entity = do -- 'Just' pos <- 'get' ('C' \@Pos) entity -- let (Pos (x', y')) = 'value' pos -- 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 @'value'@ function. -- -- We can completely bypass this by just using the @Val@ query transformer which unwraps the @Result@ for us: -- -- @ -- moveBy (x, y) entity = do -- 'Just' (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 = do -- 'Just' (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. -- $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: -- -- @ -- instance 'Component' OnTile where -- 'isExclusiveRel' = 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 = do -- 'Just' tile <- getTile (5, 5) -- _ <- 'spawn' (Player, 'Rel' OnTile 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 = do -- 'Just' tile <- getTile (5, 5) -- 'void' $ 'spawn' (Player, 'Rel' OnTile tile) -- @ -- -- Now we can add the player spawning logic to @Startup@: -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnPlayer -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnGrid -- @ -- -- 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: -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' $ spawnPlayer '`after`' spawnGrid -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnGrid -- @ -- -- The app should now run without issues! -- $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') -> 'System' 'Entity' -- spawnWall pos = do -- 'Just' tile <- getTile pos -- 'spawn' (Wall, 'Rel' OnTile tile) -- -- spawnWalls :: 'System' () -- spawnWalls = do -- 'for_' [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: -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnPlayer '`after`' spawnGrid -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' (spawnGrid, spawnWalls) -- @ -- -- Additionally, I wrote a system which checks if a given tile has a wall on it: -- -- @ -- hasWall :: 'Entity' -> 'System' 'Bool' -- hasWall tile = do -- walls <- 'query'' 'E' ('With' ('C' \@Wall, 'R' \@OnTile tile)) -- 'pure' $ 'not' $ 'null' walls -- @ -- -- @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' $ 'null' walls -- @ -- -- We can compress it even further using @\<$\>@: -- -- @ -- hasWall tile = 'not' . 'null' '<$>' ['q'|Entity / With (Wall, OnTile -\> tile)|] -- @ -- $move -- 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 = do -- 'Just' player <- 'single'' E ('With' ('C' \@Player)) -- -- 'Just' rel <- 'get' ('R' \@OnTile 'Any') player -- let tile = rel.target -- -- newTile <- moveBy dir tile -- -- 'for_' newTile $ \t -> do -- wall <- hasWall t -- 'unless' wall $ 'insert' ('Rel' OnTile t) player -- @ -- -- Let's break it down line-by-line. -- -- -- First, we get the entity of the player: -- -- @ -- 'Just' player <- '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: -- -- @ -- 'Just' rel <- 'get' ('R' \@OnTile 'Any') 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 t -- 'unless' wall $ 'insert' ('Rel' OnTile t) player -- @ -- -- This could also be written as: -- -- @ -- 'for_' newTile $ \t -> -- hasWall t '>>=' 'flip' 'unless' ('insert' ('Rel' OnTile 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]("Mischief.ECS.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]("Mischief.ECS.Stdin").'Mischief.ECS.Stdin.readLast' -- 'for_' 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') -- -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Stdin]("Mischief.ECS.Stdin").'Mischief.ECS.Stdin.init' -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' (spawnGrid, spawnWalls) -- -- 'plugins' _ = 'plug' PlayerPlugin -- -- data PlayerPlugin = PlayerPlugin deriving ('Eq') -- -- instance 'Plugin' PlayerPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' $ spawnPlayer '`after`' spawnGrid -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' movePlayer -- @ -- $display -- 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' -> 'System' 'Bool' -- hasWall 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. ('QueryType' c) => 'Entity' -> 'System' 'Bool' -- tileHas 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' -> 'System' 'Char' -- showTile tile = do -- player <- tileHas \@Player tile -- wall <- tileHas \@Wall tile -- -- 'pure' $ -- 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 :: 'System' 'String' -- showGrid = do -- 'Just' (Grid tiles) <- 'res' \@Grid -- lines <- 'for' tiles $ 'traverse' showTile -- 'pure' $ 'unlines' lines -- @ -- -- 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 = 'Mischief.ECS.Stdout.printClear' '=<<' showGrid -- @ -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Stdin]("Mischief.ECS.Stdin").'Mischief.ECS.Stdin.init' -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' (spawnGrid, spawnWalls) -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' printGrid -- @ -- -- If you run the app now, you should see the game's grid and we able to use @wasd@ to move the player around! -- -- @ -- #################### -- \#..................# -- \#..................# -- \#..................# -- \#..................# -- \#....\@.............# -- \#..................# -- \#..................# -- \#..................# -- #################### -- @ -- $rand -- 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 :: 'System' Rand -- newGen = Rand \<$\> (newIOGenM =<< initStdGen) -- @ -- -- Don't forget to insert the resource! -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Stdin]("Mischief.ECS.Stdin").'Mischief.ECS.Stdin.init' -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' (spawnGrid, spawnWalls) -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' printGrid -- -- 'insertRes' '=<<' newGen -- @ -- -- Now it's possible to write a system that generates a random position on the grid: -- -- @ -- randomPos :: 'System' ('Int', 'Int') -- randomPos = do -- 'Just' (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 :: 'System' 'Entity' -- randomTile = '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. -- $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 :: 'System' Entity -- spawnEnemy = do -- tile <- randomTile -- 'spawn' (Enemy, 'Rel' OnTile tile) -- @ -- -- And this as a driver to handle all enemy spawning (it just spawns 5 enemies): -- -- @ -- spawnEnemies :: 'System' () -- spawnEnemies = 'for_' [0 .. 4] $ 'const' spawnEnemy -- @ -- -- I've also modified the @showTile@ system to take enemies into account: -- -- @ -- -- showTile :: 'Entity' -> 'System' 'Char' -- showTile tile = do -- player <- tileHas \@Player tile -- enemy <- tileHas \@Enemy tile -- wall <- tileHas \@Wall tile -- -- 'pure' $ -- 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') -- -- instance 'Plugin' EnemyPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnEnemies -- @ -- -- And added it to the list of plugins added by @MainPlugin@: -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = ... -- -- 'plugins' = 'plug' (PlayerPlugin, EnemyPlugin) -- @ -- -- You should now see something like this when running the app: -- -- @ -- #################### -- \#..................# -- \#..!...............# -- \#....!.!...........# -- \#..................# -- \#....\@.............# -- \#..................# -- \#..................# -- \#........!.!.......# -- #################### -- @ -- $moveE -- 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: -- -- @ -- 'Just' tile <- 'single'' ('R' \@OnTile 'Any') ('With' ('C' \@Player)) -- 'Just' pos <- '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: -- -- @ -- 'Just' pos <- '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 = do -- 'Just' pos <- ['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 enemyTile -- 'for_' newTile $ \t -> do -- 'insert' ('Rel' OnTile 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)) = do -- 'pure' $ -- if -- | ex > px -> (-1, 0) -- | ey > py -> (0, -1) -- | ex < px -> (1, 0) -- | ey < py -> (0, 1) -- | otherwise -> (0, 0) -- @ -- -- And don't forget to schedule it: -- -- @ -- instance 'Plugin' EnemyPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnEnemies -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' moveEnemies -- @ -- -- 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 @'TimePlugin'@ to your app, so I'll add it to our @MainPlugin@: -- -- @ -- '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" ('Mischief.ECS.Timer.Timer') -- import "Mischief.ECS.Timer" qualified as [Timer]("Mischief.ECS.Timer") -- @ -- -- We can now create a @Cooldown@ component which stores a Timer. -- -- @ -- data Cooldown = Cooldown {timer :: 'Mischief.ECS.Timer'} deriving ('Component') -- @ -- -- We want this to always be on every Enemy, so we can make it a required component of the @Enemy@ component. -- -- @ -- instance 'Component' Enemy where -- 'required' = 'require' \@Cooldown -- @ -- -- In order to have that compile, we also need to provide a @Default@ instance for @Cooldown@: -- -- @ -- instance 'Default' Cooldown where -- 'def' = [Timer]("Mischief.ECS.Timer").'Mischief.ECS.Timer.new' 1 [Timer]("Mischief.ECS.Timer").'Mischief.ECS.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 = do -- 'Just' pos <- ['s'|OnTile -> (*Pos) / With Player|] -- delta <- 'deltaTime' -- -- enemies <- ['q'|Entity, OnTile -> (Entity, *Pos), Cooldown / With Enemy|] -- 'for_' enemies $ \(enemy, (enemyTile, enemyPos), cooldown) -> do -- let (timer, finished) = [Timer]("Mischief.ECS.Timer").'Mischief.ECS.Timer.tick' delta cooldown.timer -- 'set' cooldown $ Cooldown timer -- -- 'when' finished $ do -- diff <- decideEnemyDir enemyPos pos -- -- newTile <- moveBy diff enemyTile -- 'for_ newTile $ \t -> do -- 'insert' ('Rel' OnTile 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! -- $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' -> 'System' Bool -- 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') -> 'System' 'Bool' -- tileAtPosIsFree pos = do -- tile <- getTile pos -- 'maybe' ('pure' False) 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 < px && right -> (1, 0) -- | ey < py && 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 @'unless'@ with @'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 -- @ -- -- @ -- instance 'Component' Player where -- 'required' = 'require' \@Health -- @ -- -- I've written a system that returns a string for the health: -- -- @ -- showHealth :: 'System' 'String' -- showHealth = do -- 'Just' health <- ['s'|Health / With Player|] -- 'pure' $ "Health: " ++ 'show' health.hp -- @ -- -- And I added it to the printing system: -- -- @ -- printGrid :: 'System' () -- printGrid = do -- grid <- showGrid -- health <- showHealth -- 'printClear' $ 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 -- #################### -- \#............!.....# -- \#.....!............# -- \#..................# -- \#..................# -- \#....\@!......!..!..# -- \#..................# -- \#..................# -- \#..................# -- #################### -- @ -- $dmg -- 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 = do -- 'Just' health <- ['s'|Health / With Player|] -- 'modify' health $ \(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]("Mischief.ECS.Observers") -- @ -- -- @ -- instance 'Plugin' PlayerPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' $ spawnPlayer '`after`' spawnGrid -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' movePlayer -- 'void' $ [Observers]("Mischief.ECS.Observers").'Mischief.ECS.Observers.spawn' onDamage -- @ -- -- 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 = do -- 'Just' player \<- ['s'|OnTile -\> (*Pos) / With Player|] -- enemies \<- ['q'|OnTile -\> (*Pos) / With Enemy|] -- -- 'for_' enemies $ \pos -> do -- 'when' (isAdjacent pos player) $ do -- 'trigger' (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: -- -- @ -- instance 'Plugin' EnemyPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' spawnEnemies -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' moveEnemies -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' $ 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) -> do -- 'modify' health $ \(Health x) -> Health $ max (x - dmg.amount) 0 -- -- 'insert' Invincible entity -- 'delay' 1000000 $ '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): -- -- @ -- 'delay' 1000000 $ 'remove' ('C' \@Invincible) entity -- @ -- -- Now, the player will only be able to take damage once per second! -- $quit -- 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) -> do -- 'modify' health $ \(Health x) -> Health $ max (x - dmg.amount) 0 -- -- 'insert' Invincible entity -- 'delay' 1000000 $ 'remove' ('C' \@Invincible) entity -- -- 'when' (health.hp == 0) $ 'liftIO' 'exitSuccess' -- @ -- -- 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: -- -- @ -- 'Just' health <- 'update' health -- 'when' (health.hp == 0) $ 'liftIO' 'exitSuccess' -- @ -- -- The logic should now work as expected. -- $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 -- then -- 'void' $ 'spawn' (Coin, 'Rel' OnTile 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]("Mischief.ECS.Interval") -- @ -- -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Stdin]("Mischief.ECS.Stdin").'Mischief.ECS.Stdin.init' -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' (spawnGrid, spawnWalls) -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' printGrid -- -- interval <- [Interval]("Mischief.ECS.Interval").'Mischief.ECS.Interval.start' 2000000 spawnCoin -- -- 'insertRes' '=<<' 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' -> 'System' 'Char' -- showTile tile = do -- player <- tileHas \@Player tile -- enemy <- tileHas \@Enemy tile -- wall <- tileHas \@Wall tile -- coin <- tileHas \@Coin tile -- -- 'pure' $ -- if -- | player -> \'@\' -- | wall -> \'#\' -- | enemy -> \'!\' -- | coin -> \'$\' -- | otherwise -> \'.\' -- @ -- $collect -- 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 = Coins 'Int' deriving ('Component') -- @ -- -- I'll insert the resource in @MainPlugin@: -- -- @ -- instance 'Plugin' MainPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Stdin]("Mischief.ECS.Stdin").'Mischief.ECS.Stdin.init' -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' (spawnGrid, spawnWalls) -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' printGrid -- -- interval <- [Interval]("Mischief.ECS.Interval").'Mischief.ECS.Interval.start' 2000000 spawnCoin -- -- 'insertRes' '=<<' newGen -- 'insertRes' $ Coins 0 -- @ -- -- And I'll update the display to also show the number of coins: -- -- @ -- showCoins :: 'System' 'String' -- showCoins = do -- 'Just' (Coins c) <- 'res' \@Coins -- 'pure' $ \"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 = do -- 'Just' playerTile <- ['s'|OnTile -> (Entity) / With Player|] -- coins <- ['q'|Entity / With OnTile -> playerTile, With Coin|] -- -- 'Just' (Coins c) <- 'res' \@Coins -- 'insertRes $ Coins $ c + 'length' coins -- -- 'for_' coins despawn -- @ -- -- And I'll schedule it: -- -- @ -- instance 'Plugin' PlayerPlugin where -- 'Mischief.ECS.App.Plugins.init' _ = do -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Startup' $ spawnPlayer '`after`' spawnGrid -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' movePlayer -- [Systems]("Mischief.ECS.Systems").'Mischief.ECS.Systems.add' 'Update' $ collectCoins '`after`' movePlayer -- -- 'void' $ [Observers]("Mischief.ECS.Observers").'Mischief.ECS.Observers.spawn' onDamage -- @ -- -- And that's it! Out player should now be able to collect coins! -- -- @ -- Health: 5 -- #################### -- \#..................# -- \#..................# -- \#$....!!!!!\@...$...# -- \#$.................# -- \#..................# -- \#......$...........# -- \#.$................# -- \#..................# -- #################### -- Coins: 16 -- @ -- $next -- 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.