Fiz um jogo da cobrinha com objetivo de treinar minhas habibilidades na plataforma Arduino e programação em C++.

O projeto foi todo simulado na plataforma Wokwi, um simulador online de eletrônica com suporte a vários tipos de componentes, incluindo várias placas Arduino.

Preparação

Antes de começar a programar, eu desenhei como ficaria cada uma das telas do jogo. Fiz isso utilizando o Excel. Essa etapa foi muito importante. O display OLED que utilizei tem apenas 128×64 pixels. É relativamente pequeno. Com essas limitações, cada pixel importava e eu não poderia desenhar os gráficos do jogo livremente.

A imagem a seguir mostra uma matriz de 128 colunas com 64 linhas, exatamente a mesma resolução do display. As áreas em cor verde são os locais por onde a cobrinha vai passar, e também onde as frutinhas para ela coletar vão aparecer. As linhas em cor amarela clara indicam um pixel de divisão entre cada célula. A área em cor alaranjada vai mostrar a pontuação atual e o nível que o jogador estar.

Depois de tudo pronto, o jogo deverá se parecer com a imagem a seguir (a borda em cor preta foi adicionada apenas para contraste neste post, ele não vai estar presente no jogo). No total, o local onde a cobrinha vai ser mover tem 200 células (20 colunas e 10 linhas).

Materiais

Não é necessário nenhum material físico além do computador com acesso à internet. Como disse, o projeto foi totalmente simulado no Wokwi. Todos os componentes eletrônicos necessários já são fornecidos pela plataforma.

  • 1 Placa Arduino UNO – o código fonte deve funcionar com qualquer outro tipo de placa arduino que utilize o chip ATmega328p. Podem haver diferenças entre as numerações dos pinos
  • 1 Display OLED SSD 1306 128×64 – minha ideia inicial era utilizar um display Nokia 5110, mas ele não está disponível no Wokwi
  • 5 botões – para as ações de mover a cobrinha para cima, baixo, direita e esquerda, e mais um botão de ação para pausar ou iniciar o jogo
  • Fios – os fios servem para conectar os componentes à placa arduino

Implementação

A intenção é fazer o jogo inteiro rodar numa placa Arduino R3. Os botões e o display estarão ligados à ela. O esquema de conexões é bastante simples, na verdade. Não é necessário adição de resistores ou outros componentes eletrônicos.

A placa Arduino R3 utiliza um chip ATmega328p, um microcontrolador com tamanho de palavra de 8 bits. Isso significa que cada instrução, operação ou manipulação de dados são processados em grupos de 8 bits. Desta forma, é interessante utilizar variáveis com tamanho de 8 bits sempre que possível, pois as operações com essas variáveis serão realizadas de maneira mais rápida. Muitas das variáveis do jogo podem ser representadas com 8 bits.

O ATmega328p possui apenas 2 KB de memória SRAM, isto é, a memória que o programa tem para realizar as tarefas. A implementação deve ser tão econômica quanto possível.

Implementação dos gráficos

Comecei a implementação pelos gráficos. Me parece o início mais apropriado. Uma vez que as rotinas de desenhar o jogo do display OLED estiverem prontas, posso visualizar se a lógica de movimentar a cobrinha e pontuação estão corretas.

As rotinas que vão desenhar os gráficos não têm nenhuma lógica sobre a jogabilidade. Assim, essa parte se preocupa apenas em acender e apagar os pixels nos locais corretos.

Inicialmente construi o código que faz acender uma célula do corpo da cobrinha. O código simplesmente desenha quadrados de 4×4 pixels com cuidado para deixar 1 pixel de distância entre eles. Todas essas célculas são endereçadas a partir da posição [linha = 0, coluna = 0] (posição superior esquerda), até a posição [linha = 9, coluna = 19] (célcula mais à direita da última linha).


void Ssd1306Screen::drawCell(uint8_t row, uint8_t column) {
  display.fillRect(
    SSD1306_GAME_FIELD_X_OFFSET + row * (SSD1306_CELL_SIZE + 1),
    SSD1306_GAME_FIELD_Y_OFFSET + column * (SSD1306_CELL_SIZE + 1),
    SSD1306_CELL_SIZE,
    SSD1306_CELL_SIZE,
    WHITE
  );
}

