Skip to main content

Cómo añadir aparición de monstruos y búsqueda de caminos

El tutorial de raycasting nos dio un renderizador en primera persona — paredes texturizadas, sombreado por distancia, un minimapa — pero el mundo está vacío. No hay nada de lo que huir.

Vamos a solucionar eso añadiendo monstruos. Renderizaremos sprites 2D como billboards dentro de la vista 3D, les daremos detección de línea de visión para que nos persigan cuando nos vean, añadiremos búsqueda de caminos BFS para que naveguen alrededor de las paredes, y conectaremos activadores de aparición que poblarán el mundo mientras exploramos.

Todo se construye sobre el raycaster base del tutorial anterior. No necesitas los tutoriales de niebla o sacudida de cámara — empezamos desde cero con solo paredes y un jugador.

El punto de partida

Aquí está el raycaster del tutorial anterior, adaptado a un mapa más grande de 10x10. Tiene pasillos y espacios abiertos — espacio para que los monstruos naveguen después.

Si ya hiciste el tutorial de raycasting, es el mismo código con una cuadrícula más grande. Si estás empezando desde cero, estas son las piezas clave:

  • Una cuadrícula 2D donde 1 es pared y 0 es suelo
  • Raycasting DDA para encontrar distancias a las paredes en cada columna de pantalla
  • Paredes texturizadas dibujadas píxel a píxel con pset()
  • Sombreado por distancia en tres niveles mediante una tabla de búsqueda Darken
  • Un minimapa en la esquina inferior derecha

El mapa, la textura de pared, la tabla de sombreado y las constantes se definen al principio:

const Map = [
    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
    [1, 0, 0, 0, 1, 1, 0, 0, 0, 1],
    [1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
    [1, 0, 1, 0, 0, 0, 0, 1, 0, 1],
    [1, 0, 1, 0, 0, 0, 0, 1, 0, 1],
    [1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
    [1, 0, 0, 0, 1, 1, 0, 0, 0, 1],
    [1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
];

const sprites = { wall: WallTexture };

let px = 1.5;
let py = 1.5;
let pa = 0;

El jugador empieza en 1.5, 1.5 — el centro de la celda [1][1] — mirando hacia la derecha.

El bucle de raycasting es el mismo de antes. Para cada columna de pantalla, lanzamos un rayo usando DDA, encontramos la distancia a la pared y dibujamos una franja texturizada:

for (let x = 0; x < 128; x++) {
    const cameraX = (2 * x) / 128 - 1;
    const rdx = dirX + planeX * cameraX;
    const rdy = dirY + planeY * cameraX;

    // DDA to find the nearest wall...

    const perpDist =
        side === 0
            ? sideDistX - deltaDistX
            : sideDistY - deltaDistY;

    const stripeH = Math.floor(128 / perpDist);
    // draw textured wall stripe with distance shading...
}

Camina por ahí y familiarízate con el diseño. A continuación, vamos a meter monstruos.

Monster Spawning & Pathfinding: Base Raycaster
El raycaster base con un mapa de 10x10 — usa las flechas para moverte

Sprites Billboard

Un sprite billboard es una imagen 2D dibujada en una posición del mundo dentro de la vista 3D. Siempre mira hacia la cámara — no necesita rotación. Así es como Wolfenstein 3D renderizaba sus enemigos, objetos y decoraciones.

Para dibujar sprites correctamente, necesitamos saber qué columnas de pantalla ya están ocupadas por paredes más cercanas. Durante el bucle de dibujado de paredes, almacenamos la distancia perpendicular de cada columna en un array zBuffer:

const zBuffer = new Array(128);

for (let x = 0; x < 128; x++) {
    // ... existing DDA raycasting ...

    zBuffer[x] = perpDist;

    // ... draw wall stripe ...
}

Ahora la parte divertida — la transformación del sprite. Cada monstruo tiene una posición en el mundo (m.x, m.y). Para determinar dónde aparece en pantalla, lo proyectamos a través de la matriz inversa de la cámara:

const invDet = 1 / (planeX * dirY - dirX * planeY);

const sx = m.x - px;
const sy = m.y - py;

const transformX = invDet * (dirY * sx - dirX * sy);
const transformY = invDet * (-planeY * sx + planeX * sy);

transformY es la profundidad — qué tan lejos está el sprite en la dirección frontal de la cámara. Si es cero o negativo, el sprite está detrás de nosotros. transformX determina la posición horizontal en pantalla:

const screenX = Math.floor((128 / 2) * (1 + transformX / transformY));

El tamaño del sprite en pantalla escala igual que las paredes — 128 / 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));

Luego lo dibujamos columna por columna, saltando cualquier columna donde la pared esté más cerca:

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.monster[ty * 8 + tx];
        if (color < 0) continue;

        // apply distance shading same as walls
        if (transformY >= 6) {
            color = Darken[Darken[color]];
        } else if (transformY >= 3) {
            color = Darken[color];
        }

        pset(stripe, y, color);
    }
}

