Capítulo 23. UI con bevy_ui v2: HUDs, menús, navegación con gamepad

CAP 23 · Bevy 0.18/0.19
"La interfaz de usuario es el juego que el jugador juega antes de jugar tu juego."

Nolan Bushnell, fundador de Atari (1972)

Sí, lo sé. "UI" suena a cosa seria, a JavaScript de los 2000, a <div> con position: absolute. Tranqui. En Bevy la UI es ECS pura: nodos como entidades, layout como componentes, interacción como sistemas. Si te aprendiste los capítulos anteriores, esto es pan comido. Si no, vuelve al capítulo 3 y léelo otra vez, pero con café.

23.1 ¿Qué demonios es bevy_ui v2?

Históricamente Bevy tuvo bevy_ui v1 (la versión "experimental que nunca acaba de estabilizarse"). La v2, introducida formalmente con Bevy 0.15 (2024) y consolidada en 0.16/0.17/0.18, es un rewrite completo con:

Flexbox — modelo de layout de CSS donde los hijos de un contenedor se
distribuyen en un eje (row o column) con reglas de grow/shrink/wrap.
En Bevy_ui v2 funciona igual: Node con `flex_direction`, `flex_wrap`,
`justify_content`, `align_items`.

CSS Grid — el primo poderoso de Flexbox. Layout en 2D con filas y
columnas explícitas. En Bevy 0.18 lo activa `GridTrack` y `GridPlacement`.

23.2 El Node, esa bendita entidad

En bevy_ui v2 todo nodo visual es una entidad con Node. El Node reemplaza al viejo Style y se parece más a la API moderna de Taffy (el motor de layout que Bevy usa por dentro).

// crates/ui_basics/src/main.rs
use bevy::prelude::*;
use bevy::ui::{FlexDirection, JustifyContent, AlignItems};

