Como criar um componente de menu de jogo 3D

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

Nesta postagem, quero compartilhar ideias para criar um componente de menu de jogos 3D. Confira a demonstração.

Demonstração

Se você preferir o 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 em espaço 3D. É popular em novos jogos de RA/RV fazer com que o menu pareça flutuar no espaço. Hoje vamos recriar os elementos essenciais desse efeito, mas com o toque adicional de um esquema de cores adaptável e acomodações para usuários que preferem movimento reduzido.

HTML

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

<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 será anunciada corretamente para as tecnologias de leitores de tela e funciona sem JavaScript ou CSS.

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

CSS

O estilo da lista de botões é dividido nas seguintes etapas de alto nível:

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

Visão geral das propriedades personalizadas

As propriedades personalizadas ajudam a eliminar a ambiguidade de valores, atribuindo nomes significativos a valores de aparência aleatória, evitando códigos repetidos e compartilhando valores entre filhos.

Confira 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 de menu do jogo usa preferências de movimento, o esquema de cores do sistema e os 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 abaixo gerenciam o esquema de cores e mantêm os valores posicionais do mouse para tornar o menu do jogo interativo ao passar o cursor. A nomeação de propriedades personalizadas ajuda a legibilidade do código, porque revela o caso de uso do valor ou um nome amigável 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);
    }
  }
}

Planos de fundo cônicos 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 mudança do plano de fundo entre as preferências de cor clara e escura.

Ativando a perspectiva 3D

Para que os elementos existam no espaço 3D de uma página da Web, uma janela de visualização com perspectiva precisa ser inicializada. Optei por colocar a perspectiva no elemento body e usei unidades de janela de visualização para criar o estilo que eu gostei.

body {
  perspective: 40vw;
}

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

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 cartão interativo e flutuante 3D. Confira como 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 verifique se cada item tem o tamanho do conteúdo 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 do espaço 3D e configure as funções clamp() do CSS para garantir que o cartão não gire além das rotações legíveis. O valor do meio do fecho é uma propriedade personalizada. Esses valores de --x e --y serão definidos a partir do JavaScript na interação com o mouse posteriormente.

.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 usuário visitante estiver correto, 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 vai ocorrer quando o mouse interage com o cartão, permitindo transições sem falhas nas mudanças de rotação. Ela é uma animação em execução constante que demonstra o espaço 3D em que o cartão 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 define somente o frame-chave do meio em 50%, já que o navegador definirá 0% e 100% como padrão para o estilo padrão do elemento. Ela é uma abreviação de 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);
  }
}

Como definir o estilo dos elementos <li>

Cada item da lista (<li>) contém o botão e os elementos de borda. O estilo display é modificado para que o item não mostre uma ::marker. O estilo position é definido como relative para que os pseudoelementos do botão "Próximo" possam se posicionar dentro da á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.

Como definir o estilo dos elementos <button>

Definir o estilo de botões pode ser difícil, porque 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 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 de botão

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

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

Esses elementos são cruciais para apresentar a perspectiva 3D estabelecida. Um desses pseudoelementos será empurrado para longe do botão e outro será puxado para mais perto 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 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 condicionais

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 mudanças e uma transição será definida para as propriedades transform e background-color. Observe a diferença na duração. Achei que criou um efeito escalonado sutil lindo.

.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 compuseram o botão de exibição 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 em foco por um dispositivo que precisa ver um indicador de foco e não está sendo ativado. Nesse caso, ele aplica CSS 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ção.
  • Distribua as transições dos 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 boa para a preferência de movimento reduced. Os elementos de cima e de baixo mostram o efeito de maneira sutil.

Pequenas melhorias com o JavaScript

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

Compatibilidade com teclas de seta

A tecla Tab é uma ótima maneira de navegar pelo menu, mas esperaria que o botão direcional ou os joysticks movam o foco em um gamepad. A biblioteca roving-ux, muito usada para interfaces de desafio de GUI, processará as teclas de seta. O código abaixo instrui a biblioteca a capturar o foco em .threeD-button-set e encaminhá-lo para os filhos do botão.

import {rovingIndex} from 'roving-ux'

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

Interação do paralaxe do mouse

Rastrear o mouse e inclinar o menu serve para imitar interfaces de jogos de vídeo de RA e RV, em que, em vez de um mouse, você pode ter um ponteiro virtual. Pode ser divertido quando os elementos reconhecem muito o ponteiro.

Como esse é um recurso extra pequeno, vamos colocar a interação em uma consulta de preferência de movimento do usuário. Além disso, como parte da configuração, armazene o componente da 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 cartão 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 que lado da caixa está e o quanto ela está. 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 propriedades personalizadas. Dividi por 20 para preencher o delta e diminuir a tensão. Pode haver uma maneira melhor de fazer isso. Lembre-se desde o início, colocamos as propriedades --x e --y no meio de uma função clamp(). Isso evita que a posição do mouse gire excessivamente o card em 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 instruções

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 de jogos precisava mudar para acomodar o design desejado. Alterar 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, o que você faria ‽ 🙂 Você pode adicionar a interação com o acelerômetro ao menu, para que colocar o smartphone lado a lado 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, envie um tweet para mim (link em inglês) e eu vou adicionar o conteúdo à seção de remixes da comunidade abaixo.

Remixes da comunidade

Não há nada aqui ainda.