7
\$\begingroup\$

I just started making a text adventure game in Haskell. Because this is the largest project I have done in Haskell, I wanted to ask about it here before I got too far on it. I am still pretty new to Haskell, so I would really appreciate any advice at all on how this could be improved.

To compile the game, I just used ghc Main.

Here is my code:

Main.hs

import System.IO (hFlush, stdout)
import Control.Monad (unless, when, guard)
import Control.Applicative ((<|>), (<*>))
import Data.Maybe (fromMaybe, isJust, fromJust)
import Control.Monad.State
import Data.Char (isSpace)
import Room (Room)
import qualified Room as Room
import Game (Game, GameState)
import qualified Game as Game
import Direction
import Item (Item)
import qualified Item as Item
type GameResponse = IO (String, Game)
trim :: String -> String
trim = foldr pickChars []
 where
 pickChars ' ' (' ':xs) = ' ':xs
 pickChars c1 (x:xs) = c1:x:xs
 pickChars c1 [] = [c1]
travel :: Direction -> GameState
travel d = state $ \g -> fromMaybe ("You can't go that way.", g)
 (flip runState g . Game.enterRoom <$>
 Room.roomInDirection d (Game.currentRoom g))
main = do
 (msg, _) <- play . return $ runState Game.initGame Game.gameData
 putStrLn msg
play :: GameResponse -> GameResponse
play x = do
 (msg, gameData) <- x
 unless (null msg) $ putStrLn msg >> putStrLn ""
 putStr "> "
 hFlush stdout
 response <- trim <$> getLine
 if response `elem` ["q", "quit"]
 then return ("Adiós!", gameData)
 else play . return $ runState (exec response) gameData
exec :: String -> GameState
exec "look" = state $ \g -> (Room.nameWithDescription $ Game.currentRoom g, g)
exec s
 | isJust direction = travel (fromJust direction)
 | take 4 s == "take" || take 3 s == "get" = Game.takeItem s
 | s `elem` ["i", "inv", "inventory"] = Game.displayInv
 | s `elem` [" ", ""] = return ""
 | otherwise = return "What?"
 where
 direction = directionFromString s

Direction.hs

module Direction
( Direction(..)
, directionFromString
) where
import Data.Char (toLower)
data Direction = North | NorthEast | East | SouthEast 
 | South | SouthWest | West | NorthWest
 deriving (Show, Eq, Ord)
directionFromString :: String -> Maybe Direction
directionFromString s
 | d `elem` ["n", "north"] = return North
 | d `elem` ["ne", "northeast", "north east"] = return NorthEast
 | d `elem` ["e", "east"] = return East
 | d `elem` ["se", "southeast", "south east"] = return SouthEast
 | d `elem` ["s", "south"] = return South
 | d `elem` ["sw", "southwest", "south west"] = return SouthWest
 | d `elem` ["w", "west"] = return West
 | d `elem` ["nw", "northwest", "north west"] = return NorthWest
 | otherwise = Nothing
 where d = map toLower s 

Room.hs

module Room
( Room(..)
, roomInDirection
, look
, nameWithDescription
, removeItem
, findItem
) where
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.List (find, isInfixOf, delete)
import Direction
import Item (Item)
import qualified Item as Item
data Room = Room
 { name :: String
 , description :: String
 , directions :: Map Direction String
 , visited :: Bool
 , items :: [Item]
 } deriving (Show, Eq)
roomInDirection :: Direction -> Room -> Maybe String
roomInDirection d r = Map.lookup d (directions r)
nameWithDescription :: Room -> String
nameWithDescription r = name r ++ '\n':(description r)
look :: Room -> IO ()
look = putStrLn . nameWithDescription
findItem :: String -> Room -> Maybe Item
findItem n = find (\i -> isInfixOf (Item.name i) n) . items
removeItem :: Item -> Room -> Room
removeItem i r = r { items = delete i (items r) }

Item.hs

module Item 
( Item(..)
) where
data Item = Item
 { name :: String
 } deriving (Show, Eq)

Game.hs

