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:
funman300
2026-04-28 22:02:52 +00:00
parent 71c0c273a1
commit ffc79447d4
32 changed files with 1824 additions and 244 deletions
+8 -5
View File
@@ -1,11 +1,11 @@
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{ use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin, AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
}; };
fn main() { fn main() {
@@ -32,8 +32,10 @@ fn main() {
.add_plugins(CardPlugin) .add_plugins(CardPlugin)
.add_plugins(CursorPlugin) .add_plugins(CursorPlugin)
.add_plugins(InputPlugin) .add_plugins(InputPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin) .add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(StatsPlugin::default()) .add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default()) .add_plugins(ProgressPlugin::default())
@@ -52,5 +54,6 @@ fn main() {
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin) .add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.run(); .run();
} }
+115 -3
View File
@@ -12,20 +12,25 @@
/// `StatsSnapshot`, the final `GameState`, and wall-clock time. /// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AchievementContext { pub struct AchievementContext {
// Stats (after this win has been recorded). /// Total number of games played (after this win has been recorded).
pub games_played: u32, pub games_played: u32,
/// Total number of games won (after this win has been recorded).
pub games_won: u32, pub games_won: u32,
/// Current consecutive win streak (after this win has been recorded).
pub win_streak_current: u32, pub win_streak_current: u32,
/// Highest single-game score ever achieved.
pub best_single_score: u32, pub best_single_score: u32,
/// Cumulative score across all games ever played.
pub lifetime_score: u64, pub lifetime_score: u64,
/// Total wins completed in Draw 3 mode.
pub draw_three_wins: u32, pub draw_three_wins: u32,
// Progression.
/// Current daily-challenge completion streak (consecutive days). /// Current daily-challenge completion streak (consecutive days).
pub daily_challenge_streak: u32, pub daily_challenge_streak: u32,
// Last-win facts (GameWonEvent + GameState at win time). /// Score achieved in the just-won game.
pub last_win_score: i32, pub last_win_score: i32,
/// Elapsed seconds for the just-won game.
pub last_win_time_seconds: u64, pub last_win_time_seconds: u64,
/// `true` if `undo()` was called at least once during the won game. /// `true` if `undo()` was called at least once during the won game.
pub last_win_used_undo: bool, pub last_win_used_undo: bool,
@@ -55,13 +60,17 @@ pub enum Reward {
/// A single achievement's static metadata + unlock condition. /// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct AchievementDef { pub struct AchievementDef {
/// Unique string identifier for this achievement (e.g. `"first_win"`).
pub id: &'static str, pub id: &'static str,
/// Human-readable display name shown in the achievements screen.
pub name: &'static str, pub name: &'static str,
/// Flavour text describing how to unlock the achievement.
pub description: &'static str, pub description: &'static str,
/// Hidden from the achievements screen until unlocked. /// Hidden from the achievements screen until unlocked.
pub secret: bool, pub secret: bool,
/// Reward granted on first unlock. `None` for cosmetic-only recognition. /// Reward granted on first unlock. `None` for cosmetic-only recognition.
pub reward: Option<Reward>, pub reward: Option<Reward>,
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
pub condition: fn(&AchievementContext) -> bool, pub condition: fn(&AchievementContext) -> bool,
} }
@@ -477,6 +486,109 @@ mod tests {
assert!(achievement_by_id("nonexistent").is_none()); assert!(achievement_by_id("nonexistent").is_none());
} }
// -----------------------------------------------------------------------
// Direct predicate tests via ctx_defaults()
// -----------------------------------------------------------------------
/// Baseline context representing a single clean one-minute win in Draw-One mode.
fn ctx_defaults() -> AchievementContext {
AchievementContext {
games_played: 1,
games_won: 1,
win_streak_current: 1,
best_single_score: 0,
lifetime_score: 0,
draw_three_wins: 0,
daily_challenge_streak: 0,
last_win_score: 0,
last_win_time_seconds: 600,
last_win_used_undo: false,
wall_clock_hour: Some(12),
last_win_recycle_count: 0,
last_win_is_zen: false,
}
}
#[test]
fn speed_demon_true_when_under_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
}
#[test]
fn speed_demon_false_when_over_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 181;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
}
#[test]
fn lightning_true_when_under_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 89;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
}
#[test]
fn lightning_false_at_exactly_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
}
#[test]
fn no_undo_true_when_zero_undos() {
let mut c = ctx_defaults();
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
}
#[test]
fn no_undo_false_when_undo_used() {
let mut c = ctx_defaults();
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
}
#[test]
fn high_scorer_true_when_score_5000_or_more() {
let mut c = ctx_defaults();
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
}
#[test]
fn high_scorer_false_when_below_5000() {
let mut c = ctx_defaults();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
}
#[test]
fn on_a_roll_true_at_streak_3() {
let mut c = ctx_defaults();
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
}
#[test]
fn comeback_true_when_three_or_more_recycles() {
let mut c = ctx_defaults();
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
}
#[test] #[test]
fn on_a_roll_requires_streak_of_3() { fn on_a_roll_requires_streak_of_3() {
let mut c = ctx(); let mut c = ctx();
+4
View File
@@ -63,9 +63,13 @@ impl Rank {
/// A single playing card. /// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Card { pub struct Card {
/// Unique identifier for this card within the deal. Stable across moves and undo.
pub id: u32, pub id: u32,
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
pub suit: Suit, pub suit: Suit,
/// The card's rank (Ace through King).
pub rank: Rank, pub rank: Rank,
/// Whether the card is visible to the player. Face-down cards may not be moved.
pub face_up: bool, pub face_up: bool,
} }
+1
View File
@@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [
/// A standard 52-card deck. /// A standard 52-card deck.
pub struct Deck { pub struct Deck {
/// All 52 cards in the deck, in deal order.
pub cards: Vec<Card>, pub cards: Vec<Card>,
} }
+27
View File
@@ -64,18 +64,26 @@ struct StateSnapshot {
/// Full state of an in-progress Klondike Solitaire game. /// Full state of an in-progress Klondike Solitaire game.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameState { pub struct GameState {
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
#[serde(with = "pile_map_serde")] #[serde(with = "pile_map_serde")]
pub piles: HashMap<PileType, Pile>, pub piles: HashMap<PileType, Pile>,
/// Whether the player draws one or three cards from the stock per turn.
pub draw_mode: DrawMode, pub draw_mode: DrawMode,
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards /// Top-level mode (Classic / Zen). Defaults to Classic for backwards
/// compatibility with older save files via `#[serde(default)]`. /// compatibility with older save files via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub mode: GameMode, pub mode: GameMode,
/// Current game score. Can be negative (undo penalties subtract from score).
pub score: i32, pub score: i32,
/// Total moves made this game, including draws and stock recycles.
pub move_count: u32, pub move_count: u32,
/// Seconds elapsed since the game started, used for time-bonus scoring.
pub elapsed_seconds: u64, pub elapsed_seconds: u64,
/// RNG seed used to deal this game. Same seed always produces the same layout.
pub seed: u64, pub seed: u64,
/// True once all 52 cards are on the foundations. No further moves are accepted.
pub is_won: bool, pub is_won: bool,
/// True when the game can be completed without further input (all remaining cards are face-up and in order).
pub is_auto_completable: bool, pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game. /// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`. /// Used by achievement conditions like `no_undo`.
@@ -173,6 +181,7 @@ impl GameState {
stock.cards.push(card); stock.cards.push(card);
} }
self.recycle_count = self.recycle_count.saturating_add(1); self.recycle_count = self.recycle_count.saturating_add(1);
self.move_count += 1;
return Ok(()); return Ok(());
} }
@@ -562,6 +571,24 @@ mod tests {
assert_eq!(g.recycle_count, 2); assert_eq!(g.recycle_count, 2);
} }
#[test]
fn move_count_increments_on_recycle() {
let mut g = new_game();
// Drain stock to waste, recording how many draws it took.
let mut draws: u32 = 0;
while !g.piles[&PileType::Stock].cards.is_empty() {
g.draw().unwrap();
draws += 1;
}
let before = g.move_count;
g.draw().unwrap(); // recycle
assert_eq!(
g.move_count,
before + 1,
"recycling waste back to stock must increment move_count (was {before}, draws={draws})"
);
}
#[test] #[test]
fn draw_from_empty_stock_and_waste_returns_error() { fn draw_from_empty_stock_and_waste_returns_error() {
// The only stop condition for draw() is: both stock AND waste are // The only stop condition for draw() is: both stock AND waste are
+2
View File
@@ -17,7 +17,9 @@ pub enum PileType {
/// A named collection of cards in a specific board position. /// A named collection of cards in a specific board position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pile { pub struct Pile {
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
pub pile_type: PileType, pub pile_type: PileType,
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
pub cards: Vec<Card>, pub cards: Vec<Card>,
} }
+1 -1
View File
@@ -13,7 +13,7 @@ pub use solitaire_sync::StatsSnapshot;
/// ///
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`. /// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
pub trait StatsExt { pub trait StatsExt {
/// Record a completed win. Updates all relevant counters and rolling averages. /// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode); fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
} }
+3 -3
View File
@@ -9,7 +9,7 @@ use solitaire_core::game_state::DrawMode;
/// XP awarded each time a weekly goal is just completed. /// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75; pub const WEEKLY_GOAL_XP: u64 = 75;
/// What kind of game outcome counts as progress toward this goal. /// Discriminant for the type of weekly goal the player is working toward.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeeklyGoalKind { pub enum WeeklyGoalKind {
/// Any win counts. /// Any win counts.
@@ -22,7 +22,7 @@ pub enum WeeklyGoalKind {
WinDrawThree, WinDrawThree,
} }
/// Static metadata for a single weekly goal. /// Static definition of a weekly goal — the goal type, target value, and display strings.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct WeeklyGoalDef { pub struct WeeklyGoalDef {
pub id: &'static str, pub id: &'static str,
@@ -31,7 +31,7 @@ pub struct WeeklyGoalDef {
pub kind: WeeklyGoalKind, pub kind: WeeklyGoalKind,
} }
/// Per-event facts a goal needs to decide whether it matched. /// Runtime snapshot of game metrics used to evaluate weekly goal progress.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WeeklyGoalContext { pub struct WeeklyGoalContext {
pub time_seconds: u64, pub time_seconds: u64,
+1
View File
@@ -149,6 +149,7 @@ pub struct ActiveToast {
/// Duration of each queued info-toast in seconds. /// Duration of each queued info-toast in seconds.
const QUEUED_TOAST_SECS: f32 = 2.5; 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; pub struct AnimationPlugin;
impl Plugin for AnimationPlugin { impl Plugin for AnimationPlugin {
+1
View File
@@ -97,6 +97,7 @@ pub struct MuteState {
pub music_muted: bool, 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; pub struct AudioPlugin;
impl Plugin for AudioPlugin { impl Plugin for AudioPlugin {
@@ -140,10 +140,22 @@ impl CardAnimation {
/// Redirects a card to a new destination without snapping or interrupting motion. /// Redirects a card to a new destination without snapping or interrupting motion.
/// ///
/// Reads the card's current interpolated position (from a live `CardAnimation` if /// Reads the card's current interpolated position (from a live [`CardAnimation`]
/// present, or from `Transform` if the card is stationary) and starts a fresh /// if present, or from `Transform` if stationary) and starts a fresh
/// `CardAnimation` from that position. Duration is recalculated from the remaining /// [`CardAnimation`] from that position. Duration is recalculated from the
/// distance so short remaining paths feel appropriately quick. /// 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 /// # Example
/// ///
@@ -169,17 +181,29 @@ pub fn retarget_animation(
new_end_z: f32, new_end_z: f32,
curve: MotionCurve, curve: MotionCurve,
) { ) {
let (current_xy, current_z) = match current_anim { let (current_xy, current_z, momentum_carry) = match current_anim {
Some(anim) => (anim.current_xy(), transform.translation.z), Some(anim) if anim.duration > 0.0 => {
None => (transform.translation.truncate(), transform.translation.z), // 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 distance = current_xy.distance(new_end);
let duration = compute_duration(distance);
commands.entity(entity).insert(CardAnimation { commands.entity(entity).insert(CardAnimation {
start: current_xy, start: current_xy,
end: new_end, end: new_end,
elapsed: 0.0, // Start slightly into the new animation to carry forward momentum.
duration: compute_duration(distance), elapsed: momentum_carry * duration,
duration,
curve, curve,
delay: 0.0, delay: 0.0,
start_z: current_z, 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 bevy::window::PrimaryWindow;
use super::animation::CardAnimation; use super::animation::CardAnimation;
use super::tuning::AnimationTuning;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
@@ -48,15 +49,6 @@ type CardTransformQuery<'w, 's> =
// Constants // 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. /// Lerp speed for drag scale interpolation.
const DRAG_LERP_SPEED: f32 = 20.0; 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. /// 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 /// Only runs on cards that have **no active [`CardAnimation`]** — animated
/// cards control their own scale. When hover changes entities, the previous /// 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 /// entity's scale is snapped back to 1.0 to avoid leaving a permanently
/// enlarged card. /// enlarged card.
pub(crate) fn apply_hover_scale( pub(crate) fn apply_hover_scale(
time: Res<Time>, time: Res<Time>,
tuning: Res<AnimationTuning>,
mut hover_state: ResMut<HoverState>, mut hover_state: ResMut<HoverState>,
mut cards: CardTransformQuery, mut cards: CardTransformQuery,
) { ) {
let dt = time.delta_secs(); let dt = time.delta_secs();
let target_entity = hover_state.entity; 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 { for (entity, mut transform) in &mut cards {
let target_scale = if Some(entity) == target_entity { let target_scale = if Some(entity) == target_entity {
HOVER_SCALE hover_target
} else { } else {
1.0 1.0
}; };
let current = transform.scale.x; 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); transform.scale = Vec3::splat(new_scale);
} }
@@ -191,29 +190,36 @@ pub(crate) fn apply_hover_scale(
cards cards
.get(entity) .get(entity)
.map(|(_, t)| t.scale.x) .map(|(_, t)| t.scale.x)
.unwrap_or(HOVER_SCALE) .unwrap_or(hover_target)
} else { } else {
1.0 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** /// Uses [`AnimationTuning`] for the platform-correct drag scale. Only applies
/// modify `translation.xy` — the existing `InputPlugin` owns drag translation. /// to cards whose drag has been *committed* (threshold crossed); cards in the
/// Only writes `scale` and `translation.z` so the two systems are disjoint. /// pending-drag state stay at scale 1.0. Does **not** modify `translation.xy`
/// — `InputPlugin` owns drag translation.
pub(crate) fn apply_drag_visual( pub(crate) fn apply_drag_visual(
time: Res<Time>, time: Res<Time>,
drag: Option<Res<DragState>>, drag: Option<Res<DragState>>,
tuning: Res<AnimationTuning>,
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>, mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
) { ) {
let dt = time.delta_secs(); let dt = time.delta_secs();
let empty: Vec<u32> = Vec::new(); let drag_scale = tuning.drag_scale;
let dragged_ids: &[u32] = drag.as_ref().map_or(empty.as_slice(), |d| &d.cards);
// 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 { for (_, card, mut transform) in &mut cards {
let is_dragged = dragged_ids.contains(&card.card_id); let is_active_drag = committed && dragged_ids.contains(&card.card_id);
let target_scale = if is_dragged { DRAG_LIFT_SCALE } else { 1.0 }; let target_scale = if is_active_drag { drag_scale } else { 1.0 };
let current = transform.scale.x; let current = transform.scale.x;
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0); let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
transform.scale = Vec3::splat(new_scale); transform.scale = Vec3::splat(new_scale);
+26 -5
View File
@@ -73,17 +73,23 @@
//! | `apply_drag_visual` scale + `CardAnimation` scale | ✓ (same filter) | //! | `apply_drag_visual` scale + `CardAnimation` scale | ✓ (same filter) |
pub mod animation; pub mod animation;
pub mod chain;
pub mod curves; pub mod curves;
pub mod diagnostics;
pub mod interaction; pub mod interaction;
pub mod timing; pub mod timing;
pub mod tuning;
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation}; pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
pub use chain::AnimationChain;
pub use curves::{sample_curve, MotionCurve}; pub use curves::{sample_curve, MotionCurve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
pub use interaction::{BufferedInput, HoverState, InputBuffer}; pub use interaction::{BufferedInput, HoverState, InputBuffer};
pub use timing::{ pub use timing::{
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS, cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
}; };
pub use tuning::{AnimationTuning, InputPlatform};
use bevy::prelude::*; use bevy::prelude::*;
@@ -94,14 +100,18 @@ use crate::layout::LayoutResource;
use crate::resources::DragState; use crate::resources::DragState;
use animation::advance_card_animations; 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 interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
use tuning::update_input_platform;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Registers all systems, resources, and components for curve-based card /// 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 /// Safe to register alongside `AnimationPlugin` and `FeedbackAnimPlugin` as
/// long as no single entity carries both `CardAnim` and `CardAnimation`. /// long as no single entity carries both `CardAnim` and `CardAnimation`.
@@ -109,8 +119,8 @@ pub struct CardAnimationPlugin;
impl Plugin for CardAnimationPlugin { impl Plugin for CardAnimationPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
// Register events and resources that interaction systems depend on, // Register events and resources idempotently — double-registration is
// idempotently — double-registration is safe in Bevy. // safe in Bevy.
app.add_message::<MoveRequestEvent>() app.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>() .add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>() .add_message::<UndoRequestEvent>()
@@ -118,12 +128,23 @@ impl Plugin for CardAnimationPlugin {
.init_resource::<DragState>() .init_resource::<DragState>()
.init_resource::<HoverState>() .init_resource::<HoverState>()
.init_resource::<InputBuffer>() .init_resource::<InputBuffer>()
// Platform-adaptive tuning (desktop by default, switches on touch).
.init_resource::<AnimationTuning>()
// Rolling frame-time statistics.
.init_resource::<FrameTimeDiagnostics>()
.add_systems( .add_systems(
Update, 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, 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, detect_hover,
apply_hover_scale, apply_hover_scale,
apply_drag_visual, 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);
}
}
+2
View File
@@ -24,6 +24,8 @@ pub struct ChallengeAdvancedEvent {
pub new_index: u32, 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; pub struct ChallengePlugin;
impl Plugin for ChallengePlugin { impl Plugin for ChallengePlugin {
+1
View File
@@ -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. /// 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); 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; pub struct CursorPlugin;
impl Plugin for CursorPlugin { impl Plugin for CursorPlugin {
@@ -71,6 +71,8 @@ pub struct DailyChallengeCompletedEvent {
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>); 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; pub struct DailyChallengePlugin;
impl Plugin for DailyChallengePlugin { impl Plugin for DailyChallengePlugin {
+2
View File
@@ -9,6 +9,8 @@ use bevy::prelude::*;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HelpScreen; 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; pub struct HelpPlugin;
impl Plugin for HelpPlugin { impl Plugin for HelpPlugin {
+1
View File
@@ -86,6 +86,7 @@ pub struct HudSelection;
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens. /// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
const Z_HUD: i32 = 50; 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; pub struct HudPlugin;
impl Plugin for HudPlugin { impl Plugin for HudPlugin {
+588 -127
View File
@@ -19,6 +19,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use bevy::ecs::system::SystemParam; use bevy::ecs::system::SystemParam;
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::math::{Vec2, Vec3}; use bevy::math::{Vec2, Vec3};
use bevy::prelude::*; use bevy::prelude::*;
@@ -28,6 +29,7 @@ use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_animation::tuning::AnimationTuning;
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FAN_FRAC}; use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, TABLEAU_FAN_FRAC};
use crate::feedback_anim_plugin::ShakeAnim; use crate::feedback_anim_plugin::ShakeAnim;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
@@ -46,18 +48,40 @@ use crate::time_attack_plugin::TimeAttackResource;
/// Z-depth used for cards while being dragged — above all resting cards. /// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0; const DRAG_Z: f32 = 500.0;
/// Registers keyboard and mouse input systems. /// Shared countdown timers for the double-press confirmation flows.
/// ///
/// Drag systems run in a fixed order each frame: /// Using a resource (instead of `Local`) lets the three keyboard sub-systems
/// `start_drag` → `follow_drag` → `end_drag`, with `follow_drag` after the /// share the same countdown state without needing to pass values between them.
/// card-position sync so it overrides resting positions for cards being #[derive(Resource, Debug, Default)]
/// dragged. `end_drag` runs before `GameMutation` so the `MoveRequestEvent` struct KeyboardConfirmState {
/// it fires is consumed the same frame. /// Seconds remaining in the new-game confirmation window (> 0 while open).
new_game_countdown: f32,
/// True while we are waiting for the second N press to confirm a new game.
new_game_pending: bool,
/// Seconds remaining in the forfeit confirmation window (> 0 while open).
forfeit_countdown: f32,
}
/// Registers keyboard, mouse, and touch input systems.
///
/// Mouse drag pipeline (ordered, left-to-right):
/// `start_drag` → `follow_drag` → `end_drag`
///
/// Touch drag pipeline (ordered, interleaved with mouse):
/// `touch_start_drag` → `touch_follow_drag` → `touch_end_drag`
///
/// Both pipelines share [`DragState`]. Only one can be active at a time —
/// the second checks `drag.is_idle()` before proceeding, and mouse drags
/// check `drag.active_touch_id.is_none()`.
///
/// All drag systems run before [`GameMutation`] so move events are consumed
/// in the same frame they are emitted.
pub struct InputPlugin; pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<HintCycleIndex>() app.init_resource::<HintCycleIndex>()
.init_resource::<KeyboardConfirmState>()
.add_message::<NewGameConfirmEvent>() .add_message::<NewGameConfirmEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
@@ -65,12 +89,20 @@ impl Plugin for InputPlugin {
.add_systems( .add_systems(
Update, Update,
( (
handle_keyboard, handle_keyboard_core,
handle_keyboard_hint,
handle_keyboard_forfeit,
handle_stock_click, handle_stock_click,
handle_touch_stock_tap,
handle_double_click, handle_double_click,
// Mouse drag pipeline.
start_drag, start_drag,
follow_drag, follow_drag,
end_drag.before(GameMutation), end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState).
touch_start_drag,
touch_follow_drag,
touch_end_drag.before(GameMutation),
) )
.chain(), .chain(),
) )
@@ -85,64 +117,60 @@ const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
/// Seconds after the first G press during which a second G confirms forfeit. /// Seconds after the first G press during which a second G confirms forfeit.
const FORFEIT_CONFIRM_WINDOW: f32 = 3.0; const FORFEIT_CONFIRM_WINDOW: f32 = 3.0;
/// Bundles all event writers used by `handle_keyboard` so the system stays /// Bundles the event writers needed by the core keyboard handler.
/// within Bevy's 16-parameter limit. ///
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
#[derive(SystemParam)] #[derive(SystemParam)]
struct KeyboardMessages<'w> { struct CoreKeyboardMessages<'w> {
undo: MessageWriter<'w, UndoRequestEvent>, undo: MessageWriter<'w, UndoRequestEvent>,
new_game: MessageWriter<'w, NewGameRequestEvent>, new_game: MessageWriter<'w, NewGameRequestEvent>,
confirm_event: MessageWriter<'w, NewGameConfirmEvent>, confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
info_toast: MessageWriter<'w, InfoToastEvent>, info_toast: MessageWriter<'w, InfoToastEvent>,
draw: MessageWriter<'w, DrawRequestEvent>, draw: MessageWriter<'w, DrawRequestEvent>,
forfeit: MessageWriter<'w, ForfeitEvent>,
hint_visual: MessageWriter<'w, HintVisualEvent>,
} }
#[allow(clippy::too_many_arguments)] /// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
fn handle_keyboard( /// window), Z (zen mode), D / Space (draw), and ticks down the new-game
/// confirmation countdown each frame.
///
/// Also resets `forfeit_countdown` whenever U, D, Z, or N are pressed so that
/// an in-flight forfeit confirmation is cancelled by any other action.
fn handle_keyboard_core(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
game: Option<Res<GameStateResource>>, game: Option<Res<GameStateResource>>,
time: Res<Time>, time: Res<Time>,
mut confirm_countdown: Local<f32>, mut confirm: ResMut<KeyboardConfirmState>,
mut confirm_pending: Local<bool>, mut ev: CoreKeyboardMessages<'_>,
mut forfeit_countdown: Local<f32>,
mut ev: KeyboardMessages<'_>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
layout: Option<Res<LayoutResource>>,
mut hint_cycle: ResMut<HintCycleIndex>,
mut time_attack: Option<ResMut<TimeAttackResource>>, mut time_attack: Option<ResMut<TimeAttackResource>>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Tick down any active confirmation window.
if *confirm_countdown > 0.0 { // Tick down the new-game confirmation window each frame.
*confirm_countdown -= time.delta_secs(); if confirm.new_game_countdown > 0.0 {
if *confirm_countdown <= 0.0 { confirm.new_game_countdown -= time.delta_secs();
*confirm_countdown = 0.0; if confirm.new_game_countdown <= 0.0 {
// Countdown expired without a second N press — notify the player. confirm.new_game_countdown = 0.0;
if *confirm_pending { if confirm.new_game_pending {
*confirm_pending = false; confirm.new_game_pending = false;
ev.info_toast.write(InfoToastEvent("New game cancelled".to_string())); ev.info_toast.write(InfoToastEvent("New game cancelled".to_string()));
} }
} }
} }
// Tick down the forfeit confirmation window.
if *forfeit_countdown > 0.0 {
*forfeit_countdown -= time.delta_secs();
if *forfeit_countdown <= 0.0 {
*forfeit_countdown = 0.0;
}
}
if keys.just_pressed(KeyCode::KeyU) { if keys.just_pressed(KeyCode::KeyU) {
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; } // Cancel any pending forfeit when the player takes another action.
confirm.forfeit_countdown = 0.0;
ev.undo.write(UndoRequestEvent); ev.undo.write(UndoRequestEvent);
} }
if keys.just_pressed(KeyCode::KeyN) { if keys.just_pressed(KeyCode::KeyN) {
// Cancel any pending forfeit when the player takes another action.
confirm.forfeit_countdown = 0.0;
// If a Time Attack session is running, cancel it and start a Classic game. // If a Time Attack session is running, cancel it and start a Classic game.
if let Some(ref mut session) = time_attack { if let Some(ref mut session) = time_attack {
if session.active { if session.active {
@@ -153,7 +181,7 @@ fn handle_keyboard(
seed: None, seed: None,
mode: Some(solitaire_core::game_state::GameMode::Classic), mode: Some(solitaire_core::game_state::GameMode::Classic),
}); });
*confirm_countdown = 0.0; confirm.new_game_countdown = 0.0;
return; return;
} }
} }
@@ -163,22 +191,24 @@ fn handle_keyboard(
if shift_held || !active_game { if shift_held || !active_game {
// Shift+N or no active game — start immediately, no confirmation. // Shift+N or no active game — start immediately, no confirmation.
ev.new_game.write(NewGameRequestEvent::default()); ev.new_game.write(NewGameRequestEvent::default());
*confirm_countdown = 0.0; confirm.new_game_countdown = 0.0;
*confirm_pending = false; confirm.new_game_pending = false;
} else if *confirm_countdown > 0.0 { } else if confirm.new_game_countdown > 0.0 {
// Second press within the window — confirmed. // Second press within the window — confirmed.
ev.new_game.write(NewGameRequestEvent::default()); ev.new_game.write(NewGameRequestEvent::default());
*confirm_countdown = 0.0; confirm.new_game_countdown = 0.0;
*confirm_pending = false; confirm.new_game_pending = false;
} else { } else {
// First press on an active game — require confirmation. // First press on an active game — require confirmation.
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW; confirm.new_game_countdown = NEW_GAME_CONFIRM_WINDOW;
*confirm_pending = true; confirm.new_game_pending = true;
ev.confirm_event.write(NewGameConfirmEvent); ev.confirm_event.write(NewGameConfirmEvent);
} }
} }
if keys.just_pressed(KeyCode::KeyZ) { if keys.just_pressed(KeyCode::KeyZ) {
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; } // Cancel any pending forfeit when the player takes another action.
confirm.forfeit_countdown = 0.0;
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL. // Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
// X is gated separately by ChallengePlugin. // X is gated separately by ChallengePlugin.
let level = progress.as_ref().map_or(0, |p| p.0.level); let level = progress.as_ref().map_or(0, |p| p.0.level);
@@ -193,29 +223,63 @@ fn handle_keyboard(
))); )));
} }
} }
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) { if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) {
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; } // Cancel any pending forfeit when the player takes another action.
confirm.forfeit_countdown = 0.0;
ev.draw.write(DrawRequestEvent); ev.draw.write(DrawRequestEvent);
} }
// H — cycle through all available hints on each press, highlighting the // Esc is handled by `PausePlugin` (overlay toggle + paused flag).
// source card yellow for 1.5 s. The index wraps around once all hints have }
// been shown. When no moves are available a toast is shown instead.
if keys.just_pressed(KeyCode::KeyH) { /// Handles the H key: cycles through all available hints, highlighting the
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; } /// source card yellow for 2 s and showing a descriptive toast. Resets the
if let Some(ref g) = game { /// forfeit countdown on each press.
///
/// The hint index wraps around once all hints have been cycled through. When no
/// moves are available a "No hints available" toast is shown instead.
fn handle_keyboard_hint(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
mut confirm: ResMut<KeyboardConfirmState>,
mut hint_cycle: ResMut<HintCycleIndex>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut hint_visual: MessageWriter<HintVisualEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
if !keys.just_pressed(KeyCode::KeyH) {
return;
}
// H cancels any in-flight forfeit confirmation.
confirm.forfeit_countdown = 0.0;
let Some(ref g) = game else { return };
if g.0.is_won { if g.0.is_won {
ev.info_toast.write(InfoToastEvent( info_toast.write(InfoToastEvent("Game won! Press N for a new game".to_string()));
"Game won! Press N for a new game".to_string(), return;
)); }
} else if let Some(ref layout_res) = layout {
let Some(ref layout_res) = layout else { return };
let hints = all_hints(&g.0); let hints = all_hints(&g.0);
if hints.is_empty() { if hints.is_empty() {
ev.info_toast.write(InfoToastEvent("No hints available".to_string())); info_toast.write(InfoToastEvent("No hints available".to_string()));
} else { return;
}
// Pick the hint at the current cycle index (wrapping) and advance. // Pick the hint at the current cycle index (wrapping) and advance.
let idx = hint_cycle.0 % hints.len(); let idx = hint_cycle.0 % hints.len();
hint_cycle.0 = hint_cycle.0.wrapping_add(1); hint_cycle.0 = hint_cycle.0.wrapping_add(1);
let (from, to, _count) = &hints[idx]; let (from, to, _count) = &hints[idx];
// When the hint points at the stock (draw suggestion) there is no // When the hint points at the stock (draw suggestion) there is no
// face-up card to highlight — show a toast instead. // face-up card to highlight — show a toast instead.
// If the stock is empty, pressing D will recycle the waste rather // If the stock is empty, pressing D will recycle the waste rather
@@ -229,8 +293,10 @@ fn handle_keyboard(
} else { } else {
"Hint: draw from stock (D)".to_string() "Hint: draw from stock (D)".to_string()
}; };
ev.info_toast.write(InfoToastEvent(msg)); info_toast.write(InfoToastEvent(msg));
} else { return;
}
// Find the top face-up card in the source pile and highlight it. // Find the top face-up card in the source pile and highlight it.
let top_card_id = g.0.piles.get(from) let top_card_id = g.0.piles.get(from)
.and_then(|p| p.cards.last().filter(|c| c.face_up)) .and_then(|p| p.cards.last().filter(|c| c.face_up))
@@ -249,15 +315,16 @@ fn handle_keyboard(
break; break;
} }
} }
// Emit HintVisualEvent so the destination pile // Emit HintVisualEvent so the destination pile marker is also
// marker is also tinted gold for 2 s. // tinted gold for 2 s.
ev.hint_visual.write(HintVisualEvent { hint_visual.write(HintVisualEvent {
source_card_id: card_id, source_card_id: card_id,
dest_pile: to.clone(), dest_pile: to.clone(),
}); });
} }
// Fire an informational toast describing where the hinted card
// should move so the player always sees the suggestion in text. // Fire an informational toast describing where the hinted card should
// move so the player always sees the suggestion in text.
let msg = match to { let msg = match to {
PileType::Foundation(suit) => { PileType::Foundation(suit) => {
let suit_name = match suit { let suit_name = match suit {
@@ -268,36 +335,59 @@ fn handle_keyboard(
}; };
format!("Hint: move to {suit_name} foundation") format!("Hint: move to {suit_name} foundation")
} }
PileType::Tableau(col) => { PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1),
format!("Hint: move to tableau (col {})", col + 1)
}
_ => "Hint: move card".to_string(), _ => "Hint: move card".to_string(),
}; };
ev.info_toast.write(InfoToastEvent(msg)); info_toast.write(InfoToastEvent(msg));
}
/// Handles the G key: forfeit the current game with a 3-second double-confirm
/// window to prevent accidental forfeits.
///
/// First press shows a toast and starts the countdown.
/// Second press **within the window** sends [`ForfeitEvent`].
/// Pressing any other key between presses cancels the countdown
/// (handled by [`handle_keyboard_core`]).
fn handle_keyboard_forfeit(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
time: Res<Time>,
game: Option<Res<GameStateResource>>,
mut confirm: ResMut<KeyboardConfirmState>,
mut forfeit: MessageWriter<ForfeitEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Tick down the forfeit confirmation window each frame.
if confirm.forfeit_countdown > 0.0 {
confirm.forfeit_countdown -= time.delta_secs();
if confirm.forfeit_countdown <= 0.0 {
confirm.forfeit_countdown = 0.0;
} }
} }
if !keys.just_pressed(KeyCode::KeyG) {
return;
} }
}
}
// G — forfeit the current game with a 3-second double-confirm window to
// prevent accidental forfeits. First press shows a toast and starts the
// countdown; second press within the window sends the ForfeitEvent.
if keys.just_pressed(KeyCode::KeyG) {
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won); let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
if active_game { if !active_game {
if *forfeit_countdown > 0.0 { return;
}
if confirm.forfeit_countdown > 0.0 {
// Second press within the confirmation window — confirmed. // Second press within the confirmation window — confirmed.
ev.forfeit.write(ForfeitEvent); forfeit.write(ForfeitEvent);
*forfeit_countdown = 0.0; confirm.forfeit_countdown = 0.0;
} else { } else {
// First press — start the countdown and warn the player. // First press — start the countdown and warn the player.
*forfeit_countdown = FORFEIT_CONFIRM_WINDOW; confirm.forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
ev.info_toast.write(InfoToastEvent("Press G again to forfeit".to_string())); info_toast.write(InfoToastEvent("Press G again to forfeit".to_string()));
} }
} }
}
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}
/// Resets [`HintCycleIndex`] to `0` whenever the game state changes or a new /// Resets [`HintCycleIndex`] to `0` whenever the game state changes or a new
/// game is requested so the next H press always starts cycling from the first /// game is requested so the next H press always starts cycling from the first
@@ -370,7 +460,47 @@ fn handle_stock_click(
} }
} }
#[allow(clippy::too_many_arguments)] /// Fires [`DrawRequestEvent`] when the player taps the stock pile on a touch screen.
///
/// Uses `TouchPhase::Started` (the finger-down moment) for instant responsiveness
/// — since the stock cannot be dragged, there is no ambiguity between a tap and
/// the start of a drag on this pile. Does nothing while a drag is in progress.
fn handle_touch_stock_tap(
mut touch_events: EventReader<TouchInput>,
paused: Option<Res<PausedResource>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
drag: Res<DragState>,
mut draw: MessageWriter<DrawRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
if !drag.is_idle() {
return;
}
let Some(layout) = layout else { return };
for event in touch_events.read() {
if event.phase != TouchPhase::Started {
continue;
}
let Some(world) = touch_to_world(&cameras, event.position) else {
continue;
};
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else {
continue;
};
if point_in_rect(world, stock_pos, layout.0.card_size) {
draw.write(DrawRequestEvent);
break; // one draw per tap frame
}
}
}
/// Begins a mouse drag: records the press position and the cards that would be
/// dragged. Cards are **not** elevated yet — that happens in [`follow_drag`]
/// once the drag threshold is crossed.
fn start_drag( fn start_drag(
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
@@ -379,81 +509,98 @@ fn start_drag(
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
mut drag: ResMut<DragState>, mut drag: ResMut<DragState>,
mut card_visuals: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Only start a new drag when idle (no touch drag running either).
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() { if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return; return;
} }
let Some(layout) = layout else { let Some(layout) = layout else { return };
return; let Some(world) = cursor_world(&windows, &cameras) else { return };
};
let Some(world) = cursor_world(&windows, &cameras) else {
return;
};
// Don't try to pick up the stock — that's the draw click. // Don't pick up the stock — that is handled by handle_stock_click.
let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else { let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
return; return;
}; };
let Some(&bottom_id) = card_ids.first() else {
return;
};
// Find the bottom drag card's current world position so we can compute
// the offset between cursor and that card (grab point).
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index); let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
let cursor_offset = bottom_pos - world;
// Elevate dragged cards to DRAG_Z and dim them slightly so the board
// beneath remains visible during the drag.
for (i, id) in card_ids.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) = card_visuals
.iter_mut()
.find(|(entity, _, _)| entity.card_id == *id)
{
transform.translation.z = DRAG_Z + (i as f32) * 0.01;
sprite.color.set_alpha(0.85);
}
}
// Store as a pending drag. We do NOT elevate the cards yet — the visual
// lift happens in follow_drag once the threshold is crossed.
drag.cards = card_ids; drag.cards = card_ids;
drag.origin_pile = Some(pile); drag.origin_pile = Some(pile);
drag.cursor_offset = cursor_offset; drag.cursor_offset = bottom_pos - world;
drag.origin_z = DRAG_Z; drag.origin_z = DRAG_Z;
let _ = bottom_id; // retained for clarity, not used further drag.press_pos = world;
drag.committed = false;
drag.active_touch_id = None;
} }
/// Moves dragged cards with the mouse cursor each frame.
///
/// If the drag has not yet been committed (threshold not crossed), checks
/// whether the cursor has moved far enough from the press position to commit.
/// On commit, cards are elevated to `DRAG_Z` and dimmed. Does nothing for
/// touch-driven drags (`drag.active_touch_id.is_some()`).
#[allow(clippy::too_many_arguments)]
fn follow_drag( fn follow_drag(
windows: Query<&Window, With<PrimaryWindow>>, windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
drag: Res<DragState>, mut drag: ResMut<DragState>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
mut card_transforms: Query<(&CardEntity, &mut Transform)>, tuning: Res<AnimationTuning>,
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
) { ) {
if drag.is_idle() { // Skip if idle or if a touch drag is running.
if drag.is_idle() || drag.active_touch_id.is_some() {
return; return;
} }
let Some(layout) = layout else { let Some(layout) = layout else { return };
return;
};
let Some(world) = cursor_world(&windows, &cameras) else { let Some(world) = cursor_world(&windows, &cameras) else {
// Cursor left the window mid-drag. Cancel a pending drag; let a
// committed drag freeze at the last known position.
if !drag.committed {
drag.clear();
}
return; return;
}; };
// Check drag threshold on the first frames after press.
if !drag.committed {
// Use screen-space distance (world ≈ screen for 2-D games with no
// camera zoom, which is our case).
let moved = world.distance(drag.press_pos);
if moved < tuning.drag_threshold_px {
return; // Still within tap zone — don't start visual drag yet.
}
// Threshold crossed → commit.
drag.committed = true;
// Elevate cards: push to DRAG_Z and dim slightly so the board
// beneath stays readable.
for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) =
card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id)
{
transform.translation.z = DRAG_Z + i as f32 * 0.01;
sprite.color.set_alpha(0.85);
}
}
}
// Move cards to the cursor.
let bottom_pos = world + drag.cursor_offset; let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC; let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
for (i, id) in drag.cards.iter().enumerate() { for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform)) = card_transforms if let Some((_, mut transform, _)) =
.iter_mut() card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id)
.find(|(entity, _)| entity.card_id == *id)
{ {
transform.translation.x = bottom_pos.x; transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * (i as f32); transform.translation.y = bottom_pos.y + fan * i as f32;
} }
} }
} }
@@ -477,9 +624,21 @@ fn end_drag(
drag.clear(); drag.clear();
return; return;
} }
// Only handle mouse releases; touch releases are handled by touch_end_drag.
if !buttons.just_released(MouseButton::Left) || drag.is_idle() { if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
return; return;
} }
if drag.active_touch_id.is_some() {
return; // Touch-driven drag — not ours to handle.
}
// If the drag was never committed (user tapped without moving far enough),
// treat it as a click: just cancel the pending drag and resync card positions.
if !drag.committed {
drag.clear();
changed.write(StateChangedEvent);
return;
}
let Some(layout) = layout else { let Some(layout) = layout else {
return; return;
}; };
@@ -556,6 +715,220 @@ fn end_drag(
let _ = fired; let _ = fired;
} }
// ---------------------------------------------------------------------------
// Touch drag pipeline
// ---------------------------------------------------------------------------
/// Begins a touch drag when a finger first touches a face-up card.
///
/// Mirrors [`start_drag`] but uses [`TouchInput`] events instead of mouse
/// buttons. Records the touch ID in [`DragState`] so only this finger drives
/// the drag — other fingers are ignored.
fn touch_start_drag(
mut touch_events: EventReader<TouchInput>,
paused: Option<Res<PausedResource>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Only one drag at a time.
if !drag.is_idle() {
return;
}
let Some(layout) = layout else { return };
for event in touch_events.read() {
if event.phase != TouchPhase::Started {
continue;
}
let Some(world) = touch_to_world(&cameras, event.position) else {
continue;
};
let Some((pile, stack_index, card_ids)) =
find_draggable_at(world, &game.0, &layout.0)
else {
continue;
};
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
drag.cards = card_ids;
drag.origin_pile = Some(pile);
drag.cursor_offset = bottom_pos - world;
drag.origin_z = DRAG_Z;
drag.press_pos = event.position; // screen-space for threshold comparison
drag.committed = false;
drag.active_touch_id = Some(event.id);
// Process only the first touch that landed on a card.
break;
}
}
/// Moves touch-dragged cards with the active finger each frame.
///
/// Checks the drag threshold on the first frames after the touch began and
/// commits (elevates cards) once exceeded. Does nothing for mouse drags.
#[allow(clippy::too_many_arguments)]
fn touch_follow_drag(
touches: Option<Res<Touches>>,
cameras: Query<(&Camera, &GlobalTransform)>,
mut drag: ResMut<DragState>,
layout: Option<Res<LayoutResource>>,
tuning: Res<AnimationTuning>,
mut card_transforms: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
) {
let Some(active_id) = drag.active_touch_id else {
return; // Mouse drag or idle.
};
let Some(touches) = touches else { return };
let Some(layout) = layout else { return };
// Look up the driving touch.
let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
// Touch no longer active — will be cleaned up by touch_end_drag.
return;
};
let Some(world) = touch_to_world(&cameras, touch.position()) else {
return;
};
if !drag.committed {
// Compare screen-space distance from the original press position.
let moved = touch.position().distance(drag.press_pos);
if moved < tuning.drag_threshold_px {
return;
}
drag.committed = true;
for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, mut sprite)) =
card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id)
{
transform.translation.z = DRAG_Z + i as f32 * 0.01;
sprite.color.set_alpha(0.85);
}
}
}
let bottom_pos = world + drag.cursor_offset;
let fan = -layout.0.card_size.y * TABLEAU_FAN_FRAC;
for (i, &id) in drag.cards.iter().enumerate() {
if let Some((_, mut transform, _)) =
card_transforms.iter_mut().find(|(ce, _, _)| ce.card_id == id)
{
transform.translation.x = bottom_pos.x;
transform.translation.y = bottom_pos.y + fan * i as f32;
}
}
}
/// Resolves a touch drag when the finger lifts or is cancelled.
///
/// Mirrors [`end_drag`] but reads [`TouchInput`] events instead of mouse
/// buttons. Uncommitted drags (tap gestures) are cancelled cleanly.
#[allow(clippy::too_many_arguments)]
fn touch_end_drag(
mut touch_events: EventReader<TouchInput>,
paused: Option<Res<PausedResource>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
mut changed: MessageWriter<StateChangedEvent>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
) {
let Some(active_id) = drag.active_touch_id else {
return; // Mouse drag or idle.
};
if paused.is_some_and(|p| p.0) {
drag.clear();
return;
}
for event in touch_events.read() {
if event.id != active_id {
continue;
}
if !matches!(event.phase, TouchPhase::Ended | TouchPhase::Canceled) {
continue;
}
// Uncommitted tap — cancel cleanly.
if !drag.committed {
drag.clear();
changed.write(StateChangedEvent);
return;
}
let Some(origin) = drag.origin_pile.clone() else {
drag.clear();
return;
};
let count = drag.cards.len();
// Find the drop target using the finger's lift position.
let world = touch_to_world(&cameras, event.position);
let Some(layout) = layout.as_ref() else {
drag.clear();
changed.write(StateChangedEvent);
return;
};
let target =
world.and_then(|w| find_drop_target(w, &game.0, &layout.0, &origin));
let mut fired = false;
if let Some(target) = target {
if target != origin {
let bottom_card_id = drag.cards[0];
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
let ok = match &target {
PileType::Foundation(suit) => {
count == 1
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target], *suit)
}
PileType::Tableau(_) => {
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
}
_ => false,
};
if ok {
moves.write(MoveRequestEvent { from: origin.clone(), to: target, count });
fired = true;
} else {
rejected.write(MoveRejectedEvent { from: origin.clone(), to: target, count });
for &card_id in &drag.cards {
if let Some((entity, _, transform)) =
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
{
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: transform.translation.x,
});
}
}
}
}
}
}
drag.clear();
changed.write(StateChangedEvent);
let _ = fired;
return;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -570,6 +943,18 @@ fn cursor_world(
camera.viewport_to_world_2d(camera_transform, cursor).ok() camera.viewport_to_world_2d(camera_transform, cursor).ok()
} }
/// Converts a touch screen position (logical pixels, top-left origin) to
/// world-space 2-D coordinates using the primary camera.
///
/// Returns `None` if no camera is present or the projection fails.
fn touch_to_world(
cameras: &Query<(&Camera, &GlobalTransform)>,
screen_pos: Vec2,
) -> Option<Vec2> {
let (camera, camera_transform) = cameras.single().ok()?;
camera.viewport_to_world_2d(camera_transform, screen_pos).ok()
}
/// Axis-aligned rectangle hit-test with a center and full size. /// Axis-aligned rectangle hit-test with a center and full size.
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
let half = size / 2.0; let half = size / 2.0;
@@ -1560,6 +1945,82 @@ mod tests {
assert_eq!(forfeit_countdown, 0.0, "countdown must remain 0 when no game is active"); assert_eq!(forfeit_countdown, 0.0, "countdown must remain 0 when no game is active");
} }
// -----------------------------------------------------------------------
// all_hints / new-game window — pure-function tests added during refactor
// -----------------------------------------------------------------------
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
/// are no other moves and the stock is non-empty.
#[test]
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Remove all foundation, tableau, and waste cards so no pile-to-pile
// move exists. Leave one card in the stock.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
// Put one card back into the stock so "draw" is a valid suggestion.
game.piles.get_mut(&PileType::Stock).unwrap().cards.push(Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: false,
});
let hints = all_hints(&game);
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
let (from, to, count) = &hints[0];
assert_eq!(*from, PileType::Stock, "hint must come from Stock");
assert_eq!(*to, PileType::Waste, "hint must point to Waste");
assert_eq!(*count, 1);
}
/// `all_hints` must be empty when both stock and waste are empty and no
/// pile-to-pile move exists — the game is truly stuck.
#[test]
fn all_hints_is_empty_when_truly_stuck() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear every pile, then put a single card that has nowhere to go.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
// Two of Clubs on tableau 0 — can't go to an empty foundation (needs Ace
// first) and can't go to any empty tableau column (not a King).
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 700,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
});
let hints = all_hints(&game);
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
}
/// Const-assert that `NEW_GAME_CONFIRM_WINDOW` is positive so the
/// confirmation countdown actually opens on the first N press.
///
/// Mirrors the existing `forfeit_confirm_window_is_positive` test.
#[test]
fn new_game_confirm_window_is_positive() {
const { assert!(NEW_GAME_CONFIRM_WINDOW > 0.0, "NEW_GAME_CONFIRM_WINDOW must be > 0"); }
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Task #57 — ShakeAnim insertion on rejected drag // Task #57 — ShakeAnim insertion on rejected drag
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -62,6 +62,7 @@ struct OptOutTask(Option<Task<Result<(), String>>>);
// Plugin // 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; pub struct LeaderboardPlugin;
impl Plugin for LeaderboardPlugin { impl Plugin for LeaderboardPlugin {
+3
View File
@@ -48,6 +48,9 @@ pub use card_animation::{
HoverState, InputBuffer, BufferedInput, HoverState, InputBuffer, BufferedInput,
win_scatter_targets, WIN_CASCADE_INTERVAL_SECS, DEAL_INTERVAL_SECS, win_scatter_targets, WIN_CASCADE_INTERVAL_SECS, DEAL_INTERVAL_SECS,
MIN_DURATION_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, MAX_DURATION_SECS,
AnimationChain,
AnimationTuning, InputPlatform,
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
}; };
pub use feedback_anim_plugin::{ pub use feedback_anim_plugin::{
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale, 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. /// 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); 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; pub struct OnboardingPlugin;
impl Plugin for OnboardingPlugin { impl Plugin for OnboardingPlugin {
+1
View File
@@ -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; pub struct PausePlugin;
impl Plugin for PausePlugin { impl Plugin for PausePlugin {
+49 -5
View File
@@ -12,28 +12,72 @@ pub struct GameStateResource(pub GameState);
/// Tracks an in-progress drag operation. /// Tracks an in-progress drag operation.
/// ///
/// When `cards` is empty there is no active drag. When non-empty, the listed cards /// When `cards` is empty there is no active drag. When non-empty, the listed
/// are being moved by the user and should be rendered at the cursor position. /// cards are being moved by the user and should be rendered at the cursor or
#[derive(Resource, Debug, Clone, Default)] /// 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 { pub struct DragState {
/// IDs of the cards being dragged (bottom-to-top stacking order).
pub cards: Vec<u32>, pub cards: Vec<u32>,
/// Pile the drag originated from.
pub origin_pile: Option<PileType>, pub origin_pile: Option<PileType>,
/// World-space offset from the cursor/touch to the bottom card's centre.
pub cursor_offset: Vec2, pub cursor_offset: Vec2,
/// Z coordinate used for the dragged cards.
pub origin_z: f32, 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 { 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 { pub fn is_idle(&self) -> bool {
self.cards.is_empty() 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) { pub fn clear(&mut self) {
self.cards.clear(); self.cards.clear();
self.origin_pile = None; self.origin_pile = None;
self.cursor_offset = Vec2::ZERO; self.cursor_offset = Vec2::ZERO;
self.origin_z = 0.0; 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, 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; pub struct TimeAttackPlugin;
impl Plugin for TimeAttackPlugin { impl Plugin for TimeAttackPlugin {
@@ -21,6 +21,8 @@ pub struct WeeklyGoalCompletedEvent {
pub description: String, 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; pub struct WeeklyGoalsPlugin;
impl Plugin for WeeklyGoalsPlugin { impl Plugin for WeeklyGoalsPlugin {
-4
View File
@@ -30,7 +30,3 @@ dotenvy = { workspace = true }
[dev-dependencies] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
solitaire_sync = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
jsonwebtoken = { workspace = true }
+68
View File
@@ -152,3 +152,71 @@ pub async fn opt_in(
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))
} }
// ---------------------------------------------------------------------------
// Tests — data shape and display-name logic; no database required
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use chrono::Utc;
use solitaire_sync::LeaderboardEntry;
/// Helper that constructs a `LeaderboardEntry` with the given display name
/// and best score. `best_time_secs` is left as `None`.
fn entry(display_name: &str, best_score: Option<i32>) -> LeaderboardEntry {
LeaderboardEntry {
display_name: display_name.to_string(),
best_score,
best_time_secs: None,
recorded_at: Utc::now(),
}
}
// -----------------------------------------------------------------------
// 1. A LeaderboardEntry always carries a non-empty display_name.
// -----------------------------------------------------------------------
#[test]
fn leaderboard_entry_has_display_name() {
let e = entry("Alice", Some(4_500));
assert!(
!e.display_name.is_empty(),
"display_name must not be empty for a valid leaderboard entry"
);
assert_eq!(e.display_name, "Alice");
}
// -----------------------------------------------------------------------
// 2. A Vec of entries sorts by best_score descending (matching the SQL
// ORDER BY used in get_leaderboard).
// -----------------------------------------------------------------------
#[test]
fn leaderboard_entries_sorted_by_score_descending() {
let mut entries = vec![
entry("Charlie", Some(1_200)),
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),
entry("Dave", None), // no score — should rank last
];
// Mirrors the SQL sort:
// CASE WHEN best_score IS NULL THEN 1 ELSE 0 END ASC,
// best_score DESC
entries.sort_by(|a, b| {
let a_null = a.best_score.is_none() as u8;
let b_null = b.best_score.is_none() as u8;
a_null
.cmp(&b_null)
.then_with(|| b.best_score.cmp(&a.best_score))
});
// Scored entries first, in descending order.
assert_eq!(entries[0].display_name, "Alice", "highest scorer must be first");
assert_eq!(entries[1].display_name, "Bob", "second-highest scorer must be second");
assert_eq!(entries[2].display_name, "Charlie", "lowest scorer must be third");
// Null-score entry sinks to the bottom.
assert_eq!(entries[3].display_name, "Dave", "entry with no score must rank last");
}
}
+115
View File
@@ -220,3 +220,118 @@ async fn update_leaderboard_if_opted_in(
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// Tests — pure merge logic; no database required
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use chrono::Utc;
use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, merge};
use uuid::Uuid;
/// Build a minimal `SyncPayload` with default fields, overridden by the
/// caller as needed. Using `Uuid::nil()` keeps every test self-contained.
fn make_payload(stats: StatsSnapshot, achievements: Vec<AchievementRecord>) -> SyncPayload {
SyncPayload {
user_id: Uuid::nil(),
stats,
achievements,
progress: PlayerProgress::default(),
last_modified: Utc::now(),
}
}
fn default_payload() -> SyncPayload {
make_payload(StatsSnapshot::default(), vec![])
}
// -----------------------------------------------------------------------
// 1. Merge keeps the higher games_played from the remote side.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_keeps_higher_games_played() {
let mut local = default_payload();
local.stats.games_played = 10;
let mut remote = default_payload();
remote.stats.games_played = 25; // remote is ahead
let (merged, _) = merge(&local, &remote);
assert_eq!(
merged.stats.games_played, 25,
"merge must keep the higher games_played value from remote"
);
}
// -----------------------------------------------------------------------
// 2. Merge keeps the higher best_single_score from the local side.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_keeps_best_single_score() {
let mut local = default_payload();
local.stats.best_single_score = 8_000; // local is better
let mut remote = default_payload();
remote.stats.best_single_score = 3_500;
let (merged, _) = merge(&local, &remote);
assert_eq!(
merged.stats.best_single_score, 8_000,
"merge must keep the higher best_single_score (local in this case)"
);
}
// -----------------------------------------------------------------------
// 3. Merge never removes an achievement that is unlocked on one side.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_never_removes_unlocked_achievement() {
let mut unlocked = AchievementRecord::locked("first_win");
unlocked.unlock(Utc::now());
// local has the achievement unlocked; remote has no achievements at all.
let local = make_payload(StatsSnapshot::default(), vec![unlocked]);
let remote = make_payload(StatsSnapshot::default(), vec![]);
let (merged, _) = merge(&local, &remote);
let found = merged
.achievements
.iter()
.find(|a| a.id == "first_win")
.expect("achievement must survive the merge");
assert!(
found.unlocked,
"achievement unlocked on local must remain unlocked after merge with remote that lacks it"
);
}
// -----------------------------------------------------------------------
// 4. merge(payload, payload) is idempotent for key numeric fields.
// -----------------------------------------------------------------------
#[test]
fn sync_merge_is_idempotent() {
let mut payload = default_payload();
payload.stats.games_played = 42;
payload.stats.games_won = 20;
payload.stats.best_single_score = 5_500;
payload.stats.fastest_win_seconds = 90;
payload.stats.lifetime_score = 110_000;
payload.progress.total_xp = 3_000;
let (merged, _) = merge(&payload, &payload);
assert_eq!(merged.stats.games_played, 42, "idempotent: games_played");
assert_eq!(merged.stats.games_won, 20, "idempotent: games_won");
assert_eq!(merged.stats.best_single_score, 5_500, "idempotent: best_single_score");
assert_eq!(merged.stats.fastest_win_seconds, 90, "idempotent: fastest_win_seconds");
assert_eq!(merged.stats.lifetime_score, 110_000, "idempotent: lifetime_score");
assert_eq!(merged.progress.total_xp, 3_000, "idempotent: total_xp");
}
}