Skip to main content

Cómo Agregar Gestión de Escenas y Niveles a un Raycaster

Este tutorial fue escrito para la v2 del motor.

Todos los tutoriales de raycaster hasta ahora han usado un solo mapa — una cuadrícula, una textura, un espacio para explorar. Eso está bien para aprender el renderizador, pero los juegos tienen niveles. Un dungeon crawler necesita pasillos que lleven a algún lugar. Un juego de terror necesita habitaciones que empeoren cada vez más.

Vamos a agregar gestión de niveles a nuestro raycaster. Empaquetaremos los datos de nivel en objetos independientes, cargaremos diferentes mapas en tiempo de ejecución, esparciremos llaves coleccionables como condición de victoria, haremos fundidos a negro entre niveles, y conectaremos una pantalla de título con un bucle de juego completo. Al final, tendremos un dungeon crawl de tres niveles con mapas, texturas y progresión distintos.

Esto se basa en el tutorial de raycasting. Si buscas el patrón general de gestión de escenas — registros de escenas, cuadrículas de selección de nivel, seguimiento de progreso — el tutorial de gestión de escenas cubre eso. Aquí nos enfocamos en cosas específicas del raycaster: intercambiar mapas y texturas, renderizar sprites billboard coleccionables, y hacer que las transiciones se sientan bien en primera persona.

El Punto de Partida

Esto se basa en el tutorial de raycasting. Si no lo has trabajado, empieza ahí — asumimos que estás cómodo con el algoritmo DDA, las paredes texturizadas y el minimapa.

Aquí está nuestro raycaster inicial. Es el mismo código del tutorial de raycasting, con un cambio estructural: el mapa, la textura de pared y la posición de aparición viven en un objeto de nivel en lugar de variables sueltas:

const level = {
    name: 'THE CORRIDOR',
    map: [
        [1,1,1,1,1,1,1,1],
        [1,0,0,0,0,0,0,1],
        [1,0,1,0,0,1,0,1],
        [1,0,0,0,0,0,0,1],
        [1,0,0,0,0,0,0,1],
        [1,0,1,0,0,1,0,1],
        [1,0,0,0,0,0,0,1],
        [1,1,1,1,1,1,1,1],
    ],
    wallTexture: [
        6, 6, 6, 6, 6, 6, 6, 6,
        6,13,13, 6, 6,13,13, 6,
        6,13,13, 6, 6,13,13, 6,
        6, 6, 6, 6, 6, 6, 6, 6,
        6, 6, 6, 6, 6, 6, 6, 6,
        6,13, 6,13,13, 6,13, 6,
        6,13,13,13,13,13,13, 6,
        6, 6, 6, 6, 6, 6, 6, 6,
    ],
    spawnX: 1.5,
    spawnY: 1.5,
};

El map es la misma cuadrícula de 8×8 — 1 para paredes, 0 para espacio abierto. wallTexture es un sprite de pixel art de 8×8 como un array plano de índices de paleta. spawnX/spawnY le dicen al motor dónde colocar al jugador cuando el nivel se carga.

Nada del raycaster en sí cambia. Solo estamos agrupando datos relacionados para que después, cuando tengamos múltiples niveles, podamos intercambiar todo de una vez cambiando a un objeto de nivel diferente.

El resto del código no cambia — raycasting DDA, renderizado de paredes texturizadas con sombreado por distancia, y un minimapa en la esquina:

const map = level.map;
const wallTexture = level.wallTexture;
const sprites = { wall: wallTexture };

let px = level.spawnX;
let py = level.spawnY;
let pa = 0;

const Constants = {
    Fov: 0.66,
    MoveSpeed: 0.05,
    RotSpeed: 0.05,
    MapSize: 8,
    Cell: 4,
    MmX: 96,
    MmY: 96,
};

const Darken = [
    0, 0, 0, 0, 2, 0, 5, 6,
    2, 4, 9, 3, 1, 1, 2, 9,
];

