Tutorial de programação do jogo da serpente em javascript

Programando o clássico jogo da serpente usando o framework p5.js. Tutorial indicado para iniciantes da programação que querem aprender os conceitos básico da área criando jogos.

Postado em 08 dezembro 2022

Atualizado em 08 dezembro 2022



Introdução

O jogo da serpente é um jogo que já existia desde os anos 80. Além de ser um jogo simples, não necessita de muitos requerimentos para sua funcionalidade.

O desenvolvimento do jogo da serpente é um bom candidato para programadores que ainda estão na fase de aprendizado tentando aprender os conceitos básicos de programação.

Esse projeto irá mostrar como desenvolver um jogo da serpente bastante simples, utilizando javascript como linguagem de programação e p5.js como framework.

Editor utilizado

Estarei utilizando o editor online disponibilizado pelo p5.js que pode ser acessado pelo link abaixo:
https://editor.p5js.org/

Ao clicar no link acima, o editor já estará com duas funções escritas.

function setup() {
  createCanvas(400, 400);
}

function draw() {
  background(220);
}

A função “setup” é responsável pela inicialização de variáveis e a invocação de alguns métodos que só precisam ser executados apenas uma vez.

A função “draw” é repetida infinitamente até que o usuário feche a janela do navegador.

Ao executarmos o programa acima no editor do p5.js, veremos uma janela cinza com tamanho de 400x400. A cor e o tamanho da janela estão sendo definidos pela função “background()” e “createCanvas()” respectivamente.

Definindo a lógica do jogo da serpente

Conceitos básicos serão definidos antes do desenvolvimento entrar em prática. Os conceitos são:

Atributo Valor
Comprimento da tela 600
Altura da tela 400
FPS 10
Tamanho do corpo da serpente 25
Tamanho da fruta 25

Os atributos acima serão constantes durante o jogo inteiro. O corpo da serpente será composto por vários blocos quadrados com tamanho 25. A fruta será um círculo com o mesmo tamanho do que um bloco do corpo da serpente. A tela será atualizada 10 vezes a cada segundo (10 FPS).

conceitos básicos do jogo da serpente

Definindo a classe do bloco que compõe a serpente

Como já dito anteriormente, o corpo da serpente será constituída de blocos. Quanto mais frutas a serpente come, mais comprido seu corpo se torna.

class Body {
    constructor(x, y, currentDirection) {
	    this.size = 25;
	    this.direction = currentDirection;
	    this.x = x;
	    this.y = y;
    }
}

Ao todo, a classe “Body” possui quatro variáveis. Os detalhes estão escritos na tabela abaixo:

Atributo Valor
size Tamanho do bloco
direction Direção atual que o bloco se desloca
x Coordenada horizontal
y Coordenada vertical

O movimento da serpente acontece em cascata, ou seja, os blocos devem seguir o mesmo caminho que a cabeça da serpente traçou. Por isso, cada bloco deve ter uma variável responsável pela sua direção atual.

direção serpente e cada bloco

Definindo a classe da serpente

A classe da serpente será a classe principal do jogo. Ela será responsável pelas seguintes funções:

  • Exibir a serpente
  • Adicionar novo bloco após comer uma fruta
  • Gerenciar o movimento de cada bloco

Definindo a classe de inicialização

A classe da serpente irá manter a variável responsável pelo gerenciamento dos blocos.

constructor() {
    this.body = [];
    let newBody = new Body(screenWidth / 2, screenHeight / 2, null);
    
    // Adiciona bloco ao corpo da serpente
    this.body.push(newBody);
  }

Ao ser inicializada pela primeira vez, a classe da serpente irá exibir um bloco no centro da tela. Esse bloco será o primeiro bloco no corpo da serpente, consequentemente sendo a cabeça da serpente.

Exibindo a serpente

A função “draw()” será responsável por exibir todos os blocos do corpo da serpente.

draw() {
    for (let i = 0; i < this.body.length; i++) {
      square(this.body[i].x, this.body[i].y, this.body[i].size);
    }
}

Os blocos serão exibidos um de cada vez, como mostra na função acima.

Adicionando blocos ao corpo da serpente

Conforme a serpente se alimenta, seu corpo vai se tornando mais comprido. Ao consumir uma fruta, um novo bloco será adicionado na sua cauda.

