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
+46 -1
View File
@@ -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();