Building a roguelike in F# from scratch - Take items

in #programming7 years ago (edited)

Introduction

You can now take items that are on the floor of the map. This triggered another round of refactor when I realized the item storage in the game way incorrect, it was too complex and gained nothing for the complexity.

The full command set is now:

  • 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

And here it is running with a couple of items "?" in the map

The rest of the article is an outline of the refactor and how Take items was implemented

What was wrong with items

Originally the items structure in Level was similar to actors, a Map of id to item and Map of locations to List Id. When I started with the manipulation code things got too complex too quick. I realized the structure didn't actually buy me anything, there was simply no need to have a single store for all the items.

So items has been simplified. A single map of location to the items at that location.

type level = 
    {
        playerId: id

        map: map; 
        doors: Map<vector, door>; 
        actors: Map<id, actor>
        items: Map<vector, List<item>>

        mapActors: Map<vector, id>
    }

The Actor has also been updated so the backpack now holds items instead of their id.

type actor = 
    {
        id: int
        isNpc: bool
        name: string
        stats: Map<stats, stat>
        location: vector
        backpack: List<item>
    }

There have been loads of other tweaks and also the module structure has been simplified. You can all of this in the GitHub Repo if you want to dig deeper.

Optics

I wanted a way to interact with List initialization of a new location was automatic, as was cleanup when the last item was taken. I build some low level list manipulation on the Aether library.

First up is the notEmpty_ Isomorphism. This takes a List option and maps it to a list. The thinking here is simple, the lookup for a location might fail with option. It should also be noted that None pushed into a map means remove that item. So if you get a None it results in an empty List. In set it returns Non for an empty list which will remove it from that location.

let notEmpty_ : Isomorphism<List<'a> option, List<'a>> =
    (fun listOption -> 
        match listOption with
        | Some list -> list
        | None -> []), 
    (fun list -> 
        match Seq.isEmpty list with
        | true -> None
        | false -> Some list)

Next up some ways to find an Item in the List by its id. As with the way I use Map the where_ version allows for item removal setting None into the list.

let private without (predicate: ('a -> bool)) list =
    list |> List.filter (not predicate) 

let where_ (predicate: ('a -> bool)) : Lens<list<'a>, 'a option> =
    List.tryFind predicate,
    (fun itemOption list -> 
        let cleanList = list |> without predicate
        match itemOption with
        | Some item -> item :: cleanList
        | None -> cleanList)

let expectWhere_ (predicate: ('a -> bool)) : Lens<list<'a>, 'a> =
    List.find predicate,
    (fun item list -> 
        let cleanList = list |> without predicate
        item :: cleanList)

Item lenses

So on to some lens composition using what you have just seen.

The basic interaction with the actor backpack is easy enough. backpack_ is a basic lens to get the backpack in the actor. The other two provide CRUD for specific items in the backpack.

let backpack_ =
    (fun actor -> actor.backpack), 
    (fun backpack actor -> {actor with backpack = backpack})    

let backpackItemWithId_ id = 
    backpack_ >-> (where_ (Item.hasId id))

let expectBackpackItemWithId_ id = 
    backpack_ >-> expectWhere_ (Item.hasId id)

Same with level for the items on the floor. itemsAt_ is the only real point of interest. It make use of the notEmpty_ Isomorphism to create a list when not present and removes empty lists.

let private items_ = 
    (fun level -> level.items), 
    (fun items level -> {level with items = items})    

let itemsAt_ location = 
    items_ >-> Map.value_ location >-> notEmpty_

let itemWithId_ location id = 
    itemsAt_ location >-> where_ (Item.hasId id)

let expectItemWithId_ location id = 
    itemsAt_ location >-> expectWhere_ (Item.hasId id)

Finally the standard set of helpers.

let getItems location = Optic.get (itemsAt_ location)

let hasItems location level = 
    level 
        |> getItems location 
        |> Seq.isEmpty 
        |> not

let findItem location id = 
    Optic.get (itemWithId_ location id)

let expectItem location id = 
    Optic.get (expectItemWithId_ location id)

I will probably explore the Aether library and re-write its set and map operators. They would remove the need for many of the optics helpers without adding the Optic.[g|s]et syntax all over the place.

Validation

Validation was simple, most of the building blocks are already in place. itemsAtLocation ensures the tile has items to take.

let private itemsAtLocation location level =
    let items = level |> getItems location
    if items |> Seq.isEmpty then
        Invalid "No items to take" 
    else
        Valid items

canTakeItems is the full composition

let canTakeItems direction actorId level =
    result {
        let! (actor, validTarget) = level |> isValidDirection direction actorId 
        let! _ = actor.location |> canReachDoor validTarget 
        let! _ = level |> itemsAtLocation validTarget 
        return level
    }
  • Operations and command builder

Only the basic operations for item manipulation have been implemented. placeItem is like spawnActor, it injects the supplied item into the level structure at the specified location

let placeItem (item: item) location level =
    level |> Optic.set (itemWithId_ location item.id) (Some item)

takeItems takes the items from the floor and stores in the actor backpack. Basic stuff if you have followed the series to this point.

let takeItems direction actorId level =
    let (actor, targetLocation) = level |> actorTarget direction actorId
    let locationItems = level |> Optic.get (itemsAt_ targetLocation)
    let newBackpack = List.concat [locationItems; actor |> Optic.get backpack_]
    let newActor = actor |> Optic.set backpack_ newBackpack
    level 
        |> Optic.set (expectActorWithId_ actorId) newActor
        |> Optic.set (itemsAt_ targetLocation) []
  • Final bits of the puzzle

The command builder is simple because of the work already done in that area. Just a composition of canTakeItems and takeItems.

let buildTakeItemsCommand = buildCommand canTakeItems takeItems

Likewise handleKeyPress was a simple one line change. The handler for T(ake) is the same as O and C but passes in buildTakeItemsCommand instead of buildOpenDoorCommand

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.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

renderTile was also a simple change, a new "case" was added that selects ? as the location ASCII character if the location has any items.

let private renderTile level location =
    let char = maybeOrElse {
        return! level 
            |> Optic.get (mapActorAt_ location) 
            |> Option.bind (fun actorId -> Some '@')
        return! level 
            |> hasItems location 
            |> asOption
            |> Option.bind (fun _ -> Some '?')
        return! level 
            |> findDoor location 
            |> Option.bind (fun door -> Some (doorToChar door))
        return! level 
            |> getTile location 
            |> tileToChar |> Some
    }
    match char with
    | Some c -> c
    | None -> ' '

Summary

I will admit I took a huge wrong turn with the original items structure in level and that wasted some time, but good learning exercise. It made me step back as soon as I realized and the cleanup resulted in far easier code. I believe there is still a hefty refactor waiting. I am not happy with the lens compositions because the broken ^= and %= operators in Aether. I might pull in the code and fix it and then clean up next time.

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

Sort:  

Awesome :)

Tempted to explore F# yet?

I will take a look for sure! :D

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

Award for the total payout 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!

Coin Marketplace

STEEM 0.16
TRX 0.13
JST 0.027
BTC 56386.16
ETH 2529.81
USDT 1.00
SBD 2.49