fix+refactor+docs: P0–P3 todo list items
P0 fixes: - Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs (all three were exported but never wired — features silently did nothing) - game_state::draw(): increment move_count on waste→stock recycle, not just on normal draws; add move_count_increments_on_recycle regression test P1 fixes: - solitaire_server/Cargo.toml: remove duplicate dev-dependencies (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections) P2 — input_plugin refactor: - Split 198-line handle_keyboard() into three focused systems under 110 lines each: handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G) - Introduce KeyboardConfirmState resource to share countdown timers across systems - Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck, new_game_confirm_window_is_positive P2 — achievement predicate tests (solitaire_core): - Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer, on_a_roll, comeback predicates (previously only covered via check_achievements()) - 141 core tests now passing P2 — server tests: - solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required) - solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order P3 — documentation: - Add struct-level /// to 12 Plugin structs (ChallengePlugin, CursorPlugin, AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin, HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin) - Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef - Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win card_animation module (new files from previous session): - chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs - Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants - Add handle_touch_stock_tap so touch users can draw from the stock pile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -149,6 +149,7 @@ pub struct ActiveToast {
|
||||
/// Duration of each queued info-toast in seconds.
|
||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||
|
||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||
pub struct AnimationPlugin;
|
||||
|
||||
impl Plugin for AnimationPlugin {
|
||||
|
||||
@@ -97,6 +97,7 @@ pub struct MuteState {
|
||||
pub music_muted: bool,
|
||||
}
|
||||
|
||||
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
pub struct AudioPlugin;
|
||||
|
||||
impl Plugin for AudioPlugin {
|
||||
|
||||
@@ -140,10 +140,22 @@ impl CardAnimation {
|
||||
|
||||
/// Redirects a card to a new destination without snapping or interrupting motion.
|
||||
///
|
||||
/// Reads the card's current interpolated position (from a live `CardAnimation` if
|
||||
/// present, or from `Transform` if the card is stationary) and starts a fresh
|
||||
/// `CardAnimation` from that position. Duration is recalculated from the remaining
|
||||
/// distance so short remaining paths feel appropriately quick.
|
||||
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
|
||||
/// if present, or from `Transform` if stationary) and starts a fresh
|
||||
/// [`CardAnimation`] from that position. Duration is recalculated from the
|
||||
/// remaining distance so short paths stay quick.
|
||||
///
|
||||
/// # Velocity continuity
|
||||
///
|
||||
/// When a card is mid-flight, the new animation starts with a small positive
|
||||
/// `elapsed` offset (`carry`) derived from how far through the current animation
|
||||
/// the card is. This preserves a sense of forward momentum: the new curve does
|
||||
/// not restart from zero velocity, avoiding a visible "lurch" when the target
|
||||
/// changes rapidly.
|
||||
///
|
||||
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
|
||||
/// never causes a visible position jump — the card's start position is still
|
||||
/// read from the current transform.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
@@ -169,17 +181,29 @@ pub fn retarget_animation(
|
||||
new_end_z: f32,
|
||||
curve: MotionCurve,
|
||||
) {
|
||||
let (current_xy, current_z) = match current_anim {
|
||||
Some(anim) => (anim.current_xy(), transform.translation.z),
|
||||
None => (transform.translation.truncate(), transform.translation.z),
|
||||
let (current_xy, current_z, momentum_carry) = match current_anim {
|
||||
Some(anim) if anim.duration > 0.0 => {
|
||||
// Estimate how far into the current animation we are and carry
|
||||
// a small fraction of that progress into the new animation.
|
||||
// This avoids restarting from zero velocity and makes the motion
|
||||
// feel continuous when the target changes mid-flight.
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
// Cap at 10 % of the new animation so there's no visible jump.
|
||||
let carry = (t * 0.12).min(0.10);
|
||||
(anim.current_xy(), transform.translation.z, carry)
|
||||
}
|
||||
_ => (transform.translation.truncate(), transform.translation.z, 0.0),
|
||||
};
|
||||
|
||||
let distance = current_xy.distance(new_end);
|
||||
let duration = compute_duration(distance);
|
||||
|
||||
commands.entity(entity).insert(CardAnimation {
|
||||
start: current_xy,
|
||||
end: new_end,
|
||||
elapsed: 0.0,
|
||||
duration: compute_duration(distance),
|
||||
// Start slightly into the new animation to carry forward momentum.
|
||||
elapsed: momentum_carry * duration,
|
||||
duration,
|
||||
curve,
|
||||
delay: 0.0,
|
||||
start_z: current_z,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Animation chaining — play a sequence of [`CardAnimation`] segments in order.
|
||||
//!
|
||||
//! Insert [`AnimationChain`] on a card entity alongside the *first* segment as
|
||||
//! a [`CardAnimation`] to sequence multi-step motion. When the active
|
||||
//! [`CardAnimation`] finishes and is removed, [`advance_animation_chains`]
|
||||
//! pops the next segment and inserts it automatically.
|
||||
//!
|
||||
//! # Example — arc then settle
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Arc up to a midpoint, then settle onto the foundation with a soft bounce.
|
||||
//! let mid = (start + end) / 2.0 + Vec2::new(0.0, 30.0);
|
||||
//!
|
||||
//! let first_leg = CardAnimation::slide(start, z, mid, z + 20.0, MotionCurve::SmoothSnap)
|
||||
//! .with_z_lift(15.0);
|
||||
//! let second_leg = CardAnimation::slide(mid, z + 20.0, end, resting_z, MotionCurve::SoftBounce);
|
||||
//!
|
||||
//! commands.entity(card_entity).insert((
|
||||
//! first_leg, // plays immediately
|
||||
//! AnimationChain::new().then(second_leg), // queued
|
||||
//! ));
|
||||
//! ```
|
||||
//!
|
||||
//! # Invariant
|
||||
//!
|
||||
//! The chain holds only the *queued* segments — the segment currently playing
|
||||
//! lives on the entity as a [`CardAnimation`] component and has already been
|
||||
//! removed from the queue. When the queue is exhausted the `AnimationChain`
|
||||
//! component removes itself.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::animation::CardAnimation;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A FIFO queue of [`CardAnimation`] segments to be played one after another.
|
||||
///
|
||||
/// The currently playing segment lives on the entity as a [`CardAnimation`]
|
||||
/// component (already removed from this queue). When that animation completes,
|
||||
/// [`advance_animation_chains`] pops the next entry and inserts it.
|
||||
///
|
||||
/// Remove this component to cancel the entire chain mid-flight. The in-progress
|
||||
/// [`CardAnimation`] (if any) will still play to completion unless also removed.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct AnimationChain {
|
||||
pub(crate) queue: VecDeque<CardAnimation>,
|
||||
}
|
||||
|
||||
impl AnimationChain {
|
||||
/// Creates an empty chain with no queued segments.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends `anim` to the end of the chain.
|
||||
///
|
||||
/// Returns `self` for builder-style chaining.
|
||||
#[must_use]
|
||||
pub fn then(mut self, anim: CardAnimation) -> Self {
|
||||
self.queue.push_back(anim);
|
||||
self
|
||||
}
|
||||
|
||||
/// Number of segments waiting in the queue (not including any
|
||||
/// currently active [`CardAnimation`]).
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.queue.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when no segments remain in the queue.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AnimationChain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pops the next queued segment when the active [`CardAnimation`] has finished.
|
||||
///
|
||||
/// Must run **after** `advance_card_animations` so the completed animation has
|
||||
/// already been removed before this system inspects the entity.
|
||||
pub(crate) fn advance_animation_chains(
|
||||
mut commands: Commands,
|
||||
mut chains: Query<(Entity, &mut AnimationChain), Without<CardAnimation>>,
|
||||
) {
|
||||
for (entity, mut chain) in &mut chains {
|
||||
match chain.queue.pop_front() {
|
||||
Some(next) => {
|
||||
// Insert the next segment; the chain component stays until empty.
|
||||
commands.entity(entity).insert(next);
|
||||
}
|
||||
None => {
|
||||
// Queue exhausted — clean up the chain component.
|
||||
commands.entity(entity).remove::<AnimationChain>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card_animation::MotionCurve;
|
||||
|
||||
fn slide(end_x: f32) -> CardAnimation {
|
||||
CardAnimation::slide(
|
||||
Vec2::ZERO,
|
||||
0.0,
|
||||
Vec2::new(end_x, 0.0),
|
||||
0.0,
|
||||
MotionCurve::SmoothSnap,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_chain_is_empty() {
|
||||
let c = AnimationChain::new();
|
||||
assert_eq!(c.remaining(), 0);
|
||||
assert!(c.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn then_appends_and_increments_remaining() {
|
||||
let c = AnimationChain::new().then(slide(1.0)).then(slide(2.0));
|
||||
assert_eq!(c.remaining(), 2);
|
||||
assert!(!c.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_is_fifo() {
|
||||
let mut c = AnimationChain::new().then(slide(1.0)).then(slide(2.0));
|
||||
let first = c.queue.pop_front().expect("must have first segment");
|
||||
assert!(
|
||||
(first.end.x - 1.0).abs() < 1e-6,
|
||||
"first dequeued must be the first appended (end.x=1), got {}",
|
||||
first.end.x
|
||||
);
|
||||
let second = c.queue.pop_front().expect("must have second segment");
|
||||
assert!(
|
||||
(second.end.x - 2.0).abs() < 1e-6,
|
||||
"second dequeued must be the second appended (end.x=2), got {}",
|
||||
second.end.x
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_equals_new() {
|
||||
assert_eq!(AnimationChain::default().remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_with_three_segments() {
|
||||
let c = AnimationChain::new()
|
||||
.then(slide(1.0))
|
||||
.then(slide(2.0))
|
||||
.then(slide(3.0));
|
||||
assert_eq!(c.remaining(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_system_inserts_next_segment() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(crate::card_animation::CardAnimationPlugin);
|
||||
|
||||
let chain = AnimationChain::new().then(slide(100.0));
|
||||
// Spawn an entity with only AnimationChain (no CardAnimation) so the
|
||||
// system fires immediately on the first update.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((Transform::from_translation(Vec3::ZERO), chain))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
// After one update, the chain system should have popped `slide(100)` and
|
||||
// inserted it as a `CardAnimation`.
|
||||
assert!(
|
||||
app.world().entity(entity).get::<CardAnimation>().is_some(),
|
||||
"advance_animation_chains must insert CardAnimation from first queued segment"
|
||||
);
|
||||
// The chain component should still be present (but now empty).
|
||||
// Actually, since we popped the last item, the chain removes itself too.
|
||||
// Whether it's present or not depends on system ordering, but the
|
||||
// CardAnimation must definitely be present.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Lightweight frame-time diagnostics.
|
||||
//!
|
||||
//! [`FrameTimeDiagnostics`] is a Bevy resource that maintains a rolling window
|
||||
//! of the last [`WINDOW_SIZE`] frame durations. Any system can read it to make
|
||||
//! performance-aware decisions — for example, disabling settle-bounce animations
|
||||
//! when the game is running below 30 FPS on a low-end device.
|
||||
//!
|
||||
//! # Reading diagnostics
|
||||
//!
|
||||
//! ```ignore
|
||||
//! fn my_system(diag: Res<FrameTimeDiagnostics>) {
|
||||
//! if diag.is_low_performance() {
|
||||
//! // Skip expensive visual effects.
|
||||
//! return;
|
||||
//! }
|
||||
//! println!("avg FPS: {:.1}", diag.fps());
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Update
|
||||
//!
|
||||
//! [`update_frame_time_diagnostics`] runs every frame via [`CardAnimationPlugin`]
|
||||
//! (or whichever plugin registers it). The window is circular so only the last
|
||||
//! `WINDOW_SIZE` frames influence the statistics.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Number of frames kept in the rolling statistics window.
|
||||
pub const WINDOW_SIZE: usize = 60;
|
||||
|
||||
/// Rolling frame-time statistics over the last [`WINDOW_SIZE`] frames.
|
||||
///
|
||||
/// All times are in seconds. Statistics are updated every frame by
|
||||
/// [`update_frame_time_diagnostics`].
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct FrameTimeDiagnostics {
|
||||
samples: [f32; WINDOW_SIZE],
|
||||
head: usize,
|
||||
count: usize,
|
||||
/// Smoothed average frame duration over the window (seconds).
|
||||
pub avg_secs: f32,
|
||||
/// Worst-case (slowest) frame duration in the window (seconds).
|
||||
pub max_secs: f32,
|
||||
/// Best-case (fastest) frame duration in the window (seconds).
|
||||
pub min_secs: f32,
|
||||
}
|
||||
|
||||
impl Default for FrameTimeDiagnostics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
samples: [0.0; WINDOW_SIZE],
|
||||
head: 0,
|
||||
count: 0,
|
||||
avg_secs: 0.0,
|
||||
max_secs: 0.0,
|
||||
min_secs: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameTimeDiagnostics {
|
||||
/// Estimated frames per second based on the rolling average.
|
||||
///
|
||||
/// Returns `0.0` until at least one frame has been recorded.
|
||||
pub fn fps(&self) -> f32 {
|
||||
if self.avg_secs > 0.0 {
|
||||
1.0 / self.avg_secs
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the rolling-average FPS is above `target`.
|
||||
///
|
||||
/// Always returns `false` until the window is fully populated.
|
||||
pub fn is_above_target(&self, target_fps: f32) -> bool {
|
||||
self.count >= WINDOW_SIZE && self.fps() > target_fps
|
||||
}
|
||||
|
||||
/// Returns `true` when the device appears to be running below 30 FPS.
|
||||
///
|
||||
/// Only asserted after the window is fully populated so a single slow
|
||||
/// startup frame does not permanently suppress visual effects.
|
||||
pub fn is_low_performance(&self) -> bool {
|
||||
self.count >= WINDOW_SIZE && self.fps() < 30.0
|
||||
}
|
||||
|
||||
/// Appends `dt` to the ring buffer and recomputes statistics.
|
||||
///
|
||||
/// O(WINDOW_SIZE) — acceptable because WINDOW_SIZE is small and constant.
|
||||
fn push(&mut self, dt: f32) {
|
||||
self.samples[self.head] = dt;
|
||||
self.head = (self.head + 1) % WINDOW_SIZE;
|
||||
if self.count < WINDOW_SIZE {
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
let n = self.count;
|
||||
let mut sum = 0.0_f32;
|
||||
let mut max_val = 0.0_f32;
|
||||
let mut min_val = f32::MAX;
|
||||
|
||||
for &s in &self.samples[..n] {
|
||||
sum += s;
|
||||
if s > max_val {
|
||||
max_val = s;
|
||||
}
|
||||
if s < min_val {
|
||||
min_val = s;
|
||||
}
|
||||
}
|
||||
|
||||
self.avg_secs = sum / n as f32;
|
||||
self.max_secs = max_val;
|
||||
self.min_secs = if min_val == f32::MAX { 0.0 } else { min_val };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Records the current frame's delta time in [`FrameTimeDiagnostics`].
|
||||
///
|
||||
/// Registered by [`CardAnimationPlugin`]. Runs every frame in `Update`.
|
||||
pub(crate) fn update_frame_time_diagnostics(
|
||||
time: Res<Time>,
|
||||
mut diag: ResMut<FrameTimeDiagnostics>,
|
||||
) {
|
||||
diag.push(time.delta_secs());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fps_zero_when_no_samples() {
|
||||
assert_eq!(FrameTimeDiagnostics::default().fps(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_correct_after_uniform_frames() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 60.0);
|
||||
}
|
||||
assert!(
|
||||
(d.fps() - 60.0).abs() < 0.5,
|
||||
"expected ~60 fps, got {}",
|
||||
d.fps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_low_performance_requires_full_window() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
// Partial window filled with very slow frames.
|
||||
for _ in 0..(WINDOW_SIZE / 2) {
|
||||
d.push(1.0 / 5.0); // 5 FPS
|
||||
}
|
||||
assert!(
|
||||
!d.is_low_performance(),
|
||||
"must not report low performance until the window is full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_low_performance_true_below_30fps() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 20.0); // 20 FPS
|
||||
}
|
||||
assert!(
|
||||
d.is_low_performance(),
|
||||
"20 FPS should be reported as low performance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_above_target_false_below_target() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 30.0); // exactly 30 FPS
|
||||
}
|
||||
// is_above_target(30.0) is strict: fps must be > 30, not >=.
|
||||
// At exactly 30 FPS the result depends on floating-point rounding,
|
||||
// so just check that it's consistent with > 60 being false.
|
||||
assert!(!d.is_above_target(60.0), "30 FPS is not above 60 FPS target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_and_min_track_extremes() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
d.push(0.010); // fast frame (100 FPS)
|
||||
d.push(0.050); // slow frame (20 FPS)
|
||||
assert!(
|
||||
d.max_secs >= 0.050,
|
||||
"max_secs must be at least the slow frame, got {}",
|
||||
d.max_secs
|
||||
);
|
||||
assert!(
|
||||
d.min_secs <= 0.010,
|
||||
"min_secs must be at most the fast frame, got {}",
|
||||
d.min_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_buffer_overwrites_oldest() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
// Fill with 60-FPS samples.
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 60.0);
|
||||
}
|
||||
// Overwrite every slot with 10-FPS samples.
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 10.0);
|
||||
}
|
||||
assert!(
|
||||
d.fps() < 15.0,
|
||||
"after full overwrite, avg must reflect new slow frames; got fps={}",
|
||||
d.fps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_does_not_exceed_window_size() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE * 3 {
|
||||
d.push(0.016);
|
||||
}
|
||||
assert_eq!(d.count, WINDOW_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use super::animation::CardAnimation;
|
||||
use super::tuning::AnimationTuning;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
|
||||
use crate::layout::LayoutResource;
|
||||
@@ -48,15 +49,6 @@ type CardTransformQuery<'w, 's> =
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scale applied to the card currently under the cursor (1.0 = no change).
|
||||
const HOVER_SCALE: f32 = 1.04;
|
||||
|
||||
/// Additional scale applied to dragged cards while in flight.
|
||||
const DRAG_LIFT_SCALE: f32 = 1.08;
|
||||
|
||||
/// Lerp speed for hover scale interpolation (higher = snappier).
|
||||
const HOVER_LERP_SPEED: f32 = 14.0;
|
||||
|
||||
/// Lerp speed for drag scale interpolation.
|
||||
const DRAG_LERP_SPEED: f32 = 20.0;
|
||||
|
||||
@@ -162,27 +154,34 @@ pub(crate) fn detect_hover(
|
||||
|
||||
/// Applies the hover scale to the currently hovered card via smooth lerp.
|
||||
///
|
||||
/// Uses [`AnimationTuning`] to get the platform-appropriate hover scale.
|
||||
/// On touch (`hover_scale == 1.0`) this becomes a no-op — there is no
|
||||
/// hover affordance on a touchscreen.
|
||||
///
|
||||
/// Only runs on cards that have **no active [`CardAnimation`]** — animated
|
||||
/// cards control their own scale. When hover changes entities, the previous
|
||||
/// entity's scale is snapped back to 1.0 to avoid leaving a permanently
|
||||
/// enlarged card.
|
||||
pub(crate) fn apply_hover_scale(
|
||||
time: Res<Time>,
|
||||
tuning: Res<AnimationTuning>,
|
||||
mut hover_state: ResMut<HoverState>,
|
||||
mut cards: CardTransformQuery,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let target_entity = hover_state.entity;
|
||||
let hover_target = tuning.hover_scale;
|
||||
let lerp_speed = tuning.hover_lerp_speed;
|
||||
|
||||
for (entity, mut transform) in &mut cards {
|
||||
let target_scale = if Some(entity) == target_entity {
|
||||
HOVER_SCALE
|
||||
hover_target
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let current = transform.scale.x;
|
||||
let new_scale = current + (target_scale - current) * (HOVER_LERP_SPEED * dt).min(1.0);
|
||||
let new_scale = current + (target_scale - current) * (lerp_speed * dt).min(1.0);
|
||||
transform.scale = Vec3::splat(new_scale);
|
||||
}
|
||||
|
||||
@@ -191,29 +190,36 @@ pub(crate) fn apply_hover_scale(
|
||||
cards
|
||||
.get(entity)
|
||||
.map(|(_, t)| t.scale.x)
|
||||
.unwrap_or(HOVER_SCALE)
|
||||
.unwrap_or(hover_target)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
|
||||
/// Applies a scale boost and z-lift to dragged card entities.
|
||||
/// Applies a scale boost to committed dragged card entities.
|
||||
///
|
||||
/// Reads [`DragState`] for the list of card IDs being dragged. Does **not**
|
||||
/// modify `translation.xy` — the existing `InputPlugin` owns drag translation.
|
||||
/// Only writes `scale` and `translation.z` so the two systems are disjoint.
|
||||
/// Uses [`AnimationTuning`] for the platform-correct drag scale. Only applies
|
||||
/// to cards whose drag has been *committed* (threshold crossed); cards in the
|
||||
/// pending-drag state stay at scale 1.0. Does **not** modify `translation.xy`
|
||||
/// — `InputPlugin` owns drag translation.
|
||||
pub(crate) fn apply_drag_visual(
|
||||
time: Res<Time>,
|
||||
drag: Option<Res<DragState>>,
|
||||
tuning: Res<AnimationTuning>,
|
||||
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let empty: Vec<u32> = Vec::new();
|
||||
let dragged_ids: &[u32] = drag.as_ref().map_or(empty.as_slice(), |d| &d.cards);
|
||||
let drag_scale = tuning.drag_scale;
|
||||
|
||||
// Only lift cards that are in a *committed* drag. Pending drags (below
|
||||
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
||||
let (dragged_ids, committed): (&[u32], bool) = drag
|
||||
.as_ref()
|
||||
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
||||
|
||||
for (_, card, mut transform) in &mut cards {
|
||||
let is_dragged = dragged_ids.contains(&card.card_id);
|
||||
let target_scale = if is_dragged { DRAG_LIFT_SCALE } else { 1.0 };
|
||||
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
|
||||
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
||||
let current = transform.scale.x;
|
||||
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
||||
transform.scale = Vec3::splat(new_scale);
|
||||
|
||||
@@ -73,17 +73,23 @@
|
||||
//! | `apply_drag_visual` scale + `CardAnimation` scale | ✓ (same filter) |
|
||||
|
||||
pub mod animation;
|
||||
pub mod chain;
|
||||
pub mod curves;
|
||||
pub mod diagnostics;
|
||||
pub mod interaction;
|
||||
pub mod timing;
|
||||
pub mod tuning;
|
||||
|
||||
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
|
||||
pub use chain::AnimationChain;
|
||||
pub use curves::{sample_curve, MotionCurve};
|
||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||
pub use timing::{
|
||||
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
|
||||
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||
};
|
||||
pub use tuning::{AnimationTuning, InputPlatform};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -94,14 +100,18 @@ use crate::layout::LayoutResource;
|
||||
use crate::resources::DragState;
|
||||
|
||||
use animation::advance_card_animations;
|
||||
use chain::advance_animation_chains;
|
||||
use diagnostics::update_frame_time_diagnostics;
|
||||
use interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
|
||||
use tuning::update_input_platform;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers all systems, resources, and components for curve-based card
|
||||
/// animation, hover visuals, drag lift, and input buffering.
|
||||
/// animation, hover visuals, drag lift, input buffering, platform-adaptive
|
||||
/// tuning, animation chaining, and frame-time diagnostics.
|
||||
///
|
||||
/// Safe to register alongside `AnimationPlugin` and `FeedbackAnimPlugin` as
|
||||
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
|
||||
@@ -109,8 +119,8 @@ pub struct CardAnimationPlugin;
|
||||
|
||||
impl Plugin for CardAnimationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Register events and resources that interaction systems depend on,
|
||||
// idempotently — double-registration is safe in Bevy.
|
||||
// Register events and resources idempotently — double-registration is
|
||||
// safe in Bevy.
|
||||
app.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
@@ -118,12 +128,23 @@ impl Plugin for CardAnimationPlugin {
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<HoverState>()
|
||||
.init_resource::<InputBuffer>()
|
||||
// Platform-adaptive tuning (desktop by default, switches on touch).
|
||||
.init_resource::<AnimationTuning>()
|
||||
// Rolling frame-time statistics.
|
||||
.init_resource::<FrameTimeDiagnostics>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Advance active animations (highest priority — runs first).
|
||||
// Detect input platform and update tuning — runs first so
|
||||
// all downstream systems in this frame see the fresh value.
|
||||
update_input_platform,
|
||||
// Frame-time diagnostics — cheap, runs unconditionally.
|
||||
update_frame_time_diagnostics,
|
||||
// Advance active animations.
|
||||
advance_card_animations,
|
||||
// Interaction visuals (run after animation to read final positions).
|
||||
// After each animation finishes, pop the next chain segment.
|
||||
advance_animation_chains,
|
||||
// Interaction visuals (run after animation for final positions).
|
||||
detect_hover,
|
||||
apply_hover_scale,
|
||||
apply_drag_visual,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 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: 10.0,
|
||||
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: 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.get_just_pressed().next().is_some()
|
||||
|| mouse_buttons.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 mut t = AnimationTuning::default();
|
||||
t.duration_scale = 0.5;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ pub struct ChallengeAdvancedEvent {
|
||||
pub new_index: u32,
|
||||
}
|
||||
|
||||
/// Manages Challenge Mode progression: seeded hard deals, no-undo rules, and advancement through the challenge sequence.
|
||||
/// Requires the player to be at least level `CHALLENGE_UNLOCK_LEVEL`.
|
||||
pub struct ChallengePlugin;
|
||||
|
||||
impl Plugin for ChallengePlugin {
|
||||
|
||||
@@ -30,6 +30,7 @@ const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Green tint applied to pile markers that are valid drop targets during drag.
|
||||
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
|
||||
|
||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
||||
pub struct CursorPlugin;
|
||||
|
||||
impl Plugin for CursorPlugin {
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct DailyChallengeCompletedEvent {
|
||||
#[derive(Resource, Default)]
|
||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||
|
||||
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
|
||||
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
|
||||
pub struct DailyChallengePlugin;
|
||||
|
||||
impl Plugin for DailyChallengePlugin {
|
||||
|
||||
@@ -9,6 +9,8 @@ use bevy::prelude::*;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpScreen;
|
||||
|
||||
/// Spawns and despawns the help/controls overlay shown when the player presses H (or the help button).
|
||||
/// All hotkeys and gesture guides live here.
|
||||
pub struct HelpPlugin;
|
||||
|
||||
impl Plugin for HelpPlugin {
|
||||
|
||||
@@ -86,6 +86,7 @@ pub struct HudSelection;
|
||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||
const Z_HUD: i32 = 50;
|
||||
|
||||
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
|
||||
pub struct HudPlugin;
|
||||
|
||||
impl Plugin for HudPlugin {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@ struct OptOutTask(Option<Task<Result<(), String>>>);
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages the leaderboard overlay: fetches scores from the sync server, handles opt-in/opt-out, and displays the ranked list of player scores.
|
||||
pub struct LeaderboardPlugin;
|
||||
|
||||
impl Plugin for LeaderboardPlugin {
|
||||
|
||||
@@ -48,6 +48,9 @@ pub use card_animation::{
|
||||
HoverState, InputBuffer, BufferedInput,
|
||||
win_scatter_targets, WIN_CASCADE_INTERVAL_SECS, DEAL_INTERVAL_SECS,
|
||||
MIN_DURATION_SECS, MAX_DURATION_SECS,
|
||||
AnimationChain,
|
||||
AnimationTuning, InputPlatform,
|
||||
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
||||
};
|
||||
pub use feedback_anim_plugin::{
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
|
||||
@@ -32,6 +32,8 @@ const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0);
|
||||
/// Bright orange used for key-name spans so they stand out from body text.
|
||||
const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1);
|
||||
|
||||
/// Shows a first-run welcome screen that introduces the controls and draw mode.
|
||||
/// Sets `Settings::first_run_complete` once dismissed so it never appears again.
|
||||
pub struct OnboardingPlugin;
|
||||
|
||||
impl Plugin for OnboardingPlugin {
|
||||
|
||||
@@ -48,6 +48,7 @@ pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles pause and resume: toggles the pause overlay on Esc, freezes game-input systems via `PausedResource`, and saves the in-progress game state to disk.
|
||||
pub struct PausePlugin;
|
||||
|
||||
impl Plugin for PausePlugin {
|
||||
|
||||
@@ -12,28 +12,72 @@ pub struct GameStateResource(pub GameState);
|
||||
|
||||
/// Tracks an in-progress drag operation.
|
||||
///
|
||||
/// When `cards` is empty there is no active drag. When non-empty, the listed cards
|
||||
/// are being moved by the user and should be rendered at the cursor position.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
/// When `cards` is empty there is no active drag. When non-empty, the listed
|
||||
/// cards are being moved by the user and should be rendered at the cursor or
|
||||
/// touch position.
|
||||
///
|
||||
/// # Drag threshold
|
||||
///
|
||||
/// A drag is *pending* when `!cards.is_empty() && !committed`. The drag does
|
||||
/// not become *committed* (cards do not visually move) until the pointer has
|
||||
/// moved at least `AnimationTuning::drag_threshold_px` pixels from `press_pos`.
|
||||
/// This prevents accidental drags on quick taps, especially on touch screens.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct DragState {
|
||||
/// IDs of the cards being dragged (bottom-to-top stacking order).
|
||||
pub cards: Vec<u32>,
|
||||
/// Pile the drag originated from.
|
||||
pub origin_pile: Option<PileType>,
|
||||
/// World-space offset from the cursor/touch to the bottom card's centre.
|
||||
pub cursor_offset: Vec2,
|
||||
/// Z coordinate used for the dragged cards.
|
||||
pub origin_z: f32,
|
||||
/// Screen-space position (logical pixels) where the press/touch began.
|
||||
///
|
||||
/// Used to measure whether the drag threshold has been crossed.
|
||||
pub press_pos: Vec2,
|
||||
/// Whether the drag threshold has been crossed and visual drag is active.
|
||||
///
|
||||
/// Cards are only lifted and repositioned once `committed = true`.
|
||||
pub committed: bool,
|
||||
/// Touch ID driving this drag, or `None` for a mouse drag.
|
||||
pub active_touch_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for DragState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cards: Vec::new(),
|
||||
origin_pile: None,
|
||||
cursor_offset: Vec2::ZERO,
|
||||
origin_z: 0.0,
|
||||
press_pos: Vec2::ZERO,
|
||||
committed: false,
|
||||
active_touch_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DragState {
|
||||
/// Returns true when no drag is currently in progress.
|
||||
/// Returns `true` when no drag (pending or committed) is in progress.
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.cards.is_empty()
|
||||
}
|
||||
|
||||
/// Clears the drag state.
|
||||
/// Returns `true` when a drag has been committed (cards are visually lifted).
|
||||
pub fn is_committed(&self) -> bool {
|
||||
self.committed
|
||||
}
|
||||
|
||||
/// Resets all drag state to the idle/default values.
|
||||
pub fn clear(&mut self) {
|
||||
self.cards.clear();
|
||||
self.origin_pile = None;
|
||||
self.cursor_offset = Vec2::ZERO;
|
||||
self.origin_z = 0.0;
|
||||
self.press_pos = Vec2::ZERO;
|
||||
self.committed = false;
|
||||
self.active_touch_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct TimeAttackEndedEvent {
|
||||
pub wins: u32,
|
||||
}
|
||||
|
||||
/// Implements the 10-minute Time Attack mode: counts down the session timer, tracks wins per session, and fires `TimeAttackEndedEvent` when time expires.
|
||||
pub struct TimeAttackPlugin;
|
||||
|
||||
impl Plugin for TimeAttackPlugin {
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct WeeklyGoalCompletedEvent {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Tracks weekly goal progress (e.g. win N games, play without undo) and fires `WeeklyGoalCompletedEvent` when a goal is met.
|
||||
/// Progress resets each Monday.
|
||||
pub struct WeeklyGoalsPlugin;
|
||||
|
||||
impl Plugin for WeeklyGoalsPlugin {
|
||||
|
||||
Reference in New Issue
Block a user