feat: Add achievement tracking and persistence (#42)

This commit is contained in:
demenik
2025-12-11 17:26:19 +01:00
parent 1e7e4e7b95
commit 172f32a4d8
9 changed files with 139 additions and 4 deletions

26
Cargo.lock generated
View File

@@ -210,7 +210,7 @@ dependencies = [
"flate2", "flate2",
"itertools 0.13.0", "itertools 0.13.0",
"nom", "nom",
"strum", "strum 0.26.3",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@@ -3940,6 +3940,8 @@ dependencies = [
"directories", "directories",
"serde", "serde",
"serde_json", "serde_json",
"strum 0.27.2",
"strum_macros 0.27.2",
"tungstenite", "tungstenite",
"url", "url",
"uuid", "uuid",
@@ -4565,9 +4567,15 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [ 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]] [[package]]
name = "strum_macros" name = "strum_macros"
version = "0.26.4" version = "0.26.4"
@@ -4581,6 +4589,18 @@ dependencies = [
"syn", "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]] [[package]]
name = "svg_fmt" name = "svg_fmt"
version = "0.4.5" version = "0.4.5"
@@ -4714,7 +4734,7 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix 1.1.2", "rustix 1.1.2",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View File

@@ -22,6 +22,8 @@ serde_json = "1.0"
directories = "6.0" directories = "6.0"
tungstenite = { version = "0.28.0", features = ["native-tls"] } tungstenite = { version = "0.28.0", features = ["native-tls"] }
url = "2.5.7" url = "2.5.7"
strum = "0.27.2"
strum_macros = "0.27.2"
[dev-dependencies] [dev-dependencies]
uuid = "1.18.1" uuid = "1.18.1"

View File

@@ -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<AchievementId, bool>,
}
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);
}
}

View File

@@ -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::<AchievementProgress>();
app.add_systems(
Update,
check_achievements.run_if(in_state(AppState::GameScreen)),
);
}
}
fn check_achievements(
tracker: Res<SessionTracker>,
mut progress: ResMut<AchievementProgress>,
mut notifications: ResMut<Notifications>,
) {
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());
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod achievement;
pub mod config; pub mod config;
pub mod core; pub mod core;
pub mod game_screen; pub mod game_screen;
@@ -14,6 +15,7 @@ pub mod start_screen;
pub mod ui; pub mod ui;
pub mod wonderevent; pub mod wonderevent;
pub use achievement::AchievementPlugin;
pub use core::CorePlugin; pub use core::CorePlugin;
pub use game_screen::GameScreenPlugin; pub use game_screen::GameScreenPlugin;
pub use grid::GridPlugin; pub use grid::GridPlugin;

View File

