Предсказание от 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 (возможно, рассмотрим этот вариант в следующих статьях).
Эта статья — не туториал, а скорее просто иллюстрация того, как можно занять себя на пару часов решением интересной и нетривиальной задачи.
Не стесняйтесь использовать наработки, вот ссылочка на гитхаб!