Я разработал игру Desert Racer, чтобы показать уникальные и инновационные приемы, которые используют только CSS, включая функционал свайпа и детектирования столкновений, выполненные исключительно средствами CSS. На мой взгляд, это первые в своем роде решения. Вы вольны бросить вызов этому утверждению. В данной статье мы рассмотрим упомянутые техники и обсудим общие этапы создания игры со свайп-управлением.

На момент написания статьи, 27 марта 2024 года, я провел проверку, и Gemini по-прежнему утверждает, что реализация свайпа с использованием только CSS невозможна:

Идеален ли CSS для этой задачи? На данный момент, возможно, нет. Однако CSS начинает заменять некоторые функции обработчиков событий JavaScript. Я надеюсь, что эта статья внесет вклад в продвижение к этой цели. В конце концов, как творческому человеку, мне нравится использовать неподходящие инструменты для создания чего-то, что считается невозможным — это вызов, который я не могу игнорировать. У меня было предчувствие, что это должно работать, и оно оправдалось.

Эта статья предложит вам интерактивный и, надеюсь, вдохновляющий опыт. Приятного прочтения! (По крайней мере, вы можете прокрутить вниз, чтобы посмотреть крутые GIF-анимации)

Идея

6 октября 2023 года мне стало известно о революционной возможности в CSS — анимациях, активируемых скроллингом, благодаря подробной статье от Брамуса. Поздно вечером я зафиксировал для себя в WhatsApp следующую мысль:

Вы можете ощутить воодушевление сквозь все опечатки, неправильную пунктуацию, опущенные артикли и пропущенные предлоги. Возможно ли создать функцию определения свайпа без использования JavaScript? Эта идея превратилась в HTML- и CSS-игру, которую вы видите сегодня. Попробуйте её в действии, а затем возвращайтесь, чтобы узнать, как я это сделал:

Для создания оригинального фона CSS были использованы ресурсы, сгенерированные ИИ, а музыкальное сопровождение взято с Pixabay.

Функционал scroll-timeline поддерживается браузерами на движке Blink/Chromium, такими как Chrome и Edge для настольных компьютеров и Chrome для Android. Пользователям iPhone стоит знать, что Chrome для iOS фактически представляет собой Safari с другим интерфейсом, поэтому для лучшего опыта рекомендуется использовать Chrome на MacBook, который, вероятно, у вас имеется.

Создание игры с функцией свайпа

Этот процесс включал множество этапов: тестирование прототипов, адаптацию, рефакторинг и разработку совершенно новых CSS-техник, с которыми вы, возможно, ранее не сталкивались (и которые могут увлечь вас в глубины исследований).

Обратите внимание, что во время разработки этой игры я не был знаком с трюком, позволяющим изменять пространство состояний. Следовательно, вся логика игры основывается на использовании CSS-свойств, которые принимают числовые значения. Так, я мог корректировать длительность анимации, но не управлять её состоянием воспроизведения.

Мы свайпаем или прокручиваем?

В основе нашего подхода лежит использование CSS-свойств прокрутки в сочетании с экспериментальной функцией scroll-timeline. Однако, когда речь идет о сенсорных устройствах и трекпадах, ключевым элементом управления является свайп, отсюда и происходит название — обнаружение свайпов. Я разработал эту игру, предполагающую свайп как основной метод управления, предусмотрев при этом альтернативное управление через колесо прокрутки мыши. Если ваша мышь обладает возможностью горизонтальной прокрутки, не забудьте включить опцию управления мышью на главном экране Desert Racer.

Основные вызовы (тестирование идей)

Инициация двусторонней прокрутки, управляемой анимацией

Первым шагом была проверка возможности контролировать временные рамки анимации с помощью прокрутки в обоих направлениях. Изначально я был сбит с толку определением свойства scroll-timeline-axis, так как оно поддерживало только однонаправленные значения (x, y, block и inline), чтобы обойти это ограничение, я использовал два прокручиваемых контейнера, каждый из которых обрабатывал свою ось. Этот метод до сих пор актуален для мобильных устройств, так как он ограничивает движение и предотвращает нежелательное горизонтальное смещение при вертикальном свайпе.

На полпути к завершению проекта я открыл для себя изящное решение от Брамуса для создания двунаправленного взаимодействия с использованием одного элемента: временные интервалы прокрутки, разделённые запятыми. Это стало очевидным только после знакомства с ним, ибо ранее я не учитывал, что новое CSS-свойство может поддерживать разделение запятыми.

Возвращение к центру

