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:
@@ -19,12 +19,16 @@ use solitaire_core::card::{Card, Rank, 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::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::events::{CardFlippedEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
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::table_plugin::PileMarker;
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
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.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
|
||||
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);
|
||||
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||
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.
|
||||
/// Index 0 = default blue; 1–4 are unlockable alternate designs.
|
||||
@@ -65,6 +73,56 @@ pub struct CardEntity {
|
||||
#[derive(Component, Debug)]
|
||||
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`.
|
||||
pub struct CardPlugin;
|
||||
|
||||
@@ -72,13 +130,24 @@ impl Plugin for CardPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||
// 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(
|
||||
Update,
|
||||
(
|
||||
sync_cards_on_change.after(GameMutation),
|
||||
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
|
||||
.as_ref()
|
||||
.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
|
||||
.as_ref()
|
||||
.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,
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
@@ -165,9 +237,12 @@ fn sync_cards(
|
||||
for (card, position, z) in positions {
|
||||
match existing.get(&card.id) {
|
||||
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
|
||||
}
|
||||
|
||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color) {
|
||||
let body_colour = if card.face_up {
|
||||
/// Returns the appropriate face-up body colour for a card.
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
back_colour
|
||||
};
|
||||
@@ -288,10 +376,11 @@ fn update_card_entity(
|
||||
layout: &Layout,
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
cur: Vec3,
|
||||
) {
|
||||
let body_colour = if card.face_up {
|
||||
CARD_FACE_COLOUR
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -600,6 +982,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_back_colour_known_indices_are_distinct() {
|
||||
// Indices 0–3 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]
|
||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
||||
@@ -8,25 +9,40 @@
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
/// Marker on the score text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct HudScore;
|
||||
pub struct HudScore;
|
||||
|
||||
/// Marker on the move-count text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct HudMoves;
|
||||
pub struct HudMoves;
|
||||
|
||||
/// Marker on the elapsed-time text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct HudTime;
|
||||
pub struct HudTime;
|
||||
|
||||
/// Marker on the mode badge text node.
|
||||
#[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.
|
||||
const Z_HUD: i32 = 50;
|
||||
@@ -59,28 +75,114 @@ fn spawn_hud(mut commands: Commands) {
|
||||
.with_children(|b| {
|
||||
b.spawn((HudScore, Text::new("Score: 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((
|
||||
HudMode,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
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(
|
||||
game: Res<GameStateResource>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
mut score_q: Query<&mut Text, (With<HudScore>, Without<HudMoves>, Without<HudTime>, Without<HudMode>)>,
|
||||
mut moves_q: Query<&mut Text, (With<HudMoves>, Without<HudScore>, Without<HudTime>, Without<HudMode>)>,
|
||||
mut time_q: Query<&mut Text, (With<HudTime>, Without<HudScore>, Without<HudMoves>, Without<HudMode>)>,
|
||||
mut mode_q: Query<&mut Text, (With<HudMode>, Without<HudScore>, Without<HudMoves>, Without<HudTime>)>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
mut score_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
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);
|
||||
|
||||
// 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() {
|
||||
let g = &game.0;
|
||||
let is_zen = g.mode == GameMode::Zen;
|
||||
@@ -106,6 +208,31 @@ fn update_hud(
|
||||
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;
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use chrono::Local;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
@@ -220,4 +363,141 @@ mod tests {
|
||||
// 125 seconds = 2 minutes 5 seconds → "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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod auto_complete_plugin;
|
||||
pub mod audio_plugin;
|
||||
pub mod card_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cursor_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
@@ -24,6 +25,7 @@ pub mod sync_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
pub use challenge_plugin::{
|
||||
@@ -37,11 +39,12 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
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::{
|
||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent,
|
||||
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
|
||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||
};
|
||||
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
|
||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||
@@ -61,3 +64,6 @@ pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
||||
pub use time_attack_plugin::{
|
||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||
};
|
||||
pub use win_summary_plugin::{
|
||||
format_win_time, ScreenShakeResource, WinSummaryPending, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,9 @@ use bevy::prelude::*;
|
||||
use solitaire_data::save_game_state_to;
|
||||
|
||||
use crate::game_plugin::GameStatePath;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::stats_plugin::StatsResource;
|
||||
|
||||
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
@@ -33,6 +35,7 @@ impl Plugin for PausePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_pause(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
@@ -40,6 +43,8 @@ fn toggle_pause(
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Escape) {
|
||||
return;
|
||||
@@ -48,7 +53,10 @@ fn toggle_pause(
|
||||
commands.entity(entity).despawn_recursive();
|
||||
paused.0 = false;
|
||||
} 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;
|
||||
// Persist the current game state whenever the player opens the pause
|
||||
// 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
|
||||
.spawn((
|
||||
PauseScreen,
|
||||
@@ -90,6 +103,18 @@ fn spawn_pause_screen(commands: &mut Commands) {
|
||||
},
|
||||
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((
|
||||
Text::new("Press Esc to resume"),
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -175,4 +213,74 @@ mod tests {
|
||||
"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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
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)]
|
||||
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.
|
||||
#[derive(Component, Debug)]
|
||||
enum SettingsButton {
|
||||
@@ -86,6 +95,7 @@ enum SettingsButton {
|
||||
ToggleTheme,
|
||||
CycleCardBack,
|
||||
CycleBackground,
|
||||
ToggleColorBlind,
|
||||
SyncNow,
|
||||
Done,
|
||||
}
|
||||
@@ -129,7 +139,8 @@ impl Plugin for SettingsPlugin {
|
||||
.init_resource::<SettingsScreen>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.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 {
|
||||
app.add_systems(
|
||||
@@ -141,6 +152,7 @@ impl Plugin for SettingsPlugin {
|
||||
update_card_back_text,
|
||||
update_background_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 {
|
||||
if idx == 0 {
|
||||
"Default".to_string()
|
||||
@@ -346,11 +370,12 @@ fn handle_settings_buttons(
|
||||
mut changed: EventWriter<SettingsChangedEvent>,
|
||||
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>)>,
|
||||
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
|
||||
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>, Without<ColorBlindText>)>,
|
||||
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>, Without<ColorBlindText>)>,
|
||||
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 {
|
||||
if *interaction != Interaction::Pressed {
|
||||
@@ -456,6 +481,14 @@ fn handle_settings_buttons(
|
||||
persist(&path, &settings.0);
|
||||
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 => {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -518,15 +584,18 @@ fn spawn_settings_panel(
|
||||
ZIndex(200),
|
||||
))
|
||||
.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((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(28.0)),
|
||||
row_gap: Val::Px(14.0),
|
||||
min_width: Val::Px(340.0),
|
||||
max_height: Val::Percent(88.0),
|
||||
overflow: Overflow::clip_y(),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
||||
@@ -626,6 +695,28 @@ fn spawn_settings_panel(
|
||||
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.
|
||||
if unlocked_card_backs.len() > 1 {
|
||||
card.spawn(Node {
|
||||
@@ -942,4 +1033,92 @@ mod tests {
|
||||
fn cycle_unlocked_empty_returns_zero() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use solitaire_data::{
|
||||
};
|
||||
|
||||
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::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -72,6 +72,8 @@ impl Plugin for StatsPlugin {
|
||||
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<ForfeitEvent>()
|
||||
.add_event::<InfoToastEvent>()
|
||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||
// clobbers it with a fresh game.
|
||||
.add_systems(
|
||||
@@ -84,6 +86,10 @@ impl Plugin for StatsPlugin {
|
||||
Update,
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
@@ -361,6 +387,25 @@ mod tests {
|
||||
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]
|
||||
fn new_game_after_moves_records_abandoned() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -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) {
|
||||
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
let marker_size = layout.card_size;
|
||||
let font_size = layout.card_size.x * 0.28;
|
||||
|
||||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||||
piles.push(PileType::Stock);
|
||||
@@ -140,15 +153,40 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
|
||||
for pile in piles {
|
||||
let pos = layout.pile_positions[&pile];
|
||||
commands.spawn((
|
||||
let mut entity = commands.spawn((
|
||||
Sprite {
|
||||
color: marker_colour,
|
||||
custom_size: Some(marker_size),
|
||||
..default()
|
||||
},
|
||||
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, 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user