Extraemos map y wallTexture del objeto de nivel a variables locales para que el bucle de raycasting no necesite ningún cambio. Constants agrupa los valores de ajuste — campo de visión, velocidades de movimiento, disposición del minimapa — en un solo lugar. Darken desplaza cada color de paleta un tono más oscuro para el sombreado por distancia y el sombreado de caras laterales.

Scene & Level Management: Base Raycaster
Flechas para moverse y rotar

Usa las flechas del teclado para caminar. Este es el corredor — pasillos estrechos con textura de ladrillo. A continuación, agregaremos dos mapas más y una forma de cambiar entre ellos.

Múltiples Mapas

Un objeto de nivel está bien, pero el objetivo es tener múltiples niveles. Vamos a definir tres mapas con diferentes diseños y texturas de pared, y luego agregar una función para cargar cualquiera de ellos por índice.

Primero, los tres mapas. Cada uno es una cuadrícula de 8×8, mismo formato que antes:

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

El Corredor (mapa 0) tiene pasillos estrechos con paredes internas creando una ruta en forma de L. El Patio (mapa 1) es amplio y abierto con un bloque de pilar en el centro. El Laberinto (mapa 2) es denso y sinuoso — muchos callejones sin salida.

Cada mapa tiene su propia textura de pared para que la diferencia sea obvia al cambiar:

const WallTextures = [
    [
        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,
    ],
    [
        6, 6,13, 6, 6,13, 6, 6,
        6, 6,13, 6, 6,13, 6, 6,
        13,13,13,13,13,13,13,13,
        6, 6, 6, 6,13, 6, 6, 6,
        6, 6, 6, 6,13, 6, 6, 6,
        13,13,13,13,13,13,13,13,
        6, 6,13, 6, 6,13, 6, 6,
        6, 6,13, 6, 6,13, 6, 6,
    ],
    [
        4, 9, 4, 9, 4, 9, 4, 9,
        9, 4, 9, 4, 9, 4, 9, 4,
        4, 9, 4, 9, 4, 9, 4, 9,
        9, 4, 9, 4, 9, 4, 9, 4,
        4, 9, 4, 9, 4, 9, 4, 9,
        9, 4, 9, 4, 9, 4, 9, 4,
        4, 9, 4, 9, 4, 9, 4, 9,
        9, 4, 9, 4, 9, 4, 9, 4,
    ],
];

La textura 0 es un patrón de ladrillo marrón (colores de paleta 4 y 5). La textura 1 son bloques de piedra gris (6 y 13). La textura 2 es un damero (4 y 9). Incluso a 8×8 píxeles, se leen como materiales diferentes una vez que el raycaster los estira por las paredes.

Ahora agrupemos todo en un array Levels:

const Levels = [
    {
        name: 'THE CORRIDOR',
        map: Maps[0],
        wallTexture: WallTextures[0],
        spawnX: 1.5,
        spawnY: 1.5,
    },
    {
        name: 'THE COURTYARD',
        map: Maps[1],
        wallTexture: WallTextures[1],
        spawnX: 1.5,
        spawnY: 1.5,
    },
    {
        name: 'THE MAZE',
        map: Maps[2],
        wallTexture: WallTextures[2],
        spawnX: 1.5,
        spawnY: 1.5,
    },
];

Cada nivel tiene un name (para el HUD), un map, un wallTexture y una posición de aparición. Agregar un nuevo nivel después es solo otro objeto en este array.

La adición clave es loadLevel — una función que intercambia todo de una vez:

let currentLevel = 0;
let map, wallTexture, px, py, pa;

function loadLevel(index) {
    currentLevel = index;
    const level = Levels[currentLevel];
    map = level.map;
    wallTexture = level.wallTexture;
    px = level.spawnX;
    py = level.spawnY;
    pa = 0;
}

loadLevel(0);

const sprites = { wall: Levels[0].wallTexture };

En lugar de declarar map, wallTexture, px, py y pa con valores iniciales, los declaramos vacíos y dejamos que loadLevel los rellene. El objeto sprites también necesita actualizarse cuando cambia la textura de pared — lo manejaremos en el bucle de actualización.

