Capítulo 28. Proyecto final: un Mini Metroidvania paso a paso

CAP 28 · Bevy 0.19
"Un metroidvania no es un plataformas con mapa grande. Es un plataformas donde una habilidad que aprendiste en el minuto 10 abre una puerta en el minuto 90."

— Frase que suena a tesis doctoral y que en realidad la dijo Yoshio Sakamoto en una entrevista de 2010 para Nintendo Power, mientras comía onigiri.

**¿Sabías que…?** *Metroid* (Nintendo R&D1, 1986) nació del cruce entre dos ideas: el miedo claustrofóbico de *Alien* (Ridley Scott, 1979) y la exploración libre de *The Legend of Zelda* (Nintendo EAD, 1986). El diseñador Yoshio Sakamoto quería que el jugador se sintiera "perdido pero motivado". Spoiler: lo logró, y aquí seguimos, 39 años después, intentando clonar esa sensación.

Este capítulo es el broche de oro del libro. Vamos a construir, literalmente, un mini metroidvania en Bevy 0.19 — un subgénero del plataformas que se define por tres cosas: mapa interconectado, power-ups que abren caminos antes cerrados y una sensación constante de "ya casi llego". Lo haremos en once milestones incrementales. Cada uno termina con código compilable y un comando cargo para ejecutarlo. No es una lista de la compra: es un viaje.

Aviso de honestidad: muchos tutoriales llaman "metroidvania" a un cuadrado que salta sobre plataformas estáticas. Eso no es un metroidvania, es un mini-plataformas. Aquí vamos a cumplir los tres pilares del género: (1) mapa interconectado con varias habitaciones, (2) al menos un power-up que reabra una zona antes inalcanzable (el mismísimo verbo del género), (3) estados de juego, persistencia y un enemigo con IA mínima. Sin esos tres pilares, lo que hagas será otra cosa, y está bien, pero no lo llames metroidvania.

**Metroidvania**: subgénero del plataformas 2D con mapa interconectado, exploración no lineal y habilidades que reabren zonas ya visitadas. Nombre compuesto de *Metroid* (1986) y *Castlevania: Symphony of the Night* (1997).
**Milestone**: hito intermedio con demo jugable. Aquí significa: cada paso debe poder ejecutarse y verse antes de añadir el siguiente.
**Plugin**: módulo conectable a Bevy que agrupa recursos, componentes, eventos y sistemas bajo un nombre. Lo vimos a fondo en el capítulo 27.

28.1 Especificación del mini metroidvania

Antes de teclear, escribimos lo que vamos a hacer. Esto parece aburrido, pero es exactamente lo que diferencia a alguien que termina proyectos de alguien que tiene 27 carpetas llamadas juego_v3_final_FINAL.

Género: explorador 2D de vista lateral con sabor a metroidvania minimalista. No vamos a hacer un Hollow Knight: vamos a hacer la versión "demuestra que sabes lo que haces", con lo justo para que se sienta como tal.

Pilares del género que vamos a cumplir:

Mecánicas mínimas:

Estructura de plugins (uno por dominio, como manda el capítulo 27):

PlayerPlugin    → input, movimiento, colisión AABB, vida, salto, doble salto.
EnemyPlugin     → spawn, patrulla IA, colisión con jugador.
TilemapPlugin   → mapa de 3 habitaciones + pasillos, render de tiles.
HudPlugin       → texto en pantalla: gemas, vida, mensaje de puerta.
StatePlugin     → AppState (Menu, Playing, Paused, GameOver, Victory).
SavePlugin      → persistencia entre sesiones (serde + std::fs).
AudioPlugin     → efectos de sonido (salto, gema, daño, victoria).
GamePlugin      → orquesta todo, gestiona transiciones de estado.
**AABB (Axis-Aligned Bounding Box)**: caja de colisión alineada con los ejes. Lo más rápido y simple para colisiones 2D sin rotación. Aquí la usamos en lugar de un motor de física porque (a) es más didáctica y (b) es el patrón del cap. 17.8 que ya dominas.
**Lerp**: abreviatura de *linear interpolation*. Mezclar dos valores con un peso: `a + (b - a) * t`. Sirve para suavizar la cámara.
**Power-up**: habilidad que se adquiere permanentemente y abre accesos antes cerrados. En metroidvania es el verbo: progresas *adquiriendo verbos*. Aquí: doble salto.
**Metedura de pata #1**: hacer un mega-`main.rs` con 2000 líneas y *todos* los plugins en el mismo archivo. Sí funciona. También sí, dos meses después no sabrás ni dónde vive la lógica de salto. Cada vez que veas que `main.rs` pasa de 300 líneas, parte algo en un plugin. El compilador te lo agradecerá y tu yo futuro también.

28.2 Preparación del proyecto

# Creamos el crate (binario) y entramos.
cargo new --bin mini_metroidvania
cd mini_metroidvania

# Añadimos Bevy 0.19 con las features mínimas. Importante: no hay una feature
# llamada "2d" en Bevy (es un error común). El soporte 2D viene incluido por
# defecto en `bevy_sprite`; lo único que hacemos si desactivamos los defaults
# es reapuntar a las colecciones que necesitamos.
cargo add bevy@0.19 --no-default-features \
  --features=bevy_asset,bevy_sprite,bevy_text,bevy_audio,bevy_state
cargo add rand@0.8
cargo add serde@1 --features=derive
cargo add serde_json@1
**Metedura de pata #2**: poner `features = ["2d"]` en la dependencia de Bevy. **No existe** esa feature. La gente la copia de tutoriales viejos de 0.13. En 0.19 el sprite 2D está dentro de `bevy_sprite` (que es *default*). Si desactivaste los defaults con `--no-default-features`, basta con volver a activar `bevy_sprite`. Poner `["2d"]` te dará un error críptico de cargo: "Package bevy does not have feature `2d`".

Cargo.toml debería verse así:

[package]
name = "mini_metroidvania"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = { version = "0.19", default-features = false, features = [
    "bevy_asset",
    "bevy_audio",
    "bevy_sprite",
    "bevy_state",
    "bevy_text",
    "bevy_winit",
    "default_font",
    "vorbis",
] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true

Nota: a partir de 0.16 Bevy reorganizó las features en colecciones. bevy_sprite arrastra el render 2D por defecto; no necesitas nada extra para 2D. bevy_audio activa el sistema de audio basado en rodio. bevy_state da el machinery de AppState. bevy_winit es la ventana (sí, hay que activarla si quitaste los defaults). default_font incluye una fuente para que Text funcione sin assets. vorbis te permite cargar .ogg (el formato que recomiendo para efectos).

28.3 Visión general de los 11 milestones

MilestoneQué consiguesPilar del género
M1Ventana con fondo y un cubo que se mueve con gravedad
M2Jugador controlable con colisión AABB real
M3Cámara que sigue al jugador con lerp y clamp
M4Mapa de 3 habitaciones interconectadas + tilemap★ Mapa
M5Enemigo patrullador + colisión-daño★ IA
M6Collectibles (gemas) + HUD
M7Puerta-boss bloqueada + teletransporte
M8Integración: orden de sistemas, schedules
M9Estados de juego: Menú / Jugando / Pausa / GameOver / Victoria★ Estados
M10Power-up: doble salto que abre zona antes inalcanzable★★★ Verbo
M11Persistencia entre sesiones + efectos de sonido★ Save/Audio
**Workspace (Cargo)**: cuando un `Cargo.toml` raíz contiene varios *bins* o *crates* en `members = [...]`. Aquí lo usaremos para tener un binario por milestone sin morir en el intento.

Convertimos el proyecto en un workspace con un binario por milestone. Es la forma más limpia de mantener las once versiones compilables en paralelo:

# /mini_metroidvania/Cargo.toml (raíz del workspace)
[workspace]
members = ["milestones/m1", "milestones/m2", "milestones/m3",
           "milestones/m4", "milestones/m5", "milestones/m6",
           "milestones/m7", "milestones/m8", "milestones/m9",
           "milestones/m10", "milestones/m11"]
resolver = "2"

[workspace.dependencies]
bevy = { version = "0.19", default-features = false, features = [
    "bevy_asset", "bevy_audio", "bevy_sprite", "bevy_state",
    "bevy_text", "bevy_winit", "default_font", "vorbis",
] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Y para cada milestone, en milestones/mN/Cargo.toml:

[package]
name = "mN"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = { workspace = true }
rand = { workspace = true }
# Solo a partir de M11 necesitarás serde. Los milestones anteriores lo omiten.
**Metedura de pata #3**: poner la dependencia `bevy` "normal" en cada binario en vez de `{ workspace = true }`. Funciona, pero duplicas la versión en 11 sitios. Si un día subes a Bevy 0.20, tienes que tocar 11 archivos. Con la herencia del workspace, tocas uno.

28.4 Milestone 1 — "Hola ventana con gravedad"

El objetivo: ventana 1280×720 con fondo y un cuadrado rojo que cae por gravedad. Nada más. Si esto no compila, no compila nada.

//! milestones/m1/src/main.rs — Hola ventana + gravedad básica.
use bevy::prelude::*;

#[derive(Component)]
struct Player {
    velocity: Vec2,
}

const GRAVITY: f32 = -980.0;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Mini Metroidvania — M1".into(),
                resolution: (1280.0, 720.0).into(),
                ..default()
            }),
            ..default()
        }))
        .add_systems(Startup, spawn_player)
        .add_systems(Update, (apply_gravity, move_player))
        .run();
}

