Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9285ccb41 | |||
| 648c3ed11d | |||
| 102506f799 | |||
| 9b00af29d9 | |||
| ea28121675 |
@@ -15,6 +15,8 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use bevy::sprite::Anchor;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -65,6 +67,12 @@ pub const STACK_FAN_FRAC: f32 = 0.003;
|
|||||||
/// Font size as a fraction of card width.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
|
|
||||||
|
/// Font-size fraction for the large-print readability overlay on Android.
|
||||||
|
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
|
||||||
|
/// scale, where the baked-in PNG corner text is only ~10 px physical.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
|
||||||
|
|
||||||
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
||||||
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
||||||
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
||||||
@@ -163,6 +171,25 @@ pub struct CardEntity {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct CardLabel;
|
pub struct CardLabel;
|
||||||
|
|
||||||
|
/// Marker for the large-print rank+suit corner overlay on Android.
|
||||||
|
///
|
||||||
|
/// Spawned on top of PNG face cards (face-up only) at font size
|
||||||
|
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
||||||
|
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||||
|
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
struct AndroidCornerLabel;
|
||||||
|
|
||||||
|
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||||||
|
///
|
||||||
|
/// Covers the card art's own small corner rank/suit text so only the
|
||||||
|
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
||||||
|
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
struct AndroidCornerBg;
|
||||||
|
|
||||||
/// Marker component indicating the card is currently highlighted as a hint.
|
/// Marker component indicating the card is currently highlighted as a hint.
|
||||||
/// `remaining` counts down in real seconds; the highlight is removed when it
|
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||||
/// reaches zero and the card sprite colour is restored to its normal value.
|
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||||
@@ -339,8 +366,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
|
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
|
||||||
/// back PNG has a visible perimeter against the dark felt.
|
/// thin perimeter against the dark felt, regardless of face state.
|
||||||
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||||
parent.spawn((
|
parent.spawn((
|
||||||
CardBackFrame,
|
CardBackFrame,
|
||||||
@@ -429,6 +456,9 @@ impl Plugin for CardPlugin {
|
|||||||
snap_cards_on_window_resize.after(collect_resize_events),
|
snap_cards_on_window_resize.after(collect_resize_events),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
app.add_systems(Update, resize_android_corner_labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,15 +784,15 @@ fn spawn_card_entity(
|
|||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
add_card_shadow_child(b, layout.card_size);
|
add_card_shadow_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
// Face-down cards get a thin contrasting border frame so the dark back
|
// Every card gets a thin border frame so it reads as a distinct
|
||||||
// PNG reads as a distinct rectangle against the dark felt.
|
// rectangle against the dark felt, regardless of face state.
|
||||||
if !card.face_up {
|
|
||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
add_card_back_frame_child(b, layout.card_size);
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||||
|
// On Android we additionally spawn a large-print corner label even in
|
||||||
|
// image mode so the rank/suit are legible at phone scale.
|
||||||
if card_images.is_none() {
|
if card_images.is_none() {
|
||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -778,6 +808,12 @@ fn spawn_card_entity(
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if card_images.is_some() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -831,16 +867,15 @@ fn update_card_entity(
|
|||||||
|
|
||||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||||
// in solid-colour fallback mode, the label overlay. In image mode the
|
// in solid-colour fallback mode, the label overlay. In image mode the
|
||||||
// rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
|
// rank/suit are baked into the PNG; on Android we also add a large-print
|
||||||
|
// corner overlay so they are legible at phone scale.
|
||||||
commands.entity(entity).despawn_related::<Children>();
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_card_shadow_child(b, layout.card_size);
|
add_card_shadow_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
if !card.face_up {
|
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_card_back_frame_child(b, layout.card_size);
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if card_images.is_none() {
|
if card_images.is_none() {
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -856,6 +891,12 @@ fn update_card_entity(
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if card_images.is_some() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label_for(card: &Card) -> String {
|
fn label_for(card: &Card) -> String {
|
||||||
@@ -928,6 +969,87 @@ fn label_visibility(card: &Card) -> Visibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rank+suit string for the Android readability overlay.
|
||||||
|
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn mobile_label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit {
|
||||||
|
Suit::Clubs => "♣",
|
||||||
|
Suit::Diamonds => "♦",
|
||||||
|
Suit::Hearts => "♥",
|
||||||
|
Suit::Spades => "♠",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards. The background sprite covers the card art's own small
|
||||||
|
/// corner text so only the large overlay is visible.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn add_android_corner_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
card: &Card,
|
||||||
|
card_size: Vec2,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
) {
|
||||||
|
if !card.face_up {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
// Background covers ~3 monospace chars wide × 1 line tall.
|
||||||
|
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
|
||||||
|
// (3 chars = 1.8× font_size) plus a small margin.
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
|
||||||
|
// Solid background that hides the card art's small corner label.
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: CARD_FACE_COLOUR,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
-card_size.x / 2.0 + inset + bg_w / 2.0,
|
||||||
|
card_size.y / 2.0 - inset - bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Large rank+suit text drawn on top of the background.
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerLabel,
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(mobile_label_for(card)),
|
||||||
|
TextFont { font_size, ..default() },
|
||||||
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
|
Anchor::TOP_LEFT,
|
||||||
|
Transform::from_xyz(
|
||||||
|
-card_size.x / 2.0 + inset,
|
||||||
|
card_size.y / 2.0 - inset,
|
||||||
|
0.02,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #34 — Card-flip animation systems
|
// Task #34 — Card-flip animation systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1836,6 +1958,43 @@ fn resize_cards_in_place(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates font size and top-left anchor transform of every
|
||||||
|
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
|
||||||
|
/// change or any window resize). The full despawn/respawn path in
|
||||||
|
/// `update_card_entity` already handles game-state changes; this system
|
||||||
|
/// covers the resize-only path where children are mutated in place.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn resize_android_corner_labels(
|
||||||
|
layout: Res<LayoutResource>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>,
|
||||||
|
mut bg_query: Query<
|
||||||
|
(&mut Sprite, &mut Transform),
|
||||||
|
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if !layout.is_changed() || card_images.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||||
|
let text_y = layout.0.card_size.y / 2.0 - inset;
|
||||||
|
|
||||||
|
for (mut font, mut transform) in text_query.iter_mut() {
|
||||||
|
font.font_size = font_size;
|
||||||
|
transform.translation.x = text_x;
|
||||||
|
transform.translation.y = text_y;
|
||||||
|
}
|
||||||
|
for (mut sprite, mut transform) in bg_query.iter_mut() {
|
||||||
|
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
|
||||||
|
transform.translation.x = text_x + bg_w / 2.0;
|
||||||
|
transform.translation.y = text_y - bg_h / 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
|
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
|
||||||
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
||||||
/// expands as the player reveals cards while staying within the window.
|
/// expands as the player reveals cards while staying within the window.
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ use solitaire_core::game_state::{DrawMode, GameMode};
|
|||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
use crate::avatar_plugin::AvatarResource;
|
||||||
|
use solitaire_data::SyncBackend;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
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::SPACE_2;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
@@ -138,6 +140,13 @@ pub struct HudColumn;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudActionBar;
|
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
|
/// Controls whether the in-game HUD (band, score column, action buttons) is
|
||||||
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
||||||
/// on desktop. Resets to `Visible` whenever a modal opens.
|
/// on desktop. Resets to `Visible` whenever a modal opens.
|
||||||
@@ -152,10 +161,13 @@ pub enum HudVisibility {
|
|||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Default)]
|
||||||
struct HudTapTracker {
|
struct HudTapTracker {
|
||||||
start_pos: Option<bevy::math::Vec2>,
|
start_pos: Option<bevy::math::Vec2>,
|
||||||
|
/// 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")]
|
#[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
|
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
||||||
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
/// 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
|
// WindowResized is registered by table_plugin; re-register
|
||||||
// defensively so the HUD plugin works standalone in tests.
|
// defensively so the HUD plugin works standalone in tests.
|
||||||
.add_message::<WindowResized>()
|
.add_message::<WindowResized>()
|
||||||
.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, update_hud.after(GameMutation))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
||||||
)
|
)
|
||||||
.add_systems(Update, restore_hud_on_modal)
|
.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, update_won_previously.after(GameMutation))
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||||
.add_systems(Update, update_selection_hud)
|
.add_systems(Update, update_selection_hud)
|
||||||
@@ -446,7 +459,12 @@ impl Plugin for HudPlugin {
|
|||||||
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
||||||
// `paint_action_buttons` would clobber the alpha back to 1.0
|
// `paint_action_buttons` would clobber the alpha back to 1.0
|
||||||
// mid-fade and produce a visible blip.
|
// 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")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
app.init_resource::<HudTapTracker>()
|
app.init_resource::<HudTapTracker>()
|
||||||
@@ -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<Res<FontResource>>,
|
||||||
|
insets: Option<Res<SafeAreaInsets>>,
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
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<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
q: Query<Entity, With<HudAvatar>>,
|
||||||
|
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::<Children>();
|
||||||
|
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<HudAvatar>, Changed<Interaction>)>,
|
||||||
|
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
) {
|
||||||
|
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.
|
/// Spawns the action button bar anchored to the top-right of the window.
|
||||||
/// Each child is a clickable button mirroring a keyboard accelerator —
|
/// Each child is a clickable button mirroring a keyboard accelerator —
|
||||||
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
||||||
@@ -697,23 +844,19 @@ fn spawn_action_buttons(
|
|||||||
insets: Option<Res<SafeAreaInsets>>,
|
insets: Option<Res<SafeAreaInsets>>,
|
||||||
mut commands: Commands,
|
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 {
|
let font = TextFont {
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
|
// On Android, compact Unicode symbols fit all 7 buttons in one row.
|
||||||
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
|
// On desktop, keep the descriptive text labels.
|
||||||
// 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.
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let (max_width, col_gap, row_gap_val) =
|
let col_gap = Val::Px(4.0);
|
||||||
(Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0));
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
let (max_width, col_gap, row_gap_val) =
|
let col_gap = VAL_SPACE_2;
|
||||||
(Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2);
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let labels = (
|
let labels = (
|
||||||
@@ -721,9 +864,8 @@ fn spawn_action_buttons(
|
|||||||
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
||||||
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
||||||
/* help */ "?",
|
/* help */ "?",
|
||||||
/* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono)
|
/* hint */ "!", // ! attention/alert — semantically: "look here"
|
||||||
/* modes */ "\u{2193}", // ↓ downwards arrow (Arrows block, confirmed FiraMono)
|
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
|
||||||
// replaces ▾ (U+25BE) which is absent from FiraMono
|
|
||||||
/* new */ "+",
|
/* new */ "+",
|
||||||
);
|
);
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -737,23 +879,33 @@ fn spawn_action_buttons(
|
|||||||
"New Game",
|
"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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
bottom: Val::Px(bottom_inset),
|
||||||
top: Val::Px(SPACE_2 + top_inset),
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
max_width,
|
|
||||||
flex_wrap: FlexWrap::Wrap,
|
flex_wrap: FlexWrap::Wrap,
|
||||||
justify_content: JustifyContent::FlexEnd,
|
justify_content: JustifyContent::Center,
|
||||||
column_gap: col_gap,
|
column_gap: col_gap,
|
||||||
row_gap: row_gap_val,
|
row_gap: VAL_SPACE_2,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect {
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
top: VAL_SPACE_2,
|
||||||
|
bottom: VAL_SPACE_2,
|
||||||
|
},
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
|
BackgroundColor(BG_HUD_BAND),
|
||||||
ZIndex(Z_HUD),
|
ZIndex(Z_HUD),
|
||||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
||||||
HudActionBar,
|
HudActionBar,
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
ModesPopover,
|
ModesPopover,
|
||||||
@@ -1019,7 +1179,7 @@ fn spawn_modes_popover(
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
right: VAL_SPACE_3,
|
||||||
top: Val::Px(50.0),
|
bottom: popover_bottom,
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_1,
|
row_gap: VAL_SPACE_1,
|
||||||
padding: UiRect::all(VAL_SPACE_2),
|
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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
MenuPopover,
|
MenuPopover,
|
||||||
@@ -1211,7 +1377,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
right: VAL_SPACE_3,
|
||||||
top: Val::Px(50.0),
|
bottom: popover_bottom,
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_1,
|
row_gap: VAL_SPACE_1,
|
||||||
padding: UiRect::all(VAL_SPACE_2),
|
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
|
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
|
||||||
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
|
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
|
||||||
/// in as the cursor approaches, not only once it crosses into the band.
|
/// cursor approaches, not only when it crosses into the band itself.
|
||||||
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||||
|
|
||||||
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
/// 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;
|
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||||
|
|
||||||
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
/// 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
|
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
||||||
/// `target` at a fixed rate so the visual transition is smooth across
|
/// `target` at a fixed rate so the visual transition is smooth across
|
||||||
/// variable framerates.
|
/// variable framerates.
|
||||||
@@ -1446,8 +1612,9 @@ fn update_action_fade(
|
|||||||
let Ok(window) = windows.single() else {
|
let Ok(window) = windows.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let height = window.resolution.height();
|
||||||
fade.target = match window.cursor_position() {
|
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,
|
Some(_) => 0.0,
|
||||||
// Off-window cursor: assume keyboard navigation and keep the
|
// Off-window cursor: assume keyboard navigation and keep the
|
||||||
// bar visible so Tab cycling doesn't lead to invisible focus.
|
// 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(
|
fn apply_hud_visibility(
|
||||||
hud_vis: Res<HudVisibility>,
|
hud_vis: Res<HudVisibility>,
|
||||||
mut nodes: Query<
|
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
||||||
&mut Visibility,
|
|
||||||
Or<(With<HudBand>, With<HudColumn>, With<HudActionBar>)>,
|
|
||||||
>,
|
|
||||||
window_entities: Query<(Entity, &Window)>,
|
|
||||||
mut resize_events: MessageWriter<WindowResized>,
|
|
||||||
) {
|
) {
|
||||||
if !hud_vis.is_changed() {
|
if !hud_vis.is_changed() {
|
||||||
return;
|
return;
|
||||||
@@ -2298,16 +2459,11 @@ fn apply_hud_visibility(
|
|||||||
} else {
|
} else {
|
||||||
Visibility::Hidden
|
Visibility::Hidden
|
||||||
};
|
};
|
||||||
for mut node_vis in &mut nodes {
|
for mut vis in &mut action_bar {
|
||||||
*node_vis = v;
|
*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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// 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(
|
fn restore_hud_on_modal(
|
||||||
@@ -2327,29 +2483,47 @@ fn toggle_hud_on_tap(
|
|||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut tracker: ResMut<HudTapTracker>,
|
mut tracker: ResMut<HudTapTracker>,
|
||||||
mut hud_vis: ResMut<HudVisibility>,
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
buttons: Query<&Interaction, With<ActionButton>>,
|
||||||
) {
|
) {
|
||||||
use bevy::input::touch::TouchPhase;
|
use bevy::input::touch::TouchPhase;
|
||||||
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
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.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for event in touch_events.read() {
|
for event in touch_events.read() {
|
||||||
match event.phase {
|
match event.phase {
|
||||||
TouchPhase::Started => {
|
TouchPhase::Started => {
|
||||||
tracker.start_pos = Some(event.position);
|
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() => {
|
TouchPhase::Ended if drag.is_idle() => {
|
||||||
|
let on_button = tracker.started_on_button;
|
||||||
if let Some(start) = tracker.start_pos.take() {
|
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 {
|
*hud_vis = match *hud_vis {
|
||||||
HudVisibility::Visible => HudVisibility::Hidden,
|
HudVisibility::Visible => HudVisibility::Hidden,
|
||||||
HudVisibility::Hidden => HudVisibility::Visible,
|
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.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
|
|||||||
/// which rendered the cards ~3.6 % squashed vertically.
|
/// which rendered the cards ~3.6 % squashed vertically.
|
||||||
const CARD_ASPECT: f32 = 1.4523;
|
const CARD_ASPECT: f32 = 1.4523;
|
||||||
|
|
||||||
|
/// Divisor used to derive the horizontal gap between columns from the card
|
||||||
|
/// width: `h_gap = card_width / H_GAP_DIVISOR`.
|
||||||
|
///
|
||||||
|
/// This constant also drives `card_width_width_based`:
|
||||||
|
/// total layout width = 7*card_width + 8*h_gap = card_width*(7 + 8/H_GAP_DIVISOR)
|
||||||
|
/// → card_width = window.x / (7 + 8/H_GAP_DIVISOR)
|
||||||
|
///
|
||||||
|
/// Desktop (H_GAP_DIVISOR = 4): card_width = window.x / 9 — existing behaviour.
|
||||||
|
/// Android (H_GAP_DIVISOR = 32): card_width = window.x / 7.25 — cards are ~10 %
|
||||||
|
/// wider than at divisor 8, with very tight gaps (~4 px) that are still visible
|
||||||
|
/// as a faint seam between columns. The primary readability boost on Android
|
||||||
|
/// comes from the `AndroidCornerLabel` overlay in `card_plugin`, but maximising
|
||||||
|
/// the physical card size helps too.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const H_GAP_DIVISOR: f32 = 4.0;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const H_GAP_DIVISOR: f32 = 32.0;
|
||||||
|
|
||||||
/// Fraction of card height used as vertical padding between the top row and
|
/// Fraction of card height used as vertical padding between the top row and
|
||||||
/// the tableau row.
|
/// the tableau row.
|
||||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||||
@@ -77,15 +95,14 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
|
|||||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||||
/// below this band so the HUD doesn't bleed into the play surface.
|
/// 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.
|
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
|
||||||
/// Android: 128 px accommodates the two-row button wrap on narrow phones
|
/// Android: 80 px gives the same content rows comfortable clearance.
|
||||||
/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two
|
/// (Previously 128 px when action buttons lived in the top band; those are
|
||||||
/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of
|
/// now in the bottom bar so the larger reserve is no longer needed.)
|
||||||
/// buttons overlaps the top card row.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||||
#[cfg(target_os = "android")]
|
#[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).
|
/// Table background colour (dark green felt).
|
||||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||||
@@ -150,8 +167,10 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
|||||||
let window = window.max(MIN_WINDOW);
|
let window = window.max(MIN_WINDOW);
|
||||||
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
|
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
|
||||||
|
|
||||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
// Width-based candidate: 7 cards + 8 h_gaps where h_gap = card_width/H_GAP_DIVISOR.
|
||||||
let card_width_width_based = window.x / 9.0;
|
// Total = card_width*(7 + 8/H_GAP_DIVISOR) = window.x → card_width = window.x/card_width_divisor.
|
||||||
|
let card_width_divisor = 7.0 + 8.0 / H_GAP_DIVISOR;
|
||||||
|
let card_width_width_based = window.x / card_width_divisor;
|
||||||
|
|
||||||
// Height-based candidate. The vertical budget below the top row must hold
|
// Height-based candidate. The vertical budget below the top row must hold
|
||||||
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
|
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
|
||||||
@@ -176,13 +195,12 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
|||||||
let card_height = card_width * CARD_ASPECT;
|
let card_height = card_width * CARD_ASPECT;
|
||||||
let card_size = Vec2::new(card_width, card_height);
|
let card_size = Vec2::new(card_width, card_height);
|
||||||
|
|
||||||
let h_gap = card_width / 4.0;
|
let h_gap = card_width / H_GAP_DIVISOR;
|
||||||
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
|
// Total occupied width = 7*card_width + 8*h_gap = card_width_divisor*card_width.
|
||||||
// sizing is height-limited (tall/narrow windows), this is smaller than
|
// When card sizing is height-limited (tall/narrow windows) this is smaller than
|
||||||
// window.x, so the grid is centred horizontally; otherwise side_margin
|
// window.x and the grid is centred horizontally; otherwise side_margin collapses
|
||||||
// collapses to h_gap and the geometry matches the original width-based
|
// to h_gap and the geometry fills the window exactly.
|
||||||
// layout exactly.
|
let total_grid_width = card_width_divisor * card_width;
|
||||||
let total_grid_width = 9.0 * card_width;
|
|
||||||
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
||||||
let left_edge = -window.x / 2.0;
|
let left_edge = -window.x / 2.0;
|
||||||
let col_x = |col: usize| -> f32 {
|
let col_x = |col: usize| -> f32 {
|
||||||
@@ -402,11 +420,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tall_narrow_window_keeps_width_based_sizing() {
|
fn tall_narrow_window_keeps_width_based_sizing() {
|
||||||
// Tall narrow window: there's plenty of vertical budget, so width is
|
// Tall narrow window: there's plenty of vertical budget, so width is
|
||||||
// the bottleneck and card_width matches the legacy window.x / 9
|
// the bottleneck and card_width matches window.x / (7 + 8/H_GAP_DIVISOR).
|
||||||
// derivation exactly.
|
|
||||||
let window = Vec2::new(900.0, 1600.0);
|
let window = Vec2::new(900.0, 1600.0);
|
||||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||||
let width_based = window.x / 9.0;
|
let width_based = window.x / (7.0 + 8.0 / H_GAP_DIVISOR);
|
||||||
assert!(
|
assert!(
|
||||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||||
"expected width-based sizing (card_width {} should equal {})",
|
"expected width-based sizing (card_width {} should equal {})",
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ use crate::replay_playback::{
|
|||||||
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
||||||
toggle_pause_replay_playback, ReplayPlaybackState,
|
toggle_pause_replay_playback, ReplayPlaybackState,
|
||||||
};
|
};
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::ReplayMove;
|
use solitaire_data::ReplayMove;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
|
||||||
@@ -154,6 +157,12 @@ const MOVE_LOG_PREV_ROWS: usize = 2;
|
|||||||
/// preview-shape might need rethinking.
|
/// preview-shape might need rethinking.
|
||||||
const MOVE_LOG_NEXT_ROWS: usize = 2;
|
const MOVE_LOG_NEXT_ROWS: usize = 2;
|
||||||
|
|
||||||
|
/// Vertical offset from the top edge of the window to the top edge of the
|
||||||
|
/// mini-tableau preview panel. Places the panel 8 px below the banner's
|
||||||
|
/// bottom edge so the two surfaces don't overlap. Derived from
|
||||||
|
/// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows.
|
||||||
|
const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.0;
|
||||||
|
|
||||||
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
||||||
/// reads as a clear "this is a UI strip" callout while still letting the
|
/// reads as a clear "this is a UI strip" callout while still letting the
|
||||||
/// felt show through enough to anchor the banner to the play surface.
|
/// felt show through enough to anchor the banner to the play surface.
|
||||||
@@ -404,6 +413,34 @@ pub struct ReplayOverlayMoveLogNextRow {
|
|||||||
pub offset: u8,
|
pub offset: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
|
||||||
|
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
|
||||||
|
/// to despawn all of them, rather than keeping a separate query per
|
||||||
|
/// entity type. Future sibling overlay surfaces just need this marker
|
||||||
|
/// at spawn time — no changes to the despawn logic required.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct DespawnWithReplay;
|
||||||
|
|
||||||
|
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
|
||||||
|
/// panel that shows a compact summary of the live game state during
|
||||||
|
/// replay: the four foundation tops and the stock / waste heads.
|
||||||
|
/// Spawned as a sibling root entity (same lifecycle pattern as
|
||||||
|
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayMiniTableauPanel;
|
||||||
|
|
||||||
|
/// Marker on the foundations row `Text` inside the mini-tableau panel.
|
||||||
|
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
|
||||||
|
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayMiniTableauFoundations;
|
||||||
|
|
||||||
|
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
|
||||||
|
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
|
||||||
|
/// [`GameStateResource`] changes.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayMiniTableauStockWaste;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -451,6 +488,8 @@ impl Plugin for ReplayOverlayPlugin {
|
|||||||
update_move_log_active_row,
|
update_move_log_active_row,
|
||||||
update_move_log_prev_rows,
|
update_move_log_prev_rows,
|
||||||
update_move_log_next_rows,
|
update_move_log_next_rows,
|
||||||
|
update_mini_tableau_foundations,
|
||||||
|
update_mini_tableau_stock_waste,
|
||||||
update_pause_button_label,
|
update_pause_button_label,
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_step_button,
|
handle_step_button,
|
||||||
@@ -476,10 +515,8 @@ impl Plugin for ReplayOverlayPlugin {
|
|||||||
fn react_to_state_change(
|
fn react_to_state_change(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
state: Res<ReplayPlaybackState>,
|
state: Res<ReplayPlaybackState>,
|
||||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
||||||
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
|
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
||||||
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
|
|
||||||
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if !state.is_changed() {
|
if !state.is_changed() {
|
||||||
@@ -487,30 +524,15 @@ fn react_to_state_change(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let should_be_visible = state.is_playing() || state.is_completed();
|
let should_be_visible = state.is_playing() || state.is_completed();
|
||||||
let already_spawned = existing.iter().next().is_some();
|
let already_spawned = roots.iter().next().is_some();
|
||||||
|
|
||||||
if should_be_visible && !already_spawned {
|
if should_be_visible && !already_spawned {
|
||||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
||||||
} else if !should_be_visible && already_spawned {
|
} else if !should_be_visible && already_spawned {
|
||||||
for entity in &existing {
|
// Despawn all sibling root entities in one loop — every entity
|
||||||
commands.entity(entity).despawn();
|
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
|
||||||
}
|
// exactly this purpose.
|
||||||
// Floating chip lives outside the UI tree (world-space
|
for entity in &despawnable {
|
||||||
// entity), so the banner-root despawn doesn't reach it.
|
|
||||||
// Despawn separately on the same state transition so both
|
|
||||||
// disappear together when the replay ends.
|
|
||||||
for entity in &floating_chips {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
// Move-log panel is also a separate root entity (sibling
|
|
||||||
// of the banner anchored to the viewport's bottom edge),
|
|
||||||
// so the banner-root despawn doesn't reach it either.
|
|
||||||
for entity in &move_log_panels {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
// Tableau dim layer is also a separate root entity — same
|
|
||||||
// pattern as the move-log panel.
|
|
||||||
for entity in &dim_layers {
|
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,6 +568,8 @@ fn spawn_overlay(
|
|||||||
// entity spawned after the banner closure closes. Mirrors the
|
// entity spawned after the banner closure closes. Mirrors the
|
||||||
// floating-chip clone reasoning.
|
// floating-chip clone reasoning.
|
||||||
let font_handle_for_move_log = font_handle.clone();
|
let font_handle_for_move_log = font_handle.clone();
|
||||||
|
// Fourth clone for the mini-tableau preview panel.
|
||||||
|
let font_handle_for_mini_tableau = font_handle.clone();
|
||||||
|
|
||||||
let banner_label = if state.is_completed() {
|
let banner_label = if state.is_completed() {
|
||||||
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
||||||
@@ -562,6 +586,7 @@ fn spawn_overlay(
|
|||||||
// component — purely visual.
|
// component — purely visual.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
ReplayTableauDimLayer,
|
ReplayTableauDimLayer,
|
||||||
|
DespawnWithReplay,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
@@ -585,6 +610,7 @@ fn spawn_overlay(
|
|||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
ReplayOverlayRoot,
|
ReplayOverlayRoot,
|
||||||
|
DespawnWithReplay,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
@@ -967,6 +993,7 @@ fn spawn_overlay(
|
|||||||
// when the replay state transitions back to `Inactive`.
|
// when the replay state transitions back to `Inactive`.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
ReplayFloatingProgressChip,
|
ReplayFloatingProgressChip,
|
||||||
|
DespawnWithReplay,
|
||||||
Text2d::new(format_progress(state)),
|
Text2d::new(format_progress(state)),
|
||||||
TextFont {
|
TextFont {
|
||||||
font: font_handle_for_floating,
|
font: font_handle_for_floating,
|
||||||
@@ -996,6 +1023,7 @@ fn spawn_overlay(
|
|||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
ReplayOverlayMoveLogPanel,
|
ReplayOverlayMoveLogPanel,
|
||||||
|
DespawnWithReplay,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
@@ -1111,6 +1139,68 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mini-tableau preview panel — right-edge anchor, just below the banner.
|
||||||
|
// Compact two-row readout: foundation tops then stock/waste head.
|
||||||
|
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
|
||||||
|
let banner_bg = Color::srgba(
|
||||||
|
BG_ELEVATED_HI.to_srgba().red,
|
||||||
|
BG_ELEVATED_HI.to_srgba().green,
|
||||||
|
BG_ELEVATED_HI.to_srgba().blue,
|
||||||
|
BANNER_ALPHA,
|
||||||
|
);
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
ReplayMiniTableauPanel,
|
||||||
|
DespawnWithReplay,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Val::Px(0.0),
|
||||||
|
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
|
||||||
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
align_items: AlignItems::FlexStart,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
border: UiRect::left(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(banner_bg),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
ZIndex(Z_REPLAY_OVERLAY),
|
||||||
|
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
panel.spawn((
|
||||||
|
Text::new("\u{258C} BOARD"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle_for_mini_tableau.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
panel.spawn((
|
||||||
|
ReplayMiniTableauFoundations,
|
||||||
|
Text::new("F: -- -- -- --"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle_for_mini_tableau.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
panel.spawn((
|
||||||
|
ReplayMiniTableauStockWaste,
|
||||||
|
Text::new("STK:-- WST:--"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle_for_mini_tableau,
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — returns the scrub-fill width as a percentage of the
|
/// Pure helper — returns the scrub-fill width as a percentage of the
|
||||||
@@ -1554,6 +1644,118 @@ fn format_active_move_row(state: &ReplayPlaybackState) -> String {
|
|||||||
format!("\u{25B6} {body}") // ▶
|
format!("\u{25B6} {body}") // ▶
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mini-tableau format helpers and update system
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper — short rank symbol. Single character for all ranks
|
||||||
|
/// except Ten which uses "T" (keeps every card a consistent 2-char
|
||||||
|
/// wide render: rank-char + suit-glyph). Players familiar with
|
||||||
|
/// solitaire shorthand read "T" instantly; the suit glyph immediately
|
||||||
|
/// follows and disambiguates from an ambiguous "T".
|
||||||
|
fn format_rank_short(rank: Rank) -> &'static str {
|
||||||
|
match rank {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "T",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — Unicode suit glyph from FiraMono's covered range
|
||||||
|
/// (U+2660–U+2666). These four code points are confirmed present in
|
||||||
|
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
|
||||||
|
fn format_suit_glyph(suit: Suit) -> &'static str {
|
||||||
|
match suit {
|
||||||
|
Suit::Spades => "\u{2660}", // ♠
|
||||||
|
Suit::Hearts => "\u{2665}", // ♥
|
||||||
|
Suit::Diamonds => "\u{2666}", // ♦
|
||||||
|
Suit::Clubs => "\u{2663}", // ♣
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
|
||||||
|
/// known card, or `"--"` for an absent top card (empty pile).
|
||||||
|
fn format_card_short(card: Option<&Card>) -> String {
|
||||||
|
match card {
|
||||||
|
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
|
||||||
|
None => "--".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — one-line summary of the four foundation tops.
|
||||||
|
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
|
||||||
|
/// Foundation slots are displayed in their natural 0-3 order
|
||||||
|
/// (matching the visual left-to-right order on screen).
|
||||||
|
fn format_foundations_row(game: &GameState) -> String {
|
||||||
|
let slots: [String; 4] = std::array::from_fn(|i| {
|
||||||
|
let top = game.piles
|
||||||
|
.get(&PileType::Foundation(i as u8))
|
||||||
|
.and_then(|p| p.cards.last());
|
||||||
|
format_card_short(top)
|
||||||
|
});
|
||||||
|
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — one-line stock / waste summary.
|
||||||
|
/// Renders as `STK:N WST:X♠` where N is the stock card count and
|
||||||
|
/// X♠ is the top waste card (or `--` when the waste pile is empty).
|
||||||
|
fn format_stock_waste_row(game: &GameState) -> String {
|
||||||
|
let stock_count = game.piles
|
||||||
|
.get(&PileType::Stock)
|
||||||
|
.map(|p| p.cards.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let waste_top = game.piles
|
||||||
|
.get(&PileType::Waste)
|
||||||
|
.and_then(|p| p.cards.last());
|
||||||
|
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints the foundations row whenever [`GameStateResource`] changes.
|
||||||
|
/// Split into its own system (rather than combined with the stock/waste
|
||||||
|
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
|
||||||
|
/// queries in one system are always ambiguous regardless of marker
|
||||||
|
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
|
||||||
|
fn update_mini_tableau_foundations(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
if !game.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = format_foundations_row(&game.0);
|
||||||
|
for mut t in &mut q {
|
||||||
|
**t = text.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
|
||||||
|
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
|
||||||
|
/// guard, separate system to avoid the B0001 query conflict.
|
||||||
|
fn update_mini_tableau_stock_waste(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
if !game.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = format_stock_waste_row(&game.0);
|
||||||
|
for mut t in &mut q {
|
||||||
|
**t = text.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Playback-control button handlers
|
// Playback-control button handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1763,6 +1965,7 @@ fn handle_stop_keyboard(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use solitaire_core::card::{Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::{Replay, ReplayMove};
|
||||||
|
|
||||||
@@ -3990,4 +4193,113 @@ mod tests {
|
|||||||
fn dim_layer_z_is_below_replay_chrome() {
|
fn dim_layer_z_is_below_replay_chrome() {
|
||||||
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
|
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Mini-tableau preview tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn mini_tableau_panel_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayMiniTableauPanel>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mini-tableau panel spawns alongside the other overlay surfaces
|
||||||
|
/// when playback starts and despawns when it ends.
|
||||||
|
#[test]
|
||||||
|
fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
mini_tableau_panel_count(&mut app),
|
||||||
|
0,
|
||||||
|
"no mini-tableau panel while playback is Inactive",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(
|
||||||
|
&mut app,
|
||||||
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(5),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
mini_tableau_panel_count(&mut app),
|
||||||
|
1,
|
||||||
|
"mini-tableau panel must spawn when playback starts",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
mini_tableau_panel_count(&mut app),
|
||||||
|
0,
|
||||||
|
"mini-tableau panel must despawn when playback ends",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_rank_short` maps every `Rank` variant to a single ASCII
|
||||||
|
/// character except Ten which maps to `"T"`.
|
||||||
|
#[test]
|
||||||
|
fn format_rank_short_all_ranks() {
|
||||||
|
assert_eq!(format_rank_short(Rank::Ace), "A");
|
||||||
|
assert_eq!(format_rank_short(Rank::Two), "2");
|
||||||
|
assert_eq!(format_rank_short(Rank::Three), "3");
|
||||||
|
assert_eq!(format_rank_short(Rank::Four), "4");
|
||||||
|
assert_eq!(format_rank_short(Rank::Five), "5");
|
||||||
|
assert_eq!(format_rank_short(Rank::Six), "6");
|
||||||
|
assert_eq!(format_rank_short(Rank::Seven), "7");
|
||||||
|
assert_eq!(format_rank_short(Rank::Eight), "8");
|
||||||
|
assert_eq!(format_rank_short(Rank::Nine), "9");
|
||||||
|
assert_eq!(format_rank_short(Rank::Ten), "T");
|
||||||
|
assert_eq!(format_rank_short(Rank::Jack), "J");
|
||||||
|
assert_eq!(format_rank_short(Rank::Queen), "Q");
|
||||||
|
assert_eq!(format_rank_short(Rank::King), "K");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_suit_glyph` returns the FiraMono-covered Unicode suit
|
||||||
|
/// glyphs for each `Suit` variant (U+2660–U+2666 confirmed on Android).
|
||||||
|
#[test]
|
||||||
|
fn format_suit_glyph_all_suits() {
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}");
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_foundations_row` with a freshly-dealt game (all empty).
|
||||||
|
#[test]
|
||||||
|
fn format_foundations_row_empty_board() {
|
||||||
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
|
42,
|
||||||
|
solitaire_core::game_state::DrawMode::DrawOne,
|
||||||
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
|
);
|
||||||
|
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_stock_waste_row` with a freshly-dealt game: stock has
|
||||||
|
/// 24 cards, waste is empty.
|
||||||
|
#[test]
|
||||||
|
fn format_stock_waste_row_initial_state() {
|
||||||
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
|
42,
|
||||||
|
solitaire_core::game_state::DrawMode::DrawOne,
|
||||||
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
|
);
|
||||||
|
let text = format_stock_waste_row(&game);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("STK:"),
|
||||||
|
"row must start with STK: prefix; got {text:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("WST:--"),
|
||||||
|
"waste must show -- on a fresh deal; got {text:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,25 @@ pub struct SafeAreaAnchoredTop {
|
|||||||
pub base_top: f32,
|
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;
|
pub struct SafeAreaInsetsPlugin;
|
||||||
|
|
||||||
impl Plugin for SafeAreaInsetsPlugin {
|
impl Plugin for SafeAreaInsetsPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<SafeAreaInsets>()
|
app.init_resource::<SafeAreaInsets>()
|
||||||
.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")]
|
#[cfg(target_os = "android")]
|
||||||
app.add_systems(Update, android::refresh_insets);
|
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<SafeAreaInsets>,
|
||||||
|
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
|
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
|
||||||
/// modal cards don't extend into the Android gesture-navigation zone.
|
/// modal cards don't extend into the Android gesture-navigation zone.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<section id="board"></section>
|
<section id="board"></section>
|
||||||
|
|
||||||
<section id="controls">
|
<section id="controls">
|
||||||
<button id="btn-prev" disabled>⏮ Restart</button>
|
<button id="btn-prev" disabled>◀ Back</button>
|
||||||
<button id="btn-play">▶ Play</button>
|
<button id="btn-play">▶ Play</button>
|
||||||
<button id="btn-step">⏭ Step</button>
|
<button id="btn-step">⏭ Step</button>
|
||||||
<span id="progress" class="muted">step 0 / 0</span>
|
<span id="progress" class="muted">step 0 / 0</span>
|
||||||
|
|||||||
@@ -301,16 +301,30 @@ btnPlay.addEventListener("click", () => {
|
|||||||
}, STEP_INTERVAL_MS);
|
}, STEP_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
||||||
|
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
||||||
|
/// renders once so the CSS transition animates each card to its previous
|
||||||
|
/// position.
|
||||||
|
function stepBack() {
|
||||||
|
if (!player || player.step_idx() === 0) return;
|
||||||
|
if (playInterval) {
|
||||||
|
clearInterval(playInterval);
|
||||||
|
playInterval = null;
|
||||||
|
btnPlay.textContent = "▶ Play";
|
||||||
|
}
|
||||||
|
const target = player.step_idx() - 1;
|
||||||
|
player = new ReplayPlayer(replayJson);
|
||||||
|
for (let i = 0; i < target; i++) {
|
||||||
|
player.step();
|
||||||
|
}
|
||||||
|
render(player.state());
|
||||||
|
btnPrev.disabled = player.step_idx() === 0;
|
||||||
|
btnStep.disabled = false;
|
||||||
|
btnPlay.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
btnPrev.addEventListener("click", () => {
|
btnPrev.addEventListener("click", () => {
|
||||||
if (!replayJson) return;
|
if (player) stepBack();
|
||||||
// Drop every existing card so the next render fades them all in
|
|
||||||
// at the freshly-dealt positions. Without this, cards from the
|
|
||||||
// current state would slide to wherever the new deal puts them
|
|
||||||
// — confusing since the deal is supposed to look like a fresh
|
|
||||||
// start, not a continuation.
|
|
||||||
cardEls.forEach((el) => el.remove());
|
|
||||||
cardEls.clear();
|
|
||||||
resetPlayer();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user