Skip to main content

Cómo Construir un Gestor de Escenas

Este tutorial fue escrito para la v2 del motor.

La mayoría de los juegos tienen más de una pantalla. Una pantalla de título, una selección de nivel, una pantalla de fin de juego — y el juego en sí. El tutorial de máquinas de estado cubre lo básico con cadenas de modo y despachadores if/else, pero ese enfoque se desmorona cuando tienes más de tres o cuatro pantallas. Los datos no fluyen limpiamente entre ellas, y agregar una nueva pantalla significa tocar cada despachador.

Vamos a construir algo mejor: escenas como objetos independientes con sus propios métodos de ciclo de vida. Haremos un pequeño juego de recoger gemas, y luego agregaremos una pantalla de selección de niveles, seguimiento de progreso entre niveles y transiciones visuales entre escenas. Al final tendrás un patrón que escala a cualquier cantidad de pantallas.

Un Registro de Escenas Simple

La idea es sencilla. Cada escena es un objeto con métodos init, update y draw. Los guardamos en un mapa indexado por nombre, y una función switchTo establece la escena actual y llama a su init:

let scenes = {};
let current = null;

function switchTo(name, data) {
    current = name;
    if (scenes[name].init) scenes[name].init(data || {});
}

El update y draw de nivel superior delegan a cualquier escena que esté activa:

function update() {
    if (scenes[current] && scenes[current].update) scenes[current].update();
}

function draw(time, frame) {
    if (scenes[current] && scenes[current].draw) scenes[current].draw(time, frame);
}

Eso es todo el gestor de escenas. Todo lo demás es simplemente escribir las escenas en sí.

Necesitamos un nivel para jugar. Cada nivel es un arreglo de 8 cadenas — W para pared, G para gema, . para piso, @ para el inicio del jugador. Un helper parseLevel extrae lo que necesitamos:

let level0 = [
    'WWWWWWWW',
    'W......W',
    'W.G..G.W',
    'W......W',
    '[email protected]',
    'W.G..G.W',
    'W......W',
    'WWWWWWWW',
];

function parseLevel(data) {
    let walls = [];
    let gems = [];
    let startX = 0;
    let startY = 0;
    for (let y = 0; y < data.length; y++) {
        for (let x = 0; x < data[y].length; x++) {
            let ch = data[y][x];
            if (ch === 'W') walls.push({ x: x, y: y });
            if (ch === 'G') gems.push({ x: x, y: y });
            if (ch === '@') { startX = x; startY = y; }
        }
    }
    return { walls: walls, gems: gems, startX: startX, startY: startY };
}

Ahora escribamos las tres escenas. La pantalla de título espera Z y cambia al juego. La escena del juego analiza el nivel al iniciar, maneja el movimiento y la recolección de gemas, y cambia a gameover cuando todas las gemas desaparecen. La pantalla de gameover espera Z y vuelve al título:

scenes.title = {
    update: function () {
        if (btnp('z')) switchTo('game');
    },
    draw: function () {
        cls(1);
        text('COLLECT THE GEMS', 16, 40, 7);
        text('PRESS Z TO START', 16, 56, 6);
    },
};

scenes.game = {
    init: function () {
        let parsed = parseLevel(level0);
        walls = parsed.walls;
        gems = parsed.gems;
        playerX = parsed.startX;
        playerY = parsed.startY;
    },
    update: function () {
        let nx = playerX;
        let ny = playerY;
        if (btnp('ArrowLeft')) nx--;
        if (btnp('ArrowRight')) nx++;
        if (btnp('ArrowUp')) ny--;
        if (btnp('ArrowDown')) ny++;

        if (nx === playerX && ny === playerY) return;

        for (let w of walls) {
            if (w.x === nx && w.y === ny) return;
        }

        playerX = nx;
        playerY = ny;

        gems = gems.filter(function (g) {
            return !(g.x === playerX && g.y === playerY);
        });

        if (gems.length === 0) switchTo('gameover');
    },
    draw: function () {
        cls(1);

        for (let w of walls) {
            rectfill(w.x * 16, w.y * 16, w.x * 16 + 15, w.y * 16 + 15, 5);
        }

        for (let g of gems) {
            circfill(g.x * 16 + 8, g.y * 16 + 8, 4, 10);
        }

        rectfill(playerX * 16 + 2, playerY * 16 + 2, playerX * 16 + 13, playerY * 16 + 13, 12);

        caption(gems.length + ' gems left');
    },
};

scenes.gameover = {
    update: function () {
        if (btnp('z')) switchTo('title');
    },
    draw: function () {
        cls(1);
        text('LEVEL COMPLETE!', 20, 40, 11);
        text('PRESS Z', 44, 56, 6);
    },
};

