Merge branch 'dev' into 'main'

Merge Sprint 3

Closes #52

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!28
This commit is contained in:
Moritz Peter Maile
2025-12-05 13:04:10 +00:00
53 changed files with 2772 additions and 200 deletions

32
.gitlab-ci.yml Normal file
View File

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

1
Cargo.lock generated
View File

@@ -3663,6 +3663,7 @@ dependencies = [
"directories", "directories",
"serde", "serde",
"serde_json", "serde_json",
"uuid",
] ]
[[package]] [[package]]

View File

@@ -20,3 +20,6 @@ bevy_dev_tools = "0.17.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
directories = "6.0" directories = "6.0"
[dev-dependencies]
uuid = "1.18.1"

View File

@@ -1,6 +1,6 @@
# Pomomon Garden ![Sleeping Pom](pom-sleep.gif) # 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/). It uses the [ECS](https://en.wikipedia.org/wiki/Entity_component_system)-based Rust game engine called [Bevy](https://bevy.org/).
--- ---
@@ -40,10 +40,14 @@ cargo run
### Hidden binds (Only available in the debug build) ### Hidden binds (Only available in the debug build)
- `Shift + Enter`: Duration of the current phase is set to 3 seconds. - `Shift + Enter`: Duration of the current phase is set to 3 seconds.
- `Left Mouse Button` on Tile: Rotate tile state. - `Shift + Left Mouse Button` on Tile: Toggle tile state.
- `Shift + Arrow Up`: Add one berry to your inventory
- `Shift + Arrow Down`: Remove one berry from your inventory
--- ---
## Licensing ## Licensing
This project is released under the terms of the [MIT License](LICENSE). This project is released under the terms of the [MIT License](LICENSE).
The font used is called Jersey 10. It is protected under the [SIL OPEN FONT LICENSE Version 1.1](assets/fonts/Jersey10.LICENSE).

BIN
assets/berry.aseprite Normal file

Binary file not shown.

View File

@@ -1,5 +1,30 @@
{ {
"grid_width": 12, "grid_width": 12,
"grid_height": 4, "grid_height": 4,
"pom_speed": 1.5 "pom_speed": 1.5,
"shovel_base_price": 10,
"shovel_rate": 0.5,
"berry_seeds": [
{
"name": "Normale Samen",
"cost": 1,
"grants": 2,
"slice": "Seed1",
"growth_stages": 2
},
{
"name": "Super-Samen",
"cost": 3,
"grants": 9,
"slice": "Seed2",
"growth_stages": 4
},
{
"name": "Zauber-Samen",
"cost": 5,
"grants": 20,
"slice": "Seed3",
"growth_stages": 6
}
]
} }

BIN
assets/crop.aseprite Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2023 The Soft Type Project Authors (https://github.com/scfried/soft-type-jersey)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
assets/seed.aseprite Normal file

Binary file not shown.

View File

@@ -7,6 +7,18 @@ pub struct GameConfig {
pub grid_width: u32, pub grid_width: u32,
pub grid_height: u32, pub grid_height: u32,
pub pom_speed: f32, pub pom_speed: f32,
pub shovel_base_price: u32,
pub shovel_rate: f32,
pub berry_seeds: Vec<BerrySeedConfig>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BerrySeedConfig {
pub name: String,
pub cost: u32,
pub grants: u32,
pub slice: String,
pub growth_stages: u32,
} }
impl Default for GameConfig { impl Default for GameConfig {
@@ -15,13 +27,42 @@ impl Default for GameConfig {
grid_width: 12, grid_width: 12,
grid_height: 4, grid_height: 4,
pom_speed: 1.5, pom_speed: 1.5,
shovel_base_price: 10,
shovel_rate: 0.2,
berry_seeds: vec![
BerrySeedConfig {
name: "Normale Samen".to_string(),
cost: 1,
grants: 2,
slice: "Seed1".to_string(),
growth_stages: 2,
},
BerrySeedConfig {
name: "Super-Samen".to_string(),
cost: 3,
grants: 9,
slice: "Seed2".to_string(),
growth_stages: 4,
},
BerrySeedConfig {
name: "Zauber-Samen".to_string(),
cost: 5,
grants: 20,
slice: "Seed3".to_string(),
growth_stages: 6,
},
],
} }
} }
} }
impl GameConfig { impl GameConfig {
pub fn read_config() -> Option<Self> { pub fn read_config() -> Option<Self> {
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<Self> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
serde_json::from_reader(reader).ok() serde_json::from_reader(reader).ok()
} }

View File

@@ -7,18 +7,32 @@ pub struct Tile {
pub y: u32, pub y: u32,
} }
#[derive(Component, Default, Serialize, Deserialize, Clone, Copy, Debug)] #[derive(Component)]
pub struct CropVisual;
#[derive(Component)]
pub struct WaterVisual;
#[derive(Component, Default, Serialize, Deserialize, Clone, Debug)]
pub enum TileState { pub enum TileState {
#[default] #[default]
Unclaimed, Unclaimed,
Empty, Empty,
Occupied, Occupied {
seed: ItemType,
watered: bool,
growth_stage: u32,
#[serde(default)]
withered: bool,
#[serde(default)]
dry_counter: u8,
},
} }
impl TileState { impl TileState {
pub fn is_blocking(&self) -> bool { pub fn is_blocking(&self) -> bool {
match self { match self {
TileState::Occupied => true, TileState::Occupied { .. } => true,
_ => false, _ => false,
} }
} }

View File

@@ -1,4 +1,5 @@
use crate::prelude::*; use crate::prelude::*;
use components::{CropVisual, WaterVisual};
pub mod components; pub mod components;
pub mod consts; pub mod consts;
@@ -12,10 +13,7 @@ impl Plugin for GridPlugin {
app.add_systems(OnEnter(AppState::GameScreen), setup); app.add_systems(OnEnter(AppState::GameScreen), setup);
app.add_systems(OnExit(AppState::GameScreen), cleanup); app.add_systems(OnExit(AppState::GameScreen), cleanup);
app.add_systems( app.add_systems(Update, update_tiles.run_if(in_state(AppState::GameScreen)));
Update,
update_tile_colors.run_if(in_state(AppState::GameScreen)),
);
} }
} }
@@ -45,6 +43,30 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<Gam
grid_height, grid_height,
)), )),
)) ))
.with_children(|parent| {
parent.spawn((
CropVisual,
AseSlice {
name: "Crop".into(),
aseprite: asset_server.load("crop.aseprite"),
},
Sprite::default(),
Transform::from_translation(Vec3::new(0.0, 0.0, 1.0)),
Visibility::Hidden,
ZIndex(1),
));
parent.spawn((
WaterVisual,
AseSlice {
name: "Water".into(),
aseprite: asset_server.load("crop.aseprite"),
},
Sprite::default(),
Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)),
Visibility::Hidden,
ZIndex(2),
));
})
.id(); .id();
column.push(tile_entity); column.push(tile_entity);
} }
@@ -65,22 +87,73 @@ fn cleanup(mut commands: Commands, tile_query: Query<Entity, With<Tile>>) {
commands.remove_resource::<Grid>(); commands.remove_resource::<Grid>();
} }
fn update_tile_colors( fn update_tiles(
mut query: Query<(&TileState, &mut AseSlice)>, mut query: Query<(&TileState, &mut AseSlice, &Children), (With<Tile>, Without<CropVisual>)>,
mut crop_query: Query<
(&mut Visibility, &mut Transform, &mut AseSlice),
(With<CropVisual>, Without<WaterVisual>, Without<Tile>),
>,
mut water_query: Query<
(&mut Visibility, &mut Transform),
(With<WaterVisual>, Without<CropVisual>),
>,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
game_config: Res<GameConfig>,
) { ) {
for (state, mut slice) in &mut query { for (state, mut slice, children) in &mut query {
slice.name = match state { slice.name = match state {
TileState::Unclaimed => "Unclaimed", TileState::Unclaimed => "Unclaimed",
TileState::Empty => "Empty", TileState::Empty => "Empty",
TileState::Occupied => "Occupied", TileState::Occupied { .. } => "Occupied",
} }
.into(); .into();
slice.aseprite = match state { slice.aseprite = match state {
TileState::Unclaimed => asset_server.load("tiles/tile-unclaimed.aseprite"), TileState::Unclaimed => asset_server.load("tiles/tile-unclaimed.aseprite"),
TileState::Empty => asset_server.load("tiles/tile-empty.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"),
}; };
let scale: Vec3 = match state {
TileState::Occupied {
seed, growth_stage, ..
} => {
let max_stages = seed
.get_seed_config(&game_config)
.map(|config| config.growth_stages)
.unwrap_or(0);
if max_stages > 0 {
let progress = (*growth_stage as f32 / max_stages as f32).min(1.0);
Vec3::splat(0.3 + (progress * 0.7))
} else {
Vec3::ONE
}
}
_ => Vec3::ONE,
};
for child in children.iter() {
if let Ok((mut visibility, mut transform, mut sprite)) = crop_query.get_mut(child) {
*visibility = match state {
TileState::Occupied { .. } => Visibility::Visible,
_ => Visibility::Hidden,
};
transform.scale = scale;
if let TileState::Occupied { withered: true, .. } = state {
sprite.name = "Wither".into();
} else {
sprite.name = "Crop".into();
}
}
if let Ok((mut visibility, mut transform)) = water_query.get_mut(child) {
*visibility = match state {
TileState::Occupied { watered: true, .. } => Visibility::Visible,
_ => Visibility::Hidden,
};
transform.scale = scale;
}
}
} }
} }

