feat(engine): convert LeaderboardScreen to modal scaffold + Done button
Phase 3 step 5e of the UX overhaul. Wraps the leaderboard list inside the standard ui_modal scaffold; converts the Opt In / Opt Out buttons to use spawn_modal_button (so they pick up the shared hover / press paint system); replaces "Press L to close" prose with a primary Done button. Changes: - spawn_leaderboard_screen now goes through spawn_modal(LeaderboardScreen, Z_MODAL_PANEL, ...). The bespoke 0.82-alpha scrim and hand-rolled card surface are gone — same visual contract as every other overlay. - Opt In becomes a Secondary modal button; Opt Out becomes Tertiary. Both fire the same fetch tasks they did before. - Header / data cells switch to ui_theme tokens. The top-3 podium effect now uses ACCENT_PRIMARY (yellow) for #1 and TEXT_PRIMARY for #2/#3 instead of metallic-coloured srgb literals; #4+ use TEXT_SECONDARY. - Header-cell and data-cell helpers now take a `&TextFont` so all three sizes (HEADLINE / BODY_LG / BODY / CAPTION) come from the shared scale instead of inline 13px / 15px sizes. - "Fetching\u{2026}" loading state uses STATE_INFO; empty-state copy uses TEXT_SECONDARY. - handle_leaderboard_close_button is the click counterpart to L; it also sets ClosedThisFrame so update_leaderboard_panel doesn't immediately respawn the modal when a fetch completes in the same frame. The sort-by-score code is replaced with `sort_by_key(Reverse(...))` to satisfy clippy's unnecessary_sort_by lint that surfaced once the file was otherwise warning-free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,16 @@ use solitaire_data::settings::SyncBackend;
|
|||||||
use solitaire_sync::LeaderboardEntry;
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
|
use crate::ui_modal::{
|
||||||
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||||
|
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Resources
|
// Resources
|
||||||
@@ -79,6 +87,7 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
(
|
(
|
||||||
reset_closed_flag,
|
reset_closed_flag,
|
||||||
toggle_leaderboard_screen,
|
toggle_leaderboard_screen,
|
||||||
|
handle_leaderboard_close_button,
|
||||||
poll_leaderboard_fetch,
|
poll_leaderboard_fetch,
|
||||||
update_leaderboard_panel,
|
update_leaderboard_panel,
|
||||||
handle_opt_in_button,
|
handle_opt_in_button,
|
||||||
@@ -111,6 +120,7 @@ fn toggle_leaderboard_screen(
|
|||||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
data: Res<LeaderboardResource>,
|
data: Res<LeaderboardResource>,
|
||||||
provider: Option<Res<SyncProviderResource>>,
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
mut task_res: ResMut<LeaderboardFetchTask>,
|
mut task_res: ResMut<LeaderboardFetchTask>,
|
||||||
mut closed_flag: ResMut<ClosedThisFrame>,
|
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||||
) {
|
) {
|
||||||
@@ -125,7 +135,7 @@ fn toggle_leaderboard_screen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the panel immediately with whatever data we have (may be None).
|
// Spawn the panel immediately with whatever data we have (may be None).
|
||||||
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
|
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
|
||||||
|
|
||||||
// Start a background fetch if not already in flight.
|
// Start a background fetch if not already in flight.
|
||||||
if task_res.0.is_none()
|
if task_res.0.is_none()
|
||||||
@@ -157,6 +167,7 @@ fn update_leaderboard_panel(
|
|||||||
mut result_res: ResMut<LeaderboardFetchResult>,
|
mut result_res: ResMut<LeaderboardFetchResult>,
|
||||||
mut data: ResMut<LeaderboardResource>,
|
mut data: ResMut<LeaderboardResource>,
|
||||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
closed_flag: Res<ClosedThisFrame>,
|
closed_flag: Res<ClosedThisFrame>,
|
||||||
) {
|
) {
|
||||||
let Some(result) = result_res.0.take() else { return };
|
let Some(result) = result_res.0.take() else { return };
|
||||||
@@ -180,7 +191,23 @@ fn update_leaderboard_panel(
|
|||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
spawn_leaderboard_screen(&mut commands, data.0.as_deref());
|
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click handler for the modal's "Done" button — despawns the overlay.
|
||||||
|
fn handle_leaderboard_close_button(
|
||||||
|
mut commands: Commands,
|
||||||
|
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||||
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
|
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||||
|
) {
|
||||||
|
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
closed_flag.0 = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,197 +310,183 @@ fn poll_opt_out_task(
|
|||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[LeaderboardEntry]>) {
|
/// Marker on the "Done" button inside the Leaderboard modal.
|
||||||
commands
|
#[derive(Component, Debug)]
|
||||||
.spawn((
|
pub struct LeaderboardCloseButton;
|
||||||
LeaderboardScreen,
|
|
||||||
|
fn spawn_leaderboard_screen(
|
||||||
|
commands: &mut Commands,
|
||||||
|
entries: Option<&[LeaderboardEntry]>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, "Leaderboard", font_res);
|
||||||
|
|
||||||
|
// Subhead — what the screen does + what the buttons control.
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_caption = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_status = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_row = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_header = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Use Opt In / Opt Out to control your visibility on the server."),
|
||||||
|
font_caption.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Opt In / Opt Out row uses the same modal-button helpers as
|
||||||
|
// the rest of the UI for consistent hover / press feedback.
|
||||||
|
spawn_modal_actions(card, |row| {
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
LeaderboardOptInButton,
|
||||||
|
"Opt In",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
LeaderboardOptOutButton,
|
||||||
|
"Opt Out",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Tertiary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subtle separator between the controls and the data area.
|
||||||
|
card.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
height: Val::Px(1.0),
|
||||||
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::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)),
|
BackgroundColor(BORDER_SUBTLE),
|
||||||
ZIndex(210),
|
));
|
||||||
))
|
|
||||||
.with_children(|root| {
|
|
||||||
root.spawn((
|
|
||||||
Node {
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
padding: UiRect::all(Val::Px(28.0)),
|
|
||||||
row_gap: Val::Px(8.0),
|
|
||||||
min_width: Val::Px(420.0),
|
|
||||||
max_height: Val::Percent(80.0),
|
|
||||||
overflow: Overflow::clip_y(),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(8.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgb(0.09, 0.09, 0.12)),
|
|
||||||
))
|
|
||||||
.with_children(|card| {
|
|
||||||
// Header
|
|
||||||
card.spawn((
|
|
||||||
Text::new("Leaderboard"),
|
|
||||||
TextFont { font_size: 26.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
card.spawn((
|
|
||||||
Text::new("Press L to close • Opt In / Opt Out to control your visibility"),
|
|
||||||
TextFont { font_size: 14.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Separator
|
match entries {
|
||||||
|
None => {
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Node {
|
Text::new("Fetching\u{2026}"),
|
||||||
height: Val::Px(1.0),
|
font_status.clone(),
|
||||||
margin: UiRect::vertical(Val::Px(6.0)),
|
TextColor(STATE_INFO),
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
|
|
||||||
));
|
));
|
||||||
|
}
|
||||||
// Opt-in / Opt-out buttons row
|
Some([]) => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
|
||||||
|
font_row.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(rows) => {
|
||||||
|
// Column headers
|
||||||
card.spawn(Node {
|
card.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
column_gap: Val::Px(10.0),
|
column_gap: VAL_SPACE_4,
|
||||||
margin: UiRect::bottom(Val::Px(8.0)),
|
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn((
|
header_cell(row, "#", 30.0, &font_header);
|
||||||
LeaderboardOptInButton,
|
header_cell(row, "Player", 160.0, &font_header);
|
||||||
Button,
|
header_cell(row, "Best Score", 100.0, &font_header);
|
||||||
Node {
|
header_cell(row, "Fastest Win", 110.0, &font_header);
|
||||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgb(0.18, 0.35, 0.50)),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Opt In"),
|
|
||||||
TextFont { font_size: 15.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
row.spawn((
|
|
||||||
LeaderboardOptOutButton,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(4.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::srgb(0.42, 0.15, 0.15)),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Opt Out"),
|
|
||||||
TextFont { font_size: 15.0, ..default() },
|
|
||||||
TextColor(Color::WHITE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
match entries {
|
let mut sorted = rows.to_vec();
|
||||||
None => {
|
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
|
||||||
// Fetch in progress
|
|
||||||
card.spawn((
|
|
||||||
Text::new("Fetching…"),
|
|
||||||
TextFont { font_size: 18.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.65, 0.65, 0.70)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Some([]) => {
|
|
||||||
card.spawn((
|
|
||||||
Text::new("No entries yet — sync and opt in to appear here."),
|
|
||||||
TextFont { font_size: 16.0, ..default() },
|
|
||||||
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Some(rows) => {
|
|
||||||
// Column headers
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
column_gap: Val::Px(16.0),
|
|
||||||
margin: UiRect::bottom(Val::Px(4.0)),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
header_cell(row, "#", 30.0);
|
|
||||||
header_cell(row, "Player", 160.0);
|
|
||||||
header_cell(row, "Best Score", 100.0);
|
|
||||||
header_cell(row, "Fastest Win", 110.0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Data rows (top 10)
|
for (i, entry) in sorted.iter().take(10).enumerate() {
|
||||||
let mut sorted = rows.to_vec();
|
// Top three get accent treatments to highlight the
|
||||||
sorted.sort_by(|a, b| {
|
// podium without leaning on hand-picked metallic
|
||||||
b.best_score
|
// colours that sit outside the token system.
|
||||||
.unwrap_or(0)
|
let rank_color = match i {
|
||||||
.cmp(&a.best_score.unwrap_or(0))
|
0 => ACCENT_PRIMARY, // Balatro yellow for #1
|
||||||
});
|
1 | 2 => TEXT_PRIMARY,
|
||||||
|
_ => TEXT_SECONDARY,
|
||||||
|
};
|
||||||
|
|
||||||
for (i, entry) in sorted.iter().take(10).enumerate() {
|
let time_str = entry
|
||||||
let rank_color = match i {
|
.best_time_secs
|
||||||
0 => Color::srgb(1.0, 0.84, 0.0),
|
.map(format_secs)
|
||||||
1 => Color::srgb(0.75, 0.75, 0.75),
|
.unwrap_or_else(|| "-".to_string());
|
||||||
2 => Color::srgb(0.80, 0.50, 0.20),
|
let score_str = entry
|
||||||
_ => Color::srgb(0.80, 0.80, 0.80),
|
.best_score
|
||||||
};
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
let time_str = entry
|
card.spawn(Node {
|
||||||
.best_time_secs
|
flex_direction: FlexDirection::Row,
|
||||||
.map(format_secs)
|
column_gap: VAL_SPACE_4,
|
||||||
.unwrap_or_else(|| "-".to_string());
|
..default()
|
||||||
let score_str = entry
|
})
|
||||||
.best_score
|
.with_children(|row| {
|
||||||
.map(|s| s.to_string())
|
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
|
||||||
.unwrap_or_else(|| "-".to_string());
|
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
|
||||||
|
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
|
||||||
card.spawn(Node {
|
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
|
||||||
flex_direction: FlexDirection::Row,
|
});
|
||||||
column_gap: Val::Px(16.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
data_cell(row, &format!("{}", i + 1), 30.0, rank_color);
|
|
||||||
data_cell(row, &entry.display_name, 160.0, Color::WHITE);
|
|
||||||
data_cell(row, &score_str, 100.0, Color::WHITE);
|
|
||||||
data_cell(row, &time_str, 110.0, Color::WHITE);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
LeaderboardCloseButton,
|
||||||
|
"Done",
|
||||||
|
Some("L"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32) {
|
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
|
||||||
parent.spawn((
|
parent.spawn((
|
||||||
Text::new(text.to_string()),
|
Text::new(text.to_string()),
|
||||||
TextFont { font_size: 13.0, ..default() },
|
font.clone(),
|
||||||
TextColor(Color::srgb(0.55, 0.75, 0.55)),
|
TextColor(TEXT_SECONDARY),
|
||||||
Node { width: Val::Px(width), ..default() },
|
Node {
|
||||||
|
width: Val::Px(width),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, color: Color) {
|
fn data_cell(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
text: &str,
|
||||||
|
width: f32,
|
||||||
|
color: Color,
|
||||||
|
font: &TextFont,
|
||||||
|
) {
|
||||||
parent.spawn((
|
parent.spawn((
|
||||||
Text::new(text.to_string()),
|
Text::new(text.to_string()),
|
||||||
TextFont { font_size: 15.0, ..default() },
|
font.clone(),
|
||||||
TextColor(color),
|
TextColor(color),
|
||||||
Node { width: Val::Px(width), ..default() },
|
Node {
|
||||||
|
width: Val::Px(width),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user