Desenvolvendo o jogo Snake [parte 4]

12 minute read

Neste post vamos introduzir o conceito de colisão, tratando da comida para a cobra.

Criando o alimento para a cobra

No jogo Snake o alimento que a cobra come é o que fará com que ela cresça de tamanho, tornando o jogo, com o tempo, mais desafiador. O algoritmo para criar o alimento para a cobra é muito simples, precisamos apenas sortear valores aleatórios para o x e y do alimento a ser posicionado na tela. Ele será sorteado sempre que o jogador realizar a captura do alimento. Vamos tentar criar essa função para o nosso jogo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import random

def nova_comida(self):
    comida = Posicao(
        random.randrange(self.tamanho_sprite, pyxel.width - self.tamanho_sprite, self.tamanho_sprite),
        random.randrange(self.tamanho_sprite, pyxel.height - self.tamanho_sprite, self.tamanho_sprite))

    # se a posicao sorteada para comida for uma posicao ocupada por um
    # segmento da cobra, tentar novamente ate conseguir um fora da cobra
    while comida in self.segmentos:
        comida = Posicao(
            random.randrange(self.tamanho_sprite, pyxel.width - self.tamanho_sprite, self.tamanho_sprite),
            random.randrange(self.tamanho_sprite, pyxel.height - self.tamanho_sprite, self.tamanho_sprite))
    return comida

Uma coisa importante para notar é que se não impusermos nenhuma restrição, a função que faz o sorteio da posição da cobra pode sortear uma posição que já é ocupada pela cobra. A estratégia aqui é continuar sorteando até que consigamos uma posição diferente das que a cobra já ocupa. Essa estratégia não é a melhor possível, porque à medida que a cobra for crescendo vai ficar cada vez mais difícil sortear uma posição que não seja ocupada pela cobra, uma vez que com o tempo ela vai ocupar cada vez mais posições na tela. Como esta série de posts é para iniciantes em programação não vamos nos preocupar com isso agora porque a solução ideal é mais complicada que esta que apresento aqui. É até um bom exercício para você tentar resolver esse problema sozinho :wink:

Pra finalizar as modificações relacionadas à criação de alimento, precisamos incluir uma linha nova no método __init__ para criar um novo alimento assim que o jogo for inicializado (a modificação é apenas a inclusão da linha 11):

1
2
3
4
5
6
7
8
9
10
11
12
def __init__(self):
    pyxel.init(208, 208)
    self.tamanho_sprite = 8
    self.segmentos = []
    cabeca = Posicao(pyxel.width/2, pyxel.height/2)
    self.segmentos.append(cabeca)
    self.segmentos.append(Posicao(pyxel.width/2 - self.tamanho_sprite, pyxel.height/2))
    self.segmentos.append(Posicao(pyxel.width/2 - self.tamanho_sprite*2, pyxel.height/2))
    self.segmentos.append(Posicao(pyxel.width/2 - self.tamanho_sprite*3, pyxel.height/2))
    self.direcao = DIREITA
    self.comida = self.nova_comida()
    pyxel.run(self.update, self.draw)

Perceba que também agora criamos a cobra com apenas 3 segmentos ao invés dos 5 que tínhamos antes (linhas 7-9).

Implementando o método eq

Para que o teste comida in self.segmentos funcione, precisamos implementar um novo método na nossa classe Posicao, para que o Python consiga fazer a comparação da posição da comida com a posição de um segmento. Por padrão o operador in consegue testar, a partir do valor, tipos primitivos (como o int, por exemplo) e se os objetos são os mesmos. Se tivermos objetos diferentes, mesmo com os mesmos valores internos (no nosso caso x e y), o Python não vai dizer que eles são iguais, a não ser que digamos a ele como isso deve ser feito. Para isso, precisamos implementar o método __eq__, que vai permitir que o Python compare dois objetos diferentes para saber se são iguais semanticamente (baseado em seu conteúdo). Fica assim:

1
2
3
4
5
6
7
class Posicao:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, outro):
        return self.x == outro.x and self.y == outro.y

Comendo a comida

Como você deve ter percebido, ainda não detectamos quando a cobre come a comida. Para fazer isso precisamos fazer um teste bem simples. Lembra-se da nossa estratégia para fazer a cobra “parecer” que andava? Sempre criamos uma nova cabeça e removemos um segmento da cauda. Precisamos apenas verificar se essa nova cabeça, ao tentar ser inserida, ocuparia uma posição que já é ocupada pela comida. Para isso podemos usar um laço de repetição e comparar a posição da nova cabeça com a posição da comida. Uma pergunta pra você: onde devemos colocar esse código? Ele é relacionado a desenho de algo na tela? Então não deve ir para a função draw. Ele é relacionado a inicialização do jogo? Então não deve ir par ao __init__. Ele é relacionado a atualização de lógica do jogo, pois afinal de contas estamos implementando uma regra do jogo, a cobra deve comer a comida quando passa por cima dela.