Отличительные черты этой методики от традиционной прокрутки, управляющей анимацией, заключаются в двух аспектах: намерении и возможности повторения.

  • Намерение: наша цель не в том, чтобы анимировать контент при помощи прокрутки, а в том, чтобы распознать жест свайпа. DOM остаётся статичным, изменяются лишь значения --x и --y.

  • Возможность повторения: благодаря возвращению в исходное положение, мы можем повторять жест свайпа столько раз, сколько потребуется. Нам не нужен блок прокрутки, который возвращается в начало после каждого взаимодействия; вместо этого нам важна способность осуществлять свайпы заново.

Адам Аргайл также сочетал анимацию на основе прокрутки с зафиксированной прокруткой, имитируя жест "свайп вниз для обновления" на мобильных устройствах в своей работе. Заслуживает восхищения!

Основная хитрость

Мы применяем два раздельных временных диапазона прокрутки, чтобы управлять изменениями вдоль горизонтальной и вертикальной осей. Чтобы эти диапазоны заработали, необходимо, чтобы контент превышал размеры прокручиваемого контейнера — это стандартное ожидание. В зависимости от задач, поставленных в вашей игре, вы можете использовать сетку любых размеров (хотя рекомендуется формат 3x3). Также у нас есть возможность выбора: возвращать ли элемент в исходное положение после взаимодействия. В Desert Racer, например, ось прыжка (вертикальная ось y) приводит к возвращению на землю, в то время как изменение дорожек (горизонтальное движение) не влечёт за собой возврат в исходное положение. Мы также можем воспользоваться декларацией свойства @property от Houdini для достижения интерполированных значений от 0 до 1, что позволяет сделать свайп заметным даже при незначительном движении. Это даёт возможность создавать разнообразные движения, например, рисование кругов.

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

Меню настроек также было написано чистым CSS, потому что "а почему бы и нет?"

Обнаружение столкновений

Для определения столкновений я проверяю совпадение текущей ячейки, на которой расположено транспортное средство, с ячейкой, содержащей препятствие.
--collision-on-cell-4: calc(var(--vehicle-on-cell-4) * var(--obstacle-on-cell-4))

Процесс по шагам:

Определение текущую позицию транспортного средства, преобразуя координаты свайпа --x и --y в соответствующие ячейки сетки.

--cell-pattern-n представляет собой --vehicle-on-cell-n. Например, если наши координаты свайпа (-1, 0):

   --vehicle-on-cell-4: 1;

Расположение препятствия на сетке размером 3x3. В качестве примера, если необходимо разместить дерево слева от дороги:

   --obstacle-on-cell-1: 1;
   --obstacle-on-cell-4: 1;
   --obstacle-on-cell-7: 1;

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

--collision-on-cell-4: calc(
     var(--vehicle-on-cell-4) * var(--obstacle-on-cell-4)
   );

Эту логику можно наблюдать в действии ниже:

Анимация карты столкновений во времени

Запуск анимации значения --obstacle-on-cell-k, где k принимает значения от 1 до 9.

Чтобы облегчить себе задачу, я создал анимацию декларативным способом, используя .SCSS.

$OBSTACLES: {
  (),
  (),
  (("tree", 1), ("tree-arch", 3)),
  (("tree-arch", 1), ("tree", 2), ("tree-arch", 3)),
  (),
  (),
  (("arch", 1), ("arch", 3)),
  (("rock", 1), ("rock", 3)),
  (("arch", 1), ("rock", 2), ("arch", 3)),
  (),
  (),
}

Каждый элемент соответствует определенному ключевому кадру анимации.

Ниже вы можете увидеть последовательность анимации препятствий во времени:

Синий слой предназначен для обозначения препятствий, а красный – для указания столкновений.

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

Немедленное прекращение игры после любого столкновения

Я проверяю каждую ячейку на наличие столкновения и записываю результаты в переменные --collision-on-cell-k, где k может принимать значения от 1 до 9. Если общая сумма всех потенциальных столкновений превышает ноль, это означает, что столкновение произошло!

Теперь перед нами стоит наиболее сложная задача.

Когда анимация переходит к следующему ключевому кадру, следы столкновения исчезают. Так как же сохранить состояние столкновения? Учитывая, что я не могу управлять нечисловыми CSS-свойствами, я не могу просто установить значение animation-play-state на paused. Изменение длительности анимации с помощью animation-duration: calc(var(--virtually-infinite) * 1s) также влияет на текущую анимацию. (Например, если анимация находится на 50% своего выполнения и я внезапно увеличиваю её длительность в 10 раз, то общий прогресс анимации уменьшится до 5%).

Что же я сделал?

Я мгновенно вызываю экран "Game Over" и устанавливаю время его отображения на 31.7 года! Это значит, что если вы не готовы ждать так долго, экран "Game Over" будет восприниматься как постоянное состояние.

Код:

:root {
  --virtually-infinite: 1000000000s; // 31.7 years
}

