Taking a Shortcut!

drilling.png

In the last couple weeks we focused on code improvements and peripheral features. We added parameters and enabled saving and loading. This week we're going back into the meat of the game to add a new feature. We want to give our player another option for navigating the maze faster. We'll add some "drill" power-ups throughout the map. These will allow our player to "drill" through a wall, permanently removing that wall from our grid. Then we can take shortcuts through the maze!

This article will once again emphasize using a methodical process. We'll add the feature step-by-step. We'll include specific commit links for each of the process, so you can follow along. You'll want to be on the part-10 branch of our Github repository. As reminder, here's our development approach with this game:

  1. Determine what extra data we need in our World and related types. Initialize these with reasonable values.
  2. Implement the core logic. This means determining how the new data affects either player inputs or the passage of game time. This means changing our update function and/or our input handler.
  3. Update the drawing function. Determine what Pictures we can make to represent the new data.

Steps 1 and 3 will involve modifying our parameter types and changing JSON instances. We won't emphasize these changes in the article because they're boilerplate code. But it's good to be aware of that! Check out the commits if you're confused about how to update the instances!

For some more ideas on building full-fleged Haskell applications, download our Production Checklist!

Modifying Data

We'll first concern ourselves with the status of the player's drilling ability. We'll add power-ups to the map later. What information do we need to have? First, the Player type needs a field for drillsRemaining.

data Player = Player
  { …
  , playerDrillsRemaining :: Word
  }

We'll also need to select an initial value for this. We'll put this in our game parameters.

data PlayerGameParameters = PlayerGameParameters
  { …
  , initialDrills :: Word
  }

Now when we initialize the player, we'll use that parameter:

newPlayer :: PlayerGameParameters -> Player
newPlayer params = Player (0, 0) 0
  (initialStunTimer params) (initialDrills params)

In this step we also need to update our JSON instances, and add to the game's default parameters.

Our drilling action will also change the maze itself. To make this easier, let's add the adjacent location to our Wall constructor:

data BoundaryType =
  WorldBoundary |
  Wall Location |
  AdjacentCell Location
  deriving (Show, Eq)

The resulting changes aren't too complicated. In pretty much all cases of initialization, we already have access to the proper location.

You can explore these changes more by perusing the first two commits on this branch. The first is for the player data, the second is for the adjacent walls.

Activating the Drill

Now let's implement the logic for actually breaking down these walls! We'll break down walls in response to key commands. So the main part of our logic goes in the input handler. But first, a few helpers will be very useful. For best code reuse, we're going to make some functions that mutate cell boundaries. Each one will remove a wall in the specified direction. Here's an example removing the wall in the "up" direction:

breakUpWall :: CellBoundaries -> CellBoundaries
breakUpWall cb = case upBoundary cb of
  (Wall adjacentLoc) -> cb {upBoundary = AdjacentCell adjacentLoc}
  _ -> error "Can't break wall"

Notice how the extra location information makes it easy to create the AdjacentCell! We want to throw an error because we shouldn't invoke this function if it's not a wall in that direction. We'll want comparable functions for the other three directions.

We also want a mutator function on the player. This reduces the number of drills remaining:

activatePlayerDrill :: Player -> Player
activatePlayerDrill pl = pl
  { playerDrillsRemaining = decrementIfPositive (playerDrillsRemaining pl)}

Now we can create a function drillLocation. This function will fall under our input handler. This way, we don't have to pass all the world state information as parameters.

where
    worldBounds = worldBoundaries w
    currentPlayer = worldPlayer w
    currentLocation = playerLocation currentPlayer
    cellBounds = worldBounds Array.! currentLocation

    drillLocation = ...

The function will take two of the mutator functions for breaking walls. The first will allow us to break the wall from the current cell. The second will allow us to break the wall from the adjacent cell. It will also take a function giving us the cell boundaries in a particular direction. Finally, it will take the World as a parameter. We could access the existing w value if we wanted. But doing it this way could allow us to chain multiple World mutation functions in the future.

drillLocation
      :: (CellBoundaries -> BoundaryType)
      -> (CellBoundaries -> CellBoundaries)
      -> (CellBoundaries -> CellBoundaries)
      -> World
      -> World
    drillLocation boundaryFunc breakFunc1 breakFunc2 w = ...

We first need to determine if we can drill in this state. Our player must have a drill remaining, and there must be a wall in the given direction. If these conditions aren't met, we return our original World parameter.

drillLocation boundaryFunc breakFunc1 breakFunc2 w =
  case (drillLeft, boundaryFunc cellBounds) of
    (True, Wall location2) -> …
    _ -> w
  where
    drillLeft = playerDrillsRemaining currentPlayer > 0

