diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 08eb6aa..a1ed7dd 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -339,8 +339,8 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) { )); } -/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark -/// back PNG has a visible perimeter against the dark felt. +/// Spawns a `CardBackFrame` child behind a card entity to give every card a +/// thin perimeter against the dark felt, regardless of face state. fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) { parent.spawn(( CardBackFrame, @@ -754,13 +754,11 @@ fn spawn_card_entity( entity.with_children(|b| { add_card_shadow_child(b, layout.card_size); }); - // Face-down cards get a thin contrasting border frame so the dark back - // PNG reads as a distinct rectangle against the dark felt. - if !card.face_up { - entity.with_children(|b| { - add_card_back_frame_child(b, layout.card_size); - }); - } + // Every card gets a thin border frame so it reads as a distinct + // rectangle against the dark felt, regardless of face state. + entity.with_children(|b| { + add_card_back_frame_child(b, layout.card_size); + }); // When PNG faces are loaded the rank/suit are baked into the image. // Only spawn the Text2d overlay in the solid-colour fallback (tests). if card_images.is_none() { @@ -836,11 +834,9 @@ fn update_card_entity( commands.entity(entity).with_children(|b| { add_card_shadow_child(b, layout.card_size); }); - if !card.face_up { - commands.entity(entity).with_children(|b| { - add_card_back_frame_child(b, layout.card_size); - }); - } + commands.entity(entity).with_children(|b| { + add_card_back_frame_child(b, layout.card_size); + }); if card_images.is_none() { commands.entity(entity).with_children(|b| { b.spawn(( diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index ab738a3..3685eb7 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -13,12 +13,14 @@ use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::pile::PileType; use crate::auto_complete_plugin::AutoCompleteState; +use crate::avatar_plugin::AvatarResource; +use solitaire_data::SyncBackend; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; use crate::progress_plugin::ProgressResource; use crate::settings_plugin::SettingsResource; use crate::layout::HUD_BAND_HEIGHT; -use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets}; +use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop, SafeAreaInsets}; use crate::ui_theme::SPACE_2; use crate::ui_theme::{ scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, @@ -138,6 +140,13 @@ pub struct HudColumn; #[derive(Component, Debug)] pub struct HudActionBar; +/// Marker on the circular profile-picture button anchored to the +/// top-right of the HUD band. Pressing it opens the Profile overlay. +/// Shows the server avatar image when loaded; falls back to the player's +/// initial on a filled disc when no image is available. +#[derive(Component, Debug)] +pub struct HudAvatar; + /// Controls whether the in-game HUD (band, score column, action buttons) is /// visible. Toggled on Android by tapping empty board space; always `Visible` /// on desktop. Resets to `Visible` whenever a modal opens. @@ -152,10 +161,13 @@ pub enum HudVisibility { #[derive(Resource, Debug, Default)] struct HudTapTracker { start_pos: Option, + /// Set `true` when the finger-down hit an action button so the + /// finger-up never toggles bar visibility. + started_on_button: bool, } #[cfg(target_os = "android")] -const HUD_TAP_SLOP_PX: f32 = 15.0; +const HUD_TAP_SLOP_PX: f32 = 25.0; /// Drives the score-readout pulse: scales the [`HudScore`] text from /// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by @@ -395,13 +407,14 @@ impl Plugin for HudPlugin { // WindowResized is registered by table_plugin; re-register // defensively so the HUD plugin works standalone in tests. .add_message::() - .add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons)) + .add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar)) .add_systems(Update, update_hud.after(GameMutation)) .add_systems( Update, apply_hud_visibility.before(LayoutSystem::UpdateOnResize), ) .add_systems(Update, restore_hud_on_modal) + .add_systems(Update, (update_hud_avatar, handle_avatar_button)) .add_systems(Update, update_won_previously.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, update_selection_hud) @@ -446,7 +459,12 @@ impl Plugin for HudPlugin { // Otherwise on a hover-state change (`Changed`), // `paint_action_buttons` would clobber the alpha back to 1.0 // mid-fade and produce a visible blip. - .add_systems(Last, (update_action_fade, apply_action_fade).chain()); + ; + // Desktop-only: cursor-proximity fade. On Android the bar + // visibility is toggled explicitly; cursor_position() returning + // Some(touch_pos) during a tap would otherwise fade the bar out. + #[cfg(not(target_os = "android"))] + app.add_systems(Last, (update_action_fade, apply_action_fade).chain()); #[cfg(target_os = "android")] { app.init_resource::() @@ -684,6 +702,135 @@ fn spawn_hud( }); } +/// Spawns the circular avatar / initials button anchored to the top-right +/// of the HUD band. Initial content is seeded from whatever resources are +/// available at startup; `update_hud_avatar` replaces the children whenever +/// `AvatarResource` or `SettingsResource` later changes. +fn spawn_hud_avatar( + font_res: Option>, + insets: Option>, + avatar: Option>, + settings: Option>, + mut commands: Commands, +) { + const SIZE: f32 = 32.0; + let top_inset = insets.as_deref().copied().unwrap_or_default().top; + let id = commands + .spawn(( + HudAvatar, + Button, + Tooltip::new("Your profile — tap to open."), + Node { + position_type: PositionType::Absolute, + top: Val::Px(SPACE_2 + top_inset), + right: VAL_SPACE_3, + width: Val::Px(SIZE), + height: Val::Px(SIZE), + border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..default() + }, + BackgroundColor(ACCENT_PRIMARY), + ZIndex(Z_HUD), + SafeAreaAnchoredTop { base_top: SPACE_2 }, + )) + .id(); + spawn_avatar_child( + &mut commands, + id, + avatar.as_deref(), + settings.as_deref(), + font_res.as_deref(), + ); +} + +/// Re-spawns the avatar circle content (image or initials) whenever either +/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the +/// image arriving after download and the username changing after login. +fn update_hud_avatar( + avatar: Option>, + settings: Option>, + font_res: Option>, + q: Query>, + mut commands: Commands, +) { + let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed()); + let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed()); + if !avatar_changed && !settings_changed { + return; + } + let Ok(entity) = q.single() else { + return; + }; + commands.entity(entity).despawn_related::(); + spawn_avatar_child( + &mut commands, + entity, + avatar.as_deref(), + settings.as_deref(), + font_res.as_deref(), + ); +} + +/// Populates the avatar container with either the downloaded image or an +/// initials fallback disc. Called from both the startup spawn and the +/// reactive update system so the rendering logic lives in one place. +fn spawn_avatar_child( + commands: &mut Commands, + parent: Entity, + avatar: Option<&AvatarResource>, + settings: Option<&SettingsResource>, + font_res: Option<&FontResource>, +) { + const SIZE: f32 = 32.0; + if let Some(handle) = avatar.and_then(|a| a.0.clone()) { + // Image fills the circle container; border_radius clips it to a disc. + commands.entity(parent).with_children(|b| { + b.spawn(( + ImageNode::new(handle), + Node { + width: Val::Px(SIZE), + height: Val::Px(SIZE), + border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)), + ..default() + }, + )); + }); + } else { + let initial = settings + .and_then(|s| match &s.0.sync_backend { + SyncBackend::SolitaireServer { username, .. } => username.chars().next(), + SyncBackend::Local => None, + }) + .and_then(|c| c.to_uppercase().next()) + .unwrap_or('?'); + commands.entity(parent).with_children(|b| { + b.spawn(( + Text::new(initial.to_string()), + TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: 14.0, + ..default() + }, + TextColor(TEXT_PRIMARY), + )); + }); + } +} + +/// Opens the Profile overlay when the avatar button is pressed. +fn handle_avatar_button( + interaction_query: Query<&Interaction, (With, Changed)>, + mut toggle_profile: MessageWriter, +) { + for interaction in &interaction_query { + if *interaction == Interaction::Pressed { + toggle_profile.write(ToggleProfileRequestEvent); + } + } +} + /// Spawns the action button bar anchored to the top-right of the window. /// Each child is a clickable button mirroring a keyboard accelerator — /// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons @@ -697,23 +844,19 @@ fn spawn_action_buttons( insets: Option>, mut commands: Commands, ) { - let top_inset = insets.as_deref().copied().unwrap_or_default().top; + let bottom_inset = insets.as_deref().copied().unwrap_or_default().bottom; let font = TextFont { font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY, ..default() }; - // On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on - // a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7 - // fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band - // of 370 dp). On desktop, keep the descriptive text labels. + // On Android, compact Unicode symbols fit all 7 buttons in one row. + // On desktop, keep the descriptive text labels. #[cfg(target_os = "android")] - let (max_width, col_gap, row_gap_val) = - (Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0)); + let col_gap = Val::Px(4.0); #[cfg(not(target_os = "android"))] - let (max_width, col_gap, row_gap_val) = - (Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2); + let col_gap = VAL_SPACE_2; #[cfg(target_os = "android")] let labels = ( @@ -721,9 +864,8 @@ fn spawn_action_buttons( /* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono) /* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono /* help */ "?", - /* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono) - /* modes */ "\u{2193}", // ↓ downwards arrow (Arrows block, confirmed FiraMono) - // replaces ▾ (U+25BE) which is absent from FiraMono + /* hint */ "!", // ! attention/alert — semantically: "look here" + /* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono /* new */ "+", ); #[cfg(not(target_os = "android"))] @@ -737,23 +879,33 @@ fn spawn_action_buttons( "New Game", ); + // Bottom bar: full-width, centered, sits above the gesture-navigation zone. + // `bottom` is set to `bottom_inset` initially; `SafeAreaAnchoredBottom` keeps + // it correct as Android insets arrive in later frames. commands .spawn(( Node { position_type: PositionType::Absolute, - right: VAL_SPACE_3, - top: Val::Px(SPACE_2 + top_inset), + bottom: Val::Px(bottom_inset), + left: Val::Px(0.0), + width: Val::Percent(100.0), flex_direction: FlexDirection::Row, - max_width, flex_wrap: FlexWrap::Wrap, - justify_content: JustifyContent::FlexEnd, + justify_content: JustifyContent::Center, column_gap: col_gap, - row_gap: row_gap_val, + row_gap: VAL_SPACE_2, align_items: AlignItems::Center, + padding: UiRect { + left: VAL_SPACE_3, + right: VAL_SPACE_3, + top: VAL_SPACE_2, + bottom: VAL_SPACE_2, + }, ..default() }, + BackgroundColor(BG_HUD_BAND), ZIndex(Z_HUD), - SafeAreaAnchoredTop { base_top: SPACE_2 }, + SafeAreaAnchoredBottom { base_bottom: 0.0 }, HudActionBar, )) .with_children(|row| { @@ -1012,6 +1164,14 @@ fn spawn_modes_popover( )); } + // Popover opens upward from just above the bottom action bar. + // Use a platform-aware offset that clears the bar height + safe-area + // gesture zone on Android, and the flat bar height on desktop. + #[cfg(target_os = "android")] + let popover_bottom = Val::Px(200.0); + #[cfg(not(target_os = "android"))] + let popover_bottom = Val::Px(80.0); + commands .spawn(( ModesPopover, @@ -1019,7 +1179,7 @@ fn spawn_modes_popover( Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, - top: Val::Px(50.0), + bottom: popover_bottom, flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, padding: UiRect::all(VAL_SPACE_2), @@ -1204,6 +1364,12 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) ), ]; + // Same upward-opening placement as ModesPopover. + #[cfg(target_os = "android")] + let popover_bottom = Val::Px(200.0); + #[cfg(not(target_os = "android"))] + let popover_bottom = Val::Px(80.0); + commands .spawn(( MenuPopover, @@ -1211,7 +1377,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, - top: Val::Px(50.0), + bottom: popover_bottom, flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, padding: UiRect::all(VAL_SPACE_2), @@ -1423,9 +1589,9 @@ impl Default for HudActionFade { } } -/// Cursor-y threshold (in window pixels, 0 = top) below which the bar -/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades -/// in as the cursor approaches, not only once it crosses into the band. +/// How many pixels from the bottom edge the cursor must be to reveal the bar. +/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the +/// cursor approaches, not only when it crosses into the band itself. const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0; /// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full @@ -1434,7 +1600,7 @@ const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0; const ACTION_FADE_RATE_PER_SEC: f32 = 6.0; /// Updates the fade state from cursor position. Sets `target = 1.0` if -/// the cursor is in the reveal zone (top of window) or off-screen +/// the cursor is in the reveal zone (bottom of window) or off-screen /// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward /// `target` at a fixed rate so the visual transition is smooth across /// variable framerates. @@ -1446,8 +1612,9 @@ fn update_action_fade( let Ok(window) = windows.single() else { return; }; + let height = window.resolution.height(); fade.target = match window.cursor_position() { - Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0, + Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0, Some(_) => 0.0, // Off-window cursor: assume keyboard navigation and keep the // bar visible so Tab cycling doesn't lead to invisible focus. @@ -2280,15 +2447,9 @@ fn update_hud_typography( } } -#[allow(clippy::type_complexity)] fn apply_hud_visibility( hud_vis: Res, - mut nodes: Query< - &mut Visibility, - Or<(With, With, With)>, - >, - window_entities: Query<(Entity, &Window)>, - mut resize_events: MessageWriter, + mut action_bar: Query<&mut Visibility, With>, ) { if !hud_vis.is_changed() { return; @@ -2298,16 +2459,11 @@ fn apply_hud_visibility( } else { Visibility::Hidden }; - for mut node_vis in &mut nodes { - *node_vis = v; - } - if let Some((entity, window)) = window_entities.iter().next() { - resize_events.write(WindowResized { - window: entity, - width: window.resolution.width(), - height: window.resolution.height(), - }); + 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. } fn restore_hud_on_modal( @@ -2327,29 +2483,47 @@ fn toggle_hud_on_tap( paused: Option>, mut tracker: ResMut, mut hud_vis: ResMut, + buttons: Query<&Interaction, With>, ) { use bevy::input::touch::TouchPhase; if !scrims.is_empty() || paused.is_some_and(|p| p.0) { + // Drain buffered events so they don't replay in the frame after + // the scrim despawns, which would trigger a spurious visibility + // toggle as the resume/close button tap's Started+Ended pair + // replays in the now-scrim-free frame. + for _ in touch_events.read() {} tracker.start_pos = None; + tracker.started_on_button = false; return; } for event in touch_events.read() { match event.phase { TouchPhase::Started => { tracker.start_pos = Some(event.position); + // Record whether the finger-down landed on a button so + // the finger-up doesn't double-fire (toggle bar + press + // button at the same time). + tracker.started_on_button = + buttons.iter().any(|i| *i != Interaction::None); } TouchPhase::Ended if drag.is_idle() => { + let on_button = tracker.started_on_button; if let Some(start) = tracker.start_pos.take() { - if (event.position - start).length() < HUD_TAP_SLOP_PX { + if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX { *hud_vis = match *hud_vis { HudVisibility::Visible => HudVisibility::Hidden, HudVisibility::Hidden => HudVisibility::Visible, }; } } + tracker.started_on_button = false; } - TouchPhase::Canceled | TouchPhase::Moved => { + // Moved: don't clear start_pos — Android fires Moved for normal + // tap jitter, and the distance check at Ended already rejects + // real drags. Clearing here would silently swallow tap toggles. + TouchPhase::Canceled => { tracker.start_pos = None; + tracker.started_on_button = false; } _ => {} } diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 922e1d6..736449e 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -77,15 +77,14 @@ const MAX_TABLEAU_CARDS: f32 = 13.0; /// (action buttons, Score / Moves / Timer readouts). The card grid starts /// below this band so the HUD doesn't bleed into the play surface. /// -/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line. -/// Android: 128 px accommodates the two-row button wrap on narrow phones -/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two -/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of -/// buttons overlaps the top card row. +/// Desktop: 64 px fits the score/moves/time + mode badge rows. +/// Android: 80 px gives the same content rows comfortable clearance. +/// (Previously 128 px when action buttons lived in the top band; those are +/// now in the bottom bar so the larger reserve is no longer needed.) #[cfg(not(target_os = "android"))] pub const HUD_BAND_HEIGHT: f32 = 64.0; #[cfg(target_os = "android")] -pub const HUD_BAND_HEIGHT: f32 = 128.0; +pub const HUD_BAND_HEIGHT: f32 = 80.0; /// Table background colour (dark green felt). pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196]; diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs index 0ed5cb7..ce98207 100644 --- a/solitaire_engine/src/safe_area.rs +++ b/solitaire_engine/src/safe_area.rs @@ -51,12 +51,25 @@ pub struct SafeAreaAnchoredTop { pub base_top: f32, } +/// Marker for `Node` entities whose `bottom` offset should be re-applied +/// as `base_bottom + SafeAreaInsets::bottom / scale`. +/// +/// Use this for elements anchored to the bottom edge (e.g. a bottom action +/// bar) so they clear the Android gesture-navigation zone automatically. +#[derive(Component, Debug, Clone, Copy)] +pub struct SafeAreaAnchoredBottom { + pub base_bottom: f32, +} + pub struct SafeAreaInsetsPlugin; impl Plugin for SafeAreaInsetsPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims)); + .add_systems( + Update, + (apply_safe_area_anchors, apply_safe_area_bottom_anchors, apply_safe_area_to_modal_scrims), + ); #[cfg(target_os = "android")] app.add_systems(Update, android::refresh_insets); @@ -89,6 +102,23 @@ fn apply_safe_area_anchors( } } +/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying +/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes. +fn apply_safe_area_bottom_anchors( + insets: Res, + windows: Query<&Window>, + mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>, +) { + if !insets.is_changed() { + return; + } + let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); + let bottom_logical = insets.bottom / scale; + for (anchor, mut node) in &mut q { + node.bottom = Val::Px(anchor.base_bottom + bottom_logical); + } +} + /// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so /// modal cards don't extend into the Android gesture-navigation zone. ///