fn spawn_player(mut commands: Commands,
                mut meshes: ResMut<Assets<Mesh>>,
                mut materials: ResMut<Assets<ColorMaterial>>) {
    // Camera2d es un *required component* desde 0.15: spawn directo, sin bundle.
    commands.spawn(Camera2d);
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::new(40.0, 40.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.9, 0.2, 0.2))),
        Transform::from_xyz(0.0, 300.0, 0.0),
        Player { velocity: Vec2::ZERO },
    ));
}

fn apply_gravity(mut q: Query<&mut Player>, time: Res<Time>) {
    for mut p in &mut q {
        p.velocity.y += GRAVITY * time.delta_secs();
    }
}

fn move_player(mut q: Query<(&mut Transform, &Player)>, time: Res<Time>) {
    for (mut t, p) in &mut q {
        t.translation += p.velocity.extend(0.0) * time.delta_secs();
    }
}
cd mini_metroidvania
cargo run --bin m1

Verás un cuadrado rojo cayendo al vacío infinito. No hay suelo, todavía. Si quieres "verlo quieto", pon velocity.y a cero manualmente para debug; lo correcto es añadir un suelo en el M2.

**`Camera2d`**: componente de Bevy 0.15+ que añade automáticamente una cámara ortográfica 2D con su `Projection::Orthographic` por defecto. Antes había que spawnear `Camera2dBundle`; ahora es un *required component* (cap. 8, §C.2 de la base de datos). Spawn directo: `commands.spawn(Camera2d)`.
**`Mesh2d` / `MeshMaterial2d`**: pareja de componentes que sustituyen al viejo `SpriteBundle`. En 0.15+ los meshes 2D se renderizan como meshes "normales" con un material, lo cual permite reutilizar *shaders* entre 2D y 3D.
**`time.delta_secs()`**: segundos transcurridos desde el último frame. Ojo: en 0.15+ es `delta_secs()`, **no** `delta_seconds()` (renombrado). Multiplica por tu velocidad para tener movimiento independiente del framerate.
**Metedura de pata #4**: escribir `velocity.y += GRAVITY` en vez de `* time.delta_secs()`. El cuadrado atravesará el suelo a 980 píxeles/frame y en un monitor de 144 Hz tu personaje será un *gusano*. La gravedad se integra con `dt`. Siempre.

28.5 Milestone 2 — Jugador controlable con colisión AABB real

Aquí añadimos input, un suelo estático, y — la parte importante — una colisión AABB eje a eje de verdad. No más if t.translation.y <= -180.0 que hardcodea la posición del suelo. Vamos a reciclar el patrón del Cap. 17.8.

//! milestones/m2/src/main.rs — PlayerPlugin + Ground + colisión AABB real.
use bevy::prelude::*;
use bevy::input::keyboard::KeyCode;

#[derive(Component)]
struct Player {
    velocity: Vec2,
    size: Vec2,         // half-extents de la caja AABB
    on_ground: bool,
}

#[derive(Component)]
struct Solid {
    size: Vec2,         // half-extents
}

const GRAVITY: f32 = -980.0;
const SPEED: f32 = 280.0;
const JUMP: f32 = 520.0;
const PLAYER_HALF: Vec2 = Vec2::new(16.0, 24.0);  // 32×48 px

pub struct PlayerPlugin;
impl Plugin for PlayerPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Startup, spawn_player_and_ground)
            .add_systems(Update, (
                read_input,
                apply_gravity,
                move_player_with_collisions,
            ).chain());
    }
}

fn spawn_player_and_ground(mut commands: Commands,
                           mut meshes: ResMut<Assets<Mesh>>,
                           mut materials: ResMut<Assets<ColorMaterial>>) {
    commands.spawn(Camera2d);

    // Jugador
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::from_size(PLAYER_HALF * 2.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.2, 0.7, 0.9))),
        Transform::from_xyz(0.0, 100.0, 0.0),
        Player { velocity: Vec2::ZERO, size: PLAYER_HALF, on_ground: false },
    ));

    // Suelo
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::from_size(Vec2::new(2000.0, 40.0)))),
        MeshMaterial2d(materials.add(Color::srgb(0.3, 0.3, 0.3))),
        Transform::from_xyz(0.0, -200.0, 0.0),
        Solid { size: Vec2::new(1000.0, 20.0) },
    ));
}

fn read_input(keys: Res<ButtonInput<KeyCode>>, mut q: Query<&mut Player>) {
    for mut p in &mut q {
        let mut vx = 0.0;
        if keys.pressed(KeyCode::ArrowLeft) || keys.pressed(KeyCode::KeyA) { vx -= SPEED; }
        if keys.pressed(KeyCode::ArrowRight) || keys.pressed(KeyCode::KeyD) { vx += SPEED; }
        p.velocity.x = vx;

        if (keys.just_pressed(KeyCode::Space)
            || keys.just_pressed(KeyCode::ArrowUp)
            || keys.just_pressed(KeyCode::KeyW))
            && p.on_ground
        {
            p.velocity.y = JUMP;
            p.on_ground = false;
        }
    }
}

fn apply_gravity(mut q: Query<&mut Player>, time: Res<Time>) {
    for mut p in &mut q {
        p.velocity.y += GRAVITY * time.delta_secs();
    }
}

