From 6a8beae3451a751c2d788ec5916069c925ac7572 Mon Sep 17 00:00:00 2001 From: Lucas Farias Date: Sat, 21 Jun 2025 16:22:34 -0300 Subject: [PATCH 1/6] Create `selection_menu` example --- Cargo.toml | 11 + examples/usage/control_flow/selection_menu.rs | 981 ++++++++++++++++++ 2 files changed, 992 insertions(+) create mode 100644 examples/usage/control_flow/selection_menu.rs diff --git a/Cargo.toml b/Cargo.toml index ca29624ef92a2..f2b0d89d29614 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4459,6 +4459,17 @@ description = "Example for cooldown on button clicks" category = "Usage" wasm = true +[[example]] +name = "selection_menu" +path = "examples/usage/control_flow/selection_menu.rs" +doc-scrape-examples = true + +[package.metadata.example.selection_menu] +name = "Selection Menu" +description = "Shows multiple types of selection menu" +category = "Usage" +wasm = true + [[example]] name = "hotpatching_systems" path = "examples/ecs/hotpatching_systems.rs" diff --git a/examples/usage/control_flow/selection_menu.rs b/examples/usage/control_flow/selection_menu.rs new file mode 100644 index 0000000000000..1450158ddcbe7 --- /dev/null +++ b/examples/usage/control_flow/selection_menu.rs @@ -0,0 +1,981 @@ +//! Shows different types of selection menu. +//! +//! [`SelectionMenu::Single`] displays all items in a single horizontal line. + +use std::borrow::BorrowMut; + +use bevy::{ + app::{App, PluginGroup, Startup, Update}, + asset::{AssetServer, Handle}, + color::{Alpha, Color}, + core_pipeline::core_2d::Camera2d, + ecs::{ + bundle::Bundle, + children, + component::Component, + entity::Entity, + hierarchy::Children, + lifecycle::{Insert, Replace}, + name::Name, + observer::On, + query::With, + relationship::Relationship, + schedule::{IntoScheduleConfigs, SystemCondition}, + spawn::{SpawnIter, SpawnRelated}, + system::{Commands, Query, Res, Single}, + }, + image::{Image, ImageSamplerDescriptor, TextureAtlas, TextureAtlasLayout}, + input::{common_conditions::input_just_pressed, keyboard::KeyCode}, + math::{ops, UVec2}, + render::{ + camera::{OrthographicProjection, Projection, ScalingMode}, + texture::ImagePlugin, + view::Visibility, + }, + sprite::Sprite, + state::{ + app::AppExtStates, + commands::CommandsStatesExt, + condition::in_state, + state::{OnEnter, OnExit, States}, + }, + time::Time, + transform::components::Transform, + ui::{ + widget::ImageNode, BackgroundColor, Display, FlexDirection, Node, PositionType, UiRect, + Val, ZIndex, + }, + DefaultPlugins, +}; +use bevy_ecs::query::Has; + +/// How fast the ui background darkens/lightens +const DECAY_FACTOR: f32 = 0.875; +/// Target Ui Background Alpha +const DARK_UI_BACKGROUND: f32 = 0.75; + +fn main() { + let mut app = App::new(); + app.add_plugins(DefaultPlugins.build().set(ImagePlugin { + default_sampler: ImageSamplerDescriptor::nearest(), + })) + .add_plugins(single::SingleSelectionMenuPlugin); + + // Init states used by the example. + // `GameState` indicates if the game is in `Game`, or shown the `SelectionMenu` + // `SelectionMenu` indicates the style of the `SelectionMenu` being shown + app.init_state::().init_state::(); + + // Show or hide the selection menu by using `Tab` + app.add_systems( + Update, + show_selection_menu.run_if(in_state(GameState::Game).and(input_just_pressed(KeyCode::Tab))), + ) + .add_systems( + Update, + hide_selection_menu + .run_if(in_state(GameState::SelectionMenu).and(input_just_pressed(KeyCode::Tab))), + ); + + app + // Initialize inventory + .add_systems(Startup, fill_inventory) + // Observers to present and remove items from the quick slot + .add_observer(present_item) + .add_observer(presented_item_lost) + // Update Ui background's alpha + .add_systems(OnEnter(GameState::SelectionMenu), darker_ui_background) + .add_systems(OnExit(GameState::SelectionMenu), lighten_ui_background) + .add_systems(Update, (update_ui_backgroud, update_image_node_alpha)); + + // For visuals + app.add_systems(Startup, setup_world); + + app.run(); +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, States)] +enum GameState { + #[default] + Game, + SelectionMenu, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, States)] +enum SelectionMenu { + #[default] + Single, + Stacked, +} + +fn show_selection_menu(mut commands: Commands) { + commands.set_state(GameState::SelectionMenu); +} + +fn hide_selection_menu(mut commands: Commands) { + commands.set_state(GameState::Game); +} + +fn present_item( + trigger: On, + mut commands: Commands, + presenters: Query<&PresentingItem, With>, + items: Query<&Sprite, With>, +) { + let Ok(presenter) = presenters.get(trigger.target()) else { + unreachable!("Entity must already have PresentingItem inside the Insert observer."); + }; + let Ok(item) = items.get(presenter.get()) else { + unreachable!("Tried to add an entity that was not an item to the quick slot"); + }; + commands.entity(trigger.target()).insert(ImageNode { + image: item.image.clone(), + texture_atlas: item.texture_atlas.clone(), + ..Default::default() + }); +} + +fn presented_item_lost(trigger: On, mut commands: Commands) { + commands.entity(trigger.target()).despawn_children(); +} + +/// The list of items to show on the selection menu +#[derive(Debug, Component)] +struct Inventory; + +/// An [`Item`] contains a [`Name`], [`Sprite`], [`ItemId`], [`ItemCategory`] +#[derive(Debug, Clone, Bundle)] +struct Item { + name: Name, + sprite: Sprite, + item_id: ItemId, + category: ItemCategory, +} + +/// Unique item id +#[derive(Debug, Clone, Component)] +struct ItemId(u8); + +/// The category that the item belongs to +#[derive(Debug, Clone, Component)] +enum ItemCategory { + Fruit, + Vegetable, + Ingredient, + Condiment, + Protein, + Soup, + Canned, + Hamburger, + Cake, + Chocolate, + Tool, + Liquid, + Cheese, +} + +/// Ui backgroud marker +#[derive(Debug, Component)] +#[require(BackgroundColor = BackgroundColor(Color::BLACK.with_alpha(0.)))] +pub struct UiBackground; + +/// Sets a target alpha an entity. +#[derive(Debug, Component)] +pub struct TargetAlpha(f32); + +/// Marks an entity as having fixed alpha. This prevents it's alpha from being modified +/// even if [`TargetAlpha`] is added. +#[derive(Debug, Component)] +pub struct FixedAlpha; + +/// Marker component for the quick slot ui +#[derive(Debug, Component)] +#[require(Node)] +pub struct QuickSlotUi; + +/// Refers to the entity being displayed on this UI node +#[derive(Debug, Clone, Component)] +#[relationship(relationship_target = PresentedIn)] +pub struct PresentingItem(Entity); + +/// Refers to the UI nodes this item is being presented on +#[derive(Debug, Component)] +#[relationship_target(relationship = PresentingItem)] +pub struct PresentedIn(Vec); + +mod single { + use bevy::{ + app::{Plugin, Startup, Update}, + asset::AssetServer, + color::Color, + ecs::{ + children, + component::Component, + entity::Entity, + hierarchy::Children, + lifecycle::{Insert, Replace}, + name::Name, + observer::On, + query::{Has, With}, + schedule::IntoScheduleConfigs, + spawn::SpawnIter, + system::{Commands, Query, Res, Single}, + }, + input::{keyboard::KeyCode, ButtonInput}, + prelude::SpawnRelated, + render::view::Visibility, + state::{ + app::AppExtStates, + condition::in_state, + state::{ComputedStates, OnEnter, OnExit, States}, + }, + text::{TextColor, TextFont}, + time::Time, + ui::{ + widget::{ImageNode, Text}, + AlignItems, AlignSelf, FlexDirection, JustifyContent, Node, Overflow, PositionType, + ScrollPosition, Val, ZIndex, + }, + }; + + use crate::{GameState, Inventory, ItemId, PresentingItem, QuickSlotUi, SelectionMenu}; + + /// Side lenght of nodes containing images + const NODE_SIDES: f32 = 64. * 2.; + /// Gap between items on the scrollable list + const SCROLL_ITEM_GAP: f32 = 4.; + /// [`ItemNameBox`] width + const ITEM_NAME_BOX_WIDTH: f32 = 112. * 2.; + /// [`ItemNameBox`] height + const ITEM_NAME_BOX_HEIGHT: f32 = 32. * 2.; + + /// Plugin for the Single list selection menu. + /// + /// All items in the inventory are presented in a single horizontal row, and are + /// selected by using the [`KeyCode::ArrowLeft`] or [`KeyCode::ArrowRight`]. + pub struct SingleSelectionMenuPlugin; + + impl Plugin for SingleSelectionMenuPlugin { + fn build(&self, app: &mut bevy::app::App) { + // Creates the UI + app.add_systems( + Startup, + (create_ui, add_cursor_to_first_item) + .chain() + .after(super::fill_inventory) + .after(super::setup_world), + ); + + // Show/hide single selection menu UI + app.add_systems( + OnEnter(SingleSelectionMenuState::Shown), + show_selection_menu, + ) + .add_systems(OnExit(SingleSelectionMenuState::Shown), hide_selection_menu); + + // Update item name box text on cursor move + app.add_observer(drop_item_name).add_observer(add_item_name); + + // Moves [`Cursor`] + app.add_systems( + Update, + (move_cursor, tween_cursor).run_if(in_state(SingleSelectionMenuState::Shown)), + ); + + // Adds item to the quick slot when closing selection menu + app.add_systems(OnExit(SingleSelectionMenuState::Shown), select_item); + + // Single Selection Menu computed state + app.add_computed_state::(); + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + enum SingleSelectionMenuState { + Hidden, + Shown, + } + + impl ComputedStates for SingleSelectionMenuState { + type SourceStates = (GameState, SelectionMenu); + + fn compute(sources: Self::SourceStates) -> Option { + match sources { + (GameState::SelectionMenu, SelectionMenu::Single) => Some(Self::Shown), + (GameState::Game, SelectionMenu::Single) => Some(Self::Hidden), + _ => None, + } + } + } + + /// Marker component for the current selected item + #[derive(Debug, Component)] + struct Cursor; + + /// Marker component for the current selected item + #[derive(Debug, Component)] + struct Tweening { + /// Index of the node that the [`Cursor`] was on before starting + start: usize, + /// Index of the destination node of the [`Cursor`] + end: usize, + /// Time passed since the start of the tweening, this is not real time + time: f32, + } + + /// Marker component for the ui root + #[derive(Debug, Component)] + struct SingleSelectionMenu; + + /// Marker component for the ui node with the list of items + #[derive(Debug, Component)] + struct SingleSelectionMenuScroll { + cursor: usize, + } + + /// Marker component for items presented on the selection menu + #[derive(Debug, Component)] + struct SingleSelectionMenuItem; + + /// Marker component for the box that shows the item name + #[derive(Debug, Component)] + struct ItemNameBox; + + /// Shows the UI for the [`SingleSelectionMenu`] + fn show_selection_menu( + mut commands: Commands, + selection_menu: Single>, + ) { + commands + .entity(*selection_menu) + .insert(Visibility::Inherited); + } + + /// Hides the UI for the [`SingleSelectionMenu`] + fn hide_selection_menu( + mut commands: Commands, + selection_menu: Single>, + ) { + commands.entity(*selection_menu).insert(Visibility::Hidden); + } + + /// Adds [`Cursor`] to the first [`SingleSelectionMenuItem`] + fn add_cursor_to_first_item( + mut commands: Commands, + items: Query>, + ) { + let first = items.iter().next().unwrap(); + commands.entity(first).insert(Cursor); + } + + /// Drops the text from the [`ItemNameBox`] + fn drop_item_name( + _trigger: On, + mut commands: Commands, + item_name_box: Single>, + ) { + commands.entity(*item_name_box).despawn_children(); + } + + /// Add [`Name`] of the current item pointed to by [`Cursor`] + /// to the [`ItemNameBox`] + fn add_item_name( + trigger: On, + mut commands: Commands, + presenting: Query<&PresentingItem, With>, + names: Query<&Name, With>, + item_name_box: Single>, + ) { + let Ok(presented) = presenting.get(trigger.target()) else { + unreachable!("Cursor should only ever be added to SingleSelectionMenuItems"); + }; + let Ok(name) = names.get(presented.0) else { + unreachable!("Cursor should only ever be added to SingleSelectionMenuItems"); + }; + commands.entity(*item_name_box).insert(children![( + Node { + width: Val::Percent(100.), + justify_content: JustifyContent::Center, + align_self: AlignSelf::Center, + ..Default::default() + }, + children![( + Text::new(name.to_string()), + TextFont { + font_size: 12., + ..Default::default() + }, + TextColor(Color::BLACK) + )] + )]); + } + + /// Moves [`Cursor`] in response to [`KeyCode::ArrowLeft`] or [`KeyCode::ArrowRight`] key presses + fn move_cursor( + mut commands: Commands, + key_input: Res>, + tweening: Single<( + Entity, + &mut SingleSelectionMenuScroll, + &Children, + Has, + )>, + ) { + let (entity, mut scroll, children, tweening) = tweening.into_inner(); + if tweening { + return; + } + + let to_left = key_input.pressed(KeyCode::ArrowLeft); + let to_right = key_input.pressed(KeyCode::ArrowRight); + + let move_to = if to_right { + Some((scroll.cursor, (scroll.cursor + 1) % children.len())) + } else if to_left { + Some(( + scroll.cursor, + scroll.cursor.checked_sub(1).unwrap_or(children.len() - 1), + )) + } else { + None + }; + + if let Some((prev, next)) = move_to { + scroll.cursor = next; + commands.entity(children[prev]).remove::(); + commands.entity(children[next]).insert(Cursor); + commands.entity(entity).insert(Tweening { + start: prev, + end: next, + time: 0., + }); + } + } + + /// Scrolls the [`SingleSelectMenuScroll`] so that [`Cursor`] is the middle item + fn tween_cursor( + mut commands: Commands, + scroll: Single< + (Entity, &mut ScrollPosition, &mut Tweening), + With, + >, + time: Res