ed152e2d8f
Resume-prompt Option E, part 1 of 2 (the reduce-motion piece; HC chrome borders follow in a separate commit). v0.21.1 wired `Settings::reduce_motion_mode` through `effective_slide_secs` so cards snap instead of sliding under reduce-motion. The design-system spec at §Accessibility (#3) calls out two more sources of non-essential motion that reduce-motion should suppress: the splash CRT scanline effect and the splash cursor pulse. This commit gates both. ### Splash cursor pulse (`pulse_splash_cursor`) Previously sine-pulsed every frame regardless of settings. Now reads `Settings::reduce_motion_mode` and skips the pulse multiplier when on — the cursor still fades in / out with the global splash alpha (essential timing), but doesn't blink (decorative motion). The fade is preserved on purpose: skipping it would hard-cut the splash on/off, which is jarring; the spec specifically calls out *non-essential* motion as the reduce- motion target, and a decorative blink is more clearly non-essential than a fade timeline. ### Splash scanline overlay (`spawn_splash`) Previously generated and spawned unconditionally when `Assets<Image>` was available. Now skipped entirely when reduce-motion is on — without the scanline overlay the boot screen still reads as terminal-themed (foreground content, borders, palette swatches all unchanged); the scanlines are purely decorative. ### Test New `splash_skips_scanline_overlay_under_reduce_motion` pins the gate behaviour: under `reduce_motion_mode = true`, the splash root still spawns (essential motion intact) but the `SplashScanlineOverlay` entity is absent. 1193 passing (+1 from prior 1192). Workspace clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1344 lines
52 KiB
Rust
1344 lines
52 KiB
Rust
//! Launch splash overlay.
|
||
//!
|
||
//! On app start the engine spawns a fullscreen, high-Z overlay that
|
||
//! reads the Terminal-style "boot screen" — an accent-coloured 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.
|
||
//!
|
||
//! ## 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).
|
||
//!
|
||
//! ## 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).
|
||
//!
|
||
//! 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
|
||
//! 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;
|
||
|
||
use bevy::asset::RenderAssetUsages;
|
||
use bevy::image::Image;
|
||
use bevy::input::touch::Touches;
|
||
use bevy::prelude::*;
|
||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||
use bevy::ui::widget::NodeImageMode;
|
||
|
||
use crate::font_plugin::FontResource;
|
||
use crate::settings_plugin::SettingsResource;
|
||
use crate::ui_theme::{
|
||
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
|
||
/// [`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.
|
||
pub struct SplashPlugin;
|
||
|
||
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,
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Marker on the splash overlay scrim (root entity for the launch beat).
|
||
/// Despawned with descendants once [`MOTION_SPLASH_TOTAL_SECS`] elapses
|
||
/// or once a user-input dismissal advances the timeline past the hold.
|
||
#[derive(Component, Debug)]
|
||
pub struct SplashRoot;
|
||
|
||
/// Tracks the splash's elapsed visible duration. Stored as a component
|
||
/// on the splash root rather than a global resource so despawning the
|
||
/// splash root removes its state along with it — there's no second-run
|
||
/// concern (the splash is one-shot at app start) and a component keeps
|
||
/// the splash data co-located with its entity.
|
||
#[derive(Component, Debug, Default)]
|
||
pub struct SplashAge(pub Duration);
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// Marks an [`ImageNode`] whose `color` tint should fade with the
|
||
/// global splash timeline. The per-tick write is `tint = (1, 1, 1,
|
||
/// global_alpha)`, so the GPU composite is `texture_α × global_α` —
|
||
/// per-pixel transparency in the texture (e.g. the 30 %-alpha
|
||
/// scanline rows) is preserved while the whole image still fades
|
||
/// in / out with the splash. The alternative of cramming the alpha
|
||
/// into [`SplashFadableBg`] doesn't work because that writer
|
||
/// *overwrites* the base-colour alpha rather than multiplying it.
|
||
#[derive(Component, Debug)]
|
||
struct SplashFadableImage;
|
||
|
||
/// Marker on the fullscreen scanline overlay. Distinct from
|
||
/// [`SplashFadableImage`] so tests can locate the overlay without
|
||
/// scanning every fadable image (there's only ever one, but the
|
||
/// marker makes the query intent explicit).
|
||
#[derive(Component, Debug)]
|
||
struct SplashScanlineOverlay;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Systems
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Spawns the splash overlay at `Startup`. Builds a fullscreen scrim
|
||
/// 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.
|
||
fn spawn_splash(
|
||
mut commands: Commands,
|
||
font_res: Option<Res<FontResource>>,
|
||
settings: Option<Res<SettingsResource>>,
|
||
images: Option<ResMut<Assets<Image>>>,
|
||
) {
|
||
if let Some(settings) = settings.as_deref()
|
||
&& settings.0.first_run_complete
|
||
{
|
||
return;
|
||
}
|
||
|
||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||
|
||
// Settings is borrowed twice — once for the first_run_complete
|
||
// gate above, once here for the reduce-motion gate. The borrow
|
||
// above already happened (and was let-go via the `settings.as_deref()`
|
||
// pattern's auto-drop), so this re-read is safe.
|
||
let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
|
||
|
||
// Generate the scanline texture handle up-front (when the asset
|
||
// store is available — always true in production; opt-out under
|
||
// bare `MinimalPlugins` test fixtures so existing tests that
|
||
// don't init `Assets<Image>` keep working with the rest of the
|
||
// splash content unchanged). Also skipped when reduce-motion is
|
||
// on — the scanline overlay is the "CRT scanline effect" the
|
||
// design-system spec calls out as non-essential motion under
|
||
// reduce-motion (`design-system.md` §Accessibility #3). Without
|
||
// it the boot screen still reads as terminal-themed; the
|
||
// scanlines are decorative.
|
||
let scanline_handle = if reduce_motion {
|
||
None
|
||
} else {
|
||
images.map(|mut images| images.add(build_scanline_image()))
|
||
};
|
||
|
||
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);
|
||
// Scanline overlay sits last so it renders on top of the
|
||
// boot-screen content. Absolute-positioned to fill the
|
||
// root; `NodeImageMode::Tiled` repeats the 2×2 source
|
||
// texture across the whole viewport.
|
||
if let Some(handle) = scanline_handle {
|
||
root.spawn((
|
||
SplashScanlineOverlay,
|
||
SplashFadableImage,
|
||
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),
|
||
..default()
|
||
},
|
||
ImageNode {
|
||
image: handle,
|
||
// Start fully transparent so the very first
|
||
// frame matches every other fadable; the
|
||
// first `advance_splash` tick lifts this to
|
||
// `(1, 1, 1, global_alpha)`.
|
||
color: Color::srgba(1.0, 1.0, 1.0, 0.0),
|
||
image_mode: NodeImageMode::Tiled {
|
||
tile_x: true,
|
||
tile_y: true,
|
||
stretch_value: 1.0,
|
||
},
|
||
..default()
|
||
},
|
||
));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Pure helper — builds the 2×2 source texture for the scanline
|
||
/// overlay. Top row is fully transparent; bottom row is `#1a1a1a` at
|
||
/// ~30 % alpha (76 / 255 ≈ 0.298). Tiled across the splash by
|
||
/// `NodeImageMode::Tiled`, the result is a 2 px-pitch horizontal
|
||
/// scanline pattern at the alpha called for in the mockup.
|
||
///
|
||
/// The tilable unit is 2 px tall (one transparent, one tinted) by
|
||
/// any width — 2 px wide here is the minimum that still satisfies
|
||
/// `RenderAssetUsages::RENDER_WORLD`'s validation; the GPU samples
|
||
/// the same column for every horizontal position.
|
||
fn build_scanline_image() -> Image {
|
||
// Per-pixel RGBA bytes. Order is row-major top-to-bottom.
|
||
let pixels: Vec<u8> = vec![
|
||
// Row 0: transparent.
|
||
0, 0, 0, 0, 0, 0, 0, 0, // Row 1: #1a1a1a at ~30 % alpha (26, 26, 26, 76).
|
||
26, 26, 26, 76, 26, 26, 26, 76,
|
||
];
|
||
// 2 × 2 pixels × 4 bytes per RGBA8 pixel = 16 bytes. Hard-coded
|
||
// because `TextureFormat::pixel_size()` returns a `Result` in this
|
||
// Bevy version and a `debug_assert_eq!` shouldn't carry the
|
||
// unwrap noise.
|
||
debug_assert_eq!(
|
||
pixels.len(),
|
||
16,
|
||
"scanline pixel buffer must be 2x2 RGBA8",
|
||
);
|
||
Image::new(
|
||
Extent3d {
|
||
width: 2,
|
||
height: 2,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
TextureDimension::D2,
|
||
pixels,
|
||
TextureFormat::Rgba8UnormSrgb,
|
||
RenderAssetUsages::RENDER_WORLD,
|
||
)
|
||
}
|
||
|
||
/// 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
|
||
// signature element above the wordmark. Hand-tuned literal —
|
||
// a one-off display character outside the regular text scale.
|
||
font_size: 96.0,
|
||
..default()
|
||
};
|
||
let title_font = TextFont {
|
||
font: font_handle.clone(),
|
||
font_size: TYPE_DISPLAY,
|
||
..default()
|
||
};
|
||
let subtitle_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_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(transparent(ACCENT_PRIMARY)),
|
||
));
|
||
hdr.spawn((
|
||
SplashFadable { base_color: TEXT_PRIMARY },
|
||
Text::new("Solitaire Quest"),
|
||
title_font,
|
||
TextColor(transparent(TEXT_PRIMARY)),
|
||
));
|
||
// 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(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". The leading `▌` glyph picks up `TEXT_PRIMARY` rather than
|
||
/// `ACCENT_PRIMARY` so it doesn't compete with the big accent cursor in
|
||
/// the header; the *trailing* 6×12 px accent 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 {
|
||
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)),
|
||
));
|
||
// Trailing 6×12 accent 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)),
|
||
));
|
||
});
|
||
}
|
||
|
||
/// Progress bar — a 1 px tall track in `BORDER_SUBTLE` with a 100 %-
|
||
/// width accent 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` (0–1). 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`.
|
||
/// * `fade..total - fade` — hold: `alpha = 1.0`.
|
||
/// * `total - fade..total` — fade-out: `alpha = (total - age) / fade`.
|
||
/// * `>= total` — splash is complete; caller despawns the root.
|
||
///
|
||
/// Returns `None` once the timeline is finished, signalling the splash
|
||
/// should be despawned.
|
||
fn splash_alpha(age: Duration) -> Option<f32> {
|
||
let age_s = age.as_secs_f32();
|
||
let total = MOTION_SPLASH_TOTAL_SECS;
|
||
let fade = MOTION_SPLASH_FADE_SECS;
|
||
|
||
if age_s >= total {
|
||
return None;
|
||
}
|
||
if age_s < fade {
|
||
// Fade-in.
|
||
return Some((age_s / fade).clamp(0.0, 1.0));
|
||
}
|
||
if age_s < total - fade {
|
||
// Hold.
|
||
return Some(1.0);
|
||
}
|
||
// Fade-out.
|
||
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).
|
||
///
|
||
/// Under `Settings::reduce_motion_mode`, the per-frame pulse
|
||
/// multiplier is skipped — the cursor still fades in / out with
|
||
/// the global splash alpha (essential timing) but doesn't blink
|
||
/// (decorative motion). Spec at `design-system.md` §Accessibility
|
||
/// (#3): reduce-motion suppresses non-essential motion only;
|
||
/// fade-in / fade-out timelines stay intact because the splash
|
||
/// itself would otherwise hard-cut on/off, which is jarring.
|
||
fn pulse_splash_cursor(
|
||
roots: Query<&SplashAge, With<SplashRoot>>,
|
||
settings: Option<Res<SettingsResource>>,
|
||
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 reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
|
||
let pulse = if reduce_motion {
|
||
1.0
|
||
} else {
|
||
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
|
||
/// 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), With<SplashRoot>>,
|
||
mut fadable_texts: Query<(&SplashFadable, &mut TextColor)>,
|
||
mut fadable_bgs: Query<(&SplashFadableBg, &mut BackgroundColor), Without<SplashRoot>>,
|
||
mut fadable_images: Query<&mut ImageNode, With<SplashFadableImage>>,
|
||
) {
|
||
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;
|
||
};
|
||
|
||
bg.0 = scrim_with_alpha(alpha);
|
||
|
||
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;
|
||
}
|
||
// ImageNode tints fade by overwriting alpha on a white base so
|
||
// per-pixel texture transparency (e.g. the 30 %-alpha scanline
|
||
// rows) survives the multiplication on the GPU.
|
||
for mut image in &mut fadable_images {
|
||
let mut c = image.color;
|
||
c.set_alpha(alpha);
|
||
image.color = c;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Dismisses the splash on any user input. Accelerates each splash
|
||
/// root's age into the fade-out window so the dissolve still plays
|
||
/// (despawning instantly would feel abrupt). If the timeline is
|
||
/// already inside fade-out, the splash is left to finish on its own.
|
||
///
|
||
/// **Input is not consumed.** The splash neither calls
|
||
/// `clear_just_pressed` nor drains the touch / mouse buffers, so a
|
||
/// keystroke that dismissed the splash also reaches downstream
|
||
/// systems on the same tick (e.g. Space → `DrawRequestEvent`). This
|
||
/// matches what the user expects — the splash is a brand beat, not a
|
||
/// modal stop sign.
|
||
fn dismiss_splash_on_input(
|
||
keys: Res<ButtonInput<KeyCode>>,
|
||
mouse: Res<ButtonInput<MouseButton>>,
|
||
touches: Option<Res<Touches>>,
|
||
mut roots: Query<&mut SplashAge, With<SplashRoot>>,
|
||
) {
|
||
if roots.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let touch_pressed = touches.is_some_and(|t| t.iter_just_pressed().next().is_some());
|
||
let dismissed = keys.get_just_pressed().next().is_some()
|
||
|| mouse.get_just_pressed().next().is_some()
|
||
|| touch_pressed;
|
||
|
||
if !dismissed {
|
||
return;
|
||
}
|
||
|
||
// Jump the age forward to the start of the fade-out so the
|
||
// overlay dissolves cleanly. Saturating arithmetic on Duration
|
||
// means an already-past-fade-out splash stays past fade-out.
|
||
let fade_out_start = Duration::from_secs_f32(
|
||
(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS).max(0.0),
|
||
);
|
||
for mut age in &mut roots {
|
||
if age.0 < fade_out_start {
|
||
age.0 = fade_out_start;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use bevy::time::TimeUpdateStrategy;
|
||
|
||
/// Builds a headless `App` with `MinimalPlugins + SplashPlugin` and
|
||
/// runs one tick so `spawn_splash` (Startup) has executed before
|
||
/// the first asserting `update`.
|
||
fn headless_app() -> App {
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
|
||
app.init_resource::<ButtonInput<KeyCode>>();
|
||
app.init_resource::<ButtonInput<MouseButton>>();
|
||
// `MinimalPlugins` doesn't pull `AssetPlugin`, so init the
|
||
// image store explicitly — same pattern as
|
||
// `settings_plugin::tests`. Without this, `spawn_splash`'s
|
||
// `Option<ResMut<Assets<Image>>>` falls through and the
|
||
// scanline overlay is silently skipped, which would defeat
|
||
// the new tests.
|
||
app.init_resource::<Assets<Image>>();
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
/// Tells `TimePlugin` to advance the virtual clock by `secs` on the
|
||
/// next `app.update()`. Mirrors the helper in `ui_tooltip::tests`.
|
||
fn set_manual_time_step(app: &mut App, secs: f32) {
|
||
app.insert_resource(TimeUpdateStrategy::ManualDuration(
|
||
Duration::from_secs_f32(secs),
|
||
));
|
||
}
|
||
|
||
/// `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).
|
||
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;
|
||
for _ in 0..ticks {
|
||
app.update();
|
||
}
|
||
let mut q = app
|
||
.world_mut()
|
||
.query_filtered::<&SplashAge, With<SplashRoot>>();
|
||
q.iter(app.world()).next().map(|a| a.0)
|
||
}
|
||
|
||
fn count_splash_roots(app: &mut App) -> usize {
|
||
app.world_mut()
|
||
.query_filtered::<Entity, With<SplashRoot>>()
|
||
.iter(app.world())
|
||
.count()
|
||
}
|
||
|
||
fn press_key(app: &mut App, key: KeyCode) {
|
||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||
input.release_all();
|
||
input.clear();
|
||
input.press(key);
|
||
}
|
||
|
||
fn press_mouse(app: &mut App, button: MouseButton) {
|
||
let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
|
||
input.release_all();
|
||
input.clear();
|
||
input.press(button);
|
||
}
|
||
|
||
/// Reads the splash scrim's `BackgroundColor` alpha. Panics if the
|
||
/// splash root is missing — that's a regression in `spawn_splash`.
|
||
fn scrim_alpha(app: &mut App) -> f32 {
|
||
let mut q = app
|
||
.world_mut()
|
||
.query_filtered::<&BackgroundColor, With<SplashRoot>>();
|
||
q.iter(app.world())
|
||
.next()
|
||
.expect("SplashRoot should exist")
|
||
.0
|
||
.alpha()
|
||
}
|
||
|
||
#[test]
|
||
fn splash_spawns_on_startup() {
|
||
let mut app = headless_app();
|
||
assert_eq!(
|
||
count_splash_roots(&mut app),
|
||
1,
|
||
"SplashRoot must exist after Startup"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn splash_skipped_when_first_run_complete() {
|
||
use solitaire_data::Settings;
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
|
||
app.init_resource::<ButtonInput<KeyCode>>();
|
||
app.init_resource::<ButtonInput<MouseButton>>();
|
||
app.insert_resource(SettingsResource(Settings {
|
||
first_run_complete: true,
|
||
..Settings::default()
|
||
}));
|
||
app.update();
|
||
assert_eq!(
|
||
count_splash_roots(&mut app),
|
||
0,
|
||
"SplashRoot must NOT spawn on subsequent launches"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn splash_still_shows_when_first_run_incomplete() {
|
||
use solitaire_data::Settings;
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
|
||
app.init_resource::<ButtonInput<KeyCode>>();
|
||
app.init_resource::<ButtonInput<MouseButton>>();
|
||
app.insert_resource(SettingsResource(Settings {
|
||
first_run_complete: false,
|
||
..Settings::default()
|
||
}));
|
||
app.update();
|
||
assert_eq!(
|
||
count_splash_roots(&mut app),
|
||
1,
|
||
"SplashRoot must spawn for first-run players (first_run_complete = false)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn splash_skips_scanline_overlay_under_reduce_motion() {
|
||
// The CRT scanline overlay is decorative motion that
|
||
// `Settings::reduce_motion_mode` suppresses per the
|
||
// design-system spec (§Accessibility #3). The splash
|
||
// root itself still spawns — the cursor still fades in
|
||
// and out (essential timing), but the scanline overlay
|
||
// node is omitted entirely.
|
||
use solitaire_data::Settings;
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins)
|
||
.add_plugins(bevy::asset::AssetPlugin::default())
|
||
.init_asset::<bevy::image::Image>()
|
||
.add_plugins(SplashPlugin);
|
||
app.init_resource::<ButtonInput<KeyCode>>();
|
||
app.init_resource::<ButtonInput<MouseButton>>();
|
||
app.insert_resource(SettingsResource(Settings {
|
||
first_run_complete: false,
|
||
reduce_motion_mode: true,
|
||
..Settings::default()
|
||
}));
|
||
app.update();
|
||
// The splash root spawns (essential motion intact)
|
||
assert_eq!(
|
||
count_splash_roots(&mut app),
|
||
1,
|
||
"splash should still spawn under reduce-motion — only the scanline + pulse are gated",
|
||
);
|
||
// The scanline overlay is gone
|
||
let scanline_count = app
|
||
.world_mut()
|
||
.query::<&SplashScanlineOverlay>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert_eq!(
|
||
scanline_count, 0,
|
||
"scanline overlay must NOT spawn under reduce-motion",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn splash_despawns_after_total_duration() {
|
||
let mut app = headless_app();
|
||
let _ = advance_by(&mut app, MOTION_SPLASH_TOTAL_SECS + 0.5);
|
||
assert_eq!(
|
||
count_splash_roots(&mut app),
|
||
0,
|
||
"SplashRoot must be despawned after MOTION_SPLASH_TOTAL_SECS"
|
||
);
|
||
}
|
||
|
||
#[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.
|
||
// Start of fade-in.
|
||
assert!(
|
||
splash_alpha(Duration::ZERO).unwrap() < 0.05,
|
||
"alpha at t=0 must be near 0 (fade-in start)"
|
||
);
|
||
// End of fade-in.
|
||
let after_fade_in = Duration::from_secs_f32(MOTION_SPLASH_FADE_SECS);
|
||
assert!(
|
||
(splash_alpha(after_fade_in).unwrap() - 1.0).abs() < 0.001,
|
||
"alpha at end of fade-in must be ~1.0"
|
||
);
|
||
// Mid-hold.
|
||
let mid_hold = Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS / 2.0);
|
||
assert!(
|
||
(splash_alpha(mid_hold).unwrap() - 1.0).abs() < f32::EPSILON,
|
||
"alpha mid-hold must be exactly 1.0"
|
||
);
|
||
// Inside fade-out.
|
||
let mid_fade_out = Duration::from_secs_f32(
|
||
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS / 2.0,
|
||
);
|
||
let mid_out_alpha = splash_alpha(mid_fade_out).unwrap();
|
||
assert!(
|
||
mid_out_alpha < 0.6 && mid_out_alpha > 0.4,
|
||
"alpha mid-fade-out should be ~0.5, got {mid_out_alpha}"
|
||
);
|
||
// Past total.
|
||
let past_total = Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS + 0.1);
|
||
assert!(
|
||
splash_alpha(past_total).is_none(),
|
||
"alpha past total duration must be None (signal: despawn)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn splash_dismisses_immediately_on_keypress() {
|
||
let mut app = headless_app();
|
||
set_manual_time_step(&mut app, 0.05);
|
||
app.update();
|
||
let pre_alpha = scrim_alpha(&mut app);
|
||
assert!(
|
||
pre_alpha < 1.0,
|
||
"precondition: splash should be inside fade-in, not yet at full alpha (got {pre_alpha})"
|
||
);
|
||
|
||
press_key(&mut app, KeyCode::Space);
|
||
app.update();
|
||
|
||
if count_splash_roots(&mut app) == 0 {
|
||
return;
|
||
}
|
||
let mut q = app
|
||
.world_mut()
|
||
.query_filtered::<&SplashAge, With<SplashRoot>>();
|
||
let age = q
|
||
.iter(app.world())
|
||
.next()
|
||
.expect("splash should exist after one post-dismiss tick")
|
||
.0;
|
||
let fade_out_start = Duration::from_secs_f32(
|
||
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
|
||
);
|
||
assert!(
|
||
age >= fade_out_start,
|
||
"after a keypress dismiss the splash must be in fade-out (age >= {fade_out_start:?}); got {age:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn splash_dismisses_on_mouse_click() {
|
||
let mut app = headless_app();
|
||
set_manual_time_step(&mut app, 0.05);
|
||
app.update();
|
||
assert!(scrim_alpha(&mut app) < 1.0);
|
||
|
||
press_mouse(&mut app, MouseButton::Left);
|
||
app.update();
|
||
|
||
if count_splash_roots(&mut app) == 0 {
|
||
return;
|
||
}
|
||
let mut q = app
|
||
.world_mut()
|
||
.query_filtered::<&SplashAge, With<SplashRoot>>();
|
||
let age = q
|
||
.iter(app.world())
|
||
.next()
|
||
.expect("splash should exist after one post-dismiss tick")
|
||
.0;
|
||
let fade_out_start = Duration::from_secs_f32(
|
||
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
|
||
);
|
||
assert!(
|
||
age >= fade_out_start,
|
||
"after a left-click dismiss the splash must be in fade-out; got {age:?}"
|
||
);
|
||
}
|
||
|
||
/// 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.
|
||
#[test]
|
||
fn dismissal_keypress_is_visible_to_other_systems() {
|
||
let mut app = headless_app();
|
||
press_key(&mut app, KeyCode::Space);
|
||
app.update();
|
||
let keys = app.world().resource::<ButtonInput<KeyCode>>();
|
||
assert!(
|
||
keys.just_pressed(KeyCode::Space),
|
||
"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:?}"
|
||
);
|
||
}
|
||
|
||
/// Pure-helper guard for [`build_scanline_image`]. Asserts the
|
||
/// generated texture matches the spec literally:
|
||
///
|
||
/// * 2 × 2 RGBA8 sRGB.
|
||
/// * Top row fully transparent (`α = 0`).
|
||
/// * Bottom row `#1a1a1a` (26, 26, 26) at ~30 % alpha (76 / 255).
|
||
///
|
||
/// Locks the bytes so a future tweak to the colour or alpha
|
||
/// can't silently drift the visible scanline appearance.
|
||
#[test]
|
||
fn build_scanline_image_has_expected_2x2_rgba_bytes() {
|
||
let image = build_scanline_image();
|
||
let size = image.size();
|
||
assert_eq!(size.x, 2, "scanline texture width should be 2 px");
|
||
assert_eq!(size.y, 2, "scanline texture height should be 2 px");
|
||
|
||
let bytes = image
|
||
.data
|
||
.as_ref()
|
||
.expect("scanline texture should ship with raw byte data");
|
||
assert_eq!(
|
||
bytes.as_slice(),
|
||
&[
|
||
0, 0, 0, 0, 0, 0, 0, 0, // top row: transparent
|
||
26, 26, 26, 76, 26, 26, 26, 76, // bottom row: #1a1a1a @ ~30 % alpha
|
||
],
|
||
"scanline pixel buffer drifted from the mockup spec",
|
||
);
|
||
}
|
||
|
||
/// End-to-end: the scanline overlay is spawned as a child of the
|
||
/// splash root and its `ImageNode.color` tint fades from
|
||
/// transparent up toward full alpha as `advance_splash` runs.
|
||
/// Pinning both lets a future regression in either spawn placement
|
||
/// or the new fade-images branch surface here rather than in a
|
||
/// visual review.
|
||
#[test]
|
||
fn scanline_overlay_spawns_and_fades_with_splash() {
|
||
let mut app = headless_app();
|
||
|
||
let initial_alpha = scanline_tint_alpha(&mut app)
|
||
.expect("scanline overlay must spawn with the splash root");
|
||
assert!(
|
||
initial_alpha <= 0.05,
|
||
"scanline tint should start near 0; got {initial_alpha}",
|
||
);
|
||
|
||
// Advance past the fade-in window. Tint should now be near 1.
|
||
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_alpha = scanline_tint_alpha(&mut app)
|
||
.expect("scanline overlay should still exist during the hold");
|
||
assert!(
|
||
mid_alpha >= 0.9,
|
||
"scanline tint should reach full alpha during the hold; got {mid_alpha}",
|
||
);
|
||
}
|
||
|
||
/// Read the unique scanline overlay's `ImageNode.color` tint
|
||
/// alpha. Returns `None` if the overlay isn't in the world (e.g.
|
||
/// the splash already despawned, or this tick is pre-spawn).
|
||
fn scanline_tint_alpha(app: &mut App) -> Option<f32> {
|
||
let mut q = app
|
||
.world_mut()
|
||
.query_filtered::<&ImageNode, With<SplashScanlineOverlay>>();
|
||
q.iter(app.world()).next().map(|img| img.color.alpha())
|
||
}
|
||
|
||
/// 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
|
||
);
|
||
}
|
||
}
|