Merge branch '15-farm-expansion' into 'dev'

Implement farm expansion

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!34
This commit is contained in:
Dominik Bernroider
2025-12-09 17:34:39 +00:00
11 changed files with 397 additions and 28 deletions

View File

@@ -1,9 +1,9 @@
{
"grid_width": 12,
"grid_height": 4,
"grid_width": 15,
"grid_height": 5,
"pom_speed": 1.5,
"shovel_base_price": 10,
"shovel_rate": 0.5,
"shovel_rate": 0.2,
"berry_seeds": [
{
"name": "Normale Samen",
@@ -29,4 +29,3 @@
],
"wonder_event_url": "wss://pomomon.farm/ws"
}

BIN
assets/shovel.aseprite Normal file

Binary file not shown.

View File

@@ -13,7 +13,7 @@ pub struct CropVisual;
#[derive(Component)]
pub struct WaterVisual;
#[derive(Component, Default, Serialize, Deserialize, Clone, Debug)]
#[derive(Component, Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum TileState {
#[default]
Unclaimed,
@@ -74,4 +74,18 @@ impl Grid {
*tile_state = mapper(&*tile_state);
Ok(())
}
pub fn count_claimed_tiles(&self, tile_query: &Query<&TileState>) -> u32 {
self.tiles
.iter()
.flatten()
.filter(|&entity| {
if let Ok(state) = tile_query.get(*entity) {
!matches!(state, TileState::Unclaimed)
} else {
false
}
})
.count() as u32
}
}

View File