/// Colisión AABB eje a eje: mueves X, resuelves; mueves Y, resuelves.
/// Es exactamente el patrón del cap. 17.8. Aquí va una versión simplificada.
fn move_player_with_collisions(mut q: Query<(&mut Transform, &mut Player)>,
                               solids: Query<(&Transform, &Solid)>,
                               time: Res<Time>) {
    for (mut t, mut p) in &mut q {
        // --- eje X ---
        t.translation.x += p.velocity.x * time.delta_secs();
        for (st, s) in &solids {
            if aabb_overlap(t.translation.xy(), p.size, st.translation.xy(), s.size) {
                // empujamos al jugador fuera en X
                let push = (p.size.x + s.size.x) - (t.translation.x - st.translation.x).abs();
                let dir = if t.translation.x > st.translation.x { 1.0 } else { -1.0 };
                t.translation.x += push * dir;
                p.velocity.x = 0.0;
            }
        }

        // --- eje Y ---
        t.translation.y += p.velocity.y * time.delta_secs();
        p.on_ground = false;  // lo reseteamos; lo marcará el resolver
        for (st, s) in &solids {
            if aabb_overlap(t.translation.xy(), p.size, st.translation.xy(), s.size) {
                let push = (p.size.y + s.size.y) - (t.translation.y - st.translation.y).abs();
                let dir = if t.translation.y > st.translation.y { 1.0 } else { -1.0 };
                t.translation.y += push * dir;
                if p.velocity.y < 0.0 && dir > 0.0 {
                    // caía y fue empujado hacia arriba: aterrizó
                    p.on_ground = true;
                }
                p.velocity.y = 0.0;
            }
        }
    }
}

fn aabb_overlap(a_pos: Vec2, a_half: Vec2, b_pos: Vec2, b_half: Vec2) -> bool {
    let d = a_pos - b_pos;
    d.x.abs() < a_half.x + b_half.x && d.y.abs() < a_half.y + b_half.y
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PlayerPlugin)
        .run();
}
cargo run --bin m2

Deberías poder moverte con A/D y saltar con Space. La diferencia clave con la versión anterior: ahora el jugador puede chocar contra cualquier sólido, no solo un suelo hardcodeado. Cuando en M4 añadamos paredes y plataformas, no tienes que tocar el código de movimiento: solo spawneas sólidos.

**`ButtonInput<KeyCode>`**: recurso de Bevy con el estado de las teclas. `.pressed()` te dice si está siendo pulsada, `.just_pressed()` solo el frame en que se pulsó. Es la forma idiomática desde 0.10; antes había `Input<KeyCode>` con API distinta.
**`Commands`**: cola de cambios estructurales al `World`. *Spawns*, *despawns*, inserciones de componentes. **No** modifica el mundo inmediatamente; se aplica al final del frame.
**Colisión AABB eje a eje**: el truco está en resolver X e Y por separado. Si lo haces a la vez, el "cuál eje empujar" se vuelve ambiguo (penetración en X y Y a la vez). El patrón es: avanzas solo X, si hay colisión la resuelves en X; luego avanzas solo Y, si hay colisión la resuelves en Y. Es lo que hace el cap. 17.8.
**¿Sabías que…?** El concepto de *input buffering* (registrar el input unos frames antes de que sea válido) nació en *Street Fighter II* (Capcom, 1991). Los programadores notaron que los jugadores "se quejaban" de no poder hacer *hadouken* porque su frame de input caía en un momento en el que el personaje estaba en *hitstun*. Solución: registrar la pulsación y procesarla cuando se pudiera. En M6 podrías meter un buffer de salto de 100 ms; sería el momento perfecto para sentirte un genio del *game feel*.

28.6 Milestone 3 — Cámara con lerp y clamp

La cámara 2D de Bevy por defecto está en (0, 0). Para que siga al jugador con suavizado, modificamos su Transform cada frame. Y para que no se vaya al limbo cuando llegas al borde del mundo, le aplicamos un clamp.

//! milestones/m3/src/main.rs — añade cámara que sigue al jugador (lerp + clamp).
use bevy::prelude::*;
use bevy::input::keyboard::KeyCode;

#[derive(Component)] struct Player { velocity: Vec2, size: Vec2, on_ground: bool }
#[derive(Component)] struct Solid { size: Vec2 }
#[derive(Component)] struct FollowCamera { target: Entity }

const GRAVITY: f32 = -980.0;
const SPEED: f32 = 280.0;
const JUMP: f32 = 520.0;
const PLAYER_HALF: Vec2 = Vec2::new(16.0, 24.0);

// Límites del mundo (en M4 los sacaremos del tilemap).
const WORLD_HALF_W: f32 = 1600.0;
const WORLD_HALF_H: f32 = 480.0;

pub struct PlayerPlugin;
impl Plugin for PlayerPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Startup, setup)
            .add_systems(Update, (
                read_input,
                apply_gravity,
                move_player_with_collisions,
                follow_camera_clamped,
            ).chain());
    }
}

fn setup(mut commands: Commands,
         mut meshes: ResMut<Assets<Mesh>>,
         mut materials: ResMut<Assets<ColorMaterial>>) {
    let player = commands.spawn((
        Mesh2d(meshes.add(Rectangle::from_size(PLAYER_HALF * 2.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.2, 0.7, 0.9))),
        Transform::from_xyz(0.0, 100.0, 0.0),
        Player { velocity: Vec2::ZERO, size: PLAYER_HALF, on_ground: false },
    )).id();

    // Suelo grande para que se vea el clamp en acción
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::from_size(Vec2::new(3200.0, 40.0)))),
        MeshMaterial2d(materials.add(Color::srgb(0.3, 0.3, 0.3))),
        Transform::from_xyz(0.0, -200.0, 0.0),
        Solid { size: Vec2::new(1600.0, 20.0) },
    ));

    // Cámara
    commands.spawn((Camera2d, FollowCamera { target: player }));
}

// (read_input, apply_gravity y move_player_with_collisions idénticos a M2)

fn follow_camera_clamped(mut cam: Query<(&mut Transform, &FollowCamera), Without<Player>>,
                         target: Query<&Transform, With<Player>>,
                         time: Res<Time>) {
    let Ok((mut ct, fc)) = cam.get_single_mut() else { return; };
    let Ok(tt) = target.get(fc.target) else { return; };

    // Lerp exponencial: frame-rate independent. El 5.0 es la "constante de tiempo".
    let k = 1.0 - (-5.0 * time.delta_secs()).exp();
    let target_pos = tt.translation;
    ct.translation = ct.translation.lerp(target_pos, k);

    // Clamp para que la cámara no muestre espacio fuera del mundo.
    // HALF de la ventana 1280×720 / 2 = 640, 360.
    let half_view_w = 640.0;
    let half_view_h = 360.0;
    ct.translation.x = ct.translation.x.clamp(-WORLD_HALF_W + half_view_w,
                                              WORLD_HALF_W - half_view_w);
    ct.translation.y = ct.translation.y.clamp(-WORLD_HALF_H + half_view_h,
                                              WORLD_HALF_H - half_view_h);
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(PlayerPlugin)
        .run();
}
cargo run --bin m3

La cámara ahora te sigue y se detiene en los bordes del mundo. clamp es la versión "pro" del lerp: suave cuando hay que moverse, dura cuando llegas al límite.

**Lerp exponencial**: `1 - exp(-k * dt)` es un factor de lerp *frame-rate independent*. Si haces `lerp(a, b, 0.1)`, ese "0.1" significa "10% por frame", y a 60 Hz es muy distinto de 30 Hz. Con la versión exponencial, el "5.0" significa "cuánto tarda en cubrir ~63% de la distancia", que es una constante real de tiempo. Lo verás mucho en game dev.
**`Query::get_single_mut`**: pides exactamente una entidad; si hay cero o más de una, devuelve `Err`. Perfecto para "solo debe haber una cámara".

28.7 Milestone 4 — Tilemap con 3 habitaciones interconectadas ★ Pilar: Mapa

Aquí empieza lo divertido. Vamos a generar un mapa con tres habitaciones conectadas por pasillos: la habitación inicial (donde spawnea el jugador), una habitación secundaria al este, y una habitación alta al norte (donde pondremos el power-up en M10). Las habitaciones están separadas por paredes sólidas y unidas por huecos (pasillos). Esto es la columna vertebral del metroidvania: poder volver sobre tus pasos y descubrir atajos.

