diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index fac1c63..7d45c15 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -693,11 +693,16 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve }; let mut y_offset = 0.0_f32; + let rendered_len = cards[render_start..].len(); for (slot, card) in cards[render_start..].iter().enumerate() { let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) { - // Slot 0 is the hidden extra card; keep it at x=0 under the stack. - // Slots 1..=3 are the visible fan (left→right). - slot.saturating_sub(1) as f32 * layout.card_size.x * 0.28 + // When len > visible, slot 0 is a hidden buffer card kept at + // x=0 to prevent a flash during the draw tween. When len ≤ + // visible (small pile), every card is visible and should fan + // normally — no card is hidden, so the shift is 0. + let visible = 3_usize; + let hidden = rendered_len.saturating_sub(visible); + slot.saturating_sub(hidden) as f32 * layout.card_size.x * 0.28 } else { 0.0 }; @@ -2050,6 +2055,43 @@ mod tests { assert_eq!(waste_rendered.last().unwrap().0.id, top_id); } + #[test] + fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() { + // Regression: slot.saturating_sub(1) always hid slot-0 even when the + // pile was too small to have a buffer card, collapsing 2 visible cards + // onto x=0 instead of fanning them. + use solitaire_core::game_state::DrawMode; + let mut g = GameState::new(42, DrawMode::DrawThree); + // Draw exactly once — in Draw-Three mode with a full stock this gives + // 3 waste cards (still ≤ visible=3, so no hidden buffer needed). + let _ = g.draw(); + let waste_pile = &g.piles[&PileType::Waste].cards; + // We need exactly 2 or 3 waste cards to hit the small-pile path. + // One draw in Draw-Three adds up to 3 cards; take the first 2 if needed. + let count = waste_pile.len(); + assert!(count >= 2, "need at least 2 waste cards"); + + let waste_ids: std::collections::HashSet = + waste_pile.iter().map(|c| c.id).collect(); + let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0); + let positions = card_positions(&g, &layout); + + let mut waste_rendered: Vec<_> = positions + .iter() + .filter(|(card, _, _)| waste_ids.contains(&card.id)) + .collect(); + // All waste cards should be visible (no hidden buffer when len ≤ visible). + assert_eq!(waste_rendered.len(), count, "all waste cards rendered when pile ≤ visible"); + + // Cards must be fanned with distinct x positions (or equal for 1-card). + waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap()); + if count >= 2 { + let last = waste_rendered.last().unwrap(); + let second_last = &waste_rendered[waste_rendered.len() - 2]; + assert!(last.1.x > second_last.1.x, "top 2 waste cards must fan to distinct x positions"); + } + } + #[test] fn card_positions_tableau_cards_are_fanned_downward() { let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 12c768f..c92d07b 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -1072,9 +1072,11 @@ fn check_no_moves( } if !moves_ok && !*already_fired { - toast.write(InfoToastEvent( - "No moves available \u{2014} press D to draw or N for a new game".to_string(), - )); + #[cfg(target_os = "android")] + let no_moves_msg = "No moves available \u{2014} tap the stock to draw or start a new game"; + #[cfg(not(target_os = "android"))] + let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game"; + toast.write(InfoToastEvent(no_moves_msg.to_string())); *already_fired = true; // Only spawn the overlay if one does not already exist. if game_over_screens.is_empty() { diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 4fca9c6..ec51e3a 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -242,6 +242,11 @@ pub struct PauseButton; #[derive(Component, Debug)] pub struct HelpButton; +/// Marker on the "Hint" action button. Click spawns an async solver task +/// (same as the `H` keyboard accelerator) and highlights the suggested card. +#[derive(Component, Debug)] +pub struct HintButton; + /// Marker on the "Modes" action button. Click toggles the [`ModesPopover`] /// (a small dropdown panel) below the action bar. Each popover row starts /// the corresponding game mode. @@ -367,6 +372,7 @@ impl Plugin for HudPlugin { handle_undo_button, handle_pause_button, handle_help_button, + handle_hint_button, handle_modes_button, handle_mode_option_click, handle_modes_backdrop_click, @@ -702,6 +708,15 @@ fn spawn_action_buttons( &font, 3, ); + spawn_action_button( + row, + HintButton, + "Hint", + Some("H"), + "Highlight a suggested move. Cycles through alternatives on repeat taps.", + &font, + 4, + ); spawn_action_button( row, ModesButton, @@ -709,7 +724,7 @@ fn spawn_action_buttons( None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, - 4, + 5, ); spawn_action_button( row, @@ -718,7 +733,7 @@ fn spawn_action_buttons( Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, - 5, + 6, ); }); } @@ -857,6 +872,36 @@ fn handle_help_button( } } +fn handle_hint_button( + interaction_query: Query<&Interaction, (With, Changed)>, + paused: Option>, + game: Option>, + solver_config: Option>, + mut pending_hint: Option>, + mut info_toast: MessageWriter, +) { + for interaction in &interaction_query { + if *interaction != Interaction::Pressed { + continue; + } + if paused.as_ref().is_some_and(|p| p.0) { + return; + } + let Some(ref g) = game else { return }; + if g.0.is_won { + #[cfg(target_os = "android")] + let won_msg = "Game won! Tap New Game to play again"; + #[cfg(not(target_os = "android"))] + let won_msg = "Game won! Press N for a new game"; + info_toast.write(InfoToastEvent(won_msg.to_string())); + return; + } + if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { + hint.spawn(g.0.clone(), cfg.0); + } + } +} + /// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on /// second click. Mode rows are populated per the player's current level so /// only unlocked options appear. @@ -2648,6 +2693,7 @@ mod tests { focusable_for::(&mut app), focusable_for::(&mut app), focusable_for::(&mut app), + focusable_for::(&mut app), focusable_for::(&mut app), focusable_for::(&mut app), ] { @@ -2756,6 +2802,10 @@ mod tests { tooltip_for::(&mut app), "Show controls, rules, and keyboard shortcuts." ); + assert_eq!( + tooltip_for::(&mut app), + "Highlight a suggested move. Cycles through alternatives on repeat taps." + ); assert_eq!( tooltip_for::(&mut app), "Switch modes: Classic, Daily, Zen, Challenge, Time Attack." @@ -2875,14 +2925,15 @@ mod tests { fn hud_button_order_matches_spawn_order() { let mut app = headless_app(); // Visual reading order (left → right): Menu, Undo, Pause, Help, - // Modes, New Game. Their `order` fields must be 0..=5 in that - // order so Tab cycles them as the player reads them. + // Hint, Modes, New Game. Their `order` fields must be 0..=6 in + // that order so Tab cycles them as the player reads them. assert_eq!(focusable_for::(&mut app).order, 0); assert_eq!(focusable_for::(&mut app).order, 1); assert_eq!(focusable_for::(&mut app).order, 2); assert_eq!(focusable_for::(&mut app).order, 3); - assert_eq!(focusable_for::(&mut app).order, 4); - assert_eq!(focusable_for::(&mut app).order, 5); + assert_eq!(focusable_for::(&mut app).order, 4); + assert_eq!(focusable_for::(&mut app).order, 5); + assert_eq!(focusable_for::(&mut app).order, 6); } #[test] diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index e1ba188..4ab23c5 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -41,7 +41,13 @@ use crate::ui_theme::{ // --------------------------------------------------------------------------- /// Total number of onboarding slides (0-based index goes 0..SLIDE_COUNT-1). +/// +/// Android omits the keyboard-shortcuts slide (index 2) because there is no +/// physical keyboard on a touchscreen device, dropping the count to 2. +#[cfg(not(target_os = "android"))] const SLIDE_COUNT: u8 = 3; +#[cfg(target_os = "android")] +const SLIDE_COUNT: u8 = 2; // --------------------------------------------------------------------------- // Components (private — never re-exported) @@ -276,6 +282,8 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc match index { 0 => spawn_slide_welcome(commands, font_res), 1 => spawn_slide_how_to_play(commands, font_res), + // Slide 2 (keyboard shortcuts) is desktop-only; Android has no keyboard. + #[cfg(not(target_os = "android"))] 2 => spawn_slide_hotkeys(commands, font_res), _ => spawn_slide_welcome(commands, font_res), } @@ -664,8 +672,15 @@ mod tests { // ----------------------------------------------------------------------- #[test] + #[cfg(not(target_os = "android"))] fn slide_count_constant_is_three() { - assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3"); + assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3 on desktop"); + } + + #[test] + #[cfg(target_os = "android")] + fn slide_count_constant_is_two_on_android() { + assert_eq!(SLIDE_COUNT, 2, "SLIDE_COUNT must be 2 on Android (no keyboard slide)"); } #[test] diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index 6b23fa9..b518df2 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -167,10 +167,11 @@ pub struct SessionAchievements { #[derive(Component, Debug)] pub struct WinSummaryOverlay; -/// Marker on the "Play Again" button inside the win-summary modal. +/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal. #[derive(Component, Debug)] enum WinSummaryButton { PlayAgain, + WatchReplay, } /// Marker for one row of the win-modal score-breakdown reveal. @@ -602,26 +603,58 @@ fn spawn_win_summary_after_delay( } } -/// Despawns the win-summary modal and fires `NewGameRequestEvent` when -/// the player presses "Play Again". +/// Handles "Play Again" and "Watch Replay" in the win-summary modal. +/// Handles "Play Again" and "Watch Replay" in the win-summary modal. fn handle_win_summary_buttons( interaction_query: Query<(&Interaction, &WinSummaryButton), Changed>, overlays: Query>, mut commands: Commands, mut new_game: MessageWriter, + mut toast: MessageWriter, + history: Option>, + mut playback: Option>, ) { - for (interaction, button) in &interaction_query { - if *interaction != Interaction::Pressed { - continue; - } + // Collect all pressed buttons first to avoid moving `playback` inside the loop. + let pressed: Vec<&WinSummaryButton> = interaction_query + .iter() + .filter(|(i, _)| **i == Interaction::Pressed) + .map(|(_, b)| b) + .collect(); + + for button in pressed { match button { WinSummaryButton::PlayAgain => { - // Despawn the modal. for entity in &overlays { commands.entity(entity).despawn(); } new_game.write(NewGameRequestEvent::default()); } + WinSummaryButton::WatchReplay => { + let latest = history + .as_ref() + .and_then(|h| h.0.replays.last()) + .cloned(); + match (latest, playback.as_mut()) { + (Some(replay), Some(pb)) => { + for entity in &overlays { + commands.entity(entity).despawn(); + } + crate::replay_playback::start_replay_playback( + &mut commands, + pb, + replay, + ); + } + (Some(_), None) => { + toast.write(InfoToastEvent( + "Replay playback not available".to_string(), + )); + } + (None, _) => { + toast.write(InfoToastEvent("No replay saved yet".to_string())); + } + } + } } } } @@ -811,28 +844,56 @@ fn spawn_overlay( spawn_achievements_section(card, &session.names); } - // Play Again button - card.spawn(( - WinSummaryButton::PlayAgain, - Button, - Node { - padding: UiRect::axes(Val::Px(28.0), VAL_SPACE_3), - justify_content: JustifyContent::Center, - margin: UiRect::top(VAL_SPACE_2), - border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), - ..default() - }, - BackgroundColor(ACCENT_PRIMARY), - )) - .with_children(|b| { - // Append the Enter / Return glyph so keyboard players see - // the accelerator on the button itself — mirrors the - // chip-style hints on every modal button helper. - b.spawn(( - Text::new("Play Again \u{21B5}"), - TextFont { font_size: TYPE_BODY_LG, ..default() }, - TextColor(BG_BASE), - )); + // Button row: Watch Replay + Play Again side by side. + card.spawn(Node { + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::Center, + column_gap: VAL_SPACE_3, + margin: UiRect::top(VAL_SPACE_2), + ..default() + }) + .with_children(|row| { + // Watch Replay (secondary style) + row.spawn(( + WinSummaryButton::WatchReplay, + Button, + Node { + padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3), + justify_content: JustifyContent::Center, + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::NONE), + BorderColor::all(ACCENT_PRIMARY), + )) + .with_children(|b| { + b.spawn(( + Text::new("Watch Replay"), + TextFont { font_size: TYPE_BODY_LG, ..default() }, + TextColor(ACCENT_PRIMARY), + )); + }); + + // Play Again (primary style) + row.spawn(( + WinSummaryButton::PlayAgain, + Button, + Node { + padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3), + justify_content: JustifyContent::Center, + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BackgroundColor(ACCENT_PRIMARY), + )) + .with_children(|b| { + b.spawn(( + Text::new("Play Again \u{21B5}"), + TextFont { font_size: TYPE_BODY_LG, ..default() }, + TextColor(BG_BASE), + )); + }); }); }); });