Cómo Construir Sistemas de Cámara
Este tutorial fue escrito en febrero de 2026, para la v2 del motor.
La mayoría de los juegos tienen mundos más grandes que la pantalla. Un nivel de plataformas se extiende por cientos de píxeles. Un RPG top-down tiene pueblos y mazmorras. Un survivor-like tiene una arena que el jugador recorre libremente. En todos ellos, la cámara necesita seguir al jugador — y hacerlo lo suficientemente bien como para que el jugador nunca piense en ello.
Vamos a construir tres sistemas de cámara desde cero: una cámara de seguimiento básica, una cámara suave con lerp, y vibración de pantalla para feedback de impacto. Sin sprites, sin enemigos, sin mecánicas de juego — solo un círculo jugador moviéndose por una arena llena de puntos de referencia coloridos. Al final tendrás patrones que puedes usar en cualquier juego con un mundo más grande que la pantalla.
Siguiendo al Jugador
La función camera(x, y) del motor desplaza cada llamada de dibujo por ese offset. Llamar a camera(10, 20) hace que todo se dibuje 10 píxeles a la izquierda y 20 hacia arriba — como si la vista se moviera a la derecha y abajo. Para centrar al jugador en un canvas de 128×128, configuramos la cámara a (playerX - 64, playerY - 64).
Aquí está la configuración. Tenemos un mundo de 384×384 (tres veces el canvas en cada dirección) con un círculo jugador y algunos puntos de referencia coloridos dispersos:
const WORLD = 384;
const CANVAS = 128;
const SPEED = 1.5;
let px = 192;
let py = 192;
const landmarks = [
{ x: 40, y: 40, w: 24, h: 24, color: 8 },
{ x: 300, y: 50, w: 16, h: 32, color: 11 },
{ x: 60, y: 280, w: 20, h: 20, color: 12 },
{ x: 320, y: 320, w: 28, h: 16, color: 9 },
{ x: 180, y: 80, w: 12, h: 12, color: 14 },
{ x: 100, y: 340, w: 18, h: 18, color: 10 },
{ x: 260, y: 200, w: 22, h: 22, color: 13 },
{ x: 350, y: 160, w: 14, h: 30, color: 15 },
{ x: 140, y: 160, w: 20, h: 20, color: 3 },
{ x: 220, y: 300, w: 16, h: 16, color: 5 },
];
El jugador comienza en el centro del mundo. Los puntos de referencia son simplemente rectángulos de colores en posiciones fijas — no hacen nada excepto hacer visible el desplazamiento.
La función de actualización mueve al jugador con las teclas de flecha y configura la cámara:
function update() {
if (btn('ArrowLeft')) px -= SPEED;
if (btn('ArrowRight')) px += SPEED;
if (btn('ArrowUp')) py -= SPEED;
if (btn('ArrowDown')) py += SPEED;
camera(px - 64, py - 64);
}
Esa es toda la cámara de seguimiento. px - 64 centra al jugador horizontalmente, py - 64 lo centra verticalmente. Cada frame, la cámara se posiciona exactamente donde está el jugador.
Hay un detalle con el HUD. Si dibujamos texto mientras la cámara está desplazada, se va fuera de la pantalla con el mundo. creset() reinicia la cámara a (0, 0), así que cualquier cosa dibujada después queda fija en la pantalla:
function draw() {
cls(1);
rect(0, 0, WORLD - 1, WORLD - 1, 6);
for (const lm of landmarks) {
rectfill(lm.x, lm.y, lm.x + lm.w, lm.y + lm.h, lm.color);
}
circfill(px, py, 3, 7);
creset();
caption('x:' + Math.floor(px) + ' y:' + Math.floor(py), 1, 1, 7);
}
Muévete y observa cómo los puntos de referencia pasan. La cámara se pega al jugador perfectamente — quizás demasiado perfectamente. Camina hacia un borde y verás el vacío más allá del límite del mundo. Vamos a arreglar eso.
Manteniendo la Cámara Dentro de los Límites
Cuando el jugador camina cerca del borde del mundo, la cámara lo sigue más allá y muestra espacio vacío. Dos cosas necesitan limitarse: la posición de la cámara y la posición del jugador.
Para la cámara, el rango válido es [0, tamañoMundo - tamañoCanvas]. En nuestro caso eso es [0, 256] — si la X de la cámara baja de 0 vemos vacío a la izquierda, si sube de 256 vemos vacío a la derecha. Math.max y Math.min se encargan de esto:
let camX = px - 64;
let camY = py - 64;
camX = Math.max(0, Math.min(WORLD - CANVAS, camX));
camY = Math.max(0, Math.min(WORLD - CANVAS, camY));
camera(camX, camY);
Math.max(0, Math.min(256, camX)) se lee como "no bajar de 0, no pasar de 256." El Math.min interior limita el extremo superior, el Math.max exterior limita el inferior.
También limitamos al jugador a los bordes del mundo menos su radio, para que no pueda atravesar el borde:
px = Math.max(3, Math.min(WORLD - 4, px));
py = Math.max(3, Math.min(WORLD - 4, py));
El jugador tiene un radio de 3, así que lo mantenemos al menos a 3 píxeles del borde izquierdo/superior y a 4 del derecho/inferior (radio + 1 para quedar dentro de la línea del límite).
Eso es todo — dos operaciones de clamp. No más vacío, no más escapar del mundo. No incluimos un playground para esto ya que es un cambio de dos líneas, pero lo verás en acción en la siguiente sección.
Movimiento Suave de Cámara
La cámara de seguimiento funciona, pero se siente rígida. Se posiciona exactamente en la posición del jugador cada frame sin ningún retraso — técnicamente correcto, pero no hay sensación de peso o movimiento. Una técnica llamada interpolación lineal (lerp) soluciona esto haciendo que la cámara se deslice hacia el jugador en vez de teletransportarse.
La idea: en vez de configurar la cámara directamente al objetivo, la movemos una fracción de la distancia restante cada frame.
const LERP = 0.08;
let camX = px - 64;
let camY = py - 64;
La posición de la cámara ahora es estado persistente — se mantiene entre frames en vez de recalcularse desde cero. Cada frame, cierra el 8% de la distancia restante:
let targetX = px - 64;
let targetY = py - 64;
camX += (targetX - camX) * LERP;
camY += (targetY - camY) * LERP;
(targetX - camX) es la distancia que falta por recorrer. Multiplicar por 0.08 nos da un pequeño paso hacia el objetivo. Cuando el jugador está lejos, el paso es grande. Conforme la cámara se acerca, el paso se reduce. Eso es lo que crea la desaceleración suave que se siente en los juegos pulidos.
El factor de lerp controla la sensación. 0.08 da un flotamiento notable — puedes ver el retraso de la cámara cuando cambias de dirección. 0.2 se siente mucho más ágil, casi como la cámara de seguimiento directa. 0.03 se siente cinematográfico y lento, como una toma de dron. Prueba diferentes valores y ve cuál encaja en tu juego.
Después del lerp, seguimos limitando a los bordes del mundo:
camX = Math.max(0, Math.min(WORLD - CANVAS, camX));
camY = Math.max(0, Math.min(WORLD - CANVAS, camY));
camera(camX, camY);
El orden importa. Primero lerp, luego clamp. Si limitamos primero y hacemos lerp después, la cámara podría sobrepasar los límites antes de volver — tembloroso y feo.
Cambia de dirección rápidamente y observa cómo la cámara se queda atrás, luego alcanza. Eso es el lerp haciendo lo suyo. Compara esto con el primer playground — misma arena, misma velocidad de movimiento, sensación completamente diferente.
Vibración de Pantalla
La vibración de pantalla es la forma más simple de agregar impacto a un evento. Una explosión, un golpe, un power-up — cualquier cosa que deba sentirse contundente lleva una vibración. La implementación son tres líneas de matemáticas: establecer una amplitud, decaerla cada frame, y aplicar un offset aleatorio a la cámara.
Empezamos con una variable de cantidad de vibración:
const SHAKE_DECAY = 0.9;
const SHAKE_STRENGTH = 4;
let shakeAmount = 0;
Cuando algo dispara una vibración, establecemos shakeAmount en SHAKE_STRENGTH. Cada frame, multiplicamos por SHAKE_DECAY para atenuarla:
let shakeX = 0;
let shakeY = 0;
if (shakeAmount > 0.5) {
shakeX = (rnd(2) - 1) * shakeAmount;
shakeY = (rnd(2) - 1) * shakeAmount;
shakeAmount *= SHAKE_DECAY;
} else {
shakeAmount = 0;
}
camera(camX + shakeX, camY + shakeY);
rnd(2) - 1 da un valor aleatorio entre -1 y 1. Multiplicar por shakeAmount nos da un offset que empieza fuerte y se atenúa. El umbral de 0.5 lleva la vibración a cero cuando es demasiado pequeña para verse — sin él, la pantalla vibraría a niveles sub-píxel indefinidamente.
El factor de decaimiento de 0.9 significa que la vibración pierde el 10% de su amplitud cada frame. A 60 FPS, una fuerza de 4 tarda unos 20 frames (un tercio de segundo) en decaer completamente. Un decaimiento mayor (0.95) produce vibraciones más largas. Uno menor (0.8) produce vibraciones más breves.
La idea clave es dónde se aplica la vibración: después del lerp suave y el clamp de límites. El flujo es: calcular objetivo → lerp hacia él → limitar a bordes → agregar offset de vibración. Si aplicáramos la vibración antes del clamp, el clamp se comería la vibración en los bordes del mundo. Si la aplicáramos antes del lerp, el lerp la suavizaría y mataría la sacudida brusca que queremos.
Ejemplo Completo
Aquí está todo conectado. La arena ahora tiene objetos coleccionables dispersos — pequeños círculos de colores que el jugador puede recoger. Caminar sobre uno dispara una vibración de pantalla y lo elimina. También puedes hacer clic en cualquier lugar para una vibración manual y probar la sensación.
engine.scope(({ start, cls, circfill, rectfill, rect, btn, click, camera, creset,
caption, randomIntegerBetween, rnd }) => {
const WORLD = 384;
const CANVAS = 128;
const SPEED = 1.5;
const LERP = 0.08;
const SHAKE_DECAY = 0.9;
const SHAKE_STRENGTH = 4;
let px = 192;
let py = 192;
let camX = px - 64;
let camY = py - 64;
let shakeAmount = 0;
let score = 0;
const collectColors = [8, 9, 10, 11, 12, 14];
let collectibles = [];
for (let i = 0; i < 15; i++) {
collectibles.push({
x: randomIntegerBetween(20, WORLD - 20),
y: randomIntegerBetween(20, WORLD - 20),
color: collectColors[i % collectColors.length],
alive: true,
});
}
const totalCollectibles = collectibles.length;
const landmarks = [
{ x: 40, y: 40, w: 24, h: 24, color: 5 },
{ x: 300, y: 50, w: 16, h: 32, color: 5 },
{ x: 60, y: 280, w: 20, h: 20, color: 5 },
{ x: 320, y: 320, w: 28, h: 16, color: 5 },
{ x: 180, y: 80, w: 12, h: 12, color: 5 },
{ x: 260, y: 200, w: 22, h: 22, color: 5 },
];
function update() {
if (btn('ArrowLeft')) px -= SPEED;
if (btn('ArrowRight')) px += SPEED;
if (btn('ArrowUp')) py -= SPEED;
if (btn('ArrowDown')) py += SPEED;
px = Math.max(3, Math.min(WORLD - 4, px));
py = Math.max(3, Math.min(WORLD - 4, py));
for (const c of collectibles) {
if (!c.alive) continue;
let dx = px - c.x;
let dy = py - c.y;
if (dx * dx + dy * dy < 64) {
c.alive = false;
score++;
shakeAmount = SHAKE_STRENGTH;
}
}
if (click()) {
shakeAmount = SHAKE_STRENGTH;
}
let targetX = px - 64;
let targetY = py - 64;
camX += (targetX - camX) * LERP;
camY += (targetY - camY) * LERP;
camX = Math.max(0, Math.min(WORLD - CANVAS, camX));
camY = Math.max(0, Math.min(WORLD - CANVAS, camY));
let shakeX = 0;
let shakeY = 0;
if (shakeAmount > 0.5) {
shakeX = (rnd(2) - 1) * shakeAmount;
shakeY = (rnd(2) - 1) * shakeAmount;
shakeAmount *= SHAKE_DECAY;
} else {
shakeAmount = 0;
}
camera(camX + shakeX, camY + shakeY);
}
function draw() {
cls(1);
rect(0, 0, WORLD - 1, WORLD - 1, 6);
for (const lm of landmarks) {
rectfill(lm.x, lm.y, lm.x + lm.w, lm.y + lm.h, lm.color);
}
for (const c of collectibles) {
if (!c.alive) continue;
circfill(c.x, c.y, 3, c.color);
}
circfill(px, py, 3, 7);
creset();
caption(score + '/' + totalCollectibles, 1, 1, 7);
}
start({ sprites: {}, sounds: {}, update, draw });
});
La verificación de colisión usa distancia al cuadrado — dx * dx + dy * dy < 64 es lo mismo que "distancia menor a 8 píxeles." Elevar al cuadrado evita una llamada a Math.sqrt cada frame por cada coleccionable. Los puntos de referencia son tenues (color 5, gris oscuro) para que los coleccionables coloridos resalten sobre ellos.
Observa cómo todos los sistemas se apilan limpiamente. El movimiento alimenta una posición objetivo. El lerp suaviza la cámara hacia ella. El clamp la mantiene dentro de los límites. La vibración agrega un offset temporal encima. Cada paso toma la salida del anterior, y nunca interfieren entre sí.
Para Seguir Explorando
- Scrolling parallax — dibuja capas de fondo a diferentes velocidades de desplazamiento para dar profundidad. Una capa de montañas lejanas al 0.3× del offset de la cámara, un plano medio al 0.6×, y el primer plano al 1×. Multiplicación simple, gran impacto visual.
- Zonas de cámara — define regiones rectangulares que sobreescriben el objetivo de la cámara. Entra a la sala del jefe y la cámara se fija ahí. Entra a un trigger de cinemática y la cámara hace un paneo a un punto específico. Cambia el objetivo del lerp cuando el jugador entra en una zona.
- Zona muerta de cámara — solo mueve la cámara cuando el jugador excede una distancia umbral del centro de la pantalla. Esto le da al jugador espacio para moverse sin que la cámara reaccione, lo cual se siente genial en plataformeros.
- Sesgo direccional — desplaza la cámara en la dirección en que se mueve el jugador para que pueda ver más de lo que viene. Agrega una fracción de la velocidad del jugador a la posición objetivo.
- Zoom — escala el mundo renderizando una región más pequeña o más grande al mismo canvas. Duplica las matemáticas del offset de cámara y dibuja todo al 2× para un aspecto con zoom.
- Vibración basada en trauma — en vez de una sola cantidad de vibración, acumula "trauma" de múltiples fuentes y elévalo al cuadrado para la magnitud de vibración. Dos golpes pequeños se sienten más grandes que uno mediano. Este es el sistema que usan los juegos de Vlambeer.
Estos sistemas de cámara son la base para cualquier juego con un mundo con desplazamiento. El tutorial de object pooling maneja el reciclaje de entidades, aparición y oleadas maneja el flujo de enemigos — combina los tres y tienes la estructura de un juego tipo survivor.