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

от автора

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

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

  • общий контроллер (назовем его 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>

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

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 после резолва промиса reset первым делом мы перемешиваем исходный массив с картами, который импортировали на 2 строке файла (import cardsData from '../data.js';). Затем с помощью обычного цикла for итерируемся указанное в параметре количество раз и создаем экземпляр класса TarotCard, куда передаем данные текущей карты (пока без разницы, что это будут за данные), текущий индекс карты, общее кол-во карт и сам инстанс нашего контроллера, чтобы у карты был к нему доступ.

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

  • анимация появления карточки (выплывание из верхней части странички);

  • анимация скрытия карточки (скрытие карты за нижнюю границу страницы);

  • анимация по наведению на карту (перемещение по оси Z, ближе к пользователю);

  • анимация появления (”разворота”) карты по клику.

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

position += (targetPosition - position) * coeff;.

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

Пришло время создать класс TarotCard. Заочно мы уже определили какие будут параметры конструктора и некоторые методы:

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() {} }

Метод fadeOut будет скрывать нашу карту в методе reset у класса TarotController. Заодно сделаем метод 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();

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

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

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

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

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

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

Нам нужен отрезок от -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)`;   }

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

Все работает, осталось подставить нужные данные и навести 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; }

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/795383/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *