Apuntes DAM
Volver al inicio

Física de un juego de plataformas: gravedad, saltos y colisiones

Ejercicio de JavaScriptMuy difícilUnos 100 minutos

Programa el motor de un juego de plataformas 2D por fotogramas: movimiento horizontal, salto solo desde el suelo, gravedad con velocidad máxima de caída, colisiones con un mapa de casillas resolviendo cada eje por separado, monedas, meta y caída al vacío. Bucle de juego y cajas AABB.

  • Bucle de juego por fotogramas
  • Gravedad y velocidad vertical
  • Colisiones AABB con casillas
  • Resolver cada eje por separado
  • Mapas de casillas (tilemaps)
  • Set para lo recogido

Enunciado

Los juegos de plataformas 2D (de Super Mario a Celeste) se mueven con unas pocas reglas que se aplican en cada fotograma: leer los controles, mover al personaje, aplicar la gravedad y corregir su posición si ha atravesado una pared o el suelo. Motores como Unity o Godot traen esto hecho, pero entenderlo es lo que permite ajustar el «tacto» del juego y depurar los fallos de colisiones.

El nivel es un mapa de casillas de 16×16 píxeles: # es suelo o pared, C una moneda, E la meta, P la posición inicial del jugador y . aire. El jugador es una caja de 12×14 píxeles (una AABB, caja alineada con los ejes) y todo se calcula con números enteros, como en las consolas antiguas: así el resultado es exacto y repetible.

El truco clásico de las colisiones es mover y corregir cada eje por separado: primero en horizontal (si choca con una pared, se pega a ella) y después en vertical (si cae sobre el suelo, se queda encima y puede volver a saltar). Hacerlo con los dos ejes a la vez provoca que el personaje se enganche en las esquinas.

