From 9887343d8b7826cae5232a0a2915c50f257390de Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 2 May 2026 02:40:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20focus=20ring=20breathes=20at=20?= =?UTF-8?q?1.4=20s=20=E2=80=94=20gentle=20pulse=20instead=20of=20flat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard focus ring rendered as a static yellow outline. A new pulse_focus_overlay system modulates the overlay's BorderColor alpha with a sin curve over MOTION_FOCUS_PULSE_SECS (1.4 s), breathing the visible alpha between 0.65× and 1.0× of FOCUS_RING's native value. The motion is slow enough to read as a calm heartbeat in peripheral vision rather than a competing animation, and a focus change still draws the eye because the ring re-attaches at full brightness on the next pulse cycle. The pulse honours AnimSpeed::Instant by reading SettingsResource and skipping the modulation entirely (static FOCUS_RING colour) for reduced-motion users — matches the convention used elsewhere for animation gating. A pure focus_ring_pulse_factor(elapsed_secs) helper is unit-tested for the curve shape: 0.825 at t=0 (mid-point), 1.0 at the quarter-period peak, 0.65 at the three-quarter-period trough, and a sweep across two full periods stays within the [0.65, 1.0] range. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/ui_focus.rs | 85 +++++++++++++++++++++++++++++++- solitaire_engine/src/ui_theme.rs | 13 +++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/solitaire_engine/src/ui_focus.rs b/solitaire_engine/src/ui_focus.rs index eaaa341..00de3f0 100644 --- a/solitaire_engine/src/ui_focus.rs +++ b/solitaire_engine/src/ui_focus.rs @@ -41,13 +41,17 @@ //! here no-ops so [`crate::selection_plugin`]'s Tab/Enter //! card-selection still works. +use std::f32::consts::TAU; + use bevy::ecs::query::Has; use bevy::input::ButtonInput; use bevy::prelude::*; use bevy::ui::{ComputedNode, UiGlobalTransform}; +use solitaire_data::AnimSpeed; +use crate::settings_plugin::SettingsResource; use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim}; -use crate::ui_theme::{FOCUS_RING, RADIUS_MD, Z_FOCUS_RING}; +use crate::ui_theme::{FOCUS_RING, MOTION_FOCUS_PULSE_SECS, RADIUS_MD, Z_FOCUS_RING}; // --------------------------------------------------------------------------- // Public component / resource API @@ -126,12 +130,57 @@ impl Plugin for UiFocusPlugin { clear_hud_focus_on_unhover, handle_focus_keys, update_focus_overlay, + pulse_focus_overlay, ) .chain(), ); } } +/// Computes the focus-ring breathing factor for a given elapsed time. +/// +/// Returns a value in `[0.65, 1.0]` following a sin curve over +/// [`MOTION_FOCUS_PULSE_SECS`]. Multiply [`FOCUS_RING`]'s native alpha by +/// this factor each frame to produce the breathing effect. +/// +/// Pure helper so the curve can be unit-tested without a Bevy app. +pub fn focus_ring_pulse_factor(elapsed_secs: f32) -> f32 { + let phase = (elapsed_secs * TAU / MOTION_FOCUS_PULSE_SECS).sin(); + // 0.825 mid-point ± 0.175 amplitude → range [0.65, 1.0]. Multiplicative + // factor against FOCUS_RING's static alpha so the brightest tick is + // exactly the original colour, not a brighter one. + 0.825 + 0.175 * phase +} + +/// Modulates the focus overlay's border alpha with a slow sin-curve +/// breathing pulse so the indicator catches the eye without competing +/// with gameplay motion. Skipped under `AnimSpeed::Instant` — the static +/// border colour is restored so reduced-motion users see no animation. +fn pulse_focus_overlay( + time: Res