Capítulo 19. Input multiplataforma con leafwing-input-manager

CAP 19 · Bevy 0.18/0.19
"El input es la única parte del juego que el jugador toca con las manos. Si falla, todo lo demás da igual."

— Anónimo, taller de control schemes, GDC 2018.

19.1 La forma equivocada de leer input

Si abres un proyecto de un estudiante de primero, lo más probable es que encuentres algo así:

// cap-19 — sección 19.1: anti-patrón "if keyboard".
fn mover_player(keys: Res<Input<KeyCode>>, mut query: Query<&mut Transform, With<Player>>) {
    for mut t in &mut query {
        if keys.pressed(KeyCode::W) { t.translation.y += 1.0; }
        if keys.pressed(KeyCode::S) { t.translation.y -= 1.0; }
        if keys.pressed(KeyCode::A) { t.translation.x -= 1.0; }
        if keys.pressed(KeyCode::D) { t.translation.x += 1.0; }
    }
}

Funciona, sí. Hasta el día que quieres soportar gamepad, o reasignar teclas, o soportar mando de Xbox y de PlayStation con la misma acción, o tener un menú de rebind visible, o grabar macros. En ese momento el código se convierte en un campo de minas: cada sistema tiene que conocer todos los bindings posibles, no hay una fuente única de verdad, y cambiar una tecla implica recompilar.

La solución estándar de la industria, portada a Rust por el crate leafwing-input-manager (en adelante LWIM), es separar la intención del botón: tú declaras acciones semánticas (Jump, Attack, Move), las mapeas a una colección de bindings (Space, GamepadButton::South, MouseButton::Left), y los sistemas leen acciones. El binding se vuelve un dato configurable, no un detalle cableado al código.

Para seguir los ejemplos del capítulo, añadí a tu Cargo.toml:

# cap-19 — dependencias del capítulo (Bevy 0.19, junio 2026).
[dependencies]
bevy = "0.19"
leafwing-input-manager = "0.21"

Si vienes de Unreal Engine, échale un ojo también a bevy_enhanced_input ("0.26", compatible con Bevy 0.19), una alternativa más reciente inspirada directamente en el sistema Enhanced Input de Unreal: está orientada a observers y trata cada acción como un trigger de ciclo de vida, lo que encaja de forma muy natural con el modelo ECS reactivo de Bevy 0.16+. LWIM y bevy_enhanced_input cubren el mismo nicho con filosofías distintas; este capítulo usa LWIM por ser el más consolidado, pero el patrón "intención semántica" es idéntico en ambos.

Una analogía culinaria: tu sistema no debería leer "se pulsó la tecla 32"; debería leer "el jugador quiere saltar". El primer mensaje es del teclado; el segundo es del juego. Si cambias el teclado, el juego ni se entera.

// cap-19 — sección 19.1: el mismo movimiento, con acciones.
fn mover_player(action: Res<ActionState<PlayerAction>>, mut query: Query<&mut Transform, With<Player>>) {
    let dir = action.axis_pair(&PlayerAction::Move);
    for mut t in &mut query {
        t.translation.x += dir.x;
        t.translation.y += dir.y;
    }
}

Fíjate en el cambio: dir es un Vec2 que vale lo mismo si el jugador usa WASD, un stick analógico o un D-pad. La capa de input normaliza todo a una representación común.

19.2 Action + InputMap: la pareja fundamental

LWIM se apoya en dos tipos:

// cap-19 — sección 19.2: definir el enum de acciones.
use leafwing_input_manager::prelude::*;

#[derive(Actionlike, PartialEq, Eq, Hash, Clone, Copy, Debug, Reflect)]
pub enum PlayerAction {
    Move,
    Jump,
    Attack,
    Dash,
    Pause,
}

#[derive(Actionlike)] genera automáticamente todo el código de hashing, Display, serialización y registro. Reflect te permite exponerlo al editor de Bevy y a bevy_remote (inspector remoto) sin esfuerzo adicional.

El InputMap se construye igual que un HashMap:

// cap-19 — sección 19.2: construir el InputMap.
use bevy::prelude::*;
use leafwing_input_manager::input_map::InputMap;

