Em 1980, conta a lenda, Toru Iwatani olhou para uma pizza com uma fatia faltando e enxergou um personagem. Nascia o Pac-Man, que no Japão se chamava Puck-Man, de paku-paku, o som de abrir e fechar a boca para comer. Quarenta e tantos anos depois, ele continua sendo um dos melhores exercícios de programação de jogos que existem: um labirinto, cinco personagens e regras simples que escondem muita lógica.
Este artigo conta como um clone antigo e inacabado virou um jogo completo, feito só com HTML, CSS e JavaScript, sem nenhuma biblioteca. E como, no meio do caminho, o jogo ganhou um modo inverso: você controla um fantasma e quem foge é o Pac-Man, comandado por uma IA.
Jogar o Pac-Man Código no GitHub
Onde tudo começou
O projeto nasceu no XAMPP do meu computador, como uma página de 536 linhas, quase todas de <div> escritas à mão. Cada parede do labirinto era uma <div> com classes de borda, e cada personagem era uma grade de 196 <div> coloridas por CSS. O Pac-Man andava, mas não comia nada, os fantasmas ficavam parados e a colisão era calculada medindo a posição de todas as <div> da página com jQuery a cada quadro.
A reconstrução foi feita em etapas pequenas e testadas uma a uma. Do projeto original ficou tudo o que tinha valor: o desenho do labirinto, a pixel art dos personagens e o visual. A lógica foi reescrita do zero, e o jQuery foi embora.
O labirinto em 21 linhas de texto
A primeira descoberta foi que as 399 células do labirinto podiam virar um mapa de texto. Cada caractere é um tile de 30 pixels:
// # parede . pastilha o power pill
// - porta da casa dos fantasmas S início do Pacman
const MAP = [
"###################",
"#........#........#",
"#o##.###.#.###.##o#",
"#.................#",
"#.##.#.#####.#.##.#",
"#....#...#...#....#",
"####.### # ###.####",
" #.# #.# ",
"####.# ##-## #.####",
"..... # # .....",
"####.# ##### #.####",
" #.# #.# ",
"####.# ##### #.####",
"#........#........#",
"#.##.###.#.###.##.#",
"#o.#.....S.....#.o#",
"##.#.#.#####.#.#.##",
"#....#...#...#....#",
"#.######.#.######.#",
"#.................#",
"###################"
];
E as bordas azuis? No HTML antigo elas foram desenhadas à mão, célula por célula. Analisando todas, apareceu uma regra simples: uma parede tem borda em cada lado onde não existe outra parede vizinha. Com ela, o labirinto se desenha sozinho, e o resultado bateu com as 399 células feitas à mão, sem uma única diferença:
if (tile == '#') {
if (!isWall(col, row - 1)) cell.classList.add('T');
if (!isWall(col + 1, row)) cell.classList.add('R');
if (!isWall(col, row + 1)) cell.classList.add('B');
if (!isWall(col - 1, row)) cell.classList.add('L');
}
O labirinto original do arcade tem 28 por 31 tiles. Este tem 19 por 21, mas mantém o que importa: a casa dos fantasmas no centro, as quatro power pills nos cantos e o túnel lateral que leva de um lado ao outro.
Pixel art sem nenhuma imagem
O jogo não carrega nenhum arquivo de imagem. Cada personagem é uma grade de 14 por 14 <div> de 2 pixels, e o CSS pinta cada uma com nth-child. Os quatro fantasmas usam o mesmo desenho, e só a cor muda, por uma variável CSS:
.ghost.blinky { --ghost-color: #FE0002; }
.ghost.pinky { --ghost-color: #FFA0A1; }
.ghost.inky { --ghost-color: #1BD8DA; }
.ghost.clyde { --ghost-color: #FF9000; }
/* Assustado: todos ficam azuis */
.ghost.frightened { --ghost-color: #2121DE; }
/* Comido: o corpo some e sobram só os olhos voltando para casa */
.ghost.eaten { --ghost-color: transparent; }
Com centenas de seletores nth-child, escrever esse CSS à mão seria um pesadelo. Ele é gerado por um pequeno script a partir de mapas ASCII, e é assim que os fantasmas ganharam três detalhes do arcade:
- O lençol que balança: a barra de baixo alterna entre dois desenhos, um com quatro pontas e outro com três.
- Os olhos que olham para onde o fantasma vai: são quatro versões dos olhos (cima, baixo, esquerda e direita), trocadas por uma classe a cada quadro.
- A piscada: de tempos em tempos o olho fecha num traço em arco, e cada fantasma pisca num momento diferente.
O lençol é só um par de animações com steps(1), que troca de quadro sem transição, como num fliperama:
@keyframes skirt-a {
0% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes skirt-b {
0% { opacity: 0; }
50% { opacity: 1; }
}
Movimento por tiles e um laço que não depende do monitor
A colisão antiga perguntava ao navegador onde estava cada <div>. A nova pergunta ao mapa. O Pac-Man só decide para onde ir quando passa pelo centro de um tile, e o jogo guarda a curva: se você aperta para cima um pouco antes da esquina, ele vira assim que o caminho fica livre. Esse detalhe é o que faz o controle parecer justo:
function stepControlled(entity, onCenter)
{
// Inverter o sentido é permitido a qualquer momento
if (entity.next && entity.next == OPPOSITE[entity.dir]) {
entity.dir = entity.next;
}
if (isCentered(entity)) {
var tile = tileOf(entity);
if (onCenter) {
onCenter(tile);
}
// Curva guardada: vira assim que o caminho estiver livre
if (entity.next && canWalk(tile.col + DIRS[entity.next].x, tile.row + DIRS[entity.next].y)) {
entity.dir = entity.next;
}
if (!entity.dir || !canWalk(tile.col + DIRS[entity.dir].x, tile.row + DIRS[entity.dir].y)) {
return false;
}
}
step(entity, entity.dir);
return true;
}
A mesma função serve para quem o jogador controla, seja o Pac-Man ou um fantasma. O laço principal usa passo fixo: a lógica roda sempre 60 vezes por segundo, não importa se o monitor é de 60 ou 144 Hz. Sem isso, o jogo seria mais rápido em telas mais rápidas:
function loop(now)
{
lag += Math.min(now - last, 250);
last = now;
if (running) {
while (lag >= 1000 / UPDATES) {
update();
lag -= 1000 / UPDATES;
}
} else {
lag = 0;
}
draw();
requestAnimationFrame(loop);
}
Um efeito colateral valioso do passo fixo: com a aleatoriedade controlada por uma semente, o jogo fica determinístico. Durante o desenvolvimento, uma partida de referência com 12.000 quadros era gravada antes de cada mudança e comparada quadro a quadro depois. Se uma refatoração mudasse a posição de qualquer personagem em qualquer quadro, o teste acusava.
Os quatro fantasmas
A genialidade do Pac-Man original está nos fantasmas. Eles não perseguem do mesmo jeito: cada um mira um ponto diferente do labirinto, e é a combinação dos quatro que cria o cerco. Os nomes japoneses originais descrevem a personalidade de cada um:
- Blinky (Oikake, "o perseguidor"): mira exatamente onde o Pac-Man está.
- Pinky (Machibuse, "o emboscador"): mira 4 tiles à frente do Pac-Man, tentando cortar o caminho.
- Inky (Kimagure, "o imprevisível"): usa a posição do Blinky como referência e mira o ponto do outro lado, fechando um cerco em pinça.
- Clyde (Otoboke, "o que se faz de bobo"): persegue quando está longe, mas a menos de 8 tiles desiste e volta para o seu canto.
No código, cada personalidade é uma linha:
switch (ghost.id) {
case 'blinky': // Persegue o Pacman diretamente
return p;
case 'pinky': // Mira 4 tiles à frente do Pacman
return { col: p.col + d.x * 4, row: p.row + d.y * 4 };
case 'inky': // Usa o Blinky como referência para cercar
var blinky = tileOf(ghosts[0]);
var ahead = { col: p.col + d.x * 2, row: p.row + d.y * 2 };
return { col: ahead.col * 2 - blinky.col, row: ahead.row * 2 - blinky.row };
case 'clyde': // Persegue de longe, foge quando chega perto
var g = tileOf(ghost);
var dist = Math.pow(g.col - p.col, 2) + Math.pow(g.row - p.row, 2);
return dist > 64 ? p : ghost.scatter;
}
Em cada cruzamento, o fantasma escolhe a saída que deixa ele mais perto do alvo, sem nunca dar meia-volta. Eles também alternam entre perseguição e dispersão, quando cada um vai para um canto do labirinto e dá uma trégua. Sem essa alternância, o jogo seria impossível.
Duas curiosidades do arcade: no original, quando o Pac-Man olha para cima, um erro de cálculo faz o Pinky mirar 4 tiles para cima e 4 para a esquerda. Esse bug virou parte do comportamento clássico, e jogadores experientes o usam a seu favor. E o jogo original não tem fim: na fase 256 outro bug corrompe metade da tela e torna a fase impossível de completar. É a famosa kill screen.
O modo inverso: agora o fantasma é você
Com os fantasmas funcionando, veio a pergunta: e se o jogo fosse ao contrário? No modo inverso, você escolhe um fantasma e precisa capturar o Pac-Man três vezes antes que ele coma todas as pastilhas. As power pills continuam valendo: quando ele come uma, é você quem fica azul e precisa fugir.
Logo no início apareceu um problema matemático. Num labirinto cheio de circuitos fechados, um perseguidor sozinho, com a mesma velocidade, nunca alcança um fugitivo perfeito: ele sempre pode dar a volta no quarteirão. Por isso cada fantasma ganhou uma habilidade inspirada na sua personalidade:
- Blinky: 10% mais rápido que o Pac-Man, o tempo todo.
- Pinky: teletransporte para a frente do Pac-Man. O destino aparece no mapa 2 segundos antes, e o fantasma continua andando, o que cria um cerco com duas ameaças ao mesmo tempo.
- Inky: deixa o Pac-Man mais lento por 2 segundos, de qualquer lugar do mapa.
- Clyde: nenhuma habilidade. É o modo hardcore, e os pontos valem 5 vezes mais.
As habilidades têm 10 segundos de recarga, mostrada num anel em volta do ícone de cada fantasma. O código que as aciona é curto:
if (playerGhost == 'pinky') {
var target = ghost.frightened ? farthestFromPacman() : aheadOfPacman();
teleport = { col: target.col, row: target.row, timer: TELEPORT_DELAY };
Sound.play('teleport');
} else if (playerGhost == 'inky') {
pacman.slowTimer = SLOW_TIME;
Sound.play('slow');
}
Repare no detalhe do Pinky: quando ele está azul, o teletransporte o leva para o ponto do labirinto mais distante do Pac-Man. A mesma habilidade serve para atacar e para fugir.
Uma IA para o Pac-Man
Para o modo inverso existir, o Pac-Man precisava jogar sozinho, e jogar bem. A IA decide no centro de cada tile, usando busca em largura (BFS) sobre o labirinto:
- Calcula um mapa de perigo: em quanto tempo cada fantasma chega a cada tile. É medido em tempo, não em distância, porque o Blinky é mais rápido e o Pac-Man pode estar lento.
- Monta a região segura: os tiles aonde o Pac-Man chega antes de qualquer fantasma, com uma folga.
- Escolhe o objetivo, nesta ordem: caçar um fantasma azul que dá para alcançar, ir à power pill se estiver ameaçado, comer a pastilha mais próxima e, se nada for seguro, fugir pelo caminho com mais folga.
// Região segura: chega antes de qualquer fantasma, com folga
const safe = bfs(start, null, function (idx, d) {
return pacTime(d) + margin < danger[idx];
});
// Power pill quando está ameaçado; senão, a pastilha mais próxima
const target = threatened && pill !== null ? pill : dot !== null ? dot : pill;
if (target !== null) {
return safe.first[target];
}
Uma IA perfeita seria imbatível, e um jogo imbatível não tem graça. Por isso ela erra de propósito, de um jeito controlado por fase:
// margin: folga para considerar um tile seguro
// vision: até quantos tiles enxerga os fantasmas
// reaction: atraso (em atualizações) com que percebe os fantasmas
// mistake: chance de escolher uma direção qualquer num cruzamento
const LEVELS = [
{ margin: 4, vision: 7, reaction: 20, mistake: 0.10 },
{ margin: 6, vision: 9, reaction: 16, mistake: 0.07 },
{ margin: 8, vision: 12, reaction: 12, mistake: 0.05 },
{ margin: 10, vision: 16, reaction: 9, mistake: 0.03 },
{ margin: 12, vision: 24, reaction: 6, mistake: 0.02 },
{ margin: 14, vision: 99, reaction: 4, mistake: 0.01 }
];
O "tempo de reação" é o meu favorito: a IA guarda as posições dos fantasmas a cada atualização e decide olhando para onde eles estavam alguns instantes atrás. Na fase 1 ela reage com um terço de segundo de atraso, como uma pessoa distraída. Na fase 6, quase instantaneamente.
A mesma IA rende um bônus: o menu IA vs IA, em que você assiste o Pac-Man jogando sozinho contra os fantasmas, no modo clássico ou no inverso.
Balanceando com números, não com achismo
Como saber se o Inky está fácil demais? Jogando, mas não só. Um robô caçador (que persegue pelo menor caminho e usa a habilidade assim que ela fica pronta) jogou dezenas de partidas simuladas com cada fantasma, em velocidade máxima. A primeira versão do Inky deixava o Pac-Man com metade da velocidade por 3 segundos, e o robô venceu 100% das partidas da fase 1, contra 20% do Pinky. Depois de testar várias combinações de força e duração, a lentidão ficou em 80% da velocidade por 2 segundos, e o Inky caiu para 38%. A ordem final bate com a dificuldade anunciada na tela de escolha: Blinky fácil, Pinky e Inky médios, Clyde hardcore.
Sons sem arquivos de áudio
Todos os sons são sintetizados na hora com a Web Audio API. O "waka-waka" é um deslize de frequência que alterna entre subir e descer a cada pastilha. A sirene de fundo é um oscilador cuja frequência é balançada por um segundo oscilador bem lento, um LFO:
siren.lfo.connect(siren.depth); // o LFO balança...
siren.depth.connect(siren.osc.frequency); // ...a frequência da sirene
siren.osc.connect(siren.gain);
siren.gain.connect(master);
Como no arcade, a sirene fica mais aguda conforme as pastilhas acabam, muda quando os fantasmas ficam azuis e fica nervosa quando há olhos voltando para casa. Na pausa, o jogo suspende o relógio do áudio inteiro com AudioContext.suspend(), e até sons já agendados esperam e continuam do ponto certo.
No celular, é só deslizar o dedo
O jogo funciona no celular: deslizar o dedo em qualquer lugar da tela muda a direção, e dá para emendar curvas sem tirar o dedo:
if (Math.abs(dx) > Math.abs(dy)) {
handleInput(dx > 0 ? 'right' : 'left');
} else {
handleInput(dy > 0 ? 'down' : 'up');
}
// Permite emendar curvas sem tirar o dedo da tela
touchStart = { x: touch.clientX, y: touch.clientY };
O labirinto tem tamanho fixo em pixels, e o jogo aplica zoom para caber em qualquer tela. A pixel art continua alinhada à grade, sem ficar borrada.
Ranking com desconfiança saudável
Cada modo tem seu ranking, gravado por uma pequena API em PHP. Como a pontuação vem do navegador, o servidor confere se a partida é possível antes de aceitar. No modo inverso, por exemplo, o máximo de pontos depende das fases vencidas e do multiplicador do fantasma:
$max = (($level - 1) * INVERSE_WON_LEVEL_MAX + INVERSE_LOST_LEVEL_MAX) * $GHOST_MULTIPLIER[$ghost];
$min_time = ($level - 1) * INVERSE_MIN_SECONDS_PER_WON_LEVEL + INVERSE_MIN_SECONDS_LAST_LEVEL;
if ($score % 10 !== 0 || $score > $max || $time < $min_time)
{
fail('Dados da partida inválidos.', 422);
}
Não impede alguém determinado a trapacear, mas elimina os recordes impossíveis. E o ranking do modo inverso mostra o fantasma de cada jogador ao lado do nome.
Resultado
- Nenhuma dependência e nenhuma imagem: personagens, labirinto e ícones são CSS e SVG em pixel art.
- Os quatro fantasmas com a personalidade do arcade, os olhos que seguem o movimento, a piscada e o lençol balançando.
- Modo inverso com quatro fantasmas jogáveis, cada um com sua habilidade.
- Uma IA para o Pac-Man que fica mais esperta a cada fase, e o modo IA vs IA para assistir.
- Sons sintetizados, pausa que congela até o áudio, controles por toque e ranking por modo.
Conclusão
Recriar um clássico é uma aula de humildade: as regras do Pac-Man parecem simples até você tentar programá-las. A personalidade dos fantasmas, a curva guardada, a alternância entre perseguição e dispersão, tudo foi pensado para o jogo ser difícil sem ser injusto. Virar o jogo do avesso deixou isso ainda mais claro: para o fantasma ter chance, foi preciso dar habilidades a ele e ensinar o Pac-Man a errar.
Venha jogar, tente vencer a fase com o Clyde e, se quiser estudar ou sugerir melhorias, o código está todo no GitHub.
0 Comentário
Deixe seu Comentário!
Dúvida, sugestão ou só um "valeu!" — tudo é bem-vindo por aqui.