View File

@@ -1,3 +1,4 @@
use super::errors::GridError;
use crate::prelude::*; use crate::prelude::*;
pub fn grid_start_x(grid_width: u32) -> f32 { pub fn grid_start_x(grid_width: u32) -> f32 {
@@ -8,24 +9,25 @@ pub fn grid_start_y(grid_height: u32) -> f32 {
-(grid_height as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0 -(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_x = grid_start_x(grid_width);
let start_y = grid_start_y(grid_height); let start_y = grid_start_y(grid_height);
let x = ((world_pos.x - start_x + TILE_SIZE / 2.0) / TILE_SIZE).floor(); 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 y = ((world_pos.y - start_y + TILE_SIZE / 2.0) / TILE_SIZE).floor();
let mut x_u32 = x as u32; if x >= grid_width as f32 || y >= grid_height as f32 || x < 0.0 || y < 0.0 {
let mut y_u32 = y as u32; return Err(GridError::OutOfBounds {
x: x as i32,
if x_u32 >= grid_width { y: x as i32,
x_u32 = grid_width - 1; });
}
if y_u32 >= grid_height {
y_u32 = grid_height - 1;
} }
(x_u32, y_u32) Ok((x as u32, y as u32))
} }
pub fn grid_to_world_coords( pub fn grid_to_world_coords(

View File

@@ -1,6 +1,6 @@
use crate::features::inventory;
use crate::features::phase::components::TimerSettings; use crate::features::phase::components::TimerSettings;
use crate::features::savegame::messages::SavegameDumpMessage; use crate::features::savegame::messages::SavegameDumpMessage;
use crate::features::{inventory, shop};
use crate::prelude::*; use crate::prelude::*;
use components::*; use components::*;
use ui::*; use ui::*;
@@ -41,6 +41,15 @@ fn setup(mut commands: Commands) {
children![ children![
text_with_component(TextType::Phase, "...", 16.0, Color::WHITE), text_with_component(TextType::Phase, "...", 16.0, Color::WHITE),
text_with_component(TextType::Timer, "...", 16.0, Color::WHITE), text_with_component(TextType::Timer, "...", 16.0, Color::WHITE),
button(
shop::components::ButtonType::ShopOpen,
ButtonVariant::Secondary,
Node {
padding: UiRect::all(px(10)),
..default()
},
|color| text("Shop [P]", 16.0, color)
),
button( button(
inventory::components::ButtonType::InventoryOpen, inventory::components::ButtonType::InventoryOpen,
ButtonVariant::Secondary, ButtonVariant::Secondary,
@@ -48,8 +57,7 @@ fn setup(mut commands: Commands) {
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..default() ..default()
}, },
"Inventar", |color| text("Inventar", 16.0, color)
16.0
), ),
button( button(
ButtonType::SettingsOpen, ButtonType::SettingsOpen,
@@ -58,8 +66,7 @@ fn setup(mut commands: Commands) {
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..default() ..default()
}, },
"Einstellungen", |color| text("Einstellungen", 16.0, color)
16.0
) )
], ],
)); ));

View File

@@ -14,6 +14,7 @@ pub fn open_settings(commands: &mut Commands) {
}, },
ZIndex(1), ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)), BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
)) ))
.with_children(|parent| { .with_children(|parent| {
parent parent
@@ -42,8 +43,7 @@ pub fn open_settings(commands: &mut Commands) {
height: px(40), height: px(40),
..default() ..default()
}, },
"X", |color| text("X", 24.0, color)
24.0
), ),
], ],
)); ));
@@ -58,8 +58,7 @@ pub fn open_settings(commands: &mut Commands) {
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..default() ..default()
}, },
"Spiel verlassen", |color| text("Spiel verlassen", 24.0, color)
24.0,
)); ));
parent.spawn(button( parent.spawn(button(
@@ -69,8 +68,7 @@ pub fn open_settings(commands: &mut Commands) {
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..default() ..default()
}, },
"Spiel speichern", |color| text("Spiel speichern", 24.0, color)
24.0,
)); ));
parent.spawn(( parent.spawn((

View File

@@ -29,8 +29,7 @@ fn timer_settings_part(input: SettingsTimerInput, amount: u32) -> impl Bundle {
width: percent(100), width: percent(100),
..default() ..default()
}, },
"+", |color| text("+", 12.0, color)
12.0
), ),
text_with_component(input.clone(), "--", 24.0, Color::WHITE), text_with_component(input.clone(), "--", 24.0, Color::WHITE),
button( button(
@@ -43,8 +42,7 @@ fn timer_settings_part(input: SettingsTimerInput, amount: u32) -> impl Bundle {
width: percent(100), width: percent(100),
..default() ..default()
}, },
"-", |color| text("-", 12.0, color)
12.0
), ),
], ],
) )

View File

