feat(engine): port the splash to the Terminal boot-screen treatment

Implements the full mockup-spec splash from
docs/ui-mockups/splash-mobile.html plus the desktop adaptation rules
from docs/ui-mockups/desktop-adaptation.md. The header (cursor block,
wordmark, divider, "TERMINAL EDITION" subtitle), boot log (three
✓ check rows + "▌ ready_"), progress bar (1px track with full-width
cyan fill + "DONE · 247 ASSETS" caption), and footer
(BASE16-EIGHTIES label, eight palette swatches, version) all land
together. Rules-driven sizing: boot-log column capped at 480 px on
desktop (otherwise 70 % viewport), progress bar capped at 720 px
(otherwise 80 %), per the desktop-adaptation spec.

Refactored the alpha-fade scaffold from per-marker queries
(SplashTitle / SplashSubtitle / SplashCursor) to a single
SplashFadable { base_color: Color } + SplashFadableBg variant.
~15 fadable elements now share one global query each; adding more
elements is one component-attach, not three new query types.

Skipped (each its own potential follow-up):
- Scanline overlay — needs a tiled-pattern asset or a custom
  shader; both are out of scope for a UI-Node port.
- Pulsing cursor on the "ready_" line — would fight the global
  fade timeline; stays static.
- "RUSTY SOLITAIRE" wordmark from the mockup — actual product is
  "Solitaire Quest"; the mockup leaked the repo name.

