Volver al Blog
Videojuegos4 de agosto de 20269 min de lectura

Dash, salto y movimiento: afinar la sensación de control

Aceleración, fricción y curvas que hacen el movimiento agradable.

IM
Ignacio MelendezDesarrollador Full-Stack y de Videojuegos
Dash, salto y movimiento: afinar la sensación de control

Prueba esto: coge cualquier prototipo de movimiento hecho en una tarde, uno donde velocity.x = input.x * speed se ejecuta directamente en Update, y muévete por una plataforma. Funciona. El personaje va donde el stick apunta. Y aun así, algo se siente mal. Arranca como un robot, frena como si chocara contra un muro, y cualquier salto se siente rígido.

Ese "algo mal" casi nunca es un bug. Es la ausencia de aceleración, fricción y curvas: las tres herramientas que separan un movimiento que obedece de un movimiento que se siente bien. Este artículo es un recorrido por esas tres piezas, con el dash y el salto como los dos casos donde más se nota la diferencia.

1. El input crudo no es la sensación

Un stick analógico o un teclado entregan un valor entre -1 y 1 (o, en teclado, un -1, 0 o 1 seco). La tentación es multiplicar ese valor directamente por la velocidad máxima y asignarlo a la física en cada frame. Es la implementación más corta posible, y es la razón por la que tantos prototipos se sienten "de programador": técnicamente correctos, sensorialmente planos.

El problema es que ese input crudo no representa la intención del jugador en el tiempo. Cuando alguien suelta el stick, no quiere parar en el frame siguiente, quiere desacelerar. Cuando lo empuja al máximo desde cero, no quiere estar a velocidad máxima en el frame siguiente, quiere sentir que el personaje coge impulso. La velocidad real del personaje no debería ser el input, debería perseguir al input.

Esta idea es la misma que resuelve el coyote time o el jump buffering: la simulación tiene un estado exacto, pero el jugador vive en un mundo de intenciones difusas. El trabajo del control es traducir entre los dos.

2. Aceleración y fricción: dos tasas, no una

La primera mejora real es dejar de tratar "cambiar de velocidad" como una sola operación y separarla en dos: aceleración (cuando hay input) y fricción o deceleración (cuando no lo hay, o cuando el input apunta en contra). Son conceptualmente distintas y casi nunca deberían compartir el mismo valor.

1public class MovementController : MonoBehaviour
2{
3    [SerializeField] private float maxSpeed = 8f;
4    [SerializeField] private float acceleration = 60f;
5    [SerializeField] private float friction = 80f;
6
7    private float currentSpeed;
8    private Rigidbody2D rb;
9
10    private void Awake() => rb = GetComponent<Rigidbody2D>();
11
12    private void FixedUpdate()
13    {
14        float input = Input.GetAxisRaw("Horizontal");
15        float targetSpeed = input * maxSpeed;
16
17        float rate = Mathf.Abs(targetSpeed) > 0.01f ? acceleration : friction;
18        currentSpeed = Mathf.MoveTowards(currentSpeed, targetSpeed, rate * Time.fixedDeltaTime);
19
20        rb.velocity = new Vector2(currentSpeed, rb.velocity.y);
21    }
22}

Mathf.MoveTowards avanza la velocidad actual hacia el objetivo a una tasa fija por segundo, y esa tasa cambia según si hay input o no. Una fricción más alta que la aceleración da un personaje que frena en seco (típico de un shooter). Una fricción más baja da un personaje que resbala (típico de un niveles con suelo de hielo o de un vehículo). Ninguna de las dos es "la correcta": son decisiones de diseño, y por eso deben ser dos campos separados, no una constante compartida.

Un error habitual es usar la misma variable para aceleración y fricción "para simplificar". El resultado es un personaje que arranca igual de lento de lo que frena, lo cual casi nunca es lo que se quiere: normalmente se prefiere parar más rápido de lo que se arranca, porque el jugador espera control inmediato al soltar el input, no al pulsarlo.

3. Por qué un lerp ingenuo depende del framerate

Mathf.MoveTowards funciona porque avanza una cantidad fija por segundo. Pero es habitual ver esta otra versión, que parece equivalente y no lo es:

1// Versión con Lerp: parece razonable, no lo es
2currentSpeed = Mathf.Lerp(currentSpeed, targetSpeed, 0.1f);

El problema es que ese 0.1f es una fracción de la distancia restante por frame, no por segundo. A 30 fps, la velocidad se acerca al objetivo un 10% cada frame, 30 veces por segundo. A 60 fps, también un 10%, pero 60 veces por segundo. El resultado es que el mismo código produce una aceleración distinta según la máquina en la que corra, y un juego que se probó a 60 fps se sentirá notablemente más lento o más rápido en una pantalla de 144 Hz.

