Imagem e Som — Recursos de Jogo com RAII, e o Início do Snake

Imagem e Som — Recursos de Jogo com RAII, e o Início do Snake

Textura e som entram na mesma lista de arquivo, memória e conexão: alguém precisa liberá-los. A diferença aqui é a posse compartilhada, que justifica o shared_ptr num gerenciador de recursos depois de um curso inteiro começando pelo dono único. E o Snake começa, modelado em coordenadas de grade.
Linguagem C++

13 min de leitura

No artigo Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Pong construímos o Pong, mas ele era feito de retângulos brancos e silêncio absoluto. Jogos de verdade têm imagens — sprites, texturas, fundos — e som — efeitos, música. Hoje aprendemos a carregar e usar esses recursos, e, fiel ao curso, a gerenciá-los com RAII (Fase 2). Porque uma textura não é um dado qualquer: ela ocupa memória da placa de vídeo, e precisa ser liberada quando não é mais usada — o LoadTexture/UnloadTexture da raylib é o fopen/fclose do Nascimento e Morte de um Objeto e o InitWindow/CloseWindow do O Primeiro Pixel de novo, e você já sabe domá-lo. Envolveremos texturas e sons em objetos RAII, para que carreguem no construtor e liberem no destrutor, sem vazamentos. E começaremos a construir o Snake, nosso segundo jogo, introduzindo a ideia de uma grade e o crescimento da cobra. O jogo vai ganhar cara e voz.

Texturas são recursos: o problema do gerenciamento manual

Uma textura é uma imagem carregada na memória da GPU. Carregá-la e liberá-la são operações explícitas na raylib, com o perigo de sempre — se você carregar e esquecer de liberar (ou uma exceção pular a liberação), a memória da GPU vaza:

// Estilo manual — PERIGOSO (o padrão que já sabemos evitar).
Texture2D textura = LoadTexture("jogador.png");   // carrega na GPU
// ... usa a textura ...
// Se um return antecipado ou exceção ocorrer aqui, o unload abaixo é pulado → VAZA.
UnloadTexture(textura);   // precisa lembrar SEMPRE

É exatamente o mesmo padrão de recurso que enfrentamos com arquivos, memória, conexões de banco e a janela. E a solução é exatamente a mesma: RAII.

Envolvendo textura e som em RAII

Criamos classes que carregam o recurso no construtor e o liberam no destrutor, com cópia proibida (um recurso de dono único) e movimento permitido — a Regra dos Cinco do Quando Copiar Dá Errado aplicada a recursos gráficos:

#include <raylib.h>
#include <string>
#include <stdexcept>

// Envolve uma textura da raylib em RAII.
class Textura {
public:
    explicit Textura(const std::string& caminho) {
        tex_ = LoadTexture(caminho.c_str());   // carrega na GPU (adquire)
        if (tex_.id == 0)                       // id 0 = falha ao carregar
            throw std::runtime_error("falha ao carregar textura: " + caminho);
    }
    ~Textura() {
        if (tex_.id != 0) UnloadTexture(tex_);  // libera automaticamente
    }

    // Recurso de dono único: proíbe cópia (Fase 2/8).
    Textura(const Textura&) = delete;
    Textura& operator=(const Textura&) = delete;

    // Permite movimento: transfere a posse (Fase 2, Regra dos Cinco).
    Textura(Textura&& outro) noexcept : tex_(outro.tex_) {
        outro.tex_.id = 0;   // o movido não deve liberar o que já não é seu
    }

    // Desenha a textura numa posição.
    void desenha(int x, int y) const { DrawTexture(tex_, x, y, WHITE); }

    const Texture2D& bruta() const { return tex_; }

private:
    Texture2D tex_{};
};

A classe Som seguiria o mesmo molde, com LoadSound/UnloadSound e um método toca() que chama PlaySound. Contemple a repetição do padrão: exatamente como o BancoSQLite (Extensão) e a Janela (O Primeiro Pixel), carregamos no construtor, liberamos no destrutor, proibimos cópia e permitimos movimento. Um std::vector<Textura> ou um std::map<std::string, Textura> gerenciaria uma coleção de sprites, cada um liberado automaticamente quando o container morre (a Regra do Zero, Fase 2). Os princípios do curso não mudam com o domínio — eles domam recursos gráficos com a mesma elegância com que domaram arquivos e conexões. Você aprende o padrão uma vez e o aplica a tudo.

