feat(engine): card backs follow active theme
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<Image> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Image>; 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<Image>; 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<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
||||
@@ -370,7 +383,14 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, 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::<bevy::image::Image>::default();
|
||||
let backs: [Handle<bevy::image::Image>; 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::<bevy::image::Image>::default();
|
||||
let theme_back: Handle<bevy::image::Image> = 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::<bevy::image::Image>::default();
|
||||
let theme_back: Handle<bevy::image::Image> = 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::<CardKey, Handle<bevy::image::Image>>::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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@ fn sync_settings_panel_visibility(
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||
) {
|
||||
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,6 +1105,16 @@ fn spawn_settings_panel(
|
||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||
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",
|
||||
@@ -1093,6 +1124,7 @@ fn spawn_settings_panel(
|
||||
"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
|
||||
|
||||
@@ -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<bevy::image::Image>; 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]
|
||||
|
||||
Reference in New Issue
Block a user