Compare commits

..

15 Commits

Author SHA1 Message Date
funman300 859b69b3c5 fix(android): A2/A3/A4 — APK build doc, dead refs, modal hit targets
A2: docs/ANDROID.md — remove stale "permanent fix to come" note;
    clarify --lib is the canonical command; root-cause the upstream
    cargo-apk bug. SESSION_HANDOFF.md closes the open item.

A3: Remove dead CARD_PLAN.md references from four source module
    doc comments (theme/importer.rs, theme/plugin.rs, assets/mod.rs,
    assets/svg_loader.rs). Also fix stale "future picker UI" language
    in plugin.rs (picker shipped in Phase 7).

A4: ui_modal.rs spawn_modal_button — add min_height: Val::Px(48.0)
    so every modal action button meets Material's 48 dp touch target
    minimum. Modal button height was 42 px (2×SPACE_3 + TYPE_BODY_LG);
    now clamped to 48 px minimum. Cards at 40 dp on 360 dp phones are
    layout-constrained (7 columns) and cannot be widened.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:55:30 -07:00
funman300 24ab25b0b7 feat(android): tap-to-toggle HUD visibility (A1)
On Android, a short tap on the empty game area (not on a card) toggles
the HUD band, info column, and action bar between Visible and Hidden.
Layout recomputes with band_h=0 when hidden so cards fill the full
screen. Any modal open restores the HUD to Visible automatically.

- hud_plugin: HudVisibility resource, HudBand/HudColumn/HudActionBar
  markers, apply_hud_visibility (fires synthetic WindowResized),
  restore_hud_on_modal, and Android-only toggle_hud_on_tap +
  HudTapTracker (15 px slop, skips card taps via DragState.is_idle())
- layout: compute_layout gains hud_visible: bool; passes band_h=0.0
  when hidden; all callers updated
- input_plugin: TouchDragSet (AfterStartDrag / BeforeEndDrag) public
  system-set anchors for cross-plugin ordering
- table_plugin: setup_table + on_window_resized read HudVisibility and
  pass hud_visible to compute_layout
