//! Per-frame HUD text/typography/visibility updater systems. use super::*; use bevy::window::WindowResized; use solitaire_core::Suit; use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{Foundation, KlondikePile, Tableau}; use crate::auto_complete_plugin::AutoCompleteState; /// 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}") } // --------------------------------------------------------------------------- // Score-change feedback (G2) // // The flow for each Update tick: // 1. `detect_score_change` diffs `GameStateResource.score` against // `PreviousScore`. On any positive delta it inserts/refreshes // `ScorePulse` on the score readout; on a delta ≥ // `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text // anchored just below the score. // 2. `advance_score_pulse` ticks the pulse component, applies the // triangular 1.0 → 1.1 → 1.0 scale curve, and removes the // component on completion. // 3. `advance_score_floater` drifts each floater upward, fades it to // transparent, and despawns it when its lifetime expires. // // The threshold of 50 (a foundation promotion's typical bonus) keeps // floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`. // --------------------------------------------------------------------------- /// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the /// current deal's seed + draw_mode + mode triple matches an entry in /// the rolling [`ReplayHistory`]. Cleared while the active game is won /// (the on-screen "Game won!" cue already conveys victory) and on /// fresh deals the player hasn't won before. /// /// Lives in its own system rather than `update_hud` to keep this /// orthogonal: `update_hud`'s query disambiguation is already busy /// enough; threading another marker through every Without filter /// would touch ~10 unrelated queries for no benefit. pub(super) fn update_won_previously( game: Res, // Optional because the HUD plugin's headless tests run without // `StatsPlugin` and therefore without this resource. With the // resource absent there's no history to compare against; the // indicator just stays empty. history: Option>, mut q: Query<&mut Text, With>, ) { let Ok(mut text) = q.single_mut() else { return; }; let won_before = !game.0.is_won() && history.as_ref().is_some_and(|h| { h.0.replays.iter().any(|r| { r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode }) }); let next = if won_before { "\u{2713} Won before" } else { "" }; if text.0 != next { text.0 = next.to_string(); } } #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub(super) fn update_hud( game: Res, time_attack: Option>, daily: Option>, auto_complete: Option>, mut score_q: Query< &mut Text, ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut moves_q: Query< &mut Text, ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut time_q: Query< &mut Text, ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut mode_q: Query< &mut Text, ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut challenge_q: Query< (&mut Text, &mut TextColor), ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut undos_q: Query< (&mut Text, &mut TextColor), ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut auto_q: Query< &mut Text, ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut recycles_q: Query< &mut Text, ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, mut draw_cycle_q: Query< &mut Text, ( With, Without, Without, Without, Without, Without, Without, Without, Without, Without, ), >, ) { let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active); // 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; if let Ok(mut t) = score_q.single_mut() { // Zen mode suppresses score display per spec ("No score display"). **t = if is_zen { String::new() } else { format!("Score: {}", g.score()) }; } if let Ok(mut t) = moves_q.single_mut() { **t = format!("Moves: {}", g.move_count()); } if let Ok(mut t) = mode_q.single_mut() { **t = match g.mode { GameMode::Classic => match g.draw_mode() { DrawStockConfig::DrawOne => String::new(), DrawStockConfig::DrawThree => "Draw 3".to_string(), }, GameMode::Zen => "ZEN".to_string(), GameMode::Challenge => "CHALLENGE".to_string(), GameMode::TimeAttack => "TIME ATTACK".to_string(), GameMode::Difficulty(level) => level.label().to_uppercase(), }; } // --- Daily challenge constraint (with time-low colour warning) --- if let Ok((mut t, mut color)) = challenge_q.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.single_mut() { let count = g.undo_count(); if count == 0 { **t = String::new(); *color = TextColor(TEXT_PRIMARY); } else { **t = format!("Undos: {count}"); // STATE_WARNING signals "you took a penalty" — same hue // as the Recycles counter so they read as one category. *color = TextColor(STATE_WARNING); } } // --- Recycle counter (both modes, hidden until first recycle) --- if let Ok(mut t) = recycles_q.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.single_mut() { **t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree { // Hide when not in Draw-Three or after the game is won. String::new() } else { let stock_len = g.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") — 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; if update_time { if let Ok(mut t) = time_q.single_mut() { if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) { let remaining = ta.remaining_secs.max(0.0) as u64; let m = remaining / 60; let s = remaining % 60; **t = format!("{m}:{s:02}"); } else { let secs = game.0.elapsed_seconds; let m = secs / 60; let s = secs % 60; **t = format!("{m}:{s:02}"); } } } 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.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()) && let Ok(mut t) = auto_q.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. pub(super) fn update_selection_hud( selection: Option>, game: Option>, mut q: Query<&mut Text, With>, ) { let Ok(mut t) = q.single_mut() else { return }; let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) { None => String::new(), Some(KlondikePile::Stock) => "▶ Waste".to_string(), Some(KlondikePile::Foundation(slot)) => match game.as_deref() { Some(g) => foundation_selection_label(*slot, &g.0), // No game resource means we can't probe claimed_suit; show the // slot-based placeholder so the HUD still surfaces the selection. None => format!("▶ Foundation {}", foundation_number(*slot)), }, Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)), }; **t = label; } /// Returns the HUD selection label for a foundation slot. /// /// When the slot has a claimed suit (any card has landed) the announcement is /// "▶ {Suit} Foundation"; while the slot is empty it falls back to a /// "▶ Foundation N" placeholder labelled by the 1-based slot index. pub(super) fn foundation_selection_label( slot: Foundation, game: &solitaire_core::game_state::GameState, ) -> String { let claimed = game .pile(KlondikePile::Foundation(slot)) .first() .map(|c| c.0.suit()); match claimed { Some(suit) => { let s = match suit { Suit::Clubs => "Clubs", Suit::Diamonds => "Diamonds", Suit::Hearts => "Hearts", Suit::Spades => "Spades", }; format!("▶ {s} Foundation") } None => format!("▶ Foundation {}", foundation_number(slot)), } } const fn foundation_number(foundation: Foundation) -> u8 { match foundation { Foundation::Foundation1 => 1, Foundation::Foundation2 => 2, Foundation::Foundation3 => 3, Foundation::Foundation4 => 4, } } const fn tableau_number(tableau: Tableau) -> u8 { match tableau { Tableau::Tableau1 => 1, Tableau::Tableau2 => 2, Tableau::Tableau3 => 3, Tableau::Tableau4 => 4, Tableau::Tableau5 => 5, Tableau::Tableau6 => 6, Tableau::Tableau7 => 7, } } /// Fires `InfoToastEvent("Auto-completing...")` exactly once each time /// `AutoCompleteState` transitions from inactive to active. Uses a `Local` /// to debounce so the toast only appears on the leading edge. pub(super) fn announce_auto_complete( auto_complete: Option>, mut toast: MessageWriter, mut was_active: Local, ) { let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active); if now_active && !*was_active { toast.write(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. pub(super) 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. Uses theme tokens so the urgency ramp picks up /// palette changes for free. /// /// | Remaining | Token | /// |-------------|------------------| /// | ≥ 60 s | `STATE_INFO` | /// | 30 – 59 s | `STATE_WARNING` | /// | < 30 s | `STATE_DANGER` | pub fn challenge_time_color(remaining: u64) -> Color { if remaining < 30 { STATE_DANGER } else if remaining < 60 { STATE_WARNING } else { STATE_INFO } } /// Scales HUD Tier-1 font sizes to fit a narrow viewport. /// /// Fires on every `WindowResized` event. Below 480 logical pixels wide the /// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the /// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so /// all three items remain on one row inside the 50 %-wide HUD column /// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are /// restored so desktop/tablet layouts are unaffected. type HudScoreFont<'w, 's> = Query<'w, 's, &'static mut TextFont, (With, Without, Without)>; type HudMovesFont<'w, 's> = Query<'w, 's, &'static mut TextFont, (With, Without, Without)>; type HudTimeFont<'w, 's> = Query<'w, 's, &'static mut TextFont, (With, Without, Without)>; pub(super) fn update_hud_typography( mut events: MessageReader, mut score_q: HudScoreFont, mut moves_q: HudMovesFont, mut time_q: HudTimeFont, ) { let Some(ev) = events.read().last() else { return; }; let (score_size, secondary_size) = if ev.width < 480.0 { (TYPE_BODY_LG, TYPE_CAPTION) } else { (TYPE_HEADLINE, TYPE_BODY_LG) }; for mut font in &mut score_q { font.font_size = score_size; } for mut font in &mut moves_q { font.font_size = secondary_size; } for mut font in &mut time_q { font.font_size = secondary_size; } } pub(super) fn apply_hud_visibility( hud_vis: Res, mut action_bar: Query<&mut Visibility, With>, ) { if !hud_vis.is_changed() { return; } let v = if *hud_vis == HudVisibility::Visible { Visibility::Visible } else { Visibility::Hidden }; for mut vis in &mut action_bar { *vis = v; } // The bottom action bar is a pure overlay — it does not claim any // space in the card layout, so no WindowResized event is needed. } pub(super) fn restore_hud_on_modal( new_scrims: Query<(), (With, Added)>, mut hud_vis: ResMut, ) { if !new_scrims.is_empty() { *hud_vis = HudVisibility::Visible; } } /// Returns the action-bar label font size for a given logical window width. pub(super) fn action_bar_font_size(window_width: f32) -> f32 { if USE_TOUCH_UI_LAYOUT { // Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New") // must share one row. The widest characters are in FiraMono (a // monospace whose advance is ~0.62 of the font size). On a 900 // logical-px phone the row budget after bar padding (2*12) and six // 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button // padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the // labels are advance-bound only on very narrow viewports; the real // constraint is legibility, not fit. ~1/60 of the width yields ~15 px // at 900 px — comfortably one row with margin to spare — clamped so it // never drops below the 12 px legibility floor or grows past 18 px on // landscape tablets where it would crowd the row again. (window_width / 60.0).clamp(12.0, 18.0) } else { TYPE_BODY } } pub(super) fn action_button_metrics() -> (UiRect, Val, Val) { if USE_TOUCH_UI_LAYOUT { // Tight 3 px horizontal padding (down from 4) trims 14 px off the row // total across 7 buttons, and a 44 px min_width (down from 52) lets the // shortest labels ("New", "Help") shrink to their text rather than // padding the row out past the 900 logical-px viewport. min_height // stays at 44 px to preserve the comfortable touch target. ( UiRect::axes(Val::Px(3.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0), ) } else { ( UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0), ) } } pub(super) fn spawn_action_button_label( parent: &mut ChildSpawnerCommands, label: &str, font: &TextFont, text_color: Color, ) { if USE_TOUCH_UI_LAYOUT { parent.spawn(( ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color), )); } else { parent.spawn((Text::new(label), font.clone(), TextColor(text_color))); } } /// Resizes the glyph text inside every [`ActionButtonLabel`] to match the /// current viewport width whenever [`LayoutResource`] changes (orientation /// change or window resize). #[cfg(target_os = "android")] pub(super) fn resize_action_bar_labels( layout: Res, windows: Query<&Window>, mut labels: Query<&mut TextFont, With>, ) { let w = windows .iter() .next() .map_or(layout.0.card_size.x * 7.25, |win| win.width()); let new_size = action_bar_font_size(w); for mut font in &mut labels { font.font_size = new_size; } }