Esa verificación de zBuffer en cada columna es lo que hace que los sprites desaparezcan detrás de las paredes. Sin ella, los monstruos se dibujarían encima de todo.

Una cosa más: cuando varios sprites se superponen, los más cercanos deben pintarse sobre los más lejanos. Ordenamos los monstruos por distancia antes de dibujar, del más lejano al más cercano:

const sorted = monsters
    .map((m, i) => ({
        i,
        dist: (m.x - px) * (m.x - px) + (m.y - py) * (m.y - py),
    }))
    .sort((a, b) => b.dist - a.dist);

Los monstruos también aparecen como puntos rojos en el minimapa. Camina por ahí — los verás asomarse detrás de las paredes y cambiar de tamaño con la distancia.

Monster Spawning & Pathfinding: Billboard Sprites
Tres monstruos estáticos renderizados como billboards — camina para ver el ordenamiento por profundidad y la oclusión por paredes

Persiguiendo al jugador

Los monstruos estáticos no dan miedo. Hagamos que se muevan.

Cada monstruo tiene un estado chasing. En cada frame, verificamos si puede ver al jugador. Si puede, camina directo hacia ti. Si hay una pared en medio, se queda quieto — la búsqueda de caminos viene después.

Línea de visión

La verificación de línea de visión reutiliza el mismo algoritmo DDA del raycaster. Lanzamos un rayo desde el monstruo hacia el jugador. Si golpea una celda de pared antes de llegar a la celda del jugador, la línea de visión está bloqueada:

function hasLineOfSight(fromX, fromY, toX, toY) {
    const dx = toX - fromX;
    const dy = toY - fromY;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < 0.01) return true;

    const rdx = dx / dist;
    const rdy = dy / dist;

    let mx = Math.floor(fromX);
    let my = Math.floor(fromY);

    const deltaDistX = Math.abs(rdx) < 1e-10 ? 1e30 : Math.abs(1 / rdx);
    const deltaDistY = Math.abs(rdy) < 1e-10 ? 1e30 : Math.abs(1 / rdy);

    // ... same DDA stepping as the raycaster ...

    const targetCellX = Math.floor(toX);
    const targetCellY = Math.floor(toY);

    for (let i = 0; i < 100; i++) {
        if (sideDistX < sideDistY) {
            sideDistX += deltaDistX;
            mx += stepX;
        } else {
            sideDistY += deltaDistY;
            my += stepY;
        }

        if (mx === targetCellX && my === targetCellY) return true;
        if (Map[my]?.[mx] === 1) return false;
    }

    return false;
}

Es barato — un recorrido DDA por monstruo por frame. Y como es el mismo algoritmo que ya conocemos del renderizado de paredes, no hay nada nuevo que aprender.

Movimiento

Cuando un monstruo tiene línea de visión, se mueve directamente hacia el jugador. El movimiento usa colisión por ejes separados — el mismo enfoque que el jugador. Probamos X primero, verificamos las paredes, luego probamos Y:

const MonsterSpeed = 0.02;

for (const m of monsters) {
    m.chasing = hasLineOfSight(m.x, m.y, px, py);

    if (m.chasing) {
        const dx = px - m.x;
        const dy = py - m.y;
        const dist = Math.sqrt(dx * dx + dy * dy);

        const moveX = (dx / dist) * MonsterSpeed;
        const moveY = (dy / dist) * MonsterSpeed;

        const nx = m.x + moveX;
        const ny = m.y + moveY;

        if (Map[Math.floor(m.y)][Math.floor(nx)] === 0) m.x = nx;
        if (Map[Math.floor(ny)][Math.floor(m.x)] === 0) m.y = ny;
    }
}

La colisión por ejes separados hace que los monstruos se deslicen a lo largo de las paredes en lugar de quedarse atascados en las esquinas. Si el movimiento en X los pondría dentro de una pared, solo se aplica el movimiento en Y, y viceversa.

Ser atrapado

Cuando un monstruo se acerca lo suficiente (distancia < 0.4), la pantalla parpadea en rojo durante 60 frames y el monstruo se reinicia a su posición inicial:

if (dist < 0.4) {
    caught = 60;
    m.x = 5.5;
    m.y = 1.5 + monsters.indexOf(m) * 3;
    m.chasing = false;
    continue;
}

En el minimapa, los monstruos que persiguen se muestran como puntos amarillos en lugar de rojos. Intenta caminar hacia la línea de visión de un monstruo y esconderte detrás de una pared — verás que se detiene cuando te pierde de vista.

