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. Teste a demonstração.

Demo

Se preferir vídeos, confira a versão desta postagem 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 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 tecnologias de leitor de tela e funcionará 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 gerais:

  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ódigo repetido e compartilhando valores entre as crianças.

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. Nomear propriedades personalizadas ajuda na 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.

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

Como estilizar a lista de botões <ul>

Esse elemento é responsável pelo layout macro geral da lista de botões e é 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 interagir com o card, permitindo transições suaves para mudanças de rotação. A animação é 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 interaja 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 do meio em 50%, já que o navegador vai definir 0% e 100% como 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 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 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, e
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 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 bordas tradicionais, são pseudoelementos de posição absoluta com bordas.

Captura de tela do painel &quot;Elements&quot; do Chrome DevTools com um botão mostrado com
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 no passe do cursor e foco.

.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 aceitar o movimento, o botão vai sugerir ao navegador que a propriedade de transformação precisa estar pronta para mudanças e que uma transição foi definida para as propriedades transform e background-color. Observe a diferença na duração. Achei que isso criou 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 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 fazer 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 encaminhar o foco para os botões filhos.

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. A delta é retornada pela 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 a delta e torná-la 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 impede que a posição do mouse gire demais o card 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 direções

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

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.