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
+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");
}
}