//! 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::{Has, With}, relationship::Relationship, schedule::{IntoScheduleConfigs, SystemCondition}, spawn::{SpawnIter, SpawnRelated}, system::{Commands, Query, Res, Single}, }, image::{ Image, ImageLoaderSettings, ImageSampler, 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, FlexDirection, Node, PositionType, UiRect, Val, ZIndex, }, DefaultPlugins, }; /// 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_background, 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, #[expect(dead_code, reason = "TODO")] 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)] #[expect(dead_code, reason = "Will be used on sorting later")] 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 background 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}, }, 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 length 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