fn spawn_main_menu(mut commands: Commands) {
    commands.spawn((
        // Root: un contenedor a pantalla completa
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            flex_direction: FlexDirection::Column,
            justify_content: JustifyContent::Center,
            align_items: AlignItems::Center,
            ..default()
        },
        BackgroundColor(Color::srgb(0.10, 0.12, 0.18)),
    ))
    .with_children(|parent| {
        // Título
        parent.spawn((
            Node {
                margin: UiRect::bottom(Val::Px(40.0)),
                ..default()
            },
            Text::new("Mi Juego Épico"),
            TextFont {
                font_size: 64.0,
                ..default()
            },
            TextColor(Color::WHITE),
        ));

        // Botón "Jugar"
        parent.spawn((
            Button,                  // marker component de Bevy
            Node {
                width: Val::Px(220.0),
                height: Val::Px(60.0),
                margin: UiRect::vertical(Val::Px(8.0)),
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                ..default()
            },
            // BorderRadius es un COMPONENTE separado en bevy_ui v2 (no un campo de Node).
            BorderRadius::all(Val::Px(8.0)),
            BackgroundColor(Color::srgb(0.20, 0.55, 0.90)),
        ))
        .with_child((
            Text::new("Jugar"),
            TextFont { font_size: 28.0, ..default() },
            TextColor(Color::WHITE),
        ));

        // Botón "Salir"
        parent.spawn((
            Button,
            Node {
                width: Val::Px(220.0),
                height: Val::Px(60.0),
                margin: UiRect::vertical(Val::Px(8.0)),
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                ..default()
            },
            BorderRadius::all(Val::Px(8.0)),
            BackgroundColor(Color::srgb(0.70, 0.25, 0.25)),
        ))
        .with_child((
            Text::new("Salir"),
            TextFont { font_size: 28.0, ..default() },
            TextColor(Color::WHITE),
        ));
    });
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, spawn_main_menu)
        .run();
}
📚 Bevy usa internamente **Taffy** (https://github.com/DioxusLabs/taffy),
un motor de layout Rust que también usa Dioxus, un framework web/desktop
escrito en Rust. Mismo motor, distinta chaqueta.

Anatomía de un nodo

Un Node define cómo se ve a sí mismo dentro de su padre y cómo coloca a sus hijos. Los hijos son entidades separadas conectadas con Children. Cada hijo, a su vez, puede tener su propio Node. Es un árbol. Como el DOM, pero sin las reuniones de condominio.

Node {
    // Tamaño intrínseco
    width:  Val::Px(200.0),       // o Percent(50.0), Auto, Vw(10.0)
    height: Val::Px(100.0),

    // Márgenes (UiRect es top/right/bottom/left)
    margin: UiRect::all(Val::Px(8.0)),
    padding: UiRect::horizontal(Val::Px(16.0)),

    // Layout de hijos
    flex_direction: FlexDirection::Column,
    flex_wrap: FlexWrap::Wrap,
    justify_content: JustifyContent::SpaceBetween,
    align_items: AlignItems::Center,
    row_gap: Val::Px(8.0),
    column_gap: Val::Px(8.0),

    // Borde (grosor): también va aquí como UiRect
    border: UiRect::all(Val::Px(2.0)),
    ..default()
}

// El color del borde va en `BorderColor`, y el radio en `BorderRadius`, AMBOS
// como componentes separados de la entidad (no como campos de Node).
commands.spawn((
    Node { /* ... */ ..default() },
    BorderColor(Color::WHITE),
    BorderRadius::all(Val::Px(8.0)),
));

23.3 Grid: cuando Flex no basta

Flexbox es unidimensional. Cuando quieres una tabla 2D (inventario, HUD con zonas, panel de settings), te toca GridTrack y GridPlacement.

// crates/ui_grid/src/main.rs
use bevy::prelude::*;
use bevy::ui::{GridTrack, GridPlacement, RepeatedGridTrack};

fn spawn_inventory(mut commands: Commands, asset_server: Res<AssetServer>) {
    // Cargamos 6 iconos (imaginarios)
    let items = vec!["🗡️", "🛡️", "🧪", "📜", "💰", "🗝️"];

    commands.spawn((
        Node {
            width: Val::Px(420.0),
            padding: UiRect::all(Val::Px(16.0)),
            display: Display::Grid,
            grid_template_columns: vec![
                GridTrack::px(120.0),
                GridTrack::px(120.0),
                GridTrack::px(120.0),
            ],
            grid_template_rows: vec![
                GridTrack::px(120.0),
                GridTrack::px(120.0),
            ],
            row_gap: Val::Px(8.0),
            column_gap: Val::Px(8.0),
            ..default()
        },
        BackgroundColor(Color::srgb(0.15, 0.15, 0.20)),
    ))
    .with_children(|parent| {
        for (i, item) in items.iter().enumerate() {
            let row = (i / 3) as i16 + 1;
            let col = (i % 3) as i16 + 1;
            parent.spawn((
                Node {
                    width: Val::Percent(100.0),
                    height: Val::Percent(100.0),
                    justify_content: JustifyContent::Center,
                    align_items: AlignItems::Center,
                    ..default()
                },
                BorderRadius::all(Val::Px(12.0)),
                BackgroundColor(Color::srgb(0.30, 0.30, 0.40)),
                GridPlacement::cell(row, col),  // <-- la magia
            )).with_child((
                Text::new(*item),
                TextFont { font_size: 48.0, ..default() },
                TextColor(Color::WHITE),
            ));
        }
    });
}
📚 El algoritmo de Grid Layout fue especificado originalmente por
**W3C** y popularizado por **Mozilla** (Firefox) y **Google Chrome**
alrededor de **2017**. Bevy lo soporta vía Taffy desde **0.15 (2024)**.
❌ Confundir `row` y `col` en `GridPlacement::cell(row, col)`.
Sí, suena tonto. Sí, lo haces tres veces seguidas.
El compilador no te salva porque ambos son `i16`. Usa nombres de variables
explícitos: `let row_idx = i / cols;` y `let col_idx = i % cols;`.

Para grids repetitivos (por ejemplo, un inventario de 10×10) puedes usar RepeatedGridTrack:

grid_template_columns: vec![
    GridTrack::repeat(RepeatedGridTrack::px(10, 64.0)), // 10 columnas de 64px
],

23.4 Automatic directional navigation (0.18) — ¡gamepad friendly!

Esto es la killer feature de bevy_ui v2 para juegos 2D. Bevy detecta qué entidad con Button está al lado (arriba/abajo/izquierda/derecha) según su posición en pantalla y mueve el foco con la stick o el D-pad sin que escribas nada.

// crates/ui_nav/src/main.rs
use bevy::prelude::*;
use bevy::ui::AutomaticDirectionalNavigation;

fn setup(mut commands: Commands) {
    commands.spawn((
        Camera2d,
        // Habilita navegación gamepad global
        AutomaticDirectionalNavigation::default(),
    ));

    // Spawn de los 4 botones (recortado por brevedad)
    // ...
}

// Sistema opcional: visualizar qué botón tiene foco. En 0.19, el componente
// `Interaction` está deprecated; la forma idiomática de detectar click/hover
// es `bevy_picking` (ver §23.12). Mantenemos aquí la versión con `Interaction`
// para迁移 progresiva, pero en código nuevo usa `Pointer<Click>` / `Pointer<Over>`.
//
// 
fn highlight_focus(
    mut q: Query<(&mut BackgroundColor, &Interaction), With<Button>>,
) {
    for (mut bg, interaction) in &mut q {
        *bg = match interaction {
            Interaction::Pressed  => BackgroundColor(Color::srgb(0.40, 0.80, 0.40)),
            Interaction::Hovered  => BackgroundColor(Color::srgb(0.30, 0.60, 0.90)),
            Interaction::None     => BackgroundColor(Color::srgb(0.25, 0.25, 0.35)),
        };
    }
}

Para que el gamepad realmente mueva el foco, el plugin de Bevy de Input ya lo hace por ti con GamepadButtonType::DPadUp/Down/Left/Right y los sticks. Lo único que necesitas es:

  1. Que las entidades botón tengan Button + Node.
  2. Que la cámara tenga AutomaticDirectionalNavigation.

¡Ya! Compila, ejecuta, conecta el mando, mueve el stick. Funciona.

📚 La idea de "spatial focus navigation" la popularizó **id Software**
en **DOOM (1993)**: el menú del juego se navegaba con flechas y el cursor
"sabía" dónde estaba el siguiente botón. 30 años después, sigue siendo
la mejor UX para menús de juego.

23.5 Feathers widgets (0.18+) — el kit oficial incompleto

Feathers es el conjunto oficial de widgets de Bevy, todavía marcado como "experimental" cuando se introdujo en 0.18 (alineado con la versión de Bevy: el crate bevy_feathers sigue la misma numeración que Bevy y en julio 2026 está en 0.19.0). Lo activa el feature opt-in bevy_feathers en tu Cargo.toml y se re-exporta como bevy::feathers:

# Cargo.toml — feature opt-in obligatorio
[dependencies]
bevy = { version = "0.19", features = ["bevy_feathers"] }

Trae botones, checkboxes, sliders, radio buttons y barras de scroll (algunos aún inestables). Importante: los widgets NO se exponen como funciones planas tipo button(parent, "Volver") (esa API no existe). La forma real es a través de templates BSN (Bevy Scene Notation, cap. 12) o de funciones helper estilo button_bundle(...) que devuelven un set de componentes. Ejemplo esquemático:

// Cargo.toml
// bevy = { version = "0.19", features = ["bevy_feathers"] }

use bevy::prelude::*;
use bevy::feathers::theme::Theme;
// Los widgets se instancian vía templates BSN (@FeathersButton, @FeathersSlider, ...)
// o, cuando están disponibles, vía funciones helper `xxx_bundle(...)` que
// devuelven los componentes que tú insertas en tu propia entidad.

fn spawn_settings(mut commands: Commands) {
    commands.spawn((
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            flex_direction: FlexDirection::Column,
            padding: UiRect::all(Val::Px(24.0)),
            row_gap: Val::Px(16.0)),
            ..default()
        },
        Theme::default(),
    )).with_children(|parent| {
        // Ejemplo usando un template BSN (sintaxis simplificada):
        parent.spawn(bsn! {
            FeathersButton { text: "Volver" }
        });

        // O construyendo el bundle a mano a partir de los componentes
        // que define Feathers (consulta la docs del crate para los
        // nombres exactos de cada widget en tu versión).
        // 
    });
}
❌ Llamar a funciones `button(parent, ...)` / `slider(parent, ...)` que
no existen. Feathers no expone widgets como funciones planas con ese
nombre: usa **templates BSN** (`@FeathersButton`) o funciones helper con
sufijo `_bundle(...)`. Si estás en una versión anterior a 0.18 (cuando
se introdujo `bevy_feathers`), no existe el crate y tendrás que construir
tus propios widgets copiando las recetas de los ejemplos de Bevy.

