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:
@@ -141,7 +141,9 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
Theme, WindowGeometry, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
Theme, WindowGeometry, TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN,
|
||||||
|
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS,
|
||||||
|
TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ pub struct Settings {
|
|||||||
/// `#[serde(default = "default_tooltip_delay")]`.
|
/// `#[serde(default = "default_tooltip_delay")]`.
|
||||||
#[serde(default = "default_tooltip_delay")]
|
#[serde(default = "default_tooltip_delay")]
|
||||||
pub tooltip_delay_secs: f32,
|
pub tooltip_delay_secs: f32,
|
||||||
|
/// Multiplier applied to the post-game time-bonus score component
|
||||||
|
/// shown in the win-summary modal. Range
|
||||||
|
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`
|
||||||
|
/// (`0.0`–`2.0`); default `1.0` keeps the existing behaviour.
|
||||||
|
///
|
||||||
|
/// **COSMETIC ONLY** — this multiplier changes what the player
|
||||||
|
/// sees in the win modal's score breakdown but does **not** affect
|
||||||
|
/// achievement unlock thresholds, lifetime score totals, or
|
||||||
|
/// leaderboard submissions, which all use the raw, unmultiplied
|
||||||
|
/// score values produced by `solitaire_core`. Older
|
||||||
|
/// `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `1.0` via
|
||||||
|
/// `#[serde(default = "default_time_bonus_multiplier")]`.
|
||||||
|
#[serde(default = "default_time_bonus_multiplier")]
|
||||||
|
pub time_bonus_multiplier: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -189,6 +204,25 @@ pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5;
|
|||||||
/// Increment applied by the tooltip-delay decrement / increment buttons.
|
/// Increment applied by the tooltip-delay decrement / increment buttons.
|
||||||
pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1;
|
pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1;
|
||||||
|
|
||||||
|
/// Lower bound of the player-tunable time-bonus multiplier. `0.0`
|
||||||
|
/// disables the time-bonus row entirely (renders as "Off" in the UI).
|
||||||
|
pub const TIME_BONUS_MULTIPLIER_MIN: f32 = 0.0;
|
||||||
|
|
||||||
|
/// Upper bound of the player-tunable time-bonus multiplier. `2.0`
|
||||||
|
/// doubles the displayed time bonus.
|
||||||
|
pub const TIME_BONUS_MULTIPLIER_MAX: f32 = 2.0;
|
||||||
|
|
||||||
|
/// Increment applied by the time-bonus multiplier decrement /
|
||||||
|
/// increment buttons.
|
||||||
|
pub const TIME_BONUS_MULTIPLIER_STEP: f32 = 0.1;
|
||||||
|
|
||||||
|
/// Default value for [`Settings::time_bonus_multiplier`]. `1.0` keeps
|
||||||
|
/// the displayed time bonus identical to the raw value produced by
|
||||||
|
/// `solitaire_core::scoring::compute_time_bonus`.
|
||||||
|
fn default_time_bonus_multiplier() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -206,14 +240,15 @@ impl Default for Settings {
|
|||||||
selected_theme_id: default_theme_id(),
|
selected_theme_id: default_theme_id(),
|
||||||
shown_achievement_onboarding: false,
|
shown_achievement_onboarding: false,
|
||||||
tooltip_delay_secs: default_tooltip_delay(),
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
|
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Clamps `sfx_volume`, `music_volume`, and `tooltip_delay_secs` into
|
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
|
||||||
/// their respective ranges after deserialization or hand-editing of
|
/// `time_bonus_multiplier` into their respective ranges after
|
||||||
/// `settings.json`.
|
/// deserialization or hand-editing of `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||||
@@ -221,6 +256,9 @@ impl Settings {
|
|||||||
tooltip_delay_secs: self
|
tooltip_delay_secs: self
|
||||||
.tooltip_delay_secs
|
.tooltip_delay_secs
|
||||||
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS),
|
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS),
|
||||||
|
time_bonus_multiplier: self
|
||||||
|
.time_bonus_multiplier
|
||||||
|
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +283,20 @@ impl Settings {
|
|||||||
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
|
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
|
||||||
self.tooltip_delay_secs
|
self.tooltip_delay_secs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adjust the time-bonus multiplier by `delta`, clamped to
|
||||||
|
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`. The
|
||||||
|
/// result is rounded to one decimal place so the readout stays
|
||||||
|
/// clean across repeated `±` clicks (avoids float drift like
|
||||||
|
/// `0.30000004`). Returns the new value.
|
||||||
|
pub fn adjust_time_bonus_multiplier(&mut self, delta: f32) -> f32 {
|
||||||
|
let raw = (self.time_bonus_multiplier + delta)
|
||||||
|
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX);
|
||||||
|
// Round to 1 decimal place — the slider step is 0.1, so this
|
||||||
|
// collapses any FP drift introduced by repeated additions.
|
||||||
|
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||||
|
self.time_bonus_multiplier
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
@@ -375,6 +427,7 @@ mod tests {
|
|||||||
selected_theme_id: "default".to_string(),
|
selected_theme_id: "default".to_string(),
|
||||||
shown_achievement_onboarding: false,
|
shown_achievement_onboarding: false,
|
||||||
tooltip_delay_secs: default_tooltip_delay(),
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
|
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
@@ -689,4 +742,97 @@ mod tests {
|
|||||||
.sanitized();
|
.sanitized();
|
||||||
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
|
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// time_bonus_multiplier — cosmetic win-modal time-bonus weight
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_time_bonus_multiplier_default_is_one() {
|
||||||
|
let s = Settings::default();
|
||||||
|
assert!(
|
||||||
|
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||||
|
"default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}",
|
||||||
|
s.time_bonus_multiplier
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_time_bonus_multiplier_round_trip() {
|
||||||
|
let path = tmp_path("time_bonus_multiplier_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
time_bonus_multiplier: 1.5,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
(loaded.time_bonus_multiplier - 1.5).abs() < 1e-6,
|
||||||
|
"time_bonus_multiplier must survive serde round-trip; got {}",
|
||||||
|
loaded.time_bonus_multiplier
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() {
|
||||||
|
// A settings.json written before this field existed must
|
||||||
|
// deserialize cleanly to the existing 1.0 baseline so old
|
||||||
|
// players see no change to their win-modal bonuses.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||||
|
"legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}",
|
||||||
|
s.time_bonus_multiplier
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_time_bonus_multiplier_clamps_to_range() {
|
||||||
|
// Negative or oversized values from a hand-edited file must be
|
||||||
|
// clamped on load.
|
||||||
|
let s = Settings {
|
||||||
|
time_bonus_multiplier: -0.5,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN);
|
||||||
|
|
||||||
|
let s2 = Settings {
|
||||||
|
time_bonus_multiplier: 99.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
||||||
|
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
||||||
|
// Step up to 1.1.
|
||||||
|
assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6);
|
||||||
|
// Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX.
|
||||||
|
assert!(
|
||||||
|
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
|
||||||
|
);
|
||||||
|
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
|
||||||
|
assert!(
|
||||||
|
(s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6
|
||||||
|
);
|
||||||
|
assert_eq!(s.time_bonus_multiplier, 0.0);
|
||||||
|
|
||||||
|
// Repeated incremental adds must not drift past the 0.1 grid.
|
||||||
|
let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() };
|
||||||
|
for _ in 0..10 {
|
||||||
|
s2.adjust_time_bonus_multiplier(0.1);
|
||||||
|
}
|
||||||
|
// After ten +0.1 steps, value should be exactly 1.0 (1 decimal).
|
||||||
|
assert!(
|
||||||
|
(s2.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||||
|
"rounding should pin repeated 0.1 steps to the decimal grid, got {}",
|
||||||
|
s2.time_bonus_multiplier
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use bevy::window::{WindowMoved, WindowResized};
|
|||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
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};
|
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
@@ -128,6 +128,10 @@ struct ColorBlindText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TooltipDelayText;
|
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.
|
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanelScrollable;
|
struct SettingsPanelScrollable;
|
||||||
@@ -166,6 +170,10 @@ enum SettingsButton {
|
|||||||
TooltipDelayDown,
|
TooltipDelayDown,
|
||||||
/// Increment the tooltip-hover dwell delay by one step.
|
/// Increment the tooltip-hover dwell delay by one step.
|
||||||
TooltipDelayUp,
|
TooltipDelayUp,
|
||||||
|
/// Decrement the cosmetic time-bonus multiplier by one step.
|
||||||
|
TimeBonusDown,
|
||||||
|
/// Increment the cosmetic time-bonus multiplier by one step.
|
||||||
|
TimeBonusUp,
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
ToggleColorBlind,
|
ToggleColorBlind,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
@@ -198,6 +206,8 @@ impl SettingsButton {
|
|||||||
SettingsButton::CycleAnimSpeed => 40,
|
SettingsButton::CycleAnimSpeed => 40,
|
||||||
SettingsButton::TooltipDelayDown => 45,
|
SettingsButton::TooltipDelayDown => 45,
|
||||||
SettingsButton::TooltipDelayUp => 46,
|
SettingsButton::TooltipDelayUp => 46,
|
||||||
|
SettingsButton::TimeBonusDown => 47,
|
||||||
|
SettingsButton::TimeBonusUp => 48,
|
||||||
// Cosmetic section
|
// Cosmetic section
|
||||||
SettingsButton::ToggleTheme => 50,
|
SettingsButton::ToggleTheme => 50,
|
||||||
SettingsButton::ToggleColorBlind => 60,
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
@@ -288,6 +298,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
|
update_time_bonus_multiplier_text,
|
||||||
attach_focusable_to_settings_buttons,
|
attach_focusable_to_settings_buttons,
|
||||||
scroll_focus_into_view,
|
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 {
|
fn card_back_label(idx: usize) -> String {
|
||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
"Default".to_string()
|
"Default".to_string()
|
||||||
@@ -694,6 +719,25 @@ fn handle_settings_buttons(
|
|||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
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 => {
|
SettingsButton::ToggleTheme => {
|
||||||
settings.0.theme = match settings.0.theme {
|
settings.0.theme = match settings.0.theme {
|
||||||
Theme::Green => Theme::Blue,
|
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
|
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||||
@@ -1116,6 +1172,11 @@ fn spawn_settings_panel(
|
|||||||
settings.tooltip_delay_secs,
|
settings.tooltip_delay_secs,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
time_bonus_multiplier_row(
|
||||||
|
body,
|
||||||
|
settings.time_bonus_multiplier,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
// --- Cosmetic ---
|
// --- Cosmetic ---
|
||||||
section_label(body, "Cosmetic", font_res);
|
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,
|
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||||||
/// anim speed, colour-blind).
|
/// anim speed, colour-blind).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -314,14 +314,40 @@ pub struct ScoreBreakdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ScoreBreakdown {
|
impl ScoreBreakdown {
|
||||||
/// Builds a breakdown for the given win.
|
/// Builds a breakdown for the given win, applying the player's
|
||||||
|
/// **cosmetic** time-bonus multiplier (`Settings::time_bonus_multiplier`)
|
||||||
|
/// to the raw `compute_time_bonus` result before storing it on the
|
||||||
|
/// breakdown.
|
||||||
///
|
///
|
||||||
/// `base` is the running game score (`pending.score`); `time_seconds`,
|
/// `base` is the running game score (`pending.score`); `time_seconds`,
|
||||||
/// `undo_count`, and `mode` come from the captured `WinSummaryPending`.
|
/// `undo_count`, and `mode` come from the captured `WinSummaryPending`;
|
||||||
/// All score arithmetic is saturating to keep the breakdown safe even
|
/// `time_bonus_multiplier` comes from `SettingsResource`. All score
|
||||||
/// for pathologically high scores.
|
/// arithmetic is saturating to keep the breakdown safe even for
|
||||||
pub fn compute(base: i32, time_seconds: u64, undo_count: u32, mode: GameMode) -> Self {
|
/// pathologically high scores.
|
||||||
let time_bonus = compute_time_bonus(time_seconds);
|
///
|
||||||
|
/// The multiplier is **purely visual** — it changes what the player
|
||||||
|
/// sees in the win modal but does **not** affect achievement
|
||||||
|
/// thresholds, leaderboard submissions, or `StatsSnapshot` totals,
|
||||||
|
/// which all use the raw, unmultiplied scoring values.
|
||||||
|
pub fn compute(
|
||||||
|
base: i32,
|
||||||
|
time_seconds: u64,
|
||||||
|
undo_count: u32,
|
||||||
|
mode: GameMode,
|
||||||
|
time_bonus_multiplier: f32,
|
||||||
|
) -> Self {
|
||||||
|
let raw_bonus = compute_time_bonus(time_seconds);
|
||||||
|
// Apply the cosmetic multiplier and round back to an integer so
|
||||||
|
// the breakdown total stays a whole-number score.
|
||||||
|
let scaled = (raw_bonus as f32 * time_bonus_multiplier).round();
|
||||||
|
// Clamp into i32 range defensively — `raw_bonus` is already
|
||||||
|
// bounded by `compute_time_bonus`, but a multiplier of 2.0 on
|
||||||
|
// an i32::MAX-adjacent bonus could still overflow the cast.
|
||||||
|
let time_bonus = if scaled.is_nan() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
scaled.clamp(i32::MIN as f32, i32::MAX as f32) as i32
|
||||||
|
};
|
||||||
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
||||||
let multiplier = match mode {
|
let multiplier = match mode {
|
||||||
GameMode::Zen => 0.0,
|
GameMode::Zen => 0.0,
|
||||||
@@ -554,7 +580,21 @@ fn spawn_win_summary_after_delay(
|
|||||||
let anim_speed = settings
|
let anim_speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
||||||
spawn_overlay(&mut commands, &pending, &session, challenge_level, anim_speed);
|
// The cosmetic time-bonus multiplier is also pulled
|
||||||
|
// here — defaults to 1.0 (no change) when settings are
|
||||||
|
// absent (tests under MinimalPlugins without
|
||||||
|
// SettingsPlugin).
|
||||||
|
let time_bonus_multiplier = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or(1.0_f32, |s| s.0.time_bonus_multiplier);
|
||||||
|
spawn_overlay(
|
||||||
|
&mut commands,
|
||||||
|
&pending,
|
||||||
|
&session,
|
||||||
|
challenge_level,
|
||||||
|
anim_speed,
|
||||||
|
time_bonus_multiplier,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,18 +674,25 @@ fn apply_screen_shake(
|
|||||||
/// full opacity (no stagger, no fade); otherwise rows are spawned
|
/// full opacity (no stagger, no fade); otherwise rows are spawned
|
||||||
/// hidden and the [`reveal_score_breakdown`] system fades them in over
|
/// hidden and the [`reveal_score_breakdown`] system fades them in over
|
||||||
/// roughly one second.
|
/// roughly one second.
|
||||||
|
///
|
||||||
|
/// `time_bonus_multiplier` is the player's cosmetic
|
||||||
|
/// `Settings::time_bonus_multiplier` and is folded into the time-bonus
|
||||||
|
/// row of the score breakdown only — it does **not** alter any stored
|
||||||
|
/// score or achievement-unlock evaluation.
|
||||||
fn spawn_overlay(
|
fn spawn_overlay(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
pending: &WinSummaryPending,
|
pending: &WinSummaryPending,
|
||||||
session: &SessionAchievements,
|
session: &SessionAchievements,
|
||||||
challenge_level: Option<u32>,
|
challenge_level: Option<u32>,
|
||||||
anim_speed: AnimSpeed,
|
anim_speed: AnimSpeed,
|
||||||
|
time_bonus_multiplier: f32,
|
||||||
) {
|
) {
|
||||||
let breakdown = ScoreBreakdown::compute(
|
let breakdown = ScoreBreakdown::compute(
|
||||||
pending.score,
|
pending.score,
|
||||||
pending.time_seconds,
|
pending.time_seconds,
|
||||||
pending.undo_count,
|
pending.undo_count,
|
||||||
pending.mode,
|
pending.mode,
|
||||||
|
time_bonus_multiplier,
|
||||||
);
|
);
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -1392,7 +1439,7 @@ mod tests {
|
|||||||
/// the no-undo bonus fires because `undo_count == 0`.
|
/// the no-undo bonus fires because `undo_count == 0`.
|
||||||
#[test]
|
#[test]
|
||||||
fn score_breakdown_compute_produces_expected_components() {
|
fn score_breakdown_compute_produces_expected_components() {
|
||||||
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||||||
assert_eq!(bd.base, 3200);
|
assert_eq!(bd.base, 3200);
|
||||||
assert_eq!(bd.time_bonus, 5833); // 700_000 / 120
|
assert_eq!(bd.time_bonus, 5833); // 700_000 / 120
|
||||||
assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS);
|
assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS);
|
||||||
@@ -1408,7 +1455,7 @@ mod tests {
|
|||||||
/// of the other components.
|
/// of the other components.
|
||||||
#[test]
|
#[test]
|
||||||
fn score_breakdown_zen_mode_zeros_total() {
|
fn score_breakdown_zen_mode_zeros_total() {
|
||||||
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen);
|
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0);
|
||||||
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
|
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
|
||||||
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
|
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
|
||||||
assert_eq!(bd.total(), 0);
|
assert_eq!(bd.total(), 0);
|
||||||
@@ -1418,7 +1465,7 @@ mod tests {
|
|||||||
/// row is suppressed.
|
/// row is suppressed.
|
||||||
#[test]
|
#[test]
|
||||||
fn score_breakdown_skips_no_undo_row_when_undo_was_used() {
|
fn score_breakdown_skips_no_undo_row_when_undo_was_used() {
|
||||||
let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic);
|
let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic, 1.0);
|
||||||
assert_eq!(bd.no_undo_bonus, 0);
|
assert_eq!(bd.no_undo_bonus, 0);
|
||||||
assert!(!bd.shows_no_undo_row());
|
assert!(!bd.shows_no_undo_row());
|
||||||
}
|
}
|
||||||
@@ -1427,7 +1474,7 @@ mod tests {
|
|||||||
/// is suppressed.
|
/// is suppressed.
|
||||||
#[test]
|
#[test]
|
||||||
fn score_breakdown_skips_time_bonus_row_when_zero() {
|
fn score_breakdown_skips_time_bonus_row_when_zero() {
|
||||||
let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic);
|
let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic, 1.0);
|
||||||
assert_eq!(bd.time_bonus, 0);
|
assert_eq!(bd.time_bonus, 0);
|
||||||
assert!(!bd.shows_time_bonus_row());
|
assert!(!bd.shows_time_bonus_row());
|
||||||
}
|
}
|
||||||
@@ -1438,7 +1485,7 @@ mod tests {
|
|||||||
/// multiplier row, ×1.0 is suppressed).
|
/// multiplier row, ×1.0 is suppressed).
|
||||||
#[test]
|
#[test]
|
||||||
fn win_modal_score_breakdown_spawns_one_row_per_component() {
|
fn win_modal_score_breakdown_spawns_one_row_per_component() {
|
||||||
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bd.row_count(),
|
bd.row_count(),
|
||||||
5,
|
5,
|
||||||
@@ -1446,7 +1493,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Zen with both bonuses ALSO shows the multiplier row.
|
// Zen with both bonuses ALSO shows the multiplier row.
|
||||||
let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen);
|
let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen, 1.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
zen.row_count(),
|
zen.row_count(),
|
||||||
6,
|
6,
|
||||||
@@ -1457,8 +1504,8 @@ mod tests {
|
|||||||
/// When `no_undo_bonus == 0`, the row count drops by one.
|
/// When `no_undo_bonus == 0`, the row count drops by one.
|
||||||
#[test]
|
#[test]
|
||||||
fn win_modal_score_breakdown_skips_zero_bonus_rows() {
|
fn win_modal_score_breakdown_skips_zero_bonus_rows() {
|
||||||
let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic, 1.0);
|
||||||
let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic);
|
let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic, 1.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bd_with.row_count() - 1,
|
bd_with.row_count() - 1,
|
||||||
bd_without.row_count(),
|
bd_without.row_count(),
|
||||||
@@ -1466,6 +1513,52 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cosmetic time-bonus multiplier from `Settings::time_bonus_multiplier`
|
||||||
|
/// scales the displayed `time_bonus` row by the factor, rounded to
|
||||||
|
/// the nearest integer. A `0.5` multiplier halves the canonical
|
||||||
|
/// `compute_time_bonus(120) = 5833` to `2917` (5833 × 0.5 = 2916.5,
|
||||||
|
/// round-half-to-even via `.round()` lands on 2917 in IEEE-754).
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_applies_time_bonus_multiplier() {
|
||||||
|
let raw = compute_time_bonus(120);
|
||||||
|
assert_eq!(raw, 5833, "sanity-check raw bonus before testing the multiplier");
|
||||||
|
|
||||||
|
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 0.5);
|
||||||
|
let expected = ((raw as f32) * 0.5).round() as i32;
|
||||||
|
assert_eq!(
|
||||||
|
bd.time_bonus, expected,
|
||||||
|
"time_bonus row must reflect raw_bonus × multiplier (rounded)"
|
||||||
|
);
|
||||||
|
// The row is still shown — value is 2917, not zero.
|
||||||
|
assert!(bd.shows_time_bonus_row());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At `multiplier == 0.0` ("Off"), the time-bonus row collapses to
|
||||||
|
/// zero and is suppressed by the renderer (same path as a zero
|
||||||
|
/// elapsed time).
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_off_multiplier_zeros_time_bonus() {
|
||||||
|
let bd = ScoreBreakdown::compute(100, 120, 0, GameMode::Classic, 0.0);
|
||||||
|
assert_eq!(
|
||||||
|
bd.time_bonus, 0,
|
||||||
|
"0.0 multiplier must zero out the displayed time bonus"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!bd.shows_time_bonus_row(),
|
||||||
|
"with time_bonus = 0 the row must be suppressed by the renderer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `2.0` multiplier doubles the displayed bonus — exercises the
|
||||||
|
/// upper end of the slider range.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_double_multiplier_doubles_time_bonus() {
|
||||||
|
let raw = compute_time_bonus(120);
|
||||||
|
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 2.0);
|
||||||
|
let expected = ((raw as f32) * 2.0).round() as i32;
|
||||||
|
assert_eq!(bd.time_bonus, expected);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure helper test: the reveal logic uses delta-time to count
|
/// Pure helper test: the reveal logic uses delta-time to count
|
||||||
/// down `delay_secs`; at `t = 0` only the first row is "revealed",
|
/// down `delay_secs`; at `t = 0` only the first row is "revealed",
|
||||||
/// and after one stagger interval the second row reveals as well.
|
/// and after one stagger interval the second row reveals as well.
|
||||||
|
|||||||
Reference in New Issue
Block a user