From 5d57b67934af71caf98443032571b864f10e3801 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 30 Apr 2026 23:31:13 +0000 Subject: [PATCH] feat(engine): branded splash screen on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window previously snapped straight to a card deal, which read more like a prototype than a finished game. SplashPlugin lays a fullscreen overlay (BG_BASE backdrop, ACCENT_PRIMARY title, version subtitle) on top of the gameplay layer for MOTION_SPLASH_TOTAL_SECS — the board deals behind it so the splash dissolve hands off naturally to the deal animation. Visibility curves through fade-in (300ms), hold (~1s), fade-out (300ms) using a pure splash_alpha helper that gets pinned by a unit test rather than wired to the Bevy clock — Time's 250ms per-tick clamp makes float-tight alpha assertions around the fade boundary brittle. Any keystroke or mouse-button press jumps the age forward to the fade-out window so the splash dissolves immediately. The dismiss handler is read-only on ButtonInput / Touches, so the same press is still visible to gameplay handlers downstream — pressing Space on the splash both dismisses it and triggers the next-tick stock draw, as verified by dismissal_keypress_is_visible_to_other_systems. Z_SPLASH sits above every other UI rung (Z_TOAST + 100) so the splash owns the viewport for its brief lifetime. The hierarchy test was extended to enforce the new rung's monotonic position. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_app/src/main.rs | 5 +- solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/splash_plugin.rs | 516 ++++++++++++++++++++++++++ solitaire_engine/src/ui_theme.rs | 23 ++ 4 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 solitaire_engine/src/splash_plugin.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 50bd2fb..fa94c1d 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -10,8 +10,8 @@ use solitaire_engine::{ CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, - StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, - UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, + SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, + UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, }; fn main() { @@ -101,6 +101,7 @@ fn main() { .add_plugins(UiModalPlugin) .add_plugins(UiFocusPlugin) .add_plugins(UiTooltipPlugin) + .add_plugins(SplashPlugin) .run(); } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 1c45088..eaab5e3 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -26,6 +26,7 @@ pub mod settings_plugin; pub mod progress_plugin; pub mod resources; pub mod selection_plugin; +pub mod splash_plugin; pub mod stats_plugin; pub mod sync_plugin; pub mod table_plugin; @@ -97,6 +98,7 @@ pub use settings_plugin::{ pub use layout::{compute_layout, Layout, LayoutResource}; pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource}; pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState}; +pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate}; pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; diff --git a/solitaire_engine/src/splash_plugin.rs b/solitaire_engine/src/splash_plugin.rs new file mode 100644 index 0000000..2fad87a --- /dev/null +++ b/solitaire_engine/src/splash_plugin.rs @@ -0,0 +1,516 @@ +//! 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. +//! +//! ## 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). +//! +//! ## 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`). + +use std::time::Duration; + +use bevy::input::touch::Touches; +use bevy::prelude::*; + +use crate::font_plugin::FontResource; +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, +}; + +// --------------------------------------------------------------------------- +// 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).chain(), + ); + } +} + +// --------------------------------------------------------------------------- +// 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); + +/// 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; + +/// 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; + +// --------------------------------------------------------------------------- +// 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. +fn spawn_splash(mut commands: Commands, font_res: Option>) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let title_font = TextFont { + font: font_handle.clone(), + font_size: TYPE_DISPLAY, + ..default() + }; + let subtitle_font = TextFont { + font: font_handle, + 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(( + SplashTitle, + Text::new("Solitaire Quest"), + title_font, + TextColor(initial_title), + )); + root.spawn(( + SplashSubtitle, + Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))), + subtitle_font, + TextColor(initial_subtitle), + )); + }); +} + +/// 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 { + 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)) +} + +/// 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. +fn advance_splash( + mut commands: Commands, + time: Res