diff --git a/assets/crop.aseprite b/assets/crop.aseprite new file mode 100644 index 0000000..0178a31 Binary files /dev/null and b/assets/crop.aseprite differ diff --git a/src/features/grid/components.rs b/src/features/grid/components.rs index 6875de2..a9a62a1 100644 --- a/src/features/grid/components.rs +++ b/src/features/grid/components.rs @@ -7,18 +7,23 @@ pub struct Tile { pub y: u32, } -#[derive(Component, Default, Serialize, Deserialize, Clone, Copy, Debug)] +#[derive(Component)] +pub struct CropVisual; + +#[derive(Component, Default, Serialize, Deserialize, Clone, Debug)] pub enum TileState { #[default] Unclaimed, Empty, - Occupied, + Occupied { + seed: ItemType, + }, } impl TileState { pub fn is_blocking(&self) -> bool { match self { - TileState::Occupied => true, + TileState::Occupied { .. } => true, _ => false, } } @@ -60,4 +65,4 @@ impl Grid { *tile_state = mapper(&*tile_state); Ok(()) } -} +} \ No newline at end of file diff --git a/src/features/grid/mod.rs b/src/features/grid/mod.rs index 1f06f99..ba20b81 100644 --- a/src/features/grid/mod.rs +++ b/src/features/grid/mod.rs @@ -1,4 +1,5 @@ use crate::prelude::*; +use components::CropVisual; pub mod components; pub mod consts; @@ -12,10 +13,7 @@ impl Plugin for GridPlugin { app.add_systems(OnEnter(AppState::GameScreen), setup); app.add_systems(OnExit(AppState::GameScreen), cleanup); - app.add_systems( - Update, - update_tile_colors.run_if(in_state(AppState::GameScreen)), - ); + app.add_systems(Update, update_tiles.run_if(in_state(AppState::GameScreen))); } } @@ -45,6 +43,19 @@ fn setup(mut commands: Commands, asset_server: Res, config: Res>) { commands.remove_resource::(); } -fn update_tile_colors( - mut query: Query<(&TileState, &mut AseSlice)>, +fn update_tiles( + mut query: Query<(&TileState, &mut AseSlice, &Children)>, + mut crop_query: Query<&mut Visibility, With>, asset_server: Res, ) { - for (state, mut slice) in &mut query { + for (state, mut slice, children) in &mut query { slice.name = match state { TileState::Unclaimed => "Unclaimed", TileState::Empty => "Empty", - TileState::Occupied => "Occupied", + TileState::Occupied { .. } => "Occupied", } .into(); slice.aseprite = match state { TileState::Unclaimed => asset_server.load("tiles/tile-unclaimed.aseprite"), TileState::Empty => asset_server.load("tiles/tile-empty.aseprite"), - TileState::Occupied => asset_server.load("tiles/tile-occupied.aseprite"), + TileState::Occupied { .. } => asset_server.load("tiles/tile-occupied.aseprite"), }; + + for child in children.iter() { + if let Ok(mut visibility) = crop_query.get_mut(child) { + *visibility = match state { + TileState::Occupied { .. } => Visibility::Visible, + _ => Visibility::Hidden, + }; + } + } } } diff --git a/src/features/input/mod.rs b/src/features/input/mod.rs index 0365c10..ccb422b 100644 --- a/src/features/input/mod.rs +++ b/src/features/input/mod.rs @@ -121,8 +121,12 @@ fn debug_click( (x, y), |state| match state { TileState::Unclaimed => TileState::Empty, - TileState::Empty => TileState::Occupied, - TileState::Occupied => TileState::Unclaimed, + TileState::Empty => TileState::Occupied { + seed: ItemType::BerrySeed { + name: "Debug".into(), + }, + }, + TileState::Occupied { .. } => TileState::Unclaimed, }, tile_query, ) diff --git a/src/features/inventory/components.rs b/src/features/inventory/components.rs index 6bc67a4..b5889bc 100644 --- a/src/features/inventory/components.rs +++ b/src/features/inventory/components.rs @@ -114,6 +114,14 @@ pub struct Inventory { } impl Inventory { + pub fn has_item(&self, items_query: Query<&ItemStack>) -> bool { + self.items + .iter() + .map(|entity| items_query.get(*entity).ok()) + .find(|option| option.is_some()) + .is_some() + } + pub fn update_item_stack( &mut self, commands: &mut Commands, diff --git a/src/features/pom/actions.rs b/src/features/pom/actions.rs new file mode 100644 index 0000000..afe0a26 --- /dev/null +++ b/src/features/pom/actions.rs @@ -0,0 +1,94 @@ +use crate::prelude::*; + +#[derive(Clone, Debug, PartialEq)] +pub enum InteractionAction { + Plant(ItemType), +} + +impl InteractionAction { + pub fn get_name(&self, game_config: &GameConfig) -> String { + match self { + InteractionAction::Plant(item) => format!("Pflanze {}", item.singular(game_config)), + } + } + + pub fn get_sprite( + &self, + asset_server: &Res, + game_config: &GameConfig, + ) -> Option { + match self { + InteractionAction::Plant(item) => Some(item.get_sprite(asset_server, game_config)), + } + } + + pub fn list_options( + tile_state: &TileState, + inventory: &Inventory, + item_query: Query<&ItemStack>, + ) -> Vec { + let mut options: Vec = vec![]; + + for &entity in &inventory.items { + let Ok(stack) = item_query.get(entity) else { + continue; + }; + if stack.amount <= 0 { + continue; + } + + match tile_state { + TileState::Empty => match &stack.item_type { + ItemType::BerrySeed { .. } => { + options.push(InteractionAction::Plant(stack.item_type.clone())); + } + _ => (), + }, + _ => (), + } + } + + options + } + + pub fn execute( + &self, + pos: (u32, u32), + grid: &Grid, + tile_query: &mut Query<&mut TileState>, + inventory: &mut Inventory, + item_stack_query: &mut Query<&mut ItemStack>, + commands: &mut Commands, + ) { + let Ok(tile_entity) = grid.get_tile(pos) else { + println!("Error during interaction: Couldn't get tile_entity"); + return; + }; + let Ok(mut tile_state) = tile_query.get_mut(tile_entity) else { + println!("Error during interaction: Couldn't get mut tile_state"); + return; + }; + + match self { + InteractionAction::Plant(seed_type) => { + if let TileState::Empty = *tile_state { + if inventory.update_item_stack( + commands, + item_stack_query, + seed_type.clone(), + -1, + ) { + println!("Planting {:?}", seed_type); + *tile_state = TileState::Occupied { + seed: seed_type.clone(), + }; + } else { + println!("No {:?} in inventory!", seed_type); + } + } else { + println!("Tile is not empty, cannot plant."); + } + } + } + } +} diff --git a/src/features/pom/components.rs b/src/features/pom/components.rs index 089438a..43dfa25 100644 --- a/src/features/pom/components.rs +++ b/src/features/pom/components.rs @@ -1,3 +1,4 @@ +use crate::features::pom::actions::InteractionAction; use crate::prelude::*; use std::collections::VecDeque; @@ -40,4 +41,5 @@ impl MovingState { #[derive(Component, Default)] pub struct InteractionTarget { pub target: Option<(u32, u32)>, + pub action: Option, } diff --git a/src/features/pom/messages.rs b/src/features/pom/messages.rs index 60aa82c..58b2355 100644 --- a/src/features/pom/messages.rs +++ b/src/features/pom/messages.rs @@ -1,3 +1,4 @@ +use crate::features::pom::actions::InteractionAction; use crate::prelude::*; #[derive(Message)] @@ -15,6 +16,7 @@ pub struct InvalidMoveMessage { pub struct InteractStartMessage { pub x: u32, pub y: u32, + pub action: InteractionAction, } #[derive(Message)] diff --git a/src/features/pom/mod.rs b/src/features/pom/mod.rs index 447278c..9f08aa8 100644 --- a/src/features/pom/mod.rs +++ b/src/features/pom/mod.rs @@ -5,6 +5,7 @@ use std::collections::VecDeque; use utils::find_path; use utils::manhattan_distance; +pub mod actions; pub mod components; pub mod messages; pub mod ui; @@ -71,6 +72,7 @@ fn handle_move( for (grid_pos, mut path_queue, mut interaction_target) in pom_query.iter_mut() { // Clear any pending interaction when moving manually interaction_target.target = None; + interaction_target.action = None; let grid_start = (grid_pos.x, grid_pos.y); let start = path_queue.steps.front().unwrap_or(&grid_start); @@ -108,6 +110,7 @@ fn handle_interact( if manhattan_distance(current_pos.0, current_pos.1, target_pos.0, target_pos.1) == 1 { path_queue.steps.clear(); interaction_target.target = Some(target_pos); + interaction_target.action = Some(message.action.clone()); continue; } @@ -138,10 +141,12 @@ fn handle_interact( if let Some(path) = best_path { path_queue.steps = path; interaction_target.target = Some(target_pos); + interaction_target.action = Some(message.action.clone()); } else { println!("Cannot reach interaction target at {:?}", target_pos); // Don't set target if unreachable interaction_target.target = None; + interaction_target.action = None; } } } @@ -149,8 +154,11 @@ fn handle_interact( fn perform_interaction( mut pom_query: Query<(&GridPosition, &mut InteractionTarget, &PathQueue)>, - // grid: Res, - // mut tile_query: Query<&mut TileState>, + grid: Res, + mut tile_query: Query<&mut TileState>, + mut inventory: ResMut, + mut item_stack_query: Query<&mut ItemStack>, + mut commands: Commands, ) { for (pos, mut target_component, path_queue) in pom_query.iter_mut() { if let Some(target) = target_component.target { @@ -165,10 +173,20 @@ fn perform_interaction( target.0, target.1 ); - // TODO: Implement interaction logic + if let Some(action) = &target_component.action { + action.execute( + target, + &grid, + &mut tile_query, + &mut inventory, + &mut item_stack_query, + &mut commands, + ); + } } target_component.target = None; + target_component.action = None; } } } @@ -248,4 +266,3 @@ fn update_pom(asset_server: Res, mut query: Query<(&MovingState, &m } } } - diff --git a/src/features/pom/ui/context_menu.rs b/src/features/pom/ui/context_menu.rs index 1253f0b..289bb52 100644 --- a/src/features/pom/ui/context_menu.rs +++ b/src/features/pom/ui/context_menu.rs @@ -1,3 +1,4 @@ +use crate::features::pom::actions::InteractionAction; use crate::features::ui::utils::ui_blocks; use crate::prelude::*; use bevy::window::PrimaryWindow; @@ -9,7 +10,11 @@ pub enum RootMarker { #[derive(Component)] pub enum ButtonType { - Interact { x: u32, y: u32 }, + Interact { + x: u32, + y: u32, + action: InteractionAction, + }, Cancel, } @@ -19,6 +24,11 @@ pub fn spawn_context_menu( root_query: Query>, camera_query: Single<(&Camera, &GlobalTransform), With>, config: Res, + grid: Res, + tile_query: Query<&TileState>, + inventory: Res, + item_query: Query<&ItemStack>, + game_config: Res, ) { for message in tile_click_messages.read() { // Despawn existing menu @@ -37,6 +47,14 @@ pub fn spawn_context_menu( let (camera, camera_transform) = *camera_query; if let Ok(screen_pos) = camera.world_to_viewport(camera_transform, world_pos) { + let Ok(tile_entity) = grid.get_tile((message.x, message.y)) else { + return; + }; + let Ok(tile_state) = tile_query.get(tile_entity) else { + return; + }; + let options = InteractionAction::list_options(tile_state, &inventory, item_query); + commands .spawn(( Node { @@ -53,18 +71,21 @@ pub fn spawn_context_menu( GlobalTransform::default(), )) .with_children(|parent| { - parent.spawn(button( - ButtonType::Interact { - x: message.x, - y: message.y, - }, - ButtonVariant::Primary, - Node { - padding: UiRect::all(px(5)), - ..default() - }, - |c| text("", 20.0, c), - )); + for option in options { + parent.spawn(button( + ButtonType::Interact { + x: message.x, + y: message.y, + action: option.clone(), + }, + ButtonVariant::Primary, + Node { + padding: UiRect::all(px(5)), + ..default() + }, + |c| text(option.clone().get_name(&game_config), 20.0, c), // TODO: add sprite + )); + } parent.spawn(button( ButtonType::Cancel, @@ -109,8 +130,12 @@ pub fn buttons( for (interaction, button_type) in button_query.iter_mut() { if *interaction == Interaction::Pressed { match button_type { - ButtonType::Interact { x, y } => { - interact_messages.write(InteractStartMessage { x: *x, y: *y }); + ButtonType::Interact { x, y, action } => { + interact_messages.write(InteractStartMessage { + x: *x, + y: *y, + action: action.clone(), + }); } ButtonType::Cancel => (), } diff --git a/src/features/savegame/mod.rs b/src/features/savegame/mod.rs index eb6d0e3..597e01e 100644 --- a/src/features/savegame/mod.rs +++ b/src/features/savegame/mod.rs @@ -55,7 +55,7 @@ fn dump_savegame( for y in 0..grid.height { if let Ok(entity) = grid.get_tile((x, y)) { if let Ok(state) = tile_query.get(entity) { - col.push(*state); + col.push(state.clone()); } else { col.push(TileState::Unclaimed); } @@ -146,7 +146,7 @@ fn load_savegame( if x < grid.width && y < grid.height { if let Ok(entity) = grid.get_tile((x, y)) { if let Ok(mut state) = tile_query.get_mut(entity) { - *state = save_data.tiles[x as usize][y as usize]; + *state = save_data.tiles[x as usize][y as usize].clone(); } } } diff --git a/tests/interaction.rs b/tests/interaction.rs new file mode 100644 index 0000000..4bd3014 --- /dev/null +++ b/tests/interaction.rs @@ -0,0 +1,166 @@ +use bevy::ecs::system::RunSystemOnce; +use pomomon_garden::features::grid::components::{Grid, Tile, TileState}; +use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType}; +use pomomon_garden::features::pom::actions::InteractionAction; +use pomomon_garden::prelude::*; + +fn setup_interaction_app( + grid_width: u32, + grid_height: u32, + initial_tile_states: &[(u32, u32, TileState)], + initial_inventory: Vec<(ItemType, u32)>, +) -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_plugins(AssetPlugin::default()); // Needed for asset server if used, though we mock or avoid visuals here + + // Grid Setup + let mut grid_tiles = Vec::with_capacity(grid_width as usize); + for x in 0..grid_width { + let mut column = Vec::with_capacity(grid_height as usize); + for y in 0..grid_height { + let entity = app + .world_mut() + .spawn((Tile { x, y }, TileState::Unclaimed)) + .id(); + column.push(entity); + } + grid_tiles.push(column); + } + app.world_mut().insert_resource(Grid { + width: grid_width, + height: grid_height, + tiles: grid_tiles, + }); + + for &(x, y, ref state) in initial_tile_states { + if let Ok(entity) = app.world().resource::().get_tile((x, y)) { + *app.world_mut().get_mut::(entity).unwrap() = state.clone(); + } + } + + // Inventory Setup + let mut inventory_items = Vec::new(); + for (item_type, amount) in initial_inventory { + let id = app.world_mut().spawn(ItemStack { item_type, amount }).id(); + inventory_items.push(id); + } + app.world_mut().insert_resource(Inventory { + items: inventory_items, + }); + + app +} + +#[test] +fn test_plant_seed_interaction() { + let seed_type = ItemType::BerrySeed { + name: "TestSeed".into(), + }; + let mut app = setup_interaction_app( + 3, + 3, + &[(1, 1, TileState::Empty)], + vec![(seed_type.clone(), 1)], + ); + + // Run the interaction logic as a system or closure + let _ = app.world_mut().run_system_once( + move |grid: Res, + mut tile_query: Query<&mut TileState>, + mut inventory: ResMut, + mut item_stack_query: Query<&mut ItemStack>, + mut commands: Commands| { + let action = InteractionAction::Plant(seed_type.clone()); + action.execute( + (1, 1), + &grid, + &mut tile_query, + &mut inventory, + &mut item_stack_query, + &mut commands, + ); + }, + ); + + // Apply commands (despawns etc.) + app.update(); + + // Assert Tile State + let grid = app.world().resource::(); + let tile_entity = grid.get_tile((1, 1)).unwrap(); + let tile_state = app.world().entity(tile_entity).get::().unwrap(); + + if let TileState::Occupied { seed } = tile_state { + assert_eq!( + *seed, + ItemType::BerrySeed { + name: "TestSeed".into() + } + ); + } else { + panic!("Tile should be Occupied with seed, found {:?}", tile_state); + } + + // Assert Inventory Empty + let inventory = app.world().resource::(); + // Item stack entity should be despawned or amount 0 + if !inventory.items.is_empty() { + // If the entity still exists, check amount + if let Some(entity) = inventory.items.first() { + if let Some(stack) = app.world().entity(*entity).get::() { + assert_eq!(stack.amount, 0, "Item amount should be 0"); + } else { + panic!( + "Inventory items list should be empty or point to valid 0 amount entities. Found phantom entity." + ); + } + } + } else { + assert!(inventory.items.is_empty()); + } +} + +#[test] +fn test_plant_seed_no_inventory() { + let seed_type = ItemType::BerrySeed { + name: "TestSeed".into(), + }; + let mut app = setup_interaction_app( + 3, + 3, + &[(1, 1, TileState::Empty)], + vec![], // Empty inventory + ); + + let _ = app.world_mut().run_system_once( + move |grid: Res, + mut tile_query: Query<&mut TileState>, + mut inventory: ResMut, + mut item_stack_query: Query<&mut ItemStack>, + mut commands: Commands| { + let action = InteractionAction::Plant(seed_type.clone()); + action.execute( + (1, 1), + &grid, + &mut tile_query, + &mut inventory, + &mut item_stack_query, + &mut commands, + ); + }, + ); + + app.update(); + + // Assert Tile State Unchanged + let grid = app.world().resource::(); + let tile_entity = grid.get_tile((1, 1)).unwrap(); + let tile_state = app.world().entity(tile_entity).get::().unwrap(); + + if let TileState::Empty = tile_state { + // Correct + } else { + panic!("Tile should remain Empty, found {:?}", tile_state); + } +} diff --git a/tests/pathfinding.rs b/tests/pathfinding.rs index 02ffde8..93bde1d 100644 --- a/tests/pathfinding.rs +++ b/tests/pathfinding.rs @@ -89,10 +89,15 @@ fn test_find_path_simple() { #[test] fn test_find_path_around_obstacle() { + let obstacle: TileState = TileState::Occupied { + seed: ItemType::BerrySeed { + name: "test".into(), + }, + }; let obstacles = vec![ - (2, 2, TileState::Occupied), - (2, 3, TileState::Occupied), - (2, 4, TileState::Occupied), + (2, 2, obstacle.clone()), + (2, 3, obstacle.clone()), + (2, 4, obstacle.clone()), ]; let mut app = setup_pathfinding_app(5, 5, &obstacles); @@ -135,12 +140,17 @@ fn test_find_path_around_obstacle() { #[test] fn test_find_path_no_path() { + let obstacle: TileState = TileState::Occupied { + seed: ItemType::BerrySeed { + name: "test".into(), + }, + }; let obstacles = vec![ - (2, 0, TileState::Occupied), - (2, 1, TileState::Occupied), - (2, 2, TileState::Occupied), - (2, 3, TileState::Occupied), - (2, 4, TileState::Occupied), + (2, 0, obstacle.clone()), + (2, 1, obstacle.clone()), + (2, 2, obstacle.clone()), + (2, 3, obstacle.clone()), + (2, 4, obstacle.clone()), ]; let mut app = setup_pathfinding_app(5, 5, &obstacles);