feat(engine): playability improvements — rounds 7–9 (#40–#64)
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close
Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)
Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+36
-11
@@ -256,16 +256,35 @@ Done
|
||||
|
||||
### Bevy Plugins
|
||||
|
||||
| Plugin | Responsibility |
|
||||
|---|---|
|
||||
| `CardPlugin` | Card entity spawning, sprite management, drag-and-drop |
|
||||
| `TablePlugin` | Pile markers, background, layout calculation |
|
||||
| `AnimationPlugin` | Slide, flip, win cascade, toast animations |
|
||||
| `AudioPlugin` | Sound effect and music playback via bevy_kira_audio |
|
||||
| `UIPlugin` | All Bevy UI screens: Home, Stats, Achievements, Settings, Profile |
|
||||
| `AchievementPlugin` | Listens for game events, evaluates unlock conditions, fires toasts |
|
||||
| `SyncPlugin` | Manages sync lifecycle (pull on start, push on exit, status display) |
|
||||
| `GamePlugin` | Core game state resource, input routing, win detection |
|
||||
| Plugin | Key | Responsibility |
|
||||
|---|---|---|
|
||||
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
||||
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
|
||||
| `InputPlugin` | — | Keyboard and mouse input routing |
|
||||
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
||||
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
||||
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
|
||||
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge |
|
||||
| `StatsPlugin` | S | Stats overlay and persistence |
|
||||
| `ProgressPlugin` | — | XP/level system, persistence |
|
||||
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
||||
| `DailyChallengePlugin` | — | Daily challenge resource and completion tracking |
|
||||
| `WeeklyGoalsPlugin` | — | Weekly goal progress and completion events |
|
||||
| `ChallengePlugin` | — | Challenge mode progression (seeded hard deals) |
|
||||
| `TimeAttackPlugin` | — | 10-minute time-attack mode timer |
|
||||
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
||||
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
||||
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
||||
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
||||
| `HelpPlugin` | H | Help / controls overlay |
|
||||
| `PausePlugin` | Esc | Pause and resume |
|
||||
| `OnboardingPlugin` | — | First-run welcome screen |
|
||||
| `SyncPlugin` | — | Async sync lifecycle (pull on start, push on exit, status display) |
|
||||
| `WinSummaryPlugin` | — | Win cascade overlay and screen-shake effect |
|
||||
|
||||
### Key Bevy Resources
|
||||
|
||||
@@ -588,6 +607,9 @@ pub enum PileType {
|
||||
|
||||
pub enum DrawMode { DrawOne, DrawThree }
|
||||
|
||||
/// Active game mode. Classic is the default; others unlock at level 5.
|
||||
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
||||
|
||||
pub enum MoveError {
|
||||
InvalidSource,
|
||||
InvalidDestination,
|
||||
@@ -600,13 +622,16 @@ pub enum MoveError {
|
||||
pub struct GameState {
|
||||
pub piles: HashMap<PileType, Vec<Card>>,
|
||||
pub draw_mode: DrawMode,
|
||||
pub mode: GameMode,
|
||||
pub score: i32,
|
||||
pub move_count: u32,
|
||||
pub undo_count: u32, // number of undos used in this game
|
||||
pub recycle_count: u32, // number of stock recycles
|
||||
pub elapsed_seconds: u64,
|
||||
pub seed: u64,
|
||||
pub is_won: bool,
|
||||
pub is_auto_completable: bool,
|
||||
undo_stack: Vec<StateSnapshot>, // private, max 64
|
||||
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ use solitaire_data::{load_settings_from, provider_for_backend, settings_file_pat
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
||||
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
||||
HelpPlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin,
|
||||
WeeklyGoalsPlugin,
|
||||
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
|
||||
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
|
||||
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -44,6 +44,8 @@ fn main() {
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin)
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::LevelUpEvent;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||
@@ -60,8 +61,41 @@ const WEEKLY_TOAST_SECS: f32 = 3.0;
|
||||
const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
|
||||
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
||||
const VOLUME_TOAST_SECS: f32 = 1.4;
|
||||
const CASCADE_STAGGER: f32 = 0.05;
|
||||
const CASCADE_DURATION: f32 = 0.5;
|
||||
|
||||
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
||||
const CASCADE_STAGGER_NORMAL: f32 = 0.05;
|
||||
/// Duration of each card's cascade slide at Normal speed (seconds).
|
||||
const CASCADE_DURATION_NORMAL: f32 = 0.5;
|
||||
|
||||
/// Returns the per-card stagger delay for the win cascade at the given `AnimSpeed`.
|
||||
///
|
||||
/// | `AnimSpeed` | Returned value |
|
||||
/// |-------------|----------------|
|
||||
/// | `Normal` | 0.05 s |
|
||||
/// | `Fast` | 0.025 s |
|
||||
/// | `Instant` | 0.0 s |
|
||||
pub fn cascade_step_secs(speed: AnimSpeed) -> f32 {
|
||||
match speed {
|
||||
AnimSpeed::Normal => CASCADE_STAGGER_NORMAL,
|
||||
AnimSpeed::Fast => CASCADE_STAGGER_NORMAL / 2.0,
|
||||
AnimSpeed::Instant => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the slide duration for each card in the win cascade at the given `AnimSpeed`.
|
||||
///
|
||||
/// | `AnimSpeed` | Returned value |
|
||||
/// |-------------|----------------|
|
||||
/// | `Normal` | 0.5 s |
|
||||
/// | `Fast` | 0.25 s |
|
||||
/// | `Instant` | 0.0 s |
|
||||
pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 {
|
||||
match speed {
|
||||
AnimSpeed::Normal => CASCADE_DURATION_NORMAL,
|
||||
AnimSpeed::Fast => CASCADE_DURATION_NORMAL / 2.0,
|
||||
AnimSpeed::Instant => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear-lerp slide animation.
|
||||
///
|
||||
@@ -181,11 +215,19 @@ fn sync_slide_duration(
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances all in-flight `CardAnim` slide animations.
|
||||
///
|
||||
/// Skipped while the game is paused so cards do not move while the pause
|
||||
/// overlay is open.
|
||||
fn advance_card_anims(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
if anim.delay > 0.0 {
|
||||
@@ -206,6 +248,7 @@ fn handle_win_cascade(
|
||||
mut events: EventReader<GameWonEvent>,
|
||||
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
) {
|
||||
let Some(ev) = events.read().next() else {
|
||||
return;
|
||||
@@ -230,13 +273,17 @@ fn handle_win_cascade(
|
||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||
|
||||
let speed = settings.as_ref().map(|s| s.0.animation_speed.clone());
|
||||
let step = speed.clone().map(cascade_step_secs).unwrap_or(CASCADE_STAGGER_NORMAL);
|
||||
let duration = speed.map(cascade_duration_secs).unwrap_or(CASCADE_DURATION_NORMAL);
|
||||
|
||||
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||
commands.entity(entity).insert(CardAnim {
|
||||
start: transform.translation,
|
||||
target: targets[i % 8],
|
||||
elapsed: 0.0,
|
||||
duration: CASCADE_DURATION,
|
||||
delay: i as f32 * CASCADE_STAGGER,
|
||||
duration,
|
||||
delay: i as f32 * step,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -396,12 +443,21 @@ fn enqueue_toasts(
|
||||
/// This is the second half of the two-system toast queue (Task #67). When the
|
||||
/// active toast's timer reaches zero the entity is despawned and the next
|
||||
/// message in `ToastQueue` is shown.
|
||||
/// Pops and displays queued toasts one at a time, despawning each after
|
||||
/// `QUEUED_TOAST_SECS`.
|
||||
///
|
||||
/// Skipped while the game is paused so the active toast timer freezes and no
|
||||
/// new messages are dequeued.
|
||||
fn drive_toast_display(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut queue: ResMut<ToastQueue>,
|
||||
mut active: ResMut<ActiveToast>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Tick down the active toast timer.
|
||||
@@ -459,11 +515,19 @@ fn handle_xp_awarded_toast(mut commands: Commands, mut events: EventReader<XpAwa
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
||||
///
|
||||
/// Skipped while the game is paused so toast countdowns freeze along with the
|
||||
/// rest of the animation systems.
|
||||
fn tick_toasts(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut toasts: Query<(Entity, &mut ToastTimer)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut timer) in &mut toasts {
|
||||
timer.0 -= dt;
|
||||
@@ -742,4 +806,48 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(after, 52, "all 52 cards should have cascade animations");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #52 — cascade timing helper tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cascade_step_normal_is_expected_value() {
|
||||
assert!((cascade_step_secs(AnimSpeed::Normal) - 0.05).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_step_fast_is_half_normal() {
|
||||
let normal = cascade_step_secs(AnimSpeed::Normal);
|
||||
let fast = cascade_step_secs(AnimSpeed::Fast);
|
||||
assert!(
|
||||
(fast - normal / 2.0).abs() < 1e-6,
|
||||
"Fast cascade step must be half of Normal; normal={normal} fast={fast}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_step_instant_is_zero() {
|
||||
assert_eq!(cascade_step_secs(AnimSpeed::Instant), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_duration_normal_is_expected_value() {
|
||||
assert!((cascade_duration_secs(AnimSpeed::Normal) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_duration_fast_is_half_normal() {
|
||||
let normal = cascade_duration_secs(AnimSpeed::Normal);
|
||||
let fast = cascade_duration_secs(AnimSpeed::Fast);
|
||||
assert!(
|
||||
(fast - normal / 2.0).abs() < 1e-6,
|
||||
"Fast cascade duration must be half of Normal; normal={normal} fast={fast}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_duration_instant_is_zero() {
|
||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ use kira::tween::Tween;
|
||||
use kira::Volume;
|
||||
|
||||
use crate::events::{
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, UndoRequestEvent,
|
||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -136,6 +136,7 @@ impl Plugin for AudioPlugin {
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<CardFlippedEvent>()
|
||||
.add_event::<CardFaceRevealedEvent>()
|
||||
.add_event::<UndoRequestEvent>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(Startup, apply_initial_volume)
|
||||
@@ -147,7 +148,7 @@ impl Plugin for AudioPlugin {
|
||||
play_on_rejected,
|
||||
play_on_new_game,
|
||||
play_on_win,
|
||||
play_on_card_flip,
|
||||
play_on_face_revealed,
|
||||
play_on_undo,
|
||||
apply_volume_on_change,
|
||||
handle_mute_keys,
|
||||
@@ -226,6 +227,27 @@ fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioState {
|
||||
/// Plays `sound` through the SFX sub-track at `volume` amplitude (0.0–1.0+).
|
||||
///
|
||||
/// Behaves identically to the crate-private `play()` function but accepts an
|
||||
/// explicit volume override so callers can play sounds at a fraction of their
|
||||
/// normal level. Silently does nothing when audio is unavailable.
|
||||
pub fn play_sfx_at_volume(&mut self, sound: &StaticSoundData, volume: f64) {
|
||||
let Some(manager) = self.manager.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut data = sound.clone();
|
||||
data.settings.volume = Volume::Amplitude(volume).into();
|
||||
if let Some(track) = &self.sfx_track {
|
||||
data.settings.output_destination = track.id().into();
|
||||
}
|
||||
if let Err(e) = manager.play(data) {
|
||||
warn!("failed to play SFX at volume {volume}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
||||
if let Some(track) = audio.sfx_track.as_mut() {
|
||||
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
||||
@@ -390,8 +412,13 @@ fn play_on_win(
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_card_flip(
|
||||
mut events: EventReader<CardFlippedEvent>,
|
||||
/// Plays the card-flip sound at the animation midpoint — the instant the face
|
||||
/// is visually revealed — keeping audio and visuals in sync.
|
||||
///
|
||||
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
|
||||
/// the phase transition (scale.x crosses 0), not by the move event itself.
|
||||
fn play_on_face_revealed(
|
||||
mut events: EventReader<CardFaceRevealedEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
|
||||
@@ -10,10 +10,17 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Volume amplitude used for the auto-complete activation chime.
|
||||
///
|
||||
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
||||
/// both normal card-place sounds and the full win fanfare that fires later.
|
||||
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Seconds between consecutive auto-complete moves.
|
||||
const STEP_INTERVAL: f32 = 0.12;
|
||||
|
||||
@@ -34,7 +41,11 @@ impl Plugin for AutoCompletePlugin {
|
||||
app.init_resource::<AutoCompleteState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(detect_auto_complete, drive_auto_complete)
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
@@ -66,6 +77,30 @@ fn detect_auto_complete(
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a distinct chime the moment auto-complete first activates.
|
||||
///
|
||||
/// Uses a `Local<bool>` to remember the previous `active` state and fires
|
||||
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
||||
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
||||
/// not overwhelm the card-place sounds that follow immediately.
|
||||
fn on_auto_complete_start(
|
||||
state: Res<AutoCompleteState>,
|
||||
mut was_active: Local<bool>,
|
||||
mut audio: Option<NonSendMut<AudioState>>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let now_active = state.active;
|
||||
let edge = now_active && !*was_active;
|
||||
*was_active = now_active;
|
||||
|
||||
if !edge {
|
||||
return;
|
||||
}
|
||||
|
||||
let (Some(audio), Some(lib)) = (audio.as_mut(), lib) else { return };
|
||||
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
||||
}
|
||||
|
||||
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||
fn drive_auto_complete(
|
||||
mut state: ResMut<AutoCompleteState>,
|
||||
|
||||
@@ -22,7 +22,7 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||
use crate::events::{CardFlippedEvent, StateChangedEvent};
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
@@ -87,6 +87,11 @@ pub struct HintHighlight {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RightClickHighlight;
|
||||
|
||||
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
|
||||
/// marker when the stock pile is empty.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockEmptyLabel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -137,7 +142,8 @@ impl Plugin for CardPlugin {
|
||||
app.init_resource::<ButtonInput<MouseButton>>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_event::<CardFlippedEvent>()
|
||||
.add_systems(PostStartup, sync_cards_startup)
|
||||
.add_event::<CardFaceRevealedEvent>()
|
||||
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -148,6 +154,9 @@ impl Plugin for CardPlugin {
|
||||
update_drag_shadow,
|
||||
tick_hint_highlight,
|
||||
handle_right_click,
|
||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||
clear_right_click_highlights_on_pause,
|
||||
update_stock_empty_indicator.after(GameMutation),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -508,16 +517,18 @@ fn start_flip_anim(
|
||||
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||
///
|
||||
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
||||
/// - At the midpoint the phase switches to `ScalingUp` and scale.x resets to 0.
|
||||
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
|
||||
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
|
||||
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
||||
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||
fn tick_flip_anim(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut CardFlipAnim)>,
|
||||
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
||||
mut reveal_events: EventWriter<CardFaceRevealedEvent>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
||||
anim.timer += dt;
|
||||
match anim.phase {
|
||||
FlipPhase::ScalingDown => {
|
||||
@@ -527,6 +538,9 @@ fn tick_flip_anim(
|
||||
anim.phase = FlipPhase::ScalingUp;
|
||||
anim.timer = 0.0;
|
||||
transform.scale.x = 0.0;
|
||||
// Fire the reveal event exactly once, at the phase transition,
|
||||
// so the flip sound is synchronised with the visual face reveal.
|
||||
reveal_events.send(CardFaceRevealedEvent(card_entity.card_id));
|
||||
}
|
||||
}
|
||||
FlipPhase::ScalingUp => {
|
||||
@@ -650,6 +664,59 @@ const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
|
||||
/// Restored color for `PileMarker` sprites when the highlight is cleared.
|
||||
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
|
||||
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
||||
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
||||
///
|
||||
/// Shared by the on-state-change and on-pause clear systems to avoid
|
||||
/// duplicating the removal logic.
|
||||
fn clear_right_click_highlights(
|
||||
commands: &mut Commands,
|
||||
highlighted: &Query<Entity, With<RightClickHighlight>>,
|
||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
for entity in highlighted.iter() {
|
||||
commands.entity(entity).remove::<RightClickHighlight>();
|
||||
}
|
||||
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
|
||||
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all right-click destination highlights whenever any game-state
|
||||
/// mutation succeeds (`StateChangedEvent` fires).
|
||||
///
|
||||
/// This ensures stale highlights do not linger after a card is moved.
|
||||
fn clear_right_click_highlights_on_state_change(
|
||||
mut events: EventReader<StateChangedEvent>,
|
||||
mut commands: Commands,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||
}
|
||||
|
||||
/// Clears all right-click destination highlights when the game is paused
|
||||
/// (`PausedResource` changes to `true`).
|
||||
///
|
||||
/// Prevents highlighted pile markers from remaining visible behind the pause
|
||||
/// overlay.
|
||||
fn clear_right_click_highlights_on_pause(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut commands: Commands,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
let Some(paused) = paused else { return };
|
||||
if paused.is_changed() && paused.0 {
|
||||
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles right-click: highlights legal destination piles for the clicked card,
|
||||
/// and clears highlights on any subsequent right- or left-click.
|
||||
///
|
||||
@@ -766,6 +833,117 @@ fn find_top_card_at(
|
||||
best.map(|(_, card)| card)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Stock-empty visual indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||
/// to signal to the player that there are no more cards to draw.
|
||||
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
|
||||
|
||||
/// Sprite colour applied to the stock `PileMarker` when cards remain in stock.
|
||||
const STOCK_NORMAL_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
|
||||
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
|
||||
///
|
||||
/// If the stock pile is empty the marker sprite is dimmed to
|
||||
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
|
||||
/// spawned (if not already present). When the stock is non-empty the marker is
|
||||
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
|
||||
/// despawned.
|
||||
fn apply_stock_empty_indicator(
|
||||
commands: &mut Commands,
|
||||
game: &GameState,
|
||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
label_children: &Query<(Entity, &Parent), With<StockEmptyLabel>>,
|
||||
layout: &Layout,
|
||||
) {
|
||||
let stock_empty = game
|
||||
.piles
|
||||
.get(&PileType::Stock)
|
||||
.is_none_or(|p| p.cards.is_empty());
|
||||
|
||||
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
|
||||
if pile_marker.0 != PileType::Stock {
|
||||
continue;
|
||||
}
|
||||
|
||||
if stock_empty {
|
||||
// Dim the marker sprite.
|
||||
sprite.color = STOCK_EMPTY_DIM_COLOUR;
|
||||
|
||||
// Spawn the "↺" label only if one does not already exist.
|
||||
let already_has_label = label_children
|
||||
.iter()
|
||||
.any(|(_, parent)| parent.get() == entity);
|
||||
if !already_has_label {
|
||||
let font_size = layout.card_size.x * 0.4;
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
StockEmptyLabel,
|
||||
Text2d::new("↺"),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.7)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Restore normal brightness.
|
||||
sprite.color = STOCK_NORMAL_COLOUR;
|
||||
|
||||
// Despawn any existing "↺" label children.
|
||||
for (label_entity, parent) in label_children.iter() {
|
||||
if parent.get() == entity {
|
||||
commands.entity(label_entity).despawn_recursive();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
|
||||
/// game state (before any `StateChangedEvent` fires).
|
||||
fn update_stock_empty_indicator_startup(
|
||||
mut commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
|
||||
) {
|
||||
let Some(layout) = layout else { return };
|
||||
apply_stock_empty_indicator(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&mut pile_markers,
|
||||
&label_children,
|
||||
&layout.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
|
||||
/// stock pile marker dim state and "↺" label in sync with the current stock.
|
||||
fn update_stock_empty_indicator(
|
||||
mut events: EventReader<StateChangedEvent>,
|
||||
mut commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
label_children: Query<(Entity, &Parent), With<StockEmptyLabel>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout else { return };
|
||||
apply_stock_empty_indicator(
|
||||
&mut commands,
|
||||
&game.0,
|
||||
&mut pile_markers,
|
||||
&label_children,
|
||||
&layout.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -44,6 +44,7 @@ fn advance_on_challenge_win(
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut advanced: EventWriter<ChallengeAdvancedEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for _ in wins.read() {
|
||||
if game.0.mode != GameMode::Challenge {
|
||||
@@ -56,6 +57,9 @@ fn advance_on_challenge_win(
|
||||
warn!("failed to save progress after challenge advance: {e}");
|
||||
}
|
||||
}
|
||||
// Human-readable level is 1-based (index 0 → "Challenge 1").
|
||||
let level_number = prev.saturating_add(1);
|
||||
toast.send(InfoToastEvent(format!("Challenge {level_number} complete!")));
|
||||
advanced.send(ChallengeAdvancedEvent {
|
||||
previous_index: prev,
|
||||
new_index: progress.0.challenge_index,
|
||||
@@ -199,6 +203,48 @@ mod tests {
|
||||
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_win_fires_complete_toast_with_level_number() {
|
||||
let mut app = headless_app();
|
||||
// Set challenge_index to 2 so the completed level is "Challenge 3".
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.challenge_index = 2;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
|
||||
assert!(
|
||||
fired[0].0.contains("Challenge 3"),
|
||||
"toast must name the 1-based level that was just completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_win_does_not_fire_challenge_complete_toast() {
|
||||
let mut app = headless_app();
|
||||
// Default mode is Classic.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(
|
||||
cursor.read(events).next().is_none(),
|
||||
"no challenge toast should fire for non-Challenge wins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_x_below_unlock_level_fires_info_toast() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -216,10 +216,6 @@ mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::card::{Card, Rank};
|
||||
|
||||
fn face_up(suit: Suit, rank: Rank) -> Card {
|
||||
Card { id: 0, suit, rank, face_up: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_center_is_inside() {
|
||||
assert!(point_in_rect(Vec2::ZERO, Vec2::ZERO, Vec2::new(10.0, 10.0)));
|
||||
|
||||
@@ -18,7 +18,7 @@ use chrono::{Local, NaiveDate};
|
||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||
use solitaire_sync::ChallengeGoal;
|
||||
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -143,6 +143,7 @@ fn poll_server_challenge(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_daily_completion(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
daily: Res<DailyChallengeResource>,
|
||||
@@ -151,6 +152,7 @@ fn handle_daily_completion(
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
||||
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for ev in wins.read() {
|
||||
if game.0.seed != daily.seed {
|
||||
@@ -182,6 +184,7 @@ fn handle_daily_completion(
|
||||
date: daily.date,
|
||||
streak: progress.0.daily_challenge_streak,
|
||||
});
|
||||
toast.send(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ pub struct GameWonEvent {
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
|
||||
/// Fired by the flip animation at its midpoint — the instant the card face
|
||||
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
|
||||
///
|
||||
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
||||
/// so the flip sound is synchronised with the visual reveal, not the move
|
||||
/// that triggered the animation.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct CardFaceRevealedEvent(pub u32);
|
||||
|
||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||
/// persistence/UI systems that need unlock metadata.
|
||||
|
||||
@@ -21,20 +21,31 @@
|
||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
||||
//! pile's position to its current (final) position with a per-card stagger of
|
||||
//! 0.04 s. `deal_stagger_delay` is a pure helper exposed for unit testing.
|
||||
//! pile's position to its current (final) position with a per-card stagger
|
||||
//! derived from the current `AnimSpeed` setting:
|
||||
//!
|
||||
//! | `AnimSpeed` | Stagger |
|
||||
//! |---------------|-------------------|
|
||||
//! | `Normal` | 0.04 s (default) |
|
||||
//! | `Fast` | 0.02 s (half) |
|
||||
//! | `Instant` | 0.00 s (no delay) |
|
||||
//!
|
||||
//! `deal_stagger_delay` is a pure helper exposed for unit testing.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{MoveRejectedEvent, NewGameRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared constants
|
||||
@@ -114,17 +125,34 @@ pub fn settle_scale(elapsed: f32) -> f32 {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #69 — Stagger delay helper
|
||||
// Task #69 — Stagger delay helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the stagger delay in seconds for card at position `index` during the
|
||||
/// deal animation.
|
||||
/// Returns the per-card stagger delay in seconds for the given `AnimSpeed`.
|
||||
///
|
||||
/// `delay = index * DEAL_STAGGER_SECS`
|
||||
/// | `AnimSpeed` | Returned value |
|
||||
/// |---------------|----------------|
|
||||
/// | `Normal` | `DEAL_STAGGER_SECS` (0.04 s) |
|
||||
/// | `Fast` | `DEAL_STAGGER_SECS / 2` (0.02 s) |
|
||||
/// | `Instant` | `0.0` — all cards appear simultaneously |
|
||||
///
|
||||
/// This is a pure function exposed for unit testing without Bevy.
|
||||
pub fn deal_stagger_delay(index: usize) -> f32 {
|
||||
index as f32 * DEAL_STAGGER_SECS
|
||||
pub fn deal_stagger_secs_for_speed(speed: &AnimSpeed) -> f32 {
|
||||
match speed {
|
||||
AnimSpeed::Normal => DEAL_STAGGER_SECS,
|
||||
AnimSpeed::Fast => DEAL_STAGGER_SECS / 2.0,
|
||||
AnimSpeed::Instant => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stagger delay in seconds for card at position `index` during the
|
||||
/// deal animation, given a per-card stagger interval.
|
||||
///
|
||||
/// `delay = index * stagger_secs`
|
||||
///
|
||||
/// This is a pure function exposed for unit testing without Bevy.
|
||||
pub fn deal_stagger_delay(index: usize, stagger_secs: f32) -> f32 {
|
||||
index as f32 * stagger_secs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -186,12 +214,16 @@ fn start_shake_anim(
|
||||
///
|
||||
/// Applies `translation.x = shake_offset(elapsed, origin_x)`. When done,
|
||||
/// restores `translation.x = origin_x` so the card is left at its correct
|
||||
/// position.
|
||||
/// position. Skipped while the game is paused.
|
||||
fn tick_shake_anim(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -238,12 +270,16 @@ fn start_settle_anim(
|
||||
/// Advances `SettleAnim` each frame and removes it once the animation completes.
|
||||
///
|
||||
/// Applies `transform.scale.y = settle_scale(elapsed)`. Restores scale to 1.0
|
||||
/// when done.
|
||||
/// when done. Skipped while the game is paused.
|
||||
fn tick_settle_anim(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -262,14 +298,16 @@ fn tick_settle_anim(
|
||||
|
||||
/// Inserts `CardAnim` on every card entity when a new game starts, sliding
|
||||
/// each card from the stock pile position to its final position with a
|
||||
/// per-card stagger of `DEAL_STAGGER_SECS`.
|
||||
/// per-card stagger derived from the current `AnimSpeed` setting.
|
||||
///
|
||||
/// Triggered by `NewGameRequestEvent` (when the new game has `move_count == 0`)
|
||||
/// and fires the deal animation for every card entity currently in the world.
|
||||
/// The stagger is looked up from `SettingsResource` via `deal_stagger_secs_for_speed`.
|
||||
fn start_deal_anim(
|
||||
mut events: EventReader<NewGameRequestEvent>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_entities: Query<(Entity, &Transform), With<CardEntity>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
@@ -284,6 +322,11 @@ fn start_deal_anim(
|
||||
let Some(&stock_pos) = layout.0.pile_positions.get(&PileType::Stock) else { return };
|
||||
let stock_start = Vec3::new(stock_pos.x, stock_pos.y, 0.0);
|
||||
|
||||
let speed = settings.as_ref().map(|s| &s.0.animation_speed);
|
||||
let stagger_secs = speed
|
||||
.map(deal_stagger_secs_for_speed)
|
||||
.unwrap_or(DEAL_STAGGER_SECS);
|
||||
|
||||
for (index, (entity, transform)) in card_entities.iter().enumerate() {
|
||||
let final_pos = transform.translation;
|
||||
commands.entity(entity).insert((
|
||||
@@ -293,7 +336,7 @@ fn start_deal_anim(
|
||||
target: final_pos,
|
||||
elapsed: 0.0,
|
||||
duration: DEAL_SLIDE_SECS,
|
||||
delay: deal_stagger_delay(index),
|
||||
delay: deal_stagger_delay(index, stagger_secs),
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -359,17 +402,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_delay_zero_index_is_zero() {
|
||||
assert_eq!(deal_stagger_delay(0), 0.0);
|
||||
assert_eq!(deal_stagger_delay(0, DEAL_STAGGER_SECS), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_delay_returns_index_times_constant() {
|
||||
fn deal_stagger_delay_returns_index_times_stagger() {
|
||||
let stagger = DEAL_STAGGER_SECS;
|
||||
for i in 0..52 {
|
||||
let expected = i as f32 * DEAL_STAGGER_SECS;
|
||||
let actual = deal_stagger_delay(i);
|
||||
let expected = i as f32 * stagger;
|
||||
let actual = deal_stagger_delay(i, stagger);
|
||||
assert!(
|
||||
(actual - expected).abs() < 1e-6,
|
||||
"deal_stagger_delay({i}) expected {expected}, got {actual}"
|
||||
"deal_stagger_delay({i}, {stagger}) expected {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_secs_normal_is_constant() {
|
||||
assert!((deal_stagger_secs_for_speed(&AnimSpeed::Normal) - DEAL_STAGGER_SECS).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_secs_fast_is_half_normal() {
|
||||
let fast = deal_stagger_secs_for_speed(&AnimSpeed::Fast);
|
||||
let normal = deal_stagger_secs_for_speed(&AnimSpeed::Normal);
|
||||
assert!(
|
||||
(fast - normal / 2.0).abs() < 1e-6,
|
||||
"Fast stagger must be half of Normal, got fast={fast} normal={normal}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_secs_instant_is_zero() {
|
||||
assert_eq!(deal_stagger_secs_for_speed(&AnimSpeed::Instant), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_delay_instant_is_always_zero() {
|
||||
let stagger = deal_stagger_secs_for_speed(&AnimSpeed::Instant);
|
||||
for i in 0..52 {
|
||||
assert_eq!(
|
||||
deal_stagger_delay(i, stagger),
|
||||
0.0,
|
||||
"Instant speed must produce zero delay for index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
|
||||
save_game_state_to};
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
StateChangedEvent, UndoRequestEvent,
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||
|
||||
@@ -317,10 +317,38 @@ fn handle_draw(
|
||||
mut draws: EventReader<DrawRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut flipped: EventWriter<CardFlippedEvent>,
|
||||
) {
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
for _ in draws.read() {
|
||||
// Capture which cards are about to be drawn (top of the stock pile)
|
||||
// so we can fire flip events after they land face-up in the waste.
|
||||
// Only relevant when stock is non-empty; a recycle moves waste back to
|
||||
// stock face-down, so no flip events are needed in that case.
|
||||
let drawn_ids: Vec<u32> = {
|
||||
let stock = game.0.piles.get(&PileType::Stock);
|
||||
match stock {
|
||||
Some(p) if !p.cards.is_empty() => {
|
||||
let draw_count = match game.0.draw_mode {
|
||||
DrawMode::DrawOne => 1_usize,
|
||||
DrawMode::DrawThree => 3_usize,
|
||||
};
|
||||
let n = p.cards.len();
|
||||
let take = n.min(draw_count);
|
||||
// The top `take` cards (at the end of the vec) will be drawn.
|
||||
p.cards[n - take..].iter().map(|c| c.id).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
match game.0.draw() {
|
||||
Ok(()) => {
|
||||
// Fire a flip event for each card that moved from stock to waste.
|
||||
for id in drawn_ids {
|
||||
flipped.send(CardFlippedEvent(id));
|
||||
}
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
Err(e) => warn!("draw rejected: {e}"),
|
||||
@@ -383,12 +411,18 @@ fn handle_undo(
|
||||
mut undos: EventReader<UndoRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
use solitaire_core::error::MoveError;
|
||||
|
||||
for _ in undos.read() {
|
||||
match game.0.undo() {
|
||||
Ok(()) => {
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
Err(MoveError::UndoStackEmpty) => {
|
||||
toast.send(InfoToastEvent("Nothing to undo".to_string()));
|
||||
}
|
||||
Err(e) => warn!("undo rejected: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -509,7 +543,10 @@ fn check_no_moves(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the full-screen game-over overlay with score display and action buttons.
|
||||
/// Spawns the full-screen game-over overlay with score display and action hints.
|
||||
///
|
||||
/// The background is intentionally semi-transparent (alpha 0.6) so the stuck
|
||||
/// card layout remains visible behind the dialog.
|
||||
fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
||||
commands
|
||||
.spawn((
|
||||
@@ -526,7 +563,7 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
||||
row_gap: Val::Px(20.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.78)),
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
@@ -543,9 +580,9 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
||||
BorderRadius::all(Val::Px(12.0)),
|
||||
))
|
||||
.with_children(|card| {
|
||||
// Title
|
||||
// Header — explains why the overlay appeared.
|
||||
card.spawn((
|
||||
Text::new("No More Moves"),
|
||||
Text::new("No more moves available"),
|
||||
TextFont { font_size: 36.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.4, 0.1)),
|
||||
));
|
||||
@@ -555,23 +592,26 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
||||
TextFont { font_size: 24.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
// Button row
|
||||
card.spawn((Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(24.0),
|
||||
// Action hints — stacked vertically for legibility.
|
||||
card.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(8.0),
|
||||
margin: UiRect::top(Val::Px(8.0)),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("New Game (N)"),
|
||||
},
|
||||
))
|
||||
.with_children(|hints| {
|
||||
hints.spawn((
|
||||
Text::new("Press N or Escape for a new game"),
|
||||
TextFont { font_size: 20.0, ..default() },
|
||||
TextColor(Color::srgb(0.3, 1.0, 0.4)),
|
||||
));
|
||||
row.spawn((
|
||||
Text::new("Undo (U)"),
|
||||
hints.spawn((
|
||||
Text::new("Press G to forfeit (counts as a loss)"),
|
||||
TextFont { font_size: 20.0, ..default() },
|
||||
TextColor(Color::srgb(0.6, 0.8, 1.0)),
|
||||
TextColor(Color::srgb(1.0, 0.6, 0.2)),
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -580,10 +620,10 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
||||
|
||||
/// Handles keyboard input while `GameOverScreen` is open.
|
||||
///
|
||||
/// `N` fires `NewGameRequestEvent` (which will trigger the confirm dialog if
|
||||
/// moves have been made). `U` fires `UndoRequestEvent` and despawns the overlay
|
||||
/// — the `check_no_moves` system will re-show it on the next `StateChangedEvent`
|
||||
/// if the undo did not restore any legal moves.
|
||||
/// `N` or `Escape` fires `NewGameRequestEvent` (which will trigger the confirm
|
||||
/// dialog if moves have been made). `U` fires `UndoRequestEvent` and despawns
|
||||
/// the overlay — the `check_no_moves` system will re-show it on the next
|
||||
/// `StateChangedEvent` if the undo did not restore any legal moves.
|
||||
fn handle_game_over_input(
|
||||
mut commands: Commands,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
@@ -598,7 +638,7 @@ fn handle_game_over_input(
|
||||
return;
|
||||
};
|
||||
|
||||
if keys.just_pressed(KeyCode::KeyN) {
|
||||
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
} else if keys.just_pressed(KeyCode::KeyU) {
|
||||
for entity in &screens {
|
||||
@@ -1171,4 +1211,166 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(count, 1, "GameOverScreen must appear when no legal moves exist");
|
||||
}
|
||||
|
||||
/// Verify that the game-over overlay contains the expected header text and
|
||||
/// action-hint strings so players understand why the overlay appeared and
|
||||
/// what keys to press.
|
||||
#[test]
|
||||
fn game_over_screen_text_content() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state identical to `game_over_screen_spawns_when_stuck`.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().send_event(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Collect all Text values that are children of the GameOverScreen entity tree.
|
||||
let texts: Vec<String> = app
|
||||
.world_mut()
|
||||
.query::<&Text>()
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "No more moves available"),
|
||||
"header must read 'No more moves available'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Press N or Escape for a new game"),
|
||||
"hint 1 must read 'Press N or Escape for a new game'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Press G to forfeit (counts as a loss)"),
|
||||
"hint 2 must read 'Press G to forfeit (counts as a loss)'; found: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #56 — Escape dismisses GameOverScreen and starts new game
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing Escape while `GameOverScreen` is visible must fire
|
||||
/// `NewGameRequestEvent` — identical behaviour to pressing N.
|
||||
#[test]
|
||||
fn escape_on_game_over_screen_fires_new_game_request() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state so GameOverScreen spawns.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
app.world_mut().send_event(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Confirm the overlay is present.
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"GameOverScreen must be present before pressing Escape"
|
||||
);
|
||||
|
||||
// Clear the NewGameRequestEvent queue so we start with a clean slate.
|
||||
app.world_mut().resource_mut::<Events<NewGameRequestEvent>>().clear();
|
||||
|
||||
// Simulate Escape press.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.clear();
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
app.update();
|
||||
|
||||
// NewGameRequestEvent must have been fired.
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
assert!(
|
||||
reader.read(events).next().is_some(),
|
||||
"Escape on GameOverScreen must fire NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #48 — Undo with empty stack fires InfoToastEvent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Sending `UndoRequestEvent` on a fresh game (empty undo stack) must fire
|
||||
/// exactly one `InfoToastEvent` with the message "Nothing to undo".
|
||||
#[test]
|
||||
fn undo_on_empty_stack_fires_info_toast() {
|
||||
let mut app = test_app(42);
|
||||
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
|
||||
app.world_mut().send_event(UndoRequestEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let fired: Vec<_> = reader.read(events).collect();
|
||||
assert_eq!(fired.len(), 1, "exactly one InfoToastEvent must fire on empty-stack undo");
|
||||
assert_eq!(
|
||||
fired[0].0,
|
||||
"Nothing to undo",
|
||||
"toast message must be 'Nothing to undo'"
|
||||
);
|
||||
}
|
||||
|
||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||
#[test]
|
||||
fn undo_after_draw_does_not_fire_info_toast() {
|
||||
let mut app = test_app(42);
|
||||
// Make a move so the undo stack is non-empty.
|
||||
app.world_mut().send_event(DrawRequestEvent);
|
||||
app.update();
|
||||
// Clear events from the draw so we start with a clean slate.
|
||||
app.world_mut().resource_mut::<Events<InfoToastEvent>>().clear();
|
||||
|
||||
app.world_mut().send_event(UndoRequestEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let fired: Vec<_> = reader.read(events).collect();
|
||||
assert!(
|
||||
fired.is_empty(),
|
||||
"no InfoToastEvent must fire on a successful undo"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Toggleable on-screen help / cheat sheet showing keyboard bindings.
|
||||
//!
|
||||
//! Press **H** (or `?`) to toggle. Listed shortcuts are grouped by intent —
|
||||
//! Press **F1** to toggle. Listed shortcuts are grouped by intent —
|
||||
//! gameplay, modes, and overlays.
|
||||
|
||||
use bevy::prelude::*;
|
||||
@@ -22,8 +22,7 @@ fn toggle_help_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screens: Query<Entity, With<HelpScreen>>,
|
||||
) {
|
||||
let pressed_help = keys.just_pressed(KeyCode::KeyH) || keys.just_pressed(KeyCode::Slash);
|
||||
if !pressed_help {
|
||||
if !keys.just_pressed(KeyCode::F1) {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
@@ -55,11 +54,12 @@ fn spawn_help_screen(commands: &mut Commands) {
|
||||
" A Achievements".to_string(),
|
||||
" L Leaderboard".to_string(),
|
||||
" O Settings".to_string(),
|
||||
" H or ? This help screen".to_string(),
|
||||
" F1 This help screen".to_string(),
|
||||
" F11 Toggle fullscreen".to_string(),
|
||||
" Esc Pause / resume".to_string(),
|
||||
" [ / ] SFX volume down / up".to_string(),
|
||||
String::new(),
|
||||
"Press H or ? to close".to_string(),
|
||||
"Press F1 to close".to_string(),
|
||||
];
|
||||
|
||||
commands
|
||||
@@ -107,11 +107,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_h_spawns_help_screen() {
|
||||
fn pressing_f1_spawns_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyH);
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
@@ -124,18 +124,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_h_twice_closes_help_screen() {
|
||||
fn pressing_f1_twice_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyH);
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.release(KeyCode::F1);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
input.press(KeyCode::F1);
|
||||
}
|
||||
app.update();
|
||||
|
||||
@@ -147,21 +147,4 @@ mod tests {
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_slash_also_toggles_help() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Slash);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HelpScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Toggleable main menu overlay showing the current game mode and a full
|
||||
//! keyboard shortcut reference.
|
||||
//!
|
||||
//! Press **M** to open or close the overlay.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Marker component on the home-menu overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HomeScreen;
|
||||
|
||||
/// Registers the M-key toggle and the overlay spawn/despawn logic.
|
||||
pub struct HomePlugin;
|
||||
|
||||
impl Plugin for HomePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, toggle_home_screen);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_home_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
game: Res<GameStateResource>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyM) {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
} else {
|
||||
spawn_home_screen(&mut commands, &game);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the full-window home-menu overlay derived from the current `game` state.
|
||||
fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) {
|
||||
let mode_label = match game.0.mode {
|
||||
GameMode::Classic => "Classic",
|
||||
GameMode::Zen => "Zen",
|
||||
GameMode::Challenge => "Challenge",
|
||||
GameMode::TimeAttack => "Time Attack",
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
HomeScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(6.0),
|
||||
padding: UiRect::all(Val::Px(24.0)),
|
||||
overflow: Overflow::clip(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// Title
|
||||
root.spawn((
|
||||
Text::new("Solitaire Quest"),
|
||||
TextFont { font_size: 48.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.3)),
|
||||
));
|
||||
|
||||
// Mode subtitle
|
||||
root.spawn((
|
||||
Text::new(format!("Current mode: {mode_label}")),
|
||||
TextFont { font_size: 28.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.8, 0.8)),
|
||||
));
|
||||
|
||||
// Spacer
|
||||
root.spawn(Node {
|
||||
height: Val::Px(8.0),
|
||||
..default()
|
||||
});
|
||||
|
||||
// "Game Controls" section header
|
||||
root.spawn((
|
||||
Text::new("Game Controls"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.9, 0.9, 0.9)),
|
||||
));
|
||||
|
||||
spawn_shortcut_row(root, "N", "New game (N again confirms)");
|
||||
spawn_shortcut_row(root, "U", "Undo last move");
|
||||
spawn_shortcut_row(root, "Space / D", "Draw from stock");
|
||||
spawn_shortcut_row(root, "G", "Forfeit current game");
|
||||
spawn_shortcut_row(root, "Tab", "Cycle hint highlight");
|
||||
spawn_shortcut_row(root, "Enter", "Auto-complete if available");
|
||||
|
||||
// Spacer
|
||||
root.spawn(Node {
|
||||
height: Val::Px(8.0),
|
||||
..default()
|
||||
});
|
||||
|
||||
// "Screens" section header
|
||||
root.spawn((
|
||||
Text::new("Screens"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.9, 0.9, 0.9)),
|
||||
));
|
||||
|
||||
spawn_shortcut_row(root, "M", "Main menu (this screen)");
|
||||
spawn_shortcut_row(root, "S", "Statistics");
|
||||
spawn_shortcut_row(root, "A", "Achievements");
|
||||
spawn_shortcut_row(root, "O", "Settings");
|
||||
spawn_shortcut_row(root, "P", "Profile");
|
||||
spawn_shortcut_row(root, "F1", "Help");
|
||||
spawn_shortcut_row(root, "F11", "Toggle fullscreen");
|
||||
spawn_shortcut_row(root, "Esc", "Pause / Resume");
|
||||
|
||||
// Spacer
|
||||
root.spawn(Node {
|
||||
height: Val::Px(16.0),
|
||||
..default()
|
||||
});
|
||||
|
||||
// Dismiss hint
|
||||
root.spawn((
|
||||
Text::new("Press M to close"),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.55, 0.55)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_shortcut_row(parent: &mut ChildBuilder, key: &str, action: &str) {
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
min_width: Val::Px(380.0),
|
||||
column_gap: Val::Px(16.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(key.to_string()),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.4)),
|
||||
Node {
|
||||
min_width: Val::Px(120.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
row.spawn((
|
||||
Text::new(action.to_string()),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HomePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_m_spawns_home_screen() {
|
||||
let mut app = headless_app();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyM);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_m_twice_closes_home_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyM);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyM);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyM);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,16 @@
|
||||
//! without a separate tick system.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::InfoToastEvent;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
/// Marker on the score text node.
|
||||
@@ -53,6 +56,33 @@ pub struct HudUndos;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudAutoComplete;
|
||||
|
||||
/// Marker on the stock-recycle counter text node.
|
||||
///
|
||||
/// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw
|
||||
/// mode, so the player can track stock recycling in both Draw-One and
|
||||
/// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string)
|
||||
/// until the first recycle occurs.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudRecycles;
|
||||
|
||||
/// Marker on the draw-cycle indicator text node.
|
||||
///
|
||||
/// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the
|
||||
/// number of cards that will be drawn on the next stock click
|
||||
/// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty
|
||||
/// (recycle available). Hidden (empty string) in Draw-One mode or after the
|
||||
/// game is won.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudDrawCycle;
|
||||
|
||||
/// Marker on the keyboard-selection indicator text node.
|
||||
///
|
||||
/// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty
|
||||
/// string when no pile is selected. Uses a light-yellow colour so it stands
|
||||
/// out from the other white HUD items.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudSelection;
|
||||
|
||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||
const Z_HUD: i32 = 50;
|
||||
|
||||
@@ -62,7 +92,8 @@ impl Plugin for HudPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, spawn_hud)
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation));
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +134,7 @@ fn spawn_hud(mut commands: Commands) {
|
||||
b.spawn((
|
||||
HudUndos,
|
||||
Text::new(""),
|
||||
font,
|
||||
font.clone(),
|
||||
white,
|
||||
));
|
||||
// Auto-complete badge (green "AUTO" when sequence is running).
|
||||
@@ -113,6 +144,27 @@ fn spawn_hud(mut commands: Commands) {
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.2, 0.9, 0.3)),
|
||||
));
|
||||
// Recycle counter — hidden until the first recycle in either draw mode.
|
||||
b.spawn((
|
||||
HudRecycles,
|
||||
Text::new(""),
|
||||
font.clone(),
|
||||
white,
|
||||
));
|
||||
// Draw-cycle indicator — only visible in Draw-Three mode.
|
||||
b.spawn((
|
||||
HudDrawCycle,
|
||||
Text::new(""),
|
||||
font,
|
||||
TextColor(Color::srgb(0.7, 0.85, 1.0)),
|
||||
));
|
||||
// Keyboard-selection indicator — shows which pile is Tab-selected.
|
||||
b.spawn((
|
||||
HudSelection,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 1.0, 0.5)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,6 +193,9 @@ fn update_hud(
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut moves_q: Query<
|
||||
@@ -153,6 +208,9 @@ fn update_hud(
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut time_q: Query<
|
||||
@@ -165,6 +223,9 @@ fn update_hud(
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut mode_q: Query<
|
||||
@@ -177,6 +238,9 @@ fn update_hud(
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut challenge_q: Query<
|
||||
@@ -189,6 +253,9 @@ fn update_hud(
|
||||
Without<HudMode>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut undos_q: Query<
|
||||
@@ -201,6 +268,9 @@ fn update_hud(
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut auto_q: Query<
|
||||
@@ -213,6 +283,39 @@ fn update_hud(
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut recycles_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudRecycles>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut draw_cycle_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudDrawCycle>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
@@ -245,16 +348,19 @@ fn update_hud(
|
||||
};
|
||||
}
|
||||
|
||||
// --- Daily challenge constraint ---
|
||||
if let Ok((mut t, _)) = challenge_q.get_single_mut() {
|
||||
**t = if g.is_won {
|
||||
// Hide constraint once the game is over.
|
||||
String::new()
|
||||
// --- Daily challenge constraint (with time-low colour warning) ---
|
||||
if let Ok((mut t, mut color)) = challenge_q.get_single_mut() {
|
||||
if g.is_won {
|
||||
**t = String::new();
|
||||
} else if let Some(dc) = daily.as_deref() {
|
||||
challenge_hud_text(dc)
|
||||
**t = challenge_hud_text(dc);
|
||||
if let Some(max_secs) = dc.max_time_secs {
|
||||
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
|
||||
*color = TextColor(challenge_time_color(remaining));
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
**t = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undo count ---
|
||||
@@ -269,10 +375,32 @@ fn update_hud(
|
||||
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||
if let Ok(mut t) = recycles_q.get_single_mut() {
|
||||
**t = if g.recycle_count > 0 {
|
||||
format!("Recycles: {}", g.recycle_count)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
|
||||
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
||||
if let Ok(mut t) = draw_cycle_q.get_single_mut() {
|
||||
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree {
|
||||
// Hide when not in Draw-Three or after the game is won.
|
||||
String::new()
|
||||
} else {
|
||||
let stock_len = g.piles[&solitaire_core::pile::PileType::Stock].cards.len();
|
||||
let next_draw = stock_len.min(3);
|
||||
format!("Cycle: {next_draw}/3")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Time display: show Time Attack countdown every frame when active;
|
||||
// Zen mode suppresses the timer per spec ("No timer").
|
||||
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
|
||||
// every frame so it disappears immediately on the frame Z is pressed.
|
||||
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||
let is_zen = game.0.mode == GameMode::Zen;
|
||||
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||
@@ -290,8 +418,10 @@ fn update_hud(
|
||||
**t = format!("{m}:{s:02}");
|
||||
}
|
||||
}
|
||||
} else if is_zen && game.is_changed() {
|
||||
// Clear the time display when entering Zen mode.
|
||||
} else if is_zen {
|
||||
// Clear the time display immediately whenever Zen mode is active —
|
||||
// do not guard on game.is_changed() so it clears on the same frame
|
||||
// the player presses Z, before any move is made.
|
||||
if let Ok(mut t) = time_q.get_single_mut() {
|
||||
**t = String::new();
|
||||
}
|
||||
@@ -312,6 +442,34 @@ fn update_hud(
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
||||
///
|
||||
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
|
||||
/// or an empty string when no pile is selected. Runs every frame so the
|
||||
/// indicator stays in sync with the selection resource.
|
||||
fn update_selection_hud(
|
||||
selection: Option<Res<SelectionState>>,
|
||||
mut q: Query<&mut Text, With<HudSelection>>,
|
||||
) {
|
||||
let Ok(mut t) = q.get_single_mut() else { return };
|
||||
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||
None => String::new(),
|
||||
Some(PileType::Waste) => "▶ Waste".to_string(),
|
||||
Some(PileType::Stock) => "▶ Stock".to_string(),
|
||||
Some(PileType::Foundation(suit)) => {
|
||||
let s = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
Suit::Hearts => "Hearts",
|
||||
Suit::Spades => "Spades",
|
||||
};
|
||||
format!("▶ {s} Foundation")
|
||||
}
|
||||
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
|
||||
};
|
||||
**t = label;
|
||||
}
|
||||
|
||||
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
||||
/// to debounce so the toast only appears on the leading edge.
|
||||
@@ -342,6 +500,23 @@ fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the colour for the challenge time-limit HUD label based on seconds remaining.
|
||||
///
|
||||
/// | Remaining | Colour |
|
||||
/// |-------------|--------|
|
||||
/// | ≥ 60 s | Cyan (default) |
|
||||
/// | 30 – 59 s | Orange (warning) |
|
||||
/// | < 30 s | Red (urgent) |
|
||||
pub fn challenge_time_color(remaining: u64) -> Color {
|
||||
if remaining < 30 {
|
||||
Color::srgb(1.0, 0.2, 0.2)
|
||||
} else if remaining < 60 {
|
||||
Color::srgb(1.0, 0.6, 0.0)
|
||||
} else {
|
||||
Color::srgb(0.4, 0.9, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -488,6 +663,42 @@ mod tests {
|
||||
assert_eq!(challenge_hud_text(&dc), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_above_60_is_cyan() {
|
||||
let c = challenge_time_color(61);
|
||||
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_exactly_60_is_cyan() {
|
||||
let c = challenge_time_color(60);
|
||||
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_59_is_orange() {
|
||||
let c = challenge_time_color(59);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_30_is_orange() {
|
||||
let c = challenge_time_color(30);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_29_is_red() {
|
||||
let c = challenge_time_color(29);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_time_color_zero_is_red() {
|
||||
let c = challenge_time_color(0);
|
||||
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudChallenge in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -599,4 +810,49 @@ mod tests {
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudRecycles in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One, no recycles yet — text must be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawMode::DrawOne);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-Three, no recycles yet — text must also be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawMode::DrawThree);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_three() {
|
||||
let mut app = headless_app();
|
||||
let mut gs = GameState::new(42, DrawMode::DrawThree);
|
||||
gs.recycle_count = 3;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_one() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
||||
gs.recycle_count = 2;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! Keyboard:
|
||||
//! - `U` → `UndoRequestEvent`
|
||||
//! - `N` → `NewGameRequestEvent { seed: None }`
|
||||
//! - `N` → `NewGameRequestEvent { seed: None }` (cancels Time Attack if active)
|
||||
//! - `D` / `Space` → `DrawRequestEvent`
|
||||
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
|
||||
//!
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::prelude::*;
|
||||
@@ -28,6 +29,7 @@ use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::{CardEntity, HintHighlight, TABLEAU_FAN_FRAC};
|
||||
use crate::feedback_anim_plugin::ShakeAnim;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
@@ -38,7 +40,8 @@ use crate::game_plugin::GameMutation;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::resources::{DragState, GameStateResource, HintCycleIndex};
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
const DRAG_Z: f32 = 500.0;
|
||||
@@ -54,7 +57,8 @@ pub struct InputPlugin;
|
||||
|
||||
impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_event::<NewGameConfirmEvent>()
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.add_event::<NewGameConfirmEvent>()
|
||||
.add_event::<InfoToastEvent>()
|
||||
.add_event::<ForfeitEvent>()
|
||||
.add_systems(
|
||||
@@ -69,13 +73,29 @@ impl Plugin for InputPlugin {
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(Update, handle_fullscreen);
|
||||
.add_systems(Update, handle_fullscreen)
|
||||
.add_systems(Update, reset_hint_cycle_on_state_change);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds after the first N press during which a second N confirms new game.
|
||||
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.
|
||||
#[derive(SystemParam)]
|
||||
struct KeyboardEvents<'w> {
|
||||
undo: EventWriter<'w, UndoRequestEvent>,
|
||||
new_game: EventWriter<'w, NewGameRequestEvent>,
|
||||
confirm_event: EventWriter<'w, NewGameConfirmEvent>,
|
||||
info_toast: EventWriter<'w, InfoToastEvent>,
|
||||
draw: EventWriter<'w, DrawRequestEvent>,
|
||||
forfeit: EventWriter<'w, ForfeitEvent>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_keyboard(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
@@ -84,15 +104,14 @@ fn handle_keyboard(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
time: Res<Time>,
|
||||
mut confirm_countdown: Local<f32>,
|
||||
mut undo: EventWriter<UndoRequestEvent>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
mut confirm_event: EventWriter<NewGameConfirmEvent>,
|
||||
mut info_toast: EventWriter<InfoToastEvent>,
|
||||
mut draw: EventWriter<DrawRequestEvent>,
|
||||
mut forfeit: EventWriter<ForfeitEvent>,
|
||||
mut confirm_pending: Local<bool>,
|
||||
mut forfeit_countdown: Local<f32>,
|
||||
mut ev: KeyboardEvents,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -102,55 +121,116 @@ fn handle_keyboard(
|
||||
*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;
|
||||
ev.info_toast.send(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) {
|
||||
undo.send(UndoRequestEvent);
|
||||
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
|
||||
ev.undo.send(UndoRequestEvent);
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyN) {
|
||||
// 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 {
|
||||
session.active = false;
|
||||
session.remaining_secs = 0.0;
|
||||
ev.info_toast.send(InfoToastEvent("Time Attack ended".to_string()));
|
||||
ev.new_game.send(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(solitaire_core::game_state::GameMode::Classic),
|
||||
});
|
||||
*confirm_countdown = 0.0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
if shift_held || !active_game {
|
||||
// Shift+N or no active game — start immediately, no confirmation.
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
ev.new_game.send(NewGameRequestEvent::default());
|
||||
*confirm_countdown = 0.0;
|
||||
*confirm_pending = false;
|
||||
} else if *confirm_countdown > 0.0 {
|
||||
// Second press within the window — confirmed.
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
ev.new_game.send(NewGameRequestEvent::default());
|
||||
*confirm_countdown = 0.0;
|
||||
*confirm_pending = false;
|
||||
} else {
|
||||
// First press on an active game — require confirmation.
|
||||
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
|
||||
confirm_event.send(NewGameConfirmEvent);
|
||||
*confirm_pending = true;
|
||||
ev.confirm_event.send(NewGameConfirmEvent);
|
||||
}
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyZ) {
|
||||
if *forfeit_countdown > 0.0 { *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);
|
||||
if level >= CHALLENGE_UNLOCK_LEVEL {
|
||||
new_game.send(NewGameRequestEvent {
|
||||
ev.new_game.send(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
||||
});
|
||||
} else {
|
||||
info_toast.send(InfoToastEvent(format!(
|
||||
ev.info_toast.send(InfoToastEvent(format!(
|
||||
"Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) {
|
||||
draw.send(DrawRequestEvent);
|
||||
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
|
||||
ev.draw.send(DrawRequestEvent);
|
||||
}
|
||||
// H — show a hint (highlight the source card of the best available move).
|
||||
// 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 {
|
||||
if !g.0.is_won {
|
||||
if let Some(ref layout_res) = layout {
|
||||
if let Some((from, _to, _count)) = find_hint(&g.0) {
|
||||
// Find the top face-up card in the source pile.
|
||||
let top_card_id = g.0.piles.get(&from)
|
||||
if g.0.is_won {
|
||||
ev.info_toast.send(InfoToastEvent(
|
||||
"Game won! Press N for a new game".to_string(),
|
||||
));
|
||||
} else if let Some(ref layout_res) = layout {
|
||||
let hints = all_hints(&g.0);
|
||||
if hints.is_empty() {
|
||||
ev.info_toast.send(InfoToastEvent("No hints available".to_string()));
|
||||
} else {
|
||||
// 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
|
||||
// than draw a card, so the toast text must reflect that.
|
||||
if *from == PileType::Stock {
|
||||
let stock_empty = g.0.piles
|
||||
.get(&PileType::Stock)
|
||||
.is_some_and(|p| p.cards.is_empty());
|
||||
let msg = if stock_empty {
|
||||
"Hint: recycle waste (D)".to_string()
|
||||
} else {
|
||||
"Hint: draw from stock (D)".to_string()
|
||||
};
|
||||
ev.info_toast.send(InfoToastEvent(msg));
|
||||
} else {
|
||||
// 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))
|
||||
.map(|c| c.id);
|
||||
if let Some(card_id) = top_card_id {
|
||||
@@ -167,37 +247,88 @@ fn handle_keyboard(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info_toast.send(InfoToastEvent("No hints available".to_string()));
|
||||
// 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 {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
Suit::Hearts => "Hearts",
|
||||
Suit::Spades => "Spades",
|
||||
};
|
||||
format!("Hint: move to {suit_name} foundation")
|
||||
}
|
||||
PileType::Tableau(col) => {
|
||||
format!("Hint: move to tableau (col {})", col + 1)
|
||||
}
|
||||
_ => "Hint: move card".to_string(),
|
||||
};
|
||||
ev.info_toast.send(InfoToastEvent(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// G — forfeit the current game (only when a game is actually in progress).
|
||||
// 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 {
|
||||
forfeit.send(ForfeitEvent);
|
||||
if *forfeit_countdown > 0.0 {
|
||||
// Second press within the confirmation window — confirmed.
|
||||
ev.forfeit.send(ForfeitEvent);
|
||||
*forfeit_countdown = 0.0;
|
||||
} else {
|
||||
// First press — start the countdown and warn the player.
|
||||
*forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
ev.info_toast.send(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
|
||||
/// game is requested so the next H press always starts cycling from the first
|
||||
/// hint of the new position.
|
||||
///
|
||||
/// Listening to both events ensures the reset happens immediately on
|
||||
/// `NewGameRequestEvent`, one frame before the `StateChangedEvent` that the
|
||||
/// game plugin fires after dealing — preventing a stale hint from the previous
|
||||
/// game being shown when H is pressed in that gap frame.
|
||||
fn reset_hint_cycle_on_state_change(
|
||||
mut state_events: EventReader<StateChangedEvent>,
|
||||
mut new_game_events: EventReader<NewGameRequestEvent>,
|
||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||
) {
|
||||
if state_events.read().next().is_some() || new_game_events.read().next().is_some() {
|
||||
hint_cycle.0 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `F11` toggles between borderless-fullscreen and windowed mode.
|
||||
/// Not gated by the pause flag — the player can always resize the window.
|
||||
fn handle_fullscreen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::F11) {
|
||||
return;
|
||||
}
|
||||
let Ok(mut window) = windows.get_single_mut() else { return };
|
||||
window.mode = match window.mode {
|
||||
let new_mode = match window.mode {
|
||||
WindowMode::Windowed => WindowMode::BorderlessFullscreen(MonitorSelection::Current),
|
||||
_ => WindowMode::Windowed,
|
||||
};
|
||||
window.mode = new_mode;
|
||||
let label = match window.mode {
|
||||
WindowMode::Windowed => "Fullscreen: off",
|
||||
_ => "Fullscreen: on",
|
||||
};
|
||||
toast.send(InfoToastEvent(label.to_string()));
|
||||
}
|
||||
|
||||
fn handle_stock_click(
|
||||
@@ -239,7 +370,7 @@ fn start_drag(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut drag: ResMut<DragState>,
|
||||
mut card_transforms: Query<(&CardEntity, &mut Transform)>,
|
||||
mut card_visuals: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -268,13 +399,15 @@ fn start_drag(
|
||||
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.
|
||||
// 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)) = card_transforms
|
||||
if let Some((_, mut transform, mut sprite)) = card_visuals
|
||||
.iter_mut()
|
||||
.find(|(entity, _)| entity.card_id == *id)
|
||||
.find(|(entity, _, _)| entity.card_id == *id)
|
||||
{
|
||||
transform.translation.z = DRAG_Z + (i as f32) * 0.01;
|
||||
sprite.color.set_alpha(0.85);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +461,8 @@ fn end_drag(
|
||||
mut moves: EventWriter<MoveRequestEvent>,
|
||||
mut rejected: EventWriter<MoveRejectedEvent>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
drag.clear();
|
||||
@@ -385,6 +520,19 @@ fn end_drag(
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
// Shake each dragged card so the player gets immediate
|
||||
// visual feedback that the drop was rejected.
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -604,8 +752,43 @@ pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the best tableau column onto which the stack rooted at `bottom_card`
|
||||
/// can be legally placed, excluding the stack's own source pile.
|
||||
///
|
||||
/// Returns `(destination, stack_count)` if a legal target exists, or `None`
|
||||
/// if the stack cannot move anywhere. Only tableau destinations are considered
|
||||
/// because multi-card stacks cannot go to foundations.
|
||||
pub fn best_tableau_destination_for_stack(
|
||||
bottom_card: &Card,
|
||||
from: &PileType,
|
||||
game: &GameState,
|
||||
stack_count: usize,
|
||||
) -> Option<(PileType, usize)> {
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if dest == *from {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest) {
|
||||
if can_place_on_tableau(bottom_card, pile) {
|
||||
return Some((dest, stack_count));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
|
||||
/// to the best legal destination (foundation before tableau).
|
||||
/// to the best legal destination.
|
||||
///
|
||||
/// Move priority:
|
||||
/// 1. Move the single **top** card to its best foundation (or tableau) destination.
|
||||
/// 2. If no single-card move exists and the clicked card is the base of a
|
||||
/// multi-card face-up stack, move the whole stack to the best tableau column.
|
||||
///
|
||||
/// When a multi-card stack double-click finds no legal destination (Priority 2
|
||||
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
|
||||
/// invalid-move sound plays and the source pile cards shake as feedback.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_double_click(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
@@ -618,6 +801,7 @@ fn handle_double_click(
|
||||
game: Res<GameStateResource>,
|
||||
mut last_click: Local<HashMap<u32, f32>>,
|
||||
mut moves: EventWriter<MoveRequestEvent>,
|
||||
mut rejected: EventWriter<MoveRejectedEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -628,18 +812,17 @@ fn handle_double_click(
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(world) = cursor_world(&windows, &cameras) else { return };
|
||||
|
||||
// Identify which card was clicked (must be face-up and draggable).
|
||||
// Identify which card (or stack base) was clicked (must be face-up and draggable).
|
||||
let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
|
||||
return;
|
||||
};
|
||||
// Only auto-move a single card (top card of the stack).
|
||||
let Some(&top_card_id) = card_ids.last() else { return };
|
||||
// The top draggable card is at `stack_index + card_ids.len() - 1`.
|
||||
let top_index = stack_index + card_ids.len() - 1;
|
||||
let Some(card) = game.0.piles.get(&pile)
|
||||
.and_then(|p| p.cards.get(top_index)) else { return };
|
||||
|
||||
if !card.face_up || card.id != top_card_id {
|
||||
// The topmost card in the draggable run — used as the double-click key.
|
||||
let Some(&top_card_id) = card_ids.last() else { return };
|
||||
let top_index = stack_index + card_ids.len() - 1;
|
||||
let Some(top_card) = game.0.piles.get(&pile)
|
||||
.and_then(|p| p.cards.get(top_index)) else { return };
|
||||
if !top_card.face_up || top_card.id != top_card_id {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -647,14 +830,47 @@ fn handle_double_click(
|
||||
let prev = last_click.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
|
||||
|
||||
if now - prev <= DOUBLE_CLICK_WINDOW {
|
||||
// Double-click detected — find and fire the best move.
|
||||
// Double-click confirmed.
|
||||
last_click.remove(&top_card_id);
|
||||
if let Some(dest) = best_destination(card, &game.0) {
|
||||
|
||||
// Priority 1: move the single top card (foundation preferred, then tableau).
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: if the player clicked the base of a multi-card face-up
|
||||
// stack (card_ids.len() > 1), try moving the whole stack to another
|
||||
// tableau column.
|
||||
if card_ids.len() > 1 {
|
||||
let Some(bottom_card) = game.0.piles.get(&pile)
|
||||
.and_then(|p| p.cards.get(stack_index)) else { return };
|
||||
if let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
bottom_card,
|
||||
&pile,
|
||||
&game.0,
|
||||
card_ids.len(),
|
||||
) {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile,
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
} else {
|
||||
// No legal destination for the stack — play the invalid-move
|
||||
// sound and shake the source pile cards as feedback.
|
||||
// `MoveRejectedEvent` with `from == to` routes the shake to
|
||||
// the source pile (which `start_shake_anim` reads from `ev.to`).
|
||||
rejected.send(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile,
|
||||
count: card_ids.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single click — record the time.
|
||||
@@ -666,12 +882,19 @@ fn handle_double_click(
|
||||
// Task #28 — Hint system helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Find one valid move in the current game state.
|
||||
/// Build the complete list of legal moves available in `game`, ordered so that
|
||||
/// foundation moves come first, then tableau-to-tableau moves, with "draw from
|
||||
/// stock" appended last when the stock is non-empty and nothing else is
|
||||
/// available.
|
||||
///
|
||||
/// Returns `(from, to, count)` for the first legal move found, or `None` if
|
||||
/// no move is available. Sources checked: Waste top, then Tableau 0–6.
|
||||
/// Destinations checked: all 4 Foundations, then all 7 Tableau piles.
|
||||
pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
|
||||
/// Each entry is `(from, to, count)` — the same triple used by
|
||||
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
|
||||
/// (game is stuck).
|
||||
///
|
||||
/// This is the backing data for the cycling hint system: the H key steps
|
||||
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
||||
pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let sources: Vec<PileType> = {
|
||||
let mut s = vec![PileType::Waste];
|
||||
for i in 0..7_usize {
|
||||
@@ -680,23 +903,38 @@ pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
|
||||
s
|
||||
};
|
||||
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let mut hints: Vec<(PileType, PileType, usize)> = Vec::new();
|
||||
|
||||
// Pass 1 — foundation moves (highest priority, shown first).
|
||||
for from in &sources {
|
||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
|
||||
// Check foundations.
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, dest_pile, suit) {
|
||||
return Some((from.clone(), dest, 1));
|
||||
hints.push((from.clone(), dest, 1));
|
||||
// Each source card can go to at most one foundation suit;
|
||||
// no need to check the remaining three for this card.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check tableau piles (skip the source pile itself).
|
||||
// Pass 2 — tableau moves (deduplicated by source pile so we don't
|
||||
// repeat the same source card multiple times for different destinations).
|
||||
for from in &sources {
|
||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
// Skip if this source already has a foundation hint — prefer to show
|
||||
// that one when cycling rather than suggesting a less optimal move.
|
||||
let already_has_foundation_hint = hints.iter().any(|(f, t, _)| {
|
||||
f == from && matches!(t, PileType::Foundation(_))
|
||||
});
|
||||
if already_has_foundation_hint {
|
||||
continue;
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if dest == *from {
|
||||
@@ -704,12 +942,41 @@ pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
|
||||
}
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_tableau(card, dest_pile) {
|
||||
return Some((from.clone(), dest, 1));
|
||||
hints.push((from.clone(), dest, 1));
|
||||
// One tableau destination per source card is enough for the
|
||||
// hint list — the player can see where else a card can go
|
||||
// via the right-click destination highlights.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
// Pass 3 — suggest drawing from the stock when no other hint was found.
|
||||
if hints.is_empty() {
|
||||
let stock_non_empty = game.piles.get(&PileType::Stock)
|
||||
.is_some_and(|p| !p.cards.is_empty());
|
||||
let waste_can_recycle = game.piles.get(&PileType::Stock)
|
||||
.is_some_and(|p| p.cards.is_empty())
|
||||
&& game.piles.get(&PileType::Waste)
|
||||
.is_some_and(|p| !p.cards.is_empty());
|
||||
if stock_non_empty || waste_can_recycle {
|
||||
// Stock→Waste is not a real pile-to-pile move, but we reuse the
|
||||
// triple to signal "draw". The H handler only reads `from` to
|
||||
// locate the card to highlight; we point at the stock pile.
|
||||
hints.push((PileType::Stock, PileType::Waste, 1));
|
||||
}
|
||||
}
|
||||
|
||||
hints
|
||||
}
|
||||
|
||||
/// Find one valid move in the current game state.
|
||||
///
|
||||
/// Returns `(from, to, count)` for the first legal move found, or `None` if
|
||||
/// no move is available. This is a convenience wrapper over [`all_hints`].
|
||||
pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
|
||||
all_hints(game).into_iter().next()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -999,6 +1266,102 @@ mod tests {
|
||||
assert!(best_destination(&card, &game).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// best_tableau_destination_for_stack pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_finds_legal_column() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear all piles for a clean test.
|
||||
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();
|
||||
}
|
||||
|
||||
// Tableau 0: King of Spades (the source stack base), Queen of Hearts on top.
|
||||
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.push(Card { id: 100, suit: Suit::Spades, rank: Rank::King, face_up: true });
|
||||
t0.cards.push(Card { id: 101, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
|
||||
|
||||
// Tableau 1..6: empty — Kings can land on any of them.
|
||||
|
||||
let bottom_card = Card { id: 100, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&bottom_card,
|
||||
&PileType::Tableau(0),
|
||||
&game,
|
||||
2,
|
||||
);
|
||||
assert!(result.is_some(), "should find a destination for King-stack");
|
||||
let (dest, count) = result.unwrap();
|
||||
assert!(matches!(dest, PileType::Tableau(_)));
|
||||
assert_ne!(dest, PileType::Tableau(0), "must not return the source pile");
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Only tableau 0 has anything; every other column is empty.
|
||||
// A King is the only card that can go on an empty tableau column.
|
||||
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.push(Card { id: 200, suit: Suit::Hearts, rank: Rank::King, face_up: true });
|
||||
|
||||
let bottom_card = Card { id: 200, suit: Suit::Hearts, rank: Rank::King, face_up: true };
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&bottom_card,
|
||||
&PileType::Tableau(0),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
// Result must be some other empty tableau column, never the source.
|
||||
if let Some((dest, _)) = result {
|
||||
assert_ne!(dest, PileType::Tableau(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||
// All other piles are empty — no legal tableau target.
|
||||
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.push(Card { id: 300, suit: Suit::Clubs, rank: Rank::Two, face_up: true });
|
||||
|
||||
let bottom_card = Card { id: 300, suit: Suit::Clubs, rank: Rank::Two, face_up: true };
|
||||
let result = best_tableau_destination_for_stack(
|
||||
&bottom_card,
|
||||
&PileType::Tableau(0),
|
||||
&game,
|
||||
1,
|
||||
);
|
||||
assert!(result.is_none(), "Two of Clubs has no legal tableau destination on empty piles");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #28 — find_hint pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1047,6 +1410,201 @@ mod tests {
|
||||
|
||||
assert!(find_hint(&game).is_none(), "no hint should exist");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #54 — forfeit double-confirm logic pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Verify the FORFEIT_CONFIRM_WINDOW constant is positive so the countdown
|
||||
/// window actually opens on the first G press.
|
||||
#[test]
|
||||
fn forfeit_confirm_window_is_positive() {
|
||||
assert!(FORFEIT_CONFIRM_WINDOW > 0.0, "FORFEIT_CONFIRM_WINDOW must be > 0");
|
||||
}
|
||||
|
||||
/// Simulate the first G press: countdown was 0, so it should become
|
||||
/// FORFEIT_CONFIRM_WINDOW and no ForfeitEvent should be "sent" yet.
|
||||
#[test]
|
||||
fn forfeit_first_press_opens_countdown() {
|
||||
// Simulate: forfeit_countdown starts at 0 (no pending confirmation).
|
||||
let mut forfeit_countdown: f32 = 0.0;
|
||||
let active_game = true;
|
||||
|
||||
// --- first G press logic (mirrors handle_keyboard) ---
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
// Second press — would send ForfeitEvent.
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
// First press — open window, send toast (not ForfeitEvent).
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(!forfeit_sent, "ForfeitEvent must NOT fire on first G press");
|
||||
assert_eq!(
|
||||
forfeit_countdown, FORFEIT_CONFIRM_WINDOW,
|
||||
"countdown must be opened to FORFEIT_CONFIRM_WINDOW after first press"
|
||||
);
|
||||
}
|
||||
|
||||
/// Simulate the second G press within the window: countdown > 0, so
|
||||
/// ForfeitEvent should fire and countdown resets to 0.
|
||||
#[test]
|
||||
fn forfeit_second_press_within_window_sends_event() {
|
||||
// Countdown is open from the first press.
|
||||
let mut forfeit_countdown: f32 = FORFEIT_CONFIRM_WINDOW - 1.0; // still in window
|
||||
let active_game = true;
|
||||
|
||||
// --- second G press logic ---
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(forfeit_sent, "ForfeitEvent MUST fire on second G press within window");
|
||||
assert_eq!(forfeit_countdown, 0.0, "countdown must reset to 0 after confirmation");
|
||||
}
|
||||
|
||||
/// Simulate G press after the countdown has expired: countdown ticked to 0,
|
||||
/// so the next G press opens a fresh window (no ForfeitEvent).
|
||||
#[test]
|
||||
fn forfeit_press_after_countdown_expired_reopens_window() {
|
||||
// Countdown already expired.
|
||||
let mut forfeit_countdown: f32 = 0.0;
|
||||
let active_game = true;
|
||||
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(!forfeit_sent, "ForfeitEvent must NOT fire when countdown expired before second press");
|
||||
assert_eq!(
|
||||
forfeit_countdown, FORFEIT_CONFIRM_WINDOW,
|
||||
"a new confirmation window must open"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pressing any other key (e.g. U for undo) while the forfeit countdown is
|
||||
/// active must immediately cancel it (reset to 0.0).
|
||||
#[test]
|
||||
fn forfeit_cancelled_by_other_key_press() {
|
||||
// Countdown is open from the first G press.
|
||||
let mut forfeit_countdown: f32 = FORFEIT_CONFIRM_WINDOW - 0.5; // still in window
|
||||
|
||||
// --- simulate U (undo) press: cancel countdown ---
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
}
|
||||
// Then perform undo logic (omitted here as it requires Bevy infrastructure).
|
||||
|
||||
assert_eq!(
|
||||
forfeit_countdown, 0.0,
|
||||
"forfeit countdown must be reset to 0.0 when another key is pressed"
|
||||
);
|
||||
}
|
||||
|
||||
/// G press when no game is active must never fire ForfeitEvent and must
|
||||
/// not open a countdown.
|
||||
#[test]
|
||||
fn forfeit_no_active_game_does_nothing() {
|
||||
let mut forfeit_countdown: f32 = 0.0;
|
||||
let active_game = false;
|
||||
|
||||
let forfeit_sent = if active_game {
|
||||
if forfeit_countdown > 0.0 {
|
||||
forfeit_countdown = 0.0;
|
||||
true
|
||||
} else {
|
||||
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
assert!(!forfeit_sent, "ForfeitEvent must not fire when no game is active");
|
||||
assert_eq!(forfeit_countdown, 0.0, "countdown must remain 0 when no game is active");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #57 — ShakeAnim insertion on rejected drag
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Verifies that `ShakeAnim` constructed for a rejected drag has the
|
||||
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches
|
||||
/// the card's current transform X position.
|
||||
///
|
||||
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test
|
||||
/// covers the data path — that we build the component with the right values
|
||||
/// before handing it to `commands.entity(...).insert(...)`.
|
||||
#[test]
|
||||
fn shake_anim_for_rejected_drag_has_correct_initial_values() {
|
||||
use crate::feedback_anim_plugin::ShakeAnim;
|
||||
|
||||
// Simulate the transform X that a dragged card would have at the
|
||||
// moment the drag is released (could be anywhere on screen).
|
||||
let current_x = 123.5_f32;
|
||||
|
||||
// This mirrors the ShakeAnim construction in `end_drag`.
|
||||
let anim = ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: current_x,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
anim.elapsed, 0.0,
|
||||
"ShakeAnim must start with elapsed=0.0 so the animation plays from the beginning"
|
||||
);
|
||||
assert!(
|
||||
(anim.origin_x - current_x).abs() < 1e-6,
|
||||
"ShakeAnim origin_x must match the card's transform X at drop time, \
|
||||
expected {current_x}, got {}",
|
||||
anim.origin_x
|
||||
);
|
||||
}
|
||||
|
||||
/// When a drag is rejected, every card id in `drag.cards` should receive a
|
||||
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches
|
||||
/// exactly the ids stored in `DragState::cards` at rejection time.
|
||||
#[test]
|
||||
fn rejected_drag_shakes_all_dragged_cards() {
|
||||
// Simulate a DragState with two card ids (a stack drag).
|
||||
let dragged_ids: Vec<u32> = vec![10, 11];
|
||||
|
||||
// In `end_drag`, we iterate `drag.cards` and look up each id in
|
||||
// `card_entities`. The ids we would insert ShakeAnim on must exactly
|
||||
// match the dragged set.
|
||||
let mut shaken: Vec<u32> = Vec::new();
|
||||
for &card_id in &dragged_ids {
|
||||
// Simulate finding the entity for card_id (always succeeds here).
|
||||
shaken.push(card_id);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
shaken, dragged_ids,
|
||||
"every card id in drag.cards must receive a ShakeAnim on rejection"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
|
||||
|
||||
@@ -14,6 +14,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
use solitaire_sync::LeaderboardEntry;
|
||||
|
||||
use crate::events::InfoToastEvent;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
|
||||
@@ -214,13 +215,22 @@ fn handle_opt_in_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the opt-in task; logs on error, clears on completion.
|
||||
fn poll_opt_in_task(mut task_res: ResMut<OptInTask>) {
|
||||
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
||||
fn poll_opt_in_task(
|
||||
mut task_res: ResMut<OptInTask>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else { return };
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
task_res.0 = None;
|
||||
if let Err(e) = result {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
toast.send(InfoToastEvent("Opted in to leaderboard".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-in failed: {e}");
|
||||
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,13 +255,22 @@ fn handle_opt_out_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the opt-out task; logs on error, clears on completion.
|
||||
fn poll_opt_out_task(mut task_res: ResMut<OptOutTask>) {
|
||||
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
||||
fn poll_opt_out_task(
|
||||
mut task_res: ResMut<OptOutTask>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else { return };
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
task_res.0 = None;
|
||||
if let Err(e) = result {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
toast.send(InfoToastEvent("Opted out of leaderboard".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-out failed: {e}");
|
||||
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ pub mod daily_challenge_plugin;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
pub mod help_plugin;
|
||||
pub mod home_plugin;
|
||||
pub mod hud_plugin;
|
||||
pub mod leaderboard_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod profile_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
@@ -40,7 +42,8 @@ pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, Progre
|
||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
||||
pub use feedback_anim_plugin::{
|
||||
deal_stagger_delay, shake_offset, settle_scale, FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||
};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
@@ -53,16 +56,18 @@ pub use events::{
|
||||
};
|
||||
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||
pub use hud_plugin::{HudAutoComplete, HudPlugin};
|
||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||
pub use settings_plugin::{
|
||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
@@ -71,5 +76,5 @@ pub use time_attack_plugin::{
|
||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||
};
|
||||
pub use win_summary_plugin::{
|
||||
format_win_time, ScreenShakeResource, WinSummaryPending, WinSummaryPlugin,
|
||||
format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! First-run onboarding banner.
|
||||
//!
|
||||
//! On startup, if `Settings.first_run_complete` is `false`, spawn a centered
|
||||
//! welcome banner pointing at the **H**/`?` cheat sheet. The first key or
|
||||
//! welcome banner pointing at the **F1** cheat sheet. The first key or
|
||||
//! mouse-button press dismisses it, sets the flag, and persists settings —
|
||||
//! so returning players never see it again.
|
||||
//!
|
||||
//! **Key highlights** (#49): The key names **D**, **H**, and **U** inside the
|
||||
//! **Key highlights** (#49): The key names **D** and **U** inside the
|
||||
//! instructional text are rendered in a bright orange colour via `TextSpan`
|
||||
//! children tagged with `KeyHighlightSpan`.
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
#[derive(Component, Debug)]
|
||||
pub struct OnboardingScreen;
|
||||
|
||||
/// Marker on `TextSpan` entities that display a key name (D, H, U …) in the
|
||||
/// Marker on `TextSpan` entities that display a key name (D, U …) in the
|
||||
/// onboarding banner. Colour distinct from body text; usable by tests and any
|
||||
/// future flash-animation system.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -112,7 +112,7 @@ fn spawn_onboarding_screen(commands: &mut Commands) {
|
||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
||||
|
||||
// Instruction line: "Drag cards between piles. Press D to draw, U to undo."
|
||||
// D and U rendered as KeyHighlightSpan children with KEY_COLOR.
|
||||
// D is tagged KeyHighlightSpan; U uses KEY_COLOR but not the marker.
|
||||
b.spawn((
|
||||
Text::new("Drag cards between piles. Press "),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
@@ -129,24 +129,12 @@ fn spawn_onboarding_screen(commands: &mut Commands) {
|
||||
t.spawn((TextSpan::new(" to undo."), TextColor(BODY_COLOR)));
|
||||
});
|
||||
|
||||
// Help line: "Press H or ? at any time to see the full controls."
|
||||
// H rendered as a KeyHighlightSpan child with KEY_COLOR.
|
||||
// Help line: "Press F1 at any time to see the full controls."
|
||||
b.spawn((
|
||||
Text::new("Press "),
|
||||
Text::new("Press F1 at any time to see the full controls."),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(BODY_COLOR),
|
||||
))
|
||||
.with_children(|t| {
|
||||
t.spawn((
|
||||
TextSpan::new("H"),
|
||||
TextColor(KEY_COLOR),
|
||||
KeyHighlightSpan,
|
||||
));
|
||||
t.spawn((
|
||||
TextSpan::new(" or ? at any time to see the full controls."),
|
||||
TextColor(BODY_COLOR),
|
||||
));
|
||||
});
|
||||
|
||||
// Spacer
|
||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
||||
@@ -237,9 +225,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_has_two_key_highlight_spans() {
|
||||
// D and H must be tagged KeyHighlightSpan so their colour is distinct
|
||||
// from body text and future flash-animation systems can target them.
|
||||
fn banner_has_key_highlight_span_for_d() {
|
||||
// D must be tagged KeyHighlightSpan so its colour is distinct from body
|
||||
// text and future flash-animation systems can target it.
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
let count = app
|
||||
@@ -247,7 +235,7 @@ mod tests {
|
||||
.query::<&KeyHighlightSpan>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 2, "expected KeyHighlightSpan for D and H");
|
||||
assert_eq!(count, 1, "expected KeyHighlightSpan for D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,14 +9,20 @@
|
||||
//! input (drag, keyboard hotkeys) is **not** blocked — pause is purely a
|
||||
//! "stop the clock" screen for now. A future polish slice can layer
|
||||
//! input-blocking on top if desired.
|
||||
//!
|
||||
//! **Drag cancellation:** when Esc is pressed while a mouse drag is in
|
||||
//! progress, the drag is cancelled (cards snap back to their origin) and
|
||||
//! the pause overlay is **not** opened. Pressing Esc again with no drag
|
||||
//! active opens the overlay as normal.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::save_game_state_to;
|
||||
|
||||
use crate::game_plugin::GameStatePath;
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::StatsResource;
|
||||
|
||||
@@ -46,9 +52,10 @@ pub struct PausePlugin;
|
||||
|
||||
impl Plugin for PausePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// SettingsChangedEvent may already be registered by SettingsPlugin;
|
||||
// add_event is idempotent so this is safe in either order.
|
||||
// Both add_event calls are idempotent — other plugins may register these
|
||||
// events first, but calling add_event again is always safe.
|
||||
app.add_event::<SettingsChangedEvent>()
|
||||
.add_event::<StateChangedEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.add_systems(Update, (toggle_pause, handle_pause_draw_toggle));
|
||||
}
|
||||
@@ -60,15 +67,33 @@ fn toggle_pause(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut drag: Option<ResMut<DragState>>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Escape) {
|
||||
return;
|
||||
}
|
||||
// If the game-over overlay is visible, let handle_game_over_input consume
|
||||
// the Escape key (to start a new game). Do not open the pause overlay.
|
||||
if !game_over_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a drag is in progress, cancel it instead of opening the pause overlay.
|
||||
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards
|
||||
// back to their resting positions exactly as a rejected drop does.
|
||||
if let Some(ref mut d) = drag {
|
||||
if !d.is_idle() {
|
||||
d.clear();
|
||||
changed.send(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
paused.0 = false;
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Toggleable full-window profile overlay (press **P**).
|
||||
//!
|
||||
//! Shows the player's sync account, progression, achievements, and a statistics
|
||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||
//! despawned on the second.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::achievement::achievement_by_id;
|
||||
use solitaire_data::SyncBackend;
|
||||
|
||||
use crate::achievement_plugin::AchievementsResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
|
||||
|
||||
/// Marker component on the profile overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileScreen;
|
||||
|
||||
/// Registers the `P` key toggle for the profile overlay.
|
||||
pub struct ProfilePlugin;
|
||||
|
||||
impl Plugin for ProfilePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, toggle_profile_screen);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_profile_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
achievements: Option<Res<AchievementsResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyP) {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
} else {
|
||||
spawn_profile_screen(
|
||||
&mut commands,
|
||||
settings.as_deref(),
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
achievements.as_deref(),
|
||||
stats.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_profile_screen(
|
||||
commands: &mut Commands,
|
||||
settings: Option<&SettingsResource>,
|
||||
sync_status: Option<&SyncStatusResource>,
|
||||
progress: Option<&ProgressResource>,
|
||||
achievements: Option<&AchievementsResource>,
|
||||
stats: Option<&StatsResource>,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
ProfileScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(4.0),
|
||||
padding: UiRect::all(Val::Px(24.0)),
|
||||
overflow: Overflow::clip(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// ── Title ────────────────────────────────────────────────────────
|
||||
root.spawn((
|
||||
Text::new("Profile"),
|
||||
TextFont { font_size: 28.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.3)),
|
||||
));
|
||||
|
||||
// ── Sync section ─────────────────────────────────────────────────
|
||||
if let Some(s) = settings {
|
||||
let (backend_name, username) = sync_info(&s.0.sync_backend);
|
||||
root.spawn((
|
||||
Text::new(format!("Account: {username} | Backend: {backend_name}")),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 0.9, 1.0)),
|
||||
));
|
||||
}
|
||||
if let Some(ss) = sync_status {
|
||||
let status_text = match &ss.0 {
|
||||
SyncStatus::Idle => "Sync: idle".to_string(),
|
||||
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
|
||||
SyncStatus::LastSynced(dt) => {
|
||||
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
|
||||
}
|
||||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(status_text),
|
||||
TextFont { font_size: 15.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 0.7, 0.7)),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Progression section ───────────────────────────────────────────
|
||||
spawn_spacer(root, 4.0);
|
||||
root.spawn((
|
||||
Text::new("Progression"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.9, 0.8)),
|
||||
));
|
||||
if let Some(p) = progress {
|
||||
let prog = &p.0;
|
||||
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||
let pct = if xp_span == 0 {
|
||||
100u64
|
||||
} else {
|
||||
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
|
||||
prog.level, prog.total_xp, xp_done, xp_span, pct
|
||||
)),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
|
||||
prog.daily_challenge_streak,
|
||||
prog.unlocked_card_backs.len(),
|
||||
prog.unlocked_backgrounds.len(),
|
||||
)),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Achievements section ──────────────────────────────────────────
|
||||
spawn_spacer(root, 4.0);
|
||||
root.spawn((
|
||||
Text::new("Achievements"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.9, 0.8)),
|
||||
));
|
||||
if let Some(ar) = achievements {
|
||||
let records = &ar.0;
|
||||
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
|
||||
root.spawn((
|
||||
Text::new(format!("{} / 18 unlocked", unlocked_count)),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.4)),
|
||||
));
|
||||
|
||||
let mut any_unlocked = false;
|
||||
for record in records {
|
||||
let def = achievement_by_id(record.id.as_str());
|
||||
// Skip secret achievements that are not unlocked.
|
||||
let is_secret = def.map(|d| d.secret).unwrap_or(false);
|
||||
if is_secret && !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
if !record.unlocked {
|
||||
continue;
|
||||
}
|
||||
any_unlocked = true;
|
||||
let name = def.map(|d| d.name).unwrap_or(record.id.as_str());
|
||||
let date_str = match record.unlock_date {
|
||||
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
|
||||
None => String::new(),
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(format!(" [x] {name}{date_str}")),
|
||||
TextFont { font_size: 14.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 1.0, 0.7)),
|
||||
));
|
||||
}
|
||||
if !any_unlocked {
|
||||
root.spawn((
|
||||
Text::new(" No achievements unlocked yet."),
|
||||
TextFont { font_size: 14.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 0.7, 0.7)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistics summary section ────────────────────────────────────
|
||||
spawn_spacer(root, 4.0);
|
||||
root.spawn((
|
||||
Text::new("Statistics Summary"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.9, 0.8)),
|
||||
));
|
||||
if let Some(sr) = stats {
|
||||
let s = &sr.0;
|
||||
let best_score_str = if s.best_single_score == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
s.best_single_score.to_string()
|
||||
};
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
|
||||
s.games_played,
|
||||
s.games_won,
|
||||
format_win_rate(s),
|
||||
format_fastest_win(s.fastest_win_seconds),
|
||||
)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Win streak: {} current, {} best | Best score: {}",
|
||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
||||
)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Dismiss hint ──────────────────────────────────────────────────
|
||||
spawn_spacer(root, 8.0);
|
||||
root.spawn((
|
||||
Text::new("Press P to close"),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.55, 0.55)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn a fixed-height vertical spacer node.
|
||||
fn spawn_spacer(parent: &mut ChildBuilder, height_px: f32) {
|
||||
parent.spawn(Node {
|
||||
height: Val::Px(height_px),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Return `(backend_name, username_display)` for the given sync backend.
|
||||
fn sync_info(backend: &SyncBackend) -> (&'static str, String) {
|
||||
match backend {
|
||||
SyncBackend::Local => ("Local", "—".to_string()),
|
||||
SyncBackend::SolitaireServer { username, .. } => {
|
||||
("Solitaire Server", username.clone())
|
||||
}
|
||||
SyncBackend::GooglePlayGames => ("Google Play Games", "—".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return `(xp_span_for_level, xp_done_in_level)` for the given `total_xp` and `level`.
|
||||
///
|
||||
/// Levels 1–10 each require 500 XP; levels 11+ each require 1 000 XP.
|
||||
fn xp_progress(total_xp: u64, level: u32) -> (u64, u64) {
|
||||
let level_start = if level < 10 {
|
||||
level as u64 * 500
|
||||
} else {
|
||||
5_000 + (level as u64 - 10) * 1_000
|
||||
};
|
||||
let xp_span: u64 = if level < 10 { 500 } else { 1_000 };
|
||||
let xp_done = total_xp.saturating_sub(level_start).min(xp_span);
|
||||
(xp_span, xp_done)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::achievement_plugin::AchievementPlugin;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::settings_plugin::SettingsPlugin;
|
||||
use crate::stats_plugin::StatsPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(SettingsPlugin::headless())
|
||||
.add_plugins(ProfilePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_p_spawns_profile_screen() {
|
||||
let mut app = headless_app();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ProfileScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyP);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ProfileScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_p_twice_closes_profile_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyP);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyP);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyP);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ProfileScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_progress_at_zero() {
|
||||
assert_eq!(xp_progress(0, 0), (500, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_progress_halfway_through_level_1() {
|
||||
// Level 1 starts at 500 XP; span is 500. At 750 XP: done = 250.
|
||||
assert_eq!(xp_progress(750, 1), (500, 250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_progress_at_level_10() {
|
||||
// Level 10 is the first post-table level (span = 1000, starts at 5000).
|
||||
assert_eq!(xp_progress(5_000, 10), (1_000, 0));
|
||||
}
|
||||
}
|
||||
@@ -53,3 +53,17 @@ pub enum SyncStatus {
|
||||
/// Bevy resource wrapping the current `SyncStatus`.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SyncStatusResource(pub SyncStatus);
|
||||
|
||||
/// Tracks which hint the player is currently cycling through.
|
||||
///
|
||||
/// Incremented on each H press so repeated presses reveal different moves.
|
||||
/// Reset to `0` whenever the game state changes (move, draw, undo, new game).
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct HintCycleIndex(pub usize);
|
||||
|
||||
/// Remembers the vertical scroll offset of the Settings panel between open/close cycles.
|
||||
///
|
||||
/// Saved when the panel is despawned and restored on next spawn so the player
|
||||
/// returns to the same position in the list without re-scrolling.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SettingsScrollPos(pub f32);
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
//!
|
||||
//! Pressing `Tab` cycles through piles that have a face-up draggable top card.
|
||||
//! Pressing `Enter` or `Space` fires a [`MoveRequestEvent`] to the best
|
||||
//! available destination (foundation first, then tableau), then clears the
|
||||
//! selection. Pressing `Escape` clears the selection without moving.
|
||||
//! available destination using the following priority order, then clears the
|
||||
//! selection:
|
||||
//!
|
||||
//! 1. Move the top card to its best foundation (count = 1).
|
||||
//! 2. Move the full face-up run from the selected tableau pile to the best
|
||||
//! tableau destination (count = run length). Single-card stacks from
|
||||
//! non-tableau piles fall back to [`best_destination`] for tableau targets.
|
||||
//!
|
||||
//! Pressing `Escape` clears the selection without moving.
|
||||
//!
|
||||
//! The selected card is highlighted by a cyan [`SelectionHighlight`] outline
|
||||
//! sprite parented to the selected card entity. The highlight is despawned when
|
||||
@@ -15,9 +22,9 @@ use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::MoveRequestEvent;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::input_plugin::best_destination;
|
||||
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -115,17 +122,48 @@ pub fn cycle_next_pile(
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns `true` when cycling from `current` to `next` wraps around the
|
||||
/// available list — i.e., `next` appears at or before `current` in the global
|
||||
/// cycle order defined by [`cycled_piles`].
|
||||
///
|
||||
/// Both `current` and `next` must be `Some`; if either is `None` this returns
|
||||
/// `false`.
|
||||
fn did_wrap(
|
||||
available: &[PileType],
|
||||
current: Option<&PileType>,
|
||||
next: Option<&PileType>,
|
||||
) -> bool {
|
||||
let (Some(cur), Some(nxt)) = (current, next) else {
|
||||
return false;
|
||||
};
|
||||
let order = cycled_piles();
|
||||
// Position of each pile within the *available* subset, ordered by the
|
||||
// global cycle order.
|
||||
let pos_in_available = |target: &PileType| -> Option<usize> {
|
||||
order
|
||||
.iter()
|
||||
.filter(|p| available.contains(p))
|
||||
.position(|p| p == target)
|
||||
};
|
||||
match (pos_in_available(cur), pos_in_available(nxt)) {
|
||||
(Some(cur_pos), Some(nxt_pos)) => nxt_pos <= cur_pos,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles Tab / Enter / Space / Escape for keyboard card selection.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_selection_keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut selection: ResMut<SelectionState>,
|
||||
mut moves: EventWriter<MoveRequestEvent>,
|
||||
mut info_toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -160,8 +198,15 @@ fn handle_selection_keys(
|
||||
|
||||
// Tab — cycle selection.
|
||||
if keys.just_pressed(KeyCode::Tab) {
|
||||
selection.selected_pile =
|
||||
cycle_next_pile(&available, selection.selected_pile.as_ref());
|
||||
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
|
||||
if next.is_none() {
|
||||
info_toast.send(InfoToastEvent("No cards to select".to_string()));
|
||||
} else if selection.selected_pile.is_some()
|
||||
&& did_wrap(&available, selection.selected_pile.as_ref(), next.as_ref())
|
||||
{
|
||||
info_toast.send(InfoToastEvent("Back to first card".to_string()));
|
||||
}
|
||||
selection.selected_pile = next;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +216,12 @@ fn handle_selection_keys(
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter / Space — execute move for the selected pile's top card.
|
||||
// Enter / Space — execute move for the selected pile's top card (or full
|
||||
// face-up run when the source is a tableau column).
|
||||
//
|
||||
// Priority:
|
||||
// 1. Foundation move — always count = 1.
|
||||
// 2. Tableau stack move — count = full face-up run length from the source.
|
||||
let activate =
|
||||
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
|
||||
if activate {
|
||||
@@ -183,6 +233,46 @@ fn handle_selection_keys(
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
{
|
||||
// --- Priority 1: foundation move (single card) ---
|
||||
let foundation_dest = try_foundation_dest(card, &game.0);
|
||||
if let Some(dest) = foundation_dest {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
selection.selected_pile = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Priority 2: tableau stack move ---
|
||||
// Count the full contiguous face-up run in the source pile.
|
||||
let run_len = face_up_run_len(game.0.piles.get(pile).map(|p| p.cards.as_slice()).unwrap_or(&[]));
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.and_then(|p| {
|
||||
let start = p.cards.len().saturating_sub(run_len);
|
||||
p.cards.get(start)
|
||||
});
|
||||
if let Some(bottom) = bottom_card {
|
||||
if let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
|
||||
{
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
selection.selected_pile = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: single-card move to any destination ---
|
||||
// Covers non-tableau sources (Waste, Foundation) that have no
|
||||
// stack-move logic.
|
||||
if let Some(dest) = best_destination(card, &game.0) {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
@@ -196,6 +286,49 @@ fn handle_selection_keys(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Count the contiguous face-up cards at the top of `cards`.
|
||||
///
|
||||
/// Walks backwards from the last element and stops at the first face-down card
|
||||
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
||||
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
||||
fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
|
||||
let mut count = 0;
|
||||
for card in cards.iter().rev() {
|
||||
if card.face_up {
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Find the best foundation destination for `card` — returns the first
|
||||
/// foundation pile that legally accepts the card, or `None`.
|
||||
///
|
||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||
/// handler can attempt a foundation move first and fall through to a
|
||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||
fn try_foundation_dest(
|
||||
card: &solitaire_core::card::Card,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
use solitaire_core::rules::can_place_on_foundation;
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, pile, suit) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Maintains the `SelectionHighlight` outline sprite.
|
||||
///
|
||||
/// When a pile is selected, a cyan sprite is placed at the selected card's
|
||||
@@ -310,10 +443,97 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #59 — wrap detection: 3 piles, Tab ×3 fires wrap on third press
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Simulate three Tab presses over [Waste, Tableau(0), Tableau(1)].
|
||||
///
|
||||
/// Press 1: None → Waste — no wrap (started from nothing)
|
||||
/// Press 2: Waste → Tableau(0) — no wrap (advancing forward)
|
||||
/// Press 3: T(0) → Tableau(1) — no wrap (still advancing forward)
|
||||
/// (A fourth press would wrap T(1) → Waste.)
|
||||
#[test]
|
||||
fn wrap_detected_on_third_tab_with_three_piles() {
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
|
||||
// Press 1: no current selection → first pile, no wrap.
|
||||
let sel1 = cycle_next_pile(&available, None);
|
||||
assert_eq!(sel1, Some(PileType::Waste));
|
||||
assert!(!did_wrap(&available, None, sel1.as_ref()), "first Tab should not wrap");
|
||||
|
||||
// Press 2: Waste → Tableau(0), no wrap.
|
||||
let sel2 = cycle_next_pile(&available, sel1.as_ref());
|
||||
assert_eq!(sel2, Some(PileType::Tableau(0)));
|
||||
assert!(!did_wrap(&available, sel1.as_ref(), sel2.as_ref()), "second Tab should not wrap");
|
||||
|
||||
// Press 3: Tableau(0) → Tableau(1), still no wrap.
|
||||
let sel3 = cycle_next_pile(&available, sel2.as_ref());
|
||||
assert_eq!(sel3, Some(PileType::Tableau(1)));
|
||||
assert!(!did_wrap(&available, sel2.as_ref(), sel3.as_ref()), "third Tab (T0→T1) should not wrap");
|
||||
|
||||
// Press 4: Tableau(1) → Waste, this IS the wrap.
|
||||
let sel4 = cycle_next_pile(&available, sel3.as_ref());
|
||||
assert_eq!(sel4, Some(PileType::Waste));
|
||||
assert!(did_wrap(&available, sel3.as_ref(), sel4.as_ref()), "fourth Tab should wrap back to Waste");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_single_element_wraps_to_itself() {
|
||||
let available = vec![PileType::Waste];
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #8 — face_up_run_len pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_empty_slice_is_zero() {
|
||||
assert_eq!(face_up_run_len(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_all_face_up() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_mixed_stops_at_face_down() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: false },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
Card { id: 3, suit: Suit::Diamonds, rank: Rank::Ten, face_up: true },
|
||||
];
|
||||
// Only the top two cards are face-up.
|
||||
assert_eq!(face_up_run_len(&cards), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_top_card_face_down_is_zero() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_single_face_up_card() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, s
|
||||
|
||||
use crate::events::ManualSyncRequestEvent;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
|
||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||
pub const SFX_STEP: f32 = 0.1;
|
||||
@@ -83,6 +83,10 @@ struct ColorBlindText;
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
|
||||
/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsScrollNode;
|
||||
|
||||
/// Tags interactive buttons inside the Settings panel.
|
||||
#[derive(Component, Debug)]
|
||||
enum SettingsButton {
|
||||
@@ -139,6 +143,7 @@ impl Plugin for SettingsPlugin {
|
||||
app.insert_resource(SettingsResource(loaded))
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.init_resource::<SettingsScreen>()
|
||||
.init_resource::<SettingsScrollPos>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_event::<ManualSyncRequestEvent>()
|
||||
.add_event::<bevy::input::mouse::MouseWheel>()
|
||||
@@ -213,9 +218,12 @@ fn toggle_settings_screen(
|
||||
|
||||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||
/// despawns it when it becomes `false`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||
mut commands: Commands,
|
||||
settings: Res<SettingsResource>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
@@ -243,9 +251,14 @@ fn sync_settings_panel_visibility(
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
scroll_pos.0,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Save the current scroll offset before despawning the panel.
|
||||
if let Ok(sp) = scroll_nodes.get_single() {
|
||||
scroll_pos.0 = sp.offset_y;
|
||||
}
|
||||
for entity in &panels {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
@@ -557,6 +570,7 @@ fn spawn_settings_panel(
|
||||
sync_status: &str,
|
||||
unlocked_card_backs: &[usize],
|
||||
unlocked_backgrounds: &[usize],
|
||||
scroll_offset: f32,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
@@ -580,7 +594,8 @@ fn spawn_settings_panel(
|
||||
// on small windows by scrolling with the mouse wheel.
|
||||
root.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition::default(),
|
||||
SettingsScrollNode,
|
||||
ScrollPosition { offset_y: scroll_offset, ..default() },
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(28.0)),
|
||||
|
||||
@@ -15,6 +15,7 @@ use solitaire_data::{
|
||||
WEEKLY_GOALS,
|
||||
};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::challenge_plugin::challenge_progress_label;
|
||||
use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
@@ -128,17 +129,25 @@ fn update_stats_on_new_game(
|
||||
game: Res<GameStateResource>,
|
||||
mut stats: ResMut<StatsResource>,
|
||||
path: Res<StatsStoragePath>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
if game.0.move_count > 0 && !game.0.is_won {
|
||||
let streak = stats.0.win_streak_current;
|
||||
stats.0.record_abandoned();
|
||||
persist(&path, &stats.0, "abandoned game");
|
||||
if streak > 1 {
|
||||
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the player presses G to forfeit, record the game as abandoned, save
|
||||
/// stats, fire an informational toast, and start a new game.
|
||||
///
|
||||
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
|
||||
/// into the new deal (task #41).
|
||||
fn handle_forfeit(
|
||||
mut events: EventReader<ForfeitEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
@@ -146,11 +155,21 @@ fn handle_forfeit(
|
||||
path: Res<StatsStoragePath>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
mut auto_complete: Option<ResMut<AutoCompleteState>>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
if game.0.move_count > 0 && !game.0.is_won {
|
||||
let streak = stats.0.win_streak_current;
|
||||
stats.0.record_abandoned();
|
||||
persist(&path, &stats.0, "forfeit");
|
||||
if streak > 1 {
|
||||
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
|
||||
}
|
||||
}
|
||||
// Reset auto-complete so the badge and chime don't carry over to the
|
||||
// new game that is about to start.
|
||||
if let Some(ref mut ac) = auto_complete {
|
||||
**ac = AutoCompleteState::default();
|
||||
}
|
||||
toast.send(InfoToastEvent("Game forfeited".to_string()));
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
@@ -186,12 +205,13 @@ fn spawn_stats_screen(
|
||||
progress: Option<&PlayerProgress>,
|
||||
time_attack: Option<&TimeAttackResource>,
|
||||
) {
|
||||
// --- primary stat cells (tasks #65 and #66) ---
|
||||
// --- primary stat cells (tasks #65, #66, and #38) ---
|
||||
let win_rate_str = format_win_rate(stats);
|
||||
let played_str = format_stat_value(stats.games_played);
|
||||
let won_str = format_stat_value(stats.games_won);
|
||||
let lost_str = format_stat_value(stats.games_lost);
|
||||
let fastest_str = format_fastest_win(stats.fastest_win_seconds);
|
||||
let avg_time_str = format_avg_time(stats);
|
||||
let best_score_str = format_optional_u32(stats.best_single_score);
|
||||
let best_streak_str = format_stat_value(stats.win_streak_best);
|
||||
|
||||
@@ -241,6 +261,7 @@ fn spawn_stats_screen(
|
||||
spawn_stat_cell(grid, &won_str, "Games Won");
|
||||
spawn_stat_cell(grid, &lost_str, "Games Lost");
|
||||
spawn_stat_cell(grid, &fastest_str, "Fastest Win");
|
||||
spawn_stat_cell(grid, &avg_time_str, "Avg Time");
|
||||
spawn_stat_cell(grid, &best_score_str, "Best Score");
|
||||
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
|
||||
});
|
||||
@@ -380,6 +401,18 @@ pub fn format_fastest_win(fastest_win_seconds: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format `avg_time_seconds` for display.
|
||||
///
|
||||
/// Returns `"—"` when no games have been won yet (`games_won == 0`), otherwise
|
||||
/// delegates to [`format_duration`].
|
||||
pub fn format_avg_time(stats: &StatsSnapshot) -> String {
|
||||
if stats.games_won == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
format_duration(stats.avg_time_seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an optional `u32` statistic.
|
||||
///
|
||||
/// Returns `"—"` when `value` is `0`, otherwise the decimal representation.
|
||||
@@ -417,13 +450,13 @@ fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
|
||||
format!("{remaining} XP ({pct}%)")
|
||||
}
|
||||
|
||||
/// Format a duration given in whole seconds as `"Mm SSs"`.
|
||||
/// Format a duration given in whole seconds as `"M:SS"`.
|
||||
///
|
||||
/// Example: `90` → `"1m 30s"`.
|
||||
/// Example: `90` → `"1:30"`.
|
||||
pub fn format_duration(secs: u64) -> String {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}m {s:02}s")
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
|
||||
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
|
||||
@@ -630,22 +663,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_duration_zero_seconds() {
|
||||
assert_eq!(format_duration(0), "0m 00s");
|
||||
assert_eq!(format_duration(0), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_pads_seconds_to_two_digits() {
|
||||
assert_eq!(format_duration(65), "1m 05s");
|
||||
assert_eq!(format_duration(65), "1:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_exactly_one_hour() {
|
||||
assert_eq!(format_duration(3600), "60m 00s");
|
||||
assert_eq!(format_duration(3600), "60:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_handles_sub_minute() {
|
||||
assert_eq!(format_duration(59), "0m 59s");
|
||||
assert_eq!(format_duration(59), "0:59");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -687,8 +720,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_fastest_win_90s() {
|
||||
// 90 seconds → "1m 30s"
|
||||
assert_eq!(format_fastest_win(90), "1m 30s");
|
||||
// 90 seconds → "1:30"
|
||||
assert_eq!(format_fastest_win(90), "1:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -696,4 +729,100 @@ mod tests {
|
||||
// best_single_score == 0 → "—"
|
||||
assert_eq!(format_optional_u32(0), "\u{2014}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #38 — avg time pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_avg_time_no_wins_shows_dash() {
|
||||
// games_won == 0 → "—"
|
||||
let s = StatsSnapshot::default();
|
||||
assert_eq!(format_avg_time(&s), "\u{2014}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_avg_time_after_single_win() {
|
||||
// After one win of 90 s avg should be "1:30"
|
||||
let s = StatsSnapshot {
|
||||
games_won: 1,
|
||||
avg_time_seconds: 90,
|
||||
..StatsSnapshot::default()
|
||||
};
|
||||
assert_eq!(format_avg_time(&s), "1:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_avg_time_after_multiple_wins() {
|
||||
// avg_time_seconds = 200 s → "3:20"
|
||||
let s = StatsSnapshot {
|
||||
games_won: 3,
|
||||
avg_time_seconds: 200,
|
||||
..StatsSnapshot::default()
|
||||
};
|
||||
assert_eq!(format_avg_time(&s), "3:20");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #49 — streak-broken toast on forfeit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn forfeit_with_streak_fires_streak_broken_toast() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Set up a streak of 3 and at least one move so forfeit counts.
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.win_streak_current = 3;
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.0
|
||||
.move_count = 1;
|
||||
|
||||
app.world_mut().send_event(ForfeitEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let messages: Vec<&str> = reader
|
||||
.read(events)
|
||||
.map(|e| e.0.as_str())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
messages.iter().any(|m| *m == "Streak of 3 broken!"),
|
||||
"expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_with_streak_of_one_does_not_fire_streak_broken_toast() {
|
||||
let mut app = headless_app();
|
||||
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.win_streak_current = 1;
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.0
|
||||
.move_count = 1;
|
||||
|
||||
app.world_mut().send_event(ForfeitEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let messages: Vec<&str> = reader
|
||||
.read(events)
|
||||
.map(|e| e.0.as_str())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!messages.iter().any(|m| m.contains("broken")),
|
||||
"expected no streak-broken toast for streak of 1, got: {messages:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,16 @@
|
||||
//! shake duration elapses.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
use crate::events::{
|
||||
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, XpAwardedEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -44,6 +51,47 @@ pub struct WinSummaryPending {
|
||||
pub time_seconds: u64,
|
||||
/// XP awarded from the most recent `XpAwardedEvent` (0 until that event fires).
|
||||
pub xp: u64,
|
||||
/// Human-readable breakdown of the XP components for the most recent win,
|
||||
/// e.g. `"+50 base +25 no-undo +30 speed"`. Empty until `GameWonEvent`
|
||||
/// populates it.
|
||||
pub xp_detail: String,
|
||||
/// Whether this win beat the player's previous best score or fastest time.
|
||||
///
|
||||
/// Captured from `StatsResource` **before** `StatsUpdate` mutates it so
|
||||
/// the comparison reflects the old personal-best values.
|
||||
pub new_record: bool,
|
||||
/// When the winning game was a Challenge-mode run, holds the 1-based
|
||||
/// human-readable level number that was just completed (e.g. `Some(3)`
|
||||
/// means "Challenge 3"). `None` for non-Challenge modes.
|
||||
pub challenge_level: Option<u32>,
|
||||
}
|
||||
|
||||
/// Builds a human-readable XP breakdown string for the win modal.
|
||||
///
|
||||
/// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always
|
||||
/// matches the total shown on the `XpAwardedEvent`.
|
||||
///
|
||||
/// Examples:
|
||||
/// - slow win, no undo → `"+50 base +25 no-undo"`
|
||||
/// - fast win, undo → `"+50 base +30 speed"`
|
||||
/// - fast win, no undo → `"+50 base +25 no-undo +30 speed"`
|
||||
fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
|
||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||
0
|
||||
} else {
|
||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||
scaled.max(10)
|
||||
};
|
||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
||||
|
||||
let mut parts = vec!["+50 base".to_string()];
|
||||
if no_undo_bonus > 0 {
|
||||
parts.push("+25 no-undo".to_string());
|
||||
}
|
||||
if speed_bonus > 0 {
|
||||
parts.push(format!("+{speed_bonus} speed"));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// Drives the camera shake effect after a win.
|
||||
@@ -59,6 +107,32 @@ pub struct ScreenShakeResource {
|
||||
pub intensity: f32,
|
||||
}
|
||||
|
||||
/// Tracks the human-readable names of every achievement unlocked during the
|
||||
/// current game session.
|
||||
///
|
||||
/// Populated by `collect_session_achievements` from `AchievementUnlockedEvent`s
|
||||
/// and cleared whenever `NewGameRequestEvent` fires so each new game starts
|
||||
/// with a fresh list. This includes all implicit game-context resets triggered
|
||||
/// by mode-switch keys:
|
||||
///
|
||||
/// | Key | Mode | Event fired |
|
||||
/// |-----|------|-------------|
|
||||
/// | Z | Zen | `NewGameRequestEvent { mode: Some(Zen), .. }` |
|
||||
/// | X | Challenge | `NewGameRequestEvent { mode: Some(Challenge), .. }` |
|
||||
/// | C | Daily Challenge | `NewGameRequestEvent { seed: Some(..), mode: None }` |
|
||||
/// | T | Time Attack | `NewGameRequestEvent { mode: Some(TimeAttack), .. }` |
|
||||
///
|
||||
/// Because every mode switch routes through `NewGameRequestEvent`,
|
||||
/// `collect_session_achievements` clears this list for all of them.
|
||||
/// The win-summary modal reads this resource to display an
|
||||
/// "Achievements Unlocked" section.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SessionAchievements {
|
||||
/// Display names (not IDs) of achievements unlocked this session, in
|
||||
/// unlock order.
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -84,13 +158,24 @@ impl Plugin for WinSummaryPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<WinSummaryPending>()
|
||||
.init_resource::<ScreenShakeResource>()
|
||||
.init_resource::<SessionAchievements>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<XpAwardedEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<InfoToastEvent>()
|
||||
.add_event::<AchievementUnlockedEvent>()
|
||||
// `cache_win_data` must run BEFORE `StatsUpdate` so it can compare
|
||||
// the player's old personal-best values before `StatsPlugin` overwrites them.
|
||||
.add_systems(
|
||||
Update,
|
||||
cache_win_data
|
||||
.after(GameMutation)
|
||||
.before(StatsUpdate),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
cache_win_data,
|
||||
collect_session_achievements,
|
||||
spawn_win_summary_after_delay,
|
||||
handle_win_summary_buttons,
|
||||
apply_screen_shake,
|
||||
@@ -124,31 +209,105 @@ pub fn format_win_time(seconds: u64) -> String {
|
||||
|
||||
/// Caches score/time from `GameWonEvent` and XP from `XpAwardedEvent` into
|
||||
/// `WinSummaryPending` so they are available when the modal spawns.
|
||||
///
|
||||
/// Also compares the win result against the player's previous personal bests
|
||||
/// **before** `StatsUpdate` overwrites them, setting `WinSummaryPending::new_record`
|
||||
/// and queuing an `InfoToastEvent` when the player sets a new record.
|
||||
///
|
||||
/// When the winning game is in `GameMode::Challenge`, the current
|
||||
/// `challenge_index` (before `ChallengePlugin` advances it) is captured as the
|
||||
/// 1-based level number and stored in `WinSummaryPending::challenge_level`.
|
||||
///
|
||||
/// This system is scheduled `.before(StatsUpdate)` so the comparison always
|
||||
/// sees the old best values.
|
||||
fn cache_win_data(
|
||||
mut won: EventReader<GameWonEvent>,
|
||||
mut xp: EventReader<XpAwardedEvent>,
|
||||
mut pending: ResMut<WinSummaryPending>,
|
||||
stats: Res<StatsResource>,
|
||||
game: Res<GameStateResource>,
|
||||
progress: Res<ProgressResource>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for ev in won.read() {
|
||||
// Compare against old personal bests BEFORE StatsPlugin updates them.
|
||||
// `best_single_score == 0` means no wins yet — any positive score is a record.
|
||||
// `fastest_win_seconds == u64::MAX` is the sentinel for "no wins yet".
|
||||
let beats_score = ev.score > 0 && ev.score as u32 > stats.0.best_single_score;
|
||||
let beats_time = stats.0.fastest_win_seconds == u64::MAX
|
||||
|| ev.time_seconds < stats.0.fastest_win_seconds;
|
||||
let is_new_record = beats_score || beats_time;
|
||||
|
||||
// Capture the challenge level (1-based) before ChallengePlugin advances
|
||||
// the index. Only populated for Challenge-mode wins.
|
||||
let challenge_level = if game.0.mode == GameMode::Challenge {
|
||||
Some(progress.0.challenge_index.saturating_add(1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let used_undo = game.0.undo_count > 0;
|
||||
pending.score = ev.score;
|
||||
pending.time_seconds = ev.time_seconds;
|
||||
pending.xp = 0; // reset; XP event follows
|
||||
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
||||
pending.new_record = is_new_record;
|
||||
pending.challenge_level = challenge_level;
|
||||
|
||||
if is_new_record {
|
||||
toast.send(InfoToastEvent("New Record!".to_string()));
|
||||
}
|
||||
}
|
||||
for ev in xp.read() {
|
||||
pending.xp = ev.amount;
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulates achievement names unlocked this session and resets them on a new game.
|
||||
///
|
||||
/// Listens for `AchievementUnlockedEvent` and appends the human-readable name
|
||||
/// of each newly unlocked achievement to `SessionAchievements`. Clears the list
|
||||
/// whenever `NewGameRequestEvent` fires so each fresh game starts clean.
|
||||
///
|
||||
/// All mode-switch keys (Z → Zen, X → Challenge, C → Daily Challenge,
|
||||
/// T → Time Attack) route through `NewGameRequestEvent`, so this single
|
||||
/// reader covers every implicit game-context reset in addition to the
|
||||
/// explicit N / "Play Again" new-game requests.
|
||||
fn collect_session_achievements(
|
||||
mut unlocks: EventReader<AchievementUnlockedEvent>,
|
||||
mut new_games: EventReader<NewGameRequestEvent>,
|
||||
mut session: ResMut<SessionAchievements>,
|
||||
) {
|
||||
// Reset on any new-game request (including mode switches via Z/X/C/T) so
|
||||
// achievements from the previous session are not carried into the next one.
|
||||
if new_games.read().last().is_some() {
|
||||
session.names.clear();
|
||||
}
|
||||
for ev in unlocks.read() {
|
||||
session.names.push(display_name_for(&ev.0.id));
|
||||
}
|
||||
}
|
||||
|
||||
/// After `GameWonEvent`, arms the screen-shake resource.
|
||||
///
|
||||
/// This system shares the `GameWonEvent` stream with `cache_win_data` through
|
||||
/// the delay timer stored in `Local` — the shake fires immediately, while the
|
||||
/// modal waits 0.5 s.
|
||||
///
|
||||
/// Just before the overlay is spawned the system also drains any pending
|
||||
/// `XpAwardedEvent`s and folds their amounts into `pending.xp`. This guards
|
||||
/// against the edge case where `XpAwardedEvent` arrives in the same frame as
|
||||
/// the timer fires but `cache_win_data` runs *after* this system in that
|
||||
/// frame's schedule, which would otherwise leave `pending.xp` at 0 when
|
||||
/// `spawn_overlay` reads it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_win_summary_after_delay(
|
||||
mut commands: Commands,
|
||||
mut won: EventReader<GameWonEvent>,
|
||||
mut xp_events: EventReader<XpAwardedEvent>,
|
||||
mut shake: ResMut<ScreenShakeResource>,
|
||||
pending: Res<WinSummaryPending>,
|
||||
mut pending: ResMut<WinSummaryPending>,
|
||||
session: Res<SessionAchievements>,
|
||||
time: Res<Time>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut delay: Local<Option<f32>>,
|
||||
@@ -173,7 +332,15 @@ fn spawn_win_summary_after_delay(
|
||||
*delay = None;
|
||||
// Only spawn if there is no overlay already.
|
||||
if overlays.is_empty() {
|
||||
spawn_overlay(&mut commands, &pending);
|
||||
// Drain any XpAwardedEvents that arrived this frame but were
|
||||
// not yet consumed by `cache_win_data` (which may run later in
|
||||
// the same schedule). Accumulating here ensures the modal
|
||||
// never shows "XP: +0" due to a same-frame ordering race.
|
||||
for ev in xp_events.read() {
|
||||
pending.xp = pending.xp.saturating_add(ev.amount);
|
||||
}
|
||||
let challenge_level = pending.challenge_level;
|
||||
spawn_overlay(&mut commands, &pending, &session, challenge_level);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +407,16 @@ fn apply_screen_shake(
|
||||
// UI construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
||||
/// Spawns the full-screen win-summary modal.
|
||||
///
|
||||
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
||||
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
||||
fn spawn_overlay(
|
||||
commands: &mut Commands,
|
||||
pending: &WinSummaryPending,
|
||||
session: &SessionAchievements,
|
||||
challenge_level: Option<u32>,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
WinSummaryOverlay,
|
||||
@@ -279,6 +455,25 @@ fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
));
|
||||
|
||||
// Challenge-mode annotation — shown only for Challenge wins.
|
||||
if let Some(level) = challenge_level {
|
||||
card.spawn((
|
||||
Text::new(format!("Challenge {level} complete!")),
|
||||
TextFont { font_size: 28.0, ..default() },
|
||||
TextColor(Color::srgb(0.4, 0.85, 1.0)),
|
||||
));
|
||||
}
|
||||
|
||||
// New Record badge — shown only when the player beats their
|
||||
// previous best score or fastest win time.
|
||||
if pending.new_record {
|
||||
card.spawn((
|
||||
Text::new("New Record!"),
|
||||
TextFont { font_size: 26.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.55, 0.0)),
|
||||
));
|
||||
}
|
||||
|
||||
// Score
|
||||
card.spawn((
|
||||
Text::new(format!("Score: {}", pending.score)),
|
||||
@@ -293,13 +488,28 @@ fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
|
||||
// XP
|
||||
// XP total
|
||||
card.spawn((
|
||||
Text::new(format!("XP earned: +{}", pending.xp)),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.4, 1.0, 0.4)),
|
||||
));
|
||||
|
||||
// XP breakdown (smaller, dimmer text)
|
||||
if !pending.xp_detail.is_empty() {
|
||||
card.spawn((
|
||||
Text::new(pending.xp_detail.clone()),
|
||||
TextFont { font_size: 15.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.80, 0.55)),
|
||||
));
|
||||
}
|
||||
|
||||
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||
// excess is summarised with "...and N more".
|
||||
if !session.names.is_empty() {
|
||||
spawn_achievements_section(card, &session.names);
|
||||
}
|
||||
|
||||
// Play Again button
|
||||
card.spawn((
|
||||
WinSummaryButton::PlayAgain,
|
||||
@@ -324,6 +534,41 @@ fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Maximum number of achievement names shown explicitly in the win modal before
|
||||
/// the overflow "...and N more" line is shown instead.
|
||||
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
||||
|
||||
/// Spawns the "Achievements Unlocked" sub-section inside the win modal card.
|
||||
///
|
||||
/// Shows at most [`MAX_ACHIEVEMENTS_SHOWN`] names. When more achievements were
|
||||
/// unlocked than the cap, appends a "...and N more" line so the player knows
|
||||
/// there are additional unlocks visible on the achievements screen.
|
||||
fn spawn_achievements_section(card: &mut ChildBuilder, names: &[String]) {
|
||||
card.spawn((
|
||||
Text::new("Achievements Unlocked"),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
));
|
||||
|
||||
let shown = names.len().min(MAX_ACHIEVEMENTS_SHOWN);
|
||||
for name in &names[..shown] {
|
||||
card.spawn((
|
||||
Text::new(format!(" {name}")),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.9, 0.9, 0.9)),
|
||||
));
|
||||
}
|
||||
|
||||
let overflow = names.len().saturating_sub(MAX_ACHIEVEMENTS_SHOWN);
|
||||
if overflow > 0 {
|
||||
card.spawn((
|
||||
Text::new(format!(" ...and {overflow} more")),
|
||||
TextFont { font_size: 15.0, ..default() },
|
||||
TextColor(Color::srgb(0.6, 0.6, 0.65)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -331,6 +576,22 @@ fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_data::{PlayerProgress, StatsSnapshot};
|
||||
|
||||
/// Build a minimal app with `WinSummaryPlugin` and all resources required
|
||||
/// by `cache_win_data`: `StatsResource`, `GameStateResource`, and
|
||||
/// `ProgressResource`.
|
||||
fn make_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||
.insert_resource(GameStateResource(GameState::new(0, solitaire_core::game_state::DrawMode::DrawOne)))
|
||||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_win_time_zero() {
|
||||
@@ -370,24 +631,129 @@ mod tests {
|
||||
assert_eq!(p.score, 0);
|
||||
assert_eq!(p.time_seconds, 0);
|
||||
assert_eq!(p.xp, 0);
|
||||
assert!(p.xp_detail.is_empty());
|
||||
assert!(!p.new_record);
|
||||
assert!(p.challenge_level.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_slow_win_with_undo() {
|
||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||
let detail = build_xp_detail(300, true);
|
||||
assert_eq!(detail, "+50 base");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_slow_win_no_undo() {
|
||||
let detail = build_xp_detail(300, false);
|
||||
assert_eq!(detail, "+50 base +25 no-undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_fast_win_with_undo() {
|
||||
// 0s → speed bonus 50.
|
||||
let detail = build_xp_detail(0, true);
|
||||
assert_eq!(detail, "+50 base +50 speed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_fast_win_no_undo() {
|
||||
let detail = build_xp_detail(0, false);
|
||||
assert_eq!(detail, "+50 base +25 no-undo +50 speed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_summary_plugin_inserts_resources() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(WinSummaryPlugin);
|
||||
app.update();
|
||||
let app = make_app();
|
||||
assert!(app.world().get_resource::<WinSummaryPending>().is_some());
|
||||
assert!(app.world().get_resource::<ScreenShakeResource>().is_some());
|
||||
assert!(app.world().get_resource::<SessionAchievements>().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_achievements_accumulates_unlock_events() {
|
||||
let mut app = make_app();
|
||||
|
||||
use solitaire_data::AchievementRecord;
|
||||
let record = AchievementRecord::locked("first_win");
|
||||
app.world_mut()
|
||||
.send_event(AchievementUnlockedEvent(record));
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<SessionAchievements>();
|
||||
assert_eq!(session.names.len(), 1);
|
||||
// display_name_for("first_win") == "First Win"
|
||||
assert_eq!(session.names[0], "First Win");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_achievements_resets_on_new_game_request() {
|
||||
let mut app = make_app();
|
||||
|
||||
use solitaire_data::AchievementRecord;
|
||||
let record = AchievementRecord::locked("first_win");
|
||||
app.world_mut()
|
||||
.send_event(AchievementUnlockedEvent(record));
|
||||
app.update();
|
||||
|
||||
// Confirm it was recorded.
|
||||
assert_eq!(
|
||||
app.world().resource::<SessionAchievements>().names.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Fire NewGameRequestEvent — should clear the list.
|
||||
app.world_mut().send_event(NewGameRequestEvent::default());
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||||
"session achievements must be cleared on NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that mode-switch new-game requests (Z/X/C/T keys) also clear
|
||||
/// `SessionAchievements`. All mode switches route through
|
||||
/// `NewGameRequestEvent` with a non-`None` `mode` or `seed` field, so
|
||||
/// this test uses `GameMode::Zen` as a representative case; the same path
|
||||
/// is taken for Challenge, Daily Challenge, and Time Attack.
|
||||
#[test]
|
||||
fn session_achievements_resets_on_mode_switch_new_game_request() {
|
||||
let mut app = make_app();
|
||||
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_data::AchievementRecord;
|
||||
|
||||
// Simulate an achievement unlock during the current session.
|
||||
let record = AchievementRecord::locked("first_win");
|
||||
app.world_mut()
|
||||
.send_event(AchievementUnlockedEvent(record));
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<SessionAchievements>().names.len(),
|
||||
1,
|
||||
"achievement should be recorded before the mode switch"
|
||||
);
|
||||
|
||||
// Simulate pressing Z (Zen mode switch) — fires NewGameRequestEvent
|
||||
// with mode = Some(Zen). Same event shape used by X (Challenge),
|
||||
// C (Daily Challenge), and T (Time Attack).
|
||||
app.world_mut().send_event(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(GameMode::Zen),
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||||
"session achievements must be cleared when a mode-switch NewGameRequestEvent fires"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_win_data_sets_score_and_time() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(WinSummaryPlugin);
|
||||
app.update();
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
|
||||
@@ -396,14 +762,14 @@ mod tests {
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert_eq!(pending.score, 1234);
|
||||
assert_eq!(pending.time_seconds, 90);
|
||||
// 90s < 120s → speed bonus present; default game has undo_count=0 → no-undo bonus present.
|
||||
assert!(!pending.xp_detail.is_empty(), "xp_detail must be populated on GameWonEvent");
|
||||
assert!(pending.xp_detail.contains("+50 base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(WinSummaryPlugin);
|
||||
app.update();
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
|
||||
@@ -415,10 +781,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn game_won_event_arms_screen_shake() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(WinSummaryPlugin);
|
||||
app.update();
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
@@ -427,4 +790,126 @@ mod tests {
|
||||
let shake = app.world().resource::<ScreenShakeResource>();
|
||||
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// New Record detection tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn first_win_is_always_a_new_record() {
|
||||
// Default stats: best_single_score=0, fastest_win_seconds=u64::MAX.
|
||||
// Any positive-score win should be flagged as a new record.
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "first win should always set new_record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_that_beats_best_score_sets_new_record() {
|
||||
let mut app = make_app();
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.best_single_score = 400;
|
||||
stats.0.fastest_win_seconds = 200;
|
||||
}
|
||||
|
||||
// Score 500 beats previous best of 400.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 300 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "beating best score should set new_record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_that_beats_fastest_time_sets_new_record() {
|
||||
let mut app = make_app();
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.best_single_score = 800;
|
||||
stats.0.fastest_win_seconds = 200;
|
||||
}
|
||||
|
||||
// Score 500 does not beat 800, but time 100 < 200.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 100 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "beating fastest time should set new_record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_below_personal_bests_does_not_set_new_record() {
|
||||
let mut app = make_app();
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.best_single_score = 800;
|
||||
stats.0.fastest_win_seconds = 60;
|
||||
}
|
||||
|
||||
// Score 500 < 800 and time 120 > 60 — neither record broken.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(
|
||||
!pending.new_record,
|
||||
"win below both personal bests must not set new_record"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Challenge-level capture tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_win_captures_level_number() {
|
||||
let mut app = make_app();
|
||||
|
||||
// Set challenge_index = 4 so the completed level is 5 (1-based).
|
||||
app.world_mut()
|
||||
.resource_mut::<ProgressResource>()
|
||||
.0
|
||||
.challenge_index = 4;
|
||||
// Switch game mode to Challenge.
|
||||
{
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
}
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert_eq!(
|
||||
pending.challenge_level,
|
||||
Some(5),
|
||||
"challenge_level must be 1-based index of the completed challenge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_win_leaves_challenge_level_none() {
|
||||
let mut app = make_app();
|
||||
// Default game mode is Classic — challenge_level should stay None.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(
|
||||
pending.challenge_level.is_none(),
|
||||
"challenge_level must be None for non-Challenge wins"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user