Предсказание от 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/


Интересные статьи

Интересные статьи

Обычное дело — на этапе обсуждения будущего проекта студия выясняет, что на стороне заказчика нет лица, принимающего решение. Точнее он есть, но до него не добраться. Вместо него дела ведет доверенный...
Один из самых простых способов попасть в IT — обучиться профессии тестировщика. Причём перейти в неё может даже человек без образования программиста, а на начальные курсы уйдёт от 3 до 4 месяцев учёбы...
Epic Games провела 26 мая презентацию Unreal Engine 5, на которой показала новые возможности движка, включая системы по работе с ассетами, светом, анимациями и звуком. С...
Привет, Хабр! У нас настало время очередного релиза Acronis True Image, нашего флагманского продукта для персональных пользователей. Версия 2021 года вышла действительно особенной, по...
Картинка, которую вы видите, позаимствована с сайта DeepMind, и демонстрирует 57 игр, в которых их новейшая разработка Agent57 (обзор статьи на Хабре) достигла успеха. Само число 57 взято не ...