O resultado aparece na imagem a seguir. Lembrando que esse quadriculado será usado para montar o corpo da cobrinha. Apenas as células onde a cobrinha está posicionada ficarão acesas.

O código que desenha a moldura do placar e do campo de movimento da cobrinha não tem muita complicação. Basta acertar os pixels de início e fim de alguns retângulos. Essas informações podem ser obtidas facilmente pelo Excel com os modelos das telas do jogo.

void Ssd1306Screen::drawGameFrame() {
  display.fillRect(
    SSD1306_GAME_FRAME_UP_X1,
    SSD1306_GAME_FRAME_UP_Y1,
    SSD1306_GAME_FRAME_UP_X2 - SSD1306_GAME_FRAME_UP_X1 + 1,
    SSD1306_GAME_FRAME_UP_Y2 - SSD1306_GAME_FRAME_UP_Y1 + 1,
    WHITE
  );
  display.fillRect(
    SSD1306_GAME_FRAME_DOWN_X1,
    SSD1306_GAME_FRAME_DOWN_Y1,
    SSD1306_GAME_FRAME_DOWN_X2 - SSD1306_GAME_FRAME_DOWN_X1 + 1,
    SSD1306_GAME_FRAME_DOWN_Y2 - SSD1306_GAME_FRAME_DOWN_Y1 + 1,
    WHITE
  );
  display.fillRect(
    SSD1306_GAME_FRAME_LEFT_X1,
    SSD1306_GAME_FRAME_LEFT_Y1,
    SSD1306_GAME_FRAME_LEFT_X2 - SSD1306_GAME_FRAME_LEFT_X1 + 1,
    SSD1306_GAME_FRAME_LEFT_Y2 - SSD1306_GAME_FRAME_LEFT_Y1 + 1,
    WHITE
  );
  display.fillRect(
    SSD1306_GAME_FRAME_CENTER_X1,
    SSD1306_GAME_FRAME_CENTER_Y1,
    SSD1306_GAME_FRAME_CENTER_X2 - SSD1306_GAME_FRAME_CENTER_X1 + 1,
    SSD1306_GAME_FRAME_CENTER_Y2 - SSD1306_GAME_FRAME_CENTER_Y1 + 1,
    WHITE
  );
  display.fillRect(
    SSD1306_GAME_FRAME_RIGHT_X1,
    SSD1306_GAME_FRAME_RIGHT_Y1,
    SSD1306_GAME_FRAME_RIGHT_X2 - SSD1306_GAME_FRAME_RIGHT_X1 + 1,
    SSD1306_GAME_FRAME_RIGHT_Y2 - SSD1306_GAME_FRAME_RIGHT_Y1 + 1,
    WHITE
  );
}

Com a adição da moldura, a tela do jogo agora está parecida com a imagem abaixo.

Para desenhar o placar, vou usar uma recurso da biblioteca de comunicação com o display, que permite desenhar um bitmap direto na tela. Dessa forma, eu crio um mapeamento de bits com o desenho das letras e números e simplesmente passo para a rotina que envia isso para o display, que por sua vez vai acender ou apagar cada pixel específico.

Um detalhe importante é que essa biblioteca exige que o bitmap esteja armazenado na memória do programa, e não na memória RAM. Isso é realizado adicionando algumas instruções extras no código, como a PROGMEM, disponível para no compilador de C++ para o microcontrolador baseado em na arquitetura AVR. Isso é bastante útil, uma vez que o ATmega328p possui apenas seus 2 KB de memória RAM. A memória onde o programa é armazenado tem 32 KB de tamanho.

static const uint8_t lettersBitmaps_C [] PROGMEM = {
  0b01100000,
  0b10000000,
  0b10000000,
  0b10000000,
  0b01100000
};

static const uint8_t numbersBitmaps_7 [] PROGMEM = {
  0b11100000,
  0b00100000,
  0b01000000,
  0b10000000,
  0b10000000
};

