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