Para probarlo, usamos btnp('z') para ciclar entre niveles:

function update() {
    if (btnp('z')) {
        loadLevel((currentLevel + 1) % Levels.length);
        sprites.wall = wallTexture;
    }

    // ... movement code unchanged
}

Al presionar Z avanzamos al siguiente nivel (volviendo a 0 después del último) e intercambiamos la textura de pared en sprites. El bucle de raycasting no necesita ningún cambio — ya lee de map y sprites.wall, que ahora apuntan a los datos del nuevo nivel.

También mostramos el nombre del nivel en pantalla:

text(Levels[currentLevel].name, 2, 2, 7);
text('z = next level', 2, 10, 6);
Scene & Level Management: Multiple Maps
Presiona Z para ciclar entre tres niveles

Presiona Z para cambiar entre El Corredor, El Patio y El Laberinto. Las paredes, el diseño y tu posición cambian al instante. En un juego real no tendrías un botón de "siguiente nivel" — el jugador se ganaría la transición completando un objetivo. Eso es lo que construiremos a continuación.

Llaves Coleccionables

Presionar Z para cambiar de nivel funciona para probar, pero los jugadores necesitan una razón para avanzar al siguiente mapa. Vamos a esparcir llaves coleccionables por cada nivel — recógelas todas y aparece un portal de salida. Camina hacia él y se carga el siguiente nivel.

Primero, extendemos cada objeto de nivel con posiciones de llaves y una ubicación de salida:

const Levels = [
    {
        name: 'THE CORRIDOR',
        map: Maps[0],
        wallTexture: WallTextures[0],
        spawnX: 1.5,
        spawnY: 1.5,
        keys: [{ x: 5.5, y: 1.5 }, { x: 1.5, y: 5.5 }],
        exitX: 6.5,
        exitY: 6.5,
    },
    {
        name: 'THE COURTYARD',
        map: Maps[1],
        wallTexture: WallTextures[1],
        spawnX: 1.5,
        spawnY: 1.5,
        keys: [{ x: 6.5, y: 1.5 }, { x: 1.5, y: 6.5 }, { x: 6.5, y: 6.5 }],
        exitX: 6.5,
        exitY: 3.5,
    },
    {
        name: 'THE MAZE',
        map: Maps[2],
        wallTexture: WallTextures[2],
        spawnX: 1.5,
        spawnY: 1.5,
        keys: [{ x: 3.5, y: 1.5 }, { x: 6.5, y: 6.5 }],
        exitX: 6.5,
        exitY: 2.5,
    },
];

Cada array keys contiene posiciones en el mundo donde aparecen los objetos de llave. El desplazamiento de .5 los centra en su celda del mapa. El corredor tiene 2 llaves, el patio tiene 3, el laberinto tiene 2. exitX/exitY marcan dónde aparece el portal de salida una vez que se recogen todas las llaves.

Actualicemos loadLevel para configurar el seguimiento de llaves:

let keys, exitX, exitY, exitOpen;

function loadLevel(index) {
    currentLevel = index;
    const level = Levels[currentLevel];
    map = level.map;
    wallTexture = level.wallTexture;
    px = level.spawnX;
    py = level.spawnY;
    pa = 0;
    keys = level.keys.map(k => ({ x: k.x, y: k.y, collected: false }));
    exitX = level.exitX;
    exitY = level.exitY;
    exitOpen = false;
}

Copiamos las posiciones de las llaves en nuevos objetos con una bandera collected para poder rastrear las recolecciones sin mutar los datos del nivel. La salida comienza cerrada.

Necesitamos dos sprites de 8×8 — una llave y un portal de salida:

const KeySprite = [
    -1, -1, -1, 10, 10, -1, -1, -1,
    -1, -1, 10, 10, 10, 10, -1, -1,
    -1, 10, 10, 11, 11, 10, 10, -1,
    10, 10, 11, 11, 11, 11, 10, 10,
    10, 10, 11, 11, 11, 11, 10, 10,
    -1, 10, 10, 11, 11, 10, 10, -1,
    -1, -1, 10, 10, 10, 10, -1, -1,
    -1, -1, -1, 10, 10, -1, -1, -1,
];

