From c3ee7c45a71f51b7db58bf00496245a5691febe6 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 27 Apr 2026 19:03:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20card=20visual=20improvements=20?= =?UTF-8?q?=E2=80=94=20flip=20animation,=20foundation/tableau=20placeholde?= =?UTF-8?q?rs,=20drag=20shadow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed. Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation markers so the suit is visible while the pile is empty. Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land on empty columns. Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card. Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin, cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting). Co-Authored-By: Claude Sonnet 4.6 --- solitaire_data/src/settings.rs | 49 +++ solitaire_engine/src/card_plugin.rs | 471 ++++++++++++++++++++- solitaire_engine/src/cursor_plugin.rs | 266 ++++++++++++ solitaire_engine/src/hud_plugin.rs | 304 ++++++++++++- solitaire_engine/src/lib.rs | 14 +- solitaire_engine/src/pause_plugin.rs | 112 ++++- solitaire_engine/src/settings_plugin.rs | 195 ++++++++- solitaire_engine/src/stats_plugin.rs | 47 +- solitaire_engine/src/table_plugin.rs | 64 ++- solitaire_engine/src/win_summary_plugin.rs | 430 +++++++++++++++++++ 10 files changed, 1910 insertions(+), 42 deletions(-) create mode 100644 solitaire_engine/src/cursor_plugin.rs create mode 100644 solitaire_engine/src/win_summary_plugin.rs diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 5cb3dbf..844a460 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -95,6 +95,11 @@ pub struct Settings { /// Set to `true` once the player has dismissed the first-run banner. #[serde(default)] pub first_run_complete: bool, + /// When `true`, red-suit card faces use a blue tint instead of the default + /// cream so they are distinguishable from black-suit cards without relying + /// solely on colour. + #[serde(default)] + pub color_blind_mode: bool, } fn default_draw_mode() -> DrawMode { @@ -121,6 +126,7 @@ impl Default for Settings { selected_card_back: 0, selected_background: 0, first_run_complete: false, + color_blind_mode: false, } } } @@ -277,12 +283,30 @@ mod tests { selected_card_back: 0, selected_background: 0, first_run_complete: true, + color_blind_mode: false, }; save_settings_to(&path, &s).expect("save"); let loaded = load_settings_from(&path); assert_eq!(loaded, s); } + #[test] + fn round_trip_preserves_non_default_cosmetic_selections() { + // selected_card_back and selected_background must survive save→load with + // non-zero values — zero is the default and not a meaningful regression check. + let path = tmp_path("cosmetic_selections"); + let _ = fs::remove_file(&path); + let s = Settings { + selected_card_back: 3, + selected_background: 2, + ..Settings::default() + }; + save_settings_to(&path, &s).expect("save"); + let loaded = load_settings_from(&path); + assert_eq!(loaded.selected_card_back, 3); + assert_eq!(loaded.selected_background, 2); + } + #[test] fn load_from_missing_file_returns_default() { let path = tmp_path("missing_xyz"); @@ -318,5 +342,30 @@ mod tests { assert_eq!(s.theme, Theme::Green); assert_eq!(s.sync_backend, SyncBackend::Local); assert_eq!(s.draw_mode, DrawMode::DrawOne); + assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format"); + assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format"); + assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format"); + } + + #[test] + fn color_blind_mode_defaults_to_false_when_field_absent() { + // Simulate a JSON file that has no color_blind_mode field. + let json = br#"{ "sfx_volume": 0.7 }"#; + let s: Settings = serde_json::from_slice(json).unwrap_or_default(); + assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON"); + } + + #[test] + fn color_blind_mode_round_trips() { + let path = tmp_path("color_blind"); + let _ = std::fs::remove_file(&path); + let s = Settings { + color_blind_mode: true, + ..Settings::default() + }; + save_settings_to(&path, &s).expect("save"); + let loaded = load_settings_from(&path); + assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip"); + let _ = std::fs::remove_file(&path); } } diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 3e6c0d0..5e4e130 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -19,12 +19,16 @@ use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; +use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; + use crate::animation_plugin::{CardAnim, EffectiveSlideDuration}; -use crate::events::StateChangedEvent; +use crate::events::{CardFlippedEvent, StateChangedEvent}; use crate::game_plugin::GameMutation; use crate::layout::{Layout, LayoutResource}; -use crate::resources::GameStateResource; +use crate::pause_plugin::PausedResource; +use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; +use crate::table_plugin::PileMarker; /// Fraction of card height used as vertical offset between face-up tableau cards. pub const TABLEAU_FAN_FRAC: f32 = 0.25; @@ -39,9 +43,13 @@ const STACK_FAN_FRAC: f32 = 0.003; /// Font size as a fraction of card width. const FONT_SIZE_FRAC: f32 = 0.28; -const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95); -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); +pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95); +pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15); +pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08); + +/// Alternative face tint for red-suit cards in color-blind mode — a subtle +/// blue wash that distinguishes them from black-suit cards without colour alone. +const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0); /// Returns the card back color for the given unlocked card-back index. /// Index 0 = default blue; 1–4 are unlockable alternate designs. @@ -65,6 +73,56 @@ pub struct CardEntity { #[derive(Component, Debug)] pub struct CardLabel; +/// Marker component indicating the card is currently highlighted as a hint. +/// `remaining` counts down in real seconds; the highlight is removed when it +/// reaches zero and the card sprite colour is restored to its normal value. +#[derive(Component, Debug, Clone)] +pub struct HintHighlight { + /// Seconds remaining before the highlight is cleared. + pub remaining: f32, +} + +/// Marker on a `PileMarker` entity that is highlighted because the right-clicked +/// card can legally be placed there. +#[derive(Component, Debug)] +pub struct RightClickHighlight; + +// --------------------------------------------------------------------------- +// Task #34 — Card-flip animation +// --------------------------------------------------------------------------- + +/// Phase of the two-stage flip animation. +#[derive(Debug, Clone, PartialEq)] +pub enum FlipPhase { + /// Scale X from 1.0 → 0.0 (hiding the back face). + ScalingDown, + /// Scale X from 0.0 → 1.0 (revealing the front face). + ScalingUp, +} + +/// Drives a 2-phase "card flip" animation on `CardEntity` entities. +/// +/// The animation squashes X to 0, swaps the sprite to the face-up colour, +/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`. +#[derive(Component, Debug, Clone)] +pub struct CardFlipAnim { + /// Seconds elapsed in the current phase. + pub timer: f32, + /// Which half of the flip we are in. + pub phase: FlipPhase, +} + +/// Duration of each half of the flip animation (scale-down or scale-up). +const FLIP_HALF_SECS: f32 = 0.08; + +// --------------------------------------------------------------------------- +// Task #38 — Drag-elevation shadow +// --------------------------------------------------------------------------- + +/// Marker component for the semi-transparent shadow sprite shown while dragging. +#[derive(Component, Debug)] +pub struct ShadowEntity; + /// Renders cards by reading `GameStateResource` on `StateChangedEvent`. pub struct CardPlugin; @@ -72,13 +130,24 @@ 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_event::() + // + // `handle_right_click` reads `ButtonInput`. Under + // `MinimalPlugins` (tests) this resource is absent by default, so we + // ensure it exists here. Under `DefaultPlugins` the call is a no-op. + app.init_resource::>() + .add_event::() + .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), + start_flip_anim.after(GameMutation), + tick_flip_anim, + update_drag_shadow, + tick_hint_highlight, + handle_right_click, ), ); } @@ -111,7 +180,8 @@ fn sync_cards_startup( 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); + let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); + sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities); } } @@ -132,7 +202,8 @@ fn sync_cards_on_change( 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); + let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); + sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities); } } @@ -142,6 +213,7 @@ fn sync_cards( layout: &Layout, slide_secs: f32, back_colour: Color, + color_blind: bool, entities: &Query<(Entity, &CardEntity, &Transform)>, ) { let positions = card_positions(game, layout); @@ -165,9 +237,12 @@ 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, back_colour, cur) + update_card_entity( + &mut commands, entity, &card, position, z, layout, + slide_secs, back_colour, color_blind, cur, + ) } - None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour), + None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour, color_blind), } } } @@ -243,9 +318,22 @@ 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, back_colour: Color) { - let body_colour = if card.face_up { +/// Returns the appropriate face-up body colour for a card. +/// +/// In color-blind mode, red-suit cards receive a subtle blue tint +/// (`CARD_FACE_COLOUR_RED_CBM`) so they are distinguishable from black-suit +/// cards without relying on the text colour alone. +fn face_colour(card: &Card, color_blind: bool) -> Color { + if color_blind && card.suit.is_red() { + CARD_FACE_COLOUR_RED_CBM + } else { CARD_FACE_COLOUR + } +} + +fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) { + let body_colour = if card.face_up { + face_colour(card, color_blind) } else { back_colour }; @@ -288,10 +376,11 @@ fn update_card_entity( layout: &Layout, slide_secs: f32, back_colour: Color, + color_blind: bool, cur: Vec3, ) { let body_colour = if card.face_up { - CARD_FACE_COLOUR + face_colour(card, color_blind) } else { back_colour }; @@ -384,6 +473,299 @@ fn label_visibility(card: &Card) -> Visibility { } } +// --------------------------------------------------------------------------- +// Task #34 — Card-flip animation systems +// --------------------------------------------------------------------------- + +/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity. +/// +/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed). +fn start_flip_anim( + mut events: EventReader, + slide_dur: Option>, + mut commands: Commands, + card_entities: Query<(Entity, &CardEntity)>, +) { + if slide_dur.is_some_and(|d| d.slide_secs == 0.0) { + // Instant animation speed — skip the flip effect entirely. + events.clear(); + return; + } + + for CardFlippedEvent(card_id) in events.read() { + for (entity, marker) in &card_entities { + if marker.card_id == *card_id { + commands.entity(entity).insert(CardFlipAnim { + timer: 0.0, + phase: FlipPhase::ScalingDown, + }); + break; + } + } + } +} + +/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`. +/// +/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`. +/// - At the midpoint the phase switches to `ScalingUp` and scale.x resets to 0. +/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`. +/// - When complete the component is removed and scale.x is restored to 1.0. +fn tick_flip_anim( + mut commands: Commands, + time: Res