299e0c6a94
- Card backs: selected_card_back index maps to distinct Color values in card rendering - Backgrounds: selected_background index applied in TablePlugin alongside theme - Both re-render immediately on SettingsChangedEvent - Stats screen now shows Games Lost, Draw 1/3 Wins, and Lifetime Score - Daily challenge win no longer credited if server-supplied target_score or max_time_secs constraints are not met Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
248 lines
7.8 KiB
Rust
248 lines
7.8 KiB
Rust
//! 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 solitaire_data::settings::Theme;
|
||
|
||
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||
|
||
/// 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);
|
||
|
||
/// 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_event::<WindowResized>()
|
||
.add_event::<SettingsChangedEvent>()
|
||
.add_systems(Startup, setup_table)
|
||
.add_systems(Update, (on_window_resized, apply_theme_on_settings_change));
|
||
}
|
||
}
|
||
|
||
/// Returns the felt colour for a given theme.
|
||
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`.
|
||
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<Camera>>,
|
||
settings: Option<Res<SettingsResource>>,
|
||
) {
|
||
// 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(default_window_size)
|
||
.unwrap_or(Vec2::new(1280.0, 800.0));
|
||
let layout = compute_layout(window_size);
|
||
|
||
let initial_colour = 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]));
|
||
|
||
spawn_background(&mut commands, window_size, initial_colour);
|
||
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.
|
||
commands.spawn((
|
||
Sprite {
|
||
color,
|
||
custom_size: Some(window_size * 2.0),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(0.0, 0.0, Z_BACKGROUND),
|
||
TableBackground,
|
||
));
|
||
}
|
||
|
||
fn apply_theme_on_settings_change(
|
||
mut events: EventReader<SettingsChangedEvent>,
|
||
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||
) {
|
||
let Some(ev) = events.read().last() else {
|
||
return;
|
||
};
|
||
let colour = effective_background_colour(&ev.0.theme, ev.0.selected_background);
|
||
for mut sprite in &mut backgrounds {
|
||
sprite.color = colour;
|
||
}
|
||
}
|
||
|
||
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||
let marker_size = layout.card_size;
|
||
|
||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||
piles.push(PileType::Stock);
|
||
piles.push(PileType::Waste);
|
||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||
piles.push(PileType::Foundation(suit));
|
||
}
|
||
for i in 0..7 {
|
||
piles.push(PileType::Tableau(i));
|
||
}
|
||
|
||
for pile in piles {
|
||
let pos = layout.pile_positions[&pile];
|
||
commands.spawn((
|
||
Sprite {
|
||
color: marker_colour,
|
||
custom_size: Some(marker_size),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
||
PileMarker(pile),
|
||
));
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::type_complexity)]
|
||
fn on_window_resized(
|
||
mut events: EventReader<WindowResized>,
|
||
mut layout_res: Option<ResMut<LayoutResource>>,
|
||
mut backgrounds: Query<
|
||
(&mut Sprite, &mut Transform),
|
||
(With<TableBackground>, Without<PileMarker>),
|
||
>,
|
||
mut markers: Query<(&PileMarker, &mut Sprite, &mut Transform), Without<TableBackground>>,
|
||
) {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::game_plugin::GamePlugin;
|
||
|
||
/// Minimal headless app — omits windowing so pile markers are spawned with
|
||
/// the default 1280×800 layout and no camera is created.
|
||
fn headless_app() -> App {
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins)
|
||
.add_plugins(GamePlugin)
|
||
.add_plugins(TablePlugin);
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_spawns_thirteen_pile_markers() {
|
||
let mut app = headless_app();
|
||
let count = app
|
||
.world_mut()
|
||
.query::<&PileMarker>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert_eq!(count, 13);
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_spawns_one_background() {
|
||
let mut app = headless_app();
|
||
let count = app
|
||
.world_mut()
|
||
.query::<&TableBackground>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_inserts_layout_resource() {
|
||
let app = headless_app();
|
||
assert!(app.world().get_resource::<LayoutResource>().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn every_pile_marker_has_unique_type() {
|
||
let mut app = headless_app();
|
||
let mut types: Vec<PileType> = app
|
||
.world_mut()
|
||
.query::<&PileMarker>()
|
||
.iter(app.world())
|
||
.map(|m| m.0.clone())
|
||
.collect();
|
||
types.sort_by_key(|p| format!("{p:?}"));
|
||
types.dedup();
|
||
assert_eq!(types.len(), 13);
|
||
}
|
||
}
|