[Перевод] Как написать интерфейс пользователя (UI) PlayStation 5 на JavaScript

Интерактивное демо PS5.js


Вот демо интерфейса PS5, созданного при помощи анимаций на JavaScript и CSS, которые мы будем писать в этом туториале. Интерактивный пример можно потрогать в оригинале статьи.
iraff96dzpknphut5fkbuxgxpgs.gif

Поставьте звёздочку или форкните проект ps5.js 35,9 КБ на GitHub.

Я написал твит о демо PS3, когда создавал на JavaScript базовую версию UI консоли PS3. Пока у меня нет кода, но я планирую его опубликовать. Более того, данный туториал построен на основании знаний, полученных при создании первой работы.

Подготовка


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

Но даже если вы используете фреймворки или библиотеки, то вам всё равно нужно разработать свой паттерн решения задачи. В этом туториале про UI я расскажу о самой концепции, лежащей в основе разработки. Этот подход можно легко адаптировать к React, Vue или Angular.

Я использовал эту заготовку HTML-файла с заранее созданными flex-стилями. Он содержит всё необходимое и общую структуру приложения, позволяющую приступить к работе. Это не React или Vue, но это та минимальная конфигурация, которая необходима для создания приложения. Я использую эту заготовку каждый раз, когда мне нужно начать работу над новым ванильным приложением или сайтом.

HTML и CSS


В этом разделе я объясню некоторые основы заготовки HTML-файла.

Простой самодельный CSS-фреймворк


Я не особый фанат CSS-фреймворков и предпочитаю начинать с чистого листа. Однако спустя тысячи часов кодинга вы в любом случае начинаете замечать часто повторяющиеся паттерны. Почему бы не создать несколько простых классов, охватывающих наиболее распространённые случаи? Благодаря этому нам не придётся сотни раз вводить одни и те же имена свойств и значения.
.rel { position: relative }
.abs { position: absolute }

.top { top: 0 }
.left { left: 0 }
.right { right: 0 }
.bottom { bottom: 0 }

/* flex */
.f { display: flex; }
.v { align-items: center }
.vs { align-items: flex-start }
.ve { align-items: flex-end }
.h { justify-content: center }
.hs { justify-content: flex-start }
.he { justify-content: flex-end }
.r { flex-direction: row }
.rr { flex-direction: row-reverse }
.c { flex-direction: column }
.cr { flex-direction: column-reverse }
.s { justify-content: space-around }

.zero-padding { padding: 0 }

.o { padding: 5px }
.p { padding: 10px }
.pp { padding: 20px }
.ppp { padding: 30px }
.pppp { padding: 50px }
.ppppp { padding: 100px }

.m { margin: 5px }
.mm { margin: 10px }
.mmm { margin: 20px }
.mmmm { margin: 30px }

Эти классы CSS говорят сами за себя.

Наши первые CSS-стили


Теперь, когда у нас задана основа CSS, добавим несколько стилей, изменяющих внешний вид скрытых и отображаемых контейнеров меню. Помните, что поскольку у нас есть много меню и мы можем переключаться между ними, нам нужно как-то обозначать, какие меню «включены», а какие «выключены».

Под множественными меню я подразумеваю, что у каждого меню есть собственный экран, задаваемый отдельным HTML-элементом. При переключении на следующее меню предыдущий контейнер скрывается и отображается новый. Для создания плавных UX-переходов могут также использоваться CSS-переходы, изменяющие непрозрачность, позицию и масштаб.

Все контейнеры с классом .menu по умолчанию будут в состоянии «отключен» («off») (то есть скрыты). Любой элемент с классами .menu и .current будет находиться в состоянии «включен» («on») и отображаться на экране.

Другие элементы, например, выбираемые в меню кнопки, сами используют класс .current, но в другом контексте иерархии CSS. Мы изучим их CSS-стили в следующих частях туториала.

#ps5 {
   width: 1065px;
   height: 600px;
   background: url('https://semicolon.dev/static/playstation_5_teaser_v2.jpg');
   background-size: cover;
}

