Menu, Jogo, Fim — Estados de Jogo e o Snake Completo

Menu, Jogo, Fim — Estados de Jogo e o Snake Completo

Controlar telas com um punhado de booleanos admite estados impossíveis, e o bug aparece na transição. A máquina de estados torna isso irrepresentável e deixa quase mecânico acrescentar uma tela nova. Com o Snake completo: menu, jogo e fim, mais o timer que separa o movimento da taxa de quadros.
Linguagem C++

13 min de leitura

No artigo Imagem e Som — Recursos de Jogo com RAII, e o Início do Snake montamos a estrutura do Snake e aprendemos a gerenciar recursos com RAII. Hoje completamos o Snake — a cobra vai se mover, comer, crescer e morrer — e, com ele, aprendemos um conceito que todo jogo real precisa: os estados de jogo. Nenhum jogo é só "jogando" — há a tela de menu antes, a tela de "game over" depois, talvez uma pausa no meio. Transitar limpamente entre esses estados é essencial, e a forma elegante de modelá-lo é uma máquina de estados, que construiremos com o enum class (Fase 1) e a clareza do C++ moderno. Ao fim desta aula, você terá um Snake completo e jogável, com menu, jogo e game over — nosso segundo jogo pronto, e a penúltima peça antes do capstone final.

A lógica do Snake: mover, comer, crescer, morrer

Primeiro completamos a jogabilidade que o Imagem e Som deixou em aberto. Usando as operações sobre o std::vector que descrevemos, a cobra se move adicionando uma cabeça e removendo o rabo; cresce quando come; e morre quando bate em si mesma ou na parede:

#include <raylib.h>
#include <vector>
#include <cstdlib>

struct Celula { int x, y; };
bool mesma(Celula a, Celula b) { return a.x == b.x && a.y == b.y; }

const int TAM = 20, COLS = 40, LINS = 30;

// Move a cobra na direção dada. Devolve false se ela morreu (bateu em algo).
bool mover_cobra(std::vector<Celula>& cobra, Celula dir, Celula& comida, bool& cresceu) {
    Celula cabeca = { cobra.front().x + dir.x, cobra.front().y + dir.y };

    // Morte por parede:
    if (cabeca.x < 0 || cabeca.x >= COLS || cabeca.y < 0 || cabeca.y >= LINS)
        return false;
    // Morte por auto-colisão (cabeça bate no corpo):
    for (const auto& seg : cobra)
        if (mesma(cabeca, seg)) return false;

    cobra.insert(cobra.begin(), cabeca);   // adiciona a nova cabeça (Fase 4)

    if (mesma(cabeca, comida)) {           // comeu?
        cresceu = true;
        // Reposiciona a comida aleatoriamente (simplificado).
        comida = { rand() % COLS, rand() % LINS };
    } else {
        cobra.pop_back();                  // não cresceu: remove o rabo
    }
    return true;   // sobreviveu
}

A elegância do vector aparece: mover é insert no início mais pop_back; crescer é só o insert (sem remover o rabo). A morte é detectada checando se a nova cabeça saiu da grade ou bateu no corpo. Esta é a lógica inteira do Snake — poucas linhas, apoiadas na STL da Fase 4. Mas falta o que envolve o jogo: os estados.

Estados de jogo: a máquina de estados

Um jogo tem momentos distintos — menu, jogando, game over — e em cada um ele se comporta diferente: no menu, espera você apertar Enter; jogando, move a cobra; no game over, mostra a pontuação e espera reiniciar. Modelar isso com ifs soltos vira um emaranhado. A forma limpa é uma máquina de estados: um enum class (Fase 1) que nomeia os estados, e o game loop que se comporta conforme o estado atual:

// Os estados possíveis do jogo — enum class (Fase 1): forte, sem conversões acidentais.
enum class Estado {
    Menu,
    Jogando,
    GameOver
};

O game loop então ramifica pelo estado atual, e as transições entre estados acontecem em resposta a eventos (apertar Enter no menu → Jogando; morrer → GameOver; apertar Enter no game over → Menu). Isso organiza o jogo inteiro em torno de um único Estado:

Estado estado = Estado::Menu;   // começa no menu

