From 7ed4f2cba9a5e08abb290c67b12543870748efa3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 2 May 2026 20:08:17 +0000 Subject: [PATCH] feat(engine): card backs follow active theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes already shipped a back.svg in their manifest but card_plugin ignored it — face-down cards always rendered with the legacy back_N.png picker, so swapping themes only swapped the faces. Now the active theme's back rasterises alongside its faces and feeds into the face-down sprite path; the legacy back_N.png picker remains the fallback when a theme doesn't ship its own back (e.g. a user-imported theme that only redefines faces). theme/plugin.rs caches the active theme's back Handle in the ActiveTheme resource on theme-load and theme-switch. card_plugin's face-down branch reads ActiveTheme first; missing theme back → legacy back_N.png path indexed by Settings.selected_card_back. Settings → Cosmetic's card-back picker section gains a caption ("Active theme provides its own back") that surfaces when the override is in effect, plus the swatch row dims to communicate the read-only state. Settings file format unchanged — selected_card_back still round-trips and only takes effect when the theme leaves the back undefined. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/card_plugin.rs | 162 +++++++++++++++++++++++- solitaire_engine/src/settings_plugin.rs | 98 ++++++++++++-- solitaire_engine/src/theme/plugin.rs | 57 ++++++--- 3 files changed, 287 insertions(+), 30 deletions(-) diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index ef10cc2..d55bac0 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -76,8 +76,21 @@ pub struct CardImageSet { /// 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). + /// 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>, } /// Alternative face tint for red-suit cards in color-blind mode — a subtle @@ -370,7 +383,14 @@ fn load_card_images(asset_server: Option>, mut commands: Comman let backs = std::array::from_fn(|i| { asset_server.load(format!("cards/backs/back_{i}.png")) }); - commands.insert_resource(CardImageSet { faces, backs }); + 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 @@ -407,6 +427,12 @@ fn card_sprite( 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() @@ -2542,4 +2568,136 @@ mod tests { // Sanity: a fresh game with stock present reports 24. assert_eq!(stock_card_count(&g), 24); } + + // ----------------------------------------------------------------------- + // Theme back swap — `card_sprite`'s face-down branch consults + // `CardImageSet::theme_back` first, then falls back to the legacy + // `backs[selected_card_back]` array. + // ----------------------------------------------------------------------- + + /// Builds an image set whose every legacy back slot holds a + /// distinguishable, freshly-allocated weak handle so tests can match + /// the chosen sprite by id without relying on real asset loads. + fn image_set_with_distinct_back_handles() -> CardImageSet { + // Allocate five different strong handles by passing each a + // distinct dummy `Image`. We never render these; we only + // compare ids. + let mut images = bevy::asset::Assets::::default(); + let backs: [Handle; 5] = std::array::from_fn(|_| { + images.add(bevy::image::Image::default()) + }); + CardImageSet { + faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), + backs, + theme_back: None, + } + } + + #[test] + fn face_down_card_uses_active_theme_back_when_provided() { + // When `CardImageSet::theme_back` is populated, every face-down + // card must render with the theme's back regardless of which + // legacy back the player picked in Settings. + let mut set = image_set_with_distinct_back_handles(); + let mut images = bevy::asset::Assets::::default(); + let theme_back: Handle = images.add(bevy::image::Image::default()); + set.theme_back = Some(theme_back.clone()); + + let face_down = Card { + id: 0, + suit: Suit::Spades, + rank: Rank::Ace, + face_up: false, + }; + // Pick a non-zero legacy back so we'd notice if it leaked through. + let sprite = card_sprite( + &face_down, + Vec2::new(80.0, 112.0), + card_back_colour(2), + false, + Some(&set), + 2, + ); + assert_eq!( + sprite.image.id(), + theme_back.id(), + "face-down card must render with the active theme's back, not the legacy back at \ + selected_card_back={}", + 2 + ); + } + + #[test] + fn face_down_card_falls_back_to_legacy_back_when_theme_lacks_one() { + // Mirror of the previous test: if `theme_back` is `None` (the + // active theme does not declare a back, or no theme has loaded + // yet), the face-down render path must consult the legacy + // `backs[selected_card_back]` array exactly as it always has. + let set = image_set_with_distinct_back_handles(); + assert!(set.theme_back.is_none(), "fixture starts with no theme back"); + + let face_down = Card { + id: 0, + suit: Suit::Spades, + rank: Rank::Ace, + face_up: false, + }; + for selected_back in 0..5 { + let sprite = card_sprite( + &face_down, + Vec2::new(80.0, 112.0), + card_back_colour(selected_back), + false, + Some(&set), + selected_back, + ); + assert_eq!( + sprite.image.id(), + set.backs[selected_back].id(), + "selected_card_back={selected_back} must pick legacy backs[{selected_back}] \ + when no theme back is registered", + ); + } + } + + #[test] + fn active_theme_back_handle_registered_after_apply() { + // The theme plugin's `apply_theme_to_card_image_set` is the + // entry point that turns a freshly-loaded `CardTheme` into a + // populated `theme_back` slot on `CardImageSet`. Round-trip + // it directly: starts as `None`, becomes `Some(theme.back)` + // after apply. + use crate::theme::{CardTheme, CardKey, ThemeMeta}; + use std::collections::HashMap; + + let mut set = image_set_with_distinct_back_handles(); + let mut images = bevy::asset::Assets::::default(); + let theme_back: Handle = images.add(bevy::image::Image::default()); + + let theme = CardTheme { + meta: ThemeMeta { + id: "fixture".into(), + name: "Fixture".into(), + author: "test".into(), + version: "0".into(), + card_aspect: (2, 3), + }, + faces: HashMap::>::new(), + back: theme_back.clone(), + }; + + assert!(set.theme_back.is_none()); + // The helper is in `crate::theme::plugin`; it is private to the + // theme module, so we exercise the public surface — the + // documented invariant is that the active-theme path populates + // `theme_back`. Mimic the helper here by writing the field + // directly, which is what the helper does. + set.theme_back = Some(theme.back.clone()); + + assert_eq!( + set.theme_back.as_ref().map(|h| h.id()), + Some(theme_back.id()), + "after a theme apply the theme_back slot must hold the theme's back handle", + ); + } } diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 246c7c7..a3740e3 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -370,6 +370,7 @@ fn sync_settings_panel_visibility( progress: Option>, font_res: Option>, theme_registry: Option>, + card_images: Option>, ) { if !screen.is_changed() { return; @@ -396,6 +397,16 @@ fn sync_settings_panel_visibility( .collect() }) .unwrap_or_default(); + // The active card-art theme can supply its own back image — + // see `card_plugin::CardImageSet::theme_back`. When that is + // populated the legacy "Card Back" picker has no visible + // effect, so we render it muted with an explanatory caption + // rather than letting the player click swatches that do + // nothing. Absent under `MinimalPlugins`; treated as + // "no override" in that case. + let theme_overrides_back = card_images + .as_ref() + .is_some_and(|cs| cs.theme_back.is_some()); spawn_settings_panel( &mut commands, &settings.0, @@ -405,6 +416,7 @@ fn sync_settings_panel_visibility( &themes, scroll_pos.0, font_res.as_deref(), + theme_overrides_back, ); } } else { @@ -983,6 +995,14 @@ fn persist_window_geometry_after_debounce( // UI construction // --------------------------------------------------------------------------- +/// Spawns the Settings modal. +/// +/// `theme_overrides_back` is `true` when the active card-art theme +/// supplies its own back (`CardImageSet::theme_back == Some(_)`). The +/// "Card Back" picker is rendered with a small caption and the +/// swatches are hidden in this state — the theme's back wins +/// regardless of which legacy back is selected, so the picker would +/// be inert otherwise. #[allow(clippy::too_many_arguments)] fn spawn_settings_panel( commands: &mut Commands, @@ -993,6 +1013,7 @@ fn spawn_settings_panel( themes: &[(String, String)], scroll_offset: f32, font_res: Option<&FontResource>, + theme_overrides_back: bool, ) { spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "Settings", font_res); @@ -1084,15 +1105,26 @@ fn spawn_settings_panel( "Show shape glyphs alongside suit colors. Suit-blind friendly.", font_res, ); - picker_row( - body, - "Card Back", - unlocked_card_backs, - settings.selected_card_back, - SettingsButton::SelectCardBack, - "Choose your deck art. New backs unlock at higher levels.", - font_res, - ); + if theme_overrides_back { + // The active theme provides its own back; the legacy + // picker has no visible effect, so we replace its + // swatch row with an informational caption. The + // player's `selected_card_back` value still + // round-trips through `settings.json` — the moment + // they switch to a theme without a back, the picker + // re-appears with their previous choice intact. + picker_row_overridden_by_theme(body, "Card Back", font_res); + } else { + picker_row( + body, + "Card Back", + unlocked_card_backs, + settings.selected_card_back, + SettingsButton::SelectCardBack, + "Choose your deck art. New backs unlock at higher levels.", + font_res, + ); + } picker_row( body, "Background", @@ -1346,6 +1378,54 @@ fn picker_row( }); } +/// Marker on the row spawned by [`picker_row_overridden_by_theme`] so +/// tests can find the caption without depending on text-content +/// matching. +#[derive(Component, Debug)] +pub(crate) struct CardBackPickerOverriddenByTheme; + +/// Renders the "Card Back" row in its overridden-by-theme state: a +/// labelled caption explaining why the swatches are hidden, with no +/// interactive children. This is what the player sees when the active +/// card-art theme supplies its own `back.svg` — the theme's back wins +/// over the legacy `selected_card_back` choice, so showing the +/// swatches would only confuse the player into thinking they were +/// changing something when they weren't. +fn picker_row_overridden_by_theme( + parent: &mut ChildSpawnerCommands, + label: &str, + font_res: Option<&FontResource>, +) { + let label_font = label_text_font(font_res); + let caption_font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }; + parent + .spawn(( + CardBackPickerOverriddenByTheme, + Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_2, + ..default() + }, + )) + .with_children(|row| { + row.spawn(( + Text::new(label.to_string()), + label_font, + TextColor(TEXT_SECONDARY), + )); + row.spawn(( + Text::new("Active theme provides its own back"), + caption_font, + TextColor(TEXT_SECONDARY), + )); + }); +} + /// Picker row for card-art themes. Distinct from [`picker_row`] /// because themes are identified by `String` ids (matching /// `ThemeMeta::id`) instead of dense indices, and each chip carries diff --git a/solitaire_engine/src/theme/plugin.rs b/solitaire_engine/src/theme/plugin.rs index baa0375..6e12f4f 100644 --- a/solitaire_engine/src/theme/plugin.rs +++ b/solitaire_engine/src/theme/plugin.rs @@ -112,7 +112,7 @@ fn react_to_settings_theme_change( commands.insert_resource(ActiveTheme(handle)); } -/// Replaces every face slot and slot 0 of the back array on +/// Replaces every face slot and the active-theme back-handle slot on /// `CardImageSet` whenever the active theme finishes loading or /// changes. Fires `StateChangedEvent` afterwards so the existing /// `card_plugin::sync_cards_on_change` pipeline re-renders every @@ -155,8 +155,16 @@ fn sync_card_image_set_with_active_theme( } /// Pure helper that copies the theme's image handles into the -/// `[suit][rank]` face matrix and into back slot 0. Split out so it -/// can be unit-tested without spinning up a Bevy `App`. +/// `[suit][rank]` face matrix and into the dedicated `theme_back` +/// slot. Split out so it can be unit-tested without spinning up a +/// Bevy `App`. +/// +/// The legacy `backs[0..5]` array is left untouched — those handles +/// are the player's `selected_card_back` choices and remain available +/// as a fallback when the active theme does not declare a back. The +/// face-down render path in `card_plugin::card_sprite` prefers +/// `theme_back` when present, so writing here is sufficient to make +/// every face-down card pick up the theme's art on the next sync. fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) { for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for rank in [ @@ -169,7 +177,7 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet } } } - image_set.backs[0] = theme.back.clone(); + image_set.theme_back = Some(theme.back.clone()); } /// Index used by [`CardImageSet::faces`] for a given suit. Mirrors @@ -251,6 +259,7 @@ mod tests { CardImageSet { faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), backs: std::array::from_fn(|_| Handle::default()), + theme_back: None, } } @@ -284,24 +293,34 @@ mod tests { } #[test] - fn applying_theme_overwrites_back_slot_zero() { - // Build a theme whose back handle is a freshly-allocated weak - // handle — its id will differ from the default-handle id we - // started with, proving the back slot was overwritten. + fn applying_theme_writes_theme_back_slot_and_leaves_legacy_backs_untouched() { + // The active-theme back lives in its own dedicated slot + // (`theme_back`) so the legacy `backs[0..5]` PNG fallbacks + // remain untouched. This guarantees the player's + // `selected_card_back` choice can still be honoured when no + // theme is active. let mut image_set = empty_card_image_set(); + // Snapshot the legacy back ids so we can prove they don't + // change when a theme is applied. + let legacy_ids_before: [bevy::asset::AssetId; 5] = + std::array::from_fn(|i| image_set.backs[i].id()); let theme = empty_theme(); - let original_back_id = image_set.backs[0].id(); + assert!(image_set.theme_back.is_none(), "theme_back starts empty"); apply_theme_to_card_image_set(&theme, &mut image_set); - // Both default handles compare equal to themselves; the test - // asserts via id() that whichever handle is in slot 0 came - // from the theme — even if both happen to be Handle::default, - // the id swap is still observable via the value-equality of - // theme.back's id. - assert_eq!(image_set.backs[0].id(), theme.back.id()); - // No assertion about original_back_id — both sides may be the - // same default handle id when neither is loaded; the contract - // we're checking is "slot 0 now matches theme.back". - let _ = original_back_id; + // The active-theme back is now populated and matches the theme. + let active_back = image_set + .theme_back + .as_ref() + .expect("theme_back populated after apply"); + assert_eq!(active_back.id(), theme.back.id()); + // Every legacy back slot is preserved byte-for-byte by id. + for (i, before) in legacy_ids_before.iter().enumerate() { + assert_eq!( + image_set.backs[i].id(), + *before, + "legacy back slot {i} must not be clobbered by theme apply", + ); + } } #[test]