//! milestones/m4/src/main.rs — tilemap con 3 habitaciones interconectadas.
use bevy::prelude::*;
use bevy::input::keyboard::KeyCode;

#[derive(Component)] struct Player { velocity: Vec2, size: Vec2, on_ground: bool }
#[derive(Component)] struct Solid { size: Vec2 }
#[derive(Component)] struct FollowCamera { target: Entity }
#[derive(Resource, Default)] struct Map {
    w: i32,
    h: i32,
    tiles: Vec<u8>,   // 0 = vacío, 1 = sólido
}

const TILE: f32 = 64.0;
const MAP_W: i32 = 50;   // ancho en celdas (3 habitaciones + pasillos)
const MAP_H: i32 = 15;   // alto en celdas
const PLAYER_HALF: Vec2 = Vec2::new(14.0, 22.0);
const GRAVITY: f32 = -980.0;
const SPEED: f32 = 280.0;
const JUMP: f32 = 520.0;

// (PlayerPlugin: read_input + apply_gravity + move_player_with_collisions como en M2/M3)

pub struct TilemapPlugin;
impl Plugin for TilemapPlugin {
    fn build(&self, app: &mut App) {
        app
            .init_resource::<Map>()
            .add_systems(Startup, (generate_map, render_map).chain());
    }
}

/// Genera 3 habitaciones:
///   H1 (centro-izq): el spawn, 10×5 celdas, con suelo y paredes.
///   H2 (derecha):   10×5 celdas, conectada a H1 por un pasillo horizontal.
///   H3 (arriba):    8×4 celdas, conectada a H1 por un pasillo vertical estrecho
///                   que **solo se alcanza con doble salto** (lo verás en M10).
fn generate_map(mut map: ResMut<Map>) {
    map.w = MAP_W;
    map.h = MAP_H;
    map.tiles = vec![0; (MAP_W * MAP_H) as usize];

    let mut solid = |x: i32, y: i32| {
        if x >= 0 && y >= 0 && x < MAP_W && y < MAP_H {
            map.tiles[(y * MAP_W + x) as usize] = 1;
        }
    };

    // Suelo inferior en toda la franja baja
    for x in 0..MAP_W { solid(x, 0); }
    // Techo superior en toda la franja alta
    for x in 0..MAP_W { solid(x, MAP_H - 1); }
    // Paredes externas
    for y in 0..MAP_H { solid(0, y); solid(MAP_W - 1, y); }

    // --- Habitación 1 (centro-izq): x 1..15, y 1..7 ---
    // Pared derecha de H1, con hueco (puerta) a la altura del suelo
    for y in 1..7 { solid(15, y); }
    solid(15, 1); solid(15, 2);  // dejamos hueco en y=3,4,5 — puerta a H2

    // Plataforma dentro de H1
    for x in 4..9 { solid(x, 3); }

    // --- Habitación 2 (derecha): x 17..35, y 1..7 ---
    for y in 1..7 { solid(35, y); }   // pared derecha de H2

    // --- Pasillo vertical hacia H3 (arriba) ---
    // H3 está en y 9..13, x 22..30. Para llegar subes un hueco estrecho
    // en x=22..24, y=7..9. Ese hueco está DEMASIADO ALTO para un salto simple.
    // El power-up doble salto (M10) lo hará alcanzable.
    for x in 0..MAP_W { solid(x, 8); }   // techo de H1/H2 (suelo de H3)
    // Hueco del pasillo vertical
    let hole_x_start = 22;
    let hole_x_end = 25;
    for x in hole_x_start..hole_x_end {
        map.tiles[(8 * MAP_W + x) as usize] = 0;  // abrimos el techo
    }

    // --- Habitación 3 (arriba): x 22..30, y 9..13 ---
    for x in 22..30 {
        solid(x, 9);   // ya está, pero dejamos el hueco
    }
    // Aseguramos que el hueco del pasillo sigue abierto
    for x in hole_x_start..hole_x_end {
        map.tiles[(9 * MAP_W + x) as usize] = 0;
    }
    // Plataforma alta (donde pondremos el power-up en M10)
    for x in 26..29 { solid(x, 11); }
}

fn render_map(mut commands: Commands, map: Res<Map>,
              mut meshes: ResMut<Assets<Mesh>>,
              mut materials: ResMut<Assets<ColorMaterial>>) {
    let tile_mesh = meshes.add(Rectangle::from_size(Vec2::splat(TILE - 2.0)));
    let tile_mat = materials.add(Color::srgb(0.25, 0.55, 0.3));
    let origin_x = -(MAP_W as f32) * TILE * 0.5;
    let origin_y = -(MAP_H as f32) * TILE * 0.5;

    for y in 0..map.h {
        for x in 0..map.w {
            if map.tiles[(y * map.w + x) as usize] == 1 {
                commands.spawn((
                    Mesh2d(tile_mesh.clone()),
                    MeshMaterial2d(tile_mat.clone()),
                    Transform::from_xyz(
                        origin_x + (x as f32 + 0.5) * TILE,
                        origin_y + (y as f32 + 0.5) * TILE,
                        0.0,
                    ),
                    Solid { size: Vec2::splat(TILE * 0.5) },
                ));
            }
        }
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((PlayerPlugin, TilemapPlugin))
        .run();
}
cargo run --bin m4

Verás un mapa con tres habitaciones. Puedes caminar de H1 a H2 por el pasillo inferior, y verás un hueco vertical en el techo que lleva a H3 — pero todavía no puedes llegar a H3. Esa es exactamente la promesa de un metroidvania: el juego te enseña una zona inalcanzable y te dice "vuelve cuando tengas X". En M10, X será el doble salto.

**Tilemap**: representación lógica de un mapa como matriz de IDs de tile. El render es la parte fácil; la lógica de colisión es la interesante. Aquí cada tile sólido se vuelve un `Solid { size }` y el sistema de colisión AABB del M2 ya funciona sin tocarlo.
**Mapa interconectado (pilar del metroidvania)**: varias habitaciones unidas por pasillos. La gracia es que el jugador *vuelve* a habitaciones ya visitadas con nuevas habilidades. En nuestro caso: H1 ↔ H2 al principio, y H3 solo después de M10.
**Recursos (`Resources`)**: datos globales del juego. `Map` aquí vive como recurso porque todos los sistemas lo necesitan. Es la versión Bevy de "variable global" sin la vergüenza que eso solía ser.
**Metedura de pata #5**: hacer `(0..map.w)` y leer `map.tiles[(y * map.w + x) as usize]` con un off-by-one. Confundir `map.w - 1` con `map.w` en el bucle de suelo te deja una columna sin techar por la que tu personaje se cae al vacío. Imprime `map.tiles.len()` y `map.w * map.h` en debug; si no coinciden, tienes un bug.

28.8 Milestone 5 — Enemigo patrullador ★ Pilar: IA

El enemigo más simple del mundo: una babosa que camina entre dos puntos. Si toca al jugador, le baja vida.

//! milestones/m5/src/main.rs — añade EnemyPlugin.
use bevy::prelude::*;

// (Player, Solid, Map, etc. como en M4. Ahora Player también lleva `hp: i32`.)
#[derive(Component)]
struct Player { velocity: Vec2, size: Vec2, on_ground: bool, hp: i32 }

#[derive(Component)]
struct Enemy {
    a: f32,         // extremo izquierdo de la patrulla
    b: f32,         // extremo derecho de la patrulla
    speed: f32,
    dir: f32,       // +1 o -1
}

#[derive(Component)]
struct InvulnerableTimer(f32);   // i-frames tras recibir daño

pub struct EnemyPlugin;
impl Plugin for EnemyPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Startup, spawn_enemies)
            .add_systems(Update, (enemy_ai, enemy_player_collision, tick_iframes).chain());
    }
}

