Recrea Flappy Bird con Lua y Love2D

Recrea Flappy Bird con Lua y Love2D

En este artículo crearemos Flat Bird, que es un clon de Flappy Bird, pero con gráficos más simplificados que el original, ya que la atención se centrará en la mecánica del juego.

Para ello, utilizaremos el lenguaje de programación Lua y el framework Love2D (también conocido como LÖVE).

Lua

Lua es un pequeño e interesante lenguaje de programación desarrollado en la universidad brasileña PUC-Rio y cuya razón de existencia es básicamente simplificar la vida de los programadores que dependen de los lenguajes C o C++. Por lo tanto, fue diseñado para integrarse fácilmente en el lenguaje C (también C++).

Si nunca has oído hablar de él, no te preocupes porque la sintaxis es muy sencilla, especialmente para aquellos que ya han programado en Python o JavaScript. ¡Y no necesitas saber C para usarlo!

Pero ten en cuenta que varios juegos famosos como Angry Birds y World of Warcraft lo utilizaron en partes de su desarrollo. Puedes encontrar una lista más completa de dichos títulos aquí.

Love2D

Es un framework para crear juegos 2D usando Lua.

La instalación es muy sencilla. Simplemente descarga el archivo correspondiente a tu plataforma en el site:


Game loop

En el corazón de prácticamente todos los juegos se encuentra el llamado game loop. Es básicamente un loop infinito, donde el juego procesa la entrada (por ejemplo, presionar teclas y tocar la pantalla), actualiza el estado de las entidades del juego (ya sea respondiendo a la entrada o continuando una simulación física) y, finalmente, muestra en la pantalla el resultado.

Y esto se repite varias veces por segundo. Normalmente hay una velocidad constante con unos 30 o 60 fotogramas por segundo.

Con LÖVE no es diferente. Pero, en este caso, usted no es responsable de escribir el loop del juego, sino sólo ciertas devoluciones de llamada que son funciones especiales, que el framework llama en el momento adecuado.

Por ejemplo, un “Hello, world!” consiste simplemente en implementar el callback love.draw:

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


Considera que no fue necesario escribir código para crear la ventana ni trabajar directamente con API de gráficos como OpenGL o Vulkan, lo que hace la vida mucho más fácil para aquellos que recién comienzan en el campo del desarrollo de juegos.

Sin embargo, todavía es posible tener un control más refinado sobre ciertos aspectos de la aplicación, como veremos más adelante.

Para ejecutar el código anterior, guárdalo en un archivo main.lua, abre una terminal, ve al directorio donde guardaste el archivo y ejecuta love main.lua.

Esto supone que se puede acceder al programa Love en la variable de entorno PATH. Si usaste el instalador, no tendrás ningún problema. Si eliges la versión .zip, deberás agregar manualmente el directorio al PATH.

Flat Bird

Si llevas varios años viviendo en una cueva y no conoces al adictivo y molesto Flappy Bird, puedes jugarlo aquí.

Hechas las presentaciones, pasemos a nuestro juego.

Los elementos centrales son el pájaro, las tuberías y el suelo. El jugador anota al pasar a través de la abertura entre los dos tubos verticales y muere al tocar los tubos o el suelo.

El control consiste simplemente en la acción de hacer que el pájaro se eleve una determinada distancia, luchando contra la gravedad. Se puede implementar con solo presionar una tecla, hacer clic con el mouse o tocar la pantalla, en el caso de plataformas móviles.

Antes de pasar al código del juego, definamos el tamaño de la ventana y su título. Para hacer esto, cree un archivo conf.lua y agregue el siguiente 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 obtener más información sobre las configuraciones disponibles, consulta este link.

El código del juego estará en un único archivo main.lua para evitar tener que trastear con módulos o paquetes.

Para empezar elegimos una resolución para el lienzo, que es donde dibujaremos el juego. Ten en cuenta que no es necesario que esta resolución sea la misma que la ventana o pantalla donde se ejecutará el juego. En este caso, asumo que la ventana y el lienzo tienen la misma resolución para simplificar el código.

local CANVAS_WIDTH = 450
local CANVAS_HEIGHT = 600


Piense en la palabra clave "local" como "let" de JavaScript.

Aquí tienes algunos colores para pintar los elementos del juego. LÖVE supone que los componentes RGBA de un color están en el rango [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 }
}


Una constante que simula la aceleración inducida por la gravedad. Podemos pensar en la unidad como píxeles/segundo.

local GRAVITY = 120


En LÖVE, el origen del sistema de coordenadas, el punto (0, 0), es el más cercano a la esquina superior izquierda de la pantalla. Y el eje y se invierte en el sentido de que cuando aumentas la coordenada y, el objeto se mueve hacia abajo en la pantalla.

Puede parecer extraño, pero es una convención muy común en bibliotecas y frameworks de desarrollo de juegos.

Pájaro

A continuación tenemos el código responsable del pájaro. Coloca al pájaro en el centro de la pantalla verticalmente y un poco a la izquierda del centro horizontalmente. También se definen velocidades mínimas y máximas, para evitar ser demasiado lento o demasiado rápido. El bump_height es la altura a la que el pájaro se mueve verticalmente cada vez que el jugador activa el control.

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


En la función birdUpdate cambiamos la velocidad según la constante gravitacional que definimos arriba y también cambiamos la posición vertical del pájaro según su velocidad. ¿Qué es dt? Bueno, es sólo la forma abreviada de "tiempo delta", que es el tiempo que ha pasado entre los dos últimos fotogramas.

Esto es necesario porque esta función se llamará aproximadamente 60 veces por segundo y como cada cuadro puede tardar un poco más o un poco menos de lo esperado, usar dt ayuda a suavizar el movimiento independientemente de la máquina del jugador.

Tubos

En Lua sólo existe una estructura de datos, que es la tabla (hash). Es similar al objeto de JavaScript.

Para trabajar con tuberías, usaremos una tabla de tuberías que contiene dos campos (.clock y .gen_rate) que funcionan como un reloj para determinar cuándo se debe crear la siguiente tubería. También usaremos los campos numéricos de la tabla pipes como estructura de cola.

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


Puntuación

Aquí tenemos el código del sistema de puntuación. En él almacenamos la puntuación actual y la puntuación máxima (que sólo persiste mientras el juego está abierto). El criterio para aumentar la puntuación es: si el centro del pájaro pasó por el centro de una tubería, cuyo campo behind_bird sigue siendo falso, para evitar contar el mismo punto varias veces.

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

Aquí definimos el suelo, o suelo, que es simplemente un rectángulo marrón en la parte inferior de la pantalla.

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



Funciones auxiliares

Aquí definimos un reloj similar al usado para generar las tuberías, pero con el objetivo de contar el tiempo hasta que el juego se reinicie cuando se acabe el juego (game over).

La función gameReset transforma el estado del juego al estado inicial, modificando sólo los campos necesarios. Nota que scoreboardReset no restablece la puntuación 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


La función hasIntersection detecta si hay una intersección entre dos rectángulos, donde xj, yj representa la esquina superior izquierda del rectángulo j y wj, hj representa su ancho y alto, respectivamente.

LÖVE callbacks

Este callback es llamado apenas una vez, durante el inicio del juego, por lo que es un buen lugar para escribir código que cargue recursos como música del juego e imágenes de fondo.

Aquí definimos una semilla para el sistema generador de números aleatorios, inicializamos las entidades del juego y definimos la fuente que se utilizará para todos los textos que se muestran en la pantalla. Elegí utilizar una sola fuente, en este caso la misma que la del marcador.

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

    love.graphics.setFont(scoreboard.font)
end


Aquí movemos el pájaro, las tuberías, comprobamos si hay colisión (lo que determina el final del juego) y, si no hay colisión, actualizamos la puntuación, si corresponde. Tenga en cuenta que si el pájaro está "muerto", no actualizamos el estado de las entidades del juego, sino que mostramos un mensaje de "juego terminado", que dura unos segundos antes de que se reinicie el juego.

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



Ese es el callback responsable de diseñar en la pantalla (cualquier código como love.graphics.rect solo funciona como se esperaba si se llama dentro de esa función).

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


Ese callback se llama cuando hay un evento de “tecla presionada”. En este caso, debemos hacer que el pájaro se eleve un poco y vuelva a su velocidad inicial al pulsar la tecla espacio, flecha arriba o W. Pero sólo si el pájaro sigue vivo, ya que el modo zombie no se ha 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

Al final, el juego queda así:


Para tu conveniencia, el código también está aquí.

Si no te gustó el aspecto minimalista del juego, hay algunos paquetes de arte que sus creadores pusieron a disposición de forma gratuita, como los siguientes:

En lugar de dibujar rectángulos, ahora es necesario cargar las imágenes del disco a la memoria con la función love.graphics.newImage y para diseñar en la pantalla se usa la función love.graphics.draw.

Las imágenes de estos paquetes tienen una resolución muy baja, algo así como 16x16 y 32x32. Para hacer el juego en una resolución más alta, debes cambiar el tipo de filtro utilizado por LÖVE (el algoritmo utilizado para ampliar o reducir imágenes), usando la siguiente función https://love2d.org/wiki/love.graphics.setDefaultFilter.

Los modos disponibles son “linear” y “nearest”. El patrón es “linear”, entonces, para preservar el aspecto "pixelado" de las imágenes, sin introducir desenfoque, querrás utilizar el modo "nearest".


Para más información sobre Lua y LÖVE, recomiendo los siguientes enlaces:

¡Gracias y disfruta el juego!

💡
Las opiniones y comentarios emitidos en este artículo son propiedad única de su autor y no necesariamente representan el punto de vista de Listopro.

Listopro Community da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.