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:
funman300
2026-05-02 20:08:17 +00:00
parent ddc8f27c82
commit 7ed4f2cba9
3 changed files with 287 additions and 30 deletions
+89 -9
View File
@@ -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,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