Building a roguelike in F# from scratch - Level Queries

in #programming7 years ago

Introduction

After my setbacks in First Problems article I finally have some more code to walk through. Time to pick up where I left off and look at some the the queries and code that has been added.

Beefed up Vector

I have added a number of operators for the vector to make it easier to work with. These all come from my article on Roguelike line of sight calculation, I will re-implement much of that article later in F#. I could have just built a C# library and used that, but where is the fun in that lol

The type definition for Vector has not changed but you can see it has gained a number of operators. I will pull in the rest of my C# Vector later but these are good enough for my current needs. This is the F# overloaded sort of operator syntax.

type public Vector = 
    {
        x: int
        y: int
    }

    // +
    static member op_Addition (lhs, rhs) = 
        {x = lhs.x + rhs.x; y = lhs.y + rhs.y}

    // -
    static member op_Subtraction (lhs, rhs) = 
        {x = lhs.x - rhs.x; y = lhs.y - rhs.y}

    // *
    static member op_Multiply (vector: Vector, scale: int) = 
        {x = vector.x * scale; y = vector.y * scale}

    // =
    static member op_Equals (lhs, rhs) = 
        lhs.x = rhs.x && lhs.y = rhs.y

    // <=
    static member op_LessThanOrEqual (lhs, rhs) = 
        lhs.x <= rhs.x && lhs.y <= rhs.y

    // <
    static member op_LessThan (lhs, rhs) = 
        lhs.x < rhs.x && lhs.y < rhs.y

    // >=
    static member op_GreaterThanOrEqual (lhs, rhs) = 
        lhs.x >= rhs.x && lhs.y >= rhs.y

    // >
    static member op_GreaterThan (lhs, rhs) = 
        lhs.x > rhs.x && lhs.y > rhs.y

Some type changes

I have also made a couple of tweaks to the types that I realised were required during my initial work against the Level structure. First up is the Map type.

type Map = 
    {
        tiles: Tile[][]
        topRight: Vector
    }

    static member bottomLeft = {x = 0; y = 0}

Simple enough, It now has the equivalent to a statis constant for the bottom left coordinate of the map {0, 0}. The old size field has been renamed to topRight. These simple changes make it far more explicit now on how the coordinates for the map flow, so a worthwhile change in my books.

Level has also gained a lookup from Vector to ActorId, this saves scanning all the actors to find who is at a given coordinate for a slight update cost increase.

type Level = 
    {
        playerId: ActorId
  
        map: Map; 
        doors: Map<Vector, Door>; 
        actors: Map<ActorId, Actor>
        items: Map<ItemId, Item>
  
        mapActors: Map<Vector, ActorId>
        mapItems: Map<Vector, List<ItemId>>
    }

Show me the code

Finally we can get to some code, this is where you will start to see the difference between functional and imperative code.

I have not currently run or tested any of this code but once you see it you will start to realise that does not matter, They are all nearly impossible to screw up. They are all pure, operate on immutable objects and above all else simple. You can see they are correct just by looking at them.

First up is hasCoordinate. This takes a level and a coordinate and reports back if the coordinate is in the level.

let hasCoordinate level location = 
    location >= Map.bottomLeft && location <= level.map.topRight

This is statically typed, level is type Level and locator is Vector. The compiler infers this because level contains a map field and location is beging used as if it were a Vector. Try and pass the wrong types and the compiler will report an error. The changes in the Map type to have bottomLeft and topRight now make sense.

The other point of interest is the parameter order. level is first to allow this:

let level = // build level here
let levelHasCoordinate = hasCoordinate level

levelHasCoordinate is a new function that just takes a location object, it has the level it operates against baked in. This is possible because the actual signature for hasCoordinate is this. It is called a curried function, most functional languages work this way.

Level -> Location -> bool

It is a function that takes a Level and returns a function from Location to bool. This is what I meant in the Last Article by Follow The Types. If I told you to write this function and only gave you the signature and the name you would probably create something exactly the same, it is that obvious.

We can rattle through the next few quickly as they are all similar, they are ways to access the structure.

let getTile level location = 
    level.map.tiles.[location.y].[location.y]

let getDoor level location = 
    level.doors.TryFind location

let getActorById level actorId =
    level.actors.TryFind actorId

let getActorByLocation level location =
    level.mapActors.TryFind location

