//! Renders the static table: felt background and empty pile markers. //! //! Pile markers are translucent rectangles that sit beneath any cards. They //! remain visible only where a pile is empty, so the player can see where to //! drop cards. All geometry comes from `LayoutResource`. use bevy::prelude::*; use bevy::window::WindowResized; use solitaire_core::card::Suit; use solitaire_core::pile::PileType; use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem}; use crate::resources::GameStateResource; #[cfg(test)] use crate::layout::TABLE_COLOUR; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::ui_theme::TEXT_PRIMARY; #[cfg(test)] use solitaire_data::Theme; /// Default tint applied to every empty-pile marker sprite. Pure white /// at 8% alpha — soft enough that the marker reads as a "hint of a /// slot" rather than a panel, but visible against every felt /// background. /// /// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`, /// which used to duplicate the literal alongside a "kept in sync" doc /// comment. Pulling both call sites through this const makes drift a /// compile error instead of a stale comment. pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08); /// Holds pre-loaded [`Handle`]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>, } /// 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 /// the background. const Z_PILE_MARKER: f32 = -1.0; /// Marker component for the table felt background. #[derive(Component, Debug)] pub struct TableBackground; /// Marker component attached to each of the 13 empty-pile placeholders. #[derive(Component, Debug, Clone)] pub struct PileMarker(pub PileType); /// Attached to a `PileMarker` entity when it has been temporarily tinted gold /// as a hint destination. Stores the remaining countdown and the original sprite /// colour so it can be restored when the timer expires. #[derive(Component, Debug, Clone)] pub struct HintPileHighlight { /// Seconds remaining before the pile marker colour is restored. pub timer: f32, /// The sprite colour the marker had before the hint tint was applied. pub original_color: Color, } /// Registers the table background and pile-marker rendering. pub struct TablePlugin; impl Plugin for TablePlugin { fn build(&self, app: &mut App) { // Register WindowResized so the plugin works under MinimalPlugins in // tests. Under DefaultPlugins, bevy_window has already registered it // and this call is a no-op. app.add_message::() .add_message::() .add_message::() .add_message::() .add_systems(Startup, load_background_images.before(setup_table)) .add_systems(Startup, setup_table) .add_systems( Update, ( on_window_resized.in_set(LayoutSystem::UpdateOnResize), apply_theme_on_settings_change, apply_hint_pile_highlight, tick_hint_pile_highlights, sync_pile_marker_visibility, ), ); } } /// Loads the 5 background PNG files at startup via the Bevy `AssetServer` and /// stores their [`Handle`]s in [`BackgroundImageSet`]. fn load_background_images(asset_server: Option>, mut commands: Commands) { let Some(asset_server) = asset_server else { // AssetServer 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; }; let handles = (0..5) .map(|i| asset_server.load(format!("backgrounds/bg_{i}.png"))) .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]), Theme::Blue => Color::srgb(0.059, 0.196, 0.322), Theme::Dark => Color::srgb(0.08, 0.08, 0.10), } } /// 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), 1 => Color::srgb(0.25, 0.18, 0.10), // dark wood 2 => Color::srgb(0.05, 0.08, 0.22), // navy 3 => Color::srgb(0.30, 0.05, 0.08), // burgundy _ => Color::srgb(0.12, 0.12, 0.14), // charcoal (4+) } } fn default_window_size(window: &Window) -> Vec2 { Vec2::new(window.resolution.width(), window.resolution.height()) } fn setup_table( mut commands: Commands, windows: Query<&Window>, existing_camera: Query<(), With>, settings: Option>, bg_images: Option>, ) { // Only spawn a camera if one does not already exist (e.g. a parent app // may have added one in tests). if existing_camera.is_empty() { commands.spawn(Camera2d); } let window_size = windows .iter() .next() .map_or(Vec2::new(1280.0, 800.0), default_window_size); let layout = compute_layout(window_size); let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background); 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)); } /// 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) { // 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 { image, color: Color::WHITE, custom_size: Some(window_size * 2.0), ..default() }, Transform::from_xyz(0.0, 0.0, Z_BACKGROUND), TableBackground, )); } /// 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, mut backgrounds: Query<&mut Sprite, With>, bg_images: Option>, ) { let Some(ev) = events.read().last() else { return; }; 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.image = handle.clone(); sprite.color = Color::WHITE; } } /// Returns the single-letter suit symbol used on empty foundation markers. /// /// Matches the same ASCII convention used by `CardPlugin` for card labels. pub fn suit_symbol(suit: &Suit) -> &'static str { match suit { Suit::Spades => "S", Suit::Hearts => "H", Suit::Diamonds => "D", Suit::Clubs => "C", } } fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) { let marker_colour = PILE_MARKER_DEFAULT_COLOUR; let marker_size = layout.card_size; let font_size = layout.card_size.x * 0.28; let mut piles: Vec = Vec::with_capacity(13); piles.push(PileType::Stock); piles.push(PileType::Waste); for slot in 0..4_u8 { piles.push(PileType::Foundation(slot)); } for i in 0..7 { piles.push(PileType::Tableau(i)); } for pile in piles { let pos = layout.pile_positions[&pile]; let mut entity = commands.spawn(( Sprite { color: marker_colour, custom_size: Some(marker_size), ..default() }, Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER), PileMarker(pile.clone()), )); // Foundation slots no longer carry a suit letter — any Ace can claim // any empty slot, so a fixed C/D/H/S badge would be misleading. Empty // foundation markers render as plain translucent rectangles. // Task #43 — King indicator on empty tableau placeholders. if let PileType::Tableau(_) = &pile { entity.with_children(|b| { b.spawn(( Text2d::new("K"), TextFont { font_size, ..default() }, TextColor(TEXT_PRIMARY.with_alpha(0.35)), Transform::from_xyz(0.0, 0.0, 0.1), )); }); } } } #[allow(clippy::type_complexity)] fn on_window_resized( mut events: MessageReader, mut layout_res: Option>, mut backgrounds: Query< (&mut Sprite, &mut Transform), (With, Without), >, mut markers: Query<(&PileMarker, &mut Sprite, &mut Transform), Without>, ) { let Some(ev) = events.read().last() else { return; }; let window_size = Vec2::new(ev.width, ev.height); let new_layout = compute_layout(window_size); if let Some(layout_res) = layout_res.as_deref_mut() { layout_res.0 = new_layout.clone(); } for (mut sprite, mut transform) in &mut backgrounds { sprite.custom_size = Some(window_size * 2.0); transform.translation.x = 0.0; transform.translation.y = 0.0; } for (marker, mut sprite, mut transform) in &mut markers { if let Some(pos) = new_layout.pile_positions.get(&marker.0) { sprite.custom_size = Some(new_layout.card_size); transform.translation.x = pos.x; transform.translation.y = pos.y; } } // Card sprites are repositioned by `card_plugin::snap_cards_on_window_resize` // running `.after(LayoutSystem::UpdateOnResize)` — that system snaps card // transforms directly to the new layout instead of going through // `StateChangedEvent → sync_cards → CardAnim` which would retarget the // slide tween every frame during a corner drag (the visible "snap back // and forth" jitter). } // --------------------------------------------------------------------------- // Task #6 — Hint pile-marker highlight // --------------------------------------------------------------------------- /// Gold tint applied to a `PileMarker` sprite when it is the current /// hint destination. Same RGB as the design-system [`STATE_WARNING`] /// token (`#ddb26f`) so the in-game "look here" colour is the same hue /// as every other warning/attention signal in the UI. Spelled as a /// literal because `Alpha::with_alpha` is not yet a `const` trait /// method on stable; the tracking test below pins the RGB to /// `STATE_WARNING` so a future palette swap can't drift the two apart. const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(0.867, 0.698, 0.435); /// Listens for `HintVisualEvent` and tints the matching `PileMarker` entity /// gold for 2 s, storing the original colour in `HintPileHighlight` so it can /// be restored when the timer expires. /// /// If the pile marker already has a `HintPileHighlight` from a previous hint /// press, the timer is reset to 2 s without changing `original_color`. fn apply_hint_pile_highlight( mut events: MessageReader, mut commands: Commands, mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>, ) { for ev in events.read() { for (entity, pile_marker, mut sprite, existing) in pile_markers.iter_mut() { if pile_marker.0 != ev.dest_pile { continue; } let original_color = existing.map_or(sprite.color, |h| h.original_color); sprite.color = HINT_PILE_HIGHLIGHT_COLOUR; commands.entity(entity).insert(HintPileHighlight { timer: 2.0, original_color, }); } } } /// Counts down `HintPileHighlight::timer` each frame and restores the original /// pile marker colour when the timer expires. fn tick_hint_pile_highlights( mut commands: Commands, time: Res