Capítulo 9. Component Hooks: mantener invariantes sin esfuerzo

CAP 09 · Bevy 0.18/0.19
"La diferencia entre un componente y un contrato es que el contrato se cumple solo."

— Anónimo, en un canal de Discord de Bevy, 2024.

9.1 Hooks como automatismos invisibles

En el capítulo anterior vimos cómo #[require] garantiza que un componente esté presente. Eso resuelve la mitad del problema. La otra mitad es mantener el componente coherente con el resto del mundo cuando cambian las condiciones.

Pongamos un caso real. Tienes Player con Health { actual, max } y HealthRegen { per_second: 2 }. La regla de oro: health.actual <= health.max. ¿Quién la aplica? Hoy lo escribes a mano en cuatro sitios (sistema de curación, sistema de daño, sistema de buff, sistema de muerte). El día que se te olvida uno, el mago tiene 9999/100 de vida y todo se rompe.

Analogía absurda: un hook de componente es como el termostato de una olla a presión. Tú no vigilas la presión; la olla tiene un mecanismo que, cuando la temperatura sube demasiado, abre la válvula sola. Si desconectas el mecanismo (desactivas el hook), la olla explota. Si lo dejas conectado, puedes olvidarte de la presión. Lo mismo pasa con los hooks: tú pones el dato, Bevy vigila que se mantenga coherente.

En Bevy 0.19 hay cuatro hooks que puedes enganchar a un componente (módulo bevy::ecs::lifecycle):