Monster Spawning & Pathfinding: Chasing the Player
Los monstruos persiguen con línea de visión — observa cómo los puntos del minimapa se vuelven amarillos cuando te detectan

Búsqueda de caminos alrededor de paredes

Ahora mismo, los monstruos se congelan cuando pierden la línea de visión. Puedes simplemente esconderte detrás de una pared y la amenaza desaparece. Vamos a solucionar eso con búsqueda de caminos BFS — cuando un monstruo no puede verte, encuentra una ruta alrededor de las paredes.

BFS en la cuadrícula

BFS (búsqueda en anchura) explora el mapa capa por capa hacia afuera desde la celda del monstruo hasta llegar a la celda del jugador. Usa una cola FIFO y movimiento en 4 direcciones:

function bfs(startX, startY, goalX, goalY) {
    const sx = Math.floor(startX);
    const sy = Math.floor(startY);
    const gx = Math.floor(goalX);
    const gy = Math.floor(goalY);

    if (sx === gx && sy === gy) return [];

    const visited = new Set();
    const queue = [{ x: sx, y: sy, path: [] }];
    visited.add(sy * Constants.MapSize + sx);

    const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]];

    while (queue.length > 0) {
        const { x, y, path } = queue.shift();

        for (const [dx, dy] of dirs) {
            const nx = x + dx;
            const ny = y + dy;
            const key = ny * Constants.MapSize + nx;

            if (visited.has(key)) continue;
            if (Map[ny]?.[nx] !== 0) continue;

            visited.add(key);

            const newPath = [...path, { x: nx + 0.5, y: ny + 0.5 }];

            if (nx === gx && ny === gy) return newPath;

            queue.push({ x: nx, y: ny, path: newPath });
        }
    }

    return [];
}

Cada nodo del camino apunta al centro de una celda de la cuadrícula (+ 0.5). El conjunto de visitados usa y * MapSize + x como clave plana para evitar la asignación de strings. BFS garantiza el camino más corto en una cuadrícula sin pesos.

Usamos BFS en lugar de A* porque es más simple y el mapa es lo suficientemente pequeño como para que no importe. Si quieres explorar A*, Dijkstra o búsqueda de caminos con pesos, el tutorial de pathfinding cubre todo eso.

Alternar entre persecución y búsqueda de caminos

La IA del monstruo ahora tiene dos modos. Cuando tiene línea de visión, persigue directamente. Cuando no, sigue el camino BFS:

if (los) {
    targetX = px;
    targetY = py;
    m.path = [];
} else {
    m.pathAge++;
    if (m.path.length === 0 || m.pathAge >= PathRecalcInterval) {
        m.path = bfs(m.x, m.y, px, py);
        m.pathAge = 0;
    }

    if (m.path.length > 0) {
        targetX = m.path[0].x;
        targetY = m.path[0].y;

        const dx = targetX - m.x;
        const dy = targetY - m.y;
        if (Math.sqrt(dx * dx + dy * dy) < 0.2) {
            m.path.shift();
        }
    } else {
        continue;
    }
}

Cuando el monstruo llega a menos de 0.2 unidades de un nodo del camino, elimina ese nodo y se mueve hacia el siguiente. Cuando la línea de visión vuelve, el camino se borra y cambia a persecución directa.

Limitación de frecuencia

Ejecutar BFS para cada monstruo en cada frame sería un desperdicio. En su lugar, cada monstruo lleva un contador pathAge. Los caminos solo se recalculan cada 30 frames — aproximadamente medio segundo. Eso es suficiente para un mapa de 10x10. En mapas más grandes querrías escalonar los recálculos para que no todos ejecuten BFS en el mismo frame.

Observa el minimapa — verás a los monstruos navegando alrededor de las paredes para alcanzarte incluso cuando no pueden verte. Los puntos naranjas están buscando camino, los amarillos tienen línea de visión.

Monster Spawning & Pathfinding: Pathfinding
Cuatro monstruos navegan alrededor de paredes con BFS cuando pierden la línea de visión

Activadores de aparición

Tener todos los monstruos presentes desde el inicio se siente plano. Es más interesante cuando aparecen mientras exploras — caminas hacia una nueva zona y los enemigos se materializan a tu alrededor.

Zonas de aparición

Las zonas de aparición se almacenan por separado de la cuadrícula del mapa. Cada zona tiene una posición central, un radio de activación y la cantidad de monstruos a crear:

const spawnZones = [
    { x: 5, y: 1.5, radius: 1.5, count: 2, triggered: false },
    { x: 8, y: 5, radius: 1.5, count: 1, triggered: false },
    { x: 2, y: 7.5, radius: 1.5, count: 2, triggered: false },
    { x: 7, y: 8, radius: 1, count: 1, triggered: false },
];