@@ -1,11 +1,15 @@
use crate::features::{ use crate::features::{
input::utils::mouse_to_grid,
phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage}, phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage},
pom::messages::InvalidMoveMessage, pom::messages::InvalidMoveMessage,
shop::ui::open_shop,
}; };
use crate::prelude::*; use crate::prelude::*;
use bevy::input::mouse::MouseButton; use bevy::input::mouse::MouseButton;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
pub mod utils;
pub struct InputPlugin; pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
@@ -15,11 +19,15 @@ impl Plugin for InputPlugin {
app.add_systems(Update, move_click.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, move_click.run_if(in_state(AppState::GameScreen)));
app.add_message::<InteractStartMessage>(); app.add_message::<InteractStartMessage>();
app.add_message::<TileClickMessage>();
app.add_systems( app.add_systems(
Update, Update,
interact_click.run_if(in_state(AppState::GameScreen)), interact_click.run_if(in_state(AppState::GameScreen)),
); );
#[cfg(debug_assertions)]
app.add_systems(Update, debug_click.run_if(in_state(AppState::GameScreen)));
app.add_message::<PhaseTimerPauseMessage>(); app.add_message::<PhaseTimerPauseMessage>();
app.add_systems( app.add_systems(
Update, Update,
@@ -28,6 +36,8 @@ impl Plugin for InputPlugin {
app.add_message::<NextPhaseMessage>(); app.add_message::<NextPhaseMessage>();
app.add_systems(Update, next_phase.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, next_phase.run_if(in_state(AppState::GameScreen)));
app.add_systems(Update, shop_keybind.run_if(in_state(AppState::GameScreen)));
} }
} }
@@ -38,6 +48,7 @@ fn move_click(
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>, camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>, config: Res<GameConfig>,
phase: Res<CurrentPhase>, phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
) { ) {
match phase.0 { match phase.0 {
Phase::Focus { .. } => return, Phase::Focus { .. } => return,
@@ -45,15 +56,9 @@ fn move_click(
} }
if mouse_btn.just_pressed(MouseButton::Right) { if mouse_btn.just_pressed(MouseButton::Right) {
let (cam, cam_transform) = *camera; let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
let Some(cursor_pos) = window.cursor_position() else {
return; 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);
println!("Move Click: ({}, {})", x, y); println!("Move Click: ({}, {})", x, y);
move_messages.write(MoveMessage { x, y }); move_messages.write(MoveMessage { x, y });
@@ -61,14 +66,41 @@ fn move_click(
} }
fn interact_click( fn interact_click(
mut interact_messages: MessageWriter<InteractStartMessage>, mut tile_click_messages: MessageWriter<TileClickMessage>,
mouse_btn: Res<ButtonInput<MouseButton>>, mouse_btn: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
window: Single<&Window, With<PrimaryWindow>>, window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>, camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>, config: Res<GameConfig>,
phase: Res<CurrentPhase>, phase: Res<CurrentPhase>,
// for debug ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
grid: ResMut<Grid>, ) {
match phase.0 {
Phase::Focus { .. } => return,
_ => {}
}
if mouse_btn.just_pressed(MouseButton::Left) {
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
return;
}
let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
return;
};
tile_click_messages.write(TileClickMessage { x, y });
}
}
fn debug_click(
mouse_btn: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
grid: Res<Grid>,
tile_query: Query<&mut TileState>, tile_query: Query<&mut TileState>,
) { ) {
match phase.0 { match phase.0 {
@@ -77,31 +109,32 @@ fn interact_click(
} }
if mouse_btn.just_pressed(MouseButton::Left) { if mouse_btn.just_pressed(MouseButton::Left) {
let (cam, cam_transform) = *camera; if !keys.pressed(KeyCode::ShiftLeft) && !keys.pressed(KeyCode::ShiftRight) {
return;
let Some(cursor_pos) = window.cursor_position() else { }
let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
return; 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);
println!("Interact Click: ({}, {})", x, y); println!("Debug Toggle Click: ({}, {})", x, y);
interact_messages.write(InteractStartMessage { x, y });
if cfg!(debug_assertions) {
grid.map_tile_state( grid.map_tile_state(
(x, y), (x, y),
|state| match state { |state| match state {
TileState::Unclaimed => TileState::Empty, TileState::Unclaimed => TileState::Empty,
TileState::Empty => TileState::Occupied, TileState::Empty => TileState::Occupied {
TileState::Occupied => TileState::Unclaimed, seed: ItemType::BerrySeed {
name: "Debug".into(),
},
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
},
TileState::Occupied { .. } => TileState::Unclaimed,
}, },
tile_query, tile_query,
) )
.unwrap(); .unwrap_or_else(|_| ());
}
} }
} }
@@ -119,3 +152,14 @@ fn next_phase(mut messages: MessageWriter<NextPhaseMessage>, keys: Res<ButtonInp
messages.write(NextPhaseMessage); messages.write(NextPhaseMessage);
} }
} }
fn shop_keybind(
keys: Res<ButtonInput<KeyCode>>,
mut commands: Commands,
game_config: Res<GameConfig>,
asset_server: Res<AssetServer>,
) {
if keys.just_pressed(KeyCode::KeyP) {
open_shop(&mut commands, &game_config, &asset_server);
}
}

View File

@@ -0,0 +1,28 @@
use crate::prelude::*;
use bevy::window::PrimaryWindow;
pub fn mouse_to_grid(
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
) -> Option<(u32, u32)> {
let (cam, cam_transform) = *camera;
let Some(cursor_pos) = window.cursor_position() else {
return None;
};
if ui_blocks(window, cursor_pos, ui_query) {
return None;
}
let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else {
return None;
};
let Ok(grid_pos) =
world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height)
else {
return None;
};
Some(grid_pos)
}

View File

@@ -1,30 +1,104 @@
use crate::features::config::components::BerrySeedConfig;
use crate::prelude::*; use crate::prelude::*;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
pub enum ItemType { pub enum ItemType {
Berry, Berry,
BerrySeed { name: String },
Shovel,
} }
impl ItemType { impl ItemType {
pub fn singular(&self) -> String { pub fn singular(&self, game_config: &GameConfig) -> String {
match self { match self {
ItemType::Berry => "Beere", ItemType::Berry => "Beere".into(),
ItemType::Shovel => "Schaufel".into(),
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))
}
} }
.into()
} }
pub fn plural(&self) -> String { pub fn plural(&self, game_config: &GameConfig) -> String {
match self { match self {
ItemType::Berry => "Beeren", ItemType::Berry => "Beeren".into(),
ItemType::Shovel => "Schaufeln".into(),
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))
}
} }
.into()
} }
pub fn description(&self) -> String { pub fn description(&self, game_config: &GameConfig) -> String {
match self { 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::Shovel => "Im Shop kaufbar. Schaltet ein neues Feld im Garten frei. Preis steigt bei jedem Kauf!".into(),
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. Nach {} Fokus-Phasen ausgewachsen. Erhalte beim Ernten {} {}.",
s.growth_stages,
s.grants,
match s.grants {
1 => ItemType::Berry.singular(game_config),
_ => ItemType::Berry.plural(game_config),
}
)
} else {
format!("Unbekannter Samen ({})", name)
}
}
}
}
pub fn get_seed_config<'a>(&self, game_config: &'a GameConfig) -> Option<&'a BerrySeedConfig> {
match self {
ItemType::Berry | ItemType::Shovel => None,
ItemType::BerrySeed { name } => {
game_config.berry_seeds.iter().find(|s| s.name == *name)
}
}
}
pub fn get_sprite(
&self,
asset_server: &Res<AssetServer>,
game_config: &GameConfig,
) -> AseSlice {
match self {
ItemType::Berry => AseSlice {
name: "Berry".into(),
aseprite: asset_server.load("berry.aseprite"),
},
ItemType::Shovel => AseSlice {
name: "Berry".into(),
aseprite: asset_server.load("berry.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"),
}
}
}
} }
.into()
} }
} }
@@ -39,6 +113,84 @@ pub struct Inventory {
pub items: Vec<Entity>, pub items: Vec<Entity>,
} }
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,
items_query: &mut Query<&mut ItemStack>,
item_type_to_update: ItemType,
amount_delta: i32,
) -> bool {
let mut target_entity_index: Option<usize> = None;
let mut current_stack_amount: u32 = 0;
let mut entity_id_to_update: Option<Entity> = None;
// Try to find an existing stack of the item
for (i, &entity) in self.items.iter().enumerate() {
if let Ok(stack) = items_query.get(entity) {
if stack.item_type == item_type_to_update {
target_entity_index = Some(i);
current_stack_amount = stack.amount;
entity_id_to_update = Some(entity);
break;
}
}
}
match amount_delta {
val if val > 0 => {
// Add items
let add_amount = amount_delta as u32;
if let Some(entity) = entity_id_to_update {
if let Ok(mut stack) = items_query.get_mut(entity) {
stack.amount += add_amount;
}
} else {
// Item not found, create a new stack
let new_item_stack = ItemStack {
item_type: item_type_to_update,
amount: add_amount,
};
let id = commands.spawn(new_item_stack).id();
self.items.push(id);
}
true
}
val if val < 0 => {
// Remove items
let remove_amount = amount_delta.abs() as u32;
let Some(entity) = entity_id_to_update else {
return false; // Item not found for removal
};
if current_stack_amount < remove_amount {
return false; // Not enough items
};
if let Ok(mut stack) = items_query.get_mut(entity) {
stack.amount -= remove_amount;
if stack.amount == 0 {
commands.entity(entity).despawn();
if let Some(index) = target_entity_index {
self.items.remove(index);
}
}
}
true
}
_ => true,
}
}
}
#[derive(Component)] #[derive(Component)]
pub enum RootMarker { pub enum RootMarker {
Inventory, Inventory,

View File

@@ -11,6 +11,9 @@ impl Plugin for InventoryPlugin {
app.init_resource::<Inventory>(); app.init_resource::<Inventory>();
app.add_systems(Update, buttons.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, buttons.run_if(in_state(AppState::GameScreen)));
#[cfg(debug_assertions)]
app.add_systems(Update, debug_modify_berries);
} }
} }
@@ -19,12 +22,14 @@ fn buttons(
mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>, mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
itemstack_query: Query<&ItemStack>, itemstack_query: Query<&ItemStack>,
root_query: Query<(Entity, &RootMarker)>, root_query: Query<(Entity, &RootMarker)>,
game_config: Res<GameConfig>,
asset_server: Res<AssetServer>,
) { ) {
for (interaction, button_type) in &mut interaction_query { for (interaction, button_type) in &mut interaction_query {
match *interaction { match *interaction {
Interaction::Pressed => match button_type { Interaction::Pressed => match button_type {
ButtonType::InventoryOpen => { ButtonType::InventoryOpen => {
open_inventory(&mut commands, itemstack_query); open_inventory(&mut commands, itemstack_query, &game_config, &asset_server);
} }
ButtonType::InventoryClose => { ButtonType::InventoryClose => {
for (entity, root) in root_query.iter() { for (entity, root) in root_query.iter() {
@@ -38,3 +43,21 @@ fn buttons(
} }
} }
} }
#[cfg(debug_assertions)]
fn debug_modify_berries(
mut commands: Commands,
mut inventory: ResMut<Inventory>,
mut items: Query<&mut ItemStack>,
keys: Res<ButtonInput<KeyCode>>,
) {
if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
if keys.just_pressed(KeyCode::ArrowUp) {
println!("Adding 1 berry using debug bind");
inventory.update_item_stack(&mut commands, &mut items, ItemType::Berry, 1);
} else if keys.just_pressed(KeyCode::ArrowDown) {
println!("Removing 1 berry using debug bind");
inventory.update_item_stack(&mut commands, &mut items, ItemType::Berry, -1);
}
}
}

View File

@@ -1,7 +1,13 @@
use super::super::components::{ButtonType, RootMarker}; use super::super::components::{ButtonType, RootMarker};
use crate::prelude::GameConfig;
use crate::{features::inventory::ui::list_itemstack, prelude::*}; use crate::{features::inventory::ui::list_itemstack, prelude::*};
pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) { pub fn open_inventory(
commands: &mut Commands,
items: Query<&ItemStack>,
game_config: &Res<GameConfig>,
asset_server: &Res<AssetServer>,
) {
commands commands
.spawn(( .spawn((
RootMarker::Inventory, RootMarker::Inventory,
@@ -13,6 +19,7 @@ pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) {
}, },
ZIndex(1), ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)), BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
)) ))
.with_children(|parent| { .with_children(|parent| {
parent parent
@@ -42,8 +49,7 @@ pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) {
height: px(40), height: px(40),
..default() ..default()
}, },
"X", |color| text("X", 24.0, color)
24.0
), ),
], ],
)); ));
@@ -56,7 +62,7 @@ pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) {
}) })
.with_children(|parent| { .with_children(|parent| {
for itemstack in items.iter() { for itemstack in items.iter() {
parent.spawn(list_itemstack(itemstack)); parent.spawn(list_itemstack(itemstack, game_config, asset_server));
} }
}); });
}); });

