Merge branch '42-achievement-data-persistence' into 'dev'
Add achievement tracking and persistence See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!44
This commit is contained in:
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
65
src/features/achievement/components.rs
Normal file
65
src/features/achievement/components.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/features/achievement/mod.rs
Normal file
32
src/features/achievement/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ pub fn next_phase(
|
||||
pub fn handle_continue(
|
||||
mut messages: MessageReader<NextPhaseMessage>,
|
||||
mut phase_res: ResMut<CurrentPhase>,
|
||||
session_tracker: Res<SessionTracker>,
|
||||
mut session_tracker: ResMut<SessionTracker>,
|
||||
settings: Res<TimerSettings>,
|
||||
mut tile_query: Query<&mut TileState>,
|
||||
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 {
|
||||
seed: seed.clone(),
|
||||
watered: false,
|
||||
|
||||
@@ -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<Vec<TileState>>,
|
||||
current_phase: CurrentPhase,
|
||||
session_tracker: SessionTracker,
|
||||
achievement_progress: AchievementProgress,
|
||||
timer_settings: TimerSettings,
|
||||
pom_position: GridPosition,
|
||||
inventory: Vec<ItemStack>,
|
||||
@@ -47,6 +49,7 @@ fn dump_savegame(
|
||||
tile_query: Query<&TileState>,
|
||||
phase: Res<CurrentPhase>,
|
||||
tracker: Res<SessionTracker>,
|
||||
achievement_progress: Res<AchievementProgress>,
|
||||
settings: Res<TimerSettings>,
|
||||
pom_query: Query<&GridPosition, With<Pom>>,
|
||||
inventory: Res<Inventory>,
|
||||
@@ -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<CurrentPhase>,
|
||||
mut tracker: ResMut<SessionTracker>,
|
||||
mut achievement_progress: ResMut<AchievementProgress>,
|
||||
mut settings: ResMut<TimerSettings>,
|
||||
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
|
||||
mut inventory: ResMut<Inventory>,
|
||||
@@ -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<CurrentPhase>,
|
||||
mut tracker: ResMut<SessionTracker>,
|
||||
mut achievement_progress: ResMut<AchievementProgress>,
|
||||
mut settings: ResMut<TimerSettings>,
|
||||
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
|
||||
mut inventory: ResMut<Inventory>,
|
||||
) {
|
||||
*tracker = SessionTracker::default();
|
||||
*achievement_progress = AchievementProgress::default();
|
||||
*settings = TimerSettings::default();
|
||||
*phase = CurrentPhase(Phase::Focus {
|
||||
duration: settings.focus_duration as f32,
|
||||
|
||||
@@ -36,6 +36,7 @@ fn main() {
|
||||
features::ShopPlugin,
|
||||
features::WonderEventPlugin,
|
||||
features::NotificationPlugin,
|
||||
features::AchievementPlugin,
|
||||
))
|
||||
.insert_resource(config)
|
||||
.add_systems(Startup, overwrite_default_font)
|
||||
|
||||
75
tests/achievement.rs
Normal file
75
tests/achievement.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use pomomon_garden::features::achievement::AchievementPlugin;
|
||||
use pomomon_garden::features::achievement::components::{AchievementId, AchievementProgress};
|
||||
use pomomon_garden::features::notification::components::Notifications;
|
||||
use pomomon_garden::features::phase::components::SessionTracker;
|
||||
use pomomon_garden::prelude::*;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
#[test]
|
||||
fn test_achievement_unlock_logic() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_plugins(AchievementPlugin);
|
||||
app.insert_resource(Notifications::default()); // Needs to be present because check_achievements uses it
|
||||
|
||||
// Helper to run systems and get progress
|
||||
let mut run_check = |completed_focus: u32, total_berries: u32, total_withered: u32| {
|
||||
app.insert_resource(SessionTracker {
|
||||
completed_focus_phases: completed_focus,
|
||||
total_berries_earned: total_berries,
|
||||
total_plants_withered: total_withered,
|
||||
});
|
||||
app.update(); // Runs check_achievements
|
||||
app.world().resource::<AchievementProgress>().clone()
|
||||
};
|
||||
|
||||
// --- Initial State ---
|
||||
let progress = run_check(0, 0, 0);
|
||||
for id in AchievementId::iter() {
|
||||
assert!(
|
||||
!progress.is_unlocked(&id),
|
||||
"Achievement {:?} should not be unlocked initially",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// --- First Steps (1 berry) ---
|
||||
let progress = run_check(0, 1, 0);
|
||||
assert!(progress.is_unlocked(&AchievementId::FirstSteps));
|
||||
|
||||
// --- Getting Started (1 focus phase) ---
|
||||
let progress = run_check(1, 1, 0);
|
||||
assert!(progress.is_unlocked(&AchievementId::GettingStarted));
|
||||
|
||||
// --- Negligent (1 withered plant) ---
|
||||
let progress = run_check(1, 1, 1);
|
||||
assert!(progress.is_unlocked(&AchievementId::Negligent));
|
||||
|
||||
// --- Focus Master (10 focus phases) ---
|
||||
let progress = run_check(10, 1, 1);
|
||||
assert!(progress.is_unlocked(&AchievementId::FocusMaster));
|
||||
|
||||
// --- Master Farmer (100 berries) ---
|
||||
let progress = run_check(10, 100, 1);
|
||||
assert!(progress.is_unlocked(&AchievementId::MasterFarmer));
|
||||
|
||||
// --- Zen Master (50 focus phases) ---
|
||||
let progress = run_check(50, 100, 1);
|
||||
assert!(progress.is_unlocked(&AchievementId::ZenMaster));
|
||||
|
||||
// --- Compost King (10 withered plants) ---
|
||||
let progress = run_check(50, 100, 10);
|
||||
assert!(progress.is_unlocked(&AchievementId::CompostKing));
|
||||
|
||||
// --- Berry Tycoon (1000 berries) ---
|
||||
let progress = run_check(50, 1000, 10);
|
||||
assert!(progress.is_unlocked(&AchievementId::BerryTycoon));
|
||||
|
||||
// --- Test idempotency: already unlocked should stay unlocked ---
|
||||
let initial_progress = progress.clone();
|
||||
let progress = run_check(50, 1000, 10);
|
||||
assert_eq!(progress.unlocked.len(), initial_progress.unlocked.len()); // Same number unlocked
|
||||
for id in AchievementId::iter() {
|
||||
assert_eq!(progress.is_unlocked(&id), initial_progress.is_unlocked(&id));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ fn test_session_tracker_focus_to_short_break() {
|
||||
let session_tracker = SessionTracker {
|
||||
completed_focus_phases: 1,
|
||||
total_berries_earned: 0,
|
||||
total_plants_withered: 0,
|
||||
};
|
||||
|
||||
next_phase(&mut current_phase, &session_tracker, &timer_settings);
|
||||
@@ -45,6 +46,7 @@ fn test_session_tracker_focus_to_long_break() {
|
||||
let session_tracker = SessionTracker {
|
||||
completed_focus_phases: timer_settings.long_break_interval,
|
||||
total_berries_earned: 0,
|
||||
total_plants_withered: 0,
|
||||
};
|
||||
|
||||
next_phase(&mut current_phase, &session_tracker, &timer_settings);
|
||||
@@ -73,6 +75,7 @@ fn test_session_tracker_break_to_focus() {
|
||||
let session_tracker = SessionTracker {
|
||||
completed_focus_phases: 1,
|
||||
total_berries_earned: 0,
|
||||
total_plants_withered: 0,
|
||||
}; // Arbitrary value, should not change
|
||||
let timer_settings = TimerSettings::default();
|
||||
|
||||
@@ -99,6 +102,7 @@ fn test_session_tracker_not_finished_phase_no_change() {
|
||||
let session_tracker = SessionTracker {
|
||||
completed_focus_phases: 0,
|
||||
total_berries_earned: 0,
|
||||
total_plants_withered: 0,
|
||||
};
|
||||
let timer_settings = TimerSettings::default();
|
||||
|
||||
@@ -116,4 +120,3 @@ fn test_session_tracker_not_finished_phase_no_change() {
|
||||
"Session tracker should not change if phase not Finished"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user