It's time for the annual Easter-themed web tutorial. And I've got a good one here for you today. It's an Easter-themed memory game, the kind you might have played as a kid. For this, we'll be using a crowd-pleaser... ReactJS!
This, of course, will require us to start with some boilerplate code.
<!DOCTYPE html>
<html>
<head>
<title>Easter Memory Game</title>
<style>
</style>
<script src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6.26.0/babel.js"></script>
</head>
<body>
<script type="text/babel">
</script>
</body>
</html>
Add this div, and style it using the CSS class
preload. In it, we have image tags for all the six images we'll be using.
<!DOCTYPE html>
<html>
<head>
<title>Easter Memory Game</title>
<style>
</style>
<script src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6.26.0/babel.js"></script>
</head>
<body>
<div class="preload">
<img src="easter0.jpg" />
<img src="easter1.jpg" />
<img src="easter2.jpg" />
<img src="easter3.jpg" />
<img src="easter4.jpg" />
<img src="easter5.jpg" />
</div>
<script type="text/babel">
</script>
</body>
</html>
In the stylesheet, give all divs a
red outline. Set the default font to Verdana and font size to 12 pixels. And hide the
preload CSS class.
<style>
div {outline: 1px solid #FF0000;}
body
{
font-size: 12px;
font-family: verdana;
}
.preload
{
display:none;
}
</style>
The images in the div will not be visible. That's the entire point. We put them there so that the images will load normally when they're being used. It's a cheap low-tech preloading technique, but it works! These are the images in question.
|
easter0.jpg |
|
easter1.jpg |
|
easter2.jpg |
|
easter3.jpg |
|
easter4.jpg |
|
easter5.jpg |
Now let's add one more div in our app. It has an id of
appContainer.
<div class="preload">
<img src="easter0.jpg" />
<img src="easter1.jpg" />
<img src="easter2.jpg" />
<img src="easter3.jpg" />
<img src="easter4.jpg" />
<img src="easter5.jpg" />
</div>
<div id="appContainer">
</div>
<script type="text/babel">
</script>
Here's the styling for it. It will be 500 by 700 pixels, and centered.
.preload
{
display:none;
}
#appContainer
{
height: 700px;
width: 500px;
margin: 5px auto 0 auto;
}
This is how we begin - with a tall box.
For the script, our component will be
EasterMemoryGame. In it, I've added a constructor with a blank
state, a
componentDidMount() method for stuff we want to run upon loading, and the
render() method. Pretty standard stuff.
<script type="text/babel">
class EasterMemoryGame extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
};
}
componentDidMount()
{
}
render()
{
}
}
ReactDOM.render(<EasterMemoryGame />, document.getElementById("appContainer"));
</script>
For the state, we need the property
seconds, which will default to a value of 100. The second value,
cards, is an array.
constructor(props)
{
super(props);
this.state =
{
"seconds": 100,
"cards": []
};
}
In the
render() method, we will return this component within
appContainer. There is a div with an id of
easterMemoryGameContainer. In turn, it contains an id with a div of
timeContainer, a div with an id of
buttonContainer containing a button, and a div with an id of
deckContainer.
render()
{
return (
<div id="easterMemoryGameContainer">
<div id="timeContainer">
</div>
<div id="buttonContainer">
<button></button>
</div>
<div id="deckContainer">
</div>
</div>
);
}
This won't look like much right now. We need to style it.
I won't spend a whole lot of time explaining the layout because it's really simple.
easterMemoryGameContainer will fit the whole of
appContainer. Wthin it, the three divs are meant to occupy a certain space in that one-column layout, so all have widths set to 100%.
<style>
#easterMemoryGameContainer
{
height: 100%;
width: 100%;
}
#timeContainer
{
width: 100%;
}
#buttonContainer
{
width: 100%;
}
#deckContainer
{
width: 100%;
}
</style>
These are the heights of each of the divs.
#easterMemoryGameContainer
{
height: 100%;
width: 100%;
}
#timeContainer
{
height: 150px;
width: 100%;
}
#buttonContainer
{
height: 50px;
width: 100%;
}
#deckContainer
{
height: 500px;
width: 100%;
}
Also,
timeContainer and
buttonContainer have text color set to
black and aligned center.
#easterMemoryGameContainer
{
height: 100%;
width: 100%;
}
#timeContainer
{
height: 150px;
width: 100%;
text-align: center;
color: #000000;
}
#buttonContainer
{
height: 50px;
width: 100%;
text-align: center;
color: #000000;
}
#deckContainer
{
height: 500px;
width: 100%;
}
Now we're talking. Let's style the button next.
This is not really all that essential. It's mostly up to personal preferences. I've generally made the button
orange with
white text, with some changes upon a mouseover.
#buttonContainer
{
height: 50px;
width: 100%;
text-align: center;
color: #000000;
}
#buttonContainer button
{
margin: 0.25em;
width: 5em;
height: 2.5em;
text-align: center;
border-radius: 5px;
border: 0px solid #000000;
background-color: rgba(255, 200, 0, 1);
color: rgba(255, 255, 255, 1);
font-weight: bold;
}
#buttonContainer button:hover
{
background-color: rgba(255, 200, 0, 0.5);
color: rgba(0, 0, 0, 1);
}
#deckContainer
{
height: 500px;
width: 100%;
}
Oh, there's no text in the button? That shouldn't surprise you at all. We never added any, after all. This is going to be the domain of the
render() method.
In the
render() method, the returned method will include the
seconds property of this application's
state, within another div.
render()
{
return (
<div id="easterMemoryGameContainer">
<div id="timeContainer">
<div>
<h1>{this.state.seconds}</h1>
seconds
</div>
</div>
<div id="buttonContainer">
<button></button>
</div>
<div id="deckContainer">
</div>
</div>
);
}
And since that value is set to 100, this is what you should see.
Declare two variables -
message and
buttonText.
render()
{
var message;
var buttonText;
return (
<div id="easterMemoryGameContainer">
<div id="timeContainer">
<div>
<h1>{this.state.seconds}</h1>
seconds
</div>
</div>
<div id="buttonContainer">
<button></button>
</div>
<div id="deckContainer">
</div>
</div>
);
}
Next, create an
If block for the condition that
seconds is 0. Add an
Else block.
render()
{
var message;
var buttonText;
if (this.state.seconds == 0)
{
}
else
{
}
return (
<div id="easterMemoryGameContainer">
<div id="timeContainer">
<div>
<h1>{this.state.seconds}</h1>
seconds
</div>
</div>
<div id="buttonContainer">
<button></button>
</div>
<div id="deckContainer">
</div>
</div>
);
}
Set
message and
buttonText accordingly. Here's what we're trying to accomplish - the user is given 100 seconds to finish the game. As long as
seconds is not zero, message should show "TIME ELAPSED: " and the button acts as a Reset button. Once
seconds reaches 0, message should tell the user that the game is over, and the button acts as a Replay button. In reality, the button's functionality doesn't change, but it makes for a more friendly UI.
render()
{
var message;
var buttonText;
if (this.state.seconds == 0)
{
message = "GAME OVER!";
buttonText = "REPLAY";
}
else
{
message = "TIME ELAPSED: ";
buttonText = "RESET";
}
return (
<div id="easterMemoryGameContainer">
<div id="timeContainer">
<div>
<h1>{this.state.seconds}</h1>
seconds
</div>
</div>
<div id="buttonContainer">
<button></button>
</div>
<div id="deckContainer">
</div>
</div>
);
}
Add the
message and
buttonText template strings.
return (
<div id="easterMemoryGameContainer">
<div id="timeContainer">
<div>
<br />{message}
<h1>{this.state.seconds}</h1>
seconds
</div>
</div>
<div id="buttonContainer">
<button>{buttonText}</button>
</div>
<div id="deckContainer">
</div>
</div>
);
You should see that your button now has text!
Timer Functions
It's time to move on to actually making the timer move. In the
componentDidMount() method, add a call to the
reset() method. This means that
reset() will be called as soon as the component loads.
componentDidMount()
{
this.reset();
}
Create the
reset() method and add calls to the
stopTimer() method and the
startTimer() method. Create those methods.
stopTimer()
{
}
startTimer()
{
}
reset()
{
this.stopTimer();
this.startTimer();
}
componentDidMount()
{
this.reset();
}
In the
stopTimer() method, run the
clearInterval() function with the app's built-in
interval property as an argument. Then set
interval to
undefined.
stopTimer()
{
clearInterval(this.interval);
this.interval = undefined;
}
First, check if
interval is undefined. It should be (either reset in
stopTimer() or as a default value, but no harm being careful.
startTimer()
{
if (this.interval == undefined)
{
}
}
Then use
interval to run the
setInterval() method. Here, we're using the ECMAScript's Fat Arrow Notation convention. The interval is one second.
startTimer()
{
if (this.interval == undefined)
{
this.interval = setInterval
(
() =>
{
},
1000
);
}
}
Decrement the
seconds property of the application
state. If this means that
seconds is now 0, run the
stopTimer() method.
this.interval = setInterval
(
() =>
{
this.setState({"seconds": this.state.seconds - 1});
if (this.state.seconds == 0)
{
this.stopTimer();
}
},
1000
);
Now when you refresh, you can see the number running down.
And when it reaches 0, you can see that the message and button text have changed.
The Cards
Now we get to the mest of the application - the cards. Here's some styling for
cardContainer. Our aim is to have a 6 by 6 grid of cards fitting into the
deckContainer div. So if
deckContainer is 500 pixels width (because it takes up 100% of
appContainer's 500 pixel width), divide that by 6 and you get 83 with some left over. So 72 pixels height and width, with a 10 pixel margin to the top and left, sounds reasonable. Float everything left.
#deckContainer
{
height: 500px;
width: 100%;
}
.cardContainer
{
height: 72px;
width: 72px;
margin-left: 10px;
margin-top: 10px;
float: left;
}
card is another CSS class which will be nested within
cardContainer. It will take up all of its parent's height and width, with rounded corners. Because it is clickable, the
cursor property has been set to
pointer. The
overflow property is set to
hidden because we're going to have stuff nested that may exceed the boundaries of this CSS class.
.cardContainer
{
height: 72px;
width: 72px;
margin-left: 10px;
margin-top: 10px;
float: left;
}
.card
{
height: 100%;
width: 100%;
border-radius: 5px;
overflow: hidden;
cursor: pointer;
}
Our next piece of work will be done at the
reset() method. First, declare an empty array,
cards. Then set the
seconds property of the
state to 100, and the
cards property to the array you just declared. This method is meant to reset, and that means these are the values at the beginning of every game.
reset()
{
this.stopTimer();
var cards = [];
this.setState({"seconds": 100, "cards": cards});
this.startTimer();
}
Our intention, remember, is to have a 6 by 6 grid of cards. For that, we'll have 6 templates. Declare the variable
totalTemplates and set that to 6. Then declare
totalCards and set the value to that of
totalTemplates squared.
reset()
{
this.stopTimer();
var cards = [];
var totalTemplates = 6;
var totalCards = totalTemplates * totalTemplates;
this.setState({"seconds": 100, "cards": cards});
this.startTimer();
}
Now implement a
For loop to iterate through
totalCards.
reset()
{
this.stopTimer();
var cards = [];
var totalTemplates = 6;
var totalCards = totalTemplates * totalTemplates;
for(var i = 0; i < totalCards; i++)
{
}
this.setState({"seconds": 100, "cards": cards});
this.startTimer();
}
Within that loop, declare
card as an object that has the properties
id (not strictly necessary, but good to have) and
isOpened, which is a boolean value telling us if the card is opened (
true) or closed (
false). By default, it's
false. Then add
card into the
cards array with the
push() method.
reset()
{
this.stopTimer();
var cards = [];
var totalTemplates = 6;
var totalCards = totalTemplates * totalTemplates;
for(var i = 0; i < totalCards; i++)
{
var card =
{
id: i,
isOpened: false,
}
cards.push(card);
}
this.setState({"seconds": 100, "cards": cards});
this.startTimer();
}
Now that's done, let's move on to the
render() method. Before declaring
message and
buttonText, declare
cardDisplay. It will be a HTML component generated using the
map() method applied to the
cards array in
state which we've just updated with a 36-element grid!
render()
{
var cardDisplay = this.state.cards.map(
(item, key) =>
{
}
);
var message;
var buttonText;
Declare
style. It's "card " with a space at the end, and either "opened " (again with a space) or "closed" depending on the value of the
isOpened property.
var cardDisplay = this.state.cards.map(
(item, key) =>
{
var style = "card " + (item.isOpened ? "opened " : "closed");
}
);
Then return a div styled using
cardContainer. We add a key so as to prevent any complaints from the transpiler. Nested within is another div, this one styled using
style.
var cardDisplay = this.state.cards.map(
(item, key) =>
{
var style = "card " + (item.isOpened ? "opened " : "closed");
return (
<div className="cardContainer" key={key}>
<div className={style}>
</div>
</div>
);
}
);
Then in the
return statement of the
render() method, add
cardDisplay to the template returned.
return (
<div id="easterMemoryGameContainer">
<div id="timeContainer">
<div>
<br />{message}
<h1>{this.state.seconds}</h1>
seconds
</div>
</div>
<div id="buttonContainer">
<button>{buttonText}</button>
</div>
<div id="deckContainer">
{cardDisplay}
</div>
</div>
);
Here you can see the grid you just made! The squares are blank, and that's because we have not yet created the CSS classes for
opened or
closed.
Let's start by specifying the CSS class
closed. We'll use the before pseudoselector for this because we want to make use of the
content property. Text is set to
white and background is
orange. Get creative!
The
display property is
block, of cours, and we'll make it take up full height and width. Since the parent,
card, has
overflow set to
hidden, its round corners will be aptly shown.
Finally, for content, we have a line of text followed by a break and the HTML symbol of a cross. The
white-space property is set to
pre-wrap because we want the browser to preserve white spaces for this CSS class and break on line breaks.
.card
{
height: 100%;
width: 100%;
border-radius: 5px;
overflow: hidden;
cursor: pointer;
}
.closed:before
{
display: block;
content: "EASTER MEMORY GAME\A\2671";
white-space: pre-wrap;
text-align: center;
height: 100%;
width: 100%;
background-color: #FFCC00;
color: #FFFFFF;
}
For
opened, it's pretty much the same, only there's no content.
.closed:before
{
display: block;
content: "EASTER MEMORY GAME\A\2671";
white-space: pre-wrap;
text-align: center;
height: 100%;
width: 100%;
background-color: #FFCC00;
color: #FFFFFF;
}
.opened:before
{
display: block;
content: "";
height: 100%;
width: 100%;
}
Also, at this point, you can get rid of the
red lines. They're no longer needed.
div {outline: 0px solid #FF0000;}
Now refresh your browser... and see what you've just made! It's a little tacky, sure, what what the hell, right?
If you want to see what
opened looks like, just do this, and refresh. You should see the entire grid disappear because no content has been rendered yet for
opened, plus we got rid of the
red lines.
for(var i = 0; i < totalCards; i++)
{
var card =
{
id: i,
isOpened: true,
}
cards.push(card);
}
Next
No sweat, we're just getting started. Next up, we will focus on game logic and handle opening and closing of cards.