23.6 EditableText (0.19) — escribir texto, el clásico olvidado

Antes de 0.19, escribir texto en una caja de input era un infierno: teclado, IME, copiar/pegar, foco… todo a mano. En Bevy 0.19 (lanzado en junio de 2026) ya está publicado EditableText, un componente que maneja el estado del input y del caret por ti. Vive en bevy::text::EditableText (no en bevy::ui::EditableText).

// crates/ui_editable/src/main.rs
// Requiere Bevy 0.19+
use bevy::prelude::*;
use bevy::text::EditableText;   // path correcto en 0.19

fn spawn_name_input(mut commands: Commands) {
    commands.spawn((
        Node {
            width: Val::Px(300.0),
            height: Val::Px(48.0),
            padding: UiRect::horizontal(Val::Px(12.0)),
            align_items: AlignItems::Center,
            ..default()
        },
        BorderRadius::all(Val::Px(8.0)),
        BackgroundColor(Color::srgb(0.95, 0.95, 0.95)),
    ))
    .with_child((
        EditableText::new("Tu nombre aquí"),
        TextFont { font_size: 20.0, ..default() },
        TextColor(Color::BLACK),
    ));
}

// Para leer lo que escribió el usuario: el método es `.value()`, NO `.text()`.
fn read_name(q: Query<&EditableText, Changed<EditableText>>) {
    for edit in &q {
        info!("El usuario escribió: {:?}", edit.value());
    }
}
📚 El manejo de campos de texto con foco, selección y clipboard es
uno de los problemas más antiguos de la informática. Xerox PARC lo
resolvió (a medias) en **1973** con el **Alto**. Treinta años después
seguimos reinventándolo.

