Skip to main content

Cómo construir niveles a partir de pixel art

Todos los tutoriales de raycaster hasta ahora han usado arrays 2D escritos a mano para los mapas de niveles. Eso está bien para demos pequeñas, pero intenta diseñar una mazmorra de 16x16 escribiendo unos y ceros. Vas a querer rendirte antes de terminar el primer pasillo.

La cuestión es que el motor ya entiende arrays planos de índices de color. Así funcionan los sprites. Entonces, ¿y si usáramos un sprite como mapa, donde cada color de píxel representa un tipo de tile diferente? Pinta una imagen diminuta, y el código la lee como un nivel.

También vamos a abordar una segunda limitación: hasta ahora, cada pared, póster y prop ha sido un único sprite de 8x8. Eso son 64 píxeles de detalle. Al componer múltiples sprites en texturas más grandes, podemos duplicar o cuadruplicar la fidelidad sin cambiar el motor.

Vas a querer estar cómodo con los fundamentos de raycasting de los tutoriales anteriores, especialmente Poster Decals and Props. Todo aquí se construye directamente sobre esos patrones.

El punto de partida

Aquí tenemos un raycaster conocido — mapa de 8x8, paredes con texturas, pósteres como decals y props de barriles. El mapa es un array 2D escrito a mano, y cada elemento del juego se define por separado:

const MapSize = 8;

const Map = [
    [1, 1, 1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 1, 0, 0, 1],
    [1, 0, 0, 0, 1, 0, 0, 1],
    [1, 0, 0, 0, 0, 0, 0, 1],
    [1, 1, 0, 0, 0, 0, 1, 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],
];

const Decals = [
    { mx: 4, my: 1, side: 1 },
    { mx: 1, my: 4, side: 0 },
];

const Props = [
    { x: 2.5, y: 1.5 },
    { x: 5.5, y: 3.5 },
    { x: 2.5, y: 5.5 },
];

let px = 1.5;
let py = 1.5;

El array del mapa, la lista de decals, la lista de props y las coordenadas de aparición tienen que mantenerse sincronizados manualmente. Mueve una pared y puede que necesites actualizar la posición de un decal para que coincida. Añade una habitación y estarás calculando nuevas coordenadas de props a mano.

Para 8x8 esto es manejable. Para cualquier cosa más grande, se vuelve doloroso rápido.

PNG Level Design: The Starting Point
El raycaster de punto de partida

Pintando un sprite de mapa

Un sprite es un array plano de índices de color. Un mapa es una cuadrícula 2D de tipos de tile. Son la misma cosa — un píxel por tile, un color por tipo. Así que vamos a definir un "sprite de mapa" donde cada índice de color significa algo:

Color Significado
0 Suelo vacío
1 Pared
8 Aparición del jugador
9 Prop
11 Pared con decal

Aquí está el sprite de mapa para ese mismo nivel de 8x8:

const mapSprite = [
    1,  1,  1,  1,  1,  1,  1,  1,
    1,  8,  0,  9,  1,  0,  0,  1,
    1,  0,  0,  0, 11,  0,  0,  1,
    1,  0,  0,  0,  0,  0,  9,  1,
    1, 11,  0,  0,  0,  0,  1,  1,
    1,  0,  0,  0,  0,  0,  0,  1,
    1,  0,  0,  9,  0,  0,  0,  1,
    1,  1,  1,  1,  1,  1,  1,  1,
];

Todo está en un solo lugar. Paredes, props, decals y el punto de aparición son todos visibles en la misma cuadrícula. Vamos a escribir una función que lea este sprite y construya todos los datos del juego a partir de él:

function parseMapSprite(sprite, size) {
    const map = [];
    const props = [];
    const decals = [];
    let spawnX = 1.5, spawnY = 1.5;

    for (let y = 0; y < size; y++) {
        map[y] = [];
        for (let x = 0; x < size; x++) {
            const color = sprite[y * size + x];
            switch (color) {
                case 0: map[y][x] = 0; break;
                case 1: map[y][x] = 1; break;
                case 8:
                    map[y][x] = 0;
                    spawnX = x + 0.5;
                    spawnY = y + 0.5;
                    break;
                case 9:
                    map[y][x] = 0;
                    props.push({ x: x + 0.5, y: y + 0.5 });
                    break;
                case 11:
                    map[y][x] = 1;
                    decals.push({
                        mx: x, my: y,
                        side: x === 0 || sprite[y * size + (x - 1)] === 0 ? 0 : 1,
                    });
                    break;
                default: map[y][x] = 0; break;
            }
        }
    }
    return { map, props, decals, spawnX, spawnY };
}

Recorremos cada píxel. Las paredes y los suelos van directamente al array del mapa. La aparición (color 8) registra la posición inicial del jugador y deja suelo debajo. Los props (color 9) añaden una entrada con coordenadas del mundo en el centro de esa celda. Las paredes con decal (color 11) colocan una pared y determinan en qué cara debe aparecer el decal comprobando si la celda vecina a la izquierda está abierta.

Una sola llamada reemplaza todos los datos escritos a mano:

const { map: Map, props: Props, decals: Decals, spawnX, spawnY } =
    parseMapSprite(mapSprite, MapSize);

let px = spawnX;
let py = spawnY;

El resultado es visualmente idéntico a la versión anterior. Las mismas paredes, los mismos props, los mismos decals — pero todo viene de una única fuente de verdad.

PNG Level Design: Painting a Map Sprite
Pintando un sprite de mapa

Diseñando un nivel más grande

Aquí es donde vale la pena. Vamos a escalar a un sprite de mapa de 16x16 — cuatro veces el área, con habitaciones conectadas por puertas, props dispersos por el espacio y paredes con decals marcando puntos de interés:

const MapSize = 16;

const mapSprite = [
    1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,
    1,  8,  0,  0,  0,  1,  0,  0,  0,  0,  1,  0,  0,  0,  0,  1,
    1,  0,  0,  0,  0,  1,  0,  9,  0,  0,  1,  0,  0,  0,  0,  1,
    1,  0,  0,  9,  0,  0,  0,  0,  0,  0, 11,  0,  0,  9,  0,  1,
    1,  0,  0,  0,  0,  1,  0,  0,  0,  0,  1,  0,  0,  0,  0,  1,
    1,  1, 11,  0,  1,  1,  1,  0,  0,  1,  1,  1,  0, 11,  1,  1,
    1,  0,  0,  0,  0,  1,  0,  0,  0,  0,  0,  0,  0,  0,  0,  1,
    1,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  1,
    1,  0,  9,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  9,  0,  1,
    1,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  1,
    1,  0,  0,  0,  0,  1,  0,  0,  0,  0,  0,  1,  0,  0,  0,  1,
    1,  1, 11,  0,  1,  1,  0,  0,  0,  0,  0,  1,  1,  0, 11,  1,
    1,  0,  0,  0,  0,  1,  0,  0,  0,  0,  0,  1,  0,  0,  0,  1,
    1,  0,  9,  0,  0,  0,  0,  0,  9,  0,  0,  0,  0,  0,  9,  1,
    1,  0,  0,  0,  0,  1,  0,  0,  0,  0,  0,  1,  0,  0,  0,  1,
    1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,  1,
];

Intenta diseñar esto a mano como un array 2D de filas [1,0,0,1,...]. Luego intenta leerlo para averiguar dónde están los props. La versión con sprite de mapa es inmediatamente legible — puedes ver las formas de las habitaciones, las puertas y la colocación de objetos de un vistazo.

El único código que cambia son las constantes. Un mapa de 16x16 necesita un tamaño de celda de minimapa más pequeño (1 píxel por celda en lugar de 3) para que siga cabiendo en la esquina:

const Cell = 1;
const MmX = 112;
const MmY = 112;

parseMapSprite no cambia en absoluto. Pásale un array de 256 elementos y un tamaño de 16 en lugar de 64 elementos y 8, y produce los mismos tipos de salidas — solo que más. Ese es todo el punto: el diseño de niveles escala, pero el código de parseo se mantiene igual.

