From 172f32a4d8a4fb23975baa81c6a73155f689a0b9 Mon Sep 17 00:00:00 2001 From: demenik Date: Thu, 11 Dec 2025 17:26:19 +0100 Subject: [PATCH] feat: Add achievement tracking and persistence (#42) --- Cargo.lock | 26 +++++++++-- Cargo.toml | 2 + src/features/achievement/components.rs | 65 ++++++++++++++++++++++++++ src/features/achievement/mod.rs | 32 +++++++++++++ src/features/mod.rs | 2 + src/features/phase/components.rs | 1 + src/features/phase/mod.rs | 6 ++- src/features/savegame/mod.rs | 8 ++++ src/main.rs | 1 + 9 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/features/achievement/components.rs create mode 100644 src/features/achievement/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 0ac9b38..1e88b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,7 +210,7 @@ dependencies = [ "flate2", "itertools 0.13.0", "nom", - "strum", + "strum 0.26.3", "thiserror 1.0.69", ] @@ -3940,6 +3940,8 @@ dependencies = [ "directories", "serde", "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", "tungstenite", "url", "uuid", @@ -4565,9 +4567,15 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + [[package]] name = "strum_macros" version = "0.26.4" @@ -4581,6 +4589,18 @@ dependencies = [ "syn", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4714,7 +4734,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bdee263..2dedd2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ serde_json = "1.0" directories = "6.0" tungstenite = { version = "0.28.0", features = ["native-tls"] } url = "2.5.7" +strum = "0.27.2" +strum_macros = "0.27.2" [dev-dependencies] uuid = "1.18.1" diff --git a/src/features/achievement/components.rs b/src/features/achievement/components.rs new file mode 100644 index 0000000..788daf8 --- /dev/null +++ b/src/features/achievement/components.rs @@ -0,0 +1,65 @@ +use crate::{features::phase::components::SessionTracker, prelude::*}; +use std::collections::HashMap; +use strum_macros::EnumIter; + +/// Represents an unlockable achievement. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, EnumIter)] +pub enum AchievementId { + // Berry achievements + FirstSteps, + MasterFarmer, + BerryTycoon, + // Focus achievements, + GettingStarted, + FocusMaster, + ZenMaster, + // Withered achievements + Negligent, + CompostKing, +} + +impl AchievementId { + /// Label to be displayed ingame + pub fn label(&self) -> String { + match self { + AchievementId::FirstSteps => "Erste Schritte: Verdiene eine Beere.", + AchievementId::MasterFarmer => "Meisterbauer: Verdiene 100 Beeren.", + AchievementId::BerryTycoon => "Beeren-Tycoon: Verdiene 1.000 Beeren.", + AchievementId::GettingStarted => "Aller Anfang: Schließe deine erste Fokus-Phase ab.", + AchievementId::FocusMaster => "Fokus-Meister: Schließe 10 Fokus-Phasen ab.", + AchievementId::ZenMaster => "Zen-Meister: Schließe 50 Fokus-Phasen ab.", + AchievementId::Negligent => "Nachlässig: Lasse eine Pflanze verdorren.", + AchievementId::CompostKing => "Kompost-König: Lasse 10 Pflanzen verdorren.", + } + .into() + } + + /// Checks if an achievement's conditions are met + pub fn conditions_met(&self, tracker: &SessionTracker) -> bool { + match self { + AchievementId::FirstSteps => tracker.total_berries_earned >= 1, + AchievementId::MasterFarmer => tracker.total_berries_earned >= 100, + AchievementId::BerryTycoon => tracker.total_berries_earned >= 1000, + AchievementId::GettingStarted => tracker.completed_focus_phases >= 1, + AchievementId::FocusMaster => tracker.completed_focus_phases >= 10, + AchievementId::ZenMaster => tracker.completed_focus_phases >= 50, + AchievementId::Negligent => tracker.total_plants_withered >= 1, + AchievementId::CompostKing => tracker.total_plants_withered >= 10, + } + } +} + +#[derive(Resource, Default, Debug, Serialize, Deserialize, Clone)] +pub struct AchievementProgress { + pub unlocked: HashMap, +} + +impl AchievementProgress { + pub fn is_unlocked(&self, id: &AchievementId) -> bool { + *self.unlocked.get(id).unwrap_or(&false) + } + + pub fn unlock(&mut self, id: AchievementId) { + self.unlocked.insert(id, true); + } +} diff --git a/src/features/achievement/mod.rs b/src/features/achievement/mod.rs new file mode 100644 index 0000000..eaff214 --- /dev/null +++ b/src/features/achievement/mod.rs @@ -0,0 +1,32 @@ +use crate::features::notification::components::Notifications; +use crate::features::phase::components::SessionTracker; +use crate::prelude::*; +use components::{AchievementId, AchievementProgress}; +use strum::IntoEnumIterator; + +pub mod components; + +pub struct AchievementPlugin; + +impl Plugin for AchievementPlugin { + fn build(&self, app: &mut App) { + app.init_resource::(); + app.add_systems( + Update, + check_achievements.run_if(in_state(AppState::GameScreen)), + ); + } +} + +fn check_achievements( + tracker: Res, + mut progress: ResMut, + mut notifications: ResMut, +) { + for achievement in AchievementId::iter() { + if !progress.is_unlocked(&achievement) && achievement.conditions_met(&tracker) { + progress.unlock(achievement.clone()); + notifications.info(Some("Erfolg freigeschaltet!"), achievement.label()); + } + } +} diff --git a/src/features/mod.rs b/src/features/mod.rs index 6b6c2b8..a47c7ad 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,3 +1,4 @@ +pub mod achievement; pub mod config; pub mod core; pub mod game_screen; @@ -14,6 +15,7 @@ pub mod start_screen; pub mod ui; pub mod wonderevent; +pub use achievement::AchievementPlugin; pub use core::CorePlugin; pub use game_screen::GameScreenPlugin; pub use grid::GridPlugin; diff --git a/src/features/phase/components.rs b/src/features/phase/components.rs index 7dc430d..e41947d 100644 --- a/src/features/phase/components.rs +++ b/src/features/phase/components.rs @@ -67,4 +67,5 @@ impl Default for TimerSettings { pub struct SessionTracker { pub completed_focus_phases: u32, pub total_berries_earned: u32, + pub total_plants_withered: u32, } diff --git a/src/features/phase/mod.rs b/src/features/phase/mod.rs index baf6397..0bb49e2 100644 --- a/src/features/phase/mod.rs +++ b/src/features/phase/mod.rs @@ -213,7 +213,7 @@ pub fn next_phase( pub fn handle_continue( mut messages: MessageReader, mut phase_res: ResMut, - session_tracker: Res, + mut session_tracker: ResMut, settings: Res, mut tile_query: Query<&mut TileState>, game_config: Res, @@ -256,6 +256,10 @@ pub fn handle_continue( } } + if new_withered && !*withered { + session_tracker.total_plants_withered += 1; + } + *state = TileState::Occupied { seed: seed.clone(), watered: false, diff --git a/src/features/savegame/mod.rs b/src/features/savegame/mod.rs index ca6441b..4584e23 100644 --- a/src/features/savegame/mod.rs +++ b/src/features/savegame/mod.rs @@ -1,3 +1,4 @@ +use crate::features::achievement::components::AchievementProgress; use crate::features::phase::components::{SessionTracker, TimerSettings}; use crate::features::savegame::ui::load_popup_handler; use crate::prelude::*; @@ -34,6 +35,7 @@ struct SaveData { tiles: Vec>, current_phase: CurrentPhase, session_tracker: SessionTracker, + achievement_progress: AchievementProgress, timer_settings: TimerSettings, pom_position: GridPosition, inventory: Vec, @@ -47,6 +49,7 @@ fn dump_savegame( tile_query: Query<&TileState>, phase: Res, tracker: Res, + achievement_progress: Res, settings: Res, pom_query: Query<&GridPosition, With>, inventory: Res, @@ -85,6 +88,7 @@ fn dump_savegame( tiles: tile_states, current_phase: phase.clone(), session_tracker: tracker.clone(), + achievement_progress: achievement_progress.clone(), timer_settings: settings.clone(), pom_position: *pom_pos, inventory: item_stacks, @@ -118,6 +122,7 @@ fn load_savegame( mut tile_query: Query<&mut TileState>, mut phase: ResMut, mut tracker: ResMut, + mut achievement_progress: ResMut, mut settings: ResMut, mut pom_query: Query<(&mut GridPosition, &mut Transform), With>, mut inventory: ResMut, @@ -134,6 +139,7 @@ fn load_savegame( Ok(save_data) => { *phase = save_data.current_phase; *tracker = save_data.session_tracker; + *achievement_progress = save_data.achievement_progress; *settings = save_data.timer_settings; if let Ok((mut pom_pos, mut pom_transform)) = pom_query.single_mut() { @@ -185,11 +191,13 @@ fn reset_savegame( mut tile_query: Query<&mut TileState>, mut phase: ResMut, mut tracker: ResMut, + mut achievement_progress: ResMut, mut settings: ResMut, mut pom_query: Query<(&mut GridPosition, &mut Transform), With>, mut inventory: ResMut, ) { *tracker = SessionTracker::default(); + *achievement_progress = AchievementProgress::default(); *settings = TimerSettings::default(); *phase = CurrentPhase(Phase::Focus { duration: settings.focus_duration as f32, diff --git a/src/main.rs b/src/main.rs index 01c3643..af08cac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ fn main() { features::ShopPlugin, features::WonderEventPlugin, features::NotificationPlugin, + features::AchievementPlugin, )) .insert_resource(config) .add_systems(Startup, overwrite_default_font)