@@ -1,5 +1,6 @@
use crate::prelude::*;
use components::{CropVisual, WaterVisual};
use std::collections::HashSet;
pub mod components;
pub mod consts;
@@ -26,13 +27,28 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<Gam
let mut column = Vec::with_capacity(grid_height as usize);
for y in 0..grid_height {
let initial_state = if x == 1 && y == 1 {
TileState::Empty
} else {
TileState::Unclaimed
};
let tile_entity = commands
.spawn((
Tile { x, y },
TileState::Unclaimed,
initial_state.clone(),
AseSlice {
name: "Unclaimed".into(),
aseprite: asset_server.load("tiles/tile-unclaimed.aseprite"),
name: match initial_state {
TileState::Unclaimed => "Unclaimed",
TileState::Empty => "Empty",
_ => unreachable!(),
}
.into(),
aseprite: asset_server.load(match initial_state {
TileState::Unclaimed => "tiles/tile-unclaimed.aseprite",
TileState::Empty => "tiles/tile-empty.aseprite",
_ => unreachable!(),
}),
},
Sprite::default(),
Transform::from_translation(grid_to_world_coords(
@@ -88,7 +104,10 @@ fn cleanup(mut commands: Commands, tile_query: Query<Entity, With<Tile>>) {
}
fn update_tiles(
mut query: Query<(&TileState, &mut AseSlice, &Children), (With<Tile>, Without<CropVisual>)>,
mut query: Query<
(&TileState, &mut AseSlice, &Children, &Tile),
(With<Tile>, Without<CropVisual>),
>,
mut crop_query: Query<
(&mut Visibility, &mut Transform, &mut AseSlice),
(With<CropVisual>, Without<WaterVisual>, Without<Tile>),
@@ -99,8 +118,27 @@ fn update_tiles(
>,
asset_server: Res<AssetServer>,
game_config: Res<GameConfig>,
inventory: Res<Inventory>,
item_stacks: Query<&ItemStack>,
grid: Res<Grid>,
mut sprite_query: Query<&mut Sprite, With<Tile>>,
) {
for (state, mut slice, children) in &mut query {
let has_shovel = inventory.has_item_type(&item_stacks, ItemType::Shovel);
let owned_tiles: HashSet<(u32, u32)> = query
.iter()
.filter_map(|(state, _, _, tile)| {
if !matches!(state, TileState::Unclaimed) {
Some((tile.x, tile.y))
} else {
None
}
})
.collect();
for (state, mut slice, children, tile) in &mut query {
let entity = grid.get_tile((tile.x, tile.y)).unwrap(); // Get entity for sprite query
slice.name = match state {
TileState::Unclaimed => "Unclaimed",
TileState::Empty => "Empty",
@@ -133,6 +171,34 @@ fn update_tiles(
_ => Vec3::ONE,
};
let mut is_highlighted = false;
if has_shovel && matches!(state, TileState::Unclaimed) {
// Check if not on edge
if tile.x > 0 && tile.x < grid.width - 1 && tile.y > 0 && tile.y < grid.height - 1 {
// Check neighbors
let neighbors = [
(tile.x + 1, tile.y),
(tile.x.saturating_sub(1), tile.y),
(tile.x, tile.y + 1),
(tile.x, tile.y.saturating_sub(1)),
];
for n in neighbors.iter() {
if owned_tiles.contains(n) {
is_highlighted = true;
break;
}
}
}
}
if let Ok(mut sprite) = sprite_query.get_mut(entity) {
if is_highlighted {
sprite.color = Color::srgb(0.3, 1.0, 0.3); // Green tint
} else {
sprite.color = Color::WHITE;
}
}
for child in children.iter() {
if let Ok((mut visibility, mut transform, mut sprite)) = crop_query.get_mut(child) {
*visibility = match state {

View File

@@ -4,6 +4,7 @@ use crate::{features::phase::components::TimerSettings, prelude::*};
pub enum RootMarker {
Status,
Settings,
ShovelOverlay,
}
#[derive(Component)]

View File

@@ -16,12 +16,18 @@ impl Plugin for HudPlugin {
app.add_systems(OnExit(AppState::GameScreen), cleanup);
app.add_systems(
Update,
(update_status, buttons, update_timer_settings).run_if(in_state(AppState::GameScreen)),
(
update_status,
buttons,
update_timer_settings,
update_shovel_overlay_visibility,
)
.run_if(in_state(AppState::GameScreen)),
);
}
}
fn setup(mut commands: Commands) {
fn setup(mut commands: Commands, game_config: Res<GameConfig>, asset_server: Res<AssetServer>) {
commands.spawn((
RootMarker::Status,
Node {
@@ -61,6 +67,63 @@ fn setup(mut commands: Commands) {
)
],
));
// Shovel Overlay
commands.spawn((
RootMarker::ShovelOverlay,
Node {
position_type: PositionType::Absolute,
top: px(20),
left: px(0),
right: px(0),
width: percent(100),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
flex_direction: FlexDirection::Column,
row_gap: px(5),
..default()
},
Visibility::Hidden,
children![(
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::all(px(10)),
row_gap: px(5),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)),
BorderRadius::all(px(10)),
children![
(
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: px(10),
..default()
},
children![
(
Node {
width: px(32),
height: px(32),
..default()
},
inventory::components::ItemType::Shovel
.get_sprite(&asset_server, &game_config),
ImageNode::default()
),
text("Schaufel-Modus", 20.0, Color::WHITE)
]
),
text(
"Klicke auf ein freies Feld, um es freizuschalten.",
14.0,
Color::WHITE
)
]
)],
));
}
fn update_status(phase_res: Res<CurrentPhase>, mut text_query: Query<(&mut Text, &TextType)>) {
@@ -151,3 +214,21 @@ fn update_timer_settings(
}
}
}
fn update_shovel_overlay_visibility(
inventory: Res<inventory::components::Inventory>,
item_stacks: Query<&inventory::components::ItemStack>,
mut overlay_query: Query<(&RootMarker, &mut Visibility)>,
) {
let has_shovel = inventory.has_item_type(&item_stacks, inventory::components::ItemType::Shovel);
for (marker, mut vis) in overlay_query.iter_mut() {
if let RootMarker::ShovelOverlay = marker {
*vis = if has_shovel {
Visibility::Inherited
} else {
Visibility::Hidden
};
}
}
}

View File

@@ -53,7 +53,13 @@ fn move_click(
config: Res<GameConfig>,
phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
inventory: Res<Inventory>,
item_stacks: Query<&ItemStack>,
) {
if inventory.has_item_type(&item_stacks, ItemType::Shovel) {
return; // Block movement if player has a shovel
}
match phase.0 {
Phase::Focus { .. } => return,
_ => {}
@@ -78,12 +84,25 @@ fn interact_click(
config: Res<GameConfig>,
phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
mut commands: Commands,
mut inventory: ResMut<Inventory>,
mut item_stacks: Query<&mut ItemStack>,
grid: Res<Grid>,
mut tile_states: Query<&mut TileState>,
) {
match phase.0 {
Phase::Focus { .. } => return,
_ => {}
}
let has_shovel = inventory.items.iter().any(|&entity| {
if let Ok(stack) = item_stacks.get(entity) {
stack.item_type == ItemType::Shovel
} else {
false
}
});
if mouse_btn.just_pressed(MouseButton::Left) {
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
return;
@@ -92,10 +111,69 @@ fn interact_click(
return;
};
tile_click_messages.write(TileClickMessage { x, y });
if has_shovel {
// Shovel interaction logic
let tile_entity = match grid.get_tile((x, y)) {
Ok(entity) => entity,
Err(_) => return, // Clicked outside grid
};
// Before getting mutable tile_state, check neighbors with immutable borrow
let mut has_claimed_neighbor = false;
// Check if not on edge first for early exit to avoid checking neighbors outside grid.
if x == 0 || x == grid.width - 1 || y == 0 || y == grid.height - 1 {
return;
}
let neighbors = [
(x + 1, y),
(x.saturating_sub(1), y),
(x, y + 1),
(x, y.saturating_sub(1)),
];
for (nx, ny) in neighbors.iter() {
// Ensure neighbor coordinates are within grid boundaries before attempting to get tile
if *nx < grid.width && *ny < grid.height {
if let Ok(neighbor_entity) = grid.get_tile((*nx, *ny)) {
if let Ok(neighbor_state) = tile_states.get(neighbor_entity) {
if !matches!(*neighbor_state, TileState::Unclaimed) {
has_claimed_neighbor = true;
break;
}
}
}
}
}
if !has_claimed_neighbor {
return; // No claimed neighbor, cannot unlock
}
// Now get mutable tile_state, after all immutable neighbor checks are done
let mut tile_state = match tile_states.get_mut(tile_entity) {
Ok(state) => state,
Err(_) => return, // Should not happen
};
// Check if unclaimed AFTER determining neighbor status
if !matches!(*tile_state, TileState::Unclaimed) {
return;
}
if has_claimed_neighbor {
// This check is redundant due to early return above, but kept for clarity
// Unlock tile
*tile_state = TileState::Empty;
inventory.update_item_stack(&mut commands, &mut item_stacks, ItemType::Shovel, -1);
}
return; // Consume click event, prevent normal tile_click_messages
} else {
// Normal interaction
tile_click_messages.write(TileClickMessage { x, y });
}
}
}
fn debug_click(
mouse_btn: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
@@ -162,9 +240,17 @@ fn shop_keybind(
mut commands: Commands,
game_config: Res<GameConfig>,
asset_server: Res<AssetServer>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
) {
if keys.just_pressed(KeyCode::KeyP) {
open_shop(&mut commands, &game_config, &asset_server);
open_shop(
&mut commands,
&game_config,
&asset_server,
&grid,
&tile_query,
);
}
}

View File

@@ -80,8 +80,8 @@ impl ItemType {
aseprite: asset_server.load("berry.aseprite"),
},
ItemType::Shovel => AseSlice {
name: "Berry".into(),
aseprite: asset_server.load("berry.aseprite"),
name: "Shovel".into(),
aseprite: asset_server.load("shovel.aseprite"),
},
ItemType::BerrySeed { name } => {
let seed_config = game_config.berry_seeds.iter().find(|s| s.name == *name);
@@ -102,7 +102,7 @@ impl ItemType {
}
}
#[derive(Component, Serialize, Deserialize, Clone)]
#[derive(Component, Serialize, Deserialize, Clone, Debug)]
pub struct ItemStack {
pub item_type: ItemType,
pub amount: u32,
@@ -114,12 +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 has_item_type(&self, items_query: &Query<&ItemStack>, item_type: ItemType) -> bool {
self.items.iter().any(|&entity| {
if let Ok(stack) = items_query.get(entity) {
stack.item_type == item_type
} else {
false
}
})
}
pub fn update_item_stack(

View File

@@ -21,12 +21,14 @@ fn buttons(
asset_server: Res<AssetServer>,
mut inventory: ResMut<Inventory>,
mut items: Query<&mut ItemStack>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
) {
for (interaction, button_type) in &mut interaction_query {
match *interaction {
Interaction::Pressed => match button_type {
ButtonType::ShopOpen => {
open_shop(&mut commands, &game_config, &asset_server);
open_shop(&mut commands, &game_config, &asset_server, &grid, &tile_query);
}
ButtonType::ShopBuyItem(offer) => {
if offer.buy(&mut inventory, &mut commands, &mut items) {

View File

@@ -5,9 +5,11 @@ pub fn open_shop(
commands: &mut Commands,
game_config: &GameConfig,
asset_server: &Res<AssetServer>,
grid: &Grid,
tile_query: &Query<&TileState>,
) {
// TODO: calculate tile_count
let offers = ShopOffer::list_all(game_config, 0);
let tile_count = grid.count_claimed_tiles(tile_query);
let offers = ShopOffer::list_all(game_config, tile_count);
spawn_popup(
commands,

116
tests/expansion.rs Normal file
View File

@@ -0,0 +1,116 @@
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::prelude::*;
mod common;
use common::setup_app;
#[test]
fn test_shovel_expansion() {
let mut app = setup_app(
5,
5,
&[(2, 2, TileState::Empty)], // (2,2) is Empty, making it a "claimed" neighbor
vec![(ItemType::Shovel, 1)], // One shovel in inventory
None,
);
let target_pos = (2, 3); // Unclaimed tile next to (2,2), not on edge of 5x5 grid
let _ = app.world_mut().run_system_once(
move |
grid: Res<Grid>,
mut tile_states: Query<&mut TileState>,
mut inventory: ResMut<Inventory>,
mut item_stack_query: Query<&mut ItemStack>,
mut commands: Commands,
_game_config: Res<GameConfig> // Not directly used but required by signature
| {
let (x, y) = target_pos;
let tile_entity = match grid.get_tile((x, y)) {
Ok(entity) => entity,
Err(_) => panic!("Clicked outside grid at {:?}", (x,y)),
};
// Mimic the edge check from interact_click
if x == 0 || x == grid.width - 1 || y == 0 || y == grid.height - 1 {
panic!("Should not return early due to edge check for {:?} in a 5x5 grid", target_pos);
}
let neighbors = [
(x + 1, y),
(x.saturating_sub(1), y),
(x, y + 1),
(x, y.saturating_sub(1)),
];
let mut has_claimed_neighbor = false;
for (nx, ny) in neighbors.iter() {
if *nx < grid.width && *ny < grid.height {
if let Ok(neighbor_entity) = grid.get_tile((*nx, *ny)) {
if let Ok(neighbor_state) = tile_states.get(neighbor_entity) {
if !matches!(*neighbor_state, TileState::Unclaimed) {
has_claimed_neighbor = true;
break;
}
}
}
}
}
assert!(has_claimed_neighbor, "Target tile {:?} should have a claimed neighbor", target_pos);
let mut tile_state = match tile_states.get_mut(tile_entity) {
Ok(state) => state,
Err(_) => panic!("Failed to get mutable tile state for {:?}", target_pos),
};
assert!(matches!(*tile_state, TileState::Unclaimed), "Target tile {:?} should be Unclaimed initially, but was {:?}", target_pos, tile_state);
// Execute the expansion
if inventory.update_item_stack(
&mut commands,
&mut item_stack_query,
ItemType::Shovel,
-1,
) {
*tile_state = TileState::Empty;
} else {
panic!("Shovel not consumed or not found in inventory");
}
},
);
app.update(); // Apply commands
// Assert Tile State
let grid = app.world().resource::<Grid>();
let tile_entity = grid.get_tile(target_pos).unwrap();
let tile_state = app.world().entity(tile_entity).get::<TileState>().unwrap();
assert!(
matches!(*tile_state, TileState::Empty),
"Tile (1,2) should be Empty after expansion, but was {:?}",
tile_state
);
// Assert Inventory
let inventory = app.world().resource::<Inventory>();
let shovel_stack = inventory.items.iter().find_map(|&entity| {
let stack = app.world().entity(entity).get::<ItemStack>()?;
if stack.item_type == ItemType::Shovel {
Some(stack)
} else {
None
}
});
assert!(
shovel_stack.is_none() || shovel_stack.unwrap().amount == 0,
"Shovel should have been consumed, found {:?}",
shovel_stack
);
}