La corrección frame-independiente para un lerp exponencial usa el deltaTime como exponente, no como factor:

vt+Δt=vobjetivo+(vtvobjetivo)bΔtv_{t+\Delta t} = v_{objetivo} + (v_t - v_{objetivo}) \cdot b^{\Delta t}

Donde b es la fracción de distancia que sobrevive cada segundo (por ejemplo, 0.01 significa que al cabo de un segundo solo queda un 1% de la distancia original). Traducido a código:

1float ExponentialLerp(float current, float target, float decayPerSecond, float deltaTime)
2{
3    float t = Mathf.Pow(decayPerSecond, deltaTime);
4    return target + (current - target) * t;
5}

Con esta versión, decayPerSecond significa lo mismo a 30, 60 o 144 fps: la curva de aproximación es idéntica, solo cambia la resolución con la que se muestrea. Mathf.MoveTowards sigue siendo la opción más simple y más que suficiente para aceleración/fricción lineal, pero en cuanto se necesita una curva de suavizado (por ejemplo, para una cámara o para el dash de la siguiente sección), esta fórmula es la mejor opción a usar.

4. El dash: una curva de velocidad, no un teletransporte

El dash es el caso donde más se nota trabajar con curvas en vez de con números sueltos. La implementación ingenua es aplicar una velocidad fija durante N frames y luego cortarla:

1// Versión ingenua: velocidad constante, corte abrupto
2if (isDashing)
3{
4    rb.velocity = dashDirection * dashSpeed;
5    dashTimer -= Time.deltaTime;
6    if (dashTimer <= 0f) isDashing = false;
7}

Se siente mecánico porque la velocidad es un escalón: cero, luego dashSpeed de golpe, luego cero de golpe otra vez. Una curva de dash resuelve esto describiendo cómo evoluciona la velocidad a lo largo de la duración del dash, normalmente con un pico inicial fuerte y una cola que decae, en vez de un valor plano:

1[SerializeField] private AnimationCurve dashCurve = AnimationCurve.EaseInOut(0, 1, 1, 0);
2[SerializeField] private float dashSpeed = 20f;
3[SerializeField] private float dashDuration = 0.2f;
4
5private float dashElapsed;
6
7private void UpdateDash()
8{
9    float normalizedTime = dashElapsed / dashDuration;
10    float curveValue = dashCurve.Evaluate(normalizedTime);
11
12    rb.velocity = dashDirection * dashSpeed * curveValue;
13
14    dashElapsed += Time.deltaTime;
15    if (dashElapsed >= dashDuration)
16        EndDash();
17}

La AnimationCurve expuesta en el inspector es la pieza clave: permite a un diseñador dibujar la forma del dash a mano (arranque explosivo y cola suave, o rampa progresiva, o incluso un pequeño rebote al final) sin tocar código. Ese es el valor real de trabajar con curvas en vez de con constantes: mover el ajuste fino de la sensación fuera del código y ponerlo en manos de quien está jugando el juego una y otra vez.

Un truco habitual en juegos de acción es que el dash ignore la fricción normal mientras está activo (isDashing desactiva el MoveTowards de la sección 2) y que, al terminar, la velocidad final del dash se conserve como velocidad base en vez de cortarse a cero. Así el dash se siente como impulso ganado, no como un estado aparte que se activa y desactiva.

5. El salto: gravedad variable para controlar el peso

El salto tiene el mismo problema que el dash: una parábola generada con una única constante de gravedad se siente uniforme, y "uniforme" rara vez es "bien". Los platformers que se sienten ágiles (Celeste, Hollow Knight, Mario) casi siempre usan gravedad variable: una tasa distinta mientras el personaje sube y otra mientras cae.

1[SerializeField] private float baseGravity = 20f;
2[SerializeField] private float risingGravityMultiplier = 1f;
3[SerializeField] private float fallingGravityMultiplier = 1.8f;
4[SerializeField] private float lowJumpGravityMultiplier = 2.5f;
5
6private void ApplyGravity()
7{
8    float multiplier = risingGravityMultiplier;
9
10    if (rb.velocity.y < 0f)
11        multiplier = fallingGravityMultiplier;
12    else if (rb.velocity.y > 0f && !Input.GetButton("Jump"))
13        multiplier = lowJumpGravityMultiplier; // salto corto: soltó el botón pronto
14
15    rb.velocity += Vector2.up * baseGravity * multiplier * Time.deltaTime;
16}

Con fallingGravityMultiplier por encima de 1, la caída es más rápida que la subida, lo que da una sensación de peso y de respuesta inmediata al aterrizar, en vez de una parábola simétrica que se siente flotante. Con lowJumpGravityMultiplier, soltar el botón de salto antes de tiempo corta la subida con más fuerza, dando control fino sobre la altura del salto sin necesitar botones ni estados extra: es el mismo truco que usan Mario y Celeste para diferenciar un salto corto de uno mantenido.