addBody() {
    let lastBody = this.body[this.body.length - 1];
    let currentDirection = this.calculateDirection(lastBody.direction);
    let positionX = lastBody.x - (lastBody.size * currentDirection.horizontalDirection);
    let positionY = lastBody.y - (lastBody.size * currentDirection.verticalDirection);
    let newBody = new Body(positionX, positionY, lastBody.direction);
    this.body.push(newBody);
}

Dependendo da direção atual do último bloco da serpente, a posição que o novo bloco será adicionado será diferente. A lógica do algoritmo acima pode ser representado na tabela abaixo:

Direção último bloco Posição novo bloco
Esquerda Direita
Direita Esquerda
Cima Baixo
Baixo Cima

Se o último bloco estiver direcionado para a esquerda (avançando para a esquerda), o novo bloco deverá ser adicionado à direita do último bloco.

Implementando a função de movimento da serpente

O deslocamento de cada bloco do corpo da serpente irá seguir a direção atual do bloco. Se um bloco estiver direcionado para a direita, ele irá ser deslocado para a direita e assim por diante.

Uma vez que o bloco se deslocou uma posição, este bloco irá herdar a direção do bloco na sua frente. A herança de direções irá acontecer com todos os blocos até chegar na cabeça da serpente que irá ter a sua direção controlada pelo usuário.

move() {
    // Calcula o deslocamento da cobra baseado na direção
    let headDirection = this.calculateDirection(this.body[0].direction);
    this.body[0].x += this.body[0].size * headDirection.horizontalDirection;
    this.body[0].y += this.body[0].size * headDirection.verticalDirection;
    
    for (let i = this.body.length - 1; i > 0; i--) {
      let currentBodyDirection = this.calculateDirection(this.body[i].direction);
      
      // Deslocamento
      this.body[i].x += this.body[i].size * currentBodyDirection.horizontalDirection;
      this.body[i].y += this.body[i].size * currentBodyDirection.verticalDirection;
      
      // Herda direção do bloco seguinte
      this.body[i].direction = this.body[i - 1].direction;
    }
}

O algoritmo responsável por calcular a direção e está presente em quase todas as funções da serpente é o “calculateDirection()”.

calculateDirection(currentDirection) {
    let horizontalDirection = 0;
    let verticalDirection = 0;
    
    switch (currentDirection) {
      case direction.left:
        horizontalDirection = -1;
        break;
      case direction.right:
        horizontalDirection = 1;
        break;
      case direction.up:
        verticalDirection = -1;
        break;
      case direction.down:
        verticalDirection = 1;
        break;
    }
    
    return {
      horizontalDirection: horizontalDirection,
      verticalDirection: verticalDirection,
    };
  }

As fórmulas de deslocamento podem ser representadas pelas resoluções abaixo:
direita=x+size1esquerda=x+size1cima=y+size1baixo=y+size1horizontalparado=x+size0verticalparado=y+size0 direita = x + size * 1 \\ esquerda = x + size * -1 \\ cima = y + size * -1 \\ baixo = y + size * 1 \\ horizontal parado = x + size * 0 \\ vertical parado = y + size * 0

Por exemplo, se a coordenada atual x do bloco é 200 e sua direção for para a direita, o resultado da coordenada x após o deslocamento será 225. Caso a direção seja para a esquerda, a coordenada x será 175.

coordenadas jogo da serpente

Inicializando a serpente no escopo global

Uma vez que a classe serpente está pronta, só precisamos invocá-la para ver o resultado.

const screenWidth = 600;
const screenHeight = 400;

function setup() {
  createCanvas(screenWidth, screenHeight);
  frameRate(10);
  snake = new Snake();
}

function draw() {
  background(220);
  snake.draw();
  snake.move();
}

Definindo os controles de entrada

A serpente irá ser controlada através das setas pelo usuário. O p5.js já possui funcionalidades que agilizam o processo. O algoritmo abaixo já é o suficiente para gerenciar os controles.

var isRunning = true;
var direction = {
  up: 38,
  down: 40,
  left: 37,
  right: 39
};

...

