Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8d8eee9a | |||
| 15c924c3dc | |||
| ede58f9666 | |||
| ccfb9394e0 | |||
| b5c1ba4867 | |||
| d179d9d582 | |||
| db1cc58f3a | |||
| 255b781420 | |||
| 5b5d587818 | |||
| 2b2e7a7f2c | |||
| 5c4d440b31 | |||
| 374858ab6d | |||
| 38b82d4858 | |||
| 4700bd7912 | |||
| d4448bf0cd | |||
| e1d91bee73 | |||
| 9dcd25b3e7 | |||
| 2ef0bce1ea | |||
| 326ef6894a | |||
| caaafe34e0 | |||
| 2254693a7a | |||
| 4ba646738c | |||
| 251d35bc28 | |||
| 1d2b6dc5de | |||
| fe3c3aed31 | |||
| d0e4ce796b | |||
| 9f038250d9 | |||
| 0c69d6859d | |||
| 50d6d41d85 | |||
| 9e4d4a6716 | |||
| 4f849a23b8 | |||
| 2c2f0b592a | |||
| 8c0eb3dfab | |||
| a39e04329e |
@@ -10,9 +10,11 @@ on:
|
|||||||
- 'solitaire_web/**'
|
- 'solitaire_web/**'
|
||||||
- 'solitaire_sync/**'
|
- 'solitaire_sync/**'
|
||||||
- 'solitaire_core/**'
|
- 'solitaire_core/**'
|
||||||
|
- 'solitaire_data/**'
|
||||||
- 'solitaire_engine/**'
|
- 'solitaire_engine/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
|
- 'build_wasm.sh'
|
||||||
- 'solitaire_server/Dockerfile'
|
- 'solitaire_server/Dockerfile'
|
||||||
- '.gitea/workflows/docker-build.yml'
|
- '.gitea/workflows/docker-build.yml'
|
||||||
|
|
||||||
@@ -36,12 +38,9 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||||
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
# the Dockerfile's wasm-builder stage builds them from source inside
|
||||||
# and commits it back (CI is the single source of truth — the artifacts
|
# this image build, so the deployed image always ships fresh wasm.
|
||||||
# aren't byte-reproducible on contributor machines). That pkg/ commit then
|
|
||||||
# triggers this workflow, so the deployed image always ships fresh wasm.
|
|
||||||
# No drift check is needed here.
|
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
||||||
# locally, run on every master push and pull request. Until this workflow
|
# 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
|
# 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.
|
# master was entirely unguarded.
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -29,14 +29,21 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: rust-host
|
||||||
|
|
||||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
# 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.
|
# 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
|
# line-tables-only keeps file:line in panic backtraces while cutting the
|
||||||
# link's memory footprint enough to fit the runner.
|
# link's memory footprint enough to fit the runner.
|
||||||
|
#
|
||||||
|
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
|
||||||
|
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
||||||
|
# two+ simultaneous ld processes OOM-killed the runner again even at
|
||||||
|
# line-tables-only. Two jobs keeps at most two links in flight — the
|
||||||
|
# compile-throughput cost is small next to the cache-warm build.
|
||||||
env:
|
env:
|
||||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||||
|
CARGO_BUILD_JOBS: '2'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ on:
|
|||||||
- 'solitaire_server/src/**'
|
- 'solitaire_server/src/**'
|
||||||
- 'solitaire_server/e2e/**'
|
- 'solitaire_server/e2e/**'
|
||||||
- 'solitaire_wasm/**'
|
- 'solitaire_wasm/**'
|
||||||
|
- 'solitaire_web/**'
|
||||||
|
- 'solitaire_engine/**'
|
||||||
|
- 'solitaire_data/**'
|
||||||
- 'solitaire_core/**'
|
- 'solitaire_core/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
|
- 'build_wasm.sh'
|
||||||
- '.gitea/workflows/web-e2e.yml'
|
- '.gitea/workflows/web-e2e.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -24,10 +28,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Cache cargo build
|
- name: Cache cargo build
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||||
|
# build them here so the served pages have real wasm to load. Tool
|
||||||
|
# versions are pinned; keep in sync with solitaire_server/Dockerfile.
|
||||||
|
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
||||||
|
uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
||||||
|
|
||||||
|
- name: Install binaryen 130 (wasm-opt, pinned)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -sSL \
|
||||||
|
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||||
|
| tar xz
|
||||||
|
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Build WASM artifacts
|
||||||
|
run: ./build_wasm.sh
|
||||||
|
|
||||||
# Prebuild the server so Playwright's `webServer` (which runs
|
# Prebuild the server so Playwright's `webServer` (which runs
|
||||||
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
||||||
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
name: Web WASM Rebuild
|
|
||||||
|
|
||||||
# CI is the single source of truth for solitaire_server/web/pkg/.
|
|
||||||
#
|
|
||||||
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
|
|
||||||
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
|
|
||||||
# flags, the same Cargo.lock and remapped source paths, the output still differs
|
|
||||||
# by host environment. So rather than police freshness with a rebuild-and-diff
|
|
||||||
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
|
|
||||||
# itself on every master change to a wasm-feeding crate and commits them back.
|
|
||||||
#
|
|
||||||
# Result: the deployed pkg/ can't silently rot, and contributors never need to
|
|
||||||
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
|
|
||||||
# workflow's trigger paths (so it does not re-trigger here) but does match
|
|
||||||
# docker-build's, so the refreshed wasm deploys.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
paths:
|
|
||||||
- 'solitaire_core/**'
|
|
||||||
- 'solitaire_engine/**'
|
|
||||||
- 'solitaire_data/**'
|
|
||||||
- 'solitaire_sync/**'
|
|
||||||
- 'solitaire_wasm/**'
|
|
||||||
- 'solitaire_web/**'
|
|
||||||
- 'Cargo.toml'
|
|
||||||
- 'Cargo.lock'
|
|
||||||
- 'build_wasm.sh'
|
|
||||||
- '.gitea/workflows/web-wasm-rebuild.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: web-wasm-rebuild
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rebuild:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.CI_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install Rust 1.95.0
|
|
||||||
uses: dtolnay/rust-toolchain@master
|
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
|
||||||
targets: wasm32-unknown-unknown
|
|
||||||
|
|
||||||
- name: Cache cargo build
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
|
||||||
uses: taiki-e/install-action@v2
|
|
||||||
with:
|
|
||||||
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
|
||||||
|
|
||||||
- name: Install binaryen 130 (wasm-opt, pinned)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl -sSL \
|
|
||||||
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
|
||||||
| tar xz
|
|
||||||
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Rebuild WASM artifacts
|
|
||||||
run: ./build_wasm.sh
|
|
||||||
|
|
||||||
- name: Commit refreshed artifacts if changed
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if git diff --quiet -- solitaire_server/web/pkg/; then
|
|
||||||
echo "pkg/ already up to date — nothing to commit."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
git config user.email "ci@gitea.local"
|
|
||||||
git config user.name "Gitea CI"
|
|
||||||
git add solitaire_server/web/pkg/
|
|
||||||
git commit -m "chore(web): regenerate wasm artifacts"
|
|
||||||
# master is unprotected; retry once if the tip moved under us.
|
|
||||||
git push origin HEAD:master || {
|
|
||||||
git fetch origin master
|
|
||||||
git rebase origin/master
|
|
||||||
git push origin HEAD:master
|
|
||||||
}
|
|
||||||
@@ -41,3 +41,7 @@ deploy/*-auth-secret.yaml
|
|||||||
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||||
scripts/*.go
|
scripts/*.go
|
||||||
|
|
||||||
|
# WASM bundles — built by build_wasm.sh locally and by the Docker wasm-builder
|
||||||
|
# stage / web-e2e workflow in CI; never committed (issue #156)
|
||||||
|
solitaire_server/web/pkg/
|
||||||
|
|||||||
+2
-2
@@ -193,7 +193,7 @@ Owns:
|
|||||||
### `solitaire_wasm`
|
### `solitaire_wasm`
|
||||||
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||||
|
|
||||||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build` (`build_wasm.sh`); the output lands in `solitaire_server/web/pkg/` — gitignored, built in CI (Docker `wasm-builder` stage, web-e2e workflow) — and is served statically by the server.
|
||||||
|
|
||||||
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||||||
|
|
||||||
@@ -745,7 +745,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
| Method | Path | Auth | Notes |
|
| Method | Path | Auth | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output — gitignored, produced by `build_wasm.sh` / CI) |
|
||||||
|
|
||||||
### Account Management
|
### Account Management
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -7331,6 +7331,7 @@ dependencies = [
|
|||||||
"card_game",
|
"card_game",
|
||||||
"klondike",
|
"klondike",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-4
@@ -13,9 +13,10 @@
|
|||||||
# Run from the repo root:
|
# Run from the repo root:
|
||||||
# ./build_wasm.sh
|
# ./build_wasm.sh
|
||||||
#
|
#
|
||||||
# The generated pkg/ files are committed to git so self-hosters who don't
|
# The generated pkg/ files are NOT committed to git (issue #156). CI builds
|
||||||
# touch the WASM crates can skip this step. Regenerate after any change to
|
# them where needed: the Docker image's wasm-builder stage for deployment,
|
||||||
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
|
# and the web-e2e workflow for browser tests. Run this script locally before
|
||||||
|
# serving /web or /play from a source checkout.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ wasm-bindgen \
|
|||||||
--out-name canvas \
|
--out-name canvas \
|
||||||
--target web \
|
--target web \
|
||||||
--no-typescript \
|
--no-typescript \
|
||||||
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
"${CARGO_TARGET_DIR:-$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.
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Menu UX Redesign — July 2026
|
||||||
|
|
||||||
|
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
||||||
|
out of scope — this is about **structure and interaction**, not colors or type.
|
||||||
|
|
||||||
|
## Diagnosis (from code survey, 2026-07-07)
|
||||||
|
|
||||||
|
| Surface | Today | Pain |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
|
||||||
|
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
|
||||||
|
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
|
||||||
|
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
|
||||||
|
|
||||||
|
## Phase A — Settings tabs (highest pain, lowest risk)
|
||||||
|
|
||||||
|
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
|
||||||
|
Accessibility · Account`.
|
||||||
|
- *Appearance* = today's Cosmetic (card back, background, card theme,
|
||||||
|
theme store, import).
|
||||||
|
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
|
||||||
|
high-contrast, reduce-motion, touch input mode, tooltip delay.
|
||||||
|
- *Account* = Sync + Privacy merged.
|
||||||
|
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
|
||||||
|
unfolded Fold, desktop): left rail, rows in two columns.
|
||||||
|
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
|
||||||
|
cover screen without scrolling.
|
||||||
|
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
|
||||||
|
resource. The `SettingsButton` enum, input handlers, and persistence are
|
||||||
|
untouched, so the 25 settings tests and the ambiguity gate stay green.
|
||||||
|
|
||||||
|
## Phase B — Home becomes a real home
|
||||||
|
|
||||||
|
- **Hierarchy, top to bottom:**
|
||||||
|
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
|
||||||
|
2. Hero `New Game` — one tap, reuses last mode + deal options.
|
||||||
|
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
|
||||||
|
Seed) — smaller cards, glyph + name + one-line description on wide only.
|
||||||
|
4. Stats strip moves to the bottom (or right pane when unfolded).
|
||||||
|
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
|
||||||
|
level into a disclosure on the Classic card / New Game hero — they are
|
||||||
|
Classic-mode concerns, not global ones.
|
||||||
|
- Cancel becomes `Back to table` and only renders when a game exists.
|
||||||
|
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
|
||||||
|
daily/weekly right.
|
||||||
|
|
||||||
|
## Phase C — HUD menu consolidation
|
||||||
|
|
||||||
|
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
|
||||||
|
path opens Home).
|
||||||
|
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
|
||||||
|
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
|
||||||
|
section dividers in the existing popover, not a new widget.
|
||||||
|
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
|
||||||
|
modal (most already do via `ScrimDismissible`; sweep the stragglers).
|
||||||
|
|
||||||
|
## Phase D — Validation gates
|
||||||
|
|
||||||
|
- Optional Stitch mockups (Terminal design-system asset
|
||||||
|
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
|
||||||
|
Home + Settings before implementing Phase B. Phase A is safe to build
|
||||||
|
straight in-engine.
|
||||||
|
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
|
||||||
|
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
|
||||||
|
|
||||||
|
## Sequencing & risk
|
||||||
|
|
||||||
|
| Phase | Size | Risk | Depends on |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A — Settings tabs | ~1 session | Low (layout-only) | — |
|
||||||
|
| B — Home hierarchy | 1–2 sessions | Medium (touches launch flow) | D mockups if wanted |
|
||||||
|
| C — Menu grouping | small | Low | B (Modes removal) |
|
||||||
|
|
||||||
|
## Phase E — "You" hub (reuses the Phase A tab component)
|
||||||
|
|
||||||
|
Profile, Stats, Achievements, and the replay browser are four separate
|
||||||
|
modals today, reached through the HUD popover one at a time. Fold them
|
||||||
|
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
|
||||||
|
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
|
||||||
|
four destinations and makes the tab component pay for itself twice.
|
||||||
|
Existing per-screen systems keep their markers/handlers; only the outer
|
||||||
|
shell changes (same trick as Phase A).
|
||||||
|
|
||||||
|
## Phase F — Mobile ergonomics (Fold-first)
|
||||||
|
|
||||||
|
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
|
||||||
|
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
|
||||||
|
score-only band stays on top. Desktop keeps the current top band.
|
||||||
|
Biggest one-handed-play win available; folded posture is tall and
|
||||||
|
top-heavy today.
|
||||||
|
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
|
||||||
|
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
|
||||||
|
- Both are additive; game stays fully playable with the top band alone
|
||||||
|
(UI-first rule §3.3 satisfied).
|
||||||
|
|
||||||
|
## Phase G — Post-win flow
|
||||||
|
|
||||||
|
Win summary today is a stats dump with a close. Give it an action
|
||||||
|
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
|
||||||
|
(the upload already returns a share URL; surface it here with copy
|
||||||
|
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
|
||||||
|
4 taps to 1.
|
||||||
|
|
||||||
|
## Phase H — Feedback polish (small, bundling candidates)
|
||||||
|
|
||||||
|
- Unify queued vs. immediate toast styling (two subtly different styles
|
||||||
|
exist today) and anchor both to one position; consider a 5-item toast
|
||||||
|
history on the stats screen.
|
||||||
|
- Theme-store modal: render the preview PNGs the server already serves
|
||||||
|
(v1 is text-only rows).
|
||||||
|
- Hint: optional ghost-motion preview of the suggested move instead of a
|
||||||
|
static highlight (auto-disabled under reduce-motion).
|
||||||
|
|
||||||
|
## Phase I — Onboarding & discoverability
|
||||||
|
|
||||||
|
The first-run onboarding exists, but everything learned after it is
|
||||||
|
invisible: the radial menu (long-press/right-click), hint cycling,
|
||||||
|
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
|
||||||
|
fired by the situation, not a tour (first stall → hint tip; first
|
||||||
|
long-press-able stack → radial tip; each shows once, stored in
|
||||||
|
Settings like `shown_achievement_onboarding` already is). Plus a
|
||||||
|
one-shot **"What's new"** card on first launch after a version bump —
|
||||||
|
ObtainX updates are silent today, so shipped features go unnoticed
|
||||||
|
(nobody will find the theme store on their own).
|
||||||
|
|
||||||
|
## Phase J — Keyboard & focus completeness (desktop + web)
|
||||||
|
|
||||||
|
`ui_focus` already gives deterministic Esc order and tab-walk
|
||||||
|
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
|
||||||
|
keyboard-only play has never been audited end to end. Deliverables: every
|
||||||
|
modal reachable/dismissable without a mouse, a visible focus ring styled
|
||||||
|
to the Terminal system (current focus state is subtle), and a hotkey
|
||||||
|
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
|
||||||
|
of the static Help text. Web build (Rhys) benefits most.
|
||||||
|
|
||||||
|
## Phase K — UI scale & touch-target accessibility
|
||||||
|
|
||||||
|
All type sizes flow through `TYPE_*` tokens and layout through
|
||||||
|
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
|
||||||
|
cheap to wire and genuinely useful on the Fold's dense cover screen.
|
||||||
|
Pair with: respect Android system font scale on first run (seed the
|
||||||
|
setting from it), and a one-time audit that every interactive element
|
||||||
|
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
|
||||||
|
and picker swatches are the suspects). Lands in the Accessibility tab
|
||||||
|
that Phase A creates.
|
||||||
|
|
||||||
|
## Phase L — Empty & loading states, standardized
|
||||||
|
|
||||||
|
Async/empty surfaces each improvise today: leaderboard with no entries,
|
||||||
|
stats with no games, replay browser with no replays, theme store
|
||||||
|
loading/error (just added), sync status text. Define ONE pattern —
|
||||||
|
glyph + one-line explanation + a single next-step action — as a
|
||||||
|
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
|
||||||
|
onto it. Small, mechanical, big perceived-quality win.
|
||||||
|
|
||||||
|
## Phase M — Sync transparency & data stewardship
|
||||||
|
|
||||||
|
The sync layer returns `ConflictReport`s ("data is never silently
|
||||||
|
discarded") — but no UI ever shows them; players can't tell what a merge
|
||||||
|
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
|
||||||
|
values" → tap for detail in the Account tab), a visible last-synced
|
||||||
|
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
|
||||||
|
local **data export/import** (zip of the JSON saves) for device
|
||||||
|
migrations without a server. Closes the trust loop the server work
|
||||||
|
opened.
|
||||||
|
|
||||||
|
## Roadmap tie-ins (already in flight elsewhere)
|
||||||
|
|
||||||
|
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
|
||||||
|
7th card once engine work lands — the 2×3 grid in Phase B should be
|
||||||
|
designed as N-card flow from day one. Spider UI also needs positional
|
||||||
|
card→entity keys (documented in spider.rs module docs).
|
||||||
|
- **Show solution** (feat/solution-line, in flight): lands in the pause
|
||||||
|
menu; Phase C's grouping should leave room for it under a "Game"
|
||||||
|
cluster if the pause menu grows.
|
||||||
|
|
||||||
|
## Sequencing (updated)
|
||||||
|
|
||||||
|
| Phase | Size | Risk | Depends on |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
|
||||||
|
| E — "You" hub | ~1 session | Low | A's tab component |
|
||||||
|
| C — Menu grouping | small | Low | E (popover shrinks) |
|
||||||
|
| B — Home hierarchy | 1–2 sessions | Medium | D mockups if wanted |
|
||||||
|
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
|
||||||
|
| G — Post-win flow | small | Low | — |
|
||||||
|
| H — Feedback polish | small each | Low | — |
|
||||||
|
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
|
||||||
|
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
|
||||||
|
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
|
||||||
|
| L — Empty-state standardization | small | Low | — |
|
||||||
|
| M — Sync transparency & export | ~1 session | Low–Medium | A (Account tab) |
|
||||||
|
|
||||||
|
Suggested order: **A → E → C** (one arc: the tab component and the menu
|
||||||
|
slimming), then **B**, then **F/G/H** as independent follow-ups.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||||
|
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
||||||
|
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
||||||
|
tucked under a "More" card?
|
||||||
|
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
||||||
|
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||||
#
|
#
|
||||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||||
# Stops when the newest docker-build (the deploy) has completed and no
|
# Stops when the newest docker-build (the deploy) has completed — the wasm
|
||||||
# web-wasm-rebuild is still pending — i.e. the full fix is live.
|
# is built inside that image build, so no other workflow gates the deploy.
|
||||||
#
|
#
|
||||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||||
# token is read from ~/.config/tea/config.yml (never printed).
|
# token is read from ~/.config/tea/config.yml (never printed).
|
||||||
@@ -49,16 +49,15 @@ for r in runs:
|
|||||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||||
r.get("status"), r.get("conclusion")))
|
r.get("status"), r.get("conclusion")))
|
||||||
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
||||||
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
|
|
||||||
top = db[0] if db else None
|
top = db[0] if db else None
|
||||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
|
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success")
|
||||||
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
|
print("STATE=%s" % ("LIVE" if live else "DEPLOYING"))
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
echo "$out" | grep -v '^STATE='
|
echo "$out" | grep -v '^STATE='
|
||||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
|
echo "DEPLOY LIVE — newest docker-build succeeded."
|
||||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -37,11 +37,6 @@ fn load_settings() -> Settings {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the Bevy app without entering the event loop.
|
|
||||||
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
|
|
||||||
build_app_with_settings(load_settings(), sync_provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// App entry point — configures runtime services, builds, and runs the app.
|
/// App entry point — configures runtime services, builds, and runs the app.
|
||||||
///
|
///
|
||||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ serde = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
klondike = { workspace = true }
|
klondike = { workspace = true }
|
||||||
card_game = { workspace = true }
|
card_game = { workspace = true }
|
||||||
|
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
|
||||||
|
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
|
||||||
|
# against the same crate version as `klondike::Rng` and Spider deals go
|
||||||
|
# through the identical shuffle stack as Klondike deals.
|
||||||
|
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod error;
|
|||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod klondike_adapter;
|
pub mod klondike_adapter;
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
pub mod spider;
|
||||||
|
|
||||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||||
// downstream crates (engine, wasm) can import from one place without a direct
|
// downstream crates (engine, wasm) can import from one place without a direct
|
||||||
@@ -12,7 +13,7 @@ pub mod scoring;
|
|||||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||||
// 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, SolveError, Suit};
|
||||||
pub use klondike::{
|
pub use klondike::{
|
||||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||||
};
|
};
|
||||||
@@ -21,6 +22,13 @@ pub use klondike::{
|
|||||||
// 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};
|
||||||
|
|
||||||
|
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||||
|
// later phase — nothing outside solitaire_core consumes these yet).
|
||||||
|
pub use spider::{
|
||||||
|
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
|
||||||
|
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
|
||||||
|
};
|
||||||
|
|
||||||
/// All four foundation slots, in slot order.
|
/// All four foundation slots, in slot order.
|
||||||
///
|
///
|
||||||
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
|
|||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
||||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
|
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
|
||||||
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
|
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
|
||||||
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
|
stats_file_path, time_attack_session_path,
|
||||||
time_attack_session_path, time_attack_session_with_now,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod achievements;
|
pub mod achievements;
|
||||||
@@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
|||||||
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
|
||||||
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
|
save_settings_to, settings_file_path,
|
||||||
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
|
|
||||||
load_settings_from, save_settings_to, settings_file_path,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -152,9 +149,7 @@ mod android_keystore;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use auth_tokens::{
|
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
|
||||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub mod sync_client;
|
pub mod sync_client;
|
||||||
pub use sync_client::LocalOnlyProvider;
|
pub use sync_client::LocalOnlyProvider;
|
||||||
|
|||||||
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load stats from the platform default path. Returns default if the path
|
|
||||||
/// is unavailable or the file is missing/corrupt.
|
|
||||||
pub fn load_stats() -> StatsSnapshot {
|
|
||||||
stats_file_path()
|
|
||||||
.map(|p| load_stats_from(&p))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save stats to the platform default path. Returns an error if the platform
|
|
||||||
/// data dir is unavailable or the write fails.
|
|
||||||
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
|
||||||
let path = stats_file_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
|
|
||||||
save_stats_to(&path, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// In-progress game state
|
// In-progress game state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -245,18 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience helper for callers that want to stamp a session with the
|
|
||||||
/// current wall-clock time. Equivalent to constructing the struct
|
|
||||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
|
||||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
|
||||||
let now = Utc::now().timestamp().max(0) as u64;
|
|
||||||
TimeAttackSession {
|
|
||||||
remaining_secs,
|
|
||||||
wins,
|
|
||||||
saved_at_unix_secs: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||||
///
|
///
|
||||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||||
|
|||||||
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
|
|||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_modal::{
|
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
|
||||||
spawn_modal_button, spawn_modal_header,
|
|
||||||
};
|
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
|
||||||
};
|
};
|
||||||
use crate::ui_tooltip::Tooltip;
|
use crate::ui_tooltip::Tooltip;
|
||||||
|
|
||||||
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
|
|||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
.after(StatsUpdate),
|
.after(StatsUpdate),
|
||||||
)
|
)
|
||||||
.add_systems(Update, toggle_achievements_screen)
|
// Open/close/tab handling moved to `you_hub_plugin`
|
||||||
.add_systems(Update, handle_achievements_close_button)
|
// (Phase E) — this plugin now owns body content + scroll.
|
||||||
.add_systems(Update, scroll_achievements_panel)
|
.add_systems(Update, scroll_achievements_panel)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
|
|||||||
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
|
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker on the "Done" button inside the Achievements modal.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct AchievementsCloseButton;
|
|
||||||
|
|
||||||
/// Toggle the achievements overlay — `A` keyboard accelerator or
|
|
||||||
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
|
|
||||||
fn toggle_achievements_screen(
|
|
||||||
mut commands: Commands,
|
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
|
||||||
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
|
|
||||||
achievements: Res<AchievementsResource>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
screens: Query<Entity, With<AchievementsScreen>>,
|
|
||||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
|
|
||||||
) {
|
|
||||||
let button_clicked = requests.read().count() > 0;
|
|
||||||
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(entity) = screens.single() {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
} else if other_modal_scrims.is_empty() {
|
|
||||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Click handler for the modal's "Done" button — despawns the overlay
|
|
||||||
/// the same way the `A` accelerator does.
|
|
||||||
fn handle_achievements_close_button(
|
|
||||||
mut commands: Commands,
|
|
||||||
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
|
|
||||||
screens: Query<Entity, With<AchievementsScreen>>,
|
|
||||||
) {
|
|
||||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for entity in &screens {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||||
/// while the panel is open.
|
/// while the panel is open.
|
||||||
///
|
///
|
||||||
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_achievements_screen(
|
/// Builds the Achievements tab body inside the You hub's card. The
|
||||||
commands: &mut Commands,
|
/// unlock-count line that used to live in the standalone modal's
|
||||||
|
/// header renders as the first body line instead (the hub owns the
|
||||||
|
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
|
||||||
|
/// are unchanged.
|
||||||
|
pub(crate) fn spawn_achievements_body(
|
||||||
|
card: &mut ChildSpawnerCommands,
|
||||||
records: &[AchievementRecord],
|
records: &[AchievementRecord],
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
||||||
let total = ALL_ACHIEVEMENTS.len();
|
let total = ALL_ACHIEVEMENTS.len();
|
||||||
let header = format!("Achievements ({}/{})", unlocked.len(), total);
|
|
||||||
|
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_name = TextFont {
|
let font_name = TextFont {
|
||||||
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
|
|||||||
|
|
||||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||||
|
|
||||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
{
|
||||||
spawn_modal_header(card, header, font_res);
|
// Unlock progress — formerly the standalone modal's header.
|
||||||
|
card.spawn((
|
||||||
|
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
|
||||||
|
font_name.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
// First-time hint — shown until the player has unlocked anything.
|
// First-time hint — shown until the player has unlocked anything.
|
||||||
// The list itself describes individual rewards, but a top-level
|
// The list itself describes individual rewards, but a top-level
|
||||||
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
spawn_modal_actions(card, |actions| {
|
|
||||||
spawn_modal_button(
|
|
||||||
actions,
|
|
||||||
AchievementsCloseButton,
|
|
||||||
"Done",
|
|
||||||
Some("A"),
|
|
||||||
ButtonVariant::Primary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Achievements is a read-only list — clicking the scrim outside
|
|
||||||
// the card dismisses alongside the existing A / Done paths.
|
|
||||||
commands.entity(scrim).insert(ScrimDismissible);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_reward(reward: Reward) -> String {
|
fn format_reward(reward: Reward) -> String {
|
||||||
@@ -668,7 +618,8 @@ mod tests {
|
|||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(StatsPlugin::headless())
|
.add_plugins(StatsPlugin::headless())
|
||||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||||
.add_plugins(AchievementPlugin::headless());
|
.add_plugins(AchievementPlugin::headless())
|
||||||
|
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||||
// MinimalPlugins it isn't auto-registered.
|
// MinimalPlugins it isn't auto-registered.
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
|||||||
@@ -252,10 +252,17 @@ fn advance_card_anims(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep the winit loop awake at full frame rate while slides (including
|
||||||
|
// staggered deals still in their delay phase) are in flight — required
|
||||||
|
// for Android's reactive_low_power focused_mode.
|
||||||
|
if !anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
if anim.delay > 0.0 {
|
if anim.delay > 0.0 {
|
||||||
@@ -558,10 +565,17 @@ fn drive_toast_display(
|
|||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut queue: ResMut<ToastQueue>,
|
mut queue: ResMut<ToastQueue>,
|
||||||
mut active: ResMut<ActiveToast>,
|
mut active: ResMut<ActiveToast>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep the loop ticking while a toast is displayed or queued so the
|
||||||
|
// countdown advances and the despawn frame isn't held hostage by
|
||||||
|
// Android's reactive_low_power wake ceiling.
|
||||||
|
if active.entity.is_some() || !queue.0.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
// Tick down the active toast timer.
|
// Tick down the active toast timer.
|
||||||
@@ -593,9 +607,9 @@ pub enum ToastVariant {
|
|||||||
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||||
/// settings volume notifications, and the auto-complete announcement.
|
/// settings volume notifications, and the auto-complete announcement.
|
||||||
Info,
|
Info,
|
||||||
/// Caution / penalty — gold border. Currently unused by an in-engine
|
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
|
||||||
/// event; kept so future warning-flavoured toasts have a slot.
|
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
|
||||||
#[allow(dead_code)]
|
/// theme-store, and leaderboard warnings).
|
||||||
Warning,
|
Warning,
|
||||||
/// Failure / rejected action — pink border. Used by
|
/// Failure / rejected action — pink border. Used by
|
||||||
/// [`handle_move_rejected_toast`] for illegal-placement
|
/// [`handle_move_rejected_toast`] for illegal-placement
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Sound-effect playback via `kira`.
|
//! Sound-effect playback via `kira`.
|
||||||
//!
|
//!
|
||||||
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
|
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
|
||||||
//! response to gameplay events:
|
//! the ambient loop — and plays them in response to gameplay events:
|
||||||
//!
|
//!
|
||||||
//! | Event | Sound |
|
//! | Event | Sound |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||||
|
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
|
||||||
//!
|
//!
|
||||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||||
@@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
|||||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||||
const RECYCLE_VOLUME: f64 = 0.5;
|
const RECYCLE_VOLUME: f64 = 0.5;
|
||||||
|
|
||||||
/// Volume amplitude for the ambient music loop placeholder.
|
/// Volume amplitude for the ambient music loop.
|
||||||
const AMBIENT_VOLUME: f64 = 0.05;
|
const AMBIENT_VOLUME: f64 = 0.05;
|
||||||
|
|
||||||
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
||||||
@@ -101,7 +102,7 @@ pub struct MuteState {
|
|||||||
pub music_muted: bool,
|
pub music_muted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||||
pub struct AudioPlugin;
|
pub struct AudioPlugin;
|
||||||
|
|
||||||
impl Plugin for AudioPlugin {
|
impl Plugin for AudioPlugin {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ fn drive_auto_complete(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut moves: MessageWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if !state.active {
|
if !state.active {
|
||||||
return;
|
return;
|
||||||
@@ -154,6 +155,10 @@ fn drive_auto_complete(
|
|||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keepalive: the step-interval cooldown only advances on frames that
|
||||||
|
// actually run, so keep the winit loop awake for the whole burst under
|
||||||
|
// Android's reactive_low_power focused_mode.
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
|
||||||
state.cooldown -= time.delta_secs();
|
state.cooldown -= time.delta_secs();
|
||||||
if state.cooldown > 0.0 {
|
if state.cooldown > 0.0 {
|
||||||
|
|||||||
@@ -18,11 +18,6 @@
|
|||||||
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
|
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
|
||||||
//! card "floats up" in the middle of its travel and lands at its correct rest z.
|
//! card "floats up" in the middle of its travel and lands at its correct rest z.
|
||||||
//!
|
//!
|
||||||
//! # Retargeting
|
|
||||||
//!
|
|
||||||
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
|
|
||||||
//! the current interpolated position so the card never snaps.
|
|
||||||
//!
|
|
||||||
//! # Coexistence with `CardAnim`
|
//! # Coexistence with `CardAnim`
|
||||||
//!
|
//!
|
||||||
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
||||||
@@ -33,6 +28,7 @@
|
|||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
use super::curves::{MotionCurve, sample_curve};
|
use super::curves::{MotionCurve, sample_curve};
|
||||||
use super::timing::compute_duration;
|
use super::timing::compute_duration;
|
||||||
@@ -122,8 +118,6 @@ impl CardAnimation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current interpolated XY position without advancing time.
|
/// Returns the current interpolated XY position without advancing time.
|
||||||
///
|
|
||||||
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
|
|
||||||
pub fn current_xy(&self) -> Vec2 {
|
pub fn current_xy(&self) -> Vec2 {
|
||||||
if self.duration <= 0.0 {
|
if self.duration <= 0.0 {
|
||||||
return self.end;
|
return self.end;
|
||||||
@@ -134,90 +128,6 @@ impl CardAnimation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Retarget helper
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Redirects a card to a new destination without snapping or interrupting motion.
|
|
||||||
///
|
|
||||||
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
|
|
||||||
/// if present, or from `Transform` if stationary) and starts a fresh
|
|
||||||
/// [`CardAnimation`] from that position. Duration is recalculated from the
|
|
||||||
/// remaining distance so short paths stay quick.
|
|
||||||
///
|
|
||||||
/// # Velocity continuity
|
|
||||||
///
|
|
||||||
/// When a card is mid-flight, the new animation starts with a small positive
|
|
||||||
/// `elapsed` offset (`carry`) derived from how far through the current animation
|
|
||||||
/// the card is. This preserves a sense of forward momentum: the new curve does
|
|
||||||
/// not restart from zero velocity, avoiding a visible "lurch" when the target
|
|
||||||
/// changes rapidly.
|
|
||||||
///
|
|
||||||
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
|
|
||||||
/// never causes a visible position jump — the card's start position is still
|
|
||||||
/// read from the current transform.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// // Inside a system that decides to move a card to a new target:
|
|
||||||
/// let (entity, transform, anim) = cards.get(card_entity)?;
|
|
||||||
/// retarget_animation(
|
|
||||||
/// &mut commands,
|
|
||||||
/// entity,
|
|
||||||
/// anim, // Option<&CardAnimation>
|
|
||||||
/// transform,
|
|
||||||
/// Vec2::new(400.0, 200.0),
|
|
||||||
/// resting_z,
|
|
||||||
/// MotionCurve::SmoothSnap,
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
pub fn retarget_animation(
|
|
||||||
commands: &mut Commands,
|
|
||||||
entity: Entity,
|
|
||||||
current_anim: Option<&CardAnimation>,
|
|
||||||
transform: &Transform,
|
|
||||||
new_end: Vec2,
|
|
||||||
new_end_z: f32,
|
|
||||||
curve: MotionCurve,
|
|
||||||
) {
|
|
||||||
let (current_xy, current_z, momentum_carry) = match current_anim {
|
|
||||||
Some(anim) if anim.duration > 0.0 => {
|
|
||||||
// Estimate how far into the current animation we are and carry
|
|
||||||
// a small fraction of that progress into the new animation.
|
|
||||||
// This avoids restarting from zero velocity and makes the motion
|
|
||||||
// feel continuous when the target changes mid-flight.
|
|
||||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
|
||||||
// Cap at 10 % of the new animation so there's no visible jump.
|
|
||||||
let carry = (t * 0.12).min(0.10);
|
|
||||||
(anim.current_xy(), transform.translation.z, carry)
|
|
||||||
}
|
|
||||||
_ => (
|
|
||||||
transform.translation.truncate(),
|
|
||||||
transform.translation.z,
|
|
||||||
0.0,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let distance = current_xy.distance(new_end);
|
|
||||||
let duration = compute_duration(distance);
|
|
||||||
|
|
||||||
commands.entity(entity).insert(CardAnimation {
|
|
||||||
start: current_xy,
|
|
||||||
end: new_end,
|
|
||||||
// Start slightly into the new animation to carry forward momentum.
|
|
||||||
elapsed: momentum_carry * duration,
|
|
||||||
duration,
|
|
||||||
curve,
|
|
||||||
delay: 0.0,
|
|
||||||
start_z: current_z,
|
|
||||||
end_z: new_end_z,
|
|
||||||
z_lift: 8.0,
|
|
||||||
scale_start: 1.0,
|
|
||||||
scale_end: 1.0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// System
|
// System
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep the winit event loop awake while any animation (including one
|
||||||
|
// still in its delay phase) needs per-frame ticks. Without this,
|
||||||
|
// Android's reactive_low_power focused_mode only wakes at its 100 ms
|
||||||
|
// ceiling and card slides render at ~10 fps.
|
||||||
|
if !q.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
for (entity, mut transform, mut anim) in &mut q {
|
for (entity, mut transform, mut anim) in &mut q {
|
||||||
|
|||||||
@@ -31,28 +31,6 @@
|
|||||||
//! ));
|
//! ));
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Retarget a card mid-flight:
|
|
||||||
//!
|
|
||||||
//! ```ignore
|
|
||||||
//! use solitaire_engine::card_animation::retarget_animation;
|
|
||||||
//!
|
|
||||||
//! fn handle_drop(
|
|
||||||
//! mut commands: Commands,
|
|
||||||
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
|
|
||||||
//! ) {
|
|
||||||
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
|
|
||||||
//! retarget_animation(
|
|
||||||
//! &mut commands,
|
|
||||||
//! entity,
|
|
||||||
//! anim,
|
|
||||||
//! transform,
|
|
||||||
//! new_target_xy,
|
|
||||||
//! new_target_z,
|
|
||||||
//! MotionCurve::SmoothSnap,
|
|
||||||
//! );
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! # Win cascade with `Expressive` curve
|
//! # Win cascade with `Expressive` curve
|
||||||
//!
|
//!
|
||||||
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
||||||
@@ -80,7 +58,7 @@ pub mod interaction;
|
|||||||
pub mod timing;
|
pub mod timing;
|
||||||
pub mod tuning;
|
pub mod tuning;
|
||||||
|
|
||||||
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
|
pub use animation::{CardAnimation, win_scatter_targets};
|
||||||
pub use chain::AnimationChain;
|
pub use chain::AnimationChain;
|
||||||
pub use curves::{MotionCurve, sample_curve};
|
pub use curves::{MotionCurve, sample_curve};
|
||||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||||
@@ -307,6 +285,49 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for the v0.40.0 Android animation-lag bug: commit
|
||||||
|
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
|
||||||
|
/// premise that animation systems write `RequestRedraw` while active,
|
||||||
|
/// but the writers were never added — card slides rendered at the 100 ms
|
||||||
|
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
|
||||||
|
/// every frame; an idle board must not.
|
||||||
|
#[test]
|
||||||
|
fn active_card_animation_requests_redraw() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(CardAnimationPlugin);
|
||||||
|
|
||||||
|
// Idle board: no redraw requests.
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||||
|
"no RequestRedraw expected while no animation is active"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().spawn((
|
||||||
|
Transform::from_translation(Vec3::ZERO),
|
||||||
|
CardAnimation {
|
||||||
|
start: Vec2::ZERO,
|
||||||
|
end: Vec2::new(100.0, 0.0),
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: 1.0,
|
||||||
|
curve: MotionCurve::Responsive,
|
||||||
|
delay: 0.0,
|
||||||
|
start_z: 0.0,
|
||||||
|
end_z: 0.0,
|
||||||
|
z_lift: 0.0,
|
||||||
|
scale_start: 1.0,
|
||||||
|
scale_end: 1.0,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||||
|
"an active CardAnimation must write RequestRedraw each frame to \
|
||||||
|
sustain the reactive render loop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_animation_instant_snaps_on_zero_duration() {
|
fn card_animation_instant_snaps_on_zero_duration() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin::default())
|
.add_plugins(HomePlugin::default())
|
||||||
.add_plugins(ProfilePlugin)
|
.add_plugins(ProfilePlugin)
|
||||||
|
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
|
||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
|
|||||||
@@ -243,6 +243,11 @@ pub struct ToggleSettingsRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ToggleLeaderboardRequestEvent;
|
pub struct ToggleLeaderboardRequestEvent;
|
||||||
|
|
||||||
|
/// Request to toggle the Home mode launcher. Fired by the HUD
|
||||||
|
/// Menu-popover "Home" row alongside the existing `M` accelerator.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ToggleHomeRequestEvent;
|
||||||
|
|
||||||
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
||||||
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
||||||
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
||||||
@@ -295,15 +300,6 @@ pub struct ForfeitEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ForfeitRequestEvent;
|
pub struct ForfeitRequestEvent;
|
||||||
|
|
||||||
/// Fired when the player clicks "Scan for new themes" in Settings.
|
|
||||||
///
|
|
||||||
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
|
|
||||||
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
|
|
||||||
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
|
|
||||||
/// fires [`InfoToastEvent`] messages to report results.
|
|
||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
|
||||||
pub struct ScanThemesRequestEvent;
|
|
||||||
|
|
||||||
/// Fired when the player requests a hint (H key). Carries the source card ID
|
/// Fired when the player requests a hint (H key). Carries the source card ID
|
||||||
/// and destination pile for visual highlighting.
|
/// and destination pile for visual highlighting.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -276,10 +276,16 @@ fn tick_shake_anim(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sustain full-rate frames for the shake under Android's
|
||||||
|
// reactive_low_power focused_mode.
|
||||||
|
if !anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
@@ -356,10 +362,16 @@ fn tick_settle_anim(
|
|||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sustain full-rate frames for the settle bounce under Android's
|
||||||
|
// reactive_low_power focused_mode.
|
||||||
|
if !anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (entity, mut transform, mut anim) in &mut anims {
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
@@ -581,10 +593,16 @@ fn tick_foundation_flourish(
|
|||||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||||
Without<FoundationFlourish>,
|
Without<FoundationFlourish>,
|
||||||
>,
|
>,
|
||||||
|
mut redraw: MessageWriter<RequestRedraw>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sustain full-rate frames for the flourish under Android's
|
||||||
|
// reactive_low_power focused_mode.
|
||||||
|
if !card_anims.is_empty() || !marker_anims.is_empty() {
|
||||||
|
redraw.write(RequestRedraw);
|
||||||
|
}
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
// Advance the King's scale pulse.
|
// Advance the King's scale pulse.
|
||||||
|
|||||||
@@ -86,13 +86,18 @@ fn toggle_help_screen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Click handler for the modal's "Done" button. F1 toggles the overlay
|
/// Click handler for the modal's "Done" button. F1 toggles the overlay
|
||||||
/// the same way; this just exposes the close action to mouse / touch.
|
/// the same way; Esc closes too, so dismissal matches every other
|
||||||
|
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
|
||||||
|
/// so Esc needs no topmost gate.
|
||||||
fn handle_help_close_button(
|
fn handle_help_close_button(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
|
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
|
||||||
screens: Query<Entity, With<HelpScreen>>,
|
screens: Query<Entity, With<HelpScreen>>,
|
||||||
) {
|
) {
|
||||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||||
|
if !clicked && !esc {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
@@ -583,4 +588,33 @@ mod tests {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Esc must dismiss the Help modal like Done and F1 do (Phase C
|
||||||
|
/// dismissal audit).
|
||||||
|
#[test]
|
||||||
|
fn escape_closes_help_screen() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::F1);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::F1);
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::Escape);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HelpScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"Esc must close the Help modal"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
|||||||
use crate::events::{
|
use crate::events::{
|
||||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||||
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
|
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent,
|
||||||
|
ToggleProfileRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -264,6 +265,7 @@ impl Plugin for HomePlugin {
|
|||||||
.add_message::<StartPlayBySeedRequestEvent>()
|
.add_message::<StartPlayBySeedRequestEvent>()
|
||||||
.add_message::<StartDifficultyRequestEvent>()
|
.add_message::<StartDifficultyRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<ToggleHomeRequestEvent>()
|
||||||
.add_message::<ToggleProfileRequestEvent>()
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
// Defensively register MouseWheel so `scroll_home_panel`
|
// Defensively register MouseWheel so `scroll_home_panel`
|
||||||
@@ -371,6 +373,7 @@ fn spawn_home_on_launch(
|
|||||||
fn toggle_home_screen(
|
fn toggle_home_screen(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut requests: MessageReader<ToggleHomeRequestEvent>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
stats: Option<Res<StatsResource>>,
|
stats: Option<Res<StatsResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
@@ -380,7 +383,8 @@ fn toggle_home_screen(
|
|||||||
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
diff_expanded: Res<DifficultyExpanded>,
|
diff_expanded: Res<DifficultyExpanded>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::KeyM) {
|
let button_clicked = requests.read().count() > 0;
|
||||||
|
if !keys.just_pressed(KeyCode::KeyM) && !button_clicked {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
@@ -1621,6 +1625,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The HUD Menu popover's "Home" row fires
|
||||||
|
/// `ToggleHomeRequestEvent`; it must open Home exactly like the
|
||||||
|
/// `M` accelerator (Phase C: the popover's Play section replaces
|
||||||
|
/// the old Modes row).
|
||||||
|
#[test]
|
||||||
|
fn toggle_home_event_opens_home_screen() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
|
||||||
|
.write(ToggleHomeRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"ToggleHomeRequestEvent must open the Home modal"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second request toggles it closed, matching the M key.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
|
||||||
|
.write(ToggleHomeRequestEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HomeScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"second ToggleHomeRequestEvent must close the Home modal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pressing_m_twice_closes_home_screen() {
|
fn pressing_m_twice_closes_home_screen() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|||||||
@@ -311,44 +311,65 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Each row carries a tooltip alongside its label so hover reveals
|
// One popover row: destination, label, hover tooltip.
|
||||||
// a one-line description of what each overlay shows — mirroring
|
type MenuRow = (MenuOption, &'static str, &'static str);
|
||||||
// the tooltips on the action-bar buttons that opened this popover.
|
// Destinations grouped into labelled sections (Phase C of the menu
|
||||||
let rows: [(MenuOption, &'static str, &'static str); 7] = [
|
// redesign): Play · You · Community · System. 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. Mode
|
||||||
|
// selection lives on Home now, so there is no Modes row.
|
||||||
|
let sections: [(&'static str, &'static [MenuRow]); 4] = [
|
||||||
(
|
(
|
||||||
MenuOption::Help,
|
"Play",
|
||||||
"Help",
|
&[(
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
MenuOption::Home,
|
||||||
|
"Home",
|
||||||
|
"Pick a mode, continue, or start a new game.",
|
||||||
|
)],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
MenuOption::Modes,
|
"You",
|
||||||
"Game Modes",
|
&[
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
(
|
||||||
|
MenuOption::Profile,
|
||||||
|
"Profile",
|
||||||
|
"Your level, XP progress, and sync status.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Stats,
|
||||||
|
"Stats",
|
||||||
|
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Achievements,
|
||||||
|
"Achievements",
|
||||||
|
"Browse unlocked achievements and the rewards still ahead.",
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
MenuOption::Stats,
|
"Community",
|
||||||
"Stats",
|
&[(
|
||||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
MenuOption::Leaderboard,
|
||||||
|
"Leaderboard",
|
||||||
|
"Top players from your sync server. Opt in from Profile.",
|
||||||
|
)],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
MenuOption::Achievements,
|
"System",
|
||||||
"Achievements",
|
&[
|
||||||
"Browse unlocked achievements and the rewards still ahead.",
|
(
|
||||||
),
|
MenuOption::Settings,
|
||||||
(
|
"Settings",
|
||||||
MenuOption::Profile,
|
"Audio, animations, theme, draw mode, and sync.",
|
||||||
"Profile",
|
),
|
||||||
"Your level, XP progress, and sync status.",
|
(
|
||||||
),
|
MenuOption::Help,
|
||||||
(
|
"Help",
|
||||||
MenuOption::Settings,
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
"Settings",
|
),
|
||||||
"Audio, animations, theme, draw mode, and sync.",
|
],
|
||||||
),
|
|
||||||
(
|
|
||||||
MenuOption::Leaderboard,
|
|
||||||
"Leaderboard",
|
|
||||||
"Top players from your sync server. Opt in from Profile.",
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -373,27 +394,48 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
|
|||||||
ZIndex(Z_HUD_POPOVER),
|
ZIndex(Z_HUD_POPOVER),
|
||||||
))
|
))
|
||||||
.with_children(|panel| {
|
.with_children(|panel| {
|
||||||
for (option, label, tooltip) in rows {
|
let section_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
for (section, rows) in sections {
|
||||||
|
// Non-interactive section header — a quiet divider
|
||||||
|
// inside the existing panel, not a new widget.
|
||||||
panel
|
panel
|
||||||
.spawn((
|
.spawn(Node {
|
||||||
option,
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
|
||||||
ActionButton,
|
..default()
|
||||||
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| {
|
.with_children(|b| {
|
||||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
b.spawn((
|
||||||
|
Text::new(section),
|
||||||
|
section_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -422,31 +464,28 @@ pub(super) fn handle_menu_option_click(
|
|||||||
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||||
popovers: Query<Entity, With<MenuPopover>>,
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut home: MessageWriter<ToggleHomeRequestEvent>,
|
||||||
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||||
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
||||||
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
||||||
mut help: MessageWriter<HelpRequestEvent>,
|
mut help: MessageWriter<HelpRequestEvent>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
|
||||||
daily: Option<Res<DailyChallengeResource>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let mut clicked_any = false;
|
let mut clicked_any = false;
|
||||||
let mut open_modes = false;
|
|
||||||
for (interaction, option) in &interaction_query {
|
for (interaction, option) in &interaction_query {
|
||||||
if *interaction != Interaction::Pressed {
|
if *interaction != Interaction::Pressed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
clicked_any = true;
|
clicked_any = true;
|
||||||
match option {
|
match option {
|
||||||
|
MenuOption::Home => {
|
||||||
|
home.write(ToggleHomeRequestEvent);
|
||||||
|
}
|
||||||
MenuOption::Help => {
|
MenuOption::Help => {
|
||||||
help.write(HelpRequestEvent);
|
help.write(HelpRequestEvent);
|
||||||
}
|
}
|
||||||
MenuOption::Modes => {
|
|
||||||
open_modes = true;
|
|
||||||
}
|
|
||||||
MenuOption::Stats => {
|
MenuOption::Stats => {
|
||||||
stats.write(ToggleStatsRequestEvent);
|
stats.write(ToggleStatsRequestEvent);
|
||||||
}
|
}
|
||||||
@@ -470,14 +509,6 @@ pub(super) fn handle_menu_option_click(
|
|||||||
commands.entity(e).despawn();
|
commands.entity(e).despawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if open_modes {
|
|
||||||
spawn_modes_popover(
|
|
||||||
&mut commands,
|
|
||||||
progress.as_deref(),
|
|
||||||
daily.as_deref(),
|
|
||||||
font_res.as_deref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
|||||||
use crate::events::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
||||||
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
UndoRequestEvent, WinStreakMilestoneEvent,
|
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
||||||
@@ -386,8 +386,9 @@ pub enum ModeOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
|
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
|
||||||
/// which exposes the Stats / Achievements / Profile / Settings /
|
/// which exposes the Home / Profile / Stats / Achievements /
|
||||||
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
|
/// Leaderboard / Settings / Help overlays without needing the
|
||||||
|
/// M/P/S/A/L/O/F1 hotkeys.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct MenuButton;
|
pub struct MenuButton;
|
||||||
|
|
||||||
@@ -413,16 +414,18 @@ struct MenuPopoverBackdrop;
|
|||||||
struct ModesPopoverBackdrop;
|
struct ModesPopoverBackdrop;
|
||||||
|
|
||||||
/// One row inside the [`MenuPopover`]. The variant selects which
|
/// One row inside the [`MenuPopover`]. The variant selects which
|
||||||
/// `Toggle*RequestEvent` the click handler fires.
|
/// `Toggle*RequestEvent` the click handler fires. Rows render grouped
|
||||||
|
/// under section headers (Play · You · Community · System); mode
|
||||||
|
/// selection lives on Home, so there is no Modes row here.
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub enum MenuOption {
|
pub enum MenuOption {
|
||||||
Help,
|
Home,
|
||||||
Modes,
|
Profile,
|
||||||
Stats,
|
Stats,
|
||||||
Achievements,
|
Achievements,
|
||||||
Profile,
|
|
||||||
Settings,
|
|
||||||
Leaderboard,
|
Leaderboard,
|
||||||
|
Settings,
|
||||||
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||||
@@ -459,6 +462,7 @@ impl Plugin for HudPlugin {
|
|||||||
.add_message::<StartTimeAttackRequestEvent>()
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<ToggleStatsRequestEvent>()
|
.add_message::<ToggleStatsRequestEvent>()
|
||||||
|
.add_message::<ToggleHomeRequestEvent>()
|
||||||
.add_message::<ToggleAchievementsRequestEvent>()
|
.add_message::<ToggleAchievementsRequestEvent>()
|
||||||
.add_message::<ToggleProfileRequestEvent>()
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
|
|||||||
@@ -786,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() {
|
|||||||
menu_tooltips.len()
|
menu_tooltips.len()
|
||||||
);
|
);
|
||||||
for expected in [
|
for expected in [
|
||||||
|
"Pick a mode, continue, or start a new game.",
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||||
"Browse unlocked achievements and the rewards still ahead.",
|
"Browse unlocked achievements and the rewards still ahead.",
|
||||||
"Your level, XP progress, and sync status.",
|
"Your level, XP progress, and sync status.",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use std::collections::HashMap;
|
|||||||
use bevy::ecs::system::SystemParam;
|
use bevy::ecs::system::SystemParam;
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||||
use bevy::math::{Vec2, Vec3};
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -1806,10 +1806,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
|||||||
all_hints(game).into_iter().next()
|
all_hints(game).into_iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
|
|
||||||
// when the compiler can't see it used.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -343,13 +343,21 @@ fn scroll_leaderboard_panel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Done click or Esc dismisses the leaderboard (Phase C dismissal
|
||||||
|
/// audit). Esc only fires when the leaderboard is the topmost modal —
|
||||||
|
/// with the display-name dialog stacked on top, that dialog owns Esc.
|
||||||
fn handle_leaderboard_close_button(
|
fn handle_leaderboard_close_button(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
|
||||||
mut closed_flag: ResMut<ClosedThisFrame>,
|
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||||
) {
|
) {
|
||||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let esc =
|
||||||
|
keys.just_pressed(KeyCode::Escape) && !screens.is_empty() && other_modal_scrims.is_empty();
|
||||||
|
if !clicked && !esc {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
@@ -888,12 +896,18 @@ fn handle_display_name_confirm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Discards any typed text and closes the display-name editor modal.
|
/// Discards any typed text and closes the display-name editor modal.
|
||||||
|
/// Cancel click or Esc dismisses the display-name dialog without
|
||||||
|
/// saving (Phase C dismissal audit — same contract as the sync-setup
|
||||||
|
/// dialog's Cancel/Esc pair).
|
||||||
fn handle_display_name_cancel(
|
fn handle_display_name_cancel(
|
||||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
|
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
screens: Query<Entity, With<DisplayNameModal>>,
|
screens: Query<Entity, With<DisplayNameModal>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
let clicked = button_q.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||||
|
if !clicked && !esc {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ pub mod ui_theme;
|
|||||||
pub mod ui_tooltip;
|
pub mod ui_tooltip;
|
||||||
pub mod weekly_goals_plugin;
|
pub mod weekly_goals_plugin;
|
||||||
pub mod win_summary_plugin;
|
pub mod win_summary_plugin;
|
||||||
|
pub mod you_hub_plugin;
|
||||||
|
|
||||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -83,8 +84,8 @@ pub use card_animation::{
|
|||||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||||
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
||||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
||||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
|
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve,
|
||||||
sample_curve, win_scatter_targets,
|
win_scatter_targets,
|
||||||
};
|
};
|
||||||
pub use card_plugin::{
|
pub use card_plugin::{
|
||||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||||
@@ -197,3 +198,4 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
|||||||
pub use win_summary_plugin::{
|
pub use win_summary_plugin::{
|
||||||
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
||||||
};
|
};
|
||||||
|
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||||
//! despawned on the second.
|
//! despawned on the second.
|
||||||
|
|
||||||
use bevy::input::ButtonInput;
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use chrono::{Duration, Local, NaiveDate};
|
use chrono::{Duration, Local, NaiveDate};
|
||||||
@@ -13,23 +12,19 @@ use solitaire_data::SyncBackend;
|
|||||||
|
|
||||||
use crate::achievement_plugin::AchievementsResource;
|
use crate::achievement_plugin::AchievementsResource;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::avatar_plugin::AvatarResource;
|
pub(crate) use crate::avatar_plugin::AvatarResource;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[derive(bevy::prelude::Resource)]
|
#[derive(bevy::prelude::Resource)]
|
||||||
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||||
use crate::events::ToggleProfileRequestEvent;
|
use crate::events::ToggleProfileRequestEvent;
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
|
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
|
||||||
use crate::ui_modal::{
|
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
|
||||||
spawn_modal_button, spawn_modal_header,
|
|
||||||
};
|
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Number of days surfaced in the daily-challenge calendar row.
|
/// Number of days surfaced in the daily-challenge calendar row.
|
||||||
@@ -60,13 +55,11 @@ pub struct DailyCalendarDot {
|
|||||||
pub is_today: bool,
|
pub is_today: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers the `P` key toggle for the profile overlay.
|
/// Registers the Profile body's scroll handling. Opening/closing moved
|
||||||
|
/// to the You hub (`you_hub_plugin`), which calls
|
||||||
|
/// [`spawn_profile_body`] for this tab's content.
|
||||||
pub struct ProfilePlugin;
|
pub struct ProfilePlugin;
|
||||||
|
|
||||||
/// Marker on the "Done" button inside the Profile modal.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ProfileCloseButton;
|
|
||||||
|
|
||||||
/// Marker on the scrollable body Node inside the Profile modal.
|
/// Marker on the scrollable body Node inside the Profile modal.
|
||||||
///
|
///
|
||||||
/// The Profile panel renders sync info, progression (incl. 14-day
|
/// The Profile panel renders sync info, progression (incl. 14-day
|
||||||
@@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin {
|
|||||||
// profile-scroll system also runs cleanly under
|
// profile-scroll system also runs cleanly under
|
||||||
// `MinimalPlugins` in tests.
|
// `MinimalPlugins` in tests.
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_systems(
|
// Open/close/tab handling moved to `you_hub_plugin`
|
||||||
Update,
|
// (Phase E) — this plugin now owns body content + scroll.
|
||||||
(
|
.add_systems(Update, scroll_profile_panel);
|
||||||
toggle_profile_screen,
|
|
||||||
handle_profile_close_button,
|
|
||||||
scroll_profile_panel,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,70 +112,12 @@ fn scroll_profile_panel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_profile_close_button(
|
/// Builds the Profile tab body inside the You hub's card. All markers
|
||||||
mut commands: Commands,
|
/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the
|
||||||
close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>,
|
/// scroll and test queries keep working.
|
||||||
screens: Query<Entity, With<ProfileScreen>>,
|
|
||||||
) {
|
|
||||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for entity in &screens {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn toggle_profile_screen(
|
pub(crate) fn spawn_profile_body(
|
||||||
mut commands: Commands,
|
card: &mut ChildSpawnerCommands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
|
||||||
mut requests: MessageReader<ToggleProfileRequestEvent>,
|
|
||||||
settings: Option<Res<SettingsResource>>,
|
|
||||||
sync_status: Option<Res<SyncStatusResource>>,
|
|
||||||
progress: Option<Res<ProgressResource>>,
|
|
||||||
achievements: Option<Res<AchievementsResource>>,
|
|
||||||
stats: Option<Res<StatsResource>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
avatar: Option<Res<AvatarResource>>,
|
|
||||||
screens: Query<Entity, With<ProfileScreen>>,
|
|
||||||
scrims: Query<(), With<ModalScrim>>,
|
|
||||||
) {
|
|
||||||
let button_clicked = requests.read().count() > 0;
|
|
||||||
let p_pressed = keys.just_pressed(KeyCode::KeyP);
|
|
||||||
let esc_pressed = keys.just_pressed(KeyCode::Escape);
|
|
||||||
let already_open = !screens.is_empty();
|
|
||||||
// P / button toggles open-or-close. Esc only ever closes — when
|
|
||||||
// Profile is layered over Home (clicking the new Home stats chip
|
|
||||||
// opens this on top), Esc must dismiss the *topmost* modal.
|
|
||||||
// Without this branch, Esc fell through to Home's cancel handler
|
|
||||||
// and closed the wrong modal.
|
|
||||||
let want_open = !already_open && (p_pressed || button_clicked);
|
|
||||||
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
|
|
||||||
if !want_open && !want_close {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if want_open && !scrims.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(entity) = screens.single() {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
} else {
|
|
||||||
spawn_profile_screen(
|
|
||||||
&mut commands,
|
|
||||||
settings.as_deref(),
|
|
||||||
sync_status.as_deref(),
|
|
||||||
progress.as_deref(),
|
|
||||||
achievements.as_deref(),
|
|
||||||
stats.as_deref(),
|
|
||||||
font_res.as_deref(),
|
|
||||||
avatar.as_deref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_profile_screen(
|
|
||||||
commands: &mut Commands,
|
|
||||||
settings: Option<&SettingsResource>,
|
settings: Option<&SettingsResource>,
|
||||||
sync_status: Option<&SyncStatusResource>,
|
sync_status: Option<&SyncStatusResource>,
|
||||||
progress: Option<&ProgressResource>,
|
progress: Option<&ProgressResource>,
|
||||||
@@ -208,9 +138,7 @@ fn spawn_profile_screen(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
{
|
||||||
spawn_modal_header(card, "Profile", font_res);
|
|
||||||
|
|
||||||
// Scrollable body — the Profile panel renders sync info,
|
// Scrollable body — the Profile panel renders sync info,
|
||||||
// progression (incl. a 14-day calendar), every unlocked
|
// progression (incl. a 14-day calendar), every unlocked
|
||||||
// achievement (up to ~18), and a stats summary, which can
|
// achievement (up to ~18), and a stats summary, which can
|
||||||
@@ -472,20 +400,7 @@ fn spawn_profile_screen(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
spawn_modal_actions(card, |actions| {
|
|
||||||
spawn_modal_button(
|
|
||||||
actions,
|
|
||||||
ProfileCloseButton,
|
|
||||||
"Done",
|
|
||||||
Some("P"),
|
|
||||||
ButtonVariant::Primary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Profile is read-only — opt into click-outside-to-dismiss.
|
|
||||||
commands.entity(scrim).insert(ScrimDismissible);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a fixed-height vertical spacer node.
|
/// Spawn a fixed-height vertical spacer node.
|
||||||
@@ -627,6 +542,7 @@ mod tests {
|
|||||||
use crate::settings_plugin::SettingsPlugin;
|
use crate::settings_plugin::SettingsPlugin;
|
||||||
use crate::stats_plugin::StatsPlugin;
|
use crate::stats_plugin::StatsPlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -637,7 +553,8 @@ mod tests {
|
|||||||
.add_plugins(ProgressPlugin::headless())
|
.add_plugins(ProgressPlugin::headless())
|
||||||
.add_plugins(AchievementPlugin::headless())
|
.add_plugins(AchievementPlugin::headless())
|
||||||
.add_plugins(SettingsPlugin::headless())
|
.add_plugins(SettingsPlugin::headless())
|
||||||
.add_plugins(ProfilePlugin);
|
.add_plugins(ProfilePlugin)
|
||||||
|
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold {
|
|||||||
pub(crate) right_held_secs: f32,
|
pub(crate) right_held_secs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker on the keybind-hint footer row at the bottom edge of the
|
|
||||||
/// banner. Carries two `Text` children: a vim-style mode indicator
|
|
||||||
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
|
|
||||||
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
|
|
||||||
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
|
|
||||||
///
|
|
||||||
/// Surfaces the existing Space-key accelerator visually so the
|
|
||||||
/// UI-first contract from CLAUDE.md §3.3 (every player action has
|
|
||||||
/// a visible UI control) holds for keyboard accelerators too.
|
|
||||||
/// Future commits that wire ESC for stop or ← / → for scrub will
|
|
||||||
/// extend the right-hand text in lockstep — the footer always
|
|
||||||
/// reflects what's actually wired, never aspirational.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayKeybindFooter;
|
|
||||||
|
|
||||||
/// Marker on the bottom-edge **Move Log** panel — a separate root
|
|
||||||
/// UI entity (not a child of the banner) that sits anchored to the
|
|
||||||
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
|
|
||||||
/// plus a row showing the most-recently-applied move.
|
|
||||||
///
|
|
||||||
/// Spawned by `spawn_overlay` alongside the banner and the
|
|
||||||
/// floating progress chip; despawned by `react_to_state_change`
|
|
||||||
/// on the same `Playing → Inactive` transition. Same lifecycle
|
|
||||||
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
|
|
||||||
/// a banner child, because it lives at a different screen anchor.
|
|
||||||
///
|
|
||||||
/// First slice of the move-log mockup at
|
|
||||||
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
|
|
||||||
/// Subsequent commits add prev/next rows and scrolling.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogPanel;
|
|
||||||
|
|
||||||
/// Marker on the move-log panel's header `Text`. Carries
|
|
||||||
/// `▌ MOVE LOG · N/M` while a replay is playing; the
|
|
||||||
/// `update_move_log_header` system repaints it as the cursor
|
|
||||||
/// advances.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogHeader;
|
|
||||||
|
|
||||||
/// Marker on the move-log panel's active-row `Text`. Carries the
|
|
||||||
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
|
|
||||||
/// when `cursor > 0`; empty when no moves have been applied yet
|
|
||||||
/// (initial spawn) or in `Completed`/`Inactive` states. The
|
|
||||||
/// `update_move_log_active_row` system repaints it as the cursor
|
|
||||||
/// advances.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogActiveRow;
|
|
||||||
|
|
||||||
/// Marker on a "previous move" row above the active row.
|
|
||||||
/// `offset` is the 1-based distance backwards from the active
|
|
||||||
/// row: `offset = 1` is the move applied just before the active
|
|
||||||
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
|
|
||||||
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
|
|
||||||
/// rows render above the active row.
|
|
||||||
///
|
|
||||||
/// Empty text when there isn't enough history (`offset >= cursor`,
|
|
||||||
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
|
|
||||||
/// `offset = 1` row populated).
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogPrevRow {
|
|
||||||
/// Distance backwards from the active row (1-based).
|
|
||||||
pub offset: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker on a "next move" row below the active row. `offset`
|
|
||||||
/// is the 1-based distance forward from the active row:
|
|
||||||
/// `offset = 1` is the move that will apply next
|
|
||||||
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
|
|
||||||
/// `offset = 2` is the one after that, and so on. Up to
|
|
||||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
|
||||||
///
|
|
||||||
/// Empty text when there isn't enough remaining replay
|
|
||||||
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
|
|
||||||
/// a 100-move replay shows offset 1 but offset 2 stays empty).
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayOverlayMoveLogNextRow {
|
|
||||||
/// Distance forward from the active row (1-based).
|
|
||||||
pub offset: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
|
|
||||||
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
|
|
||||||
/// to despawn all of them, rather than keeping a separate query per
|
|
||||||
/// entity type. Future sibling overlay surfaces just need this marker
|
|
||||||
/// at spawn time — no changes to the despawn logic required.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct DespawnWithReplay;
|
|
||||||
|
|
||||||
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
|
|
||||||
/// panel that shows a compact summary of the live game state during
|
|
||||||
/// replay: the four foundation tops and the stock / waste heads.
|
|
||||||
/// Spawned as a sibling root entity (same lifecycle pattern as
|
|
||||||
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayMiniTableauPanel;
|
|
||||||
|
|
||||||
/// Marker on the foundations row `Text` inside the mini-tableau panel.
|
|
||||||
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
|
|
||||||
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayMiniTableauFoundations;
|
|
||||||
|
|
||||||
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
|
|
||||||
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
|
|
||||||
/// [`GameStateResource`] changes.
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct ReplayMiniTableauStockWaste;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Plugin
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Bevy plugin that registers every system needed to drive the replay
|
|
||||||
/// overlay's lifecycle.
|
|
||||||
///
|
|
||||||
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
|
|
||||||
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
|
|
||||||
/// the resource manually and exercise the overlay in isolation.
|
|
||||||
pub struct ReplayOverlayPlugin;
|
|
||||||
|
|
||||||
impl Plugin for ReplayOverlayPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
// The systems are ordered so that, on a single frame:
|
|
||||||
// 1. The state-watcher spawns or despawns the overlay if the
|
|
||||||
// `ReplayPlaybackState` resource changed.
|
|
||||||
// 2. The completion-text update swaps the banner label when the
|
|
||||||
// state is `Completed`.
|
|
||||||
// 3. The progress-text update writes the latest "Move N of M".
|
|
||||||
// 4. The Stop-button click handler reads `Interaction::Pressed`
|
|
||||||
// and calls `stop_replay_playback` (which mutates the state).
|
|
||||||
// Putting Stop last means a click in frame N is observed by
|
|
||||||
// `react_to_state_change` in frame N+1, which then despawns the
|
|
||||||
// overlay in response — a clean state-driven loop.
|
|
||||||
// Step-button handler dispatches into the same canonical move
|
|
||||||
// / draw events that the tick loop fires. Register them
|
|
||||||
// defensively here so this plugin can run under
|
|
||||||
// `MinimalPlugins` without the playback plugin attached;
|
|
||||||
// `add_message` is idempotent so the duplicate registration
|
|
||||||
// in production (alongside `replay_playback`) is harmless.
|
|
||||||
app.init_resource::<ReplayScrubKeyHold>()
|
|
||||||
.add_message::<MoveRequestEvent>()
|
|
||||||
.add_message::<DrawRequestEvent>()
|
|
||||||
.add_message::<UndoRequestEvent>()
|
|
||||||
.add_message::<StateChangedEvent>()
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
react_to_state_change,
|
|
||||||
update_banner_label,
|
|
||||||
update_progress_text,
|
|
||||||
update_floating_progress_chip,
|
|
||||||
update_scrub_fill,
|
|
||||||
update_move_log_header,
|
|
||||||
update_move_log_active_row,
|
|
||||||
update_move_log_prev_rows,
|
|
||||||
update_move_log_next_rows,
|
|
||||||
update_mini_tableau_foundations,
|
|
||||||
update_mini_tableau_stock_waste,
|
|
||||||
update_pause_button_label,
|
|
||||||
handle_pause_button,
|
|
||||||
handle_step_button,
|
|
||||||
handle_pause_keyboard,
|
|
||||||
handle_stop_keyboard,
|
|
||||||
handle_arrow_keyboard,
|
|
||||||
handle_stop_button,
|
|
||||||
)
|
|
||||||
.chain(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Spawning
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
|
|
||||||
/// spawns or despawns the overlay accordingly. Treats the resource as the
|
|
||||||
/// single source of truth — the spawn / despawn decision is derived from
|
|
||||||
/// `is_playing() || is_completed()` rather than tracking previous-state
|
|
||||||
/// transitions explicitly, which keeps the system stateless.
|
|
||||||
pub(crate) fn react_to_state_change(
|
|
||||||
mut commands: Commands,
|
|
||||||
state: Res<ReplayPlaybackState>,
|
|
||||||
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
|
||||||
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
) {
|
|
||||||
if !state.is_changed() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let should_be_visible = state.is_playing() || state.is_completed();
|
|
||||||
let already_spawned = roots.iter().next().is_some();
|
|
||||||
|
|
||||||
if should_be_visible && !already_spawned {
|
|
||||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
|
||||||
} else if !should_be_visible && already_spawned {
|
|
||||||
// Despawn all sibling root entities in one loop — every entity
|
|
||||||
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
|
|
||||||
// exactly this purpose.
|
|
||||||
for entity in &despawnable {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
|
||||||
// the per-frame text update systems below repaint the banner label
|
|
||||||
// and progress readout in place without a respawn.
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the banner — a flex-row Node anchored to the top edge of the
|
|
||||||
/// window with three children: the "▌ replay" / "▌ replay complete" label,
|
|
||||||
/// the centred progress text, and the right-aligned Stop button.
|
|
||||||
pub(crate) fn spawn_overlay(
|
|
||||||
commands: &mut Commands,
|
|
||||||
font_res: Option<&FontResource>,
|
|
||||||
state: &ReplayPlaybackState,
|
|
||||||
) {
|
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
||||||
// Clone for the floating chip spawn that runs *after* the
|
|
||||||
// banner's `.with_children(|banner| { ... })` closure consumes
|
|
||||||
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
|
|
||||||
// `Arc`-backed, the clone bumps a refcount.
|
|
||||||
let font_handle_for_floating = font_handle.clone();
|
|
||||||
// Second clone for the scrub-bar label row and keybind footer
|
|
||||||
// inside the outer banner closure. The inner top-row closure
|
|
||||||
// consumes the original `font_handle` for the progress-chip
|
|
||||||
// text, so by the time the outer closure reaches the
|
|
||||||
// label-row / footer spawns the original is gone.
|
|
||||||
// `font_handle_for_labels` is `.clone()`'d (never moved) inside
|
|
||||||
// the labels closure, so it's still alive for the footer
|
|
||||||
// spawn afterwards — single shared clone covers both.
|
|
||||||
let font_handle_for_labels = font_handle.clone();
|
|
||||||
// Third clone for the move-log panel — a separate root
|
|
||||||
// entity spawned after the banner closure closes. Mirrors the
|
|
||||||
// floating-chip clone reasoning.
|
|
||||||
let font_handle_for_move_log = font_handle.clone();
|
|
||||||
// Fourth clone for the mini-tableau preview panel.
|
|
||||||
let font_handle_for_mini_tableau = font_handle.clone();
|
|
||||||
|
|
||||||
let banner_label = if state.is_completed() {
|
|
||||||
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
|
||||||
} else {
|
|
||||||
"\u{258C} replay" // ▌
|
|
||||||
};
|
|
||||||
let progress_label = format_progress(state);
|
|
||||||
|
|
||||||
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
|
|
||||||
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
|
|
||||||
// in the UI stacking context. World-space sprites (cards, badges) are
|
|
||||||
// always below any UI node, so the dim layer darkens the entire
|
|
||||||
// gameplay scene without needing to touch card_plugin. No Interaction
|
|
||||||
// component — purely visual.
|
|
||||||
commands.spawn((
|
|
||||||
ReplayTableauDimLayer,
|
|
||||||
DespawnWithReplay,
|
|
||||||
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::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
|
|
||||||
ZIndex(Z_REPLAY_DIM),
|
|
||||||
GlobalZIndex(Z_REPLAY_DIM),
|
|
||||||
));
|
|
||||||
|
|
||||||
let banner_bg = Color::srgba(
|
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
|
||||||
BG_ELEVATED_HI.to_srgba().blue,
|
|
||||||
BANNER_ALPHA,
|
|
||||||
);
|
|
||||||
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
ReplayOverlayRoot,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
top: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(BANNER_HEIGHT),
|
|
||||||
// Column outer so the content row sits above the 1px
|
|
||||||
// scrub bar at the bottom edge.
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(banner_bg),
|
|
||||||
// Pin the banner to its z layer in both the local and the
|
|
||||||
// global stacking context — `GlobalZIndex` matters because
|
|
||||||
// the overlay is a top-level Node (no parent), and Bevy 0.18
|
|
||||||
// has historically had subtle stacking-context drift here.
|
|
||||||
ZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
))
|
|
||||||
.with_children(|banner| {
|
|
||||||
// Top row: the existing content (label / progress / Stop).
|
|
||||||
banner
|
|
||||||
.spawn(Node {
|
|
||||||
flex_grow: 1.0,
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
justify_content: JustifyContent::SpaceBetween,
|
|
||||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
column_gap: VAL_SPACE_4,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
// Left: column with the accent "▌ replay" headline
|
|
||||||
// above and a small `GAME #YYYY-DDD` caption below.
|
|
||||||
// The caption mirrors the mockup's right-anchored
|
|
||||||
// game identifier but stays visually grouped with
|
|
||||||
// the headline so the two pieces of "this is a
|
|
||||||
// replay of game X" read as a single unit.
|
|
||||||
row.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
align_items: AlignItems::FlexStart,
|
|
||||||
row_gap: Val::Px(2.0),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|left| {
|
|
||||||
left.spawn((
|
|
||||||
ReplayOverlayBannerText,
|
|
||||||
Text::new(banner_label),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_HEADLINE,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
left.spawn((
|
|
||||||
ReplayOverlayGameCaption,
|
|
||||||
Text::new(format_game_caption(state).unwrap_or_default()),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Centre: progress readout, wrapped in a 1 px
|
|
||||||
// ACCENT_PRIMARY-bordered chip so it reads as a
|
|
||||||
// discrete callout rather than free-floating
|
|
||||||
// text. No fill — the Terminal aesthetic gets
|
|
||||||
// depth from borders + tonal layering, not
|
|
||||||
// shadows. The marker stays on the inner Text so
|
|
||||||
// `update_progress_text` keeps working unchanged.
|
|
||||||
row.spawn((
|
|
||||||
Node {
|
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BorderColor::all(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|chip| {
|
|
||||||
chip.spawn((
|
|
||||||
ReplayOverlayProgressText,
|
|
||||||
Text::new(progress_label),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle,
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Right: Stop button. Tertiary variant — the
|
|
||||||
// action is available but not the loudest element
|
|
||||||
// in the banner; the "Replay" primary accent owns
|
|
||||||
// that slot. `spawn_modal_button` gives us hover /
|
|
||||||
// press paint and focus rings for free via the
|
|
||||||
// existing `UiModalPlugin` paint system.
|
|
||||||
row.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
column_gap: VAL_SPACE_2,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|wrap| {
|
|
||||||
// Pause / Resume label is set from the current
|
|
||||||
// state so a freshly-spawned overlay (which
|
|
||||||
// currently always starts unpaused) reads
|
|
||||||
// "Pause". `update_pause_button_label`
|
|
||||||
// repaints it whenever the state changes.
|
|
||||||
spawn_modal_button(
|
|
||||||
wrap,
|
|
||||||
ReplayPauseButton,
|
|
||||||
pause_button_label(state),
|
|
||||||
None,
|
|
||||||
ButtonVariant::Tertiary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_button(
|
|
||||||
wrap,
|
|
||||||
ReplayStepButton,
|
|
||||||
"Step",
|
|
||||||
None,
|
|
||||||
ButtonVariant::Tertiary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_button(
|
|
||||||
wrap,
|
|
||||||
ReplayStopButton,
|
|
||||||
"Stop",
|
|
||||||
None,
|
|
||||||
ButtonVariant::Tertiary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
|
|
||||||
// fill in `ACCENT_PRIMARY`. The fill width is rewritten by
|
|
||||||
// [`update_scrub_fill`] every tick the cursor advances.
|
|
||||||
// Initial fill width matches the spawn-time progress so the
|
|
||||||
// first-frame paint already reflects state instead of
|
|
||||||
// popping from 0 → cursor on the first tick.
|
|
||||||
let initial_scrub_pct = scrub_pct(state);
|
|
||||||
let win_pct = win_move_marker_pct(state);
|
|
||||||
banner
|
|
||||||
.spawn((
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(1.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BORDER_SUBTLE),
|
|
||||||
// HC marker: bumps the 1 px track from #505050
|
|
||||||
// → #a0a0a0 under high-contrast mode. The track
|
|
||||||
// paints via BackgroundColor (it's a 1 px Node,
|
|
||||||
// not a border on a wider container) so the
|
|
||||||
// BorderColor-targeting HighContrastBorder marker
|
|
||||||
// doesn't apply — HighContrastBackground is the
|
|
||||||
// parallel primitive for this case.
|
|
||||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|track| {
|
|
||||||
track.spawn((
|
|
||||||
ReplayOverlayScrubFill,
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(initial_scrub_pct),
|
|
||||||
height: Val::Percent(100.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
// WIN MOVE marker — small green tick anchored at
|
|
||||||
// `win_move_index / total`. Spawned only when the
|
|
||||||
// active replay carries the field; older replays
|
|
||||||
// pre-dating `win_move_index` simply don't get a
|
|
||||||
// marker. Centered vertically on the 1px track via
|
|
||||||
// a 3px-tall node offset 1px above the track top so
|
|
||||||
// 1px sits above and 1px below the track line.
|
|
||||||
if let Some(pct) = win_pct {
|
|
||||||
track.spawn((
|
|
||||||
ReplayOverlayWinMoveMarker,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Percent(pct),
|
|
||||||
top: Val::Px(-1.0),
|
|
||||||
width: Val::Px(2.0),
|
|
||||||
height: Val::Px(3.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(STATE_SUCCESS),
|
|
||||||
// HC bump: lime → brighter lime so the win
|
|
||||||
// marker reads clearly above the bumped
|
|
||||||
// notch ticks (BORDER_SUBTLE_HC gray) under
|
|
||||||
// high-contrast mode.
|
|
||||||
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Fixed quarter-mark notches: five 1px vertical
|
|
||||||
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
|
|
||||||
// player visual anchor points without needing to
|
|
||||||
// mentally bisect the bar. Painted in
|
|
||||||
// BORDER_SUBTLE — same colour as the unfilled
|
|
||||||
// track — so visibility comes from extending past
|
|
||||||
// the 1px track height (5px tall, anchored 2px
|
|
||||||
// above the track top) rather than colour
|
|
||||||
// contrast. Spawned *after* the WIN MOVE marker
|
|
||||||
// so a notch and the marker landing on the same
|
|
||||||
// percentage paint the marker on top.
|
|
||||||
for pct in scrub_notch_positions() {
|
|
||||||
track.spawn((
|
|
||||||
ReplayOverlayScrubNotch,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Percent(pct),
|
|
||||||
top: Val::Px(-2.0),
|
|
||||||
width: Val::Px(1.0),
|
|
||||||
height: Val::Px(5.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BORDER_SUBTLE),
|
|
||||||
// Same HC-paint reasoning as the track
|
|
||||||
// above: 5 px tall × 1 px wide tick mark
|
|
||||||
// paints via BackgroundColor, so
|
|
||||||
// HighContrastBackground (not -Border) is
|
|
||||||
// the right marker.
|
|
||||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Third banner row: percentage labels (`0%` / `25%` /
|
|
||||||
// `50%` / `75%` / `100%`) under each scrub-bar notch.
|
|
||||||
// Sibling of (not child of) the 1px track because labels
|
|
||||||
// need their own vertical real estate (TYPE_CAPTION text
|
|
||||||
// doesn't fit inside a 1px container). Position math:
|
|
||||||
// track Node has `Val::Percent(p)` referencing the
|
|
||||||
// banner's full width; this label row also has the
|
|
||||||
// banner's full width, so labels at the same
|
|
||||||
// percentages line up vertically with their notches.
|
|
||||||
let labels = scrub_notch_labels();
|
|
||||||
let positions = scrub_notch_positions();
|
|
||||||
banner
|
|
||||||
.spawn(Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
|
|
||||||
position_type: PositionType::Relative,
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
|
|
||||||
// Endpoints flush to the row's edges; middle
|
|
||||||
// three labels use the `translateX(-50%)`
|
|
||||||
// pattern for Bevy 0.18 UI: a fixed-width
|
|
||||||
// container is placed at `left: Percent(pct)`
|
|
||||||
// then shifted left by half its own width via
|
|
||||||
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
|
|
||||||
// `Justify::Center` renders the text centred
|
|
||||||
// within the container so the text's visual
|
|
||||||
// centre coincides with the notch line.
|
|
||||||
let (node, justify) = if i == 0 {
|
|
||||||
(
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(2.0),
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
Justify::Left,
|
|
||||||
)
|
|
||||||
} else if i == labels.len() - 1 {
|
|
||||||
(
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(2.0),
|
|
||||||
right: Val::Px(0.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
Justify::Right,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
top: Val::Px(2.0),
|
|
||||||
left: Val::Percent(*pct),
|
|
||||||
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
|
|
||||||
margin: UiRect {
|
|
||||||
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
Justify::Center,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
row.spawn((
|
|
||||||
ReplayOverlayScrubNotchLabel,
|
|
||||||
node,
|
|
||||||
Text::new(*label),
|
|
||||||
TextLayout::new_with_justify(justify),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_labels.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
// TEXT_SECONDARY keeps the subdued visual
|
|
||||||
// hierarchy (caption, not headline) while
|
|
||||||
// staying readable against BG_ELEVATED_HI.
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fourth banner row: keybind-hint footer. Vim-style
|
|
||||||
// mode line on the left (`▌ NORMAL │ replay`), keybind
|
|
||||||
// hint on the right (`[SPACE] pause/resume`), 1px top
|
|
||||||
// border in BORDER_SUBTLE separating it from the
|
|
||||||
// labels row above. Surfaces the existing Space
|
|
||||||
// accelerator visually so CLAUDE.md §3.3's UI-first
|
|
||||||
// contract holds for keyboard accelerators too.
|
|
||||||
banner
|
|
||||||
.spawn((
|
|
||||||
ReplayOverlayKeybindFooter,
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(KEYBIND_FOOTER_HEIGHT),
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
justify_content: JustifyContent::SpaceBetween,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
padding: UiRect::horizontal(VAL_SPACE_4),
|
|
||||||
border: UiRect::top(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
// Marker for `apply_high_contrast_borders`: bumps
|
|
||||||
// the 1 px top border from BORDER_SUBTLE (#505050)
|
|
||||||
// to BORDER_SUBTLE_HC (#a0a0a0) when
|
|
||||||
// `Settings::high_contrast_mode` is on. Without
|
|
||||||
// this the footer reads as floating loose under
|
|
||||||
// HC because the border that visually anchors it
|
|
||||||
// to the labels row above is near-invisible.
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|footer| {
|
|
||||||
footer.spawn((
|
|
||||||
Text::new(keybind_footer_mode_text()),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_labels.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
if SHOW_KEYBOARD_ACCELERATORS {
|
|
||||||
footer.spawn((
|
|
||||||
Text::new(keybind_footer_hint_text()),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_labels.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Floating progress chip — a 2D world-space `Text2d` rendered
|
|
||||||
// above the destination pile of the most-recently-applied move.
|
|
||||||
// Sibling of (not child of) the banner overlay because it lives
|
|
||||||
// in world-space coordinates, not the UI tree. Spawned hidden;
|
|
||||||
// `update_floating_progress_chip` shows + positions it on the
|
|
||||||
// first frame the cursor advances past 0. Lifecycle matches
|
|
||||||
// the banner overlay — `react_to_state_change` despawns both
|
|
||||||
// when the replay state transitions back to `Inactive`.
|
|
||||||
commands.spawn((
|
|
||||||
ReplayFloatingProgressChip,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Text2d::new(format_progress(state)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_floating,
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
// High Z keeps the chip above every card stack
|
|
||||||
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
|
|
||||||
// stack to the low double digits at most).
|
|
||||||
Transform::from_xyz(0.0, 0.0, 100.0),
|
|
||||||
Visibility::Hidden,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Move-log panel — a separate root UI entity anchored to the
|
|
||||||
// viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
|
|
||||||
// plus a row showing the most-recently-applied move.
|
|
||||||
// Sibling-of-banner pattern (not a banner child) because the
|
|
||||||
// panel lives at a different screen anchor and has its own
|
|
||||||
// spawn/despawn lifecycle synced via `react_to_state_change`.
|
|
||||||
let banner_bg = Color::srgba(
|
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
|
||||||
BG_ELEVATED_HI.to_srgba().blue,
|
|
||||||
BANNER_ALPHA,
|
|
||||||
);
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
ReplayOverlayMoveLogPanel,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
left: Val::Px(0.0),
|
|
||||||
bottom: Val::Px(0.0),
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
align_items: AlignItems::FlexStart,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
border: UiRect::top(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(banner_bg),
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
// Same z-stack rationale as the banner — above gameplay,
|
|
||||||
// below modals.
|
|
||||||
ZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
// HC marker so the top border bumps under HC mode.
|
|
||||||
// Without it the panel reads as floating loose because
|
|
||||||
// the border that anchors it to the gameplay area above
|
|
||||||
// is near-invisible at #505050.
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|panel| {
|
|
||||||
// Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
|
|
||||||
// the cursor-block prefix consistency with the banner
|
|
||||||
// headline.
|
|
||||||
panel.spawn((
|
|
||||||
ReplayOverlayMoveLogHeader,
|
|
||||||
Text::new(format_move_log_header(state)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
// Prev rows — render above the active row in display
|
|
||||||
// order (oldest first), so the active row sits at the
|
|
||||||
// bottom of the visible window. Spawn from
|
|
||||||
// MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
|
|
||||||
// the highest-offset (oldest) row is topmost in the
|
|
||||||
// panel's flex column. Each carries
|
|
||||||
// ReplayOverlayMoveLogPrevRow { offset } — the
|
|
||||||
// per-frame system reads `offset` and recomputes the
|
|
||||||
// text on cursor advance. Painted in TEXT_SECONDARY
|
|
||||||
// so the active row stands out from context rows.
|
|
||||||
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
|
|
||||||
panel.spawn((
|
|
||||||
ReplayOverlayMoveLogPrevRow { offset },
|
|
||||||
Text::new(format_kth_recent_row(state, offset as usize + 1)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Active move row. Wrapped in a Node with an
|
|
||||||
// ACCENT_PRIMARY background so the row reads as
|
|
||||||
// "current focus" — the player can scan vertically
|
|
||||||
// and the highlighted row is the move that just
|
|
||||||
// applied. Empty text at spawn time when cursor=0;
|
|
||||||
// the per-frame update system populates it as the
|
|
||||||
// cursor advances. Text colour is TEXT_PRIMARY_HC
|
|
||||||
// (near-white) for contrast against the brick-red
|
|
||||||
// background — same trick as the modal-button
|
|
||||||
// primary-variant paint.
|
|
||||||
panel
|
|
||||||
.spawn((
|
|
||||||
Node {
|
|
||||||
width: Val::Percent(100.0),
|
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|active| {
|
|
||||||
active.spawn((
|
|
||||||
ReplayOverlayMoveLogActiveRow,
|
|
||||||
Text::new(format_active_move_row(state)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY_HC),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
// Next rows — render below the active row in display
|
|
||||||
// order (offset 1 directly below active, then offset
|
|
||||||
// 2). Same TEXT_SECONDARY de-emphasis as prev rows so
|
|
||||||
// the active row stays the focal point. Empty text
|
|
||||||
// late in the replay (when cursor + offset exceeds
|
|
||||||
// moves.len()) — the panel under-fills gracefully.
|
|
||||||
for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
|
|
||||||
panel.spawn((
|
|
||||||
ReplayOverlayMoveLogNextRow { offset },
|
|
||||||
Text::new(format_kth_next_row(state, offset as usize)),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_move_log.clone(),
|
|
||||||
font_size: TYPE_BODY,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mini-tableau preview panel — right-edge anchor, just below the banner.
|
|
||||||
// Compact two-row readout: foundation tops then stock/waste head.
|
|
||||||
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
|
|
||||||
let banner_bg = Color::srgba(
|
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
|
||||||
BG_ELEVATED_HI.to_srgba().blue,
|
|
||||||
BANNER_ALPHA,
|
|
||||||
);
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
ReplayMiniTableauPanel,
|
|
||||||
DespawnWithReplay,
|
|
||||||
Node {
|
|
||||||
position_type: PositionType::Absolute,
|
|
||||||
right: Val::Px(0.0),
|
|
||||||
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
|
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
align_items: AlignItems::FlexStart,
|
|
||||||
row_gap: VAL_SPACE_1,
|
|
||||||
border: UiRect::left(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(banner_bg),
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
ZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|panel| {
|
|
||||||
panel.spawn((
|
|
||||||
Text::new("\u{258C} BOARD"),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_mini_tableau.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
panel.spawn((
|
|
||||||
ReplayMiniTableauFoundations,
|
|
||||||
Text::new("F: -- -- -- --"),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_mini_tableau.clone(),
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
|
||||||
panel.spawn((
|
|
||||||
ReplayMiniTableauStockWaste,
|
|
||||||
Text::new("STK:-- WST:--"),
|
|
||||||
TextFont {
|
|
||||||
font: font_handle_for_mini_tableau,
|
|
||||||
font_size: TYPE_CAPTION,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the scrub-fill width as a percentage of the
|
|
||||||
/// track for the given playback state. `Completed` reads as 100 %;
|
|
||||||
/// `Inactive` and `Playing` with no progress read as 0 %.
|
|
||||||
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
|
|
||||||
if state.is_completed() {
|
|
||||||
return 100.0;
|
|
||||||
}
|
|
||||||
match state.progress() {
|
|
||||||
Some((_, 0)) | None => 0.0,
|
|
||||||
Some((cursor, total)) => {
|
|
||||||
let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
|
|
||||||
frac * 100.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the fixed scrub-bar notch positions as
|
|
||||||
/// percentages along the track. Five evenly-spaced notches at the
|
|
||||||
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
|
|
||||||
/// const) so the unit-test surface is obvious and a future
|
|
||||||
/// regression — e.g. someone simplifying to three notches — fails
|
|
||||||
/// at the helper test rather than at visual review.
|
|
||||||
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
|
|
||||||
[0.0, 25.0, 50.0, 75.0, 100.0]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the percentage-label text for each notch,
|
|
||||||
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
|
|
||||||
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
|
|
||||||
/// the same reason as the positions helper: a clean unit-test
|
|
||||||
/// surface that fails at a regression (e.g. someone simplifying
|
|
||||||
/// `100%` → `MAX`) rather than at visual review.
|
|
||||||
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
|
|
||||||
["0%", "25%", "50%", "75%", "100%"]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the vim-style mode indicator text shown on
|
|
||||||
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
|
|
||||||
/// matches the `▌replay.tsx` motif from the splash boot-screen and
|
|
||||||
/// the screen-takeover mockup. The cursor block (`▌`) matches the
|
|
||||||
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
|
|
||||||
/// "replay" identifies the surface.
|
|
||||||
pub(crate) fn keybind_footer_mode_text() -> &'static str {
|
|
||||||
"\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the keybind-hint text shown on the right
|
|
||||||
/// side of the keybind-hint footer row. Lists only the keys that
|
|
||||||
/// are *actually wired* today: the Space accelerator for
|
|
||||||
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
|
||||||
/// accelerators for paused single-move stepping. The footer never
|
|
||||||
/// lists unimplemented keybinds (would lie to users).
|
|
||||||
pub(crate) fn keybind_footer_hint_text() -> &'static str {
|
|
||||||
if SHOW_KEYBOARD_ACCELERATORS {
|
|
||||||
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
|
||||||
/// a percentage of the scrub track, or `None` when no marker should
|
|
||||||
/// be drawn.
|
|
||||||
///
|
|
||||||
/// `None` is returned in any of these cases:
|
|
||||||
/// - The state isn't `Playing` (no replay attached).
|
|
||||||
/// - The replay's `win_move_index` is `None` (older replay loaded
|
|
||||||
/// from disk pre-dating the field).
|
|
||||||
/// - The replay's move list is empty (shouldn't happen for real wins,
|
|
||||||
/// but guards the divide-by-zero).
|
|
||||||
///
|
|
||||||
/// The percentage clamps to `[0, 100]` so a malformed
|
|
||||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
|
||||||
/// position the marker outside the track.
|
|
||||||
pub(crate) fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
|
||||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let idx = replay.win_move_index?;
|
|
||||||
let total = replay.moves.len();
|
|
||||||
if total == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
|
|
||||||
Some(frac * 100.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Playback-control button handlers
|
// Playback-control button handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ 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);
|
.add_systems(Update, android::refresh_surface_size);
|
||||||
@@ -330,11 +331,16 @@ mod android {
|
|||||||
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||||
/// 3. re-arms the inset poller, because a screen change almost always
|
/// 3. re-arms the inset poller, because a screen change almost always
|
||||||
/// moves the system bars too — covering the "re-poll never fires"
|
/// moves the system bars too — covering the "re-poll never fires"
|
||||||
/// hole left open in #116.
|
/// 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(
|
pub(super) fn refresh_surface_size(
|
||||||
mut frame: Local<u32>,
|
mut frame: Local<u32>,
|
||||||
mut windows: Query<(Entity, &mut Window)>,
|
mut windows: Query<(Entity, &mut Window)>,
|
||||||
mut resize_events: MessageWriter<WindowResized>,
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
|
||||||
mut poll: ResMut<SafeAreaPollTries>,
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
) {
|
) {
|
||||||
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||||
@@ -381,6 +387,7 @@ mod android {
|
|||||||
width: decor_w as f32 / scale,
|
width: decor_w as f32 / scale,
|
||||||
height: decor_h as f32 / scale,
|
height: decor_h as f32 / scale,
|
||||||
});
|
});
|
||||||
|
state_events.write(crate::events::StateChangedEvent);
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes
|
||||||
|
/// too (Phase C dismissal audit), but only when Settings is the
|
||||||
|
/// topmost modal — with sync-setup or the theme store stacked on top,
|
||||||
|
/// the stacked dialog owns Esc.
|
||||||
pub(super) fn toggle_settings_screen(
|
pub(super) fn toggle_settings_screen(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||||||
mut screen: ResMut<SettingsScreen>,
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||||
) {
|
) {
|
||||||
let button_clicked = requests.read().count() > 0;
|
let button_clicked = requests.read().count() > 0;
|
||||||
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||||||
screen.0 = !screen.0;
|
screen.0 = !screen.0;
|
||||||
|
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
|
||||||
|
screen.0 = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +424,25 @@ pub(super) fn handle_settings_buttons(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switches the active Settings tab when a chip is pressed. The
|
||||||
|
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
|
||||||
|
/// change detection on [`ActiveSettingsTab`].
|
||||||
|
pub(super) fn handle_tab_buttons(
|
||||||
|
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
|
||||||
|
mut active: ResMut<ActiveSettingsTab>,
|
||||||
|
) {
|
||||||
|
for (interaction, chip) in &interactions {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Compare before writing so re-clicking the active chip doesn't
|
||||||
|
// trip change detection and rebuild the panel for nothing.
|
||||||
|
if active.0 != chip.0 {
|
||||||
|
active.0 = chip.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||||
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||||
/// Bevy's 16-parameter system limit.
|
/// Bevy's 16-parameter system limit.
|
||||||
|
|||||||
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct AnalyticsEnabledText;
|
struct AnalyticsEnabledText;
|
||||||
|
|
||||||
|
/// Which tab of the Settings panel is showing. Tabs replace the old
|
||||||
|
/// single-scroll five-section layout (Phase A of
|
||||||
|
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
|
||||||
|
/// scroll-hunting.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub(super) enum SettingsTab {
|
||||||
|
#[default]
|
||||||
|
Audio,
|
||||||
|
Gameplay,
|
||||||
|
Appearance,
|
||||||
|
Accessibility,
|
||||||
|
Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsTab {
|
||||||
|
/// Every tab, in display order.
|
||||||
|
pub(super) const ALL: [Self; 5] = [
|
||||||
|
Self::Audio,
|
||||||
|
Self::Gameplay,
|
||||||
|
Self::Appearance,
|
||||||
|
Self::Accessibility,
|
||||||
|
Self::Account,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Chip label.
|
||||||
|
pub(super) fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Audio => "Audio",
|
||||||
|
Self::Gameplay => "Gameplay",
|
||||||
|
Self::Appearance => "Appearance",
|
||||||
|
Self::Accessibility => "Access",
|
||||||
|
Self::Account => "Account",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active Settings tab. Session-only — deliberately not persisted;
|
||||||
|
/// reopening Settings within a session returns to the last tab.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
|
||||||
|
|
||||||
|
/// Marker on each tab chip button in the Settings header row.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
|
||||||
|
|
||||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanelScrollable;
|
struct SettingsPanelScrollable;
|
||||||
@@ -369,6 +414,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
.init_resource::<SettingsScreen>()
|
.init_resource::<SettingsScreen>()
|
||||||
.init_resource::<SettingsScrollPos>()
|
.init_resource::<SettingsScrollPos>()
|
||||||
|
.init_resource::<ActiveSettingsTab>()
|
||||||
.init_resource::<PendingWindowGeometry>()
|
.init_resource::<PendingWindowGeometry>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
@@ -423,7 +469,15 @@ impl Plugin for SettingsPlugin {
|
|||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
sync_settings_panel_visibility,
|
// Chained: a tab click must observe this frame's
|
||||||
|
// visibility state, and the rebuild must observe the
|
||||||
|
// click — total order avoids panel double-spawns.
|
||||||
|
(
|
||||||
|
sync_settings_panel_visibility,
|
||||||
|
handle_tab_buttons,
|
||||||
|
rebuild_panel_on_tab_change,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
handle_settings_buttons,
|
handle_settings_buttons,
|
||||||
handle_sync_buttons,
|
handle_sync_buttons,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|||||||
@@ -98,6 +98,25 @@ fn volume_clamped_at_zero_does_not_emit_event() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the Settings panel showing `tab`, then waits out the
|
||||||
|
/// spawn / child-flush / marker-attach frames.
|
||||||
|
fn open_settings_on(app: &mut App, tab: SettingsTab) {
|
||||||
|
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the already-open panel to `tab` (drives the
|
||||||
|
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
|
||||||
|
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
|
||||||
|
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pressing_o_toggles_settings_screen_flag() {
|
fn pressing_o_toggles_settings_screen_flag() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
@@ -121,6 +140,31 @@ fn pressing_o_toggles_settings_screen_flag() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Esc closes the Settings panel like O / Done do (Phase C dismissal
|
||||||
|
/// audit). Esc while the panel is closed must NOT open it.
|
||||||
|
#[test]
|
||||||
|
fn escape_closes_settings_screen_flag() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::Escape);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SettingsScreen>().0,
|
||||||
|
"Esc on a closed panel stays closed"
|
||||||
|
);
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::KeyO);
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
|
||||||
|
|
||||||
|
press(&mut app, KeyCode::Escape);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SettingsScreen>().0,
|
||||||
|
"Esc closes settings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// cycle_unlocked pure-function tests
|
// cycle_unlocked pure-function tests
|
||||||
#[test]
|
#[test]
|
||||||
fn cycle_unlocked_wraps_at_end() {
|
fn cycle_unlocked_wraps_at_end() {
|
||||||
@@ -243,40 +287,40 @@ fn headless_app_with_focus() -> App {
|
|||||||
fn settings_buttons_get_focusable_marker() {
|
fn settings_buttons_get_focusable_marker() {
|
||||||
let mut app = headless_app_with_focus();
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
// Open the panel.
|
// Walk every tab: each rebuild must leave no bespoke
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
|
||||||
app.update();
|
// without a `Focusable`.
|
||||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
let mut total_tagged = 0usize;
|
||||||
// and queues the spawn commands; the second flushes them and
|
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||||
// runs `attach_focusable_to_settings_buttons`.
|
if i == 0 {
|
||||||
app.update();
|
open_settings_on(&mut app, tab);
|
||||||
app.update();
|
} else {
|
||||||
|
switch_settings_tab(&mut app, tab);
|
||||||
|
}
|
||||||
|
|
||||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
let untagged: Vec<&SettingsButton> = app
|
||||||
// `ModalButton`) must carry a `Focusable`.
|
.world_mut()
|
||||||
let untagged: Vec<&SettingsButton> = app
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||||
.world_mut()
|
.iter(app.world())
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
.collect();
|
||||||
.iter(app.world())
|
assert!(
|
||||||
.collect();
|
untagged.is_empty(),
|
||||||
|
"every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
total_tagged += app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor across all five tabs so the assertion above isn't
|
||||||
|
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
|
||||||
|
// accessibility 6 + account ≥1).
|
||||||
assert!(
|
assert!(
|
||||||
untagged.is_empty(),
|
total_tagged >= 20,
|
||||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
|
||||||
untagged
|
|
||||||
);
|
|
||||||
|
|
||||||
// And there must be at least one tagged `SettingsButton` so the
|
|
||||||
// assertion above isn't vacuously true (the panel really did
|
|
||||||
// spawn).
|
|
||||||
let tagged_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
tagged_count >= 6,
|
|
||||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,41 +334,45 @@ fn settings_buttons_get_focusable_marker() {
|
|||||||
fn settings_buttons_carry_tooltip() {
|
fn settings_buttons_carry_tooltip() {
|
||||||
let mut app = headless_app_with_focus();
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
// Open the panel and let spawn + child-flush run.
|
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// `Done`, which is also a `ModalButton`) may be missing a
|
||||||
app.update();
|
// `Tooltip` on any of them.
|
||||||
app.update();
|
let mut total_tipped = 0usize;
|
||||||
app.update();
|
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||||
|
if i == 0 {
|
||||||
|
open_settings_on(&mut app, tab);
|
||||||
|
} else {
|
||||||
|
switch_settings_tab(&mut app, tab);
|
||||||
|
}
|
||||||
|
|
||||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
let untipped: Vec<&SettingsButton> = app
|
||||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
.world_mut()
|
||||||
let untipped: Vec<&SettingsButton> = app
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||||
.world_mut()
|
.iter(app.world())
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
.collect();
|
||||||
.iter(app.world())
|
assert!(
|
||||||
.collect();
|
untipped.is_empty(),
|
||||||
|
"every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
total_tipped += app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor across all five tabs so the assertion above isn't
|
||||||
|
// vacuously true.
|
||||||
assert!(
|
assert!(
|
||||||
untipped.is_empty(),
|
total_tipped >= 20,
|
||||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
|
||||||
untipped
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// And there must be at least 6 tipped buttons so the assertion
|
// The loop ends on the Account tab, where with default (Local)
|
||||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
// settings the Connect button spawns.
|
||||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
|
||||||
// card-back and one background swatch — well over the floor.
|
|
||||||
let tipped_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
tipped_count >= 6,
|
|
||||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spot-check: with default (Local) settings the Connect button
|
// We verify its tooltip carries the canonical microcopy.
|
||||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
|
||||||
let connect_tip = app
|
let connect_tip = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
@@ -342,10 +390,8 @@ fn settings_buttons_carry_tooltip() {
|
|||||||
fn settings_picker_rows_get_focus_row_marker() {
|
fn settings_picker_rows_get_focus_row_marker() {
|
||||||
let mut app = headless_app_with_focus();
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// Picker rows live on the Appearance tab.
|
||||||
app.update();
|
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Two picker rows are spawned (card-back + background); each
|
// Two picker rows are spawned (card-back + background); each
|
||||||
// must carry the FocusRow marker.
|
// must carry the FocusRow marker.
|
||||||
@@ -360,6 +406,45 @@ fn settings_picker_rows_get_focus_row_marker() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switching tabs despawns the old rows and spawns the new tab's —
|
||||||
|
/// exactly one panel exists afterwards (no double-spawn from the
|
||||||
|
/// visibility system racing the rebuild system).
|
||||||
|
#[test]
|
||||||
|
fn switching_tab_rebuilds_panel_with_new_rows() {
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
|
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&SettingsButton>()
|
||||||
|
.iter(app.world())
|
||||||
|
.any(wanted)
|
||||||
|
}
|
||||||
|
|
||||||
|
open_settings_on(&mut app, SettingsTab::Audio);
|
||||||
|
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
|
||||||
|
assert!(
|
||||||
|
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
|
||||||
|
"Gameplay rows must not spawn while the Audio tab is active"
|
||||||
|
);
|
||||||
|
|
||||||
|
switch_settings_tab(&mut app, SettingsTab::Gameplay);
|
||||||
|
assert!(
|
||||||
|
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
|
||||||
|
"Audio rows must despawn when leaving the Audio tab"
|
||||||
|
);
|
||||||
|
assert!(has_button(&mut app, |b| matches!(
|
||||||
|
b,
|
||||||
|
SettingsButton::ToggleDrawMode
|
||||||
|
)));
|
||||||
|
|
||||||
|
let panel_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<SettingsPanel>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(panel_count, 1, "tab switch must not stack panels");
|
||||||
|
}
|
||||||
|
|
||||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||||
@@ -404,11 +489,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
|||||||
);
|
);
|
||||||
app.insert_resource(cache);
|
app.insert_resource(cache);
|
||||||
|
|
||||||
// Open the panel and let the spawn + child-flush systems run.
|
// Open the panel on the Appearance tab (the theme picker's home)
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// and let the spawn + child-flush systems run.
|
||||||
app.update();
|
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||||
// theme picker chip for "test_theme" must contribute exactly
|
// theme picker chip for "test_theme" must contribute exactly
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
|
|||||||
use crate::ui_focus::FocusRow;
|
use crate::ui_focus::FocusRow;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
|
spawn_tab_chip,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
|
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
|
||||||
@@ -45,13 +46,34 @@ pub(super) fn spawn_settings_panel(
|
|||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
theme_overrides_back: bool,
|
theme_overrides_back: bool,
|
||||||
|
active_tab: SettingsTab,
|
||||||
) {
|
) {
|
||||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Settings", font_res);
|
spawn_modal_header(card, "Settings", font_res);
|
||||||
|
|
||||||
// Scrollable body — contains every section so tall content stays
|
// Tab chips — one group of rows on screen at a time, no
|
||||||
// reachable on short windows. The Done button below stays fixed
|
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
|
||||||
// outside the scroll so it's always one click away.
|
card.spawn((
|
||||||
|
FocusRow,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
margin: UiRect::bottom(VAL_SPACE_3),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
for tab in SettingsTab::ALL {
|
||||||
|
tab_chip(row, tab, tab == active_tab, font_res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scrollable body — most tabs fit without scrolling, but the
|
||||||
|
// Appearance pickers can still overflow short windows. The Done
|
||||||
|
// button below stays fixed outside the scroll so it's always one
|
||||||
|
// click away.
|
||||||
card.spawn((
|
card.spawn((
|
||||||
SettingsPanelScrollable,
|
SettingsPanelScrollable,
|
||||||
SettingsScrollNode,
|
SettingsScrollNode,
|
||||||
@@ -65,200 +87,20 @@ pub(super) fn spawn_settings_panel(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
.with_children(|body| {
|
.with_children(|body| match active_tab {
|
||||||
// --- Audio ---
|
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
|
||||||
section_label(body, "Audio", font_res);
|
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
|
||||||
volume_row(
|
SettingsTab::Appearance => spawn_appearance_tab(
|
||||||
body,
|
body,
|
||||||
"SFX Volume",
|
settings,
|
||||||
settings.sfx_volume,
|
unlocked_card_backs,
|
||||||
SfxVolumeText,
|
|
||||||
SettingsButton::SfxDown,
|
|
||||||
SettingsButton::SfxUp,
|
|
||||||
"Lower sound effects volume.",
|
|
||||||
"Raise sound effects volume.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
volume_row(
|
|
||||||
body,
|
|
||||||
"Music Volume",
|
|
||||||
settings.music_volume,
|
|
||||||
MusicVolumeText,
|
|
||||||
SettingsButton::MusicDown,
|
|
||||||
SettingsButton::MusicUp,
|
|
||||||
"Lower music and ambience volume.",
|
|
||||||
"Raise music and ambience volume.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Gameplay ---
|
|
||||||
section_label(body, "Gameplay", font_res);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Draw Mode",
|
|
||||||
DrawModeText,
|
|
||||||
draw_mode_label(&settings.draw_mode),
|
|
||||||
SettingsButton::ToggleDrawMode,
|
|
||||||
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Winnable deals only",
|
|
||||||
WinnableDealsOnlyText,
|
|
||||||
winnable_deals_only_label(settings.winnable_deals_only),
|
|
||||||
SettingsButton::ToggleWinnableDealsOnly,
|
|
||||||
"When on, fresh Classic deals are filtered through a solver \
|
|
||||||
(may take a moment when on).",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Anim Speed",
|
|
||||||
AnimSpeedText,
|
|
||||||
anim_speed_label(&settings.animation_speed),
|
|
||||||
SettingsButton::CycleAnimSpeed,
|
|
||||||
"Cycle animation speed: Normal, Fast, Instant.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
tooltip_delay_row(
|
|
||||||
body,
|
|
||||||
settings.tooltip_delay_secs,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
time_bonus_multiplier_row(
|
|
||||||
body,
|
|
||||||
settings.time_bonus_multiplier,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
replay_move_interval_row(
|
|
||||||
body,
|
|
||||||
settings.replay_move_interval_secs,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Smart window size",
|
|
||||||
SmartDefaultSizeText,
|
|
||||||
smart_default_size_label(!settings.disable_smart_default_size),
|
|
||||||
SettingsButton::ToggleSmartDefaultSize,
|
|
||||||
"When ON, fresh launches resize the window to ~70 % of the \
|
|
||||||
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
|
|
||||||
window size always wins.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Cosmetic ---
|
|
||||||
section_label(body, "Cosmetic", font_res);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Theme",
|
|
||||||
ThemeText,
|
|
||||||
theme_label(&settings.theme),
|
|
||||||
SettingsButton::ToggleTheme,
|
|
||||||
"Cycle felt color: Green, Blue, Dark.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Color-blind Mode",
|
|
||||||
ColorBlindText,
|
|
||||||
color_blind_label(settings.color_blind_mode),
|
|
||||||
SettingsButton::ToggleColorBlind,
|
|
||||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"High Contrast",
|
|
||||||
HighContrastText,
|
|
||||||
on_off_label(settings.high_contrast_mode),
|
|
||||||
SettingsButton::ToggleHighContrast,
|
|
||||||
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Reduce Motion",
|
|
||||||
ReduceMotionText,
|
|
||||||
on_off_label(settings.reduce_motion_mode),
|
|
||||||
SettingsButton::ToggleReduceMotion,
|
|
||||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Touch Input Mode",
|
|
||||||
TouchInputModeText,
|
|
||||||
touch_input_mode_label(&settings.touch_input_mode),
|
|
||||||
SettingsButton::ToggleTouchInputMode,
|
|
||||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
if theme_overrides_back {
|
|
||||||
// The active theme provides its own back; the legacy
|
|
||||||
// picker has no visible effect, so we replace its
|
|
||||||
// swatch row with an informational caption. The
|
|
||||||
// player's `selected_card_back` value still
|
|
||||||
// round-trips through `settings.json` — the moment
|
|
||||||
// they switch to a theme without a back, the picker
|
|
||||||
// re-appears with their previous choice intact.
|
|
||||||
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
|
||||||
} else {
|
|
||||||
picker_row(
|
|
||||||
body,
|
|
||||||
"Card Back",
|
|
||||||
unlocked_card_backs,
|
|
||||||
settings.selected_card_back,
|
|
||||||
SettingsButton::SelectCardBack,
|
|
||||||
"Choose your deck art. New backs unlock at higher levels.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
picker_row(
|
|
||||||
body,
|
|
||||||
"Background",
|
|
||||||
unlocked_backgrounds,
|
unlocked_backgrounds,
|
||||||
settings.selected_background,
|
themes,
|
||||||
SettingsButton::SelectBackground,
|
theme_overrides_back,
|
||||||
"Choose your felt art. New felts unlock at higher levels.",
|
|
||||||
font_res,
|
font_res,
|
||||||
);
|
),
|
||||||
// Card-art theme picker — only renders when the registry has
|
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||||||
// entries (production: always; tests: only when
|
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
||||||
// ThemeRegistryPlugin is installed).
|
|
||||||
if !themes.is_empty() {
|
|
||||||
theme_picker_row(
|
|
||||||
body,
|
|
||||||
"Card Theme",
|
|
||||||
themes,
|
|
||||||
&settings.selected_theme_id,
|
|
||||||
"Choose card-face artwork. Imported themes appear here.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
import_themes_row(body, font_res);
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
theme_store_row(body, font_res);
|
|
||||||
|
|
||||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
|
||||||
if settings.matomo_url.is_some() {
|
|
||||||
section_label(body, "Privacy", font_res);
|
|
||||||
toggle_row(
|
|
||||||
body,
|
|
||||||
"Share usage data",
|
|
||||||
AnalyticsEnabledText,
|
|
||||||
on_off_label(settings.analytics_enabled),
|
|
||||||
SettingsButton::ToggleAnalytics,
|
|
||||||
"Sends anonymous game events to Matomo for aggregate analytics.",
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sync ---
|
|
||||||
section_label(body, "Sync", font_res);
|
|
||||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Done is the only action — primary so the player always knows
|
// Done is the only action — primary so the player always knows
|
||||||
@@ -276,6 +118,244 @@ pub(super) fn spawn_settings_panel(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One Settings tab chip — thin wrapper over the shared
|
||||||
|
/// [`spawn_tab_chip`] widget so Settings and the You hub stay visually
|
||||||
|
/// identical.
|
||||||
|
fn tab_chip(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
tab: SettingsTab,
|
||||||
|
active: bool,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
spawn_tab_chip(
|
||||||
|
parent,
|
||||||
|
SettingsTabButton(tab),
|
||||||
|
tab.label(),
|
||||||
|
active,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio tab: the two volume rows.
|
||||||
|
fn spawn_audio_tab(
|
||||||
|
body: &mut ChildSpawnerCommands,
|
||||||
|
settings: &Settings,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
volume_row(
|
||||||
|
body,
|
||||||
|
"SFX Volume",
|
||||||
|
settings.sfx_volume,
|
||||||
|
SfxVolumeText,
|
||||||
|
SettingsButton::SfxDown,
|
||||||
|
SettingsButton::SfxUp,
|
||||||
|
"Lower sound effects volume.",
|
||||||
|
"Raise sound effects volume.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
volume_row(
|
||||||
|
body,
|
||||||
|
"Music Volume",
|
||||||
|
settings.music_volume,
|
||||||
|
MusicVolumeText,
|
||||||
|
SettingsButton::MusicDown,
|
||||||
|
SettingsButton::MusicUp,
|
||||||
|
"Lower music and ambience volume.",
|
||||||
|
"Raise music and ambience volume.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gameplay tab: rules and pacing.
|
||||||
|
fn spawn_gameplay_tab(
|
||||||
|
body: &mut ChildSpawnerCommands,
|
||||||
|
settings: &Settings,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Draw Mode",
|
||||||
|
DrawModeText,
|
||||||
|
draw_mode_label(&settings.draw_mode),
|
||||||
|
SettingsButton::ToggleDrawMode,
|
||||||
|
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Winnable deals only",
|
||||||
|
WinnableDealsOnlyText,
|
||||||
|
winnable_deals_only_label(settings.winnable_deals_only),
|
||||||
|
SettingsButton::ToggleWinnableDealsOnly,
|
||||||
|
"When on, fresh Classic deals are filtered through a solver \
|
||||||
|
(may take a moment when on).",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Anim Speed",
|
||||||
|
AnimSpeedText,
|
||||||
|
anim_speed_label(&settings.animation_speed),
|
||||||
|
SettingsButton::CycleAnimSpeed,
|
||||||
|
"Cycle animation speed: Normal, Fast, Instant.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
|
||||||
|
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Smart window size",
|
||||||
|
SmartDefaultSizeText,
|
||||||
|
smart_default_size_label(!settings.disable_smart_default_size),
|
||||||
|
SettingsButton::ToggleSmartDefaultSize,
|
||||||
|
"When ON, fresh launches resize the window to ~70 % of the \
|
||||||
|
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
|
||||||
|
window size always wins.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_appearance_tab(
|
||||||
|
body: &mut ChildSpawnerCommands,
|
||||||
|
settings: &Settings,
|
||||||
|
unlocked_card_backs: &[usize],
|
||||||
|
unlocked_backgrounds: &[usize],
|
||||||
|
themes: &[ThemePickerEntry],
|
||||||
|
theme_overrides_back: bool,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Theme",
|
||||||
|
ThemeText,
|
||||||
|
theme_label(&settings.theme),
|
||||||
|
SettingsButton::ToggleTheme,
|
||||||
|
"Cycle felt color: Green, Blue, Dark.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
if theme_overrides_back {
|
||||||
|
// The active theme provides its own back; the legacy
|
||||||
|
// picker has no visible effect, so we replace its
|
||||||
|
// swatch row with an informational caption. The
|
||||||
|
// player's `selected_card_back` value still
|
||||||
|
// round-trips through `settings.json` — the moment
|
||||||
|
// they switch to a theme without a back, the picker
|
||||||
|
// re-appears with their previous choice intact.
|
||||||
|
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
||||||
|
} else {
|
||||||
|
picker_row(
|
||||||
|
body,
|
||||||
|
"Card Back",
|
||||||
|
unlocked_card_backs,
|
||||||
|
settings.selected_card_back,
|
||||||
|
SettingsButton::SelectCardBack,
|
||||||
|
"Choose your deck art. New backs unlock at higher levels.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
picker_row(
|
||||||
|
body,
|
||||||
|
"Background",
|
||||||
|
unlocked_backgrounds,
|
||||||
|
settings.selected_background,
|
||||||
|
SettingsButton::SelectBackground,
|
||||||
|
"Choose your felt art. New felts unlock at higher levels.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
// Card-art theme picker — only renders when the registry has
|
||||||
|
// entries (production: always; tests: only when
|
||||||
|
// ThemeRegistryPlugin is installed).
|
||||||
|
if !themes.is_empty() {
|
||||||
|
theme_picker_row(
|
||||||
|
body,
|
||||||
|
"Card Theme",
|
||||||
|
themes,
|
||||||
|
&settings.selected_theme_id,
|
||||||
|
"Choose card-face artwork. Imported themes appear here.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
import_themes_row(body, font_res);
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
theme_store_row(body, font_res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accessibility tab: perception and input assists, gathered from the
|
||||||
|
/// old Gameplay/Cosmetic sections so they're findable in one place.
|
||||||
|
fn spawn_accessibility_tab(
|
||||||
|
body: &mut ChildSpawnerCommands,
|
||||||
|
settings: &Settings,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Color-blind Mode",
|
||||||
|
ColorBlindText,
|
||||||
|
color_blind_label(settings.color_blind_mode),
|
||||||
|
SettingsButton::ToggleColorBlind,
|
||||||
|
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"High Contrast",
|
||||||
|
HighContrastText,
|
||||||
|
on_off_label(settings.high_contrast_mode),
|
||||||
|
SettingsButton::ToggleHighContrast,
|
||||||
|
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Reduce Motion",
|
||||||
|
ReduceMotionText,
|
||||||
|
on_off_label(settings.reduce_motion_mode),
|
||||||
|
SettingsButton::ToggleReduceMotion,
|
||||||
|
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Touch Input Mode",
|
||||||
|
TouchInputModeText,
|
||||||
|
touch_input_mode_label(&settings.touch_input_mode),
|
||||||
|
SettingsButton::ToggleTouchInputMode,
|
||||||
|
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account tab: privacy opt-in (when analytics is configured) and the
|
||||||
|
/// sync backend controls.
|
||||||
|
fn spawn_account_tab(
|
||||||
|
body: &mut ChildSpawnerCommands,
|
||||||
|
settings: &Settings,
|
||||||
|
sync_status: &str,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||||
|
if settings.matomo_url.is_some() {
|
||||||
|
section_label(body, "Privacy", font_res);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Share usage data",
|
||||||
|
AnalyticsEnabledText,
|
||||||
|
on_off_label(settings.analytics_enabled),
|
||||||
|
SettingsButton::ToggleAnalytics,
|
||||||
|
"Sends anonymous game events to Matomo for aggregate analytics.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sync ---
|
||||||
|
section_label(body, "Sync", font_res);
|
||||||
|
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||||
|
}
|
||||||
|
|
||||||
/// Section divider — small lavender label inside the scrollable body.
|
/// Section divider — small lavender label inside the scrollable body.
|
||||||
pub(super) fn section_label(
|
pub(super) fn section_label(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) fn sync_settings_panel_visibility(
|
pub(super) fn sync_settings_panel_visibility(
|
||||||
screen: Res<SettingsScreen>,
|
screen: Res<SettingsScreen>,
|
||||||
|
active_tab: Res<ActiveSettingsTab>,
|
||||||
panels: Query<Entity, With<SettingsPanel>>,
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||||
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
|
|||||||
}
|
}
|
||||||
if screen.0 {
|
if screen.0 {
|
||||||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||||
let status_label = sync_status.map_or_else(
|
build_panel(
|
||||||
|| "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,
|
&mut commands,
|
||||||
&settings.0,
|
&settings.0,
|
||||||
&status_label,
|
sync_status.as_deref(),
|
||||||
unlocked_backs,
|
progress.as_deref(),
|
||||||
unlocked_bgs,
|
|
||||||
&themes,
|
|
||||||
scroll_pos.0,
|
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
theme_overrides_back,
|
theme_registry.as_deref(),
|
||||||
|
theme_thumbs.as_deref(),
|
||||||
|
card_images.as_deref(),
|
||||||
|
scroll_pos.0,
|
||||||
|
active_tab.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -102,6 +60,116 @@ pub(super) fn sync_settings_panel_visibility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
|
||||||
|
/// back-override flag) and spawns the panel showing `active_tab`.
|
||||||
|
/// Shared by [`sync_settings_panel_visibility`] (open) and
|
||||||
|
/// [`rebuild_panel_on_tab_change`] (tab switch).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_panel(
|
||||||
|
commands: &mut Commands,
|
||||||
|
settings: &Settings,
|
||||||
|
sync_status: Option<&SyncStatusResource>,
|
||||||
|
progress: Option<&ProgressResource>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
theme_registry: Option<&crate::theme::ThemeRegistry>,
|
||||||
|
theme_thumbs: Option<&ThemeThumbnailCache>,
|
||||||
|
card_images: Option<&crate::card_plugin::CardImageSet>,
|
||||||
|
scroll_offset: f32,
|
||||||
|
active_tab: SettingsTab,
|
||||||
|
) {
|
||||||
|
let status_label = sync_status.map_or_else(
|
||||||
|
|| "Status: local only".to_string(),
|
||||||
|
|s| sync_status_label(&s.0),
|
||||||
|
);
|
||||||
|
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||||
|
let unlocked_bgs = progress.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
|
||||||
|
.map(|r| {
|
||||||
|
r.iter()
|
||||||
|
.map(|e| ThemePickerEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
display_name: e.display_name.clone(),
|
||||||
|
thumbnails: theme_thumbs
|
||||||
|
.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.is_some_and(|cs| cs.theme_back.is_some());
|
||||||
|
spawn_settings_panel(
|
||||||
|
commands,
|
||||||
|
settings,
|
||||||
|
&status_label,
|
||||||
|
unlocked_backs,
|
||||||
|
unlocked_bgs,
|
||||||
|
&themes,
|
||||||
|
scroll_offset,
|
||||||
|
font_res,
|
||||||
|
theme_overrides_back,
|
||||||
|
active_tab,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
|
||||||
|
/// clicked). No-op while the panel is closed — the next open reads the
|
||||||
|
/// resource directly.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn rebuild_panel_on_tab_change(
|
||||||
|
active_tab: Res<ActiveSettingsTab>,
|
||||||
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
|
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 !active_tab.is_changed() || active_tab.is_added() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if panels.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &panels {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
// A fresh tab starts at the top; per-tab scroll memory isn't worth
|
||||||
|
// the bookkeeping now that no tab needs much scrolling.
|
||||||
|
scroll_pos.0 = 0.0;
|
||||||
|
build_panel(
|
||||||
|
&mut commands,
|
||||||
|
&settings.0,
|
||||||
|
sync_status.as_deref(),
|
||||||
|
progress.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
theme_registry.as_deref(),
|
||||||
|
theme_thumbs.as_deref(),
|
||||||
|
card_images.as_deref(),
|
||||||
|
0.0,
|
||||||
|
active_tab.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Keeps the sync-status text node current while the panel is open.
|
/// Keeps the sync-status text node current while the panel is open.
|
||||||
pub(super) fn update_sync_status_text(
|
pub(super) fn update_sync_status_text(
|
||||||
sync_status: Option<Res<SyncStatusResource>>,
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use bevy::input::ButtonInput;
|
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
@@ -25,17 +24,13 @@ use crate::events::{
|
|||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::platform::ClipboardBackendResource;
|
use crate::platform::ClipboardBackendResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
||||||
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
|
||||||
spawn_modal_button, spawn_modal_header,
|
|
||||||
};
|
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bevy resource wrapping the current stats.
|
/// Bevy resource wrapping the current stats.
|
||||||
@@ -225,8 +220,9 @@ impl Plugin for StatsPlugin {
|
|||||||
.before(GameMutation)
|
.before(GameMutation)
|
||||||
.before(update_stats_on_new_game),
|
.before(update_stats_on_new_game),
|
||||||
)
|
)
|
||||||
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
// Open/close/tab handling moved to `you_hub_plugin`
|
||||||
.add_systems(Update, handle_stats_close_button)
|
// (Phase E) — this plugin now owns body content, replay
|
||||||
|
// selector behavior, and scroll.
|
||||||
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
|
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
|
||||||
.add_systems(Update, handle_watch_replay_button)
|
.add_systems(Update, handle_watch_replay_button)
|
||||||
.add_systems(Update, handle_copy_share_link_button)
|
.add_systems(Update, handle_copy_share_link_button)
|
||||||
@@ -632,70 +628,17 @@ fn handle_forfeit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker on the "Done" button inside the Stats modal. Click despawns
|
/// Builds the Stats tab body inside the You hub's card: the 8-cell
|
||||||
/// the overlay; `S` keyboard shortcut toggles it the same way.
|
/// grid plus per-mode bests, progression, weekly goals, unlocks, and
|
||||||
#[derive(Component, Debug)]
|
/// the optional Time Attack line. The replay selector lives on its own
|
||||||
pub struct StatsCloseButton;
|
/// hub tab now — see [`spawn_replays_body`]. All markers
|
||||||
|
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
|
||||||
#[allow(clippy::too_many_arguments)]
|
pub(crate) fn spawn_stats_body(
|
||||||
fn toggle_stats_screen(
|
card: &mut ChildSpawnerCommands,
|
||||||
mut commands: Commands,
|
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
|
||||||
mut requests: MessageReader<ToggleStatsRequestEvent>,
|
|
||||||
stats: Res<StatsResource>,
|
|
||||||
progress: Option<Res<ProgressResource>>,
|
|
||||||
time_attack: Option<Res<TimeAttackResource>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
|
||||||
latest_replay: Res<ReplayHistoryResource>,
|
|
||||||
selected_index: Res<SelectedReplayIndex>,
|
|
||||||
screens: Query<Entity, With<StatsScreen>>,
|
|
||||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
|
|
||||||
) {
|
|
||||||
let button_clicked = requests.read().count() > 0;
|
|
||||||
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(entity) = screens.single() {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
} else {
|
|
||||||
if !other_modal_scrims.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
spawn_stats_screen(
|
|
||||||
&mut commands,
|
|
||||||
&stats.0,
|
|
||||||
progress.as_deref().map(|p| &p.0),
|
|
||||||
time_attack.as_deref(),
|
|
||||||
font_res.as_deref(),
|
|
||||||
&latest_replay.0.replays,
|
|
||||||
selected_index.0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Click handler for the modal's "Done" button — despawns the overlay
|
|
||||||
/// the same way the `S` accelerator does.
|
|
||||||
fn handle_stats_close_button(
|
|
||||||
mut commands: Commands,
|
|
||||||
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
|
|
||||||
screens: Query<Entity, With<StatsScreen>>,
|
|
||||||
) {
|
|
||||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for entity in &screens {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_stats_screen(
|
|
||||||
commands: &mut Commands,
|
|
||||||
stats: &StatsSnapshot,
|
stats: &StatsSnapshot,
|
||||||
progress: Option<&PlayerProgress>,
|
progress: Option<&PlayerProgress>,
|
||||||
time_attack: Option<&TimeAttackResource>,
|
time_attack: Option<&TimeAttackResource>,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
replays: &[Replay],
|
|
||||||
selected_index: usize,
|
|
||||||
) {
|
) {
|
||||||
// --- primary stat cells ---
|
// --- primary stat cells ---
|
||||||
// First-launch zero-state: when no games have been played yet, render
|
// First-launch zero-state: when no games have been played yet, render
|
||||||
@@ -756,9 +699,7 @@ fn spawn_stats_screen(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
{
|
||||||
spawn_modal_header(card, "Statistics", font_res);
|
|
||||||
|
|
||||||
// Scrollable body — the Stats panel renders an 8-cell grid plus
|
// Scrollable body — the Stats panel renders an 8-cell grid plus
|
||||||
// multiple sections (per-mode bests, progression, weekly goals,
|
// multiple sections (per-mode bests, progression, weekly goals,
|
||||||
// unlocks, optional Time Attack, latest replay caption) and
|
// unlocks, optional Time Attack, latest replay caption) and
|
||||||
@@ -935,7 +876,45 @@ fn spawn_stats_screen(
|
|||||||
TextColor(STATE_WARNING),
|
TextColor(STATE_WARNING),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the Replays tab body inside the You hub's card: the
|
||||||
|
/// Prev/Next selector, detail line, and the Watch / Copy-share-link
|
||||||
|
/// actions (buttons live in the body now — the hub owns the single
|
||||||
|
/// Done in the action row). All markers (`ReplayPrevButton`,
|
||||||
|
/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`,
|
||||||
|
/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the
|
||||||
|
/// selector/watch/copy systems keep working.
|
||||||
|
pub(crate) fn spawn_replays_body(
|
||||||
|
card: &mut ChildSpawnerCommands,
|
||||||
|
replays: &[Replay],
|
||||||
|
selected_index: usize,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_row = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
// Scrollable body — mirrors the other hub tabs so short
|
||||||
|
// windows can still reach every control.
|
||||||
|
card.spawn((
|
||||||
|
StatsScrollable,
|
||||||
|
ScrollPosition::default(),
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_3,
|
||||||
|
max_height: Val::Vh(70.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|body| {
|
||||||
// --- Replay selector ---
|
// --- Replay selector ---
|
||||||
// Prev / Next chips step through the full replay history;
|
// Prev / Next chips step through the full replay history;
|
||||||
// `repaint_replay_selector_caption` and
|
// `repaint_replay_selector_caption` and
|
||||||
@@ -1019,12 +998,21 @@ fn spawn_stats_screen(
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
spawn_modal_actions(card, |actions| {
|
// Tab-local actions. The Watch Replay button is always
|
||||||
// The Watch Replay button is always rendered so the
|
// rendered so the affordance is discoverable from a fresh
|
||||||
// affordance is discoverable from a fresh install. When no
|
// install — with no replay, its click handler surfaces a
|
||||||
// replay exists, the click handler surfaces a clear
|
// "No replay recorded yet" toast rather than silently doing
|
||||||
// "No replay recorded yet" toast rather than silently
|
// nothing. Same policy for Copy share link (toast explains
|
||||||
// doing nothing.
|
// when no shareable upload exists).
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
margin: UiRect::top(VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
WatchReplayButton,
|
WatchReplayButton,
|
||||||
@@ -1033,11 +1021,6 @@ fn spawn_stats_screen(
|
|||||||
ButtonVariant::Secondary,
|
ButtonVariant::Secondary,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
// Copy share link only renders when a sharable URL is in
|
|
||||||
// hand. The button is intentionally absent (rather than
|
|
||||||
// disabled) when no upload has happened yet — keeps the
|
|
||||||
// action bar free of dead controls in the local-only and
|
|
||||||
// first-launch cases.
|
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
CopyShareLinkButton,
|
CopyShareLinkButton,
|
||||||
@@ -1046,18 +1029,8 @@ fn spawn_stats_screen(
|
|||||||
ButtonVariant::Secondary,
|
ButtonVariant::Secondary,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
spawn_modal_button(
|
|
||||||
actions,
|
|
||||||
StatsCloseButton,
|
|
||||||
"Done",
|
|
||||||
Some("S"),
|
|
||||||
ButtonVariant::Primary,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
// Stats is read-only — opt into click-outside-to-dismiss.
|
|
||||||
commands.entity(scrim).insert(ScrimDismissible);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn one row of the "Per-mode bests" section: the mode label on the
|
/// Spawn one row of the "Per-mode bests" section: the mode label on the
|
||||||
@@ -1290,6 +1263,7 @@ mod tests {
|
|||||||
// ProgressResource is an optional dependency for the stats screen;
|
// ProgressResource is an optional dependency for the stats screen;
|
||||||
// include it so toggle tests exercise the progression panel.
|
// include it so toggle tests exercise the progression panel.
|
||||||
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
|
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
|
||||||
|
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -1832,6 +1806,25 @@ mod tests {
|
|||||||
// Prev/Next replay selector spawn-site tests
|
// Prev/Next replay selector spawn-site tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Opens the You hub via `S`, then switches to the Replays tab
|
||||||
|
/// (where the selector lives since Phase E) and settles the rebuild.
|
||||||
|
fn open_replays_tab(app: &mut App) {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyS);
|
||||||
|
app.update();
|
||||||
|
// MinimalPlugins never clears input, so the sticky `just_pressed`
|
||||||
|
// would keep re-requesting the Stats tab every frame.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.clear();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<crate::you_hub_plugin::ActiveYouTab>()
|
||||||
|
.0 = crate::you_hub_plugin::YouTab::Replays;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selector_row_spawns_when_stats_screen_opens() {
|
fn selector_row_spawns_when_stats_screen_opens() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
@@ -1840,10 +1833,7 @@ mod tests {
|
|||||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||||
hist.0.replays.push(make_test_replay(90, None));
|
hist.0.replays.push(make_test_replay(90, None));
|
||||||
}
|
}
|
||||||
app.world_mut()
|
open_replays_tab(&mut app);
|
||||||
.resource_mut::<ButtonInput<KeyCode>>()
|
|
||||||
.press(KeyCode::KeyS);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let prev = app
|
let prev = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
@@ -1878,10 +1868,7 @@ mod tests {
|
|||||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||||
hist.0.replays.push(make_test_replay(120, None));
|
hist.0.replays.push(make_test_replay(120, None));
|
||||||
}
|
}
|
||||||
app.world_mut()
|
open_replays_tab(&mut app);
|
||||||
.resource_mut::<ButtonInput<KeyCode>>()
|
|
||||||
.press(KeyCode::KeyS);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let mut q = app
|
let mut q = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
@@ -1901,10 +1888,7 @@ mod tests {
|
|||||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||||
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
|
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
|
||||||
}
|
}
|
||||||
app.world_mut()
|
open_replays_tab(&mut app);
|
||||||
.resource_mut::<ButtonInput<KeyCode>>()
|
|
||||||
.press(KeyCode::KeyS);
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
let mut q = app
|
let mut q = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ impl Plugin for ThemeStorePlugin {
|
|||||||
.init_resource::<CatalogTask>()
|
.init_resource::<CatalogTask>()
|
||||||
.init_resource::<InstallTask>()
|
.init_resource::<InstallTask>()
|
||||||
.init_resource::<StoreBaseUrl>()
|
.init_resource::<StoreBaseUrl>()
|
||||||
|
// Esc-close reads keyboard input; register defensively so
|
||||||
|
// the plugin works under MinimalPlugins in tests.
|
||||||
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
.add_message::<ThemeStoreOpenRequestEvent>()
|
.add_message::<ThemeStoreOpenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
@@ -344,19 +347,23 @@ fn poll_install_task(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Despawns the store modal when Close is pressed.
|
/// Despawns the store modal when Close is pressed or on Esc (Phase C
|
||||||
|
/// dismissal audit). The store only ever stacks over Settings and
|
||||||
|
/// nothing stacks over the store, so it owns Esc whenever it is open
|
||||||
|
/// (Settings' own Esc handler is gated on being topmost).
|
||||||
fn handle_close_button(
|
fn handle_close_button(
|
||||||
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
|
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
for interaction in &interactions {
|
let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
|
||||||
if *interaction != Interaction::Pressed {
|
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||||
continue;
|
if !clicked && !esc {
|
||||||
}
|
return;
|
||||||
for entity in &screens {
|
}
|
||||||
commands.entity(entity).despawn();
|
for entity in &screens {
|
||||||
}
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
||||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
|
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
|
||||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
|
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
scaled_duration,
|
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -402,6 +402,46 @@ pub fn spawn_modal_button<M: Component>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One tab chip for a tabbed modal (Settings, the You hub). The active
|
||||||
|
/// chip is filled + bright with a success-green border; inactive chips
|
||||||
|
/// are quiet outlines. `marker` is the plugin's click-target component
|
||||||
|
/// carrying which tab the chip selects.
|
||||||
|
pub fn spawn_tab_chip<M: Component>(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
active: bool,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
|
||||||
|
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||||
|
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(label),
|
||||||
|
font,
|
||||||
|
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Generic touch-scroll helper
|
// Generic touch-scroll helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
||||||
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
||||||
//!
|
//!
|
||||||
|
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
|
||||||
|
//! The modal leads with actions: **Play Again** (primary, Enter
|
||||||
|
//! accelerator), then **Watch Replay** and **Share Replay** (shared
|
||||||
|
//! `stats_plugin` markers, so the global handlers there act on the
|
||||||
|
//! just-won replay), with the score/time/XP recap reading quietly
|
||||||
|
//! below.
|
||||||
|
//!
|
||||||
//! # Task #47 — Win fanfare screen-shake
|
//! # Task #47 — Win fanfare screen-shake
|
||||||
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
||||||
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
||||||
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||||
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct WinSummaryOverlay;
|
pub struct WinSummaryOverlay;
|
||||||
|
|
||||||
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
|
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||||
|
///
|
||||||
|
/// Watch Replay and Share Replay carry the shared
|
||||||
|
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
|
||||||
|
/// `stats_plugin`, so the global handlers there drive them (both act
|
||||||
|
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
|
||||||
|
/// just-won replay on every `GameWonEvent`).
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
enum WinSummaryButton {
|
struct WinSummaryPlayAgainButton;
|
||||||
PlayAgain,
|
|
||||||
WatchReplay,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker for one row of the win-modal score-breakdown reveal.
|
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||||
///
|
///
|
||||||
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
collect_session_achievements,
|
collect_session_achievements,
|
||||||
spawn_win_summary_after_delay,
|
spawn_win_summary_after_delay,
|
||||||
handle_win_summary_buttons,
|
handle_win_summary_buttons,
|
||||||
|
close_overlay_on_watch_replay,
|
||||||
handle_win_summary_keyboard,
|
handle_win_summary_keyboard,
|
||||||
apply_screen_shake,
|
apply_screen_shake,
|
||||||
reveal_score_breakdown,
|
reveal_score_breakdown,
|
||||||
@@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
/// Handles "Play Again" in the win-summary modal: collapses the
|
||||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
/// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
|
||||||
|
/// reuses the current game's `GameMode`, and `handle_new_game` reads
|
||||||
|
/// the deal options (draw mode, difficulty) from `Settings` — so the
|
||||||
|
/// rematch is "same mode + same options" in one tap.
|
||||||
fn handle_win_summary_buttons(
|
fn handle_win_summary_buttons(
|
||||||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
|
||||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
|
||||||
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
|
||||||
) {
|
) {
|
||||||
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
|
if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
let pressed: Vec<&WinSummaryButton> = interaction_query
|
return;
|
||||||
.iter()
|
}
|
||||||
.filter(|(i, _)| **i == Interaction::Pressed)
|
for entity in &overlays {
|
||||||
.map(|(_, b)| b)
|
commands.entity(entity).despawn();
|
||||||
.collect();
|
}
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
|
||||||
for button in pressed {
|
/// Collapses the win-summary overlay when its "Watch Replay" button is
|
||||||
match button {
|
/// pressed, so playback (started by `stats_plugin`'s global
|
||||||
WinSummaryButton::PlayAgain => {
|
/// [`WatchReplayButton`] handler reacting to the same press) is not
|
||||||
for entity in &overlays {
|
/// hidden behind the celebration scrim. No-op while the overlay is
|
||||||
commands.entity(entity).despawn();
|
/// closed — the Replays-tab copy of the button manages its own modal.
|
||||||
}
|
///
|
||||||
new_game.write(NewGameRequestEvent::default());
|
/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
|
||||||
}
|
/// close the overlay: the player stays on the celebration while the
|
||||||
WinSummaryButton::WatchReplay => {
|
/// copy-feedback toast confirms the link.
|
||||||
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
|
fn close_overlay_on_watch_replay(
|
||||||
match (latest, playback.as_mut()) {
|
buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
|
||||||
(Some(replay), Some(pb)) => {
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
for entity in &overlays {
|
mut commands: Commands,
|
||||||
commands.entity(entity).despawn();
|
) {
|
||||||
}
|
if overlays.is_empty() {
|
||||||
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
|
return;
|
||||||
}
|
}
|
||||||
(Some(_), None) => {
|
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
toast.write(InfoToastEvent("Replay playback not available".to_string()));
|
return;
|
||||||
}
|
}
|
||||||
(None, _) => {
|
for entity in &overlays {
|
||||||
toast.write(InfoToastEvent("No replay saved yet".to_string()));
|
commands.entity(entity).despawn();
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,18 +827,68 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score breakdown reveal — replaces the previous single
|
// --- Action hierarchy (Phase G) ---
|
||||||
// "Score:" line with a per-component multi-row layout.
|
// Play Again is the hero action; the two replay
|
||||||
|
// actions sit under it; the stats recap reads quietly
|
||||||
|
// below all three. Rematch is one tap.
|
||||||
|
|
||||||
|
// Play Again (primary, full row)
|
||||||
|
card.spawn((
|
||||||
|
WinSummaryPlayAgainButton,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_self: AlignSelf::Stretch,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
margin: UiRect::top(VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Play Again \u{21B5}"),
|
||||||
|
TextFont {
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(BG_BASE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch Replay + Share Replay (secondary, side by side).
|
||||||
|
// Both reuse the global stats_plugin handlers, which
|
||||||
|
// target the just-won replay (`SelectedReplayIndex`
|
||||||
|
// snaps to 0 on every win). Each is always rendered —
|
||||||
|
// with no replay / no share URL the handler explains
|
||||||
|
// itself in a toast instead of silently doing nothing.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
|
||||||
|
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Quiet stats recap, below the actions ---
|
||||||
|
|
||||||
|
// Score breakdown reveal — per-component multi-row
|
||||||
|
// layout with the staggered fade-in.
|
||||||
spawn_score_breakdown(card, &breakdown, anim_speed);
|
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||||
|
|
||||||
// Time
|
// Time (demoted to body/secondary — part of the quiet
|
||||||
|
// recap, not the celebration headline)
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: TYPE_HEADLINE,
|
font_size: TYPE_BODY_LG,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
// XP total
|
// XP total
|
||||||
@@ -859,68 +918,40 @@ fn spawn_overlay(
|
|||||||
if !session.names.is_empty() {
|
if !session.names.is_empty() {
|
||||||
spawn_achievements_section(card, &session.names);
|
spawn_achievements_section(card, &session.names);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button row: Watch Replay + Play Again side by side.
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
column_gap: VAL_SPACE_3,
|
|
||||||
margin: UiRect::top(VAL_SPACE_2),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
// Watch Replay (secondary style)
|
|
||||||
row.spawn((
|
|
||||||
WinSummaryButton::WatchReplay,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::NONE),
|
|
||||||
BorderColor::all(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Watch Replay"),
|
|
||||||
TextFont {
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Play Again (primary style)
|
|
||||||
row.spawn((
|
|
||||||
WinSummaryButton::PlayAgain,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Play Again \u{21B5}"),
|
|
||||||
TextFont {
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(BG_BASE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns one secondary (outline-style) replay action button in the
|
||||||
|
/// win modal's replay row. `marker` is the shared `stats_plugin`
|
||||||
|
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
|
||||||
|
/// whose global handler reacts to the press.
|
||||||
|
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
BorderColor::all(ACCENT_PRIMARY),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
TextFont {
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum number of achievement names shown explicitly in the win modal before
|
/// Maximum number of achievement names shown explicitly in the win modal before
|
||||||
/// the overflow "...and N more" line is shown instead.
|
/// the overflow "...and N more" line is shown instead.
|
||||||
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
||||||
@@ -1863,4 +1894,134 @@ mod tests {
|
|||||||
assert_eq!(stagger, 0.0);
|
assert_eq!(stagger, 0.0);
|
||||||
assert_eq!(fade, 0.0);
|
assert_eq!(fade, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase G — action hierarchy
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
|
||||||
|
/// `Time` resource, so tests can step the win-summary delay timer
|
||||||
|
/// deterministically via `Time::advance_by` (the real clock's
|
||||||
|
/// microsecond deltas would never reach the 0.5 s threshold).
|
||||||
|
fn make_app_manual_clock() -> App {
|
||||||
|
use bevy::time::TimePlugin;
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
|
||||||
|
.add_plugins(WinSummaryPlugin)
|
||||||
|
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||||
|
.insert_resource(GameStateResource(GameState::new(
|
||||||
|
0,
|
||||||
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
|
)))
|
||||||
|
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||||
|
app.init_resource::<Time>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the real spawn path: fire `GameWonEvent`, then advance
|
||||||
|
/// `Time` past the 0.5 s celebration delay so
|
||||||
|
/// `spawn_win_summary_after_delay` spawns the overlay.
|
||||||
|
fn open_win_overlay(app: &mut App) {
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1200,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
{
|
||||||
|
let mut time = app.world_mut().resource_mut::<Time>();
|
||||||
|
time.advance_by(std::time::Duration::from_secs_f32(
|
||||||
|
WIN_SUMMARY_DELAY_SECS + 0.1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
// One more frame so the deferred spawn commands flush.
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&WinSummaryOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button_entity<M: Component>(app: &mut App) -> Entity {
|
||||||
|
let entities: Vec<Entity> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<M>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(entities.len(), 1, "expected exactly one button");
|
||||||
|
entities[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The win modal must render all three Phase G actions: the
|
||||||
|
/// primary Play Again plus the shared Watch/Share replay buttons.
|
||||||
|
#[test]
|
||||||
|
fn win_modal_renders_play_again_watch_and_share_actions() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
|
||||||
|
button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||||
|
button_entity::<WatchReplayButton>(&mut app);
|
||||||
|
button_entity::<CopyShareLinkButton>(&mut app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play Again collapses the overlay and requests a fresh deal.
|
||||||
|
#[test]
|
||||||
|
fn play_again_press_closes_overlay_and_requests_new_game() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
|
||||||
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
assert!(
|
||||||
|
!events.is_empty(),
|
||||||
|
"Play Again must write NewGameRequestEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch Replay collapses the overlay so playback (driven by the
|
||||||
|
/// global `stats_plugin` handler on the same press) is visible.
|
||||||
|
#[test]
|
||||||
|
fn watch_replay_press_closes_the_overlay() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<WatchReplayButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
0,
|
||||||
|
"Watch Replay must close the celebration overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share Replay leaves the overlay open — the player stays on the
|
||||||
|
/// celebration while the copy-feedback toast confirms the link.
|
||||||
|
#[test]
|
||||||
|
fn share_replay_press_keeps_the_overlay_open() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<CopyShareLinkButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
1,
|
||||||
|
"Share Replay must not close the overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
//! The "You" hub — Profile · Stats · Achievements · Replays folded
|
||||||
|
//! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`).
|
||||||
|
//!
|
||||||
|
//! The four screens were previously standalone modals reached one at a
|
||||||
|
//! time through the HUD popover. The hub owns the modal shell (header,
|
||||||
|
//! tab chips via the shared [`spawn_tab_chip`] widget, a single Done
|
||||||
|
//! button); each tab's content is a body builder that lives in its
|
||||||
|
//! original plugin (`spawn_profile_body`, `spawn_stats_body`,
|
||||||
|
//! `spawn_achievements_body`, `spawn_replays_body`) so every marker
|
||||||
|
//! component and per-row update system keeps working unchanged.
|
||||||
|
//!
|
||||||
|
//! Legacy screen markers (`ProfileScreen`, `StatsScreen`,
|
||||||
|
//! `AchievementsScreen`) are inserted on the hub scrim while their tab
|
||||||
|
//! is active, so existing queries and tests keep their meaning.
|
||||||
|
//!
|
||||||
|
//! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent`
|
||||||
|
//! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A
|
||||||
|
//! accelerators — each opens the hub pre-selected to its tab, toggles
|
||||||
|
//! the hub closed when its tab is already showing, or switches tabs
|
||||||
|
//! when a different tab is showing. Esc, Done, and scrim-click close.
|
||||||
|
|
||||||
|
use bevy::ecs::system::SystemParam;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use solitaire_data::StatsSnapshot;
|
||||||
|
|
||||||
|
use crate::achievement_plugin::{AchievementsResource, AchievementsScreen};
|
||||||
|
use crate::events::{
|
||||||
|
ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::profile_plugin::{AvatarResource, ProfileScreen};
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::resources::SyncStatusResource;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen};
|
||||||
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
|
use crate::ui_focus::FocusRow;
|
||||||
|
use crate::ui_modal::{
|
||||||
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||||
|
spawn_modal_button, spawn_modal_header, spawn_tab_chip,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
|
||||||
|
|
||||||
|
/// Which tab of the You hub is showing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum YouTab {
|
||||||
|
#[default]
|
||||||
|
Profile,
|
||||||
|
Stats,
|
||||||
|
Achievements,
|
||||||
|
Replays,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YouTab {
|
||||||
|
/// Every tab, in display order.
|
||||||
|
pub const ALL: [Self; 4] = [
|
||||||
|
Self::Profile,
|
||||||
|
Self::Stats,
|
||||||
|
Self::Achievements,
|
||||||
|
Self::Replays,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Chip label.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Profile => "Profile",
|
||||||
|
Self::Stats => "Stats",
|
||||||
|
Self::Achievements => "Awards",
|
||||||
|
Self::Replays => "Replays",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active hub tab. Session-only; reopening returns to the last tab
|
||||||
|
/// unless an open request names a different one.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct ActiveYouTab(pub YouTab);
|
||||||
|
|
||||||
|
/// Marker on the hub modal's scrim root.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct YouHubScreen;
|
||||||
|
|
||||||
|
/// Per-chip tab selector button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct YouHubTabButton(YouTab);
|
||||||
|
|
||||||
|
/// Marker on the hub's Done button.
|
||||||
|
#[derive(Component)]
|
||||||
|
struct YouHubCloseButton;
|
||||||
|
|
||||||
|
/// Read-only bundle of everything the tab bodies render from. Split
|
||||||
|
/// out as a [`SystemParam`] because the open + rebuild systems both
|
||||||
|
/// need the full set and Bevy caps systems at 16 parameters.
|
||||||
|
#[derive(SystemParam)]
|
||||||
|
struct YouHubContext<'w> {
|
||||||
|
settings: Option<Res<'w, SettingsResource>>,
|
||||||
|
sync_status: Option<Res<'w, SyncStatusResource>>,
|
||||||
|
progress: Option<Res<'w, ProgressResource>>,
|
||||||
|
achievements: Option<Res<'w, AchievementsResource>>,
|
||||||
|
stats: Option<Res<'w, StatsResource>>,
|
||||||
|
avatar: Option<Res<'w, AvatarResource>>,
|
||||||
|
time_attack: Option<Res<'w, TimeAttackResource>>,
|
||||||
|
replay_history: Option<Res<'w, ReplayHistoryResource>>,
|
||||||
|
selected_replay: Option<Res<'w, SelectedReplayIndex>>,
|
||||||
|
font_res: Option<Res<'w, FontResource>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bevy plugin owning the You hub lifecycle. Requires the profile,
|
||||||
|
/// stats, and achievement plugins for live data; degrades to empty tab
|
||||||
|
/// bodies without them (headless tests).
|
||||||
|
pub struct YouHubPlugin;
|
||||||
|
|
||||||
|
impl Plugin for YouHubPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<ActiveYouTab>()
|
||||||
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
|
.add_message::<ToggleStatsRequestEvent>()
|
||||||
|
.add_message::<ToggleAchievementsRequestEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
// Chained: a toggle/chip press must be observed before
|
||||||
|
// the rebuild, and the rebuild before close — total
|
||||||
|
// order prevents double-spawns within one frame.
|
||||||
|
(
|
||||||
|
open_or_toggle_you_hub,
|
||||||
|
handle_tab_buttons,
|
||||||
|
rebuild_on_tab_change,
|
||||||
|
handle_close_button,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps this frame's toggle events + accelerator keys to a requested
|
||||||
|
/// tab, mirroring the semantics the three standalone screens had.
|
||||||
|
fn requested_tab(
|
||||||
|
profile_events: &mut MessageReader<ToggleProfileRequestEvent>,
|
||||||
|
stats_events: &mut MessageReader<ToggleStatsRequestEvent>,
|
||||||
|
achievements_events: &mut MessageReader<ToggleAchievementsRequestEvent>,
|
||||||
|
keys: &ButtonInput<KeyCode>,
|
||||||
|
) -> Option<YouTab> {
|
||||||
|
let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP);
|
||||||
|
let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS);
|
||||||
|
let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA);
|
||||||
|
if profile {
|
||||||
|
Some(YouTab::Profile)
|
||||||
|
} else if stats {
|
||||||
|
Some(YouTab::Stats)
|
||||||
|
} else if achievements {
|
||||||
|
Some(YouTab::Achievements)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the hub on the requested tab, switches tabs when it's already
|
||||||
|
/// open on a different one, toggles it closed on a same-tab request or
|
||||||
|
/// Esc.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn open_or_toggle_you_hub(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut profile_events: MessageReader<ToggleProfileRequestEvent>,
|
||||||
|
mut stats_events: MessageReader<ToggleStatsRequestEvent>,
|
||||||
|
mut achievements_events: MessageReader<ToggleAchievementsRequestEvent>,
|
||||||
|
screens: Query<Entity, With<YouHubScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<YouHubScreen>)>,
|
||||||
|
mut active: ResMut<ActiveYouTab>,
|
||||||
|
ctx: YouHubContext,
|
||||||
|
) {
|
||||||
|
let requested = requested_tab(
|
||||||
|
&mut profile_events,
|
||||||
|
&mut stats_events,
|
||||||
|
&mut achievements_events,
|
||||||
|
&keys,
|
||||||
|
);
|
||||||
|
let open = !screens.is_empty();
|
||||||
|
|
||||||
|
if open {
|
||||||
|
// Esc closes the topmost modal — the hub, when it's showing.
|
||||||
|
if keys.just_pressed(KeyCode::Escape) {
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match requested {
|
||||||
|
Some(tab) if tab == active.0 => {
|
||||||
|
// Same-tab request toggles closed (P opens Profile,
|
||||||
|
// P again closes — parity with the old screens).
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(tab) => {
|
||||||
|
// Different tab: switch in place. The rebuild system
|
||||||
|
// (next in the chain) observes the change.
|
||||||
|
active.0 = tab;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(tab) = requested else { return };
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return; // Another modal is already visible (§14.2).
|
||||||
|
}
|
||||||
|
if active.0 != tab {
|
||||||
|
// Written before the spawn; the rebuild system also runs this
|
||||||
|
// frame but sees no live hub (the spawn below is deferred), so
|
||||||
|
// no double-spawn.
|
||||||
|
active.0 = tab;
|
||||||
|
}
|
||||||
|
spawn_you_hub(&mut commands, tab, &ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the active tab when a chip is pressed.
|
||||||
|
fn handle_tab_buttons(
|
||||||
|
interactions: Query<(&Interaction, &YouHubTabButton), Changed<Interaction>>,
|
||||||
|
mut active: ResMut<ActiveYouTab>,
|
||||||
|
) {
|
||||||
|
for (interaction, chip) in &interactions {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if active.0 != chip.0 {
|
||||||
|
active.0 = chip.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the open hub when [`ActiveYouTab`] changes.
|
||||||
|
fn rebuild_on_tab_change(
|
||||||
|
active: Res<ActiveYouTab>,
|
||||||
|
screens: Query<Entity, With<YouHubScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
ctx: YouHubContext,
|
||||||
|
) {
|
||||||
|
if !active.is_changed() || active.is_added() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
spawn_you_hub(&mut commands, active.0, &ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the hub when Done is pressed.
|
||||||
|
fn handle_close_button(
|
||||||
|
interactions: Query<&Interaction, (Changed<Interaction>, With<YouHubCloseButton>)>,
|
||||||
|
screens: Query<Entity, With<YouHubScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
for interaction in &interactions {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the hub modal showing `tab`, and stamps the scrim with the
|
||||||
|
/// tab's legacy screen marker so pre-hub queries keep working.
|
||||||
|
fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) {
|
||||||
|
let font_res = ctx.font_res.as_deref();
|
||||||
|
let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, "You", font_res);
|
||||||
|
|
||||||
|
// Tab chips — shared widget with the Settings panel.
|
||||||
|
card.spawn((
|
||||||
|
FocusRow,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
margin: UiRect::bottom(VAL_SPACE_3),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
for chip_tab in YouTab::ALL {
|
||||||
|
spawn_tab_chip(
|
||||||
|
row,
|
||||||
|
YouHubTabButton(chip_tab),
|
||||||
|
chip_tab.label(),
|
||||||
|
chip_tab == tab,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match tab {
|
||||||
|
YouTab::Profile => crate::profile_plugin::spawn_profile_body(
|
||||||
|
card,
|
||||||
|
ctx.settings.as_deref(),
|
||||||
|
ctx.sync_status.as_deref(),
|
||||||
|
ctx.progress.as_deref(),
|
||||||
|
ctx.achievements.as_deref(),
|
||||||
|
ctx.stats.as_deref(),
|
||||||
|
font_res,
|
||||||
|
ctx.avatar.as_deref(),
|
||||||
|
),
|
||||||
|
YouTab::Stats => {
|
||||||
|
let default_stats = StatsSnapshot::default();
|
||||||
|
crate::stats_plugin::spawn_stats_body(
|
||||||
|
card,
|
||||||
|
ctx.stats.as_deref().map_or(&default_stats, |s| &s.0),
|
||||||
|
ctx.progress.as_deref().map(|p| &p.0),
|
||||||
|
ctx.time_attack.as_deref(),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body(
|
||||||
|
card,
|
||||||
|
ctx.achievements
|
||||||
|
.as_deref()
|
||||||
|
.map_or(&[][..], |a| a.0.as_slice()),
|
||||||
|
font_res,
|
||||||
|
),
|
||||||
|
YouTab::Replays => crate::stats_plugin::spawn_replays_body(
|
||||||
|
card,
|
||||||
|
ctx.replay_history
|
||||||
|
.as_deref()
|
||||||
|
.map_or(&[][..], |h| h.0.replays.as_slice()),
|
||||||
|
ctx.selected_replay.as_deref().map_or(0, |s| s.0),
|
||||||
|
font_res,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
YouHubCloseButton,
|
||||||
|
"Done",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let mut scrim_commands = commands.entity(scrim);
|
||||||
|
scrim_commands.insert(ScrimDismissible);
|
||||||
|
// Legacy markers: pre-hub code and tests query these.
|
||||||
|
match tab {
|
||||||
|
YouTab::Profile => {
|
||||||
|
scrim_commands.insert(ProfileScreen);
|
||||||
|
}
|
||||||
|
YouTab::Stats | YouTab::Replays => {
|
||||||
|
scrim_commands.insert(StatsScreen);
|
||||||
|
}
|
||||||
|
YouTab::Achievements => {
|
||||||
|
scrim_commands.insert(AchievementsScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hub_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&YouHubScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each Toggle*RequestEvent opens the hub pre-selected to its tab,
|
||||||
|
/// stamped with the tab's legacy screen marker.
|
||||||
|
#[test]
|
||||||
|
fn toggle_events_open_hub_on_their_tab() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ToggleStatsRequestEvent>>()
|
||||||
|
.write(ToggleStatsRequestEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(hub_count(&mut app), 1, "stats request must open the hub");
|
||||||
|
assert_eq!(app.world().resource::<ActiveYouTab>().0, YouTab::Stats);
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&StatsScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"legacy StatsScreen marker must ride the hub scrim"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A different tab's request switches in place — still one hub.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
|
||||||
|
.write(ToggleAchievementsRequestEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs");
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<ActiveYouTab>().0,
|
||||||
|
YouTab::Achievements
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&AchievementsScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same-tab request toggles the hub closed.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
|
||||||
|
.write(ToggleAchievementsRequestEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(hub_count(&mut app), 0, "same-tab request must close");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chip-driven tab switches rebuild the single hub with the new
|
||||||
|
/// tab's body (Profile scrollable swaps for the Stats one).
|
||||||
|
#[test]
|
||||||
|
fn tab_switch_swaps_bodies_without_stacking() {
|
||||||
|
use crate::profile_plugin::ProfileScrollable;
|
||||||
|
use crate::stats_plugin::StatsScrollable;
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ToggleProfileRequestEvent>>()
|
||||||
|
.write(ToggleProfileRequestEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ProfileScrollable>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"profile body must spawn on the Profile tab"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().resource_mut::<ActiveYouTab>().0 = YouTab::Stats;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims");
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ProfileScrollable>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"profile body must despawn when leaving the tab"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&StatsScrollable>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"stats body must spawn on the Stats tab"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,68 @@
|
|||||||
|
# --- WASM build stage ---
|
||||||
|
# Builds solitaire_server/web/pkg/ (replay viewer + Bevy canvas app) from
|
||||||
|
# source. The artifacts are not committed to the repo (issue #156); this
|
||||||
|
# stage is their single source of truth for deployment.
|
||||||
|
FROM rust:1.95-slim AS wasm-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
|
# Pinned wasm toolchain — keep versions in sync with
|
||||||
|
# .gitea/workflows/web-e2e.yml and build_wasm.sh prerequisites.
|
||||||
|
RUN curl -sSL https://github.com/rustwasm/wasm-bindgen/releases/download/0.2.120/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl.tar.gz \
|
||||||
|
| tar xz -C /opt \
|
||||||
|
&& ln -s /opt/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl/wasm-bindgen /usr/local/bin/wasm-bindgen \
|
||||||
|
&& curl -sSL https://github.com/rustwasm/wasm-pack/releases/download/v0.14.0/wasm-pack-v0.14.0-x86_64-unknown-linux-musl.tar.gz \
|
||||||
|
| tar xz -C /opt \
|
||||||
|
&& ln -s /opt/wasm-pack-v0.14.0-x86_64-unknown-linux-musl/wasm-pack /usr/local/bin/wasm-pack \
|
||||||
|
&& curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||||
|
| tar xz -C /opt \
|
||||||
|
&& ln -s /opt/binaryen-version_130/bin/wasm-opt /usr/local/bin/wasm-opt
|
||||||
|
|
||||||
|
# Manifests first so the dependency-fetch layer caches across source changes
|
||||||
|
# (same pattern as the server build stage below).
|
||||||
|
COPY .cargo/config.toml ./.cargo/config.toml
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
|
||||||
|
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
|
||||||
|
COPY solitaire_data/Cargo.toml ./solitaire_data/Cargo.toml
|
||||||
|
COPY solitaire_engine/Cargo.toml ./solitaire_engine/Cargo.toml
|
||||||
|
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
|
||||||
|
COPY solitaire_app/Cargo.toml ./solitaire_app/Cargo.toml
|
||||||
|
COPY solitaire_assetgen/Cargo.toml ./solitaire_assetgen/Cargo.toml
|
||||||
|
COPY solitaire_wasm/Cargo.toml ./solitaire_wasm/Cargo.toml
|
||||||
|
COPY solitaire_web/Cargo.toml ./solitaire_web/Cargo.toml
|
||||||
|
|
||||||
|
RUN for crate in solitaire_core solitaire_sync solitaire_data solitaire_engine \
|
||||||
|
solitaire_server solitaire_app solitaire_assetgen solitaire_wasm solitaire_web; do \
|
||||||
|
mkdir -p $crate/src && echo "pub fn _stub() {}" > $crate/src/lib.rs; \
|
||||||
|
done && \
|
||||||
|
echo "fn main() {}" > solitaire_server/src/main.rs && \
|
||||||
|
echo "fn main() {}" > solitaire_app/src/main.rs && \
|
||||||
|
echo "fn main() {}" > solitaire_assetgen/src/main.rs
|
||||||
|
|
||||||
|
RUN cargo fetch --locked
|
||||||
|
|
||||||
|
# Real source for the wasm-feeding crates. Whole crate directories (not just
|
||||||
|
# src/) because solitaire_engine embeds theme/audio/font assets at compile
|
||||||
|
# time from its own assets/ and the workspace assets/.
|
||||||
|
COPY build_wasm.sh ./
|
||||||
|
COPY solitaire_core ./solitaire_core
|
||||||
|
COPY solitaire_sync ./solitaire_sync
|
||||||
|
COPY solitaire_data ./solitaire_data
|
||||||
|
COPY solitaire_engine ./solitaire_engine
|
||||||
|
COPY solitaire_wasm ./solitaire_wasm
|
||||||
|
COPY solitaire_web ./solitaire_web
|
||||||
|
COPY assets ./assets
|
||||||
|
|
||||||
|
RUN ./build_wasm.sh
|
||||||
|
|
||||||
# --- Build stage ---
|
# --- Build stage ---
|
||||||
FROM rust:1.95-slim AS builder
|
FROM rust:1.95-slim AS builder
|
||||||
|
|
||||||
@@ -67,6 +132,8 @@ COPY --from=builder /build/target/release/solitaire_server ./server
|
|||||||
# /app/assets → /assets route
|
# /app/assets → /assets route
|
||||||
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
|
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
|
||||||
COPY solitaire_server/web ./solitaire_server/web
|
COPY solitaire_server/web ./solitaire_server/web
|
||||||
|
# The wasm bundles are never in the repo — they come from the wasm-builder stage.
|
||||||
|
COPY --from=wasm-builder /build/solitaire_server/web/pkg ./solitaire_server/web/pkg
|
||||||
COPY assets ./assets
|
COPY assets ./assets
|
||||||
|
|
||||||
ENV SERVER_PORT=8080
|
ENV SERVER_PORT=8080
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,595 +0,0 @@
|
|||||||
/**
|
|
||||||
* Browser-side replay state machine. Owns a live `GameState` and the
|
|
||||||
* replay's move list; each `step()` applies the next move.
|
|
||||||
*/
|
|
||||||
export class ReplayPlayer {
|
|
||||||
__destroy_into_raw() {
|
|
||||||
const ptr = this.__wbg_ptr;
|
|
||||||
this.__wbg_ptr = 0;
|
|
||||||
ReplayPlayerFinalization.unregister(this);
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
free() {
|
|
||||||
const ptr = this.__destroy_into_raw();
|
|
||||||
wasm.__wbg_replayplayer_free(ptr, 0);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns `true` once every move has been applied.
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
is_finished() {
|
|
||||||
const ret = wasm.replayplayer_is_finished(this.__wbg_ptr);
|
|
||||||
return ret !== 0;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Construct from a raw replay JSON string.
|
|
||||||
* @param {string} replay_json
|
|
||||||
*/
|
|
||||||
constructor(replay_json) {
|
|
||||||
const ptr0 = passStringToWasm0(replay_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.replayplayer_new(ptr0, len0);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
this.__wbg_ptr = ret[0];
|
|
||||||
ReplayPlayerFinalization.register(this, this.__wbg_ptr, this);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
|
||||||
*
|
|
||||||
* Throws a JS string exception on serialisation failure (should never
|
|
||||||
* occur in practice — `StateSnapshot` contains only primitive types).
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
state() {
|
|
||||||
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Apply the next move; returns the post-step snapshot, or `null`
|
|
||||||
* once the move list is exhausted.
|
|
||||||
*
|
|
||||||
* Returns `null` (not an exception) when the replay is finished.
|
|
||||||
* Throws `"replay_desync"` when the next recorded move is illegal for
|
|
||||||
* the current state, and logs the underlying core error to the JS console.
|
|
||||||
* Throws a JS string exception on serialisation failure.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
step() {
|
|
||||||
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 0-indexed position of the next move to apply.
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
step_idx() {
|
|
||||||
const ret = wasm.replayplayer_step_idx(this.__wbg_ptr);
|
|
||||||
return ret >>> 0;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Total number of moves the replay contains.
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
total_steps() {
|
|
||||||
const ret = wasm.replayplayer_total_steps(this.__wbg_ptr);
|
|
||||||
return ret >>> 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
|
||||||
*
|
|
||||||
* Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
|
||||||
* `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
|
|
||||||
* full pile snapshot at any time without mutating state.
|
|
||||||
*/
|
|
||||||
export class SolitaireGame {
|
|
||||||
static __wrap(ptr) {
|
|
||||||
const obj = Object.create(SolitaireGame.prototype);
|
|
||||||
obj.__wbg_ptr = ptr;
|
|
||||||
SolitaireGameFinalization.register(obj, obj.__wbg_ptr, obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
__destroy_into_raw() {
|
|
||||||
const ptr = this.__wbg_ptr;
|
|
||||||
this.__wbg_ptr = 0;
|
|
||||||
SolitaireGameFinalization.unregister(this);
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
free() {
|
|
||||||
const ptr = this.__destroy_into_raw();
|
|
||||||
wasm.__wbg_solitairegame_free(ptr, 0);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Apply one auto-complete move (only valid when `is_auto_completable`).
|
|
||||||
*
|
|
||||||
* If no card can go directly to a foundation this step, advances the
|
|
||||||
* waste by calling `draw()` so the next step can try again. Returns the
|
|
||||||
* post-move snapshot, or `null` when no progress is possible.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
auto_complete_step() {
|
|
||||||
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Applies the legal move currently at `index` from `debug_legal_moves()`.
|
|
||||||
* @param {number} index
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_apply_legal_move(index) {
|
|
||||||
const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Applies one debug move encoded as JSON.
|
|
||||||
*
|
|
||||||
* JSON must match [`DebugMove`], for example:
|
|
||||||
* `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
|
|
||||||
* `{"kind":"stock_click"}`.
|
|
||||||
* @param {string} move_json
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_apply_move_json(move_json) {
|
|
||||||
const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns all currently-legal debug moves as a JS array.
|
|
||||||
*
|
|
||||||
* Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_legal_moves() {
|
|
||||||
const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns deterministic instruction history for the current game.
|
|
||||||
*
|
|
||||||
* Together with `seed()` and `draw_mode`, this history is replayable.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_move_history() {
|
|
||||||
const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns a comprehensive debug snapshot for automated verification.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
debug_snapshot() {
|
|
||||||
const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
|
||||||
* Returns `{ok, error?, snapshot?}`.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
draw() {
|
|
||||||
const ret = wasm.solitairegame_draw(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
|
|
||||||
*
|
|
||||||
* Returns an error string if the JSON is malformed or describes a state
|
|
||||||
* that can't be deserialised (e.g. from a future schema version).
|
|
||||||
* @param {string} json
|
|
||||||
* @returns {SolitaireGame}
|
|
||||||
*/
|
|
||||||
static from_saved(json) {
|
|
||||||
const ptr0 = passStringToWasm0(json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.solitairegame_from_saved(ptr0, len0);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return SolitaireGame.__wrap(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Move `count` cards from pile `from` to pile `to`.
|
|
||||||
*
|
|
||||||
* Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
|
|
||||||
* `"tableau-0"` .. `"tableau-6"`.
|
|
||||||
*
|
|
||||||
* Returns `{ok, error?, snapshot?}`.
|
|
||||||
* @param {string} from
|
|
||||||
* @param {string} to
|
|
||||||
* @param {number} count
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
move_cards(from, to, count) {
|
|
||||||
const ptr0 = passStringToWasm0(from, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len0 = WASM_VECTOR_LEN;
|
|
||||||
const ptr1 = passStringToWasm0(to, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len1 = WASM_VECTOR_LEN;
|
|
||||||
const ret = wasm.solitairegame_move_cards(this.__wbg_ptr, ptr0, len0, ptr1, len1, count);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Create a new DrawOne or DrawThree Classic game from the given seed.
|
|
||||||
*
|
|
||||||
* `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
|
|
||||||
* Pass `Date.now()` or a random integer from JS for variety.
|
|
||||||
* @param {number} seed
|
|
||||||
* @param {boolean} draw_three
|
|
||||||
*/
|
|
||||||
constructor(seed, draw_three) {
|
|
||||||
const ret = wasm.solitairegame_new(seed, draw_three);
|
|
||||||
this.__wbg_ptr = ret;
|
|
||||||
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Returns replay moves encoded in the `solitaire_data::Replay` wire format
|
|
||||||
* — a list of upstream [`KlondikeInstruction`]s.
|
|
||||||
*
|
|
||||||
* This is the deterministic instruction history; together with `seed()`
|
|
||||||
* and the draw mode it replays cleanly via `apply_instruction`.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
replay_moves() {
|
|
||||||
const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* The seed used to deal this game.
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
seed() {
|
|
||||||
const ret = wasm.solitairegame_seed(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Serialise the full game state as a JSON string for `localStorage`.
|
|
||||||
*
|
|
||||||
* Use [`SolitaireGame::from_saved`] to restore it. The returned string is
|
|
||||||
* opaque — callers should treat it as a blob and store/restore it verbatim.
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
serialize() {
|
|
||||||
let deferred2_0;
|
|
||||||
let deferred2_1;
|
|
||||||
try {
|
|
||||||
const ret = wasm.solitairegame_serialize(this.__wbg_ptr);
|
|
||||||
var ptr1 = ret[0];
|
|
||||||
var len1 = ret[1];
|
|
||||||
if (ret[3]) {
|
|
||||||
ptr1 = 0; len1 = 0;
|
|
||||||
throw takeFromExternrefTable0(ret[2]);
|
|
||||||
}
|
|
||||||
deferred2_0 = ptr1;
|
|
||||||
deferred2_1 = len1;
|
|
||||||
return getStringFromWasm0(ptr1, len1);
|
|
||||||
} finally {
|
|
||||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Full pile snapshot as a JS object.
|
|
||||||
*
|
|
||||||
* Throws a JS string exception on serialisation failure.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
state() {
|
|
||||||
const ret = wasm.solitairegame_state(this.__wbg_ptr);
|
|
||||||
if (ret[2]) {
|
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
undo() {
|
|
||||||
const ret = wasm.solitairegame_undo(this.__wbg_ptr);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Symbol.dispose) SolitaireGame.prototype[Symbol.dispose] = SolitaireGame.prototype.free;
|
|
||||||
function __wbg_get_imports() {
|
|
||||||
const import0 = {
|
|
||||||
__proto__: null,
|
|
||||||
__wbg_Error_3639a60ed15f87e7: function(arg0, arg1) {
|
|
||||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_String_8564e559799eccda: function(arg0, arg1) {
|
|
||||||
const ret = String(arg1);
|
|
||||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len1 = WASM_VECTOR_LEN;
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
|
||||||
},
|
|
||||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
|
||||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
|
||||||
},
|
|
||||||
__wbg_error_48655ee7e4756f8b: function(arg0) {
|
|
||||||
console.error(arg0);
|
|
||||||
},
|
|
||||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
|
||||||
let deferred0_0;
|
|
||||||
let deferred0_1;
|
|
||||||
try {
|
|
||||||
deferred0_0 = arg0;
|
|
||||||
deferred0_1 = arg1;
|
|
||||||
console.error(getStringFromWasm0(arg0, arg1));
|
|
||||||
} finally {
|
|
||||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
__wbg_new_227d7c05414eb861: function() {
|
|
||||||
const ret = new Error();
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_new_2fad8ca02fd00684: function() {
|
|
||||||
const ret = new Object();
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_new_3baa8d9866155c79: function() {
|
|
||||||
const ret = new Array();
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
|
|
||||||
arg0[arg1] = arg2;
|
|
||||||
},
|
|
||||||
__wbg_set_f614f6a0608d1d1d: function(arg0, arg1, arg2) {
|
|
||||||
arg0[arg1 >>> 0] = arg2;
|
|
||||||
},
|
|
||||||
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
|
||||||
const ret = arg1.stack;
|
|
||||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
||||||
const len1 = WASM_VECTOR_LEN;
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
|
||||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000001: function(arg0) {
|
|
||||||
// Cast intrinsic for `F64 -> Externref`.
|
|
||||||
const ret = arg0;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
|
||||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
|
||||||
const ret = getStringFromWasm0(arg0, arg1);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_cast_0000000000000003: function(arg0) {
|
|
||||||
// Cast intrinsic for `U64 -> Externref`.
|
|
||||||
const ret = BigInt.asUintN(64, arg0);
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
__wbindgen_init_externref_table: function() {
|
|
||||||
const table = wasm.__wbindgen_externrefs;
|
|
||||||
const offset = table.grow(4);
|
|
||||||
table.set(0, undefined);
|
|
||||||
table.set(offset + 0, undefined);
|
|
||||||
table.set(offset + 1, null);
|
|
||||||
table.set(offset + 2, true);
|
|
||||||
table.set(offset + 3, false);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
__proto__: null,
|
|
||||||
"./solitaire_wasm_bg.js": import0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
||||||
? { register: () => {}, unregister: () => {} }
|
|
||||||
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
|
|
||||||
const SolitaireGameFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
||||||
? { register: () => {}, unregister: () => {} }
|
|
||||||
: new FinalizationRegistry(ptr => wasm.__wbg_solitairegame_free(ptr, 1));
|
|
||||||
|
|
||||||
let cachedDataViewMemory0 = null;
|
|
||||||
function getDataViewMemory0() {
|
|
||||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
|
||||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
|
||||||
}
|
|
||||||
return cachedDataViewMemory0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStringFromWasm0(ptr, len) {
|
|
||||||
return decodeText(ptr >>> 0, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedUint8ArrayMemory0 = null;
|
|
||||||
function getUint8ArrayMemory0() {
|
|
||||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
|
||||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
|
||||||
}
|
|
||||||
return cachedUint8ArrayMemory0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function passStringToWasm0(arg, malloc, realloc) {
|
|
||||||
if (realloc === undefined) {
|
|
||||||
const buf = cachedTextEncoder.encode(arg);
|
|
||||||
const ptr = malloc(buf.length, 1) >>> 0;
|
|
||||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
|
||||||
WASM_VECTOR_LEN = buf.length;
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
let len = arg.length;
|
|
||||||
let ptr = malloc(len, 1) >>> 0;
|
|
||||||
|
|
||||||
const mem = getUint8ArrayMemory0();
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (; offset < len; offset++) {
|
|
||||||
const code = arg.charCodeAt(offset);
|
|
||||||
if (code > 0x7F) break;
|
|
||||||
mem[ptr + offset] = code;
|
|
||||||
}
|
|
||||||
if (offset !== len) {
|
|
||||||
if (offset !== 0) {
|
|
||||||
arg = arg.slice(offset);
|
|
||||||
}
|
|
||||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
|
||||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
|
||||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
|
||||||
|
|
||||||
offset += ret.written;
|
|
||||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
WASM_VECTOR_LEN = offset;
|
|
||||||
return ptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeFromExternrefTable0(idx) {
|
|
||||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
|
||||||
wasm.__externref_table_dealloc(idx);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
||||||
cachedTextDecoder.decode();
|
|
||||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
|
||||||
let numBytesDecoded = 0;
|
|
||||||
function decodeText(ptr, len) {
|
|
||||||
numBytesDecoded += len;
|
|
||||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
|
||||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
||||||
cachedTextDecoder.decode();
|
|
||||||
numBytesDecoded = len;
|
|
||||||
}
|
|
||||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedTextEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
if (!('encodeInto' in cachedTextEncoder)) {
|
|
||||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
|
||||||
const buf = cachedTextEncoder.encode(arg);
|
|
||||||
view.set(buf);
|
|
||||||
return {
|
|
||||||
read: arg.length,
|
|
||||||
written: buf.length
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let WASM_VECTOR_LEN = 0;
|
|
||||||
|
|
||||||
let wasmModule, wasmInstance, wasm;
|
|
||||||
function __wbg_finalize_init(instance, module) {
|
|
||||||
wasmInstance = instance;
|
|
||||||
wasm = instance.exports;
|
|
||||||
wasmModule = module;
|
|
||||||
cachedDataViewMemory0 = null;
|
|
||||||
cachedUint8ArrayMemory0 = null;
|
|
||||||
wasm.__wbindgen_start();
|
|
||||||
return wasm;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function __wbg_load(module, imports) {
|
|
||||||
if (typeof Response === 'function' && module instanceof Response) {
|
|
||||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
|
||||||
try {
|
|
||||||
return await WebAssembly.instantiateStreaming(module, imports);
|
|
||||||
} catch (e) {
|
|
||||||
const validResponse = module.ok && expectedResponseType(module.type);
|
|
||||||
|
|
||||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
|
||||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
|
||||||
|
|
||||||
} else { throw e; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = await module.arrayBuffer();
|
|
||||||
return await WebAssembly.instantiate(bytes, imports);
|
|
||||||
} else {
|
|
||||||
const instance = await WebAssembly.instantiate(module, imports);
|
|
||||||
|
|
||||||
if (instance instanceof WebAssembly.Instance) {
|
|
||||||
return { instance, module };
|
|
||||||
} else {
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectedResponseType(type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'basic': case 'cors': case 'default': return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSync(module) {
|
|
||||||
if (wasm !== undefined) return wasm;
|
|
||||||
|
|
||||||
|
|
||||||
if (module !== undefined) {
|
|
||||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
|
||||||
({module} = module)
|
|
||||||
} else {
|
|
||||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imports = __wbg_get_imports();
|
|
||||||
if (!(module instanceof WebAssembly.Module)) {
|
|
||||||
module = new WebAssembly.Module(module);
|
|
||||||
}
|
|
||||||
const instance = new WebAssembly.Instance(module, imports);
|
|
||||||
return __wbg_finalize_init(instance, module);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function __wbg_init(module_or_path) {
|
|
||||||
if (wasm !== undefined) return wasm;
|
|
||||||
|
|
||||||
|
|
||||||
if (module_or_path !== undefined) {
|
|
||||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
|
||||||
({module_or_path} = module_or_path)
|
|
||||||
} else {
|
|
||||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (module_or_path === undefined) {
|
|
||||||
module_or_path = new URL('solitaire_wasm_bg.wasm', import.meta.url);
|
|
||||||
}
|
|
||||||
const imports = __wbg_get_imports();
|
|
||||||
|
|
||||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
|
||||||
module_or_path = fetch(module_or_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
|
||||||
|
|
||||||
return __wbg_finalize_init(instance, module);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { initSync, __wbg_init as default };
|
|
||||||
Binary file not shown.
@@ -228,11 +228,6 @@ impl ReplayPlayer {
|
|||||||
pub fn step_idx(&self) -> usize {
|
pub fn step_idx(&self) -> usize {
|
||||||
self.step_idx
|
self.step_idx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` once every move has been applied.
|
|
||||||
pub fn is_finished(&self) -> bool {
|
|
||||||
self.step_idx >= self.moves.len()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user