В этой статье мы создадим простую игру с помощью HTML5, CSS3 и чистого JavaScript. Вам не понадобятся глубокие знания программирования. Если вы знаете, для чего нужны HTML, CSS и JS, то этого более чем достаточно. На работу игры вы можете посмотреть здесь.
Структура файлов
Начнём с создания нужных папок и файлов:
$ mkdir memory-game
$ cd memory-game
$ touch index.html styles.css scripts.js
$ mkdir img
HTML
Начальный шаблон, соединяющий CSS- и JS-файлы:
<!-- 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>
В игре будет 12 карточек. Каждая карта состоит из контейнера div
с классом .memory-card
, внутри которого находится два элемента img
. Первая отвечает за лицо (front-face
) карточки, а вторая — за рубашку (back-face
).
<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>
Необходимые изображения можно скачать из репозитория проекта.
Обернём набор карточек в контейнер section
. В итоге получаем:
<!-- 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
Мы используем простой, но очень полезный сброс стилей, который будет применён ко всем элементам:
/* styles.css */
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}i
Свойство box-sizing: border-box
учитывает значения внутренних отступов и границ элемента при подсчёте общей высоты и ширины, поэтому нам не нужно заниматься математикой.
Если применить к body
свойство display: flex
и margin: auto
к контейнеру .memory-game
, то он будет выровнен вертикально и горизонтально.
.memory-game
также будет flex-контейнером. По умолчанию ширина элементов уменьшается, чтобы они помещались в контейнер. Если присвоить свойству flex-wrap
значение wrap
, элементы будут располагаться на нескольких строках в соответствии с их размерами:
/* styles.css */
body {
height: 100vh;
display: flex;
background: #060AB2;
}
.memory-game {
width: 640px;
height: 640px;
margin: auto;
display: flex;
flex-wrap: wrap;
}
Ширина и высота каждой карточки подсчитывается с помощью CSS-функции calc()
. Создадим три ряда по четыре карточки, установив значения ширины и высоты равными 25%
и 33.333%
соответственно минус 10px
от внешнего отступа.
Чтобы разместить наследников .memory-card
, добавим position: relative
. Так мы сможем абсолютно расположить наследников относительно родительского элемента.
Смотрите также: Вредные советы по CSS
Свойство position: absolute
, установленное для .front-face
и .back-face
, уберёт элементы с их исходных позиций и разместит поверх друг друга:
/* 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;
}
Поле из карточек должно выглядеть примерно так:
Добавим ещё эффект при клике. Псевдокласс :active
будет срабатывать при каждом нажатии на элемент. Он устанавливает длительность анимации равной 0.2 с:
.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
. Для этого давайте выберем все элементы memory-card
с помощью document.querySelectorAll()
. Затем пройдёмся по ним в forEach
-цикле и добавим обработчики событий. При каждом нажатии на карточку будет вызываться функция flipCard()
. this
отвечает за нажатую карточку. Функция получает доступ к списку классов элемента и активирует класс flip
:
// scripts.js
const cards = document.querySelectorAll('.memory-card');
function flipCard() {
this.classList.toggle('flip');
}
cards.forEach(card => card.addEventListener('click', flipCard));
CSS класс flip
переворачивает карточку на 180 градусов:
.memory-card.flip {
transform: rotateY(180deg);
}
Смотрите также: Детальный список инструментов для JavaScript
Чтобы создать 3D-эффект переворота, добавим свойство perspective
в .memory-game
. Это свойство отвечает за расстояние между объектом и пользователем в z-плоскости. Чем ниже значение, тем сильнее эффект. Установим значение 1000px
для едва уловимого эффекта:
.memory-game {
width: 640px;
height: 640px;
margin: auto;
display: flex;
flex-wrap: wrap;
perspective: 1000px;
}
Добавим к элементам .memory-card
свойство transform-style: preserve-3d
, чтобы поместить их в 3D-пространство, созданное в родителе, вместо того, чтобы ограничивать их плоскостью z = 0
(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;
}
Теперь мы можем применить transition
к свойству transform
, чтобы создать эффект движения:
.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;
}
Отлично, теперь карточки переворачиваются в 3D! Но почему мы не видим лицо карточки? На данный момент .front-face
и .back-face
наложены друг на друга из-за абсолютного позиционирования. Рубашкой каждого элемента является зеркальное отражение его лица. По умолчанию значение свойства backface-visibility
равно visible
, поэтому вот что мы видим при перевороте карточки:
Чтобы исправить это, применим свойство backface-visibility: hidden
для .front-face
и .back-face
:
.front-face,
.back-face {
width: 100%;
height: 100%;
padding: 20px;
position: absolute;
border-radius: 5px;
background: #1C7CCC;
backface-visibility: hidden;
}
Если перезагрузить страницу и снова перевернуть карточку, она пропадёт!
Так как мы скрыли заднюю сторону обеих картинок, на обратной стороне ничего нет. Поэтому сейчас нам нужно перевернуть .front-face
на 180 градусов:
.front-face {
transform: rotateY(180deg);
}
Наконец, мы получили желаемый эффект переворота!
Ищем пару
Мы научились переворачивать карточки, теперь нужно разобраться с проверкой на совпадение.
После нажатия на первую карточку она ожидает переворота другой. Переменные hasFlippedCard
и flippedCard
будут отвечать за состояние переворота. Если ни одна карточка не перевёрнута, значение hasFlippedCard
устанавливается равным true
, а нажатой карточке присваивается flippedCard
. Ещё давайте сменим метод toggle()
на add()
:
const cards = document.querySelectorAll('.memory-card');
let hasFlippedCard = false;
let firstCard, secondCard;
function flipCard() {
this.classList.add('flip');
if (!hasFlippedCard) {
hasFlippedCard = true;
firstCard = this;
}
}
cards.forEach(card => card.addEventListener('click', flipCard));
Теперь при нажатии на вторую карточку мы попадаем в else-блок нашего условия. Чтобы проверить, совпадают ли карточки, нужно их всех идентифицировать.
Всякий раз, когда нам нужно добавить дополнительную информацию к HTML-элементам, мы можем использовать data-* атрибуты, где вместо «*» может быть любое слово. Добавим каждой карточке атрибут data-framework
:
<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>
Теперь мы можем проверить, совпадают ли карточки, с помощью свойства dataset
. Поместим логику сравнения в метод checkForMatch()
и снова присвоим переменной hasFlippedCard
значение false
. В случае совпадения будет вызван метод disableCards()
и обработчики событий будут откреплены от обеих карточек, чтобы предотвратить их переворот. В противном случае метод unflipCards()
перевернёт обе карточки с помощью 1500 мс тайм-аута, который удалит класс .flip
:
function checkForMatch() {
if (firstCard.dataset.framework === secondCard.dataset.framework) {
disableCards();
return;
}
unflipCards();
}
Складываем всё воедино:
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));
Более элегантный способ написать условие совпадения — тернарный оператор. Он состоит из трёх частей. Первая часть — это условие, вторая часть выполняется, если условие возвращает true
, в противном случае выполняется третья часть:
let isMatch = firstCard.dataset.name === secondCard.dataset.name;
isMatch ? disableCards() : unflipCards()
Блокируем поле
Мы научились проверять, совпадают ли карточки, а теперь нужно заблокировать поле. Это нужно для того, чтобы два набора карточек не могли быть перевёрнуты одновременно, в противном карточки не будут переворачиваться обратно.
Объявим переменную lockBoard
. Когда игрок нажмёт на вторую карточку, lockBoard
будет присвоено значение true
, а условие if (lockBoard) return;
предотвратит переворот других карточек до того, как эти две будут спрятаны или совпадут:
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));
Нажатие на ту же карточку
У нас всё ещё есть сценарий, при котором после нажатия на одну карточку дважды условие совпадения будет выполнено и обработчик событий будет удалён.
Чтобы избежать этого, добавим проверку на то, равняется ли нажатая карточка переменной firstCard
, и вёрнемся из функции, если это так:
if (this === firstCard) return;
Переменные firstCard
и secondCard
нужно обнулять после каждого раунда. Реализуем эту логику в новом методе resetBoard()
. Поместим в него hasFlippedCard = false
и lockBoard = false
. Деструктурирующее присваивание [var1, var2] = ['value1', 'value2']
из ES6 позволяет писать код меньших размеров:
function resetBoard() {
[hasFlippedCard, lockBoard] = [false, false];
[firstCard, secondCard] = [null, null];
}
Новый метод будет вызываться из disableCards()
и 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;
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');
resetBoard();
}, 1500);
}
function resetBoard() {
[hasFlippedCard, lockBoard] = [false, false];
[firstCard, secondCard] = [null, null];
}
cards.forEach(card => card.addEventListener('click', flipCard));
Перемешивание
Наша игра выглядит довольно неплохо, но играть в неё не очень весело, если карточки всегда на одном месте. Пора это исправить.
Когда у контейнера есть свойство display: flex
, его элементы упорядочиваются сначала по номеру группы, а потом по порядку в исходном коде. Каждая группа определяется свойством order
, которое содержит положительное или отрицательное целое число. По умолчанию свойство order
каждого flex-элемента имеет значение 0
. Если групп больше одной, элементы сначала упорядочиваются по возрастанию порядка группы.
В игре есть 12 карточек, поэтому мы пройдёмся по ним в цикле, сгенерируем случайное число и присвоим его свойству order
. Например пусть будут сгенерированы числа в диапазоне от 0 до 12:
function shuffle() {
cards.forEach(card => {
let ramdomPos = Math.floor(Math.random() * 12);
card.style.order = ramdomPos;
});
}
Чтобы вызвать функцию shuffle()
, сделаем её IIFE (Immediately Invoked Function Expression). Это значит, что она будет выполнена сразу после объявления. Скрипт должен иметь примерно такой вид:
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));
Вот и всё!
Перевод статьи «Memory Game in Vanilla JavaScript»