Como criar um componente de menu de jogo 3D

Visão geral básica de como criar um menu de jogo 3D responsivo, adaptável e acessível.

Nesta postagem, quero compartilhar ideias sobre como criar um componente de menu de jogo 3D. Teste a demonstração.

Demonstração

Se preferir vídeo, aqui está uma versão do YouTube desta postagem:

Visão geral

Os videogames geralmente apresentam aos usuários um menu criativo e incomum, animado e no espaço 3D. Nos novos jogos de RA/RV, é muito comum fazer o menu parecer flutuar no espaço. Hoje vamos recriar os elementos essenciais desse efeito, mas com o toque extra de um esquema de cores adaptável e acomodações para usuários que preferem movimento reduzido.

HTML

Um menu de jogo é uma lista de botões. A melhor maneira de representar isso em HTML é da seguinte maneira:

<ul class="threeD-button-set">
  <li><button>New Game</button></li>
  <li><button>Continue</button></li>
  <li><button>Online</button></li>
  <li><button>Settings</button></li>
  <li><button>Quit</button></li>
</ul>

Uma lista de botões vai aparecer bem nas tecnologias de leitor de tela e funcionar sem JavaScript ou CSS.

uma
lista com marcadores bastante genérica com botões comuns como itens.

CSS

A definição do estilo da lista de botões é dividida nas seguintes etapas:

  1. Configurar propriedades personalizadas.
  2. Um layout flexbox.
  3. Um botão personalizado com pseudoelementos decorativos.
  4. Colocar elementos no espaço 3D.

Visão geral das propriedades personalizadas

As propriedades personalizadas ajudam a eliminar a ambiguidade dos valores, atribuindo nomes significativos a valores que parecem aleatórios, evitando código repetido e compartilhamento de valores entre filhos.

Veja abaixo as consultas de mídia salvas como variáveis CSS, também conhecidas como mídia personalizada. Eles são globais e serão usados em vários seletores para manter o código conciso e legível. O componente do menu do jogo usa preferências de movimento, esquema de cores do sistema e recursos de variedade de cores da tela.

@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --HDcolor (dynamic-range: high);

As propriedades personalizadas abaixo gerenciam o esquema de cores e mantêm os valores de posição do mouse pressionados para tornar o menu do jogo interativo ao passar o cursor. Nomear propriedades personalizadas ajuda a legibilidade do código, porque revela o caso de uso do valor ou um nome fácil para o resultado do valor.

.threeD-button-set {
  --y:;
  --x:;
  --distance: 1px;
  --theme: hsl(180 100% 50%);
  --theme-bg: hsl(180 100% 50% / 25%);
  --theme-bg-hover: hsl(180 100% 50% / 40%);
  --theme-text: white;
  --theme-shadow: hsl(180 100% 10% / 25%);

  --_max-rotateY: 10deg;
  --_max-rotateX: 15deg;
  --_btn-bg: var(--theme-bg);
  --_btn-bg-hover: var(--theme-bg-hover);
  --_btn-text: var(--theme-text);
  --_btn-text-shadow: var(--theme-shadow);
  --_bounce-ease: cubic-bezier(.5, 1.75, .75, 1.25);

  @media (--dark) {
    --theme: hsl(255 53% 50%);
    --theme-bg: hsl(255 53% 71% / 25%);
    --theme-bg-hover: hsl(255 53% 50% / 40%);
    --theme-shadow: hsl(255 53% 10% / 25%);
  }

  @media (--HDcolor) {
    @supports (color: color(display-p3 0 0 0)) {
      --theme: color(display-p3 .4 0 .9);
    }
  }
}

Plano de fundo cônico com tema claro e escuro

O tema claro tem um gradiente cônico de cyan a deeppink vibrante, enquanto o tema escuro tem um gradiente cônico sutil escuro. Para saber mais sobre o que pode ser feito com gradientes cônicos, consulte conic.style.

