Introducción al Desarrollo de Videojuegos con GameMaker · Módulo 11: Desarrollo completo de un videojuego de plataformas 2D.

Pulido de mecánicas (Juice) y respuesta visual

Clase 53 de 60 · 60 min

También disponible en: EN

Introducción al concepto de «Juice» y Game Feel

En el desarrollo de videojuegos existe una distinción crucial entre una mecánica que funciona y una mecánica que se siente bien. Un personaje puede correr, saltar y colisionar con precisión matemática estricta según las reglas programadas en los módulos anteriores; sin embargo, si carece de respuesta sensorial inmediata, el jugador percibirá el control como rígido, acartonado o desconectado. En la industria del videojuego independiente, el término coloquial «Juice» (jugosidad) se refiere a todas aquellas capas de retroalimentación visual, sonora y temporal añadidas sobre una mecánica funcional para maximizar el placer táctil y visual al interactuar con el mundo virtual.

El «Game Feel» no altera las reglas fundamentales de la física del juego, sino cómo se comunican y cómo se toleran las imperfecciones del propio jugador humano. Cuando un personaje salta, no solo debe cambiar su posición vertical: el suelo debe emitir partículas, el cuerpo del personaje debe comprimirse antes de impulsarse y estirarse en el aire, la cámara puede reaccionar con un microtemblor si el impacto es contundente, y el juego debe conceder un pequeño margen de error si el usuario presiona el botón unos milisegundos antes o después del momento exacto. En esta clase abordaremos el pulido integral de nuestro juego de plataformas 2D en GameMaker, asegurando que cada movimiento transmita peso, dinamismo y satisfacción.

Tolerancia del control: Coyote Time y Jump Buffering

En un motor físico estricto, si el personaje deja de tocar el suelo durante un solo fotograma (por ejemplo, al caminar hacia el vacío desde el borde de un acantilado), la variable de contacto con el suelo pasa inmediatamente a false y el comando de salto queda inhabilitado. Para el cerebro humano y la tasa de refresco visual, esto suele percibirse como un fallo injusto del juego: el usuario cree haber presionado el botón cuando aún estaba en el borde, pero el procesador ya detectó caída libre. Para resolver esto implementamos dos mecánicas universales de tolerancia temporal: el Coyote Time y el Jump Buffering.

El «Coyote Time» (nombrado en honor al coyote de los dibujos animados que corre en el aire por un instante antes de caer) permite al jugador saltar durante una pequeña ventana de tiempo (habitualmente entre 5 y 8 fotogramas a 60 FPS) después de haber abandonado una superficie sólida sin haber saltado. Por su parte, el «Jump Buffering» (amortiguación de entrada) almacena en memoria la pulsación de salto si el jugador la ejecuta en el aire justo antes de tocar el suelo (en un intervalo similar de 5 a 8 fotogramas), disparando el salto automáticamente en el fotograma exacto en el que el personaje hace contacto con la plataforma.

// Objeto: obj_player
// Evento: Create

// Variables de movimiento base (ya existentes)
hsp = 0;
vsp = 0;
grav = 0.4;
walk_spd = 3.5;
jump_spd = -7.5;
grounded = false;

// Tolerancias de entrada (Juice: Control Feel)
coyote_max = 6;         // Fotogramas de gracia tras salir del suelo
coyote_timer = 0;       // Contador regresivo para el salto tardío

buffer_max = 6;         // Fotogramas que recordamos la pulsación de salto
buffer_timer = 0;       // Contador regresivo para el salto anticipado

En el evento Step del jugador, actualizamos estos temporizadores fotograma a fotograma antes de evaluar la lógica de salto. Si el jugador está sobre el suelo (`grounded == true`), recargamos el contador de Coyote Time a su valor máximo. En caso contrario, reducimos el contador en 1 unidad por cada paso. Para el Jump Buffer, si detectamos la pulsación de la tecla de salto, asignamos el valor máximo a la variable `buffer_timer`. El salto solo se consumirá si ambos márgenes convergen favorablemente.

// Objeto: obj_player
// Evento: Step (Sección de gestión de temporizadores y salto)

// 1. Lectura de entrada de salto
var _key_jump = keyboard_check_pressed(vk_space) || gamepad_button_check_pressed(0, gp_face1);

// 2. Gestión de Coyote Time
if (grounded) {
    coyote_timer = coyote_max;
} else {
    if (coyote_timer > 0) coyote_timer--;
}

// 3. Gestión de Jump Buffering
if (_key_jump) {
    buffer_timer = buffer_max;
} else {
    if (buffer_timer > 0) buffer_timer--;
}

// 4. Ejecución del salto utilizando las tolerancias
if (buffer_timer > 0 && coyote_timer > 0) {
    vsp = jump_spd;
    buffer_timer = 0;   // Consumimos el buffer inmediatamente para no repetir
    coyote_timer = 0;   // Consumimos la oportunidad del coyote
    grounded = false;
}

Con estas pocas líneas de código, la sensación del salto se transforma radicalmente. El jugador ya no experimentará caídas frustrantes al intentar apurar los saltos en los bordes de los precipicios ni perderá la fluidez rítmica al intentar encadenar saltos sucesivos al tocar tierra.

Deformación elástica: Squash and Stretch

El principio de animación clásica conocido como estiramiento y encogimiento («Squash and Stretch») es fundamental para conferir masa, elasticidad y dinamismo a un cuerpo 2D. Cuando un cuerpo orgánico choca fuertemente contra el suelo, se deforma ensanchándose horizontalmente y achatándose verticalmente (Squash) para disipar la energía cinética; a la inversa, cuando se impulsa hacia arriba o cae a gran velocidad, se elonga verticalmente y se estrecha horizontalmente (Stretch).

Diagrama esquemático que muestra las fases de deformación elástica de un personaje: reposo neutro, estiramiento vertical durante el despegue del salto, compresión horizontal al aterrizar y retorno elástico a la escala base.
Figura 1: Fases de deformación elástica (Squash and Stretch) durante el ciclo de despegue y aterrizaje. · Imagen generada con IA

En GameMaker, alterar directamente `image_xscale` e `image_yscale` para efectos visuales presenta un riesgo crítico: si la máscara de colisión de la instancia depende de la escala de la imagen, modificar estas propiedades alterará la caja delimitadora (bounding box) física del personaje, lo que provocará que el personaje se atasque dentro de las paredes o del suelo. La solución profesional y segura consiste en mantener intactas las variables nativas de escala para la física y definir variables personalizadas destinadas exclusivamente al renderizado en el evento Draw.

// Objeto: obj_player
// Evento: Create

// Variables de deformación visual independiente de la colisión física
x_scale_display = 1.0;
y_scale_display = 1.0;

// Variable de ayuda para detectar el impacto exacto con el suelo
was_grounded = true;

En el evento Step, detectamos el momento exacto en el que el personaje salta y el momento exacto en el que aterriza tras una caída. Cuando esto ocurre, forzamos valores asimétricos en `x_scale_display` e `y_scale_display`. Acto seguido, en cada fotograma, utilizamos la función de interpolación lineal `lerp()` para devolver gradualmente estas variables a su valor neutro de 1.0.

// Objeto: obj_player
// Evento: Step (Sección de Squash and Stretch)

// Detección de salto para estirar al personaje (Stretch)
if (buffer_timer == 0 && coyote_timer == 0 && vsp == jump_spd) {
    x_scale_display = 0.7; // Se estrecha
    y_scale_display = 1.3; // Se estira hacia arriba
}

// Detección del impacto contra el suelo (Squash)
if (!was_grounded && grounded) {
    // El impacto aplasta al personaje contra el suelo
    x_scale_display = 1.35;
    y_scale_display = 0.65;
}

// Almacenamos el estado del suelo para el siguiente paso
was_grounded = grounded;

// Retorno elástico continuo hacia la escala neutra (1.0)
x_scale_display = lerp(x_scale_display, 1.0, 0.15);
y_scale_display = lerp(y_scale_display, 1.0, 0.15);

💡 Recuerda que `lerp(a, b, amt)` aproxima el valor 'a' hacia 'b' en un porcentaje 'amt' por fotograma. Con un factor de 0.15, la deformación retrocede de forma suave y amortiguada sin requerir complejas fórmulas trigonométricas de resortes.

Para que esta deformación tenga efecto real sin alterar la física del juego, reemplazamos el dibujado por defecto en el evento Draw del jugador. Usamos `draw_sprite_ext()`, multiplicando la dirección horizontal tradicional (`facing`, que toma valores de 1 o -1 para mirar a derecha o izquierda) por nuestra escala personalizada.

// Objeto: obj_player
// Evento: Draw

// Asegúrate de que el origen del sprite de obj_player esté situado en 'Bottom Centre'
// de modo que la deformación se asiente en los pies y no flote en el aire.

draw_sprite_ext(
    sprite_index,
    image_index,
    x,
    y,
    facing * x_scale_display,
    y_scale_display,
    0,
    c_white,
    image_alpha
);

Sacudida de cámara (Screen Shake)

La sacudida de pantalla (Screen Shake) es uno de los métodos más efectivos para transmitir fuerza, peso y violencia cinemática. Un impacto contra un obstáculo, el aterrizaje desde una gran altura o la eliminación de un enemigo cobran vida si el punto de vista se perturba brevemente. Sin embargo, un temblor mal calibrado o persistente produce mareo cinemático (motion sickness) y desorientación. Por tanto, el temblor debe ser súbito en su inicio y disiparse de forma exponencial rápida.

En el Módulo 10 aprendimos a configurar una cámara manual o seguidora utilizando la cámara de la vista (`view_camera[0]`). Para añadir sacudida sin que la cámara se desvíe progresivamente del objetivo, tomamos la posición teórica deseada de la cámara (centrada en el jugador) y le sumamos un desplazamiento aleatorio que decrece en cada fotograma.

// Objeto: obj_camera
// Evento: Create

target = obj_player;
cam = view_camera[0];
cam_w = camera_get_view_width(cam);
cam_h = camera_get_view_height(cam);

// Variables de Screen Shake
shake_magnitude = 0;   // Fuerza del temblor actual en píxeles
shake_remain = 0;      // Magnitud residual decreciente
shake_fade = 0.9;      // Coeficiente de atenuación multiplicativa

Para facilitar que cualquier entidad del juego (el jugador, un proyectil o un jefe) pueda solicitar un temblor sin preocuparse por la implementación interna de la cámara, creamos una función global o script llamada `screen_shake()`.

// Script: scr_screen_shake
// Función utilizable desde cualquier evento de cualquier objeto

function screen_shake(_magnitude, _force_overwrite = false) {
    if (instance_exists(obj_camera)) {
        with (obj_camera) {
            // Si la nueva sacudida es más potente que la que está ocurriendo, o si forzamos el reemplazo
            if (_magnitude > shake_remain || _force_overwrite) {
                shake_magnitude = _magnitude;
                shake_remain = _magnitude;
            }
        }
    }
}

Finalmente, en el evento Step (o preferiblemente en el evento End Step) de `obj_camera`, calculamos el seguimiento clásico y sumamos la perturbación pseudoaleatoria calculada mediante la función `random_range()` multiplicada por la fuerza restante.

// Objeto: obj_camera
// Evento: End Step

if (instance_exists(target)) {
    // Posición ideal sin perturbación
    var _dest_x = target.x - (cam_w / 2);
    var _dest_y = target.y - (cam_h / 2);

    // Suavizado de seguimiento continuo
    var _cur_x = camera_get_view_x(cam);
    var _cur_y = camera_get_view_y(cam);
    var _final_x = lerp(_cur_x, _dest_x, 0.1);
    var _final_y = lerp(_cur_y, _dest_y, 0.1);

    // Cálculo de la desviación por sacudida
    var _offset_x = random_range(-shake_remain, shake_remain);
    var _offset_y = random_range(-shake_remain, shake_remain);

    // Disipación del temblor residual
    shake_remain = lerp(shake_remain, 0, 0.1);
    if (shake_remain < 0.1) shake_remain = 0;

    // Límites de la habitación para evitar ver el vacío exterior
    _final_x = clamp(_final_x + _offset_x, 0, room_width - cam_w);
    _final_y = clamp(_final_y + _offset_y, 0, room_height - cam_h);

    camera_set_view_pos(cam, _final_x, _final_y);
}

Destello de daño (White Flash) y parada de impacto (Hitstop)

Cuando dos objetos colisionan con consecuencias críticas (por ejemplo, el jugador pisando a un enemigo patrullero o recibiendo daño de una trampa), el cambio inmediato de variables numéricas como `hp -= 1` resulta demasiado abstracto. Los juegos de acción y plataformas de mayor prestigio emplean dos técnicas complementarias para hacer tangible este evento: el destello monocromático (generalmente blanco puro) y la microcongelación temporal (Hitstop o Sleep Frames).

El destello monocromático consiste en forzar que el sprite del personaje o enemigo se dibuje completamente blanco durante 2 a 4 fotogramas. En GameMaker, esto puede lograrse de forma extraordinariamente eficiente sin necesidad de crear sprites duplicados ni escribir un shader complejo, utilizando las funciones de niebla de la GPU (`gpu_set_fog`). Al activar la niebla con color blanco puro y distancia cero, cualquier pixel opaco que se envíe a la tarjeta gráfica adoptará dicho color homogéneamente conservando intacta su silueta de canal alfa.

// Objeto: obj_enemy_parent (o obj_player)
// Evento: Create

flash_timer = 0; // Duración del destello en fotogramas
// Objeto: obj_enemy_parent (o obj_player)
// Evento: Draw

if (flash_timer > 0) {
    flash_timer--;
    // Activamos la sustitución de color mediante la GPU
    gpu_set_fog(true, c_white, 0, 0);
    draw_self();
    // IMPORTANTE: Desactivar inmediatamente la niebla para no afectar al resto del juego
    gpu_set_fog(false, c_white, 0, 0);
} else {
    draw_self();
}

La segunda técnica, el «Hitstop», detiene deliberadamente el procesamiento de los objetos y las animaciones durante un lapso minúsculo (habitualmente entre 3 y 6 fotogramas, lo que equivale a 0.05 - 0.10 segundos). Esta pausa infinitesimal permite que el cerebro del jugador registre con claridad la violencia y trascendencia del impacto antes de ver sus consecuencias físicas (el enemigo saliendo despedido o desintegrándose).

Para implementar Hitstop sin congelar la interfaz ni bloquear el bucle general de GameMaker, creamos un objeto global de control de efectos, o delegamos esta variable en un gestor existente como `obj_game_controller`.

// Objeto: obj_game_controller
// Evento: Create
hitstop_frames = 0;

// Función pública para activar congelación
function trigger_hitstop(_frames) {
    if (instance_exists(obj_game_controller)) {
        obj_game_controller.hitstop_frames = _frames;
    }
}
// Objeto: obj_game_controller
// Evento: Step

// Si el Hitstop está activo, decrementamos el temporizador
if (hitstop_frames > 0) {
    hitstop_frames--;
}

En los objetos con lógica física y animación (como `obj_player` y los enemigos), colocamos una cláusula de guarda al principio de sus eventos Step. Si `hitstop_frames > 0`, detenemos la actualización física y congelamos la velocidad de animación (`image_speed = 0`). Al finalizar el congelamiento, restauramos la animación regular.

// Objeto: obj_player (y obj_enemy_parent)
// Evento: Step (Al inicio absoluto del evento)

if (instance_exists(obj_game_controller)) {
    if (obj_game_controller.hitstop_frames > 0) {
        image_speed = 0; // Congela la animación visual del sprite actual
        exit;            // Abandona inmediatamente el evento Step; no se computa movimiento ni gravedad
    } else {
        image_speed = 1; // Restaura la velocidad normal de animación
    }
}

Matriz de técnicas de retroalimentación

La siguiente tabla resume cómo interactúan y cuándo deben aplicarse los diferentes componentes de retroalimentación analizados en esta sesión para mantener una armonía equilibrada en el juego:

Técnica de JuiceDisparador recomendadoParámetros óptimosEfecto psicológico en el jugador
Coyote TimeAbandono de plataforma al caminar al vacío5 a 8 fotogramas (0.08s - 0.13s)Sensación de control receptivo y justo; elimina saltos fallidos.
Jump BufferPulsación de salto previa al aterrizaje5 a 8 fotogramas (0.08s - 0.13s)Elimina la frustración por entradas no registradas por milésimas.
Squash & StretchDespegue vertical e impacto con sueloEscala 0.7/1.3 en salto; 1.35/0.65 en choqueComunica masa, flexibilidad física y naturalidad orgánica.
Screen ShakeDaño recibido, explosiones, caída pesadaMagnitud 2 a 6 px; amortiguación rápidaTransmite peso, conmoción y escala física contundente.
White FlashCualquier golpe recibido por jugador o enemigo2 a 4 fotogramasConfirma inequívocamente el impacto visual en el blanco.
HitstopAtaque con éxito sobre un enemigo o daño letal3 a 6 fotogramas de congelamiento totalAñade énfasis dramático y contundencia física al golpe.

Integración práctica: El ciclo de impacto completo

Para observar la sinergia de todas estas técnicas combinadas, analicemos qué sucede cuando el jugador cae desde el aire y pisa con éxito la cabeza de un enemigo. En un juego sin pulir, el enemigo simplemente desaparecería mediante `instance_destroy()` y el jugador adquiriría una velocidad ascendente `vsp = jump_spd`. Aunque funcionalmente válido, el evento resulta plano e insípido.

Al aplicar la batería completa de efectos, el mismo evento se orquesta de la siguiente forma coordinada: en el momento del impacto, el jugador y el enemigo sufren un Hitstop de 4 fotogramas; el sprite del enemigo emite un destello blanco; la cámara se sacude 3 píxeles; el jugador adopta una deformación de Squash instantánea que precede a su rebote elástico hacia arriba; se emite una ráfaga de partículas de polvo o esquirlas; y se reproduce el efecto de sonido de impacto ecualizado.

// Objeto: obj_player
// Evento: Step (Sección de colisión vertical con enemigos)

var _enemy = instance_place(x, y + vsp, obj_enemy_parent);

if (_enemy != noone) {
    // Verificamos si el jugador está cayendo sobre la cabeza del enemigo
    if (vsp > 0 && y < _enemy.y) {
        // 1. Física: Rebote del jugador
        vsp = jump_spd * 0.85;
        
        // 2. Jugosidad en el jugador (Squash al pisar)
        x_scale_display = 1.4;
        y_scale_display = 0.6;
        
        // 3. Jugosidad en el enemigo (Destello blanco y reducción de vida)
        _enemy.flash_timer = 4;
        _enemy.hp -= 1;
        
        // 4. Sacudida de pantalla sutil pero nítida
        screen_shake(3);
        
        // 5. Congelación de impacto (Hitstop) para enfatizar la violencia de la colisión
        trigger_hitstop(4);
        
        // 6. Efecto de audio integrado
        audio_play_sound(snd_stomp, 10, false);
    }
}

La conjunción sincrónica de estos seis estímulos transforma una simple resta de datos numéricos en un momento cinemático vibrante y memorable. Ninguno de estos efectos requiere una gran potencia de procesamiento ni librerías complejas, sino una dedicación consciente al detalle por parte del diseñador de juego.

Ejercicios prácticos

1. Calibración de Coyote Time y Jump Buffer: Modifica las variables `coyote_max` y `buffer_max` en el evento Create de `obj_player` probando con valores extremos: primero asígnales 0 fotogramas a ambas y juega durante dos minutos tratando de saltar justo en las esquinas de tus plataformas. Luego, ajústalas a 15 fotogramas. Identifica por qué un valor de 0 se siente injusto y un valor de 15 parece hacer que el personaje «flote» o salte involuntariamente en el vacío. Encuentra el punto de equilibrio óptimo para tu velocidad de movimiento (generalmente entre 5 y 7 fotogramas). Criterio de comprobación: El jugador debe poder saltar de forma natural justo al abandonar un borde sin notar un retraso visible ni percibir que salta sobre el aire deliberadamente.