display.drawBitmap(3, 8, lettersBitmaps_S, 3, 5, WHITE);
display.drawBitmap(7, 8, lettersBitmaps_C, 3, 5, WHITE);
display.drawBitmap(11, 8, lettersBitmaps_O, 3, 5, WHITE);
display.drawBitmap(15, 8, lettersBitmaps_R, 3, 5, WHITE);
display.drawBitmap(19, 8, lettersBitmaps_E, 3, 5, WHITE);

display.drawBitmap(3, 14, numbersBitmaps[0], 3, 5, WHITE);
display.drawBitmap(7, 14, numbersBitmaps[1], 3, 5, WHITE);
display.drawBitmap(11, 14, numbersBitmaps[2], 3, 5, WHITE);
display.drawBitmap(15, 14, numbersBitmaps[3], 3, 5, WHITE);
display.drawBitmap(19, 14, numbersBitmaps[4], 3, 5, WHITE);
display.drawBitmap(3, 28, numbersBitmaps[5], 3, 5, WHITE);
display.drawBitmap(7, 28, numbersBitmaps[6], 3, 5, WHITE);
display.drawBitmap(11, 28, numbersBitmaps[7], 3, 5, WHITE);
display.drawBitmap(15, 28, numbersBitmaps[8], 3, 5, WHITE);
display.drawBitmap(19, 28, numbersBitmaps[9], 3, 5, WHITE);

O resultado é mostrado na imagem a seguir.

Implementando o gameplay

Com as rotinas de desenho dos gráficos prontas, falta implementar o lógica do gameplay. A ideia é que a cobrinha vá ganhando velocidade à medida que ela cresce. Os valores de velocidade mínima (a velocidade de início do jogo) e a velocidade máxima foram obitidos a partir de alguns testes. Depois do jogo pronto, eu testei alguns parâmetros que me pareceram apropriados, considerando também que o jogo estava rodando no navegador.

Mudando a direção da cobrinha

Sempre que o jogador acionar um dos comando para cima, baixo, esquerda ou direita, a cobrinha vai mudar de direção. A exceção é quando o movimento for contrário ao sentido atual, que nesse caso o comando não vai ter nenhum efeito. Por exemplo, se cobrinha estiver se movimentando para a direita e o jogador acionar o comando para esquerda, nada vai acontecer.

O comando do jogador, no entanto, apenas indica para a cobrinha qual a direção tomar. Ela só vai fazer isso no seu próximo movimento automático. Assim, é possível que no pequeno intervalo de tempo entre um movimento e outro, o jogador possa indicar várias direções. A cobrinha vai pegar a última indicada.

Durante a implementação eu utilizei uma biblioteca para realizar debounce, mas por alguma razão o programa parava de funcionar. Temi que fosse algo relacionado à memória, mas compilando o código na Arduino IDE (fora do Wokwi), o log mostrou que o programa usava apenas cerca de 52% da SRAM. Para os fins desse projeto, não fiz uma análise mais profunda. Resolvi deixar o código, mas com uma flag para habilitar, ou não, essa opção. Na Wokwi, o código utilizado usa apenas o pinMode e digitalRead sem qualquer tratamento de bounce. A investigação desse problema pretendo realizar futuramente.

upPressed = false;
downPressed = false;
leftPressed = false;
rightPressed = false;

if(!digitalRead(UP_PIN)) {
  upPressed = true;
  Serial.println("up");
}
if(!digitalRead(DOWN_PIN)) {
  downPressed = true;
  Serial.println("down");
}
if(!digitalRead(LEFT_PIN)) {
  leftPressed = true;
  Serial.println("left");
}
if(!digitalRead(RIGHT_PIN)) {
  rightPressed = true;
  Serial.println("right");
}

Movimento automático

Esse é o movimento que faz a cobrinha se mover na direção atual. Se o jogador não fizer, a cobrinha vai colidir com a parede do jogo ou com o próprio corpo. A cada intervalo de tempo, a cobrinha se movimento um célula. O intervalo de tempo vai diminuindo conforme o jogador progride em comer as frutinhas, tornando jogo mais difícil.

