Compare commits

...

10 Commits

Author SHA1 Message Date
Moritz Peter Maile
cfa88ffa43 Merge branch 'dev' into 'main'
Merge Sprint 4

Closes #42

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!47
2025-12-12 22:41:25 +00:00
demenik
337444b19d fix: Remove debug features from release build 2025-12-12 14:44:37 +01:00
Dominik Bernroider
93946696e3 Merge branch '47-achievement-menu-ui-implementation' into 'dev'
Achievement Menu UI

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!46
2025-12-11 19:30:20 +00:00
demenik
526a4657ae fix: Make save game delete button not rounded 2025-12-11 20:24:29 +01:00
demenik
01067d4770 fix: Change order of buttons in load save game menu 2025-12-11 20:22:31 +01:00
demenik
2857f59a85 feat: Show achievement progress in savegame load menu (#47) 2025-12-11 20:21:46 +01:00
demenik
52dd300ca0 feat: Add achievements button to HUD settings (#47) 2025-12-11 20:21:02 +01:00
demenik
8c9a27f0df feat: Implement achievement menu UI (#47) 2025-12-11 20:20:30 +01:00
demenik
d58b23c1b1 feat: Add title and description methods to AchievementId (#47) 2025-12-11 20:19:00 +01:00
demenik
e8af0add0b feat: Add achievement sprite (#47) 2025-12-11 19:52:53 +01:00
13 changed files with 212 additions and 43 deletions

BIN
assets/achievement.aseprite Normal file

Binary file not shown.

View File

@@ -19,21 +19,41 @@ pub enum AchievementId {
} }
impl AchievementId { impl AchievementId {
/// Label to be displayed ingame /// Title to be displayed ingame
pub fn label(&self) -> String { pub fn title(&self) -> String {
match self { match self {
AchievementId::FirstSteps => "Erste Schritte: Verdiene eine Beere.", AchievementId::FirstSteps => "Erste Schritte",
AchievementId::MasterFarmer => "Meisterbauer: Verdiene 100 Beeren.", AchievementId::MasterFarmer => "Meisterbauer",
AchievementId::BerryTycoon => "Beeren-Tycoon: Verdiene 1.000 Beeren.", AchievementId::BerryTycoon => "Beeren-Tycoon",
AchievementId::GettingStarted => "Aller Anfang: Schließe deine erste Fokus-Phase ab.", AchievementId::GettingStarted => "Aller Anfang",
AchievementId::FocusMaster => "Fokus-Meister: Schließe 10 Fokus-Phasen ab.", AchievementId::FocusMaster => "Fokus-Meister",
AchievementId::ZenMaster => "Zen-Meister: Schließe 50 Fokus-Phasen ab.", AchievementId::ZenMaster => "Zen-Meister",
AchievementId::Negligent => "Nachlässig: Lasse eine Pflanze verdorren.", AchievementId::Negligent => "Nachlässig",
AchievementId::CompostKing => "Kompost-König: Lasse 10 Pflanzen verdorren.", AchievementId::CompostKing => "Kompost-König",
} }
.into() .into()
} }
/// Description to be displayed ingame
pub fn description(&self) -> String {
match self {
AchievementId::FirstSteps => "Verdiene eine Beere.",
AchievementId::MasterFarmer => "Verdiene 100 Beeren.",
AchievementId::BerryTycoon => "Verdiene 1.000 Beeren.",
AchievementId::GettingStarted => "Schließe deine erste Fokus-Phase ab.",
AchievementId::FocusMaster => "Schließe 10 Fokus-Phasen ab.",
AchievementId::ZenMaster => "Schließe 50 Fokus-Phasen ab.",
AchievementId::Negligent => "Lasse eine Pflanze verdorren.",
AchievementId::CompostKing => "Lasse 10 Pflanzen verdorren.",
}
.into()
}
/// Label to be displayed ingame (Title: Description)
pub fn label(&self) -> String {
format!("{}: {}", self.title(), self.description())
}
/// Checks if an achievement's conditions are met /// Checks if an achievement's conditions are met
pub fn conditions_met(&self, tracker: &SessionTracker) -> bool { pub fn conditions_met(&self, tracker: &SessionTracker) -> bool {
match self { match self {

View File

@@ -5,6 +5,7 @@ use components::{AchievementId, AchievementProgress};
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
pub mod components; pub mod components;
pub mod ui;
pub struct AchievementPlugin; pub struct AchievementPlugin;

View File

@@ -0,0 +1,90 @@
use crate::features::achievement::components::{AchievementId, AchievementProgress};
use crate::features::ui::ui::popups::spawn_popup;
use crate::prelude::*;
use strum::IntoEnumIterator;
#[derive(Component)]
pub enum AchievementRootMarker {
Menu,
}
pub fn open_achievements_menu(commands: &mut Commands, progress: &AchievementProgress) {
spawn_popup(
commands,
AchievementRootMarker::Menu,
"Erfolge",
Node {
width: px(700),
height: px(500),
..default()
},
|parent| {
// Scrollable Content
parent
.spawn((Node {
width: percent(100),
height: percent(100),
flex_direction: FlexDirection::Column,
overflow: Overflow::scroll_y(),
padding: UiRect::all(px(10)),
row_gap: px(10),
..default()
},))
.with_children(|list| {
for id in AchievementId::iter() {
let unlocked = progress.is_unlocked(&id);
let color = if unlocked {
Color::WHITE
} else {
Color::srgb(0.5, 0.5, 0.5)
};
let bg_color = if unlocked {
Color::srgb(0.2, 0.3, 0.2) // Dark Greenish for unlocked
} else {
Color::srgb(0.1, 0.1, 0.1) // Dark Grey for locked
};
list.spawn((
Node {
width: percent(100),
padding: UiRect::all(px(10)),
flex_direction: FlexDirection::Column,
row_gap: px(5),
border: UiRect::all(px(2)),
..default()
},
BackgroundColor(bg_color),
BorderColor::all(if unlocked {
Color::srgb(0.4, 0.8, 0.4)
} else {
Color::BLACK
}),
BorderRadius::all(px(5)),
))
.with_children(|item| {
// Title
item.spawn(text(id.title(), 20.0, color));
// Description
item.spawn(text(id.description(), 16.0, color));
// Status Text
let status = if unlocked {
"Freigeschaltet"
} else {
"Gesperrt"
};
item.spawn(text(
status,
12.0,
if unlocked {
Color::srgb(0.6, 1.0, 0.6)
} else {
Color::srgb(0.7, 0.3, 0.3)
},
));
});
}
});
},
);
}

View File

@@ -21,6 +21,7 @@ pub enum ButtonType {
SettingsOpen, SettingsOpen,
SettingsExit, SettingsExit,
SettingsSave, SettingsSave,
SettingsAchievements,
SettingsTimerChange { SettingsTimerChange {
input: SettingsTimerInput, input: SettingsTimerInput,
amount: i32, amount: i32,

View File

@@ -1,3 +1,4 @@
use crate::features::achievement::{components::AchievementProgress, ui::open_achievements_menu};
use crate::features::phase::components::TimerSettings; use crate::features::phase::components::TimerSettings;
use crate::features::savegame::messages::SavegameDumpMessage; use crate::features::savegame::messages::SavegameDumpMessage;
use crate::features::{inventory, shop}; use crate::features::{inventory, shop};
@@ -151,6 +152,8 @@ fn buttons(
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
mut timer_settings: ResMut<TimerSettings>, mut timer_settings: ResMut<TimerSettings>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
achievement_progress: Res<AchievementProgress>,
root_query: Query<(Entity, &RootMarker)>,
) { ) {
let shift_multiplier = if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) { let shift_multiplier = if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
10 10
@@ -171,6 +174,14 @@ fn buttons(
ButtonType::SettingsSave => { ButtonType::SettingsSave => {
savegame_messages.write(SavegameDumpMessage); savegame_messages.write(SavegameDumpMessage);
} }
ButtonType::SettingsAchievements => {
open_achievements_menu(&mut commands, &achievement_progress);
for (entity, root) in root_query.iter() {
if let RootMarker::Settings = root {
commands.entity(entity).despawn();
}
}
}
ButtonType::SettingsTimerChange { input, amount } => match input { ButtonType::SettingsTimerChange { input, amount } => match input {
SettingsTimerInput::Minutes(timer_type) => { SettingsTimerInput::Minutes(timer_type) => {
timer_settings.change(timer_type, 60 * amount * shift_multiplier) timer_settings.change(timer_type, 60 * amount * shift_multiplier)

View File

@@ -27,6 +27,12 @@ pub fn open_settings(commands: &mut Commands) {
ButtonVariant::Secondary, ButtonVariant::Secondary,
Node::from_padding(UiRect::all(px(10))), Node::from_padding(UiRect::all(px(10))),
|color| text("Spiel speichern", 24.0, color) |color| text("Spiel speichern", 24.0, color)
),
button(
ButtonType::SettingsAchievements,
ButtonVariant::Secondary,
Node::from_padding(UiRect::all(px(10))),
|color| text("Erfolge", 24.0, color)
),( ),(
Node { Node {
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,

View File

@@ -42,7 +42,10 @@ impl Plugin for InputPlugin {
app.add_systems(Update, next_phase.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, next_phase.run_if(in_state(AppState::GameScreen)));
app.add_systems(Update, shop_keybind.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, shop_keybind.run_if(in_state(AppState::GameScreen)));
app.add_systems(Update, inventory_keybind.run_if(in_state(AppState::GameScreen))); app.add_systems(
Update,
inventory_keybind.run_if(in_state(AppState::GameScreen)),
);
app.add_message::<ClosePopupMessage>(); app.add_message::<ClosePopupMessage>();
app.add_systems(Update, popup_keybind); app.add_systems(Update, popup_keybind);
@@ -181,6 +184,7 @@ fn interact_click(
} }
} }
/// Handles debug interactions (shift + left click). /// Handles debug interactions (shift + left click).
#[cfg(debug_assertions)]
fn debug_click( fn debug_click(
mouse_btn: Res<ButtonInput<MouseButton>>, mouse_btn: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,

View File

@@ -1,3 +1,4 @@
#[cfg(debug_assertions)]
use crate::features::phase::components::SessionTracker; use crate::features::phase::components::SessionTracker;
use crate::{features::inventory::ui::open_inventory, prelude::*}; use crate::{features::inventory::ui::open_inventory, prelude::*};
use components::*; use components::*;

View File

@@ -1,3 +1,4 @@
use crate::features::achievement::components::AchievementProgress;
use crate::prelude::*; use crate::prelude::*;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -7,18 +8,20 @@ use std::path::PathBuf;
pub struct SavegamePath(pub PathBuf); pub struct SavegamePath(pub PathBuf);
/// Metadata about a savegame. /// Metadata about a savegame.
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct SavegameInfo { pub struct SavegameInfo {
pub path: SavegamePath, pub path: SavegamePath,
pub index: u32, pub index: u32,
pub total_berries: u32, pub total_berries: u32,
pub completed_focus: u32, pub completed_focus: u32,
pub achievement_progress: AchievementProgress,
} }
/// Helper for partial JSON deserialization. /// Helper for partial JSON deserialization.
#[derive(Deserialize)] #[derive(Deserialize)]
struct PartialSaveData { struct PartialSaveData {
session_tracker: PartialSessionTracker, session_tracker: PartialSessionTracker,
achievement_progress: AchievementProgress,
} }
/// Helper for partial JSON deserialization of session stats. /// Helper for partial JSON deserialization of session stats.
@@ -87,6 +90,7 @@ impl SavegamePath {
index, index,
total_berries: data.session_tracker.total_berries_earned, total_berries: data.session_tracker.total_berries_earned,
completed_focus: data.session_tracker.completed_focus_phases, completed_focus: data.session_tracker.completed_focus_phases,
achievement_progress: data.achievement_progress,
}); });
} }
@@ -113,4 +117,5 @@ pub enum RootMarker {
pub enum ButtonType { pub enum ButtonType {
SavegameLoad { savegame_path: SavegamePath }, SavegameLoad { savegame_path: SavegamePath },
SavegameDelete { savegame_path: SavegamePath }, SavegameDelete { savegame_path: SavegamePath },
Achievements { savegame: SavegameInfo },
} }

View File

@@ -1,8 +1,9 @@
use super::super::components::{ButtonType, RootMarker}; use super::super::components::{ButtonType, RootMarker};
use crate::features::achievement::ui::open_achievements_menu;
use crate::{features::savegame::messages::SavegameLoadMessage, prelude::*}; use crate::{features::savegame::messages::SavegameLoadMessage, prelude::*};
/// Spawns the "Load Game" popup. /// Spawns the "Load Game" popup.
pub fn spawn_load_popup(commands: &mut Commands) { pub fn spawn_load_popup(commands: &mut Commands, asset_server: &AssetServer) {
spawn_popup( spawn_popup(
commands, commands,
RootMarker::PopupSavegameLoad, RootMarker::PopupSavegameLoad,
@@ -67,7 +68,26 @@ pub fn spawn_load_popup(commands: &mut Commands) {
), ),
] ]
), ),
pill_button( button(
ButtonType::Achievements {
savegame: savegame.clone()
},
ButtonVariant::Primary,
Node {
width: px(40),
height: px(40),
..default()
},
|_| (
ImageNode::default(),
AseSlice {
aseprite: asset_server
.load("achievement.aseprite"),
name: "Achievement".into()
}
)
),
button(
ButtonType::SavegameDelete { ButtonType::SavegameDelete {
savegame_path: savegame.path.clone() savegame_path: savegame.path.clone()
}, },
@@ -111,6 +131,9 @@ pub fn load_popup_handler(
println!("Error while deleting savegame: {:?}", e); println!("Error while deleting savegame: {:?}", e);
} }
} }
ButtonType::Achievements { savegame } => {
open_achievements_menu(&mut commands, &savegame.achievement_progress);
}
}; };
for (entity, root) in root_query.iter() { for (entity, root) in root_query.iter() {

View File

@@ -80,12 +80,13 @@ fn menu(
mut commands: Commands, mut commands: Commands,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>, mut interaction_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
asset_server: Res<AssetServer>,
) { ) {
for (interaction, button_type) in &mut interaction_query { for (interaction, button_type) in &mut interaction_query {
match *interaction { match *interaction {
Interaction::Pressed => match button_type { Interaction::Pressed => match button_type {
ButtonType::LoadGame => { ButtonType::LoadGame => {
spawn_load_popup(&mut commands); spawn_load_popup(&mut commands, &asset_server);
} }
ButtonType::NewGame => { ButtonType::NewGame => {
commands.insert_resource(SavegamePath::next()); commands.insert_resource(SavegamePath::next());

View File

@@ -1,15 +1,19 @@
use bevy_dev_tools::fps_overlay::*;
use pomomon_garden::prelude::*; use pomomon_garden::prelude::*;
fn main() { fn main() {
let config = GameConfig::read_config().unwrap_or(GameConfig::default()); let config = GameConfig::read_config().unwrap_or(GameConfig::default());
App::new() let mut app = App::new();
.add_plugins((
app.add_plugins((
DefaultPlugins.set(ImagePlugin::default_nearest()), DefaultPlugins.set(ImagePlugin::default_nearest()),
AsepriteUltraPlugin, AsepriteUltraPlugin,
)) ));
.add_plugins((FpsOverlayPlugin {
#[cfg(debug_assertions)]
{
use bevy_dev_tools::fps_overlay::*;
app.add_plugins(FpsOverlayPlugin {
config: FpsOverlayConfig { config: FpsOverlayConfig {
refresh_interval: core::time::Duration::from_millis(100), refresh_interval: core::time::Duration::from_millis(100),
enabled: true, enabled: true,
@@ -20,8 +24,10 @@ fn main() {
}, },
..default() ..default()
}, },
},)) });
.add_plugins(( }
app.add_plugins((
features::CorePlugin, features::CorePlugin,
features::StartScreenPlugin, features::StartScreenPlugin,
features::GameScreenPlugin, features::GameScreenPlugin,
@@ -37,10 +43,10 @@ fn main() {
features::WonderEventPlugin, features::WonderEventPlugin,
features::NotificationPlugin, features::NotificationPlugin,
features::AchievementPlugin, features::AchievementPlugin,
)) ));
.insert_resource(config) app.insert_resource(config);
.add_systems(Startup, overwrite_default_font) app.add_systems(Startup, overwrite_default_font);
.run(); app.run();
} }
fn overwrite_default_font(mut fonts: ResMut<Assets<Font>>) { fn overwrite_default_font(mut fonts: ResMut<Assets<Font>>) {