@@ -67,4 +67,5 @@ impl Default for TimerSettings {
pub struct SessionTracker { pub struct SessionTracker {
pub completed_focus_phases: u32, pub completed_focus_phases: u32,
pub total_berries_earned: u32, pub total_berries_earned: u32,
pub total_plants_withered: u32,
} }

View File

@@ -213,7 +213,7 @@ pub fn next_phase(
pub fn handle_continue( pub fn handle_continue(
mut messages: MessageReader<NextPhaseMessage>, mut messages: MessageReader<NextPhaseMessage>,
mut phase_res: ResMut<CurrentPhase>, mut phase_res: ResMut<CurrentPhase>,
session_tracker: Res<SessionTracker>, mut session_tracker: ResMut<SessionTracker>,
settings: Res<TimerSettings>, settings: Res<TimerSettings>,
mut tile_query: Query<&mut TileState>, mut tile_query: Query<&mut TileState>,
game_config: Res<GameConfig>, game_config: Res<GameConfig>,
@@ -256,6 +256,10 @@ pub fn handle_continue(
} }
} }
if new_withered && !*withered {
session_tracker.total_plants_withered += 1;
}
*state = TileState::Occupied { *state = TileState::Occupied {
seed: seed.clone(), seed: seed.clone(),
watered: false, watered: false,

View File

@@ -1,3 +1,4 @@
use crate::features::achievement::components::AchievementProgress;
use crate::features::phase::components::{SessionTracker, TimerSettings}; use crate::features::phase::components::{SessionTracker, TimerSettings};
use crate::features::savegame::ui::load_popup_handler; use crate::features::savegame::ui::load_popup_handler;
use crate::prelude::*; use crate::prelude::*;
@@ -34,6 +35,7 @@ struct SaveData {
tiles: Vec<Vec<TileState>>, tiles: Vec<Vec<TileState>>,
current_phase: CurrentPhase, current_phase: CurrentPhase,
session_tracker: SessionTracker, session_tracker: SessionTracker,
achievement_progress: AchievementProgress,
timer_settings: TimerSettings, timer_settings: TimerSettings,
pom_position: GridPosition, pom_position: GridPosition,
inventory: Vec<ItemStack>, inventory: Vec<ItemStack>,
@@ -47,6 +49,7 @@ fn dump_savegame(
tile_query: Query<&TileState>, tile_query: Query<&TileState>,
phase: Res<CurrentPhase>, phase: Res<CurrentPhase>,
tracker: Res<SessionTracker>, tracker: Res<SessionTracker>,
achievement_progress: Res<AchievementProgress>,
settings: Res<TimerSettings>, settings: Res<TimerSettings>,
pom_query: Query<&GridPosition, With<Pom>>, pom_query: Query<&GridPosition, With<Pom>>,
inventory: Res<Inventory>, inventory: Res<Inventory>,
@@ -85,6 +88,7 @@ fn dump_savegame(
tiles: tile_states, tiles: tile_states,
current_phase: phase.clone(), current_phase: phase.clone(),
session_tracker: tracker.clone(), session_tracker: tracker.clone(),
achievement_progress: achievement_progress.clone(),
timer_settings: settings.clone(), timer_settings: settings.clone(),
pom_position: *pom_pos, pom_position: *pom_pos,
inventory: item_stacks, inventory: item_stacks,
@@ -118,6 +122,7 @@ fn load_savegame(
mut tile_query: Query<&mut TileState>, mut tile_query: Query<&mut TileState>,
mut phase: ResMut<CurrentPhase>, mut phase: ResMut<CurrentPhase>,
mut tracker: ResMut<SessionTracker>, mut tracker: ResMut<SessionTracker>,
mut achievement_progress: ResMut<AchievementProgress>,
mut settings: ResMut<TimerSettings>, mut settings: ResMut<TimerSettings>,
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>, mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
@@ -134,6 +139,7 @@ fn load_savegame(
Ok(save_data) => { Ok(save_data) => {
*phase = save_data.current_phase; *phase = save_data.current_phase;
*tracker = save_data.session_tracker; *tracker = save_data.session_tracker;
*achievement_progress = save_data.achievement_progress;
*settings = save_data.timer_settings; *settings = save_data.timer_settings;
if let Ok((mut pom_pos, mut pom_transform)) = pom_query.single_mut() { 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 tile_query: Query<&mut TileState>,
mut phase: ResMut<CurrentPhase>, mut phase: ResMut<CurrentPhase>,
mut tracker: ResMut<SessionTracker>, mut tracker: ResMut<SessionTracker>,
mut achievement_progress: ResMut<AchievementProgress>,
mut settings: ResMut<TimerSettings>, mut settings: ResMut<TimerSettings>,
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>, mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
mut inventory: ResMut<Inventory>, mut inventory: ResMut<Inventory>,
) { ) {
*tracker = SessionTracker::default(); *tracker = SessionTracker::default();
*achievement_progress = AchievementProgress::default();
*settings = TimerSettings::default(); *settings = TimerSettings::default();
*phase = CurrentPhase(Phase::Focus { *phase = CurrentPhase(Phase::Focus {
duration: settings.focus_duration as f32, duration: settings.focus_duration as f32,

View File

@@ -36,6 +36,7 @@ fn main() {
features::ShopPlugin, features::ShopPlugin,
features::WonderEventPlugin, features::WonderEventPlugin,
features::NotificationPlugin, features::NotificationPlugin,
features::AchievementPlugin,
)) ))
.insert_resource(config) .insert_resource(config)
.add_systems(Startup, overwrite_default_font) .add_systems(Startup, overwrite_default_font)