diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 6c02c4d..b222363 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,5 +1,5 @@ use bevy::prelude::*; -use solitaire_engine::GamePlugin; +use solitaire_engine::{GamePlugin, TablePlugin}; fn main() { App::new() @@ -14,5 +14,6 @@ fn main() { }), ) .add_plugins(GamePlugin) + .add_plugins(TablePlugin) .run(); } diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs new file mode 100644 index 0000000..ed3b90d --- /dev/null +++ b/solitaire_engine/src/layout.rs @@ -0,0 +1,212 @@ +//! Pure layout calculation — maps a window size to card size and pile positions. +//! +//! Bevy 2D uses a center-origin coordinate system: `(0, 0)` is the window +//! center, `+y` is up, `+x` is right. + +use std::collections::HashMap; + +use bevy::math::Vec2; +use bevy::prelude::Resource; +use solitaire_core::card::Suit; +use solitaire_core::pile::PileType; + +/// Minimum supported window dimensions. Layout is still computed below this +/// size but cards will be small. +pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0); + +/// Aspect ratio (height / width) of a standard playing card. +const CARD_ASPECT: f32 = 1.4; + +/// Fraction of card height used as vertical padding between the top row and +/// the tableau row. +const VERTICAL_GAP_FRAC: f32 = 0.2; + +/// Table background colour (dark green felt). +pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196]; + +/// Computed board layout for a given window size. +#[derive(Debug, Clone)] +pub struct Layout { + /// Width/height of a single card, in world units. + pub card_size: Vec2, + /// Centre position of each pile, in world coordinates. + pub pile_positions: HashMap, +} + +/// Compute the board layout from a window size. +/// +/// # Geometry +/// - `card_width = window.x / 9.0` — seven tableau columns with eight gaps +/// (two outer margins + six inner). +/// - `card_height = card_width * 1.4`. +/// - Horizontal gap `h_gap = card_width / 4.0`. +/// - Top row (stock, waste, 4 foundations) aligns with tableau columns +/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the +/// waste/stock cluster from the foundations. +pub fn compute_layout(window: Vec2) -> Layout { + let window = window.max(MIN_WINDOW); + + let card_width = window.x / 9.0; + let card_height = card_width * CARD_ASPECT; + let card_size = Vec2::new(card_width, card_height); + + let h_gap = card_width / 4.0; + // With h_gap = card_width/4, total width = 7*card_width + 8*h_gap = 9*card_width. + // Leftmost card's centre sits at: -window.x/2 + h_gap + card_width/2. + let left_edge = -window.x / 2.0; + let col_x = |col: usize| -> f32 { + left_edge + h_gap + card_width / 2.0 + (col as f32) * (card_width + h_gap) + }; + + let vertical_gap = card_height * VERTICAL_GAP_FRAC; + let top_y = window.y / 2.0 - h_gap - card_height / 2.0; + let tableau_y = top_y - card_height - vertical_gap; + + let mut pile_positions: HashMap = HashMap::with_capacity(13); + + pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y)); + pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y)); + + // Column 2 is skipped — visual separation between waste and foundations. + let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; + for (i, suit) in foundation_suits.into_iter().enumerate() { + pile_positions.insert( + PileType::Foundation(suit), + Vec2::new(col_x(3 + i), top_y), + ); + } + + for i in 0..7 { + pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y)); + } + + Layout { + card_size, + pile_positions, + } +} + +/// Bevy resource wrapping the current `Layout`. Recomputed on `WindowResized`. +#[derive(Resource, Debug, Clone)] +pub struct LayoutResource(pub Layout); + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_all_piles_present(layout: &Layout) { + assert!(layout.pile_positions.contains_key(&PileType::Stock)); + assert!(layout.pile_positions.contains_key(&PileType::Waste)); + for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { + assert!( + layout.pile_positions.contains_key(&PileType::Foundation(suit)), + "missing foundation for {:?}", + suit + ); + } + for i in 0..7 { + assert!( + layout.pile_positions.contains_key(&PileType::Tableau(i)), + "missing tableau {i}" + ); + } + assert_eq!(layout.pile_positions.len(), 13); + } + + #[test] + fn layout_has_all_thirteen_piles() { + assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0))); + assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0))); + assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0))); + } + + #[test] + fn card_size_scales_with_window_width() { + let small = compute_layout(Vec2::new(800.0, 600.0)); + let large = compute_layout(Vec2::new(1920.0, 1080.0)); + assert!(large.card_size.x > small.card_size.x); + assert!( + (large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5, + "card aspect ratio should be preserved", + ); + } + + #[test] + fn layout_below_minimum_clamps_to_minimum() { + let below = compute_layout(Vec2::new(400.0, 300.0)); + let at_min = compute_layout(MIN_WINDOW); + assert_eq!(below.card_size, at_min.card_size); + } + + #[test] + fn tableau_columns_are_sorted_left_to_right() { + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + for i in 0..6 { + let lhs = layout.pile_positions[&PileType::Tableau(i)].x; + let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x; + assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1); + } + } + + #[test] + fn top_row_is_above_tableau_row() { + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + let stock_y = layout.pile_positions[&PileType::Stock].y; + let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; + assert!(stock_y > tableau_y); + } + + #[test] + fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + let stock_x = layout.pile_positions[&PileType::Stock].x; + let waste_x = layout.pile_positions[&PileType::Waste].x; + let t0_x = layout.pile_positions[&PileType::Tableau(0)].x; + let t1_x = layout.pile_positions[&PileType::Tableau(1)].x; + assert!((stock_x - t0_x).abs() < 1e-5); + assert!((waste_x - t1_x).abs() < 1e-5); + } + + #[test] + fn foundations_align_with_tableau_cols_3_to_6() { + let layout = compute_layout(Vec2::new(1280.0, 800.0)); + let foundation_suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; + for (i, suit) in foundation_suits.into_iter().enumerate() { + let f_x = layout.pile_positions[&PileType::Foundation(suit)].x; + let t_x = layout.pile_positions[&PileType::Tableau(3 + i)].x; + assert!( + (f_x - t_x).abs() < 1e-5, + "foundation {:?} should align with tableau {}", + suit, + 3 + i + ); + } + } + + #[test] + fn all_piles_fit_inside_window_horizontally() { + for window in [ + Vec2::new(800.0, 600.0), + Vec2::new(1280.0, 800.0), + Vec2::new(1920.0, 1080.0), + ] { + let layout = compute_layout(window); + let half_w = window.x / 2.0; + let half_card = layout.card_size.x / 2.0; + for (pile, pos) in &layout.pile_positions { + assert!( + pos.x - half_card >= -half_w - 1e-3, + "{:?} overflows left at window {:?}", + pile, + window + ); + assert!( + pos.x + half_card <= half_w + 1e-3, + "{:?} overflows right at window {:?}", + pile, + window + ); + } + } + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index bd4ac60..0c25140 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -6,11 +6,15 @@ pub mod events; pub mod game_plugin; +pub mod layout; pub mod resources; +pub mod table_plugin; pub use events::{ CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, }; pub use game_plugin::GamePlugin; +pub use layout::{compute_layout, Layout, LayoutResource}; pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource}; +pub use table_plugin::{PileMarker, TableBackground, TablePlugin}; diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs new file mode 100644 index 0000000..0d4824e --- /dev/null +++ b/solitaire_engine/src/table_plugin.rs @@ -0,0 +1,204 @@ +//! 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::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR}; + +/// 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::() + .add_systems(Startup, setup_table) + .add_systems(Update, on_window_resized); + } +} + +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>, +) { + // 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); + + spawn_background(&mut commands, window_size); + spawn_pile_markers(&mut commands, &layout); + commands.insert_resource(LayoutResource(layout)); +} + +fn spawn_background(commands: &mut Commands, window_size: Vec2) { + // 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: Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]), + custom_size: Some(window_size * 2.0), + ..default() + }, + Transform::from_xyz(0.0, 0.0, Z_BACKGROUND), + TableBackground, + )); +} + +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 = 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, + 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; + } + } +} + +#[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::().is_some()); + } + + #[test] + fn every_pile_marker_has_unique_type() { + let mut app = headless_app(); + let mut types: Vec = 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); + } +}