Jogo da Memória em JavaScript Puro (Vanilla)

Esse tutorial introduz alguns conceitos básicos de HTML5, CSS3 e Javascript puro (sem jQuery). Vamos discutir sobre o atributo data-*, posicionamento, perspectiva, transições, flexbox, eventos, timeouts e ternários. Não é necessário que você tenha muita experiência com programação para desenvolver o jogo.

Memory Game

Estrutura de Arquivos

Vamos começar criando os arquivos no terminal:

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

Template

HTML

Template inicial adicionando os arquivos css e js:

<!-- index.html -->

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

  <title>Jogo da Memória</title>

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

O jogo possui doze cartas e cada uma delas consiste em uma div pai chamada .memory-card, que possui dois elementos filhos img. O primeiro, chamado front-face, representa a face da carta e o segundo, back-face, o seu verso.

Carta face e verso

<div class="memory-card">
  <img class="front-face" src="img/react.svg" alt="Face da Carta">
  <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
</div>

As imagens podem ser baixadas aqui: Repositório

As cartas serão ainda envolvidas por um elemento section chamado `memory-game’:

<!-- index.html -->
<section class="memory-game">
  <div class="memory-card">
    <img class="front-face" src="img/react.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/react.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/angular.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/angular.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/ember.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/ember.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/vue.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/vue.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/backbone.svg" Face da Carta="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/backbone.svg" Face da Carta="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/aurelia.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/aurelia.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>
</section>

CSS

Vamos aplicar um reset simples a todos os elementos:

/* styles.css */

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

A propriedade box-sizing: border-box inclui os valores de padding e border no tamanho total do elemento. Assim, ficamos livres da necessidade de subtraí-las ao aplicar largura e altura.

Ao aplicar display: flex no body e margin: auto no .memory-game container, o último será centralizado vertical e horizontalmente.

.memory-game também será um flex-container. Por default, os items encolhem a sua largura para se acomodar na largura do container em apenas uma linha. Setando flex-wrap como wrap, flex-items ao invés de encolher, retomam a sua largura inicial e ocupam quantas linhas forem necessárias.

/* styles.css */

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

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

A largura e a altura de cada carta é calculada através da função calc(). Para construir três linhas e quatro colunas, aplicamos 25% para a propriedade width e 33.333% para height, menos os 10px da margin.

Para posicionar as divs filhas da .memory-card absolutamente, adicionamos a propriedade position: relative, que posiciona o elemento na tela, servindo de referência.

A propriedade position: absolute adicionada em ambas front-face e back-face, remove os elementos da sua posição original e os posiciona um sobre o outro.

/* 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;
}

O template com as doze cartas deve ficar assim:

Template Jogo da Memória

Vamos utilizar a pseudo classe :active, adicionada quando o elemento é clicado, para um efeito de clique. Aplicamos uma transição de .2s no seu tamanho:

.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;
+ }

Efeito de clique

Virar Carta

Para que a carta vire ao ser clicada, a classe flip será adicionada ao elemento. Selecionamos todos os elementos memory-card com document.querySelectorAll, iteramos através da lista com forEach e adicionamos o detector de evento com addEventListener. Toda vez que uma carta for clicada a função flipCard será chamada. A variável this representa a carta que foi clicada. A função acessa a lista de classes do elemento (classList), se a classe flip não estiver na lista, ela é adicionada e se estiver, é retirada:

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

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

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

No CSS a classe flip rotaciona a carta 180°:

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

Para produzir o efeito 3D, vamos adicionar a propriedade perspective ao container .memory-game. Essa propriedade é responsável pelo valor da distância entre o usuário e o elemento ao longo do plano z. Quanto menor o valor, maior é o efeito de perspectiva. Para um efeito sutil vamos aplicar 1000px.

/* styles.css */

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

Para posicionar o elemento .memory-card no espaço 3D criado no container, aplicamos a propriedade transform-style: preserve-3d, caso contrário a carta continuará achatada no plano z = 0 (transform-style).

/* 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);
  transform: scale(1);
+ transform-style: preserve-3d;
}

Para gerar o efeito de movimento, aplicamos uma transição para a propriedade transform:

/* 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);
  transform: scale(1);
  transform-style: preserve-3d;
+ transition: transform .5s;
}

O efeito 3D funciona 🎉! Mas por que a face da carta não está aparecendo? Nesse momento, .front-face e .back-face estão sobrepostas, deviso à propriedade position: absolute. Todo elemento possui um verso, que é a imagem espelhada da sua face. A propriedade backface-visibility tem como valor default visible, assim, quando viramos a carta, o que vemos é o verso do logo do Javascript.

Verso da carta visível

Para que possamos enxergar a face da carta, aplicammos backface-visibility: hidden em ambas .front-face e .back-face:

/* styles.css */

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

Se atualizarmos a página e viramos a carta, ela some! 😯

Verso da carta invisível

Como nós escondemos o verso de ambas as imagens, não há nada do outro lado. Então precisamos virar a .front-face 180°:

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

E agora sim, conseguimos o efeito!

Carta virando

Lógica do Jogo

Quando clicamos na primeira carta, precisamos esperar a segunda carta virar. As variáveis hasFlippedCard e flippedCard se encarregarão de gerenciar o estado do jogo. Ao primeiro clique, se não houver carta virada, hasFlippedCard é setada como true e flippedCard guarda a carta clicada. Vamos trocar o método toggle para add:

  // scritps.js

  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));

Quando a segunda carta for clicada, entraremos no bloco else. Checaremos então se as cartas são iguais. Mas para isso, precisamos identificar as cartas.

Para adicionar informação extra em elementos HTML, podemos utilizar o atributo data-*. A partir da seguinte sintaxe: data-*, onde * pode ser qualquer palavra, o atributo será inserido na propriedade dataset do elemento. Vamos então adicionar data-framework em cada carta:

<!-- index.html -->

<section class="memory-game">
+ &lt;div class="memory-card" data-framework="react">
    <img class="front-face" src="img/react.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="react">
    <img class="front-face" src="img/react.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="angular">
    <img class="front-face" src="img/angular.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="angular">
    <img class="front-face" src="img/angular.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="ember">
    <img class="front-face" src="img/ember.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="ember">
    <img class="front-face" src="img/ember.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="vue">
    <img class="front-face" src="img/vue.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="vue">
    <img class="front-face" src="img/vue.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="backbone">
    <img class="front-face" src="img/backbone.svg" Face da Carta="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="backbone">
    <img class="front-face" src="img/backbone.svg" Face da Carta="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="aurelia">
    <img class="front-face" src="img/aurelia.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>

+ &lt;div class="memory-card" data-framework="aurelia">
    <img class="front-face" src="img/aurelia.svg" alt="Face da Carta">
    <img class="back-face" src="img/js-badge.svg" alt="Verso da Carta">
  </div>
</section>

Agora podemos testar se as cartas formam pares acessando o seu dataset. Vamos extrair a lógica para o método checkForMatch() e setar hasFlippedCard para false. Se as cartas formarem um par, disableCards() é chamada e os detectores de eventos são removidos, para prevenir que as cartas sejam viradas novamente. Caso contrário, unflipCards() remove a classe .flip após 1500ms e a carta retorna a sua posição inicial.

Juntando tudo:

  // scritps.js

  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));

Uma forma mais elegante de escrever o método checkForMatch() pode ser alcançada usando um ternário. Composto por três blocos: o primeiro é a condição a ser avaliada. O segundo é a expressão a ser executada caso a condição seja verdadeira, e o terceiro, caso seja falsa.

  // scritps.js

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

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

Bloqueio do Tabuleiro

Já cobrimos o pareamento das cartas, agora precisamos travar as cartas para evitar que mais de um par de cartas seja virado ao mesmo tempo, senão as cartas não serão desviradas corretamente.

Falha ao desvirar carta

Vamos declarar uma variável lockBoard. Quando o jogador clicar na segunda carta, setamos lockBoard como true e a condição if (lockBoard) return; previne que qualquer outra carta seja virada até que as cartas desvirem.

  // scritps.js

  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.framework === secondCard.dataset.framework;
    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));

Duplo Clique

Ainda devemos tratar o caso que o jogador clica duas vezes sobre a mesma carta. A condição de pareamento seria avaliada como verdadeira, removendo o detector de evento da carta incorretamente.

Dois cliques sobre a mesma carta

Para prevenir esse comportamento, vamos avaliar se a segunda carta clicada é a mesma que a primeira e retornar em caso positivo:

if (this === firstCard) return;

As variáveis firstCard e secondCard precisam ser resetadas após cada rodada. Vamos criar um método resetBoard() e extrair hasFlippedCard = false; e lockBoard = false para lá. Vamos utilizar o destructuring assignment do ES6: [var1, var2] = ['value1', 'value2'], para enxugar o código:

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

O novo método será chamado por ambas as funções disableCards() e unflipCards():

  // scritps.js

  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.framework === secondCard.dataset.framework;
    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));

Embaralhar

O jogo está quase pronto, só falta embaralhar as cartas.

Quando display: flex é declarada no container, flex-items são ordenados a partir da seguinte hierarquia: ordem de grupo e de código fonte. Cada grupo é definido pela propriedade order, que possui como valor um número inteiro, positivo ou negativo. Seu valor default é 0, o que significa que todos os elementos pertencem ao mesmo grupo e serão ordenados pela ordem em que aparecem no código fonte. Se existir mais de um grupo, os elementos são primeiro ordenados ascendentemente por grupo.

Vamos iterar através das doze cartas do tabuleiro, gerar um número aleatório entre 0 e 11 e atribuí-lo à propriedade order:

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

Para invocar a função shuffle, vamos transformá-la em uma Immediately Invoked Function Expression (IIFE), assim ela será executada logo após a sua definição. A versão final do script é mostrada abaixo:

  // scritps.js

  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.framework === secondCard.dataset.framework;
    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));

And that’s all folks!

Referências