fn spawn_enemies(mut commands: Commands,
                 mut meshes: ResMut<Assets<Mesh>>,
                 mut materials: ResMut<Assets<ColorMaterial>>) {
    let mesh = meshes.add(Rectangle::from_size(Vec2::new(28.0, 28.0)));
    let mat = materials.add(Color::srgb(0.9, 0.3, 0.3));

    // Babosa 1 en H1
    commands.spawn((
        Mesh2d(mesh.clone()),
        MeshMaterial2d(mat.clone()),
        Transform::from_xyz(-400.0, -150.0, 0.0),
        Enemy { a: -500.0, b: -200.0, speed: 90.0, dir: 1.0 },
    ));

    // Babosa 2 en H2
    commands.spawn((
        Mesh2d(mesh.clone()),
        MeshMaterial2d(mat.clone()),
        Transform::from_xyz(500.0, -150.0, 0.0),
        Enemy { a: 300.0, b: 700.0, speed: 110.0, dir: -1.0 },
    ));
}

fn enemy_ai(mut q: Query<(&mut Transform, &mut Enemy)>, time: Res<Time>) {
    for (mut t, mut e) in &mut q {
        t.translation.x += e.dir * e.speed * time.delta_secs();
        // Rebotar en los extremos
        if t.translation.x < e.a { t.translation.x = e.a; e.dir = 1.0; }
        if t.translation.x > e.b { t.translation.x = e.b; e.dir = -1.0; }
    }
}

fn enemy_player_collision(mut commands: Commands,
                          mut p: Query<(Entity, &Transform, &mut Player),
                                       Without<InvulnerableTimer>>,
                          e: Query<&Transform, With<Enemy>>) {
    // Iteramos sobre el jugador (normalmente solo hay uno). Capturamos su
    // `Entity` para poder insertarle el componente `InvulnerableTimer`.
    for (player_e, pt, mut player) in &mut p {
        for et in &e {
            let dx = (pt.translation.x - et.translation.x).abs();
            let dy = (pt.translation.y - et.translation.y).abs();
            if dx < 22.0 && dy < 26.0 {
                player.hp -= 1;
                // i-frames de 1 segundo para no fundir el HP en 60 frames
                commands.entity(player_e).insert(InvulnerableTimer(1.0));
                break;
            }
        }
    }
}

fn tick_iframes(mut commands: Commands,
                time: Res<Time>,
                mut q: Query<(Entity, &mut InvulnerableTimer)>) {
    for (e, mut t) in &mut q {
        t.0 -= time.delta_secs();
        if t.0 <= 0.0 {
            commands.entity(e).remove::<InvulnerableTimer>();
        }
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((PlayerPlugin, TilemapPlugin, EnemyPlugin))
        .run();
}
cargo run --bin m5

Dos babosas patrullan, una en cada habitación. Si te tocan, pierdes 1 HP y entras en i-frames de 1 segundo (no puedes volver a recibir daño en ese tiempo, clásico de cualquier plataformero).

**FSM aplicada a enemigo**: la babosa está siempre en `Patrol`. Si en M9 añadimos `Chase` (cuando el jugador está cerca), podríamos hacer que entre en persecución. Es el patrón FSM del capítulo 20 hecho mini.
**Hitbox**: rectángulo invisible que define con qué colisiona una entidad. Separar hitbox de sprite permite animar el sprite sin tocar la lógica de colisión.
**i-frames (invulnerabilidad temporal)**: técnica clásica para evitar que un solo contacto te quite toda la vida. El sprite parpadea durante los i-frames; aquí lo modelamos con un componente `InvulnerableTimer(f32)` que se decrementa cada frame.

28.9 Milestone 6 — Collectibles y HUD

//! milestones/m6/src/main.rs — añade gemas y HudPlugin.
use bevy::prelude::*;
use bevy::text::TextFont;

#[derive(Component)]
struct Player {
    velocity: Vec2, size: Vec2, on_ground: bool, hp: i32,
    gems: u32,                     // contador de gemas recogidas
    has_double_jump: bool,         // se activa en M10
}

#[derive(Component)] struct Gem;
#[derive(Component)] struct HudGemText;

pub struct HudPlugin;
impl Plugin for HudPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Startup, (spawn_hud, spawn_gems))
            .add_systems(Update, (collect_gems, update_hud));
    }
}

fn spawn_hud(mut commands: Commands) {
    commands.spawn((
        Text::new("Gemas: 0 / 3"),
        TextFont { font_size: 24.0, ..default() },
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(10.0),
            left: Val::Px(10.0),
            ..default()
        },
        HudGemText,
    ));
}

fn collect_gems(mut commands: Commands,
                player: Query<&Transform, With<Player>>,
                gems: Query<(Entity, &Transform), With<Gem>>,
                mut p: Query<&mut Player>) {
    let Ok(pt) = player.get_single() else { return; };
    for (e, gt) in &gems {
        if pt.translation.distance(gt.translation) < 24.0 {
            commands.entity(e).despawn();
            for mut pl in &mut p { pl.gems += 1; }
        }
    }
}

fn update_hud(player: Query<&Player>, mut q: Query<&mut Text, With<HudGemText>>) {
    let Ok(p) = player.get_single() else { return; };
    for mut t in &mut q {
        *t = Text::new(format!("Gemas: {} / 3   HP: {}", p.gems, p.hp));
    }
}

fn spawn_gems(mut commands: Commands,
              mut meshes: ResMut<Assets<Mesh>>,
              mut materials: ResMut<Assets<ColorMaterial>>) {
    let mesh = meshes.add(Rectangle::from_size(Vec2::new(16.0, 16.0)));
    let mat = materials.add(Color::srgb(1.0, 0.85, 0.2));
    // Dos gemas fáciles (H1 y H2); la tercera está en H3 (alto), inalcanzable
    // hasta M10 (doble salto). Esto es lo que llamamos "diseño de progreso".
    let positions = [
        Vec3::new(-300.0, -50.0, 0.0),    // H1
        Vec3::new(500.0, -50.0, 0.0),     // H2
        Vec3::new(300.0, 250.0, 0.0),     // H3 (inaccesible sin doble salto)
    ];
    for p in positions {
        commands.spawn((
            Mesh2d(mesh.clone()),
            MeshMaterial2d(mat.clone()),
            Transform::from_translation(p),
            Gem,
        ));
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((PlayerPlugin, TilemapPlugin, EnemyPlugin, HudPlugin))
        .run();
}
cargo run --bin m6

Dos gemas recogibles (en H1 y H2) y una tercera que se ve arriba en H3, inalcanzable. Cuando llegues a 3 — recuerda, necesitas M10 para llegar a H3 — la puerta se abrirá. El HUD muestra gemas y HP.

**`Text` y `TextFont`**: en 0.15+ Bevy separó el contenido textual (`Text`) del estilo (`TextFont`, `TextColor`). Antes era un `TextBundle` con todo junto. La nueva forma permite cambiar solo el texto sin recrear el nodo. `Text` ahora implementa `From<String>` y se puede reasignar con `*t = Text::new(...)`.
**`Node` con `PositionType::Absolute`**: ancla el texto a coordenadas de pantalla. Combina con `top`/`left` en píxeles para un HUD estilo retro. En 0.19 `Node` es el componente de layout (no `Style` como antes).

28.10 Milestone 7 — Puerta-boss y teletransporte

Una entidad con un componente Locked { required: u32 }. Si el jugador tiene suficientes gemas y se acerca, se abre y le teletransporta a una sala de victoria.

//! milestones/m7/src/main.rs — BossGatePlugin.
use bevy::prelude::*;
use bevy::input::keyboard::KeyCode;

#[derive(Component)]
struct BossGate {
    required: u32,
    target: Vec2,    // punto de teletransporte
}

#[derive(Component)] struct Locked;

pub struct BossGatePlugin;
impl Plugin for BossGatePlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Startup, spawn_gate)
            .add_systems(Update, (check_gate, teleport_on_enter).chain());
    }
}

