Building a roguelike in F# from scratch - CRUD Operations

in #programming8 years ago (edited)

Introduction

Finally, I have some of the CRUD operations for the Level structure. This has been a battle of wills with F# and the Aether library. The main issues have been:

  • I have never had more than a one file F# project, simple toy projects. Once you have multiple files you have to get the structure correct. This is subtle but logical once you grok it. Get it wrong and you get a metric shit tonne of compile issues with cryptic reasons on why you can't see types.
  • My understanding on the Aether Optics library was slighty wrong when I wrote Optics - Get/Set properties for immutable data, so this corrects that.

The good news is that I am now happy with the structure. So in following articles I will move up the stack and prepare for the game loop.

Change Overview

A lot has changed so this is a brief overview of the changes. All the changes are pushed to my GitHub Repo for the project. So the commit diffs will highlight all the changes.

The basics are:

  • Now multi file. I have split everything up into logical units, easier to find stuff. AetherExtensions, GameTypes, Queries, Operations.
  • I have started to build extensions on the Aether library, these make it easier to work with optional types in Optics.
  • All the game types now have a set of Lenses, to ease access and update of the Level structure.
  • The queries have been updated to use Optics
  • There are now a basic set of CRUD manipulations of the Level structure.

I will only cover the final point in this article. The rest are more house keeping and slight refactors which you can see in the GitHub Repo.

Time For CRUD

Right... code time. First I will point out that that all these CRUD operations expect the values they are looking for to exist, they will throw an exception when this is not the case. The commands that will drive the engine will include validation, these protect against errors. An exception means I have screwed up higher in the stack and I want to know about them instantly :)

First up there are some functions to manipulate Stats. These encapsulate the mapipulations so everything stays within range.

let increaseCurrent amount stat =
    {stat with current = min (stat.current + amount) stat.max}
    
let decreaseCurrent amount stat =
    {stat with current = max (stat.current - amount) 0}
  
let increaseMax amount stat =
    let newValue = stat.max + amount
    {stat with current = newValue; max = newValue}

Nothing scary here:

  • You can't increase the current Stat value above the max.
  • You can't decrease the current Stat value below 0.
  • When you increase the max the current value is reset. This is like a level up operation.

Next a simple helper to wrap a common request, getting the actor with a specific id from the level.

let getActor actorId level =
    level |> Optic.get (expectActorWithId_ actorId)

And again Rubber Duck Debugging to the rescue, writing this up made me realise getActor should be in the Queries module. That is a commit in the repo :)

Anyway... Moving on this is how a new Actor is spawned into the Level structure. It simply pushes the actor into the level.actors and level.mapActors structures. This is a good example of the Optics in action.

The level structure is piped through two Lenses. The first puts the actor into the level, the second puts the ActorId into the mapActors lookup structure. This is the beauty of Optics, all the rebuilding of the immutable objects is wrapped up and abstracted away but the code remains clean looking.

I have used the Optic.set function in place of the ^= operator, the library has the params the wrong way round so it does not read as well imho.

let spawnActor actor level =
    level
        |> Optic.set (expectActorWithId_ actor.id) actor
        |> Optic.set (expectMapActorAt_ actor.location) actor.id

Removing a dead actor is similar but instead of pushing the values in it passes None, this triggers the remove path of the Map optics.

let removeActor actorId level =
    let actor = level |> getActor actorId
    level 
        |> Optic.set (actorWithId_ actorId) None
        |> Optic.set (mapActorAt_ actor.location) None

moveActor is similar again. I could have simply chained removeActor and spawnActor together but this solution is quicker, that would have added an extra step of removing the actor before adding it again.

let moveActor location actorId level =
    let actor = level |> getActor actorId
    let movedActor = actor |> Optic.set location_ location 
    level 
        |> Optic.set (expectActorWithId_ actorId) movedActor
        |> Optic.set (mapActorAt_ actor.location) None
        |> Optic.set (expectMapActorAt_ location) actorId

Finally hurtActor inflicts damage to the specified actor, using decreaseCurrent you saw ealier. It gets the Health Stat for the actor, edits it then pushes it back into the Level structure.

Shame I had to wrap for steemit display :(

let hurtActor damage actorId level =
    let actorHealth_ = 
        expectActorWithId_ actorId 
        >-> expectStatFor_ Health
    let updatedHealth = 
        decreaseCurrent damage (level |> Optic.get actorHealth_)
    level |> Optic.set actorHealth_ updatedHealth

Hopefully you can see the power of the Optics library by now. If you try and do this long hand using the F# with keyword it gets real ugly because of the Map lookups.

Feel free to ask any questions you have.

Happy coding

Woz

Sort:  

This language seems like its build to create those type of games :)
The syntax is a little bit weird, but you can do so much stuff with so little lines of Code...
I enjoyed it!
Nice job ;)

Really is a beautiful language. The more I use it the better it gets.

You are right that it is terse, but because functions are small it is easier to see what they do.

A lot of functional code can take longer to write. It is far more exacting because everything is immutable. You also revisit and refine the code more, just to make it even smaller lol

You can almost be sure it is correct without running it. I have not run or tested a single piece of this yet, but know it is correct even without unit tests :)

Coin Marketplace

STEEM 0.08
TRX 0.30
JST 0.037
BTC 102830.58
ETH 3444.49
USDT 1.00
SBD 0.55