Предсказание от We Wizards:
сегодня вас ждут успех в делах и новые скиллы

Вместе с Даниилом Сарабьевым (разработчик We Wizards) сделаем сервис, позволяющий получить случайный набор закрытых карт таро с возможностью вскрыть выбранные карты

01

Разделим наш сервис на два класса:

  • общий контроллер (назовем его TarotController);
  • класс единичной карты (TarotCard).

Контроллер будет отвечать за показ и скрытие карт, в нем же будет крутиться рекурсивная функция, вызываемая с помощью requestAnimationFrame. В каждом цикле анимации она будет вызывать метод update у каждого инстанса класса TarotCard (об этом чуть позже).

Создадим базовую разметку, где пока просто подключим индексный файл скрипта с атрибутом type="module" для поддержки импортов.

Добавим обертку с классом container, внутрь которой положим элемент c классом cards-container для вставки самих карт и кнопку с классом reset для сброса состояния приложения и получения новых карт.

Заранее сделаем темплейт, который будет использоваться внутри класса TarotCard

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Animatied tarot</title>
</head>
<body>
  <div class="container">
    <div class="cards-container"></div>
    <button class="reset">RESET</button>
  </div>

  <template id="card">
    <div class="card"></div>
  </template>

  <script type="module" src="./js/index.js"></script>
</body>
</html>

Напишем «на ощупь» код класса TarotController, пока без отображения, так как логика довольно простая, не запутаемся

import Card from "./TarotCard.js";
import cardsData from '../data.js';

export default class TarotController {

  /**
   * Контейнер для вставки резметки карт
   * @type {HTMLElement}
   */
  container;

  /**
   * Массив с текущими картами
   * @type {TarotCard[]}
   */
  cards = [];

  constructor() {
    this.container = document.querySelector('.cards-container');
    this.setCards();
    this.loop();
  }

  /**
   * Установить случайный набор карт
   * в указанном количестве
   *
   * @param {number} amount - Количество карт
   */
  async setCards(amount = 5) {
    this.reset().then(() => {
      const shuffledCards = this.getShuffledCards();
      for (let i = 0; i < amount; i ++) {
        if (!shuffledCards[i]) {
          return;
        }
        this.cards.push(new TarotCard(shuffledCards[i], i, amount, this));
      }
    });
  }
	
	/**
   * Метод для обновления всех существующих карт
   */
  loop() {
    this.cards.forEach(card => card.update());
    requestAnimationFrame(this.loop.bind(this));
  }
	
  /**
   * Спрятать существующие карты и очистить контейнер 
   * с разметкой
   */
  reset() {
    return new Promise((resolve) => {
      if (!this.cards.length) {
        resolve(true);
        return;
      }

      this.cards.forEach(card => card.fadeOut());

      setTimeout(() => {
        this.cards = [];
        this.container.innerHTML = '';
        resolve(true);
      }, 800);
    });
  }

  /**
   * Получить перемешанный массив с картами
   * @return {Object} Перемешанный массив с картами
   */
  getShuffledCards() {
    return cardsData
      .map(val => ({ val, sort: Math.random() }))
      .sort((a, b) => a.sort > b.sort ? 1 : -1)
      .map(({ val }) => val);
  }
}

Стоит обратить внимание, что перед тем как задать набор карт в методе TarotController.setCards, мы вызываем другой метод этого же класса, который сбрасывает текущее состояние приложения. Метод TarotController.reset возвращает промис, который резолвится сразу, если массив с картами TarotController.сards пустой, в ином случае мы прячем текущие карты, ждем окончания анимации и чистим разметку и массив TarotController.сards

В методе TarotController.setCards после резолва промиса TarotController.reset первым делом перемешиваем исходный массив с картами, который импортировали на 2 строке файла (import cardsData from ’../data.js’;).

Затем с помощью обычного цикла for итерируемся указанное в параметре количество раз и создаем экземпляр класса TarotCard, куда передаем данные текущей карты (пока без разницы, что это будут за данные), текущий индекс карты, общее количество карт и сам инстанс нашего контроллера, чтобы у карты был к нему доступ.

02