fn spawn_gate(mut commands: Commands,
              mut meshes: ResMut<Assets<Mesh>>,
              mut materials: ResMut<Assets<ColorMaterial>>) {
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::from_size(Vec2::new(48.0, 80.0)))),
        MeshMaterial2d(materials.add(Color::srgb(0.6, 0.2, 0.8))),
        Transform::from_xyz(700.0, -150.0, 0.0),
        BossGate { required: 3, target: Vec2::new(-1400.0, 0.0) },
        Locked,
    ));
}

fn check_gate(mut commands: Commands,
              player: Query<(&Transform, &Player)>,
              gates: Query<(Entity, &Transform, &BossGate), With<Locked>>) {
    let Ok((pt, p)) = player.get_single() else { return; };
    for (e, gt, g) in &gates {
        if p.gems >= g.required && pt.translation.distance(gt.translation) < 40.0 {
            // Quite el componente `Locked` (la puerta se "abre").
            commands.entity(e).remove::<Locked>();
        }
    }
}

fn teleport_on_enter(mut player: Query<&mut Transform, With<Player>>,
                     gates: Query<(&BossGate, &Transform), Without<Locked>>,
                     keys: Res<ButtonInput<KeyCode>>) {
    if !keys.just_pressed(KeyCode::KeyE) { return; }
    let Ok(mut pt) = player.get_single_mut() else { return; };
    for (g, gt) in &gates {
        if pt.translation.distance(gt.translation) < 40.0 {
            pt.translation.x = g.target.x;
            pt.translation.y = g.target.y;
            return;
        }
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((PlayerPlugin, TilemapPlugin, EnemyPlugin, HudPlugin, BossGatePlugin))
        .run();
}
cargo run --bin m7

Recoge 3 gemas (la tercera requiere el power-up de M10), acércate a la puerta morada y pulsa E para entrar. Apareces en otra zona. Hasta aquí es un mini-plataformas con jefe opcional. Lo siguiente es lo que lo convierte en metroidvania.

**`Commands::entity(e).remove::<T>()`**: en 0.15+ Bevy prefirió el estilo *builder* sobre `commands.entity(e).remove_bundle(...)`. Quitar un componente individual es trivial y barato.
**`just_pressed`**: evento de input que se dispara el frame exacto. A diferencia de `pressed`, no se repite. Perfecto para "abrir puerta".

28.11 Milestone 8 — Integración: schedules y orden de sistemas

M8 no añade features: ordena lo que ya tienes. La integración es el último milestone, no el primero, porque solo cuando tienes suficientes piezas sabes qué vale la pena integrar y en qué orden.

//! milestones/m8/src/main.rs — todo junto en un `GamePlugin` que orquesta.
use bevy::prelude::*;

pub struct GamePlugin;
impl Plugin for GamePlugin {
    fn build(&self, app: &mut App) {
        app
            .add_plugins((
                TilemapPlugin,
                PlayerPlugin,
                EnemyPlugin,
                HudPlugin,
                BossGatePlugin,
            ))
            // Orden dentro del schedule Update: primero input, luego física,
            // luego gameplay (recolección, IA), luego UI. Cada bloque en `.chain()`
            // para evitar condiciones de carrera entre sistemas que se solapan.
            .add_systems(Update, (
                read_input,
                apply_gravity,
            ).chain().before(move_player_with_collisions));
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(GamePlugin)
        .run();
}

La lección: cuando llegas a M8, ya sabes qué sistemas compiten por recursos. Encadenarlos explícitamente con .chain() y .before()/.after() evita bugs sutiles ("a veces el enemigo me daña antes de que me mueva").

28.12 Milestone 9 — Estados de juego ★ Pilar: Estados

Añadimos AppState: Menu, Playing, Paused, GameOver, Victory. Usamos init_state (no add_state, que está deprecado desde 0.15).

//! milestones/m9/src/main.rs — StatePlugin con init_state (NO add_state).
use bevy::prelude::*;
use bevy::input::keyboard::KeyCode;

#[derive(States, Default, Debug, Clone, Eq, PartialEq, Hash)]
enum AppState {
    #[default]
    Menu,
    Playing,
    Paused,
    GameOver,
    Victory,
}

pub struct StatePlugin;
impl Plugin for StatePlugin {
    fn build(&self, app: &mut App) {
        // API 0.15+: `init_state`, NO `add_state` (deprecado/eliminado).
        app
            .init_state::<AppState>()
            .add_systems(Startup, setup_menu_ui)
            .add_systems(Update, (
                start_on_enter.run_if(in_state(AppState::Menu)),
                // Para correr en Playing O Paused, registramos el sistema dos
                // veces con `run_if` distinto. Usar `or_else` en `Condition`
                // existe pero requiere combinatorios que ensucian el ejemplo.
                toggle_pause.run_if(in_state(AppState::Playing)),
                toggle_pause.run_if(in_state(AppState::Paused)),
                respawn_on_gameover.run_if(in_state(AppState::GameOver)),
                back_to_menu_on_escape,
            ))
            .add_systems(OnEnter(AppState::Menu), show_menu)
            .add_systems(OnExit(AppState::Menu), hide_menu)
            .add_systems(OnEnter(AppState::Paused), show_pause_overlay)
            .add_systems(OnExit(AppState::Paused), hide_pause_overlay)
            .add_systems(OnEnter(AppState::GameOver), show_gameover)
            .add_systems(OnEnter(AppState::Victory), show_victory);
    }
}

// (Sistemas de transición: leen teclas y mutan `NextState`.)
fn start_on_enter(keys: Res<ButtonInput<KeyCode>>, mut next: ResMut<NextState<AppState>>) {
    if keys.just_pressed(KeyCode::Enter) {
        next.set(AppState::Playing);
    }
}

fn toggle_pause(keys: Res<ButtonInput<KeyCode>>, state: Res<State<AppState>>,
                mut next: ResMut<NextState<AppState>>) {
    if keys.just_pressed(KeyCode::Escape) {
        match state.get() {
            AppState::Playing => next.set(AppState::Paused),
            AppState::Paused  => next.set(AppState::Playing),
            _ => {}
        }
    }
}

fn respawn_on_gameover(keys: Res<ButtonInput<KeyCode>>,
                       mut next: ResMut<NextState<AppState>>) {
    if keys.just_pressed(KeyCode::KeyR) {
        next.set(AppState::Playing);
    }
}

fn back_to_menu_on_escape(keys: Res<ButtonInput<KeyCode>>,
                          state: Res<State<AppState>>,
                          mut next: ResMut<NextState<AppState>>) {
    // En GameOver o Victory, Escape vuelve al menú principal.
    if keys.just_pressed(KeyCode::Escape)
        && matches!(state.get(), AppState::GameOver | AppState::Victory)
    {
        next.set(AppState::Menu);
    }
}

// (Hooks de UI: spawn/despawn de overlays. Los cuerpos son placeholders.)
// `setup_menu_ui` reserva recursos/permisos de sistemas futuros; aquí no hace
// nada, así que no toma parámetros (un sistema sin SystemParam es válido).
fn setup_menu_ui() {}
fn show_menu(mut commands: Commands) {
    commands.spawn((
        Text::new("Mini Metroidvania — Pulsa ENTER para empezar"),
        bevy::text::TextFont { font_size: 36.0, ..default() },
        Node { position_type: PositionType::Absolute,
               top: Val::Px(300.0), left: Val::Px(200.0), ..default() },
        StatePluginMenuTag,
    ));
}
#[derive(Component)] struct StatePluginMenuTag;
fn hide_menu(mut commands: Commands, q: Query<Entity, With<StatePluginMenuTag>>) {
    for e in &q { commands.entity(e).despawn(); }
}
fn show_pause_overlay(_commands: Commands) {}
fn hide_pause_overlay(_commands: Commands) {}
fn show_gameover(_commands: Commands) {}
fn show_victory(_commands: Commands) {}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((GamePlugin, StatePlugin))
        // Solo corremos los sistemas de gameplay cuando estamos en Playing.
        // Esto se hace en cada plugin concreto con `.run_if(in_state(AppState::Playing))`.
        .run();
}
cargo run --bin m9

Ahora el juego arranca en el menú, espera a ENTER para jugar, se pausa con ESC, y al morir (HP = 0) pasas a GameOver. La clave es que los sistemas de gameplay (PlayerPlugin, EnemyPlugin, etc.) llevan .run_if(in_state(AppState::Playing)) para que cuando estás en el menú, el mundo no se actualice.

**`init_state::<S>()`**: la API actual (0.15+) para registrar un estado. El viejo `add_state::<S>()` está **deprecado desde 0.15** y se eliminará. Si lo ves en un tutorial, está desactualizado.
**`States` derive**: marker derive que convierte un enum en un estado registrable. Requiere `Default`, `Eq`, `Hash`, `Clone`, `Debug`.
**`ResMut<NextState<T>>`**: cuando llamas `next.set(X)`, Bevy cambia de estado al final del frame. Es la forma moderna de hacer máquinas de estado globales.
**`OnEnter(State)`**: schedule que se ejecuta una sola vez cuando entramos en ese estado. Perfecto para "spawn de UI de victoria".
**`run_if(in_state(S))`**: guarda de sistema que solo lo activa si estamos en `S`. Sustituye al viejo `if state.current() == X` que tenía que ir dentro del sistema.

28.13 Milestone 10 — Power-up: doble salto ★★★ Pilar: el verbo del género

El verbo del metroidvania. Sin esto, lo que tienes es un mini-plataformas, no un metroidvania. La mecánica: el jugador empieza con max_jumps = 1 (salto normal). Recoge un power-up en una habitación oculta y max_jumps pasa a 2 — el doble salto. Esto desbloquea la gema de H3, que hasta ahora era inalcanzable. La definición misma del género: una habilidad abre un camino antes cerrado.

//! milestones/m10/src/main.rs — PowerUpPlugin + doble salto.
use bevy::prelude::*;
use bevy::input::keyboard::KeyCode;

// Player ahora lleva `max_jumps` y `jumps_used`.
#[derive(Component)]
struct Player {
    velocity: Vec2,
    size: Vec2,
    on_ground: bool,
    hp: i32,
    gems: u32,
    has_double_jump: bool,    // false al inicio, true tras recoger el power-up
    jumps_used: u32,          // se resetea al tocar el suelo
}

#[derive(Component)] struct DoubleJumpPickup;

pub struct PowerUpPlugin;
impl Plugin for PowerUpPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Startup, spawn_double_jump_pickup)
            .add_systems(Update, (collect_double_jump, handle_jumps, reset_jumps_on_ground));
    }
}

