From 03be4fcc6793a88a8353bfd4bfe55eabde7deed5 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 12 May 2026 14:38:53 -0700 Subject: [PATCH] feat(leaderboard): add custom public display name Adds `leaderboard_display_name: Option` to `Settings` (serde default = None, backwards-compatible). When set, this name is submitted to the server on opt-in instead of the player's username, giving players a separate public identity on the leaderboard. Engine changes: - `handle_opt_in_button` prefers `leaderboard_display_name` over username - Leaderboard panel shows "Public name: X" row with "Set Name" button - "Set Name" opens a modal with a single text-input field (32-char max) - Save/Cancel buttons write to SettingsResource and persist to disk Co-Authored-By: Claude Sonnet 4.6 --- solitaire_data/src/settings.rs | 7 + solitaire_engine/src/leaderboard_plugin.rs | 289 +++++++++++++++++++-- 2 files changed, 280 insertions(+), 16 deletions(-) diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 3483fa1..8ef214a 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -231,6 +231,12 @@ pub struct Settings { /// cleanly to `None` via `#[serde(default)]`. #[serde(default)] pub last_difficulty: Option, + /// Custom public name displayed on the leaderboard. When `None`, the + /// player's server `username` is used instead. Trimmed to 32 characters + /// before submission. Older `settings.json` files written before this + /// field existed deserialize cleanly to `None` via `#[serde(default)]`. + #[serde(default)] + pub leaderboard_display_name: Option, } fn default_draw_mode() -> DrawMode { @@ -350,6 +356,7 @@ impl Default for Settings { disable_smart_default_size: false, replay_move_interval_secs: default_replay_move_interval_secs(), last_difficulty: None, + leaderboard_display_name: None, } } } diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs index 2d08868..a7a6f41 100644 --- a/solitaire_engine/src/leaderboard_plugin.rs +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -9,23 +9,24 @@ //! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`) //! the panel shows "Not available" immediately. -use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; +use bevy::input::{ButtonState, keyboard::KeyboardInput, mouse::{MouseScrollUnit, MouseWheel}}; use bevy::prelude::*; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; -use solitaire_data::settings::SyncBackend; +use solitaire_data::{save_settings_to, settings::SyncBackend}; use solitaire_sync::LeaderboardEntry; use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent}; use crate::font_plugin::FontResource; -use crate::settings_plugin::SettingsResource; +use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::sync_plugin::SyncProviderResource; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, ScrimDismissible, }; use crate::ui_theme::{ - ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, - TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL, + ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, + TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, + VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, Z_PAUSE_DIALOG, }; // --------------------------------------------------------------------------- @@ -96,6 +97,30 @@ struct OptInTask(Option>>); #[derive(Resource, Default)] struct OptOutTask(Option>>); +/// Marker on the "Set Name" button inside the leaderboard panel. +#[derive(Component, Debug)] +struct SetDisplayNameButton; + +/// Marker on the display-name editor modal root. +#[derive(Component, Debug)] +struct DisplayNameModal; + +/// Text currently typed in the display-name modal's input field. +#[derive(Resource, Default)] +struct DisplayNameBuffer(String); + +/// Marker on the text node inside the display-name input field. +#[derive(Component, Debug)] +struct DisplayNameTextField; + +/// Marker on the "Save" button in the display-name modal. +#[derive(Component, Debug)] +struct DisplayNameConfirmButton; + +/// Marker on the "Cancel" button in the display-name modal. +#[derive(Component, Debug)] +struct DisplayNameCancelButton; + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -111,12 +136,13 @@ impl Plugin for LeaderboardPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() - // `MouseWheel` is emitted by Bevy's input plugin under - // `DefaultPlugins`; register it explicitly so the - // leaderboard-scroll system also runs cleanly under - // `MinimalPlugins` in tests. + // `MouseWheel` and `KeyboardInput` are emitted by Bevy's input + // plugin under `DefaultPlugins`; register them explicitly so all + // leaderboard systems run cleanly under `MinimalPlugins` in tests. .add_message::() + .add_message::() .add_systems( Update, ( @@ -129,6 +155,10 @@ impl Plugin for LeaderboardPlugin { poll_opt_in_task, handle_opt_out_button, poll_opt_out_task, + handle_set_display_name_button, + handle_display_name_text_input, + handle_display_name_confirm, + handle_display_name_cancel, ) .chain(), ) @@ -156,6 +186,7 @@ fn toggle_leaderboard_screen( screens: Query>, data: Res, provider: Option>, + settings: Option>, font_res: Option>, mut task_res: ResMut, mut closed_flag: ResMut, @@ -174,7 +205,8 @@ fn toggle_leaderboard_screen( let remote_available = provider .as_ref() .is_some_and(|p| p.0.backend_name() != "local"); - spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref()); + let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()); + spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref()); // Start a background fetch if not already in flight. if task_res.0.is_none() @@ -201,12 +233,14 @@ fn poll_leaderboard_fetch( /// When a fetch completes, cache the data and update any open panel. /// Skips the panel rebuild if the user closed the panel in this same frame /// (commands are deferred, so the query would still see the despawned entity). +#[allow(clippy::too_many_arguments)] fn update_leaderboard_panel( mut commands: Commands, mut result_res: ResMut, mut data: ResMut, screens: Query>, provider: Option>, + settings: Option>, font_res: Option>, closed_flag: Res, ) { @@ -235,9 +269,10 @@ fn update_leaderboard_panel( let remote_available = provider .as_ref() .is_some_and(|p| p.0.backend_name() != "local"); + let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()); for entity in &screens { commands.entity(entity).despawn(); - spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref()); + spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref()); } } @@ -305,11 +340,17 @@ fn handle_opt_in_button( let display_name = settings .as_ref() .and_then(|s| { - if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend { - Some(username.clone()) - } else { - None - } + // Prefer an explicit display name; fall back to server username. + s.0.leaderboard_display_name + .as_deref() + .or_else(|| { + if let SyncBackend::SolitaireServer { username, .. } = &s.0.sync_backend { + Some(username.as_str()) + } else { + None + } + }) + .map(str::to_string) }) .unwrap_or_else(|| "Player".to_string()); @@ -391,6 +432,7 @@ fn spawn_leaderboard_screen( commands: &mut Commands, data: &LeaderboardResource, remote_available: bool, + effective_display_name: Option<&str>, font_res: Option<&FontResource>, ) { let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| { @@ -426,6 +468,33 @@ fn spawn_leaderboard_screen( TextColor(TEXT_SECONDARY), )); + // Public name row: shows the effective display name + "Set Name" button. + card.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|row| { + let label = match effective_display_name { + Some(n) => format!("Public name: {n}"), + None => "Public name: (same as username)".to_string(), + }; + row.spawn(( + Text::new(label), + font_caption.clone(), + TextColor(TEXT_SECONDARY), + )); + spawn_modal_button( + row, + SetDisplayNameButton, + "Set Name", + None, + ButtonVariant::Tertiary, + font_res, + ); + }); + // 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| { @@ -606,6 +675,194 @@ fn data_cell( )); } +// --------------------------------------------------------------------------- +// Display-name editor +// --------------------------------------------------------------------------- + +/// Opens the display-name editor modal when the "Set Name" button is pressed. +fn handle_set_display_name_button( + button_q: Query<&Interaction, (Changed, With)>, + existing: Query<(), With>, + mut commands: Commands, + settings: Option>, + font_res: Option>, + mut buf: ResMut, +) { + if !button_q.iter().any(|i| *i == Interaction::Pressed) { + return; + } + if !existing.is_empty() { + return; // already open + } + buf.0 = settings + .as_ref() + .and_then(|s| s.0.leaderboard_display_name.clone()) + .unwrap_or_default(); + spawn_display_name_modal(&mut commands, &buf.0, font_res.as_deref()); +} + +/// Routes keyboard input into the display-name buffer while the editor is open. +fn handle_display_name_text_input( + screen: Query<(), With>, + mut key_events: MessageReader, + mut buf: ResMut, + mut text_q: Query<&mut Text, With>, +) { + if screen.is_empty() { + key_events.clear(); + return; + } + for ev in key_events.read() { + if ev.state != ButtonState::Pressed { + continue; + } + if ev.key_code == KeyCode::Backspace { + buf.0.pop(); + } else if let Some(ch) = ev.text.as_deref().and_then(printable_char_dn) + && buf.0.len() < 32 + { + buf.0.push(ch); + } + } + for mut text in &mut text_q { + text.0 = if buf.0.is_empty() { + " ".to_string() + } else { + buf.0.clone() + }; + } +} + +/// Saves the typed display name to `SettingsResource` and closes the modal. +fn handle_display_name_confirm( + button_q: Query<&Interaction, (Changed, With)>, + screens: Query>, + mut commands: Commands, + buf: Res, + settings: Option>, + settings_path: Option>, +) { + if !button_q.iter().any(|i| *i == Interaction::Pressed) { + return; + } + if let Some(mut settings) = settings { + let trimmed = buf.0.trim().to_string(); + settings.0.leaderboard_display_name = if trimmed.is_empty() { + None + } else { + Some(trimmed) + }; + if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref()) + && let Err(e) = save_settings_to(path, &settings.0) + { + warn!("failed to save settings: {e}"); + } + } + for entity in &screens { + commands.entity(entity).despawn(); + } +} + +/// Discards any typed text and closes the display-name editor modal. +fn handle_display_name_cancel( + button_q: Query<&Interaction, (Changed, With)>, + screens: Query>, + mut commands: Commands, +) { + if !button_q.iter().any(|i| *i == Interaction::Pressed) { + return; + } + for entity in &screens { + commands.entity(entity).despawn(); + } +} + +fn spawn_display_name_modal( + commands: &mut Commands, + current_name: &str, + font_res: Option<&FontResource>, +) { + let make_font = |size: f32| TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: size, + ..default() + }; + + spawn_modal(commands, DisplayNameModal, Z_PAUSE_DIALOG, |card| { + spawn_modal_header(card, "Public Display Name", font_res); + + card.spawn(( + Text::new( + "Shown on the leaderboard when you opt in. Leave blank to use your username.", + ), + make_font(TYPE_CAPTION), + TextColor(TEXT_SECONDARY), + )); + + // Input field container. + card.spawn(( + Node { + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)), + min_height: Val::Px(32.0), + min_width: Val::Px(260.0), + ..default() + }, + BackgroundColor(BG_ELEVATED), + BorderColor::all(ACCENT_PRIMARY), + )) + .with_children(|border| { + let initial = if current_name.is_empty() { + " ".to_string() + } else { + current_name.to_string() + }; + border.spawn(( + DisplayNameTextField, + Text::new(initial), + make_font(TYPE_BODY), + TextColor(if current_name.is_empty() { + TEXT_DISABLED + } else { + TEXT_PRIMARY + }), + )); + }); + + card.spawn(( + Text::new("Max 32 characters."), + make_font(TYPE_CAPTION), + TextColor(TEXT_SECONDARY), + )); + + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + DisplayNameCancelButton, + "Cancel", + None, + ButtonVariant::Tertiary, + font_res, + ); + spawn_modal_button( + actions, + DisplayNameConfirmButton, + "Save", + None, + ButtonVariant::Primary, + font_res, + ); + }); + }); +} + +/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field. +fn printable_char_dn(text: &str) -> Option { + let ch = text.chars().next()?; + (' '..='~').contains(&ch).then_some(ch) +} + fn format_secs(secs: u64) -> String { let m = secs / 60; let s = secs % 60;