Приступаем к самому интересному — пишем код класса TarotCard и реализации всех задуманных анимаций, а именно:

  • анимация появления карточки (выплывание из верхней части странички);
  • анимация скрытия карточки (скрытие карты за нижнюю границу страницы);
  • анимация по наведению на карту (перемещение по оси Z, ближе к пользователю);
  • анимация появления («разворота») карты по клику.

Все перемещения будем реализовывать с помощью базовой функции инерции:

position += (targetPosition - position) * coeff;

Получается, что переменная position каждый цикл анимации будет приближаться к целевой позиции на расстояние, равное разнице целевой и текущей позиции умноженной на коэффициент, соответственно этот коэффициент будет отвечать за скорость анимации.

Заочно мы уже определили параметры конструктора и некоторые методы:

import TarotController from "./TarotController.js";

export default class TarotCard {

  /**
   * @type {TarotController}
   */
  controller;
  
  /**
   * 
   * @param {*} data 
   * @param {number} index - Индекс текущей карты
   * @param {number} amount - Общее кол-во карт
   * @param {TarotController} controller 
   */
  constructor(data, index, amount, controller) {
    this.controller = controller;
  }

  fadeOut() {}

  fadeIn() {}
	
  update() {}
}

Метод TarotCard.fadeOut будет скрывать нашу карту в методе TarotController.reset. Заодно сделаем метод fadeIn для появления карты. Метод update будет вызываться каждый цикл анимации в TarotController, тут будем рассчитывать позицию и повороты карты.

Определим какие могут быть состояния у каждой карты и создадим соответствующие поля в классе TarotCard:

export default class TarotCard {

	/**
   * @type {TarotController}
   */
  controller;

	isFadeIn = false;
	isFadeOut = false;
	isHovered = false;
	isRevealed = false;

  ...
}

Сразу же добавим изменения состояний в наших созданных методах:

fadeOut() {
  this.isFadeIn = false;
  this.isFadeOut = true;
}

fadeIn() {
  this.isFadeIn = true;
  this.isFadeOut = false;   
}

Далее предлагаю сделать HTML-элемент, чтобы уже работать наглядно. Создаем метод TarotCard.createElement, который будем вызывать в конструкторе. В нем мы возьмем наш template #card, скопируем его и вставим в контейнер контроллера, пока без данных:

createElement() {
    const clone = document.querySelector('#card').content.cloneNode(true);
    this.element = clone.querySelector('.card');
    this.controller.container.appendChild(this.element);
		/**
     * TODO: заполнить карточку данными
     */
  }

Теперь при создании экземпляра TarotCard у нас будет создаваться элемент внутри контейнера.

Напишем базовые стили и посмотрим что у нас получается:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

.cards-container {
  width: 100%;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 2rem;
  perspective: 1000px;
  overflow: hidden;
}

.card {
  min-width: 10rem;
  aspect-ratio: 9/16;
  background-color: red;
  border-radius: 4px;
  will-change: transform;
  position: relative;
}

.reset {
  z-index: 2;
  cursor: pointer;
  position: absolute;
  left: 50%;
  bottom: 2rem;
  transform: translateX(-50%);
  padding: 1rem 2rem;
}

Теперь нужно создать экземпляр класса TarotController, и сразу же повесим слушатель события — click-кнопку:

import TarotController from "./modules/TarotController.js";

const initTarot = () => {
  
  const tarot = new TarotController();
  const btn = document.querySelector('.reset');

  btn.addEventListener('click', () => {
    tarot.setCards();
  });
};

initTarot();

Получаем такой результат:

03

Отлично! Теперь приступим реализации анимаций

Первым делом определим, какие свойства будем анимировать. По задумке карта должна иметь возможность перемещаться в трех направлениях и вращаться вокруг осей — X и Y. Соответственно, будем использовать translate3d для перемещения и rotateX/rotateY для поворотов. Обозначим следующие поля:

  • стартовую (дефолтную) позицию как pos = {x: 0, y: 0, z: 0};
  • целевую позицию, которую и будем изменять targetPos = {x: 0, y: 0, z: 0};
  • стартовое значения вращения как rotate = {x: 0, y: 0};
  • целевое значение вращения, которое и будем изменять targetRotate = {x: 0, y: 0, z: 0}.