fn spawn_input_map() -> InputMap<PlayerAction> {
    let mut map = InputMap::default();

    // Movimiento: WASD + flechas + stick izquierdo.
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::W);
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::A);
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::S);
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::D);
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::Up);
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::Left);
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::Down);
    map.insert(PlayerAction::Move, KeyboardVirtualKeyCode::Right);

    // Stick analógico como input compuesto: dirección unitaria.
    map.insert(PlayerAction::Move, DualAxis::left_stick());

    // Acciones digitales.
    map.insert(PlayerAction::Jump, KeyCode::Space);
    map.insert(PlayerAction::Jump, GamepadButton::South);    // A en Xbox, X en PS.
    map.insert(PlayerAction::Attack, MouseButton::Left);
    map.insert(PlayerAction::Attack, GamepadButton::West);   // X en Xbox, □ en PS.
    map.insert(PlayerAction::Dash, KeyCode::LShift);
    map.insert(PlayerAction::Dash, GamepadButton::LeftTrigger);
    map.insert(PlayerAction::Pause, KeyCode::Escape);
    map.insert(PlayerAction::Pause, GamepadButton::Start);

    map
}

Y se inserta como resource:

// cap-19 — sección 19.2: registrar el plugin y el resource.
fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(InputManagerPlugin::<PlayerAction>::default())
        .init_resource::<ActionState<PlayerAction>>()
        .insert_resource(spawn_input_map())
        .add_systems(Startup, setup_player)
        .add_systems(Update, mover_player)
        .run();
}

19.2.1 DualAxis: cuando la acción es continua

Move no es digital (pulsado / no pulsado), es analógico (un vector de dirección con magnitud). LWIM distingue entre:

Cuando usas DualAxis, el crate agrega los dos ejes en un Vec2 normalizado. Eso permite cosas elegantes como:

// cap-19 — sección 19.2.1: leer el stick como Vec2.
let dir: Vec2 = action_state.axis_pair(&PlayerAction::Move);
if dir.length_squared() > 0.0 {
    // dir ya es unitario si está en el borde del stick,
    // y de magnitud menor en la zona muerta.
}

LWIM también aplica automáticamente una zona muerta al stick analógico. Por defecto es del 12.5 %, configurable globalmente con InputMap::set_gamepad_deadzone. Esto resuelve el problema clásico de "el personaje se mueve solo al inicio del nivel porque el stick no está perfectamente centrado".

19.2.2 Gamepad como ciudadano de primera

Una queja habitual de los jugadores de Steam es que un juego "no detecta el mando". El motivo típico: el desarrollador leyó KeyboardInput y se olvidó del resto. Con LWIM esto se resuelve estructuralmente: insertas el binding del gamepad en el InputMap y listo, los dos dispositivos coexisten. El último que pulsa gana, pero LWIM también puede configurarse para que el gamepad tenga prioridad si se detecta actividad reciente.

Para soporte multiplataforma real, conviene activar el plugin de gamepad:

// cap-19 — sección 19.2.2: soporte explícito de gamepads.
.add_plugins(InputManagerPlugin::<PlayerAction>::default())
.add_plugins(GamepadPlugin::default())  // ya viene en DefaultPlugins.

Y respetar las convenciones: South = aceptar/atacar (A/X), East = cancelar/dash (B/○), West = secundario (X/□), North = menú (Y/△). Los sticks Left y Right son dirección y cámara, respectivamente. El botón Start es pausa. Cumplir esto sin pensarlo dos veces es un patrón: "physical layout is convention, not opinion".

19.3 ActionState<A>: cómo leer el estado

ActionState<A> es un Resource que mantiene, para cada acción registrada, cuatro datos útiles:

// cap-19 — sección 19.3: leer el estado de las acciones.
fn combate(
    action: Res<ActionState<PlayerAction>>,
    mut query: Query<&mut Health, With<Enemy>>,
) {
    if action.just_pressed(&PlayerAction::Attack) {
        for mut hp in &mut query {
            hp.0 -= 10;
        }
    }
    if action.just_pressed(&PlayerAction::Jump) {
        // lógica de salto con buffer de input:
        // si llevas menos de 100 ms queriendo saltar pero estás en el suelo,
        // saltas al tocar tierra.
    }
}