Um gerenciador de recursos

Jogos carregam muitos recursos e frequentemente os reutilizam (a mesma textura de tijolo aparece cem vezes). Carregar a mesma imagem cem vezes seria desperdício. O padrão é um gerenciador de recursos que carrega cada um uma vez e o compartilha — e aqui os shared_ptr da Fase 2 encontram seu uso natural, pois vários objetos de jogo compartilham a mesma textura:

#include <memory>
#include <unordered_map>
#include <string>

// Carrega cada textura uma vez e a compartilha entre quem precisar.
class GerenciadorTexturas {
public:
    // Devolve a textura, carregando-a se ainda não estiver em cache.
    std::shared_ptr<Textura> obter(const std::string& caminho) {
        auto it = cache_.find(caminho);
        if (it != cache_.end())
            return it->second;                      // já carregada: compartilha (Fase 2)
        auto tex = std::make_shared<Textura>(caminho);
        cache_[caminho] = tex;                      // guarda no cache
        return tex;
    }
private:
    std::unordered_map<std::string, std::shared_ptr<Textura>> cache_;   // Fase 4
};

Aqui o shared_ptr (Fase 2) brilha com propósito genuíno: uma textura é legitimamente compartilhada por muitas entidades (todos os inimigos usam a mesma imagem), então a posse compartilhada é o modelo certo — a textura é liberada quando a última entidade que a usa (e o cache) some. É o caso de uso do shared_ptr que o artigo Posse Compartilhada e seus Perigos — shared_ptr e weak_ptr descreveu, encontrado na vida real. O unordered_map (Fase 4) indexa por caminho, evitando recarregar. Este pequeno gerenciador reúne ponteiros inteligentes, containers e RAII num padrão que jogos reais usam.

Começando o Snake: a grade

Vamos iniciar nosso segundo jogo, o Snake, que introduz um conceito novo: a grade. Diferente do Pong (movimento livre e contínuo), o Snake acontece numa grade de células, e a cobra se move de célula em célula. Modelamos a cobra como uma sequência de posições de grade — e um std::vector (Fase 4) é perfeito para isso:

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

struct Celula { int x, y; };   // posição na GRADE (não em pixels)

const int TAM_CELULA = 20;     // cada célula tem 20x20 pixels
const int COLUNAS = 40, LINHAS = 30;   // grade de 40x30 células (800x600)

int main() {
    Janela janela(800, 600, "Snake");

    // A cobra é uma sequência de células; a cabeça é o primeiro elemento.
    std::vector<Celula> cobra = {{20, 15}, {19, 15}, {18, 15}};   // começa com 3 segmentos
    Celula direcao = {1, 0};   // movendo para a direita

    while (!janela.deve_fechar()) {
        // (a lógica de movimento e crescimento vem no próximo artigo)

        // --- RENDER: desenha cada segmento como um quadrado na grade ---
        BeginDrawing();
        ClearBackground(BLACK);
        for (const auto& seg : cobra)   // percorre a cobra (Fase 4)
            DrawRectangle(seg.x * TAM_CELULA, seg.y * TAM_CELULA,
                          TAM_CELULA - 1, TAM_CELULA - 1, GREEN);
        EndDrawing();
    }
    return 0;
}

A cobra é um std::vector<Celula> — a cabeça no início, o rabo no fim. Cada segmento é desenhado como um quadrado, convertendo a posição de grade (coluna, linha) para pixels (multiplicando por TAM_CELULA). Essa distinção entre coordenadas de grade e de pixel é a essência do Snake, e o vector modela a cobra naturalmente: crescer é adicionar um elemento, mover é adicionar uma cabeça nova e remover o rabo. No próximo artigo completamos a lógica. Por ora, você tem a cobra desenhada e a estrutura montada — e ela já usa o vector da Fase 4 como sua espinha.

Textura e som entram na mesma lista de recursos que arquivo, memória, conexão e janela: alguém precisa liberá-los, e o destrutor é o lugar certo. A diferença aqui é a posse — a mesma textura costuma ser usada por muitos objetos ao mesmo tempo, e é isso que justifica shared_ptr num gerenciador, quando o padrão do curso inteiro foi começar pelo dono único.

