Recriando Flappy Bird com Lua e Love2D

Recriando Flappy Bird com Lua e Love2D

Nesse artigo vamos criar o Flat Bird, que é um clone de Flappy Bird, mas com gráficos mais simplificados que o original, pois o foco será na mecânica do jogo.  

Para isso utilizaremos a linguagem de programação Lua e o framework Love2D (também conhecido como LÖVE).

Lua

Lua é uma pequena e interessante linguagem de programação desenvolvida na universidade brasileira PUC-Rio e cuja razão de existir é basicamente simplificar a vida dos programadores que dependem das linguagens C ou C++. Portanto, foi projetado para ser facilmente integrado à linguagem C (também C++).

Se você nunca ouviu falar, não se preocupe pois a sintaxe é muito simples, especialmente para quem já programou em Python ou JavaScript. E não precisa saber C para usá-la!  

Mas saiba que vários jogos famosos como Angry Birds e World of Warcraft utilizaram-na em partes do seu desenvolvimento. Uma lista mais completa de tais títulos pode ser encontrada aqui.

Love2D

É um framework para criar jogos 2D usando Lua.

A instalação é muito simples, basta baixar o arquivo correspondente à sua plataforma no site:


Game loop

No coração de praticamente todo jogo existe o chamado game loop. É basicamente um loop infinito, onde o jogo processa a entrada (e.g. teclas pressionadas e toques na tela), atualiza o estado das entidades do jogo (seja respondendo à entrada ou dando continuidade a uma simulação física) e, finalmente, mostra na tela o resultado.

E isso se repete várias vezes por segundo, geralmente há uma taxa constante com cerca de 30 ou 60 frames por segundo.

Com o LÖVE não é diferente. Mas, nesse caso, você não tem a responsabilidade de escrever o game loop e sim apenas certas callbacks que são funções especiais, as quais o framework chama na hora certa.

Por exemplo, um “Hello, world!” consiste simplesmente em implementar a callback love.draw:

function love.draw()
    love.graphics.print("Hello, World!", 0, 0)
end


Note que não foi preciso escrever código para criar a janela, nem para lidar diretamente com APIs gráficas como OpenGL ou Vulkan, o que facilita muito a vida de quem está começando na área de game dev.

Porém, ainda assim é possível ter um controle mais refinado de certos aspectos da aplicação como veremos mais adiante.

Para rodar o código acima, salve-o em um arquivo main.lua, abra um terminal, vá até o diretório em que você salvou o arquivo e execute “love main.lua”.

Isso assume que o programa love está acessível na variável de ambiente PATH. Se você usou o instalador, não vai ter problemas. Se escolheu a versão .zip, então você terá que adicionar manualmente o diretório na PATH.

Flat Bird

Se você esteve morando em uma caverna nos últimos anos e não conhece o viciante e irritante Flappy Bird, você pode jogá-lo aqui.

Feitas as apresentações, vamos ao nosso jogo.

Os elementos centrais são o pássaro, os canos e o chão. O jogador pontua ao passar pela abertura entre os dois canos na vertical e morre ao tocar nos canos ou no chão.

O controle consiste simplesmente na ação de fazer o pássaro subir um certa distância, lutando contra a gravidade. Pode ser implementado com o aperto de uma tecla, clique do mouse ou toque na tela, em caso de plataformas móveis.

Antes de chegar ao código do jogo vamos definir o tamanho da janela e o título da mesma. Para isso, crie um arquivo conf.lua e adicione o seguinte código:

function love.conf(t)
    t.version = "11.4"

    t.window.title = 'Flat Bird'
    t.window.width = 450
    t.window.height = 600

    t.window.vsync = 1
end


Para mais informações sobre as configurações disponíveis, veja esse link.

O código do jogo ficará num único arquivo `main.lua` para evitar ter que mexer com módulos ou pacotes.

Para começar, escolhemos uma resolução para o canvas, que é onde iremos desenhar o jogo. Note que essa resolução não precisa ser a mesma da janela ou da tela onde o jogo vai rodar. No caso, eu estou assumindo que a janela e o canvas tem a mesma resolução para simplificar o código.

local CANVAS_WIDTH = 450
local CANVAS_HEIGHT = 600


Pense na palavra-chave “local” como o “let” do JavaScript.

Aqui tem algumas cores para pintar os elementos do jogo. O LÖVE assume que as componentes RGBA de uma cor estão no intervalo [0, 1].

local colors = {
    WHITE = { 1, 1, 1 },
    SKY_BLUE = { 135 / 255, 206 / 255, 235 / 255 },
    GREEN = { 0, 0.75, 0 },
    ORANGE = { 1, 165 / 255, 0 },
    BROWN = { 0.58, 0.29, 0 }
}


Uma constante que simula a aceleração induzida pela gravidade. Podemos pensar na unidade como pixels/segundo.

local GRAVITY = 120


No LÖVE, a origem do sistema de coordenadas, o ponto (0, 0), é aquele mais próximo do canto superior esquerdo da tela. E o eixo y é invertido no sentido que ao aumentar a coordenada y, o objeto se move para baixo na tela.

Pode parecer estranho mas é uma convenção bem comum em frameworks e bibliotecas de desenvolvimento de jogos.

Pássaro

A seguir temos o código responsável pelo pássaro. Ele posiciona o pássaro no centro da tela na vertical e um pouco à esquerda do centro na horizontal. Também são definidas velocidades mínimas e máximas, para evitar ficar muito lento ou muito rápido. O `bump_height` é a altura que o pássaro se desloca na vertical toda vez que o jogador acionar o controle.

local bird = {}

local function birdInit()
    bird.width = 25
    bird.height = 25
    bird.x = CANVAS_WIDTH / 2 - 50
    bird.y = (CANVAS_HEIGHT - bird.height) / 2

    bird.min_speed = 25
    bird.max_speed = 250
    bird.speed = bird.min_speed
    bird.bump_height = 25

    bird.color = colors.ORANGE

    bird.alive = true
end

local function birdReset()
    bird.y = (CANVAS_HEIGHT - bird.height) / 2
    bird.speed = bird.min_speed
    bird.alive = true
end

local function birdUpdate(dt)
    bird.speed = bird.speed + GRAVITY * dt
    if bird.speed > bird.max_speed then
        bird.speed = bird.max_speed
    end
    bird.y = bird.y + bird.speed * dt
end


Na função birdUpdate nós alteramos a velocidade de acordo com a constante gravitacional que definimos acima e também alteramos a posição vertical do pássaro segundo sua velocidade. O que é o `dt`? Bem, é só a forma encurtada de "delta time" que é o tempo que se passou entre os dois últimos frames.

Isso é necessário pois esta função será chamada cerca de 60 vezes por segundo e como cada frame pode demorar um pouco mais ou um pouco menos do que o esperado, usar o `dt` ajuda a suavizar o movimento independentemente da máquina do jogador.

Canos

Em Lua só existe uma estrutura de dados, que é a tabela(hash). É parecido com o Object do JavaScript.

Para lidar com os canos, iremos usar uma tabela pipes contendo dois campos (.clock e .gen_rate) que funcionam como um relógio para determinar quando o próximo cano tem que ser criado. Também usaremos os campos numéricos da tabela `pipes` como uma estrutura de fila.

local pipes = {}

local function pipesInit()
    pipes.clock = 0 -- how much time(in seconds) has elapsed since the last pipe was generated
    pipes.gen_rate = 3 -- how much time(in seconds) the game should waiting before generating another pipe
end

local function pipesReset()
    pipes.clock = 0
    while #pipes > 0 do
        table.remove(pipes, 1)
    end
end

local function pipeCreate()
    local pipe = {}
    pipe.width = 50
    pipe.height1 = math.random(100, CANVAS_HEIGHT - 250)
    pipe.empty_space = 100
    pipe.height2 = CANVAS_HEIGHT - pipe.height1 - pipe.empty_space
    pipe.x = CANVAS_WIDTH
    pipe.y = 0
    pipe.speed = -100
    pipe.color = colors.GREEN
    pipe.behind_bird = false

    return pipe
end

local function pipesUpdate(dt)
    pipes.clock = pipes.clock + dt
    if pipes.clock > pipes.gen_rate then
        pipes.clock = 0
        table.insert(pipes, pipeCreate())
    end

    -- move all the pipes a bit to the left
    for k, pipe in ipairs(pipes) do
        pipe.x = pipe.x + pipe.speed * dt
    end

    -- count how many pipes are out of screen
    local dead_pipes_count = 0
    for k, pipe in ipairs(pipes) do
        if pipe.x < -pipe.width then
            dead_pipes_count = dead_pipes_count + 1
        else
            break
        end
    end

    -- remove each of the first dead_pipes_count pipes
    for _ = 1, dead_pipes_count do
        table.remove(pipes, 1)
    end
end


Pontuação

Aqui temos o código do sistema de pontuação. Nele guardamos a pontuação atual e a pontuação máxima (que persiste apenas enquanto o jogo estiver aberto). O critério para aumentar a pontuação é: se o centro do pássaro passou do centro de um cano, cujo campo behind_bird ainda é falso, para evitar contar várias vezes o mesmo ponto.

local scoreboard = {}

local function scoreboardInit()
    scoreboard.current_score = 0
    scoreboard.highest_score = 0

    scoreboard.color = colors.WHITE
    scoreboard.font = love.graphics.newFont(36)

    scoreboard.x = (CANVAS_WIDTH - scoreboard.font:getWidth('0')) / 2
    scoreboard.y = 30
end

local function scoreboardReset()
    scoreboard.current_score = 0
    scoreboard.x = (CANVAS_WIDTH - scoreboard.font:getWidth('0')) / 2
end

local function scoreboardUpdate(dt)
    local bird_center_x = bird.x + bird.width / 2
    for k, pipe in ipairs(pipes) do
        local pipe_center_x = pipe.x + pipe.width / 2
        if pipe_center_x < bird_center_x and not pipe.behind_bird then
            scoreboard.current_score = scoreboard.current_score + 1
            if scoreboard.current_score > scoreboard.highest_score then
                scoreboard.highest_score = scoreboard.current_score
            end
            pipe.behind_bird = true
        end
    end
    scoreboard.x = (CANVAS_WIDTH -
                      scoreboard.font:getWidth(
                          tostring(scoreboard.current_score)
                      )) / 2
end


Piso

Aqui definimos o piso, ou chão, que é simplesmente um retângulo marrom na parte inferior da tela.

local floor = {}

local function floorInit()
    floor.width = CANVAS_WIDTH
    floor.height = 50
    floor.x = 0
    floor.y = CANVAS_HEIGHT - floor.height
    floor.color = colors.BROWN
end



Funções auxiliares

Aqui definimos um relógio parecido com aquele usado para gerar os canos, mas com o objetivo de contar o tempo até reiniciar o jogo quando der “game over”.

A função gameReset transforma o estado do jogo para o estado inicial, modificando apenas os campos necessários. Note que scoreboardReset não zera a pontuação máxima.

local game_over_clock = 0
local game_over_duration = 3

local function gameInit()
    birdInit()
    pipesInit()
    scoreboardInit()
    floorInit()
end

local function gameReset()
    birdReset()
    pipesReset()
    scoreboardReset()

    game_over_clock = 0
end

-- detect intersection between two rectangles
local function hasIntersection(x1, y1, w1, h1, x2, y2, w2, h2)
    return x1 < x2 + w2 and x2 < x1 + w1 and y1 < y2 + h2 and y2 < y1 + h1
end


A função hasIntersection detecta se há interseção entre dois retângulos, onde xj, yj representa o canto superior esquerdo do retângulo j e wj, hj representa a largura e altura do mesmo, respectivamente.

LÖVE callbacks

Esta callback é chamada apenas uma vez, durante a inicialização do jogo, por isso é um bom lugar para escrever códigos que carregam assets como a música do jogo e imagem de fundo.

Aqui definimos uma seed para o sistema gerador de números aleatórios, inicializamos as entidades do jogo e definimos a fonte a ser usada para todos os textos que forem mostrados na tela. Escolhi usar uma única fonte, no caso a mesma do scoreboard.

function love.load()
    math.randomseed(os.time())
    gameInit()

    love.graphics.setFont(scoreboard.font)
end


Aqui movemos o pássaro, os canos, checamos se há colisão (o que determina o fim do jogo) e caso não haja colisão, atualizamos a pontuação, se for o caso. Note que caso o pássaro esteja “morto”, não atualizamos o estado das entidades do jogo, mas  exibimos uma mensagem de “game over” no lugar, que dura alguns segundos antes do jogo ser reiniciado.

function love.update(dt)
    if bird.alive then
        birdUpdate(dt)
        pipesUpdate(dt)

        -- check collision between bird and floor
        if hasIntersection(
            bird.x, bird.y, bird.width, bird.height, floor.x, floor.y,
            floor.width, floor.height
        ) then
            bird.alive = false
        end

        -- check collision between bird and pipes
        for k, pipe in ipairs(pipes) do
            if hasIntersection(
                bird.x, bird.y, bird.width, bird.height, pipe.x, pipe.y,
                pipe.width, pipe.height1
            ) or hasIntersection(
                bird.x, bird.y, bird.width, bird.height, pipe.x,
                pipe.y + pipe.height1 + pipe.empty_space, pipe.width,
                pipe.height2
            ) then
                bird.alive = false
            end
        end

        if not bird.alive then
            return
        end

        scoreboardUpdate(dt)
    else
        game_over_clock = game_over_clock + dt
        if game_over_clock > game_over_duration then
            gameReset()
        end
    end
end



Essa é a callback responsável por desenhar na tela (qualquer código como love.graphics.rect só funciona como esperado se chamado dentro dessa função).

function love.draw()
    -- draw sky
    love.graphics.clear(colors.SKY_BLUE)

    -- draw bird
    love.graphics.setColor(bird.color)
    love.graphics.rectangle('fill', bird.x, bird.y, bird.width, bird.height)

    -- draw pipes
    for k, pipe in ipairs(pipes) do
        love.graphics.setColor(pipe.color)
        love.graphics
            .rectangle('fill', pipe.x, pipe.y, pipe.width, pipe.height1)
        love.graphics.rectangle(
            'fill', pipe.x, pipe.y + pipe.height1 + pipe.empty_space,
            pipe.width, pipe.height2
        )
    end

    -- draw scoreboard
    love.graphics.setColor(scoreboard.color)
    love.graphics.print(
        tostring(scoreboard.current_score), scoreboard.x, scoreboard.y
    )

    -- draw floor
    love.graphics.setColor(floor.color)
    love.graphics.rectangle('fill', floor.x, floor.y, floor.width, floor.height)

    -- draw game over message if bird is dead
    if not bird.alive then
        love.graphics.setColor(scoreboard.color)

        local line1 = "GAME OVER"
        love.graphics.print(
            line1, (CANVAS_WIDTH - scoreboard.font:getWidth(line1)) / 2,
            CANVAS_HEIGHT / 3
        )

        local line2 = "Best: " .. tostring(scoreboard.highest_score)
        love.graphics.print(
            line2, (CANVAS_WIDTH - scoreboard.font:getWidth(line2)) / 2,
            CANVAS_HEIGHT / 3 + scoreboard.font:getHeight()
        )
    end
end


Essa callback é chamada quando há um evento de “tecla pressionada”. No caso, precisamos fazer o pássaro subir um pouco e voltar a velocidade inicial quando a tecla espaço, seta pra cima ou W for pressionada. Mas só se o pássaro ainda estiver vivo, pois o modo zumbi não foi implementado.

function love.keypressed(key, scancode, isrepeat)
    if (scancode == 'space' or scancode == 'up' or scancode == 'w') and
        bird.alive then
        bird.y = bird.y - bird.bump_height
        bird.speed = bird.min_speed
    end
end


Resultado

No final o jogo fica assim:


Para sua conveniência, o código também pode ser encontrado aqui.

Se você não gostou do visual minimalista do jogo, existem alguns pacotes de arte que foram disponibilizados gratuitamente pelos seus criadores como os seguintes:

Ao invés de desenhar retângulos, agora é necessário carregar as imagens do disco para a memória com a função love.graphics.newImage e para desenhá-las na tela usa-se a função love.graphics.draw.

As imagens nesses pacotes são em uma resolução bem baixa, algo como 16x16 e 32x32. Para fazer o jogo numa resolução maior, você precisa alterar o tipo de filtro usado pelo LÖVE(o algoritmo usado para aumentar ou diminuir imagens), usando a seguinte função https://love2d.org/wiki/love.graphics.setDefaultFilter.

Os modos disponíveis são “linear” e “nearest”. O padrão é o “linear”, então para preservar o aspecto “pixelado” das imagens, sem introduzir borrões, você vai querer usar o modo “nearest”.


Para mais informações sobre Lua e LÖVE, recomendo os seguintes links:

Muito obrigado! Desfrute o jogo!

💡
As opiniões e comentários expressos neste artigo são de propriedade exclusiva de seu autor e não representam necessariamente o ponto de vista da Revelo.

A Revelo Content Network acolhe todas as raças, etnias, nacionalidades, credos, gêneros, orientações, pontos de vista e ideologias, desde que promovam diversidade, equidade, inclusão e crescimento na carreira dos profissionais de tecnologia.