/* default menu container - can be any UI screen */
#ps5 section.menu {
    display: none;
    opacity: 0;

    // gives us automatic transitions between opacities
    // which will create fade in/fade out effect.
    // without writing any additional JavaScript
    transition: 400ms;      
}

#ps5 section.menu.current {
    display: flex;
    opacity: 1;
}

section.menu снова является стандартным родительским контейнером для всех слоёв меню, которые мы создадим. Это может быть экран «браузера игр» или экран «настроек». По умолчанию он невидим, пока мы не применим к свойству classlist элемента класс .current.

А section.menu.current обозначает текущее выбранное меню. Все остальные меню должны быть невидимыми, и класс .current никогда не должен одновременно применяться более чем к одному меню!

HTML


Наш самодельный крошечный CSS-фреймворк сильно упрощает HTML. Вот основной каркас:

    

Элемент ps5 — это основной контейнер приложения.

Основная часть flex — это f v h для центрирования элементов, поэтому это сочетание мы будем встречать часто.

Также мы встретим f r вместо flex-direction:row; и f c вместо flex-direction:column;.

Подразделы — это отдельные области меню, требующие класса menu. Мы сможем переключаться между ними.

В коде они будут перечисляться замороженным объектом (мы увидим это ниже).

Замена фона


Одна из первых задач, с которой я хотел разобраться — это функция смены фона. Если я смогу реализовать её сначала, то просто интегрирую её позже во все будущие функции, которым нужно будет менять фон. Для этого я решил создать два div.

Когда новый фон становится активным, я просто меняю местами два div, заменяя значение свойства style.background на URL нового изображения, и применяю к новому фону класс .fade-in, убирая его у предыдущего.

Я начал со следующего CSS:

#background-1, #background-2 {
    position: absolute;
    top: 0;
    left: 0;
    width: inherit;
    height: inherit;
    background: transparent;
    background-position: center center;
    background-size: cover;
    pointer-events: none;
    transition: 300ms;
    z-index: 0;
    opacity: 0;
    transform: scale(0.9)
}

/* This class will be applied from Background.change() function */
.fade-in { opacity: 1 !important; transform: scale(1.0) !important; z-index: 1 }

/* set first visible background */
#background-2 { background-image: url(https://semicolon.dev/static/playstation_5_teaser_v2.jpg); }

Затем я создал вспомогательную статическую функцию .change, берущую начало от класса Background, который заменяет местами два div и выполняет их плавное понижение или повышение яркости (функция получает один аргумент — URL следующего изображения):
class Background {constructor() {}}

Background.change = url => {

    console.log(`Changing background to ${url}`)

    let currentBackground = $(`.currentBackground`);
    let nextBackground = $(`.nextBackground`);

    // set new background to url
    nextBackground.style.backgroundImage = `url(${url})`

    // fade in and out
    currentBackground.classList.remove('fade-in')
    nextBackground.classList.add('fade-in')

    // swap background identity
    currentBackground.classList.remove('currentBackground')
    currentBackground.classList.add('nextBackground')
    nextBackground.classList.remove('nextBackground')
    nextBackground.classList.add('currentBackground')
    
}

Теперь каждый раз, когда мне понадобится показать новый фон, я просто буду вызывать эту функцию с URL изображения, которое нужно отобразить:
Background.change('https://semicolon.dev/static/background-1.png')

Постепенное увеличение яркости (fade in) будет реализовано автоматически, потому что transform: 300ms уже применено к каждому фону, а класс .fade-in занимается всем остальным.

Создаём основное меню навигации


Теперь, когда базовый каркас готов, мы можем начинать создание остальной части UI. Но нам нужно ещё написать класс для управления UI. Назовём этот класс PS5Menu. Как им пользоваться, объясню ниже.

Экран System


Для создания кнопки Start был использован простой CSS. После нажатия кнопки пользователем мы переходим в основное меню PS5. Поместим кнопку Start в первое меню на экране — в меню System:

Аналогично контент всех остальных меню будет располагаться в соответствующих родительских элементах-контейнерах.

До этого мы доберёмся позже. А сейчас нам надо решить, как организовать несколько экранов меню.

На данном этапе нам нужно узнать о концепции постановки в очередь нескольких меню. У PS5 есть несколько слоёв различных навигационных UI. Например, при выборе Settings открывается новое, совершенно другое меню, и управление с клавиатуры переносится в это новое меню.

Нам нужен объект для отслеживания всех этих меню, которые постоянно открываются, закрываются, а затем заменяются новым или предыдущим меню.

Можно воспользоваться встроенным методом push объекта Array в JavaScript, чтобы добавлять в очередь новое меню. А когда нам понадобится вернуться, мы сможем вызвать метод pop массива, чтобы вернуться к предыдущему меню.

Мы перечисляем меню по атрибуту id элемента:

const MENU = Object.freeze({
    system: `system`,
      main: `main`,
   browser: `browser`,
  settings: `settings`,

/* add more if needed*/

});

Я использовал Object.freeze(), чтобы ни одно из свойств после их задания не изменялось. Некоторые типы объектов лучше всего замораживать. Это те объекты, которые точно не должны меняться на протяжении срока жизни приложения.

Здесь каждое значение — это имя свойства в строковом формате. Таким образом, мы сможем ссылаться на элементы меню по MENU.system или MENU.settings. В этом подходе нет ничего, кроме синтаксической эстетики, кроме того, это простой способ, позволяющий не хранить все объекты меню «в одной корзине».

Класс PS5Menu


Для начала я создал класс PS5Menu. Его конструктор использует свойство this.queue типа Array.
// menu queue object for layered PS5 navigation
class PS5Menu {

    constructor() {
        this.queue = []
    }

    set push(elementId) {
        // hide previous menu on the queue by removing "current" class
        this.queue.length > 0 && this.queue[this.queue.length - 1].classList.remove(`current`)

        // get menu container
        const menu = $(`#${elementId}`) 

        // make the new menu appear by applying "current" class
        !menu.classList.contains(`current`) && menu.classList.add(`current`)
        
        // push this element onto the menu queue
        this.queue.push( menu ) 

        console.log(`Pushed #${elementId} onto the menu queue`)
    }

    pop() {
        // remove current menu from queue
        const element = this.queue.pop()

        console.log(`Removed #${element.getAttribute('id')} from the menu queue`)
    }
}

Как использовать класс PS5Menu?


Этот класс имеет два метода, сеттер push(argument) и статическую функцию pop(). Они будут делать практически то же, что и методы массива .push() и .pop делают с нашим массивом this.queue.
Например, чтобы создать экземпляр класса меню и добавить или удалить из его стека меню, мы можем вызывать методы push и pop непосредственно из экземпляра класса.
// instantiate the menu object from class
const menu = new PS5Menu()

// add menu to the stack
menu.push = `system`

// remove the last menu that was pushed onto the stack from it
menu.pop()

Функции сеттеров классов наподобие set push() нельзя вызывать с (). Они присваивают значение при помощи оператора присваивания =. Функция сеттера класса set push() выполнится с этим параметром.

Давайте объединим всё, что мы уже сделали:

/* Your DOM just loaded */
window.addEventListener('DOMContentLoaded', event => {      

    // Instantiate the queable menu
    const menu = new PS5Menu()

    // Push system menu onto the menu
    menu.push = `system`

    // Attach click event to Start button
    menu.queue[0].addEventListener(`click`, event => {

        console.log(`Start button pressed!`)

        // begin the ps5 demo!
        menu.push = `main`
    });

});

Здесь мы создали экземпляр класса PS5Menu и сохранили его экземпляр объекта в переменной menu.

Затем мы поместили в очередь нескольких меню первое меню с id #system.

Далее мы прикрепили к кнопке Start событие click. При нажатии на эту кнопку мы делаем основное меню (с id, равным main) нашим текущим меню. При этом меню системы скроется (меню в данный момент находится в очереди меню) и отобразится контейнер #menu.

