diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 3b765a1..3c91bdf 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -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()) diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs new file mode 100644 index 0000000..61982cb --- /dev/null +++ b/solitaire_engine/src/hud_plugin.rs @@ -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, + mut score_q: Query<&mut Text, (With, Without, Without, Without)>, + mut moves_q: Query<&mut Text, (With, Without, Without, Without)>, + mut time_q: Query<&mut Text, (With, Without, Without, Without)>, + mut mode_q: Query<&mut Text, (With, Without, Without, Without)>, +) { + 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::().0 = + GameState::new(42, DrawMode::DrawOne); + app.update(); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index edfae28..5d678de 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -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};