feat(engine): card visual improvements — flip animation, foundation/tableau placeholders, drag shadow

Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed
cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed.

Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation
markers so the suit is visible while the pile is empty.

Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land
on empty columns.

Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a
card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card.

Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin,
cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-27 19:03:59 +00:00
parent 4d132afdc2
commit c3ee7c45a7
10 changed files with 1910 additions and 42 deletions
+49
View File
@@ -95,6 +95,11 @@ pub struct Settings {
/// Set to `true` once the player has dismissed the first-run banner. /// Set to `true` once the player has dismissed the first-run banner.
#[serde(default)] #[serde(default)]
pub first_run_complete: bool, pub first_run_complete: bool,
/// When `true`, red-suit card faces use a blue tint instead of the default
/// cream so they are distinguishable from black-suit cards without relying
/// solely on colour.
#[serde(default)]
pub color_blind_mode: bool,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -121,6 +126,7 @@ impl Default for Settings {
selected_card_back: 0, selected_card_back: 0,
selected_background: 0, selected_background: 0,
first_run_complete: false, first_run_complete: false,
color_blind_mode: false,
} }
} }
} }
@@ -277,12 +283,30 @@ mod tests {
selected_card_back: 0, selected_card_back: 0,
selected_background: 0, selected_background: 0,
first_run_complete: true, first_run_complete: true,
color_blind_mode: false,
}; };
save_settings_to(&path, &s).expect("save"); save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path); let loaded = load_settings_from(&path);
assert_eq!(loaded, s); assert_eq!(loaded, s);
} }
#[test]
fn round_trip_preserves_non_default_cosmetic_selections() {
// selected_card_back and selected_background must survive save→load with
// non-zero values — zero is the default and not a meaningful regression check.
let path = tmp_path("cosmetic_selections");
let _ = fs::remove_file(&path);
let s = Settings {
selected_card_back: 3,
selected_background: 2,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded.selected_card_back, 3);
assert_eq!(loaded.selected_background, 2);
}
#[test] #[test]
fn load_from_missing_file_returns_default() { fn load_from_missing_file_returns_default() {
let path = tmp_path("missing_xyz"); let path = tmp_path("missing_xyz");
@@ -318,5 +342,30 @@ mod tests {
assert_eq!(s.theme, Theme::Green); assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local); assert_eq!(s.sync_backend, SyncBackend::Local);
assert_eq!(s.draw_mode, DrawMode::DrawOne); assert_eq!(s.draw_mode, DrawMode::DrawOne);
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
}
#[test]
fn color_blind_mode_defaults_to_false_when_field_absent() {
// Simulate a JSON file that has no color_blind_mode field.
let json = br#"{ "sfx_volume": 0.7 }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
}
#[test]
fn color_blind_mode_round_trips() {
let path = tmp_path("color_blind");
let _ = std::fs::remove_file(&path);
let s = Settings {
color_blind_mode: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
let _ = std::fs::remove_file(&path);
} }
} }
+458 -13
View File
@@ -19,12 +19,16 @@ use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration}; use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
use crate::events::StateChangedEvent; use crate::events::{CardFlippedEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::table_plugin::PileMarker;
/// Fraction of card height used as vertical offset between face-up tableau cards. /// Fraction of card height used as vertical offset between face-up tableau cards.
pub const TABLEAU_FAN_FRAC: f32 = 0.25; pub const TABLEAU_FAN_FRAC: f32 = 0.25;
@@ -39,9 +43,13 @@ const STACK_FAN_FRAC: f32 = 0.003;
/// Font size as a fraction of card width. /// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28; const FONT_SIZE_FRAC: f32 = 0.28;
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95); pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15); pub 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); pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
/// blue wash that distinguishes them from black-suit cards without colour alone.
const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0);
/// Returns the card back color for the given unlocked card-back index. /// Returns the card back color for the given unlocked card-back index.
/// Index 0 = default blue; 14 are unlockable alternate designs. /// Index 0 = default blue; 14 are unlockable alternate designs.
@@ -65,6 +73,56 @@ pub struct CardEntity {
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct CardLabel; pub struct CardLabel;
/// Marker component indicating the card is currently highlighted as a hint.
/// `remaining` counts down in real seconds; the highlight is removed when it
/// reaches zero and the card sprite colour is restored to its normal value.
#[derive(Component, Debug, Clone)]
pub struct HintHighlight {
/// Seconds remaining before the highlight is cleared.
pub remaining: f32,
}
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
/// card can legally be placed there.
#[derive(Component, Debug)]
pub struct RightClickHighlight;
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation
// ---------------------------------------------------------------------------
/// Phase of the two-stage flip animation.
#[derive(Debug, Clone, PartialEq)]
pub enum FlipPhase {
/// Scale X from 1.0 → 0.0 (hiding the back face).
ScalingDown,
/// Scale X from 0.0 → 1.0 (revealing the front face).
ScalingUp,
}
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
///
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
#[derive(Component, Debug, Clone)]
pub struct CardFlipAnim {
/// Seconds elapsed in the current phase.
pub timer: f32,
/// Which half of the flip we are in.
pub phase: FlipPhase,
}
/// Duration of each half of the flip animation (scale-down or scale-up).
const FLIP_HALF_SECS: f32 = 0.08;
// ---------------------------------------------------------------------------
// Task #38 — Drag-elevation shadow
// ---------------------------------------------------------------------------
/// Marker component for the semi-transparent shadow sprite shown while dragging.
#[derive(Component, Debug)]
pub struct ShadowEntity;
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`. /// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
pub struct CardPlugin; pub struct CardPlugin;
@@ -72,13 +130,24 @@ impl Plugin for CardPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// PostStartup ensures TablePlugin's Startup system has inserted // PostStartup ensures TablePlugin's Startup system has inserted
// LayoutResource before we try to read it. // LayoutResource before we try to read it.
app.add_event::<SettingsChangedEvent>() //
// `handle_right_click` reads `ButtonInput<MouseButton>`. Under
// `MinimalPlugins` (tests) this resource is absent by default, so we
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
app.init_resource::<ButtonInput<MouseButton>>()
.add_event::<SettingsChangedEvent>()
.add_event::<CardFlippedEvent>()
.add_systems(PostStartup, sync_cards_startup) .add_systems(PostStartup, sync_cards_startup)
.add_systems( .add_systems(
Update, Update,
( (
sync_cards_on_change.after(GameMutation), sync_cards_on_change.after(GameMutation),
resync_cards_on_settings_change.before(sync_cards_on_change), resync_cards_on_settings_change.before(sync_cards_on_change),
start_flip_anim.after(GameMutation),
tick_flip_anim,
update_drag_shadow,
tick_hint_highlight,
handle_right_click,
), ),
); );
} }
@@ -111,7 +180,8 @@ fn sync_cards_startup(
let back_colour = settings let back_colour = settings
.as_ref() .as_ref()
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back)); .map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
} }
} }
@@ -132,7 +202,8 @@ fn sync_cards_on_change(
let back_colour = settings let back_colour = settings
.as_ref() .as_ref()
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back)); .map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
} }
} }
@@ -142,6 +213,7 @@ fn sync_cards(
layout: &Layout, layout: &Layout,
slide_secs: f32, slide_secs: f32,
back_colour: Color, back_colour: Color,
color_blind: bool,
entities: &Query<(Entity, &CardEntity, &Transform)>, entities: &Query<(Entity, &CardEntity, &Transform)>,
) { ) {
let positions = card_positions(game, layout); let positions = card_positions(game, layout);
@@ -165,9 +237,12 @@ fn sync_cards(
for (card, position, z) in positions { for (card, position, z) in positions {
match existing.get(&card.id) { match existing.get(&card.id) {
Some(&(entity, cur)) => { Some(&(entity, cur)) => {
update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, back_colour, cur) update_card_entity(
&mut commands, entity, &card, position, z, layout,
slide_secs, back_colour, color_blind, cur,
)
} }
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour), None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour, color_blind),
} }
} }
} }
@@ -243,9 +318,22 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
out out
} }
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color) { /// Returns the appropriate face-up body colour for a card.
let body_colour = if card.face_up { ///
/// In color-blind mode, red-suit cards receive a subtle blue tint
/// (`CARD_FACE_COLOUR_RED_CBM`) so they are distinguishable from black-suit
/// cards without relying on the text colour alone.
fn face_colour(card: &Card, color_blind: bool) -> Color {
if color_blind && card.suit.is_red() {
CARD_FACE_COLOUR_RED_CBM
} else {
CARD_FACE_COLOUR CARD_FACE_COLOUR
}
}
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) {
let body_colour = if card.face_up {
face_colour(card, color_blind)
} else { } else {
back_colour back_colour
}; };
@@ -288,10 +376,11 @@ fn update_card_entity(
layout: &Layout, layout: &Layout,
slide_secs: f32, slide_secs: f32,
back_colour: Color, back_colour: Color,
color_blind: bool,
cur: Vec3, cur: Vec3,
) { ) {
let body_colour = if card.face_up { let body_colour = if card.face_up {
CARD_FACE_COLOUR face_colour(card, color_blind)
} else { } else {
back_colour back_colour
}; };
@@ -384,6 +473,299 @@ fn label_visibility(card: &Card) -> Visibility {
} }
} }
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
///
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
fn start_flip_anim(
mut events: EventReader<CardFlippedEvent>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity)>,
) {
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
// Instant animation speed — skip the flip effect entirely.
events.clear();
return;
}
for CardFlippedEvent(card_id) in events.read() {
for (entity, marker) in &card_entities {
if marker.card_id == *card_id {
commands.entity(entity).insert(CardFlipAnim {
timer: 0.0,
phase: FlipPhase::ScalingDown,
});
break;
}
}
}
}
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
///
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
/// - At the midpoint the phase switches to `ScalingUp` and scale.x resets to 0.
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
/// - When complete the component is removed and scale.x is restored to 1.0.
fn tick_flip_anim(
mut commands: Commands,
time: Res<Time>,
mut anims: Query<(Entity, &mut Transform, &mut CardFlipAnim)>,
) {
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
anim.timer += dt;
match anim.phase {
FlipPhase::ScalingDown => {
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
transform.scale.x = 1.0 - t;
if t >= 1.0 {
anim.phase = FlipPhase::ScalingUp;
anim.timer = 0.0;
transform.scale.x = 0.0;
}
}
FlipPhase::ScalingUp => {
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
transform.scale.x = t;
if t >= 1.0 {
transform.scale.x = 1.0;
commands.entity(entity).remove::<CardFlipAnim>();
}
}
}
}
}
// ---------------------------------------------------------------------------
// Task #38 — Drag-elevation shadow
// ---------------------------------------------------------------------------
/// Maintains a single `ShadowEntity` while cards are being dragged.
///
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
/// sprite behind the top dragged card.
/// - If no drag is active, despawns the shadow entity.
fn update_drag_shadow(
mut commands: Commands,
drag: Res<DragState>,
layout: Option<Res<LayoutResource>>,
card_entities: Query<(&CardEntity, &Transform)>,
mut shadow: Local<Option<Entity>>,
) {
if drag.is_idle() {
// No drag in progress — remove shadow if it exists.
if let Some(e) = shadow.take() {
commands.entity(e).despawn_recursive();
}
return;
}
let Some(layout) = layout else { return };
let card_w = layout.0.card_size.x;
let card_h = layout.0.card_size.y;
// Find the world position of the first (top) dragged card.
let first_id = drag.cards.first().copied();
let top_pos = first_id.and_then(|id| {
card_entities
.iter()
.find(|(marker, _)| marker.card_id == id)
.map(|(_, t)| t.translation)
});
let Some(top_pos) = top_pos else { return };
// Shadow is slightly larger, offset behind-and-below, at a z slightly
// below the dragged cards.
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
match *shadow {
Some(e) => {
// Reposition the existing shadow.
commands.entity(e).insert(Transform::from_translation(shadow_pos));
}
None => {
// Spawn a new shadow sprite.
let e = commands
.spawn((
ShadowEntity,
Sprite {
color: Color::srgba(0.0, 0.0, 0.0, 0.35),
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
..default()
},
Transform::from_translation(shadow_pos),
Visibility::default(),
))
.id();
*shadow = Some(e);
}
}
}
// ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system
// ---------------------------------------------------------------------------
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
/// removes the component and resets the card sprite to its normal face-up colour.
fn tick_hint_highlight(
time: Res<Time>,
mut commands: Commands,
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
) {
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
hint.remaining -= time.delta_secs();
if hint.remaining <= 0.0 {
// Restore normal face-up colour.
let is_face_up = game.0.piles.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == card_entity.card_id)
.is_some_and(|c| c.face_up);
sprite.color = if is_face_up {
CARD_FACE_COLOUR
} else {
card_back_colour(back_idx)
};
commands.entity(entity).remove::<HintHighlight>();
}
}
}
// ---------------------------------------------------------------------------
// Task #46 — Right-click legal destination highlights
// ---------------------------------------------------------------------------
/// Color applied to a `PileMarker` sprite when it is a legal destination for
/// the right-clicked card.
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
/// Restored color for `PileMarker` sprites when the highlight is cleared.
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Handles right-click: highlights legal destination piles for the clicked card,
/// and clears highlights on any subsequent right- or left-click.
///
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
#[allow(clippy::too_many_arguments)]
fn handle_right_click(
buttons: Option<Res<ButtonInput<MouseButton>>>,
paused: Option<Res<PausedResource>>,
drag: Res<DragState>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut commands: Commands,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
highlighted: Query<Entity, With<RightClickHighlight>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let Some(buttons) = buttons else { return };
let left_pressed = buttons.just_pressed(MouseButton::Left);
let right_pressed = buttons.just_pressed(MouseButton::Right);
// Clear existing highlights on any click.
if left_pressed || right_pressed {
for entity in &highlighted {
commands.entity(entity).remove::<RightClickHighlight>();
}
for (_entity, _, mut sprite) in &mut pile_markers {
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
}
}
}
// Only proceed for right-clicks while not dragging.
if !right_pressed || !drag.is_idle() {
return;
}
let Some(layout) = layout else { return };
// Convert cursor to world-space position.
let Some(world) = cursor_world_pos(&windows, &cameras) else { return };
// Find the topmost face-up card under the cursor.
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else { return };
// Tint piles that legally accept the card.
for (entity, pile_marker, mut sprite) in &mut pile_markers {
let pile_type = &pile_marker.0;
let Some(pile) = game.0.piles.get(pile_type) else { continue };
let legal = match pile_type {
PileType::Foundation(suit) => {
can_place_on_foundation(&card, pile, *suit)
}
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
_ => false,
};
if legal {
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
commands.entity(entity).insert(RightClickHighlight);
}
}
}
/// Converts cursor position to 2-D world coordinates.
fn cursor_world_pos(
windows: &Query<&Window, With<bevy::window::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()
}
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
/// bounding rectangles of all card sprites, picking the highest Z.
fn find_top_card_at(
cursor: Vec2,
game: &GameState,
layout: &Layout,
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
) -> Option<Card> {
let half = layout.card_size / 2.0;
let mut best: Option<(f32, Card)> = None;
for (_, card_entity, transform) in card_entities.iter() {
let pos = transform.translation.truncate();
if cursor.x < pos.x - half.x
|| cursor.x > pos.x + half.x
|| cursor.y < pos.y - half.y
|| cursor.y > pos.y + half.y
{
continue;
}
let card = game
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == card_entity.card_id && c.face_up)
.cloned();
if let Some(card) = card {
let z = transform.translation.z;
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
best = Some((z, card));
}
}
}
best.map(|(_, card)| card)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -600,6 +982,69 @@ mod tests {
} }
} }
#[test]
fn card_back_colour_known_indices_are_distinct() {
// Indices 03 must each produce a unique colour.
let colours: Vec<_> = (0..4).map(card_back_colour).collect();
for i in 0..colours.len() {
for j in (i + 1)..colours.len() {
assert_ne!(colours[i], colours[j], "indices {i} and {j} must be distinct");
}
}
}
#[test]
fn card_back_colour_out_of_range_does_not_panic() {
// Indices >= 4 are beyond the defined set; the wildcard arm must handle them
// without panicking and return the same teal fallback for all.
let c4 = card_back_colour(4);
let c5 = card_back_colour(5);
let c99 = card_back_colour(99);
assert_eq!(c4, c5, "out-of-range indices must share the fallback colour");
assert_eq!(c4, c99, "index 99 must share the fallback colour");
}
// -----------------------------------------------------------------------
// Task #34 pure-function / phase-transition tests
// -----------------------------------------------------------------------
#[test]
fn flip_phase_scaling_down_starts_at_one() {
// A brand-new flip anim in ScalingDown at timer=0 should produce scale 1.0
// (no time has elapsed yet).
let t = 0.0_f32 / FLIP_HALF_SECS;
let scale_x = 1.0 - t.min(1.0);
assert!((scale_x - 1.0).abs() < 1e-6, "scale_x at timer=0 must be 1.0");
}
#[test]
fn flip_phase_scaling_down_reaches_zero_at_half_secs() {
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
let scale_x = 1.0 - t;
assert!(scale_x.abs() < 1e-6, "scale_x must reach 0.0 after one half-period");
}
#[test]
fn flip_phase_scaling_up_starts_at_zero() {
let t = 0.0_f32 / FLIP_HALF_SECS;
let scale_x = t.min(1.0);
assert!(scale_x.abs() < 1e-6, "scale_x at start of ScalingUp must be 0.0");
}
#[test]
fn flip_phase_scaling_up_reaches_one_at_half_secs() {
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
let scale_x = t;
assert!((scale_x - 1.0).abs() < 1e-6, "scale_x must reach 1.0 after second half-period");
}
#[test]
fn flip_phase_enum_equality() {
assert_eq!(FlipPhase::ScalingDown, FlipPhase::ScalingDown);
assert_eq!(FlipPhase::ScalingUp, FlipPhase::ScalingUp);
assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp);
}
#[test] #[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
+266
View File
@@ -0,0 +1,266 @@
//! Cursor-icon feedback (#31) and drag drop-target highlighting (#32).
//!
//! **Cursor icons** (`update_cursor_icon`)
//! - Cards are being dragged → `Grabbing` (closed hand)
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
//! - Otherwise → `Default` (arrow)
//!
//! **Drop-target highlights** (`update_drop_highlights`)
//! While a drag is in progress every `PileMarker` sprite is tinted:
//! - **Green** if the dragged stack can legally land there.
//! - **Default** (nearly transparent white) otherwise.
//! The tint is cleared to default the frame the drag ends.
use bevy::prelude::*;
use bevy::window::{PrimaryWindow, SystemCursorIcon};
use bevy::winit::cursor::CursorIcon;
use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
use crate::table_plugin::PileMarker;
/// Semi-transparent white that `table_plugin` uses for idle pile markers.
/// Kept in sync with the `marker_colour` constant there.
const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Green tint applied to pile markers that are valid drop targets during drag.
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
pub struct CursorPlugin;
impl Plugin for CursorPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, (update_cursor_icon, update_drop_highlights));
}
}
// ---------------------------------------------------------------------------
// #31 — Cursor icon
// ---------------------------------------------------------------------------
/// Updates the primary-window cursor icon based on drag state and hover.
fn update_cursor_icon(
drag: Res<DragState>,
windows: Query<(Entity, &Window), With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>,
mut commands: Commands,
) {
let Ok((win_entity, window)) = windows.get_single() else { return };
if !drag.is_idle() {
commands
.entity(win_entity)
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
return;
}
let hovering = (|| {
let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.get_single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
let layout = layout.as_ref()?.0.clone();
let game = game.as_ref()?;
Some(cursor_over_draggable(world, &game.0, &layout))
})()
.unwrap_or(false);
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
SystemCursorIcon::Grab
} else {
SystemCursorIcon::Default
}));
}
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
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;
};
let is_tableau = matches!(pile, PileType::Tableau(_));
let base = layout.pile_positions[&pile];
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
if !card.face_up {
continue;
}
// Only the topmost card is draggable on non-tableau piles.
if !is_tableau && i != pile_cards.cards.len() - 1 {
continue;
}
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
if point_in_rect(cursor, pos, layout.card_size) {
return true;
}
}
}
false
}
// ---------------------------------------------------------------------------
// #32 — Drop-target highlighting
// ---------------------------------------------------------------------------
/// Tints pile-marker sprites green when they are valid drag destinations,
/// and restores the default colour when no drag is active.
/// Markers tagged with `RightClickHighlight` are skipped during the idle reset
/// so the right-click legal-destination highlight remains visible.
fn update_drop_highlights(
drag: Res<DragState>,
game: Option<Res<GameStateResource>>,
mut markers: Query<(&PileMarker, &mut Sprite, Option<&RightClickHighlight>)>,
) {
if drag.is_idle() {
// Drag ended — restore markers that are not right-click-highlighted.
for (_, mut sprite, rch) in &mut markers {
if rch.is_none() {
sprite.color = MARKER_DEFAULT;
}
}
return;
}
let Some(game) = game else { return };
// The first element of drag.cards is the bottom card that lands on the target.
let Some(&bottom_id) = drag.cards.first() else { return };
let bottom_card = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == bottom_id)
.cloned();
let Some(bottom_card) = bottom_card else { return };
let drag_count = drag.cards.len();
for (marker, mut sprite, _rch) in &mut markers {
let valid = match &marker.0 {
PileType::Foundation(suit) => {
if drag_count != 1 {
false
} else {
let pile = game.0.piles.get(&PileType::Foundation(*suit));
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit))
}
}
PileType::Tableau(idx) => {
let pile = game.0.piles.get(&PileType::Tableau(*idx));
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
}
_ => false,
};
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
fn tableau_or_stack_pos(
game: &GameState,
layout: &Layout,
pile: &PileType,
index: usize,
base: Vec2,
is_tableau: bool,
) -> Vec2 {
if is_tableau {
Vec2::new(
base.x,
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
)
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
} else {
base
}
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use solitaire_core::card::{Card, Rank};
fn face_up(suit: Suit, rank: Rank) -> Card {
Card { id: 0, suit, rank, face_up: true }
}
#[test]
fn point_in_rect_center_is_inside() {
assert!(point_in_rect(Vec2::ZERO, Vec2::ZERO, Vec2::new(10.0, 10.0)));
}
#[test]
fn point_in_rect_edge_is_inside() {
assert!(point_in_rect(
Vec2::new(5.0, 5.0),
Vec2::ZERO,
Vec2::new(10.0, 10.0)
));
}
#[test]
fn point_in_rect_outside() {
assert!(!point_in_rect(
Vec2::new(6.0, 0.0),
Vec2::ZERO,
Vec2::new(10.0, 10.0)
));
}
#[test]
fn marker_valid_and_default_colours_are_distinct() {
// Regression guard — ensure these constants haven't been accidentally
// set to the same value.
assert_ne!(
format!("{MARKER_VALID:?}"),
format!("{MARKER_DEFAULT:?}")
);
}
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use solitaire_core::game_state::{DrawMode, GameState};
use crate::layout::compute_layout;
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0));
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
}
}
+292 -12
View File
@@ -1,4 +1,5 @@
//! Persistent in-game HUD: score, move count, elapsed time, and mode badge. //! Persistent in-game HUD: score, move count, elapsed time, mode badge,
//! daily-challenge constraint, and undo count.
//! //!
//! The HUD spawns once at startup and lives for the app's lifetime. Text is //! The HUD spawns once at startup and lives for the app's lifetime. Text is
//! refreshed whenever `GameStateResource` changes (which happens on every move //! refreshed whenever `GameStateResource` changes (which happens on every move
@@ -8,25 +9,40 @@
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
/// Marker on the score text node. /// Marker on the score text node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct HudScore; pub struct HudScore;
/// Marker on the move-count text node. /// Marker on the move-count text node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct HudMoves; pub struct HudMoves;
/// Marker on the elapsed-time text node. /// Marker on the elapsed-time text node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct HudTime; pub struct HudTime;
/// Marker on the mode badge text node. /// Marker on the mode badge text node.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct HudMode; pub struct HudMode;
/// Marker on the daily-challenge constraint text node.
///
/// Displays the active goal (time limit or score target) when a daily challenge
/// is in progress. Empty string when no challenge is active or the game is won.
#[derive(Component, Debug)]
pub struct HudChallenge;
/// Marker on the undo-count text node.
///
/// Shows how many undos have been used this game. Displayed in amber when
/// `undo_count > 0` because using undo blocks the no-undo achievement.
#[derive(Component, Debug)]
pub struct HudUndos;
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens. /// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
const Z_HUD: i32 = 50; const Z_HUD: i32 = 50;
@@ -59,28 +75,114 @@ fn spawn_hud(mut commands: Commands) {
.with_children(|b| { .with_children(|b| {
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white)); b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white)); b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
b.spawn((HudTime, Text::new("0:00"), font, white)); b.spawn((HudTime, Text::new("0:00"), font.clone(), white));
b.spawn(( b.spawn((
HudMode, HudMode,
Text::new(""), Text::new(""),
TextFont { font_size: 17.0, ..default() }, TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.25)), TextColor(Color::srgb(1.0, 0.85, 0.25)),
)); ));
// Daily-challenge constraint (hidden until a challenge is active).
b.spawn((
HudChallenge,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.4, 0.9, 1.0)),
));
// Undo counter (white by default; turns amber when undos are used).
b.spawn((
HudUndos,
Text::new(""),
font,
white,
));
}); });
} }
#[allow(clippy::type_complexity)] /// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
///
/// For example `format_time_limit(300)` returns `"5:00"`.
pub fn format_time_limit(secs: u64) -> String {
let m = secs / 60;
let s = secs % 60;
format!("{m}:{s:02}")
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn update_hud( fn update_hud(
game: Res<GameStateResource>, game: Res<GameStateResource>,
time_attack: Option<Res<TimeAttackResource>>, time_attack: Option<Res<TimeAttackResource>>,
mut score_q: Query<&mut Text, (With<HudScore>, Without<HudMoves>, Without<HudTime>, Without<HudMode>)>, daily: Option<Res<DailyChallengeResource>>,
mut moves_q: Query<&mut Text, (With<HudMoves>, Without<HudScore>, Without<HudTime>, Without<HudMode>)>, mut score_q: Query<
mut time_q: Query<&mut Text, (With<HudTime>, Without<HudScore>, Without<HudMoves>, Without<HudMode>)>, &mut Text,
mut mode_q: Query<&mut Text, (With<HudMode>, Without<HudScore>, Without<HudMoves>, Without<HudTime>)>, (
With<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
),
>,
mut moves_q: Query<
&mut Text,
(
With<HudMoves>,
Without<HudScore>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
),
>,
mut time_q: Query<
&mut Text,
(
With<HudTime>,
Without<HudScore>,
Without<HudMoves>,
Without<HudMode>,
Without<HudChallenge>,
Without<HudUndos>,
),
>,
mut mode_q: Query<
&mut Text,
(
With<HudMode>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudChallenge>,
Without<HudUndos>,
),
>,
mut challenge_q: Query<
(&mut Text, &mut TextColor),
(
With<HudChallenge>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudUndos>,
),
>,
mut undos_q: Query<
(&mut Text, &mut TextColor),
(
With<HudUndos>,
Without<HudScore>,
Without<HudMoves>,
Without<HudTime>,
Without<HudMode>,
Without<HudChallenge>,
),
>,
) { ) {
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active); let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
// Score, moves, and mode only need updating when the game state changes. // Score, moves, mode, challenge, and undos only need updating when game state changes.
if game.is_changed() { if game.is_changed() {
let g = &game.0; let g = &game.0;
let is_zen = g.mode == GameMode::Zen; let is_zen = g.mode == GameMode::Zen;
@@ -106,6 +208,31 @@ fn update_hud(
GameMode::TimeAttack => "TIME ATTACK".to_string(), GameMode::TimeAttack => "TIME ATTACK".to_string(),
}; };
} }
// --- Daily challenge constraint ---
if let Ok((mut t, _)) = challenge_q.get_single_mut() {
**t = if g.is_won {
// Hide constraint once the game is over.
String::new()
} else if let Some(dc) = daily.as_deref() {
challenge_hud_text(dc)
} else {
String::new()
};
}
// --- Undo count ---
if let Ok((mut t, mut color)) = undos_q.get_single_mut() {
let count = g.undo_count;
if count == 0 {
**t = String::new();
*color = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
} else {
**t = format!("Undos: {count}");
// Amber warning: using undo blocks the no-undo achievement.
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
}
}
} }
// Time display: show Time Attack countdown every frame when active; // Time display: show Time Attack countdown every frame when active;
@@ -135,11 +262,27 @@ fn update_hud(
} }
} }
/// Builds the HUD text for the active daily challenge constraints.
///
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
/// score target is set, or an empty string when the challenge has no extra
/// constraints.
fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
if let Some(secs) = dc.max_time_secs {
format!("Limit: {}", format_time_limit(secs))
} else if let Some(score) = dc.target_score {
format!("Goal: {score} pts")
} else {
String::new()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use chrono::Local;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App { fn headless_app() -> App {
@@ -220,4 +363,141 @@ mod tests {
// 125 seconds = 2 minutes 5 seconds → "2:05" // 125 seconds = 2 minutes 5 seconds → "2:05"
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05"); assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
} }
// -----------------------------------------------------------------------
// format_time_limit (pure function)
// -----------------------------------------------------------------------
#[test]
fn format_time_limit_300_is_5_00() {
assert_eq!(format_time_limit(300), "5:00");
}
#[test]
fn format_time_limit_zero() {
assert_eq!(format_time_limit(0), "0:00");
}
#[test]
fn format_time_limit_pads_seconds() {
assert_eq!(format_time_limit(65), "1:05");
}
// -----------------------------------------------------------------------
// challenge_hud_text (pure function)
// -----------------------------------------------------------------------
#[test]
fn challenge_hud_text_shows_time_limit() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: Some(300),
};
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
}
#[test]
fn challenge_hud_text_shows_score_goal() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: Some(4000),
max_time_secs: None,
};
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
}
#[test]
fn challenge_hud_text_empty_when_no_constraints() {
let dc = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: None,
};
assert_eq!(challenge_hud_text(&dc), "");
}
// -----------------------------------------------------------------------
// HudChallenge in-app tests
// -----------------------------------------------------------------------
#[test]
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
#[test]
fn challenge_hud_shows_time_limit_when_resource_present() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: Some("Win fast".to_string()),
target_score: None,
max_time_secs: Some(300),
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
#[test]
fn challenge_hud_shows_score_goal_when_resource_present() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: None,
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
#[test]
fn challenge_hud_clears_on_win() {
let mut app = headless_app();
app.world_mut().insert_resource(DailyChallengeResource {
date: Local::now().date_naive(),
seed: 42,
goal_description: None,
target_score: None,
max_time_secs: Some(300),
});
// Mark the game as won — HudChallenge should be empty.
app.world_mut().resource_mut::<GameStateResource>().0.is_won = true;
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
// -----------------------------------------------------------------------
// HudUndos in-app tests
// -----------------------------------------------------------------------
#[test]
fn undos_hud_empty_at_game_start() {
let mut app = headless_app();
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
}
#[test]
fn undos_hud_shows_count_after_undo() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.undo_count = 3;
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
} }
+10 -4
View File
@@ -6,6 +6,7 @@ pub mod auto_complete_plugin;
pub mod audio_plugin; pub mod audio_plugin;
pub mod card_plugin; pub mod card_plugin;
pub mod challenge_plugin; pub mod challenge_plugin;
pub mod cursor_plugin;
pub mod daily_challenge_plugin; pub mod daily_challenge_plugin;
pub mod events; pub mod events;
pub mod game_plugin; pub mod game_plugin;
@@ -24,6 +25,7 @@ pub mod sync_plugin;
pub mod table_plugin; pub mod table_plugin;
pub mod time_attack_plugin; pub mod time_attack_plugin;
pub mod weekly_goals_plugin; pub mod weekly_goals_plugin;
pub mod win_summary_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
pub use challenge_plugin::{ pub use challenge_plugin::{
@@ -37,11 +39,12 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use animation_plugin::{AnimationPlugin, CardAnim}; pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use auto_complete_plugin::AutoCompletePlugin; pub use auto_complete_plugin::AutoCompletePlugin;
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; pub use card_plugin::{CardEntity, CardLabel, CardPlugin, HintHighlight, RightClickHighlight};
pub use cursor_plugin::CursorPlugin;
pub use events::{ pub use events::{
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent, NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
}; };
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath}; pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen}; pub use help_plugin::{HelpPlugin, HelpScreen};
@@ -61,3 +64,6 @@ pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
pub use time_attack_plugin::{ pub use time_attack_plugin::{
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
}; };
pub use win_summary_plugin::{
format_win_time, ScreenShakeResource, WinSummaryPending, WinSummaryPlugin,
};
+110 -2
View File
@@ -14,7 +14,9 @@ use bevy::prelude::*;
use solitaire_data::save_game_state_to; use solitaire_data::save_game_state_to;
use crate::game_plugin::GameStatePath; use crate::game_plugin::GameStatePath;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::stats_plugin::StatsResource;
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`. /// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, Default)]
@@ -33,6 +35,7 @@ impl Plugin for PausePlugin {
} }
} }
#[allow(clippy::too_many_arguments)]
fn toggle_pause( fn toggle_pause(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
@@ -40,6 +43,8 @@ fn toggle_pause(
screens: Query<Entity, With<PauseScreen>>, screens: Query<Entity, With<PauseScreen>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
) { ) {
if !keys.just_pressed(KeyCode::Escape) { if !keys.just_pressed(KeyCode::Escape) {
return; return;
@@ -48,7 +53,10 @@ fn toggle_pause(
commands.entity(entity).despawn_recursive(); commands.entity(entity).despawn_recursive();
paused.0 = false; paused.0 = false;
} else { } else {
spawn_pause_screen(&mut commands); // Snapshot current level and streak at pause time.
let level = progress.as_deref().map(|p| p.0.level);
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
spawn_pause_screen(&mut commands, level, streak);
paused.0 = true; paused.0 = true;
// Persist the current game state whenever the player opens the pause // Persist the current game state whenever the player opens the pause
// overlay so an OS-level kill still leaves a resumable save. // overlay so an OS-level kill still leaves a resumable save.
@@ -62,7 +70,12 @@ fn toggle_pause(
} }
} }
fn spawn_pause_screen(commands: &mut Commands) { /// Spawns the full-screen pause overlay.
///
/// `level` and `streak` are optional snapshots taken at pause time. When
/// `ProgressResource` or `StatsResource` is not installed (e.g. in headless
/// tests), those lines are omitted from the overlay.
fn spawn_pause_screen(commands: &mut Commands, level: Option<u32>, streak: Option<u32>) {
commands commands
.spawn(( .spawn((
PauseScreen, PauseScreen,
@@ -90,6 +103,18 @@ fn spawn_pause_screen(commands: &mut Commands) {
}, },
TextColor(Color::srgb(1.0, 0.87, 0.0)), TextColor(Color::srgb(1.0, 0.87, 0.0)),
)); ));
// Level and streak line — only shown when the resources are present.
if level.is_some() || streak.is_some() {
let info = build_level_streak_line(level, streak);
b.spawn((
Text::new(info),
TextFont {
font_size: 22.0,
..default()
},
TextColor(Color::srgb(0.75, 0.95, 0.75)),
));
}
b.spawn(( b.spawn((
Text::new("Press Esc to resume"), Text::new("Press Esc to resume"),
TextFont { TextFont {
@@ -101,6 +126,19 @@ fn spawn_pause_screen(commands: &mut Commands) {
}); });
} }
/// Formats the level / win-streak summary line for the pause overlay.
///
/// Both values are optional because either resource may be absent in
/// headless or partially-configured app contexts.
fn build_level_streak_line(level: Option<u32>, streak: Option<u32>) -> String {
match (level, streak) {
(Some(l), Some(s)) => format!("Level {l} Win streak: {s}"),
(Some(l), None) => format!("Level {l}"),
(None, Some(s)) => format!("Win streak: {s}"),
(None, None) => String::new(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -175,4 +213,74 @@ mod tests {
"third Esc must re-spawn PauseScreen" "third Esc must re-spawn PauseScreen"
); );
} }
// -----------------------------------------------------------------------
// build_level_streak_line (pure function)
// -----------------------------------------------------------------------
#[test]
fn level_streak_both_present() {
assert_eq!(
build_level_streak_line(Some(7), Some(3)),
"Level 7 Win streak: 3"
);
}
#[test]
fn level_streak_only_level() {
assert_eq!(build_level_streak_line(Some(5), None), "Level 5");
}
#[test]
fn level_streak_only_streak() {
assert_eq!(build_level_streak_line(None, Some(4)), "Win streak: 4");
}
#[test]
fn level_streak_neither() {
assert_eq!(build_level_streak_line(None, None), "");
}
// -----------------------------------------------------------------------
// Pause screen with progress / stats resources present
// -----------------------------------------------------------------------
#[test]
fn pause_screen_spawns_with_level_and_streak_when_resources_present() {
use crate::progress_plugin::{ProgressPlugin, ProgressResource};
use crate::stats_plugin::{StatsPlugin, StatsResource};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(crate::game_plugin::GamePlugin)
.add_plugins(crate::table_plugin::TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(StatsPlugin::headless())
.add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
// Set known values.
app.world_mut().resource_mut::<ProgressResource>().0.level = 7;
app.world_mut().resource_mut::<StatsResource>().0.win_streak_current = 3;
press_esc(&mut app);
app.update();
// Verify the screen was spawned.
assert!(app.world().resource::<PausedResource>().0);
// Find the text nodes on the PauseScreen children and check one contains
// the expected level/streak string.
let texts: Vec<String> = app
.world_mut()
.query::<&Text>()
.iter(app.world())
.map(|t| t.0.clone())
.collect();
assert!(
texts.iter().any(|t| t == "Level 7 Win streak: 3"),
"expected level/streak line in pause screen texts, got: {texts:?}"
);
}
} }
+187 -8
View File
@@ -11,6 +11,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings}; use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
@@ -74,6 +75,14 @@ struct AnimSpeedText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct BackgroundText; struct BackgroundText;
/// Marks the `Text` node showing the current color-blind mode state.
#[derive(Component, Debug)]
struct ColorBlindText;
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
/// Tags interactive buttons inside the Settings panel. /// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)] #[derive(Component, Debug)]
enum SettingsButton { enum SettingsButton {
@@ -86,6 +95,7 @@ enum SettingsButton {
ToggleTheme, ToggleTheme,
CycleCardBack, CycleCardBack,
CycleBackground, CycleBackground,
ToggleColorBlind,
SyncNow, SyncNow,
Done, Done,
} }
@@ -129,7 +139,8 @@ impl Plugin for SettingsPlugin {
.init_resource::<SettingsScreen>() .init_resource::<SettingsScreen>()
.add_event::<SettingsChangedEvent>() .add_event::<SettingsChangedEvent>()
.add_event::<ManualSyncRequestEvent>() .add_event::<ManualSyncRequestEvent>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen)); .add_event::<bevy::input::mouse::MouseWheel>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
if self.ui_enabled { if self.ui_enabled {
app.add_systems( app.add_systems(
@@ -141,6 +152,7 @@ impl Plugin for SettingsPlugin {
update_card_back_text, update_card_back_text,
update_background_text, update_background_text,
update_anim_speed_text, update_anim_speed_text,
update_color_blind_text,
), ),
); );
} }
@@ -301,6 +313,18 @@ fn update_anim_speed_text(
} }
} }
fn update_color_blind_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = color_blind_label(settings.0.color_blind_mode);
}
}
fn card_back_label(idx: usize) -> String { fn card_back_label(idx: usize) -> String {
if idx == 0 { if idx == 0 {
"Default".to_string() "Default".to_string()
@@ -346,11 +370,12 @@ fn handle_settings_buttons(
mut changed: EventWriter<SettingsChangedEvent>, mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>, mut manual_sync: EventWriter<ManualSyncRequestEvent>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>, mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>, mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>)>, mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>)>, mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>, mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<ColorBlindText>)>,
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
) { ) {
for (interaction, button) in &interaction_query { for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
@@ -456,6 +481,14 @@ fn handle_settings_buttons(
persist(&path, &settings.0); persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone())); changed.send(SettingsChangedEvent(settings.0.clone()));
} }
SettingsButton::ToggleColorBlind => {
settings.0.color_blind_mode = !settings.0.color_blind_mode;
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = color_blind_text.get_single_mut() {
**t = color_blind_label(settings.0.color_blind_mode);
}
}
SettingsButton::SyncNow => { SettingsButton::SyncNow => {
manual_sync.send(ManualSyncRequestEvent); manual_sync.send(ManualSyncRequestEvent);
} }
@@ -489,6 +522,39 @@ fn theme_label(theme: &Theme) -> String {
} }
} }
fn color_blind_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Scrolls the settings panel inner card in response to mouse-wheel events.
///
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
/// scrolls past the top.
fn scroll_settings_panel(
mut scroll_evr: EventReader<MouseWheel>,
screen: Res<SettingsScreen>,
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
) {
if !screen.0 {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.offset_y = (sp.offset_y - delta_y).max(0.0);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// UI construction // UI construction
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -518,15 +584,18 @@ fn spawn_settings_panel(
ZIndex(200), ZIndex(200),
)) ))
.with_children(|root| { .with_children(|root| {
// Inner card — max_height + clip_y keeps it on-screen on small windows. // Inner card — max_height + scroll_y lets the player reach all rows
// on small windows by scrolling with the mouse wheel.
root.spawn(( root.spawn((
SettingsPanelScrollable,
ScrollPosition::default(),
Node { Node {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)), padding: UiRect::all(Val::Px(28.0)),
row_gap: Val::Px(14.0), row_gap: Val::Px(14.0),
min_width: Val::Px(340.0), min_width: Val::Px(340.0),
max_height: Val::Percent(88.0), max_height: Val::Percent(88.0),
overflow: Overflow::clip_y(), overflow: Overflow::scroll_y(),
..default() ..default()
}, },
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)), BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
@@ -626,6 +695,28 @@ fn spawn_settings_panel(
icon_button(row, "", SettingsButton::ToggleTheme); icon_button(row, "", SettingsButton::ToggleTheme);
}); });
// Color-blind mode row
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Color-blind Mode"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
ColorBlindText,
Text::new(color_blind_label(settings.color_blind_mode)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::ToggleColorBlind);
});
// Card back row — only shown when the player has unlocked more than one. // Card back row — only shown when the player has unlocked more than one.
if unlocked_card_backs.len() > 1 { if unlocked_card_backs.len() > 1 {
card.spawn(Node { card.spawn(Node {
@@ -942,4 +1033,92 @@ mod tests {
fn cycle_unlocked_empty_returns_zero() { fn cycle_unlocked_empty_returns_zero() {
assert_eq!(cycle_unlocked(&[], 0), 0); assert_eq!(cycle_unlocked(&[], 0), 0);
} }
#[test]
fn scroll_is_noop_when_settings_panel_closed() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
let entity = app
.world_mut()
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
.id();
// Send a downward scroll event while the panel is closed.
app.world_mut().send_event(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -3.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
});
app.update();
// ScrollPosition must remain at 0.0 — panel was closed.
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
#[test]
fn scroll_moves_offset_when_panel_open() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 100.0, ..default() },
))
.id();
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
app.world_mut().send_event(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -2.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
}
#[test]
fn scroll_clamps_offset_to_zero_at_top() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Entity starts at 10 px offset.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition { offset_y: 10.0, ..default() },
))
.id();
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
app.world_mut().send_event(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 5.0,
window: bevy::ecs::entity::Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.offset_y;
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
}
} }
+46 -1
View File
@@ -16,7 +16,7 @@ use solitaire_data::{
}; };
use crate::challenge_plugin::challenge_progress_label; use crate::challenge_plugin::challenge_progress_label;
use crate::events::{GameWonEvent, NewGameRequestEvent}; use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -72,6 +72,8 @@ impl Plugin for StatsPlugin {
.insert_resource(StatsStoragePath(self.storage_path.clone())) .insert_resource(StatsStoragePath(self.storage_path.clone()))
.add_event::<GameWonEvent>() .add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>() .add_event::<NewGameRequestEvent>()
.add_event::<ForfeitEvent>()
.add_event::<InfoToastEvent>()
// record_abandoned must read `move_count` BEFORE handle_new_game // record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. // clobbers it with a fresh game.
.add_systems( .add_systems(
@@ -84,6 +86,10 @@ impl Plugin for StatsPlugin {
Update, Update,
update_stats_on_win.after(GameMutation).in_set(StatsUpdate), update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
) )
.add_systems(
Update,
handle_forfeit.before(GameMutation).in_set(StatsUpdate),
)
.add_systems(Update, toggle_stats_screen.after(GameMutation)); .add_systems(Update, toggle_stats_screen.after(GameMutation));
} }
} }
@@ -125,6 +131,26 @@ fn update_stats_on_new_game(
} }
} }
/// When the player presses G to forfeit, record the game as abandoned, save
/// stats, fire an informational toast, and start a new game.
fn handle_forfeit(
mut events: EventReader<ForfeitEvent>,
game: Res<GameStateResource>,
mut stats: ResMut<StatsResource>,
path: Res<StatsStoragePath>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut toast: EventWriter<InfoToastEvent>,
) {
for _ in events.read() {
if game.0.move_count > 0 && !game.0.is_won {
stats.0.record_abandoned();
persist(&path, &stats.0, "forfeit");
}
toast.send(InfoToastEvent("Game forfeited".to_string()));
new_game.send(NewGameRequestEvent::default());
}
}
fn toggle_stats_screen( fn toggle_stats_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
@@ -361,6 +387,25 @@ mod tests {
assert_eq!(stats.games_played, 1); assert_eq!(stats.games_played, 1);
} }
#[test]
fn draw_three_win_increments_draw_three_wins_only() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<crate::resources::GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 200,
});
app.update();
let stats = &app.world().resource::<StatsResource>().0;
assert_eq!(stats.draw_three_wins, 1, "draw_three_wins must increment for DrawThree mode");
assert_eq!(stats.draw_one_wins, 0, "draw_one_wins must not increment for DrawThree mode");
}
#[test] #[test]
fn new_game_after_moves_records_abandoned() { fn new_game_after_moves_records_abandoned() {
let mut app = headless_app(); let mut app = headless_app();
+62 -2
View File
@@ -124,9 +124,22 @@ fn apply_theme_on_settings_change(
} }
} }
/// Returns the single-letter suit symbol used on empty foundation markers.
///
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
pub fn suit_symbol(suit: &Suit) -> &'static str {
match suit {
Suit::Spades => "S",
Suit::Hearts => "H",
Suit::Diamonds => "D",
Suit::Clubs => "C",
}
}
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) { fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08); let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
let marker_size = layout.card_size; let marker_size = layout.card_size;
let font_size = layout.card_size.x * 0.28;
let mut piles: Vec<PileType> = Vec::with_capacity(13); let mut piles: Vec<PileType> = Vec::with_capacity(13);
piles.push(PileType::Stock); piles.push(PileType::Stock);
@@ -140,15 +153,40 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
for pile in piles { for pile in piles {
let pos = layout.pile_positions[&pile]; let pos = layout.pile_positions[&pile];
commands.spawn(( let mut entity = commands.spawn((
Sprite { Sprite {
color: marker_colour, color: marker_colour,
custom_size: Some(marker_size), custom_size: Some(marker_size),
..default() ..default()
}, },
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER), Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
PileMarker(pile), PileMarker(pile.clone()),
)); ));
// Task #35 — suit symbol on empty foundation placeholders.
if let PileType::Foundation(suit) = &pile {
let symbol = suit_symbol(suit).to_string();
entity.with_children(|b| {
b.spawn((
Text2d::new(symbol),
TextFont { font_size, ..default() },
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
// Task #43 — King indicator on empty tableau placeholders.
if let PileType::Tableau(_) = &pile {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
TextFont { font_size, ..default() },
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
}
} }
} }
@@ -291,4 +329,26 @@ mod tests {
assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback"); assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
assert_eq!(c4, c99, "index 99 must share the charcoal fallback"); assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
} }
// -----------------------------------------------------------------------
// suit_symbol pure-function tests (Task #35)
// -----------------------------------------------------------------------
#[test]
fn suit_symbol_returns_correct_letters() {
assert_eq!(suit_symbol(&Suit::Spades), "S");
assert_eq!(suit_symbol(&Suit::Hearts), "H");
assert_eq!(suit_symbol(&Suit::Diamonds), "D");
assert_eq!(suit_symbol(&Suit::Clubs), "C");
}
#[test]
fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
.iter()
.map(suit_symbol)
.collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
}
} }
+430
View File
@@ -0,0 +1,430 @@
//! Win summary modal overlay and screen-shake effect.
//!
//! # Task #33 — Win summary screen
//! On `GameWonEvent`, after a 0.5 s delay (so the cascade animation has
//! started), a full-screen modal is spawned showing score, time, XP, and a
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
//!
//! # Task #47 — Win fanfare screen-shake
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
//! shake duration elapses.
use bevy::prelude::*;
use crate::events::{GameWonEvent, NewGameRequestEvent, XpAwardedEvent};
use crate::game_plugin::GameMutation;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Delay after `GameWonEvent` before the win-summary modal is spawned.
/// Chosen so the cascade animation has a moment to start first.
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
/// Duration of the screen-shake in seconds.
const SHAKE_DURATION_SECS: f32 = 0.6;
/// Maximum camera displacement in world-space pixels at the start of the shake.
const SHAKE_INTENSITY: f32 = 8.0;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Accumulates win data while waiting for `XpAwardedEvent` to arrive.
///
/// The XP event fires shortly after `GameWonEvent`. We store both pieces of
/// data here so the modal can show the complete picture.
#[derive(Resource, Debug, Clone, Default)]
pub struct WinSummaryPending {
/// Score from the most recent `GameWonEvent`.
pub score: i32,
/// Elapsed game time (seconds) from the most recent `GameWonEvent`.
pub time_seconds: u64,
/// XP awarded from the most recent `XpAwardedEvent` (0 until that event fires).
pub xp: u64,
}
/// Drives the camera shake effect after a win.
///
/// While `remaining > 0` a system applies a decaying sinusoidal offset to the
/// main camera's `Transform`. The system resets the camera to the origin when
/// `remaining` reaches zero.
#[derive(Resource, Debug, Clone, Default)]
pub struct ScreenShakeResource {
/// Seconds of shake remaining.
pub remaining: f32,
/// Peak displacement in world-space pixels (decays to zero over `remaining`).
pub intensity: f32,
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Marker on the win-summary modal root entity.
#[derive(Component, Debug)]
pub struct WinSummaryOverlay;
/// Marker on the "Play Again" button inside the win-summary modal.
#[derive(Component, Debug)]
enum WinSummaryButton {
PlayAgain,
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers the win-summary modal and screen-shake systems.
pub struct WinSummaryPlugin;
impl Plugin for WinSummaryPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WinSummaryPending>()
.init_resource::<ScreenShakeResource>()
.add_event::<GameWonEvent>()
.add_event::<XpAwardedEvent>()
.add_event::<NewGameRequestEvent>()
.add_systems(
Update,
(
cache_win_data,
spawn_win_summary_after_delay,
handle_win_summary_buttons,
apply_screen_shake,
)
.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
/// Formats `seconds` as `m:ss`.
///
/// ```
/// # use solitaire_engine::win_summary_plugin::format_win_time;
/// assert_eq!(format_win_time(0), "0:00");
/// assert_eq!(format_win_time(65), "1:05");
/// assert_eq!(format_win_time(3661), "61:01");
/// ```
pub fn format_win_time(seconds: u64) -> String {
let m = seconds / 60;
let s = seconds % 60;
format!("{m}:{s:02}")
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Caches score/time from `GameWonEvent` and XP from `XpAwardedEvent` into
/// `WinSummaryPending` so they are available when the modal spawns.
fn cache_win_data(
mut won: EventReader<GameWonEvent>,
mut xp: EventReader<XpAwardedEvent>,
mut pending: ResMut<WinSummaryPending>,
) {
for ev in won.read() {
pending.score = ev.score;
pending.time_seconds = ev.time_seconds;
pending.xp = 0; // reset; XP event follows
}
for ev in xp.read() {
pending.xp = ev.amount;
}
}
/// After `GameWonEvent`, arms the screen-shake resource.
///
/// This system shares the `GameWonEvent` stream with `cache_win_data` through
/// the delay timer stored in `Local` — the shake fires immediately, while the
/// modal waits 0.5 s.
fn spawn_win_summary_after_delay(
mut commands: Commands,
mut won: EventReader<GameWonEvent>,
mut shake: ResMut<ScreenShakeResource>,
pending: Res<WinSummaryPending>,
time: Res<Time>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut delay: Local<Option<f32>>,
) {
// Process new win events.
for _ in won.read() {
// Arm the screen shake immediately.
shake.remaining = SHAKE_DURATION_SECS;
shake.intensity = SHAKE_INTENSITY;
// Start the delay timer (overwrite if a second win arrives).
*delay = Some(WIN_SUMMARY_DELAY_SECS);
// Clear any stale overlay from a previous win.
for entity in &overlays {
commands.entity(entity).despawn_recursive();
}
}
// Tick the delay timer.
if let Some(remaining) = delay.as_mut() {
*remaining -= time.delta_secs();
if *remaining <= 0.0 {
*delay = None;
// Only spawn if there is no overlay already.
if overlays.is_empty() {
spawn_overlay(&mut commands, &pending);
}
}
}
}
/// Despawns the win-summary modal and fires `NewGameRequestEvent` when
/// the player presses "Play Again".
fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
mut new_game: EventWriter<NewGameRequestEvent>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
match button {
WinSummaryButton::PlayAgain => {
// Despawn the modal.
for entity in &overlays {
commands.entity(entity).despawn_recursive();
}
new_game.send(NewGameRequestEvent::default());
}
}
}
}
/// Applies a decaying sinusoidal offset to the main `Camera2d` each frame
/// while `ScreenShakeResource::remaining > 0`.
///
/// Uses a deterministic oscillation (`sin`/`cos` of total elapsed time) to
/// avoid a dependency on a random-number crate in this crate.
fn apply_screen_shake(
mut shake: ResMut<ScreenShakeResource>,
time: Res<Time>,
mut cameras: Query<&mut Transform, With<Camera2d>>,
) {
let dt = time.delta_secs();
if shake.remaining <= 0.0 {
// Ensure the camera is back at origin whenever shake is idle.
for mut t in &mut cameras {
t.translation.x = 0.0;
t.translation.y = 0.0;
}
return;
}
shake.remaining = (shake.remaining - dt).max(0.0);
// Decay factor: 1.0 at start, 0.0 at end.
let decay = shake.remaining / SHAKE_DURATION_SECS;
let elapsed = time.elapsed_secs();
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
for mut t in &mut cameras {
t.translation.x = offset_x;
t.translation.y = offset_y;
}
}
// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------
fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
commands
.spawn((
WinSummaryOverlay,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.70)),
ZIndex(300),
))
.with_children(|root| {
root.spawn((
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(36.0)),
row_gap: Val::Px(18.0),
min_width: Val::Px(320.0),
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
BorderRadius::all(Val::Px(12.0)),
))
.with_children(|card| {
// Heading
card.spawn((
Text::new("You Won!"),
TextFont { font_size: 42.0, ..default() },
TextColor(Color::srgb(1.0, 0.87, 0.0)),
));
// Score
card.spawn((
Text::new(format!("Score: {}", pending.score)),
TextFont { font_size: 26.0, ..default() },
TextColor(Color::WHITE),
));
// Time
card.spawn((
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
TextFont { font_size: 26.0, ..default() },
TextColor(Color::WHITE),
));
// XP
card.spawn((
Text::new(format!("XP earned: +{}", pending.xp)),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.4, 1.0, 0.4)),
));
// Play Again button
card.spawn((
WinSummaryButton::PlayAgain,
Button,
Node {
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
justify_content: JustifyContent::Center,
margin: UiRect::top(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
BorderRadius::all(Val::Px(6.0)),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::WHITE),
));
});
});
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_win_time_zero() {
assert_eq!(format_win_time(0), "0:00");
}
#[test]
fn format_win_time_one_minute_five_seconds() {
assert_eq!(format_win_time(65), "1:05");
}
#[test]
fn format_win_time_exact_minute() {
assert_eq!(format_win_time(120), "2:00");
}
#[test]
fn format_win_time_large() {
// 3661 s = 61 min 1 s
assert_eq!(format_win_time(3661), "61:01");
}
#[test]
fn format_win_time_59_seconds() {
assert_eq!(format_win_time(59), "0:59");
}
#[test]
fn screen_shake_resource_default_is_idle() {
let shake = ScreenShakeResource::default();
assert!(shake.remaining <= 0.0);
}
#[test]
fn win_summary_pending_default_is_zeroed() {
let p = WinSummaryPending::default();
assert_eq!(p.score, 0);
assert_eq!(p.time_seconds, 0);
assert_eq!(p.xp, 0);
}
#[test]
fn win_summary_plugin_inserts_resources() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(WinSummaryPlugin);
app.update();
assert!(app.world().get_resource::<WinSummaryPending>().is_some());
assert!(app.world().get_resource::<ScreenShakeResource>().is_some());
}
#[test]
fn cache_win_data_sets_score_and_time() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(WinSummaryPlugin);
app.update();
app.world_mut()
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert_eq!(pending.score, 1234);
assert_eq!(pending.time_seconds, 90);
}
#[test]
fn cache_win_data_sets_xp_from_xp_awarded_event() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(WinSummaryPlugin);
app.update();
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert_eq!(pending.xp, 75);
}
#[test]
fn game_won_event_arms_screen_shake() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(WinSummaryPlugin);
app.update();
app.world_mut()
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
app.update();
let shake = app.world().resource::<ScreenShakeResource>();
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
}
}