function keyPressed() {
  if (! isRunning) {
    return;
  }
  
  if (keyCode === UP_ARROW && snake.body[0].direction != direction.down) {
    snake.body[0].direction = direction.up;
  } else if (keyCode === DOWN_ARROW && snake.body[0].direction != direction.up) {
    snake.body[0].direction = direction.down;
  } else if (keyCode === LEFT_ARROW && snake.body[0].direction != direction.right) {
    snake.body[0].direction = direction.left;
  } else if (keyCode === RIGHT_ARROW && snake.body[0].direction != direction.left) {
    snake.body[0].direction = direction.right;
  }
}

Como é possível observar acima, as teclas mudam a direção da cabeça da serpente. O deslocamento da serpente é realizado em outro algoritmo e não sofre influência das teclas diretamente.

Definindo classe da fruta

A fruta terá apenas três atributos:

Atributo Função
size Tamanho da fruta
x Coordenada horizontal
y Coordenada vertical

Com esses atributos podemos definir o local na tela que a fruta será exibida e baseando-se nessas variáveis, implementaremos o detector de colisões mais adiante.

class Food {  
  constructor(x, y, size) {
    this.size = size;
    this.x = x;
    this.y = y;
  }
  
  draw() {
    circle(this.x, this.y, this.size);
  }
}

Queremos que a fruta seja exibida em lugares aleatórios e que estejam alinhados com o trajeto que a serpente pode percorrer. Para fazer isso, precisamos implementar um algoritmo dependente do tamanho da tela e o tamanho da fruta. Ainda dentro da classe da fruta, adicionaremos o seguinte algoritmo:

static instantiate() {
    // Tamanho da fruta
    let size = 25;
	
	// Quantidade de espaços que a fruta pode ocupar 
    let screenPosX = screenWidth / size;
    let screenPosY = screenHeight / size;

	// Deixa a fruta alinhada com o trajeto que a serpente pode traçar
    let foodX = round(random(0, screenPosX - 1)) * size + (size / 2);
    let foodY = round(random(0, screenPosY - 1)) * size + (size / 2);

	// Inicializa a fruta
    let newFood = new Food(foodX, foodY, size);
    return newFood;
 }

A quantidade de espaços que a fruta pode ocupar pode ser calculada da seguinte maneira:
screenPosX=screenWidth/sizescreenPosX=600/25screenPosX=24 screenPosX = screenWidth / size \\ screenPosX = 600/25 \\ screenPosX = 24

São 24 espaços que podem ser ocupados pela fruta na horizontal de modo que ela fique alinhada com a serpente. Lembrando que um bloco que compõem o corpo da serpente também tem tamanho 25. Isso significa que um bloco do corpo da serpente só pode ocupar 24 espaços na horizontal.

A quantidade de espaços na vertical segue o mesmo raciocínio, tendo um resultado de 16 espaços.

Em seguida, um cálculo usando valores aleatórios são executados para determinar as coordenadas da fruta.

round(random(0, screenPosX - 1))

A função “random” aceita dois parâmetros: mínimo e máximo. O resultado retornado será um valor entre esses dois parâmetros. No caso da resolução acima, um valor entre 0 e 23 será devolvido. A função “round” tem o papel de arredondar o número retornado, uma vez que a função “random” retorna um valor float.

O valor acima será multiplicado pelo tamanho da fruta e somado com o tamanho da fruta.

... * size + (size / 2);

O motivo da divisão se dá pelo modo de como o círculo é desenhado. Na biblioteca p5.js o círculo é expandido a partir do centro. A imagem abaixo ilustra muito bem a diferença entre o ponto inicial do quadrado e do círculo.

ponto inicial do quadrado e do círculo

Implementação do detector de colisões

O detector de conflitos irá buscar por conflitos a cada frame. Teremos três tipos de detector de colisões:

  • Colisão entre a serpente e a fruta
  • Colisão entre a serpente e os limites da tela
  • Colisão entre a cabeça da serpente e seu próprio corpo

Os dois últimos conflitos finalizam o jogo imediatamente, como se fosse um game over. O conflito com a fruta aumenta o corpo da serpente em um bloco.

Dentro da função padrão “draw”, adicionaremos todos os detectores de colisão.

Colisão entre fruta e a cabeça da serpente