while (!janela.deve_fechar()) {
    switch (estado) {
        case Estado::Menu:
            // No menu: espera Enter para começar.
            if (IsKeyPressed(KEY_ENTER)) {
                reinicia_jogo();          // prepara a cobra, a comida, o placar
                estado = Estado::Jogando; // TRANSIÇÃO para jogando
            }
            break;

        case Estado::Jogando:
            // Jogando: atualiza a cobra; se morrer, vai para game over.
            if (!atualiza_jogo())         // devolve false se a cobra morreu
                estado = Estado::GameOver; // TRANSIÇÃO para game over
            break;

        case Estado::GameOver:
            // Game over: mostra placar, espera Enter para voltar ao menu.
            if (IsKeyPressed(KEY_ENTER))
                estado = Estado::Menu;    // TRANSIÇÃO de volta ao menu
            break;
    }
    // (o desenho também ramifica pelo estado — cada tela desenha diferente)
}

O enum class Estado (Fase 1) nomeia os estados de forma segura (sem conversões acidentais para inteiro), o switch trata cada um, e as transições são atribuições explícitas de estado. É uma máquina de estados clássica, limpa e extensível — adicionar um estado de "pausa" seria acrescentar um valor ao enum e um caso ao switch. Todo jogo real usa essa estrutura, e ela é a espinha que organiza o fluxo entre as telas. (Para jogos com estados mais complexos, o std::variant da Fase 6 permite que cada estado carregue seus próprios dados, uma evolução natural — mas o enum class basta para o Snake.)

O Snake completo, com as três telas

Juntando a lógica e os estados, temos o Snake inteiro — menu, jogo com cobra que cresce, e game over com placar. Eis a estrutura completa (com as funções auxiliares que esboçamos):

#include <raylib.h>
#include <vector>
#include <cstdlib>

int main() {
    Janela janela(800, 600, "Snake Completo");   // RAII (O Primeiro Pixel)

    Estado estado = Estado::Menu;
    std::vector<Celula> cobra;
    Celula direcao{}, comida{};
    int placar = 0;
    float timer = 0;
    const float intervalo = 0.12f;   // a cobra move a cada 0.12s (velocidade do jogo)

    auto reinicia = [&]() {
        cobra = {{20, 15}, {19, 15}, {18, 15}};
        direcao = {1, 0};
        comida = { rand() % COLS, rand() % LINS };
        placar = 0;
        timer = 0;
    };

    while (!janela.deve_fechar()) {
        float dt = GetFrameTime();

        // --- UPDATE por estado ---
        if (estado == Estado::Menu) {
            if (IsKeyPressed(KEY_ENTER)) { reinicia(); estado = Estado::Jogando; }
        }
        else if (estado == Estado::Jogando) {
            // Input de direção (impede inverter diretamente sobre si).
            if (IsKeyPressed(KEY_UP)    && direcao.y == 0) direcao = {0, -1};
            if (IsKeyPressed(KEY_DOWN)  && direcao.y == 0) direcao = {0,  1};
            if (IsKeyPressed(KEY_LEFT)  && direcao.x == 0) direcao = {-1, 0};
            if (IsKeyPressed(KEY_RIGHT) && direcao.x == 0) direcao = { 1, 0};

            // Move em intervalos fixos (não a cada frame — controla a velocidade).
            timer += dt;
            if (timer >= intervalo) {
                timer = 0;
                bool cresceu = false;
                if (!mover_cobra(cobra, direcao, comida, cresceu))
                    estado = Estado::GameOver;
                if (cresceu) placar += 10;
            }
        }
        else if (estado == Estado::GameOver) {
            if (IsKeyPressed(KEY_ENTER)) estado = Estado::Menu;
        }

        // --- RENDER por estado ---
        BeginDrawing();
        ClearBackground(BLACK);
        if (estado == Estado::Menu) {
            DrawText("SNAKE", 320, 200, 60, GREEN);
            DrawText("Enter para jogar", 290, 320, 20, WHITE);
        }
        else if (estado == Estado::Jogando) {
            for (const auto& s : cobra)
                DrawRectangle(s.x*TAM, s.y*TAM, TAM-1, TAM-1, GREEN);
            DrawRectangle(comida.x*TAM, comida.y*TAM, TAM-1, TAM-1, RED);
            DrawText(TextFormat("Placar: %d", placar), 10, 10, 20, WHITE);
        }
        else if (estado == Estado::GameOver) {
            DrawText("GAME OVER", 260, 220, 50, RED);
            DrawText(TextFormat("Placar: %d", placar), 320, 300, 25, WHITE);
            DrawText("Enter para o menu", 280, 360, 20, WHITE);
        }
        EndDrawing();
    }
    return 0;
}

