//! PNG-based card rendering. //! //! Card entities are synced with [`GameStateResource`] on every //! [`StateChangedEvent`]: missing cards are spawned, present cards are //! repositioned/updated in place, and stale cards are despawned. //! //! When [`CardImageSet`] is available, each face-up card renders its own //! 120×168 px `Handle` chosen from the 52 per-card PNGs loaded from //! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a //! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is //! absent (e.g. in tests running under `MinimalPlugins`). use std::collections::{HashMap, HashSet}; use bevy::color::Color; use bevy::prelude::*; use bevy::window::WindowResized; #[cfg(target_os = "android")] use bevy::sprite::Anchor; 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::card_animation::CardAnimation; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::game_plugin::GameMutation; use crate::layout::{Layout, LayoutResource, LayoutSystem}; use crate::pause_plugin::PausedResource; use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR}; use crate::font_plugin::FontResource; use crate::ui_theme::{ CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z, CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC, TYPE_BODY, Z_STOCK_BADGE, }; /// Fraction of card height used as vertical offset between face-up tableau cards. pub const TABLEAU_FAN_FRAC: f32 = 0.25; /// Per-card vertical step for face-down tableau cards, as a fraction of /// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards /// don't need their full body shown — only the back-pattern strip is /// visible. Public so `input_plugin` can mirror the exact sprite layout /// when hit-testing tableau columns; any drift between this and the /// renderer creates a visible offset between the card face and where /// clicks land. /// /// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must /// stay in sync; the layout constant drives the adaptive LayoutResource value /// used at runtime, while this one is the minimum floor used by /// `update_tableau_fan_frac` when computing proportional updates. pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20; /// Fraction of card height used as a tiny offset between stacked cards in /// non-tableau piles, so stacking is visible. Public so other plugins /// (e.g. input_plugin's drag-rejection tween) can compute the resting /// `Transform.translation.z` for a card at a given stack index without /// drifting from the value used by [`card_positions`]. pub const STACK_FAN_FRAC: f32 = 0.003; /// Font size as a fraction of card width. const FONT_SIZE_FRAC: f32 = 0.28; /// Font-size fraction for the large-print readability overlay on Android. /// Spawned on top of PNG face cards to make the rank+suit legible at phone /// scale, where the baked-in PNG corner text is only ~10 px physical. #[cfg(target_os = "android")] const FONT_SIZE_FRAC_MOBILE: f32 = 0.35; /// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED). pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102); /// Suit colour for hearts + diamonds — saturated red `#e35353`. /// 2-colour traditional pairing (the "Microsoft Solitaire on dark /// mode" feel) replacing the brief 4-colour-deck experiment that /// shipped between v0.21.0 and this commit. Brighter and more /// saturated than the v0.21.0 pink `#fb9fb1` so the cards read as /// a "real solitaire deck" rather than a Terminal-pastel theme. /// Visually distinct from `ACCENT_PRIMARY` (`#a54242` brick red, /// darker) so chrome and suit don't read as the same hue. pub const RED_SUIT_COLOUR: Color = Color::srgb(0.890, 0.325, 0.325); /// High-contrast variant of [`RED_SUIT_COLOUR`] — `#ff6868`. Lifted /// luminance for the Settings → Accessibility → High-contrast mode /// toggle. Pre-2-colour-revert this was `#ff8aa0` (pink-salmon) /// matching the v0.21.0 pink default; rebumped to a brighter red /// so it reads as "more chromatic" than the new saturated default, /// not "less saturated." Independent of `RED_SUIT_COLOUR_CBM` /// (lime) — high-contrast is *additive* over the default colour /// palette; CBM is a *replacement* of red with a hue-distinct /// alternative. The two modes can stack; CBM wins when both are on /// because the CBM lime is itself a high-contrast colour. pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408); /// Suit colour for spades + clubs — near-white `#e8e8e8`. Brighter /// than `TEXT_PRIMARY` (`#d0d0d0`, foreground gray) so the /// "black suit" reads as a distinct, chromatic-neutral counterpart /// to the new saturated red, not as "the same gray as body text." /// `TEXT_PRIMARY_HC` (`#f5f5f5`) is still brighter for the /// high-contrast boost path. pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910); /// Pre-loaded [`Handle`]s for card face and back PNG textures. /// /// Loaded once at startup by [`load_card_images`]. When this resource is /// present, card sprites use the PNG artwork; otherwise they fall back to /// solid-colour sprites (used in tests with `MinimalPlugins`). #[derive(Resource)] pub struct CardImageSet { /// Per-card face images indexed by `[suit][rank]`. /// /// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3. /// Rank order: Ace=0, Two=1 … King=12. pub faces: [[Handle; 13]; 4], /// One handle per unlockable card-back design (indices 0–4). These /// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed /// by `Settings::selected_card_back`. Used as a fallback when the active /// theme does not provide its own back (see [`Self::theme_back`]). pub backs: [Handle; 5], /// Back image supplied by the currently-active card theme, if any. /// /// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever /// a `CardTheme` finishes loading. The face-down render path in /// [`card_sprite`] prefers this handle over the legacy `backs[]` array, /// so a theme switch swaps both faces *and* the back without the player /// needing to touch the legacy `selected_card_back` picker. `None` means /// the active theme did not declare a back asset (or no theme has loaded /// yet); in that case [`card_sprite`] falls back to the legacy array. pub theme_back: Option>, } /// Suit-colour swap for red-suit cards in colour-blind mode — Terminal /// `#acc267` (lime). Replaces `RED_SUIT_COLOUR` (pink) when CBM is on, /// providing a hue-distinct alternative that survives the most common /// red/green deficiencies. Pre-Terminal this was a *face tint*; the new /// design moves CBM differentiation into the suit glyph colour itself /// and keeps the face uniformly `CARD_FACE_COLOUR` regardless of CBM. /// /// The CBM swap is lime (not the `ACCENT_PRIMARY` brick-red) because /// the primary accent is itself in the red family — using it for /// "the not-red CBM alternative" would defeat the purpose. Lime is /// the next-best non-red base16-eighties accent; deuteranopia and /// protanopia readers see it as visibly distinct from pink. const RED_SUIT_COLOUR_CBM: Color = Color::srgb(0.675, 0.761, 0.404); /// Returns the fallback card-back colour for the given unlocked card-back /// index. Production renders backs from PNG artwork; this fallback only /// fires under `MinimalPlugins` (tests). Mirrors the 5 accent colours /// from `card_face_svg::BACK_ACCENTS` so the test-environment back lives /// in the same hue family as the on-disk PNG art for that index. fn card_back_colour(selected_card_back: usize) -> Color { match selected_card_back { 0 => Color::srgb(0.647, 0.259, 0.259), // #a54242 brick red (Terminal canonical, ACCENT_PRIMARY) 1 => Color::srgb(0.675, 0.761, 0.404), // #acc267 lime 2 => Color::srgb(0.882, 0.639, 0.933), // #e1a3ee lavender 3 => Color::srgb(0.984, 0.624, 0.694), // #fb9fb1 pink _ => Color::srgb(0.867, 0.698, 0.435), // #ddb26f gold (4+) } } /// Marker component linking a Bevy entity to a `solitaire_core::Card::id`. #[derive(Component, Debug, Clone, Copy)] pub struct CardEntity { pub card_id: u32, } /// Marker for the text child inside a card entity. #[derive(Component, Debug)] pub struct CardLabel; /// Marker for the large-print rank+suit corner overlay on Android. /// /// Spawned on top of PNG face cards (face-up only) at font size /// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are /// readable at phone scale. Only exists when `CardImageSet` is present /// (the fallback solid-colour path uses a plain `CardLabel` instead). #[cfg(target_os = "android")] #[derive(Component, Debug, Clone, Copy)] struct AndroidCornerLabel; /// Solid-colour background sprite behind [`AndroidCornerLabel`]. /// /// Covers the card art's own small corner rank/suit text so only the /// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived /// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face. #[cfg(target_os = "android")] #[derive(Component, Debug, Clone, Copy)] struct AndroidCornerBg; /// 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, } /// Countdown (seconds) until the `HintHighlight` on a card entity is removed. /// /// Inserted alongside `HintHighlight` by the hint-visual system. When the timer /// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from /// the entity and the sprite colour is restored. #[derive(Component, Debug, Clone)] pub struct HintHighlightTimer(pub 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; /// Countdown (seconds) until this right-click destination highlight despawns. /// /// Inserted alongside `RightClickHighlight` so that highlights auto-clear after /// 1.5 s even if the player does not make a move or click again. The existing /// clear-on-state-change and clear-on-pause logic still fires early when /// appropriate. #[derive(Component, Debug, Clone)] pub struct RightClickHighlightTimer(pub f32); /// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile /// marker when the stock pile is empty. #[derive(Component, Debug)] pub struct StockEmptyLabel; /// Marker on the chip-background sprite of the stock-pile remaining-count /// badge. /// /// The badge is spawned as a *top-level* world entity (not parented to the /// stock [`PileMarker`]) and its `Transform` is recomputed each frame from /// `LayoutResource` so it tracks the stock pile through window resizes. /// The chip sits in the top-right corner of the stock pile and is hidden /// while the stock is empty — the existing `↺` overlay /// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two /// indicators never render simultaneously. #[derive(Component, Debug)] pub struct StockCountBadge; /// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric /// count of cards remaining in the stock pile. /// /// Update systems query this component to write the new count in place rather /// than despawning and respawning the text entity each tick. #[derive(Component, Debug)] pub struct StockCountBadgeText; // --------------------------------------------------------------------------- // 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; /// Marker component for the per-card drop-shadow child sprite. /// /// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a /// neutral-black halo painted slightly down-and-right of the card. Idle state /// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the /// parent card is being dragged the shadow is pushed to the deeper /// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the /// stack reads as "lifted" off the felt. #[derive(Component, Debug)] pub struct CardShadow; /// Marker on the thin contrasting border sprite spawned behind face-down cards. /// /// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the /// dark-green felt the edges are nearly invisible. This child sprite — slightly /// larger than the card, rendered at local z=-0.01 so it peeks out as a thin /// frame — gives every face-down card a visible perimeter. #[derive(Component, Debug)] pub struct CardBackFrame; /// Fill colour for the face-down card border frame. Medium gray so it reads as /// a neutral "edge" without competing with the suit colours on face-up cards. const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.38, 0.38, 0.38); /// Extra width/height (in world units) added to each side of the card to form /// the visible border. 3 world units ≈ 3 dp on a 1× screen. const CARD_BACK_FRAME_PADDING: f32 = 3.0; /// Returns the `(offset, padding, alpha)` triple used to paint a per-card /// shadow given whether its parent card is currently part of the dragged /// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested /// without spinning up a Bevy app. /// /// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)` /// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)` pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) { if is_dragged { ( CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_ALPHA_DRAG, ) } else { ( CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_IDLE, CARD_SHADOW_ALPHA_IDLE, ) } } /// Builds the `Sprite` used for a per-card shadow at the resting state. The /// alpha and size both use the idle tokens; `update_card_shadows_on_drag` /// retunes them at runtime when the parent card joins / leaves the dragged /// stack. fn card_shadow_sprite(card_size: Vec2) -> Sprite { let (_offset, padding, alpha) = card_shadow_params(false); Sprite { color: CARD_SHADOW_COLOR.with_alpha(alpha), custom_size: Some(card_size + padding), ..default() } } /// Builds the `Transform` used for a per-card shadow at the resting state. /// Local — it is parented to the card entity, so positions are relative. fn card_shadow_transform() -> Transform { let (offset, _padding, _alpha) = card_shadow_params(false); Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z) } /// Spawns a single `CardShadow` child under the given card entity builder. /// Extracted so `spawn_card_entity` and `update_card_entity` can share the /// exact same shadow recipe — we never want one path to drift from the other. fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) { parent.spawn(( CardShadow, card_shadow_sprite(card_size), card_shadow_transform(), Visibility::default(), )); } /// Spawns a `CardBackFrame` child behind a card entity to give every card a /// thin perimeter against the dark felt, regardless of face state. fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) { parent.spawn(( CardBackFrame, Sprite { color: CARD_BACK_FRAME_COLOR, custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)), ..default() }, Transform::from_xyz(0.0, 0.0, -0.01), Visibility::default(), )); } /// Throttle interval for resize-driven card snap work, in seconds. /// /// `WindowResized` fires once per pixel of drag, so a fast corner-drag can /// produce dozens of events per frame. Re-running the per-card snap logic /// (52 cards × sprite/transform/font_size touches) for every event is the /// dominant cost of resize lag. We coalesce pending work and apply it at most /// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates /// during a sustained drag, and the layout always catches up to the final /// size when the drag stops because the pending size is held until applied. const RESIZE_THROTTLE_SECS: f32 = 0.05; /// Holds the latest pending window size from `WindowResized` events plus a /// timestamp for the last applied snap, so the resize-snap work can be /// rate-limited to ~20 Hz during sustained drags. #[derive(Resource, Debug, Default)] pub struct ResizeThrottle { /// Latest unapplied window size from `WindowResized`. `None` when there is /// nothing to apply. pub pending: Option, /// `Time::elapsed_secs()` value at the moment of the most recent applied /// snap. `0.0` until the first apply. pub last_applied_secs: f32, } /// Pure helper used by the throttled resize-snap system: returns `true` when /// a pending resize should be flushed given the current `now_secs` and the /// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`]. /// /// Extracted so the rate-limit logic can be unit-tested without spinning up /// a full Bevy app. fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool { (now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS } /// Renders cards by reading `GameStateResource` on `StateChangedEvent`. pub struct CardPlugin; 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. // // `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::>() .init_resource::() .add_message::() .add_message::() .add_message::() .add_systems(Startup, load_card_images) .add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup)) .add_systems( Update, ( update_tableau_fan_frac .after(GameMutation) .before(sync_cards_on_change), 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, update_card_shadows_on_drag.after(sync_cards_on_change), tick_hint_highlight, handle_right_click, tick_right_click_highlights, clear_right_click_highlights_on_state_change.after(GameMutation), clear_right_click_highlights_on_pause, update_stock_empty_indicator.after(GameMutation), update_stock_count_badge .after(GameMutation) .run_if(resource_changed::), collect_resize_events.after(LayoutSystem::UpdateOnResize), snap_cards_on_window_resize.after(collect_resize_events), ), ); #[cfg(target_os = "android")] app.add_systems(Update, resize_android_corner_labels); } } /// Returns the relative asset path for a card face PNG. /// /// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png` /// for the Queen of Spades. Both `load_card_images` and the unit tests use /// this function so the filename formula is tested in isolation from the /// asset-loading machinery. /// /// Note: this function verifies only the **code-side mapping**. If the PNG /// file at the returned path contains wrong artwork (e.g. `QS.png` has a /// diamond watermark baked in), that is an **asset content bug** and must be /// fixed by replacing the file — no code change can correct it. fn card_face_asset_path(rank: Rank, suit: Suit) -> String { const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"]; const RANK_STRS: [&str; 13] = [ "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", ]; let suit_idx = match suit { Suit::Clubs => 0, Suit::Diamonds => 1, Suit::Hearts => 2, Suit::Spades => 3, }; let rank_idx = match rank { Rank::Ace => 0, Rank::Two => 1, Rank::Three => 2, Rank::Four => 3, Rank::Five => 4, Rank::Six => 5, Rank::Seven => 6, Rank::Eight => 7, Rank::Nine => 8, Rank::Ten => 9, Rank::Jack => 10, Rank::Queen => 11, Rank::King => 12, }; format!( "cards/faces/classic/{}{}.png", RANK_STRS[rank_idx], SUIT_CHARS[suit_idx] ) } /// Loads card face and back PNGs at startup via [`AssetServer`] and inserts /// [`CardImageSet`]. /// /// Faces: `assets/cards/faces/{RANK}{SUIT}.png` (e.g. `AC.png`, `10H.png`) /// Backs: `assets/cards/backs/back_{0..4}.png` /// /// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system /// returns without inserting `CardImageSet` and the plugin falls back to /// solid-colour sprites. fn load_card_images(asset_server: Option>, mut commands: Commands) { let Some(asset_server) = asset_server else { return; }; const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; const RANKS: [Rank; 13] = [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King, ]; let faces: [[Handle; 13]; 4] = std::array::from_fn(|si| { std::array::from_fn(|ri| { asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si])) }) }); let backs = std::array::from_fn(|i| { asset_server.load(format!("cards/backs/classic/back_{i}.png")) }); commands.insert_resource(CardImageSet { faces, backs, // Populated by the theme plugin once a `CardTheme` finishes loading. // Until then the legacy back fallback (`backs[selected_card_back]`) // is used. theme_back: None, }); } /// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is /// available and falling back to a solid-colour sprite in tests. fn card_sprite( card: &Card, card_size: Vec2, back_colour: Color, card_images: Option<&CardImageSet>, selected_back: usize, ) -> Sprite { if let Some(set) = card_images { let image = if card.face_up { let suit_idx = match card.suit { Suit::Clubs => 0, Suit::Diamonds => 1, Suit::Hearts => 2, Suit::Spades => 3, }; let rank_idx = match card.rank { Rank::Ace => 0, Rank::Two => 1, Rank::Three => 2, Rank::Four => 3, Rank::Five => 4, Rank::Six => 5, Rank::Seven => 6, Rank::Eight => 7, Rank::Nine => 8, Rank::Ten => 9, Rank::Jack => 10, Rank::Queen => 11, Rank::King => 12, }; set.faces[suit_idx][rank_idx].clone() } else if let Some(theme_back) = &set.theme_back { // Active theme provides its own back — always wins over the // legacy `selected_card_back` picker, so a theme switch swaps // faces *and* the back. The picker is treated as informational // only while a theme back is active (see settings_plugin). theme_back.clone() } else { let idx = selected_back.min(set.backs.len() - 1); set.backs[idx].clone() }; Sprite { image, color: Color::WHITE, custom_size: Some(card_size), ..default() } } else { // Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR // regardless of colour-blind mode (CBM differentiation now lives in // the suit glyph colour, applied by `text_colour`, not the face // background). Pre-Terminal this branch dispatched through a // separate `face_colour(card, color_blind)` helper. let body_colour = if card.face_up { CARD_FACE_COLOUR } else { back_colour }; Sprite { color: body_colour, custom_size: Some(card_size), ..default() } } } /// 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: MessageReader, mut state_events: MessageWriter, ) { if setting_events.read().next().is_some() { state_events.write(StateChangedEvent); } } /// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems /// (including `TablePlugin::setup_table` which inserts `LayoutResource`) /// have already completed. #[allow(clippy::too_many_arguments)] fn sync_cards_startup( commands: Commands, game: Res, layout: Option>, slide_dur: Option>, settings: Option>, entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, card_images: Option>, font_res: Option>, ) { if let Some(layout) = layout { let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back); let back_colour = card_back_colour(selected_back); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); let font_handle = font_res.as_ref().map(|r| &r.0); sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle); } } #[allow(clippy::too_many_arguments)] fn sync_cards_on_change( mut events: MessageReader, commands: Commands, game: Res, layout: Option>, slide_dur: Option>, settings: Option>, entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, card_images: Option>, font_res: Option>, ) { if events.read().next().is_none() { return; } if let Some(layout) = layout { let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs); let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back); let back_colour = card_back_colour(selected_back); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); let font_handle = font_res.as_ref().map(|r| &r.0); sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle); } } #[allow(clippy::too_many_arguments)] fn sync_cards( mut commands: Commands, game: &GameState, layout: &Layout, slide_secs: f32, back_colour: Color, color_blind: bool, high_contrast: bool, entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, card_images: Option<&CardImageSet>, selected_back: usize, font_handle: Option<&Handle>, ) { let positions = card_positions(game, layout); // Map card_id -> (Entity, current_translation, has_card_animation) for // in-place updates. The `has_card_animation` flag lets `update_card_entity` // skip the snap/slide path on cards that are already being driven by a // curve-based `CardAnimation` tween (e.g. the drag-rejection return tween // — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that // accompanies a rejection would race the tween and the card would jump. let mut existing: HashMap = HashMap::new(); for (entity, marker, transform, anim) in entities.iter() { existing.insert(marker.card_id, (entity, transform.translation, anim.is_some())); } let live_ids: HashSet = positions.iter().map(|(c, _, _)| c.id).collect(); // Despawn any entity whose card is no longer tracked. for (card_id, (entity, _, _)) in &existing { if !live_ids.contains(card_id) { commands.entity(*entity).despawn(); } } // For each card in the current state: spawn or update its entity. for (card, position, z) in positions { match existing.get(&card.id) { Some(&(entity, cur, has_anim)) => { update_card_entity( &mut commands, entity, card, position, z, layout, slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle, ) } None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle), } } } /// Returns an ordered vec of (card, position, z) for every card in the game. fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> { let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52); let piles = [ PileType::Stock, PileType::Waste, PileType::Foundation(0), PileType::Foundation(1), PileType::Foundation(2), PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), PileType::Tableau(3), PileType::Tableau(4), PileType::Tableau(5), PileType::Tableau(6), ]; // Compute the Draw-Three waste fan step proportional to the column spacing // (waste_x − stock_x = card_width + h_gap) rather than a fixed fraction of // card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and // 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android // (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping // the top fanned card's centre within the waste column's own horizontal // footprint instead of spilling into the adjacent gap. let waste_fan_step = { let s = layout.pile_positions.get(&PileType::Stock).copied().unwrap_or_default(); let w = layout.pile_positions.get(&PileType::Waste).copied().unwrap_or_default(); (w.x - s.x).abs() * 0.224 }; for pile_type in piles { let Some(base) = layout.pile_positions.get(&pile_type) else { continue; }; let Some(pile) = game.piles.get(&pile_type) else { continue; }; let is_tableau = matches!(pile_type, PileType::Tableau(_)); let is_waste = matches!(pile_type, PileType::Waste); // Tableau uses a two-speed fan: face-down cards are packed tighter // than face-up cards so the visible (playable) portion stands out. // Non-tableau piles stack with a negligible offset. // // Waste pile: only the top N cards are rendered to prevent bleed-through // while new cards animate in from the stock. Draw-One shows 1; Draw-Three // shows up to 3 fanned in X (matching the standard Klondike presentation). let cards = &pile.cards; let render_start = if is_waste { let visible = match game.draw_mode { DrawMode::DrawOne => 1_usize, DrawMode::DrawThree => 3_usize, }; // Render one extra card so that the card sliding off the waste // during a draw animation is still present in the world at z=0 // (hidden under the stack) rather than vanishing mid-tween. cards.len().saturating_sub(visible + 1) } else { 0 }; let mut y_offset = 0.0_f32; let rendered_len = cards[render_start..].len(); for (slot, card) in cards[render_start..].iter().enumerate() { let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) { // When len > visible, slot 0 is a hidden buffer card kept at // x=0 to prevent a flash during the draw tween. When len ≤ // visible (small pile), every card is visible and should fan // normally — no card is hidden, so the shift is 0. let visible = 3_usize; let hidden = rendered_len.saturating_sub(visible); slot.saturating_sub(hidden) as f32 * waste_fan_step } else { 0.0 }; let pos = Vec2::new(base.x + x_offset, base.y + y_offset); let z = 1.0 + (slot as f32) * STACK_FAN_FRAC; out.push((card, pos, z)); if is_tableau { let step = if card.face_up { layout.tableau_fan_frac } else { layout.tableau_facedown_fan_frac }; y_offset -= layout.card_size.y * step; } } } out } #[allow(clippy::too_many_arguments)] fn spawn_card_entity( commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool, high_contrast: bool, card_images: Option<&CardImageSet>, selected_back: usize, font_handle: Option<&Handle>, ) { let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back); let mut entity = commands.spawn(( CardEntity { card_id: card.id }, sprite, Transform::from_xyz(pos.x, pos.y, z), Visibility::default(), )); // Every card gets a subtle drop-shadow child so the play surface reads // as physical instead of flat. Spawned in idle state; the drag-tracking // system retunes its offset / alpha when this card joins the dragged // stack. entity.with_children(|b| { add_card_shadow_child(b, layout.card_size); }); // Every card gets a thin border frame so it reads as a distinct // rectangle against the dark felt, regardless of face state. entity.with_children(|b| { add_card_back_frame_child(b, layout.card_size); }); // When PNG faces are loaded the rank/suit are baked into the image. // Only spawn the Text2d overlay in the solid-colour fallback (tests). // On Android we additionally spawn a large-print corner label even in // image mode so the rank/suit are legible at phone scale. if card_images.is_none() { entity.with_children(|b| { b.spawn(( CardLabel, Text2d::new(label_for(card)), TextFont { font_size: layout.card_size.x * FONT_SIZE_FRAC, ..default() }, TextColor(text_colour(card, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(card), )); }); } #[cfg(target_os = "android")] if card_images.is_some() { entity.with_children(|b| { add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle); }); } // Suppress unused-variable warning when not building for Android. #[cfg(not(target_os = "android"))] let _ = font_handle; } #[allow(clippy::too_many_arguments)] fn update_card_entity( commands: &mut Commands, entity: Entity, card: &Card, pos: Vec2, z: f32, layout: &Layout, slide_secs: f32, back_colour: Color, color_blind: bool, high_contrast: bool, cur: Vec3, has_card_animation: bool, card_images: Option<&CardImageSet>, selected_back: usize, font_handle: Option<&Handle>, ) { let target = Vec3::new(pos.x, pos.y, z); // Always refresh the visual appearance. commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, card_images, selected_back)); // Skip the snap/slide path entirely when a curve-based `CardAnimation` // is driving this card (e.g. the drag-rejection return tween). Writing // `Transform` here would race that animation each frame and cause a // visible jump. The animation system snaps the final position itself // when it completes. if !has_card_animation { // Slide to the new position when it differs meaningfully; snap otherwise. if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 { let start = Vec3::new(cur.x, cur.y, z); // update Z immediately commands .entity(entity) .insert(Transform::from_translation(start)) .insert(CardAnim { start, target, elapsed: 0.0, duration: slide_secs, delay: 0.0, }); } else { commands .entity(entity) .remove::() .insert(Transform::from_xyz(pos.x, pos.y, z)); } } // Despawn any stale children and re-add the per-card drop shadow plus, // in solid-colour fallback mode, the label overlay. In image mode the // rank/suit are baked into the PNG; on Android we also add a large-print // corner overlay so they are legible at phone scale. commands.entity(entity).despawn_related::(); commands.entity(entity).with_children(|b| { add_card_shadow_child(b, layout.card_size); }); commands.entity(entity).with_children(|b| { add_card_back_frame_child(b, layout.card_size); }); if card_images.is_none() { commands.entity(entity).with_children(|b| { b.spawn(( CardLabel, Text2d::new(label_for(card)), TextFont { font_size: layout.card_size.x * FONT_SIZE_FRAC, ..default() }, TextColor(text_colour(card, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(card), )); }); } #[cfg(target_os = "android")] if card_images.is_some() { commands.entity(entity).with_children(|b| { add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle); }); } // Suppress unused-variable warning when not building for Android. #[cfg(not(target_os = "android"))] let _ = font_handle; } fn label_for(card: &Card) -> String { let rank = match card.rank { Rank::Ace => "A", Rank::Two => "2", Rank::Three => "3", Rank::Four => "4", Rank::Five => "5", Rank::Six => "6", Rank::Seven => "7", Rank::Eight => "8", Rank::Nine => "9", Rank::Ten => "10", Rank::Jack => "J", Rank::Queen => "Q", Rank::King => "K", }; let suit = match card.suit { Suit::Clubs => "C", Suit::Diamonds => "D", Suit::Hearts => "H", Suit::Spades => "S", }; format!("{rank}{suit}") } /// Suit colour for the rank/suit overlay rendered atop the constant /// fallback sprite (only fires under `MinimalPlugins` — production /// renders the suit glyph baked into the PNG). 2-colour traditional /// pairing — hearts + diamonds share the saturated red, clubs + /// spades share the near-white. Two accessibility flags compose: /// /// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM` /// (lime) — the "Settings toggle swaps red→lime" half of the /// design system's colour-blind support. CBM is a hue-replacement /// for red, so HC has no further effect on red when CBM is on /// (the lime is itself a high-luminance colour). /// - `high_contrast`: when CBM is off, red suits boost to /// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from /// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`). /// /// The other half of CBM support (always-on filled-vs-outlined /// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art /// and has no constant-fallback equivalent. fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color { if card.suit.is_red() { if color_blind { // CBM lime wins — the colour-blind swap replaces the // red hue entirely, and the lime is already high- // luminance, so an HC boost on top has nothing to do. RED_SUIT_COLOUR_CBM } else if high_contrast { RED_SUIT_COLOUR_HC } else { RED_SUIT_COLOUR } } else if high_contrast { TEXT_PRIMARY_HC } else { BLACK_SUIT_COLOUR } } fn label_visibility(card: &Card) -> Visibility { if card.face_up { Visibility::Inherited } else { Visibility::Hidden } } /// Rank+suit string for the Android readability overlay. /// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono). #[cfg(target_os = "android")] fn mobile_label_for(card: &Card) -> String { let rank = match card.rank { Rank::Ace => "A", Rank::Two => "2", Rank::Three => "3", Rank::Four => "4", Rank::Five => "5", Rank::Six => "6", Rank::Seven => "7", Rank::Eight => "8", Rank::Nine => "9", Rank::Ten => "10", Rank::Jack => "J", Rank::Queen => "Q", Rank::King => "K", }; let suit = match card.suit { Suit::Clubs => "♣", Suit::Diamonds => "♦", Suit::Hearts => "♥", Suit::Spades => "♠", }; format!("{rank}{suit}") } /// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on /// face-up cards. The background sprite covers the card art's own small /// corner text so only the large overlay is visible. /// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on /// face-up cards using FiraMono (passed via `font_handle`) so that the /// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit /// font handle Bevy falls back to its built-in face which does not include /// those glyphs, causing a coloured missing-glyph rectangle to appear in /// the text colour — the root cause of the "red square on face-down cards" /// visual bug (the box bleeds through near the card edge at z=0.02). #[cfg(target_os = "android")] fn add_android_corner_label( parent: &mut ChildSpawnerCommands, card: &Card, card_size: Vec2, color_blind: bool, high_contrast: bool, font_handle: Option<&Handle>, ) { if !card.face_up { return; } let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE; let inset = 3.0_f32; // Background covers ~3 monospace chars wide × 1 line tall. // FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠" // (3 chars = 1.8× font_size) plus a small margin. let bg_w = font_size * 2.0; let bg_h = font_size * 1.25; // Solid background that hides the card art's small corner label. parent.spawn(( AndroidCornerBg, Sprite { color: CARD_FACE_COLOUR, custom_size: Some(Vec2::new(bg_w, bg_h)), ..default() }, Transform::from_xyz( -card_size.x / 2.0 + inset + bg_w / 2.0, card_size.y / 2.0 - inset - bg_h / 2.0, 0.015, ), )); // Large rank+suit text drawn on top of the background. FiraMono must be // wired here explicitly — the suit glyphs (U+2660–U+2666) are not in // Bevy's built-in font and render as a coloured rectangle without it. parent.spawn(( AndroidCornerLabel, CardLabel, Text2d::new(mobile_label_for(card)), TextFont { font: font_handle.cloned().unwrap_or_default(), font_size, ..default() }, TextColor(text_colour(card, color_blind, high_contrast)), Anchor::TOP_LEFT, Transform::from_xyz( -card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02, ), )); } // --------------------------------------------------------------------------- // 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: MessageReader, 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`, scale.x resets to 0, /// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal. /// - 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