Movimentar a cabeça da cobrinha não requer uma lógica muito elabora. Basta apenas seguir a direção indicada pelo jogador e então incrementar ou decrementar a posição da linha ou coluna.

Para mover o final da cauda, no entanto, é um pouco mais complicado. A cauda precisa seguir o mesmo caminho realizado pelo resto do seu corpo. Para resolver essa situação eu mantive em cada célula do campo de movimento um valor que indica a direção que a cauda deve seguir. Essa direção é um rastro deixado pela cabeça da cobrinha sempre que ela se movimenta. Com isso, basta eu ler a posição indicada na cauda e realizar o movimento de maneira muito direta sem precisar de qualquer tipo de varredura.

A imagem a seguir ilustra essa ideia. Cada célula possui uma informação sobre a direção a seguir. Isso não usa mais memória do que deveria. A matriz que mantém a informação das células ocupadas já tem 20×10 bytes de tamanho. Eu apenar uso esse byte para colocar um número indicativo de qual direção seguir.

O código para mover a cabeça da cobrinha é parecido como seguinte:

Position nextHeadPosition() {
  Position nextPosition = snake.head;
  if(snakeDirection == RIGHT) {
    nextPosition.column++;
  } else if(snakeDirection == LEFT) {
    nextPosition.column--;
  } else if(snakeDirection == DOWN) {
    nextPosition.row++;
  } else if(snakeDirection == UP) {
    nextPosition.row--;
  }
  return nextPosition;
}

Position targetHeadPosition = nextHeadPosition();
if(checkCollision(targetHeadPosition.row, targetHeadPosition.column)) {

  return;
}

// Mantém o rastro de movimento da cobrinha para ser percorrido pela cauda
moveField[snake.head.row][snake.head.column] = snakeDirection;
snake.head = targetHeadPosition;
screen->drawCell(snake.head.row, snake.head.column);

if(snake.head.row == fruitPosition.row && snake.head.column == fruitPosition.column) {
  fruitPosition = createFruitPosition();
  screen->drawFruit(fruitPosition.row, fruitPosition.column);


  // Evita que a cauda se mova quando a cobrinha come uma fruta
  return;
}

E o código que faz a cauda movimentar é parecido com o seguinte:

Direction tailDirection = moveField[snake.tail.row][snake.tail.column];

// Indica que uma posição antes ocupada pela cobrinha agora vai estar vazia porque ela se moveu
moveField[snake.tail.row][snake.tail.column] = NONE;

if(tailDirection == RIGHT) {
  snake.tail.column++;
} else if(tailDirection == LEFT) {
  snake.tail.column--;
} else if(tailDirection == DOWN) {
  snake.tail.row++;
} else if(tailDirection == UP) {
  snake.tail.row--;
}

Fazendo a cobrinha crescer quando come uma frutinha

Quando a cobrinha come uma frutinha, o movimento da cauda é interrompido por 1 intervalo de tempo. Isso faz com que a cobrinha cresça. Esse comportamento se mantém até que o jogador consiga comer todas as 197 frutinhas que vão aparecer pela tela.

Implementando o placar

A cobrinha vai movimentar inicialmente com velocidade de 1 segundo e diminuindo até chegar ao limite de 100 milissegundos. Isso dá uma diferença de 900 milissegundos. Decrementos de 30 milissegundos vai permitir o jogo ir até o nível 30. A cada 6 frutinhas, o jogo aumenta 1 nível. Em 100 milissegundos e 180 frutinhas, o nível estabiliza e velocidade não diminui mais.

A pontuação é contada como 1 + 2 + 3 + 4 + 5… A quantidade de frutinhas coletadas é adicionada à pontuação sempre que que uma nova frutinha é encontrada. No total, serão cerca de 19 mil pontos.

Verificando o game over

Nessa versão, no game over o jogo vai simplesmente parar. Um novo jogo deve ser iniciado ao interromper e iniciar uma nova simulação no Wokwi.

O game over acontece quando o jogador pega todas as frutas disponíveis fazendo a cobrinha crescer até ocupar todo o campo de jogo, ou quando há uma colisão com as paredes ou com o próprio corpo.

