feat(engine): add layout, LayoutResource, and TablePlugin
compute_layout is a pure function that maps window size to card size and the 13 pile positions, with clamping at the 800x600 minimum and seven tableau columns horizontally aligned with stock/waste (cols 0,1) and the four foundations (cols 3,4,5,6). TablePlugin spawns a 2D camera, a felt background sprite, and 13 translucent pile-marker sprites, and repositions them on WindowResized. Plugin registers WindowResized explicitly so it works under MinimalPlugins in tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<PileType, Vec2>,
|
||||
}
|
||||
|
||||
/// 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<PileType, Vec2> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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::<WindowResized>()
|
||||
.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<Camera>>,
|
||||
) {
|
||||
// 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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user