* 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.
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:
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.wavconf.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
endEntidades 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 PaddleLa 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 BallColisió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 MUna 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)
endAudio 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()
endEl 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")
endHallazgos
- 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.
- El bucle de tres callbacks es liberador. Acostumbrado a frameworks que imponen escenas, nodos y árboles, regresar a
load/update/drawme obligó a pensar en sistemas en vez de jerarquías. - Box2D entra por la puerta de atrás. No lo necesité para Pong, pero saber que
love.physicslo expone con una API idiomática es tranquilizador para futuros proyectos. - 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.
hump,bumpyanim8son las tres dependencias que casi todo el mundo termina añadiendo; las descubrí tarde y las recomiendo desde la primera línea.