diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index 9ee95dd..e848b7c 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -34,6 +34,19 @@ pub struct AchievementContext { pub wall_clock_hour: Option, } +/// Reward granted when an achievement is first unlocked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reward { + /// Unlocks a card-back design at the given index (0 is always unlocked). + CardBack(usize), + /// Unlocks a background design at the given index (0 is always unlocked). + Background(usize), + /// Awards bonus XP on top of the standard win XP. + BonusXp(u64), + /// A visual badge — no gameplay effect. + Badge, +} + /// A single achievement's static metadata + unlock condition. #[derive(Debug, Clone, Copy)] pub struct AchievementDef { @@ -42,6 +55,8 @@ pub struct AchievementDef { pub description: &'static str, /// Hidden from the achievements screen until unlocked. pub secret: bool, + /// Reward granted on first unlock. `None` for cosmetic-only recognition. + pub reward: Option, pub condition: fn(&AchievementContext) -> bool, } @@ -112,6 +127,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "First Win", description: "Win your first game", secret: false, + reward: None, condition: first_win, }, AchievementDef { @@ -119,6 +135,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "On a Roll", description: "Win 3 games in a row", secret: false, + reward: Some(Reward::CardBack(1)), condition: on_a_roll, }, AchievementDef { @@ -126,6 +143,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Unstoppable", description: "Win 10 games in a row", secret: false, + reward: Some(Reward::Background(1)), condition: unstoppable, }, AchievementDef { @@ -133,6 +151,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Century", description: "Play 100 games", secret: false, + reward: None, condition: century, }, AchievementDef { @@ -140,6 +159,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Veteran", description: "Play 500 games", secret: false, + reward: Some(Reward::Badge), condition: veteran, }, AchievementDef { @@ -147,6 +167,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Speed Demon", description: "Win in under 3 minutes", secret: false, + reward: None, condition: speed_demon, }, AchievementDef { @@ -154,6 +175,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Lightning", description: "Win in under 90 seconds", secret: false, + reward: Some(Reward::CardBack(2)), condition: lightning, }, AchievementDef { @@ -161,6 +183,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "High Scorer", description: "Score at least 5,000 in one game", secret: false, + reward: None, condition: high_scorer, }, AchievementDef { @@ -168,6 +191,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Point Machine", description: "Accumulate 50,000 lifetime points", secret: false, + reward: Some(Reward::Background(2)), condition: point_machine, }, AchievementDef { @@ -175,6 +199,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "No Undo", description: "Win a game without using undo", secret: false, + reward: Some(Reward::BonusXp(25)), condition: no_undo, }, AchievementDef { @@ -182,6 +207,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Draw 3 Master", description: "Win 10 games in Draw 3 mode", secret: false, + reward: Some(Reward::CardBack(3)), condition: draw_three_master, }, AchievementDef { @@ -189,6 +215,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Night Owl", description: "Win a game after midnight", secret: false, + reward: None, condition: night_owl, }, AchievementDef { @@ -196,6 +223,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Early Bird", description: "Win a game before 6am", secret: false, + reward: None, condition: early_bird, }, AchievementDef { @@ -203,6 +231,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "???", description: "A secret achievement", secret: true, + reward: Some(Reward::CardBack(4)), condition: speed_and_skill, }, AchievementDef { @@ -210,6 +239,7 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ name: "Daily Devotee", description: "Complete the daily challenge 7 days in a row", secret: false, + reward: Some(Reward::Background(3)), condition: daily_devotee, }, ]; diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index a98bf3c..5cb3dbf 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -84,6 +84,14 @@ pub struct Settings { /// Which sync backend is active. #[serde(default)] pub sync_backend: SyncBackend, + /// Index of the card-back design currently in use (0 = default). + /// Only indices present in `PlayerProgress::unlocked_card_backs` are valid. + #[serde(default)] + pub selected_card_back: usize, + /// Index of the background design currently in use (0 = default). + /// Only indices present in `PlayerProgress::unlocked_backgrounds` are valid. + #[serde(default)] + pub selected_background: usize, /// Set to `true` once the player has dismissed the first-run banner. #[serde(default)] pub first_run_complete: bool, @@ -110,6 +118,8 @@ impl Default for Settings { animation_speed: AnimSpeed::Normal, theme: Theme::Green, sync_backend: SyncBackend::Local, + selected_card_back: 0, + selected_background: 0, first_run_complete: false, } } @@ -264,6 +274,8 @@ mod tests { url: "https://example.com".to_string(), username: "testuser".to_string(), }, + selected_card_back: 0, + selected_background: 0, first_run_complete: true, }; save_settings_to(&path, &s).expect("save"); diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 40fed27..eec2149 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -10,15 +10,16 @@ use std::path::PathBuf; use bevy::prelude::*; use chrono::{Local, Timelike, Utc}; use solitaire_core::achievement::{ - achievement_by_id, check_achievements, AchievementContext, ALL_ACHIEVEMENTS, + achievement_by_id, check_achievements, AchievementContext, Reward, ALL_ACHIEVEMENTS, }; use solitaire_data::{ achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord, + save_progress_to, }; use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; -use crate::progress_plugin::{ProgressResource, ProgressUpdate}; +use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::resources::GameStateResource; use crate::stats_plugin::{StatsResource, StatsUpdate}; @@ -89,11 +90,13 @@ impl Plugin for AchievementPlugin { fn evaluate_on_win( mut wins: EventReader, mut unlocks: EventWriter, + mut levelups: EventWriter, game: Res, stats: Res, - progress: Res, path: Res, + progress_path: Res, mut achievements: ResMut, + mut progress: ResMut, ) { let Some(ev) = wins.read().last() else { return; @@ -119,7 +122,9 @@ fn evaluate_on_win( } let now = Utc::now(); - let mut changed = false; + let mut achievements_changed = false; + let mut progress_changed = false; + for def in hits { let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else { continue; @@ -128,17 +133,59 @@ fn evaluate_on_win( continue; } record.unlock(now); - changed = true; + achievements_changed = true; + + // Grant the reward on first unlock. + if !record.reward_granted { + if let Some(reward) = def.reward { + match reward { + Reward::CardBack(idx) => { + if !progress.0.unlocked_card_backs.contains(&idx) { + progress.0.unlocked_card_backs.push(idx); + progress_changed = true; + } + } + Reward::Background(idx) => { + if !progress.0.unlocked_backgrounds.contains(&idx) { + progress.0.unlocked_backgrounds.push(idx); + progress_changed = true; + } + } + Reward::BonusXp(amount) => { + let prev_level = progress.0.add_xp(amount); + if progress.0.leveled_up_from(prev_level) { + levelups.send(LevelUpEvent { + previous_level: prev_level, + new_level: progress.0.level, + total_xp: progress.0.total_xp, + }); + } + progress_changed = true; + } + Reward::Badge => {} + } + } + record.reward_granted = true; + } + unlocks.send(AchievementUnlockedEvent(record.clone())); } - if changed { + if achievements_changed { if let Some(target) = &path.0 { if let Err(e) = save_achievements_to(target, &achievements.0) { warn!("failed to save achievements: {e}"); } } } + + if progress_changed { + if let Some(target) = &progress_path.0 { + if let Err(e) = save_progress_to(target, &progress.0) { + warn!("failed to save progress after reward: {e}"); + } + } + } } /// Convenience: resolve an achievement ID to its human-readable name. diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 45ae38d..744d974 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -16,6 +16,7 @@ use solitaire_core::game_state::DrawMode; use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings}; use crate::events::ManualSyncRequestEvent; +use crate::progress_plugin::ProgressResource; use crate::resources::{SyncStatus, SyncStatusResource}; /// Volume adjustment step applied by the `[` / `]` hotkeys. @@ -61,6 +62,14 @@ struct ThemeText; #[derive(Component, Debug)] struct SyncStatusText; +/// Marks the `Text` node showing the active card-back index. +#[derive(Component, Debug)] +struct CardBackText; + +/// Marks the `Text` node showing the active background index. +#[derive(Component, Debug)] +struct BackgroundText; + /// Tags interactive buttons inside the Settings panel. #[derive(Component, Debug)] enum SettingsButton { @@ -70,6 +79,8 @@ enum SettingsButton { MusicUp, ToggleDrawMode, ToggleTheme, + CycleCardBack, + CycleBackground, SyncNow, Done, } @@ -122,6 +133,8 @@ impl Plugin for SettingsPlugin { sync_settings_panel_visibility, handle_settings_buttons, update_sync_status_text, + update_card_back_text, + update_background_text, ), ); } @@ -186,6 +199,7 @@ fn sync_settings_panel_visibility( mut commands: Commands, settings: Res, sync_status: Option>, + progress: Option>, ) { if !screen.is_changed() { return; @@ -195,7 +209,21 @@ fn sync_settings_panel_visibility( let status_label = sync_status .map(|s| sync_status_label(&s.0)) .unwrap_or_else(|| "Status: not configured".to_string()); - spawn_settings_panel(&mut commands, &settings.0, &status_label); + let unlocked_backs = progress + .as_ref() + .map(|p| p.0.unlocked_card_backs.as_slice()) + .unwrap_or(&[0]); + let unlocked_bgs = progress + .as_ref() + .map(|p| p.0.unlocked_backgrounds.as_slice()) + .unwrap_or(&[0]); + spawn_settings_panel( + &mut commands, + &settings.0, + &status_label, + unlocked_backs, + unlocked_bgs, + ); } } else { for entity in &panels { @@ -204,6 +232,16 @@ fn sync_settings_panel_visibility( } } +/// Returns the next unlocked index after `current` in the sorted `unlocked` list. +/// Wraps around. Falls back to `unlocked[0]` if `current` is not found. +fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize { + if unlocked.is_empty() { + return 0; + } + let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0); + unlocked[(pos + 1) % unlocked.len()] +} + /// Keeps the sync-status text node current while the panel is open. fn update_sync_status_text( sync_status: Option>, @@ -221,6 +259,46 @@ fn update_sync_status_text( } } +fn update_card_back_text( + settings: Res, + mut text_nodes: Query<&mut Text, With>, +) { + if !settings.is_changed() { + return; + } + for mut text in &mut text_nodes { + **text = card_back_label(settings.0.selected_card_back); + } +} + +fn update_background_text( + settings: Res, + mut text_nodes: Query<&mut Text, With>, +) { + if !settings.is_changed() { + return; + } + for mut text in &mut text_nodes { + **text = background_label(settings.0.selected_background); + } +} + +fn card_back_label(idx: usize) -> String { + if idx == 0 { + "Default".to_string() + } else { + format!("Style {idx}") + } +} + +fn background_label(idx: usize) -> String { + if idx == 0 { + "Default".to_string() + } else { + format!("Style {idx}") + } +} + fn sync_status_label(status: &SyncStatus) -> String { match status { SyncStatus::Idle => "Status: idle".to_string(), @@ -249,6 +327,7 @@ fn handle_settings_buttons( path: Res, mut changed: EventWriter, mut manual_sync: EventWriter, + progress: Option>, mut sfx_text: Query<&mut Text, (With, Without, Without, Without)>, mut music_text: Query<&mut Text, (With, Without, Without, Without)>, mut draw_text: Query<&mut Text, (With, Without, Without, Without)>, @@ -326,6 +405,26 @@ fn handle_settings_buttons( **t = theme_label(&settings.0.theme); } } + SettingsButton::CycleCardBack => { + let unlocked = progress + .as_ref() + .map(|p| p.0.unlocked_card_backs.clone()) + .unwrap_or_else(|| vec![0]); + settings.0.selected_card_back = + cycle_unlocked(&unlocked, settings.0.selected_card_back); + persist(&path, &settings.0); + changed.send(SettingsChangedEvent(settings.0.clone())); + } + SettingsButton::CycleBackground => { + let unlocked = progress + .as_ref() + .map(|p| p.0.unlocked_backgrounds.clone()) + .unwrap_or_else(|| vec![0]); + settings.0.selected_background = + cycle_unlocked(&unlocked, settings.0.selected_background); + persist(&path, &settings.0); + changed.send(SettingsChangedEvent(settings.0.clone())); + } SettingsButton::SyncNow => { manual_sync.send(ManualSyncRequestEvent); } @@ -355,7 +454,13 @@ fn theme_label(theme: &Theme) -> String { // UI construction // --------------------------------------------------------------------------- -fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_status: &str) { +fn spawn_settings_panel( + commands: &mut Commands, + settings: &Settings, + sync_status: &str, + unlocked_card_backs: &[usize], + unlocked_backgrounds: &[usize], +) { commands .spawn(( SettingsPanel, @@ -458,6 +563,54 @@ fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_statu icon_button(row, "⇄", SettingsButton::ToggleTheme); }); + // Card back row — only shown when the player has unlocked more than one. + if unlocked_card_backs.len() > 1 { + card.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(8.0), + ..default() + }) + .with_children(|row| { + row.spawn(( + Text::new("Card Back"), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::srgb(0.85, 0.85, 0.80)), + )); + row.spawn(( + CardBackText, + Text::new(card_back_label(settings.selected_card_back)), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::WHITE), + )); + icon_button(row, "⇄", SettingsButton::CycleCardBack); + }); + } + + // Background row — only shown when the player has unlocked more than one. + if unlocked_backgrounds.len() > 1 { + card.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(8.0), + ..default() + }) + .with_children(|row| { + row.spawn(( + Text::new("Background"), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::srgb(0.85, 0.85, 0.80)), + )); + row.spawn(( + BackgroundText, + Text::new(background_label(settings.selected_background)), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::WHITE), + )); + icon_button(row, "⇄", SettingsButton::CycleBackground); + }); + } + // --- Sync section --- section_label(card, "Sync"); card.spawn(Node {