Building a roguelike in F# from scratch - Working Doors

in #programming7 years ago

Introduction

Today I added working doors in my roguelike, only open and closed state so far as no keys yet to unlock a locked door. I have also done some cleanup/refactor work to ease moving forwards.

This is what it looks like, - is an open door and + a closed one.

If you walk into a closed turn it opens it, you can then walk through it.

Monads Again

Before some of the later code makes sense I need to talk about two new monadic comprehensions I have built.

I wont go into detail on how these work, just what they do. They are like an if/else ladder but for monadic types, they select the first monadic value in a chain that is either a Some or Valid depending on the monad type.

type resultOrElseFactory() =
    member this.ReturnFrom(x) = x
    member this.Combine (firstMonad,secondMonad) = 
        match firstMonad with
        | Valid _ -> firstMonad
        | Invalid _ -> secondMonad
    member this.Delay(f) = f()

type maybeOrElseFactory() =
    member this.ReturnFrom(x) = x
    member this.Combine (firstMonad,secondMonad) = 
        match firstMonad with
        | Some _ -> firstMonad
        | None _ -> secondMonad
    member this.Delay(f) = f()

Comprehensions blocks are a wide reaching subject well out of this scope of these ramblings. I included the implementation here to

  • Explain some slight of hand black magic later on.
  • Show they can be used to encapsulate different types of calculations, code flow or state.

Operations

First up a helper function, this flow started to crop repeat so I extracted it into a separate function. For a given actorId and direction it calculates the target location. It returns a tuple of actor * targetLocation as both might be needed.

let actorTarget direction actorId level =
    let actor = level |> expectActor actorId
    let targetLocation = actor.location + direction
    (actor, targetLocation)

The door manipulation is easy enough. Place door is used to put a door on the map or even replace an existing one.

let placeDoor state location level =
    level |> Optic.set (expectDoorAt_ location) state

Open and close manipulations just use actorTarget to get the target location and then placeDoor to update its state.

let openDoor direction actorId level = 
    let (_, targetLocation) = level |> actorTarget direction actorId
    level |> placeDoor Open targetLocation 

let closeDoor direction actorId level = 
    let (_, targetLocation) = level |> actorTarget direction actorId
    level |> placeDoor Closed targetLocation 

So the CRUD level stuff was easy enough

Validation

I also needed two new validators, canOpenDoor and canCloseDoor. These are used to protect the related commands to ensure the door is there similar to isValidMove.

First some individual tests, these should not need much description by now

let private doorExists location level =
    match level |> getDoor location with
    | Some door -> Valid door
    | None -> Invalid "There is no door there"
    
let private canDoorBeOpened door =
    match door with
    | Closed -> Valid door
    | Open -> Invalid "Thet door is already open" 
    | Locked _ -> Invalid "That door is locked" 
    
let private canDoorBeClosed door =
    match door with
    | Open -> Valid door
    | Closed -> Invalid "Thet door is already closed" 
    | Locked _ -> Invalid "That door is locked closed" 

let private canReachDoor target location =
    if location |> distanceFrom target <= 1.0 then
        Valid target
    else
        Invalid "You can't reach that door" 

Now a refactor. I lifted a common set of tests I need for the doors from isValidMove into the function isValidDirection. It returns a tuple as I need both the values in the validators that use the test.

let private isValidDirection direction actorId level =
    result {
        let! actor = level |> actorExists actorId 
        let targetLocation = (actor |> Optic.get location_) + direction
        let! validTarget = level |> isValidLocation targetLocation
        return (actor, validTarget)
    }

// And in use in isValidMove
let isValidMove direction actorId level =
    result {
        let! (actor, validTarget) = level |> isValidDirection direction actorId 
        let! tile = level |> isEmptyTile validTarget 
        let! validMove = actor.location |> isValidMoveDistance validTarget 
        return level
    }

Next canOpenDoor and canCloseDoor. These both use testDoor to perform the logic and are given the test to check if the door is in the correct state.

Notice the partial application in canOpenDoor and canCloseDoor. Their full argument set is direction, actorId and level. I do not need to supply them because the function returned will expect them.

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

let canOpenDoor = testDoor canDoorBeOpened
    
let canCloseDoor = testDoor canDoorBeClosed

Commands

The command builders have been through a re-write that became obvious when I had more than one command. All the composition is now abstracted away in buildCommand. This makes it simple to build the commands, you just give buildCommand the validator and operation and job done.

let private composeCommand validation operation level =
    result {
        let! validLevel = validation level
        return operation validLevel
    }

let private buildCommand validator operation direction actorId =
    let test = validator direction actorId
    let action = operation direction actorId
    composeCommand test action

let buildIdleCommand (actorId: actorId) level = Valid level

let buildMoveActorCommand = buildCommand isValidMove moveActor 

let buildOpenDoorCommand = buildCommand canOpenDoor openDoor 

let buildCloseDoorCommand = buildCommand canCloseDoor closeDoor 

Player Input

Adding doors meant I needed to enhance the player input a fair bit.

If you try and move in a direction the following chacks are done:

  • If valid move then build move command
  • Else if closed door build open command
  • Else build idle command

Because the validators return monadic values they are harder to use in an if else ladder. So I use the resultOrElse comprehension detailed earlier, this runs the validator and if good builds the associated command otherwise moves on to the next.

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

let private selectActorCommand direction actorId level =
    resultOrElse {
        return! selectCommand isValidMove buildMoveActorCommand direction actorId level
        return! selectCommand canOpenDoor buildOpenDoorCommand direction actorId level
        return! (buildIdleCommand actorId level)
    }

let getPlayerCommand actorId =
    match Console.ReadKey().Key with
    | ConsoleKey.W -> selectActorCommand north actorId
    | ConsoleKey.A -> selectActorCommand west actorId
    | ConsoleKey.S -> selectActorCommand south actorId
    | ConsoleKey.D -> selectActorCommand east actorId
    | _ -> buildIdleCommand actorId

Render Engine

Not too much has changed here really.

There is a mapping from door to the ASCII representation.

let private doorToChar door =
    match door with
    | Open -> '-'
    | Closed -> '+'
    | Locked _ -> '*'

The biggest change is renderTile, this make use of the maybeOrElse comprehesion to select the graphic in the following order:

  • Actor
  • Door
  • Tile
let private renderTile level location =
    let char = maybeOrElse {
        return! level 
            |> Optic.get (mapActorAt_ location) 
            |> Option.bind (fun actorId -> Some '@')
        return! level 
            |> getDoor location 
            |> Option.bind (fun door -> Some (doorToChar door))
        return! level 
            |> getTile location 
            |> tileToChar |> Some
    }
    match char with
    | Some c -> c
    | None -> ' '

Summary

This went far smoother than I had hoped. Because the code is modular it is really just a case of expanding out some areas, the rest was really the Validation, Operation and CRUD to support the new feature.

The Git change list will look worse than it is but that was just cleanup, refactor and simplification. You should never be scared of doing this as you work, when you spot duplicate code abstract it when viable. The more you do this the better you get and the more natural it will flow.

Because functional code is pure functions on immutable data everything is localized and so save to reshape.

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

Finally the GitHub Repo

Feel free to ask questions and happy coding :)

Woz

Coin Marketplace

STEEM 0.19
TRX 0.15
JST 0.029
BTC 63287.47
ETH 2569.39
USDT 1.00
SBD 2.81