Rode e jogue — menu, a cobra come as maçãs vermelhas e cresce, o placar sobe, e ao bater na parede ou em si mesma, game over. Contemple a integração: o game loop (Um Novo Território), a janela RAII (O Primeiro Pixel), o vector da cobra (Fase 4), o enum class dos estados (Fase 1), a lambda reinicia (Fase 5), o delta time controlando a velocidade — todo o curso num jogo completo. Note o detalhe do timer: a cobra não se move a cada frame (seria rápido demais), mas a cada intervalo de tempo, usando o delta time para medir — uma aplicação elegante do Um Novo Território. Você tem agora dois jogos completos, Pong e Snake, e a base para o terceiro.

Um punhado de bool para controlar telas admite estados impossíveis — jogando e em game over ao mesmo tempo — e o bug aparece na transição, não no estado. O enum class com switch torna isso irrepresentável e deixa o acréscimo de uma tela nova quase mecânico. O timer separado do frame é o outro ponto: a cobra anda em intervalos fixos, não a cada quadro, e o delta time é o que mantém isso igual em qualquer máquina.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Compile e jogue o Snake completo. Descreva as três telas (menu, jogando, game over) e as transições entre elas. O que acontece quando a cobra bate na parede?

Ver resposta

✓ Resposta: Ao jogar: a tela de menu mostra o título "SNAKE" e a instrução "Enter para jogar"; apertando Enter, transita para jogando. Na tela jogando, a cobra verde se move continuamente na direção escolhida pelas setas, come as maçãs vermelhas (crescendo e aumentando o placar em 10), e o placar aparece no canto. Quando a cobra bate na parede (ou em si mesma), transita para game over, que mostra "GAME OVER", o placar final, e "Enter para o menu"; apertando Enter, volta ao menu, pronto para uma nova partida. As transições são: Menu →(Enter)→ Jogando →(morte)→ Game Over →(Enter)→ Menu. Ao bater na parede, o mover_cobra detecta que a nova cabeça saiu da grade, devolve false, e o estado muda para GameOver — a cobra para e a tela de fim aparece.

Exercício 2

Explique por que uma máquina de estados (enum class Estado + switch) é mais limpa que gerenciar as telas do jogo com variáveis booleanas soltas (como bool no_menu; bool jogando; bool game_over;). Que problema as booleanas soltas podem causar?

Ver resposta

✓ Resposta: A máquina de estados com enum class Estado é mais limpa porque garante que o jogo está em exatamente um estado a qualquer momento — o estado é uma única variável que vale Menu, Jogando ou GameOver, nunca dois ao mesmo tempo. Com variáveis booleanas soltas (bool no_menu; bool jogando; bool game_over;), nada impede combinações inválidas: você poderia acidentalmente ter no_menu == true e jogando == true simultaneamente (por esquecer de zerar uma ao ligar outra), colocando o jogo num estado incoerente e imprevisível — talvez desenhando o menu e movendo a cobra ao mesmo tempo. Gerenciar N booleanas exige lembrar de, a cada transição, ligar a certa e desligar todas as outras, o que é propenso a erro e piora exponencialmente com mais estados. O enum class torna estados inválidos irrepresentáveis: só um valor por vez, e o switch trata cada um exaustivamente (o compilador pode até avisar se você esquecer um caso). É a filosofia de "tornar estados inválidos impossíveis" — a mesma lição de tornar intenções visíveis e verificáveis no tipo (Fase 6) — aplicada ao fluxo do jogo.

Exercício 3

No Snake, o input de direção tem uma condição extra: IsKeyPressed(KEY_UP) && direcao.y == 0. Explique por que essa checagem (direcao.y == 0) impede um bug em que a cobra se mataria instantaneamente ao inverter a direção.

Ver resposta

✓ Resposta: A checagem direcao.y == 0 (na tecla para cima) impede a cobra de inverter a direção diretamente sobre si mesma — o que a mataria instantaneamente. Considere: a cobra se move para a direita, e seu corpo se estende à esquerda da cabeça. Se o jogador apertasse "esquerda", a cabeça tentaria entrar na célula imediatamente atrás dela, que é ocupada pelo primeiro segmento do corpo — a cobra colidiria consigo mesma e morreria no ato, um comportamento frustrante e não-intencional. A condição impede isso: direcao.y == 0 só permite virar para cima/baixo quando a cobra não está se movendo verticalmente (ou seja, está se movendo horizontalmente), e a checagem análoga direcao.x == 0 só permite virar para esquerda/direita quando não está se movendo horizontalmente. O efeito é bloquear a inversão de 180 graus (virar diretamente para o sentido oposto ao atual), permitindo apenas curvas de 90 graus — que é exatamente como o Snake deve se comportar. A cobra só pode virar "para os lados" em relação à sua direção atual, nunca voltar sobre o próprio pescoço.

