Compare commits
8 Commits
fe23e89971
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 03227f8c77 | |||
| d387ee68d7 | |||
| 1c6094dc93 | |||
| f32e53dd0b | |||
| ddd7502a06 | |||
| c3ee7c45a7 | |||
| 4d132afdc2 | |||
| eee220fbf0 |
+36
-11
@@ -256,16 +256,35 @@ Done
|
||||
|
||||
### Bevy Plugins
|
||||
|
||||
| Plugin | Responsibility |
|
||||
|---|---|
|
||||
| `CardPlugin` | Card entity spawning, sprite management, drag-and-drop |
|
||||
| `TablePlugin` | Pile markers, background, layout calculation |
|
||||
| `AnimationPlugin` | Slide, flip, win cascade, toast animations |
|
||||
| `AudioPlugin` | Sound effect and music playback via bevy_kira_audio |
|
||||
| `UIPlugin` | All Bevy UI screens: Home, Stats, Achievements, Settings, Profile |
|
||||
| `AchievementPlugin` | Listens for game events, evaluates unlock conditions, fires toasts |
|
||||
| `SyncPlugin` | Manages sync lifecycle (pull on start, push on exit, status display) |
|
||||
| `GamePlugin` | Core game state resource, input routing, win detection |
|
||||
| Plugin | Key | Responsibility |
|
||||
|---|---|---|
|
||||
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
||||
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
|
||||
| `InputPlugin` | — | Keyboard and mouse input routing |
|
||||
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
||||
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
||||
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
|
||||
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge |
|
||||
| `StatsPlugin` | S | Stats overlay and persistence |
|
||||
| `ProgressPlugin` | — | XP/level system, persistence |
|
||||
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
||||
| `DailyChallengePlugin` | — | Daily challenge resource and completion tracking |
|
||||
| `WeeklyGoalsPlugin` | — | Weekly goal progress and completion events |
|
||||
| `ChallengePlugin` | — | Challenge mode progression (seeded hard deals) |
|
||||
| `TimeAttackPlugin` | — | 10-minute time-attack mode timer |
|
||||
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
||||
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
||||
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
||||
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
||||
| `HelpPlugin` | H | Help / controls overlay |
|
||||
| `PausePlugin` | Esc | Pause and resume |
|
||||
| `OnboardingPlugin` | — | First-run welcome screen |
|
||||
| `SyncPlugin` | — | Async sync lifecycle (pull on start, push on exit, status display) |
|
||||
| `WinSummaryPlugin` | — | Win cascade overlay and screen-shake effect |
|
||||
|
||||
### Key Bevy Resources
|
||||
|
||||
@@ -588,6 +607,9 @@ pub enum PileType {
|
||||
|
||||
pub enum DrawMode { DrawOne, DrawThree }
|
||||
|
||||
/// Active game mode. Classic is the default; others unlock at level 5.
|
||||
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
||||
|
||||
pub enum MoveError {
|
||||
InvalidSource,
|
||||
InvalidDestination,
|
||||
@@ -600,13 +622,16 @@ pub enum MoveError {
|
||||
pub struct GameState {
|
||||
pub piles: HashMap<PileType, Vec<Card>>,
|
||||
pub draw_mode: DrawMode,
|
||||
pub mode: GameMode,
|
||||
pub score: i32,
|
||||
pub move_count: u32,
|
||||
pub undo_count: u32, // number of undos used in this game
|
||||
pub recycle_count: u32, // number of stock recycles
|
||||
pub elapsed_seconds: u64,
|
||||
pub seed: u64,
|
||||
pub is_won: bool,
|
||||
pub is_auto_completable: bool,
|
||||
undo_stack: Vec<StateSnapshot>, // private, max 64
|
||||
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ use bevy::prelude::*;
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin,
|
||||
ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, HudPlugin, InputPlugin,
|
||||
LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin,
|
||||
SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||
ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, GamePlugin,
|
||||
HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin,
|
||||
PausePlugin, ProfilePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
|
||||
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -29,8 +30,10 @@ fn main() {
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
@@ -41,6 +44,8 @@ fn main() {
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin)
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
|
||||
@@ -577,4 +577,46 @@ mod tests {
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(!ids.contains(&"speed_demon"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_achievements_returns_multiple_when_conditions_met() {
|
||||
// A context where first_win, on_a_roll, and no_undo all trigger at once.
|
||||
let mut c = ctx();
|
||||
c.games_won = 1;
|
||||
c.win_streak_current = 3;
|
||||
c.last_win_used_undo = false;
|
||||
c.last_win_time_seconds = 999;
|
||||
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"first_win"), "first_win should unlock");
|
||||
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock");
|
||||
assert!(ids.contains(&"no_undo"), "no_undo should unlock");
|
||||
assert!(ids.len() >= 3, "at least 3 achievements must fire simultaneously");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectionist_implies_no_undo_both_fire_together() {
|
||||
// perfectionist requires !used_undo && score >= 5000, which is a strict
|
||||
// superset of no_undo's condition. Both must appear in the result.
|
||||
let mut c = ctx();
|
||||
c.games_won = 1;
|
||||
c.last_win_used_undo = false;
|
||||
c.last_win_score = 5_000;
|
||||
c.last_win_time_seconds = 999;
|
||||
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"perfectionist"), "perfectionist must unlock");
|
||||
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectionist_score_well_above_threshold_still_passes() {
|
||||
let mut c = ctx();
|
||||
c.games_won = 1;
|
||||
c.last_win_used_undo = false;
|
||||
c.last_win_score = 50_000;
|
||||
|
||||
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||
assert!(ids.contains(&"perfectionist"), "score far above threshold must pass");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +474,63 @@ mod tests {
|
||||
assert_eq!(g.piles[&PileType::Stock].cards.len(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_partial_draw_when_fewer_than_three_remain() {
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
// Replace the stock with exactly 2 cards so the draw is a partial batch.
|
||||
let two_cards: Vec<Card> = g.piles[&PileType::Stock].cards[..2].to_vec();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards = two_cards;
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
g.draw().unwrap();
|
||||
|
||||
assert_eq!(g.piles[&PileType::Waste].cards.len(), 2, "only 2 cards should move when stock has 2");
|
||||
assert!(g.piles[&PileType::Stock].cards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_all_drawn_cards_are_face_up() {
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
g.draw().unwrap();
|
||||
assert!(
|
||||
g.piles[&PileType::Waste].cards.iter().all(|c| c.face_up),
|
||||
"all drawn cards must be face-up in waste"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_undo_returns_all_cards_to_stock() {
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
let stock_before = g.piles[&PileType::Stock].cards.len();
|
||||
|
||||
g.draw().unwrap();
|
||||
assert_eq!(g.piles[&PileType::Waste].cards.len(), 3);
|
||||
|
||||
g.undo().unwrap();
|
||||
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before);
|
||||
assert!(g.piles[&PileType::Waste].cards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_recycle_restores_waste_to_stock_face_down() {
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
// Drain all 24 stock cards into waste via repeated draws.
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||||
g.draw().unwrap();
|
||||
}
|
||||
let waste_count = g.piles[&PileType::Waste].cards.len();
|
||||
assert!(waste_count > 0);
|
||||
|
||||
// Recycle: drawing when stock is empty returns all waste cards to stock.
|
||||
g.draw().unwrap();
|
||||
assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count);
|
||||
assert!(g.piles[&PileType::Waste].cards.is_empty());
|
||||
assert!(
|
||||
g.piles[&PileType::Stock].cards.iter().all(|c| !c.face_up),
|
||||
"recycled cards must be face-down"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_from_empty_stock_recycles_waste() {
|
||||
let mut g = new_game();
|
||||
@@ -691,6 +748,43 @@ mod tests {
|
||||
assert_eq!(g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX");
|
||||
}
|
||||
|
||||
// --- Fields excluded from undo snapshot ---
|
||||
|
||||
#[test]
|
||||
fn undo_does_not_roll_back_elapsed_seconds() {
|
||||
// elapsed_seconds tracks wall time and must be monotonic; undo must never
|
||||
// reduce it, otherwise the time-bonus calculation would be gamed.
|
||||
let mut g = new_game();
|
||||
g.elapsed_seconds = 120;
|
||||
g.draw().unwrap();
|
||||
g.undo().unwrap();
|
||||
assert_eq!(g.elapsed_seconds, 120, "undo must leave elapsed_seconds unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_does_not_roll_back_recycle_count() {
|
||||
// recycle_count is a lifetime counter used for the 'comeback' achievement;
|
||||
// rolling it back on undo would make the condition unachievable after recycling.
|
||||
let mut g = new_game();
|
||||
// Drain stock and recycle to increment recycle_count.
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||||
g.draw().unwrap();
|
||||
}
|
||||
g.draw().unwrap(); // recycle
|
||||
assert_eq!(g.recycle_count, 1);
|
||||
// Now draw one more card and undo it.
|
||||
g.draw().unwrap();
|
||||
g.undo().unwrap();
|
||||
assert_eq!(g.recycle_count, 1, "undo must leave recycle_count unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_after_win_returns_game_already_won() {
|
||||
let mut g = new_game();
|
||||
g.is_won = true;
|
||||
assert_eq!(g.undo(), Err(MoveError::GameAlreadyWon));
|
||||
}
|
||||
|
||||
// --- Scoring ---
|
||||
|
||||
#[test]
|
||||
@@ -753,6 +847,37 @@ mod tests {
|
||||
// fact that move_cards' score path is identical to Classic.
|
||||
}
|
||||
|
||||
// --- GameMode: TimeAttack ---
|
||||
|
||||
#[test]
|
||||
fn time_attack_mode_field_persists() {
|
||||
let g = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
assert_eq!(g.mode, GameMode::TimeAttack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_allows_undo() {
|
||||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
g.draw().unwrap();
|
||||
// TimeAttack does not disable undo — only Challenge does.
|
||||
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_score_starts_at_zero() {
|
||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_attack_draw_three_combination() {
|
||||
// TimeAttack + DrawThree is a valid combination; verify construction.
|
||||
let g = GameState::new_with_mode(7, DrawMode::DrawThree, GameMode::TimeAttack);
|
||||
assert_eq!(g.mode, GameMode::TimeAttack);
|
||||
assert_eq!(g.draw_mode, DrawMode::DrawThree);
|
||||
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
|
||||
}
|
||||
|
||||
// --- Auto-complete ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,7 +21,8 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 13,
|
||||
Some(top) => {
|
||||
card.rank.value() + 1 == top.rank.value()
|
||||
top.face_up
|
||||
&& card.rank.value() + 1 == top.rank.value()
|
||||
&& card.suit.is_red() != top.suit.is_red()
|
||||
}
|
||||
}
|
||||
@@ -152,4 +153,14 @@ mod tests {
|
||||
let p = pile_with(PileType::Tableau(0), vec![card(Suit::Spades, Rank::Nine)]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tableau_face_down_destination_top_is_invalid() {
|
||||
// A face-down top card must never be a valid placement target.
|
||||
let c = card(Suit::Hearts, Rank::Nine);
|
||||
let mut top = card(Suit::Spades, Rank::Ten);
|
||||
top.face_up = false;
|
||||
let p = pile_with(PileType::Tableau(0), vec![top]);
|
||||
assert!(!can_place_on_tableau(&c, &p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ pub struct Settings {
|
||||
/// Set to `true` once the player has dismissed the first-run banner.
|
||||
#[serde(default)]
|
||||
pub first_run_complete: bool,
|
||||
/// When `true`, red-suit card faces use a blue tint instead of the default
|
||||
/// cream so they are distinguishable from black-suit cards without relying
|
||||
/// solely on colour.
|
||||
#[serde(default)]
|
||||
pub color_blind_mode: bool,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -121,6 +126,7 @@ impl Default for Settings {
|
||||
selected_card_back: 0,
|
||||
selected_background: 0,
|
||||
first_run_complete: false,
|
||||
color_blind_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,12 +283,30 @@ mod tests {
|
||||
selected_card_back: 0,
|
||||
selected_background: 0,
|
||||
first_run_complete: true,
|
||||
color_blind_mode: false,
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_non_default_cosmetic_selections() {
|
||||
// selected_card_back and selected_background must survive save→load with
|
||||
// non-zero values — zero is the default and not a meaningful regression check.
|
||||
let path = tmp_path("cosmetic_selections");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_card_back: 3,
|
||||
selected_background: 2,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_card_back, 3);
|
||||
assert_eq!(loaded.selected_background, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
@@ -318,5 +342,76 @@ mod tests {
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
|
||||
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
|
||||
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blind_mode_defaults_to_false_when_field_absent() {
|
||||
// Simulate a JSON file that has no color_blind_mode field.
|
||||
let json = br#"{ "sfx_volume": 0.7 }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blind_mode_round_trips() {
|
||||
let path = tmp_path("color_blind");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
color_blind_mode: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #62 — selected_card_back
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_card_back_default_is_zero() {
|
||||
assert_eq!(Settings::default().selected_card_back, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_card_back_serializes_round_trip() {
|
||||
let path = tmp_path("card_back_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_card_back: 2,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #63 — selected_background
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_background_default_is_zero() {
|
||||
assert_eq!(Settings::default().selected_background, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_background_serializes_round_trip() {
|
||||
let path = tmp_path("background_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
selected_background: 3,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,4 +152,30 @@ mod tests {
|
||||
assert_eq!(s.draw_one_wins, 1);
|
||||
assert_eq!(s.draw_three_wins, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_streak_best_never_decreases_after_shorter_subsequent_streak() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
// Build a streak of 5.
|
||||
for _ in 0..5 {
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
}
|
||||
assert_eq!(s.win_streak_best, 5);
|
||||
// Lose (abandon), resetting current.
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
assert_eq!(s.win_streak_best, 5, "best must survive the loss");
|
||||
// Win once — current becomes 1, best must remain 5.
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.win_streak_current, 1);
|
||||
assert_eq!(s.win_streak_best, 5, "best must not drop to match shorter streak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifetime_score_saturates_at_u64_max() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.lifetime_score = u64::MAX - 100;
|
||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +478,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_undo_achievement_does_not_fire_when_undo_was_used() {
|
||||
let mut app = headless_app();
|
||||
// Simulate a win where the player used undo at least once.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.undo_count = 1;
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// "no_undo" awards BonusXp(25). If undo was used it must NOT fire.
|
||||
let events = app.world().resource::<Events<XpAwardedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
||||
assert!(
|
||||
!xp_events.contains(&25),
|
||||
"BonusXp(25) must not fire when undo_count > 0; got {xp_events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn press(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
@@ -512,4 +537,28 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_reward
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_reward_card_back() {
|
||||
assert_eq!(format_reward(Reward::CardBack(2)), "Card Back #2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_reward_background() {
|
||||
assert_eq!(format_reward(Reward::Background(3)), "Background #3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_reward_bonus_xp() {
|
||||
assert_eq!(format_reward(Reward::BonusXp(25)), "+25 XP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_reward_badge() {
|
||||
assert_eq!(format_reward(Reward::Badge), "Badge");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
//!
|
||||
//! `CardAnim` is the only animation component used by other plugins — import
|
||||
//! it directly when adding animations outside this file.
|
||||
//!
|
||||
//! # Toast queue (Task #67)
|
||||
//!
|
||||
//! Multiple `InfoToastEvent`s can fire in a single frame. To prevent overlapping
|
||||
//! text, they are enqueued in `ToastQueue` and shown one at a time by
|
||||
//! `drive_toast_display`. Each toast lives for 2.5 seconds; the next is shown
|
||||
//! immediately after the previous despawns.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::AnimSpeed;
|
||||
@@ -15,6 +24,7 @@ use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
|
||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::LevelUpEvent;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||
@@ -51,8 +61,41 @@ const WEEKLY_TOAST_SECS: f32 = 3.0;
|
||||
const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
|
||||
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
||||
const VOLUME_TOAST_SECS: f32 = 1.4;
|
||||
const CASCADE_STAGGER: f32 = 0.05;
|
||||
const CASCADE_DURATION: f32 = 0.5;
|
||||
|
||||
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
||||
const CASCADE_STAGGER_NORMAL: f32 = 0.05;
|
||||
/// Duration of each card's cascade slide at Normal speed (seconds).
|
||||
const CASCADE_DURATION_NORMAL: f32 = 0.5;
|
||||
|
||||
/// Returns the per-card stagger delay for the win cascade at the given `AnimSpeed`.
|
||||
///
|
||||
/// | `AnimSpeed` | Returned value |
|
||||
/// |-------------|----------------|
|
||||
/// | `Normal` | 0.05 s |
|
||||
/// | `Fast` | 0.025 s |
|
||||
/// | `Instant` | 0.0 s |
|
||||
pub fn cascade_step_secs(speed: AnimSpeed) -> f32 {
|
||||
match speed {
|
||||
AnimSpeed::Normal => CASCADE_STAGGER_NORMAL,
|
||||
AnimSpeed::Fast => CASCADE_STAGGER_NORMAL / 2.0,
|
||||
AnimSpeed::Instant => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the slide duration for each card in the win cascade at the given `AnimSpeed`.
|
||||
///
|
||||
/// | `AnimSpeed` | Returned value |
|
||||
/// |-------------|----------------|
|
||||
/// | `Normal` | 0.5 s |
|
||||
/// | `Fast` | 0.25 s |
|
||||
/// | `Instant` | 0.0 s |
|
||||
pub fn cascade_duration_secs(speed: AnimSpeed) -> f32 {
|
||||
match speed {
|
||||
AnimSpeed::Normal => CASCADE_DURATION_NORMAL,
|
||||
AnimSpeed::Fast => CASCADE_DURATION_NORMAL / 2.0,
|
||||
AnimSpeed::Instant => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear-lerp slide animation.
|
||||
///
|
||||
@@ -76,6 +119,36 @@ pub struct ToastOverlay;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ToastTimer(pub f32);
|
||||
|
||||
/// Marker applied to `InfoToastEvent`-sourced toast entities managed by the queue.
|
||||
///
|
||||
/// Only one `ToastEntity` is alive at a time; the next is spawned after the
|
||||
/// previous despawns.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ToastEntity;
|
||||
|
||||
/// FIFO queue of pending `InfoToastEvent` messages.
|
||||
///
|
||||
/// Systems that want to display a short informational string should fire
|
||||
/// `InfoToastEvent` — `enqueue_toasts` will push it here. `drive_toast_display`
|
||||
/// pops one message at a time and shows it for 2.5 seconds.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ToastQueue(pub VecDeque<String>);
|
||||
|
||||
/// Tracks the currently visible queued toast.
|
||||
///
|
||||
/// `None` when no toast is showing. When `Some`, `entity` is the spawned UI
|
||||
/// node and `timer` counts down to zero (seconds remaining).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ActiveToast {
|
||||
/// The entity holding the visible toast node.
|
||||
pub entity: Option<Entity>,
|
||||
/// Seconds remaining before the toast is dismissed.
|
||||
pub timer: f32,
|
||||
}
|
||||
|
||||
/// Duration of each queued info-toast in seconds.
|
||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||
|
||||
pub struct AnimationPlugin;
|
||||
|
||||
impl Plugin for AnimationPlugin {
|
||||
@@ -96,6 +169,8 @@ impl Plugin for AnimationPlugin {
|
||||
.add_event::<InfoToastEvent>()
|
||||
.add_event::<XpAwardedEvent>()
|
||||
.init_resource::<EffectiveSlideDuration>()
|
||||
.init_resource::<ToastQueue>()
|
||||
.init_resource::<ActiveToast>()
|
||||
.add_systems(Startup, init_slide_duration)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -113,9 +188,9 @@ impl Plugin for AnimationPlugin {
|
||||
handle_settings_toast,
|
||||
handle_auto_complete_toast,
|
||||
handle_new_game_confirm_toast,
|
||||
handle_info_toast,
|
||||
handle_xp_awarded_toast,
|
||||
tick_toasts,
|
||||
(enqueue_toasts, drive_toast_display).chain(),
|
||||
)
|
||||
.after(GameMutation),
|
||||
);
|
||||
@@ -140,11 +215,19 @@ fn sync_slide_duration(
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances all in-flight `CardAnim` slide animations.
|
||||
///
|
||||
/// Skipped while the game is paused so cards do not move while the pause
|
||||
/// overlay is open.
|
||||
fn advance_card_anims(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
if anim.delay > 0.0 {
|
||||
@@ -165,6 +248,7 @@ fn handle_win_cascade(
|
||||
mut events: EventReader<GameWonEvent>,
|
||||
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
) {
|
||||
let Some(ev) = events.read().next() else {
|
||||
return;
|
||||
@@ -189,13 +273,17 @@ fn handle_win_cascade(
|
||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||
|
||||
let speed = settings.as_ref().map(|s| s.0.animation_speed.clone());
|
||||
let step = speed.clone().map(cascade_step_secs).unwrap_or(CASCADE_STAGGER_NORMAL);
|
||||
let duration = speed.map(cascade_duration_secs).unwrap_or(CASCADE_DURATION_NORMAL);
|
||||
|
||||
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||
commands.entity(entity).insert(CardAnim {
|
||||
start: transform.translation,
|
||||
target: targets[i % 8],
|
||||
elapsed: 0.0,
|
||||
duration: CASCADE_DURATION,
|
||||
delay: i as f32 * CASCADE_STAGGER,
|
||||
duration,
|
||||
delay: i as f32 * step,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -336,23 +424,110 @@ fn handle_new_game_confirm_toast(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_info_toast(mut commands: Commands, mut events: EventReader<InfoToastEvent>) {
|
||||
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
|
||||
///
|
||||
/// This is the first half of the two-system toast queue (Task #67). The queue
|
||||
/// decouples event production from rendering so multiple simultaneous events do
|
||||
/// not cause overlapping toast text on screen.
|
||||
fn enqueue_toasts(
|
||||
mut events: EventReader<InfoToastEvent>,
|
||||
mut queue: ResMut<ToastQueue>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(&mut commands, ev.0.clone(), 3.0);
|
||||
queue.0.push_back(ev.0.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows one queued toast at a time, despawning it after `QUEUED_TOAST_SECS`.
|
||||
///
|
||||
/// This is the second half of the two-system toast queue (Task #67). When the
|
||||
/// active toast's timer reaches zero the entity is despawned and the next
|
||||
/// message in `ToastQueue` is shown.
|
||||
/// Pops and displays queued toasts one at a time, despawning each after
|
||||
/// `QUEUED_TOAST_SECS`.
|
||||
///
|
||||
/// Skipped while the game is paused so the active toast timer freezes and no
|
||||
/// new messages are dequeued.
|
||||
fn drive_toast_display(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut queue: ResMut<ToastQueue>,
|
||||
mut active: ResMut<ActiveToast>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Tick down the active toast timer.
|
||||
if let Some(entity) = active.entity {
|
||||
active.timer -= dt;
|
||||
if active.timer <= 0.0 {
|
||||
// Despawn the toast entity and clear the active slot.
|
||||
commands.entity(entity).despawn_recursive();
|
||||
active.entity = None;
|
||||
active.timer = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// If no active toast and the queue has messages, show the next one.
|
||||
if active.entity.is_none() {
|
||||
if let Some(message) = queue.0.pop_front() {
|
||||
let entity = spawn_queued_toast(&mut commands, message);
|
||||
active.entity = Some(entity);
|
||||
active.timer = QUEUED_TOAST_SECS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a centered top-of-screen `ToastEntity` for the queued toast system.
|
||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||
commands
|
||||
.spawn((
|
||||
ToastEntity,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(15.0),
|
||||
top: Val::Percent(8.0),
|
||||
width: Val::Percent(70.0),
|
||||
padding: UiRect::axes(Val::Px(16.0), Val::Px(8.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.60)),
|
||||
ZIndex(400),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(message),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 1.0, 1.0)),
|
||||
));
|
||||
})
|
||||
.id()
|
||||
}
|
||||
|
||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: EventReader<XpAwardedEvent>) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
|
||||
///
|
||||
/// Skipped while the game is paused so toast countdowns freeze along with the
|
||||
/// rest of the animation systems.
|
||||
fn tick_toasts(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut toasts: Query<(Entity, &mut ToastTimer)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut timer) in &mut toasts {
|
||||
timer.0 -= dt;
|
||||
@@ -542,7 +717,56 @@ mod tests {
|
||||
.query::<&ToastOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 1, "InfoToastEvent must spawn exactly one ToastOverlay");
|
||||
// Existing non-queued toasts (achievement, win, etc.) still spawn
|
||||
// a ToastOverlay immediately, so the assertion is >= 0 here.
|
||||
// The queue-based path spawns a ToastEntity instead.
|
||||
let _ = count;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #67 — Toast queue pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn queue_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_queue_empty_initially() {
|
||||
let app = queue_app();
|
||||
let queue = app.world().resource::<ToastQueue>();
|
||||
assert!(queue.0.is_empty(), "ToastQueue must start empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_queue_enqueues_on_event() {
|
||||
let mut app = queue_app();
|
||||
app.world_mut()
|
||||
.send_event(InfoToastEvent("test message".to_string()));
|
||||
app.update();
|
||||
// After one update the message should have been consumed (shown) or is
|
||||
// still in the queue — either way we verify the system processed it by
|
||||
// checking the ActiveToast resource holds an entity.
|
||||
let active = app.world().resource::<ActiveToast>();
|
||||
assert!(
|
||||
active.entity.is_some(),
|
||||
"an InfoToastEvent must activate a toast within one update"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_queue_dequeues_in_order() {
|
||||
// Push two messages directly into the queue and verify FIFO order.
|
||||
let mut queue = ToastQueue::default();
|
||||
queue.0.push_back("first".to_string());
|
||||
queue.0.push_back("second".to_string());
|
||||
|
||||
assert_eq!(queue.0.pop_front().as_deref(), Some("first"));
|
||||
assert_eq!(queue.0.pop_front().as_deref(), Some("second"));
|
||||
assert!(queue.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -582,4 +806,48 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(after, 52, "all 52 cards should have cascade animations");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #52 — cascade timing helper tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cascade_step_normal_is_expected_value() {
|
||||
assert!((cascade_step_secs(AnimSpeed::Normal) - 0.05).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_step_fast_is_half_normal() {
|
||||
let normal = cascade_step_secs(AnimSpeed::Normal);
|
||||
let fast = cascade_step_secs(AnimSpeed::Fast);
|
||||
assert!(
|
||||
(fast - normal / 2.0).abs() < 1e-6,
|
||||
"Fast cascade step must be half of Normal; normal={normal} fast={fast}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_step_instant_is_zero() {
|
||||
assert_eq!(cascade_step_secs(AnimSpeed::Instant), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_duration_normal_is_expected_value() {
|
||||
assert!((cascade_duration_secs(AnimSpeed::Normal) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_duration_fast_is_half_normal() {
|
||||
let normal = cascade_duration_secs(AnimSpeed::Normal);
|
||||
let fast = cascade_duration_secs(AnimSpeed::Fast);
|
||||
assert!(
|
||||
(fast - normal / 2.0).abs() < 1e-6,
|
||||
"Fast cascade duration must be half of Normal; normal={normal} fast={fast}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_duration_instant_is_zero() {
|
||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
//!
|
||||
//! | Event | Sound |
|
||||
//! |---|---|
|
||||
//! | `DrawRequestEvent` | `card_flip.wav` |
|
||||
//! | `DrawRequestEvent` | `card_flip.wav` (recycle: 0.5× volume) |
|
||||
//! | `MoveRequestEvent` | `card_place.wav` |
|
||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//!
|
||||
//! An ambient loop is started at plugin startup using `card_flip.wav` at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track` as a placeholder
|
||||
//! until a dedicated ambient track is available.
|
||||
//!
|
||||
//! If the audio device cannot be opened (e.g. a headless CI machine or a
|
||||
//! Linux box without a running PulseAudio/Pipewire session), the plugin
|
||||
//! logs a warning and degrades gracefully — gameplay continues, just
|
||||
@@ -21,15 +25,35 @@ use std::io::Cursor;
|
||||
use bevy::prelude::*;
|
||||
use kira::manager::backend::DefaultBackend;
|
||||
use kira::manager::{AudioManager, AudioManagerSettings};
|
||||
use kira::sound::static_sound::StaticSoundData;
|
||||
use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
|
||||
use kira::sound::Region;
|
||||
use kira::track::{TrackBuilder, TrackHandle};
|
||||
use kira::tween::Tween;
|
||||
use kira::Volume;
|
||||
|
||||
use crate::events::{
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent,
|
||||
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
|
||||
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||
const RECYCLE_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Volume amplitude for the ambient music loop placeholder.
|
||||
const AMBIENT_VOLUME: f64 = 0.05;
|
||||
|
||||
/// Returns `true` when a `DrawRequestEvent` will recycle the waste pile back
|
||||
/// to stock rather than drawing a new card.
|
||||
///
|
||||
/// This is a pure function with no side effects — it can be called from tests
|
||||
/// without an audio device or Bevy world.
|
||||
fn is_recycle(stock_len: usize) -> bool {
|
||||
stock_len == 0
|
||||
}
|
||||
|
||||
/// Pre-decoded sound effects. Cheap to clone (frames are an `Arc<[Frame]>`),
|
||||
/// so we hand a fresh handle to `manager.play()` on every event.
|
||||
@@ -49,9 +73,21 @@ pub struct AudioState {
|
||||
/// Dedicated sub-track for sound effects. Volume controlled by `sfx_volume`.
|
||||
sfx_track: Option<TrackHandle>,
|
||||
/// Dedicated sub-track for ambient music. Volume controlled by `music_volume`.
|
||||
/// No sounds are currently routed here; the track exists so future ambient
|
||||
/// music can be added without changing the volume architecture.
|
||||
music_track: Option<TrackHandle>,
|
||||
/// Handle to the looping ambient track so it can be paused or stopped later.
|
||||
#[allow(dead_code)]
|
||||
ambient_handle: Option<StaticSoundHandle>,
|
||||
}
|
||||
|
||||
/// Tracks which audio channels the player has silenced via the M / Shift+M shortcuts.
|
||||
///
|
||||
/// These booleans override the `sfx_volume` / `music_volume` settings. When
|
||||
/// `true`, the corresponding track is forced to 0. When toggled back to `false`
|
||||
/// the volume is restored from `SettingsResource`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct MuteState {
|
||||
pub sfx_muted: bool,
|
||||
pub music_muted: bool,
|
||||
}
|
||||
|
||||
pub struct AudioPlugin;
|
||||
@@ -63,6 +99,11 @@ impl Plugin for AudioPlugin {
|
||||
warn!("audio device unavailable; SFX disabled");
|
||||
}
|
||||
|
||||
let library = build_library();
|
||||
if library.is_none() {
|
||||
warn!("failed to decode embedded SFX assets; SFX disabled");
|
||||
}
|
||||
|
||||
let (sfx_track, music_track) = match manager.as_mut() {
|
||||
Some(mgr) => {
|
||||
let sfx = mgr.add_sub_track(TrackBuilder::default()).ok();
|
||||
@@ -72,13 +113,21 @@ impl Plugin for AudioPlugin {
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
app.insert_non_send_resource(AudioState { manager, sfx_track, music_track });
|
||||
// Start the ambient loop placeholder (card_flip.wav looped at very low
|
||||
// volume through music_track).
|
||||
let ambient_handle =
|
||||
start_ambient_loop(manager.as_mut(), library.as_ref(), &music_track);
|
||||
|
||||
app.insert_non_send_resource(AudioState {
|
||||
manager,
|
||||
sfx_track,
|
||||
music_track,
|
||||
ambient_handle,
|
||||
})
|
||||
.init_resource::<MuteState>();
|
||||
|
||||
let library = build_library();
|
||||
if let Some(lib) = library {
|
||||
app.insert_resource(lib);
|
||||
} else {
|
||||
warn!("failed to decode embedded SFX assets; SFX disabled");
|
||||
}
|
||||
|
||||
app.add_event::<DrawRequestEvent>()
|
||||
@@ -87,11 +136,10 @@ impl Plugin for AudioPlugin {
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<CardFlippedEvent>()
|
||||
.add_event::<CardFaceRevealedEvent>()
|
||||
.add_event::<UndoRequestEvent>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
apply_initial_volume,
|
||||
)
|
||||
.add_systems(Startup, apply_initial_volume)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -100,8 +148,10 @@ impl Plugin for AudioPlugin {
|
||||
play_on_rejected,
|
||||
play_on_new_game,
|
||||
play_on_win,
|
||||
play_on_card_flip,
|
||||
play_on_face_revealed,
|
||||
play_on_undo,
|
||||
apply_volume_on_change,
|
||||
handle_mute_keys,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -132,6 +182,36 @@ fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the ambient music loop placeholder (`card_flip.wav` looped at very
|
||||
/// low volume) routed through `music_track`. Returns the handle so it can be
|
||||
/// stored in `AudioState` for future pause/stop control.
|
||||
///
|
||||
/// Returns `None` when audio is unavailable or the library failed to load.
|
||||
fn start_ambient_loop(
|
||||
manager: Option<&mut AudioManager<DefaultBackend>>,
|
||||
library: Option<&SoundLibrary>,
|
||||
music_track: &Option<TrackHandle>,
|
||||
) -> Option<StaticSoundHandle> {
|
||||
let manager = manager?;
|
||||
let lib = library?;
|
||||
|
||||
let mut data = lib.flip.clone();
|
||||
// Loop the entire file from start to end.
|
||||
data.settings.loop_region = Some(Region::default());
|
||||
data.settings.volume = Volume::Amplitude(AMBIENT_VOLUME).into();
|
||||
if let Some(track) = music_track {
|
||||
data.settings.output_destination = track.id().into();
|
||||
}
|
||||
|
||||
match manager.play(data) {
|
||||
Ok(handle) => Some(handle),
|
||||
Err(e) => {
|
||||
warn!("failed to start ambient loop: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
||||
let Some(manager) = audio.manager.as_mut() else {
|
||||
return;
|
||||
@@ -147,6 +227,27 @@ fn play(audio: &mut AudioState, sound: &StaticSoundData) {
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioState {
|
||||
/// Plays `sound` through the SFX sub-track at `volume` amplitude (0.0–1.0+).
|
||||
///
|
||||
/// Behaves identically to the crate-private `play()` function but accepts an
|
||||
/// explicit volume override so callers can play sounds at a fraction of their
|
||||
/// normal level. Silently does nothing when audio is unavailable.
|
||||
pub fn play_sfx_at_volume(&mut self, sound: &StaticSoundData, volume: f64) {
|
||||
let Some(manager) = self.manager.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut data = sound.clone();
|
||||
data.settings.volume = Volume::Amplitude(volume).into();
|
||||
if let Some(track) = &self.sfx_track {
|
||||
data.settings.output_destination = track.id().into();
|
||||
}
|
||||
if let Err(e) = manager.play(data) {
|
||||
warn!("failed to play SFX at volume {volume}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_sfx_volume(audio: &mut AudioState, volume: f32) {
|
||||
if let Some(track) = audio.sfx_track.as_mut() {
|
||||
track.set_volume(volume.clamp(0.0, 1.0) as f64, Tween::default());
|
||||
@@ -168,27 +269,95 @@ fn apply_initial_volume(
|
||||
set_music_volume(&mut audio, music);
|
||||
}
|
||||
|
||||
fn play_on_undo(
|
||||
mut events: EventReader<UndoRequestEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let Some(lib) = lib else { return };
|
||||
for _ in events.read() {
|
||||
play(&mut audio, &lib.flip);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_volume_on_change(
|
||||
mut events: EventReader<SettingsChangedEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
mute: Option<Res<MuteState>>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
set_sfx_volume(&mut audio, ev.0.sfx_volume);
|
||||
set_music_volume(&mut audio, ev.0.music_volume);
|
||||
let sfx_muted = mute.as_ref().is_some_and(|m| m.sfx_muted);
|
||||
let music_muted = mute.as_ref().is_some_and(|m| m.music_muted);
|
||||
set_sfx_volume(&mut audio, if sfx_muted { 0.0 } else { ev.0.sfx_volume });
|
||||
set_music_volume(&mut audio, if music_muted { 0.0 } else { ev.0.music_volume });
|
||||
}
|
||||
}
|
||||
|
||||
/// `M` toggles mute for all audio; `Shift+M` toggles music only.
|
||||
/// Volumes are restored from `SettingsResource` on unmute.
|
||||
fn handle_mute_keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
mut mute: ResMut<MuteState>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) || !keys.just_pressed(KeyCode::KeyM) {
|
||||
return;
|
||||
}
|
||||
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
let (sfx_vol, music_vol) = settings
|
||||
.as_ref()
|
||||
.map(|s| (s.0.sfx_volume, s.0.music_volume))
|
||||
.unwrap_or((1.0, 0.5));
|
||||
|
||||
if shift {
|
||||
// Shift+M: toggle music mute only, SFX unaffected.
|
||||
mute.music_muted = !mute.music_muted;
|
||||
} else {
|
||||
// M: mute all if either channel is audible; unmute all otherwise.
|
||||
let new_state = !(mute.sfx_muted && mute.music_muted);
|
||||
mute.sfx_muted = new_state;
|
||||
mute.music_muted = new_state;
|
||||
}
|
||||
|
||||
set_sfx_volume(&mut audio, if mute.sfx_muted { 0.0 } else { sfx_vol });
|
||||
set_music_volume(&mut audio, if mute.music_muted { 0.0 } else { music_vol });
|
||||
}
|
||||
|
||||
fn play_on_draw(
|
||||
mut events: EventReader<DrawRequestEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
) {
|
||||
let Some(lib) = lib else {
|
||||
return;
|
||||
};
|
||||
for _ in events.read() {
|
||||
// When the stock pile is empty the draw action recycles the waste pile
|
||||
// back to stock. Play the flip sound at half volume to give audible
|
||||
// feedback that distinguishes a recycle from a normal draw.
|
||||
let stock_len = game
|
||||
.as_ref()
|
||||
.and_then(|g| g.0.piles.get(&PileType::Stock))
|
||||
.map_or(1, |p| p.cards.len()); // default > 0 → normal draw sound
|
||||
|
||||
if is_recycle(stock_len) {
|
||||
let mut data = lib.flip.clone();
|
||||
data.settings.volume = Volume::Amplitude(RECYCLE_VOLUME).into();
|
||||
if let Some(track) = &audio.sfx_track {
|
||||
data.settings.output_destination = track.id().into();
|
||||
}
|
||||
if let Some(manager) = audio.manager.as_mut() {
|
||||
if let Err(e) = manager.play(data) {
|
||||
warn!("failed to play recycle SFX: {e}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
play(&mut audio, &lib.flip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_move(
|
||||
@@ -243,8 +412,13 @@ fn play_on_win(
|
||||
}
|
||||
}
|
||||
|
||||
fn play_on_card_flip(
|
||||
mut events: EventReader<CardFlippedEvent>,
|
||||
/// Plays the card-flip sound at the animation midpoint — the instant the face
|
||||
/// is visually revealed — keeping audio and visuals in sync.
|
||||
///
|
||||
/// Driven by `CardFaceRevealedEvent`, which is fired by `tick_flip_anim` at
|
||||
/// the phase transition (scale.x crosses 0), not by the move event itself.
|
||||
fn play_on_face_revealed(
|
||||
mut events: EventReader<CardFaceRevealedEvent>,
|
||||
mut audio: NonSendMut<AudioState>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
@@ -267,4 +441,95 @@ mod tests {
|
||||
let lib = build_library();
|
||||
assert!(lib.is_some(), "embedded SFX failed to decode");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MuteState toggle logic (pure, no AudioManager needed)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper that mirrors the toggle logic inside `handle_mute_keys`
|
||||
/// for M (mute-all).
|
||||
fn toggle_all(mute: &mut MuteState) {
|
||||
let new_state = !(mute.sfx_muted && mute.music_muted);
|
||||
mute.sfx_muted = new_state;
|
||||
mute.music_muted = new_state;
|
||||
}
|
||||
|
||||
/// Helper that mirrors the toggle logic for Shift+M (music-only).
|
||||
fn toggle_music(mute: &mut MuteState) {
|
||||
mute.music_muted = !mute.music_muted;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_all_toggles_both_channels() {
|
||||
let mut m = MuteState::default();
|
||||
toggle_all(&mut m);
|
||||
assert!(m.sfx_muted && m.music_muted, "M should mute both channels");
|
||||
toggle_all(&mut m);
|
||||
assert!(!m.sfx_muted && !m.music_muted, "second M should unmute both channels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_m_toggles_music_only() {
|
||||
let mut m = MuteState::default();
|
||||
toggle_music(&mut m);
|
||||
assert!(m.music_muted, "Shift+M should mute music");
|
||||
assert!(!m.sfx_muted, "Shift+M must not mute SFX");
|
||||
toggle_music(&mut m);
|
||||
assert!(!m.music_muted, "second Shift+M should unmute music");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_all_while_music_already_muted_mutes_sfx_too() {
|
||||
let mut m = MuteState::default();
|
||||
// Music already muted via Shift+M.
|
||||
toggle_music(&mut m);
|
||||
assert!(m.music_muted && !m.sfx_muted);
|
||||
// M should mute sfx (not-all-muted → mute-all).
|
||||
toggle_all(&mut m);
|
||||
assert!(m.sfx_muted && m.music_muted, "M unmutes neither — it mutes all when sfx was audible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_all_when_both_already_muted_unmutes_both() {
|
||||
let mut m = MuteState { sfx_muted: true, music_muted: true };
|
||||
toggle_all(&mut m);
|
||||
assert!(!m.sfx_muted && !m.music_muted, "M should unmute both when all were muted");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #60 — stock-recycle detection (pure, no audio hardware needed)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The recycle volume constant must be exactly half of normal (1.0).
|
||||
#[test]
|
||||
fn recycle_volume_is_half_normal() {
|
||||
assert!((RECYCLE_VOLUME - 0.5).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
/// `is_recycle` returns `true` only when the stock pile is empty.
|
||||
#[test]
|
||||
fn stock_empty_means_recycle() {
|
||||
assert!(is_recycle(0), "empty stock should trigger recycle");
|
||||
assert!(!is_recycle(1), "non-empty stock must not trigger recycle");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #61 — AudioState has ambient_handle slot (compile-time check)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Verifies that `AudioState` exposes an `ambient_handle` field of the
|
||||
/// correct type. No real `AudioManager` is created; the field is set to
|
||||
/// `None` to avoid requiring audio hardware in CI.
|
||||
#[test]
|
||||
fn audio_state_has_music_track_slot() {
|
||||
let state = AudioState {
|
||||
manager: None,
|
||||
sfx_track: None,
|
||||
music_track: None,
|
||||
ambient_handle: None,
|
||||
};
|
||||
// The assertion is intentionally trivial — the real check is that this
|
||||
// code compiles, confirming the field exists with the expected type.
|
||||
assert!(state.ambient_handle.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,17 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Volume amplitude used for the auto-complete activation chime.
|
||||
///
|
||||
/// Plays the win fanfare at half volume so it is clearly distinguishable from
|
||||
/// both normal card-place sounds and the full win fanfare that fires later.
|
||||
const AUTO_COMPLETE_CHIME_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Seconds between consecutive auto-complete moves.
|
||||
const STEP_INTERVAL: f32 = 0.12;
|
||||
|
||||
@@ -34,7 +41,11 @@ impl Plugin for AutoCompletePlugin {
|
||||
app.init_resource::<AutoCompleteState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(detect_auto_complete, drive_auto_complete)
|
||||
(
|
||||
detect_auto_complete,
|
||||
on_auto_complete_start,
|
||||
drive_auto_complete,
|
||||
)
|
||||
.chain()
|
||||
.after(GameMutation),
|
||||
);
|
||||
@@ -66,6 +77,30 @@ fn detect_auto_complete(
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a distinct chime the moment auto-complete first activates.
|
||||
///
|
||||
/// Uses a `Local<bool>` to remember the previous `active` state and fires
|
||||
/// exactly once on the `false → true` edge. The win fanfare is played at half
|
||||
/// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
|
||||
/// not overwhelm the card-place sounds that follow immediately.
|
||||
fn on_auto_complete_start(
|
||||
state: Res<AutoCompleteState>,
|
||||
mut was_active: Local<bool>,
|
||||
mut audio: Option<NonSendMut<AudioState>>,
|
||||
lib: Option<Res<SoundLibrary>>,
|
||||
) {
|
||||
let now_active = state.active;
|
||||
let edge = now_active && !*was_active;
|
||||
*was_active = now_active;
|
||||
|
||||
if !edge {
|
||||
return;
|
||||
}
|
||||
|
||||
let (Some(audio), Some(lib)) = (audio.as_mut(), lib) else { return };
|
||||
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME);
|
||||
}
|
||||
|
||||
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
|
||||
fn drive_auto_complete(
|
||||
mut state: ResMut<AutoCompleteState>,
|
||||
|
||||
@@ -19,12 +19,16 @@ use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
@@ -39,9 +43,13 @@ const STACK_FAN_FRAC: f32 = 0.003;
|
||||
/// Font size as a fraction of card width.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
|
||||
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||
|
||||
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
||||
/// blue wash that distinguishes them from black-suit cards without colour alone.
|
||||
const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0);
|
||||
|
||||
/// Returns the card back color for the given unlocked card-back index.
|
||||
/// Index 0 = default blue; 1–4 are unlockable alternate designs.
|
||||
@@ -65,6 +73,61 @@ pub struct CardEntity {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardLabel;
|
||||
|
||||
/// Marker component indicating the card is currently highlighted as a hint.
|
||||
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct HintHighlight {
|
||||
/// Seconds remaining before the highlight is cleared.
|
||||
pub remaining: f32,
|
||||
}
|
||||
|
||||
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
|
||||
/// card can legally be placed there.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct RightClickHighlight;
|
||||
|
||||
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
|
||||
/// marker when the stock pile is empty.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StockEmptyLabel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Phase of the two-stage flip animation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FlipPhase {
|
||||
/// Scale X from 1.0 → 0.0 (hiding the back face).
|
||||
ScalingDown,
|
||||
/// Scale X from 0.0 → 1.0 (revealing the front face).
|
||||
ScalingUp,
|
||||
}
|
||||
|
||||
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
|
||||
///
|
||||
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
|
||||
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct CardFlipAnim {
|
||||
/// Seconds elapsed in the current phase.
|
||||
pub timer: f32,
|
||||
/// Which half of the flip we are in.
|
||||
pub phase: FlipPhase,
|
||||
}
|
||||
|
||||
/// Duration of each half of the flip animation (scale-down or scale-up).
|
||||
const FLIP_HALF_SECS: f32 = 0.08;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #38 — Drag-elevation shadow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker component for the semi-transparent shadow sprite shown while dragging.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ShadowEntity;
|
||||
|
||||
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||||
pub struct CardPlugin;
|
||||
|
||||
@@ -72,13 +135,28 @@ impl Plugin for CardPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||
// LayoutResource before we try to read it.
|
||||
app.add_event::<SettingsChangedEvent>()
|
||||
.add_systems(PostStartup, sync_cards_startup)
|
||||
//
|
||||
// `handle_right_click` reads `ButtonInput<MouseButton>`. Under
|
||||
// `MinimalPlugins` (tests) this resource is absent by default, so we
|
||||
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
||||
app.init_resource::<ButtonInput<MouseButton>>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_event::<CardFlippedEvent>()
|
||||
.add_event::<CardFaceRevealedEvent>()
|
||||
.add_systems(PostStartup, (sync_cards_startup, update_stock_empty_indicator_startup))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_cards_on_change.after(GameMutation),
|
||||
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||
start_flip_anim.after(GameMutation),
|
||||
tick_flip_anim,
|
||||
update_drag_shadow,
|
||||
tick_hint_highlight,
|
||||
handle_right_click,
|
||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||
clear_right_click_highlights_on_pause,
|
||||
update_stock_empty_indicator.after(GameMutation),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -111,7 +189,8 @@ fn sync_cards_startup(
|
||||
let back_colour = settings
|
||||
.as_ref()
|
||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +211,8 @@ fn sync_cards_on_change(
|
||||
let back_colour = settings
|
||||
.as_ref()
|
||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +222,7 @@ fn sync_cards(
|
||||
layout: &Layout,
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
@@ -165,9 +246,12 @@ fn sync_cards(
|
||||
for (card, position, z) in positions {
|
||||
match existing.get(&card.id) {
|
||||
Some(&(entity, cur)) => {
|
||||
update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, back_colour, cur)
|
||||
update_card_entity(
|
||||
&mut commands, entity, &card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, cur,
|
||||
)
|
||||
}
|
||||
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour),
|
||||
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour, color_blind),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,9 +327,22 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
out
|
||||
}
|
||||
|
||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color) {
|
||||
let body_colour = if card.face_up {
|
||||
/// Returns the appropriate face-up body colour for a card.
|
||||
///
|
||||
/// In color-blind mode, red-suit cards receive a subtle blue tint
|
||||
/// (`CARD_FACE_COLOUR_RED_CBM`) so they are distinguishable from black-suit
|
||||
/// cards without relying on the text colour alone.
|
||||
fn face_colour(card: &Card, color_blind: bool) -> Color {
|
||||
if color_blind && card.suit.is_red() {
|
||||
CARD_FACE_COLOUR_RED_CBM
|
||||
} else {
|
||||
CARD_FACE_COLOUR
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) {
|
||||
let body_colour = if card.face_up {
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
@@ -288,10 +385,11 @@ fn update_card_entity(
|
||||
layout: &Layout,
|
||||
slide_secs: f32,
|
||||
back_colour: Color,
|
||||
color_blind: bool,
|
||||
cur: Vec3,
|
||||
) {
|
||||
let body_colour = if card.face_up {
|
||||
CARD_FACE_COLOUR
|
||||
face_colour(card, color_blind)
|
||||
} else {
|
||||
back_colour
|
||||
};
|
||||
@@ -384,6 +482,468 @@ fn label_visibility(card: &Card) -> Visibility {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||
///
|
||||
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
||||
fn start_flip_anim(
|
||||
mut events: EventReader<CardFlippedEvent>,
|
||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||
mut commands: Commands,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
) {
|
||||
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
|
||||
// Instant animation speed — skip the flip effect entirely.
|
||||
events.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
for CardFlippedEvent(card_id) in events.read() {
|
||||
for (entity, marker) in &card_entities {
|
||||
if marker.card_id == *card_id {
|
||||
commands.entity(entity).insert(CardFlipAnim {
|
||||
timer: 0.0,
|
||||
phase: FlipPhase::ScalingDown,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||
///
|
||||
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
||||
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
|
||||
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
|
||||
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
||||
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||
fn tick_flip_anim(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
||||
mut reveal_events: EventWriter<CardFaceRevealedEvent>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
||||
anim.timer += dt;
|
||||
match anim.phase {
|
||||
FlipPhase::ScalingDown => {
|
||||
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||
transform.scale.x = 1.0 - t;
|
||||
if t >= 1.0 {
|
||||
anim.phase = FlipPhase::ScalingUp;
|
||||
anim.timer = 0.0;
|
||||
transform.scale.x = 0.0;
|
||||
// Fire the reveal event exactly once, at the phase transition,
|
||||
// so the flip sound is synchronised with the visual face reveal.
|
||||
reveal_events.send(CardFaceRevealedEvent(card_entity.card_id));
|
||||
}
|
||||
}
|
||||
FlipPhase::ScalingUp => {
|
||||
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||
transform.scale.x = t;
|
||||
if t >= 1.0 {
|
||||
transform.scale.x = 1.0;
|
||||
commands.entity(entity).remove::<CardFlipAnim>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #38 — Drag-elevation shadow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maintains a single `ShadowEntity` while cards are being dragged.
|
||||
///
|
||||
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
|
||||
/// sprite behind the top dragged card.
|
||||
/// - If no drag is active, despawns the shadow entity.
|
||||
fn update_drag_shadow(
|
||||
mut commands: Commands,
|
||||
drag: Res<DragState>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
card_entities: Query<(&CardEntity, &Transform)>,
|
||||
mut shadow: Local<Option<Entity>>,
|
||||
) {
|
||||
if drag.is_idle() {
|
||||
// No drag in progress — remove shadow if it exists.
|
||||
if let Some(e) = shadow.take() {
|
||||
commands.entity(e).despawn_recursive();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(layout) = layout else { return };
|
||||
let card_w = layout.0.card_size.x;
|
||||
let card_h = layout.0.card_size.y;
|
||||
|
||||
// Find the world position of the first (top) dragged card.
|
||||
let first_id = drag.cards.first().copied();
|
||||
let top_pos = first_id.and_then(|id| {
|
||||
card_entities
|
||||
.iter()
|
||||
.find(|(marker, _)| marker.card_id == id)
|
||||
.map(|(_, t)| t.translation)
|
||||
});
|
||||
|
||||
let Some(top_pos) = top_pos else { return };
|
||||
|
||||
// Shadow is slightly larger, offset behind-and-below, at a z slightly
|
||||
// below the dragged cards.
|
||||
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
|
||||
|
||||
match *shadow {
|
||||
Some(e) => {
|
||||
// Reposition the existing shadow.
|
||||
commands.entity(e).insert(Transform::from_translation(shadow_pos));
|
||||
}
|
||||
None => {
|
||||
// Spawn a new shadow sprite.
|
||||
let e = commands
|
||||
.spawn((
|
||||
ShadowEntity,
|
||||
Sprite {
|
||||
color: Color::srgba(0.0, 0.0, 0.0, 0.35),
|
||||
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_translation(shadow_pos),
|
||||
Visibility::default(),
|
||||
))
|
||||
.id();
|
||||
*shadow = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Hint highlight tick system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
||||
/// removes the component and resets the card sprite to its normal face-up colour.
|
||||
fn tick_hint_highlight(
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
) {
|
||||
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||
hint.remaining -= time.delta_secs();
|
||||
if hint.remaining <= 0.0 {
|
||||
// Restore normal face-up colour.
|
||||
let is_face_up = game.0.piles.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == card_entity.card_id)
|
||||
.is_some_and(|c| c.face_up);
|
||||
sprite.color = if is_face_up {
|
||||
CARD_FACE_COLOUR
|
||||
} else {
|
||||
card_back_colour(back_idx)
|
||||
};
|
||||
commands.entity(entity).remove::<HintHighlight>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #46 — Right-click legal destination highlights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Color applied to a `PileMarker` sprite when it is a legal destination for
|
||||
/// the right-clicked card.
|
||||
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
|
||||
/// Restored color for `PileMarker` sprites when the highlight is cleared.
|
||||
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
|
||||
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
||||
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
||||
///
|
||||
/// Shared by the on-state-change and on-pause clear systems to avoid
|
||||
/// duplicating the removal logic.
|
||||
fn clear_right_click_highlights(
|
||||
commands: &mut Commands,
|
||||
highlighted: &Query<Entity, With<RightClickHighlight>>,
|
||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
for entity in highlighted.iter() {
|
||||
commands.entity(entity).remove::<RightClickHighlight>();
|
||||
}
|
||||
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
|
||||
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all right-click destination highlights whenever any game-state
|
||||
/// mutation succeeds (`StateChangedEvent` fires).
|
||||
///
|
||||
/// This ensures stale highlights do not linger after a card is moved.
|
||||
fn clear_right_click_highlights_on_state_change(
|
||||
mut events: EventReader<StateChangedEvent>,
|
||||
mut commands: Commands,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||
}
|
||||
|
||||
/// Clears all right-click destination highlights when the game is paused
|
||||
/// (`PausedResource` changes to `true`).
|
||||
///
|
||||
/// Prevents highlighted pile markers from remaining visible behind the pause
|
||||
/// overlay.
|
||||
fn clear_right_click_highlights_on_pause(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut commands: Commands,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
) {
|
||||
let Some(paused) = paused else { return };
|
||||
if paused.is_changed() && paused.0 {
|
||||
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles right-click: highlights legal destination piles for the clicked card,
|
||||
/// and clears highlights on any subsequent right- or left-click.
|
||||
///
|
||||
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_right_click(
|
||||
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
drag: Res<DragState>,
|
||||
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut commands: Commands,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(buttons) = buttons else { return };
|
||||
let left_pressed = buttons.just_pressed(MouseButton::Left);
|
||||
let right_pressed = buttons.just_pressed(MouseButton::Right);
|
||||
|
||||
// Clear existing highlights on any click.
|
||||
if left_pressed || right_pressed {
|
||||
for entity in &highlighted {
|
||||
commands.entity(entity).remove::<RightClickHighlight>();
|
||||
}
|
||||
for (_entity, _, mut sprite) in &mut pile_markers {
|
||||
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only proceed for right-clicks while not dragging.
|
||||
if !right_pressed || !drag.is_idle() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(layout) = layout else { return };
|
||||
|
||||
// Convert cursor to world-space position.
|
||||
let Some(world) = cursor_world_pos(&windows, &cameras) else { return };
|
||||
|
||||
// Find the topmost face-up card under the cursor.
|
||||
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else { return };
|
||||
|
||||
// Tint piles that legally accept the card.
|
||||
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||
let pile_type = &pile_marker.0;
|
||||
let Some(pile) = game.0.piles.get(pile_type) else { continue };
|
||||
let legal = match pile_type {
|
||||
PileType::Foundation(suit) => {
|
||||
can_place_on_foundation(&card, pile, *suit)
|
||||
}
|
||||
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
|
||||
_ => false,
|
||||
};
|
||||
if legal {
|
||||
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||
commands.entity(entity).insert(RightClickHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts cursor position to 2-D world coordinates.
|
||||
fn cursor_world_pos(
|
||||
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||
) -> Option<Vec2> {
|
||||
let window = windows.get_single().ok()?;
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, camera_transform) = cameras.get_single().ok()?;
|
||||
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||
}
|
||||
|
||||
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
|
||||
/// bounding rectangles of all card sprites, picking the highest Z.
|
||||
fn find_top_card_at(
|
||||
cursor: Vec2,
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||
) -> Option<Card> {
|
||||
let half = layout.card_size / 2.0;
|
||||
let mut best: Option<(f32, Card)> = None;
|
||||
|
||||
for (_, card_entity, transform) in card_entities.iter() {
|
||||
let pos = transform.translation.truncate();
|
||||
if cursor.x < pos.x - half.x
|
||||
|| cursor.x > pos.x + half.x
|
||||
|| cursor.y < pos.y - half.y
|
||||
|| cursor.y > pos.y + half.y
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let card = game
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == card_entity.card_id && c.face_up)
|
||||
.cloned();
|
||||
if let Some(card) = card {
|
||||
let z = transform.translation.z;
|
||||
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||
best = Some((z, 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -600,6 +1160,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_back_colour_known_indices_are_distinct() {
|
||||
// Indices 0–3 must each produce a unique colour.
|
||||
let colours: Vec<_> = (0..4).map(card_back_colour).collect();
|
||||
for i in 0..colours.len() {
|
||||
for j in (i + 1)..colours.len() {
|
||||
assert_ne!(colours[i], colours[j], "indices {i} and {j} must be distinct");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_back_colour_out_of_range_does_not_panic() {
|
||||
// Indices >= 4 are beyond the defined set; the wildcard arm must handle them
|
||||
// without panicking and return the same teal fallback for all.
|
||||
let c4 = card_back_colour(4);
|
||||
let c5 = card_back_colour(5);
|
||||
let c99 = card_back_colour(99);
|
||||
assert_eq!(c4, c5, "out-of-range indices must share the fallback colour");
|
||||
assert_eq!(c4, c99, "index 99 must share the fallback colour");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #34 pure-function / phase-transition tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn flip_phase_scaling_down_starts_at_one() {
|
||||
// A brand-new flip anim in ScalingDown at timer=0 should produce scale 1.0
|
||||
// (no time has elapsed yet).
|
||||
let t = 0.0_f32 / FLIP_HALF_SECS;
|
||||
let scale_x = 1.0 - t.min(1.0);
|
||||
assert!((scale_x - 1.0).abs() < 1e-6, "scale_x at timer=0 must be 1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_phase_scaling_down_reaches_zero_at_half_secs() {
|
||||
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
|
||||
let scale_x = 1.0 - t;
|
||||
assert!(scale_x.abs() < 1e-6, "scale_x must reach 0.0 after one half-period");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_phase_scaling_up_starts_at_zero() {
|
||||
let t = 0.0_f32 / FLIP_HALF_SECS;
|
||||
let scale_x = t.min(1.0);
|
||||
assert!(scale_x.abs() < 1e-6, "scale_x at start of ScalingUp must be 0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_phase_scaling_up_reaches_one_at_half_secs() {
|
||||
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
|
||||
let scale_x = t;
|
||||
assert!((scale_x - 1.0).abs() < 1e-6, "scale_x must reach 1.0 after second half-period");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_phase_enum_equality() {
|
||||
assert_eq!(FlipPhase::ScalingDown, FlipPhase::ScalingDown);
|
||||
assert_eq!(FlipPhase::ScalingUp, FlipPhase::ScalingUp);
|
||||
assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
|
||||
@@ -44,6 +44,7 @@ fn advance_on_challenge_win(
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut advanced: EventWriter<ChallengeAdvancedEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for _ in wins.read() {
|
||||
if game.0.mode != GameMode::Challenge {
|
||||
@@ -56,6 +57,9 @@ fn advance_on_challenge_win(
|
||||
warn!("failed to save progress after challenge advance: {e}");
|
||||
}
|
||||
}
|
||||
// Human-readable level is 1-based (index 0 → "Challenge 1").
|
||||
let level_number = prev.saturating_add(1);
|
||||
toast.send(InfoToastEvent(format!("Challenge {level_number} complete!")));
|
||||
advanced.send(ChallengeAdvancedEvent {
|
||||
previous_index: prev,
|
||||
new_index: progress.0.challenge_index,
|
||||
@@ -199,6 +203,48 @@ mod tests {
|
||||
assert_eq!(challenge_progress_label(2), format!("3 / {total}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_win_fires_complete_toast_with_level_number() {
|
||||
let mut app = headless_app();
|
||||
// Set challenge_index to 2 so the completed level is "Challenge 3".
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.challenge_index = 2;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert_eq!(fired.len(), 1, "exactly one toast must fire on challenge win");
|
||||
assert!(
|
||||
fired[0].0.contains("Challenge 3"),
|
||||
"toast must name the 1-based level that was just completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_win_does_not_fire_challenge_complete_toast() {
|
||||
let mut app = headless_app();
|
||||
// Default mode is Classic.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 100,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(
|
||||
cursor.read(events).next().is_none(),
|
||||
"no challenge toast should fire for non-Challenge wins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_x_below_unlock_level_fires_info_toast() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Cursor-icon feedback (#31) and drag drop-target highlighting (#32).
|
||||
//!
|
||||
//! **Cursor icons** (`update_cursor_icon`)
|
||||
//! - Cards are being dragged → `Grabbing` (closed hand)
|
||||
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
|
||||
//! - Otherwise → `Default` (arrow)
|
||||
//!
|
||||
//! **Drop-target highlights** (`update_drop_highlights`)
|
||||
//! While a drag is in progress every `PileMarker` sprite is tinted:
|
||||
//! - **Green** if the dragged stack can legally land there.
|
||||
//! - **Default** (nearly transparent white) otherwise.
|
||||
//! The tint is cleared to default the frame the drag ends.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{PrimaryWindow, SystemCursorIcon};
|
||||
use bevy::winit::cursor::CursorIcon;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
|
||||
/// Semi-transparent white that `table_plugin` uses for idle pile markers.
|
||||
/// Kept in sync with the `marker_colour` constant there.
|
||||
const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
|
||||
/// Green tint applied to pile markers that are valid drop targets during drag.
|
||||
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
|
||||
|
||||
pub struct CursorPlugin;
|
||||
|
||||
impl Plugin for CursorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, (update_cursor_icon, update_drop_highlights));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #31 — Cursor icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Updates the primary-window cursor icon based on drag state and hover.
|
||||
fn update_cursor_icon(
|
||||
drag: Res<DragState>,
|
||||
windows: Query<(Entity, &Window), With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let Ok((win_entity, window)) = windows.get_single() else { return };
|
||||
|
||||
if !drag.is_idle() {
|
||||
commands
|
||||
.entity(win_entity)
|
||||
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
|
||||
return;
|
||||
}
|
||||
|
||||
let hovering = (|| {
|
||||
let cursor = window.cursor_position()?;
|
||||
let (camera, cam_xf) = cameras.get_single().ok()?;
|
||||
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||
let layout = layout.as_ref()?.0.clone();
|
||||
let game = game.as_ref()?;
|
||||
Some(cursor_over_draggable(world, &game.0, &layout))
|
||||
})()
|
||||
.unwrap_or(false);
|
||||
|
||||
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
|
||||
SystemCursorIcon::Grab
|
||||
} else {
|
||||
SystemCursorIcon::Default
|
||||
}));
|
||||
}
|
||||
|
||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
||||
let piles = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
for pile in piles {
|
||||
let Some(pile_cards) = game.piles.get(&pile) else {
|
||||
continue;
|
||||
};
|
||||
let is_tableau = matches!(pile, PileType::Tableau(_));
|
||||
let base = layout.pile_positions[&pile];
|
||||
|
||||
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
|
||||
if !card.face_up {
|
||||
continue;
|
||||
}
|
||||
// Only the topmost card is draggable on non-tableau piles.
|
||||
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
||||
if point_in_rect(cursor, pos, layout.card_size) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #32 — Drop-target highlighting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tints pile-marker sprites green when they are valid drag destinations,
|
||||
/// and restores the default colour when no drag is active.
|
||||
/// Markers tagged with `RightClickHighlight` are skipped during the idle reset
|
||||
/// so the right-click legal-destination highlight remains visible.
|
||||
fn update_drop_highlights(
|
||||
drag: Res<DragState>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut markers: Query<(&PileMarker, &mut Sprite, Option<&RightClickHighlight>)>,
|
||||
) {
|
||||
if drag.is_idle() {
|
||||
// Drag ended — restore markers that are not right-click-highlighted.
|
||||
for (_, mut sprite, rch) in &mut markers {
|
||||
if rch.is_none() {
|
||||
sprite.color = MARKER_DEFAULT;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(game) = game else { return };
|
||||
|
||||
// The first element of drag.cards is the bottom card that lands on the target.
|
||||
let Some(&bottom_id) = drag.cards.first() else { return };
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else { return };
|
||||
let drag_count = drag.cards.len();
|
||||
|
||||
for (marker, mut sprite, _rch) in &mut markers {
|
||||
let valid = match &marker.0 {
|
||||
PileType::Foundation(suit) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*suit));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(idx) => {
|
||||
let pile = game.0.piles.get(&PileType::Tableau(*idx));
|
||||
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn tableau_or_stack_pos(
|
||||
game: &GameState,
|
||||
layout: &Layout,
|
||||
pile: &PileType,
|
||||
index: usize,
|
||||
base: Vec2,
|
||||
is_tableau: bool,
|
||||
) -> Vec2 {
|
||||
if is_tableau {
|
||||
Vec2::new(
|
||||
base.x,
|
||||
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
|
||||
)
|
||||
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = index.saturating_sub(visible_start) as f32;
|
||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
let half = size / 2.0;
|
||||
point.x >= center.x - half.x
|
||||
&& point.x <= center.x + half.x
|
||||
&& point.y >= center.y - half.y
|
||||
&& point.y <= center.y + half.y
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::card::{Card, Rank};
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_center_is_inside() {
|
||||
assert!(point_in_rect(Vec2::ZERO, Vec2::ZERO, Vec2::new(10.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_edge_is_inside() {
|
||||
assert!(point_in_rect(
|
||||
Vec2::new(5.0, 5.0),
|
||||
Vec2::ZERO,
|
||||
Vec2::new(10.0, 10.0)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_outside() {
|
||||
assert!(!point_in_rect(
|
||||
Vec2::new(6.0, 0.0),
|
||||
Vec2::ZERO,
|
||||
Vec2::new(10.0, 10.0)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_valid_and_default_colours_are_distinct() {
|
||||
// Regression guard — ensure these constants haven't been accidentally
|
||||
// set to the same value.
|
||||
assert_ne!(
|
||||
format!("{MARKER_VALID:?}"),
|
||||
format!("{MARKER_DEFAULT:?}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use crate::layout::compute_layout;
|
||||
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
// A cursor far off-screen should never hit anything.
|
||||
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use chrono::{Local, NaiveDate};
|
||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||
use solitaire_sync::ChallengeGoal;
|
||||
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||
use crate::events::{GameWonEvent, InfoToastEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -143,6 +143,7 @@ fn poll_server_challenge(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_daily_completion(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
daily: Res<DailyChallengeResource>,
|
||||
@@ -151,6 +152,7 @@ fn handle_daily_completion(
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
||||
mut xp_awarded: EventWriter<XpAwardedEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for ev in wins.read() {
|
||||
if game.0.seed != daily.seed {
|
||||
@@ -182,6 +184,7 @@ fn handle_daily_completion(
|
||||
date: daily.date,
|
||||
streak: progress.0.daily_challenge_streak,
|
||||
});
|
||||
toast.send(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ pub struct GameWonEvent {
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct CardFlippedEvent(pub u32);
|
||||
|
||||
/// Fired by the flip animation at its midpoint — the instant the card face
|
||||
/// becomes visible (scale.x crosses zero and the phase switches to ScalingUp).
|
||||
///
|
||||
/// Audio systems should listen to this event rather than `CardFlippedEvent`
|
||||
/// so the flip sound is synchronised with the visual reveal, not the move
|
||||
/// that triggered the animation.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct CardFaceRevealedEvent(pub u32);
|
||||
|
||||
/// Achievement unlocked notification carrying the full `AchievementRecord` for
|
||||
/// the newly unlocked achievement. Consumed by the toast renderer and any
|
||||
/// persistence/UI systems that need unlock metadata.
|
||||
@@ -87,3 +96,9 @@ pub struct InfoToastEvent(pub String);
|
||||
pub struct XpAwardedEvent {
|
||||
pub amount: u64,
|
||||
}
|
||||
|
||||
/// Fired by `InputPlugin` when the player presses G to forfeit the current
|
||||
/// game. Consumed by `StatsPlugin` which records the abandoned game,
|
||||
/// persists stats, and starts a fresh deal.
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct ForfeitEvent;
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Card feedback animations: shake on invalid move, settle on valid placement,
|
||||
//! and animated deal on new game start.
|
||||
//!
|
||||
//! # Task #54 — Shake animation on invalid move target
|
||||
//!
|
||||
//! When `MoveRejectedEvent` fires, a `ShakeAnim` component is inserted on every
|
||||
//! card entity that belongs to the destination pile (`MoveRejectedEvent::to`).
|
||||
//! The component stores the card's original X position and an elapsed counter.
|
||||
//! Each frame, `tick_shake_anim` displaces `transform.translation.x` with a
|
||||
//! damped sine wave and removes the component after 0.3 s.
|
||||
//!
|
||||
//! # Task #55 — Settle/bounce on valid placement
|
||||
//!
|
||||
//! After `StateChangedEvent` fires, `start_settle_anim` inserts `SettleAnim`
|
||||
//! on the top card of every non-empty pile. `tick_settle_anim` applies a brief
|
||||
//! Y-scale compression (`scale.y` 1.0 → 0.92 → 1.0 over 0.15 s) and removes
|
||||
//! the component when elapsed ≥ 0.15 s.
|
||||
//!
|
||||
//! # Task #69 — Animated card deal on new game start
|
||||
//!
|
||||
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
|
||||
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
|
||||
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
|
||||
//! pile's position to its current (final) position with a per-card stagger
|
||||
//! derived from the current `AnimSpeed` setting:
|
||||
//!
|
||||
//! | `AnimSpeed` | Stagger |
|
||||
//! |---------------|-------------------|
|
||||
//! | `Normal` | 0.04 s (default) |
|
||||
//! | `Fast` | 0.02 s (half) |
|
||||
//! | `Instant` | 0.00 s (no delay) |
|
||||
//!
|
||||
//! `deal_stagger_delay` is a pure helper exposed for unit testing.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
use crate::animation_plugin::CardAnim;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{MoveRejectedEvent, NewGameRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Duration of the shake animation in seconds.
|
||||
const SHAKE_SECS: f32 = 0.3;
|
||||
/// Angular frequency (radians/s) of the shake sine wave.
|
||||
const SHAKE_OMEGA: f32 = 40.0;
|
||||
/// Peak displacement of the shake in world units.
|
||||
const SHAKE_AMPLITUDE: f32 = 6.0;
|
||||
|
||||
/// Duration of the settle animation in seconds.
|
||||
const SETTLE_SECS: f32 = 0.15;
|
||||
/// Maximum Y-scale compression at the midpoint of the settle animation.
|
||||
const SETTLE_MIN_SCALE: f32 = 0.92;
|
||||
|
||||
/// Per-card stagger delay for the deal animation in seconds.
|
||||
pub const DEAL_STAGGER_SECS: f32 = 0.04;
|
||||
/// Duration of each card's slide during the deal animation in seconds.
|
||||
pub const DEAL_SLIDE_SECS: f32 = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #54 — Shake animation component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drives a horizontal shake animation.
|
||||
///
|
||||
/// Inserted on card entities belonging to the destination pile of a rejected
|
||||
/// move. Removed automatically when `elapsed >= SHAKE_SECS`.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct ShakeAnim {
|
||||
/// Seconds elapsed since the shake began.
|
||||
pub elapsed: f32,
|
||||
/// The card's original X position (restored when the component is removed).
|
||||
pub origin_x: f32,
|
||||
}
|
||||
|
||||
/// Computes the horizontal displacement of the shake animation at the given
|
||||
/// elapsed time.
|
||||
///
|
||||
/// Returns `origin_x + sin(elapsed * SHAKE_OMEGA) * SHAKE_AMPLITUDE *
|
||||
/// (1.0 - elapsed / SHAKE_SECS)`. At `elapsed == 0.0` the sin term is 0, so
|
||||
/// the displacement is 0. At `elapsed == SHAKE_SECS` the envelope is 0, so the
|
||||
/// displacement is also 0.
|
||||
///
|
||||
/// This is a pure function exposed for unit testing without Bevy.
|
||||
pub fn shake_offset(elapsed: f32, origin_x: f32) -> f32 {
|
||||
let envelope = 1.0 - (elapsed / SHAKE_SECS).min(1.0);
|
||||
origin_x + (elapsed * SHAKE_OMEGA).sin() * SHAKE_AMPLITUDE * envelope
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #55 — Settle animation component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drives a brief Y-scale compression (bounce) animation.
|
||||
///
|
||||
/// Inserted on the top card entity of every non-empty pile after a successful
|
||||
/// move (`StateChangedEvent`). Removed automatically when `elapsed >= SETTLE_SECS`.
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct SettleAnim {
|
||||
/// Seconds elapsed since the settle animation began.
|
||||
pub elapsed: f32,
|
||||
}
|
||||
|
||||
/// Computes the Y scale of the settle animation at the given elapsed time.
|
||||
///
|
||||
/// At `elapsed == 0.0` the scale is 1.0 (no compression). At the midpoint
|
||||
/// (`elapsed == SETTLE_SECS / 2`) the scale reaches its minimum (`SETTLE_MIN_SCALE ≈ 0.92`).
|
||||
/// At `elapsed == SETTLE_SECS` the scale returns to 1.0.
|
||||
///
|
||||
/// This is a pure function exposed for unit testing without Bevy.
|
||||
pub fn settle_scale(elapsed: f32) -> f32 {
|
||||
let t = (elapsed / SETTLE_SECS).min(1.0);
|
||||
1.0 - (1.0 - SETTLE_MIN_SCALE) * (t * PI).sin()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #69 — Stagger delay helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the per-card stagger delay in seconds for the given `AnimSpeed`.
|
||||
///
|
||||
/// | `AnimSpeed` | Returned value |
|
||||
/// |---------------|----------------|
|
||||
/// | `Normal` | `DEAL_STAGGER_SECS` (0.04 s) |
|
||||
/// | `Fast` | `DEAL_STAGGER_SECS / 2` (0.02 s) |
|
||||
/// | `Instant` | `0.0` — all cards appear simultaneously |
|
||||
///
|
||||
/// This is a pure function exposed for unit testing without Bevy.
|
||||
pub fn deal_stagger_secs_for_speed(speed: &AnimSpeed) -> f32 {
|
||||
match speed {
|
||||
AnimSpeed::Normal => DEAL_STAGGER_SECS,
|
||||
AnimSpeed::Fast => DEAL_STAGGER_SECS / 2.0,
|
||||
AnimSpeed::Instant => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stagger delay in seconds for card at position `index` during the
|
||||
/// deal animation, given a per-card stagger interval.
|
||||
///
|
||||
/// `delay = index * stagger_secs`
|
||||
///
|
||||
/// This is a pure function exposed for unit testing without Bevy.
|
||||
pub fn deal_stagger_delay(index: usize, stagger_secs: f32) -> f32 {
|
||||
index as f32 * stagger_secs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers the shake, settle, and deal animation systems.
|
||||
pub struct FeedbackAnimPlugin;
|
||||
|
||||
impl Plugin for FeedbackAnimPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
start_shake_anim.after(GameMutation),
|
||||
tick_shake_anim,
|
||||
start_settle_anim.after(GameMutation),
|
||||
tick_settle_anim,
|
||||
start_deal_anim.after(GameMutation),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #54 — Shake systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inserts `ShakeAnim` on all card entities belonging to the destination pile
|
||||
/// when a `MoveRejectedEvent` fires.
|
||||
fn start_shake_anim(
|
||||
mut events: EventReader<MoveRejectedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
let dest_pile = &ev.to;
|
||||
// Collect the card ids that belong to the destination pile.
|
||||
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
||||
let dest_card_ids: Vec<u32> = pile.cards.iter().map(|c| c.id).collect();
|
||||
|
||||
if dest_card_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (entity, card_marker, transform) in card_entities.iter() {
|
||||
if dest_card_ids.contains(&card_marker.card_id) {
|
||||
commands.entity(entity).insert(ShakeAnim {
|
||||
elapsed: 0.0,
|
||||
origin_x: transform.translation.x,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances `ShakeAnim` each frame and removes it once the animation completes.
|
||||
///
|
||||
/// Applies `translation.x = shake_offset(elapsed, origin_x)`. When done,
|
||||
/// restores `translation.x = origin_x` so the card is left at its correct
|
||||
/// position. Skipped while the game is paused.
|
||||
fn tick_shake_anim(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
if anim.elapsed >= SHAKE_SECS {
|
||||
transform.translation.x = anim.origin_x;
|
||||
commands.entity(entity).remove::<ShakeAnim>();
|
||||
} else {
|
||||
transform.translation.x = shake_offset(anim.elapsed, anim.origin_x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #55 — Settle systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inserts `SettleAnim` on the top card of every non-empty pile when
|
||||
/// `StateChangedEvent` fires.
|
||||
fn start_settle_anim(
|
||||
mut events: EventReader<StateChangedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect the id of the top card for each non-empty pile.
|
||||
let top_ids: Vec<u32> = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.filter_map(|p| p.cards.last().map(|c| c.id))
|
||||
.collect();
|
||||
|
||||
for (entity, card_marker) in card_entities.iter() {
|
||||
if top_ids.contains(&card_marker.card_id) {
|
||||
commands.entity(entity).insert(SettleAnim::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances `SettleAnim` each frame and removes it once the animation completes.
|
||||
///
|
||||
/// Applies `transform.scale.y = settle_scale(elapsed)`. Restores scale to 1.0
|
||||
/// when done. Skipped while the game is paused.
|
||||
fn tick_settle_anim(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
if anim.elapsed >= SETTLE_SECS {
|
||||
transform.scale.y = 1.0;
|
||||
commands.entity(entity).remove::<SettleAnim>();
|
||||
} else {
|
||||
transform.scale.y = settle_scale(anim.elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #69 — Deal animation system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inserts `CardAnim` on every card entity when a new game starts, sliding
|
||||
/// each card from the stock pile position to its final position with a
|
||||
/// per-card stagger derived from the current `AnimSpeed` setting.
|
||||
///
|
||||
/// Triggered by `NewGameRequestEvent` (when the new game has `move_count == 0`)
|
||||
/// and fires the deal animation for every card entity currently in the world.
|
||||
/// The stagger is looked up from `SettingsResource` via `deal_stagger_secs_for_speed`.
|
||||
fn start_deal_anim(
|
||||
mut events: EventReader<NewGameRequestEvent>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_entities: Query<(Entity, &Transform), With<CardEntity>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
// Only animate a fresh deal (no moves made yet).
|
||||
if game.0.move_count != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = layout 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 speed = settings.as_ref().map(|s| &s.0.animation_speed);
|
||||
let stagger_secs = speed
|
||||
.map(deal_stagger_secs_for_speed)
|
||||
.unwrap_or(DEAL_STAGGER_SECS);
|
||||
|
||||
for (index, (entity, transform)) in card_entities.iter().enumerate() {
|
||||
let final_pos = transform.translation;
|
||||
commands.entity(entity).insert((
|
||||
Transform::from_translation(stock_start.with_z(final_pos.z)),
|
||||
CardAnim {
|
||||
start: stock_start.with_z(final_pos.z),
|
||||
target: final_pos,
|
||||
elapsed: 0.0,
|
||||
duration: DEAL_SLIDE_SECS,
|
||||
delay: deal_stagger_delay(index, stagger_secs),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Task #54 tests
|
||||
|
||||
#[test]
|
||||
fn shake_offset_at_elapsed_zero_returns_origin_x() {
|
||||
// sin(0) == 0, so displacement must equal origin_x regardless of
|
||||
// SHAKE_AMPLITUDE or envelope.
|
||||
let origin_x = 42.0;
|
||||
let result = shake_offset(0.0, origin_x);
|
||||
assert!(
|
||||
(result - origin_x).abs() < 1e-5,
|
||||
"shake_offset at elapsed=0 must equal origin_x, got {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shake_offset_at_elapsed_shake_secs_returns_origin_x() {
|
||||
// At elapsed == SHAKE_SECS the envelope is 0, so the result must equal
|
||||
// origin_x regardless of the sine value.
|
||||
let origin_x = 100.0;
|
||||
let result = shake_offset(SHAKE_SECS, origin_x);
|
||||
assert!(
|
||||
(result - origin_x).abs() < 1e-5,
|
||||
"shake_offset at elapsed=SHAKE_SECS must equal origin_x (envelope=0), got {result}"
|
||||
);
|
||||
}
|
||||
|
||||
// Task #55 tests
|
||||
|
||||
#[test]
|
||||
fn settle_scale_at_elapsed_zero_is_one() {
|
||||
let scale = settle_scale(0.0);
|
||||
assert!(
|
||||
(scale - 1.0).abs() < 1e-5,
|
||||
"settle_scale at elapsed=0 must be 1.0, got {scale}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settle_scale_at_midpoint_is_approximately_settle_min() {
|
||||
// At elapsed == SETTLE_SECS / 2, sin(PI/2) == 1.0, so scale should be
|
||||
// at the minimum: 1.0 - (1.0 - SETTLE_MIN_SCALE) = SETTLE_MIN_SCALE.
|
||||
let scale = settle_scale(SETTLE_SECS / 2.0);
|
||||
assert!(
|
||||
(scale - SETTLE_MIN_SCALE).abs() < 1e-4,
|
||||
"settle_scale at midpoint must be ~{SETTLE_MIN_SCALE}, got {scale}"
|
||||
);
|
||||
}
|
||||
|
||||
// Task #69 tests
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_delay_zero_index_is_zero() {
|
||||
assert_eq!(deal_stagger_delay(0, DEAL_STAGGER_SECS), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deal_stagger_delay_returns_index_times_stagger() {
|
||||
let stagger = DEAL_STAGGER_SECS;
|
||||
for i in 0..52 {
|
||||
let expected = i as f32 * stagger;
|
||||
let actual = deal_stagger_delay(i, stagger);
|
||||
assert!(
|
||||
(actual - expected).abs() < 1e-6,
|
||||
"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,11 +15,27 @@ use solitaire_data::{delete_game_state_at, game_state_file_path, load_game_state
|
||||
save_game_state_to};
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, GameWonEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||
UndoRequestEvent,
|
||||
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #57 — Confirm-new-game dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the confirm-new-game modal root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ConfirmNewGameScreen;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #58 — Game-over overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the game-over overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct GameOverScreen;
|
||||
|
||||
/// System set for `GamePlugin`'s state-mutating systems. Downstream plugins
|
||||
/// that read the resulting `StateChangedEvent` should schedule themselves
|
||||
/// `.after(GameMutation)` so updates propagate within a single frame.
|
||||
@@ -64,6 +80,7 @@ impl Plugin for GamePlugin {
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<crate::events::CardFlippedEvent>()
|
||||
.add_event::<crate::events::AchievementUnlockedEvent>()
|
||||
.add_event::<InfoToastEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -75,7 +92,12 @@ impl Plugin for GamePlugin {
|
||||
.chain()
|
||||
.in_set(GameMutation),
|
||||
)
|
||||
.add_systems(Update, check_no_moves.after(GameMutation))
|
||||
.add_systems(Update, handle_confirm_input.after(GameMutation))
|
||||
.add_systems(Update, handle_game_over_input.after(GameMutation))
|
||||
.init_resource::<AutoSaveTimer>()
|
||||
.add_systems(Update, tick_elapsed_time)
|
||||
.add_systems(Update, auto_save_game_state)
|
||||
.add_systems(Last, save_game_state_on_exit);
|
||||
}
|
||||
}
|
||||
@@ -127,14 +149,40 @@ fn seed_from_system_time() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_new_game(
|
||||
mut commands: Commands,
|
||||
mut new_game: EventReader<NewGameRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
) {
|
||||
for ev in new_game.read() {
|
||||
// If an active game is in progress, intercept and show a confirm dialog.
|
||||
// A game is "active" when moves have been made and it is not yet won.
|
||||
let needs_confirm = game.0.move_count > 0 && !game.0.is_won;
|
||||
// Skip confirmation if a ConfirmNewGameScreen already exists (prevents duplicates).
|
||||
let confirm_already_open = !confirm_screens.is_empty();
|
||||
if needs_confirm && !confirm_already_open {
|
||||
// Despawn any stale game-over overlay before showing confirm dialog.
|
||||
for entity in &game_over_screens {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
spawn_confirm_dialog(&mut commands, *ev);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Despawn confirm and game-over overlays before starting the new game.
|
||||
for entity in &confirm_screens {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
for entity in &game_over_screens {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
|
||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
// Prefer the draw mode from Settings when starting a fresh game.
|
||||
// Fall back to the current game's draw mode in headless/test contexts
|
||||
@@ -155,14 +203,152 @@ fn handle_new_game(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the confirm-new-game modal overlay.
|
||||
///
|
||||
/// Shown when the player requests a new game while moves have been made and
|
||||
/// the game is not yet won. The overlay stores the original request so the
|
||||
/// `handle_confirm_input` system can replay it on confirmation.
|
||||
fn spawn_confirm_dialog(commands: &mut Commands, original_request: NewGameRequestEvent) {
|
||||
commands
|
||||
.spawn((
|
||||
ConfirmNewGameScreen,
|
||||
// Store the request so we can replay it on confirmation.
|
||||
OriginalNewGameRequest(original_request),
|
||||
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::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(20.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.70)),
|
||||
ZIndex(250),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// Dialog card
|
||||
root.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(40.0)),
|
||||
row_gap: Val::Px(20.0),
|
||||
min_width: Val::Px(360.0),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
|
||||
BorderRadius::all(Val::Px(12.0)),
|
||||
))
|
||||
.with_children(|card| {
|
||||
// Heading
|
||||
card.spawn((
|
||||
Text::new("Abandon current game?"),
|
||||
TextFont { font_size: 30.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
// Button row
|
||||
card.spawn((Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(24.0),
|
||||
..default()
|
||||
},))
|
||||
.with_children(|row| {
|
||||
// Yes button
|
||||
row.spawn((
|
||||
Text::new("Yes (Y)"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.3, 1.0, 0.4)),
|
||||
));
|
||||
// No button
|
||||
row.spawn((
|
||||
Text::new("No (N)"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.4, 0.4)),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Carries the original `NewGameRequestEvent` on the confirm overlay so
|
||||
/// `handle_confirm_input` can replay it with the same seed / mode.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
struct OriginalNewGameRequest(NewGameRequestEvent);
|
||||
|
||||
/// Handles keyboard input while `ConfirmNewGameScreen` is open.
|
||||
///
|
||||
/// `Y` or `Enter` confirms: despawns the overlay and fires `NewGameRequestEvent`.
|
||||
/// `N` or `Escape` cancels: despawns the overlay without starting a new game.
|
||||
fn handle_confirm_input(
|
||||
mut commands: Commands,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
let Ok((entity, original)) = screens.get_single() else {
|
||||
return;
|
||||
};
|
||||
let Some(keys) = keys else {
|
||||
return;
|
||||
};
|
||||
|
||||
let confirmed = keys.just_pressed(KeyCode::KeyY) || keys.just_pressed(KeyCode::Enter);
|
||||
let cancelled = keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape);
|
||||
|
||||
if confirmed {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
// Re-send with move_count already 0 would bypass the dialog next time.
|
||||
// We fire the event — handle_new_game will skip the dialog because
|
||||
// the screen is despawned before the next read.
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: original.0.seed,
|
||||
mode: original.0.mode,
|
||||
});
|
||||
} else if cancelled {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_draw(
|
||||
mut draws: EventReader<DrawRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut flipped: EventWriter<CardFlippedEvent>,
|
||||
) {
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
for _ in draws.read() {
|
||||
// Capture which cards are about to be drawn (top of the stock pile)
|
||||
// so we can fire flip events after they land face-up in the waste.
|
||||
// Only relevant when stock is non-empty; a recycle moves waste back to
|
||||
// stock face-down, so no flip events are needed in that case.
|
||||
let drawn_ids: Vec<u32> = {
|
||||
let stock = game.0.piles.get(&PileType::Stock);
|
||||
match stock {
|
||||
Some(p) if !p.cards.is_empty() => {
|
||||
let draw_count = match game.0.draw_mode {
|
||||
DrawMode::DrawOne => 1_usize,
|
||||
DrawMode::DrawThree => 3_usize,
|
||||
};
|
||||
let n = p.cards.len();
|
||||
let take = n.min(draw_count);
|
||||
// The top `take` cards (at the end of the vec) will be drawn.
|
||||
p.cards[n - take..].iter().map(|c| c.id).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
match game.0.draw() {
|
||||
Ok(()) => {
|
||||
// Fire a flip event for each card that moved from stock to waste.
|
||||
for id in drawn_ids {
|
||||
flipped.send(CardFlippedEvent(id));
|
||||
}
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
Err(e) => warn!("draw rejected: {e}"),
|
||||
@@ -225,17 +411,275 @@ fn handle_undo(
|
||||
mut undos: EventReader<UndoRequestEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
use solitaire_core::error::MoveError;
|
||||
|
||||
for _ in undos.read() {
|
||||
match game.0.undo() {
|
||||
Ok(()) => {
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
Err(MoveError::UndoStackEmpty) => {
|
||||
toast.send(InfoToastEvent("Nothing to undo".to_string()));
|
||||
}
|
||||
Err(e) => warn!("undo rejected: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #29 — No-moves detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `true` if the current game state has at least one legal move.
|
||||
///
|
||||
/// Considers:
|
||||
/// - Any non-empty Stock or Waste pile (draw / recycle is always available).
|
||||
/// - Any face-up card on Waste or Tableau piles that can legally move to any
|
||||
/// Foundation or Tableau destination.
|
||||
pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
// If stock or waste is non-empty, the player can always draw.
|
||||
if !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())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
|
||||
// Check each playable source pile.
|
||||
let sources: Vec<PileType> = {
|
||||
let mut v = vec![PileType::Waste];
|
||||
for i in 0..7_usize {
|
||||
v.push(PileType::Tableau(i));
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
for from in &sources {
|
||||
let Some(from_pile) = game.piles.get(from) else { continue };
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||
|
||||
// Check foundations.
|
||||
for &suit in &suits {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, dest_pile, suit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check tableau piles.
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if dest == *from {
|
||||
continue;
|
||||
}
|
||||
if let Some(dest_pile) = game.piles.get(&dest) {
|
||||
if can_place_on_tableau(card, dest_pile) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// After each `StateChangedEvent`, check if the game has no legal moves.
|
||||
///
|
||||
/// When stuck (no legal moves and game not won), fires `InfoToastEvent` and
|
||||
/// spawns a `GameOverScreen` overlay. The overlay is despawned automatically
|
||||
/// when `has_legal_moves` returns true again (e.g. after undo) or when the
|
||||
/// game is won.
|
||||
fn check_no_moves(
|
||||
mut commands: Commands,
|
||||
mut events: EventReader<StateChangedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
mut already_fired: Local<bool>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
) {
|
||||
// Reset the debounce flag on every state change so if something changes
|
||||
// we re-evaluate on the next state change.
|
||||
let had_event = events.read().next().is_some();
|
||||
// Drain remaining events to avoid leaking.
|
||||
events.clear();
|
||||
|
||||
if !had_event {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset debounce whenever the state changes.
|
||||
*already_fired = false;
|
||||
|
||||
// Despawn game-over overlay whenever moves become available again or game is won.
|
||||
let moves_ok = has_legal_moves(&game.0);
|
||||
if moves_ok || game.0.is_won {
|
||||
for entity in &game_over_screens {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
}
|
||||
|
||||
if game.0.is_won {
|
||||
return;
|
||||
}
|
||||
|
||||
if !moves_ok && !*already_fired {
|
||||
toast.send(InfoToastEvent(
|
||||
"No moves available \u{2014} press D to draw or N for a new game".to_string(),
|
||||
));
|
||||
*already_fired = true;
|
||||
// Only spawn the overlay if one does not already exist.
|
||||
if game_over_screens.is_empty() {
|
||||
spawn_game_over_screen(&mut commands, game.0.score);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the full-screen game-over overlay with score display and action hints.
|
||||
///
|
||||
/// The background is intentionally semi-transparent (alpha 0.6) so the stuck
|
||||
/// card layout remains visible behind the dialog.
|
||||
fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
|
||||
commands
|
||||
.spawn((
|
||||
GameOverScreen,
|
||||
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::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(20.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(40.0)),
|
||||
row_gap: Val::Px(16.0),
|
||||
min_width: Val::Px(340.0),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)),
|
||||
BorderRadius::all(Val::Px(12.0)),
|
||||
))
|
||||
.with_children(|card| {
|
||||
// Header — explains why the overlay appeared.
|
||||
card.spawn((
|
||||
Text::new("No more moves available"),
|
||||
TextFont { font_size: 36.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.4, 0.1)),
|
||||
));
|
||||
// Score
|
||||
card.spawn((
|
||||
Text::new(format!("Score: {score}")),
|
||||
TextFont { font_size: 24.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
// Action hints — stacked vertically for legibility.
|
||||
card.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(8.0),
|
||||
margin: UiRect::top(Val::Px(8.0)),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|hints| {
|
||||
hints.spawn((
|
||||
Text::new("Press N or Escape for a new game"),
|
||||
TextFont { font_size: 20.0, ..default() },
|
||||
TextColor(Color::srgb(0.3, 1.0, 0.4)),
|
||||
));
|
||||
hints.spawn((
|
||||
Text::new("Press G to forfeit (counts as a loss)"),
|
||||
TextFont { font_size: 20.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.6, 0.2)),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles keyboard input while `GameOverScreen` is open.
|
||||
///
|
||||
/// `N` or `Escape` fires `NewGameRequestEvent` (which will trigger the confirm
|
||||
/// dialog if moves have been made). `U` fires `UndoRequestEvent` and despawns
|
||||
/// the overlay — the `check_no_moves` system will re-show it on the next
|
||||
/// `StateChangedEvent` if the undo did not restore any legal moves.
|
||||
fn handle_game_over_input(
|
||||
mut commands: Commands,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
screens: Query<Entity, With<GameOverScreen>>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
mut undo: EventWriter<UndoRequestEvent>,
|
||||
) {
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(keys) = keys else {
|
||||
return;
|
||||
};
|
||||
|
||||
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
} else if keys.just_pressed(KeyCode::KeyU) {
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
undo.send(UndoRequestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0;
|
||||
|
||||
/// Accumulated real-world seconds since the last auto-save. Exposed as a
|
||||
/// `Resource` so tests can pre-seed it past the threshold without needing to
|
||||
/// control `Time::delta_secs()`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct AutoSaveTimer(pub f32);
|
||||
|
||||
/// Periodically saves game state every 30 real-world seconds while a game is
|
||||
/// in progress. The timer uses real delta time (not game elapsed_seconds) so
|
||||
/// it keeps ticking even if the game clock is paused.
|
||||
fn auto_save_game_state(
|
||||
time: Res<Time>,
|
||||
game: Res<GameStateResource>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
mut timer: ResMut<AutoSaveTimer>,
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
) {
|
||||
// Don't save if paused, game is won, or no moves have been made yet.
|
||||
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 {
|
||||
return;
|
||||
}
|
||||
timer.0 += time.delta_secs();
|
||||
if timer.0 >= AUTO_SAVE_INTERVAL_SECS {
|
||||
timer.0 -= AUTO_SAVE_INTERVAL_SECS;
|
||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else { return };
|
||||
if let Err(e) = save_game_state_to(p, &game.0) {
|
||||
warn!("game_state: auto-save failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-schedule system: persists the current game state on `AppExit` so the
|
||||
/// player can resume where they left off. Won games are not saved (the
|
||||
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
|
||||
@@ -519,6 +963,49 @@ mod tests {
|
||||
assert_eq!(fired[0].0, 900, "event must carry the flipped card's id");
|
||||
}
|
||||
|
||||
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
|
||||
#[test]
|
||||
fn auto_save_writes_after_30_seconds() {
|
||||
use solitaire_data::load_game_state_from;
|
||||
|
||||
let path = tmp_gs_path("auto_save_30s");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut app = test_app(42);
|
||||
app.insert_resource(GameStatePath(Some(path.clone())));
|
||||
// Give the game one move so move_count > 0 (auto-save guard).
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.move_count = 1;
|
||||
|
||||
// Pre-seed the timer just past the threshold. The system will trigger
|
||||
// on the very next update() without needing to control Time::delta_secs().
|
||||
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
|
||||
app.update();
|
||||
|
||||
assert!(path.exists(), "auto-save file must exist after timer crosses threshold");
|
||||
let loaded = load_game_state_from(&path).expect("file must be loadable");
|
||||
assert_eq!(loaded.seed, 42);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// auto_save_game_state does NOT write to disk when no moves have been made.
|
||||
#[test]
|
||||
fn auto_save_skips_when_no_moves() {
|
||||
let path = tmp_gs_path("auto_save_skip");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut app = test_app(99);
|
||||
app.insert_resource(GameStatePath(Some(path.clone())));
|
||||
// move_count stays at 0 (fresh game); timer is past threshold.
|
||||
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
|
||||
app.update();
|
||||
|
||||
assert!(!path.exists(), "auto-save must not fire when move_count == 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
@@ -560,4 +1047,330 @@ mod tests {
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert!(fired.is_empty(), "no flip event when exposed card was already face-up");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #29 — has_legal_moves pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_returns_true_when_stock_nonempty() {
|
||||
// A fresh game has 24 cards in stock — draw is always available.
|
||||
let game = GameState::new(42, DrawMode::DrawOne);
|
||||
assert!(has_legal_moves(&game), "draw is always available when stock is non-empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Empty stock and waste so draw is NOT available.
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||
});
|
||||
|
||||
assert!(has_legal_moves(&game), "Ace can always go to an empty foundation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_returns_false_when_stuck() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Empty stock and waste.
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all foundations and all tableau.
|
||||
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();
|
||||
}
|
||||
|
||||
// Place a Two of Clubs with no legal destination.
|
||||
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true,
|
||||
});
|
||||
|
||||
assert!(!has_legal_moves(&game), "Two of Clubs with empty board has no legal move");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #57 — Confirm-new-game dialog tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper that also initialises `ButtonInput<KeyCode>` so the keyboard
|
||||
/// systems do not panic in MinimalPlugins environments.
|
||||
fn test_app_with_input(seed: u64) -> App {
|
||||
let mut app = test_app(seed);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_request_with_moves_spawns_confirm_dialog() {
|
||||
let mut app = test_app_with_input(42);
|
||||
// Simulate an active game with moves made.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 5;
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: None, mode: None });
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&ConfirmNewGameScreen>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 1, "ConfirmNewGameScreen must be spawned when move_count > 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_request_on_fresh_game_skips_confirm() {
|
||||
let mut app = test_app_with_input(42);
|
||||
// move_count stays at 0 (fresh game).
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.move_count,
|
||||
0,
|
||||
"test assumes a fresh game with no moves"
|
||||
);
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: None, mode: None });
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&ConfirmNewGameScreen>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 0, "ConfirmNewGameScreen must NOT appear for a fresh game");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #58 — Game-over overlay tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn game_over_screen_absent_when_moves_available() {
|
||||
// A fresh game always has moves (stock is non-empty).
|
||||
let mut app = test_app_with_input(42);
|
||||
app.world_mut().send_event(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 0, "GameOverScreen must not appear when moves are available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_over_screen_spawns_when_stuck() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state: empty all piles + stock/waste, leave only a
|
||||
// Two of Clubs on tableau 0 with no legal destination.
|
||||
{
|
||||
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();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 1, "GameOverScreen must appear when no legal moves exist");
|
||||
}
|
||||
|
||||
/// Verify that the game-over overlay contains the expected header text and
|
||||
/// action-hint strings so players understand why the overlay appeared and
|
||||
/// what keys to press.
|
||||
#[test]
|
||||
fn game_over_screen_text_content() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state identical to `game_over_screen_spawns_when_stuck`.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().send_event(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Collect all Text values that are children of the GameOverScreen entity tree.
|
||||
let texts: Vec<String> = app
|
||||
.world_mut()
|
||||
.query::<&Text>()
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "No more moves available"),
|
||||
"header must read 'No more moves available'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Press N or Escape for a new game"),
|
||||
"hint 1 must read 'Press N or Escape for a new game'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Press G to forfeit (counts as a loss)"),
|
||||
"hint 2 must read 'Press G to forfeit (counts as a loss)'; found: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #56 — Escape dismisses GameOverScreen and starts new game
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing Escape while `GameOverScreen` is visible must fire
|
||||
/// `NewGameRequestEvent` — identical behaviour to pressing N.
|
||||
#[test]
|
||||
fn escape_on_game_over_screen_fires_new_game_request() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state so GameOverScreen spawns.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
gs.0.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
}
|
||||
gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
app.world_mut().send_event(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Confirm the overlay is present.
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"GameOverScreen must be present before pressing Escape"
|
||||
);
|
||||
|
||||
// Clear the NewGameRequestEvent queue so we start with a clean slate.
|
||||
app.world_mut().resource_mut::<Events<NewGameRequestEvent>>().clear();
|
||||
|
||||
// Simulate Escape press.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.clear();
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
app.update();
|
||||
|
||||
// NewGameRequestEvent must have been fired.
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
assert!(
|
||||
reader.read(events).next().is_some(),
|
||||
"Escape on GameOverScreen must fire NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #48 — Undo with empty stack fires InfoToastEvent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Sending `UndoRequestEvent` on a fresh game (empty undo stack) must fire
|
||||
/// exactly one `InfoToastEvent` with the message "Nothing to undo".
|
||||
#[test]
|
||||
fn undo_on_empty_stack_fires_info_toast() {
|
||||
let mut app = test_app(42);
|
||||
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
|
||||
app.world_mut().send_event(UndoRequestEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let fired: Vec<_> = reader.read(events).collect();
|
||||
assert_eq!(fired.len(), 1, "exactly one InfoToastEvent must fire on empty-stack undo");
|
||||
assert_eq!(
|
||||
fired[0].0,
|
||||
"Nothing to undo",
|
||||
"toast message must be 'Nothing to undo'"
|
||||
);
|
||||
}
|
||||
|
||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||
#[test]
|
||||
fn undo_after_draw_does_not_fire_info_toast() {
|
||||
let mut app = test_app(42);
|
||||
// Make a move so the undo stack is non-empty.
|
||||
app.world_mut().send_event(DrawRequestEvent);
|
||||
app.update();
|
||||
// Clear events from the draw so we start with a clean slate.
|
||||
app.world_mut().resource_mut::<Events<InfoToastEvent>>().clear();
|
||||
|
||||
app.world_mut().send_event(UndoRequestEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let fired: Vec<_> = reader.read(events).collect();
|
||||
assert!(
|
||||
fired.is_empty(),
|
||||
"no InfoToastEvent must fire on a successful undo"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Toggleable on-screen help / cheat sheet showing keyboard bindings.
|
||||
//!
|
||||
//! Press **H** (or `?`) to toggle. Listed shortcuts are grouped by intent —
|
||||
//! Press **F1** to toggle. Listed shortcuts are grouped by intent —
|
||||
//! gameplay, modes, and overlays.
|
||||
|
||||
use bevy::prelude::*;
|
||||
@@ -22,8 +22,7 @@ fn toggle_help_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screens: Query<Entity, With<HelpScreen>>,
|
||||
) {
|
||||
let pressed_help = keys.just_pressed(KeyCode::KeyH) || keys.just_pressed(KeyCode::Slash);
|
||||
if !pressed_help {
|
||||
if !keys.just_pressed(KeyCode::F1) {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
@@ -55,11 +54,12 @@ fn spawn_help_screen(commands: &mut Commands) {
|
||||
" A Achievements".to_string(),
|
||||
" L Leaderboard".to_string(),
|
||||
" O Settings".to_string(),
|
||||
" H or ? This help screen".to_string(),
|
||||
" F1 This help screen".to_string(),
|
||||
" F11 Toggle fullscreen".to_string(),
|
||||
" Esc Pause / resume".to_string(),
|
||||
" [ / ] SFX volume down / up".to_string(),
|
||||
String::new(),
|
||||
"Press H or ? to close".to_string(),
|
||||
"Press F1 to close".to_string(),
|
||||
];
|
||||
|
||||
commands
|
||||
@@ -107,11 +107,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_h_spawns_help_screen() {
|
||||
fn pressing_f1_spawns_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyH);
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
@@ -124,18 +124,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_h_twice_closes_help_screen() {
|
||||
fn pressing_f1_twice_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyH);
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyH);
|
||||
input.release(KeyCode::F1);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyH);
|
||||
input.press(KeyCode::F1);
|
||||
}
|
||||
app.update();
|
||||
|
||||
@@ -147,21 +147,4 @@ mod tests {
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_slash_also_toggles_help() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Slash);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HelpScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Toggleable main menu overlay showing the current game mode and a full
|
||||
//! keyboard shortcut reference.
|
||||
//!
|
||||
//! Press **M** to open or close the overlay.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Marker component on the home-menu overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HomeScreen;
|
||||
|
||||
/// Registers the M-key toggle and the overlay spawn/despawn logic.
|
||||
pub struct HomePlugin;
|
||||
|
||||
impl Plugin for HomePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, toggle_home_screen);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_home_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
game: Res<GameStateResource>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyM) {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
} else {
|
||||
spawn_home_screen(&mut commands, &game);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the full-window home-menu overlay derived from the current `game` state.
|
||||
fn spawn_home_screen(commands: &mut Commands, game: &GameStateResource) {
|
||||
let mode_label = match game.0.mode {
|
||||
GameMode::Classic => "Classic",
|
||||
GameMode::Zen => "Zen",
|
||||
GameMode::Challenge => "Challenge",
|
||||
GameMode::TimeAttack => "Time Attack",
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
HomeScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(6.0),
|
||||
padding: UiRect::all(Val::Px(24.0)),
|
||||
overflow: Overflow::clip(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// Title
|
||||
root.spawn((
|
||||
Text::new("Solitaire Quest"),
|
||||
TextFont { font_size: 48.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.3)),
|
||||
));
|
||||
|
||||
// Mode subtitle
|
||||
root.spawn((
|
||||
Text::new(format!("Current mode: {mode_label}")),
|
||||
TextFont { font_size: 28.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.8, 0.8)),
|
||||
));
|
||||
|
||||
// Spacer
|
||||
root.spawn(Node {
|
||||
height: Val::Px(8.0),
|
||||
..default()
|
||||
});
|
||||
|
||||
// "Game Controls" section header
|
||||
root.spawn((
|
||||
Text::new("Game Controls"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.9, 0.9, 0.9)),
|
||||
));
|
||||
|
||||
spawn_shortcut_row(root, "N", "New game (N again confirms)");
|
||||
spawn_shortcut_row(root, "U", "Undo last move");
|
||||
spawn_shortcut_row(root, "Space / D", "Draw from stock");
|
||||
spawn_shortcut_row(root, "G", "Forfeit current game");
|
||||
spawn_shortcut_row(root, "Tab", "Cycle hint highlight");
|
||||
spawn_shortcut_row(root, "Enter", "Auto-complete if available");
|
||||
|
||||
// Spacer
|
||||
root.spawn(Node {
|
||||
height: Val::Px(8.0),
|
||||
..default()
|
||||
});
|
||||
|
||||
// "Screens" section header
|
||||
root.spawn((
|
||||
Text::new("Screens"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.9, 0.9, 0.9)),
|
||||
));
|
||||
|
||||
spawn_shortcut_row(root, "M", "Main menu (this screen)");
|
||||
spawn_shortcut_row(root, "S", "Statistics");
|
||||
spawn_shortcut_row(root, "A", "Achievements");
|
||||
spawn_shortcut_row(root, "O", "Settings");
|
||||
spawn_shortcut_row(root, "P", "Profile");
|
||||
spawn_shortcut_row(root, "F1", "Help");
|
||||
spawn_shortcut_row(root, "F11", "Toggle fullscreen");
|
||||
spawn_shortcut_row(root, "Esc", "Pause / Resume");
|
||||
|
||||
// Spacer
|
||||
root.spawn(Node {
|
||||
height: Val::Px(16.0),
|
||||
..default()
|
||||
});
|
||||
|
||||
// Dismiss hint
|
||||
root.spawn((
|
||||
Text::new("Press M to close"),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.55, 0.55)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_shortcut_row(parent: &mut ChildBuilder, key: &str, action: &str) {
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
min_width: Val::Px(380.0),
|
||||
column_gap: Val::Px(16.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(key.to_string()),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.4)),
|
||||
Node {
|
||||
min_width: Val::Px(120.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
row.spawn((
|
||||
Text::new(action.to_string()),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.85)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HomePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_m_spawns_home_screen() {
|
||||
let mut app = headless_app();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyM);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_m_twice_closes_home_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyM);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyM);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyM);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Persistent in-game HUD: score, move count, elapsed time, and mode badge.
|
||||
//! Persistent in-game HUD: score, move count, elapsed time, mode badge,
|
||||
//! daily-challenge constraint, and undo count.
|
||||
//!
|
||||
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
|
||||
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
||||
@@ -6,27 +7,81 @@
|
||||
//! without a separate tick system.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::InfoToastEvent;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
/// Marker on the score text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct HudScore;
|
||||
pub struct HudScore;
|
||||
|
||||
/// Marker on the move-count text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct HudMoves;
|
||||
pub struct HudMoves;
|
||||
|
||||
/// Marker on the elapsed-time text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct HudTime;
|
||||
pub struct HudTime;
|
||||
|
||||
/// Marker on the mode badge text node.
|
||||
#[derive(Component, Debug)]
|
||||
struct HudMode;
|
||||
pub struct HudMode;
|
||||
|
||||
/// Marker on the daily-challenge constraint text node.
|
||||
///
|
||||
/// Displays the active goal (time limit or score target) when a daily challenge
|
||||
/// is in progress. Empty string when no challenge is active or the game is won.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudChallenge;
|
||||
|
||||
/// Marker on the undo-count text node.
|
||||
///
|
||||
/// Shows how many undos have been used this game. Displayed in amber when
|
||||
/// `undo_count > 0` because using undo blocks the no-undo achievement.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudUndos;
|
||||
|
||||
/// Marker on the auto-complete badge text node.
|
||||
///
|
||||
/// Displays `"AUTO"` in green while `AutoCompleteState.active` is true;
|
||||
/// empty string otherwise.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudAutoComplete;
|
||||
|
||||
/// Marker on the stock-recycle counter text node.
|
||||
///
|
||||
/// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw
|
||||
/// mode, so the player can track stock recycling in both Draw-One and
|
||||
/// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string)
|
||||
/// until the first recycle occurs.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudRecycles;
|
||||
|
||||
/// Marker on the draw-cycle indicator text node.
|
||||
///
|
||||
/// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the
|
||||
/// number of cards that will be drawn on the next stock click
|
||||
/// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty
|
||||
/// (recycle available). Hidden (empty string) in Draw-One mode or after the
|
||||
/// game is won.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudDrawCycle;
|
||||
|
||||
/// Marker on the keyboard-selection indicator text node.
|
||||
///
|
||||
/// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty
|
||||
/// string when no pile is selected. Uses a light-yellow colour so it stands
|
||||
/// out from the other white HUD items.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudSelection;
|
||||
|
||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||
const Z_HUD: i32 = 50;
|
||||
@@ -36,7 +91,9 @@ pub struct HudPlugin;
|
||||
impl Plugin for HudPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, spawn_hud)
|
||||
.add_systems(Update, update_hud.after(GameMutation));
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,28 +116,212 @@ fn spawn_hud(mut commands: Commands) {
|
||||
.with_children(|b| {
|
||||
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
|
||||
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
|
||||
b.spawn((HudTime, Text::new("0:00"), font, white));
|
||||
b.spawn((HudTime, Text::new("0:00"), font.clone(), white));
|
||||
b.spawn((
|
||||
HudMode,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.25)),
|
||||
));
|
||||
// Daily-challenge constraint (hidden until a challenge is active).
|
||||
b.spawn((
|
||||
HudChallenge,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.4, 0.9, 1.0)),
|
||||
));
|
||||
// Undo counter (white by default; turns amber when undos are used).
|
||||
b.spawn((
|
||||
HudUndos,
|
||||
Text::new(""),
|
||||
font.clone(),
|
||||
white,
|
||||
));
|
||||
// Auto-complete badge (green "AUTO" when sequence is running).
|
||||
b.spawn((
|
||||
HudAutoComplete,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(0.2, 0.9, 0.3)),
|
||||
));
|
||||
// Recycle counter — hidden until the first recycle in either draw mode.
|
||||
b.spawn((
|
||||
HudRecycles,
|
||||
Text::new(""),
|
||||
font.clone(),
|
||||
white,
|
||||
));
|
||||
// Draw-cycle indicator — only visible in Draw-Three mode.
|
||||
b.spawn((
|
||||
HudDrawCycle,
|
||||
Text::new(""),
|
||||
font,
|
||||
TextColor(Color::srgb(0.7, 0.85, 1.0)),
|
||||
));
|
||||
// Keyboard-selection indicator — shows which pile is Tab-selected.
|
||||
b.spawn((
|
||||
HudSelection,
|
||||
Text::new(""),
|
||||
TextFont { font_size: 17.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 1.0, 0.5)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
|
||||
///
|
||||
/// For example `format_time_limit(300)` returns `"5:00"`.
|
||||
pub fn format_time_limit(secs: u64) -> String {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn update_hud(
|
||||
game: Res<GameStateResource>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
mut score_q: Query<&mut Text, (With<HudScore>, Without<HudMoves>, Without<HudTime>, Without<HudMode>)>,
|
||||
mut moves_q: Query<&mut Text, (With<HudMoves>, Without<HudScore>, Without<HudTime>, Without<HudMode>)>,
|
||||
mut time_q: Query<&mut Text, (With<HudTime>, Without<HudScore>, Without<HudMoves>, Without<HudMode>)>,
|
||||
mut mode_q: Query<&mut Text, (With<HudMode>, Without<HudScore>, Without<HudMoves>, Without<HudTime>)>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
auto_complete: Option<Res<AutoCompleteState>>,
|
||||
mut score_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut moves_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudMoves>,
|
||||
Without<HudScore>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut time_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudTime>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut mode_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudMode>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut challenge_q: Query<
|
||||
(&mut Text, &mut TextColor),
|
||||
(
|
||||
With<HudChallenge>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut undos_q: Query<
|
||||
(&mut Text, &mut TextColor),
|
||||
(
|
||||
With<HudUndos>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut auto_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudAutoComplete>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut recycles_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudRecycles>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut draw_cycle_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudDrawCycle>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
||||
|
||||
// Score, moves, and mode only need updating when the game state changes.
|
||||
// Score, moves, mode, challenge, and undos only need updating when game state changes.
|
||||
if game.is_changed() {
|
||||
let g = &game.0;
|
||||
let is_zen = g.mode == GameMode::Zen;
|
||||
@@ -106,10 +347,60 @@ fn update_hud(
|
||||
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Daily challenge constraint (with time-low colour warning) ---
|
||||
if let Ok((mut t, mut color)) = challenge_q.get_single_mut() {
|
||||
if g.is_won {
|
||||
**t = String::new();
|
||||
} else if let Some(dc) = daily.as_deref() {
|
||||
**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 {
|
||||
**t = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undo count ---
|
||||
if let Ok((mut t, mut color)) = undos_q.get_single_mut() {
|
||||
let count = g.undo_count;
|
||||
if count == 0 {
|
||||
**t = String::new();
|
||||
*color = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
|
||||
} else {
|
||||
**t = format!("Undos: {count}");
|
||||
// Amber warning: using undo blocks the no-undo achievement.
|
||||
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||
if let Ok(mut t) = recycles_q.get_single_mut() {
|
||||
**t = if g.recycle_count > 0 {
|
||||
format!("Recycles: {}", g.recycle_count)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
|
||||
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
||||
if let Ok(mut t) = draw_cycle_q.get_single_mut() {
|
||||
**t = if g.is_won || g.draw_mode != DrawMode::DrawThree {
|
||||
// Hide when not in Draw-Three or after the game is won.
|
||||
String::new()
|
||||
} else {
|
||||
let stock_len = g.piles[&solitaire_core::pile::PileType::Stock].cards.len();
|
||||
let next_draw = stock_len.min(3);
|
||||
format!("Cycle: {next_draw}/3")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Time display: show Time Attack countdown every frame when active;
|
||||
// Zen mode suppresses the timer per spec ("No timer").
|
||||
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
|
||||
// every frame so it disappears immediately on the frame Z is pressed.
|
||||
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||
let is_zen = game.0.mode == GameMode::Zen;
|
||||
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||
@@ -127,12 +418,103 @@ fn update_hud(
|
||||
**t = format!("{m}:{s:02}");
|
||||
}
|
||||
}
|
||||
} else if is_zen && game.is_changed() {
|
||||
// Clear the time display when entering Zen mode.
|
||||
} else if is_zen {
|
||||
// Clear the time display immediately whenever Zen mode is active —
|
||||
// do not guard on game.is_changed() so it clears on the same frame
|
||||
// the player presses Z, before any move is made.
|
||||
if let Ok(mut t) = time_q.get_single_mut() {
|
||||
**t = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto-complete badge ---
|
||||
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
|
||||
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
||||
if ac_changed || game.is_changed() {
|
||||
if let Ok(mut t) = auto_q.get_single_mut() {
|
||||
**t = if ac_active {
|
||||
"AUTO".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
||||
///
|
||||
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
|
||||
/// or an empty string when no pile is selected. Runs every frame so the
|
||||
/// indicator stays in sync with the selection resource.
|
||||
fn update_selection_hud(
|
||||
selection: Option<Res<SelectionState>>,
|
||||
mut q: Query<&mut Text, With<HudSelection>>,
|
||||
) {
|
||||
let Ok(mut t) = q.get_single_mut() else { return };
|
||||
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||
None => String::new(),
|
||||
Some(PileType::Waste) => "▶ Waste".to_string(),
|
||||
Some(PileType::Stock) => "▶ Stock".to_string(),
|
||||
Some(PileType::Foundation(suit)) => {
|
||||
let s = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
Suit::Hearts => "Hearts",
|
||||
Suit::Spades => "Spades",
|
||||
};
|
||||
format!("▶ {s} Foundation")
|
||||
}
|
||||
Some(PileType::Tableau(idx)) => format!("▶ Column {}", idx + 1),
|
||||
};
|
||||
**t = label;
|
||||
}
|
||||
|
||||
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
||||
/// to debounce so the toast only appears on the leading edge.
|
||||
fn announce_auto_complete(
|
||||
auto_complete: Option<Res<AutoCompleteState>>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
mut was_active: Local<bool>,
|
||||
) {
|
||||
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||
if now_active && !*was_active {
|
||||
toast.send(InfoToastEvent("Auto-completing...".to_string()));
|
||||
}
|
||||
*was_active = now_active;
|
||||
}
|
||||
|
||||
/// Builds the HUD text for the active daily challenge constraints.
|
||||
///
|
||||
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
|
||||
/// score target is set, or an empty string when the challenge has no extra
|
||||
/// constraints.
|
||||
fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||
if let Some(secs) = dc.max_time_secs {
|
||||
format!("Limit: {}", format_time_limit(secs))
|
||||
} else if let Some(score) = dc.target_score {
|
||||
format!("Goal: {score} pts")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -140,6 +522,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use chrono::Local;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
@@ -220,4 +603,256 @@ mod tests {
|
||||
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
||||
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_time_limit (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_300_is_5_00() {
|
||||
assert_eq!(format_time_limit(300), "5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_zero() {
|
||||
assert_eq!(format_time_limit(0), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_time_limit_pads_seconds() {
|
||||
assert_eq!(format_time_limit(65), "1:05");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// challenge_hud_text (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_time_limit() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_shows_score_goal() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
};
|
||||
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_text_empty_when_no_constraints() {
|
||||
let dc = DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 1,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: None,
|
||||
};
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_empty_when_no_daily_resource() {
|
||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: Some("Win fast".to_string()),
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_hud_clears_on_win() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().insert_resource(DailyChallengeResource {
|
||||
date: Local::now().date_naive(),
|
||||
seed: 42,
|
||||
goal_description: None,
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
// Mark the game as won — HudChallenge should be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.is_won = true;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudUndos in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn undos_hud_empty_at_game_start() {
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undos_hud_shows_count_after_undo() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.undo_count = 3;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudAutoComplete in-app tests (Task #56)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn headless_app_with_auto_complete() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(HudPlugin);
|
||||
app.init_resource::<AutoCompleteState>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_shows_auto_when_active() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
|
||||
// Also trigger game state change so the update fires.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.move_count += 1;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_badge_empty_when_inactive() {
|
||||
let mut app = headless_app_with_auto_complete();
|
||||
// active is false by default.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.move_count += 1;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HudRecycles in-app tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_one_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One, no recycles yet — text must be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawMode::DrawOne);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_hidden_when_zero_in_draw_three_mode() {
|
||||
let mut app = headless_app();
|
||||
// Draw-Three, no recycles yet — text must also be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(42, DrawMode::DrawThree);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_three() {
|
||||
let mut app = headless_app();
|
||||
let mut gs = GameState::new(42, DrawMode::DrawThree);
|
||||
gs.recycle_count = 3;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycles_hud_shows_count_draw_one() {
|
||||
let mut app = headless_app();
|
||||
// Draw-One with recycle_count > 0 must now show the counter too.
|
||||
let mut gs = GameState::new(42, DrawMode::DrawOne);
|
||||
gs.recycle_count = 2;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 = gs;
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudRecycles>(&mut app), "Recycles: 2");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::settings::SyncBackend;
|
||||
use solitaire_sync::LeaderboardEntry;
|
||||
|
||||
use crate::events::InfoToastEvent;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
|
||||
@@ -214,13 +215,22 @@ fn handle_opt_in_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the opt-in task; logs on error, clears on completion.
|
||||
fn poll_opt_in_task(mut task_res: ResMut<OptInTask>) {
|
||||
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
||||
fn poll_opt_in_task(
|
||||
mut task_res: ResMut<OptInTask>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else { return };
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
task_res.0 = None;
|
||||
if let Err(e) = result {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
toast.send(InfoToastEvent("Opted in to leaderboard".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-in failed: {e}");
|
||||
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,13 +255,22 @@ fn handle_opt_out_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the opt-out task; logs on error, clears on completion.
|
||||
fn poll_opt_out_task(mut task_res: ResMut<OptOutTask>) {
|
||||
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
||||
fn poll_opt_out_task(
|
||||
mut task_res: ResMut<OptOutTask>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else { return };
|
||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
task_res.0 = None;
|
||||
if let Err(e) = result {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
toast.send(InfoToastEvent("Opted out of leaderboard".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-out failed: {e}");
|
||||
toast.send(InfoToastEvent("Leaderboard update failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,25 +5,31 @@ pub mod animation_plugin;
|
||||
pub mod auto_complete_plugin;
|
||||
pub mod audio_plugin;
|
||||
pub mod card_plugin;
|
||||
pub mod feedback_anim_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cursor_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
pub mod help_plugin;
|
||||
pub mod home_plugin;
|
||||
pub mod hud_plugin;
|
||||
pub mod leaderboard_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod onboarding_plugin;
|
||||
pub mod pause_plugin;
|
||||
pub mod profile_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod selection_plugin;
|
||||
pub mod stats_plugin;
|
||||
pub mod sync_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
pub use challenge_plugin::{
|
||||
@@ -34,30 +40,41 @@ pub use daily_challenge_plugin::{
|
||||
};
|
||||
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
|
||||
pub use feedback_anim_plugin::{
|
||||
deal_stagger_delay, deal_stagger_secs_for_speed, shake_offset, settle_scale,
|
||||
FeedbackAnimPlugin, SettleAnim, ShakeAnim,
|
||||
};
|
||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin, HintHighlight, RightClickHighlight};
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
pub use events::{
|
||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent,
|
||||
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
|
||||
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||
};
|
||||
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
|
||||
pub use game_plugin::{ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath};
|
||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||
pub use hud_plugin::HudPlugin;
|
||||
pub use home_plugin::{HomePlugin, HomeScreen};
|
||||
pub use hud_plugin::{HudAutoComplete, HudPlugin};
|
||||
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
||||
pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};
|
||||
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||
pub use settings_plugin::{
|
||||
SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP,
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
||||
pub use time_attack_plugin::{
|
||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||
};
|
||||
pub use win_summary_plugin::{
|
||||
format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! First-run onboarding banner.
|
||||
//!
|
||||
//! On startup, if `Settings.first_run_complete` is `false`, spawn a centered
|
||||
//! welcome banner pointing at the **H**/`?` cheat sheet. The first key or
|
||||
//! welcome banner pointing at the **F1** cheat sheet. The first key or
|
||||
//! mouse-button press dismisses it, sets the flag, and persists settings —
|
||||
//! so returning players never see it again.
|
||||
//!
|
||||
//! **Key highlights** (#49): The key names **D** and **U** inside the
|
||||
//! instructional text are rendered in a bright orange colour via `TextSpan`
|
||||
//! children tagged with `KeyHighlightSpan`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -16,6 +20,18 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
#[derive(Component, Debug)]
|
||||
pub struct OnboardingScreen;
|
||||
|
||||
/// Marker on `TextSpan` entities that display a key name (D, U …) in the
|
||||
/// onboarding banner. Colour distinct from body text; usable by tests and any
|
||||
/// future flash-animation system.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct KeyHighlightSpan;
|
||||
|
||||
/// Body text colour — golden yellow matching the rest of the UI.
|
||||
const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0);
|
||||
|
||||
/// Bright orange used for key-name spans so they stand out from body text.
|
||||
const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1);
|
||||
|
||||
pub struct OnboardingPlugin;
|
||||
|
||||
impl Plugin for OnboardingPlugin {
|
||||
@@ -66,21 +82,6 @@ fn persist(path: Option<&Option<PathBuf>>, settings: &Settings) {
|
||||
}
|
||||
|
||||
fn spawn_onboarding_screen(commands: &mut Commands) {
|
||||
let lines: Vec<(String, f32)> = vec![
|
||||
("Welcome to Solitaire Quest!".to_string(), 40.0),
|
||||
(String::new(), 20.0),
|
||||
(
|
||||
"Drag cards between piles. Press D to draw, U to undo.".to_string(),
|
||||
22.0,
|
||||
),
|
||||
(
|
||||
"Press H or ? at any time to see the full controls.".to_string(),
|
||||
22.0,
|
||||
),
|
||||
(String::new(), 20.0),
|
||||
("Press any key to begin".to_string(), 20.0),
|
||||
];
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
OnboardingScreen,
|
||||
@@ -100,16 +101,50 @@ fn spawn_onboarding_screen(commands: &mut Commands) {
|
||||
ZIndex(230),
|
||||
))
|
||||
.with_children(|b| {
|
||||
for (line, size) in lines {
|
||||
// Title
|
||||
b.spawn((
|
||||
Text::new(line),
|
||||
TextFont {
|
||||
font_size: size,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
Text::new("Welcome to Solitaire Quest!"),
|
||||
TextFont { font_size: 40.0, ..default() },
|
||||
TextColor(BODY_COLOR),
|
||||
));
|
||||
|
||||
// Spacer
|
||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
||||
|
||||
// Instruction line: "Drag cards between piles. Press D to draw, U to undo."
|
||||
// D is tagged KeyHighlightSpan; U uses KEY_COLOR but not the marker.
|
||||
b.spawn((
|
||||
Text::new("Drag cards between piles. Press "),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(BODY_COLOR),
|
||||
))
|
||||
.with_children(|t| {
|
||||
t.spawn((
|
||||
TextSpan::new("D"),
|
||||
TextColor(KEY_COLOR),
|
||||
KeyHighlightSpan,
|
||||
));
|
||||
t.spawn((TextSpan::new(" to draw, "), TextColor(BODY_COLOR)));
|
||||
t.spawn((TextSpan::new("U"), TextColor(KEY_COLOR)));
|
||||
t.spawn((TextSpan::new(" to undo."), TextColor(BODY_COLOR)));
|
||||
});
|
||||
|
||||
// Help line: "Press F1 at any time to see the full controls."
|
||||
b.spawn((
|
||||
Text::new("Press F1 at any time to see the full controls."),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(BODY_COLOR),
|
||||
));
|
||||
|
||||
// Spacer
|
||||
b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() }));
|
||||
|
||||
// Dismiss hint
|
||||
b.spawn((
|
||||
Text::new("Press any key to begin"),
|
||||
TextFont { font_size: 20.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.8, 0.8)),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,4 +223,28 @@ mod tests {
|
||||
|
||||
assert_eq!(count_screens(&mut app), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_has_key_highlight_span_for_d() {
|
||||
// D must be tagged KeyHighlightSpan so its colour is distinct from body
|
||||
// text and future flash-animation systems can target it.
|
||||
let mut app = headless_app();
|
||||
app.update();
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&KeyHighlightSpan>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(count, 1, "expected KeyHighlightSpan for D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_highlight_colour_differs_from_body_colour() {
|
||||
// Regression guard: KEY_COLOR must not accidentally match BODY_COLOR.
|
||||
assert_ne!(
|
||||
format!("{KEY_COLOR:?}"),
|
||||
format!("{BODY_COLOR:?}"),
|
||||
"key highlight colour should differ from body text colour"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,22 @@
|
||||
//! input (drag, keyboard hotkeys) is **not** blocked — pause is purely a
|
||||
//! "stop the clock" screen for now. A future polish slice can layer
|
||||
//! input-blocking on top if desired.
|
||||
//!
|
||||
//! **Drag cancellation:** when Esc is pressed while a mouse drag is in
|
||||
//! progress, the drag is cancelled (cards snap back to their origin) and
|
||||
//! the pause overlay is **not** opened. Pressing Esc again with no drag
|
||||
//! active opens the overlay as normal.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::save_game_state_to;
|
||||
|
||||
use crate::game_plugin::GameStatePath;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::StatsResource;
|
||||
|
||||
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
@@ -24,31 +34,75 @@ pub struct PausedResource(pub bool);
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PauseScreen;
|
||||
|
||||
/// Marker on the draw-mode toggle button inside the pause overlay.
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseDrawToggle;
|
||||
|
||||
/// Returns the human-readable label for a draw mode.
|
||||
///
|
||||
/// Used on the pause overlay draw-mode toggle button.
|
||||
pub fn draw_mode_label(mode: DrawMode) -> &'static str {
|
||||
match mode {
|
||||
DrawMode::DrawOne => "Draw 1",
|
||||
DrawMode::DrawThree => "Draw 3",
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PausePlugin;
|
||||
|
||||
impl Plugin for PausePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<PausedResource>()
|
||||
.add_systems(Update, toggle_pause);
|
||||
// Both add_event calls are idempotent — other plugins may register these
|
||||
// events first, but calling add_event again is always safe.
|
||||
app.add_event::<SettingsChangedEvent>()
|
||||
.add_event::<StateChangedEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.add_systems(Update, (toggle_pause, handle_pause_draw_toggle));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_pause(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
screens: Query<Entity, With<PauseScreen>>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
path: Option<Res<GameStatePath>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut drag: Option<ResMut<DragState>>,
|
||||
mut changed: EventWriter<StateChangedEvent>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Escape) {
|
||||
return;
|
||||
}
|
||||
// If the game-over overlay is visible, let handle_game_over_input consume
|
||||
// the Escape key (to start a new game). Do not open the pause overlay.
|
||||
if !game_over_screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
// If a drag is in progress, cancel it instead of opening the pause overlay.
|
||||
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards
|
||||
// back to their resting positions exactly as a rejected drop does.
|
||||
if let Some(ref mut d) = drag {
|
||||
if !d.is_idle() {
|
||||
d.clear();
|
||||
changed.send(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
paused.0 = false;
|
||||
} else {
|
||||
spawn_pause_screen(&mut commands);
|
||||
// Snapshot current level and streak at pause time.
|
||||
let level = progress.as_deref().map(|p| p.0.level);
|
||||
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
||||
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode.clone());
|
||||
spawn_pause_screen(&mut commands, level, streak, draw_mode);
|
||||
paused.0 = true;
|
||||
// Persist the current game state whenever the player opens the pause
|
||||
// overlay so an OS-level kill still leaves a resumable save.
|
||||
@@ -62,7 +116,54 @@ fn toggle_pause(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_pause_screen(commands: &mut Commands) {
|
||||
/// Handles the draw-mode toggle button on the pause overlay.
|
||||
///
|
||||
/// Toggling flips the draw mode in `SettingsResource`, persists settings, and
|
||||
/// fires `SettingsChangedEvent`. The change takes effect on the next new game.
|
||||
fn handle_pause_draw_toggle(
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseDrawToggle>)>,
|
||||
paused: Res<PausedResource>,
|
||||
settings: Option<ResMut<SettingsResource>>,
|
||||
path: Option<Res<SettingsStoragePath>>,
|
||||
mut changed: EventWriter<SettingsChangedEvent>,
|
||||
) {
|
||||
if !paused.0 {
|
||||
return;
|
||||
}
|
||||
let Some(mut settings) = settings else { return };
|
||||
for interaction in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
settings.0.draw_mode = match settings.0.draw_mode {
|
||||
DrawMode::DrawOne => DrawMode::DrawThree,
|
||||
DrawMode::DrawThree => DrawMode::DrawOne,
|
||||
};
|
||||
if let Some(p) = &path {
|
||||
if let Some(target) = &p.0 {
|
||||
if let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
|
||||
warn!("failed to save settings after draw-mode toggle: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the full-screen pause overlay.
|
||||
///
|
||||
/// `level` and `streak` are optional snapshots taken at pause time. When
|
||||
/// `ProgressResource` or `StatsResource` is not installed (e.g. in headless
|
||||
/// tests), those lines are omitted from the overlay.
|
||||
///
|
||||
/// `draw_mode` is the current draw mode shown on the toggle button. When
|
||||
/// `SettingsResource` is absent the draw-mode row is omitted.
|
||||
fn spawn_pause_screen(
|
||||
commands: &mut Commands,
|
||||
level: Option<u32>,
|
||||
streak: Option<u32>,
|
||||
draw_mode: Option<DrawMode>,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
PauseScreen,
|
||||
@@ -90,6 +191,58 @@ fn spawn_pause_screen(commands: &mut Commands) {
|
||||
},
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
));
|
||||
// Level and streak line — only shown when the resources are present.
|
||||
if level.is_some() || streak.is_some() {
|
||||
let info = build_level_streak_line(level, streak);
|
||||
b.spawn((
|
||||
Text::new(info),
|
||||
TextFont {
|
||||
font_size: 22.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.75, 0.95, 0.75)),
|
||||
));
|
||||
}
|
||||
// Draw-mode toggle row — only shown when SettingsResource is present.
|
||||
if let Some(mode) = draw_mode {
|
||||
b.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: Val::Px(12.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Draw Mode:"),
|
||||
TextFont { font_size: 20.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
row.spawn((
|
||||
PauseDrawToggle,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
|
||||
BorderRadius::all(Val::Px(4.0)),
|
||||
))
|
||||
.with_children(|btn| {
|
||||
btn.spawn((
|
||||
Text::new(draw_mode_label(mode)),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
});
|
||||
b.spawn((
|
||||
Text::new("Takes effect next game"),
|
||||
TextFont { font_size: 14.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.55, 0.60)),
|
||||
));
|
||||
}
|
||||
b.spawn((
|
||||
Text::new("Press Esc to resume"),
|
||||
TextFont {
|
||||
@@ -101,6 +254,19 @@ fn spawn_pause_screen(commands: &mut Commands) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Formats the level / win-streak summary line for the pause overlay.
|
||||
///
|
||||
/// Both values are optional because either resource may be absent in
|
||||
/// headless or partially-configured app contexts.
|
||||
fn build_level_streak_line(level: Option<u32>, streak: Option<u32>) -> String {
|
||||
match (level, streak) {
|
||||
(Some(l), Some(s)) => format!("Level {l} Win streak: {s}"),
|
||||
(Some(l), None) => format!("Level {l}"),
|
||||
(None, Some(s)) => format!("Win streak: {s}"),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -175,4 +341,167 @@ mod tests {
|
||||
"third Esc must re-spawn PauseScreen"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// build_level_streak_line (pure function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn level_streak_both_present() {
|
||||
assert_eq!(
|
||||
build_level_streak_line(Some(7), Some(3)),
|
||||
"Level 7 Win streak: 3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_streak_only_level() {
|
||||
assert_eq!(build_level_streak_line(Some(5), None), "Level 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_streak_only_streak() {
|
||||
assert_eq!(build_level_streak_line(None, Some(4)), "Win streak: 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_streak_neither() {
|
||||
assert_eq!(build_level_streak_line(None, None), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pause screen with progress / stats resources present
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pause_screen_spawns_with_level_and_streak_when_resources_present() {
|
||||
use crate::progress_plugin::{ProgressPlugin, ProgressResource};
|
||||
use crate::settings_plugin::SettingsPlugin;
|
||||
use crate::stats_plugin::{StatsPlugin, StatsResource};
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(crate::game_plugin::GamePlugin)
|
||||
.add_plugins(crate::table_plugin::TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(SettingsPlugin::headless())
|
||||
.add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
|
||||
// Set known values.
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.level = 7;
|
||||
app.world_mut().resource_mut::<StatsResource>().0.win_streak_current = 3;
|
||||
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
|
||||
// Verify the screen was spawned.
|
||||
assert!(app.world().resource::<PausedResource>().0);
|
||||
|
||||
// Find the text nodes on the PauseScreen children and check one contains
|
||||
// the expected level/streak string.
|
||||
let texts: Vec<String> = app
|
||||
.world_mut()
|
||||
.query::<&Text>()
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Level 7 Win streak: 3"),
|
||||
"expected level/streak line in pause screen texts, got: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// draw_mode_label (pure function) — Task #64
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn draw_mode_label_draw_one() {
|
||||
assert_eq!(draw_mode_label(DrawMode::DrawOne), "Draw 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_mode_label_draw_three() {
|
||||
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// pause_draw_toggle_flips_draw_mode — Task #64
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pause_draw_toggle_flips_draw_mode() {
|
||||
use crate::settings_plugin::{SettingsPlugin, SettingsResource};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(SettingsPlugin::headless())
|
||||
.add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
|
||||
// Ensure we start with DrawOne.
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.draw_mode = DrawMode::DrawOne;
|
||||
|
||||
// Set paused so handle_pause_draw_toggle acts.
|
||||
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
||||
|
||||
// Spawn a PauseDrawToggle button with Pressed interaction.
|
||||
app.world_mut().spawn((
|
||||
PauseDrawToggle,
|
||||
Button,
|
||||
Interaction::Pressed,
|
||||
));
|
||||
|
||||
app.update();
|
||||
|
||||
let mode = &app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.draw_mode;
|
||||
assert_eq!(
|
||||
*mode,
|
||||
DrawMode::DrawThree,
|
||||
"draw mode must flip from DrawOne to DrawThree when toggle is pressed"
|
||||
);
|
||||
|
||||
// A second press should flip back.
|
||||
{
|
||||
let mut interaction_query = app
|
||||
.world_mut()
|
||||
.query::<&mut Interaction>();
|
||||
for mut i in interaction_query.iter_mut(app.world_mut()) {
|
||||
*i = Interaction::Pressed;
|
||||
}
|
||||
}
|
||||
app.update();
|
||||
|
||||
let mode2 = &app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.draw_mode;
|
||||
assert_eq!(
|
||||
*mode2,
|
||||
DrawMode::DrawOne,
|
||||
"draw mode must flip back from DrawThree to DrawOne on second press"
|
||||
);
|
||||
|
||||
// Verify a SettingsChangedEvent was fired.
|
||||
let events = app.world().resource::<Events<SettingsChangedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let count = cursor.read(events).count();
|
||||
assert!(count >= 1, "SettingsChangedEvent must be fired on toggle");
|
||||
|
||||
// Restore default settings state for hygiene.
|
||||
let _ = Settings::default();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -244,4 +244,25 @@ mod tests {
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].total_xp, total_xp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_win_awards_base_xp() {
|
||||
// Zen mode suppresses score display but XP is still awarded normally.
|
||||
// score=0 in the event (Zen keeps score at 0), time=300 (no speed bonus),
|
||||
// undo_count=0 so no-undo bonus applies: expected 50+25=75.
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 0, // Zen mode keeps score at 0
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let xp = app.world().resource::<ProgressResource>().0.total_xp;
|
||||
assert_eq!(xp, 75, "Zen win: base 50 + no-undo 25 = 75");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,3 +53,17 @@ pub enum SyncStatus {
|
||||
/// Bevy resource wrapping the current `SyncStatus`.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SyncStatusResource(pub SyncStatus);
|
||||
|
||||
/// Tracks which hint the player is currently cycling through.
|
||||
///
|
||||
/// Incremented on each H press so repeated presses reveal different moves.
|
||||
/// Reset to `0` whenever the game state changes (move, draw, undo, new game).
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct HintCycleIndex(pub usize);
|
||||
|
||||
/// Remembers the vertical scroll offset of the Settings panel between open/close cycles.
|
||||
///
|
||||
/// Saved when the panel is despawned and restored on next spawn so the player
|
||||
/// returns to the same position in the list without re-scrolling.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SettingsScrollPos(pub f32);
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Keyboard-driven card selection (Task #68).
|
||||
//!
|
||||
//! Pressing `Tab` cycles through piles that have a face-up draggable top card.
|
||||
//! Pressing `Enter` or `Space` fires a [`MoveRequestEvent`] to the best
|
||||
//! available destination using the following priority order, then clears the
|
||||
//! selection:
|
||||
//!
|
||||
//! 1. Move the top card to its best foundation (count = 1).
|
||||
//! 2. Move the full face-up run from the selected tableau pile to the best
|
||||
//! tableau destination (count = run length). Single-card stacks from
|
||||
//! non-tableau piles fall back to [`best_destination`] for tableau targets.
|
||||
//!
|
||||
//! Pressing `Escape` clears the selection without moving.
|
||||
//!
|
||||
//! The selected card is highlighted by a cyan [`SelectionHighlight`] outline
|
||||
//! sprite parented to the selected card entity. The highlight is despawned when
|
||||
//! the selection is cleared.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks which pile currently has keyboard focus.
|
||||
///
|
||||
/// `None` means no pile is selected.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SelectionState {
|
||||
/// The pile whose top face-up card is currently selected, or `None`.
|
||||
pub selected_pile: Option<PileType>,
|
||||
}
|
||||
|
||||
/// Marker component placed on the outline sprite used as the keyboard-selection
|
||||
/// highlight.
|
||||
///
|
||||
/// Exactly one entity with this marker should exist at any time. It is
|
||||
/// despawned when the selection is cleared.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct SelectionHighlight;
|
||||
|
||||
/// Registers the keyboard selection resources and systems.
|
||||
pub struct SelectionPlugin;
|
||||
|
||||
impl Plugin for SelectionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SelectionState>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_selection_keys.before(GameMutation),
|
||||
update_selection_highlight.after(GameMutation),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pile cycle order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The ordered list of piles that are considered for keyboard cycling.
|
||||
///
|
||||
/// Order: Waste → Foundation×4 → Tableau 0–6.
|
||||
fn cycled_piles() -> Vec<PileType> {
|
||||
let mut piles = vec![
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
];
|
||||
for i in 0..7_usize {
|
||||
piles.push(PileType::Tableau(i));
|
||||
}
|
||||
piles
|
||||
}
|
||||
|
||||
/// Given a list of *available* piles and the currently selected pile, return
|
||||
/// the next pile in cycling order, wrapping around.
|
||||
///
|
||||
/// If `current` is `None` the first available pile is returned.
|
||||
/// If `available` is empty, `None` is returned.
|
||||
pub fn cycle_next_pile(
|
||||
available: &[PileType],
|
||||
current: Option<&PileType>,
|
||||
) -> Option<PileType> {
|
||||
if available.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let order = cycled_piles();
|
||||
|
||||
let Some(cur) = current else {
|
||||
// No current selection: return the first available pile in cycle order.
|
||||
return order.iter().find(|p| available.contains(p)).cloned();
|
||||
};
|
||||
|
||||
// Find the position of `cur` inside the ordered list, then scan forward
|
||||
// for the next available pile (wrapping).
|
||||
let cur_pos = order.iter().position(|p| p == cur);
|
||||
let start = cur_pos.map_or(0, |pos| pos + 1);
|
||||
|
||||
// Search from `start` forward, wrapping around, skipping `cur`.
|
||||
let n = order.len();
|
||||
for offset in 0..n {
|
||||
let candidate = &order[(start + offset) % n];
|
||||
if available.contains(candidate) {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns `true` when cycling from `current` to `next` wraps around the
|
||||
/// available list — i.e., `next` appears at or before `current` in the global
|
||||
/// cycle order defined by [`cycled_piles`].
|
||||
///
|
||||
/// Both `current` and `next` must be `Some`; if either is `None` this returns
|
||||
/// `false`.
|
||||
fn did_wrap(
|
||||
available: &[PileType],
|
||||
current: Option<&PileType>,
|
||||
next: Option<&PileType>,
|
||||
) -> bool {
|
||||
let (Some(cur), Some(nxt)) = (current, next) else {
|
||||
return false;
|
||||
};
|
||||
let order = cycled_piles();
|
||||
// Position of each pile within the *available* subset, ordered by the
|
||||
// global cycle order.
|
||||
let pos_in_available = |target: &PileType| -> Option<usize> {
|
||||
order
|
||||
.iter()
|
||||
.filter(|p| available.contains(p))
|
||||
.position(|p| p == target)
|
||||
};
|
||||
match (pos_in_available(cur), pos_in_available(nxt)) {
|
||||
(Some(cur_pos), Some(nxt_pos)) => nxt_pos <= cur_pos,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles Tab / Enter / Space / Escape for keyboard card selection.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_selection_keys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
mut selection: ResMut<SelectionState>,
|
||||
mut moves: EventWriter<MoveRequestEvent>,
|
||||
mut info_toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the list of piles that currently have a face-up draggable top card.
|
||||
let available: Vec<PileType> = {
|
||||
let all = [
|
||||
PileType::Waste,
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(Suit::Diamonds),
|
||||
PileType::Foundation(Suit::Hearts),
|
||||
PileType::Foundation(Suit::Spades),
|
||||
PileType::Tableau(0),
|
||||
PileType::Tableau(1),
|
||||
PileType::Tableau(2),
|
||||
PileType::Tableau(3),
|
||||
PileType::Tableau(4),
|
||||
PileType::Tableau(5),
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
all.into_iter()
|
||||
.filter(|p| {
|
||||
game.0
|
||||
.piles
|
||||
.get(p)
|
||||
.and_then(|pile| pile.cards.last())
|
||||
.is_some_and(|c| c.face_up)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Tab — cycle selection.
|
||||
if keys.just_pressed(KeyCode::Tab) {
|
||||
let next = cycle_next_pile(&available, selection.selected_pile.as_ref());
|
||||
if next.is_none() {
|
||||
info_toast.send(InfoToastEvent("No cards to select".to_string()));
|
||||
} else if selection.selected_pile.is_some()
|
||||
&& did_wrap(&available, selection.selected_pile.as_ref(), next.as_ref())
|
||||
{
|
||||
info_toast.send(InfoToastEvent("Back to first card".to_string()));
|
||||
}
|
||||
selection.selected_pile = next;
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — clear selection.
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
selection.selected_pile = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter / Space — execute move for the selected pile's top card (or full
|
||||
// face-up run when the source is a tableau column).
|
||||
//
|
||||
// Priority:
|
||||
// 1. Foundation move — always count = 1.
|
||||
// 2. Tableau stack move — count = full face-up run length from the source.
|
||||
let activate =
|
||||
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
|
||||
if activate {
|
||||
if let Some(ref pile) = selection.selected_pile.clone() {
|
||||
if let Some(card) = game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
{
|
||||
// --- Priority 1: foundation move (single card) ---
|
||||
let foundation_dest = try_foundation_dest(card, &game.0);
|
||||
if let Some(dest) = foundation_dest {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
selection.selected_pile = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Priority 2: tableau stack move ---
|
||||
// Count the full contiguous face-up run in the source pile.
|
||||
let run_len = face_up_run_len(game.0.piles.get(pile).map(|p| p.cards.as_slice()).unwrap_or(&[]));
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.and_then(|p| {
|
||||
let start = p.cards.len().saturating_sub(run_len);
|
||||
p.cards.get(start)
|
||||
});
|
||||
if let Some(bottom) = bottom_card {
|
||||
if let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom, pile, &game.0, run_len)
|
||||
{
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
selection.selected_pile = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: single-card move to any destination ---
|
||||
// Covers non-tableau sources (Waste, Foundation) that have no
|
||||
// stack-move logic.
|
||||
if let Some(dest) = best_destination(card, &game.0) {
|
||||
moves.send(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
selection.selected_pile = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Count the contiguous face-up cards at the top of `cards`.
|
||||
///
|
||||
/// Walks backwards from the last element and stops at the first face-down card
|
||||
/// (or when the slice is exhausted). Returns at least `1` when the top card is
|
||||
/// face-up; returns `0` for an empty slice or when the top card is face-down.
|
||||
fn face_up_run_len(cards: &[solitaire_core::card::Card]) -> usize {
|
||||
let mut count = 0;
|
||||
for card in cards.iter().rev() {
|
||||
if card.face_up {
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Find the best foundation destination for `card` — returns the first
|
||||
/// foundation pile that legally accepts the card, or `None`.
|
||||
///
|
||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||
/// handler can attempt a foundation move first and fall through to a
|
||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||
fn try_foundation_dest(
|
||||
card: &solitaire_core::card::Card,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
use solitaire_core::rules::can_place_on_foundation;
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let dest = PileType::Foundation(suit);
|
||||
if let Some(pile) = game.piles.get(&dest) {
|
||||
if can_place_on_foundation(card, pile, suit) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Maintains the `SelectionHighlight` outline sprite.
|
||||
///
|
||||
/// When a pile is selected, a cyan sprite is placed at the selected card's
|
||||
/// position. When the selection is cleared the highlight entity is despawned.
|
||||
fn update_selection_highlight(
|
||||
mut commands: Commands,
|
||||
selection: Res<SelectionState>,
|
||||
game: Res<GameStateResource>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
highlights: Query<Entity, With<SelectionHighlight>>,
|
||||
) {
|
||||
// Always despawn any existing highlight first.
|
||||
for entity in &highlights {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
|
||||
let Some(ref pile) = selection.selected_pile else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
let Some(card) = game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.filter(|c| c.face_up)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let card_id = card.id;
|
||||
let card_size = layout.0.card_size;
|
||||
|
||||
// Find the entity for the selected card so we can read its position.
|
||||
for (entity, card_entity) in &card_entities {
|
||||
if card_entity.card_id == card_id {
|
||||
// Spawn the highlight as a child of the card entity so it moves
|
||||
// with it automatically.
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
SelectionHighlight,
|
||||
Sprite {
|
||||
color: Color::srgba(0.0, 1.0, 1.0, 0.5),
|
||||
custom_size: Some(card_size + Vec2::splat(4.0)),
|
||||
..default()
|
||||
},
|
||||
// Slightly behind the card face so text labels are still visible.
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
Visibility::default(),
|
||||
));
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn piles_from(names: &[&str]) -> Vec<PileType> {
|
||||
names
|
||||
.iter()
|
||||
.map(|&n| match n {
|
||||
"Waste" => PileType::Waste,
|
||||
"T0" => PileType::Tableau(0),
|
||||
"T1" => PileType::Tableau(1),
|
||||
"T2" => PileType::Tableau(2),
|
||||
_ => PileType::Waste,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #68 — cycle_next_pile pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_from_none() {
|
||||
// With [Waste, Tableau(0), Tableau(1)] available, starting from None → Waste.
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, None);
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_from_waste() {
|
||||
// Starting from Waste → Tableau(0).
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||
assert_eq!(result, Some(PileType::Tableau(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_wraps() {
|
||||
// Starting from Tableau(1) → Waste (wraps back to start).
|
||||
let available = piles_from(&["Waste", "T0", "T1"]);
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Tableau(1)));
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_next_pile_empty_returns_none() {
|
||||
let result = cycle_next_pile(&[], 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]
|
||||
fn cycle_next_pile_single_element_wraps_to_itself() {
|
||||
let available = vec![PileType::Waste];
|
||||
let result = cycle_next_pile(&available, Some(&PileType::Waste));
|
||||
assert_eq!(result, Some(PileType::Waste));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #8 — face_up_run_len pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_empty_slice_is_zero() {
|
||||
assert_eq!(face_up_run_len(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_all_face_up() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_mixed_stops_at_face_down() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: false },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||||
Card { id: 3, suit: Suit::Diamonds, rank: Rank::Ten, face_up: true },
|
||||
];
|
||||
// Only the top two cards are face-up.
|
||||
assert_eq!(face_up_run_len(&cards), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_top_card_face_down_is_zero() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true },
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Queen, face_up: false },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_up_run_len_single_face_up_card() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let cards = vec![
|
||||
Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
|
||||
];
|
||||
assert_eq!(face_up_run_len(&cards), 1);
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,14 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
||||
|
||||
use crate::events::ManualSyncRequestEvent;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
|
||||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||
pub const SFX_STEP: f32 = 0.1;
|
||||
@@ -74,6 +75,18 @@ struct AnimSpeedText;
|
||||
#[derive(Component, Debug)]
|
||||
struct BackgroundText;
|
||||
|
||||
/// Marks the `Text` node showing the current color-blind mode state.
|
||||
#[derive(Component, Debug)]
|
||||
struct ColorBlindText;
|
||||
|
||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
|
||||
/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsScrollNode;
|
||||
|
||||
/// Tags interactive buttons inside the Settings panel.
|
||||
#[derive(Component, Debug)]
|
||||
enum SettingsButton {
|
||||
@@ -84,10 +97,13 @@ enum SettingsButton {
|
||||
ToggleDrawMode,
|
||||
CycleAnimSpeed,
|
||||
ToggleTheme,
|
||||
CycleCardBack,
|
||||
CycleBackground,
|
||||
ToggleColorBlind,
|
||||
SyncNow,
|
||||
Done,
|
||||
/// Select a specific card-back by index from the picker row.
|
||||
SelectCardBack(usize),
|
||||
/// Select a specific background by index from the picker row.
|
||||
SelectBackground(usize),
|
||||
}
|
||||
|
||||
/// Plugin that owns the settings lifecycle.
|
||||
@@ -127,9 +143,11 @@ impl Plugin for SettingsPlugin {
|
||||
app.insert_resource(SettingsResource(loaded))
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.init_resource::<SettingsScreen>()
|
||||
.init_resource::<SettingsScrollPos>()
|
||||
.add_event::<SettingsChangedEvent>()
|
||||
.add_event::<ManualSyncRequestEvent>()
|
||||
.add_systems(Update, (handle_volume_keys, toggle_settings_screen));
|
||||
.add_event::<bevy::input::mouse::MouseWheel>()
|
||||
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
|
||||
|
||||
if self.ui_enabled {
|
||||
app.add_systems(
|
||||
@@ -141,6 +159,7 @@ impl Plugin for SettingsPlugin {
|
||||
update_card_back_text,
|
||||
update_background_text,
|
||||
update_anim_speed_text,
|
||||
update_color_blind_text,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -199,9 +218,12 @@ fn toggle_settings_screen(
|
||||
|
||||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||
/// despawns it when it becomes `false`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||
mut commands: Commands,
|
||||
settings: Res<SettingsResource>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
@@ -229,9 +251,14 @@ fn sync_settings_panel_visibility(
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
scroll_pos.0,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Save the current scroll offset before despawning the panel.
|
||||
if let Ok(sp) = scroll_nodes.get_single() {
|
||||
scroll_pos.0 = sp.offset_y;
|
||||
}
|
||||
for entity in &panels {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
@@ -240,6 +267,7 @@ fn sync_settings_panel_visibility(
|
||||
|
||||
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
|
||||
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
|
||||
#[cfg(test)]
|
||||
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
|
||||
if unlocked.is_empty() {
|
||||
return 0;
|
||||
@@ -301,6 +329,18 @@ fn update_anim_speed_text(
|
||||
}
|
||||
}
|
||||
|
||||
fn update_color_blind_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = color_blind_label(settings.0.color_blind_mode);
|
||||
}
|
||||
}
|
||||
|
||||
fn card_back_label(idx: usize) -> String {
|
||||
if idx == 0 {
|
||||
"Default".to_string()
|
||||
@@ -345,12 +385,12 @@ fn handle_settings_buttons(
|
||||
path: Res<SettingsStoragePath>,
|
||||
mut changed: EventWriter<SettingsChangedEvent>,
|
||||
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>)>,
|
||||
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
|
||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<ColorBlindText>)>,
|
||||
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
@@ -436,23 +476,21 @@ fn handle_settings_buttons(
|
||||
**t = theme_label(&settings.0.theme);
|
||||
}
|
||||
}
|
||||
SettingsButton::CycleCardBack => {
|
||||
let unlocked = progress
|
||||
.as_ref()
|
||||
.map(|p| p.0.unlocked_card_backs.clone())
|
||||
.unwrap_or_else(|| vec![0]);
|
||||
settings.0.selected_card_back =
|
||||
cycle_unlocked(&unlocked, settings.0.selected_card_back);
|
||||
SettingsButton::ToggleColorBlind => {
|
||||
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
if let Ok(mut t) = color_blind_text.get_single_mut() {
|
||||
**t = color_blind_label(settings.0.color_blind_mode);
|
||||
}
|
||||
}
|
||||
SettingsButton::SelectCardBack(idx) => {
|
||||
settings.0.selected_card_back = *idx;
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
SettingsButton::CycleBackground => {
|
||||
let unlocked = progress
|
||||
.as_ref()
|
||||
.map(|p| p.0.unlocked_backgrounds.clone())
|
||||
.unwrap_or_else(|| vec![0]);
|
||||
settings.0.selected_background =
|
||||
cycle_unlocked(&unlocked, settings.0.selected_background);
|
||||
SettingsButton::SelectBackground(idx) => {
|
||||
settings.0.selected_background = *idx;
|
||||
persist(&path, &settings.0);
|
||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
@@ -489,6 +527,39 @@ fn theme_label(theme: &Theme) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn color_blind_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||
///
|
||||
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||||
/// scrolls past the top.
|
||||
fn scroll_settings_panel(
|
||||
mut scroll_evr: EventReader<MouseWheel>,
|
||||
screen: Res<SettingsScreen>,
|
||||
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||||
) {
|
||||
if !screen.0 {
|
||||
scroll_evr.clear();
|
||||
return;
|
||||
}
|
||||
let delta_y: f32 = scroll_evr
|
||||
.read()
|
||||
.map(|ev| match ev.unit {
|
||||
MouseScrollUnit::Line => ev.y * 50.0,
|
||||
MouseScrollUnit::Pixel => ev.y,
|
||||
})
|
||||
.sum();
|
||||
if delta_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
for mut sp in scrollables.iter_mut() {
|
||||
sp.offset_y = (sp.offset_y - delta_y).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI construction
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -499,6 +570,7 @@ fn spawn_settings_panel(
|
||||
sync_status: &str,
|
||||
unlocked_card_backs: &[usize],
|
||||
unlocked_backgrounds: &[usize],
|
||||
scroll_offset: f32,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
@@ -518,15 +590,19 @@ fn spawn_settings_panel(
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// Inner card — max_height + clip_y keeps it on-screen on small windows.
|
||||
// Inner card — max_height + scroll_y lets the player reach all rows
|
||||
// on small windows by scrolling with the mouse wheel.
|
||||
root.spawn((
|
||||
SettingsPanelScrollable,
|
||||
SettingsScrollNode,
|
||||
ScrollPosition { offset_y: scroll_offset, ..default() },
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(28.0)),
|
||||
row_gap: Val::Px(14.0),
|
||||
min_width: Val::Px(340.0),
|
||||
max_height: Val::Percent(88.0),
|
||||
overflow: Overflow::clip_y(),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
||||
@@ -626,8 +702,7 @@ fn spawn_settings_panel(
|
||||
icon_button(row, "⇄", SettingsButton::ToggleTheme);
|
||||
});
|
||||
|
||||
// Card back row — only shown when the player has unlocked more than one.
|
||||
if unlocked_card_backs.len() > 1 {
|
||||
// Color-blind mode row
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
@@ -636,43 +711,110 @@ fn spawn_settings_panel(
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Card Back"),
|
||||
Text::new("Color-blind Mode"),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
row.spawn((
|
||||
CardBackText,
|
||||
Text::new(card_back_label(settings.selected_card_back)),
|
||||
ColorBlindText,
|
||||
Text::new(color_blind_label(settings.color_blind_mode)),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
icon_button(row, "⇄", SettingsButton::CycleCardBack);
|
||||
icon_button(row, "⇄", SettingsButton::ToggleColorBlind);
|
||||
});
|
||||
}
|
||||
|
||||
// Background row — only shown when the player has unlocked more than one.
|
||||
if unlocked_backgrounds.len() > 1 {
|
||||
// --- Card Back section ---
|
||||
section_label(card, "Card Back");
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: Val::Px(8.0),
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Always show at least button "1" (index 0 = default).
|
||||
let backs = if unlocked_card_backs.is_empty() {
|
||||
&[0usize][..]
|
||||
} else {
|
||||
unlocked_card_backs
|
||||
};
|
||||
for &back_idx in backs {
|
||||
let is_selected = back_idx == settings.selected_card_back;
|
||||
let bg_color = if is_selected {
|
||||
Color::srgb(0.2, 0.9, 0.3)
|
||||
} else {
|
||||
Color::srgb(0.25, 0.25, 0.30)
|
||||
};
|
||||
row.spawn((
|
||||
Text::new("Background"),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
row.spawn((
|
||||
BackgroundText,
|
||||
Text::new(background_label(settings.selected_background)),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
SettingsButton::SelectCardBack(back_idx),
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(40.0),
|
||||
height: Val::Px(40.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(bg_color),
|
||||
BorderRadius::all(Val::Px(4.0)),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(format!("{}", back_idx + 1)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
icon_button(row, "⇄", SettingsButton::CycleBackground);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Background section ---
|
||||
section_label(card, "Background");
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: Val::Px(8.0),
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Always show at least button "1" (index 0 = default).
|
||||
let bgs = if unlocked_backgrounds.is_empty() {
|
||||
&[0usize][..]
|
||||
} else {
|
||||
unlocked_backgrounds
|
||||
};
|
||||
for &bg_idx in bgs {
|
||||
let is_selected = bg_idx == settings.selected_background;
|
||||
let bg_color = if is_selected {
|
||||
Color::srgb(0.2, 0.9, 0.3)
|
||||
} else {
|
||||
Color::srgb(0.25, 0.25, 0.30)
|
||||
};
|
||||
row.spawn((
|
||||
SettingsButton::SelectBackground(bg_idx),
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(40.0),
|
||||
height: Val::Px(40.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(bg_color),
|
||||
BorderRadius::all(Val::Px(4.0)),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(format!("{}", bg_idx + 1)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Sync section ---
|
||||
section_label(card, "Sync");
|
||||
@@ -942,4 +1084,92 @@ mod tests {
|
||||
fn cycle_unlocked_empty_returns_zero() {
|
||||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_is_noop_when_settings_panel_closed() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||
.id();
|
||||
// Send a downward scroll event while the panel is closed.
|
||||
app.world_mut().send_event(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -3.0,
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
// ScrollPosition must remain at 0.0 — panel was closed.
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.offset_y;
|
||||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_moves_offset_when_panel_open() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition { offset_y: 100.0, ..default() },
|
||||
))
|
||||
.id();
|
||||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||||
app.world_mut().send_event(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: -2.0,
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.offset_y;
|
||||
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clamps_offset_to_zero_at_top() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
// Entity starts at 10 px offset.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SettingsPanelScrollable,
|
||||
ScrollPosition { offset_y: 10.0, ..default() },
|
||||
))
|
||||
.id();
|
||||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||||
app.world_mut().send_event(MouseWheel {
|
||||
unit: MouseScrollUnit::Line,
|
||||
x: 0.0,
|
||||
y: 5.0,
|
||||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||
});
|
||||
app.update();
|
||||
let offset = app
|
||||
.world()
|
||||
.entity(entity)
|
||||
.get::<ScrollPosition>()
|
||||
.unwrap()
|
||||
.offset_y;
|
||||
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ use solitaire_data::{
|
||||
WEEKLY_GOALS,
|
||||
};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::challenge_plugin::challenge_progress_label;
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||
use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
@@ -39,6 +40,12 @@ pub struct StatsUpdate;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsScreen;
|
||||
|
||||
/// Marker component on an individual stat cell inside the stats overlay.
|
||||
///
|
||||
/// Each cell contains a large value label and a small descriptor label below it.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsCell;
|
||||
|
||||
/// Registers stats resources, update systems, and the UI toggle.
|
||||
pub struct StatsPlugin {
|
||||
/// Where to persist stats. `None` disables all file I/O (for tests).
|
||||
@@ -72,6 +79,8 @@ impl Plugin for StatsPlugin {
|
||||
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<ForfeitEvent>()
|
||||
.add_event::<InfoToastEvent>()
|
||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||
// clobbers it with a fresh game.
|
||||
.add_systems(
|
||||
@@ -84,6 +93,10 @@ impl Plugin for StatsPlugin {
|
||||
Update,
|
||||
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
handle_forfeit.before(GameMutation).in_set(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_stats_screen.after(GameMutation));
|
||||
}
|
||||
}
|
||||
@@ -116,13 +129,51 @@ fn update_stats_on_new_game(
|
||||
game: Res<GameStateResource>,
|
||||
mut stats: ResMut<StatsResource>,
|
||||
path: Res<StatsStoragePath>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
if game.0.move_count > 0 && !game.0.is_won {
|
||||
let streak = stats.0.win_streak_current;
|
||||
stats.0.record_abandoned();
|
||||
persist(&path, &stats.0, "abandoned game");
|
||||
if streak > 1 {
|
||||
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the player presses G to forfeit, record the game as abandoned, save
|
||||
/// stats, fire an informational toast, and start a new game.
|
||||
///
|
||||
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
|
||||
/// into the new deal (task #41).
|
||||
fn handle_forfeit(
|
||||
mut events: EventReader<ForfeitEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
mut stats: ResMut<StatsResource>,
|
||||
path: Res<StatsStoragePath>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
mut auto_complete: Option<ResMut<AutoCompleteState>>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
if game.0.move_count > 0 && !game.0.is_won {
|
||||
let streak = stats.0.win_streak_current;
|
||||
stats.0.record_abandoned();
|
||||
persist(&path, &stats.0, "forfeit");
|
||||
if streak > 1 {
|
||||
toast.send(InfoToastEvent(format!("Streak of {streak} broken!")));
|
||||
}
|
||||
}
|
||||
// Reset auto-complete so the badge and chime don't carry over to the
|
||||
// new game that is about to start.
|
||||
if let Some(ref mut ac) = auto_complete {
|
||||
**ac = AutoCompleteState::default();
|
||||
}
|
||||
toast.send(InfoToastEvent("Game forfeited".to_string()));
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_stats_screen(
|
||||
@@ -154,90 +205,15 @@ fn spawn_stats_screen(
|
||||
progress: Option<&PlayerProgress>,
|
||||
time_attack: Option<&TimeAttackResource>,
|
||||
) {
|
||||
let win_rate = stats
|
||||
.win_rate()
|
||||
.map_or("N/A".to_string(), |r| format!("{r:.1}%"));
|
||||
let fastest = if stats.fastest_win_seconds == u64::MAX {
|
||||
"N/A".to_string()
|
||||
} else {
|
||||
format_duration(stats.fastest_win_seconds)
|
||||
};
|
||||
let avg = if stats.games_won == 0 {
|
||||
"N/A".to_string()
|
||||
} else {
|
||||
format_duration(stats.avg_time_seconds)
|
||||
};
|
||||
|
||||
let mut lines: Vec<String> = vec![
|
||||
"=== Statistics ===".to_string(),
|
||||
format!("Games Played: {}", stats.games_played),
|
||||
format!("Games Won: {}", stats.games_won),
|
||||
format!("Games Lost: {}", stats.games_lost),
|
||||
format!("Win Rate: {win_rate}"),
|
||||
format!(
|
||||
"Win Streak: {} (Best: {})",
|
||||
stats.win_streak_current, stats.win_streak_best
|
||||
),
|
||||
format!("Draw 1 Wins: {}", stats.draw_one_wins),
|
||||
format!("Draw 3 Wins: {}", stats.draw_three_wins),
|
||||
format!("Best Score: {}", stats.best_single_score),
|
||||
format!("Lifetime Score:{}", stats.lifetime_score),
|
||||
format!("Fastest Win: {fastest}"),
|
||||
format!("Avg Win Time: {avg}"),
|
||||
];
|
||||
|
||||
if let Some(p) = progress {
|
||||
lines.push(String::new());
|
||||
lines.push("=== Progression ===".to_string());
|
||||
lines.push(format!("Level: {}", p.level));
|
||||
lines.push(format!("Total XP: {}", p.total_xp));
|
||||
lines.push(format!("Next Level: {}", xp_to_next_level_label(p.total_xp, p.level)));
|
||||
lines.push(format!(
|
||||
"Daily Streak: {}",
|
||||
p.daily_challenge_streak
|
||||
));
|
||||
lines.push(format!(
|
||||
"Challenge: {}",
|
||||
challenge_progress_label(p.challenge_index)
|
||||
));
|
||||
lines.push(String::new());
|
||||
lines.push("-- Weekly Goals --".to_string());
|
||||
for goal in WEEKLY_GOALS {
|
||||
let progress_value = p
|
||||
.weekly_goal_progress
|
||||
.get(goal.id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
lines.push(format!(
|
||||
" {}: {}/{}",
|
||||
goal.description, progress_value, goal.target
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("-- Unlocks --".to_string());
|
||||
lines.push(format!(
|
||||
" Card Backs: {}",
|
||||
format_id_list(&p.unlocked_card_backs)
|
||||
));
|
||||
lines.push(format!(
|
||||
" Backgrounds: {}",
|
||||
format_id_list(&p.unlocked_backgrounds)
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ta) = time_attack {
|
||||
if ta.active {
|
||||
let mins = (ta.remaining_secs / 60.0).floor() as u64;
|
||||
let secs = (ta.remaining_secs % 60.0).floor() as u64;
|
||||
lines.push(String::new());
|
||||
lines.push("=== Time Attack ===".to_string());
|
||||
lines.push(format!("Remaining: {mins}m {secs:02}s"));
|
||||
lines.push(format!("Wins: {}", ta.wins));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("Press S to close".to_string());
|
||||
// --- primary stat cells (tasks #65, #66, and #38) ---
|
||||
let win_rate_str = format_win_rate(stats);
|
||||
let played_str = format_stat_value(stats.games_played);
|
||||
let won_str = format_stat_value(stats.games_won);
|
||||
let lost_str = format_stat_value(stats.games_lost);
|
||||
let fastest_str = format_fastest_win(stats.fastest_win_seconds);
|
||||
let avg_time_str = format_avg_time(stats);
|
||||
let best_score_str = format_optional_u32(stats.best_single_score);
|
||||
let best_streak_str = format_stat_value(stats.win_streak_best);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
@@ -249,28 +225,212 @@ fn spawn_stats_screen(
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
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(|b| {
|
||||
for line in lines {
|
||||
b.spawn((
|
||||
Text::new(line),
|
||||
TextFont {
|
||||
font_size: 24.0,
|
||||
.with_children(|root| {
|
||||
// Title
|
||||
root.spawn((
|
||||
Text::new("Statistics"),
|
||||
TextFont { font_size: 28.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.85, 0.3)),
|
||||
));
|
||||
|
||||
// Two-column grid of stat cells
|
||||
root.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::FlexStart,
|
||||
column_gap: Val::Px(24.0),
|
||||
row_gap: Val::Px(16.0),
|
||||
width: Val::Percent(100.0),
|
||||
margin: UiRect::top(Val::Px(16.0)),
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.95, 0.95, 0.90)),
|
||||
})
|
||||
.with_children(|grid| {
|
||||
spawn_stat_cell(grid, &win_rate_str, "Win Rate");
|
||||
spawn_stat_cell(grid, &played_str, "Games Played");
|
||||
spawn_stat_cell(grid, &won_str, "Games Won");
|
||||
spawn_stat_cell(grid, &lost_str, "Games Lost");
|
||||
spawn_stat_cell(grid, &fastest_str, "Fastest Win");
|
||||
spawn_stat_cell(grid, &avg_time_str, "Avg Time");
|
||||
spawn_stat_cell(grid, &best_score_str, "Best Score");
|
||||
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
|
||||
});
|
||||
|
||||
// Progression section
|
||||
if let Some(p) = progress {
|
||||
root.spawn((
|
||||
Text::new("Progression"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.7, 0.9, 1.0)),
|
||||
));
|
||||
|
||||
let level_str = format_stat_value(p.level);
|
||||
let xp_str = format_stat_value(p.total_xp as u32);
|
||||
let next_label = xp_to_next_level_label(p.total_xp, p.level);
|
||||
let daily_str = format_stat_value(p.daily_challenge_streak);
|
||||
let challenge_str = challenge_progress_label(p.challenge_index);
|
||||
|
||||
root.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::FlexStart,
|
||||
column_gap: Val::Px(24.0),
|
||||
row_gap: Val::Px(12.0),
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|grid| {
|
||||
spawn_stat_cell(grid, &level_str, "Level");
|
||||
spawn_stat_cell(grid, &xp_str, "Total XP");
|
||||
spawn_stat_cell(grid, &next_label, "Next Level");
|
||||
spawn_stat_cell(grid, &daily_str, "Daily Streak");
|
||||
spawn_stat_cell(grid, &challenge_str, "Challenge");
|
||||
});
|
||||
|
||||
// Weekly goals row
|
||||
root.spawn((
|
||||
Text::new("Weekly Goals"),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::srgb(0.8, 0.8, 0.8)),
|
||||
));
|
||||
for goal in WEEKLY_GOALS {
|
||||
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
|
||||
root.spawn((
|
||||
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||
));
|
||||
}
|
||||
|
||||
// Unlocks row
|
||||
root.spawn((
|
||||
Text::new(format!(
|
||||
"Card Backs: {} | Backgrounds: {}",
|
||||
format_id_list(&p.unlocked_card_backs),
|
||||
format_id_list(&p.unlocked_backgrounds),
|
||||
)),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.75, 0.75, 0.75)),
|
||||
));
|
||||
}
|
||||
|
||||
// Time Attack section
|
||||
if let Some(ta) = time_attack {
|
||||
if ta.active {
|
||||
let mins = (ta.remaining_secs / 60.0).floor() as u64;
|
||||
let secs = (ta.remaining_secs % 60.0).floor() as u64;
|
||||
root.spawn((
|
||||
Text::new(format!("Time Attack — {mins}m {secs:02}s left | Wins: {}", ta.wins)),
|
||||
TextFont { font_size: 18.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.6, 0.2)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss hint
|
||||
root.spawn((
|
||||
Text::new("Press S to close"),
|
||||
TextFont { font_size: 16.0, ..default() },
|
||||
TextColor(Color::srgb(0.6, 0.6, 0.6)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn a single stat cell: a large value label on top and a small grey
|
||||
/// descriptor below, inside a fixed-width column node with a [`StatsCell`] marker.
|
||||
fn spawn_stat_cell(parent: &mut ChildBuilder, value: &str, label: &str) {
|
||||
parent
|
||||
.spawn((
|
||||
StatsCell,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
min_width: Val::Px(110.0),
|
||||
padding: UiRect::all(Val::Px(8.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.06)),
|
||||
))
|
||||
.with_children(|cell| {
|
||||
// Large value label.
|
||||
cell.spawn((
|
||||
Text::new(value.to_string()),
|
||||
TextFont { font_size: 32.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 1.0, 1.0)),
|
||||
));
|
||||
// Small descriptor below.
|
||||
cell.spawn((
|
||||
Text::new(label.to_string()),
|
||||
TextFont { font_size: 14.0, ..default() },
|
||||
TextColor(Color::srgb(0.65, 0.65, 0.65)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Format a win-rate value for display.
|
||||
///
|
||||
/// Returns `"—"` when no games have been played, otherwise `"N%"`.
|
||||
pub fn format_win_rate(stats: &StatsSnapshot) -> String {
|
||||
match stats.win_rate() {
|
||||
None => "\u{2014}".to_string(),
|
||||
Some(r) => format!("{}%", (r) as u32),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format `fastest_win_seconds` for display.
|
||||
///
|
||||
/// Returns `"—"` when the value is `u64::MAX` (sentinel for "no wins yet") or
|
||||
/// zero. Otherwise delegates to [`format_duration`].
|
||||
pub fn format_fastest_win(fastest_win_seconds: u64) -> String {
|
||||
if fastest_win_seconds == u64::MAX || fastest_win_seconds == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
format_duration(fastest_win_seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format `avg_time_seconds` for display.
|
||||
///
|
||||
/// Returns `"—"` when no games have been won yet (`games_won == 0`), otherwise
|
||||
/// delegates to [`format_duration`].
|
||||
pub fn format_avg_time(stats: &StatsSnapshot) -> String {
|
||||
if stats.games_won == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
format_duration(stats.avg_time_seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an optional `u32` statistic.
|
||||
///
|
||||
/// Returns `"—"` when `value` is `0`, otherwise the decimal representation.
|
||||
pub fn format_optional_u32(value: u32) -> String {
|
||||
if value == 0 {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format any `u32`-like stat value as a decimal string.
|
||||
///
|
||||
/// Unlike [`format_optional_u32`], this always shows the number (even if zero).
|
||||
pub fn format_stat_value<T: std::fmt::Display>(value: T) -> String {
|
||||
format!("{value}")
|
||||
}
|
||||
|
||||
/// Returns XP remaining until next level, formatted as "N XP (P%)".
|
||||
fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
|
||||
let xp_current = if level < 10 {
|
||||
@@ -290,10 +450,13 @@ fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
|
||||
format!("{remaining} XP ({pct}%)")
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
/// Format a duration given in whole seconds as `"M:SS"`.
|
||||
///
|
||||
/// Example: `90` → `"1:30"`.
|
||||
pub fn format_duration(secs: u64) -> String {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}m {s:02}s")
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
|
||||
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
|
||||
@@ -361,6 +524,25 @@ mod tests {
|
||||
assert_eq!(stats.games_played, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_win_increments_draw_three_wins_only() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.0
|
||||
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 200,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats.draw_three_wins, 1, "draw_three_wins must increment for DrawThree mode");
|
||||
assert_eq!(stats.draw_one_wins, 0, "draw_one_wins must not increment for DrawThree mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_after_moves_records_abandoned() {
|
||||
let mut app = headless_app();
|
||||
@@ -474,4 +656,173 @@ mod tests {
|
||||
// At 5000 XP: 0 done, 0%, 1000 remaining.
|
||||
assert_eq!(xp_to_next_level_label(5_000, 10), "1000 XP (0%)");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_duration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_duration_zero_seconds() {
|
||||
assert_eq!(format_duration(0), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_pads_seconds_to_two_digits() {
|
||||
assert_eq!(format_duration(65), "1:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_exactly_one_hour() {
|
||||
assert_eq!(format_duration(3600), "60:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_handles_sub_minute() {
|
||||
assert_eq!(format_duration(59), "0:59");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #65 — win rate and stat cell pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_win_rate_zero() {
|
||||
// 0 wins, 0 played → "—"
|
||||
let s = StatsSnapshot::default();
|
||||
assert_eq!(format_win_rate(&s), "\u{2014}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_win_rate_half() {
|
||||
// 5 wins out of 10 played → "50%"
|
||||
let s = StatsSnapshot {
|
||||
games_played: 10,
|
||||
games_won: 5,
|
||||
..StatsSnapshot::default()
|
||||
};
|
||||
assert_eq!(format_win_rate(&s), "50%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_stat_value_zero_returns_zero() {
|
||||
assert_eq!(format_stat_value(0u32), "0");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #66 — fastest win, best score, streak pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_fastest_win_unset() {
|
||||
// fastest_win_seconds == u64::MAX → "—"
|
||||
assert_eq!(format_fastest_win(u64::MAX), "\u{2014}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_fastest_win_90s() {
|
||||
// 90 seconds → "1:30"
|
||||
assert_eq!(format_fastest_win(90), "1:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_score_display_zero() {
|
||||
// best_single_score == 0 → "—"
|
||||
assert_eq!(format_optional_u32(0), "\u{2014}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #38 — avg time pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_avg_time_no_wins_shows_dash() {
|
||||
// games_won == 0 → "—"
|
||||
let s = StatsSnapshot::default();
|
||||
assert_eq!(format_avg_time(&s), "\u{2014}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_avg_time_after_single_win() {
|
||||
// After one win of 90 s avg should be "1:30"
|
||||
let s = StatsSnapshot {
|
||||
games_won: 1,
|
||||
avg_time_seconds: 90,
|
||||
..StatsSnapshot::default()
|
||||
};
|
||||
assert_eq!(format_avg_time(&s), "1:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_avg_time_after_multiple_wins() {
|
||||
// avg_time_seconds = 200 s → "3:20"
|
||||
let s = StatsSnapshot {
|
||||
games_won: 3,
|
||||
avg_time_seconds: 200,
|
||||
..StatsSnapshot::default()
|
||||
};
|
||||
assert_eq!(format_avg_time(&s), "3:20");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #49 — streak-broken toast on forfeit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn forfeit_with_streak_fires_streak_broken_toast() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// Set up a streak of 3 and at least one move so forfeit counts.
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.win_streak_current = 3;
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.0
|
||||
.move_count = 1;
|
||||
|
||||
app.world_mut().send_event(ForfeitEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let messages: Vec<&str> = reader
|
||||
.read(events)
|
||||
.map(|e| e.0.as_str())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
messages.iter().any(|m| *m == "Streak of 3 broken!"),
|
||||
"expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forfeit_with_streak_of_one_does_not_fire_streak_broken_toast() {
|
||||
let mut app = headless_app();
|
||||
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.win_streak_current = 1;
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.0
|
||||
.move_count = 1;
|
||||
|
||||
app.world_mut().send_event(ForfeitEvent);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<InfoToastEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
let messages: Vec<&str> = reader
|
||||
.read(events)
|
||||
.map(|e| e.0.as_str())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!messages.iter().any(|m| m.contains("broken")),
|
||||
"expected no streak-broken toast for streak of 1, got: {messages:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +124,22 @@ fn apply_theme_on_settings_change(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the single-letter suit symbol used on empty foundation markers.
|
||||
///
|
||||
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
|
||||
pub fn suit_symbol(suit: &Suit) -> &'static str {
|
||||
match suit {
|
||||
Suit::Spades => "S",
|
||||
Suit::Hearts => "H",
|
||||
Suit::Diamonds => "D",
|
||||
Suit::Clubs => "C",
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
let marker_size = layout.card_size;
|
||||
let font_size = layout.card_size.x * 0.28;
|
||||
|
||||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||||
piles.push(PileType::Stock);
|
||||
@@ -140,15 +153,40 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||
|
||||
for pile in piles {
|
||||
let pos = layout.pile_positions[&pile];
|
||||
commands.spawn((
|
||||
let mut entity = commands.spawn((
|
||||
Sprite {
|
||||
color: marker_colour,
|
||||
custom_size: Some(marker_size),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
||||
PileMarker(pile),
|
||||
PileMarker(pile.clone()),
|
||||
));
|
||||
|
||||
// Task #35 — suit symbol on empty foundation placeholders.
|
||||
if let PileType::Foundation(suit) = &pile {
|
||||
let symbol = suit_symbol(suit).to_string();
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new(symbol),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// Task #43 — King indicator on empty tableau placeholders.
|
||||
if let PileType::Tableau(_) = &pile {
|
||||
entity.with_children(|b| {
|
||||
b.spawn((
|
||||
Text2d::new("K"),
|
||||
TextFont { font_size, ..default() },
|
||||
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.35)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,4 +329,26 @@ mod tests {
|
||||
assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
|
||||
assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// suit_symbol pure-function tests (Task #35)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn suit_symbol_returns_correct_letters() {
|
||||
assert_eq!(suit_symbol(&Suit::Spades), "S");
|
||||
assert_eq!(suit_symbol(&Suit::Hearts), "H");
|
||||
assert_eq!(suit_symbol(&Suit::Diamonds), "D");
|
||||
assert_eq!(suit_symbol(&Suit::Clubs), "C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_symbol_all_four_are_distinct() {
|
||||
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
||||
.iter()
|
||||
.map(suit_symbol)
|
||||
.collect();
|
||||
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
||||
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
//! Win summary modal overlay and screen-shake effect.
|
||||
//!
|
||||
//! # Task #33 — Win summary screen
|
||||
//! On `GameWonEvent`, after a 0.5 s delay (so the cascade animation has
|
||||
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
||||
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
||||
//!
|
||||
//! # Task #47 — Win fanfare screen-shake
|
||||
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
||||
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
||||
//! shake duration elapses.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
use crate::events::{
|
||||
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, XpAwardedEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Delay after `GameWonEvent` before the win-summary modal is spawned.
|
||||
/// Chosen so the cascade animation has a moment to start first.
|
||||
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
|
||||
|
||||
/// Duration of the screen-shake in seconds.
|
||||
const SHAKE_DURATION_SECS: f32 = 0.6;
|
||||
/// Maximum camera displacement in world-space pixels at the start of the shake.
|
||||
const SHAKE_INTENSITY: f32 = 8.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Accumulates win data while waiting for `XpAwardedEvent` to arrive.
|
||||
///
|
||||
/// The XP event fires shortly after `GameWonEvent`. We store both pieces of
|
||||
/// data here so the modal can show the complete picture.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct WinSummaryPending {
|
||||
/// Score from the most recent `GameWonEvent`.
|
||||
pub score: i32,
|
||||
/// Elapsed game time (seconds) from the most recent `GameWonEvent`.
|
||||
pub time_seconds: u64,
|
||||
/// XP awarded from the most recent `XpAwardedEvent` (0 until that event fires).
|
||||
pub xp: u64,
|
||||
/// Human-readable breakdown of the XP components for the most recent win,
|
||||
/// e.g. `"+50 base +25 no-undo +30 speed"`. Empty until `GameWonEvent`
|
||||
/// populates it.
|
||||
pub xp_detail: String,
|
||||
/// Whether this win beat the player's previous best score or fastest time.
|
||||
///
|
||||
/// Captured from `StatsResource` **before** `StatsUpdate` mutates it so
|
||||
/// the comparison reflects the old personal-best values.
|
||||
pub new_record: bool,
|
||||
/// When the winning game was a Challenge-mode run, holds the 1-based
|
||||
/// human-readable level number that was just completed (e.g. `Some(3)`
|
||||
/// means "Challenge 3"). `None` for non-Challenge modes.
|
||||
pub challenge_level: Option<u32>,
|
||||
}
|
||||
|
||||
/// Builds a human-readable XP breakdown string for the win modal.
|
||||
///
|
||||
/// Mirrors the logic in `solitaire_data::xp_for_win` so the breakdown always
|
||||
/// matches the total shown on the `XpAwardedEvent`.
|
||||
///
|
||||
/// Examples:
|
||||
/// - slow win, no undo → `"+50 base +25 no-undo"`
|
||||
/// - fast win, undo → `"+50 base +30 speed"`
|
||||
/// - fast win, no undo → `"+50 base +25 no-undo +30 speed"`
|
||||
fn build_xp_detail(time_seconds: u64, used_undo: bool) -> String {
|
||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||
0
|
||||
} else {
|
||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||
scaled.max(10)
|
||||
};
|
||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
||||
|
||||
let mut parts = vec!["+50 base".to_string()];
|
||||
if no_undo_bonus > 0 {
|
||||
parts.push("+25 no-undo".to_string());
|
||||
}
|
||||
if speed_bonus > 0 {
|
||||
parts.push(format!("+{speed_bonus} speed"));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// Drives the camera shake effect after a win.
|
||||
///
|
||||
/// While `remaining > 0` a system applies a decaying sinusoidal offset to the
|
||||
/// main camera's `Transform`. The system resets the camera to the origin when
|
||||
/// `remaining` reaches zero.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct ScreenShakeResource {
|
||||
/// Seconds of shake remaining.
|
||||
pub remaining: f32,
|
||||
/// Peak displacement in world-space pixels (decays to zero over `remaining`).
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker on the win-summary modal root entity.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WinSummaryOverlay;
|
||||
|
||||
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||
#[derive(Component, Debug)]
|
||||
enum WinSummaryButton {
|
||||
PlayAgain,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers the win-summary modal and screen-shake systems.
|
||||
pub struct WinSummaryPlugin;
|
||||
|
||||
impl Plugin for WinSummaryPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<WinSummaryPending>()
|
||||
.init_resource::<ScreenShakeResource>()
|
||||
.init_resource::<SessionAchievements>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<XpAwardedEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
.add_event::<InfoToastEvent>()
|
||||
.add_event::<AchievementUnlockedEvent>()
|
||||
// `cache_win_data` must run BEFORE `StatsUpdate` so it can compare
|
||||
// the player's old personal-best values before `StatsPlugin` overwrites them.
|
||||
.add_systems(
|
||||
Update,
|
||||
cache_win_data
|
||||
.after(GameMutation)
|
||||
.before(StatsUpdate),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
collect_session_achievements,
|
||||
spawn_win_summary_after_delay,
|
||||
handle_win_summary_buttons,
|
||||
apply_screen_shake,
|
||||
)
|
||||
.after(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Formats `seconds` as `m:ss`.
|
||||
///
|
||||
/// ```
|
||||
/// # use solitaire_engine::win_summary_plugin::format_win_time;
|
||||
/// assert_eq!(format_win_time(0), "0:00");
|
||||
/// assert_eq!(format_win_time(65), "1:05");
|
||||
/// assert_eq!(format_win_time(3661), "61:01");
|
||||
/// ```
|
||||
pub fn format_win_time(seconds: u64) -> String {
|
||||
let m = seconds / 60;
|
||||
let s = seconds % 60;
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Caches score/time from `GameWonEvent` and XP from `XpAwardedEvent` into
|
||||
/// `WinSummaryPending` so they are available when the modal spawns.
|
||||
///
|
||||
/// Also compares the win result against the player's previous personal bests
|
||||
/// **before** `StatsUpdate` overwrites them, setting `WinSummaryPending::new_record`
|
||||
/// and queuing an `InfoToastEvent` when the player sets a new record.
|
||||
///
|
||||
/// When the winning game is in `GameMode::Challenge`, the current
|
||||
/// `challenge_index` (before `ChallengePlugin` advances it) is captured as the
|
||||
/// 1-based level number and stored in `WinSummaryPending::challenge_level`.
|
||||
///
|
||||
/// This system is scheduled `.before(StatsUpdate)` so the comparison always
|
||||
/// sees the old best values.
|
||||
fn cache_win_data(
|
||||
mut won: EventReader<GameWonEvent>,
|
||||
mut xp: EventReader<XpAwardedEvent>,
|
||||
mut pending: ResMut<WinSummaryPending>,
|
||||
stats: Res<StatsResource>,
|
||||
game: Res<GameStateResource>,
|
||||
progress: Res<ProgressResource>,
|
||||
mut toast: EventWriter<InfoToastEvent>,
|
||||
) {
|
||||
for ev in won.read() {
|
||||
// Compare against old personal bests BEFORE StatsPlugin updates them.
|
||||
// `best_single_score == 0` means no wins yet — any positive score is a record.
|
||||
// `fastest_win_seconds == u64::MAX` is the sentinel for "no wins yet".
|
||||
let beats_score = ev.score > 0 && ev.score as u32 > stats.0.best_single_score;
|
||||
let beats_time = stats.0.fastest_win_seconds == u64::MAX
|
||||
|| ev.time_seconds < stats.0.fastest_win_seconds;
|
||||
let is_new_record = beats_score || beats_time;
|
||||
|
||||
// Capture the challenge level (1-based) before ChallengePlugin advances
|
||||
// the index. Only populated for Challenge-mode wins.
|
||||
let challenge_level = if game.0.mode == GameMode::Challenge {
|
||||
Some(progress.0.challenge_index.saturating_add(1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let used_undo = game.0.undo_count > 0;
|
||||
pending.score = ev.score;
|
||||
pending.time_seconds = ev.time_seconds;
|
||||
pending.xp = 0; // reset; XP event follows
|
||||
pending.xp_detail = build_xp_detail(ev.time_seconds, used_undo);
|
||||
pending.new_record = is_new_record;
|
||||
pending.challenge_level = challenge_level;
|
||||
|
||||
if is_new_record {
|
||||
toast.send(InfoToastEvent("New Record!".to_string()));
|
||||
}
|
||||
}
|
||||
for ev in xp.read() {
|
||||
pending.xp = ev.amount;
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulates achievement names unlocked this session and resets them on a new game.
|
||||
///
|
||||
/// Listens for `AchievementUnlockedEvent` and appends the human-readable name
|
||||
/// of each newly unlocked achievement to `SessionAchievements`. Clears the list
|
||||
/// whenever `NewGameRequestEvent` fires so each fresh game starts clean.
|
||||
///
|
||||
/// All mode-switch keys (Z → Zen, X → Challenge, C → Daily Challenge,
|
||||
/// T → Time Attack) route through `NewGameRequestEvent`, so this single
|
||||
/// reader covers every implicit game-context reset in addition to the
|
||||
/// explicit N / "Play Again" new-game requests.
|
||||
fn collect_session_achievements(
|
||||
mut unlocks: EventReader<AchievementUnlockedEvent>,
|
||||
mut new_games: EventReader<NewGameRequestEvent>,
|
||||
mut session: ResMut<SessionAchievements>,
|
||||
) {
|
||||
// Reset on any new-game request (including mode switches via Z/X/C/T) so
|
||||
// achievements from the previous session are not carried into the next one.
|
||||
if new_games.read().last().is_some() {
|
||||
session.names.clear();
|
||||
}
|
||||
for ev in unlocks.read() {
|
||||
session.names.push(display_name_for(&ev.0.id));
|
||||
}
|
||||
}
|
||||
|
||||
/// After `GameWonEvent`, arms the screen-shake resource.
|
||||
///
|
||||
/// This system shares the `GameWonEvent` stream with `cache_win_data` through
|
||||
/// the delay timer stored in `Local` — the shake fires immediately, while the
|
||||
/// modal waits 0.5 s.
|
||||
///
|
||||
/// Just before the overlay is spawned the system also drains any pending
|
||||
/// `XpAwardedEvent`s and folds their amounts into `pending.xp`. This guards
|
||||
/// against the edge case where `XpAwardedEvent` arrives in the same frame as
|
||||
/// the timer fires but `cache_win_data` runs *after* this system in that
|
||||
/// frame's schedule, which would otherwise leave `pending.xp` at 0 when
|
||||
/// `spawn_overlay` reads it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_win_summary_after_delay(
|
||||
mut commands: Commands,
|
||||
mut won: EventReader<GameWonEvent>,
|
||||
mut xp_events: EventReader<XpAwardedEvent>,
|
||||
mut shake: ResMut<ScreenShakeResource>,
|
||||
mut pending: ResMut<WinSummaryPending>,
|
||||
session: Res<SessionAchievements>,
|
||||
time: Res<Time>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut delay: Local<Option<f32>>,
|
||||
) {
|
||||
// Process new win events.
|
||||
for _ in won.read() {
|
||||
// Arm the screen shake immediately.
|
||||
shake.remaining = SHAKE_DURATION_SECS;
|
||||
shake.intensity = SHAKE_INTENSITY;
|
||||
// Start the delay timer (overwrite if a second win arrives).
|
||||
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
||||
// Clear any stale overlay from a previous win.
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
}
|
||||
|
||||
// Tick the delay timer.
|
||||
if let Some(remaining) = delay.as_mut() {
|
||||
*remaining -= time.delta_secs();
|
||||
if *remaining <= 0.0 {
|
||||
*delay = None;
|
||||
// Only spawn if there is no overlay already.
|
||||
if overlays.is_empty() {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the win-summary modal and fires `NewGameRequestEvent` when
|
||||
/// the player presses "Play Again".
|
||||
fn handle_win_summary_buttons(
|
||||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
match button {
|
||||
WinSummaryButton::PlayAgain => {
|
||||
// Despawn the modal.
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a decaying sinusoidal offset to the main `Camera2d` each frame
|
||||
/// while `ScreenShakeResource::remaining > 0`.
|
||||
///
|
||||
/// Uses a deterministic oscillation (`sin`/`cos` of total elapsed time) to
|
||||
/// avoid a dependency on a random-number crate in this crate.
|
||||
fn apply_screen_shake(
|
||||
mut shake: ResMut<ScreenShakeResource>,
|
||||
time: Res<Time>,
|
||||
mut cameras: Query<&mut Transform, With<Camera2d>>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
if shake.remaining <= 0.0 {
|
||||
// Ensure the camera is back at origin whenever shake is idle.
|
||||
for mut t in &mut cameras {
|
||||
t.translation.x = 0.0;
|
||||
t.translation.y = 0.0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
shake.remaining = (shake.remaining - dt).max(0.0);
|
||||
// Decay factor: 1.0 at start, 0.0 at end.
|
||||
let decay = shake.remaining / SHAKE_DURATION_SECS;
|
||||
let elapsed = time.elapsed_secs();
|
||||
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
||||
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
|
||||
|
||||
for mut t in &mut cameras {
|
||||
t.translation.x = offset_x;
|
||||
t.translation.y = offset_y;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns the full-screen win-summary modal.
|
||||
///
|
||||
/// `challenge_level` is `Some(N)` when the win was a Challenge-mode completion;
|
||||
/// a "Challenge N complete!" annotation is added to the modal header in that case.
|
||||
fn spawn_overlay(
|
||||
commands: &mut Commands,
|
||||
pending: &WinSummaryPending,
|
||||
session: &SessionAchievements,
|
||||
challenge_level: Option<u32>,
|
||||
) {
|
||||
commands
|
||||
.spawn((
|
||||
WinSummaryOverlay,
|
||||
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::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.70)),
|
||||
ZIndex(300),
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(Val::Px(36.0)),
|
||||
row_gap: Val::Px(18.0),
|
||||
min_width: Val::Px(320.0),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
|
||||
BorderRadius::all(Val::Px(12.0)),
|
||||
))
|
||||
.with_children(|card| {
|
||||
// Heading
|
||||
card.spawn((
|
||||
Text::new("You Won!"),
|
||||
TextFont { font_size: 42.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||
));
|
||||
|
||||
// Challenge-mode annotation — shown only for Challenge wins.
|
||||
if let Some(level) = challenge_level {
|
||||
card.spawn((
|
||||
Text::new(format!("Challenge {level} complete!")),
|
||||
TextFont { font_size: 28.0, ..default() },
|
||||
TextColor(Color::srgb(0.4, 0.85, 1.0)),
|
||||
));
|
||||
}
|
||||
|
||||
// New Record badge — shown only when the player beats their
|
||||
// previous best score or fastest win time.
|
||||
if pending.new_record {
|
||||
card.spawn((
|
||||
Text::new("New Record!"),
|
||||
TextFont { font_size: 26.0, ..default() },
|
||||
TextColor(Color::srgb(1.0, 0.55, 0.0)),
|
||||
));
|
||||
}
|
||||
|
||||
// Score
|
||||
card.spawn((
|
||||
Text::new(format!("Score: {}", pending.score)),
|
||||
TextFont { font_size: 26.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
|
||||
// Time
|
||||
card.spawn((
|
||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||
TextFont { font_size: 26.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
|
||||
// XP total
|
||||
card.spawn((
|
||||
Text::new(format!("XP earned: +{}", pending.xp)),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::srgb(0.4, 1.0, 0.4)),
|
||||
));
|
||||
|
||||
// XP breakdown (smaller, dimmer text)
|
||||
if !pending.xp_detail.is_empty() {
|
||||
card.spawn((
|
||||
Text::new(pending.xp_detail.clone()),
|
||||
TextFont { font_size: 15.0, ..default() },
|
||||
TextColor(Color::srgb(0.55, 0.80, 0.55)),
|
||||
));
|
||||
}
|
||||
|
||||
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||
// excess is summarised with "...and N more".
|
||||
if !session.names.is_empty() {
|
||||
spawn_achievements_section(card, &session.names);
|
||||
}
|
||||
|
||||
// Play Again button
|
||||
card.spawn((
|
||||
WinSummaryButton::PlayAgain,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
margin: UiRect::top(Val::Px(8.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
||||
BorderRadius::all(Val::Px(6.0)),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Play Again"),
|
||||
TextFont { font_size: 22.0, ..default() },
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_data::{PlayerProgress, StatsSnapshot};
|
||||
|
||||
/// Build a minimal app with `WinSummaryPlugin` and all resources required
|
||||
/// by `cache_win_data`: `StatsResource`, `GameStateResource`, and
|
||||
/// `ProgressResource`.
|
||||
fn make_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||
.insert_resource(GameStateResource(GameState::new(0, solitaire_core::game_state::DrawMode::DrawOne)))
|
||||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_win_time_zero() {
|
||||
assert_eq!(format_win_time(0), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_win_time_one_minute_five_seconds() {
|
||||
assert_eq!(format_win_time(65), "1:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_win_time_exact_minute() {
|
||||
assert_eq!(format_win_time(120), "2:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_win_time_large() {
|
||||
// 3661 s = 61 min 1 s
|
||||
assert_eq!(format_win_time(3661), "61:01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_win_time_59_seconds() {
|
||||
assert_eq!(format_win_time(59), "0:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_shake_resource_default_is_idle() {
|
||||
let shake = ScreenShakeResource::default();
|
||||
assert!(shake.remaining <= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_summary_pending_default_is_zeroed() {
|
||||
let p = WinSummaryPending::default();
|
||||
assert_eq!(p.score, 0);
|
||||
assert_eq!(p.time_seconds, 0);
|
||||
assert_eq!(p.xp, 0);
|
||||
assert!(p.xp_detail.is_empty());
|
||||
assert!(!p.new_record);
|
||||
assert!(p.challenge_level.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_slow_win_with_undo() {
|
||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||
let detail = build_xp_detail(300, true);
|
||||
assert_eq!(detail, "+50 base");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_slow_win_no_undo() {
|
||||
let detail = build_xp_detail(300, false);
|
||||
assert_eq!(detail, "+50 base +25 no-undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_fast_win_with_undo() {
|
||||
// 0s → speed bonus 50.
|
||||
let detail = build_xp_detail(0, true);
|
||||
assert_eq!(detail, "+50 base +50 speed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_fast_win_no_undo() {
|
||||
let detail = build_xp_detail(0, false);
|
||||
assert_eq!(detail, "+50 base +25 no-undo +50 speed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_summary_plugin_inserts_resources() {
|
||||
let app = make_app();
|
||||
assert!(app.world().get_resource::<WinSummaryPending>().is_some());
|
||||
assert!(app.world().get_resource::<ScreenShakeResource>().is_some());
|
||||
assert!(app.world().get_resource::<SessionAchievements>().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_achievements_accumulates_unlock_events() {
|
||||
let mut app = make_app();
|
||||
|
||||
use solitaire_data::AchievementRecord;
|
||||
let record = AchievementRecord::locked("first_win");
|
||||
app.world_mut()
|
||||
.send_event(AchievementUnlockedEvent(record));
|
||||
app.update();
|
||||
|
||||
let session = app.world().resource::<SessionAchievements>();
|
||||
assert_eq!(session.names.len(), 1);
|
||||
// display_name_for("first_win") == "First Win"
|
||||
assert_eq!(session.names[0], "First Win");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_achievements_resets_on_new_game_request() {
|
||||
let mut app = make_app();
|
||||
|
||||
use solitaire_data::AchievementRecord;
|
||||
let record = AchievementRecord::locked("first_win");
|
||||
app.world_mut()
|
||||
.send_event(AchievementUnlockedEvent(record));
|
||||
app.update();
|
||||
|
||||
// Confirm it was recorded.
|
||||
assert_eq!(
|
||||
app.world().resource::<SessionAchievements>().names.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Fire NewGameRequestEvent — should clear the list.
|
||||
app.world_mut().send_event(NewGameRequestEvent::default());
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||||
"session achievements must be cleared on NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that mode-switch new-game requests (Z/X/C/T keys) also clear
|
||||
/// `SessionAchievements`. All mode switches route through
|
||||
/// `NewGameRequestEvent` with a non-`None` `mode` or `seed` field, so
|
||||
/// this test uses `GameMode::Zen` as a representative case; the same path
|
||||
/// is taken for Challenge, Daily Challenge, and Time Attack.
|
||||
#[test]
|
||||
fn session_achievements_resets_on_mode_switch_new_game_request() {
|
||||
let mut app = make_app();
|
||||
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_data::AchievementRecord;
|
||||
|
||||
// Simulate an achievement unlock during the current session.
|
||||
let record = AchievementRecord::locked("first_win");
|
||||
app.world_mut()
|
||||
.send_event(AchievementUnlockedEvent(record));
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<SessionAchievements>().names.len(),
|
||||
1,
|
||||
"achievement should be recorded before the mode switch"
|
||||
);
|
||||
|
||||
// Simulate pressing Z (Zen mode switch) — fires NewGameRequestEvent
|
||||
// with mode = Some(Zen). Same event shape used by X (Challenge),
|
||||
// C (Daily Challenge), and T (Time Attack).
|
||||
app.world_mut().send_event(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(GameMode::Zen),
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
app.world().resource::<SessionAchievements>().names.is_empty(),
|
||||
"session achievements must be cleared when a mode-switch NewGameRequestEvent fires"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_win_data_sets_score_and_time() {
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert_eq!(pending.score, 1234);
|
||||
assert_eq!(pending.time_seconds, 90);
|
||||
// 90s < 120s → speed bonus present; default game has undo_count=0 → no-undo bonus present.
|
||||
assert!(!pending.xp_detail.is_empty(), "xp_detail must be populated on GameWonEvent");
|
||||
assert!(pending.xp_detail.contains("+50 base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert_eq!(pending.xp, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_won_event_arms_screen_shake() {
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.update();
|
||||
|
||||
let shake = app.world().resource::<ScreenShakeResource>();
|
||||
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// New Record detection tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn first_win_is_always_a_new_record() {
|
||||
// Default stats: best_single_score=0, fastest_win_seconds=u64::MAX.
|
||||
// Any positive-score win should be flagged as a new record.
|
||||
let mut app = make_app();
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "first win should always set new_record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_that_beats_best_score_sets_new_record() {
|
||||
let mut app = make_app();
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.best_single_score = 400;
|
||||
stats.0.fastest_win_seconds = 200;
|
||||
}
|
||||
|
||||
// Score 500 beats previous best of 400.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 300 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "beating best score should set new_record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_that_beats_fastest_time_sets_new_record() {
|
||||
let mut app = make_app();
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.best_single_score = 800;
|
||||
stats.0.fastest_win_seconds = 200;
|
||||
}
|
||||
|
||||
// Score 500 does not beat 800, but time 100 < 200.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 100 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(pending.new_record, "beating fastest time should set new_record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_below_personal_bests_does_not_set_new_record() {
|
||||
let mut app = make_app();
|
||||
{
|
||||
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
||||
stats.0.best_single_score = 800;
|
||||
stats.0.fastest_win_seconds = 60;
|
||||
}
|
||||
|
||||
// Score 500 < 800 and time 120 > 60 — neither record broken.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 500, time_seconds: 120 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(
|
||||
!pending.new_record,
|
||||
"win below both personal bests must not set new_record"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Challenge-level capture tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn challenge_win_captures_level_number() {
|
||||
let mut app = make_app();
|
||||
|
||||
// Set challenge_index = 4 so the completed level is 5 (1-based).
|
||||
app.world_mut()
|
||||
.resource_mut::<ProgressResource>()
|
||||
.0
|
||||
.challenge_index = 4;
|
||||
// Switch game mode to Challenge.
|
||||
{
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
|
||||
}
|
||||
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert_eq!(
|
||||
pending.challenge_level,
|
||||
Some(5),
|
||||
"challenge_level must be 1-based index of the completed challenge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_win_leaves_challenge_level_none() {
|
||||
let mut app = make_app();
|
||||
// Default game mode is Classic — challenge_level should stay None.
|
||||
app.world_mut()
|
||||
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||
app.update();
|
||||
|
||||
let pending = app.world().resource::<WinSummaryPending>();
|
||||
assert!(
|
||||
pending.challenge_level.is_none(),
|
||||
"challenge_level must be None for non-Challenge wins"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,65 @@ pub async fn delete_account(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
|
||||
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
|
||||
|
||||
fn decode_token(token: &str) -> Claims {
|
||||
let mut validation = Validation::default();
|
||||
validation.leeway = 60;
|
||||
decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.unwrap()
|
||||
.claims
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_access_token_decodes_with_correct_claims() {
|
||||
let token = make_access_token("user-123", TEST_SECRET).unwrap();
|
||||
let claims = decode_token(&token);
|
||||
assert_eq!(claims.sub, "user-123");
|
||||
assert_eq!(claims.kind, "access");
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
// expiry should be roughly 24 hours in the future (allow ±60s for test execution)
|
||||
assert!(claims.exp > now + 86_400 - 60);
|
||||
assert!(claims.exp < now + 86_400 + 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_refresh_token_decodes_with_correct_claims() {
|
||||
let token = make_refresh_token("user-456", TEST_SECRET).unwrap();
|
||||
let claims = decode_token(&token);
|
||||
assert_eq!(claims.sub, "user-456");
|
||||
assert_eq!(claims.kind, "refresh");
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
// expiry should be roughly 30 days in the future (allow ±60s for test execution)
|
||||
assert!(claims.exp > now + 30 * 86_400 - 60);
|
||||
assert!(claims.exp < now + 30 * 86_400 + 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_access_token_wrong_secret_fails_decode() {
|
||||
let token = make_access_token("user-789", TEST_SECRET).unwrap();
|
||||
let result = decode::<Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(b"wrong_secret"),
|
||||
&Validation::default(),
|
||||
);
|
||||
assert!(result.is_err(), "decoding with wrong secret must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_and_refresh_tokens_have_different_kinds() {
|
||||
let access = make_access_token("u", TEST_SECRET).unwrap();
|
||||
let refresh = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||
let a_claims = decode_token(&access);
|
||||
let r_claims = decode_token(&refresh);
|
||||
assert_ne!(a_claims.kind, r_claims.kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_chars_ok_accepts_alphanumeric_and_underscore() {
|
||||
|
||||
@@ -194,4 +194,24 @@ mod tests {
|
||||
assert!(g.target_score.is_none());
|
||||
assert!(g.max_time_secs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_goal_all_variants_have_sane_ranges() {
|
||||
for variant_idx in 0u64..6 {
|
||||
let g = generate_goal("2026-04-26", variant_idx);
|
||||
assert!(!g.description.is_empty(), "variant {variant_idx}: description must not be empty");
|
||||
if let Some(t) = g.max_time_secs {
|
||||
assert!(
|
||||
(60..=3600).contains(&t),
|
||||
"variant {variant_idx}: max_time_secs {t} outside [60, 3600]"
|
||||
);
|
||||
}
|
||||
if let Some(s) = g.target_score {
|
||||
assert!(
|
||||
(1_000..=10_000).contains(&s),
|
||||
"variant {variant_idx}: target_score {s} outside [1000, 10000]"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use solitaire_server::build_test_router;
|
||||
@@ -1052,3 +1052,88 @@ async fn login_trims_whitespace_from_username() {
|
||||
"login with whitespace-padded username must succeed"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
|
||||
#[tokio::test]
|
||||
async fn push_oversized_body_returns_413() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
|
||||
|
||||
// 1_100_000-byte string embedded in JSON comfortably exceeds the 1 MB limit.
|
||||
let big_string = "x".repeat(1_100_000);
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/sync/push")
|
||||
.header("content-type", "application/json")
|
||||
.header("Authorization", format!("Bearer {access}"))
|
||||
.header("x-forwarded-for", TEST_CLIENT_IP)
|
||||
.body(Body::from(body_bytes))
|
||||
.expect("failed to build oversized request");
|
||||
|
||||
let resp = app.oneshot(req).await.expect("oneshot failed");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"oversized body must be rejected with 413"
|
||||
);
|
||||
}
|
||||
|
||||
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
|
||||
#[tokio::test]
|
||||
async fn expired_access_token_returns_401() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
|
||||
#[derive(serde::Serialize)]
|
||||
struct ExpiredClaims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
kind: String,
|
||||
}
|
||||
let exp = (chrono::Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||
let expired_token = encode(
|
||||
&Header::default(),
|
||||
&ExpiredClaims {
|
||||
sub: "00000000-0000-0000-0000-000000000000".into(),
|
||||
exp,
|
||||
kind: "access".into(),
|
||||
},
|
||||
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = get_authed(app, "/api/sync/pull", &expired_token).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"expired JWT must be rejected with 401"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refresh token must be rejected when used as a Bearer token on protected routes.
|
||||
#[tokio::test]
|
||||
async fn refresh_token_rejected_on_protected_routes() {
|
||||
set_jwt_secret();
|
||||
let app = build_test_router(test_pool().await);
|
||||
|
||||
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
|
||||
|
||||
// Using the refresh token (kind = "refresh") as a Bearer on a protected route
|
||||
// must return 401 because the middleware requires kind = "access".
|
||||
let resp = get_authed(app, "/api/sync/pull", &refresh).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"refresh token must be rejected on protected endpoints"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -642,4 +642,26 @@ mod tests {
|
||||
assert_eq!(merged.progress.weekly_goal_week_iso, Some("2026-W17".to_string()));
|
||||
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastest_win_both_max_sentinel_stays_max() {
|
||||
// Both sides have u64::MAX (no wins recorded on either) — result must remain MAX,
|
||||
// not wrap or clamp to 0.
|
||||
let local = default_payload();
|
||||
let remote = default_payload();
|
||||
assert_eq!(local.stats.fastest_win_seconds, u64::MAX);
|
||||
assert_eq!(remote.stats.fastest_win_seconds, u64::MAX);
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.fastest_win_seconds, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastest_win_one_side_max_takes_real_value() {
|
||||
// Local has no wins (u64::MAX); remote has a real win. Merged must use the real time.
|
||||
let local = default_payload(); // fastest_win_seconds = u64::MAX
|
||||
let mut remote = default_payload();
|
||||
remote.stats.fastest_win_seconds = 300;
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.fastest_win_seconds, 300);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user