Destrua a complexidade: Tetris do zero em C++ com SFML

Este tutorial ensina como criar o jogo Tetris do zero usando C++ e SFML. Vamos começar com conceitos básicos e construir o conhecimento passo a passo, explicando cada parte de forma clara e detalhada.
O que é Tetris
Imagine um jogo onde peças de diferentes formatos caem do céu como chuva, e você precisa organizá-las de forma inteligente para que se encaixem perfeitamente. É como um quebra-cabeças em movimento, onde:
- Peças geométricas (chamadas de "tetrominós") caem de cima para baixo
- Você pode mover as peças para esquerda e direita
- Você pode girar as peças para encaixá-las melhor
- Quando uma linha horizontal fica completamente preenchida, ela desaparece
- O objetivo é durar o máximo de tempo possível sem deixar as peças chegarem ao topo
Este jogo é perfeito para aprender conceitos fundamentais de programação como arrays bidimensionais, rotação de objetos, detecção de colisões e manipulação de dados.
A Matemática Secreta dos Tetrominós
O que são Tetrominós?
Tetrominós são formas geométricas feitas de exatamente 4 quadrados conectados. Existem apenas 7 formas diferentes possíveis:
graph TD
A[I - Linha] --> B[████]
C[Z - Zigue-zague] --> D[██<br/> ██]
E[S - Zigue-zague reverso] --> F[ ██<br/>██]
G[T - Formato T] --> H[███<br/> █]
I[L - Formato L] --> J[█<br/>█<br/>██]
K[J - L reverso] --> L[ █<br/> █<br/>██]
M[O - Quadrado] --> N[██<br/>██]
Como Representar Formas com Números
A parte mais "inteligente" do nosso Tetris é como representamos cada peça usando apenas números. Em vez de desenhar cada forma, usamos um sistema de coordenadas em uma grade 4x4:
graph TB
subgraph "Grade 4x4"
A[0,1,2,3<br/>4,5,6,7<br/>8,9,10,11<br/>12,13,14,15]
end
subgraph "Peça I"
B[1,3,5,7]
end
subgraph "Peça O"
C[2,3,4,5]
end
Vamos entender como isso funciona:
int figures[7][4] = {
1, 3, 5, 7, // I - Linha vertical
2, 4, 5, 7, // Z - Zigue-zague
3, 5, 4, 6, // S - Zigue-zague reverso
3, 5, 4, 7, // T - Formato T
2, 3, 5, 7, // L - Formato L
3, 5, 7, 6, // J - L reverso
2, 3, 4, 5, // O - Quadrado
};
Como converter números em posições:
Para transformar um número em coordenadas (x, y):
int x = numero % 2; // Resto da divisão por 2 = coluna
int y = numero / 2; // Divisão inteira por 2 = linha
Por exemplo, o número 5:
- x = 5 % 2 = 1 (coluna 1)
- y = 5 / 2 = 2 (linha 2)
Isso significa que a posição 5 está na coluna 1, linha 2 da nossa grade 4x4.
A Estrutura do Jogo
O Campo de Jogo - Uma Grade Inteligente
O campo de jogo é como uma folha de papel quadriculado, onde cada quadradinho pode estar vazio (0) ou preenchido com uma cor (1 a 7):
const int M = 20; // 20 linhas de altura
const int N = 10; // 10 colunas de largura
int field[M][N] = {0}; // Inicializa tudo com 0 (vazio)
Por que 20x10?
- É o tamanho clássico do Tetris original
- Oferece desafio suficiente sem ser impossível
- Permite que as peças tenham espaço para manobrar
Representando as Peças Ativas
Cada peça tem 4 blocos, então usamos duas estruturas de dados:
struct Point {
int x, y; // Coordenadas de cada bloco
} a[4], b[4]; // a = posição atual, b = posição anterior
Por que duas arrays?
- a[4]: Posição atual da peça (onde ela está agora)
- b[4]: Posição anterior da peça (onde ela estava antes)
- Se um movimento for inválido, copiamos b para a (desfazemos o movimento)
As Principais Mecânicas do Jogo
1. Verificação de Colisões - A Função Mais Importante
Esta é a função que decide se um movimento é válido ou não:
bool check() {
for (int i = 0; i < 4; i++) {
// Verifica se a peça saiu dos limites da tela
if (a[i].x < 0 || a[i].x >= N || a[i].y >= M) return false;
// Verifica se a peça colidiu com algo já no campo
else if (field[a[i].y][a[i].x]) return false;
}
return true; // Movimento é válido
}
O que esta função verifica:
flowchart TD
A[Verificar cada bloco da peça] --> B{Está fora dos limites?}
B -->|Sim| C[Movimento INVÁLIDO]
B -->|Não| D{Colidiu com peça existente?}
D -->|Sim| C
D -->|Não| E[Continuar verificando]
E --> F{Todos os 4 blocos OK?}
F -->|Sim| G[Movimento VÁLIDO]
F -->|Não| A
2. Movimento Horizontal - Esquerda e Direita
O movimento horizontal é simples mas usa um truque inteligente:
// Salvar posição atual
for (int i = 0; i < 4; i++) {
b[i] = a[i]; // Guardar posição antiga
a[i].x += dx; // Mover para nova posição
}
// Se o movimento for inválido, desfazer
if (!check()) {
for (int i = 0; i < 4; i++) {
a[i] = b[i]; // Voltar para posição anterior
}
}
A estratégia "Tentar e Desfazer":
- Salvar a posição atual em
b - Mover a peça para a nova posição em
a - Verificar se a nova posição é válida
- Se não for válida, copiar
bde volta paraa
3. Rotação - A Parte Mais Matemática
A rotação é baseada em matemática de transformação de coordenadas. Giramos cada bloco 90 graus ao redor do segundo bloco da peça:
if (rotate) {
Point p = a[1]; // Centro de rotação (segundo bloco)
for (int i = 0; i < 4; i++) {
// Calcular posição relativa ao centro
int x = a[i].y - p.y;
int y = a[i].x - p.x;
// Aplicar rotação de 90 graus
a[i].x = p.x - x;
a[i].y = p.y + y;
}
// Se a rotação for inválida, desfazer
if (!check()) {
for (int i = 0; i < 4; i++) {
a[i] = b[i];
}
}
}
Como funciona a rotação matemática:
graph TD
A[Ponto Original: x,y] --> B[Relativo ao Centro: x-cx, y-cy]
B --> C[Rotação 90°: -y, x]
C --> D[Volta ao Sistema: cx-y, cy+x]
E[Exemplo: 3,1 com centro 2,2] --> F[Relativo: 1,-1]
F --> G[Rotacionado: 1,1]
G --> H[Final: 1,3]
4. Queda Automática - O Tick do Jogo
As peças caem sozinhas seguindo um cronômetro:
float timer = 0, delay = 0.3; // 0.3 segundos entre cada queda
if (timer > delay) {
// Salvar posição atual
for (int i = 0; i < 4; i++) {
b[i] = a[i];
a[i].y += 1; // Mover para baixo
}
// Se não conseguir mover para baixo, fixar a peça
if (!check()) {
for (int i = 0; i < 4; i++) {
field[b[i].y][b[i].x] = colorNum; // Fixar no campo
}
// Gerar nova peça
colorNum = 1 + rand() % 7;
int n = rand() % 7;
for (int i = 0; i < 4; i++) {
a[i].x = figures[n][i] % 2;
a[i].y = figures[n][i] / 2;
}
}
timer = 0; // Resetar cronômetro
}
A Lógica de Eliminar Linhas
Detectando Linhas Completas
A parte mais inteligente do Tetris é como removemos linhas completas. Usamos um algoritmo de "compactação":
int k = M - 1; // Ponteiro para onde escrever
for (int i = M - 1; i > 0; i--) { // Ler de baixo para cima
int count = 0;
// Contar quantos blocos há nesta linha
for (int j = 0; j < N; j++) {
if (field[i][j]) count++;
field[k][j] = field[i][j]; // Copiar linha
}
// Se a linha não está completa, manter ela
if (count < N) k--;
}
Como Funciona o Algoritmo
sequenceDiagram
participant Leitura as Linha de Leitura (i)
participant Escrita as Linha de Escrita (k)
participant Campo as Campo de Jogo
Leitura->>Campo: Ler linha de baixo para cima
Campo->>Leitura: Contar blocos na linha
Leitura->>Escrita: Copiar linha para posição k
alt Linha incompleta
Escrita->>Escrita: k-- (mover ponteiro para cima)
else Linha completa
Escrita->>Escrita: k permanece (linha será sobrescrita)
end
Por que isso funciona:
- Começamos de baixo para cima
- Copiamos cada linha para a posição
k - Se a linha não está completa, movemos
kpara cima - Se a linha está completa,
knão se move (linha será sobrescrita) - Linhas completas "desaparecem" naturalmente
Exemplo prático:
Antes: Depois:
████████████ ████████████
██████████ ██████████
████████████ ████████████
████████████ ████████
████████ ████████
Gerando Peças Aleatórias
Como Criar Peças Novas
Quando uma peça é fixada, criamos uma nova peça aleatória:
colorNum = 1 + rand() % 7; // Cor aleatória de 1 a 7
int n = rand() % 7; // Forma aleatória de 0 a 6
for (int i = 0; i < 4; i++) {
a[i].x = figures[n][i] % 2; // Posição X do bloco
a[i].y = figures[n][i] / 2; // Posição Y do bloco
}
Por que Começar no Topo?
As peças novas sempre aparecem no topo da tela porque:
- figures[n][i] representa posições de 0 a 15
- figures[n][i] / 2 dá valores de 0 a 7 (linhas do topo)
- Isso garante que as peças sempre começam visíveis
O Sistema de Cores
Cada Peça Tem Sua Cor
int colorNum = 1; // Cor da peça atual (1 a 7)
Mapeamento de cores:
- 0: Vazio (preto)
- 1-7: Diferentes cores para cada tipo de peça
Como Desenhar com Cores
// Para peças já fixadas no campo
s.setTextureRect(IntRect(field[i][j] * 18, 0, 18, 18));
// Para a peça atual em movimento
s.setTextureRect(IntRect(colorNum * 18, 0, 18, 18));
Cada cor é uma seção de 18x18 pixels na textura.
Otimizações
Por que Usar Arrays de Tamanho Fixo?
Point a[4], b[4]; // Sempre exatamente 4 pontos
Vantagens:
- Cada tetrominó tem exatamente 4 blocos
- Arrays de tamanho fixo são mais rápidos
- Menos uso de memória
- Código mais simples
Por que Não Usar Vetores Dinâmicos?
Para um jogo simples como Tetris:
- A complexidade extra não vale a pena
- Arrays fixos são mais eficientes
- O código fica mais fácil de entender
A Matemática do Timing
Controlando a Velocidade
float timer = 0, delay = 0.3;
Como funciona:
timeracumula o tempo passadodelaydefine quantos segundos entre cada queda- Quando
timer > delay, a peça desce um nível
Calculando FPS
Se o jogo roda a 60 FPS:
- delay = 0.3 segundos
- Peça cai a cada 0.3 × 60 = 18 frames
- Isso dá tempo suficiente para o jogador pensar e agir
Detecção de Game Over
Quando o Jogo Termina?
O jogo termina quando uma nova peça não consegue ser colocada no topo:
// Após gerar nova peça
if (!check()) {
// Game Over - peça não cabe no topo
}
Embora o código atual não implemente explicitamente o game over, a lógica está lá: se check() retornar false para uma peça recém-criada, significa que o campo está cheio.
Conceitos Avançados
Por que Usar Coordenadas Relativas?
Quando geramos peças, usamos coordenadas relativas (0-15) que depois convertemos para coordenadas absolutas da tela. Isso permite:
- Fácil mudança de tamanho: Alterar o tamanho dos blocos não quebra o jogo
- Rotação simples: Matemática de rotação funciona melhor com coordenadas relativas
- Reutilização: O mesmo código serve para diferentes posições na tela
A Beleza da Simplicidade
O Tetris prova que jogos incríveis podem ter código simples:
- Apenas 154 linhas de código
- Usa conceitos básicos: arrays, loops, condicionais
- Sem classes complexas ou padrões de design rebuscados
- Foca na lógica do jogo, não em arquitetura
Possíveis Melhorias
1. Sistema de Pontuação
int score = 0;
int linesCleared = 0;
// Após eliminar linha
score += 100 * level;
linesCleared++;
2. Níveis de Dificuldade
int level = 1;
delay = 0.5 - (level * 0.05); // Fica mais rápido a cada nível
3. Previsão da Próxima Peça
int nextPiece = rand() % 7;
// Mostrar nextPiece na interface
4. Sistema de Pausa
bool paused = false;
if (Keyboard::isKeyPressed(Keyboard::P)) paused = !paused;
Por que este Código Funciona Tão Bem
1. Separação Clara de Responsabilidades
check(): Valida movimentos- Loop principal: Gerencia input e timing
- Algoritmo de linha: Remove linhas completas
2. Uso Inteligente de Dados
- Arrays 2D para o campo
- Arrays 1D para peças
- Números simples para representar formas
3. Algoritmos Eficientes
- Verificação de colisão: O(1) por bloco
- Remoção de linhas: O(n) onde n é o número de linhas
- Rotação: O(1) por bloco
4. Matemática Simples mas Poderosa
- Módulo e divisão para conversão de coordenadas
- Rotação usando transformação linear
- Timing baseado em acumulação de tempo
O Tetris Como Ferramenta de Aprendizado
Este jogo é perfeito para iniciantes porque ensina:
- Arrays bidimensionais: O campo de jogo
- Estruturas de dados: Points para representar blocos
- Algoritmos: Detecção de colisão, remoção de linhas
- Matemática básica: Rotação, conversão de coordenadas
- Game loop: Timing, input, renderização
- Lógica booleana: Verificações de validade
Cada conceito é usado de forma prática e imediata, tornando o aprendizado mais efetivo do que estudar teoria abstrata.
Conclusão
O Tetris é um exemplo perfeito de como um jogo simples pode ensinar conceitos profundos de programação. Sua beleza está na simplicidade elegante: com menos de 200 linhas de código, temos um jogo completo e divertido que demonstra arrays, algoritmos, matemática e lógica de jogos.
O código não é apenas funcional - é educativo. Cada linha tem um propósito claro, cada algoritmo resolve um problema específico, e o resultado final é um jogo que diverte e ensina ao mesmo tempo.