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
|
### Bevy Plugins
|
||||||
|
|
||||||
| Plugin | Responsibility |
|
| Plugin | Key | Responsibility |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `CardPlugin` | Card entity spawning, sprite management, drag-and-drop |
|
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
||||||
| `TablePlugin` | Pile markers, background, layout calculation |
|
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||||
| `AnimationPlugin` | Slide, flip, win cascade, toast animations |
|
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||||
| `AudioPlugin` | Sound effect and music playback via bevy_kira_audio |
|
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||||
| `UIPlugin` | All Bevy UI screens: Home, Stats, Achievements, Settings, Profile |
|
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||||
| `AchievementPlugin` | Listens for game events, evaluates unlock conditions, fires toasts |
|
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
|
||||||
| `SyncPlugin` | Manages sync lifecycle (pull on start, push on exit, status display) |
|
| `InputPlugin` | — | Keyboard and mouse input routing |
|
||||||
| `GamePlugin` | Core game state resource, input routing, win detection |
|
| `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
|
### Key Bevy Resources
|
||||||
|
|
||||||
@@ -588,6 +607,9 @@ pub enum PileType {
|
|||||||
|
|
||||||
pub enum DrawMode { DrawOne, DrawThree }
|
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 {
|
pub enum MoveError {
|
||||||
InvalidSource,
|
InvalidSource,
|
||||||
InvalidDestination,
|
InvalidDestination,
|
||||||
@@ -600,13 +622,16 @@ pub enum MoveError {
|
|||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
pub piles: HashMap<PileType, Vec<Card>>,
|
pub piles: HashMap<PileType, Vec<Card>>,
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub move_count: u32,
|
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 elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub is_won: bool,
|
pub is_won: bool,
|
||||||
pub is_auto_completable: 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::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
||||||
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
||||||
HelpPlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
|
||||||
ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin,
|
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
|
||||||
WeeklyGoalsPlugin,
|
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -44,6 +44,8 @@ fn main() {
|
|||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
|
.add_plugins(HomePlugin)
|
||||||
|
.add_plugins(ProfilePlugin)
|
||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
|||||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
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 TIME_ATTACK_TOAST_SECS: f32 = 5.0;
|
||||||
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
||||||
const VOLUME_TOAST_SECS: f32 = 1.4;
|
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.
|
/// 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(
|
fn advance_card_anims(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||||
) {
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
if anim.delay > 0.0 {
|
if anim.delay > 0.0 {
|
||||||
@@ -206,6 +248,7 @@ fn handle_win_cascade(
|
|||||||
mut events: EventReader<GameWonEvent>,
|
mut events: EventReader<GameWonEvent>,
|
||||||
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
) {
|
) {
|
||||||
let Some(ev) = events.read().next() else {
|
let Some(ev) = events.read().next() else {
|
||||||
return;
|
return;
|
||||||
@@ -230,13 +273,17 @@ fn handle_win_cascade(
|
|||||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
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() {
|
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||||
commands.entity(entity).insert(CardAnim {
|
commands.entity(entity).insert(CardAnim {
|
||||||
start: transform.translation,
|
start: transform.translation,
|
||||||
target: targets[i % 8],
|
target: targets[i % 8],
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration: CASCADE_DURATION,
|
duration,
|
||||||
delay: i as f32 * CASCADE_STAGGER,
|
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
|
/// 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
|
/// active toast's timer reaches zero the entity is despawned and the next
|
||||||
/// message in `ToastQueue` is shown.
|
/// 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(
|
fn drive_toast_display(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
mut queue: ResMut<ToastQueue>,
|
mut queue: ResMut<ToastQueue>,
|
||||||
mut active: ResMut<ActiveToast>,
|
mut active: ResMut<ActiveToast>,
|
||||||
) {
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
// Tick down the active toast timer.
|
// 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(
|
fn tick_toasts(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
mut toasts: Query<(Entity, &mut ToastTimer)>,
|
mut toasts: Query<(Entity, &mut ToastTimer)>,
|
||||||
) {
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut timer) in &mut toasts {
|
for (entity, mut timer) in &mut toasts {
|
||||||
timer.0 -= dt;
|
timer.0 -= dt;
|
||||||
@@ -742,4 +806,48 @@ mod tests {
|
|||||||
.count();
|
.count();
|
||||||
assert_eq!(after, 52, "all 52 cards should have cascade animations");
|
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 kira::Volume;
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent,
|
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||||
NewGameRequestEvent, UndoRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -136,6 +136,7 @@ impl Plugin for AudioPlugin {
|
|||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<CardFlippedEvent>()
|
.add_event::<CardFlippedEvent>()
|
||||||
|
.add_event::<CardFaceRevealedEvent>()
|
||||||
.add_event::<UndoRequestEvent>()
|
.add_event::<UndoRequestEvent>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_systems(Startup, apply_initial_volume)
|
.add_systems(Startup, apply_initial_volume)
|
||||||
@@ -147,7 +148,7 @@ impl Plugin for AudioPlugin {
|
|||||||
play_on_rejected,
|
play_on_rejected,
|
||||||
play_on_new_game,
|
play_on_new_game,
|
||||||
play_on_win,
|
play_on_win,
|
||||||
play_on_card_flip,
|
play_on_face_revealed,
|
||||||
play_on_undo,
|
play_on_undo,
|
||||||
apply_volume_on_change,
|
apply_volume_on_change,
|
||||||
handle_mute_keys,
|
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) {
|
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
||||||
if let Some(track) = audio.sfx_track.as_mut() {
|
if let Some(track) = audio.sfx_track.as_mut() {
|
||||||
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
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(
|
/// Plays the card-flip sound at the animation midpoint — the instant the face
|
||||||
mut events: EventReader<CardFlippedEvent>,
|
/// 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>,
|
mut audio: NonSendMut<AudioState>,
|
||||||
lib: Option<Res<SoundLibrary>>,
|
lib: Option<Res<SoundLibrary>>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -10,10 +10,17 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::resources::GameStateResource;
|
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.
|
/// Seconds between consecutive auto-complete moves.
|
||||||
const STEP_INTERVAL: f32 = 0.12;
|
const STEP_INTERVAL: f32 = 0.12;
|
||||||
|
|
||||||
@@ -34,7 +41,11 @@ impl Plugin for AutoCompletePlugin {
|
|||||||
app.init_resource::<AutoCompleteState>()
|
app.init_resource::<AutoCompleteState>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(detect_auto_complete, drive_auto_complete)
|
(
|
||||||
|
detect_auto_complete,
|
||||||
|
on_auto_complete_start,
|
||||||
|
drive_auto_complete,
|
||||||
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.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.
|
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||||
fn drive_auto_complete(
|
fn drive_auto_complete(
|
||||||
mut state: ResMut<AutoCompleteState>,
|
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 solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||||
use crate::events::{CardFlippedEvent, StateChangedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
@@ -87,6 +87,11 @@ pub struct HintHighlight {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct RightClickHighlight;
|
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
|
// Task #34 — Card-flip animation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -137,7 +142,8 @@ impl Plugin for CardPlugin {
|
|||||||
app.init_resource::<ButtonInput<MouseButton>>()
|
app.init_resource::<ButtonInput<MouseButton>>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_event::<CardFlippedEvent>()
|
.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(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -148,6 +154,9 @@ impl Plugin for CardPlugin {
|
|||||||
update_drag_shadow,
|
update_drag_shadow,
|
||||||
tick_hint_highlight,
|
tick_hint_highlight,
|
||||||
handle_right_click,
|
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`.
|
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||||
///
|
///
|
||||||
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
/// - 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`.
|
/// - 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.
|
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||||
fn tick_flip_anim(
|
fn tick_flip_anim(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
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();
|
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;
|
anim.timer += dt;
|
||||||
match anim.phase {
|
match anim.phase {
|
||||||
FlipPhase::ScalingDown => {
|
FlipPhase::ScalingDown => {
|
||||||
@@ -527,6 +538,9 @@ fn tick_flip_anim(
|
|||||||
anim.phase = FlipPhase::ScalingUp;
|
anim.phase = FlipPhase::ScalingUp;
|
||||||
anim.timer = 0.0;
|
anim.timer = 0.0;
|
||||||
transform.scale.x = 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 => {
|
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.
|
/// 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);
|
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,
|
/// Handles right-click: highlights legal destination piles for the clicked card,
|
||||||
/// and clears highlights on any subsequent right- or left-click.
|
/// and clears highlights on any subsequent right- or left-click.
|
||||||
///
|
///
|
||||||
@@ -766,6 +833,117 @@ fn find_top_card_at(
|
|||||||
best.map(|(_, card)| card)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fn advance_on_challenge_win(
|
|||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut advanced: EventWriter<ChallengeAdvancedEvent>,
|
mut advanced: EventWriter<ChallengeAdvancedEvent>,
|
||||||
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in wins.read() {
|
for _ in wins.read() {
|
||||||
if game.0.mode != GameMode::Challenge {
|
if game.0.mode != GameMode::Challenge {
|
||||||
@@ -56,6 +57,9 @@ fn advance_on_challenge_win(
|
|||||||
warn!("failed to save progress after challenge advance: {e}");
|
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 {
|
advanced.send(ChallengeAdvancedEvent {
|
||||||
previous_index: prev,
|
previous_index: prev,
|
||||||
new_index: progress.0.challenge_index,
|
new_index: progress.0.challenge_index,
|
||||||
@@ -199,6 +203,48 @@ mod tests {
|
|||||||
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
|
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]
|
#[test]
|
||||||
fn pressing_x_below_unlock_level_fires_info_toast() {
|
fn pressing_x_below_unlock_level_fires_info_toast() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|||||||
@@ -216,10 +216,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use solitaire_core::card::{Card, Rank};
|
use solitaire_core::card::{Card, Rank};
|
||||||
|
|
||||||
fn face_up(suit: Suit, rank: Rank) -> Card {
|
|
||||||
Card { id: 0, suit, rank, face_up: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn point_in_rect_center_is_inside() {
|
fn point_in_rect_center_is_inside() {
|
||||||
assert!(point_in_rect(Vec2::ZERO, Vec2::ZERO, Vec2::new(10.0, 10.0)));
|
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_data::{daily_seed_for, save_progress_to};
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::events::{GameWonEvent, NewGameRequestEvent, XpAwardedEvent};
|
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -143,6 +143,7 @@ fn poll_server_challenge(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_daily_completion(
|
fn handle_daily_completion(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<GameWonEvent>,
|
||||||
daily: Res<DailyChallengeResource>,
|
daily: Res<DailyChallengeResource>,
|
||||||
@@ -151,6 +152,7 @@ fn handle_daily_completion(
|
|||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
||||||
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
||||||
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in wins.read() {
|
for ev in wins.read() {
|
||||||
if game.0.seed != daily.seed {
|
if game.0.seed != daily.seed {
|
||||||
@@ -182,6 +184,7 @@ fn handle_daily_completion(
|
|||||||
date: daily.date,
|
date: daily.date,
|
||||||
streak: progress.0.daily_challenge_streak,
|
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)]
|
#[derive(Event, Debug, Clone, Copy)]
|
||||||
pub struct CardFlippedEvent(pub u32);
|
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
|
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||||
/// persistence/UI systems that need unlock metadata.
|
/// persistence/UI systems that need unlock metadata.
|
||||||
|
|||||||
@@ -21,20 +21,31 @@
|
|||||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
||||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
||||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
//! 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
|
//! pile's position to its current (final) position with a per-card stagger
|
||||||
//! 0.04 s. `deal_stagger_delay` is a pure helper exposed for unit testing.
|
//! 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 std::f32::consts::PI;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{MoveRejectedEvent, NewGameRequestEvent, StateChangedEvent};
|
use crate::events::{MoveRejectedEvent, NewGameRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared constants
|
// 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
|
/// Returns the per-card stagger delay in seconds for the given `AnimSpeed`.
|
||||||
/// deal animation.
|
|
||||||
///
|
///
|
||||||
/// `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.
|
/// This is a pure function exposed for unit testing without Bevy.
|
||||||
pub fn deal_stagger_delay(index: usize) -> f32 {
|
pub fn deal_stagger_secs_for_speed(speed: &AnimSpeed) -> f32 {
|
||||||
index as f32 * DEAL_STAGGER_SECS
|
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,
|
/// Applies `translation.x = shake_offset(elapsed, origin_x)`. When done,
|
||||||
/// restores `translation.x = origin_x` so the card is left at its correct
|
/// 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(
|
fn tick_shake_anim(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||||
) {
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
@@ -238,12 +270,16 @@ fn start_settle_anim(
|
|||||||
/// Advances `SettleAnim` each frame and removes it once the animation completes.
|
/// Advances `SettleAnim` each frame and removes it once the animation completes.
|
||||||
///
|
///
|
||||||
/// Applies `transform.scale.y = settle_scale(elapsed)`. Restores scale to 1.0
|
/// 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(
|
fn tick_settle_anim(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||||
) {
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
@@ -262,14 +298,16 @@ fn tick_settle_anim(
|
|||||||
|
|
||||||
/// Inserts `CardAnim` on every card entity when a new game starts, sliding
|
/// 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
|
/// 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`)
|
/// Triggered by `NewGameRequestEvent` (when the new game has `move_count == 0`)
|
||||||
/// and fires the deal animation for every card entity currently in the world.
|
/// 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(
|
fn start_deal_anim(
|
||||||
mut events: EventReader<NewGameRequestEvent>,
|
mut events: EventReader<NewGameRequestEvent>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &Transform), With<CardEntity>>,
|
card_entities: Query<(Entity, &Transform), With<CardEntity>>,
|
||||||
mut commands: Commands,
|
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 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 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() {
|
for (index, (entity, transform)) in card_entities.iter().enumerate() {
|
||||||
let final_pos = transform.translation;
|
let final_pos = transform.translation;
|
||||||
commands.entity(entity).insert((
|
commands.entity(entity).insert((
|
||||||
@@ -293,7 +336,7 @@ fn start_deal_anim(
|
|||||||
target: final_pos,
|
target: final_pos,
|
||||||
elapsed: 0.0,
|
elapsed: 0.0,
|
||||||
duration: DEAL_SLIDE_SECS,
|
duration: DEAL_SLIDE_SECS,
|
||||||
delay: deal_stagger_delay(index),
|
delay: deal_stagger_delay(index, stagger_secs),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -359,17 +402,50 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deal_stagger_delay_zero_index_is_zero() {
|
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]
|
#[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 {
|
for i in 0..52 {
|
||||||
let expected = i as f32 * DEAL_STAGGER_SECS;
|
let expected = i as f32 * stagger;
|
||||||
let actual = deal_stagger_delay(i);
|
let actual = deal_stagger_delay(i, stagger);
|
||||||
assert!(
|
assert!(
|
||||||
(actual - expected).abs() < 1e-6,
|
(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};
|
save_game_state_to};
|
||||||
|
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent, NewGameRequestEvent,
|
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
||||||
StateChangedEvent, UndoRequestEvent,
|
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||||
|
|
||||||
@@ -317,10 +317,38 @@ fn handle_draw(
|
|||||||
mut draws: EventReader<DrawRequestEvent>,
|
mut draws: EventReader<DrawRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
|
mut flipped: EventWriter<CardFlippedEvent>,
|
||||||
) {
|
) {
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
for _ in draws.read() {
|
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() {
|
match game.0.draw() {
|
||||||
Ok(()) => {
|
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);
|
changed.send(StateChangedEvent);
|
||||||
}
|
}
|
||||||
Err(e) => warn!("draw rejected: {e}"),
|
Err(e) => warn!("draw rejected: {e}"),
|
||||||
@@ -383,12 +411,18 @@ fn handle_undo(
|
|||||||
mut undos: EventReader<UndoRequestEvent>,
|
mut undos: EventReader<UndoRequestEvent>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
|
use solitaire_core::error::MoveError;
|
||||||
|
|
||||||
for _ in undos.read() {
|
for _ in undos.read() {
|
||||||
match game.0.undo() {
|
match game.0.undo() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
changed.send(StateChangedEvent);
|
changed.send(StateChangedEvent);
|
||||||
}
|
}
|
||||||
|
Err(MoveError::UndoStackEmpty) => {
|
||||||
|
toast.send(InfoToastEvent("Nothing to undo".to_string()));
|
||||||
|
}
|
||||||
Err(e) => warn!("undo rejected: {e}"),
|
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) {
|
fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -526,7 +563,7 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
|||||||
row_gap: Val::Px(20.0),
|
row_gap: Val::Px(20.0),
|
||||||
..default()
|
..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),
|
ZIndex(200),
|
||||||
))
|
))
|
||||||
.with_children(|root| {
|
.with_children(|root| {
|
||||||
@@ -543,9 +580,9 @@ fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
|||||||
BorderRadius::all(Val::Px(12.0)),
|
BorderRadius::all(Val::Px(12.0)),
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|card| {
|
||||||
// Title
|
// Header — explains why the overlay appeared.
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new("No More Moves"),
|
Text::new("No more moves available"),
|
||||||
TextFont { font_size: 36.0, ..default() },
|
TextFont { font_size: 36.0, ..default() },
|
||||||
TextColor(Color::srgb(1.0, 0.4, 0.1)),
|
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() },
|
TextFont { font_size: 24.0, ..default() },
|
||||||
TextColor(Color::WHITE),
|
TextColor(Color::WHITE),
|
||||||
));
|
));
|
||||||
// Button row
|
// Action hints — stacked vertically for legibility.
|
||||||
card.spawn((Node {
|
card.spawn((
|
||||||
flex_direction: FlexDirection::Row,
|
Node {
|
||||||
column_gap: Val::Px(24.0),
|
flex_direction: FlexDirection::Column,
|
||||||
margin: UiRect::top(Val::Px(8.0)),
|
row_gap: Val::Px(8.0),
|
||||||
..default()
|
margin: UiRect::top(Val::Px(8.0)),
|
||||||
},))
|
align_items: AlignItems::Center,
|
||||||
.with_children(|row| {
|
..default()
|
||||||
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() },
|
TextFont { font_size: 20.0, ..default() },
|
||||||
TextColor(Color::srgb(0.3, 1.0, 0.4)),
|
TextColor(Color::srgb(0.3, 1.0, 0.4)),
|
||||||
));
|
));
|
||||||
row.spawn((
|
hints.spawn((
|
||||||
Text::new("Undo (U)"),
|
Text::new("Press G to forfeit (counts as a loss)"),
|
||||||
TextFont { font_size: 20.0, ..default() },
|
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.
|
/// Handles keyboard input while `GameOverScreen` is open.
|
||||||
///
|
///
|
||||||
/// `N` fires `NewGameRequestEvent` (which will trigger the confirm dialog if
|
/// `N` or `Escape` fires `NewGameRequestEvent` (which will trigger the confirm
|
||||||
/// moves have been made). `U` fires `UndoRequestEvent` and despawns the overlay
|
/// dialog if moves have been made). `U` fires `UndoRequestEvent` and despawns
|
||||||
/// — the `check_no_moves` system will re-show it on the next `StateChangedEvent`
|
/// the overlay — the `check_no_moves` system will re-show it on the next
|
||||||
/// if the undo did not restore any legal moves.
|
/// `StateChangedEvent` if the undo did not restore any legal moves.
|
||||||
fn handle_game_over_input(
|
fn handle_game_over_input(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
@@ -598,7 +638,7 @@ fn handle_game_over_input(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if keys.just_pressed(KeyCode::KeyN) {
|
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
|
||||||
new_game.send(NewGameRequestEvent::default());
|
new_game.send(NewGameRequestEvent::default());
|
||||||
} else if keys.just_pressed(KeyCode::KeyU) {
|
} else if keys.just_pressed(KeyCode::KeyU) {
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
@@ -1171,4 +1211,166 @@ mod tests {
|
|||||||
.count();
|
.count();
|
||||||
assert_eq!(count, 1, "GameOverScreen must appear when no legal moves exist");
|
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.
|
//! 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.
|
//! gameplay, modes, and overlays.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -22,8 +22,7 @@ fn toggle_help_screen(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
screens: Query<Entity, With<HelpScreen>>,
|
screens: Query<Entity, With<HelpScreen>>,
|
||||||
) {
|
) {
|
||||||
let pressed_help = keys.just_pressed(KeyCode::KeyH) || keys.just_pressed(KeyCode::Slash);
|
if !keys.just_pressed(KeyCode::F1) {
|
||||||
if !pressed_help {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.get_single() {
|
if let Ok(entity) = screens.get_single() {
|
||||||
@@ -55,11 +54,12 @@ fn spawn_help_screen(commands: &mut Commands) {
|
|||||||
" A Achievements".to_string(),
|
" A Achievements".to_string(),
|
||||||
" L Leaderboard".to_string(),
|
" L Leaderboard".to_string(),
|
||||||
" O Settings".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(),
|
" Esc Pause / resume".to_string(),
|
||||||
" [ / ] SFX volume down / up".to_string(),
|
" [ / ] SFX volume down / up".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
"Press H or ? to close".to_string(),
|
"Press F1 to close".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
commands
|
commands
|
||||||
@@ -107,11 +107,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pressing_h_spawns_help_screen() {
|
fn pressing_f1_spawns_help_screen() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<ButtonInput<KeyCode>>()
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
.press(KeyCode::KeyH);
|
.press(KeyCode::F1);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -124,18 +124,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pressing_h_twice_closes_help_screen() {
|
fn pressing_f1_twice_closes_help_screen() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.resource_mut::<ButtonInput<KeyCode>>()
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
.press(KeyCode::KeyH);
|
.press(KeyCode::F1);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
input.release(KeyCode::KeyH);
|
input.release(KeyCode::F1);
|
||||||
input.clear();
|
input.clear();
|
||||||
input.press(KeyCode::KeyH);
|
input.press(KeyCode::F1);
|
||||||
}
|
}
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -147,21 +147,4 @@ mod tests {
|
|||||||
0
|
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.
|
//! without a separate tick system.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::events::InfoToastEvent;
|
use crate::events::InfoToastEvent;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::selection_plugin::SelectionState;
|
||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
|
|
||||||
/// Marker on the score text node.
|
/// Marker on the score text node.
|
||||||
@@ -53,6 +56,33 @@ pub struct HudUndos;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudAutoComplete;
|
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.
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||||
const Z_HUD: i32 = 50;
|
const Z_HUD: i32 = 50;
|
||||||
|
|
||||||
@@ -62,7 +92,8 @@ impl Plugin for HudPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(Startup, spawn_hud)
|
app.add_systems(Startup, spawn_hud)
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
.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((
|
b.spawn((
|
||||||
HudUndos,
|
HudUndos,
|
||||||
Text::new(""),
|
Text::new(""),
|
||||||
font,
|
font.clone(),
|
||||||
white,
|
white,
|
||||||
));
|
));
|
||||||
// Auto-complete badge (green "AUTO" when sequence is running).
|
// 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() },
|
TextFont { font_size: 17.0, ..default() },
|
||||||
TextColor(Color::srgb(0.2, 0.9, 0.3)),
|
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<HudChallenge>,
|
||||||
Without<HudUndos>,
|
Without<HudUndos>,
|
||||||
Without<HudAutoComplete>,
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
),
|
),
|
||||||
>,
|
>,
|
||||||
mut moves_q: Query<
|
mut moves_q: Query<
|
||||||
@@ -153,6 +208,9 @@ fn update_hud(
|
|||||||
Without<HudChallenge>,
|
Without<HudChallenge>,
|
||||||
Without<HudUndos>,
|
Without<HudUndos>,
|
||||||
Without<HudAutoComplete>,
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
),
|
),
|
||||||
>,
|
>,
|
||||||
mut time_q: Query<
|
mut time_q: Query<
|
||||||
@@ -165,6 +223,9 @@ fn update_hud(
|
|||||||
Without<HudChallenge>,
|
Without<HudChallenge>,
|
||||||
Without<HudUndos>,
|
Without<HudUndos>,
|
||||||
Without<HudAutoComplete>,
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
),
|
),
|
||||||
>,
|
>,
|
||||||
mut mode_q: Query<
|
mut mode_q: Query<
|
||||||
@@ -177,6 +238,9 @@ fn update_hud(
|
|||||||
Without<HudChallenge>,
|
Without<HudChallenge>,
|
||||||
Without<HudUndos>,
|
Without<HudUndos>,
|
||||||
Without<HudAutoComplete>,
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
),
|
),
|
||||||
>,
|
>,
|
||||||
mut challenge_q: Query<
|
mut challenge_q: Query<
|
||||||
@@ -189,6 +253,9 @@ fn update_hud(
|
|||||||
Without<HudMode>,
|
Without<HudMode>,
|
||||||
Without<HudUndos>,
|
Without<HudUndos>,
|
||||||
Without<HudAutoComplete>,
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
),
|
),
|
||||||
>,
|
>,
|
||||||
mut undos_q: Query<
|
mut undos_q: Query<
|
||||||
@@ -201,6 +268,9 @@ fn update_hud(
|
|||||||
Without<HudMode>,
|
Without<HudMode>,
|
||||||
Without<HudChallenge>,
|
Without<HudChallenge>,
|
||||||
Without<HudAutoComplete>,
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
),
|
),
|
||||||
>,
|
>,
|
||||||
mut auto_q: Query<
|
mut auto_q: Query<
|
||||||
@@ -213,6 +283,39 @@ fn update_hud(
|
|||||||
Without<HudMode>,
|
Without<HudMode>,
|
||||||
Without<HudChallenge>,
|
Without<HudChallenge>,
|
||||||
Without<HudUndos>,
|
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 ---
|
// --- Daily challenge constraint (with time-low colour warning) ---
|
||||||
if let Ok((mut t, _)) = challenge_q.get_single_mut() {
|
if let Ok((mut t, mut color)) = challenge_q.get_single_mut() {
|
||||||
**t = if g.is_won {
|
if g.is_won {
|
||||||
// Hide constraint once the game is over.
|
**t = String::new();
|
||||||
String::new()
|
|
||||||
} else if let Some(dc) = daily.as_deref() {
|
} 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 {
|
} else {
|
||||||
String::new()
|
**t = String::new();
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Undo count ---
|
// --- Undo count ---
|
||||||
@@ -269,10 +375,32 @@ fn update_hud(
|
|||||||
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
|
*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;
|
// 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()).
|
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||||
let is_zen = game.0.mode == GameMode::Zen;
|
let is_zen = game.0.mode == GameMode::Zen;
|
||||||
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||||
@@ -290,8 +418,10 @@ fn update_hud(
|
|||||||
**t = format!("{m}:{s:02}");
|
**t = format!("{m}:{s:02}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if is_zen && game.is_changed() {
|
} else if is_zen {
|
||||||
// Clear the time display when entering Zen mode.
|
// 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() {
|
if let Ok(mut t) = time_q.get_single_mut() {
|
||||||
**t = String::new();
|
**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
|
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||||
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
||||||
/// to debounce so the toast only appears on the leading edge.
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -488,6 +663,42 @@ mod tests {
|
|||||||
assert_eq!(challenge_hud_text(&dc), "");
|
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
|
// HudChallenge in-app tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -599,4 +810,49 @@ mod tests {
|
|||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
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:
|
//! Keyboard:
|
||||||
//! - `U` → `UndoRequestEvent`
|
//! - `U` → `UndoRequestEvent`
|
||||||
//! - `N` → `NewGameRequestEvent { seed: None }`
|
//! - `N` → `NewGameRequestEvent { seed: None }` (cancels Time Attack if active)
|
||||||
//! - `D` / `Space` → `DrawRequestEvent`
|
//! - `D` / `Space` → `DrawRequestEvent`
|
||||||
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
|
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
|
||||||
//!
|
//!
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::ecs::system::SystemParam;
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::math::{Vec2, Vec3};
|
use bevy::math::{Vec2, Vec3};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -28,6 +29,7 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::card_plugin::{CardEntity, HintHighlight, TABLEAU_FAN_FRAC};
|
use crate::card_plugin::{CardEntity, HintHighlight, TABLEAU_FAN_FRAC};
|
||||||
|
use crate::feedback_anim_plugin::ShakeAnim;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -38,7 +40,8 @@ use crate::game_plugin::GameMutation;
|
|||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
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.
|
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||||
const DRAG_Z: f32 = 500.0;
|
const DRAG_Z: f32 = 500.0;
|
||||||
@@ -54,7 +57,8 @@ pub struct InputPlugin;
|
|||||||
|
|
||||||
impl Plugin for InputPlugin {
|
impl Plugin for InputPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_event::<NewGameConfirmEvent>()
|
app.init_resource::<HintCycleIndex>()
|
||||||
|
.add_event::<NewGameConfirmEvent>()
|
||||||
.add_event::<InfoToastEvent>()
|
.add_event::<InfoToastEvent>()
|
||||||
.add_event::<ForfeitEvent>()
|
.add_event::<ForfeitEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -69,13 +73,29 @@ impl Plugin for InputPlugin {
|
|||||||
)
|
)
|
||||||
.chain(),
|
.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.
|
/// Seconds after the first N press during which a second N confirms new game.
|
||||||
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_keyboard(
|
fn handle_keyboard(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
@@ -84,15 +104,14 @@ fn handle_keyboard(
|
|||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut confirm_countdown: Local<f32>,
|
mut confirm_countdown: Local<f32>,
|
||||||
mut undo: EventWriter<UndoRequestEvent>,
|
mut confirm_pending: Local<bool>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut forfeit_countdown: Local<f32>,
|
||||||
mut confirm_event: EventWriter<NewGameConfirmEvent>,
|
mut ev: KeyboardEvents,
|
||||||
mut info_toast: EventWriter<InfoToastEvent>,
|
|
||||||
mut draw: EventWriter<DrawRequestEvent>,
|
|
||||||
mut forfeit: EventWriter<ForfeitEvent>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
|
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||||
|
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
@@ -102,102 +121,214 @@ fn handle_keyboard(
|
|||||||
*confirm_countdown -= time.delta_secs();
|
*confirm_countdown -= time.delta_secs();
|
||||||
if *confirm_countdown <= 0.0 {
|
if *confirm_countdown <= 0.0 {
|
||||||
*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) {
|
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 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 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);
|
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||||
if shift_held || !active_game {
|
if shift_held || !active_game {
|
||||||
// Shift+N or no active game — start immediately, no confirmation.
|
// Shift+N or no active game — start immediately, no confirmation.
|
||||||
new_game.send(NewGameRequestEvent::default());
|
ev.new_game.send(NewGameRequestEvent::default());
|
||||||
*confirm_countdown = 0.0;
|
*confirm_countdown = 0.0;
|
||||||
|
*confirm_pending = false;
|
||||||
} else if *confirm_countdown > 0.0 {
|
} else if *confirm_countdown > 0.0 {
|
||||||
// Second press within the window — confirmed.
|
// Second press within the window — confirmed.
|
||||||
new_game.send(NewGameRequestEvent::default());
|
ev.new_game.send(NewGameRequestEvent::default());
|
||||||
*confirm_countdown = 0.0;
|
*confirm_countdown = 0.0;
|
||||||
|
*confirm_pending = false;
|
||||||
} else {
|
} else {
|
||||||
// First press on an active game — require confirmation.
|
// First press on an active game — require confirmation.
|
||||||
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
|
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
|
||||||
confirm_event.send(NewGameConfirmEvent);
|
*confirm_pending = true;
|
||||||
|
ev.confirm_event.send(NewGameConfirmEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::KeyZ) {
|
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.
|
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
|
||||||
// X is gated separately by ChallengePlugin.
|
// X is gated separately by ChallengePlugin.
|
||||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||||
if level >= CHALLENGE_UNLOCK_LEVEL {
|
if level >= CHALLENGE_UNLOCK_LEVEL {
|
||||||
new_game.send(NewGameRequestEvent {
|
ev.new_game.send(NewGameRequestEvent {
|
||||||
seed: None,
|
seed: None,
|
||||||
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
info_toast.send(InfoToastEvent(format!(
|
ev.info_toast.send(InfoToastEvent(format!(
|
||||||
"Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
"Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) {
|
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 keys.just_pressed(KeyCode::KeyH) {
|
||||||
|
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
|
||||||
if let Some(ref g) = game {
|
if let Some(ref g) = game {
|
||||||
if !g.0.is_won {
|
if g.0.is_won {
|
||||||
if let Some(ref layout_res) = layout {
|
ev.info_toast.send(InfoToastEvent(
|
||||||
if let Some((from, _to, _count)) = find_hint(&g.0) {
|
"Game won! Press N for a new game".to_string(),
|
||||||
// Find the top face-up card in the source pile.
|
));
|
||||||
let top_card_id = g.0.piles.get(&from)
|
} else if let Some(ref layout_res) = layout {
|
||||||
.and_then(|p| p.cards.last().filter(|c| c.face_up))
|
let hints = all_hints(&g.0);
|
||||||
.map(|c| c.id);
|
if hints.is_empty() {
|
||||||
if let Some(card_id) = top_card_id {
|
ev.info_toast.send(InfoToastEvent("No hints available".to_string()));
|
||||||
for (entity, card_entity, _sprite) in card_entities.iter() {
|
} else {
|
||||||
if card_entity.card_id == card_id {
|
// Pick the hint at the current cycle index (wrapping) and advance.
|
||||||
commands.entity(entity)
|
let idx = hint_cycle.0 % hints.len();
|
||||||
.insert(HintHighlight { remaining: 1.5 })
|
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||||
.insert(Sprite {
|
let (from, to, _count) = &hints[idx];
|
||||||
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
|
// When the hint points at the stock (draw suggestion) there is no
|
||||||
custom_size: Some(layout_res.0.card_size),
|
// face-up card to highlight — show a toast instead.
|
||||||
..default()
|
// If the stock is empty, pressing D will recycle the waste rather
|
||||||
});
|
// than draw a card, so the toast text must reflect that.
|
||||||
break;
|
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 {
|
||||||
|
for (entity, card_entity, _sprite) in card_entities.iter() {
|
||||||
|
if card_entity.card_id == card_id {
|
||||||
|
commands.entity(entity)
|
||||||
|
.insert(HintHighlight { remaining: 1.5 })
|
||||||
|
.insert(Sprite {
|
||||||
|
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
|
||||||
|
custom_size: Some(layout_res.0.card_size),
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
info_toast.send(InfoToastEvent("No hints available".to_string()));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 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) {
|
if keys.just_pressed(KeyCode::KeyG) {
|
||||||
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
|
||||||
if active_game {
|
if active_game {
|
||||||
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).
|
// 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.
|
/// `F11` toggles between borderless-fullscreen and windowed mode.
|
||||||
/// Not gated by the pause flag — the player can always resize the window.
|
/// Not gated by the pause flag — the player can always resize the window.
|
||||||
fn handle_fullscreen(
|
fn handle_fullscreen(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||||
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::F11) {
|
if !keys.just_pressed(KeyCode::F11) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(mut window) = windows.get_single_mut() else { 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 => WindowMode::BorderlessFullscreen(MonitorSelection::Current),
|
||||||
_ => WindowMode::Windowed,
|
_ => 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(
|
fn handle_stock_click(
|
||||||
@@ -239,7 +370,7 @@ fn start_drag(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut drag: ResMut<DragState>,
|
mut drag: ResMut<DragState>,
|
||||||
mut card_transforms: Query<(&CardEntity, &mut Transform)>,
|
mut card_visuals: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
@@ -268,13 +399,15 @@ fn start_drag(
|
|||||||
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
|
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
|
||||||
let cursor_offset = bottom_pos - world;
|
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() {
|
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()
|
.iter_mut()
|
||||||
.find(|(entity, _)| entity.card_id == *id)
|
.find(|(entity, _, _)| entity.card_id == *id)
|
||||||
{
|
{
|
||||||
transform.translation.z = DRAG_Z + (i as f32) * 0.01;
|
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 moves: EventWriter<MoveRequestEvent>,
|
||||||
mut rejected: EventWriter<MoveRejectedEvent>,
|
mut rejected: EventWriter<MoveRejectedEvent>,
|
||||||
mut changed: EventWriter<StateChangedEvent>,
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
drag.clear();
|
drag.clear();
|
||||||
@@ -385,6 +520,19 @@ fn end_drag(
|
|||||||
to: target.clone(),
|
to: target.clone(),
|
||||||
count,
|
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
|
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`
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_double_click(
|
fn handle_double_click(
|
||||||
buttons: Res<ButtonInput<MouseButton>>,
|
buttons: Res<ButtonInput<MouseButton>>,
|
||||||
@@ -618,6 +801,7 @@ fn handle_double_click(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut last_click: Local<HashMap<u32, f32>>,
|
mut last_click: Local<HashMap<u32, f32>>,
|
||||||
mut moves: EventWriter<MoveRequestEvent>,
|
mut moves: EventWriter<MoveRequestEvent>,
|
||||||
|
mut rejected: EventWriter<MoveRejectedEvent>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
@@ -628,18 +812,17 @@ fn handle_double_click(
|
|||||||
let Some(layout) = layout else { return };
|
let Some(layout) = layout else { return };
|
||||||
let Some(world) = cursor_world(&windows, &cameras) 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 {
|
let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,14 +830,47 @@ fn handle_double_click(
|
|||||||
let prev = last_click.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
|
let prev = last_click.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
|
||||||
|
|
||||||
if now - prev <= DOUBLE_CLICK_WINDOW {
|
if now - prev <= DOUBLE_CLICK_WINDOW {
|
||||||
// Double-click detected — find and fire the best move.
|
// Double-click confirmed.
|
||||||
last_click.remove(&top_card_id);
|
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 {
|
moves.send(MoveRequestEvent {
|
||||||
from: pile,
|
from: pile,
|
||||||
to: dest,
|
to: dest,
|
||||||
count: 1,
|
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 {
|
} else {
|
||||||
// Single click — record the time.
|
// Single click — record the time.
|
||||||
@@ -666,12 +882,19 @@ fn handle_double_click(
|
|||||||
// Task #28 — Hint system helpers
|
// 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
|
/// Each entry is `(from, to, count)` — the same triple used by
|
||||||
/// no move is available. Sources checked: Waste top, then Tableau 0–6.
|
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
|
||||||
/// Destinations checked: all 4 Foundations, then all 7 Tableau piles.
|
/// (game is stuck).
|
||||||
pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
|
///
|
||||||
|
/// 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 sources: Vec<PileType> = {
|
||||||
let mut s = vec![PileType::Waste];
|
let mut s = vec![PileType::Waste];
|
||||||
for i in 0..7_usize {
|
for i in 0..7_usize {
|
||||||
@@ -680,23 +903,38 @@ pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
|
|||||||
s
|
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 {
|
for from in &sources {
|
||||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||||
|
|
||||||
// Check foundations.
|
|
||||||
for &suit in &suits {
|
for &suit in &suits {
|
||||||
let dest = PileType::Foundation(suit);
|
let dest = PileType::Foundation(suit);
|
||||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||||
if can_place_on_foundation(card, dest_pile, suit) {
|
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 {
|
for i in 0..7_usize {
|
||||||
let dest = PileType::Tableau(i);
|
let dest = PileType::Tableau(i);
|
||||||
if dest == *from {
|
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 let Some(dest_pile) = game.piles.get(&dest) {
|
||||||
if can_place_on_tableau(card, dest_pile) {
|
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)]
|
#[cfg(test)]
|
||||||
@@ -999,6 +1266,102 @@ mod tests {
|
|||||||
assert!(best_destination(&card, &game).is_none());
|
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
|
// Task #28 — find_hint pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1047,6 +1410,201 @@ mod tests {
|
|||||||
|
|
||||||
assert!(find_hint(&game).is_none(), "no hint should exist");
|
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
|
// `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_data::settings::SyncBackend;
|
||||||
use solitaire_sync::LeaderboardEntry;
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
|
use crate::events::InfoToastEvent;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
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.
|
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
||||||
fn poll_opt_in_task(mut task_res: ResMut<OptInTask>) {
|
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(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
task_res.0 = None;
|
task_res.0 = None;
|
||||||
if let Err(e) = result {
|
match result {
|
||||||
warn!("leaderboard opt-in failed: {e}");
|
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.
|
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
||||||
fn poll_opt_out_task(mut task_res: ResMut<OptOutTask>) {
|
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(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
task_res.0 = None;
|
task_res.0 = None;
|
||||||
if let Err(e) = result {
|
match result {
|
||||||
warn!("leaderboard opt-out failed: {e}");
|
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 events;
|
||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
pub mod help_plugin;
|
pub mod help_plugin;
|
||||||
|
pub mod home_plugin;
|
||||||
pub mod hud_plugin;
|
pub mod hud_plugin;
|
||||||
pub mod leaderboard_plugin;
|
pub mod leaderboard_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod onboarding_plugin;
|
pub mod onboarding_plugin;
|
||||||
pub mod pause_plugin;
|
pub mod pause_plugin;
|
||||||
|
pub mod profile_plugin;
|
||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
pub mod progress_plugin;
|
pub mod progress_plugin;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
@@ -40,7 +42,8 @@ pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, Progre
|
|||||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
||||||
pub use feedback_anim_plugin::{
|
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 auto_complete_plugin::AutoCompletePlugin;
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
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 game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
|
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||||
pub use hud_plugin::{HudAutoComplete, HudPlugin};
|
pub use hud_plugin::{HudAutoComplete, HudPlugin};
|
||||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||||
pub use input_plugin::InputPlugin;
|
pub use input_plugin::InputPlugin;
|
||||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||||
|
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||||
pub use settings_plugin::{
|
pub use settings_plugin::{
|
||||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
||||||
};
|
};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
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 selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
||||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||||
@@ -71,5 +76,5 @@ pub use time_attack_plugin::{
|
|||||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||||
};
|
};
|
||||||
pub use win_summary_plugin::{
|
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.
|
//! First-run onboarding banner.
|
||||||
//!
|
//!
|
||||||
//! On startup, if `Settings.first_run_complete` is `false`, spawn a centered
|
//! 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 —
|
//! mouse-button press dismisses it, sets the flag, and persists settings —
|
||||||
//! so returning players never see it again.
|
//! 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`
|
//! instructional text are rendered in a bright orange colour via `TextSpan`
|
||||||
//! children tagged with `KeyHighlightSpan`.
|
//! children tagged with `KeyHighlightSpan`.
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct OnboardingScreen;
|
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
|
/// onboarding banner. Colour distinct from body text; usable by tests and any
|
||||||
/// future flash-animation system.
|
/// future flash-animation system.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -112,7 +112,7 @@ fn spawn_onboarding_screen(commands: &mut Commands) {
|
|||||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
||||||
|
|
||||||
// Instruction line: "Drag cards between piles. Press D to draw, U to undo."
|
// 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((
|
b.spawn((
|
||||||
Text::new("Drag cards between piles. Press "),
|
Text::new("Drag cards between piles. Press "),
|
||||||
TextFont { font_size: 22.0, ..default() },
|
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)));
|
t.spawn((TextSpan::new(" to undo."), TextColor(BODY_COLOR)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Help line: "Press H or ? at any time to see the full controls."
|
// Help line: "Press F1 at any time to see the full controls."
|
||||||
// H rendered as a KeyHighlightSpan child with KEY_COLOR.
|
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new("Press "),
|
Text::new("Press F1 at any time to see the full controls."),
|
||||||
TextFont { font_size: 22.0, ..default() },
|
TextFont { font_size: 22.0, ..default() },
|
||||||
TextColor(BODY_COLOR),
|
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
|
// Spacer
|
||||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
||||||
@@ -237,9 +225,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn banner_has_two_key_highlight_spans() {
|
fn banner_has_key_highlight_span_for_d() {
|
||||||
// D and H must be tagged KeyHighlightSpan so their colour is distinct
|
// D must be tagged KeyHighlightSpan so its colour is distinct from body
|
||||||
// from body text and future flash-animation systems can target them.
|
// text and future flash-animation systems can target it.
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.update();
|
app.update();
|
||||||
let count = app
|
let count = app
|
||||||
@@ -247,7 +235,7 @@ mod tests {
|
|||||||
.query::<&KeyHighlightSpan>()
|
.query::<&KeyHighlightSpan>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(count, 2, "expected KeyHighlightSpan for D and H");
|
assert_eq!(count, 1, "expected KeyHighlightSpan for D");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,14 +9,20 @@
|
|||||||
//! input (drag, keyboard hotkeys) is **not** blocked — pause is purely a
|
//! input (drag, keyboard hotkeys) is **not** blocked — pause is purely a
|
||||||
//! "stop the clock" screen for now. A future polish slice can layer
|
//! "stop the clock" screen for now. A future polish slice can layer
|
||||||
//! input-blocking on top if desired.
|
//! 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 bevy::prelude::*;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::save_game_state_to;
|
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::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||||
use crate::stats_plugin::StatsResource;
|
use crate::stats_plugin::StatsResource;
|
||||||
|
|
||||||
@@ -46,9 +52,10 @@ pub struct PausePlugin;
|
|||||||
|
|
||||||
impl Plugin for PausePlugin {
|
impl Plugin for PausePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// SettingsChangedEvent may already be registered by SettingsPlugin;
|
// Both add_event calls are idempotent — other plugins may register these
|
||||||
// add_event is idempotent so this is safe in either order.
|
// events first, but calling add_event again is always safe.
|
||||||
app.add_event::<SettingsChangedEvent>()
|
app.add_event::<SettingsChangedEvent>()
|
||||||
|
.add_event::<StateChangedEvent>()
|
||||||
.init_resource::<PausedResource>()
|
.init_resource::<PausedResource>()
|
||||||
.add_systems(Update, (toggle_pause, handle_pause_draw_toggle));
|
.add_systems(Update, (toggle_pause, handle_pause_draw_toggle));
|
||||||
}
|
}
|
||||||
@@ -60,15 +67,33 @@ fn toggle_pause(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut paused: ResMut<PausedResource>,
|
mut paused: ResMut<PausedResource>,
|
||||||
screens: Query<Entity, With<PauseScreen>>,
|
screens: Query<Entity, With<PauseScreen>>,
|
||||||
|
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
stats: Option<Res<StatsResource>>,
|
stats: Option<Res<StatsResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut drag: Option<ResMut<DragState>>,
|
||||||
|
mut changed: EventWriter<StateChangedEvent>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::Escape) {
|
if !keys.just_pressed(KeyCode::Escape) {
|
||||||
return;
|
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() {
|
if let Ok(entity) = screens.get_single() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn_recursive();
|
||||||
paused.0 = false;
|
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`.
|
/// Bevy resource wrapping the current `SyncStatus`.
|
||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct SyncStatusResource(pub SyncStatus);
|
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 `Tab` cycles through piles that have a face-up draggable top card.
|
||||||
//! Pressing `Enter` or `Space` fires a [`MoveRequestEvent`] to the best
|
//! Pressing `Enter` or `Space` fires a [`MoveRequestEvent`] to the best
|
||||||
//! available destination (foundation first, then tableau), then clears the
|
//! available destination using the following priority order, then clears the
|
||||||
//! selection. Pressing `Escape` clears the selection without moving.
|
//! 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
|
//! The selected card is highlighted by a cyan [`SelectionHighlight`] outline
|
||||||
//! sprite parented to the selected card entity. The highlight is despawned when
|
//! 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 solitaire_core::pile::PileType;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::MoveRequestEvent;
|
use crate::events::{InfoToastEvent, MoveRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
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::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -115,17 +122,48 @@ pub fn cycle_next_pile(
|
|||||||
None
|
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
|
// Systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Handles Tab / Enter / Space / Escape for keyboard card selection.
|
/// Handles Tab / Enter / Space / Escape for keyboard card selection.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_selection_keys(
|
fn handle_selection_keys(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut selection: ResMut<SelectionState>,
|
mut selection: ResMut<SelectionState>,
|
||||||
mut moves: EventWriter<MoveRequestEvent>,
|
mut moves: EventWriter<MoveRequestEvent>,
|
||||||
|
mut info_toast: EventWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
@@ -160,8 +198,15 @@ fn handle_selection_keys(
|
|||||||
|
|
||||||
// Tab — cycle selection.
|
// Tab — cycle selection.
|
||||||
if keys.just_pressed(KeyCode::Tab) {
|
if keys.just_pressed(KeyCode::Tab) {
|
||||||
selection.selected_pile =
|
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +216,12 @@ fn handle_selection_keys(
|
|||||||
return;
|
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 =
|
let activate =
|
||||||
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
|
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
|
||||||
if activate {
|
if activate {
|
||||||
@@ -183,6 +233,46 @@ fn handle_selection_keys(
|
|||||||
.and_then(|p| p.cards.last())
|
.and_then(|p| p.cards.last())
|
||||||
.filter(|c| c.face_up)
|
.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) {
|
if let Some(dest) = best_destination(card, &game.0) {
|
||||||
moves.send(MoveRequestEvent {
|
moves.send(MoveRequestEvent {
|
||||||
from: pile.clone(),
|
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.
|
/// Maintains the `SelectionHighlight` outline sprite.
|
||||||
///
|
///
|
||||||
/// When a pile is selected, a cyan sprite is placed at the selected card's
|
/// 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());
|
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]
|
#[test]
|
||||||
fn cycle_next_pile_single_element_wraps_to_itself() {
|
fn cycle_next_pile_single_element_wraps_to_itself() {
|
||||||
let available = vec![PileType::Waste];
|
let available = vec![PileType::Waste];
|
||||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||||
assert_eq!(result, 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::events::ManualSyncRequestEvent;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
|
||||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||||
pub const SFX_STEP: f32 = 0.1;
|
pub const SFX_STEP: f32 = 0.1;
|
||||||
@@ -83,6 +83,10 @@ struct ColorBlindText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanelScrollable;
|
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.
|
/// Tags interactive buttons inside the Settings panel.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
enum SettingsButton {
|
enum SettingsButton {
|
||||||
@@ -139,6 +143,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
app.insert_resource(SettingsResource(loaded))
|
app.insert_resource(SettingsResource(loaded))
|
||||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
.init_resource::<SettingsScreen>()
|
.init_resource::<SettingsScreen>()
|
||||||
|
.init_resource::<SettingsScrollPos>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_event::<ManualSyncRequestEvent>()
|
.add_event::<ManualSyncRequestEvent>()
|
||||||
.add_event::<bevy::input::mouse::MouseWheel>()
|
.add_event::<bevy::input::mouse::MouseWheel>()
|
||||||
@@ -213,9 +218,12 @@ fn toggle_settings_screen(
|
|||||||
|
|
||||||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||||
/// despawns it when it becomes `false`.
|
/// despawns it when it becomes `false`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn sync_settings_panel_visibility(
|
fn sync_settings_panel_visibility(
|
||||||
screen: Res<SettingsScreen>,
|
screen: Res<SettingsScreen>,
|
||||||
panels: Query<Entity, With<SettingsPanel>>,
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
|
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||||
|
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
settings: Res<SettingsResource>,
|
settings: Res<SettingsResource>,
|
||||||
sync_status: Option<Res<SyncStatusResource>>,
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
@@ -243,9 +251,14 @@ fn sync_settings_panel_visibility(
|
|||||||
&status_label,
|
&status_label,
|
||||||
unlocked_backs,
|
unlocked_backs,
|
||||||
unlocked_bgs,
|
unlocked_bgs,
|
||||||
|
scroll_pos.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
for entity in &panels {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn_recursive();
|
||||||
}
|
}
|
||||||
@@ -557,6 +570,7 @@ fn spawn_settings_panel(
|
|||||||
sync_status: &str,
|
sync_status: &str,
|
||||||
unlocked_card_backs: &[usize],
|
unlocked_card_backs: &[usize],
|
||||||
unlocked_backgrounds: &[usize],
|
unlocked_backgrounds: &[usize],
|
||||||
|
scroll_offset: f32,
|
||||||
) {
|
) {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -580,7 +594,8 @@ fn spawn_settings_panel(
|
|||||||
// on small windows by scrolling with the mouse wheel.
|
// on small windows by scrolling with the mouse wheel.
|
||||||
root.spawn((
|
root.spawn((
|
||||||
SettingsPanelScrollable,
|
SettingsPanelScrollable,
|
||||||
ScrollPosition::default(),
|
SettingsScrollNode,
|
||||||
|
ScrollPosition { offset_y: scroll_offset, ..default() },
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
padding: UiRect::all(Val::Px(28.0)),
|
padding: UiRect::all(Val::Px(28.0)),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use solitaire_data::{
|
|||||||
WEEKLY_GOALS,
|
WEEKLY_GOALS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::challenge_plugin::challenge_progress_label;
|
use crate::challenge_plugin::challenge_progress_label;
|
||||||
use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent};
|
use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -128,17 +129,25 @@ fn update_stats_on_new_game(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
mut stats: ResMut<StatsResource>,
|
mut stats: ResMut<StatsResource>,
|
||||||
path: Res<StatsStoragePath>,
|
path: Res<StatsStoragePath>,
|
||||||
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in events.read() {
|
for _ in events.read() {
|
||||||
if game.0.move_count > 0 && !game.0.is_won {
|
if game.0.move_count > 0 && !game.0.is_won {
|
||||||
|
let streak = stats.0.win_streak_current;
|
||||||
stats.0.record_abandoned();
|
stats.0.record_abandoned();
|
||||||
persist(&path, &stats.0, "abandoned game");
|
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
|
/// When the player presses G to forfeit, record the game as abandoned, save
|
||||||
/// stats, fire an informational toast, and start a new game.
|
/// 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(
|
fn handle_forfeit(
|
||||||
mut events: EventReader<ForfeitEvent>,
|
mut events: EventReader<ForfeitEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -146,11 +155,21 @@ fn handle_forfeit(
|
|||||||
path: Res<StatsStoragePath>,
|
path: Res<StatsStoragePath>,
|
||||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||||
mut toast: EventWriter<InfoToastEvent>,
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
|
mut auto_complete: Option<ResMut<AutoCompleteState>>,
|
||||||
) {
|
) {
|
||||||
for _ in events.read() {
|
for _ in events.read() {
|
||||||
if game.0.move_count > 0 && !game.0.is_won {
|
if game.0.move_count > 0 && !game.0.is_won {
|
||||||
|
let streak = stats.0.win_streak_current;
|
||||||
stats.0.record_abandoned();
|
stats.0.record_abandoned();
|
||||||
persist(&path, &stats.0, "forfeit");
|
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()));
|
toast.send(InfoToastEvent("Game forfeited".to_string()));
|
||||||
new_game.send(NewGameRequestEvent::default());
|
new_game.send(NewGameRequestEvent::default());
|
||||||
@@ -186,12 +205,13 @@ fn spawn_stats_screen(
|
|||||||
progress: Option<&PlayerProgress>,
|
progress: Option<&PlayerProgress>,
|
||||||
time_attack: Option<&TimeAttackResource>,
|
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 win_rate_str = format_win_rate(stats);
|
||||||
let played_str = format_stat_value(stats.games_played);
|
let played_str = format_stat_value(stats.games_played);
|
||||||
let won_str = format_stat_value(stats.games_won);
|
let won_str = format_stat_value(stats.games_won);
|
||||||
let lost_str = format_stat_value(stats.games_lost);
|
let lost_str = format_stat_value(stats.games_lost);
|
||||||
let fastest_str = format_fastest_win(stats.fastest_win_seconds);
|
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_score_str = format_optional_u32(stats.best_single_score);
|
||||||
let best_streak_str = format_stat_value(stats.win_streak_best);
|
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, &won_str, "Games Won");
|
||||||
spawn_stat_cell(grid, &lost_str, "Games Lost");
|
spawn_stat_cell(grid, &lost_str, "Games Lost");
|
||||||
spawn_stat_cell(grid, &fastest_str, "Fastest Win");
|
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_score_str, "Best Score");
|
||||||
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
|
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.
|
/// Format an optional `u32` statistic.
|
||||||
///
|
///
|
||||||
/// Returns `"—"` when `value` is `0`, otherwise the decimal representation.
|
/// 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!("{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 {
|
pub fn format_duration(secs: u64) -> String {
|
||||||
let m = secs / 60;
|
let m = secs / 60;
|
||||||
let s = 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.
|
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
|
||||||
@@ -630,22 +663,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_duration_zero_seconds() {
|
fn format_duration_zero_seconds() {
|
||||||
assert_eq!(format_duration(0), "0m 00s");
|
assert_eq!(format_duration(0), "0:00");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_duration_pads_seconds_to_two_digits() {
|
fn format_duration_pads_seconds_to_two_digits() {
|
||||||
assert_eq!(format_duration(65), "1m 05s");
|
assert_eq!(format_duration(65), "1:05");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_duration_exactly_one_hour() {
|
fn format_duration_exactly_one_hour() {
|
||||||
assert_eq!(format_duration(3600), "60m 00s");
|
assert_eq!(format_duration(3600), "60:00");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_duration_handles_sub_minute() {
|
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]
|
#[test]
|
||||||
fn format_fastest_win_90s() {
|
fn format_fastest_win_90s() {
|
||||||
// 90 seconds → "1m 30s"
|
// 90 seconds → "1:30"
|
||||||
assert_eq!(format_fastest_win(90), "1m 30s");
|
assert_eq!(format_fastest_win(90), "1:30");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -696,4 +729,100 @@ mod tests {
|
|||||||
// best_single_score == 0 → "—"
|
// best_single_score == 0 → "—"
|
||||||
assert_eq!(format_optional_u32(0), "\u{2014}");
|
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.
|
//! shake duration elapses.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
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::game_plugin::GameMutation;
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -44,6 +51,47 @@ pub struct WinSummaryPending {
|
|||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
/// XP awarded from the most recent `XpAwardedEvent` (0 until that event fires).
|
/// XP awarded from the most recent `XpAwardedEvent` (0 until that event fires).
|
||||||
pub xp: u64,
|
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.
|
/// Drives the camera shake effect after a win.
|
||||||
@@ -59,6 +107,32 @@ pub struct ScreenShakeResource {
|
|||||||
pub intensity: f32,
|
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
|
// Components
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -84,13 +158,24 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<WinSummaryPending>()
|
app.init_resource::<WinSummaryPending>()
|
||||||
.init_resource::<ScreenShakeResource>()
|
.init_resource::<ScreenShakeResource>()
|
||||||
|
.init_resource::<SessionAchievements>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<XpAwardedEvent>()
|
.add_event::<XpAwardedEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.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(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
cache_win_data,
|
collect_session_achievements,
|
||||||
spawn_win_summary_after_delay,
|
spawn_win_summary_after_delay,
|
||||||
handle_win_summary_buttons,
|
handle_win_summary_buttons,
|
||||||
apply_screen_shake,
|
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
|
/// Caches score/time from `GameWonEvent` and XP from `XpAwardedEvent` into
|
||||||
/// `WinSummaryPending` so they are available when the modal spawns.
|
/// `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(
|
fn cache_win_data(
|
||||||
mut won: EventReader<GameWonEvent>,
|
mut won: EventReader<GameWonEvent>,
|
||||||
mut xp: EventReader<XpAwardedEvent>,
|
mut xp: EventReader<XpAwardedEvent>,
|
||||||
mut pending: ResMut<WinSummaryPending>,
|
mut pending: ResMut<WinSummaryPending>,
|
||||||
|
stats: Res<StatsResource>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
progress: Res<ProgressResource>,
|
||||||
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
for ev in won.read() {
|
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.score = ev.score;
|
||||||
pending.time_seconds = ev.time_seconds;
|
pending.time_seconds = ev.time_seconds;
|
||||||
pending.xp = 0; // reset; XP event follows
|
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() {
|
for ev in xp.read() {
|
||||||
pending.xp = ev.amount;
|
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.
|
/// After `GameWonEvent`, arms the screen-shake resource.
|
||||||
///
|
///
|
||||||
/// This system shares the `GameWonEvent` stream with `cache_win_data` through
|
/// This system shares the `GameWonEvent` stream with `cache_win_data` through
|
||||||
/// the delay timer stored in `Local` — the shake fires immediately, while the
|
/// the delay timer stored in `Local` — the shake fires immediately, while the
|
||||||
/// modal waits 0.5 s.
|
/// 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(
|
fn spawn_win_summary_after_delay(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut won: EventReader<GameWonEvent>,
|
mut won: EventReader<GameWonEvent>,
|
||||||
|
mut xp_events: EventReader<XpAwardedEvent>,
|
||||||
mut shake: ResMut<ScreenShakeResource>,
|
mut shake: ResMut<ScreenShakeResource>,
|
||||||
pending: Res<WinSummaryPending>,
|
mut pending: ResMut<WinSummaryPending>,
|
||||||
|
session: Res<SessionAchievements>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
mut delay: Local<Option<f32>>,
|
mut delay: Local<Option<f32>>,
|
||||||
@@ -173,7 +332,15 @@ fn spawn_win_summary_after_delay(
|
|||||||
*delay = None;
|
*delay = None;
|
||||||
// Only spawn if there is no overlay already.
|
// Only spawn if there is no overlay already.
|
||||||
if overlays.is_empty() {
|
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
|
// 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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
WinSummaryOverlay,
|
WinSummaryOverlay,
|
||||||
@@ -279,6 +455,25 @@ fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
|||||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
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
|
// Score
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("Score: {}", pending.score)),
|
Text::new(format!("Score: {}", pending.score)),
|
||||||
@@ -293,13 +488,28 @@ fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
|||||||
TextColor(Color::WHITE),
|
TextColor(Color::WHITE),
|
||||||
));
|
));
|
||||||
|
|
||||||
// XP
|
// XP total
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("XP earned: +{}", pending.xp)),
|
Text::new(format!("XP earned: +{}", pending.xp)),
|
||||||
TextFont { font_size: 22.0, ..default() },
|
TextFont { font_size: 22.0, ..default() },
|
||||||
TextColor(Color::srgb(0.4, 1.0, 0.4)),
|
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
|
// Play Again button
|
||||||
card.spawn((
|
card.spawn((
|
||||||
WinSummaryButton::PlayAgain,
|
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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -331,6 +576,22 @@ fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn format_win_time_zero() {
|
fn format_win_time_zero() {
|
||||||
@@ -370,24 +631,129 @@ mod tests {
|
|||||||
assert_eq!(p.score, 0);
|
assert_eq!(p.score, 0);
|
||||||
assert_eq!(p.time_seconds, 0);
|
assert_eq!(p.time_seconds, 0);
|
||||||
assert_eq!(p.xp, 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]
|
#[test]
|
||||||
fn win_summary_plugin_inserts_resources() {
|
fn win_summary_plugin_inserts_resources() {
|
||||||
let mut app = App::new();
|
let app = make_app();
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(WinSummaryPlugin);
|
|
||||||
app.update();
|
|
||||||
assert!(app.world().get_resource::<WinSummaryPending>().is_some());
|
assert!(app.world().get_resource::<WinSummaryPending>().is_some());
|
||||||
assert!(app.world().get_resource::<ScreenShakeResource>().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]
|
#[test]
|
||||||
fn cache_win_data_sets_score_and_time() {
|
fn cache_win_data_sets_score_and_time() {
|
||||||
let mut app = App::new();
|
let mut app = make_app();
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(WinSummaryPlugin);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
|
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
|
||||||
@@ -396,14 +762,14 @@ mod tests {
|
|||||||
let pending = app.world().resource::<WinSummaryPending>();
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
assert_eq!(pending.score, 1234);
|
assert_eq!(pending.score, 1234);
|
||||||
assert_eq!(pending.time_seconds, 90);
|
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]
|
#[test]
|
||||||
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
||||||
let mut app = App::new();
|
let mut app = make_app();
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(WinSummaryPlugin);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
|
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
|
||||||
@@ -415,10 +781,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_won_event_arms_screen_shake() {
|
fn game_won_event_arms_screen_shake() {
|
||||||
let mut app = App::new();
|
let mut app = make_app();
|
||||||
app.add_plugins(MinimalPlugins)
|
|
||||||
.add_plugins(WinSummaryPlugin);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
@@ -427,4 +790,126 @@ mod tests {
|
|||||||
let shake = app.world().resource::<ScreenShakeResource>();
|
let shake = app.world().resource::<ScreenShakeResource>();
|
||||||
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
|
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