const ExitSprite = [
    -1, -1, 11, 11, 11, 11, -1, -1,
    -1, 11,  3,  3,  3,  3, 11, -1,
    11,  3,  3, 11, 11,  3,  3, 11,
    11,  3, 11, 11, 11, 11,  3, 11,
    11,  3, 11, 11, 11, 11,  3, 11,
    11,  3,  3, 11, 11,  3,  3, 11,
    -1, 11,  3,  3,  3,  3, 11, -1,
    -1, -1, 11, 11, 11, 11, -1, -1,
];

const sprites = { wall: wallTexture, key: KeySprite, exit: ExitSprite };

La llave es un diamante verde (colores de paleta 10 y 11). La salida es un anillo de portal azul-verde. Los píxeles con valor -1 son transparentes — el raycaster los omite para que la pared o el suelo detrás se vean.

La detección de recolección ocurre en update. Verificamos la distancia del jugador a cada llave no recolectada:

for (const k of keys) {
    if (k.collected) continue;
    const dx = px - k.x;
    const dy = py - k.y;
    if (Math.sqrt(dx * dx + dy * dy) < 0.5) {
        k.collected = true;
    }
}

const remaining = keys.filter(k => !k.collected).length;
if (remaining === 0) exitOpen = true;

Cuando el jugador se acerca a menos de 0.5 unidades de una llave, se recolecta. Cuando no quedan ninguna, exitOpen cambia a true y la salida se activa. La misma comprobación de proximidad en la salida carga el siguiente nivel:

if (exitOpen) {
    const dx = px - exitX;
    const dy = py - exitY;
    if (Math.sqrt(dx * dx + dy * dy) < 0.5) {
        const next = (currentLevel + 1) % Levels.length;
        loadLevel(next);
        sprites.wall = wallTexture;
    }
}

Para renderizar las llaves y la salida en la vista 3D, necesitamos sprites billboard — imágenes 2D que siempre miran hacia la cámara. Es la misma técnica del tutorial de aparición de monstruos, pero más simple — sin IA, solo posiciones estáticas.

drawBillboard transforma una posición del mundo en coordenadas de pantalla usando la matriz inversa de la cámara:

function drawBillboard(spriteData, worldX, worldY,
    dirX, dirY, planeX, planeY, zBuffer) {
    const sx = worldX - px;
    const sy = worldY - py;
    const invDet = 1 / (planeX * dirY - dirX * planeY);
    const transformX = invDet * (dirY * sx - dirX * sy);
    const transformY = invDet * (-planeY * sx + planeX * sy);

    if (transformY <= 0) return;

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

    const drawStartY = Math.max(
        0, Math.floor(64 + 64 / transformY / 2 - sprH),
    );
    const drawEndY = Math.min(
        128, Math.floor(64 + 64 / transformY / 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 - (drawStartY + sprH
                    - (drawEndY - drawStartY))) * 8) / sprH,
            );
            if (ty < 0 || ty > 7) continue;

            let color = spriteData[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);
        }
    }
}

El valor clave aquí es transformY — la profundidad que nos dice qué tan lejos está el sprite de la cámara. Lo comparamos contra el zBuffer (llenado durante el renderizado de paredes) para que los sprites detrás de las paredes queden ocultos. El sprite también usa el mismo sombreado por distancia que las paredes, así que las llaves se oscurecen a medida que se alejan.

El cálculo de drawStartY posiciona el sprite en el suelo en lugar de centrarlo a la altura de los ojos. Usar 64 + 64/transformY/2 - sprH en lugar del habitual 64 - sprH/2 lo desplaza hacia abajo para que se asiente en el plano del suelo.

Después del bucle de raycasting de paredes, reunimos todos los billboards visibles, los ordenamos de atrás hacia adelante, y dibujamos:

const billboards = [];
for (const k of keys) {
    if (!k.collected) {
        billboards.push({ sprite: sprites.key, x: k.x, y: k.y });
    }
}
if (exitOpen) {
    billboards.push({ sprite: sprites.exit, x: exitX, y: exitY });
}

billboards
    .map(b => ({
        b,
        dist: (b.x - px) * (b.x - px)
            + (b.y - py) * (b.y - py),
    }))
    .sort((a, b) => b.dist - a.dist)
    .forEach(({ b }) =>
        drawBillboard(
            b.sprite, b.x, b.y,
            dirX, dirY, planeX, planeY, zBuffer,
        ),
    );

Ordenar de atrás hacia adelante significa que los sprites distantes se renderizan primero y los más cercanos pintan encima — el algoritmo del pintor. Sin esta ordenación, una llave lejana podría sobreescribir una cercana.

Las llaves y la salida también aparecen en el minimapa como píxeles de colores:

for (const k of keys) {
    if (k.collected) continue;
    pset(
        Constants.MmX + Math.floor(k.x * Constants.Cell),
        Constants.MmY + Math.floor(k.y * Constants.Cell),
        10,
    );
}

if (exitOpen) {
    pset(
        Constants.MmX + Math.floor(exitX * Constants.Cell),
        Constants.MmY + Math.floor(exitY * Constants.Cell),
        11,
    );
}

Puntos verdes para las llaves, un punto brillante para la salida. El HUD muestra cuántas llaves quedan:

const remaining = keys.filter(k => !k.collected).length;
if (remaining > 0) {
    text(remaining + ' keys left', 2, 2, 10);
} else {
    text('find the exit!', 2, 2, 11);
}
text(Levels[currentLevel].name, 2, 10, 7);
Scene & Level Management: Collectible Keys
Recoge todas las llaves para abrir la salida

Camina hacia los diamantes verdes para recogerlos. El HUD muestra cuántos quedan. Cuando se acaben todos, aparece un portal — camina hacia él para cargar el siguiente nivel. Ahora mismo la transición es instantánea, lo cual se siente un poco abrupto. Arreglemos eso a continuación.

Transiciones de Escena

Saltar directamente de un nivel al siguiente se siente brusco — un fotograma estás en un corredor de ladrillo, al siguiente estás en un patio de piedra sin aviso. Un breve fundido a negro conecta la transición y le da al jugador un momento para reorientarse.

La transición tiene dos fases: cierre (barras negras se deslizan desde arriba y abajo hasta encontrarse en el medio) y apertura (las barras se deslizan hacia afuera para revelar el nuevo nivel). El cambio de nivel ocurre en el punto medio cuando la pantalla está completamente negra.

Agreguemos algo de estado para rastrearlo:

let transitioning = false;
let transPhase = 0;
let transProgress = 0;
let transTarget = 0;
const TransSpeed = 4;

let victory = false;

transPhase es 0 para cierre, 1 para apertura. transProgress rastrea cuántos píxeles se han movido las barras negras (0 a 64, ya que cada barra cubre la mitad de la pantalla de 128 píxeles). TransSpeed controla la velocidad — 4 píxeles por fotograma significa que la transición completa toma unos 32 fotogramas. victory se activa cuando el jugador completa el último nivel.

Una función auxiliar inicia la transición:

function startTransition(nextLevel) {
    if (transitioning) return;
    transitioning = true;
    transPhase = 0;
    transProgress = 0;
    transTarget = nextLevel;
}

Reemplazamos la carga instantánea de nivel de la sección anterior con esto:

if (exitOpen) {
    const dx = px - exitX;
    const dy = py - exitY;
    if (Math.sqrt(dx * dx + dy * dy) < 0.5) {
        startTransition(currentLevel + 1);
    }
}

En lugar de llamar a loadLevel directamente, iniciamos una transición con el índice del siguiente nivel como objetivo.

La lógica de transición va al inicio de update y retorna temprano para bloquear la entrada mientras anima:

function update() {
    if (transitioning) {
        if (transPhase === 0) {
            transProgress += TransSpeed;
            if (transProgress >= 64) {
                transProgress = 64;
                if (transTarget >= Levels.length) {
                    victory = true;
                    transitioning = false;
                } else {
                    loadLevel(transTarget);
                    sprites.wall = wallTexture;
                    transPhase = 1;
                }
            }
        } else {
            transProgress -= TransSpeed;
            if (transProgress <= 0) {
                transProgress = 0;
                transitioning = false;
            }
        }
        return;
    }

    if (victory) return;

    // ... movement and key collection unchanged
}

La fase 0 incrementa transProgress hasta que las barras se encuentran a 64 píxeles. En ese punto, si el nivel objetivo está más allá del último, activamos la bandera de victoria. De lo contrario cargamos el nuevo nivel y pasamos a la fase 1, que reduce las barras de vuelta a cero.

Ese return al final del bloque de transición es importante — evita que el jugador se mueva o recoja llaves mientras la pantalla se desvanece.

Dibujar la transición son dos rectángulos negros, pintados después de la escena del juego para que se superpongan a todo:

if (transitioning) {
    rectfill(0, 0, 128, transProgress, 0);
    rectfill(0, 128 - transProgress, 128, 128, 0);
}

El primer rectángulo crece hacia abajo desde arriba (y = 0 a y = transProgress). El segundo crece hacia arriba desde abajo (y = 128 − transProgress a y = 128). Se encuentran en y = 64 cuando transProgress llega a 64.

La pantalla de victoria es simple — limpiar y mostrar un mensaje:

if (victory) {
    text('ALL LEVELS COMPLETE!', 14, 56, 11);
    return;
}

Esto va al inicio de draw, antes de cualquier renderizado del juego.

Scene & Level Management: Scene Transitions
Transiciones de fundido a negro entre niveles

Recoge las llaves y llega a la salida. Las barras negras se cierran, el nivel cambia, y se abren para revelar el nuevo mapa. Completa los tres niveles y verás el mensaje de victoria. Mucho mejor que un corte instantáneo.

Pantalla de Título y Flujo de Juego

Ahora mismo el juego te lanza directo al nivel 1. Un juego completo necesita una pantalla de título, un recorrido por todos los niveles, una pantalla de victoria y una forma de reiniciar. Agreguemos una variable gameState para controlar qué pantalla está activa:

let gameState = 'title';

Tres estados: 'title', 'playing' y 'victory'. El bucle de actualización verifica el estado y dirige la entrada:

if (gameState === 'title') {
    if (btnp('z')) {
        startTransition('start');
    }
    return;
}

if (gameState === 'victory') {
    if (btnp('z')) {
        gameState = 'title';
    }
    return;
}

En la pantalla de título, presionar Z activa una transición a 'start' — un objetivo especial que significa "cargar el nivel 0 y comenzar a jugar." En la pantalla de victoria, Z vuelve al título instantáneamente (no se necesita transición — es solo una pantalla de texto).

El sistema de transiciones necesita manejar estos nuevos objetivos. Antes transTarget era siempre un índice de nivel. Ahora puede ser un índice de nivel, 'victory' o 'start':

function startTransition(target) {
    if (transitioning) return;
    transitioning = true;
    transPhase = 0;
    transProgress = 0;
    transTarget = target;
}

La lógica del punto medio se bifurca según el tipo de objetivo:

if (transPhase === 0) {
    transProgress += TransSpeed;
    if (transProgress >= 64) {
        transProgress = 64;
        if (transTarget === 'victory') {
            gameState = 'victory';
            transitioning = false;
        } else if (transTarget === 'start') {
            loadLevel(0);
            gameState = 'playing';
            transPhase = 1;
        } else {
            loadLevel(transTarget);
            transPhase = 1;
        }
    }
}

Cuando el objetivo es 'victory', la pantalla se queda negra y cambiamos al estado de victoria — sin fase de apertura. Cuando es 'start', cargamos el nivel 0 y abrimos la cortina hacia el juego. Para objetivos numéricos, funciona exactamente como antes.