module Game 
( Game(..)
, GameState
, find
, enterRoom
, gameData
, initGame
, takeItem
, displayInv
) where
import Data.Map.Strict (Map, (!))
import qualified Data.Map.Strict as Map
import Control.Monad.State
import Data.Maybe (fromMaybe)
import Direction
import Item (Item)
import qualified Item as Item
import Room (Room)
import qualified Room as Room
type GameState = State Game String
data Game = Game
 { currentRoom :: Room
 , roomMap :: Map String Room
 , inventory :: [Item]
 } deriving Show
gameData = Game { roomMap = Map.fromList
 [("corridor",
 Room.Room { Room.name = "Corridor"
 , Room.description = "ayyyy"
 , Room.directions = Map.fromList [
 (North, "dining hall") ]
 , Room.visited = False
 , Room.items = []
 })
 ,("dining hall",
 Room.Room { Room.name = "Dining Hall"
 , Room.description = "good food is here"
 , Room.directions = Map.fromList [
 (South, "corridor") ]
 , Room.visited = False
 , Room.items = [
 Item.Item { Item.name = "apple" } ]
 })
 ]
 , currentRoom = undefined
 , inventory = []
 }
find :: String -> Game -> Room
find n g = (roomMap g) ! n
enterRoom :: String -> GameState
enterRoom n =
 state $ \g ->
 (msg g
 , g { roomMap = Map.update (\x -> Just (x { Room.visited = True })) n (roomMap g),
 currentRoom = r g })
 where 
 r g = find n g
 msg g
 | Room.visited (r g) = Room.name (r g)
 | otherwise = Room.nameWithDescription (r g)
initGame :: GameState
initGame = enterRoom "corridor"
removeItemFromCurrentRoom :: Item -> GameState
removeItemFromCurrentRoom i = state $ \g ->
 ("Taken!", g { currentRoom = Room.removeItem i (currentRoom g), inventory = i:(inventory g) })
takeItem :: String -> GameState
takeItem n = state $ \g -> fromMaybe ("Item not found", g)
 (flip runState g . removeItemFromCurrentRoom
 <$> Room.findItem n (currentRoom g))
displayInv :: GameState
displayInv = state $ \g -> (safeInit . unlines . fmap Item.name . inventory $ g , g)
 where safeInit [] = []
 safeInit xs = init xs
asked Mar 27, 2017 at 23:16
\$\endgroup\$

1 Answer 1

4
\$\begingroup\$

The last two lines of pickChars collapse to pickChars c xs = c:xs. trim = unwords . words may be to your liking.

StateT can handle play's state-passery, but you'll have to redefine GameState as Monad m => StateT Game m String to allow play's IO actions.

main = putStrLn =<< evalStateT (Game.initGame >>= play) Game.gameData
play :: StateT Game IO String
play msg = do
 unless (null msg) $ putStrLn msg >> putStrLn ""
 putStr "> "
 hFlush stdout
 response <- trim <$> getLine
 if response `elem` ["q", "quit"]
 then return "Adiós!"
 else exec response >>= play

exec "look" = gets $ Room.nameWithDescription . Game.currentRoom

| Just direction <- directionFromString s = travel direction

You don't seem to be using State's monad instance. State's purpose is for you to not need to pass gamestates around manually! Doing state $ \s -> runState everywhere misses the point.

lens can help with nested data structures. An example:

data Room = Room
 { _name :: String
 , _description :: String
 , _directions :: Map Direction String
 , _visited :: Bool
 , _items :: [Item]
 } _deriving (Show, Eq)
makeFields ''Room
find :: String -> Lens' Game Room
find n = roomMap . singular (ix n)
enterRoom :: String -> GameState
enterRoom n = do
 r <- use $ find n
 v <- find n . Room.visited <<.= True
 currentRoom .= r
 return $ r ^. if v then Room.name else Room.nameWithDescription

Note that currentRoom is the version of r that does not have its visited set to True yet. This could have been averted if currentRoom merely contained the room's name.

The recommendation to use abstractions theoretically also goes for travel, but what's needed here isn't available in the common libraries. Using my package prototype pointed-alternative and missing StateT combinator getsT:

travel :: Direction -> GameState
travel d = ascertain "You can't go that way."
 $ Game.enterRoom
 =<< getsT (Room.roomInDirection d . Game.currentRoom)
answered Mar 28, 2017 at 5:11
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.