Pizza Click: Criando um Jogo com JavaScript Puro

Como foi construído o Pizza Click, um jogo de raciocínio rápido em HTML, CSS e JavaScript sem dependências: estado do jogo, laço com requestAnimationFrame, animações com a Web Animations API, sons com Web Audio e layout responsivo com CSS Grid.

O Pizza Click é um jogo de raciocínio rápido feito só com HTML, CSS e JavaScript, sem nenhuma biblioteca. A ideia é simples: uma fatia aparece no centro da tela e você precisa clicar numa das seis pizzas ao redor onde ela ainda não existe. Cada pizza aceita uma fatia de cada tipo (são sete) e, quando fica completa, vira pontos. Se o tempo acabar, aquela fatia some de todas as pizzas e você perde uma vida.

Neste artigo mostro as principais funcionalidades do jogo e os trechos de código mais importantes.

Jogar o Pizza Click Código no GitHub

Regras do jogo

Sem dependências

O jogo não usa nenhuma biblioteca. Tudo vem do próprio navegador: querySelector e classList para manipular a página, animações em CSS e a Element.animate() para os movimentos. O projeto inteiro, com imagens e fontes, tem menos de 500 KB.

O maior desafio de um jogo baseado em tempo é lidar com cliques e callbacks assíncronos acontecendo ao mesmo tempo. Um clique duplo durante uma animação, por exemplo, não pode virar duas jogadas. A base para resolver isso é a forma como o estado é organizado.

Um único objeto de estado

Todo o estado do jogo fica em um objeto. A tela é apenas um reflexo dele:

var game = {
    status: 'idle', // idle | playing | paused | over
    gen: 0,         // muda a cada reinício: invalida callbacks pendentes
    busy: false,    // animação em andamento: ignora cliques e pausa o cronômetro
    pizzas: [],     // pizzas[i][s] === true se a pizza i tem a fatia s
    slice: 0,
    duration: 0,
    remaining: 0,
    score: 0,
    combo: 0,
    lives: 3
}

Dois campos resolvem a maior parte dos bugs de concorrência:

Sorteando só fatias que cabem

A fatia sorteada precisa caber em pelo menos uma pizza. Sortear e testar em um laço até acertar poderia nunca terminar e travar o navegador, então o sorteio é feito apenas entre as fatias livres:

function freeSlices()
{
    var free = []

    for (var s = 1; s <= SLICES; s++)
    {
        for (var i = 1; i <= PIZZAS; i++)
        {
            if (!game.pizzas[i][s]) { free.push(s); break }
        }
    }

    return free
}

function nextSlice()
{
    var free = freeSlices()

    game.slice = free[Math.floor(Math.random() * free.length)]
    game.duration = sliceTime()
    game.remaining = game.duration

    showRand(game.slice)
}

O laço principal com requestAnimationFrame

O tempo de cada fatia não usa setTimeout: ele é descontado a cada quadro em um único laço com requestAnimationFrame. Assim, pausar é só não descontar tempo, e o tempo restante da fatia é preservado:

function frame(now)
{
    // Limita o salto se o navegador ficar um tempo sem desenhar.
    var dt = Math.min(now - game.lastFrame, 100)

    game.lastFrame = now

    if (game.status === 'playing')
    {
        game.playTime += dt

        if (!game.busy)
        {
            game.remaining -= dt

            if (game.remaining <= 0) timeout()
        }
    }

    renderTimer()
    renderClock()

    requestAnimationFrame(frame)
}

O Math.min(..., 100) evita que o jogo "pule" vários segundos de uma vez se o navegador travar por um momento. Para o caso de o jogador trocar de aba, o jogo pausa sozinho:

document.addEventListener('visibilitychange', function () {
    if (document.hidden) pause()
})

A barra de tempo é atualizada com transform: scaleX(), que é barato para o navegador animar, e muda de cor perto do fim:

function renderTimer()
{
    var ratio = game.duration ? Math.max(0, game.remaining / game.duration) : 0

    el.timerFill.style.transform = 'scaleX(' + ratio + ')'
    el.timerFill.classList.toggle('danger', ratio > 0 && game.remaining <= WARN_TIME)
}

A fatia voando até a pizza

Quando o jogador escolhe uma pizza, a fatia do centro voa até ela. A distância é calculada com getBoundingClientRect(), então a animação funciona em qualquer tamanho de tela. O movimento usa a Web Animations API, que avisa quando termina por meio do onfinish:

function place(i)
{
    var gen = game.gen

    game.busy = true

    var from = el.rand.getBoundingClientRect()
    var to = el.pizzas[i].getBoundingClientRect()

    var anim = el.rand.animate([
        { transform: 'translate(0, 0)' },
        { transform: 'translate(' + (to.left - from.left) + 'px, ' + (to.top - from.top) + 'px)' }
    ], { duration: 300, easing: 'ease-in', fill: 'forwards' })

    anim.onfinish = function () {
        showRand(0)
        anim.cancel()

        if (gen !== game.gen) return // o jogo foi reiniciado no meio do caminho

        game.pizzas[i][game.slice] = true
        renderPizza(i)

        if (isFull(i)) complete(i)

        game.busy = false
        nextSlice()
    }
}

O fill: 'forwards' mantém a fatia no destino até o callback escondê-la. Sem ele, ela voltaria ao centro por um quadro antes de sumir.

Fatias que somem piscando

Quando uma pizza é completada, ou quando o tempo acaba e uma fatia sai de todas as pizzas, as fatias somem piscando. O detalhe é que a pizza precisa ficar livre na hora, mesmo durante a animação. A solução é animar uma cópia da imagem e remover a original do estado imediatamente:

function ghost(i, slices)
{
    slices.forEach(function (s) {
        var copy = el.slices[i][s].cloneNode(false)

        copy.classList.remove('on')
        copy.classList.add('ghost')
        copy.addEventListener('animationend', function () { copy.remove() })

        el.pizzas[i].appendChild(copy)

        game.pizzas[i][s] = false
    })

    renderPizza(i)
}

O efeito de piscar é um @keyframes de poucas linhas:

.slice.ghost {
    display: block;
    animation: pulsate 0.8s linear forwards;
}

@keyframes pulsate {
    0%, 40%, 80% { opacity: 1; }
    20%, 60% { opacity: 0.1; }
    100% { opacity: 0; }
}

Sons sem arquivos de áudio

Os efeitos sonoros são gerados na hora com a Web Audio API. Cada som é uma lista de notas com frequência, início e duração, tocadas por um oscilador com volume decaindo:

var SOUNDS = {
    place:    [[660, 0, 0.06, 'square', 0.08]],
    complete: [[523, 0, 0.1], [659, 0.1, 0.1], [784, 0.2, 0.18]],
    lose:     [[220, 0, 0.3, 'sawtooth', 0.12]]
}

function sound(name)
{
    if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)()

    var now = audio.currentTime

    SOUNDS[name].forEach(function (note) {
        var osc = audio.createOscillator()
        var gain = audio.createGain()
        var start = now + note[1]
        var end = start + note[2]

        osc.type = note[3] || 'triangle'
        osc.frequency.value = note[0]
        gain.gain.setValueAtTime(note[4] || 0.15, start)
        gain.gain.exponentialRampToValueAtTime(0.001, end)
        osc.connect(gain).connect(audio.destination)
        osc.start(start)
        osc.stop(end)
    })
}

O AudioContext só é criado no primeiro som, que sempre acontece depois de um clique ou tecla. Isso respeita a regra dos navegadores de só liberar áudio após uma interação do usuário. O "Dó, Mi, Sol" de pizza completa, por exemplo, ocupa uma linha e zero bytes de download.

Tabuleiro responsivo com CSS Grid

As seis pizzas ficam em volta do centro em formato de hexágono. Isso é montado com uma grade de 3 colunas e 6 meias-linhas: as pizzas laterais ocupam duas meias-linhas deslocadas, o que cria o encaixe. Todas as medidas derivam de uma única variável, --u, o tamanho de uma célula:

:root {
    --u: max(80px, min(240px, calc((100vw - 32px) / 3), calc((100dvh - 76px) / 3)));
    --p: calc(var(--u) * 5 / 6); /* tamanho da pizza */
}

.board {
    display: grid;
    grid-template-columns: repeat(3, var(--u));
    grid-template-rows: repeat(6, calc(var(--u) / 2));
}

#pizza-1 { grid-area: 1 / 2 / 3 / 3; }
#pizza-2 { grid-area: 2 / 3 / 4 / 4; }
#pizza-3 { grid-area: 4 / 3 / 6 / 4; }
#pizza-4 { grid-area: 5 / 2 / 7 / 3; }
#pizza-5 { grid-area: 4 / 1 / 6 / 2; }
#pizza-6 { grid-area: 2 / 1 / 4 / 2; }
.center  { grid-area: 3 / 2 / 5 / 3; }

A função min() escolhe o menor valor entre 240px, um terço da largura e um terço da altura da tela. Assim o tabuleiro inteiro encolhe proporcionalmente no celular, sem nenhuma media query. Fontes, ícones e a barra de tempo também são calculados a partir de --u.

Recorde salvo com segurança

O recorde e a preferência de som ficam no localStorage. Ele pode lançar exceção em janelas anônimas ou com cookies bloqueados, então todo acesso é protegido. O jogo continua funcionando normalmente, só sem salvar:

function storageGet(key)
{
    try { return localStorage.getItem(key) } catch (e) { return null }
}

function storageSet(key, value)
{
    try { localStorage.setItem(key, value) } catch (e) {}
}

Teclado e acessibilidade

As pizzas são elementos <button>, então já recebem foco e respondem ao Enter nativamente. Além disso, as teclas 1 a 6 escolhem a pizza, Espaço pausa, R reinicia e M liga ou desliga o som. Um detalhe: quando o jogador clica com o mouse, o foco é removido do botão, para que o contorno de foco não apareça se ele passar a usar o teclado em seguida:

function onClick(handler)
{
    return function (e) {
        if (e.detail > 0) e.currentTarget.blur() // detail > 0: clique real, não teclado

        handler()
    }
}

O CSS também respeita prefers-reduced-motion, desligando o tremor da pizza no erro para quem prefere menos movimento.

Resultado

Conclusão

Um jogo pequeno é um ótimo exercício para praticar as APIs modernas do navegador. requestAnimationFrame, Web Animations, Web Audio e CSS Grid dão conta de tudo sem nenhuma biblioteca, e concentrar o estado em um único objeto torna os bugs de tempo muito mais fáceis de entender e corrigir. O código completo está no GitHub. Fique à vontade para jogar, estudar e sugerir melhorias.

Jogar o Pizza Click github.com/SandroJordao/pizza-click

Tecnologia:
js

Deixe seu Comentário!

Dúvida, sugestão ou só um "valeu!" — tudo é bem-vindo por aqui.

Comentário
Nome
Email
lock Seu e-mail não será publicado.