Complete the memory game in JavaScript in 30 minutes


Learn JS, CSS and HTML by building a memory game in 30 minutes!
This tutorial introduces some basic concepts about HTML5, CSS3 and JavaScript. We will discuss data attributes, location, perspective, transformation, flexbox, event handling, timeouts, and ternary expressions. You don't need a lot of programming knowledge to read this article. If you already know the uses of HTML, CSS and JS, that's more than enough!

Project structure

Let's create a project file in the terminal:

🌹 mkdir memory-game 
🌹 cd memory-game 
🌹 touch index.html styles.css scripts.js 
🌹 mkdir img

HTML

The initial page template that connects the css and js files.

<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">

  <title>Memory Game</title>

  <link rel="stylesheet" href="./styles.css">
</head>
<body>
  <script src="./scripts.js"></script>
</body>
</html>

The game has 12 cards. Each card consists of a container div called. memory-card, which contains two img elements. The first represents the front-face of the card, and the second represents the back-face of the card.

<div class="memory-card">
  <img class="front-face" src="img/react.svg" alt="React">
  <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
</div>

We can download the project's resource files in the Moory Game Repo.
This set of cards will be packaged in the section container element. The end result of the code is as follows:

<!-- index.html -->

<section class="memory-game">
  <div class="memory-card">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>
</section>

CSS

We will use a simple but very useful reset for all projects:

/* styles.css */

* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

Box-sizing: The border-box attribute fills the entire border with elements, so we can skip mathematical calculations.
By setting display: flex to body and margin: auto to. memory-game containers, it will center vertically and horizontally.
Memor-game will also be a flex-container. By default, the elements inside will be narrowed to fit the container. By setting flex-wrap to wrap, flex-items can adapt to the size of the elastic elements.

/* styles.css */

body {
  height: 100vh;
  display: flex;
  background: #060AB2;
}

.memory-game {
  width: 640px;
  height: 640px;
  margin: auto;
  display: flex;
  flex-wrap: wrap;
}

The width (meaning width) and height (meaning height) of each card are calculated using the calc() CSS function. We set the width to 25%, height to 33.333%, and subtract 10px from margin to make three lines and four cards.
For the. memory-card subelement, we add position: relative, so that we can absolutely locate the subelement relative to it.
Set the attributes front-face and back-face to position: absolute, so that elements can be removed from the original location and stacked together.


/* styles.css */

.memory-card {
  width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  box-shadow: 1px 1px 1px rgba(0,0,0,.3);
}

.front-face,
.back-face {
  width: 100%;
  height: 100%;
  padding: 20px;
  position: absolute;
  border-radius: 5px;
  background: #1C7CCC;
}

At this point, the page template should look like this:


Let's also add a click effect. active pseudo class will be triggered every time an element is clicked. It triggers a 0.2 second transition:

.memory-card {
  width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  transform-style: preserve-3d;
  box-shadow: 1px 1px 0 rgba(0, 0, 0, .3);
+ transform: scale(1);
}

+.memory-card:active {
+  transform: scale(0.97);
+  transition: transform .2s;
+}

Flip card

To flip the card when clicked, we need to add the category flip to the element. To do this, let's use document.querySelectorAll to select all memory-card elements. They are then traversed using a forEach loop with an event listener attached. Every time a card is clicked, flipCard triggers. This variable represents the clicked card. This function accesses the classList of elements and switches the flip class:

// scripts.js
const cards = document.querySelectorAll('.memory-card');

function flipCard() {
  this.classList.toggle('flip');
}

cards.forEach(card => card.addEventListener('click', flipCard));

In CSS, the flip class rotates the card 180 degrees:

.memory-card.flip {
  transform: rotateY(180deg);
}

In order to produce a 3D flip effect, we will add the perspective attribute to. memory-game. This property is used to set the distance between the object and the user on the z axis. The lower the value, the greater the perspective effect. For best results, let's set it to 1000px:

.memory-game {
  width: 640px;
  height: 640px;
  margin: auto;
  display: flex;
  flex-wrap: wrap;
+ perspective: 1000px;
}

For the. memory-card element, we add the transform-style: preserve-3d attribute, so that the card is placed in the 3D space created in the parent node, rather than flat on the z = 0 plane (transform-style).

.memory-card {
  width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  box-shadow: 1px 1px 1px rgba(0,0,0,.3);
  transform: scale(1);
+ transform-style: preserve-3d;
}

Now, we need to set the value of the transition attribute to transform to generate dynamic effects:

.memory-card {
  width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  box-shadow: 1px 1px 1px rgba(0,0,0,.3);
  transform: scale(1);
  transform-style: preserve-3d;
+ transition: transform .5s;
}

So, we made the cards flip in 3D, yeah! But why doesn't the other side of the card appear? Now,.front-face and. back-face are stacked together because they are absolutely positioned. Each element has a back face, which is a mirror of its front face. The property backface-visibility defaults to visible, so when we flip the card, we get the JS badge on the back.


To display the image on its back, let's apply back face-visibility: hidden to. front-face and. back-face.

.front-face,
.back-face {
  width: 100%;
  height: 100%;
  padding: 20px;
  position: absolute;
  border-radius: 5px;
  background: #1C7CCC;
+ backface-visibility: hidden;
}

If we refresh the page and flip a card, it disappears!


Because we hide both images on the back, there is nothing on the other side. Next we need to rotate. front-face 180 degrees:

.front-face {
  transform: rotateY(180deg);
}

Now, we have the desired flip effect!

Matching Card

Now that we have finished flipping cards, let's deal with the matching logic.
When we click on the first card, it needs to wait for another card to be turned. Variables hasFlippedCard and flippedCard manage the flip state. If there are no flipped cards, hasFlippedCard is set to true and flippedCard is set to clicked cards. Let's switch to the toggle method add (meaning add):

  const cards = document.querySelectorAll('.memory-card');

+ let hasFlippedCard = false;
+ let firstCard, secondCard;

  function flipCard() {
-   this.classList.toggle('flip');
+   this.classList.add('flip');

+   if (!hasFlippedCard) {
+     hasFlippedCard = true;
+     firstCard = this;
+   }
  }

cards.forEach(card => card.addEventListener('click', flipCard));

Now, when the user clicks on the second card, we will enter the else block. We'll check if they match. To do this, we need to be able to identify each card.
Whenever we want to add additional information to HTML elements, we can use data attributes. By using the following grammar: data -, which can be any word, the attribute is inserted into the element's dataset attribute. So let's add a data-framework for each card:

<section class="memory-game">
+ <div class="memory-card" data-framework="react">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="react">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="angular">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="angular">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="ember">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="ember">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="vue">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="vue">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="backbone">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="backbone">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="aurelia">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="aurelia">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>
</section>

So now we can check matches by accessing two card datasets. Let's extract the matching logic to its own method checkForMatch(), and set hasFlippedCard to false. If matched, disableCards() is called and event listeners on two cards are separated to prevent another flip. Otherwise, unflipCards() will restore both cards to timeouts exceeding 1500 milliseconds, thereby deleting the. flip class:
Put all the code together:

const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
  let firstCard, secondCard;

  function flipCard() {
    this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
+     return;
+   }
+
+   secondCard = this;
+   hasFlippedCard = false;
+
+   checkForMatch();
+ }
+
+ function checkForMatch() {
+   if (firstCard.dataset.framework === secondCard.dataset.framework) {
+     disableCards();
+     return;
+   }
+
+   unflipCards();
+ }
+
+ function disableCards() {
+   firstCard.removeEventListener('click', flipCard);
+   secondCard.removeEventListener('click', flipCard);
+ }
+
+ function unflipCards() {
+   setTimeout(() => {
+     firstCard.classList.remove('flip');
+     secondCard.classList.remove('flip');
+   }, 1500);
+ }

  cards.forEach(card => card.addEventListener('click', flipCard));

A more concise way to write matching conditions is to use ternary operators. It consists of three blocks. The first block is the condition of judgment. If the condition is satisfied, the second block is executed, otherwise the third block is executed:

- if (firstCard.dataset.name === secondCard.dataset.name) {
-   disableCards();
-   return;
- }
-
- unflipCards();

+ let isMatch = firstCard.dataset.name === secondCard.dataset.name;
+ isMatch ? disableCards() : unflipCards();

locking

Now that we have completed the matching logic, we need to lock the two cards in order to avoid rotating them at the same time, otherwise the flip will fail.
Let's declare a lockBoard variable first. When the player clicks on the second card, lockBoard will be set to true, conditional if (lockBoard) return; other cards will be prevented from flipping before they are hidden or matched:

  const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