rotate = {
  x: 0, 
  y: 0,
};

pos = {
  x: 0,
  y: 0,
  z: 0,
};

targetRotate = {
  x: 0,
  y: 0,
};

targetPos = {
  x: 0,
  y: 0,
  z: 0,
};

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

Допустим, угол будет изменяться от 30° до −30°, а позиция по оси Z от 6 до 0 и обратно.

Тут нам поможет функция линейной интерполяции, выделим ее в отдельный метод:

interpolation(value, min, max, newMin, newMax) {
	let newValue = ( (value-min) / (max-min) ) * (newMax-newMin) + newMin;
	return newValue;
}

Эта функция принимает на входе число, изменяемое в интервале от min до max, и возвращает интерполированное значение в интервале от newMin до newMax.

Расчитаем значение на основе индекса карты и общего количества карт, которое изменяется от −1 до 1.

Интерполируем index карты между значениями 0 и кол-во карт — 1 (это будет index последнего элемента):

const basicCoeff = interpolation(index, 0, amount -1, -1, 1);

Соответственно, если нам нужно изменить угол от 30° до −30°, то полученное значение (от −1 до 1) умножим на −30, а позицию просто умножим на 6, получив тем самым изменение от −6 до 6 и просто возьмем модуль этого значения.

Можно попробовать расположить карты полукругом, в этом нам поможет функция косинуса:

Нам нужен отрезок от -PI/2 до PI/2. И так как у нас уже есть коеффициент изменения (от −1 до 1), то косинус будем искать от Math.PI * 0.5 * basicCoeff. Но пока-что значение нам не подходит, так как косинус от 0 равен 1. Вычтем из результата едницу и инвертируем значение, чтобы получить значение, которое изменяется от 1 до 0 и обратно к 1.

const cosCoeff = -(Math.cos(basicCoeff * Math.PI * 0.5) - 1).toFixed(2);

Реализуем метод, назовем его TarotCard.calculateProps и сразу учтем возможное начальное состояние TarotCard.isFadeIn (карты будут появляться сверху, так что просто изменим начальную позицию Y):

/**
 * 
 * @param {number} index 
 * @param {number} amount 
 */
calculateProps(index, amount) {
  const basicCoeff = this.interpolation(index, 0, amount - 1, -1, 1);
  const cosCoeff = -(Math.cos(basicCoeff * Math.PI * 0.5) - 1).toFixed(2);

  // Calculate rotation on Y-axis
  this.rotate.y = -(basicCoeff * 30).toFixed(2);
  // Calculate position on Z-axis
  this.pos.z = cosCoeff * 8;

  if (this.isFadeIn) {
    this.pos.y = -50;
    this.targetPos.y = -50;
  }
}

Вызовем этот метод в конструкторе и посмотрим что у нас получилось:

/**
   * 
   * @param {*} data 
   * @param {number} index 
   * @param {number} amount 
   * @param {TarotController} controller 
   */
  constructor(data, index, amount, controller) {
    this.controller = controller;
    this.fadeIn();
    this.calculateProps(index, amount);
    this.createElement();

		console.log('position', this.pos);
    console.log('rotation', this.rotate);
  }

Сразу обозначим начальное состояние TarotCard.fadeIn, и создадим элемент уже реализованным методом. В консоли получим следующие сообщения:

Это означает, что у нас все сработало!

Дополним метод TarotCard.fadeIn непосредственно самим появлением, а именно, после небольшой задержки просто изменим изначальное положение координаты Y на 0:

fadeIn() {
    this.isFadeIn = true;
    this.isFadeOut = false;

    setTimeout(() => {
      this.isFadeIn = false;
      this.pos.y = 0;
    }, 50);
  }

Теперь нужно плавно изменять поля targetPos и targetRotate в зависимости от состояния. При дефолтном состоянии эти поля должны всегда стремиться в сторону дефолтных значений.

Реализуем метод, который рассчитывает координаты в зависимости от текущего состояния карты. По каждому этапу оставлю комментарии в коде:

