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
+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::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; 14 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 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]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);