diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 31462db..143f6a0 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -49,13 +49,15 @@ //! ``` use bevy::prelude::*; +use solitaire_data::AnimSpeed; use crate::font_plugin::FontResource; +use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ - ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, - BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, RADIUS_LG, RADIUS_MD, - SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, - VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, + scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, + BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, + MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, + TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, }; // --------------------------------------------------------------------------- @@ -89,6 +91,27 @@ pub struct ModalActions; #[derive(Component, Debug, Clone, Copy)] pub struct ModalButton(pub ButtonVariant); +/// Drives the modal open animation. Inserted on the scrim entity by +/// [`spawn_modal`]; advanced and removed by [`advance_modal_enter`] once +/// `elapsed >= duration`. +/// +/// During the animation the scrim's `BackgroundColor` alpha lerps from +/// 0 → `SCRIM`'s native alpha and the card's `Transform` scale lerps from +/// `MODAL_ENTER_START_SCALE` → 1.0. Under `AnimSpeed::Instant`, +/// `duration == 0.0` and the system snaps everything to the final state on +/// the first tick so no half-state is ever shown. +#[derive(Component, Debug, Clone, Copy)] +pub struct ModalEntering { + /// Seconds elapsed since the animation started. + pub elapsed: f32, + /// Total duration in seconds. May be zero (`AnimSpeed::Instant`). + pub duration: f32, +} + +/// Initial card scale at `t = 0` for the modal open animation. The card +/// grows from this value to `1.0` over `MOTION_MODAL_SECS`. +pub const MODAL_ENTER_START_SCALE: f32 = 0.96; + // --------------------------------------------------------------------------- // Button variants — three rungs of emphasis. A single overlay should have // at most one Primary; Secondary and Tertiary fill out the rest. @@ -120,6 +143,16 @@ pub enum ButtonVariant { /// `plugin_marker` is the overlay's plugin-specific marker /// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers /// can find their own modal. +/// +/// **Open animation.** The scrim is spawned with alpha 0 and the card +/// with `Transform::scale = MODAL_ENTER_START_SCALE`; a [`ModalEntering`] +/// component on the scrim drives the scrim alpha → `SCRIM`'s native +/// alpha and the card scale → 1.0 lerps via [`advance_modal_enter`]. The +/// duration is `scaled_duration(MOTION_MODAL_SECS, settings.animation_speed)` +/// so the open animation respects the player's `AnimSpeed` preference; +/// under `AnimSpeed::Instant` the duration is zero and the very first +/// tick snaps to the final state. The animate-OUT path is intentionally +/// out of scope — modals despawn instantly. pub fn spawn_modal( commands: &mut Commands, plugin_marker: M, @@ -129,10 +162,19 @@ pub fn spawn_modal( where F: FnOnce(&mut ChildSpawnerCommands), { + // The duration here is the `AnimSpeed::Normal` baseline; the + // `apply_modal_enter_speed` system rescales it (or zeroes it for + // `AnimSpeed::Instant`) on the first frame after spawn by reading + // `SettingsResource`. Doing it that way keeps `spawn_modal` a free + // function with no resource dependencies — every existing call site + // (~11 plugins) continues to work without a signature change. + let duration = MOTION_MODAL_SECS; + let initial_scrim = scrim_with_alpha(0.0); commands .spawn(( plugin_marker, ModalScrim, + ModalEntering { elapsed: 0.0, duration }, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), @@ -143,7 +185,7 @@ where align_items: AlignItems::Center, ..default() }, - BackgroundColor(SCRIM), + BackgroundColor(initial_scrim), // GlobalZIndex pins this root modal at `z_panel` regardless // of any sibling stacking-context quirks in Bevy 0.18 — the // ordinary `ZIndex` is preserved as a fallback for nested @@ -167,6 +209,9 @@ where align_items: AlignItems::Stretch, ..default() }, + // Card UI nodes carry a Transform; the open animation + // lerps `scale` from MODAL_ENTER_START_SCALE → 1.0. + Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)), BackgroundColor(BG_ELEVATED), BorderColor::all(BORDER_STRONG), )) @@ -175,6 +220,16 @@ where .id() } +/// Returns `SCRIM` with its alpha multiplied by `factor` (0.0–1.0). The +/// open animation lerps `factor` from 0 → 1 over the modal-enter +/// duration so the scrim fades in instead of popping. +fn scrim_with_alpha(factor: f32) -> Color { + let mut c = SCRIM; + let target = SCRIM.alpha(); + c.set_alpha(target * factor.clamp(0.0, 1.0)); + c +} + /// Spawns the standard modal header — `TYPE_HEADLINE` + `TEXT_PRIMARY`. pub fn spawn_modal_header( parent: &mut ChildSpawnerCommands, @@ -336,6 +391,90 @@ fn pressed_bg(variant: ButtonVariant) -> Color { } } +// --------------------------------------------------------------------------- +// Modal open animation +// --------------------------------------------------------------------------- + +/// Patches the `ModalEntering::duration` of newly-spawned modals against +/// the player's `AnimSpeed` setting. Runs on `Added` so it +/// only fires once per modal, immediately after [`spawn_modal`] inserts +/// the component. +/// +/// Under `AnimSpeed::Instant` this drops the duration to 0; the next +/// frame [`advance_modal_enter`] sees `t >= 1.0` and snaps the modal to +/// its final state, so no half-state is ever shown. +pub fn apply_modal_enter_speed( + settings: Option>, + mut q: Query<&mut ModalEntering, Added>, +) { + let speed = settings + .as_ref() + .map(|s| s.0.animation_speed) + .unwrap_or(AnimSpeed::Normal); + for mut entering in &mut q { + entering.duration = scaled_duration(MOTION_MODAL_SECS, speed); + } +} + +/// Drives the modal open animation. For each scrim entity carrying +/// [`ModalEntering`] this system increments `elapsed`, computes +/// `t = (elapsed / duration).clamp(0, 1)`, applies an ease-out +/// (`t * (2 - t)`) curve to both the scrim alpha and the card scale, +/// and removes the component plus any leftover transform offset once +/// `t >= 1.0`. +/// +/// The card scale is patched on the modal's `ModalCard` child rather +/// than on the scrim — the scrim is full-window and any scale on it +/// would visibly squash the layout. The card carries its own +/// `Transform`, started at `Vec3::splat(MODAL_ENTER_START_SCALE)` by +/// [`spawn_modal`]. +pub fn advance_modal_enter( + time: Res