//! - Fixed-size grid, some spaces with items and some empty. //! - Each item slot has an image of the item and the item count overlayed on the image. //! - Items can be moved with drag and drop. //! - Both image and item count move along with the cursor while dragging. //! - The image and item count are not visible in the original position while dragging. //! - You can leave the bounding box of the inventory while dragging. //! - A tooltip with the item's name is shown when hovering over an item. // TODO: fix cursor not updating when placing an item in an empty cell and then moving cursor // outside use std::{collections::HashMap, convert::identity, sync::OnceLock}; use bevy::prelude::*; use bevy_asset_loader::prelude::*; use bevy_mod_picking::{ events::{Click, Down, Move, Pointer, Up}, prelude::{Pickable, PointerButton}, }; use haalka::{prelude::*, raw::DeferredUpdaterAppendDirection}; use rand::{ distributions::{Bernoulli, Distribution}, Rng, }; fn main() { App::new() .add_plugins(( DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { position: WindowPosition::Centered(MonitorSelection::Primary), ..default() }), ..default() }), HaalkaPlugin, )) .init_state::() .add_loading_state( LoadingState::new(AssetState::Loading) .continue_to_state(AssetState::Loaded) .load_collection::(), ) .add_systems(Startup, camera) .add_systems(OnEnter(AssetState::Loaded), (set_icon_texture_atlas, ui_root).chain()) .run(); } const CELL_WIDTH: f32 = 70.; const INVENTORY_BACKGROUND_COLOR: Color = Color::hsl(0., 0., 0.78); const CELL_BACKGROUND_COLOR: Color = Color::hsl(0., 0., 0.55); const CELL_HIGHLIGHT_COLOR: Color = Color::hsl(0., 0., 0.83); const CELL_GAP: f32 = 5.; const INVENTORY_SIZE: f32 = 700.; const CELL_BORDER_WIDTH: f32 = 2.; const CELL_DARK_BORDER_COLOR: Color = Color::hsl(0., 0., 0.19); // const CELL_LIGHT_BORDER_COLOR: Color = Color::hsl(0., 0., 0.98); static ITEM_NAMES: Lazy> = Lazy::new(|| { HashMap::from([ (0, "copper dagger"), (1, "copper sword"), (2, "shortbow"), (3, "copper spear"), (4, "copper axe"), (5, "copper mace"), (6, "copper shovel"), (7, "copper pickaxe"), (8, "copper hammer"), (9, "copper scythe"), (10, "steel dagger"), (11, "steel sword"), (12, "longbow"), (13, "steel spear"), (14, "steel axe"), (15, "steel mace"), (16, "steel shovel"), (17, "steel pickaxe"), (18, "steel hammer"), (19, "steel scythe"), (20, "golden dagger"), (21, "golden sword"), (22, "golden longbow"), (23, "golden spear"), (24, "golden axe"), (25, "golden mace"), (26, "golden shovel"), (27, "golden pickaxe"), (28, "golden hammer"), (29, "golden scythe"), (30, "copper arrow"), (31, "steel arrow"), (32, "golden arrow"), (33, "poison arrow"), (34, "fire arrow"), (35, "ice arrow"), (36, "electric arrow"), (37, "charm arrow"), (38, "leather quiver"), (39, "elven quiver"), (40, "apprentice robes"), (41, "common shirt"), (42, "copper armor"), (43, "turtle buckler"), (44, "wooden shield"), (45, "plank shield"), (46, "shoes"), (47, "apprentice hat"), (48, "cloth cap"), (49, "copper helmet"), (50, "mage robes"), (51, "leather armor"), (52, "steel armor"), (53, "wooden buckler"), (54, "reinforced wooden shield"), (55, "steel shield"), (56, "leather boots"), (57, "mage hat"), (58, "leather helmet"), (59, "steel helmet"), (60, "archmage robes"), (61, "elven armor"), (62, "golden armor"), (63, "steel buckler"), (64, "steel round shield"), (65, "golden shield"), (66, "elven boots"), (67, "archmage hat"), (68, "elven helmet"), (69, "golden helmet"), (70, "wooden staff"), (71, "fire staff"), (72, "lightning staff"), (73, "ice staff"), (74, "fire ring"), (75, "lightning ring"), (76, "ice ring"), (77, "fire necklace"), (78, "lightning necklace"), (79, "ice necklace"), (80, "minor healing potion"), (81, "healing potion"), (82, "greater healing potion"), (83, "minor mana potion"), (84, "mana potion"), (85, "greater mana potion"), (86, "yellow potion"), (87, "green potion"), (88, "purple potion"), (89, "flying potion"), (90, "gold coins (small)"), (91, "gold coins (medium)"), (92, "gold coins (big)"), (93, "gold pouch"), (94, "gold chest"), (95, "ruby"), (96, "topaz"), (97, "emerald"), (98, "sapphire"), (99, "diamond"), (100, "map"), (101, "journal"), (102, "satchel"), (103, "backpack"), (104, "pouch"), (105, "chest (small)"), (106, "chest (big)"), (107, "bronze key"), (108, "silver key"), (109, "golden key"), (110, "wood log"), (111, "stone"), (112, "meat"), (113, "cheese"), (114, "apple"), (115, "poisoned apple"), (116, "milk glass"), (117, "egg (white)"), (118, "egg (brown)"), (119, "egg (golden)"), (120, "carrot"), (121, "berries"), (122, "sunflower"), (123, "flower (yellow)"), (124, "flower (blue)"), (125, "flower (red)"), (126, "fishing rod"), (127, "worm"), (128, "fish_1"), (129, "fish_2"), ]) }); // TODO: port to Lazy static ICON_TEXTURE_ATLAS: OnceLock = OnceLock::new(); // using a global handle for this so we don't need to thread the texture atlas handle through the // ui tree when we can guarantee it exists before any cells are inserted fn icon_sheet() -> &'static RpgIconSheet { ICON_TEXTURE_ATLAS .get() .expect("expected ICON_TEXTURE_ATLAS to be initialized") } #[derive(AssetCollection, Resource, Clone, Debug)] struct RpgIconSheet { #[asset(texture_atlas(tile_size_x = 48, tile_size_y = 48, columns = 10, rows = 27))] layout: Handle, #[asset(image(sampler = nearest))] #[asset(path = "rpg_icon_sheet.png")] image: Handle, } fn icon( index_signal: impl Signal + Send + 'static, count_signal: impl Signal + Send + 'static, ) -> Stack { Stack::new() .layer( El::::new() .image(UiImage::from(icon_sheet().image.clone())) .update_raw_el(|raw_el| { raw_el .insert(TextureAtlas::from(icon_sheet().layout.clone())) // TODO: fix grey flash when inserting into an empty cell, making the index static does not // suffice; this might actually be the item label flashing on top before the frame it is moved .on_signal_with_component(index_signal, |mut texture_atlas: Mut, index| { texture_atlas.index = index; }) }), ) .layer( El::::new() .with_style(|mut style| style.top = Val::Px(6.)) .align(Align::new().bottom().right()) .text_signal(count_signal.map(|count| { Text::from_section( count.to_string(), TextStyle { font_size: 40., ..default() }, ) })), ) } #[derive(Clone, Component)] struct CellData { index: Mutable, count: Mutable, } fn cell(cell_data_option: Mutable>, insertable: bool) -> impl Element { let hovered = Mutable::new(false); let original_position = Mutable::new(None); let down = Mutable::new(false); // let stop_propagation_trigger = Mutable::new(false); // let cursor_disabling_forwarder = spawn(sync_neq( // signal::or(cell_data_option.signal_ref(Option::is_none), is_dragging()), // stop_propagation_trigger.clone(), // )); El::::new() .update_raw_el(clone!((cell_data_option, hovered, down/* , stop_propagation_trigger */) move |mut raw_el| { // raw_el = raw_el.hold_tasks([cursor_disabling_forwarder]); if insertable { raw_el = raw_el .insert(( Pickable::default(), On::>::run(clone!((down) move || down.set_neq(false))), )) // `.component_signal` conveniently allows us to reactively add/remove components, // if the provided signal returns `None`, then the component is removed; but the // signal below doesn't look like it returns an `Option`? actually it does thanks to // `.map_true` which is syntactic sugar for `.map(|bool| if bool { Some(...) } else { None }))` .component_signal::>, _>( // we don't want the click listener to trigger if we've just grabbed some of // the stack as it would immediately drop one down, so we track the `Down` state signal::and(signal::not(down.signal()), hovered.signal()).dedupe() .map_true(clone!((cell_data_option) move || { On::>::run(clone!((cell_data_option => self_cell_data_option/* , stop_propagation_trigger */) move |click: Listener>/* , mut commands: Commands */| { let mut consume = false; if let Some(dragging_cell_data_option) = &*DRAGGING_OPTION.lock_ref() { if self_cell_data_option.lock_ref().is_none() { if let Some(dragging_cell_data) = &*dragging_cell_data_option.lock_ref() { self_cell_data_option.set(Some(CellData { index: Mutable::new(dragging_cell_data.index.get()), count: Mutable::new(0), })); } } if let Some((dragging_cell_data, self_cell_data)) = dragging_cell_data_option.lock_ref().as_ref().zip(self_cell_data_option.lock_ref().as_ref()) { if self_cell_data.index.get() == dragging_cell_data.index.get() { let to_add = { if matches!(click.button, PointerButton::Secondary) { *dragging_cell_data.count.lock_mut() -= 1; if dragging_cell_data.count.get() == 0 { consume = true; } 1 } else { let count = dragging_cell_data.count.take(); consume = true; count } }; self_cell_data.count.update(|count| count + to_add); } else { self_cell_data.index.swap(&dragging_cell_data.index); self_cell_data.count.swap(&dragging_cell_data.count); } } } if consume { // because propagation stoppage may not synchronize before the `Over` event is fired, // the `Over` event may leak through to the root, racing the root and cell's cursors; // to avoid this we first notify the cell to stop propagation, and wait for that to // propagate, before clearing the dragging cell data, which fires the `Over` event // // TODO: how can i address this more ergonomically? do bubbling observers help? // let waiter = stop_propagation_trigger.signal().wait_for(true); // async { // waiter.await; if let Some(cell_data_option) = DRAGGING_OPTION.take() { cell_data_option.take(); // commands.entity(click.listener()).remove::(); } // } // .apply(spawn) // .detach(); // stop_propagation_trigger.set_neq(true); } })) })) ); } raw_el .component_signal::>, _>( signal::and(signal::not(is_dragging()), cell_data_option.signal_ref(Option::is_some)).dedupe() .map_true(clone!((cell_data_option, down) move || On::>::run(clone!((cell_data_option, down) move |pointer_down: Listener>| { let to_drag_option = { if pointer_down.button == PointerButton::Secondary { if let Some(cell_data) = &*cell_data_option.lock_ref() { let to_take = (cell_data.count.get() / 2).max(1); cell_data.count.update(|count| count - to_take); Some(CellData { index: Mutable::new(cell_data.index.get()), count: Mutable::new(to_take), }) } else { None } } else { cell_data_option.take() } }; if cell_data_option.lock_ref().as_ref().map(|cell_data| cell_data.count.get() == 0).unwrap_or(false) { cell_data_option.take(); } DRAGGING_OPTION.set(Some(Mutable::new(to_drag_option))); POINTER_POSITION.set(pointer_down.pointer_location.position.into()); down.set_neq(true); })) )) ) })) // alternative to the stop propagation trigger pattern, which is kinda/pretty cringe .cursor_signal( map_ref! { let &populated = cell_data_option.signal_ref(Option::is_some), let &is_dragging = is_dragging() => { if is_dragging { CursorIcon::Grabbing } else if populated { CursorIcon::Grab } else { CursorIcon::Default } } } ) // .cursor_disableable_signal(CursorIcon::Grab, stop_propagation_trigger.signal()) // TODO: this is more idiomatic and should work, but it doesn't due to various shenanigans, see stop_propagation_trigger strat attempt for some exploration // .cursor_disableable_signal(CursorIcon::Grab, signal::or(cell_data_option.signal_ref(Option::is_none), is_dragging())) .hovered_sync(hovered.clone()) .width(Val::Px(CELL_WIDTH)) .height(Val::Px(CELL_WIDTH)) .with_style(|mut style| style.border = UiRect::all(Val::Px(CELL_BORDER_WIDTH))) .background_color_signal( hovered.signal() .map_bool(|| CELL_HIGHLIGHT_COLOR, || CELL_BACKGROUND_COLOR).map(Into::into), ) .border_color(BorderColor(CELL_DARK_BORDER_COLOR)) .child_signal( cell_data_option .signal_cloned() .map_some(move |cell_data| { Stack::::new() .layer(icon(cell_data.index.signal(), cell_data.count.signal())) .layer_signal( signal::and(hovered.signal(), signal::not(is_dragging())).dedupe() .map_true(clone!((original_position) move || El::::new() // TODO: global transform isn't populated on spawn // .with_global_transform(clone!((original_position) move |transform| original_position.set(Some(transform.compute_transform().translation.xy())))) .height(Val::Px(CELL_WIDTH)) .with_style(|mut style| { style.position_type = PositionType::Absolute; style.border = UiRect::all(Val::Px(CELL_BORDER_WIDTH)); style.padding = UiRect::horizontal(Val::Px(10.)); }) .update_raw_el(clone!((original_position) move |raw_el| { raw_el .on_signal_with_entity(POINTER_POSITION.signal(), move |mut entity, (mut left, mut top)| { if let Some(transform) = entity.get::() { // TODO: global transform isn't populated on spawn so we have to set it here if original_position.get().is_none() { original_position.set(Some(transform.compute_transform().translation.xy())); } let original_position = original_position.get().unwrap(); left -= original_position.x - CELL_WIDTH / 2.; top -= original_position.y + CELL_WIDTH / 2.; } if let Some(mut style) = entity.get_mut::