Capítulo 15. Tilemaps con bevy_ecs_tilemap: el mundo como matriz

CAP 15 · Bevy 0.18/0.19
"El mejor mapa no es el que tiene los píxeles más bonitos, sino el que se cambia con dos líneas de JSON."

— Anónimo, en una reunión de diseño, 1998

¿Sabías que el primer juego con scrolling tile-based fue *Rogue* (1980, Michael Toy y Glenn Wichman, con ayuda de Ken Arnold), originalmente escrito en una PDP-11 de la Universidad de Stanford? Sí, eso que ahora haces con `bevy_ecs_tilemap` nació como un programa universitario que dibujaba símbolos ASCII en una terminal.

Vamos a hablar de tilemaps. Suena aburrido — "pones cuadritos en una rejilla" — pero los tilemaps son el equivalente 2D de un sistema de nivel procedural: si sabes manejarlos, puedes construir un plataformas de 100 niveles en una tarde. Si no los sabes manejar, acabarás pegando sprites uno a uno y llorando sobre SpriteBundle a las 3 de la mañana.

En este capítulo vamos a:


15.1 ¿Qué es un tilemap, realmente?

Un tilemap es una estructura que dice: "en la posición lógica (x=4, y=2) de mi mundo, dibuja el tile número 137 de mi atlas". Es una matriz de índices, no un bitmap. Esa distinción es importantísima:

ConceptoBitmap (PNG)Tilemap (matriz)
Memoria por tile4 bytes (RGBA)2 bytes (u16) o menos
ReordenableNo, está cocinadoSí, cambias un índice
ColisionesTienes que detectarlas por píxelLas defines por celda
Memoria para 100×10040.000 bytes20.000 bytes
**Tile**: un cuadradito de tamaño fijo (típicamente 16×16, 32×32 o 64×64) que viene de un atlas. Es la "letra" del alfabeto visual.

**Atlas (o tileset)**: la imagen grande que contiene todos los tiles juntos. Como un abecedario de letras en una sola hoja.

**Chunk**: un bloque de tiles contiguos (por ejemplo 16×16 tiles) que Bevy trata como una sola entidad para temas de batching. Es la "página" del libro.

**Capa (layer)**: un tilemap independiente apilado sobre otro. El suelo es una capa, los adornos otra, las colisiones otra. Puedes activar/desactivar capas enteras sin tocar las demás.

Piénsalo así: si tus tiles son letras, el atlas es el abecedario entero en una hoja, el chunk es una página del libro, y la capa es el párrafo. Mezclas letras para escribir palabras — decides si la palabra es "suelo", "pared" o "lava".


15.2 El crate bevy_ecs_tilemap

Añádelo a tu Cargo.toml:

[dependencies]
bevy = { version = "0.19", features = ["2d"] }
bevy_ecs_tilemap = "0.19.0"   # ← sincronizado con la versión de Bevy.
**Crates.io**: el registro de paquetes de Rust. Piénsalo como la Play Store pero sin anuncios ni micropagos. Todo lo que no sea `bevy` puro vive aquí.

**Alternativa — `bevy_ecs_tiled` 0.13**: si trabajas con mapas del editor **Tiled** (`.tmx`/`.tsx`), el crate `bevy_ecs_tiled = "0.13"` (compatible con Bevy 0.19) está más activamente mantenido y expone directamente las capas, objetos y propiedades de Tiled como entidades. Es complementario a `bevy_ecs_tilemap`: este último expone la API de bajo nivel (storage, chunks, tiles como entidades); `bevy_ecs_tiled` se apoya en él para ofrecer el loader `.tmx` listo para usar.

Importante: bevy_ecs_tilemap se versiona al ritmo de Bevy (a diferencia de otros crates del ecosistema). Para Bevy 0.19, la versión correcta es 0.19.0. Versiones antiguas como "0.18" o "0.17" no existen publicadas; si las veías en ejemplos viejos, apuntaban a release candidates. Si te equivocas, el compilador te dirá cosas como "trait bound TilemapSize: From<...> is not satisfied" y tú mirarás al techo preguntándote qué hiciste mal.

Error clásico: instalar `bevy_ecs_tilemap = "0.18"` con Bevy 0.19 (o viceversa). El crate usa tipos internos de Bevy que cambiaron entre versiones y tu proyecto **compila** a veces, pero al ejecutar peta con un panic dentro del render plugin. Si tu mapa no aparece y solo ves cuadrados rosas, revisa el lockfile: la versión del crate tiene que coincidir con la versión de Bevy que uses.

15.3 Tu primer tilemap funcional

Vamos a montar el "Hola Mundo" tile-based. Un atlas de 3 tiles, un chunk de 10×10, una sola capa. Nada de optimización todavía.

