This is part 3 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 works now. Cool. But looking at the current Model
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String )
type alias Words =
List Word
type alias Model =
{ sentence : String
, chosenWords : Words
, chosenSentence : Words
}
we can spot several problems:
- The
chosenSentenceandchosenWordsare 2 separate parts of theModeland we can accidentally make them out of sync - Since
chosenWordsis of typeWords, it may contain alsoSentenceWord - The
sentencemay also become out of sync with the rest of theModel - We also have some code smell in
viewHiddenWord:
viewHiddenWord : Word -> List Word -> Html msg
viewHiddenWord hiddenWord chosenWords =
-- omitted code
wordElement : Word -> Html Msg
wordElement word =
case word of
HiddenWord ( _, wordString ) ->
viewOption wordString
SentenceWord ( _, wordString ) ->
viewOption wordString
-- omitted code
And probably more.
In Elm we want to invest additional time into a good planning of our model and in getting it as close as possible to a form that will eliminate the invalid state of our application.
This idea, not new I suppose, was introduced by Richard Feldman at Elm-Conf 2016 in his amazing (as always) talk:
Since then we often hear "make impossible states impossible" in different communities, not only Elm.
How can we improve our Model and eliminate possibilities for the invalid state and code smell?
Instead of having chosenWords and sentenceWords - we can just have sentence property and rename the sentence to sentenceOriginal:
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String )
type alias Words =
List Word
type alias Model =
{ sentenceOriginal : String
, sentence : Words
}
Looks better, but now we lost the original sentence's word or player's input, since there is only 1 String placeholder. Well... we can have the player's input and the original word in the same type constructor:
type Word
= SentenceWord ( Int, String )
| HiddenWord ( Int, String, String ) -- index, answer, player's choice
Also, maybe instead of having an index that can also get out of sync, we can use List.indexedMap when rendering the sentence which will give us the index:
type Word
= SentenceWord String
| HiddenWord String String -- answer, player's choice
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
We can go even further into modeling and replace String with a concrete types:
type PlayerChoice =
PlayerChoice String
type Answer =
Answer String
type Word
= SentenceWord String
| HiddenWord Answer PlayerChoice
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
This will prevent us from passing the answer (which is String) and the player's input (also String) to HiddenWord, in the wrong order. The code now is also more readable.
We have almost all we need now. The ability to have a random order of chosen words, however, is still missing. To solve this, we can add a SortKey to the HiddenWord:
type PlayerChoice =
PlayerChoice String
type Answer =
Answer String
type SortKey =
SortKey Int
type Word
= SentenceWord String
| HiddenWord SortKey Answer PlayerChoice
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
When the type constructor grows, it may be better to turn it to a record. Let's convert HiddenWord into a record:
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
We don't need the SortKey, just Int is sufficient:
type PlayerChoice =
PlayerChoice String
type Answer =
Answer String
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
type Word
= SentenceWord String
| HiddenWord HiddenWord
type alias Words =
List Word
type alias Model =
{ originalSentence : String
, sentence : Words
}
At this point Elm will complain about 2 HiddenWord type definitions.
We need to have different names. I renamed SentenceWord and HiddenWord to SentenceWrd and HiddenWrd:
type Word
= SentenceWrd String
| HiddenWrd HiddenWord
After this change we have a lot to refactor. Luckily we have our back covered with Elm compiler. Let's update the current initialModel:
type alias Model =
{ sentence : String
, chosenWords : Words
, chosenSentence : Words
}
initialModel : Model
initialModel =
{ originalSentence = "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, "" )
]
}
to this:
type alias Model =
{ sentence : Words
}
initialModel : Model
initialModel =
{ sentence =
[ SentenceWrd "The"
, HiddenWrd
{ sortKey = 1
, answer = Answer "pen"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "is"
, HiddenWrd
{ sortKey = 3
, answer = Answer "mightier"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "than"
, SentenceWrd "the"
, HiddenWrd
{ sortKey = 2
, answer = Answer "sword"
, playerChoice = PlayerChoice ""
}
]
}
We hardcoded the "random" order of the chosen words by setting the sortKey, also removed the originalSentence from the model (for now) and renamed chosenSentence to sentence.
The update function is the first we can refactor easily. Let's change:
-
newSentenceto useList.indexedMap -
updateWordto take an index as the first argument
update : Msg -> Model -> Model
update msg model =
case msg of
WordChanged index wordString ->
let
updateWord : Int -> Word -> Word
updateWord wordIndex word =
case word of
HiddenWrd hiddenWord ->
if wordIndex == index then
HiddenWrd
{ hiddenWord
| playerChoice = PlayerChoice wordString
}
else
word
_ ->
word
newSentence : Words
newSentence =
List.indexedMap updateWord model.sentence
in
{ model | sentence = newSentence }
We now have an index of the word to update with the player's input, and wordIndex of the current word we are running List.indexedMap on. If both indexes are equal, we need to update the playerChoice field of the HiddenWrd.
Now there are a lot of compiler errors. Let's go step by step and comment out all the functions with errors, that is viewSentence, viewHiddenWord, viewChosenWords and the invocation of them in the view:
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
]
]
]
The compilers complains about text model.sentence because text expects a String as input, but after the refactoring model.sentence is not a simple String but Words (which is itself a type alias for List Word).
First we need something that can convert a Word into a String:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd hiddenWord ->
case hiddenWord.answer of
Answer answerString ->
answerString
If word is SenteceWrd, we'll take the String part of it. If it's the HiddenWrd, we'll take the Answer from it and then pattern match to get its String part.
Let's add a viewOriginalSentence function to render the sentence:
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
(List.map
(\word ->
wordToString word |> text
)
words
)
We are mapping each Word to a String and passing the String to Html.text.
Let's update the view function to use viewOriginalSentence:
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ viewOriginalSentence model.sentence ]
-- , viewSentence model.chosenSentence model.chosenWords
]
-- , viewChosenWords model.chosenWords model.chosenSentence
]
]
The application now compiles! Lets see how it looks:
Well... all the words are written without any spaces between them. We need to add some space, one way we can do it is by adding a space after each word:
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
(List.map
(\word ->
wordToString word ++ " " |> text
)
words
)
Or we can use List.intersperse that "Places the given value between all members of the given list.":
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
(words
|> List.map wordToString
|> List.intersperse " "
|> List.map text
)
or event simpler with String.join:
viewOriginalSentence : Words -> Html Msg
viewOriginalSentence words =
p
[ class "has-text-centered" ]
[ words
|> List.map wordToString
|> String.join " "
|> text
]
And now the game looks better:
Let's do a small refactor before moving on. Let's add answerToString helper function to convert Answer into a String. We can use it from wordToString and it may come handy later when comparing the player's choice with the answer:
answerToString : Answer -> String
answerToString (Answer wordString) =
wordString
wordToString : Word -> String
wordToString word =
case word of
HiddenWrd hiddenWord ->
answerToString hiddenWord.answer
SentenceWrd wordString ->
wordString
Great. The next in line is the view for chosen words. Let's uncomment and examine it:
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)
The firs thing that we can improve is the signature. Now it takes chosenWords as Words, but it should only handle List HiddenWord.
Previously, we needed the sentenceWords to hint if the player's choice was correct. Now we have all the information in HiddenWord, both playerChoice and answer!
This means that the signature can be simplified and made to express the input better - we can only pass List HiddenWord here:
viewChosenWords : List HiddenWord -> Html msg
viewChosenWords chosenWords =
Let's also rename viewChosenWords to viewHiddenWords because hidden and chosen are used and it's confusing:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
We also need a way to get the List HiddenWord from our model. Let's add this ability. We need to filter out the HiddenWrd and take HiddenWord from it (note "Word" vs. "Wrd"). And Elm has us covered with List.filterMap:
hiddenWords : Words -> List HiddenWord
hiddenWords sentence =
List.filterMap
(\word ->
case word of
HiddenWrd hiddenWord ->
Just hiddenWord
_ ->
Nothing
)
sentence
Now we can update view to call it:
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ viewOriginalSentence model.sentence
-- , viewSentence model.chosenSentence model.chosenWords
]
, viewHiddenWords (hiddenWords model.sentence)
]
]
]
Back to viewHiddenWords. It has an inner viewChosenWord function. Let's rename it to viewHiddenWord and change the signature, since we now have only HiddenWord:
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
We don't need the case anymore for the same reason - we only have HiddenWord now, so we can delete it:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
isCorrectGuess : Bool
isCorrectGuess =
List.member hiddenWord 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" ] [] ]
]
]
in
ul [] (List.map viewHiddenWord hiddenWordList)
How do we know that the player's choice is the correct choice? As I previously mentioned, we have all we need in the HiddenWord type:
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
All we need is to compare the payerChoice and answer. They both are container types:
type PlayerChoice
= PlayerChoice String
type Answer
= Answer String
So we need to extract the String value from them. Let's add playerChoiceToString in addition to a previously declared answerToString:
playerChoiceToString : PlayerChoice -> String
playerChoiceToString (PlayerChoice stringValue) =
stringValue
answerToString : Answer -> String
answerToString (Answer stringValue) =
stringValue
Pretty straightforward using the destructuring.
Now we can use them in isCorrectGuess:
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
isCorrectGuess : Bool
isCorrectGuess =
answerToString hiddenWord.answer == playerChoiceToString hiddenWord.playerChoice
The className needs no change. But the li declaration again needs a String where wordString was:
li []
[ span [ class className ]
[ text wordString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
What should go there? The String from the hiddenWord.answer of course!
li []
[ span [ class className ]
[ text <| answerToString hiddenWord.answer
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
We can refactor it a little to reuse the value of answerToString hiddenWord.answer by adding 2 declarations:
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
answerString : String
answerString =
answerToString hiddenWord.answer
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
and:
isCorrectGuess : Bool
isCorrectGuess =
answerString == playerChoiceString
-- omitted code
li []
[ span [ class className ]
[ text answerString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
And now our viewHiddenWords looks like this:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
let
answerString : String
answerString =
answerToString hiddenWord.answer
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
isCorrectGuess : Bool
isCorrectGuess =
answerString == playerChoiceString
className : String
className =
if isCorrectGuess then
"has-text-success"
else
"has-text-grey-light"
in
li []
[ span [ class className ]
[ text answerString
, text " "
, span [ class "icon is-small" ]
[ i [ class "far fa-check-circle" ] [] ]
]
]
in
ul [] (List.map viewHiddenWord hiddenWordList)
Lets look at our game:
Looks good, but we are missing one more detail - the hidden words are presented in the same order as they appear in the sentence... If you remember we added sortKey to our HiddenWord to be able to show them in random order:
type alias HiddenWord =
{ sortKey : Int
, playerChoice : PlayerChoice
, answer : Answer
}
Currently, we have the random order hardcoded in the initialModel:
initialModel : Model
initialModel =
{ sentence =
[ SentenceWrd "The"
, HiddenWrd
{ sortKey = 3
, answer = Answer "pen"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "is"
, HiddenWrd
{ sortKey = 1
, answer = Answer "mightier"
, playerChoice = PlayerChoice ""
}
, SentenceWrd "than"
, SentenceWrd "the"
, HiddenWrd
{ sortKey = 2
, answer = Answer "sword"
, playerChoice = PlayerChoice ""
}
]
}
So what is left to do is to sort the hidden words by the sortKey before showing them. We'll add hiddenWordsSorted and use it :
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
hiddenWordsSorted : List HiddenWord
hiddenWordsSorted =
List.sortBy .sortKey hiddenWordList
viewHiddenWord : HiddenWord -> Html msg
viewHiddenWord hiddenWord =
-- omitted
in
ul [] <| List.map viewHiddenWord hiddenWordsSorted
One last small refactor I would like to do is to wordToString:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd hiddenWord ->
case hiddenWord.answer of
Answer answerString ->
answerString
We have answerToString so we can use it in the second branch of the case:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd hiddenWord ->
answerToString hiddenWord.answer
or even shorter:
wordToString : Word -> String
wordToString word =
case word of
SentenceWrd wordString ->
wordString
HiddenWrd { answer } ->
answerToString answer
The final part - viewSentence. Let's uncomment it in the view function.
viewSentence model.chosenSentence model.chosenWords
We don't have chosenSentence anymore since we replaced sentence, and we also got rid of the additional chosenWords. We do need to send the List HiddenWord to the viewSentence so it knows what to render as a <select> element. We already have a helper function for it - hiddenWords - that we use when calling viewHiddenWords. So the viewSentence may look like this:
view : Model -> Html Msg
view model =
-- omitted
[ viewOriginalSentence model.sentence
, viewSentence model.sentence (hiddenWords model.sentence)
]
Looks like we are sending model.sentence to viewSentence and using it to get hidden words... We don't need to send (hiddenWords model.sentence) since viewSentence can calculate them by itself!
view : Model -> Html Msg
view model =
main_ [ class "section" ]
[ div [ class "container" ]
[ viewTitle
, div [ class "box" ]
[ p
[ class "has-text-centered" ]
[ viewOriginalSentence model.sentence
, viewSentence model.sentence
]
, viewHiddenWords (hiddenWords model.sentence)
]
]
]
Let's uncomment viewSentence and change its signature from:
viewSentence : Words -> Words -> Html Msg
viewSentence sentence chosenWords =
to
viewSentence : Words -> Html Msg
viewSentence sentence =
Now we'll obtain hiddenWords:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ =
hiddenWords sentence
in
And update the rest of viewSentence to:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
span [ class "sentence-word" ] [ text (answerToString hiddenWord.answer) ]
-- viewHiddenWord sentenceWord hiddenWords_
)
sentence
)
I have commented out the call to viewHiddenWord so we can see the intermediate result:
Now let's uncomment viewHiddenWord and return the line inside viewSentence:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
viewHiddenWord sentenceWord hiddenWords_
)
sentence
)
We need to change sentenceWord to hiddenWord:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.map
(\sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
viewHiddenWord hiddenWord hiddenWords_
)
sentence
)
Good. Now to viewHiddenWord. Let's update its type signature from:
viewHiddenWord : Word -> List Word -> Html Msg
viewHiddenWord hiddenWord chosenWords =
to
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords =
The only type that viewHiddenWord can accept now is HiddenWord. We also renamed all the chosenXYZ to hiddenXYZ.
Again, we don't need the case expression anymore!
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords =
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 hiddenWords
]
Let's also rename the 2 instances of hiddenWords to hiddenWords_, since the compiler complains about name collisions (2nd line and the line before the last in the code snippet).
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
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 hiddenWords_
]
The wordElement also gets simpler since it should only get HiddenWord, so the strange-looking case is gone:
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
let
viewOption : String -> Html Msg
viewOption wordString =
option
[ value wordString, selected (wordString == hiddenWordText) ]
[ text <| String.toLower wordString ]
wordElement : HiddenWord -> Html Msg
wordElement hiddenWord_ =
viewOption (answerToString hiddenWord_.answer)
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement hiddenWords_
]
The viewOption change is a pretty simple one. We need to select the <option> where the answer from the current HiddenWord equals playerChoice from the HiddenWord that was passed in:
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
let
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
viewOption : String -> Html Msg
viewOption answerString =
option
[ value answerString, selected (answerString == playerChoiceString) ]
[ text <| String.toLower answerString ]
wordElement : HiddenWord -> Html Msg
wordElement hiddenWord_ =
viewOption
(answerToString hiddenWord_.answer)
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map wordElement hiddenWords_
]
The compiler now complains about name collision between this viewHiddenWord and inner viewHiddenWord inside viewHiddenWords... Let's add _ to the inner viewHiddenWord:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
hiddenWordsSorted : List HiddenWord
hiddenWordsSorted =
List.sortBy .sortKey hiddenWordList
viewHiddenWord_ : HiddenWord -> Html msg
viewHiddenWord_ hiddenWord =
-- omitted
in
ul [] <| List.map viewHiddenWord_ hiddenWordsSorted
Looking at the code we can see that wordElement does almost nothing and just calls viewOption, so we can just remove it and call viewOption directly:
viewHiddenWord : HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord hiddenWord hiddenWords_ =
let
playerChoiceString : String
playerChoiceString =
playerChoiceToString hiddenWord.playerChoice
viewOption : HiddenWord -> Html Msg
viewOption hiddenWord_ =
let
answerString =
answerToString hiddenWord_.answer
in
option
[ value answerString, selected (answerString == playerChoiceString) ]
[ text <| String.toLower answerString ]
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged hiddenIndex) ] <|
option []
[ text "" ]
:: List.map viewOption hiddenWords_
]
We now pass HiddenWord to viewOption and calculate answerString inside it. We also can use destructuring in the viewOption declaration to get hiddenWord_.answer immediately:
viewOption : HiddenWord -> Html Msg
viewOption { answer } =
let
answerString : String
answerString =
answerToString answer
in
option
[ value answerString, selected (answerString == playerChoiceString) ]
[ text <| String.toLower answerString ]
We still have hiddenIndex in our viewHiddenWord that we haven't handled. This should be the index of the word in the sentence, so we can know what word to update with the player's choice. As we discussed in the beginning of the refactor, we can have this index by using List.indexedMap instead of List.map in viewSentence, and call viewHiddenWord with that index.
Let's update viewHiddenWord to use List.indexedMap, get the index in the inner function and pass it to viewHiddenWord:
viewSentence : Words -> Html Msg
viewSentence sentence =
let
hiddenWords_ : List HiddenWord
hiddenWords_ =
hiddenWords sentence
in
div [ class "has-text-centered" ]
(List.indexedMap
(\index sentenceWord ->
case sentenceWord of
SentenceWrd word ->
span [ class "sentence-word" ] [ text word ]
HiddenWrd hiddenWord ->
viewHiddenWord index hiddenWord hiddenWords_
)
sentence
)
Now let's update viewHiddenWord to take the index as its first argument and use it:
viewHiddenWord : Int -> HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord index hiddenWord hiddenWords_ =
-- omitted
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged index) ] <|
option []
[ text "" ]
:: List.map viewOption hiddenWords_
]
The game compiles and works!
But the <option>s in the drop down again come in the same order as they appear in the sentence. We still need to sort them by sortKey as we did in viewHiddenWords.
We are repeating ourselves with hiddenWordsSorted, so lets refactor it to its own function:
hiddenWordsSorted : List HiddenWord -> List HiddenWord
hiddenWordsSorted hiddenWordList =
List.sortBy .sortKey hiddenWordList
and use it inside viewHiddenWords:
viewHiddenWords : List HiddenWord -> Html msg
viewHiddenWords hiddenWordList =
let
hiddenWordsSorted_ : List HiddenWord
hiddenWordsSorted_ =
hiddenWordsSorted hiddenWordList
--- omitted
in
ul [] <| List.map viewHiddenWord_ hiddenWordsSorted_
and in viewHiddenWord:
viewHiddenWord : Int -> HiddenWord -> List HiddenWord -> Html Msg
viewHiddenWord index hiddenWord hiddenWords_ =
let
hiddenWordsSorted_ : List HiddenWord
hiddenWordsSorted_ =
hiddenWordsSorted hiddenWords_
-- omitted
in
div [ class "select" ]
[ select [ class "hidden-word", onInput (WordChanged index) ] <|
option []
[ text "" ]
:: List.map viewOption hiddenWordsSorted_
]
And now we have the hidden words in the correct random order also in the drop down:
Phew! That was a lot! And we gained a lot! Much less places to get our code into invalid state.
The current progress is saved in the repo under a Tag v2.0 https://github.com/mickeyvip/words-memory-game/tree/v2.0.
In the next post we will add randomness to select different words for each new game. Stay tuned!
Acknowledgements
Special thanks to Joël Quenneville for reviewing the code and suggesting the refactoring presented in this post.







Top comments (0)