Обратите внимание, что поскольку наш класс контейнера меню .menu.current имеет свойство transform: 400ms;, то при простом добавлении или удалении класса .current у элемента только что добавленные или удалённые свойства будут анимироваться в течение 0,4 миллисекунд.

Теперь нужно подумать над тем, как создавать контент для основного меню.

Обратите внимание, что этот шаг выполняется в событии DOM «Content Loaded» (DOMContentLoaded). Оно должно быть точкой входа для любого приложения с UI. Вторая точка входа — это событие window.onload, но в данном демо оно нам не нужно. Оно ожидает завершения скачивания медиа (изображений и т. п.), что может произойти гораздо позже того, как стали доступными элементы DOM.

Экран заставки


Изначально основной UI представляет собой ряд из нескольких элементов. Весь ряд появляется с правого края экрана. При первом появлении он анимируется перемещением влево.

Я встроил эти элементы в контейнер #main следующим образом:


Первое меню PS5 помещается внутри родительского контейнера, стиль которого задаётся следующим образом:
#primary {
    position: absolute;
    top: 72px;
    left: 1200px;
    width: 1000px;
    height: 64px;
    opacity: 0;

    /* animate at the rate of 0.4s */
    transition: 400ms;
}

#primary.hidden {
    left: 1200px;
}

По умолчанию в своём скрытом состоянии (hidden) #primary намеренно не показывается, он и передвинут достаточно далеко вправо (на 1200 px).

Нам пришлось двигаться путём проб и ошибок, а также воспользоваться интуицией. Похоже, неплохо подходит значение 1200 px. Этот контейнер также наследует opacity:0 от класса .menu.

Поэтому когда #primary появляется в первый раз, он одновременно скользит и увеличивает свою яркость.

Здесь снова использовано значение transform:400ms; (эквивалентное 0.4s), потому что большинство микроанимаций выглядит красиво при 0.4s. Значение 0.3s тоже неплохо подходит, но может быть слишком быстрым, а 0.5s — слишком медленным.

Используем CSS-переходы для управления анимациями UI


Вместо того, чтобы манипулировать CSS-стилями вручную каждый раз, когда нам понадобится изменить стиль или положение блока UI, мы можем просто назначать и удалять классы:
// get element:
const element = $(`#primary`)

// check if element already contains a CSS class:
element.style.classList.contains("menu")

// add a new class to element's class list:
element.style.classList.add("menu")

// remove a class from element's class list:
element.style.classList.remove("menu")

Это важная стратегия, которая сэкономит кучу времени и сохранит чистоту кода в любом ванильном проекте. Вместо изменения свойства style.left мы просто удалим класс .hidden у элемента #primary. Так как он имеет transform:400ms;, автоматически воспроизведётся анимация.

Мы будем пользоваться этой тактикой для изменения почти каждого состояния элементов UI.

Вторичная анимация Slide-Out


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

Также существуют анимации при наведении курсора, срабатывающие, когда мышь или контроллер выбирает новый соседний элемент в текущем меню навигации.

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

Используем функцию setTimeout для управления состояниями анимаций


При выдвижении элементов воспроизводится вторичная небольшая анимация. Для имитации этого двойного эффекта использовалась функция JavaScript setTimeout сразу после полной загрузки дерева DOM.

Так как это первый экран меню, появляющийся вскоре после нажатия на кнопку Start, теперь нам нужно обновить событие click кнопки Start в событии DOMContentLoaded сразу после menu.push = `main`.

Следующий код будет находиться внизу уже существующей функции события DOMContentLoaded (см. показанный выше пример исходного кода):