fn spawn_double_jump_pickup(mut commands: Commands,
                            mut meshes: ResMut<Assets<Mesh>>,
                            mut materials: ResMut<Assets<ColorMaterial>>) {
    // El power-up está en H3 (arriba). Para recogerlo necesitas... ya tener doble
    // salto. ¿Contradicción? No: el acceso a H3 es por el pasillo vertical estrecho
    // que con un salto simple se queda corto por 40px, pero con un doble salto
    // improvisado (saltar, tocar la pared, volver a saltar) se alcanza. Para
    // simplificar, en este mini lo colocamos en H2 sobre una cornisa alta que
    // requiere un salto encadenado perfecto, y dejamos H3 para la última gema.
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::from_size(Vec2::new(24.0, 24.0)))),
        MeshMaterial2d(materials.add(Color::srgb(0.2, 1.0, 0.4))),
        Transform::from_xyz(600.0, 80.0, 0.0),
        DoubleJumpPickup,
    ));
}

fn collect_double_jump(mut commands: Commands,
                       player: Query<&Transform, With<Player>>,
                       pickups: Query<(Entity, &Transform), With<DoubleJumpPickup>>,
                       mut p: Query<&mut Player>) {
    let Ok(pt) = player.get_single() else { return; };
    for (e, pt_pickup) in &pickups {
        if pt.translation.distance(pt_pickup.translation) < 24.0 {
            commands.entity(e).despawn();
            for mut pl in &mut p {
                pl.has_double_jump = true;
            }
        }
    }
}

/// Salto simple + salto doble. Solo se puede saltar si `jumps_used < max_jumps`.
/// `max_jumps` depende de `has_double_jump`.
fn handle_jumps(keys: Res<ButtonInput<KeyCode>>, mut q: Query<&mut Player>) {
    let jump_pressed = keys.just_pressed(KeyCode::Space)
        || keys.just_pressed(KeyCode::ArrowUp)
        || keys.just_pressed(KeyCode::KeyW);

    for mut p in &mut q {
        if !jump_pressed { continue; }
        let max_jumps: u32 = if p.has_double_jump { 2 } else { 1 };
        if p.jumps_used < max_jumps {
            p.velocity.y = 520.0;
            p.jumps_used += 1;
            p.on_ground = false;
        }
    }
}

/// Cuando tocas el suelo, reseteas los saltos usados.
fn reset_jumps_on_ground(mut q: Query<&mut Player>) {
    for mut p in &mut q {
        if p.on_ground {
            p.jumps_used = 0;
        }
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((GamePlugin, StatePlugin, PowerUpPlugin))
        .run();
}

Detalles de diseño: aquí el power-up no requiere tener ya el doble salto (es el primero). Lo pones en una cornisa alta a la que se llega saltando y rebotando contra una pared, o saltando desde una plataforma adyacente. Es importante que el jugador sienta que recogerlo cambia el juego. Una vez lo tiene, el pasillo vertical a H3 se vuelve alcanzable (antes le faltaban 40 px de altura; con doble salto, los sobra).

cargo run --bin m10

Recoge el power-up verde en H2. Ahora puedes pulsar Space dos veces seguidas para un doble salto. Vuelve a H1 y sube por el pasillo vertical a H3 para recoger la última gema. Eso es un metroidvania: el power-up que abrió una zona antes cerrada.

**Power-up como verbo**: en metroidvania no progresas matando más fuerte; progresas *adquiriendo verbos*. *Metroid*: morph ball, missiles, hi-jump boots. *Castlevania: SOTN*: doble salto, transformaciones. *Hollow Knight*: dash, wall jump, double jump. El nuestro: doble salto. Pequeño pero significativo: cambia de qué zonas eres dueño.
**Coyote time**: técnica opcional para mejorar el game feel. Aunque el jugador acabe de salir de una plataforma, le permites saltar durante ~100 ms extra. Aquí no lo implementamos por simplicidad, pero el patrón es: un `coyote_timer: f32` que se resetea a 0.1 al tocar el suelo y disminuye cada frame; puedes saltar mientras sea > 0.

28.14 Milestone 11 — Persistencia y audio ★ Pilar: Save + Audio

Dos cosas finales: guardar/cargar la posición y las gemas entre sesiones, y añadir efectos de sonido. Para el guardado usamos serde + std::fs (sin dependencias extra); para el audio, bevy_audio con AudioPlayer.

//! milestones/m11/src/main.rs — SavePlugin + AudioPlugin.
use bevy::prelude::*;
use bevy::input::keyboard::KeyCode;
use serde::{Serialize, Deserialize};
use std::path::PathBuf;

#[derive(Serialize, Deserialize, Default)]
struct SaveData {
    version: u32,            // para migraciones futuras (cap. 28B §28B.7)
    player_x: f32,
    player_y: f32,
    gems: u32,
    has_double_jump: bool,
}

fn save_path() -> PathBuf {
    // En una app real usarías `dirs::data_dir()`. Aquí simplificamos al cwd.
    PathBuf::from("save.json")
}

pub struct SavePlugin;
impl Plugin for SavePlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Update, (
                save_on_keypress.run_if(in_state(AppState::Playing)),
                load_on_keypress.run_if(in_state(AppState::Menu)),
            ));
    }
}

