Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f604f5df6 | |||
| 099b7a263f | |||
| a0776f79b1 | |||
| 4b484f899a | |||
| c2256582cd | |||
| b405e54bb0 | |||
| 5c97913868 | |||
| b2581024f3 |
@@ -6,6 +6,44 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.48.1] — 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Reduce Transparency.** Settings → Accessibility gains a toggle that
|
||||
replaces the see-through glass toolbar with a solid panel, so cards
|
||||
behind it can never reduce readability. High Contrast mode now also
|
||||
makes the glass markedly more opaque with a brighter outline. (#187)
|
||||
|
||||
### Changed
|
||||
|
||||
- **The toolbar gets out of your way.** While you're dragging a card,
|
||||
the floating toolbar slides off the bottom edge and returns when you
|
||||
drop — bottom-row piles are never hidden behind it. Instant (no
|
||||
animation) when Reduce Motion is on. (#187)
|
||||
|
||||
## [0.48.0] — 2026-07-15
|
||||
|
||||
### Changed
|
||||
|
||||
- **Floating glass tab bar** (touch). The bottom action bar is now a
|
||||
floating translucent pill — Undo, Draw, Hint, and Pause — with a
|
||||
separate round Menu button beside it, hovering above the bottom edge
|
||||
instead of docking to it. Draw stays expanded as the highlighted
|
||||
action; the other buttons reveal their label while held (instantly,
|
||||
with reduce-motion on). Same actions, same behaviour — new clothes.
|
||||
(#186)
|
||||
- **Consistent empty, loading, and error screens.** The theme store,
|
||||
leaderboard, and replay list now share one visual language for
|
||||
"nothing here yet", "still loading", and "something went wrong" —
|
||||
each with a next step instead of a dead end. (#184)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hotkeys during drags.** Pressing Undo, New Game, Draw, or a mode
|
||||
key mid-drag no longer fights the card you're holding — game-mutating
|
||||
hotkeys are ignored until the card is dropped. (#185)
|
||||
|
||||
## [0.47.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -490,6 +490,32 @@ impl GameState {
|
||||
self.session.stats().stats().recycle_count()
|
||||
}
|
||||
|
||||
/// Number of cards moved onto foundations this game, read from the
|
||||
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
|
||||
/// not rolled back on undo.
|
||||
pub fn move_to_foundation_count(&self) -> u32 {
|
||||
self.session.stats().stats().move_to_foundation_count()
|
||||
}
|
||||
|
||||
/// Number of stacks moved onto tableaus (from stock or another tableau)
|
||||
/// this game, read from the upstream session stats. Cumulative — not
|
||||
/// rolled back on undo.
|
||||
pub fn move_to_tableau_count(&self) -> u32 {
|
||||
self.session.stats().stats().move_to_tableau_count()
|
||||
}
|
||||
|
||||
/// Number of cards taken back off a foundation this game, read from the
|
||||
/// upstream session stats. Cumulative — not rolled back on undo.
|
||||
pub fn move_from_foundation_count(&self) -> u32 {
|
||||
self.session.stats().stats().move_from_foundation_count()
|
||||
}
|
||||
|
||||
/// Number of face-down cards revealed (flipped up) this game, read from
|
||||
/// the upstream session stats. Cumulative — not rolled back on undo.
|
||||
pub fn flip_up_count(&self) -> u32 {
|
||||
self.session.stats().stats().flip_up_bonus_count()
|
||||
}
|
||||
|
||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||
/// from the session's instruction history length.
|
||||
pub fn move_count(&self) -> u32 {
|
||||
|
||||
@@ -154,6 +154,14 @@ pub struct Settings {
|
||||
/// `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub reduce_motion_mode: bool,
|
||||
/// When `true`, replaces translucent "glass" chrome (the floating
|
||||
/// touch tab bar) with opaque fills so table content behind it can
|
||||
/// never reduce label/icon contrast. The accessibility counterpart
|
||||
/// of iOS "Reduce Transparency". Older `settings.json` files written
|
||||
/// before this field existed deserialize cleanly to `false` thanks
|
||||
/// to `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub reduce_transparency_mode: bool,
|
||||
/// Window size and screen position to restore on next launch. `None`
|
||||
/// means "use platform defaults" — set on first run, then populated
|
||||
/// as the player resizes / moves the window. Older `settings.json`
|
||||
@@ -451,6 +459,7 @@ impl Default for Settings {
|
||||
color_blind_mode: false,
|
||||
high_contrast_mode: false,
|
||||
reduce_motion_mode: false,
|
||||
reduce_transparency_mode: false,
|
||||
window_geometry: None,
|
||||
selected_theme_id: default_theme_id(),
|
||||
shown_achievement_onboarding: false,
|
||||
|
||||
@@ -603,6 +603,7 @@ impl Plugin for HudPlugin {
|
||||
// disjoint from the UiTextFx readout writers (tab labels
|
||||
// vs HUD readouts), which Bevy can't prove, so declare it.
|
||||
animate_tab_expansion.ambiguous_with(UiTextFx),
|
||||
duck_tab_bar_during_drag,
|
||||
)
|
||||
.chain()
|
||||
.in_set(HudButtons)
|
||||
|
||||
@@ -24,7 +24,10 @@ use crate::assets::hud_icon_svg::HudIcon;
|
||||
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
|
||||
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
|
||||
use bevy::text::{LineBreak, TextLayout};
|
||||
use bevy::ui::Overflow;
|
||||
use bevy::ui::{Overflow, UiTransform, Val2};
|
||||
|
||||
use crate::resources::DragState;
|
||||
use crate::safe_area::SafeAreaInsets;
|
||||
|
||||
/// Height of both the pill and the circular Menu button, in logical px.
|
||||
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
|
||||
@@ -99,6 +102,20 @@ pub struct TabLabelWrap {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct TabLabel;
|
||||
|
||||
/// Duck-away state on the bar container: while a card drag is committed
|
||||
/// the whole bar slides below the screen edge so it never occludes a
|
||||
/// drop target on the bottom tableau rows, then slides back on release.
|
||||
/// `progress` ∈ [0, 1]; 1 = fully off-screen.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct TabBarDuck {
|
||||
/// Current slide amount, animated by [`duck_tab_bar_during_drag`].
|
||||
pub progress: f32,
|
||||
}
|
||||
|
||||
/// Extra slide distance past the bar's own height so its drop shadow
|
||||
/// (16 px blur + 6 px offset) also clears the screen edge.
|
||||
const DUCK_SHADOW_ALLOWANCE_PX: f32 = 24.0;
|
||||
|
||||
/// Rasterised icon textures for the five tab-bar actions.
|
||||
pub(super) struct TabIcons {
|
||||
undo: Handle<Image>,
|
||||
@@ -179,6 +196,8 @@ pub(super) fn spawn_glass_tab_bar(
|
||||
base_bottom: TAB_BAR_MARGIN,
|
||||
},
|
||||
HudActionBar,
|
||||
TabBarDuck { progress: 0.0 },
|
||||
UiTransform::IDENTITY,
|
||||
))
|
||||
.with_children(|bar| {
|
||||
// The main pill: Undo · [Draw] · Hint · Pause.
|
||||
@@ -453,6 +472,46 @@ pub(super) fn animate_tab_expansion(
|
||||
}
|
||||
}
|
||||
|
||||
/// Slides the bar off the bottom edge while a card drag is committed and
|
||||
/// back once it ends. Effectively touch-only: the desktop docked bar
|
||||
/// never carries [`TabBarDuck`], so the query is empty there.
|
||||
///
|
||||
/// The slide animates `UiTransform::translation` (a render-time offset,
|
||||
/// +y = down) rather than `Node::bottom`, for two reasons: layout is
|
||||
/// never dirtied mid-drag (the whole point is not to cost frames while
|
||||
/// the player is dragging), and `Node::bottom` is owned by the safe-area
|
||||
/// anchor system — two writers on one field would fight.
|
||||
pub(super) fn duck_tab_bar_during_drag(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
drag: Option<Res<DragState>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
windows: Query<&Window>,
|
||||
mut bars: Query<(&mut TabBarDuck, &mut UiTransform), With<HudActionBar>>,
|
||||
) {
|
||||
let target = if drag.as_deref().is_some_and(|d| d.committed) {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||
let dt = time.delta_secs();
|
||||
// Physical-px inset → logical, same conversion the safe-area anchors
|
||||
// apply (CLAUDE.md §10): the bar sits `inset` above its base position,
|
||||
// so the slide must cover that extra travel to fully clear the edge.
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let inset_logical = insets.as_deref().map_or(0.0, |i| i.bottom / scale);
|
||||
let slide_px = TAB_BAR_CLEARANCE_PX + inset_logical + DUCK_SHADOW_ALLOWANCE_PX;
|
||||
for (mut duck, mut transform) in &mut bars {
|
||||
let next = step_expansion(duck.progress, target, dt, reduce_motion);
|
||||
if next == duck.progress && duck.progress == target {
|
||||
continue;
|
||||
}
|
||||
duck.progress = next;
|
||||
transform.translation = Val2::px(0.0, next * slide_px);
|
||||
}
|
||||
}
|
||||
|
||||
/// Idle / hover / pressed feedback for glass tab buttons — the glass
|
||||
/// counterpart of `paint_action_buttons`, using each button's own colour
|
||||
/// triple so the accent-filled active pill and the transparent icon
|
||||
|
||||
@@ -1014,6 +1014,54 @@ fn expansion_width_is_linear_in_progress() {
|
||||
assert_eq!(expansion_width(4, 16.0, 1.5), full);
|
||||
}
|
||||
|
||||
/// While a committed drag is active the bar must slide down (positive
|
||||
/// `UiTransform` y — off-screen) and return to identity once the drag
|
||||
/// ends. Manual time steps make the tween deterministic.
|
||||
#[test]
|
||||
fn tab_bar_ducks_during_committed_drag_and_returns() {
|
||||
use crate::resources::DragState;
|
||||
use bevy::ui::UiTransform;
|
||||
|
||||
let mut app = bar_only_app(true);
|
||||
set_manual_time_step(&mut app, 0.05);
|
||||
app.add_systems(Update, duck_tab_bar_during_drag);
|
||||
app.insert_resource(DragState {
|
||||
committed: true,
|
||||
..default()
|
||||
});
|
||||
|
||||
let translation_y_px = |app: &mut App| -> f32 {
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query_filtered::<&UiTransform, With<HudActionBar>>();
|
||||
let t = q.single(world).expect("bar carries UiTransform");
|
||||
match t.translation.y {
|
||||
Val::Px(px) => px,
|
||||
other => panic!("duck must write Val::Px, got {other:?}"),
|
||||
}
|
||||
};
|
||||
|
||||
// 0.18s slide at 0.05s/frame: fully ducked within 5 frames.
|
||||
app.update();
|
||||
app.update();
|
||||
let mid = translation_y_px(&mut app);
|
||||
assert!(mid > 0.0, "bar must start sliding down: {mid}");
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
let ducked = translation_y_px(&mut app);
|
||||
assert!(
|
||||
ducked > TAB_BAR_CLEARANCE_PX,
|
||||
"fully ducked bar must clear its own floating footprint: {ducked}"
|
||||
);
|
||||
|
||||
// Drag ends → bar slides back to identity.
|
||||
app.world_mut().resource_mut::<DragState>().committed = false;
|
||||
for _ in 0..8 {
|
||||
app.update();
|
||||
}
|
||||
assert_eq!(translation_y_px(&mut app), 0.0);
|
||||
}
|
||||
|
||||
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
|
||||
/// and snap instantly under reduce-motion.
|
||||
#[test]
|
||||
|
||||
@@ -353,6 +353,14 @@ pub(super) fn handle_settings_buttons(
|
||||
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleReduceTransparency => {
|
||||
settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_reduce_transparency_text`; the
|
||||
// glass chrome itself follows via `update_glass_surfaces`
|
||||
// next frame.
|
||||
}
|
||||
SettingsButton::ToggleTouchInputMode => {
|
||||
use solitaire_data::settings::TouchInputMode;
|
||||
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||
|
||||
@@ -135,6 +135,10 @@ struct HighContrastText;
|
||||
#[derive(Component, Debug)]
|
||||
struct ReduceMotionText;
|
||||
|
||||
/// Marks the `Text` node showing the current reduce-transparency state.
|
||||
#[derive(Component, Debug)]
|
||||
struct ReduceTransparencyText;
|
||||
|
||||
/// Marks the `Text` node showing the current touch input mode state.
|
||||
#[derive(Component, Debug)]
|
||||
struct TouchInputModeText;
|
||||
@@ -277,6 +281,10 @@ enum SettingsButton {
|
||||
/// non-essential motion (card-slide animations become instant
|
||||
/// snaps) per `design-system.md` §Accessibility (#3).
|
||||
ToggleReduceMotion,
|
||||
/// Toggle the [`Settings::reduce_transparency_mode`] flag — swaps
|
||||
/// translucent glass chrome (the floating touch tab bar) for opaque
|
||||
/// fills so table content never bleeds through UI text.
|
||||
ToggleReduceTransparency,
|
||||
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
|
||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||
/// a card/stack, second tap on a target pile moves it).
|
||||
@@ -361,8 +369,9 @@ impl SettingsButton {
|
||||
// run before continuing to the picker rows.
|
||||
SettingsButton::ToggleHighContrast => 61,
|
||||
SettingsButton::ToggleReduceMotion => 62,
|
||||
SettingsButton::ToggleTouchInputMode => 63,
|
||||
SettingsButton::CycleUiScale => 64,
|
||||
SettingsButton::ToggleReduceTransparency => 63,
|
||||
SettingsButton::ToggleTouchInputMode => 64,
|
||||
SettingsButton::CycleUiScale => 65,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
@@ -514,6 +523,16 @@ impl Plugin for SettingsPlugin {
|
||||
update_smart_default_size_text,
|
||||
),
|
||||
);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_reduce_transparency_text,
|
||||
// Guards internally on settings-change / newly added
|
||||
// glass — `Added<GlassSurface>` can't be expressed as
|
||||
// a resource run condition.
|
||||
update_glass_surfaces,
|
||||
),
|
||||
);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -317,6 +317,15 @@ fn spawn_accessibility_tab(
|
||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Reduce Transparency",
|
||||
ReduceTransparencyText,
|
||||
on_off_label(settings.reduce_transparency_mode),
|
||||
SettingsButton::ToggleReduceTransparency,
|
||||
"Replaces the see-through glass toolbar with a solid panel so cards behind it never reduce readability.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Touch Input Mode",
|
||||
|
||||
@@ -9,8 +9,10 @@ use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
use crate::theme::ThemeThumbnailCache;
|
||||
use crate::ui_glass::{GlassSurface, glass_decorations};
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||||
use bevy::ui::{BackgroundGradient, BorderGradient};
|
||||
|
||||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||
/// despawns it when it becomes `false`.
|
||||
@@ -330,6 +332,41 @@ pub(super) fn update_reduce_motion_text(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_reduce_transparency_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ReduceTransparencyText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = on_off_label(settings.0.reduce_transparency_mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retargets every [`GlassSurface`]'s sheen + rim gradients when the
|
||||
/// reduce-transparency or high-contrast toggles change. Runs on
|
||||
/// `SettingsResource` change and on newly added surfaces (so glass
|
||||
/// spawned after startup picks up an already-active toggle). Parallel to
|
||||
/// [`update_high_contrast_borders`]: same trigger, different components.
|
||||
pub(super) fn update_glass_surfaces(
|
||||
settings: Res<SettingsResource>,
|
||||
mut surfaces: Query<(&mut BackgroundGradient, &mut BorderGradient), With<GlassSurface>>,
|
||||
added: Query<(), Added<GlassSurface>>,
|
||||
) {
|
||||
if !settings.is_changed() && added.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (sheen, rim) = glass_decorations(
|
||||
settings.0.reduce_transparency_mode,
|
||||
settings.0.high_contrast_mode,
|
||||
);
|
||||
for (mut s, mut r) in &mut surfaces {
|
||||
*s = sheen.clone();
|
||||
*r = rim.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_touch_input_mode_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||||
|
||||
@@ -30,10 +30,19 @@ use bevy::ui::{
|
||||
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle,
|
||||
};
|
||||
|
||||
use crate::ui_theme::{BG_ELEVATED, BORDER_SUBTLE, BORDER_SUBTLE_HC};
|
||||
|
||||
/// Border width every glass surface must reserve in its `Node::border` so
|
||||
/// the rim gradient has a strip to paint into.
|
||||
pub const GLASS_BORDER_PX: f32 = 1.0;
|
||||
|
||||
/// Marker on every node that received [`glass_surface`]. The settings
|
||||
/// plugin's `update_glass_surfaces` retargets these when the player
|
||||
/// toggles reduce-transparency or high-contrast, swapping the gradients
|
||||
/// in place via [`glass_decorations`].
|
||||
#[derive(Component, Debug)]
|
||||
pub struct GlassSurface;
|
||||
|
||||
/// Base fill at the *bottom* of the glass sheet — near-black at ~62%
|
||||
/// opacity so the felt and cards remain visible through the bar while
|
||||
/// keeping icon/label contrast comfortable.
|
||||
@@ -58,6 +67,22 @@ const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16);
|
||||
/// Drop shadow under the floating surface.
|
||||
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
|
||||
|
||||
/// High-contrast fill — same glass, far less see-through, so text and
|
||||
/// icons keep contrast over any card art. Per `design-system.md`
|
||||
/// §Accessibility the HC toggle trades aesthetics for legibility.
|
||||
const GLASS_FILL_TOP_HC: Color = Color::srgba(0.20, 0.21, 0.23, 0.90);
|
||||
/// High-contrast fill at the bottom of the sheet.
|
||||
const GLASS_FILL_BOTTOM_HC: Color = Color::srgba(0.055, 0.055, 0.063, 0.88);
|
||||
|
||||
/// High-contrast rim at the top edge — matches the luminance of
|
||||
/// `BORDER_SUBTLE_HC` so the bar's outline reads as strongly as every
|
||||
/// other HC-boosted border.
|
||||
const GLASS_RIM_TOP_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.75);
|
||||
/// High-contrast rim at the midpoint.
|
||||
const GLASS_RIM_MID_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.35);
|
||||
/// High-contrast rim at the bottom edge.
|
||||
const GLASS_RIM_BOTTOM_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.50);
|
||||
|
||||
/// Visual components for a floating glass surface.
|
||||
///
|
||||
/// The caller owns the `Node` and must set two fields for the material to
|
||||
@@ -69,33 +94,11 @@ const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
|
||||
/// Everything here is fragment-shader work inside Bevy's stock UI pipeline,
|
||||
/// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
|
||||
pub fn glass_surface() -> impl Bundle {
|
||||
let (sheen, rim) = glass_decorations(false, false);
|
||||
(
|
||||
// Sheen: one top-to-bottom linear gradient carries both the fill and
|
||||
// the lighting so there is a single source of truth for the surface
|
||||
// colour (a separate `BackgroundColor` would just be painted over).
|
||||
BackgroundGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_FILL_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_FILL_BOTTOM, Val::Percent(60.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Specular rim: bright at the top edge, fading out through the
|
||||
// sides, with a faint return at the bottom.
|
||||
BorderGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_RIM_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_RIM_MID, Val::Percent(55.0)),
|
||||
ColorStop::new(GLASS_RIM_BOTTOM, Val::Percent(100.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
GlassSurface,
|
||||
sheen,
|
||||
rim,
|
||||
// Soft shadow underneath sells the "floating above the table" read.
|
||||
BoxShadow(vec![ShadowStyle {
|
||||
color: GLASS_SHADOW,
|
||||
@@ -107,9 +110,98 @@ pub fn glass_surface() -> impl Bundle {
|
||||
)
|
||||
}
|
||||
|
||||
/// The sheen + rim pair for the requested accessibility state. The
|
||||
/// settings plugin overwrites every [`GlassSurface`]'s components with
|
||||
/// these when the relevant toggles change.
|
||||
///
|
||||
/// - Default: translucent fill with a top-lit sheen and a specular rim.
|
||||
/// - `high_contrast`: same shape, near-opaque fill, and a rim boosted to
|
||||
/// `BORDER_SUBTLE_HC` luminance so the outline stays legible.
|
||||
/// - `reduce_transparency`: flat opaque `BG_ELEVATED` fill and a flat
|
||||
/// border — no see-through at all. Combined with `high_contrast` the
|
||||
/// flat border brightens to `BORDER_SUBTLE_HC`.
|
||||
pub fn glass_decorations(
|
||||
reduce_transparency: bool,
|
||||
high_contrast: bool,
|
||||
) -> (BackgroundGradient, BorderGradient) {
|
||||
if reduce_transparency {
|
||||
// "Gradients" with a single stop render as flat fills — reusing the
|
||||
// same component types means the settings toggle swaps values, not
|
||||
// component sets.
|
||||
let border = if high_contrast {
|
||||
BORDER_SUBTLE_HC
|
||||
} else {
|
||||
BORDER_SUBTLE
|
||||
};
|
||||
return (
|
||||
BackgroundGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![ColorStop::new(BG_ELEVATED, Val::Percent(0.0))],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
BorderGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![ColorStop::new(border, Val::Percent(0.0))],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
let (fill_top, fill_bottom, rim_top, rim_mid, rim_bottom) = if high_contrast {
|
||||
(
|
||||
GLASS_FILL_TOP_HC,
|
||||
GLASS_FILL_BOTTOM_HC,
|
||||
GLASS_RIM_TOP_HC,
|
||||
GLASS_RIM_MID_HC,
|
||||
GLASS_RIM_BOTTOM_HC,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
GLASS_FILL_TOP,
|
||||
GLASS_FILL_BOTTOM,
|
||||
GLASS_RIM_TOP,
|
||||
GLASS_RIM_MID,
|
||||
GLASS_RIM_BOTTOM,
|
||||
)
|
||||
};
|
||||
(
|
||||
// Sheen: one top-to-bottom linear gradient carries both the fill and
|
||||
// the lighting so there is a single source of truth for the surface
|
||||
// colour (a separate `BackgroundColor` would just be painted over).
|
||||
BackgroundGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(fill_top, Val::Percent(0.0)),
|
||||
ColorStop::new(fill_bottom, Val::Percent(60.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Specular rim: bright at the top edge, fading out through the
|
||||
// sides, with a faint return at the bottom.
|
||||
BorderGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(rim_top, Val::Percent(0.0)),
|
||||
ColorStop::new(rim_mid, Val::Percent(55.0)),
|
||||
ColorStop::new(rim_bottom, Val::Percent(100.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::ui::Gradient;
|
||||
|
||||
/// The bundle must insert all three visual components — a regression
|
||||
/// here (e.g. a refactor dropping the rim) would silently flatten the
|
||||
@@ -142,5 +234,48 @@ mod tests {
|
||||
fn rim_highlight_peaks_at_top() {
|
||||
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
|
||||
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha());
|
||||
assert!(GLASS_RIM_TOP_HC.alpha() > GLASS_RIM_BOTTOM_HC.alpha());
|
||||
assert!(GLASS_RIM_BOTTOM_HC.alpha() > GLASS_RIM_MID_HC.alpha());
|
||||
}
|
||||
|
||||
/// Reduce-transparency must produce fully opaque fills — the entire
|
||||
/// point of the toggle is that nothing shows through.
|
||||
#[test]
|
||||
fn reduce_transparency_is_fully_opaque() {
|
||||
for high_contrast in [false, true] {
|
||||
let (sheen, _) = glass_decorations(true, high_contrast);
|
||||
for gradient in &sheen.0 {
|
||||
let Gradient::Linear(linear) = gradient else {
|
||||
panic!("reduce-transparency sheen must stay linear");
|
||||
};
|
||||
for stop in &linear.stops {
|
||||
assert_eq!(
|
||||
stop.color.alpha(),
|
||||
1.0,
|
||||
"opaque variant leaked translucency (hc={high_contrast})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-contrast glass must be meaningfully less transparent than the
|
||||
/// default, and its rim meaningfully brighter — otherwise the toggle
|
||||
/// does nothing perceptible on this surface.
|
||||
#[test]
|
||||
fn high_contrast_boosts_fill_and_rim() {
|
||||
assert!(GLASS_FILL_TOP_HC.alpha() >= GLASS_FILL_TOP.alpha() + 0.15);
|
||||
assert!(GLASS_FILL_BOTTOM_HC.alpha() >= GLASS_FILL_BOTTOM.alpha() + 0.15);
|
||||
assert!(GLASS_RIM_TOP_HC.alpha() >= GLASS_RIM_TOP.alpha() + 0.25);
|
||||
}
|
||||
|
||||
/// `glass_surface()` must carry the marker the settings applier
|
||||
/// retargets — without it the accessibility toggles silently skip
|
||||
/// the bar.
|
||||
#[test]
|
||||
fn glass_surface_carries_retarget_marker() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn(glass_surface()).id();
|
||||
assert!(world.get::<GlassSurface>(e).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! shake duration elapses.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::game_state::{GameMode, GameState};
|
||||
use solitaire_core::scoring::compute_time_bonus;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
|
||||
/// score-breakdown reveal can format the mode-multiplier row
|
||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||
pub mode: GameMode,
|
||||
/// Per-move-type recap of the winning game, e.g.
|
||||
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
|
||||
/// Built from the upstream session counters at win time; empty when
|
||||
/// every counter is zero (synthesised test wins).
|
||||
pub move_detail: String,
|
||||
}
|
||||
|
||||
/// Formats the per-move-type recap line for the win modal from the
|
||||
/// upstream session counters. Zero-valued components are omitted so the
|
||||
/// line stays short; returns an empty string when nothing was counted
|
||||
/// (only possible for synthesised test wins).
|
||||
fn build_move_detail(game: &GameState) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let foundation = game.move_to_foundation_count();
|
||||
if foundation > 0 {
|
||||
parts.push(format!("{foundation} to foundation"));
|
||||
}
|
||||
let tableau = game.move_to_tableau_count();
|
||||
if tableau > 0 {
|
||||
parts.push(format!("{tableau} tableau moves"));
|
||||
}
|
||||
let flips = game.flip_up_count();
|
||||
if flips > 0 {
|
||||
parts.push(format!("{flips} flips"));
|
||||
}
|
||||
let recycles = game.recycle_count();
|
||||
if recycles > 0 {
|
||||
parts.push(format!("{recycles} recycles"));
|
||||
}
|
||||
let returns = game.move_from_foundation_count();
|
||||
if returns > 0 {
|
||||
parts.push(format!("{returns} foundation returns"));
|
||||
}
|
||||
parts.join(" \u{00B7} ")
|
||||
}
|
||||
|
||||
/// Builds a human-readable XP breakdown string for the win modal.
|
||||
@@ -492,6 +526,7 @@ fn cache_win_data(
|
||||
pending.challenge_level = challenge_level;
|
||||
pending.undo_count = game.0.undo_count();
|
||||
pending.mode = game.0.mode;
|
||||
pending.move_detail = build_move_detail(&game.0);
|
||||
|
||||
if is_new_record {
|
||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||
@@ -913,6 +948,18 @@ fn spawn_overlay(
|
||||
));
|
||||
}
|
||||
|
||||
// Move-type recap (same quiet styling as the XP breakdown)
|
||||
if !pending.move_detail.is_empty() {
|
||||
card.spawn((
|
||||
Text::new(pending.move_detail.clone()),
|
||||
TextFont {
|
||||
font_size: 15.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
|
||||
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||
// excess is summarised with "...and N more".
|
||||
if !session.names.is_empty() {
|
||||
@@ -1288,6 +1335,49 @@ mod tests {
|
||||
assert_eq!(p.mode, GameMode::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_move_detail_fresh_game_is_empty() {
|
||||
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||
assert!(
|
||||
build_move_detail(&game).is_empty(),
|
||||
"no moves yet, so the recap line must be empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_move_detail_reports_played_move_types() {
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
// Drive a real deal forward so the upstream counters accumulate:
|
||||
// prefer foundation moves, then anything else, drawing as needed.
|
||||
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||
for _ in 0..80 {
|
||||
let instructions = game.possible_instructions();
|
||||
let next = instructions
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
|
||||
.or_else(|| instructions.into_iter().next());
|
||||
match next {
|
||||
Some(i) => {
|
||||
let _ = game.apply_instruction(i);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let detail = build_move_detail(&game);
|
||||
assert!(
|
||||
detail.contains("to foundation"),
|
||||
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
|
||||
);
|
||||
assert!(
|
||||
!detail.contains("0 "),
|
||||
"zero-valued components must be omitted; got: {detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_xp_detail_slow_win_with_undo() {
|
||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||
|
||||
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
|
||||
}, STEP_INTERVAL_MS);
|
||||
});
|
||||
|
||||
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
||||
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
||||
/// renders once so the CSS transition animates each card to its previous
|
||||
/// position.
|
||||
/// Step the player back one move via the wasm-side seek (rewinds to the
|
||||
/// recorded deal and fast-forwards internally), then renders once so the
|
||||
/// CSS transition animates each card to its previous position.
|
||||
function stepBack() {
|
||||
if (!player || player.step_idx() === 0) return;
|
||||
if (playInterval) {
|
||||
@@ -326,12 +325,7 @@ function stepBack() {
|
||||
playInterval = null;
|
||||
btnPlay.textContent = "▶ Play";
|
||||
}
|
||||
const target = player.step_idx() - 1;
|
||||
player = new ReplayPlayer(replayJson);
|
||||
for (let i = 0; i < target; i++) {
|
||||
player.step();
|
||||
}
|
||||
render(player.state());
|
||||
render(player.seek(player.step_idx() - 1));
|
||||
btnPrev.disabled = player.step_idx() === 0;
|
||||
btnRestart.disabled = player.step_idx() === 0;
|
||||
btnStep.disabled = false;
|
||||
|
||||
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
|
||||
#[wasm_bindgen]
|
||||
pub struct ReplayPlayer {
|
||||
game: GameState,
|
||||
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
|
||||
/// can rewind without reparsing the replay JSON.
|
||||
initial: GameState,
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
step_idx: usize,
|
||||
}
|
||||
@@ -144,12 +147,30 @@ impl ReplayPlayer {
|
||||
// the current build maps seeds to deals.
|
||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||
Ok(Self {
|
||||
initial: game.clone(),
|
||||
game,
|
||||
moves,
|
||||
step_idx: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Jump to `step` (clamped to the move count): the board state after
|
||||
/// `step` moves have been applied. Rewinds by resetting to the stored
|
||||
/// initial deal, then fast-forwards — a few hundred instruction
|
||||
/// applications, microseconds in practice.
|
||||
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
|
||||
let target = step.min(self.moves.len());
|
||||
if target < self.step_idx {
|
||||
self.game = self.initial.clone();
|
||||
self.step_idx = 0;
|
||||
}
|
||||
while self.step_idx < target {
|
||||
self.game.apply_instruction(self.moves[self.step_idx])?;
|
||||
self.step_idx += 1;
|
||||
}
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||
if self.step_idx >= self.moves.len() {
|
||||
@@ -236,6 +257,25 @@ impl ReplayPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump directly to `step` moves applied (clamped to the move count)
|
||||
/// and return the snapshot there. Backwards seeks rewind to the
|
||||
/// recorded deal and fast-forward, so any position is O(replay length)
|
||||
/// at worst — no JSON reparse, no intermediate renders.
|
||||
///
|
||||
/// Throws `"replay_desync"` if a recorded move is illegal during the
|
||||
/// fast-forward (corrupt recording).
|
||||
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
|
||||
match self.seek_native(step) {
|
||||
Ok(snap) => {
|
||||
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
log_replay_move_error(&e);
|
||||
Err(JsValue::from_str("replay_desync"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of moves the replay contains.
|
||||
pub fn total_steps(&self) -> usize {
|
||||
self.moves.len()
|
||||
@@ -1062,6 +1102,57 @@ mod tests {
|
||||
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
||||
}
|
||||
|
||||
/// `seek` must land on exactly the state produced by stepping — both
|
||||
/// forwards (fast-forward from the current position) and backwards
|
||||
/// (rewind to the recorded deal, then fast-forward).
|
||||
#[test]
|
||||
fn seek_matches_stepping_in_both_directions() {
|
||||
let mut game = SolitaireGame {
|
||||
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||
};
|
||||
for _ in 0..24 {
|
||||
let legal_moves = game.legal_moves_native();
|
||||
if legal_moves.is_empty() {
|
||||
break;
|
||||
}
|
||||
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
||||
game.apply_legal_move_native(idx).expect("advance game");
|
||||
}
|
||||
let replay_json = game
|
||||
.replay_export_native(60, "2026-07-10")
|
||||
.expect("export replay");
|
||||
|
||||
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
|
||||
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
|
||||
let total = stepped.total_steps();
|
||||
assert!(total >= 4, "test needs a few moves, got {total}");
|
||||
|
||||
// Forward: step A to k, seek B to k, compare snapshots.
|
||||
let k = total / 2;
|
||||
for _ in 0..k {
|
||||
stepped.step_native().expect("step").expect("mid-replay");
|
||||
}
|
||||
let sought = seeker.seek_native(k).expect("seek forward");
|
||||
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
|
||||
|
||||
// Backward: seek B to k - 2 and compare against a fresh stepper.
|
||||
let back = k - 2;
|
||||
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
|
||||
for _ in 0..back {
|
||||
fresh.step_native().expect("step").expect("mid-replay");
|
||||
}
|
||||
let sought_back = seeker.seek_native(back).expect("seek backward");
|
||||
assert_eq!(
|
||||
sought_back,
|
||||
fresh.snapshot(),
|
||||
"backward seek diverged at {back}"
|
||||
);
|
||||
|
||||
// Clamping: past-the-end seeks stop at the final state.
|
||||
let end = seeker.seek_native(usize::MAX).expect("seek to end");
|
||||
assert_eq!(end.step_idx, total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_api_autonomous_seed_batch_smoke() {
|
||||
for seed in 0_u64..128_u64 {
|
||||
|
||||
Reference in New Issue
Block a user