Como criar um componente de menu de jogo 3D

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

Neste post, quero compartilhar uma maneira de criar um componente de menu de jogo 3D. Confira a demonstração.

Demo

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. É comum 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 de um esquema de cores adaptável e adaptações para usuários que preferem movimentos reduzidos.

HTML

O menu de um jogo é 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 de marcadores 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. Posicionar 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 do menu do jogo usa preferências de movimento, o esquema de cores do sistema e os recursos de faixa 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 os valores de posição 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 de temas claros e escuros

O tema claro tem um gradiente cônico vibrante de cyan para deeppink, enquanto o tema escuro tem um gradiente cônico 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 as preferências de cores claras e escuras.

Ativando 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.

Como estilizar a 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. Veja 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 garanta que cada item tenha 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 de espaço 3D e configure as funções clamp() do CSS para garantir que o card não gire além das rotações legíveis. Observe que o valor médio para o limite é uma propriedade personalizada. Esses 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 a animação estiver funcionando corretamente para o usuário visitante, adicione uma dica ao navegador informando que a transformação desse item vai mudar constantemente com will-change. Além disso, ative a interpolação definindo uma 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. Essa é uma abreviação para animações que 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 de botão futuros 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.
Cada item da lista não tem mais um marcador.

Como estilizar os elementos <button>

Criar estilos para 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 equilíbrio entre pseudoelementos, animações e interações.

Estilos <button> iniciais

Confira abaixo os estilos básicos que oferecem 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 bordas 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 mostrar a perspectiva 3D que foi estabelecida. Um desses pseudoelementos será afastado 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, o valor é definido como preserve-3d para que os filhos possam se distribuir 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 com o cursor e foco

O objetivo da animação de interação é espalhar as camadas que compunham 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 abaixo verifica se o botão está sendo pairado ou focado por um dispositivo que deve ter um indicador de foco, e não está sendo ativado. Se sim, o CSS é aplicado para fazer o seguinte:

  • Aplicar a cor de plano de fundo do cursor.
  • Aumente a distância.
  • Adicione um efeito de facilidade de salto.
  • Alterne 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 legal para a preferência de movimento reduced. Os elementos de cima e de baixo 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 mouse, mas podemos adicionar alguns toques leves de JavaScript para facilitar alguns cenários.

Suporte a teclas de seta

A tecla Tab é uma boa maneira de navegar pelo menu, mas eu esperaria que o direcional ou os joysticks mudassem o foco em um gamepad. A biblioteca roving-ux, usada com frequência para interfaces de desafio de GUI, processa as teclas de seta para nós. 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 de paralaxe do mouse

O rastreamento do mouse e a inclinação do menu têm como objetivo imitar interfaces de jogos 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 atrás de uma consulta da 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 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 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 a função getAngles() e use os valores delta como estilos de propriedade personalizados. 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 direçõ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 agente do usuário. Isso significa 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 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 interação do acelerômetro ao menu para que o redimensionamento do 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, envie links para mim e vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade

Ainda não há nada aqui.