Merge branch '29-harvesting-crops' into 'dev'

Implement harvesting crops

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!27
This commit is contained in:
Dominik Bernroider
2025-12-03 21:54:39 +00:00
8 changed files with 561 additions and 259 deletions

View File

@@ -4,6 +4,7 @@ use crate::prelude::*;
pub enum InteractionAction {
Plant(ItemType),
Water,
Harvest,
}
impl InteractionAction {
@@ -11,6 +12,7 @@ impl InteractionAction {
match self {
InteractionAction::Plant(item) => format!("Pflanze {}", item.singular(game_config)),
InteractionAction::Water => "Gießen".into(),
InteractionAction::Harvest => "Ernten".into(),
}
}
@@ -22,6 +24,7 @@ impl InteractionAction {
match self {
InteractionAction::Plant(item) => Some(item.get_sprite(asset_server, game_config)),
InteractionAction::Water => None,
InteractionAction::Harvest => Some(ItemType::Berry.get_sprite(asset_server, game_config)),
}
}
@@ -29,13 +32,28 @@ impl InteractionAction {
tile_state: &TileState,
inventory: &Inventory,
item_query: Query<&ItemStack>,
game_config: &GameConfig,
) -> Vec<InteractionAction> {
let mut options: Vec<InteractionAction> = vec![];
match tile_state {
TileState::Occupied { watered, .. } => {
if !*watered {
options.push(InteractionAction::Water);
TileState::Occupied {
watered,
withered,
seed,
growth_stage,
..
} => {
if *withered {
options.push(InteractionAction::Harvest);
} else if let ItemType::BerrySeed { name } = seed {
if let Some(config) = game_config.berry_seeds.iter().find(|s| s.name == *name) {
if *growth_stage >= config.growth_stages {
options.push(InteractionAction::Harvest);
} else if !*watered {
options.push(InteractionAction::Water);
}
}
}
}
TileState::Empty => {
@@ -69,6 +87,7 @@ impl InteractionAction {
inventory: &mut Inventory,
item_stack_query: &mut Query<&mut ItemStack>,
commands: &mut Commands,
game_config: &GameConfig,
) {
let Ok(tile_entity) = grid.get_tile(pos) else {
println!("Error during interaction: Couldn't get tile_entity");
@@ -123,6 +142,41 @@ impl InteractionAction {
println!("Tile is not occupied, cannot water.");
}
}
InteractionAction::Harvest => {
if let TileState::Occupied {
seed,
withered,
growth_stage,
..
} = &*tile_state
{
let mut can_harvest = *withered;
if !can_harvest {
if let ItemType::BerrySeed { name } = seed {
if let Some(config) =
game_config.berry_seeds.iter().find(|s| s.name == *name)
{
if *growth_stage >= config.growth_stages {
can_harvest = true;
inventory.update_item_stack(
commands,
item_stack_query,
ItemType::Berry,
config.grants as i32,
);
}
}
}
}
if can_harvest {
*tile_state = TileState::Empty;
} else {
println!("Cannot harvest: not withered and not fully grown.");
}
}
}
}
}
}

View File

@@ -191,6 +191,7 @@ fn perform_interaction(
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands,
config: Res<GameConfig>,
) {
for (pos, mut target_component, path_queue) in pom_query.iter_mut() {
if let Some(target) = target_component.target {
@@ -213,6 +214,7 @@ fn perform_interaction(
&mut inventory,
&mut item_stack_query,
&mut commands,
&config,
);
}
}

View File

@@ -53,7 +53,8 @@ pub fn spawn_context_menu(
let Ok(tile_state) = tile_query.get(tile_entity) else {
return;
};
let options = InteractionAction::list_options(tile_state, &inventory, item_query);
let options =
InteractionAction::list_options(tile_state, &inventory, item_query, &game_config);
commands
.spawn((

69
tests/common/mod.rs Normal file
View File

@@ -0,0 +1,69 @@
use bevy::prelude::*;
use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig};
use pomomon_garden::features::grid::components::{Grid, Tile, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
pub fn setup_app(
grid_width: u32,
grid_height: u32,
initial_tile_states: &[(u32, u32, TileState)],
initial_inventory: Vec<(ItemType, u32)>,
seed_configs: Option<Vec<BerrySeedConfig>>,
) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(AssetPlugin::default());
// 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.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::<Grid>().get_tile((x, y)) {
*app.world_mut().get_mut::<TileState>(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.insert_resource(Inventory {
items: inventory_items,
});
// Game Config
let seeds = seed_configs.unwrap_or_else(|| {
vec![BerrySeedConfig {
name: "TestSeed".to_string(),
cost: 1,
grants: 1,
slice: "seed.aseprite".to_string(),
growth_stages: 2,
}]
});
app.insert_resource(GameConfig {
berry_seeds: seeds,
..Default::default()
});
app
}

197
tests/harvest.rs Normal file
View File

@@ -0,0 +1,197 @@
use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig};
use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::features::pom::actions::InteractionAction;
use pomomon_garden::prelude::*;
mod common;
use common::setup_app;
#[test]
fn test_harvest_fully_grown() {
let seed_type = ItemType::BerrySeed {
name: "TestSeed".into(),
};
let initial_states = vec![(
0,
0,
TileState::Occupied {
seed: seed_type.clone(),
watered: false,
growth_stage: 2, // Max
withered: false,
dry_counter: 0,
},
)];
let seed_config = BerrySeedConfig {
name: "TestSeed".into(),
cost: 1,
grants: 5,
slice: "".into(),
growth_stages: 2,
};
let mut app = setup_app(1, 1, &initial_states, vec![], Some(vec![seed_config]));
let _ = app.world_mut().run_system_once(
|mut commands: Commands,
grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
config: Res<GameConfig>| {
InteractionAction::Harvest.execute(
(0, 0),
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
&config,
);
},
);
// Check Tile State -> Empty
// Wait for commands to apply
app.update();
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((0, 0)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
assert!(matches!(tile_state, TileState::Empty));
// Check Inventory -> 5 Berries
let inventory = app.world().resource::<Inventory>();
assert_eq!(inventory.items.len(), 1);
let stack_entity = inventory.items[0];
let stack = app.world().entity(stack_entity).get::<ItemStack>().unwrap();
assert_eq!(stack.item_type, ItemType::Berry);
assert_eq!(stack.amount, 5);
}
#[test]
fn test_harvest_withered() {
let seed_type = ItemType::BerrySeed {
name: "TestSeed".into(),
};
let initial_states = vec![(
0,
0,
TileState::Occupied {
seed: seed_type.clone(),
watered: false,
growth_stage: 1,
withered: true, // Withered
dry_counter: 0,
},
)];
let seed_config = BerrySeedConfig {
name: "TestSeed".into(),
cost: 1,
grants: 5,
slice: "".into(),
growth_stages: 2,
};
let mut app = setup_app(1, 1, &initial_states, vec![], Some(vec![seed_config]));
let _ = app.world_mut().run_system_once(
|mut commands: Commands,
grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
config: Res<GameConfig>| {
InteractionAction::Harvest.execute(
(0, 0),
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
&config,
);
},
);
app.update();
// Check Tile State -> Empty
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((0, 0)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
assert!(matches!(tile_state, TileState::Empty));
// Check Inventory -> Empty (no berries for withered)
let inventory = app.world().resource::<Inventory>();
assert!(inventory.items.is_empty());
}
#[test]
fn test_cannot_harvest_growing() {
let seed_type = ItemType::BerrySeed {
name: "TestSeed".into(),
};
let initial_states = vec![(
0,
0,
TileState::Occupied {
seed: seed_type.clone(),
watered: false,
growth_stage: 1, // Growing (Max is 2)
withered: false,
dry_counter: 0,
},
)];
let seed_config = BerrySeedConfig {
name: "TestSeed".into(),
cost: 1,
grants: 5,
slice: "".into(),
growth_stages: 2,
};
let mut app = setup_app(1, 1, &initial_states, vec![], Some(vec![seed_config]));
let _ = app.world_mut().run_system_once(
|mut commands: Commands,
grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
config: Res<GameConfig>| {
InteractionAction::Harvest.execute(
(0, 0),
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
&config,
);
},
);
app.update();
// Check Tile State -> Still Occupied
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((0, 0)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
match tile_state {
TileState::Occupied { growth_stage, .. } => {
assert_eq!(*growth_stage, 1);
}
_ => panic!("Should still be occupied"),
}
// Check Inventory -> Empty
let inventory = app.world().resource::<Inventory>();
assert!(inventory.items.is_empty());
}

View File

@@ -1,255 +0,0 @@
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::<Grid>().get_tile((x, y)) {
*app.world_mut().get_mut::<TileState>(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<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
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::<Grid>();
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().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::<Inventory>();
// 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::<ItemStack>() {
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<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
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::<Grid>();
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
if let TileState::Empty = tile_state {
// Correct
} else {
panic!("Tile should remain Empty, found {:?}", tile_state);
}
}
#[test]
fn test_water_crop() {
let seed_type = ItemType::BerrySeed {
name: "TestSeed".into(),
};
let mut app = setup_interaction_app(
3,
3,
&[(
1,
1,
TileState::Occupied {
seed: seed_type.clone(),
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
},
)],
vec![],
);
// Verify Water option is available
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
tile_query: Query<&TileState>,
inventory: Res<Inventory>,
item_query: Query<&ItemStack>| {
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = tile_query.get(tile_entity).unwrap();
let options = InteractionAction::list_options(tile_state, &inventory, item_query);
assert!(
options.contains(&InteractionAction::Water),
"Water option should be available"
);
},
);
// Execute Water
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands| {
let action = InteractionAction::Water;
action.execute(
(1, 1),
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
);
},
);
app.update();
// Assert Tile State Watered
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
if let TileState::Occupied { watered, .. } = tile_state {
assert!(watered, "Tile should be watered");
} else {
panic!("Tile should be Occupied, found {:?}", tile_state);
}
// Verify Water option is NOT available
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
tile_query: Query<&TileState>,
inventory: Res<Inventory>,
item_query: Query<&ItemStack>| {
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = tile_query.get(tile_entity).unwrap();
let options = InteractionAction::list_options(tile_state, &inventory, item_query);
assert!(
!options.contains(&InteractionAction::Water),
"Water option should NOT be available"
);
},
);
}

128
tests/planting.rs Normal file
View File

@@ -0,0 +1,128 @@
use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::GameConfig;
use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::features::pom::actions::InteractionAction;
use pomomon_garden::prelude::*;
mod common;
use common::setup_app;
#[test]
fn test_plant_seed_interaction() {
let seed_type = ItemType::BerrySeed {
name: "TestSeed".into(),
};
let mut app = setup_app(
3,
3,
&[(1, 1, TileState::Empty)],
vec![(seed_type.clone(), 1)],
None,
);
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands,
game_config: Res<GameConfig>| {
let action = InteractionAction::Plant(seed_type.clone());
action.execute(
(1, 1),
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
&game_config,
);
},
);
// Apply commands (despawns etc.)
app.update();
// Assert Tile State
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().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::<Inventory>();
// 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::<ItemStack>() {
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_app(
3,
3,
&[(1, 1, TileState::Empty)],
vec![], // Empty inventory
None,
);
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands,
game_config: Res<GameConfig>| {
let action = InteractionAction::Plant(seed_type.clone());
action.execute(
(1, 1),
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
&game_config,
);
},
);
app.update();
// Assert Tile State Unchanged
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
if let TileState::Empty = tile_state {
// Correct
} else {
panic!("Tile should remain Empty, found {:?}", tile_state);
}
}

106
tests/watering.rs Normal file
View File

@@ -0,0 +1,106 @@
use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::GameConfig;
use pomomon_garden::features::grid::components::{Grid, TileState};
use pomomon_garden::features::inventory::components::{Inventory, ItemStack, ItemType};
use pomomon_garden::features::pom::actions::InteractionAction;
use pomomon_garden::prelude::*;
mod common;
use common::setup_app;
#[test]
fn test_water_crop() {
let seed_type = ItemType::BerrySeed {
name: "TestSeed".into(),
};
let mut app = setup_app(
3,
3,
&[(
1,
1,
TileState::Occupied {
seed: seed_type.clone(),
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
},
)],
vec![],
None,
);
// Verify Water option is available
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
tile_query: Query<&TileState>,
inventory: Res<Inventory>,
item_query: Query<&ItemStack>,
game_config: Res<GameConfig>| {
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = tile_query.get(tile_entity).unwrap();
let options =
InteractionAction::list_options(tile_state, &inventory, item_query, &game_config);
assert!(
options.contains(&InteractionAction::Water),
"Water option should be available"
);
},
);
// Execute Water
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands,
game_config: Res<GameConfig>| {
let action = InteractionAction::Water;
action.execute(
(1, 1),
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
&game_config,
);
},
);
app.update();
// Assert Tile State Watered
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
if let TileState::Occupied { watered, .. } = tile_state {
assert!(watered, "Tile should be watered");
} else {
panic!("Tile should be Occupied, found {:?}", tile_state);
}
// Verify Water option is NOT available
let _ = app.world_mut().run_system_once(
move |grid: Res<Grid>,
tile_query: Query<&TileState>,
inventory: Res<Inventory>,
item_query: Query<&ItemStack>,
game_config: Res<GameConfig>| {
let tile_entity = grid.get_tile((1, 1)).unwrap();
let tile_state = tile_query.get(tile_entity).unwrap();
let options =
InteractionAction::list_options(tile_state, &inventory, item_query, &game_config);
assert!(
!options.contains(&InteractionAction::Water),
"Water option should NOT be available"
);
},
);
}