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 { let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ") (ACCENT_PRIMARY, TEXT_PRIMARY, "+ ")
} else { } else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ") (TEXT_DISABLED, TEXT_DISABLED, "- ")
}; };
let tooltip_text = tooltip_for_row(record.unlocked, def); 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 // 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 // the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so // existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators // 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| { .with_children(|b| {
b.spawn(( b.spawn((
StockCountBadgeText, StockCountBadgeText,
Text2d::new(format!("·{count}")), Text2d::new(format!("{count}")),
text_font, text_font,
TextColor(STOCK_BADGE_FG), TextColor(STOCK_BADGE_FG),
// Slightly above the chip background so the digits aren't // 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) { if let Ok(badge_children) = children.get(entity) {
for child in badge_children.iter() { for child in badge_children.iter() {
if let Ok(mut text) = texts.get_mut(child) { if let Ok(mut text) = texts.get_mut(child) {
let new = format!("·{count}"); let new = format!("{count}");
if text.0 != new { if text.0 != new {
text.0 = new; text.0 = new;
} }
@@ -2811,7 +2811,7 @@ mod tests {
// First update inside `app()` runs the spawn path; run one more to // First update inside `app()` runs the spawn path; run one more to
// confirm the in-place update path is also stable. // confirm the in-place update path is also stable.
app.update(); 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)); assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
} }
@@ -2837,7 +2837,7 @@ mod tests {
// initial 24) and assert the badge text follows. // initial 24) and assert the badge text follows.
let mut app = app(); let mut app = app();
// Sanity-check the starting count. // 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>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) { if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
@@ -2845,7 +2845,7 @@ mod tests {
} }
} }
app.update(); 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)); 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::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::window::AppLifecycle;
use chrono::Utc; use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode, GameState}; use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
@@ -200,6 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<crate::events::AchievementUnlockedEvent>() .add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>()
.add_systems( .add_systems(
Update, Update,
poll_pending_new_game_seed.before(GameMutation), 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 /// 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 /// the onboarding modal is visible so a new player's first-game time
/// isn't inflated by reading the tutorial. /// 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( fn tick_elapsed_time(
time: Res<Time>, time: Res<Time>,
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>, mut accumulator: Local<f32>,
mut skip_next_delta: Local<bool>,
paused: Option<Res<crate::pause_plugin::PausedResource>>, paused: Option<Res<crate::pause_plugin::PausedResource>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>, home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
onboarding_screens: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>, 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) if paused.is_some_and(|p| p.0)
|| !home_screens.is_empty() || !home_screens.is_empty()
|| !onboarding_screens.is_empty() || !onboarding_screens.is_empty()
{ {
return; return;
} }
if *skip_next_delta {
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won; let is_won = game.0.is_won;
advance_elapsed( advance_elapsed(
&mut game.0.elapsed_seconds, &mut game.0.elapsed_seconds,
+30
View File
@@ -135,6 +135,36 @@ struct ControlSection {
rows: &'static [ControlRow], 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] = &[ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection { ControlSection {
title: "Gameplay", title: "Gameplay",
+48 -12
View File
@@ -302,6 +302,8 @@ struct ModesPopoverBackdrop;
/// `Toggle*RequestEvent` the click handler fires. /// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)] #[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption { pub enum MenuOption {
Help,
Modes,
Stats, Stats,
Achievements, Achievements,
Profile, Profile,
@@ -698,13 +700,15 @@ fn spawn_action_buttons(
.with_children(|row| { .with_children(|row| {
// The trailing `order` argument feeds `Focusable { group: Hud, order }` // The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading 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); // Undo and Pause are the primary gameplay actions — full brightness.
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1); // Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2); spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3); 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, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4); spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5); spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, 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); 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, tooltip: &'static str,
font: &TextFont, font: &TextFont,
order: i32, order: i32,
text_color: Color,
) { ) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a // Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they // 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), HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .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 { if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label — // Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without // 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 // Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring // a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover. // 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, MenuOption::Stats,
"Stats", "Stats",
@@ -1202,15 +1217,26 @@ fn handle_menu_option_click(
mut profile: MessageWriter<ToggleProfileRequestEvent>, mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>, mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>, mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let mut clicked_any = false; let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query { for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
continue; continue;
} }
clicked_any = true; clicked_any = true;
match option { match option {
MenuOption::Help => {
help.write(HelpRequestEvent);
}
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => { MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent); stats.write(ToggleStatsRequestEvent);
} }
@@ -1235,6 +1261,14 @@ fn handle_menu_option_click(
commands.entity(e).despawn(); 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 /// 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. // tooltip, exact strings matching the approved microcopy.
let mut menu_q = app let mut menu_q = app
.world_mut() .world_mut()
@@ -2902,11 +2936,13 @@ mod tests {
.collect(); .collect();
assert_eq!( assert_eq!(
menu_tooltips.len(), menu_tooltips.len(),
5, 7,
"expected a tooltip on each of the 5 menu rows, got {}", "expected a tooltip on each of the 7 menu rows, got {}",
menu_tooltips.len() menu_tooltips.len()
); );
for expected in [ 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.", "Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.", "Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.", "Your level, XP progress, and sync status.",
+102 -46
View File
@@ -53,9 +53,13 @@ pub struct PausedResource(pub bool);
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct PauseScreen; 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)] #[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. /// Marker on the Resume primary button on the pause modal.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -118,12 +122,13 @@ impl Plugin for PausePlugin {
toggle_pause toggle_pause
.before(SelectionKeySet) .before(SelectionKeySet)
.before(handle_forfeit_keyboard), .before(handle_forfeit_keyboard),
handle_pause_draw_toggle, handle_pause_draw_buttons,
handle_pause_resume_button, handle_pause_resume_button,
handle_pause_forfeit_button, handle_pause_forfeit_button,
handle_forfeit_request, handle_forfeit_request,
handle_forfeit_confirm_buttons, handle_forfeit_confirm_buttons,
handle_forfeit_keyboard, 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 /// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
/// fires `SettingsChangedEvent`. The change takes effect on the next new game. /// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
fn handle_pause_draw_toggle( /// so the rest of the engine sees the update. Change takes effect next game.
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseDrawToggle>)>, 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>, paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>, settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>, path: Option<Res<SettingsStoragePath>>,
@@ -263,22 +270,23 @@ fn handle_pause_draw_toggle(
if !paused.0 { if !paused.0 {
return; return;
} }
let Some(mut settings) = settings else { return }; let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
for interaction in &interaction_query { let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
if *interaction != Interaction::Pressed { if !pressed_one && !pressed_three {
continue; return;
}
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 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. /// 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 — /// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary /// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
/// action buttons, plus a Draw Mode toggle row when settings are /// 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 /// Inline "Draw Mode [Draw 1] [Draw 3]" segmented control + caption.
/// applies to the next game. Spawned inside the modal body. ///
/// 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( fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
mode: DrawMode, mode: DrawMode,
@@ -486,6 +517,10 @@ fn spawn_draw_mode_row(
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
let (one_variant, three_variant) = match mode {
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent parent
.spawn(Node { .spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
@@ -499,14 +534,8 @@ fn spawn_draw_mode_row(
label_font, label_font,
TextColor(TEXT_PRIMARY), TextColor(TEXT_PRIMARY),
)); ));
spawn_modal_button( spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
row, spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
PauseDrawToggle,
draw_mode_label(mode),
None,
ButtonVariant::Secondary,
font_res,
);
}); });
parent.spawn(( parent.spawn((
Text::new("Takes effect next game"), Text::new("Takes effect next game"),
@@ -790,9 +819,9 @@ mod tests {
// Set paused so handle_pause_draw_toggle acts. // Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true; 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(( app.world_mut().spawn((
PauseDrawToggle, PauseDrawThreeButton,
Button, Button,
Interaction::Pressed, Interaction::Pressed,
)); ));
@@ -807,18 +836,16 @@ mod tests {
assert_eq!( assert_eq!(
*mode, *mode,
DrawMode::DrawThree, 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. // Pressing "Draw 1" while DrawThree is active should switch back.
{ app.world_mut().spawn((
let mut interaction_query = app PauseDrawOneButton,
.world_mut() Button,
.query::<&mut Interaction>(); Interaction::Pressed,
for mut i in interaction_query.iter_mut(app.world_mut()) { ));
*i = Interaction::Pressed;
}
}
app.update(); app.update();
let mode2 = &app let mode2 = &app
@@ -829,7 +856,7 @@ mod tests {
assert_eq!( assert_eq!(
*mode2, *mode2,
DrawMode::DrawOne, 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. // 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] #[test]
fn forfeit_confirm_y_also_closes_pause_modal() { fn forfeit_confirm_y_also_closes_pause_modal() {
let mut app = forfeit_app(); let mut app = forfeit_app();