Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bef7ab3c13 | |||
| 4d2379c426 | |||
| a8a323c6c3 |
@@ -6,7 +6,7 @@ See @ARCHITECTURE.md for full project design, crate responsibilities, data model
|
|||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
```
|
```text
|
||||||
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
|
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
|
||||||
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
||||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
solitaire_data/ # Persistence + SyncProvider trait + server client
|
||||||
@@ -14,7 +14,7 @@ solitaire_engine/ # Bevy ECS systems, components, plugins
|
|||||||
solitaire_server/ # Axum sync server binary
|
solitaire_server/ # Axum sync server binary
|
||||||
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
|
solitaire_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
|
||||||
solitaire_app/ # Thin binary entry point
|
solitaire_app/ # Thin binary entry point
|
||||||
assets/ # Loaded at runtime via Bevy AssetServer only
|
assets/ # Source assets — embedded at compile time via include_bytes!()
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -48,7 +48,7 @@ cargo clippy -p solitaire_core -- -D warnings
|
|||||||
|
|
||||||
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
||||||
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
||||||
- No hardcoded bytes in source. All assets go through Bevy's `AssetServer`.
|
- Assets are embedded at compile time using `include_bytes!()`. No runtime asset loading via `AssetServer`.
|
||||||
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
||||||
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
||||||
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
||||||
@@ -75,7 +75,7 @@ cargo clippy -p solitaire_core -- -D warnings
|
|||||||
|
|
||||||
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
|
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
|
||||||
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
||||||
- All egui screens live in `solitaire_engine::ui`. Never mix egui and Bevy spawn logic in the same system.
|
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
|
||||||
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+423
-1803
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -32,9 +32,8 @@ solitaire_sync = { path = "solitaire_sync" }
|
|||||||
solitaire_data = { path = "solitaire_data" }
|
solitaire_data = { path = "solitaire_data" }
|
||||||
solitaire_engine = { path = "solitaire_engine" }
|
solitaire_engine = { path = "solitaire_engine" }
|
||||||
|
|
||||||
bevy = "0.15"
|
bevy = "0.15"
|
||||||
bevy_egui = "0.30"
|
kira = "0.9"
|
||||||
bevy_kira_audio = "0.21"
|
|
||||||
|
|
||||||
axum = "0.7"
|
axum = "0.7"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||||
|
|||||||
+10
-3
@@ -106,11 +106,18 @@ See the full spec in the master prompt (originally pasted by the user) or in `AR
|
|||||||
## Important Implementation Notes
|
## Important Implementation Notes
|
||||||
|
|
||||||
### Versions (Cargo.toml workspace deps)
|
### Versions (Cargo.toml workspace deps)
|
||||||
- `bevy = "0.15"` (resolved to 0.15.3)
|
|
||||||
- `bevy_egui = "0.30"` (0.30.1)
|
- `bevy = "0.15"` (resolved to 0.15.3) — UI via built-in `bevy::ui`, no bevy_egui
|
||||||
- `bevy_kira_audio = "0.21"` (0.21.0)
|
- `kira = "0.9"` — audio via `kira` crate directly, no bevy_kira_audio or AssetServer
|
||||||
- `rand = "0.8"` — note: `small_rng` feature is NOT enabled; use `StdRng`, not `SmallRng`
|
- `rand = "0.8"` — note: `small_rng` feature is NOT enabled; use `StdRng`, not `SmallRng`
|
||||||
|
|
||||||
|
### Asset strategy
|
||||||
|
|
||||||
|
- No `AssetServer` — assets embedded at compile time using `include_bytes!()`
|
||||||
|
- Fonts: `Font::try_from_bytes(include_bytes!("../assets/fonts/main.ttf"))`
|
||||||
|
- Audio: load from `&[u8]` via `kira` `StaticSoundData::from_cursor()`
|
||||||
|
- Card rendering: procedural (`bevy::prelude::Sprite` + `Text2d`) — no sprite sheets required
|
||||||
|
|
||||||
### Hard rules (from CLAUDE.md)
|
### Hard rules (from CLAUDE.md)
|
||||||
- `solitaire_core` and `solitaire_sync` must NEVER gain Bevy or network dependencies
|
- `solitaire_core` and `solitaire_sync` must NEVER gain Bevy or network dependencies
|
||||||
- No `unwrap()` or `panic!()` in game logic — use `Result<_, MoveError>` everywhere
|
- No `unwrap()` or `panic!()` in game logic — use `Result<_, MoveError>` everywhere
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> Status: In progress (started 2026-04-23)
|
> Status: In progress (started 2026-04-23)
|
||||||
> Crate: `solitaire_engine`
|
> Crate: `solitaire_engine`
|
||||||
> Depends on: `solitaire_core` (complete), `bevy = 0.15`, `bevy_egui = 0.30`
|
> Depends on: `solitaire_core` (complete), `bevy = 0.15` (includes `bevy::ui`), `kira = 0.9` (audio — Phase 3F+)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
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`.
|
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):
|
Out of scope (later phases):
|
||||||
|
|
||||||
- Persistence (`StatsSnapshot`, file I/O) — Phase 4
|
- Persistence (`StatsSnapshot`, file I/O) — Phase 4
|
||||||
- Achievements toast content — Phase 5
|
- Achievements toast content — Phase 5
|
||||||
- Audio — Phase 7
|
- Audio — Phase 7
|
||||||
@@ -136,7 +137,7 @@ Commit: `feat(engine): add drag-and-drop input with multi-card tableau support`
|
|||||||
- Component `CardAnim { start: Vec3, target: Vec3, elapsed: f32, duration: f32 }` — linear lerp 0.15s for moves
|
- 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`
|
- 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
|
- 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)
|
- Toast component scaffold: bevy_ui `Node`/`Text` overlay, wired to `AchievementUnlockedEvent` (no content yet)
|
||||||
|
|
||||||
**Exit:** Valid moves animate smoothly; flipping a tableau card shows a flip; winning plays a cascade.
|
**Exit:** Valid moves animate smoothly; flipping a tableau card shows a flip; winning plays a cascade.
|
||||||
|
|
||||||
@@ -167,5 +168,5 @@ Commit: `feat(engine): add AnimationPlugin with slide, flip, and win cascade`
|
|||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
- Bevy 0.15 API drift from older tutorials — verify each API call as written.
|
- 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, embed a `.ttf` via `include_bytes!()` as a follow-up (still Phase 3, not 3F).
|
||||||
- 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).
|
- `kira` audio API is async-friendly but requires careful thread management — initialise the `AudioManager` once at startup and store it in a Bevy `NonSend` resource.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_engine::{CardPlugin, GamePlugin, InputPlugin, TablePlugin};
|
use solitaire_engine::{AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, TablePlugin};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
App::new()
|
App::new()
|
||||||
@@ -17,5 +17,6 @@ fn main() {
|
|||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
|
.add_plugins(AnimationPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = { workspace = true }
|
bevy = { workspace = true }
|
||||||
bevy_egui = { workspace = true }
|
kira = { 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 }
|
||||||
chrono = { workspace = true }
|
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
//! Smooth animations: card slide (linear lerp), win cascade, achievement toast.
|
||||||
|
//!
|
||||||
|
//! `CardAnim` is the only animation component used by other plugins — import
|
||||||
|
//! it directly when adding animations outside this file.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::card_plugin::CardEntity;
|
||||||
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
|
|
||||||
|
/// Duration of a card slide (move) animation in seconds.
|
||||||
|
pub const SLIDE_SECS: f32 = 0.15;
|
||||||
|
|
||||||
|
const WIN_TOAST_SECS: f32 = 4.0;
|
||||||
|
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
||||||
|
const CASCADE_STAGGER: f32 = 0.05;
|
||||||
|
const CASCADE_DURATION: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Linear-lerp slide animation.
|
||||||
|
///
|
||||||
|
/// After `delay` seconds the card moves from `start` to `target` over
|
||||||
|
/// `duration` seconds. The component removes itself when the slide completes.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct CardAnim {
|
||||||
|
pub start: Vec3,
|
||||||
|
pub target: Vec3,
|
||||||
|
pub elapsed: f32,
|
||||||
|
pub duration: f32,
|
||||||
|
/// Additional wait before the slide begins.
|
||||||
|
pub delay: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on a toast overlay UI node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastOverlay;
|
||||||
|
|
||||||
|
/// Auto-dismiss countdown (seconds remaining). Attached to toast entities.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastTimer(pub f32);
|
||||||
|
|
||||||
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
|
impl Plugin for AnimationPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
// Register the events this plugin consumes so tests that don't include
|
||||||
|
// GamePlugin can still run AnimationPlugin in isolation. Double-registration
|
||||||
|
// is idempotent in Bevy.
|
||||||
|
app.add_event::<GameWonEvent>()
|
||||||
|
.add_event::<AchievementUnlockedEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
advance_card_anims,
|
||||||
|
handle_win_cascade,
|
||||||
|
handle_achievement_toast,
|
||||||
|
tick_toasts,
|
||||||
|
)
|
||||||
|
.after(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_card_anims(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
|
if anim.delay > 0.0 {
|
||||||
|
anim.delay = (anim.delay - dt).max(0.0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
anim.elapsed += dt;
|
||||||
|
let t = (anim.elapsed / anim.duration).min(1.0);
|
||||||
|
transform.translation = anim.start.lerp(anim.target, t);
|
||||||
|
if t >= 1.0 {
|
||||||
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_win_cascade(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut events: EventReader<GameWonEvent>,
|
||||||
|
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let margin = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||||
|
|
||||||
|
// Eight off-screen destinations spread around the window edges.
|
||||||
|
let targets: [Vec3; 8] = [
|
||||||
|
Vec3::new(margin, margin, 300.0),
|
||||||
|
Vec3::new(-margin, margin, 300.0),
|
||||||
|
Vec3::new(margin, -margin, 300.0),
|
||||||
|
Vec3::new(-margin, -margin, 300.0),
|
||||||
|
Vec3::new(0.0, margin, 300.0),
|
||||||
|
Vec3::new(0.0, -margin, 300.0),
|
||||||
|
Vec3::new(margin, 0.0, 300.0),
|
||||||
|
Vec3::new(-margin, 0.0, 300.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
spawn_toast(&mut commands, "You Win!".to_string(), WIN_TOAST_SECS);
|
||||||
|
|
||||||
|
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||||
|
commands.entity(entity).insert(CardAnim {
|
||||||
|
start: transform.translation,
|
||||||
|
target: targets[i % 8],
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: CASCADE_DURATION,
|
||||||
|
delay: i as f32 * CASCADE_STAGGER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_achievement_toast(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut events: EventReader<AchievementUnlockedEvent>,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
format!("Achievement: {}", ev.0),
|
||||||
|
ACHIEVEMENT_TOAST_SECS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick_toasts(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut toasts: Query<(Entity, &mut ToastTimer)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut timer) in &mut toasts {
|
||||||
|
timer.0 -= dt;
|
||||||
|
if timer.0 <= 0.0 {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_toast(commands: &mut Commands, message: String, duration_secs: f32) {
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
ToastOverlay,
|
||||||
|
ToastTimer(duration_secs),
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Percent(25.0),
|
||||||
|
top: Val::Percent(42.0),
|
||||||
|
width: Val::Percent(50.0),
|
||||||
|
padding: UiRect::axes(Val::Px(16.0), Val::Px(10.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(message),
|
||||||
|
TextFont {
|
||||||
|
font_size: 32.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::card_plugin::CardPlugin;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
|
||||||
|
fn app_with_anim() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(CardPlugin)
|
||||||
|
.add_plugins(AnimationPlugin);
|
||||||
|
app.update(); // PostStartup: spawns cards
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_anim_at_half_elapsed_reaches_midpoint() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
let start = Vec3::ZERO;
|
||||||
|
let target = Vec3::new(100.0, 0.0, 0.0);
|
||||||
|
// elapsed = 0.5, duration = 1.0 → t = 0.5 even when dt=0
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
Transform::from_translation(start),
|
||||||
|
CardAnim { start, target, elapsed: 0.5, duration: 1.0, delay: 0.0 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
|
||||||
|
assert!((pos.x - 50.0).abs() < 1e-3, "expected midpoint x=50, got {}", pos.x);
|
||||||
|
assert!(
|
||||||
|
app.world().entity(entity).get::<CardAnim>().is_some(),
|
||||||
|
"animation not yet complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_anim_removed_and_at_target_when_elapsed_equals_duration() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
let target = Vec3::new(10.0, 0.0, 0.0);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
Transform::from_translation(Vec3::ZERO),
|
||||||
|
CardAnim { start: Vec3::ZERO, target, elapsed: 1.0, duration: 1.0, delay: 0.0 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().entity(entity).get::<CardAnim>().is_none(),
|
||||||
|
"CardAnim should be removed when done"
|
||||||
|
);
|
||||||
|
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
|
||||||
|
assert!((pos.x - 10.0).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_anim_does_not_move_during_delay() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
Transform::from_translation(Vec3::ZERO),
|
||||||
|
CardAnim {
|
||||||
|
start: Vec3::ZERO,
|
||||||
|
target: Vec3::new(100.0, 0.0, 0.0),
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: 0.15,
|
||||||
|
delay: 100.0, // large delay — card must not move
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
|
||||||
|
assert!(pos.x.abs() < 1e-3, "card must not move during delay period");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_cascade_adds_anim_to_all_52_cards() {
|
||||||
|
let mut app = app_with_anim();
|
||||||
|
|
||||||
|
let before = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&CardAnim>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(before, 0, "no animations before win");
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.send_event(GameWonEvent { score: 500, time_seconds: 60 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let after = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&CardAnim>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(after, 52, "all 52 cards should have cascade animations");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ use solitaire_core::card::{Card, Rank, Suit};
|
|||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
|
use crate::animation_plugin::{CardAnim, SLIDE_SECS};
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -68,7 +69,7 @@ fn sync_cards_startup(
|
|||||||
commands: Commands,
|
commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
entities: Query<(Entity, &CardEntity)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
sync_cards(commands, &game.0, &layout.0, &entities);
|
sync_cards(commands, &game.0, &layout.0, &entities);
|
||||||
@@ -80,7 +81,7 @@ fn sync_cards_on_change(
|
|||||||
commands: Commands,
|
commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
entities: Query<(Entity, &CardEntity)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -94,20 +95,20 @@ fn sync_cards(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
entities: &Query<(Entity, &CardEntity)>,
|
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
// Map card_id -> Entity for in-place updates.
|
// Map card_id -> (Entity, current_translation) for in-place updates.
|
||||||
let mut existing: HashMap<u32, Entity> = HashMap::new();
|
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
|
||||||
for (entity, marker) in entities.iter() {
|
for (entity, marker, transform) in entities.iter() {
|
||||||
existing.insert(marker.card_id, entity);
|
existing.insert(marker.card_id, (entity, transform.translation));
|
||||||
}
|
}
|
||||||
|
|
||||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||||
|
|
||||||
// Despawn any entity whose card is no longer tracked.
|
// Despawn any entity whose card is no longer tracked.
|
||||||
for (card_id, entity) in &existing {
|
for (card_id, (entity, _)) in &existing {
|
||||||
if !live_ids.contains(card_id) {
|
if !live_ids.contains(card_id) {
|
||||||
commands.entity(*entity).despawn_recursive();
|
commands.entity(*entity).despawn_recursive();
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,9 @@ fn sync_cards(
|
|||||||
// For each card in the current state: spawn or update its entity.
|
// For each card in the current state: spawn or update its entity.
|
||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
match existing.get(&card.id) {
|
match existing.get(&card.id) {
|
||||||
Some(&entity) => update_card_entity(&mut commands, entity, &card, position, z, layout),
|
Some(&(entity, cur)) => {
|
||||||
|
update_card_entity(&mut commands, entity, &card, position, z, layout, cur)
|
||||||
|
}
|
||||||
None => spawn_card_entity(&mut commands, &card, position, z, layout),
|
None => spawn_card_entity(&mut commands, &card, position, z, layout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,6 +209,7 @@ fn update_card_entity(
|
|||||||
pos: Vec2,
|
pos: Vec2,
|
||||||
z: f32,
|
z: f32,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
cur: Vec3,
|
||||||
) {
|
) {
|
||||||
let body_colour = if card.face_up {
|
let body_colour = if card.face_up {
|
||||||
CARD_FACE_COLOUR
|
CARD_FACE_COLOUR
|
||||||
@@ -213,14 +217,34 @@ fn update_card_entity(
|
|||||||
CARD_BACK_COLOUR
|
CARD_BACK_COLOUR
|
||||||
};
|
};
|
||||||
|
|
||||||
commands.entity(entity).insert((
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
Sprite {
|
|
||||||
color: body_colour,
|
// Always refresh the visual appearance.
|
||||||
custom_size: Some(layout.card_size),
|
commands.entity(entity).insert(Sprite {
|
||||||
..default()
|
color: body_colour,
|
||||||
},
|
custom_size: Some(layout.card_size),
|
||||||
Transform::from_xyz(pos.x, pos.y, z),
|
..default()
|
||||||
));
|
});
|
||||||
|
|
||||||
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
|
if (cur.truncate() - target.truncate()).length() > 1.0 {
|
||||||
|
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.insert(Transform::from_translation(start))
|
||||||
|
.insert(CardAnim {
|
||||||
|
start,
|
||||||
|
target,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: SLIDE_SECS,
|
||||||
|
delay: 0.0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<CardAnim>()
|
||||||
|
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||||
|
}
|
||||||
|
|
||||||
// Despawn the old label child and respawn a fresh one, so rank/suit/
|
// 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.
|
// colour/visibility all stay in sync with the card's current state.
|
||||||
|
|||||||
@@ -41,3 +41,9 @@ pub struct GameWonEvent {
|
|||||||
/// Fired when a card's face-up state changes during gameplay.
|
/// Fired when a card's face-up state changes during gameplay.
|
||||||
#[derive(Event, Debug, Clone, Copy)]
|
#[derive(Event, Debug, Clone, Copy)]
|
||||||
pub struct CardFlippedEvent(pub u32);
|
pub struct CardFlippedEvent(pub u32);
|
||||||
|
|
||||||
|
/// Achievement unlocked notification — name of the achievement.
|
||||||
|
///
|
||||||
|
/// Uses `String` as a placeholder; replaced with `AchievementRecord` in Phase 5.
|
||||||
|
#[derive(Event, Debug, Clone)]
|
||||||
|
pub struct AchievementUnlockedEvent(pub String);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ impl Plugin for GamePlugin {
|
|||||||
.add_event::<StateChangedEvent>()
|
.add_event::<StateChangedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<crate::events::CardFlippedEvent>()
|
.add_event::<crate::events::CardFlippedEvent>()
|
||||||
|
.add_event::<crate::events::AchievementUnlockedEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ fn follow_drag(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn end_drag(
|
fn end_drag(
|
||||||
buttons: Res<ButtonInput<MouseButton>>,
|
buttons: Res<ButtonInput<MouseButton>>,
|
||||||
windows: Query<&Window, With<PrimaryWindow>>,
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
@@ -510,8 +511,12 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||||
// Click the middle card (Queen at stack index 1).
|
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||||
let pos = card_position(&game, &layout, PileType::Tableau(0), 1);
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||||
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||||
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||||
|
let queen_center = card_position(&game, &layout, PileType::Tableau(0), 1);
|
||||||
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
assert_eq!(pile, PileType::Tableau(0));
|
assert_eq!(pile, PileType::Tableau(0));
|
||||||
assert_eq!(start, 1);
|
assert_eq!(start, 1);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
//! Bevy integration layer for Solitaire Quest.
|
//! 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 animation_plugin;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
@@ -12,10 +9,11 @@ pub mod layout;
|
|||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
|
|
||||||
|
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent,
|
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRequestEvent,
|
||||||
StateChangedEvent, UndoRequestEvent,
|
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{GameMutation, GamePlugin};
|
pub use game_plugin::{GameMutation, GamePlugin};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
|
|||||||
Reference in New Issue
Block a user