feat(engine): add pulsing trailing cursor to splash "▌ ready_" line

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.
This commit is contained in:
funman300
2026-05-07 22:31:55 -07:00
parent ef54cdeb65
commit 29136d815d
+146 -5
View File
@@ -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<Virtual>` 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<f32> {
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<SplashRoot>>,
mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>,
) {
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
);
}
}