feat(engine): convert StatsScreen to modal scaffold + Done button

Phase 3 step 5b of the UX overhaul. Wraps the existing 8-cell stats
grid + progression / weekly-goals / time-attack sections inside the
standard modal scaffold. The cell layout (the audit's pick for
"best layout in the codebase") is preserved.

Changes:
- spawn_stats_screen now calls spawn_modal(StatsScreen, ...) and
  populates the card with the same content as before, retoned to
  ui_theme: stat values are TYPE_HEADLINE in ACCENT_PRIMARY (yellow
  numbers pop against the midnight-purple card), labels are TYPE_BODY
  in TEXT_SECONDARY.
- Stat cells lose their 6%-alpha-white fill (clashed with the new
  card surface) and gain a BORDER_SUBTLE outline at RADIUS_SM
  instead — same visual purpose, fits the new palette.
- Section headers ("Progression", "Weekly Goals") use STATE_INFO and
  TEXT_SECONDARY respectively at TYPE_BODY_LG.
- Time Attack callout uses STATE_WARNING.
- "Press S to close" prose hint replaced by a primary "Done" button
  carrying its "S" hotkey chip.

A new handle_stats_close_button system mirrors the keyboard `S`
toggle for clicks. font_res threaded through toggle_stats_screen so
the modal scaffold can pick up FiraMono.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 01:12:55 +00:00
parent deb034c5fb
commit 75fc3aa3d6
+110 -72
View File
@@ -22,8 +22,17 @@ use crate::events::{
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::font_plugin::FontResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL,
};
/// Bevy resource wrapping the current stats. /// Bevy resource wrapping the current stats.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
@@ -102,7 +111,8 @@ impl Plugin for StatsPlugin {
Update, Update,
handle_forfeit.before(GameMutation), handle_forfeit.before(GameMutation),
) )
.add_systems(Update, toggle_stats_screen.after(GameMutation)); .add_systems(Update, toggle_stats_screen.after(GameMutation))
.add_systems(Update, handle_stats_close_button);
} }
} }
@@ -181,6 +191,12 @@ fn handle_forfeit(
} }
} }
/// Marker on the "Done" button inside the Stats modal. Click despawns
/// the overlay; `S` keyboard shortcut toggles it the same way.
#[derive(Component, Debug)]
pub struct StatsCloseButton;
#[allow(clippy::too_many_arguments)]
fn toggle_stats_screen( fn toggle_stats_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
@@ -188,6 +204,7 @@ fn toggle_stats_screen(
stats: Res<StatsResource>, stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>, time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -202,17 +219,34 @@ fn toggle_stats_screen(
&stats.0, &stats.0,
progress.as_deref().map(|p| &p.0), progress.as_deref().map(|p| &p.0),
time_attack.as_deref(), time_attack.as_deref(),
font_res.as_deref(),
); );
} }
} }
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `S` accelerator does.
fn handle_stats_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_stats_screen( fn spawn_stats_screen(
commands: &mut Commands, commands: &mut Commands,
stats: &StatsSnapshot, stats: &StatsSnapshot,
progress: Option<&PlayerProgress>, progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>, time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>,
) { ) {
// --- primary stat cells (tasks #65, #66, and #38) --- // --- primary stat cells ---
let win_rate_str = format_win_rate(stats); let win_rate_str = format_win_rate(stats);
let played_str = format_stat_value(stats.games_played); let played_str = format_stat_value(stats.games_played);
let won_str = format_stat_value(stats.games_won); let won_str = format_stat_value(stats.games_won);
@@ -222,44 +256,30 @@ fn spawn_stats_screen(
let best_score_str = format_optional_u32(stats.best_single_score); let best_score_str = format_optional_u32(stats.best_single_score);
let best_streak_str = format_stat_value(stats.win_streak_best); let best_streak_str = format_stat_value(stats.win_streak_best);
commands let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
.spawn(( let font_section = TextFont {
StatsScreen, font: font_handle.clone(),
Node { font_size: TYPE_BODY_LG,
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
row_gap: Val::Px(6.0),
padding: UiRect::all(Val::Px(24.0)),
overflow: Overflow::clip(),
..default() ..default()
}, };
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)), let font_row = TextFont {
ZIndex(200), font: font_handle,
)) font_size: TYPE_BODY,
.with_children(|root| { ..default()
// Title };
root.spawn((
Text::new("Statistics"),
TextFont { font_size: 28.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.3)),
));
// Two-column grid of stat cells spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
root.spawn(Node { spawn_modal_header(card, "Statistics", font_res);
// --- primary stat cells grid ---
card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap, flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::FlexStart, align_items: AlignItems::FlexStart,
column_gap: Val::Px(24.0), column_gap: VAL_SPACE_4,
row_gap: Val::Px(16.0), row_gap: VAL_SPACE_3,
width: Val::Percent(100.0), width: Val::Percent(100.0),
margin: UiRect::top(Val::Px(16.0)),
..default() ..default()
}) })
.with_children(|grid| { .with_children(|grid| {
@@ -273,12 +293,12 @@ fn spawn_stats_screen(
spawn_stat_cell(grid, &best_streak_str, "Best Streak"); spawn_stat_cell(grid, &best_streak_str, "Best Streak");
}); });
// Progression section // --- progression section ---
if let Some(p) = progress { if let Some(p) = progress {
root.spawn(( card.spawn((
Text::new("Progression"), Text::new("Progression"),
TextFont { font_size: 22.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.7, 0.9, 1.0)), TextColor(STATE_INFO),
)); ));
let level_str = format_stat_value(p.level); let level_str = format_stat_value(p.level);
@@ -287,13 +307,13 @@ fn spawn_stats_screen(
let daily_str = format_stat_value(p.daily_challenge_streak); let daily_str = format_stat_value(p.daily_challenge_streak);
let challenge_str = challenge_progress_label(p.challenge_index); let challenge_str = challenge_progress_label(p.challenge_index);
root.spawn(Node { card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap, flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::FlexStart, align_items: AlignItems::FlexStart,
column_gap: Val::Px(24.0), column_gap: VAL_SPACE_4,
row_gap: Val::Px(12.0), row_gap: VAL_SPACE_3,
width: Val::Percent(100.0), width: Val::Percent(100.0),
..default() ..default()
}) })
@@ -305,56 +325,65 @@ fn spawn_stats_screen(
spawn_stat_cell(grid, &challenge_str, "Challenge"); spawn_stat_cell(grid, &challenge_str, "Challenge");
}); });
// Weekly goals row // Weekly goals
root.spawn(( card.spawn((
Text::new("Weekly Goals"), Text::new("Weekly Goals"),
TextFont { font_size: 18.0, ..default() }, font_section.clone(),
TextColor(Color::srgb(0.8, 0.8, 0.8)), TextColor(TEXT_SECONDARY),
)); ));
for goal in WEEKLY_GOALS { for goal in WEEKLY_GOALS {
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0); let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
root.spawn(( card.spawn((
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)), Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
TextFont { font_size: 16.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.85, 0.85, 0.80)), TextColor(TEXT_PRIMARY),
)); ));
} }
// Unlocks row // Unlocks line
root.spawn(( card.spawn((
Text::new(format!( Text::new(format!(
"Card Backs: {} | Backgrounds: {}", "Card Backs: {} | Backgrounds: {}",
format_id_list(&p.unlocked_card_backs), format_id_list(&p.unlocked_card_backs),
format_id_list(&p.unlocked_backgrounds), format_id_list(&p.unlocked_backgrounds),
)), )),
TextFont { font_size: 16.0, ..default() }, font_row.clone(),
TextColor(Color::srgb(0.75, 0.75, 0.75)), TextColor(TEXT_SECONDARY),
)); ));
} }
// Time Attack section // --- Time Attack section ---
if let Some(ta) = time_attack if let Some(ta) = time_attack
&& ta.active { && ta.active {
let mins = (ta.remaining_secs / 60.0).floor() as u64; let mins = (ta.remaining_secs / 60.0).floor() as u64;
let secs = (ta.remaining_secs % 60.0).floor() as u64; let secs = (ta.remaining_secs % 60.0).floor() as u64;
root.spawn(( card.spawn((
Text::new(format!("Time Attack — {mins}m {secs:02}s left | Wins: {}", ta.wins)), Text::new(format!(
TextFont { font_size: 18.0, ..default() }, "Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
TextColor(Color::srgb(1.0, 0.6, 0.2)), ta.wins
)),
font_section.clone(),
TextColor(STATE_WARNING),
)); ));
} }
// Dismiss hint spawn_modal_actions(card, |actions| {
root.spawn(( spawn_modal_button(
Text::new("Press S to close"), actions,
TextFont { font_size: 16.0, ..default() }, StatsCloseButton,
TextColor(Color::srgb(0.6, 0.6, 0.6)), "Done",
)); Some("S"),
ButtonVariant::Primary,
font_res,
);
});
}); });
} }
/// Spawn a single stat cell: a large value label on top and a small grey /// Spawn a single stat cell: a large value label on top and a small
/// descriptor below, inside a fixed-width column node with a [`StatsCell`] marker. /// descriptor below, inside a fixed-min-width column with a subtle
/// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white
/// fill clashed against the new midnight-purple modal surface.
fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str) { fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str) {
parent parent
.spawn(( .spawn((
@@ -364,23 +393,32 @@ fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str)
align_items: AlignItems::Center, align_items: AlignItems::Center,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
min_width: Val::Px(110.0), min_width: Val::Px(110.0),
padding: UiRect::all(Val::Px(8.0)), padding: UiRect::all(VAL_SPACE_2),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default() ..default()
}, },
BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.06)), BorderColor::all(BORDER_SUBTLE),
)) ))
.with_children(|cell| { .with_children(|cell| {
// Large value label. // Large value label — accent yellow makes the number sing
// against the dark card surface.
cell.spawn(( cell.spawn((
Text::new(value.to_string()), Text::new(value.to_string()),
TextFont { font_size: 32.0, ..default() }, TextFont {
TextColor(Color::srgb(1.0, 1.0, 1.0)), font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
)); ));
// Small descriptor below. // Small descriptor below the value.
cell.spawn(( cell.spawn((
Text::new(label.to_string()), Text::new(label.to_string()),
TextFont { font_size: 14.0, ..default() }, TextFont {
TextColor(Color::srgb(0.65, 0.65, 0.65)), font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
)); ));
}); });
} }