Compare commits
5 Commits
v0.41.0
...
b3b53c4adf
| Author | SHA1 | Date | |
|---|---|---|---|
| b3b53c4adf | |||
| ba76936aba | |||
| 59ba7ba4c3 | |||
| 1ca1efb3b6 | |||
| c5ad487256 |
@@ -0,0 +1,430 @@
|
|||||||
|
//! HUD feedback effects: action-bar fades, score pulses and floaters,
|
||||||
|
//! and streak flourishes.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Auto-fade state for the action button bar. The bar fades out when
|
||||||
|
/// the cursor is in the play area (below the HUD band) and back in when
|
||||||
|
/// the cursor approaches the top of the window — same UX as a video
|
||||||
|
/// player's auto-hide controls. Buttons remain fully interactive when
|
||||||
|
/// visible; when faded out they're geometrically out of cursor reach
|
||||||
|
/// (hover requires the cursor to be on a button), so no extra
|
||||||
|
/// pointer-events guard is needed.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
|
pub struct HudActionFade {
|
||||||
|
/// Currently displayed alpha. Lerped toward `target` each frame.
|
||||||
|
pub alpha: f32,
|
||||||
|
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
|
||||||
|
pub target: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HudActionFade {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Start visible so the player sees the controls on first launch
|
||||||
|
// before they've moved the cursor anywhere.
|
||||||
|
Self {
|
||||||
|
alpha: 1.0,
|
||||||
|
target: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||||
|
|
||||||
|
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||||
|
/// transition — fast enough to feel responsive without flashing on
|
||||||
|
/// brief cursor wanders into the reveal zone.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
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 (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.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
|
||||||
|
let Ok(window) = windows.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let height = window.resolution.height();
|
||||||
|
fade.target = match window.cursor_position() {
|
||||||
|
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.
|
||||||
|
None => 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
|
||||||
|
let diff = fade.target - fade.alpha;
|
||||||
|
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the current fade alpha to every action button's
|
||||||
|
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
|
||||||
|
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||||
|
/// same frame doesn't override the fade with an opaque idle / hover
|
||||||
|
/// colour.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn apply_action_fade(
|
||||||
|
fade: Res<HudActionFade>,
|
||||||
|
// Excludes `PopoverRow` so the auto-fade only applies to the
|
||||||
|
// top-level action bar buttons. Popover rows live inside an
|
||||||
|
// explicitly-opened dropdown panel and need to stay visible
|
||||||
|
// regardless of the bar's fade state — without the exclusion
|
||||||
|
// the rows fade to invisible while the popover container stays
|
||||||
|
// visible, leaving a solid background block with no readable
|
||||||
|
// content.
|
||||||
|
mut buttons: Query<
|
||||||
|
(&Children, &mut BackgroundColor),
|
||||||
|
(With<ActionButton>, Without<PopoverRow>),
|
||||||
|
>,
|
||||||
|
mut text_q: Query<&mut TextColor>,
|
||||||
|
) {
|
||||||
|
for (children, mut bg) in &mut buttons {
|
||||||
|
let mut c = bg.0;
|
||||||
|
c.set_alpha(fade.alpha);
|
||||||
|
bg.0 = c;
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Ok(mut tc) = text_q.get_mut(child) {
|
||||||
|
let mut cc = tc.0;
|
||||||
|
cc.set_alpha(fade.alpha);
|
||||||
|
tc.0 = cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||||
|
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||||
|
/// changes. One query covers all action buttons via the shared
|
||||||
|
/// `ActionButton` marker.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn paint_action_buttons(
|
||||||
|
mut buttons: Query<
|
||||||
|
(&Interaction, &mut BackgroundColor),
|
||||||
|
(With<ActionButton>, Changed<Interaction>),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
for (interaction, mut bg) in &mut buttons {
|
||||||
|
bg.0 = match interaction {
|
||||||
|
Interaction::Pressed => ACTION_BTN_PRESSED,
|
||||||
|
Interaction::Hovered => ACTION_BTN_HOVER,
|
||||||
|
Interaction::None => ACTION_BTN_IDLE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
|
||||||
|
/// function so the test suite can assert on the curve directly
|
||||||
|
/// without spinning up a Bevy app.
|
||||||
|
///
|
||||||
|
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
|
||||||
|
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
|
||||||
|
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
|
||||||
|
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
|
||||||
|
/// triangle peaking at 1.1.
|
||||||
|
pub(super) fn score_pulse_scale(t: f32) -> f32 {
|
||||||
|
let clamped = t.clamp(0.0, 1.0);
|
||||||
|
if clamped < 0.5 {
|
||||||
|
1.0 + 0.2 * clamped
|
||||||
|
} else {
|
||||||
|
1.1 - 0.2 * (clamped - 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical pixels the floating "+N" drifts up over its lifetime.
|
||||||
|
const FLOATER_DRIFT_PX: f32 = 40.0;
|
||||||
|
|
||||||
|
/// Diffs the current `GameStateResource.score` against
|
||||||
|
/// [`PreviousScore`]. On a positive delta:
|
||||||
|
///
|
||||||
|
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
|
||||||
|
/// so the readout pulses 1.0 → 1.1 → 1.0.
|
||||||
|
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
|
||||||
|
/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
|
||||||
|
/// readout (see the doc comment on [`ScoreFloater`] for why this is a
|
||||||
|
/// UI Node rather than a `Text2d`).
|
||||||
|
pub(super) fn detect_score_change(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut prev: ResMut<PreviousScore>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
score_q: Query<Entity, With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let current = game.0.score();
|
||||||
|
let delta = current - prev.0;
|
||||||
|
prev.0 = current;
|
||||||
|
if delta <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
if reduce_motion {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
|
||||||
|
let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);
|
||||||
|
|
||||||
|
// Refresh ScorePulse on every score readout entity (in practice
|
||||||
|
// there's exactly one, but iterating is cheaper than asserting).
|
||||||
|
for entity in &score_q {
|
||||||
|
commands.entity(entity).insert(ScorePulse {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: pulse_secs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if delta < SCORE_FLOATER_THRESHOLD {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
// Spawned as an absolutely-positioned UI Node so the floater rides
|
||||||
|
// the same screen-coordinate system as the score readout. Using a
|
||||||
|
// `Text2d` here would require translating UI layout coordinates to
|
||||||
|
// world space every frame; a UI node piggybacks on the same
|
||||||
|
// anchoring `update_hud` already uses for the score and stays
|
||||||
|
// testable under `MinimalPlugins`.
|
||||||
|
commands.spawn((
|
||||||
|
ScoreFloater {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: floater_secs,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
// Anchored next to the HUD column; matches the
|
||||||
|
// `spawn_hud` left/top offsets so the floater appears
|
||||||
|
// overlaid on the score line and drifts up from there.
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD_TOP),
|
||||||
|
Text::new(format!("+{delta}")),
|
||||||
|
font,
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScorePulse`], scaling its entity's `Transform`
|
||||||
|
/// using [`score_pulse_scale`]. Removes the component once
|
||||||
|
/// `elapsed >= duration` (or immediately under
|
||||||
|
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
|
||||||
|
/// 0) and pins the scale back to 1.0 so no float drift survives.
|
||||||
|
pub(super) fn advance_score_pulse(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut pulse, mut transform) in &mut q {
|
||||||
|
let t = if pulse.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
pulse.elapsed += dt;
|
||||||
|
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = score_pulse_scale(t);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
commands.entity(entity).remove::<ScorePulse>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
|
||||||
|
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
|
||||||
|
/// its lifetime. Despawns the entity once `elapsed >= duration`.
|
||||||
|
pub(super) fn advance_score_floater(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut floater, mut node, mut color) in &mut nodes {
|
||||||
|
let t = if floater.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
floater.elapsed += dt;
|
||||||
|
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
// Drift upward: top decreases as t grows. Starting top=0 keeps
|
||||||
|
// the floater on the score line; ending at -FLOATER_DRIFT_PX
|
||||||
|
// pulls it up off the readout.
|
||||||
|
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
|
||||||
|
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
|
||||||
|
let mut c = ACCENT_PRIMARY;
|
||||||
|
c.set_alpha(1.0 - t);
|
||||||
|
color.0 = c;
|
||||||
|
if t >= 1.0 {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streak-milestone flourish
|
||||||
|
//
|
||||||
|
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
|
||||||
|
// completion celebration; the streak flourish is its lifetime equivalent —
|
||||||
|
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
|
||||||
|
// readout pulses larger than a normal score-change pulse and tints magenta
|
||||||
|
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
|
||||||
|
//
|
||||||
|
// Why the score readout: there is no always-on streak number on the HUD
|
||||||
|
// today (the readout lives in the Stats overlay), and the score is the
|
||||||
|
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
|
||||||
|
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
|
||||||
|
// player who isn't watching the score still sees the celebration land.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||||
|
/// the streak flourish at `elapsed_secs` over `duration_secs`.
|
||||||
|
///
|
||||||
|
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
|
||||||
|
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||||
|
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
|
||||||
|
/// Out-of-range values are clamped so the score readout never freezes
|
||||||
|
/// at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
///
|
||||||
|
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
||||||
|
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
||||||
|
/// without dividing by zero.
|
||||||
|
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||||
|
if duration_secs <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
||||||
|
let peak = STREAK_FLOURISH_PEAK_SCALE;
|
||||||
|
if t < 0.5 {
|
||||||
|
// Climb from 1.0 at t=0 to peak at t=0.5.
|
||||||
|
1.0 + (peak - 1.0) * (t / 0.5)
|
||||||
|
} else {
|
||||||
|
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
||||||
|
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
|
||||||
|
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
|
||||||
|
/// `TextColor` so `advance_streak_flourish` can restore it when the
|
||||||
|
/// timer expires; reuses any existing flourish's `original_color` so
|
||||||
|
/// re-entering the system mid-flourish doesn't snapshot the magenta
|
||||||
|
/// tint as the new "original".
|
||||||
|
///
|
||||||
|
/// Removes any concurrent [`ScorePulse`] from the same entity so the
|
||||||
|
/// flourish takes over the scale slot cleanly — score pulses last
|
||||||
|
/// 250 ms, the flourish 600 ms, and the streak crossing always
|
||||||
|
/// coincides with a positive score delta, so the flourish is the
|
||||||
|
/// louder of the two celebrations.
|
||||||
|
pub(super) fn start_streak_flourish(
|
||||||
|
mut events: MessageReader<WinStreakMilestoneEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let Some(latest) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
|
||||||
|
for (entity, color, existing) in &score_q {
|
||||||
|
let original_color = existing.map_or(color.0, |f| f.original_color);
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<ScorePulse>()
|
||||||
|
.insert(StreakFlourish {
|
||||||
|
streak: latest.streak,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration,
|
||||||
|
original_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
|
||||||
|
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
|
||||||
|
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
|
||||||
|
/// `original_color`. Removes the component once `elapsed >= duration`
|
||||||
|
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
|
||||||
|
/// where duration is 0) and pins the scale back to 1.0 / restores the
|
||||||
|
/// original colour so no half-state is ever shown.
|
||||||
|
///
|
||||||
|
/// Filtered with `Without<ScorePulse>` so the streak flourish never
|
||||||
|
/// races a score pulse for the same `Transform.scale` slot —
|
||||||
|
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
|
||||||
|
/// score entity before this system runs, so the filter is purely a
|
||||||
|
/// belt-and-braces invariant.
|
||||||
|
pub(super) fn advance_streak_flourish(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<
|
||||||
|
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
|
||||||
|
Without<ScorePulse>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut anim, mut transform, mut color) in &mut q {
|
||||||
|
let t = if anim.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
// Tint mix: full magenta at t=0..=0.5, fades back to the
|
||||||
|
// original colour over t=0.5..=1.0.
|
||||||
|
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||||
|
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
color.0 = anim.original_color;
|
||||||
|
commands.entity(entity).remove::<StreakFlourish>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB-space linear interpolation between two `Color`s — small local
|
||||||
|
/// helper so `advance_streak_flourish` stays readable. sRGB-space
|
||||||
|
/// lerping is fine for a brief decorative tint (a perceptually-uniform
|
||||||
|
/// space would be overkill).
|
||||||
|
pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||||
|
let from = from.to_srgba();
|
||||||
|
let to = to.to_srgba();
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Color::srgba(
|
||||||
|
from.red + (to.red - from.red) * t,
|
||||||
|
from.green + (to.green - from.green) * t,
|
||||||
|
from.blue + (to.blue - from.blue) * t,
|
||||||
|
from.alpha + (to.alpha - from.alpha) * t,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
//! HUD interaction: action-button handlers, Modes/Menu popovers, and
|
||||||
|
//! the chrome tap-to-toggle gesture.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
||||||
|
/// interaction state transitions, avoiding repeat events while the button
|
||||||
|
/// is held down. Each click handler fires the corresponding request event,
|
||||||
|
/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside
|
||||||
|
/// their existing keyboard handlers.
|
||||||
|
pub(super) fn handle_new_game_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_undo_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
|
||||||
|
mut undo: MessageWriter<UndoRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
undo.write(UndoRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_pause_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
||||||
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
pause.write(PauseRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_help_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
|
||||||
|
mut help: MessageWriter<HelpRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
help.write(HelpRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_hint_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
||||||
|
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if paused.as_ref().is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(ref g) = game else { return };
|
||||||
|
if g.0.is_won() {
|
||||||
|
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
||||||
|
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
|
||||||
|
/// second click. Mode rows are populated per the player's current level so
|
||||||
|
/// only unlocked options appear.
|
||||||
|
pub(super) fn handle_modes_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spawn_modes_popover(
|
||||||
|
&mut commands,
|
||||||
|
progress.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the modes popover anchored just below the action bar's right
|
||||||
|
/// edge. Always includes Classic; includes Daily Challenge when a daily
|
||||||
|
/// resource is loaded; includes Zen / Challenge / Time Attack once the
|
||||||
|
/// player reaches the challenge unlock level.
|
||||||
|
pub(super) fn spawn_modes_popover(
|
||||||
|
commands: &mut Commands,
|
||||||
|
progress: Option<&ProgressResource>,
|
||||||
|
daily: Option<&DailyChallengeResource>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let level = progress.map_or(0, |p| p.0.level);
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 15.0,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each row carries a tooltip alongside its label so hover reveals
|
||||||
|
// a one-line description of what the mode does — mirroring the
|
||||||
|
// tooltips on the action-bar buttons that opened this popover.
|
||||||
|
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
|
||||||
|
ModeOption::Classic,
|
||||||
|
"Classic",
|
||||||
|
"Standard Klondike. Score, timer, and full progression.",
|
||||||
|
)];
|
||||||
|
if daily.is_some() {
|
||||||
|
rows.push((
|
||||||
|
ModeOption::DailyChallenge,
|
||||||
|
"Daily Challenge",
|
||||||
|
"Today's seeded deal. Same for every player worldwide.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if level >= CHALLENGE_UNLOCK_LEVEL {
|
||||||
|
rows.push((
|
||||||
|
ModeOption::Zen,
|
||||||
|
"Zen",
|
||||||
|
"No timer, no score, no penalties. Just play.",
|
||||||
|
));
|
||||||
|
rows.push((
|
||||||
|
ModeOption::Challenge,
|
||||||
|
"Challenge",
|
||||||
|
"Hand-picked hard seeds. No undo allowed.",
|
||||||
|
));
|
||||||
|
rows.push((
|
||||||
|
ModeOption::TimeAttack,
|
||||||
|
"Time Attack",
|
||||||
|
"Win as many games as you can in ten minutes.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
ModesPopover,
|
||||||
|
HudPopoverOpen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
bottom: popover_bottom,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
ZIndex(Z_HUD_POPOVER),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
for (option, label, tooltip) in rows {
|
||||||
|
panel
|
||||||
|
.spawn((
|
||||||
|
option,
|
||||||
|
ActionButton,
|
||||||
|
PopoverRow,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::FlexStart,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
min_width: Val::Px(150.0),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
|
||||||
|
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
|
||||||
|
commands.spawn((
|
||||||
|
ModesPopoverBackdrop,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches the click on a popover row to the matching request event,
|
||||||
|
/// then despawns the popover.
|
||||||
|
///
|
||||||
|
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
|
||||||
|
/// their `Start*RequestEvent` so the existing keyboard handler runs
|
||||||
|
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
|
||||||
|
/// the popover stays a thin entry point and never duplicates that logic.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_mode_option_click(
|
||||||
|
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||||
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let mut clicked_any = false;
|
||||||
|
for (interaction, option) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clicked_any = true;
|
||||||
|
match option {
|
||||||
|
ModeOption::Classic => {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
ModeOption::DailyChallenge => {
|
||||||
|
daily.write(StartDailyChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::Zen => {
|
||||||
|
zen.write(StartZenRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::Challenge => {
|
||||||
|
challenge.write(StartChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::TimeAttack => {
|
||||||
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clicked_any && let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
|
||||||
|
/// second click. The popover lists the five overlays previously only
|
||||||
|
/// reachable via the S / A / P / O / L hotkeys.
|
||||||
|
pub(super) fn handle_menu_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
} else if scrims.is_empty() {
|
||||||
|
spawn_menu_popover(&mut commands, font_res.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the menu popover anchored just below the action bar, with one
|
||||||
|
/// row per overlay. Each row dispatches its corresponding
|
||||||
|
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
|
||||||
|
/// HUD never duplicates spawn / despawn / fetch logic).
|
||||||
|
pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 15.0,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each row carries a tooltip alongside its label so hover reveals
|
||||||
|
// a one-line description of what each overlay shows — mirroring
|
||||||
|
// the tooltips on the action-bar buttons that opened this popover.
|
||||||
|
let rows: [(MenuOption, &'static str, &'static str); 7] = [
|
||||||
|
(
|
||||||
|
MenuOption::Help,
|
||||||
|
"Help",
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Modes,
|
||||||
|
"Game Modes",
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Stats,
|
||||||
|
"Stats",
|
||||||
|
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Achievements,
|
||||||
|
"Achievements",
|
||||||
|
"Browse unlocked achievements and the rewards still ahead.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Profile,
|
||||||
|
"Profile",
|
||||||
|
"Your level, XP progress, and sync status.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Settings,
|
||||||
|
"Settings",
|
||||||
|
"Audio, animations, theme, draw mode, and sync.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Leaderboard,
|
||||||
|
"Leaderboard",
|
||||||
|
"Top players from your sync server. Opt in from Profile.",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same upward-opening placement as ModesPopover.
|
||||||
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
MenuPopover,
|
||||||
|
HudPopoverOpen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
bottom: popover_bottom,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
ZIndex(Z_HUD_POPOVER),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
for (option, label, tooltip) in rows {
|
||||||
|
panel
|
||||||
|
.spawn((
|
||||||
|
option,
|
||||||
|
ActionButton,
|
||||||
|
PopoverRow,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::FlexStart,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
min_width: Val::Px(150.0),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transparent fullscreen backdrop behind the popover — tapping anywhere
|
||||||
|
// outside the panel light-dismisses it via handle_menu_backdrop_click.
|
||||||
|
commands.spawn((
|
||||||
|
MenuPopoverBackdrop,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches the click on a menu row to the matching toggle event,
|
||||||
|
/// then despawns the popover.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_menu_option_click(
|
||||||
|
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||||
|
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||||
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
||||||
|
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
||||||
|
mut help: MessageWriter<HelpRequestEvent>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let mut clicked_any = false;
|
||||||
|
let mut open_modes = false;
|
||||||
|
for (interaction, option) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clicked_any = true;
|
||||||
|
match option {
|
||||||
|
MenuOption::Help => {
|
||||||
|
help.write(HelpRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Modes => {
|
||||||
|
open_modes = true;
|
||||||
|
}
|
||||||
|
MenuOption::Stats => {
|
||||||
|
stats.write(ToggleStatsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Achievements => {
|
||||||
|
achievements.write(ToggleAchievementsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Profile => {
|
||||||
|
profile.write(ToggleProfileRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Settings => {
|
||||||
|
settings.write(ToggleSettingsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Leaderboard => {
|
||||||
|
leaderboard.write(ToggleLeaderboardRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clicked_any && let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if open_modes {
|
||||||
|
spawn_modes_popover(
|
||||||
|
&mut commands,
|
||||||
|
progress.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
||||||
|
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
|
||||||
|
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
|
||||||
|
pub(super) fn close_modes_popover_on_escape(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
|
||||||
|
/// is pressed while the popover is open.
|
||||||
|
pub(super) fn close_menu_popover_on_escape(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
|
||||||
|
/// anywhere outside the panel.
|
||||||
|
pub(super) fn handle_modes_backdrop_click(
|
||||||
|
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
|
||||||
|
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
|
||||||
|
pub(super) fn handle_menu_backdrop_click(
|
||||||
|
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn toggle_hud_on_tap(
|
||||||
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut tracker: ResMut<HudTapTracker>,
|
||||||
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
buttons: Query<&Interaction, With<ActionButton>>,
|
||||||
|
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
game_consumed.0 = 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() => {
|
||||||
|
// Also treat taps where game logic consumed the touch (e.g.
|
||||||
|
// drawing from stock) as "on button" so they don't toggle
|
||||||
|
// the HUD. The flag is set on TouchPhase::Started by the
|
||||||
|
// input system that consumed the tap and must be cleared here
|
||||||
|
// regardless of whether we toggle.
|
||||||
|
let on_button = tracker.started_on_button || game_consumed.0;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
if let Some(start) = tracker.start_pos.take()
|
||||||
|
&& !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;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,552 @@
|
|||||||
|
//! HUD construction: band, columns, avatar, and action-bar spawning.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::avatar_plugin::AvatarResource;
|
||||||
|
|
||||||
|
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
||||||
|
/// the screen so the card layout (computed by `layout::compute_layout` using
|
||||||
|
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
||||||
|
///
|
||||||
|
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||||
|
/// A slim grey background is handled by each content section individually
|
||||||
|
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||||
|
pub(super) fn spawn_hud_band(mut commands: Commands) {
|
||||||
|
const BASE_TOP: f32 = 0.0;
|
||||||
|
commands.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(BASE_TOP),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Px(HUD_BAND_HEIGHT),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD - 1),
|
||||||
|
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||||
|
HudBand,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
||||||
|
/// top-left of the play area.
|
||||||
|
///
|
||||||
|
/// Tiers (top to bottom):
|
||||||
|
/// 1. **Primary** — Score (display weight) · Moves · Timer.
|
||||||
|
/// Always visible during gameplay.
|
||||||
|
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
|
||||||
|
/// Draw-cycle indicator. Each cell is empty when not relevant; the
|
||||||
|
/// row collapses visually when all cells are empty.
|
||||||
|
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
|
||||||
|
/// Both penalty counters share `STATE_WARNING` (the audit found
|
||||||
|
/// they were inconsistent: Undos amber, Recycles white).
|
||||||
|
/// 4. **Selection** — keyboard-driven pile selector chip.
|
||||||
|
///
|
||||||
|
/// The audit identified the original single-row layout (10 readouts in
|
||||||
|
/// one horizontal flex row, 5+ colour families competing) as the
|
||||||
|
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||||
|
/// transient items disappear cleanly, and uses the typography scale to
|
||||||
|
/// make Score the visual protagonist.
|
||||||
|
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||||
|
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_score = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_HEADLINE,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_lg = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_body = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let row_node = || Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
||||||
|
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
||||||
|
// Auto-complete, selection chip) can collectively be wider than
|
||||||
|
// the available space and overflow into the action-button column
|
||||||
|
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
||||||
|
// a second line; on a desktop window the rows stay single-line
|
||||||
|
// because the parent column has no width cap and the row never
|
||||||
|
// exceeds the natural line width.
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
align_items: AlignItems::Baseline,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
top: Val::Px(SPACE_2),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
// Cap the column at 50% of viewport so on narrow
|
||||||
|
// (mobile) widths the inner tier rows have a bounded
|
||||||
|
// width to wrap against, and the column can't bleed
|
||||||
|
// into the right-anchored action button row (also
|
||||||
|
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
||||||
|
// wider than any tier row's natural width, so the
|
||||||
|
// visible layout is unaffected.
|
||||||
|
max_width: Val::Percent(50.0),
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
|
HudColumn,
|
||||||
|
))
|
||||||
|
.with_children(|hud| {
|
||||||
|
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||||
|
// Moves and Timer are supporting context (BODY_LG, secondary tone).
|
||||||
|
hud.spawn(row_node()).with_children(|t1| {
|
||||||
|
t1.spawn((
|
||||||
|
HudScore,
|
||||||
|
Tooltip::new("Points earned this game. Hidden in Zen mode."),
|
||||||
|
Text::new("Score: 0"),
|
||||||
|
font_score.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
t1.spawn((
|
||||||
|
HudMoves,
|
||||||
|
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
|
||||||
|
Text::new("Moves: 0"),
|
||||||
|
font_lg.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
t1.spawn((
|
||||||
|
HudTime,
|
||||||
|
Tooltip::new("Time on this game. Counts down in Time Attack."),
|
||||||
|
Text::new("0:00"),
|
||||||
|
font_lg.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 2 — mode context. Each cell is empty until update_hud
|
||||||
|
// populates it (and clears it when no longer relevant), so the
|
||||||
|
// row collapses when nothing in this tier applies.
|
||||||
|
hud.spawn(row_node()).with_children(|t2| {
|
||||||
|
t2.spawn((
|
||||||
|
HudMode,
|
||||||
|
Tooltip::new("Active game mode. Click Modes to switch."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudChallenge,
|
||||||
|
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_INFO),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudDrawCycle,
|
||||||
|
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_INFO),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudWonPreviously,
|
||||||
|
Tooltip::new("You've won this deal before. Same seed in your replay history."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_SUCCESS),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||||
|
// warning hue so they read as the same category ("you took a
|
||||||
|
// penalty"); the auto-complete badge stays success-green.
|
||||||
|
hud.spawn(row_node()).with_children(|t3| {
|
||||||
|
t3.spawn((
|
||||||
|
HudUndos,
|
||||||
|
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_WARNING),
|
||||||
|
));
|
||||||
|
t3.spawn((
|
||||||
|
HudRecycles,
|
||||||
|
Tooltip::new(
|
||||||
|
"Times you've recycled the stock. Three or more unlocks Comeback.",
|
||||||
|
),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_WARNING),
|
||||||
|
));
|
||||||
|
t3.spawn((
|
||||||
|
HudAutoComplete,
|
||||||
|
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_SUCCESS),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 4 — selection chip. Stays in HUD for now; a future
|
||||||
|
// pass can reposition it next to the selected pile.
|
||||||
|
hud.spawn(row_node()).with_children(|t4| {
|
||||||
|
t4.spawn((
|
||||||
|
HudSelection,
|
||||||
|
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body,
|
||||||
|
TextColor(ACCENT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub(super) fn spawn_hud_avatar(
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 32.0;
|
||||||
|
let id = commands
|
||||||
|
.spawn((
|
||||||
|
HudAvatar,
|
||||||
|
Button,
|
||||||
|
Tooltip::new("Your profile — tap to open."),
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(SPACE_2),
|
||||||
|
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.
|
||||||
|
pub(super) 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.
|
||||||
|
pub(super) 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()) {
|
||||||
|
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||||
|
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
|
||||||
|
// 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('?');
|
||||||
|
// Real initial (logged in) keeps the red accent disc; the '?'
|
||||||
|
// unauthenticated fallback uses a neutral grey so it reads as a
|
||||||
|
// "tap to log in" affordance rather than an error.
|
||||||
|
let disc_bg = if initial == '?' {
|
||||||
|
BG_ELEVATED_HI
|
||||||
|
} else {
|
||||||
|
ACCENT_PRIMARY
|
||||||
|
};
|
||||||
|
commands.entity(parent).insert(BackgroundColor(disc_bg));
|
||||||
|
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.
|
||||||
|
pub(super) 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.
|
||||||
|
/// Each child is a clickable button mirroring a keyboard accelerator —
|
||||||
|
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
||||||
|
/// are the primary entry point and the hotkeys are optional.
|
||||||
|
///
|
||||||
|
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||||
|
/// because it's the most consequential action; the destructive button sits
|
||||||
|
/// on its own visual edge.
|
||||||
|
pub(super) fn spawn_action_buttons(
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let action_font_size =
|
||||||
|
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: action_font_size,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||||
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||||
|
// Android reports it (frames 1-3); initial value is 0.0.
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(0.0),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
column_gap: ACTION_BAR_COLUMN_GAP,
|
||||||
|
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),
|
||||||
|
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
||||||
|
HudActionBar,
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||||
|
// so Tab cycles the action bar in visual reading order.
|
||||||
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||||
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
MenuButton,
|
||||||
|
ACTION_BAR_LABELS[0],
|
||||||
|
None,
|
||||||
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
&font,
|
||||||
|
0,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
UndoButton,
|
||||||
|
ACTION_BAR_LABELS[1],
|
||||||
|
Some("U"),
|
||||||
|
"Take back your last move. Costs points and blocks No Undo.",
|
||||||
|
&font,
|
||||||
|
1,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
PauseButton,
|
||||||
|
ACTION_BAR_LABELS[2],
|
||||||
|
Some("Esc"),
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
&font,
|
||||||
|
2,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HelpButton,
|
||||||
|
ACTION_BAR_LABELS[3],
|
||||||
|
Some("F1"),
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
&font,
|
||||||
|
3,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HintButton,
|
||||||
|
ACTION_BAR_LABELS[4],
|
||||||
|
Some("H"),
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
&font,
|
||||||
|
4,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
ModesButton,
|
||||||
|
ACTION_BAR_LABELS[5],
|
||||||
|
None,
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
&font,
|
||||||
|
5,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
NewGameButton,
|
||||||
|
ACTION_BAR_LABELS[6],
|
||||||
|
Some("N"),
|
||||||
|
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||||
|
&font,
|
||||||
|
6,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a single action button as a child of `row`. Each button shares
|
||||||
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||||
|
/// `paint_action_buttons` can recolour all of them with one query.
|
||||||
|
///
|
||||||
|
/// `order` is the button's index inside the action bar (0 for the
|
||||||
|
/// leftmost). It propagates into the [`Focusable`] this function inserts
|
||||||
|
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
|
||||||
|
///
|
||||||
|
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||||||
|
/// action button ships with one — there is no opt-out — because each button
|
||||||
|
/// represents a player-triggered action and benefits from a one-line
|
||||||
|
/// reminder of what it does.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn spawn_action_button<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
hotkey: Option<&'static str>,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
text_color: Color,
|
||||||
|
) {
|
||||||
|
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||||
|
// touch device — the button itself is the affordance — and they
|
||||||
|
// visibly clutter the narrow-viewport action row. The chevrons on
|
||||||
|
// Menu/Modes remain because they indicate dropdown behaviour.
|
||||||
|
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
hotkey
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let hotkey_font = TextFont {
|
||||||
|
font: font.font.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let (pad, min_w, min_h) = action_button_metrics();
|
||||||
|
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Focusable {
|
||||||
|
group: FocusGroup::Hud,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
padding: pad,
|
||||||
|
min_width: min_w,
|
||||||
|
min_height: min_h,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
spawn_action_button_label(b, label, font, text_color);
|
||||||
|
if let Some(key) = hotkey {
|
||||||
|
// Hotkey hint rendered as a dim caption next to the label —
|
||||||
|
// keeps the keyboard accelerator discoverable without
|
||||||
|
// hijacking the button's primary affordance.
|
||||||
|
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
|
|||||||
@@ -0,0 +1,612 @@
|
|||||||
|
//! Per-frame HUD text/typography/visibility updater systems.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
use solitaire_core::Suit;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
|
||||||
|
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
|
||||||
|
///
|
||||||
|
/// For example `format_time_limit(300)` returns `"5:00"`.
|
||||||
|
pub fn format_time_limit(secs: u64) -> String {
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Score-change feedback (G2)
|
||||||
|
//
|
||||||
|
// The flow for each Update tick:
|
||||||
|
// 1. `detect_score_change` diffs `GameStateResource.score` against
|
||||||
|
// `PreviousScore`. On any positive delta it inserts/refreshes
|
||||||
|
// `ScorePulse` on the score readout; on a delta ≥
|
||||||
|
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
|
||||||
|
// anchored just below the score.
|
||||||
|
// 2. `advance_score_pulse` ticks the pulse component, applies the
|
||||||
|
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
|
||||||
|
// component on completion.
|
||||||
|
// 3. `advance_score_floater` drifts each floater upward, fades it to
|
||||||
|
// transparent, and despawns it when its lifetime expires.
|
||||||
|
//
|
||||||
|
// The threshold of 50 (a foundation promotion's typical bonus) keeps
|
||||||
|
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
||||||
|
/// current deal's seed + draw_mode + mode triple matches an entry in
|
||||||
|
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
||||||
|
/// (the on-screen "Game won!" cue already conveys victory) and on
|
||||||
|
/// fresh deals the player hasn't won before.
|
||||||
|
///
|
||||||
|
/// Lives in its own system rather than `update_hud` to keep this
|
||||||
|
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
||||||
|
/// enough; threading another marker through every Without filter
|
||||||
|
/// would touch ~10 unrelated queries for no benefit.
|
||||||
|
pub(super) fn update_won_previously(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
// Optional because the HUD plugin's headless tests run without
|
||||||
|
// `StatsPlugin` and therefore without this resource. With the
|
||||||
|
// resource absent there's no history to compare against; the
|
||||||
|
// indicator just stays empty.
|
||||||
|
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||||
|
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = q.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let won_before = !game.0.is_won()
|
||||||
|
&& history.as_ref().is_some_and(|h| {
|
||||||
|
h.0.replays.iter().any(|r| {
|
||||||
|
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let next = if won_before {
|
||||||
|
"\u{2713} Won before"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if text.0 != next {
|
||||||
|
text.0 = next.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_hud(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
time_attack: Option<Res<TimeAttackResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
auto_complete: Option<Res<AutoCompleteState>>,
|
||||||
|
mut score_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut moves_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMoves>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut time_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudTime>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut mode_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMode>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut challenge_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudChallenge>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut undos_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudUndos>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut auto_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudAutoComplete>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut recycles_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudRecycles>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut draw_cycle_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudDrawCycle>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
||||||
|
|
||||||
|
// Score, moves, mode, challenge, and undos only need updating when game state changes.
|
||||||
|
if game.is_changed() {
|
||||||
|
let g = &game.0;
|
||||||
|
let is_zen = g.mode == GameMode::Zen;
|
||||||
|
if let Ok(mut t) = score_q.single_mut() {
|
||||||
|
// Zen mode suppresses score display per spec ("No score display").
|
||||||
|
**t = if is_zen {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("Score: {}", g.score())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = moves_q.single_mut() {
|
||||||
|
**t = format!("Moves: {}", g.move_count());
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = mode_q.single_mut() {
|
||||||
|
**t = match g.mode {
|
||||||
|
GameMode::Classic => match g.draw_mode() {
|
||||||
|
DrawStockConfig::DrawOne => String::new(),
|
||||||
|
DrawStockConfig::DrawThree => "Draw 3".to_string(),
|
||||||
|
},
|
||||||
|
GameMode::Zen => "ZEN".to_string(),
|
||||||
|
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||||
|
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||||
|
GameMode::Difficulty(level) => level.label().to_uppercase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daily challenge constraint (with time-low colour warning) ---
|
||||||
|
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
|
||||||
|
if g.is_won() {
|
||||||
|
**t = String::new();
|
||||||
|
} else if let Some(dc) = daily.as_deref() {
|
||||||
|
**t = challenge_hud_text(dc);
|
||||||
|
if let Some(max_secs) = dc.max_time_secs {
|
||||||
|
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
|
||||||
|
*color = TextColor(challenge_time_color(remaining));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
**t = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Undo count ---
|
||||||
|
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
||||||
|
let count = g.undo_count();
|
||||||
|
if count == 0 {
|
||||||
|
**t = String::new();
|
||||||
|
*color = TextColor(TEXT_PRIMARY);
|
||||||
|
} else {
|
||||||
|
**t = format!("Undos: {count}");
|
||||||
|
// STATE_WARNING signals "you took a penalty" — same hue
|
||||||
|
// as the Recycles counter so they read as one category.
|
||||||
|
*color = TextColor(STATE_WARNING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||||
|
if let Ok(mut t) = recycles_q.single_mut() {
|
||||||
|
**t = if g.recycle_count() > 0 {
|
||||||
|
format!("Recycles: {}", g.recycle_count())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
||||||
|
if let Ok(mut t) = draw_cycle_q.single_mut() {
|
||||||
|
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
|
||||||
|
// Hide when not in Draw-Three or after the game is won.
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let stock_len = g.stock_cards().len();
|
||||||
|
let next_draw = stock_len.min(3);
|
||||||
|
format!("Cycle: {next_draw}/3")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time display: show Time Attack countdown every frame when active;
|
||||||
|
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
|
||||||
|
// every frame so it disappears immediately on the frame Z is pressed.
|
||||||
|
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||||
|
let is_zen = game.0.mode == GameMode::Zen;
|
||||||
|
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||||
|
if update_time {
|
||||||
|
if let Ok(mut t) = time_q.single_mut() {
|
||||||
|
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
|
||||||
|
let remaining = ta.remaining_secs.max(0.0) as u64;
|
||||||
|
let m = remaining / 60;
|
||||||
|
let s = remaining % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
} else {
|
||||||
|
let secs = game.0.elapsed_seconds;
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if is_zen {
|
||||||
|
// Clear the time display immediately whenever Zen mode is active —
|
||||||
|
// do not guard on game.is_changed() so it clears on the same frame
|
||||||
|
// the player presses Z, before any move is made.
|
||||||
|
if let Ok(mut t) = time_q.single_mut() {
|
||||||
|
**t = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-complete badge ---
|
||||||
|
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
|
||||||
|
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||||
|
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
||||||
|
if (ac_changed || game.is_changed())
|
||||||
|
&& let Ok(mut t) = auto_q.single_mut()
|
||||||
|
{
|
||||||
|
**t = if ac_active {
|
||||||
|
"AUTO".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
||||||
|
///
|
||||||
|
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
|
||||||
|
/// or an empty string when no pile is selected. Runs every frame so the
|
||||||
|
/// indicator stays in sync with the selection resource.
|
||||||
|
pub(super) fn update_selection_hud(
|
||||||
|
selection: Option<Res<SelectionState>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut q: Query<&mut Text, With<HudSelection>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut t) = q.single_mut() else { return };
|
||||||
|
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||||
|
None => String::new(),
|
||||||
|
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
|
||||||
|
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
|
||||||
|
Some(g) => foundation_selection_label(*slot, &g.0),
|
||||||
|
// No game resource means we can't probe claimed_suit; show the
|
||||||
|
// slot-based placeholder so the HUD still surfaces the selection.
|
||||||
|
None => format!("▶ Foundation {}", foundation_number(*slot)),
|
||||||
|
},
|
||||||
|
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
|
||||||
|
};
|
||||||
|
**t = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HUD selection label for a foundation slot.
|
||||||
|
///
|
||||||
|
/// When the slot has a claimed suit (any card has landed) the announcement is
|
||||||
|
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||||
|
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
||||||
|
pub(super) fn foundation_selection_label(
|
||||||
|
slot: Foundation,
|
||||||
|
game: &solitaire_core::game_state::GameState,
|
||||||
|
) -> String {
|
||||||
|
let claimed = game
|
||||||
|
.pile(KlondikePile::Foundation(slot))
|
||||||
|
.first()
|
||||||
|
.map(|c| c.0.suit());
|
||||||
|
match claimed {
|
||||||
|
Some(suit) => {
|
||||||
|
let s = match suit {
|
||||||
|
Suit::Clubs => "Clubs",
|
||||||
|
Suit::Diamonds => "Diamonds",
|
||||||
|
Suit::Hearts => "Hearts",
|
||||||
|
Suit::Spades => "Spades",
|
||||||
|
};
|
||||||
|
format!("▶ {s} Foundation")
|
||||||
|
}
|
||||||
|
None => format!("▶ Foundation {}", foundation_number(slot)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn foundation_number(foundation: Foundation) -> u8 {
|
||||||
|
match foundation {
|
||||||
|
Foundation::Foundation1 => 1,
|
||||||
|
Foundation::Foundation2 => 2,
|
||||||
|
Foundation::Foundation3 => 3,
|
||||||
|
Foundation::Foundation4 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||||
|
match tableau {
|
||||||
|
Tableau::Tableau1 => 1,
|
||||||
|
Tableau::Tableau2 => 2,
|
||||||
|
Tableau::Tableau3 => 3,
|
||||||
|
Tableau::Tableau4 => 4,
|
||||||
|
Tableau::Tableau5 => 5,
|
||||||
|
Tableau::Tableau6 => 6,
|
||||||
|
Tableau::Tableau7 => 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||||
|
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
||||||
|
/// to debounce so the toast only appears on the leading edge.
|
||||||
|
pub(super) fn announce_auto_complete(
|
||||||
|
auto_complete: Option<Res<AutoCompleteState>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut was_active: Local<bool>,
|
||||||
|
) {
|
||||||
|
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||||
|
if now_active && !*was_active {
|
||||||
|
toast.write(InfoToastEvent("Auto-completing...".to_string()));
|
||||||
|
}
|
||||||
|
*was_active = now_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the HUD text for the active daily challenge constraints.
|
||||||
|
///
|
||||||
|
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
|
||||||
|
/// score target is set, or an empty string when the challenge has no extra
|
||||||
|
/// constraints.
|
||||||
|
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||||
|
if let Some(secs) = dc.max_time_secs {
|
||||||
|
format!("Limit: {}", format_time_limit(secs))
|
||||||
|
} else if let Some(score) = dc.target_score {
|
||||||
|
format!("Goal: {score} pts")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the colour for the challenge time-limit HUD label based on
|
||||||
|
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
|
||||||
|
/// palette changes for free.
|
||||||
|
///
|
||||||
|
/// | Remaining | Token |
|
||||||
|
/// |-------------|------------------|
|
||||||
|
/// | ≥ 60 s | `STATE_INFO` |
|
||||||
|
/// | 30 – 59 s | `STATE_WARNING` |
|
||||||
|
/// | < 30 s | `STATE_DANGER` |
|
||||||
|
pub fn challenge_time_color(remaining: u64) -> Color {
|
||||||
|
if remaining < 30 {
|
||||||
|
STATE_DANGER
|
||||||
|
} else if remaining < 60 {
|
||||||
|
STATE_WARNING
|
||||||
|
} else {
|
||||||
|
STATE_INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
|
||||||
|
///
|
||||||
|
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
|
||||||
|
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
|
||||||
|
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
|
||||||
|
/// all three items remain on one row inside the 50 %-wide HUD column
|
||||||
|
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
|
||||||
|
/// restored so desktop/tablet layouts are unaffected.
|
||||||
|
type HudScoreFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
|
||||||
|
type HudMovesFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
|
||||||
|
type HudTimeFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
|
||||||
|
|
||||||
|
pub(super) fn update_hud_typography(
|
||||||
|
mut events: MessageReader<WindowResized>,
|
||||||
|
mut score_q: HudScoreFont,
|
||||||
|
mut moves_q: HudMovesFont,
|
||||||
|
mut time_q: HudTimeFont,
|
||||||
|
) {
|
||||||
|
let Some(ev) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (score_size, secondary_size) = if ev.width < 480.0 {
|
||||||
|
(TYPE_BODY_LG, TYPE_CAPTION)
|
||||||
|
} else {
|
||||||
|
(TYPE_HEADLINE, TYPE_BODY_LG)
|
||||||
|
};
|
||||||
|
for mut font in &mut score_q {
|
||||||
|
font.font_size = score_size;
|
||||||
|
}
|
||||||
|
for mut font in &mut moves_q {
|
||||||
|
font.font_size = secondary_size;
|
||||||
|
}
|
||||||
|
for mut font in &mut time_q {
|
||||||
|
font.font_size = secondary_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn apply_hud_visibility(
|
||||||
|
hud_vis: Res<HudVisibility>,
|
||||||
|
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
||||||
|
) {
|
||||||
|
if !hud_vis.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let v = if *hud_vis == HudVisibility::Visible {
|
||||||
|
Visibility::Visible
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
};
|
||||||
|
for mut vis in &mut action_bar {
|
||||||
|
*vis = v;
|
||||||
|
}
|
||||||
|
// The bottom action bar is a pure overlay — it does not claim any
|
||||||
|
// space in the card layout, so no WindowResized event is needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restore_hud_on_modal(
|
||||||
|
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||||
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
) {
|
||||||
|
if !new_scrims.is_empty() {
|
||||||
|
*hud_vis = HudVisibility::Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the action-bar label font size for a given logical window width.
|
||||||
|
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
|
||||||
|
// must share one row. The widest characters are in FiraMono (a
|
||||||
|
// monospace whose advance is ~0.62 of the font size). On a 900
|
||||||
|
// logical-px phone the row budget after bar padding (2*12) and six
|
||||||
|
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
|
||||||
|
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
|
||||||
|
// labels are advance-bound only on very narrow viewports; the real
|
||||||
|
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
|
||||||
|
// at 900 px — comfortably one row with margin to spare — clamped so it
|
||||||
|
// never drops below the 12 px legibility floor or grows past 18 px on
|
||||||
|
// landscape tablets where it would crowd the row again.
|
||||||
|
(window_width / 60.0).clamp(12.0, 18.0)
|
||||||
|
} else {
|
||||||
|
TYPE_BODY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
||||||
|
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
||||||
|
// shortest labels ("New", "Help") shrink to their text rather than
|
||||||
|
// padding the row out past the 900 logical-px viewport. min_height
|
||||||
|
// stays at 44 px to preserve the comfortable touch target.
|
||||||
|
(
|
||||||
|
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||||
|
Val::Px(44.0),
|
||||||
|
Val::Px(44.0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
|
Val::Px(48.0),
|
||||||
|
Val::Px(48.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn spawn_action_button_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
font: &TextFont,
|
||||||
|
text_color: Color,
|
||||||
|
) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
parent.spawn((
|
||||||
|
ActionButtonLabel,
|
||||||
|
Text::new(label),
|
||||||
|
font.clone(),
|
||||||
|
TextColor(text_color),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||||
|
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
||||||
|
/// change or window resize).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(super) fn resize_action_bar_labels(
|
||||||
|
layout: Res<crate::layout::LayoutResource>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
||||||
|
) {
|
||||||
|
let w = windows
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
||||||
|
let new_size = action_bar_font_size(w);
|
||||||
|
for mut font in &mut labels {
|
||||||
|
font.font_size = new_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
//! Input handling for the Settings panel: button presses, keyboard
|
||||||
|
//! accelerators, focus attachment, and scrolling.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
use solitaire_data::{
|
||||||
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||||||
|
settings::Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::ui_focus::{FocusGroup, Focusable, FocusedButton};
|
||||||
|
use crate::ui_modal::{ModalButton, ModalScrim};
|
||||||
|
use crate::ui_theme::SPACE_2;
|
||||||
|
|
||||||
|
pub(super) fn handle_volume_keys(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let mut delta = 0.0_f32;
|
||||||
|
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||||
|
delta -= SFX_STEP;
|
||||||
|
}
|
||||||
|
if keys.just_pressed(KeyCode::BracketRight) {
|
||||||
|
delta += SFX_STEP;
|
||||||
|
}
|
||||||
|
if delta == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(delta);
|
||||||
|
if (before - after).abs() < f32::EPSILON {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"SFX volume: {}%",
|
||||||
|
(after * 100.0).round() as i32
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||||
|
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
||||||
|
pub(super) fn toggle_settings_screen(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
) {
|
||||||
|
let button_clicked = requests.read().count() > 0;
|
||||||
|
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||||||
|
screen.0 = !screen.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to button presses inside the Settings panel.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
pub(super) fn handle_settings_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut sfx_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut music_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<MusicVolumeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut draw_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<DrawModeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut theme_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ThemeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut anim_speed_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<AnimSpeedText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut color_blind_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ColorBlindText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut high_contrast_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HighContrastText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut reduce_motion_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ReduceMotionText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SfxDown => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::SfxUp => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicDown => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicUp => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleDrawMode => {
|
||||||
|
settings.0.draw_mode = match settings.0.draw_mode {
|
||||||
|
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||||
|
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = draw_text.single_mut() {
|
||||||
|
**t = draw_mode_label(&settings.0.draw_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::CycleAnimSpeed => {
|
||||||
|
settings.0.animation_speed = match settings.0.animation_speed {
|
||||||
|
AnimSpeed::Normal => AnimSpeed::Fast,
|
||||||
|
AnimSpeed::Fast => AnimSpeed::Instant,
|
||||||
|
AnimSpeed::Instant => AnimSpeed::Normal,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = anim_speed_text.single_mut() {
|
||||||
|
**t = anim_speed_label(&settings.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayDown => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_tooltip_delay_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayUp => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusDown => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_time_bonus_multiplier_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusUp => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_replay_move_interval_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTheme => {
|
||||||
|
settings.0.theme = match settings.0.theme {
|
||||||
|
Theme::Green => Theme::Blue,
|
||||||
|
Theme::Blue => Theme::Dark,
|
||||||
|
Theme::Dark => Theme::Green,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = theme_text.single_mut() {
|
||||||
|
**t = theme_label(&settings.0.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleColorBlind => {
|
||||||
|
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = color_blind_text.single_mut() {
|
||||||
|
**t = color_blind_label(settings.0.color_blind_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleHighContrast => {
|
||||||
|
settings.0.high_contrast_mode = !settings.0.high_contrast_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = high_contrast_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.high_contrast_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleReduceMotion => {
|
||||||
|
settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = reduce_motion_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTouchInputMode => {
|
||||||
|
use solitaire_data::settings::TouchInputMode;
|
||||||
|
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||||
|
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
|
||||||
|
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleAnalytics => {
|
||||||
|
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleSmartDefaultSize => {
|
||||||
|
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_smart_default_size_text` next frame. The
|
||||||
|
// sizer system is gated only at startup, so flipping
|
||||||
|
// this mid-session takes effect on the next launch —
|
||||||
|
// documented on the field in `solitaire_data::Settings`.
|
||||||
|
}
|
||||||
|
SettingsButton::SelectCardBack(idx) => {
|
||||||
|
settings.0.selected_card_back = *idx;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::SelectBackground(idx) => {
|
||||||
|
settings.0.selected_background = *idx;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::SelectTheme(theme_id) => {
|
||||||
|
if settings.0.selected_theme_id != *theme_id {
|
||||||
|
settings.0.selected_theme_id = theme_id.clone();
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::ScanThemes => {
|
||||||
|
// Handled by `handle_scan_themes`.
|
||||||
|
}
|
||||||
|
SettingsButton::SyncNow
|
||||||
|
| SettingsButton::ConnectSync
|
||||||
|
| SettingsButton::DisconnectSync
|
||||||
|
| SettingsButton::DeleteAccount => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
|
SettingsButton::Done => {
|
||||||
|
screen.0 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||||
|
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||||
|
/// Bevy's 16-parameter system limit.
|
||||||
|
pub(super) fn handle_sync_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SyncNow => {
|
||||||
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::ConnectSync => {
|
||||||
|
// Close settings before the sync-setup modal opens so the
|
||||||
|
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||||
|
screen.0 = false;
|
||||||
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DisconnectSync => {
|
||||||
|
logout_sync.write(SyncLogoutRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DeleteAccount => {
|
||||||
|
delete_account.write(DeleteAccountRequestEvent);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||||
|
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||||
|
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||||
|
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||||||
|
/// [`ModalButton`]) and is filtered out here.
|
||||||
|
///
|
||||||
|
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||||||
|
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||||||
|
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn attach_focusable_to_settings_buttons(
|
||||||
|
mut commands: Commands,
|
||||||
|
new_buttons: Query<
|
||||||
|
(Entity, &SettingsButton),
|
||||||
|
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||||||
|
>,
|
||||||
|
parents: Query<&ChildOf>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
) {
|
||||||
|
for (button, settings_button) in &new_buttons {
|
||||||
|
let mut current = button;
|
||||||
|
let mut scrim_entity: Option<Entity> = None;
|
||||||
|
for _ in 0..32 {
|
||||||
|
if scrims.get(current).is_ok() {
|
||||||
|
scrim_entity = Some(current);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match parents.get(current) {
|
||||||
|
Ok(parent) => current = parent.parent(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(scrim) = scrim_entity {
|
||||||
|
commands.entity(button).insert(Focusable {
|
||||||
|
group: FocusGroup::Modal(scrim),
|
||||||
|
order: settings_button.focus_order(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical padding (logical px) added around the focused button when
|
||||||
|
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||||||
|
/// below the viewport edge.
|
||||||
|
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||||||
|
|
||||||
|
/// When the focused entity sits outside the visible Settings scroll
|
||||||
|
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||||||
|
/// fully visible. No-op when:
|
||||||
|
///
|
||||||
|
/// - `FocusedButton` is `None`
|
||||||
|
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||||||
|
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||||||
|
/// - the focused entity is not a descendant of the
|
||||||
|
/// [`SettingsPanelScrollable`] container
|
||||||
|
///
|
||||||
|
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||||||
|
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||||||
|
/// The focused button's vertical extent is computed from its
|
||||||
|
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||||||
|
/// `ComputedNode.size.y`. Because the scroll container's local
|
||||||
|
/// coordinates run [0, content_height] and the visible window is
|
||||||
|
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||||||
|
/// space Y to container-local Y by subtracting the container's window-
|
||||||
|
/// space top and adding the current scroll offset.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn scroll_focus_into_view(
|
||||||
|
focused: Res<FocusedButton>,
|
||||||
|
parents: Query<&ChildOf>,
|
||||||
|
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||||||
|
mut containers: Query<
|
||||||
|
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||||||
|
With<SettingsPanelScrollable>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let Some(target) = focused.0 else { return };
|
||||||
|
// Gather button geometry.
|
||||||
|
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk ancestors looking for the scroll container. Bounded to keep
|
||||||
|
// a malformed hierarchy from hanging the system.
|
||||||
|
let mut current = target;
|
||||||
|
let mut container_entity: Option<Entity> = None;
|
||||||
|
for _ in 0..32 {
|
||||||
|
if containers.get(current).is_ok() {
|
||||||
|
container_entity = Some(current);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match parents.get(current) {
|
||||||
|
Ok(parent) => current = parent.parent(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(container) = container_entity else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||||||
|
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||||||
|
// so convert via `inverse_scale_factor` before we write.
|
||||||
|
let inv = target_node.inverse_scale_factor;
|
||||||
|
let target_height = target_node.size().y;
|
||||||
|
let target_centre_y = target_transform.translation.y;
|
||||||
|
let target_top = target_centre_y - target_height * 0.5;
|
||||||
|
let target_bottom = target_centre_y + target_height * 0.5;
|
||||||
|
|
||||||
|
let container_height = container_node.size().y;
|
||||||
|
let container_top = container_transform.translation.y - container_height * 0.5;
|
||||||
|
|
||||||
|
// Convert button window-space Y to container-local Y. The container
|
||||||
|
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||||||
|
// by physical-per-logical to compare with physical pixel extents.
|
||||||
|
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||||||
|
let viewport_top = container_top + scroll_phys;
|
||||||
|
let viewport_bottom = viewport_top + container_height;
|
||||||
|
|
||||||
|
// Layout may not have run yet (zero size on first frame) — no
|
||||||
|
// sensible scroll target until the container has dimensions.
|
||||||
|
if container_height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||||||
|
if target_top < viewport_top {
|
||||||
|
// Button extends above the viewport — scroll up.
|
||||||
|
let new_top = target_top - pad_phys;
|
||||||
|
let delta = new_top - viewport_top;
|
||||||
|
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||||
|
} else if target_bottom > viewport_bottom {
|
||||||
|
// Button extends below the viewport — scroll down.
|
||||||
|
let new_bottom = target_bottom + pad_phys;
|
||||||
|
let delta = new_bottom - viewport_bottom;
|
||||||
|
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||||
|
///
|
||||||
|
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||||
|
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||||||
|
/// scrolls past the top.
|
||||||
|
pub(super) fn scroll_settings_panel(
|
||||||
|
mut scroll_evr: MessageReader<MouseWheel>,
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||||||
|
) {
|
||||||
|
if !screen.0 {
|
||||||
|
scroll_evr.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta_y: f32 = scroll_evr
|
||||||
|
.read()
|
||||||
|
.map(|ev| match ev.unit {
|
||||||
|
MouseScrollUnit::Line => ev.y * 50.0,
|
||||||
|
MouseScrollUnit::Pixel => ev.y,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if delta_y == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut sp in scrollables.iter_mut() {
|
||||||
|
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::ui_focus::{FocusRow, Focusable};
|
||||||
|
use crate::ui_modal::ModalButton;
|
||||||
|
use crate::ui_tooltip::Tooltip;
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -326,9 +329,7 @@ fn settings_buttons_carry_tooltip() {
|
|||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.find_map(|(btn, tip)| {
|
.find_map(|(btn, tip)| matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()))
|
||||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
|
||||||
})
|
|
||||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
connect_tip.as_ref(),
|
connect_tip.as_ref(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
|||||||
|
//! Per-frame value-text updater systems and their label helpers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
use solitaire_data::{AnimSpeed, settings::Theme};
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
use crate::theme::ThemeThumbnailCache;
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||||||
|
|
||||||
|
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||||
|
/// despawns it when it becomes `false`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_settings_panel_visibility(
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||||
|
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||||
|
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||||
|
mut commands: Commands,
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
|
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||||
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
|
) {
|
||||||
|
if !screen.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if screen.0 {
|
||||||
|
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||||
|
let status_label = sync_status.map_or_else(
|
||||||
|
|| "Status: local only".to_string(),
|
||||||
|
|s| sync_status_label(&s.0),
|
||||||
|
);
|
||||||
|
let unlocked_backs = progress
|
||||||
|
.as_ref()
|
||||||
|
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||||
|
let unlocked_bgs = progress
|
||||||
|
.as_ref()
|
||||||
|
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||||||
|
// Snapshot themes by id, display_name and (optional)
|
||||||
|
// thumbnail pair so spawn_settings_panel doesn't have to
|
||||||
|
// know about the registry / cache shapes. Empty when
|
||||||
|
// ThemeRegistryPlugin isn't installed (tests under
|
||||||
|
// MinimalPlugins) — the picker row simply won't render.
|
||||||
|
// Missing thumbnails (cache not ready, or partial user
|
||||||
|
// theme) leave `thumbnails: None` so the chip renders its
|
||||||
|
// plain-text fallback instead of a broken sprite.
|
||||||
|
let themes: Vec<ThemePickerEntry> = theme_registry
|
||||||
|
.as_deref()
|
||||||
|
.map(|r| {
|
||||||
|
r.iter()
|
||||||
|
.map(|e| ThemePickerEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
display_name: e.display_name.clone(),
|
||||||
|
thumbnails: theme_thumbs
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|c| c.get(&e.id))
|
||||||
|
.filter(|p| p.is_fully_populated())
|
||||||
|
.cloned(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
// The active card-art theme can supply its own back image —
|
||||||
|
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||||||
|
// populated the legacy "Card Back" picker has no visible
|
||||||
|
// effect, so we render it muted with an explanatory caption
|
||||||
|
// rather than letting the player click swatches that do
|
||||||
|
// nothing. Absent under `MinimalPlugins`; treated as
|
||||||
|
// "no override" in that case.
|
||||||
|
let theme_overrides_back = card_images
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|cs| cs.theme_back.is_some());
|
||||||
|
spawn_settings_panel(
|
||||||
|
&mut commands,
|
||||||
|
&settings.0,
|
||||||
|
&status_label,
|
||||||
|
unlocked_backs,
|
||||||
|
unlocked_bgs,
|
||||||
|
&themes,
|
||||||
|
scroll_pos.0,
|
||||||
|
font_res.as_deref(),
|
||||||
|
theme_overrides_back,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Save the current scroll offset before despawning the panel.
|
||||||
|
if let Ok(sp) = scroll_nodes.single() {
|
||||||
|
scroll_pos.0 = sp.0.y;
|
||||||
|
}
|
||||||
|
for entity in &panels {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the sync-status text node current while the panel is open.
|
||||||
|
pub(super) fn update_sync_status_text(
|
||||||
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
|
||||||
|
) {
|
||||||
|
let Some(status) = sync_status else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !status.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let label = sync_status_label(&status.0);
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_card_back_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<CardBackText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = card_back_label(settings.0.selected_card_back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_background_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = background_label(settings.0.selected_background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_anim_speed_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = anim_speed_label(&settings.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_color_blind_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = color_blind_label(settings.0.color_blind_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_high_contrast_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<HighContrastText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.high_contrast_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints `BorderColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current border colour and
|
||||||
|
/// only mutates when different so Bevy's change-detection
|
||||||
|
/// doesn't trigger repaints every frame.
|
||||||
|
///
|
||||||
|
/// Spec at `design-system.md` §Accessibility (#2): under HC,
|
||||||
|
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
|
||||||
|
/// modal panels, popover edges, and focus-ring carriers stay
|
||||||
|
/// legible on low-quality displays / for low-vision users.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the modal scaffold's card border
|
||||||
|
/// (`ui_modal::spawn_modal`). More sites can be tagged in
|
||||||
|
/// follow-ups by adding `HighContrastBorder::with_default(...)`
|
||||||
|
/// to their spawn tuple.
|
||||||
|
pub(super) fn update_high_contrast_borders(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut border) in borders.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
BORDER_SUBTLE_HC
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
// Only mutate when actually different — avoids per-frame
|
||||||
|
// change-detection churn. `border.left` is representative
|
||||||
|
// because every tagged site uses `BorderColor::all(...)`.
|
||||||
|
if border.left != target {
|
||||||
|
*border = BorderColor::all(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints `BackgroundColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current background and only
|
||||||
|
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||||
|
/// repaints every frame.
|
||||||
|
///
|
||||||
|
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||||
|
/// same change-suppression idiom, different colour channel —
|
||||||
|
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||||
|
/// separators that paint their shape directly rather than via a
|
||||||
|
/// `BorderColor` on a wider Node.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||||
|
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||||
|
///
|
||||||
|
/// More sites can be tagged in follow-ups by adding
|
||||||
|
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||||
|
pub(super) fn update_high_contrast_backgrounds(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
marker.hc_color
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
if bg.0 != target {
|
||||||
|
*bg = BackgroundColor(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_reduce_motion_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.reduce_motion_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_touch_input_mode_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = touch_input_mode_label(&settings.0.touch_input_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Winnable deals only" toggle value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||||||
|
/// hand-edited `settings.json` reload, etc.).
|
||||||
|
pub(super) fn update_winnable_deals_only_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Share usage data" toggle value in the Privacy section
|
||||||
|
/// whenever `SettingsResource` changes.
|
||||||
|
pub(super) fn update_analytics_enabled_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.analytics_enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Smart window size" toggle value whenever
|
||||||
|
/// `SettingsResource` changes. The flag is stored negatively as
|
||||||
|
/// `disable_smart_default_size`, so the label inverts.
|
||||||
|
pub(super) fn update_smart_default_size_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<SmartDefaultSizeText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = smart_default_size_label(!settings.0.disable_smart_default_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live tooltip-delay value in the Gameplay section
|
||||||
|
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||||||
|
/// settings.json reload, etc.).
|
||||||
|
pub(super) fn update_tooltip_delay_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live time-bonus-multiplier value in the Gameplay
|
||||||
|
/// section whenever `SettingsResource` changes.
|
||||||
|
pub(super) fn update_time_bonus_multiplier_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = time_bonus_label(settings.0.time_bonus_multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live replay-playback per-move-interval value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||||
|
/// hand-edited settings.json reload, etc.).
|
||||||
|
pub(super) fn update_replay_move_interval_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn card_back_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn background_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sync_status_label(status: &SyncStatus) -> String {
|
||||||
|
match status {
|
||||||
|
SyncStatus::Idle => "Status: idle".to_string(),
|
||||||
|
SyncStatus::Syncing => "Status: syncing…".to_string(),
|
||||||
|
SyncStatus::LastSynced(t) => {
|
||||||
|
let secs = chrono::Utc::now()
|
||||||
|
.signed_duration_since(*t)
|
||||||
|
.num_seconds()
|
||||||
|
.max(0);
|
||||||
|
if secs < 60 {
|
||||||
|
format!("Last synced: {secs}s ago")
|
||||||
|
} else {
|
||||||
|
format!("Last synced: {}m ago", secs / 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||||||
|
match mode {
|
||||||
|
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||||||
|
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String {
|
||||||
|
match speed {
|
||||||
|
AnimSpeed::Normal => "Normal".into(),
|
||||||
|
AnimSpeed::Fast => "Fast".into(),
|
||||||
|
AnimSpeed::Instant => "Instant".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn theme_label(theme: &Theme) -> String {
|
||||||
|
match theme {
|
||||||
|
Theme::Green => "Green".into(),
|
||||||
|
Theme::Blue => "Blue".into(),
|
||||||
|
Theme::Dark => "Dark".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn color_blind_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic ON/OFF label shared by the high-contrast and reduce-
|
||||||
|
/// motion accessibility toggles. Same format as
|
||||||
|
/// [`color_blind_label`] / [`winnable_deals_only_label`] —
|
||||||
|
/// keeping all simple boolean toggle rows visually uniform.
|
||||||
|
pub(super) fn on_off_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display string for the "Winnable deals only" toggle. Mirrors
|
||||||
|
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
|
||||||
|
/// with the rest of the Gameplay-section toggles.
|
||||||
|
pub(super) fn winnable_deals_only_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
|
||||||
|
use solitaire_data::settings::TouchInputMode;
|
||||||
|
match mode {
|
||||||
|
TouchInputMode::OneTap => "One-tap".into(),
|
||||||
|
TouchInputMode::TapToSelect => "Tap to select".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display string for the "Smart window size" toggle. The argument
|
||||||
|
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||||
|
/// `disable_smart_default_size` field) so reading the label gives
|
||||||
|
/// the player intuitive ON/OFF semantics.
|
||||||
|
pub(super) fn smart_default_size_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the tooltip-hover delay for display in the Settings panel.
|
||||||
|
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
|
||||||
|
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
|
||||||
|
pub(super) fn tooltip_delay_label(secs: f32) -> String {
|
||||||
|
if secs <= 0.0 {
|
||||||
|
"Instant".into()
|
||||||
|
} else {
|
||||||
|
format!("{secs:.1} s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the cosmetic time-bonus multiplier for display in the
|
||||||
|
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
|
||||||
|
/// time-bonus row will be hidden; any other value prints as
|
||||||
|
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
|
||||||
|
pub(super) fn time_bonus_label(value: f32) -> String {
|
||||||
|
if value <= 0.0 {
|
||||||
|
"Off".into()
|
||||||
|
} else {
|
||||||
|
format!("{value:.1}×")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the replay-playback per-move interval for display in the
|
||||||
|
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||||||
|
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||||||
|
/// using two decimal places because the step is 0.05 s.
|
||||||
|
pub(super) fn replay_move_interval_label(secs: f32) -> String {
|
||||||
|
format!("{secs:.2} s/move")
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user