for (let i = 0; i < foods.length; i++) {
    // Aproveitando o loop pra exibir a fruta
    let food = foods[i];
    food.draw();
    
    // Detector de conflitos entre a cabeça da serpente e a fruta
    if (snake.body[0].x + snake.body[0].size > food.x &&
        snake.body[0].x < food.x + (food.size / 2) &&
        snake.body[0].y + snake.body[0].size > food.y &&
        snake.body[0].y < food.y + (food.size / 2)) {
        foods.splice(i, 1);
        foods.push(Food.instantiate());
        snake.addBody();
        return;
    }
}

O primeiro bloco dentro do corpo da serpente será a cabeça. O cálculo acima detecta a colisão quando a cabeça da serpente entra no mesmo espaço que a fruta se encontra. No exato momento que os dois estão no mesmo espaço, uma nova fruta é criada e um novo bloco é adicionado ao corpo da serpente.

Colisão entre os limites da tela e a cabeça da serpente

Precisamos de uma variável global que gerencie o andamento do jogo.

var isRunning = true;

Caso a variável acima seja trocada para o valor “false” o jogo é pausado, indicando um game over. A função responsável por trocar o valor da variável acima será a função “stopGame()”.

function stopGame() {
  isRunning = false;
  for (let i = 0; i < snake.body.length; i++) {
    snake.body[i].direction = null;
  }
}

A função também reinicia a direção atual de todos os blocos da serpente, fazendo com que a serpente trave. E para finalizar, novas linhas de código também serão adicionadas na função “keyPressed()”, responsável pelos controles de entrada do usuário.

function keyPressed() {
  if (! isRunning) {
    return;
  }
  ...
}

Uma vez que temos todas as ferramentas necessários implementadas, adicionado o detector de colisões entre os limites da tela e a cabeça da serpente.

if (snake.body[0].x < 0 || 
      snake.body[0].x + snake.body[0].size > screenWidth || 
      snake.body[0].y < 0 ||
      snake.body[0].y + snake.body[0].size > screenHeight) {
    stopGame();
}

A condição acima é bem simples. Caso a cabeça da serpente esteja saindo por alguns dos limites da tela, a função “stopGame()” é chamada.

Colisão entre o corpo e a cabeça da serpente

A lógica do detector de colisões do corpo e da cabeça da serpente é igual a colisão com a fruta.

for (let i = 1; i < snake.body.length; i++) {
    if (snake.body[0].x + snake.body[0].size > snake.body[i].x &&
        snake.body[0].x < snake.body[i].x + snake.body[i].size &&
        snake.body[0].y + snake.body[0].size > snake.body[i].y &&
        snake.body[0].y < snake.body[i].y + snake.body[i].size) {
        stopGame();
    }
}

Caso a cabeça da serpente e algum bloco do corpo da serpente estejam ocupando o mesmo espaço, a função “stopGame()” é acionada.

Testando o jogo

O jogo pode ser acessado e testado abaixo:

Para medir o espaço que cada bloco ou fruta pode ocupar, colocamos linhas na vertical e horizontal. Caso estes estejam alinhados com os espaços divididos pelas linhas, os cálculos são considerados corretos.

O que aprendemos com o jogo da serpente?

O jogo da serpente é um jogo extremamente simples, porém exige um pequeno esforço do programador para criar a lógica do jogo. Jogos clássicos como pacman e block breaker podem se tornar um grande aprendizado para programadores iniciantes, o jogo da serpente não é diferente.

O DicionarioTec criou a lógica desse jogo sem nenhuma referência, isso significa que podem haver outras formas muito mais simples de criar o jogo da serpente. O código foi disponibilizado para programadores que desejam aperfeiçoar o algoritmo.

https://editor.p5js.org/Dicionariotec/sketches/oaUSQV5Qo

Postagens mais vistas

Os 5 principais componentes do computador

Os 5 principais componentes do computador são a unidade de controle, unidade aritmética e lógica, memória, dispositivo de entrada e dispositivo de saída.

Portas TCP e UDP

A porta é um número de 16 bits que é adicionado no final do endereço IP, insinuando qual aplicativo está vinculado e atuando nessa porta.

LAN

Rede local de computadores (LAN) é um conjunto de computadores ou dispositivos conectados uns aos outros de forma isolada em um pequeno local.

Retornar aos projetos