2. Deformación al impactar contra paredes: Utilizando las variables `x_scale_display` e `y_scale_display`, programa en el evento Step de `obj_player` una reacción elástica cuando el personaje colisione lateralmente a máxima velocidad contra un muro sólido (`obj_solid`). Al chocar de frente, el personaje debe aplastarse horizontalmente (`x_scale_display = 0.75`) y estirarse verticalmente (`y_scale_display = 1.25`) durante un instante antes de retornar a 1.0 mediante `lerp()`. Criterio de comprobación: Al correr a toda velocidad y chocar de frente contra una pared, el sprite debe comprimirse levemente en el eje X sin que la máscara de colisión física atraviese el bloque sólido.

3. Sistema integrado de Screen Shake y Flash al recibir daño: En el evento donde el jugador pierde una vida o colisiona lateralmente con un enemigo, integra una llamada a `screen_shake(6)` y activa `flash_timer = 5` en el jugador. Modifica el evento Draw de `obj_player` para que dibuje el sprite en color rojo puro en lugar de blanco cuando sea el jugador quien reciba el impacto (utilizando `gpu_set_fog(true, c_red, 0, 0)`). Criterio de comprobación: Cada vez que el jugador sea golpeado por un enemigo, la pantalla debe vibrar notablemente con atenuación suave y la silueta del personaje debe teñirse de rojo sólido durante 5 fotogramas.

Resumen

  • El «Juice» y el «Game Feel» transforman mecánicas matemáticamente correctas en experiencias táctiles, reactivas y gratificantes mediante retroalimentación visual, temporal y sonora.
  • Coyote Time y Jump Buffering ofrecen tolerancia al jugador humano almacenando temporalmente las intenciones de salto en pequeñas ventanas de 5 a 8 fotogramas.
  • El Squash and Stretch simula masa y elasticidad mediante la alteración temporal de variables independientes de dibujado en el evento Draw, evitando alterar las máscaras físicas de colisión.
  • El Screen Shake transmite contundencia aplicando desplazamientos pseudoaleatorios con amortiguación lineal o exponencial a la posición de la cámara de la vista.
  • El destello monocromático (White Flash) con gpu_set_fog() y la microcongelación temporal (Hitstop) destacan los momentos de choque o daño sin sobrecargar el procesador ni romper el ritmo de juego.

Practica lo aprendido

Ejercicio 1 · 1 punto

¿Por qué se recomienda crear variables personalizadas (como `x_scale_display` e `y_scale_display`) en lugar de modificar directamente `image_xscale` e `image_yscale` para el efecto de Squash and Stretch?

Practicas sin cuenta: no se guarda nada. Regístrate para guardar tu progreso.

Ejercicio 2 · 1 punto

Relaciona cada técnica de pulido visual y de control (Juice) con su definición correspondiente según lo aprendido en la lección:

  • Coyote Time
  • Jump Buffering
  • Squash
  • Stretch
Practicas sin cuenta: no se guarda nada. Regístrate para guardar tu progreso.

Ejercicio 3 · 1 punto

Ordena cronológicamente los pasos que se ejecutan en el evento Step de `obj_player` para procesar el salto tolerante con Coyote Time y Jump Buffering:

  1. 1Actualizar el temporizador de Jump Buffering (fijándolo al máximo si se presionó la tecla o restando 1 si está en espera).
  2. 2Detectar y almacenar la pulsación del botón o tecla de salto en una variable local.
  3. 3Actualizar el temporizador de Coyote Time (recargándolo al máximo si está en el suelo o restando 1 si está en el aire).
  4. 4Evaluar si ambos temporizadores son mayores que cero simultáneamente.
  5. 5Asignar la velocidad vertical de salto (`vsp = jump_spd`) y reiniciar de inmediato ambos temporizadores a cero.
Practicas sin cuenta: no se guarda nada. Regístrate para guardar tu progreso.

Ingresa para guardar tu progreso — Ingresar