From dcfa976dad6f2cfe8d22d32c49c2457bb362303c Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 30 Apr 2026 20:05:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20score=20change=20feedback=20?= =?UTF-8?q?=E2=80=94=20pulse=20and=20floating=20delta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Score readouts now react to mutations: ScorePulse drives a triangular 1.0 → 1.1 → 1.0 scale on the HUD score over MOTION_SCORE_PULSE_SECS, and jumps of at least SCORE_FLOATER_THRESHOLD points spawn a floating "+N" that drifts up 40px and fades over 2× the pulse duration before despawning. Detection runs after GameMutation so the visuals trail the state update by exactly one frame. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/hud_plugin.rs | 336 ++++++++++++++++++++++++++++- 1 file changed, 332 insertions(+), 4 deletions(-) diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 1804119..115d744 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -15,11 +15,12 @@ use crate::auto_complete_plugin::AutoCompleteState; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; use crate::progress_plugin::ProgressResource; +use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ - ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, - BORDER_SUBTLE, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, - TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, - VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, + scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, + BG_ELEVATED_PRESSED, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, RADIUS_MD, RADIUS_SM, + STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY, TEXT_SECONDARY, + TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, }; use crate::events::{ HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, @@ -98,6 +99,46 @@ pub struct HudDrawCycle; #[derive(Component, Debug)] pub struct HudSelection; +/// Drives the score-readout pulse: scales the [`HudScore`] text from +/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by +/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score +/// entity whenever the score increases; removed once `elapsed >= +/// duration`. +#[derive(Component, Debug, Clone, Copy)] +pub struct ScorePulse { + /// Seconds elapsed since the pulse started. + pub elapsed: f32, + /// Total duration. Zero under `AnimSpeed::Instant` — the system + /// snaps the scale back to 1.0 on first tick so no half-state + /// is ever shown. + pub duration: f32, +} + +/// Marker on a transient floating "+N" text spawned next to the score +/// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more. +/// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`, +/// then despawns. Kept rare/meaningful by the threshold gate. +#[derive(Component, Debug, Clone, Copy)] +pub struct ScoreFloater { + /// Seconds elapsed since the floater spawned. + pub elapsed: f32, + /// Total lifetime. Zero under `AnimSpeed::Instant` — the system + /// despawns it on first tick. + pub duration: f32, +} + +/// Tracks the score from the previous frame so the HUD can detect +/// changes without a `ScoreChangedEvent`. The plugin wires this to the +/// pulse + floater systems on every `Update`. +#[derive(Resource, Debug, Default, Clone, Copy)] +pub struct PreviousScore(pub i32); + +/// Score increase (in points) below which no floating "+N" is spawned. +/// 50 keeps the feedback for foundation drops and tableau-to-foundation +/// promotions; single-card placements (which can earn as little as +5) +/// stay quiet so the floater feels like a reward instead of noise. +pub const SCORE_FLOATER_THRESHOLD: i32 = 50; + /// Marker shared by every clickable HUD action button so a single /// `paint_action_buttons` system can recolour them on hover/press without /// each button needing its own paint handler. @@ -207,10 +248,21 @@ impl Plugin for HudPlugin { .add_message::() .add_message::() .add_message::() + .init_resource::() .add_systems(Startup, (spawn_hud, spawn_action_buttons)) .add_systems(Update, update_hud.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, update_selection_hud) + .add_systems( + Update, + ( + detect_score_change, + advance_score_pulse, + advance_score_floater, + ) + .chain() + .after(GameMutation), + ) .add_systems( Update, ( @@ -796,6 +848,179 @@ pub fn format_time_limit(secs: u64) -> String { format!("{m}:{s:02}") } +// --------------------------------------------------------------------------- +// Score-change feedback (G2) +// +// The flow for each Update tick: +// 1. `detect_score_change` diffs `GameStateResource.score` against +// `PreviousScore`. On any positive delta it inserts/refreshes +// `ScorePulse` on the score readout; on a delta ≥ +// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text +// anchored just below the score. +// 2. `advance_score_pulse` ticks the pulse component, applies the +// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the +// component on completion. +// 3. `advance_score_floater` drifts each floater upward, fades it to +// transparent, and despawns it when its lifetime expires. +// +// The threshold of 50 (a foundation promotion's typical bonus) keeps +// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`. +// --------------------------------------------------------------------------- + +/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure +/// function so the test suite can assert on the curve directly +/// without spinning up a Bevy app. +/// +/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`, +/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and +/// ends at 1.1 instead of 1.0. The corrected form below preserves the +/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous +/// triangle peaking at 1.1. +fn score_pulse_scale(t: f32) -> f32 { + let clamped = t.clamp(0.0, 1.0); + if clamped < 0.5 { + 1.0 + 0.2 * clamped + } else { + 1.1 - 0.2 * (clamped - 0.5) + } +} + +/// Vertical pixels the floating "+N" drifts up over its lifetime. +const FLOATER_DRIFT_PX: f32 = 40.0; + +/// Diffs the current `GameStateResource.score` against +/// [`PreviousScore`]. On a positive delta: +/// +/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity +/// so the readout pulses 1.0 → 1.1 → 1.0. +/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating +/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score +/// readout (see the doc comment on [`ScoreFloater`] for why this is a +/// UI Node rather than a `Text2d`). +fn detect_score_change( + game: Res, + settings: Option>, + mut prev: ResMut, + font_res: Option>, + score_q: Query>, + mut commands: Commands, +) { + let current = game.0.score; + let delta = current - prev.0; + prev.0 = current; + if delta <= 0 { + return; + } + + let speed = settings + .as_ref() + .map(|s| s.0.animation_speed) + .unwrap_or_default(); + let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed); + let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed); + + // Refresh ScorePulse on every score readout entity (in practice + // there's exactly one, but iterating is cheaper than asserting). + for entity in &score_q { + commands.entity(entity).insert(ScorePulse { + elapsed: 0.0, + duration: pulse_secs, + }); + } + + if delta < SCORE_FLOATER_THRESHOLD { + return; + } + + let font = TextFont { + font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_BODY_LG, + ..default() + }; + // Spawned as an absolutely-positioned UI Node so the floater rides + // the same screen-coordinate system as the score readout. Using a + // `Text2d` here would require translating UI layout coordinates to + // world space every frame; a UI node piggybacks on the same + // anchoring `update_hud` already uses for the score and stays + // testable under `MinimalPlugins`. + commands.spawn(( + ScoreFloater { + elapsed: 0.0, + duration: floater_secs, + }, + Node { + position_type: PositionType::Absolute, + // Anchored next to the HUD column; matches the + // `spawn_hud` left/top offsets so the floater appears + // overlaid on the score line and drifts up from there. + left: VAL_SPACE_3, + top: Val::Px(0.0), + ..default() + }, + ZIndex(Z_HUD + 10), + Text::new(format!("+{delta}")), + font, + TextColor(ACCENT_PRIMARY), + )); +} + +/// Advances every [`ScorePulse`], scaling its entity's `Transform` +/// using [`score_pulse_scale`]. 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 so no float drift survives. +fn advance_score_pulse( + time: Res