diff --git a/solitaire_engine/src/splash_plugin.rs b/solitaire_engine/src/splash_plugin.rs index 6d5dd0a..a3a0938 100644 --- a/solitaire_engine/src/splash_plugin.rs +++ b/solitaire_engine/src/splash_plugin.rs @@ -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` 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>, @@ -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) { 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) { + 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) { + 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`. @@ -239,61 +530,39 @@ fn splash_alpha(age: Duration) -> Option { } /// 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