View File

@@ -1,28 +1,33 @@
use crate::prelude::*; use crate::prelude::*;
pub fn list_itemstack(itemstack: &ItemStack) -> impl Bundle { pub fn list_itemstack(
itemstack: &ItemStack,
game_config: &GameConfig,
asset_server: &Res<AssetServer>,
) -> impl Bundle {
let name = match itemstack.amount { let name = match itemstack.amount {
1 => itemstack.item_type.singular(), 1 => itemstack.item_type.singular(game_config),
_ => itemstack.item_type.plural(), _ => itemstack.item_type.plural(game_config),
}; };
( (
Node { Node {
width: percent(100),
padding: UiRect::all(px(4)), padding: UiRect::all(px(4)),
..Node::hstack(px(8)) ..Node::hstack(px(8))
}, },
BackgroundColor(ButtonVariant::Secondary.normal_background()),
BorderRadius::all(px(10)), BorderRadius::all(px(10)),
children![ children![
( (
// Placeholder for icon
Node { Node {
height: percent(100), height: percent(100),
aspect_ratio: Some(1.0), aspect_ratio: Some(1.0),
..default() ..default()
}, },
BackgroundColor(ButtonVariant::Secondary.hover_background()), BackgroundColor(ButtonVariant::Secondary.hover_background()),
BorderRadius::all(px(10)) BorderRadius::all(px(10)),
itemstack.item_type.get_sprite(asset_server, game_config),
ImageNode::default()
), ),
( (
Node { Node {
@@ -36,7 +41,11 @@ pub fn list_itemstack(itemstack: &ItemStack) -> impl Bundle {
14.0, 14.0,
Color::WHITE Color::WHITE
), ),
text(itemstack.item_type.description(), 10.0, Color::WHITE) text(
itemstack.item_type.description(game_config),
10.0,
Color::WHITE
)
] ]
) )
], ],

View File

@@ -8,6 +8,7 @@ pub mod inventory;
pub mod phase; pub mod phase;
pub mod pom; pub mod pom;
pub mod savegame; pub mod savegame;
pub mod shop;
pub mod start_screen; pub mod start_screen;
pub mod ui; pub mod ui;
@@ -20,5 +21,6 @@ pub use inventory::InventoryPlugin;
pub use phase::PhasePlugin; pub use phase::PhasePlugin;
pub use pom::PomPlugin; pub use pom::PomPlugin;
pub use savegame::SavegamePlugin; pub use savegame::SavegamePlugin;
pub use shop::ShopPlugin;
pub use start_screen::StartScreenPlugin; pub use start_screen::StartScreenPlugin;
pub use ui::UiPlugin; pub use ui::UiPlugin;

View File

@@ -119,43 +119,94 @@ fn handle_pause(
} }
} }
fn handle_continue( pub fn next_phase(
mut messages: MessageReader<NextPhaseMessage>, current_phase: &mut CurrentPhase,
mut phase_res: ResMut<CurrentPhase>, session_tracker: &mut SessionTracker,
mut session_tracker: ResMut<SessionTracker>, settings: &TimerSettings,
settings: Res<TimerSettings>,
) { ) {
for _ in messages.read() { if let Phase::Finished { completed_phase } = &current_phase.0 {
let phase = &mut phase_res.0;
if let Phase::Finished { completed_phase } = phase {
match **completed_phase { match **completed_phase {
Phase::Focus { .. } => { Phase::Focus { .. } => {
session_tracker.completed_focus_phases += 1; session_tracker.completed_focus_phases += 1;
// TODO: add berry grant logic
let is_long_break = session_tracker.completed_focus_phases > 0 let is_long_break = session_tracker.completed_focus_phases > 0
&& session_tracker.completed_focus_phases % settings.long_break_interval && session_tracker.completed_focus_phases % settings.long_break_interval == 0;
== 0;
if is_long_break { if is_long_break {
*phase = Phase::Break { current_phase.0 = Phase::Break {
duration: settings.long_break_duration as f32, duration: settings.long_break_duration as f32,
}; };
} else { } else {
*phase = Phase::Break { current_phase.0 = Phase::Break {
duration: settings.short_break_duration as f32, duration: settings.short_break_duration as f32,
}; };
} }
} }
Phase::Break { .. } => { Phase::Break { .. } => {
*phase = Phase::Focus { current_phase.0 = Phase::Focus {
duration: 25.0 * 60.0, duration: settings.focus_duration as f32,
}; };
} }
_ => {} _ => {}
} }
} }
} }
pub fn handle_continue(
mut messages: MessageReader<NextPhaseMessage>,
mut phase_res: ResMut<CurrentPhase>,
mut session_tracker: ResMut<SessionTracker>,
settings: Res<TimerSettings>,
mut tile_query: Query<&mut TileState>,
game_config: Res<GameConfig>,
) {
for _ in messages.read() {
let entering_break = if let Phase::Finished { completed_phase } = &phase_res.0 {
matches!(**completed_phase, Phase::Focus { .. })
} else {
false
};
next_phase(&mut phase_res, &mut session_tracker, &settings);
if entering_break {
println!("Growing crops and resetting watered state.");
for mut state in tile_query.iter_mut() {
if let TileState::Occupied {
seed,
watered,
growth_stage,
withered,
dry_counter,
} = &*state
{
let mut new_stage = *growth_stage;
let mut new_withered = *withered;
let mut new_dry_counter = *dry_counter;
if *watered {
new_dry_counter = 0;
if let Some(config) = seed.get_seed_config(&game_config) {
if new_stage < config.growth_stages && !new_withered {
new_stage += 1;
}
}
} else {
new_dry_counter += 1;
if new_dry_counter >= 2 {
new_withered = true;
}
}
*state = TileState::Occupied {
seed: seed.clone(),
watered: false,
growth_stage: new_stage,
withered: new_withered,
dry_counter: new_dry_counter,
};
}
}
}
}
} }

