feat(settings): time-bonus multiplier slider in Settings → Gameplay

Cosmetic-only player setting (default 1.0, range 0.0-2.0, step 0.1)
that scales the time-bonus row shown in the win-summary modal's
score breakdown. Achievement thresholds, lifetime score totals, and
leaderboard submissions still use the raw values produced by
`solitaire_core::scoring`, so the multiplier never affects what gets
recorded — just what the player sees on the win screen.

- New `Settings::time_bonus_multiplier` field with `#[serde(default)]`
  + `sanitized()` clamp so older settings.json files load cleanly.
- New constants `TIME_BONUS_MULTIPLIER_{MIN,MAX,STEP}` re-exported
  through `solitaire_data::lib`.
- `settings_plugin` adds a slider row under the Gameplay header
  matching the existing tooltip-delay control.
- `win_summary_plugin` applies the multiplier when rendering the
  time-bonus row of the score breakdown; "Off" label when 0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 18:49:07 +00:00
parent 3984231c9b
commit 89c51ab712
4 changed files with 372 additions and 20 deletions
+112 -1
View File
@@ -18,7 +18,7 @@ use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode;
use solitaire_data::{
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
WindowGeometry, TOOLTIP_DELAY_STEP_SECS,
WindowGeometry, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
};
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
@@ -128,6 +128,10 @@ struct ColorBlindText;
#[derive(Component, Debug)]
struct TooltipDelayText;
/// Marks the `Text` node showing the live time-bonus-multiplier value.
#[derive(Component, Debug)]
struct TimeBonusMultiplierText;
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
@@ -166,6 +170,10 @@ enum SettingsButton {
TooltipDelayDown,
/// Increment the tooltip-hover dwell delay by one step.
TooltipDelayUp,
/// Decrement the cosmetic time-bonus multiplier by one step.
TimeBonusDown,
/// Increment the cosmetic time-bonus multiplier by one step.
TimeBonusUp,
ToggleTheme,
ToggleColorBlind,
SyncNow,
@@ -198,6 +206,8 @@ impl SettingsButton {
SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46,
SettingsButton::TimeBonusDown => 47,
SettingsButton::TimeBonusUp => 48,
// Cosmetic section
SettingsButton::ToggleTheme => 50,
SettingsButton::ToggleColorBlind => 60,
@@ -288,6 +298,7 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text,
update_color_blind_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
attach_focusable_to_settings_buttons,
scroll_focus_into_view,
),
@@ -553,6 +564,20 @@ fn update_tooltip_delay_text(
}
}
/// Refreshes the live time-bonus-multiplier value in the Gameplay
/// section whenever `SettingsResource` changes.
fn update_time_bonus_multiplier_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = time_bonus_label(settings.0.time_bonus_multiplier);
}
}
fn card_back_label(idx: usize) -> String {
if idx == 0 {
"Default".to_string()
@@ -694,6 +719,25 @@ fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::TimeBonusDown => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by
// `update_time_bonus_multiplier_text` on the next
// frame via `settings.is_changed()`.
}
}
SettingsButton::TimeBonusUp => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::ToggleTheme => {
settings.0.theme = match settings.0.theme {
Theme::Green => Theme::Blue,
@@ -779,6 +823,18 @@ fn tooltip_delay_label(secs: f32) -> String {
}
}
/// Formats the cosmetic time-bonus multiplier for display in the
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
/// time-bonus row will be hidden; any other value prints as
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
fn time_bonus_label(value: f32) -> String {
if value <= 0.0 {
"Off".into()
} else {
format!("{value:.1}×")
}
}
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
/// buttons (volume +/, toggle, cycle), swatch buttons (card-back,
/// background pickers), and the "Sync Now" button. The "Done" button is
@@ -1116,6 +1172,11 @@ fn spawn_settings_panel(
settings.tooltip_delay_secs,
font_res,
);
time_bonus_multiplier_row(
body,
settings.time_bonus_multiplier,
font_res,
);
// --- Cosmetic ---
section_label(body, "Cosmetic", font_res);
@@ -1300,6 +1361,56 @@ fn tooltip_delay_row(
});
}
/// `Time bonus 1.0× [] [+]` — slider row for the cosmetic
/// `Settings::time_bonus_multiplier`. Mirrors [`tooltip_delay_row`]
/// (label, current value, decrement, increment) but formats the value
/// via [`time_bonus_label`] so `0.0` reads as `"Off"` and other values
/// as `"{n:.1}×"`. The multiplier is **cosmetic** — adjusting it
/// changes only the win-modal score breakdown, not the canonical
/// scores recorded in stats / achievements / leaderboards.
fn time_bonus_multiplier_row(
parent: &mut ChildSpawnerCommands,
value: f32,
font_res: Option<&FontResource>,
) {
let label_font = label_text_font(font_res);
let value_font = value_text_font(font_res);
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Time bonus".to_string()),
label_font,
TextColor(TEXT_SECONDARY),
));
row.spawn((
TimeBonusMultiplierText,
Text::new(time_bonus_label(value)),
value_font,
TextColor(TEXT_PRIMARY),
));
icon_button(
row,
"",
SettingsButton::TimeBonusDown,
"Shrink the time-bonus shown in the win modal. Cosmetic only.",
font_res,
);
icon_button(
row,
"+",
SettingsButton::TimeBonusUp,
"Boost the time-bonus shown in the win modal. Cosmetic only.",
font_res,
);
});
}
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
/// anim speed, colour-blind).
///