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 | | macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set | | Windows | Self-hosted server | Full feature set |
| Linux | 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 ### Design Principles
@@ -322,6 +323,12 @@ struct FontResource(Handle<Font>);
struct BackgroundImageSet { struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 04 match selected_background setting 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 ### 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`) | | macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain | | Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ | | 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 | | 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`. 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] ## [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 ## [0.23.0] — 2026-05-12
+148 -26
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # 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_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer) solitaire_server/ # Axum backend (optional sync layer)
solitaire_wasm/ # WASM bindings for browser-side replay player
solitaire_app/ # Entry binary 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 `unwrap()`
* NO `panic!()` in runtime/game logic * NO `panic!()` in runtime/game logic
* All state transitions: * Core game state mutations MUST return:
```rust id="err_model" ```rust id="err_model"
Result<T, MoveError> 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 ## 2.4 Threading Rules
@@ -126,10 +131,15 @@ trait SyncProvider
## 3.1 ECS Design ## 3.1 ECS Design
* systems = single responsibility * systems = single responsibility
* communication = Events only * cross-system communication = Events (fire-and-forget triggers)
* shared state = Resources only * persistent shared state = Resources (polled every frame or on change)
* per-entity state = Components only * 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 ## 3.2 Game State Authority
@@ -149,11 +159,22 @@ Every player action MUST:
Keyboard shortcuts are: Keyboard shortcuts are:
→ optional accelerators only → 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 ## 3.4 Layout System
* recompute on `WindowResized` * 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 * no fixed resolution assumptions
--- ---
@@ -178,11 +199,18 @@ Includes:
## 4.2 Embedded Assets ## 4.2 Embedded Assets
Only audio: Embed via `include_bytes!()` only when ALL of the following are true:
```text id="audio_rule" * the asset is small (< 500 KB uncompressed)
include_bytes!() * 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 ## 5.2 Public API Rules
* prefer `Into<T>` over concrete types * 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: Claude must request confirmation before:
* adding dependencies * adding dependencies to `solitaire_core` or `solitaire_sync`
* modifying `solitaire_sync` (engine/server crates may add deps without confirmation)
* changing DB schema * modifying `solitaire_sync` types or the `SyncProvider` trait
* changing DB schema (migrations are append-only)
* introducing `unsafe` * 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: Must always be handled explicitly:
**All platforms**
* Bevy `Time` uses `f32` * Bevy `Time` uses `f32`
* `sqlx::migrate!()` path is crate-relative * `sqlx::migrate!()` path is crate-relative
* `dirs::data_dir()` may return `None` * `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 * blocking async calls in ECS
* insecure credential storage * insecure credential storage
* bypassing core logic layer * 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 | | 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**. 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: 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. After classification, Claude MUST include ONLY the relevant sections below.
--- ---
## 14.4 Context Map (CORE RULESET) ## 16.4 Context Map (CORE RULESET)
### feature ### feature
@@ -495,7 +617,7 @@ Include:
--- ---
## 14.5 Context Compression Rules ## 16.5 Context Compression Rules
Claude MUST obey: Claude MUST obey:
@@ -506,7 +628,7 @@ Claude MUST obey:
--- ---
## 14.6 Context Priority Order ## 16.6 Context Priority Order
When space is limited: 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: 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: 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: 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 * ARCHITECTURE.md = source of truth
* CLAUDE.md = execution constraints * CLAUDE.md = execution constraints
+2 -2
View File
@@ -12,7 +12,7 @@ You must follow CLAUDE_SPEC.md strictly.
Rules: Rules:
- Do not expand scope beyond what is defined - Do not expand scope beyond what is defined
- Do not refactor unrelated code - 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 - Prefer minimal, surgical changes
- Use existing patterns in the codebase - Use existing patterns in the codebase
- Return minimal diffs or changed functions only - Return minimal diffs or changed functions only
@@ -360,7 +360,7 @@ notes:
target: target:
"<what is slow>" "<what is slow>"
constraints:CLAUDE_WORKFLOW.md constraints:
- no behavior change - no behavior change
- no architecture change - no architecture change
- minimal code changes - minimal code changes
+5 -1
View File
@@ -41,6 +41,10 @@ solitaire_server:
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken] depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
role: "backend" role: "backend"
solitaire_wasm:
depends_on: [solitaire_core, wasm-bindgen, serde-wasm-bindgen]
role: "wasm_replay_player"
solitaire_app: solitaire_app:
depends_on: [solitaire_engine] depends_on: [solitaire_engine]
role: "entrypoint" role: "entrypoint"
@@ -180,7 +184,7 @@ threading:
plugins: plugins:
pattern: "feature_isolation" 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 confirmed `ClipboardManager.setPrimaryClip()` succeeds on Android 14.
Hook reverted. Production path requires Interaction::Pressed + non-null `share_url`. 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). 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 - [x] **`cargo apk build --lib` noisy stderr** — upstream cargo-apk bug; `--lib`
APK but pollutes CI output. Document `--lib` as canonical or upstream a fix. is the canonical command (CLAUDE.md §15.1, docs/ANDROID.md). No in-repo fix possible.
### 5. Feature completeness ### 5. Feature completeness
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in - [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 subcommand reads new password from stdin, bcrypt-hashes it, invalidates all
active sessions for the user. 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 ### 6. Testing gaps
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both - [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
`jwt_refresh_on_401_succeeds` (pull) and `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): READ FIRST (in order):
1. SESSION_HANDOFF.md — this file 1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.23.0] section has full Phase 8 detail 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 4. ARCHITECTURE.md — v1.3, fully up to date
5. docs/ui-mockups/ — design system + mockup library 5. docs/ui-mockups/ — design system + mockup library
6. docs/android/ — Android setup + build runbook 6. docs/android/ — Android setup + build runbook
@@ -151,5 +167,11 @@ OPEN WORK:
Phase 8 punch list is fully closed. All items verified complete. Phase 8 punch list is fully closed. All items verified complete.
Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking). 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 thread 'main' panicked: Bin is not compatible with Cdylib
``` ```
This happens AFTER the APK is on disk and signed. cargo-apk is This happens AFTER the APK is on disk and signed. cargo-apk tries to
trying to also wrap the desktop `[[bin]]` target. The APK is still also wrap the desktop `[[bin]]` target alongside the `[lib]`. The APK
valid. Work around with `--lib`: is valid — the panic is cosmetic. **Always use `--lib`**, which is the
canonical build command (see `CLAUDE.md §15.1`):
```bash ```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"]` Root cause: upstream cargo-apk bug — it does not skip `[[bin]]` targets
gate so cargo-apk skips the bin target on Android.) 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 > Last updated: 2026-04-25
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git > 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 ## Notes / decisions
* This list is screenshot-driven; expect more items to surface once * 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 // achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests. // `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// Run after GameMutation (so GameWonEvent is available), after // Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate // StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee). // (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, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button) .add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel) .add_systems(Update, scroll_achievements_panel)
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock // Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion. // `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that // 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 { let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ") (ACCENT_PRIMARY, TEXT_PRIMARY, "+ ")
} else { } else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ") (TEXT_DISABLED, TEXT_DISABLED, "- ")
}; };
let tooltip_text = tooltip_for_row(record.unlocked, def); 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 //! Asset-loading infrastructure for runtime SVG rasterisation and the
//! per-platform user-themes directory. //! per-platform user-themes directory.
//! //!
//! See `CARD_PLAN.md` for the full multi-phase implementation plan. //! Provides the SVG → `Image` loader and the `embedded://` / `themes://`
//! This module is the entry point for Phases 1 (SVG → `Image`) and 5 //! custom `AssetSource` implementations used by the theme system.
//! (user-themes directory). Phase 3 will extend it further with custom
//! `AssetSource` implementations for `embedded://` and `themes://`.
pub mod card_face_svg; pub mod card_face_svg;
pub mod icon_svg; pub mod icon_svg;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`. //! 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 //! the embedded default theme and as user-supplied themes. Bevy 0.18 has
//! no built-in SVG support, so this loader bridges `usvg` (parser) + //! no built-in SVG support, so this loader bridges `usvg` (parser) +
//! `resvg` (renderer) + `tiny-skia` (CPU pixmap) to produce textures //! `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 // 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 // the player can see how many cards remain before the next recycle. The
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so // existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
// the badge hides itself when the stock has zero cards — the two indicators // 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| { .with_children(|b| {
b.spawn(( b.spawn((
StockCountBadgeText, StockCountBadgeText,
Text2d::new(format!("·{count}")), Text2d::new(format!("{count}")),
text_font, text_font,
TextColor(STOCK_BADGE_FG), TextColor(STOCK_BADGE_FG),
// Slightly above the chip background so the digits aren't // 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) { if let Ok(badge_children) = children.get(entity) {
for child in badge_children.iter() { for child in badge_children.iter() {
if let Ok(mut text) = texts.get_mut(child) { if let Ok(mut text) = texts.get_mut(child) {
let new = format!("·{count}"); let new = format!("{count}");
if text.0 != new { if text.0 != new {
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. // 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 g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = 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); let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52); assert_eq!(positions.len(), 52);
} }
@@ -2010,7 +2010,7 @@ mod tests {
.collect(); .collect();
assert_eq!(waste_ids.len(), 3); 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); let positions = card_positions(&g, &layout);
// Filter rendered positions to only waste cards (by card ID). // Filter rendered positions to only waste cards (by card ID).
@@ -2041,7 +2041,7 @@ mod tests {
let waste_ids: std::collections::HashSet<u32> = let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect(); 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 positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions let mut waste_rendered: Vec<_> = positions
@@ -2084,7 +2084,7 @@ mod tests {
let waste_ids: std::collections::HashSet<u32> = let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect(); 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 positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions let mut waste_rendered: Vec<_> = positions
@@ -2107,7 +2107,7 @@ mod tests {
fn card_positions_tableau_cards_are_fanned_downward() { fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = 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); let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards). // Collect positions for Tableau(6) (should have 7 cards).
@@ -2419,7 +2419,7 @@ mod tests {
#[test] #[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); 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); let positions = card_positions(&g, &layout);
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top. // 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 // Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the // post-resize card width, so the in-place path is using the
// refreshed Layout. // 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; let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!( assert!(
(after - expected).abs() < 1e-3, (after - expected).abs() < 1e-3,
@@ -2811,7 +2811,7 @@ mod tests {
// First update inside `app()` runs the spawn path; run one more to // First update inside `app()` runs the spawn path; run one more to
// confirm the in-place update path is also stable. // confirm the in-place update path is also stable.
app.update(); 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)); assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
} }
@@ -2837,7 +2837,7 @@ mod tests {
// initial 24) and assert the badge text follows. // initial 24) and assert the badge text follows.
let mut app = app(); let mut app = app();
// Sanity-check the starting count. // 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>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) { if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
@@ -2845,7 +2845,7 @@ mod tests {
} }
} }
app.update(); 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)); assert!(matches!(stock_badge_visibility(&mut app), Visibility::Inherited));
} }
+2 -2
View File
@@ -604,7 +604,7 @@ mod tests {
use crate::layout::compute_layout; use crate::layout::compute_layout;
let game = GameState::new(42, DrawMode::DrawOne); 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. // A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout)); assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
} }
@@ -624,7 +624,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game)) .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()) .insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays); .add_systems(Update, update_drop_target_overlays);
app app
@@ -581,6 +581,12 @@ mod tests {
app.world_mut() app.world_mut()
.resource_mut::<DailyExpiryWarningShown>() .resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today); .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(); app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>(); let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor(); let mut cursor = events.get_cursor();
@@ -597,6 +603,9 @@ mod tests {
.resource_mut::<ProgressResource>() .resource_mut::<ProgressResource>()
.0 .0
.daily_challenge_last_completed = Some(today); .daily_challenge_last_completed = Some(today);
app.world_mut()
.resource_mut::<Messages<WarningToastEvent>>()
.clear();
app.update(); app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>(); let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor(); 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::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::window::AppLifecycle;
use chrono::Utc; use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode, GameState}; use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
@@ -200,6 +201,7 @@ impl Plugin for GamePlugin {
.add_message::<crate::events::AchievementUnlockedEvent>() .add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<AppLifecycle>()
.add_systems( .add_systems(
Update, Update,
poll_pending_new_game_seed.before(GameMutation), 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 /// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won), not paused, and the launch / /// the game is in progress (not won), not paused, and no blocking modal
/// mode-picker Home modal isn't covering the board. Stops counting on /// (Home picker or first-run onboarding) is covering the board. Stops
/// win so the final time reflects how long the player took to solve /// counting on win so the final time reflects how long the player took;
/// the deal; stops while the pause overlay is open; stops while Home /// stops while the pause overlay is open; stops while Home is up so the
/// is up so the timer doesn't tick under the picker before the player /// timer doesn't tick before the player commits to a deal; stops while
/// has actually committed to a deal. /// 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( fn tick_elapsed_time(
time: Res<Time>, time: Res<Time>,
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>, mut accumulator: Local<f32>,
mut skip_next_delta: Local<bool>,
paused: Option<Res<crate::pause_plugin::PausedResource>>, paused: Option<Res<crate::pause_plugin::PausedResource>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>, 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; return;
} }
let is_won = game.0.is_won; 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::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; 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(); 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) if let Some(p) = game.piles.get(&PileType::Waste)
&& let Some(top) = p.cards.last() && 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 card in &sources {
for slot in 0..4_u8 { for slot in 0..4_u8 {
@@ -1648,19 +1676,18 @@ mod tests {
#[test] #[test]
fn has_legal_moves_returns_true_for_fresh_game() { fn has_legal_moves_returns_true_for_fresh_game() {
// A fresh deal always contains at least one playable card — // A fresh deal always has a non-empty stock (24 cards), so drawing
// typically several tableau→tableau opportunities plus any Aces // is always a legal move regardless of the initial face-up tableau cards.
// that surface as a tableau column's bottom card.
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
assert!(has_legal_moves(&game), "fresh deal must contain at least one legal move"); assert!(has_legal_moves(&game), "fresh deal must contain at least one legal move");
} }
#[test] #[test]
fn has_legal_moves_returns_false_when_stock_only_holds_unplayable_cards() { fn has_legal_moves_returns_true_when_stock_has_cards_even_if_not_immediately_placeable() {
// Reproduces Quat's softlock: stock has cards but no card anywhere // Drawing from a non-empty stock is always a legal move in standard
// (stock or otherwise) can land on any pile. The previous heuristic // Klondike (unlimited recycles), even if the drawn card cannot be
// returned `true` here because stock was non-empty, so the game // immediately placed. The game is only stuck when both stock AND waste
// sat there forever instead of declaring softlock. // are exhausted and no visible card can be moved.
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne); let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 { 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::Tableau(i)).unwrap().cards.clear();
} }
game.piles.get_mut(&PileType::Waste).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(); let stock = game.piles.get_mut(&PileType::Stock).unwrap();
stock.cards.clear(); stock.cards.clear();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] { 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 }); 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(); // Stock is non-empty, so drawing is always a valid move.
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 });
}
assert!( assert!(
!has_legal_moves(&game), has_legal_moves(&game),
"stock cards with no legal landing should count as softlock", "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 { impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>() app.add_message::<HelpRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under // `MouseWheel` and `TouchInput` are emitted by Bevy's input
// `DefaultPlugins`; register it explicitly so the help-scroll // plugin under `DefaultPlugins`; register them explicitly so
// system also runs cleanly under `MinimalPlugins` in tests. // scroll systems run cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
.add_systems( .add_systems(
Update, 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], 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] = &[ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection { ControlSection {
title: "Gameplay", title: "Gameplay",
@@ -229,6 +265,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
row_gap: VAL_SPACE_2, row_gap: VAL_SPACE_2,
max_height: Val::Vh(70.0), max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(), overflow: Overflow::scroll_y(),
padding: UiRect::bottom(Val::Px(96.0)),
..default() ..default()
}, },
)) ))
+14 -22
View File
@@ -150,32 +150,24 @@ impl HomeMode {
/// readability rather than visual fidelity. Swap to `Image` nodes /// readability rather than visual fidelity. Swap to `Image` nodes
/// when art lands; the rest of the tile layout doesn't change. /// when art lands; the rest of the tile layout doesn't change.
/// ///
/// Picks are constrained to **card suits** (U+2660-2666) and basic /// Picks are constrained to **card suits** (U+2660-2666), the
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled /// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed
/// FiraMono-Medium face actually covers. Earlier choices in /// present in the bundled FiraMono-Medium face. The Geometric
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as /// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in
/// missing-glyph rectangles because FiraMono's coverage there is /// that range render as missing-glyph rectangles on Android.
/// minimal.
fn glyph(self) -> &'static str { fn glyph(self) -> &'static str {
match self { match self {
// Black club — card suit, the obvious solitaire mark. // Black club — card suit; the obvious solitaire mark.
HomeMode::Classic => "\u{2663}", HomeMode::Classic => "\u{2663}",
// Black diamond — Geometric Shapes; reads as the day's gem. // Black diamond suit — "gem of the day" reading.
HomeMode::Daily => "\u{25C6}", HomeMode::Daily => "\u{2666}",
// White circle — Geometric Shapes; reads as the Zen enso. // Black heart suit — calm/warm; conveys the Zen mood.
HomeMode::Zen => "\u{25CB}", HomeMode::Zen => "\u{2665}",
// Black up-pointing triangle — Geometric Shapes; reads as // Black spade suit — sharp/high-stakes; signals difficulty.
// a mountain / a step up in difficulty. HomeMode::Challenge => "\u{2660}",
HomeMode::Challenge => "\u{25B2}", // Rightwards arrow — "go / fast-forward" for the timed mode.
// 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.
HomeMode::TimeAttack => "\u{2192}", HomeMode::TimeAttack => "\u{2192}",
// Number sign — ASCII, universally available. Reads as // Number sign — ASCII; "a specific seed ID".
// "a specific number / seed ID".
HomeMode::PlayBySeed => "#", HomeMode::PlayBySeed => "#",
} }
} }
+238 -23
View File
@@ -36,10 +36,18 @@ use crate::events::{
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; 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; use crate::resources::GameStateResource;
#[cfg(target_os = "android")]
use crate::resources::DragState;
use crate::selection_plugin::SelectionState; use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable}; use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
/// Marker on the score text node. /// Marker on the score text node.
@@ -118,6 +126,37 @@ pub struct HudDrawCycle;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HudSelection; 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 /// Drives the score-readout pulse: scales the [`HudScore`] text from
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by /// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score /// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
@@ -281,6 +320,13 @@ pub struct MenuButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct MenuPopover; 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`]. /// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu. /// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -295,6 +341,8 @@ struct ModesPopoverBackdrop;
/// `Toggle*RequestEvent` the click handler fires. /// `Toggle*RequestEvent` the click handler fires.
#[derive(Component, Debug, Clone, Copy)] #[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption { pub enum MenuOption {
Help,
Modes,
Stats, Stats,
Achievements, Achievements,
Profile, Profile,
@@ -340,11 +388,20 @@ impl Plugin for HudPlugin {
.add_message::<WinStreakMilestoneEvent>() .add_message::<WinStreakMilestoneEvent>()
.init_resource::<PreviousScore>() .init_resource::<PreviousScore>()
.init_resource::<HudActionFade>() .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 // WindowResized is registered by table_plugin; re-register
// defensively so the HUD plugin works standalone in tests. // defensively so the HUD plugin works standalone in tests.
.add_message::<WindowResized>() .add_message::<WindowResized>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons)) .add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation)) .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, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation)) .add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud) .add_systems(Update, update_selection_hud)
@@ -376,9 +433,11 @@ impl Plugin for HudPlugin {
handle_modes_button, handle_modes_button,
handle_mode_option_click, handle_mode_option_click,
handle_modes_backdrop_click, handle_modes_backdrop_click,
close_modes_popover_on_escape,
handle_menu_button, handle_menu_button,
handle_menu_option_click, handle_menu_option_click,
handle_menu_backdrop_click, handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons, paint_action_buttons,
), ),
) )
@@ -388,6 +447,17 @@ impl Plugin for HudPlugin {
// `paint_action_buttons` would clobber the alpha back to 1.0 // `paint_action_buttons` would clobber the alpha back to 1.0
// mid-fade and produce a visible blip. // mid-fade and produce a visible blip.
.add_systems(Last, (update_action_fade, apply_action_fade).chain()); .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). // entities and rendered behind UI regardless).
ZIndex(Z_HUD - 1), ZIndex(Z_HUD - 1),
SafeAreaAnchoredTop { base_top: BASE_TOP }, SafeAreaAnchoredTop { base_top: BASE_TOP },
HudBand,
)); ));
} }
@@ -501,6 +572,7 @@ fn spawn_hud(
}, },
ZIndex(Z_HUD), ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 }, SafeAreaAnchoredTop { base_top: SPACE_2 },
HudColumn,
)) ))
.with_children(|hud| { .with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE); // Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
@@ -626,16 +698,11 @@ fn spawn_action_buttons(
mut commands: Commands, mut commands: Commands,
) { ) {
let top_inset = insets.as_deref().copied().unwrap_or_default().top; let top_inset = insets.as_deref().copied().unwrap_or_default().top;
#[cfg(not(target_os = "android"))]
let font = TextFont { let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY, font_size: TYPE_BODY,
..default() ..default()
}; };
// 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 // 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 // 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")] #[cfg(target_os = "android")]
let labels = ( let labels = (
/* menu */ "\u{2261}", // ≡ identical-to (hamburger look-alike, in FiraMono) /* menu */ "\u{2261}", // ≡ identical-to (Arrows/Math-Op, confirmed FiraMono)
/* undo */ "\u{2190}", // ← leftwards arrow (in FiraMono) /* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
/* pause */ "\u{2016}", // ‖ double vertical line (in FiraMono general-punct) /* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
/* help */ "?", /* help */ "?",
/* hint */ "\u{2192}", // → rightwards arrow (in FiraMono) /* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono)
/* modes */ "\u{25BE}", // small down-pointing triangle (in FiraMono) /* modes */ "\u{2193}", // downwards arrow (Arrows block, confirmed FiraMono)
// replaces ▾ (U+25BE) which is absent from FiraMono
/* new */ "+", /* new */ "+",
); );
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -686,17 +754,20 @@ fn spawn_action_buttons(
}, },
ZIndex(Z_HUD), ZIndex(Z_HUD),
SafeAreaAnchoredTop { base_top: SPACE_2 }, SafeAreaAnchoredTop { base_top: SPACE_2 },
HudActionBar,
)) ))
.with_children(|row| { .with_children(|row| {
// The trailing `order` argument feeds `Focusable { group: Hud, order }` // The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading 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); // Undo and Pause are the primary gameplay actions — full brightness.
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1); // Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2); spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3); 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, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4); spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5); spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, 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); 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, tooltip: &'static str,
font: &TextFont, font: &TextFont,
order: i32, order: i32,
text_color: Color,
) { ) {
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a // Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
// touch device — the button itself is the affordance — and they // 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), HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .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 { if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label — // Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without // keeps the keyboard accelerator discoverable without
@@ -944,6 +1016,7 @@ fn spawn_modes_popover(
commands commands
.spawn(( .spawn((
ModesPopover, ModesPopover,
HudPopoverOpen,
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
right: VAL_SPACE_3, right: VAL_SPACE_3,
@@ -1060,6 +1133,7 @@ fn handle_menu_button(
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>, interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>, popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>, backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
scrims: Query<(), With<ModalScrim>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
@@ -1074,7 +1148,7 @@ fn handle_menu_button(
for e in &backdrops { for e in &backdrops {
commands.entity(e).despawn(); commands.entity(e).despawn();
} }
} else { } else if scrims.is_empty() {
spawn_menu_popover(&mut commands, font_res.as_deref()); 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 // Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring // a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover. // 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, MenuOption::Stats,
"Stats", "Stats",
@@ -1124,6 +1208,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
commands commands
.spawn(( .spawn((
MenuPopover, MenuPopover,
HudPopoverOpen,
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
right: VAL_SPACE_3, right: VAL_SPACE_3,
@@ -1192,15 +1277,26 @@ fn handle_menu_option_click(
mut profile: MessageWriter<ToggleProfileRequestEvent>, mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>, mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>, mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let mut clicked_any = false; let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query { for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
continue; continue;
} }
clicked_any = true; clicked_any = true;
match option { match option {
MenuOption::Help => {
help.write(HelpRequestEvent);
}
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => { MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent); stats.write(ToggleStatsRequestEvent);
} }
@@ -1225,6 +1321,47 @@ fn handle_menu_option_click(
commands.entity(e).despawn(); 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 /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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. // tooltip, exact strings matching the approved microcopy.
let mut menu_q = app let mut menu_q = app
.world_mut() .world_mut()
@@ -2859,11 +3072,13 @@ mod tests {
.collect(); .collect();
assert_eq!( assert_eq!(
menu_tooltips.len(), menu_tooltips.len(),
5, 7,
"expected a tooltip on each of the 5 menu rows, got {}", "expected a tooltip on each of the 7 menu rows, got {}",
menu_tooltips.len() menu_tooltips.len()
); );
for expected in [ 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.", "Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.", "Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.", "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::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource; 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. /// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0; const DRAG_Z: f32 = 500.0;
@@ -103,10 +113,10 @@ impl Plugin for InputPlugin {
follow_drag, follow_drag,
end_drag.before(GameMutation), end_drag.before(GameMutation),
// Touch drag pipeline (parallel path through DragState). // Touch drag pipeline (parallel path through DragState).
touch_start_drag, touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
touch_follow_drag, touch_follow_drag,
handle_double_tap, // before touch_end_drag: reads drag state pre-clear 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(), .chain(),
) )
@@ -1632,7 +1642,7 @@ mod tests {
#[test] #[test]
fn find_draggable_picks_top_of_tableau() { fn find_draggable_picks_top_of_tableau() {
let game = GameState::new(42, DrawMode::DrawOne); 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. // In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6. // Its position: base.y + fan * 6.
@@ -1646,7 +1656,7 @@ mod tests {
#[test] #[test]
fn find_draggable_skips_face_down_cards() { fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawMode::DrawOne); 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 // 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 // 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 // face-up bottom card, clicking the visible card face missed the
// hit-test box and only the bottom strip of the card responded. // hit-test box and only the bottom strip of the card responded.
let game = GameState::new(42, DrawMode::DrawOne); 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 // 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 // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
@@ -1706,7 +1716,7 @@ mod tests {
face_up: true, 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 // 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 // (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 // 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, 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 // Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (id 201), with count = 1. // the visually top card (id 201), with count = 1.
let pos = card_position(&game, &layout, &PileType::Waste, 0); let pos = card_position(&game, &layout, &PileType::Waste, 0);
@@ -1751,7 +1761,7 @@ mod tests {
#[test] #[test]
fn find_drop_target_hits_empty_tableau_pile_marker() { fn find_drop_target_hits_empty_tableau_pile_marker() {
let game = GameState::new(42, DrawMode::DrawOne); 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. // Move all cards out of tableau 0 so its marker is the only drop area.
let mut game = game; let mut game = game;
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
@@ -1763,7 +1773,7 @@ mod tests {
#[test] #[test]
fn find_drop_target_returns_none_for_origin() { fn find_drop_target_returns_none_for_origin() {
let game = GameState::new(42, DrawMode::DrawOne); 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 pos = layout.pile_positions[&PileType::Tableau(3)];
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3)); let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
assert_eq!(target, None); assert_eq!(target, None);
@@ -1772,7 +1782,7 @@ mod tests {
#[test] #[test]
fn pile_drop_rect_extends_for_tableau_with_cards() { fn pile_drop_rect_extends_for_tableau_with_cards() {
let game = GameState::new(42, DrawMode::DrawOne); 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. // Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game); let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so // 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: 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 }); 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]; let waste_base = layout.pile_positions[&PileType::Waste];
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width. // 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; let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
@@ -1813,7 +1823,7 @@ mod tests {
#[test] #[test]
fn find_draggable_returns_none_for_click_on_empty_pile() { fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawMode::DrawOne); 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. // Clear tableau 0 so it's an empty slot.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let pos = layout.pile_positions[&PileType::Tableau(0)]; let pos = layout.pile_positions[&PileType::Tableau(0)];
@@ -1824,7 +1834,7 @@ mod tests {
#[test] #[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() { fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne); 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 [ for pile in [
PileType::Waste, PileType::Waste,
PileType::Foundation(2), PileType::Foundation(2),
@@ -2325,7 +2335,7 @@ mod tests {
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource( 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.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.add_systems(Update, handle_keyboard_hint); 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 /// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the /// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
/// waste/stock cluster from the foundations. /// 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 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. // Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
let card_width_width_based = window.x / 9.0; 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) // (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 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 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_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT; 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 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 tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13); let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
@@ -270,15 +271,15 @@ mod tests {
#[test] #[test]
fn layout_has_all_thirteen_piles() { 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(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)); 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)); assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0, 0.0, true));
} }
#[test] #[test]
fn card_size_scales_with_window_width() { fn card_size_scales_with_window_width() {
let small = compute_layout(Vec2::new(800.0, 600.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); 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.x > small.card_size.x);
assert!( assert!(
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5, (large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
@@ -289,9 +290,9 @@ mod tests {
#[test] #[test]
fn layout_below_minimum_clamps_to_minimum() { fn layout_below_minimum_clamps_to_minimum() {
// 200×200 sits below the floor on both axes, so the clamp pulls each // 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). // 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); 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); let at_min = compute_layout(MIN_WINDOW, 0.0, 0.0, true);
assert_eq!(below.card_size, at_min.card_size); assert_eq!(below.card_size, at_min.card_size);
} }
@@ -302,7 +303,7 @@ mod tests {
#[test] #[test]
fn phone_portrait_layout_fits_horizontally() { fn phone_portrait_layout_fits_horizontally() {
let window = Vec2::new(360.0, 800.0); 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_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0; let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions { for (pile, pos) in &layout.pile_positions {
@@ -323,7 +324,7 @@ mod tests {
#[test] #[test]
fn tableau_columns_are_sorted_left_to_right() { 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 { for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x; let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x; let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
@@ -333,7 +334,7 @@ mod tests {
#[test] #[test]
fn top_row_is_above_tableau_row() { 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 stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
assert!(stock_y > tableau_y); assert!(stock_y > tableau_y);
@@ -346,7 +347,7 @@ mod tests {
#[test] #[test]
fn top_row_clears_hud_band() { fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0); 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 stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0; let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
@@ -358,7 +359,7 @@ mod tests {
#[test] #[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { 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 stock_x = layout.pile_positions[&PileType::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x; let waste_x = layout.pile_positions[&PileType::Waste].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x; let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
@@ -369,7 +370,7 @@ mod tests {
#[test] #[test]
fn foundations_align_with_tableau_cols_3_to_6() { 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 { for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x; let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].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 // keep a worst-case 13-card column inside the window. (Most desktop
// monitors fall into this regime — e.g. 1280x800, 1920x1080.) // monitors fall into this regime — e.g. 1280x800, 1920x1080.)
let window = Vec2::new(2560.0, 1080.0); 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; let width_based = window.x / 9.0;
assert!( assert!(
layout.card_size.x < width_based, layout.card_size.x < width_based,
@@ -404,7 +405,7 @@ mod tests {
// the bottleneck and card_width matches the legacy window.x / 9 // the bottleneck and card_width matches the legacy window.x / 9
// derivation exactly. // derivation exactly.
let window = Vec2::new(900.0, 1600.0); let window = Vec2::new(900.0, 1600.0);
let layout = compute_layout(window, 0.0, 0.0); let layout = compute_layout(window, 0.0, 0.0, true);
let width_based = window.x / 9.0; let width_based = window.x / 9.0;
assert!( assert!(
(layout.card_size.x - width_based).abs() < 1e-3, (layout.card_size.x - width_based).abs() < 1e-3,
@@ -418,7 +419,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_default_resolution() { fn worst_case_tableau_fits_vertically_on_default_resolution() {
// Default app resolution (see solitaire_app/src/main.rs). // Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0); 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 tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card. // Bottom edge of the 13th fanned face-up card.
@@ -437,7 +438,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_full_hd() { fn worst_case_tableau_fits_vertically_on_full_hd() {
// The bug originally reproduced at 1920x1080. Lock in a regression test. // The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0); 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 tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; 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. /// the desktop minimum so the tableau fills the available vertical space.
#[test] #[test]
fn portrait_phone_expands_tableau_fan_frac() { fn portrait_phone_expands_tableau_fan_frac() {
let desktop = compute_layout(Vec2::new(1280.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); let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0, 0.0, true);
assert!( assert!(
phone.tableau_fan_frac > desktop.tableau_fan_frac, phone.tableau_fan_frac > desktop.tableau_fan_frac,
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})", "portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
@@ -468,7 +469,7 @@ mod tests {
#[test] #[test]
fn expanded_fan_fits_phone_viewport() { fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0); 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 tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0; let h_gap = layout.card_size.x / 4.0;
@@ -485,7 +486,7 @@ mod tests {
/// existing worst-case-fits-vertically invariant is preserved. /// existing worst-case-fits-vertically invariant is preserved.
#[test] #[test]
fn desktop_tableau_fan_frac_is_minimum() { 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!( assert!(
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3, (layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}", "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(1280.0, 800.0),
Vec2::new(1920.0, 1080.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_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0; let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions { for (pile, pos) in &layout.pile_positions {
@@ -526,8 +527,8 @@ mod tests {
#[test] #[test]
fn safe_area_top_shifts_top_row_downward() { fn safe_area_top_shifts_top_row_downward() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0); let with_inset = compute_layout(window, 32.0, 0.0, true);
let stock_no_inset = without.pile_positions[&PileType::Stock].y; let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y; let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
assert!( assert!(
@@ -548,8 +549,8 @@ mod tests {
#[test] #[test]
fn safe_area_top_does_not_affect_horizontal_layout() { fn safe_area_top_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0); let with_inset = compute_layout(window, 32.0, 0.0, true);
for pile in [ for pile in [
PileType::Stock, PileType::Stock,
PileType::Waste, PileType::Waste,
@@ -568,8 +569,8 @@ mod tests {
#[test] #[test]
fn safe_area_bottom_reduces_tableau_fan() { fn safe_area_bottom_reduces_tableau_fan() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0); let with_inset = compute_layout(window, 0.0, 48.0, true);
assert!( assert!(
with_inset.tableau_fan_frac <= without.tableau_fan_frac, with_inset.tableau_fan_frac <= without.tableau_fan_frac,
"safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}", "safe_area_bottom=48 must not increase tableau_fan_frac: {:.4} → {:.4}",
@@ -591,8 +592,8 @@ mod tests {
#[test] #[test]
fn safe_area_bottom_does_not_affect_horizontal_layout() { fn safe_area_bottom_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0); let with_inset = compute_layout(window, 0.0, 48.0, true);
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] { for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
assert!( assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, (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 help_plugin::{HelpPlugin, HelpScreen};
pub use home_plugin::{HomePlugin, HomeScreen}; pub use home_plugin::{HomePlugin, HomeScreen};
pub use hud_plugin::{ pub use hud_plugin::{
streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, MenuButton, streak_flourish_scale, ActionButton, HelpButton, HudAutoComplete, HudPlugin, HudVisibility,
MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton, PauseButton, MenuButton, MenuOption, MenuPopover, ModeOption, ModesButton, ModesPopover, NewGameButton,
StreakFlourish, UndoButton, PauseButton, StreakFlourish, UndoButton,
}; };
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use input_plugin::InputPlugin; 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::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
use crate::stats_plugin::StatsResource; use crate::stats_plugin::StatsResource;
use crate::hud_plugin::HudPopoverOpen;
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant, ModalScrim, spawn_modal_header, ButtonVariant, ModalScrim,
@@ -52,9 +53,13 @@ pub struct PausedResource(pub bool);
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct PauseScreen; 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)] #[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. /// Marker on the Resume primary button on the pause modal.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -117,12 +122,13 @@ impl Plugin for PausePlugin {
toggle_pause toggle_pause
.before(SelectionKeySet) .before(SelectionKeySet)
.before(handle_forfeit_keyboard), .before(handle_forfeit_keyboard),
handle_pause_draw_toggle, handle_pause_draw_buttons,
handle_pause_resume_button, handle_pause_resume_button,
handle_pause_forfeit_button, handle_pause_forfeit_button,
handle_forfeit_request, handle_forfeit_request,
handle_forfeit_confirm_buttons, handle_forfeit_confirm_buttons,
handle_forfeit_keyboard, handle_forfeit_keyboard,
auto_resume_on_overlay,
), ),
); );
} }
@@ -137,6 +143,7 @@ struct PauseModalQueries<'w, 's> {
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>, forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>, game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>, 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)] #[allow(clippy::too_many_arguments)]
@@ -162,6 +169,7 @@ fn toggle_pause(
forfeit_screens, forfeit_screens,
game_over_screens, game_over_screens,
other_modal_scrims, other_modal_scrims,
open_hud_popovers,
} = modal_queries; } = modal_queries;
// Either Esc or a click on the HUD "Pause" button (which fires // 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() { if !other_modal_scrims.is_empty() {
return; 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` // 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 // 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 // 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 /// Two explicit buttons replace the old cycle-toggle: pressing "Draw 1" sets
/// fires `SettingsChangedEvent`. The change takes effect on the next new game. /// `DrawOne`, pressing "Draw 3" sets `DrawThree`. Fires `SettingsChangedEvent`
fn handle_pause_draw_toggle( /// so the rest of the engine sees the update. Change takes effect next game.
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseDrawToggle>)>, 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>, paused: Res<PausedResource>,
settings: Option<ResMut<SettingsResource>>, settings: Option<ResMut<SettingsResource>>,
path: Option<Res<SettingsStoragePath>>, path: Option<Res<SettingsStoragePath>>,
@@ -254,22 +270,23 @@ fn handle_pause_draw_toggle(
if !paused.0 { if !paused.0 {
return; return;
} }
let Some(mut settings) = settings else { return }; let pressed_one = draw_one_q.iter().any(|i| *i == Interaction::Pressed);
for interaction in &interaction_query { let pressed_three = draw_three_q.iter().any(|i| *i == Interaction::Pressed);
if *interaction != Interaction::Pressed { if !pressed_one && !pressed_three {
continue; return;
}
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 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. /// 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 — /// Spawns the pause modal using the standard `ui_modal` scaffold —
/// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary /// uniform scrim, centred card, `Resume` primary + `Forfeit` tertiary
/// action buttons, plus a Draw Mode toggle row when settings are /// 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 /// Inline "Draw Mode [Draw 1] [Draw 3]" segmented control + caption.
/// applies to the next game. Spawned inside the modal body. ///
/// 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( fn spawn_draw_mode_row(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
mode: DrawMode, mode: DrawMode,
@@ -477,6 +517,10 @@ fn spawn_draw_mode_row(
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
let (one_variant, three_variant) = match mode {
DrawMode::DrawOne => (ButtonVariant::Secondary, ButtonVariant::Tertiary),
DrawMode::DrawThree => (ButtonVariant::Tertiary, ButtonVariant::Secondary),
};
parent parent
.spawn(Node { .spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
@@ -490,14 +534,8 @@ fn spawn_draw_mode_row(
label_font, label_font,
TextColor(TEXT_PRIMARY), TextColor(TEXT_PRIMARY),
)); ));
spawn_modal_button( spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
row, spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
PauseDrawToggle,
draw_mode_label(mode),
None,
ButtonVariant::Secondary,
font_res,
);
}); });
parent.spawn(( parent.spawn((
Text::new("Takes effect next game"), Text::new("Takes effect next game"),
@@ -781,9 +819,9 @@ mod tests {
// Set paused so handle_pause_draw_toggle acts. // Set paused so handle_pause_draw_toggle acts.
app.world_mut().resource_mut::<PausedResource>().0 = true; 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(( app.world_mut().spawn((
PauseDrawToggle, PauseDrawThreeButton,
Button, Button,
Interaction::Pressed, Interaction::Pressed,
)); ));
@@ -798,18 +836,16 @@ mod tests {
assert_eq!( assert_eq!(
*mode, *mode,
DrawMode::DrawThree, 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. // Pressing "Draw 1" while DrawThree is active should switch back.
{ app.world_mut().spawn((
let mut interaction_query = app PauseDrawOneButton,
.world_mut() Button,
.query::<&mut Interaction>(); Interaction::Pressed,
for mut i in interaction_query.iter_mut(app.world_mut()) { ));
*i = Interaction::Pressed;
}
}
app.update(); app.update();
let mode2 = &app let mode2 = &app
@@ -820,7 +856,7 @@ mod tests {
assert_eq!( assert_eq!(
*mode2, *mode2,
DrawMode::DrawOne, 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. // 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] #[test]
fn forfeit_confirm_y_also_closes_pause_modal() { fn forfeit_confirm_y_also_closes_pause_modal() {
let mut app = forfeit_app(); 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::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate}; 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 solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
@@ -323,7 +323,7 @@ fn spawn_profile_screen(
let records = &ar.0; let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count(); let unlocked_count = records.iter().filter(|r| r.unlocked).count();
body.spawn(( body.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")), Text::new(format!("{unlocked_count} / {} unlocked", ALL_ACHIEVEMENTS.len())),
font_row.clone(), font_row.clone(),
TextColor(ACCENT_PRIMARY), 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) { fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
app.insert_resource(GameStateResource(state)); 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); 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() { fn right_click_press_on_face_up_card_opens_radial() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); 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)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); 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() { fn right_click_release_over_destination_fires_move_request() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); 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)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); 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() { fn right_click_release_outside_any_destination_cancels() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); 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)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -1016,7 +1016,7 @@ mod tests {
fn escape_cancels_active_radial() { fn escape_cancels_active_radial() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); 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)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); 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() { fn right_click_on_face_down_card_does_not_open_radial() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); 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)]; let king_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos); install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
+26 -1
View File
@@ -19,6 +19,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::ui_modal::ModalScrim;
/// Pixel sizes of the system-reserved regions on each edge of the /// Pixel sizes of the system-reserved regions on each edge of the
/// surface. Zero on desktop. /// surface. Zero on desktop.
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)] #[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
@@ -54,7 +56,7 @@ pub struct SafeAreaInsetsPlugin;
impl Plugin for SafeAreaInsetsPlugin { impl Plugin for SafeAreaInsetsPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<SafeAreaInsets>() app.init_resource::<SafeAreaInsets>()
.add_systems(Update, apply_safe_area_anchors); .add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims));
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
app.add_systems(Update, android::refresh_insets); app.add_systems(Update, android::refresh_insets);
@@ -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")] #[cfg(target_os = "android")]
mod android { mod android {
use super::SafeAreaInsets; use super::SafeAreaInsets;
+3
View File
@@ -357,6 +357,7 @@ impl Plugin for SettingsPlugin {
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<bevy::input::mouse::MouseWheel>() .add_message::<bevy::input::mouse::MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// `WindowResized` / `WindowMoved` are real Bevy window events // `WindowResized` / `WindowMoved` are real Bevy window events
// and emitted by the windowing backend under `DefaultPlugins`, // and emitted by the windowing backend under `DefaultPlugins`,
// but we register them explicitly here so the geometry watcher // but we register them explicitly here so the geometry watcher
@@ -369,6 +370,7 @@ impl Plugin for SettingsPlugin {
handle_volume_keys, handle_volume_keys,
toggle_settings_screen, toggle_settings_screen,
scroll_settings_panel, scroll_settings_panel,
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
record_window_geometry_changes, record_window_geometry_changes,
persist_window_geometry_after_debounce, persist_window_geometry_after_debounce,
), ),
@@ -1479,6 +1481,7 @@ fn spawn_settings_panel(
row_gap: VAL_SPACE_3, row_gap: VAL_SPACE_3,
max_height: Val::Vh(60.0), max_height: Val::Vh(60.0),
overflow: Overflow::scroll_y(), overflow: Overflow::scroll_y(),
padding: UiRect::bottom(Val::Px(96.0)),
..default() ..default()
}, },
)) ))
+3 -1
View File
@@ -203,6 +203,7 @@ impl Plugin for StatsPlugin {
// `DefaultPlugins`; register it explicitly so the stats-scroll // `DefaultPlugins`; register it explicitly so the stats-scroll
// system also runs cleanly under `MinimalPlugins` in tests. // system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<bevy::input::touch::TouchInput>()
// record_abandoned must read `move_count` BEFORE handle_new_game // record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because // clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external // StatsUpdate (as a set) is ordered after GameMutation by external
@@ -238,7 +239,8 @@ impl Plugin for StatsPlugin {
) )
.chain(), .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(( body.spawn((
Text::new("Tab = next field"), Text::new("Tab = next field"),
make_font(font_res, TYPE_CAPTION), 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 solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem}; use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::safe_area::SafeAreaInsets; use crate::safe_area::SafeAreaInsets;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -149,6 +150,7 @@ fn setup_table(
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>, bg_images: Option<Res<BackgroundImageSet>>,
safe_area: Option<Res<SafeAreaInsets>>, 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 // 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 // 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 insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale; let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / 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); 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>, mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>, safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>, windows: Query<&Window>,
hud_vis: Option<Res<HudVisibility>>,
mut layout_res: Option<ResMut<LayoutResource>>, mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query< mut backgrounds: Query<
(&mut Sprite, &mut Transform), (&mut Sprite, &mut Transform),
@@ -329,7 +333,8 @@ fn on_window_resized(
let insets = safe_area.as_deref().copied().unwrap_or_default(); let insets = safe_area.as_deref().copied().unwrap_or_default();
let safe_area_top = insets.top / scale; let safe_area_top = insets.top / scale;
let safe_area_bottom = insets.bottom / 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() { if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone(); layout_res.0 = new_layout.clone();
+1 -1
View File
@@ -1,6 +1,6 @@
//! Theme zip-archive importer. //! 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 //! and install third-party themes as a single `.zip` containing a
//! `theme.ron` manifest at the archive root plus the 52 face SVGs and //! `theme.ron` manifest at the archive root plus the 52 face SVGs and
//! one back SVG referenced by that manifest. [`import_theme`] is the //! 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 //! with the currently-loaded theme so existing card-rendering systems
//! pick up the new artwork on the next state-changed tick. //! 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 //! The plugin's `set_theme` helper is the public API used by the
//! public API that the future picker UI (Phase 6) calls; for now it's //! Settings appearance picker and exposed for tests.
//! exposed for tests and for any embedder that wants to load an
//! alternative theme manually.
use std::collections::HashMap; use std::collections::HashMap;
+49
View File
@@ -48,6 +48,7 @@
//! ); //! );
//! ``` //! ```
use bevy::input::touch::{TouchInput, TouchPhase};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform}; use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::PrimaryWindow; 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. /// Spawns the bottom actions row — flex-row with primary right-aligned.
/// The closure populates the row's buttons via `spawn_modal_button`. /// 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) pub fn spawn_modal_actions<F>(parent: &mut ChildSpawnerCommands, build_buttons: F)
where where
F: FnOnce(&mut ChildSpawnerCommands), F: FnOnce(&mut ChildSpawnerCommands),
@@ -305,7 +310,9 @@ where
ModalActions, ModalActions,
Node { Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_3, column_gap: VAL_SPACE_3,
row_gap: VAL_SPACE_2,
justify_content: JustifyContent::FlexEnd, justify_content: JustifyContent::FlexEnd,
margin: UiRect::top(VAL_SPACE_2), margin: UiRect::top(VAL_SPACE_2),
..default() ..default()
@@ -365,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
Button, Button,
Node { Node {
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3), padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
min_height: Val::Px(48.0),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
column_gap: VAL_SPACE_2, 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 // Helpers + paint system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------