182
src/features/pom/actions.rs Normal file
View File

@@ -0,0 +1,182 @@
use crate::prelude::*;
#[derive(Clone, Debug, PartialEq)]
pub enum InteractionAction {
Plant(ItemType),
Water,
Harvest,
}
impl InteractionAction {
pub fn get_name(&self, game_config: &GameConfig) -> String {
match self {
InteractionAction::Plant(item) => format!("Pflanze {}", item.singular(game_config)),
InteractionAction::Water => "Gießen".into(),
InteractionAction::Harvest => "Ernten".into(),
}
}
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)),
InteractionAction::Water => None,
InteractionAction::Harvest => Some(ItemType::Berry.get_sprite(asset_server, game_config)),
}
}
pub fn list_options(
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,
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 => {
for &entity in &inventory.items {
let Ok(stack) = item_query.get(entity) else {
continue;
};
if stack.amount <= 0 {
continue;
}
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,
game_config: &GameConfig,
) {
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(),
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
};
} else {
println!("No {:?} in inventory!", seed_type);
}
} else {
println!("Tile is not empty, cannot plant.");
}
}
InteractionAction::Water => {
if let TileState::Occupied {
seed,
growth_stage,
withered,
..
} = &*tile_state
{
println!("Watering {:?}", seed);
*tile_state = TileState::Occupied {
seed: seed.clone(),
watered: true,
growth_stage: *growth_stage,
withered: *withered,
dry_counter: 0,
};
} else {
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

@@ -1,3 +1,4 @@
use crate::features::pom::actions::InteractionAction;
use crate::prelude::*; use crate::prelude::*;
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -36,3 +37,9 @@ 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::*; use crate::prelude::*;
#[derive(Message)] #[derive(Message)]
@@ -15,4 +16,11 @@ pub struct InvalidMoveMessage {
pub struct InteractStartMessage { pub struct InteractStartMessage {
pub x: u32, pub x: u32,
pub y: u32, pub y: u32,
pub action: InteractionAction,
}
#[derive(Message)]
pub struct TileClickMessage {
pub x: u32,
pub y: u32,
} }

View File

@@ -1,32 +1,77 @@
use crate::prelude::*; use crate::prelude::*;
use components::*; use components::*;
use messages::InvalidMoveMessage; use messages::{InteractStartMessage, InvalidMoveMessage};
use std::collections::VecDeque;
use utils::find_path; use utils::find_path;
use utils::manhattan_distance;
pub mod actions;
pub mod components; pub mod components;
pub mod messages; pub mod messages;
pub mod ui;
pub mod utils; pub mod utils;
pub struct PomPlugin; pub struct PomPlugin;
impl Plugin for PomPlugin { impl Plugin for PomPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_plugins(ui::PomUiPlugin);
app.add_systems(OnEnter(AppState::GameScreen), setup); app.add_systems(OnEnter(AppState::GameScreen), setup);
app.add_systems(OnExit(AppState::GameScreen), cleanup); app.add_systems(OnExit(AppState::GameScreen), cleanup);
app.add_systems( app.add_systems(
Update, Update,
(handle_move, move_pom, update_pom).run_if(in_state(AppState::GameScreen)), (
handle_move,
handle_interact,
move_pom,
update_pom,
perform_interaction,
draw_path,
)
.run_if(in_state(AppState::GameScreen)),
); );
} }
} }
fn draw_path(
mut gizmos: Gizmos,
query: Query<(&Transform, &PathQueue), With<Pom>>,
config: Res<GameConfig>,
) {
let Ok((transform, path_queue)) = query.single() else {
return;
};
if path_queue.steps.is_empty() {
return;
}
let line_z = 0.5;
let mut current_pos = transform.translation;
current_pos.z = line_z;
for step in &path_queue.steps {
let next_pos = grid_to_world_coords(
step.0,
step.1,
Some(line_z),
config.grid_width,
config.grid_height,
);
gizmos.line(current_pos, next_pos, Color::srgba(1.0, 1.0, 1.0, 0.3));
current_pos = next_pos;
}
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<GameConfig>) { fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<GameConfig>) {
commands.spawn(( commands.spawn((
Pom, Pom,
GridPosition { x: 0, y: 0 }, GridPosition { x: 0, y: 0 },
PathQueue::default(), PathQueue::default(),
MovingState::default(), MovingState::default(),
InteractionTarget::default(),
AseAnimation { AseAnimation {
aseprite: asset_server.load("pom/pom-sleep.aseprite"), aseprite: asset_server.load("pom/pom-sleep.aseprite"),
animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop), animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop),
@@ -53,10 +98,14 @@ fn handle_move(
mut invalid_move_messages: MessageWriter<InvalidMoveMessage>, mut invalid_move_messages: MessageWriter<InvalidMoveMessage>,
grid: Res<Grid>, grid: Res<Grid>,
tile_query: Query<&TileState>, tile_query: Query<&TileState>,
mut pom_query: Query<(&GridPosition, &mut PathQueue)>, mut pom_query: Query<(&GridPosition, &mut PathQueue, &mut InteractionTarget)>,
) { ) {
for message in move_messages.read() { for message in move_messages.read() {
for (grid_pos, mut path_queue) in pom_query.iter_mut() { 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 grid_start = (grid_pos.x, grid_pos.y);
let start = path_queue.steps.front().unwrap_or(&grid_start); let start = path_queue.steps.front().unwrap_or(&grid_start);
let end = (message.x, message.y); let end = (message.x, message.y);
@@ -78,6 +127,104 @@ fn handle_move(
} }
} }
fn handle_interact(
mut interact_messages: MessageReader<InteractStartMessage>,
mut pom_query: Query<(&GridPosition, &mut PathQueue, &mut InteractionTarget)>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
) {
for message in interact_messages.read() {
for (grid_pos, mut path_queue, mut interaction_target) in pom_query.iter_mut() {
let target_pos = (message.x, message.y);
let current_pos = (grid_pos.x, grid_pos.y);
// If we are already adjacent to the target, just set the target and clear path
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;
}
// Find a path to an adjacent tile
let neighbors = [
(target_pos.0 as i32 + 1, target_pos.1 as i32),
(target_pos.0 as i32 - 1, target_pos.1 as i32),
(target_pos.0 as i32, target_pos.1 as i32 + 1),
(target_pos.0 as i32, target_pos.1 as i32 - 1),
];
let mut best_path: Option<VecDeque<(u32, u32)>> = None;
for (nx, ny) in neighbors {
if nx < 0 || ny < 0 {
continue;
}
let neighbor_pos = (nx as u32, ny as u32);
if let Some(path) = find_path(current_pos, neighbor_pos, &grid, &tile_query) {
// Pick the shortest path
if best_path.as_ref().map_or(true, |p| path.len() < p.len()) {
best_path = Some(path);
}
}
}
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;
}
}
}
}
fn perform_interaction(
mut pom_query: Query<(&GridPosition, &mut InteractionTarget, &PathQueue)>,
grid: Res<Grid>,
mut tile_query: Query<&mut TileState>,
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 {
// Wait until movement stops
if !path_queue.steps.is_empty() {
continue;
}
if manhattan_distance(pos.x, pos.y, target.0, target.1) == 1 {
println!(
"Performing interaction on tile ({}, {})",
target.0, target.1
);
if let Some(action) = &target_component.action {
action.execute(
target,
&grid,
&mut tile_query,
&mut inventory,
&mut item_stack_query,
&mut commands,
&config,
);
}
}
target_component.target = None;
target_component.action = None;
}
}
}
fn move_pom( fn move_pom(
time: Res<Time>, time: Res<Time>,
mut query: Query<( mut query: Query<(

View File

@@ -0,0 +1,149 @@
use crate::features::pom::actions::InteractionAction;
use crate::features::ui::utils::ui_blocks;
use crate::prelude::*;
use bevy::window::PrimaryWindow;
#[derive(Component)]
pub enum RootMarker {
ContextMenu,
}
#[derive(Component)]
pub enum ButtonType {
Interact {
x: u32,
y: u32,
action: InteractionAction,
},
Cancel,
}
pub fn spawn_context_menu(
mut commands: Commands,
mut tile_click_messages: MessageReader<TileClickMessage>,
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
for entity in root_query.iter() {
commands.entity(entity).try_despawn();
}
let world_pos = grid_to_world_coords(
message.x,
message.y,
Some(0.0),
config.grid_width,
config.grid_height,
);
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, &game_config);
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: px(screen_pos.x),
top: px(screen_pos.y),
padding: UiRect::all(px(5.0)),
..Node::vstack(px(5.0))
},
ZIndex(100),
BackgroundColor(Color::srgb(0.1, 0.1, 0.1)),
BorderRadius::all(px(5)),
RootMarker::ContextMenu,
GlobalTransform::default(),
))
.with_children(|parent| {
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,
ButtonVariant::Destructive,
Node {
padding: UiRect::all(px(5)),
..default()
},
|c| text("Abbrechen", 20.0, c),
));
});
}
}
}
pub fn click_outside_context_menu(
mut commands: Commands,
mouse_btn: Res<ButtonInput<MouseButton>>,
window: Single<&Window, With<PrimaryWindow>>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
root_query: Query<Entity, With<RootMarker>>,
) {
if mouse_btn.just_pressed(MouseButton::Left) {
let Some(cursor_pos) = window.cursor_position() else {
return;
};
if !ui_blocks(window, cursor_pos, ui_query) {
for entity in root_query.iter() {
commands.entity(entity).try_despawn();
}
}
}
}
pub fn buttons(
mut commands: Commands,
mut button_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
mut interact_messages: MessageWriter<InteractStartMessage>,
root_query: Query<Entity, With<RootMarker>>,
) {
for (interaction, button_type) in button_query.iter_mut() {
if *interaction == Interaction::Pressed {
match button_type {
ButtonType::Interact { x, y, action } => {
interact_messages.write(InteractStartMessage {
x: *x,
y: *y,
action: action.clone(),
});
}
ButtonType::Cancel => (),
}
for entity in root_query.iter() {
commands.entity(entity).despawn();
}
}
}
}

