domingo, 22 de maio de 2016

Convertendo um jogo escrito em Basic para Python - Parte III

Nesta terceira parte, temos os seguintes objetivos:


  • Limpar as classes
  • Vários aviões
  • Múltiplos tiros.
  • Generalizar os objetos do jogo em uma super classe
  • Exibir um placar
  • Atribuir teclas para atirar e jogar de novo ou sair


Na versão da Parte II, as classes tem muito código repetido.
Analisando cada uma delas, podemos chegar a conclusão de um comportamento comum quanto
a forma de desenhar e atualizar os objetos. Um método para retirar o objeto do jogo também é utilizado.

Todo objeto do jogo precisa ter acesso ao jogo em sim.
Embora quase não utilizemos cores, eu adicionei a cor como parâmetro, uma vez que pode ser usada para representar múltiplos jogadores ou diferentes tipos de objetos.

Uma das coisas que não ficaram claras na versão anterior é o controle de colisão. Na realidade, o controle de colisão que utilizamos é bem básico e difícil de generalizar. Para permitir o controle da colisão entre objetos, cada objeto terá uma posição x e y na tela, com altura e largura.

Desta forma, dois objetos colidem se o retângulo formado pelos pontos:

(x, y), (x + largura, y), (x + largura, y + altura), (x, y + altura)

De dois objetos tiverem pontos em comum.

Como diferentes tipos de objetos colidem entre si, a propriedade colisão guarda o tipo de objeto.
Desta forma, podemos separar os tiros do avião em si.

Uma variável de DEBUG também foi adicionada, para mostrar o retângulo de colisão caso precisemos fazer ajustes.

class ObjetoDoJogo:
    def __init__(self, jogo, cor):
        self.jogo = jogo
        self.cor = cor
        self.ativo = True
        self.colisao = FUNDO
        self.largura = 0
        self.altura = 0
        self.x = 0
        self.y = 0
        self.debug = DEBUG

Se debug estiver ativo, vamos desenhar uma caixa em volta do objeto.



Desta forma, fica mais fácil detectar erros nos limites e no tamanho do objeto.

    def desenha(self):
        if self.debug:
            set_color(2)
            pyglet.graphics.draw(4, pyglet.gl.GL_LINE_LOOP,
                             ('v2f', (self.x, self.y,
                                      self.x + self.largura, self.y,
                                      self.x + self.largura, self.y + self.altura,
                                      self.x, self.y + self.altura)))

A atualização das propriedades, embora presente em todos os objetos, não tem um comportamento padrão.
Vamos utilizar apenas pass para marcar o lugar.

    def atualiza(self):
        """Calcula nova posição, atualiza valores a cada frame"""
        pass

Aqui um método para dar uma oportunidade ao objeto de tratar a colisão com outro objeto.
Este método será chamado pelo jogo, sempre que dois objetos colidirem.

    def colidiu(self, outro):
        """O que fazer em caso de colisao? Recebe o outro objeto"""
        pass

E este aqui é o teste de colisão em si, bem simples. Ele só funciona com larguras e alturas positivas.
    def colide(self, outro):
        if self.x < outro.x + outro.largura and self.x + self.largura > outro.x and \
           self.y < outro.y + outro.altura and self.altura + self.y > outro.y:
            self.colidiu(outro)
            outro.colidiu(self)

Quando a colisão é detectada, o método colidiu dos dois objetos é chamado.

E finalmente o método para desativar e retirar o objeto do jogo:

    def desativa(self):
        self.ativo = False
        self.jogo.desativa(self)

Vejamos como ficou a classe do Avião:

class Aviao(ObjetoDoJogo):
    def __init__(self, jogo, cor=3):
        super(Aviao, self).__init__(jogo, cor)
        self.velocidade = (random.randint(0, 6) + 4) * RATIO
        self.y = 159 - (random.randint(0, 135) + 11)
        self.x = self.velocidade
        self.altura = 9
        self.largura = 15
        self.colisao = AVIAO
        self.escapou = False

    def desenha(self):
        super().desenha()
        y = self.y + self.altura
        x = self.x
        set_color(self.cor)
        pyglet.graphics.draw(6, pyglet.gl.GL_LINE_LOOP,
                             ('v2f', (x, y,
                                      x, y - 8,
                                      x + 3, y - 2,
                                      x + 12, y - 2,
                                      x + 14, y,
                                      x, y)))

    def atualiza(self):
        self.x += self.velocidade
        if self.x > 265:
            self.escapou = True
            self.desativa()

    def colidiu(self, outro):
        if outro.colisao == TIRO:
            self.jogo.objetos.append(Explosao(self.jogo, self.x, self.y))
            self.desativa()