Cada escena es independiente. El título no sabe cómo funciona el juego, y el juego no sabe cómo se ve la pantalla de gameover. ¿Quieres una nueva pantalla? Agrega un nuevo objeto a scenes y llama a switchTo para llegar ahí.

Scene Management: A Simple Scene Registry
Flechas para mover. Recoge todas las gemas para completar el nivel. Z para iniciar y reiniciar.

Construyendo una Selección de Niveles

Un solo nivel no es mucho juego. Vamos a agregar cinco más y una pantalla para elegir entre ellos.

Primero, los datos de los niveles. Cada nivel usa el mismo formato de cadenas — diferentes disposiciones de paredes, diferentes ubicaciones de gemas:

let levels = [level0, level1, level2, level3, level4, level5];

La escena de selección de niveles dibuja una cuadrícula de 3x2 cajas. Una variable cursor rastrea cuál está resaltada, las flechas la mueven, y Z inicia el nivel seleccionado:

let cursor = 0;
let currentLevel = 0;

scenes.select = {
    init: function () {},
    update: function () {
        if (btnp('ArrowRight')) cursor = (cursor + 1) % 6;
        if (btnp('ArrowLeft')) cursor = (cursor + 5) % 6;
        if (btnp('ArrowDown')) cursor = (cursor + 3) % 6;
        if (btnp('ArrowUp')) cursor = (cursor + 3) % 6;
        if (btnp('z')) {
            currentLevel = cursor;
            switchTo('game', { level: cursor });
        }
    },
    draw: function () {
        cls(1);
        text('SELECT LEVEL', 24, 8, 7);

        for (let i = 0; i < 6; i++) {
            let col = i % 3;
            let row = Math.floor(i / 3);
            let bx = 8 + col * 44;
            let by = 32 + row * 48;

            rectfill(bx, by, bx + 31, by + 35, 5);
            text('' + (i + 1), bx + 12, by + 14, 7);

            if (i === cursor) {
                rect(bx - 1, by - 1, bx + 32, by + 36, 10);
            }
        }

        text('Z TO PLAY', 36, 120, 6);
    },
};

El cursor se envuelve usando aritmética modular — sumar 5 mod 6 es lo mismo que restar 1 con envolvimiento, y sumar 3 mod 6 salta entre filas (ya que tenemos 3 columnas).

Aquí está la parte interesante: los datos fluyen entre escenas. Cuando el jugador elige un nivel, switchTo('game', { level: cursor }) pasa el índice del nivel. El init de la escena del juego lo lee:

scenes.game = {
    init: function (data) {
        let idx = data.level !== undefined ? data.level : 0;
        let parsed = parseLevel(levels[idx]);
        walls = parsed.walls;
        gems = parsed.gems;
        playerX = parsed.startX;
        playerY = parsed.startY;
    },
    // ...
};

Ahora el título va a 'select' en lugar de directamente a 'game', y gameover vuelve a 'select' para que el jugador pueda elegir otro nivel.

Scene Management: Building a Level Select
Flechas para navegar la cuadrícula de selección. Z para elegir un nivel.

Seguimiento de Progreso

Ahora mismo todos los niveles están disponibles desde el inicio, y no hay indicación de cuáles has completado. Arreglemos ambas cosas.

Rastrearemos la completación en un arreglo de booleanos — una posición por nivel. El nivel 0 siempre está desbloqueado, y cada nivel subsiguiente se desbloquea cuando completas el anterior:

let completed = [false, false, false, false, false, false];

function isUnlocked(i) {
    if (i === 0) return true;
    return completed[i - 1];
}

Cuando el jugador termina un nivel, la escena del juego pasa el índice del nivel a gameover, que lo marca como completado:

// en el update de la escena del juego, cuando se recogen todas las gemas:
if (gems.length === 0) switchTo('gameover', { level: currentLevel });

// init de la escena gameover:
scenes.gameover = {
    init: function (data) {
        if (data.level !== undefined) completed[data.level] = true;
    },
    // ...
};

El mismo patrón switchTo(name, data) de antes — solo llevando información diferente esta vez.

La selección de niveles ahora dibuja tres estados visuales. Los niveles completados tienen fondo verde, los desbloqueados se quedan grises, y los bloqueados son negros con un guion en lugar de número. Z solo funciona en niveles desbloqueados:

if (completed[i]) {
    rectfill(bx, by, bx + 31, by + 35, 3);
    text('' + (i + 1), bx + 12, by + 14, 11);
} else if (isUnlocked(i)) {
    rectfill(bx, by, bx + 31, by + 35, 5);
    text('' + (i + 1), bx + 12, by + 14, 7);
} else {
    rectfill(bx, by, bx + 31, by + 35, 0);
    text('-', bx + 14, by + 14, 5);
}