View File

@@ -0,0 +1,22 @@
use crate::prelude::*;
pub mod context_menu;
pub struct PomUiPlugin;
impl Plugin for PomUiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
context_menu::spawn_context_menu.run_if(in_state(AppState::GameScreen)),
);
app.add_systems(
Update,
context_menu::click_outside_context_menu.run_if(in_state(AppState::GameScreen)),
);
app.add_systems(
Update,
context_menu::buttons.run_if(in_state(AppState::GameScreen)),
);
}
}

View File

@@ -55,7 +55,7 @@ fn dump_savegame(
for y in 0..grid.height { for y in 0..grid.height {
if let Ok(entity) = grid.get_tile((x, y)) { if let Ok(entity) = grid.get_tile((x, y)) {
if let Ok(state) = tile_query.get(entity) { if let Ok(state) = tile_query.get(entity) {
col.push(*state); col.push(state.clone());
} else { } else {
col.push(TileState::Unclaimed); col.push(TileState::Unclaimed);
} }
@@ -146,7 +146,7 @@ fn load_savegame(
if x < grid.width && y < grid.height { if x < grid.width && y < grid.height {
if let Ok(entity) = grid.get_tile((x, y)) { if let Ok(entity) = grid.get_tile((x, y)) {
if let Ok(mut state) = tile_query.get_mut(entity) { 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();
} }
} }
} }

View File

@@ -13,6 +13,7 @@ pub fn spawn_load_popup(commands: &mut Commands) {
}, },
ZIndex(1), ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)), BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
)) ))
.with_children(|parent| { .with_children(|parent| {
parent parent
@@ -46,8 +47,7 @@ pub fn spawn_load_popup(commands: &mut Commands) {
height: px(40), height: px(40),
..default() ..default()
}, },
"X", |color| text("X", 24.0, color)
24.0
) )
], ],
)); ));
@@ -63,24 +63,22 @@ pub fn spawn_load_popup(commands: &mut Commands) {
}) })
.with_children(|parent| { .with_children(|parent| {
for savegame in SavegamePath::list() { for savegame in SavegamePath::list() {
parent.spawn(( parent.spawn(
Button, button(
ButtonType::SavegameLoad { ButtonType::SavegameLoad { savegame_path: savegame.path.clone() },
savegame_path: savegame.path.clone(),
},
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node { Node {
width: percent(100), width: percent(100),
height: px(80), padding: UiRect::all(px(10)),
flex_direction: FlexDirection::Row,
column_gap: px(10.0),
padding: UiRect::horizontal(px(10.0)),
..Node::center() ..Node::center()
}, },
BackgroundColor(ButtonVariant::Secondary.normal_background()), |color| (
BorderRadius::all(px(10)), Node {
children![ width: percent(100),
( align_items: AlignItems::Center,
..Node::hstack(px(10))
},
children![(
Node { Node {
width: percent(100), width: percent(100),
height: percent(100), height: percent(100),
@@ -92,7 +90,7 @@ pub fn spawn_load_popup(commands: &mut Commands) {
text( text(
format!("Spielstand {}", savegame.index + 1), format!("Spielstand {}", savegame.index + 1),
24.0, 24.0,
Color::WHITE color
), ),
text( text(
format!( format!(
@@ -115,11 +113,11 @@ pub fn spawn_load_popup(commands: &mut Commands) {
height: px(40), height: px(40),
..default() ..default()
}, },
"X", |color| text("X", 24.0, color)
24.0 )]
)
), ),
], );
));
} }
}); });
}); });

View File

@@ -0,0 +1,74 @@
use crate::prelude::*;
#[derive(Component)]
pub enum RootMarker {
Shop,
}
#[derive(Component)]
pub enum ButtonType {
ShopOpen,
ShopClose,
ShopBuyItem(ShopOffer),
}
#[derive(Clone)]
pub struct ShopOffer {
pub item: ItemStack,
pub cost: u32,
}
impl ShopOffer {
pub fn list_all(game_config: &GameConfig, tile_count: u32) -> Vec<ShopOffer> {
let mut offers = Vec::new();
for seed in &game_config.berry_seeds {
offers.push(ShopOffer {
item: ItemStack {
item_type: ItemType::BerrySeed {
name: seed.name.clone(),
},
amount: 1,
},
cost: seed.cost,
});
}
let mut shovel_cost = game_config.shovel_base_price as f32;
for _ in 0..=tile_count {
shovel_cost = shovel_cost + (game_config.shovel_rate * shovel_cost);
shovel_cost = shovel_cost.ceil();
}
offers.push(ShopOffer {
item: ItemStack {
item_type: ItemType::Shovel,
amount: 1,
},
cost: shovel_cost as u32,
});
offers
}
pub fn buy(
&self,
inventory: &mut Inventory,
commands: &mut Commands,
items: &mut Query<&mut ItemStack>,
) -> bool {
// Try to remove cost (berries)
if inventory.update_item_stack(commands, items, ItemType::Berry, -(self.cost as i32)) {
inventory.update_item_stack(
commands,
items,
self.item.item_type.clone(),
self.item.amount as i32,
);
true
} else {
false // Not enough berries
}
}
}

54
src/features/shop/mod.rs Normal file
View File

@@ -0,0 +1,54 @@
use crate::prelude::*;
use components::*;
use ui::open_shop;
pub mod components;
pub mod ui;
pub struct ShopPlugin;
impl Plugin for ShopPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, buttons.run_if(in_state(AppState::GameScreen)));
}
}
fn buttons(
mut commands: Commands,
mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
root_query: Query<(Entity, &RootMarker)>,
game_config: Res<GameConfig>,
asset_server: Res<AssetServer>,
mut inventory: ResMut<Inventory>,
mut items: Query<&mut ItemStack>,
) {
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);
}
ButtonType::ShopClose => {
for (entity, root) in root_query.iter() {
match *root {
RootMarker::Shop => commands.entity(entity).despawn(),
}
}
}
ButtonType::ShopBuyItem(offer) => {
if offer.buy(&mut inventory, &mut commands, &mut items) {
// Item bought, exit the menu
for (entity, root) in root_query.iter() {
match *root {
RootMarker::Shop => commands.entity(entity).despawn(),
}
}
} else {
// Error (e.g. not enough berries)
}
}
},
_ => {}
}
}
}

View File

