feat(engine): UX iteration round — tooltip slider, streak fire, score breakdown
Three small UX improvements bundled because they share ui_theme token
edits.
Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
via "−" / "+" icon buttons next to a value readout. Range
[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
is absent (test path). New tooltip_should_show(elapsed, delay)
pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
load. Five round-trip / default / legacy-deserialise tests.
Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
when win_streak_current crosses any of [3, 5, 10] (only the
threshold crossing — not every subsequent win). HUD streak readout
scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
(0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.
Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
per-component reveal: Base score, Time bonus (m:ss), No-undo
bonus, Mode multiplier, separator, Total. Rows fade in over
MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
it animates. Skipped rows: zero time bonus, undo-tainted no-undo
bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
GameWonEvent.score, time bonus from
solitaire_core::scoring::compute_time_bonus, no-undo from a +25
constant when undo_count == 0, mode multiplier from GameMode (Zen
zeros the total). 9 new tests cover the math and the reveal
cadence.
Test count net: +25 across the workspace (1007 → 1031).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -126,7 +126,7 @@ 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,
|
Theme, WindowGeometry, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ pub struct Settings {
|
|||||||
/// so the toast still does not fire for them.
|
/// so the toast still does not fire for them.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shown_achievement_onboarding: bool,
|
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 {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -161,6 +169,26 @@ fn default_theme_id() -> String {
|
|||||||
"default".to_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 {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -177,17 +205,22 @@ impl Default for Settings {
|
|||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
|
/// Clamps `sfx_volume`, `music_volume`, and `tooltip_delay_secs` into
|
||||||
/// deserialization or hand-editing of `settings.json`.
|
/// their respective ranges after 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),
|
||||||
music_volume: self.music_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
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,6 +236,15 @@ impl Settings {
|
|||||||
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
||||||
self.music_volume
|
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
|
/// 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.animation_speed, AnimSpeed::Normal);
|
||||||
assert_eq!(s.theme, Theme::Green);
|
assert_eq!(s.theme, Theme::Green);
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||||
|
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -331,6 +374,7 @@ mod tests {
|
|||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
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(),
|
||||||
};
|
};
|
||||||
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);
|
||||||
@@ -563,4 +607,86 @@ mod tests {
|
|||||||
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
|
"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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,26 @@ pub struct FoundationCompletedEvent {
|
|||||||
pub suit: Suit,
|
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.
|
/// Fired when a card's face-up state changes during gameplay.
|
||||||
#[derive(Message, Debug, Clone, Copy)]
|
#[derive(Message, Debug, Clone, Copy)]
|
||||||
pub struct CardFlippedEvent(pub u32);
|
pub struct CardFlippedEvent(pub u32);
|
||||||
|
|||||||
@@ -19,16 +19,17 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
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,
|
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
|
||||||
STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY,
|
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
||||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
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::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
||||||
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
||||||
UndoRequestEvent,
|
UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -130,6 +131,51 @@ pub struct ScoreFloater {
|
|||||||
pub duration: f32,
|
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<ScorePulse>` 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
|
/// Tracks the score from the previous frame so the HUD can detect
|
||||||
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
||||||
/// pulse + floater systems on every `Update`.
|
/// pulse + floater systems on every `Update`.
|
||||||
@@ -251,6 +297,7 @@ impl Plugin for HudPlugin {
|
|||||||
.add_message::<ToggleProfileRequestEvent>()
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
.add_message::<WinStreakMilestoneEvent>()
|
||||||
.init_resource::<PreviousScore>()
|
.init_resource::<PreviousScore>()
|
||||||
.init_resource::<HudActionFade>()
|
.init_resource::<HudActionFade>()
|
||||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||||
@@ -267,6 +314,12 @@ impl Plugin for HudPlugin {
|
|||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
)
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(start_streak_flourish, advance_streak_flourish)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
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<WinStreakMilestoneEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
|
||||||
|
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::<ScorePulse>()
|
||||||
|
.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<ScorePulse>` 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<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<
|
||||||
|
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
|
||||||
|
Without<ScorePulse>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut anim, mut transform, mut color) in &mut q {
|
||||||
|
let t = if anim.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
// Tint mix: full magenta at t=0..=0.5, fades back to the
|
||||||
|
// original colour over t=0.5..=1.0.
|
||||||
|
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||||
|
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
color.0 = anim.original_color;
|
||||||
|
commands.entity(entity).remove::<StreakFlourish>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB-space linear interpolation between two `Color`s — small local
|
||||||
|
/// helper so `advance_streak_flourish` stays readable. sRGB-space
|
||||||
|
/// lerping is fine for a brief decorative tint (a perceptually-uniform
|
||||||
|
/// space would be overkill).
|
||||||
|
fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||||
|
let from = from.to_srgba();
|
||||||
|
let to = to.to_srgba();
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Color::srgba(
|
||||||
|
from.red + (to.red - from.red) * t,
|
||||||
|
from.green + (to.green - from.green) * t,
|
||||||
|
from.blue + (to.blue - from.blue) * t,
|
||||||
|
from.alpha + (to.alpha - from.alpha) * t,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
fn update_hud(
|
fn update_hud(
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -2091,6 +2286,45 @@ mod tests {
|
|||||||
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and
|
||||||
|
/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale`
|
||||||
|
/// curve test — the two animations share a triangular shape so a
|
||||||
|
/// future tweak that desyncs them shows up here.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_curves_through_one_one_one() {
|
||||||
|
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"streak flourish scale at t=0 must be 1.0",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5,
|
||||||
|
"streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
|
||||||
|
"streak flourish scale at t=duration must return to 1.0",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out-of-range values are clamped, not extrapolated. Matches the
|
||||||
|
/// foundation flourish's clamp behaviour so the score readout never
|
||||||
|
/// freezes at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_clamps_out_of_range() {
|
||||||
|
let dur = MOTION_STREAK_FLOURISH_SECS;
|
||||||
|
assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
|
||||||
|
/// divides by zero.
|
||||||
|
#[test]
|
||||||
|
fn streak_flourish_scale_zero_duration_is_one() {
|
||||||
|
assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Phase 2: keyboard focus ring — HUD action bar
|
// Phase 2: keyboard focus ring — HUD action bar
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -89,14 +89,15 @@ pub use events::{
|
|||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
||||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
ToggleStatsRequestEvent, UndoRequestEvent, XpAwardedEvent,
|
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
pub use home_plugin::{HomePlugin, HomeScreen};
|
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||||
pub use hud_plugin::{
|
pub use hud_plugin::{
|
||||||
ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton, MenuOption, MenuPopover,
|
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton,
|
||||||
ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, UndoButton,
|
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton,
|
||||||
|
StreakFlourish, UndoButton,
|
||||||
};
|
};
|
||||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
|
|||||||
@@ -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,
|
WindowGeometry, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
@@ -122,6 +122,10 @@ struct BackgroundText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct ColorBlindText;
|
struct ColorBlindText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct TooltipDelayText;
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -139,6 +143,10 @@ enum SettingsButton {
|
|||||||
MusicUp,
|
MusicUp,
|
||||||
ToggleDrawMode,
|
ToggleDrawMode,
|
||||||
CycleAnimSpeed,
|
CycleAnimSpeed,
|
||||||
|
/// Decrement the tooltip-hover dwell delay by one step.
|
||||||
|
TooltipDelayDown,
|
||||||
|
/// Increment the tooltip-hover dwell delay by one step.
|
||||||
|
TooltipDelayUp,
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
ToggleColorBlind,
|
ToggleColorBlind,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
@@ -169,6 +177,8 @@ impl SettingsButton {
|
|||||||
// Gameplay section
|
// Gameplay section
|
||||||
SettingsButton::ToggleDrawMode => 30,
|
SettingsButton::ToggleDrawMode => 30,
|
||||||
SettingsButton::CycleAnimSpeed => 40,
|
SettingsButton::CycleAnimSpeed => 40,
|
||||||
|
SettingsButton::TooltipDelayDown => 45,
|
||||||
|
SettingsButton::TooltipDelayUp => 46,
|
||||||
// Cosmetic section
|
// Cosmetic section
|
||||||
SettingsButton::ToggleTheme => 50,
|
SettingsButton::ToggleTheme => 50,
|
||||||
SettingsButton::ToggleColorBlind => 60,
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
@@ -258,6 +268,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_background_text,
|
update_background_text,
|
||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
|
update_tooltip_delay_text,
|
||||||
attach_focusable_to_settings_buttons,
|
attach_focusable_to_settings_buttons,
|
||||||
scroll_focus_into_view,
|
scroll_focus_into_view,
|
||||||
),
|
),
|
||||||
@@ -483,6 +494,21 @@ fn update_color_blind_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live tooltip-delay value in the Gameplay section
|
||||||
|
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||||||
|
/// settings.json reload, etc.).
|
||||||
|
fn update_tooltip_delay_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -606,6 +632,24 @@ fn handle_settings_buttons(
|
|||||||
**t = anim_speed_label(&settings.0.animation_speed);
|
**t = anim_speed_label(&settings.0.animation_speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingsButton::TooltipDelayDown => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_tooltip_delay_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayUp => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
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,
|
||||||
@@ -680,6 +724,17 @@ fn color_blind_label(enabled: bool) -> String {
|
|||||||
if enabled { "ON".into() } else { "OFF".into() }
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats the tooltip-hover delay for display in the Settings panel.
|
||||||
|
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
|
||||||
|
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
|
||||||
|
fn tooltip_delay_label(secs: f32) -> String {
|
||||||
|
if secs <= 0.0 {
|
||||||
|
"Instant".into()
|
||||||
|
} else {
|
||||||
|
format!("{secs:.1} s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -1003,6 +1058,11 @@ fn spawn_settings_panel(
|
|||||||
"Cycle animation speed: Normal, Fast, Instant.",
|
"Cycle animation speed: Normal, Fast, Instant.",
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
tooltip_delay_row(
|
||||||
|
body,
|
||||||
|
settings.tooltip_delay_secs,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
// --- Cosmetic ---
|
// --- Cosmetic ---
|
||||||
section_label(body, "Cosmetic", font_res);
|
section_label(body, "Cosmetic", font_res);
|
||||||
@@ -1129,6 +1189,53 @@ fn volume_row<Marker: Component>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Tooltip Delay 0.5 s [−] [+]` — slider row for the player-tunable
|
||||||
|
/// tooltip-hover dwell. Mirrors [`volume_row`] (label, current value,
|
||||||
|
/// decrement, increment) but formats the value via [`tooltip_delay_label`]
|
||||||
|
/// so `0.0` reads as `"Instant"` and other values as `"{n:.1} s"`.
|
||||||
|
fn tooltip_delay_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
value_secs: 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("Tooltip Delay".to_string()),
|
||||||
|
label_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
TooltipDelayText,
|
||||||
|
Text::new(tooltip_delay_label(value_secs)),
|
||||||
|
value_font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
icon_button(
|
||||||
|
row,
|
||||||
|
"−",
|
||||||
|
SettingsButton::TooltipDelayDown,
|
||||||
|
"Shorten the hover delay before tooltips appear.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
icon_button(
|
||||||
|
row,
|
||||||
|
"+",
|
||||||
|
SettingsButton::TooltipDelayUp,
|
||||||
|
"Lengthen the hover delay before tooltips appear.",
|
||||||
|
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).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
|||||||
use crate::challenge_plugin::challenge_progress_label;
|
use crate::challenge_plugin::challenge_progress_label;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
|
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
|
||||||
|
WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -29,9 +30,9 @@ use crate::ui_modal::{
|
|||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
|
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
|
||||||
VAL_SPACE_4, Z_MODAL_PANEL,
|
VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bevy resource wrapping the current stats.
|
/// Bevy resource wrapping the current stats.
|
||||||
@@ -93,6 +94,7 @@ impl Plugin for StatsPlugin {
|
|||||||
.add_message::<ForfeitEvent>()
|
.add_message::<ForfeitEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleStatsRequestEvent>()
|
.add_message::<ToggleStatsRequestEvent>()
|
||||||
|
.add_message::<WinStreakMilestoneEvent>()
|
||||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||||
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
||||||
// StatsUpdate (as a set) is ordered after GameMutation by external
|
// StatsUpdate (as a set) is ordered after GameMutation by external
|
||||||
@@ -130,15 +132,55 @@ fn update_stats_on_win(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut stats: ResMut<StatsResource>,
|
mut stats: ResMut<StatsResource>,
|
||||||
path: Res<StatsStoragePath>,
|
path: Res<StatsStoragePath>,
|
||||||
|
mut milestone: MessageWriter<WinStreakMilestoneEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
let prev_streak = stats.0.win_streak_current;
|
||||||
stats
|
stats
|
||||||
.0
|
.0
|
||||||
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
||||||
|
let new_streak = stats.0.win_streak_current;
|
||||||
|
// Fire the streak-milestone event only on the threshold
|
||||||
|
// crossing — `prev < threshold && new >= threshold`. This
|
||||||
|
// guarantees the flourish never retriggers at every win past
|
||||||
|
// the highest milestone.
|
||||||
|
if let Some(crossed) = streak_milestone_crossed(prev_streak, new_streak) {
|
||||||
|
milestone.write(WinStreakMilestoneEvent { streak: crossed });
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"Win streak: {crossed}! \u{1F525}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
persist(&path, &stats.0, "win");
|
persist(&path, &stats.0, "win");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the milestone value that the player just crossed, if any.
|
||||||
|
///
|
||||||
|
/// A milestone is "crossed" when `prev < threshold && new >= threshold`
|
||||||
|
/// for some `threshold` in [`STREAK_MILESTONES`]. Returns the largest
|
||||||
|
/// such threshold (so a single win that vaults the player from a
|
||||||
|
/// streak of 0 directly to 5 — implausible, but defensive — fires the
|
||||||
|
/// most-celebrated milestone, not the smallest).
|
||||||
|
///
|
||||||
|
/// Returns `None` when no threshold was crossed, i.e. either:
|
||||||
|
/// - the streak did not change,
|
||||||
|
/// - the streak rose but stayed below every threshold, or
|
||||||
|
/// - the streak rose past a threshold that `prev` was already at or
|
||||||
|
/// above.
|
||||||
|
///
|
||||||
|
/// Pure function exposed for unit testing without Bevy.
|
||||||
|
pub fn streak_milestone_crossed(prev: u32, new: u32) -> Option<u32> {
|
||||||
|
if new <= prev {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
STREAK_MILESTONES
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&t| prev < t && new >= t)
|
||||||
|
.max()
|
||||||
|
}
|
||||||
|
|
||||||
fn update_stats_on_new_game(
|
fn update_stats_on_new_game(
|
||||||
mut events: MessageReader<NewGameRequestEvent>,
|
mut events: MessageReader<NewGameRequestEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -895,4 +937,120 @@ mod tests {
|
|||||||
"expected no streak-broken toast for streak of 1, got: {messages:?}"
|
"expected no streak-broken toast for streak of 1, got: {messages:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Streak-milestone flourish — pure helper + event-firing tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper: every threshold in `STREAK_MILESTONES` (3, 5, 10) must
|
||||||
|
/// fire when the streak crosses it from below.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_helper_fires_at_each_threshold() {
|
||||||
|
for &threshold in STREAK_MILESTONES {
|
||||||
|
assert_eq!(
|
||||||
|
streak_milestone_crossed(threshold - 1, threshold),
|
||||||
|
Some(threshold),
|
||||||
|
"expected milestone {threshold} to fire when crossed from below",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper: rising past 10 to 11, 12, … must NOT fire — the
|
||||||
|
/// flourish is a threshold-crossing event, not a "every win past 10"
|
||||||
|
/// event.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_helper_does_not_fire_past_highest() {
|
||||||
|
// prev=10 → new=11: above the highest threshold, no crossing.
|
||||||
|
assert_eq!(streak_milestone_crossed(10, 11), None);
|
||||||
|
// prev=15 → new=16: well past every threshold, no crossing.
|
||||||
|
assert_eq!(streak_milestone_crossed(15, 16), None);
|
||||||
|
// prev=2 → new=2: no change → no crossing.
|
||||||
|
assert_eq!(streak_milestone_crossed(2, 2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper: rising 1 → 2 stays below the lowest threshold (3),
|
||||||
|
/// must NOT fire.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_helper_does_not_fire_below_threshold() {
|
||||||
|
assert_eq!(streak_milestone_crossed(1, 2), None);
|
||||||
|
assert_eq!(streak_milestone_crossed(0, 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integration: pre-set streak to 2, fire a win that bumps it to 3,
|
||||||
|
/// assert exactly one `WinStreakMilestoneEvent { streak: 3 }` is
|
||||||
|
/// written by the win handler.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_event_fires_at_threshold_crossing() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||||
|
stats.0.win_streak_current = 2;
|
||||||
|
}
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
||||||
|
let mut reader = events.get_cursor();
|
||||||
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
collected,
|
||||||
|
vec![3],
|
||||||
|
"expected one WinStreakMilestoneEvent {{ streak: 3 }} after crossing 2 → 3",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integration: pre-set streak to 1, fire a win that bumps it to 2 —
|
||||||
|
/// no threshold is crossed, no event must be fired.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_event_does_not_fire_at_non_threshold() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||||
|
stats.0.win_streak_current = 1;
|
||||||
|
}
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
||||||
|
let mut reader = events.get_cursor();
|
||||||
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
collected.is_empty(),
|
||||||
|
"expected no WinStreakMilestoneEvent for non-threshold streak crossing 1 → 2, got {collected:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integration: pre-set streak to 10, fire a win that bumps it to 11.
|
||||||
|
/// Past the highest threshold, no event must fire — the flourish
|
||||||
|
/// is reserved for the threshold crossing itself.
|
||||||
|
#[test]
|
||||||
|
fn streak_milestone_event_does_not_fire_past_10() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
{
|
||||||
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||||
|
stats.0.win_streak_current = 10;
|
||||||
|
}
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
||||||
|
let mut reader = events.get_cursor();
|
||||||
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
collected.is_empty(),
|
||||||
|
"expected no WinStreakMilestoneEvent past the highest threshold, got {collected:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,14 @@ pub const MOTION_CASCADE_STAGGER_SECS: f32 = 0.06;
|
|||||||
/// (overshoot) plus ±15° Z-rotation. 500 ms.
|
/// (overshoot) plus ±15° Z-rotation. 500 ms.
|
||||||
pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50;
|
pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50;
|
||||||
|
|
||||||
|
/// Per-line stagger between score-breakdown rows during the win modal
|
||||||
|
/// reveal animation, in seconds.
|
||||||
|
pub const MOTION_SCORE_BREAKDOWN_STAGGER_SECS: f32 = 0.15;
|
||||||
|
|
||||||
|
/// Per-line fade-in duration during the win modal score reveal, in
|
||||||
|
/// seconds.
|
||||||
|
pub const MOTION_SCORE_BREAKDOWN_FADE_SECS: f32 = 0.12;
|
||||||
|
|
||||||
/// Screen shake on win — wider and longer than the old 0.6 s / 8 px.
|
/// Screen shake on win — wider and longer than the old 0.6 s / 8 px.
|
||||||
/// 800 ms.
|
/// 800 ms.
|
||||||
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
|
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
|
||||||
@@ -395,6 +403,23 @@ pub const MOTION_FOUNDATION_FLOURISH_SECS: f32 = 0.4;
|
|||||||
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
|
/// 1.0 at `t=0` to this value at `t=0.5` and back to 1.0 at `t=1.0`.
|
||||||
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
|
pub const FOUNDATION_FLOURISH_PEAK_SCALE: f32 = 1.15;
|
||||||
|
|
||||||
|
/// Total duration of the streak-milestone flourish on the HUD score
|
||||||
|
/// readout, in seconds. Mirrors the foundation flourish in feel — a
|
||||||
|
/// brief celebratory pulse that does not block subsequent gameplay.
|
||||||
|
pub const MOTION_STREAK_FLOURISH_SECS: f32 = 0.6;
|
||||||
|
|
||||||
|
/// Peak scale magnification reached at the midpoint of the streak
|
||||||
|
/// flourish (1.0 → this → 1.0). Larger than the foundation flourish
|
||||||
|
/// peak so the lifetime-streak celebration reads as a bigger deal than
|
||||||
|
/// the per-suit completion.
|
||||||
|
pub const STREAK_FLOURISH_PEAK_SCALE: f32 = 1.20;
|
||||||
|
|
||||||
|
/// Win-streak counts that trigger the flourish. The flourish fires
|
||||||
|
/// only when the streak crosses a threshold from below — never at
|
||||||
|
/// every win past the highest threshold. Static for now; could become
|
||||||
|
/// a `Settings`-tunable list later if play-testing surfaces it.
|
||||||
|
pub const STREAK_MILESTONES: &[u32] = &[3, 5, 10];
|
||||||
|
|
||||||
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
|
||||||
/// 400 ms.
|
/// 400 ms.
|
||||||
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use bevy::prelude::*;
|
|||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
|
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
|
||||||
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
|
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
|
||||||
@@ -137,6 +138,23 @@ struct TooltipText;
|
|||||||
/// target's own border.
|
/// target's own border.
|
||||||
const TOOLTIP_GAP_PX: f32 = 4.0;
|
const TOOLTIP_GAP_PX: f32 = 4.0;
|
||||||
|
|
||||||
|
/// Pure helper: returns `true` once `elapsed_secs` has met or exceeded
|
||||||
|
/// the player-configured `delay_secs`, so the tooltip should be revealed.
|
||||||
|
///
|
||||||
|
/// Treating "elapsed >= delay" as the show condition (rather than
|
||||||
|
/// strictly greater than) is what makes a `delay_secs == 0.0` setting
|
||||||
|
/// behave as advertised: on the very first tick after hover starts,
|
||||||
|
/// `elapsed_secs` is `0.0` and the tooltip appears immediately. With a
|
||||||
|
/// strict `>` the zero-delay case would still wait one tick.
|
||||||
|
///
|
||||||
|
/// Extracted so the comparison can be unit-tested without spinning up
|
||||||
|
/// a Bevy `App` — `Time<Virtual>` clamps each tick to 250 ms under
|
||||||
|
/// `MinimalPlugins`, which makes precise sub-second timing assertions
|
||||||
|
/// awkward.
|
||||||
|
pub(crate) fn tooltip_should_show(elapsed_secs: f32, delay_secs: f32) -> bool {
|
||||||
|
elapsed_secs >= delay_secs
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Systems
|
// Systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -257,6 +275,7 @@ fn track_tooltip_hover(
|
|||||||
fn show_or_hide_tooltip(
|
fn show_or_hide_tooltip(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
state: Res<TooltipState>,
|
state: Res<TooltipState>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
|
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
|
||||||
tooltip_text_only: Query<&Tooltip>,
|
tooltip_text_only: Query<&Tooltip>,
|
||||||
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
|
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
|
||||||
@@ -280,9 +299,15 @@ fn show_or_hide_tooltip(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Player-configurable dwell delay; falls back to the design-token
|
||||||
|
// default when `SettingsResource` is absent (test harnesses running
|
||||||
|
// `UiTooltipPlugin` under `MinimalPlugins` without `SettingsPlugin`).
|
||||||
|
let delay_secs = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.tooltip_delay_secs)
|
||||||
|
.unwrap_or(MOTION_TOOLTIP_DELAY_SECS);
|
||||||
let elapsed = time.elapsed().saturating_sub(started_at);
|
let elapsed = time.elapsed().saturating_sub(started_at);
|
||||||
let delay = Duration::from_secs_f32(MOTION_TOOLTIP_DELAY_SECS);
|
if !tooltip_should_show(elapsed.as_secs_f32(), delay_secs) {
|
||||||
if elapsed < delay {
|
|
||||||
hide(&mut visibility);
|
hide(&mut visibility);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -550,4 +575,30 @@ mod tests {
|
|||||||
"overlay text must update to the new hovered entity's Tooltip string"
|
"overlay text must update to the new hovered entity's Tooltip string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test 5: `tooltip_should_show` is the pure helper that the system
|
||||||
|
/// uses to gate the reveal — exercising it directly avoids the
|
||||||
|
/// `Time<Virtual>` 250 ms clamp that makes precise sub-second
|
||||||
|
/// timing assertions in `MinimalPlugins` fiddly. The four cases
|
||||||
|
/// below cover the boundary semantics:
|
||||||
|
///
|
||||||
|
/// * `delay = 0.0` ("Instant") must show on the first tick.
|
||||||
|
/// * `elapsed < delay` must NOT show.
|
||||||
|
/// * `elapsed == delay` must show (boundary inclusive).
|
||||||
|
/// * `elapsed > delay` must show.
|
||||||
|
#[test]
|
||||||
|
fn tooltip_should_show_respects_delay() {
|
||||||
|
// delay == 0 ("Instant"): any elapsed (including zero) shows.
|
||||||
|
assert!(tooltip_should_show(0.0, 0.0), "instant delay must show on first tick");
|
||||||
|
assert!(tooltip_should_show(0.5, 0.0));
|
||||||
|
|
||||||
|
// Standard non-zero delay.
|
||||||
|
assert!(!tooltip_should_show(0.4, 0.5), "elapsed < delay must hide");
|
||||||
|
assert!(tooltip_should_show(0.5, 0.5), "elapsed == delay must show (boundary)");
|
||||||
|
assert!(tooltip_should_show(0.6, 0.5), "elapsed > delay must show");
|
||||||
|
|
||||||
|
// Larger delay (max-end of the slider).
|
||||||
|
assert!(!tooltip_should_show(1.0, 1.5));
|
||||||
|
assert!(tooltip_should_show(1.5, 1.5));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
|
use solitaire_core::scoring::compute_time_bonus;
|
||||||
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -23,10 +25,11 @@ use crate::resources::GameStateResource;
|
|||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_WIN_SHAKE_AMPLITUDE,
|
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||||
MOTION_WIN_SHAKE_SECS, RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
|
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
|
||||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2,
|
RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY,
|
||||||
VAL_SPACE_3, Z_WIN_CASCADE,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
Z_WIN_CASCADE,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -73,6 +76,15 @@ pub struct WinSummaryPending {
|
|||||||
/// human-readable level number that was just completed (e.g. `Some(3)`
|
/// human-readable level number that was just completed (e.g. `Some(3)`
|
||||||
/// means "Challenge 3"). `None` for non-Challenge modes.
|
/// means "Challenge 3"). `None` for non-Challenge modes.
|
||||||
pub challenge_level: Option<u32>,
|
pub challenge_level: Option<u32>,
|
||||||
|
/// Number of undos used during the winning game. Captured from
|
||||||
|
/// `GameStateResource` at the moment `GameWonEvent` fires so the
|
||||||
|
/// score-breakdown reveal can decide whether to award the no-undo
|
||||||
|
/// bonus row.
|
||||||
|
pub undo_count: u32,
|
||||||
|
/// Game mode of the winning game. Captured at win time so the
|
||||||
|
/// score-breakdown reveal can format the mode-multiplier row
|
||||||
|
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||||
|
pub mode: GameMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a human-readable XP breakdown string for the win modal.
|
/// Builds a human-readable XP breakdown string for the win modal.
|
||||||
@@ -161,6 +173,37 @@ enum WinSummaryButton {
|
|||||||
PlayAgain,
|
PlayAgain,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||||
|
///
|
||||||
|
/// Each row carries a stagger delay (seconds until the row should
|
||||||
|
/// become visible) plus a fade-in timer that lerps the row's text
|
||||||
|
/// alpha from `0.0 → 1.0` over [`MOTION_SCORE_BREAKDOWN_FADE_SECS`].
|
||||||
|
/// Rows are spawned with `Visibility::Hidden`; the reveal system
|
||||||
|
/// flips them to `Visibility::Inherited` once `delay_secs` elapses
|
||||||
|
/// and then drives the per-text alpha lerp until the row reaches
|
||||||
|
/// full opacity.
|
||||||
|
///
|
||||||
|
/// When `AnimSpeed::Instant` is active the row is spawned with
|
||||||
|
/// `delay_secs = 0.0`, `fade_duration_secs = 0.0`, and visibility
|
||||||
|
/// already set to `Inherited` so the reveal happens on frame 1.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ScoreBreakdownRow {
|
||||||
|
/// Seconds remaining until this row first becomes visible.
|
||||||
|
/// Counts down to 0 in `reveal_score_breakdown`. Zero or negative
|
||||||
|
/// means "show immediately".
|
||||||
|
pub delay_secs: f32,
|
||||||
|
/// Seconds elapsed since this row became visible. Drives the
|
||||||
|
/// alpha lerp on the row's child `Text` nodes.
|
||||||
|
pub fade_elapsed_secs: f32,
|
||||||
|
/// Total fade-in duration. Zero means "no fade — appear at full
|
||||||
|
/// opacity in one frame".
|
||||||
|
pub fade_duration_secs: f32,
|
||||||
|
/// `true` once the row's `Visibility` has been promoted from
|
||||||
|
/// `Hidden` to `Inherited`. Prevents re-running the visibility
|
||||||
|
/// switch every frame after the row first reveals.
|
||||||
|
pub revealed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -193,6 +236,7 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
spawn_win_summary_after_delay,
|
spawn_win_summary_after_delay,
|
||||||
handle_win_summary_buttons,
|
handle_win_summary_buttons,
|
||||||
apply_screen_shake,
|
apply_screen_shake,
|
||||||
|
reveal_score_breakdown,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
@@ -217,6 +261,144 @@ pub fn format_win_time(seconds: u64) -> String {
|
|||||||
format!("{m}:{s:02}")
|
format!("{m}:{s:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Score amount awarded as a "no-undo" bonus in the win modal when the
|
||||||
|
/// player completes the game without using undo. Mirrors the XP-side
|
||||||
|
/// no-undo bonus so the score and XP breakdowns reinforce each other,
|
||||||
|
/// and stays a `pub const` so tests can assert against it without
|
||||||
|
/// re-typing the literal.
|
||||||
|
pub const SCORE_NO_UNDO_BONUS: i32 = 25;
|
||||||
|
|
||||||
|
/// Decomposed view of the player's final score, displayed in the win
|
||||||
|
/// modal as a sequence of fade-in rows.
|
||||||
|
///
|
||||||
|
/// The fields mirror the row layout described in the win-modal
|
||||||
|
/// reveal:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Base score {base}
|
||||||
|
/// Time bonus ({m:ss}) +{time_bonus}
|
||||||
|
/// No-undo bonus +{no_undo_bonus}
|
||||||
|
/// Mode multiplier ({mode} ×N) ×{multiplier}
|
||||||
|
/// ─────────────────────────────────
|
||||||
|
/// Total {total}
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Components that do not apply to the current win are zeroed out:
|
||||||
|
/// `time_bonus = 0` when the player took longer than the time-bonus
|
||||||
|
/// curve produces a positive result, `no_undo_bonus = 0` when undo
|
||||||
|
/// was used, and `multiplier = 1.0` outside Zen mode. The renderer
|
||||||
|
/// uses these zero markers to skip rows the player would not benefit
|
||||||
|
/// from seeing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ScoreBreakdown {
|
||||||
|
/// Running game score before the win-time bonuses are applied.
|
||||||
|
/// Equal to `pending.score`, which is `GameState::score` at the
|
||||||
|
/// moment of `GameWonEvent`.
|
||||||
|
pub base: i32,
|
||||||
|
/// Time-bonus component — `compute_time_bonus(time_seconds)`.
|
||||||
|
/// Zero when `time_seconds == 0` or when the formula yields zero.
|
||||||
|
pub time_bonus: i32,
|
||||||
|
/// Score awarded for completing the win without using undo.
|
||||||
|
/// Zero when `undo_count > 0`.
|
||||||
|
pub no_undo_bonus: i32,
|
||||||
|
/// Multiplier applied to `(base + time_bonus + no_undo_bonus)` to
|
||||||
|
/// produce the final total. `0.0` for Zen mode (which never
|
||||||
|
/// scores), `1.0` otherwise.
|
||||||
|
pub multiplier: f32,
|
||||||
|
/// Game mode the win occurred in. Used by the renderer to format
|
||||||
|
/// the multiplier row label, e.g. `"Mode multiplier (Zen ×0)"`.
|
||||||
|
pub mode: GameMode,
|
||||||
|
/// Elapsed game time in seconds, used to format the time-bonus
|
||||||
|
/// row label as `m:ss`.
|
||||||
|
pub time_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScoreBreakdown {
|
||||||
|
/// Builds a breakdown for the given win.
|
||||||
|
///
|
||||||
|
/// `base` is the running game score (`pending.score`); `time_seconds`,
|
||||||
|
/// `undo_count`, and `mode` come from the captured `WinSummaryPending`.
|
||||||
|
/// All score arithmetic is saturating to keep the breakdown safe even
|
||||||
|
/// for pathologically high scores.
|
||||||
|
pub fn compute(base: i32, time_seconds: u64, undo_count: u32, mode: GameMode) -> Self {
|
||||||
|
let time_bonus = compute_time_bonus(time_seconds);
|
||||||
|
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
|
||||||
|
let multiplier = match mode {
|
||||||
|
GameMode::Zen => 0.0,
|
||||||
|
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack => 1.0,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
base,
|
||||||
|
time_bonus,
|
||||||
|
no_undo_bonus,
|
||||||
|
multiplier,
|
||||||
|
mode,
|
||||||
|
time_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final total displayed on the breakdown's bottom row, rounded
|
||||||
|
/// half-to-even (Rust's default `as i32` cast truncates toward
|
||||||
|
/// zero, which is fine for a non-fractional multiplier set).
|
||||||
|
pub fn total(&self) -> i32 {
|
||||||
|
let pre_mult = self
|
||||||
|
.base
|
||||||
|
.saturating_add(self.time_bonus)
|
||||||
|
.saturating_add(self.no_undo_bonus);
|
||||||
|
((pre_mult as f32) * self.multiplier) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the no-undo bonus row should be rendered. Skipped when
|
||||||
|
/// the player used undo (bonus is zero) so the modal does not
|
||||||
|
/// show a "+0" line that adds nothing.
|
||||||
|
pub fn shows_no_undo_row(&self) -> bool {
|
||||||
|
self.no_undo_bonus > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the time-bonus row should be rendered. Skipped when
|
||||||
|
/// the bonus is zero (e.g. `time_seconds == 0`).
|
||||||
|
pub fn shows_time_bonus_row(&self) -> bool {
|
||||||
|
self.time_bonus > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the mode-multiplier row should be rendered. Skipped
|
||||||
|
/// for `multiplier == 1.0` so Classic/Challenge/TimeAttack wins
|
||||||
|
/// do not show a redundant "×1.0" line.
|
||||||
|
pub fn shows_multiplier_row(&self) -> bool {
|
||||||
|
(self.multiplier - 1.0).abs() > f32::EPSILON
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total number of rows the breakdown will spawn, counting the
|
||||||
|
/// always-present `Base score` and `Total` rows plus the
|
||||||
|
/// separator. Used by tests to assert spawn counts deterministically.
|
||||||
|
pub fn row_count(&self) -> usize {
|
||||||
|
let mut n = 1; // base
|
||||||
|
if self.shows_time_bonus_row() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if self.shows_no_undo_row() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if self.shows_multiplier_row() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
n += 1; // separator
|
||||||
|
n += 1; // total
|
||||||
|
n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable display name for a game mode. Used as the prefix in
|
||||||
|
/// the mode-multiplier row, e.g. `"Mode multiplier (Zen ×0)"`.
|
||||||
|
fn mode_display_name(mode: GameMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
GameMode::Classic => "Classic",
|
||||||
|
GameMode::Zen => "Zen",
|
||||||
|
GameMode::Challenge => "Challenge",
|
||||||
|
GameMode::TimeAttack => "Time Attack",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Systems
|
// Systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -267,6 +449,8 @@ fn cache_win_data(
|
|||||||
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
||||||
pending.new_record = is_new_record;
|
pending.new_record = is_new_record;
|
||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
|
pending.undo_count = game.0.undo_count;
|
||||||
|
pending.mode = game.0.mode;
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||||
@@ -365,7 +549,12 @@ fn spawn_win_summary_after_delay(
|
|||||||
pending.xp = pending.xp.saturating_add(ev.amount);
|
pending.xp = pending.xp.saturating_add(ev.amount);
|
||||||
}
|
}
|
||||||
let challenge_level = pending.challenge_level;
|
let challenge_level = pending.challenge_level;
|
||||||
spawn_overlay(&mut commands, &pending, &session, challenge_level);
|
// Re-derive AnimSpeed here — the `speed` binding above
|
||||||
|
// only lives inside the `for _ in won.read()` loop.
|
||||||
|
let anim_speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or(AnimSpeed::Normal, |s| s.0.animation_speed);
|
||||||
|
spawn_overlay(&mut commands, &pending, &session, challenge_level, anim_speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,12 +628,25 @@ fn apply_screen_shake(
|
|||||||
///
|
///
|
||||||
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
||||||
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
||||||
|
///
|
||||||
|
/// `anim_speed` controls the score-breakdown reveal: under
|
||||||
|
/// `AnimSpeed::Instant`, every breakdown row is spawned visible and at
|
||||||
|
/// full opacity (no stagger, no fade); otherwise rows are spawned
|
||||||
|
/// hidden and the [`reveal_score_breakdown`] system fades them in over
|
||||||
|
/// roughly one second.
|
||||||
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,
|
||||||
) {
|
) {
|
||||||
|
let breakdown = ScoreBreakdown::compute(
|
||||||
|
pending.score,
|
||||||
|
pending.time_seconds,
|
||||||
|
pending.undo_count,
|
||||||
|
pending.mode,
|
||||||
|
);
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
WinSummaryOverlay,
|
WinSummaryOverlay,
|
||||||
@@ -502,12 +704,9 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score
|
// Score breakdown reveal — replaces the previous single
|
||||||
card.spawn((
|
// "Score:" line with a per-component multi-row layout.
|
||||||
Text::new(format!("Score: {}", pending.score)),
|
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||||
TextFont { font_size: TYPE_HEADLINE, ..default() },
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Time
|
// Time
|
||||||
card.spawn((
|
card.spawn((
|
||||||
@@ -597,6 +796,220 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns the score-breakdown rows inside the win-modal card.
|
||||||
|
///
|
||||||
|
/// Rows are appended in this order — only the first and last two are
|
||||||
|
/// always present, the middle three depend on `breakdown`:
|
||||||
|
///
|
||||||
|
/// 1. `Base score` — value column = `breakdown.base`.
|
||||||
|
/// 2. `Time bonus (m:ss)` — only when `breakdown.shows_time_bonus_row()`.
|
||||||
|
/// 3. `No-undo bonus` — only when `breakdown.shows_no_undo_row()`.
|
||||||
|
/// 4. `Mode multiplier (Mode-name ×N)` — only when
|
||||||
|
/// `breakdown.shows_multiplier_row()`.
|
||||||
|
/// 5. Separator (em-dashes).
|
||||||
|
/// 6. `Total` — value column = `breakdown.total()`.
|
||||||
|
///
|
||||||
|
/// Every row is spawned with a [`ScoreBreakdownRow`] component carrying
|
||||||
|
/// a per-row stagger delay calculated from
|
||||||
|
/// [`MOTION_SCORE_BREAKDOWN_STAGGER_SECS`]. Under `AnimSpeed::Instant`,
|
||||||
|
/// stagger and fade are both zero so the breakdown appears in one frame.
|
||||||
|
fn spawn_score_breakdown(
|
||||||
|
card: &mut ChildSpawnerCommands,
|
||||||
|
breakdown: &ScoreBreakdown,
|
||||||
|
anim_speed: AnimSpeed,
|
||||||
|
) {
|
||||||
|
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, anim_speed);
|
||||||
|
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, anim_speed);
|
||||||
|
let mut row_index: u32 = 0;
|
||||||
|
|
||||||
|
// 1. Base score — always shown.
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"Base score",
|
||||||
|
format!("{}", breakdown.base),
|
||||||
|
ACCENT_PRIMARY,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
|
||||||
|
// 2. Time bonus.
|
||||||
|
if breakdown.shows_time_bonus_row() {
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
&format!("Time bonus ({})", format_win_time(breakdown.time_seconds)),
|
||||||
|
format!("+{}", breakdown.time_bonus),
|
||||||
|
STATE_SUCCESS,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No-undo bonus.
|
||||||
|
if breakdown.shows_no_undo_row() {
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"No-undo bonus",
|
||||||
|
format!("+{}", breakdown.no_undo_bonus),
|
||||||
|
STATE_SUCCESS,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Mode multiplier (only when not 1.0).
|
||||||
|
if breakdown.shows_multiplier_row() {
|
||||||
|
let mode_name = mode_display_name(breakdown.mode);
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
&format!("Mode multiplier ({mode_name} ×{:.1})", breakdown.multiplier),
|
||||||
|
format!("×{:.1}", breakdown.multiplier),
|
||||||
|
STATE_INFO,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Separator — em-dashes spanning the visual width.
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"─────────────────",
|
||||||
|
"─────".to_string(),
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
row_index += 1;
|
||||||
|
|
||||||
|
// 6. Total — emphasised in primary accent.
|
||||||
|
spawn_breakdown_row(
|
||||||
|
card,
|
||||||
|
"Total",
|
||||||
|
format!("{}", breakdown.total()),
|
||||||
|
ACCENT_PRIMARY,
|
||||||
|
anim_speed,
|
||||||
|
stagger * row_index as f32,
|
||||||
|
fade,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns one row of the score breakdown — a flex-row `Node` with two
|
||||||
|
/// `Text` children (label left, value right). The row is tagged with
|
||||||
|
/// [`ScoreBreakdownRow`] and starts hidden when `anim_speed` is anything
|
||||||
|
/// other than [`AnimSpeed::Instant`]; the [`reveal_score_breakdown`]
|
||||||
|
/// system flips it visible after `delay_secs` and fades in the text
|
||||||
|
/// over `fade_duration_secs`.
|
||||||
|
fn spawn_breakdown_row(
|
||||||
|
card: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
value: String,
|
||||||
|
value_color: Color,
|
||||||
|
anim_speed: AnimSpeed,
|
||||||
|
delay_secs: f32,
|
||||||
|
fade_duration_secs: f32,
|
||||||
|
) {
|
||||||
|
// Under Instant, every row is visible immediately at full opacity.
|
||||||
|
let instant = matches!(anim_speed, AnimSpeed::Instant);
|
||||||
|
let initial_visibility = if instant {
|
||||||
|
Visibility::Inherited
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
};
|
||||||
|
let initial_alpha = if instant { 1.0 } else { 0.0 };
|
||||||
|
|
||||||
|
let label_color_with_alpha = TEXT_PRIMARY.with_alpha(initial_alpha);
|
||||||
|
let value_color_with_alpha = value_color.with_alpha(initial_alpha);
|
||||||
|
|
||||||
|
card.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs,
|
||||||
|
revealed: instant,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
min_width: Val::Px(280.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::SpaceBetween,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
initial_visibility,
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
// Label — left-aligned.
|
||||||
|
row.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
TextFont { font_size: TYPE_BODY, ..default() },
|
||||||
|
TextColor(label_color_with_alpha),
|
||||||
|
));
|
||||||
|
// Value — right-aligned via the parent's JustifyContent::SpaceBetween.
|
||||||
|
row.spawn((
|
||||||
|
Text::new(value),
|
||||||
|
TextFont { font_size: TYPE_BODY, ..default() },
|
||||||
|
TextColor(value_color_with_alpha),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reveal system — ticks each [`ScoreBreakdownRow`] down toward zero
|
||||||
|
/// and fades its child `Text` alpha from 0 → 1 over the row's
|
||||||
|
/// `fade_duration_secs` once `delay_secs` elapses.
|
||||||
|
///
|
||||||
|
/// The system is non-blocking: the Play Again button is interactable
|
||||||
|
/// from the moment the modal spawns; the breakdown reveal just plays
|
||||||
|
/// out underneath. Rows that have already reached full opacity are
|
||||||
|
/// skipped via the `revealed` flag plus an early
|
||||||
|
/// `fade_elapsed >= fade_duration` short-circuit on the alpha lerp.
|
||||||
|
pub fn reveal_score_breakdown(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut rows: Query<(&mut ScoreBreakdownRow, &mut Visibility, Option<&Children>)>,
|
||||||
|
mut texts: Query<&mut TextColor>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (mut row, mut visibility, children) in &mut rows {
|
||||||
|
if !row.revealed {
|
||||||
|
row.delay_secs -= dt;
|
||||||
|
if row.delay_secs <= 0.0 {
|
||||||
|
*visibility = Visibility::Inherited;
|
||||||
|
row.revealed = true;
|
||||||
|
} else {
|
||||||
|
continue; // still hidden, no fade work yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Row is revealed — drive the fade-in until it's fully opaque.
|
||||||
|
let fade_done = row.fade_elapsed_secs >= row.fade_duration_secs;
|
||||||
|
if !fade_done {
|
||||||
|
row.fade_elapsed_secs += dt;
|
||||||
|
}
|
||||||
|
let t = if row.fade_duration_secs <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(row.fade_elapsed_secs / row.fade_duration_secs).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let target_alpha = if fade_done { 1.0 } else { t };
|
||||||
|
if let Some(children) = children {
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Ok(mut tc) = texts.get_mut(child) {
|
||||||
|
let c = tc.0;
|
||||||
|
if (c.alpha() - target_alpha).abs() > f32::EPSILON {
|
||||||
|
tc.0 = c.with_alpha(target_alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -662,6 +1075,8 @@ mod tests {
|
|||||||
assert!(p.xp_detail.is_empty());
|
assert!(p.xp_detail.is_empty());
|
||||||
assert!(!p.new_record);
|
assert!(!p.new_record);
|
||||||
assert!(p.challenge_level.is_none());
|
assert!(p.challenge_level.is_none());
|
||||||
|
assert_eq!(p.undo_count, 0);
|
||||||
|
assert_eq!(p.mode, GameMode::Classic);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -941,4 +1356,208 @@ mod tests {
|
|||||||
"challenge_level must be None for non-Challenge wins"
|
"challenge_level must be None for non-Challenge wins"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Score-breakdown tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `cache_win_data` captures both `undo_count` and `mode` from the
|
||||||
|
/// `GameStateResource` at the moment of `GameWonEvent`. The breakdown
|
||||||
|
/// reveal needs both fields to format the no-undo-bonus and
|
||||||
|
/// mode-multiplier rows.
|
||||||
|
#[test]
|
||||||
|
fn cache_win_data_captures_undo_count_and_mode() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
|
||||||
|
let mut app = make_app();
|
||||||
|
// Set up a Zen-mode game with 2 undos used.
|
||||||
|
{
|
||||||
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
|
game.0 = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Zen);
|
||||||
|
game.0.undo_count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
|
assert_eq!(pending.undo_count, 2);
|
||||||
|
assert_eq!(pending.mode, GameMode::Zen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ScoreBreakdown::compute` produces the expected per-component
|
||||||
|
/// values for a non-trivial Classic-mode win. Time-bonus is the
|
||||||
|
/// canonical `compute_time_bonus(120) = 5833` (700_000 / 120) and
|
||||||
|
/// the no-undo bonus fires because `undo_count == 0`.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_compute_produces_expected_components() {
|
||||||
|
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
||||||
|
assert_eq!(bd.base, 3200);
|
||||||
|
assert_eq!(bd.time_bonus, 5833); // 700_000 / 120
|
||||||
|
assert_eq!(bd.no_undo_bonus, SCORE_NO_UNDO_BONUS);
|
||||||
|
assert!((bd.multiplier - 1.0).abs() < f32::EPSILON);
|
||||||
|
// Classic ×1.0 → multiplier row is suppressed.
|
||||||
|
assert!(!bd.shows_multiplier_row());
|
||||||
|
// Total == base + time_bonus + no_undo_bonus.
|
||||||
|
assert_eq!(bd.total(), 3200 + 5833 + SCORE_NO_UNDO_BONUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zen-mode wins produce a zero multiplier — the breakdown shows
|
||||||
|
/// the multiplier row and the total collapses to zero regardless
|
||||||
|
/// of the other components.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_zen_mode_zeros_total() {
|
||||||
|
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen);
|
||||||
|
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
|
||||||
|
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
|
||||||
|
assert_eq!(bd.total(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the player used undo, the `no_undo_bonus` is zero and the
|
||||||
|
/// row is suppressed.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_skips_no_undo_row_when_undo_was_used() {
|
||||||
|
let bd = ScoreBreakdown::compute(100, 60, 1, GameMode::Classic);
|
||||||
|
assert_eq!(bd.no_undo_bonus, 0);
|
||||||
|
assert!(!bd.shows_no_undo_row());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At `time_seconds == 0` the time-bonus formula yields 0; the row
|
||||||
|
/// is suppressed.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_skips_time_bonus_row_when_zero() {
|
||||||
|
let bd = ScoreBreakdown::compute(100, 0, 0, GameMode::Classic);
|
||||||
|
assert_eq!(bd.time_bonus, 0);
|
||||||
|
assert!(!bd.shows_time_bonus_row());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `row_count()` reports the number of rows the renderer will
|
||||||
|
/// spawn. A non-trivial Classic win with both bonuses produces:
|
||||||
|
/// base + time + no-undo + separator + total = 5 rows (no
|
||||||
|
/// multiplier row, ×1.0 is suppressed).
|
||||||
|
#[test]
|
||||||
|
fn win_modal_score_breakdown_spawns_one_row_per_component() {
|
||||||
|
let bd = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
||||||
|
assert_eq!(
|
||||||
|
bd.row_count(),
|
||||||
|
5,
|
||||||
|
"Classic with both bonuses: base + time + no-undo + sep + total"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zen with both bonuses ALSO shows the multiplier row.
|
||||||
|
let zen = ScoreBreakdown::compute(3200, 120, 0, GameMode::Zen);
|
||||||
|
assert_eq!(
|
||||||
|
zen.row_count(),
|
||||||
|
6,
|
||||||
|
"Zen with both bonuses: base + time + no-undo + multiplier + sep + total"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `no_undo_bonus == 0`, the row count drops by one.
|
||||||
|
#[test]
|
||||||
|
fn win_modal_score_breakdown_skips_zero_bonus_rows() {
|
||||||
|
let bd_with = ScoreBreakdown::compute(3200, 120, 0, GameMode::Classic);
|
||||||
|
let bd_without = ScoreBreakdown::compute(3200, 120, 1, GameMode::Classic);
|
||||||
|
assert_eq!(
|
||||||
|
bd_with.row_count() - 1,
|
||||||
|
bd_without.row_count(),
|
||||||
|
"removing the no-undo bonus must remove exactly one row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper test: the reveal logic uses delta-time to count
|
||||||
|
/// down `delay_secs`; at `t = 0` only the first row is "revealed",
|
||||||
|
/// and after one stagger interval the second row reveals as well.
|
||||||
|
/// We exercise the system directly on a hand-built world rather
|
||||||
|
/// than going through the full modal-spawn path so the test is
|
||||||
|
/// independent of `Time` resource quirks.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_reveal_advances_visibility_per_stagger() {
|
||||||
|
use bevy::time::TimePlugin;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>());
|
||||||
|
app.init_resource::<Time>();
|
||||||
|
app.add_systems(Update, reveal_score_breakdown);
|
||||||
|
|
||||||
|
// Spawn three rows with delays of 0.0, 0.15, and 0.30 s.
|
||||||
|
let stagger = MOTION_SCORE_BREAKDOWN_STAGGER_SECS;
|
||||||
|
let fade = MOTION_SCORE_BREAKDOWN_FADE_SECS;
|
||||||
|
let row0 = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs: 0.0,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs: fade,
|
||||||
|
revealed: false,
|
||||||
|
},
|
||||||
|
Visibility::Hidden,
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let row1 = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs: stagger,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs: fade,
|
||||||
|
revealed: false,
|
||||||
|
},
|
||||||
|
Visibility::Hidden,
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let row2 = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
ScoreBreakdownRow {
|
||||||
|
delay_secs: stagger * 2.0,
|
||||||
|
fade_elapsed_secs: 0.0,
|
||||||
|
fade_duration_secs: fade,
|
||||||
|
revealed: false,
|
||||||
|
},
|
||||||
|
Visibility::Hidden,
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
// Frame 1: `time.delta` is 0 (first frame), so only row0
|
||||||
|
// (delay = 0) should reveal.
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().entity(row0).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
assert!(!app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
|
||||||
|
// Advance time by one stagger interval — row1 should reveal.
|
||||||
|
{
|
||||||
|
let mut time = app.world_mut().resource_mut::<Time>();
|
||||||
|
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
|
||||||
|
// Advance again — row2 should reveal.
|
||||||
|
{
|
||||||
|
let mut time = app.world_mut().resource_mut::<Time>();
|
||||||
|
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under `AnimSpeed::Instant`, breakdown rows must spawn already
|
||||||
|
/// revealed and at full opacity — there should be no stagger
|
||||||
|
/// reveal animation at all.
|
||||||
|
#[test]
|
||||||
|
fn score_breakdown_instant_speed_skips_stagger() {
|
||||||
|
// Helper: simulate what `spawn_breakdown_row` constructs by
|
||||||
|
// checking the `instant` branch behaviour. Specifically: under
|
||||||
|
// Instant, scaled_duration → 0.0, so the row's stagger and
|
||||||
|
// fade are both zero.
|
||||||
|
let stagger = scaled_duration(MOTION_SCORE_BREAKDOWN_STAGGER_SECS, AnimSpeed::Instant);
|
||||||
|
let fade = scaled_duration(MOTION_SCORE_BREAKDOWN_FADE_SECS, AnimSpeed::Instant);
|
||||||
|
assert_eq!(stagger, 0.0);
|
||||||
|
assert_eq!(fade, 0.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user