19.3.1 Input buffering: el truco pro

Si el jugador pulsa saltar 50 ms antes de tocar el suelo, ¿saltas o no? En juegos de plataforma la respuesta universal es : el juego "recuerda" la intención durante una ventana corta. Esto se llama input buffer y LWIM lo soporta de fábrica con .buffered():

// cap-19 — sección 19.3.1: leer con buffer.
let jump_buffered = action.action_data(&PlayerAction::Jump).buffered; // true si se pulsó hace <N ms.

El buffer vive en ActionState y se limpia cada frame, así que no necesitas timers adicionales.

19.3.2 Chords y combinaciones

LWIM soporta chords (combinaciones de botones) sin librerías externas: registra una variante como binding compuesto.

// cap-19 — sección 19.3.2: chord = dos botones a la vez.
map.insert_chord(
    PlayerAction::Special,
    [KeyCode::LShift, KeyCode::Space],   // Shift+Space.
);

Útil para "ataques especiales" que no quieres que se disparen por accidente con un solo botón.

19.4 Rebind UI: reasignar teclas en pantalla

El santo grial de la accesibilidad es dejar que el jugador reasigne cada acción a cualquier input. LWIM lo expone directamente:

// cap-19 — sección 19.4: entrar en modo rebind.
fn start_rebind(
    mut commands: Commands,
    action_query: Query<(Entity, &PlayerAction), With<RebindButton>>,
    mut interaction_query: Query<&Interaction, With<RebindButton>>,
) {
    for (entity, action) in &action_query {
        if let Ok(Interaction::Pressed) = interaction_query.get(entity) {
            commands.entity(entity).insert(RebindRequested { action: *action });
        }
    }
}

El plugin detecta automáticamente el siguiente input válido que llegue (sea tecla, botón de gamepad o movimiento del stick) y lo asigna a esa acción. Si pulsas Esc, cancelas el rebind.

// cap-19 — sección 19.4: procesar el rebind.
fn process_rebind(
    mut commands: Commands,
    query: Query<(Entity, &RebindRequested)>,
    mut input_map: ResMut<InputMap<PlayerAction>>,
) {
    for (entity, req) in &query {
        if let Some(binding) = read_user_input() {
            input_map.insert(req.action, binding);
            input_map.reset_modified();   // evita un cambio que dispare observers falsos.
        }
        commands.entity(entity).remove::<RebindRequested>();
    }
}

El último reset_modified() es importante: InputMap usa #[derive(DerefMut)] sobre un HashMap, que también implementa DetectChanges. Si lo dejas sin reset, cada frame Bevy cree que el mapa cambió y se generan observers fantasma.

19.5 Virtual joysticks y touch: el enemigo olvidado

El móvil existe, y la mayoría de los juegos 2D viven o mueren en él. LWIM incluye VirtualDPad y VirtualJoystick como widgets listos para usar:

// cap-19 — sección 19.5: spawn de un joystick virtual en pantalla.
fn spawn_virtual_joystick(mut commands: Commands, assets: Res<AssetServer>) {
    commands.spawn((
        VirtualJoystickBundle {
            knob: VirtualJoystickKnobBundle {
                knob_image: assets.load("knob.png").into(),
                background_image: assets.load("joystick_bg.png").into(),
                style: Style {
                    position_type: PositionType::Absolute,
                    bottom: Val::Px(50.0),
                    left: Val::Px(50.0),
                    ..default()
                },
                ..default()
            },
            ..default()
        },
        // Mapea el joystick virtual a la misma acción Move.
        VirtualJoystickAction::<PlayerAction>::new(PlayerAction::Move),
    ));
}