Mira el minimapa en la esquina. Es un espejo directo del sprite de mapa — cada píxel corresponde a un valor en el array. Lo que pintas es lo que obtienes.

PNG Level Design: Designing a Bigger Level
Diseñando un nivel más grande

Texturas de pared multi-sprite

Hasta ahora, cada textura de pared ha sido un array plano de 8x8 — 64 valores, 8 píxeles de alto. Eso es suficiente para sugerir un patrón de ladrillos, pero no lo bastante para detalle real. ¿Y si apilamos dos sprites de 8x8 verticalmente en una textura de 8x16?

Los datos son simplemente un array más largo. Una textura de 8x8 tiene 64 elementos. Una de 8x16 tiene 128 — la mitad superior seguida de la inferior:

const TallWallTexture = [
    // top 8 rows (first sprite)
    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,
    // bottom 8 rows (second sprite)
    5, 5, 5, 5, 5, 5, 5, 5,
    4, 4, 15, 4, 4, 5, 4, 4,
    4, 15, 4, 4, 4, 5, 4, 4,
    5, 5, 5, 5, 5, 5, 5, 5,
    4, 4, 4, 5, 4, 4, 15, 4,
    4, 4, 4, 5, 4, 15, 4, 4,
    5, 5, 5, 5, 5, 5, 5, 5,
    4, 4, 4, 4, 4, 5, 4, 4,
];

La mitad inferior tiene motas claras (color 15) que le dan a los ladrillos un aspecto desgastado. No podíamos encajar ese detalle en 8 filas.

El bucle de renderizado necesita dos cambios. Primero, el paso de textura se duplica porque estamos muestreando 16 filas en lugar de 8:

const texStep = 16 / stripeH;

Segundo, ty ahora va de 0 a 15 en lugar de 0 a 7. El índice en el array de textura sigue siendo ty * 8 + tx porque la textura sigue teniendo 8 píxeles de ancho:

for (let sy = drawStart; sy < drawEnd; sy++) {
    const ty = Math.min(15, Math.floor(texPos));
    texPos += texStep;

    let color = TallWallTexture[ty * 8 + tx];
    if (color < 0) continue;

    // ...oscurecer y dibujar como antes
}

Eso es todo. Sin cambios en el motor, sin nueva pipeline de renderizado — solo un array más largo y matemáticas ajustadas. Las paredes ahora tienen el doble de detalle vertical.

PNG Level Design: Multi-Sprite Wall Textures
Texturas de pared multi-sprite

Props y pósteres multi-sprite

El mismo truco de composición funciona para pósteres y props. Un póster de 16x16 son cuatro sprites de 8x8 organizados en una cuadrícula de 2x2 — 256 elementos, 16 de ancho y 16 de alto. El renderizado del decal muestrea de la textura más grande calculando ambas coordenadas a la resolución mayor:

if (decal) {
    const ptx = Math.floor(wallHit * 16);
    const dc = LargePosterTexture[ty * 16 + ptx];
    if (dc >= 0) color = dc;
}

ty ya va de 0 a 15 por el muestreo de pared alta. La coordenada x del póster (ptx) mapea la posición de impacto en la pared a 16 columnas en lugar de 8. El índice es ty * 16 + ptx porque el póster tiene 16 de ancho.

Para los props, un sprite de 8x16 nos da un barril más alto que ancho. Los datos del sprite tienen 128 elementos — 8 columnas, 16 filas — con píxeles transparentes arriba para que el barril aparezca más bajo que las paredes:

const TallPropSprite = [
    -1, -1, -1, -1, -1, -1, -1, -1,
    -1, -1, -1, -1, -1, -1, -1, -1,
    -1, -1, -1, -1, -1, -1, -1, -1,
    -1, -1, -1, -1, -1, -1, -1, -1,
    -1, -1,  5,  4,  4,  5, -1, -1,
    -1,  5,  4,  9,  9,  4,  5, -1,
    -1,  4,  9,  4,  4,  9,  4, -1,
    -1,  5,  5,  5,  5,  5,  5, -1,
    -1,  4,  9,  4,  4,  9,  4, -1,
     4,  4,  9,  4,  4,  9,  4,  4,
     4,  4,  9,  4,  4,  9,  4,  4,
    -1,  4,  9,  4,  4,  9,  4, -1,
    -1,  5,  5,  5,  5,  5,  5, -1,
    -1,  4,  9,  4,  4,  9,  4, -1,
    -1,  5,  4,  4,  4,  4,  5, -1,
    -1, -1,  5,  5,  5,  5, -1, -1,
];

El renderizado de billboard para props altos necesita dos ajustes. Primero, queremos que el prop se apoye en el suelo en lugar de flotar a la altura de los ojos. Calculamos dónde está el suelo a esa distancia y anclamos la parte inferior del sprite ahí:

const wallH = Math.floor(128 / transformY);
const sprW = Math.floor(wallH / 2);
const sprH = Math.floor(wallH / 2);
const floorY = Math.floor(64 + wallH / 2);

const drawStartY = Math.max(0, floorY - sprH);
const drawEndY = Math.min(128, floorY);

Segundo, la coordenada y de textura mapea a 16 filas en lugar de 8, y necesita ser limitada para prevenir un artefacto sutil en los bordes. Cuando Math.floor recibe un valor ligeramente negativo, lee de la fila de textura incorrecta:

const ty = Math.min(15, Math.max(0, Math.floor(
    ((y - (floorY - sprH)) * 16) / sprH,
)));

El Math.max(0, ...) es la corrección clave. Sin él, cuando el ancho del sprite es impar, la primera columna renderizada calcula un desplazamiento fraccionario que se vuelve ligeramente negativo. Math.floor de -0.1 es -1, lo que lee el elemento ty * 8 - 1 — el último píxel de la fila anterior. Eso produce líneas finas de basura en los bordes del sprite. El mismo limitado se aplica a tx:

const tx = Math.min(7, Math.max(0, Math.floor(
    ((stripe - (screenX - sprW / 2)) * 8) / sprW,
)));

Con niveles de pixel art y texturas multi-sprite, tenemos un kit de herramientas completo: pinta un sprite de mapa para diseñar tu mundo, luego construye paredes, pósteres y props detallados a partir de sprites compuestos. Todo funciona sobre el mismo raycaster — sin cambios en el motor.

PNG Level Design: Multi-Sprite Props and Posters
Props y pósteres multi-sprite

Yendo más allá

Aquí hay algunas direcciones en las que podrías llevar esto:

Múltiples tipos de pared. La leyenda de colores solo usa un puñado de los 16 índices de paleta disponibles. Asigna los colores 2, 3 y 4 a diferentes texturas de pared y selecciona el array correcto según el valor del mapa durante el renderizado. Paredes de piedra, paneles de madera, placas de metal — cada uno tiene su propio color en el sprite de mapa.

Texturas animadas. Almacena dos o más versiones de una textura de pared y alterna entre ellas con un temporizador. Una antorcha que parpadea entre dos estados, o un cartel que pulsa entre brillante y tenue — un poco de vida hace mucho.

Sprites de detalle por capas. Apila una textura de pared base con una superposición semitransparente para grietas de daño, musgo o efectos climáticos. Renderiza la base primero, luego compón la superposición encima — cualquier píxel transparente (-1) en la superposición deja que la textura base se vea a través.

Editor de niveles externo. Herramientas como Aseprite o Piskel pueden exportar pixel art como arrays planos. Diseña tu nivel visualmente en un editor, exporta los datos y pégalos en el código de tu juego. El formato de sprite de mapa se mapea directamente a datos de imagen con color indexado.

Cambio de nivel en tiempo de ejecución. Almacena múltiples sprites de mapa y llama a parseMapSprite() para intercambiar niveles sobre la marcha. Combinado con las técnicas del tutorial Scene and Level Management, puedes construir un juego con múltiples niveles donde cada nivel es un solo sprite.