Exercício 4

Explique o papel do timer e do intervalo no movimento da cobra. Por que a cobra não se move a cada frame, e como o delta time é usado para controlar sua velocidade de forma independente da taxa de frames?

Ver resposta

✓ Resposta: O timer acumula o tempo decorrido (somando dt a cada frame), e quando atinge o intervalo (0.12s), a cobra dá um passo e o timer zera. Isso controla a velocidade do jogo: a cobra não se move a cada frame porque, a 60 frames por segundo, ela andaria 60 células por segundo — rápido demais para jogar. Em vez disso, ela se move a cada intervalo de tempo (a cada 0.12s, ou seja, cerca de 8 passos por segundo), um ritmo jogável. O delta time é usado para medir esse tempo de forma independente da taxa de frames: como timer += dt acumula segundos reais (não frames), a cobra se move na mesma cadência (8 passos/s) tanto numa máquina de 60fps quanto numa de 144fps — em ambas, o timer atinge 0.12s no mesmo tempo real. Sem o delta time (se contássemos frames), a velocidade da cobra variaria com o hardware, como o erro que o Um Novo Território alertou. O timer/intervalo desacopla a velocidade lógica do jogo da taxa de renderização, permitindo que a tela atualize a 60fps (suave) enquanto a cobra avança a 8 passos/s (jogável) — uma separação elegante entre "quão rápido desenho" e "quão rápido o jogo evolui".

Exercício 5

Adicione um estado Pausa ao Snake: durante o jogo, apertar P pausa (congela a cobra e mostra "PAUSADO"), e apertar P de novo retoma. Descreva as mudanças no enum, no update e no render, e explique como a máquina de estados torna essa adição simples.

Ver resposta

✓ Resposta: Adicionando o estado Pausa:

// No enum:
enum class Estado { Menu, Jogando, Pausa, GameOver };

// No update de Jogando, adiciona a transição para Pausa:
else if (estado == Estado::Jogando) {
    if (IsKeyPressed(KEY_P)) { estado = Estado::Pausa; }   // pausa
    else { /* ...move a cobra normalmente... */ }
}
// Novo caso no update: em Pausa, P retoma.
else if (estado == Estado::Pausa) {
    if (IsKeyPressed(KEY_P)) estado = Estado::Jogando;    // retoma
    // (não atualiza a cobra: ela fica congelada)
}

// No render, adiciona a tela de pausa:
else if (estado == Estado::Pausa) {
    // desenha a cobra e a comida congeladas (mesmo do Jogando)...
    DrawText("PAUSADO", 300, 260, 40, YELLOW);
}

A máquina de estados torna essa adição simples porque o novo estado se encaixa no padrão existente sem perturbar o resto: acrescenta-se um valor ao enum (Pausa), um caso no update (que trata a transição de/para pausa e simplesmente não move a cobra enquanto pausado), e um caso no render (que desenha o congelamento mais o texto "PAUSADO"). Os outros estados (Menu, Jogando, GameOver) permanecem intocados — a lógica de cada um é isolada em seu próprio caso. É a extensibilidade da máquina de estados: novos estados são adições localizadas, não modificações espalhadas, exatamente como um novo componente ou sistema no ECS. A estrutura que separa os estados claramente é o que permite crescer o jogo sem quebrar o que já funciona — a mesma lição de "estruture para crescer" que atravessa todo o curso.

Comentários

Mais em Linguagem C++

Quando o Contrato se Quebra — Exceções e a Aliança com o RAII
Quando o Contrato se Quebra — Exceções e a Aliança com o RAII

Lançar uma exceção separa o caminho do erro do caminho normal, e é o RAII que…

Pare de Escrever Laços — a Biblioteca <algorithm>
Pare de Escrever Laços — a Biblioteca <algorithm>

Escrever o laço à mão conta como percorrer; chamar o algoritmo diz o que se…

O Primeiro Pixel — Janela, Desenho e Input com raylib
O Primeiro Pixel — Janela, Desenho e Input com raylib

A primeira janela traz de volta uma lição antiga em contexto novo: ela é um…