use bevy::prelude::*;
use bevy_ecs_tilemap::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(TilemapPlugin)
        .add_systems(Startup, spawn_tilemap)
        .run();
}

fn spawn_tilemap(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    // 1) Cargamos la textura del atlas.
    let texture_handle: Handle<Image> = asset_server.load("tileset.png");

    // 2) Creamos un mapa de tipos de tile (TilemapType).
    //    Square = eje X a la derecha, Y abajo (estilo Pokémon).
    let map_type = TilemapType::Square;

    // 3) Definimos el tamaño total del mapa en tiles.
    let map_size = TilemapSize { x: 32, y: 32 };

    // 4) Tamaño de cada tile en píxeles.
    let tile_size = TilemapTileSize { x: 16.0, y: 16.0 };

    // 5) Tamaño de cada chunk (cuántos tiles por chunk).
    let chunk_size = TilemapChunkSize { x: 16, y: 16 };

    // 6) Tamaño del atlas en tiles (¡cuántos tiles tiene el PNG!).
    let atlas_size = TilemapAtlasSize { x: 4, y: 4 };

    // 7) Creamos el storage: la "matriz" en sí.
    let mut storage = TileStorage::empty(map_size);
    let tilemap_entity = commands.spawn_empty().id();

    // 8) Rellenamos celdas a mano. Para 32×32, lo ideal es un bucle.
    for x in 0..32 {
        for y in 0..32 {
            let tile_pos = TilePos { x, y };
            let tile_index = if y == 0 { 0 } else { 1 }; // suelo en la base
            let tile_entity = commands.spawn(TileBundle {
                position: tile_pos,
                tilemap_id: TilemapId(tilemap_entity),
                texture_index: TileTextureIndex(tile_index),
                ..default()
            }).id();
            storage.set(&tile_pos, tile_entity);
        }
    }

    // 9) Asociamos el storage a la entidad tilemap.
    commands.entity(tilemap_entity).insert(TilemapBundle {
        grid_size: TilemapGridSize { x: 16.0, y: 16.0 },
        size: map_size,
        storage,
        map_type,
        tile_size,
        atlas_size,
        texture: TilemapTexture::Single(texture_handle),
        transform: Transform::from_translation(Vec3::ZERO),
        ..default()
    });

    // 10) Una cámara 2D para ver todo.
    commands.spawn(Camera2d);
}

Si lo ejecutas verás un plano de 32×32 tiles donde la fila de abajo es de un color y el resto de otro. No es espectacular, pero es un tilemap real.


15.4 Chunks: por qué existen

"Vale, tengo 32×32 tiles. ¿Para qué quiero chunks?"

Imagina que tienes un mapa de 256×256 tiles. Son 65.536 entidades. Si mueves un solo tile, Bevy tiene que reordenar todo el batch de render. Con chunks, Bevy trata cada chunk como una sola entidad a efectos de batching. Mover un tile dentro del chunk es un cambio local; mover el chunk entero es un cambio global.

**Batching**: técnica del renderer por la cual agrupa muchas cosas con la misma textura/transform en un solo draw call. Menos draw calls = más FPS. Los chunks te dan batching gratis.

Reglas de oro:


15.5 Capas: el tercer nivel de la cebolla

Un tilemap de una sola capa no te sirve para casi nada. Necesitas al menos:

  1. Capa de suelo (no se mueve, no colisiona).
  2. Capa de decoración (no se mueve, está encima del suelo).
  3. Capa de colisión (no se dibuja, solo existe para el sistema de física).
#[derive(Component, Default)]
struct GroundLayer;

#[derive(Component, Default)]
struct DecorLayer;

#[derive(Component, Default)]
struct CollisionLayer;

fn spawn_layer(
    commands: &mut Commands,
    asset_server: &Res<AssetServer>,
    layer_marker: impl Component,
    texture_path: &str,
    z_offset: f32,
    map_size: TilemapSize,
) -> Entity {
    let tilemap_entity = commands.spawn(layer_marker).id();
    let mut storage = TileStorage::empty(map_size);

    for x in 0..map_size.x {
        for y in 0..map_size.y {
            let pos = TilePos { x, y };
            let tile = commands.spawn(TileBundle {
                position: pos,
                tilemap_id: TilemapId(tilemap_entity),
                ..default()
            }).id();
            storage.set(&pos, tile);
        }
    }

    commands.entity(tilemap_entity).insert(TilemapBundle {
        grid_size: TilemapGridSize { x: 16.0, y: 16.0 },
        size: map_size,
        storage,
        map_type: TilemapType::Square,
        tile_size: TilemapTileSize { x: 16.0, y: 16.0 },
        atlas_size: TilemapAtlasSize { x: 4, y: 4 },
        texture: TilemapTexture::Single(asset_server.load(texture_path)),
        transform: Transform::from_translation(Vec3::new(0.0, 0.0, z_offset)),
        ..default()
    });

    tilemap_entity
}
¿Sabías que *Super Mario Bros* (1985, Nintendo R&D4, dirigido por Shigeru Miyamoto y Takashi Tezuka) usaba **3 capas de scrolling** simultáneas? Una para el fondo (las montañas), otra para la "banda media" (los arbustos) y otra para el primer plano. Cada capa se movía a velocidad distinta para dar sensación de profundidad. Eso, querido lector, **es el patrón de capas**, 39 años antes de que tú lo descubrieras.

