Code Us Some Roguelike in Haskell (Part 2)!
This is part 2 of a multi-part tutorial:
Design Decisions
This post is more focused on what sort of a game we are making. For purposes of this tutorial Thieflike must be relatively small. The idea of roguelikes may be a little different for each person, so I want to go over the things I think needs to be in this game to place it within that genre:
- Random dungeons
- Monsters
- Gear & Potions
- Treasure
- Doors and the bashing thereof
- Environmental stuff within the dungeon (pits, traps, etc.)
- Line-of-Sight
- Mapping
Even with such a small set of features the game could get pretty hairy. Especially since we intend to eventually add support for a graphical front-end beyond the console. How can we keep each of those things in the game, while still keeping it simple?
Random Dungeons
While there are all sorts of awesome ways to randomize each level, we will be sticking to something like the traditional method used in Rogue. This may not produce the snazziest environments around, but it will serve the purpose and prevent our levels from becoming superfluous given the number of elements we are playing with.
We’ll also be implementing a system for random/wandering monsters and treasure layout similar to the old D&D Red Box, where each room has specific random percentages for oddities.
Since we’re making our design decision right now we won’t be doing the random dungeon part until next post, this time I’ll be copping out with a pre-created string.
Monsters
This may be blasphemous - but we’ll only have one enemy type, and its strength will be based entirely around how deep within the dungeon it is. We can always add more later, but this will keep complexity down initially.
Let’s call this monster type ‘villain’, just because I think that’s a funny monster type name. You can call it ‘orc’, or ‘were-gelatinous cube’, or whatever you like.
Last tutorial we kept all of our data type declarations in Main, but now we’re going to have a ton more. For now let’s break those out into a new file called Types.hs
in the same directory as Main.hs
--file: Types.hs
module Types where
import qualified Data.Map as M
type Coord = (Int, Int)
-- foul beasts
data Villain = Villain { vCurrPos :: Coord
, vGold :: Int
, vHP :: Int
, vItems :: [Item]
, vOldPos :: Coord }
We move Coord
from Main.hs
into Types.hs
and we import Data.Map
, because a lot of our data will be stored in a map. There are many name clashes in a lot of the Data.*
libraries, so I always like to qualify the import as the first letter of the primary data type provided.
vCurrPos
and vOldPos
are coordinates to where the villain is currently, and where they were last turn. We’ll be using this to make sure we only draw positions on screen that have actually changed. The other attributes are obvious monster stuff - how many hit points a creature has left, what gold its carrying, and what items it possesses…which leads us to our next point.
Gear
The only gear that is essential for me is weapons, potions, and armor. Since we aren’t including a leveling or skill system, the gear a hero finds will kind of be progress, assuming the deeper the dungeon level is the better its gear is.
-- file: Types.hs
data Item = Arm Armor
| Pot Potion
| Weap Weapon
data Armor = Armor { aDefense :: Int
, aDest :: String }
data Potion = Potion { pAmount :: Int
, pDesc :: String
, pEffect :: Effect }
data Effect = Harm
| Heal
data Weapon = Weapon { wDamage :: Int
, wDesc :: String
, wToHit :: Int }
Most of the item attributes will become more obvious when we begin putting together the combat system.
Treasure
Just like monsters, there will be only one form of currency laid out throughout the dungeon - gold. It also won’t really do anything other than provide a score system.
Environmental Stuff
Most of the things that can’t be fought/picked-up will be represented as tiles - stairs, doors, etc. To keep down on what we need to program we won’t be implementing traps, but we should have at least one damaging environmental hazard - let’s put in giant pools of acid.
-- file: Types.hs
data Tile = Acid
| Dr Door
| St Stairs
| Wall
data Door = Closed
| Open
data Stairs = Downstairs
| Upstairs
Line-of-Sight and Mapping
We’ll implement mapping by keeping a dictionary of coordinates with a Bool
of whether or not the space has been mapped before. Line-of-sight will be discussed in a later post.
Wrapping up Types
We still need to move our Hero, Input and World declaration from Main.hs
to Types.hs
. We also need to set up a data type to represent the level.
-- file: Types.hs
data Input = Dir Direction
| Exit
data Direction = Up
| Down
| Left
| Right
data Hero = Hero { hCurrPos :: Coord
, hGold :: Int
, hHP :: Int
, hItems :: [Item]
, hOldPos :: Coord
, hWield :: Weapon
, hWears :: Armor }
data Level = Level { lDepth :: Int
, lGold :: M.Map Coord Int
, lItems :: M.Map Coord Item
, lMapped :: M.Map Coord Bool
, lMax :: Coord
, lTiles :: M.Map Coord Tile
, lVillains :: M.Map Coord Villain }
data World = World { wDepth :: Int
, wHero :: Hero
, wLevel :: Level
, wLevels :: [Level] }
The Hero is pretty straightforward - similar to the villain except the Hero can wield weapons and wear armor.
Level needs some explaining. Now, I’m not saying I’m an expert roguelike or game developer and I’m not saying this is necessarily the best way to represent a world, but it certainly works. lDepth
says how deep in the dungeon this particular level is, and lMax
is the largest (x,y) coordinate for the level. Every other attribute is a mapping from coords to a representative type.
To get the game up and running we need to come up with some defaults data structures. I’ll be cheating a little on the default level and the hero’s position, but we’ll fix that when we generate random worlds.
-- file: Types.hs
emptyLevel = Level { lDepth = 0
, lGold = M.empty
, lItems = M.empty
, lMapped = M.fromList [((1,1), True)]
, lMax = (1,1)
, lTiles = M.empty
, lVillains = M.empty }
-- bare fists/no weapon
fists = Weapon 0 "Bare fists" 0
-- no armor
rags = Armor 0 "Rags"
-- a basic world used to start the game
genesis = World { wDepth = 0
, wHero = commoner
, wLevel = emptyLevel
, wLevels = [emptyLevel] } -- all levels
-- a basic hero
commoner = Hero { hCurrPos = (1,1)
, hGold = 0
, hHP = 10
, hItems = []
, hOldPos = (1,1)
, hWeild = fists
, hWears = rags }
Building a Test Level
If we’re going to build a small test level we’re going to need a couple functions to help out. Open a new file and call it Level.hs
. Usual imports here:
-- file: Level.hs
module Level where
import qualified Data.Map as M
import Types
The initial level will be given to us as a string, so we’ll need to translate characters into our maps.
-- file: Level.hs
strsToLevel :: [String] -> Level
strsToLevel str = foldl populate emptyLevel {lMax=maxXY} asciiMap
where
asciiMap = concat $ zipWith zip coords str
coords = [[(x, y) | x <- [0..]] | y <- [0..]]
maxX = maximum . map (fst . fst) $ asciiMap
maxY = maximum . map (snd . fst) $ asciiMap
maxXY = (maxX, maxY)
populate lvl (coord, tile) =
case tile of
'#' -> lvl { lTiles = M.insert coord Wall t }
'>' -> lvl { lTiles = M.insert coord (St Downstairs) t }
'<' -> lvl { lTiles = M.insert coord (St Upstairs) t }
'+' -> lvl { lTiles = M.insert coord (Dr Closed) t }
'-' -> lvl { lTiles = M.insert coord (Dr Open) t }
'~' -> lvl { lTiles = M.insert coord Acid t }
_ -> lvl
where t = lTiles lvl
Most of this function is shorthand , let’s take a look at it piece by piece.
asciiMap
is a list of tuples with a Coord
as the first element, and a character from the string as the second. The types here are important to understand what’s going on. zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
, zip :: [a] -> [b] -> [(a, b)]
, coords
is basically [[Coord]]
and str :: [String]
. So zip
is zipWith
’s (a -> b -> c)
, [[Coord]]
is zip
’s [[a]]
, etc.
coords
may seem confusing if you’re coming from a language that doesn’t support lazy lists. It is in fact a a list of lists, where the first element is a list of basically Coords
, all with y
bound to 0
and x
bound to [0..infinity]
. Each additional element binds y
to successively greater values. In a nutshell, this is building a list of infinite coordinates, where each element represents a row with an infinite number of columns. Fortunately zip
tangles our problem of infinity - it only ties as many elements to each actual row as there are available in str
. By zipWith
ing zip
we’re able to combine each coord to its corresponding character in char, but we have one problem left over - they are all one list too deep. Fortunately concat
solves this for us. concat :: [[a]] -> [a]
‘flattens’ a list of lists one-level.
If this doesn’t make much sense at first I’d suggest playing around with functions like zip
and zipWith
. They are immensely useful, along with functions like the fold
s and scan
s for manipulating sequences functionally and elegantly where one may have thought required some sort of an iterative loop. In this case its easy to keep coords
contained in strsToLevel
because that’s the only place its going to be used, but when crafting your own sequencing functions if you find them difficult you may want to break them out into their own top-level functions so you can check the types easier with either GHC or GHCi.
After building up our asciiMap
we foldl
an empty level (plus calculated max value based on the dimensions of str
) over a function we’re calling populate
. So we take our level, consume the next character in asciiMap
, and return a new level for further folding (if there are any chars left). populate
looks for specific characters and inserts them into the level being returned.
Another set of functions we’re going to need is determining if a particular Coord
is one of our dungeon features or not. Fortunately because our game is so simple we can basically just set functions like isGold
to be Data.Map
’s member
function for a given level’s lGold
map. There are a few cases where we do have to perform a lookup
on our maps, such as with Item
s and Tile
s, because we support multiple types within those corresponding dictionaries.
-- file: Level.hs
isAcid coord lvl = case M.lookup coord (lTiles lvl) of
Just Acid -> True
_ -> False
isClosedDoor coord lvl = case M.lookup coord (lTiles lvl) of
Just (Dr Closed) -> True
_ -> False
isOpenDoor coord lvl = case M.lookup coord (lTiles lvl) of
Just (Dr Open) -> True
_ -> False
isWall coord lvl = case M.lookup coord (lTiles lvl) of
Just Wall -> True
_ -> False
isDownstairs coord lvl = case M.lookup coord (lTiles lvl) of
Just (St Downstairs) -> True
_ -> False
isUpstairs coord lvl = case M.lookup coord (lTiles lvl) of
Just (St Upstairs) -> True
_ -> False
isGold coord lvl = M.member coord (lGold lvl)
isVillain coord lvl = M.member coord (lVillains lvl)
isArmor coord lvl = case M.lookup coord (lItems lvl) of
Just (Arm _) -> True
_ -> False
isPotion coord lvl = case M.lookup coord (lItems lvl) of
Just (Pot _) -> True
_ -> False
isWeapon coord lvl = case M.lookup coord (lItems lvl) of
Just (Weap _) -> True
_ -> False
And finally finishing up level for now we’ll construct a ‘cheater’ level until we get the random dungeon generator up and running.
-- file: Level.hs
map1 = [ "##############"
, "#> # ######"
, "# ############ #"
, "# - + #"
, "# ~~ ############ #"
, "# ~~ # # #"
, "# ~~ # # < #"
, "############## ######" ]
level1 = strsToLevel map1
I’ve never quite been a fan of how hallways are typically represented in roguelikes, so they’ll just look like really long rooms.
Graphics
While we’re handling breaking level out into its own file, let’s break out all of the drawing into one file. We’ll call it Console.hs
, because we eventually want to make a GUI front-end as well.
-- file: Console.hs
module Console where
import System.Console.ANSI
import Level
import Types
coordToChar coord (World _ hero lvl _)
| hCurrPos hero == coord = '@'
| isAcid coord lvl = '~'
| isClosedDoor coord lvl = '+'
| isOpenDoor coord lvl = '-'
| isDownstairs coord lvl = '<'
| isGold coord lvl = '$'
| isPotion coord lvl = '!'
| isUpstairs coord lvl = '>'
| isVillain coord lvl = 'v'
| isWall coord lvl = '#'
| isWeapon coord lvl = ')'
| otherwise = ' '
drawChar '@' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Blue ]
putChar '@'
drawChar '#' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Black ]
putChar '#'
drawChar '!' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Magenta]
putChar '!'
drawChar '$' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Yellow ]
putChar '$'
drawChar 'v' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Red ]
putChar 'v'
drawChar ')' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Cyan ]
putChar ')'
drawChar '>' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Dull Blue ]
putChar '>'
drawChar '<' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Dull Cyan ]
putChar '<'
drawChar '\n' = do
putChar '\n'
drawChar '+' = do
setSGR [ SetConsoleIntensity NormalIntensity
, SetColor Foreground Dull Magenta ]
putChar '+'
drawChar '-' = do
setSGR [ SetConsoleIntensity NormalIntensity
, SetColor Foreground Dull Yellow ]
putChar '-'
drawChar '~' = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Green ]
putChar '~'
drawChar _ = do
setSGR [ SetConsoleIntensity BoldIntensity
, SetColor Foreground Vivid Black ]
putChar ' '
Those functions should be pretty straight forward, go ahead and change the colors and intensities to whatever you wish. Now let’s remove the various draw functions from Main.hs
and make new versions of them.
-- file: Console.hs
drawCoord world coord = do
uncurry (flip setCursorPosition) coord
drawChar (coordToChar coord world)
drawHero world
| newPos == oldPos = return ()
| otherwise = do
drawCoord world newPos
drawCoord world oldPos
where
hero = wHero world
newPos = hCurrPos hero
oldPos = hOldPos hero
drawWorld world = do
setCursorPosition 0 0
mapM_ drawChar (unlines chars)
where
lvl = wLevel world
(x',y') = lMax lvl
chars = [[coordToChar (x,y) world | x <- [0..x']]
| y <- [0..y']]
drawCoord
does something a little funky - I should have mentioned in my first post that while our Coord
s are (x, y)
, setCursorPosition
is looking for input in the style of y -> x
. So we flip
that function to get it to accept x
first, and then we uncurry
it. That transforms it from being a normal curried function into being a function that will accept our Coord
.
drawHero
is pretty obvious, we just make sure to redraw the coord that the hero was previously on, and then draw the position of the hero as it is currently. This will keep our draws down.
drawWorld
is only intended to be drawn every so often. It sets the cursor to the top-left of the screen, and then it draws all the characters from left to right, top to bottom. We use the same infinite list of list of coords that we did in strsToLevel
, and we unlines
it to intersperse newlines inbetween each row so that we don’t get one long line of every character on the map.
That does it for Console.hs
, let’s get Main.hs
done and get to playing. Or rather, get to moving a guy around a map, but at least we got most of the foundation for the game pretty much down!
Main
Start out by importing everything we’ve done so far, and let’s adjust our main
function to account for our new data types.
-- file: Main.hs
module Main where
import Prelude hiding (Either(..))
import System.Console.ANSI
import System.IO
import Console
import Level
import Types
main = do
hSetEcho stdin False
hSetBuffering stdin NoBuffering
hSetBuffering stdout NoBuffering
hideCursor
setTitle "Thieflike"
clearScreen
let world = genesis { wLevel = level1, wLevels = [level1] }
drawWorld world
gameLoop world
We only need to draw the whole world once on this iteration of Thieflike, so we do that right before jumping into the gameLoop.
-- file: Main.hs
gameLoop world = do
drawHero world
input <- getInput
case input of
Exit -> handleExit
Dir dir -> handleDir world dir
getInput = do
char <- getChar
case char of
'q' -> return Exit
'w' -> return (Dir Up)
's' -> return (Dir Down)
'a' -> return (Dir Left)
'd' -> return (Dir Right)
_ -> getInput
gameLoop
and getInput
are similar to before, but we account for the fact that Input
is either Dir Direction
or simply Exit
. We also make sure to draw the hero prior to receiving input.
-- file: Main.hs
handleExit = do
clearScreen
setCursorPosition 0 0
showCursor
setSGR [Reset]
putStrLn "Thank you for playing!"
I was pointed out in comments to post 1 that I should be calling setSGR [Reset]
prior to exiting.
-- file: Main.hs
dirToCoord Up = (0, -1)
dirToCoord Down = (0, 1)
dirToCoord Left = (-1, 0)
dirToCoord Right = (1, 0)
handleDir w dir
| isWall coord lvl ||
isClosedDoor coord lvl = gameLoop w { wHero = h { hOldPos = hCurrPos h } }
| otherwise = gameLoop w { wHero = h { hOldPos = hCurrPos h
, hCurrPos = coord } }
where
h = wHero w
lvl = wLevel w
coord = (newX, newY)
newX = hConst heroX
newY = hConst heroY
(heroX, heroY) = hCurrPos h |+| dirToCoord dir
hConst i = max 0 (min i 80)
-- same as before
(|+|) :: Coord -> Coord -> Coord
(|+|) (x1, y1) (x2, y2) = (x1 + x2, y1 + y2)
handleDir
check’s the hero’s next position and looks to see if it is a wall or door. If it is - the hero stays put, otherwise the hero gets to move. We’ll add support for collision with all of our objects when we build up the combat system.
Finalizing Part 2
Now we should be able to compile Main.hs
and move our little figure around. The hero should not be able to move through walls or the closed door. This has kind of been a long post, but we got some definitions done early for what the game will become.
Thank you very much for reading, and please leave me any comments you have!
You can find the source to this post here.