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>
7.5 KiB
Phase 3 — Bevy Rendering & Interaction
Status: In progress (started 2026-04-23) Crate:
solitaire_engineDepends 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, typesresources.rsGameStateResource(pub GameState)— wrapssolitaire_core::GameStatedirectly (nosolitaire_datalayer yet)DragState { cards: Vec<u32>, origin_pile: PileType, cursor_offset: Vec2, origin_z: f32 }(starts empty)SyncStatusResource(pub SyncStatus)whereSyncStatusisIdle|Syncing|LastSynced(DateTime<Utc>)|Error(String)
events.rsMoveRequestEvent { from: PileType, to: PileType, count: usize }DrawRequestEventUndoRequestEventNewGameRequestEvent { seed: Option<u64> }StateChangedEventGameWonEvent { score: i32, time_seconds: u64 }CardFlippedEvent(pub u32)AchievementUnlockedEvent(pub AchievementRecord)— placeholder, unused until Phase 5
game_plugin.rs—GamePlugin:- On
Startup: initGameStateResource::new(system_time_seed, DrawMode::DrawOne) - Systems:
handle_draw,handle_move,handle_undo,handle_new_game - Each fires
StateChangedEventon success;GameWonEventwhencheck_win()flips to true - Errors: log via
tracing, do not panic
- On
- Register in 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 functioncompute_layout(window: Vec2) -> LayoutLayout { 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 resourcetable_plugin.rs—TablePlugin:- Spawns background rectangle (dark green
#0f5132) - Spawns 13
PileMarkersprite entities for empty-pile placeholders - System
on_window_resized: recomputeLayoutResource, reposition pile markers
- Spawns background rectangle (dark green
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 } StateChangedEventhandler: sync entities withGameStateResource— 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)
- Component
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
- Keyboard system:
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.rswith drag systems:start_drag: on left mouse-down, ray-hit the top card (or run of face-up cards) of a pile; populateDragState; elevate zfollow_cursor: whileDragState.cardsnon-empty, move those entities to cursor position + per-card stack offsetend_drag: on mouse-up, determine target pile; early-validate withcan_place_on_tableau/can_place_on_foundation; fireMoveRequestEvent(backend also validates)- On
MoveErrorviaStateChangedEventnon-emission: snap cards back with a short lerp (usesCardAnimfrom 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, toggleface_upat midpoint, fireCardFlippedEvent - Win cascade: on
GameWonEvent, iterate foundation cards and scheduleCardAnimto random off-screen targets with staggered 0.05s starts - Toast component scaffold: egui popup placeholder, wired to
AchievementUnlockedEvent(no content yet)
- Component
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_coreandsolitaire_syncgain NO new dependencies.- No
unwrap()/panic!()in new Bevy systems — log errors viatracing::warn!and continue. cargo test --workspaceandcargo clippy --workspace -- -D warningsgreen after EVERY sub-phase.- Every commit follows
type(scope): descriptionconvention. - One
Pluginper responsibility; cross-system communication is Events only.
Open questions resolved
- Procedural vs. sourced card art: procedural for Phase 3.
GameStateResourcelayer: wrapssolitaire_core::GameStatedirectly.- Phases 4–8 plugins (Audio/UI/Achievement/Sync): not in Phase 3.
- New-game seed: system time when
None, explicit whenSome(u64). - Commit cadence: one per sub-phase.
Risks
- Bevy 0.15 API drift from older tutorials — verify each API call as written.
bevy_egui0.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
.ttftoassets/fonts/main.ttfas a follow-up (still Phase 3, not 3F).