feat(engine): art pass — PNG assets, custom font, and keyring v4 upgrade
Art pass (Phase 4): - Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!) - Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time - Add FontPlugin: loads font at startup, exposes FontResource; gracefully falls back to default handle when Assets<Font> absent (MinimalPlugins tests) - Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour sprites when available; tests continue using colour fallback via MinimalPlugins - Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour background; empty set inserted when Assets<Image> absent in tests - Fix hint highlight system (input_plugin): tint sprite.color directly instead of replacing the whole Sprite (which would discard the image handle) - Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib - Register FontPlugin in solitaire_app before other plugins Dependency upgrades (latest releases): - keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into separate core library crate) - auth_tokens.rs: Entry::new now returns Result; delete_password → delete_credential; NoDefaultStore error variant handled - solitaire_app: add keyring::use_native_store(true) at startup for Linux Secret Service / macOS Keychain / Windows Credential Store selection ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section, add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables, update Section 14 to reflect actual include_bytes!() rendering approach, add Decision Log entries for embedded PNG and font decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,19 @@ pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||
|
||||
/// Pre-loaded [`Handle<Image>`]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 {
|
||||
/// Shared face image used for all face-up cards.
|
||||
pub face: Handle<Image>,
|
||||
/// One handle per unlockable card-back design (indices 0–4).
|
||||
pub backs: [Handle<Image>; 5],
|
||||
}
|
||||
|
||||
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
||||
/// blue wash that distinguishes them from black-suit cards without colour alone.
|
||||
const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0);
|
||||
@@ -160,6 +173,7 @@ impl Plugin for CardPlugin {
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
.add_systems(Startup, load_card_images)
|
||||
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -180,6 +194,81 @@ impl Plugin for CardPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads card face and back PNGs at startup and inserts [`CardImageSet`].
|
||||
///
|
||||
/// The PNGs are embedded at compile time via `include_bytes!()`. Missing
|
||||
/// files are compile errors, not runtime panics. Under `MinimalPlugins`
|
||||
/// (tests) this system is still registered but `Assets<Image>` is unavailable,
|
||||
/// so it does nothing and the plugin falls back to solid-colour sprites.
|
||||
fn load_card_images(images: Option<ResMut<Assets<Image>>>, mut commands: Commands) {
|
||||
let Some(mut images) = images else {
|
||||
// Assets<Image> is absent (e.g. MinimalPlugins in tests) — skip so
|
||||
// tests can still run. The plugin falls back to solid-colour sprites.
|
||||
return;
|
||||
};
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::image::{CompressedImageFormats, ImageSampler, ImageType};
|
||||
|
||||
let load = |bytes: &[u8]| {
|
||||
Image::from_buffer(
|
||||
bytes,
|
||||
ImageType::Extension("png"),
|
||||
CompressedImageFormats::NONE,
|
||||
true,
|
||||
ImageSampler::default(),
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
)
|
||||
.expect("valid card PNG")
|
||||
};
|
||||
|
||||
let face = images.add(load(include_bytes!("../../assets/cards/faces/face.png")));
|
||||
let backs = [
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_0.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_1.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_2.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_3.png"))),
|
||||
images.add(load(include_bytes!("../../assets/cards/backs/back_4.png"))),
|
||||
];
|
||||
commands.insert_resource(CardImageSet { face, backs });
|
||||
}
|
||||
|
||||
/// 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,
|
||||
color_blind: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) -> Sprite {
|
||||
if let Some(set) = card_images {
|
||||
let image = if card.face_up {
|
||||
set.face.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 {
|
||||
let body_colour = if card.face_up {
|
||||
face_colour(card, color_blind)
|
||||
} 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(
|
||||
@@ -201,14 +290,14 @@ fn sync_cards_startup(
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
if let Some(layout) = layout {
|
||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||
let back_colour = settings
|
||||
.as_ref()
|
||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||
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);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,17 +309,17 @@ fn sync_cards_on_change(
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
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 back_colour = settings
|
||||
.as_ref()
|
||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||
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);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +331,8 @@ fn sync_cards(
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
@@ -266,10 +357,10 @@ fn sync_cards(
|
||||
Some(&(entity, cur)) => {
|
||||
update_card_entity(
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, cur,
|
||||
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
|
||||
)
|
||||
}
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind),
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,21 +449,23 @@ fn face_colour(card: &Card, color_blind: bool) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) {
|
||||
let body_colour = if card.face_up {
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
fn spawn_card_entity(
|
||||
commands: &mut Commands,
|
||||
card: &Card,
|
||||
pos: Vec2,
|
||||
z: f32,
|
||||
layout: &Layout,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let sprite = card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
CardEntity { card_id: card.id },
|
||||
Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(layout.card_size),
|
||||
..default()
|
||||
},
|
||||
sprite,
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
))
|
||||
@@ -405,21 +498,13 @@ fn update_card_entity(
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
cur: Vec3,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
) {
|
||||
let body_colour = if card.face_up {
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
|
||||
let target = Vec3::new(pos.x, pos.y, z);
|
||||
|
||||
// Always refresh the visual appearance.
|
||||
commands.entity(entity).insert(Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(layout.card_size),
|
||||
..default()
|
||||
});
|
||||
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
|
||||
|
||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||
@@ -653,20 +738,24 @@ fn tick_hint_highlight(
|
||||
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
) {
|
||||
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
let use_images = card_images.is_some();
|
||||
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||
hint.remaining -= time.delta_secs();
|
||||
if hint.remaining <= 0.0 {
|
||||
// Restore normal face-up colour.
|
||||
let is_face_up = game.0.piles.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == card_entity.card_id)
|
||||
.is_some_and(|c| c.face_up);
|
||||
sprite.color = if is_face_up {
|
||||
CARD_FACE_COLOUR
|
||||
// Restore the normal sprite colour.
|
||||
// When image-based rendering is active, WHITE is the neutral tint;
|
||||
// otherwise restore the solid colour appropriate to the card state.
|
||||
sprite.color = if use_images {
|
||||
Color::WHITE
|
||||
} else {
|
||||
card_back_colour(back_idx)
|
||||
let is_face_up = game.0.piles.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == card_entity.card_id)
|
||||
.is_some_and(|c| c.face_up);
|
||||
if is_face_up { CARD_FACE_COLOUR } else { card_back_colour(back_idx) }
|
||||
};
|
||||
commands
|
||||
.entity(entity)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Register FontPlugin in solitaire_engine/src/lib.rs before use.
|
||||
|
||||
//! Embeds FiraMono-Medium as the project font and exposes it via [`FontResource`].
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Holds the project-wide [`Handle<Font>`] loaded at startup.
|
||||
#[derive(Resource)]
|
||||
pub struct FontResource(pub Handle<Font>);
|
||||
|
||||
/// Loads FiraMono-Medium at startup and inserts [`FontResource`].
|
||||
pub struct FontPlugin;
|
||||
|
||||
impl Plugin for FontPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, load_font);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
|
||||
let Some(mut fonts) = fonts else {
|
||||
// Assets<Font> absent (e.g. MinimalPlugins in tests) — insert default.
|
||||
commands.insert_resource(FontResource(Handle::default()));
|
||||
return;
|
||||
};
|
||||
let bytes: &'static [u8] = include_bytes!("../../assets/fonts/main.ttf");
|
||||
match Font::try_from_bytes(bytes.to_vec()) {
|
||||
Ok(font) => {
|
||||
commands.insert_resource(FontResource(fonts.add(font)));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("failed to load main.ttf: {e}; falling back to Bevy default font");
|
||||
commands.insert_resource(FontResource(Handle::default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use solitaire_core::pile::PileType;
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::InfoToastEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::selection_plugin::SelectionState;
|
||||
@@ -98,9 +99,13 @@ impl Plugin for HudPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_hud(mut commands: Commands) {
|
||||
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
|
||||
let font = TextFont { font_size: 18.0, ..default() };
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: 18.0,
|
||||
..default()
|
||||
};
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
|
||||
@@ -252,7 +252,7 @@ fn handle_keyboard_hint(
|
||||
mut confirm: ResMut<KeyboardConfirmState>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
|
||||
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||
) {
|
||||
@@ -308,16 +308,14 @@ fn handle_keyboard_hint(
|
||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||
.map(|c| c.id);
|
||||
if let Some(card_id) = top_card_id {
|
||||
for (entity, card_entity, _sprite) in card_entities.iter() {
|
||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||
if card_entity.card_id == card_id {
|
||||
// Tint the card gold without replacing the Sprite (which would
|
||||
// discard the image handle set by CardImageSet).
|
||||
sprite.color = Color::srgba(1.0, 1.0, 0.4, 1.0);
|
||||
commands.entity(entity)
|
||||
.insert(HintHighlight { remaining: 2.0 })
|
||||
.insert(HintHighlightTimer(2.0))
|
||||
.insert(Sprite {
|
||||
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
|
||||
custom_size: Some(layout_res.0.card_size),
|
||||
..default()
|
||||
});
|
||||
.insert(HintHighlightTimer(2.0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod animation_plugin;
|
||||
pub mod auto_complete_plugin;
|
||||
pub mod audio_plugin;
|
||||
pub mod card_plugin;
|
||||
pub mod font_plugin;
|
||||
pub mod feedback_anim_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cursor_plugin;
|
||||
@@ -59,9 +60,10 @@ pub use feedback_anim_plugin::{
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
pub use card_plugin::{
|
||||
CardEntity, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer, RightClickHighlight,
|
||||
RightClickHighlightTimer,
|
||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||
RightClickHighlight, RightClickHighlightTimer,
|
||||
};
|
||||
pub use font_plugin::{FontPlugin, FontResource};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
|
||||
|
||||
@@ -11,9 +11,21 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_data::settings::Theme;
|
||||
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
||||
use crate::layout::{compute_layout, Layout, LayoutResource};
|
||||
#[cfg(test)]
|
||||
use crate::layout::TABLE_COLOUR;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
|
||||
///
|
||||
/// Loaded once at startup by [`load_background_images`]. Index 0 is the
|
||||
/// default; indices 1–4 are unlockable.
|
||||
#[derive(Resource)]
|
||||
pub struct BackgroundImageSet {
|
||||
/// One handle per background slot (indices 0–4).
|
||||
pub handles: Vec<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// Z-depth used for the background — below everything.
|
||||
const Z_BACKGROUND: f32 = -10.0;
|
||||
/// Z-depth used for pile markers — below cards (which start at 0) but above
|
||||
@@ -50,6 +62,7 @@ impl Plugin for TablePlugin {
|
||||
app.add_message::<WindowResized>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<HintVisualEvent>()
|
||||
.add_systems(Startup, load_background_images.before(setup_table))
|
||||
.add_systems(Startup, setup_table)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -63,7 +76,50 @@ impl Plugin for TablePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the 5 background PNG files at startup and stores their
|
||||
/// [`Handle<Image>`]s in [`BackgroundImageSet`].
|
||||
///
|
||||
/// The PNGs are embedded at compile time via `include_bytes!()`. If a file
|
||||
/// is missing the build will fail with a clear error rather than a runtime
|
||||
/// panic.
|
||||
fn load_background_images(images: Option<ResMut<Assets<Image>>>, mut commands: Commands) {
|
||||
let Some(mut images) = images else {
|
||||
// Assets<Image> is absent (e.g. MinimalPlugins in tests) — insert an
|
||||
// empty set so setup_table can proceed using a default handle.
|
||||
commands.insert_resource(BackgroundImageSet { handles: Vec::new() });
|
||||
return;
|
||||
};
|
||||
const BG_BYTES: [&[u8]; 5] = [
|
||||
include_bytes!("../../assets/backgrounds/bg_0.png"),
|
||||
include_bytes!("../../assets/backgrounds/bg_1.png"),
|
||||
include_bytes!("../../assets/backgrounds/bg_2.png"),
|
||||
include_bytes!("../../assets/backgrounds/bg_3.png"),
|
||||
include_bytes!("../../assets/backgrounds/bg_4.png"),
|
||||
];
|
||||
let handles = BG_BYTES
|
||||
.iter()
|
||||
.map(|bytes| {
|
||||
use bevy::image::{CompressedImageFormats, ImageSampler, ImageType};
|
||||
let image = Image::from_buffer(
|
||||
bytes,
|
||||
ImageType::Extension("png"),
|
||||
CompressedImageFormats::NONE,
|
||||
true,
|
||||
ImageSampler::default(),
|
||||
bevy::asset::RenderAssetUsages::RENDER_WORLD,
|
||||
)
|
||||
.expect("valid background PNG");
|
||||
images.add(image)
|
||||
})
|
||||
.collect();
|
||||
commands.insert_resource(BackgroundImageSet { handles });
|
||||
}
|
||||
|
||||
/// Returns the felt colour for a given theme.
|
||||
///
|
||||
/// Only used in tests — the runtime path now picks a PNG image via
|
||||
/// [`BackgroundImageSet`] rather than a solid colour.
|
||||
#[cfg(test)]
|
||||
fn theme_colour(theme: &Theme) -> Color {
|
||||
match theme {
|
||||
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
||||
@@ -74,6 +130,10 @@ fn theme_colour(theme: &Theme) -> Color {
|
||||
|
||||
/// Effective table background colour: unlocked background index overrides the
|
||||
/// Theme when `selected_background > 0`.
|
||||
///
|
||||
/// Only used in tests — the runtime path now picks a PNG image via
|
||||
/// [`BackgroundImageSet`] rather than a solid colour.
|
||||
#[cfg(test)]
|
||||
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
|
||||
match selected_background {
|
||||
0 => theme_colour(theme),
|
||||
@@ -93,6 +153,7 @@ fn setup_table(
|
||||
windows: Query<&Window>,
|
||||
existing_camera: Query<(), With<Camera>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
bg_images: Option<Res<BackgroundImageSet>>,
|
||||
) {
|
||||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||||
// may have added one in tests).
|
||||
@@ -107,23 +168,34 @@ fn setup_table(
|
||||
.unwrap_or(Vec2::new(1280.0, 800.0));
|
||||
let layout = compute_layout(window_size);
|
||||
|
||||
let initial_colour = settings
|
||||
let selected_bg = settings
|
||||
.as_ref()
|
||||
.map(|s| effective_background_colour(&s.0.theme, s.0.selected_background))
|
||||
.unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]));
|
||||
.map(|s| s.0.selected_background)
|
||||
.unwrap_or(0);
|
||||
|
||||
spawn_background(&mut commands, window_size, initial_colour);
|
||||
let image_handle = bg_images
|
||||
.as_ref()
|
||||
.and_then(|set| set.handles.get(selected_bg).cloned())
|
||||
.unwrap_or_default();
|
||||
|
||||
spawn_background(&mut commands, window_size, image_handle);
|
||||
spawn_pile_markers(&mut commands, &layout);
|
||||
commands.insert_resource(LayoutResource(layout));
|
||||
}
|
||||
|
||||
fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
|
||||
// Spawn a felt-coloured rectangle that always covers the window. We give
|
||||
// it the window size plus headroom so resizing up doesn't expose edges
|
||||
// before the resize handler runs.
|
||||
/// Spawns the felt background sprite using a PNG image handle.
|
||||
///
|
||||
/// The sprite covers the window at twice the window size so brief resize gaps
|
||||
/// are never visible. The image is tinted `Color::WHITE` (no tint) so the PNG
|
||||
/// pixel data is rendered as-is.
|
||||
fn spawn_background(commands: &mut Commands, window_size: Vec2, image: Handle<Image>) {
|
||||
// Spawn a sprite covering the window. We give it the window size plus
|
||||
// headroom so resizing up doesn't expose edges before the resize handler
|
||||
// runs.
|
||||
commands.spawn((
|
||||
Sprite {
|
||||
color,
|
||||
image,
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(window_size * 2.0),
|
||||
..default()
|
||||
},
|
||||
@@ -132,16 +204,30 @@ fn spawn_background(commands: &mut Commands, window_size: Vec2, color: Color) {
|
||||
));
|
||||
}
|
||||
|
||||
/// Reacts to settings changes by updating the background sprite's image handle.
|
||||
///
|
||||
/// When [`BackgroundImageSet`] is available the selected PNG handle is applied
|
||||
/// directly (color is kept at `Color::WHITE` so the PNG pixel data shows
|
||||
/// unmodified). If the resource is not yet ready the sprite is left unchanged.
|
||||
fn apply_theme_on_settings_change(
|
||||
mut events: MessageReader<SettingsChangedEvent>,
|
||||
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||||
bg_images: Option<Res<BackgroundImageSet>>,
|
||||
) {
|
||||
let Some(ev) = events.read().last() else {
|
||||
return;
|
||||
};
|
||||
let colour = effective_background_colour(&ev.0.theme, ev.0.selected_background);
|
||||
let Some(set) = bg_images else {
|
||||
// BackgroundImageSet not ready yet — leave sprite unchanged.
|
||||
return;
|
||||
};
|
||||
let selected = ev.0.selected_background;
|
||||
let Some(handle) = set.handles.get(selected).cloned() else {
|
||||
return;
|
||||
};
|
||||
for mut sprite in &mut backgrounds {
|
||||
sprite.color = colour;
|
||||
sprite.image = handle.clone();
|
||||
sprite.color = Color::WHITE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user