Antes de fazer isso, que tal darmos uma refatorada(leia um pouco sobre o que é refatoração de código) no nosso código, pois a função update já está ficando grande demais, e já está ficando difícil de mexer nela. A primeira coisa que podemos fazer é mover da função update o código responsável por capturar a interação do usuário. A função fica assim:

1
2
3
4
5
6
7
8
9
def processar_entrada_usuario(self):
    if pyxel.btn(pyxel.KEY_UP) and self.direcao != BAIXO:
        self.direcao = CIMA
    elif pyxel.btn(pyxel.KEY_RIGHT) and self.direcao != ESQUERDA:
        self.direcao = DIREITA
    elif pyxel.btn(pyxel.KEY_DOWN) and self.direcao != CIMA:
        self.direcao = BAIXO
    elif pyxel.btn(pyxel.KEY_LEFT) and self.direcao != DIREITA:
        self.direcao = ESQUERDA

Agora precisamos apenas invocar esse comportamento dentro da função update:

1
2
3
4
5
6
7
def update(self):
# mais codigo dentro de update...

# atualiza a direção atual de acordo com entrada do usuario
self.processar_entrada_usuario()

# mais codigo dentro de update...

Sim, é só isso… Esse self. na frente da chamada da função é importante, pois estamos invocando a função processar_entrada_usuario de um objeto específico. Se tiver dúvida sobre esse conceito relacionado a orientação a objetos, volte um pouco para os posts iniciais e revisite os links que coloquei por lá.

Outra coisa que podemos fazer é mover o código responsável por criar a nova cabeça para uma outra função, para melhorar ainda mais a função update, deixa-la um pouco mais enxuta.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def obter_nova_cabeca(self, cabeca):
    if self.direcao == CIMA:
        if cabeca.y - self.tamanho_sprite < 0: #fazer o wrap
            cabeca = Posicao(cabeca.x, pyxel.height - self.tamanho_sprite)
        else:
            cabeca = Posicao(cabeca.x, cabeca.y - self.tamanho_sprite)
    elif self.direcao == DIREITA:
        if cabeca.x + self.tamanho_sprite > pyxel.width - self.tamanho_sprite:
            cabeca = Posicao(0, cabeca.y)
        else:
            cabeca = Posicao(cabeca.x + self.tamanho_sprite, cabeca.y)
    elif self.direcao == BAIXO:
        if cabeca.y + self.tamanho_sprite > pyxel.height - self.tamanho_sprite:
            cabeca = Posicao(cabeca.x, 0)
        else:
            cabeca = Posicao(cabeca.x, cabeca.y + self.tamanho_sprite)
    elif self.direcao == ESQUERDA:
        if cabeca.x - self.tamanho_sprite < 0:
            cabeca = Posicao(pyxel.width - self.tamanho_sprite, cabeca.y)
        else:
            cabeca = Posicao(cabeca.x - self.tamanho_sprite, cabeca.y)
    return cabeca

Você lembra como era nossa função update? Veja agora como ela ficou e me diga se ficou melhor :smirk: Essa é toda a função update. Como delegamos alguns pedaços de código bem extensos para funções auxiliares, temos agora um código bem mais legível e enxuto.

1
2
3
4
5
6
7
8
9
10
11
12
13
def update(self):
    # atualiza a direção atual de acordo com entrada do usuario
    self.processar_entrada_usuario()

    if pyxel.frame_count % 3 == 0:
        # adicionar uma nova cabeca e remover a cauda eh mais facil
        # do que atualizar todos os segmentos
        cabeca = self.segmentos[0]

        cabeca = self.obter_nova_cabeca(cabeca)

        self.segmentos.pop()
        self.segmentos.insert(0, cabeca)

A lógica da detecção da colisão

Quando você começar a fazer jogos mais complexos no Pyxel vai se deparar com esse problema clássico em jogos que é a detecção de colisões. Eu digo “quando fizer jogos mais complexos” por que neste aqui a detecção de colisões é muito simples, mas à medida que seu projeto fica mais complexo esse controle pode se tornar mais difícil. E eu digo “no Pyxel” porque como eu falei nos posts iniciais, o Pyxel não vai te dar muitas facilidades para desenvolver o jogo. E advinha, uma das coisas que ele não te dá é a detecção automática de colisões que outros engines vão te dão, como por exemplo a configuração do que é parede (que obvimente o personagem não consegue ultrapassar), das bouding boxes em torno dos personagens (ajudam a configurar de forma quase automática as colisões entre personagem e elementos do jogo) e outros elementos. Mas fique tranquilo, porque isso é bom pra a gente, dá mais trabalho mas assim aprendemos mais.