Nesta versão, o nome das propriedades já foi ajustado. Não utilizo mais altura_do_aviao que se tornou apenas a coordenada y. Na classe Avião, adicionei a propriedade escapou para marcar os aviões que saem da tela sem terem sido destruídos, dos que são atingidos por mísseis. Mais tarde, esta informação será utilizada para contar pontos.

Quando o avião colide com um Tiro, um novo tipo de objeto, Explosao é adicionado ao jogo. Desta forma, teremos um efeito simples quando o avião for atingido.

Uma das vantagens de se trabalhar com objetos é a facilidade de criá-los. No caso, a explosão é apenas outro objeto adicionado ao jogo.

class Explosao(ObjetoDoJogo):
    def __init__(self, jogo, x, y):
        super(Explosao, self).__init__(jogo, cor=0)
        self.estagio = 8
        self.x = x
        self.y = y

    def desenha(self):
        dx = 4 * self.estagio  #random.randint(1, 4)
        dy = random.randint(1, 4) * self.estagio
        dx2 = random.randint(1, 4) * self.estagio
        dy2 = random.randint(1, 4) * self.estagio
        y = self.y
        set_color(7)
        pyglet.graphics.draw(3, pyglet.gl.GL_TRIANGLES,
                             ('v2f', (self.x, y,
                                      self.x - dx, y + dy,
                                      self.x + dx2, y + dy2)))
        set_color(8)
        pyglet.graphics.draw(3, pyglet.gl.GL_TRIANGLES,
                             ('v2f', (self.x, y,
                                      self.x -dx/self.estagio , y + dx,
                                      self.x + dx2 * self.estagio, y + dy2)))

    def atualiza(self):
        self.estagio -= 0.4
        if(self.estagio < 0):
            self.desativa()

A ideia da explosão é que triângulos amarelos e vermelhos, de tamanhos aleatórios apareçam na tela.
Como a explosão não se move, a propriedade estagio foi adicionada para controlar seu tempo de vida.
No caso, a cada frame, subtrairemos 0.4 de estagio e quando este for negativo, o objeto será retirado do jogo.

A classe Tiro foi também melhorada:

class Tiro(ObjetoDoJogo):
    def __init__(self, jogo, posicao):
        super(Tiro, self).__init__(jogo, cor=3)
        self.y = 170
        self.x = (posicao + 1) * 70
        self.velocidade = 5 * RATIO
        self.colisao = 2
        self.altura = 5
        self.largura = 2
        self.errou = False

    def desenha(self):
        super().desenha()
        set_color(self.cor)
        y = self.y + self.altura
        pyglet.graphics.draw(2, pyglet.gl.GL_LINES,
                             ('v2f', (self.x, y,
                                      self.x, y - 4)))
        v = 4
        a = random.randint(3, 4)
        set_color(7)
        pyglet.graphics.draw(3, pyglet.gl.GL_TRIANGLES,
                             ('v2f', (self.x, y,
                                      self.x - 1, y + v,
                                      self.x + 1, y + v)))
        set_color(8)
        pyglet.graphics.draw(3, pyglet.gl.GL_TRIANGLES,
                             ('v2f', (self.x, y,
                                      self.x - 0.5, y + a,
                                      self.x + 0.5, y + a)))

    def atualiza(self):
        self.y -= self.velocidade
        if self.y < 0:
            self.errou = True
            self.desativa()

    def colidiu(self, outro):
        if outro.colisao == AVIAO:
            self.desativa()

O Tiro agora tem um foguete, o efeito é interessante, embora simples.
Quando o tiro sai da tela sem atingir um avião, este é considerado um erro e o jogador é penalizado.

As bases são também desenhadas como objetos do jogo. Nesta versão eu mudei o desenho.

class Base(ObjetoDoJogo):
    def __init__(self, jogo, posicao):
        super(Base, self).__init__(jogo, cor=3)
        self.y = 175
        self.x = (posicao + 1) * 70

    def desenha(self):
        super().desenha()
        set_color(self.cor)
        pyglet.graphics.draw(5, pyglet.gl.GL_LINE_STRIP,
                             ('v2f', (self.x - 5, self.y + 5,
                                      self.x - 3, self.y + 2,
                                      self.x, self.y,
                                      self.x + 3, self.y + 2,
                                      self.x + 5, self.y + 5,
                                      )))

A classe do jogo em si foi simplificada:

class Game:
    def __init__(self, parent):
        self.cor = CORES
        self.window = parent
        self.tiros_disparados = 0  # tiros já disparados
        self.estado = "jogando"
        self.pressionado = False
        self.label = None
        self.objetos = [Aviao(self)]
        self.objetos.extend([Base(self, base) for base in range(0, 3)])
        self.pontos = 0

        self.placar = pyglet.text.Label("Pontos: 0", font_name="Times New Roman", font_size=16,
                                   x=80, y=-185 * 4, anchor_x='center', anchor_y='center')

Agora temos um contador de pontos e um placar. Um objeto da classe Avião e as três bases são adicionadas ao jogo inicial.

def desativa(self, objeto):
        self.objetos.remove(objeto)
        if objeto.colisao == AVIAO:
            if objeto.escapou:
                self.pontos -= 3
            else:
                self.pontos += 5
            self.objetos.append(Aviao(self))
        elif objeto.colisao == TIRO:
            if objeto.errou:
                self.pontos -= 1

A desativação de um objeto é a retirada deste da lista de objetos do jogo.
Quando é um avião, verifica se o avião escapou da tela sem ter sido atingido.
Se escapou, o jogador perde 3 pontos. Se foi destruído, o jogador ganha 5 pontos.

A cada tiro disparado que sai da tela sem atingir um avião, o jogador perde 1 ponto.

Agora que temos o placar a mostrar, precisamos modificar o método mostra_mensagem.
Este deve exibir o placar sempre:

    def mostra_mensagem(self):
        glPushMatrix()
        glScalef(0.25, -0.25, 0.25)
        if self.label:
            self.label.draw()
        self.placar.text = "Pontos: {}".format(self.pontos)
        self.placar.draw()
        glPopMatrix()


O glPushMatrix e glPopMatrix foram utilizados para corrigir o desenho do texto.
Como estamos utilizando uma escala 4 com inversão das coordenadas do eixo Y, o glScalef
foi utilizado com 0.25 (que na multiplicação é o mesmo que 1/4 ou que dividir por 4) e
-0.25 no eixo Y, que divide por 4 e inverte o sinal.
O glPushMatrix é utilizado para salvar a matriz atual na pilha, desta forma o glScalef
será utilizado apenas até o glPopMatrix que restaura os parâmetros anteriores.

O método atualiza foi simplificado:

    def atualiza(self):
        self.jogue()
        if self.estado != "jogando":
            if self.pressionado:
                pyglet.app.event_loop.exit()
        self.mostra_mensagem()

O método jogue sempre é chamado. Desta forma podemos ter o desenho do jogo, mesmo quando exibimos uma mensagem de fim por exemplo. Nesta versão, não há mudança de estado, pois eu modifiquei o jogo para gerar outro avião sempre que um for destruído ou sumir, não tendo fim.

E o método jogue também simplificado:

    def jogue(self):
        for objeto in self.objetos:
            objeto.desenha()

        self.processa_tiros()
        if self.estado == "jogando":
            for objeto in self.objetos:
                objeto.atualiza()

            if self.pressionado:
                self.objetos.append(Tiro(self, self.tiros_disparados % 3))
                self.tiros_disparados += 1
                self.pressionado = False

Ele apenas desenha todos os objetos da lista e chama processa_tiros que testa colisões.
Se ainda estivermos jogando, chama a atualização de todos os objetos da lista e se algo foi pressionado, gera um tiro. Para termos vários tiros, a base que o dispara é calculada pelo módulo do número do tiro e o número de bases.

def processa_tiros(self):
        tiros = [objeto for objeto in self.objetos if objeto.colisao == TIRO]
        avioes = [objeto for objeto in self.objetos if objeto.colisao == AVIAO]

        for aviao in avioes:
            for tiro in tiros:
                aviao.colide(tiro)

Aqui uma simplificação, onde abuso um pouco do computador :-D
Utilizando duas "list comprehensions", eu filtro a lista de objetos, procurando aviões e tiros.
Desta forma, a verificação de colisão se torna mais simples, pois basta verificar se o avião colidiu com um dos tiros.

O jogo principal pouco mudou, salvo o método on_key_press:

def on_key_press(self, symbol, modifiers):        
        if symbol == key.S:
            print("Placar: {}".format(self.game.pontos))
            pyglet.app.event_loop.exit()
        elif symbol == key.R:
            self.game = Game(self)
        else:
            self.game.pressionado = True

No caso, o jogo termina quando pressionamos a tecla S e reinicia se pressionarmos a tecla R.

O jogo completo ficou assim:


Video:



Para o próximo post:

  1. Sons
  2. Imagens


A pyglet não é atualizada há bastante tempo. Eu devo procurar outra biblioteca gráfica para outros posts, mas vou terminar as melhorias deste jogo ainda com pyglet e abusando da OpenGL.


Nenhum comentário: