Skip to main content

Cómo Añadir Game Juice

Este tutorial fue escrito para la v2 del motor.

Construiste el juego. Las mecánicas funcionan. Pero algo se siente raro — los golpes no se sienten, la bola rebota como si estuviera hecha de hojas de cálculo, y romper un ladrillo tiene toda la emoción de marcar una casilla. El juego es correcto, pero no está vivo.

Eso es lo que arregla el juice. Es el feedback visual que hace que lo correcto se sienta bien: un destello cuando algo se rompe, partículas volando del impacto, texto que pulsa en vez de quedarse muerto, movimiento que se suaviza en vez de saltar. Nada de esto cambia las reglas — cambia cómo se sienten las reglas.

Vamos a construir un juego de breakout desde cero y luego añadir cuatro técnicas: pulsos de fondo y destellos de pantalla, freeze frames, explosiones de partículas y easing. Cada una es pequeña. Juntas convierten un prototipo plano en algo que se siente como un juego.

El Juego Base

Antes de poder añadir juice, necesitamos algo a lo que añadirle juice. Aquí hay un breakout mínimo: una paleta abajo, una bola que rebota y una cuadrícula de ladrillos para romper. Sin efectos, sin pulido — solo las mecánicas.

Los ladrillos están en una cuadrícula de 8x4. Cada uno mide 14 píxeles de ancho y 6 de alto, con espacios de 2 píxeles. Cuatro filas, cuatro colores:

let bricks = [];
let rowColors = [8, 9, 14, 12];

function buildBricks() {
    bricks = [];
    for (let row = 0; row < 4; row++) {
        for (let col = 0; col < 8; col++) {
            bricks.push({
                x: 3 + col * 16,
                y: 10 + row * 8,
                color: rowColors[row],
                active: true,
            });
        }
    }
}

La bandera active nos permite "romper" un ladrillo sin eliminarlo del array. Saltamos los ladrillos inactivos al dibujar y al verificar colisiones.

La paleta se mueve con las flechas del teclado vía btn() para un movimiento fluido al mantener pulsado. La bola se queda sobre la paleta hasta que presionas Z, luego se lanza hacia arriba con un ligero ángulo:

if (!launched) {
    ballX = paddleX + paddleW / 2;
    ballY = paddleY - ballR;
    if (btnp('z')) {
        launched = true;
        ballDx = 1;
        ballDy = -2;
    }
    return;
}

La colisión es directa. La bola rebota en las paredes invirtiendo su velocidad en el eje correspondiente. Los golpes en la paleta invierten dy y ajustan el ángulo de dx según dónde cae la bola — el centro va recto hacia arriba, los bordes van abiertos:

if (ballDy > 0 && ballY + ballR >= paddleY && ballY + ballR <= paddleY + paddleH
    && ballX >= paddleX && ballX <= paddleX + paddleW) {
    ballDy = -ballDy;
    let hit = (ballX - paddleX) / paddleW;
    ballDx = (hit - 0.5) * 4;
    ballY = paddleY - ballR;
}

Los golpes en ladrillos invierten dy y desactivan el ladrillo. Si la bola cae por debajo de la paleta, se reinicia sobre ella.

Dale una jugada. Las mecánicas funcionan. Pero romper un ladrillo se siente como si nada hubiera pasado — igual que rebotar en una pared, igual que golpear la paleta. Todo es igualmente plano.

Game Juice: The Base Game
Flechas para mover la paleta. Z para lanzar la bola. Rompe todos los ladrillos.

Pulso de Fondo, Destello de Pantalla y Freeze Frames

El truco de juice más simple es cambiar el color de fondo durante unos frames cuando algo sucede. En vez de cls(0) cada frame, cambiamos a cls(1) — azul oscuro — durante 3 frames después de romper un ladrillo:

let bgFlashTimer = 0;

// in brick collision:
bgFlashTimer = 3;

// in draw():
if (bgFlashTimer > 0) {
    bgFlashTimer--;
    cls(1);
} else {
    cls(0);
}

Apenas es visible si lo estás buscando, pero tu cerebro registra el cambio. La pantalla "reacciona" al golpe.

Para momentos más grandes, un destello de pantalla completo funciona mejor. Cuando la bola cae por debajo, superponemos un rectángulo rojo que parpadea en frames alternos:

let flashTimer = 0;
let flashColor = 0;

function flash(color, duration) {
    flashColor = color;
    flashTimer = duration;
}

// in resetBall():
flash(8, 6);

// in draw(), after everything else:
if (flashTimer > 0) {
    flashTimer--;
    if (flashTimer % 2 === 0) {
        rectfill(0, 0, 127, 127, flashColor);
    }
}

El truco de frames alternos importa. Una capa roja sólida durante 6 frames simplemente parece un error. Hacerla parpadear encendida y apagada la hace sentir como un flash de cámara — breve, intenso, se fue. Tu cerebro lo lee como impacto, no como error.

Los freeze frames completan el trío. Cuando eliminas una fila entera, el juego se pausa durante 6 frames — sin movimiento, sin input, solo un instante de quietud antes de que todo continúe:

let freezeTimer = 0;

// in update(), at the very top:
if (freezeTimer > 0) {
    freezeTimer--;
    return;
}

// after breaking a brick:
if (countRow(row) === 0) {
    freezeTimer = 6;
}

Ese return temprano es toda la técnica. Mientras freezeTimer sea positivo, update no hace nada — la bola queda suspendida en el aire, la paleta ignora el input, todo se queda quieto. Seis frames es aproximadamente una décima de segundo. Lo suficiente para sentirse deliberado, lo suficiente corto para no sentirse como un bug.

El screen shake funciona de la misma manera — desplaza la cámara una cantidad aleatoria cada frame y decae hacia cero. El tutorial de bullet heaven lo cubre en detalle.

Game Juice: Screen Flash and Freeze Frames
El fondo pulsa azul oscuro al golpear un ladrillo. Destello rojo cuando la bola cae. Freeze frame cuando eliminas una fila entera.

Explosiones de Partículas

Los destellos y los freezes afectan toda la pantalla. Las partículas son localizadas — salen disparadas desde un punto específico, dándole al ojo del jugador algo que seguir. Cuando un ladrillo se rompe, puntos de colores salen volando del impacto. Cuando la bola cae por debajo, una explosión blanca más grande marca la pérdida.

El sistema de partículas es un pool con límite. Cada partícula tiene posición, velocidad, tiempo de vida y color:

let PARTICLE_CAP = 60;
let particles = [];

function burst(x, y, color, count) {
    for (let i = 0; i < count; i++) {
        if (particles.length >= PARTICLE_CAP) {
            let found = false;
            for (let p of particles) {
                if (!p.active) {
                    p.x = x;
                    p.y = y;
                    p.dx = (rnd() - 0.5) * 3;
                    p.dy = (rnd() - 0.5) * 3;
                    p.life = 15 + Math.floor(rnd() * 10);
                    p.maxLife = p.life;
                    p.color = color;
                    p.active = true;
                    found = true;
                    break;
                }
            }
            if (!found) continue;
        } else {
            particles.push({
                x: x,
                y: y,
                dx: (rnd() - 0.5) * 3,
                dy: (rnd() - 0.5) * 3,
                life: 15 + Math.floor(rnd() * 10),
                maxLife: 15,
                color: color,
                active: true,
            });
        }
    }
}

El límite previene la asignación descontrolada. Una vez que llegamos a 60 partículas, las nuevas explosiones reciclan las muertas en vez de hacer crecer el array. rnd() devuelve un valor entre 0 y 1, así que (rnd() - 0.5) * 3 le da a cada partícula una velocidad aleatoria en ambos ejes — salen disparadas en todas direcciones.

Cada frame, las partículas se desplazan, acumulan un poco de gravedad y cuentan hacia atrás su vida:

for (let p of particles) {
    if (!p.active) continue;
    p.x += p.dx;
    p.y += p.dy;
    p.dy += 0.05;
    p.life--;
    if (p.life <= 0) p.active = false;
}

Esa gravedad (dy += 0.05) es sutil pero importa. Sin ella, las partículas se desplazan en líneas rectas y se ven mecánicas. Con ella, se curvan hacia abajo y se sienten físicas — como escombros, no confeti en gravedad cero.

El dibujo usa la proporción de vida para encoger las partículas a medida que envejecen. Las partículas frescas son cuadrados rectfill de 2x2. Pasada la mitad de su tiempo de vida, se reducen a puntos pset de un solo píxel antes de desaparecer:

for (let p of particles) {
    if (!p.active) continue;
    if (p.life > p.maxLife * 0.5) {
        rectfill(p.x, p.y, p.x + 1, p.y + 1, p.color);
    } else {
        pset(p.x, p.y, p.color);
    }
}

Las roturas de ladrillos generan una pequeña explosión de 4 partículas del color del ladrillo. La pérdida de bola genera una explosión más grande de 8 partículas blancas. Los diferentes tamaños crean una jerarquía visual — feedback pequeño para eventos rutinarios, feedback grande para los importantes.

Game Juice: Particle Bursts
Explosiones de partículas de colores al romper ladrillos. Explosión blanca cuando la bola cae.

Pulsación y Easing

Todo hasta ahora ha sido reactivo — algo sucede, un efecto se dispara. La pulsación es diferente. Se ejecuta todo el tiempo, dándole a los elementos estáticos una sensación de vida incluso cuando no pasa nada.

El truco es Math.sin sobre un contador de frames. Un tick global se incrementa cada frame, y lo alimentamos en una onda senoidal para oscilar entre dos estados:

let tick = 0;

// in update():
tick++;

// in draw():
if (!launched && !cleared) {
    let pulse = Math.sin(tick * 0.1);
    let col = pulse > 0 ? 7 : 6;
    text('READY', 44, 70, col);
}

El multiplicador 0.1 controla la velocidad — valores más bajos pulsan más lento. El texto "READY" alterna entre blanco (7) y gris oscuro (6), creando un efecto de respiración que dice "hey, presiona algo." Cuando todos los ladrillos están eliminados, un pulso similar cicla entre tres colores para el texto "CLEARED!".

El easing aplica la misma idea al movimiento. En vez de que las cosas aparezcan en su posición de golpe, se deslizan. easeOutQuad toma un valor de 0 a 1 y devuelve una curva que empieza rápido y desacelera:

function easeOutQuad(t) {
    return t * (2 - t);
}

Cuando el juego empieza, los ladrillos se deslizan desde 20 píxeles por encima de su posición final durante 20 frames. Cada ladrillo almacena su baseY (donde debería terminar) y se dibuja con un desplazamiento basado en la curva de easing:

let t = animTimer < animDuration ? easeOutQuad(animTimer / animDuration) : 1;

for (let b of bricks) {
    if (!b.active) continue;
    let drawY = b.baseY - 20 + 20 * t;
    rectfill(b.x, drawY, b.x + 13, drawY + 5, b.color);
}

En el frame 0, t es 0 y los ladrillos se dibujan 20 píxeles demasiado arriba. En el frame 20, t es 1 y están en su lugar. La curva de easing significa que cubren la mayor parte de la distancia en los primeros frames y se asientan suavemente — como algo deslizándose hasta detenerse.

El puntaje usa un enfoque diferente. En vez de una animación temporizada, hace lerp hacia el valor objetivo cada frame:

if (displayScore < score) {
    displayScore += (score - displayScore) * 0.2;
    if (score - displayScore < 1) displayScore = score;
}

Cada frame, displayScore cierra el 20% de la brecha entre él y score. Rompe un ladrillo que vale 10 puntos y el display no salta de 0 a 10 — sube 2, luego 1.6, luego 1.3, luego se ajusta a 10 cuando la brecha cae por debajo de 1. El resultado es un puntaje que sube suavemente después de cada golpe.

Game Juice: Pulsing and Easing
Los ladrillos se deslizan desde arriba. Texto READY pulsante. El puntaje sube suavemente en vez de saltar. CLEARED pulsa cuando todos los ladrillos desaparecen.

Para Seguir Explorando

  • Screen shake — Combina todo lo de aquí con el shake de cámara del tutorial de bullet heaven. Shake al limpiar una fila, destello al golpear, partículas al romper — apilar efectos en el mismo evento es donde el juice realmente se multiplica.
  • Efectos de estela — Almacena las últimas 4-5 posiciones de la bola y dibuja copias difuminadas detrás. Usa colores decrecientes (blanco, gris claro, gris oscuro) para una cola de cometa. Barato de añadir, e inmediatamente hace que el movimiento se sienta más rápido.
  • Squash and stretch — Deforma la bola al rebotar. Cuando golpea la paleta, ensánchala brevemente en horizontal y aplástala en vertical. En golpes de pared, haz lo opuesto. Unos frames de distorsión venden el impacto mejor que cualquier destello.
  • Apilamiento de juice — El verdadero poder está en combinar técnicas en un solo evento. Una rotura de ladrillo podría disparar un pulso de fondo + partículas + freeze frame + incremento de puntaje todo a la vez. Cada efecto es pequeño por sí solo. Juntos hacen que un momento se sienta importante.
  • Efectos de sonido — Combina el juice visual con llamadas a sfx() para feedback multisensorial. Un blip corto al romper ladrillos, un tono más grave al limpiar una fila, un crash al perder la bola. El sonido y las imágenes se refuerzan mutuamente — uno sin el otro siempre se siente incompleto.