Física de un juego de plataformas: gravedad, saltos y colisiones
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
- La entrada es el mapa (filas del mismo ancho con
.,#,C,Ey un soloP), una línea---y las acciones separadas por espacios en una o varias líneas:R(derecha),L(izquierda),J(salto), combinaciones comoRJ,.(nada) y repeticiones comoR*10. Errores:Mapa no válido: falta el separador ---,…: las filas no miden lo mismo,…: carácter desconocidoy…: tiene que haber un jugador (P)(se comprueban en ese orden). - El jugador empieza en
x = columna × 16 + 2ey = fila × 16 + 2(la casilla de P, apoyado en su borde inferior), con velocidad vertical 0 y fuera del suelo. - En cada fotograma, en este orden: (1) la velocidad horizontal es +2 con
R, −2 conL(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 hayJy 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. - 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).
- 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
| Constante | Valor |
|---|---|
| Tamaño de casilla | 16 px |
| Caja del jugador | 12 × 14 px |
| Velocidad horizontal | 2 px por fotograma |
| Impulso del salto | −9 px por fotograma |
| Gravedad | +1 px por fotograma² |
| Velocidad máxima de caída | 8 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.
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.
Ejemplo
.............C...... ..................E. ...............####. .....C...####....... P..####............. #################### --- R*20 RJ R*12 . . R*24 RJ R*14 R*34 RJ R*16 R*30
Fotograma 42: moneda (1/2) Fotograma 111: moneda (2/2) Fotograma 146: ¡meta! Nivel superado con 2/2 monedas
Solución explicada
Ver la solución completa
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>conrequestAnimationFramey controla el personaje con el teclado usando este mismo motor.