Создаём простую игру на 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;
}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 карточек, поэтому мы пройдёмся по ним в цикле, сгенерируем случайное число от 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));

Вот и всё!

Перевод статьи «Memory Game in Vanilla JavaScript»

Подобрали два теста для вас:
— А здесь можно применить блокчейн?
Серверы для котиков: выберите лучшее решение для проекта и проверьте себя.