Создаём простую игру на Vanilla JS
В этой статье мы создадим простую игру с помощью 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;
}Свойство 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 карточек, поэтому мы пройдёмся по ним в цикле, сгенерируем случайное число от 0 до 12 и присвоим его свойству order:
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));Вот и всё!