Então vamos lá… o conceito de colisão é muito simples em um jogo: duas coisas colidem quando durante a execução do jogo elas “querem” ocupar a mesma posição. Nos jogos, algumas coisas se movem sozinhas, outras se movem a partir de interação do usuário, e durante a atualização do estado do jogo, pode acontecer que duas coisas, ao terem suas posições atualizadas de acordo com a mecânica do jogo, estariam ocupando a mesma posição na tela. Ao detectar isso o jogo pode simplesmente não permitir, fazer o jogador sofrer algum dano (perder pontos de vida), ativar algum outro item, tudo vai depender do seu jogo. No nosso caso, queremos que a comida desapareça e que a cobra ganhe um novo segmento.

O código é muito simples, e para isso vamos modificar as duas últimas linhas da nossa função update. Antes apenas adicionávamos a nova cabeça e removíamos um elemento do fim da lista de segmentos. Agora, nós vamos fazer isso apenas após checarmos se a nova cabeça da cobra está na mesma posição da comida. Após inserir a nova cabeça, fazemos uma checagem pra saber se essa posição é ocupada pela comida. Como já implementamos o __eq__ podemos fazer uma checagem simples usando o == para saber tanto a cabeça quanto a comida estão na mesma posição da tela. Se o teste for verdadeiro, tanto a comida e a cabeça estão na mesma posição, e pra a gente a cobra vai aumentar de tamanho. Caso a comida seja consumida, nós criamos uma nova comida e não removemos o segmento da cauda, fazendo assim com que a cobra aumente de tamanho. Se não estiverem na mesma posição removemos o segmento da cauda, fazendo ela continuar andando. Nosso update agora fica assim:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 def update(self):
    # atualiza a direção atual de acordo com entrada do usuario
    self.processar_entrada_usuario()

    if pyxel.frame_count % 3 == 0:
        # adicionar uma nova cabeca e remover a cauda eh mais facil
        # do que atualizar todos os segmentos
        cabeca = self.segmentos[0]

        cabeca = self.obter_nova_cabeca(cabeca)

        self.segmentos.insert(0, cabeca)
        if cabeca == self.comida:
            self.comida = self.nova_comida()
        else:
            self.segmentos.pop()

A cobra não pode passar por cima do próprio corpo

Como você já deve ter percebido também, ainda não implementamos uma mecânica muito importante do jogo. A cobra não pode passar por cima do seu próprio corpo. Agora já sabemos como detectar a colisão no contexto do nosso jogo. Vai ser muito parecido com a colisão com a comida, mas agora vamos precisar verificar a colisão da cabeça com todas as outras partes do corpo.

Faremos essa checagem após a detecção da comida, pois após esse ponto já teremos a nova cabeça no lugar, e também a cauda já terá sido removida. Utilizaremos um laço de repetição simples, pra checar com todos os segmentos. Uma observação importante: fazemos um slice para que não chequemos a colisão da cabeça com a própria cabeça. Se não fizemos isso o jogo não sai do canto :wink:

Também precisaremos de uma nova variável para sinalizar se o jogo deve rodar ou não. Criamos uma variável chamada rodando dentro do __init__ para guardar essa informação. Modificamos o primeiro if do update para além de checar o contador de frames também levar em consideração essa nossa nova variável. Quando detectarmos a colisão da cabeça com o corpo agora o jogo vai parar.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def update(self):
    # atualiza a direção atual de acordo com entrada do usuario
    self.processar_entrada_usuario()

    if pyxel.frame_count % 3 == 0 and self.rodando:
        # adicionar uma nova cabeca e remover a cauda eh mais facil
        # do que atualizar todos os segmentos
        cabeca = self.segmentos[0]

        cabeca = self.obter_nova_cabeca(cabeca)

        self.segmentos.insert(0, cabeca)
        if cabeca == self.comida:
            self.comida = self.nova_comida()
        else:
            self.segmentos.pop()

        for segmento in self.segmentos[1:]:
            if cabeca == segmento:
                self.rodando = False

Por enquanto é isso. O jogo ainda está feio, mas a mecânica principal está praticamente pronta. Nos próximos posts podemos começar a pensar na arte do nosso jogo. Felizmente o Pyxel nos dá algumas ferramentas legais pra ajudar nessa parte.

Leave a comment