Now we'll create our "new" player with the activateDrill function from above. And we'll use the input functions to get our new cell boundaries. We'll update our maze, and then return the new world!

drillLocation boundaryFunc breakFunc1 breakFunc2 w =
  case (drillLeft, boundaryFunc cellBounds) of
    (True, Wall location2) ->
      let newPlayer = activatePlayerDrill currentPlayer
          newBounds1 = breakFunc1 cellBounds
          newBounds2 = breakFunc2 (worldBounds Array.! location2)
          newMaze = worldBounds Array.//
            [(currentLocation, newBounds1), (location2, newBounds2)]
      in  w { worldPlayer = newPlayer, worldBoundaries = newMaze }
    _ -> w
  where
    drillLeft = playerDrillsRemaining currentPlayer > 0

Last of all, we have to make key inputs for this handler. We'll use the "alt" key in conjunction with a direction to signify that we should drill. We use the Modifiers constructor to signal such a combination. Here's that little snippet:

inputHandler event w
  ...
  | otherwise = case event of
      (EventKey (SpecialKey KeyUp) Down (Modifiers _ _ Down) _) ->
        drillLocation upBoundary breakUpWall breakDownWall w
      (EventKey (SpecialKey KeyDown) Down (Modifiers _ _ Down) _) ->
        drillLocation downBoundary breakDownWall breakUpWall w
      (EventKey (SpecialKey KeyRight) Down (Modifiers _ _ Down) _) ->
        drillLocation rightBoundary breakRightWall breakLeftWall w
      (EventKey (SpecialKey KeyLeft) Down (Modifiers _ _ Down) _) ->
        drillLocation leftBoundary breakLeftWall breakRightWall w

Again, notice how we use our mutator functions as parameters. When drilling "up", we pass the breakUpWall and breakDownWall functions, and so on. For a review of all these steps, take a look at this commit!

Drill Power Ups

At this point, we can use our drill within the game. But we can only use it the specified number of times from the initialDrills parameter. Next, we're going to add power-ups we can pick up in the grid that will give us more drilling abilities. This requires adding a bit more data to our world. We'll have a list of locations for the power-ups, as well as a parameter for the number of them:

data World = World
  { ...
  , worldDrillPowerUpLocations :: [Location]
  }

data GameParameters = GameParameters
  { ...
  , numDrillPowerups :: Int
  }

When we initialize our game, we have to create these locations, as we did for enemies. Since these are the same essential task, we can replicate that logic:

main = …
      let (enemyLocations, gen'') = runState
            (replicateM (numEnemies gameParams) 
              (generateRandomLocation
                (numRows gameParams, numColumns gameParams)))
            gen'
          (drillPowerupLocations, gen''') = runState
            (replicateM (numDrillPowerups gameParams) 
              (generateRandomLocation 
                (numRows gameParams, numColumns gameParams)))
            gen''
    in ...

We also need this same process when resetting the game, whether after winning or losing.

We'll also change how we update the world after a player move. We'll add a wrapper function to update the world whenever the player moves. We'll supply a directional function parameter to determine where the player goes next:

inputHandler :: Event -> World -> World
inputHandler event w
  ...
  | otherwise = case event of
      (EventKey (SpecialKey KeyUp) Down _ _) ->
        updatePlayerMove upBoundary
      (EventKey (SpecialKey KeyDown) Down _ _) ->
        updatePlayerMove downBoundary
      (EventKey (SpecialKey KeyRight) Down _ _) ->
        updatePlayerMove rightBoundary
      (EventKey (SpecialKey KeyLeft) Down _ _) ->
        updatePlayerMove leftBoundary
  where
    updatePlayerMove :: (CellBoundaries -> BoundaryType) -> World
    updatePlayerMove = …

    -- Other values we have in scope, for convenience
    playerParams = playerGameParameters . worldParameters $ w
    enemyParams = enemyGameParameters . worldParameters $ w

    worldRows = numRows . worldParameters $ w
    worldCols = numColumns . worldParameters $ w
    worldBounds = worldBoundaries w
    currentPlayer = worldPlayer w
    currentLocation = playerLocation currentPlayer
    cellBounds = worldBounds Array.! currentLocation

Let's devise a couple more mutators to help us fill in this new function. One moves the player to a new location, the other gives them an extra drill power-up if they find one:

pickupDrill :: Player -> Player
pickupDrill pl = pl
  { playerDrillsRemaining = (playerDrillsRemaining pl) + 1}

movePlayer :: Location -> Player -> Player
movePlayer newLoc pl = pl
  { playerLocation = newLoc }

Now if the location in the proper direction is "adjacent" to us (no wall), then we'll move there and try to pick up a drill. If it is not, the world does not change!

updatePlayerMove :: (CellBoundaries -> BoundaryType) -> World
updatePlayerMove boundaryFunc = case boundaryFunc cellBounds of
  (AdjacentCell cell) ->
    let movedPlayer = movePlayer cell currentPlayer
        drillLocs = worldDrillPowerUpLocations w
        ...
      _ -> w

Then for one last trick. When our next location has a drill, we have to update the player again. We also have to remove this power-up from the world! We update the world with those parameters, and we're done!

updatePlayerMove :: (CellBoundaries -> BoundaryType) -> World
updatePlayerMove boundaryFunc = case boundaryFunc cellBounds of
  (AdjacentCell cell) ->
    let movedPlayer = movePlayer cell currentPlayer
        drillLocs = worldDrillPowerUpLocations w
        (finalPlayer, finalDrillList) = if cell `elem` drillLocs
          then (pickupDrill movedPlayer, delete cell drillLocs)
          else (movedPlayer, drillLocs)
    in w
      { worldPlayer = finalPlayer, 
        worldDrillPowerUpLocations = finalDrillList }
  _ -> w

Check out this commit for a more thorough look at the changes in this part!

Drawing our Drills

We're almost done now! We have to actually draw the drills on the screen so we can figure out what's happening! As with the stun indicator, we'll use a circular ring to show when our player has a drill ready. We'll show the power-ups using purple triangles on the grid. With our new parameters system, we'll want to specify the color we'll use and the size of these indicators.

data PlayerRenderParameters = PlayerRenderParameters
  { ...
  , playerDrillPowerupSize :: Float
  , playerDrillIndicatorSize :: Float
  , playerDrillColor :: Color
  }

defaultRenderParameters :: RenderParameters
defaultRenderParameters = RenderParameters ...
  where
    playerParams = PlayerRenderParameters 10 black 5 red
      5.0 2.0 violet
    ...

As with our game parameters change, this also involves updating JSON instances. We'll also have to update the command line options parsing. But let's focus on the actual drawing that needs to take place.

The player indicator code looks a lot like the code for the stun indicator. We'll use different parameters and a different condition, but nothing else changes:

playerRP = playerRenderParameters rp
drillReadyMarker = if playerDrillsRemaining (worldPlayer world) > 0
  then Color (playerDrillColor playerRP)
    (Circle (playerDrillIndicatorSize playerRP))
  else Blank
...
playerMarker = translate px py (Pictures [drillReadyMarker, ...])

Then for the power-ups, we want to handle them like we do enemies. We'll loop through the list of locations, and apply a picture function against each one:

drawingFunc :: RenderParameters -> World -> Picture
drawingFunc rp world
  ...
 | otherwise = Pictures
    [ ...
    , Pictures (enemyPic <$> worldEnemies world)
    , Pictures (drillPic <$> worldDrillPowerUpLocations world)
    ]
  where
    ...
    drillPic :: Location -> Picture
    ...

The drillPic function will even look a lot like the function for enemy pictures. We'll just use different coordinates to make a triangle instead of a square!

drillPic :: Location -> Picture
drillPic loc =
  let (centerX, centerY) = cellCenter $ conversion loc
      radius = playerDrillPowerupSize playerRP
      top = (centerX, centerY + radius)
      br = (centerX + radius, centerY - radius)
      bl = (centerX - radius, centerY - radius)
      drillColor = playerDrillColor playerRP
  in  Color drillColor (Polygon [top, br, bl])

And that's all! We've got a working, rendered prototype of this feature now! Take a look at this commit for some wrap-up details.

Drills in Action

Here are a few quick pictures of our game in action now! We can see our player is out of drills but near a power-up here:

maze_game_drill-1.png

Then we pick up the new drill, and we're fresh again!

maze_game_drill-2.png

Then we can use it to make a hole and get closer to the end!

maze_game_drill-3.png

Conclusion

This wraps up our feature! In the next couple weeks we're going to take a step back and re-organize things a bit. We want to work towards adding an AI for the main player, and enhancing the AI for the enemies. It would be cool to use some more advanced tactics, rather than relying on hard-coded rules. At some point, we'll consider using machine learning to improve this AI. This will require re-working our code a bit so we can run it independently of Gloss. This will allow us to run many game situations!

As we get ready to try some more advanced AI ideas, it might be a good idea to brush up on how and why we can use Haskell for AI development. Take a look at our Haskell AI series for some ideas! You can also download our Haskell Tensorflow Guide to learn more!

Previous
Previous

Gloss Review!

Next
Next

Loading Games and Changing Colors!