fn save_on_keypress(keys: Res<ButtonInput<KeyCode>>,
                    player: Query<(&Transform, &Player)>) {
    if !keys.just_pressed(KeyCode::F5) { return; }
    let Ok((t, p)) = player.get_single() else { return; };
    let data = SaveData {
        version: 1,
        player_x: t.translation.x,
        player_y: t.translation.y,
        gems: p.gems,
        has_double_jump: p.has_double_jump,
    };
    let json = serde_json::to_string_pretty(&data).expect("serialize");
    std::fs::write(save_path(), json).expect("write save");
    println!("[save] guardado en {:?}", save_path());
}

fn load_on_keypress(mut commands: Commands,
                    keys: Res<ButtonInput<KeyCode>>,
                    mut next: ResMut<NextState<AppState>>,
                    mut player_q: Query<(&mut Transform, &mut Player)>) {
    if !keys.just_pressed(KeyCode::F9) { return; }
    let Ok(raw) = std::fs::read_to_string(save_path()) else {
        println!("[save] no hay archivo de guardado");
        return;
    };
    let data: SaveData = serde_json::from_str(&raw).expect("parse save");
    // Migración trivial: si `data.version < 1`, aplica cambios. (Ver cap. 28B.)
    for (mut t, mut p) in &mut player_q {
        t.translation.x = data.player_x;
        t.translation.y = data.player_y;
        p.gems = data.gems;
        p.has_double_jump = data.has_double_jump;
    }
    next.set(AppState::Playing);
    let _ = commands;
}

Y ahora el audio. La API moderna (0.15+) es por componente AudioPlayer + PlaybackSettings:

//! milestones/m11/src/main.rs (continuación) — AudioPlugin.
use bevy::audio::{AudioPlayer, PlaybackSettings, Volume};

pub struct AudioPlugin;
impl Plugin for AudioPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Update, (
                play_jump_sound.run_if(in_state(AppState::Playing)),
                play_gem_sound.run_if(in_state(AppState::Playing)),
            ));
    }
}

// Disparamos el sonido observando el input (mismo frame que el salto). Si tu juego
// fuera más complejo, usarías un `EventReader<JumpEvent>`. Aquí simplificamos.
fn play_jump_sound(keys: Res<ButtonInput<KeyCode>>,
                   mut commands: Commands,
                   asset_server: Res<AssetServer>) {
    let jump_pressed = keys.just_pressed(KeyCode::Space)
        || keys.just_pressed(KeyCode::ArrowUp)
        || keys.just_pressed(KeyCode::KeyW);
    if jump_pressed {
        // Carga el archivo de sonido y spawnea un `AudioPlayer` con playback único.
        commands.spawn((
            AudioPlayer(asset_server.load("sounds/jump.ogg")),
            PlaybackSettings::once().with_volume(Volume::Linear(0.5)),
        ));
    }
}

fn play_gem_sound(mut commands: Commands,
                  player: Query<&Player, Changed<Player>>,
                  asset_server: Res<AssetServer>) {
    // Si las gemas del jugador cambiaron, suena el pickup.
    for p in &player {
        if p.gems > 0 {
            commands.spawn((
                AudioPlayer(asset_server.load("sounds/gem.ogg")),
                PlaybackSettings::once(),
            ));
        }
    }
}

Nota sobre assets: necesitas dos archivos .ogg en assets/sounds/. Puedes generarlos con cualquier DAW gratuito (Audacity, Reaper) o bajar de bancos libres como freesound.org. El formato .ogg requiere activar la feature vorbis en bevy_audio (la añadimos en el Cargo.toml inicial). Si quieres .mp3, activa mp3 en su lugar.

cargo run --bin m11

# En el juego:
#   F5 (en Playing)  → guarda partida
#   F9 (en Menu)     → carga partida
#   Salto y gemas    → efectos de sonido

¡Y ya tienes un metroidvania real! No es Hollow Knight, pero cumple los tres pilares: mapa interconectado, power-up que abre camino, estados y persistencia. Es tuyo.

28.15 Patrón del capítulo

Patrón "construir incrementalmente, integrar al final": dividir el proyecto en hitos pequeños y ejecutables, cada uno de los cuales funciona solo y se suma al siguiente. La integración final es el penúltimo milestone (M8), no el primero.

Este capítulo exhibe un patrón que atraviesa todo el libro: **no intentes diseñar el sistema completo antes de tener una versión que se mueve**. La secuencia M1 → M11 no es "una forma de organizar el código"; es una forma de organizar tu cabeza. Cada milestone responde una pregunta concreta:

| Milestone | Pregunta |
|-----------|----------|
| M1  | ¿Puedo abrir una ventana y poner algo dentro? |
| M2  | ¿Puedo moverlo con teclado y que choque con algo? |
| M3  | ¿La cámara me sigue sin marear? |
| M4  | ¿Existe un mundo con habitaciones interconectadas? |
| M5  | ¿Hay alguien que quiera matarme? |
| M6  | ¿Tengo algo que recoger? |
| M7  | ¿Hay un objetivo final? |
| M8  | ¿Funciona todo junto sin condiciones de carrera? |
| M9  | ¿Tiene menú, pausa, game over? |
| M10 | ¿Adquiero un verbo que abre zona antes cerrada? |
| M11 | ¿Puedo guardar la partida y oír efectos? |

La tentación del junior es empezar por M11 (porque "el diseño está en mi cabeza") y atascar el código con features que no se pueden probar. La forma senior es construir el esqueleto (M1), darle carne (M2-M6), y por último el alma (M7-M11). **La integración es el penúltimo milestone, no el primero**, porque solo entonces tienes suficientes componentes reales para saber qué vale la pena integrar.

Una variante: si estás en un equipo, cada milestone puede ser un commit etiquetado (`git tag m3`). Permite reproducir bugs viejos con un solo checkout y comparar rendimientos entre versiones.
**¿Sabías que…?** La metodología de *milestones ejecutables* tiene nombre formal: *tracer bullet development*, popularizada por Steve McConnell en *Code Complete* (Microsoft Press, **1993**). La idea: en lugar de escribir todo el código y luego probarlo, escribes un *trazador* mínimo que atraviesa todas las capas (UI, lógica, datos) y lo ejecutas. Luego lo engordas. Funciona en juegos, en webs, hasta en cohetes.
**Metedura de pata #6 (la definitiva)**: saltarse M1-M3 e intentar hacer M11 directamente. Te pasará: pensarás "esto es solo colocar plugins, ¿qué más da?". Pero sin M1-M3 no tienes un *feedback loop* visual. Cuando algo no compile en M11, no sabrás si es el tilemap, el enemigo, el save, el audio o la cámara. Los milestones tempranos **son tu depurador**.
*Castlevania: Symphony of the Night* (Konami, 1997) popularizó la fórmula "exploras, vuelves más fuerte, llegas a la inversa del castillo". El director, Toru Hagihara, dijo en una entrevista que la mitad de los jugadores no llegan a la segunda mitad del castillo. Su solución: añadir un *map progress tracker* para que la gente no se sintiera perdida. Lección: la exploración necesita *feedback* de "voy por buen camino".