76a754d8e5
Two improvements to drag responsiveness on Android: 1. Guard start_drag against touch-simulated mouse presses. start_drag (mouse path) now bails when Touches::iter_just_pressed() finds an active touch, so touch_start_drag always owns drag state on touch-screen devices. Without the guard, Bevy/Winit versions that synthesise MouseButton::Left from the primary touch would have the mouse drag path claim drag state first (start_drag runs before touch_start_drag in the system chain), leaving the card tracked via cursor_world instead of the Touches resource. 2. Lower mobile drag commit threshold 10 px → 8 px. Matches Android ViewConfiguration.getScaledTouchSlop() exactly. Smaller threshold reduces the snap-to-finger displacement at commit and makes drag feel more immediate. Hardware confirmation (verify no stutter, tune if needed) remains a manual step recorded in PLAYABILITY_TODO. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
234 lines
8.0 KiB
Rust
234 lines
8.0 KiB
Rust
//! Platform-adaptive animation tuning.
|
|
//!
|
|
//! [`AnimationTuning`] is a Bevy resource that provides animation parameters
|
|
//! adapted to the currently detected input platform. Systems and components
|
|
//! that need animation timing should read from this resource instead of using
|
|
//! hardcoded constants, so the same binary behaves appropriately on both a
|
|
//! touchscreen phone and a desktop with a mouse.
|
|
//!
|
|
//! # Platform detection
|
|
//!
|
|
//! [`update_input_platform`] runs every frame. When a touch event is detected
|
|
//! the resource switches to [`InputPlatform::Touch`] (mobile defaults); when a
|
|
//! mouse event is detected it switches back to [`InputPlatform::Mouse`]
|
|
//! (desktop defaults). The transition is immediate.
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! ```ignore
|
|
//! fn my_system(tuning: Res<AnimationTuning>, time: Res<Time>) {
|
|
//! let duration = tuning.scale_duration(0.25); // 0.25 s on desktop, 0.19 s on mobile
|
|
//! let scale = tuning.drag_scale; // platform-appropriate lift
|
|
//! }
|
|
//! ```
|
|
|
|
use bevy::input::touch::Touches;
|
|
use bevy::prelude::*;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InputPlatform
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The most recently detected input platform.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum InputPlatform {
|
|
/// Mouse / keyboard — desktop behaviour (richer motion, hover states).
|
|
#[default]
|
|
Mouse,
|
|
/// Touchscreen — mobile behaviour (faster, tighter, no hover).
|
|
Touch,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AnimationTuning resource
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Animation and interaction parameters adapted to the active [`InputPlatform`].
|
|
///
|
|
/// Mobile (touch) defaults are faster and less bouncy than desktop (mouse)
|
|
/// defaults. Read this resource wherever you previously used animation
|
|
/// constants to get correct behaviour across both platforms.
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct AnimationTuning {
|
|
/// Currently detected input platform.
|
|
pub platform: InputPlatform,
|
|
|
|
/// Multiplier applied to all computed animation durations.
|
|
///
|
|
/// `1.0` on desktop; `0.75` on mobile (25 % faster).
|
|
pub duration_scale: f32,
|
|
|
|
/// Multiplier applied to spring-curve overshoot amplitude.
|
|
///
|
|
/// `1.0` on desktop (full bounce); `0.5` on mobile (half — tighter feel
|
|
/// on small screens where large overshoots look incorrect).
|
|
pub overshoot_scale: f32,
|
|
|
|
/// Minimum pointer/finger movement in **screen pixels** before a drag
|
|
/// is committed.
|
|
///
|
|
/// Prevents accidental drags from quick taps. Desktop = 4 px; mobile
|
|
/// = 10 px (fingers are less precise than a mouse cursor).
|
|
pub drag_threshold_px: f32,
|
|
|
|
/// `Transform.scale` applied to a card while it is being dragged.
|
|
pub drag_scale: f32,
|
|
|
|
/// `Transform.scale` applied to the card under the cursor (desktop only).
|
|
///
|
|
/// Always `1.0` on touch because there is no hover concept on a
|
|
/// touchscreen — applying hover to the card under the last touch
|
|
/// would feel wrong.
|
|
pub hover_scale: f32,
|
|
|
|
/// Lerp speed (per second) for the hover scale interpolation.
|
|
///
|
|
/// Higher values make the hover pop in/out faster.
|
|
pub hover_lerp_speed: f32,
|
|
|
|
/// Per-card stagger interval (seconds) for cascade / deal animations.
|
|
///
|
|
/// Mobile gets a slightly tighter stagger so the full cascade finishes
|
|
/// more quickly.
|
|
pub cascade_stagger_secs: f32,
|
|
}
|
|
|
|
impl AnimationTuning {
|
|
/// Desktop (mouse) defaults — richer motion, more expressive curves.
|
|
pub fn desktop() -> Self {
|
|
Self {
|
|
platform: InputPlatform::Mouse,
|
|
duration_scale: 1.0,
|
|
overshoot_scale: 1.0,
|
|
drag_threshold_px: 4.0,
|
|
drag_scale: 1.08,
|
|
hover_scale: 1.04,
|
|
hover_lerp_speed: 14.0,
|
|
cascade_stagger_secs: 0.018,
|
|
}
|
|
}
|
|
|
|
/// Mobile (touch) defaults — faster, tighter, no hover.
|
|
pub fn mobile() -> Self {
|
|
Self {
|
|
platform: InputPlatform::Touch,
|
|
duration_scale: 0.75,
|
|
overshoot_scale: 0.5,
|
|
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
|
|
drag_scale: 1.12,
|
|
hover_scale: 1.0, // no hover affordance on touch
|
|
hover_lerp_speed: 20.0,
|
|
cascade_stagger_secs: 0.014,
|
|
}
|
|
}
|
|
|
|
/// Scales `base_duration` by [`Self::duration_scale`].
|
|
///
|
|
/// Use this wherever you compute an animation duration to respect the
|
|
/// current platform's speed preference.
|
|
#[inline]
|
|
pub fn scale_duration(&self, base_duration: f32) -> f32 {
|
|
base_duration * self.duration_scale
|
|
}
|
|
}
|
|
|
|
impl Default for AnimationTuning {
|
|
fn default() -> Self {
|
|
Self::desktop()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detection system
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Detects the active input platform and updates [`AnimationTuning`] to match.
|
|
///
|
|
/// Called every frame. Uses `Option<Res<Touches>>` so the system is safe when
|
|
/// running under `MinimalPlugins` (which does not register the touch subsystem).
|
|
pub(crate) fn update_input_platform(
|
|
touches: Option<Res<Touches>>,
|
|
mouse_buttons: Option<Res<ButtonInput<MouseButton>>>,
|
|
mut tuning: ResMut<AnimationTuning>,
|
|
) {
|
|
let touch_active = touches.as_ref().is_some_and(|t| {
|
|
t.iter().next().is_some()
|
|
|| t.iter_just_pressed().next().is_some()
|
|
|| t.iter_just_released().next().is_some()
|
|
});
|
|
|
|
let mouse_active = mouse_buttons.as_ref().is_some_and(|mb| {
|
|
mb.get_just_pressed().next().is_some() || mb.get_pressed().next().is_some()
|
|
});
|
|
|
|
if touch_active && tuning.platform != InputPlatform::Touch {
|
|
*tuning = AnimationTuning::mobile();
|
|
} else if mouse_active && tuning.platform != InputPlatform::Mouse {
|
|
*tuning = AnimationTuning::desktop();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn desktop_defaults_are_sane() {
|
|
let t = AnimationTuning::desktop();
|
|
assert_eq!(t.duration_scale, 1.0);
|
|
assert_eq!(t.platform, InputPlatform::Mouse);
|
|
assert!(t.hover_scale > 1.0, "desktop hover must lift the card");
|
|
assert!(t.drag_threshold_px < 10.0, "desktop threshold must be smaller than mobile");
|
|
}
|
|
|
|
#[test]
|
|
fn mobile_is_faster_than_desktop() {
|
|
let d = AnimationTuning::desktop();
|
|
let m = AnimationTuning::mobile();
|
|
assert!(m.duration_scale < d.duration_scale, "mobile must animate faster");
|
|
assert!(m.overshoot_scale < d.overshoot_scale, "mobile must bounce less");
|
|
}
|
|
|
|
#[test]
|
|
fn mobile_has_no_hover() {
|
|
// On touch, `hover_scale = 1.0` means no visible hover effect.
|
|
assert_eq!(AnimationTuning::mobile().hover_scale, 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn mobile_drag_threshold_larger_than_desktop() {
|
|
assert!(
|
|
AnimationTuning::mobile().drag_threshold_px
|
|
> AnimationTuning::desktop().drag_threshold_px,
|
|
"mobile needs a larger threshold because touch is less precise"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scale_duration_applies_multiplier() {
|
|
let t = AnimationTuning {
|
|
duration_scale: 0.5,
|
|
..AnimationTuning::default()
|
|
};
|
|
assert!((t.scale_duration(1.0) - 0.5).abs() < 1e-6);
|
|
assert!((t.scale_duration(0.25) - 0.125).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn mobile_cascade_stagger_tighter_than_desktop() {
|
|
assert!(
|
|
AnimationTuning::mobile().cascade_stagger_secs
|
|
< AnimationTuning::desktop().cascade_stagger_secs
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_is_desktop() {
|
|
assert_eq!(AnimationTuning::default().platform, InputPlatform::Mouse);
|
|
}
|
|
}
|