let hasActor level location =
    (getActorByLocation level location).IsSome

They are all similar. You give them a Level and the lookup "key", location or id, and they return the thing. The only point of interest is TryFind, the functions that use this form of lookup can fail to find it so they return a Maybe monad. Option in F#.

So for getDoor it can return a Some of door or None. This is similar to nullable types in OO but it is explicit, the caller HAS to deal with the results.

null is a bad thing, the designer of C# called it the billion dollar mistake including it in the language. It is impossible you have not had to trace a null pointer exception at some time if you have written any OO code.

For getTile it is assumed you will only ever pass valid arguments, it will crash otherwise.

You will see how I handle getTile later to ensure this along with the Option types

Right, we have the building blocks to start our first query. I want to be able to determine if a given tile blocks line of sight or movement.

First up a helper that determines if a given Tile blocks movement or view, both are the same for tiles.

let isBlockingTile level location =
    match getTile level location with
    | Void | Wall -> true
    | Floor | Water -> false

This is simple enough. It gets the specified tile from the level and if Void or a wall then it blocks movement or sight. The match is like an if/else block but if I add any new Tile values the code will fail to compile until I include the new values in the match. Great for refactors and code changes :)

The test for doors is similar but here you see how I deal with the Option type where the door can be a Some door or None. If there is no dor, a None, It does not block movement or view.

let isBlockingDoor level location = 
    match getDoor level location with
    | Some door -> 
        match door with 
        | Closed | Locked _ -> true
        | Open -> false
    | None -> false

Next up we need to know if a given tile contains an Actor as that will block movement.

let hasActor level location =
    (getActorByLocation level location).IsSome

Again obvious once I explain IsSome. We get an Actor which is Some actor if an actor is at the location and None if not the case. IsSome is the save as HasValue on a nullable object. So returns true if the location has an actor otherwise false.

Now something a little more interesting, some arrays of functions. They are the tests to run to check if view or movement are blocked, functions as data.

let blockView = [isBlockingTile; isBlockingDoor]
let blockMove = [isBlockingTile; isBlockingDoor; hasActor]

Now to tie this all together, a function that uses the arrays of tests to test a location on the map. this function takes the array of tests, then the level and finally the location to test. Again the order is very specific. It means that I can create bake in the tests or tests and level similar to that described above.

let checkLocationFor predicates level location =
    if hasCoordinate level location then 
        let testPredicate = (fun predicate -> predicate level location)
        predicates |> Seq.forall testPredicate
    else true

So the first thing this does is check the coordinate is within the map bounds, if not view or movement are blocked. If a valid location is found it creates a means to run the predicate, a function that takes a predicate and calls it with the level and location. Finally it takes the predicate list and runs each in turn.

And here the value of Rubber Buck Debugging. While writing this I realised I used the wrong test. It will only report view or movement blocked if all predicates report true. It should read as follows.

let checkLocationFor predicates level location =
    if hasCoordinate level location then 
        let testPredicate = (fun predicate -> predicate level location)
        predicates |> Seq.exists testPredicate
    else true

exists returns true if it finds any predicates that return true. You will actually see the commit in the GitHub Repo where I fixed this while writing this article :)

This is how it is used

let viewBlocked = checkLocationFor blockView level location

// Or partially applied to bake in the test and level
let checkLocationBlocksView = checkLocationFor blockView level
let viewBlocked = checkLocationBlocksView location

So far the Level structue is holding up well :)

Now to get the items on the floor of a Tile

let getItems level location =
    match level.mapItems.TryFind location with
    | Some items -> items
    | None -> []

Does not need much said here, just that if no items on the Tile then it returns an empty list otherwise you get a list of all the items.

Hopefully you can see the benefits of all these small building blocks and how easy they plug together. It is also easy to see what everything does and as you have seen, spot bugs.

These changes have been pushed to the GitHub Repo for the project.

More next time, happy coding

Woz

Sort:  

Cheers for the functional programming ;)

Congratulations @woz.software! You have completed some achievement on Steemit and have been rewarded with new badge(s) :

Award for the number of posts published
Award for the number of comments received

Click on any badge to view your own Board of Honor on SteemitBoard.
For more information about SteemitBoard, click here

If you no longer want to receive notifications, reply to this comment with the word STOP

By upvoting this notification, you can help all Steemit users. Learn how here!