15.6 Animaciones de tiles: agua, lava, antorchas

La forma estándar en bevy_ecs_tilemap es el componente AnimatedTile, que se inserta directamente en la entidad de cada tile que querés animar. Internamente, un sistema del plugin cambia el TileTextureIndex de esos tiles según un timer global; vos solo declarás el rango de frames y la velocidad.

Cuidado con las APIs inventadas: en foros viejos vas a encontrar referencias a TilemapFrame, TileAnimation o Vec<TilemapFrame>. Esos tipos NO existen en el crate real. El componente estándar y documentado es AnimatedTile con tres campos: start, end y fps.

//! cap-15 — sección 15.6: animar tiles con AnimatedTile.
use bevy_ecs_tilemap::prelude::*;

// `AnimatedTile` se inserta en la entidad del tile individual:
//   start: índice del primer frame de la animación en el atlas.
//   end:   índice del último frame (inclusive).
//   fps:   cuadros por segundo a los que ciclar.
// El plugin actualiza `TileTextureIndex` solo.
fn tag_water_tiles_as_animated(
    mut commands: Commands,
    water_tiles: Query<Entity, With<WaterTile>>,
) {
    for tile in &water_tiles {
        commands.entity(tile).insert(AnimatedTile {
            start: 0,   // primer frame del agua en el atlas.
            end:   3,   // último frame (4 frames en total).
            fps:   4,   // 4 fps = 250 ms por frame.
        });
    }
}

// Marcador de componente propio para distinguir tiles de agua
// del resto (los queryamos para insertarles AnimatedTile).
#[derive(Component)]
struct WaterTile;
**AnimatedTile { start, end, fps }**: componente estándar de `bevy_ecs_tilemap`. Se inserta en la entidad del tile y le dice al plugin "tu `TileTextureIndex` debe ciclar entre `start` y `end` a `fps` cuadros por segundo". Bevy lo actualiza solo; no escribís ningún Timer.

Si querés animar "toda una capa" o un chunk entero (ej.: una cascada), iterás los tiles que pertenecen a esa capa (filtrando por un componente marcador, como WaterTile arriba) y les insertás AnimatedTile. No existe un componente separado para "animar el chunk": la granularidad es por tile, y el batching del plugin absorbe el coste.


15.7 Carga desde JSON (hecho a mano)

A veces quieres algo rápido sin instalar un editor externo. Un JSON minimal:

{
  "tile_size": 16,
  "map_size": [32, 32],
  "atlas": "tileset.png",
  "atlas_size": [4, 4],
  "layers": [
    {
      "name": "ground",
      "tiles": [
        [0, 0, 0, 0, 0],
        [1, 1, 1, 1, 1],
        [1, 2, 2, 2, 1]
      ]
    },
    {
      "name": "decor",
      "tiles": [
        [-1, -1, -1, -1, -1],
        [-1, 5, -1, 6, -1],
        [-1, -1, -1, -1, -1]
      ]
    }
  ]
}

El -1 significa "tile vacío". No tiene coste de render.

use serde::Deserialize;

#[derive(Deserialize)]
struct MapFile {
    tile_size: u32,
    map_size: [u32; 2],
    atlas: String,
    atlas_size: [u32; 2],
    layers: Vec<Layer>,
}

#[derive(Deserialize)]
struct Layer {
    name: String,
    tiles: Vec<Vec<i32>>,
}

fn load_map_from_json(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    let map: MapFile = serde_json::from_str(
        include_str!("../assets/level_01.json")
    ).unwrap();

    let map_size = TilemapSize { x: map.map_size[0], y: map.map_size[1] };
    // ... (igual que antes pero iterando `map.layers`)
}
Error clásico: definir `tiles: Vec<Vec<u32>>` con `0` para "vacío". Problema: el índice `0` es un tile válido. Tu "vacío" sobrescribe el primer tile con el primer frame. Usa `i32` y `-1`, o separa en `Option<u32>`. Sí, esto le ha pasado a un autor de este libro. No, no vamos a decir quién.

15.8 Carga desde LDtk (la herramienta pro gratis)