Qué tiene que hacer el programa

  1. La entrada es el mapa (filas del mismo ancho con ., #, C, E y un solo P), una línea --- y las acciones separadas por espacios en una o varias líneas: R (derecha), L (izquierda), J (salto), combinaciones como RJ, . (nada) y repeticiones como R*10. Errores: Mapa no válido: falta el separador ---, …: las filas no miden lo mismo, …: carácter desconocido y …: tiene que haber un jugador (P) (se comprueban en ese orden).
  2. El jugador empieza en x = columna × 16 + 2 e y = fila × 16 + 2 (la casilla de P, apoyado en su borde inferior), con velocidad vertical 0 y fuera del suelo.
  3. En cada fotograma, en este orden: (1) la velocidad horizontal es +2 con R, −2 con L (0 con las dos o ninguna); se suma a x, se limita al mapa (0 a ancho×16−12) y, si la caja toca alguna casilla #, se pega a ella (a su izquierda si iba hacia la derecha, a su derecha si no); (2) si hay J y está en el suelo, la velocidad vertical pasa a −9; después se le suma la gravedad (1) con un máximo de 8, se suma a y, y si toca un #: si bajaba, se coloca encima (y queda en el suelo); si subía, se coloca debajo; en los dos casos la velocidad vertical pasa a 0; (3) las monedas que toque la caja se recogen (una vez cada una), la meta termina el nivel y, si y llega a filas×16, el jugador ha caído.
  4. Una casilla «toca» la caja si se solapan: la caja ocupa de x a x+11 y de y a y+13. Fuera del mapa no hay suelo (se puede saltar por encima de la fila superior y caer por debajo de la inferior).
  5. Mensajes: Fotograma N: moneda (k/total) al recoger cada moneda; Fotograma N: ¡meta! Nivel superado con k/total monedas (y termina); Fotograma N: el jugador ha caído al vacío con k/total monedas (y termina). Si se acaban las acciones: Fin de las acciones (N fotogramas): posición (x, y), en el suelo|en el aire, k/total monedas.

Entrada

El mapa, una línea --- y las acciones (R, L, J, combinaciones, . y repeticiones con *).

Datos de referencia

Constantes del juego
ConstanteValor
Tamaño de casilla16 px
Caja del jugador12 × 14 px
Velocidad horizontal2 px por fotograma
Impulso del salto−9 px por fotograma
Gravedad+1 px por fotograma²
Velocidad máxima de caída8 px por fotograma

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Recorrer el nivel saltando

Entrada

.............C......
..................E.
...............####.
.....C...####.......
P..####.............
####################
---
R*20 RJ R*12 . . R*24 RJ R*14 R*34 RJ R*16 R*30

Salida por consola

Fotograma 42: moneda (1/2)
Fotograma 111: moneda (2/2)
Fotograma 146: ¡meta! Nivel superado con 2/2 monedas

Mapas que no valen

Entrada

.............C......
..................E.
...............####.
.....C...####.......
P..####.............
####################
---
R*20 RJ R*12 . . R*24 RJ R*14 R*28 RJ R*16 R*30

Salida por consola

Fotograma 42: moneda (1/2)
Fotograma 106: moneda (2/2)
Fin de las acciones (149 fotogramas): posición (284, 66), en el suelo, 2/2 monedas

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Las casillas que toca una caja

Divide entre 16 los bordes de la caja: las columnas van de Math.floor(x / 16) a Math.floor((x + 11) / 16) y las filas igual con y y 13. Una función que devuelva esas casillas se usa en las tres fases del fotograma.

javascript
for (let f = Math.floor(y / T); f <= Math.floor((y + ALTO - 1) / T); f++)
  for (let c = Math.floor(x / T); c <= Math.floor((x + ANCHO - 1) / T); c++)
    r.push([f, c]);
2. Un eje y luego el otro

Mueve en x y corrige; después mueve en y y corrige. Al corregir en x, la pared con la que chocas está a tu derecha si vx > 0 (te pegas a su borde izquierdo: c * 16 - 12) o a tu izquierda si no ((c + 1) * 16). Ojo: en cuanto corriges la posición, sal del bucle (break). Si sigues mirando casillas con la velocidad ya puesta a 0, la siguiente se resolverá en el sentido contrario y el personaje saltará de golpe al otro lado del suelo.

3. La gravedad

La velocidad vertical aumenta 1 en cada fotograma hasta 8 y se suma a y. Saltar es poner la velocidad a −9 cuando estás en el suelo: sube cada vez más despacio, se para y empieza a caer, una parábola sin ninguna fórmula.

4. Saber si está en el suelo

enSuelo se pone a false antes del movimiento vertical y a true solo cuando, bajando, choca con un #. Es lo que impide saltar en el aire (el doble salto sería otra regla).

5. Monedas una sola vez

Guarda las monedas recogidas en un Set con la clave "fila,columna": así una moneda que sigues tocando en el fotograma siguiente no se cuenta dos veces, y no hace falta modificar el mapa.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 5 casos ocultos que buscan los errores típicos.

🟨JavaScriptFísica de un juego de plataformas: gravedad, saltos y colisionesMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
.............C......
..................E.
...............####.
.....C...####.......
P..####.............
####################
---
R*20 RJ R*12 . . R*24 RJ R*14 R*34 RJ R*16 R*30
Salida esperada
Fotograma 42: moneda (1/2)
Fotograma 111: moneda (2/2)
Fotograma 146: ¡meta! Nivel superado con 2/2 monedas
⏳
Test oculto #2
⏳
Test oculto #4
⏳
Test oculto #5
⏳
Test oculto #6
⏳
Test oculto #7
0/7 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
javascript
1const lineas = require("fs").readFileSync(0, "utf8").split("\n");
2
3const T = 16;                  // tamaño de casilla en píxeles
4const ANCHO = 12, ALTO = 14;   // caja del jugador
5const VEL = 2, SALTO = -9, GRAVEDAD = 1, CAIDA_MAX = 8;
6
7function main() {
8  const corte = lineas.findIndex((l) => l.trim() === "---");
9  const mapa = (corte < 0 ? lineas : lineas.slice(0, corte)).map((l) => l.trimEnd()).filter((l) => l.length);
10  if (corte < 0 || !mapa.length) return console.log("Mapa no válido: falta el separador ---");
11  if (mapa.some((l) => l.length !== mapa[0].length)) return console.log("Mapa no válido: las filas no miden lo mismo");
12  if (mapa.some((l) => /[^.#CPE]/.test(l))) return console.log("Mapa no válido: carácter desconocido");
13  if (mapa.join("").split("P").length !== 2) return console.log("Mapa no válido: tiene que haber un jugador (P)");
14
15  const filas = mapa.length, columnas = mapa[0].length;
16  const solida = (f, c) => f >= 0 && f < filas && c >= 0 && c < columnas && mapa[f][c] === "#";
17  // Las casillas que toca una caja: de la primera a la última fila y columna que ocupa
18  const casillas = (x, y) => {
19    const r = [];
20    for (let f = Math.floor(y / T); f <= Math.floor((y + ALTO - 1) / T); f++)
21      for (let c = Math.floor(x / T); c <= Math.floor((x + ANCHO - 1) / T); c++) r.push([f, c]);
22    return r;
23  };
24
25  const inicio = mapa.findIndex((l) => l.includes("P"));
26  let x = mapa[inicio].indexOf("P") * T + 2, y = inicio * T + (T - ALTO);
27  let vy = 0, enSuelo = false;
28  const monedas = new Set();
29  const total = mapa.join("").split("C").length - 1;
30
31  // Acciones: R, L, J, RJ, LJ o «.», y «R*5» repite cinco fotogramas
32  const acciones = [];
33  for (const token of lineas.slice(corte + 1).join(" ").split(/\s+/).filter(Boolean)) {
34    const [accion, veces] = token.split("*");
35    for (let i = 0; i < (veces ? Number(veces) : 1); i++) acciones.push(accion.toUpperCase());
36  }
37
38  for (let n = 1; n <= acciones.length; n++) {
39    const a = acciones[n - 1];
40    // 1. Horizontal: mover y, si choca, pegarse a la pared
41    const vx = (a.includes("R") ? VEL : 0) - (a.includes("L") ? VEL : 0);
42    x = Math.max(0, Math.min(columnas * T - ANCHO, x + vx));
43    for (const [f, c] of casillas(x, y)) {
44      if (!solida(f, c)) continue;
45      x = vx > 0 ? c * T - ANCHO : (c + 1) * T;
46      break;                       // corregida la posición, ya no toca ninguna otra casilla de esa columna
47    }
48    // 2. Vertical: salto solo desde el suelo, gravedad con velocidad máxima de caída
49    if (a.includes("J") && enSuelo) vy = SALTO;
50    vy = Math.min(vy + GRAVEDAD, CAIDA_MAX);
51    y += vy;
52    enSuelo = false;
53    // La dirección se decide antes de corregir: después vy vale 0 y ya no se sabría si bajaba
54    const bajando = vy > 0;
55    for (const [f, c] of casillas(x, y)) {
56      if (!solida(f, c)) continue;
57      if (bajando) { y = f * T - ALTO; enSuelo = true; } else { y = (f + 1) * T; }
58      vy = 0;
59      break;
60    }
61    // 3. Monedas, meta y caída
62    for (const [f, c] of casillas(x, y)) {
63      if (f < 0 || f >= filas || c < 0 || c >= columnas) continue;
64      if (mapa[f][c] === "C" && !monedas.has(`${f},${c}`)) {
65        monedas.add(`${f},${c}`);
66        console.log(`Fotograma ${n}: moneda (${monedas.size}/${total})`);
67      }
68      if (mapa[f][c] === "E") return console.log(`Fotograma ${n}: ¡meta! Nivel superado con ${monedas.size}/${total} monedas`);
69    }
70    if (y >= filas * T) return console.log(`Fotograma ${n}: el jugador ha caído al vacío con ${monedas.size}/${total} monedas`);
71  }
72  console.log(`Fin de las acciones (${acciones.length} fotogramas): posición (${x}, ${y}), ${enSuelo ? "en el suelo" : "en el aire"}, ${monedas.size}/${total} monedas`);
73}
74
75main();

El programa es un bucle de juego en miniatura: cada vuelta es un fotograma que lee la acción, actualiza la física y comprueba los eventos. Todos los motores de juegos tienen este bucle, aunque lo oculten detrás de métodos como Update() o _physics_process().

Resolver las colisiones eje por eje es lo que hace que el personaje se deslice por las paredes y aterrice limpio en el suelo. Si se mueve en diagonal y se corrige todo a la vez, no se sabe si ha chocado con la pared o con el suelo, y el personaje se queda enganchado en las esquinas.

La física con enteros y un paso fijo por fotograma hace el juego determinista: las mismas acciones producen siempre el mismo resultado. Es lo que permite grabar repeticiones guardando solo las teclas, y lo que hace posibles estas pruebas automáticas.

Las constantes (velocidad, salto, gravedad, caída máxima) definen el «tacto» del juego. Cambiar el salto de −9 a −10 hace que llegue a plataformas que antes no alcanzaba: en un juego real se ajustan probándolo muchas veces.

Para ir más allá

  • Añade el «coyote time»: permite saltar durante 4 fotogramas después de salir de una plataforma.
  • Añade enemigos que caminan de un lado a otro y te devuelven al inicio si te tocan (salvo que les caigas encima).
  • Dibuja el nivel en un <canvas> con requestAnimationFrame y controla el personaje con el teclado usando este mismo motor.

Dónde se explica