diff --git a/Cargo.lock b/Cargo.lock index 38873fb..326b609 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6774,6 +6774,7 @@ dependencies = [ "bevy", "bevy_egui", "bevy_kira_audio", + "chrono", "solitaire_core", "solitaire_data", ] diff --git a/docs/superpowers/plans/2026-04-23-phase3-bevy-rendering.md b/docs/superpowers/plans/2026-04-23-phase3-bevy-rendering.md new file mode 100644 index 0000000..5e54246 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-phase3-bevy-rendering.md @@ -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, origin_pile: PileType, cursor_offset: Vec2, origin_z: f32 }` (starts empty) + - `SyncStatusResource(pub SyncStatus)` where `SyncStatus` is `Idle|Syncing|LastSynced(DateTime)|Error(String)` +- `events.rs` + - `MoveRequestEvent { from: PileType, to: PileType, count: usize }` + - `DrawRequestEvent` + - `UndoRequestEvent` + - `NewGameRequestEvent { seed: Option }` + - `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 }` + - 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). diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 9a325de..6c02c4d 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,4 +1,5 @@ use bevy::prelude::*; +use solitaire_engine::GamePlugin; fn main() { App::new() @@ -12,5 +13,6 @@ fn main() { ..default() }), ) + .add_plugins(GamePlugin) .run(); } diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 2acaa5e..740414a 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -9,3 +9,4 @@ bevy_egui = { workspace = true } bevy_kira_audio = { workspace = true } solitaire_core = { workspace = true } solitaire_data = { workspace = true } +chrono = { workspace = true } diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs new file mode 100644 index 0000000..dacc35e --- /dev/null +++ b/solitaire_engine/src/events.rs @@ -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, +} + +/// 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); diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs new file mode 100644 index 0000000..8ee013b --- /dev/null +++ b/solitaire_engine/src/game_plugin.rs @@ -0,0 +1,241 @@ +//! 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}; + +/// 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::() + .init_resource::() + .add_event::() + .add_event::() + .add_event::() + .add_event::() + .add_event::() + .add_event::() + .add_event::() + .add_systems( + Update, + ( + handle_new_game, + handle_draw, + handle_move, + handle_undo, + ) + .chain(), + ); + } +} + +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, + mut game: ResMut, + mut changed: EventWriter, +) { + 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, + mut game: ResMut, + mut changed: EventWriter, +) { + for _ in draws.read() { + match game.0.draw() { + Ok(()) => { + changed.send(StateChangedEvent); + } + Err(e) => warn!("draw rejected: {e}"), + } + } +} + +fn handle_move( + mut moves: EventReader, + mut game: ResMut, + mut changed: EventWriter, + mut won: EventWriter, +) { + 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, + mut game: ResMut, + mut changed: EventWriter, +) { + 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::() + .0 = GameState::new(seed, DrawMode::DrawOne); + app + } + + #[test] + fn plugin_inserts_game_state_resource() { + let app = test_app(1); + assert!(app.world().get_resource::().is_some()); + assert!(app.world().get_resource::().is_some()); + assert!(app.world().get_resource::().is_some()); + } + + #[test] + fn draw_request_advances_game_state() { + let mut app = test_app(42); + let stock_before = app + .world() + .resource::() + .0 + .piles[&PileType::Stock] + .cards + .len(); + + app.world_mut().send_event(DrawRequestEvent); + app.update(); + + let stock_after = app + .world() + .resource::() + .0 + .piles[&PileType::Stock] + .cards + .len(); + let waste_after = app + .world() + .resource::() + .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::>(); + 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::().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 = app + .world() + .resource::() + .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 = app + .world() + .resource::() + .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::>(); + let mut reader = events.get_cursor(); + assert!(reader.read(events).next().is_none()); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 3e934e1..bd4ac60 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -1,3 +1,16 @@ -// 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 events; +pub mod game_plugin; +pub mod resources; + +pub use events::{ + CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, + StateChangedEvent, UndoRequestEvent, +}; +pub use game_plugin::GamePlugin; +pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource}; diff --git a/solitaire_engine/src/resources.rs b/solitaire_engine/src/resources.rs new file mode 100644 index 0000000..3e75c0c --- /dev/null +++ b/solitaire_engine/src/resources.rs @@ -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, + pub origin_pile: Option, + 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), + Error(String), +} + +/// Bevy resource wrapping the current `SyncStatus`. +#[derive(Resource, Debug, Clone, Default)] +pub struct SyncStatusResource(pub SyncStatus);