
Привет! На связи Кристина, фронтенд-разработчик в KTS.
В этой статье рассказываю, как создавала анимацию для игры из внутреннего спецпроекта, какие SCSS-фичи использовала для оптимизации кода и как сделала CSS-анимации более производительными.
Оглавление
Сапожник с сапогами: для чего нам анимация
В KTS есть отдел спецпроектов, который занимается разработкой мини-игр под рекламные задачи заказчиков. Можете посмотреть проекты на сайте или почитать статьи:
-
Некринжовая игра с мемами для подростков: как мы сделали миниапп «ВКонтакте» для промо онлайн-школы
-
Мини-приложение «СмешАпп» для ВКонтакте к 20-летию Смешариков
Мы создали уже более 300 рекламных спецпроектов для клиентов, и ни одного для себя. Под Новый год мы решили сделать себе подарок в виде атмосферного спецпроекта. Игра была создана полностью нами и в сжатые сроки — за неделю до Нового года мы подобрали звуки, обдумали механику, нарисовали дизайн и разработали спецпроект под Telegram и ВКонтакте.
Для этого мы разработали новую механику — новогодний музыкальный сэмплер. Её суть — юзер сочиняет музыку из трех составляющих: мелодии, перкуссии и звуковых эффектов. Нажатие на кнопки сэмплера сопровождается анимациями, о которых и пойдёт речь в статье.

Какие анимации у меня были
Всего я разработала семь новогодних анимаций:
Танцующий снегирь
Снегирь начинает танцевать при использовании одного из звуковых эффектов. Для движения снегирей я взяла свойство transform: scaleY().Птички вращаются туда-сюда с углом поворота примерно 10 градусов и одновременно растягиваются по высоте:
$start-scale: 1.1; $finish-scale: 0.9; $angle: 5deg; @keyframes dance { from { transform: scaleY($start-scale) rotate($angle * -1); } 50% { transform: scaleY($finish-scale) rotate(0); } to { transform: scaleY($start-scale) rotate($angle); } }
Далее установила animation-direction: alternate, чтобы на каждой итерации анимация сначала проигрывалась в прямом направлении, а потом в обратном для красивого цикла. Это позволяет не прописывать возвратное движение внутри @keyframes.
Также сместила точку, относительно которой происходит движение, из центра в нижнюю часть снегиря с помощью transform-origin:
.bird { animation: dance 0.66s infinite linear alternate; transform-origin: 70% 100%; }
Снегирь со смещённым центром выглядит так:

