feat(engine): in-game HUD — score, move count, elapsed time, mode badge

Adds HudPlugin with a persistent top-left overlay that shows score,
move count, and elapsed time during every game. A mode badge highlights
DAILY, CHALLENGE, ZEN, or TIME ATTACK when the game is not in Classic
mode. HUD updates whenever GameStateResource changes (moves and per-second
time ticks) without a separate polling system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 01:51:50 +00:00
parent 299e0c6a94
commit fff8c66bf7
3 changed files with 142 additions and 3 deletions
+4 -3
View File
@@ -2,9 +2,9 @@ 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, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin,
TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, HudPlugin, InputPlugin,
LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin,
SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin,
};
fn main() {
@@ -39,6 +39,7 @@ fn main() {
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
+136
View File
@@ -0,0 +1,136 @@
//! Persistent in-game HUD: score, move count, elapsed time, and mode badge.
//!
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
//! refreshed whenever `GameStateResource` changes (which happens on every move
//! and every elapsed-time tick), so score, moves, and timer all stay current
//! without a separate tick system.
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource;
/// Marker on the score text node.
#[derive(Component, Debug)]
struct HudScore;
/// Marker on the move-count text node.
#[derive(Component, Debug)]
struct HudMoves;
/// Marker on the elapsed-time text node.
#[derive(Component, Debug)]
struct HudTime;
/// Marker on the mode badge text node.
#[derive(Component, Debug)]
struct HudMode;
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
const Z_HUD: i32 = 50;
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));
}
}
fn spawn_hud(mut commands: Commands) {
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
let font = TextFont { font_size: 18.0, ..default() };
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(12.0),
top: Val::Px(8.0),
flex_direction: FlexDirection::Row,
column_gap: Val::Px(20.0),
align_items: AlignItems::Center,
..default()
},
ZIndex(Z_HUD),
))
.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((
HudMode,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.25)),
));
});
}
#[allow(clippy::type_complexity)]
fn update_hud(
game: Res<GameStateResource>,
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>)>,
) {
if !game.is_changed() {
return;
}
let g = &game.0;
if let Ok(mut t) = score_q.get_single_mut() {
**t = format!("Score: {}", g.score);
}
if let Ok(mut t) = moves_q.get_single_mut() {
**t = format!("Moves: {}", g.move_count);
}
if let Ok(mut t) = time_q.get_single_mut() {
let secs = g.elapsed_seconds;
let m = secs / 60;
let s = secs % 60;
**t = format!("{m}:{s:02}");
}
if let Ok(mut t) = mode_q.get_single_mut() {
**t = match g.mode {
GameMode::Classic => String::new(),
GameMode::Zen => "ZEN".to_string(),
GameMode::Challenge => "CHALLENGE".to_string(),
GameMode::TimeAttack => "TIME ATTACK".to_string(),
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
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(HudPlugin);
app.update();
app
}
#[test]
fn hud_plugin_registers_without_panic() {
let _app = headless_app();
}
#[test]
fn update_hud_runs_after_game_mutation_without_panic() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(42, DrawMode::DrawOne);
app.update();
}
}
+2
View File
@@ -10,6 +10,7 @@ pub mod daily_challenge_plugin;
pub mod events;
pub mod game_plugin;
pub mod help_plugin;
pub mod hud_plugin;
pub mod leaderboard_plugin;
pub mod input_plugin;
pub mod layout;
@@ -43,6 +44,7 @@ pub use events::{
};
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
pub use help_plugin::{HelpPlugin, HelpScreen};
pub use hud_plugin::HudPlugin;
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use input_plugin::InputPlugin;
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};