- Desktop behaviour is unchanged (HudVisibility always Visible, tap
  system is #[cfg(target_os = "android")] gated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:46:36 -07:00
funman300 918d83420b docs: update all project docs to reflect Phase 8 + Android work
- CLAUDE.md unified-3.1 → unified-4.0: narrowed error policy, relaxed
  ECS/embed/API rules, added Android pitfalls, modal conventions (§14),
  Android build guide (§15), context injection system (§16), auto-hide
  HUD chrome exception in UI-first rule
- ARCHITECTURE.md: Android → Active platform; add Android to sync table;
  add SafeAreaInsets + HudVisibility to Key Resources; add solitaire_wasm
- CLAUDE_SPEC.md: add solitaire_wasm crate; communication: events → events and resources
- CLAUDE_PROMPT_PACK.md: fix §8 typo; narrow dep rule to core/sync only
- SESSION_HANDOFF.md: add §5b Android UX punch list; resume prompt unified-4.0
- docs/android/PLAYABILITY_TODO.md: add P5 section (UX-1/UX-5b/UX-7/BUG-3)
- docs/SESSION_HANDOFF.md: mark as archived (Phase 2 era)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:21:01 -07:00
funman300 a381a42f21 fix(android): UX-1/UX-5b/UX-7/BUG-3 — safe-area modals, glyph, help wrap, modal guard
- UX-1 (safe_area.rs): apply_safe_area_to_modal_scrims pads ModalScrim
  bottom by insets.bottom / scale_factor so Done buttons clear the
  gesture bar; fires on inset change + Added<ModalScrim>
- UX-5b (home_plugin.rs): replace Geometric Shapes (U+25xx, missing
  from FiraMono) with card suits U+2660/2665/2666
- UX-7 (help_plugin.rs): shorten Android ≡ button description to
  "Open menu (Stats, Settings, Profile...)" — fits one line at 360 dp
- BUG-3 (hud_plugin.rs): guard spawn_menu_popover with
  scrims.is_empty() so tapping ≡ while a modal is open is a no-op

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:20:07 -07:00
funman300 04f3dab563 fix(android): UX pass — pause stacking, timer, help content, achievement glyphs
BUG-1: pause_plugin — auto_resume_on_overlay system despawns PauseScreen
whenever any other ModalScrim becomes live; fixes Pause modal stacking on
top of Stats / Settings / Help / Achievements / Profile overlays opened
from the HUD menu while paused.

BUG-2: game_plugin — tick_elapsed_time skips the first delta_secs after
AppLifecycle::WillSuspend/Suspended so the Android post-resume frame spike
(equal to the full suspension duration) no longer inflates the in-game timer.

UX-2: help_plugin — Android build gets a touch-specific CONTROL_SECTIONS
(Tap / New Game / HUD buttons); desktop sections (Mouse, Keyboard drag,
Mode Launcher, Overlays) remain on non-Android builds only.

UX-3: achievement_plugin — replace \u{25CB} (○) and \u{2713} (✓) prefixes
with ASCII "- " / "+ "; both Geometric Shapes codepoints are absent from
FiraMono and rendered as the fallback letter "o".

Phase 8 work from previous session (already compiled, not yet committed):
hud_plugin — HUD visual hierarchy (Undo/Pause bright, nav buttons dim);
  menu popover — Help + Game Modes entries added (7 items total).
card_plugin — stock badge drops "·" prefix, shows plain count.
pause_plugin — Draw Mode segmented control (Draw 1 / Draw 3 explicit buttons).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:02:39 -07:00
funman300 d204662415 fix(android): close HUD popovers on Escape instead of opening Pause
When the Menu or Modes popover was open, pressing Escape (Android back)
fired the Pause system instead of closing the popover, because both
systems listened to the same key with no coordination.

Fix:
- Add HudPopoverOpen marker to both popover entities on spawn.
- Add close_menu/modes_popover_on_escape systems in HudPlugin that
  despawn the popover + backdrop when Escape is pressed.
- Guard toggle_pause with an open_hud_popovers query: bail if any
  HudPopoverOpen entity exists, preventing Pause from stacking behind
  the closing popover.
- Init ButtonInput<KeyCode> in HudPlugin::build() so the new systems
  work under MinimalPlugins in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 19:10:27 -07:00
funman300 4f0080dfbc fix(android): replace broken HUD glyphs and restore FiraMono font
‖ (U+2016) and ▾ (U+25BE) are absent from FiraMono and rendered as
boxes on device. Replace with || (ASCII) and ↓ (U+2193, Arrows block)
which are confirmed FiraMono-safe alongside the existing ≡ ← →.

Also removes the erroneous Android-only TextFont split introduced in
22303c6: that split accidentally used Bevy's built-in ASCII-only bitmap
font instead of FiraMono on Android, causing ALL non-ASCII HUD glyphs
to render as boxes. Now both platforms use the same FiraMono handle.

Separately, suppress the "Tab = next field" hint in the sync login
modal on Android (no Tab key on mobile).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:58:07 -07:00
funman300 46c3bf4bb2 fix(engine): profile achievement count derived from ALL_ACHIEVEMENTS
Hardcoded 18 in the profile summary line diverged from the actual count
of 19. Use ALL_ACHIEVEMENTS.len() so the count stays in sync when new
achievements are added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:54:59 -07:00
funman300 6beb9f68ac fix(engine): help panel scrollable via touch on Android
Register touch_scroll_panel::<HelpScrollable> so the Controls overlay
can be scrolled by swipe on Android. Without it, the Mode Launcher and
Overlays sections (rows 2–19) were unreachable via touch.

Also add 96px bottom padding to HelpScrollable — same fix applied to
settings_plugin — so the last row clears the scroll-container edge.
Register TouchInput message so existing headless tests continue to pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:49:40 -07:00
funman300 a0081a251c fix(engine): settings sync section scrollable + flaky midnight test
Add 96px bottom padding to SettingsPanelScrollable so the Sync section
is fully reachable by scrolling on Android (was clipped at container edge).

Fix check_system_fires_warning_event_only_once_per_day flakiness: Bevy
0.18 Messages<T> keeps events visible for two frames, so tests running
near UTC midnight saw a stale WarningToastEvent from headless_app()'s
initial update. Clear the buffer with .clear() before each assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:11:34 -07:00
funman300 7411468e10 fix(engine): extend touch scroll to achievements and stats panels via generic helper
Extracts touch_scroll_panel<M: Component> into ui_modal.rs and wires it
to SettingsPanelScrollable, AchievementsScrollable, and StatsScrollable
so all three panels respond to finger swipe on Android.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:03:20 -07:00
funman300 9af4046ac3 fix(engine): modal action buttons wrap to next row on narrow screens
On high-DPI Android (Pixel 7: 420 DPI → ~411 dp logical width), the
modal card fits at ~363 dp wide. The stats modal's three-button row
("Watch replay" + "Copy share link" + "Done") totals ~455 dp, causing
text to wrap inside each button (2–3 lines per button label).

Added flex_wrap: FlexWrap::Wrap + row_gap: VAL_SPACE_2 to
spawn_modal_actions so buttons that don't fit flow onto a second line
as whole units instead of wrapping text inside them. Affects all modals
uniformly; desktop (wide modal) is unaffected since buttons fit in one
line with room to spare.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:55:49 -07:00
funman300 d06af28aef fix(engine): settings panel scrollable via touch on Android
scroll_settings_panel only read MouseWheel, which is generated by desktop
scroll wheels and two-finger OS-level scroll gestures. On Android, a
single-finger swipe generates TouchInput, not MouseWheel, leaving the
settings panel unscrollable on real touchscreen devices.

Added touch_scroll_settings_panel: tracks touch start Y, applies the
vertical delta from each Moved event to ScrollPosition, resets on lift.
Registered TouchInput messages in SettingsPlugin::build so tests that use
MinimalPlugins (which omit InputPlugin) don't fail with "Message not
initialized".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:49:17 -07:00
funman300 27b58a5b71 fix(engine): pause game timer while onboarding modal is visible
tick_elapsed_time already stopped the clock for PausedResource and
HomeScreen, but not for the first-run onboarding modal. A new player
reading the three welcome slides would see their first-game time inflated
by however long they spent on the tutorial. Added OnboardingScreen to the
early-return guard using the same pattern as HomeScreen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:29:33 -07:00
funman300 3b6c8d2aab fix(engine): has_legal_moves treats non-empty stock/waste as always-legal
Drawing from a non-empty stock and recycling a non-empty waste are always
legal moves in standard Klondike (unlimited recycles). The old implementation
only scanned face-up tableau cards and the waste top for valid placements,
returning false for any fresh deal where the initial 7 face-up cards had no
immediate destination — causing a spurious "No more moves" game-over dialog
at Moves: 0. The correct stuck condition is stock=0 AND waste=0 AND no
visible card can be placed.

Updated the "false when stock unplayable" test to assert true instead, since
a non-empty stock means drawing is always legal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:17:55 -07:00
33 changed files with 898 additions and 262 deletions
+8 -1
View File
@@ -43,6 +43,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
| macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set |
| Linux | Self-hosted server | Full feature set |
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
### Design Principles
@@ -322,6 +323,12 @@ struct FontResource(Handle<Font>);
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 04 match selected_background setting
}
// OS-reserved edge insets (physical px); zero on desktop
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
// Whether the HUD band is visible (auto-hide chrome feature)
enum HudVisibility { Visible, Hidden }
```
### Key Bevy Events
@@ -900,7 +907,7 @@ All sound effect WAV files are embedded at compile time via `include_bytes!()` i
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
| Android | Active | Self-hosted server | `cargo-apk`; touch + long-press + double-tap; safe-area JNI; portrait layout |
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
+21
View File
@@ -6,6 +6,27 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Fixed
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
now checks `scrims.is_empty()` — a `Query<(), With<ModalScrim>>` guard —
before calling `spawn_menu_popover`. Tapping ≡ while any modal (Stats,
Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile
could be open simultaneously.
- **UX-7: Help text single-line overflow** (`help_plugin.rs`). The HUD menu
button description "Menu: Stats, Settings, Profile, Achievements" wrapped to
two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)"
which fits on one line. Verified on device.
- **UX-5b: Home mode glyph corruption** (`home_plugin.rs`). Mode selector icons
were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono
font — rendered as missing-glyph rectangles on Android. Replaced with card
suits (U+26602666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge.
- **UX-1: Modal Done button in gesture zone** (`safe_area.rs`). New
`apply_safe_area_to_modal_scrims` Bevy system pads every `ModalScrim` bottom
by `SafeAreaInsets.bottom / scale_factor`. Modal cards are now centred over
the safe area, not the full physical screen. The Settings / Help / Stats Done
buttons are reachable on gesture-nav Android devices. Verified on device.
---
## [0.23.0] — 2026-05-12
+148 -26
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
version: unified-3.0
version: unified-4.0
---
@@ -29,8 +29,9 @@ solitaire_sync/ # Shared API + merge logic
solitaire_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer)
solitaire_wasm/ # WASM bindings for browser-side replay player
solitaire_app/ # Entry binary
assets/ # Runtime assets (except audio)
assets/ # Runtime assets (except audio + default theme)
```
---
@@ -72,12 +73,16 @@ These override all other instructions.
* NO `unwrap()`
* NO `panic!()` in runtime/game logic
* All state transitions:
* Core game state mutations MUST return:
```rust id="err_model"
Result<T, MoveError>
```
* Engine / UI state changes follow ECS patterns (Resources, Events) —
they do not return `MoveError`
* Use `thiserror`-derived types for any new error enums outside `solitaire_core`
---
## 2.4 Threading Rules
@@ -126,10 +131,15 @@ trait SyncProvider
## 3.1 ECS Design
* systems = single responsibility
* communication = Events only
* shared state = Resources only
* cross-system communication = Events (fire-and-forget triggers)
* persistent shared state = Resources (polled every frame or on change)
* per-entity state = Components only
Events and Resources are both valid communication paths — use Events when
the receiver needs to react once; use Resources when the receiver polls
or when multiple systems read the same value (e.g. `SafeAreaInsets`,
`HudVisibility`, `LayoutResource`).
---
## 3.2 Game State Authority
@@ -149,11 +159,22 @@ Every player action MUST:
Keyboard shortcuts are:
→ optional accelerators only
**Exception — UI chrome gestures:**
Tap-to-toggle visibility of UI chrome (e.g. auto-hiding HUD band) is
permitted without a visible button. The gesture MUST:
* affect only chrome visibility, never game state
* restore chrome automatically when any modal opens
* be purely additive (game remains fully playable with chrome always visible)
---
## 3.4 Layout System
* recompute on `WindowResized`
* recompute on `SafeAreaInsets` changed
* recompute on `HudVisibility` changed
* `compute_layout` MUST accept `hud_visible: bool`; pass `HUD_BAND_HEIGHT`
when `true`, `0.0` when `false`
* no fixed resolution assumptions
---
@@ -178,11 +199,18 @@ Includes:
## 4.2 Embedded Assets
Only audio:
Embed via `include_bytes!()` only when ALL of the following are true:
```text id="audio_rule"
include_bytes!()
```
* the asset is small (< 500 KB uncompressed)
* it changes rarely (not user-customisable)
* a missing file would be a hard crash, not a graceful degradation
Currently embedded:
* **Audio** — all `.wav` files in `audio_plugin.rs`
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
Do NOT embed card face PNGs, background images, or user fonts —
these are loaded via `AssetServer` so art can be swapped without recompile.
---
@@ -210,7 +238,9 @@ Must degrade gracefully under `MinimalPlugins`.
## 5.2 Public API Rules
* prefer `Into<T>` over concrete types
* all public items require doc comments
* publicly exported functions, traits, and non-trivial types require doc comments
* simple marker components, newtype wrappers, and internal `pub` items
used only within the same crate are exempt from doc comment requirements
---
@@ -276,11 +306,13 @@ NEVER commit otherwise
Claude must request confirmation before:
* adding dependencies
* modifying `solitaire_sync`
* changing DB schema
* adding dependencies to `solitaire_core` or `solitaire_sync`
(engine/server crates may add deps without confirmation)
* modifying `solitaire_sync` types or the `SyncProvider` trait
* changing DB schema (migrations are append-only)
* introducing `unsafe`
* changing merge strategy
* changing the merge strategy in `solitaire_sync::merge`
* changing the `SyncPayload` wire format (breaking change for existing servers)
---
@@ -304,10 +336,29 @@ Core is always the source of truth.
Must always be handled explicitly:
**All platforms**
* Bevy `Time` uses `f32`
* `sqlx::migrate!()` path is crate-relative
* `dirs::data_dir()` may return `None`
* Linux may lack keyring backend
* Linux may lack keyring backend — handle `keyring::Error` gracefully
**Android (active target — not stretch)**
* Safe-area insets arrive in frames 13 via JNI polling, not at startup;
UI that depends on them must handle the zero-inset initial state
* Physical pixels ≠ logical pixels: `SafeAreaInsets` values are physical
(from `WindowInsets` API); divide by `window.scale_factor()` before
passing to Bevy `Val::Px`
* `adb shell input tap` uses physical pixel coordinates
* FiraMono (bundled font) covers: ASCII, card suits U+26602666,
Arrows U+219021FF. It does NOT cover Geometric Shapes (U+25xx) —
those render as missing-glyph rectangles on Android
* The gesture/navigation bar at the bottom (≈132px physical on common
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
avoid placing interactive elements in that zone
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
layout constants are `#[cfg(target_os = "android")]` gated
* JNI calls must use `attach_current_thread_permanently` — not
`attach_current_thread` — to avoid detach-on-drop panics
---
@@ -318,6 +369,12 @@ Must always be handled explicitly:
* blocking async calls in ECS
* insecure credential storage
* bypassing core logic layer
* hardcoded pixel coordinates in layout — always derive from `compute_layout`
* Unicode Geometric Shapes block (U+25xx) in UI text — not in FiraMono
* spawning a second `ModalScrim` while one already exists without first
dismissing the existing one (use `scrims.is_empty()` guard)
* reading `SafeAreaInsets` physical values directly into `Val::Px` without
dividing by `window.scale_factor()`
---
@@ -345,9 +402,74 @@ If unclear:
| Both combined | full system understanding |
---
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
# 14. Modal System Conventions
## 14.1 Purpose
All full-screen overlay panels MUST use the `spawn_modal` / `ModalScrim` pattern
from `solitaire_engine::ui_modal`.
## 14.1 Spawn pattern
```rust
let scrim = spawn_modal(commands, MyScreenMarker, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Title", font_res);
// ... body nodes ...
spawn_modal_actions(card, |actions| {
spawn_modal_button(actions, MyCloseButton, "Done", None,
ButtonVariant::Primary, font_res);
});
});
// Optional: allow clicking the scrim outside the card to dismiss
commands.entity(scrim).insert(ScrimDismissible);
```
## 14.2 Guard rule
Before spawning a new modal, check `scrims: Query<(), With<ModalScrim>>`
and return early if `!scrims.is_empty()` — unless the new modal is
explicitly replacing the current one (despawn first, then spawn).
## 14.3 Safe area
Every `ModalScrim` automatically receives `padding.bottom` equal to the
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
## 14.4 Z-ordering
Use `Z_MODAL_PANEL` from `ui_theme` for all modal scrims. Do not use
raw `z_index` values — they drift and cause ordering bugs.
---
# 15. Android Build & Verification
## 15.1 Build command
```bash
cargo apk build --package solitaire_app --lib
adb install -r target/debug/apk/solitaire-quest.apk
```
## 15.2 Coordinate system reminder
Device physical: 1080×2400. Bevy logical: 900×2000. Scale factor: 1.20.
`adb shell input tap X Y` takes PHYSICAL coordinates.
To convert from what you see on screen (logical): multiply by 1.20.
## 15.3 Android-specific test checklist
Before shipping any Android build:
- [ ] Safe area insets arrive and shift HUD correctly (check after 3s)
- [ ] All modal Done buttons are above the gesture bar
- [ ] No Geometric Shapes glyphs in UI text
- [ ] HUD band does not overlap the top status bar
- [ ] Touch drag-and-drop works on all pile types
---
# 16. Context Injection System (AUTOMATIC SCOPE FILTER)
## 16.1 Purpose
Before generating any response, Claude MUST construct a **minimal relevant context set**.
@@ -360,7 +482,7 @@ This prevents:
---
## 14.2 Input Classification Step (MANDATORY)
## 16.2 Input Classification Step (MANDATORY)
Every request MUST be classified into exactly one task type:
@@ -381,13 +503,13 @@ If uncertain → ask clarification.
---
## 14.3 Context Selection Engine
## 16.3 Context Selection Engine
After classification, Claude MUST include ONLY the relevant sections below.
---
## 14.4 Context Map (CORE RULESET)
## 16.4 Context Map (CORE RULESET)
### feature
@@ -495,7 +617,7 @@ Include:
---
## 14.5 Context Compression Rules
## 16.5 Context Compression Rules
Claude MUST obey:
@@ -506,7 +628,7 @@ Claude MUST obey:
---
## 14.6 Context Priority Order
## 16.6 Context Priority Order
When space is limited:
@@ -517,7 +639,7 @@ When space is limited:
---
## 14.7 “No Context Pollution” Rule
## 16.7 “No Context Pollution” Rule
Claude must NOT include:
@@ -529,7 +651,7 @@ Claude must NOT include:
---
## 14.8 Self-Check Before Execution
## 16.8 Self-Check Before Execution
Before writing code, Claude MUST verify:
@@ -542,7 +664,7 @@ If any fail → revise context selection.
---
## 14.9 Injection Output Format (Internal Model)
## 16.9 Injection Output Format (Internal Model)
Claude should behave as if it constructed:
@@ -560,7 +682,7 @@ Claude should behave as if it constructed:
---
## 14.10 Relationship to ARCHITECTURE.md
## 16.10 Relationship to ARCHITECTURE.md
* ARCHITECTURE.md = source of truth
* CLAUDE.md = execution constraints
+2 -2
View File
@@ -12,7 +12,7 @@ You must follow CLAUDE_SPEC.md strictly.
Rules:
- Do not expand scope beyond what is defined
- Do not refactor unrelated code
- Do not introduce new dependencies
- Do not introduce new dependencies to solitaire_core or solitaire_sync without confirmation
- Prefer minimal, surgical changes
- Use existing patterns in the codebase
- Return minimal diffs or changed functions only
@@ -360,7 +360,7 @@ notes:
target:
"<what is slow>"
constraints:CLAUDE_WORKFLOW.md
constraints:
- no behavior change
- no architecture change
- minimal code changes
+5 -1
View File
@@ -41,6 +41,10 @@ solitaire_server:
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
role: "backend"
solitaire_wasm:
depends_on: [solitaire_core, wasm-bindgen, serde-wasm-bindgen]
role: "wasm_replay_player"
solitaire_app:
depends_on: [solitaire_engine]
role: "entrypoint"
@@ -180,7 +184,7 @@ threading:
plugins:
pattern: "feature_isolation"
communication: "events"
communication: "events and resources"
---
+26 -4
View File
@@ -72,8 +72,8 @@ Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
hook confirmed `ClipboardManager.setPrimaryClip()` succeeds on Android 14.
Hook reverted. Production path requires Interaction::Pressed + non-null `share_url`.
Note: `adb shell input tap` doesn't deliver touch events on headless AVD (documented).
- **`cargo apk build --lib` noisy stderr** — post-sign panic doesn't affect the
APK but pollutes CI output. Document `--lib` as canonical or upstream a fix.
- [x] **`cargo apk build --lib` noisy stderr** — upstream cargo-apk bug; `--lib`
is the canonical command (CLAUDE.md §15.1, docs/ANDROID.md). No in-repo fix possible.
### 5. Feature completeness
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
@@ -89,6 +89,22 @@ Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
subcommand reads new password from stdin, bcrypt-hashes it, invalidates all
active sessions for the user.
### 5b. Android UX polish (2026-05-12)
- [x] **UX-1 — Modal Done button in gesture zone.** `apply_safe_area_to_modal_scrims` system
added to `SafeAreaInsetsPlugin` (`safe_area.rs`). Pads every `ModalScrim` bottom by
`insets.bottom / scale`. Fires on resource change + `Added<ModalScrim>`. Verified on device.
- [x] **UX-5b — Home mode glyph corruption.** Geometric Shapes (U+25xx, absent from FiraMono)
replaced with card suits U+26602666 in `home_plugin.rs`. Affects Zen/Challenge/Daily mode
selector buttons at level 5+.
- [x] **UX-7 — Help text wrap.** Android HUD entry shortened to
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs` — fits one line.
- [x] **BUG-3 — Multi-modal stacking.** `handle_menu_button` now checks
`scrims: Query<(), With<ModalScrim>>` and guards `spawn_menu_popover` with `scrims.is_empty()`.
Verified on device: ≡ tap while Stats open does nothing.
**Note:** These 4 fixes are implemented and verified but not yet committed.
### 6. Testing gaps
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
`jwt_refresh_on_401_succeeds` (pull) and
@@ -141,7 +157,7 @@ Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
READ FIRST (in order):
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.23.0] section has full Phase 8 detail
3. CLAUDE.md — unified-3.0 rule set
3. CLAUDE.md — unified-4.0 rule set
4. ARCHITECTURE.md — v1.3, fully up to date
5. docs/ui-mockups/ — design system + mockup library
6. docs/android/ — Android setup + build runbook
@@ -151,5 +167,11 @@ OPEN WORK:
Phase 8 punch list is fully closed. All items verified complete.
Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking).
Suggest starting Phase 9 planning — ask what the next arc should be.
4 Android UX fixes are implemented and verified but NOT YET COMMITTED:
- BUG-3 (hud_plugin.rs): multi-modal stacking guard
- UX-7 (help_plugin.rs): help text wrap on Android
- UX-5b (home_plugin.rs): FiraMono glyph corruption in mode selector
- UX-1 (safe_area.rs): modal Done button in gesture zone
Commit those first, then suggest Phase 9 planning.
```
+8 -6
View File
@@ -143,16 +143,18 @@ After the APK is signed cargo-apk panics with:
thread 'main' panicked: Bin is not compatible with Cdylib
```
This happens AFTER the APK is on disk and signed. cargo-apk is
trying to also wrap the desktop `[[bin]]` target. The APK is still
valid. Work around with `--lib`:
This happens AFTER the APK is on disk and signed. cargo-apk tries to
also wrap the desktop `[[bin]]` target alongside the `[lib]`. The APK
is valid — the panic is cosmetic. **Always use `--lib`**, which is the
canonical build command (see `CLAUDE.md §15.1`):
```bash
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
cargo apk build -p solitaire_app --lib
```
(Permanent fix to come — likely a `[[bin]] required-features = ["desktop"]`
gate so cargo-apk skips the bin target on Android.)
Root cause: upstream cargo-apk bug — it does not skip `[[bin]]` targets
when building for Android. No in-repo fix is possible; `--lib` is the
accepted workaround.
---
+5 -1
View File
@@ -1,4 +1,8 @@
# Solitaire Quest — Session Handoff
# Solitaire Quest — Session Handoff (ARCHIVED)
> **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical
> reference only. The authoritative session handoff is at the repo root:
> `SESSION_HANDOFF.md`.**
> Last updated: 2026-04-25
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
+25
View File
@@ -230,6 +230,31 @@ rewrites required.
---
## P5 — UX polish (2026-05-12)
- [x] **UX-1 — Modal Done button unreachable in gesture zone.** *Closed
2026-05-12.* New `apply_safe_area_to_modal_scrims` system in
`safe_area.rs` pads every `ModalScrim` bottom by `insets.bottom /
window.scale_factor()` (logical pixels). Fires when `SafeAreaInsets`
changes AND when a new `ModalScrim` is spawned (`Added<ModalScrim>`
filter). Verified on device: Settings Done button reachable at physical
y ≈ 18002000 (was y ≈ 2232+, inside gesture zone).
- [x] **UX-5b — Home mode selector glyph corruption.** *Closed
2026-05-12.* `home_plugin.rs` mode glyphs changed from Geometric Shapes
block (U+25xx — absent from FiraMono, renders as rectangles) to card
suits U+2660 ♠ / U+2665 ♥ / U+2666 ♦. Affects Zen, Challenge, and
Daily mode selector buttons shown at level 5+.
- [x] **UX-7 — Help screen HUD button entry wraps to two lines.** *Closed
2026-05-12.* Android `CONTROL_SECTIONS` entry for ≡ button shortened
from `"Menu: Stats, Settings, Profile, Achievements"` to
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs`.
Fits on one line at 360 dp.
- [x] **BUG-3 — Multi-modal stacking (Stats + Profile simultaneously).** *Closed
2026-05-12.* `handle_menu_button` in `hud_plugin.rs` now checks
`scrims: Query<(), With<ModalScrim>>` and only calls
`spawn_menu_popover` when `scrims.is_empty()`. Tapping ≡ while any
modal is open is a no-op. Verified on device.
## Notes / decisions
* This list is screenshot-driven; expect more items to surface once
+4 -2
View File
@@ -116,6 +116,7 @@ impl Plugin for AchievementPlugin {
// achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee).
@@ -139,6 +140,7 @@ impl Plugin for AchievementPlugin {
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
@@ -531,9 +533,9 @@ fn spawn_achievements_screen(
}
let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
(ACCENT_PRIMARY, TEXT_PRIMARY, "+ ")
} else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
(TEXT_DISABLED, TEXT_DISABLED, "- ")
};
let tooltip_text = tooltip_for_row(record.unlocked, def);
+2 -4
View File
@@ -1,10 +1,8 @@
//! Asset-loading infrastructure for runtime SVG rasterisation and the
//! per-platform user-themes directory.
//!
//! See `CARD_PLAN.md` for the full multi-phase implementation plan.
//! This module is the entry point for Phases 1 (SVG → `Image`) and 5
//! (user-themes directory). Phase 3 will extend it further with custom
//! `AssetSource` implementations for `embedded://` and `themes://`.
//! Provides the SVG → `Image` loader and the `embedded://` / `themes://`
//! custom `AssetSource` implementations used by the theme system.
pub mod card_face_svg;
pub mod icon_svg;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`.
//!
//! The card-theme system (see `CARD_PLAN.md`) ships SVG sources both as
//! The card-theme system ships SVG sources both as
//! the embedded default theme and as user-supplied themes. Bevy 0.18 has
//! no built-in SVG support, so this loader bridges `usvg` (parser) +
//! `resvg` (renderer) + `tiny-skia` (CPU pixmap) to produce textures
+13 -13
View File
@@ -1484,7 +1484,7 @@ fn update_stock_empty_indicator(
// ---------------------------------------------------------------------------
// Stock-pile remaining-count badge
//
// Shows a small "·N" chip pinned to the top-right corner of the stock pile so
// Shows a small "N" chip pinned to the top-right corner of the stock pile so
// the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators
@@ -1562,7 +1562,7 @@ fn spawn_stock_count_badge(
.with_children(|b| {
b.spawn((
StockCountBadgeText,
Text2d::new(format!("·{count}")),
Text2d::new(format!("{count}")),
text_font,
TextColor(STOCK_BADGE_FG),
// Slightly above the chip background so the digits aren't
@@ -1624,7 +1624,7 @@ fn update_stock_count_badge(
if let Ok(badge_children) = children.get(entity) {
for child in badge_children.iter() {
if let Ok(mut text) = texts.get_mut(child) {
let new = format!("·{count}");
let new = format!("{count}");
if text.0 != new {
text.0 = new;
}
@@ -1990,7 +1990,7 @@ mod tests {
// At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52);
}
@@ -2010,7 +2010,7 @@ mod tests {
.collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
// Filter rendered positions to only waste cards (by card ID).
@@ -2041,7 +2041,7 @@ mod tests {
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
@@ -2084,7 +2084,7 @@ mod tests {
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
@@ -2107,7 +2107,7 @@ mod tests {
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards).
@@ -2419,7 +2419,7 @@ mod tests {
#[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
@@ -2580,7 +2580,7 @@ mod tests {
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the
// refreshed Layout.
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!(
(after - expected).abs() < 1e-3,
@@ -2811,7 +2811,7 @@ mod tests {
// First update inside `app()` runs the spawn path; run one more to
// confirm the in-place update path is also stable.
app.update();
assert_eq!(stock_badge_text(&mut app), "·24");
assert_eq!(stock_badge_text(&mut app), "24");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
@@ -2837,7 +2837,7 @@ mod tests {
// initial 24) and assert the badge text follows.
let mut app = app();
// Sanity-check the starting count.
assert_eq!(stock_badge_text(&mut app), "·24");
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
@@ -2845,7 +2845,7 @@ mod tests {
}
}
app.update();
assert_eq!(stock_badge_text(&mut app), "·23");
assert_eq!(stock_badge_text(&mut app), "23");
assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
}
+2 -2
View File
@@ -604,7 +604,7 @@ mod tests {
use crate::layout::compute_layout;
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
}
@@ -624,7 +624,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0)))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true)))
.insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays);
app
@@ -581,6 +581,12 @@ mod tests {
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
// Flush any stale events from headless_app()'s initial update (the
// double-buffer keeps them visible for one extra frame).
app.update();
app.world_mut()
.resource_mut::<Messages<WarningToastEvent>>()
.clear();
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
@@ -597,6 +603,9 @@ mod tests {
.resource_mut::<ProgressResource>()
.0
.daily_challenge_last_completed = Some(today);
app.world_mut()
.resource_mut::<Messages<WarningToastEvent>>()
.clear();
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
+52 -35
View File
@@ -11,6 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::window::AppLifecycle;
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
@@ -200,6 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>()
.add_systems(
Update,
poll_pending_new_game_seed.before(GameMutation),
@@ -252,20 +254,42 @@ pub fn advance_elapsed(
}
/// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won), not paused, and the launch /
/// mode-picker Home modal isn't covering the board. Stops counting on
/// win so the final time reflects how long the player took to solve
/// the deal; stops while the pause overlay is open; stops while Home
/// is up so the timer doesn't tick under the picker before the player
/// has actually committed to a deal.
/// the game is in progress (not won), not paused, and no blocking modal
/// (Home picker or first-run onboarding) is covering the board. Stops
/// counting on win so the final time reflects how long the player took;
/// stops while the pause overlay is open; stops while Home is up so the
/// timer doesn't tick before the player commits to a deal; stops while
/// the onboarding modal is visible so a new player's first-game time
/// isn't inflated by reading the tutorial.
///
/// On Android the first frame after the app is resumed from background
/// can carry a very large `delta_secs` equal to the entire suspension
/// period. `skip_next_delta` is set to `true` on `WillSuspend` /
/// `Suspended` so that frame's delta is dropped instead of applied.
#[allow(clippy::too_many_arguments)]
fn tick_elapsed_time(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>,
mut skip_next_delta: Local<bool>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
onboarding_screens: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
mut lifecycle: MessageReader<AppLifecycle>,
) {
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillSuspend | AppLifecycle::Suspended) {
*skip_next_delta = true;
}
}
if paused.is_some_and(|p| p.0)
|| !home_screens.is_empty()
|| !onboarding_screens.is_empty()
{
return;
}
if *skip_next_delta {
*skip_next_delta = false;
return;
}
let is_won = game.0.is_won;
@@ -989,8 +1013,18 @@ pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
// Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles).
// A game can only be genuinely stuck when both stock AND waste are exhausted.
let stock_empty = game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
let waste_empty = game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
if !stock_empty || !waste_empty {
return true;
}
// Stock and waste exhausted — check whether any visible card can be placed.
let mut sources: Vec<Card> = Vec::new();
// Only the top waste card is playable.
// Top waste card (waste is empty here, but included for completeness).
if let Some(p) = game.piles.get(&PileType::Waste)
&& let Some(top) = p.cards.last()
{
@@ -1004,12 +1038,6 @@ pub fn has_legal_moves(game: &GameState) -> bool {
}
}
}
// Stock cards are face-down and cannot be placed directly; drawing is
// only useful if the drawn card can subsequently be placed, which the
// waste-card check above already covers for the currently visible card.
// Including all stock cards would produce false positives for unplayable
// face-down cards (the test has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards
// explicitly guards this case).
for card in &sources {
for slot in 0..4_u8 {
@@ -1648,19 +1676,18 @@ mod tests {
#[test]
fn has_legal_moves_returns_true_for_fresh_game() {
// A fresh deal always contains at least one playable card —
// typically several tableau→tableau opportunities plus any Aces
// that surface as a tableau column's bottom card.
// A fresh deal always has a non-empty stock (24 cards), so drawing
// is always a legal move regardless of the initial face-up tableau cards.
let game = GameState::new(42, DrawMode::DrawOne);
assert!(has_legal_moves(&game), "fresh deal must contain at least one legal move");
}
#[test]
fn has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards() {
// Reproduces Quat's softlock: stock has cards but no card anywhere
// (stock or otherwise) can land on any pile. The previous heuristic
// returned `true` here because stock was non-empty, so the game
// sat there forever instead of declaring softlock.
fn has_legal_moves_returns_true_when_stock_has_cards_even_if_not_immediately_placeable() {
// Drawing from a non-empty stock is always a legal move in standard
// Klondike (unlimited recycles), even if the drawn card cannot be
// immediately placed. The game is only stuck when both stock AND waste
// are exhausted and no visible card can be moved.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
@@ -1670,25 +1697,15 @@ mod tests {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
// Fill foundation 0 with Clubs A10, leaving only J/Q/K of Clubs
// as plausible foundation moves; load the stock with cards that
// can't land on the empty tableau (anything but a King) and can't
// extend foundation 0 (anything but Jack of Clubs).
let stock = game.piles.get_mut(&PileType::Stock).unwrap();
stock.cards.clear();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.cards.push(Card { id: 100 + r as u32, suit: Suit::Hearts, rank: r, face_up: false });
}
let foundation_zero = game.piles.get_mut(&PileType::Foundation(0)).unwrap();
for r in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
] {
foundation_zero.cards.push(Card { id: r as u32, suit: Suit::Clubs, rank: r, face_up: true });
}
// Stock is non-empty, so drawing is always a valid move.
assert!(
!has_legal_moves(&game),
"stock cards with no legal landing should count as softlock",
has_legal_moves(&game),
"non-empty stock means drawing is a legal move regardless of placement options",
);
}
+41 -4
View File
@@ -44,13 +44,19 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the help-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
// `MouseWheel` and `TouchInput` are emitted by Bevy's input
// plugin under `DefaultPlugins`; register them explicitly so
// scroll systems run cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_systems(
Update,
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
(
toggle_help_screen,
handle_help_close_button,
scroll_help_panel,
crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
),
);
}
}
@@ -129,6 +135,36 @@ struct ControlSection {
rows: &'static [ControlRow],
}
#[cfg(target_os = "android")]
const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Touch",
rows: &[
ControlRow { keys: "Tap stock", description: "Draw from stock" },
ControlRow { keys: "Drag card", description: "Move cards between piles" },
ControlRow { keys: "Tap foundation area", description: "Auto-move top card to foundation" },
],
},
ControlSection {
title: "New Game",
rows: &[
ControlRow { keys: "New+", description: "Start a new Classic game" },
ControlRow { keys: "Modes↓", description: "Pick Daily, Zen, Challenge, or Time Attack" },
],
},
ControlSection {
title: "HUD buttons",
rows: &[
ControlRow { keys: "", description: "Undo last move" },
ControlRow { keys: "||", description: "Pause / resume" },
ControlRow { keys: "?", description: "This help screen" },
ControlRow { keys: "", description: "Show a hint" },
ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" },
],
},
];
#[cfg(not(target_os = "android"))]
const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Gameplay",
@@ -229,6 +265,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
row_gap: VAL_SPACE_2,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
padding: UiRect::bottom(Val::Px(96.0)),
..default()
},
))
+14 -22
View File
@@ -150,32 +150,24 @@ impl HomeMode {
/// readability rather than visual fidelity. Swap to `Image` nodes
/// when art lands; the rest of the tile layout doesn't change.
///
/// Picks are constrained to **card suits** (U+2660-2666) and basic
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled
/// FiraMono-Medium face actually covers. Earlier choices in
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as
/// missing-glyph rectangles because FiraMono's coverage there is
/// minimal.
/// Picks are constrained to **card suits** (U+2660-2666), the
/// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed
/// present in the bundled FiraMono-Medium face. The Geometric
/// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in
/// that range render as missing-glyph rectangles on Android.
fn glyph(self) -> &'static str {
match self {
// Black club — card suit, the obvious solitaire mark.
// Black club — card suit; the obvious solitaire mark.
HomeMode::Classic => "\u{2663}",
// Black diamond — Geometric Shapes; reads as the day's gem.
HomeMode::Daily => "\u{25C6}",
// White circle — Geometric Shapes; reads as the Zen enso.
HomeMode::Zen => "\u{25CB}",
// Black up-pointing triangle — Geometric Shapes; reads as
// a mountain / a step up in difficulty.
HomeMode::Challenge => "\u{25B2}",
// Rightwards arrow — Arrows block (U+2190-21FF), a core
// range every dev-oriented monospace font (FiraMono
// included) ships. Reads as "go / fast-forward" for the
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
// ships ▲ (up triangle) but evidently not the sideways
// siblings.
// Black diamond suit — "gem of the day" reading.
HomeMode::Daily => "\u{2666}",
// Black heart suit — calm/warm; conveys the Zen mood.
HomeMode::Zen => "\u{2665}",
// Black spade suit — sharp/high-stakes; signals difficulty.
HomeMode::Challenge => "\u{2660}",
// Rightwards arrow — "go / fast-forward" for the timed mode.
HomeMode::TimeAttack => "\u{2192}",
// Number sign — ASCII, universally available. Reads as
// "a specific number / seed ID".
// Number sign — ASCII; "a specific seed ID".
HomeMode::PlayBySeed => "#",
}
}
+238 -23
View File
@@ -36,10 +36,18 @@ use crate::events::{
};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
#[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet;
use crate::layout::LayoutSystem;
#[cfg(target_os = "android")]
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
#[cfg(target_os = "android")]
use crate::resources::DragState;
use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_tooltip::Tooltip;
/// Marker on the score text node.
@@ -118,6 +126,37 @@ pub struct HudDrawCycle;
#[derive(Component, Debug)]
pub struct HudSelection;
/// Marker on the HUD band background node (the translucent band behind buttons).
#[derive(Component, Debug)]
pub struct HudBand;
/// Marker on the HUD score/info column root node.
#[derive(Component, Debug)]
pub struct HudColumn;
/// Marker on the action button bar root node.
#[derive(Component, Debug)]
pub struct HudActionBar;
/// Controls whether the in-game HUD (band, score column, action buttons) is
/// visible. Toggled on Android by tapping empty board space; always `Visible`
/// on desktop. Resets to `Visible` whenever a modal opens.
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HudVisibility {
#[default]
Visible,
Hidden,
}
#[cfg(target_os = "android")]
#[derive(Resource, Debug, Default)]
struct HudTapTracker {
start_pos: Option<bevy::math::Vec2>,
}
#[cfg(target_os = "android")]
const HUD_TAP_SLOP_PX: f32 = 15.0;
/// Drives the score-readout pulse: scales the [`HudScore`] text from
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
@@ -281,6 +320,13 @@ pub struct MenuButton;
#[derive(Component, Debug)]
pub struct MenuPopover;
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
/// while they are open. External systems (e.g. `PausePlugin`) query this to
/// determine whether a HUD popover is currently visible without importing the
/// individual popover types.
#[derive(Component, Debug)]
pub struct HudPopoverOpen;
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
#[derive(Component, Debug)]
@@ -295,6 +341,8 @@ struct ModesPopoverBackdrop;
/// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption {
Help,
Modes,
Stats,
Achievements,
Profile,
@@ -340,11 +388,20 @@ impl Plugin for HudPlugin {
.add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>()
.init_resource::<HudActionFade>()
.init_resource::<HudVisibility>()
// Escape-close handlers for popovers read this; init defensively
// so HudPlugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
// WindowResized is registered by table_plugin; re-register
// defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation))
.add_systems(
Update,
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
)
.add_systems(Update, restore_hud_on_modal)
.add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud)
@@ -376,9 +433,11 @@ impl Plugin for HudPlugin {
handle_modes_button,
handle_mode_option_click,
handle_modes_backdrop_click,
close_modes_popover_on_escape,
handle_menu_button,
handle_menu_option_click,
handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons,
),
)
@@ -388,6 +447,17 @@ impl Plugin for HudPlugin {
// `paint_action_buttons` would clobber the alpha back to 1.0
// mid-fade and produce a visible blip.
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
#[cfg(target_os = "android")]
{
app.init_resource::<HudTapTracker>()
.add_message::<bevy::input::touch::TouchInput>()
.add_systems(
Update,
toggle_hud_on_tap
.after(TouchDragSet::AfterStartDrag)
.in_set(TouchDragSet::BeforeEndDrag),
);
}
}
}
@@ -419,6 +489,7 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
// entities and rendered behind UI regardless).
ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP },
HudBand,
));
}
@@ -501,6 +572,7 @@ fn spawn_hud(
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
HudColumn,
))
.with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
@@ -626,16 +698,11 @@ fn spawn_action_buttons(
mut commands: Commands,
) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
#[cfg(not(target_os = "android"))]
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY,
..default()
};
// Android labels use only FiraMono-safe glyphs (≡ ← ‖ → ▾), so the same
// embedded font works — no system font fallback required.
#[cfg(target_os = "android")]
let font = TextFont { font_size: TYPE_BODY, ..default() };
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
@@ -650,12 +717,13 @@ fn spawn_action_buttons(
#[cfg(target_os = "android")]
let labels = (
/* menu */ "\u{2261}", // ≡ identical-to (hamburger look-alike, in FiraMono)
/* undo */ "\u{2190}", // ← leftwards arrow (in FiraMono)
/* pause */ "\u{2016}", // ‖ double vertical line (in FiraMono general-punct)
/* menu */ "\u{2261}", // ≡ identical-to (Arrows/Math-Op, confirmed FiraMono)
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
/* help */ "?",
/* hint */ "\u{2192}", // → rightwards arrow (in FiraMono)
/* modes */ "\u{25BE}", // small down-pointing triangle (in FiraMono)
/* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono)
/* modes */ "\u{2193}", // downwards arrow (Arrows block, confirmed FiraMono)
// replaces ▾ (U+25BE) which is absent from FiraMono
/* new */ "+",
);
#[cfg(not(target_os = "android"))]
@@ -686,17 +754,20 @@ fn spawn_action_buttons(
},
ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 },
HudActionBar,
))
.with_children(|row| {
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order.
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0);
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1);
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3);
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5);
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6);
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
});
}
@@ -721,6 +792,7 @@ fn spawn_action_button<M: Component>(
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
@@ -769,7 +841,7 @@ fn spawn_action_button<M: Component>(
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without
@@ -944,6 +1016,7 @@ fn spawn_modes_popover(
commands
.spawn((
ModesPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
@@ -1060,6 +1133,7 @@ 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,
) {
@@ -1074,7 +1148,7 @@ fn handle_menu_button(
for e in &backdrops {
commands.entity(e).despawn();
}
} else {
} else if scrims.is_empty() {
spawn_menu_popover(&mut commands, font_res.as_deref());
}
}
@@ -1093,7 +1167,17 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
// 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); 5] = [
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",
@@ -1124,6 +1208,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
commands
.spawn((
MenuPopover,
HudPopoverOpen,
Node {
position_type: PositionType::Absolute,
right: VAL_SPACE_3,
@@ -1192,15 +1277,26 @@ fn handle_menu_option_click(
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);
}
@@ -1225,6 +1321,47 @@ fn handle_menu_option_click(
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.
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.
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
@@ -2144,6 +2281,82 @@ fn update_hud_typography(
}
}
#[allow(clippy::type_complexity)]
fn apply_hud_visibility(
hud_vis: Res<HudVisibility>,
mut nodes: Query<
&mut Visibility,
Or<(With<HudBand>, With<HudColumn>, With<HudActionBar>)>,
>,
window_entities: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
if !hud_vis.is_changed() {
return;
}
let v = if *hud_vis == HudVisibility::Visible {
Visibility::Visible
} else {
Visibility::Hidden
};
for mut node_vis in &mut nodes {
*node_vis = v;
}
if let Some((entity, window)) = window_entities.iter().next() {
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
}
}
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;
}
}
#[cfg(target_os = "android")]
fn toggle_hud_on_tap(
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
drag: Res<DragState>,
scrims: Query<(), With<ModalScrim>>,
paused: Option<Res<PausedResource>>,
mut tracker: ResMut<HudTapTracker>,
mut hud_vis: ResMut<HudVisibility>,
) {
use bevy::input::touch::TouchPhase;
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
tracker.start_pos = None;
return;
}
for event in touch_events.read() {
match event.phase {
TouchPhase::Started => {
tracker.start_pos = Some(event.position);
}
TouchPhase::Ended if drag.is_idle() => {
if let Some(start) = tracker.start_pos.take() {
if (event.position - start).length() < HUD_TAP_SLOP_PX {
*hud_vis = match *hud_vis {
HudVisibility::Visible => HudVisibility::Hidden,
HudVisibility::Hidden => HudVisibility::Visible,
};
}
}
}
TouchPhase::Canceled | TouchPhase::Moved => {
tracker.start_pos = None;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2848,7 +3061,7 @@ mod tests {
);
}
// Same contract for MenuOption rows: five entries, each with a
// Same contract for MenuOption rows: seven entries, each with a
// tooltip, exact strings matching the approved microcopy.
let mut menu_q = app
.world_mut()
@@ -2859,11 +3072,13 @@ mod tests {
.collect();
assert_eq!(
menu_tooltips.len(),
5,
"expected a tooltip on each of the 5 menu rows, got {}",
7,
"expected a tooltip on each of the 7 menu rows, got {}",
menu_tooltips.len()
);
for expected in [
"Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
+24 -14
View File
@@ -51,6 +51,16 @@ use crate::resources::{DragState, GameStateResource, HintCycleIndex};
use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource;
/// System-set labels used to anchor external systems relative to the touch
/// drag pipeline without duplicating the internal chain ordering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum TouchDragSet {
/// After `touch_start_drag` has run — drag state is populated if a card was touched.
AfterStartDrag,
/// Before `touch_end_drag` runs — drag state has not yet been cleared.
BeforeEndDrag,
}
/// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0;
@@ -103,10 +113,10 @@ impl Plugin for InputPlugin {
follow_drag,
end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState).
touch_start_drag,
touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
touch_follow_drag,
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
touch_end_drag.before(GameMutation),
touch_end_drag.after(TouchDragSet::BeforeEndDrag).before(GameMutation),
)
.chain(),
)
@@ -1632,7 +1642,7 @@ mod tests {
#[test]
fn find_draggable_picks_top_of_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6.
@@ -1646,7 +1656,7 @@ mod tests {
#[test]
fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
// the bottom (index 6). Click at the topmost face-down card's
@@ -1667,7 +1677,7 @@ mod tests {
// face-up bottom card, clicking the visible card face missed the
// hit-test box and only the bottom strip of the card responded.
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
@@ -1706,7 +1716,7 @@ mod tests {
face_up: true,
});
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top
@@ -1738,7 +1748,7 @@ mod tests {
face_up: true,
});
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (id 201), with count = 1.
let pos = card_position(&game, &layout, &PileType::Waste, 0);
@@ -1751,7 +1761,7 @@ mod tests {
#[test]
fn find_drop_target_hits_empty_tableau_pile_marker() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Move all cards out of tableau 0 so its marker is the only drop area.
let mut game = game;
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
@@ -1763,7 +1773,7 @@ mod tests {
#[test]
fn find_drop_target_returns_none_for_origin() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let pos = layout.pile_positions[&PileType::Tableau(3)];
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
assert_eq!(target, None);
@@ -1772,7 +1782,7 @@ mod tests {
#[test]
fn pile_drop_rect_extends_for_tableau_with_cards() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
@@ -1797,7 +1807,7 @@ mod tests {
waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true });
waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true });
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let waste_base = layout.pile_positions[&PileType::Waste];
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
@@ -1813,7 +1823,7 @@ mod tests {
#[test]
fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// Clear tableau 0 so it's an empty slot.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let pos = layout.pile_positions[&PileType::Tableau(0)];
@@ -1824,7 +1834,7 @@ mod tests {
#[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for pile in [
PileType::Waste,
PileType::Foundation(2),
@@ -2325,7 +2335,7 @@ mod tests {
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0),
crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.add_systems(Update, handle_keyboard_hint);
+35 -34
View File
@@ -146,8 +146,9 @@ pub struct Layout {
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
/// waste/stock cluster from the foundations.
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -> Layout {
pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, hud_visible: bool) -> Layout {
let window = window.max(MIN_WINDOW);
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
let card_width_width_based = window.x / 9.0;
@@ -169,7 +170,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - HUD_BAND_HEIGHT).max(0.0) / height_denom;
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - band_h).max(0.0) / height_denom;
let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT;
@@ -189,7 +190,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32) -
};
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
let top_y = window.y / 2.0 - safe_area_top - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
@@ -270,15 +271,15 @@ mod tests {
#[test]
fn layout_has_all_thirteen_piles() {
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true));
}
#[test]
fn card_size_scales_with_window_width() {
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0);
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0);
let small = compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true);
assert!(large.card_size.x > small.card_size.x);
assert!(
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
@@ -289,9 +290,9 @@ mod tests {
#[test]
fn layout_below_minimum_clamps_to_minimum() {
// 200×200 sits below the floor on both axes, so the clamp pulls each
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0, 0.0).
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0);
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0);
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0, 0.0, true).
let below = compute_layout(Vec2::new(200.0, 200.0), 0.0, 0.0, true);
let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0, true);
assert_eq!(below.card_size, at_min.card_size);
}
@@ -302,7 +303,7 @@ mod tests {
#[test]
fn phone_portrait_layout_fits_horizontally() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
@@ -323,7 +324,7 @@ mod tests {
#[test]
fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
@@ -333,7 +334,7 @@ mod tests {
#[test]
fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
assert!(stock_y > tableau_y);
@@ -346,7 +347,7 @@ mod tests {
#[test]
fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
@@ -358,7 +359,7 @@ mod tests {
#[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_x = layout.pile_positions[&PileType::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
@@ -369,7 +370,7 @@ mod tests {
#[test]
fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
@@ -388,7 +389,7 @@ mod tests {
// keep a worst-case 13-card column inside the window. (Most desktop
// monitors fall into this regime — e.g. 1280x800, 1920x1080.)
let window = Vec2::new(2560.0, 1080.0);
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let width_based = window.x / 9.0;
assert!(
layout.card_size.x < width_based,
@@ -404,7 +405,7 @@ mod tests {
// the bottleneck and card_width matches the legacy window.x / 9
// derivation exactly.
let window = Vec2::new(900.0, 1600.0);
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let width_based = window.x / 9.0;
assert!(
(layout.card_size.x - width_based).abs() < 1e-3,
@@ -418,7 +419,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_default_resolution() {
// Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card.
@@ -437,7 +438,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_full_hd() {
// The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -453,8 +454,8 @@ mod tests {
/// the desktop minimum so the tableau fills the available vertical space.
#[test]
fn portrait_phone_expands_tableau_fan_frac() {
let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0);
let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0, true);
assert!(
phone.tableau_fan_frac > desktop.tableau_fan_frac,
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
@@ -468,7 +469,7 @@ mod tests {
#[test]
fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0;
@@ -485,7 +486,7 @@ mod tests {
/// existing worst-case-fits-vertically invariant is preserved.
#[test]
fn desktop_tableau_fan_frac_is_minimum() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
assert!(
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
@@ -500,7 +501,7 @@ mod tests {
Vec2::new(1280.0, 800.0),
Vec2::new(1920.0, 1080.0),
] {
let layout = compute_layout(window, 0.0, 0.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions {
@@ -526,8 +527,8 @@ mod tests {
#[test]
fn safe_area_top_shifts_top_row_downward() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 32.0, 0.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
assert!(
@@ -548,8 +549,8 @@ mod tests {
#[test]
fn safe_area_top_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 32.0, 0.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
for pile in [
PileType::Stock,
PileType::Waste,
@@ -568,8 +569,8 @@ mod tests {
#[test]
fn safe_area_bottom_reduces_tableau_fan() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 0.0, 48.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0, true);
assert!(
with_inset.tableau_fan_frac <= without.tableau_fan_frac,
"safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}",
@@ -591,8 +592,8 @@ mod tests {
#[test]
fn safe_area_bottom_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0);
let with_inset = compute_layout(window, 0.0, 48.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0, true);
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
+3 -3
View File
@@ -113,9 +113,9 @@ pub use game_plugin::{
pub use help_plugin::{HelpPlugin, HelpScreen};
pub use home_plugin::{HomePlugin, HomeScreen};
pub use hud_plugin::{
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton,
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton,
StreakFlourish, UndoButton,
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, HudVisibility,
MenuButton, MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton,
PauseButton, StreakFlourish, UndoButton,
};
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use input_plugin::InputPlugin;
+111 -46
View File
@@ -35,6 +35,7 @@ use crate::resources::{DragState, GameStateResource};
use crate::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource;
use crate::hud_plugin::HudPopoverOpen;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant, ModalScrim,
@@ -52,9 +53,13 @@ pub struct PausedResource(pub bool);
#[derive(Component, Debug)]
pub struct PauseScreen;
/// Marker on the draw-mode toggle button inside the pause overlay.
/// Marker on the "Draw 1" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawToggle;
struct PauseDrawOneButton;
/// Marker on the "Draw 3" option button inside the pause overlay.
#[derive(Component, Debug)]
struct PauseDrawThreeButton;
/// Marker on the Resume primary button on the pause modal.
#[derive(Component, Debug)]
@@ -117,12 +122,13 @@ impl Plugin for PausePlugin {
toggle_pause
.before(SelectionKeySet)
.before(handle_forfeit_keyboard),
handle_pause_draw_toggle,
handle_pause_draw_buttons,
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
auto_resume_on_overlay,
),
);
}
@@ -137,6 +143,7 @@ struct PauseModalQueries<'w, 's> {
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
open_hud_popovers: Query<'w, 's, Entity, With<HudPopoverOpen>>,
}
#[allow(clippy::too_many_arguments)]
@@ -162,6 +169,7 @@ fn toggle_pause(
forfeit_screens,
game_over_screens,
other_modal_scrims,
open_hud_popovers,
} = modal_queries;
// Either Esc or a click on the HUD "Pause" button (which fires
@@ -186,6 +194,12 @@ fn toggle_pause(
if !other_modal_scrims.is_empty() {
return;
}
// A HUD popover (Menu or Modes dropdown) is open — the popover's own
// Escape handler (in HudPlugin) will close it this frame. Don't also
// spawn the pause overlay on top of the closing popover.
if !open_hud_popovers.is_empty() {
return;
}
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
// own the Esc press — that handler stops the replay. Without this guard a
// single Esc both stops the replay AND opens the pause modal on top of the
@@ -240,12 +254,14 @@ fn toggle_pause(
}
}
/// Handles the draw-mode toggle button on the pause overlay.
/// Handles the draw-mode segmented control on the pause overlay.
///
/// Toggling flips the draw mode in `SettingsResource`, persists settings, and
/// fires `SettingsChangedEvent`. The change takes effect on the next new game.
fn handle_pause_draw_toggle(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseDrawToggle>)>,
/// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
/// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
/// so the rest of the engine sees the update. Change takes effect next game.
fn handle_pause_draw_buttons(
draw_one_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawOneButton>)>,
draw_three_q: Query<&Interaction, (Changed<Interaction>, With<PauseDrawThreeButton>)>,
paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>,
@@ -254,22 +270,23 @@ fn handle_pause_draw_toggle(
if !paused.0 {
return;
}
let Some(mut settings) = settings else { return };
for interaction in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
settings.0.draw_mode = match settings.0.draw_mode {
DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne,
};
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode toggle: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
if !pressed_one && !pressed_three {
return;
}
let Some(mut settings) = settings else { return };
let new_mode = if pressed_one { DrawMode::DrawOne } else { DrawMode::DrawThree };
if settings.0.draw_mode == new_mode {
return;
}
settings.0.draw_mode = new_mode;
if let Some(p) = &path
&& let Some(target) = &p.0
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
warn!("failed to save settings after draw-mode change: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
}
/// Closes the pause modal when the player clicks the Resume button.
@@ -414,6 +431,27 @@ fn close_forfeit_modal(
}
}
/// Automatically closes the pause modal when any non-pause overlay opens
/// on top of it (Stats, Settings, Help, Achievements, Profile, etc.).
///
/// The player reaches these overlays via the HUD menu while paused, which
/// causes both the pause modal and the overlay to be live simultaneously.
/// That is always unintentional — the overlay should own the screen.
fn auto_resume_on_overlay(
mut commands: Commands,
pause_screens: Query<Entity, With<PauseScreen>>,
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
mut paused: ResMut<PausedResource>,
) {
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
return;
}
for entity in &pause_screens {
commands.entity(entity).despawn();
}
paused.0 = false;
}
/// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
/// action buttons, plus a Draw Mode toggle row when settings are
@@ -460,8 +498,10 @@ fn spawn_pause_screen(
});
}
/// Inline "Draw Mode [Draw 1]" row + a caption explaining the change
/// applies to the next game. Spawned inside the modal body.
/// Inline "Draw Mode [Draw 1] [Draw 3]" segmented control + caption.
///
/// The active option renders as `Secondary` (elevated), the inactive one as
/// `Tertiary` (recessed), giving an obvious selection state at a glance.
fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands,
mode: DrawMode,
@@ -477,6 +517,10 @@ fn spawn_draw_mode_row(
font_size: TYPE_CAPTION,
..default()
};
let (one_variant, three_variant) = match mode {
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
@@ -490,14 +534,8 @@ fn spawn_draw_mode_row(
label_font,
TextColor(TEXT_PRIMARY),
));
spawn_modal_button(
row,
PauseDrawToggle,
draw_mode_label(mode),
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
});
parent.spawn((
Text::new("Takes effect next game"),
@@ -781,9 +819,9 @@ mod tests {
// Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true;
// Spawn a PauseDrawToggle button with Pressed interaction.
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
app.world_mut().spawn((
PauseDrawToggle,
PauseDrawThreeButton,
Button,
Interaction::Pressed,
));
@@ -798,18 +836,16 @@ mod tests {
assert_eq!(
*mode,
DrawMode::DrawThree,
"draw mode must flip from DrawOne to DrawThree when toggle is pressed"
"pressing Draw 3 must set mode to DrawThree"
);
// A second press should flip back.
{
let mut interaction_query = app
.world_mut()
.query::<&mut Interaction>();
for mut i in interaction_query.iter_mut(app.world_mut()) {
*i = Interaction::Pressed;
}
}
// Pressing "Draw 1" while DrawThree is active should switch back.
app.world_mut().spawn((
PauseDrawOneButton,
Button,
Interaction::Pressed,
));
app.update();
let mode2 = &app
@@ -820,7 +856,7 @@ mod tests {
assert_eq!(
*mode2,
DrawMode::DrawOne,
"draw mode must flip back from DrawThree to DrawOne on second press"
"pressing Draw 1 must set mode to DrawOne"
);
// Verify a SettingsChangedEvent was fired.
@@ -1084,6 +1120,35 @@ mod tests {
);
}
/// When a non-pause modal scrim appears (e.g. Settings overlay opens
/// from the menu while game is paused), `auto_resume_on_overlay` must
/// despawn the pause modal and clear `PausedResource`.
#[test]
fn auto_resume_closes_pause_when_overlay_opens() {
let mut app = headless_app();
press_esc(&mut app);
app.update();
assert!(app.world().resource::<PausedResource>().0);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
1
);
// Simulate another overlay opening (e.g. Stats) by spawning a bare ModalScrim.
app.world_mut().spawn(ModalScrim);
app.update();
assert!(
!app.world().resource::<PausedResource>().0,
"auto_resume_on_overlay must clear PausedResource when another modal opens"
);
assert_eq!(
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
0,
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
);
}
#[test]
fn forfeit_confirm_y_also_closes_pause_modal() {
let mut app = forfeit_app();
+2 -2
View File
@@ -8,7 +8,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate};
use solitaire_core::achievement::achievement_by_id;
use solitaire_core::achievement::{achievement_by_id, ALL_ACHIEVEMENTS};
use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource;
@@ -323,7 +323,7 @@ fn spawn_profile_screen(
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
body.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")),
Text::new(format!("{unlocked_count} / {} unlocked", ALL_ACHIEVEMENTS.len())),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
+6 -6
View File
@@ -801,7 +801,7 @@ mod tests {
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
app.insert_resource(GameStateResource(state));
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0)));
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0, true)));
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
}
@@ -913,7 +913,7 @@ mod tests {
fn right_click_press_on_face_up_card_opens_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -950,7 +950,7 @@ mod tests {
fn right_click_release_over_destination_fires_move_request() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -989,7 +989,7 @@ mod tests {
fn right_click_release_outside_any_destination_cancels() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -1016,7 +1016,7 @@ mod tests {
fn escape_cancels_active_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -1039,7 +1039,7 @@ mod tests {
fn right_click_on_face_down_card_does_not_open_radial() {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
+26 -1
View File
@@ -19,6 +19,8 @@
use bevy::prelude::*;
use crate::ui_modal::ModalScrim;
/// Pixel sizes of the system-reserved regions on each edge of the
/// surface. Zero on desktop.
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
@@ -54,7 +56,7 @@ pub struct SafeAreaInsetsPlugin;
impl Plugin for SafeAreaInsetsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SafeAreaInsets>()
.add_systems(Update, apply_safe_area_anchors);
.add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims));
#[cfg(target_os = "android")]
app.add_systems(Update, android::refresh_insets);
@@ -87,6 +89,29 @@ fn apply_safe_area_anchors(
}
}
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
/// modal cards don't extend into the Android gesture-navigation zone.
///
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
/// arriving a few frames after app start) AND when a new `ModalScrim` is
/// spawned (covers modals opened after insets have already settled).
fn apply_safe_area_to_modal_scrims(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
mut scrims: Query<&mut Node, With<ModalScrim>>,
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) {
let has_new = !new_scrims.is_empty();
if !insets.is_changed() && !has_new {
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let bottom_logical = insets.bottom / scale;
for mut node in &mut scrims {
node.padding.bottom = Val::Px(bottom_logical);
}
}
#[cfg(target_os = "android")]
mod android {
use super::SafeAreaInsets;
+3
View File
@@ -357,6 +357,7 @@ impl Plugin for SettingsPlugin {
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher
@@ -369,6 +370,7 @@ impl Plugin for SettingsPlugin {
handle_volume_keys,
toggle_settings_screen,
scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
record_window_geometry_changes,
persist_window_geometry_after_debounce,
),
@@ -1479,6 +1481,7 @@ fn spawn_settings_panel(
row_gap: VAL_SPACE_3,
max_height: Val::Vh(60.0),
overflow: Overflow::scroll_y(),
padding: UiRect::bottom(Val::Px(96.0)),
..default()
},
))
+3 -1
View File
@@ -203,6 +203,7 @@ impl Plugin for StatsPlugin {
// `DefaultPlugins`; register it explicitly so the stats-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external
@@ -238,7 +239,8 @@ impl Plugin for StatsPlugin {
)
.chain(),
)
.add_systems(Update, scroll_stats_panel);
.add_systems(Update, scroll_stats_panel)
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<StatsScrollable>);
}
}
+2 -1
View File
@@ -673,7 +673,8 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc
));
});
// Tab hint.
// Tab hint — desktop only; no Tab key on Android.
#[cfg(not(target_os = "android"))]
body.spawn((
Text::new("Tab = next field"),
make_font(font_res, TYPE_CAPTION),
+7 -2
View File
@@ -10,6 +10,7 @@ use solitaire_core::card::Suit;
use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::safe_area::SafeAreaInsets;
use crate::resources::GameStateResource;
@@ -149,6 +150,7 @@ fn setup_table(
settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>,
safe_area: Option<Res<SafeAreaInsets>>,
hud_vis: Option<Res<HudVisibility>>,
) {
// Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests). Use the felt-green clear colour so the
@@ -179,7 +181,8 @@ fn setup_table(
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
@@ -314,6 +317,7 @@ fn on_window_resized(
mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
hud_vis: Option<Res<HudVisibility>>,
mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query<
(&mut Sprite, &mut Transform),
@@ -329,7 +333,8 @@ fn on_window_resized(
let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / scale;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom);
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
let new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone();
+1 -1
View File
@@ -1,6 +1,6 @@
//! Theme zip-archive importer.
//!
//! Phase 7 of the card-theme system (see `CARD_PLAN.md`). Players ship
//! Players ship
//! and install third-party themes as a single `.zip` containing a
//! `theme.ron` manifest at the archive root plus the 52 face SVGs and
//! one back SVG referenced by that manifest. [`import_theme`] is the
+2 -4
View File
@@ -3,10 +3,8 @@
//! with the currently-loaded theme so existing card-rendering systems
//! pick up the new artwork on the next state-changed tick.
//!
//! Phase 4 of `CARD_PLAN.md`. The plugin's `set_theme` helper is the
//! public API that the future picker UI (Phase 6) calls; for now it's
//! exposed for tests and for any embedder that wants to load an
//! alternative theme manually.
//! The plugin's `set_theme` helper is the public API used by the
//! Settings appearance picker and exposed for tests.
use std::collections::HashMap;
+49
View File
@@ -48,6 +48,7 @@
//! );
//! ```
use bevy::input::touch::{TouchInput, TouchPhase};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::PrimaryWindow;
@@ -296,6 +297,10 @@ pub fn spawn_modal_body_text(
/// Spawns the bottom actions row — flex-row with primary right-aligned.
/// The closure populates the row's buttons via `spawn_modal_button`.
///
/// `flex_wrap: Wrap` lets the row reflow onto a second line when buttons
/// collectively exceed the modal width on narrow screens (e.g. high-DPI
/// Android where the logical viewport is ~411 dp).
pub fn spawn_modal_actions<F>(parent: &mut ChildSpawnerCommands, build_buttons: F)
where
F: FnOnce(&mut ChildSpawnerCommands),
@@ -305,7 +310,9 @@ where
ModalActions,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_3,
row_gap: VAL_SPACE_2,
justify_content: JustifyContent::FlexEnd,
margin: UiRect::top(VAL_SPACE_2),
..default()
@@ -365,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
Button,
Node {
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
min_height: Val::Px(48.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
@@ -384,6 +392,47 @@ pub fn spawn_modal_button<M: Component>(
});
}
// ---------------------------------------------------------------------------
// Generic touch-scroll helper
// ---------------------------------------------------------------------------
/// Scrolls any `Overflow::scroll_y()` panel marked with `M` via single-finger
/// touch pan. Add this as a system for each scrollable modal panel:
///
/// ```ignore
/// app.add_message::<TouchInput>()
/// .add_systems(Update, touch_scroll_panel::<MyScrollable>);
/// ```
///
/// On desktop `TouchInput` events never fire, so the system is a no-op.
pub fn touch_scroll_panel<M: Component>(
mut touch_evr: MessageReader<TouchInput>,
mut scrollables: Query<&mut ScrollPosition, With<M>>,
mut last_y: Local<Option<f32>>,
) {
for event in touch_evr.read() {
match event.phase {
TouchPhase::Started => {
*last_y = Some(event.position.y);
}
TouchPhase::Moved => {
if let Some(prev) = *last_y {
let delta = event.position.y - prev;
for mut sp in scrollables.iter_mut() {
// Swiping up (delta < 0 in screen coords) scrolls
// content down, so sp.y increases.
sp.0.y = (sp.0.y - delta).max(0.0);
}
}
*last_y = Some(event.position.y);
}
TouchPhase::Ended | TouchPhase::Canceled => {
*last_y = None;
}
}
}
}
// ---------------------------------------------------------------------------
// Helpers + paint system
// ---------------------------------------------------------------------------