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:
@@ -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<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]
|
||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<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
|
||||
/// 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::<UndoButton>(&mut app),
|
||||
focusable_for::<PauseButton>(&mut app),
|
||||
focusable_for::<HelpButton>(&mut app),
|
||||
focusable_for::<HintButton>(&mut app),
|
||||
focusable_for::<ModesButton>(&mut app),
|
||||
focusable_for::<NewGameButton>(&mut app),
|
||||
] {
|
||||
@@ -2756,6 +2802,10 @@ mod tests {
|
||||
tooltip_for::<HelpButton>(&mut app),
|
||||
"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!(
|
||||
tooltip_for::<ModesButton>(&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::<MenuButton>(&mut app).order, 0);
|
||||
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 5);
|
||||
assert_eq!(focusable_for::<HintButton>(&mut app).order, 4);
|
||||
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 5);
|
||||
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Interaction>>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
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 {
|
||||
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,23 +844,50 @@ fn spawn_overlay(
|
||||
spawn_achievements_section(card, &session.names);
|
||||
}
|
||||
|
||||
// Play Again button
|
||||
card.spawn((
|
||||
// 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(28.0), VAL_SPACE_3),
|
||||
padding: UiRect::axes(Val::Px(20.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() },
|
||||
@@ -836,6 +896,7 @@ fn spawn_overlay(
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Maximum number of achievement names shown explicitly in the win modal before
|
||||
|
||||
Reference in New Issue
Block a user