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.
Vamos começar criando os arquivos no terminal:
🌹 mkdir memory-game
🌹 cd memory-game
🌹 touch index.html styles.css scripts.js
🌹 mkdir img
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.
<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>
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:
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;
+ }
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.
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! 😯
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!
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">
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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();
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.
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));
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.
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));
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!