fix(android): UX pass — pause stacking, timer, help content, achievement glyphs

BUG-1: pause_plugin — auto_resume_on_overlay system despawns PauseScreen
whenever any other ModalScrim becomes live; fixes Pause modal stacking on
top of Stats / Settings / Help / Achievements / Profile overlays opened
from the HUD menu while paused.

BUG-2: game_plugin — tick_elapsed_time skips the first delta_secs after
AppLifecycle::WillSuspend/Suspended so the Android post-resume frame spike
(equal to the full suspension duration) no longer inflates the in-game timer.

UX-2: help_plugin — Android build gets a touch-specific CONTROL_SECTIONS
(Tap / New Game / HUD buttons); desktop sections (Mouse, Keyboard drag,
Mode Launcher, Overlays) remain on non-Android builds only.

UX-3: achievement_plugin — replace \u{25CB} (○) and \u{2713} (✓) prefixes
with ASCII "- " / "+ "; both Geometric Shapes codepoints are absent from
FiraMono and rendered as the fallback letter "o".

Phase 8 work from previous session (already compiled, not yet committed):
hud_plugin — HUD visual hierarchy (Undo/Pause bright, nav buttons dim);
  menu popover — Help + Game Modes entries added (7 items total).
card_plugin — stock badge drops "·" prefix, shows plain count.
pause_plugin — Draw Mode segmented control (Draw 1 / Draw 3 explicit buttons).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-12 20:02:39 -07:00
parent d204662415
commit 04f3dab563
6 changed files with 207 additions and 66 deletions
+2 -2
View File
@@ -533,9 +533,9 @@ fn spawn_achievements_screen(
}
let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
(ACCENT_PRIMARY, TEXT_PRIMARY, "+ ")
} else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
(TEXT_DISABLED, TEXT_DISABLED, "- ")
};
let tooltip_text = tooltip_for_row(record.unlocked, def);
+6 -6
View File
@@ -1484,7 +1484,7 @@ fn update_stock_empty_indicator(
// ---------------------------------------------------------------------------
// Stock-pile remaining-count badge
//
// Shows a small "·N" chip pinned to the top-right corner of the stock pile so
// Shows a small "N" chip pinned to the top-right corner of the stock pile so
// the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators
@@ -1562,7 +1562,7 @@ fn spawn_stock_count_badge(
.with_children(|b| {
b.spawn((
StockCountBadgeText,
Text2d::new(format!("·{count}")),
Text2d::new(format!("{count}")),
text_font,
TextColor(STOCK_BADGE_FG),
// Slightly above the chip background so the digits aren't
@@ -1624,7 +1624,7 @@ fn update_stock_count_badge(
if let Ok(badge_children) = children.get(entity) {
for child in badge_children.iter() {
if let Ok(mut text) = texts.get_mut(child) {
let new = format!("·{count}");
let new = format!("{count}");
if text.0 != new {
text.0 = new;
}
@@ -2811,7 +2811,7 @@ mod tests {
// First update inside `app()` runs the spawn path; run one more to
// confirm the in-place update path is also stable.
app.update();
assert_eq!(stock_badge_text(&mut app), "·24");
assert_eq!(stock_badge_text(&mut app), "24");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
@@ -2837,7 +2837,7 @@ mod tests {
// initial 24) and assert the badge text follows.
let mut app = app();
// Sanity-check the starting count.
assert_eq!(stock_badge_text(&mut app), "·24");
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
@@ -2845,7 +2845,7 @@ mod tests {
}
}
app.update();
assert_eq!(stock_badge_text(&mut app), "·23");
assert_eq!(stock_badge_text(&mut app), "23");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
+19
View File
@@ -11,6 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::window::AppLifecycle;
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
@@ -200,6 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>()
.add_systems(
Update,
poll_pending_new_game_seed.before(GameMutation),
@@ -259,20 +261,37 @@ pub fn advance_elapsed(
/// timer doesn't tick before the player commits to a deal; stops while
/// the onboarding modal is visible so a new player's first-game time
/// isn't inflated by reading the tutorial.
///
/// On Android the first frame after the app is resumed from background
/// can carry a very large `delta_secs` equal to the entire suspension
/// period. `skip_next_delta` is set to `true` on `WillSuspend` /
/// `Suspended` so that frame's delta is dropped instead of applied.
#[allow(clippy::too_many_arguments)]
fn tick_elapsed_time(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>,
mut skip_next_delta: Local<bool>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
onboarding_screens: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
mut lifecycle: MessageReader<AppLifecycle>,
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillSuspend | AppLifecycle::Suspended) {
*skip_next_delta = true;
}
}
if paused.is_some_and(|p| p.0)
|| !home_screens.is_empty()
|| !onboarding_screens.is_empty()
{
return;
}
if *skip_next_delta {
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won;
advance_elapsed(
&mut game.0.elapsed_seconds,
+30
View File
@@ -135,6 +135,36 @@ struct ControlSection {
rows: &'static [ControlRow],
}
#[cfg(target_os = "android")]
const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Touch",
rows: &[
ControlRow { keys: "Tap stock", description: "Draw from stock" },
ControlRow { keys: "Drag card", description: "Move cards between piles" },
ControlRow { keys: "Tap foundation area", description: "Auto-move top card to foundation" },
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "New+", description: "Start a new Classic game" },
ControlRow { keys: "Modes↓", description: "Pick Daily, Zen, Challenge, or Time Attack" },
],
},
ControlSection {
title: "HUD buttons",
rows: &[
ControlRow { keys: "", description: "Undo last move" },
ControlRow { keys: "||", description: "Pause / resume" },
ControlRow { keys: "?", description: "This help screen" },
ControlRow { keys: "", description: "Show a hint" },
ControlRow { keys: "", description: "Menu: Stats, Settings, Profile, Achievements" },
],
},
];
#[cfg(not(target_os = "android"))]
const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Gameplay",
+48 -12
View File
@@ -302,6 +302,8 @@ struct ModesPopoverBackdrop;
/// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption {
Help,
Modes,
Stats,
Achievements,
Profile,
@@ -698,13 +700,15 @@ fn spawn_action_buttons(
.with_children(|row| {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0);
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1);
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3);
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5);
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6);
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
});
}
@@ -729,6 +733,7 @@ fn spawn_action_button<M: Component>(
tooltip: &'static str,
font: &TextFont,
order: i32,
text_color: Color,
) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they
@@ -777,7 +782,7 @@ fn spawn_action_button<M: Component>(
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without
@@ -1102,7 +1107,17 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover.
let rows: [(MenuOption, &'static str, &'static str); 5] = [
let rows: [(MenuOption, &'static str, &'static str); 7] = [
(
MenuOption::Help,
"Help",
"Show controls, rules, and keyboard shortcuts.",
),
(
MenuOption::Modes,
"Game Modes",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
),
(
MenuOption::Stats,
"Stats",
@@ -1202,15 +1217,26 @@ fn handle_menu_option_click(
mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
MenuOption::Help => {
help.write(HelpRequestEvent);
}
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent);
}
@@ -1235,6 +1261,14 @@ fn handle_menu_option_click(
commands.entity(e).despawn();
}
}
if open_modes {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
@@ -2891,7 +2925,7 @@ mod tests {
);
}
// Same contract for MenuOption rows: five entries, each with a
// Same contract for MenuOption rows: seven entries, each with a
// tooltip, exact strings matching the approved microcopy.
let mut menu_q = app
.world_mut()
@@ -2902,11 +2936,13 @@ mod tests {
.collect();
assert_eq!(
menu_tooltips.len(),
5,
"expected a tooltip on each of the 5 menu rows, got {}",
7,
"expected a tooltip on each of the 7 menu rows, got {}",
menu_tooltips.len()
);
for expected in [
"Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
+102 -46
View File
@@ -53,9 +53,13 @@ pub struct PausedResource(pub bool);
#[derive(Component, Debug)]
pub struct PauseScreen;
/// Marker on the draw-mode toggle button inside the pause overlay.
/// Marker on the "Draw 1" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawToggle;
struct PauseDrawOneButton;
/// Marker on the "Draw 3" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawThreeButton;
/// Marker on the Resume primary button on the pause modal.
#[derive(Component, Debug)]
@@ -118,12 +122,13 @@ impl Plugin for PausePlugin {
toggle_pause
.before(SelectionKeySet)
.before(handle_forfeit_keyboard),
handle_pause_draw_toggle,
handle_pause_draw_buttons,
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
auto_resume_on_overlay,
),
);
}
@@ -249,12 +254,14 @@ fn toggle_pause(
}
}
/// Handles the draw-mode toggle button on the pause overlay.
/// Handles the draw-mode segmented control on the pause overlay.
///
/// Toggling flips the draw mode in `SettingsResource`, persists settings, and
/// fires `SettingsChangedEvent`. The change takes effect on the next new game.
fn handle_pause_draw_toggle(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseDrawToggle>)>,
/// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
/// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
/// so the rest of the engine sees the update. Change takes effect next game.
fn handle_pause_draw_buttons(
draw_one_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawOneButton>)>,
draw_three_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawThreeButton>)>,
paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>,
@@ -263,22 +270,23 @@ fn handle_pause_draw_toggle(
if !paused.0 {
return;
}
let Some(mut settings) = settings else { return };
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
settings.0.draw_mode = match settings.0.draw_mode {
DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne,
};
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode toggle: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
if !pressed_one && !pressed_three {
return;
}
let Some(mut settings) = settings else { return };
let new_mode = if pressed_one { DrawMode::DrawOne } else { DrawMode::DrawThree };
if settings.0.draw_mode == new_mode {
return;
}
settings.0.draw_mode = new_mode;
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode change: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
}
/// Closes the pause modal when the player clicks the Resume button.
@@ -423,6 +431,27 @@ fn close_forfeit_modal(
}
}
/// Automatically closes the pause modal when any non-pause overlay opens
/// on top of it (Stats, Settings, Help, Achievements, Profile, etc.).
///
/// The player reaches these overlays via the HUD menu while paused, which
/// causes both the pause modal and the overlay to be live simultaneously.
/// That is always unintentional — the overlay should own the screen.
fn auto_resume_on_overlay(
mut commands: Commands,
pause_screens: Query<Entity, With<PauseScreen>>,
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
mut paused: ResMut<PausedResource>,
) {
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
return;
}
for entity in &pause_screens {
commands.entity(entity).despawn();
}
paused.0 = false;
}
/// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
/// action buttons, plus a Draw Mode toggle row when settings are
@@ -469,8 +498,10 @@ fn spawn_pause_screen(
});
}
/// Inline "Draw Mode [Draw 1]" row + a caption explaining the change
/// applies to the next game. Spawned inside the modal body.
/// Inline "Draw Mode [Draw 1] [Draw 3]" segmented control + caption.
///
/// The active option renders as `Secondary` (elevated), the inactive one as
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands,
mode: DrawMode,
@@ -486,6 +517,10 @@ fn spawn_draw_mode_row(
font_size: TYPE_CAPTION,
..default()
};
let (one_variant, three_variant) = match mode {
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
@@ -499,14 +534,8 @@ fn spawn_draw_mode_row(
label_font,
TextColor(TEXT_PRIMARY),
));
spawn_modal_button(
row,
PauseDrawToggle,
draw_mode_label(mode),
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
});
parent.spawn((
Text::new("Takes effect next game"),
@@ -790,9 +819,9 @@ mod tests {
// Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true;
// Spawn a PauseDrawToggle button with Pressed interaction.
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
app.world_mut().spawn((
PauseDrawToggle,
PauseDrawThreeButton,
Button,
Interaction::Pressed,
));
@@ -807,18 +836,16 @@ mod tests {
assert_eq!(
*mode,
DrawMode::DrawThree,
"draw mode must flip from DrawOne to DrawThree when toggle is pressed"
"pressing Draw 3 must set mode to DrawThree"
);
// A second press should flip back.
{
let mut interaction_query = app
.world_mut()
.query::<&mut Interaction>();
for mut i in interaction_query.iter_mut(app.world_mut()) {
*i = Interaction::Pressed;
}
}
// Pressing "Draw 1" while DrawThree is active should switch back.
app.world_mut().spawn((
PauseDrawOneButton,
Button,
Interaction::Pressed,
));
app.update();
let mode2 = &app
@@ -829,7 +856,7 @@ mod tests {
assert_eq!(
*mode2,
DrawMode::DrawOne,
"draw mode must flip back from DrawThree to DrawOne on second press"
"pressing Draw 1 must set mode to DrawOne"
);
// Verify a SettingsChangedEvent was fired.
@@ -1093,6 +1120,35 @@ mod tests {
);
}
/// When a non-pause modal scrim appears (e.g. Settings overlay opens
/// from the menu while game is paused), `auto_resume_on_overlay` must
/// despawn the pause modal and clear `PausedResource`.
#[test]
fn auto_resume_closes_pause_when_overlay_opens() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
1
);
// Simulate another overlay opening (e.g. Stats) by spawning a bare ModalScrim.
app.world_mut().spawn(ModalScrim);
app.update();
assert!(
!app.world().resource::<PausedResource>().0,
"auto_resume_on_overlay must clear PausedResource when another modal opens"
);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
0,
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
);
}
#[test]
fn forfeit_confirm_y_also_closes_pause_modal() {
let mut app = forfeit_app();