* notes / love2d

Love2D y Lua: un ping-pong desde cero

Mi primer juego completo en LÖVE: bucle principal, física con delta-time, colisiones AABB y la elegancia desconcertante de Lua.

September 16, 20247 min read#love2d#lua#gamedev#principiantes

Llegué a Love2D por accidente, buscando un entorno mínimo para prototipar mecánicas sin pelearme con la configuración de un motor. Me recomendaron hacer un Pong en LÖVE para entender de qué iba. Lo que pensé que sería un ejercicio de una tarde se convirtió en tres días de descubrimientos: sobre Lua, sobre los bucles de juego y sobre lo que da de sí un framework que se sostiene en tres callbacks. Esto es lo que aprendí en el camino.

Contexto: ¿qué es LÖVE y por qué Lua?

Love2D (cuyo binario se llama love) es un framework 2D escrito en C++ que embebe LuaJIT. No es un motor con editor visual ni un IDE: es una librería de bajo arrastre que te entrega gráficos, audio, físicas (Box2D incluido), entrada y red, todo accesible desde Lua. Para arrancar basta con un fichero main.lua y ejecutar love ..

Lua, por su parte, es un lenguaje pequeño, dinámico y de prototipos, célebre por su única estructura de datos compuesta: la tabla. No hay clases, ni arrays, ni diccionarios separados; todo es tabla. Esa frugalidad, lejos de ser limitante, induce un estilo en el que las entidades del juego se modelan como diccionarios planos, manipulados por funciones libres. Tras décadas de jerarquías OO, resulta refrescante.

El bucle de juego

La columna vertebral de Love2D son tres callbacks que el runtime invoca en este orden cada fotograma:

love.load()una sola vezlove.update(dt)física, IA, entradalove.draw()render del fotogramasiguiente frame

Cabe destacar que dt (delta-time, en segundos) es el contrato fundamental: si multiplicas toda velocidad por dt, el juego se comporta igual a 30 fps que a 144. Olvidarlo es el rito de iniciación de todo principiante en gamedev.

Estructura del proyecto

Mi árbol final ronda los seis ficheros:

pong/
├── main.lua
├── conf.lua
├── entities/
│   ├── paddle.lua
│   └── ball.lua
├── systems/
│   └── collision.lua
└── assets/
    ├── beep.wav
    └── score.wav

conf.lua permite declarar metadatos antes de que arranque el motor:

function love.conf(t)
    t.window.title = "Pong"
    t.window.width = 800
    t.window.height = 480
    t.window.vsync = 1
    t.console = true
end

Entidades como tablas

Aquí emerge la primera revelación: en Lua una paleta no necesita una clase. Una función fábrica devuelve una tabla con estado y métodos.

local Paddle = {}
 
function Paddle.new(x, y)
    return {
        x = x, y = y,
        w = 12, h = 80,
        speed = 320,
        score = 0,
    }
end
 
function Paddle.update(p, dt, up, down)
    if love.keyboard.isDown(up)   then p.y = p.y - p.speed * dt end
    if love.keyboard.isDown(down) then p.y = p.y + p.speed * dt end
    p.y = math.max(0, math.min(p.y, love.graphics.getHeight() - p.h))
end
 
function Paddle.draw(p)
    love.graphics.rectangle("fill", p.x, p.y, p.w, p.h)
end
 
return Paddle

La pelota sigue el mismo patrón, con un detalle: al rebotar contra una paleta no me limito a invertir vx; introduzco una pequeña deflexión vertical en función del punto de impacto, lo que aporta intencionalidad a la jugada.

local Ball = {}
 
function Ball.new()
    local b = { x = 400, y = 240, r = 6, vx = 240, vy = 120 }
    return b
end
 
function Ball.update(b, dt)
    b.x = b.x + b.vx * dt
    b.y = b.y + b.vy * dt
    if b.y < b.r or b.y > love.graphics.getHeight() - b.r then
        b.vy = -b.vy
    end
end
 
function Ball.bounce(b, paddle)
    local relative = (b.y - (paddle.y + paddle.h / 2)) / (paddle.h / 2)
    b.vx = -b.vx * 1.05            -- ligera aceleración por rally
    b.vy = relative * 260
end
 
return Ball

Colisión AABB

Para dos rectángulos alineados con los ejes, la prueba de intersección se reduce a cuatro desigualdades. Por consiguiente, el sistema de colisión cabe en doce líneas:

local M = {}
 
function M.aabb(ax, ay, aw, ah, bx, by, bw, bh)
    return ax < bx + bw and
           ax + aw > bx and
           ay < by + bh and
           ay + ah > by
end
 
function M.ballVsPaddle(b, p)
    return M.aabb(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2,
                  p.x, p.y, p.w, p.h)
end
 
return M

Una IA honestamente boba

Para el rival busqué algo que no jugara perfecto: en Pong nada hay menos divertido que un oponente infalible. La heurística es perseguir la pelota con velocidad limitada y un pequeño «error de seguimiento» que la hace fallar de vez en cuando.

function ai_update(cpu, ball, dt)
    local target = ball.y + math.sin(love.timer.getTime() * 3) * 30
    local diff = target - (cpu.y + cpu.h / 2)
    local step = math.min(math.abs(diff), cpu.speed * 0.75 * dt)
    cpu.y = cpu.y + step * (diff > 0 and 1 or -1)
end

Audio sin ceremonias

love.audio es lo más cercano a un play() ideal que he visto. Cargo los sonidos una vez y los reproduzco en cada evento.

local beep  = love.audio.newSource("assets/beep.wav", "static")
local score = love.audio.newSource("assets/score.wav", "static")
 
function play(s)
    s:stop()
    s:play()
end

El main.lua que orquesta todo

local Paddle    = require "entities.paddle"
local Ball      = require "entities.ball"
local Collision = require "systems.collision"
 
local left, right, ball
 
function love.load()
    love.graphics.setDefaultFilter("nearest", "nearest")
    left  = Paddle.new(20, 200)
    right = Paddle.new(768, 200)
    ball  = Ball.new()
end
 
function love.update(dt)
    Paddle.update(left,  dt, "w", "s")
    ai_update(right, ball, dt)
    Ball.update(ball, dt)
 
    if Collision.ballVsPaddle(ball, left)  then Ball.bounce(ball, left);  play(beep) end
    if Collision.ballVsPaddle(ball, right) then Ball.bounce(ball, right); play(beep) end
 
    if ball.x < 0 then right.score = right.score + 1; play(score); ball = Ball.new() end
    if ball.x > love.graphics.getWidth() then left.score = left.score + 1; play(score); ball = Ball.new() end
end
 
function love.draw()
    Paddle.draw(left);  Paddle.draw(right)
    love.graphics.circle("fill", ball.x, ball.y, ball.r)
    love.graphics.printf(left.score .. "  -  " .. right.score, 0, 20, love.graphics.getWidth(), "center")
end

Hallazgos

  1. La tabla como tipo único deja un código asombrosamente plano. No echo de menos las clases; lo que tengo es estado puro y funciones que lo transforman, lo que se acerca a un estilo funcional sin imposición.
  2. El bucle de tres callbacks es liberador. Acostumbrado a frameworks que imponen escenas, nodos y árboles, regresar a load/update/draw me obligó a pensar en sistemas en vez de jerarquías.
  3. Box2D entra por la puerta de atrás. No lo necesité para Pong, pero saber que love.physics lo expone con una API idiomática es tranquilizador para futuros proyectos.
  4. LuaJIT vuela. Una versión con cien pelotas simultáneas rondaba los 1500 fps en una MacBook Air. El cuello jamás fue el lenguaje.
  5. hump, bump y anim8 son las tres dependencias que casi todo el mundo termina añadiendo; las descubrí tarde y las recomiendo desde la primera línea.