Main.hs
has our Main
module definition. It was generated by Stack
when we started. In the end of the main
function, it calls run
function, which is defined in Run.hs
file. This is the place where we can see overall flow of the program in one glance.
run :: RIO App ()
run = do
choice <- showMainMenu
case choice of
StarNewGame -> do
logDebug "New game starting..."
logDebug "Rolling new character..."
player <- liftIO $ evalRandIO rollNewCharacter
displayNewCharacter player
logDebug "Selecting starting gear..."
gear <- selectStartingGear $ playerGear player
logDebug "Preparing game..."
game <- liftIO $ evalRandIO $ startGame player gear
logDebug "Dealing first card..."
finishedGame <- playGame game
logDebug "Displaying game over..."
displayGameOver finishedGame
ExitGame ->
return ()
Another interesting module is Types
. Here you can find how player, items, monsters and such are represented.
Third and biggest module is UserInterface
, which contains functions to display game status to player and ask their input.
So, what does our run
function do? Lets have a look:
choice <- showMainMenu
case choice of
player <- liftIO $ evalRandIO rollNewCharacter
evalRandIO
indicates we're dealing with random numbersdisplayNewCharacter player
gear <- selectStartingGear $ playerGear player
game <- liftIO $ evalRandIO $ startGame player gear
finishedGame <- playGame game
displayGameOver finishedGame
One of the features of Haskell I like is the ability to show which functions are pure (always returning same output with given set of inputs and not having any side effects). In our program, every function that returns RIO a b
has access to input and output. In addition to that, it also has access to system wide configuration (which we don't use much here) and logging functions.
To write on the screen, we use putStrLn
and reading a user input readLine
. Since they're designed to work with IO
instead of RIO a b
, we have to use liftIO
. But all that is technical details that we won't worry now.
App
is our configuration. We aren't directly using it, so it's safe to ignore for now.
showMainMenu
function will print out the menu and then call mainMenuInput
. mainMenuInput
will read input, validate that it's either 1
or 2
and return respectively StarNewGame
or ExitGame
. In case user enters something else, mainMenuInput
will recurse until user enters valid input.
-- | Display main menu
showMainMenu :: RIO App MainMenuChoice
showMainMenu = do
logDebug "Displaying main menu"
liftIO $ putStrLn "\n\n"
liftIO $ putStrLn "Treasure Dungeon"
liftIO $ putStrLn "****************"
liftIO $ putStrLn ""
liftIO $ putStrLn "1. Start a new game"
liftIO $ putStrLn "2. Quit"
mainMenuInput
mainMenuInput :: RIO App MainMenuChoice
mainMenuInput = do
i <- liftIO getLine
case i of
"1" -> return StarNewGame
"2" -> return ExitGame
_ -> do
logDebug $ displayShow $ "Incorrect menu choice: " <> i
liftIO $ putStrLn "Please select 1 or 2"
mainMenuInput
You might wonder, why mainMenuInput
can keep calling itself without filling the stack? That's because Haskell doesn't use stack in the same sense as many other programming languages. Haskell compiler is also smart enough to notice that call to mainMenuInput
is last operation of the mainMenuInput
, there is no work to be done after the call, and thus can optimize things even more. I don't know all the dirty details how this has been implemented and how things work behind the curtains.
player <- liftIO $ evalRandIO rollNewCharacter
rolls a new character, but what exactly is going on here? rollNewChacter
has following signature: rollNewCharacter :: (RandomGen g) => Rand g Player
. It doesn't take any parameters and returns Rand g Player
, where g
implements RandomGen
. So, it's Rand
monad that returns Player
. In order to get the result of the computation, we call evalRandIO
that uses global random number generator to compute. And since it's an IO
operation, we need liftIO
. It's bit confusing at first, so don't worry if you don't get all the details. The main point is that we're doing computation with random numbers.
Implementation is not too complex:
rollNewCharacter :: (RandomGen g) => Rand g Player
rollNewCharacter = do
str <- dice 3
dex <- dice 3
mind <- dice 3
maxHp <- dice 4
return $ Player
{ playerStrength = MkStrength str
, playerDexterity = MkDexterity dex
, playerMind = MkMind mind
, playerHP = MkHP maxHp
, playerMaxHP = MkHP maxHp
, playerGear = [ ]
}
This rolls three six sided dice for each attribute and 4 for hit points. The values are then used to create Player
record that is returned.
dice
is implemented as following:
dice :: (RandomGen g) => Natural -> Rand g Natural
dice n = do
rolls <- getRandomRs (1, 6)
let roll = sum $ take (fromIntegral n) rolls
return $ fromInteger roll
Again, we're using Rand
monad for random number generation. getRandomRs
supplies us an infinite list of numbers between 1
and 6
. Then we use take
to some of them and sum
to add them together. fromIntegral n
is needed, because take
doesn't operate on Natural
type, but Int
. I wanted to use Natural
though, because that ensures that the parameter n
will always be 0
or more.
Now we have a basic layout for our program and know how to roll a character with random stats. Next time we'll finally look into getting some gear on them. The code for the game is available at my codeberg repository.
ad astra!
Unless otherwise stated, our shows are released under a Creative Commons Attribution-ShareAlike 3.0 Unported (CC BY-SA 3.0) license.
The HPR Website Design is released to the Public Domain.