feat(leaderboard): add custom public display name

Adds `leaderboard_display_name: Option<String>` 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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-12 14:38:53 -07:00
parent 9564f54fc0
commit 03be4fcc67
2 changed files with 280 additions and 16 deletions
+7
View File
@@ -231,6 +231,12 @@ pub struct Settings {
/// cleanly to `None` via `#[serde(default)]`.
#[serde(default)]
pub last_difficulty: Option<DifficultyLevel>,
/// 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<String>,
}
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,
}
}
}
+273 -16
View File
@@ -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<Task<Result<(), String>>>);
#[derive(Resource, Default)]
struct OptOutTask(Option<Task<Result<(), String>>>);
/// 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::<ClosedThisFrame>()
.init_resource::<OptInTask>()
.init_resource::<OptOutTask>()
.init_resource::<DisplayNameBuffer>()
.add_message::<ToggleLeaderboardRequestEvent>()
// `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::<MouseWheel>()
.add_message::<KeyboardInput>()
.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<Entity, With<LeaderboardScreen>>,
data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
mut task_res: ResMut<LeaderboardFetchTask>,
mut closed_flag: ResMut<ClosedThisFrame>,
@@ -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<LeaderboardFetchResult>,
mut data: ResMut<LeaderboardResource>,
screens: Query<Entity, With<LeaderboardScreen>>,
provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
@@ -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<Interaction>, With<SetDisplayNameButton>)>,
existing: Query<(), With<DisplayNameModal>>,
mut commands: Commands,
settings: Option<Res<SettingsResource>>,
font_res: Option<Res<FontResource>>,
mut buf: ResMut<DisplayNameBuffer>,
) {
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<DisplayNameModal>>,
mut key_events: MessageReader<KeyboardInput>,
mut buf: ResMut<DisplayNameBuffer>,
mut text_q: Query<&mut Text, With<DisplayNameTextField>>,
) {
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<Interaction>, With<DisplayNameConfirmButton>)>,
screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands,
buf: Res<DisplayNameBuffer>,
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
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<Interaction>, With<DisplayNameCancelButton>)>,
screens: Query<Entity, With<DisplayNameModal>>,
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 (0x200x7e) for the display-name field.
fn printable_char_dn(text: &str) -> Option<char> {
let ch = text.chars().next()?;
(' '..='~').contains(&ch).then_some(ch)
}
fn format_secs(secs: u64) -> String {
let m = secs / 60;
let s = secs % 60;