Fontes e leituras recomendadas

  • raylib.com/cheatsheet: as funções LoadTexture, UnloadTexture, DrawTexture, LoadSound, PlaySound — os recursos gerenciados aqui .
  • Robert Nystrom, Game Programming Patterns, capítulo "Object Pool" e discussões sobre recursos: padrões de gerenciamento de recursos em jogos.
  • cppreference.com/w/cpp/memory/shared_ptr: revisão do shared_ptr, cujo uso no gerenciador de recursos é o caso canônico de posse compartilhada.
  • "Snake game" (a história e as variações do clássico): contexto sobre o jogo que começamos a construir.
  • raylib examples de texturas e áudio (raylib.com/examples): demonstrações práticas de carregamento e uso de recursos.

Exercícios

Exercício 1

Explique por que uma textura precisa ser gerenciada com RAII, comparando-a com os outros recursos que envolvemos no curso (arquivo, memória, conexão de banco, janela). O que todos têm em comum?

Ver resposta

✓ Resposta: Uma textura precisa de RAII porque é um recurso — ela ocupa memória da placa de vídeo (GPU), que é finita e deve ser explicitamente liberada quando a textura não é mais necessária. Se você carregar texturas e esquecer de liberá-las (ou uma exceção pular a liberação), a memória da GPU vaza, e um jogo que carrega muitas texturas ao longo do tempo acabaria esgotando-a e travando. Isso é idêntico aos outros recursos do curso: um arquivo (artigo Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática) ocupa um descritor do sistema que deve ser fechado; a memória do heap (Fase 2) deve ser liberada; uma conexão de banco (Extensão) deve ser fechada; a janela (O Primeiro Pixel) deve ser destruída. O que todos têm em comum é o padrão adquirir/liberar: um recurso finito do sistema que você obtém explicitamente e é obrigado a devolver, sob pena de vazamento. E a solução é sempre a mesma — RAII: envolver o recurso num objeto que o adquire no construtor e o libera no destrutor, amarrando a liberação ao tempo de vida do objeto, garantindo-a em todos os caminhos de saída. É a universalidade do RAII: um princípio, aplicável a qualquer recurso.

Exercício 2

Escreva uma classe Som RAII análoga à Textura, com LoadSound/UnloadSound no construtor/destrutor, cópia proibida, movimento permitido, e um método toca().

Ver resposta

✓ Resposta: Classe Som RAII:

#include <raylib.h>
#include <string>
#include <stdexcept>
class Som {
public:
    explicit Som(const std::string& caminho) {
        som_ = LoadSound(caminho.c_str());
        if (som_.frameCount == 0)   // falha ao carregar
            throw std::runtime_error("falha ao carregar som: " + caminho);
    }
    ~Som() {
        if (som_.frameCount != 0) UnloadSound(som_);
    }
    Som(const Som&) = delete;
    Som& operator=(const Som&) = delete;
    Som(Som&& outro) noexcept : som_(outro.som_) {
        outro.som_.frameCount = 0;   // o movido não libera
    }
    void toca() const { PlaySound(som_); }
private:
    Sound som_{};
};

Segue exatamente o molde da Textura: carrega no construtor (lançando se falhar), libera no destrutor, proíbe cópia (recurso de dono único), permite movimento (transferindo a posse e zerando a origem), e oferece toca() como interface de uso. O mesmo padrão RAII, aplicado ao som.

Exercício 3

No GerenciadorTexturas, explique por que o shared_ptr é a escolha certa (e não o unique_ptr). Que característica do uso de texturas em jogos justifica a posse compartilhada?

Ver resposta

✓ Resposta: No GerenciadorTexturas, o shared_ptr é a escolha certa porque uma textura em jogos é genuinamente compartilhada por múltiplos objetos ao mesmo tempo: a mesma imagem (digamos, o sprite de um inimigo) é usada por todos os inimigos na tela simultaneamente, além de estar guardada no cache do gerenciador. Ou seja, há vários "donos" legítimos da mesma textura vivos ao mesmo tempo, e a textura só deve ser liberada quando o último deles (a última entidade que a usa, mais o cache) parar de precisar dela. Essa é exatamente a semântica de posse compartilhada que o shared_ptr implementa via contagem de referências — a textura vive enquanto houver ao menos um shared_ptr apontando para ela, e é liberada automaticamente quando a contagem chega a zero. O unique_ptr não serviria porque ele impõe um único dono: não permitiria que várias entidades e o cache compartilhassem a mesma textura, forçando ou cópias (desperdício) ou uma gestão manual de quem é o "dono verdadeiro". A característica que justifica a posse compartilhada é o reúso simultâneo por muitas entidades — precisamente o cenário para o qual o shared_ptr foi feito (artigo Posse Compartilhada e seus Perigos — shared_ptr e weak_ptr).

