Построение игры на JavaScript для запоминания

В этом уроке мы создадим игру PokeMatch с помощью обычного HTML, CSS и JavaScript. Pokemon API является бесплатным и интересным для работы, так что давайте начнем.

Это сокращенная версия учебника. Полную версию учебника смотрите на YouTube. Полный исходный код вы можете найти здесь.

Настройка

Откройте пустую папку в вашем любимом текстовом редакторе (для меня это VSCode). Затем создайте три файла.

  • index.html
  • app.js
  • app.css

В HTML-файле создайте базовый шаблон с несколькими элементами.

  • ссылки на app.css и app.js
  • header для заголовка и кнопки сброса, которая вызывает функцию resetGame
  • пустой div с id game.
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Pokemon Memory Match Game</title>
    <link rel="stylesheet" href="app.css" />
</head>
<body>
    <div class="container">

        <header>
            <h1>PokeMatch</h1>
            <button onclick="resetGame()">Reset</button>
        </header>
        <div id="game">    
        </div>
    </div>
    <script src="app.js"></script>
</body>
</html>

Вход в полноэкранный режим Выйти из полноэкранного режима

**TIP** Вы можете использовать расширение ‘live server‘ в VS Code, чтобы получить работающий в реальном времени сервер перезагрузки. По умолчанию он запускается на порту 5501.

Это вся разметка, которая нам понадобится на данный момент. Мы будем динамически генерировать доску в JavaScript.

Получение покемонов

В файле app.js нам нужно будет работать с Pokemon API, чтобы получить информацию о 8 различных покемонах для каждой итерации игры. Мы будем использовать эту конечную точку Pokemon API:

https://pokeapi.co/api/v2/pokemon/{id}
Войти в полноэкранный режим Выход из полноэкранного режима

Нам понадобятся 3 ключевых свойства каждого покемона.

  • ID
  • спрайты (изображения)
  • тип

Сначала мы создадим новую функцию loadPokemon, которая сделает fetch запрос к API для 8 случайных покемонов. Эта функция будет использовать async/await, поэтому мы можем пойти дальше и пометить ее как async.

Мы начнем с запроса к URL, основанному на PokeAPI, а затем добавим строку покемона, которого мы хотим получить. Например, если мы хотим получить информацию о Bulbasaur, мы добавим 1 в конец URL.

const pokeAPIBaseUrl = "https://pokeapi.co/api/v2/pokemon/";

const loadPokemon = async () => {
  const res = await fetch(pokeAPIBaseUrl + '1');
  const pokemon = await res.json();
}
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь нам нужно сгенерировать массив случайных идентификаторов покемонов, а затем просмотреть этот массив, чтобы сделать запрос на выборку каждого из них. Мы можем использовать set, чтобы позаботиться о любых дубликатах ID, и Match.random() для генерации случайных чисел.

Набор — это объектная структура данных, которая не допускает дубликатов и имеет постоянное время поиска.

