From 29136d815d1e16270b6b1c3515156d719df89a1f Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 7 May 2026 22:31:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20add=20pulsing=20trailing=20curs?= =?UTF-8?q?or=20to=20splash=20"=E2=96=8C=20ready=5F"=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cursor-pulse half of the splash polish arc deferred in cacb19c. The "▌ ready_" boot-log line now ends with a 6×12 px cyan Node that pulses on a 1 s sine cadence — matching the mockup at docs/ui-mockups/splash-mobile.html. The pulse alpha is multiplied with the global splash fade timeline rather than fighting it: the cursor can't reach full alpha while the rest of the splash is still fading in, and it fades out cleanly with everything else. Implementation: - New SplashCursorPulse marker on the trailing Node. Carries SplashFadableBg too so it picks up the global fade for free; the pulse system overwrites the per-tick BackgroundColor afterward (last writer wins, both values are commensurate so the override is correct, not a fight). - New pulse_splash_cursor system, scheduled .chain()'d AFTER advance_splash so the pulse multiplication is the final write. No-op when no SplashRoot exists (post-despawn or under a test fixture without one). - New pure helper cursor_pulse_factor(age, period, min) returns a sine-driven multiplier in [min..1.0]. Defensive zero/negative period guard returns 1.0 so a misconfiguration produces a steady cursor instead of a divide-by-zero NaN. - Two splash-local consts: MOTION_PULSE_PERIOD_SECS = 1.0 (terminal- blink cadence) and PULSE_ALPHA_MIN = 0.4 (the cursor never fully extinguishes — matches a real terminal's blink that dips but stays visible). Used Node-with-explicit-dimensions rather than a `█` text glyph so the 6×12 px size doesn't drift with line font; the leading `▌` glyph stays a character (textual) while the trailing pulse is a Node (geometric) — different primitives for different intents. One new test (1182 → 1183): cursor_pulse_factor_corners pins the peak (factor = 1 at age = period/4), trough (factor = min at age = period * 3/4), and the defensive zero/negative-period guard. Scanline overlay (the other half of cacb19c's skipped polish) remains open — separate commit. --- solitaire_engine/src/splash_plugin.rs | 151 +++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/solitaire_engine/src/splash_plugin.rs b/solitaire_engine/src/splash_plugin.rs index a3a0938..a685d6e 100644 --- a/solitaire_engine/src/splash_plugin.rs +++ b/solitaire_engine/src/splash_plugin.rs @@ -45,6 +45,14 @@ //! progress-bar caption, palette label, eight palette swatches, //! version line). //! +//! The trailing "▌ ready_" cursor pulse layers on top of the fade +//! by carrying both [`SplashFadableBg`] and [`SplashCursorPulse`]: +//! [`pulse_splash_cursor`] runs after [`advance_splash`] in the +//! schedule chain and overwrites the cursor's `BackgroundColor` +//! with `global_alpha × pulse_factor`. Multiplying keeps the pulse +//! visually anchored to the global timeline — no fight, just a +//! modulated signal on top of the master volume. +//! //! ## Headless tests //! //! Under `MinimalPlugins + SplashPlugin`, the `Time` clock @@ -87,11 +95,28 @@ impl Plugin for SplashPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, spawn_splash).add_systems( Update, - (dismiss_splash_on_input, advance_splash).chain(), + ( + dismiss_splash_on_input, + advance_splash, + pulse_splash_cursor, + ) + .chain(), ); } } +/// Period of the trailing "▌ ready_" pulse cursor, in seconds. ~1 s +/// reads as a comfortable terminal-blink cadence — much faster reads +/// as urgent (alarming on a hold-and-fade screen), much slower reads +/// as listless. Held as a `const` rather than a token because it's +/// splash-local: no other surface pulses on this rhythm. +const MOTION_PULSE_PERIOD_SECS: f32 = 1.0; + +/// Floor for the pulse alpha multiplier. The cursor never extinguishes +/// fully — matches a real terminal blink that dips but stays visible +/// so the player keeps a stable focal point. +const PULSE_ALPHA_MIN: f32 = 0.4; + // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- @@ -128,6 +153,18 @@ struct SplashFadableBg { base_color: Color, } +/// Marks the trailing pulse cursor on the "▌ ready_" line. Carries +/// `SplashFadableBg` too so it picks up the global fade-in / hold / +/// fade-out timeline; [`pulse_splash_cursor`] runs *after* +/// [`advance_splash`] in the chain and overwrites the +/// `BackgroundColor` with the global alpha multiplied by a +/// sine-driven pulse factor in `[PULSE_ALPHA_MIN..1.0]`. Multiplying +/// (rather than the pulse system being the only writer) keeps the +/// cursor visually anchored to the global timeline — it can't pulse +/// at full alpha while the rest of the splash is still fading in. +#[derive(Component, Debug)] +struct SplashCursorPulse; + // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- @@ -331,10 +368,13 @@ fn spawn_check_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont, labe } /// "▌ ready_" line — visual signature of "boot complete, awaiting -/// input". Static; no pulse animation in this commit (a pulse would -/// fight the global fade timeline). The cursor glyph picks up -/// `TEXT_PRIMARY` rather than `ACCENT_PRIMARY` so it doesn't compete -/// with the big cyan cursor in the header. +/// input". The leading `▌` glyph picks up `TEXT_PRIMARY` rather than +/// `ACCENT_PRIMARY` so it doesn't compete with the big cyan cursor in +/// the header; the *trailing* 6×12 px cyan pulse Node ([`SplashCursorPulse`]) +/// is what carries the "alive, blinking" signal called for by the +/// mockup. The pulse's alpha is multiplied with the global fade +/// timeline by [`pulse_splash_cursor`] so it never fights the +/// fade-in / hold / fade-out flow. fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) { parent .spawn(Node { @@ -351,6 +391,21 @@ fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) { line_font.clone(), TextColor(transparent(TEXT_PRIMARY)), )); + // Trailing 6×12 cyan pulse cursor. Node-with-explicit- + // dimensions rather than a `█` text glyph so the size + // doesn't drift with the line font; matches the mockup's + // 6×12 px spec literally. Pulse animation lives in + // `pulse_splash_cursor` for testability. + row.spawn(( + SplashFadableBg { base_color: ACCENT_PRIMARY }, + SplashCursorPulse, + Node { + width: Val::Px(6.0), + height: Val::Px(12.0), + ..default() + }, + BackgroundColor(transparent(ACCENT_PRIMARY)), + )); }); } @@ -529,6 +584,50 @@ fn splash_alpha(age: Duration) -> Option { Some(((total - age_s) / fade).clamp(0.0, 1.0)) } +/// Pure helper — computes the pulse alpha multiplier for a given +/// `age`, `period`, and `min` floor. Sine-driven smoothing in +/// `[min..1.0]`. Returns `1.0` defensively when `period <= 0.0` so a +/// misconfigured caller produces a steady (unmodulated) cursor rather +/// than a divide-by-zero. +/// +/// The phase is `age * TAU / period`, which puts the first peak at +/// `age = period / 4` and the first trough at `age = period * 3 / 4` — +/// both verified by the tests below. +fn cursor_pulse_factor(age: Duration, period: f32, min: f32) -> f32 { + if period <= 0.0 { + return 1.0; + } + let phase = age.as_secs_f32() * std::f32::consts::TAU / period; + let normalised = (phase.sin() + 1.0) * 0.5; // map [-1, 1] → [0, 1] + min + normalised * (1.0 - min) +} + +/// Per-frame system that overwrites the trailing pulse cursor's +/// `BackgroundColor` with the global splash alpha multiplied by the +/// pulse factor. Runs *after* [`advance_splash`] in the chain so the +/// last writer wins — the cursor's tick output reflects both the +/// fade timeline and the pulse, while the rest of the splash gets +/// only the fade. +/// +/// No-op when no `SplashRoot` exists (the splash has already +/// despawned, or we're under a test fixture that doesn't spawn one). +fn pulse_splash_cursor( + roots: Query<&SplashAge, With>, + mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With>, +) { + let Some(age) = roots.iter().next() else { + return; + }; + let global = splash_alpha(age.0).unwrap_or(0.0); + let pulse = cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN); + let combined = (global * pulse).clamp(0.0, 1.0); + for (fadable, mut bg) in &mut pulses { + let mut c = fadable.base_color; + c.set_alpha(combined); + bg.0 = c; + } +} + /// Advances every splash root's age by `time.delta()` and updates the /// scrim plus every [`SplashFadable`] / [`SplashFadableBg`] alpha, /// despawning the splash once the timeline finishes. Despawns with @@ -943,4 +1042,46 @@ mod tests { "fadable text alphas should be at full alpha during the hold; got {mid_text_alphas:?}" ); } + + /// Pure-helper guard. The pulse factor is a sine wave shifted into + /// `[min..1.0]`. Three corner cases are pinned: + /// + /// * Phase peak (`age = period / 4`) → factor reaches 1.0. + /// * Phase trough (`age = period * 3 / 4`) → factor falls to `min`. + /// * Defensive: a zero or negative `period` short-circuits to 1.0 + /// so a misconfigured caller produces a steady cursor instead + /// of a divide-by-zero NaN. + #[test] + fn cursor_pulse_factor_corners() { + let period = 1.0_f32; + let min = 0.4_f32; + + // Peak — sin(TAU * 0.25) = 1 → normalised = 1 → factor = 1. + let peak = cursor_pulse_factor(Duration::from_secs_f32(period / 4.0), period, min); + assert!( + (peak - 1.0).abs() < 1e-5, + "peak should reach 1.0; got {peak}" + ); + + // Trough — sin(TAU * 0.75) = -1 → normalised = 0 → factor = min. + let trough = cursor_pulse_factor( + Duration::from_secs_f32(period * 3.0 / 4.0), + period, + min, + ); + assert!( + (trough - min).abs() < 1e-5, + "trough should fall to min ({min}); got {trough}" + ); + + // Defensive: zero / negative period must not divide-by-zero. + assert_eq!( + cursor_pulse_factor(Duration::from_secs_f32(0.5), 0.0, min), + 1.0 + ); + assert_eq!( + cursor_pulse_factor(Duration::from_secs_f32(0.5), -1.0, min), + 1.0 + ); + } }