From 299e0c6a94c600710c1a94537fc6d88af12d7787 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 01:46:52 +0000 Subject: [PATCH] feat(engine): cosmetic selectors applied, stats screen expanded, daily goals enforced - Card backs: selected_card_back index maps to distinct Color values in card rendering - Backgrounds: selected_background index applied in TablePlugin alongside theme - Both re-render immediately on SettingsChangedEvent - Stats screen now shows Games Lost, Draw 1/3 Wins, and Lifetime Score - Daily challenge win no longer credited if server-supplied target_score or max_time_secs constraints are not met Co-Authored-By: Claude Sonnet 4.6 --- solitaire_engine/src/card_plugin.rs | 60 +++++++++++++++---- .../src/daily_challenge_plugin.rs | 13 +++- solitaire_engine/src/stats_plugin.rs | 4 ++ solitaire_engine/src/table_plugin.rs | 20 +++++-- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index eb776b2..8ea2dd4 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -24,6 +24,7 @@ use crate::events::StateChangedEvent; use crate::game_plugin::GameMutation; use crate::layout::{Layout, LayoutResource}; use crate::resources::GameStateResource; +use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; /// Fraction of card height used as vertical offset between stacked tableau cards. pub const TABLEAU_FAN_FRAC: f32 = 0.25; @@ -36,10 +37,21 @@ const STACK_FAN_FRAC: f32 = 0.003; const FONT_SIZE_FRAC: f32 = 0.28; const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95); -const CARD_BACK_COLOUR: Color = Color::srgb(0.15, 0.30, 0.55); const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15); const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08); +/// Returns the card back color for the given unlocked card-back index. +/// Index 0 = default blue; 1–4 are unlockable alternate designs. +fn card_back_colour(selected_card_back: usize) -> Color { + match selected_card_back { + 0 => Color::srgb(0.15, 0.30, 0.55), // default blue + 1 => Color::srgb(0.55, 0.10, 0.10), // deep red + 2 => Color::srgb(0.05, 0.40, 0.10), // forest green + 3 => Color::srgb(0.35, 0.08, 0.52), // purple + _ => Color::srgb(0.05, 0.40, 0.42), // teal (4+) + } +} + /// Marker component linking a Bevy entity to a `solitaire_core::Card::id`. #[derive(Component, Debug, Clone, Copy)] pub struct CardEntity { @@ -57,8 +69,26 @@ impl Plugin for CardPlugin { fn build(&self, app: &mut App) { // PostStartup ensures TablePlugin's Startup system has inserted // LayoutResource before we try to read it. - app.add_systems(PostStartup, sync_cards_startup) - .add_systems(Update, sync_cards_on_change.after(GameMutation)); + app.add_event::() + .add_systems(PostStartup, sync_cards_startup) + .add_systems( + Update, + ( + sync_cards_on_change.after(GameMutation), + resync_cards_on_settings_change.before(sync_cards_on_change), + ), + ); + } +} + +/// When card-back selection changes in Settings, re-render all cards so the +/// new back colour is applied immediately (without waiting for a state change). +fn resync_cards_on_settings_change( + mut setting_events: EventReader, + mut state_events: EventWriter, +) { + if setting_events.read().next().is_some() { + state_events.send(StateChangedEvent); } } @@ -70,11 +100,15 @@ fn sync_cards_startup( game: Res, layout: Option>, slide_dur: Option>, + settings: Option>, entities: Query<(Entity, &CardEntity, &Transform)>, ) { if let Some(layout) = layout { let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); - sync_cards(commands, &game.0, &layout.0, slide_secs, &entities); + let back_colour = settings + .as_ref() + .map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back)); + sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities); } } @@ -84,6 +118,7 @@ fn sync_cards_on_change( game: Res, layout: Option>, slide_dur: Option>, + settings: Option>, entities: Query<(Entity, &CardEntity, &Transform)>, ) { if events.read().next().is_none() { @@ -91,7 +126,10 @@ fn sync_cards_on_change( } if let Some(layout) = layout { let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); - sync_cards(commands, &game.0, &layout.0, slide_secs, &entities); + let back_colour = settings + .as_ref() + .map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back)); + sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities); } } @@ -100,6 +138,7 @@ fn sync_cards( game: &GameState, layout: &Layout, slide_secs: f32, + back_colour: Color, entities: &Query<(Entity, &CardEntity, &Transform)>, ) { let positions = card_positions(game, layout); @@ -123,9 +162,9 @@ fn sync_cards( for (card, position, z) in positions { match existing.get(&card.id) { Some(&(entity, cur)) => { - update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, cur) + update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, back_colour, cur) } - None => spawn_card_entity(&mut commands, &card, position, z, layout), + None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour), } } } @@ -172,11 +211,11 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> { out } -fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout) { +fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color) { let body_colour = if card.face_up { CARD_FACE_COLOUR } else { - CARD_BACK_COLOUR + back_colour }; commands @@ -216,12 +255,13 @@ fn update_card_entity( z: f32, layout: &Layout, slide_secs: f32, + back_colour: Color, cur: Vec3, ) { let body_colour = if card.face_up { CARD_FACE_COLOUR } else { - CARD_BACK_COLOUR + back_colour }; let target = Vec3::new(pos.x, pos.y, z); diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index 24d656f..86917c3 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -150,10 +150,21 @@ fn handle_daily_completion( path: Res, mut completed: EventWriter, ) { - for _ in wins.read() { + for ev in wins.read() { if game.0.seed != daily.seed { continue; } + // Enforce server-supplied goal constraints when present. + if let Some(target) = daily.target_score { + if ev.score < target { + continue; // score goal not met + } + } + if let Some(max_secs) = daily.max_time_secs { + if ev.time_seconds > max_secs { + continue; // time limit exceeded + } + } if !progress.0.record_daily_completion(daily.date) { // Already counted today — no-op. continue; diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 02414e8..fde1c55 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -171,12 +171,16 @@ fn spawn_stats_screen( "=== Statistics ===".to_string(), format!("Games Played: {}", stats.games_played), format!("Games Won: {}", stats.games_won), + format!("Games Lost: {}", stats.games_lost), format!("Win Rate: {win_rate}"), format!( "Win Streak: {} (Best: {})", stats.win_streak_current, stats.win_streak_best ), + format!("Draw 1 Wins: {}", stats.draw_one_wins), + format!("Draw 3 Wins: {}", stats.draw_three_wins), format!("Best Score: {}", stats.best_single_score), + format!("Lifetime Score:{}", stats.lifetime_score), format!("Fastest Win: {fastest}"), format!("Avg Win Time: {avg}"), ]; diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index 30fbe3b..53cd7c4 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -11,7 +11,7 @@ use solitaire_core::pile::PileType; use solitaire_data::settings::Theme; use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR}; -use crate::settings_plugin::SettingsChangedEvent; +use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; /// Z-depth used for the background — below everything. const Z_BACKGROUND: f32 = -10.0; @@ -51,6 +51,18 @@ fn theme_colour(theme: &Theme) -> Color { } } +/// Effective table background colour: unlocked background index overrides the +/// Theme when `selected_background > 0`. +fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color { + match selected_background { + 0 => theme_colour(theme), + 1 => Color::srgb(0.25, 0.18, 0.10), // dark wood + 2 => Color::srgb(0.05, 0.08, 0.22), // navy + 3 => Color::srgb(0.30, 0.05, 0.08), // burgundy + _ => Color::srgb(0.12, 0.12, 0.14), // charcoal (4+) + } +} + fn default_window_size(window: &Window) -> Vec2 { Vec2::new(window.resolution.width(), window.resolution.height()) } @@ -59,7 +71,7 @@ fn setup_table( mut commands: Commands, windows: Query<&Window>, existing_camera: Query<(), With>, - settings: Option>, + settings: Option>, ) { // Only spawn a camera if one does not already exist (e.g. a parent app // may have added one in tests). @@ -76,7 +88,7 @@ fn setup_table( let initial_colour = settings .as_ref() - .map(|s| theme_colour(&s.0.theme)) + .map(|s| effective_background_colour(&s.0.theme, s.0.selected_background)) .unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2])); spawn_background(&mut commands, window_size, initial_colour); @@ -106,7 +118,7 @@ fn apply_theme_on_settings_change( let Some(ev) = events.read().last() else { return; }; - let colour = theme_colour(&ev.0.theme); + let colour = effective_background_colour(&ev.0.theme, ev.0.selected_background); for mut sprite in &mut backgrounds { sprite.color = colour; }