LDtk (https://ldtk.io) es un editor de niveles tile-based hecho por Sébastien Bénard (2018, en su tiempo libre mientras trabajaba en Dead Cells). Soporta autotiles, intgrid, entidades personalizadas, niveles múltiples y exporta a JSON.

Ojo con la atribución: el soporte de LDtk para Bevy no es una feature de bevy_ecs_tilemap. Pertenece a un crate distinto llamado bevy_ecs_ldtk (versión 0.15.0, compatible con Bevy 0.19). Internamente, bevy_ecs_ldtk sí usa bevy_ecs_tilemap para materializar las capas, pero son dos dependencias separadas. No agregues features = ["ldtk"] a bevy_ecs_tilemap: esa feature no existe.

[dependencies]
bevy = { version = "0.19", features = ["2d"] }
bevy_ecs_tilemap = "0.19.0"       # el runtime de tiles.
bevy_ecs_ldtk = "0.15"            # el loader de archivos .ldtk (crate distinto).
//! cap-15 — sección 15.8: cargar un nivel LDtk con `bevy_ecs_ldtk`.
use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;   // ← crate distinto de bevy_ecs_tilemap.

fn setup_ldtk(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn((
        LdtkWorldBundle {
            ldtk_handle: asset_server.load("level.ldtk"),
            ..default()
        },
    ));
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(LdtkPlugin)              // de bevy_ecs_ldtk.
        .add_plugins(bevy_ecs_tilemap::TilemapPlugin) // requerido por debajo.
        .add_systems(Startup, setup_ldtk)
        .run();
}
¿Sabías que LDtk (*Level Designer Toolkit*) salió en 2018 (Sébastien Bénard) tras el exitazo de su juego anterior *Dead Cells* (2018, Motion Twin)? Sébastien se hizo tan famoso haciendo herramientas que la comunidad lo venera casi más por LDtk que por el juego. Está en Steam Workshop y todo.

LDtk tiene capas de tipo IntGrid (útiles para colisiones) y AutoLayer (útiles para terreno). Ambas se traducen a entidades y tilemaps de Bevy por bevy_ecs_ldtk, que expone componentes como GridCoords, IntGridCell y el bundle LdtkWorldBundle/LdtkLevelBundle para que tus sistemas reaccionen a ellas. Para colisiones, el patrón estándar es queryar entidades con IntGridCell y construir colliders a partir de su GridCoords.

❌ `bevy_ecs_tilemap = { version = "0.18", features = ["ldtk"] }` — la feature `ldtk` NO existe en `bevy_ecs_tilemap`.
✅ Agregá `bevy_ecs_ldtk = "0.15"` como crate aparte (junto a `bevy_ecs_tilemap`).
💡 Por qué: el soporte LDtk es un proyecto independiente. `bevy_ecs_tilemap` solo provee el runtime; `bevy_ecs_ldtk` es el loader.

🎯 15.9 Patrón del capítulo: el tilemap es solo una matriz con sabor

Aquí está el patrón que te salvará la vida:

Un tilemap NO es un mapa de bits. Es una matriz de enteros pequeños que tu código interpreta.

Consecuencias prácticas:

  1. Tu mapa es data, no código. Cambias un JSON, cambias el nivel. Sin recompilar.
  2. La IA puede leer tu mapa. "En TilePos { x: 5, y: 3 } hay un tile de tipo Wall, así que no puedo pasar". Loop sobre la matriz en 100 líneas.
  3. La física puede usar la matriz para colisiones. Recorre los tiles alrededor del jugador y comprueba su tipo. Sin necesidad de un motor físico para mapas estáticos.
  4. El guardado es trivial. serde del struct que envuelve la matriz. Tres líneas.
  5. Las herramientas externas (Tiled, LDtk) exportan a formatos matriz-friendly. No estás peleando contra Photoshop; estás peleando contra un CSV glorificado.

Cuando estés tentado de "dibujar" un nivel directamente en código, recuerda: cada tile que pones a mano es una decisión que tomarás otra vez cuando quieras cambiarlo. Ponlo en la matriz. Ponlo en JSON. Ponlo donde puedas editarlo sin recompilar.

// ❌ MAL: nivel hardcodeado
fn setup(mut commands: Commands) {
    for x in 0..5 {
        for y in 0..5 {
            // 25 líneas más para cada tile...
        }
    }
}

// ✅ BIEN: nivel como dato
#[derive(Resource)]
struct LevelData(Vec<Vec<u32>>);

fn setup(mut commands: Commands, level: Res<LevelData>) {
    for (x, row) in level.0.iter().enumerate() {
        for (y, &idx) in row.iter().enumerate() {
            // ...
        }
    }
}

15.10 Trucos avanzados rápidos


Lo que vimos

En el siguiente

En el capítulo 16 vamos a meterle luz a esos tilemaps. Punto de luz, sombras 2D, viñeta, aberración cromática y lens distortion. Descubriremos por qué "luz = información, no decoración" y cómo hacer que tu plataformas de 16 colores parezca un juego indie de 2024.