This is part 2 of the series "Writing A Word Memory Game In Elm", find:
- Part 1: Setting Up an Elm Application With Parcel
- Part 2: Modeling And Building a Basic Word Memory Game
- Part 3: Rethinking the Model
- Part 4: Spicing Things Up With Randomness
- Part 5 - More Randomness And More Game
The game view should show the sentence with missing words, and the missing words as a list below it. The player will be able to choose a word and, if this word is correct, it will be marked in green in the list. For the "easy" level the view will show a dropdown list of available words.
Let's think about how the Model
of the game should look like. For modeling a word let's declare a custom type Word
with variants SentenceWord
and HiddenWord
, each holding a String
value:
type Word
= SentenceWord String
| HiddenWord String
The sentence is a List
of Word
which we can alias as Words
:
type alias Words =
List Word
To update the correct word in a sentence when the player makes a choice, we can use the index of the word within the sentence. This means that we should update the Word
type to hold an index with a String
value by using a Tuple
:
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String )
The Model
now looks like this:
type alias Model =
{ sentence : String
, chosenWords : Words
, chosenSentence : Words
}
For now we can hardcode the initialModel
:
initialModel : Model
initialModel =
{ sentence = "The pen is mightier than the sword"
, chosenWords =
[ HiddenWord ( 1, "pen" )
, HiddenWord ( 6, "sword" )
, HiddenWord ( 3, "mightier" )
]
, chosenSentence =
[ SentenceWord ( 0, "The" )
, HiddenWord ( 1, "" )
, SentenceWord ( 2, "is" )
, HiddenWord ( 3, "" )
, SentenceWord ( 4, "than" )
, SentenceWord ( 5, "the" )
, HiddenWord ( 6, "" )
]
}
In chosenSentence
, we start by setting the String
value of each HiddenWord
to be empty. These will then be updated with the word the player chooses, and compared with chosenWords
entries by index. If this HiddenWord
in chosenSentence
equals to one of HiddenWord
in chosenWords
, the choice is correct. For example:
chosenWords =
[ HiddenWord ( 1, "pen" )
, HiddenWord ( 6, "sword" )
, HiddenWord ( 3, "mightier" )
]
chosenSentence =
[ SentenceWord ( 0, "The" )
, HiddenWord ( 1, "pen" ) -- this choice is correct
, SentenceWord ( 2, "is" )
, HiddenWord ( 3, "sword" ) -- this choice is incorrect, wrong index
, SentenceWord ( 4, "than" )
, SentenceWord ( 5, "the" )
, HiddenWord ( 6, "" )
]
Let's add a new viewSentence
function to render the sentence:
viewSentence : Words -> Words -> Html msg
viewSentence sentence chosenWords =
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWord ( _, word ) ->
span [ class "sentence-word" ] [ text word ]
HiddenWord ( index, word ) ->
span [ class "hidden-word" ] [ text "-----" ]
)
sentence
)
It's pretty straightforward: we are rendering Words
that can be SentenceWord
for showing a regular word and a HiddenWord
for showing a word that was chosen. As a first iteration we are just rendering dashes. In the next step we will render a dropdown <select>
.
In order for the words to have a space between them let's add a style.css
inside src
folder:
.hidden-word,
.sentence-word {
display: inline-block;
margin-left: 0.5em;
margin-bottom: 1em;
line-height: 2em;
}
.hidden-word:first-child,
.sentence-word:first-child {
margin-left: 0;
}
And import it inside our index.js
:
import { Elm } from './src/Main.elm';
import './src/style.css';
Elm.Main.init({
node: document.getElementById('app')
});
Update the main view
function with a call to viewSentence
:
view : Model -> Html msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ text model.sentence ]
, viewSentence model.chosenSentence model.chosenWords
]
]
]
Ellie: https://ellie-app.com/5CQqN2ptGsha1
Rendering The DropDown For Hidden Words
Let's add the new viewHiddenWord
function that will render <select>
for a HiddenWord
:
viewHiddenWord : Word -> List Word -> Html msg
viewHiddenWord hiddenWord chosenWords =
case hiddenWord of
HiddenWord ( _, hiddenWordText ) ->
let
viewOption : String -> Html msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : Word -> Html msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
in
div [ class "select" ]
[ select [ class "hidden-word" ] <|
option []
[ text "" ]
:: List.map wordElement chosenWords
]
_ ->
text ""
The viewHiddenWord
accepts the current hidden word and a list of all missing words and renders a drop down for a selection.
First thing we do is to pattern match the Word
type to get our HiddenWord
, because only for HiddenWord
should we render the <select>
element. The SentenceWord
render is still handled in the main view
function.
The viewHiddenWord
function renders a <select>
element and maps over the list of all missing words. For each one of them it calls wordElement
function. Here we must pattern match both cases but we render the same <option>
element with word's text.
Let's call it from viewSentence
:
viewSentence : Words -> Words -> Html msg
viewSentence sentence chosenWords =
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWord ( _, word ) ->
span [ class "sentence-word" ] [ text word ]
HiddenWord ( index, word ) ->
viewHiddenWord sentenceWord chosenWords
)
sentence
)
We should see that we can now choose any of the missing words:
Ellie: https://ellie-app.com/5CQyR6WT3zca1
Adding Interaction
The player can chose a word, but our Model
is not updated with this change. We can use the onInput
event on select
and update the model
with the current selection. Since we have a chosenSentence
as a List
of Tuple
s that have an index
- we know which Word
we need to update.
Let's delete the NoOp
message type and add a new one that will get an index and a new word String
:
type Msg
= WordChanged Int String
Change the update
function to handle the new Msg
(don't forget to delete the code related to NoOp
message):
update : Msg -> Model -> Model
update msg model =
case msg of
WordChanged index wordString ->
let
updateWord : Word -> Word
updateWord word =
case word of
(HiddenWord ( hiddenIndex, _ )) as hiddenWord ->
if hiddenIndex == index then
HiddenWord ( index, wordString )
else
hiddenWord
_ ->
word
newSentence : Words
newSentence =
List.map updateWord model.chosenSentence
in
{ model | chosenSentence = newSentence }
We map over all the words in the chosenSentence
and if the index of the current sentence (hiddenIndex
) word equals to the index of the changed word (index
) - we replace the String
part of the Tuple
with the new wordString
.
We are using as
syntax to have a reference to the whole matched word, which we return if the index
is not equal to hiddenIndex
.
If the current word is not a HiddenWord
- we just return the current word.
Let's update the viewHiddenWord
function to emit the message from select
element:
viewHiddenWord : Word -> List Word -> Html Msg
viewHiddenWord hiddenWord chosenWords =
case hiddenWord of
HiddenWord ( hiddenIndex, hiddenWordText ) ->
let
viewOption : String -> Html Msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : Word -> Html Msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement chosenWords
]
_ ->
text ""
The onInput
expects a function that accepts a String
as a parameter. The WordChanged
message accepts an Int
and a String
. We pass to onInput
(WordChanged hiddenIndex)
that is exactly what it needs. In Elm every function is automatically curried and, therefore, (WordChanged hiddenIndex)
returns a function that is waiting for a String
.
Note that we have changed the msg
(any type) to Msg
(our specific message type). We also need to make the same change in ViewSentence
function since it is now also propagates our new message:
viewSentence : Words -> Words -> Html Msg
-- omitted
The Model
has initial missing words as an empty String
:
The Model
is updated when a word is chosen in the dropdown:
Ellie: https://ellie-app.com/5CQDxjPhpwpa1
Hint Correctness
We have a nice interactive game already, but the player has no indication whether the words they chose are correct.
We can render a list of missing words and hint the player if their chosen word is the correct one.
To render a list of missing words and know if any word is a correct choice, we need a list of missing words and a list of sentence words. If a missing word is a member of sentence words - has the same index and a word string - the word was chosen correctly:
viewChosenWords : Words -> Words -> Html msg
viewChosenWords chosenWords sentenceWords =
let
viewChosenWord : Word -> Html msg
viewChosenWord chosenWord =
case chosenWord of
HiddenWord ( _, wordString ) ->
let
isCorrectGuess : Bool
isCorrectGuess =
List.member chosenWord sentenceWords
className : String
className =
if isCorrectGuess then
"has-text-success"
else
"has-text-grey-light"
in
li []
[ span [ class className ]
[ text wordString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
SentenceWord _ ->
text ""
in
ul [] (List.map viewChosenWord chosenWords)
We are using Font Awesome's icon and Bulma's classes for coloring the words in light grey or green.
Add viewChosenWords
to the main view
function to render the list:
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ text model.sentence ]
, viewSentence model.chosenSentence model.chosenWords
, viewChosenWords model.chosenWords model.chosenSentence
]
]
]
And now every time a player choses the correct word - it is colored in green in the chosen words list:
Ellie: https://ellie-app.com/5CQKJZdYGMKa1
The game works, but the Model
can be improved. In the next post we will refactor it to eliminate invalid states. Stay tuned!
The current progress is saved in the repo under a Tag v1.0 https://github.com/mickeyvip/words-memory-game/tree/v1.0.
Top comments (2)
viewSentence takes one argument:
But in the view you are passing two arguments:
Hi, Anton.
You are absolutely right! I was trying to code along with the writing, but still got some things wrong.
Just updated the article and added Ellie links.
Thank you.