/* Your DOM just loaded */
window.addEventListener('DOMContentLoaded', event => {      

    /* Initial setup code goes here...see previous source code example */

    // Attach click event to Start button
    menu.queue[0].addEventListener(`click`, event => {

        console.log(`Start button pressed!`)

        // begin the ps5 demo!
        menu.push = `main`

        // new code: animate the main UI screen for the first time
        // animate #primary UI block within #main container
        primary.classList.remove(`hidden`)
        primary.classList.add(`current`)

        // animate items up
        let T1 = setTimeout(nothing => {
          
            primary.classList.add('up');

            def.classList.add('current');

            // destroy this timer
            clearInterval(T1)
            T1 = null;

        }, 500)
    });    

});

Что из этого вышло


Весь написанный нами код привёл к созданию такой начальной анимации:
_qjnbgpdf2xvwueqqjkvy5twovg.gif

Создаём выбираемые элементы


Мы уже создали CSS для выбираемых элементов (класс .sel).

Но он пока выглядит простовато, не так блестящ, как интерфейс PS5.

В следующем разделе мы рассмотрим возможности создания более красивого интерфейса. Мы поднимем уровень UI до профессионального внешнего вида системы навигации PlayStation 5.

Стандартная анимация «выбранного» или «текущего» элемента


Три типа анимаций текущего выбранного элемента


В UI консоли PS5 текущие выбранные элементы имеют три визуальных эффекта. Вращающийся контур-«гало», случайное пятно света, движущееся на фоне и, наконец, «световая волна» — эффект, выглядящий как волна, движущаяся в направлении нажатой на контроллере кнопки направления.

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

Анимированное гало с градиентом


Этот эффект добавляет анимированную границу, которая вращается вокруг выбранного элемента.

В CSS это можно имитировать вращением конического градиента.

Вот общая схема CSS выбираемого элемента:

.sel {
    position: relative;
    width: 64px;
    height: 64px;
    margin: 5px;
    border: 2px solid #1f1f1f;
    border-radius: 8px;
    cursor: pointer;
    transition: 400ms;
    transform-style: preserve-3d;
    z-index: 3;
}

.sel.current {
    width: 100px;
    height: 100px;    
}

.sel .under {
    content:'';
    position: absolute;
    width: calc(100% + 8px);
    height: calc(100% + 8px);
    margin: -4px -4px;
    background: #1f1f1f;
    transform: translateZ(-2px);
    border-radius: 8px;
    z-index: 1;
}

.sel .lightwave-container {
    position: relative;
    width: 100%;
    height: 100%;
    transition: 400ms;
    background: black;
    transform: translateZ(-1px);
    z-index: 2;
    overflow: hidden;
}

.sel .lightwave {
    position: absolute;
    top: 0;
    right: 0;
    width: 500%;
    height: 500%;    
    background: radial-gradient(circle at 10% 10%, rgba(72,72,72,1) 0%, rgba(0,0,0,1) 100%);
    filter: blur(30px);
    transform: translateZ(-1px);
    z-index: 2;
    overflow: hidden;
}

Я пытался использовать псевдоэлементы ::after и ::before, но не смог простыми способами добиться нужных мне результатов, а их поддержка браузерами находится под вопросом; к тому же, в JavaScript нет нативных способов доступа к псевдоэлементам.

8ec0waay4tu2lggpxm8cfx5y3r4.png
Вместо них я решил создать новый элемент .under и уменьшить его позицию по оси Z на -1 при помощи transform: translateZ(-1px); таким образом, мы отодвинули его от камеры, позволив его родительскому элементу отображаться поверх него.

Возможно, также понадобится добавить родительским элементам, идентифицируемым по .sel, свойство transform-style: preserve-3d;, чтобы включить z-order в 3D-пространстве элемента.

В идеале нам бы хотелось сделать слой .under родителем элемента и создать пятно света с самим элементом кнопки внутри него. Но у трюка с translateZ приоритет выше, к тому же, именно так я начал создавать UI. Его можно переработать, но на данном этапе это необязательно.

С HTML всё достаточно просто. Важно здесь то, что теперь у нас есть новый элемент .under. Это элемент, на котором будет рендериться вращающийся конический градиент для создания тонкой сияющей границы.

.lightwave-container поможет нам реализовать эффект перемещения света при помощи overflow: hidden. .lightwave — это элемент, на котором будет рендериться сам эффект, он является более крупным div, выходящим за границы кнопки и содержащим смещённый радиальный градиент.


