From 7db11e9065753d60ed590230213692dfeaf6b5ce Mon Sep 17 00:00:00 2001 From: demenik Date: Sat, 29 Nov 2025 16:08:51 +0100 Subject: [PATCH 01/59] fix: Fix #52 (Move/interaction click outside grid) and fix move/interaction click through popups --- src/features/grid/utils.rs | 50 ++++++++++++++++++++------ src/features/hud/ui/settings.rs | 1 + src/features/input/mod.rs | 23 ++++++++++-- src/features/inventory/ui/inventory.rs | 1 + src/features/savegame/ui/load.rs | 1 + 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/features/grid/utils.rs b/src/features/grid/utils.rs index 3c032da..4165d82 100644 --- a/src/features/grid/utils.rs +++ b/src/features/grid/utils.rs @@ -1,3 +1,6 @@ +use bevy::window::PrimaryWindow; + +use super::errors::GridError; use crate::prelude::*; pub fn grid_start_x(grid_width: u32) -> f32 { @@ -8,24 +11,25 @@ pub fn grid_start_y(grid_height: u32) -> f32 { -(grid_height as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0 } -pub fn world_to_grid_coords(world_pos: Vec3, grid_width: u32, grid_height: u32) -> (u32, u32) { +pub fn world_to_grid_coords( + world_pos: Vec3, + grid_width: u32, + grid_height: u32, +) -> Result<(u32, u32), GridError> { let start_x = grid_start_x(grid_width); let start_y = grid_start_y(grid_height); let x = ((world_pos.x - start_x + TILE_SIZE / 2.0) / TILE_SIZE).floor(); let y = ((world_pos.y - start_y + TILE_SIZE / 2.0) / TILE_SIZE).floor(); - let mut x_u32 = x as u32; - let mut y_u32 = y as u32; - - if x_u32 >= grid_width { - x_u32 = grid_width - 1; - } - if y_u32 >= grid_height { - y_u32 = grid_height - 1; + if x >= grid_width as f32 || y >= grid_height as f32 || x < 0.0 || y < 0.0 { + return Err(GridError::OutOfBounds { + x: x as i32, + y: x as i32, + }); } - (x_u32, y_u32) + Ok((x as u32, y as u32)) } pub fn grid_to_world_coords( @@ -41,3 +45,29 @@ pub fn grid_to_world_coords( z.unwrap_or(0.0), ) } + +pub fn ui_blocks( + window: Single<&Window, With>, + cursor_pos: Vec2, + ui_query: Query<(&ComputedNode, &GlobalTransform), With>, +) -> bool { + let ui_point = Vec2::new( + cursor_pos.x - window.width() / 2.0, + (window.height() / 2.0) - cursor_pos.y, + ); + + dbg!(ui_point); + + ui_query.iter().any(|(node, global_transform)| { + dbg!(node, global_transform); + + let size = node.size(); + let center = global_transform.translation().truncate(); + let half_size = size / 2.0; + + let min = center - half_size; + let max = center + half_size; + + ui_point.x >= min.x && ui_point.x <= max.x && ui_point.y >= min.y && ui_point.y <= max.y + }) +} diff --git a/src/features/hud/ui/settings.rs b/src/features/hud/ui/settings.rs index dd8868d..f2b16f5 100644 --- a/src/features/hud/ui/settings.rs +++ b/src/features/hud/ui/settings.rs @@ -14,6 +14,7 @@ pub fn open_settings(commands: &mut Commands) { }, ZIndex(1), BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)), + GlobalTransform::default(), )) .with_children(|parent| { parent diff --git a/src/features/input/mod.rs b/src/features/input/mod.rs index ce12444..9b95b8a 100644 --- a/src/features/input/mod.rs +++ b/src/features/input/mod.rs @@ -1,4 +1,5 @@ use crate::features::{ + grid::utils::ui_blocks, phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage}, pom::messages::InvalidMoveMessage, }; @@ -38,6 +39,7 @@ fn move_click( camera: Single<(&Camera, &GlobalTransform), With>, config: Res, phase: Res, + ui_query: Query<(&ComputedNode, &GlobalTransform), With>, ) { match phase.0 { Phase::Focus { .. } => return, @@ -50,10 +52,17 @@ fn move_click( let Some(cursor_pos) = window.cursor_position() else { return; }; + if ui_blocks(window, cursor_pos, ui_query) { + return; + } let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else { return; }; - let (x, y) = world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height); + let Ok((x, y)) = + world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height) + else { + return; + }; println!("Move Click: ({}, {})", x, y); move_messages.write(MoveMessage { x, y }); @@ -67,6 +76,7 @@ fn interact_click( camera: Single<(&Camera, &GlobalTransform), With>, config: Res, phase: Res, + ui_query: Query<(&ComputedNode, &GlobalTransform), With>, // for debug grid: ResMut, tile_query: Query<&mut TileState>, @@ -82,10 +92,17 @@ fn interact_click( let Some(cursor_pos) = window.cursor_position() else { return; }; + if ui_blocks(window, cursor_pos, ui_query) { + return; + } let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else { return; }; - let (x, y) = world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height); + let Ok((x, y)) = + world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height) + else { + return; + }; println!("Interact Click: ({}, {})", x, y); interact_messages.write(InteractStartMessage { x, y }); @@ -100,7 +117,7 @@ fn interact_click( }, tile_query, ) - .unwrap(); + .unwrap_or_else(|_| ()); } } } diff --git a/src/features/inventory/ui/inventory.rs b/src/features/inventory/ui/inventory.rs index e77d9cc..769307a 100644 --- a/src/features/inventory/ui/inventory.rs +++ b/src/features/inventory/ui/inventory.rs @@ -13,6 +13,7 @@ pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) { }, ZIndex(1), BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)), + GlobalTransform::default(), )) .with_children(|parent| { parent diff --git a/src/features/savegame/ui/load.rs b/src/features/savegame/ui/load.rs index 80b405e..2df621b 100644 --- a/src/features/savegame/ui/load.rs +++ b/src/features/savegame/ui/load.rs @@ -13,6 +13,7 @@ pub fn spawn_load_popup(commands: &mut Commands) { }, ZIndex(1), BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)), + GlobalTransform::default(), )) .with_children(|parent| { parent From c3ef1f531566c5e33ae54bbb6e4162e8b747eaf6 Mon Sep 17 00:00:00 2001 From: demenik Date: Sat, 29 Nov 2025 16:17:40 +0100 Subject: [PATCH 02/59] refactor: Move ui_blocks() to ui feature --- src/features/grid/utils.rs | 28 ---------------------------- src/features/input/mod.rs | 1 - src/features/ui/mod.rs | 1 + src/features/ui/ui/button.rs | 2 ++ src/features/ui/utils.rs | 28 ++++++++++++++++++++++++++++ src/prelude.rs | 2 +- 6 files changed, 32 insertions(+), 30 deletions(-) create mode 100644 src/features/ui/utils.rs diff --git a/src/features/grid/utils.rs b/src/features/grid/utils.rs index 4165d82..95c722e 100644 --- a/src/features/grid/utils.rs +++ b/src/features/grid/utils.rs @@ -1,5 +1,3 @@ -use bevy::window::PrimaryWindow; - use super::errors::GridError; use crate::prelude::*; @@ -45,29 +43,3 @@ pub fn grid_to_world_coords( z.unwrap_or(0.0), ) } - -pub fn ui_blocks( - window: Single<&Window, With>, - cursor_pos: Vec2, - ui_query: Query<(&ComputedNode, &GlobalTransform), With>, -) -> bool { - let ui_point = Vec2::new( - cursor_pos.x - window.width() / 2.0, - (window.height() / 2.0) - cursor_pos.y, - ); - - dbg!(ui_point); - - ui_query.iter().any(|(node, global_transform)| { - dbg!(node, global_transform); - - let size = node.size(); - let center = global_transform.translation().truncate(); - let half_size = size / 2.0; - - let min = center - half_size; - let max = center + half_size; - - ui_point.x >= min.x && ui_point.x <= max.x && ui_point.y >= min.y && ui_point.y <= max.y - }) -} diff --git a/src/features/input/mod.rs b/src/features/input/mod.rs index 9b95b8a..58f4846 100644 --- a/src/features/input/mod.rs +++ b/src/features/input/mod.rs @@ -1,5 +1,4 @@ use crate::features::{ - grid::utils::ui_blocks, phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage}, pom::messages::InvalidMoveMessage, }; diff --git a/src/features/ui/mod.rs b/src/features/ui/mod.rs index 6d42304..677f301 100644 --- a/src/features/ui/mod.rs +++ b/src/features/ui/mod.rs @@ -4,6 +4,7 @@ use bevy::{input::mouse::*, picking::hover::HoverMap}; pub mod components; pub mod consts; pub mod ui; +pub mod utils; pub struct UiPlugin; diff --git a/src/features/ui/ui/button.rs b/src/features/ui/ui/button.rs index 11ae87e..7dac536 100644 --- a/src/features/ui/ui/button.rs +++ b/src/features/ui/ui/button.rs @@ -1,3 +1,5 @@ +use bevy::window::PrimaryWindow; + use crate::prelude::*; pub fn button( diff --git a/src/features/ui/utils.rs b/src/features/ui/utils.rs new file mode 100644 index 0000000..6678979 --- /dev/null +++ b/src/features/ui/utils.rs @@ -0,0 +1,28 @@ +use crate::prelude::*; +use bevy::window::PrimaryWindow; + +pub fn ui_blocks( + window: Single<&Window, With>, + cursor_pos: Vec2, + ui_query: Query<(&ComputedNode, &GlobalTransform), With>, +) -> bool { + let ui_point = Vec2::new( + cursor_pos.x - window.width() / 2.0, + (window.height() / 2.0) - cursor_pos.y, + ); + + dbg!(ui_point); + + ui_query.iter().any(|(node, global_transform)| { + dbg!(node, global_transform); + + let size = node.size(); + let center = global_transform.translation().truncate(); + let half_size = size / 2.0; + + let min = center - half_size; + let max = center + half_size; + + ui_point.x >= min.x && ui_point.x <= max.x && ui_point.y >= min.y && ui_point.y <= max.y + }) +} diff --git a/src/prelude.rs b/src/prelude.rs index d5db937..ccc3a7d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -14,7 +14,7 @@ pub use crate::features::{ messages::{InteractStartMessage, MoveMessage}, }, savegame::components::SavegamePath, - ui::{components::ButtonVariant, consts::*, ui::*}, + ui::{components::ButtonVariant, consts::*, ui::*, utils::*}, }; pub use crate::utils::path::get_internal_path; pub use bevy::prelude::*; From 90b6785bada41d337b2fd4b8b5397f1bf50fd791 Mon Sep 17 00:00:00 2001 From: demenik Date: Sat, 29 Nov 2025 16:19:08 +0100 Subject: [PATCH 03/59] refactor: Remove dbg prints in ui_blocks() --- src/features/ui/utils.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/features/ui/utils.rs b/src/features/ui/utils.rs index 6678979..c147e31 100644 --- a/src/features/ui/utils.rs +++ b/src/features/ui/utils.rs @@ -11,11 +11,7 @@ pub fn ui_blocks( (window.height() / 2.0) - cursor_pos.y, ); - dbg!(ui_point); - ui_query.iter().any(|(node, global_transform)| { - dbg!(node, global_transform); - let size = node.size(); let center = global_transform.translation().truncate(); let half_size = size / 2.0; From 2547fae54254a8aa555e3a6cb3b9371b7574cc31 Mon Sep 17 00:00:00 2001 From: demenik Date: Sat, 29 Nov 2025 14:02:16 +0100 Subject: [PATCH 04/59] docs: Correct link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 88420ea..c64f0ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pomomon Garden ![Sleeping Pom](pom-sleep.gif) -Pomomon Garden is a Pomodoro game created during ["Sopra" (Softwareprojekt)](https://www.uni-ulm.de/in/sgi/lehre/softwaojekt-sopra-1/) WiSe 25/26 at the University of Ulm. +Pomomon Garden is a Pomodoro game created during ["Sopra" (Softwareprojekt)](https://www.uni-ulm.de/in/sgi/lehre/softwareprojekt-sopra-1/) WiSe 25/26 at the University of Ulm. It uses the [ECS](https://en.wikipedia.org/wiki/Entity_component_system)-based Rust game engine called [Bevy](https://bevy.org/). --- From c69649f1aeff0eadb970a565cfbb8ab680eaaac8 Mon Sep 17 00:00:00 2001 From: demenik Date: Sat, 29 Nov 2025 18:40:37 +0100 Subject: [PATCH 05/59] feat: Add all tests from #55 except sowing, watering, and harvesting --- Cargo.lock | 1 + Cargo.toml | 3 + src/features/config/components.rs | 6 +- src/features/phase/mod.rs | 65 +++++++------ src/features/ui/ui/button.rs | 2 +- tests/config.rs | 54 ++++++++++ tests/pathfinding.rs | 157 ++++++++++++++++++++++++++++++ tests/session.rs | 110 +++++++++++++++++++++ 8 files changed, 365 insertions(+), 33 deletions(-) create mode 100644 tests/config.rs create mode 100644 tests/pathfinding.rs create mode 100644 tests/session.rs diff --git a/Cargo.lock b/Cargo.lock index 21c0c78..a56f128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3663,6 +3663,7 @@ dependencies = [ "directories", "serde", "serde_json", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c345354..411a34b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,6 @@ bevy_dev_tools = "0.17.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" directories = "6.0" + +[dev-dependencies] +uuid = "1.18.1" diff --git a/src/features/config/components.rs b/src/features/config/components.rs index 94fb4fc..63c4fc6 100644 --- a/src/features/config/components.rs +++ b/src/features/config/components.rs @@ -21,7 +21,11 @@ impl Default for GameConfig { impl GameConfig { pub fn read_config() -> Option { - let file = File::open("assets/config.json").ok()?; + Self::read_from_path(std::path::Path::new("assets/config.json")) + } + + pub fn read_from_path(path: &std::path::Path) -> Option { + let file = File::open(path).ok()?; let reader = BufReader::new(file); serde_json::from_reader(reader).ok() } diff --git a/src/features/phase/mod.rs b/src/features/phase/mod.rs index 8d3b830..4181fb1 100644 --- a/src/features/phase/mod.rs +++ b/src/features/phase/mod.rs @@ -119,6 +119,39 @@ fn handle_pause( } } +pub fn next_phase( + current_phase: &mut CurrentPhase, + session_tracker: &mut SessionTracker, + settings: &TimerSettings, +) { + if let Phase::Finished { completed_phase } = ¤t_phase.0 { + match **completed_phase { + Phase::Focus { .. } => { + session_tracker.completed_focus_phases += 1; + + let is_long_break = session_tracker.completed_focus_phases > 0 + && session_tracker.completed_focus_phases % settings.long_break_interval == 0; + + if is_long_break { + current_phase.0 = Phase::Break { + duration: settings.long_break_duration as f32, + }; + } else { + current_phase.0 = Phase::Break { + duration: settings.short_break_duration as f32, + }; + } + } + Phase::Break { .. } => { + current_phase.0 = Phase::Focus { + duration: settings.focus_duration as f32, + }; + } + _ => {} + } + } +} + fn handle_continue( mut messages: MessageReader, mut phase_res: ResMut, @@ -126,36 +159,6 @@ fn handle_continue( settings: Res, ) { for _ in messages.read() { - let phase = &mut phase_res.0; - - if let Phase::Finished { completed_phase } = phase { - match **completed_phase { - Phase::Focus { .. } => { - session_tracker.completed_focus_phases += 1; - - // TODO: add berry grant logic - - let is_long_break = session_tracker.completed_focus_phases > 0 - && session_tracker.completed_focus_phases % settings.long_break_interval - == 0; - - if is_long_break { - *phase = Phase::Break { - duration: settings.long_break_duration as f32, - }; - } else { - *phase = Phase::Break { - duration: settings.short_break_duration as f32, - }; - } - } - Phase::Break { .. } => { - *phase = Phase::Focus { - duration: 25.0 * 60.0, - }; - } - _ => {} - } - } + next_phase(&mut phase_res, &mut session_tracker, &settings); } } diff --git a/src/features/ui/ui/button.rs b/src/features/ui/ui/button.rs index 7dac536..3ce229a 100644 --- a/src/features/ui/ui/button.rs +++ b/src/features/ui/ui/button.rs @@ -1,4 +1,4 @@ -use bevy::window::PrimaryWindow; + use crate::prelude::*; diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 0000000..3401dbe --- /dev/null +++ b/tests/config.rs @@ -0,0 +1,54 @@ +use pomomon_garden::features::config::components::GameConfig; +use std::fs; +use std::io::Write; +use uuid::Uuid; + +// Helper function to create a temporary file with content +fn create_temp_file(content: &str) -> (std::path::PathBuf, String) { + let filename = format!("test_config_{}.json", Uuid::new_v4()); + let temp_dir = std::env::temp_dir(); + let filepath = temp_dir.join(&filename); + + let mut file = fs::File::create(&filepath).expect("Could not create temp file"); + file.write_all(content.as_bytes()) + .expect("Could not write to temp file"); + + (filepath, filename) +} + +#[test] +fn test_load_valid_config() { + let (filepath, _filename) = create_temp_file( + r#"{ + "grid_width": 10, + "grid_height": 5, + "pom_speed": 2.0 + }"#, + ); + + let config = GameConfig::read_from_path(&filepath).expect("Failed to read valid config"); + assert_eq!(config.grid_width, 10); + assert_eq!(config.grid_height, 5); + assert_eq!(config.pom_speed, 2.0); + + fs::remove_file(filepath).expect("Failed to delete temp file"); +} + +#[test] +fn test_load_invalid_config() { + let (filepath, _filename) = create_temp_file(r#"this is not valid json"#); + + let config = GameConfig::read_from_path(&filepath); + assert!(config.is_none(), "Expected invalid config to return None"); + + fs::remove_file(filepath).expect("Failed to delete temp file"); +} + +#[test] +fn test_load_missing_config() { + let temp_dir = std::env::temp_dir(); + let filepath = temp_dir.join("non_existent_config.json"); + + let config = GameConfig::read_from_path(&filepath); + assert!(config.is_none(), "Expected missing config to return None"); +} diff --git a/tests/pathfinding.rs b/tests/pathfinding.rs new file mode 100644 index 0000000..02ffde8 --- /dev/null +++ b/tests/pathfinding.rs @@ -0,0 +1,157 @@ +use bevy::ecs::system::RunSystemOnce; +use pomomon_garden::features::grid::components::{Grid, Tile, TileState}; +use pomomon_garden::features::pom::utils::find_path; +use pomomon_garden::prelude::*; +use std::collections::VecDeque; + +// Helper to set up a Bevy App for pathfinding tests +fn setup_pathfinding_app( + grid_width: u32, + grid_height: u32, + initial_tile_states: &[(u32, u32, TileState)], // (x, y, state) +) -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + + 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(); + } + } + + app +} + +// Struct to hold start and end positions as a Resource +#[derive(Resource)] +struct PathParams { + start: UVec2, + end: UVec2, +} + +// Test system to run find_path and store result +#[derive(Resource, Default)] +struct PathResult(Option>); + +fn pathfinding_system( + grid: Res, + tile_query: Query<&TileState>, + mut path_result: ResMut, + path_params: Res, // Input: (start, end) +) { + path_result.0 = find_path( + path_params.start.into(), + path_params.end.into(), + &grid, + &tile_query, + ); +} + +#[test] +fn test_find_path_simple() { + let mut app = setup_pathfinding_app(5, 5, &[]); // Empty 5x5 grid + + app.world_mut().insert_resource(PathResult(None)); + app.world_mut().insert_resource(PathParams { + start: UVec2::new(0, 0), + end: UVec2::new(4, 4), + }); + + let _ = app.world_mut().run_system_once(pathfinding_system); + + let path_result = app.world().resource::(); + assert!(path_result.0.is_some()); + let path = path_result.0.as_ref().unwrap(); + // A* with Manhattan distance will find a shortest path. + // Length for (0,0) to (4,4) in an empty grid is 8 steps + 1 start = 9 nodes + assert_eq!(path.len(), 9, "Path length incorrect for simple path"); + assert_eq!(*path.front().unwrap(), (0, 0), "Path should start at (0,0)"); + assert_eq!(*path.back().unwrap(), (4, 4), "Path should end at (4,4)"); +} + +#[test] +fn test_find_path_around_obstacle() { + let obstacles = vec![ + (2, 2, TileState::Occupied), + (2, 3, TileState::Occupied), + (2, 4, TileState::Occupied), + ]; + let mut app = setup_pathfinding_app(5, 5, &obstacles); + + let _ = app.world_mut().insert_resource(PathResult(None)); + let _ = app.world_mut().insert_resource(PathParams { + start: UVec2::new(0, 0), + end: UVec2::new(4, 4), + }); + + let _ = app.world_mut().run_system_once(pathfinding_system); + + let path_result = app.world().resource::(); + + if path_result.0.is_none() { + panic!("Should find a path around obstacles, but found none."); + } + + let path = path_result.0.as_ref().unwrap(); + + assert_eq!(*path.front().unwrap(), (0, 0)); + assert_eq!(*path.back().unwrap(), (4, 4)); + + // Assert that no obstacle tile is in the path + for (ox, oy, _) in &obstacles { + assert!( + !path.contains(&(*ox, *oy)), + "Path should not contain obstacle {:?}", + (ox, oy) + ); + } + + // The shortest path around these specific obstacles has a length of 9 nodes (8 steps) + // For example: (0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (4,1) -> (4,2) -> (4,3) -> (4,4) + assert_eq!( + path.len(), + 9, + "Path length incorrect for path around obstacles." + ); +} + +#[test] +fn test_find_path_no_path() { + let obstacles = vec![ + (2, 0, TileState::Occupied), + (2, 1, TileState::Occupied), + (2, 2, TileState::Occupied), + (2, 3, TileState::Occupied), + (2, 4, TileState::Occupied), + ]; + let mut app = setup_pathfinding_app(5, 5, &obstacles); + + app.world_mut().insert_resource(PathResult(None)); + app.world_mut().insert_resource(PathParams { + start: UVec2::new(0, 0), + end: UVec2::new(4, 4), + }); + + let _ = app.world_mut().run_system_once(pathfinding_system); + + let path_result = app.world().resource::(); + assert!(path_result.0.is_none(), "Expected no path when blocked"); +} diff --git a/tests/session.rs b/tests/session.rs new file mode 100644 index 0000000..8007a69 --- /dev/null +++ b/tests/session.rs @@ -0,0 +1,110 @@ +use pomomon_garden::features::phase::components::{ + CurrentPhase, Phase, SessionTracker, TimerSettings, +}; +use pomomon_garden::features::phase::next_phase; + +#[test] +fn test_session_tracker_focus_to_short_break() { + let mut current_phase = CurrentPhase(Phase::Finished { + completed_phase: Box::new(Phase::Focus { + duration: 25.0 * 60.0, + }), + }); + let timer_settings = TimerSettings::default(); + let mut session_tracker = SessionTracker::default(); + + next_phase(&mut current_phase, &mut session_tracker, &timer_settings); + + assert_eq!( + session_tracker.completed_focus_phases, 1, + "Completed focus phases should be 1" + ); + if let Phase::Break { duration } = current_phase.0 { + assert_eq!( + duration, timer_settings.short_break_duration as f32, + "Should transition to short break" + ); + } else { + panic!("Expected a Break phase, got {:?}", current_phase.0); + } +} + +#[test] +fn test_session_tracker_focus_to_long_break() { + let mut current_phase = CurrentPhase(Phase::Finished { + completed_phase: Box::new(Phase::Focus { + duration: 25.0 * 60.0, + }), + }); + let timer_settings = TimerSettings::default(); + let mut session_tracker = SessionTracker { + completed_focus_phases: timer_settings.long_break_interval - 1, + }; // To trigger long break on next phase + + next_phase(&mut current_phase, &mut session_tracker, &timer_settings); + + assert_eq!( + session_tracker.completed_focus_phases, timer_settings.long_break_interval, + "Completed focus phases should reach long break interval" + ); + if let Phase::Break { duration } = current_phase.0 { + assert_eq!( + duration, timer_settings.long_break_duration as f32, + "Should transition to long break" + ); + } else { + panic!("Expected a Break phase, got {:?}", current_phase.0); + } +} + +#[test] +fn test_session_tracker_break_to_focus() { + let mut current_phase = CurrentPhase(Phase::Finished { + completed_phase: Box::new(Phase::Break { + duration: 5.0 * 60.0, + }), + }); + let mut session_tracker = SessionTracker { + completed_focus_phases: 1, + }; // Arbitrary value, should not change + let timer_settings = TimerSettings::default(); + + next_phase(&mut current_phase, &mut session_tracker, &timer_settings); + + assert_eq!( + session_tracker.completed_focus_phases, 1, + "Completed focus phases should not change" + ); + if let Phase::Focus { duration } = current_phase.0 { + assert_eq!( + duration, timer_settings.focus_duration as f32, + "Should transition to Focus phase" + ); + } else { + panic!("Expected a Focus phase, got {:?}", current_phase.0); + } +} + +#[test] +fn test_session_tracker_not_finished_phase_no_change() { + // Test that nothing changes if the phase is not `Finished` + let mut current_phase = CurrentPhase(Phase::Focus { duration: 100.0 }); + let mut session_tracker = SessionTracker { + completed_focus_phases: 0, + }; + let timer_settings = TimerSettings::default(); + + let initial_phase = current_phase.0.clone(); + let initial_completed_focus = session_tracker.completed_focus_phases; + + next_phase(&mut current_phase, &mut session_tracker, &timer_settings); + + assert_eq!( + current_phase.0, initial_phase, + "Phase should not change if not Finished" + ); + assert_eq!( + session_tracker.completed_focus_phases, initial_completed_focus, + "Session tracker should not change if phase not Finished" + ); +} From 4a4c60a9edc560b1db06e563d5e87185cdd9305e Mon Sep 17 00:00:00 2001 From: demenik Date: Sat, 29 Nov 2025 18:57:31 +0100 Subject: [PATCH 06/59] ops: Add automatic test runs and building for linux x86_64 --- .gitlab-ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..7b414d0 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,32 @@ +image: "rust:latest" +stages: + - build + - test +cache: + key: "$CI_COMMIT_REF_SLUG" + paths: + - .cargo/ + - target/ +.only_on_main_rule: &only_on_main_rule + rules: + - if: $CI_COMMIT_REF_SLUG == "main" +.install_linux_dependencies: &install_linux_dependencies + before_script: + - apt-get update -yq + - apt-get install -yq g++ pkg-config libx11-dev libasound2-dev libudev-dev libxkbcommon-x11-0 +linux_build: + stage: build + !!merge <<: *only_on_main_rule + !!merge <<: *install_linux_dependencies + script: + - cargo build --release --target x86_64-unknown-linux-gnu + artifacts: + paths: + - target/x86_64-unknown-linux-gnu/release/pomomon-garden + name: "pomomon-garden-linux-x86_64-$CI_COMMIT_SHORT_SHA" + expire_in: "2 weeks" +run_tests: + stage: test + !!merge <<: *install_linux_dependencies + script: + - cargo test --verbose From 746f57e170570273eafff2cd3f5255fe1c7fe0ed Mon Sep 17 00:00:00 2001 From: demenik Date: Sun, 30 Nov 2025 15:57:33 +0100 Subject: [PATCH 07/59] feat: Add seed texture (#51) --- assets/seed.aseprite | Bin 0 -> 528 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/seed.aseprite diff --git a/assets/seed.aseprite b/assets/seed.aseprite new file mode 100644 index 0000000000000000000000000000000000000000..c4b2d1c4f74e7808f4a22b429d557132ed4bcbb5 GIT binary patch literal 528 zcmWe&Vqkc%l#xLJ3>g_17*c>30n`~7L4phdKu8NP0ciTKk1SwYS%4OZ0WrG**h(bp z>Va$)2B4@qkfEet;;LbjW@*?TW_`a@#`U3+F$R%BREnN!yqBcq$)$=9oQ^-uTe z$~X9I!LwGJA%>U1K!)8*iZ5ABq{-h(9%Q`&Gm?Y;1KI2hK8cm7MGA&zfE-o@AO^z! z!0?0sF`!>6=A4~k%hh1O(|Y&LoH<8s-n^;2qk<#<(NS$p7CFVg*rH!c4CUEgZQwTb%_gJ-Mf$7h{UKRc~_%443rZ@q68A35-SYr)o7#n(-rJ3r_B sY=~NO^YGlJ@(8&pTfB5l|7!0Hsy?Epw(N_37W=~b`1cE>d(0y{0aKiuDF6Tf literal 0 HcmV?d00001 From 4412246f2db57db38e8c523b5f1808eacec8f0d9 Mon Sep 17 00:00:00 2001 From: demenik Date: Sun, 30 Nov 2025 16:26:47 +0100 Subject: [PATCH 08/59] feat: Add berry sprite (#51) --- assets/berry.aseprite | Bin 0 -> 555 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/berry.aseprite diff --git a/assets/berry.aseprite b/assets/berry.aseprite new file mode 100644 index 0000000000000000000000000000000000000000..c5eba74350897c75b826e4d6b1251185c4c5b34a GIT binary patch literal 555 zcmdO9Vqkc%l#xLJ3>g_17*c>30W=vHL4phdKu8O)8ff~jk1SwYS%4OZ0WrG**h(bp zT7hg92B4@WkfEet;;LbjW@*?TW_`a@#`U3+F$R%BREnN!yqBcq$)$=9oQ^-uTe z$~Sl+nb^i}9k|2ojAY^q-{u#RrGb;&0;}8tm$}J<9HGFBxfgI`E#Tl< z$Ze7+%*+-x^CZLPd()@?y=hh}S-ws8$&AN3V&GJmsio_O8RVe#4m^DL>b3 zoFmAt_V)XSxo7`?Yf0?-F#G>cH#_ewmvr~*e?|W>THf`suz*P13 X1z-Ekw*9Yj?@39`oF{&vF`^RyLnx_u literal 0 HcmV?d00001 From 5d73ae30364f0a5b4df6caf7ca6be53f8cca1f91 Mon Sep 17 00:00:00 2001 From: demenik Date: Sun, 30 Nov 2025 16:28:56 +0100 Subject: [PATCH 09/59] fix: Add slice names --- assets/berry.aseprite | Bin 555 -> 614 bytes assets/seed.aseprite | Bin 528 -> 586 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/berry.aseprite b/assets/berry.aseprite index c5eba74350897c75b826e4d6b1251185c4c5b34a..2675e6e86707a53414550a34bfe1807f871953d9 100644 GIT binary patch delta 90 zcmZ3@@{ENsZ6aem?=waQhF>4q7*ZG*7}zHE?zPqhGL#g6qHw^<;FMZaR0-z;846I$ T2ci|2fFuLM|NoP{7#{)v6}=BV delta 30 mcmaFHvYLfadm>{!?`lQ{hF>397*ZG*7+5Cu?w#Dq_z(b$hY7p@ diff --git a/assets/seed.aseprite b/assets/seed.aseprite index c4b2d1c4f74e7808f4a22b429d557132ed4bcbb5..401ea6b354fef561785d12aa7ccc71e0dccd197c 100644 GIT binary patch delta 89 zcmbQha*Bn~Ya(Mk?4q7*ZG*7}zHEZnx3_GL#g6qHw^%5S*F{l7|BYAPa=~ SK(qoAkYr%^|9`Rz;~4397*ZG*7+5CuZl7Gocm@E0L Date: Sun, 30 Nov 2025 16:31:23 +0100 Subject: [PATCH 10/59] feat: Add BerrySeed ItemType (#51) --- src/features/inventory/components.rs | 40 +++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/features/inventory/components.rs b/src/features/inventory/components.rs index bf2e418..cc41777 100644 --- a/src/features/inventory/components.rs +++ b/src/features/inventory/components.rs @@ -3,28 +3,56 @@ use crate::prelude::*; #[derive(Serialize, Deserialize, Clone)] pub enum ItemType { Berry, + BerrySeed { + prefix: String, + cost: u32, + grants: u32, + slice: String, + }, } impl ItemType { pub fn singular(&self) -> String { match self { - ItemType::Berry => "Beere", + ItemType::Berry => "Beere".into(), + ItemType::BerrySeed { prefix, .. } => format!("{}samen", prefix), } - .into() } pub fn plural(&self) -> String { match self { - ItemType::Berry => "Beeren", + ItemType::Berry => "Beeren".into(), + ItemType::BerrySeed { prefix, .. } => format!("{}samen", prefix), } - .into() } pub fn description(&self) -> String { match self { - ItemType::Berry => "Von Pflanzen erntbar. Kann im Shop zum Einkaufen benutzt werden.", + ItemType::Berry => { + "Von Pflanzen erntbar. Kann im Shop zum Einkaufen benutzt werden.".into() + } + ItemType::BerrySeed { grants, .. } => format!( + "Im Shop kaufbar. Kann eingepflanzt werden. Erhalte beim Ernten {} {}.", + grants, + match grants { + 1 => ItemType::Berry.singular(), + _ => ItemType::Berry.plural(), + } + ), + } + } + + pub fn get_sprite(&self, asset_server: Res) -> AseSlice { + match self { + ItemType::Berry => AseSlice { + name: "Berry".into(), + aseprite: asset_server.load("berry.aseprite"), + }, + ItemType::BerrySeed { slice, .. } => AseSlice { + name: slice.into(), + aseprite: asset_server.load("seed.aseprite"), + }, } - .into() } } From 7337b553ba134390407365944b0329fd3f434f1b Mon Sep 17 00:00:00 2001 From: demenik Date: Sun, 30 Nov 2025 16:40:36 +0100 Subject: [PATCH 11/59] fix: Add 3 seed variants (#51) --- assets/seed.aseprite | Bin 586 -> 1026 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/seed.aseprite b/assets/seed.aseprite index 401ea6b354fef561785d12aa7ccc71e0dccd197c..2bf58399ff97626cf097e0fe9cd72093c37d8ccc 100644 GIT binary patch delta 648 zcmX@b(!{~Z#KOSvU@0R*!bHw;-X>-ShF>4K7*ZG*7`P_(ZkN;rGL#e;fg}vDG6bil zrWj6+VN{aCC2IsytS~u|Q5L5XV~`RDkP;?drpXPABK2VmVhj~?-p(-eJ7OT_@2k}T4>v;`Y8)*RQu9a`$(P&f0VK96o^}NGiddx(66r+MrMWXToq?);=NWY z{AzY*TC4Q`+H-q%e7gVqPPaCYnO(3n&hq;suf0X@j!vz5y5|ae(#1pZlDdDU2EKm# z>B_6~KNDWByn6q0e(2U;>FTt%H^$9 zr?l&%svqxtrLk$t*O1J(UB|xIpS|{>Pa^#9v`0~gt&?KDMt5bOvTk9`Ua_`v<=LGn z{W7)O+}8r4KL^b&J}GDUOH6Fd!fnr%nmwOnyW+jfy6nA8G4baf?z*OZ;_Fiom+AHC zHU5$Bm)A6u2S!zIv`gJP|5Fv?J=e^)M}JNET6c=QLVV?|Zd*|!ze)$6)s~ek)mkZY zJ~hk;EB?gsE#!p7Q%1wJw|3w7%oe#WZ1>Lh*RPqMDB61KZ0-JS&(=>{eKoH)ZjHg# xe-CeVi!QrnZ&SVT(3M~AYvp+UE_UT_`*(2>yYqjGY6H`A{nf|K-%nGx3IGCXDeV9N delta 269 zcmZqTIK{&0#l*nyU@0Sm!bHw;-cyVW48K0IF{CgsFtAPR-7cX6WGE>BMd5&jAviTP zWpXT|QvDgAAgclp1L1$5F%TfeAjVKJ=j;?)t_B01*1LD+%sF!N=1t`t6&(4Gj%sVN z$SL-2>r7A-VBf!Q-=C7$yOGcBPOflSapv`nOFMfkzh$1dFK1?KIK%(qDYvy}QuoT8 z{UX0l_A`gxtD|>bJ8Le=5?>g)Y4Pvx`c}R>u1(yh7(81&KR)Y>`q^paQy%l|ed~R* z_{f3pTMM?vD!y*|-1#~0XG7GQn}_E%l}E@;+2W;Z`d52rQ1uZ#wPj!Qv)C8b$G=}7 J-D4iv2>=}gZFm3x From 710f7beb5ad6d5258753d44fbf7f1142a1d7d280 Mon Sep 17 00:00:00 2001 From: demenik Date: Sun, 30 Nov 2025 16:59:23 +0100 Subject: [PATCH 12/59] feat: Add seed game config entries (#51) --- assets/config.json | 22 +++++++- src/features/config/components.rs | 29 +++++++++++ src/features/inventory/components.rs | 71 +++++++++++++++++--------- src/features/inventory/mod.rs | 3 +- src/features/inventory/ui/inventory.rs | 5 +- src/features/inventory/ui/item.rs | 12 +++-- 6 files changed, 110 insertions(+), 32 deletions(-) diff --git a/assets/config.json b/assets/config.json index 2695fb3..43ea227 100644 --- a/assets/config.json +++ b/assets/config.json @@ -1,5 +1,25 @@ { "grid_width": 12, "grid_height": 4, - "pom_speed": 1.5 + "pom_speed": 1.5, + "berry_seeds": [ + { + "name": "Normale Samen", + "cost": 1, + "grants": 2, + "slice": "Seed1" + }, + { + "name": "Super-Samen", + "cost": 3, + "grants": 9, + "slice": "Seed2" + }, + { + "name": "Zauber-Samen", + "cost": 5, + "grants": 20, + "slice": "Seed3" + } + ] } diff --git a/src/features/config/components.rs b/src/features/config/components.rs index 63c4fc6..652e70e 100644 --- a/src/features/config/components.rs +++ b/src/features/config/components.rs @@ -7,6 +7,15 @@ pub struct GameConfig { pub grid_width: u32, pub grid_height: u32, pub pom_speed: f32, + pub berry_seeds: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct BerrySeedConfig { + pub name: String, + pub cost: u32, + pub grants: u32, + pub slice: String, } impl Default for GameConfig { @@ -15,6 +24,26 @@ impl Default for GameConfig { grid_width: 12, grid_height: 4, pom_speed: 1.5, + berry_seeds: vec![ + BerrySeedConfig { + name: "Normale Samen".to_string(), + cost: 1, + grants: 2, + slice: "Seed1".to_string(), + }, + BerrySeedConfig { + name: "Super-Samen".to_string(), + cost: 3, + grants: 9, + slice: "Seed2".to_string(), + }, + BerrySeedConfig { + name: "Zauber-Samen".to_string(), + cost: 5, + grants: 20, + slice: "Seed3".to_string(), + }, + ], } } } diff --git a/src/features/inventory/components.rs b/src/features/inventory/components.rs index cc41777..7b793c7 100644 --- a/src/features/inventory/components.rs +++ b/src/features/inventory/components.rs @@ -1,57 +1,80 @@ use crate::prelude::*; -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] pub enum ItemType { Berry, - BerrySeed { - prefix: String, - cost: u32, - grants: u32, - slice: String, - }, + BerrySeed { name: String }, } impl ItemType { - pub fn singular(&self) -> String { + pub fn singular(&self, game_config: &GameConfig) -> String { match self { ItemType::Berry => "Beere".into(), - ItemType::BerrySeed { prefix, .. } => format!("{}samen", prefix), + ItemType::BerrySeed { name } => { + let seed_config = game_config.berry_seeds.iter().find(|s| s.name == *name); + seed_config + .map(|s| s.name.clone()) + .unwrap_or_else(|| format!("Unbekannter Samen ({})", name)) + } } } - pub fn plural(&self) -> String { + pub fn plural(&self, game_config: &GameConfig) -> String { match self { ItemType::Berry => "Beeren".into(), - ItemType::BerrySeed { prefix, .. } => format!("{}samen", prefix), + ItemType::BerrySeed { name } => { + let seed_config = game_config.berry_seeds.iter().find(|s| s.name == *name); + seed_config + .map(|s| s.name.clone()) + .unwrap_or_else(|| format!("Unbekannte Samen ({})", name)) + } } } - pub fn description(&self) -> String { + pub fn description(&self, game_config: &GameConfig) -> String { match self { ItemType::Berry => { "Von Pflanzen erntbar. Kann im Shop zum Einkaufen benutzt werden.".into() } - ItemType::BerrySeed { grants, .. } => format!( - "Im Shop kaufbar. Kann eingepflanzt werden. Erhalte beim Ernten {} {}.", - grants, - match grants { - 1 => ItemType::Berry.singular(), - _ => ItemType::Berry.plural(), + ItemType::BerrySeed { name } => { + let seed_config = game_config.berry_seeds.iter().find(|s| s.name == *name); + if let Some(s) = seed_config { + format!( + "Im Shop kaufbar. Kann eingepflanzt werden. Erhalte beim Ernten {} {}.", + s.grants, + match s.grants { + 1 => ItemType::Berry.singular(game_config), + _ => ItemType::Berry.plural(game_config), + } + ) + } else { + format!("Unbekannter Samen ({})", name) } - ), + } } } - pub fn get_sprite(&self, asset_server: Res) -> AseSlice { + pub fn get_sprite(&self, asset_server: Res, game_config: &GameConfig) -> AseSlice { match self { ItemType::Berry => AseSlice { name: "Berry".into(), aseprite: asset_server.load("berry.aseprite"), }, - ItemType::BerrySeed { slice, .. } => AseSlice { - name: slice.into(), - aseprite: asset_server.load("seed.aseprite"), - }, + ItemType::BerrySeed { name } => { + let seed_config = game_config.berry_seeds.iter().find(|s| s.name == *name); + if let Some(s) = seed_config { + AseSlice { + name: s.slice.clone(), + aseprite: asset_server.load("seed.aseprite"), + } + } else { + // Fallback for unknown seed + AseSlice { + name: "Seed1".into(), + aseprite: asset_server.load("seed.aseprite"), + } + } + } } } } diff --git a/src/features/inventory/mod.rs b/src/features/inventory/mod.rs index 1b60ee8..2915d40 100644 --- a/src/features/inventory/mod.rs +++ b/src/features/inventory/mod.rs @@ -19,12 +19,13 @@ fn buttons( mut interaction_query: Query<(&Interaction, &ButtonType), (Changed, With