+ let lockBoard = false;
  let firstCard, secondCard;

  function flipCard() {
+   if (lockBoard) return;
    this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
      return;
    }

    secondCard = this;
    hasFlippedCard = false;

    checkForMatch();
  }

  function checkForMatch() {
    let isMatch = firstCard.dataset.name === secondCard.dataset.name;
    isMatch ? disableCards() : unflipCards();
  }

  function disableCards() {
    firstCard.removeEventListener('click', flipCard);
    secondCard.removeEventListener('click', flipCard);
  }

  function unflipCards() {
+     lockBoard = true;

    setTimeout(() => {
      firstCard.classList.remove('flip');
      secondCard.classList.remove('flip');

+     lockBoard = false;
    }, 1500);
  }

  cards.forEach(card => card.addEventListener('click', flipCard));

Click on the same card

Players may click on the same card twice. If the matching condition is true, delete the event listener from the card.


To prevent this, you need to check whether the card currently clicked is equal to the first Card and return if it is positive.

if (this === firstCard) return;

Variables firstCard and secondCard need to be reset after each round, so let's extract it into a new method resetBoard(), and write hasFlippedCard = false; and lockBoard = false. ES6's deconstruction assignment function [var1, var2]= ['value1','value2'] allows us to write code very short:

function resetBoard() {
  [hasFlippedCard, lockBoard] = [false, false];
  [firstCard, secondCard] = [null, null];
}

Then we call the new methods disableCards() and unflipCards():

  const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
  let lockBoard = false;
  let firstCard, secondCard;

  function flipCard() {
    if (lockBoard) return;
+   if (this === firstCard) return;

    this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
      return;
    }

    secondCard = this;
-   hasFlippedCard = false;

    checkForMatch();
  }

  function checkForMatch() {
    let isMatch = firstCard.dataset.name === secondCard.dataset.name;
    isMatch ? disableCards() : unflipCards();
  }

  function disableCards() {
    firstCard.removeEventListener('click', flipCard);
    secondCard.removeEventListener('click', flipCard);

+   resetBoard();
  }

  function unflipCards() {
    lockBoard = true;

    setTimeout(() => {
      firstCard.classList.remove('flip');
      secondCard.classList.remove('flip');

-     lockBoard = false;
+     resetBoard();
    }, 1500);
  }

+ function resetBoard() {
+   [hasFlippedCard, lockBoard] = [false, false];
+   [firstCard, secondCard] = [null, null];
+ }

  cards.forEach(card => card.addEventListener('click', flipCard));

Shuffle the cards

Our game looks pretty good now, but if it doesn't shuffle, it's not fun, so let's deal with it now.
When display: flex is declared on the container, flex-items are sorted in the order of groups and sources. Each group is defined by the order attribute, which contains positive or negative integers. By default, each flex-item sets its order attribute to 0, which means that they belong to the same group and are sorted in source order. If there are multiple groups, they are arranged in ascending order first.
There are 12 cards in the game, so we will iterate over them, generate random numbers between 0 and 12 and assign them to the flex-item order attribute:

function shuffle() {
  cards.forEach(card => {
    let ramdomPos = Math.floor(Math.random() * 12);
    card.style.order = ramdomPos;
  });
}
view raw

To call the shuffle function, make it an immediate call function expression (IIFE), which means that it will execute immediately after the declaration. The script should be as follows:

const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
  let lockBoard = false;
  let firstCard, secondCard;

  function flipCard() {
    if (lockBoard) return;
    if (this === firstCard) return;

    this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
      return;
    }

    secondCard = this;
    lockBoard = true;

    checkForMatch();
  }

  function checkForMatch() {
    let isMatch = firstCard.dataset.name === secondCard.dataset.name;
    isMatch ? disableCards() : unflipCards();
  }

  function disableCards() {
    firstCard.removeEventListener('click', flipCard);
    secondCard.removeEventListener('click', flipCard);

    resetBoard();
  }

  function unflipCards() {
    setTimeout(() => {
      firstCard.classList.remove('flip');
      secondCard.classList.remove('flip');

      resetBoard();
    }, 1500);
  }

  function resetBoard() {
    [hasFlippedCard, lockBoard] = [false, false];
    [firstCard, secondCard] = [null, null];
  }

+ (function shuffle() {
+   cards.forEach(card => {
+     let ramdomPos = Math.floor(Math.random() * 12);
+     card.style.order = ramdomPos;
+   });
+ })();

  cards.forEach(card => card.addEventListener('click', flipCard));

After looking at it

Point praise, so that more people can see this content (collecting no praise, are playing hooligans - -)
Pay attention to the public number "New Front-end Community" and enjoy the experience of article launching!
Every week we focus on tackling a front-end technical difficulty.

Tags: Front-end React angular Vue Attribute

Posted on Sat, 17 Aug 2019 03:47:53 -0700 by jingato