La comprobación de salida ahora usa 'victory' en lugar de comparar contra Levels.length:

if (exitOpen) {
    const dx = px - exitX;
    const dy = py - exitY;
    if (Math.sqrt(dx * dx + dy * dy) < 0.5) {
        if (currentLevel + 1 >= Levels.length) {
            startTransition('victory');
        } else {
            startTransition(currentLevel + 1);
        }
    }
}

También movemos sprites.wall = wallTexture dentro de loadLevel para que los llamadores no necesiten recordarlo:

function loadLevel(index) {
    currentLevel = index;
    const level = Levels[currentLevel];
    map = level.map;
    wallTexture = level.wallTexture;
    px = level.spawnX;
    py = level.spawnY;
    pa = 0;
    keys = level.keys.map(k => ({ x: k.x, y: k.y, collected: false }));
    exitX = level.exitX;
    exitY = level.exitY;
    exitOpen = false;
    sprites.wall = wallTexture;
}

La función de dibujo dirige a la pantalla correcta según gameState:

function draw() {
    cls(0);

    if (gameState === 'title') {
        text('DUNGEON CRAWL', 20, 40, 7);
        text('PRESS Z TO START', 12, 56, 6);
    } else if (gameState === 'victory') {
        text('ALL LEVELS COMPLETE!', 4, 40, 11);
        text('PRESS Z FOR TITLE', 8, 56, 6);
    } else {
        drawGame();
    }

    if (transitioning) {
        rectfill(0, 0, 128, transProgress, 0);
        rectfill(0, 128 - transProgress, 128, 128, 0);
    }
}

La superposición de transición se dibuja al final, sin importar qué pantalla esté activa. Eso significa que el fundido a negro funciona en todas las transiciones — título a juego, nivel a nivel, juego a victoria.

Scene & Level Management: Complete Game
Juego completo con pantalla de título y tres niveles

Presiona Z en la pantalla de título para empezar. Juega los tres niveles — recoge las llaves, llega a las salidas — y llegarás a la pantalla de victoria. Presiona Z de nuevo para volver al título. Ese es un bucle de juego completo: título → jugar → victoria → título.

Para Ir Más Allá

Tenemos un raycaster con múltiples niveles, objetivos coleccionables, transiciones suaves y un bucle de juego completo. Aquí hay algunas formas de llevarlo más allá.

Pantalla de selección de nivel. El tutorial de gestión de escenas construye un selector de niveles basado en cuadrícula con seguimiento de desbloqueo. El mismo enfoque funciona aquí — muestra miniaturas o nombres de cada nivel, bloquea los no completados, y deja al jugador repetir niveles ya superados.

Guardar y cargar progreso. El tutorial de guardar y cargar cubre la persistencia del estado del juego en localStorage. Guarda el nivel más alto completado para que el progreso sobreviva a una recarga de página. Cárgalo al iniciar para desbloquear los niveles que el jugador ya completó.

Música o sonidos ambientales por nivel. Agrega un campo music o ambience a cada objeto de nivel. Inícialo cuando el nivel carga, haz un fade out durante la transición, e inicia la nueva pista cuando el siguiente nivel aparece.

Calificación por estrellas. Rastrea el tiempo en cada nivel. Otorga 1–3 estrellas según la velocidad de completado. Muestra las calificaciones en la pantalla de victoria y guárdalas junto con el progreso.

Generación procedural de niveles. Reemplaza los mapas hechos a mano con un generador de laberintos — backtracker recursivo, algoritmo de Prim, lo que prefieras. Genera un nuevo diseño cada vez que el jugador entra a un nivel para rejugabilidad infinita.

Escalado de dificultad. Agrega más llaves en niveles posteriores, usa mapas más grandes, o coloca llaves en lugares más difíciles de alcanzar. También podrías introducir puertas cerradas que requieren llaves de colores específicos, añadiendo un elemento de rompecabezas a la navegación.