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:
32
.gitlab-ci.yml
Normal file
32
.gitlab-ci.yml
Normal 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
1
Cargo.lock
generated
@@ -3663,6 +3663,7 @@ dependencies = [
|
||||
"directories",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -20,3 +20,6 @@ bevy_dev_tools = "0.17.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
directories = "6.0"
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = "1.18.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Pomomon Garden 
|
||||
|
||||
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/).
|
||||
|
||||
---
|
||||
@@ -40,10 +40,14 @@ cargo run
|
||||
### Hidden binds (Only available in the debug build)
|
||||
|
||||
- `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
|
||||
|
||||
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
BIN
assets/berry.aseprite
Normal file
Binary file not shown.
@@ -1,5 +1,30 @@
|
||||
{
|
||||
"grid_width": 12,
|
||||
"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
BIN
assets/crop.aseprite
Normal file
Binary file not shown.
BIN
assets/fonts/Jersey10-Regular.ttf
Normal file
BIN
assets/fonts/Jersey10-Regular.ttf
Normal file
Binary file not shown.
93
assets/fonts/Jersey10.LICENSE
Normal file
93
assets/fonts/Jersey10.LICENSE
Normal 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
BIN
assets/seed.aseprite
Normal file
Binary file not shown.
@@ -7,6 +7,18 @@ pub struct GameConfig {
|
||||
pub grid_width: u32,
|
||||
pub grid_height: u32,
|
||||
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 {
|
||||
@@ -15,13 +27,42 @@ impl Default for GameConfig {
|
||||
grid_width: 12,
|
||||
grid_height: 4,
|
||||
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 {
|
||||
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);
|
||||
serde_json::from_reader(reader).ok()
|
||||
}
|
||||
|
||||
@@ -7,18 +7,32 @@ pub struct Tile {
|
||||
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 {
|
||||
#[default]
|
||||
Unclaimed,
|
||||
Empty,
|
||||
Occupied,
|
||||
Occupied {
|
||||
seed: ItemType,
|
||||
watered: bool,
|
||||
growth_stage: u32,
|
||||
#[serde(default)]
|
||||
withered: bool,
|
||||
#[serde(default)]
|
||||
dry_counter: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl TileState {
|
||||
pub fn is_blocking(&self) -> bool {
|
||||
match self {
|
||||
TileState::Occupied => true,
|
||||
TileState::Occupied { .. } => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::prelude::*;
|
||||
use components::{CropVisual, WaterVisual};
|
||||
|
||||
pub mod components;
|
||||
pub mod consts;
|
||||
@@ -12,10 +13,7 @@ impl Plugin for GridPlugin {
|
||||
app.add_systems(OnEnter(AppState::GameScreen), setup);
|
||||
app.add_systems(OnExit(AppState::GameScreen), cleanup);
|
||||
|
||||
app.add_systems(
|
||||
Update,
|
||||
update_tile_colors.run_if(in_state(AppState::GameScreen)),
|
||||
);
|
||||
app.add_systems(Update, update_tiles.run_if(in_state(AppState::GameScreen)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +43,30 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<Gam
|
||||
grid_height,
|
||||
)),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent.spawn((
|
||||
CropVisual,
|
||||
AseSlice {
|
||||
name: "Crop".into(),
|
||||
aseprite: asset_server.load("crop.aseprite"),
|
||||
},
|
||||
Sprite::default(),
|
||||
Transform::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();
|
||||
column.push(tile_entity);
|
||||
}
|
||||
@@ -65,22 +87,73 @@ fn cleanup(mut commands: Commands, tile_query: Query<Entity, With<Tile>>) {
|
||||
commands.remove_resource::<Grid>();
|
||||
}
|
||||
|
||||
fn update_tile_colors(
|
||||
mut query: Query<(&TileState, &mut AseSlice)>,
|
||||
fn update_tiles(
|
||||
mut query: Query<(&TileState, &mut AseSlice, &Children), (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>,
|
||||
game_config: Res<GameConfig>,
|
||||
) {
|
||||
for (state, mut slice) in &mut query {
|
||||
for (state, mut slice, children) in &mut query {
|
||||
slice.name = match state {
|
||||
TileState::Unclaimed => "Unclaimed",
|
||||
TileState::Empty => "Empty",
|
||||
TileState::Occupied => "Occupied",
|
||||
TileState::Occupied { .. } => "Occupied",
|
||||
}
|
||||
.into();
|
||||
|
||||
slice.aseprite = match state {
|
||||
TileState::Unclaimed => asset_server.load("tiles/tile-unclaimed.aseprite"),
|
||||
TileState::Empty => asset_server.load("tiles/tile-empty.aseprite"),
|
||||
TileState::Occupied => asset_server.load("tiles/tile-occupied.aseprite"),
|
||||
TileState::Occupied { .. } => asset_server.load("tiles/tile-occupied.aseprite"),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::errors::GridError;
|
||||
use crate::prelude::*;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn world_to_grid_coords(world_pos: Vec3, grid_width: u32, grid_height: u32) -> (u32, u32) {
|
||||
pub fn world_to_grid_coords(
|
||||
world_pos: Vec3,
|
||||
grid_width: u32,
|
||||
grid_height: u32,
|
||||
) -> Result<(u32, u32), GridError> {
|
||||
let start_x = grid_start_x(grid_width);
|
||||
let start_y = grid_start_y(grid_height);
|
||||
|
||||
let x = ((world_pos.x - start_x + TILE_SIZE / 2.0) / TILE_SIZE).floor();
|
||||
let y = ((world_pos.y - start_y + TILE_SIZE / 2.0) / TILE_SIZE).floor();
|
||||
|
||||
let mut x_u32 = x as u32;
|
||||
let mut y_u32 = y as u32;
|
||||
|
||||
if x_u32 >= grid_width {
|
||||
x_u32 = grid_width - 1;
|
||||
}
|
||||
if y_u32 >= grid_height {
|
||||
y_u32 = grid_height - 1;
|
||||
if x >= grid_width as f32 || y >= grid_height as f32 || x < 0.0 || y < 0.0 {
|
||||
return Err(GridError::OutOfBounds {
|
||||
x: x as i32,
|
||||
y: x as i32,
|
||||
});
|
||||
}
|
||||
|
||||
(x_u32, y_u32)
|
||||
Ok((x as u32, y as u32))
|
||||
}
|
||||
|
||||
pub fn grid_to_world_coords(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::features::inventory;
|
||||
use crate::features::phase::components::TimerSettings;
|
||||
use crate::features::savegame::messages::SavegameDumpMessage;
|
||||
use crate::features::{inventory, shop};
|
||||
use crate::prelude::*;
|
||||
use components::*;
|
||||
use ui::*;
|
||||
@@ -41,6 +41,15 @@ fn setup(mut commands: Commands) {
|
||||
children![
|
||||
text_with_component(TextType::Phase, "...", 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(
|
||||
inventory::components::ButtonType::InventoryOpen,
|
||||
ButtonVariant::Secondary,
|
||||
@@ -48,8 +57,7 @@ fn setup(mut commands: Commands) {
|
||||
padding: UiRect::all(px(10)),
|
||||
..default()
|
||||
},
|
||||
"Inventar",
|
||||
16.0
|
||||
|color| text("Inventar", 16.0, color)
|
||||
),
|
||||
button(
|
||||
ButtonType::SettingsOpen,
|
||||
@@ -58,8 +66,7 @@ fn setup(mut commands: Commands) {
|
||||
padding: UiRect::all(px(10)),
|
||||
..default()
|
||||
},
|
||||
"Einstellungen",
|
||||
16.0
|
||||
|color| text("Einstellungen", 16.0, color)
|
||||
)
|
||||
],
|
||||
));
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn open_settings(commands: &mut Commands) {
|
||||
},
|
||||
ZIndex(1),
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
|
||||
GlobalTransform::default(),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent
|
||||
@@ -42,8 +43,7 @@ pub fn open_settings(commands: &mut Commands) {
|
||||
height: px(40),
|
||||
..default()
|
||||
},
|
||||
"X",
|
||||
24.0
|
||||
|color| text("X", 24.0, color)
|
||||
),
|
||||
],
|
||||
));
|
||||
@@ -58,8 +58,7 @@ pub fn open_settings(commands: &mut Commands) {
|
||||
padding: UiRect::all(px(10)),
|
||||
..default()
|
||||
},
|
||||
"Spiel verlassen",
|
||||
24.0,
|
||||
|color| text("Spiel verlassen", 24.0, color)
|
||||
));
|
||||
|
||||
parent.spawn(button(
|
||||
@@ -69,8 +68,7 @@ pub fn open_settings(commands: &mut Commands) {
|
||||
padding: UiRect::all(px(10)),
|
||||
..default()
|
||||
},
|
||||
"Spiel speichern",
|
||||
24.0,
|
||||
|color| text("Spiel speichern", 24.0, color)
|
||||
));
|
||||
|
||||
parent.spawn((
|
||||
|
||||
@@ -29,8 +29,7 @@ fn timer_settings_part(input: SettingsTimerInput, amount: u32) -> impl Bundle {
|
||||
width: percent(100),
|
||||
..default()
|
||||
},
|
||||
"+",
|
||||
12.0
|
||||
|color| text("+", 12.0, color)
|
||||
),
|
||||
text_with_component(input.clone(), "--", 24.0, Color::WHITE),
|
||||
button(
|
||||
@@ -43,8 +42,7 @@ fn timer_settings_part(input: SettingsTimerInput, amount: u32) -> impl Bundle {
|
||||
width: percent(100),
|
||||
..default()
|
||||
},
|
||||
"-",
|
||||
12.0
|
||||
|color| text("-", 12.0, color)
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use crate::features::{
|
||||
input::utils::mouse_to_grid,
|
||||
phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage},
|
||||
pom::messages::InvalidMoveMessage,
|
||||
shop::ui::open_shop,
|
||||
};
|
||||
use crate::prelude::*;
|
||||
use bevy::input::mouse::MouseButton;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
pub mod utils;
|
||||
|
||||
pub struct 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_message::<InteractStartMessage>();
|
||||
app.add_message::<TileClickMessage>();
|
||||
app.add_systems(
|
||||
Update,
|
||||
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_systems(
|
||||
Update,
|
||||
@@ -28,6 +36,8 @@ impl Plugin for InputPlugin {
|
||||
|
||||
app.add_message::<NextPhaseMessage>();
|
||||
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>>,
|
||||
config: Res<GameConfig>,
|
||||
phase: Res<CurrentPhase>,
|
||||
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
|
||||
) {
|
||||
match phase.0 {
|
||||
Phase::Focus { .. } => return,
|
||||
@@ -45,15 +56,9 @@ fn move_click(
|
||||
}
|
||||
|
||||
if mouse_btn.just_pressed(MouseButton::Right) {
|
||||
let (cam, cam_transform) = *camera;
|
||||
|
||||
let Some(cursor_pos) = window.cursor_position() else {
|
||||
let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
|
||||
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);
|
||||
move_messages.write(MoveMessage { x, y });
|
||||
@@ -61,14 +66,41 @@ fn move_click(
|
||||
}
|
||||
|
||||
fn interact_click(
|
||||
mut interact_messages: MessageWriter<InteractStartMessage>,
|
||||
mut tile_click_messages: MessageWriter<TileClickMessage>,
|
||||
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>,
|
||||
// for debug
|
||||
grid: ResMut<Grid>,
|
||||
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
|
||||
) {
|
||||
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>,
|
||||
) {
|
||||
match phase.0 {
|
||||
@@ -77,31 +109,32 @@ fn interact_click(
|
||||
}
|
||||
|
||||
if mouse_btn.just_pressed(MouseButton::Left) {
|
||||
let (cam, cam_transform) = *camera;
|
||||
|
||||
let Some(cursor_pos) = window.cursor_position() else {
|
||||
if !keys.pressed(KeyCode::ShiftLeft) && !keys.pressed(KeyCode::ShiftRight) {
|
||||
return;
|
||||
}
|
||||
let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
|
||||
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);
|
||||
interact_messages.write(InteractStartMessage { x, y });
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
println!("Debug Toggle Click: ({}, {})", x, y);
|
||||
grid.map_tile_state(
|
||||
(x, y),
|
||||
|state| match state {
|
||||
TileState::Unclaimed => TileState::Empty,
|
||||
TileState::Empty => TileState::Occupied,
|
||||
TileState::Occupied => TileState::Unclaimed,
|
||||
TileState::Empty => TileState::Occupied {
|
||||
seed: ItemType::BerrySeed {
|
||||
name: "Debug".into(),
|
||||
},
|
||||
watered: false,
|
||||
growth_stage: 0,
|
||||
withered: false,
|
||||
dry_counter: 0,
|
||||
},
|
||||
TileState::Occupied { .. } => TileState::Unclaimed,
|
||||
},
|
||||
tile_query,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
.unwrap_or_else(|_| ());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,3 +152,14 @@ fn next_phase(mut messages: MessageWriter<NextPhaseMessage>, keys: Res<ButtonInp
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
28
src/features/input/utils.rs
Normal file
28
src/features/input/utils.rs
Normal 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)
|
||||
}
|
||||
@@ -1,30 +1,104 @@
|
||||
use crate::features::config::components::BerrySeedConfig;
|
||||
use crate::prelude::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ItemType {
|
||||
Berry,
|
||||
BerrySeed { name: String },
|
||||
Shovel,
|
||||
}
|
||||
|
||||
impl ItemType {
|
||||
pub fn singular(&self) -> String {
|
||||
pub fn singular(&self, game_config: &GameConfig) -> String {
|
||||
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 {
|
||||
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 {
|
||||
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>,
|
||||
}
|
||||
|
||||
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)]
|
||||
pub enum RootMarker {
|
||||
Inventory,
|
||||
|
||||
@@ -11,6 +11,9 @@ impl Plugin for InventoryPlugin {
|
||||
app.init_resource::<Inventory>();
|
||||
|
||||
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>)>,
|
||||
itemstack_query: Query<&ItemStack>,
|
||||
root_query: Query<(Entity, &RootMarker)>,
|
||||
game_config: Res<GameConfig>,
|
||||
asset_server: Res<AssetServer>,
|
||||
) {
|
||||
for (interaction, button_type) in &mut interaction_query {
|
||||
match *interaction {
|
||||
Interaction::Pressed => match button_type {
|
||||
ButtonType::InventoryOpen => {
|
||||
open_inventory(&mut commands, itemstack_query);
|
||||
open_inventory(&mut commands, itemstack_query, &game_config, &asset_server);
|
||||
}
|
||||
ButtonType::InventoryClose => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use super::super::components::{ButtonType, RootMarker};
|
||||
use crate::prelude::GameConfig;
|
||||
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
|
||||
.spawn((
|
||||
RootMarker::Inventory,
|
||||
@@ -13,6 +19,7 @@ pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) {
|
||||
},
|
||||
ZIndex(1),
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
|
||||
GlobalTransform::default(),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent
|
||||
@@ -42,8 +49,7 @@ pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) {
|
||||
height: px(40),
|
||||
..default()
|
||||
},
|
||||
"X",
|
||||
24.0
|
||||
|color| text("X", 24.0, color)
|
||||
),
|
||||
],
|
||||
));
|
||||
@@ -56,7 +62,7 @@ pub fn open_inventory(commands: &mut Commands, items: Query<&ItemStack>) {
|
||||
})
|
||||
.with_children(|parent| {
|
||||
for itemstack in items.iter() {
|
||||
parent.spawn(list_itemstack(itemstack));
|
||||
parent.spawn(list_itemstack(itemstack, game_config, asset_server));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
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 {
|
||||
1 => itemstack.item_type.singular(),
|
||||
_ => itemstack.item_type.plural(),
|
||||
1 => itemstack.item_type.singular(game_config),
|
||||
_ => itemstack.item_type.plural(game_config),
|
||||
};
|
||||
|
||||
(
|
||||
Node {
|
||||
width: percent(100),
|
||||
padding: UiRect::all(px(4)),
|
||||
..Node::hstack(px(8))
|
||||
},
|
||||
BackgroundColor(ButtonVariant::Secondary.normal_background()),
|
||||
BorderRadius::all(px(10)),
|
||||
children![
|
||||
(
|
||||
// Placeholder for icon
|
||||
Node {
|
||||
height: percent(100),
|
||||
aspect_ratio: Some(1.0),
|
||||
..default()
|
||||
},
|
||||
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 {
|
||||
@@ -36,7 +41,11 @@ pub fn list_itemstack(itemstack: &ItemStack) -> impl Bundle {
|
||||
14.0,
|
||||
Color::WHITE
|
||||
),
|
||||
text(itemstack.item_type.description(), 10.0, Color::WHITE)
|
||||
text(
|
||||
itemstack.item_type.description(game_config),
|
||||
10.0,
|
||||
Color::WHITE
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod inventory;
|
||||
pub mod phase;
|
||||
pub mod pom;
|
||||
pub mod savegame;
|
||||
pub mod shop;
|
||||
pub mod start_screen;
|
||||
pub mod ui;
|
||||
|
||||
@@ -20,5 +21,6 @@ pub use inventory::InventoryPlugin;
|
||||
pub use phase::PhasePlugin;
|
||||
pub use pom::PomPlugin;
|
||||
pub use savegame::SavegamePlugin;
|
||||
pub use shop::ShopPlugin;
|
||||
pub use start_screen::StartScreenPlugin;
|
||||
pub use ui::UiPlugin;
|
||||
|
||||
@@ -119,43 +119,94 @@ fn handle_pause(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_continue(
|
||||
mut messages: MessageReader<NextPhaseMessage>,
|
||||
mut phase_res: ResMut<CurrentPhase>,
|
||||
mut session_tracker: ResMut<SessionTracker>,
|
||||
settings: Res<TimerSettings>,
|
||||
pub fn next_phase(
|
||||
current_phase: &mut CurrentPhase,
|
||||
session_tracker: &mut SessionTracker,
|
||||
settings: &TimerSettings,
|
||||
) {
|
||||
for _ in messages.read() {
|
||||
let phase = &mut phase_res.0;
|
||||
|
||||
if let Phase::Finished { completed_phase } = phase {
|
||||
if let Phase::Finished { completed_phase } = ¤t_phase.0 {
|
||||
match **completed_phase {
|
||||
Phase::Focus { .. } => {
|
||||
session_tracker.completed_focus_phases += 1;
|
||||
|
||||
// TODO: add berry grant logic
|
||||
|
||||
let is_long_break = session_tracker.completed_focus_phases > 0
|
||||
&& session_tracker.completed_focus_phases % settings.long_break_interval
|
||||
== 0;
|
||||
&& session_tracker.completed_focus_phases % settings.long_break_interval == 0;
|
||||
|
||||
if is_long_break {
|
||||
*phase = Phase::Break {
|
||||
current_phase.0 = Phase::Break {
|
||||
duration: settings.long_break_duration as f32,
|
||||
};
|
||||
} else {
|
||||
*phase = Phase::Break {
|
||||
current_phase.0 = Phase::Break {
|
||||
duration: settings.short_break_duration as f32,
|
||||
};
|
||||
}
|
||||
}
|
||||
Phase::Break { .. } => {
|
||||
*phase = Phase::Focus {
|
||||
duration: 25.0 * 60.0,
|
||||
current_phase.0 = Phase::Focus {
|
||||
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
182
src/features/pom/actions.rs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::features::pom::actions::InteractionAction;
|
||||
use crate::prelude::*;
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::features::pom::actions::InteractionAction;
|
||||
use crate::prelude::*;
|
||||
|
||||
#[derive(Message)]
|
||||
@@ -15,4 +16,11 @@ pub struct InvalidMoveMessage {
|
||||
pub struct InteractStartMessage {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub action: InteractionAction,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TileClickMessage {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
}
|
||||
|
||||
@@ -1,32 +1,77 @@
|
||||
use crate::prelude::*;
|
||||
use components::*;
|
||||
use messages::InvalidMoveMessage;
|
||||
use messages::{InteractStartMessage, InvalidMoveMessage};
|
||||
use std::collections::VecDeque;
|
||||
use utils::find_path;
|
||||
use utils::manhattan_distance;
|
||||
|
||||
pub mod actions;
|
||||
pub mod components;
|
||||
pub mod messages;
|
||||
pub mod ui;
|
||||
pub mod utils;
|
||||
|
||||
pub struct PomPlugin;
|
||||
|
||||
impl Plugin for PomPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(ui::PomUiPlugin);
|
||||
app.add_systems(OnEnter(AppState::GameScreen), setup);
|
||||
app.add_systems(OnExit(AppState::GameScreen), cleanup);
|
||||
|
||||
app.add_systems(
|
||||
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>) {
|
||||
commands.spawn((
|
||||
Pom,
|
||||
GridPosition { x: 0, y: 0 },
|
||||
PathQueue::default(),
|
||||
MovingState::default(),
|
||||
InteractionTarget::default(),
|
||||
AseAnimation {
|
||||
aseprite: asset_server.load("pom/pom-sleep.aseprite"),
|
||||
animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop),
|
||||
@@ -53,10 +98,14 @@ fn handle_move(
|
||||
mut invalid_move_messages: MessageWriter<InvalidMoveMessage>,
|
||||
grid: Res<Grid>,
|
||||
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 (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 start = path_queue.steps.front().unwrap_or(&grid_start);
|
||||
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(
|
||||
time: Res<Time>,
|
||||
mut query: Query<(
|
||||
|
||||
149
src/features/pom/ui/context_menu.rs
Normal file
149
src/features/pom/ui/context_menu.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/features/pom/ui/mod.rs
Normal file
22
src/features/pom/ui/mod.rs
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ fn dump_savegame(
|
||||
for y in 0..grid.height {
|
||||
if let Ok(entity) = grid.get_tile((x, y)) {
|
||||
if let Ok(state) = tile_query.get(entity) {
|
||||
col.push(*state);
|
||||
col.push(state.clone());
|
||||
} else {
|
||||
col.push(TileState::Unclaimed);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ fn load_savegame(
|
||||
if x < grid.width && y < grid.height {
|
||||
if let Ok(entity) = grid.get_tile((x, y)) {
|
||||
if let Ok(mut state) = tile_query.get_mut(entity) {
|
||||
*state = save_data.tiles[x as usize][y as usize];
|
||||
*state = save_data.tiles[x as usize][y as usize].clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn spawn_load_popup(commands: &mut Commands) {
|
||||
},
|
||||
ZIndex(1),
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
|
||||
GlobalTransform::default(),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent
|
||||
@@ -46,8 +47,7 @@ pub fn spawn_load_popup(commands: &mut Commands) {
|
||||
height: px(40),
|
||||
..default()
|
||||
},
|
||||
"X",
|
||||
24.0
|
||||
|color| text("X", 24.0, color)
|
||||
)
|
||||
],
|
||||
));
|
||||
@@ -63,24 +63,22 @@ pub fn spawn_load_popup(commands: &mut Commands) {
|
||||
})
|
||||
.with_children(|parent| {
|
||||
for savegame in SavegamePath::list() {
|
||||
parent.spawn((
|
||||
Button,
|
||||
ButtonType::SavegameLoad {
|
||||
savegame_path: savegame.path.clone(),
|
||||
},
|
||||
parent.spawn(
|
||||
button(
|
||||
ButtonType::SavegameLoad { savegame_path: savegame.path.clone() },
|
||||
ButtonVariant::Secondary,
|
||||
Node {
|
||||
width: percent(100),
|
||||
height: px(80),
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: px(10.0),
|
||||
padding: UiRect::horizontal(px(10.0)),
|
||||
padding: UiRect::all(px(10)),
|
||||
..Node::center()
|
||||
},
|
||||
BackgroundColor(ButtonVariant::Secondary.normal_background()),
|
||||
BorderRadius::all(px(10)),
|
||||
children![
|
||||
(
|
||||
|color| (
|
||||
Node {
|
||||
width: percent(100),
|
||||
align_items: AlignItems::Center,
|
||||
..Node::hstack(px(10))
|
||||
},
|
||||
children![(
|
||||
Node {
|
||||
width: percent(100),
|
||||
height: percent(100),
|
||||
@@ -92,7 +90,7 @@ pub fn spawn_load_popup(commands: &mut Commands) {
|
||||
text(
|
||||
format!("Spielstand {}", savegame.index + 1),
|
||||
24.0,
|
||||
Color::WHITE
|
||||
color
|
||||
),
|
||||
text(
|
||||
format!(
|
||||
@@ -115,11 +113,11 @@ pub fn spawn_load_popup(commands: &mut Commands) {
|
||||
height: px(40),
|
||||
..default()
|
||||
},
|
||||
"X",
|
||||
24.0
|
||||
|color| text("X", 24.0, color)
|
||||
)]
|
||||
)
|
||||
),
|
||||
],
|
||||
));
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
74
src/features/shop/components.rs
Normal file
74
src/features/shop/components.rs
Normal 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
54
src/features/shop/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/features/shop/ui/mod.rs
Normal file
5
src/features/shop/ui/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod offer;
|
||||
pub mod shop;
|
||||
|
||||
pub use offer::shop_offer;
|
||||
pub use shop::open_shop;
|
||||
47
src/features/shop/ui/offer.rs
Normal file
47
src/features/shop/ui/offer.rs
Normal 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)
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
64
src/features/shop/ui/shop.rs
Normal file
64
src/features/shop/ui/shop.rs
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -34,8 +34,7 @@ fn setup(mut commands: Commands) {
|
||||
padding: UiRect::all(px(10)),
|
||||
..default()
|
||||
},
|
||||
"Spiel laden",
|
||||
33.0
|
||||
|color| text("Spiel laden", 33.0, color)
|
||||
),
|
||||
button(
|
||||
ButtonType::NewGame,
|
||||
@@ -45,8 +44,7 @@ fn setup(mut commands: Commands) {
|
||||
padding: UiRect::all(px(10)),
|
||||
..default()
|
||||
},
|
||||
"Neues Spiel",
|
||||
33.0,
|
||||
|color| text("Neues Spiel", 33.0, color)
|
||||
),
|
||||
button(
|
||||
ButtonType::Settings,
|
||||
@@ -56,8 +54,7 @@ fn setup(mut commands: Commands) {
|
||||
padding: UiRect::all(px(10)),
|
||||
..default()
|
||||
},
|
||||
"Einstellungen",
|
||||
33.0
|
||||
|color| text("Einstellungen", 33.0, color)
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
@@ -4,6 +4,7 @@ use bevy::{input::mouse::*, picking::hover::HoverMap};
|
||||
pub mod components;
|
||||
pub mod consts;
|
||||
pub mod ui;
|
||||
pub mod utils;
|
||||
|
||||
pub struct UiPlugin;
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use crate::prelude::*;
|
||||
|
||||
pub fn button(
|
||||
pub fn button<C, R>(
|
||||
button_type: impl Component,
|
||||
variant: ButtonVariant,
|
||||
mut node: Node,
|
||||
title: impl Into<String>,
|
||||
font_size: f32,
|
||||
) -> impl Bundle {
|
||||
child: C,
|
||||
) -> impl Bundle
|
||||
where
|
||||
C: FnOnce(Color) -> R,
|
||||
R: Bundle,
|
||||
{
|
||||
node.justify_content = JustifyContent::Center;
|
||||
node.align_items = AlignItems::Center;
|
||||
|
||||
@@ -17,17 +20,20 @@ pub fn button(
|
||||
node,
|
||||
BackgroundColor(variant.normal_background()),
|
||||
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,
|
||||
variant: ButtonVariant,
|
||||
mut node: Node,
|
||||
title: impl Into<String>,
|
||||
font_size: f32,
|
||||
) -> impl Bundle {
|
||||
child: C,
|
||||
) -> impl Bundle
|
||||
where
|
||||
C: FnOnce(Color) -> R,
|
||||
R: Bundle,
|
||||
{
|
||||
node.justify_content = JustifyContent::Center;
|
||||
node.align_items = AlignItems::Center;
|
||||
|
||||
@@ -38,7 +44,7 @@ pub fn pill_button(
|
||||
node,
|
||||
BackgroundColor(variant.normal_background()),
|
||||
BorderRadius::MAX,
|
||||
children![text(title, font_size, variant.text_color())],
|
||||
children![child(variant.text_color())],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
24
src/features/ui/utils.rs
Normal file
24
src/features/ui/utils.rs
Normal 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
|
||||
})
|
||||
}
|
||||
10
src/main.rs
10
src/main.rs
@@ -33,7 +33,17 @@ fn main() {
|
||||
features::SavegamePlugin,
|
||||
features::UiPlugin,
|
||||
features::InventoryPlugin,
|
||||
features::ShopPlugin,
|
||||
))
|
||||
.insert_resource(config)
|
||||
.add_systems(Startup, overwrite_default_font)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ pub use crate::features::{
|
||||
phase::components::{CurrentPhase, Phase},
|
||||
pom::{
|
||||
components::{GridPosition, MovingState, Pom},
|
||||
messages::{InteractStartMessage, MoveMessage},
|
||||
messages::{InteractStartMessage, MoveMessage, TileClickMessage},
|
||||
},
|
||||
savegame::components::SavegamePath,
|
||||
ui::{components::ButtonVariant, consts::*, ui::*},
|
||||
ui::{components::ButtonVariant, consts::*, ui::*, utils::*},
|
||||
};
|
||||
pub use crate::utils::path::get_internal_path;
|
||||
pub use bevy::prelude::*;
|
||||
|
||||
69
tests/common/mod.rs
Normal file
69
tests/common/mod.rs
Normal 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
57
tests/config.rs
Normal 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
167
tests/growth.rs
Normal 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
197
tests/harvest.rs
Normal 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
175
tests/pathfinding.rs
Normal 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
128
tests/planting.rs
Normal 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
110
tests/session.rs
Normal 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
106
tests/watering.rs
Normal 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
162
tests/withering.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user