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.
|
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
||||||
/// Rank order: Ace=0, Two=1 … King=12.
|
/// Rank order: Ace=0, Two=1 … King=12.
|
||||||
pub faces: [[Handle<Image>; 13]; 4],
|
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],
|
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
|
/// 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| {
|
let backs = std::array::from_fn(|i| {
|
||||||
asset_server.load(format!("cards/backs/back_{i}.png"))
|
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
|
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
||||||
@@ -407,6 +427,12 @@ fn card_sprite(
|
|||||||
Rank::King => 12,
|
Rank::King => 12,
|
||||||
};
|
};
|
||||||
set.faces[suit_idx][rank_idx].clone()
|
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 {
|
} else {
|
||||||
let idx = selected_back.min(set.backs.len() - 1);
|
let idx = selected_back.min(set.backs.len() - 1);
|
||||||
set.backs[idx].clone()
|
set.backs[idx].clone()
|
||||||
@@ -2542,4 +2568,136 @@ mod tests {
|
|||||||
// Sanity: a fresh game with stock present reports 24.
|
// Sanity: a fresh game with stock present reports 24.
|
||||||
assert_eq!(stock_card_count(&g), 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>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if !screen.is_changed() {
|
if !screen.is_changed() {
|
||||||
return;
|
return;
|
||||||
@@ -396,6 +397,16 @@ fn sync_settings_panel_visibility(
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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(
|
spawn_settings_panel(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&settings.0,
|
&settings.0,
|
||||||
@@ -405,6 +416,7 @@ fn sync_settings_panel_visibility(
|
|||||||
&themes,
|
&themes,
|
||||||
scroll_pos.0,
|
scroll_pos.0,
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
|
theme_overrides_back,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -983,6 +995,14 @@ fn persist_window_geometry_after_debounce(
|
|||||||
// UI construction
|
// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_settings_panel(
|
fn spawn_settings_panel(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
@@ -993,6 +1013,7 @@ fn spawn_settings_panel(
|
|||||||
themes: &[(String, String)],
|
themes: &[(String, String)],
|
||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
|
theme_overrides_back: bool,
|
||||||
) {
|
) {
|
||||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Settings", font_res);
|
spawn_modal_header(card, "Settings", font_res);
|
||||||
@@ -1084,15 +1105,26 @@ fn spawn_settings_panel(
|
|||||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
picker_row(
|
if theme_overrides_back {
|
||||||
body,
|
// The active theme provides its own back; the legacy
|
||||||
"Card Back",
|
// picker has no visible effect, so we replace its
|
||||||
unlocked_card_backs,
|
// swatch row with an informational caption. The
|
||||||
settings.selected_card_back,
|
// player's `selected_card_back` value still
|
||||||
SettingsButton::SelectCardBack,
|
// round-trips through `settings.json` — the moment
|
||||||
"Choose your deck art. New backs unlock at higher levels.",
|
// they switch to a theme without a back, the picker
|
||||||
font_res,
|
// 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(
|
picker_row(
|
||||||
body,
|
body,
|
||||||
"Background",
|
"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`]
|
/// Picker row for card-art themes. Distinct from [`picker_row`]
|
||||||
/// because themes are identified by `String` ids (matching
|
/// because themes are identified by `String` ids (matching
|
||||||
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
|
/// `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));
|
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
|
/// `CardImageSet` whenever the active theme finishes loading or
|
||||||
/// changes. Fires `StateChangedEvent` afterwards so the existing
|
/// changes. Fires `StateChangedEvent` afterwards so the existing
|
||||||
/// `card_plugin::sync_cards_on_change` pipeline re-renders every
|
/// `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
|
/// Pure helper that copies the theme's image handles into the
|
||||||
/// `[suit][rank]` face matrix and into back slot 0. Split out so it
|
/// `[suit][rank]` face matrix and into the dedicated `theme_back`
|
||||||
/// can be unit-tested without spinning up a Bevy `App`.
|
/// 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) {
|
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 suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
for rank in [
|
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
|
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
|
||||||
@@ -251,6 +259,7 @@ mod tests {
|
|||||||
CardImageSet {
|
CardImageSet {
|
||||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||||
backs: std::array::from_fn(|_| Handle::default()),
|
backs: std::array::from_fn(|_| Handle::default()),
|
||||||
|
theme_back: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,24 +293,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn applying_theme_overwrites_back_slot_zero() {
|
fn applying_theme_writes_theme_back_slot_and_leaves_legacy_backs_untouched() {
|
||||||
// Build a theme whose back handle is a freshly-allocated weak
|
// The active-theme back lives in its own dedicated slot
|
||||||
// handle — its id will differ from the default-handle id we
|
// (`theme_back`) so the legacy `backs[0..5]` PNG fallbacks
|
||||||
// started with, proving the back slot was overwritten.
|
// 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();
|
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 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);
|
apply_theme_to_card_image_set(&theme, &mut image_set);
|
||||||
// Both default handles compare equal to themselves; the test
|
// The active-theme back is now populated and matches the theme.
|
||||||
// asserts via id() that whichever handle is in slot 0 came
|
let active_back = image_set
|
||||||
// from the theme — even if both happen to be Handle::default,
|
.theme_back
|
||||||
// the id swap is still observable via the value-equality of
|
.as_ref()
|
||||||
// theme.back's id.
|
.expect("theme_back populated after apply");
|
||||||
assert_eq!(image_set.backs[0].id(), theme.back.id());
|
assert_eq!(active_back.id(), theme.back.id());
|
||||||
// No assertion about original_back_id — both sides may be the
|
// Every legacy back slot is preserved byte-for-byte by id.
|
||||||
// same default handle id when neither is loaded; the contract
|
for (i, before) in legacy_ids_before.iter().enumerate() {
|
||||||
// we're checking is "slot 0 now matches theme.back".
|
assert_eq!(
|
||||||
let _ = original_back_id;
|
image_set.backs[i].id(),
|
||||||
|
*before,
|
||||||
|
"legacy back slot {i} must not be clobbered by theme apply",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user