calculateTargetPosition() {
	// Скопируем дефолтные значения. Будем использовать как основные
  const output = { ...this.pos };
	
	// В состоянии появления сдвинем карту наверх и немного назад,
  // чтобы все карты появлялись из одной плоскости
  if (this.isFadeIn) {
    output.y = -50;
    output.z = 0;
  }
	
	// В состоянии скрытия спрячем карту вниз и так же отодвинем
  // в одну плоскость
  if (this.isFadeOut) {
    output.y = 50;
    output.z = 0;
  }
	
	// Если навели курсор на карту, то подвинем ее немного ближе к нам
  if (this.isHovered) {
    output.z = 10;
  }
	// Вернем новые координаты
  return output;
}

По аналогии сделаем метод, рассчитывающий вращение карты:

calculateTargetRotate() {
	// Скопируем дефолтные значения. Будем использовать как основные
  const output = { ...this.rotate };
	
	// Если навели курсор на карту и она еще скрыта, 
  // то повернем ее перпендикулярно экрану
  if (this.isHovered && !this.isRevealed) {
    output.y = 0;
  }
	// Если навели курсор на карту и она раскрыта, 
  // то повернем ее обратно перпендикулярно экрану
  if (this.isRevealed && this.isHovered) {
    output.y = 180;
  }
	// Если карта просто раскрыта, то развернем ее на 180 градусов
  if (this.isRevealed && !this.isHovered) {
    output.y = this.rotate.y + 180;
  }
	// Добавим небольшой наклон по оси X и сбросим поворот по оси Y 
  // когда карта появляется или исчезает
  if (this.isFadeIn || this.isFadeOut) {
    output.y = 0;
    output.x = this.isFadeIn ? 15 : -15;
  }
  // Вернем новые значения
  return output;
}

Теперь наконец-то реализуем изменение targetRotate и targetPos в сторону новых рассчитанных координат. Концепцию этой анимации я уже описал выше, примерно так это должно выглядеть:

const rotate = this.calculateTargetRotate();
// 0.1 - коеффициент изменения, проще говоря - скорость анимации
this.targetRotate.y += (rotate.y - this.targetRotate.y) * 0.1;

Обобщим изменение полей, будем изменять значения в цикле по ключам объектов и немного нормализуем их, оставив по 2 цифры после запятой:

update() {
    const rotate = this.calculateTargetRotate();
    const pos = this.calculateTargetPosition();

    Object.keys(pos).forEach(key => {
      this.targetPos[key] += (pos[key] - this.targetPos[key]) * 0.1;
			this.targetPos[key] = +(this.targetPos[key].toFixed(2));
    });

    Object.keys(rotate).forEach(key => {
      this.targetRotate[key] += (rotate[key] - this.targetRotate[key]) * 0.1;
			this.targetRotate[key] = +(this.targetRotate[key].toFixed(2));
    });
  }

Еще немного и будем смотреть анимации, а пока создадим изменения состояний isHovered и isRevealed. Повесим слушатели событий на созданный элемент в отдельном методе и вызовем его после создания самого элемента:

addListeners() {
  this.element.addEventListener('mousemove', () => {
    this.isHovered = true;
  });

  this.element.addEventListener('mouseleave', () => {
    this.isHovered = false;
  });
	// Будем показывать и скрывать карту по клику на нее
  this.element.addEventListener('click', () => {
    this.isRevealed = !this.isRevealed;
  });
}
/**
 * 
 * @param {*} data 
 * @param {number} index 
 * @param {number} amount 
 * @param {TarotController} controller 
 */
constructor(data, index, amount, controller) {
  this.controller = controller;
  this.fadeIn();
  this.calculateProps(index, amount);
  this.createElement();
  this.addListeners();
}

Все приготовления завершены, осталось связать наши координаты со стилями элемента. Для этого создадим метод TarotCard.setStyles, который будем вызывать в методе update после расчета координат:

setStyles() {
    if (!this.element) {
      return;
    }
    this.element.style.transform = `translate3d(${this.targetPos.x}rem, ${this.targetPos.y}rem, ${this.targetPos.z}rem)
      rotateX(${this.targetRotate.x}deg) rotateY(${this.targetRotate.y}deg)`;
  }

Затем просто добавляем стили нашим картам и смотрим результат:

04

Все работает отлично, осталось подставить нужные данные и навести CSS-мишуры

До сих пор мы использовали в качестве данных массив с пустыми объектами. Теперь заполним его актуальными данными. В этом массиве будут лежать объекты с полями title и img, в первом будем хранить название карты, а во втором — путь до изображения карты.

Мы уже передаем эти данные в конструктор TarotCard, давайте сохраним их:

export default class TarotCard {
	...
	title = '';
  img = '';
	...

	/**
   * 
   * @param {*} data 
   * @param {number} index 
   * @param {number} amount 
   * @param {TarotController} controller 
   */
  constructor(data, index, amount, controller) {
    this.controller = controller;

    this.title = data?.title;
    this.img = data?.img;

    this.fadeIn();
    this.calculateProps(index, amount);
    this.createElement();
    this.addListeners();
  }
	...
}

Теперь можно добавить соответствующие картинки. Добавим в разметку шаблона элементы для рубашки карты и для изображения:

<template id="card">
  <div class="card">
    <div class="card__back"></div>
    <div class="card__front"></div>
  </div>
</template>

И следующие стили:

.card {
  min-width: 10rem;
  aspect-ratio: 9/16;
  border-radius: 4px;
  will-change: transform;
  position: relative;
  transform-style: preserve-3d;
}

.card__back,
.card__front {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  border-radius: 4px;
  backface-visibility: hidden;
}

.card__back {
  background-image: url('./img/back.jpg');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

.card__front {
  transform: rotateY(-180deg) translateZ(1px);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

Скачаем изображения таро и заполним массив с данными:

export default [
  {
    title: 'шут',
    img: './img/cards/1.jpg',
  },
  {
    title: 'маг',
    img: './img/cards/2.jpg',
  },
  {
    title: 'жрица',
    img: './img/cards/3.jpg',
  },
  {
    title: 'императрица',
    img: './img/cards/4.jpg',
  },
  // и тд
]

В методе, где создается элемент, реализуем подставление картинки в карту:

createElement() {
    const clone = document.querySelector('#card').content.cloneNode(true);
    this.element = clone.querySelector('.card');
    this.controller.container.appendChild(this.element);

    const cardFront = this.element.querySelector('.card__front');
    cardFront.style.backgroundImage = `url(${this.img})`;
  }

Смотрим что получилось:

Мне захотелось еще добавить анимацию на mousemove, чтобы карта реагировала на движение мыши небольшими поворотами. Для этого создадим новое поле в классе Card для записи координат мыши и назовем его mousePos, по дефолту сделаем значения нулевыми {x: 0, y: 0}.

В существующий обработчик события mousemove добавим следующий код:

const rect = this.element.getBoundingClientRect();
this.mousePos.x = this.interpolation(e.clientX - rect.left, 0, rect.width, -1, 1);
this.mousePos.y = this.interpolation(e.clientY - rect.top, 0, rect.height, -1, 1);

Тут мы интерполируем текущую позицию мыши в диапазоне от −1 до 1 и записываем в поле mousePos.

В уже созданный метод TarotCard.calculateTargetRotate добавим изменение поворота по двум осям, если текущее состояние isHovered.

Теперь метод выглядит так:

calculateTargetRotate() {
  const output = { ...this.rotate };

  if (this.isHovered && !this.isRevealed) {
    output.y = this.mousePos.x * 10;
    output.x = -this.mousePos.y * 10;
  }

  if (this.isRevealed && this.isHovered) {
    output.y = this.mousePos.x * 10 + 180;
    output.x = -this.mousePos.y * 10;
  }

  if (this.isRevealed && !this.isHovered) {
    output.y = this.rotate.y + 180;
  }

  if (this.isFadeIn || this.isFadeOut) {
    output.y = 0;
    output.x = this.isFadeIn ? 15 : -15;
  }

  return output;
}

05

После добавления побочных стилей для красоты, проект принимает законченный вид:

Тут нет жесткой привязки к отображению, и при желании ту же анимацию можно реализовать и путем WebGL (возможно, рассмотрим этот вариант в следующих статьях).

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

Не стесняйтесь использовать наработки, вот ссылочка на гитхаб!