const monsters = [];

El array de monstruos empieza vacío. No existe nada hasta que el jugador entra en una zona.

Activación

En cada frame, verificamos si el jugador está dentro del rango de alguna zona no activada:

for (const zone of spawnZones) {
    if (zone.triggered) continue;
    const dx = px - zone.x;
    const dy = py - zone.y;
    if (Math.sqrt(dx * dx + dy * dy) < zone.radius) {
        zone.triggered = true;
        for (let i = 0; i < zone.count; i++) {
            spawnMonster(zone.x, zone.y, zone.radius);
        }
    }
}

Una vez activada, la zona se marca y no se dispara de nuevo. La función spawnMonster elige una posición aleatoria de suelo dentro del radio de la zona:

function spawnMonster(zoneX, zoneY, radius) {
    let mx, my;
    for (let attempt = 0; attempt < 20; attempt++) {
        mx = zoneX + (Math.random() - 0.5) * radius;
        my = zoneY + (Math.random() - 0.5) * radius;
        if (
            mx > 0.5 && mx < Constants.MapSize - 0.5 &&
            my > 0.5 && my < Constants.MapSize - 0.5 &&
            Map[Math.floor(my)][Math.floor(mx)] === 0
        ) {
            break;
        }
    }
    monsters.push({
        x: mx,
        y: my,
        chasing: false,
        path: [],
        pathAge: 0,
        spawnTimer: SpawnBlinkFrames,
    });
}

Intenta hasta 20 posiciones aleatorias para evitar colocar monstruos dentro de paredes. El spawnTimer les da a los monstruos recién aparecidos una breve ventana donde parpadean y no se mueven — un toque visual para que no aparezcan de golpe.

Efecto de parpadeo al aparecer

Durante el temporizador de aparición, el monstruo omite su actualización de IA y parpadea cada 6 frames:

if (m.spawnTimer > 0) {
    m.spawnTimer--;
    continue;
}

Para el renderizado, solo dibujamos el monstruo en los frames visibles del ciclo de parpadeo:

const visibleMonsters = monsters.filter(
    (m) => m.spawnTimer <= 0 || m.spawnTimer % 6 < 3,
);

Indicadores en el minimapa

Las zonas de aparición no activadas se muestran como cuadrados naranjas en el minimapa para que puedas ver dónde espera el peligro. Desaparecen una vez activadas:

for (const zone of spawnZones) {
    if (zone.triggered) continue;
    const zx = Constants.MmX + Math.floor(zone.x * Constants.Cell);
    const zy = Constants.MmY + Math.floor(zone.y * Constants.Cell);
    const zr = Math.floor(zone.radius * Constants.Cell);
    rectfill(zx - zr, zy - zr, zx + zr, zy + zr, 9);
}

Camina hacia las zonas naranjas en el minimapa y observa cómo los monstruos parpadean al existir a tu alrededor.

Monster Spawning & Pathfinding: Spawn Triggers
La demo completa — los monstruos aparecen desde activadores de zona mientras exploras el mapa

Siguientes pasos

Tenemos sprites billboard, persecución con línea de visión, búsqueda de caminos BFS y aparición por zonas. Es una base sólida. Aquí hay algunas direcciones para llevarlo más lejos:

  • Múltiples tipos de monstruos — diferentes sprites, velocidades y comportamientos. Uno rápido que carga al verte, uno lento que siempre busca caminos, una torreta estacionaria que ataca a distancia.
  • Combate — presiona una tecla para disparar un rayo desde la posición del jugador. Si golpea a un monstruo antes que a una pared, inflige daño. Dale a los monstruos barras de vida y animaciones de muerte.
  • Escalado de dificultad — aumenta la cantidad de apariciones o la velocidad de los monstruos a medida que el jugador limpia zonas. O añade un sistema de oleadas donde eliminar todos los monstruos activa la siguiente aparición.
  • Efectos de sonido — un gruñido cuando un monstruo te detecta, pasos cuando te persigue, un zumbido ambiental cerca de zonas de aparición no activadas.
  • Combinar con niebla e iluminación — el tutorial de niebla e iluminación añade renderizado atmosférico. Los monstruos emergiendo de la oscuridad pegan diferente.
  • Rutas de patrulla — en lugar de quedarse quietos cuando están inactivos, haz que los monstruos sigan rutas de puntos de control preestablecidas. Cuando detectan al jugador, abandonan la ruta y persiguen.
  • Búsqueda de caminos A* — BFS encuentra el camino más corto pero explora todo. A* usa una heurística para buscar más eficientemente. El tutorial de pathfinding cubre BFS, Dijkstra y A* en profundidad.