.game-over {
  background: black;
  bottom: calc(var(--zero-collisions) * 200lvh);
  transition: bottom calc(
      var(--zero-collisions) * var(--virtually-infinite) + 1ms
    ) linear;
  z-index: 100;
}

В тот момент, когда переменная --zero-collisions принимает значение 0, нижняя граница устанавливается на 0, а время перехода — всего 1 миллисекунду. В результате ловушка активируется и падает. После этого --zero-collisions возвращается к значению 1, однако для восстановления ловушки в исходное положение требуется целых 31.7 года. Если же --zero-collisions сбрасывается до 0 в фоновом режиме из-за повторного столкновения, мы этого не заметим, так как ловушка уже находится в срабатывшем состоянии.

Фиксация победы

Процесс оказался простым. В конце раунда я присваиваю переменной --you-win значение true. Экран победы появляется поверх всевозможных экранов "Game Over" и остаётся в верхней части.

@keyframes move-obstacles {
  // ...

  99.999% {
    --you-win: 0;
  }
  100% {
    --you-win: 1; // last keyframe
  }
}

.victory {
  transition: opacity 250ms ease-out;
  bottom: calc((1 - var(--you-win)) * 200lvh);
  opacity: calc(0.875 * var(--you-win));
  z-index: 99;
}

Тщательная оптимизация UX на мобильных устройствах с приоритетным вниманием к функциям свайпа

Отключение встроенной свайп-навигации.

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

Вот как можно предотвратить эти действия:

html,
body {
  overscroll-behavior: contain;
}

Использование значения "contain" блокирует встроенные функции навигации браузера, такие как вертикальное обновление страницы жестом "потяни вниз" и навигацию между страницами с помощью горизонтальных свайпов.

Коррекция изменения макета из-за вертикального свайпа

Вертикальные свайпы вызывают появление и исчезновение адресной строки, что влияет на размеры отображаемого интерфейса.

Решение: закрепить элементы макета в нижней части видимого экрана:

.container {
  position: relative; // or absolute;
  height: 100lvh;
}

.game-view {
  position: absolute;
  height: 100svh;
  bottom: 0;
}

Блокировка масштабирования двумя пальцами

<meta
  name="viewport"
  content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
.view {
  touch-action: pan-x pan-y;
}

Вторичные препятствия

Управление загрузкой объемных CSS файлов

Для эффективной работы с большими CSS файлами я использовал подход MPA (multi-page application), который подразумевает загрузку отдельного CSS файла для каждой страницы. Это позволило мне добавлять новые уровни в игру без увеличения размера одного CSS файла. Однако при переходе между страницами возникла проблема сохранения состояния, так как использование флагов не было возможным. Я нашёл решение в сохранении состояния через параметры URL и использовании CSS селектора :target:

body:has(
    :is(
        #color-1:target,
        #color-1--lowres:target,
        #color-1--mouse:target,
        #color-1--lowres--mouse:target,
        #color-1--muted:target,
        #color-1--lowres--muted:target,
        #color-1--mouse--muted:target,
        #color-1--lowres--mouse--muted:target
      )
  ) {
  --car-color: 1;

  .dynamic-link:nth-of-type(1) {
    display: inline-block;
  }
}

Анимация до 126 препятствий одновременно в 3D

На самом деле, я не анимирую все 126 препятствий в трехмерном пространстве одновременно, так как это слишком ресурсоемко для GPU. Вместо этого я использую визуальный обман: все препятствия изначально скрыты и расположены на фиксированном расстоянии. Затем они анимируются по направлению к экрану с разным интервалом задержки animation-delay. В результате, в любой момент времени активно анимируется только несколько десятков препятствий.

Дополнительные техники

Использование свойств типа will-animate, contain: strict, backface-visibility: hidden также помогает, но это уже выходит за рамки данного обсуждения.

Автоматическое воспроизведение звуков

Автоматическое проигрывание звуков достигается путём вставки тегов <audio> с атрибутом autoplay в HTML-документы, при этом сами элементы скрываются средствами CSS. Чтобы предоставить возможность отключения звука, можно создать отдельную версию HTML-документа, где теги <audio> отсутствуют.

Сохранение состояния между переходами по страницам

Сохранение таких данных, как выбранный цвет автомобиля и настройки игры, осуществляется путём включения их в URL и последующего извлечения с использованием CSS селектора :target. Однако для управления звуком я применил метод рендеринга страниц с наличием или отсутствием тегов <audio>, поскольку без применения JavaScript нет возможности включить или выключить звук напрямую.

Уроки, извлечённые из опыта

Удивительно, как многое можно достичь, используя современные возможности CSS в области математики и логики. GrahamTheDev является ярким тому подтверждением!

