Cómo Agregar Pósters Decorativos y Objetos
El tutorial de raycasting nos dio paredes con texturas, y desde entonces hemos agregado niebla, monstruos y escenas con múltiples niveles. Pero los pasillos siguen viéndose vacíos. Todas las paredes se ven iguales, y no hay nada en el suelo que rompa el espacio vacío.
Vamos a arreglar eso con dos cosas: calcomanías de pared y objetos independientes. Las calcomanías son texturas pintadas sobre segmentos específicos de las paredes — pósters, letreros, grafiti — que agregan variedad visual sin tocar el diseño del mapa. Los objetos son sprites con billboard colocados libremente en el mundo — barriles, cajas, columnas — con colisión para que el jugador no pueda atravesarlos.
Estamos construyendo sobre el raycaster base aquí. Sin niebla, sin monstruos — solo paredes, texturas y movimiento — para que lo nuevo sea fácil de ver.
El Punto de Partida
Este es el raycaster del que partimos — un mapa de 10x10 con algunas paredes interiores, renderizado con texturas y sombreado por distancia, un minimapa y movimiento con las flechas. Si ya pasaste por el tutorial de raycasting, todo esto debería resultarte familiar.
Este es el mapa:
const Map = [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 1, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 1, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 0, 0, 0, 0, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
];
Algunos pasillos y habitaciones para recorrer, pero todas las paredes se ven iguales y no hay nada en el suelo. Camina por ahí — es funcional, pero sin vida.
const WallTexture = [
4, 4, 4, 5, 4, 4, 4, 4,
4, 4, 4, 5, 4, 4, 4, 4,
5, 5, 5, 5, 5, 5, 5, 5,
4, 4, 4, 4, 4, 5, 4, 4,
4, 4, 4, 4, 4, 5, 4, 4,
5, 5, 5, 5, 5, 5, 5, 5,
4, 4, 4, 5, 4, 4, 4, 4,
4, 4, 4, 5, 4, 4, 4, 4,
];
const Constants = {
MapSize: 10,
Fov: 0.66,
MoveSpeed: 0.05,
RotSpeed: 0.05,
Cell: 3,
MmX: 97,
MmY: 97,
SpawnX: 1.5,
SpawnY: 1.5,
};
El bucle de renderizado lanza un rayo por cada columna de la pantalla, avanza por el mapa con DDA hasta que golpea una pared y dibuja una franja vertical con textura. Las paredes en el eje X reciben un oscurecimiento extra para que puedas distinguir las esquinas. El código completo de raycasting está cubierto en el tutorial original — no lo vamos a repasar aquí.
Calcomanías de Pared
Una calcomanía es una textura superpuesta en un segmento específico de pared. El mapa no cambia — solo marcamos ciertas caras de las paredes como con detalle extra, y reemplazamos los píxeles de la calcomanía durante el renderizado donde no son transparentes.
Primero, la textura de la calcomanía. Piensa en ella como un pequeño letrero clavado en la pared — un póster de 8x8 con un borde y un centro de color. Los píxeles transparentes (-1) dejan que la textura de la pared se vea por los bordes:
const PosterTexture = [
-1, -1, -1, -1, -1, -1, -1, -1,
-1, 8, 8, 8, 8, 8, 8, -1,
-1, 8, 7, 7, 7, 7, 8, -1,
-1, 8, 7, 10, 10, 7, 8, -1,
-1, 8, 7, 10, 10, 7, 8, -1,
-1, 8, 7, 7, 7, 7, 8, -1,
-1, 8, 8, 8, 8, 8, 8, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
];
Ahora necesitamos una forma de decir "este segmento de pared tiene una calcomanía." Cada entrada en el array Decals identifica una pared por su posición en la cuadrícula del mapa (mx, my) y qué cara fue golpeada (side — 0 para caras en el eje X, 1 para caras en el eje Y). Estos son los mismos valores que el bucle DDA ya nos da cuando encuentra una pared:
const Decals = [
{ mx: 4, my: 1, side: 1 },
{ mx: 1, my: 3, side: 0 },
{ mx: 6, my: 6, side: 1 },
];
Un helper verifica si un impacto de pared coincide con alguna calcomanía:
function hasDecal(mx, my, side) {
return Decals.some(
(d) => d.mx === mx && d.my === my && d.side === side,
);
}
El cambio en el renderizado es pequeño. Después de que el bucle DDA encuentra una pared, llamamos a hasDecal(mx, my, side). Si coincide, muestreamos la textura de la calcomanía en las mismas coordenadas de texel que usaríamos para la pared. Los píxeles no transparentes de la calcomanía reemplazan el color de la pared; los transparentes se dejan pasar:
const decal = hasDecal(mx, my, side);
for (let sy = drawStart; sy < drawEnd; sy++) {
const ty = Math.min(7, Math.floor(texPos));
texPos += texStep;
let color = sprites.wall[ty * 8 + tx];
if (color < 0) continue;
if (decal) {
const dc = sprites.poster[ty * 8 + tx];
if (dc >= 0) color = dc;
}
if (perpDist >= 6) {
color = Darken[Darken[color]];
} else if (perpDist >= 3) {
color = Darken[color];
}
if (side === 0) {
color = Darken[color];
}
pset(x, sy, color);
}
La verificación de la calcomanía ocurre antes del sombreado por distancia, así que los pósters se oscurecen naturalmente con la distancia — igual que las paredes donde están. Camina por ahí y verás tres pósters en diferentes paredes.
Objetos y Colisión
Las calcomanías decoran las paredes, pero los pisos siguen vacíos. Los objetos arreglan eso — son sprites colocados en coordenadas del mundo, renderizados como billboards que siempre miran a la cámara. Si pasaste por el tutorial de aparición de monstruos, el enfoque de billboard te resultará familiar. La diferencia aquí es que los objetos no se mueven, y estamos agregando colisión para que el jugador no pueda atravesarlos.
Empecemos con el sprite del objeto y los datos de ubicación. El sprite es un barril de 8x8, y cada objeto tiene una posición x e y en el espacio del mundo:
const PropSprite = [
-1, -1, 4, 4, 4, 4, -1, -1,
-1, 4, 9, 9, 9, 9, 4, -1,
4, 9, 9, 15, 15, 9, 9, 4,
4, 9, 15, 15, 15, 15, 9, 4,
4, 9, 15, 15, 15, 15, 9, 4,
4, 9, 9, 15, 15, 9, 9, 4,
-1, 4, 9, 9, 9, 9, 4, -1,
-1, -1, 4, 4, 4, 4, -1, -1,
];
const Props = [
{ x: 3.5, y: 1.5 },
{ x: 7.5, y: 3.5 },
{ x: 2.5, y: 5.5 },
{ x: 5.5, y: 7.5 },
];
Renderizado con un z-buffer
Para dibujar objetos correctamente, necesitamos saber qué columnas de la pantalla ya están ocupadas por paredes más cercanas. Durante el renderizado de paredes, almacenamos la distancia perpendicular de cada columna en un array zBuffer:
const zBuffer = new Array(128);
// dentro del bucle de renderizado de paredes, después de calcular perpDist:
zBuffer[x] = perpDist;
Después de dibujar todas las paredes, ordenamos los objetos por distancia al jugador (los más lejanos primero) y proyectamos cada uno al espacio de pantalla. La proyección usa el determinante inverso de la cámara para transformar desplazamientos del espacio del mundo en coordenadas de pantalla — la misma matemática que usamos para los billboards de monstruos:
const sorted = Props.map((p, i) => ({
i,
dist: (p.x - px) * (p.x - px) + (p.y - py) * (p.y - py),
})).sort((a, b) => b.dist - a.dist);
const invDet = 1 / (planeX * dirY - dirX * planeY);
for (const { i } of sorted) {
const p = Props[i];
const sx = p.x - px;
const sy = p.y - py;
const transformX = invDet * (dirY * sx - dirX * sy);
const transformY = invDet * (-planeY * sx + planeX * sy);
if (transformY <= 0) continue;
const screenX = Math.floor((128 / 2) * (1 + transformX / transformY));
const sprH = Math.floor(128 / transformY);
const sprW = sprH;
const drawStartY = Math.max(0, Math.floor(64 - sprH / 2));
const drawEndY = Math.min(128, Math.floor(64 + sprH / 2));
const drawStartX = Math.max(0, Math.floor(screenX - sprW / 2));
const drawEndX = Math.min(128, Math.floor(screenX + sprW / 2));
for (let stripe = drawStartX; stripe < drawEndX; stripe++) {
if (transformY >= zBuffer[stripe]) continue;
const tx = Math.floor(
((stripe - (screenX - sprW / 2)) * 8) / sprW,
);
for (let y = drawStartY; y < drawEndY; y++) {
const ty = Math.floor(
((y - (64 - sprH / 2)) * 8) / sprH,
);
let color = sprites.prop[ty * 8 + tx];
if (color < 0) continue;
if (transformY >= 6) {
color = Darken[Darken[color]];
} else if (transformY >= 3) {
color = Darken[color];
}
pset(stripe, y, color);
}
}
}
La línea clave es if (transformY >= zBuffer[stripe]) continue — salta cualquier columna de objeto que esté detrás de una pared, dándonos oclusión correcta gratis.
Colisión
Sin colisión, el jugador atraviesa los objetos como si no existieran. Podemos arreglar eso con una simple verificación de distancia. Antes de aplicar el movimiento, probamos si la nueva posición está dentro de un radio de colisión del centro de cualquier objeto. X e Y se verifican independientemente para que el jugador se deslice a lo largo de los objetos en vez de detenerse en seco:
const CollisionRadius = 0.3;
function collidesWithProp(x, y) {
return Props.some(
(p) => Math.abs(p.x - x) < CollisionRadius && Math.abs(p.y - y) < CollisionRadius,
);
}
Agregamos la verificación de colisión al código de movimiento junto con la verificación de paredes existente:
if (btn('ArrowUp')) {
const nx = px + Math.cos(pa) * Constants.MoveSpeed;
const ny = py + Math.sin(pa) * Constants.MoveSpeed;
if (Map[Math.floor(py)][Math.floor(nx)] === 0 && !collidesWithProp(nx, py)) px = nx;
if (Map[Math.floor(ny)][Math.floor(px)] === 0 && !collidesWithProp(px, ny)) py = ny;
}
Cada eje se prueba por separado — collidesWithProp(nx, py) verifica solo X, y collidesWithProp(px, ny) verifica solo Y. Eso significa que caminar en diagonal hacia un barril todavía te deja deslizarte por su borde en el eje libre, lo cual se siente mucho mejor que detenerse en seco.
El minimapa muestra las posiciones de los objetos como pequeños puntos naranjas para que puedas ver dónde están desde arriba.
Para Ir Más Lejos
- Múltiples tipos de calcomanías y objetos — Almacena un índice de textura en cada entrada de
DecalsyPropspara que diferentes paredes tengan diferentes pósters y diferentes objetos usen diferentes sprites (cajas, columnas, barriles). - Calcomanías animadas — Alterna entre múltiples texturas con un temporizador para letreros parpadeantes o luces que parpadean en las paredes.
- Objetos destructibles — Lleva un registro de la salud de los objetos y elimínalos del array
Propscuando reciban suficiente daño. La verificación de colisión y el bucle de renderizado saltan las entradas eliminadas automáticamente. - Colocación basada en el mapa — Usa valores especiales en el mapa (2 para un objeto, 3 para una calcomanía) en lugar de arrays separados, para que puedas colocar decoraciones directamente en la cuadrícula del mapa.
- Transparencia y mezcla alfa — En lugar de una verificación binaria transparente/opaco, mezcla los colores de la calcomanía con los colores de la pared para efectos semitransparentes como vitrales o grafiti desvanecido.