feat(engine): Android polish sweep + hint button + watch replay

Draw-Three waste fan: slot.saturating_sub(1) was a constant shift that
hid slot-0 even when the pile had fewer cards than visible. Fixed to
slot.saturating_sub(rendered_len.saturating_sub(visible)) so small piles
fan correctly and only a genuine buffer card gets hidden. New regression
test covers the small-pile case.

Android toast: game-over "press D / N" message now shows touch-friendly
copy ("Tap the stock...") on Android via cfg gate.

Onboarding: SLIDE_COUNT drops from 3 to 2 on Android so first-time
users skip the keyboard-shortcuts slide (irrelevant on touchscreen).
spawn_slide dispatch is gated identically.

Hint button: added HintButton to the HUD action bar (order 4, between
Help and Modes). Clicking it triggers the async solver hint — same path
as the H key — via optional resources so headless tests stay clean.
All button-order and tooltip tests updated for the new 7-button bar.

Watch Replay: win-summary modal now shows a "Watch Replay" secondary
button alongside "Play Again". It loads the most recent entry from
ReplayHistoryResource and hands it to start_replay_playback, dismissing
the modal. Falls back to an info toast when the replay or playback
plugin is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-11 20:28:20 -07:00
parent 3110702c74
commit 2b01f741b4
5 changed files with 214 additions and 43 deletions
+45 -3
View File
@@ -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 mut y_offset = 0.0_f32;
let rendered_len = cards[render_start..].len();
for (slot, card) in cards[render_start..].iter().enumerate() { for (slot, card) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) { 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. // When len > visible, slot 0 is a hidden buffer card kept at
// Slots 1..=3 are the visible fan (left→right). // x=0 to prevent a flash during the draw tween. When len ≤
slot.saturating_sub(1) as f32 * layout.card_size.x * 0.28 // 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 { } else {
0.0 0.0
}; };
@@ -2050,6 +2055,43 @@ mod tests {
assert_eq!(waste_rendered.last().unwrap().0.id, top_id); 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<u32> =
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] #[test]
fn card_positions_tableau_cards_are_fanned_downward() { fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
+5 -3
View File
@@ -1072,9 +1072,11 @@ fn check_no_moves(
} }
if !moves_ok && !*already_fired { if !moves_ok && !*already_fired {
toast.write(InfoToastEvent( #[cfg(target_os = "android")]
"No moves available \u{2014} press D to draw or N for a new game".to_string(), 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; *already_fired = true;
// Only spawn the overlay if one does not already exist. // Only spawn the overlay if one does not already exist.
if game_over_screens.is_empty() { if game_over_screens.is_empty() {
+57 -6
View File
@@ -242,6 +242,11 @@ pub struct PauseButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HelpButton; 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`] /// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
/// (a small dropdown panel) below the action bar. Each popover row starts /// (a small dropdown panel) below the action bar. Each popover row starts
/// the corresponding game mode. /// the corresponding game mode.
@@ -367,6 +372,7 @@ impl Plugin for HudPlugin {
handle_undo_button, handle_undo_button,
handle_pause_button, handle_pause_button,
handle_help_button, handle_help_button,
handle_hint_button,
handle_modes_button, handle_modes_button,
handle_mode_option_click, handle_mode_option_click,
handle_modes_backdrop_click, handle_modes_backdrop_click,
@@ -702,6 +708,15 @@ fn spawn_action_buttons(
&font, &font,
3, 3,
); );
spawn_action_button(
row,
HintButton,
"Hint",
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
&font,
4,
);
spawn_action_button( spawn_action_button(
row, row,
ModesButton, ModesButton,
@@ -709,7 +724,7 @@ fn spawn_action_buttons(
None, None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font, &font,
4, 5,
); );
spawn_action_button( spawn_action_button(
row, row,
@@ -718,7 +733,7 @@ fn spawn_action_buttons(
Some("N"), Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.", "Start a fresh deal. Confirms first if a game is in progress.",
&font, &font,
5, 6,
); );
}); });
} }
@@ -857,6 +872,36 @@ fn handle_help_button(
} }
} }
fn handle_hint_button(
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
paused: Option<Res<crate::PausedResource>>,
game: Option<Res<GameStateResource>>,
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
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 /// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
/// second click. Mode rows are populated per the player's current level so /// second click. Mode rows are populated per the player's current level so
/// only unlocked options appear. /// only unlocked options appear.
@@ -2648,6 +2693,7 @@ mod tests {
focusable_for::<UndoButton>(&mut app), focusable_for::<UndoButton>(&mut app),
focusable_for::<PauseButton>(&mut app), focusable_for::<PauseButton>(&mut app),
focusable_for::<HelpButton>(&mut app), focusable_for::<HelpButton>(&mut app),
focusable_for::<HintButton>(&mut app),
focusable_for::<ModesButton>(&mut app), focusable_for::<ModesButton>(&mut app),
focusable_for::<NewGameButton>(&mut app), focusable_for::<NewGameButton>(&mut app),
] { ] {
@@ -2756,6 +2802,10 @@ mod tests {
tooltip_for::<HelpButton>(&mut app), tooltip_for::<HelpButton>(&mut app),
"Show controls, rules, and keyboard shortcuts." "Show controls, rules, and keyboard shortcuts."
); );
assert_eq!(
tooltip_for::<HintButton>(&mut app),
"Highlight a suggested move. Cycles through alternatives on repeat taps."
);
assert_eq!( assert_eq!(
tooltip_for::<ModesButton>(&mut app), tooltip_for::<ModesButton>(&mut app),
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack." "Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
@@ -2875,14 +2925,15 @@ mod tests {
fn hud_button_order_matches_spawn_order() { fn hud_button_order_matches_spawn_order() {
let mut app = headless_app(); let mut app = headless_app();
// Visual reading order (left → right): Menu, Undo, Pause, Help, // Visual reading order (left → right): Menu, Undo, Pause, Help,
// Modes, New Game. Their `order` fields must be 0..=5 in that // Hint, Modes, New Game. Their `order` fields must be 0..=6 in
// order so Tab cycles them as the player reads them. // that order so Tab cycles them as the player reads them.
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0); assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1); assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2); assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3); assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4); assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 5); assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
} }
#[test] #[test]
+16 -1
View File
@@ -41,7 +41,13 @@ use crate::ui_theme::{
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Total number of onboarding slides (0-based index goes 0..SLIDE_COUNT-1). /// 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; const SLIDE_COUNT: u8 = 3;
#[cfg(target_os = "android")]
const SLIDE_COUNT: u8 = 2;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Components (private — never re-exported) // Components (private — never re-exported)
@@ -276,6 +282,8 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
match index { match index {
0 => spawn_slide_welcome(commands, font_res), 0 => spawn_slide_welcome(commands, font_res),
1 => spawn_slide_how_to_play(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), 2 => spawn_slide_hotkeys(commands, font_res),
_ => spawn_slide_welcome(commands, font_res), _ => spawn_slide_welcome(commands, font_res),
} }
@@ -664,8 +672,15 @@ mod tests {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
#[test] #[test]
#[cfg(not(target_os = "android"))]
fn slide_count_constant_is_three() { 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] #[test]
+76 -15
View File
@@ -167,10 +167,11 @@ pub struct SessionAchievements {
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct WinSummaryOverlay; 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)] #[derive(Component, Debug)]
enum WinSummaryButton { enum WinSummaryButton {
PlayAgain, PlayAgain,
WatchReplay,
} }
/// Marker for one row of the win-modal score-breakdown reveal. /// 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 /// Handles "Play Again" and "Watch Replay" in the win-summary modal.
/// the player presses "Play Again". /// Handles "Play Again" and "Watch Replay" in the win-summary modal.
fn handle_win_summary_buttons( fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
overlays: Query<Entity, With<WinSummaryOverlay>>, overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands, mut commands: Commands,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
) { ) {
for (interaction, button) in &interaction_query { // Collect all pressed buttons first to avoid moving `playback` inside the loop.
if *interaction != Interaction::Pressed { let pressed: Vec<&WinSummaryButton> = interaction_query
continue; .iter()
} .filter(|(i, _)| **i == Interaction::Pressed)
.map(|(_, b)| b)
.collect();
for button in pressed {
match button { match button {
WinSummaryButton::PlayAgain => { WinSummaryButton::PlayAgain => {
// Despawn the modal.
for entity in &overlays { for entity in &overlays {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
new_game.write(NewGameRequestEvent::default()); 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,23 +844,50 @@ fn spawn_overlay(
spawn_achievements_section(card, &session.names); spawn_achievements_section(card, &session.names);
} }
// Play Again button // Button row: Watch Replay + Play Again side by side.
card.spawn(( 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, WinSummaryButton::PlayAgain,
Button, Button,
Node { Node {
padding: UiRect::axes(Val::Px(28.0), VAL_SPACE_3), padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
margin: UiRect::top(VAL_SPACE_2),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default() ..default()
}, },
BackgroundColor(ACCENT_PRIMARY), BackgroundColor(ACCENT_PRIMARY),
)) ))
.with_children(|b| { .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(( b.spawn((
Text::new("Play Again \u{21B5}"), Text::new("Play Again \u{21B5}"),
TextFont { font_size: TYPE_BODY_LG, ..default() }, TextFont { font_size: TYPE_BODY_LG, ..default() },
@@ -836,6 +896,7 @@ fn spawn_overlay(
}); });
}); });
}); });
});
} }
/// Maximum number of achievement names shown explicitly in the win modal before /// Maximum number of achievement names shown explicitly in the win modal before