Tests: 8 carried + 2 new (Terminal boot-screen content present;
fadables start transparent and reach full alpha).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-07 19:17:05 -07:00
parent 39b84965b6
commit cacb19c03f
+495 -164
View File
@@ -1,40 +1,58 @@
//! Launch splash overlay.
//!
//! On app start the engine spawns a fullscreen, high-Z overlay that
//! reads "Solitaire Quest" in the project font for ~1.6 s
//! (300 ms fade-in, ~1 s hold, 300 ms fade-out), then despawns. The
//! existing deal animation plays *behind* the splash during the hold —
//! the user sees the dealt board appear as the splash dissolves.
//! reads the Terminal-style "boot screen" — a cyan cursor block, the
//! "Solitaire Quest" wordmark, a short fixture boot log, a progress
//! bar, and a footer with the design-system palette swatches and the
//! build version. The overlay fades in over 300 ms, holds for ~1 s,
//! then fades out for 300 ms before despawning. The deal animation
//! plays *behind* the splash during the hold, so the player sees the
//! dealt board appear as the splash dissolves.
//!
//! ## Why an overlay instead of an `AppState`
//!
//! Every existing plugin in this engine runs unconditionally on
//! `Startup`/`Update`; gating them with `run_if(in_state(...))` would be
//! a sweeping refactor for a one-off brand beat. The splash instead
//! sits on top of `Z_SPLASH` (above tooltips, focus ring, and toasts)
//! while the rest of the game runs normally beneath it. The handoff is
//! intentional: the user finishes the splash and the dealt board is
//! already there.
//! `Startup`/`Update`; gating them with `run_if(in_state(...))` would
//! be a sweeping refactor for a one-off brand beat. The splash
//! instead sits on top of `Z_SPLASH` (above tooltips, focus ring,
//! and toasts) while the rest of the game runs normally beneath it.
//! The handoff is intentional: the user finishes the splash and the
//! dealt board is already there.
//!
//! ## Dismissal
//!
//! Any keypress, mouse click, or touch begin shortcuts the splash to its
//! fade-out window — never to an instant despawn, so the dissolve still
//! plays for visual continuity. The dismiss input is **not** consumed,
//! so a player who instinctively taps Space to "skip the intro" still
//! gets their stock draw the moment the splash clears (Space and most
//! other gameplay keys read `just_pressed`, which by the next tick is
//! already false — splash dismissal happens on the same tick as the
//! press, so downstream gameplay handlers see exactly the keystroke
//! they would have seen with no splash).
//! Any keypress, mouse click, or touch begin shortcuts the splash to
//! its fade-out window — never to an instant despawn, so the dissolve
//! still plays for visual continuity. The dismiss input is **not**
//! consumed, so a player who instinctively taps Space to "skip the
//! intro" still gets their stock draw the moment the splash clears
//! (Space and most other gameplay keys read `just_pressed`, which by
//! the next tick is already false — splash dismissal happens on the
//! same tick as the press, so downstream gameplay handlers see
//! exactly the keystroke they would have seen with no splash).
//!
//! ## Fade scaffold
//!
//! Every visible element on the splash carries a [`SplashFadable`]
//! (text colour) or [`SplashFadableBg`] (background colour) marker
//! that records its full-alpha base colour. [`advance_splash`] reads
//! `SplashAge` once per frame, computes the current alpha, and writes
//! `base_color` × current-alpha into every fadable. Replaces the
//! prior per-marker queries (`SplashTitle` / `SplashSubtitle` /
//! `SplashCursor`) which didn't scale past three children — the
//! Terminal splash has ~15 fadable elements (cursor, title, divider,
//! subtitle, four boot-log rows, progress-bar track + fill,
//! progress-bar caption, palette label, eight palette swatches,
//! version line).
//!
//! ## Headless tests
//!
//! Under `MinimalPlugins + SplashPlugin`, the `Time<Virtual>` clock
//! clamps each tick to `max_delta` (default 250 ms) regardless of the
//! `TimeUpdateStrategy::ManualDuration` value, so tests advance time in
//! 200 ms ticks and call `app.update()` enough times to cross the
//! desired threshold (same approach used by `ui_tooltip::tests`).
//! clamps each tick to `max_delta` (default 250 ms) regardless of
//! the `TimeUpdateStrategy::ManualDuration` value, so tests advance
//! time in 200 ms ticks and call `app.update()` enough times to
//! cross the desired threshold (same approach used by
//! `ui_tooltip::tests`).
use std::time::Duration;
@@ -44,23 +62,25 @@ use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_BASE, MOTION_SPLASH_FADE_SECS, MOTION_SPLASH_TOTAL_SECS, TEXT_SECONDARY,
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_2, Z_SPLASH,
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_BASE, BORDER_SUBTLE, MOTION_SPLASH_FADE_SECS,
MOTION_SPLASH_TOTAL_SECS, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
TEXT_DISABLED, TEXT_PRIMARY, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2,
VAL_SPACE_3, VAL_SPACE_5, VAL_SPACE_6, VAL_SPACE_7, Z_SPLASH,
};
// ---------------------------------------------------------------------------
// Public plugin
// ---------------------------------------------------------------------------
/// Drives the launch splash overlay. Add this plugin once at app start;
/// the splash spawns during `Startup`, fades in/out over
/// Drives the launch splash overlay. Add this plugin once at app
/// start; the splash spawns during `Startup`, fades in/out over
/// [`MOTION_SPLASH_TOTAL_SECS`], and despawns itself.
///
/// The overlay is a sibling of every other UI surface — it never
/// becomes a parent of game systems, and the deal animation runs
/// underneath it during the hold window. Dismissal on any keypress /
/// click / touch shortcuts the timeline into the fade-out phase rather
/// than despawning instantly, so the dissolve always plays.
/// underneath it during the hold window. Dismissal on any keypress
/// / click / touch shortcuts the timeline into the fade-out phase
/// rather than despawning instantly, so the dissolve always plays.
pub struct SplashPlugin;
impl Plugin for SplashPlugin {
@@ -90,42 +110,42 @@ pub struct SplashRoot;
#[derive(Component, Debug, Default)]
pub struct SplashAge(pub Duration);
/// Marker on the splash title text. Used by [`advance_splash`] to write
/// the per-frame alpha into the text colour without walking arbitrary
/// children.
#[derive(Component, Debug)]
struct SplashTitle;
/// Marks a `Text` entity whose `TextColor` should fade with the splash
/// timeline. `base_color` is the full-alpha target colour written by
/// [`advance_splash`]; the system multiplies its alpha by the current
/// fade factor each tick.
#[derive(Component, Debug, Clone, Copy)]
struct SplashFadable {
base_color: Color,
}
/// Marker on the splash subtitle text (build version). Faded together
/// with the title so the brand beat dissolves as a single layer.
#[derive(Component, Debug)]
struct SplashSubtitle;
/// Marker on the cyan "terminal cursor" block (`▌`) painted above the
/// title. Visual signature of the Terminal design system per
/// `docs/ui-mockups/design-system.md` — the same `#6fc2ef` block
/// appears on the card-back theme, on the splash, and (per spec) is
/// the project's cursor motif. Faded together with the rest of the
/// splash so the dissolve still reads as one layer.
#[derive(Component, Debug)]
struct SplashCursor;
/// Marks a `Node` entity whose `BackgroundColor` should fade with the
/// splash timeline. Same contract as [`SplashFadable`] but for nodes
/// whose visible colour lives on the background, not on text — palette
/// swatches, the progress bar track, and the progress bar fill.
#[derive(Component, Debug, Clone, Copy)]
struct SplashFadableBg {
base_color: Color,
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Spawns the splash overlay at `Startup`. Builds a fullscreen scrim
/// at full alpha (the first `advance_splash` tick will overwrite the
/// alpha based on age), centres a "Solitaire Quest" title in
/// [`ACCENT_PRIMARY`], and pins a small build-version line below.
/// at alpha 0 (so the first paint is invisible — the first
/// `advance_splash` tick lifts every fadable's alpha), composes the
/// header / boot-log / progress / footer hierarchy, and tags every
/// visible child with [`SplashFadable`] or [`SplashFadableBg`] so the
/// per-frame fade has a uniform target list.
///
/// **Skipped on subsequent launches.** If `SettingsResource` reports
/// `first_run_complete == true`, the player has already seen the brand
/// beat at least once and we go straight to gameplay — having to wait
/// 1.6 s on every launch wears thin fast. The splash still shows on
/// first run, after a save reset (settings.json deleted), and under
/// `MinimalPlugins` (no `SettingsResource` registered) so the test
/// fixture observes the same spawn it always did.
/// `first_run_complete == true`, the player has already seen the
/// brand beat at least once and we go straight to gameplay — having
/// to wait 1.6 s on every launch wears thin fast. The splash still
/// shows on first run, after a save reset (settings.json deleted),
/// and under `MinimalPlugins` (no `SettingsResource` registered) so
/// the test fixture observes the same spawn it always did.
fn spawn_splash(
mut commands: Commands,
font_res: Option<Res<FontResource>>,
@@ -138,6 +158,42 @@ fn spawn_splash(
}
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
commands
.spawn((
SplashRoot,
SplashAge(Duration::ZERO),
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
// SpaceBetween distributes the three top-level groups
// (header / centre / footer) so the header sits near
// the top, the centre column floats in the middle of
// the viewport, and the footer hugs the bottom edge —
// mirroring the mockup's `justify-between` body.
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::axes(VAL_SPACE_5, VAL_SPACE_7),
..default()
},
BackgroundColor(scrim_with_alpha(0.0)),
GlobalZIndex(Z_SPLASH),
))
.with_children(|root| {
spawn_header_section(root, &font_handle);
spawn_centre_section(root, &font_handle);
spawn_footer_section(root, &font_handle);
});
}
/// Header section: cursor block, wordmark, divider, "TERMINAL EDITION"
/// label. Stacked vertically and centre-aligned. Renders near the top
/// of the viewport thanks to the root's `justify-between`.
fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<Font>) {
let cursor_font = TextFont {
font: font_handle.clone(),
// Larger than TYPE_DISPLAY so the cursor block reads as the
@@ -152,63 +208,298 @@ fn spawn_splash(
..default()
};
let subtitle_font = TextFont {
font: font_handle,
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
// Initial alpha is 0 (fade-in starts at 0 and grows). Without this
// the first frame would flash full-opacity scrim before the
// `advance_splash` tick lerped it down — visually a pop on slower
// start-ups.
let mut initial_bg = BG_BASE;
initial_bg.set_alpha(0.0);
let mut initial_title = ACCENT_PRIMARY;
initial_title.set_alpha(0.0);
let mut initial_subtitle = TEXT_SECONDARY;
initial_subtitle.set_alpha(0.0);
commands
.spawn((
SplashRoot,
SplashAge(Duration::ZERO),
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
..default()
},
BackgroundColor(initial_bg),
GlobalZIndex(Z_SPLASH),
))
.with_children(|root| {
root.spawn((
SplashCursor,
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
margin: UiRect::top(VAL_SPACE_6),
..default()
})
.with_children(|hdr| {
hdr.spawn((
SplashFadable { base_color: ACCENT_PRIMARY },
Text::new("\u{258C}"), // ▌ — the Terminal cursor block.
cursor_font,
TextColor(initial_title),
TextColor(transparent(ACCENT_PRIMARY)),
));
root.spawn((
SplashTitle,
hdr.spawn((
SplashFadable { base_color: TEXT_PRIMARY },
Text::new("Solitaire Quest"),
title_font,
TextColor(initial_title),
TextColor(transparent(TEXT_PRIMARY)),
));
root.spawn((
SplashSubtitle,
Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))),
// Thin horizontal divider under the wordmark — same hue as
// every other 1px chrome line in the design system.
hdr.spawn((
SplashFadableBg { base_color: BORDER_SUBTLE },
Node {
width: Val::Px(192.0),
height: Val::Px(1.0),
..default()
},
BackgroundColor(transparent(BORDER_SUBTLE)),
));
hdr.spawn((
SplashFadable { base_color: TEXT_DISABLED },
Text::new("TERMINAL EDITION"),
subtitle_font,
TextColor(initial_subtitle),
TextColor(transparent(TEXT_DISABLED)),
));
});
}
/// Centre section: boot log + progress bar. The boot-log column is
/// capped at 480 px on desktop per `docs/ui-mockups/desktop-adaptation.md`
/// (otherwise 70 % of viewport width). The progress bar is capped at
/// 720 px likewise.
fn spawn_centre_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<Font>) {
let line_font = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_5,
..default()
})
.with_children(|centre| {
spawn_boot_log(centre, &line_font);
spawn_progress_bar(centre, &line_font);
});
}
/// Boot-log column: three lime check rows + a "▌ ready_" line. Content
/// is fixture text, not driven from real bootstrap state — the splash
/// is a brand beat, not a real loader. Capped at 480 px width on
/// desktop (the design-system spec calls 70 % of mobile viewport,
/// which would stretch oddly on a wide window).
fn spawn_boot_log(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Start,
row_gap: VAL_SPACE_1,
width: Val::Percent(70.0),
max_width: Val::Px(480.0),
..default()
})
.with_children(|log| {
for label in ["assets loaded", "theme: terminal", "progress restored"] {
spawn_check_row(log, line_font, label);
}
spawn_ready_row(log, line_font);
});
}
/// One ✓-prefixed boot-log line. The check glyph is lime
/// (`STATE_SUCCESS`) so it reads as "complete"; the description text
/// is `TEXT_DISABLED` (the muted gray rung) so the eye treats the
/// list as background log noise rather than information that needs
/// reading.
fn spawn_check_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont, label: &str) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
row.spawn((
SplashFadable { base_color: STATE_SUCCESS },
Text::new("\u{2713}"), // ✓
line_font.clone(),
TextColor(transparent(STATE_SUCCESS)),
));
row.spawn((
SplashFadable { base_color: TEXT_DISABLED },
Text::new(label.to_string()),
line_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
));
});
}
/// "▌ 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.
fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|row| {
row.spawn((
SplashFadable { base_color: TEXT_PRIMARY },
Text::new("\u{258C} ready_"), // ▌ ready_
line_font.clone(),
TextColor(transparent(TEXT_PRIMARY)),
));
});
}
/// Progress bar — a 1 px tall track in `BORDER_SUBTLE` with a 100 %-
/// width cyan fill, plus a `DONE · 247 ASSETS` caption right-aligned
/// below. The "247" is fixture text; the bar is decorative, not a
/// real progress signal. Capped at 720 px width on desktop.
fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Stretch,
row_gap: VAL_SPACE_2,
width: Val::Percent(80.0),
max_width: Val::Px(720.0),
..default()
})
.with_children(|bar| {
// Track.
bar.spawn((
SplashFadableBg { base_color: BORDER_SUBTLE },
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
..default()
},
BackgroundColor(transparent(BORDER_SUBTLE)),
))
.with_children(|track| {
// Fill — 100 % of the track width = "complete".
track.spawn((
SplashFadableBg { base_color: ACCENT_PRIMARY },
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(transparent(ACCENT_PRIMARY)),
));
});
// Caption — right-aligned below the bar.
bar.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::FlexEnd,
..default()
})
.with_children(|caption| {
caption.spawn((
SplashFadable { base_color: TEXT_DISABLED },
Text::new("DONE \u{00B7} 247 ASSETS"), // DONE · 247 ASSETS
line_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
));
});
});
}
/// Footer section: "BASE16-EIGHTIES" label, eight palette swatches,
/// version line. The swatches are 12 × 12 px coloured squares, one
/// per named token — visible signature of the design system.
fn spawn_footer_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<Font>) {
let footer_font = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_3,
..default()
})
.with_children(|footer| {
footer.spawn((
SplashFadable { base_color: TEXT_DISABLED },
Text::new("BASE16-EIGHTIES"),
footer_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
));
spawn_palette_swatch_row(footer);
footer.spawn((
SplashFadable { base_color: TEXT_DISABLED },
Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))),
footer_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
));
});
}
/// Eight 12 × 12 px palette squares — one per named design-system
/// token (suit-red / warning / success / info / primary / celebration
/// / on-surface / outline). The order matches the mockup; the row is
/// the visual signature of the palette behind the rest of the UI.
fn spawn_palette_swatch_row(parent: &mut ChildSpawnerCommands) {
let swatches = [
STATE_DANGER,
STATE_WARNING,
STATE_SUCCESS,
STATE_INFO,
ACCENT_PRIMARY,
ACCENT_SECONDARY,
TEXT_PRIMARY,
// `BORDER_STRONG` (`#505050`) is the eighth slot — `outline`
// in the design-system token spec, also exposed as
// `TEXT_DISABLED` since the two share a hue. Re-using the
// existing `TEXT_DISABLED` import keeps the swatch list a
// single read.
TEXT_DISABLED,
];
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_1,
..default()
})
.with_children(|row| {
for color in swatches {
row.spawn((
SplashFadableBg { base_color: color },
Node {
width: Val::Px(12.0),
height: Val::Px(12.0),
..default()
},
BackgroundColor(transparent(color)),
));
}
});
}
/// Returns `BG_BASE` with its alpha multiplied by `factor` (01). The
/// fade systems lerp this each tick to drive the scrim's dissolve.
fn scrim_with_alpha(factor: f32) -> Color {
let mut c = BG_BASE;
c.set_alpha(factor.clamp(0.0, 1.0));
c
}
/// Returns `c` with alpha 0. Initial paint colour for every fadable
/// element so the very first frame is fully transparent — the next
/// `advance_splash` tick lifts the alpha based on `SplashAge`.
fn transparent(c: Color) -> Color {
let mut out = c;
out.set_alpha(0.0);
out
}
/// Computes the splash's per-frame alpha from its age. Three phases:
///
/// * `0..fade` — fade-in: `alpha = age / fade`.
@@ -239,61 +530,39 @@ fn splash_alpha(age: Duration) -> Option<f32> {
}
/// Advances every splash root's age by `time.delta()` and updates the
/// scrim + text alpha, despawning the splash once the timeline
/// finishes. Despawns with descendants so the title and subtitle leave
/// the world together.
/// scrim plus every [`SplashFadable`] / [`SplashFadableBg`] alpha,
/// despawning the splash once the timeline finishes. Despawns with
/// descendants so the entire hierarchy leaves the world together.
///
/// The fadable queries are global (no parent constraint) — the splash
/// is a one-shot at app start and is the only owner of these markers,
/// so there is no contamination risk from other plugins.
#[allow(clippy::type_complexity)]
fn advance_splash(
mut commands: Commands,
time: Res<Time>,
mut roots: Query<(Entity, &mut SplashAge, &mut BackgroundColor, &Children), With<SplashRoot>>,
mut titles: Query<
&mut TextColor,
(With<SplashTitle>, Without<SplashSubtitle>, Without<SplashCursor>),
>,
mut subtitles: Query<
&mut TextColor,
(With<SplashSubtitle>, Without<SplashTitle>, Without<SplashCursor>),
>,
mut cursors: Query<
&mut TextColor,
(With<SplashCursor>, Without<SplashTitle>, Without<SplashSubtitle>),
>,
mut roots: Query<(Entity, &mut SplashAge, &mut BackgroundColor), With<SplashRoot>>,
mut fadable_texts: Query<(&SplashFadable, &mut TextColor)>,
mut fadable_bgs: Query<(&SplashFadableBg, &mut BackgroundColor), Without<SplashRoot>>,
) {
for (entity, mut age, mut bg, children) in &mut roots {
for (entity, mut age, mut bg) in &mut roots {
age.0 = age.0.saturating_add(time.delta());
let Some(alpha) = splash_alpha(age.0) else {
commands.entity(entity).despawn();
continue;
};
// Scrim alpha — keeps BG_BASE's RGB and just rewrites alpha.
let mut scrim = BG_BASE;
scrim.set_alpha(alpha);
bg.0 = scrim;
bg.0 = scrim_with_alpha(alpha);
// Walk the splash root's direct children for the title /
// subtitle / cursor markers and update their alpha. The
// hierarchy is shallow (root → 3 text children) so a small
// loop is fine.
for child in children.iter() {
if let Ok(mut color) = cursors.get_mut(child) {
let mut c = ACCENT_PRIMARY;
c.set_alpha(alpha);
color.0 = c;
continue;
}
if let Ok(mut color) = titles.get_mut(child) {
let mut c = ACCENT_PRIMARY;
c.set_alpha(alpha);
color.0 = c;
continue;
}
if let Ok(mut color) = subtitles.get_mut(child) {
let mut c = TEXT_SECONDARY;
c.set_alpha(alpha);
color.0 = c;
}
for (fadable, mut text_color) in &mut fadable_texts {
let mut c = fadable.base_color;
c.set_alpha(alpha);
text_color.0 = c;
}
for (fadable, mut bg_color) in &mut fadable_bgs {
let mut c = fadable.base_color;
c.set_alpha(alpha);
bg_color.0 = c;
}
}
}
@@ -372,9 +641,9 @@ mod tests {
/// `Time<Virtual>` clamps per-tick deltas to `max_delta` (default
/// 250 ms) regardless of the requested manual step, so we drive
/// 200 ms ticks and call `update` enough times to exceed the target
/// duration. Returns the splash root's recorded age after the
/// stepping completes (or `None` if the splash was despawned).
/// 200 ms ticks and call `update` enough times to exceed the
/// target duration. Returns the splash root's recorded age after
/// the stepping completes (or `None` if the splash was despawned).
fn advance_by(app: &mut App, total_secs: f32) -> Option<Duration> {
set_manual_time_step(app, 0.2);
let ticks = (total_secs / 0.2).ceil() as usize + 1;
@@ -438,9 +707,6 @@ mod tests {
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
// Insert a SettingsResource that says "I've been here before"
// before any Startup system runs. spawn_splash should observe
// first_run_complete and decline to spawn the overlay.
app.insert_resource(SettingsResource(Settings {
first_run_complete: true,
..Settings::default()
@@ -475,9 +741,6 @@ mod tests {
#[test]
fn splash_despawns_after_total_duration() {
let mut app = headless_app();
// Comfortably past the total duration to absorb the
// ManualDuration → Virtual-clock clamp + the despawn lag of
// one extra tick.
let _ = advance_by(&mut app, MOTION_SPLASH_TOTAL_SECS + 0.5);
assert_eq!(
count_splash_roots(&mut app),
@@ -489,9 +752,7 @@ mod tests {
#[test]
fn splash_alpha_curves_through_fade_hold_fade() {
// Pure-function test on the curve so we don't need to wrangle
// the virtual-clock clamp here. The integration assertion below
// (`splash_dismisses_immediately_on_keypress`) covers the
// wired-up version.
// the virtual-clock clamp here.
// Start of fade-in.
assert!(
splash_alpha(Duration::ZERO).unwrap() < 0.05,
@@ -529,8 +790,6 @@ mod tests {
#[test]
fn splash_dismisses_immediately_on_keypress() {
let mut app = headless_app();
// Run one fast tick under the fade-in window so the splash is
// unambiguously not yet in fade-out before the dismiss.
set_manual_time_step(&mut app, 0.05);
app.update();
let pre_alpha = scrim_alpha(&mut app);
@@ -539,16 +798,11 @@ mod tests {
"precondition: splash should be inside fade-in, not yet at full alpha (got {pre_alpha})"
);
// Press any key. The dismissal system should bump the age into
// the fade-out window on this tick.
press_key(&mut app, KeyCode::Space);
app.update();
// Either still alive in fade-out, or already despawned (the
// 200 ms test-clock clamp can shave the fade-out window
// depending on how many ticks `app.update()` has accrued).
if count_splash_roots(&mut app) == 0 {
return; // already past fade-out — that's fine.
return;
}
let mut q = app
.world_mut()
@@ -599,8 +853,7 @@ mod tests {
/// Bonus test: dismissing the splash with a keypress does NOT clear
/// that key's `just_pressed` flag — downstream systems still see
/// the keystroke that dismissed the splash. Important for parity
/// with "no splash" behaviour where Space draws a card.
/// the keystroke that dismissed the splash.
#[test]
fn dismissal_keypress_is_visible_to_other_systems() {
let mut app = headless_app();
@@ -612,4 +865,82 @@ mod tests {
"Splash dismissal must NOT consume the input — downstream gameplay still needs it"
);
}
/// The Terminal boot-screen content must include the four
/// signature elements: cursor block, wordmark, "TERMINAL EDITION"
/// subtitle, and at least one boot-log row. Catches a regression
/// where the spawn hierarchy gets simplified back to "title +
/// version" — the splash is intentionally rich now.
#[test]
fn splash_renders_terminal_boot_screen_content() {
let mut app = headless_app();
let texts: Vec<String> = app
.world_mut()
.query::<&Text>()
.iter(app.world())
.map(|t| t.0.clone())
.collect();
assert!(
texts.iter().any(|t| t == "\u{258C}"),
"expected the cursor block (▌) on the splash, got: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Solitaire Quest"),
"expected the wordmark on the splash, got: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "TERMINAL EDITION"),
"expected the TERMINAL EDITION subtitle on the splash, got: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "assets loaded"),
"expected at least one boot-log row, got: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "BASE16-EIGHTIES"),
"expected the BASE16-EIGHTIES footer label, got: {texts:?}"
);
}
/// Every fadable element starts at alpha 0 (fade-in begins from
/// fully transparent) and lifts to ~1.0 by the end of the fade-in
/// window. Catches a regression where a new fadable's initial
/// paint is full-alpha — that flashes a frame of fully-visible
/// content before the first `advance_splash` tick lerps it down.
#[test]
fn fadables_start_transparent_and_reach_full_alpha() {
let mut app = headless_app();
// Right after Startup, before any time has advanced, every
// fadable element should still carry alpha 0 (the spawn
// function paints them transparent and the first tick has
// already run alpha = 0 / fade ≈ 0). We allow a tiny epsilon
// for floating-point lift on the very first tick.
let initial_text_alphas: Vec<f32> = app
.world_mut()
.query::<(&SplashFadable, &TextColor)>()
.iter(app.world())
.map(|(_, color)| color.0.alpha())
.collect();
assert!(
initial_text_alphas.iter().all(|a| *a <= 0.05),
"fadable text alphas should start near 0; got {initial_text_alphas:?}"
);
// Advance past the fade-in window. Every fadable should now
// be at full alpha.
let _ = advance_by(&mut app, MOTION_SPLASH_FADE_SECS + 0.4);
if count_splash_roots(&mut app) == 0 {
return; // already past fade-out under the test clock — skip.
}
let mid_text_alphas: Vec<f32> = app
.world_mut()
.query::<(&SplashFadable, &TextColor)>()
.iter(app.world())
.map(|(_, color)| color.0.alpha())
.collect();
assert!(
mid_text_alphas.iter().all(|a| *a >= 0.9),
"fadable text alphas should be at full alpha during the hold; got {mid_text_alphas:?}"
);
}
}