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:
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
}
|
||||
settings.0.draw_mode = match settings.0.draw_mode {
|
||||
DrawMode::DrawOne => DrawMode::DrawThree,
|
||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
||||
};
|
||||
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 toggle: {e}");
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user