ui: Multiple inventories support

- Added scrollable container for inventories
- Fixed slots being visibly hovered when dragging items
This commit is contained in:
Alexey 2026-03-13 16:17:44 +03:00
commit 337986d2b9
5 changed files with 113 additions and 43 deletions

21
Cargo.lock generated
View file

@ -952,6 +952,7 @@ dependencies = [
"bevy_transform", "bevy_transform",
"bevy_ui", "bevy_ui",
"bevy_ui_render", "bevy_ui_render",
"bevy_ui_widgets",
"bevy_utils", "bevy_utils",
"bevy_window", "bevy_window",
"bevy_winit", "bevy_winit",
@ -1575,6 +1576,26 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "bevy_ui_widgets"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712"
dependencies = [
"accesskit",
"bevy_a11y",
"bevy_app",
"bevy_camera",
"bevy_ecs",
"bevy_input",
"bevy_input_focus",
"bevy_log",
"bevy_math",
"bevy_picking",
"bevy_reflect",
"bevy_ui",
]
[[package]] [[package]]
name = "bevy_utils" name = "bevy_utils"
version = "0.18.0" version = "0.18.0"

View file

@ -6,7 +6,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
bevy = { version = "0.18.0", features = ["bevy_remote", "debug"] } bevy = { version = "0.18.0", features = ["bevy_remote", "debug", "experimental_bevy_ui_widgets"] }
bevy_common_assets = { version = "0.15.0", features = ["toml"] } bevy_common_assets = { version = "0.15.0", features = ["toml"] }
bevy_input = { version = "0.18.0", features = ["serde", "serialize"] } bevy_input = { version = "0.18.0", features = ["serde", "serialize"] }
leafwing-input-manager = "0.20.0" leafwing-input-manager = "0.20.0"

View file

@ -1,12 +1,16 @@
use std::f32::consts::FRAC_PI_2; use std::f32::consts::FRAC_PI_2;
use bevy::prelude::*; use bevy::{ecs::relationship::RelatedSpawner, prelude::*, ui_widgets::{ControlOrientation, CoreScrollbarThumb, Scrollbar}};
use crate::{inventory::{ActiveInventory, Inventory, item::Item}, ui::{UiRoot, UiRotateEvent, WindowSize}}; use crate::{inventory::{ActiveInventory, Inventory, item::Item}, ui::{UiRoot, UiRotateEvent}};
const UI_SLOT_ASSET_PATH: &'static str = "sprites/ui/inventory_slot.png"; const UI_SLOT_ASSET_PATH: &'static str = "sprites/ui/inventory_slot.png";
const TEMP_ITEM_PATH: &'static str = "sprites/items/choco_bar.png"; const TEMP_ITEM_PATH: &'static str = "sprites/items/choco_bar.png";
#[derive(Component, Reflect)]
#[require(Node)]
pub struct UiInventoryManager;
#[derive(Component, Reflect)] #[derive(Component, Reflect)]
#[require(Node)] #[require(Node)]
pub struct UiInventory(pub Entity); pub struct UiInventory(pub Entity);
@ -53,6 +57,12 @@ fn update_ui_node(item: &Item, mut node: Mut<'_, Node>, mut ui_transform: Mut<'_
ui_transform.rotation = new_ui_transform.rotation; ui_transform.rotation = new_ui_transform.rotation;
} }
fn reset_slots_colors(query: Query<&mut ImageNode, With<UiInventorySlot>>) {
for mut image in query {
image.color = Color::WHITE;
}
}
fn on_slot_over(e: On<Pointer<Over>>, mut query: Query<&mut ImageNode, With<UiInventorySlot>>) { fn on_slot_over(e: On<Pointer<Over>>, mut query: Query<&mut ImageNode, With<UiInventorySlot>>) {
if let Ok(mut image) = query.get_mut(e.event_target()) { if let Ok(mut image) = query.get_mut(e.event_target()) {
image.color = Color::WHITE.darker(0.3); image.color = Color::WHITE.darker(0.3);
@ -155,25 +165,19 @@ fn on_item_drag_drop(
temp_item.position = Some(*new_position); temp_item.position = Some(*new_position);
if inventory.can_replace(item_query.as_readonly(), items, *item_entity, temp_item) { if inventory.can_replace(item_query.as_readonly(), items, *item_entity, temp_item) {
info!("Replaced item");
let mut item = item_query.get_mut(*item_entity).unwrap(); let mut item = item_query.get_mut(*item_entity).unwrap();
item.position = temp_item.position; item.position = temp_item.position;
item.size = temp_item.size; item.size = temp_item.size;
item.rotated = temp_item.rotated; item.rotated = temp_item.rotated;
commands.entity(ui_item_entity).insert(ChildOf(event.event_target())); commands.entity(ui_item_entity).insert(ChildOf(event.event_target()));
commands.entity(*item_entity).insert(ChildOf(*inventory_id));
update_ui_node(item.as_ref(), node, ui_transform); update_ui_node(item.as_ref(), node, ui_transform);
} else { } else {
if let Ok(item) = item_query.get(*item_entity) { if let Ok(item) = item_query.get(*item_entity) {
update_ui_node(item, node, ui_transform); update_ui_node(item, node, ui_transform);
} }
} }
/* commands.run_system_cached(reset_slots_colors);
if inventory.can_move(item_query.as_readonly(), items, *item_entity, *new_position) {
let mut item = item_query.get_mut(*item_entity).unwrap();
item.position = Some(*new_position);
commands.entity(ui_item_entity).insert(ChildOf(event.event_target()));
}
*/
} }
pub fn on_ui_rotate( pub fn on_ui_rotate(
@ -218,27 +222,73 @@ pub fn on_ui_rotate(
} }
} }
fn ui_manager_bundle(children: Vec<Entity>) -> impl Bundle {
(
UiInventoryManager,
Node {
position_type: PositionType::Absolute,
width: percent(50.),
height: percent(100.),
scrollbar_width: 8.,
display: Display::Grid,
grid_template_columns: vec![RepeatedGridTrack::flex(1, 1.), RepeatedGridTrack::auto(1)],
grid_template_rows: vec![RepeatedGridTrack::flex(1, 1.), RepeatedGridTrack::auto(1)],
..default()
},
Pickable::IGNORE,
GlobalZIndex::default(),
Children::spawn(SpawnWith(move |parent: &mut RelatedSpawner<ChildOf>| {
let scroll_area_id = parent.spawn((
Node {
width: percent(100.),
height: percent(100.),
display: Display::Flex,
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceAround,
overflow: Overflow::scroll_y(),
..default()
},
ScrollPosition(Vec2::ZERO),
)).add_children(children.as_slice()).id();
parent.spawn((
Node {
min_width: px(8),
grid_row: GridPlacement::start(1),
grid_column: GridPlacement::start(2),
..default()
},
Scrollbar {
orientation: ControlOrientation::Vertical,
target: scroll_area_id,
min_thumb_length: 8.0,
},
BackgroundColor(Color::hsl(0., 0., 0.5)),
children![(
Node {
position_type: PositionType::Absolute,
border_radius: BorderRadius::all(px(4.)),
..default()
},
BackgroundColor(Color::hsl(0., 0., 0.3)),
CoreScrollbarThumb,
)],
));
})),
)
}
fn ui_inventory_bundle( fn ui_inventory_bundle(
inventory: &Inventory, inventory: &Inventory,
inventory_entity: Entity, inventory_entity: Entity,
window_size: &Res<WindowSize>,
) -> impl Bundle { ) -> impl Bundle {
let window_ratio = window_size.aspect_ratio();
let (width, height) = {
if window_ratio >= 1. {
(auto(), percent(100))
} else {
(percent(100), auto())
}
};
( (
UiInventory(inventory_entity), UiInventory(inventory_entity),
Node { Node {
align_self: AlignSelf::Center, align_self: AlignSelf::Stretch,
align_content: AlignContent::Center, align_content: AlignContent::Center,
display: Display::Grid, display: Display::Grid,
width, width: percent(100.),
height,
aspect_ratio: Some(inventory.size.x as f32 / inventory.size.y as f32), aspect_ratio: Some(inventory.size.x as f32 / inventory.size.y as f32),
grid_auto_columns: vec![GridTrack::percent(100. / inventory.size.x as f32)], grid_auto_columns: vec![GridTrack::percent(100. / inventory.size.x as f32)],
grid_auto_rows: vec![GridTrack::percent(100. / inventory.size.y as f32)], grid_auto_rows: vec![GridTrack::percent(100. / inventory.size.y as f32)],
@ -264,6 +314,7 @@ fn inventory_slot_bundle(x: u32, y: u32, image: Handle<Image>) -> impl Bundle {
height: percent(100.), height: percent(100.),
grid_column: GridPlacement::start(x as i16 + 1), grid_column: GridPlacement::start(x as i16 + 1),
grid_row: GridPlacement::start(y as i16 + 1), grid_row: GridPlacement::start(y as i16 + 1),
aspect_ratio: Some(1.),
..default() ..default()
}, },
Pickable { Pickable {
@ -311,7 +362,6 @@ pub fn setup_ui_inventory(
inventory_query: Query<(Entity, &Inventory, Option<&Children>), With<ActiveInventory>>, inventory_query: Query<(Entity, &Inventory, Option<&Children>), With<ActiveInventory>>,
item_query: Query<&Item>, item_query: Query<&Item>,
root_query: Query<Entity, With<UiRoot>>, root_query: Query<Entity, With<UiRoot>>,
window_size: Res<WindowSize>,
) { ) {
let Ok(root) = root_query.single() else { let Ok(root) = root_query.single() else {
error!("Query contains more than one UiRoot"); error!("Query contains more than one UiRoot");
@ -319,7 +369,8 @@ pub fn setup_ui_inventory(
}; };
let ui_slot_image: Handle<Image> = asset_server.load(UI_SLOT_ASSET_PATH); let ui_slot_image: Handle<Image> = asset_server.load(UI_SLOT_ASSET_PATH);
let temp_item_image: Handle<Image> = asset_server.load(TEMP_ITEM_PATH); let temp_item_image: Handle<Image> = asset_server.load(TEMP_ITEM_PATH);
for (inventory_entity, inventory, children) in inventory_query { let mut inventory_ids = Vec::new();
for (inventory_entity, inventory, children) in inventory_query.iter().sort::<Entity>() {
let items = match children { let items = match children {
Some(children) => { Some(children) => {
children.iter().filter_map(|item_entity| { children.iter().filter_map(|item_entity| {
@ -334,7 +385,7 @@ pub fn setup_ui_inventory(
} }
None => Vec::new(), None => Vec::new(),
}; };
let inventory_entity = commands.spawn(ui_inventory_bundle(inventory, inventory_entity, &window_size)) let inventory_id = commands.spawn(ui_inventory_bundle(inventory, inventory_entity))
.with_children(|commands| { .with_children(|commands| {
for x in 0..inventory.size.x { for y in 0..inventory.size.y { for x in 0..inventory.size.x { for y in 0..inventory.size.y {
let mut slot_commands = commands.spawn(inventory_slot_bundle(x, y, ui_slot_image.clone())); let mut slot_commands = commands.spawn(inventory_slot_bundle(x, y, ui_slot_image.clone()));
@ -353,19 +404,16 @@ pub fn setup_ui_inventory(
}); });
} }
} } } }
}) }).id();
.id(); inventory_ids.push(inventory_id);
commands.entity(root)
.add_child(inventory_entity);
// for simplicity we'll show only first inventory
break;
} }
let inventory_manager = commands.spawn(ui_manager_bundle(inventory_ids)).id();
commands.entity(root).add_child(inventory_manager);
} }
pub fn clear_ui_inventory( pub fn clear_ui_inventory(
mut commands: Commands, mut commands: Commands,
inventory_query: Query<Entity, With<UiInventory>>, inventory_query: Query<Entity, With<UiInventoryManager>>,
) { ) {
for entity in inventory_query { for entity in inventory_query {
commands.entity(entity).despawn(); commands.entity(entity).despawn();

View file

@ -6,7 +6,7 @@ pub mod ui;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
use bevy::prelude::*; use bevy::{prelude::*, ui_widgets::ScrollbarPlugin};
use leafwing_input_manager::prelude::*; use leafwing_input_manager::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -86,6 +86,7 @@ impl Plugin for ExpeditionPlugin {
app.add_plugins(( app.add_plugins((
input::InputAssetPlugin::<InputAction>::default(), input::InputAssetPlugin::<InputAction>::default(),
input::InputAssetPlugin::<UiAction>::default(), input::InputAssetPlugin::<UiAction>::default(),
ScrollbarPlugin,
)) ))
.init_state::<GameState>() .init_state::<GameState>()
.insert_resource(ui::WindowSize::default()) .insert_resource(ui::WindowSize::default())

View file

@ -18,9 +18,12 @@ fn player_bundle(asset_server: &Res<AssetServer>) -> impl Bundle {
Sprite::from_image(image), Sprite::from_image(image),
Transform::from_xyz(0f32, 0f32, 1f32), Transform::from_xyz(0f32, 0f32, 1f32),
Action::default_input_map(), Action::default_input_map(),
Inventory::new(UVec2::new(4, 4)),
ActiveInventory,
Name::new("Player"), Name::new("Player"),
children![
(Inventory::new(UVec2::new(6, 2)), ActiveInventory),
(Inventory::new(UVec2::new(5, 3)), ActiveInventory),
(Inventory::new(UVec2::new(4, 4)), ActiveInventory),
],
) )
} }
@ -35,7 +38,7 @@ pub fn try_insert_item(
) { ) {
let mut item = Item::new(UVec2::new(1, 1)); let mut item = Item::new(UVec2::new(1, 1));
let name = Name::new(format!("Item {}x{}", item.size.x, item.size.y)); let name = Name::new(format!("Item {}x{}", item.size.x, item.size.y));
for (entity, inventory, children) in inventory_query { for (entity, inventory, children) in inventory_query.iter().sort::<Entity>() {
let children = match children { let children = match children {
Some(children) => &children[..], Some(children) => &children[..],
None => &[], None => &[],
@ -48,13 +51,10 @@ pub fn try_insert_item(
item.position = Some(position); item.position = Some(position);
info!("Spawning item {item:?}"); info!("Spawning item {item:?}");
commands.entity(entity).with_child((item, name)); commands.entity(entity).with_child((item, name));
},
None => {
warn!("Inventory does not have space for {}", item.size);
},
}
// only first inventory
break; break;
},
None => (),
}
} }
} }