ba76936aba
Continues #118 after settings_plugin (PR #127): the 2,725-line mod.rs becomes five focused submodules along existing system boundaries — mod.rs 553 markers, resources, popover enums, plugin build spawn.rs 552 band / columns / avatar / action-bar construction interaction.rs 616 button handlers, Modes/Menu popovers, tap gesture fx.rs 430 action fades, score pulses/floaters, streak flourish updates.rs 612 HUD text / typography / visibility updaters Moved items are pub(super) (fx re-exported pub for HudActionFade and format_time_limit consumers). Code moved verbatim; no behaviour change; all 44 hud tests pass unchanged. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
431 lines
16 KiB
Rust
431 lines
16 KiB
Rust
//! HUD feedback effects: action-bar fades, score pulses and floaters,
|
|
//! and streak flourishes.
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
/// Auto-fade state for the action button bar. The bar fades out when
|
|
/// the cursor is in the play area (below the HUD band) and back in when
|
|
/// the cursor approaches the top of the window — same UX as a video
|
|
/// player's auto-hide controls. Buttons remain fully interactive when
|
|
/// visible; when faded out they're geometrically out of cursor reach
|
|
/// (hover requires the cursor to be on a button), so no extra
|
|
/// pointer-events guard is needed.
|
|
#[derive(Resource, Debug, Clone, Copy)]
|
|
pub struct HudActionFade {
|
|
/// Currently displayed alpha. Lerped toward `target` each frame.
|
|
pub alpha: f32,
|
|
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
|
|
pub target: f32,
|
|
}
|
|
|
|
impl Default for HudActionFade {
|
|
fn default() -> Self {
|
|
// Start visible so the player sees the controls on first launch
|
|
// before they've moved the cursor anywhere.
|
|
Self {
|
|
alpha: 1.0,
|
|
target: 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
|
|
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
|
|
/// cursor approaches, not only when it crosses into the band itself.
|
|
#[cfg(not(target_os = "android"))]
|
|
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
|
|
|
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
|
/// transition — fast enough to feel responsive without flashing on
|
|
/// brief cursor wanders into the reveal zone.
|
|
#[cfg(not(target_os = "android"))]
|
|
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
|
|
|
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
|
/// the cursor is in the reveal zone (bottom of window) or off-screen
|
|
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
|
/// `target` at a fixed rate so the visual transition is smooth across
|
|
/// variable framerates.
|
|
#[cfg(not(target_os = "android"))]
|
|
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
|
|
let Ok(window) = windows.single() else {
|
|
return;
|
|
};
|
|
let height = window.resolution.height();
|
|
fade.target = match window.cursor_position() {
|
|
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
|
|
Some(_) => 0.0,
|
|
// Off-window cursor: assume keyboard navigation and keep the
|
|
// bar visible so Tab cycling doesn't lead to invisible focus.
|
|
None => 1.0,
|
|
};
|
|
|
|
let dt = time.delta_secs();
|
|
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
|
|
let diff = fade.target - fade.alpha;
|
|
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
|
|
}
|
|
|
|
/// Applies the current fade alpha to every action button's
|
|
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
|
|
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
|
/// same frame doesn't override the fade with an opaque idle / hover
|
|
/// colour.
|
|
#[cfg(not(target_os = "android"))]
|
|
#[allow(clippy::type_complexity)]
|
|
pub(super) fn apply_action_fade(
|
|
fade: Res<HudActionFade>,
|
|
// Excludes `PopoverRow` so the auto-fade only applies to the
|
|
// top-level action bar buttons. Popover rows live inside an
|
|
// explicitly-opened dropdown panel and need to stay visible
|
|
// regardless of the bar's fade state — without the exclusion
|
|
// the rows fade to invisible while the popover container stays
|
|
// visible, leaving a solid background block with no readable
|
|
// content.
|
|
mut buttons: Query<
|
|
(&Children, &mut BackgroundColor),
|
|
(With<ActionButton>, Without<PopoverRow>),
|
|
>,
|
|
mut text_q: Query<&mut TextColor>,
|
|
) {
|
|
for (children, mut bg) in &mut buttons {
|
|
let mut c = bg.0;
|
|
c.set_alpha(fade.alpha);
|
|
bg.0 = c;
|
|
for child in children.iter() {
|
|
if let Ok(mut tc) = text_q.get_mut(child) {
|
|
let mut cc = tc.0;
|
|
cc.set_alpha(fade.alpha);
|
|
tc.0 = cc;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Visual feedback for every action button — paints idle / hover / pressed
|
|
/// states by mutating `BackgroundColor` whenever the interaction state
|
|
/// changes. One query covers all action buttons via the shared
|
|
/// `ActionButton` marker.
|
|
#[allow(clippy::type_complexity)]
|
|
pub(super) fn paint_action_buttons(
|
|
mut buttons: Query<
|
|
(&Interaction, &mut BackgroundColor),
|
|
(With<ActionButton>, Changed<Interaction>),
|
|
>,
|
|
) {
|
|
for (interaction, mut bg) in &mut buttons {
|
|
bg.0 = match interaction {
|
|
Interaction::Pressed => ACTION_BTN_PRESSED,
|
|
Interaction::Hovered => ACTION_BTN_HOVER,
|
|
Interaction::None => ACTION_BTN_IDLE,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
pub(super) 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`).
|
|
pub(super) fn detect_score_change(
|
|
game: Res<GameStateResource>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
mut prev: ResMut<PreviousScore>,
|
|
font_res: Option<Res<FontResource>>,
|
|
score_q: Query<Entity, With<HudScore>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let current = game.0.score();
|
|
let delta = current - prev.0;
|
|
prev.0 = current;
|
|
if delta <= 0 {
|
|
return;
|
|
}
|
|
|
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
|
if reduce_motion {
|
|
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_TOP),
|
|
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.
|
|
pub(super) fn advance_score_pulse(
|
|
time: Res<Time>,
|
|
mut commands: Commands,
|
|
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
|
|
) {
|
|
let dt = time.delta_secs();
|
|
for (entity, mut pulse, mut transform) in &mut q {
|
|
let t = if pulse.duration <= 0.0 {
|
|
1.0
|
|
} else {
|
|
pulse.elapsed += dt;
|
|
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
|
|
};
|
|
let scale = score_pulse_scale(t);
|
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
|
if t >= 1.0 {
|
|
transform.scale = Vec3::ONE;
|
|
commands.entity(entity).remove::<ScorePulse>();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
|
|
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
|
|
/// its lifetime. Despawns the entity once `elapsed >= duration`.
|
|
pub(super) fn advance_score_floater(
|
|
time: Res<Time>,
|
|
mut commands: Commands,
|
|
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
|
|
) {
|
|
let dt = time.delta_secs();
|
|
for (entity, mut floater, mut node, mut color) in &mut nodes {
|
|
let t = if floater.duration <= 0.0 {
|
|
1.0
|
|
} else {
|
|
floater.elapsed += dt;
|
|
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
|
|
};
|
|
// Drift upward: top decreases as t grows. Starting top=0 keeps
|
|
// the floater on the score line; ending at -FLOATER_DRIFT_PX
|
|
// pulls it up off the readout.
|
|
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
|
|
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
|
|
let mut c = ACCENT_PRIMARY;
|
|
c.set_alpha(1.0 - t);
|
|
color.0 = c;
|
|
if t >= 1.0 {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
pub(super) 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;
|
|
};
|
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
|
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.
|
|
pub(super) 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).
|
|
pub(super) 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,
|
|
)
|
|
}
|
|
|