Exercício 4

Explique a distinção entre coordenadas de grade (coluna, linha) e de pixel no Snake. Por que a cobra é modelada em coordenadas de grade, e como se converte para pixels na hora de desenhar?

Ver resposta

✓ Resposta: Coordenadas de grade referem-se à posição em termos de células — coluna e linha (por exemplo, "coluna 20, linha 15"), como um tabuleiro de xadrez. Coordenadas de pixel referem-se à posição real na tela em pixels (por exemplo, "pixel 400, pixel 300"). A cobra é modelada em coordenadas de grade porque o Snake é, por natureza, um jogo baseado em grade: a cobra ocupa células inteiras e se move de célula em célula (não continuamente), a comida aparece numa célula, e as colisões são "a cabeça está na mesma célula que...". Pensar em células torna toda a lógica simples e discreta (números inteiros, comparações exatas), evitando a imprecisão de ponto flutuante. Na hora de desenhar, converte-se de grade para pixel multiplicando pela dimensão da célula: um segmento na célula (coluna, linha) é desenhado no pixel (coluna * TAM_CELULA, linha * TAM_CELULA). Assim, a célula (20, 15) com TAM_CELULA = 20 vira o pixel (400, 300). A grade governa a lógica; a conversão para pixel governa a exibição.

Exercício 5

A cobra é um std::vector<Celula>. Descreva, conceitualmente, como as operações de "mover a cobra" e "fazer a cobra crescer" se traduzem em operações sobre esse vetor. (Dica: mover envolve adicionar uma cabeça e remover um rabo; crescer, só adicionar a cabeça.)

Ver resposta

✓ Resposta: As operações sobre o std::vector<Celula> que representa a cobra: - Mover a cobra: adiciona-se uma nova cabeça na direção do movimento (a célula adjacente à cabeça atual, no sentido em que a cobra vai) no início do vetor, e remove-se o último elemento (o rabo) do fim do vetor. O efeito líquido é que a cobra "desliza" uma célula: ganha uma célula na frente e perde uma atrás, mantendo o mesmo comprimento. Em código, seria algo como cobra.insert(cobra.begin(), nova_cabeca); cobra.pop_back();. - Fazer a cobra crescer (quando come a comida): adiciona-se a nova cabeça no início, mas não se remove o rabo. Assim a cobra fica um segmento mais longa — ela avançou uma célula na frente sem encurtar atrás. Em código: cobra.insert(cobra.begin(), nova_cabeca); (sem o pop_back). A elegância é que o std::vector modela a cobra naturalmente: a sequência ordenada de segmentos (cabeça no início, rabo no fim) é exatamente uma sequência, e crescer/mover são inserções e remoções nas pontas. (Nota de eficiência: inserir no início de um vector é O(n) porque desloca todos os elementos; num jogo real, um std::deque — fila de duas pontas — seria mais eficiente para adicionar/remover nas duas extremidades, ou manter a cabeça no fim. Para o Snake, cujo tamanho é modesto, o vector serve bem, mas é uma otimização legítima que a Fase 4 nos ensinou a considerar.)

Comentários

Mais em Linguagem C++

Projetando Abstrações — Encapsulamento, Invariantes e o Ponteiro this
Projetando Abstrações — Encapsulamento, Invariantes e o Ponteiro this

A palavra class não é o que distingue um tipo projetado de um punhado de…

A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas
A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas

Consultar sem escrever laço exige que o filtro chegue como parâmetro, e é aí…

Funções Anônimas e Capturas — Lambdas de Verdade
Funções Anônimas e Capturas — Lambdas de Verdade

O que distingue uma lambda de uma função comum é a captura: ela leva consigo o…