Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3646d6cad | |||
| 900de7f376 | |||
| 0a87f0f8f2 | |||
| d92b4a8648 | |||
| c393eab17d |
Generated
+1
@@ -6774,6 +6774,7 @@ dependencies = [
|
||||
"bevy",
|
||||
"bevy_egui",
|
||||
"bevy_kira_audio",
|
||||
"chrono",
|
||||
"solitaire_core",
|
||||
"solitaire_data",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Phase 3 — Bevy Rendering & Interaction
|
||||
|
||||
> Status: In progress (started 2026-04-23)
|
||||
> Crate: `solitaire_engine`
|
||||
> Depends on: `solitaire_core` (complete), `bevy = 0.15`, `bevy_egui = 0.30`
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Make the game playable with a graphical interface. This phase takes `solitaire_engine` from an empty stub to a full Bevy rendering + input layer wired to `solitaire_core::GameState`.
|
||||
|
||||
Out of scope (later phases):
|
||||
- Persistence (`StatsSnapshot`, file I/O) — Phase 4
|
||||
- Achievements toast content — Phase 5
|
||||
- Audio — Phase 7
|
||||
- Sync — Phase 8
|
||||
|
||||
---
|
||||
|
||||
## Sub-phases
|
||||
|
||||
### 3A — Plumbing & event wiring
|
||||
|
||||
**Modules under `solitaire_engine/src/`:**
|
||||
|
||||
- `lib.rs` — re-exports plugins, types
|
||||
- `resources.rs`
|
||||
- `GameStateResource(pub GameState)` — wraps `solitaire_core::GameState` directly (no `solitaire_data` layer yet)
|
||||
- `DragState { cards: Vec<u32>, origin_pile: PileType, cursor_offset: Vec2, origin_z: f32 }` (starts empty)
|
||||
- `SyncStatusResource(pub SyncStatus)` where `SyncStatus` is `Idle|Syncing|LastSynced(DateTime<Utc>)|Error(String)`
|
||||
- `events.rs`
|
||||
- `MoveRequestEvent { from: PileType, to: PileType, count: usize }`
|
||||
- `DrawRequestEvent`
|
||||
- `UndoRequestEvent`
|
||||
- `NewGameRequestEvent { seed: Option<u64> }`
|
||||
- `StateChangedEvent`
|
||||
- `GameWonEvent { score: i32, time_seconds: u64 }`
|
||||
- `CardFlippedEvent(pub u32)`
|
||||
- `AchievementUnlockedEvent(pub AchievementRecord)` — placeholder, unused until Phase 5
|
||||
- `game_plugin.rs` — `GamePlugin`:
|
||||
- On `Startup`: init `GameStateResource::new(system_time_seed, DrawMode::DrawOne)`
|
||||
- Systems: `handle_draw`, `handle_move`, `handle_undo`, `handle_new_game`
|
||||
- Each fires `StateChangedEvent` on success; `GameWonEvent` when `check_win()` flips to true
|
||||
- Errors: log via `tracing`, do not panic
|
||||
- Register in [solitaire_app/src/main.rs](../../../solitaire_app/src/main.rs)
|
||||
|
||||
**Tests:** event-routing unit tests that drive `GamePlugin` in a headless `App::new()` and verify resource mutations.
|
||||
|
||||
**Exit:** `cargo test --workspace` green, `cargo clippy --workspace -- -D warnings` clean. Running the app still shows a blank window (no rendering yet), but pressing nothing crashes anything.
|
||||
|
||||
Commit: `feat(engine): add resources, events, and GamePlugin event routing`
|
||||
|
||||
---
|
||||
|
||||
### 3B — Layout + TablePlugin
|
||||
|
||||
**Modules:**
|
||||
|
||||
- `layout.rs` — pure function `compute_layout(window: Vec2) -> Layout`
|
||||
- `Layout { card_size: Vec2, pile_positions: HashMap<PileType, Vec2> }`
|
||||
- card_width = window.x / 9.0
|
||||
- card_height = card_width * 1.4
|
||||
- Row 1: stock, waste, [gap], 4 foundations
|
||||
- Row 2: 7 tableau columns below
|
||||
- `LayoutResource(pub Layout)` — a Bevy resource
|
||||
- `table_plugin.rs` — `TablePlugin`:
|
||||
- Spawns background rectangle (dark green `#0f5132`)
|
||||
- Spawns 13 `PileMarker` sprite entities for empty-pile placeholders
|
||||
- System `on_window_resized`: recompute `LayoutResource`, reposition pile markers
|
||||
|
||||
**Tests:** `compute_layout` at 800×600, 1280×800, 1920×1080 — all 13 piles within bounds, non-overlapping.
|
||||
|
||||
**Exit:** Window shows a green table with 13 translucent pile outlines that resize with the window.
|
||||
|
||||
Commit: `feat(engine): add layout, LayoutResource, and TablePlugin`
|
||||
|
||||
---
|
||||
|
||||
### 3C — CardPlugin rendering (procedural)
|
||||
|
||||
**Decision:** Phase 3 uses procedural cards (rounded white rectangle + rank/suit text). Real PNG assets can be slotted in later by replacing the sprite setup; API shape stays stable.
|
||||
|
||||
**Modules:**
|
||||
|
||||
- `card_plugin.rs` — `CardPlugin`:
|
||||
- Component `CardEntity { card_id: u32 }`
|
||||
- `StateChangedEvent` handler: sync entities with `GameStateResource` — spawn missing, despawn removed, reposition all
|
||||
- Position: `LayoutResource.pile_positions[pile] + Vec3::Z * stack_index`
|
||||
- Face-up: white rect + text of rank+suit glyph (red for hearts/diamonds, black for clubs/spades)
|
||||
- Face-down: blue rect with a subtle pattern overlay
|
||||
- No assets loaded — text uses Bevy's default font (or shipped system font if needed)
|
||||
|
||||
**Exit:** A freshly dealt game renders — stock (24 cards face-down), 7 tableau columns in standard 1/2/3/.../7 face-down + 1 face-up, empty foundations.
|
||||
|
||||
Commit: `feat(engine): add CardPlugin with procedural card rendering`
|
||||
|
||||
---
|
||||
|
||||
### 3D — Keyboard input & click-to-draw
|
||||
|
||||
**Modules:**
|
||||
|
||||
- `input_plugin.rs` — `InputPlugin`:
|
||||
- Keyboard system: `KeyCode::KeyU` → `UndoRequestEvent`, `KeyN` → `NewGameRequestEvent{seed: None}`, `KeyD` → `DrawRequestEvent`, `Escape` → pause-stub event
|
||||
- Mouse system: on left-click, if cursor over stock pile → `DrawRequestEvent`
|
||||
|
||||
**Exit:** Pressing D cycles stock↔waste on-screen; N deals a new game; U undoes.
|
||||
|
||||
Commit: `feat(engine): add InputPlugin with keyboard and stock-click`
|
||||
|
||||
---
|
||||
|
||||
### 3E — Drag & drop
|
||||
|
||||
**Modules:**
|
||||
|
||||
- Extend `input_plugin.rs` with drag systems:
|
||||
- `start_drag`: on left mouse-down, ray-hit the top card (or run of face-up cards) of a pile; populate `DragState`; elevate z
|
||||
- `follow_cursor`: while `DragState.cards` non-empty, move those entities to cursor position + per-card stack offset
|
||||
- `end_drag`: on mouse-up, determine target pile; early-validate with `can_place_on_tableau` / `can_place_on_foundation`; fire `MoveRequestEvent` (backend also validates)
|
||||
- On `MoveError` via `StateChangedEvent` non-emission: snap cards back with a short lerp (uses `CardAnim` from 3F)
|
||||
- Multi-card tableau drag: grabbing card N pulls N..=top if all face-up
|
||||
|
||||
**Exit:** Full game playable with mouse. `GameWonEvent` fires on a win. No animations yet on invalid drop (just snap back instantly in 3E, smooth in 3F).
|
||||
|
||||
Commit: `feat(engine): add drag-and-drop input with multi-card tableau support`
|
||||
|
||||
---
|
||||
|
||||
### 3F — AnimationPlugin (polish)
|
||||
|
||||
**Modules:**
|
||||
|
||||
- `animation_plugin.rs` — `AnimationPlugin`:
|
||||
- Component `CardAnim { start: Vec3, target: Vec3, elapsed: f32, duration: f32 }` — linear lerp 0.15s for moves
|
||||
- Flip: `CardFlip { elapsed: f32, duration: f32, flips_to_face_up: bool }` — scale-X 1→0→1 over 0.2s, toggle `face_up` at midpoint, fire `CardFlippedEvent`
|
||||
- Win cascade: on `GameWonEvent`, iterate foundation cards and schedule `CardAnim` to random off-screen targets with staggered 0.05s starts
|
||||
- Toast component scaffold: egui popup placeholder, wired to `AchievementUnlockedEvent` (no content yet)
|
||||
|
||||
**Exit:** Valid moves animate smoothly; flipping a tableau card shows a flip; winning plays a cascade.
|
||||
|
||||
Commit: `feat(engine): add AnimationPlugin with slide, flip, and win cascade`
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting rules
|
||||
|
||||
- `solitaire_core` and `solitaire_sync` gain NO new dependencies.
|
||||
- No `unwrap()` / `panic!()` in new Bevy systems — log errors via `tracing::warn!` and continue.
|
||||
- `cargo test --workspace` and `cargo clippy --workspace -- -D warnings` green after EVERY sub-phase.
|
||||
- Every commit follows `type(scope): description` convention.
|
||||
- One `Plugin` per responsibility; cross-system communication is Events only.
|
||||
|
||||
---
|
||||
|
||||
## Open questions resolved
|
||||
|
||||
- **Procedural vs. sourced card art**: procedural for Phase 3.
|
||||
- **`GameStateResource` layer**: wraps `solitaire_core::GameState` directly.
|
||||
- **Phases 4–8 plugins** (Audio/UI/Achievement/Sync): not in Phase 3.
|
||||
- **New-game seed**: system time when `None`, explicit when `Some(u64)`.
|
||||
- **Commit cadence**: one per sub-phase.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- Bevy 0.15 API drift from older tutorials — verify each API call as written.
|
||||
- `bevy_egui` 0.30 may require slightly different system ordering than earlier versions — pin to workspace versions, don't downgrade.
|
||||
- Procedural card text depends on Bevy's default font; if rendering is unreadable, drop in a `.ttf` to `assets/fonts/main.ttf` as a follow-up (still Phase 3, not 3F).
|
||||
@@ -1,4 +1,5 @@
|
||||
use bevy::prelude::*;
|
||||
use solitaire_engine::{CardPlugin, GamePlugin, InputPlugin, TablePlugin};
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
@@ -12,5 +13,9 @@ fn main() {
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ bevy_egui = { workspace = true }
|
||||
bevy_kira_audio = { workspace = true }
|
||||
solitaire_core = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Procedural card rendering.
|
||||
//!
|
||||
//! Each card is a parent entity with a coloured body `Sprite` and a child
|
||||
//! `Text2d` showing rank+suit. Entities are synced with `GameStateResource`
|
||||
//! on every `StateChangedEvent`: missing cards are spawned, present cards
|
||||
//! are repositioned/updated in place, and stale cards are despawned.
|
||||
//!
|
||||
//! Phase 3 uses ASCII rank letters ("A", "2"…"10", "J", "Q", "K") and ASCII
|
||||
//! suit letters ("C", "D", "H", "S") so rendering does not depend on the
|
||||
//! bundled font carrying Unicode suit glyphs. When real card art lands in a
|
||||
//! later phase, this plugin is replaced — the `CardEntity` marker and the
|
||||
//! "sync on StateChangedEvent" contract stay the same.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Fraction of card height used as vertical offset between stacked tableau cards.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
|
||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||
/// non-tableau piles, so stacking is visible.
|
||||
const STACK_FAN_FRAC: f32 = 0.003;
|
||||
|
||||
/// Font size as a fraction of card width.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
|
||||
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||
const CARD_BACK_COLOUR: Color = Color::srgb(0.15, 0.30, 0.55);
|
||||
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||
|
||||
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct CardEntity {
|
||||
pub card_id: u32,
|
||||
}
|
||||
|
||||
/// Marker for the text child inside a card entity.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardLabel;
|
||||
|
||||
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||||
pub struct CardPlugin;
|
||||
|
||||
impl Plugin for CardPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||
// LayoutResource before we try to read it.
|
||||
app.add_systems(PostStartup, sync_cards_startup)
|
||||
.add_systems(Update, sync_cards_on_change.after(GameMutation));
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
||||
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
||||
/// have already completed.
|
||||
fn sync_cards_startup(
|
||||
commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
entities: Query<(Entity, &CardEntity)>,
|
||||
) {
|
||||
if let Some(layout) = layout {
|
||||
sync_cards(commands, &game.0, &layout.0, &entities);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_cards_on_change(
|
||||
mut events: EventReader<StateChangedEvent>,
|
||||
commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
entities: Query<(Entity, &CardEntity)>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(layout) = layout {
|
||||
sync_cards(commands, &game.0, &layout.0, &entities);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_cards(
|
||||
mut commands: Commands,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
entities: &Query<(Entity, &CardEntity)>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
// Map card_id -> Entity for in-place updates.
|
||||
let mut existing: HashMap<u32, Entity> = HashMap::new();
|
||||
for (entity, marker) in entities.iter() {
|
||||
existing.insert(marker.card_id, entity);
|
||||
}
|
||||
|
||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||
|
||||
// Despawn any entity whose card is no longer tracked.
|
||||
for (card_id, entity) in &existing {
|
||||
if !live_ids.contains(card_id) {
|
||||
commands.entity(*entity).despawn_recursive();
|
||||
}
|
||||
}
|
||||
|
||||
// For each card in the current state: spawn or update its entity.
|
||||
for (card, position, z) in positions {
|
||||
match existing.get(&card.id) {
|
||||
Some(&entity) => update_card_entity(&mut commands, entity, &card, position, z, layout),
|
||||
None => spawn_card_entity(&mut commands, &card, position, z, layout),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an ordered vec of (card, position, z) for every card in the game.
|
||||
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
let piles = [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
for pile_type in piles {
|
||||
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
||||
continue;
|
||||
};
|
||||
let Some(pile) = game.piles.get(&pile_type) else {
|
||||
continue;
|
||||
};
|
||||
let is_tableau = matches!(pile_type, PileType::Tableau(_));
|
||||
let fan_y = if is_tableau {
|
||||
-layout.card_size.y * TABLEAU_FAN_FRAC
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
for (i, card) in pile.cards.iter().enumerate() {
|
||||
let pos = Vec2::new(base.x, base.y + fan_y * i as f32);
|
||||
let z = 1.0 + (i as f32) * STACK_FAN_FRAC;
|
||||
out.push((card.clone(), pos, z));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout) {
|
||||
let body_colour = if card.face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
CARD_BACK_COLOUR
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
CardEntity { card_id: card.id },
|
||||
Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(layout.card_size),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
CardLabel,
|
||||
Text2d::new(label_for(card)),
|
||||
TextFont {
|
||||
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card)),
|
||||
// Above the card body on z so it doesn't get occluded by the
|
||||
// parent sprite in back-to-front rendering.
|
||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||
label_visibility(card),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn update_card_entity(
|
||||
commands: &mut Commands,
|
||||
entity: Entity,
|
||||
card: &Card,
|
||||
pos: Vec2,
|
||||
z: f32,
|
||||
layout: &Layout,
|
||||
) {
|
||||
let body_colour = if card.face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
CARD_BACK_COLOUR
|
||||
};
|
||||
|
||||
commands.entity(entity).insert((
|
||||
Sprite {
|
||||
color: body_colour,
|
||||
custom_size: Some(layout.card_size),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
));
|
||||
|
||||
// Despawn the old label child and respawn a fresh one, so rank/suit/
|
||||
// colour/visibility all stay in sync with the card's current state.
|
||||
commands.entity(entity).despawn_descendants();
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
CardLabel,
|
||||
Text2d::new(label_for(card)),
|
||||
TextFont {
|
||||
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||
label_visibility(card),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn label_for(card: &Card) -> String {
|
||||
let rank = match card.rank {
|
||||
Rank::Ace => "A",
|
||||
Rank::Two => "2",
|
||||
Rank::Three => "3",
|
||||
Rank::Four => "4",
|
||||
Rank::Five => "5",
|
||||
Rank::Six => "6",
|
||||
Rank::Seven => "7",
|
||||
Rank::Eight => "8",
|
||||
Rank::Nine => "9",
|
||||
Rank::Ten => "10",
|
||||
Rank::Jack => "J",
|
||||
Rank::Queen => "Q",
|
||||
Rank::King => "K",
|
||||
};
|
||||
let suit = match card.suit {
|
||||
Suit::Clubs => "C",
|
||||
Suit::Diamonds => "D",
|
||||
Suit::Hearts => "H",
|
||||
Suit::Spades => "S",
|
||||
};
|
||||
format!("{rank}{suit}")
|
||||
}
|
||||
|
||||
fn text_colour(card: &Card) -> Color {
|
||||
if card.suit.is_red() {
|
||||
RED_SUIT_COLOUR
|
||||
} else {
|
||||
BLACK_SUIT_COLOUR
|
||||
}
|
||||
}
|
||||
|
||||
fn label_visibility(card: &Card) -> Visibility {
|
||||
if card.face_up {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_for_ace_of_hearts_is_ah() {
|
||||
let c = Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
assert_eq!(label_for(&c), "AH");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_for_ten_of_clubs_is_10c() {
|
||||
let c = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ten,
|
||||
face_up: true,
|
||||
};
|
||||
assert_eq!(label_for(&c), "10C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_colour_is_red_for_hearts_and_diamonds() {
|
||||
let h = Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let d = Card {
|
||||
id: 0,
|
||||
suit: Suit::Diamonds,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
assert_eq!(text_colour(&h), RED_SUIT_COLOUR);
|
||||
assert_eq!(text_colour(&d), RED_SUIT_COLOUR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_colour_is_black_for_clubs_and_spades() {
|
||||
let c = Card {
|
||||
id: 0,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let s = Card {
|
||||
id: 0,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
assert_eq!(text_colour(&c), BLACK_SUIT_COLOUR);
|
||||
assert_eq!(text_colour(&s), BLACK_SUIT_COLOUR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_plugin_spawns_all_52_cards() {
|
||||
let mut app = app();
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&CardEntity>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_face_down_cards_start_hidden_label() {
|
||||
let mut app = app();
|
||||
// Every tableau column except column 0 has face-down cards. Count
|
||||
// CardLabels with Visibility::Hidden — should equal 0+1+2+3+4+5+6 = 21
|
||||
// (every tableau card except the top of each column is face-down).
|
||||
let hidden_count = app
|
||||
.world_mut()
|
||||
.query::<(&CardLabel, &Visibility)>()
|
||||
.iter(app.world())
|
||||
.filter(|(_, v)| matches!(v, Visibility::Hidden))
|
||||
.count();
|
||||
// 21 tableau face-down + 24 stock face-down = 45.
|
||||
assert_eq!(hidden_count, 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_changed_event_triggers_resync() {
|
||||
let mut app = app();
|
||||
// Trigger a draw, which moves a card from stock to waste and should
|
||||
// flip it face-up. Count visible labels after.
|
||||
app.world_mut().send_event(crate::events::DrawRequestEvent);
|
||||
app.update();
|
||||
// Now 1 card in waste (face-up), 23 in stock (face-down). So 24
|
||||
// hidden labels total in stock, plus 21 in tableau = 44.
|
||||
let hidden_count = app
|
||||
.world_mut()
|
||||
.query::<(&CardLabel, &Visibility)>()
|
||||
.iter(app.world())
|
||||
.filter(|(_, v)| matches!(v, Visibility::Hidden))
|
||||
.count();
|
||||
assert_eq!(hidden_count, 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_positions_includes_all_52_cards() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let layout =
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let positions = card_positions(&g, &layout);
|
||||
assert_eq!(positions.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
let layout =
|
||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
// Collect positions for Tableau(6) (should have 7 cards).
|
||||
let tableau_6_base = layout.pile_positions[&PileType::Tableau(6)];
|
||||
let mut ys: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(_, pos, _)| (pos.x - tableau_6_base.x).abs() < 1e-3)
|
||||
.map(|(_, pos, _)| pos.y)
|
||||
.collect();
|
||||
ys.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||
assert_eq!(ys.len(), 7);
|
||||
// Every subsequent card should be strictly lower.
|
||||
for w in ys.windows(2) {
|
||||
assert!(w[0] > w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Cross-system events used by the engine's plugins.
|
||||
|
||||
use bevy::prelude::Event;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||
/// consumed by `GamePlugin`.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct MoveRequestEvent {
|
||||
pub from: PileType,
|
||||
pub to: PileType,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Request to draw from the stock (or recycle waste when stock is empty).
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct DrawRequestEvent;
|
||||
|
||||
/// Request to undo the most recent state change.
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct UndoRequestEvent;
|
||||
|
||||
/// Request to start a new game. `seed = None` uses a system-time seed.
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct NewGameRequestEvent {
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
|
||||
/// score-display systems listen for this to refresh.
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct StateChangedEvent;
|
||||
|
||||
/// Fired once when the active game transitions to won.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct GameWonEvent {
|
||||
pub score: i32,
|
||||
pub time_seconds: u64,
|
||||
}
|
||||
|
||||
/// Fired when a card's face-up state changes during gameplay.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Routes game-request events to `solitaire_core::GameState` and emits
|
||||
//! state-change notifications.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||
UndoRequestEvent,
|
||||
};
|
||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||
|
||||
/// System set for `GamePlugin`'s state-mutating systems. Downstream plugins
|
||||
/// that read the resulting `StateChangedEvent` should schedule themselves
|
||||
/// `.after(GameMutation)` so updates propagate within a single frame.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GameMutation;
|
||||
|
||||
/// Registers game resources, events, and the systems that route user intent
|
||||
/// (events) into mutations on `GameState`.
|
||||
pub struct GamePlugin;
|
||||
|
||||
impl Plugin for GamePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
seed_from_system_time(),
|
||||
DrawMode::DrawOne,
|
||||
)))
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.add_event::<MoveRequestEvent>()
|
||||
.add_event::<DrawRequestEvent>()
|
||||
.add_event::<UndoRequestEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<StateChangedEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<crate::events::CardFlippedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_new_game,
|
||||
handle_draw,
|
||||
handle_move,
|
||||
handle_undo,
|
||||
)
|
||||
.chain()
|
||||
.in_set(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_from_system_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn handle_new_game(
|
||||
mut new_game: EventReader<NewGameRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
) {
|
||||
for ev in new_game.read() {
|
||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
let draw_mode = game.0.draw_mode.clone();
|
||||
game.0 = GameState::new(seed, draw_mode);
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_draw(
|
||||
mut draws: EventReader<DrawRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
) {
|
||||
for _ in draws.read() {
|
||||
match game.0.draw() {
|
||||
Ok(()) => {
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
Err(e) => warn!("draw rejected: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_move(
|
||||
mut moves: EventReader<MoveRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut won: EventWriter<GameWonEvent>,
|
||||
) {
|
||||
for ev in moves.read() {
|
||||
let was_won = game.0.is_won;
|
||||
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
|
||||
Ok(()) => {
|
||||
changed.send(StateChangedEvent);
|
||||
if !was_won && game.0.is_won {
|
||||
won.send(GameWonEvent {
|
||||
score: game.0.score,
|
||||
time_seconds: game.0.elapsed_seconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("move rejected {:?} -> {:?} x{}: {e}", ev.from, ev.to, ev.count),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_undo(
|
||||
mut undos: EventReader<UndoRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
) {
|
||||
for _ in undos.read() {
|
||||
match game.0.undo() {
|
||||
Ok(()) => {
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
Err(e) => warn!("undo rejected: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
||||
/// Overrides the default random seed so tests are deterministic.
|
||||
fn test_app(seed: u64) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
|
||||
// Override the system-time seed with a known value.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0 = GameState::new(seed, DrawMode::DrawOne);
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_inserts_game_state_resource() {
|
||||
let app = test_app(1);
|
||||
assert!(app.world().get_resource::<GameStateResource>().is_some());
|
||||
assert!(app.world().get_resource::<DragState>().is_some());
|
||||
assert!(app.world().get_resource::<SyncStatusResource>().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_request_advances_game_state() {
|
||||
let mut app = test_app(42);
|
||||
let stock_before = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles[&PileType::Stock]
|
||||
.cards
|
||||
.len();
|
||||
|
||||
app.world_mut().send_event(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
let stock_after = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles[&PileType::Stock]
|
||||
.cards
|
||||
.len();
|
||||
let waste_after = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles[&PileType::Waste]
|
||||
.cards
|
||||
.len();
|
||||
assert_eq!(stock_after, stock_before - 1);
|
||||
assert_eq!(waste_after, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_request_fires_state_changed_event() {
|
||||
let mut app = test_app(42);
|
||||
app.world_mut().send_event(DrawRequestEvent);
|
||||
app.update();
|
||||
let events = app.world().resource::<Events<StateChangedEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
assert!(reader.read(events).next().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_after_draw_restores_state() {
|
||||
let mut app = test_app(42);
|
||||
app.world_mut().send_event(DrawRequestEvent);
|
||||
app.update();
|
||||
app.world_mut().send_event(UndoRequestEvent);
|
||||
app.update();
|
||||
let g = &app.world().resource::<GameStateResource>().0;
|
||||
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
|
||||
assert_eq!(g.piles[&PileType::Waste].cards.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_request_reseeds() {
|
||||
let mut app = test_app(1);
|
||||
let before: Vec<u32> = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles[&PileType::Tableau(0)]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999) });
|
||||
app.update();
|
||||
|
||||
let after: Vec<u32> = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles[&PileType::Tableau(0)]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
assert_ne!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_move_does_not_fire_state_changed() {
|
||||
let mut app = test_app(42);
|
||||
// Stock -> Waste is InvalidDestination; no state change expected.
|
||||
app.world_mut().send_event(MoveRequestEvent {
|
||||
from: PileType::Stock,
|
||||
to: PileType::Waste,
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
let events = app.world().resource::<Events<StateChangedEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
assert!(reader.read(events).next().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
//! Keyboard + mouse input for the game board.
|
||||
//!
|
||||
//! Keyboard:
|
||||
//! - `U` → `UndoRequestEvent`
|
||||
//! - `N` → `NewGameRequestEvent { seed: None }`
|
||||
//! - `D` → `DrawRequestEvent`
|
||||
//! - `Esc` → logged as a pause placeholder (no event yet; wired up when the
|
||||
//! pause screen lands in a later phase)
|
||||
//!
|
||||
//! Mouse:
|
||||
//! - Left-click on the stock pile (face-down top) → `DrawRequestEvent`
|
||||
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
|
||||
//! the origin pile and whatever pile the cursor is over at release.
|
||||
//! On rejection, the drag cards snap back to their origin via a
|
||||
//! `StateChangedEvent` re-sync.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
|
||||
use crate::events::{
|
||||
DrawRequestEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
const DRAG_Z: f32 = 500.0;
|
||||
|
||||
/// Registers keyboard and mouse input systems.
|
||||
///
|
||||
/// Drag systems run in a fixed order each frame:
|
||||
/// `start_drag` → `follow_drag` → `end_drag`, with `follow_drag` after the
|
||||
/// card-position sync so it overrides resting positions for cards being
|
||||
/// dragged. `end_drag` runs before `GameMutation` so the `MoveRequestEvent`
|
||||
/// it fires is consumed the same frame.
|
||||
pub struct InputPlugin;
|
||||
|
||||
impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_keyboard,
|
||||
handle_stock_click,
|
||||
start_drag,
|
||||
follow_drag,
|
||||
end_drag.before(GameMutation),
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_keyboard(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut undo: EventWriter<UndoRequestEvent>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
mut draw: EventWriter<DrawRequestEvent>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::KeyU) {
|
||||
undo.send(UndoRequestEvent);
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyN) {
|
||||
new_game.send(NewGameRequestEvent { seed: None });
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyD) {
|
||||
draw.send(DrawRequestEvent);
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
// Pause placeholder — the pause screen hooks this up in a later phase.
|
||||
info!("pause requested (not yet wired)");
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stock_click(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
drag: Res<DragState>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut draw: EventWriter<DrawRequestEvent>,
|
||||
) {
|
||||
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
let Some(world) = cursor_world(&windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
|
||||
return;
|
||||
};
|
||||
if point_in_rect(world, stock_pos, layout.0.card_size) {
|
||||
draw.send(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_drag(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut drag: ResMut<DragState>,
|
||||
mut card_transforms: Query<(&CardEntity, &mut Transform)>,
|
||||
) {
|
||||
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
let Some(world) = cursor_world(&windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Don't try to pick up the stock — that's the draw click.
|
||||
let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(&bottom_id) = card_ids.first() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the bottom drag card's current world position so we can compute
|
||||
// the offset between cursor and that card (grab point).
|
||||
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
|
||||
let cursor_offset = bottom_pos - world;
|
||||
|
||||
// Elevate dragged cards to DRAG_Z.
|
||||
for (i, id) in card_ids.iter().enumerate() {
|
||||
if let Some((_, mut transform)) = card_transforms
|
||||
.iter_mut()
|
||||
.find(|(entity, _)| entity.card_id == *id)
|
||||
{
|
||||
transform.translation.z = DRAG_Z + (i as f32) * 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
drag.cards = card_ids;
|
||||
drag.origin_pile = Some(pile);
|
||||
drag.cursor_offset = cursor_offset;
|
||||
drag.origin_z = DRAG_Z;
|
||||
let _ = bottom_id; // retained for clarity, not used further
|
||||
}
|
||||
|
||||
fn follow_drag(
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
drag: Res<DragState>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut card_transforms: Query<(&CardEntity, &mut Transform)>,
|
||||
) {
|
||||
if drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
let Some(world) = cursor_world(&windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let bottom_pos = world + drag.cursor_offset;
|
||||
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
|
||||
|
||||
for (i, id) in drag.cards.iter().enumerate() {
|
||||
if let Some((_, mut transform)) = card_transforms
|
||||
.iter_mut()
|
||||
.find(|(entity, _)| entity.card_id == *id)
|
||||
{
|
||||
transform.translation.x = bottom_pos.x;
|
||||
transform.translation.y = bottom_pos.y + fan * (i as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn end_drag(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut drag: ResMut<DragState>,
|
||||
mut moves: EventWriter<MoveRequestEvent>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
) {
|
||||
if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
let Some(origin) = drag.origin_pile.clone() else {
|
||||
drag.clear();
|
||||
return;
|
||||
};
|
||||
let count = drag.cards.len();
|
||||
|
||||
let world = cursor_world(&windows, &cameras);
|
||||
let target = world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin));
|
||||
|
||||
// Whether we fire a MoveRequestEvent or not, always trigger a resync so
|
||||
// the dragged cards snap back to their resting positions if the move is
|
||||
// rejected (or never fired).
|
||||
let mut fired = false;
|
||||
if let Some(target) = target {
|
||||
if target != origin {
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
PileType::Foundation(suit) => {
|
||||
count == 1
|
||||
&& can_place_on_foundation(
|
||||
&bottom_card,
|
||||
&game.0.piles[&target],
|
||||
*suit,
|
||||
)
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: origin,
|
||||
to: target,
|
||||
count,
|
||||
});
|
||||
fired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drag.clear();
|
||||
|
||||
// Either the move succeeded (GamePlugin will also fire StateChangedEvent)
|
||||
// or it didn't — in both cases we emit one so cards resync to the current
|
||||
// game state. Duplicate events are harmless.
|
||||
changed.send(StateChangedEvent);
|
||||
let _ = fired;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cursor_world(
|
||||
windows: &Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||
) -> Option<Vec2> {
|
||||
let window = windows.get_single().ok()?;
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, camera_transform) = cameras.get_single().ok()?;
|
||||
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||
}
|
||||
|
||||
/// Axis-aligned rectangle hit-test with a center and full size.
|
||||
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
let half = size / 2.0;
|
||||
point.x >= center.x - half.x
|
||||
&& point.x <= center.x + half.x
|
||||
&& point.y >= center.y - half.y
|
||||
&& point.y <= center.y + half.y
|
||||
}
|
||||
|
||||
/// Where a card at `stack_index` in pile `pile` would be rendered.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index: usize) -> Vec2 {
|
||||
let base = layout.pile_positions[&pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
Vec2::new(base.x, base.y + fan * (stack_index as f32))
|
||||
} else {
|
||||
let _ = game;
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn card_by_id(game: &GameState, id: u32) -> Option<solitaire_core::card::Card> {
|
||||
for pile in game.piles.values() {
|
||||
if let Some(card) = pile.cards.iter().find(|c| c.id == id) {
|
||||
return Some(card.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Given a world-space cursor, find the topmost draggable card. Returns
|
||||
/// `(pile, bottom_stack_index, card_ids_bottom_to_top)`.
|
||||
fn find_draggable_at(
|
||||
cursor: Vec2,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
) -> Option<(PileType, usize, Vec<u32>)> {
|
||||
// Search order: waste, foundations, tableau. Stock is skipped (click-to-draw).
|
||||
// Within a pile, we consider cards top-down because the visual top card is drawn last.
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
for pile in piles {
|
||||
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||
continue;
|
||||
};
|
||||
if pile_cards.cards.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||
|
||||
// Iterate from topmost to bottommost so the first hit is the one
|
||||
// visually on top.
|
||||
for i in (0..pile_cards.cards.len()).rev() {
|
||||
let card = &pile_cards.cards[i];
|
||||
if !card.face_up {
|
||||
continue;
|
||||
}
|
||||
let pos = card_position(game, layout, pile.clone(), i);
|
||||
if !point_in_rect(cursor, pos, layout.card_size) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Picked a face-up card. Determine drag range:
|
||||
// - Tableau: cards [i..len), must all be face-up (guaranteed
|
||||
// because tableau never has face-down above face-up).
|
||||
// - Waste / Foundation: only the top card is draggable.
|
||||
let (start, end) = if is_tableau {
|
||||
(i, pile_cards.cards.len())
|
||||
} else {
|
||||
if i != pile_cards.cards.len() - 1 {
|
||||
return None;
|
||||
}
|
||||
(i, i + 1)
|
||||
};
|
||||
let ids: Vec<u32> = pile_cards.cards[start..end].iter().map(|c| c.id).collect();
|
||||
return Some((pile, start, ids));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Pick the drop-target pile whose extended rectangle contains `cursor`.
|
||||
/// Returns `None` if the cursor is outside every pile's rectangle.
|
||||
fn find_drop_target(
|
||||
cursor: Vec2,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
origin: &PileType,
|
||||
) -> Option<PileType> {
|
||||
let piles = [
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
for pile in piles {
|
||||
let (center, size) = pile_drop_rect(&pile, layout, game);
|
||||
if point_in_rect(cursor, center, size) {
|
||||
// Skip origin — dropping onto the source is a no-op.
|
||||
if pile == *origin {
|
||||
continue;
|
||||
}
|
||||
return Some(pile);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Bounding rect used for drop detection. For tableaus this extends
|
||||
/// downward to cover the entire visible fan of cards.
|
||||
fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
|
||||
let center = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let bottom_card_center_y = center.y + fan * (card_count - 1) as f32;
|
||||
let top_edge = center.y + layout.card_size.y / 2.0;
|
||||
let bottom_edge = bottom_card_center_y - layout.card_size.y / 2.0;
|
||||
let span_height = top_edge - bottom_edge;
|
||||
let new_center_y = (top_edge + bottom_edge) / 2.0;
|
||||
return (
|
||||
Vec2::new(center.x, new_center_y),
|
||||
Vec2::new(layout.card_size.x, span_height),
|
||||
);
|
||||
}
|
||||
}
|
||||
(center, layout.card_size)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_inside_returns_true() {
|
||||
let center = Vec2::new(10.0, 20.0);
|
||||
let size = Vec2::new(40.0, 60.0);
|
||||
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_on_edge_returns_true() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
||||
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_outside_returns_false() {
|
||||
let center = Vec2::ZERO;
|
||||
let size = Vec2::new(10.0, 10.0);
|
||||
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
||||
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_top_of_tableau() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
let top_pos = card_position(&game, &layout, PileType::Tableau(6), 6);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||
assert_eq!(result.0, PileType::Tableau(6));
|
||||
assert_eq!(result.1, 6);
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_face_down_cards() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
|
||||
// Tableau 6 has 7 cards; only index 6 is face-up. A cursor over the
|
||||
// position of the bottom face-down card (index 0) should miss —
|
||||
// that card is face-down and the topmost face-up card overlaps at
|
||||
// a different fanned position.
|
||||
let bottom_pos = card_position(&game, &layout, PileType::Tableau(6), 0);
|
||||
// Shift to avoid accidental overlap with the face-up card above it.
|
||||
let below_bottom = bottom_pos - Vec2::new(0.0, layout.card_size.y * 0.4);
|
||||
let result = find_draggable_at(below_bottom, &game, &layout);
|
||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||
// Manually construct a tableau with three face-up cards all stacked.
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 100,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
t0.cards.push(Card {
|
||||
id: 101,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
});
|
||||
t0.cards.push(Card {
|
||||
id: 102,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Jack,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
// Click the middle card (Queen at stack index 1).
|
||||
let pos = card_position(&game, &layout, PileType::Tableau(0), 1);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, PileType::Tableau(0));
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![101, 102]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_non_top_waste_card() {
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let waste = game.piles.get_mut(&PileType::Waste).unwrap();
|
||||
waste.cards.clear();
|
||||
waste.cards.push(Card {
|
||||
id: 200,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
waste.cards.push(Card {
|
||||
id: 201,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Three,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (id 201), with count = 1.
|
||||
let pos = card_position(&game, &layout, PileType::Waste, 0);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, PileType::Waste);
|
||||
assert_eq!(start, 1);
|
||||
assert_eq!(ids, vec![201]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||
let mut game = game;
|
||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
||||
let pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(6));
|
||||
assert_eq!(target, Some(PileType::Tableau(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_drop_target_returns_none_for_origin() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
let pos = layout.pile_positions[&PileType::Tableau(3)];
|
||||
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
|
||||
assert_eq!(target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
// Tableau 6 has 7 cards.
|
||||
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
||||
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
|
||||
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5.
|
||||
let expected = layout.card_size.y * 2.5;
|
||||
assert!(
|
||||
(size.y - expected).abs() < 1e-3,
|
||||
"expected {expected}, got {}",
|
||||
size.y
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
for pile in [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
] {
|
||||
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||
assert_eq!(size, layout.card_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
|
||||
// when the compiler can't see it used.
|
||||
#[allow(dead_code)]
|
||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,24 @@
|
||||
// Bevy plugins are added in Phase 3.
|
||||
// This crate will expose: CardPlugin, TablePlugin, AnimationPlugin,
|
||||
// AudioPlugin, UIPlugin, AchievementPlugin, SyncPlugin, GamePlugin.
|
||||
//! Bevy integration layer for Solitaire Quest.
|
||||
//!
|
||||
//! Currently exposes `GamePlugin` plus the resources and events it owns.
|
||||
//! Additional plugins (`TablePlugin`, `CardPlugin`, `InputPlugin`,
|
||||
//! `AnimationPlugin`, etc.) land in later sub-phases of Phase 3.
|
||||
|
||||
pub mod card_plugin;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod resources;
|
||||
pub mod table_plugin;
|
||||
|
||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||
pub use events::{
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
pub use game_plugin::{GameMutation, GamePlugin};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Bevy resources owned by the engine crate.
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use chrono::{DateTime, Utc};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct GameStateResource(pub GameState);
|
||||
|
||||
/// Tracks an in-progress drag operation.
|
||||
///
|
||||
/// When `cards` is empty there is no active drag. When non-empty, the listed cards
|
||||
/// are being moved by the user and should be rendered at the cursor position.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct DragState {
|
||||
pub cards: Vec<u32>,
|
||||
pub origin_pile: Option<PileType>,
|
||||
pub cursor_offset: Vec2,
|
||||
pub origin_z: f32,
|
||||
}
|
||||
|
||||
impl DragState {
|
||||
/// Returns true when no drag is currently in progress.
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.cards.is_empty()
|
||||
}
|
||||
|
||||
/// Clears the drag state.
|
||||
pub fn clear(&mut self) {
|
||||
self.cards.clear();
|
||||
self.origin_pile = None;
|
||||
self.cursor_offset = Vec2::ZERO;
|
||||
self.origin_z = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Current sync activity — shown in the settings screen.
|
||||
///
|
||||
/// Defined here rather than in `solitaire_data` because it is a UI/runtime
|
||||
/// status value, not part of the persistence layer.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum SyncStatus {
|
||||
#[default]
|
||||
Idle,
|
||||
Syncing,
|
||||
LastSynced(DateTime<Utc>),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Bevy resource wrapping the current `SyncStatus`.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SyncStatusResource(pub SyncStatus);
|
||||
@@ -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