El bundle crea automáticamente el InputMap interno y lo conecta a tu enum. Si combinas esto con detección de plataforma (#[cfg(target_family = "wasm")] o una constante de runtime), puedes servir binarios mobile-friendly sin reescribir la lógica de gameplay.

19.5.1 Detección de touch directo en Bevy (sin LWIM)

A veces no necesitás un joystick virtual: querés un tap en un sprite, un swipe en la pantalla, un pinch. Bevy trae Touches como resource core (no requiere plugin):

//! cap-19 — sección 19.5.1: touch directo.
use bevy::prelude::*;

#[derive(Resource, Default)]
struct TouchState {
    start_positions: Vec<Vec2>,
}

fn touch_input(
    mut touch_state: ResMut<TouchState>,
    touches: Res<Touches>,
) {
    for touch in touches.iter() {
        if touch.state == TouchPhase::Started {
            touch_state.start_positions.push(touch.position);
        }
        if touch.state == TouchPhase::Ended {
            // Calcular el swipe.
            if let Some(start) = touch_state.start_positions.pop() {
                let delta = touch.position - start;
                let distance = delta.length();
                if distance > 50.0 {
                    // Swipe.
                    if delta.x.abs() > delta.y.abs() {
                        if delta.x > 0.0 { println!("Swipe right"); }
                        else { println!("Swipe left"); }
                    } else {
                        if delta.y > 0.0 { println!("Swipe up"); }
                        else { println!("Swipe down"); }
                    }
                } else {
                    println!("Tap");
                }
            }
        }
    }
}

La regla práctica: si el gesto es continuo (movimiento, dirección), joystick virtual. Si el gesto es discreto (tap, swipe, pinch), touch directo.

19.5.2 Gamepad en serio: deadzones y tipos de mando

leafwing-input-manager ya mapea los botones del gamepad. Pero un gamepad real tiene dos gotchas que el cap 19 original (y muchos tutoriales) no mencionan:

  1. Deadzone: los joysticks analógicos, cuando "soltás", no dan cero exacto. Dan 0.05, 0.08, 0.12, dependiendo del mando y de la temperatura. Si tu código lee if x > 0.0, tenés un player que camina solo. Solución: deadzone.
//! cap-19 — sección 19.5.2: deadzone.
use leafwing_input_manager::prelude::*;

const DEADZONE: f32 = 0.15;

fn apply_deadzone(value: f32) -> f32 {
    if value.abs() < DEADZONE { 0.0 } else { value.signum() * (value.abs() - DEADZONE) / (1.0 - DEADZONE) }
}

fn read_movement(player: Query<&ActionState<PlayerAction>>) {
    if let Ok(action) = player.single() {
        let axis = action.axis_pair(&PlayerAction::Move);
        let x = apply_deadzone(axis.x);
        let y = apply_deadzone(axis.y);
        // Usá x, y limpios.
    }
}
  1. Tipos de mando: Xbox, PlayStation, Switch Pro, genéricos. Cada uno tiene un GUID distinto. Bevy los trata por nombre ("Xbox Controller", "DualSense", etc.) o por índice. Para un juego serio, dejá al usuario reasignar botones en un menú (LWIM lo soporta de fábrica).

Caveat: en navegador, el gamepad solo funciona si el usuario interactuó con la página al menos una vez (autoplay policy de los navegadores). Lo mismo que con el audio.

19.6 Mocking input para tests

Si tienes tests de integración de tu gameplay (y deberías), necesitas una forma de inyectar input sin pulsar teclas reales. LWIM expone ActionMock y ActionDiff:

// cap-19 — sección 19.6: test con input simulado.
#[test]
fn dash_consumes_stamina() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins).add_plugins(InputManagerPlugin::<PlayerAction>::default());
    let entity = app.world_mut().spawn((Player, Stamina::default())).id();

    app.update();    // tick inicial.

    // Empuja una pulsación de Dash.
    let mut state = app.world_mut().resource_mut::<ActionState<PlayerAction>>();
    state.action_data_mut(PlayerAction::Dash).pressed = true;

    app.update();

    let stamina = app.world().entity(entity).get::<Stamina>().unwrap();
    assert!(stamina.0 < 100.0);
}

ActionDiff te permite inspeccionar qué cambió entre dos frames: útil para tests que verifican "si el jugador pulsa Jump y luego Attack en el mismo frame, debe entrar en combo".

19.7 Action mocking en escenarios complejos

Cuando el sistema a probar tiene muchos consumidores, conviene un helper:

// cap-19 — sección 19.7: helper de testing.
fn press_action(app: &mut App, action: PlayerAction) {
    let mut state = app.world_mut().resource_mut::<ActionState<PlayerAction>>();
    state.action_data_mut(action).press();
    state.action_data_mut(action).pressed = true;
}

O, si quieres ir más pro, crear un plugin InputMockPlugin que escuche eventos de tu sistema de tests y los inyecte en ActionState. Eso te permite simular combos largos sin escribir update() a mano.

19.8 Patrones y antipatrones

PatrónAntipatrón
Acciones semánticas (Jump, no Space).Hardcodear KeyCode en cada sistema.
InputMap editable en runtime.Bindings quemados en const.
Buffer de input para combos y platformer feel.Input "puro": sólo cuenta si está pressed AHORA.
Gamepad y teclado coexisten desde día uno."Ya añadiremos mando en la versión 1.1".
Rebind UI accesible desde el menú principal.Bindings fijos que obligan a jugar con WASD.

19.9 Trivia: la guerra de los sticks

Nintendo 64 usaba un stick analógico, pero la mayoría de los juegos lo leían como digital (8 direcciones) porque la memoria y los programadores no estaban listos para datos continuos. Super Smash Bros. (1999) fue una excepción notable: el stick izquierdo determinaba la fuerza del ataque según la magnitud. La transición "digital → analógico" en la cultura del jugador tardó casi una década en completarse. En LWIM, esa transición es un parámetro: insertas DualAxis::left_stick() en vez de GamepadButtonType::DPadUp y obtienes un input continuo sin tocar el resto del código.

19.10 Glosario del capítulo

19.11 Metedura de pata: olvidar reset_modified()

InputMap envuelve un HashMap, que a su vez implementa DetectChangesMut (sistema de Bevy para detectar qué resources cambiaron desde el último frame). Cada vez que llamas a insert() o clear(), Bevy marca el resource como modificado. Si reasignas bindings desde un menú de opciones, todos los sistemas con Changed<InputMap<PlayerAction>> se vuelven a ejecutar. La solución:

// cap-19 — sección 19.11: reset_modified tras cambios programáticos.
input_map.insert(action, binding);
input_map.reset_modified();

Otro clásico: derivar Actionlike sin PartialEq + Eq + Hash + Clone + Copy + Debug + Reflect. Sin esos traits, el enum no se puede usar como clave de HashMap ni como discriminante de eventos, y vas a perder horas viendo errores de tipo crípticos.

19.12 Patrón del capítulo: "intención semántica"

Nombre: intención semántica.

Resumen: el código de gameplay nunca debería conocer dispositivos concretos. Lee acciones (Jump), no pulsaciones (Space).

Aplicar cuando: cualquier sistema que responda al jugador (movimiento, combate, menú, pausa).

No aplicar cuando: debug overlays que sí quieren mostrar el botón físico pulsado.

Costo: una capa extra de indirección (un resource) y un trait más a derivar.

Beneficio: portabilidad multiplataforma, rebind gratis, tests triviales, accesibilidad casi resuelta.

Esta idea es tan transversal que la verás otra vez en el cap 25 (networking: los inputs del jugador son eventos que viajan por la red como acciones, no como teclas) y en el cap 27 (plugins: los bindings también deberían ser datos, no código). Cuando un patrón aparece tres veces, es un patrón.

Lo que vimos

En el siguiente

En el cap 20 cambiamos completamente de tercio: las FSM (Finite State Machines) para modelar el comportamiento de personajes y NPCs. Verás por qué un enum EstadoPlayer { Idle, Corriendo, Saltando, Atacando, Derrotado } con transiciones explícitas es la forma más clara (y menos buggy) de organizar la lógica de un personaje, y cómo Bevy 0.16+ integra las FSM con States y SubStates de primera clase. Después veremos BT (Behavior Trees) como alternativa cuando las FSM se quedan cortas, y el patrón "stack de estados" para combos y acciones encadenadas. Si el cap 19 era "el jugador quiere", el cap 20 es "el jugador está haciendo". Cambio de chip y a por ello.