HookCuándo se dispara
on_addEl componente se está añadiendo por primera vez a una entidad.
on_insertEl componente se está insertando (incluyendo sobreescrituras).
on_discardEl valor antiguo del componente está a punto de ser descartado (sobrescrito por otro valor, o removido). En 0.19 se renombró desde on_replace (PR #22789) para dejar claro que no es "reemplazo" en sentido estricto: no se dispara si el nuevo valor es idéntico al anterior.
on_removeEl componente está a punto de ser removido de la entidad.

Cambio en 0.19. El viejo on_replace fue renombrado a on_discard (PR #22789, migración _release-content/migration-guides/lifecycle-events.md). Si migras código desde 0.18, sustituye on_replace por on_discard tanto en #[component(...)] como en ComponentHooks::on_replaceon_discard. Semántica nueva: un hook on_discard siempre corre antes de cualquier on_remove, y no se dispara cuando el valor nuevo es igual al viejo.

Además de estos cuatro hooks de componente, en 0.19 aparecieron dos lifecycle events orientados a observers (no a hooks del derive) que cubren el spawn/despawn a nivel de entidad: Spawn y Despawn. Se disparan, respectivamente, cuando una entidad nace (con sus componentes iniciales ya insertados) y cuando se despawnea (un Despawn se dispara por cada componente que tenía). Los verás en acción en el cap. 10 con Trigger<Despawn>; aquí no se declaran en #[component(...)], son eventos que observas.

El orden de ejecución, cuando varios se disparan a la vez, es el del depth-first ordering que vimos en el cap. 8.

9.2 Firmas: cómo se declara un hook

La sintaxis es un atributo más sobre #[derive(Component)]. El hook se asocia a un nombre de función que tú defines:

//! cap-09 — sección 9.2: declarar hooks.
use bevy::prelude::*;

#[derive(Component)]
#[component(on_add = mi_on_add)]
#[component(on_insert = mi_on_insert)]
#[component(on_discard = mi_on_discard)]
#[component(on_remove = mi_on_remove)]
struct Health {
    actual: u32,
    max:   u32,
}

fn mi_on_add(mut world: DeferredWorld, entity: Entity, id: ComponentId) {
    info!("Health añadida a {:?}", entity);
}

fn mi_on_insert(mut world: DeferredWorld, entity: Entity, id: ComponentId) {
    // ...
}

fn mi_on_discard(mut world: DeferredWorld, entity: Entity, id: ComponentId) {
    // El valor antiguo aún está en la entidad; pronto se descarta.
}

fn mi_on_remove(mut world: DeferredWorld, entity: Entity, id: ComponentId) {
    // ...
}

Las cuatro funciones reciben lo mismo: un DeferredWorld (un World con permisos reducidos), la entidad y el ComponentId. Importante: no puedes usar Query ni Res/ResMut directamente dentro del hook porque estos viven en el contexto del sistema, no del hook. Tienes que ir por world.get::<T>() o world.resource::<T>().

La sintaxis moderna consolidada permite declarar los cuatro en un solo atributo. La derive macro de 0.19 acepta la lista de lifecycle hooks en forma corta (sin = func) y la forma larga con función:

//! cap-09 — sección 9.2: sintaxis compacta.
#[derive(Component)]
#[component(immutable, on_add = clamp_on_add)]
struct Position {
    x: f32,
    y: f32,
}

fn clamp_on_add(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
    if let Some(mut p) = world.get_mut::<Position>(entity) {
        p.x = p.x.clamp(-1000.0, 1000.0);
        p.y = p.y.clamp(-1000.0, 1000.0);
    }
}

immutable es un atributo adicional que marca el componente como "nadie lo va a mutar nunca". Habilita optimizaciones internas (los hooks lo aprovechan para no registrar cambios cuando es seguro).

9.3 Uso típico: sincronizar estados derivados

El caso de uso 90% de los hooks es mantener un componente coherente con sus datos vecinos. Ejemplos que verás a lo largo del libro:

  1. Health.actual <= Health.max. Hook on_insert que clampa al insertar. Más un sistema de regeneración que escribe dentro del clamp (ya garantizado).
  2. GlobalTransform siempre actualizado desde Transform. Bevy lo hace internamente con un sistema de PostUpdate, pero en proyectos propios es un hook clarísimo.
  3. ItemStack.count <= ItemStack.capacity. Hook al insertar que descarta lo que sobre (o lo manda al suelo).
  4. Cooldown.timer <= Cooldown.duration. Hook on_insert (o on_discard si necesitas leer el valor antiguo antes de sobrescribir) que resetea el timer si la duración cambió.

Ejemplo ampliado del inventario:

//! cap-09 — sección 9.3: inventario auto-capado.
use bevy::prelude::*;

#[derive(Component)]
#[component(on_insert = capar_inventario)]
struct Inventory {
    peso_actual: u32,
    peso_max:    u32,
}

#[derive(Component)]
struct Item {
    peso: u32,
}

fn capar_inventario(
    mut world: DeferredWorld,
    entity: Entity,
    _: ComponentId,
) {
    // 1) Leemos el inventario recién insertado.
    let inv = match world.get::<Inventory>(entity) {
        Some(i) => *i,
        None => return,
    };

    // 2) Si está sobre el peso máximo, vaciamos al suelo (simplificado).
    if inv.peso_actual > inv.peso_max {
        if let Some(mut w) = world.get_mut::<Inventory>(entity) {
            w.peso_actual = w.peso_max;
        }
        info!("inventario de {:?} capeado a {}", entity, inv.peso_max);
    }

    // 3) Si había un Item atado, sincronizamos (ejemplo adicional).
    if let Some(item) = world.get::<Item>(entity) {
        if let Some(mut w) = world.get_mut::<Inventory>(entity) {
            w.peso_actual = item.peso.min(w.peso_max);
        }
    }
}
//! cap-09 — sección 9.3: spawn que dispara on_insert automáticamente.
fn recoger_item(mut commands: Commands) {
    commands.spawn((
        Inventory { peso_actual: 9999, peso_max: 50 },  // sobre el límite.
        Item      { peso: 30 },
    ));
    // El hook capará peso_actual a 50 antes de que el sistema Update corra.
}

Cuando el frame arranque y los sistemas iteren Query<&Inventory>, ya verán peso_actual = 50. El hook se ejecutó durante el spawn, antes del schedule principal.

9.4 Hooks + required components: el combo definitivo

Capítulo 8 nos daba presencia garantizada. Capítulo 9 nos da coherencia garantizada. Juntos:

//! cap-09 — sección 9.4: required + hook.
#[derive(Component, Default)]
#[component(on_insert = clamp_health)]
#[require(Health { actual: 100, max: 100 })]   // <- require con valor.
struct Player;

fn clamp_health(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
    // Como `Health` es required de Player, sabemos que existe aquí.
    if let Some(mut h) = world.get_mut::<Health>(entity) {
        if h.actual > h.max { h.actual = h.max; }
        if h.max == 0       { h.max = 1; }   // Evita división por cero en UI.
    }
}

Resultado: el programador que hace commands.spawn(Player::default()) recibe una entidad con Health { actual: 100, max: 100 }, ya capeada, ya válida. No hay forma de obtener un Player con Health roto.

Esta combinación es la razón por la que Bevy ECS ha ganado tracción en proyectos medianos: el contrato se vuelve ejecutable, no un comentario en la doc.

Hook (concepto): función que Bevy ejecuta automáticamente cuando un componente se añade, inserta, descarta o remueve.
`on_add` (hook): se dispara la primera vez que un componente aparece en una entidad.
`on_insert` (hook): se dispara en cada inserción, incluyendo sobreescrituras.
`on_discard` (hook, 0.19): se dispara cuando el valor antiguo del componente va a ser descartado (sobrescrito o removido). Renombrado desde `on_replace` (PR #22789). NO se dispara si el nuevo valor es idéntico al anterior.
`on_remove` (hook): se dispara justo antes de que un componente sea removido.
`Spawn` / `Despawn` (lifecycle events, 0.19): eventos a nivel de entidad observables con `Trigger`, no hooks del derive. `Despawn` se dispara por cada componente de la entidad al despawnear.
`DeferredWorld` (tipo): variante de `World` con permisos limitados, pasada a los hooks para acceder al estado del mundo sin saltarse las reglas de borrow.
`#[component(...)]` (atributo): atributo sobre `#[derive(Component)]` que configura hooks, mutabilidad, etc.
`ComponentId` (tipo): identificador opaco de un componente registrado en el mundo.
`immutable` (atributo): marca el componente como no-mutable; Bevy puede saltarse cierto tracking de cambios.

9.5 Hooks vs Observers: cuándo usar cada uno

Aquí empieza la confusión típica. Hooks y observers se parecen pero hacen cosas distintas. Resumen:

AspectoHookObserver
Cuándo correEn el momento exacto de la inserción/reemplazo/removal, dentro del ciclo de mutación.En un sistema separado del schedule, normalmente en Update o PostUpdate.
Sobre quéUn componente concreto.Un evento (Event, EntityEvent) o un cambio de componente (cap. 10).
Puede bloquear el cambioSí (puede modificar el valor antes de que se asiente).No; observa después de que el cambio ocurrió.
Acceso al mundoDeferredWorld (limitado).World completo, igual que un sistema.
Cuántos por evento/componenteUno (asignado en el #[component]).Muchos, posiblemente condicionales.
Costo de ejecuciónBarato, en línea con la mutación.Más caro: schedule dispatch + ordenamiento.

Regla práctica. Si necesitas mantener un invariante local del componente (clamp, sincronización con un vecino), usa hook. Si necesitas reaccionar a un cambio lejano (este componente se modificó, ahora actualiza la UI, manda un evento, etc.), usa observer.

Hay un solapamiento: un hook puede disparar un evento (world.send_event(MyEvent)), y un observer puede modificar componentes. Lo importante es saber qué patrón semántico quieres: "vigila que este dato sea válido" → hook; "reacciona a que este dato cambió" → observer.

9.6 Ejemplo completo: el inventario del herrero

Cerremos el capítulo con un caso más grande: el herrero de un RPG. El herrero tiene inventario, dinero, una lista de recetas conocidas y un estado "está forjando". Cuando termina de forjar (estado pasa a ForgingIdle), el inventario debe perder los materiales consumidos.

//! cap-09 — sección 9.6: caso herrero.
use bevy::prelude::*;

#[derive(Component, Default)]
#[component(on_insert = validar_dinero)]
struct Dinero(u32);

fn validar_dinero(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
    if let Some(mut d) = world.get_mut::<Dinero>(entity) {
        d.0 = d.0.min(1_000_000);   // Nadie tiene más de un millón de oro.
    }
}

#[derive(Component)]
struct Inventario {
    items: Vec<u32>,    // ids de items.
}

#[derive(Component, Default, PartialEq, Eq, Copy, Clone)]
enum EstadoHerrero {
    #[default]
    Idle,
    Forjando { tiempo_restante: u32, receta: u32 },
}

#[derive(Component)]
#[component(on_discard = consumir_materiales)]
struct Herrero;

// Hook: cuando el componente Herrero está a punto de ser descartado
// (sobreescrito o removido, p. ej. al despawnear), soltamos los materiales.
// `on_discard` corre antes del `on_remove`; el valor antiguo aún está accesible.
fn consumir_materiales(
    mut world: DeferredWorld,
    entity: Entity,
    _: ComponentId,
) {
    // Al descartar un herrero, soltamos sus items al suelo.
    if let Some(inv) = world.get::<Inventario>(entity) {
        let items = inv.items.clone();
        let pos = world.get::<Position>(entity).copied();
        if let Some(p) = pos {
            info!("soltando {} items al suelo en {:?}", items.len(), p);
            // spawneamos entidades-suelo... (simplificado)
        }
    }
}

#[derive(Copy, Clone, Default)]
struct Position { x: f32, y: f32 }
//! cap-09 — sección 9.6: transición de estado con hook on_discard.
// `on_discard` (renombrado desde `on_replace` en 0.19) se dispara cuando
// el valor antiguo va a ser descartado: por sobreescritura o por remoción.
// Ojo: NO se dispara si el nuevo valor es idéntico al anterior.
#[derive(Component, Default)]
#[component(on_discard = transicion_estado)]
struct MaquinaDeEstado;

fn transicion_estado(
    mut world: DeferredWorld,
    entity: Entity,
    _: ComponentId,
) {
    // Aquí podríamos loggear cada descarte del componente.
    // Como `MaquinaDeEstado` es Default sin datos, el ejemplo es trivial;
    // el caso real sería un enum de estado con campos.
    info!("componente MaquinaDeEstado descartado en {:?}", entity);
}
❌ Intentar usar `Query` o `Res` directamente dentro de un hook:
fn mi_hook(query: Query<&Position>, mut commands: Commands) { ... }

✅ Pasarlos vía `DeferredWorld`:
fn mi_hook(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
    let pos = world.get::<Position>(entity);
    if let Some(p) = pos { /* ... */ }
}

💡 Por qué: los hooks no son sistemas; no tienen el sistema de inyección de
   dependencias. El `DeferredWorld` es tu única puerta al resto del mundo, y
   además con permisos reducidos (no puedes tomar prestada una query entera
   mientras el hook está corriendo).
❌ Mutar un componente dentro de `on_add` pensando que dispara más hooks:
#[derive(Component)]
#[component(on_add = mi_hook)]
struct A;
fn mi_hook(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
    world.get_mut::<A>(entity).unwrap().x += 1.0;   // <-- bucle potencial.
}

✅ Si necesitas re-accionar, usa un sistema normal en `Update`:
fn mi_sistema(mut query: Query<&mut A, Added<A>>) {
    for mut a in &mut query { a.x += 1.0; }
}

💡 Por qué: los hooks corren en mitad del ciclo de mutación de Bevy. Mutar
   otros componentes puede corromper el orden de inserción y causar bucles
   infinitos o panics de borrow.
★ Dungeon Keeper (Bullfrog, 1997) — cada criatura tenía un "comportamiento"
  central y una lista de modificadores ("enojado", "hambriento", "asustado").
  El motor Intern recalculaba el estado base cada vez que un modificador entraba
  o salía. Esa recalc "al cambio" es exactamente la filosofía de los hooks.
★ Baldur's Gate (BioWare, 1998) — los NPCs tenían una "agenda" diaria. Cuando
  el reloj del juego cruzaba una hora, se recalculaba automáticamente su posición
  y acción. Hoy lo resolverías con `on_discard(HoraDelDia)` o un observer sobre
  el recurso `GameClock`.
★ Hades (Supergiant, 2020) — cuando un dios te da una "bendición", el motor
  actualiza tu lista de buffs, recalcula daño, dispara el icono de UI y
  posiblemente cambia la música. Una parte importante de eso se modela como
  hooks sobre `BuffList` que sincronizan `Stats` derivados.

🎯 Patrón del capítulo: Invariantes auto-cumplidas

"La diferencia entre un componente y un contrato es que el contrato se cumple solo."

— Anónimo, en un canal de Discord de Bevy, 2024.

Tienes un dato compuesto por varios campos relacionados (Health { actual, max }, Inventory { peso, max }, Cooldown { remaining, duration }). Los sistemas que lo modifican son muchos; mantener la invariante a <= b en cada uno es propenso a olvidos.

La solución: declara el invariante como hook on_insert (y/o on_discard) sobre el componente. Cuando alguien lo inserta o sobrescribe con un valor que viola la regla, el hook lo corrige antes de que el frame arranque. Los sistemas que mutan el componente confían en que el invariante está vigente al inicio del frame.

// ❌ MAL: el invariante se mantiene "a mano" en cuatro sitios.
//    El día que se te olvida uno, el mago tiene 9999/100 de vida.
fn sistema_curar(mut query: Query<&mut Health>) {
    for mut h in &mut query { h.actual = (h.actual + 10).min(h.max); }
}
fn sistema_danar(mut query: Query<&mut Health>) {
    for mut h in &mut query { h.actual = h.actual.saturating_sub(20); }   // OK acá.
}
fn sistema_buff(mut query: Query<&mut Health>) {
    for mut h in &mut query { h.actual += 5; }   // <-- rompe: sin clamp.
}
fn sistema_muerte(/* ... */) { /* ... */ }

// ✅ BIEN: el invariante lo carga Bevy. Un solo sitio, imposible de saltarse.
#[derive(Component)]
#[component(on_insert = clamp_health)]
struct Health { actual: u32, max: u32 }

fn clamp_health(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
    if let Some(mut h) = world.get_mut::<Health>(entity) {
        h.actual = h.actual.min(h.max);
    }
}

Cuándo sí.

Cuándo no.

Lo que vimos

En el siguiente

En el cap 10 subimos al siguiente nivel: los observers te permiten reaccionar a eventos arbitrarios (no solo a inserciones/reemplazos) y dispararse desde cualquier sistema. Verás #[derive(EntityEvent)], On<E>, commands.trigger vs world.trigger, el patrón de "N observers para una sola explosión", y por qué push gana a pull cuando tienes 10.000+ entidades pero pocos eventos por frame. También tocaremos un tema que el cap 9 no podía resolver: cómo propagar una reacción a través de la jerarquía de relaciones (bubbling). Programa el café; la cosa se pone intensa.