Pac-Man Inverso: Recriando o Clássico em JavaScript Puro (e Virando o Jogo)

Como um clone antigo de Pac-Man em jQuery virou um jogo completo em JavaScript puro: labirinto em 21 linhas de texto, sprites em pixel art feitos só com CSS, a personalidade de cada fantasma, uma IA para o Pac-Man com busca em largura e um modo inverso em que você é o fantasma.

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 é 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:

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:

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:

// 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

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.

Jogar o Pac-Man github.com/SandroJordao/pacman

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.