23.7 sickle_ui — cuando Feathers no te alcanza (aviso honesto)

sickle_ui es un crate third-party que añade widgets de alto nivel sobre bevy_ui v2: menús contextuales, ventanas arrastrables, listas con scroll, pestañas, etc. Es lo más parecido a un "editor visual" sin ser editor.

⚠️ Aviso de compatibilidad (julio 2026): la versión publicada de sickle_ui es la 0.4.0 (octubre 2024) y está atascada en Bevy 0.14. No existe release compatible con Bevy 0.15, 0.16, 0.17, 0.18 ni 0.19 a día de hoy. Si estás en Bevy 0.19, este crate no compila: tendrás que bifurcarlo y portarlo tú, o esperar a que la comunidad lo actualice. Para un proyecto nuevo, valora mejor bevy_feathers (oficial) o construir tus propios widgets a mano.

# Cargo.toml — SOLO para Bevy 0.14. No compatible con 0.19.
[dependencies]
sickle_ui = "0.4"   # versión real publicada (oct 2024).
❌ Copiar pegar ejemplos antiguos del estilo `Window::new().title(...)`
o `Button::new().label(...).spawn(win)`. Esas funciones builder NO existen
en la API pública de sickle_ui; son un patrón ficticio que circula por
foros. La API real de sickle_ui usa componentes y spawns ECS estándar
(`commands.spawn((ContextMenuBundle { ... },))`). Lee siempre el repo
y los ejemplos antes de escribir código.
sickle_ui — crate comunitario (no oficial) que ofrece widgets de alto
nivel construidos sobre bevy_ui v2. Útil cuando necesitas un menú
complejo rápido y no quieres reinventar scroll, drag y resize.
Aviso: atascado en Bevy 0.14 desde 2024; verifica compatibilidad.

23.8 La UI como ECS: sistemas, eventos, recursos

La parte clave: no metas la UI en un mega-struct. Trátala como cualquier otro subsistema:

#[derive(Resource, Default)]
struct MenuState {
    current: MenuScreen,
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum MenuScreen {
    #[default]
    Main,
    Settings,
    Pause,
}

// Spawnear un submenú cuando entramos a Settings
fn open_settings(
    mut commands: Commands,
    mut state: ResMut<MenuState>,
    mut events: EventReader<MenuEvent>,
) {
    for ev in events.read() {
        if matches!(ev, MenuEvent::OpenSettings) {
            state.current = MenuScreen::Settings;
            commands.spawn(settings_node_bundle());
        }
    }
}

// Limpiar al salir
fn close_settings(
    mut commands: Commands,
    q: Query<Entity, With<SettingsNode>>,
    mut state: ResMut<MenuState>,
) {
    for e in &q {
        commands.entity(e).despawn_recursive();
    }
    state.current = MenuScreen::Main;
}

🎯 23.9 Patrón del capítulo: "la UI es ECS, trátala igual"

**La interfaz de usuario no es un caso especial. Es un sistema más.

Los nodos son entidades. El layout son componentes. La interacción

son eventos. La navegación es un recurso. Si tu UI tiene estado

encapsulado en structs globales, refactorízala hasta que sea ECS puro.**

Las consecuencias prácticas:

  1. No guardes el EntityId del botón en un campo de tu struct de estado. Usa el componente Interaction y reacciona en un sistema que lo queryee.
  2. No "pongas listeners" a nodos. Spawn menús como hijos de un nodo raíz y despawnéalos con despawn_recursive cuando cambias de pantalla.
  3. No uses singletons de UI (UI_MANAGER.get().show(...)). Un sistema que se ejecute en Update con Query<Entity, With<MenuMarker>> es tu singleton.

23.10 Internacionalización (i18n): tu juego en cinco idiomas

Un juego indie 2D que se publica en Steam, itch.io o la web tiene una chance real de llegar a jugadores que no hablan tu idioma. Si tu UI dice "Start" en inglés y el jugador lee chino, perdés un cliente. La internacionalización (i18n) es la diferencia entre "mi juego" y "el juego de este estudio".

Bevy no trae i18n built-in. Las dos opciones serias son bevy_fluent (sobre el sistema Fluent de Mozilla, completo) y bevy_simple_i18n (más simple, sobre rust-i18n). Para empezar, el segundo es suficiente.

# cap-23 — sección 23.10: Cargo.toml.
[dependencies]
bevy_simple_i18n = "0.5"
//! cap-23 — sección 23.10: setup de i18n.
use bevy::prelude::*;
use bevy_simple_i18n::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(I18nPlugin)
        .insert_resource(Locale::new("es-ES"))  // idioma por defecto.
        .add_systems(Startup, setup_i18n)
        .run();
}

fn setup_i18n(asset_server: Res<AssetServer>, i18n: Res<I18n>) {
    // Cargar los archivos de traducción.
    let _ = asset_server.load("locales/en-US.yml");
    let _ = asset_server.load("locales/es-ES.yml");
    let _ = asset_server.load("locales/ja-JP.yml");
    // Forzar un rebuild inicial.
    i18n.rebuild_all();
}

Y los archivos assets/locales/es-ES.yml:

menu:
  start: "Empezar"
  options: "Opciones"
  quit: "Salir"
game:
  score: "Puntuación: {score}"
  lives: "Vidas: {lives}"
  game_over: "Has perdido. Pulsa R para reiniciar."

En tu UI, usás el componente I18n con la clave del texto, no el texto literal:

//! cap-23 — sección 23.10: UI con i18n.
fn setup_menu(mut commands: Commands, asset_server: Res<AssetServer>) {
    // API moderna: spawn del `Node` directo, sin `NodeBundle`/`Style` (deprecated).
    commands.spawn(Node {
        flex_direction: FlexDirection::Column,
        align_items: AlignItems::Center,
        justify_content: JustifyContent::Center,
        width: Val::Percent(100.0),
        height: Val::Percent(100.0),
        ..default()
    }).with_children(|parent| {
        // Botón Start: el texto se obtiene de la clave "menu.start".
        parent.spawn((
            Button,
            I18n::new("menu.start"),  // se actualiza si cambia el locale.
        ));
    });
}

Cambiar de idioma en runtime:

//! cap-23 — sección 23.10: cambiar idioma.
fn set_locale_english(mut commands: Commands) {
    commands.insert_resource(Locale::new("en-US"));
}

Y todos los componentes I18n se actualizan automáticamente la próxima vez que el sistema i18n corre (típicamente Update).

Trucos prácticos

Cuándo SÍ:

Cuándo NO:

23.11 Accesibilidad (a11y): lo mínimo que no debería faltar

La accesibilidad en juegos es un tema largo. En este apartado nos enfocamos en lo mínimo viable para un juego 2D indie: cuatro cambios que cuestan poco y abren tu juego a jugadores con limitaciones visuales, motoras o cognitivas.

23.11.1 Input rebinding

