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:
@@ -1,11 +1,11 @@
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
||||
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
||||
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
|
||||
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
|
||||
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -32,8 +32,10 @@ fn main() {
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
@@ -52,5 +54,6 @@ fn main() {
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -12,20 +12,25 @@
|
||||
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
||||
#[derive(Debug, Clone)]
|
||||
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,
|
||||
/// Total number of games won (after this win has been recorded).
|
||||
pub games_won: u32,
|
||||
/// Current consecutive win streak (after this win has been recorded).
|
||||
pub win_streak_current: u32,
|
||||
/// Highest single-game score ever achieved.
|
||||
pub best_single_score: u32,
|
||||
/// Cumulative score across all games ever played.
|
||||
pub lifetime_score: u64,
|
||||
/// Total wins completed in Draw 3 mode.
|
||||
pub draw_three_wins: u32,
|
||||
|
||||
// Progression.
|
||||
/// Current daily-challenge completion streak (consecutive days).
|
||||
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,
|
||||
/// Elapsed seconds for the just-won game.
|
||||
pub last_win_time_seconds: u64,
|
||||
/// `true` if `undo()` was called at least once during the won game.
|
||||
pub last_win_used_undo: bool,
|
||||
@@ -55,13 +60,17 @@ pub enum Reward {
|
||||
/// A single achievement's static metadata + unlock condition.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AchievementDef {
|
||||
/// Unique string identifier for this achievement (e.g. `"first_win"`).
|
||||
pub id: &'static str,
|
||||
/// Human-readable display name shown in the achievements screen.
|
||||
pub name: &'static str,
|
||||
/// Flavour text describing how to unlock the achievement.
|
||||
pub description: &'static str,
|
||||
/// Hidden from the achievements screen until unlocked.
|
||||
pub secret: bool,
|
||||
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
|
||||
pub reward: Option<Reward>,
|
||||
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
|
||||
pub condition: fn(&AchievementContext) -> bool,
|
||||
}
|
||||
|
||||
@@ -477,6 +486,109 @@ mod tests {
|
||||
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]
|
||||
fn on_a_roll_requires_streak_of_3() {
|
||||
let mut c = ctx();
|
||||
|
||||
@@ -63,9 +63,13 @@ impl Rank {
|
||||
/// A single playing card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
/// Unique identifier for this card within the deal. Stable across moves and undo.
|
||||
pub id: u32,
|
||||
/// The card's suit (Clubs, Diamonds, Hearts, Spades).
|
||||
pub suit: Suit,
|
||||
/// The card's rank (Ace through King).
|
||||
pub rank: Rank,
|
||||
/// Whether the card is visible to the player. Face-down cards may not be moved.
|
||||
pub face_up: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const ALL_RANKS: [Rank; 13] = [
|
||||
|
||||
/// A standard 52-card deck.
|
||||
pub struct Deck {
|
||||
/// All 52 cards in the deck, in deal order.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
|
||||
@@ -64,18 +64,26 @@ struct StateSnapshot {
|
||||
/// Full state of an in-progress Klondike Solitaire game.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GameState {
|
||||
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
|
||||
#[serde(with = "pile_map_serde")]
|
||||
pub piles: HashMap<PileType, Pile>,
|
||||
/// Whether the player draws one or three cards from the stock per turn.
|
||||
pub draw_mode: DrawMode,
|
||||
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
||||
/// compatibility with older save files via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub mode: GameMode,
|
||||
/// Current game score. Can be negative (undo penalties subtract from score).
|
||||
pub score: i32,
|
||||
/// Total moves made this game, including draws and stock recycles.
|
||||
pub move_count: u32,
|
||||
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||||
pub elapsed_seconds: u64,
|
||||
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||||
pub seed: u64,
|
||||
/// True once all 52 cards are on the foundations. No further moves are accepted.
|
||||
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,
|
||||
/// Number of times `undo()` has been successfully invoked this game.
|
||||
/// Used by achievement conditions like `no_undo`.
|
||||
@@ -173,6 +181,7 @@ impl GameState {
|
||||
stock.cards.push(card);
|
||||
}
|
||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||
self.move_count += 1;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -562,6 +571,24 @@ mod tests {
|
||||
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]
|
||||
fn draw_from_empty_stock_and_waste_returns_error() {
|
||||
// The only stop condition for draw() is: both stock AND waste are
|
||||
|
||||
@@ -17,7 +17,9 @@ pub enum PileType {
|
||||
/// A named collection of cards in a specific board position.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Pile {
|
||||
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
|
||||
pub pile_type: PileType,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ pub use solitaire_sync::StatsSnapshot;
|
||||
///
|
||||
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use solitaire_core::game_state::DrawMode;
|
||||
/// XP awarded each time a weekly goal is just completed.
|
||||
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)]
|
||||
pub enum WeeklyGoalKind {
|
||||
/// Any win counts.
|
||||
@@ -22,7 +22,7 @@ pub enum WeeklyGoalKind {
|
||||
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)]
|
||||
pub struct WeeklyGoalDef {
|
||||
pub id: &'static str,
|
||||
@@ -31,7 +31,7 @@ pub struct WeeklyGoalDef {
|
||||
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)]
|
||||
pub struct WeeklyGoalContext {
|
||||
pub time_seconds: u64,
|
||||
|
||||
@@ -149,6 +149,7 @@ pub struct ActiveToast {
|
||||
/// Duration of each queued info-toast in seconds.
|
||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||
|
||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||
pub struct AnimationPlugin;
|
||||
|
||||
impl Plugin for AnimationPlugin {
|
||||
|
||||
@@ -97,6 +97,7 @@ pub struct MuteState {
|
||||
pub music_muted: bool,
|
||||
}
|
||||
|
||||
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
pub struct AudioPlugin;
|
||||
|
||||
impl Plugin for AudioPlugin {
|
||||
|
||||
@@ -140,10 +140,22 @@ impl CardAnimation {
|
||||
|
||||
/// Redirects a card to a new destination without snapping or interrupting motion.
|
||||
///
|
||||
/// Reads the card's current interpolated position (from a live `CardAnimation` if
|
||||
/// present, or from `Transform` if the card is stationary) and starts a fresh
|
||||
/// `CardAnimation` from that position. Duration is recalculated from the remaining
|
||||
/// distance so short remaining paths feel appropriately quick.
|
||||
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
|
||||
/// if present, or from `Transform` if stationary) and starts a fresh
|
||||
/// [`CardAnimation`] from that position. Duration is recalculated from the
|
||||
/// remaining distance so short paths stay quick.
|
||||
///
|
||||
/// # Velocity continuity
|
||||
///
|
||||
/// When a card is mid-flight, the new animation starts with a small positive
|
||||
/// `elapsed` offset (`carry`) derived from how far through the current animation
|
||||
/// the card is. This preserves a sense of forward momentum: the new curve does
|
||||
/// not restart from zero velocity, avoiding a visible "lurch" when the target
|
||||
/// changes rapidly.
|
||||
///
|
||||
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
|
||||
/// never causes a visible position jump — the card's start position is still
|
||||
/// read from the current transform.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
@@ -169,17 +181,29 @@ pub fn retarget_animation(
|
||||
new_end_z: f32,
|
||||
curve: MotionCurve,
|
||||
) {
|
||||
let (current_xy, current_z) = match current_anim {
|
||||
Some(anim) => (anim.current_xy(), transform.translation.z),
|
||||
None => (transform.translation.truncate(), transform.translation.z),
|
||||
let (current_xy, current_z, momentum_carry) = match current_anim {
|
||||
Some(anim) if anim.duration > 0.0 => {
|
||||
// Estimate how far into the current animation we are and carry
|
||||
// a small fraction of that progress into the new animation.
|
||||
// This avoids restarting from zero velocity and makes the motion
|
||||
// feel continuous when the target changes mid-flight.
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
// Cap at 10 % of the new animation so there's no visible jump.
|
||||
let carry = (t * 0.12).min(0.10);
|
||||
(anim.current_xy(), transform.translation.z, carry)
|
||||
}
|
||||
_ => (transform.translation.truncate(), transform.translation.z, 0.0),
|
||||
};
|
||||
|
||||
let distance = current_xy.distance(new_end);
|
||||
let duration = compute_duration(distance);
|
||||
|
||||
commands.entity(entity).insert(CardAnimation {
|
||||
start: current_xy,
|
||||
end: new_end,
|
||||
elapsed: 0.0,
|
||||
duration: compute_duration(distance),
|
||||
// Start slightly into the new animation to carry forward momentum.
|
||||
elapsed: momentum_carry * duration,
|
||||
duration,
|
||||
curve,
|
||||
delay: 0.0,
|
||||
start_z: current_z,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Animation chaining — play a sequence of [`CardAnimation`] segments in order.
|
||||
//!
|
||||
//! Insert [`AnimationChain`] on a card entity alongside the *first* segment as
|
||||
//! a [`CardAnimation`] to sequence multi-step motion. When the active
|
||||
//! [`CardAnimation`] finishes and is removed, [`advance_animation_chains`]
|
||||
//! pops the next segment and inserts it automatically.
|
||||
//!
|
||||
//! # Example — arc then settle
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Arc up to a midpoint, then settle onto the foundation with a soft bounce.
|
||||
//! let mid = (start + end) / 2.0 + Vec2::new(0.0, 30.0);
|
||||
//!
|
||||
//! let first_leg = CardAnimation::slide(start, z, mid, z + 20.0, MotionCurve::SmoothSnap)
|
||||
//! .with_z_lift(15.0);
|
||||
//! let second_leg = CardAnimation::slide(mid, z + 20.0, end, resting_z, MotionCurve::SoftBounce);
|
||||
//!
|
||||
//! commands.entity(card_entity).insert((
|
||||
//! first_leg, // plays immediately
|
||||
//! AnimationChain::new().then(second_leg), // queued
|
||||
//! ));
|
||||
//! ```
|
||||
//!
|
||||
//! # Invariant
|
||||
//!
|
||||
//! The chain holds only the *queued* segments — the segment currently playing
|
||||
//! lives on the entity as a [`CardAnimation`] component and has already been
|
||||
//! removed from the queue. When the queue is exhausted the `AnimationChain`
|
||||
//! component removes itself.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::animation::CardAnimation;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A FIFO queue of [`CardAnimation`] segments to be played one after another.
|
||||
///
|
||||
/// The currently playing segment lives on the entity as a [`CardAnimation`]
|
||||
/// component (already removed from this queue). When that animation completes,
|
||||
/// [`advance_animation_chains`] pops the next entry and inserts it.
|
||||
///
|
||||
/// Remove this component to cancel the entire chain mid-flight. The in-progress
|
||||
/// [`CardAnimation`] (if any) will still play to completion unless also removed.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct AnimationChain {
|
||||
pub(crate) queue: VecDeque<CardAnimation>,
|
||||
}
|
||||
|
||||
impl AnimationChain {
|
||||
/// Creates an empty chain with no queued segments.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends `anim` to the end of the chain.
|
||||
///
|
||||
/// Returns `self` for builder-style chaining.
|
||||
#[must_use]
|
||||
pub fn then(mut self, anim: CardAnimation) -> Self {
|
||||
self.queue.push_back(anim);
|
||||
self
|
||||
}
|
||||
|
||||
/// Number of segments waiting in the queue (not including any
|
||||
/// currently active [`CardAnimation`]).
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.queue.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when no segments remain in the queue.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AnimationChain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pops the next queued segment when the active [`CardAnimation`] has finished.
|
||||
///
|
||||
/// Must run **after** `advance_card_animations` so the completed animation has
|
||||
/// already been removed before this system inspects the entity.
|
||||
pub(crate) fn advance_animation_chains(
|
||||
mut commands: Commands,
|
||||
mut chains: Query<(Entity, &mut AnimationChain), Without<CardAnimation>>,
|
||||
) {
|
||||
for (entity, mut chain) in &mut chains {
|
||||
match chain.queue.pop_front() {
|
||||
Some(next) => {
|
||||
// Insert the next segment; the chain component stays until empty.
|
||||
commands.entity(entity).insert(next);
|
||||
}
|
||||
None => {
|
||||
// Queue exhausted — clean up the chain component.
|
||||
commands.entity(entity).remove::<AnimationChain>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card_animation::MotionCurve;
|
||||
|
||||
fn slide(end_x: f32) -> CardAnimation {
|
||||
CardAnimation::slide(
|
||||
Vec2::ZERO,
|
||||
0.0,
|
||||
Vec2::new(end_x, 0.0),
|
||||
0.0,
|
||||
MotionCurve::SmoothSnap,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_chain_is_empty() {
|
||||
let c = AnimationChain::new();
|
||||
assert_eq!(c.remaining(), 0);
|
||||
assert!(c.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn then_appends_and_increments_remaining() {
|
||||
let c = AnimationChain::new().then(slide(1.0)).then(slide(2.0));
|
||||
assert_eq!(c.remaining(), 2);
|
||||
assert!(!c.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_is_fifo() {
|
||||
let mut c = AnimationChain::new().then(slide(1.0)).then(slide(2.0));
|
||||
let first = c.queue.pop_front().expect("must have first segment");
|
||||
assert!(
|
||||
(first.end.x - 1.0).abs() < 1e-6,
|
||||
"first dequeued must be the first appended (end.x=1), got {}",
|
||||
first.end.x
|
||||
);
|
||||
let second = c.queue.pop_front().expect("must have second segment");
|
||||
assert!(
|
||||
(second.end.x - 2.0).abs() < 1e-6,
|
||||
"second dequeued must be the second appended (end.x=2), got {}",
|
||||
second.end.x
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_equals_new() {
|
||||
assert_eq!(AnimationChain::default().remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_with_three_segments() {
|
||||
let c = AnimationChain::new()
|
||||
.then(slide(1.0))
|
||||
.then(slide(2.0))
|
||||
.then(slide(3.0));
|
||||
assert_eq!(c.remaining(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_system_inserts_next_segment() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(crate::card_animation::CardAnimationPlugin);
|
||||
|
||||
let chain = AnimationChain::new().then(slide(100.0));
|
||||
// Spawn an entity with only AnimationChain (no CardAnimation) so the
|
||||
// system fires immediately on the first update.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((Transform::from_translation(Vec3::ZERO), chain))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
// After one update, the chain system should have popped `slide(100)` and
|
||||
// inserted it as a `CardAnimation`.
|
||||
assert!(
|
||||
app.world().entity(entity).get::<CardAnimation>().is_some(),
|
||||
"advance_animation_chains must insert CardAnimation from first queued segment"
|
||||
);
|
||||
// The chain component should still be present (but now empty).
|
||||
// Actually, since we popped the last item, the chain removes itself too.
|
||||
// Whether it's present or not depends on system ordering, but the
|
||||
// CardAnimation must definitely be present.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Lightweight frame-time diagnostics.
|
||||
//!
|
||||
//! [`FrameTimeDiagnostics`] is a Bevy resource that maintains a rolling window
|
||||
//! of the last [`WINDOW_SIZE`] frame durations. Any system can read it to make
|
||||
//! performance-aware decisions — for example, disabling settle-bounce animations
|
||||
//! when the game is running below 30 FPS on a low-end device.
|
||||
//!
|
||||
//! # Reading diagnostics
|
||||
//!
|
||||
//! ```ignore
|
||||
//! fn my_system(diag: Res<FrameTimeDiagnostics>) {
|
||||
//! if diag.is_low_performance() {
|
||||
//! // Skip expensive visual effects.
|
||||
//! return;
|
||||
//! }
|
||||
//! println!("avg FPS: {:.1}", diag.fps());
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Update
|
||||
//!
|
||||
//! [`update_frame_time_diagnostics`] runs every frame via [`CardAnimationPlugin`]
|
||||
//! (or whichever plugin registers it). The window is circular so only the last
|
||||
//! `WINDOW_SIZE` frames influence the statistics.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Number of frames kept in the rolling statistics window.
|
||||
pub const WINDOW_SIZE: usize = 60;
|
||||
|
||||
/// Rolling frame-time statistics over the last [`WINDOW_SIZE`] frames.
|
||||
///
|
||||
/// All times are in seconds. Statistics are updated every frame by
|
||||
/// [`update_frame_time_diagnostics`].
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct FrameTimeDiagnostics {
|
||||
samples: [f32; WINDOW_SIZE],
|
||||
head: usize,
|
||||
count: usize,
|
||||
/// Smoothed average frame duration over the window (seconds).
|
||||
pub avg_secs: f32,
|
||||
/// Worst-case (slowest) frame duration in the window (seconds).
|
||||
pub max_secs: f32,
|
||||
/// Best-case (fastest) frame duration in the window (seconds).
|
||||
pub min_secs: f32,
|
||||
}
|
||||
|
||||
impl Default for FrameTimeDiagnostics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
samples: [0.0; WINDOW_SIZE],
|
||||
head: 0,
|
||||
count: 0,
|
||||
avg_secs: 0.0,
|
||||
max_secs: 0.0,
|
||||
min_secs: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameTimeDiagnostics {
|
||||
/// Estimated frames per second based on the rolling average.
|
||||
///
|
||||
/// Returns `0.0` until at least one frame has been recorded.
|
||||
pub fn fps(&self) -> f32 {
|
||||
if self.avg_secs > 0.0 {
|
||||
1.0 / self.avg_secs
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the rolling-average FPS is above `target`.
|
||||
///
|
||||
/// Always returns `false` until the window is fully populated.
|
||||
pub fn is_above_target(&self, target_fps: f32) -> bool {
|
||||
self.count >= WINDOW_SIZE && self.fps() > target_fps
|
||||
}
|
||||
|
||||
/// Returns `true` when the device appears to be running below 30 FPS.
|
||||
///
|
||||
/// Only asserted after the window is fully populated so a single slow
|
||||
/// startup frame does not permanently suppress visual effects.
|
||||
pub fn is_low_performance(&self) -> bool {
|
||||
self.count >= WINDOW_SIZE && self.fps() < 30.0
|
||||
}
|
||||
|
||||
/// Appends `dt` to the ring buffer and recomputes statistics.
|
||||
///
|
||||
/// O(WINDOW_SIZE) — acceptable because WINDOW_SIZE is small and constant.
|
||||
fn push(&mut self, dt: f32) {
|
||||
self.samples[self.head] = dt;
|
||||
self.head = (self.head + 1) % WINDOW_SIZE;
|
||||
if self.count < WINDOW_SIZE {
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
let n = self.count;
|
||||
let mut sum = 0.0_f32;
|
||||
let mut max_val = 0.0_f32;
|
||||
let mut min_val = f32::MAX;
|
||||
|
||||
for &s in &self.samples[..n] {
|
||||
sum += s;
|
||||
if s > max_val {
|
||||
max_val = s;
|
||||
}
|
||||
if s < min_val {
|
||||
min_val = s;
|
||||
}
|
||||
}
|
||||
|
||||
self.avg_secs = sum / n as f32;
|
||||
self.max_secs = max_val;
|
||||
self.min_secs = if min_val == f32::MAX { 0.0 } else { min_val };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Records the current frame's delta time in [`FrameTimeDiagnostics`].
|
||||
///
|
||||
/// Registered by [`CardAnimationPlugin`]. Runs every frame in `Update`.
|
||||
pub(crate) fn update_frame_time_diagnostics(
|
||||
time: Res<Time>,
|
||||
mut diag: ResMut<FrameTimeDiagnostics>,
|
||||
) {
|
||||
diag.push(time.delta_secs());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fps_zero_when_no_samples() {
|
||||
assert_eq!(FrameTimeDiagnostics::default().fps(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_correct_after_uniform_frames() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 60.0);
|
||||
}
|
||||
assert!(
|
||||
(d.fps() - 60.0).abs() < 0.5,
|
||||
"expected ~60 fps, got {}",
|
||||
d.fps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_low_performance_requires_full_window() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
// Partial window filled with very slow frames.
|
||||
for _ in 0..(WINDOW_SIZE / 2) {
|
||||
d.push(1.0 / 5.0); // 5 FPS
|
||||
}
|
||||
assert!(
|
||||
!d.is_low_performance(),
|
||||
"must not report low performance until the window is full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_low_performance_true_below_30fps() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 20.0); // 20 FPS
|
||||
}
|
||||
assert!(
|
||||
d.is_low_performance(),
|
||||
"20 FPS should be reported as low performance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_above_target_false_below_target() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 30.0); // exactly 30 FPS
|
||||
}
|
||||
// is_above_target(30.0) is strict: fps must be > 30, not >=.
|
||||
// At exactly 30 FPS the result depends on floating-point rounding,
|
||||
// so just check that it's consistent with > 60 being false.
|
||||
assert!(!d.is_above_target(60.0), "30 FPS is not above 60 FPS target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_and_min_track_extremes() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
d.push(0.010); // fast frame (100 FPS)
|
||||
d.push(0.050); // slow frame (20 FPS)
|
||||
assert!(
|
||||
d.max_secs >= 0.050,
|
||||
"max_secs must be at least the slow frame, got {}",
|
||||
d.max_secs
|
||||
);
|
||||
assert!(
|
||||
d.min_secs <= 0.010,
|
||||
"min_secs must be at most the fast frame, got {}",
|
||||
d.min_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_buffer_overwrites_oldest() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
// Fill with 60-FPS samples.
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 60.0);
|
||||
}
|
||||
// Overwrite every slot with 10-FPS samples.
|
||||
for _ in 0..WINDOW_SIZE {
|
||||
d.push(1.0 / 10.0);
|
||||
}
|
||||
assert!(
|
||||
d.fps() < 15.0,
|
||||
"after full overwrite, avg must reflect new slow frames; got fps={}",
|
||||
d.fps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_does_not_exceed_window_size() {
|
||||
let mut d = FrameTimeDiagnostics::default();
|
||||
for _ in 0..WINDOW_SIZE * 3 {
|
||||
d.push(0.016);
|
||||
}
|
||||
assert_eq!(d.count, WINDOW_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use super::animation::CardAnimation;
|
||||
use super::tuning::AnimationTuning;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
|
||||
use crate::layout::LayoutResource;
|
||||
@@ -48,15 +49,6 @@ type CardTransformQuery<'w, 's> =
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scale applied to the card currently under the cursor (1.0 = no change).
|
||||
const HOVER_SCALE: f32 = 1.04;
|
||||
|
||||
/// Additional scale applied to dragged cards while in flight.
|
||||
const DRAG_LIFT_SCALE: f32 = 1.08;
|
||||
|
||||
/// Lerp speed for hover scale interpolation (higher = snappier).
|
||||
const HOVER_LERP_SPEED: f32 = 14.0;
|
||||
|
||||
/// Lerp speed for drag scale interpolation.
|
||||
const DRAG_LERP_SPEED: f32 = 20.0;
|
||||
|
||||
@@ -162,27 +154,34 @@ pub(crate) fn detect_hover(
|
||||
|
||||
/// Applies the hover scale to the currently hovered card via smooth lerp.
|
||||
///
|
||||
/// Uses [`AnimationTuning`] to get the platform-appropriate hover scale.
|
||||
/// On touch (`hover_scale == 1.0`) this becomes a no-op — there is no
|
||||
/// hover affordance on a touchscreen.
|
||||
///
|
||||
/// Only runs on cards that have **no active [`CardAnimation`]** — animated
|
||||
/// cards control their own scale. When hover changes entities, the previous
|
||||
/// entity's scale is snapped back to 1.0 to avoid leaving a permanently
|
||||
/// enlarged card.
|
||||
pub(crate) fn apply_hover_scale(
|
||||
time: Res<Time>,
|
||||
tuning: Res<AnimationTuning>,
|
||||
mut hover_state: ResMut<HoverState>,
|
||||
mut cards: CardTransformQuery,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let target_entity = hover_state.entity;
|
||||
let hover_target = tuning.hover_scale;
|
||||
let lerp_speed = tuning.hover_lerp_speed;
|
||||
|
||||
for (entity, mut transform) in &mut cards {
|
||||
let target_scale = if Some(entity) == target_entity {
|
||||
HOVER_SCALE
|
||||
hover_target
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let current = transform.scale.x;
|
||||
let new_scale = current + (target_scale - current) * (HOVER_LERP_SPEED * dt).min(1.0);
|
||||
let new_scale = current + (target_scale - current) * (lerp_speed * dt).min(1.0);
|
||||
transform.scale = Vec3::splat(new_scale);
|
||||
}
|
||||
|
||||
@@ -191,29 +190,36 @@ pub(crate) fn apply_hover_scale(
|
||||
cards
|
||||
.get(entity)
|
||||
.map(|(_, t)| t.scale.x)
|
||||
.unwrap_or(HOVER_SCALE)
|
||||
.unwrap_or(hover_target)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
|
||||
/// Applies a scale boost and z-lift to dragged card entities.
|
||||
/// Applies a scale boost to committed dragged card entities.
|
||||
///
|
||||
/// Reads [`DragState`] for the list of card IDs being dragged. Does **not**
|
||||
/// modify `translation.xy` — the existing `InputPlugin` owns drag translation.
|
||||
/// Only writes `scale` and `translation.z` so the two systems are disjoint.
|
||||
/// Uses [`AnimationTuning`] for the platform-correct drag scale. Only applies
|
||||
/// to cards whose drag has been *committed* (threshold crossed); cards in the
|
||||
/// pending-drag state stay at scale 1.0. Does **not** modify `translation.xy`
|
||||
/// — `InputPlugin` owns drag translation.
|
||||
pub(crate) fn apply_drag_visual(
|
||||
time: Res<Time>,
|
||||
drag: Option<Res<DragState>>,
|
||||
tuning: Res<AnimationTuning>,
|
||||
mut cards: Query<(Entity, &CardEntity, &mut Transform), (Without<CardAnimation>,)>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
let empty: Vec<u32> = Vec::new();
|
||||
let dragged_ids: &[u32] = drag.as_ref().map_or(empty.as_slice(), |d| &d.cards);
|
||||
let drag_scale = tuning.drag_scale;
|
||||
|
||||
// Only lift cards that are in a *committed* drag. Pending drags (below
|
||||
// threshold) must stay at scale 1.0 to avoid visible premature lift.
|
||||
let (dragged_ids, committed): (&[u32], bool) = drag
|
||||
.as_ref()
|
||||
.map_or((&[], false), |d| (d.cards.as_slice(), d.committed));
|
||||
|
||||
for (_, card, mut transform) in &mut cards {
|
||||
let is_dragged = dragged_ids.contains(&card.card_id);
|
||||
let target_scale = if is_dragged { DRAG_LIFT_SCALE } else { 1.0 };
|
||||
let is_active_drag = committed && dragged_ids.contains(&card.card_id);
|
||||
let target_scale = if is_active_drag { drag_scale } else { 1.0 };
|
||||
let current = transform.scale.x;
|
||||
let new_scale = current + (target_scale - current) * (DRAG_LERP_SPEED * dt).min(1.0);
|
||||
transform.scale = Vec3::splat(new_scale);
|
||||
|
||||
@@ -73,17 +73,23 @@
|
||||
//! | `apply_drag_visual` scale + `CardAnimation` scale | ✓ (same filter) |
|
||||
|
||||
pub mod animation;
|
||||
pub mod chain;
|
||||
pub mod curves;
|
||||
pub mod diagnostics;
|
||||
pub mod interaction;
|
||||
pub mod timing;
|
||||
pub mod tuning;
|
||||
|
||||
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
|
||||
pub use chain::AnimationChain;
|
||||
pub use curves::{sample_curve, MotionCurve};
|
||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||
pub use timing::{
|
||||
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
|
||||
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||
};
|
||||
pub use tuning::{AnimationTuning, InputPlatform};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -94,14 +100,18 @@ use crate::layout::LayoutResource;
|
||||
use crate::resources::DragState;
|
||||
|
||||
use animation::advance_card_animations;
|
||||
use chain::advance_animation_chains;
|
||||
use diagnostics::update_frame_time_diagnostics;
|
||||
use interaction::{apply_drag_visual, apply_hover_scale, detect_hover, drain_input_buffer};
|
||||
use tuning::update_input_platform;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers all systems, resources, and components for curve-based card
|
||||
/// animation, hover visuals, drag lift, and input buffering.
|
||||
/// animation, hover visuals, drag lift, input buffering, platform-adaptive
|
||||
/// tuning, animation chaining, and frame-time diagnostics.
|
||||
///
|
||||
/// Safe to register alongside `AnimationPlugin` and `FeedbackAnimPlugin` as
|
||||
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
|
||||
@@ -109,8 +119,8 @@ pub struct CardAnimationPlugin;
|
||||
|
||||
impl Plugin for CardAnimationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Register events and resources that interaction systems depend on,
|
||||
// idempotently — double-registration is safe in Bevy.
|
||||
// Register events and resources idempotently — double-registration is
|
||||
// safe in Bevy.
|
||||
app.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
@@ -118,12 +128,23 @@ impl Plugin for CardAnimationPlugin {
|
||||
.init_resource::<DragState>()
|
||||
.init_resource::<HoverState>()
|
||||
.init_resource::<InputBuffer>()
|
||||
// Platform-adaptive tuning (desktop by default, switches on touch).
|
||||
.init_resource::<AnimationTuning>()
|
||||
// Rolling frame-time statistics.
|
||||
.init_resource::<FrameTimeDiagnostics>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Advance active animations (highest priority — runs first).
|
||||
// Detect input platform and update tuning — runs first so
|
||||
// all downstream systems in this frame see the fresh value.
|
||||
update_input_platform,
|
||||
// Frame-time diagnostics — cheap, runs unconditionally.
|
||||
update_frame_time_diagnostics,
|
||||
// Advance active animations.
|
||||
advance_card_animations,
|
||||
// Interaction visuals (run after animation to read final positions).
|
||||
// After each animation finishes, pop the next chain segment.
|
||||
advance_animation_chains,
|
||||
// Interaction visuals (run after animation for final positions).
|
||||
detect_hover,
|
||||
apply_hover_scale,
|
||||
apply_drag_visual,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Platform-adaptive animation tuning.
|
||||
//!
|
||||
//! [`AnimationTuning`] is a Bevy resource that provides animation parameters
|
||||
//! adapted to the currently detected input platform. Systems and components
|
||||
//! that need animation timing should read from this resource instead of using
|
||||
//! hardcoded constants, so the same binary behaves appropriately on both a
|
||||
//! touchscreen phone and a desktop with a mouse.
|
||||
//!
|
||||
//! # Platform detection
|
||||
//!
|
||||
//! [`update_input_platform`] runs every frame. When a touch event is detected
|
||||
//! the resource switches to [`InputPlatform::Touch`] (mobile defaults); when a
|
||||
//! mouse event is detected it switches back to [`InputPlatform::Mouse`]
|
||||
//! (desktop defaults). The transition is immediate.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! fn my_system(tuning: Res<AnimationTuning>, time: Res<Time>) {
|
||||
//! let duration = tuning.scale_duration(0.25); // 0.25 s on desktop, 0.19 s on mobile
|
||||
//! let scale = tuning.drag_scale; // platform-appropriate lift
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use bevy::input::touch::Touches;
|
||||
use bevy::prelude::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InputPlatform
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The most recently detected input platform.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InputPlatform {
|
||||
/// Mouse / keyboard — desktop behaviour (richer motion, hover states).
|
||||
#[default]
|
||||
Mouse,
|
||||
/// Touchscreen — mobile behaviour (faster, tighter, no hover).
|
||||
Touch,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AnimationTuning resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Animation and interaction parameters adapted to the active [`InputPlatform`].
|
||||
///
|
||||
/// Mobile (touch) defaults are faster and less bouncy than desktop (mouse)
|
||||
/// defaults. Read this resource wherever you previously used animation
|
||||
/// constants to get correct behaviour across both platforms.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct AnimationTuning {
|
||||
/// Currently detected input platform.
|
||||
pub platform: InputPlatform,
|
||||
|
||||
/// Multiplier applied to all computed animation durations.
|
||||
///
|
||||
/// `1.0` on desktop; `0.75` on mobile (25 % faster).
|
||||
pub duration_scale: f32,
|
||||
|
||||
/// Multiplier applied to spring-curve overshoot amplitude.
|
||||
///
|
||||
/// `1.0` on desktop (full bounce); `0.5` on mobile (half — tighter feel
|
||||
/// on small screens where large overshoots look incorrect).
|
||||
pub overshoot_scale: f32,
|
||||
|
||||
/// Minimum pointer/finger movement in **screen pixels** before a drag
|
||||
/// is committed.
|
||||
///
|
||||
/// Prevents accidental drags from quick taps. Desktop = 4 px; mobile
|
||||
/// = 10 px (fingers are less precise than a mouse cursor).
|
||||
pub drag_threshold_px: f32,
|
||||
|
||||
/// `Transform.scale` applied to a card while it is being dragged.
|
||||
pub drag_scale: f32,
|
||||
|
||||
/// `Transform.scale` applied to the card under the cursor (desktop only).
|
||||
///
|
||||
/// Always `1.0` on touch because there is no hover concept on a
|
||||
/// touchscreen — applying hover to the card under the last touch
|
||||
/// would feel wrong.
|
||||
pub hover_scale: f32,
|
||||
|
||||
/// Lerp speed (per second) for the hover scale interpolation.
|
||||
///
|
||||
/// Higher values make the hover pop in/out faster.
|
||||
pub hover_lerp_speed: f32,
|
||||
|
||||
/// Per-card stagger interval (seconds) for cascade / deal animations.
|
||||
///
|
||||
/// Mobile gets a slightly tighter stagger so the full cascade finishes
|
||||
/// more quickly.
|
||||
pub cascade_stagger_secs: f32,
|
||||
}
|
||||
|
||||
impl AnimationTuning {
|
||||
/// Desktop (mouse) defaults — richer motion, more expressive curves.
|
||||
pub fn desktop() -> Self {
|
||||
Self {
|
||||
platform: InputPlatform::Mouse,
|
||||
duration_scale: 1.0,
|
||||
overshoot_scale: 1.0,
|
||||
drag_threshold_px: 4.0,
|
||||
drag_scale: 1.08,
|
||||
hover_scale: 1.04,
|
||||
hover_lerp_speed: 14.0,
|
||||
cascade_stagger_secs: 0.018,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mobile (touch) defaults — faster, tighter, no hover.
|
||||
pub fn mobile() -> Self {
|
||||
Self {
|
||||
platform: InputPlatform::Touch,
|
||||
duration_scale: 0.75,
|
||||
overshoot_scale: 0.5,
|
||||
drag_threshold_px: 10.0,
|
||||
drag_scale: 1.12,
|
||||
hover_scale: 1.0, // no hover affordance on touch
|
||||
hover_lerp_speed: 20.0,
|
||||
cascade_stagger_secs: 0.014,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales `base_duration` by [`Self::duration_scale`].
|
||||
///
|
||||
/// Use this wherever you compute an animation duration to respect the
|
||||
/// current platform's speed preference.
|
||||
#[inline]
|
||||
pub fn scale_duration(&self, base_duration: f32) -> f32 {
|
||||
base_duration * self.duration_scale
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AnimationTuning {
|
||||
fn default() -> Self {
|
||||
Self::desktop()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detects the active input platform and updates [`AnimationTuning`] to match.
|
||||
///
|
||||
/// Called every frame. Uses `Option<Res<Touches>>` so the system is safe when
|
||||
/// running under `MinimalPlugins` (which does not register the touch subsystem).
|
||||
pub(crate) fn update_input_platform(
|
||||
touches: Option<Res<Touches>>,
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
mut tuning: ResMut<AnimationTuning>,
|
||||
) {
|
||||
let touch_active = touches.as_ref().is_some_and(|t| {
|
||||
t.iter().next().is_some()
|
||||
|| t.iter_just_pressed().next().is_some()
|
||||
|| t.iter_just_released().next().is_some()
|
||||
});
|
||||
|
||||
let mouse_active = mouse_buttons.get_just_pressed().next().is_some()
|
||||
|| mouse_buttons.get_pressed().next().is_some();
|
||||
|
||||
if touch_active && tuning.platform != InputPlatform::Touch {
|
||||
*tuning = AnimationTuning::mobile();
|
||||
} else if mouse_active && tuning.platform != InputPlatform::Mouse {
|
||||
*tuning = AnimationTuning::desktop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn desktop_defaults_are_sane() {
|
||||
let t = AnimationTuning::desktop();
|
||||
assert_eq!(t.duration_scale, 1.0);
|
||||
assert_eq!(t.platform, InputPlatform::Mouse);
|
||||
assert!(t.hover_scale > 1.0, "desktop hover must lift the card");
|
||||
assert!(t.drag_threshold_px < 10.0, "desktop threshold must be smaller than mobile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_is_faster_than_desktop() {
|
||||
let d = AnimationTuning::desktop();
|
||||
let m = AnimationTuning::mobile();
|
||||
assert!(m.duration_scale < d.duration_scale, "mobile must animate faster");
|
||||
assert!(m.overshoot_scale < d.overshoot_scale, "mobile must bounce less");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_has_no_hover() {
|
||||
// On touch, `hover_scale = 1.0` means no visible hover effect.
|
||||
assert_eq!(AnimationTuning::mobile().hover_scale, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_drag_threshold_larger_than_desktop() {
|
||||
assert!(
|
||||
AnimationTuning::mobile().drag_threshold_px
|
||||
> AnimationTuning::desktop().drag_threshold_px,
|
||||
"mobile needs a larger threshold because touch is less precise"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_duration_applies_multiplier() {
|
||||
let mut t = AnimationTuning::default();
|
||||
t.duration_scale = 0.5;
|
||||
assert!((t.scale_duration(1.0) - 0.5).abs() < 1e-6);
|
||||
assert!((t.scale_duration(0.25) - 0.125).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_cascade_stagger_tighter_than_desktop() {
|
||||
assert!(
|
||||
AnimationTuning::mobile().cascade_stagger_secs
|
||||
< AnimationTuning::desktop().cascade_stagger_secs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_desktop() {
|
||||
assert_eq!(AnimationTuning::default().platform, InputPlatform::Mouse);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ pub struct ChallengeAdvancedEvent {
|
||||
pub new_index: u32,
|
||||
}
|
||||
|
||||
/// Manages Challenge Mode progression: seeded hard deals, no-undo rules, and advancement through the challenge sequence.
|
||||
/// Requires the player to be at least level `CHALLENGE_UNLOCK_LEVEL`.
|
||||
pub struct ChallengePlugin;
|
||||
|
||||
impl Plugin for ChallengePlugin {
|
||||
|
||||
@@ -30,6 +30,7 @@ const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Green tint applied to pile markers that are valid drop targets during drag.
|
||||
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
|
||||
|
||||
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
||||
pub struct CursorPlugin;
|
||||
|
||||
impl Plugin for CursorPlugin {
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct DailyChallengeCompletedEvent {
|
||||
#[derive(Resource, Default)]
|
||||
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
|
||||
|
||||
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
|
||||
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
|
||||
pub struct DailyChallengePlugin;
|
||||
|
||||
impl Plugin for DailyChallengePlugin {
|
||||
|
||||
@@ -9,6 +9,8 @@ use bevy::prelude::*;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HelpScreen;
|
||||
|
||||
/// Spawns and despawns the help/controls overlay shown when the player presses H (or the help button).
|
||||
/// All hotkeys and gesture guides live here.
|
||||
pub struct HelpPlugin;
|
||||
|
||||
impl Plugin for HelpPlugin {
|
||||
|
||||
@@ -86,6 +86,7 @@ pub struct HudSelection;
|
||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||
const Z_HUD: i32 = 50;
|
||||
|
||||
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
|
||||
pub struct HudPlugin;
|
||||
|
||||
impl Plugin for HudPlugin {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::prelude::*;
|
||||
@@ -28,6 +29,7 @@ use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
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::feedback_anim_plugin::ShakeAnim;
|
||||
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.
|
||||
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:
|
||||
/// `start_drag` → `follow_drag` → `end_drag`, with `follow_drag` after the
|
||||
/// card-position sync so it overrides resting positions for cards being
|
||||
/// dragged. `end_drag` runs before `GameMutation` so the `MoveRequestEvent`
|
||||
/// it fires is consumed the same frame.
|
||||
/// Using a resource (instead of `Local`) lets the three keyboard sub-systems
|
||||
/// share the same countdown state without needing to pass values between them.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct KeyboardConfirmState {
|
||||
/// 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;
|
||||
|
||||
impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.init_resource::<KeyboardConfirmState>()
|
||||
.add_message::<NewGameConfirmEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
@@ -65,12 +89,20 @@ impl Plugin for InputPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_keyboard,
|
||||
handle_keyboard_core,
|
||||
handle_keyboard_hint,
|
||||
handle_keyboard_forfeit,
|
||||
handle_stock_click,
|
||||
handle_touch_stock_tap,
|
||||
handle_double_click,
|
||||
// Mouse drag pipeline.
|
||||
start_drag,
|
||||
follow_drag,
|
||||
end_drag.before(GameMutation),
|
||||
// Touch drag pipeline (parallel path through DragState).
|
||||
touch_start_drag,
|
||||
touch_follow_drag,
|
||||
touch_end_drag.before(GameMutation),
|
||||
)
|
||||
.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.
|
||||
const FORFEIT_CONFIRM_WINDOW: f32 = 3.0;
|
||||
|
||||
/// Bundles all event writers used by `handle_keyboard` so the system stays
|
||||
/// within Bevy's 16-parameter limit.
|
||||
/// Bundles the event writers needed by the core keyboard handler.
|
||||
///
|
||||
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
|
||||
#[derive(SystemParam)]
|
||||
struct KeyboardMessages<'w> {
|
||||
struct CoreKeyboardMessages<'w> {
|
||||
undo: MessageWriter<'w, UndoRequestEvent>,
|
||||
new_game: MessageWriter<'w, NewGameRequestEvent>,
|
||||
confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
|
||||
info_toast: MessageWriter<'w, InfoToastEvent>,
|
||||
draw: MessageWriter<'w, DrawRequestEvent>,
|
||||
forfeit: MessageWriter<'w, ForfeitEvent>,
|
||||
hint_visual: MessageWriter<'w, HintVisualEvent>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard(
|
||||
/// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
|
||||
/// 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>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
time: Res<Time>,
|
||||
mut confirm_countdown: Local<f32>,
|
||||
mut confirm_pending: Local<bool>,
|
||||
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 confirm: ResMut<KeyboardConfirmState>,
|
||||
mut ev: CoreKeyboardMessages<'_>,
|
||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Tick down any active confirmation window.
|
||||
if *confirm_countdown > 0.0 {
|
||||
*confirm_countdown -= time.delta_secs();
|
||||
if *confirm_countdown <= 0.0 {
|
||||
*confirm_countdown = 0.0;
|
||||
// Countdown expired without a second N press — notify the player.
|
||||
if *confirm_pending {
|
||||
*confirm_pending = false;
|
||||
|
||||
// Tick down the new-game confirmation window each frame.
|
||||
if confirm.new_game_countdown > 0.0 {
|
||||
confirm.new_game_countdown -= time.delta_secs();
|
||||
if confirm.new_game_countdown <= 0.0 {
|
||||
confirm.new_game_countdown = 0.0;
|
||||
if confirm.new_game_pending {
|
||||
confirm.new_game_pending = false;
|
||||
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 *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);
|
||||
}
|
||||
|
||||
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 let Some(ref mut session) = time_attack {
|
||||
if session.active {
|
||||
@@ -153,7 +181,7 @@ fn handle_keyboard(
|
||||
seed: None,
|
||||
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
||||
});
|
||||
*confirm_countdown = 0.0;
|
||||
confirm.new_game_countdown = 0.0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -163,22 +191,24 @@ fn handle_keyboard(
|
||||
if shift_held || !active_game {
|
||||
// Shift+N or no active game — start immediately, no confirmation.
|
||||
ev.new_game.write(NewGameRequestEvent::default());
|
||||
*confirm_countdown = 0.0;
|
||||
*confirm_pending = false;
|
||||
} else if *confirm_countdown > 0.0 {
|
||||
confirm.new_game_countdown = 0.0;
|
||||
confirm.new_game_pending = false;
|
||||
} else if confirm.new_game_countdown > 0.0 {
|
||||
// Second press within the window — confirmed.
|
||||
ev.new_game.write(NewGameRequestEvent::default());
|
||||
*confirm_countdown = 0.0;
|
||||
*confirm_pending = false;
|
||||
confirm.new_game_countdown = 0.0;
|
||||
confirm.new_game_pending = false;
|
||||
} else {
|
||||
// First press on an active game — require confirmation.
|
||||
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
|
||||
*confirm_pending = true;
|
||||
confirm.new_game_countdown = NEW_GAME_CONFIRM_WINDOW;
|
||||
confirm.new_game_pending = true;
|
||||
ev.confirm_event.write(NewGameConfirmEvent);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// X is gated separately by ChallengePlugin.
|
||||
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 *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);
|
||||
}
|
||||
// H — cycle through all available hints on each press, highlighting the
|
||||
// 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) {
|
||||
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
|
||||
if let Some(ref g) = game {
|
||||
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||
}
|
||||
|
||||
/// Handles the H key: cycles through all available hints, highlighting the
|
||||
/// source card yellow for 2 s and showing a descriptive toast. Resets the
|
||||
/// 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 {
|
||||
ev.info_toast.write(InfoToastEvent(
|
||||
"Game won! Press N for a new game".to_string(),
|
||||
));
|
||||
} else if let Some(ref layout_res) = layout {
|
||||
info_toast.write(InfoToastEvent("Game won! Press N for a new game".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref layout_res) = layout else { return };
|
||||
|
||||
let hints = all_hints(&g.0);
|
||||
if hints.is_empty() {
|
||||
ev.info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
} else {
|
||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||
let idx = hint_cycle.0 % hints.len();
|
||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||
let (from, to, _count) = &hints[idx];
|
||||
|
||||
// When the hint points at the stock (draw suggestion) there is no
|
||||
// face-up card to highlight — show a toast instead.
|
||||
// If the stock is empty, pressing D will recycle the waste rather
|
||||
@@ -229,8 +293,10 @@ fn handle_keyboard(
|
||||
} else {
|
||||
"Hint: draw from stock (D)".to_string()
|
||||
};
|
||||
ev.info_toast.write(InfoToastEvent(msg));
|
||||
} else {
|
||||
info_toast.write(InfoToastEvent(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the top face-up card in the source pile and highlight it.
|
||||
let top_card_id = g.0.piles.get(from)
|
||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
||||
@@ -249,15 +315,16 @@ fn handle_keyboard(
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Emit HintVisualEvent so the destination pile
|
||||
// marker is also tinted gold for 2 s.
|
||||
ev.hint_visual.write(HintVisualEvent {
|
||||
// Emit HintVisualEvent so the destination pile marker is also
|
||||
// tinted gold for 2 s.
|
||||
hint_visual.write(HintVisualEvent {
|
||||
source_card_id: card_id,
|
||||
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 {
|
||||
PileType::Foundation(suit) => {
|
||||
let suit_name = match suit {
|
||||
@@ -268,35 +335,58 @@ fn handle_keyboard(
|
||||
};
|
||||
format!("Hint: move to {suit_name} foundation")
|
||||
}
|
||||
PileType::Tableau(col) => {
|
||||
format!("Hint: move to tableau (col {})", col + 1)
|
||||
}
|
||||
PileType::Tableau(col) => format!("Hint: move to tableau (col {})", col + 1),
|
||||
_ => "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);
|
||||
if active_game {
|
||||
if *forfeit_countdown > 0.0 {
|
||||
if !active_game {
|
||||
return;
|
||||
}
|
||||
|
||||
if confirm.forfeit_countdown > 0.0 {
|
||||
// Second press within the confirmation window — confirmed.
|
||||
ev.forfeit.write(ForfeitEvent);
|
||||
*forfeit_countdown = 0.0;
|
||||
forfeit.write(ForfeitEvent);
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
} else {
|
||||
// First press — start the countdown and warn the player.
|
||||
*forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
ev.info_toast.write(InfoToastEvent("Press G again to forfeit".to_string()));
|
||||
confirm.forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
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
|
||||
@@ -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(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
@@ -379,81 +509,98 @@ fn start_drag(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut drag: ResMut<DragState>,
|
||||
mut card_visuals: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Only start a new drag when idle (no touch drag running either).
|
||||
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
let Some(world) = cursor_world(&windows, &cameras) else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout 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 {
|
||||
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 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.origin_pile = Some(pile);
|
||||
drag.cursor_offset = cursor_offset;
|
||||
drag.cursor_offset = bottom_pos - world;
|
||||
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(
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
drag: Res<DragState>,
|
||||
mut drag: ResMut<DragState>,
|
||||
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;
|
||||
}
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout else { return };
|
||||
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;
|
||||
};
|
||||
|
||||
// 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 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(|(entity, _)| entity.card_id == *id)
|
||||
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);
|
||||
transform.translation.y = bottom_pos.y + fan * i as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,9 +624,21 @@ fn end_drag(
|
||||
drag.clear();
|
||||
return;
|
||||
}
|
||||
// Only handle mouse releases; touch releases are handled by touch_end_drag.
|
||||
if !buttons.just_released(MouseButton::Left) || drag.is_idle() {
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -556,6 +715,220 @@ fn end_drag(
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -570,6 +943,18 @@ fn cursor_world(
|
||||
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.
|
||||
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
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");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -62,6 +62,7 @@ struct OptOutTask(Option<Task<Result<(), String>>>);
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages the leaderboard overlay: fetches scores from the sync server, handles opt-in/opt-out, and displays the ranked list of player scores.
|
||||
pub struct LeaderboardPlugin;
|
||||
|
||||
impl Plugin for LeaderboardPlugin {
|
||||
|
||||
@@ -48,6 +48,9 @@ pub use card_animation::{
|
||||
HoverState, InputBuffer, BufferedInput,
|
||||
win_scatter_targets, WIN_CASCADE_INTERVAL_SECS, DEAL_INTERVAL_SECS,
|
||||
MIN_DURATION_SECS, MAX_DURATION_SECS,
|
||||
AnimationChain,
|
||||
AnimationTuning, InputPlatform,
|
||||
FrameTimeDiagnostics, DIAG_WINDOW_SIZE,
|
||||
};
|
||||
pub use feedback_anim_plugin::{
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
|
||||
@@ -32,6 +32,8 @@ const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0);
|
||||
/// Bright orange used for key-name spans so they stand out from body text.
|
||||
const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1);
|
||||
|
||||
/// Shows a first-run welcome screen that introduces the controls and draw mode.
|
||||
/// Sets `Settings::first_run_complete` once dismissed so it never appears again.
|
||||
pub struct OnboardingPlugin;
|
||||
|
||||
impl Plugin for OnboardingPlugin {
|
||||
|
||||
@@ -48,6 +48,7 @@ pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles pause and resume: toggles the pause overlay on Esc, freezes game-input systems via `PausedResource`, and saves the in-progress game state to disk.
|
||||
pub struct PausePlugin;
|
||||
|
||||
impl Plugin for PausePlugin {
|
||||
|
||||
@@ -12,28 +12,72 @@ pub struct GameStateResource(pub GameState);
|
||||
|
||||
/// Tracks an in-progress drag operation.
|
||||
///
|
||||
/// When `cards` is empty there is no active drag. When non-empty, the listed cards
|
||||
/// are being moved by the user and should be rendered at the cursor position.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
/// When `cards` is empty there is no active drag. When non-empty, the listed
|
||||
/// cards are being moved by the user and should be rendered at the cursor or
|
||||
/// touch position.
|
||||
///
|
||||
/// # Drag threshold
|
||||
///
|
||||
/// A drag is *pending* when `!cards.is_empty() && !committed`. The drag does
|
||||
/// not become *committed* (cards do not visually move) until the pointer has
|
||||
/// moved at least `AnimationTuning::drag_threshold_px` pixels from `press_pos`.
|
||||
/// This prevents accidental drags on quick taps, especially on touch screens.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct DragState {
|
||||
/// IDs of the cards being dragged (bottom-to-top stacking order).
|
||||
pub cards: Vec<u32>,
|
||||
/// Pile the drag originated from.
|
||||
pub origin_pile: Option<PileType>,
|
||||
/// World-space offset from the cursor/touch to the bottom card's centre.
|
||||
pub cursor_offset: Vec2,
|
||||
/// Z coordinate used for the dragged cards.
|
||||
pub origin_z: f32,
|
||||
/// Screen-space position (logical pixels) where the press/touch began.
|
||||
///
|
||||
/// Used to measure whether the drag threshold has been crossed.
|
||||
pub press_pos: Vec2,
|
||||
/// Whether the drag threshold has been crossed and visual drag is active.
|
||||
///
|
||||
/// Cards are only lifted and repositioned once `committed = true`.
|
||||
pub committed: bool,
|
||||
/// Touch ID driving this drag, or `None` for a mouse drag.
|
||||
pub active_touch_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for DragState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cards: Vec::new(),
|
||||
origin_pile: None,
|
||||
cursor_offset: Vec2::ZERO,
|
||||
origin_z: 0.0,
|
||||
press_pos: Vec2::ZERO,
|
||||
committed: false,
|
||||
active_touch_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DragState {
|
||||
/// Returns true when no drag is currently in progress.
|
||||
/// Returns `true` when no drag (pending or committed) is in progress.
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.cards.is_empty()
|
||||
}
|
||||
|
||||
/// Clears the drag state.
|
||||
/// Returns `true` when a drag has been committed (cards are visually lifted).
|
||||
pub fn is_committed(&self) -> bool {
|
||||
self.committed
|
||||
}
|
||||
|
||||
/// Resets all drag state to the idle/default values.
|
||||
pub fn clear(&mut self) {
|
||||
self.cards.clear();
|
||||
self.origin_pile = None;
|
||||
self.cursor_offset = Vec2::ZERO;
|
||||
self.origin_z = 0.0;
|
||||
self.press_pos = Vec2::ZERO;
|
||||
self.committed = false;
|
||||
self.active_touch_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct TimeAttackEndedEvent {
|
||||
pub wins: u32,
|
||||
}
|
||||
|
||||
/// Implements the 10-minute Time Attack mode: counts down the session timer, tracks wins per session, and fires `TimeAttackEndedEvent` when time expires.
|
||||
pub struct TimeAttackPlugin;
|
||||
|
||||
impl Plugin for TimeAttackPlugin {
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct WeeklyGoalCompletedEvent {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Tracks weekly goal progress (e.g. win N games, play without undo) and fires `WeeklyGoalCompletedEvent` when a goal is met.
|
||||
/// Progress resets each Monday.
|
||||
pub struct WeeklyGoalsPlugin;
|
||||
|
||||
impl Plugin for WeeklyGoalsPlugin {
|
||||
|
||||
@@ -30,7 +30,3 @@ dotenvy = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
solitaire_sync = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
|
||||
@@ -152,3 +152,71 @@ pub async fn opt_in(
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,3 +220,118 @@ async fn update_leaderboard_if_opted_in(
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user