@@ -0,0 +1,5 @@
pub mod offer;
pub mod shop;
pub use offer::shop_offer;
pub use shop::open_shop;

View File

@@ -0,0 +1,47 @@
use super::super::components::*;
use crate::{features::inventory::ui::item::list_itemstack, prelude::*};
pub fn shop_offer(
offer: &ShopOffer,
game_config: &GameConfig,
asset_server: &Res<AssetServer>,
) -> impl Bundle {
button(
ButtonType::ShopBuyItem(offer.clone()),
ButtonVariant::Secondary,
Node::default(),
|_| {
(
Node {
width: percent(100),
align_items: AlignItems::Center,
..Node::hstack(px(10))
},
children![
list_itemstack(&offer.item, game_config, asset_server),
shop_price(offer.cost, asset_server, game_config)
],
)
},
)
}
pub fn shop_price(
price: u32,
asset_server: &Res<AssetServer>,
game_config: &GameConfig,
) -> impl Bundle {
(
Node {
align_items: AlignItems::Center,
..Node::hstack(px(0))
},
children![
text(price.to_string(), 12.0, Color::WHITE),
(
ImageNode::default(),
ItemType::Berry.get_sprite(asset_server, game_config)
)
],
)
}

View File

@@ -0,0 +1,64 @@
use super::super::components::*;
use crate::{features::shop::ui::shop_offer, prelude::*};
pub fn open_shop(
commands: &mut Commands,
game_config: &GameConfig,
asset_server: &Res<AssetServer>,
) {
// TODO: calculate tile_count
let offers = ShopOffer::list_all(game_config, 0);
commands
.spawn((
RootMarker::Shop,
Node {
position_type: PositionType::Absolute,
width: percent(100),
height: percent(100),
..Node::center()
},
ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
GlobalTransform::default(),
))
.with_children(|parent| {
parent
.spawn((
Node {
width: px(700),
padding: UiRect::all(px(20.0)),
..Node::vstack(px(20))
},
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
BorderRadius::all(px(10.0)),
))
.with_children(|parent| {
parent.spawn((
Node {
justify_content: JustifyContent::SpaceBetween,
..Node::hstack(px(20))
},
children![
text("Shop", 40.0, Color::WHITE),
pill_button(
ButtonType::ShopClose,
ButtonVariant::Destructive,
Node {
width: px(40),
height: px(40),
..default()
},
|color| text("X", 24.0, color)
),
],
));
parent.spawn(Node::vstack(px(10))).with_children(|parent| {
for offer in offers {
parent.spawn(shop_offer(&offer, game_config, asset_server));
}
});
});
});
}

View File

@@ -34,8 +34,7 @@ fn setup(mut commands: Commands) {
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..default() ..default()
}, },
"Spiel laden", |color| text("Spiel laden", 33.0, color)
33.0
), ),
button( button(
ButtonType::NewGame, ButtonType::NewGame,
@@ -45,8 +44,7 @@ fn setup(mut commands: Commands) {
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..default() ..default()
}, },
"Neues Spiel", |color| text("Neues Spiel", 33.0, color)
33.0,
), ),
button( button(
ButtonType::Settings, ButtonType::Settings,
@@ -56,8 +54,7 @@ fn setup(mut commands: Commands) {
padding: UiRect::all(px(10)), padding: UiRect::all(px(10)),
..default() ..default()
}, },
"Einstellungen", |color| text("Einstellungen", 33.0, color)
33.0
), ),
], ],
)); ));

View File

@@ -4,6 +4,7 @@ use bevy::{input::mouse::*, picking::hover::HoverMap};
pub mod components; pub mod components;
pub mod consts; pub mod consts;
pub mod ui; pub mod ui;
pub mod utils;
pub struct UiPlugin; pub struct UiPlugin;

View File

@@ -1,12 +1,15 @@
use crate::prelude::*; use crate::prelude::*;
pub fn button( pub fn button<C, R>(
button_type: impl Component, button_type: impl Component,
variant: ButtonVariant, variant: ButtonVariant,
mut node: Node, mut node: Node,
title: impl Into<String>, child: C,
font_size: f32, ) -> impl Bundle
) -> impl Bundle { where
C: FnOnce(Color) -> R,
R: Bundle,
{
node.justify_content = JustifyContent::Center; node.justify_content = JustifyContent::Center;
node.align_items = AlignItems::Center; node.align_items = AlignItems::Center;
@@ -17,17 +20,20 @@ pub fn button(
node, node,
BackgroundColor(variant.normal_background()), BackgroundColor(variant.normal_background()),
BorderRadius::all(px(10)), BorderRadius::all(px(10)),
children![text(title, font_size, variant.text_color())], children![child(variant.text_color())],
) )
} }
pub fn pill_button( pub fn pill_button<C, R>(
button_type: impl Component, button_type: impl Component,
variant: ButtonVariant, variant: ButtonVariant,
mut node: Node, mut node: Node,
title: impl Into<String>, child: C,
font_size: f32, ) -> impl Bundle
) -> impl Bundle { where
C: FnOnce(Color) -> R,
R: Bundle,
{
node.justify_content = JustifyContent::Center; node.justify_content = JustifyContent::Center;
node.align_items = AlignItems::Center; node.align_items = AlignItems::Center;
@@ -38,7 +44,7 @@ pub fn pill_button(
node, node,
BackgroundColor(variant.normal_background()), BackgroundColor(variant.normal_background()),
BorderRadius::MAX, BorderRadius::MAX,
children![text(title, font_size, variant.text_color())], children![child(variant.text_color())],
) )
} }

24
src/features/ui/utils.rs Normal file
View File

@@ -0,0 +1,24 @@
use crate::prelude::*;
use bevy::window::PrimaryWindow;
pub fn ui_blocks(
window: Single<&Window, With<PrimaryWindow>>,
cursor_pos: Vec2,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
) -> bool {
let ui_point = Vec2::new(
cursor_pos.x - window.width() / 2.0,
(window.height() / 2.0) - cursor_pos.y,
);
ui_query.iter().any(|(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
})
}

View File

@@ -33,7 +33,17 @@ fn main() {
features::SavegamePlugin, features::SavegamePlugin,
features::UiPlugin, features::UiPlugin,
features::InventoryPlugin, features::InventoryPlugin,
features::ShopPlugin,
)) ))
.insert_resource(config) .insert_resource(config)
.add_systems(Startup, overwrite_default_font)
.run(); .run();
} }
fn overwrite_default_font(mut fonts: ResMut<Assets<Font>>) {
let custom_font_bytes = include_bytes!("../assets/fonts/Jersey10-Regular.ttf");
let custom_font =
Font::try_from_bytes(custom_font_bytes.to_vec()).expect("Failed to parse custom font");
let default_font_id = Handle::<Font>::default().id();
let _ = fonts.insert(default_font_id, custom_font);
}

View File

@@ -11,10 +11,10 @@ pub use crate::features::{
phase::components::{CurrentPhase, Phase}, phase::components::{CurrentPhase, Phase},
pom::{ pom::{
components::{GridPosition, MovingState, Pom}, components::{GridPosition, MovingState, Pom},
messages::{InteractStartMessage, MoveMessage}, messages::{InteractStartMessage, MoveMessage, TileClickMessage},
}, },
savegame::components::SavegamePath, savegame::components::SavegamePath,
ui::{components::ButtonVariant, consts::*, ui::*}, ui::{components::ButtonVariant, consts::*, ui::*, utils::*},
}; };
pub use crate::utils::path::get_internal_path; pub use crate::utils::path::get_internal_path;
pub use bevy::prelude::*; pub use bevy::prelude::*;

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
}

57
tests/config.rs Normal file
View File

@@ -0,0 +1,57 @@
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,
"shovel_base_price": 10,
"shovel_rate": 0.2,
"berry_seeds": []
}"#,
);
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");
}

167
tests/growth.rs Normal file
View File