El pie de página también cambia — "Z TO PLAY" cuando el cursor está en un nivel desbloqueado, "LOCKED" cuando no. Y cuando todos los niveles están completados, muestra "ALL LEVELS CLEARED!" en su lugar.

Algo a tener en cuenta: el progreso vive en variables regulares, así que se reinicia cuando la página se recarga. Si quieres persistencia, el tutorial de guardar y cargar cubre el almacenamiento del estado del juego entre sesiones. Los dos patrones se combinan naturalmente — guarda el arreglo completed en una cookie o localStorage, cárgalo de vuelta en el init.

Scene Management: Tracking Progress
Completa niveles para desbloquear el siguiente. Los niveles completados se ponen verdes. Los bloqueados muestran un guion.

Agregando Transiciones

Saltar entre escenas instantáneamente funciona, pero se siente abrupto. Una transición de barrido hace que el cambio se sienta deliberado. El enfoque: un rectángulo negro crece de izquierda a derecha (barrido de entrada), la escena cambia cuando la pantalla está completamente cubierta, luego el rectángulo se reduce (barrido de salida).

Necesitamos algunas variables para rastrear la transición — en qué fase estamos (barrido de entrada o salida), qué tan ancho es el rectángulo negro, y a qué escena cambiar en el punto medio:

let transitioning = false;
let transPhase = 0;
let transProgress = 0;
let transTarget = null;
let transData = null;
const TRANS_SPEED = 8;

function transitionTo(name, data) {
    if (transitioning) return;
    transitioning = true;
    transPhase = 0;
    transProgress = 0;
    transTarget = name;
    transData = data || {};
}

Esa guarda al inicio de transitionTo importa. Sin ella, presionar Z dos veces rápido podría iniciar una segunda transición antes de que la primera termine. btnp se dispara una vez por pulsación, pero si una escena lo verifica antes de que la transición empiece a consumir frames, obtienes un doble disparo.

El update de nivel superior maneja la animación. Durante una transición, ningún update de escena se ejecuta — el juego se congela mientras el barrido se reproduce:

function update() {
    if (transitioning) {
        if (transPhase === 0) {
            transProgress += TRANS_SPEED;
            if (transProgress >= 128) {
                transProgress = 128;
                switchTo(transTarget, transData);
                transPhase = 1;
            }
        } else {
            transProgress -= TRANS_SPEED;
            if (transProgress <= 0) {
                transProgress = 0;
                transitioning = false;
            }
        }
        return;
    }
    if (scenes[current] && scenes[current].update) scenes[current].update();
}

El dibujado es más simple — la escena actual se dibuja, y superponemos el rectángulo negro encima:

function draw(time, frame) {
    if (scenes[current] && scenes[current].draw) scenes[current].draw(time, frame);

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

La escena se dibuja primero, luego el barrido cubre parte (o todo) de ella. A 8 píxeles por frame en un canvas de 128 píxeles, el barrido completo toma 32 frames — aproximadamente medio segundo. Lo suficientemente rápido para no sentirse lento, lo suficientemente largo para registrarse como una transición.

Cada lugar que usaba switchTo directamente ahora usa transitionTo en su lugar. switchTo sigue existiendo internamente — transitionTo lo llama en el punto medio cuando la pantalla está completamente negra.

Scene Management: Adding Transitions
Igual que arriba pero con una transición de barrido de izquierda a derecha entre todos los cambios de escena.

Para Ir Más Allá

  • Menú de pausa — En lugar de reemplazar la escena actual, apila una escena de pausa en un stack. La pantalla de pausa se dibuja sobre el juego, y al quitarla se reanuda donde lo dejaste. El mismo patrón init/update/draw, solo con un arreglo en lugar de una sola variable current.
  • Progreso persistente — Combina esto con el tutorial de guardar y cargar para almacenar el arreglo completed en una cookie o localStorage. Cárgalo de vuelta en el init de la selección de niveles para que el progreso sobreviva las recargas de página.
  • Transiciones animadas — El barrido aquí es un simple rectángulo de izquierda a derecha. Prueba un barrido diagonal, un efecto iris (un círculo que crece o se reduce desde el centro), o un fundido a través de un color. La estructura de dos fases funciona para cualquier animación — solo cambia lo que dibujas durante cada fase.
  • Música por escena — Llama a music() en el init de cada escena para iniciar una pista diferente. Detén la pista al salir de la escena, o deja que la transición maneje el crossfade.