feat(engine): modal open animation — fade + scale with ease-out

Modals now animate in via the new ModalEntering component: scrim alpha
ramps from 0 to its full value while the card scales from 0.96 to 1.0
over MOTION_MODAL_SECS using an ease-out curve. AnimSpeed::Instant
collapses the duration to zero so reduced-motion users see the modal
snap into place on the first frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 20:04:51 +00:00
parent 5f5aba8dff
commit 71999e1062
+275 -6
View File
@@ -49,13 +49,15 @@
//! ``` //! ```
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::AnimSpeed;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, RADIUS_LG, RADIUS_MD, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE,
SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, 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)] #[derive(Component, Debug, Clone, Copy)]
pub struct ModalButton(pub ButtonVariant); 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 // Button variants — three rungs of emphasis. A single overlay should have
// at most one Primary; Secondary and Tertiary fill out the rest. // 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 /// `plugin_marker` is the overlay's plugin-specific marker
/// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers /// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers
/// can find their own modal. /// 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<M: Component, F>( pub fn spawn_modal<M: Component, F>(
commands: &mut Commands, commands: &mut Commands,
plugin_marker: M, plugin_marker: M,
@@ -129,10 +162,19 @@ pub fn spawn_modal<M: Component, F>(
where where
F: FnOnce(&mut ChildSpawnerCommands), 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 commands
.spawn(( .spawn((
plugin_marker, plugin_marker,
ModalScrim, ModalScrim,
ModalEntering { elapsed: 0.0, duration },
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
left: Val::Px(0.0), left: Val::Px(0.0),
@@ -143,7 +185,7 @@ where
align_items: AlignItems::Center, align_items: AlignItems::Center,
..default() ..default()
}, },
BackgroundColor(SCRIM), BackgroundColor(initial_scrim),
// GlobalZIndex pins this root modal at `z_panel` regardless // GlobalZIndex pins this root modal at `z_panel` regardless
// of any sibling stacking-context quirks in Bevy 0.18 — the // of any sibling stacking-context quirks in Bevy 0.18 — the
// ordinary `ZIndex` is preserved as a fallback for nested // ordinary `ZIndex` is preserved as a fallback for nested
@@ -167,6 +209,9 @@ where
align_items: AlignItems::Stretch, align_items: AlignItems::Stretch,
..default() ..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), BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG), BorderColor::all(BORDER_STRONG),
)) ))
@@ -175,6 +220,16 @@ where
.id() .id()
} }
/// Returns `SCRIM` with its alpha multiplied by `factor` (0.01.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`. /// Spawns the standard modal header — `TYPE_HEADLINE` + `TEXT_PRIMARY`.
pub fn spawn_modal_header( pub fn spawn_modal_header(
parent: &mut ChildSpawnerCommands, 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<ModalEntering>` 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<Res<SettingsResource>>,
mut q: Query<&mut ModalEntering, Added<ModalEntering>>,
) {
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<Time>,
mut commands: Commands,
mut scrims: Query<(Entity, &mut ModalEntering, &mut BackgroundColor, &Children), With<ModalScrim>>,
mut cards: Query<&mut Transform, With<ModalCard>>,
) {
let dt = time.delta_secs();
for (scrim_entity, mut entering, mut bg, children) in &mut scrims {
// Zero-duration path (AnimSpeed::Instant): snap to the final
// state on the very first tick so the modal is fully visible
// immediately and we never expose the 0.96 / alpha-0 starting
// pose to the player.
let t = if entering.duration <= 0.0 {
1.0
} else {
entering.elapsed += dt;
(entering.elapsed / entering.duration).clamp(0.0, 1.0)
};
// Ease-out: t * (2 - t). Reaches 1.0 at t=1, derivative is 0
// at the endpoint so the animation settles instead of snapping.
let eased = t * (2.0 - t);
bg.0 = scrim_with_alpha(eased);
let scale = MODAL_ENTER_START_SCALE + (1.0 - MODAL_ENTER_START_SCALE) * eased;
for child in children.iter() {
if let Ok(mut transform) = cards.get_mut(child) {
transform.scale = Vec3::splat(scale);
}
}
if t >= 1.0 {
// Pin scrim and card to their final exact values so any
// float drift from the lerp doesn't survive into normal
// use (downstream paint systems read these later).
bg.0 = SCRIM;
for child in children.iter() {
if let Ok(mut transform) = cards.get_mut(child) {
transform.scale = Vec3::ONE;
}
}
commands.entity(scrim_entity).remove::<ModalEntering>();
}
}
}
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and /// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
/// press states are visible without each overlay registering its own /// press states are visible without each overlay registering its own
/// paint system. /// paint system.
@@ -366,7 +505,17 @@ pub struct UiModalPlugin;
impl Plugin for UiModalPlugin { impl Plugin for UiModalPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, paint_modal_buttons); // Order: `apply_modal_enter_speed` patches the duration on the
// first frame after spawn (Added<ModalEntering>), then
// `advance_modal_enter` ticks. Running them in a tuple keeps
// them in the same stage so a freshly-spawned modal lands on
// the correct duration before its first frame of advance —
// important for AnimSpeed::Instant where duration must be 0
// before advance computes `t`.
app.add_systems(
Update,
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
);
} }
} }
@@ -400,5 +549,125 @@ mod tests {
// App built without panic — paint_modal_buttons is registered. // App built without panic — paint_modal_buttons is registered.
app.update(); app.update();
} }
// -----------------------------------------------------------------------
// Modal open animation (G1)
// -----------------------------------------------------------------------
/// Marker component for the test modal — `spawn_modal` requires a
/// `Component` so tests need their own dummy.
#[derive(Component, Debug)]
struct TestModal;
/// `spawn_modal` inserts `ModalEntering` carrying the full
/// `MOTION_MODAL_SECS` duration (`AnimSpeed::Normal` baseline) plus
/// a card child sized at the start scale. The
/// `apply_modal_enter_speed` system rescales later under
/// `SettingsResource`; absent that resource the baseline stands.
#[test]
fn spawn_modal_inserts_entering_with_full_duration_and_start_scale() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
let scrim = {
let world = app.world_mut();
let mut commands = world.commands();
let id = spawn_modal(&mut commands, TestModal, 0, |_| {});
world.flush();
id
};
let entering = app
.world()
.entity(scrim)
.get::<ModalEntering>()
.expect("ModalEntering should be inserted on spawn");
assert!(
(entering.duration - MOTION_MODAL_SECS).abs() < 1e-6,
"duration should be the AnimSpeed::Normal baseline before apply_modal_enter_speed runs; got {}",
entering.duration
);
assert_eq!(entering.elapsed, 0.0);
// The card child carries Transform with scale at the start value.
let card_scale = card_scale_of(&mut app, scrim);
assert!(
(card_scale - MODAL_ENTER_START_SCALE).abs() < 1e-6,
"card should spawn at MODAL_ENTER_START_SCALE; got {card_scale}"
);
}
/// After enough simulated ticks for `elapsed >= duration`, the
/// `ModalEntering` component is removed and the card scale is back
/// at 1.0. Uses `Time<Virtual>` advance to push elapsed past the
/// duration without waiting for real wall-clock time.
#[test]
fn advance_modal_enter_removes_component_and_settles_scale() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
let scrim = {
let world = app.world_mut();
let mut commands = world.commands();
let id = spawn_modal(&mut commands, TestModal, 0, |_| {});
world.flush();
id
};
// Tick once with delta well beyond MOTION_MODAL_SECS — the
// ease-out clamps t at 1.0 so a single oversized tick is enough
// to settle the animation. `ManualDuration` makes
// `Time::delta_secs()` deterministic inside the test.
set_manual_time_step(&mut app, MOTION_MODAL_SECS * 2.0 + 0.1);
// Two updates: the first sets up `Time` with the manual delta;
// the second runs the advance system with non-zero dt. The
// `Added<ModalEntering>` filter survives across these updates
// because `apply_modal_enter_speed` writes the duration on
// whichever frame the entity first appears.
app.update();
app.update();
assert!(
app.world().entity(scrim).get::<ModalEntering>().is_none(),
"ModalEntering should be removed once elapsed >= duration"
);
let card_scale = card_scale_of(&mut app, scrim);
assert!(
(card_scale - 1.0).abs() < 1e-3,
"card scale should settle at 1.0 after the open animation; got {card_scale}"
);
}
/// Returns the X-component of the first `ModalCard` child of the
/// given scrim's `Transform::scale`. All three components are kept
/// in sync by the system so reading X is sufficient.
fn card_scale_of(app: &mut App, scrim: Entity) -> f32 {
let world = app.world();
let children = world
.entity(scrim)
.get::<Children>()
.expect("scrim should have a card child");
for child in children.iter() {
if let Some(t) = world.entity(child).get::<Transform>()
&& world.entity(child).get::<ModalCard>().is_some()
{
return t.scale.x;
}
}
panic!("no ModalCard child with a Transform under scrim {scrim:?}");
}
/// Tells `TimePlugin` to advance the clock by `secs` on the next
/// `app.update()`. Inside a unit test no real wall-clock time has
/// passed between ticks, so the default `Automatic` strategy gives
/// `delta_secs() == 0`. `ManualDuration` makes the next tick
/// observe exactly `secs` of elapsed time.
fn set_manual_time_step(app: &mut App, secs: f32) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
}
} }