Вот как он забавно пританцовывает:
Раскачивающийся снеговик
Снеговик покачивается и машет руками-ветками, если использовать любую перкуссию. Его я анимировала по тому же принципу, что и снегиря. Тело и руки поворачиваются на небольшой угол с помощью transform: rotate() со смещённым вниз центром трансформации.
Движение тела и движение рук отличаются только углом поворота, поэтому @keyframes можно вынести в @mixin, чтобы избежать дублирования кода:
@mixin waveKeyframes($name, $angle) { @keyframes #{$name} { /* в начальном и конечном положении вращения нет, это состояние по умолчанию */ from, to { transform: rotate(0); } /* сначала вращаем элемент по часовой стрелке */ 25% { transform: rotate($angle); } /* потом против часовой стрелки на тот же угол, и возвращаемся в исходное положение */ 75% { transform: rotate($angle * -1); } } } @include waveKeyframes(hand-wave, 12deg); @include waveKeyframes(body-wave, 5deg);
Все мои анимации можно включить и выключить. При нажатии на кнопку на элементы навешиваются дополнительные классы-модификаторы.
Стили выглядят примерно так:
.snowman { /* ... */ &__body, &__left-hand, &__right-hand { /* тело и обе руки двигаются по одному принципу */ transform-origin: 50% 100%; animation: linear 2s infinite; } &__body { /* ... */ &_animating { /* чтобы включить анимацию, навешиваем на тело модификатор .snowman__body_animating */ animation-name: body-wave; } } &__left-hand { /* ... */ } &__right-hand { /* ... */ } &__left-hand, &__right-hand { &_animating { /* к каждой руке тоже добавляем модификаторы */ animation-name: hand-wave; } } }
Интересный момент заключается в том, что при выключении анимации не должно быть резкой смены состояний. Снеговик не останавливается резко при отключении перкуссии, а плавно возвращается в исходное положение после окончания текущего цикла.
Для этого я использовала событие animationiteration, которое срабатывает каждый раз, когда заканчивается текущая итерация CSS-анимации. При отключении перкуссии класс _animating удаляется только когда снеговик возвращается в первоначальное положение:
// когда пользователь включает и выключает анимацию, меняется пропс isAnimating const Snowman = ({ isAnimating = false }) => { // вводим дополнительное состояние isDancing, // при изменении которого включаем и выключаем анимацию // с помощью добавления и удаления классов-модификаторов const [isDancing, setIsDancing] = React.useState(false); // выключаем снеговика только после того, // как завершился текущий цикл анимации const handleAnimationIteration = () => { if (!isAnimating) { setIsDancing(false); } }; // включаем анимацию без задержек, снеговик начинает танцевать // сразу при нажатии на кнопку React.useEffect(() => { if (isAnimating) { setIsDancing(true); } }, [isAnimating]); return ( <div className="snowman"> <div className={classNames( 'snowman__wrapper', isDancing && 'snowman__wrapper_animating' )} onAnimationIteration={handleAnimationIteration} > <img className={classNames( 'snowman__left-hand', isDancing && 'snowman__left-hand_animating' )} src={leftHandImg} /> <img className="snowman__body" src={bodyImg} /> <img className={classNames( 'snowman__right-hand', isDancing && 'snowman__right-hand_animating' )} src={rightHandImg} /> </div> </div> ); };
В итоге получается такой снеговик:
Летящая упряжка Санта Клауса
Санта с оленями на упряжке пролетает за горами на фоне неба при нажатии на эффект.
Особенность анимации заключается в том, что сани должны двигаться по дуге, а не по прямой линии. Это можно реализовать с помощью комбинации слоев. Я обернула Санту в дополнительныйdiv, который вращается вокруг своей оси:
<div class="path"> <img class="santa" src="santa.png" /> </div>
Далее с помощью абсолютного позиционирования я разместила Санту на границе вращающегося блока. Для наглядности я округлила углы у обёртки. Получается движение по кругу:

Теперь остаётся только настроить угол поворота, так как описывать полный круг нет необходимости:
$initial-angle: -60deg; /* начальный угол поворота окружности */ $finish-angle: $initial-angle + 120deg; /* финальный угол поворота */ /* помимо rotate присутствуют и другие трансформации, и чтобы их не дублировать, можно вынести изменение угла поворота в @mixin */ @mixin setTransform($angle) { transform: translate(-50%, -50%) rotate($angle); } @keyframes moving { from { @include setTransform($initial-angle); } to { @include setTransform($finish-angle); } } .path { position: relative; width: 400px; height: 400px; animation: moving 3s infinite linear; } .santa { position: absolute; top: 0; left: 50%; transform: translate(-50%, -50%); width: 25%; }
Хо-хо-хо!
Звенящие елочные игрушки
Анимация ёлочных игрушек сопровождает звуковой эффект. Они висят на новогодней ёлке и покачиваются.
Анимация на самом деле весьма проста. Она основана на вращении свойства transform: rotate() с измененным центром трансформации, подобно снегирям и снеговику. Особенность в том, что анимировать пришлось больше десятка однотипных элементов. Здесь мне пригодились возможности SCSS: списки, вспомогательные функции, миксины и циклы. С их помощью мне удалось сэкономить время написания кода и соблюсти принцип DRY (don’t repeat yourself).
Вешаем игрушки на елку
У нас есть контейнер, размеры которого соответствуют размерам ёлки. Внутри с помощью абсолютного позиционирования размещаем игрушки:
<div className="toys"> Array.from({ length: 17 }).map((_, index) => ( <div key={index} className="toy" /> ))} </div>
Всего на ёлке — 17 игрушек. Прописывать стили для каждой из них мне не хотелось, поэтому я оптимизировала этот процесс.
Для начала я создала список координат всех игрушек относительно контейнера в формате (x y):
$positions: ( (60 41), (80 47), (91 91), /* ... */ );
Координаты лежат у дизайнеров в Figma. Здесьx– отступ от левой границы фрейма до игрушки,y– отступ от верхней границы фрейма:

Верстка у нас адаптивная: размеры ёлки меняются в зависимости от размера экрана. Для этого рассчитываем значенияtopиleftигрушек в относительных единицах измерения:
/* размеры фрейма с елкой из фигмы */ $frame-width: 154; $frame-height: 193; /* @param $index Порядковый номер игрушки в списке $positions */ @mixin setPosition($index) { $x: nth(nth($positions, $index), 1); $y: nth(nth($positions, $index), 2); top: $y / $frame-height * 100%; left: $x / $frame-width * 100%; }
Далее каждой ёлочной игрушке в цикле я задала позиционирование:
/* общее количество игрушек */ $totalToys: length($positions); .toy { position: absolute; width: 8.4%; @for $toyIndex from 1 through $totalToys { &:nth-of-type(#{$toyIndex}) { @include setPosition($toyIndex); } } }
Теперь все игрушки висят на своих местах.
Раскрашиваем игрушки в разные цвета
Ёлочная игрушка представляет собой SVG-элемент, который содержит 3 слоя:
-
основной цвет;
-
тень;
-
блик.

В коде это выглядит так:
<svg width="14" height="13" viewBox="0 0 14 13" fill="none"> <path className="base" d="M5.43003..."/> <!-- основной цвет --> <path className="shadow" d="M12.5017..."/> <!-- блик --> <path className="glare" d="M10.0046..." /> <!-- тень --> </svg>
С такой простой структурой я написала миксин, который будет раскрашивать игрушку в нужный цвет. Для блика основной цвет осветлен с помощью SCSS-функции lighten(), а для тени я взяла функциюdarken():
/* список всех возможных основных цветов */ $colors: (#ece897, #62dfca, #fe8884); /* @param $index Порядковый номер цвета из списка $colors */ @mixin colorizeToy($index) { $color: nth($colors, $index); path { &.base { fill: $color; } &.shadow { fill: darken($color, 9%); } &.glare { fill: lighten($color, 7%); } } }
Следующая задача — раскрашивание игрушек в равных пропорциях. Для этого:
-
Делим все игрушки на равные группы. Количество групп соответствует количеству цветов;
-
По порядковому номеру определяем, к какой из групп относится игрушка, и в зависимости от этого окрашиваем ее в нужный цвет;
-
Важен порядок игрушек в списке
$positions. В данном случае первая треть из списка будет окрашена в жёлтый, вторая треть — в зелёный, и оставшиеся — в красный.
/* общее количество всех расцветок */ $totalColors: length($colors); .toy { /* ... */ /* по умолчанию окрашиваем все игрушки в первый цвет из списка $colors */ @include colorizeToy(1); @for $toyIndex from 1 through $totalToys { &:nth-of-type(#{$toyIndex}) { /* ... */ @for $colorIndex from 1 through $totalColors { /* окрашиваем игрушку в зависимости от ее порядкового номера */ @if $toyIndex < ($totalToys * (1 - 1 / $totalColors * $colorIndex)) { @include colorizeToy($colorIndex + 1); } } } } }
Теперь всё автоматизировано, чтобы добавить новые или убрать имеющиеся цвета. Достаточно только обновить список$colors.
Добавляем покачивание
Осталось добавить каждой игрушке анимацию покачивания. Чтобы движения не были синхронными и игрушки двигались хаотично, я добавила каждой игрушке небольшую рандомную задержкуanimation-delay: random(750) * 1ms:
/* ... */ $angle: 15deg; $start-angle: calc($angle * -1); $finish-angle: $angle; @keyframes wiggle { from { transform: rotate($start-angle); } to { transform: rotate($finish-angle); } } .toy { /* ... */ transform: rotate($start-angle); transform-origin: 50% -50%; animation: wiggle infinite 750ms linear alternate; @for $toyIndex from 1 through $totalToys { &:nth-of-type(#{$toyIndex}) { /* ... */ animation-delay: random(750) * 1ms; } } }
Готово!
Смена времени суток
Время суток изменяется при выборе мелодии. Самое сложное здесь — это переключение между утром, закатом и ночью. Основная заслуга принадлежит дизайнеру, который кропотливо собрал сцену из множества разноплановых слоёв. Мне осталось только перенести эти слои в верстку и реализовать их смену в зависимости от текущего времени суток.
Наложение слоёв друг на друга выглядит так:

Чтобы наложить слои друг на друга, я использовала абсолютное позиционирование. Для масштабирования сцены в зависимости от ширины экрана размеры и положение слоёв выражены в процентах:
/* размеры сцены в px взяты из макета */ $scene-width: 816; $scene-height: 593; /* находим размеры и позицию элемента в % относительно сцены, на основе px из макета */ @mixin setSceneElementPosition($width, $height, $offsetTop, $offsetLeft) { position: absolute; top: ($offsetTop + $height / 2) / $scene-height * 100%; left: ($offsetLeft + $width / 2) / $scene-width * 100%; transform: translate(-50%, -50%); width: $width / $scene-width * 100%; }
Появление и скрытие слоев реализовано через добавление и удаление класса-модификатора и изменение прозрачностиopacity:
/* продолжительность анимации одинакова для всех слоев, поэтому выносим ее в переменную */ $ambience-duration: 1s; .element { /* ... */ opacity: 0; transition: opacity $ambience-duration; &_shown { opacity: 1; } }
Луна и солнце сменяют друг друга засчёт измененияtransform:
.moon, .sun { /* ... */ transform: translate(-50%, 150%); transition: transform $ambience-duration; &_shown { transform: translate(-50%, 0); } }
Анимация получилась очень уютной:
Красочный фейерверк
Фейерверк запускает при нажатии кнопки соответствующего эффекта. На просторах Сodepen я подсмотрела интересную реализацию салюта, которая и была взята за основу.
Анимируем взрыв
Взрыв реализуется засчёт изменения свойства box-shadow. Каждая частичка взрыва — это отдельная тень многослойного box-shadow, которая отличается цветом и сдвигом.
@keyframes bang { /* from можно опустить, так как по умолчанию box-shadow и так none */ from { box-shadow: none; } to { box-shadow: 28px -96px white, 207px -218px pink, 167px -60px green, /* ... */ -26px -113px white; } }
Когдаbox-shadowанимируется от состоянияnoneк многослойной тени, то получается эффект разлетающихся элементов:

Возможности SCSS позволяют сгенерировать взрыв с произвольным количеством частиц и рандомным разбросом:
/* список всех возможных цветов частиц */ $colors: #f1eb70, #5bd3c4, #9de7ff, #fff, #ff30ea, #31ff00; /* размер разброса частиц */ $spread: 500; /* общее количество частиц */ $particles: 50; /* в эту переменную будем записывать многослойную тень */ $box-shadow: (); @for $i from 0 through $particles { /* на каждой итерации цикла записываем в переменную $box-shadow ее предыдущее значение и добавляем к нему еще одну новую тень с рандомным сдвигом и цветом */ $box-shadow: $box-shadow, (random($spread) - $spread / 2) * 1px (random($spread) - $spread / 1.5) * 1px nth($colors, random(length($colors))); }
Далее я взяла полученную тень и задала ей анимацию:
.firework { $size: 7px; width: $size; height: $size; border-radius: 50%; animation: 1s bang ease-out infinite backwards; } /* анимация взрыва */ @keyframes bang { to { box-shadow: $box-shadow; } }
Оптимизация салюта
Если есть возможность, тоbox-shadowлучше не анимировать. В подавляющем большинстве случаев, когда требуется сделать динамическую тень, анимациюbox-shadow можно заменить на изменениеopacityиtransform:
-
При анимации
transformиopacityанимируемые элементы выносятся на отдельные композиционные слои, и браузер перерисовывает не всю страницу, а только эти слои; -
Когда анимируется
box-shadow, то происходит Repaint — один из самых трудоёмких этапов отрисовки, в процессе которого браузер заполняет пиксели цветами. Также Repaint вызывается при изменении свойствcolor,background,border-colorи других; -
Когда анимируются
top,margin,paddingи подобные CSS-свойства, то перед Repaint каждый раз происходит еще и Reflow (relayout), и браузер пересчитывает размеры и положение элементов на странице. С этим кейсом я еще столкнулась чуть дальше.Более подробно узнать о том, какие этапы перерисовки вызывают изменения свойств можно здесь.
Я провела эксперимент:
-
Вместо изменения
box-shadowэлемента.fireworkя сгенерировала под каждую частичку салюта свойdiv; -
Анимацию сдвига тени заменила на изменение
transform: translate().
Несмотря на то что теперь не происходит ресурсоемкого этапа repaint, производительность не улучшилась. Дело в том, что с помощьюbox-shadowя анимировала всего один html-элемент, а в новом варианте рендерится целых 50 элементов.
Ниже — скриншоты вкладки Performance в Chrome DevTools. В обоих примерах для наглядности выставлены настройки CPU: 6x slowdown, чтобы симулировать более слабый процессор, чем есть в действительности.
box-shadow одного элемента
transform множества элементовПри анимацииtransformвремя Painting сократилось со 160ms до 75ms, чего я и добивалась. Однако при этом многократно возросло время Rendering.
К чему это всё? Есть базовое правило, которому следуют все CSS-аниматоры: в приоритете анимировать свойстваtransformиopacity, но важно учитывать и контекст. В нашем случае в финальном варианте анимируются целых 100 огоньков, и реализация с помощью box-shadowболее производительна, чемtransform.
Включаем гравитацию
Частички равномерно разлетаются в разные стороны. Я сделала так, чтобы они падали вниз под тяжестью собственного веса. Заодно добавила плавное появление и исчезновение:
/* эффект гравитации: частицы падают вниз */ @keyframes gravity { 80% { opacity: 1; } to { transform: translate(0, $spread * 1px * 0.4); opacity: 0; } }
Добавим к анимации взрыва анимациюgravity. Повторяющиеся свойства можно вынести в переменную, чтобы избежать дублирования:
.firework { /* ... */ $local-animation: 1s infinite backwards; animation: $local-animation, $local-animation; animation-name: bang, gravity; animation-timing-function: ease-out, ease-in; }
Гравитация сработала так:

Запускаем несколько фейерверков
На последнем шаге я сделала запуск салюта в разных частях экрана, а не только по центру. Для этого я меняла местоположение перед каждым новым взрывом. В оригинальной анимации позиция изменяется за счет margin. Ранее упоминалось, что анимировать margin — так себе идея, так как это плохо сказывается на производительности браузера. Поэтому я изменилаtransform: translate():
@mixin setPosition($x, $y) { transform: translate(100vw * $x, 100vh * $y); } /* появление взрывов в разных частях экрана */ @keyframes position { 0%, 19.9% { @include setPosition(0.4, 0.1); } 20%, 39.9% { @include setPosition(0.3, 0.4); } 40%, 59.9% { @include setPosition(0.7, 0.2); } 60%, 79.9% { @include setPosition(0.2, 0.3); } 80%, 99.9% { @include setPosition(0.8, 0.2); } }
Я отрефакторила код и перенесла двойную анимацию со взрывом и гравитацией с элемента .fireworkна псевдоэлемент::before. А на.fireworkповесила анимацию смены позиции:
Смотреть код
.firework { $size: 7px; width: $size; height: $size; border-radius: 50%; animation: 5s position linear infinite backwards; &::before { content: ""; display: block; position: absolute; left: 0; top: 0; right: 0; bottom: 0; border-radius: inherit; $local-animation: 1s infinite backwards; animation: $local-animation, $local-animation; animation-name: (bang, gravity); animation-timing-function: (ease-out, ease-in); } }
Запускаем салют:

Для масштабности фейерверка в игре анимируется два html-элемента:
Что в итоге получилось
Красота требует жертв. Такое большое количество графики и анимаций, большая часть которых включена одновременно, потребляет достаточно много ресурсов. Однако благодаря оптимизации наше приложение работает без глюков и тормозов на современных устройствах.
Что я сделала для оптимизации:
-
Добавила предзагрузку всех статических файлов. Все изображения и звуки загружаются в момент открытия приложения, и, пока они грузятся, пользователь видит крутящийся значок загрузки;
-
По возможности анимировала только
transformиopacity, чтобы лишний раз не запускать процессы Repaint и Reflow; -
Не стоит забывать про аппаратное ускорение анимаций. В нашем случае все элементы передаются на обработку GPU. Так происходит, потому что в нашем проекте слои накладываются друг на друга. Стоит помнить, что если элемент по оси Z находится выше элемента, который передаётся на обработку в GPU, то к нему тоже автоматически применяется аппаратное ускорение;
-
Оптимизировала и сжала всю графику. Для .jpg и .png использовала tinypng.com, а для svg — svgomg.net;
-
Для каждого изображения подбирала подходящий формат. Растровые картинки сохранены в .jpg, простые векторные изображения — в .svg, а сложные — в .png.
В итоге такая симпатичная анимация у меня получилась всего за неделю разработки в самое горячее время новогодних дедлайнов:
Другие статьи про frontend для начинающих:
Роадмэп по современному фронтенду от KTS
Чек-лист фронтендера при разработке рекламного спецпроекта
Как сделать свой текстовый редактор на React.js
Другие статьи про frontend для продвинутых:
Как мы выбирали архитектуру микрофронтендов в ЛК для 260 000 сотрудников Пятёрочки
ссылка на оригинал статьи https://habr.com/ru/articles/819757/

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