Este control de altura variable se combina de forma natural con el coyote time y el jump buffering vistos en el artículo anterior: uno decide cuándo se puede saltar, este decide cómo se siente el salto una vez en el aire. Son capas independientes que se apilan sin pisarse.

6. Fricción en el suelo, control parcial en el aire

Un matiz que se pasa por alto con frecuencia: la fricción del apartado 2 no debería aplicarse igual en el suelo que en el aire. En el suelo, el jugador espera respuesta casi inmediata al soltar el input. En el aire, la física real (y la mayoría de juegos) reduce esa respuesta a propósito, porque un personaje en el aire ya comprometió una trayectoria y una corrección total en pleno salto rompe la sensación de estar sujeto a la gravedad.

1[SerializeField] private float airControlFactor = 0.5f; // 0 = sin control aéreo, 1 = igual que en suelo
2
3private void FixedUpdate()
4{
5    float input = Input.GetAxisRaw("Horizontal");
6    float targetSpeed = input * maxSpeed;
7
8    float rate = Mathf.Abs(targetSpeed) > 0.01f ? acceleration : friction;
9    if (!isGrounded)
10        rate *= airControlFactor;
11
12    currentSpeed = Mathf.MoveTowards(currentSpeed, targetSpeed, rate * Time.fixedDeltaTime);
13    rb.velocity = new Vector2(currentSpeed, rb.velocity.y);
14}

Un airControlFactor bajo (0.2 a 0.4) da saltos comprometidos, donde una vez en el aire la trayectoria queda casi fijada: encaja con juegos de plataformas más "duros" o realistas. Un valor alto (0.7 a 1) da control total en el aire, típico de juegos más arcade donde corregir a media caída es parte de la diversión. Ninguno es objetivamente mejor: es una decisión que cambia el género que percibe el jugador.

7. Exponer las curvas y afinar con datos, no con intuición

Todos los números de este artículo (aceleración, fricción, multiplicadores de gravedad, curva de dash, factor de control aéreo) deberían vivir como campos serializados o AnimationCurve en el inspector, nunca como constantes hardcodeadas en el código. La razón no es solo comodidad: es que el ajuste fino del game feel no se hace leyendo el código, se hace jugando repetidamente y moviendo un slider hasta encontrar los controles más cómodos y que se sientan "correctos".

El movimiento de un juego no se diseña una vez, se destila. Se juega, se mueve un número, se vuelve a jugar. Nadie acierta la curva de un dash a la primera.

Igual que se documentó para el coyote time y el jump buffering, el paso final siempre es el mismo: dedicar sesiones enteras solo a mover estos valores con testers que no conocen el código, porque el desarrollador que lleva semanas con el prototipo ya no puede sentir su propio movimiento con ojos nuevos. Una fricción que al programador le parece "instantánea" a menudo resulta lenta para alguien que prueba el juego por primera vez.

8. Conclusión

Ni la aceleración, ni la fricción, ni las curvas son trucos exóticos: son la diferencia entre asignar el input directamente a la física y modelar cómo el personaje llega a la velocidad que el jugador pide. El dash y el salto son solo los dos lugares donde esa diferencia se hace imposible de ignorar, porque son los movimientos más extremos y más visibles del set de moves de cualquier personaje.

La lección que se repite en cada sección es la misma: separar las tasas que deberían ser independientes (aceleración de fricción, subida de bajada, suelo de aire), sacarlas del código a curvas y valores serializados, y afinarlas jugando en vez de calculando. El resultado, cuando funciona, es invisible: nadie va a elogiar la curva de decaimiento del dash de tu juego. Solo van a decir que se siente bien, sin saber por qué.

Artículos Relacionados

Ver todos los artículos
Coyote time y buffering de inputs: el platformer que perdona

Coyote time y buffering de inputs: el platformer que perdona

Los mejores platformers se sienten justos porque hacen trampa a tu favor. Un análisis de coyote time, jump buffering y otros trucos de input que perdonan al jugador, con implementación en Unity/C#.

Pathfinding en Videojuegos: A* y sus Variantes

Pathfinding en Videojuegos: A* y sus Variantes

A* es el algoritmo de pathfinding más conocido, pero los juegos modernos van mucho más allá: NavMesh, JPS, HPA*. Este artículo explora por qué Dijkstra no escala, cómo funciona A* de verdad y cuándo usar cada variante.

Sensores y percepción: cómo ve y oye un NPC

Sensores y percepción: cómo ve y oye un NPC

Cómo dotar de sentidos a la IA de un videojuego: conos de visión, ráfagas de sonido y una memoria que recuerda lo que el NPC ha percibido.