2018-10-15
Let's Build a Simon Game in ReasonReact Pt. 4 Animations
In this article you will learn how to create the animation for when the Simon Game plays back the sequence the user needs to remember and repeat. When the sequence is being played by the game the box should make a sound and also lighten in color in order to visually show the user which box is active.
There is a lot to do in this article but first, there needs to be a little refactoring. Currently, the sequence which gets generated onMount
is a series of random numbers between 1 and 4 inside of an array. It made sense for it to be this way back in part 1 but this needs to be updated to make it fit with what we are doing now. Instead of the sequence being an array of random numbers, it needs to be updated to be a list of colors.
type sequence = list(Types.colors);
Types.colors
is from a separate file calledTypes.re
Hopefully, your editor will highlight some changes that you need to make to other parts of the code. If so, you will notice your use of SetSequence
is not correctly used in the onMount
lifecycle.
let list =
Belt.List.makeBy(
20,
_i => {
let num = Js.Math.floor(Js.Math.random() *. 4.0 +. 1.0);
switch (num) {
| 1 => Green
| 2 => Red
| 3 => Blue
| 4 => Yellow
| _ => Green
};
},
);
Instead of an Array
you are making a List
. And instead of an array of numbers, you have a list of colors. That’s why you are using pattern matching to update each number to their corresponding color. It’s not likely you will get a number outside of 1 thru 4 but the Reason compiler will yell at you for not being exhaustive enough in your pattern matching. So it’s important to handle all cases, i.e. _ => Green
, even if they are unlikely to happen.
There is still something wrong here though. The compiler does not understand what Green
is.
Error: Unbound constructor Green
My understanding is that the closure, Belt.List.makeBy
, is not aware of constructors that exist outside of its scope. You can change this by opening and making available the colors type inside the callback function.
open Types;
let num = Js.Math.floor(Js.Math.random() *. 4.0 +. 1.0);
The last thing to do here is update initial state. Currently, the initial state for sequence is an empty array. Change this to an empty list.
initialState: () => {sequence: []},
Before getting into more of the complex stuff, I think it’s always better to get the small stuff out of the way first. The state
needs to be updated to account for each level
the user will play. And when the app mounts the user starts at level
1. Update the state type to include a level
field with a type of int
.
type state = {
sequence,
level: int,
};
Because you are working in Reason this will set off a series of errors in your editor. Don’t get upset! This is a good thing. The compiler is just making sure you know what you are doing. Next, initialState
needs to be updated with the initial level: 1.
initialState: () => {sequence: [], level: 1},
Uh, oh! There is another error inside the reducer:
Error: Some record fields are undefined: level
In the SetSequence
action you are setting the sequence but nothing else. In React land, you could get away with this. However, state
in ReasonReact is an immutable record. That means whenever you change state, you need to create a new copy of it, including all fields. To copy all the fields in ReasonReact, you use the spread operator, just like you would in JavaScript.
| SetSequence(list) => ReasonReact.Update({...state, sequence: list})
While I did that I changed array
to list
to be consistent.
Next, what needs to be added to state is an active
field with an option
type of colors
. This is because whenever the user clicks a box, or the game plays back a sequence, there needs to be some visual indicator for the box that is making a sound. I am calling this active
. The box will be inactive most of the time, but when a box makes a sound it will be active
and shortly after it will go back to being inactive. That is why the color will be wrapped in an option
because in most cases it will be a None
. This is probably a good time to learn about option
types. If so, check this out 👉 https://reasonml.github.io/docs/en/null-undefined-option#docsNav.
type state = {
sequence,
level: int,
active: option(Types.colors),
};
This again kicked off some changes in your app. Now, initialState
needs to be updated. Head over to initialState
and update it to have an active
of None
.
initialState: () => {sequence: [], level: 1, active: None},
Having the compiler yell at you may seem annoying, but trust me, you will learn to appreciate this helper.
Lastly, what is needed is to create an action called PlaySequence
, which as you might of guessed, plays the sequence. It takes no arguments.
type action =
| SetSequence(sequence)
| PlaySequence;
In your reducer
you should now see this nice warning.
Warning 8: this pattern-matching is not exhaustive.
Here is an example of a value that is not matched:
PlaySequence
Don’t worry about having any logic for that action. Just pass in ReasonReact.NoUpdate
and keep moving.
reducer: (action, state) =>
switch (action) {
| SetSequence(list) => ReasonReact.Update({...state, sequence: list})
| PlaySequence => ReasonReact.NoUpdate
},
Now that all those things are in place let’s shift over to JSX.
In order for the user to know what level they are on, let’s render the level that is in state into the JSX.
What’s nice about ReasonReact is you can do a lot of the same things in it as you would in React. For example, you can destructure. Here you will destructure the state record and pluck off the level field.
render: self => {
let {level} = self.state;
/* More stuff here */
},
With the level
field in hand, let’s render it to the user.
<div className=Styles.controls>
<div> {{j|Level: $level|j} |> ReasonReact.string} </div>
</div>
You notice I am using {j||j}
. This is interpolation syntax. Then I can just add the $
in front of level
and it transforms the int
into a string
.
I updated some styles along the way. I added a new set of styles called controls
.
let controls = style([marginTop(`px(10))]);
And I changed the flex direction of the container so things flow top to bottom.
let container =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
minHeight(`vh(100.0)),
flexDirection(`column), /* This is new */
]);
The game in all its glory.
You have now come this far. Good. There is still a bunch more to go 😄.
The kickoff of the game begins when the player hits the start
button. Hitting the button sends the action PlaySequence
to the reducer, where it will handle the rest. Add some markup in the render
function that does just this.
<div className=Styles.controls>
<div> {{j|Level: $level|j} |> ReasonReact.string} </div>
<div>
<button onClick={_e => self.send(PlaySequence)}>
{"Start" |> ReasonReact.string}
</button>
</div>
</div>
Because the event object is not being used I put an underscore in front of it. Also, I wrapped the button in a div
. There will be another button, reset, at some point that sits to the right of Start
.
Now it’s time to get into how the game will playback the sequence which will take place in the reducer.
When the reducer receives the action PlaySequence
it needs to play back the sequence up to the point of the level. Then the user will repeat the sequence and move up a level. Right now, though, you will only focus on the play back of the sequence, not any user input.
Because you only playback the sequence up to the current level, you need to slice the sequence up to that level, then iterate on each color and play its corresponding jingle. To slice the sequence, use the Belt.List.take
function for this.
| PlaySequence =>
let l =
Belt.List.take(state.sequence, state.level)
->Belt.Option.getWithDefault([]);
ReasonReact.NoUpdate;
The Belt.List.take
function takes a list and how much to take to. This returns an option
type. I am using a helper, fast pipe, to pipe in the result of the take
function as the first argument to getWithDefault
. If I get a None
back, I will return an empty list.
ReasonReact has the concept of handling actions which only produce side-effects, with no update to state. You will use that here. The effect you are creating is iterating through each item in the list and outputting the color to the console.
let l =
Belt.List.take(state.sequence, state.level)
->Belt.Option.getWithDefault([]);
ReasonReact.SideEffects(
(
_self =>
Belt.List.forEachWithIndex(
l,
(index, color) => {
let _id =
Js.Global.setTimeout(
() => Js.log({j|$color|j}),
index * 1000,
);
();
},
)
),
);
Click the start button and see what happens. You will see some random number output to the console. The number does represent a color type but BuckleScript does this in a more optimized way under the hood. That’s why you see a number and not a string.
You didn’t come here to render some number to the screen! You want to hear a sound play, am I right? Let’s create an action which handles that. Call it PlaySound
.
type action =
| SetSequence(sequence)
| PlaySequence
| PlaySound(Types.colors);
Now update the reducer.
| PlaySound(color) => ReasonReact.NoUpdate
And apply this action inside of the ReasonReact.SideEffects
handler.
(index, color) => {
let _id =
Js.Global.setTimeout(
() => self.send(PlaySound(color)),
index * 1000,
);
();
},
Still not a darn thing is going to happen. Let’s fix that in PlaySound
.
This time you will use ReasonReact.UpdateWithSideEffects
. This is because you want to set the active
color on state, make a sound, then remove the active
color.
| PlaySound(color) =>
ReasonReact.UpdateWithSideEffects(
{...state, active: Some(color)},
(
_self => {
Js.log2("color: ", color);
}
),
)
There is a problem, however. How do we dynamically retrieve the sound instance for each color? Unfortunately, you can’t do Sounds[color]##play()
as you can in JavaScript. Oh, the joys of dynamic languages. Instead, let’s create a helper which will allow you to dynamically get the sound you need.
/* Sounds.re */
open Types;
/* Sound instances here */
let map = [(Green, green), (Red, red), (Blue, blue), (Yellow, yellow)];
Here you have an associative list. It acts as a map, or dictionary, and allows you to dynamically retrieve the sound based on the color type using Belt.List.getAssoc
. Let’s try it out.
| PlaySound(color) =>
ReasonReact.UpdateWithSideEffects(
{...state, active: Some(color)},
(
_self => {
let sound =
Belt.List.getAssoc(Sounds.map, color, (==))
->Belt.Option.getWithDefault(Sounds.green);
sound##play();
}
),
)
The getAssoc
function returns an option so I make sure to pass it through getWithDefault
. Now play it!
Change the level
to 5 in initialState
so you can hear more than just 1 jingle.
Oh snap, it works! Well, at least the sound part of it does.
At this point, you are making a sound but with no visual indicator, you don’t know which box is making that sound. You do know the active
color though, which will help you to adjust the CSS styles of the box that is active
.
Update the box
styles to accept both a bgColor
and an active
color
as labeled arguments.
let box = (~bgColor: Types.colors, ~active: option(Types.colors)) =>
Also change
switch (color)
toswitch(bgColor)
Unlike in JavaScript, in Reason, you have the ability to label your arguments. This means you don’t have to know the order of the arguments in order to call the function accordingly. This just jacked up our box
style in the render
function. Let’s fix that.
<div
className={Styles.box(~bgColor=Green, ~active)}
onClick={_e => Sounds.green##play()}
/>
How am I using ~active
that way? Well, I destructured it above.
let { level, active } = self.state;
That allows me to write ~active
which is shorthand for ~active=active
.
As I stated before, there needs to be some visual indicator for the box making the sound. For this, I will adjust the opacity
of the box to be lighter than the others. When the active
color is the same as the bgColor
, the box
will have opacity
of 0.5
, otherwise, it will be 1.0
.
let opacity =
switch (bgColor, active) {
| (Green, Some(Green)) => opacity(0.5)
| (Red, Some(Red)) => opacity(0.5)
| (Blue, Some(Blue)) => opacity(0.5)
| (Yellow, Some(Yellow)) => opacity(0.5)
| (_, None) => opacity(1.0)
| (_, Some(_)) => opacity(1.0)
};
/* More stuff here */
style([bgColor, opacity, ...baseStyle]);
Play time again!
You notice it worked but it is missing something. The color just sticks, it doesn’t reset like it should. Create an action! Call it ResetColor
and after 300ms
, active
color is reset to None
.
type action =
| SetSequence(sequence)
| PlaySequence
| PlaySound(Types.colors)
| ResetColor;
Now update the reducer.
| ResetColor => ReasonReact.Update({...state, active: None})
Lastly, call the action inside of PlaySound
.
(
self => {
let sound =
Belt.List.getAssoc(Sounds.map, color, (==))
->Belt.Option.getWithDefault(Sounds.green);
sound##play();
let _id = Js.Global.setTimeout(() => self.send(ResetColor), 300);
();
}
),
It works! Hopefully 😅.
I realize this may not be the best way to handle these actions. I am not clearing the timeout which can cause a memory leak. I will investigate this further and see if in the next section you can do a refactor to improve this.
If you have stuck it out this far then you deserve a slow clap 👏. In this article, you learned how to create dynamic actions in ReasonReact. That was pretty fun if I do say so myself. In the next lesson, you will handle user input. That’s all I have for now.
This article has Webmentions