Como criar um componente de menu de jogo 3D

Uma visão geral fundamental de como criar um menu de jogo 3D responsivo, adaptável e acessível.

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

Demonstração

Se preferir vídeo, confira uma versão deste post no YouTube:

Visão geral

Os videogames geralmente apresentam aos usuários um menu criativo e incomum, animado e em espaço 3D. É comum em novos jogos de AR/VR fazer o menu parecer flutuar no espaço. Hoje, vamos recriar os elementos essenciais desse efeito, mas com o toque especial 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 forma:

<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 se anuncia bem para tecnologias de leitor de tela e funciona sem JavaScript ou CSS.

uma
lista com marcadores de aparência muito genérica e botões comuns como itens.

CSS

O estilo da lista de botões é dividido nas seguintes etapas gerais:

  1. Configuração de 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 desambiguar valores nomes significativos a valores que, de outra forma, parecem aleatórios, evitando código repetido e compartilhando valores entre filhos.

Abaixo estão 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 de menu do jogo usa preferências de movimento, esquema de cores do sistema e recursos de intervalo 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 a seguir gerenciam o esquema de cores e mantêm valores posicionais do mouse para tornar o menu do jogo interativo ao passar o cursor. Nomear propriedades personalizadas ajuda na legibilidade do código, já que revela o caso de uso do valor ou um nome amigável para o resultado dele.

.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);
    }
  }
}

Planos de fundo cônicos para temas claro e escuro

O tema claro tem um gradiente cônico vibrante de cyan a deeppink, enquanto o tema escuro tem um gradiente cônico escuro e sutil. 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 da mudança de plano de fundo entre preferências de cores claras e escuras.

Ativar 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 de viewport para criar o estilo que eu queria.

body {
  perspective: 40vw;
}

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

Estilizar a lista de botões <ul>

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

Layout do grupo de botões

O Flexbox pode gerenciar o layout do contêiner. Mude a direção padrão do 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 em 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, estabeleça o contêiner como um contexto de espaço 3D e configure funções CSS clamp() para garantir que o card não gire além de rotações legíveis. O valor do meio para a função "clamp" é uma propriedade personalizada. Os valores --x e --y serão definidos pelo 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 estiver OK 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 ocorre quando o mouse interage com o card, permitindo transições suaves para mudanças de rotação. A animação é constante e mostra o espaço 3D em que o card está, mesmo que um mouse não possa ou não esteja 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 só define o frame-chave intermediário em 50% porque o navegador vai usar 0% e 100% como padrão para o estilo padrão do elemento. Essa é uma abreviação para animações que se alternam, precisando 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);
  }
}

Estilizar os elementos <li>

Cada item da lista (<li>) contém o botão e os elementos de borda dele. O estilo display é alterado para que o item não mostre um ::marker. O estilo position é definido como relative para que os pseudoelementos do botão possam se posicionar na área completa 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. Cada item da lista não tem mais um marcador.

Estilizar os elementos <button>

Estilizar botões pode ser um trabalho difícil, já que há muitos estados e tipos de interação a serem considerados. Esses botões ficam complexos rapidamente devido ao balanceamento de pseudoelementos, animações e interações.

Estilos iniciais de <button>

Confira abaixo os estilos fundamentais 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 de botão

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

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

Esses elementos são cruciais para mostrar a perspectiva 3D estabelecida. Um desses pseudoelementos será afastado do botão e outro será aproximado do usuário. O efeito é mais perceptível nos botões de cima e de baixo.

.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, transform-style está 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 não tiver problemas com movimento, o botão vai indicar ao navegador que a propriedade de transformação está pronta para mudança e uma transição será definida para as propriedades transform e background-color. Note a diferença na duração. Acho que isso cria um efeito sutil e escalonado.

.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 de passar o cursor e foco

O objetivo da animação de interação é espalhar as camadas que compõem o botão de aparência plana. Para isso, defina a variável --distance, inicialmente como 1px. O seletor mostrado no exemplo de código a seguir verifica se o botão está sendo passado ou focado por um dispositivo que deve mostrar um indicador de foco, e não sendo ativado. Se for esse o caso, ele vai aplicar CSS para fazer o seguinte:

  • Aplicar a cor de fundo ao passar o cursor.
  • Aumente a distância .
  • Adicione um efeito de facilidade de retorno.
  • Escalone 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 com teclados, leitores de tela, gamepads, toque e um mouse, mas podemos adicionar alguns toques leves de JavaScript para facilitar alguns cenários.

Compatibilidade com teclas de seta

A tecla Tab é uma boa maneira de navegar pelo menu, mas eu esperaria que o pad direcional ou os joysticks movessem o foco em um gamepad. A biblioteca roving-ux, geralmente usada para interfaces de desafio da GUI, processa as teclas de seta para nós. O código abaixo informa à biblioteca para capturar o foco em .threeD-button-set e encaminhar o foco para os elementos filhos do botão.

import {rovingIndex} from 'roving-ux'

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

Interação de paralaxe do mouse

O rastreamento do mouse e a inclinação do menu têm como objetivo imitar as interfaces de videogame 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 muito conscientes do ponteiro.

Como esse é um pequeno recurso extra, vamos colocar a interação por trás de 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 faça o cache dos 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 podemos usar para girar o card. A função a seguir usa a posição do mouse para determinar em qual lado da caixa ele está e em 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 nossa função getAngles() e use os valores delta como estilos de propriedade personalizados. Dividi por 20 para preencher o delta e torná-lo menos instável. Talvez haja uma maneira melhor de fazer isso. Se você se lembra do início, colocamos as propriedades --x e --y no meio de uma função clamp(). Isso evita que a posição do mouse gire demais o card até 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 ser alterado 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 eu fiz isso, como você faria? 🙂 Você pode adicionar uma interação do acelerômetro ao menu para que inclinar o smartphone gire o 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, me envie um tweet com o link, e eu vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade

Ainda não há nada disponível.