feat(engine): add resources, events, and GamePlugin event routing
Introduces the plumbing layer for Phase 3: GameStateResource wraps solitaire_core::GameState, DragState tracks in-progress drags, and SyncStatusResource holds runtime sync status. GamePlugin routes Draw/Move/Undo/NewGame request events into GameState and emits StateChangedEvent and GameWonEvent for downstream systems. Also adds the Phase 3 implementation plan under docs/superpowers/plans/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -6774,6 +6774,7 @@ dependencies = [
|
|||||||
"bevy",
|
"bevy",
|
||||||
"bevy_egui",
|
"bevy_egui",
|
||||||
"bevy_kira_audio",
|
"bevy_kira_audio",
|
||||||
|
"chrono",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_data",
|
"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 bevy::prelude::*;
|
||||||
|
use solitaire_engine::GamePlugin;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
App::new()
|
App::new()
|
||||||
@@ -12,5 +13,6 @@ fn main() {
|
|||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ bevy_egui = { workspace = true }
|
|||||||
bevy_kira_audio = { workspace = true }
|
bevy_kira_audio = { workspace = true }
|
||||||
solitaire_core = { workspace = true }
|
solitaire_core = { workspace = true }
|
||||||
solitaire_data = { workspace = true }
|
solitaire_data = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -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,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::<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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
// Bevy plugins are added in Phase 3.
|
//! Bevy integration layer for Solitaire Quest.
|
||||||
// This crate will expose: CardPlugin, TablePlugin, AnimationPlugin,
|
//!
|
||||||
// AudioPlugin, UIPlugin, AchievementPlugin, SyncPlugin, GamePlugin.
|
//! 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};
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user