👋 Hola!! Las slides del Workshop las tenéis aquí
En este tutorial veremos conceptos básico de HTML
, CSS
y JS
. Tocaremos transiciones
, data-attributes
, flexbox
...no hace falta ser una experta para seguir este tutorial, si sabéis que estos lenguajes web existen, es todo lo que se necesita 🤘.
(ℹ️ Vamos a utilizar con vanilla
tanto en css
como en js
. No utilizaremos ni precompiladores como SASS
, ni frameworks como Tailwind
,ni TypeScript
.)
Estos son los archivos necesarios para nuestro proyecto:
- Creamos la carpeta de
memory-game
- Entramos en la carpeta creada
- Dentro generamos los archivos
index.html
styles.css
scripts.js
- Creamos la carpeta
assets
- 1️⃣ mkdir memory-game
- 2️⃣ cd memory-game
- 3️⃣ touch index.html styles.css scripts.js
- 4️⃣ mkdir assets
En el archivo index.html
tenemos que linkar los dos archivos que hemos creado, el styles.css
y el scripts.js
.
<!-- index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Super Mario Bros 3 - Juego de memoria</title>
+ <link rel="stylesheet" href="./styles.css">
</head>
<body>
+ <script src="./scripts.js"></script>
</body>
</html>
Creamos el layout principal con un tag section
en la estructura del html
.
<!-- index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Super Mario Bros 3 - Juego de memoria</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
+ <section class="memory-game"></section>
<script src="./scripts.js"></script>
</body>
</html>
La interfaz del juego se ha diseñado en Figma
. Cada item
es un componente con variants
👇
-
En la carpeta assets en formato
.png
y.svg
-
SVGOMG
para optimizar los.svg
-
ImageOptim
para optimizar las imágenes -
Los assets originales los podéis encontrar en mi perfil de Community de Figma
El juego consta de 18 cartas, cada una está creada por un div
contenedor que hemos llamado .memory-card
, el cuál tiene dos imágenes SVG
.
La primera imágen será la cara frontal front-card
y la segunda será común a todas, con el logo original de Nintendo cómo back-card
.
<!-- styles.css -->
<div class="memory-card">
<img
class="front-card"
src="assets/svg/flower-snow.svg"
alt="Flor de Nieve"
/>
<img
class="back-card"
src="assets/svg/nintendo.svg"
alt="Logo de Nintendo"
/>
</div>
Utilizaremos un reset muy básico pero efectivo 👇
<!-- styles.css -->
/* reset */
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
ℹ️ Un poco de teoría:
-
El modelo de caja
box-sizing
: La propiedadborder-box
incluye los padding y valores de borders dentro del tamaño total del elemento, width y height. 🧮 Así simplificamos los cálculos. -
Indicando
display: flex;
a la clase.memory-game
que actua cómo contenedor, creamos una alineación vertical y horizontal. -
La clase
.memory-game
también será un contenedor con comportamientoflex-container
. Por defecto, los elementos vienen seteados conshrink
en lo ancho para ajustarse al contenedor. Seteandoflex-wrap
con el valorwrap
, losflex-items
se posicionará a lo largo de multiples lineas dependiendo de su propio tamaño.
<!-- styles.css -->
.memory-game {
width: 100vw;
height: 100vh;
display: flex;
flex-flow: row wrap;
place-content: space-around;
justify-content: center;
}
La altura y el ancho de cada carta está calculado con la función calc()
de CSS
, hemos creado 3 filas
, con 6 cartas en cada fila con un 16.6%
y una altura de 33.3%
y restamos 10px para añadir el margen entre cartas.
Para poder posicionar los hijos del contenedor .memory-card
, hemos añadido la propiedad position: relative
, para poder posicionarlos de manera absoluta position: absolute
y sean relativos al contenedor madre. 👇
❓ ¿Se entiende este comportamiento?
La propiedad position: absolute
está indicada a ambas caras de las cartas .front-card
y .back-card
esto hará que los elementos salgan de su flujo normal y se posicionen una cara sobre otra.
<!-- styles.css -->
.memory-card {
width: calc(16.666% - 10px);
height: calc(33.333% - 10px);
margin: 5px;
position: relative;
}
.front-card,
.back-card {
+ --border: #333333;
width: 100%;
height: 100%;
padding: 20px;
position: absolute;
border-radius: 10px;
+ border: 3px solid var(--border);
}
Vamos a añadir una micro interacción que simulará un efecto click
. La pseudo clase :active
actuará de trigger cada vez que el elemento sea clicado y aplicará una animación al tamaño de la carta con .2s transition
.
A la clase .memory-card
le añadimos un transform: scale(1);
.
<!-- styles.css -->
.memory-card {
width: calc(16.666% - 10px);
height: calc(33.333% - 10px);
margin: 5px;
position: relative;
+ transform: scale(1);
}
.memory-card:active {
transform: scale(0.97);
+ transition: transform .2s;
}
Hemos añadido la manita de Mario como cursor para simular que es él quién está seleccionando las cartas.
Podemos crear un cursor custom con la siguiente linea de código👇
<!-- styles.css -->
body {
+ cursor: url('./assets/cursor.cur'), auto;
}
Para crear el efecto de voltear la carta cada vez que sea clicada, vamos a añadir la clase .flip
.
-
Con un
document.querySelectorAll
seleccionamos todos los elementos del contenedormemory-card
y se genera un loop con cadaforEach
y le atacha unevent listener
. -
Cada vez que una carta sea clicada la
función flipCard
será ejecutada. La variablethis
representa que la carta ha sido clicada.
La función flipCard()
accede a los elementos de classList
y hace un toggle
a la flip class
:
<!-- scripts.js -->
const cards = document.querySelectorAll('.memory-card');
+function flipCard() {
+ this.classList.toggle('flip');
+}
cards.forEach(card => card.addEventListener('click', flipCard));
La clase .flip
rota la carta 180deg
:
.memory-card.flip {
+ transform: rotateY(180deg);
}
Para producir el efecto 3D
, añadimos una propiedad de perspectiva a la clase .memory-game
. Esta propiedad define cuanto de lejos en el plano de a
el objeto se alejará de nuestra vista.
Para un efecto sutil aplicamos a la perspectiva un valor de 1000px
añadiendo a la clase .memory-game
la propiedad perspective: 1000px;
:
<!-- styles.css -->
.memory-game {
width: 100vw;
height: 100vh;
display: flex;
flex-flow: row wrap;
place-content: space-around;
justify-content: center;
+ perspective: 1000px;
}
A los elementos .memory-card
le añadimos un transform-style: preserve-3d
, para posicionarlos en el espacio en 3D que hemos creado en su madre, en lugar de aplanarlos en el plano z = 0
(transform-style
).
Añadiendo la propiedad transform-style: preserve-3d;
<!-- styles.css -->
.memory-card {
width: calc(16.666% - 10px);
height: calc(33.333% - 10px);
margin: 5px;
position: relative;
transform: scale(1);
+ transform-style: preserve-3d;
}
Aplicamos una transición con la propiedad transition: transform .5s;
para producir el efecto de movimiento:
<!-- styles.css -->
.memory-card {
width: calc(16.666% - 10px);
height: calc(33.333% - 10px);
margin: 5px;
position: relative;
transform: scale(1);
transform-style: preserve-3d;
+ transition: transform .5s;
}
¡¡Ya tenemos la carta preparada para un volteo en 3D!!
Tengo una pregunta... ¿os aparece la cara de la carta?
En este momento, las dos caras .back-card
y .front-card
están posicionadas encima con un posicionamiento absoluto.
Cada elemento tiene una cara trasera, que es una imagen espejo de su cara delantera.
La propiedad backface-visibility
por defecto es visible, así que cuando la carta gira, lo que obtenemos es la cara con item de .back-card
Para revelar la imagen oculta que hay debajo, aplicamos un backface-visibility: hidden;
a las clases .front-card
y .back-card
.
<!-- styles.css -->
.front-face,
.back-face {
width: 100%;
height: 100%;
padding: 20px;
position: absolute;
border-radius: 10px;
border: 3px solid var(--border);
+ backface-visibility: hidden;
}
Como hemos ocultado ambas imágenes, la cara y la cruz, no hay nada en el otro lado. Así que ahora tenemos que girar la .front-card
con 180
grados gracias a la propiedad transform: rotateY(180deg);
:
<!-- styles.css -->
.front-face {
+ transform: rotateY(180deg);
}
Ya tenemos controlado el giro! 🥳
El comportamiento/funcionalidad que queremos es:
- Cuando hacemos clic en la primera carta, debe esperar hasta que se gire la siguiente.
- Las variables
hasFlippedCard
yflippedCard
administrarán elestado
deflip
. - En caso de que no haya una carta girada,
hasflippedCard
está configurada enTrue
yflippedCard
está configurada a la tarjeta que ha tenido el clic.
También cambiamos el método toggle
para agregar:
<!-- scripts.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));
Entonces, cuando el usuario hace clic en la segunda carta, entramos en el bloque else
de nuestra condition
. Así que necesitamos comprobar si hay un match
para poder hacer esto, tenemos que identificar cada una de las cartas.
Cuando necesitemos añadir información extra a los elementos de nuestro HTML
, podemos utilizar los atributos de datos data attributes
.
Lo haremos utilizando la siguiente sintaxis:
Data- *, donde, * puede ser cualquier palabra, ese atributo se insertará en la propiedad del conjunto de datos del elemento.
Ahora, vamos a añadir un data-item
a cada carta:
<!-- index.html -->
+ <div class="memory-card" data-item="star">
Ya podemos verificar una coincidencia accediendo a ambos conjuntos de datos de tarjetas.
Podemos extraer la lógica coincidente a su propio método checkForMatch()
y también establecer de nuevo a false
el hasFlippedCard
.
En el caso de una coincidencia, se invoca a disableCards()
y los listeners
del evento en ambas tarjetas se separan, para evitar más giros.
De lo contrario, unflipCards()
devolverá ambas tarjetas con un tiempo de espera de 1500ms
(timeout
) que elimina la clase .flip
:
<!-- scripts.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.item === secondCard.dataset.item) {
+ 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));
Una forma más elegante de escribir la condición de match
es usar un operador ternario.
Está compuesto por tres bloques: 1️⃣ El primer bloque es la condición a evaluar. 2️⃣ El segundo bloque se ejecuta si la condición devuelve verdadera. 3️⃣ De lo contrario el bloque ejecutado es el tercero:
<!-- scripts.js -->
- if (firstCard.dataset.item === secondCard.dataset.item) {
- disableCards();
- return;
- }
-
- unflipCards();
+ let isMatch = firstCard.dataset.item === secondCard.dataset.item;
+ isMatch ? disableCards() : unflipCards();
Ahora ya tenemos la logica de emparejamiento, vamos a bloquear el tablero para evitar que dos juegos de cartas se giren al mismo tiempo, de lo contrario nuestro giro fallaría.
<!-- scripts.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.item === secondCard.dataset.item;
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));
Ya sabemos que cómo usuarias, somos expertas en liarla. Y sigue habiendo la posibilidad de clicar dos veces en la misma carta.
La condición de correspondencia se evaluaría como true
, eliminando el listener
del evento de esa carta.
Para evitar este comporatemiento, vamos a verificar si la carta actualizada actual es igual a la firstCard
y que regrese si es positiva.
<!-- scripts.js -->
if (this === firstCard) return;
Las variables de firstCard
y secondCard
variables necesitan ser reseteadas después de cada ronda de juego, así que vamos a extraer esta lógica a un nuevo método resetBoard()
.
Seteamos las hasFlippedCard = false;
y lockBoard = false
.
La es6 destructuring assignment [var1, var2] = ['value1', 'value2']
, nos permite a mantener nuestro código super simple:
ℹ️ (Con es6 nos referimos al estándar que ya va por la versión ES6 y determina cómo emplear el lenguaje Javascript)
ℹ️ (Destructuring assignment es una sintaxis especial de ECMAScript 6 que nos permite desempaquetar arrays
o objects
en un montón de variables).
<!-- scripts.js -->
function resetBoard() {
[hasFlippedCard, lockBoard] = [false, false];
[firstCard, secondCard] = [null, null];
}
El nuevo método será llamado por ambas disableCards()
y unflipCards()
:
<!-- scripts.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.item === secondCard.dataset.item;
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));
Nuestro juego se ve bastante bien, pero no hay diversión si las cartas no se barajan, así que vamos a revisar esta parte.
Cuándo display: flex
está declarado en el contenedor, los flex-item
están organizados por la siguiente jerarquía:
- Grupo
- Orden de origen
Cada grupo está definido por la propiedad orden, que posee un número positivo entero o negativo.
De manera predeterminada, cada flex-item
tiene su propiedad de orden establecida en 0, lo que significa que todas las cartas de la barajan pertenecen al mismo grupo y se presentarán por orden de origen.
Si hay más de un grupo, los elementos se organizan en primer lugar mediante el orden grupal ascendente.
Hay 16 cartas en este juego, vamos a iterar entre ellas, generando un número random entre 0 y 16 y asignandolo a la propiedad de orden del flex-item
:
<!-- scripts.js -->
function shuffle() {
cards.forEach(card => {
let ramdomPos = Math.floor(Math.random() * 16);
card.style.order = ramdomPos;
});
}
Para invocar la función shuffle
, hagamos que sea una expresión de la función invocada inmediatamente (Immediately Invoked Function Expression - IIFE)
, lo que significa que se ejecutará justo después de su declaración.
Los scripts deberían verse así:
<!-- scripts.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.item === secondCard.dataset.item;
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() * 16);
+ card.style.order = ramdomPos;
+ });
+ })();
cards.forEach(card => card.addEventListener('click', flipCard));
- Proyecto en codepen