Building a roguelike in F# from scratch - Unlock Door

in #programming7 years ago

Introduction

Today I added the ability to unlock a doors if you have the correct key. I also had a long fight with one of my monadic computations, it was not doing what I expected and I was banging my head against a brick wall with it. More on that later though.

Here is the test level with me walking into a locked door without the key.

So on to the changes, before I dive into the issues I had.

Game Types

This was a simple function added to the Item module to check if the item is a key.

let isKey item = 
    match item with
    | Key _ -> true
    | _ -> false

Not much else to say for that one :)

Queries

Again only one change in here, a new function to determine if a given actor has a Key with the given name.

let hasKey keyName actor =
    actor 
        |> Optic.get backpack_
        |> Map.toSeq
        |> Seq.map snd
        |> Seq.filter isKey
        |> Seq.exists (fun i -> i |> nameOf = keyName)

A bit more complex but simple enough

  • Get the backpack map from the actor
  • Turn it into a sequence of (id * item)
  • Get just the item from each tuple, discard the id
  • Discard any item that is not a key
  • See if a key with the supplied name exists

So standard map/reduce code where you chain a set of steps together that reshape and then query the data.

Validators

First up the simple change in here. canDoorBeOpened and canDoorBeClosed were updated to take in actor, even though they ignore it. I wanted all the door related validators to have a common signature to make composition easier. You will see why later

// actor ignored, give better shape for later
let private canDoorBeOpened actor door =
    match door with
    | Closed -> Valid door
    | Open -> Invalid "Thet door is already open" 
    | Locked _ -> Invalid "That door is locked" 
    
// actor ignored, give better shape for later
let private canDoorBeClosed actor door =
    match door with
    | Open -> Valid door
    | Closed -> Invalid "That door is already closed" 
    | Locked _ -> Invalid "That door is locked closed" 

Next a couple of new validators relating to locked doors. First up the same as the one above but checking if the door can be unlocked.

// actor ignored, give better shape for later
let private canDoorBeUnlocked actor door =
    match door with
    | Locked keyName -> Valid keyName
    | _ -> Invalid "That door is not locked" 

Then a validator that checks if the actor has the required key

let private hasKeyForDoor keyName actor =
    if actor |> hasKey keyName then
        Valid keyName
    else
        Invalid ("You need " + keyName + " to unlock that door")

testDoorWith now takes in the actorId so it can pass it into "test" the custom door validator.

let private testDoorWith test direction actorId level =
    result {
        let! actor, validTarget = level |> isValidDirection direction actorId 
        let! _ = actor.location |> canReach validTarget 
        let! door = level |> doorExists validTarget 
        let! _ = door |> test actor
        return level
    }

And this is what caused that change, hasKeyForLockedDoor required the actor to see if the correct key was available.

let private hasKeyForLockedDoor actor door =
    result {
        let! keyName = door |> canDoorBeUnlocked actor
        let! _ = actor |> hasKeyForDoor keyName
        return door
    }

Finally the composition of the validators.

isDoorLocked checks if there is a locked door in the given direction and canUnlockDoor does the same checks but also checks if the actor has the required key.

let isLockedDoor = testDoorWith canDoorBeUnlocked

let canUnlockDoor = testDoorWith hasKeyForLockedDoor

Commands

Most of the building blocks for this had been done before so we just needed a new command builder using the new canUnlockDoor validator and the existing unlockDoor operation. Jump back to the Game Loop Time article if you need a reminder on the commands :)

let buildUnlockDoorCommand = buildCommand canUnlockDoor unlockDoor 

Player Input

And now to what caused me hours of pain, the resultOrElse comprehension for selecting the best command when just moving in a given direction. I started with the following which is the same as last time but with the unlock door command.

let private selectCommand validator operation direction actorId =
    validator direction actorId 
    >> bind (fun l -> operation direction actorId l)

let private selectActorCommand direction actorId level =
    resultOrElse {
        return! selectCommand isLockedDoor buildUnlockDoorCommand direction actorId level
        return! selectCommand canOpenDoor buildOpenDoorCommand direction actorId level
        return! selectCommand isValidMove buildMoveActorCommand direction actorId level
    }

This worked great apart from it would always display the invalid move error.

So what is going on?

The command builder is a mix of partial application and monadic comprehension. The idea was that selectActorCommand is not supplied level until later in the game loop. So it builds a level to result of level function.

The actual problem is selectCommand, it fired the validator to and if valid built the expected command and then run it. This meant if the player did not have the key for a locked door it would then rattle through the rest of the chain ending on movement which would fail because of the locked door but give an error about movement.

The following is the current code. I ended up writing this long hand, so a pyramid of doom. I am not happy with it but I know it works and just have to figure how to fold it together into a comprehension.

let private selectActorCommand direction actorId level =
    match level |> isLockedDoor direction actorId with
    | Valid l -> l |> buildUnlockDoorCommand direction actorId
    | Invalid _ ->
        match level |> buildOpenDoorCommand direction actorId with
        | Valid l -> Valid l
        | Invalid _ -> level |> buildMoveActorCommand direction actorId 

This is subtly different than before.

  • It tests if a locked is in the given direction. If so it creates the unlock command and stop there
  • Otherwise it runs the open door command and if that works stops
  • Otherwise it runs the move command

This now gives the correct error in all situations but is ugly as sin :(

Adding the key binding of U for unlock was simple enough.

let rec handleKeyPress activeBuilder actorId =
    let workingBuilder = 
        match activeBuilder with
        | Some builder -> builder
        | None -> selectActorCommand

    match Console.ReadKey().Key with
    | ConsoleKey.O -> handleKeyPress (Some buildOpenDoorCommand) actorId
    | ConsoleKey.C -> handleKeyPress (Some buildCloseDoorCommand) actorId
    | ConsoleKey.U -> handleKeyPress (Some buildUnlockDoorCommand) actorId
    | ConsoleKey.T -> handleKeyPress (Some buildTakeItemsCommand) actorId
    | ConsoleKey.W -> workingBuilder north actorId
    | ConsoleKey.A -> workingBuilder west actorId
    | ConsoleKey.S -> workingBuilder south actorId
    | ConsoleKey.D -> workingBuilder east actorId
    | ConsoleKey.Spacebar -> idleCommand
    | _ -> invalidCommand

Summary

Adding the unlock door command was really easy, which was expected. My miss read of the comprehension builer cost me load though. I will clean this up once I wrap my head around the flow and how to express it in a better format. I want to review all the code soon anyway to clean and simplify everything possible.

And for those who have only just found the series, here is the story so far for Building a roguelike in F# from scratch :

The current game commands are:

  • W - Up
  • A - Left
  • S - Down
  • D - Right
  • Space - Idle
  • T + [WASD] - Take all items in that direction
  • O + [WASD] - Open the door in that direction
  • C + [WASD] - Close the door in that direction
  • U + [WASD] - Unlock the door in that direction

Finally the GitHub Repo

Feel free to ask questions and happy coding :)

Woz