Please Buy now at Leanpub
Written and illustrated by GetContented.
Published on 2017-06-25.
We’re going to introduce you to a tiny toy educational game that introduces quite a few new functions and features of Haskell.
We won’t be using any special algebraic data types in this program. In particular, our main type will be:
-- a game Level is simply a pair
-- of Integer values
type Level = (Integer, Integer)
A Level
is simply a pair that has two Integer
values. As this is a simple math game, each level will be a pair of numbers that represent the two numbers for a multiplication question. (For example, a level value of (7,9)
would represent a question something like “What is 7 times 9?”).
The next definition is where we build the levels for this game:
levels :: [Level]
levels =
concat $ map pairsForNum [3,5..12]
where
pairsForNum num = zip [2..12] $ repeat num
Wow, there is a lot packed into this value expression. Let’s pull it apart piece by piece.
The levels
value is a list of Level
values. It’s defined using the ($)
function, which takes a function on the left, and applies it to the value or expression on the right.
In our case here, the value expression on the right is map pairsForNum [3,5..12]
. The main new thing in this expression for us is [3,5..12]
, which is what’s called a range. The range of numbers between 1 and 100, for example, would be represented as [1..100]
. The range above, though, is every number between 3 and 12, in odd numbers. (So, 3,5,7,9,11).
So what we’re doing, then, is mapping the pairsForNum
function across this range. This function takes a number, calling it num
, and “zips” the range [2..12]
together with repeat num
. Zipping is creating pairs from one item each of a number of lists as we’ll see. The repeat
function gives an infinite list of whatever its argument is. The zip
function takes two lists, and builds pairs out of those lists, taking one item from each as it does. So, zip [1,2,3] [4,5,6]
will create [(1,4),(2,5),(3,6)]
. However, the zip
function will stop when the first list runs out of values, so zipping an infinite list of repeated items with another that has only a few is just fine, as we’re doing.
So what we’ll end up with by using map pairsForNum [3,5,..12]
is a list of lists of pairs of Integer
values. This basically means we’re combining each of the elements with each of the others. Then, we use concat
to concatenate all the lists into one list. For example, concat [[1],[2],[3]]
results in [1,2,3]
. In math, this process of creating a big set out of two smaller sets is called a cartesian product. You don’t have to know this, but later we’ll see other, much simpler-looking, easier ways to do this. Unfortunately using them requires knowing more than we currently know, so that will have to wait.
The end result of this expression is that we have a list of 55 levels for our train of terror!
Here’s a function we’ll use in our program to work out what number level the player is at:
levelNumber :: [a] -> Int
levelNumber remainingLevels =
totalLevels - levelsLeft
where totalLevels = length levels + 1
levelsLeft = length remainingLevels
We take a list of remaining levels, then subtract the number of levels left (using the length function) from the number of levels we started with plus one. As the player “moves up” the train, we’ll be reducing the size of the list, so the remaining levels will get less, and the level number will go up. Notice from the type signature that we don’t care what the element type of the list passed into it is, as long as it’s a list.
The main
function simply gives the player a greeting message, then starts the trainLoop
function that handles all the game-play (by passing in the levels value).
main :: IO ()
main = do
putStrLn "Suddenly, you wake up. Oh no, you're on..."
putStrLn "The Times-Table Train of Terror!"
putStrLn "Try to get to the end. We DARE you!"
trainLoop levels
You can see we have our old friend the do
block in action again, joining a whole bunch of IO actions together to form one.
In a moment, we come to the main game-play function, trainLoop
.
The function trainLoop
has two definitions.
The first is for an empty list, which for our program means the player has won, because they got to the end of the train without failing. In this case, we declare their victory to them.
The second definition comes into play when there are still levels passed in. This means the game is still in play. Each time the player gets done with a level, we remove it from the list, then pass the rest of the levels back into the trainLoop function and start it again.
trainLoop :: [Level] -> IO ()
trainLoop [] =
putStrLn "You won! Well done."
trainLoop remainingLevels @ (currentLevel : levelsAfterThisOne) =
do
let currentLevelNumber =
levelNumber remainingLevels
(num1, num2) =
currentLevel
putStrLn $
"You are in a Train Carriage "
++ show currentLevelNumber
++ " of " ++ (show $ length levels)
putStrLn "Do you want to:"
putStrLn "1. Go to the next Carriage"
putStrLn "2. Jump out of the train"
putStrLn "3. Eat some food"
putStrLn "q. Quit"
activity <- getLine
case activity of
"1" ->
do
putStrLn $ "You try to go to the next carriage."
++ " The door is locked."
putStrLn "Answer this question to unlock the door:"
putStrLn $ "What is " ++ show num1
++ " times " ++ show num2 ++ "?"
answer <- getLine
if answer == (show $ num1 * num2)
then do
putStrLn "The lock is opened!"
trainLoop levelsAfterThisOne
else do
putStrLn $ "Wrong. You try to open the lock,"
++ " but it won't open."
trainLoop remainingLevels
"2" -> jumpingFutility
"3" -> eatingFutility
"q" -> putStrLn $ "You decide to quit."
++ " Thanks for playing. Bah-Bye!"
_ -> do
putStrLn "That makes NO sense! Try again."
trainLoop remainingLevels
The construction with an @
symbol means the whole argument is matched as remainingLevels
, and then, also, currentLevel
is set to the first item of the list, and levelsAfterThisOne
is set to the tail of the list. When we use the @
symbol like this, it’s called an as pattern.
The trainLoop
passes back an IO
action, so we begin a very large do
block. This do
block has lots of different kinds of things in it, to get you familiar with more complicated looking do
blocks.
Ideally, we would pull each of the sections into their own function. For now we’ll look at it like this because it serves our purposes quite nicely to show you a larger do
block.
Remember, each of these expressions will evaluate to an IO
action (because the final value of this function’s type is IO ()
), and the do
block simply connects them all up properly so they become one single IO
action.
First, we set up some variables we’ll need later using let
. A let
expression is another way we can define some locally scoped definitions. In a do
block, name defined in a let
expression will be available for the remainder of the do
block. One of these is currentLevelNumber
which uses the levelNumber
function we just looked at, and the next line is pattern matching the currentLevel
variable into two number variables num1
and num2
. Remember, currentLevel
’s type is Level
, which is (Integer, Integer)
, so num1
and num2
will both be Integer
values.
Then we print out a message explaining that the player is on a certain train carriage, using show to turn the number values into strings so they can be fed into (++)
with the other strings.
Next, a menu is described, and we receive a line of text from the player with getLine
, which goes into the variable called activity
.
After this, we have a case
expression that matches on the player’s response. If they wrote 2, 3 or q, then we write a message and restart the whole game, or quit out (q quits the game).
If, however, they typed 1 in, they’re asked this level’s multiplication question. If they get it right, it unlocks the current level and they can proceed, which we do by starting the trainLoop
again but with only the tail of the levels (by using the levelsAfterThisOne
variable).
All that remains is to show the definitions for jumpingFutility
and eatingFutility
. These actions are executed if the player tries to jump or eat.
All they do is print a message, then start the game at the very beginning by returning trainLoop applied to the initial levels.
jumpingFutility :: IO ()
jumpingFutility = do
putStrLn "You try to jump out of the train."
putStrLn "You fail and die."
trainLoop levels
eatingFutility :: IO ()
eatingFutility = do
putStrLn "You see a delicious looking cupcake."
putStrLn "You eat it. It's a time travel cupcake!"
trainLoop levels
Write a program that prints your birthday on the screen. Write another program that prints two numbers added on the screen, then one for multiplied, then subtracted.
If you’ve enjoyed reading this, please consider purchasing a copy at Leanpub today
Please follow us, and check out our videos Follow @HappyLearnTutes
Also, Volume 2 is now in beta and being written! Show your support and register your interest at its Leanpub site.