Running From Enemies!
We've spent a few weeks now refactoring a few things in our game. We made it more performant and examined some related concepts. This week, we're going to get back to adding new features to the game! We'll add some enemies, represented by little squares, to rove around our maze! If they touch our player, we'll have to re-start the level!
In the next couple weeks, we'll make these enemies smarter by giving them a better search strategy. Then later, we'll give ourselves the ability to fight back against the enemies. So there will be interesting trade-offs in features.
Remember we have a Github Repository for this project! You can find all the code for this part can in the part-5
branch! For some other interesting Haskell project ideas, download our Production Checklist!
Organizing
Let's remind ourselves of our process for adding new features. Remember that at the code level, our game has a few main elements:
- The
World
state type - The update function
- The drawing function
- The event handler
So to change our game, we should update each of these in turn. Let's start with the changes to our world type. First, it's now possible for us to "lose" the game. So we'll need to expand our GameResult
type:
data GameResult = GameInProgress | GameWon | GameLost
Now we need to store the enemies. We'll add more data about our enemies as the game develops. So let's make a formal data type and store a list of them in our World
. But for right now, all we need to know about them is their current location:
data Enemy = Enemy
{ enemyLocation :: Location
}
data World = World
{ …
, worldEnemies :: [Enemy]
}
Updating The Game
Now that our game contains information about the enemies, let's determine what they do! Enemies won't respond to any input events from the player. Instead, they'll update at a regular interval via our updateFunc
. Our first concern will be the game end condition. If the player's current location is one of the enemies locations, we've "lost".
updateFunc :: Float -> World -> World
updateFunc _ w =
-- Game Win Condition
| playerLocation w == endLocation w = w { worldResult = GameWon }
-- Game Loss Condition
| playerLocation w `elem` (enemyLocation <$> worldEnemies w) =
w { worldResult = GameLost }
| otherwise = ...
Now we'll need a function that updates the location for an individual enemy. We'll have the enemies move at random. This means we'll need to manipulate the random generator in our world. Let's make this function stateful over the random generator.
updateEnemy :: Maze -> Enemy -> State StdGen Enemy
...
We'll want to examine the enemy's location, and find all the possible locations it can move to. Then we'll select from them at random. This will look a lot like the logic we used when generating our random mazes. It would also be a great spot to use prisms if we were generating them for our types! We might explore this possibility later on in this series.
updateEnemy :: Maze -> Enemy -> State StdGen Enemy
updateEnemy maze e@(Enemy location) = if (null potentialLocs)
then return e
else do
gen <- get
let (randomIndex, newGen) = randomR
(0, (length potentialLocs) - 1)
gen
newLocation = potentialLocs !! randomIndex
put newGen
return (Enemy newLocation)
where
bounds = maze Array.! location
maybeUpLoc = case upBoundary bounds of
(AdjacentCell loc) -> Just loc
_ -> Nothing
maybeRightLoc = case rightBoundary bounds of
(AdjacentCell loc) -> Just loc
_ -> Nothing
maybeDownLoc = case downBoundary bounds of
(AdjacentCell loc) -> Just loc
_ -> Nothing
maybeLeftLoc = case leftBoundary bounds of
(AdjacentCell loc) -> Just loc
_ -> Nothing
potentialLocs = catMaybes
[maybeUpLoc, maybeRightLoc, maybeDownLoc, maybeLeftLoc]
Now that we have this function, we can incorporate it into our main update
function. It's a little tricky though. We have to use the sequence
function to combine all these stateful actions together. This will also give us our final list of enemies. Then we can insert the new generator and the new enemies into our state!
updateFunc _ w =
...
| otherwise =
w { worldRandomGenerator = newGen, worldEnemies = newEnemies}
where
(newEnemies, newGen) = runState
(sequence (updateEnemy (worldBoundaries w) <$> worldEnemies w))
(worldRandomGenerator w)
Drawing our Enemies
Now we need to draw our enemies on the board. Most of the information is already there. We have a conversion function to get the drawing coordinates. Then we'll derive the corner points of the square within that cell, and draw an orange square.
drawingFunc =
…
| otherwise = Pictures
[..., Pictures (enemyPic <$> worldEnemies world)]
where
...
enemyPic :: Enemy -> Picture
enemyPic (Enemy loc) =
let (centerX, centerY) = cellCenter $ conversion loc
tl = (centerX - 5, centerY + 5)
tr = (centerX + 5, centerY + 5)
br = (centerX + 5, centerY - 5)
bl = (centerX - 5, centerY - 5)
in Color orange (Polygon [tl, tr, br, bl])
One extra part of updating the drawing function is that we'll have to draw a "losing" message. This will be a lot like the winning message.
drawingFunc :: (Float, Float) -> Float -> World -> Picture
drawingFunc (xOffset, yOffset) cellSize world
...
| worldResult world == GameLost =
Translate (-275) 0 $ Scale 0.12 0.25
(Text "Oh no! You've lost! Press enter to restart this maze!")
...
Odds and Ends
Two little things remain. First, we want a function to randomize the locations of the enemies. We'll use this to decide their positions at the beginning and when we restart. In the future we may add a power-up that allows the player to run this randomizer. As with other random functions, we'll make this function stateful over the StdGen
element.
generateRandomLocation :: (Int, Int) -> State StdGen Location
generateRandomLocation (numCols, numRows) = do
gen <- get
let (randomCol, gen') = randomR (0, numCols - 1) gen
(randomRow, gen'') = randomR (0, numRows - 1) gen'
put gen''
return (randomCol, randomRow)
As before, we can sequence these stateful actions together. In the case of initializing the board, we'll use replicateM
and the number of enemies. Then we can use the locations to make our enemies, and then place the final generator back into our world.
main = do
gen <- getStdGen
let (maze, gen') = generateRandomMaze gen (25, 25)
numEnemies = 4
(randomLocations, gen'') = runState
(replicateM numEnemies (generateRandomLocation (25,25)))
gen'
enemies = Enemy <$> randomLocations
initialWorld = World (0, 0) (0,0) (24,24)
maze GameInProgress gen'' enemies
play ...
The second thing we'll want to do is update the event handler so that it restarts the game when we lose. We'll have similar code to when we win. However, we'll stick with the original maze rather than re-randomizing.
inputHandler :: Event -> World -> World
inputHandler event w
...
| worldResult w == GameLost = case event of
(EventKey (SpecialKey KeyEnter) Down _ _) ->
let (newLocations, gen') = runState
(replicateM (length (worldEnemies w))
(generateRandomLocation (25, 25)))
(worldRandomGenerator w)
in World (0,0) (0,0) (24, 24)
(worldBoundaries w) GameInProgress gen'
(Enemy <$> newLocations)
_ -> w
...
(Note we also have to update the game winning code!) And now we have enemies roving around our maze. Awesome!
Conclusion
Next week we'll step up the difficulty of our game! We'll make the enemies much smarter so that they'll move towards us! This will give us an opportunity to learn about the breadth first search algorithm. There are a few nuances to writing this in Haskell. So don't miss it! The week after, we'll develop a way to stun the enemies. Remember you can follow this project on our Github! The code for this article is on the part-5
branch.
We've used monads, particularly the State
monad, quite a bit in this series. Hopefully you can see now how important they are! But they don't have to be difficult to learn! Check out our series on Functional Structures to learn more! It starts with simpler structures like functors. But it will ultimately teach you all the common monads!