Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a39e04329e | |||
| 6f27e775f2 | |||
| 358bbc7eb5 | |||
| 444f8d7e33 | |||
| a80547c514 | |||
| d87397b382 | |||
| 018b69285d | |||
| a4ad848c93 | |||
| ff8c00d2f4 | |||
| abf1312cf5 | |||
| 8b09c51271 | |||
| f61573513c | |||
| 757c35e4a0 | |||
| 113a933170 | |||
| 18bb1fa0be | |||
| 38b81a4004 | |||
| ae7af9adf4 | |||
| c0cd7c2c15 | |||
| ac002d8255 | |||
| 0fc1fa139e | |||
| 4f0c5bb808 | |||
| a6b22df666 | |||
| b402c01918 | |||
| be478acde7 | |||
| 379873765d | |||
| 713a292057 | |||
| 58c2dfd0a9 | |||
| 710555bd7e | |||
| 42a5f3bc3b | |||
| 19647b5209 | |||
| d8a255869c | |||
| f0336d784d | |||
| a218999243 | |||
| 55fa7df2bf | |||
| b2341c652b | |||
| 021c5d6ad8 | |||
| d1264a7797 | |||
| 7a5f03987d | |||
| 8eb316751d | |||
| d0c1db6c1d | |||
| f6e57b759e | |||
| c9adcaa5e4 | |||
| 0d5204b5ec | |||
| 15bb136c79 | |||
| 16a1139eab | |||
| 710cabcf0d | |||
| 5acd8e4cd0 | |||
| 652c9290b9 | |||
| beddbcf94d | |||
| 3c6b6e8c22 | |||
| dbe2addc30 | |||
| c286593415 | |||
| ddba5c0b26 | |||
| 5b37f35eb8 | |||
| d1e87765af | |||
| b3b53c4adf | |||
| ba76936aba | |||
| 59ba7ba4c3 | |||
| 1ca1efb3b6 | |||
| c5ad487256 |
@@ -0,0 +1,76 @@
|
|||||||
|
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
||||||
|
# locally, run on every master push and pull request. Until this workflow
|
||||||
|
# existed, nothing in CI ran the test suite at all — a direct push to
|
||||||
|
# master (or the web-wasm-rebuild bot commit) was entirely unguarded.
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'solitaire_app/**'
|
||||||
|
- 'solitaire_assetgen/**'
|
||||||
|
- 'solitaire_core/**'
|
||||||
|
- 'solitaire_data/**'
|
||||||
|
- 'solitaire_engine/**'
|
||||||
|
- 'solitaire_server/src/**'
|
||||||
|
- 'solitaire_server/tests/**'
|
||||||
|
- 'solitaire_server/migrations/**'
|
||||||
|
- 'solitaire_sync/**'
|
||||||
|
- 'solitaire_wasm/**'
|
||||||
|
- 'solitaire_web/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.cargo/**'
|
||||||
|
- '.sqlx/**'
|
||||||
|
- '.gitea/workflows/test.yml'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
||||||
|
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||||
|
# line-tables-only keeps file:line in panic backtraces while cutting the
|
||||||
|
# link's memory footprint enough to fit the runner.
|
||||||
|
env:
|
||||||
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust 1.95.0
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.95.0
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
||||||
|
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||||
|
- name: Install Bevy native dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||||
|
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||||
|
# same as the web-e2e workflow's server prebuild.
|
||||||
|
- name: Clippy (deny warnings)
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: 'true'
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: 'true'
|
||||||
|
run: cargo test --workspace
|
||||||
+77
-41
@@ -1,9 +1,11 @@
|
|||||||
# Ferrous Solitaire — Architecture Document
|
# Ferrous Solitaire — Architecture Document
|
||||||
|
|
||||||
> **Version:** 1.3
|
> **Version:** 1.4
|
||||||
> **Language:** Rust (Edition 2024)
|
> **Language:** Rust (Edition 2024)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **Engine:** Bevy (latest stable)
|
||||||
> **Last Updated:** 2026-05-12
|
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
|
||||||
|
> core card/pile types come from the upstream `card_game` workspace and
|
||||||
|
> score/undo/recycle are derived from the upstream session, not stored.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,13 +84,15 @@ ferrous_solitaire/
|
|||||||
│ ├── win_fanfare.wav
|
│ ├── win_fanfare.wav
|
||||||
│ └── ambient_loop.wav
|
│ └── ambient_loop.wav
|
||||||
│
|
│
|
||||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
|
||||||
├── solitaire_sync/ # Shared API types — used by client and server
|
├── solitaire_sync/ # Shared API types — used by client and server
|
||||||
├── solitaire_data/ # Persistence, sync client, settings
|
├── solitaire_data/ # Persistence, sync client, settings
|
||||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) + web frontend
|
||||||
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
├── solitaire_wasm/ # WebAssembly bindings — browser-side logic/replay + debug bridge
|
||||||
└── solitaire_app/ # Main binary entry point
|
├── solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||||
|
├── solitaire_assetgen/ # One-shot generator for card/background PNG assets
|
||||||
|
└── solitaire_app/ # Main binary entry point (desktop + Android cdylib)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -96,18 +100,30 @@ ferrous_solitaire/
|
|||||||
## 3. Crate Responsibilities
|
## 3. Crate Responsibilities
|
||||||
|
|
||||||
### `solitaire_core`
|
### `solitaire_core`
|
||||||
**Dependencies:** `rand`, `serde`, `chrono` only.
|
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
|
||||||
|
`klondike` crates (pinned via the Quaternions registry — never edit upstream).
|
||||||
|
|
||||||
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with `cargo test -p solitaire_core`.
|
The game rules layer. No Bevy, no network, no file I/O. Designed to be tested
|
||||||
|
in isolation with `cargo test -p solitaire_core`.
|
||||||
|
|
||||||
|
Since the card_game migration (2026-06-22, PR #88) the primitive types are
|
||||||
|
**upstream**: `Card`, `Deck`, `Suit`, `Rank`, `Session` come from `card_game`;
|
||||||
|
`Klondike`, `KlondikePile`, `KlondikeInstruction`, `DrawStockConfig`,
|
||||||
|
`Foundation`, `Tableau` come from `klondike`. `solitaire_core` re-exports them
|
||||||
|
so downstream crates import from one place and never depend on the upstream
|
||||||
|
crates directly.
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
|
- `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
|
||||||
- Move validation logic
|
is the single source of truth for board state and stats
|
||||||
- Scoring engine
|
- `MoveError` and the `Result`-based mutation API
|
||||||
- Undo stack
|
- `KlondikeConfig` adaptation (`klondike_adapter`) — draw mode, scoring,
|
||||||
|
take-from-foundation
|
||||||
|
- `GameMode` (Classic / Zen / Challenge / TimeAttack) and mode-aware scoring
|
||||||
|
- Solvability check API (`SolveOutcome`, delegating to `Session::solve`)
|
||||||
- Win / auto-complete detection
|
- Win / auto-complete detection
|
||||||
- Achievement unlock condition evaluation
|
- Achievement unlock condition evaluation
|
||||||
- Seeded RNG for reproducible deals
|
- Seeded deals (same seed ⇒ same layout, via the upstream dealer)
|
||||||
|
|
||||||
**Rules decisions:**
|
**Rules decisions:**
|
||||||
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
||||||
@@ -189,9 +205,13 @@ Owns:
|
|||||||
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||||
|
|
||||||
### `solitaire_app`
|
### `solitaire_app`
|
||||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
**Dependencies:** `bevy`, `solitaire_engine`, `solitaire_data` (+ `jni` on Android).
|
||||||
|
|
||||||
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
|
Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
|
||||||
|
plugins and sets initial window properties. The one crate in the workspace
|
||||||
|
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
|
||||||
|
hands them to the safe `solitaire_data::android_jni` bridge; everything else
|
||||||
|
is `forbid(unsafe_code)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -559,26 +579,36 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
|||||||
|
|
||||||
### Core Game Models (`solitaire_core`)
|
### Core Game Models (`solitaire_core`)
|
||||||
|
|
||||||
|
Since the card_game migration, the primitives are upstream types re-exported
|
||||||
|
through `solitaire_core`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
|
// From `card_game` (upstream — never edit):
|
||||||
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
|
pub enum Suit { /* Clubs, Diamonds, Hearts, Spades */ }
|
||||||
|
pub enum Rank { /* Ace ..= King */ }
|
||||||
|
pub struct Card { /* deck + suit + rank; identity type, no face_up flag —
|
||||||
|
facing is positional, tracked by the Klondike board */ }
|
||||||
|
pub struct Session<G> { /* replayable instruction log + derived stats */ }
|
||||||
|
|
||||||
pub struct Card {
|
// From `klondike` (upstream — never edit):
|
||||||
pub id: u32,
|
pub enum KlondikePile {
|
||||||
pub suit: Suit,
|
Stock, // NB: no Waste variant — see below
|
||||||
pub rank: Rank,
|
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||||
pub face_up: bool,
|
Tableau(Tableau), // 7 columns
|
||||||
}
|
}
|
||||||
|
// Pile-coordinate convention: upstream has no `Waste` variant. In
|
||||||
|
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
|
||||||
|
// *waste* pile (the only stock-side pile cards move out of); use
|
||||||
|
// `GameState::stock_cards()` / `waste_cards()` when the face-down
|
||||||
|
// draw stack must be distinguished. Documented on `GameState::pile`.
|
||||||
|
pub enum DrawStockConfig { DrawOne, DrawThree }
|
||||||
|
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||||
|
serialized move format (schema v4+) */ }
|
||||||
|
```
|
||||||
|
|
||||||
pub enum PileType {
|
Owned by `solitaire_core`:
|
||||||
Stock,
|
|
||||||
Waste,
|
|
||||||
Foundation(Suit),
|
|
||||||
Tableau(usize), // 0–6
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum DrawMode { DrawOne, DrawThree }
|
|
||||||
|
|
||||||
|
```rust
|
||||||
/// Active game mode. Classic is the default; others unlock at level 5.
|
/// Active game mode. Classic is the default; others unlock at level 5.
|
||||||
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
||||||
|
|
||||||
@@ -589,24 +619,30 @@ pub enum MoveError {
|
|||||||
RuleViolation(String),
|
RuleViolation(String),
|
||||||
UndoStackEmpty,
|
UndoStackEmpty,
|
||||||
GameAlreadyWon,
|
GameAlreadyWon,
|
||||||
|
StockEmpty,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
pub piles: HashMap<PileType, Vec<Card>>,
|
|
||||||
pub draw_mode: DrawMode,
|
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
|
||||||
pub move_count: u32,
|
|
||||||
pub undo_count: u32, // number of undos used in this game
|
|
||||||
pub recycle_count: u32, // number of stock recycles
|
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64, // same seed ⇒ same deal
|
||||||
pub is_won: bool,
|
pub take_from_foundation: bool,
|
||||||
pub is_auto_completable: bool,
|
session: Session<Klondike>, // private — the single source of truth
|
||||||
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Derived, not stored:** `score()`, `move_count()`, `undo_count()`,
|
||||||
|
`recycle_count()`, `is_won()`, `is_auto_completable()`, and all pile
|
||||||
|
accessors read through the session. Undo replays the instruction log
|
||||||
|
(no snapshot stack); the −15 undo penalty is applied by the upstream
|
||||||
|
score formula via the session config. Persistence (schema v5) saves
|
||||||
|
`saved_moves` as upstream `KlondikeInstruction`s and rebuilds the
|
||||||
|
session by replay on load — older files carrying `score`/`undo_count`/
|
||||||
|
`recycle_count` keys load fine, the extra fields are ignored.
|
||||||
|
|
||||||
|
**Rules decision:** stock recycling is unlimited in every draw mode
|
||||||
|
(see the "Rules decisions" note in §3 `solitaire_core`).
|
||||||
|
|
||||||
### Persistence Models (`solitaire_data`)
|
### Persistence Models (`solitaire_data`)
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -644,7 +680,7 @@ pub struct AchievementRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
pub sfx_volume: f32, // 0.0–1.0
|
pub sfx_volume: f32, // 0.0–1.0
|
||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub animation_speed: AnimSpeed,
|
pub animation_speed: AnimSpeed,
|
||||||
|
|||||||
@@ -6,6 +6,65 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.42.0] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **CI workspace gate.** New `test.yml` workflow runs clippy (deny warnings)
|
||||||
|
and the full test suite on every master push and PR — previously no CI ran
|
||||||
|
tests at all. Caught its own first bug (missing Bevy native deps) on its
|
||||||
|
own PR. (#135)
|
||||||
|
- **Schedule ambiguity gate.** A headless test builds the gameplay plugin
|
||||||
|
cluster with Bevy ambiguity detection promoted to error. The initial
|
||||||
|
measurement found 302 system pairs with conflicting data access and no
|
||||||
|
ordering; four burn-down batches (PRs #146–#149) took it to ZERO the same
|
||||||
|
day, and the gate now enforces 0. Keyboard consumption, board painting,
|
||||||
|
and HUD updates all have deterministic order for the first time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Browser canvas 36% smaller.** `canvas_bg.wasm` shrank 36.2 MB → 23.2 MB
|
||||||
|
via a size-focused `wasm-release` profile (fat LTO, single codegen unit,
|
||||||
|
opt-level "s"); verified visually identical in production. (#134)
|
||||||
|
- **Quaternions API adoption.** Canonical `FOUNDATIONS`/`TABLEAUS` consts in
|
||||||
|
`solitaire_core` replace five scattered enum lists; upstream
|
||||||
|
`Suit::SUITS`/`Rank::RANKS` replace nine hand-rolled arrays, with the
|
||||||
|
texture-atlas indexing re-keyed through tested canonical helpers. Net
|
||||||
|
−177 lines. (#137)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Sync push race.** The server's load→merge→store cycle now runs in one
|
||||||
|
transaction; concurrent pushes from two devices can no longer overwrite
|
||||||
|
each other's merge. (#136)
|
||||||
|
- **Refresh-token rotation is single-use under concurrency** — rotation
|
||||||
|
gates on the DELETE's row count, so a stolen-then-replayed refresh token
|
||||||
|
loses the race and gets 401. (#136)
|
||||||
|
- **Exit sync push actually completes.** Was a detached task killed by
|
||||||
|
process teardown; now a bounded 2-second blocking wait on the app's final
|
||||||
|
frame. (#138)
|
||||||
|
- **Server auth hardening.** Login timing no longer reveals whether a
|
||||||
|
username exists; concurrent duplicate registration returns 409 instead of
|
||||||
|
500; avatar uploads are magic-byte checked. (#144, issues #139–#141)
|
||||||
|
|
||||||
|
## [0.41.1] — 2026-07-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Oversized pile-marker frames after fold/unfold.** `on_window_resized`
|
||||||
|
resized the marker fill sprite but never its children, so the outline frame
|
||||||
|
and the "A"/"K" watermark kept their spawn-time size after any resize —
|
||||||
|
rendering as oversized grey slabs over empty foundation slots on foldables
|
||||||
|
(found during Galaxy Fold 7 on-device verification of v0.41.0, fixed and
|
||||||
|
re-verified on the same device). Both children are now re-derived from the
|
||||||
|
new layout on every relayout.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Android relayout diagnostics.** Every layout recompute on Android now
|
||||||
|
logs its window dimensions and insets (`layout: resize to WxH …`) — the
|
||||||
|
evidence channel for foldable layout reports (#130).
|
||||||
|
|
||||||
## [0.41.0] — 2026-07-06
|
## [0.41.0] — 2026-07-06
|
||||||
|
|
||||||
> Consolidates everything shipped since v0.39.0, including the v0.40.0–v0.40.3
|
> Consolidates everything shipped since v0.39.0, including the v0.40.0–v0.40.3
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ solitaire_data/ # Persistence + sync client
|
|||||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||||
solitaire_server/ # Axum backend (optional sync layer)
|
solitaire_server/ # Axum backend (optional sync layer)
|
||||||
solitaire_wasm/ # WASM bindings for browser-side replay player
|
solitaire_wasm/ # WASM bindings for browser-side replay player
|
||||||
solitaire_app/ # Entry binary
|
solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||||
|
solitaire_assetgen/ # One-shot card/background PNG asset generator
|
||||||
|
solitaire_app/ # Entry binary (desktop + Android cdylib)
|
||||||
assets/ # Runtime assets (except audio + default theme)
|
assets/ # Runtime assets (except audio + default theme)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Generated
+8
@@ -7351,13 +7351,16 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_server",
|
"solitaire_server",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7402,10 +7405,13 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -7414,6 +7420,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7422,6 +7429,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
+17
@@ -47,6 +47,7 @@ dirs = "6"
|
|||||||
keyring = "4"
|
keyring = "4"
|
||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
|
sha2 = "0.10"
|
||||||
arboard = { version = "3", default-features = false }
|
arboard = { version = "3", default-features = false }
|
||||||
jni = { version = "0.21", default-features = false }
|
jni = { version = "0.21", default-features = false }
|
||||||
|
|
||||||
@@ -156,3 +157,19 @@ opt-level = 3
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
||||||
|
# Size-focused profile for the browser canvas build (solitaire_web →
|
||||||
|
# canvas_bg.wasm). Download size is the constraint on the web, not peak
|
||||||
|
# throughput: fat LTO + one codegen unit + opt-level "s" cut the Bevy wasm
|
||||||
|
# bundle substantially versus plain release. This is rustc-side sizing only —
|
||||||
|
# the binaryen pass in build_wasm.sh stays at wasm-opt -O2 because -Oz has
|
||||||
|
# miscompiled Bevy's render pipeline before (grey screen on first load).
|
||||||
|
[profile.wasm-release]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "s"
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
# No `strip`: on wasm it also removes the target_features custom section,
|
||||||
|
# which makes wasm-opt reject the module ("all used features should be
|
||||||
|
# allowed" on trunc_sat). wasm-opt drops the name section in its output
|
||||||
|
# anyway, so strip buys nothing here.
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Theme store
|
||||||
|
|
||||||
|
The server can offer card-art themes for in-game download. Drop theme
|
||||||
|
`.zip` archives (the same format the game's Settings → Import accepts:
|
||||||
|
a `theme.ron` manifest plus 53 SVGs) into the directory named by
|
||||||
|
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
|
||||||
|
restart the server — the catalog is scanned once at startup. An
|
||||||
|
optional `<theme-id>.png` in the same directory becomes the theme's
|
||||||
|
store preview.
|
||||||
|
|
||||||
|
Endpoints (public, no auth):
|
||||||
|
|
||||||
|
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
|
||||||
|
- `GET /api/themes/<id>/download` — the archive
|
||||||
|
- `GET /api/themes/<id>/preview` — the preview PNG, if present
|
||||||
|
|
||||||
|
Archives that are oversized (> 20 MiB), unreadable, or have a
|
||||||
|
malformed `theme.ron` are skipped with a warning in the server log;
|
||||||
|
they never fail startup.
|
||||||
|
|
||||||
## Admin — Password Reset
|
## Admin — Password Reset
|
||||||
|
|
||||||
If a player loses access to their account, the server binary includes a
|
If a player loses access to their account, the server binary includes a
|
||||||
|
|||||||
+74
-30
@@ -1,16 +1,57 @@
|
|||||||
# Ferrous Solitaire — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
|
**Last updated:** 2026-07-06 — v0.41.0 + v0.41.1 released and verified on a
|
||||||
|
physical Galaxy Fold 7; issue tracker empty except low-priority #130.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105–#108).
|
- **Branch state:** `master` pushed to origin; latest work is the 2026-07-06
|
||||||
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
|
arc (PRs #121–#131): scripted repo review, five issues filed and fixed,
|
||||||
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
|
plugin module splits, two releases.
|
||||||
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
|
- **Latest tags:** `v0.41.1` (pile-marker child-resize fix + Android relayout
|
||||||
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
|
logging) on top of `v0.41.0` (consolidated release for everything since
|
||||||
|
v0.39.0). Both released via tag push → CI signed APK; both verified
|
||||||
|
installed on hardware (`versionCode 4101`).
|
||||||
|
- **Working tree:** clean. Local `scripts/*.go` helpers are intentionally
|
||||||
|
gitignored (`.gitignore:43`); `scripts/watch_deploy.sh` is now committed.
|
||||||
|
- **Latest verification:** workspace clippy `--all-targets -D warnings`,
|
||||||
|
full test suite, `cargo ndk` clippy for `aarch64-linux-android`, CI release
|
||||||
|
builds green, and an on-device pass on the Fold 7 (fold/unfold layout,
|
||||||
|
safe-area resume, marker fix).
|
||||||
|
- **Issue tracker:** #116/#117/#118/#119/#120 all closed 2026-07-06. #130
|
||||||
|
(transient tableau clip after fold) open at low priority — did not
|
||||||
|
reproduce in repeat testing; v0.41.1's relayout logging is the evidence
|
||||||
|
channel if it recurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 session summary (v0.40.3 → v0.41.1)
|
||||||
|
|
||||||
|
- **Scripted repo review** (cratemap/todoctx/cargoclip/testfail): clippy
|
||||||
|
clean, tests green, error/SQL policies compliant. Five issues filed and
|
||||||
|
all resolved same-day.
|
||||||
|
- **#116 safe-area re-poll after resume** (PR #121): `refresh_insets` now
|
||||||
|
gates on the poll counter, settles per cycle, and rewrites insets only on
|
||||||
|
change. Verified on Fold 7 — note both Fold screens report identical
|
||||||
|
insets (top=110 bottom=0), so the re-poll path is a no-op on this device.
|
||||||
|
- **#117 Draw-1 recycle** (PR #122): unlimited recycling documented as an
|
||||||
|
intentional rules decision (ARCHITECTURE.md "Rules decisions" +
|
||||||
|
`draw_one_recycling_is_unlimited_by_design` lock-in test). A hard limit
|
||||||
|
would invalidate the difficulty seed catalog and the winnable-deal solver.
|
||||||
|
- **#118 module splits** (PRs #124/#125/#127/#128/#129): card, hud,
|
||||||
|
settings, game, and input plugins are now module directories; tests in
|
||||||
|
sibling `tests.rs`, runtime code split along system boundaries
|
||||||
|
(`pub(super)` items, mod.rs glob-imports children). Largest runtime file
|
||||||
|
is now `card_plugin/sync.rs` at 748 lines (was `card_plugin.rs` at 4,129).
|
||||||
|
- **v0.41.1 pile-marker fix** (PR #131): marker outline + "A"/"K" watermark
|
||||||
|
children are re-derived from the layout on every resize — previously
|
||||||
|
spawn-time-sized, rendering as oversized grey slabs on empty foundations
|
||||||
|
after fold/unfold. Found via photo evidence, fixed, re-verified on device.
|
||||||
|
- **Obtainium note:** reported "no suitable release" for v0.41.0 even though
|
||||||
|
the anonymous releases API, `releases/latest`, and the APK download were
|
||||||
|
all verified fine — client-side issue; sideload via adb was used instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,32 +168,17 @@ Three bugs fixed:
|
|||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
|
### 1. Physical-device smoke test — DONE (2026-07-06, Galaxy Fold 7)
|
||||||
|
|
||||||
This is the **single outstanding task** for the v0.40.0 Android release. Everything
|
v0.41.1 installed via adb and the full device checklist passed on hardware:
|
||||||
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
|
fold/unfold layout on both screens (incl. the #116 resume path and the
|
||||||
clippy, release manifest sanity, a full local signed APK, the published release, and
|
pile-marker fix), safe-area inset resolution, Draw-Three waste fan tap
|
||||||
Obtainium-facing delivery (public releases API, latest non-draft release, APK
|
accuracy (#106), modal centring on both screens, drag-and-drop across all
|
||||||
downloadable anonymously). The only thing that cannot be done without hardware is
|
pile types, text rendering, kill-and-restore, and the sync token flow.
|
||||||
running the app on a real phone.
|
Reminder for future gates: AVD is not a substitute — `adb shell input tap`
|
||||||
|
doesn't deliver real touch events.
|
||||||
|
|
||||||
Install the published APK on a real Android device (not AVD) and run the checklist
|
### 2. Matomo analytics live validation (independent — NOT a release blocker)
|
||||||
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
|
|
||||||
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
|
|
||||||
|
|
||||||
The signed release APK is published (grab it from the release page, or use the local
|
|
||||||
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
|
|
||||||
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
|
|
||||||
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
|
|
||||||
|
|
||||||
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
|
||||||
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
|
||||||
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
|
|
||||||
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
|
|
||||||
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
|
|
||||||
filtered logcat showed no Ferrous panic/fatal/ANR.
|
|
||||||
|
|
||||||
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
|
|
||||||
|
|
||||||
Separate, ongoing task unrelated to the Android release. `Settings` has
|
Separate, ongoing task unrelated to the Android release. `Settings` has
|
||||||
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
||||||
@@ -164,6 +190,24 @@ validation checklist and the current web/WASM decision notes.
|
|||||||
|
|
||||||
## Architectural notes for next session
|
## Architectural notes for next session
|
||||||
|
|
||||||
|
- **Plugin submodule pattern (2026-07-06 splits):** big plugins are module
|
||||||
|
directories: `mod.rs` holds types/markers/plugin-build and glob-imports the
|
||||||
|
children (`use input::*;`); children hold `pub(super)` systems and start
|
||||||
|
with `use super::*;` plus their own external imports. Tests (`tests.rs`)
|
||||||
|
may need explicit imports for names no longer used by `mod.rs` itself.
|
||||||
|
|
||||||
|
- **Marker child-resize rule:** anything spawned as a *child* of a
|
||||||
|
layout-sized entity (outline frames, watermark text) must be re-derived in
|
||||||
|
`on_window_resized` too — resizing only the parent sprite leaves children
|
||||||
|
at spawn-time size (the v0.41.1 foldable bug).
|
||||||
|
|
||||||
|
- **Fold 7 quirks:** both screens report identical safe-area insets
|
||||||
|
(top=110, bottom=0), so inset-driven relayout never fires on fold; layout
|
||||||
|
correctness across folds rides entirely on `WindowResized`. winit 0.30
|
||||||
|
logs `TODO: find a way to notify application of content rect change` on
|
||||||
|
resume — see #130 if a stale-width layout ever reproduces; v0.41.1 logs
|
||||||
|
every Android relayout (`layout: resize to WxH`) for exactly this.
|
||||||
|
|
||||||
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
|
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
|
||||||
(the trigger), not the `tick_*` system. If the component is never inserted, the
|
(the trigger), not the `tick_*` system. If the component is never inserted, the
|
||||||
tick path never runs. See `hud_plugin.rs::detect_score_change` and
|
tick path never runs. See `hud_plugin.rs::detect_score_change` and
|
||||||
|
|||||||
+4
-2
@@ -67,7 +67,9 @@ if ! command -v wasm-bindgen &> /dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building solitaire_web (Bevy WASM app)..."
|
echo "Building solitaire_web (Bevy WASM app)..."
|
||||||
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
|
# wasm-release is the size-focused profile (fat LTO, CGU=1, opt-level "s") —
|
||||||
|
# see [profile.wasm-release] in Cargo.toml. Download size is the constraint.
|
||||||
|
cargo build --profile wasm-release --target wasm32-unknown-unknown -p solitaire_web
|
||||||
|
|
||||||
echo "Running wasm-bindgen for solitaire_web..."
|
echo "Running wasm-bindgen for solitaire_web..."
|
||||||
wasm-bindgen \
|
wasm-bindgen \
|
||||||
@@ -75,7 +77,7 @@ wasm-bindgen \
|
|||||||
--out-name canvas \
|
--out-name canvas \
|
||||||
--target web \
|
--target web \
|
||||||
--no-typescript \
|
--no-typescript \
|
||||||
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
|
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||||
|
|
||||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
||||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ spec:
|
|||||||
key: jwt-secret
|
key: jwt-secret
|
||||||
- name: SERVER_PORT
|
- name: SERVER_PORT
|
||||||
value: "8080"
|
value: "8080"
|
||||||
|
# Theme-store catalog directory on the persistent volume.
|
||||||
|
# Scanned once at startup — after dropping new theme zips
|
||||||
|
# into /data/theme_store, restart the deployment.
|
||||||
|
- name: THEME_STORE_DIR
|
||||||
|
value: /data/theme_store
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: db-data
|
- name: db-data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ services:
|
|||||||
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
||||||
# regardless of what .env contains.
|
# regardless of what .env contains.
|
||||||
DATABASE_URL: sqlite:///data/solitaire.db
|
DATABASE_URL: sqlite:///data/solitaire.db
|
||||||
|
# Theme-store catalog directory (scanned once at startup; the
|
||||||
|
# host ./theme_store folder is where the operator drops theme
|
||||||
|
# zips + preview PNGs).
|
||||||
|
THEME_STORE_DIR: /theme_store
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- ./theme_store:/theme_store:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
- "${SERVER_PORT:-8080}"
|
- "${SERVER_PORT:-8080}"
|
||||||
|
|||||||
@@ -439,10 +439,7 @@ impl GameState {
|
|||||||
self.session.history().len()
|
self.session.history().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cards_with_face(
|
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
|
||||||
cards: impl IntoIterator<Item = Card>,
|
|
||||||
face_up: bool,
|
|
||||||
) -> Vec<(Card, bool)> {
|
|
||||||
cards.into_iter().map(|card| (card, face_up)).collect()
|
cards.into_iter().map(|card| (card, face_up)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,8 +504,10 @@ impl GameState {
|
|||||||
Self::cards_with_face(cards.iter().cloned(), true)
|
Self::cards_with_face(cards.iter().cloned(), true)
|
||||||
}
|
}
|
||||||
KlondikePile::Tableau(tableau) => {
|
KlondikePile::Tableau(tableau) => {
|
||||||
let mut cards =
|
let mut cards = Self::cards_with_face(
|
||||||
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
|
state.tableau_face_down_cards(tableau).iter().cloned(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
cards.extend(Self::cards_with_face(
|
cards.extend(Self::cards_with_face(
|
||||||
state.tableau_face_up_cards(tableau).iter().cloned(),
|
state.tableau_face_up_cards(tableau).iter().cloned(),
|
||||||
true,
|
true,
|
||||||
@@ -823,9 +822,7 @@ impl GameState {
|
|||||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||||
let state = self.session.state().state().state();
|
let state = self.session.state().state().state();
|
||||||
match instruction {
|
match instruction {
|
||||||
KlondikeInstruction::RotateStock => {
|
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
|
||||||
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
|
||||||
}
|
|
||||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||||
return None;
|
return None;
|
||||||
@@ -928,10 +925,7 @@ impl GameState {
|
|||||||
///
|
///
|
||||||
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
||||||
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
||||||
pub fn apply_instruction(
|
pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
|
||||||
&mut self,
|
|
||||||
instruction: KlondikeInstruction,
|
|
||||||
) -> Result<(), MoveError> {
|
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
}
|
}
|
||||||
@@ -1296,12 +1290,13 @@ mod tests {
|
|||||||
|
|
||||||
game.take_from_foundation = false;
|
game.take_from_foundation = false;
|
||||||
assert!(!game.can_move_cards(&from, &to, 1));
|
assert!(!game.can_move_cards(&from, &to, 1));
|
||||||
assert!(
|
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
|
||||||
legal_pile_moves(&game)
|
f,
|
||||||
.iter()
|
KlondikePile::Foundation(_)
|
||||||
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|
) || !matches!(
|
||||||
|| !matches!(t, KlondikePile::Tableau(_)))
|
t,
|
||||||
);
|
KlondikePile::Tableau(_)
|
||||||
|
)));
|
||||||
assert!(game.move_cards(from, to, 1).is_err());
|
assert!(game.move_cards(from, to, 1).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1360,9 +1355,18 @@ mod tests {
|
|||||||
fn budget_is_passed_through_not_clamped() {
|
fn budget_is_passed_through_not_clamped() {
|
||||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||||
// budget reaches the solver unchanged.
|
// budget reaches the solver unchanged.
|
||||||
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
|
let easy = GameState::solve_fresh_deal(
|
||||||
let medium =
|
0xD1FF_0000_0000_0012,
|
||||||
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
|
DrawStockConfig::DrawOne,
|
||||||
|
1_000,
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
let medium = GameState::solve_fresh_deal(
|
||||||
|
0xD1FF_0000_0000_0012,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
5_000,
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
assert!(easy.is_err());
|
assert!(easy.is_err());
|
||||||
assert!(matches!(medium, Ok(Some(_))));
|
assert!(matches!(medium, Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,41 @@ pub mod scoring;
|
|||||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||||
// appear in any public method signature.
|
// appear in any public method signature.
|
||||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||||
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
pub use klondike::{
|
||||||
|
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||||
|
};
|
||||||
|
|
||||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||||
|
|
||||||
|
/// All four foundation slots, in slot order.
|
||||||
|
///
|
||||||
|
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||||
|
/// `Foundation::ALL` (unlike `Suit::SUITS` / `Rank::RANKS` in `card_game`),
|
||||||
|
/// and inherent impls cannot be added to a foreign type, so the workspace
|
||||||
|
/// const lives here. Use this instead of hand-rolling `[Foundation; 4]`
|
||||||
|
/// arrays; scattered copies can silently diverge.
|
||||||
|
pub const FOUNDATIONS: [Foundation; 4] = [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// All seven tableau columns, in column order (left to right on screen).
|
||||||
|
///
|
||||||
|
/// Canonical iteration source for `Tableau` — see [`FOUNDATIONS`] for why
|
||||||
|
/// this lives here rather than upstream.
|
||||||
|
pub const TABLEAUS: [Tableau; 7] = [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod proptest_tests;
|
mod proptest_tests;
|
||||||
|
|||||||
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for t in &tableaux {
|
for t in &tableaux {
|
||||||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
cards.extend(
|
||||||
|
game.pile(KlondikePile::Tableau(*t))
|
||||||
|
.iter()
|
||||||
|
.map(|(c, _)| c.clone()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
cards
|
cards
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||||
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
prop_oneof![
|
||||||
|
Just(DrawStockConfig::DrawOne),
|
||||||
|
Just(DrawStockConfig::DrawThree)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ uuid = { workspace = true }
|
|||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
# Theme-store downloads are verified against the catalog's SHA-256
|
||||||
|
# before the archive is handed to the engine's theme importer.
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
# `keyring-core` is the typed Entry/Error API used by
|
# `keyring-core` is the typed Entry/Error API used by
|
||||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||||
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
# theme_store_round_trip builds a theme zip in a tempdir for the
|
||||||
|
# in-process store server.
|
||||||
|
zip = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ pub use sync_client::LocalOnlyProvider;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod theme_store_client;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
||||||
|
|
||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub use replay::{
|
pub use replay::{
|
||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||||
|
|||||||
@@ -161,8 +161,10 @@ pub struct Settings {
|
|||||||
/// Identifier of the active card-art theme. Matches `meta.id` from
|
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||||
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
||||||
/// always present; user-supplied themes register under their own ids.
|
/// always present; user-supplied themes register under their own ids.
|
||||||
/// Older `settings.json` files that stored `"default"` or `"classic"`
|
/// Older `settings.json` files that stored `"default"` (the
|
||||||
/// are migrated to `"dark"` by [`Settings::sanitized`].
|
/// pre-rename id of the dark theme) are migrated to `"dark"` by
|
||||||
|
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
|
||||||
|
/// and is never rewritten.
|
||||||
#[serde(default = "default_theme_id")]
|
#[serde(default = "default_theme_id")]
|
||||||
pub selected_theme_id: String,
|
pub selected_theme_id: String,
|
||||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||||
|
|||||||
@@ -553,8 +553,8 @@ mod tests {
|
|||||||
"saved file must use schema version 5",
|
"saved file must use schema version 5",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path)
|
let loaded =
|
||||||
.expect("a valid in-progress game must load without error");
|
load_game_state_from(&path).expect("a valid in-progress game must load without error");
|
||||||
|
|
||||||
// The forward instruction history round-trips, so the reconstructed board
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
// re-serialises to byte-identical JSON.
|
// re-serialises to byte-identical JSON.
|
||||||
@@ -569,7 +569,11 @@ mod tests {
|
|||||||
|
|
||||||
// Derived board reads match the live game (move count + recycle count are
|
// Derived board reads match the live game (move count + recycle count are
|
||||||
// both rebuilt from the replayed forward history).
|
// both rebuilt from the replayed forward history).
|
||||||
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
assert_eq!(
|
||||||
|
loaded.move_count(),
|
||||||
|
gs.move_count(),
|
||||||
|
"move_count round-trips"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.recycle_count(),
|
loaded.recycle_count(),
|
||||||
gs.recycle_count(),
|
gs.recycle_count(),
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
|
|||||||
username: String,
|
username: String,
|
||||||
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
/// Serialises token refreshes. The server rotates refresh tokens and each
|
||||||
|
/// is single-use, so two overlapping 401-retries must not both spend one:
|
||||||
|
/// the loser's (already-consumed) token would be rejected and surface as
|
||||||
|
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
|
||||||
|
refresh_lock: tokio::sync::Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
|
|||||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||||
username: username.into(),
|
username: username.into(),
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
|
refresh_lock: tokio::sync::Mutex::new(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +178,27 @@ impl SolitaireServerClient {
|
|||||||
/// The server rotates refresh tokens on each call: the response includes a
|
/// The server rotates refresh tokens on each call: the response includes a
|
||||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||||
/// to the OS keychain on success.
|
/// to the OS keychain on success.
|
||||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
///
|
||||||
|
/// `stale_access` is the access token that just earned the caller a 401.
|
||||||
|
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
|
||||||
|
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
|
||||||
|
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
|
||||||
|
/// the second call would present an already-consumed token, get rejected,
|
||||||
|
/// and force a re-login for no user-visible reason. Whoever loses the lock
|
||||||
|
/// race checks whether the stored access token has already moved past
|
||||||
|
/// `stale_access`; if so the refresh already happened and there is nothing
|
||||||
|
/// left to do.
|
||||||
|
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
|
||||||
|
let _guard = self.refresh_lock.lock().await;
|
||||||
|
|
||||||
|
if let Ok(current) = load_access_token(&self.username)
|
||||||
|
&& current != stale_access
|
||||||
|
{
|
||||||
|
// Another task refreshed while we waited on the lock; retry with
|
||||||
|
// the token it stored rather than spending the new refresh token.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let old_refresh =
|
let old_refresh =
|
||||||
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||||
|
|
||||||
@@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
// Token expired — refresh and retry once.
|
// Token expired — refresh and retry once.
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
// Token expired — refresh and retry once.
|
// Token expired — refresh and retry once.
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! HTTP client for the server's theme-store endpoints.
|
||||||
|
//!
|
||||||
|
//! Fetches the catalog (`GET /api/themes`) and downloads theme
|
||||||
|
//! archives, verifying each download's size and SHA-256 against the
|
||||||
|
//! catalog entry before returning the bytes. The caller (the engine's
|
||||||
|
//! theme-store UI) hands verified bytes to the theme importer, which
|
||||||
|
//! independently re-validates the archive's structure — the store
|
||||||
|
//! pipeline never trusts a byte the importer hasn't checked.
|
||||||
|
//!
|
||||||
|
//! Native-only: gated out on wasm32 alongside the other `reqwest`
|
||||||
|
//! consumers in this crate.
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Hard cap on a theme archive download, matching the engine
|
||||||
|
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
|
||||||
|
/// so downloading it is pure waste.
|
||||||
|
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Errors surfaced by [`ThemeStoreClient`].
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ThemeStoreError {
|
||||||
|
/// The request could not be sent or the response body not read.
|
||||||
|
#[error("network error: {0}")]
|
||||||
|
Network(String),
|
||||||
|
/// The server answered with a non-success status code.
|
||||||
|
#[error("server returned HTTP {0}")]
|
||||||
|
Http(u16),
|
||||||
|
/// The catalog JSON did not parse.
|
||||||
|
#[error("malformed catalog: {0}")]
|
||||||
|
MalformedCatalog(String),
|
||||||
|
/// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
|
||||||
|
/// catalog-declared size.
|
||||||
|
#[error("archive size {got} exceeds the expected {expected} bytes")]
|
||||||
|
Oversized { expected: u64, got: u64 },
|
||||||
|
/// The downloaded bytes do not hash to the catalog's SHA-256 —
|
||||||
|
/// the file changed on the server or was corrupted in transit.
|
||||||
|
#[error("archive checksum mismatch (expected {expected}, got {got})")]
|
||||||
|
ChecksumMismatch { expected: String, got: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client for one server's theme store.
|
||||||
|
///
|
||||||
|
/// Unauthenticated: the catalog and downloads are public endpoints,
|
||||||
|
/// so unlike `SolitaireServerClient` there is no token handling.
|
||||||
|
pub struct ThemeStoreClient {
|
||||||
|
/// Base URL of the server, trailing slash stripped.
|
||||||
|
base_url: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeStoreClient {
|
||||||
|
/// Construct a client for the server at `base_url`
|
||||||
|
/// (e.g. `"https://solitaire.example.com"`).
|
||||||
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the theme catalog, sorted by display name (server-side).
|
||||||
|
pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/themes", self.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let catalog: ThemeCatalogResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
|
||||||
|
Ok(catalog.themes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download `entry`'s archive and verify it against the catalog's
|
||||||
|
/// size and SHA-256. Returns the verified `.zip` bytes, ready for
|
||||||
|
/// the engine's theme importer.
|
||||||
|
pub async fn download_theme(
|
||||||
|
&self,
|
||||||
|
entry: &ThemeCatalogEntry,
|
||||||
|
) -> Result<Vec<u8>, ThemeStoreError> {
|
||||||
|
// The catalog itself could name an absurd size; refuse before
|
||||||
|
// buffering anything.
|
||||||
|
if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
|
||||||
|
return Err(ThemeStoreError::Oversized {
|
||||||
|
expected: MAX_THEME_DOWNLOAD_BYTES,
|
||||||
|
got: entry.size_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}{}", self.base_url, entry.download_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
verify_archive(&bytes, entry)?;
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
||||||
|
/// size and SHA-256. Pure so it can be unit-tested without a server.
|
||||||
|
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
|
||||||
|
if bytes.len() as u64 != entry.size_bytes {
|
||||||
|
return Err(ThemeStoreError::Oversized {
|
||||||
|
expected: entry.size_bytes,
|
||||||
|
got: bytes.len() as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let got = hex_digest(bytes);
|
||||||
|
// Case-insensitive: the server emits lowercase hex, but a
|
||||||
|
// hand-authored catalog mirror shouldn't fail on case alone.
|
||||||
|
if !got.eq_ignore_ascii_case(&entry.sha256) {
|
||||||
|
return Err(ThemeStoreError::ChecksumMismatch {
|
||||||
|
expected: entry.sha256.clone(),
|
||||||
|
got,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
|
||||||
|
/// encoding in `solitaire_server::theme_store`.
|
||||||
|
fn hex_digest(bytes: &[u8]) -> String {
|
||||||
|
let digest = Sha256::digest(bytes);
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
|
||||||
|
ThemeCatalogEntry {
|
||||||
|
id: "neon".into(),
|
||||||
|
name: "Neon".into(),
|
||||||
|
author: "Test".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
size_bytes: bytes.len() as u64,
|
||||||
|
sha256: hex_digest(bytes),
|
||||||
|
download_url: "/api/themes/neon/download".into(),
|
||||||
|
preview_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_accepts_matching_bytes() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_accepts_uppercase_catalog_hash() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
let mut entry = entry_for(bytes);
|
||||||
|
entry.sha256 = entry.sha256.to_uppercase();
|
||||||
|
assert!(verify_archive(bytes, &entry).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_size_mismatch() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
let mut entry = entry_for(bytes);
|
||||||
|
entry.size_bytes += 1;
|
||||||
|
assert!(matches!(
|
||||||
|
verify_archive(bytes, &entry),
|
||||||
|
Err(ThemeStoreError::Oversized { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_checksum_mismatch() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
let mut entry = entry_for(bytes);
|
||||||
|
entry.sha256 = "0".repeat(64);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_archive(bytes, &entry),
|
||||||
|
Err(ThemeStoreError::ChecksumMismatch { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! End-to-end theme-store test: a real `solitaire_server` router
|
||||||
|
//! serving a scanned catalog over a localhost TCP socket, consumed by
|
||||||
|
//! [`solitaire_data::ThemeStoreClient`].
|
||||||
|
//!
|
||||||
|
//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0,
|
||||||
|
//! router in a background `tokio::spawn`, no explicit shutdown.
|
||||||
|
|
||||||
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use solitaire_data::ThemeStoreClient;
|
||||||
|
use solitaire_server::theme_store::ThemeStore;
|
||||||
|
|
||||||
|
/// Minimal theme archive: the catalog scan only reads the `meta`
|
||||||
|
/// block, so no face SVGs are needed.
|
||||||
|
fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf {
|
||||||
|
let manifest = format!(
|
||||||
|
r#"(
|
||||||
|
meta: (
|
||||||
|
id: "{id}",
|
||||||
|
name: "{name}",
|
||||||
|
author: "Round Trip",
|
||||||
|
version: "1.0.0",
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
),
|
||||||
|
faces: {{}},
|
||||||
|
back: "back.svg",
|
||||||
|
)"#
|
||||||
|
);
|
||||||
|
let path = dir.join(file_name);
|
||||||
|
let file = std::fs::File::create(&path).expect("create zip");
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default();
|
||||||
|
writer.start_file("theme.ron", options).expect("start_file");
|
||||||
|
writer
|
||||||
|
.write_all(manifest.as_bytes())
|
||||||
|
.expect("write manifest");
|
||||||
|
writer.finish().expect("finish zip");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the test server with a theme store scanned from `store_dir`
|
||||||
|
/// and return its base URL.
|
||||||
|
async fn spawn_store_server(store_dir: &Path) -> String {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("failed to bind test listener");
|
||||||
|
let addr = listener.local_addr().expect("listener has no local addr");
|
||||||
|
|
||||||
|
let pool = solitaire_server::build_test_pool().await;
|
||||||
|
let app =
|
||||||
|
solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir));
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
eprintln!("test server crashed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog fetch → download → verified bytes match the file the
|
||||||
|
/// server scanned. This is the exact path the engine's store UI runs.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn catalog_fetch_and_verified_download_round_trip() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||||
|
let base_url = spawn_store_server(dir.path()).await;
|
||||||
|
|
||||||
|
let client = ThemeStoreClient::new(&base_url);
|
||||||
|
let catalog = client.fetch_catalog().await.expect("fetch catalog");
|
||||||
|
assert_eq!(catalog.len(), 1);
|
||||||
|
let entry = &catalog[0];
|
||||||
|
assert_eq!(entry.id, "neon");
|
||||||
|
|
||||||
|
let bytes = client.download_theme(entry).await.expect("download");
|
||||||
|
assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A catalog entry whose hash no longer matches the served file must
|
||||||
|
/// be rejected client-side — the importer never sees unverified bytes.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_with_stale_catalog_hash_is_rejected() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||||
|
let base_url = spawn_store_server(dir.path()).await;
|
||||||
|
|
||||||
|
let client = ThemeStoreClient::new(&base_url);
|
||||||
|
let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone();
|
||||||
|
entry.sha256 = "0".repeat(64);
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.download_theme(&entry)
|
||||||
|
.await
|
||||||
|
.expect_err("mismatched hash must fail");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
solitaire_data::ThemeStoreError::ChecksumMismatch { .. }
|
||||||
|
),
|
||||||
|
"expected ChecksumMismatch, got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty (or absent) store directory serves an empty catalog — the
|
||||||
|
/// client sees a store with nothing in it, not an error.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_store_yields_empty_catalog() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let base_url = spawn_store_server(dir.path()).await;
|
||||||
|
|
||||||
|
let client = ThemeStoreClient::new(&base_url);
|
||||||
|
let catalog = client.fetch_catalog().await.expect("fetch catalog");
|
||||||
|
assert!(catalog.is_empty());
|
||||||
|
}
|
||||||
@@ -911,8 +911,7 @@ mod tests {
|
|||||||
|
|
||||||
// Put the active game in Zen mode. evaluate_on_win reads
|
// Put the active game in Zen mode. evaluate_on_win reads
|
||||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
|
||||||
GameMode::Zen;
|
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 0,
|
score: 0,
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ pub const ALL_RANKS: [Rank; 13] = [
|
|||||||
Rank::King,
|
Rank::King,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches
|
/// Iteration order for the SVG generator and the pin test only —
|
||||||
/// `card_plugin::load_card_images` so the suit index used here lines
|
/// output files are keyed by `suit_filename`, so no runtime index
|
||||||
/// up with `CardImageSet.faces[suit]`.
|
/// depends on this order. Kept local (not `Suit::SUITS`) because
|
||||||
|
/// reordering would churn the pinned snapshot ordering for no
|
||||||
|
/// benefit.
|
||||||
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
|
||||||
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
|
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
|||||||
/// Returns the absolute path of the user-themes directory on the
|
/// Returns the absolute path of the user-themes directory on the
|
||||||
/// current platform.
|
/// current platform.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
|
||||||
///
|
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
|
||||||
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
|
/// returns an empty path — callers treat that as "no user themes" and
|
||||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
|
/// the bundled default theme still works. A warning naming the
|
||||||
/// Android always returns `Some`. The panic message names the
|
/// [`set_user_theme_dir`] workaround is logged once. Android always
|
||||||
/// supported workaround ([`set_user_theme_dir`]).
|
/// resolves.
|
||||||
pub fn user_theme_dir() -> PathBuf {
|
pub fn user_theme_dir() -> PathBuf {
|
||||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||||
return p.clone();
|
return p.clone();
|
||||||
@@ -76,29 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
|||||||
/// Per-target-os resolution of the platform's data dir. Delegates
|
/// Per-target-os resolution of the platform's data dir. Delegates
|
||||||
/// to [`solitaire_data::data_dir`] which encapsulates the
|
/// to [`solitaire_data::data_dir`] which encapsulates the
|
||||||
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
||||||
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
|
/// hardcoded `/data/data/<package>/files` sandbox path).
|
||||||
/// only when the underlying resolver returns `None`, which on
|
///
|
||||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
|
/// When the resolver returns `None` — always on wasm32 (no
|
||||||
/// the panic message names the supported workaround.
|
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
|
||||||
|
/// desktop — this degrades to an empty path, which downstream theme
|
||||||
|
/// scanning treats as "no user themes"; the bundled default theme is
|
||||||
|
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
|
||||||
|
/// themes must not take the whole game down with it.
|
||||||
fn detected_platform_data_dir() -> PathBuf {
|
fn detected_platform_data_dir() -> PathBuf {
|
||||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||||
// On wasm32, data_dir() always returns None — there is no filesystem.
|
|
||||||
// User themes are not supported in the browser build; return an empty
|
|
||||||
// path so callers produce a benign empty dir rather than panicking.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
PathBuf::new()
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
panic!(
|
use std::sync::Once;
|
||||||
"user_theme_dir(): platform data directory is unavailable. \
|
static WARN_ONCE: Once = Once::new();
|
||||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
WARN_ONCE.call_once(|| {
|
||||||
the OS reported no Application Support / AppData path. \
|
bevy::log::warn!(
|
||||||
As a workaround call solitaire_engine::assets::user_dir::\
|
"user_theme_dir(): platform data directory is unavailable; \
|
||||||
set_user_theme_dir() before App::run()."
|
user themes are disabled. On Linux check $XDG_DATA_HOME or \
|
||||||
)
|
$HOME; on macOS / Windows the OS reported no Application \
|
||||||
|
Support / AppData path. As a workaround call \
|
||||||
|
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||||
|
before App::run()."
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
PathBuf::new()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ pub struct AutoCompleteState {
|
|||||||
/// Plugin that drives the auto-complete sequence.
|
/// Plugin that drives the auto-complete sequence.
|
||||||
pub struct AutoCompletePlugin;
|
pub struct AutoCompletePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the auto-complete detect/drive chain; HUD readers of
|
||||||
|
/// [`AutoCompleteState`] order themselves after it (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AutoComplete;
|
||||||
|
|
||||||
impl Plugin for AutoCompletePlugin {
|
impl Plugin for AutoCompletePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AutoCompleteState>()
|
app.init_resource::<AutoCompleteState>()
|
||||||
@@ -58,7 +63,9 @@ impl Plugin for AutoCompletePlugin {
|
|||||||
drive_auto_complete,
|
drive_auto_complete,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.in_set(AutoComplete)
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,9 +174,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{Deck, Rank, Suit};
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -207,7 +214,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
g.set_test_tableau_cards(
|
g.set_test_tableau_cards(
|
||||||
Tableau::Tableau1,
|
Tableau::Tableau1,
|
||||||
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
vec![solitaire_core::Card::new(
|
||||||
|
Deck::Deck1,
|
||||||
|
Suit::Clubs,
|
||||||
|
Rank::Ace,
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
g.set_test_auto_completable(true);
|
g.set_test_auto_completable(true);
|
||||||
let expected = (
|
let expected = (
|
||||||
|
|||||||
@@ -72,9 +72,7 @@ pub struct HoverState {
|
|||||||
/// Describes a user action that arrived while cards were still animating.
|
/// Describes a user action that arrived while cards were still animating.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BufferedInput {
|
pub enum BufferedInput {
|
||||||
Move {
|
Move { from: MoveRequestEvent },
|
||||||
from: MoveRequestEvent,
|
|
||||||
},
|
|
||||||
Draw,
|
Draw,
|
||||||
Undo,
|
Undo,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! Card flip animation and drag shadows.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use solitaire_core::Card;
|
||||||
|
|
||||||
|
use crate::animation_plugin::EffectiveSlideDuration;
|
||||||
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
|
||||||
|
|
||||||
|
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||||
|
///
|
||||||
|
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
||||||
|
pub(super) fn start_flip_anim(
|
||||||
|
mut events: MessageReader<CardFlippedEvent>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
|
) {
|
||||||
|
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
|
||||||
|
// Instant animation speed — skip the flip effect entirely.
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for CardFlippedEvent(flipped_card) in events.read() {
|
||||||
|
for (entity, marker) in &card_entities {
|
||||||
|
if marker.card == *flipped_card {
|
||||||
|
commands.entity(entity).insert(CardFlipAnim {
|
||||||
|
timer: 0.0,
|
||||||
|
phase: FlipPhase::ScalingDown,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||||
|
///
|
||||||
|
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
|
||||||
|
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
|
||||||
|
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||||
|
pub(super) fn tick_flip_anim(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
||||||
|
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
||||||
|
anim.timer += dt;
|
||||||
|
match anim.phase {
|
||||||
|
FlipPhase::ScalingDown => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = 1.0 - t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
anim.phase = FlipPhase::ScalingUp;
|
||||||
|
anim.timer = 0.0;
|
||||||
|
transform.scale.x = 0.0;
|
||||||
|
// Fire the reveal event exactly once, at the phase transition,
|
||||||
|
// so the flip sound is synchronised with the visual face reveal.
|
||||||
|
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlipPhase::ScalingUp => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale.x = 1.0;
|
||||||
|
commands.entity(entity).remove::<CardFlipAnim>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #38 — Drag-elevation shadow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maintains a single `ShadowEntity` while cards are being dragged.
|
||||||
|
///
|
||||||
|
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
|
||||||
|
/// sprite behind the top dragged card.
|
||||||
|
/// - If no drag is active, despawns the shadow entity.
|
||||||
|
pub(super) fn update_drag_shadow(
|
||||||
|
mut commands: Commands,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
card_entities: Query<(&CardEntity, &Transform)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
|
mut shadow: Local<Option<Entity>>,
|
||||||
|
) {
|
||||||
|
if drag.is_idle() {
|
||||||
|
// No drag in progress — remove shadow if it exists.
|
||||||
|
if let Some(e) = shadow.take() {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let card_w = layout.0.card_size.x;
|
||||||
|
let card_h = layout.0.card_size.y;
|
||||||
|
|
||||||
|
// Find the world position of the first (top) dragged card.
|
||||||
|
let top_pos = drag.cards.first().and_then(|first_card| {
|
||||||
|
card_index
|
||||||
|
.get(first_card)
|
||||||
|
.and_then(|entity| card_entities.get(entity).ok())
|
||||||
|
.map(|(_, t)| t.translation)
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(top_pos) = top_pos else { return };
|
||||||
|
|
||||||
|
// Shadow is slightly larger, offset behind-and-below, at a z slightly
|
||||||
|
// below the dragged cards.
|
||||||
|
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
|
||||||
|
|
||||||
|
match *shadow {
|
||||||
|
Some(e) => {
|
||||||
|
// Reposition the existing shadow.
|
||||||
|
commands
|
||||||
|
.entity(e)
|
||||||
|
.insert(Transform::from_translation(shadow_pos));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Spawn a new shadow sprite. Alpha tracks the per-card
|
||||||
|
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
|
||||||
|
// "no box-shadow" policy disables this stack shadow in
|
||||||
|
// lockstep with the per-card shadows. Re-enabling shadows
|
||||||
|
// is then a one-line change in `ui_theme`, not a hunt
|
||||||
|
// through plugin code.
|
||||||
|
let e = commands
|
||||||
|
.spawn((
|
||||||
|
ShadowEntity,
|
||||||
|
Sprite {
|
||||||
|
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
|
||||||
|
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_translation(shadow_pos),
|
||||||
|
Visibility::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
*shadow = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
|
||||||
|
/// based on whether the parent [`CardEntity`] is currently in
|
||||||
|
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
|
||||||
|
/// (no lerp) — the existing shake / settle feedback already handles motion
|
||||||
|
/// at drag-end, so an additional shadow tween would compete with those cues.
|
||||||
|
///
|
||||||
|
/// The shadow size is rebuilt from the parent card's current `Sprite`
|
||||||
|
/// `custom_size` plus the appropriate padding, so the resize handler does
|
||||||
|
/// not need to pre-tune shadow sizes for the drag state — this system fixes
|
||||||
|
/// the geometry within one frame.
|
||||||
|
pub(super) fn update_card_shadows_on_drag(
|
||||||
|
drag: Res<DragState>,
|
||||||
|
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
||||||
|
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
||||||
|
) {
|
||||||
|
let dragged: HashSet<&Card> = drag.cards.iter().collect();
|
||||||
|
|
||||||
|
for (card_entity, card_sprite, children) in cards.iter() {
|
||||||
|
let is_dragged = dragged.contains(&card_entity.card);
|
||||||
|
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
||||||
|
let Some(card_size) = card_sprite.custom_size else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in children.iter() {
|
||||||
|
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
|
||||||
|
shadow_sprite.custom_size = Some(card_size + padding);
|
||||||
|
shadow_transform.translation.x = offset.x;
|
||||||
|
shadow_transform.translation.y = offset.y;
|
||||||
|
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #28 — Hint highlight tick system
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//! Hint and right-click highlights, plus cursor hit-testing helpers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::resources::{DragState, GameStateResource};
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||||
|
|
||||||
|
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
||||||
|
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
|
||||||
|
/// resets the card sprite to its normal face-up colour.
|
||||||
|
pub(super) fn tick_hint_highlight(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
) {
|
||||||
|
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
let use_images = card_images.is_some();
|
||||||
|
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||||
|
hint.remaining -= time.delta_secs();
|
||||||
|
if hint.remaining <= 0.0 {
|
||||||
|
// Restore the normal sprite colour.
|
||||||
|
// When image-based rendering is active, WHITE is the neutral tint;
|
||||||
|
// otherwise restore the solid colour appropriate to the card state.
|
||||||
|
sprite.color = if use_images {
|
||||||
|
Color::WHITE
|
||||||
|
} else {
|
||||||
|
let is_face_up = all_cards(&game.0)
|
||||||
|
.iter()
|
||||||
|
.find(|(c, _face_up)| *c == card_entity.card)
|
||||||
|
.is_some_and(|(_, face_up)| *face_up);
|
||||||
|
if is_face_up {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
} else {
|
||||||
|
card_back_colour(back_idx)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<HintHighlight>()
|
||||||
|
.remove::<HintHighlightTimer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #46 — Right-click legal destination highlights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Lime tint applied to a `PileMarker` sprite when it is a legal
|
||||||
|
/// destination for the right-clicked card. Same RGB as the design-
|
||||||
|
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
|
||||||
|
/// because `Alpha::with_alpha` is not yet a `const` trait method on
|
||||||
|
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
|
||||||
|
/// so a palette swap can't drift the two apart silently.
|
||||||
|
pub(super) const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
|
||||||
|
|
||||||
|
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
|
||||||
|
/// when the timer expires.
|
||||||
|
///
|
||||||
|
/// This is a fallback expiry: highlights also clear immediately on
|
||||||
|
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
|
||||||
|
/// first. The 1.5 s timer ensures highlights always disappear even if the
|
||||||
|
/// player takes no further action.
|
||||||
|
pub(super) fn tick_right_click_highlights(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut highlights: Query<
|
||||||
|
(Entity, &mut RightClickHighlightTimer, &mut Sprite),
|
||||||
|
With<RightClickHighlight>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut timer, mut sprite) in &mut highlights {
|
||||||
|
timer.0 -= dt;
|
||||||
|
if timer.0 <= 0.0 {
|
||||||
|
// Restore the pile marker to its default colour before removing
|
||||||
|
// the highlight marker component.
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<RightClickHighlight>()
|
||||||
|
.remove::<RightClickHighlightTimer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
||||||
|
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
||||||
|
///
|
||||||
|
/// Shared by the on-state-change and on-pause clear systems to avoid
|
||||||
|
/// duplicating the removal logic.
|
||||||
|
pub(super) fn clear_right_click_highlights(
|
||||||
|
commands: &mut Commands,
|
||||||
|
highlighted: &Query<Entity, With<RightClickHighlight>>,
|
||||||
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
for entity in highlighted.iter() {
|
||||||
|
commands.entity(entity).remove::<RightClickHighlight>();
|
||||||
|
}
|
||||||
|
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
|
||||||
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all right-click destination highlights whenever any game-state
|
||||||
|
/// mutation succeeds (`StateChangedEvent` fires).
|
||||||
|
///
|
||||||
|
/// This ensures stale highlights do not linger after a card is moved.
|
||||||
|
pub(super) fn clear_right_click_highlights_on_state_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all right-click destination highlights when the game is paused
|
||||||
|
/// (`PausedResource` changes to `true`).
|
||||||
|
///
|
||||||
|
/// Prevents highlighted pile markers from remaining visible behind the pause
|
||||||
|
/// overlay.
|
||||||
|
pub(super) fn clear_right_click_highlights_on_pause(
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
let Some(paused) = paused else { return };
|
||||||
|
if paused.is_changed() && paused.0 {
|
||||||
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles right-click: highlights legal destination piles for the clicked card,
|
||||||
|
/// and clears highlights on any subsequent right- or left-click.
|
||||||
|
///
|
||||||
|
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_right_click(
|
||||||
|
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(buttons) = buttons else { return };
|
||||||
|
let left_pressed = buttons.just_pressed(MouseButton::Left);
|
||||||
|
let right_pressed = buttons.just_pressed(MouseButton::Right);
|
||||||
|
|
||||||
|
// Clear existing highlights on any click.
|
||||||
|
if left_pressed || right_pressed {
|
||||||
|
for entity in &highlighted {
|
||||||
|
commands.entity(entity).remove::<RightClickHighlight>();
|
||||||
|
}
|
||||||
|
for (_entity, _, mut sprite) in &mut pile_markers {
|
||||||
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only proceed for right-clicks while not dragging.
|
||||||
|
if !right_pressed || !drag.is_idle() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
|
||||||
|
// Convert cursor to world-space position.
|
||||||
|
let Some(world) = cursor_world_pos(&windows, &cameras) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the topmost face-up card under the cursor.
|
||||||
|
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tint piles that legally accept the card.
|
||||||
|
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||||
|
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
|
||||||
|
if legal {
|
||||||
|
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.insert(RightClickHighlight)
|
||||||
|
.insert(RightClickHighlightTimer(1.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts cursor position to 2-D world coordinates.
|
||||||
|
pub(super) fn cursor_world_pos(
|
||||||
|
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
|
) -> Option<Vec2> {
|
||||||
|
let window = windows.single().ok()?;
|
||||||
|
let cursor = window.cursor_position()?;
|
||||||
|
let (camera, camera_transform) = cameras.single().ok()?;
|
||||||
|
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
|
||||||
|
/// bounding rectangles of all card sprites, picking the highest Z.
|
||||||
|
pub(super) fn find_top_card_at(
|
||||||
|
cursor: Vec2,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
) -> Option<Card> {
|
||||||
|
let half = layout.card_size / 2.0;
|
||||||
|
let mut best: Option<(f32, Card)> = None;
|
||||||
|
|
||||||
|
for (_, card_entity, transform) in card_entities.iter() {
|
||||||
|
let pos = transform.translation.truncate();
|
||||||
|
if cursor.x < pos.x - half.x
|
||||||
|
|| cursor.x > pos.x + half.x
|
||||||
|
|| cursor.y < pos.y - half.y
|
||||||
|
|| cursor.y > pos.y + half.y
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let found = all_cards(game)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(c, face_up)| *c == card_entity.card && *face_up);
|
||||||
|
if let Some((card, _)) = found {
|
||||||
|
let z = transform.translation.z;
|
||||||
|
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||||
|
best = Some((z, card));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, card)| card)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #28 — Stock-empty visual indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! Card face labels: desktop text labels and Android corner labels.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use bevy::sprite::Anchor;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||||
|
|
||||||
|
pub(super) fn label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank() {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit() {
|
||||||
|
Suit::Clubs => "C",
|
||||||
|
Suit::Diamonds => "D",
|
||||||
|
Suit::Hearts => "H",
|
||||||
|
Suit::Spades => "S",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suit colour for the rank/suit overlay rendered atop the constant
|
||||||
|
/// fallback sprite (only fires under `MinimalPlugins` — production
|
||||||
|
/// renders the suit glyph baked into the PNG). 2-colour traditional
|
||||||
|
/// pairing — hearts + diamonds share the saturated red, clubs +
|
||||||
|
/// spades share the near-white. Two accessibility flags compose:
|
||||||
|
///
|
||||||
|
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
|
||||||
|
/// (lime) — the "Settings toggle swaps red→lime" half of the
|
||||||
|
/// design system's colour-blind support. CBM is a hue-replacement
|
||||||
|
/// for red, so HC has no further effect on red when CBM is on
|
||||||
|
/// (the lime is itself a high-luminance colour).
|
||||||
|
/// - `high_contrast`: when CBM is off, red suits boost to
|
||||||
|
/// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from
|
||||||
|
/// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`).
|
||||||
|
///
|
||||||
|
/// The other half of CBM support (always-on filled-vs-outlined
|
||||||
|
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
|
||||||
|
/// and has no constant-fallback equivalent.
|
||||||
|
pub(super) fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
|
||||||
|
if card.suit().is_red() {
|
||||||
|
if color_blind {
|
||||||
|
// CBM lime wins — the colour-blind swap replaces the
|
||||||
|
// red hue entirely, and the lime is already high-
|
||||||
|
// luminance, so an HC boost on top has nothing to do.
|
||||||
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
|
} else {
|
||||||
|
RED_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
} else if high_contrast {
|
||||||
|
TEXT_PRIMARY_HC
|
||||||
|
} else {
|
||||||
|
BLACK_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn label_visibility(face_up: bool) -> Visibility {
|
||||||
|
if face_up {
|
||||||
|
Visibility::Inherited
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank+suit string for the readability overlay on touch HUD layouts.
|
||||||
|
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||||
|
pub(super) fn mobile_label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank() {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit() {
|
||||||
|
Suit::Clubs => "♣",
|
||||||
|
Suit::Diamonds => "♦",
|
||||||
|
Suit::Hearts => "♥",
|
||||||
|
Suit::Spades => "♠",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards. The background sprite covers the card art's own small
|
||||||
|
/// corner text so only the large overlay is visible.
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards using FiraMono (passed via `font_handle`) so that the
|
||||||
|
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
|
||||||
|
/// font handle Bevy falls back to its built-in face which does not include
|
||||||
|
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||||
|
/// the text colour — the root cause of the "red square on face-down cards"
|
||||||
|
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||||
|
pub(super) fn add_android_corner_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
card_size: Vec2,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
if !face_up {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
// Background covers ~3 monospace chars wide × 1 line tall.
|
||||||
|
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
|
||||||
|
// (3 chars = 1.8× font_size) plus a small margin.
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
|
||||||
|
// Background covers the PNG's baked-in small corner text (top-left).
|
||||||
|
// Classic PNG cards have a white face, so the background must be white too.
|
||||||
|
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
-card_size.x / 2.0 + inset + bg_w / 2.0,
|
||||||
|
card_size.y / 2.0 - inset - bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
// Cover the matching rotated baked-in text at the bottom-right corner.
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
card_size.x / 2.0 - inset - bg_w / 2.0,
|
||||||
|
-card_size.y / 2.0 + inset + bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||||
|
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||||
|
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||||
|
//
|
||||||
|
// Classic PNG cards have a white face: red suits stay the same saturated
|
||||||
|
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||||
|
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||||
|
// Terminal theme background.
|
||||||
|
let text_col = if card.suit().is_red() {
|
||||||
|
if color_blind {
|
||||||
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
|
} else {
|
||||||
|
RED_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
};
|
||||||
|
let label_text = mobile_label_for(card);
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerLabel(label_text.clone()),
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_text),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle.cloned().unwrap_or_default(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_col),
|
||||||
|
Anchor::TOP_LEFT,
|
||||||
|
Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #34 — Card-flip animation systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! Resize handling: window-resize snapping, in-place card resizing,
|
||||||
|
//! and tableau fan spread.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::animation_plugin::CardAnim;
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::table_plugin::PileMarker;
|
||||||
|
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
|
||||||
|
|
||||||
|
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||||
|
/// pending size on [`ResizeThrottle`].
|
||||||
|
///
|
||||||
|
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
|
||||||
|
/// emit many events per frame. Reading `.last()` keeps only the final size —
|
||||||
|
/// every frame's snap target is the most recent window size, never a stale
|
||||||
|
/// one. Pending stays set across frames until the throttled applier consumes
|
||||||
|
/// it; that's how we still flush the final "release" position when the user
|
||||||
|
/// stops dragging.
|
||||||
|
pub(super) fn collect_resize_events(
|
||||||
|
mut events: MessageReader<WindowResized>,
|
||||||
|
mut throttle: ResMut<ResizeThrottle>,
|
||||||
|
) {
|
||||||
|
if let Some(ev) = events.read().last() {
|
||||||
|
throttle.pending = Some(Vec2::new(ev.width, ev.height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snaps every card sprite to its target position, size, and (in the
|
||||||
|
/// fallback Text2d label path) font size when the window is resized.
|
||||||
|
///
|
||||||
|
/// **In-place mutation only.** Resize is the hot path — events fire per
|
||||||
|
/// pixel of drag, so this system cannot afford the despawn/respawn churn
|
||||||
|
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
|
||||||
|
/// and child `TextFont.font_size` directly, leaving the card image handle,
|
||||||
|
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
|
||||||
|
/// across resizes; only their size and position change. The full repaint
|
||||||
|
/// path lives in [`update_card_entity`] and is still used by every non-resize
|
||||||
|
/// caller (deals, moves, flips, settings toggles).
|
||||||
|
///
|
||||||
|
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
|
||||||
|
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
|
||||||
|
/// tick past the throttle window flushes the final size and clears
|
||||||
|
/// `pending`, so the steady-state always matches the user's release size.
|
||||||
|
///
|
||||||
|
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
|
||||||
|
/// tween is not retargeted relative to the previous card-size's position.
|
||||||
|
///
|
||||||
|
/// The "↺" stock-empty label's `font_size` is derived from
|
||||||
|
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
|
||||||
|
/// otherwise the label would not rescale on resize.
|
||||||
|
///
|
||||||
|
/// Scheduled after [`collect_resize_events`] (which itself runs after
|
||||||
|
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
|
||||||
|
/// window size before we read it.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
pub(super) fn snap_cards_on_window_resize(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut throttle: ResMut<ResizeThrottle>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
entities: Query<
|
||||||
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
|
(
|
||||||
|
Without<CardLabel>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||||
|
shadow_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardShadow>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
frame_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardBackFrame>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut pile_markers: Query<
|
||||||
|
(Entity, &PileMarker, &mut Sprite),
|
||||||
|
(
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
if throttle.pending.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = time.elapsed_secs();
|
||||||
|
if !should_apply_resize(now, throttle.last_applied_secs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(game) = game else {
|
||||||
|
// Nothing to apply — clear pending so we don't busy-loop.
|
||||||
|
throttle.pending = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout else {
|
||||||
|
throttle.pending = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
resize_cards_in_place(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&layout.0,
|
||||||
|
card_images.as_deref(),
|
||||||
|
entities,
|
||||||
|
label_query,
|
||||||
|
shadow_query,
|
||||||
|
frame_query,
|
||||||
|
);
|
||||||
|
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
|
||||||
|
throttle.last_applied_secs = now;
|
||||||
|
throttle.pending = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
|
||||||
|
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
|
||||||
|
/// fresh layout, and (in fallback solid-colour mode) also updates the child
|
||||||
|
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
|
||||||
|
/// replacement, no children rebuild — that's the entire point of this path.
|
||||||
|
///
|
||||||
|
/// Called only from the resize handler. Game-state changes (deals, moves,
|
||||||
|
/// flips, settings toggles) still flow through [`sync_cards`] /
|
||||||
|
/// [`update_card_entity`], which handle add/remove/repaint correctly.
|
||||||
|
///
|
||||||
|
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||||
|
/// retargeted relative to the previous card-size's position.
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
pub(super) fn resize_cards_in_place(
|
||||||
|
commands: &mut Commands,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
mut entities: Query<
|
||||||
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
|
(
|
||||||
|
Without<CardLabel>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||||
|
mut shadow_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardShadow>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut frame_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardBackFrame>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let positions = card_positions(game, layout);
|
||||||
|
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
|
||||||
|
.into_iter()
|
||||||
|
.map(|((c, _face_up), p, z)| (c, (p, z)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
|
||||||
|
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
sprite.custom_size = Some(layout.card_size);
|
||||||
|
transform.translation.x = pos.x;
|
||||||
|
transform.translation.y = pos.y;
|
||||||
|
transform.translation.z = z;
|
||||||
|
// Cancel any in-flight slide so it doesn't retarget from a stale
|
||||||
|
// mid-animation position computed against the previous card size.
|
||||||
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize every per-card shadow halo to match the new card size. Both
|
||||||
|
// idle and drag states scale with the card body, so we preserve the
|
||||||
|
// *current* padding (idle vs drag) by keeping the alpha as-is and only
|
||||||
|
// recomputing the geometry. The drag-tracking system runs every frame
|
||||||
|
// and will retune offset / alpha / padding-mode within one frame if the
|
||||||
|
// drag state diverges from the resized geometry.
|
||||||
|
let idle_padding = CARD_SHADOW_PADDING_IDLE;
|
||||||
|
let drag_padding = CARD_SHADOW_PADDING_DRAG;
|
||||||
|
for mut shadow_sprite in shadow_query.iter_mut() {
|
||||||
|
// Choose padding based on the shadow's current alpha — preserves
|
||||||
|
// a lifted shadow's larger halo across resize without needing to
|
||||||
|
// plumb DragState through the resize handler.
|
||||||
|
let alpha = shadow_sprite.color.alpha();
|
||||||
|
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
|
||||||
|
drag_padding
|
||||||
|
} else {
|
||||||
|
idle_padding
|
||||||
|
};
|
||||||
|
shadow_sprite.custom_size = Some(layout.card_size + padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
|
||||||
|
// when PNG faces are loaded the rank/suit are baked into the image and
|
||||||
|
// there is nothing to resize on the label side.
|
||||||
|
if card_images.is_none() {
|
||||||
|
let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
|
||||||
|
for mut font in label_query.iter_mut() {
|
||||||
|
font.font_size = new_font_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize every face-down border frame to match the new card size.
|
||||||
|
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
|
||||||
|
for mut frame_sprite in frame_query.iter_mut() {
|
||||||
|
frame_sprite.custom_size = Some(frame_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates font size and top-left anchor transform of every
|
||||||
|
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
|
||||||
|
/// change or any window resize). The full despawn/respawn path in
|
||||||
|
/// `update_card_entity` already handles game-state changes; this system
|
||||||
|
/// covers the resize-only path where children are mutated in place.
|
||||||
|
pub(super) fn resize_android_corner_labels(
|
||||||
|
layout: Res<LayoutResource>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
mut text_query: Query<(
|
||||||
|
&AndroidCornerLabel,
|
||||||
|
&mut Text2d,
|
||||||
|
&mut TextFont,
|
||||||
|
&mut Transform,
|
||||||
|
)>,
|
||||||
|
mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
|
||||||
|
) {
|
||||||
|
if !layout.is_changed() || card_images.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||||
|
let text_y = layout.0.card_size.y / 2.0 - inset;
|
||||||
|
|
||||||
|
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
|
||||||
|
text2d.0 = label.0.clone();
|
||||||
|
font.font_size = font_size;
|
||||||
|
transform.translation.x = text_x;
|
||||||
|
transform.translation.y = text_y;
|
||||||
|
}
|
||||||
|
for (mut sprite, mut transform) in bg_query.iter_mut() {
|
||||||
|
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
|
||||||
|
transform.translation.x = text_x + bg_w / 2.0;
|
||||||
|
transform.translation.y = text_y - bg_h / 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
|
||||||
|
/// the deepest tableau column fills the available vertical space at every stage
|
||||||
|
/// of play. Runs after every `StateChangedEvent`.
|
||||||
|
///
|
||||||
|
/// Depth is measured across *all* cards in a column, weighting each face-down
|
||||||
|
/// card by the fixed face-down/face-up step ratio. Counting the face-down
|
||||||
|
/// portion — not just the face-up tail — is what fills the lower screen on a
|
||||||
|
/// fresh deal (the deepest column is then six face-down cards under one face-up
|
||||||
|
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
|
||||||
|
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
|
||||||
|
///
|
||||||
|
/// Deeper columns drive the fraction down so everything still fits the window;
|
||||||
|
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
|
||||||
|
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
|
||||||
|
/// cards far apart.
|
||||||
|
pub(super) fn update_tableau_fan_frac(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut layout: Option<ResMut<LayoutResource>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(game) = game else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
|
||||||
|
/// inserted directly as `GameStateResource` at startup without a
|
||||||
|
/// `StateChangedEvent`, so the event-driven system never fires for it. This
|
||||||
|
/// runs once, before [`sync_cards_startup`] renders, so the very first board
|
||||||
|
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
|
||||||
|
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
|
||||||
|
/// and a large empty band below the tableau until the first move.
|
||||||
|
pub(super) fn fill_tableau_fan_on_startup(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut layout: Option<ResMut<LayoutResource>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
|||||||
|
//! Stock-pile indicators: the empty-stock recycle hint and count badge.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||||
|
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
|
||||||
|
|
||||||
|
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||||
|
/// to signal to the player that there are no more cards to draw. Pure white
|
||||||
|
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
|
||||||
|
/// the "empty" state is more visible, not less. Not derived from a palette
|
||||||
|
/// token: this is a sprite tint, not chrome colour.
|
||||||
|
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
|
||||||
|
|
||||||
|
/// Sprite colour applied to the stock `PileMarker` when cards remain in
|
||||||
|
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
|
||||||
|
/// of the engine's idle pile-marker tint automatically.
|
||||||
|
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
|
||||||
|
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
|
||||||
|
///
|
||||||
|
/// If the stock pile is empty the marker sprite is dimmed to
|
||||||
|
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
|
||||||
|
/// spawned (if not already present). When the stock is non-empty the marker is
|
||||||
|
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
|
||||||
|
/// despawned.
|
||||||
|
pub(super) fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||||
|
commands: &mut Commands,
|
||||||
|
game: &GameState,
|
||||||
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
||||||
|
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
layout: &Layout,
|
||||||
|
font: Handle<Font>,
|
||||||
|
) {
|
||||||
|
let stock_empty = game.stock_cards().is_empty();
|
||||||
|
|
||||||
|
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
|
||||||
|
if pile_marker.0 != KlondikePile::Stock {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if stock_empty {
|
||||||
|
// Dim the marker sprite.
|
||||||
|
sprite.color = STOCK_EMPTY_DIM_COLOUR;
|
||||||
|
|
||||||
|
// Spawn the "↺" label only if one does not already exist.
|
||||||
|
let already_has_label = label_children
|
||||||
|
.iter()
|
||||||
|
.any(|(_, parent)| parent.parent() == entity);
|
||||||
|
if !already_has_label {
|
||||||
|
let font_size = layout.card_size.x * 0.4;
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
StockEmptyLabel,
|
||||||
|
Text2d::new("↺"),
|
||||||
|
TextFont {
|
||||||
|
font: font.clone(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Restore normal brightness.
|
||||||
|
sprite.color = STOCK_NORMAL_COLOUR;
|
||||||
|
|
||||||
|
// Despawn any existing "↺" label children.
|
||||||
|
for (label_entity, parent) in label_children.iter() {
|
||||||
|
if parent.parent() == entity {
|
||||||
|
commands.entity(label_entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
|
||||||
|
/// game state (before any `StateChangedEvent` fires).
|
||||||
|
pub(super) fn update_stock_empty_indicator_startup(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
|
||||||
|
/// stock pile marker dim state and "↺" label in sync with the current stock.
|
||||||
|
pub(super) fn update_stock_empty_indicator(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stock-pile remaining-count badge
|
||||||
|
//
|
||||||
|
// Shows a small "N" chip pinned to the bottom-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
|
||||||
|
// never render at the same time.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Inset (in pixels) from the bottom-right corner of the stock pile sprite to
|
||||||
|
/// the centre of the count badge. Anchoring to the bottom-right keeps the chip
|
||||||
|
/// clear of the rank/suit pip in the card's top-left corner. Both components
|
||||||
|
/// move the centre *inward* from that corner: `x` is subtracted from the right
|
||||||
|
/// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy
|
||||||
|
/// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock
|
||||||
|
/// pile and never overlaps the adjacent waste pile — critical on Android where
|
||||||
|
/// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px.
|
||||||
|
const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0);
|
||||||
|
|
||||||
|
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||||
|
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||||
|
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||||
|
|
||||||
|
/// Returns the count of cards currently in the stock pile.
|
||||||
|
///
|
||||||
|
/// Pure helper extracted so the count source is identical between the spawn
|
||||||
|
/// system, the update system, and the unit tests.
|
||||||
|
pub(super) fn stock_card_count(game: &GameState) -> usize {
|
||||||
|
game.stock_cards().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
|
||||||
|
/// given the current `Layout`. The badge sits at the bottom-right corner of
|
||||||
|
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of
|
||||||
|
/// the rank/suit pip in the card's top-left corner.
|
||||||
|
pub(super) fn stock_badge_translation(layout: &Layout) -> Vec3 {
|
||||||
|
// Empty layouts don't contain a Stock entry — fall back to origin so
|
||||||
|
// the badge stays in a deterministic spot until the layout is filled.
|
||||||
|
let pile_pos = layout
|
||||||
|
.pile_positions
|
||||||
|
.get(&KlondikePile::Stock)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(Vec2::ZERO);
|
||||||
|
let half = layout.card_size * 0.5;
|
||||||
|
// Anchor to the bottom-right corner, then move the centre inward.
|
||||||
|
let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x;
|
||||||
|
let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y;
|
||||||
|
Vec3::new(x, y, Z_STOCK_BADGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the stock-count badge entity (background sprite + child text)
|
||||||
|
/// into the world. Called once, when the badge does not yet exist.
|
||||||
|
pub(super) fn spawn_stock_count_badge(
|
||||||
|
commands: &mut Commands,
|
||||||
|
layout: &Layout,
|
||||||
|
font: Option<&Handle<Font>>,
|
||||||
|
count: usize,
|
||||||
|
) {
|
||||||
|
let translation = stock_badge_translation(layout);
|
||||||
|
let visibility = if count == 0 {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
let text_font = TextFont {
|
||||||
|
font: font.cloned().unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
StockCountBadge,
|
||||||
|
Sprite {
|
||||||
|
color: STOCK_BADGE_BG,
|
||||||
|
custom_size: Some(STOCK_BADGE_SIZE),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_translation(translation),
|
||||||
|
visibility,
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
StockCountBadgeText,
|
||||||
|
Text2d::new(format!("{count}")),
|
||||||
|
text_font,
|
||||||
|
TextColor(STOCK_BADGE_FG),
|
||||||
|
// Slightly above the chip background so the digits aren't
|
||||||
|
// occluded by the sprite they sit on.
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
|
||||||
|
/// and otherwise updates its text and visibility in place.
|
||||||
|
///
|
||||||
|
/// Visibility rule: hidden when the stock is empty (the existing `↺`
|
||||||
|
/// `StockEmptyLabel` overlay covers that state), shown when one or more
|
||||||
|
/// cards remain.
|
||||||
|
///
|
||||||
|
/// Position is recomputed from `LayoutResource` every tick so the badge
|
||||||
|
/// follows the stock pile across `WindowResized` layout updates without
|
||||||
|
/// needing a dedicated resize handler.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_stock_count_badge(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font: Option<Res<FontResource>>,
|
||||||
|
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
|
||||||
|
children: Query<&Children, With<StockCountBadge>>,
|
||||||
|
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
|
||||||
|
let count = stock_card_count(&game.0);
|
||||||
|
let translation = stock_badge_translation(&layout.0);
|
||||||
|
let target_visibility = if count == 0 {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
|
||||||
|
if badges.is_empty() {
|
||||||
|
spawn_stock_count_badge(&mut commands, &layout.0, font.as_ref().map(|f| &f.0), count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (entity, mut transform, mut visibility) in badges.iter_mut() {
|
||||||
|
transform.translation = translation;
|
||||||
|
if *visibility != target_visibility {
|
||||||
|
*visibility = target_visibility;
|
||||||
|
}
|
||||||
|
// Update the child text to reflect the latest count. The text node
|
||||||
|
// is created at spawn time, so under normal operation we always
|
||||||
|
// have exactly one child here.
|
||||||
|
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}");
|
||||||
|
if text.0 != new {
|
||||||
|
text.0 = new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
//! Card asset loading and entity lifecycle: the sync systems that
|
||||||
|
//! spawn, update, and position card entities from `GameStateResource`.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
|
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||||
|
use crate::card_animation::CardAnimation;
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
|
/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
|
||||||
|
///
|
||||||
|
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
|
||||||
|
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
|
||||||
|
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
|
||||||
|
/// Rebuilding from scratch (rather than incrementally patching at every
|
||||||
|
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
|
||||||
|
/// and makes a stale entry structurally impossible.
|
||||||
|
///
|
||||||
|
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
|
||||||
|
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
|
||||||
|
/// `card` field is write-once (never mutated in place), so card-reposition
|
||||||
|
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
|
||||||
|
pub(super) fn rebuild_card_entity_index(
|
||||||
|
mut index: ResMut<CardEntityIndex>,
|
||||||
|
cards: Query<(Entity, &CardEntity)>,
|
||||||
|
changed: Query<(), Changed<CardEntity>>,
|
||||||
|
removed: RemovedComponents<CardEntity>,
|
||||||
|
) {
|
||||||
|
if changed.is_empty() && removed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let map = &mut index.0;
|
||||||
|
map.clear();
|
||||||
|
for (entity, ce) in &cards {
|
||||||
|
map.insert(ce.card.clone(), entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the relative asset path for a card face PNG.
|
||||||
|
///
|
||||||
|
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
|
||||||
|
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
|
||||||
|
/// this function so the filename formula is tested in isolation from the
|
||||||
|
/// asset-loading machinery.
|
||||||
|
///
|
||||||
|
/// Note: this function verifies only the **code-side mapping**. If the PNG
|
||||||
|
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
|
||||||
|
/// diamond watermark baked in), that is an **asset content bug** and must be
|
||||||
|
/// fixed by replacing the file — no code change can correct it.
|
||||||
|
pub(super) fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
|
||||||
|
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||||
|
const RANK_STRS: [&str; 13] = [
|
||||||
|
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
|
||||||
|
];
|
||||||
|
let suit_idx = match suit {
|
||||||
|
Suit::Clubs => 0,
|
||||||
|
Suit::Diamonds => 1,
|
||||||
|
Suit::Hearts => 2,
|
||||||
|
Suit::Spades => 3,
|
||||||
|
};
|
||||||
|
let rank_idx = match rank {
|
||||||
|
Rank::Ace => 0,
|
||||||
|
Rank::Two => 1,
|
||||||
|
Rank::Three => 2,
|
||||||
|
Rank::Four => 3,
|
||||||
|
Rank::Five => 4,
|
||||||
|
Rank::Six => 5,
|
||||||
|
Rank::Seven => 6,
|
||||||
|
Rank::Eight => 7,
|
||||||
|
Rank::Nine => 8,
|
||||||
|
Rank::Ten => 9,
|
||||||
|
Rank::Jack => 10,
|
||||||
|
Rank::Queen => 11,
|
||||||
|
Rank::King => 12,
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"cards/faces/classic/{}{}.png",
|
||||||
|
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
||||||
|
/// [`CardImageSet`].
|
||||||
|
///
|
||||||
|
/// Faces: `assets/cards/faces/{RANK}{SUIT}.png` (e.g. `AC.png`, `10H.png`)
|
||||||
|
/// Backs: `assets/cards/backs/back_{0..4}.png`
|
||||||
|
///
|
||||||
|
/// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system
|
||||||
|
/// returns without inserting `CardImageSet` and the plugin falls back to
|
||||||
|
/// solid-colour sprites.
|
||||||
|
pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
|
||||||
|
let Some(asset_server) = asset_server else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
|
||||||
|
// card_plugin::mod; building from SUITS/RANKS order matches them.
|
||||||
|
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||||
|
std::array::from_fn(|ri| {
|
||||||
|
asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si]))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let backs =
|
||||||
|
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
|
||||||
|
commands.insert_resource(CardImageSet {
|
||||||
|
faces,
|
||||||
|
backs,
|
||||||
|
// Populated by the theme plugin once a `CardTheme` finishes loading.
|
||||||
|
// Until then the legacy back fallback (`backs[selected_card_back]`)
|
||||||
|
// is used.
|
||||||
|
theme_back: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
||||||
|
/// available and falling back to a solid-colour sprite in tests.
|
||||||
|
pub(super) fn card_sprite(
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
card_size: Vec2,
|
||||||
|
back_colour: Color,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
) -> Sprite {
|
||||||
|
if let Some(set) = card_images {
|
||||||
|
let image = if face_up {
|
||||||
|
let suit_idx = suit_index(card.suit());
|
||||||
|
let rank_idx = rank_index(card.rank());
|
||||||
|
set.faces[suit_idx][rank_idx].clone()
|
||||||
|
} else if let Some(theme_back) = &set.theme_back {
|
||||||
|
// Active theme provides its own back — always wins over the
|
||||||
|
// legacy `selected_card_back` picker, so a theme switch swaps
|
||||||
|
// faces *and* the back. The picker is treated as informational
|
||||||
|
// only while a theme back is active (see settings_plugin).
|
||||||
|
theme_back.clone()
|
||||||
|
} else {
|
||||||
|
let idx = selected_back.min(set.backs.len() - 1);
|
||||||
|
set.backs[idx].clone()
|
||||||
|
};
|
||||||
|
Sprite {
|
||||||
|
image,
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(card_size),
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR
|
||||||
|
// regardless of colour-blind mode (CBM differentiation now lives in
|
||||||
|
// the suit glyph colour, applied by `text_colour`, not the face
|
||||||
|
// background). Pre-Terminal this branch dispatched through a
|
||||||
|
// separate `face_colour(card, color_blind)` helper.
|
||||||
|
let body_colour = if face_up {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
} else {
|
||||||
|
back_colour
|
||||||
|
};
|
||||||
|
Sprite {
|
||||||
|
color: body_colour,
|
||||||
|
custom_size: Some(card_size),
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When card-back selection changes in Settings, re-render all cards so the
|
||||||
|
/// new back colour is applied immediately (without waiting for a state change).
|
||||||
|
pub(super) fn resync_cards_on_settings_change(
|
||||||
|
mut setting_events: MessageReader<SettingsChangedEvent>,
|
||||||
|
mut state_events: MessageWriter<StateChangedEvent>,
|
||||||
|
) {
|
||||||
|
if setting_events.read().next().is_some() {
|
||||||
|
state_events.write(StateChangedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
||||||
|
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
||||||
|
/// have already completed.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_cards_startup(
|
||||||
|
commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
entities: Query<CardSyncData>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if let Some(layout) = layout {
|
||||||
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
|
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
let back_colour = card_back_colour(selected_back);
|
||||||
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(
|
||||||
|
commands,
|
||||||
|
&game.0,
|
||||||
|
&layout.0,
|
||||||
|
slide_secs,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
&entities,
|
||||||
|
card_images.as_deref(),
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_cards_on_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
entities: Query<CardSyncData>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(layout) = layout {
|
||||||
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
|
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
let back_colour = card_back_colour(selected_back);
|
||||||
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(
|
||||||
|
commands,
|
||||||
|
&game.0,
|
||||||
|
&layout.0,
|
||||||
|
slide_secs,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
&entities,
|
||||||
|
card_images.as_deref(),
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_cards(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
entities: &Query<CardSyncData>,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
|
// The waste buffer card exists only to keep its entity alive while the new
|
||||||
|
// top card's slide animation plays — it must never be visible to the player.
|
||||||
|
// Without this, the buffer sits at waste_base uncovered during the animation
|
||||||
|
// and its rank/suit peek behind the incoming card.
|
||||||
|
let waste_buffer_id: Option<Card> = {
|
||||||
|
let visible = match game.draw_mode() {
|
||||||
|
DrawStockConfig::DrawOne => 1_usize,
|
||||||
|
DrawStockConfig::DrawThree => 3_usize,
|
||||||
|
};
|
||||||
|
let waste_cards = game.waste_cards();
|
||||||
|
(waste_cards.len() > visible)
|
||||||
|
.then_some(waste_cards)
|
||||||
|
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
|
||||||
|
.map(|(c, _face_up)| c)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map Card -> (Entity, current_translation, anim_end) for in-place
|
||||||
|
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
|
||||||
|
// is currently driving the card (e.g. a drag-rejection return tween).
|
||||||
|
//
|
||||||
|
// In the position loop below we compare `anim_end` against the new game-
|
||||||
|
// state target position to decide whether to honour or cancel the tween:
|
||||||
|
// • end ≈ target → animation is still heading to the right place; let
|
||||||
|
// it finish (skip the snap/slide path).
|
||||||
|
// • end ≠ target → the game state has changed (e.g. a new game started
|
||||||
|
// while the win-cascade was mid-flight); cancel the
|
||||||
|
// stale `CardAnimation` and apply the new position.
|
||||||
|
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
|
||||||
|
HashMap::new();
|
||||||
|
for (entity, marker, transform, anim, children_key) in entities.iter() {
|
||||||
|
existing.insert(
|
||||||
|
marker.card.clone(),
|
||||||
|
(
|
||||||
|
entity,
|
||||||
|
transform.translation,
|
||||||
|
anim.map(|a| a.end),
|
||||||
|
children_key.copied(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
|
||||||
|
|
||||||
|
// Despawn any entity whose card is no longer tracked.
|
||||||
|
for (card, (entity, _, _, _)) in &existing {
|
||||||
|
if !live_ids.contains(card) {
|
||||||
|
commands.entity(*entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each card in the current state: spawn or update its entity, then
|
||||||
|
// apply visibility. The waste buffer card is hidden so it cannot peek
|
||||||
|
// behind the incoming top card during the draw slide animation.
|
||||||
|
for ((card, face_up), position, z) in positions {
|
||||||
|
let entity = match existing.get(&card) {
|
||||||
|
Some(&(entity, cur, anim_end, children_key)) => {
|
||||||
|
// If a CardAnimation is in flight, check whether its destination
|
||||||
|
// still matches the game-state target. If the game moved the card
|
||||||
|
// elsewhere (e.g. new game started during a win-cascade scatter),
|
||||||
|
// cancel the stale tween so the card snaps/slides to its new home.
|
||||||
|
let has_anim = match anim_end {
|
||||||
|
Some(end_xy) if (end_xy - position).length() > 2.0 => {
|
||||||
|
commands.entity(entity).remove::<CardAnimation>();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(_) => true,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
update_card_entity(
|
||||||
|
&mut commands,
|
||||||
|
entity,
|
||||||
|
&card,
|
||||||
|
face_up,
|
||||||
|
position,
|
||||||
|
z,
|
||||||
|
layout,
|
||||||
|
slide_secs,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
cur,
|
||||||
|
has_anim,
|
||||||
|
children_key,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
entity
|
||||||
|
}
|
||||||
|
None => spawn_card_entity(
|
||||||
|
&mut commands,
|
||||||
|
&card,
|
||||||
|
face_up,
|
||||||
|
position,
|
||||||
|
z,
|
||||||
|
layout,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let visibility = if waste_buffer_id.as_ref() == Some(&card) {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
commands.entity(entity).insert(visibility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
|
||||||
|
pub(super) fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
|
||||||
|
let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
|
||||||
|
let piles = [
|
||||||
|
(KlondikePile::Stock, true),
|
||||||
|
(KlondikePile::Stock, false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation1), false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation2), false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation3), false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation4), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau1), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau2), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau3), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau4), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau5), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau6), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau7), false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Draw-Three waste fan step, proportional to the column spacing so it scales
|
||||||
|
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
|
||||||
|
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
|
||||||
|
// click target on the card beneath it).
|
||||||
|
let waste_fan_step = waste_fan_step(layout);
|
||||||
|
|
||||||
|
for (pile_type, is_stock_area) in piles {
|
||||||
|
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
|
||||||
|
base.x -= tableau_col_step(layout);
|
||||||
|
}
|
||||||
|
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
|
||||||
|
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
|
||||||
|
let cards = if matches!(pile_type, KlondikePile::Stock) {
|
||||||
|
if is_stock_area {
|
||||||
|
game.stock_cards()
|
||||||
|
} else {
|
||||||
|
game.waste_cards()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
game.pile(pile_type)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tableau uses a two-speed fan: face-down cards are packed tighter
|
||||||
|
// than face-up cards so the visible (playable) portion stands out.
|
||||||
|
// Non-tableau piles stack with a negligible offset.
|
||||||
|
//
|
||||||
|
// Waste pile: only the top N cards are rendered to prevent bleed-through
|
||||||
|
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
|
||||||
|
// shows up to 3 fanned in X (matching the standard Klondike presentation).
|
||||||
|
let render_start = if is_waste {
|
||||||
|
let visible = match game.draw_mode() {
|
||||||
|
DrawStockConfig::DrawOne => 1_usize,
|
||||||
|
DrawStockConfig::DrawThree => 3_usize,
|
||||||
|
};
|
||||||
|
// Render one extra card so that the card sliding off the waste
|
||||||
|
// during a draw animation is still present in the world at z=0
|
||||||
|
// (hidden under the stack) rather than vanishing mid-tween.
|
||||||
|
cards.len().saturating_sub(visible + 1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut y_offset = 0.0_f32;
|
||||||
|
let rendered_len = cards[render_start..].len();
|
||||||
|
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
|
||||||
|
let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
|
||||||
|
// When len > visible, slot 0 is a hidden buffer card kept at
|
||||||
|
// x=0 to prevent a flash during the draw tween. When len ≤
|
||||||
|
// visible (small pile), every card is visible and should fan
|
||||||
|
// normally — no card is hidden, so the shift is 0.
|
||||||
|
let visible = 3_usize;
|
||||||
|
let hidden = rendered_len.saturating_sub(visible);
|
||||||
|
slot.saturating_sub(hidden) as f32 * waste_fan_step
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||||
|
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||||
|
out.push(((card.clone(), *face_up), pos, z));
|
||||||
|
if is_tableau {
|
||||||
|
let step = if *face_up {
|
||||||
|
layout.tableau_fan_frac
|
||||||
|
} else {
|
||||||
|
layout.tableau_facedown_fan_frac
|
||||||
|
};
|
||||||
|
y_offset -= layout.card_size.y * step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
|
||||||
|
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
|
||||||
|
cards.extend(game.stock_cards());
|
||||||
|
cards.extend(game.waste_cards());
|
||||||
|
for foundation in solitaire_core::FOUNDATIONS {
|
||||||
|
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
|
||||||
|
}
|
||||||
|
for tableau in solitaire_core::TABLEAUS {
|
||||||
|
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
|
||||||
|
}
|
||||||
|
cards
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn spawn_card_entity(
|
||||||
|
commands: &mut Commands,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
pos: Vec2,
|
||||||
|
z: f32,
|
||||||
|
layout: &Layout,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) -> Entity {
|
||||||
|
let sprite = card_sprite(
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
back_colour,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut entity = commands.spawn((
|
||||||
|
CardEntity { card: card.clone() },
|
||||||
|
sprite,
|
||||||
|
Transform::from_xyz(pos.x, pos.y, z),
|
||||||
|
Visibility::default(),
|
||||||
|
));
|
||||||
|
let entity_id = entity.id();
|
||||||
|
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||||
|
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||||
|
// system retunes its offset / alpha when this card joins the dragged
|
||||||
|
// stack.
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_card_shadow_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
// Every card gets a thin border frame so it reads as a distinct
|
||||||
|
// rectangle against the dark felt, regardless of face state.
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||||
|
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||||
|
// On Android we additionally spawn a large-print corner label even in
|
||||||
|
// image mode so the rank/suit are legible at phone scale.
|
||||||
|
if card_images.is_none() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_for(card)),
|
||||||
|
TextFont {
|
||||||
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
|
label_visibility(face_up),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_android_corner_label(
|
||||||
|
b,
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Record the appearance signature so subsequent `update_card_entity` calls
|
||||||
|
// can skip rebuilding these children until one of the inputs changes.
|
||||||
|
entity.insert(CardChildrenKey {
|
||||||
|
face_up,
|
||||||
|
card_size: layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
});
|
||||||
|
entity_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_card_entity(
|
||||||
|
commands: &mut Commands,
|
||||||
|
entity: Entity,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
pos: Vec2,
|
||||||
|
z: f32,
|
||||||
|
layout: &Layout,
|
||||||
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
cur: Vec3,
|
||||||
|
has_card_animation: bool,
|
||||||
|
existing_children_key: Option<CardChildrenKey>,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
|
|
||||||
|
// Always refresh the visual appearance.
|
||||||
|
commands.entity(entity).insert(card_sprite(
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
back_colour,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||||
|
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||||
|
// `Transform` here would race that animation each frame and cause a
|
||||||
|
// visible jump. The animation system snaps the final position itself
|
||||||
|
// when it completes.
|
||||||
|
if !has_card_animation {
|
||||||
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
|
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||||
|
// Lift the card immediately on the first frame of the animation so
|
||||||
|
// it never appears behind a card that is already resting at the
|
||||||
|
// destination slot. `advance_card_anims` will maintain this lift
|
||||||
|
// throughout the tween and snap to `target` (without lift) on
|
||||||
|
// completion.
|
||||||
|
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.insert(Transform::from_translation(start))
|
||||||
|
.insert(CardAnim {
|
||||||
|
start,
|
||||||
|
target,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: slide_secs,
|
||||||
|
delay: 0.0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<CardAnim>()
|
||||||
|
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the card's child visuals (drop-shadow, border frame, and the
|
||||||
|
// rank/suit label / large-print corner overlay) only when an input that
|
||||||
|
// affects them actually changed. The child set depends solely on
|
||||||
|
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
|
||||||
|
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
|
||||||
|
// this on a position-only move avoids despawning and respawning the child
|
||||||
|
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
|
||||||
|
// `StateChangedEvent` — the spike that stuttered the slide animation on
|
||||||
|
// high-resolution devices.
|
||||||
|
let new_children_key = CardChildrenKey {
|
||||||
|
face_up,
|
||||||
|
card_size: layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
};
|
||||||
|
if existing_children_key != Some(new_children_key) {
|
||||||
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_card_shadow_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
if card_images.is_none() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_for(card)),
|
||||||
|
TextFont {
|
||||||
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
|
label_visibility(face_up),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_android_corner_label(
|
||||||
|
b,
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
commands.entity(entity).insert(new_children_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
use crate::layout::TABLEAU_FAN_FRAC;
|
use crate::layout::TABLEAU_FAN_FRAC;
|
||||||
|
use crate::resources::DragState;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::Deck;
|
use solitaire_core::Deck;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// Convenience constructor — all unit tests use Deck1.
|
/// Convenience constructor — all unit tests use Deck1.
|
||||||
fn make_card(suit: Suit, rank: Rank) -> Card {
|
fn make_card(suit: Suit, rank: Rank) -> Card {
|
||||||
@@ -110,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() {
|
|||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
}
|
}
|
||||||
let waste_ids: HashSet<Card> =
|
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
|
||||||
assert_eq!(waste_ids.len(), 3);
|
assert_eq!(waste_ids.len(), 3);
|
||||||
|
|
||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
@@ -154,8 +161,7 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
|
|||||||
"need at least 3 waste cards for this test"
|
"need at least 3 waste cards for this test"
|
||||||
);
|
);
|
||||||
|
|
||||||
let waste_ids: HashSet<Card> =
|
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||||
waste_pile.iter().map(|c| c.0.clone()).collect();
|
|
||||||
|
|
||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
@@ -207,8 +213,7 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
|
|||||||
let count = waste_pile.len();
|
let count = waste_pile.len();
|
||||||
assert!(count >= 2, "need at least 2 waste cards");
|
assert!(count >= 2, "need at least 2 waste cards");
|
||||||
|
|
||||||
let waste_ids: HashSet<Card> =
|
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||||
waste_pile.iter().map(|c| c.0.clone()).collect();
|
|
||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
@@ -245,8 +250,7 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
|||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = g.draw();
|
let _ = g.draw();
|
||||||
}
|
}
|
||||||
let waste_ids: HashSet<Card> =
|
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
|
||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
let waste_rendered: Vec<_> = positions
|
let waste_rendered: Vec<_> = positions
|
||||||
@@ -736,8 +740,7 @@ fn advance_past_resize_throttle(app: &mut App) {
|
|||||||
|
|
||||||
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
|
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
|
||||||
// Any Entity will do — the snap system reads only width/height.
|
// Any Entity will do — the snap system reads only width/height.
|
||||||
let window = Entity::from_raw_u32(0)
|
let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
|
||||||
.expect("Entity::from_raw_u32(0) is a valid placeholder");
|
|
||||||
app.world_mut().write_message(WindowResized {
|
app.world_mut().write_message(WindowResized {
|
||||||
window,
|
window,
|
||||||
width,
|
width,
|
||||||
@@ -919,8 +922,7 @@ fn resize_in_place_updates_card_label_font_size() {
|
|||||||
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
||||||
// post-resize card width, so the in-place path is using the
|
// post-resize card width, so the in-place path is using the
|
||||||
// refreshed Layout.
|
// refreshed Layout.
|
||||||
let expected_layout =
|
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
||||||
crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
|
||||||
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||||
assert!(
|
assert!(
|
||||||
(after - expected).abs() < 1e-3,
|
(after - expected).abs() < 1e-3,
|
||||||
@@ -1037,7 +1039,8 @@ fn shadow_offset_increases_during_drag() {
|
|||||||
q.iter(app.world())
|
q.iter(app.world())
|
||||||
.next()
|
.next()
|
||||||
.expect("fixture should spawn at least one CardEntity")
|
.expect("fixture should spawn at least one CardEntity")
|
||||||
.card.clone()
|
.card
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pick a *different* card to act as the negative control —
|
// Pick a *different* card to act as the negative control —
|
||||||
@@ -1092,9 +1095,7 @@ fn shadow_offset_increases_during_drag() {
|
|||||||
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
|
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
|
||||||
// Map every CardEntity to its (Entity, card).
|
// Map every CardEntity to its (Entity, card).
|
||||||
let card_entity = {
|
let card_entity = {
|
||||||
let mut q = app
|
let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
|
||||||
.world_mut()
|
|
||||||
.query::<(Entity, &CardEntity)>();
|
|
||||||
q.iter(app.world())
|
q.iter(app.world())
|
||||||
.find(|(_, c)| c.card == *card)
|
.find(|(_, c)| c.card == *card)
|
||||||
.map(|(e, _)| e)
|
.map(|(e, _)| e)
|
||||||
@@ -1189,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() {
|
|||||||
assert_eq!(stock_badge_text(&mut app), "24");
|
assert_eq!(stock_badge_text(&mut app), "24");
|
||||||
{
|
{
|
||||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
let mut stock: Vec<Card> =
|
let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
|
||||||
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
|
|
||||||
let _ = stock.pop();
|
let _ = stock.pop();
|
||||||
game.0.set_test_stock_cards(stock);
|
game.0.set_test_stock_cards(stock);
|
||||||
}
|
}
|
||||||
@@ -1225,8 +1225,7 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
|
|||||||
// distinct dummy `Image`. We never render these; we only
|
// distinct dummy `Image`. We never render these; we only
|
||||||
// compare ids.
|
// compare ids.
|
||||||
let mut images = Assets::<Image>::default();
|
let mut images = Assets::<Image>::default();
|
||||||
let backs: [Handle<Image>; 5] =
|
let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
|
||||||
std::array::from_fn(|_| images.add(Image::default()));
|
|
||||||
CardImageSet {
|
CardImageSet {
|
||||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||||
backs,
|
backs,
|
||||||
@@ -1484,8 +1483,7 @@ fn waste_pile_cards_have_strictly_increasing_z() {
|
|||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
let waste_ids: HashSet<Card> =
|
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
|
||||||
|
|
||||||
let mut waste_zs: Vec<f32> = positions
|
let mut waste_zs: Vec<f32> = positions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1534,8 +1532,7 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
|||||||
|
|
||||||
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
||||||
|
|
||||||
let waste_ids: HashSet<Card> =
|
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
|
||||||
|
|
||||||
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
|
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1564,8 +1561,7 @@ fn waste_pile_draw_one_cards_have_distinct_z() {
|
|||||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
let waste_ids: HashSet<Card> =
|
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
|
||||||
|
|
||||||
let mut waste_zs: Vec<f32> = positions
|
let mut waste_zs: Vec<f32> = positions
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use crate::{
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::{
|
use crate::{
|
||||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||||
|
ThemeStorePlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||||
@@ -126,6 +127,7 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
.add_plugins(SyncSetupPlugin)
|
.add_plugins(SyncSetupPlugin)
|
||||||
|
.add_plugins(ThemeStorePlugin)
|
||||||
.add_plugins(AnalyticsPlugin)
|
.add_plugins(AnalyticsPlugin)
|
||||||
.add_plugins(LeaderboardPlugin);
|
.add_plugins(LeaderboardPlugin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
use crate::card_plugin::RightClickHighlight;
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
|
|||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
||||||
|
{
|
||||||
let pile_len = game.waste_cards().len();
|
let pile_len = game.waste_cards().len();
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = index.saturating_sub(visible_start) as f32;
|
let slot = index.saturating_sub(visible_start) as f32;
|
||||||
@@ -581,7 +582,10 @@ mod tests {
|
|||||||
|
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{GameMode, GameState},
|
||||||
|
};
|
||||||
|
|
||||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||||
/// registered, plus the resources the system needs. Callers
|
/// registered, plus the resources the system needs. Callers
|
||||||
@@ -630,11 +634,7 @@ mod tests {
|
|||||||
// — same colour family, illegal. Tableau(2) must NOT be
|
// — same colour family, illegal. Tableau(2) must NOT be
|
||||||
// highlighted.
|
// highlighted.
|
||||||
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||||
set_tableau_top(
|
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
|
||||||
&mut game,
|
|
||||||
2,
|
|
||||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
|
|
||||||
);
|
|
||||||
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
||||||
|
|
||||||
let mut app = overlay_test_app(game);
|
let mut app = overlay_test_app(game);
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
|
|||||||
DifficultyLevel::Hard => &mut self.hard,
|
DifficultyLevel::Hard => &mut self.hard,
|
||||||
DifficultyLevel::Expert => &mut self.expert,
|
DifficultyLevel::Expert => &mut self.expert,
|
||||||
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||||
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
// Random has no catalog today, so seeds_for() already returned
|
||||||
|
// None above; if it ever gains one, a time seed is still the
|
||||||
|
// right answer for "Random" — never a reachable panic.
|
||||||
|
DifficultyLevel::Random => return seed_from_system_time(),
|
||||||
};
|
};
|
||||||
let seed = catalog[*cursor % catalog.len()];
|
let seed = catalog[*cursor % catalog.len()];
|
||||||
*cursor = cursor.wrapping_add(1);
|
*cursor = cursor.wrapping_add(1);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::{Card, Suit};
|
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
use solitaire_sync::SyncResponse;
|
||||||
|
|
||||||
@@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct SyncConfigureRequestEvent;
|
pub struct SyncConfigureRequestEvent;
|
||||||
|
|
||||||
|
/// Request to open the in-game theme-store modal. Fired by the
|
||||||
|
/// "Browse theme store" button in the Settings cosmetic section;
|
||||||
|
/// handled by `theme_store_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ThemeStoreOpenRequestEvent;
|
||||||
|
|
||||||
/// Request to disconnect from the current sync backend, clear stored
|
/// Request to disconnect from the current sync backend, clear stored
|
||||||
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||||
/// button in the Settings sync section.
|
/// button in the Settings sync section.
|
||||||
|
|||||||
@@ -852,7 +852,10 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -906,7 +909,10 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ use bevy::prelude::*;
|
|||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use bevy::window::AppLifecycle;
|
use bevy::window::AppLifecycle;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
use solitaire_core::{
|
||||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
|
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
|
||||||
|
};
|
||||||
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{GameMode, GameState},
|
||||||
|
};
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
use solitaire_data::latest_replay_path;
|
use solitaire_data::latest_replay_path;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
@@ -63,6 +68,29 @@ pub struct GameOverScreen;
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct GameMutation;
|
pub struct GameMutation;
|
||||||
|
|
||||||
|
/// System set for every writer of [`crate::events::NewGameRequestEvent`].
|
||||||
|
///
|
||||||
|
/// Many UI entry points fire this trigger (buttons, keyboard, modals,
|
||||||
|
/// mode pickers). Their relative append order within a frame is
|
||||||
|
/// meaningless — consumers drain the whole queue — so members are
|
||||||
|
/// registered `.in_set(NewGameRequestWriters).ambiguous_with(NewGameRequestWriters)`
|
||||||
|
/// to declare writer-vs-writer order irrelevant instead of leaving it as an
|
||||||
|
/// ambiguity (#143). Only ever combine with `.ambiguous_with` on the same
|
||||||
|
/// set; do NOT hang ordering edges off this set.
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct NewGameRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `UndoRequestEvent` — same rationale as
|
||||||
|
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
|
||||||
|
/// meaningless (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct UndoRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
||||||
|
/// arrival order and any same-frame order is fine (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct InfoToastWriters;
|
||||||
|
|
||||||
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct GameStatePath(pub Option<PathBuf>);
|
pub struct GameStatePath(pub Option<PathBuf>);
|
||||||
@@ -165,7 +193,9 @@ impl Plugin for GamePlugin {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
|
saved.unwrap_or_else(|| {
|
||||||
|
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)
|
||||||
|
}),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -208,28 +238,66 @@ impl Plugin for GamePlugin {
|
|||||||
.add_message::<AppLifecycle>()
|
.add_message::<AppLifecycle>()
|
||||||
// add_message is idempotent; SettingsPlugin also registers this.
|
// add_message is idempotent; SettingsPlugin also registers this.
|
||||||
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
||||||
.add_systems(Update, poll_pending_new_game_seed.before(GameMutation))
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
poll_pending_new_game_seed
|
||||||
|
.before(GameMutation)
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(handle_new_game, handle_draw, handle_move, handle_undo)
|
(handle_new_game, handle_draw, handle_move, handle_undo)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(GameMutation),
|
.in_set(GameMutation),
|
||||||
)
|
)
|
||||||
.add_systems(Update, check_no_moves.after(GameMutation))
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
check_no_moves
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals)
|
||||||
|
.in_set(InfoToastWriters)
|
||||||
|
.ambiguous_with(InfoToastWriters),
|
||||||
|
)
|
||||||
.add_systems(Update, record_replay_on_win.after(GameMutation))
|
.add_systems(Update, record_replay_on_win.after(GameMutation))
|
||||||
.add_systems(Update, handle_confirm_input.after(GameMutation))
|
.add_systems(
|
||||||
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
|
Update,
|
||||||
.add_systems(Update, handle_game_over_input.after(GameMutation))
|
(
|
||||||
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
|
handle_confirm_input,
|
||||||
|
handle_confirm_button_input,
|
||||||
|
handle_game_over_input,
|
||||||
|
handle_game_over_button_input,
|
||||||
|
)
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::ui_focus::FocusKeys)
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters)
|
||||||
|
.in_set(UndoRequestWriters)
|
||||||
|
.ambiguous_with(UndoRequestWriters),
|
||||||
|
)
|
||||||
// Restore prompt: spawn the modal once the splash is gone,
|
// Restore prompt: spawn the modal once the splash is gone,
|
||||||
// route Continue / New Game intents back into the existing
|
// route Continue / New Game intents back into the existing
|
||||||
// GameMutation flow.
|
// GameMutation flow.
|
||||||
.add_systems(Update, spawn_restore_prompt_if_pending)
|
// All pre-mutation game-state writers are chained: elapsed
|
||||||
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
// time ticks first, settings sync next, then the restore prompt —
|
||||||
.add_systems(Update, sync_settings_to_game.before(GameMutation))
|
// a deterministic spine instead of three unordered ResMut holders
|
||||||
|
// (ambiguity burn-down, #143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
tick_elapsed_time,
|
||||||
|
sync_settings_to_game,
|
||||||
|
spawn_restore_prompt_if_pending,
|
||||||
|
handle_restore_prompt
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
.before(GameMutation),
|
||||||
|
)
|
||||||
.init_resource::<AutoSaveTimer>()
|
.init_resource::<AutoSaveTimer>()
|
||||||
.add_systems(Update, tick_elapsed_time)
|
.add_systems(Update, auto_save_game_state.after(GameMutation))
|
||||||
.add_systems(Update, auto_save_game_state)
|
|
||||||
.add_systems(Last, save_game_state_on_exit);
|
.add_systems(Last, save_game_state_on_exit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1263,7 +1331,10 @@ fn auto_save_game_state(
|
|||||||
// or there's a pending restore the player hasn't answered — saving
|
// or there's a pending restore the player hasn't answered — saving
|
||||||
// the fresh-deal placeholder we seeded GameStateResource with at
|
// the fresh-deal placeholder we seeded GameStateResource with at
|
||||||
// startup would clobber the real saved game on disk.
|
// startup would clobber the real saved game on disk.
|
||||||
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
|
if paused.is_some_and(|p| p.0)
|
||||||
|
|| game.0.is_won()
|
||||||
|
|| game.0.move_count() == 0
|
||||||
|
|| pending.0.is_some()
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
|||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let events = app
|
let events = app.world().resource::<Messages<CardFlippedEvent>>();
|
||||||
.world()
|
|
||||||
.resource::<Messages<CardFlippedEvent>>();
|
|
||||||
let mut cursor = events.get_cursor();
|
let mut cursor = events.get_cursor();
|
||||||
let fired: Vec<_> = cursor.read(events).collect();
|
let fired: Vec<_> = cursor.read(events).collect();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -800,7 +798,10 @@ fn replay_recording_skips_undo() {
|
|||||||
1,
|
1,
|
||||||
"only the draw is recorded; the undo does not erase it nor add a new entry",
|
"only the draw is recorded; the undo does not erase it nor add a new entry",
|
||||||
);
|
);
|
||||||
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
|
assert!(matches!(
|
||||||
|
recording.moves[0],
|
||||||
|
KlondikeInstruction::RotateStock
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starting a new game wipes the recording so the next deal begins
|
/// Starting a new game wipes the recording so the next deal begins
|
||||||
@@ -872,8 +873,8 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
|||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let history = load_replay_history_from(&path)
|
let history =
|
||||||
.expect("a winning replay must be persisted to ReplayPath");
|
load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
history.replays.len(),
|
history.replays.len(),
|
||||||
1,
|
1,
|
||||||
@@ -1059,7 +1060,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
|
|||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Game state was reseeded — move_count is 0 on the new game.
|
// Game state was reseeded — move_count is 0 on the new game.
|
||||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
assert_eq!(
|
||||||
|
app.world().resource::<GameStateResource>().0.move_count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1133,7 +1137,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
|
|||||||
// The chosen seed is non-deterministic (system time),
|
// The chosen seed is non-deterministic (system time),
|
||||||
// but the new game must have been started cleanly:
|
// but the new game must have been started cleanly:
|
||||||
// move_count back to 0, undo stack empty.
|
// move_count back to 0, undo stack empty.
|
||||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
assert_eq!(
|
||||||
|
app.world().resource::<GameStateResource>().0.move_count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.world()
|
app.world()
|
||||||
.resource::<GameStateResource>()
|
.resource::<GameStateResource>()
|
||||||
|
|||||||
@@ -432,7 +432,9 @@ fn build_home_context<'a>(
|
|||||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||||
daily_today,
|
daily_today,
|
||||||
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
|
draw_mode: settings
|
||||||
|
.map(|s| s.0.draw_mode)
|
||||||
|
.unwrap_or(DrawStockConfig::DrawOne),
|
||||||
font_res,
|
font_res,
|
||||||
difficulty_expanded,
|
difficulty_expanded,
|
||||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
//! HUD feedback effects: action-bar fades, score pulses and floaters,
|
||||||
|
//! and streak flourishes.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Auto-fade state for the action button bar. The bar fades out when
|
||||||
|
/// the cursor is in the play area (below the HUD band) and back in when
|
||||||
|
/// the cursor approaches the top of the window — same UX as a video
|
||||||
|
/// player's auto-hide controls. Buttons remain fully interactive when
|
||||||
|
/// visible; when faded out they're geometrically out of cursor reach
|
||||||
|
/// (hover requires the cursor to be on a button), so no extra
|
||||||
|
/// pointer-events guard is needed.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
|
pub struct HudActionFade {
|
||||||
|
/// Currently displayed alpha. Lerped toward `target` each frame.
|
||||||
|
pub alpha: f32,
|
||||||
|
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
|
||||||
|
pub target: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HudActionFade {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Start visible so the player sees the controls on first launch
|
||||||
|
// before they've moved the cursor anywhere.
|
||||||
|
Self {
|
||||||
|
alpha: 1.0,
|
||||||
|
target: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
|
||||||
|
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
|
||||||
|
/// cursor approaches, not only when it crosses into the band itself.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||||
|
|
||||||
|
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||||
|
/// transition — fast enough to feel responsive without flashing on
|
||||||
|
/// brief cursor wanders into the reveal zone.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||||
|
|
||||||
|
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
||||||
|
/// the cursor is in the reveal zone (bottom of window) or off-screen
|
||||||
|
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
||||||
|
/// `target` at a fixed rate so the visual transition is smooth across
|
||||||
|
/// variable framerates.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub(super) fn update_action_fade(
|
||||||
|
windows: Query<&Window>,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut fade: ResMut<HudActionFade>,
|
||||||
|
) {
|
||||||
|
let Ok(window) = windows.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let height = window.resolution.height();
|
||||||
|
fade.target = match window.cursor_position() {
|
||||||
|
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
|
||||||
|
Some(_) => 0.0,
|
||||||
|
// Off-window cursor: assume keyboard navigation and keep the
|
||||||
|
// bar visible so Tab cycling doesn't lead to invisible focus.
|
||||||
|
None => 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
|
||||||
|
let diff = fade.target - fade.alpha;
|
||||||
|
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the current fade alpha to every action button's
|
||||||
|
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
|
||||||
|
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||||
|
/// same frame doesn't override the fade with an opaque idle / hover
|
||||||
|
/// colour.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn apply_action_fade(
|
||||||
|
fade: Res<HudActionFade>,
|
||||||
|
// Excludes `PopoverRow` so the auto-fade only applies to the
|
||||||
|
// top-level action bar buttons. Popover rows live inside an
|
||||||
|
// explicitly-opened dropdown panel and need to stay visible
|
||||||
|
// regardless of the bar's fade state — without the exclusion
|
||||||
|
// the rows fade to invisible while the popover container stays
|
||||||
|
// visible, leaving a solid background block with no readable
|
||||||
|
// content.
|
||||||
|
mut buttons: Query<
|
||||||
|
(&Children, &mut BackgroundColor),
|
||||||
|
(With<ActionButton>, Without<PopoverRow>),
|
||||||
|
>,
|
||||||
|
mut text_q: Query<&mut TextColor>,
|
||||||
|
) {
|
||||||
|
for (children, mut bg) in &mut buttons {
|
||||||
|
let mut c = bg.0;
|
||||||
|
c.set_alpha(fade.alpha);
|
||||||
|
bg.0 = c;
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Ok(mut tc) = text_q.get_mut(child) {
|
||||||
|
let mut cc = tc.0;
|
||||||
|
cc.set_alpha(fade.alpha);
|
||||||
|
tc.0 = cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||||
|
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||||
|
/// changes. One query covers all action buttons via the shared
|
||||||
|
/// `ActionButton` marker.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn paint_action_buttons(
|
||||||
|
mut buttons: Query<
|
||||||
|
(&Interaction, &mut BackgroundColor),
|
||||||
|
(With<ActionButton>, Changed<Interaction>),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
for (interaction, mut bg) in &mut buttons {
|
||||||
|
bg.0 = match interaction {
|
||||||
|
Interaction::Pressed => ACTION_BTN_PRESSED,
|
||||||
|
Interaction::Hovered => ACTION_BTN_HOVER,
|
||||||
|
Interaction::None => ACTION_BTN_IDLE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
|
||||||
|
/// function so the test suite can assert on the curve directly
|
||||||
|
/// without spinning up a Bevy app.
|
||||||
|
///
|
||||||
|
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
|
||||||
|
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
|
||||||
|
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
|
||||||
|
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
|
||||||
|
/// triangle peaking at 1.1.
|
||||||
|
pub(super) fn score_pulse_scale(t: f32) -> f32 {
|
||||||
|
let clamped = t.clamp(0.0, 1.0);
|
||||||
|
if clamped < 0.5 {
|
||||||
|
1.0 + 0.2 * clamped
|
||||||
|
} else {
|
||||||
|
1.1 - 0.2 * (clamped - 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical pixels the floating "+N" drifts up over its lifetime.
|
||||||
|
const FLOATER_DRIFT_PX: f32 = 40.0;
|
||||||
|
|
||||||
|
/// Diffs the current `GameStateResource.score` against
|
||||||
|
/// [`PreviousScore`]. On a positive delta:
|
||||||
|
///
|
||||||
|
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
|
||||||
|
/// so the readout pulses 1.0 → 1.1 → 1.0.
|
||||||
|
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
|
||||||
|
/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
|
||||||
|
/// readout (see the doc comment on [`ScoreFloater`] for why this is a
|
||||||
|
/// UI Node rather than a `Text2d`).
|
||||||
|
pub(super) fn detect_score_change(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut prev: ResMut<PreviousScore>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
score_q: Query<Entity, With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let current = game.0.score();
|
||||||
|
let delta = current - prev.0;
|
||||||
|
prev.0 = current;
|
||||||
|
if delta <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
if reduce_motion {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
|
||||||
|
let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);
|
||||||
|
|
||||||
|
// Refresh ScorePulse on every score readout entity (in practice
|
||||||
|
// there's exactly one, but iterating is cheaper than asserting).
|
||||||
|
for entity in &score_q {
|
||||||
|
commands.entity(entity).insert(ScorePulse {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: pulse_secs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if delta < SCORE_FLOATER_THRESHOLD {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
// Spawned as an absolutely-positioned UI Node so the floater rides
|
||||||
|
// the same screen-coordinate system as the score readout. Using a
|
||||||
|
// `Text2d` here would require translating UI layout coordinates to
|
||||||
|
// world space every frame; a UI node piggybacks on the same
|
||||||
|
// anchoring `update_hud` already uses for the score and stays
|
||||||
|
// testable under `MinimalPlugins`.
|
||||||
|
commands.spawn((
|
||||||
|
ScoreFloater {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: floater_secs,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
// Anchored next to the HUD column; matches the
|
||||||
|
// `spawn_hud` left/top offsets so the floater appears
|
||||||
|
// overlaid on the score line and drifts up from there.
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD_TOP),
|
||||||
|
Text::new(format!("+{delta}")),
|
||||||
|
font,
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScorePulse`], scaling its entity's `Transform`
|
||||||
|
/// using [`score_pulse_scale`]. Removes the component once
|
||||||
|
/// `elapsed >= duration` (or immediately under
|
||||||
|
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
|
||||||
|
/// 0) and pins the scale back to 1.0 so no float drift survives.
|
||||||
|
pub(super) fn advance_score_pulse(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut pulse, mut transform) in &mut q {
|
||||||
|
let t = if pulse.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
pulse.elapsed += dt;
|
||||||
|
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = score_pulse_scale(t);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
commands.entity(entity).remove::<ScorePulse>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
|
||||||
|
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
|
||||||
|
/// its lifetime. Despawns the entity once `elapsed >= duration`.
|
||||||
|
pub(super) fn advance_score_floater(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut floater, mut node, mut color) in &mut nodes {
|
||||||
|
let t = if floater.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
floater.elapsed += dt;
|
||||||
|
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
// Drift upward: top decreases as t grows. Starting top=0 keeps
|
||||||
|
// the floater on the score line; ending at -FLOATER_DRIFT_PX
|
||||||
|
// pulls it up off the readout.
|
||||||
|
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
|
||||||
|
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
|
||||||
|
let mut c = ACCENT_PRIMARY;
|
||||||
|
c.set_alpha(1.0 - t);
|
||||||
|
color.0 = c;
|
||||||
|
if t >= 1.0 {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streak-milestone flourish
|
||||||
|
//
|
||||||
|
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
|
||||||
|
// completion celebration; the streak flourish is its lifetime equivalent —
|
||||||
|
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
|
||||||
|
// readout pulses larger than a normal score-change pulse and tints magenta
|
||||||
|
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
|
||||||
|
//
|
||||||
|
// Why the score readout: there is no always-on streak number on the HUD
|
||||||
|
// today (the readout lives in the Stats overlay), and the score is the
|
||||||
|
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
|
||||||
|
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
|
||||||
|
// player who isn't watching the score still sees the celebration land.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||||
|
/// the streak flourish at `elapsed_secs` over `duration_secs`.
|
||||||
|
///
|
||||||
|
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
|
||||||
|
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||||
|
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
|
||||||
|
/// Out-of-range values are clamped so the score readout never freezes
|
||||||
|
/// at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
///
|
||||||
|
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
||||||
|
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
||||||
|
/// without dividing by zero.
|
||||||
|
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||||
|
if duration_secs <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
||||||
|
let peak = STREAK_FLOURISH_PEAK_SCALE;
|
||||||
|
if t < 0.5 {
|
||||||
|
// Climb from 1.0 at t=0 to peak at t=0.5.
|
||||||
|
1.0 + (peak - 1.0) * (t / 0.5)
|
||||||
|
} else {
|
||||||
|
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
||||||
|
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
|
||||||
|
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
|
||||||
|
/// `TextColor` so `advance_streak_flourish` can restore it when the
|
||||||
|
/// timer expires; reuses any existing flourish's `original_color` so
|
||||||
|
/// re-entering the system mid-flourish doesn't snapshot the magenta
|
||||||
|
/// tint as the new "original".
|
||||||
|
///
|
||||||
|
/// Removes any concurrent [`ScorePulse`] from the same entity so the
|
||||||
|
/// flourish takes over the scale slot cleanly — score pulses last
|
||||||
|
/// 250 ms, the flourish 600 ms, and the streak crossing always
|
||||||
|
/// coincides with a positive score delta, so the flourish is the
|
||||||
|
/// louder of the two celebrations.
|
||||||
|
pub(super) fn start_streak_flourish(
|
||||||
|
mut events: MessageReader<WinStreakMilestoneEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let Some(latest) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
|
||||||
|
for (entity, color, existing) in &score_q {
|
||||||
|
let original_color = existing.map_or(color.0, |f| f.original_color);
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<ScorePulse>()
|
||||||
|
.insert(StreakFlourish {
|
||||||
|
streak: latest.streak,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration,
|
||||||
|
original_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
|
||||||
|
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
|
||||||
|
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
|
||||||
|
/// `original_color`. Removes the component once `elapsed >= duration`
|
||||||
|
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
|
||||||
|
/// where duration is 0) and pins the scale back to 1.0 / restores the
|
||||||
|
/// original colour so no half-state is ever shown.
|
||||||
|
///
|
||||||
|
/// Filtered with `Without<ScorePulse>` so the streak flourish never
|
||||||
|
/// races a score pulse for the same `Transform.scale` slot —
|
||||||
|
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
|
||||||
|
/// score entity before this system runs, so the filter is purely a
|
||||||
|
/// belt-and-braces invariant.
|
||||||
|
pub(super) fn advance_streak_flourish(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<
|
||||||
|
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
|
||||||
|
Without<ScorePulse>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut anim, mut transform, mut color) in &mut q {
|
||||||
|
let t = if anim.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
// Tint mix: full magenta at t=0..=0.5, fades back to the
|
||||||
|
// original colour over t=0.5..=1.0.
|
||||||
|
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||||
|
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
color.0 = anim.original_color;
|
||||||
|
commands.entity(entity).remove::<StreakFlourish>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB-space linear interpolation between two `Color`s — small local
|
||||||
|
/// helper so `advance_streak_flourish` stays readable. sRGB-space
|
||||||
|
/// lerping is fine for a brief decorative tint (a perceptually-uniform
|
||||||
|
/// space would be overkill).
|
||||||
|
pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||||
|
let from = from.to_srgba();
|
||||||
|
let to = to.to_srgba();
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Color::srgba(
|
||||||
|
from.red + (to.red - from.red) * t,
|
||||||
|
from.green + (to.green - from.green) * t,
|
||||||
|
from.blue + (to.blue - from.blue) * t,
|
||||||
|
from.alpha + (to.alpha - from.alpha) * t,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
//! HUD interaction: action-button handlers, Modes/Menu popovers, and
|
||||||
|
//! the chrome tap-to-toggle gesture.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
||||||
|
/// interaction state transitions, avoiding repeat events while the button
|
||||||
|
/// is held down. Each click handler fires the corresponding request event,
|
||||||
|
/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside
|
||||||
|
/// their existing keyboard handlers.
|
||||||
|
pub(super) fn handle_new_game_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_undo_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
|
||||||
|
mut undo: MessageWriter<UndoRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
undo.write(UndoRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_pause_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
||||||
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
pause.write(PauseRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_help_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
|
||||||
|
mut help: MessageWriter<HelpRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
help.write(HelpRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_hint_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
||||||
|
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if paused.as_ref().is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(ref g) = game else { return };
|
||||||
|
if g.0.is_won() {
|
||||||
|
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
||||||
|
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
|
||||||
|
/// second click. Mode rows are populated per the player's current level so
|
||||||
|
/// only unlocked options appear.
|
||||||
|
pub(super) fn handle_modes_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spawn_modes_popover(
|
||||||
|
&mut commands,
|
||||||
|
progress.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the modes popover anchored just below the action bar's right
|
||||||
|
/// edge. Always includes Classic; includes Daily Challenge when a daily
|
||||||
|
/// resource is loaded; includes Zen / Challenge / Time Attack once the
|
||||||
|
/// player reaches the challenge unlock level.
|
||||||
|
pub(super) fn spawn_modes_popover(
|
||||||
|
commands: &mut Commands,
|
||||||
|
progress: Option<&ProgressResource>,
|
||||||
|
daily: Option<&DailyChallengeResource>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let level = progress.map_or(0, |p| p.0.level);
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 15.0,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each row carries a tooltip alongside its label so hover reveals
|
||||||
|
// a one-line description of what the mode does — mirroring the
|
||||||
|
// tooltips on the action-bar buttons that opened this popover.
|
||||||
|
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
|
||||||
|
ModeOption::Classic,
|
||||||
|
"Classic",
|
||||||
|
"Standard Klondike. Score, timer, and full progression.",
|
||||||
|
)];
|
||||||
|
if daily.is_some() {
|
||||||
|
rows.push((
|
||||||
|
ModeOption::DailyChallenge,
|
||||||
|
"Daily Challenge",
|
||||||
|
"Today's seeded deal. Same for every player worldwide.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if level >= CHALLENGE_UNLOCK_LEVEL {
|
||||||
|
rows.push((
|
||||||
|
ModeOption::Zen,
|
||||||
|
"Zen",
|
||||||
|
"No timer, no score, no penalties. Just play.",
|
||||||
|
));
|
||||||
|
rows.push((
|
||||||
|
ModeOption::Challenge,
|
||||||
|
"Challenge",
|
||||||
|
"Hand-picked hard seeds. No undo allowed.",
|
||||||
|
));
|
||||||
|
rows.push((
|
||||||
|
ModeOption::TimeAttack,
|
||||||
|
"Time Attack",
|
||||||
|
"Win as many games as you can in ten minutes.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popover opens upward from just above the bottom action bar.
|
||||||
|
// Use a platform-aware offset that clears the bar height + safe-area
|
||||||
|
// gesture zone on Android, and the flat bar height on desktop.
|
||||||
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
ModesPopover,
|
||||||
|
HudPopoverOpen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
bottom: popover_bottom,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
ZIndex(Z_HUD_POPOVER),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
for (option, label, tooltip) in rows {
|
||||||
|
panel
|
||||||
|
.spawn((
|
||||||
|
option,
|
||||||
|
ActionButton,
|
||||||
|
PopoverRow,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::FlexStart,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
min_width: Val::Px(150.0),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
|
||||||
|
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
|
||||||
|
commands.spawn((
|
||||||
|
ModesPopoverBackdrop,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches the click on a popover row to the matching request event,
|
||||||
|
/// then despawns the popover.
|
||||||
|
///
|
||||||
|
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
|
||||||
|
/// their `Start*RequestEvent` so the existing keyboard handler runs
|
||||||
|
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
|
||||||
|
/// the popover stays a thin entry point and never duplicates that logic.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_mode_option_click(
|
||||||
|
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||||
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let mut clicked_any = false;
|
||||||
|
for (interaction, option) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clicked_any = true;
|
||||||
|
match option {
|
||||||
|
ModeOption::Classic => {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
ModeOption::DailyChallenge => {
|
||||||
|
daily.write(StartDailyChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::Zen => {
|
||||||
|
zen.write(StartZenRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::Challenge => {
|
||||||
|
challenge.write(StartChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::TimeAttack => {
|
||||||
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clicked_any && let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
|
||||||
|
/// second click. The popover lists the five overlays previously only
|
||||||
|
/// reachable via the S / A / P / O / L hotkeys.
|
||||||
|
pub(super) fn handle_menu_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
} else if scrims.is_empty() {
|
||||||
|
spawn_menu_popover(&mut commands, font_res.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the menu popover anchored just below the action bar, with one
|
||||||
|
/// row per overlay. Each row dispatches its corresponding
|
||||||
|
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
|
||||||
|
/// HUD never duplicates spawn / despawn / fetch logic).
|
||||||
|
pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 15.0,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each row carries a tooltip alongside its label so hover reveals
|
||||||
|
// a one-line description of what each overlay shows — mirroring
|
||||||
|
// the tooltips on the action-bar buttons that opened this popover.
|
||||||
|
let rows: [(MenuOption, &'static str, &'static str); 7] = [
|
||||||
|
(
|
||||||
|
MenuOption::Help,
|
||||||
|
"Help",
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Modes,
|
||||||
|
"Game Modes",
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Stats,
|
||||||
|
"Stats",
|
||||||
|
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Achievements,
|
||||||
|
"Achievements",
|
||||||
|
"Browse unlocked achievements and the rewards still ahead.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Profile,
|
||||||
|
"Profile",
|
||||||
|
"Your level, XP progress, and sync status.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Settings,
|
||||||
|
"Settings",
|
||||||
|
"Audio, animations, theme, draw mode, and sync.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Leaderboard,
|
||||||
|
"Leaderboard",
|
||||||
|
"Top players from your sync server. Opt in from Profile.",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same upward-opening placement as ModesPopover.
|
||||||
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
MenuPopover,
|
||||||
|
HudPopoverOpen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
bottom: popover_bottom,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
ZIndex(Z_HUD_POPOVER),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
for (option, label, tooltip) in rows {
|
||||||
|
panel
|
||||||
|
.spawn((
|
||||||
|
option,
|
||||||
|
ActionButton,
|
||||||
|
PopoverRow,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::FlexStart,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
min_width: Val::Px(150.0),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transparent fullscreen backdrop behind the popover — tapping anywhere
|
||||||
|
// outside the panel light-dismisses it via handle_menu_backdrop_click.
|
||||||
|
commands.spawn((
|
||||||
|
MenuPopoverBackdrop,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches the click on a menu row to the matching toggle event,
|
||||||
|
/// then despawns the popover.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_menu_option_click(
|
||||||
|
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||||
|
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||||
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
||||||
|
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
||||||
|
mut help: MessageWriter<HelpRequestEvent>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let mut clicked_any = false;
|
||||||
|
let mut open_modes = false;
|
||||||
|
for (interaction, option) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clicked_any = true;
|
||||||
|
match option {
|
||||||
|
MenuOption::Help => {
|
||||||
|
help.write(HelpRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Modes => {
|
||||||
|
open_modes = true;
|
||||||
|
}
|
||||||
|
MenuOption::Stats => {
|
||||||
|
stats.write(ToggleStatsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Achievements => {
|
||||||
|
achievements.write(ToggleAchievementsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Profile => {
|
||||||
|
profile.write(ToggleProfileRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Settings => {
|
||||||
|
settings.write(ToggleSettingsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Leaderboard => {
|
||||||
|
leaderboard.write(ToggleLeaderboardRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clicked_any && let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if open_modes {
|
||||||
|
spawn_modes_popover(
|
||||||
|
&mut commands,
|
||||||
|
progress.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
||||||
|
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
|
||||||
|
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
|
||||||
|
pub(super) fn close_modes_popover_on_escape(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
|
||||||
|
/// is pressed while the popover is open.
|
||||||
|
pub(super) fn close_menu_popover_on_escape(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
|
||||||
|
/// anywhere outside the panel.
|
||||||
|
pub(super) fn handle_modes_backdrop_click(
|
||||||
|
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
|
||||||
|
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
|
||||||
|
pub(super) fn handle_menu_backdrop_click(
|
||||||
|
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn toggle_hud_on_tap(
|
||||||
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut tracker: ResMut<HudTapTracker>,
|
||||||
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
buttons: Query<&Interaction, With<ActionButton>>,
|
||||||
|
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||||
|
) {
|
||||||
|
use bevy::input::touch::TouchPhase;
|
||||||
|
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
||||||
|
// Drain buffered events so they don't replay in the frame after
|
||||||
|
// the scrim despawns, which would trigger a spurious visibility
|
||||||
|
// toggle as the resume/close button tap's Started+Ended pair
|
||||||
|
// replays in the now-scrim-free frame.
|
||||||
|
for _ in touch_events.read() {}
|
||||||
|
tracker.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for event in touch_events.read() {
|
||||||
|
match event.phase {
|
||||||
|
TouchPhase::Started => {
|
||||||
|
tracker.start_pos = Some(event.position);
|
||||||
|
// Record whether the finger-down landed on a button so
|
||||||
|
// the finger-up doesn't double-fire (toggle bar + press
|
||||||
|
// button at the same time).
|
||||||
|
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
|
||||||
|
}
|
||||||
|
TouchPhase::Ended if drag.is_idle() => {
|
||||||
|
// Also treat taps where game logic consumed the touch (e.g.
|
||||||
|
// drawing from stock) as "on button" so they don't toggle
|
||||||
|
// the HUD. The flag is set on TouchPhase::Started by the
|
||||||
|
// input system that consumed the tap and must be cleared here
|
||||||
|
// regardless of whether we toggle.
|
||||||
|
let on_button = tracker.started_on_button || game_consumed.0;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
if let Some(start) = tracker.start_pos.take()
|
||||||
|
&& !on_button
|
||||||
|
&& (event.position - start).length() < HUD_TAP_SLOP_PX
|
||||||
|
{
|
||||||
|
*hud_vis = match *hud_vis {
|
||||||
|
HudVisibility::Visible => HudVisibility::Hidden,
|
||||||
|
HudVisibility::Hidden => HudVisibility::Visible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
}
|
||||||
|
// Moved: don't clear start_pos — Android fires Moved for normal
|
||||||
|
// tap jitter, and the distance check at Ended already rejects
|
||||||
|
// real drags. Clearing here would silently swallow tap toggles.
|
||||||
|
TouchPhase::Canceled => {
|
||||||
|
tracker.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,552 @@
|
|||||||
|
//! HUD construction: band, columns, avatar, and action-bar spawning.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::avatar_plugin::AvatarResource;
|
||||||
|
|
||||||
|
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
||||||
|
/// the screen so the card layout (computed by `layout::compute_layout` using
|
||||||
|
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
||||||
|
///
|
||||||
|
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||||
|
/// A slim grey background is handled by each content section individually
|
||||||
|
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||||
|
pub(super) fn spawn_hud_band(mut commands: Commands) {
|
||||||
|
const BASE_TOP: f32 = 0.0;
|
||||||
|
commands.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(BASE_TOP),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Px(HUD_BAND_HEIGHT),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD - 1),
|
||||||
|
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||||
|
HudBand,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
||||||
|
/// top-left of the play area.
|
||||||
|
///
|
||||||
|
/// Tiers (top to bottom):
|
||||||
|
/// 1. **Primary** — Score (display weight) · Moves · Timer.
|
||||||
|
/// Always visible during gameplay.
|
||||||
|
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
|
||||||
|
/// Draw-cycle indicator. Each cell is empty when not relevant; the
|
||||||
|
/// row collapses visually when all cells are empty.
|
||||||
|
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
|
||||||
|
/// Both penalty counters share `STATE_WARNING` (the audit found
|
||||||
|
/// they were inconsistent: Undos amber, Recycles white).
|
||||||
|
/// 4. **Selection** — keyboard-driven pile selector chip.
|
||||||
|
///
|
||||||
|
/// The audit identified the original single-row layout (10 readouts in
|
||||||
|
/// one horizontal flex row, 5+ colour families competing) as the
|
||||||
|
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||||
|
/// transient items disappear cleanly, and uses the typography scale to
|
||||||
|
/// make Score the visual protagonist.
|
||||||
|
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||||
|
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_score = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_HEADLINE,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_lg = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_body = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let row_node = || Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
||||||
|
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
||||||
|
// Auto-complete, selection chip) can collectively be wider than
|
||||||
|
// the available space and overflow into the action-button column
|
||||||
|
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
||||||
|
// a second line; on a desktop window the rows stay single-line
|
||||||
|
// because the parent column has no width cap and the row never
|
||||||
|
// exceeds the natural line width.
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
align_items: AlignItems::Baseline,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
top: Val::Px(SPACE_2),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
// Cap the column at 50% of viewport so on narrow
|
||||||
|
// (mobile) widths the inner tier rows have a bounded
|
||||||
|
// width to wrap against, and the column can't bleed
|
||||||
|
// into the right-anchored action button row (also
|
||||||
|
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
||||||
|
// wider than any tier row's natural width, so the
|
||||||
|
// visible layout is unaffected.
|
||||||
|
max_width: Val::Percent(50.0),
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
|
HudColumn,
|
||||||
|
))
|
||||||
|
.with_children(|hud| {
|
||||||
|
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||||
|
// Moves and Timer are supporting context (BODY_LG, secondary tone).
|
||||||
|
hud.spawn(row_node()).with_children(|t1| {
|
||||||
|
t1.spawn((
|
||||||
|
HudScore,
|
||||||
|
Tooltip::new("Points earned this game. Hidden in Zen mode."),
|
||||||
|
Text::new("Score: 0"),
|
||||||
|
font_score.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
t1.spawn((
|
||||||
|
HudMoves,
|
||||||
|
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
|
||||||
|
Text::new("Moves: 0"),
|
||||||
|
font_lg.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
t1.spawn((
|
||||||
|
HudTime,
|
||||||
|
Tooltip::new("Time on this game. Counts down in Time Attack."),
|
||||||
|
Text::new("0:00"),
|
||||||
|
font_lg.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 2 — mode context. Each cell is empty until update_hud
|
||||||
|
// populates it (and clears it when no longer relevant), so the
|
||||||
|
// row collapses when nothing in this tier applies.
|
||||||
|
hud.spawn(row_node()).with_children(|t2| {
|
||||||
|
t2.spawn((
|
||||||
|
HudMode,
|
||||||
|
Tooltip::new("Active game mode. Click Modes to switch."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudChallenge,
|
||||||
|
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_INFO),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudDrawCycle,
|
||||||
|
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_INFO),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudWonPreviously,
|
||||||
|
Tooltip::new("You've won this deal before. Same seed in your replay history."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_SUCCESS),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||||
|
// warning hue so they read as the same category ("you took a
|
||||||
|
// penalty"); the auto-complete badge stays success-green.
|
||||||
|
hud.spawn(row_node()).with_children(|t3| {
|
||||||
|
t3.spawn((
|
||||||
|
HudUndos,
|
||||||
|
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_WARNING),
|
||||||
|
));
|
||||||
|
t3.spawn((
|
||||||
|
HudRecycles,
|
||||||
|
Tooltip::new(
|
||||||
|
"Times you've recycled the stock. Three or more unlocks Comeback.",
|
||||||
|
),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_WARNING),
|
||||||
|
));
|
||||||
|
t3.spawn((
|
||||||
|
HudAutoComplete,
|
||||||
|
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_SUCCESS),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 4 — selection chip. Stays in HUD for now; a future
|
||||||
|
// pass can reposition it next to the selected pile.
|
||||||
|
hud.spawn(row_node()).with_children(|t4| {
|
||||||
|
t4.spawn((
|
||||||
|
HudSelection,
|
||||||
|
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body,
|
||||||
|
TextColor(ACCENT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the circular avatar / initials button anchored to the top-right
|
||||||
|
/// of the HUD band. Initial content is seeded from whatever resources are
|
||||||
|
/// available at startup; `update_hud_avatar` replaces the children whenever
|
||||||
|
/// `AvatarResource` or `SettingsResource` later changes.
|
||||||
|
pub(super) fn spawn_hud_avatar(
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 32.0;
|
||||||
|
let id = commands
|
||||||
|
.spawn((
|
||||||
|
HudAvatar,
|
||||||
|
Button,
|
||||||
|
Tooltip::new("Your profile — tap to open."),
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(SPACE_2),
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
width: Val::Px(SIZE),
|
||||||
|
height: Val::Px(SIZE),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
spawn_avatar_child(
|
||||||
|
&mut commands,
|
||||||
|
id,
|
||||||
|
avatar.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-spawns the avatar circle content (image or initials) whenever either
|
||||||
|
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
|
||||||
|
/// image arriving after download and the username changing after login.
|
||||||
|
pub(super) fn update_hud_avatar(
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
q: Query<Entity, With<HudAvatar>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
|
||||||
|
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
|
||||||
|
if !avatar_changed && !settings_changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entity) = q.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
|
spawn_avatar_child(
|
||||||
|
&mut commands,
|
||||||
|
entity,
|
||||||
|
avatar.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populates the avatar container with either the downloaded image or an
|
||||||
|
/// initials fallback disc. Called from both the startup spawn and the
|
||||||
|
/// reactive update system so the rendering logic lives in one place.
|
||||||
|
pub(super) fn spawn_avatar_child(
|
||||||
|
commands: &mut Commands,
|
||||||
|
parent: Entity,
|
||||||
|
avatar: Option<&AvatarResource>,
|
||||||
|
settings: Option<&SettingsResource>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 32.0;
|
||||||
|
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||||
|
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||||
|
commands
|
||||||
|
.entity(parent)
|
||||||
|
.insert(BackgroundColor(ACCENT_PRIMARY));
|
||||||
|
// Image fills the circle container; border_radius clips it to a disc.
|
||||||
|
commands.entity(parent).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
ImageNode::new(handle),
|
||||||
|
Node {
|
||||||
|
width: Val::Px(SIZE),
|
||||||
|
height: Val::Px(SIZE),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let initial = settings
|
||||||
|
.and_then(|s| match &s.0.sync_backend {
|
||||||
|
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
|
||||||
|
SyncBackend::Local => None,
|
||||||
|
})
|
||||||
|
.and_then(|c| c.to_uppercase().next())
|
||||||
|
.unwrap_or('?');
|
||||||
|
// Real initial (logged in) keeps the red accent disc; the '?'
|
||||||
|
// unauthenticated fallback uses a neutral grey so it reads as a
|
||||||
|
// "tap to log in" affordance rather than an error.
|
||||||
|
let disc_bg = if initial == '?' {
|
||||||
|
BG_ELEVATED_HI
|
||||||
|
} else {
|
||||||
|
ACCENT_PRIMARY
|
||||||
|
};
|
||||||
|
commands.entity(parent).insert(BackgroundColor(disc_bg));
|
||||||
|
commands.entity(parent).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(initial.to_string()),
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 14.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the Profile overlay when the avatar button is pressed.
|
||||||
|
pub(super) fn handle_avatar_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
|
||||||
|
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
toggle_profile.write(ToggleProfileRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the action button bar anchored to the top-right of the window.
|
||||||
|
/// Each child is a clickable button mirroring a keyboard accelerator —
|
||||||
|
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
||||||
|
/// are the primary entry point and the hotkeys are optional.
|
||||||
|
///
|
||||||
|
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||||
|
/// because it's the most consequential action; the destructive button sits
|
||||||
|
/// on its own visual edge.
|
||||||
|
pub(super) fn spawn_action_buttons(
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let action_font_size =
|
||||||
|
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: action_font_size,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||||
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||||
|
// Android reports it (frames 1-3); initial value is 0.0.
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(0.0),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
column_gap: ACTION_BAR_COLUMN_GAP,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect {
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
top: VAL_SPACE_2,
|
||||||
|
bottom: VAL_SPACE_2,
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_HUD_BAND),
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
||||||
|
HudActionBar,
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||||
|
// so Tab cycles the action bar in visual reading order.
|
||||||
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||||
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
MenuButton,
|
||||||
|
ACTION_BAR_LABELS[0],
|
||||||
|
None,
|
||||||
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
&font,
|
||||||
|
0,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
UndoButton,
|
||||||
|
ACTION_BAR_LABELS[1],
|
||||||
|
Some("U"),
|
||||||
|
"Take back your last move. Costs points and blocks No Undo.",
|
||||||
|
&font,
|
||||||
|
1,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
PauseButton,
|
||||||
|
ACTION_BAR_LABELS[2],
|
||||||
|
Some("Esc"),
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
&font,
|
||||||
|
2,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HelpButton,
|
||||||
|
ACTION_BAR_LABELS[3],
|
||||||
|
Some("F1"),
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
&font,
|
||||||
|
3,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HintButton,
|
||||||
|
ACTION_BAR_LABELS[4],
|
||||||
|
Some("H"),
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
&font,
|
||||||
|
4,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
ModesButton,
|
||||||
|
ACTION_BAR_LABELS[5],
|
||||||
|
None,
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
&font,
|
||||||
|
5,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
NewGameButton,
|
||||||
|
ACTION_BAR_LABELS[6],
|
||||||
|
Some("N"),
|
||||||
|
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||||
|
&font,
|
||||||
|
6,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a single action button as a child of `row`. Each button shares
|
||||||
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||||
|
/// `paint_action_buttons` can recolour all of them with one query.
|
||||||
|
///
|
||||||
|
/// `order` is the button's index inside the action bar (0 for the
|
||||||
|
/// leftmost). It propagates into the [`Focusable`] this function inserts
|
||||||
|
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
|
||||||
|
///
|
||||||
|
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||||||
|
/// action button ships with one — there is no opt-out — because each button
|
||||||
|
/// represents a player-triggered action and benefits from a one-line
|
||||||
|
/// reminder of what it does.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn spawn_action_button<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
hotkey: Option<&'static str>,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
text_color: Color,
|
||||||
|
) {
|
||||||
|
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||||
|
// touch device — the button itself is the affordance — and they
|
||||||
|
// visibly clutter the narrow-viewport action row. The chevrons on
|
||||||
|
// Menu/Modes remain because they indicate dropdown behaviour.
|
||||||
|
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
hotkey
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let hotkey_font = TextFont {
|
||||||
|
font: font.font.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let (pad, min_w, min_h) = action_button_metrics();
|
||||||
|
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Focusable {
|
||||||
|
group: FocusGroup::Hud,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
padding: pad,
|
||||||
|
min_width: min_w,
|
||||||
|
min_height: min_h,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
spawn_action_button_label(b, label, font, text_color);
|
||||||
|
if let Some(key) = hotkey {
|
||||||
|
// Hotkey hint rendered as a dim caption next to the label —
|
||||||
|
// keeps the keyboard accelerator discoverable without
|
||||||
|
// hijacking the button's primary affordance.
|
||||||
|
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
@@ -39,9 +40,16 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_reflects_game_state() {
|
fn score_reflects_game_state() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
let score = app
|
||||||
|
.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.force_test_score(20);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
assert_eq!(
|
||||||
|
read_hud_text::<HudScore>(&mut app),
|
||||||
|
format!("Score: {score}")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -191,7 +199,9 @@ fn challenge_time_color_zero_is_danger() {
|
|||||||
fn challenge_hud_empty_when_no_daily_resource() {
|
fn challenge_hud_empty_when_no_daily_resource() {
|
||||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -206,7 +216,9 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
|
|||||||
target_score: None,
|
target_score: None,
|
||||||
max_time_secs: Some(300),
|
max_time_secs: Some(300),
|
||||||
});
|
});
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||||
}
|
}
|
||||||
@@ -221,7 +233,9 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
|
|||||||
target_score: Some(4000),
|
target_score: Some(4000),
|
||||||
max_time_secs: None,
|
max_time_secs: None,
|
||||||
});
|
});
|
||||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.set_changed();
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||||
}
|
}
|
||||||
@@ -237,7 +251,10 @@ fn challenge_hud_clears_on_win() {
|
|||||||
max_time_secs: Some(300),
|
max_time_secs: Some(300),
|
||||||
});
|
});
|
||||||
// Mark the game as won — HudChallenge should be empty.
|
// Mark the game as won — HudChallenge should be empty.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.set_test_won(true);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
}
|
}
|
||||||
@@ -383,7 +400,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
|||||||
set_manual_time_step(&mut app, 0.0);
|
set_manual_time_step(&mut app, 0.0);
|
||||||
// Initial state has score=0; bumping by 50 (the threshold)
|
// Initial state has score=0; bumping by 50 (the threshold)
|
||||||
// is the smallest jump that triggers the floater.
|
// is the smallest jump that triggers the floater.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// One floater should now exist.
|
// One floater should now exist.
|
||||||
@@ -404,7 +424,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_floater_despawns_after_full_lifetime() {
|
fn score_floater_despawns_after_full_lifetime() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||||
|
|
||||||
@@ -430,7 +453,10 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
// +5 mirrors a single tableau-to-foundation move; well below
|
// +5 mirrors a single tableau-to-foundation move; well below
|
||||||
// the 50-point threshold so the floater path stays dormant.
|
// the 50-point threshold so the floater path stays dormant.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.force_test_score(5);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScoreFloater>(&mut app),
|
count_with::<ScoreFloater>(&mut app),
|
||||||
@@ -506,7 +532,10 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
|||||||
..Settings::default()
|
..Settings::default()
|
||||||
}));
|
}));
|
||||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
app.world_mut()
|
||||||
|
.resource_mut::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.force_test_score(50);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_with::<ScorePulse>(&mut app),
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
//! Per-frame HUD text/typography/visibility updater systems.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::Suit;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
|
||||||
|
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
|
||||||
|
///
|
||||||
|
/// For example `format_time_limit(300)` returns `"5:00"`.
|
||||||
|
pub fn format_time_limit(secs: u64) -> String {
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Score-change feedback (G2)
|
||||||
|
//
|
||||||
|
// The flow for each Update tick:
|
||||||
|
// 1. `detect_score_change` diffs `GameStateResource.score` against
|
||||||
|
// `PreviousScore`. On any positive delta it inserts/refreshes
|
||||||
|
// `ScorePulse` on the score readout; on a delta ≥
|
||||||
|
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
|
||||||
|
// anchored just below the score.
|
||||||
|
// 2. `advance_score_pulse` ticks the pulse component, applies the
|
||||||
|
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
|
||||||
|
// component on completion.
|
||||||
|
// 3. `advance_score_floater` drifts each floater upward, fades it to
|
||||||
|
// transparent, and despawns it when its lifetime expires.
|
||||||
|
//
|
||||||
|
// The threshold of 50 (a foundation promotion's typical bonus) keeps
|
||||||
|
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
||||||
|
/// current deal's seed + draw_mode + mode triple matches an entry in
|
||||||
|
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
||||||
|
/// (the on-screen "Game won!" cue already conveys victory) and on
|
||||||
|
/// fresh deals the player hasn't won before.
|
||||||
|
///
|
||||||
|
/// Lives in its own system rather than `update_hud` to keep this
|
||||||
|
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
||||||
|
/// enough; threading another marker through every Without filter
|
||||||
|
/// would touch ~10 unrelated queries for no benefit.
|
||||||
|
pub(super) fn update_won_previously(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
// Optional because the HUD plugin's headless tests run without
|
||||||
|
// `StatsPlugin` and therefore without this resource. With the
|
||||||
|
// resource absent there's no history to compare against; the
|
||||||
|
// indicator just stays empty.
|
||||||
|
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||||
|
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = q.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let won_before = !game.0.is_won()
|
||||||
|
&& history.as_ref().is_some_and(|h| {
|
||||||
|
h.0.replays.iter().any(|r| {
|
||||||
|
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let next = if won_before {
|
||||||
|
"\u{2713} Won before"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if text.0 != next {
|
||||||
|
text.0 = next.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_hud(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
time_attack: Option<Res<TimeAttackResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
auto_complete: Option<Res<AutoCompleteState>>,
|
||||||
|
mut score_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut moves_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMoves>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut time_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudTime>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut mode_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMode>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut challenge_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudChallenge>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut undos_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudUndos>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut auto_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudAutoComplete>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut recycles_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudRecycles>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut draw_cycle_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudDrawCycle>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
||||||
|
|
||||||
|
// Score, moves, mode, challenge, and undos only need updating when game state changes.
|
||||||
|
if game.is_changed() {
|
||||||
|
let g = &game.0;
|
||||||
|
let is_zen = g.mode == GameMode::Zen;
|
||||||
|
if let Ok(mut t) = score_q.single_mut() {
|
||||||
|
// Zen mode suppresses score display per spec ("No score display").
|
||||||
|
**t = if is_zen {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("Score: {}", g.score())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = moves_q.single_mut() {
|
||||||
|
**t = format!("Moves: {}", g.move_count());
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = mode_q.single_mut() {
|
||||||
|
**t = match g.mode {
|
||||||
|
GameMode::Classic => match g.draw_mode() {
|
||||||
|
DrawStockConfig::DrawOne => String::new(),
|
||||||
|
DrawStockConfig::DrawThree => "Draw 3".to_string(),
|
||||||
|
},
|
||||||
|
GameMode::Zen => "ZEN".to_string(),
|
||||||
|
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||||
|
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||||
|
GameMode::Difficulty(level) => level.label().to_uppercase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daily challenge constraint (with time-low colour warning) ---
|
||||||
|
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
|
||||||
|
if g.is_won() {
|
||||||
|
**t = String::new();
|
||||||
|
} else if let Some(dc) = daily.as_deref() {
|
||||||
|
**t = challenge_hud_text(dc);
|
||||||
|
if let Some(max_secs) = dc.max_time_secs {
|
||||||
|
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
|
||||||
|
*color = TextColor(challenge_time_color(remaining));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
**t = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Undo count ---
|
||||||
|
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
||||||
|
let count = g.undo_count();
|
||||||
|
if count == 0 {
|
||||||
|
**t = String::new();
|
||||||
|
*color = TextColor(TEXT_PRIMARY);
|
||||||
|
} else {
|
||||||
|
**t = format!("Undos: {count}");
|
||||||
|
// STATE_WARNING signals "you took a penalty" — same hue
|
||||||
|
// as the Recycles counter so they read as one category.
|
||||||
|
*color = TextColor(STATE_WARNING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||||
|
if let Ok(mut t) = recycles_q.single_mut() {
|
||||||
|
**t = if g.recycle_count() > 0 {
|
||||||
|
format!("Recycles: {}", g.recycle_count())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
||||||
|
if let Ok(mut t) = draw_cycle_q.single_mut() {
|
||||||
|
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
|
||||||
|
// Hide when not in Draw-Three or after the game is won.
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let stock_len = g.stock_cards().len();
|
||||||
|
let next_draw = stock_len.min(3);
|
||||||
|
format!("Cycle: {next_draw}/3")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time display: show Time Attack countdown every frame when active;
|
||||||
|
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
|
||||||
|
// every frame so it disappears immediately on the frame Z is pressed.
|
||||||
|
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||||
|
let is_zen = game.0.mode == GameMode::Zen;
|
||||||
|
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||||
|
if update_time {
|
||||||
|
if let Ok(mut t) = time_q.single_mut() {
|
||||||
|
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
|
||||||
|
let remaining = ta.remaining_secs.max(0.0) as u64;
|
||||||
|
let m = remaining / 60;
|
||||||
|
let s = remaining % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
} else {
|
||||||
|
let secs = game.0.elapsed_seconds;
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if is_zen {
|
||||||
|
// Clear the time display immediately whenever Zen mode is active —
|
||||||
|
// do not guard on game.is_changed() so it clears on the same frame
|
||||||
|
// the player presses Z, before any move is made.
|
||||||
|
if let Ok(mut t) = time_q.single_mut() {
|
||||||
|
**t = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-complete badge ---
|
||||||
|
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
|
||||||
|
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||||
|
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
||||||
|
if (ac_changed || game.is_changed())
|
||||||
|
&& let Ok(mut t) = auto_q.single_mut()
|
||||||
|
{
|
||||||
|
**t = if ac_active {
|
||||||
|
"AUTO".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
||||||
|
///
|
||||||
|
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
|
||||||
|
/// or an empty string when no pile is selected. Runs every frame so the
|
||||||
|
/// indicator stays in sync with the selection resource.
|
||||||
|
pub(super) fn update_selection_hud(
|
||||||
|
selection: Option<Res<SelectionState>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut q: Query<&mut Text, With<HudSelection>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut t) = q.single_mut() else { return };
|
||||||
|
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||||
|
None => String::new(),
|
||||||
|
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
|
||||||
|
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
|
||||||
|
Some(g) => foundation_selection_label(*slot, &g.0),
|
||||||
|
// No game resource means we can't probe claimed_suit; show the
|
||||||
|
// slot-based placeholder so the HUD still surfaces the selection.
|
||||||
|
None => format!("▶ Foundation {}", foundation_number(*slot)),
|
||||||
|
},
|
||||||
|
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
|
||||||
|
};
|
||||||
|
**t = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HUD selection label for a foundation slot.
|
||||||
|
///
|
||||||
|
/// When the slot has a claimed suit (any card has landed) the announcement is
|
||||||
|
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||||
|
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
||||||
|
pub(super) fn foundation_selection_label(
|
||||||
|
slot: Foundation,
|
||||||
|
game: &solitaire_core::game_state::GameState,
|
||||||
|
) -> String {
|
||||||
|
let claimed = game
|
||||||
|
.pile(KlondikePile::Foundation(slot))
|
||||||
|
.first()
|
||||||
|
.map(|c| c.0.suit());
|
||||||
|
match claimed {
|
||||||
|
Some(suit) => {
|
||||||
|
let s = match suit {
|
||||||
|
Suit::Clubs => "Clubs",
|
||||||
|
Suit::Diamonds => "Diamonds",
|
||||||
|
Suit::Hearts => "Hearts",
|
||||||
|
Suit::Spades => "Spades",
|
||||||
|
};
|
||||||
|
format!("▶ {s} Foundation")
|
||||||
|
}
|
||||||
|
None => format!("▶ Foundation {}", foundation_number(slot)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn foundation_number(foundation: Foundation) -> u8 {
|
||||||
|
match foundation {
|
||||||
|
Foundation::Foundation1 => 1,
|
||||||
|
Foundation::Foundation2 => 2,
|
||||||
|
Foundation::Foundation3 => 3,
|
||||||
|
Foundation::Foundation4 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||||
|
match tableau {
|
||||||
|
Tableau::Tableau1 => 1,
|
||||||
|
Tableau::Tableau2 => 2,
|
||||||
|
Tableau::Tableau3 => 3,
|
||||||
|
Tableau::Tableau4 => 4,
|
||||||
|
Tableau::Tableau5 => 5,
|
||||||
|
Tableau::Tableau6 => 6,
|
||||||
|
Tableau::Tableau7 => 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||||
|
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
||||||
|
/// to debounce so the toast only appears on the leading edge.
|
||||||
|
pub(super) fn announce_auto_complete(
|
||||||
|
auto_complete: Option<Res<AutoCompleteState>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut was_active: Local<bool>,
|
||||||
|
) {
|
||||||
|
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||||
|
if now_active && !*was_active {
|
||||||
|
toast.write(InfoToastEvent("Auto-completing...".to_string()));
|
||||||
|
}
|
||||||
|
*was_active = now_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the HUD text for the active daily challenge constraints.
|
||||||
|
///
|
||||||
|
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
|
||||||
|
/// score target is set, or an empty string when the challenge has no extra
|
||||||
|
/// constraints.
|
||||||
|
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||||
|
if let Some(secs) = dc.max_time_secs {
|
||||||
|
format!("Limit: {}", format_time_limit(secs))
|
||||||
|
} else if let Some(score) = dc.target_score {
|
||||||
|
format!("Goal: {score} pts")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the colour for the challenge time-limit HUD label based on
|
||||||
|
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
|
||||||
|
/// palette changes for free.
|
||||||
|
///
|
||||||
|
/// | Remaining | Token |
|
||||||
|
/// |-------------|------------------|
|
||||||
|
/// | ≥ 60 s | `STATE_INFO` |
|
||||||
|
/// | 30 – 59 s | `STATE_WARNING` |
|
||||||
|
/// | < 30 s | `STATE_DANGER` |
|
||||||
|
pub fn challenge_time_color(remaining: u64) -> Color {
|
||||||
|
if remaining < 30 {
|
||||||
|
STATE_DANGER
|
||||||
|
} else if remaining < 60 {
|
||||||
|
STATE_WARNING
|
||||||
|
} else {
|
||||||
|
STATE_INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
|
||||||
|
///
|
||||||
|
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
|
||||||
|
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
|
||||||
|
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
|
||||||
|
/// all three items remain on one row inside the 50 %-wide HUD column
|
||||||
|
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
|
||||||
|
/// restored so desktop/tablet layouts are unaffected.
|
||||||
|
type HudScoreFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
|
||||||
|
type HudMovesFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
|
||||||
|
type HudTimeFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
|
||||||
|
|
||||||
|
pub(super) fn update_hud_typography(
|
||||||
|
mut events: MessageReader<WindowResized>,
|
||||||
|
mut score_q: HudScoreFont,
|
||||||
|
mut moves_q: HudMovesFont,
|
||||||
|
mut time_q: HudTimeFont,
|
||||||
|
) {
|
||||||
|
let Some(ev) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (score_size, secondary_size) = if ev.width < 480.0 {
|
||||||
|
(TYPE_BODY_LG, TYPE_CAPTION)
|
||||||
|
} else {
|
||||||
|
(TYPE_HEADLINE, TYPE_BODY_LG)
|
||||||
|
};
|
||||||
|
for mut font in &mut score_q {
|
||||||
|
font.font_size = score_size;
|
||||||
|
}
|
||||||
|
for mut font in &mut moves_q {
|
||||||
|
font.font_size = secondary_size;
|
||||||
|
}
|
||||||
|
for mut font in &mut time_q {
|
||||||
|
font.font_size = secondary_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn apply_hud_visibility(
|
||||||
|
hud_vis: Res<HudVisibility>,
|
||||||
|
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
||||||
|
) {
|
||||||
|
if !hud_vis.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let v = if *hud_vis == HudVisibility::Visible {
|
||||||
|
Visibility::Visible
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
};
|
||||||
|
for mut vis in &mut action_bar {
|
||||||
|
*vis = v;
|
||||||
|
}
|
||||||
|
// The bottom action bar is a pure overlay — it does not claim any
|
||||||
|
// space in the card layout, so no WindowResized event is needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restore_hud_on_modal(
|
||||||
|
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||||
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
) {
|
||||||
|
if !new_scrims.is_empty() {
|
||||||
|
*hud_vis = HudVisibility::Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the action-bar label font size for a given logical window width.
|
||||||
|
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
|
||||||
|
// must share one row. The widest characters are in FiraMono (a
|
||||||
|
// monospace whose advance is ~0.62 of the font size). On a 900
|
||||||
|
// logical-px phone the row budget after bar padding (2*12) and six
|
||||||
|
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
|
||||||
|
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
|
||||||
|
// labels are advance-bound only on very narrow viewports; the real
|
||||||
|
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
|
||||||
|
// at 900 px — comfortably one row with margin to spare — clamped so it
|
||||||
|
// never drops below the 12 px legibility floor or grows past 18 px on
|
||||||
|
// landscape tablets where it would crowd the row again.
|
||||||
|
(window_width / 60.0).clamp(12.0, 18.0)
|
||||||
|
} else {
|
||||||
|
TYPE_BODY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
||||||
|
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
||||||
|
// shortest labels ("New", "Help") shrink to their text rather than
|
||||||
|
// padding the row out past the 900 logical-px viewport. min_height
|
||||||
|
// stays at 44 px to preserve the comfortable touch target.
|
||||||
|
(
|
||||||
|
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||||
|
Val::Px(44.0),
|
||||||
|
Val::Px(44.0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
|
Val::Px(48.0),
|
||||||
|
Val::Px(48.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn spawn_action_button_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
font: &TextFont,
|
||||||
|
text_color: Color,
|
||||||
|
) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
parent.spawn((
|
||||||
|
ActionButtonLabel,
|
||||||
|
Text::new(label),
|
||||||
|
font.clone(),
|
||||||
|
TextColor(text_color),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||||
|
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
||||||
|
/// change or window resize).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(super) fn resize_action_bar_labels(
|
||||||
|
layout: Res<crate::layout::LayoutResource>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
||||||
|
) {
|
||||||
|
let w = windows
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
||||||
|
let new_size = action_bar_font_size(w);
|
||||||
|
for mut font in &mut labels {
|
||||||
|
font.font_size = new_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ use bevy::prelude::*;
|
|||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{MonitorSelection, WindowMode};
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{Card, Suit};
|
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{Card, Suit};
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
@@ -393,7 +394,10 @@ pub fn emit_hint_visuals(
|
|||||||
|
|
||||||
// Find the top face-up card in the source pile and highlight it.
|
// Find the top face-up card in the source pile and highlight it.
|
||||||
let source_cards = pile_cards(game, from);
|
let source_cards = pile_cards(game, from);
|
||||||
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
|
let top_card = source_cards
|
||||||
|
.last()
|
||||||
|
.filter(|(_, face_up)| *face_up)
|
||||||
|
.map(|(c, _)| c.clone());
|
||||||
if let Some(card) = top_card {
|
if let Some(card) = top_card {
|
||||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||||
if card_entity.card == card {
|
if card_entity.card == card {
|
||||||
@@ -830,9 +834,7 @@ fn end_drag(
|
|||||||
let origin_cards = pile_cards(&game.0, &origin);
|
let origin_cards = pile_cards(&game.0, &origin);
|
||||||
if !origin_cards.is_empty() {
|
if !origin_cards.is_empty() {
|
||||||
for card in &drag.cards {
|
for card in &drag.cards {
|
||||||
let Some(stack_index) =
|
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
|
||||||
origin_cards.iter().position(|(c, _)| c == card)
|
|
||||||
else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
@@ -1069,8 +1071,7 @@ fn touch_end_drag(
|
|||||||
let origin_cards = pile_cards(&game.0, &origin);
|
let origin_cards = pile_cards(&game.0, &origin);
|
||||||
if !origin_cards.is_empty() {
|
if !origin_cards.is_empty() {
|
||||||
for card in &drag.cards {
|
for card in &drag.cards {
|
||||||
let Some(stack_index) =
|
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
|
||||||
origin_cards.iter().position(|(c, _)| c == card)
|
|
||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -1173,7 +1174,8 @@ fn card_position(
|
|||||||
y_offset -= layout.card_size.y * step;
|
y_offset -= layout.card_size.y * step;
|
||||||
}
|
}
|
||||||
Vec2::new(base.x, base.y + y_offset)
|
Vec2::new(base.x, base.y + y_offset)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
||||||
|
{
|
||||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||||
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||||
// so clicking the visually rightmost (top) card actually registers — a
|
// so clicking the visually rightmost (top) card actually registers — a
|
||||||
@@ -1247,7 +1249,10 @@ fn find_draggable_at(
|
|||||||
}
|
}
|
||||||
(i, i + 1)
|
(i, i + 1)
|
||||||
};
|
};
|
||||||
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
|
let cards: Vec<Card> = pile_cards[start..end]
|
||||||
|
.iter()
|
||||||
|
.map(|(c, _)| c.clone())
|
||||||
|
.collect();
|
||||||
return Some((pile, start, cards));
|
return Some((pile, start, cards));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1329,13 +1334,13 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
|
|||||||
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
||||||
let source = game.pile_containing_card(card.clone())?;
|
let source = game.pile_containing_card(card.clone())?;
|
||||||
|
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let dest = KlondikePile::Foundation(foundation);
|
let dest = KlondikePile::Foundation(foundation);
|
||||||
if game.can_move_cards(&source, &dest, 1) {
|
if game.can_move_cards(&source, &dest, 1) {
|
||||||
return Some(dest);
|
return Some(dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(&source, &dest, 1) {
|
if game.can_move_cards(&source, &dest, 1) {
|
||||||
return Some(dest);
|
return Some(dest);
|
||||||
@@ -1356,7 +1361,7 @@ pub fn best_tableau_destination_for_stack(
|
|||||||
game: &GameState,
|
game: &GameState,
|
||||||
stack_count: usize,
|
stack_count: usize,
|
||||||
) -> Option<(KlondikePile, usize)> {
|
) -> Option<(KlondikePile, usize)> {
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(from, &dest, stack_count) {
|
if game.can_move_cards(from, &dest, stack_count) {
|
||||||
return Some((dest, stack_count));
|
return Some((dest, stack_count));
|
||||||
@@ -1549,8 +1554,7 @@ fn handle_double_tap(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some((found_card, found_face_up)) =
|
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
|
||||||
pile_cards.iter().find(|(c, _)| c == top_card)
|
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1692,7 +1696,7 @@ pub(crate) fn hint_piles(
|
|||||||
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
||||||
let sources: Vec<KlondikePile> = {
|
let sources: Vec<KlondikePile> = {
|
||||||
let mut s = vec![KlondikePile::Stock];
|
let mut s = vec![KlondikePile::Stock];
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
s.push(KlondikePile::Tableau(tableau));
|
s.push(KlondikePile::Tableau(tableau));
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
@@ -1706,7 +1710,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
|||||||
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let dest = KlondikePile::Foundation(foundation);
|
let dest = KlondikePile::Foundation(foundation);
|
||||||
if game.can_move_cards(from, &dest, 1) {
|
if game.can_move_cards(from, &dest, 1) {
|
||||||
hints.push((*from, dest));
|
hints.push((*from, dest));
|
||||||
@@ -1728,7 +1732,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
|||||||
if already_has_foundation_hint {
|
if already_has_foundation_hint {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(from, &dest, 1) {
|
if game.can_move_cards(from, &dest, 1) {
|
||||||
hints.push((*from, dest));
|
hints.push((*from, dest));
|
||||||
@@ -1742,13 +1746,13 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
|||||||
// should never hint Foundation→Foundation. Here we handle the return path
|
// should never hint Foundation→Foundation. Here we handle the return path
|
||||||
// separately so the guarded `take_from_foundation` rule is respected.
|
// separately so the guarded `take_from_foundation` rule is respected.
|
||||||
if game.take_from_foundation {
|
if game.take_from_foundation {
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let from = KlondikePile::Foundation(foundation);
|
let from = KlondikePile::Foundation(foundation);
|
||||||
let from_pile = pile_cards(game, &from);
|
let from_pile = pile_cards(game, &from);
|
||||||
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(&from, &dest, 1) {
|
if game.can_move_cards(&from, &dest, 1) {
|
||||||
hints.push((from, dest));
|
hints.push((from, dest));
|
||||||
@@ -1782,27 +1786,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
|
||||||
[
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableaus() -> [Tableau; 7] {
|
|
||||||
[
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||||
match tableau {
|
match tableau {
|
||||||
Tableau::Tableau1 => 1,
|
Tableau::Tableau1 => 1,
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
|||||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
let waste = vec![
|
||||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||||
|
];
|
||||||
game.set_test_waste_cards(waste.clone());
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
let top_index = waste.len() - 1; // 2 = the visible top
|
let top_index = waste.len() - 1; // 2 = the visible top
|
||||||
@@ -99,7 +101,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
|||||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
assert_eq!(
|
||||||
|
result.2,
|
||||||
|
vec![waste[top_index].clone()],
|
||||||
|
"drags the top card only"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -176,8 +182,7 @@ fn find_draggable_skips_face_down_cards() {
|
|||||||
// face-up card, but the iterator should skip face-down cards and
|
// face-up card, but the iterator should skip face-down cards and
|
||||||
// the cursor sits above the face-up card's AABB, so the result
|
// the cursor sits above the face-up card's AABB, so the result
|
||||||
// is None.
|
// is None.
|
||||||
let face_down_pos =
|
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
|
||||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||||
}
|
}
|
||||||
@@ -195,8 +200,7 @@ fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
|||||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||||
let face_up_pos =
|
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
|
||||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
@@ -213,18 +217,14 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
|
|||||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||||
game.set_test_tableau_cards(
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
|
||||||
Tableau::Tableau1,
|
|
||||||
vec![king, queen.clone(), jack.clone()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||||
let queen_center =
|
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
|
||||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
@@ -322,7 +322,11 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
|||||||
);
|
);
|
||||||
let (pile, _start, ids) = result.unwrap();
|
let (pile, _start, ids) = result.unwrap();
|
||||||
assert_eq!(pile, KlondikePile::Stock);
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec![four_clubs],
|
||||||
|
"only the top card is draggable from waste"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -558,8 +562,7 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
|||||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||||
let anim =
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(anim.end - target_pos).length() < 1e-6,
|
(anim.end - target_pos).length() < 1e-6,
|
||||||
@@ -577,8 +580,7 @@ fn rejected_drag_animation_targets_origin_resting_position() {
|
|||||||
fn rejected_drag_animation_starts_from_drag_position() {
|
fn rejected_drag_animation_starts_from_drag_position() {
|
||||||
let drag_pos = Vec2::new(640.0, 200.0);
|
let drag_pos = Vec2::new(640.0, 200.0);
|
||||||
let target_pos = Vec2::new(80.0, -120.0);
|
let target_pos = Vec2::new(80.0, -120.0);
|
||||||
let anim =
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
(anim.start - drag_pos).length() < 1e-6,
|
(anim.start - drag_pos).length() < 1e-6,
|
||||||
@@ -601,12 +603,8 @@ fn rejected_drag_animation_starts_from_drag_position() {
|
|||||||
/// the call site honest.
|
/// the call site honest.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_drag_animation_uses_correct_duration() {
|
fn rejected_drag_animation_uses_correct_duration() {
|
||||||
let anim = build_drag_reject_animation(
|
let anim =
|
||||||
Vec2::new(640.0, 200.0),
|
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||||
@@ -620,12 +618,8 @@ fn rejected_drag_animation_uses_correct_duration() {
|
|||||||
/// jittery rather than forgiving.
|
/// jittery rather than forgiving.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_drag_animation_uses_responsive_curve() {
|
fn rejected_drag_animation_uses_responsive_curve() {
|
||||||
let anim = build_drag_reject_animation(
|
let anim =
|
||||||
Vec2::new(640.0, 200.0),
|
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||||
DRAG_Z,
|
|
||||||
Vec2::new(80.0, -120.0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
anim.curve,
|
anim.curve,
|
||||||
MotionCurve::Responsive,
|
MotionCurve::Responsive,
|
||||||
@@ -683,10 +677,16 @@ fn pressing_h_spawns_pending_hint_task() {
|
|||||||
app.init_resource::<HintSolverConfig>();
|
app.init_resource::<HintSolverConfig>();
|
||||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.insert_resource(LayoutResource(
|
app.insert_resource(LayoutResource(compute_layout(
|
||||||
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
Vec2::new(1280.0, 800.0),
|
||||||
));
|
0.0,
|
||||||
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
0.0,
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
42,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
app.add_systems(Update, handle_keyboard_hint);
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
|
||||||
// Simulate the H key being pressed this frame.
|
// Simulate the H key being pressed this frame.
|
||||||
|
|||||||
@@ -862,10 +862,8 @@ fn handle_display_name_confirm(
|
|||||||
.leaderboard_display_name
|
.leaderboard_display_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if let SyncBackend::SolitaireServer {
|
if let SyncBackend::SolitaireServer { ref username, .. } =
|
||||||
ref username,
|
settings.0.sync_backend
|
||||||
..
|
|
||||||
} = settings.0.sync_backend
|
|
||||||
{
|
{
|
||||||
username.chars().take(32).collect()
|
username.chars().take(32).collect()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub mod replay_overlay;
|
|||||||
pub mod replay_playback;
|
pub mod replay_playback;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod safe_area;
|
pub mod safe_area;
|
||||||
|
mod schedule_checks;
|
||||||
pub mod selection_plugin;
|
pub mod selection_plugin;
|
||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
@@ -53,6 +54,8 @@ pub mod sync_plugin;
|
|||||||
pub mod sync_setup_plugin;
|
pub mod sync_setup_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod theme_store_plugin;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod touch_selection_plugin;
|
pub mod touch_selection_plugin;
|
||||||
pub mod ui_focus;
|
pub mod ui_focus;
|
||||||
@@ -175,6 +178,8 @@ pub use theme::{
|
|||||||
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
||||||
ThemeRegistryPlugin, set_theme,
|
ThemeRegistryPlugin, set_theme,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
|
||||||
pub use time_attack_plugin::{
|
pub use time_attack_plugin::{
|
||||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use solitaire_data::{Settings, save_settings_to};
|
|||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
|
use crate::splash_plugin::SplashRoot;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
spawn_modal_header,
|
spawn_modal_header,
|
||||||
@@ -36,7 +37,6 @@ use crate::ui_theme::{
|
|||||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
use crate::splash_plugin::SplashRoot;
|
|
||||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -969,7 +969,10 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,9 +178,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::events::HintVisualEvent;
|
use crate::events::HintVisualEvent;
|
||||||
use crate::input_plugin::HintSolverConfig;
|
use crate::input_plugin::HintSolverConfig;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
/// Build a minimal Bevy app exercising only the polling system
|
/// Build a minimal Bevy app exercising only the polling system
|
||||||
/// and the resources/messages it touches.
|
/// and the resources/messages it touches.
|
||||||
@@ -220,32 +220,11 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
game.set_test_foundation_cards(foundation, Vec::new());
|
game.set_test_foundation_cards(foundation, Vec::new());
|
||||||
}
|
}
|
||||||
for tableau in [
|
for tableau in solitaire_core::TABLEAUS {
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
] {
|
|
||||||
game.set_test_tableau_cards(tableau, Vec::new());
|
game.set_test_tableau_cards(tableau, Vec::new());
|
||||||
}
|
}
|
||||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
let suits = Suit::SUITS;
|
||||||
let ranks_below_king = [
|
let ranks_below_king = &Rank::RANKS[..12]; // everything below King
|
||||||
Rank::Ace,
|
|
||||||
Rank::Two,
|
|
||||||
Rank::Three,
|
|
||||||
Rank::Four,
|
|
||||||
Rank::Five,
|
|
||||||
Rank::Six,
|
|
||||||
Rank::Seven,
|
|
||||||
Rank::Eight,
|
|
||||||
Rank::Nine,
|
|
||||||
Rank::Ten,
|
|
||||||
Rank::Jack,
|
|
||||||
Rank::Queen,
|
|
||||||
];
|
|
||||||
for (foundation, suit) in [
|
for (foundation, suit) in [
|
||||||
Foundation::Foundation1,
|
Foundation::Foundation1,
|
||||||
Foundation::Foundation2,
|
Foundation::Foundation2,
|
||||||
@@ -270,10 +249,7 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.zip(suits.iter())
|
.zip(suits.iter())
|
||||||
{
|
{
|
||||||
game.set_test_tableau_cards(
|
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
|
||||||
tableau,
|
|
||||||
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
game
|
game
|
||||||
}
|
}
|
||||||
@@ -288,9 +264,11 @@ mod tests {
|
|||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||||
@@ -327,9 +305,11 @@ mod tests {
|
|||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.world().resource::<PendingHintTask>().is_pending(),
|
app.world().resource::<PendingHintTask>().is_pending(),
|
||||||
"task is in flight after spawn",
|
"task is in flight after spawn",
|
||||||
@@ -365,18 +345,22 @@ mod tests {
|
|||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
|
|
||||||
// First spawn.
|
// First spawn.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||||
assert!(first_handle_present);
|
assert!(first_handle_present);
|
||||||
|
|
||||||
// Second spawn. The `spawn` helper drops the prior task
|
// Second spawn. The `spawn` helper drops the prior task
|
||||||
// before assigning the new one — at no point are two tasks
|
// before assigning the new one — at no point are two tasks
|
||||||
// in flight.
|
// in flight.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
// Resource still pending (the second task), but the first
|
// Resource still pending (the second task), but the first
|
||||||
// is gone. We can't directly observe the first handle once
|
// is gone. We can't directly observe the first handle once
|
||||||
// it's been overwritten — what we *can* assert is that the
|
// it's been overwritten — what we *can* assert is that the
|
||||||
|
|||||||
@@ -47,9 +47,10 @@ use bevy::input::touch::Touches;
|
|||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||||
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
||||||
@@ -254,13 +255,13 @@ pub fn legal_destinations_for_card(
|
|||||||
game: &GameState,
|
game: &GameState,
|
||||||
) -> Vec<KlondikePile> {
|
) -> Vec<KlondikePile> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let dest = KlondikePile::Foundation(foundation);
|
let dest = KlondikePile::Foundation(foundation);
|
||||||
if game.can_move_cards(source_pile, &dest, 1) {
|
if game.can_move_cards(source_pile, &dest, 1) {
|
||||||
out.push(dest);
|
out.push(dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(source_pile, &dest, 1) {
|
if game.can_move_cards(source_pile, &dest, 1) {
|
||||||
out.push(dest);
|
out.push(dest);
|
||||||
@@ -359,28 +360,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
|
||||||
[
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableaus() -> [Tableau; 7] {
|
|
||||||
[
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||||
///
|
///
|
||||||
/// `half_extents` is the window half-size in world space — icons are clamped
|
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||||
@@ -399,8 +378,10 @@ fn build_radial_destinations(
|
|||||||
.map(|(i, d)| {
|
.map(|(i, d)| {
|
||||||
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||||
let clamped = Vec2::new(
|
let clamped = Vec2::new(
|
||||||
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
raw.x
|
||||||
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||||
|
raw.y
|
||||||
|
.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||||
);
|
);
|
||||||
(d, clamped)
|
(d, clamped)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -246,7 +246,11 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
|
|||||||
/// known card, or `"--"` for an absent top card (empty pile).
|
/// known card, or `"--"` for an absent top card (empty pile).
|
||||||
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
||||||
match card {
|
match card {
|
||||||
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
|
Some((c, _)) => format!(
|
||||||
|
"{}{}",
|
||||||
|
format_rank_short(c.rank()),
|
||||||
|
format_suit_glyph(c.suit())
|
||||||
|
),
|
||||||
None => "--".to_string(),
|
None => "--".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use std::sync::Arc;
|
|||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::Resource;
|
use bevy::prelude::Resource;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use solitaire_core::KlondikePile;
|
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.init_resource::<android::SafeAreaPollTries>()
|
app.add_message::<crate::events::StateChangedEvent>()
|
||||||
|
.init_resource::<android::SafeAreaPollTries>()
|
||||||
.add_systems(Update, android::refresh_insets)
|
.add_systems(Update, android::refresh_insets)
|
||||||
.add_systems(Update, android::rearm_on_resumed);
|
.add_systems(Update, android::rearm_on_resumed)
|
||||||
|
.add_systems(Update, android::refresh_surface_size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +227,7 @@ fn on_app_resumed(
|
|||||||
mod android {
|
mod android {
|
||||||
use super::{AppLifecycle, SafeAreaInsets};
|
use super::{AppLifecycle, SafeAreaInsets};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
|
||||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||||
@@ -299,11 +302,114 @@ mod android {
|
|||||||
) {
|
) {
|
||||||
for event in lifecycle.read() {
|
for event in lifecycle.read() {
|
||||||
if matches!(event, AppLifecycle::WillResume) {
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
|
// Evidence line for #130: winit's Android backend has open
|
||||||
|
// TODOs around forwarding resume notifications, so whether
|
||||||
|
// this ever fires on a given device is an open question.
|
||||||
|
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls the decor view's size via JNI and forces a relayout when it
|
||||||
|
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||||||
|
///
|
||||||
|
/// winit's Android backend does not forward content-rect changes that
|
||||||
|
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||||||
|
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||||||
|
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||||||
|
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||||||
|
/// it, because that event is itself delivered through the same unreliable
|
||||||
|
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||||||
|
/// frames is cheap.
|
||||||
|
///
|
||||||
|
/// On a mismatch it:
|
||||||
|
/// 1. writes the real size into `window.resolution` so the renderer
|
||||||
|
/// reconfigures the surface and systems reading `window.width()` see
|
||||||
|
/// the truth,
|
||||||
|
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||||||
|
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||||
|
/// 3. re-arms the inset poller, because a screen change almost always
|
||||||
|
/// moves the system bars too — covering the "re-poll never fires"
|
||||||
|
/// hole left open in #116,
|
||||||
|
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
|
||||||
|
/// re-renders every card sprite from scratch — belt-and-braces for
|
||||||
|
/// the transient tableau clip of #130, where geometry converged but
|
||||||
|
/// stale card visuals survived the relayout.
|
||||||
|
pub(super) fn refresh_surface_size(
|
||||||
|
mut frame: Local<u32>,
|
||||||
|
mut windows: Query<(Entity, &mut Window)>,
|
||||||
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
|
||||||
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
|
) {
|
||||||
|
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||||
|
|
||||||
|
*frame += 1;
|
||||||
|
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (decor_w, decor_h) = match query_decor_size() {
|
||||||
|
Ok(size) => size,
|
||||||
|
Err(e) => {
|
||||||
|
// One-time note; the bridge simply isn't up yet during the
|
||||||
|
// first frames of a launch.
|
||||||
|
if *frame == POLL_INTERVAL_FRAMES {
|
||||||
|
warn!("safe_area: decor size query failed (will retry): {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if decor_w == 0 || decor_h == 0 {
|
||||||
|
return; // decor view not laid out yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads go through `Deref` and do not trip change detection; only
|
||||||
|
// mutate `window` once a mismatch is confirmed.
|
||||||
|
let cached_w = window.resolution.physical_width();
|
||||||
|
let cached_h = window.resolution.physical_height();
|
||||||
|
if decor_w == cached_w && decor_h == cached_h {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||||||
|
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||||||
|
);
|
||||||
|
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
resize_events.write(WindowResized {
|
||||||
|
window: entity,
|
||||||
|
width: decor_w as f32 / scale,
|
||||||
|
height: decor_h as f32 / scale,
|
||||||
|
});
|
||||||
|
state_events.write(crate::events::StateChangedEvent);
|
||||||
|
poll.0 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical pixel size of the activity's decor view — the ground truth
|
||||||
|
/// for the surface we are actually being displayed on, independent of
|
||||||
|
/// whatever winit last told Bevy.
|
||||||
|
fn query_decor_size() -> Result<(u32, u32), String> {
|
||||||
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
android_jni::with_activity_env(|env, activity| {
|
||||||
|
let window = env
|
||||||
|
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let decor = env
|
||||||
|
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||||||
|
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||||||
|
Ok((w.max(0) as u32, h.max(0) as u32))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use solitaire_data::android_jni;
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Schedule hygiene checks (issue #143).
|
||||||
|
//!
|
||||||
|
//! Bevy runs systems with conflicting data access in nondeterministic order
|
||||||
|
//! unless an ordering edge exists. Nothing enforced this historically, and
|
||||||
|
//! the headless gameplay cluster carries a large legacy backlog of such
|
||||||
|
//! pairs — most benign (event/resource writers that never observably race),
|
||||||
|
//! but unproven. Rather than a permanently red hard-error test or no test
|
||||||
|
//! at all, this is a RATCHET: the count may only go down. New conflicting
|
||||||
|
//! pairs fail CI immediately; the backlog can be triaged incrementally
|
||||||
|
//! (add `.before`/`.after` where order matters, `.ambiguous_with` where it
|
||||||
|
//! provably doesn't, then lower `AMBIGUITY_BASELINE`).
|
||||||
|
//!
|
||||||
|
//! To see the offending pair names, add the `bevy_debug` / debug feature to
|
||||||
|
//! the bevy dev-dependency (system names are stripped otherwise) and set
|
||||||
|
//! `ambiguity_detection: LogLevel::Error` manually.
|
||||||
|
//!
|
||||||
|
//! Scope note: `CoreGamePlugin` (the full composition) performs real
|
||||||
|
//! storage/platform I/O in `build` and cannot run in a unit test; the
|
||||||
|
//! cluster below is the same one the engine's headless behaviour tests use.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::auto_complete_plugin::AutoCompletePlugin;
|
||||||
|
use crate::card_plugin::CardPlugin;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::hud_plugin::HudPlugin;
|
||||||
|
use crate::settings_plugin::SettingsPlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use crate::ui_focus::UiFocusPlugin;
|
||||||
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
|
||||||
|
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
|
||||||
|
/// same day (#143, PRs #146–#149) — this is now a hard gate. If your
|
||||||
|
/// change trips this assertion you have added a pair of systems with
|
||||||
|
/// conflicting data access and no ordering edge: add `.before`/`.after`
|
||||||
|
/// where order matters, or `.ambiguous_with` the relevant domain set
|
||||||
|
/// (BoardVisuals, MarkerVisuals, UiTextFx, HudButtons, writer sets)
|
||||||
|
/// where it provably does not. Do not raise this constant.
|
||||||
|
const AMBIGUITY_BASELINE: usize = 0;
|
||||||
|
|
||||||
|
fn cluster_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(CardPlugin)
|
||||||
|
.add_plugins(HudPlugin)
|
||||||
|
.add_plugins(AutoCompletePlugin)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(SettingsPlugin::headless());
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_schedule_ambiguities_do_not_grow() {
|
||||||
|
let mut app = cluster_app();
|
||||||
|
app.edit_schedule(Update, |s| {
|
||||||
|
s.set_build_settings(ScheduleBuildSettings {
|
||||||
|
ambiguity_detection: LogLevel::Error,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// With LogLevel::Error the first schedule build panics when any
|
||||||
|
// ambiguity exists, and the panic message begins with the pair
|
||||||
|
// count. Catch it and ratchet on that count. (Parsing a panic
|
||||||
|
// message is brittle by design — if a Bevy upgrade rewords it,
|
||||||
|
// this test fails loudly and the parse below needs one-line
|
||||||
|
// maintenance, which is preferable to losing the ratchet.)
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
app.update();
|
||||||
|
}));
|
||||||
|
|
||||||
|
let count = match result {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(payload) => {
|
||||||
|
let msg = payload
|
||||||
|
.downcast_ref::<String>()
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let parsed = msg
|
||||||
|
.split(" pairs of systems")
|
||||||
|
.next()
|
||||||
|
.and_then(|prefix| prefix.split_whitespace().last())
|
||||||
|
.and_then(|n| n.parse::<usize>().ok());
|
||||||
|
parsed.unwrap_or_else(|| {
|
||||||
|
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count, AMBIGUITY_BASELINE,
|
||||||
|
"system-order ambiguities changed from the enforced baseline. \
|
||||||
|
Add .before/.after or .ambiguous_with at the new registration site \
|
||||||
|
(or, if the count legitimately dropped below a nonzero baseline, \
|
||||||
|
lower AMBIGUITY_BASELINE).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
use crate::card_plugin::CardEntityIndex;
|
use crate::card_plugin::CardEntityIndex;
|
||||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||||
@@ -487,8 +487,10 @@ fn handle_selection_keys(
|
|||||||
1
|
1
|
||||||
};
|
};
|
||||||
let start = source_cards.len().saturating_sub(count);
|
let start = source_cards.len().saturating_sub(count);
|
||||||
let lifted_cards: Vec<Card> =
|
let lifted_cards: Vec<Card> = source_cards[start..]
|
||||||
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
|
.iter()
|
||||||
|
.map(|(c, _)| c.clone())
|
||||||
|
.collect();
|
||||||
let Some((bottom, _)) = source_cards.get(start) else {
|
let Some((bottom, _)) = source_cards.get(start) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
|
|||||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||||
/// handler can attempt a foundation move first and fall through to a
|
/// handler can attempt a foundation move first and fall through to a
|
||||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||||
fn try_foundation_dest(
|
fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
||||||
card: &Card,
|
|
||||||
game: &GameState,
|
|
||||||
) -> Option<KlondikePile> {
|
|
||||||
let source = game.pile_containing_card(card.clone())?;
|
let source = game.pile_containing_card(card.clone())?;
|
||||||
for foundation in [
|
for foundation in [
|
||||||
Foundation::Foundation1,
|
Foundation::Foundation1,
|
||||||
@@ -697,13 +696,7 @@ fn update_selection_highlight(
|
|||||||
if let Some(ref pile) = source_pile
|
if let Some(ref pile) = source_pile
|
||||||
&& let Some(card) = top_face_up_card(pile, &game.0)
|
&& let Some(card) = top_face_up_card(pile, &game.0)
|
||||||
{
|
{
|
||||||
spawn_highlight_on_card(
|
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
|
||||||
&mut commands,
|
|
||||||
&card_index,
|
|
||||||
&card,
|
|
||||||
card_size,
|
|
||||||
source_color,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destination highlight while lifted.
|
// Destination highlight while lifted.
|
||||||
@@ -714,13 +707,7 @@ fn update_selection_highlight(
|
|||||||
// in destination-pick mode and the focused index is observable
|
// in destination-pick mode and the focused index is observable
|
||||||
// via the resource.
|
// via the resource.
|
||||||
if let Some(card) = top_face_up_card(dest, &game.0) {
|
if let Some(card) = top_face_up_card(dest, &game.0) {
|
||||||
spawn_highlight_on_card(
|
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
|
||||||
&mut commands,
|
|
||||||
&card_index,
|
|
||||||
&card,
|
|
||||||
card_size,
|
|
||||||
dest_color,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1047,10 +1034,7 @@ mod tests {
|
|||||||
press_key(&mut app, KeyCode::Tab);
|
press_key(&mut app, KeyCode::Tab);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let selected = app
|
let selected = app.world().resource::<SelectionState>().selected_pile;
|
||||||
.world()
|
|
||||||
.resource::<SelectionState>()
|
|
||||||
.selected_pile;
|
|
||||||
// The cycle order starts at Waste, but Waste is empty so the next
|
// The cycle order starts at Waste, but Waste is empty so the next
|
||||||
// available pile (Tableau(0)) is selected.
|
// available pile (Tableau(0)) is selected.
|
||||||
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
@@ -1216,16 +1200,10 @@ mod tests {
|
|||||||
drag.active_touch_id = None;
|
drag.active_touch_id = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let before = app
|
let before = app.world().resource::<SelectionState>().selected_pile;
|
||||||
.world()
|
|
||||||
.resource::<SelectionState>()
|
|
||||||
.selected_pile;
|
|
||||||
press_key(&mut app, KeyCode::Tab);
|
press_key(&mut app, KeyCode::Tab);
|
||||||
app.update();
|
app.update();
|
||||||
let after = app
|
let after = app.world().resource::<SelectionState>().selected_pile;
|
||||||
.world()
|
|
||||||
.resource::<SelectionState>()
|
|
||||||
.selected_pile;
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
before, after,
|
before, after,
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
//! Input handling for the Settings panel: button presses, keyboard
|
||||||
|
//! accelerators, focus attachment, and scrolling.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
use solitaire_data::{
|
||||||
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||||||
|
settings::Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::ui_focus::{FocusGroup, Focusable, FocusedButton};
|
||||||
|
use crate::ui_modal::{ModalButton, ModalScrim};
|
||||||
|
use crate::ui_theme::SPACE_2;
|
||||||
|
|
||||||
|
pub(super) fn handle_volume_keys(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let mut delta = 0.0_f32;
|
||||||
|
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||||
|
delta -= SFX_STEP;
|
||||||
|
}
|
||||||
|
if keys.just_pressed(KeyCode::BracketRight) {
|
||||||
|
delta += SFX_STEP;
|
||||||
|
}
|
||||||
|
if delta == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(delta);
|
||||||
|
if (before - after).abs() < f32::EPSILON {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"SFX volume: {}%",
|
||||||
|
(after * 100.0).round() as i32
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||||
|
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
||||||
|
pub(super) fn toggle_settings_screen(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
) {
|
||||||
|
let button_clicked = requests.read().count() > 0;
|
||||||
|
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||||||
|
screen.0 = !screen.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to button presses inside the Settings panel.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
pub(super) fn handle_settings_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut sfx_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut music_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<MusicVolumeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut draw_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<DrawModeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut theme_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ThemeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut anim_speed_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<AnimSpeedText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut color_blind_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ColorBlindText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut high_contrast_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HighContrastText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut reduce_motion_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ReduceMotionText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SfxDown => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::SfxUp => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicDown => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicUp => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleDrawMode => {
|
||||||
|
settings.0.draw_mode = match settings.0.draw_mode {
|
||||||
|
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||||
|
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = draw_text.single_mut() {
|
||||||
|
**t = draw_mode_label(&settings.0.draw_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::CycleAnimSpeed => {
|
||||||
|
settings.0.animation_speed = match settings.0.animation_speed {
|
||||||
|
AnimSpeed::Normal => AnimSpeed::Fast,
|
||||||
|
AnimSpeed::Fast => AnimSpeed::Instant,
|
||||||
|
AnimSpeed::Instant => AnimSpeed::Normal,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = anim_speed_text.single_mut() {
|
||||||
|
**t = anim_speed_label(&settings.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayDown => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_tooltip_delay_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayUp => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusDown => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_time_bonus_multiplier_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusUp => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_replay_move_interval_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTheme => {
|
||||||
|
settings.0.theme = match settings.0.theme {
|
||||||
|
Theme::Green => Theme::Blue,
|
||||||
|
Theme::Blue => Theme::Dark,
|
||||||
|
Theme::Dark => Theme::Green,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = theme_text.single_mut() {
|
||||||
|
**t = theme_label(&settings.0.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleColorBlind => {
|
||||||
|
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = color_blind_text.single_mut() {
|
||||||
|
**t = color_blind_label(settings.0.color_blind_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleHighContrast => {
|
||||||
|
settings.0.high_contrast_mode = !settings.0.high_contrast_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = high_contrast_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.high_contrast_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleReduceMotion => {
|
||||||
|
settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = reduce_motion_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTouchInputMode => {
|
||||||
|
use solitaire_data::settings::TouchInputMode;
|
||||||
|
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||||
|
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
|
||||||
|
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleAnalytics => {
|
||||||
|
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleSmartDefaultSize => {
|
||||||
|
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_smart_default_size_text` next frame. The
|
||||||
|
// sizer system is gated only at startup, so flipping
|
||||||
|
// this mid-session takes effect on the next launch —
|
||||||
|
// documented on the field in `solitaire_data::Settings`.
|
||||||
|
}
|
||||||
|
SettingsButton::SelectCardBack(idx) => {
|
||||||
|
settings.0.selected_card_back = *idx;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::SelectBackground(idx) => {
|
||||||
|
settings.0.selected_background = *idx;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::SelectTheme(theme_id) => {
|
||||||
|
if settings.0.selected_theme_id != *theme_id {
|
||||||
|
settings.0.selected_theme_id = theme_id.clone();
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::ScanThemes => {
|
||||||
|
// Handled by `handle_scan_themes`.
|
||||||
|
}
|
||||||
|
SettingsButton::SyncNow
|
||||||
|
| SettingsButton::ConnectSync
|
||||||
|
| SettingsButton::DisconnectSync
|
||||||
|
| SettingsButton::DeleteAccount => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
|
SettingsButton::Done => {
|
||||||
|
screen.0 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||||
|
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||||
|
/// Bevy's 16-parameter system limit.
|
||||||
|
pub(super) fn handle_sync_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
||||||
|
crate::events::ThemeStoreOpenRequestEvent,
|
||||||
|
>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SyncNow => {
|
||||||
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::ConnectSync => {
|
||||||
|
// Close settings before the sync-setup modal opens so the
|
||||||
|
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||||
|
screen.0 = false;
|
||||||
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => {
|
||||||
|
// Same shape as ConnectSync: close settings first so the
|
||||||
|
// store modal's own-scrim guard doesn't block.
|
||||||
|
screen.0 = false;
|
||||||
|
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DisconnectSync => {
|
||||||
|
logout_sync.write(SyncLogoutRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DeleteAccount => {
|
||||||
|
delete_account.write(DeleteAccountRequestEvent);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||||
|
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||||
|
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||||
|
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||||||
|
/// [`ModalButton`]) and is filtered out here.
|
||||||
|
///
|
||||||
|
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||||||
|
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||||||
|
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn attach_focusable_to_settings_buttons(
|
||||||
|
mut commands: Commands,
|
||||||
|
new_buttons: Query<
|
||||||
|
(Entity, &SettingsButton),
|
||||||
|
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||||||
|
>,
|
||||||
|
parents: Query<&ChildOf>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
) {
|
||||||
|
for (button, settings_button) in &new_buttons {
|
||||||
|
let mut current = button;
|
||||||
|
let mut scrim_entity: Option<Entity> = None;
|
||||||
|
for _ in 0..32 {
|
||||||
|
if scrims.get(current).is_ok() {
|
||||||
|
scrim_entity = Some(current);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match parents.get(current) {
|
||||||
|
Ok(parent) => current = parent.parent(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(scrim) = scrim_entity {
|
||||||
|
commands.entity(button).insert(Focusable {
|
||||||
|
group: FocusGroup::Modal(scrim),
|
||||||
|
order: settings_button.focus_order(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical padding (logical px) added around the focused button when
|
||||||
|
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||||||
|
/// below the viewport edge.
|
||||||
|
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||||||
|
|
||||||
|
/// When the focused entity sits outside the visible Settings scroll
|
||||||
|
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||||||
|
/// fully visible. No-op when:
|
||||||
|
///
|
||||||
|
/// - `FocusedButton` is `None`
|
||||||
|
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||||||
|
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||||||
|
/// - the focused entity is not a descendant of the
|
||||||
|
/// [`SettingsPanelScrollable`] container
|
||||||
|
///
|
||||||
|
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||||||
|
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||||||
|
/// The focused button's vertical extent is computed from its
|
||||||
|
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||||||
|
/// `ComputedNode.size.y`. Because the scroll container's local
|
||||||
|
/// coordinates run [0, content_height] and the visible window is
|
||||||
|
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||||||
|
/// space Y to container-local Y by subtracting the container's window-
|
||||||
|
/// space top and adding the current scroll offset.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn scroll_focus_into_view(
|
||||||
|
focused: Res<FocusedButton>,
|
||||||
|
parents: Query<&ChildOf>,
|
||||||
|
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||||||
|
mut containers: Query<
|
||||||
|
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||||||
|
With<SettingsPanelScrollable>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let Some(target) = focused.0 else { return };
|
||||||
|
// Gather button geometry.
|
||||||
|
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk ancestors looking for the scroll container. Bounded to keep
|
||||||
|
// a malformed hierarchy from hanging the system.
|
||||||
|
let mut current = target;
|
||||||
|
let mut container_entity: Option<Entity> = None;
|
||||||
|
for _ in 0..32 {
|
||||||
|
if containers.get(current).is_ok() {
|
||||||
|
container_entity = Some(current);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match parents.get(current) {
|
||||||
|
Ok(parent) => current = parent.parent(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(container) = container_entity else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||||||
|
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||||||
|
// so convert via `inverse_scale_factor` before we write.
|
||||||
|
let inv = target_node.inverse_scale_factor;
|
||||||
|
let target_height = target_node.size().y;
|
||||||
|
let target_centre_y = target_transform.translation.y;
|
||||||
|
let target_top = target_centre_y - target_height * 0.5;
|
||||||
|
let target_bottom = target_centre_y + target_height * 0.5;
|
||||||
|
|
||||||
|
let container_height = container_node.size().y;
|
||||||
|
let container_top = container_transform.translation.y - container_height * 0.5;
|
||||||
|
|
||||||
|
// Convert button window-space Y to container-local Y. The container
|
||||||
|
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||||||
|
// by physical-per-logical to compare with physical pixel extents.
|
||||||
|
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||||||
|
let viewport_top = container_top + scroll_phys;
|
||||||
|
let viewport_bottom = viewport_top + container_height;
|
||||||
|
|
||||||
|
// Layout may not have run yet (zero size on first frame) — no
|
||||||
|
// sensible scroll target until the container has dimensions.
|
||||||
|
if container_height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||||||
|
if target_top < viewport_top {
|
||||||
|
// Button extends above the viewport — scroll up.
|
||||||
|
let new_top = target_top - pad_phys;
|
||||||
|
let delta = new_top - viewport_top;
|
||||||
|
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||||
|
} else if target_bottom > viewport_bottom {
|
||||||
|
// Button extends below the viewport — scroll down.
|
||||||
|
let new_bottom = target_bottom + pad_phys;
|
||||||
|
let delta = new_bottom - viewport_bottom;
|
||||||
|
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||||
|
///
|
||||||
|
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||||
|
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||||||
|
/// scrolls past the top.
|
||||||
|
pub(super) fn scroll_settings_panel(
|
||||||
|
mut scroll_evr: MessageReader<MouseWheel>,
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||||||
|
) {
|
||||||
|
if !screen.0 {
|
||||||
|
scroll_evr.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta_y: f32 = scroll_evr
|
||||||
|
.read()
|
||||||
|
.map(|ev| match ev.unit {
|
||||||
|
MouseScrollUnit::Line => ev.y * 50.0,
|
||||||
|
MouseScrollUnit::Pixel => ev.y,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if delta_y == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut sp in scrollables.iter_mut() {
|
||||||
|
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::ui_focus::{FocusRow, Focusable};
|
||||||
|
use crate::ui_modal::ModalButton;
|
||||||
|
use crate::ui_tooltip::Tooltip;
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -326,9 +329,7 @@ fn settings_buttons_carry_tooltip() {
|
|||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.find_map(|(btn, tip)| {
|
.find_map(|(btn, tip)| matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()))
|
||||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
|
||||||
})
|
|
||||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
connect_tip.as_ref(),
|
connect_tip.as_ref(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
|||||||
|
//! Per-frame value-text updater systems and their label helpers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
use solitaire_data::{AnimSpeed, settings::Theme};
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
use crate::theme::ThemeThumbnailCache;
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||||||
|
|
||||||
|
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||||
|
/// despawns it when it becomes `false`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_settings_panel_visibility(
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||||
|
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||||
|
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||||
|
mut commands: Commands,
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
|
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||||
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
|
) {
|
||||||
|
if !screen.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if screen.0 {
|
||||||
|
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||||
|
let status_label = sync_status.map_or_else(
|
||||||
|
|| "Status: local only".to_string(),
|
||||||
|
|s| sync_status_label(&s.0),
|
||||||
|
);
|
||||||
|
let unlocked_backs = progress
|
||||||
|
.as_ref()
|
||||||
|
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||||
|
let unlocked_bgs = progress
|
||||||
|
.as_ref()
|
||||||
|
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||||||
|
// Snapshot themes by id, display_name and (optional)
|
||||||
|
// thumbnail pair so spawn_settings_panel doesn't have to
|
||||||
|
// know about the registry / cache shapes. Empty when
|
||||||
|
// ThemeRegistryPlugin isn't installed (tests under
|
||||||
|
// MinimalPlugins) — the picker row simply won't render.
|
||||||
|
// Missing thumbnails (cache not ready, or partial user
|
||||||
|
// theme) leave `thumbnails: None` so the chip renders its
|
||||||
|
// plain-text fallback instead of a broken sprite.
|
||||||
|
let themes: Vec<ThemePickerEntry> = theme_registry
|
||||||
|
.as_deref()
|
||||||
|
.map(|r| {
|
||||||
|
r.iter()
|
||||||
|
.map(|e| ThemePickerEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
display_name: e.display_name.clone(),
|
||||||
|
thumbnails: theme_thumbs
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|c| c.get(&e.id))
|
||||||
|
.filter(|p| p.is_fully_populated())
|
||||||
|
.cloned(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
// The active card-art theme can supply its own back image —
|
||||||
|
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||||||
|
// populated the legacy "Card Back" picker has no visible
|
||||||
|
// effect, so we render it muted with an explanatory caption
|
||||||
|
// rather than letting the player click swatches that do
|
||||||
|
// nothing. Absent under `MinimalPlugins`; treated as
|
||||||
|
// "no override" in that case.
|
||||||
|
let theme_overrides_back = card_images
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|cs| cs.theme_back.is_some());
|
||||||
|
spawn_settings_panel(
|
||||||
|
&mut commands,
|
||||||
|
&settings.0,
|
||||||
|
&status_label,
|
||||||
|
unlocked_backs,
|
||||||
|
unlocked_bgs,
|
||||||
|
&themes,
|
||||||
|
scroll_pos.0,
|
||||||
|
font_res.as_deref(),
|
||||||
|
theme_overrides_back,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Save the current scroll offset before despawning the panel.
|
||||||
|
if let Ok(sp) = scroll_nodes.single() {
|
||||||
|
scroll_pos.0 = sp.0.y;
|
||||||
|
}
|
||||||
|
for entity in &panels {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the sync-status text node current while the panel is open.
|
||||||
|
pub(super) fn update_sync_status_text(
|
||||||
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
|
||||||
|
) {
|
||||||
|
let Some(status) = sync_status else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !status.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let label = sync_status_label(&status.0);
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_card_back_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<CardBackText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = card_back_label(settings.0.selected_card_back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_background_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = background_label(settings.0.selected_background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_anim_speed_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = anim_speed_label(&settings.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_color_blind_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = color_blind_label(settings.0.color_blind_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_high_contrast_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<HighContrastText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.high_contrast_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints `BorderColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current border colour and
|
||||||
|
/// only mutates when different so Bevy's change-detection
|
||||||
|
/// doesn't trigger repaints every frame.
|
||||||
|
///
|
||||||
|
/// Spec at `design-system.md` §Accessibility (#2): under HC,
|
||||||
|
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
|
||||||
|
/// modal panels, popover edges, and focus-ring carriers stay
|
||||||
|
/// legible on low-quality displays / for low-vision users.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the modal scaffold's card border
|
||||||
|
/// (`ui_modal::spawn_modal`). More sites can be tagged in
|
||||||
|
/// follow-ups by adding `HighContrastBorder::with_default(...)`
|
||||||
|
/// to their spawn tuple.
|
||||||
|
pub(super) fn update_high_contrast_borders(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut border) in borders.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
BORDER_SUBTLE_HC
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
// Only mutate when actually different — avoids per-frame
|
||||||
|
// change-detection churn. `border.left` is representative
|
||||||
|
// because every tagged site uses `BorderColor::all(...)`.
|
||||||
|
if border.left != target {
|
||||||
|
*border = BorderColor::all(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints `BackgroundColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current background and only
|
||||||
|
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||||
|
/// repaints every frame.
|
||||||
|
///
|
||||||
|
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||||
|
/// same change-suppression idiom, different colour channel —
|
||||||
|
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||||
|
/// separators that paint their shape directly rather than via a
|
||||||
|
/// `BorderColor` on a wider Node.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||||
|
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||||
|
///
|
||||||
|
/// More sites can be tagged in follow-ups by adding
|
||||||
|
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||||
|
pub(super) fn update_high_contrast_backgrounds(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
marker.hc_color
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
if bg.0 != target {
|
||||||
|
*bg = BackgroundColor(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_reduce_motion_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.reduce_motion_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_touch_input_mode_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = touch_input_mode_label(&settings.0.touch_input_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Winnable deals only" toggle value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||||||
|
/// hand-edited `settings.json` reload, etc.).
|
||||||
|
pub(super) fn update_winnable_deals_only_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Share usage data" toggle value in the Privacy section
|
||||||
|
/// whenever `SettingsResource` changes.
|
||||||
|
pub(super) fn update_analytics_enabled_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.analytics_enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Smart window size" toggle value whenever
|
||||||
|
/// `SettingsResource` changes. The flag is stored negatively as
|
||||||
|
/// `disable_smart_default_size`, so the label inverts.
|
||||||
|
pub(super) fn update_smart_default_size_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<SmartDefaultSizeText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = smart_default_size_label(!settings.0.disable_smart_default_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live tooltip-delay value in the Gameplay section
|
||||||
|
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||||||
|
/// settings.json reload, etc.).
|
||||||
|
pub(super) fn update_tooltip_delay_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live time-bonus-multiplier value in the Gameplay
|
||||||
|
/// section whenever `SettingsResource` changes.
|
||||||
|
pub(super) fn update_time_bonus_multiplier_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = time_bonus_label(settings.0.time_bonus_multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live replay-playback per-move-interval value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||||
|
/// hand-edited settings.json reload, etc.).
|
||||||
|
pub(super) fn update_replay_move_interval_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn card_back_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn background_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sync_status_label(status: &SyncStatus) -> String {
|
||||||
|
match status {
|
||||||
|
SyncStatus::Idle => "Status: idle".to_string(),
|
||||||
|
SyncStatus::Syncing => "Status: syncing…".to_string(),
|
||||||
|
SyncStatus::LastSynced(t) => {
|
||||||
|
let secs = chrono::Utc::now()
|
||||||
|
.signed_duration_since(*t)
|
||||||
|
.num_seconds()
|
||||||
|
.max(0);
|
||||||
|
if secs < 60 {
|
||||||
|
format!("Last synced: {secs}s ago")
|
||||||
|
} else {
|
||||||
|
format!("Last synced: {}m ago", secs / 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||||||
|
match mode {
|
||||||
|
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||||||
|
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String {
|
||||||
|
match speed {
|
||||||
|
AnimSpeed::Normal => "Normal".into(),
|
||||||
|
AnimSpeed::Fast => "Fast".into(),
|
||||||
|
AnimSpeed::Instant => "Instant".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn theme_label(theme: &Theme) -> String {
|
||||||
|
match theme {
|
||||||
|
Theme::Green => "Green".into(),
|
||||||
|
Theme::Blue => "Blue".into(),
|
||||||
|
Theme::Dark => "Dark".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn color_blind_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic ON/OFF label shared by the high-contrast and reduce-
|
||||||
|
/// motion accessibility toggles. Same format as
|
||||||
|
/// [`color_blind_label`] / [`winnable_deals_only_label`] —
|
||||||
|
/// keeping all simple boolean toggle rows visually uniform.
|
||||||
|
pub(super) fn on_off_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display string for the "Winnable deals only" toggle. Mirrors
|
||||||
|
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
|
||||||
|
/// with the rest of the Gameplay-section toggles.
|
||||||
|
pub(super) fn winnable_deals_only_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
|
||||||
|
use solitaire_data::settings::TouchInputMode;
|
||||||
|
match mode {
|
||||||
|
TouchInputMode::OneTap => "One-tap".into(),
|
||||||
|
TouchInputMode::TapToSelect => "Tap to select".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display string for the "Smart window size" toggle. The argument
|
||||||
|
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||||
|
/// `disable_smart_default_size` field) so reading the label gives
|
||||||
|
/// the player intuitive ON/OFF semantics.
|
||||||
|
pub(super) fn smart_default_size_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the tooltip-hover delay for display in the Settings panel.
|
||||||
|
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
|
||||||
|
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
|
||||||
|
pub(super) fn tooltip_delay_label(secs: f32) -> String {
|
||||||
|
if secs <= 0.0 {
|
||||||
|
"Instant".into()
|
||||||
|
} else {
|
||||||
|
format!("{secs:.1} s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the cosmetic time-bonus multiplier for display in the
|
||||||
|
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
|
||||||
|
/// time-bonus row will be hidden; any other value prints as
|
||||||
|
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
|
||||||
|
pub(super) fn time_bonus_label(value: f32) -> String {
|
||||||
|
if value <= 0.0 {
|
||||||
|
"Off".into()
|
||||||
|
} else {
|
||||||
|
format!("{value:.1}×")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the replay-playback per-move interval for display in the
|
||||||
|
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||||||
|
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||||||
|
/// using two decimal places because the step is 0.05 s.
|
||||||
|
pub(super) fn replay_move_interval_label(secs: f32) -> String {
|
||||||
|
format!("{secs:.2} s/move")
|
||||||
|
}
|
||||||
@@ -1500,10 +1500,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn zen_win_event_updates_zen_best_score_only() {
|
fn zen_win_event_updates_zen_best_score_only() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||||
.resource_mut::<GameStateResource>()
|
solitaire_core::game_state::GameMode::Zen;
|
||||||
.0
|
|
||||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 1800,
|
score: 1800,
|
||||||
|
|||||||
@@ -272,12 +272,21 @@ fn poll_pull_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last-schedule system: starts a best-effort push of the current local state
|
/// Upper bound on how long [`push_on_exit`] may block the closing app.
|
||||||
/// on [`AppExit`] without blocking the Bevy main thread.
|
/// Long enough for one healthy round-trip; short enough that quitting
|
||||||
|
/// never feels hung when the server is unreachable.
|
||||||
|
const EXIT_PUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Last-schedule system: pushes the current local state on [`AppExit`],
|
||||||
|
/// blocking the main thread for at most [`EXIT_PUSH_TIMEOUT`].
|
||||||
///
|
///
|
||||||
/// The detached task may be cut short by process teardown, so local atomic
|
/// This deliberately blocks: the previous detached-task version was almost
|
||||||
/// persistence remains the durable source of truth even if the final remote
|
/// always cut short by process teardown, so the final session's push
|
||||||
/// push does not complete.
|
/// silently never happened (2026-07-06 review, finding M3). A bounded wait
|
||||||
|
/// during the app's final frame is invisible to the player and lets the
|
||||||
|
/// round-trip actually complete on a healthy network. On timeout or error
|
||||||
|
/// the push is skipped — local atomic persistence remains the durable
|
||||||
|
/// source of truth and the next launch's pull/push converges.
|
||||||
fn push_on_exit(
|
fn push_on_exit(
|
||||||
mut exit_events: MessageReader<AppExit>,
|
mut exit_events: MessageReader<AppExit>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
@@ -292,16 +301,17 @@ fn push_on_exit(
|
|||||||
exit_events.clear();
|
exit_events.clear();
|
||||||
|
|
||||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let provider = provider.0.clone();
|
let result = rt.0.block_on(async {
|
||||||
let rt = rt.0.clone();
|
tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
|
||||||
AsyncComputeTaskPool::get()
|
});
|
||||||
.spawn(async move {
|
match result {
|
||||||
match rt.block_on(provider.push(&payload)) {
|
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
|
||||||
Ok(_) | Err(SyncError::UnsupportedPlatform) => {}
|
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
|
||||||
Err(e) => warn!("sync push on exit failed: {e}"),
|
Err(_) => warn!(
|
||||||
}
|
"sync push on exit timed out after {}s; will sync on next launch",
|
||||||
})
|
EXIT_PUSH_TIMEOUT.as_secs()
|
||||||
.detach();
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update-schedule system: on each `GameWonEvent` push the just-completed
|
/// Update-schedule system: on each `GameWonEvent` push the just-completed
|
||||||
@@ -657,9 +667,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// In-memory contract: replays[0].share_url is now Some(url).
|
// In-memory contract: replays[0].share_url is now Some(url).
|
||||||
let live = app
|
let live = app.world().resource::<ReplayHistoryResource>();
|
||||||
.world()
|
|
||||||
.resource::<ReplayHistoryResource>();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
||||||
Some(url.clone()),
|
Some(url.clone()),
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::Suit;
|
use solitaire_core::Suit;
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
|
|
||||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::hud_plugin::HudVisibility;
|
use crate::hud_plugin::HudVisibility;
|
||||||
use crate::layout::{
|
use crate::layout::{
|
||||||
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
||||||
@@ -83,6 +85,13 @@ pub struct HintPileHighlight {
|
|||||||
/// Registers the table background and pile-marker rendering.
|
/// Registers the table background and pile-marker rendering.
|
||||||
pub struct TablePlugin;
|
pub struct TablePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the pile-marker painter chain (theme, hint highlights,
|
||||||
|
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
|
||||||
|
/// systems that touch `Visibility` on UI entities declare themselves
|
||||||
|
/// ambiguous with it (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MarkerVisuals;
|
||||||
|
|
||||||
impl Plugin for TablePlugin {
|
impl Plugin for TablePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register WindowResized so the plugin works under MinimalPlugins in
|
// Register WindowResized so the plugin works under MinimalPlugins in
|
||||||
@@ -99,10 +108,18 @@ impl Plugin for TablePlugin {
|
|||||||
(
|
(
|
||||||
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
||||||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||||||
apply_theme_on_settings_change,
|
// Marker painters: deterministic chain after the card
|
||||||
apply_hint_pile_highlight,
|
// paint pipeline — markers and cards share Sprite/
|
||||||
tick_hint_pile_highlights,
|
// Transform access (#143).
|
||||||
sync_pile_marker_visibility,
|
(
|
||||||
|
apply_theme_on_settings_change,
|
||||||
|
apply_hint_pile_highlight,
|
||||||
|
tick_hint_pile_highlights,
|
||||||
|
sync_pile_marker_visibility.after(GameMutation),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(MarkerVisuals)
|
||||||
|
.after(crate::card_plugin::BoardVisuals),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,10 +297,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
|||||||
|
|
||||||
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
|
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
|
||||||
piles.push(KlondikePile::Stock);
|
piles.push(KlondikePile::Stock);
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
piles.push(KlondikePile::Foundation(foundation));
|
piles.push(KlondikePile::Foundation(foundation));
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
piles.push(KlondikePile::Tableau(tableau));
|
piles.push(KlondikePile::Tableau(tableau));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +379,19 @@ fn on_window_resized(
|
|||||||
(&mut Sprite, &mut Transform),
|
(&mut Sprite, &mut Transform),
|
||||||
(With<TableBackground>, Without<PileMarker>),
|
(With<TableBackground>, Without<PileMarker>),
|
||||||
>,
|
>,
|
||||||
mut markers: Query<(&PileMarker, &mut Sprite, &mut Transform), Without<TableBackground>>,
|
mut markers: Query<
|
||||||
|
(&PileMarker, &mut Sprite, &mut Transform, Option<&Children>),
|
||||||
|
Without<TableBackground>,
|
||||||
|
>,
|
||||||
|
mut marker_outlines: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
Without<PileMarker>,
|
||||||
|
Without<TableBackground>,
|
||||||
|
Without<Text2d>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
|
||||||
) {
|
) {
|
||||||
let Some(ev) = events.read().last() else {
|
let Some(ev) = events.read().last() else {
|
||||||
return;
|
return;
|
||||||
@@ -373,6 +402,14 @@ fn on_window_resized(
|
|||||||
let safe_area_top = insets.top / scale;
|
let safe_area_top = insets.top / scale;
|
||||||
let safe_area_bottom = insets.bottom / scale;
|
let safe_area_bottom = insets.bottom / scale;
|
||||||
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
||||||
|
// Android surface sizes after fold/unfold are unreliable (winit does not
|
||||||
|
// forward every content-rect change), so make each relayout observable
|
||||||
|
// in logcat — this is the primary evidence channel for foldable bugs.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
info!(
|
||||||
|
"layout: resize to {:.0}x{:.0} (insets top={:.0} bottom={:.0}, hud={hud_visible})",
|
||||||
|
window_size.x, window_size.y, insets.top, insets.bottom
|
||||||
|
);
|
||||||
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
||||||
|
|
||||||
// compute_layout sizes the fan for a worst-case column; refine it to the
|
// compute_layout sizes the fan for a worst-case column; refine it to the
|
||||||
@@ -394,11 +431,26 @@ fn on_window_resized(
|
|||||||
transform.translation.y = 0.0;
|
transform.translation.y = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (marker, mut sprite, mut transform) in &mut markers {
|
for (marker, mut sprite, mut transform, children) in &mut markers {
|
||||||
if let Some(pos) = new_layout.pile_positions.get(&marker.0) {
|
if let Some(pos) = new_layout.pile_positions.get(&marker.0) {
|
||||||
sprite.custom_size = Some(new_layout.card_size);
|
sprite.custom_size = Some(new_layout.card_size);
|
||||||
transform.translation.x = pos.x;
|
transform.translation.x = pos.x;
|
||||||
transform.translation.y = pos.y;
|
transform.translation.y = pos.y;
|
||||||
|
// Children carry the outline frame and the "A"/"K" watermark.
|
||||||
|
// They are sized at spawn from that layout's card size, so they
|
||||||
|
// must be re-derived here too or a resize (fold/unfold, rotation)
|
||||||
|
// leaves them at the stale size — visible as oversized grey
|
||||||
|
// frames on empty piles.
|
||||||
|
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||||
|
let font_size = new_layout.card_size.x * 0.28;
|
||||||
|
for child in children.into_iter().flatten() {
|
||||||
|
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
|
||||||
|
outline.custom_size = Some(outline_size);
|
||||||
|
}
|
||||||
|
if let Ok(mut label) = marker_labels.get_mut(*child) {
|
||||||
|
label.font_size = font_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,31 +596,11 @@ fn pile_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
|
||||||
[
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableaus() -> [Tableau; 7] {
|
|
||||||
[
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use solitaire_core::{Foundation, Tableau};
|
||||||
|
|
||||||
/// Minimal headless app — omits windowing so pile markers are spawned with
|
/// Minimal headless app — omits windowing so pile markers are spawned with
|
||||||
/// the default 1280×800 layout and no camera is created.
|
/// the default 1280×800 layout and no camera is created.
|
||||||
@@ -623,6 +655,61 @@ mod tests {
|
|||||||
assert_eq!(types.len(), 12);
|
assert_eq!(types.len(), 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_updates_marker_outline_and_watermark_children() {
|
||||||
|
// Regression (found on Galaxy Fold 7, v0.41.0): `on_window_resized`
|
||||||
|
// resized the marker fill sprite but never its children, so after a
|
||||||
|
// fold/unfold the outline frame and "A"/"K" watermark stayed at
|
||||||
|
// spawn-time size — rendering as oversized grey slabs on empty piles.
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Shrink the window well below the 1280×800 spawn default.
|
||||||
|
app.world_mut().write_message(WindowResized {
|
||||||
|
window: Entity::PLACEHOLDER,
|
||||||
|
width: 640.0,
|
||||||
|
height: 480.0,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let card_size = app.world().resource::<LayoutResource>().0.card_size;
|
||||||
|
let expected_outline = card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||||
|
let expected_font = card_size.x * 0.28;
|
||||||
|
|
||||||
|
let marker_children: Vec<Entity> = {
|
||||||
|
let mut q = app.world_mut().query::<(&PileMarker, &Children)>();
|
||||||
|
q.iter(app.world())
|
||||||
|
.flat_map(|(_, c)| c.iter().collect::<Vec<_>>())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!marker_children.is_empty(),
|
||||||
|
"pile markers must have outline/watermark children"
|
||||||
|
);
|
||||||
|
let mut outlines = 0;
|
||||||
|
let mut labels = 0;
|
||||||
|
for child in marker_children {
|
||||||
|
if let Some(sprite) = app.world().entity(child).get::<Sprite>() {
|
||||||
|
assert_eq!(
|
||||||
|
sprite.custom_size,
|
||||||
|
Some(expected_outline),
|
||||||
|
"outline child must track the resized card size",
|
||||||
|
);
|
||||||
|
outlines += 1;
|
||||||
|
}
|
||||||
|
if let Some(font) = app.world().entity(child).get::<TextFont>() {
|
||||||
|
assert!(
|
||||||
|
(font.font_size - expected_font).abs() < 0.01,
|
||||||
|
"watermark font must track the resized card size",
|
||||||
|
);
|
||||||
|
labels += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
|
||||||
|
assert!(
|
||||||
|
labels >= 11,
|
||||||
|
"tableau + foundation markers carry watermarks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pile_markers_hide_when_pile_is_occupied() {
|
fn pile_markers_hide_when_pile_is_occupied() {
|
||||||
// After a fresh deal: the 7 tableau piles + the stock pile are
|
// After a fresh deal: the 7 tableau piles + the stock pile are
|
||||||
@@ -856,10 +943,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_symbol_all_four_are_distinct() {
|
fn suit_symbol_all_four_are_distinct() {
|
||||||
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
|
||||||
.iter()
|
|
||||||
.map(suit_symbol)
|
|
||||||
.collect();
|
|
||||||
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
||||||
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,22 +62,10 @@ impl CardKey {
|
|||||||
/// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
|
/// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
|
||||||
/// Used to enumerate the manifest's required entries.
|
/// Used to enumerate the manifest's required entries.
|
||||||
pub fn all() -> impl Iterator<Item = CardKey> {
|
pub fn all() -> impl Iterator<Item = CardKey> {
|
||||||
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
// Order-independent enumeration — consumers check completeness and
|
||||||
const RANKS: [Rank; 13] = [
|
// round-trips, never positions.
|
||||||
Rank::Ace,
|
const SUITS: [Suit; 4] = Suit::SUITS;
|
||||||
Rank::Two,
|
const RANKS: [Rank; 13] = Rank::RANKS;
|
||||||
Rank::Three,
|
|
||||||
Rank::Four,
|
|
||||||
Rank::Five,
|
|
||||||
Rank::Six,
|
|
||||||
Rank::Seven,
|
|
||||||
Rank::Eight,
|
|
||||||
Rank::Nine,
|
|
||||||
Rank::Ten,
|
|
||||||
Rank::Jack,
|
|
||||||
Rank::Queen,
|
|
||||||
Rank::King,
|
|
||||||
];
|
|
||||||
SUITS
|
SUITS
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r)))
|
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r)))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use solitaire_core::{Rank, Suit};
|
|||||||
use crate::assets::{
|
use crate::assets::{
|
||||||
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
|
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
|
||||||
};
|
};
|
||||||
use crate::card_plugin::CardImageSet;
|
use crate::card_plugin::{CardImageSet, rank_index, suit_index};
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
|
|
||||||
use super::loader::CardThemeLoader;
|
use super::loader::CardThemeLoader;
|
||||||
@@ -116,24 +116,32 @@ impl Plugin for ThemePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves the manifest URL for `theme_id`: the bundled themes
|
||||||
|
/// (`"dark"`, `"classic"`) load from the embedded source; any other id
|
||||||
|
/// is a user theme served from the `themes://` directory source.
|
||||||
|
fn theme_manifest_url(theme_id: &str) -> String {
|
||||||
|
bundled_theme_url(theme_id)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("themes://{theme_id}/theme.ron"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Kicks off the initial theme load — the one named by
|
/// Kicks off the initial theme load — the one named by
|
||||||
/// `Settings::selected_theme_id` if available, falling back to the
|
/// `Settings::selected_theme_id` if available, falling back to the
|
||||||
/// embedded default. The actual rasterisation runs asynchronously on
|
/// fresh-install default from `solitaire_data::Settings`. The actual
|
||||||
/// the asset task pool; the sync system below picks up the
|
/// rasterisation runs asynchronously on the asset task pool; the sync
|
||||||
/// `LoadedWithDependencies` event when every face + back is ready.
|
/// system below picks up the `LoadedWithDependencies` event when every
|
||||||
|
/// face + back is ready.
|
||||||
fn load_initial_theme(
|
fn load_initial_theme(
|
||||||
asset_server: Res<AssetServer>,
|
asset_server: Res<AssetServer>,
|
||||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
||||||
let id = settings
|
let id = settings
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|s| s.0.selected_theme_id.as_str())
|
.map(|s| s.0.selected_theme_id.as_str())
|
||||||
.unwrap_or("classic");
|
.unwrap_or(&default_id);
|
||||||
let url = bundled_theme_url(id)
|
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
|
||||||
.map(str::to_string)
|
|
||||||
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
|
||||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
|
||||||
commands.insert_resource(ActiveTheme(handle));
|
commands.insert_resource(ActiveTheme(handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = bundled_theme_url(new_id)
|
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
|
||||||
.map(str::to_string)
|
|
||||||
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
|
|
||||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
|
||||||
commands.insert_resource(ActiveTheme(handle));
|
commands.insert_resource(ActiveTheme(handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,22 +247,8 @@ fn sync_card_image_set_with_active_theme(
|
|||||||
/// `theme_back` when present, so writing here is sufficient to make
|
/// `theme_back` when present, so writing here is sufficient to make
|
||||||
/// every face-down card pick up the theme's art on the next sync.
|
/// every face-down card pick up the theme's art on the next sync.
|
||||||
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for suit in Suit::SUITS {
|
||||||
for rank in [
|
for rank in Rank::RANKS {
|
||||||
Rank::Ace,
|
|
||||||
Rank::Two,
|
|
||||||
Rank::Three,
|
|
||||||
Rank::Four,
|
|
||||||
Rank::Five,
|
|
||||||
Rank::Six,
|
|
||||||
Rank::Seven,
|
|
||||||
Rank::Eight,
|
|
||||||
Rank::Nine,
|
|
||||||
Rank::Ten,
|
|
||||||
Rank::Jack,
|
|
||||||
Rank::Queen,
|
|
||||||
Rank::King,
|
|
||||||
] {
|
|
||||||
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
|
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
|
||||||
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
|
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
|
||||||
}
|
}
|
||||||
@@ -266,41 +257,11 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
|
|||||||
image_set.theme_back = Some(theme.back.clone());
|
image_set.theme_back = Some(theme.back.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
|
/// Switches the active theme to `theme_id` — the embedded source for
|
||||||
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
|
||||||
const fn suit_index(s: Suit) -> usize {
|
/// themes. Returns the new `Handle<CardTheme>` so callers can poll
|
||||||
match s {
|
/// `Assets<CardTheme>` if they want to wait for the load before
|
||||||
Suit::Clubs => 0,
|
/// changing UI state.
|
||||||
Suit::Diamonds => 1,
|
|
||||||
Suit::Hearts => 2,
|
|
||||||
Suit::Spades => 3,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Index used by [`CardImageSet::faces`] for a given rank.
|
|
||||||
/// Ace=0, Two=1 … King=12.
|
|
||||||
const fn rank_index(r: Rank) -> usize {
|
|
||||||
match r {
|
|
||||||
Rank::Ace => 0,
|
|
||||||
Rank::Two => 1,
|
|
||||||
Rank::Three => 2,
|
|
||||||
Rank::Four => 3,
|
|
||||||
Rank::Five => 4,
|
|
||||||
Rank::Six => 5,
|
|
||||||
Rank::Seven => 6,
|
|
||||||
Rank::Eight => 7,
|
|
||||||
Rank::Nine => 8,
|
|
||||||
Rank::Ten => 9,
|
|
||||||
Rank::Jack => 10,
|
|
||||||
Rank::Queen => 11,
|
|
||||||
Rank::King => 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switches the active theme to the one served at
|
|
||||||
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
|
|
||||||
/// so callers can poll `Assets<CardTheme>` if they want to wait for
|
|
||||||
/// the load before changing UI state.
|
|
||||||
///
|
///
|
||||||
/// The handle is also written to the [`ActiveTheme`] resource — the
|
/// The handle is also written to the [`ActiveTheme`] resource — the
|
||||||
/// per-frame sync system picks up the `LoadedWithDependencies` event
|
/// per-frame sync system picks up the `LoadedWithDependencies` event
|
||||||
@@ -311,8 +272,7 @@ pub fn set_theme(
|
|||||||
asset_server: &AssetServer,
|
asset_server: &AssetServer,
|
||||||
theme_id: &str,
|
theme_id: &str,
|
||||||
) -> Handle<CardTheme> {
|
) -> Handle<CardTheme> {
|
||||||
let url = format!("themes://{theme_id}/theme.ron");
|
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
|
||||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
|
||||||
commands.insert_resource(ActiveTheme(handle.clone()));
|
commands.insert_resource(ActiveTheme(handle.clone()));
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
@@ -446,21 +406,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_index_ranges_match_card_plugin_layout() {
|
fn suit_index_matches_upstream_suits_order() {
|
||||||
assert_eq!(suit_index(Suit::Clubs), 0);
|
for (i, s) in Suit::SUITS.iter().enumerate() {
|
||||||
assert_eq!(suit_index(Suit::Diamonds), 1);
|
assert_eq!(suit_index(*s), i, "faces outer layout = Suit::SUITS order");
|
||||||
assert_eq!(suit_index(Suit::Hearts), 2);
|
}
|
||||||
assert_eq!(suit_index(Suit::Spades), 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() {
|
fn rank_index_matches_upstream_ranks_order() {
|
||||||
assert_eq!(rank_index(Rank::Ace), 0);
|
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||||
assert_eq!(rank_index(Rank::Two), 1);
|
assert_eq!(rank_index(*r), i, "faces inner layout = Rank::RANKS order");
|
||||||
assert_eq!(rank_index(Rank::Ten), 9);
|
}
|
||||||
assert_eq!(rank_index(Rank::Jack), 10);
|
|
||||||
assert_eq!(rank_index(Rank::Queen), 11);
|
|
||||||
assert_eq!(rank_index(Rank::King), 12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -524,13 +480,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_theme_url_format_matches_themes_source() {
|
fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
|
||||||
// The format string is the only behavioural surface of
|
// The bundled themes live in the binary, not in the user
|
||||||
// set_theme that doesn't require an App. We assert the URL
|
// themes directory — resolving them to `themes://` would
|
||||||
// shape so a future refactor doesn't accidentally change the
|
// NotFound and silently leave the previous theme active.
|
||||||
// path layout.
|
assert_eq!(
|
||||||
let url = format!("themes://{}/theme.ron", "user_uploaded");
|
theme_manifest_url("dark"),
|
||||||
assert_eq!(url, "themes://user_uploaded/theme.ron");
|
crate::assets::DARK_THEME_MANIFEST_URL
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
theme_manifest_url("classic"),
|
||||||
|
crate::assets::CLASSIC_THEME_MANIFEST_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_manifest_url_routes_user_ids_to_themes_source() {
|
||||||
|
// We assert the URL shape so a future refactor doesn't
|
||||||
|
// accidentally change the path layout of the user-themes
|
||||||
|
// source.
|
||||||
|
assert_eq!(
|
||||||
|
theme_manifest_url("user_uploaded"),
|
||||||
|
"themes://user_uploaded/theme.ron"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_theme_fallback_matches_settings_default() {
|
||||||
|
// `load_initial_theme` falls back to the data-crate default
|
||||||
|
// when `SettingsResource` is absent. That default must always
|
||||||
|
// name a bundled theme, or a fresh minimal setup would resolve
|
||||||
|
// to a nonexistent user theme (see the v0.33.0 "classic"
|
||||||
|
// fallback drift in CHANGELOG.md).
|
||||||
|
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
||||||
|
assert!(
|
||||||
|
bundled_theme_url(&default_id).is_some(),
|
||||||
|
"default theme id {default_id:?} must be a bundled theme"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test 1: the bundled dark theme always has embedded SVG bytes
|
/// Test 1: the bundled dark theme always has embedded SVG bytes
|
||||||
|
|||||||
@@ -0,0 +1,657 @@
|
|||||||
|
//! In-game theme store: browse the sync server's theme catalog and
|
||||||
|
//! install themes without leaving the app.
|
||||||
|
//!
|
||||||
|
//! Settings → Cosmetic → "Browse theme store" fires
|
||||||
|
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
|
||||||
|
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
|
||||||
|
//! handles per-theme Install buttons. An install downloads the archive
|
||||||
|
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
|
||||||
|
//! into `user_theme_dir()`, and runs it through the same hardened
|
||||||
|
//! [`import_theme`] pipeline as a manually dropped zip — the store
|
||||||
|
//! never bypasses the importer's validation.
|
||||||
|
//!
|
||||||
|
//! Native-only: downloads need `reqwest`/Tokio and the importer is
|
||||||
|
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
||||||
|
//! `SyncPlugin`.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
||||||
|
use solitaire_data::{Settings, SyncBackend};
|
||||||
|
use solitaire_sync::ThemeCatalogEntry;
|
||||||
|
|
||||||
|
use crate::assets::user_theme_dir;
|
||||||
|
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::resources::TokioRuntimeResource;
|
||||||
|
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
||||||
|
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
||||||
|
use crate::ui_modal::{
|
||||||
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||||
|
spawn_modal_button, spawn_modal_header,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Everything that can go wrong between clicking Install and the theme
|
||||||
|
/// appearing in the picker.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
enum ThemeInstallError {
|
||||||
|
/// Catalog fetch or archive download failed (network, HTTP status,
|
||||||
|
/// size/checksum verification).
|
||||||
|
#[error(transparent)]
|
||||||
|
Download(#[from] ThemeStoreError),
|
||||||
|
/// The verified archive could not be written into the themes dir.
|
||||||
|
#[error("could not save archive: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
/// The downloaded archive failed importer validation.
|
||||||
|
#[error(transparent)]
|
||||||
|
Import(#[from] ImportError),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components & resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on the theme-store modal's scrim root.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct ThemeStoreScreen;
|
||||||
|
|
||||||
|
/// Marker on the modal's Close button.
|
||||||
|
#[derive(Component)]
|
||||||
|
struct ThemeStoreCloseButton;
|
||||||
|
|
||||||
|
/// Per-row Install button carrying its catalog entry.
|
||||||
|
#[derive(Component)]
|
||||||
|
struct ThemeStoreInstallButton(ThemeCatalogEntry);
|
||||||
|
|
||||||
|
/// Catalog fetch lifecycle. The modal renders directly from this state
|
||||||
|
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
|
||||||
|
/// whenever it changes.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
enum CatalogState {
|
||||||
|
/// No fetch has run since the modal was last opened.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
Loading,
|
||||||
|
Loaded(Vec<ThemeCatalogEntry>),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight catalog fetch, polled without blocking.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);
|
||||||
|
|
||||||
|
/// Result of one download + import: `Ok(Some(id))` on a fresh install,
|
||||||
|
/// `Ok(None)` when the theme was already installed (id collision) —
|
||||||
|
/// informational, not an error.
|
||||||
|
type InstallResult = Result<Option<String>, ThemeInstallError>;
|
||||||
|
|
||||||
|
/// In-flight download + import. `String` is the theme id, for toasts.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct InstallTask(Option<(String, Task<InstallResult>)>);
|
||||||
|
|
||||||
|
/// Server base URL captured when the modal opens. Catalog
|
||||||
|
/// `download_url`s are server-relative, so installs join them onto
|
||||||
|
/// this. `None` while the modal has never been opened.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct StoreBaseUrl(Option<String>);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bevy plugin for the in-game theme store. See the module docs.
|
||||||
|
pub struct ThemeStorePlugin;
|
||||||
|
|
||||||
|
impl Plugin for ThemeStorePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<CatalogState>()
|
||||||
|
.init_resource::<CatalogTask>()
|
||||||
|
.init_resource::<InstallTask>()
|
||||||
|
.init_resource::<StoreBaseUrl>()
|
||||||
|
.add_message::<ThemeStoreOpenRequestEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_open_request,
|
||||||
|
poll_catalog_task,
|
||||||
|
handle_install_buttons,
|
||||||
|
poll_install_task,
|
||||||
|
handle_close_button,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolves the store's server base URL from the player's sync
|
||||||
|
/// backend. The store rides the same self-hosted server as sync;
|
||||||
|
/// without one configured there is nothing to browse.
|
||||||
|
fn store_base_url(settings: &Settings) -> Option<String> {
|
||||||
|
match &settings.sync_backend {
|
||||||
|
SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the store modal and kicks off the catalog fetch.
|
||||||
|
///
|
||||||
|
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
|
||||||
|
/// settings panel in the same frame it fires the event, and despawns
|
||||||
|
/// are deferred, so the settings scrim is excluded from the
|
||||||
|
/// another-modal-is-open guard.
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
fn handle_open_request(
|
||||||
|
mut events: MessageReader<ThemeStoreOpenRequestEvent>,
|
||||||
|
existing: Query<(), With<ThemeStoreScreen>>,
|
||||||
|
other_modal_scrims: Query<
|
||||||
|
(),
|
||||||
|
(
|
||||||
|
With<ModalScrim>,
|
||||||
|
Without<ThemeStoreScreen>,
|
||||||
|
Without<SettingsPanel>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
rt: Option<Res<TokioRuntimeResource>>,
|
||||||
|
mut catalog_state: ResMut<CatalogState>,
|
||||||
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
|
mut store_base: ResMut<StoreBaseUrl>,
|
||||||
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
if !existing.is_empty() || !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
|
||||||
|
warning_toast.write(WarningToastEvent(
|
||||||
|
"Theme store needs a server — connect one in Settings → Sync.".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(rt) = rt else {
|
||||||
|
warning_toast.write(WarningToastEvent(
|
||||||
|
"Theme store unavailable — no network runtime.".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
store_base.0 = Some(base_url.clone());
|
||||||
|
*catalog_state = CatalogState::Loading;
|
||||||
|
let rt = rt.0.clone();
|
||||||
|
catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
||||||
|
}));
|
||||||
|
|
||||||
|
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
|
||||||
|
/// rebuilds the modal if it is still open.
|
||||||
|
fn poll_catalog_task(
|
||||||
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
|
mut catalog_state: ResMut<CatalogState>,
|
||||||
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
registry: Option<Res<ThemeRegistry>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
let Some(task) = catalog_task.0.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
catalog_task.0 = None;
|
||||||
|
|
||||||
|
*catalog_state = match result {
|
||||||
|
Ok(entries) => CatalogState::Loaded(entries),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("theme store: catalog fetch failed: {e}");
|
||||||
|
CatalogState::Error(e.to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuild_open_modal(
|
||||||
|
&screens,
|
||||||
|
&mut commands,
|
||||||
|
&catalog_state,
|
||||||
|
registry.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a download + import task when an Install button is pressed.
|
||||||
|
/// One install at a time; repeat clicks while busy are ignored.
|
||||||
|
fn handle_install_buttons(
|
||||||
|
interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
|
||||||
|
rt: Option<Res<TokioRuntimeResource>>,
|
||||||
|
store_base: Res<StoreBaseUrl>,
|
||||||
|
mut install_task: ResMut<InstallTask>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interactions {
|
||||||
|
if *interaction != Interaction::Pressed || install_task.0.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entry = button.0.clone();
|
||||||
|
info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
|
||||||
|
let rt = rt.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
let bytes = rt
|
||||||
|
.block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
|
||||||
|
install_verified_archive(&entry.id, &bytes)
|
||||||
|
});
|
||||||
|
install_task.0 = Some((button.0.id.clone(), task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads land here (already SHA-256-verified): write the archive
|
||||||
|
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
|
||||||
|
/// run the standard importer. Returns `Ok(None)` when the theme was
|
||||||
|
/// already installed.
|
||||||
|
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
|
||||||
|
let themes_dir = user_theme_dir();
|
||||||
|
std::fs::create_dir_all(&themes_dir)?;
|
||||||
|
let final_path = themes_dir.join(format!("{id}.zip"));
|
||||||
|
let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
|
||||||
|
std::fs::write(&tmp_path, bytes)?;
|
||||||
|
std::fs::rename(&tmp_path, &final_path)?;
|
||||||
|
|
||||||
|
match import_theme(&final_path) {
|
||||||
|
Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
|
||||||
|
Err(ImportError::IdCollision { .. }) => Ok(None),
|
||||||
|
Err(e) => {
|
||||||
|
// Don't leave a zip that fails validation lying around for
|
||||||
|
// the folder-scan flow to re-try on every scan.
|
||||||
|
let _ = std::fs::remove_file(&final_path);
|
||||||
|
Err(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the install task; on completion refreshes the theme registry,
|
||||||
|
/// toasts the outcome, and rebuilds the modal so the row flips to
|
||||||
|
/// "Installed".
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn poll_install_task(
|
||||||
|
mut install_task: ResMut<InstallTask>,
|
||||||
|
mut registry: Option<ResMut<ThemeRegistry>>,
|
||||||
|
catalog_state: Res<CatalogState>,
|
||||||
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
let Some((id, task)) = install_task.0.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let id = id.clone();
|
||||||
|
install_task.0 = None;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Some(theme_id)) => {
|
||||||
|
if let Some(registry) = registry.as_deref_mut() {
|
||||||
|
refresh_registry(registry, &user_theme_dir());
|
||||||
|
}
|
||||||
|
info_toast.write(InfoToastEvent(format!(
|
||||||
|
"Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("theme store: install of '{id}' failed: {e}");
|
||||||
|
warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild_open_modal(
|
||||||
|
&screens,
|
||||||
|
&mut commands,
|
||||||
|
&catalog_state,
|
||||||
|
registry.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the store modal when Close is pressed.
|
||||||
|
fn handle_close_button(
|
||||||
|
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
|
||||||
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
for interaction in &interactions {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modal rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Despawn + respawn the modal for every open screen (Bevy despawns
|
||||||
|
/// are deferred, so this is safe within one frame).
|
||||||
|
fn rebuild_open_modal(
|
||||||
|
screens: &Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
commands: &mut Commands,
|
||||||
|
catalog_state: &CatalogState,
|
||||||
|
registry: Option<&ThemeRegistry>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
for entity in screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
spawn_store_modal(commands, catalog_state, registry, font_res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the store modal for the current catalog state.
|
||||||
|
fn spawn_store_modal(
|
||||||
|
commands: &mut Commands,
|
||||||
|
catalog_state: &CatalogState,
|
||||||
|
registry: Option<&ThemeRegistry>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let body_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let caption_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
|
||||||
|
spawn_modal_header(card, "Theme Store", font_res);
|
||||||
|
|
||||||
|
match catalog_state {
|
||||||
|
CatalogState::Idle | CatalogState::Loading => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Loading catalog…"),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CatalogState::Error(msg) => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new(format!("Could not load the catalog: {msg}")),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new("The server has no themes yet."),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CatalogState::Loaded(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
let installed =
|
||||||
|
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
||||||
|
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
ThemeStoreCloseButton,
|
||||||
|
"Close",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One catalog row: name + author/size caption on the left, Install
|
||||||
|
/// button (or "Installed" caption) on the right.
|
||||||
|
fn spawn_store_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
entry: &ThemeCatalogEntry,
|
||||||
|
installed: bool,
|
||||||
|
body_font: &TextFont,
|
||||||
|
caption_font: &TextFont,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::SpaceBetween,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
padding: UiRect::vertical(VAL_SPACE_2),
|
||||||
|
border: UiRect::bottom(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|col| {
|
||||||
|
col.spawn((
|
||||||
|
Text::new(entry.name.clone()),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
col.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
"by {} — {} KB",
|
||||||
|
entry.author,
|
||||||
|
entry.size_bytes.div_ceil(1024)
|
||||||
|
)),
|
||||||
|
caption_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
if installed {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Installed"),
|
||||||
|
caption_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
ThemeStoreInstallButton(entry.clone()),
|
||||||
|
"Install",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(ThemeStorePlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_with_server() -> SettingsResource {
|
||||||
|
SettingsResource(Settings {
|
||||||
|
sync_backend: SyncBackend::SolitaireServer {
|
||||||
|
url: "http://127.0.0.1:1".to_string(),
|
||||||
|
username: "tester".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
|
},
|
||||||
|
..Settings::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ThemeStoreScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_base_url_requires_server_backend() {
|
||||||
|
assert_eq!(store_base_url(&Settings::default()), None);
|
||||||
|
assert_eq!(
|
||||||
|
store_base_url(&settings_with_server().0).as_deref(),
|
||||||
|
Some("http://127.0.0.1:1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_request_without_server_warns_and_spawns_nothing() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Default settings → SyncBackend::Local → no store.
|
||||||
|
app.insert_resource(SettingsResource(Settings::default()));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(screen_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<Messages<WarningToastEvent>>()
|
||||||
|
.is_empty(),
|
||||||
|
"player must be told why the store did not open"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_request_without_runtime_warns_and_spawns_nothing() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
// No TokioRuntimeResource inserted.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(screen_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<Messages<WarningToastEvent>>()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_request_with_server_spawns_modal_in_loading_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(screen_count(&mut app), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
*app.world().resource::<CatalogState>(),
|
||||||
|
CatalogState::Loading | CatalogState::Error(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<StoreBaseUrl>().0.as_deref(),
|
||||||
|
Some("http://127.0.0.1:1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_open_request_does_not_stack_a_second_modal() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||||
|
for _ in 0..2 {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert_eq!(screen_count(&mut app), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// 127.0.0.1:1 refuses connections; the fetch task must resolve
|
||||||
|
// to an error within a bounded number of frames and the modal
|
||||||
|
// must survive the rebuild.
|
||||||
|
for _ in 0..200 {
|
||||||
|
app.update();
|
||||||
|
if matches!(
|
||||||
|
*app.world().resource::<CatalogState>(),
|
||||||
|
CatalogState::Error(_)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
*app.world().resource::<CatalogState>(),
|
||||||
|
CatalogState::Error(_)
|
||||||
|
));
|
||||||
|
assert_eq!(screen_count(&mut app), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
|
|
||||||
use bevy::ecs::message::MessageReader;
|
use bevy::ecs::message::MessageReader;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::KlondikePile;
|
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ pub struct FocusedButton(pub Option<Entity>);
|
|||||||
/// gains keyboard navigation without per-plugin wiring.
|
/// gains keyboard navigation without per-plugin wiring.
|
||||||
pub struct UiFocusPlugin;
|
pub struct UiFocusPlugin;
|
||||||
|
|
||||||
|
/// Set on [`handle_focus_keys`], the focus-ring keyboard navigator. It runs
|
||||||
|
/// AFTER every app-level keyboard consumer (HUD buttons/popovers, restore
|
||||||
|
/// prompt, settings toggle) so Esc/Tab consumption order is defined instead
|
||||||
|
/// of scheduler-dependent (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct FocusKeys;
|
||||||
|
|
||||||
impl Plugin for UiFocusPlugin {
|
impl Plugin for UiFocusPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<FocusedButton>()
|
app.init_resource::<FocusedButton>()
|
||||||
@@ -147,9 +154,19 @@ impl Plugin for UiFocusPlugin {
|
|||||||
(
|
(
|
||||||
sync_focus_on_mouse_click,
|
sync_focus_on_mouse_click,
|
||||||
clear_hud_focus_on_unhover,
|
clear_hud_focus_on_unhover,
|
||||||
handle_focus_keys,
|
handle_focus_keys
|
||||||
update_focus_overlay,
|
.in_set(FocusKeys)
|
||||||
pulse_focus_overlay,
|
.after(crate::game_plugin::GameMutation),
|
||||||
|
update_focus_overlay
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
.ambiguous_with(crate::table_plugin::MarkerVisuals),
|
||||||
|
pulse_focus_overlay
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -695,7 +695,12 @@ impl Plugin for UiModalPlugin {
|
|||||||
advance_modal_enter,
|
advance_modal_enter,
|
||||||
paint_modal_buttons,
|
paint_modal_buttons,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
.ambiguous_with(crate::hud_plugin::HudButtons),
|
||||||
);
|
);
|
||||||
// Click-outside-to-dismiss is independent of the open
|
// Click-outside-to-dismiss is independent of the open
|
||||||
// animation chain — it reads `just_pressed(Left)` and runs
|
// animation chain — it reads `just_pressed(Left)` and runs
|
||||||
|
|||||||
@@ -304,10 +304,7 @@ impl HighContrastBackground {
|
|||||||
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
|
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
|
||||||
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
||||||
/// than to a neutral gray.
|
/// than to a neutral gray.
|
||||||
pub const fn with_hc(
|
pub const fn with_hc(default_color: Color, hc_color: Color) -> Self {
|
||||||
default_color: Color,
|
|
||||||
hc_color: Color,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
default_color,
|
default_color,
|
||||||
hc_color,
|
hc_color,
|
||||||
@@ -698,3 +695,12 @@ mod tests {
|
|||||||
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
|
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// System set for text/UI visual effects that animate `Transform`/`Sprite`
|
||||||
|
/// on chrome entities (HUD score pulse, streak flourish, modal enter, focus
|
||||||
|
/// ring). These never touch board entities, so members are declared
|
||||||
|
/// `.ambiguous_with(BoardVisuals)` and `.ambiguous_with(UiTextFx)` — the
|
||||||
|
/// entity domains are disjoint by construction and relative order within a
|
||||||
|
/// frame is invisible (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct UiTextFx;
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ tower-http = { version = "0.6", features = ["fs"] }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
|
# Theme store: scans a directory of theme .zip archives at startup,
|
||||||
|
# reading each archive's theme.ron manifest and hashing the bytes.
|
||||||
|
ron = { workspace = true }
|
||||||
|
zip = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+104
-17
@@ -74,6 +74,15 @@ struct UserRow {
|
|||||||
/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
|
/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
|
||||||
pub const BCRYPT_COST: u32 = 12;
|
pub const BCRYPT_COST: u32 = 12;
|
||||||
|
|
||||||
|
/// Static bcrypt hash used to equalise login timing when the username does
|
||||||
|
/// not exist (issue #139: user-enumeration timing oracle). Both login paths
|
||||||
|
/// must pay the same bcrypt cost; this hash is verified against when there
|
||||||
|
/// is no real one. Computed once at first use with the same [`BCRYPT_COST`]
|
||||||
|
/// as real hashes. `None` only if bcrypt itself fails on a constant input —
|
||||||
|
/// in that case the dummy verify is skipped rather than panicking.
|
||||||
|
static DUMMY_PASSWORD_HASH: std::sync::LazyLock<Option<String>> =
|
||||||
|
std::sync::LazyLock::new(|| hash("ferrous-dummy-timing-pad", BCRYPT_COST).ok());
|
||||||
|
|
||||||
async fn hash_password(password: String) -> Result<String, AppError> {
|
async fn hash_password(password: String) -> Result<String, AppError> {
|
||||||
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
|
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
|
||||||
.await
|
.await
|
||||||
@@ -208,6 +217,10 @@ pub async fn register(
|
|||||||
let password_hash = hash_password(body.password).await?;
|
let password_hash = hash_password(body.password).await?;
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// The SELECT above is a friendly fast path; the UNIQUE constraint is the
|
||||||
|
// real arbiter. A concurrent registration that slips between the two
|
||||||
|
// surfaces as a unique violation here — map it to the same 409 the
|
||||||
|
// fast path produces instead of a raw 500 (issue #140).
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
user_id,
|
user_id,
|
||||||
@@ -216,7 +229,11 @@ pub async fn register(
|
|||||||
now
|
now
|
||||||
)
|
)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(db) if db.is_unique_violation() => AppError::UsernameTaken,
|
||||||
|
_ => AppError::from(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
|
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
|
||||||
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
|
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
|
||||||
@@ -242,7 +259,16 @@ pub async fn login(
|
|||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let row = row.ok_or(AppError::InvalidCredentials)?;
|
let Some(row) = row else {
|
||||||
|
// Unknown username: burn a bcrypt verify against a static dummy hash
|
||||||
|
// so this path costs the same as a wrong-password attempt. Returning
|
||||||
|
// immediately here would let response timing reveal which usernames
|
||||||
|
// exist (issue #139).
|
||||||
|
if let Some(dummy) = DUMMY_PASSWORD_HASH.as_ref() {
|
||||||
|
let _ = verify_password(body.password, dummy.clone()).await;
|
||||||
|
}
|
||||||
|
return Err(AppError::InvalidCredentials);
|
||||||
|
};
|
||||||
let row_id = row
|
let row_id = row
|
||||||
.id
|
.id
|
||||||
.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
||||||
@@ -283,23 +309,20 @@ pub async fn refresh(
|
|||||||
// Tokens without jti predate rotation — require re-login.
|
// Tokens without jti predate rotation — require re-login.
|
||||||
let jti = claims.jti.ok_or(AppError::Unauthorized)?;
|
let jti = claims.jti.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
// Verify this jti is still live (not yet consumed or from a deleted account).
|
// Consume the old token before issuing new ones, gating on the DELETE
|
||||||
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option.
|
// actually removing a row. rows_affected == 0 covers both "jti never
|
||||||
let exists: Option<String> =
|
// existed / account deleted" and "a concurrent refresh already consumed
|
||||||
sqlx::query_scalar!("SELECT jti FROM refresh_tokens WHERE jti = ?", jti)
|
// it" — the previous SELECT-then-DELETE let two concurrent refreshes
|
||||||
.fetch_optional(&state.pool)
|
// both pass the check and both mint fresh token pairs. The DELETE is
|
||||||
.await?
|
// the mutex: whoever removes the row wins; everyone else gets 401.
|
||||||
.flatten();
|
// If the insert below fails, the user loses this session (must
|
||||||
|
// re-login) — safe by design.
|
||||||
if exists.is_none() {
|
let deleted = sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
|
||||||
return Err(AppError::Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume the old token before issuing new ones. If the insert below
|
|
||||||
// fails, the user loses this session (must re-login) — safe by design.
|
|
||||||
sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
|
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
if deleted.rows_affected() != 1 {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
|
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
|
||||||
let (new_refresh, new_jti) = make_refresh_token(&claims.sub, &state.jwt_secret)?;
|
let (new_refresh, new_jti) = make_refresh_token(&claims.sub, &state.jwt_secret)?;
|
||||||
@@ -365,6 +388,22 @@ const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
|||||||
///
|
///
|
||||||
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
|
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
|
||||||
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
|
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
|
||||||
|
/// Returns `true` when `body` begins with the magic bytes of the image type
|
||||||
|
/// implied by `ext` (the extension derived from the declared Content-Type).
|
||||||
|
///
|
||||||
|
/// Avatars are re-served under the stored extension, so the extension must be
|
||||||
|
/// derived from the content itself — a client header is not evidence of what
|
||||||
|
/// the bytes are (issue #141).
|
||||||
|
fn magic_bytes_match(ext: &str, body: &[u8]) -> bool {
|
||||||
|
match ext {
|
||||||
|
"png" => body.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||||
|
"jpg" => body.starts_with(&[0xFF, 0xD8, 0xFF]),
|
||||||
|
"gif" => body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a"),
|
||||||
|
"webp" => body.len() >= 12 && body.starts_with(b"RIFF") && &body[8..12] == b"WEBP",
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upload_avatar(
|
pub async fn upload_avatar(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
@@ -390,6 +429,14 @@ pub async fn upload_avatar(
|
|||||||
if body.len() > AVATAR_MAX_BYTES {
|
if body.len() > AVATAR_MAX_BYTES {
|
||||||
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||||||
}
|
}
|
||||||
|
// The stored extension decides how the file is re-served, so it must be
|
||||||
|
// backed by the bytes, not just the client's Content-Type header
|
||||||
|
// (issue #141).
|
||||||
|
if !magic_bytes_match(ext, &body) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"avatar bytes do not match the declared image type".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Write to avatars/ directory, replacing any previous file for this user.
|
// Write to avatars/ directory, replacing any previous file for this user.
|
||||||
tokio::fs::create_dir_all("avatars")
|
tokio::fs::create_dir_all("avatars")
|
||||||
@@ -590,6 +637,46 @@ mod tests {
|
|||||||
assert!(username_chars_ok(""));
|
assert!(username_chars_ok(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_bytes_match_accepts_real_signatures() {
|
||||||
|
assert!(magic_bytes_match(
|
||||||
|
"png",
|
||||||
|
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00]
|
||||||
|
));
|
||||||
|
assert!(magic_bytes_match("jpg", &[0xFF, 0xD8, 0xFF, 0xE0, 0x00]));
|
||||||
|
assert!(magic_bytes_match("gif", b"GIF89a\x00\x00"));
|
||||||
|
assert!(magic_bytes_match("gif", b"GIF87a\x00\x00"));
|
||||||
|
assert!(magic_bytes_match("webp", b"RIFF\x00\x00\x00\x00WEBPVP8 "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_bytes_match_rejects_mismatched_or_bogus_content() {
|
||||||
|
// HTML declared as PNG — the exact spoof the check exists to stop.
|
||||||
|
assert!(!magic_bytes_match("png", b"<html><script>"));
|
||||||
|
// Real PNG bytes declared as JPEG: extension must match the bytes.
|
||||||
|
assert!(!magic_bytes_match(
|
||||||
|
"jpg",
|
||||||
|
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
|
||||||
|
));
|
||||||
|
// Truncated / empty bodies never match.
|
||||||
|
assert!(!magic_bytes_match("png", &[0x89, b'P']));
|
||||||
|
assert!(!magic_bytes_match("webp", b"RIFF1234WEB"));
|
||||||
|
assert!(!magic_bytes_match("gif", b""));
|
||||||
|
// Unknown extensions are never accepted.
|
||||||
|
assert!(!magic_bytes_match("svg", b"<svg xmlns="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dummy_password_hash_is_available_and_verifiable() {
|
||||||
|
// The login timing pad (issue #139) must be a real bcrypt hash so the
|
||||||
|
// unknown-user path pays a genuine verify at BCRYPT_COST.
|
||||||
|
let dummy = DUMMY_PASSWORD_HASH
|
||||||
|
.as_ref()
|
||||||
|
.expect("bcrypt of a constant input must succeed");
|
||||||
|
assert!(verify("ferrous-dummy-timing-pad", dummy).unwrap_or(false));
|
||||||
|
assert!(!verify("wrong-password", dummy).unwrap_or(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn username_chars_ok_rejects_unicode_letters() {
|
fn username_chars_ok_rejects_unicode_letters() {
|
||||||
// Non-ASCII characters must be rejected even if they look like letters.
|
// Non-ASCII characters must be rejected even if they look like letters.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod leaderboard;
|
|||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod replays;
|
pub mod replays;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
pub mod theme_store;
|
||||||
|
|
||||||
pub use auth::reset_password;
|
pub use auth::reset_password;
|
||||||
|
|
||||||
@@ -86,6 +87,10 @@ pub struct AppState {
|
|||||||
pub pool: SqlitePool,
|
pub pool: SqlitePool,
|
||||||
/// HS256 signing secret for JWT access and refresh tokens.
|
/// HS256 signing secret for JWT access and refresh tokens.
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
|
/// Theme-store catalog scanned once at startup.
|
||||||
|
/// [`theme_store::ThemeStore::empty`] when the server runs without
|
||||||
|
/// a store directory.
|
||||||
|
pub theme_store: Arc<theme_store::ThemeStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct the full Axum [`Router`].
|
/// Construct the full Axum [`Router`].
|
||||||
@@ -125,9 +130,20 @@ pub async fn build_test_pool() -> SqlitePool {
|
|||||||
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub fn build_test_router(pool: SqlitePool) -> Router {
|
pub fn build_test_router(pool: SqlitePool) -> Router {
|
||||||
|
build_test_router_with_theme_store(pool, theme_store::ThemeStore::empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`build_test_router`] with a caller-supplied theme store, for
|
||||||
|
/// integration tests exercising the theme endpoints.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn build_test_router_with_theme_store(
|
||||||
|
pool: SqlitePool,
|
||||||
|
theme_store: theme_store::ThemeStore,
|
||||||
|
) -> Router {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
pool,
|
pool,
|
||||||
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
|
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
|
||||||
|
theme_store: Arc::new(theme_store),
|
||||||
};
|
};
|
||||||
build_router_inner(state, false)
|
build_router_inner(state, false)
|
||||||
}
|
}
|
||||||
@@ -195,6 +211,9 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||||
.route("/api/replays/recent", get(replays::recent))
|
.route("/api/replays/recent", get(replays::recent))
|
||||||
.route("/api/replays/{id}", get(replays::get_by_id))
|
.route("/api/replays/{id}", get(replays::get_by_id))
|
||||||
|
.route("/api/themes", get(theme_store::list))
|
||||||
|
.route("/api/themes/{id}/download", get(theme_store::download))
|
||||||
|
.route("/api/themes/{id}/preview", get(theme_store::preview))
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.nest_service("/avatars", ServeDir::new("avatars"));
|
.nest_service("/avatars", ServeDir::new("avatars"));
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,13 @@
|
|||||||
//! echo "new_password" | ./solitaire_server --reset-password alice
|
//! echo "new_password" | ./solitaire_server --reset-password alice
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use solitaire_server::{AppState, build_router};
|
use solitaire_server::{AppState, build_router, theme_store};
|
||||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
||||||
use std::{
|
use std::{
|
||||||
io::{self, BufRead},
|
io::{self, BufRead},
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
const JWT_SECRET_MIN_BYTES: usize = 32;
|
const JWT_SECRET_MIN_BYTES: usize = 32;
|
||||||
@@ -142,7 +143,19 @@ async fn run_server() {
|
|||||||
|
|
||||||
tracing::info!("database ready at {db_url}");
|
tracing::info!("database ready at {db_url}");
|
||||||
|
|
||||||
let state = AppState { pool, jwt_secret };
|
// Theme store: optional; a missing directory just means an empty
|
||||||
|
// catalog. Scanned once — add themes, then restart to publish.
|
||||||
|
let theme_store_dir = std::env::var(theme_store::THEME_STORE_DIR_ENV)
|
||||||
|
.unwrap_or_else(|_| theme_store::DEFAULT_THEME_STORE_DIR.into());
|
||||||
|
let theme_store = Arc::new(theme_store::ThemeStore::scan(std::path::Path::new(
|
||||||
|
&theme_store_dir,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
pool,
|
||||||
|
jwt_secret,
|
||||||
|
theme_store,
|
||||||
|
};
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
use axum::{Json, extract::State};
|
use axum::{Json, extract::State};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use solitaire_sync::{
|
use solitaire_sync::{
|
||||||
@@ -26,13 +25,19 @@ struct SyncRow {
|
|||||||
|
|
||||||
/// Load the stored `SyncPayload` for `user_id` from the database.
|
/// Load the stored `SyncPayload` for `user_id` from the database.
|
||||||
/// Returns `None` if this user has not pushed any data yet.
|
/// Returns `None` if this user has not pushed any data yet.
|
||||||
async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRow>, AppError> {
|
///
|
||||||
|
/// Executor-generic so `push` can run it inside its transaction while
|
||||||
|
/// `pull` keeps passing the pool directly.
|
||||||
|
async fn load_sync_row(
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Option<SyncRow>, AppError> {
|
||||||
let row = sqlx::query_as!(
|
let row = sqlx::query_as!(
|
||||||
SyncRow,
|
SyncRow,
|
||||||
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
|
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
|
||||||
user_id
|
user_id
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(exec)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
@@ -69,7 +74,7 @@ fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError>
|
|||||||
|
|
||||||
/// Persist a `SyncPayload` for `user_id` using an upsert.
|
/// Persist a `SyncPayload` for `user_id` using an upsert.
|
||||||
async fn store_payload(
|
async fn store_payload(
|
||||||
pool: &SqlitePool,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
payload: &SyncPayload,
|
payload: &SyncPayload,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
@@ -92,7 +97,7 @@ async fn store_payload(
|
|||||||
progress_json,
|
progress_json,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(exec)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -159,12 +164,20 @@ pub async fn push(
|
|||||||
return Err(AppError::BadRequest("user_id mismatch".into()));
|
return Err(AppError::BadRequest("user_id mismatch".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let server_payload = match load_sync_row(&state.pool, &user.user_id).await? {
|
// The whole read-merge-write cycle runs in ONE transaction. Without it,
|
||||||
|
// two devices pushing concurrently both read the same stored payload,
|
||||||
|
// merge independently, and the second store overwrites the first merge —
|
||||||
|
// the server visibly regresses until the losing device pushes again.
|
||||||
|
// SQLite serialises writers, so the second transaction simply waits.
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
|
let server_payload = match load_sync_row(&mut *tx, &user.user_id).await? {
|
||||||
Some(row) => row_to_payload(&row, &user.user_id)?,
|
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||||
None => {
|
None => {
|
||||||
// First push — nothing to merge against; store directly.
|
// First push — nothing to merge against; store directly.
|
||||||
store_payload(&state.pool, &user.user_id, &client_payload).await?;
|
store_payload(&mut *tx, &user.user_id, &client_payload).await?;
|
||||||
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &client_payload).await?;
|
update_leaderboard_if_opted_in(&mut tx, &user.user_id, &client_payload).await?;
|
||||||
|
tx.commit().await?;
|
||||||
return Ok(Json(SyncResponse {
|
return Ok(Json(SyncResponse {
|
||||||
merged: client_payload,
|
merged: client_payload,
|
||||||
server_time: Utc::now(),
|
server_time: Utc::now(),
|
||||||
@@ -175,8 +188,9 @@ pub async fn push(
|
|||||||
|
|
||||||
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
||||||
|
|
||||||
store_payload(&state.pool, &user.user_id, &merged).await?;
|
store_payload(&mut *tx, &user.user_id, &merged).await?;
|
||||||
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?;
|
update_leaderboard_if_opted_in(&mut tx, &user.user_id, &merged).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(Json(SyncResponse {
|
Ok(Json(SyncResponse {
|
||||||
merged,
|
merged,
|
||||||
@@ -188,16 +202,18 @@ pub async fn push(
|
|||||||
/// If the user is opted in to the leaderboard, update their row with the
|
/// If the user is opted in to the leaderboard, update their row with the
|
||||||
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
|
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
|
||||||
///
|
///
|
||||||
/// The opt-in check and the update are performed atomically in a single
|
/// Runs on the caller's transaction connection, so the opt-in check and the
|
||||||
/// conditional UPDATE (WHERE EXISTS subquery) to avoid a TOCTOU race where
|
/// update are atomic with the surrounding push — an opt-out between the check
|
||||||
/// the user opts out between the check and the write.
|
/// and the write can no longer interleave. (An earlier doc comment claimed
|
||||||
|
/// this was a single conditional UPDATE; it has always been two statements —
|
||||||
|
/// the enclosing transaction is what actually provides the atomicity.)
|
||||||
async fn update_leaderboard_if_opted_in(
|
async fn update_leaderboard_if_opted_in(
|
||||||
pool: &SqlitePool,
|
conn: &mut sqlx::SqliteConnection,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
payload: &SyncPayload,
|
payload: &SyncPayload,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
|
let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.map(|r| r.leaderboard_opt_in)
|
.map(|r| r.leaderboard_opt_in)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@@ -231,7 +247,7 @@ async fn update_leaderboard_if_opted_in(
|
|||||||
now,
|
now,
|
||||||
user_id
|
user_id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! Theme-store catalog and file-serving endpoints.
|
||||||
|
//!
|
||||||
|
//! The store is filesystem-driven: the operator drops theme `.zip`
|
||||||
|
//! archives (the same format the engine's theme importer consumes)
|
||||||
|
//! into the directory named by the `THEME_STORE_DIR` environment
|
||||||
|
//! variable (default `theme_store/`), plus an optional `<id>.png`
|
||||||
|
//! preview per theme. [`ThemeStore::scan`] runs once at startup —
|
||||||
|
//! adding a theme means dropping the files and restarting the server.
|
||||||
|
//!
|
||||||
|
//! Endpoints (all public, no auth — the catalog is free):
|
||||||
|
//!
|
||||||
|
//! - `GET /api/themes` — [`ThemeCatalogResponse`] JSON
|
||||||
|
//! - `GET /api/themes/{id}/download` — the theme `.zip`
|
||||||
|
//! - `GET /api/themes/{id}/preview` — the preview PNG (404 when absent)
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path as UrlPath, State};
|
||||||
|
use axum::http::header;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Environment variable naming the theme-store directory.
|
||||||
|
pub const THEME_STORE_DIR_ENV: &str = "THEME_STORE_DIR";
|
||||||
|
|
||||||
|
/// Default theme-store directory, relative to the server working dir —
|
||||||
|
/// same convention as the `avatars/` upload directory.
|
||||||
|
pub const DEFAULT_THEME_STORE_DIR: &str = "theme_store";
|
||||||
|
|
||||||
|
/// Archives larger than this are skipped at scan time with a warning.
|
||||||
|
/// Mirrors the engine importer's `MAX_ARCHIVE_BYTES` (20 MiB) — a zip
|
||||||
|
/// whose *compressed* size already exceeds the client's uncompressed
|
||||||
|
/// limit can never import, so serving it only wastes bandwidth.
|
||||||
|
pub const MAX_STORE_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Upper bound on the bytes read out of an archive's `theme.ron`
|
||||||
|
/// entry at scan time, guarding the catalog scan against a
|
||||||
|
/// zip-bombed manifest. Real manifests are a few KB.
|
||||||
|
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// `theme.ron`'s outer shape, `meta` block only — the 53 face entries
|
||||||
|
/// are irrelevant to the catalog and are validated client-side by the
|
||||||
|
/// importer at install time.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ManifestMetaOnly {
|
||||||
|
meta: StoreThemeMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of the `meta` block of a theme manifest. The canonical
|
||||||
|
/// definition is `solitaire_engine::theme::ThemeMeta`; the server
|
||||||
|
/// cannot depend on the engine crate (it links Bevy), so the shape is
|
||||||
|
/// re-declared here. Keep the two in lockstep.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StoreThemeMeta {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
author: String,
|
||||||
|
version: String,
|
||||||
|
card_aspect: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory catalog built by scanning the theme-store directory once
|
||||||
|
/// at startup. Shared via [`AppState`].
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ThemeStore {
|
||||||
|
entries: Vec<ThemeCatalogEntry>,
|
||||||
|
zip_paths: HashMap<String, PathBuf>,
|
||||||
|
preview_paths: HashMap<String, PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeStore {
|
||||||
|
/// An empty store — used by tests and when the store directory is
|
||||||
|
/// absent (the server runs fine without a theme store).
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans `dir` for `*.zip` theme archives and builds the catalog.
|
||||||
|
///
|
||||||
|
/// Best-effort per archive: unreadable files, oversized archives,
|
||||||
|
/// missing/malformed manifests, and duplicate ids are skipped with
|
||||||
|
/// a warning rather than failing startup — one bad upload must not
|
||||||
|
/// take the store (or the server) down.
|
||||||
|
pub fn scan(dir: &Path) -> Self {
|
||||||
|
let mut store = Self::default();
|
||||||
|
let read_dir = match std::fs::read_dir(dir) {
|
||||||
|
Ok(read_dir) => read_dir,
|
||||||
|
Err(e) => {
|
||||||
|
info!(
|
||||||
|
"theme store: directory {} not readable ({e}); serving an empty catalog",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut zips: Vec<PathBuf> = read_dir
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().is_some_and(|ext| ext == "zip"))
|
||||||
|
.collect();
|
||||||
|
// Deterministic catalog regardless of directory iteration order.
|
||||||
|
zips.sort();
|
||||||
|
|
||||||
|
for zip_path in zips {
|
||||||
|
match scan_archive(&zip_path) {
|
||||||
|
Ok((meta, size_bytes, sha256)) => {
|
||||||
|
if store.zip_paths.contains_key(&meta.id) {
|
||||||
|
warn!(
|
||||||
|
"theme store: skipping {} — duplicate theme id {:?}",
|
||||||
|
zip_path.display(),
|
||||||
|
meta.id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let preview_path = dir.join(format!("{}.png", meta.id));
|
||||||
|
let preview_url = if preview_path.is_file() {
|
||||||
|
store.preview_paths.insert(meta.id.clone(), preview_path);
|
||||||
|
Some(format!("/api/themes/{}/preview", meta.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
store.entries.push(ThemeCatalogEntry {
|
||||||
|
download_url: format!("/api/themes/{}/download", meta.id),
|
||||||
|
preview_url,
|
||||||
|
id: meta.id.clone(),
|
||||||
|
name: meta.name,
|
||||||
|
author: meta.author,
|
||||||
|
version: meta.version,
|
||||||
|
card_aspect: meta.card_aspect,
|
||||||
|
size_bytes,
|
||||||
|
sha256,
|
||||||
|
});
|
||||||
|
store.zip_paths.insert(meta.id, zip_path);
|
||||||
|
}
|
||||||
|
Err(reason) => {
|
||||||
|
warn!("theme store: skipping {} — {reason}", zip_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
info!("theme store: serving {} theme(s)", store.entries.len());
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog entries, sorted by display name.
|
||||||
|
pub fn entries(&self) -> &[ThemeCatalogEntry] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one archive: enforces the size cap, hashes the bytes, and
|
||||||
|
/// extracts + validates the manifest's `meta` block.
|
||||||
|
fn scan_archive(zip_path: &Path) -> Result<(StoreThemeMeta, u64, String), String> {
|
||||||
|
let size_bytes = std::fs::metadata(zip_path)
|
||||||
|
.map_err(|e| format!("cannot stat: {e}"))?
|
||||||
|
.len();
|
||||||
|
if size_bytes > MAX_STORE_ARCHIVE_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"{size_bytes} bytes exceeds the {MAX_STORE_ARCHIVE_BYTES}-byte store limit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::fs::read(zip_path).map_err(|e| format!("cannot read: {e}"))?;
|
||||||
|
let sha256 = hex_digest(&bytes);
|
||||||
|
|
||||||
|
let mut archive =
|
||||||
|
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip archive: {e}"))?;
|
||||||
|
let manifest_bytes = {
|
||||||
|
let entry = archive
|
||||||
|
.by_name("theme.ron")
|
||||||
|
.map_err(|e| format!("no theme.ron in archive: {e}"))?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
entry
|
||||||
|
.take(MAX_MANIFEST_BYTES)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| format!("cannot read theme.ron: {e}"))?;
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed: ManifestMetaOnly =
|
||||||
|
ron::de::from_bytes(&manifest_bytes).map_err(|e| format!("malformed theme.ron: {e}"))?;
|
||||||
|
let meta = parsed.meta;
|
||||||
|
if meta.id.is_empty() {
|
||||||
|
return Err("theme id is empty".to_string());
|
||||||
|
}
|
||||||
|
if meta.id.contains('/') || meta.id.contains('\\') || meta.id.contains("..") {
|
||||||
|
return Err(format!("theme id {:?} is not filesystem-safe", meta.id));
|
||||||
|
}
|
||||||
|
if meta.card_aspect.0 == 0 || meta.card_aspect.1 == 0 {
|
||||||
|
return Err("card_aspect contains a zero".to_string());
|
||||||
|
}
|
||||||
|
Ok((meta, size_bytes, sha256))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase-hex SHA-256 of `bytes`.
|
||||||
|
fn hex_digest(bytes: &[u8]) -> String {
|
||||||
|
let digest = Sha256::digest(bytes);
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/themes` — the full catalog.
|
||||||
|
pub async fn list(State(state): State<AppState>) -> Json<ThemeCatalogResponse> {
|
||||||
|
Json(ThemeCatalogResponse {
|
||||||
|
themes: state.theme_store.entries().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/themes/{id}/download` — the theme archive bytes.
|
||||||
|
pub async fn download(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let path = state
|
||||||
|
.theme_store
|
||||||
|
.zip_paths
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| AppError::NotFound("theme not found".into()))?;
|
||||||
|
let bytes = tokio::fs::read(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("theme archive unreadable: {e}")))?;
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/zip".to_string()),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{id}.zip\""),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/themes/{id}/preview` — the preview PNG, when the store
|
||||||
|
/// ships one for this theme.
|
||||||
|
pub async fn preview(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let path = state
|
||||||
|
.theme_store
|
||||||
|
.preview_paths
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| AppError::NotFound("theme preview not found".into()))?;
|
||||||
|
let bytes = tokio::fs::read(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("theme preview unreadable: {e}")))?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "image/png".to_string())], bytes).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// Minimal valid manifest for id/name pairs — the scan only reads
|
||||||
|
/// the `meta` block, so no face entries are needed.
|
||||||
|
fn manifest(id: &str, name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"(
|
||||||
|
meta: (
|
||||||
|
id: "{id}",
|
||||||
|
name: "{name}",
|
||||||
|
author: "Test Author",
|
||||||
|
version: "1.0.0",
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
),
|
||||||
|
faces: {{}},
|
||||||
|
back: "back.svg",
|
||||||
|
)"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_zip(dir: &Path, file_name: &str, manifest: &str) -> PathBuf {
|
||||||
|
let path = dir.join(file_name);
|
||||||
|
let file = std::fs::File::create(&path).expect("create zip");
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default();
|
||||||
|
writer.start_file("theme.ron", options).expect("start");
|
||||||
|
writer.write_all(manifest.as_bytes()).expect("write");
|
||||||
|
writer.finish().expect("finish");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_of_missing_dir_yields_empty_catalog() {
|
||||||
|
let store = ThemeStore::scan(Path::new("/nonexistent/theme/store"));
|
||||||
|
assert!(store.entries().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_builds_sorted_catalog_with_hashes() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let zebra = write_zip(dir.path(), "b.zip", &manifest("zebra", "Zebra"));
|
||||||
|
write_zip(dir.path(), "a.zip", &manifest("aurora", "Aurora"));
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
let entries = store.entries();
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
// Sorted by display name, not filename.
|
||||||
|
assert_eq!(entries[0].id, "aurora");
|
||||||
|
assert_eq!(entries[1].id, "zebra");
|
||||||
|
assert_eq!(entries[1].download_url, "/api/themes/zebra/download");
|
||||||
|
assert_eq!(entries[1].preview_url, None);
|
||||||
|
|
||||||
|
let zebra_bytes = std::fs::read(&zebra).expect("read zip");
|
||||||
|
assert_eq!(entries[1].sha256, hex_digest(&zebra_bytes));
|
||||||
|
assert_eq!(entries[1].size_bytes, zebra_bytes.len() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_picks_up_preview_png_keyed_by_theme_id() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_zip(dir.path(), "neon.zip", &manifest("neon", "Neon"));
|
||||||
|
std::fs::write(dir.path().join("neon.png"), b"png-bytes").expect("write png");
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
assert_eq!(
|
||||||
|
store.entries()[0].preview_url.as_deref(),
|
||||||
|
Some("/api/themes/neon/preview")
|
||||||
|
);
|
||||||
|
assert!(store.preview_paths.contains_key("neon"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_skips_duplicate_ids_and_keeps_first() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_zip(dir.path(), "1_first.zip", &manifest("dupe", "First"));
|
||||||
|
write_zip(dir.path(), "2_second.zip", &manifest("dupe", "Second"));
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
assert_eq!(store.entries().len(), 1);
|
||||||
|
assert_eq!(store.entries()[0].name, "First");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_skips_malformed_and_unsafe_manifests() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
// Not a zip at all.
|
||||||
|
std::fs::write(dir.path().join("junk.zip"), b"not a zip").expect("write");
|
||||||
|
// Path-separator id must be rejected — it would become a URL
|
||||||
|
// path segment and a client install directory name.
|
||||||
|
write_zip(dir.path(), "evil.zip", &manifest("../evil", "Evil"));
|
||||||
|
// Valid one to prove the scan carries on past failures.
|
||||||
|
write_zip(dir.path(), "ok.zip", &manifest("ok", "Ok"));
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
assert_eq!(store.entries().len(), 1);
|
||||||
|
assert_eq!(store.entries()[0].id, "ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1614,6 +1614,7 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
|
|||||||
let state = solitaire_server::AppState {
|
let state = solitaire_server::AppState {
|
||||||
pool: test_pool().await,
|
pool: test_pool().await,
|
||||||
jwt_secret: TEST_SECRET.to_string(),
|
jwt_secret: TEST_SECRET.to_string(),
|
||||||
|
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
|
||||||
};
|
};
|
||||||
let app = solitaire_server::build_router(state);
|
let app = solitaire_server::build_router(state);
|
||||||
|
|
||||||
@@ -1675,6 +1676,7 @@ async fn sync_push_rate_limit_returns_429_on_11th_request() {
|
|||||||
let state = solitaire_server::AppState {
|
let state = solitaire_server::AppState {
|
||||||
pool: test_pool().await,
|
pool: test_pool().await,
|
||||||
jwt_secret: TEST_SECRET.to_string(),
|
jwt_secret: TEST_SECRET.to_string(),
|
||||||
|
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
|
||||||
};
|
};
|
||||||
let app = solitaire_server::build_router(state);
|
let app = solitaire_server::build_router(state);
|
||||||
|
|
||||||
@@ -1879,3 +1881,125 @@ async fn replay_upload_malformed_body_returns_400() {
|
|||||||
let resp = post_authed(app, "/api/replays", &token, bad).await;
|
let resp = post_authed(app, "/api/replays", &token, bad).await;
|
||||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Theme store
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Writes a minimal theme zip (manifest only — the catalog scan reads
|
||||||
|
/// just the `meta` block) into `dir` and returns its path.
|
||||||
|
fn write_store_zip(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
file_name: &str,
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> std::path::PathBuf {
|
||||||
|
use std::io::Write as _;
|
||||||
|
let manifest = format!(
|
||||||
|
r#"(
|
||||||
|
meta: (
|
||||||
|
id: "{id}",
|
||||||
|
name: "{name}",
|
||||||
|
author: "Test Author",
|
||||||
|
version: "1.0.0",
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
),
|
||||||
|
faces: {{}},
|
||||||
|
back: "back.svg",
|
||||||
|
)"#
|
||||||
|
);
|
||||||
|
let path = dir.join(file_name);
|
||||||
|
let file = std::fs::File::create(&path).expect("create zip");
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default();
|
||||||
|
writer.start_file("theme.ron", options).expect("start_file");
|
||||||
|
writer
|
||||||
|
.write_all(manifest.as_bytes())
|
||||||
|
.expect("write manifest");
|
||||||
|
writer.finish().expect("finish zip");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/themes` returns the scanned catalog as JSON, no auth needed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn theme_catalog_lists_scanned_themes() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||||
|
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
|
||||||
|
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/themes")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("build request");
|
||||||
|
let resp = app.oneshot(req).await.expect("oneshot");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("read body");
|
||||||
|
let catalog: solitaire_sync::ThemeCatalogResponse =
|
||||||
|
serde_json::from_slice(&body).expect("parse catalog");
|
||||||
|
assert_eq!(catalog.themes.len(), 1);
|
||||||
|
let entry = &catalog.themes[0];
|
||||||
|
assert_eq!(entry.id, "neon");
|
||||||
|
assert_eq!(entry.name, "Neon");
|
||||||
|
assert_eq!(entry.download_url, "/api/themes/neon/download");
|
||||||
|
assert_eq!(entry.preview_url, None);
|
||||||
|
let zip_bytes = std::fs::read(&zip_path).expect("read zip");
|
||||||
|
assert_eq!(entry.size_bytes, zip_bytes.len() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The download endpoint streams back exactly the bytes on disk, so a
|
||||||
|
/// client hashing the response gets the catalog's sha256.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn theme_download_returns_archive_bytes() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||||
|
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
|
||||||
|
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/themes/neon/download")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("build request");
|
||||||
|
let resp = app.oneshot(req).await.expect("oneshot");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("application/zip")
|
||||||
|
);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("read body");
|
||||||
|
assert_eq!(&body[..], &std::fs::read(&zip_path).expect("read zip")[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown ids 404 on both file endpoints; a theme without preview art
|
||||||
|
/// 404s on `/preview` while still serving its download.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn theme_unknown_id_and_missing_preview_return_404() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||||
|
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
|
||||||
|
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
|
||||||
|
|
||||||
|
for uri in [
|
||||||
|
"/api/themes/nope/download",
|
||||||
|
"/api/themes/nope/preview",
|
||||||
|
"/api/themes/neon/preview",
|
||||||
|
] {
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(uri)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("build request");
|
||||||
|
let resp = app.clone().oneshot(req).await.expect("oneshot");
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"expected 404 for {uri}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1649,63 +1649,63 @@ function __wbg_get_imports() {
|
|||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114857, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9843, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7364, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h876550298b312ff8);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9840, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9834, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h545edb23183e448a);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000d: function(arg0) {
|
__wbindgen_cast_000000000000000d: function(arg0) {
|
||||||
@@ -1769,55 +1769,55 @@ function __wbg_get_imports() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1) {
|
function wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c(arg0, arg1) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h545edb23183e448a(arg0, arg1);
|
wasm.wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c(arg0, arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_3(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_4(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_5(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_6(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_7(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_8(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h15aa57dbc666225a_9(arg0, arg1, arg2);
|
wasm.wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9(arg0, arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__hd94d76233321402f(arg0, arg1, arg2) {
|
||||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hfb2e9a2f0bbd9ecc(arg0, arg1, arg2);
|
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hd94d76233321402f(arg0, arg1, arg2);
|
||||||
if (ret[1]) {
|
if (ret[1]) {
|
||||||
throw takeFromExternrefTable0(ret[0]);
|
throw takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3) {
|
function wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e(arg0, arg1, arg2, arg3) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h876550298b312ff8(arg0, arg1, arg2, arg3);
|
wasm.wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e(arg0, arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, arg2) {
|
function wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1(arg0, arg1, arg2) {
|
||||||
wasm.wasm_bindgen__convert__closures_____invoke__h657f46feffff6fe4(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
|
wasm.wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1(arg0, arg1, isLikeNone(arg2) ? 0 : addToExternrefTable0(arg2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -10,5 +10,8 @@ uuid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ pub mod achievements;
|
|||||||
pub mod merge;
|
pub mod merge;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
pub mod theme_store;
|
||||||
|
|
||||||
pub use achievements::AchievementRecord;
|
pub use achievements::AchievementRecord;
|
||||||
pub use merge::{merge, merge_at};
|
pub use merge::{merge, merge_at};
|
||||||
pub use progress::{PlayerProgress, level_for_xp};
|
pub use progress::{PlayerProgress, level_for_xp};
|
||||||
pub use stats::StatsSnapshot;
|
pub use stats::StatsSnapshot;
|
||||||
|
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! Theme-store catalog wire types.
|
||||||
|
//!
|
||||||
|
//! Shared between `solitaire_server` (which builds the catalog by
|
||||||
|
//! scanning its theme-store directory) and `solitaire_data` (which
|
||||||
|
//! fetches it and downloads theme archives). These are **additive**
|
||||||
|
//! API types: they do not participate in [`crate::SyncPayload`] and
|
||||||
|
//! changing them cannot break the sync wire format.
|
||||||
|
//!
|
||||||
|
//! Store entries describe downloadable card-art theme archives — the
|
||||||
|
//! same `.zip` format consumed by the engine's theme importer (a
|
||||||
|
//! `theme.ron` manifest plus 53 SVGs).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One downloadable theme in the server's catalog.
|
||||||
|
///
|
||||||
|
/// URLs are server-relative (e.g. `/api/themes/neon/download`) so a
|
||||||
|
/// client can join them onto whichever base URL it is configured with.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ThemeCatalogEntry {
|
||||||
|
/// Unique theme id — `meta.id` from the archive's `theme.ron`.
|
||||||
|
/// Also the id the theme installs under on the client.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name shown in the store UI.
|
||||||
|
pub name: String,
|
||||||
|
/// Author attribution (free-form text).
|
||||||
|
pub author: String,
|
||||||
|
/// Version string (conventionally semver).
|
||||||
|
pub version: String,
|
||||||
|
/// Card aspect ratio as `(numerator, denominator)`.
|
||||||
|
pub card_aspect: (u32, u32),
|
||||||
|
/// Size of the `.zip` archive in bytes. Clients use this for
|
||||||
|
/// display and as a pre-download sanity bound.
|
||||||
|
pub size_bytes: u64,
|
||||||
|
/// Lowercase-hex SHA-256 of the `.zip` archive. Clients MUST
|
||||||
|
/// verify the downloaded bytes against this digest before handing
|
||||||
|
/// the archive to the theme importer.
|
||||||
|
pub sha256: String,
|
||||||
|
/// Server-relative URL of the theme archive.
|
||||||
|
pub download_url: String,
|
||||||
|
/// Server-relative URL of a PNG preview, when the store ships one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preview_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body of the catalog listing endpoint (`GET /api/themes`).
|
||||||
|
///
|
||||||
|
/// Wrapped in a struct (rather than a bare `Vec`) so future additive
|
||||||
|
/// fields — pagination, store revision, purchase metadata — don't
|
||||||
|
/// break existing clients.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ThemeCatalogResponse {
|
||||||
|
/// Every theme currently offered by the server, sorted by name.
|
||||||
|
pub themes: Vec<ThemeCatalogEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry() -> ThemeCatalogEntry {
|
||||||
|
ThemeCatalogEntry {
|
||||||
|
id: "neon".into(),
|
||||||
|
name: "Neon".into(),
|
||||||
|
author: "Rusty Solitaire".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
size_bytes: 123_456,
|
||||||
|
sha256: "ab".repeat(32),
|
||||||
|
download_url: "/api/themes/neon/download".into(),
|
||||||
|
preview_url: Some("/api/themes/neon/preview".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_round_trips_through_json() {
|
||||||
|
let response = ThemeCatalogResponse {
|
||||||
|
themes: vec![entry()],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&response).expect("serialize");
|
||||||
|
let back: ThemeCatalogResponse = serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(back, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_preview_url_deserializes_to_none() {
|
||||||
|
// Older servers (or themes without preview art) omit the key
|
||||||
|
// entirely; the field must default rather than error.
|
||||||
|
let json = r#"{
|
||||||
|
"id": "neon",
|
||||||
|
"name": "Neon",
|
||||||
|
"author": "Rusty Solitaire",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"card_aspect": [2, 3],
|
||||||
|
"size_bytes": 1,
|
||||||
|
"sha256": "00",
|
||||||
|
"download_url": "/api/themes/neon/download"
|
||||||
|
}"#;
|
||||||
|
let entry: ThemeCatalogEntry = serde_json::from_str(json).expect("deserialize");
|
||||||
|
assert_eq!(entry.preview_url, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-76
@@ -19,11 +19,14 @@
|
|||||||
//! is the contract.
|
//! is the contract.
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
|
||||||
use solitaire_core::error::MoveError;
|
use solitaire_core::error::MoveError;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{GameMode, GameState},
|
||||||
|
};
|
||||||
|
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Mirrors `solitaire_data::Replay` v3.
|
/// Mirrors `solitaire_data::Replay` v3.
|
||||||
@@ -145,21 +148,10 @@ impl ReplayPlayer {
|
|||||||
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||||
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||||
};
|
};
|
||||||
let foundations: [Vec<CardSnapshot>; 4] = [
|
let foundations: [Vec<CardSnapshot>; 4] =
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
let tableaus: [Vec<CardSnapshot>; 7] =
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)),
|
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
||||||
];
|
|
||||||
let tableaus: [Vec<CardSnapshot>; 7] = [
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau1)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau2)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau3)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau4)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau5)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau6)),
|
|
||||||
pile_cards(KlondikePile::Tableau(Tableau::Tableau7)),
|
|
||||||
];
|
|
||||||
StateSnapshot {
|
StateSnapshot {
|
||||||
step_idx: self.step_idx,
|
step_idx: self.step_idx,
|
||||||
total_steps: self.moves.len(),
|
total_steps: self.moves.len(),
|
||||||
@@ -353,21 +345,8 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
|
|||||||
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
||||||
let stock = game.stock_cards();
|
let stock = game.stock_cards();
|
||||||
let waste = game.waste_cards();
|
let waste = game.waste_cards();
|
||||||
let foundations = [
|
let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation1)),
|
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation2)),
|
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation3)),
|
|
||||||
game.pile(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
||||||
];
|
|
||||||
let tableaus = [
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau1)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau2)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau3)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau4)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau5)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau6)),
|
|
||||||
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
||||||
let mut duplicate_cards = Vec::new();
|
let mut duplicate_cards = Vec::new();
|
||||||
@@ -426,7 +405,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
|
|||||||
false
|
false
|
||||||
});
|
});
|
||||||
|
|
||||||
let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
|
let soft_lock =
|
||||||
|
!game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
|
||||||
|
|
||||||
let state_ok = duplicate_cards.is_empty()
|
let state_ok = duplicate_cards.is_empty()
|
||||||
&& missing_cards.is_empty()
|
&& missing_cards.is_empty()
|
||||||
@@ -488,21 +468,8 @@ impl SolitaireGame {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(CardSnapshot::from)
|
.map(CardSnapshot::from)
|
||||||
.collect(),
|
.collect(),
|
||||||
foundations: [
|
foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation3)),
|
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation4)),
|
|
||||||
],
|
|
||||||
tableaus: [
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau1)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau2)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau3)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau4)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau5)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau6)),
|
|
||||||
cards(KlondikePile::Tableau(Tableau::Tableau7)),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,34 +480,21 @@ impl SolitaireGame {
|
|||||||
let slot: u8 = s["foundation-".len()..]
|
let slot: u8 = s["foundation-".len()..]
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| format!("bad pile: {s}"))?;
|
.map_err(|_| format!("bad pile: {s}"))?;
|
||||||
if slot >= 4 {
|
let foundation = solitaire_core::FOUNDATIONS
|
||||||
return Err(format!("foundation slot out of range: {slot}"));
|
.get(slot as usize)
|
||||||
}
|
.copied()
|
||||||
Ok(KlondikePile::Foundation(match slot {
|
.ok_or_else(|| format!("foundation slot out of range: {slot}"))?;
|
||||||
0 => Foundation::Foundation1,
|
Ok(KlondikePile::Foundation(foundation))
|
||||||
1 => Foundation::Foundation2,
|
|
||||||
2 => Foundation::Foundation3,
|
|
||||||
3 => Foundation::Foundation4,
|
|
||||||
_ => return Err(format!("foundation slot out of range: {slot}")),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
_ if s.starts_with("tableau-") => {
|
_ if s.starts_with("tableau-") => {
|
||||||
let col: usize = s["tableau-".len()..]
|
let col: usize = s["tableau-".len()..]
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| format!("bad pile: {s}"))?;
|
.map_err(|_| format!("bad pile: {s}"))?;
|
||||||
if col >= 7 {
|
let tableau = solitaire_core::TABLEAUS
|
||||||
return Err(format!("tableau col out of range: {col}"));
|
.get(col)
|
||||||
}
|
.copied()
|
||||||
Ok(KlondikePile::Tableau(match col {
|
.ok_or_else(|| format!("tableau col out of range: {col}"))?;
|
||||||
0 => Tableau::Tableau1,
|
Ok(KlondikePile::Tableau(tableau))
|
||||||
1 => Tableau::Tableau2,
|
|
||||||
2 => Tableau::Tableau3,
|
|
||||||
3 => Tableau::Tableau4,
|
|
||||||
4 => Tableau::Tableau5,
|
|
||||||
5 => Tableau::Tableau6,
|
|
||||||
6 => Tableau::Tableau7,
|
|
||||||
_ => return Err(format!("tableau col out of range: {col}")),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
_ => Err(format!("unknown pile: {s}")),
|
_ => Err(format!("unknown pile: {s}")),
|
||||||
}
|
}
|
||||||
@@ -936,7 +890,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
||||||
if let Err(e) = game.apply_legal_move_native(idx) {
|
if let Err(e) = game.apply_legal_move_native(idx) {
|
||||||
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
|
panic!(
|
||||||
|
"failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,9 +1077,14 @@ mod tests {
|
|||||||
let stock_before = game.game.stock_cards().len();
|
let stock_before = game.game.stock_cards().len();
|
||||||
let waste_before = game.game.waste_cards().len();
|
let waste_before = game.game.waste_cards().len();
|
||||||
|
|
||||||
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");
|
assert!(
|
||||||
|
stock_before > 0,
|
||||||
|
"seed {seed}: stock must be non-empty at start"
|
||||||
|
);
|
||||||
|
|
||||||
game.game.draw().expect("draw must succeed when stock is non-empty");
|
game.game
|
||||||
|
.draw()
|
||||||
|
.expect("draw must succeed when stock is non-empty");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
game.game.stock_cards().len(),
|
game.game.stock_cards().len(),
|
||||||
@@ -1152,7 +1113,9 @@ mod tests {
|
|||||||
"seed {seed}: stock must have at least 3 cards for this test"
|
"seed {seed}: stock must have at least 3 cards for this test"
|
||||||
);
|
);
|
||||||
|
|
||||||
game.game.draw().expect("draw must succeed when stock has cards");
|
game.game
|
||||||
|
.draw()
|
||||||
|
.expect("draw must succeed when stock has cards");
|
||||||
|
|
||||||
let expected_drawn = stock_before.min(3);
|
let expected_drawn = stock_before.min(3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ pub fn start() {
|
|||||||
// texture-dimension limit is now taken from the adapter (see
|
// texture-dimension limit is now taken from the adapter (see
|
||||||
// the RenderPlugin below), so this is purely a quality/perf
|
// the RenderPlugin below), so this is purely a quality/perf
|
||||||
// choice, no longer a crash-avoidance hack.
|
// choice, no longer a crash-avoidance hack.
|
||||||
resolution: WindowResolution::default()
|
resolution: WindowResolution::default().with_scale_factor_override(1.0),
|
||||||
.with_scale_factor_override(1.0),
|
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
..default()
|
..default()
|
||||||
|
|||||||
Reference in New Issue
Block a user