На начало марта 2021 года CSS-анимация не поддерживает градиентного вращения фона.

Чтобы обойти это затруднение, я воспользовался встроенной функцией JavaScript window.requestAnimationFrame. Она плавно анимирует свойство фона в соответствии с частотой кадров монитора, которая обычно равна 60FPS

// Continuously rotate currently selected item's gradient border
let rotate = () => {

    let currentlySelectedItem = $(`.sel.current .under`)
    let lightwave = $(`.sel.current .lightwave`)

    if (currentlySelectedItem) {

        let deg = parseInt(selectedGradientDegree);
        let colors = `#aaaaaa, black, #aaaaaa, black, #aaaaaa`;

        // dynamically construct the css style property
        let val = `conic-gradient(from ${deg}deg at 50% 50%, ${colors})`;

        // rotate the border
        currentlySelectedItem.style.background = val

        // rotate lightwave
        lightwave.style.transform = `rotate(${selectedGradientDegree}deg)`;

        // rotate the angle
        selectedGradientDegree += 0.8
    }
    window.requestAnimationFrame(rotate)
}
window.requestAnimationFrame(rotate)

Эта функция отвечает за анимирование вращающейся границы и более крупного элемента световой волны.

Парадигма Event Listener


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

Каждый экран уникален. Проще всего жёстко прописать события для каждого экрана. Это не хак, а просто код, относящийся к каждой уникальной системе навигации. Для некоторых вещей просто нет удобных решений.

Две следующие функции будут подключать и отключать события от различных экранов.

См. полный исходный код PS5.js, чтобы понять, как всё устроено в целом.

function AttachEventsFor(parentElementId) {

    switch (parentElementId) {
        case "system":

          break;
        case "main":

          break;
        case "browser":

          break;
        case "settings":

          break;
    }
}

function RemoveEventsFrom(parentElementId) {

    switch (parentElementId) {
        case "system":

          break;
        case "main":

          break;
        case "browser":

          break;
        case "settings":

          break;
    }
}

Благодаря этому мы никогда не будем прослушивать больше событий мыши, чем у нас есть, чтобы код UX оптимально выполнялся для каждого отдельного экрана меню.

Навигация с помощью клавиатуры


Клавиатурное управление редко используется в веб-приложениях и на сайтах. Поэтому я создал клавиатурную библиотеку на ванильном JS, распознающую основные клавиши и позволяющую просто подключать события нажатия клавиш.

Нам необходимо перехватывать следующие клавиши:

  • Enter или Space — выбирают текущий выбранный элемент.
  • Left, Right, Up, Down — навигация по текущему выбранному меню.
  • Escape — отменяет текущее меню, находящееся в очереди, и выполняет возврат к предыдущему меню.

Привязать все основные клавиши к переменным можно следующим образом:
// Map variables representing keys to ASCII codes
const [ A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z ] = Array.from({ length: 26 }, (v, i) => 65 + i);

const Delete = 46;
const Shift = 16;
const Ctrl = 17;
const Alt = 18;

const Left = 37;
const Right = 39;
const Up = 38;
const Down = 40;

const Enter = 13;
const Return = 13;
const Space = 32;
const Escape = 27;

А затем создать обработчик событий клавиатуры:
function keyboard_events_main_menu(e) {

    let key = e.which || e.keyCode;

    if (key == Left) {
        if (menu.x > 0) menu.x--
    }

    if (key == Right) {
        if (menu.x < 3) menu.x++
    }

    if (key == Up) {
        if (menu.y > 0) menu.y--
    }

    if (key == Down) {
        if (menu.y < 3) menu.y++
    }

}

И подключить его к объекту документа:
document.body.addEventListener("keydown", keyboard_events_main_menu);

Звуковой API


Всё ещё работаю над ним…

А пока вы можете скачать отсюда простую библиотеку звукового API на ванильном JS.

© Habrahabr.ru