Ya lo cubre el cap 19 (LWIM tiene un sistema de reasignación de teclas). El resumen: el jugador puede cambiar la tecla de "Saltar" de Espacio a K si quiere. Sin código extra, LWIM te da el flujo. Lo que no hace LWIM por ti: persistir las teclas elegidas entre sesiones (eso es tu trabajo: serializa el InputMap a JSON en disco, y al arrancar lo restauras).

23.11.2 Modo daltónico

Tres de cada cien hombres tienen algún grado de daltonismo. Para un juego 2D indie, el cambio más efectivo es ofrecer paletas alternativas para el "enemigo distingue al aliado" o "daño crítico se ve rojo". Tres paletas: por defecto, deuteranopia (rojo-verdo), protanopia (otra variante rojo-verde). Cargás las paletas como Image assets y swappeás un resource.

//! cap-23 — sección 23.11.2: paletas daltónicas.
use bevy::prelude::*;

#[derive(Resource)]
struct ColorPalette {
    pub default: Handle<Image>,
    pub deuteranopia: Handle<Image>,
    pub protanopia: Handle<Image>,
}

#[derive(States, Clone, Copy, Default, PartialEq, Eq, Hash, Debug)]
enum PaletteMode { #[default] Default, Deuteranopia, Protanopia }

fn apply_palette(
    mut sprites: Query<&mut Handle<Image>, With<ColorByPalette>>,
    palette: Res<ColorPalette>,
    mode: Res<State<PaletteMode>>,
) {
    let new = match mode.get() {
        PaletteMode::Default => palette.default.clone(),
        PaletteMode::Deuteranopia => palette.deuteranopia.clone(),
        PaletteMode::Protanopia => palette.protanopia.clone(),
    };
    for mut h in sprites.iter_mut() { *h = new.clone(); }
}

Truco pro: si querés evitar tres sets de assets, podés hacer un post-process (cap. 14F) que aplique una matriz de color al framebuffer. Más caro en GPU pero cero trabajo de assets.

23.11.3 Subtítulos y texto escalable

Para un juego con diálogo, los subtítulos son obligatorios si querés llegar a jugadores con problemas auditivos. Un sistema simple:

//! cap-23 — sección 23.11.3: subtítulos.
#[derive(Component)]
struct Subtitle {
    pub text: String,
    pub ttl: Timer,
}

fn show_subtitle(
    mut commands: Commands,
    mut events: EventReader<DialogueEvent>,
) {
    for ev in events.read() {
        // API moderna (Bevy 0.15+): NO uses `TextBundle::from_section` (deprecated).
        // Spawn directamente los componentes `Text` + `TextFont` + `TextColor`.
        commands.spawn((
            Subtitle {
                text: ev.text.clone(),
                ttl: Timer::from_seconds(3.0, TimerMode::Once),
            },
            Text::new(ev.text.clone()),
            TextFont {
                font_size: 24.0,
                ..default()
            },
            TextColor(Color::WHITE),
        ));
    }
}

fn expire_subtitles(
    mut commands: Commands,
    time: Res<Time>,
    mut q: Query<(Entity, &mut Subtitle)>,
) {
    for (e, mut s) in q.iter_mut() {
        s.ttl.tick(time.delta());
        if s.ttl.finished() { commands.entity(e).despawn(); }
    }
}

Texto escalable: en el menú de opciones, dejás al jugador elegir entre tres tamaños (FontSize::Small, Normal, Large) y aplicás a todos los Text. Un Resource con el factor, y un sistema que actualice los TextStyle cuando cambia.

23.11.4 Sin destellos (photosensitivity)

Un porcentaje pequeño de jugadores tiene epilepsia fotosensible. Los destellos rápidos de luz (>3 Hz) pueden provocarles convulsiones. La regla: evitá patrones de flash a alta frecuencia. Si tu juego tiene explosiones, niebla eléctrica, o teletransportes, hacé que el flash dure al menos 1/3 de segundo y no se repita más de 3 veces por segundo. La mayoría de motores de juego no avisan de esto; sos vos.

Cuándo SÍ y cuándo NO a11y

Cuándo SÍ:

Cuándo NO:

Lo que vimos

En el siguiente

En el capítulo 24 veremos persistencia: cómo guardar settings con el nuevo SettingsPlugin oficial de 0.19, y entidades enteras con DynamicSceneBuilder + serde, y por qué debes guardar datos, no componentes.