Merge branch '26-planting-crops' into 'dev'

Implement planting crops

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!22
This commit is contained in:
Dominik Bernroider
2025-12-02 13:13:54 +00:00
13 changed files with 398 additions and 44 deletions

BIN
assets/crop.aseprite Normal file

Binary file not shown.

View File

@@ -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(())
}
}
}

View File

@@ -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<AssetServer>, config: Res<Gam
grid_height,
)),
))
.with_children(|parent| {
parent.spawn((
CropVisual,
AseSlice {
name: "Crop".into(),
aseprite: asset_server.load("crop.aseprite"),
},
Sprite::default(),
Transform::default(),
Visibility::Hidden,
ZIndex(1),
));
})
.id();
column.push(tile_entity);
}
@@ -65,22 +76,32 @@ fn cleanup(mut commands: Commands, tile_query: Query<Entity, With<Tile>>) {
commands.remove_resource::<Grid>();
}
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<CropVisual>>,
asset_server: Res<AssetServer>,
) {
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,
};
}
}
}
}

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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<AssetServer>,
game_config: &GameConfig,
) -> Option<AseSlice> {
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<InteractionAction> {
let mut options: Vec<InteractionAction> = 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.");
}
}
}
}
}

View File

@@ -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<InteractionAction>,
}

View File

@@ -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)]

View File

@@ -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<Grid>,
// mut tile_query: Query<&mut TileState>,
grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
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<AssetServer>, mut query: Query<(&MovingState, &m
}
}
}

View File

@@ -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<Entity, With<RootMarker>>,
camera_query: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
inventory: Res<Inventory>,
item_query: Query<&ItemStack>,
game_config: Res<GameConfig>,
) {
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("<Interact>", 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 => (),
}

View File

@@ -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();
}
}
}

166
tests/interaction.rs Normal file
View File

@@ -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::<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);
}
}

View File

@@ -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);