const randomIds = new Set();
while(randomIds.size < 8){
    const randomNumber = Math.ceil(Math.random() * 150);
    randomIds.add(randomNumber);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем мы возьмем этот набор случайных идентификаторов и сделаем запрос к Pokemon API для каждого из них. Для повышения производительности мы будем использовать Promise.all(), что позволит различным запросам API выполняться параллельно. Вот финальная функция loadPokemon.

const loadPokemon = async () => {
  const randomIds = new Set();
  while(randomIds.size < 8){
      const randomNumber = Math.ceil(Math.random() * 150);
      randomIds.add(randomNumber);
  }
  const pokePromises = [...randomIds].map(id => fetch(pokeAPIBaseUrl + id))
  const results = await Promise.all(pokePromises);
  return await Promise.all(results.map(res => res.json()));
}
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте создадим функцию displayPokemon для отображения карт. Эта функция будет:

  • сортировать покемонов в случайном порядке, используя этот трюк — pokemon.sort( _ => Math.random() - 0.5);
  • итерация по каждому покемону с помощью Array.map()
  • преобразовать каждого покемона в строку HTML-шаблона
  • вызовите join для результирующего массива, чтобы сгенерировать одну HTML-строку, включающую все карточки покемонов
const displayPokemon = (pokemon) => {
    pokemon.sort( _ => Math.random() - 0.5);
    const pokemonHTML = pokemon.map(pokemon => {
    return '
            <div class ="card">
                <h2>${pokemon.name}
            </div>
        '
    }).join('');
}
Войдите в полноэкранный режим Выйти из полноэкранного режима

Теперь, когда HTML-строка сгенерирована, нам нужно установить ее в качестве свойства innerHTML игрового div.

const game = document.getElementById('game');

const displayPokemon = (pokemon) => {
    pokemon.sort( _ => Math.random() - 0.5);
    const pokemonHTML = pokemon.map(pokemon => {
    return '
            <div class ="card">
                <h2>${pokemon.name}
            </div>
        '
    }).join('');
  game.innerHTML = pokemonHTML;

}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь давайте создадим функцию resetGame, которая будет загружать покемонов и затем вызывать displayPokemon. Поскольку это игра на соответствие, нам понадобятся две карты для каждого покемона. Для этого мы можем создать новый массив с двумя копиями loadedPokemon с помощью оператора Spread.

const resetGame = async() => {
  game.innerHTML = '';
  const loadedPokemon = await loadPokemon();
  displayPokemon([...loadedPokemon, ...loadedPokemon]);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Базовый стайлинг

Давайте начнем стилизовать наше приложение с помощью CSS.

Это необязательно, но для развлечения я загрузил бесплатный шрифт Pokemon. После загрузки Pokemon.TTF и добавления его в корень вашей директории, вы можете использовать его в вашем CSS.

@font-face {
  font-family: pokemon;
  src: url(pokemon.ttf);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь о контейнере игры. Давайте используем Flexbox для центрирования содержимого на экране, а также несколько дополнительных стилей.

.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  height: 100vh;
  font-family: pokemon;
  letter-spacing: 5px;
  gap: 10px;
  max-width: 800px;
  margin: 0 auto;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь перейдем к заголовку. Здесь мы снова можем использовать Flexbox, чтобы отцентрировать заголовок и кнопку сброса по вертикали и разнести их друг от друга. Мы также можем сделать заголовок немного больше.

header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
}

h1 {
    font-size: 54px;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Стилизация карт покемонов

Сначала давайте оформим игру в виде сетки четыре на четыре с небольшим промежутком между ними.

#game {
  display: grid;
  grid-template-columns: repeat(4, 160px);
  grid-template-rows: repeat(4, 160px);
  grid-gap: 10px;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь для каждой отдельной карточки мы добавим box-shadow, border-radius и position.

Мы также установим overflow скрытым.

Есть одно дополнительное свойство transform-style, которое мы добавим. Это позволит нам придать анимации перелистывания вид 3d.

.card {
  box-shadow: 0 3px 10px rgba(200,200,200, 0.9);
  border-radius: 10px;
  position: relative;
  transform-style: preserve-3d;
  overflow: hidden;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Анимация и стилизация карточек

Давайте поработаем над анимацией перелистывания, чтобы показать лицевую и оборотную стороны каждой карточки.

Внутри разметки для каждой карточки мы добавим два содержащих div, один для передней, другой для задней части.

Для передней части карты я взял изображение покебола, которое нужно отобразить. Мы сделаем это в CSS. Вы можете взять его из исходного кода, если хотите его добавить.

На обратной стороне карточки мы выведем изображение покемона и его имя. Задняя сторона карточки также будет иметь класс rotated. Мы будем переключать этот класс для запуска анимации в JavaScript.

Наконец, мы добавим два свойства к карточке-контейнеру.

  • свойство onclick, которое вызывает функцию clickCard (мы создадим ее в ближайшее время)
  • пользовательское свойство данных data-pokename — мы будем использовать его для определения имени покемона, на котором был сделан щелчок.
<div class="card" onclick="clickCard(event)" data-pokename="${pokemon.name}">
  <div class="front">
  </div>
  <div class="back rotated">
    <img src="${pokemon.sprites.front_default}" alt="${pokemon.name}" />
    <h2>${pokemon.name}</h2>
  </div>
</div>
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте изменим стиль передней части карты в CSS, чтобы добавить фоновое изображение.

.front {
  background-image: url("/pokeball.png");
  background-position: center;
  background-repeat: no-repeat;
  background-color: black;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы также можем добавить некоторые общие стили, которые применяются как к передней, так и к задней части карточки.

  • Flexbox для центрирования содержимого
  • положение absolute и высота и ширина, установленные на 100%
  • переход для плавного перелистывания

Одно интересное свойство, которое мы установим, это свойство backface-visibility в hidden. Это свойство будет использоваться для того, чтобы одновременно отображалась только задняя или передняя часть экрана.

.card > .front, .card > .back {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  position: absolute;
  height: 100%;
  width: 100%;
  backface-visibility: hidden;
  transition: transform 0.5s;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Переворачивание карточки

Теперь создадим функцию clickCard. Нам нужно знать, какая карта была нажата. Мы можем сделать это, получив ссылку на e.currentTarget. Затем мы можем найти имя покемона из созданного нами пользовательского свойства данных.

const clickCard = (e) => {
  const pokemonCard = e.currentTarget;
  const pokemonName = pokemonCard.dataset.pokename;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы перевернуть карту, нам нужно получить элементы передней и задней части карты. Затем мы перевернем их, переключив класс rotated. Вот небольшая вспомогательная функция, которая использует селектор запроса для получения переднего и заднего элементов.

const getFrontAndBackFromCard = (card) => {
  const front = card.querySelector(".front");
  const back = card.querySelector(".back");
  return [front, back]
}
Вход в полноэкранный режим Выход из полноэкранного режима

А вот вспомогательная функция для переключения класса rotated на массиве элементов.

const rotateElements = (elements) => {
  if(typeof elements !== 'object' || !elements.length) return;
  elements.forEach(element => element.classList.toggle('rotated'));
}

Вход в полноэкранный режим Выход из полноэкранного режима

Отсюда мы можем переключать передний и задний план следующим образом.

const clickCard = (e) => {
  const pokemonCard = e.currentTarget;
  const [front, back] = getFrontAndBackFromCard(pokemonCard)
  const pokemonName = pokemonCard.dataset.pokename;
  rotateElements([front, back]);
}
Войти в полноэкранный режим Выход из полноэкранного режима

Добавление дополнительной игровой логики

Теперь, когда мы можем перевернуть лицевую и оборотную стороны одной карты, нам нужно добавить дополнительную игровую логику, чтобы учитывать две карты. Это очень важно для работы игры с памятью, потому что нам нужно отслеживать две карты одновременно.

После нажатия на карту мы будем отслеживать ее, сохраняя элемент карты в переменной firstPick. Затем мы можем проверить, существует ли существующая карта, на которую был сделан щелчок, чтобы определить, что делать дальше. Мы также можем игнорировать нажатие на карточку, если она уже была нажата, проверяя, имеет ли ее передний элемент класс rotated.

const firstPick = null;
...

const clickCard = (e) => {
  const pokemonCard = e.currentTarget;
  const [front, back] = getFrontAndBackFromCard(pokemonCard)

  if(front.classList.contains("rotated")) {
    return;
  }
  rotateElements([front, back]);

  if(!firstPick){
    //track the clicked card
    firstPick = pokemonCard;
  }else {
    //check for matches
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь рассмотрим сценарий, в котором пользователь нажимает на вторую карточку. Мы хотим проверить, совпадает ли эта карта с первой нажатой картой. Если да, то мы оставим их перевернутыми. Если нет, мы перевернем их обратно. Мы поместим часть переворачивания внутрь setTimeout, чтобы дать ему небольшую задержку.

else {
  const firstPokemonName = firstPick.dataset.pokename;
  const secondPokemonName = pokemonCard.dataset.pokename;
  if(firstPokemonName !== secondPokemonName) {
    const [firstFront, firstBack] = getFrontAndBackFromCard(firstPick);
    setTimeout(() => {
        rotateElements([front, back, firstFront, firstBack]);
        firstPick = null;
    }, 500)    
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

А что если карты все-таки совпадут? Ну, давайте отслеживать, сколько совпадений пользователь получил до сих пор, с помощью переменной matches. Затем, если это число достигнет 8, пользователь выиграл. Вот как выглядит полная версия функции.

let matches = 0;
...

const clickCard = (e) => {
  const pokemonCard = e.currentTarget;
  const [front, back] = getFrontAndBackFromCard(pokemonCard)
  if(front.classList.contains("rotated")) {
    return;
  }
  isPaused = true;
  rotateElements([front, back]);
  if(!firstPick){
    firstPick = pokemonCard;
  }
  else {
    const secondPokemonName = pokemonCard.dataset.pokename;
    const firstPokemonName = firstPick.dataset.pokename;
    if(firstPokemonName !== secondPokemonName) {
        const [firstFront, firstBack] = getFrontAndBackFromCard(firstPick);
        setTimeout(() => {
            rotateElements([front, back, firstFront, firstBack]);
            firstPick = null;
        }, 500)    
    }else {
        matches++;
        if(matches === 8) {
            console.log("WINNER");
        }
        firstPick = null;
    }
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Наконец, мы можем обновить функцию resetGame, чтобы соответствующим образом сбросить состояние игры. Затем мы вызовем функцию resetGame, чтобы запустить игру.

const resetGame = async() => {
  game.innerHTML = '';
  firstPick = null;
  matches = 0;
  const loadedPokemon = await loadPokemon();  
  displayPokemon([...loadedPokemon, ...loadedPokemon]);
}

resetGame()
Вход в полноэкранный режим Выход из полноэкранного режима

Оцените статью
devanswers.ru
Добавить комментарий