html {
  background: conic-gradient(at -10% 50%, deeppink, cyan);

  @media (--dark) {
    background: conic-gradient(at -10% 50%, #212529, 50%, #495057, #212529);
  }
}
Demonstração de uma mudança de plano de fundo entre preferências de cores claras e escuras.

Permitindo a perspectiva 3D

Para que os elementos existam no espaço 3D de uma página da Web, é necessário inicializar uma janela de visualização com perspectiva. Escolhi colocar a perspectiva no elemento body e usei unidades da janela de visualização para criar o estilo que gostei.

body {
  perspective: 40vw;
}

Esse é o tipo de impacto que a perspectiva pode ter.

Como definir o estilo da lista de botões <ul>

Esse elemento é responsável pelo layout geral da macro da lista de botões, além de ser um card flutuante interativo e 3D. Vamos mostrar como fazer isso.

Layout do grupo de botões

O Flexbox pode gerenciar o layout do contêiner. Altere a direção padrão de flex de linhas para colunas com flex-direction e garanta que cada item tenha o tamanho do conteúdo dele mudando de stretch para start para align-items.

.threeD-button-set {
  /* remove <ul> margins */
  margin: 0;

  /* vertical rag-right layout */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2.5vh;
}

Em seguida, defina o contêiner como um contexto de espaço 3D e configure as funções CSS clamp() para garantir que o card não gire além das rotações legíveis. O valor do meio do limite é uma propriedade personalizada. Os valores de --x e --y serão definidos no JavaScript após a interação do mouse.

.threeD-button-set {
  …

  /* create 3D space context */
  transform-style: preserve-3d;

  /* clamped menu rotation to not be too extreme */
  transform:
    rotateY(
      clamp(
        calc(var(--_max-rotateY) * -1),
        var(--y),
        var(--_max-rotateY)
      )
    )
    rotateX(
      clamp(
        calc(var(--_max-rotateX) * -1),
        var(--x),
        var(--_max-rotateX)
      )
    )
  ;
}

Em seguida, se o movimento for aceitável para o usuário visitante, adicione uma dica ao navegador de que a transformação desse item vai mudar constantemente com will-change. Além disso, ative a interpolação definindo um transition nas transformações. Essa transição ocorrerá quando o mouse interagir com o cartão, permitindo transições suaves para mudanças de rotação. A animação é uma execução constante que demonstra o espaço 3D em que o cartão está, mesmo que o mouse não possa ou não estiver interagindo com o componente.

@media (--motionOK) {
  .threeD-button-set {
    /* browser hint so it can be prepared and optimized */
    will-change: transform;

    /* transition transform style changes and run an infinite animation */
    transition: transform .1s ease;
    animation: rotate-y 5s ease-in-out infinite;
  }
}

A animação rotate-y define somente o frame-chave do meio em 50%, já que o navegador vai definir 0% e 100% por padrão com o estilo padrão do elemento. Essa é uma abreviação de animações que se alternam, precisam começar e terminar na mesma posição. É uma ótima maneira de articular animações alternadas infinitas.

@keyframes rotate-y {
  50% {
    transform: rotateY(15deg) rotateX(-6deg);
  }
}

Como definir o estilo dos elementos <li>

Cada item da lista (<li>) contém o botão e os elementos de borda dele. O estilo display mudou para que o item não mostre uma ::marker. O estilo position é definido como relative para que os próximos pseudoelementos do botão possam se posicionar dentro da área total que o botão consome.

.threeD-button-set > li {
  /* change display type from list-item */
  display: inline-flex;

  /* create context for button pseudos */
  position: relative;

  /* create 3D space context */
  transform-style: preserve-3d;
}

Captura de tela da lista girada no espaço 3D para mostrar a perspectiva, e
cada item da lista não tem mais um marcador.

Como definir o estilo dos elementos <button>

O estilo dos botões pode ser um trabalho difícil. Há muitos estados e tipos de interação a serem considerados. Esses botões se tornam complexos rapidamente devido ao balanceamento de pseudoelementos, animações e interações.

Estilos iniciais <button>

Confira abaixo os estilos básicos que vão oferecer suporte aos outros estados.

.threeD-button-set button {
  /* strip out default button styles */
  appearance: none;
  outline: none;
  border: none;

  /* bring in brand styles via props */
  background-color: var(--_btn-bg);
  color: var(--_btn-text);
  text-shadow: 0 1px 1px var(--_btn-text-shadow);

  /* large text rounded corner and padded*/
  font-size: 5vmin;
  font-family: Audiowide;
  padding-block: .75ch;
  padding-inline: 2ch;
  border-radius: 5px 20px;
}

Captura de tela da lista de botões em perspectiva 3D, desta vez com botões
estilizados.

Pseudoelementos do botão

As bordas do botão não são tradicionais, elas são pseudoelementos de posição absoluta com bordas.

Captura de tela do painel &quot;Chrome Devtools Elements&quot; com um botão mostrado com os elementos
::before e ::after.

Esses elementos são cruciais para mostrar a perspectiva 3D estabelecida. Um desses pseudoelementos será empurrado para longe do botão, e outro será puxado mais perto do usuário. O efeito é mais perceptível nos botões superior e inferior.

.threeD-button button {
  …

  &::after,
  &::before {
    /* create empty element */
    content: '';
    opacity: .8;

    /* cover the parent (button) */
    position: absolute;
    inset: 0;

    /* style the element for border accents */
    border: 1px solid var(--theme);
    border-radius: 5px 20px;
  }

  /* exceptions for one of the pseudo elements */
  /* this will be pushed back (3x) and have a thicker border */
  &::before {
    border-width: 3px;

    /* in dark mode, it glows! */
    @media (--dark) {
      box-shadow:
        0 0 25px var(--theme),
        inset 0 0 25px var(--theme);
    }
  }
}

Estilos de transformação 3D

Abaixo de transform-style é definido como preserve-3d para que os filhos possam se espaçar no eixo z. O transform é definido como a propriedade personalizada --distance, que será aumentada ao passar o cursor e focar.

.threeD-button-set button {
  …

  transform: translateZ(var(--distance));
  transform-style: preserve-3d;

  &::after {
    /* pull forward in Z space with a 3x multiplier */
    transform: translateZ(calc(var(--distance) / 3));
  }

  &::before {
    /* push back in Z space with a 3x multiplier */
    transform: translateZ(calc(var(--distance) / 3 * -1));
  }
}

Estilos de animação condicional

Se o usuário concordar com o movimento, o botão vai indicar ao navegador que a propriedade de transformação precisa estar pronta para ser modificada, e uma transição será definida para as propriedades transform e background-color. Observe a diferença de duração. Achei que criou um efeito escalonado sutil.

.threeD-button-set button {
  …

  @media (--motionOK) {
    will-change: transform;
    transition:
      transform .2s ease,
      background-color .5s ease
    ;

    &::before,
    &::after {
      transition: transform .1s ease-out;
    }

    &::after    { transition-duration: .5s }
    &::before { transition-duration: .3s }
  }
}

Estilos de interação ao passar o cursor e focar

O objetivo da animação de interação é espalhar as camadas que formavam o botão simples que aparece. Faça isso definindo a variável --distance, inicialmente como 1px. O seletor mostrado no exemplo de código a seguir verifica se o botão está passando ou focando no botão por um dispositivo que precisa ver um indicador de foco, e não estar ativado. Nesse caso, o CSS será aplicado para fazer o seguinte:

  • Aplique a cor do plano de fundo ao passar o cursor.
  • Aumentar a distância .
  • Adicione um efeito de facilidade de rejeições.
  • Distribuir as transições de pseudoelementos.
.threeD-button-set button {
  …

  &:is(:hover, :focus-visible):not(:active) {
    /* subtle distance plus bg color change on hover/focus */
    --distance: 15px;
    background-color: var(--_btn-bg-hover);

    /* if motion is OK, setup transitions and increase distance */
    @media (--motionOK) {
      --distance: 3vmax;

      transition-timing-function: var(--_bounce-ease);
      transition-duration: .4s;

      &::after  { transition-duration: .5s }
      &::before { transition-duration: .3s }
    }
  }
}

A perspectiva 3D ainda era muito interessante para a preferência de movimento reduced. Os elementos superior e inferior mostram o efeito de uma maneira sutil.

Pequenas melhorias com JavaScript

A interface já pode ser usada em teclados, leitores de tela, gamepads, toque e um mouse, mas podemos adicionar alguns toques leves de JavaScript para facilitar alguns cenários.

Teclas de seta de suporte

A tecla Tab é uma ótima maneira de navegar pelo menu, mas espero que o botão direcional ou os joysticks movam o foco em um gamepad. A biblioteca roving-ux, muitas vezes usada para interfaces do GUI Challenge, vai processar as teclas de seta para nós. O código abaixo instrui a biblioteca a capturar o foco em .threeD-button-set e encaminhar o foco para os filhos do botão.

import {rovingIndex} from 'roving-ux'

rovingIndex({
  element: document.querySelector('.threeD-button-set'),
  target: 'button',
})

Interação de paralaxe do mouse

Rastrear o mouse e inclinar o menu serve para imitar as interfaces de videogames de RA e RV, em que, em vez de um mouse, você pode ter um ponteiro virtual. Pode ser divertido quando os elementos estão super cientes do ponteiro.

Como esse é um pequeno recurso extra, colocaremos a interação em uma consulta da preferência de movimento do usuário. Além disso, como parte da configuração, armazene o componente de lista de botões na memória com querySelector e armazene em cache os limites do elemento em menuRect. Use esses limites para determinar o deslocamento de rotação aplicado ao card com base na posição do mouse.

const menu = document.querySelector('.threeD-button-set')
const menuRect = menu.getBoundingClientRect()

const { matches:motionOK } = window.matchMedia(
  '(prefers-reduced-motion: no-preference)'
)

Em seguida, precisamos de uma função que aceite as posições x e y do mouse e retorne um valor que possa ser usado para girar o cartão. A função a seguir usa a posição do mouse para determinar de qual lado da caixa está dentro e o quanto. O delta é retornado da função.

const getAngles = (clientX, clientY) => {
  const { x, y, width, height } = menuRect

  const dx = clientX - (x + 0.5 * width)
  const dy = clientY - (y + 0.5 * height)

  return {dx,dy}
}

Por fim, observe o movimento do mouse, transmita a posição para a função getAngles() e use os valores delta como estilos de propriedade personalizada. Dividi por 20 para preencher o delta e torná-lo menos agitado, pode haver uma maneira melhor de fazer isso. Desde o início, colocamos as propriedades --x e --y no meio de uma função clamp(). Isso impede que a posição do mouse gire o card sobre ele para uma posição ilegível.

if (motionOK) {
  window.addEventListener('mousemove', ({target, clientX, clientY}) => {
    const {dx,dy} = getAngles(clientX, clientY)

    menu.attributeStyleMap.set('--x', `${dy / 20}deg`)
    menu.attributeStyleMap.set('--y', `${dx / 20}deg`)
  })
}

Traduções e rotas

Houve um problema ao testar o menu do jogo em outros modos de escrita e idiomas.

Os elementos <button> têm um estilo !important para writing-mode na folha de estilo do user agent. Isso significava que o HTML do menu do jogo precisava mudar para acomodar o design desejado. Mudar a lista de botões para uma lista de links permite que as propriedades lógicas mudem a direção do menu, já que os elementos <a> não têm um estilo !important fornecido pelo navegador.

Conclusão

Agora que você sabe como fiz isso, como você faria‽ 🙂 Você pode adicionar a interação com o acelerômetro ao menu para que lado a lado faça a rotação do menu? Podemos melhorar a experiência sem movimento?

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie uma demonstração, envie um tweet para mim e os adicionarei à seção de remixes da comunidade abaixo.

Remixes da comunidade

Ainda não temos nada aqui.