@@ -0,0 +1,167 @@
use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig};
use pomomon_garden::features::grid::components::{Grid, Tile, TileState};
use pomomon_garden::features::inventory::components::ItemType;
use pomomon_garden::features::phase::components::{
CurrentPhase, Phase, SessionTracker, TimerSettings,
};
use pomomon_garden::features::phase::handle_continue;
use pomomon_garden::features::phase::messages::NextPhaseMessage;
use pomomon_garden::prelude::*;
fn setup_growth_app(
grid_width: u32,
grid_height: u32,
initial_tile_states: &[(u32, u32, TileState)],
) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(AssetPlugin::default());
app.init_resource::<TimerSettings>();
app.init_resource::<SessionTracker>();
// Initialize phase as Finished(Focus) to trigger growth on continue
app.insert_resource(CurrentPhase(Phase::Finished {
completed_phase: Box::new(Phase::Focus { duration: 25.0 }),
}));
// GameConfig with known seeds
app.insert_resource(GameConfig {
berry_seeds: vec![BerrySeedConfig {
name: "FastSeed".into(),
cost: 1,
grants: 1,
slice: "".into(),
growth_stages: 2,
}],
..Default::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();
}
}
app.add_message::<NextPhaseMessage>();
app
}
#[test]
fn test_crop_growth_logic() {
let seed_type = ItemType::BerrySeed {
name: "FastSeed".into(),
};
// Scenario:
// (0,0): Watered, Stage 0 -> Should grow to 1, become unwatered
// (1,0): Unwatered, Stage 0 -> Should NOT grow, stay unwatered
// (2,0): Watered, Stage 2 (Max) -> Should NOT grow, become unwatered
let initial_states = vec![
(
0,
0,
TileState::Occupied {
seed: seed_type.clone(),
watered: true,
growth_stage: 0,
withered: false,
dry_counter: 0,
},
),
(
1,
0,
TileState::Occupied {
seed: seed_type.clone(),
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
},
),
(
2,
0,
TileState::Occupied {
seed: seed_type.clone(),
watered: true,
growth_stage: 2, // Max
withered: false,
dry_counter: 0,
},
),
];
let mut app = setup_growth_app(3, 1, &initial_states);
app.world_mut().write_message(NextPhaseMessage);
let _ = app.world_mut().run_system_once(handle_continue);
let grid = app.world().resource::<Grid>();
// Check (0,0)
let t1 = grid.get_tile((0, 0)).unwrap();
let s1 = app.world().entity(t1).get::<TileState>().unwrap();
match s1 {
TileState::Occupied {
watered,
growth_stage,
..
} => {
assert_eq!(*growth_stage, 1, "(0,0) should grow to stage 1");
assert_eq!(*watered, false, "(0,0) should be unwatered");
}
_ => panic!("Invalid state at (0,0)"),
}
// Check (1,0)
let t2 = grid.get_tile((1, 0)).unwrap();
let s2 = app.world().entity(t2).get::<TileState>().unwrap();
match s2 {
TileState::Occupied {
watered,
growth_stage,
..
} => {
assert_eq!(*growth_stage, 0, "(1,0) should stay at stage 0");
assert_eq!(*watered, false, "(1,0) should be unwatered");
}
_ => panic!("Invalid state at (1,0)"),
}
// Check (2,0)
let t3 = grid.get_tile((2, 0)).unwrap();
let s3 = app.world().entity(t3).get::<TileState>().unwrap();
match s3 {
TileState::Occupied {
watered,
growth_stage,
..
} => {
assert_eq!(*growth_stage, 2, "(2,0) should stay at stage 2 (max)");
assert_eq!(*watered, false, "(2,0) should be unwatered");
}
_ => panic!("Invalid state at (2,0)"),
}
}

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

175
tests/pathfinding.rs Normal file
View File

@@ -0,0 +1,175 @@
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::<Grid>().get_tile((x, y)) {
*app.world_mut().get_mut::<TileState>(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<VecDeque<(u32, u32)>>);
fn pathfinding_system(
grid: Res<Grid>,
tile_query: Query<&TileState>,
mut path_result: ResMut<PathResult>,
path_params: Res<PathParams>, // 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::<PathResult>();
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 obstacle: TileState = TileState::Occupied {
seed: ItemType::BerrySeed {
name: "Test".into(),
},
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
};
let obstacles = vec![
(2, 2, obstacle.clone()),
(2, 3, obstacle.clone()),
(2, 4, obstacle.clone()),
];
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::<PathResult>();
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 obstacle: TileState = TileState::Occupied {
seed: ItemType::BerrySeed {
name: "Test".into(),
},
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
};
let obstacles = vec![
(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);
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::<PathResult>();
assert!(path_result.0.is_none(), "Expected no path when blocked");
}

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

110
tests/session.rs Normal file
View File

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

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"
);
},
);
}

162
tests/withering.rs Normal file
View File

@@ -0,0 +1,162 @@
use bevy::ecs::system::RunSystemOnce;
use pomomon_garden::features::config::components::{BerrySeedConfig, GameConfig};
use pomomon_garden::features::grid::components::{Grid, Tile, TileState};
use pomomon_garden::features::inventory::components::ItemType;
use pomomon_garden::features::phase::components::{
CurrentPhase, Phase, SessionTracker, TimerSettings,
};
use pomomon_garden::features::phase::handle_continue;
use pomomon_garden::features::phase::messages::NextPhaseMessage;
use pomomon_garden::prelude::*;
fn setup_withering_app(
grid_width: u32,
grid_height: u32,
initial_tile_states: &[(u32, u32, TileState)],
) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(AssetPlugin::default());
app.init_resource::<TimerSettings>();
app.init_resource::<SessionTracker>();
app.insert_resource(CurrentPhase(Phase::Finished {
completed_phase: Box::new(Phase::Focus { duration: 25.0 }),
}));
app.insert_resource(GameConfig {
berry_seeds: vec![BerrySeedConfig {
name: "TestSeed".into(),
cost: 1,
grants: 1,
slice: "".into(),
growth_stages: 5,
}],
..Default::default()
});
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();
}
}
app.add_message::<NextPhaseMessage>();
app
}
#[test]
fn test_withering_progression() {
let seed_type = ItemType::BerrySeed {
name: "TestSeed".into(),
};
// Start with a fresh plant
let initial_states = vec![(
0,
0,
TileState::Occupied {
seed: seed_type.clone(),
watered: false,
growth_stage: 0,
withered: false,
dry_counter: 0,
},
)];
let mut app = setup_withering_app(1, 1, &initial_states);
// --- Phase 1: Not watered ---
app.world_mut().write_message(NextPhaseMessage);
let _ = app.world_mut().run_system_once(handle_continue);
let t = app.world().resource::<Grid>().get_tile((0, 0)).unwrap();
let s = app.world().entity(t).get::<TileState>().unwrap();
match s {
TileState::Occupied {
withered,
dry_counter,
..
} => {
assert_eq!(
*dry_counter, 1,
"Dry counter should be 1 after 1st dry phase"
);
assert_eq!(*withered, false, "Should not be withered yet");
}
_ => panic!("Invalid state"),
}
// --- Phase 2: Not watered -> Wither ---
// Reset phase to trigger update again
app.insert_resource(CurrentPhase(Phase::Finished {
completed_phase: Box::new(Phase::Focus { duration: 25.0 }),
}));
app.world_mut().write_message(NextPhaseMessage);
let _ = app.world_mut().run_system_once(handle_continue);
let s = app.world().entity(t).get::<TileState>().unwrap();
match s {
TileState::Occupied {
withered,
dry_counter,
..
} => {
assert_eq!(*dry_counter, 2, "Dry counter should be 2");
assert_eq!(*withered, true, "Should be withered now");
}
_ => panic!("Invalid state"),
}
// --- Phase 3: Watered -> Withered persists ---
// Manually water it (simulate user action)
if let Some(mut s_mut) = app.world_mut().get_mut::<TileState>(t) {
match *s_mut {
TileState::Occupied {
ref mut watered, ..
} => *watered = true,
_ => (),
}
}
// Run phase
app.insert_resource(CurrentPhase(Phase::Finished {
completed_phase: Box::new(Phase::Focus { duration: 25.0 }),
}));
app.world_mut().write_message(NextPhaseMessage);
let _ = app.world_mut().run_system_once(handle_continue);
let s = app.world().entity(t).get::<TileState>().unwrap();
match s {
TileState::Occupied {
growth_stage,
withered,
dry_counter,
..
} => {
assert_eq!(*dry_counter, 0, "Watering should reset dry counter");
assert_eq!(*withered, true, "Should still be withered");
assert_eq!(*growth_stage, 0, "Should not grow if withered");
}
_ => panic!("Invalid state"),
}
}