diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index eea23ee..31abc1c 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -126,7 +126,7 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS}; pub mod settings; pub use settings::{ load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, - Theme, WindowGeometry, + Theme, WindowGeometry, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, }; pub mod auth_tokens; diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 5ebdaae..886bf1c 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -143,6 +143,14 @@ pub struct Settings { /// so the toast still does not fire for them. #[serde(default)] pub shown_achievement_onboarding: bool, + /// Hover delay (seconds) before a tooltip appears. Range + /// `[0.0, 1.5]`; default matches `MOTION_TOOLTIP_DELAY_SECS` (0.5 s). + /// `0.0` means tooltips fire on the very next tick after hover — + /// the "Instant" setting. Older `settings.json` files written before + /// this field existed deserialize cleanly to the default via + /// `#[serde(default = "default_tooltip_delay")]`. + #[serde(default = "default_tooltip_delay")] + pub tooltip_delay_secs: f32, } fn default_draw_mode() -> DrawMode { @@ -161,6 +169,26 @@ fn default_theme_id() -> String { "default".to_string() } +/// Default tooltip-hover dwell delay in seconds. Mirrors +/// `solitaire_engine::ui_theme::MOTION_TOOLTIP_DELAY_SECS` so legacy +/// `settings.json` files load to the existing baseline. The constant +/// lives in the engine crate (which the data crate cannot depend on), +/// so the value is duplicated here — kept in sync by the +/// `settings_tooltip_delay_default_is_existing_baseline` test in +/// `solitaire_engine::settings_plugin`. +fn default_tooltip_delay() -> f32 { + 0.5 +} + +/// Lower bound of the player-tunable tooltip delay slider, in seconds. +pub const TOOLTIP_DELAY_MIN_SECS: f32 = 0.0; + +/// Upper bound of the player-tunable tooltip delay slider, in seconds. +pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5; + +/// Increment applied by the tooltip-delay decrement / increment buttons. +pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1; + impl Default for Settings { fn default() -> Self { Self { @@ -177,17 +205,22 @@ impl Default for Settings { window_geometry: None, selected_theme_id: default_theme_id(), shown_achievement_onboarding: false, + tooltip_delay_secs: default_tooltip_delay(), } } } impl Settings { - /// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after - /// deserialization or hand-editing of `settings.json`. + /// Clamps `sfx_volume`, `music_volume`, and `tooltip_delay_secs` into + /// their respective ranges after deserialization or hand-editing of + /// `settings.json`. pub fn sanitized(self) -> Self { Self { sfx_volume: self.sfx_volume.clamp(0.0, 1.0), music_volume: self.music_volume.clamp(0.0, 1.0), + tooltip_delay_secs: self + .tooltip_delay_secs + .clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS), ..self } } @@ -203,6 +236,15 @@ impl Settings { self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0); self.music_volume } + + /// Adjust the tooltip-hover dwell delay by `delta` seconds, clamped + /// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the + /// new value. + pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 { + self.tooltip_delay_secs = (self.tooltip_delay_secs + delta) + .clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS); + self.tooltip_delay_secs + } } /// Returns the platform-specific path to `settings.json`, or `None` if @@ -253,6 +295,7 @@ mod tests { assert_eq!(s.animation_speed, AnimSpeed::Normal); assert_eq!(s.theme, Theme::Green); assert_eq!(s.sync_backend, SyncBackend::Local); + assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6); } #[test] @@ -331,6 +374,7 @@ mod tests { window_geometry: None, selected_theme_id: "default".to_string(), shown_achievement_onboarding: false, + tooltip_delay_secs: default_tooltip_delay(), }; save_settings_to(&path, &s).expect("save"); let loaded = load_settings_from(&path); @@ -563,4 +607,86 @@ mod tests { "legacy settings.json missing shown_achievement_onboarding must deserialize to false" ); } + + // ----------------------------------------------------------------------- + // tooltip_delay_secs — player-tunable tooltip hover delay + // ----------------------------------------------------------------------- + + #[test] + fn settings_tooltip_delay_default_is_existing_baseline() { + // The existing baseline pre-slider is 0.5 s, matching the + // `MOTION_TOOLTIP_DELAY_SECS` constant in + // `solitaire_engine::ui_theme`. The default must not regress. + let s = Settings::default(); + assert!( + (s.tooltip_delay_secs - 0.5).abs() < 1e-6, + "tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}", + s.tooltip_delay_secs + ); + } + + #[test] + fn settings_tooltip_delay_round_trip() { + let path = tmp_path("tooltip_delay_round_trip"); + let _ = fs::remove_file(&path); + let s = Settings { + tooltip_delay_secs: 1.2, + ..Settings::default() + }; + save_settings_to(&path, &s).expect("save"); + let loaded = load_settings_from(&path); + assert!( + (loaded.tooltip_delay_secs - 1.2).abs() < 1e-6, + "tooltip_delay_secs must survive serde round-trip; got {}", + loaded.tooltip_delay_secs + ); + let _ = fs::remove_file(&path); + } + + #[test] + fn legacy_settings_without_tooltip_delay_deserializes_to_default() { + // A settings.json written before this field existed must + // deserialize cleanly to the existing 0.5 s baseline rather + // than failing the whole load or yielding a zero value. + let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; + let s: Settings = serde_json::from_slice(json).unwrap_or_default(); + assert!( + (s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6, + "legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}", + default_tooltip_delay(), + s.tooltip_delay_secs + ); + } + + #[test] + fn adjust_tooltip_delay_clamps_to_range() { + let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() }; + // Step up to 0.6. + assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6); + // Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS. + assert!((s.adjust_tooltip_delay(5.0) - TOOLTIP_DELAY_MAX_SECS).abs() < 1e-6); + // Big negative jump clamps to TOOLTIP_DELAY_MIN_SECS. + assert!((s.adjust_tooltip_delay(-99.0) - TOOLTIP_DELAY_MIN_SECS).abs() < 1e-6); + // Confirm the floor is exactly zero. + assert_eq!(s.tooltip_delay_secs, 0.0); + } + + #[test] + fn sanitized_clamps_out_of_range_tooltip_delay() { + // Negative or oversized values from a hand-edited file must be + // clamped on load. + let s = Settings { + tooltip_delay_secs: -0.4, + ..Settings::default() + } + .sanitized(); + assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS); + + let s2 = Settings { + tooltip_delay_secs: 99.0, + ..Settings::default() + } + .sanitized(); + assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS); + } } diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 538bc3e..9f351e8 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -83,6 +83,26 @@ pub struct FoundationCompletedEvent { pub suit: Suit, } +/// Fired by `StatsPlugin` when the player's `win_streak_current` +/// crosses one of the milestone thresholds in +/// [`crate::ui_theme::STREAK_MILESTONES`] (currently 3, 5, 10). +/// +/// Fires only on the threshold crossing — i.e. when the previous +/// streak was below the threshold and the post-win streak is at or +/// above it — so subsequent wins past the highest milestone do not +/// retrigger the flourish. +/// +/// Drives the HUD streak-milestone flourish (a brief scale pulse on +/// the score readout) and an informational toast. UI/audio cue only; +/// not persisted, not synchronised. +#[derive(Message, Debug, Clone, Copy)] +pub struct WinStreakMilestoneEvent { + /// The new `win_streak_current` value at the moment the + /// threshold was crossed. Always equal to a value in + /// [`crate::ui_theme::STREAK_MILESTONES`]. + pub streak: u32, +} + /// Fired when a card's face-up state changes during gameplay. #[derive(Message, Debug, Clone, Copy)] pub struct CardFlippedEvent(pub u32); diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index e41fec2..bbadee0 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -19,16 +19,17 @@ use crate::settings_plugin::SettingsResource; use crate::layout::HUD_BAND_HEIGHT; use crate::ui_theme::{ scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, - BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM, - STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY, - TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, + BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, + MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, + STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, + TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, }; use crate::events::{ HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, - UndoRequestEvent, + UndoRequestEvent, WinStreakMilestoneEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; @@ -130,6 +131,51 @@ pub struct ScoreFloater { pub duration: f32, } +/// Drives the streak-milestone flourish: scales the [`HudScore`] text +/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over +/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by +/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it +/// [`ACCENT_SECONDARY`] for the same window before restoring the +/// original colour. +/// +/// The streak readout currently lives in the Stats overlay (press +/// `S`) — there is no always-on HUD streak counter — so the flourish +/// piggybacks on the score readout, which is the most prominent +/// always-visible HUD number. Mirrors the `FoundationFlourish` +/// pattern: triangular scale curve, fixed duration, restores state +/// when the timer expires. +/// +/// Inserted on `HudScore` entities by `start_streak_flourish` when a +/// `WinStreakMilestoneEvent` fires; removed once `elapsed >= +/// duration` so the readout returns to its rest state for the next +/// frame's transform sync. +/// +/// Coexists with [`ScorePulse`]: the streak flourish lives on a +/// dedicated marker so a streak-crossing win that also ticks the +/// score (every win does) doesn't have the two animations stomp on +/// each other's `Transform.scale` writes — the streak flourish runs +/// in a `Without` query so only the loudest of the two +/// celebrations is active at a time. +#[derive(Component, Debug, Clone, Copy)] +pub struct StreakFlourish { + /// The streak milestone that triggered this flourish (3, 5, 10). + /// Carried for diagnostic logging only — the visual is identical + /// for every threshold so play-testing can decide later whether + /// to differentiate. + pub streak: u32, + /// Seconds elapsed since the flourish began. + pub elapsed: f32, + /// Total animation length in seconds. Zero under + /// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system + /// snaps the scale back to 1.0 on the first tick so no half-state + /// is ever shown. + pub duration: f32, + /// The score readout's colour before the flourish began — + /// restored when the timer expires so the readout returns to its + /// resting `TEXT_PRIMARY` (or whatever it was) tint. + pub original_color: Color, +} + /// Tracks the score from the previous frame so the HUD can detect /// changes without a `ScoreChangedEvent`. The plugin wires this to the /// pulse + floater systems on every `Update`. @@ -251,6 +297,7 @@ impl Plugin for HudPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .init_resource::() .init_resource::() .add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons)) @@ -267,6 +314,12 @@ impl Plugin for HudPlugin { .chain() .after(GameMutation), ) + .add_systems( + Update, + (start_streak_flourish, advance_streak_flourish) + .chain() + .after(GameMutation), + ) .add_systems( Update, ( @@ -1285,6 +1338,148 @@ fn advance_score_floater( } } +// --------------------------------------------------------------------------- +// Streak-milestone flourish +// +// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit +// completion celebration; the streak flourish is its lifetime equivalent — +// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score +// readout pulses larger than a normal score-change pulse and tints magenta +// (`ACCENT_SECONDARY`) before snapping back to its resting state. +// +// Why the score readout: there is no always-on streak number on the HUD +// today (the readout lives in the Stats overlay), and the score is the +// most prominent always-visible HUD figure. The accompanying `InfoToastEvent` +// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a +// player who isn't watching the score still sees the celebration land. +// --------------------------------------------------------------------------- + +/// Pure helper for unit tests — returns the per-frame scale factor for +/// the streak flourish at `elapsed_secs` over `duration_secs`. +/// +/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale): +/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns +/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`. +/// Out-of-range values are clamped so the score readout never freezes +/// at a non-1.0 scale on the frame after the flourish ends. +/// +/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running +/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish +/// without dividing by zero. +pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 { + if duration_secs <= 0.0 { + return 1.0; + } + let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0); + let peak = STREAK_FLOURISH_PEAK_SCALE; + if t < 0.5 { + // Climb from 1.0 at t=0 to peak at t=0.5. + 1.0 + (peak - 1.0) * (t / 0.5) + } else { + // Descend from peak at t=0.5 back to 1.0 at t=1.0. + peak - (peak - 1.0) * ((t - 0.5) / 0.5) + } +} + +/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a +/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current +/// `TextColor` so `advance_streak_flourish` can restore it when the +/// timer expires; reuses any existing flourish's `original_color` so +/// re-entering the system mid-flourish doesn't snapshot the magenta +/// tint as the new "original". +/// +/// Removes any concurrent [`ScorePulse`] from the same entity so the +/// flourish takes over the scale slot cleanly — score pulses last +/// 250 ms, the flourish 600 ms, and the streak crossing always +/// coincides with a positive score delta, so the flourish is the +/// louder of the two celebrations. +fn start_streak_flourish( + mut events: MessageReader, + settings: Option>, + score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With>, + mut commands: Commands, +) { + let Some(latest) = events.read().last() else { + return; + }; + let speed = settings + .as_ref() + .map(|s| s.0.animation_speed) + .unwrap_or_default(); + let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed); + for (entity, color, existing) in &score_q { + let original_color = existing.map_or(color.0, |f| f.original_color); + commands + .entity(entity) + .remove::() + .insert(StreakFlourish { + streak: latest.streak, + elapsed: 0.0, + duration, + original_color, + }); + } +} + +/// Advances every [`StreakFlourish`], scaling its entity's `Transform` +/// using [`streak_flourish_scale`] and lerping the `TextColor` toward +/// [`ACCENT_SECONDARY`] for the first half then back to the captured +/// `original_color`. Removes the component once `elapsed >= duration` +/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) +/// where duration is 0) and pins the scale back to 1.0 / restores the +/// original colour so no half-state is ever shown. +/// +/// Filtered with `Without` so the streak flourish never +/// races a score pulse for the same `Transform.scale` slot — +/// `start_streak_flourish` strips any concurrent `ScorePulse` from the +/// score entity before this system runs, so the filter is purely a +/// belt-and-braces invariant. +fn advance_streak_flourish( + time: Res