Cómo añadir sacudida de pantalla y balanceo de vista
Este tutorial está escrito para la versión 2 del motor.
Una cámara estática se siente sin vida. Camina por cualquier juego en primera persona y notarás dos cosas en las que apenas piensas: la vista se balancea suavemente con cada paso, y la pantalla se sacude cuando chocas con algo. Estos pequeños efectos convierten una demo técnica en algo que se siente físico.
Vamos a añadir ambos al raycaster del tutorial de raycasting. Desplazaremos el centro de renderizado con una onda sinusoidal para el balanceo de vista, y luego usaremos la función camera() del motor para la sacudida de pantalla en colisiones con paredes. Las técnicas funcionan en cualquier proyecto en primera persona, pero el raycaster las hace fáciles de ver.
El punto de partida
Este es el raycaster que vamos a modificar. Es el mismo renderizador DDA del tutorial de raycasting — un mapa de 8×8, paredes texturizadas con sombreado por distancia, un minimapa a la izquierda, y movimiento con flechas con detección de colisiones. Camina por ahí y siente lo rígida que es la cámara:
La línea clave para ambos efectos es la posición vertical de las franjas de pared. Ahora mismo cada columna está centrada en viewH / 2:
const stripeH = Math.floor(viewH / perpDist);
const drawStart = Math.max(0, Math.floor(viewH / 2 - stripeH / 2));
const drawEnd = Math.min(viewH, Math.floor(viewH / 2 + stripeH / 2));
El techo y el suelo se dividen en el mismo punto medio fijo:
rectfill(viewX, 0, viewX + viewW, viewH / 2, 1);
rectfill(viewX, viewH / 2, viewX + viewW, viewH, 5);
Ambos efectos funcionan moviendo ese punto medio. El balanceo de vista lo mueve suavemente con una onda sinusoidal. La sacudida de pantalla lo mueve aleatoriamente con decaimiento. Empecemos con el balanceo.
Balanceo de vista
El balanceo de vista se reduce a tres cosas: una fase que avanza mientras el jugador se mueve, una onda sinusoidal que convierte la fase en un desplazamiento vertical, y una forma de aplicar ese desplazamiento al renderizado.
Añade el estado de balanceo junto a las otras variables del jugador:
let bobPhase = 0;
const BOB_SPEED = 0.15;
const BOB_AMOUNT = 2;
let bobOffset = 0;
bobPhase es un ángulo en radianes que alimenta a Math.sin(). Avanza BOB_SPEED cada fotograma mientras el jugador se mueve. BOB_AMOUNT controla cuántos píxeles se desplaza la vista en el pico. Dos píxeles es lo suficientemente sutil para sentirse natural sin ser molesto.
Al final de update(), avanza la fase cuando hay movimiento y déjala decaer cuando se detiene:
const moving = btn('ArrowUp') || btn('ArrowDown');
if (moving) {
bobPhase += BOB_SPEED;
} else {
bobPhase *= 0.9;
}
bobOffset = Math.sin(bobPhase) * BOB_AMOUNT;
El decaimiento *= 0.9 importa. Sin él, detenerse a medio paso congelaría el balanceo en cualquier desplazamiento que tuviera, dejando la vista inclinada. El decaimiento la devuelve suavemente al centro.
Apliquemos bobOffset al renderizado. La división techo/suelo se desplaza con el balanceo:
const horizonY = Math.floor(viewH / 2 + bobOffset);
rectfill(viewX, 0, viewX + viewW, horizonY, 1);
rectfill(viewX, horizonY, viewX + viewW, viewH, 5);
Dentro del bucle de columnas, usa center en vez de viewH / 2 para la posición de la franja de pared:
const center = viewH / 2 + bobOffset;
const drawStart = Math.max(0, Math.floor(center - stripeH / 2));
const drawEnd = Math.min(viewH, Math.floor(center + stripeH / 2));
El cálculo de coordenadas de textura también necesita el centro desplazado para que la textura no se desgarre:
let texPos = (drawStart - center + stripeH / 2) * texStep;
¿Por qué no usar camera() para esto? Dos razones. Primero, camera() desplaza todo — incluyendo el minimapa, que no debería balancearse. Segundo, camera() redondea sus argumentos a enteros, así que la onda sinusoidal tartamudearía en los límites de píxel en vez de fluir suavemente. Desplazar el centro de renderizado directamente evita ambos problemas.
Sacudida de pantalla
La sacudida de pantalla es la filosofía opuesta al balanceo de vista. Donde el balanceo es suave y solo afecta la vista 3D, la sacudida debe ser brusca y sacudir todo el fotograma — minimapa, paredes, todo. Eso hace que camera() sea la herramienta correcta aquí.
Añade el estado de sacudida:
let shakeAmount = 0;
const SHAKE_DECAY = 0.85;
const SHAKE_STRENGTH = 3;
shakeAmount empieza en cero y se establece a SHAKE_STRENGTH cuando el jugador choca con una pared. Cada fotograma se multiplica por SHAKE_DECAY, así que decae rápidamente: 3 → 2.55 → 2.17 → 1.84 → ... → 0. El decaimiento de 0.85 da unas 10 fotogramas de sacudida visible antes de desvanecerse.
El raycaster ya tiene detección de colisiones que prueba el movimiento en X e Y por separado. Añade una bandera bumped para detectar cuando el jugador choca con una pared:
let bumped = false;
if (btn('ArrowUp')) {
const nx = px + Math.cos(pa) * moveSpeed;
const ny = py + Math.sin(pa) * moveSpeed;
if (map[Math.floor(py)][Math.floor(nx)] === 0) px = nx;
else bumped = true;
if (map[Math.floor(ny)][Math.floor(px)] === 0) py = ny;
else bumped = true;
}
if (bumped) shakeAmount = SHAKE_STRENGTH;
Apliquemos la sacudida al inicio de draw(). Reinicia la cámara, limpia la pantalla, y luego establece el nuevo desplazamiento:
creset();
cls(0);
if (shakeAmount > 0.5) {
camera(
(rnd(2) - 1) * shakeAmount,
(rnd(2) - 1) * shakeAmount,
);
shakeAmount *= SHAKE_DECAY;
}
El creset() antes de cls() es importante. El desplazamiento de cámara del fotograma anterior sigue activo cuando draw() comienza. Si limpias la pantalla mientras la cámara está desplazada, deja una franja de píxeles sin limpiar en el borde. Reiniciar primero asegura un lienzo limpio.
rnd(2) - 1 da un valor aleatorio entre -1 y 1, escalado por shakeAmount. Cada fotograma recibe un desplazamiento aleatorio diferente, creando el temblor. Una vez que shakeAmount cae por debajo de 0.5, la sacudida se detiene — a esa escala es menos de un píxel y no sería visible.
Si dibujas cualquier interfaz fija — puntuación, salud, instrucciones — llama a creset() antes de esos dibujos para que se mantengan en su lugar.
Necesitamos camera, creset y rnd del scope del motor — añádelos al destructure al inicio.
Para seguir explorando
- Balanceo horizontal — añade una segunda onda sinusoidal que desplace las columnas de pared a izquierda y derecha, desfasada del balanceo vertical por un cuarto de ciclo. Esto da un ritmo de caminata más pronunciado, como si los hombros del jugador se balancearan.
- Sacudida por daño — asocia una tecla para simular recibir daño con una sacudida más fuerte. Usa un
SHAKE_STRENGTHmayor y unSHAKE_DECAYmás lento para un golpe más largo y violento. - Sacudida direccional — en vez de desplazamientos aleatorios en X e Y, sacude a lo largo del eje de la pared golpeada. La variable
sideya indica qué cara golpeó el jugador — úsala para sacudir horizontalmente en colisiones con caras X y verticalmente con caras Y. - Acumulación de trauma — en vez de reiniciar
shakeAmounta un valor fijo en cada golpe, súmalo. Eleva al cuadrado el trauma acumulado para la magnitud final de sacudida. Los golpes rápidos se combinan en una sacudida mayor — este es el enfoque que popularizó Vlambeer. - Balanceo al correr — aumenta
BOB_SPEEDyBOB_AMOUNTcuando el jugador mantiene una tecla de correr. Un balanceo más rápido y amplio vende la diferencia entre caminar y correr. - Balanceo de arma — si añades un sprite de arma en la parte inferior de la pantalla, desplázalo en dirección opuesta al balanceo. Este efecto de contra-balanceo hace que el arma se sienta como si tuviera su propio peso.