Da Teoria à Prática: Desenvolvendo o clássico Snake em linguagem C
Motivação
Há algumas semanas, durante as classes de Programação de Computadores do segundo período do curso de Engenharia da Computação, fui introduzido à linguagem de programação C, uma linguagem generalista robusta, capaz de resolver problemas complexos e atender demandas reais do mercado atual. Durante meu aprendizado, percebi que dominar apenas a base teórica não era suficiente para consolidar a grande monta de conceitos que vinham sendo estudados até então, fato esse que me motivou a criar um projeto que possibilitasse a visualização dos tópicos que estava aprendendo.
Em busca de um ponto focal
Ao buscar por um ponto de partida, percebi que recorrer a conteúdos estudados em outras disciplinas seria uma boa forma de praticá-los e, ao mesmo tempo, ampliar meu aprendizado de maneira interdisciplinar no decorrer do projeto.
A partir disso, se destacaram bastante as classes de Álgebra Linear, ministradas em conjunto com a disciplina de Programação de Computadores, no qual o uso e as aplicações de matrizes se tornaram objeto recorrente de discussão em sala de aula, me inspirando a desenvolver um projeto prático que explorasse a teoria das matrizes por meio da linguagem C.
Modelagem conceitual
Analisando minuciosamente a lógica computacional por de trás dos laços de repetições, assunto mais recente abordado em sala, conclui que uma sequencial de caracteres pode ser formada através da iteração de um laço de repetição sobre uma lista de elementos:
for (int i = 0; i < (sizeof(e) / sizeof(*e)); i++)
printf(" %i ", e[i]);
// Console: 0 1 2 3 4 5
Expandindo essa ideia, um par de laços aninhados, por conseguinte, geraria uma cadeia bidimensional de caracteres, essencialmente, uma representação visual de uma matriz:
for (int i = 0; i < (sizeof(e) / sizeof(*e)); i++) {
for (int j = 0; j < (sizeof(e) / sizeof(*e)); j++)
printf(" %i ", e[j]);
printf("\n");
}
Após algumas discussões com colegas que detinham mesmo nível de conhecimento acerca do assunto, concluímos que seria interessante criar um sistema onde as entradas do usuário fossem refletidas numa matriz que representasse um mundo bidimensional, desta forma, escolhi aplicar tal ideia reproduzindo o clássico Snake.
Desenvolvimento
Fundação
Iniciei definindo uma matriz de duas dimensões no topo do programa cujas extensões são guardadas por um valor constante atribuído a CHUNKS:
#define CHUNKS 9
char map[CHUNKS][CHUNKS];
Para organizar melhor o código, criei estruturas para agrupar variáveis relacionadas, uma técnica mais eficiente que encontrei para organizar dados de objetos comuns:
struct Snake {
int x, y, speed, size;
bool crashed, ateAll;
char avatar;
};
struct World {
int fruits, maxFruits, rounds;
char path, wall, fruit;
};
Construção do Mundo Virtual
O próximo passo foi criar uma função para gerar o mapa do jogo. Utilizei caracteres para representar os ladrilhos do cenário, atribuindo aos elementos da borda o valor definido como barreira, enquanto o interior permanece como espaço livre:
void buildMap() {
for (int row = 0; row < CHUNKS; row++) {
for (int column = 0; column < CHUNKS; column++) {
bool isWorldBoundary =
column == 0 || row == 0 ||
column == (CHUNKS - 1) || row == (CHUNKS - 1);
map[row][column] = isWorldBoundary ? world.wall : world.path;
}
}
}
Sistema de Renderização
Para exibir o mundo, criei uma função de renderização que percorre a matriz e lê o valor contido em cada elemento, imprimindo-o no console:
void renderMap(void) {
system("clear");
for (int column = 0; column < CHUNKS; column++) {
for (int row = 0; row < CHUNKS; row++) {
printf(" %c ", map[row][column]);
}
printf("\n");
}
}
Geração de Entidades
Uma vez que o mapa básico foi gerado, precisei posicionar o jogador no centro e distribuir frutas aleatoriamente com base nos elementos da matriz que ainda não possuíam valor. Usei uma semente baseada na data da máquina para garantir aleatoriedade:
srand(time(NULL));
map[snake.x][snake.y] = snake.avatar;
while (world.fruits < world.maxFruits) {
int randomColumn = rand() % CHUNKS;
int randomRow = rand() % CHUNKS;
bool isEmpty = map[randomColumn][randomRow] == world.path;
if (isEmpty) {
map[randomColumn][randomRow] = world.fruit;
world.fruits++;
}
}
Sistema de movimentação
Para o sistema de movimento, implementei uma solução usando arrays paralelos. Um laço descobre qual ação deve ser executada com base em uma classificação do valor de entrada fornecido pelo usuário e então o atribui as coordenadas do jogador se aplicável:
char inputDir;
scanf(" %c", &inputDir);
inputDir = tolower(inputDir);
char dir[] = {'a', 'd', 'w', 's'};
int dx[] = {-1, 1, 0, 0};
int dy[] = { 0, 0, -1, 1};
for (int i = 0; i < strlen(dir); i++) {
if (inputDir == dir[i]) {
int newX = snake.x + dx[i] * snake.speed;
int newY = snake.y + dy[i] * snake.speed;
if (map[newX][newY] == snake.avatar || map[newX][newY] == world.wall)
snake.crashed = true;
else {
snake.x = newX;
snake.y = newY;
}
break;
}
}
Condições de Fim de Jogo
O jogo possui duas condições principais que determinam seu término, implementadas através de um sistema de verificação de colisões e objetivos:
Colisão com Obstáculos: O jogo termina imediatamente se o jogador tentar se mover para uma posição ocupada por uma parede ou pelo próprio corpo, assim como no jogo original.
Vitória por Objetivo Completo: O jogador vence ao coletar todas as frutas disponíveis no mapa.
O laço de execução definido no método principal do jogo verifica essas condições antes de decidir se deve renderizar o próximo quadro com as novas informações inseridas ou se deve interromper a execução:
bool gameOver = false;
while (!gameOver) {
// ... lógica do jogo ...
world.rounds++;
gameOver = snake.crashed || snake.ateAll;
}
Ao final do jogo, o sistema também exibe uma lista de estatísticas relacionadas com o desempenho do jogador:
printf("\n------ ESTATÍSTICAS ------\n");
printf("Rodadas jogadas: %d\n", world.rounds);
printf("Frutas encontradas: %d\n", snake.size);
printf("\n---------------------------\n");
Regras de Design
A estrutura do jogo não foi concebida para rodar em tempo real e, aliado a isso, minhas próprias limitações de conhecimento técnico me levaram a desenvolver o projeto em um formato baseado em turnos.
A partir disso, surgiu a ideia de adotar uma nova lógica para equilibrar essa mudança, concluí que ajustando a regra de crescimento da cobra para que a cada partida ela ganhe uma unidade de massa, o movimento se torna uma decisão estratégica compatível com o formato que o jogo se baseou, já que o crescimento contínuo do jogador eventualmente pode levar à situação onde não há mais movimentos válidos disponíveis para completar o objetivo, resultando em uma colisão com o próprio corpo.
Próximos passos
Em vista da vasta gama de aplicações dos sistemas abordados neste projeto, cogito evoluir algumas de suas partes para uma biblioteca própria voltada ao desenvolvimento de jogos. Para quem quiser conferir o projeto finalizado, estarei disponibilizando o código-fonte no meu perfil do github.
Agradeço a leitura!