Один важный урок, который я хотел бы выделить: сохраняйте CSS-переменные без указания единиц измерения до тех пор, пока они действительно не понадобятся в коде. Я не единственный, кто это отмечает, но это правило заслуживает особого внимания.

width: calc(var(--complex-logic) * 1vw);

Благодарности

Хотелось бы выразить признательность нескольким разработчикам, чьи образовательные материалы оказали неоценимое влияние на реализацию этого проекта.

Благодарность Брамусу

  • за его изысканные уроки по анимациям, управляемым скроллингом.

  • за его методы настройки плавных двусторонних анимаций, контролируемых прокруткой.

Благодарность Джейми Коултеру

  • за его вклад в повышение качества игр на CSS.

    • Его искусное использование CSS-флажков вдохновило меня на осознанный выбор не применять их в своём проекте.

Благодарность Эми Каперник

  • за распространение знаний о трюке с хранением состояния HTML с использованием псевдокласса :target.

    • Спасибо ей, цвет автомобиля и настройки игры были успешно сохранены именно таким способом.

Благодарность Кевину Пауэллу

  • за распространение информации об именованных линиях сетки.

    • Создание динамичной сетки на главной странице в стиле "Bento" было бы невозможно без этих знаний!

.bento-box {
     grid-template-columns: [header-start display-start] 4fr [display-end share-start actions-start specs-start] 3fr [header-end share-end config-start] 1fr [config-end actions-end specs-end];
     grid-template-rows: [header-start config-start] 1fr [header-end display-start share-start] 0.75fr [config-end share-end actions-start] 1fr [actions-end specs-start] 1.5fr [display-end specs-end];
   }

   @media screen and (max-width: 1300px) {
     .bento-box {
       grid-template-columns: [header-start config-start display-start] 3.25fr [config-end display-end actions-start specs-start share-start] 3.875fr [header-end display-end actions-end specs-end share-end];
       grid-template-rows: [header-start] 0.875fr [header-end config-start actions-start] 0.625fr [config-end display-start] 0.125fr [actions-end specs-start] 1.5fr [specs-end share-start] 1fr [display-end share-end];
     }
   }

   @media screen and (max-width: 1000px) {
     .bento-box {
       grid-template-columns: [header-start config-start display-start specs-start] 2.75fr [config-end display-end specs-end actions-start share-start] 1.5fr [header-end actions-end share-end];
       grid-template-rows: [header-start] 1.125fr [header-end config-start actions-start] 0.75fr [config-end display-start] 2fr [actions-end share-start] 2fr [display-end specs-start] 2.5fr [share-end specs-end];
     }
   }

Выражаю благодарность игре LEGO® Friends Heartlake Rush за вдохновение, которое я черпал из её пользовательского интерфейса и игрового процесса!

Авторство и инструменты

Дизайн ресурсов и пользовательский интерфейс

  • Земля и небо были созданы как CSS-арт warkentien2.

  • Дизайн дороги с ошибками выполнен в SVG формате с помощью Figma, авторство warkentien2.

  • Все препятствия разработаны warkentien2.

  • Автоматически созданные препятствия сгенерированы AI Art Generator.

  • Удаление фона изображений осуществлено с помощью инструмента remove.bg на базе искусственного интеллекта.

  • Преобразование изображений в пиксель-арт выполнено через сервис Pixelied.

  • Создание спрайт-листов произведено с помощью Pixlr Express.

  • Транспортное средство спроектировано warkentien2.

  • Генерация вариантов транспортного средства выполнена через Recraft.

  • Фоновые изображения обработаны алгоритмами remove.bg.

  • Превращение изображений в пиксельный стиль обеспечено Pixelied.

  • Редактирование деталей и обрезка изображений проведены через PixilArt.

  • Компоновка и улучшение визуальных материалов реализованы в Pixlr Express.

  • Вариации цвета и создание спрайтов оформлены через Pixlr Express.

  • Анимация пыли адаптирована из учебных материалов Эстебана Диаза на Youtube.

  • Изображения пейзажа предоставлены warkentien2.

  • Искусственно созданные пейзажи разработаны с использованием Recraft.

  • Первоначальные эскизы выполнены вручную:

Сниппеты и саундтреки

  • Домашняя — revving — от warkentien2

  • Все фазы — driving noises — от warkentien2

  • Фаза 1 — Dark Country Rock — от moodmode

  • Фаза 2 — Western Cowboy — от Music_For_Videos

  • Фаза 3 — Tumbleweed Tango — от moodmode

  • Фаза 4 — Excess Voltage — от moodmode

  • Фаза 5 — Spirit of the Road — от SergePavkinMusic

  • Фаза X — Cowboy Redemption — от Music_Unlimited


Спасибо за прочтение!