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
- Uma fatia sorteada aparece no centro. Clique na pizza onde ela deve entrar (ou use as teclas
1a6). - Colocar uma fatia que a pizza já tem custa uma vida.
- Pizzas completadas em sequência, sem errar, formam combo: a primeira vale +1, a segunda +2, a terceira +3.
- Uma barra mostra o tempo restante. A cada duas pizzas o jogo sobe de fase e o tempo por fatia cai de 10 para até 3 segundos.
- São três vidas, e o recorde fica salvo no navegador.
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:
- busy: enquanto uma fatia está voando ou o fim do tempo está sendo tratado, novos cliques são ignorados. Isso elimina o clique duplo.
- gen: um contador de "geração" que aumenta a cada reinício. Toda função assíncrona guarda o valor no início e confere antes de agir. Se o jogador reiniciou no meio de uma animação, o callback antigo simplesmente não faz nada.
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
- Nenhuma dependência: o projeto inteiro tem menos de 500 KB, com imagens e fontes incluídas.
- Controle de estado com
busyegen, que evita cliques duplos e callbacks fora de hora. - Barra de tempo, fases, combo, recorde, sons, teclado e pausa automática.
- Funciona no celular, com o tabuleiro se adaptando à tela.
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.
0 Comentário
Deixe seu Comentário!
Dúvida, sugestão ou só um "valeu!" — tudo é bem-vindo por aqui.