feat(engine,core): add Time Attack mode + unlocks panel

- Core: GameMode::TimeAttack variant (no scoring/undo changes — session marker only)
- Engine: TimeAttackPlugin with TimeAttackResource, TimeAttackEndedEvent,
  T hotkey (gated to level >= 5), auto-deal on win, summary toast
- Engine: Stats overlay (S) gains an Unlocks subsection (card backs /
  backgrounds, sorted/deduped) and a live Time Attack panel while active

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-25 17:27:53 -07:00
parent 294f6fe9d4
commit 193410200e
7 changed files with 367 additions and 9 deletions
+17
View File
@@ -12,6 +12,7 @@ use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::progress_plugin::LevelUpEvent;
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
/// Duration of a card slide (move) animation in seconds.
@@ -22,6 +23,7 @@ const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
const LEVELUP_TOAST_SECS: f32 = 3.0;
const DAILY_TOAST_SECS: f32 = 3.0;
const WEEKLY_TOAST_SECS: f32 = 3.0;
const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
const CASCADE_STAGGER: f32 = 0.05;
const CASCADE_DURATION: f32 = 0.5;
@@ -59,6 +61,7 @@ impl Plugin for AnimationPlugin {
.add_event::<LevelUpEvent>()
.add_event::<DailyChallengeCompletedEvent>()
.add_event::<WeeklyGoalCompletedEvent>()
.add_event::<TimeAttackEndedEvent>()
.add_systems(
Update,
(
@@ -68,6 +71,7 @@ impl Plugin for AnimationPlugin {
handle_levelup_toast,
handle_daily_toast,
handle_weekly_toast,
handle_time_attack_toast,
tick_toasts,
)
.after(GameMutation),
@@ -181,6 +185,19 @@ fn handle_weekly_toast(
}
}
fn handle_time_attack_toast(
mut commands: Commands,
mut events: EventReader<TimeAttackEndedEvent>,
) {
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
TIME_ATTACK_TOAST_SECS,
);
}
}
fn tick_toasts(
mut commands: Commands,
time: Res<Time>,
+4
View File
@@ -13,6 +13,7 @@ pub mod progress_plugin;
pub mod resources;
pub mod stats_plugin;
pub mod table_plugin;
pub mod time_attack_plugin;
pub mod weekly_goals_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
@@ -36,3 +37,6 @@ pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
pub use time_attack_plugin::{
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
};
+56 -1
View File
@@ -18,6 +18,7 @@ use crate::events::{GameWonEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
/// Bevy resource wrapping the current stats.
#[derive(Resource, Debug, Clone)]
@@ -127,6 +128,7 @@ fn toggle_stats_screen(
keys: Res<ButtonInput<KeyCode>>,
stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !keys.just_pressed(KeyCode::KeyS) {
@@ -135,7 +137,12 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.get_single() {
commands.entity(entity).despawn_recursive();
} else {
spawn_stats_screen(&mut commands, &stats.0, progress.as_deref().map(|p| &p.0));
spawn_stats_screen(
&mut commands,
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
);
}
}
@@ -143,6 +150,7 @@ fn spawn_stats_screen(
commands: &mut Commands,
stats: &StatsSnapshot,
progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>,
) {
let win_rate = stats
.win_rate()
@@ -194,6 +202,27 @@ fn spawn_stats_screen(
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());
@@ -237,6 +266,22 @@ fn format_duration(secs: u64) -> String {
format!("{m}m {s:02}s")
}
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
/// Empty list shows as "None".
fn format_id_list(ids: &[usize]) -> String {
if ids.is_empty() {
return "None".to_string();
}
let mut sorted: Vec<usize> = ids.to_vec();
sorted.sort_unstable();
sorted.dedup();
sorted
.iter()
.map(|i| format!("#{i}"))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -369,4 +414,14 @@ mod tests {
0
);
}
#[test]
fn format_id_list_renders_empty_as_none() {
assert_eq!(format_id_list(&[]), "None");
}
#[test]
fn format_id_list_sorts_dedups_and_prefixes() {
assert_eq!(format_id_list(&[3, 1, 1, 2]), "#1, #2, #3");
}
}
+268
View File
@@ -0,0 +1,268 @@
//! Time Attack mode runtime: 10-minute countdown wrapped around back-to-back
//! `GameMode::TimeAttack` games. Pressing **T** starts a session (gated by
//! level ≥ `CHALLENGE_UNLOCK_LEVEL`); each win during the session bumps the
//! counter and auto-deals a fresh game. When the timer expires the session
//! ends and `TimeAttackEndedEvent` fires.
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{GameWonEvent, NewGameRequestEvent};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
/// Length of a Time Attack session in real-world seconds (10 minutes).
pub const TIME_ATTACK_DURATION_SECS: f32 = 600.0;
/// Session state for an in-progress Time Attack run. Not persisted.
#[derive(Resource, Debug, Clone, Default)]
pub struct TimeAttackResource {
pub active: bool,
pub remaining_secs: f32,
pub wins: u32,
}
/// Fired when the Time Attack timer expires. The summary toast in
/// `AnimationPlugin` consumes this; UI/stats consumers can also subscribe.
#[derive(Event, Debug, Clone, Copy)]
pub struct TimeAttackEndedEvent {
pub wins: u32,
}
pub struct TimeAttackPlugin;
impl Plugin for TimeAttackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TimeAttackResource>()
.add_event::<TimeAttackEndedEvent>()
.add_event::<GameWonEvent>()
.add_event::<NewGameRequestEvent>()
.add_systems(
Update,
handle_start_time_attack_request.before(GameMutation),
)
.add_systems(Update, advance_time_attack)
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation));
}
}
fn handle_start_time_attack_request(
keys: Res<ButtonInput<KeyCode>>,
progress: Res<ProgressResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
) {
if !keys.just_pressed(KeyCode::KeyT) {
return;
}
if progress.0.level < CHALLENGE_UNLOCK_LEVEL {
info!(
"Time Attack locked — reach level {} (currently {}).",
CHALLENGE_UNLOCK_LEVEL, progress.0.level
);
return;
}
*session = TimeAttackResource {
active: true,
remaining_secs: TIME_ATTACK_DURATION_SECS,
wins: 0,
};
new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
}
fn advance_time_attack(
time: Res<Time>,
mut session: ResMut<TimeAttackResource>,
mut ended: EventWriter<TimeAttackEndedEvent>,
) {
if !session.active {
return;
}
session.remaining_secs -= time.delta_secs();
if session.remaining_secs <= 0.0 {
let wins = session.wins;
session.active = false;
session.remaining_secs = 0.0;
ended.send(TimeAttackEndedEvent { wins });
}
}
fn auto_deal_on_time_attack_win(
mut wins: EventReader<GameWonEvent>,
game: Res<GameStateResource>,
mut session: ResMut<TimeAttackResource>,
mut new_game: EventWriter<NewGameRequestEvent>,
) {
for _ in wins.read() {
if !session.active || game.0.mode != GameMode::TimeAttack {
continue;
}
session.wins = session.wins.saturating_add(1);
new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(GameMode::TimeAttack),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(TimeAttackPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press_t(app: &mut App) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyT);
input.clear();
input.press(KeyCode::KeyT);
}
#[test]
fn pressing_t_below_unlock_level_is_ignored() {
let mut app = headless_app();
press_t(&mut app);
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert!(!session.active);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
#[test]
fn pressing_t_at_unlock_level_starts_session_and_deals_time_attack_game() {
let mut app = headless_app();
app.world_mut().resource_mut::<ProgressResource>().0.level = CHALLENGE_UNLOCK_LEVEL;
press_t(&mut app);
app.update();
let session = app.world().resource::<TimeAttackResource>().clone();
assert!(session.active);
assert_eq!(session.wins, 0);
assert!((session.remaining_secs - TIME_ATTACK_DURATION_SECS).abs() < 1.0);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].mode, Some(GameMode::TimeAttack));
}
#[test]
fn timer_expiry_fires_ended_event_and_clears_active() {
let mut app = headless_app();
// Manually start a near-expired session.
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: 0.001,
wins: 5,
};
app.update();
// First update advances time slightly; force the timer past zero.
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: -1.0,
wins: 5,
};
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert!(!session.active);
assert_eq!(session.remaining_secs, 0.0);
let events = app.world().resource::<Events<TimeAttackEndedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].wins, 5);
}
#[test]
fn win_during_session_increments_wins_and_auto_deals() {
let mut app = headless_app();
// Start a session manually.
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: 100.0,
wins: 0,
};
// The current game must be in TimeAttack mode for auto-deal to fire.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 1);
let events = app.world().resource::<Events<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
assert_eq!(fired[0].mode, Some(GameMode::TimeAttack));
assert!(fired[0].seed.is_none());
}
#[test]
fn win_when_session_inactive_does_not_increment() {
let mut app = headless_app();
// Default session is inactive. Game is TimeAttack mode — still no count.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::TimeAttack);
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 0);
}
#[test]
fn classic_win_during_session_does_not_increment() {
let mut app = headless_app();
*app.world_mut().resource_mut::<TimeAttackResource>() = TimeAttackResource {
active: true,
remaining_secs: 100.0,
wins: 0,
};
// GameStateResource defaults to Classic mode.
app.world_mut().send_event(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let session = app.world().resource::<TimeAttackResource>();
assert_eq!(session.wins, 0);
}
}