diff --git a/assets/items/enforcer.scn.ron b/assets/items/enforcer.scn.ron index 9c0d477b..a211320e 100644 --- a/assets/items/enforcer.scn.ron +++ b/assets/items/enforcer.scn.ron @@ -10,7 +10,8 @@ id: "models/items/enforcer.glb#Mesh0/Primitive0" ), "ssnt::items::Item": ( - name: "Enforcer Handgun" + name: "Enforcer Handgun", + size: (x: 3, y: 2), ), "ssnt::combat::ranged::Gun": ( ), diff --git a/assets/items/gray_backpack.scn.ron b/assets/items/gray_backpack.scn.ron index 57343c8a..97be14eb 100644 --- a/assets/items/gray_backpack.scn.ron +++ b/assets/items/gray_backpack.scn.ron @@ -16,7 +16,7 @@ clothing_type: "back", ), "ssnt::items::containers::Container": ( - size: (x: 10, y: 10), + size: (x: 6, y: 5), ), "physics::RigidBody": ( kind: Dynamic diff --git a/src/interaction.rs b/src/interaction.rs index 3c8ca798..147bd647 100644 --- a/src/interaction.rs +++ b/src/interaction.rs @@ -177,7 +177,9 @@ pub enum InteractionStatus { pub enum InteractionSpecificity { /// The interaction is available on a limited set of specific objects. Specific, - // The interaction is available on many objects. + // The interaction is available on a class of objects. + Common, + // The interaction is available on most objects. Generic, } diff --git a/src/items/containers.rs b/src/items/containers.rs index 4f762c6b..fd198952 100644 --- a/src/items/containers.rs +++ b/src/items/containers.rs @@ -14,6 +14,8 @@ use utils::task::{Task, Tasks}; use super::{Item, StoredItem}; +mod ui; + pub struct ContainerPlugin; impl Plugin for ContainerPlugin { @@ -31,6 +33,8 @@ impl Plugin for ContainerPlugin { ) .add_systems(Update, (cleanup_deleted_entities, do_item_move).chain()); } + + app.add_plugins(ui::ContainerUiPlugin); } } @@ -82,11 +86,12 @@ impl Container { for (&other_position, &entity) in self.items.iter() { let other_item = items_query.get(entity).unwrap(); - let x_overlap = (position.x as i32 - other_position.x as i32).unsigned_abs() * 2 - < item.size.x + other_item.size.x; - let y_overlap = (position.y as i32 - other_position.y as i32).unsigned_abs() * 2 - < item.size.y + other_item.size.y; - + let x_overlap = (other_position.x..(other_position.x + other_item.size.x)) + .contains(&position.x) + || (position.x..(position.x + item.size.x)).contains(&other_position.x); + let y_overlap = (other_position.y..(other_position.y + other_item.size.y)) + .contains(&position.y) + || (position.y..(position.y + item.size.y)).contains(&other_position.y); if x_overlap && y_overlap { return false; } @@ -95,6 +100,24 @@ impl Container { true } + fn find_space(&self, items_query: &Query<&Item>, item: &Item) -> Option { + let mut current = UVec2::ZERO; + while current.x < self.size.x && current.y < self.size.y { + // TODO: This is very inefficient <3 + if self.can_fit(items_query, item, current) { + return Some(current); + } + + current += UVec2::X; + if current.x >= self.size.x { + current.x = 0; + current.y += 1; + } + } + + None + } + pub fn iter(&self) -> impl Iterator { self.items.iter() } @@ -149,6 +172,11 @@ fn do_item_move( return MoveItemResult { success: false }; }; + if data.container == Some(data.item) { + error!(task = ?data, "Tried to store a container inside itself"); + return MoveItemResult { success: false }; + } + // Remove from old container if it exists if let Some(stored) = stored.as_mut() { let mut container = containers.get_mut(*stored.container).unwrap(); @@ -186,7 +214,9 @@ fn do_item_move( return MoveItemResult { success: false }; }; - let position = data.position.expect("Automatic position not supported yet"); + let position = data + .position + .unwrap_or_else(|| container.find_space(&only_items, item).unwrap_or_default()); if !container.can_fit(&only_items, item, position) { warn!(task = ?data, "Failed to move item because it does not fit in the container"); return MoveItemResult { success: false }; @@ -195,10 +225,12 @@ fn do_item_move( container.insert_item_unchecked(data.item, position); if let Some(stored) = stored.as_mut() { *stored.container = container_entity; + *stored.slot = position; *stored.visible = container.items_visible; } else { commands.entity(data.item).insert(StoredItem { container: container_entity.into(), + slot: position.into(), visible: container.items_visible.into(), }); } diff --git a/src/items/containers/ui.rs b/src/items/containers/ui.rs new file mode 100644 index 00000000..deff2276 --- /dev/null +++ b/src/items/containers/ui.rs @@ -0,0 +1,431 @@ +use bevy::{prelude::*, reflect::TypeUuid, utils::HashMap}; +use bevy_egui::{egui, EguiContexts}; +use networking::{ + component::AppExt as _, + identity::{EntityCommandsExt as _, NetworkIdentities, NetworkIdentity}, + is_server, + messaging::{AppExt, MessageEvent, MessageSender}, + variable::{NetworkVar, ServerVar}, + visibility::AlwaysVisible, + Networked, +}; +use serde::{Deserialize, Serialize}; +use utils::task::Tasks; + +use crate::{ + interaction::{ + ActiveInteraction, GenerateInteractionList, InteractionListEvents, InteractionOption, + InteractionSpecificity, InteractionStatus, + }, + items::{Item, StoredItemClient}, + ui::{has_window, CloseUiMessage, NetworkUi}, +}; + +use super::{Container, MoveItem}; + +pub struct ContainerUiPlugin; + +impl Plugin for ContainerUiPlugin { + fn build(&self, app: &mut App) { + app.add_networked_component::() + .add_network_message::(); + if is_server(app) { + app.register_type::() + .register_type::() + .add_systems( + Update, + ( + view_interaction, + insert_interaction, + (prepare_view_interaction, prepare_insert_interaction) + .in_set(GenerateInteractionList), + handle_move_message, + ), + ); + } else { + app.init_resource::() + .add_systems(Update, container_ui.run_if(has_window)); + } + } +} + +#[derive(Component, Networked)] +#[networked(client = "ContainerUiClient")] +struct ContainerUi { + container: NetworkVar, +} + +#[derive(Component, TypeUuid, Default, Networked)] +#[uuid = "56ca80f9-e239-48f9-86c3-4bf06249ec0e"] +#[networked(server = "ContainerUi")] +struct ContainerUiClient { + container: ServerVar, +} + +#[derive(Resource, Default)] +struct DraggedItem { + info: Option, +} + +struct DragInfo { + entity: Entity, + size: UVec2, + over_anything: bool, + just_dropped: bool, +} + +const SLOT_SIZE: egui::Vec2 = egui::vec2(36.0, 36.0); + +#[allow(clippy::too_many_arguments)] +fn container_ui( + mut contexts: EguiContexts, + uis: Query<(Entity, &NetworkIdentity, &ContainerUiClient)>, + mut items: Query<(Entity, &NetworkIdentity, &Item, &mut StoredItemClient)>, + containers: Query<(&Container, &Children)>, + identities: Res, + mut dragged: ResMut, + mut sender: MessageSender, + mut commands: Commands, +) { + for (ui_entity, identity, container_ui) in uis.iter() { + let Some(container_entity) = identities.get_entity(*container_ui.container) else { + continue; + }; + let Ok((container, children)) = containers.get(container_entity) else { + continue; + }; + + let stored: HashMap<_, _> = items + .iter_many(children) + .map(|(entity, _, item, stored)| (*stored.slot, (entity, item.name.clone(), item.size))) + .collect(); + + let mut keep_open = true; + egui::Window::new("Container") + .id(egui::Id::new(("container", ui_entity))) + .open(&mut keep_open) + .show(contexts.ctx_mut(), |ui| { + let anything_dragged = ui.memory(|mem| mem.is_anything_being_dragged()); + if !anything_dragged { + dragged.info = None; + } + + let (grid_rect, grid_response) = ui.allocate_at_least( + egui::vec2( + SLOT_SIZE.x * container.size.x as f32, + SLOT_SIZE.y * container.size.y as f32, + ), + egui::Sense::hover(), + ); + + // Find where dragged item would be dropped + let mut drop_item = None; + if let Some(info) = dragged.info.as_ref() { + if let Some(hover_pos) = grid_response.hover_pos() { + let offset = hover_pos + - egui::vec2( + (info.size.x) as f32 * SLOT_SIZE.x / 2.0, + (info.size.y) as f32 * SLOT_SIZE.y / 2.0, + ) + - grid_rect.left_top(); + let position = UVec2::new( + (offset.x / SLOT_SIZE.x).round() as u32, + (offset.y / SLOT_SIZE.y).round() as u32, + ); + drop_item = Some((info.entity, position, info.size)); + } + } + + // Paint slots + for y in 0..container.size.y { + for x in 0..container.size.x { + let slot_rect = egui::Rect::from_min_size( + egui::pos2(x as f32 * SLOT_SIZE.x, y as f32 * SLOT_SIZE.y), + egui::vec2(SLOT_SIZE.x, SLOT_SIZE.y), + ) + .translate(grid_rect.left_top().to_vec2()); + ui.painter().rect( + slot_rect, + 0., + egui::Color32::from_gray(32), + egui::Stroke::new(0.8, egui::Color32::from_gray(80)), + ); + } + } + + // Draw dragged item preview in container + if let Some((entity, position, size)) = drop_item { + let x_slots_to_draw = size.x.min(container.size.x - position.x); + let y_slots_to_draw = size.y.min(container.size.y - position.y); + let out_of_bounds = x_slots_to_draw != size.x || y_slots_to_draw != size.y; + if x_slots_to_draw != 0 && y_slots_to_draw != 0 { + dragged.info.as_mut().unwrap().over_anything = true; + + let item_rect = egui::Rect::from_min_size( + egui::pos2( + position.x as f32 * SLOT_SIZE.x, + position.y as f32 * SLOT_SIZE.y, + ), + egui::vec2( + SLOT_SIZE.x * x_slots_to_draw as f32, + SLOT_SIZE.y * y_slots_to_draw as f32, + ), + ) + .translate(grid_rect.left_top().to_vec2()); + ui.painter().rect( + item_rect, + 0., + if out_of_bounds { + egui::Color32::RED + } else { + egui::Color32::GREEN + } + .gamma_multiply(0.25), + egui::Stroke::NONE, + ); + } + + if !out_of_bounds { + // Drop if pointer released + if ui.input(|i| i.pointer.any_released()) { + if let Ok((item_entity, &identity, _, mut item)) = items.get_mut(entity) + { + // Tell server to move it + sender.send_to_server(&MoveItemMessage { + item: identity, + to_container: Some(*container_ui.container), + to_slot: position, + }); + // Predict item move + // TODO: actually do rollback on fail + item.slot.set(position); + item.container.set(*container_ui.container); + commands.entity(item_entity).set_parent(container_entity); + + dragged.info.as_mut().unwrap().just_dropped = true; + } + } + } + } + + // Paint all items in container + for (position, (item_entity, name, size)) in stored.iter() { + let id = egui::Id::new(item_entity).with(container_entity); + let is_being_dragged = ui.memory(|mem| mem.is_being_dragged(id)); + let item_rect = egui::Rect::from_min_size( + egui::pos2( + position.x as f32 * SLOT_SIZE.x, + position.y as f32 * SLOT_SIZE.y, + ), + egui::vec2(SLOT_SIZE.x * size.x as f32, SLOT_SIZE.y * size.y as f32), + ) + .translate(grid_rect.left_top().to_vec2()); + if is_being_dragged { + if dragged.info.is_none() { + dragged.info = Some(DragInfo { + entity: *item_entity, + size: *size, + over_anything: false, + just_dropped: false, + }); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + + let layer_id = egui::LayerId::new(egui::Order::Tooltip, id); + ui.with_layer_id(layer_id, |ui| { + draw_item(ui, item_rect, name); + }); + + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + let delta = pointer_pos - item_rect.center(); + ui.ctx().translate_layer(layer_id, delta); + } + } else { + ui.interact(item_rect, id, egui::Sense::drag()); + draw_item(ui, item_rect, name); + } + } + }); + + if !keep_open { + sender.send_to_server(&CloseUiMessage { ui: *identity }); + } + } + + // Dropping over empty space + if let Some(info) = dragged.info.as_ref() { + if !info.over_anything + && !info.just_dropped + && contexts.ctx_mut().input(|i| i.pointer.any_released()) + { + let Ok((_, &item, ..)) = items.get(info.entity) else { + return; + }; + sender.send_to_server(&MoveItemMessage { + item, + to_container: None, + to_slot: UVec2::ZERO, + }); + } + } + + // Remove old drag info + if let Some(info) = dragged.info.as_mut() { + if info.just_dropped { + dragged.info = None; + } else { + info.over_anything = false; + } + } +} + +fn draw_item(ui: &mut egui::Ui, item_rect: egui::Rect, name: &str) { + ui.painter().rect( + item_rect, + 0., + egui::Color32::from_white_alpha(16), + egui::Stroke::new(1.0, egui::Color32::WHITE), + ); + let styled_text = egui::RichText::new(name) + .size(11.0) + .color(egui::Color32::WHITE); + ui.put(item_rect, egui::Label::new(styled_text)); +} + +#[derive(Serialize, Deserialize)] +struct MoveItemMessage { + item: NetworkIdentity, + to_container: Option, + to_slot: UVec2, +} + +fn handle_move_message( + mut messages: EventReader>, + identities: Res, + mut item_moves: ResMut>, +) { + for event in messages.iter() { + let message = &event.message; + let Some(item_entity) = identities.get_entity(message.item) else { + continue; + }; + let container_entity = message.to_container.and_then(|i| identities.get_entity(i)); + item_moves.create_ignore(MoveItem { + item: item_entity, + container: container_entity, + position: Some(message.to_slot), + }) + + // TODO: Support rollback + } +} +#[derive(Component, Reflect, Default)] +#[reflect(Component)] +#[component(storage = "SparseSet")] +struct ViewContainerInteraction {} + +fn prepare_view_interaction( + interaction_lists: Res, + containers: Query<&Container>, +) { + for event in interaction_lists.events.iter() { + let Ok(_) = containers.get(event.target) else { + continue; + }; + + event.add_interaction(InteractionOption { + text: "View container".into(), + interaction: Box::::default(), + specificity: InteractionSpecificity::Common, + }); + } +} + +fn view_interaction( + mut query: Query<( + Entity, + &mut ViewContainerInteraction, + &mut ActiveInteraction, + )>, + containers: Query<&NetworkIdentity, With>, + mut commands: Commands, +) { + for (source, _, mut active) in query.iter_mut() { + let Ok(identity) = containers.get(active.target) else { + active.status = InteractionStatus::Canceled; + continue; + }; + + commands + .spawn(( + NetworkUi, + ContainerUi { + container: (*identity).into(), + }, + AlwaysVisible::single(source), + )) + .networked(); + active.status = InteractionStatus::Completed; + } +} + +#[derive(Component, Reflect)] +#[reflect(Component)] +#[component(storage = "SparseSet")] +struct InsertItemInteraction { + item: Entity, +} + +impl FromWorld for InsertItemInteraction { + fn from_world(_: &mut World) -> Self { + Self { + item: Entity::PLACEHOLDER, + } + } +} + +fn prepare_insert_interaction( + interaction_lists: Res, + containers: Query<&Container>, +) { + for event in interaction_lists.events.iter() { + let Ok(_) = containers.get(event.target) else { + continue; + }; + + let Some(item) = event.item_in_hand else { + continue; + }; + + // Don't let a container be inserted into itself + if event.target == item { + continue; + } + + event.add_interaction(InteractionOption { + text: "Insert".into(), + interaction: Box::new(InsertItemInteraction { item }), + specificity: InteractionSpecificity::Common, + }); + } +} + +fn insert_interaction( + mut query: Query<(Entity, &mut InsertItemInteraction, &mut ActiveInteraction)>, + containers: Query>, + mut move_tasks: ResMut>, +) { + for (_, interaction, mut active) in query.iter_mut() { + let Ok(container) = containers.get(active.target) else { + active.status = InteractionStatus::Canceled; + continue; + }; + + move_tasks.create_ignore(MoveItem { + item: interaction.item, + container: Some(container), + position: None, + }); + active.status = InteractionStatus::Completed; + } +} diff --git a/src/items/mod.rs b/src/items/mod.rs index 1deebe00..b3eba233 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -56,6 +56,7 @@ pub struct StoredItem { with = "Self::network_container(Res<'static, NetworkIdentities>) -> NetworkIdentity" )] container: NetworkVar, + slot: NetworkVar, visible: NetworkVar, } @@ -76,6 +77,7 @@ impl StoredItem { #[networked(server = "StoredItem")] pub struct StoredItemClient { container: ServerVar, + slot: ServerVar, visible: ServerVar, }