O game over pelo fim das frutinhas é verificado apenas contando quantas foram comidas. O campo de jogo tem 200 células, a cobrinha começa com tamanho 3, então são 197 frutas. A imagem abaixo ilustra um última posição possível. Nela, a cobrinha come a frutinha e a cauda deixa de crescer, ocupando todo campo de jogo.

O game over pela colisão pode ser detectado facilmente ao verificar se a cabeça está indo para uma posição já ocupada pelo corpo ou fora dos limites do campo de jogo.

bool checkCollision(int8_t row, int8_t column) {
  return row < 0
    || row >= FIELD_HEIGHT
    || column < 0
    || column >= FIELD_WIDTH
    || moveField[row][column] != NONE;
}

Conectando as células que formam o corpo da cobrinha e outras melhorias

O jogo está totalmente funcional. Um detalhe que eu gostaria de acrescentar era a conexão entre os quadradinhos que forma o corpo da cobrinha. Imagino que daria um tom bem melhor aos gráficos.

A imagem a seguir mostra as duas versões.

Não desenhei a célula diferente para a boca e a cauda da cobrinha. Também não implementei telas diferentes para o início do jogo, nem telas para o game over. O botão de ação, ficou sem ação. A implementação do jogo custou um pouco mais de tempo do que eu gostaria, mas o resultado ficou muito bom. Todas essas partes vão ficar para trabalhos futuros =)

Conclusão

O jogo pronto!

Eu sabia que o chip ATmega328p possui 3 tipos de meória: flash, SRAM e EEPROM. O que eu não sabia era sobre a possibilidade de manter algumas informações, além do código do programa, na memória flash. Este exercício de construção do jogo da cobrinha acrescentou mais esse conhecimento na caixa de ferramentas. Muito provavelmente, essa prática não é exclusiva dos chips AVR. Talvez seja algo comum na programação de microcontroladores. Vou pesquisar no futuro para entender como algumas famílias de microcontroladores lidam com essa situação.

Este trabalho também é um exercício de usabilidade. O modelo que criei no Excel e que apresentei neste post foi a terceira versão, na verdade. Inicialmente o jogo ocuparia toda a tela do display. Felizmente, essa eu nem cheguei a implementar e, portanto, não perdi tempo escrevendo código. Redesenhei uma segunda vez, e refinei até chegar nessa versão que mostra o placar. Essa versão foi a implementada. Isso demonstra a necessidade de um trabalho prévio nos projetos de programação. Algo parecido com um levantamento de requisitos em projetos de sistemas de software.

Também foi o primeiro projeto desse porte que construi usando a plataforma Wokwi. Me pareceu um ambiente muito robusto para projeto desse tamanho. A compilação falhou algumas vezes, mas não foi algo que atrapalhou. A mensagens com os erros de compilação do próprio compilador são mostrados pra gente , o que facilita encontrar e corrigir.

Lidar com o display OLED também foi enriquecedor. Esse tipo de display é bastante utilizado em projetos makers. Esse display também é vendido em outros formatos e tamanhos. É um conhecimento que pode ser usado em outros projetos.

Por fim, lidar com a pouca memória e outras limitações de um microcontrolador é sempre um bom exerício de lógica de programação. Programar APIs backends, que é o meu trabalho formal no mercado, ganha muito em ter esse pensamento voltado também aos recursos computacionais, que nunca são infinitos!

Para ver o projeto funcionando, basta acessá-lo na plataforma Wokwi usando o link https://wokwi.com/projects/384602095539580929 e dar play. O jogo vai rodar no próprio navegador de internet.

Se ficou interessado, tiver dúvidas, críticas ou sugestões, deixe um comentário.

Abraços!!

Referências

O desenho da fonte 3×5 pixels usada no jogo foi encontrada nessa página: https://fontstruct.com/fontstructions/show/716744/3_by_5_pixel_font.

Mais informações sobre a instrução PROGMEM: https://www.arduino.cc/reference/en/language/variables/utilities/progmem/

A imagem do título desta postagem foi gerada pelo ChatGPT.

Categorized in:

Jogos,

Last Update: 18/01/2024